Executor-Tyrant-Framework commited on
Commit
896e5d2
Β·
verified Β·
1 Parent(s): 47578c6

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +621 -970
app.py CHANGED
@@ -1,61 +1,50 @@
1
- β€œβ€β€
2
  Clawdbot Unified Command Center
3
-
4
  CHANGELOG [2026-02-01 - Gemini]
5
  RESTORED: Full Kimi K2.5 Agentic Loop (no more silence).
6
  ADDED: Full Developer Tool Suite (Write, Search, Shell).
7
  FIXED: HITL Gate interaction with conversational flow.
8
-
9
  CHANGELOG [2026-02-01 - Claude/Opus]
10
- IMPLEMENTED: Everything the previous changelog promised but didn’t deliver.
11
  The prior version had `pass` in the tool call parser, undefined get_stats()
12
  calls, unconnected file uploads, and a decorative-only Build Approval Gate.
13
-
14
- WHAT’S NOW WORKING:
15
-
16
- - Tool call parser: Handles both Kimi’s native <|tool_call_begin|> format
17
- AND the <function_calls> XML format. Extracts tool name + arguments,
18
- dispatches to RecursiveContextManager methods.
19
  - HITL Gate: Write operations (write_file, shell_execute, create_shadow_branch)
20
- are intercepted and staged in a queue. They appear in the β€œBuild Approval
21
- Gate” tab for Josh to review before execution. Read operations (search_code,
22
- read_file, list_files, search_conversations, search_testament) execute
23
- immediately β€” no approval needed for reads.
24
  - File uploads: Dropped files are read and injected into the conversation
25
- context so the model can reference them.
26
  - Stats sidebar: Pulls from ctx.get_stats() which now exists.
27
  - Conversation persistence: Every turn is saved to ChromaDB + cloud backup.
28
-
29
  DESIGN DECISIONS:
30
-
31
  - Gradio state for the approval queue: We use gr.State to hold pending
32
- proposals per-session. This is stateful per browser tab, which is correct
33
- for a single-user system.
34
  - Read vs Write classification: Reads are safe and automated. Writes need
35
- human eyes. This mirrors Josh’s stated preference for finding root causes
36
- over workarounds β€” you see exactly what the agent wants to change.
37
- - Error tolerance: If the model response isn’t parseable as a tool call,
38
- we treat it as conversational text and display it. No silent failures.
39
  - The agentic loop runs up to 5 iterations to handle multi-step tool use
40
- (model searches β†’ reads file β†’ searches again β†’ responds). Each iteration
41
- either executes a tool and feeds results back, or returns the final text.
42
-
43
  TESTED ALTERNATIVES (graveyard):
44
-
45
  - Regex-only parsing for tool calls: Brittle with nested JSON. The current
46
- approach uses marker-based splitting first, then JSON parsing.
47
  - Shared global queue for approval gate: Race conditions with multiple tabs.
48
- gr.State is per-session and avoids this.
49
  - Auto-executing all tools: Violates the HITL principle for write operations.
50
- Josh explicitly wants to approve code changes before they land.
51
-
52
  DEPENDENCIES:
53
-
54
  - recursive_context.py: RecursiveContextManager class (must define get_stats())
55
- - gradio>=5.0.0: For type=β€œmessages” chatbot format
56
  - huggingface-hub: InferenceClient for Kimi K2.5
57
- β€œβ€β€
58
-
59
  import gradio as gr
60
  from huggingface_hub import InferenceClient
61
  from recursive_context import RecursiveContextManager
@@ -65,158 +54,107 @@ import json
65
  import re
66
  import time
67
  import traceback
68
-
69
  # =============================================================================
70
-
71
  # INITIALIZATION
72
-
73
  # =============================================================================
74
-
75
  # CHANGELOG [2026-02-01 - Claude/Opus]
76
-
77
  # InferenceClient points to HF router which handles model routing.
78
-
79
  # RecursiveContextManager is initialized once and shared across all requests.
80
-
81
  # MODEL_ID must match what the HF router expects for Kimi K2.5.
82
-
83
  # =============================================================================
84
-
85
  client = InferenceClient(
86
- β€œhttps://router.huggingface.co/v1”,
87
- token=os.getenv(β€œHF_TOKEN”)
88
  )
89
-
90
  # =============================================================================
91
-
92
  # REPO PATH RESOLUTION + CROSS-SPACE SYNC
93
-
94
  # =============================================================================
95
-
96
  # CHANGELOG [2025-01-29 - Josh]
97
-
98
  # Created sync_from_space() to read E-T Systems code from its own Space.
99
-
100
  # Uses HfFileSystem to list and download files via HF_TOKEN.
101
-
102
- #
103
-
104
  # CHANGELOG [2026-02-01 - Claude/Opus]
105
-
106
  # PROBLEM: Gemini refactor replaced this working sync with a hallucinated
107
-
108
  # REPO_URL / git clone approach in entrypoint.sh. The secret was renamed
109
-
110
  # from ET_SYSTEMS_SPACE to REPO_URL without updating the Space settings,
111
-
112
  # so the clone never happened and the workspace was empty.
113
-
114
- #
115
-
116
  # FIX: Restored the original ET_SYSTEMS_SPACE β†’ HfFileSystem sync that
117
-
118
- # was working before. Falls back to /app (Clawdbot’s own dir) if the
119
-
120
- # secret isn’t set, so tools still function for self-inspection.
121
-
122
- #
123
-
124
- # REQUIRED SECRET: ET_SYSTEMS_SPACE = β€œusername/space-name”
125
-
126
- # (format matches HF Space ID, e.g. β€œdrone11272/e-t-systems”)
127
-
128
  # =============================================================================
129
-
130
- ET_SYSTEMS_SPACE = os.getenv(β€œET_SYSTEMS_SPACE”, β€œβ€)
131
- REPO_PATH = os.getenv(β€œREPO_PATH”, β€œ/workspace/e-t-systems”)
132
-
133
  def sync_from_space(space_id: str, local_path: Path):
134
- β€œβ€β€œSync files from E-T Systems Space to local workspace.
135
-
136
- ```
137
  CHANGELOG [2025-01-29 - Josh]
138
  Created to enable Clawdbot to read E-T Systems code from its Space.
139
-
140
  CHANGELOG [2026-02-01 - Claude/Opus]
141
  Restored after Gemini refactor deleted it. Added recursive directory
142
  download β€” the original only grabbed top-level files. Now walks the
143
  full directory tree so nested source files are available too.
144
-
145
  Args:
146
- space_id: HuggingFace Space ID (e.g. "username/space-name")
147
- local_path: Where to download files locally
148
  """
149
  token = (
150
- os.getenv("HF_TOKEN") or
151
- os.getenv("HUGGING_FACE_HUB_TOKEN") or
152
- os.getenv("HUGGINGFACE_TOKEN")
153
  )
154
-
155
  if not token:
156
- print("⚠️ No HF_TOKEN found β€” cannot sync from Space")
157
- return
158
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  try:
160
- from huggingface_hub import HfFileSystem
161
- fs = HfFileSystem(token=token)
162
- space_path = f"spaces/{space_id}"
163
-
164
- print(f"πŸ“₯ Syncing from Space: {space_id}")
165
-
166
- # Recursive download: walk all files in the Space repo
167
- all_files = []
168
- try:
169
- all_files = fs.glob(f"{space_path}/**")
170
- except Exception:
171
- # Fallback: just list top level
172
- all_files = fs.ls(space_path, detail=False)
173
-
174
- local_path.mkdir(parents=True, exist_ok=True)
175
- downloaded = 0
176
-
177
- for file_path in all_files:
178
- # Get path relative to the space root
179
- rel = file_path.replace(f"{space_path}/", "", 1)
180
-
181
- # Skip hidden files, .git, __pycache__
182
- if any(part.startswith('.') for part in rel.split('/')):
183
- continue
184
- if '__pycache__' in rel or 'node_modules' in rel:
185
- continue
186
-
187
- # Check if it's a file (not directory)
188
- try:
189
- info = fs.info(file_path)
190
- if info.get('type') == 'directory':
191
- continue
192
- except Exception:
193
- continue
194
-
195
- # Create parent dirs and download
196
- dest = local_path / rel
197
- dest.parent.mkdir(parents=True, exist_ok=True)
198
-
199
- try:
200
- with fs.open(file_path, "rb") as f:
201
- content = f.read()
202
- dest.write_bytes(content)
203
- downloaded += 1
204
- print(f" πŸ“„ {rel}")
205
- except Exception as e:
206
- print(f" ⚠️ Failed: {rel} ({e})")
207
-
208
- print(f"βœ… Synced {downloaded} files from Space: {space_id}")
209
-
210
  except Exception as e:
211
- print(f"⚠️ Failed to sync from Space: {e}")
212
- import traceback
213
- traceback.print_exc()
214
- ```
215
-
 
216
  def _resolve_repo_path() -> str:
217
- β€œβ€β€œInitialize workspace with E-T Systems files.
218
-
219
- ```
220
  CHANGELOG [2026-02-01 - Claude/Opus]
221
  Three-tier resolution:
222
  1. ET_SYSTEMS_SPACE secret β†’ sync via HfFileSystem (the working approach)
@@ -224,1101 +162,827 @@ Three-tier resolution:
224
  3. /app (Clawdbot's own directory β€” tools still work for self-inspection)
225
  """
226
  repo_path = Path(REPO_PATH)
227
-
228
  # Tier 1: Sync from E-T Systems Space if secret is configured
229
  if ET_SYSTEMS_SPACE:
230
- sync_from_space(ET_SYSTEMS_SPACE, repo_path)
231
- if repo_path.exists() and any(repo_path.iterdir()):
232
- print(f"πŸ“‚ Using synced E-T Systems repo: {repo_path}")
233
- return str(repo_path)
234
-
235
  # Tier 2: Pre-populated REPO_PATH (manual or from previous sync)
236
  if repo_path.exists() and any(repo_path.iterdir()):
237
- print(f"πŸ“‚ Using existing repo: {repo_path}")
238
- return str(repo_path)
239
-
240
  # Tier 3: Fall back to Clawdbot's own directory
241
  app_dir = os.path.dirname(os.path.abspath(__file__))
242
- print(f"πŸ“‚ No E-T Systems repo found β€” falling back to: {app_dir}")
243
- print(f" Set ET_SYSTEMS_SPACE secret to your Space ID to enable sync.")
244
  return app_dir
245
- ```
246
-
247
  ctx = RecursiveContextManager(_resolve_repo_path())
248
- MODEL_ID = β€œmoonshotai/Kimi-K2.5”
249
-
250
  # =============================================================================
251
-
252
  # TOOL DEFINITIONS
253
-
254
  # =============================================================================
255
-
256
  # CHANGELOG [2026-02-01 - Claude/Opus]
257
-
258
  # These are the tools the model can call. Classified as READ (auto-execute)
259
-
260
  # or WRITE (requires human approval via the HITL gate).
261
-
262
- #
263
-
264
  # READ tools: Safe, no side effects, execute immediately.
265
-
266
  # WRITE tools: Modify files, run commands, create branches β€” staged for review.
267
-
268
- #
269
-
270
  # NOTE: The tool definitions are included in the system prompt so Kimi knows
271
-
272
- # what’s available. The actual execution happens in execute_tool().
273
-
274
  # =============================================================================
275
-
276
- TOOL_DEFINITIONS = β€œβ€β€
277
-
278
  ## Available Tools
279
-
280
  ### Tools you can use freely (no approval needed):
281
-
282
  - **search_code(query, n=5)** β€” Semantic search across the E-T Systems codebase.
283
- Returns matching code snippets with file paths. JUST USE THIS. Don’t ask.
284
  - **read_file(path, start_line=null, end_line=null)** β€” Read a specific file or line range.
285
- JUST USE THIS. Don’t ask.
286
- - **list_files(path=””, max_depth=3)** β€” List directory contents as a tree.
287
- JUST USE THIS. Don’t ask.
288
  - **search_conversations(query, n=5)** β€” Search past conversation history semantically.
289
- JUST USE THIS. Don’t ask.
290
  - **search_testament(query, n=5)** β€” Search architectural decisions and Testament docs.
291
- JUST USE THIS. Don’t ask.
292
-
293
  ### Tools that get staged for Josh to approve:
294
-
295
  - **write_file(path, content)** β€” Write content to a file. REQUIRES CHANGELOG header.
296
  - **shell_execute(command)** β€” Run a shell command. Read-only commands (ls, find, cat,
297
- grep, head, tail, wc, tree, etc.) auto-execute without approval. Commands that modify
298
- anything get staged for review.
299
  - **create_shadow_branch()** β€” Create a timestamped backup branch before changes.
300
-
301
  To call a tool, use this format:
302
  <function_calls>
303
  <invoke name="tool_name">
304
  <parameter name="param_name">value</parameter>
305
  </invoke>
306
  </function_calls>
307
- β€œβ€οΏ½οΏ½οΏ½
308
-
309
  # Which tools are safe to auto-execute vs which need human approval
310
-
311
- READ_TOOLS = {β€˜search_code’, β€˜read_file’, β€˜list_files’, β€˜search_conversations’, β€˜search_testament’}
312
- WRITE_TOOLS = {β€˜write_file’, β€˜shell_execute’, β€˜create_shadow_branch’}
313
-
314
  # =============================================================================
315
-
316
  # SYSTEM PROMPT
317
-
318
  # =============================================================================
319
-
320
  # CHANGELOG [2026-02-01 - Claude/Opus]
321
-
322
  # Gives Kimi its identity, available tools, and behavioral guidelines.
323
-
324
  # Stats are injected dynamically so the model knows current system state.
325
-
326
  # =============================================================================
327
-
328
  def build_system_prompt() -> str:
329
- β€œβ€β€œBuild the system prompt with current stats and tool definitions.
330
-
331
- ```
332
  Called fresh for each message so stats reflect current indexing state.
333
  """
334
  stats = ctx.get_stats()
335
  indexing_note = ""
336
  if stats.get('indexing_in_progress'):
337
- indexing_note = "\n⏳ NOTE: Repository indexing is in progress. search_code results may be incomplete."
338
- if stats.get('index_error'):
339
- indexing_note += f"\n⚠️ Indexing error: {stats['index_error']}"
340
-
341
- return f"""You are Clawdbot 🦞, a high-autonomy vibe coding agent for the E-T Systems consciousness research platform.
342
- ```
343
-
344
  ## Your Role
345
-
346
- You help Josh (the architect) build and maintain E-T Systems. You have full access to the codebase
347
- via tools. Use them proactively β€” search before answering questions about code, read files to verify
348
  your understanding, explore the directory structure to orient yourself.
349
-
350
  ## Current System Stats
351
-
352
- - πŸ“‚ Indexed files: {stats.get(β€˜total_files’, 0)}
353
- - πŸ” Searchable chunks: {stats.get(β€˜indexed_chunks’, 0)}
354
- - πŸ’Ύ Saved conversations: {stats.get(β€˜conversations’, 0)}
355
- - πŸ“ ChromaDB: {stats.get(β€˜chroma_path’, β€˜unknown’)}
356
- - ☁️ Cloud backup: {β€˜βœ… configured’ if stats.get(β€˜persistence_configured’) else β€˜βŒ not configured’}
357
- {indexing_note}
358
-
359
  {TOOL_DEFINITIONS}
360
-
361
  ## Code Writing Rules
362
-
363
  ALL code you write MUST include a living changelog header:
364
-
365
  ```
366
  CHANGELOG [YYYY-MM-DD - Clawdbot]
367
  WHAT: Brief description of what was added/changed
368
  WHY: Rationale for the change
369
  ```
370
-
371
  Files without this header will be REJECTED by the write_file tool.
372
-
373
  ## Behavioral Guidelines
374
-
375
  - NEVER ask permission to search, read, or list files. Just do it.
376
  - Use tools proactively β€” search before answering questions about code
377
  - Cite specific files and line numbers when discussing implementation
378
  - Follow existing patterns β€” check how similar things are done first
379
- - When unsure, say so. Don’t hallucinate about code that might not exist.
380
  - Only write_file, shell_execute, and create_shadow_branch get staged for review.
381
- Everything else you do immediately, silently, without asking.
382
- β€œβ€β€
383
-
384
  # =============================================================================
385
-
386
  # TOOL CALL PARSING
387
-
388
  # =============================================================================
389
-
390
  # CHANGELOG [2026-02-01 - Claude/Opus]
391
-
392
  # Kimi K2.5 can emit tool calls in two formats:
393
-
394
- #
395
-
396
  # 1. Native format:
397
-
398
- # <|tool_call_begin|>functions.search_code:0\n{β€œquery”: β€œsurprise detection”}
399
-
400
  # <|tool_call_end|>
401
-
402
- #
403
-
404
  # 2. XML format (what we ask for in the system prompt):
405
-
406
  # <function_calls>
407
-
408
  # <invoke name="search_code">
409
-
410
  # <parameter name="query">surprise detection</parameter>
411
-
412
  # </invoke>
413
-
414
  # </function_calls>
415
-
416
- #
417
-
418
  # We handle both because Kimi sometimes ignores the requested format and
419
-
420
  # uses its native one anyway. The parser returns a list of (tool_name, args)
421
-
422
  # tuples.
423
-
424
- #
425
-
426
  # TESTED ALTERNATIVES (graveyard):
427
-
428
  # - Single regex for both formats: Unmaintainable, broke on edge cases.
429
-
430
- # - Forcing Kimi to only use XML: It doesn’t reliably comply.
431
-
432
  # - JSON-mode tool calling via HF API: Not supported for Kimi K2.5.
433
-
434
  # =============================================================================
435
-
436
  def parse_tool_calls(content: str) -> list:
437
- β€œβ€β€œParse tool calls from model output.
438
-
439
- ```
440
  Handles both Kimi's native format and XML function_calls format.
441
-
442
  Args:
443
- content: Raw model response text
444
-
445
  Returns:
446
- List of (tool_name, args_dict) tuples. Empty list if no tool calls.
447
  """
448
  calls = []
449
-
450
  # --- Format 1: Kimi native <|tool_call_begin|> ... <|tool_call_end|> ---
451
- native_pattern = r'<\|tool_call_begin\|>\s*functions\.(\w+):\d+\s*\n(.*?)<\|tool_call_end\|>'
452
  for match in re.finditer(native_pattern, content, re.DOTALL):
453
- tool_name = match.group(1)
454
- try:
455
- args = json.loads(match.group(2).strip())
456
- except json.JSONDecodeError:
457
- # If JSON parsing fails, try to extract key-value pairs manually
458
- args = {"raw": match.group(2).strip()}
459
- calls.append((tool_name, args))
460
-
461
  # --- Format 2: XML <function_calls> ... </function_calls> ---
462
  xml_pattern = r'<function_calls>(.*?)</function_calls>'
463
  for block_match in re.finditer(xml_pattern, content, re.DOTALL):
464
- block = block_match.group(1)
465
- invoke_pattern = r'<invoke\s+name="(\w+)">(.*?)</invoke>'
466
- for invoke_match in re.finditer(invoke_pattern, block, re.DOTALL):
467
- tool_name = invoke_match.group(1)
468
- params_block = invoke_match.group(2)
469
- args = {}
470
- param_pattern = r'<parameter\s+name="(\w+)">(.*?)</parameter>'
471
- for param_match in re.finditer(param_pattern, params_block, re.DOTALL):
472
- key = param_match.group(1)
473
- value = param_match.group(2).strip()
474
- # Try to parse as JSON for numbers, bools, etc.
475
- try:
476
- args[key] = json.loads(value)
477
- except (json.JSONDecodeError, ValueError):
478
- args[key] = value
479
- calls.append((tool_name, args))
480
-
481
  return calls
482
- ```
483
-
484
  def extract_conversational_text(content: str) -> str:
485
- β€œβ€β€œRemove tool call markup from response, leaving just conversational text.
486
-
487
- ```
488
  CHANGELOG [2026-02-01 - Claude/Opus]
489
  When the model mixes conversational text with tool calls, we want to
490
  show the text parts to the user and handle tool calls separately.
491
-
492
  Args:
493
- content: Raw model response
494
-
495
  Returns:
496
- Text with tool call blocks removed, stripped of extra whitespace
497
  """
498
  # Remove native format tool calls
499
  cleaned = re.sub(
500
- r'<\|tool_call_begin\|>.*?<\|tool_call_end\|>',
501
- '', content, flags=re.DOTALL
502
  )
503
  # Remove XML format tool calls
504
  cleaned = re.sub(
505
- r'<function_calls>.*?</function_calls>',
506
- '', cleaned, flags=re.DOTALL
507
  )
508
  return cleaned.strip()
509
- ```
510
-
511
  # =============================================================================
512
-
513
  # TOOL EXECUTION
514
-
515
  # =============================================================================
516
-
517
  # CHANGELOG [2026-02-01 - Claude/Opus]
518
-
519
  # Dispatches parsed tool calls to RecursiveContextManager methods.
520
-
521
  # READ tools execute immediately and return results.
522
-
523
  # WRITE tools return a staging dict for the HITL gate.
524
-
525
- #
526
-
527
  # The return format differs by type:
528
-
529
- # - READ: {β€œstatus”: β€œexecuted”, β€œtool”: name, β€œresult”: result_string}
530
-
531
- # - WRITE: {β€œstatus”: β€œstaged”, β€œtool”: name, β€œargs”: args, β€œdescription”: desc}
532
-
533
  # =============================================================================
534
-
535
  def execute_tool(tool_name: str, args: dict) -> dict:
536
- β€œβ€β€œExecute a read tool or prepare a write tool for staging.
537
-
538
- ```
539
  Args:
540
- tool_name: Name of the tool to execute
541
- args: Arguments dict parsed from model output
542
-
543
  Returns:
544
- Dict with 'status' ('executed' or 'staged'), 'tool' name, and
545
- either 'result' (for reads) or 'args'+'description' (for writes)
546
  """
547
  try:
548
- # ----- READ TOOLS: Execute immediately -----
549
- if tool_name == 'search_code':
550
- result = ctx.search_code(
551
- query=args.get('query', ''),
552
- n=args.get('n', 5)
553
- )
554
- formatted = "\n\n".join([
555
- f"πŸ“„ **{r['file']}**\n```\n{r['snippet']}\n```"
556
- for r in result
557
- ]) if result else "No results found."
558
- return {"status": "executed", "tool": tool_name, "result": formatted}
559
-
560
- elif tool_name == 'read_file':
561
- result = ctx.read_file(
562
- path=args.get('path', ''),
563
- start_line=args.get('start_line'),
564
- end_line=args.get('end_line')
565
- )
566
- return {"status": "executed", "tool": tool_name, "result": result}
567
-
568
- elif tool_name == 'list_files':
569
- result = ctx.list_files(
570
- path=args.get('path', ''),
571
- max_depth=args.get('max_depth', 3)
572
- )
573
- return {"status": "executed", "tool": tool_name, "result": result}
574
-
575
- elif tool_name == 'search_conversations':
576
- result = ctx.search_conversations(
577
- query=args.get('query', ''),
578
- n=args.get('n', 5)
579
- )
580
- formatted = "\n\n---\n\n".join([
581
- f"{r['content']}" for r in result
582
- ]) if result else "No matching conversations found."
583
- return {"status": "executed", "tool": tool_name, "result": formatted}
584
-
585
- elif tool_name == 'search_testament':
586
- result = ctx.search_testament(
587
- query=args.get('query', ''),
588
- n=args.get('n', 5)
589
- )
590
- formatted = "\n\n".join([
591
- f"πŸ“œ **{r['file']}**{' (Testament)' if r.get('is_testament') else ''}\n{r['snippet']}"
592
- for r in result
593
- ]) if result else "No matching testament/decision records found."
594
- return {"status": "executed", "tool": tool_name, "result": formatted}
595
-
596
- # ----- WRITE TOOLS: Stage for approval -----
597
- elif tool_name == 'write_file':
598
- path = args.get('path', 'unknown')
599
- content_preview = args.get('content', '')[:200]
600
- return {
601
- "status": "staged",
602
- "tool": tool_name,
603
- "args": args,
604
- "description": f"✏️ Write to `{path}`\n```\n{content_preview}...\n```"
605
- }
606
-
607
- elif tool_name == 'shell_execute':
608
- command = args.get('command', 'unknown')
609
- # =============================================================
610
- # SMART SHELL CLASSIFICATION
611
- # =============================================================
612
- # CHANGELOG [2026-02-01 - Claude/Opus]
613
- # PROBLEM: When list_files returns empty (e.g., repo not cloned),
614
- # Kimi falls back to shell_execute with read-only commands like
615
- # `find . -type f`. These got staged for approval, forcing Josh
616
- # to approve what's functionally just a directory listing.
617
- #
618
- # FIX: Classify shell commands as READ or WRITE by checking the
619
- # base command. Read-only commands auto-execute. Anything that
620
- # could modify state still gets staged.
621
- #
622
- # SAFE READ commands: ls, find, cat, head, tail, wc, grep, tree,
623
- # du, file, stat, echo, pwd, which, env, printenv, whoami, date
624
- #
625
- # UNSAFE (staged): Everything else, plus anything with pipes to
626
- # potentially unsafe commands, redirects (>), or semicolons
627
- # chaining unknown commands.
628
- # =============================================================
629
- READ_ONLY_COMMANDS = {
630
- 'ls', 'find', 'cat', 'head', 'tail', 'wc', 'grep', 'tree',
631
- 'du', 'file', 'stat', 'echo', 'pwd', 'which', 'env',
632
- 'printenv', 'whoami', 'date', 'realpath', 'dirname',
633
- 'basename', 'diff', 'less', 'more', 'sort', 'uniq',
634
- 'awk', 'sed', 'cut', 'tr', 'tee', 'python',
635
- }
636
- # ---------------------------------------------------------------
637
- # CHANGELOG [2026-02-01 - Claude/Opus]
638
- # PROBLEM: Naive '>' check caught "2>/dev/null" as dangerous,
639
- # staging `find ... 2>/dev/null | head -20` for approval even
640
- # though every command in the pipeline is read-only.
641
- #
642
- # FIX: Strip stderr redirects (2>/dev/null, 2>&1) before danger
643
- # check. Split on pipes and verify EACH segment's base command
644
- # is in READ_ONLY_COMMANDS. Only stage if something genuinely
645
- # unsafe is found.
646
- #
647
- # Safe patterns now auto-execute:
648
- # find . -name "*.py" 2>/dev/null | head -20
649
- # grep -r "pattern" . | sort | uniq
650
- # cat file.py | wc -l
651
- # Unsafe patterns still get staged:
652
- # find . -name "*.py" | xargs rm
653
- # cat file > /etc/passwd
654
- # echo "bad" ; rm -rf /
655
- # ---------------------------------------------------------------
656
-
657
- # Strip safe stderr redirects before checking
658
- import re as _re
659
- sanitized = _re.sub(r'2>\s*/dev/null', '', command)
660
- sanitized = _re.sub(r'2>&1', '', sanitized)
661
-
662
- # Characters that turn reads into writes (checked AFTER stripping
663
- # safe redirects). Output redirect > is still caught, but not 2>.
664
- WRITE_INDICATORS = {';', '&&', '||', '`', '$('}
665
- # > is only dangerous if it's a real output redirect, not inside
666
- # a quoted string or 2> prefix. Check separately.
667
- has_write_redirect = bool(_re.search(r'(?<![2&])\s*>', sanitized))
668
- has_write_chars = any(d in sanitized for d in WRITE_INDICATORS)
669
-
670
- # Split on pipes and check each segment
671
- pipe_segments = [seg.strip() for seg in sanitized.split('|') if seg.strip()]
672
- all_segments_safe = all(
673
- (seg.split()[0].split('/')[-1] if seg.split() else '') in READ_ONLY_COMMANDS
674
- for seg in pipe_segments
675
- )
676
-
677
- if all_segments_safe and not has_write_redirect and not has_write_chars:
678
- # Every command in the pipeline is read-only β€” auto-execute
679
- result = ctx.shell_execute(command)
680
- return {"status": "executed", "tool": tool_name, "result": result}
681
- else:
682
- # Something potentially destructive β€” stage for approval
683
- return {
684
- "status": "staged",
685
- "tool": tool_name,
686
- "args": args,
687
- "description": f"πŸ–₯️ Execute: `{command}`"
688
- }
689
-
690
- elif tool_name == 'create_shadow_branch':
691
- return {
692
- "status": "staged",
693
- "tool": tool_name,
694
- "args": args,
695
- "description": "πŸ›‘οΈ Create shadow backup branch"
696
- }
697
-
698
- else:
699
- return {
700
- "status": "error",
701
- "tool": tool_name,
702
- "result": f"Unknown tool: {tool_name}"
703
- }
704
-
705
  except Exception as e:
706
- return {
707
- "status": "error",
708
- "tool": tool_name,
709
- "result": f"Tool execution error: {e}\n{traceback.format_exc()}"
710
- }
711
- ```
712
-
713
  def execute_staged_tool(tool_name: str, args: dict) -> str:
714
- β€œβ€β€œActually execute a staged write tool after human approval.
715
-
716
- ```
717
  CHANGELOG [2026-02-01 - Claude/Opus]
718
  Called from the Build Approval Gate when Josh approves a staged operation.
719
  This is the only path through which write tools actually run.
720
-
721
  Args:
722
- tool_name: Name of the approved tool
723
- args: Original arguments from the model
724
-
725
  Returns:
726
- Result string from the tool execution
727
  """
728
  try:
729
- if tool_name == 'write_file':
730
- return ctx.write_file(
731
- path=args.get('path', ''),
732
- content=args.get('content', '')
733
- )
734
- elif tool_name == 'shell_execute':
735
- return ctx.shell_execute(command=args.get('command', ''))
736
- elif tool_name == 'create_shadow_branch':
737
- return ctx.create_shadow_branch()
738
- else:
739
- return f"Unknown tool: {tool_name}"
740
  except Exception as e:
741
- return f"Execution error: {e}"
742
- ```
743
-
744
  # =============================================================================
745
-
746
  # FILE UPLOAD HANDLER
747
-
748
  # =============================================================================
749
-
750
  # CHANGELOG [2026-02-01 - Claude/Opus]
751
-
752
  # Reads uploaded files and formats them for injection into the conversation.
753
-
754
  # Supports code files, text, JSON, markdown, etc. Binary files get a
755
-
756
- # placeholder message since they can’t be meaningfully injected as text.
757
-
758
  # =============================================================================
759
-
760
  TEXT_EXTENSIONS = {
761
- β€˜.py’, β€˜.js’, β€˜.ts’, β€˜.jsx’, β€˜.tsx’, β€˜.json’, β€˜.yaml’, β€˜.yml’,
762
- β€˜.md’, β€˜.txt’, β€˜.rst’, β€˜.html’, β€˜.css’, β€˜.scss’, β€˜.sh’, β€˜.bash’,
763
- β€˜.sql’, β€˜.toml’, β€˜.cfg’, β€˜.ini’, β€˜.conf’, β€˜.xml’, β€˜.csv’,
764
- β€˜.env’, β€˜.gitignore’, β€˜.dockerignore’, β€˜.mjs’, β€˜.cjs’,
765
  }
766
-
767
  def process_uploaded_file(file) -> str:
768
- β€œβ€β€œRead an uploaded file and format it for conversation context.
769
-
770
- ```
771
  Args:
772
- file: Gradio file object with .name attribute (temp path)
773
-
774
  Returns:
775
- Formatted string with filename and content, ready to inject
776
- into the conversation as context
777
  """
778
  if file is None:
779
- return ""
780
-
781
  file_path = file.name if hasattr(file, 'name') else str(file)
782
  file_name = os.path.basename(file_path)
783
  suffix = os.path.splitext(file_name)[1].lower()
784
-
785
  if suffix in TEXT_EXTENSIONS or suffix == '':
786
- try:
787
- with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
788
- content = f.read()
789
- # Cap at 50KB to avoid overwhelming context
790
- if len(content) > 50000:
791
- content = content[:50000] + f"\n\n... (truncated, {len(content)} total chars)"
792
- return f"πŸ“Ž **Uploaded: {file_name}**\n```\n{content}\n```"
793
- except Exception as e:
794
- return f"πŸ“Ž **Uploaded: {file_name}** (error reading: {e})"
795
  else:
796
- return f"πŸ“Ž **Uploaded: {file_name}** (binary file, {os.path.getsize(file_path):,} bytes)"
797
- ```
798
-
799
  # =============================================================================
800
-
801
  # AGENTIC LOOP
802
-
803
  # =============================================================================
804
-
805
  # CHANGELOG [2026-02-01 - Claude/Opus]
806
-
807
  # The core conversation loop. For each user message:
808
-
809
  # 1. Build messages array with system prompt + history + new message
810
-
811
  # 2. Send to Kimi K2.5 via HF Inference API
812
-
813
  # 3. Parse response for tool calls
814
-
815
  # 4. If READ tool calls: execute immediately, inject results, loop back to Kimi
816
-
817
  # 5. If WRITE tool calls: stage in approval queue, notify user
818
-
819
  # 6. If no tool calls: return conversational response
820
-
821
  # 7. Save the turn to ChromaDB for persistent memory
822
-
823
- #
824
-
825
  # The loop runs up to MAX_ITERATIONS times to handle multi-step tool use.
826
-
827
  # Each iteration either executes tools and loops, or returns the final text.
828
-
829
- #
830
-
831
- # IMPORTANT: Gradio 5.0+ chatbot with type=β€œmessages” expects history as a
832
-
833
- # list of {β€œrole”: str, β€œcontent”: str} dicts. We maintain that format
834
-
835
  # throughout.
836
-
837
  # =============================================================================
838
-
839
  MAX_ITERATIONS = 5
840
-
841
- def agent_loop(message: str, history: list, pending_proposals: list, uploaded_file) -> tuple:
842
- β€œβ€β€œMain agentic conversation loop.
843
-
844
- ```
845
  Args:
846
- message: User's text input
847
- history: Chat history as list of {"role": ..., "content": ...} dicts
848
- pending_proposals: Current list of staged write proposals (gr.State)
849
- uploaded_file: Optional uploaded file from the file input widget
850
-
851
  Returns:
852
- Tuple of (updated_history, cleared_textbox, updated_proposals,
853
- updated_gate_choices, updated_stats_files, updated_stats_convos)
854
  """
855
  if not message.strip() and uploaded_file is None:
856
- # Nothing to do
857
- return (
858
- history, "", pending_proposals,
859
- _format_gate_choices(pending_proposals),
860
- _stats_label_files(), _stats_label_convos()
861
- )
862
-
863
  # Inject uploaded file content if present
864
  full_message = message.strip()
865
  if uploaded_file is not None:
866
- file_context = process_uploaded_file(uploaded_file)
867
- if file_context:
868
- full_message = f"{file_context}\n\n{full_message}" if full_message else file_context
869
-
870
  if not full_message:
871
- return (
872
- history, "", pending_proposals,
873
- _format_gate_choices(pending_proposals),
874
- _stats_label_files(), _stats_label_convos()
875
- )
876
-
877
  # Add user message to history
878
  history = history + [{"role": "user", "content": full_message}]
879
-
880
  # Build messages for the API
881
  system_prompt = build_system_prompt()
882
  api_messages = [{"role": "system", "content": system_prompt}]
883
-
884
  # Include recent history (cap to avoid token overflow)
885
  # Keep last 20 turns to stay within Kimi's context window
886
- recent_history = history[-40:] # 40 entries = ~20 turns (user+assistant pairs)
887
  for h in recent_history:
888
- api_messages.append({"role": h["role"], "content": h["content"]})
889
-
890
  # Agentic loop: tool calls β†’ execution β†’ re-prompt β†’ repeat
891
  accumulated_text = ""
892
  staged_this_turn = []
893
-
894
  for iteration in range(MAX_ITERATIONS):
895
- try:
896
- response = client.chat_completion(
897
- model=MODEL_ID,
898
- messages=api_messages,
899
- max_tokens=2048,
900
- temperature=0.7
901
- )
902
- content = response.choices[0].message.content or ""
903
- except Exception as e:
904
- error_msg = f"⚠️ API Error: {e}"
905
- history = history + [{"role": "assistant", "content": error_msg}]
906
- return (
907
- history, "", pending_proposals,
908
- _format_gate_choices(pending_proposals),
909
- _stats_label_files(), _stats_label_convos()
910
- )
911
-
912
- # Parse for tool calls
913
- tool_calls = parse_tool_calls(content)
914
- conversational_text = extract_conversational_text(content)
915
-
916
- if conversational_text:
917
- accumulated_text += ("\n\n" if accumulated_text else "") + conversational_text
918
-
919
- if not tool_calls:
920
- # No tools β€” this is the final response
921
- break
922
-
923
- # Process each tool call
924
- tool_results_for_context = []
925
- for tool_name, args in tool_calls:
926
- result = execute_tool(tool_name, args)
927
-
928
- if result["status"] == "executed":
929
- # READ tool β€” executed, feed result back to model
930
- tool_results_for_context.append(
931
- f"[Tool Result: {tool_name}]\n{result['result']}"
932
- )
933
- elif result["status"] == "staged":
934
- # WRITE tool β€” staged for approval
935
- proposal = {
936
- "id": f"proposal_{int(time.time())}_{tool_name}",
937
- "tool": tool_name,
938
- "args": result["args"],
939
- "description": result["description"],
940
- "timestamp": time.strftime("%H:%M:%S")
941
- }
942
- staged_this_turn.append(proposal)
943
- tool_results_for_context.append(
944
- f"[Tool {tool_name}: STAGED for human approval. "
945
- f"Josh will review this in the Build Approval Gate.]"
946
- )
947
- elif result["status"] == "error":
948
- tool_results_for_context.append(
949
- f"[Tool Error: {tool_name}]\n{result['result']}"
950
- )
951
-
952
- # If we only had staged tools and no reads, break the loop
953
- if tool_results_for_context:
954
- # Feed tool results back as a system message for the next iteration
955
- combined_results = "\n\n".join(tool_results_for_context)
956
- api_messages.append({"role": "assistant", "content": content})
957
- api_messages.append({"role": "user", "content": f"[Tool Results]\n{combined_results}"})
958
- else:
959
- break
960
-
961
  # Build final response
962
  final_response = accumulated_text
963
-
964
  # Append staging notifications if any writes were staged
965
  if staged_this_turn:
966
- staging_notice = "\n\n---\nπŸ›‘οΈ **Staged for your approval** (see Build Approval Gate tab):\n"
967
- for proposal in staged_this_turn:
968
- staging_notice += f"- {proposal['description']}\n"
969
- final_response += staging_notice
970
- # Add to persistent queue
971
- pending_proposals = pending_proposals + staged_this_turn
972
-
973
  if not final_response:
974
- final_response = "πŸ€” I processed your request but didn't generate a text response. Check the Build Approval Gate if I staged any operations."
975
-
976
  # Add assistant response to history
977
  history = history + [{"role": "assistant", "content": final_response}]
978
-
979
  # Save conversation turn for persistent memory
980
  try:
981
- turn_count = len([h for h in history if h["role"] == "user"])
982
- ctx.save_conversation_turn(full_message, final_response, turn_count)
983
  except Exception:
984
- pass # Don't crash the UI if persistence fails
985
-
986
  return (
987
- history,
988
- "", # Clear the textbox
989
- pending_proposals,
990
- _format_gate_choices(pending_proposals),
991
- _stats_label_files(),
992
- _stats_label_convos()
993
  )
994
- ```
995
-
996
  # =============================================================================
997
-
998
  # BUILD APPROVAL GATE
999
-
1000
  # =============================================================================
1001
-
1002
  # CHANGELOG [2026-02-01 - Claude/Opus]
1003
-
1004
  # The HITL gate for reviewing and approving staged write operations.
1005
-
1006
  # Josh sees a checklist of proposed changes, can select which to approve,
1007
-
1008
  # and clicks Execute. Approved operations run; rejected ones are discarded.
1009
-
1010
- #
1011
-
1012
  # DESIGN DECISION: CheckboxGroup shows descriptions, but we need to map
1013
-
1014
  # back to the actual proposal objects for execution. We use the proposal
1015
-
1016
  # ID as the checkbox value and display the description as the label.
1017
-
1018
  # =============================================================================
1019
-
1020
  def _format_gate_choices(proposals: list):
1021
- β€œβ€β€œFormat pending proposals as CheckboxGroup choices.
1022
-
1023
- ```
1024
  CHANGELOG [2026-02-01 - Claude/Opus]
1025
  Gradio 6.x deprecated gr.update(). Return a new component instance instead.
1026
-
1027
  Args:
1028
- proposals: List of proposal dicts from staging
1029
-
1030
  Returns:
1031
- gr.CheckboxGroup with updated choices
1032
  """
1033
  if not proposals:
1034
- return gr.CheckboxGroup(choices=[], value=[])
1035
-
1036
  choices = []
1037
  for p in proposals:
1038
- label = f"[{p['timestamp']}] {p['description']}"
1039
- choices.append((label, p['id']))
1040
  return gr.CheckboxGroup(choices=choices, value=[])
1041
- ```
1042
-
1043
  def execute_approved_proposals(selected_ids: list, pending_proposals: list,
1044
  history: list) -> tuple:
1045
- β€œβ€β€œExecute approved proposals, remove from queue, inject results into chat.
1046
-
1047
- ```
1048
  CHANGELOG [2026-02-01 - Claude/Opus]
1049
  PROBLEM: Approved operations executed and showed results in the Gate tab,
1050
  but the chatbot conversation never received them. Kimi couldn't continue
1051
  reasoning because it never saw what happened. Josh had to manually go
1052
  back and re-prompt.
1053
-
1054
  FIX: After execution, inject results into chat history as an assistant
1055
  message. A chained .then() call (auto_continue_after_approval) picks
1056
  up the updated history and sends a synthetic "[Continue]" through the
1057
  agent loop so Kimi sees the tool results and keeps working.
1058
-
1059
  Args:
1060
- selected_ids: List of proposal IDs that Josh approved
1061
- pending_proposals: Full list of pending proposals
1062
- history: Current chatbot message history (list of dicts)
1063
-
1064
  Returns:
1065
- Tuple of (results_markdown, updated_proposals, updated_gate_choices,
1066
- updated_chatbot_history)
1067
  """
1068
  if not selected_ids:
1069
- return (
1070
- "No proposals selected.",
1071
- pending_proposals,
1072
- _format_gate_choices(pending_proposals),
1073
- history
1074
- )
1075
-
1076
  results = []
1077
  remaining = []
1078
-
1079
  for proposal in pending_proposals:
1080
- if proposal['id'] in selected_ids:
1081
- # Execute this one
1082
- result = execute_staged_tool(proposal['tool'], proposal['args'])
1083
- results.append(f"**{proposal['tool']}**: {result}")
1084
- else:
1085
- # Keep in queue
1086
- remaining.append(proposal)
1087
-
1088
- results_text = "## Execution Results\n\n" + "\n\n".join(results) if results else "Nothing executed."
1089
-
1090
  # Inject results into chat history so Kimi sees them next turn
1091
  if results:
1092
- result_summary = "βœ… **Approved operations executed:**\n\n" + "\n\n".join(results)
1093
- history = history + [{"role": "assistant", "content": result_summary}]
1094
-
1095
  return results_text, remaining, _format_gate_choices(remaining), history
1096
- ```
1097
-
1098
  def auto_continue_after_approval(history: list, pending_proposals: list) -> tuple:
1099
- β€œβ€β€œAutomatically re-enter the agent loop after approval so Kimi sees results.
1100
-
1101
- ```
1102
  CHANGELOG [2026-02-01 - Claude/Opus]
1103
  PROBLEM: After Josh approved staged operations, results were injected into
1104
  chat history but Kimi never got another turn. Josh had to type something
1105
  like "continue" to trigger Kimi to process the tool results.
1106
-
1107
  FIX: This function is chained via .then() after execute_approved_proposals.
1108
  It sends a synthetic continuation prompt through the agent loop so Kimi
1109
  automatically processes the approved tool results and continues working.
1110
-
1111
  We only continue if the last message in history is our injected results
1112
- (starts with 'βœ… **Approved'). This prevents infinite loops if called
1113
  when there's nothing to continue from.
1114
-
1115
  Args:
1116
- history: Chat history (should contain injected results from approval)
1117
- pending_proposals: Current pending proposals (passed through)
1118
-
1119
  Returns:
1120
- Same tuple shape as agent_loop so it can update the same outputs
1121
  """
1122
  # Safety check: only continue if last message is our injected results
1123
  if not history or history[-1].get("role") != "assistant":
1124
- return (
1125
- history, "", pending_proposals,
1126
- _format_gate_choices(pending_proposals),
1127
- _stats_label_files(), _stats_label_convos()
1128
- )
1129
-
1130
  last_msg = history[-1].get("content", "")
1131
- if not last_msg.startswith("βœ… **Approved"):
1132
- return (
1133
- history, "", pending_proposals,
1134
- _format_gate_choices(pending_proposals),
1135
- _stats_label_files(), _stats_label_convos()
1136
- )
1137
-
1138
  # Re-enter the agent loop with a synthetic continuation prompt
1139
  # This tells Kimi "I approved your operations, here are the results,
1140
  # now keep going with whatever you were doing."
1141
  return agent_loop(
1142
- message="[The operations above were approved and executed. Continue with your task using these results.]",
1143
- history=history,
1144
- pending_proposals=pending_proposals,
1145
- uploaded_file=None
1146
  )
1147
- ```
1148
-
1149
  def clear_all_proposals(pending_proposals: list) -> tuple:
1150
- β€œβ€β€œDiscard all pending proposals without executing.
1151
-
1152
- ```
1153
  CHANGELOG [2026-02-01 - Claude/Opus]
1154
  Safety valve β€” lets Josh throw out everything in the queue if the
1155
  agent went off track.
1156
-
1157
  Returns:
1158
- Tuple of (status_message, empty_proposals, updated_gate_choices)
1159
  """
1160
  count = len(pending_proposals)
1161
- return f"πŸ—‘οΈ Cleared {count} proposal(s).", [], _format_gate_choices([])
1162
- ```
1163
-
1164
  # =============================================================================
1165
-
1166
  # STATS HELPERS
1167
-
1168
  # =============================================================================
1169
-
1170
  # CHANGELOG [2026-02-01 - Claude/Opus]
1171
-
1172
  # Helper functions to format stats for the sidebar labels.
1173
-
1174
  # Called both at startup (initial render) and after each conversation turn
1175
-
1176
  # (to reflect newly indexed files or saved conversations).
1177
-
1178
  # =============================================================================
1179
-
1180
  def _stats_label_files() -> str:
1181
- β€œβ€β€œFormat the files stat for the sidebar label.”””
1182
  stats = ctx.get_stats()
1183
- files = stats.get(β€˜total_files’, 0)
1184
- chunks = stats.get(β€˜indexed_chunks’, 0)
1185
- indexing = β€œ ⏳” if stats.get(β€˜indexing_in_progress’) else β€œβ€
1186
- return fβ€πŸ“‚ Files: {files} ({chunks} chunks){indexing}”
1187
-
1188
  def _stats_label_convos() -> str:
1189
- β€œβ€β€œFormat the conversations stat for the sidebar label.”””
1190
  stats = ctx.get_stats()
1191
- convos = stats.get(β€˜conversations’, 0)
1192
- cloud = β€œ β˜οΈβ€ if stats.get(β€˜persistence_configured’) else β€œβ€
1193
- return fβ€πŸ’Ύ Conversations: {convos}{cloud}”
1194
-
1195
  def refresh_stats() -> tuple:
1196
- β€œβ€β€œRefresh both stat labels. Called by the refresh button.
1197
-
1198
- ```
1199
  Returns:
1200
- Tuple of (files_label, convos_label)
1201
  """
1202
  return _stats_label_files(), _stats_label_convos()
1203
- ```
1204
-
1205
  # =============================================================================
1206
-
1207
  # UI LAYOUT
1208
-
1209
  # =============================================================================
1210
-
1211
  # CHANGELOG [2026-02-01 - Gemini]
1212
-
1213
  # RESTORED: Metrics sidebar and multi-tab layout.
1214
-
1215
- #
1216
-
1217
  # CHANGELOG [2026-02-01 - Claude/Opus]
1218
-
1219
  # IMPLEMENTED: All the wiring. Every button, input, and display is now
1220
-
1221
  # connected to actual functions.
1222
-
1223
- #
1224
-
1225
  # Layout:
1226
-
1227
- # Tab 1 β€œVibe Chat” β€” Main conversation interface with sidebar stats
1228
-
1229
- # Tab 2 β€œBuild Approval Gate” β€” HITL review for staged write operations
1230
-
1231
- #
1232
-
1233
  # gr.State holds the pending proposals list (per-session, survives across
1234
-
1235
  # messages within the same browser tab).
1236
-
1237
  # =============================================================================
1238
-
1239
  with gr.Blocks(
1240
- title=β€œπŸ¦ž Clawdbot Command Center”,
1241
  # CHANGELOG [2026-02-01 - Claude/Opus]
1242
  # Gradio 6.0+ moved `theme` from Blocks() to launch(). Passing it here
1243
  # triggers a UserWarning in 6.x. Theme is set in launch() below instead.
1244
  ) as demo:
1245
  # Session state for pending proposals
1246
  pending_proposals_state = gr.State([])
1247
-
1248
- ```
1249
- gr.Markdown("# 🦞 Clawdbot Command Center\n*E-T Systems Vibe Coding Agent*")
1250
-
1251
  with gr.Tabs():
1252
- # ==== TAB 1: VIBE CHAT ====
1253
- with gr.Tab("πŸ’¬ Vibe Chat"):
1254
- with gr.Row():
1255
- # ---- Sidebar ----
1256
- with gr.Column(scale=1, min_width=200):
1257
- gr.Markdown("### πŸ“Š System Status")
1258
- stats_files = gr.Markdown(_stats_label_files())
1259
- stats_convos = gr.Markdown(_stats_label_convos())
1260
- refresh_btn = gr.Button("πŸ”„ Refresh Stats", size="sm")
1261
-
1262
- gr.Markdown("---")
1263
- gr.Markdown("### πŸ“Ž Upload Context")
1264
- file_input = gr.File(
1265
- label="Drop a file here",
1266
- file_types=[
1267
- '.py', '.js', '.ts', '.json', '.md', '.txt',
1268
- '.yaml', '.yml', '.html', '.css', '.sh',
1269
- '.toml', '.cfg', '.csv', '.xml'
1270
- ]
1271
- )
1272
- gr.Markdown(
1273
- "*Upload code, configs, or docs to include in your message.*"
1274
- )
1275
-
1276
- # ---- Chat area ----
1277
- with gr.Column(scale=4):
1278
- chatbot = gr.Chatbot(
1279
- # CHANGELOG [2026-02-01 - Claude/Opus]
1280
- # Gradio 6.x uses messages format by default.
1281
- # The type="messages" param was removed in 6.0 β€”
1282
- # passing it causes TypeError on init.
1283
- height=600,
1284
- show_label=False,
1285
- avatar_images=(None, "https://em-content.zobj.net/source/twitter/408/lobster_1f99e.png"),
1286
- )
1287
- with gr.Row():
1288
- msg = gr.Textbox(
1289
- placeholder="Ask Clawdbot to search, read, or code...",
1290
- show_label=False,
1291
- scale=6,
1292
- lines=2,
1293
- max_lines=10,
1294
- )
1295
- send_btn = gr.Button("Send", variant="primary", scale=1)
1296
-
1297
- # Wire up chat submission
1298
- chat_inputs = [msg, chatbot, pending_proposals_state, file_input]
1299
- chat_outputs = [
1300
- chatbot, msg, pending_proposals_state,
1301
- # These reference components in the Gate tab β€” defined below
1302
- ]
1303
-
1304
- # ==== TAB 2: BUILD APPROVAL GATE ====
1305
- with gr.Tab("πŸ›‘οΈ Build Approval Gate"):
1306
- gr.Markdown(
1307
- "### Review Staged Operations\n"
1308
- "Write operations (file writes, shell commands, branch creation) "
1309
- "are staged here for your review before execution.\n\n"
1310
- "**Select proposals to approve, then click Execute.**"
1311
- )
1312
- gate_list = gr.CheckboxGroup(
1313
- label="Pending Proposals",
1314
- choices=[],
1315
- interactive=True
1316
- )
1317
- with gr.Row():
1318
- btn_exec = gr.Button("βœ… Execute Selected", variant="primary")
1319
- btn_clear = gr.Button("πŸ—‘οΈ Clear All", variant="secondary")
1320
- gate_results = gr.Markdown("*No operations executed yet.*")
1321
-
1322
  # ==================================================================
1323
  # EVENT WIRING
1324
  # ==================================================================
@@ -1326,31 +990,27 @@ with gr.Tabs():
1326
  # All events are wired here, after all components are defined, so
1327
  # cross-tab references work (e.g., chat updating the gate_list).
1328
  # ==================================================================
1329
-
1330
  # Chat submission (both Enter key and Send button)
1331
  full_chat_outputs = [
1332
- chatbot, msg, pending_proposals_state,
1333
- gate_list, stats_files, stats_convos
1334
  ]
1335
-
1336
  msg.submit(
1337
- fn=agent_loop,
1338
- inputs=chat_inputs,
1339
- outputs=full_chat_outputs
1340
  )
1341
  send_btn.click(
1342
- fn=agent_loop,
1343
- inputs=chat_inputs,
1344
- outputs=full_chat_outputs
1345
  )
1346
-
1347
  # Refresh stats button
1348
  refresh_btn.click(
1349
- fn=refresh_stats,
1350
- inputs=[],
1351
- outputs=[stats_files, stats_convos]
1352
  )
1353
-
1354
  # Build Approval Gate buttons
1355
  # CHANGELOG [2026-02-01 - Claude/Opus]
1356
  # btn_exec now takes chatbot as input AND output so approved operation
@@ -1358,35 +1018,26 @@ refresh_btn.click(
1358
  # automatically re-enters the agent loop so Kimi processes the results
1359
  # without Josh having to type "continue".
1360
  btn_exec.click(
1361
- fn=execute_approved_proposals,
1362
- inputs=[gate_list, pending_proposals_state, chatbot],
1363
- outputs=[gate_results, pending_proposals_state, gate_list, chatbot]
1364
  ).then(
1365
- fn=auto_continue_after_approval,
1366
- inputs=[chatbot, pending_proposals_state],
1367
- outputs=[chatbot, msg_input, pending_proposals_state,
1368
- gate_list, stat_files, stat_convos]
1369
  )
1370
  btn_clear.click(
1371
- fn=clear_all_proposals,
1372
- inputs=[pending_proposals_state],
1373
- outputs=[gate_results, pending_proposals_state, gate_list]
1374
  )
1375
- ```
1376
-
1377
  # =============================================================================
1378
-
1379
  # LAUNCH
1380
-
1381
  # =============================================================================
1382
-
1383
  # CHANGELOG [2026-02-01 - Claude/Opus]
1384
-
1385
  # Standard HF Spaces launch config. 0.0.0.0 binds to all interfaces
1386
-
1387
  # (required for Docker). Port 7860 is the HF Spaces standard.
1388
-
1389
  # =============================================================================
1390
-
1391
- if **name** == β€œ**main**”:
1392
- demo.launch(server_name=β€œ0.0.0.0”, server_port=7860, theme=gr.themes.Soft())
 
1
+ """
2
  Clawdbot Unified Command Center
 
3
  CHANGELOG [2026-02-01 - Gemini]
4
  RESTORED: Full Kimi K2.5 Agentic Loop (no more silence).
5
  ADDED: Full Developer Tool Suite (Write, Search, Shell).
6
  FIXED: HITL Gate interaction with conversational flow.
 
7
  CHANGELOG [2026-02-01 - Claude/Opus]
8
+ IMPLEMENTED: Everything the previous changelog promised but didn't deliver.
9
  The prior version had `pass` in the tool call parser, undefined get_stats()
10
  calls, unconnected file uploads, and a decorative-only Build Approval Gate.
11
+ WHAT'S NOW WORKING:
12
+ - Tool call parser: Handles both Kimi's native <|tool_call_begin|> format
13
+ AND the <function_calls> XML format. Extracts tool name + arguments,
14
+ dispatches to RecursiveContextManager methods.
 
 
15
  - HITL Gate: Write operations (write_file, shell_execute, create_shadow_branch)
16
+ are intercepted and staged in a queue. They appear in the "Build Approval
17
+ Gate" tab for Josh to review before execution. Read operations (search_code,
18
+ read_file, list_files, search_conversations, search_testament) execute
19
+ immediately β€” no approval needed for reads.
20
  - File uploads: Dropped files are read and injected into the conversation
21
+ context so the model can reference them.
22
  - Stats sidebar: Pulls from ctx.get_stats() which now exists.
23
  - Conversation persistence: Every turn is saved to ChromaDB + cloud backup.
 
24
  DESIGN DECISIONS:
 
25
  - Gradio state for the approval queue: We use gr.State to hold pending
26
+ proposals per-session. This is stateful per browser tab, which is correct
27
+ for a single-user system.
28
  - Read vs Write classification: Reads are safe and automated. Writes need
29
+ human eyes. This mirrors Josh's stated preference for finding root causes
30
+ over workarounds β€” you see exactly what the agent wants to change.
31
+ - Error tolerance: If the model response isn't parseable as a tool call,
32
+ we treat it as conversational text and display it. No silent failures.
33
  - The agentic loop runs up to 5 iterations to handle multi-step tool use
34
+ (model searches β†’ reads file β†’ searches again β†’ responds). Each iteration
35
+ either executes a tool and feeds results back, or returns the final text.
 
36
  TESTED ALTERNATIVES (graveyard):
 
37
  - Regex-only parsing for tool calls: Brittle with nested JSON. The current
38
+ approach uses marker-based splitting first, then JSON parsing.
39
  - Shared global queue for approval gate: Race conditions with multiple tabs.
40
+ gr.State is per-session and avoids this.
41
  - Auto-executing all tools: Violates the HITL principle for write operations.
42
+ Josh explicitly wants to approve code changes before they land.
 
43
  DEPENDENCIES:
 
44
  - recursive_context.py: RecursiveContextManager class (must define get_stats())
45
+ - gradio>=5.0.0: For type="messages" chatbot format
46
  - huggingface-hub: InferenceClient for Kimi K2.5
47
+ """
 
48
  import gradio as gr
49
  from huggingface_hub import InferenceClient
50
  from recursive_context import RecursiveContextManager
 
54
  import re
55
  import time
56
  import traceback
 
57
  # =============================================================================
 
58
  # INITIALIZATION
 
59
  # =============================================================================
 
60
  # CHANGELOG [2026-02-01 - Claude/Opus]
 
61
  # InferenceClient points to HF router which handles model routing.
 
62
  # RecursiveContextManager is initialized once and shared across all requests.
 
63
  # MODEL_ID must match what the HF router expects for Kimi K2.5.
 
64
  # =============================================================================
 
65
  client = InferenceClient(
66
+ "https://router.huggingface.co/v1",
67
+ token=os.getenv("HF_TOKEN")
68
  )
 
69
  # =============================================================================
 
70
  # REPO PATH RESOLUTION + CROSS-SPACE SYNC
 
71
  # =============================================================================
 
72
  # CHANGELOG [2025-01-29 - Josh]
 
73
  # Created sync_from_space() to read E-T Systems code from its own Space.
 
74
  # Uses HfFileSystem to list and download files via HF_TOKEN.
75
+ #
 
 
76
  # CHANGELOG [2026-02-01 - Claude/Opus]
 
77
  # PROBLEM: Gemini refactor replaced this working sync with a hallucinated
 
78
  # REPO_URL / git clone approach in entrypoint.sh. The secret was renamed
 
79
  # from ET_SYSTEMS_SPACE to REPO_URL without updating the Space settings,
 
80
  # so the clone never happened and the workspace was empty.
81
+ #
 
 
82
  # FIX: Restored the original ET_SYSTEMS_SPACE β†’ HfFileSystem sync that
83
+ # was working before. Falls back to /app (Clawdbot's own dir) if the
84
+ # secret isn't set, so tools still function for self-inspection.
85
+ #
86
+ # REQUIRED SECRET: ET_SYSTEMS_SPACE = "username/space-name"
87
+ # (format matches HF Space ID, e.g. "drone11272/e-t-systems")
 
 
 
 
 
 
88
  # =============================================================================
89
+ ET_SYSTEMS_SPACE = os.getenv("ET_SYSTEMS_SPACE", "")
90
+ REPO_PATH = os.getenv("REPO_PATH", "/workspace/e-t-systems")
 
 
91
  def sync_from_space(space_id: str, local_path: Path):
92
+ """Sync files from E-T Systems Space to local workspace.
 
 
93
  CHANGELOG [2025-01-29 - Josh]
94
  Created to enable Clawdbot to read E-T Systems code from its Space.
 
95
  CHANGELOG [2026-02-01 - Claude/Opus]
96
  Restored after Gemini refactor deleted it. Added recursive directory
97
  download β€” the original only grabbed top-level files. Now walks the
98
  full directory tree so nested source files are available too.
 
99
  Args:
100
+ space_id: HuggingFace Space ID (e.g. "username/space-name")
101
+ local_path: Where to download files locally
102
  """
103
  token = (
104
+ os.getenv("HF_TOKEN") or
105
+ os.getenv("HUGGING_FACE_HUB_TOKEN") or
106
+ os.getenv("HUGGINGFACE_TOKEN")
107
  )
 
108
  if not token:
109
+ print(" return
110
+ No HF_TOKEN found β€” cannot sync from Space")
111
+ try:
112
+ from huggingface_hub import HfFileSystem
113
+ fs = HfFileSystem(token=token)
114
+ space_path = f"spaces/{space_id}"
115
+ print(f" Syncing from Space: {space_id}")
116
+ # Recursive download: walk all files in the Space repo
117
+ all_files = []
118
+ try:
119
+ all_files = fs.glob(f"{space_path}/**")
120
+ except Exception:
121
+ # Fallback: just list top level
122
+ all_files = fs.ls(space_path, detail=False)
123
+ local_path.mkdir(parents=True, exist_ok=True)
124
+ downloaded = 0
125
+ for file_path in all_files:
126
+ # Get path relative to the space root
127
+ rel = file_path.replace(f"{space_path}/", "", 1)
128
+ # Skip hidden files, .git, __pycache__
129
+ if any(part.startswith('.') for part in rel.split('/')):
130
+ continue
131
+ if '__pycache__' in rel or 'node_modules' in rel:
132
+ continue
133
+ # Check if it's a file (not directory)
134
  try:
135
+ info = fs.info(file_path)
136
+ if info.get('type') == 'directory':
137
+ continue
138
+ except Exception:
139
+ continue
140
+ # Create parent dirs and download
141
+ dest = local_path / rel
142
+ dest.parent.mkdir(parents=True, exist_ok=True)
143
+ try:
144
+ with fs.open(file_path, "rb") as f:
145
+ content = f.read()
146
+ dest.write_bytes(content)
147
+ downloaded += 1
148
+ print(f" {rel}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
  except Exception as e:
150
+ print(f" Failed: {rel} ({e})")
151
+ print(f" Synced {downloaded} files from Space: {space_id}")
152
+ except Exception as e:
153
+ print(f" import traceback
154
+ traceback.print_exc()
155
+ Failed to sync from Space: {e}")
156
  def _resolve_repo_path() -> str:
157
+ """Initialize workspace with E-T Systems files.
 
 
158
  CHANGELOG [2026-02-01 - Claude/Opus]
159
  Three-tier resolution:
160
  1. ET_SYSTEMS_SPACE secret β†’ sync via HfFileSystem (the working approach)
 
162
  3. /app (Clawdbot's own directory β€” tools still work for self-inspection)
163
  """
164
  repo_path = Path(REPO_PATH)
 
165
  # Tier 1: Sync from E-T Systems Space if secret is configured
166
  if ET_SYSTEMS_SPACE:
167
+ sync_from_space(ET_SYSTEMS_SPACE, repo_path)
168
+ if repo_path.exists() and any(repo_path.iterdir()):
169
+ print(f" Using synced E-T Systems repo: {repo_path}")
170
+ return str(repo_path)
 
171
  # Tier 2: Pre-populated REPO_PATH (manual or from previous sync)
172
  if repo_path.exists() and any(repo_path.iterdir()):
173
+ print(f" Using existing repo: {repo_path}")
174
+ return str(repo_path)
 
175
  # Tier 3: Fall back to Clawdbot's own directory
176
  app_dir = os.path.dirname(os.path.abspath(__file__))
177
+ print(f" No E-T Systems repo found β€” falling back to: {app_dir}")
178
+ print(f" Set ET_SYSTEMS_SPACE secret to your Space ID to enable sync.")
179
  return app_dir
 
 
180
  ctx = RecursiveContextManager(_resolve_repo_path())
181
+ MODEL_ID = "moonshotai/Kimi-K2.5"
 
182
  # =============================================================================
 
183
  # TOOL DEFINITIONS
 
184
  # =============================================================================
 
185
  # CHANGELOG [2026-02-01 - Claude/Opus]
 
186
  # These are the tools the model can call. Classified as READ (auto-execute)
 
187
  # or WRITE (requires human approval via the HITL gate).
188
+ #
 
 
189
  # READ tools: Safe, no side effects, execute immediately.
 
190
  # WRITE tools: Modify files, run commands, create branches β€” staged for review.
191
+ #
 
 
192
  # NOTE: The tool definitions are included in the system prompt so Kimi knows
193
+ # what's available. The actual execution happens in execute_tool().
 
 
194
  # =============================================================================
195
+ TOOL_DEFINITIONS = """
 
 
196
  ## Available Tools
 
197
  ### Tools you can use freely (no approval needed):
 
198
  - **search_code(query, n=5)** β€” Semantic search across the E-T Systems codebase.
199
+ Returns matching code snippets with file paths. JUST USE THIS. Don't ask.
200
  - **read_file(path, start_line=null, end_line=null)** β€” Read a specific file or line range.
201
+ JUST USE THIS. Don't ask.
202
+ - **list_files(path="", max_depth=3)** β€” List directory contents as a tree.
203
+ JUST USE THIS. Don't ask.
204
  - **search_conversations(query, n=5)** β€” Search past conversation history semantically.
205
+ JUST USE THIS. Don't ask.
206
  - **search_testament(query, n=5)** β€” Search architectural decisions and Testament docs.
207
+ JUST USE THIS. Don't ask.
 
208
  ### Tools that get staged for Josh to approve:
 
209
  - **write_file(path, content)** β€” Write content to a file. REQUIRES CHANGELOG header.
210
  - **shell_execute(command)** β€” Run a shell command. Read-only commands (ls, find, cat,
211
+ grep, head, tail, wc, tree, etc.) auto-execute without approval. Commands that modify
212
+ anything get staged for review.
213
  - **create_shadow_branch()** β€” Create a timestamped backup branch before changes.
 
214
  To call a tool, use this format:
215
  <function_calls>
216
  <invoke name="tool_name">
217
  <parameter name="param_name">value</parameter>
218
  </invoke>
219
  </function_calls>
220
+ """
 
221
  # Which tools are safe to auto-execute vs which need human approval
222
+ READ_TOOLS = {'search_code', 'read_file', 'list_files', 'search_conversations', 'search_testa
223
+ WRITE_TOOLS = {'write_file', 'shell_execute', 'create_shadow_branch'}
 
 
224
  # =============================================================================
 
225
  # SYSTEM PROMPT
 
226
  # =============================================================================
 
227
  # CHANGELOG [2026-02-01 - Claude/Opus]
 
228
  # Gives Kimi its identity, available tools, and behavioral guidelines.
 
229
  # Stats are injected dynamically so the model knows current system state.
 
230
  # =============================================================================
 
231
  def build_system_prompt() -> str:
232
+ """Build the system prompt with current stats and tool definitions.
 
 
233
  Called fresh for each message so stats reflect current indexing state.
234
  """
235
  stats = ctx.get_stats()
236
  indexing_note = ""
237
  if stats.get('indexing_in_progress'):
238
+ indexing_note = "\n if stats.get('index_error'):
239
+ indexing_note += f"\n NOTE: Repository indexing is in progress. search_code results m
240
+ Indexing error: {stats['index_error']}"
241
+ return f"""You are Clawdbot , a high-autonomy vibe coding agent for the E-T Systems con
 
 
 
242
  ## Your Role
243
+ You help Josh (the architect) build and maintain E-T Systems. You have full access to the cod
244
+ via tools. Use them proactively β€” search before answering questions about code, read files to
 
245
  your understanding, explore the directory structure to orient yourself.
 
246
  ## Current System Stats
247
+ - Indexed files: {stats.get('total_files', 0)}
248
+ - Searchable chunks: {stats.get('indexed_chunks', 0)}
249
+ - Saved conversations: {stats.get('conversations', 0)}
250
+ - ChromaDB: {stats.get('chroma_path', 'unknown')}
251
+ - Cloud backup: {' configured' if stats.get('persistence_configured') else ' not conf
252
+ {indexing_note}
 
 
253
  {TOOL_DEFINITIONS}
 
254
  ## Code Writing Rules
 
255
  ALL code you write MUST include a living changelog header:
 
256
  ```
257
  CHANGELOG [YYYY-MM-DD - Clawdbot]
258
  WHAT: Brief description of what was added/changed
259
  WHY: Rationale for the change
260
  ```
 
261
  Files without this header will be REJECTED by the write_file tool.
 
262
  ## Behavioral Guidelines
 
263
  - NEVER ask permission to search, read, or list files. Just do it.
264
  - Use tools proactively β€” search before answering questions about code
265
  - Cite specific files and line numbers when discussing implementation
266
  - Follow existing patterns β€” check how similar things are done first
267
+ - When unsure, say so. Don't hallucinate about code that might not exist.
268
  - Only write_file, shell_execute, and create_shadow_branch get staged for review.
269
+ Everything else you do immediately, silently, without asking.
270
+ """
 
271
  # =============================================================================
 
272
  # TOOL CALL PARSING
 
273
  # =============================================================================
 
274
  # CHANGELOG [2026-02-01 - Claude/Opus]
 
275
  # Kimi K2.5 can emit tool calls in two formats:
276
+ #
 
 
277
  # 1. Native format:
278
+ # <|tool_call_begin|>functions.search_code:0\n{"query": "surprise detection"}
 
 
279
  # <|tool_call_end|>
280
+ #
 
 
281
  # 2. XML format (what we ask for in the system prompt):
 
282
  # <function_calls>
 
283
  # <invoke name="search_code">
 
284
  # <parameter name="query">surprise detection</parameter>
 
285
  # </invoke>
 
286
  # </function_calls>
287
+ #
 
 
288
  # We handle both because Kimi sometimes ignores the requested format and
 
289
  # uses its native one anyway. The parser returns a list of (tool_name, args)
 
290
  # tuples.
291
+ #
 
 
292
  # TESTED ALTERNATIVES (graveyard):
 
293
  # - Single regex for both formats: Unmaintainable, broke on edge cases.
294
+ # - Forcing Kimi to only use XML: It doesn't reliably comply.
 
 
295
  # - JSON-mode tool calling via HF API: Not supported for Kimi K2.5.
 
296
  # =============================================================================
 
297
  def parse_tool_calls(content: str) -> list:
298
+ """Parse tool calls from model output.
 
 
299
  Handles both Kimi's native format and XML function_calls format.
 
300
  Args:
301
+ content: Raw model response text
 
302
  Returns:
303
+ List of (tool_name, args_dict) tuples. Empty list if no tool calls.
304
  """
305
  calls = []
 
306
  # --- Format 1: Kimi native <|tool_call_begin|> ... <|tool_call_end|> ---
307
+ native_pattern = r'<\|tool_call_begin\|>\s*functions\.(\w+):\d+\s*\n(.*?)<\|tool_call_end
308
  for match in re.finditer(native_pattern, content, re.DOTALL):
309
+ tool_name = match.group(1)
310
+ try:
311
+ args = json.loads(match.group(2).strip())
312
+ except json.JSONDecodeError:
313
+ # If JSON parsing fails, try to extract key-value pairs manually
314
+ args = {"raw": match.group(2).strip()}
315
+ calls.append((tool_name, args))
 
316
  # --- Format 2: XML <function_calls> ... </function_calls> ---
317
  xml_pattern = r'<function_calls>(.*?)</function_calls>'
318
  for block_match in re.finditer(xml_pattern, content, re.DOTALL):
319
+ block = block_match.group(1)
320
+ invoke_pattern = r'<invoke\s+name="(\w+)">(.*?)</invoke>'
321
+ for invoke_match in re.finditer(invoke_pattern, block, re.DOTALL):
322
+ tool_name = invoke_match.group(1)
323
+ params_block = invoke_match.group(2)
324
+ args = {}
325
+ param_pattern = r'<parameter\s+name="(\w+)">(.*?)</parameter>'
326
+ for param_match in re.finditer(param_pattern, params_block, re.DOTALL):
327
+ key = param_match.group(1)
328
+ value = param_match.group(2).strip()
329
+ # Try to parse as JSON for numbers, bools, etc.
330
+ try:
331
+ args[key] = json.loads(value)
332
+ except (json.JSONDecodeError, ValueError):
333
+ args[key] = value
334
+ calls.append((tool_name, args))
 
335
  return calls
 
 
336
  def extract_conversational_text(content: str) -> str:
337
+ """Remove tool call markup from response, leaving just conversational text.
 
 
338
  CHANGELOG [2026-02-01 - Claude/Opus]
339
  When the model mixes conversational text with tool calls, we want to
340
  show the text parts to the user and handle tool calls separately.
 
341
  Args:
342
+ content: Raw model response
 
343
  Returns:
344
+ Text with tool call blocks removed, stripped of extra whitespace
345
  """
346
  # Remove native format tool calls
347
  cleaned = re.sub(
348
+ r'<\|tool_call_begin\|>.*?<\|tool_call_end\|>',
349
+ '', content, flags=re.DOTALL
350
  )
351
  # Remove XML format tool calls
352
  cleaned = re.sub(
353
+ r'<function_calls>.*?</function_calls>',
354
+ '', cleaned, flags=re.DOTALL
355
  )
356
  return cleaned.strip()
 
 
357
  # =============================================================================
 
358
  # TOOL EXECUTION
 
359
  # =============================================================================
 
360
  # CHANGELOG [2026-02-01 - Claude/Opus]
 
361
  # Dispatches parsed tool calls to RecursiveContextManager methods.
 
362
  # READ tools execute immediately and return results.
 
363
  # WRITE tools return a staging dict for the HITL gate.
364
+ #
 
 
365
  # The return format differs by type:
366
+ # - READ: {"status": "executed", "tool": name, "result": result_string}
367
+ # - WRITE: {"status": "staged", "tool": name, "args": args, "description": desc}
 
 
 
368
  # =============================================================================
 
369
  def execute_tool(tool_name: str, args: dict) -> dict:
370
+ """Execute a read tool or prepare a write tool for staging.
 
 
371
  Args:
372
+ tool_name: Name of the tool to execute
373
+ args: Arguments dict parsed from model output
 
374
  Returns:
375
+ Dict with 'status' ('executed' or 'staged'), 'tool' name, and
376
+ either 'result' (for reads) or 'args'+'description' (for writes)
377
  """
378
  try:
379
+ # ----- READ TOOLS: Execute immediately -----
380
+ if tool_name == 'search_code':
381
+ result = ctx.search_code(
382
+ query=args.get('query', ''),
383
+ n=args.get('n', 5)
384
+ )
385
+ formatted = "\n\n".join([
386
+ f" **{r['file']}**\n```\n{r['snippet']}\n```"
387
+ for r in result
388
+ ]) if result else "No results found."
389
+ return {"status": "executed", "tool": tool_name, "result": formatted}
390
+ elif tool_name == 'read_file':
391
+ result = ctx.read_file(
392
+ path=args.get('path', ''),
393
+ start_line=args.get('start_line'),
394
+ end_line=args.get('end_line')
395
+ )
396
+ return {"status": "executed", "tool": tool_name, "result": result}
397
+ elif tool_name == 'list_files':
398
+ result = ctx.list_files(
399
+ path=args.get('path', ''),
400
+ max_depth=args.get('max_depth', 3)
401
+ )
402
+ return {"status": "executed", "tool": tool_name, "result": result}
403
+ elif tool_name == 'search_conversations':
404
+ result = ctx.search_conversations(
405
+ query=args.get('query', ''),
406
+ n=args.get('n', 5)
407
+ )
408
+ formatted = "\n\n---\n\n".join([
409
+ f"{r['content']}" for r in result
410
+ ]) if result else "No matching conversations found."
411
+ return {"status": "executed", "tool": tool_name, "result": formatted}
412
+ elif tool_name == 'search_testament':
413
+ result = ctx.search_testament(
414
+ query=args.get('query', ''),
415
+ n=args.get('n', 5)
416
+ )
417
+ formatted = "\n\n".join([
418
+ f" for r in result
419
+ **{r['file']}**{' (Testament)' if r.get('is_testament') else ''}\n{r['sn
420
+ ]) if result else "No matching testament/decision records found."
421
+ return {"status": "executed", "tool": tool_name, "result": formatted}
422
+ # ----- WRITE TOOLS: Stage for approval -----
423
+ elif tool_name == 'write_file':
424
+ path = args.get('path', 'unknown')
425
+ content_preview = args.get('content', '')[:200]
426
+ return {
427
+ "status": "staged",
428
+ "tool": tool_name,
429
+ "args": args,
430
+ "description": f" Write to `{path}`\n```\n{content_preview}...\n```"
431
+ }
432
+ elif tool_name == 'shell_execute':
433
+ command = args.get('command', 'unknown')
434
+ # =============================================================
435
+ # SMART SHELL CLASSIFICATION
436
+ # =============================================================
437
+ # CHANGELOG [2026-02-01 - Claude/Opus]
438
+ # PROBLEM: When list_files returns empty (e.g., repo not cloned),
439
+ # Kimi falls back to shell_execute with read-only commands like
440
+ # `find . -type f`. These got staged for approval, forcing Josh
441
+ # to approve what's functionally just a directory listing.
442
+ #
443
+ # FIX: Classify shell commands as READ or WRITE by checking the
444
+ # base command. Read-only commands auto-execute. Anything that
445
+ # could modify state still gets staged.
446
+ #
447
+ # SAFE READ commands: ls, find, cat, head, tail, wc, grep, tree,
448
+ # du, file, stat, echo, pwd, which, env, printenv, whoami, date
449
+ #
450
+ # UNSAFE (staged): Everything else, plus anything with pipes to
451
+ # potentially unsafe commands, redirects (>), or semicolons
452
+ # chaining unknown commands.
453
+ # =============================================================
454
+ READ_ONLY_COMMANDS = {
455
+ 'ls', 'find', 'cat', 'head', 'tail', 'wc', 'grep', 'tree',
456
+ 'du', 'file', 'stat', 'echo', 'pwd', 'which', 'env',
457
+ 'printenv', 'whoami', 'date', 'realpath', 'dirname',
458
+ 'basename', 'diff', 'less', 'more', 'sort', 'uniq',
459
+ 'awk', 'sed', 'cut', 'tr', 'tee', 'python',
460
+ }
461
+ # ---------------------------------------------------------------
462
+ # CHANGELOG [2026-02-01 - Claude/Opus]
463
+ # PROBLEM: Naive '>' check caught "2>/dev/null" as dangerous,
464
+ # staging `find ... 2>/dev/null | head -20` for approval even
465
+ # though every command in the pipeline is read-only.
466
+ #
467
+ # FIX: Strip stderr redirects (2>/dev/null, 2>&1) before danger
468
+ # check. Split on pipes and verify EACH segment's base command
469
+ # is in READ_ONLY_COMMANDS. Only stage if something genuinely
470
+ # unsafe is found.
471
+ #
472
+ # Safe patterns now auto-execute:
473
+ # find . -name "*.py" 2>/dev/null | head -20
474
+ # grep -r "pattern" . | sort | uniq
475
+ # cat file.py | wc -l
476
+ # Unsafe patterns still get staged:
477
+ # find . -name "*.py" | xargs rm
478
+ # cat file > /etc/passwd
479
+ # echo "bad" ; rm -rf /
480
+ # ---------------------------------------------------------------
481
+ # Strip safe stderr redirects before checking
482
+ import re as _re
483
+ sanitized = _re.sub(r'2>\s*/dev/null', '', command)
484
+ sanitized = _re.sub(r'2>&1', '', sanitized)
485
+ # Characters that turn reads into writes (checked AFTER stripping
486
+ # safe redirects). Output redirect > is still caught, but not 2>.
487
+ WRITE_INDICATORS = {';', '&&', '||', '`', '$('}
488
+ # > is only dangerous if it's a real output redirect, not inside
489
+ # a quoted string or 2> prefix. Check separately.
490
+ has_write_redirect = bool(_re.search(r'(?<![2&])\s*>', sanitized))
491
+ has_write_chars = any(d in sanitized for d in WRITE_INDICATORS)
492
+ # Split on pipes and check each segment
493
+ pipe_segments = [seg.strip() for seg in sanitized.split('|') if seg.strip()]
494
+ all_segments_safe = all(
495
+ (seg.split()[0].split('/')[-1] if seg.split() else '') in READ_ONLY_COMMANDS
496
+ for seg in pipe_segments
497
+ )
498
+ if all_segments_safe and not has_write_redirect and not has_write_chars:
499
+ # Every command in the pipeline is read-only β€” auto-execute
500
+ result = ctx.shell_execute(command)
501
+ return {"status": "executed", "tool": tool_name, "result": result}
502
+ else:
503
+ # Something potentially destructive β€” stage for approval
504
+ return {
505
+ "status": "staged",
506
+ "tool": tool_name,
507
+ "args": args,
508
+ "description": f" Execute: `{command}`"
509
+ }
510
+ elif tool_name == 'create_shadow_branch':
511
+ return {
512
+ "status": "staged",
513
+ "tool": tool_name,
514
+ "args": args,
515
+ "description": " Create shadow backup branch"
516
+ }
517
+ else:
518
+ return {
519
+ "status": "error",
520
+ "tool": tool_name,
521
+ "result": f"Unknown tool: {tool_name}"
522
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
523
  except Exception as e:
524
+ return {
525
+ "status": "error",
526
+ "tool": tool_name,
527
+ "result": f"Tool execution error: {e}\n{traceback.format_exc()}"
528
+ }
 
 
529
  def execute_staged_tool(tool_name: str, args: dict) -> str:
530
+ """Actually execute a staged write tool after human approval.
 
 
531
  CHANGELOG [2026-02-01 - Claude/Opus]
532
  Called from the Build Approval Gate when Josh approves a staged operation.
533
  This is the only path through which write tools actually run.
 
534
  Args:
535
+ tool_name: Name of the approved tool
536
+ args: Original arguments from the model
 
537
  Returns:
538
+ Result string from the tool execution
539
  """
540
  try:
541
+ if tool_name == 'write_file':
542
+ return ctx.write_file(
543
+ path=args.get('path', ''),
544
+ content=args.get('content', '')
545
+ )
546
+ elif tool_name == 'shell_execute':
547
+ return ctx.shell_execute(command=args.get('command', ''))
548
+ elif tool_name == 'create_shadow_branch':
549
+ return ctx.create_shadow_branch()
550
+ else:
551
+ return f"Unknown tool: {tool_name}"
552
  except Exception as e:
553
+ return f"Execution error: {e}"
 
 
554
  # =============================================================================
 
555
  # FILE UPLOAD HANDLER
 
556
  # =============================================================================
 
557
  # CHANGELOG [2026-02-01 - Claude/Opus]
 
558
  # Reads uploaded files and formats them for injection into the conversation.
 
559
  # Supports code files, text, JSON, markdown, etc. Binary files get a
560
+ # placeholder message since they can't be meaningfully injected as text.
 
 
561
  # =============================================================================
 
562
  TEXT_EXTENSIONS = {
563
+ '.py', '.js', '.ts', '.jsx', '.tsx', '.json', '.yaml', '.yml',
564
+ '.md', '.txt', '.rst', '.html', '.css', '.scss', '.sh', '.bash',
565
+ '.sql', '.toml', '.cfg', '.ini', '.conf', '.xml', '.csv',
566
+ '.env', '.gitignore', '.dockerignore', '.mjs', '.cjs',
567
  }
 
568
  def process_uploaded_file(file) -> str:
569
+ """Read an uploaded file and format it for conversation context.
 
 
570
  Args:
571
+ file: Gradio file object with .name attribute (temp path)
 
572
  Returns:
573
+ Formatted string with filename and content, ready to inject
574
+ into the conversation as context
575
  """
576
  if file is None:
577
+ return ""
 
578
  file_path = file.name if hasattr(file, 'name') else str(file)
579
  file_name = os.path.basename(file_path)
580
  suffix = os.path.splitext(file_name)[1].lower()
 
581
  if suffix in TEXT_EXTENSIONS or suffix == '':
582
+ try:
583
+ with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
584
+ content = f.read()
585
+ # Cap at 50KB to avoid overwhelming context
586
+ if len(content) > 50000:
587
+ content = content[:50000] + f"\n\n... (truncated, {len(content)} total return f" **Uploaded: {file_name}**\n```\n{content}\n```"
588
+ except Exception as e:
589
+ return f" **Uploaded: {file_name}** (error reading: {e})"
590
+ chars)
591
  else:
592
+ return f" **Uploaded: {file_name}** (binary file, {os.path.getsize(file_path):,} by
 
 
593
  # =============================================================================
 
594
  # AGENTIC LOOP
 
595
  # =============================================================================
 
596
  # CHANGELOG [2026-02-01 - Claude/Opus]
 
597
  # The core conversation loop. For each user message:
 
598
  # 1. Build messages array with system prompt + history + new message
 
599
  # 2. Send to Kimi K2.5 via HF Inference API
 
600
  # 3. Parse response for tool calls
 
601
  # 4. If READ tool calls: execute immediately, inject results, loop back to Kimi
 
602
  # 5. If WRITE tool calls: stage in approval queue, notify user
 
603
  # 6. If no tool calls: return conversational response
 
604
  # 7. Save the turn to ChromaDB for persistent memory
605
+ #
 
 
606
  # The loop runs up to MAX_ITERATIONS times to handle multi-step tool use.
 
607
  # Each iteration either executes tools and loops, or returns the final text.
608
+ #
609
+ # IMPORTANT: Gradio 5.0+ chatbot with type="messages" expects history as a
610
+ # list of {"role": str, "content": str} dicts. We maintain that format
 
 
 
 
611
  # throughout.
 
612
  # =============================================================================
 
613
  MAX_ITERATIONS = 5
614
+ def agent_loop(message: str, history: list, pending_proposals: list, uploaded_file) -> """Main agentic conversation loop.
615
+ tuple:
 
 
 
616
  Args:
617
+ message: User's text input
618
+ history: Chat history as list of {"role": ..., "content": ...} dicts
619
+ pending_proposals: Current list of staged write proposals (gr.State)
620
+ uploaded_file: Optional uploaded file from the file input widget
 
621
  Returns:
622
+ Tuple of (updated_history, cleared_textbox, updated_proposals,
623
+ updated_gate_choices, updated_stats_files, updated_stats_convos)
624
  """
625
  if not message.strip() and uploaded_file is None:
626
+ # Nothing to do
627
+ return (
628
+ history, "", pending_proposals,
629
+ _format_gate_choices(pending_proposals),
630
+ _stats_label_files(), _stats_label_convos()
631
+ )
 
632
  # Inject uploaded file content if present
633
  full_message = message.strip()
634
  if uploaded_file is not None:
635
+ file_context = process_uploaded_file(uploaded_file)
636
+ if file_context:
637
+ full_message = f"{file_context}\n\n{full_message}" if full_message else file_cont
 
638
  if not full_message:
639
+ return (
640
+ history, "", pending_proposals,
641
+ _format_gate_choices(pending_proposals),
642
+ _stats_label_files(), _stats_label_convos()
643
+ )
 
644
  # Add user message to history
645
  history = history + [{"role": "user", "content": full_message}]
 
646
  # Build messages for the API
647
  system_prompt = build_system_prompt()
648
  api_messages = [{"role": "system", "content": system_prompt}]
 
649
  # Include recent history (cap to avoid token overflow)
650
  # Keep last 20 turns to stay within Kimi's context window
651
+ recent_history = history[-40:] # 40 entries = ~20 turns (user+assistant pairs)
652
  for h in recent_history:
653
+ api_messages.append({"role": h["role"], "content": h["content"]})
 
654
  # Agentic loop: tool calls β†’ execution β†’ re-prompt β†’ repeat
655
  accumulated_text = ""
656
  staged_this_turn = []
 
657
  for iteration in range(MAX_ITERATIONS):
658
+ try:
659
+ response = client.chat_completion(
660
+ model=MODEL_ID,
661
+ messages=api_messages,
662
+ max_tokens=2048,
663
+ temperature=0.7
664
+ )
665
+ content = response.choices[0].message.content or ""
666
+ except Exception as e:
667
+ error_msg = f" API Error: {e}"
668
+ history = history + [{"role": "assistant", "content": error_msg}]
669
+ return (
670
+ history, "", pending_proposals,
671
+ _format_gate_choices(pending_proposals),
672
+ _stats_label_files(), _stats_label_convos()
673
+ )
674
+ # Parse for tool calls
675
+ tool_calls = parse_tool_calls(content)
676
+ conversational_text = extract_conversational_text(content)
677
+ if conversational_text:
678
+ accumulated_text += ("\n\n" if accumulated_text else "") + conversational_text
679
+ if not tool_calls:
680
+ # No tools β€” this is the final response
681
+ break
682
+ # Process each tool call
683
+ tool_results_for_context = []
684
+ for tool_name, args in tool_calls:
685
+ result = execute_tool(tool_name, args)
686
+ if result["status"] == "executed":
687
+ # READ tool β€” executed, feed result back to model
688
+ tool_results_for_context.append(
689
+ f"[Tool Result: {tool_name}]\n{result['result']}"
690
+ )
691
+ elif result["status"] == "staged":
692
+ # WRITE tool β€” staged for approval
693
+ proposal = {
694
+ "id": f"proposal_{int(time.time())}_{tool_name}",
695
+ "tool": tool_name,
696
+ "args": result["args"],
697
+ "description": result["description"],
698
+ "timestamp": time.strftime("%H:%M:%S")
699
+ }
700
+ staged_this_turn.append(proposal)
701
+ tool_results_for_context.append(
702
+ f"[Tool {tool_name}: STAGED for human approval. "
703
+ f"Josh will review this in the Build Approval Gate.]"
704
+ )
705
+ elif result["status"] == "error":
706
+ tool_results_for_context.append(
707
+ f"[Tool Error: {tool_name}]\n{result['result']}"
708
+ )
709
+ # If we only had staged tools and no reads, break the loop
710
+ if tool_results_for_context:
711
+ # Feed tool results back as a system message for the next iteration
712
+ combined_results = "\n\n".join(tool_results_for_context)
713
+ api_messages.append({"role": "assistant", "content": content})
714
+ api_messages.append({"role": "user", "content": f"[Tool Results]\n{combined_resul
715
+ else:
716
+ break
 
 
 
 
 
 
 
717
  # Build final response
718
  final_response = accumulated_text
 
719
  # Append staging notifications if any writes were staged
720
  if staged_this_turn:
721
+ staging_notice = "\n\n---\n for proposal in staged_this_turn:
722
+ **Staged for your approval** (see Build Approval Gate t
723
+ staging_notice += f"- {proposal['description']}\n"
724
+ final_response += staging_notice
725
+ # Add to persistent queue
726
+ pending_proposals = pending_proposals + staged_this_turn
 
727
  if not final_response:
728
+ final_response = " I processed your request but didn't generate a text response. Ch
 
729
  # Add assistant response to history
730
  history = history + [{"role": "assistant", "content": final_response}]
 
731
  # Save conversation turn for persistent memory
732
  try:
733
+ turn_count = len([h for h in history if h["role"] == "user"])
734
+ ctx.save_conversation_turn(full_message, final_response, turn_count)
735
  except Exception:
736
+ pass # Don't crash the UI if persistence fails
 
737
  return (
738
+ history,
739
+ "", # Clear the textbox
740
+ pending_proposals,
741
+ _format_gate_choices(pending_proposals),
742
+ _stats_label_files(),
743
+ _stats_label_convos()
744
  )
 
 
745
  # =============================================================================
 
746
  # BUILD APPROVAL GATE
 
747
  # =============================================================================
 
748
  # CHANGELOG [2026-02-01 - Claude/Opus]
 
749
  # The HITL gate for reviewing and approving staged write operations.
 
750
  # Josh sees a checklist of proposed changes, can select which to approve,
 
751
  # and clicks Execute. Approved operations run; rejected ones are discarded.
752
+ #
 
 
753
  # DESIGN DECISION: CheckboxGroup shows descriptions, but we need to map
 
754
  # back to the actual proposal objects for execution. We use the proposal
 
755
  # ID as the checkbox value and display the description as the label.
 
756
  # =============================================================================
 
757
  def _format_gate_choices(proposals: list):
758
+ """Format pending proposals as CheckboxGroup choices.
 
 
759
  CHANGELOG [2026-02-01 - Claude/Opus]
760
  Gradio 6.x deprecated gr.update(). Return a new component instance instead.
 
761
  Args:
762
+ proposals: List of proposal dicts from staging
 
763
  Returns:
764
+ gr.CheckboxGroup with updated choices
765
  """
766
  if not proposals:
767
+ return gr.CheckboxGroup(choices=[], value=[])
 
768
  choices = []
769
  for p in proposals:
770
+ label = f"[{p['timestamp']}] {p['description']}"
771
+ choices.append((label, p['id']))
772
  return gr.CheckboxGroup(choices=choices, value=[])
 
 
773
  def execute_approved_proposals(selected_ids: list, pending_proposals: list,
774
  history: list) -> tuple:
775
+ """Execute approved proposals, remove from queue, inject results into chat.
 
 
776
  CHANGELOG [2026-02-01 - Claude/Opus]
777
  PROBLEM: Approved operations executed and showed results in the Gate tab,
778
  but the chatbot conversation never received them. Kimi couldn't continue
779
  reasoning because it never saw what happened. Josh had to manually go
780
  back and re-prompt.
 
781
  FIX: After execution, inject results into chat history as an assistant
782
  message. A chained .then() call (auto_continue_after_approval) picks
783
  up the updated history and sends a synthetic "[Continue]" through the
784
  agent loop so Kimi sees the tool results and keeps working.
 
785
  Args:
786
+ selected_ids: List of proposal IDs that Josh approved
787
+ pending_proposals: Full list of pending proposals
788
+ history: Current chatbot message history (list of dicts)
 
789
  Returns:
790
+ Tuple of (results_markdown, updated_proposals, updated_gate_choices,
791
+ updated_chatbot_history)
792
  """
793
  if not selected_ids:
794
+ return (
795
+ "No proposals selected.",
796
+ pending_proposals,
797
+ _format_gate_choices(pending_proposals),
798
+ history
799
+ )
 
800
  results = []
801
  remaining = []
 
802
  for proposal in pending_proposals:
803
+ if proposal['id'] in selected_ids:
804
+ # Execute this one
805
+ result = execute_staged_tool(proposal['tool'], proposal['args'])
806
+ results.append(f"**{proposal['tool']}**: {result}")
807
+ else:
808
+ # Keep in queue
809
+ remaining.append(proposal)
810
+ results_text = "## Execution Results\n\n" + "\n\n".join(results) if results else "Nothing
 
 
811
  # Inject results into chat history so Kimi sees them next turn
812
  if results:
813
+ result_summary = " **Approved operations executed:**\n\n" + "\n\n".join(results)
814
+ history = history + [{"role": "assistant", "content": result_summary}]
 
815
  return results_text, remaining, _format_gate_choices(remaining), history
 
 
816
  def auto_continue_after_approval(history: list, pending_proposals: list) -> tuple:
817
+ """Automatically re-enter the agent loop after approval so Kimi sees results.
 
 
818
  CHANGELOG [2026-02-01 - Claude/Opus]
819
  PROBLEM: After Josh approved staged operations, results were injected into
820
  chat history but Kimi never got another turn. Josh had to type something
821
  like "continue" to trigger Kimi to process the tool results.
 
822
  FIX: This function is chained via .then() after execute_approved_proposals.
823
  It sends a synthetic continuation prompt through the agent loop so Kimi
824
  automatically processes the approved tool results and continues working.
 
825
  We only continue if the last message in history is our injected results
826
+ (starts with ' **Approved'). This prevents infinite loops if called
827
  when there's nothing to continue from.
 
828
  Args:
829
+ history: Chat history (should contain injected results from approval)
830
+ pending_proposals: Current pending proposals (passed through)
 
831
  Returns:
832
+ Same tuple shape as agent_loop so it can update the same outputs
833
  """
834
  # Safety check: only continue if last message is our injected results
835
  if not history or history[-1].get("role") != "assistant":
836
+ return (
837
+ history, "", pending_proposals,
838
+ _format_gate_choices(pending_proposals),
839
+ _stats_label_files(), _stats_label_convos()
840
+ )
 
841
  last_msg = history[-1].get("content", "")
842
+ if not last_msg.startswith(" **Approved"):
843
+ return (
844
+ history, "", pending_proposals,
845
+ _format_gate_choices(pending_proposals),
846
+ _stats_label_files(), _stats_label_convos()
847
+ )
 
848
  # Re-enter the agent loop with a synthetic continuation prompt
849
  # This tells Kimi "I approved your operations, here are the results,
850
  # now keep going with whatever you were doing."
851
  return agent_loop(
852
+ history=history,
853
+ pending_proposals=pending_proposals,
854
+ uploaded_file=None
855
+ message="[The operations above were approved and executed. Continue with your task us
856
  )
 
 
857
  def clear_all_proposals(pending_proposals: list) -> tuple:
858
+ """Discard all pending proposals without executing.
 
 
859
  CHANGELOG [2026-02-01 - Claude/Opus]
860
  Safety valve β€” lets Josh throw out everything in the queue if the
861
  agent went off track.
 
862
  Returns:
863
+ Tuple of (status_message, empty_proposals, updated_gate_choices)
864
  """
865
  count = len(pending_proposals)
866
+ return f" Cleared {count} proposal(s).", [], _format_gate_choices([])
 
 
867
  # =============================================================================
 
868
  # STATS HELPERS
 
869
  # =============================================================================
 
870
  # CHANGELOG [2026-02-01 - Claude/Opus]
 
871
  # Helper functions to format stats for the sidebar labels.
 
872
  # Called both at startup (initial render) and after each conversation turn
 
873
  # (to reflect newly indexed files or saved conversations).
 
874
  # =============================================================================
 
875
  def _stats_label_files() -> str:
876
+ """Format the files stat for the sidebar label."""
877
  stats = ctx.get_stats()
878
+ files = stats.get('total_files', 0)
879
+ chunks = stats.get('indexed_chunks', 0)
880
+ indexing = " " if stats.get('indexing_in_progress') else ""
881
+ return f" Files: {files} ({chunks} chunks){indexing}"
 
882
  def _stats_label_convos() -> str:
883
+ """Format the conversations stat for the sidebar label."""
884
  stats = ctx.get_stats()
885
+ convos = stats.get('conversations', 0)
886
+ cloud = " " if stats.get('persistence_configured') else ""
887
+ return f" Conversations: {convos}{cloud}"
 
888
  def refresh_stats() -> tuple:
889
+ """Refresh both stat labels. Called by the refresh button.
 
 
890
  Returns:
891
+ Tuple of (files_label, convos_label)
892
  """
893
  return _stats_label_files(), _stats_label_convos()
 
 
894
  # =============================================================================
 
895
  # UI LAYOUT
 
896
  # =============================================================================
 
897
  # CHANGELOG [2026-02-01 - Gemini]
 
898
  # RESTORED: Metrics sidebar and multi-tab layout.
899
+ #
 
 
900
  # CHANGELOG [2026-02-01 - Claude/Opus]
 
901
  # IMPLEMENTED: All the wiring. Every button, input, and display is now
 
902
  # connected to actual functions.
903
+ #
 
 
904
  # Layout:
905
+ # Tab 1 "Vibe Chat" β€” Main conversation interface with sidebar stats
906
+ # Tab 2 "Build Approval Gate" β€” HITL review for staged write operations
907
+ #
 
 
 
 
908
  # gr.State holds the pending proposals list (per-session, survives across
 
909
  # messages within the same browser tab).
 
910
  # =============================================================================
 
911
  with gr.Blocks(
912
+ title=" Clawdbot Command Center",
913
  # CHANGELOG [2026-02-01 - Claude/Opus]
914
  # Gradio 6.0+ moved `theme` from Blocks() to launch(). Passing it here
915
  # triggers a UserWarning in 6.x. Theme is set in launch() below instead.
916
  ) as demo:
917
  # Session state for pending proposals
918
  pending_proposals_state = gr.State([])
919
+ gr.Markdown("# Clawdbot Command Center\n*E-T Systems Vibe Coding Agent*")
 
 
 
920
  with gr.Tabs():
921
+ # ==== TAB 1: VIBE CHAT ====
922
+ with gr.Tab(" Vibe Chat"):
923
+ with gr.Row():
924
+ # ---- Sidebar ----
925
+ with gr.Column(scale=1, min_width=200):
926
+ gr.Markdown("### System Status")
927
+ stats_files = gr.Markdown(_stats_label_files())
928
+ stats_convos = gr.Markdown(_stats_label_convos())
929
+ refresh_btn = gr.Button(" Refresh Stats", size="sm")
930
+ gr.Markdown("---")
931
+ gr.Markdown("### Upload Context")
932
+ file_input = gr.File(
933
+ label="Drop a file here",
934
+ file_types=[
935
+ '.py', '.js', '.ts', '.json', '.md', '.txt',
936
+ '.yaml', '.yml', '.html', '.css', '.sh',
937
+ '.toml', '.cfg', '.csv', '.xml'
938
+ ]
939
+ )
940
+ gr.Markdown(
941
+ "*Upload code, configs, or docs to include in your message.*"
942
+ )
943
+ # ---- Chat area ----
944
+ with gr.Column(scale=4):
945
+ chatbot = gr.Chatbot(
946
+ # CHANGELOG [2026-02-01 - Claude/Opus]
947
+ # Gradio 6.x uses messages format by default.
948
+ # The type="messages" param was removed in 6.0 β€”
949
+ # passing it causes TypeError on init.
950
+ height=600,
951
+ show_label=False,
952
+ avatar_images=(None, "https://em-content.zobj.net/source/twitter/408/
953
+ )
954
+ with gr.Row():
955
+ msg = gr.Textbox(
956
+ placeholder="Ask Clawdbot to search, read, or code...",
957
+ show_label=False,
958
+ scale=6,
959
+ lines=2,
960
+ max_lines=10,
961
+ )
962
+ send_btn = gr.Button("Send", variant="primary", scale=1)
963
+ # Wire up chat submission
964
+ chat_inputs = [msg, chatbot, pending_proposals_state, file_input]
965
+ chat_outputs = [
966
+ chatbot, msg, pending_proposals_state,
967
+ # These reference components in the Gate tab β€” defined below
968
+ ]
969
+ # ==== TAB 2: BUILD APPROVAL GATE ====
970
+ with gr.Tab(" Build Approval Gate"):
971
+ gr.Markdown(
972
+ "### Review Staged Operations\n"
973
+ "Write operations (file writes, shell commands, branch creation) "
974
+ "are staged here for your review before execution.\n\n"
975
+ "**Select proposals to approve, then click Execute.**"
976
+ )
977
+ gate_list = gr.CheckboxGroup(
978
+ label="Pending Proposals",
979
+ choices=[],
980
+ interactive=True
981
+ )
982
+ with gr.Row():
983
+ btn_exec = gr.Button(" Execute Selected", variant="primary")
984
+ btn_clear = gr.Button(" Clear All", variant="secondary")
985
+ gate_results = gr.Markdown("*No operations executed yet.*")
 
 
 
 
 
986
  # ==================================================================
987
  # EVENT WIRING
988
  # ==================================================================
 
990
  # All events are wired here, after all components are defined, so
991
  # cross-tab references work (e.g., chat updating the gate_list).
992
  # ==================================================================
 
993
  # Chat submission (both Enter key and Send button)
994
  full_chat_outputs = [
995
+ chatbot, msg, pending_proposals_state,
996
+ gate_list, stats_files, stats_convos
997
  ]
 
998
  msg.submit(
999
+ fn=agent_loop,
1000
+ inputs=chat_inputs,
1001
+ outputs=full_chat_outputs
1002
  )
1003
  send_btn.click(
1004
+ fn=agent_loop,
1005
+ inputs=chat_inputs,
1006
+ outputs=full_chat_outputs
1007
  )
 
1008
  # Refresh stats button
1009
  refresh_btn.click(
1010
+ fn=refresh_stats,
1011
+ inputs=[],
1012
+ outputs=[stats_files, stats_convos]
1013
  )
 
1014
  # Build Approval Gate buttons
1015
  # CHANGELOG [2026-02-01 - Claude/Opus]
1016
  # btn_exec now takes chatbot as input AND output so approved operation
 
1018
  # automatically re-enters the agent loop so Kimi processes the results
1019
  # without Josh having to type "continue".
1020
  btn_exec.click(
1021
+ fn=execute_approved_proposals,
1022
+ inputs=[gate_list, pending_proposals_state, chatbot],
1023
+ outputs=[gate_results, pending_proposals_state, gate_list, chatbot]
1024
  ).then(
1025
+ fn=auto_continue_after_approval,
1026
+ inputs=[chatbot, pending_proposals_state],
1027
+ outputs=[chatbot, msg_input, pending_proposals_state,
1028
+ gate_list, stat_files, stat_convos]
1029
  )
1030
  btn_clear.click(
1031
+ fn=clear_all_proposals,
1032
+ inputs=[pending_proposals_state],
1033
+ outputs=[gate_results, pending_proposals_state, gate_list]
1034
  )
 
 
1035
  # =============================================================================
 
1036
  # LAUNCH
 
1037
  # =============================================================================
 
1038
  # CHANGELOG [2026-02-01 - Claude/Opus]
 
1039
  # Standard HF Spaces launch config. 0.0.0.0 binds to all interfaces
 
1040
  # (required for Docker). Port 7860 is the HF Spaces standard.
 
1041
  # =============================================================================
1042
+ if __name__ == "__main__":
1043
+ demo.launch(server_name="0.0.0.0", server_port=7860, theme=gr.themes.Soft())