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

Update app.py

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