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

Update app.py

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