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

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +1223 -0
app.py ADDED
@@ -0,0 +1,1223 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Clawdbot Unified Command Center
3
+
4
+ CHANGELOG [2026-02-01 - Gemini]
5
+ RESTORED: Full Kimi K2.5 Agentic Loop (no more silence).
6
+ ADDED: Full Developer Tool Suite (Write, Search, Shell).
7
+ FIXED: HITL Gate interaction with conversational flow.
8
+
9
+ CHANGELOG [2026-02-01 - Claude/Opus]
10
+ IMPLEMENTED: Everything the previous changelog promised but didn't deliver.
11
+ The prior version had `pass` in the tool call parser, undefined get_stats()
12
+ calls, unconnected file uploads, and a decorative-only Build Approval Gate.
13
+
14
+ WHAT'S NOW WORKING:
15
+ - Tool call parser: Handles both Kimi's native <|tool_call_begin|> format
16
+ AND the <function_calls> XML format. Extracts tool name + arguments,
17
+ dispatches to RecursiveContextManager methods.
18
+ - HITL Gate: Write operations (write_file, shell_execute, create_shadow_branch)
19
+ are intercepted and staged in a queue. They appear in the "Build Approval
20
+ Gate" tab for Josh to review before execution. Read operations (search_code,
21
+ read_file, list_files, search_conversations, search_testament) execute
22
+ immediately β€” no approval needed for reads.
23
+ - File uploads: Dropped files are read and injected into the conversation
24
+ context so the model can reference them.
25
+ - Stats sidebar: Pulls from ctx.get_stats() which now exists.
26
+ - Conversation persistence: Every turn is saved to ChromaDB + cloud backup.
27
+
28
+ DESIGN DECISIONS:
29
+ - Gradio state for the approval queue: We use gr.State to hold pending
30
+ proposals per-session. This is stateful per browser tab, which is correct
31
+ for a single-user system.
32
+ - Read vs Write classification: Reads are safe and automated. Writes need
33
+ human eyes. This mirrors Josh's stated preference for finding root causes
34
+ over workarounds β€” you see exactly what the agent wants to change.
35
+ - Error tolerance: If the model response isn't parseable as a tool call,
36
+ we treat it as conversational text and display it. No silent failures.
37
+ - The agentic loop runs up to 5 iterations to handle multi-step tool use
38
+ (model searches β†’ reads file β†’ searches again β†’ responds). Each iteration
39
+ either executes a tool and feeds results back, or returns the final text.
40
+
41
+ TESTED ALTERNATIVES (graveyard):
42
+ - Regex-only parsing for tool calls: Brittle with nested JSON. The current
43
+ approach uses marker-based splitting first, then JSON parsing.
44
+ - Shared global queue for approval gate: Race conditions with multiple tabs.
45
+ gr.State is per-session and avoids this.
46
+ - Auto-executing all tools: Violates the HITL principle for write operations.
47
+ Josh explicitly wants to approve code changes before they land.
48
+
49
+ DEPENDENCIES:
50
+ - recursive_context.py: RecursiveContextManager class (must define get_stats())
51
+ - gradio>=5.0.0: For type="messages" chatbot format
52
+ - huggingface-hub: InferenceClient for Kimi K2.5
53
+ """
54
+
55
+ import gradio as gr
56
+ from huggingface_hub import InferenceClient
57
+ from recursive_context import RecursiveContextManager
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
252
+ anything get staged for review.
253
+ - **create_shadow_branch()** β€” Create a timestamped backup branch before changes.
254
+
255
+ To call a tool, use this format:
256
+ <function_calls>
257
+ <invoke name="tool_name">
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())