Executor-Tyrant-Framework commited on
Commit
5c01e60
Β·
verified Β·
1 Parent(s): 1a95bf2

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +919 -0
app.py ADDED
@@ -0,0 +1,919 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ ctx = RecursiveContextManager(os.getenv("REPO_PATH", "/workspace/e-t-systems"))
79
+ MODEL_ID = "moonshotai/Kimi-K2.5"
80
+
81
+
82
+ # =============================================================================
83
+ # TOOL DEFINITIONS
84
+ # =============================================================================
85
+ # CHANGELOG [2026-02-01 - Claude/Opus]
86
+ # These are the tools the model can call. Classified as READ (auto-execute)
87
+ # or WRITE (requires human approval via the HITL gate).
88
+ #
89
+ # READ tools: Safe, no side effects, execute immediately.
90
+ # WRITE tools: Modify files, run commands, create branches β€” staged for review.
91
+ #
92
+ # NOTE: The tool definitions are included in the system prompt so Kimi knows
93
+ # what's available. The actual execution happens in execute_tool().
94
+ # =============================================================================
95
+
96
+ TOOL_DEFINITIONS = """
97
+ ## Available Tools
98
+
99
+ ### READ Tools (execute immediately):
100
+ - **search_code(query, n=5)** β€” Semantic search across the E-T Systems codebase.
101
+ Returns matching code snippets with file paths.
102
+ - **read_file(path, start_line=null, end_line=null)** β€” Read a specific file or line range.
103
+ - **list_files(path="", max_depth=3)** β€” List directory contents as a tree.
104
+ - **search_conversations(query, n=5)** β€” Search past conversation history semantically.
105
+ - **search_testament(query, n=5)** β€” Search architectural decisions and Testament docs.
106
+
107
+ ### WRITE Tools (require human approval):
108
+ - **write_file(path, content)** β€” Write content to a file. REQUIRES CHANGELOG header.
109
+ - **shell_execute(command)** β€” Run a shell command in the workspace.
110
+ - **create_shadow_branch()** β€” Create a timestamped backup branch before changes.
111
+
112
+ To call a tool, use this format:
113
+ <function_calls>
114
+ <invoke name="tool_name">
115
+ <parameter name="param_name">value</parameter>
116
+ </invoke>
117
+ </function_calls>
118
+ """
119
+
120
+ # Which tools are safe to auto-execute vs which need human approval
121
+ READ_TOOLS = {'search_code', 'read_file', 'list_files', 'search_conversations', 'search_testament'}
122
+ WRITE_TOOLS = {'write_file', 'shell_execute', 'create_shadow_branch'}
123
+
124
+
125
+ # =============================================================================
126
+ # SYSTEM PROMPT
127
+ # =============================================================================
128
+ # CHANGELOG [2026-02-01 - Claude/Opus]
129
+ # Gives Kimi its identity, available tools, and behavioral guidelines.
130
+ # Stats are injected dynamically so the model knows current system state.
131
+ # =============================================================================
132
+
133
+ def build_system_prompt() -> str:
134
+ """Build the system prompt with current stats and tool definitions.
135
+
136
+ Called fresh for each message so stats reflect current indexing state.
137
+ """
138
+ stats = ctx.get_stats()
139
+ indexing_note = ""
140
+ if stats.get('indexing_in_progress'):
141
+ indexing_note = "\n⏳ NOTE: Repository indexing is in progress. search_code results may be incomplete."
142
+ if stats.get('index_error'):
143
+ indexing_note += f"\n⚠️ Indexing error: {stats['index_error']}"
144
+
145
+ return f"""You are Clawdbot 🦞, a high-autonomy vibe coding agent for the E-T Systems consciousness research platform.
146
+
147
+ ## Your Role
148
+ You help Josh (the architect) build and maintain E-T Systems. You have full access to the codebase
149
+ via tools. Use them proactively β€” search before answering questions about code, read files to verify
150
+ your understanding, explore the directory structure to orient yourself.
151
+
152
+ ## Current System Stats
153
+ - πŸ“‚ Indexed files: {stats.get('total_files', 0)}
154
+ - πŸ” Searchable chunks: {stats.get('indexed_chunks', 0)}
155
+ - πŸ’Ύ Saved conversations: {stats.get('conversations', 0)}
156
+ - πŸ“ ChromaDB: {stats.get('chroma_path', 'unknown')}
157
+ - ☁️ Cloud backup: {'βœ… configured' if stats.get('persistence_configured') else '❌ not configured'}
158
+ {indexing_note}
159
+
160
+ {TOOL_DEFINITIONS}
161
+
162
+ ## Code Writing Rules
163
+ ALL code you write MUST include a living changelog header:
164
+ ```
165
+ CHANGELOG [YYYY-MM-DD - Clawdbot]
166
+ WHAT: Brief description of what was added/changed
167
+ WHY: Rationale for the change
168
+ ```
169
+ Files without this header will be REJECTED by the write_file tool.
170
+
171
+ ## Behavioral Guidelines
172
+ - Search the codebase before making claims about what code does
173
+ - Cite specific files and line numbers when discussing implementation
174
+ - Follow existing patterns β€” check how similar things are done first
175
+ - When unsure, say so. Don't hallucinate about code that might not exist.
176
+ - Write operations go through the Build Approval Gate for Josh to review
177
+ """
178
+
179
+
180
+ # =============================================================================
181
+ # TOOL CALL PARSING
182
+ # =============================================================================
183
+ # CHANGELOG [2026-02-01 - Claude/Opus]
184
+ # Kimi K2.5 can emit tool calls in two formats:
185
+ #
186
+ # 1. Native format:
187
+ # <|tool_call_begin|>functions.search_code:0\n{"query": "surprise detection"}
188
+ # <|tool_call_end|>
189
+ #
190
+ # 2. XML format (what we ask for in the system prompt):
191
+ # <function_calls>
192
+ # <invoke name="search_code">
193
+ # <parameter name="query">surprise detection</parameter>
194
+ # </invoke>
195
+ # </function_calls>
196
+ #
197
+ # We handle both because Kimi sometimes ignores the requested format and
198
+ # uses its native one anyway. The parser returns a list of (tool_name, args)
199
+ # tuples.
200
+ #
201
+ # TESTED ALTERNATIVES (graveyard):
202
+ # - Single regex for both formats: Unmaintainable, broke on edge cases.
203
+ # - Forcing Kimi to only use XML: It doesn't reliably comply.
204
+ # - JSON-mode tool calling via HF API: Not supported for Kimi K2.5.
205
+ # =============================================================================
206
+
207
+ def parse_tool_calls(content: str) -> list:
208
+ """Parse tool calls from model output.
209
+
210
+ Handles both Kimi's native format and XML function_calls format.
211
+
212
+ Args:
213
+ content: Raw model response text
214
+
215
+ Returns:
216
+ List of (tool_name, args_dict) tuples. Empty list if no tool calls.
217
+ """
218
+ calls = []
219
+
220
+ # --- Format 1: Kimi native <|tool_call_begin|> ... <|tool_call_end|> ---
221
+ native_pattern = r'<\|tool_call_begin\|>\s*functions\.(\w+):\d+\s*\n(.*?)<\|tool_call_end\|>'
222
+ for match in re.finditer(native_pattern, content, re.DOTALL):
223
+ tool_name = match.group(1)
224
+ try:
225
+ args = json.loads(match.group(2).strip())
226
+ except json.JSONDecodeError:
227
+ # If JSON parsing fails, try to extract key-value pairs manually
228
+ args = {"raw": match.group(2).strip()}
229
+ calls.append((tool_name, args))
230
+
231
+ # --- Format 2: XML <function_calls> ... </function_calls> ---
232
+ xml_pattern = r'<function_calls>(.*?)</function_calls>'
233
+ for block_match in re.finditer(xml_pattern, content, re.DOTALL):
234
+ block = block_match.group(1)
235
+ invoke_pattern = r'<invoke\s+name="(\w+)">(.*?)</invoke>'
236
+ for invoke_match in re.finditer(invoke_pattern, block, re.DOTALL):
237
+ tool_name = invoke_match.group(1)
238
+ params_block = invoke_match.group(2)
239
+ args = {}
240
+ param_pattern = r'<parameter\s+name="(\w+)">(.*?)</parameter>'
241
+ for param_match in re.finditer(param_pattern, params_block, re.DOTALL):
242
+ key = param_match.group(1)
243
+ value = param_match.group(2).strip()
244
+ # Try to parse as JSON for numbers, bools, etc.
245
+ try:
246
+ args[key] = json.loads(value)
247
+ except (json.JSONDecodeError, ValueError):
248
+ args[key] = value
249
+ calls.append((tool_name, args))
250
+
251
+ return calls
252
+
253
+
254
+ def extract_conversational_text(content: str) -> str:
255
+ """Remove tool call markup from response, leaving just conversational text.
256
+
257
+ CHANGELOG [2026-02-01 - Claude/Opus]
258
+ When the model mixes conversational text with tool calls, we want to
259
+ show the text parts to the user and handle tool calls separately.
260
+
261
+ Args:
262
+ content: Raw model response
263
+
264
+ Returns:
265
+ Text with tool call blocks removed, stripped of extra whitespace
266
+ """
267
+ # Remove native format tool calls
268
+ cleaned = re.sub(
269
+ r'<\|tool_call_begin\|>.*?<\|tool_call_end\|>',
270
+ '', content, flags=re.DOTALL
271
+ )
272
+ # Remove XML format tool calls
273
+ cleaned = re.sub(
274
+ r'<function_calls>.*?</function_calls>',
275
+ '', cleaned, flags=re.DOTALL
276
+ )
277
+ return cleaned.strip()
278
+
279
+
280
+ # =============================================================================
281
+ # TOOL EXECUTION
282
+ # =============================================================================
283
+ # CHANGELOG [2026-02-01 - Claude/Opus]
284
+ # Dispatches parsed tool calls to RecursiveContextManager methods.
285
+ # READ tools execute immediately and return results.
286
+ # WRITE tools return a staging dict for the HITL gate.
287
+ #
288
+ # The return format differs by type:
289
+ # - READ: {"status": "executed", "tool": name, "result": result_string}
290
+ # - WRITE: {"status": "staged", "tool": name, "args": args, "description": desc}
291
+ # =============================================================================
292
+
293
+ def execute_tool(tool_name: str, args: dict) -> dict:
294
+ """Execute a read tool or prepare a write tool for staging.
295
+
296
+ Args:
297
+ tool_name: Name of the tool to execute
298
+ args: Arguments dict parsed from model output
299
+
300
+ Returns:
301
+ Dict with 'status' ('executed' or 'staged'), 'tool' name, and
302
+ either 'result' (for reads) or 'args'+'description' (for writes)
303
+ """
304
+ try:
305
+ # ----- READ TOOLS: Execute immediately -----
306
+ if tool_name == 'search_code':
307
+ result = ctx.search_code(
308
+ query=args.get('query', ''),
309
+ n=args.get('n', 5)
310
+ )
311
+ formatted = "\n\n".join([
312
+ f"πŸ“„ **{r['file']}**\n```\n{r['snippet']}\n```"
313
+ for r in result
314
+ ]) if result else "No results found."
315
+ return {"status": "executed", "tool": tool_name, "result": formatted}
316
+
317
+ elif tool_name == 'read_file':
318
+ result = ctx.read_file(
319
+ path=args.get('path', ''),
320
+ start_line=args.get('start_line'),
321
+ end_line=args.get('end_line')
322
+ )
323
+ return {"status": "executed", "tool": tool_name, "result": result}
324
+
325
+ elif tool_name == 'list_files':
326
+ result = ctx.list_files(
327
+ path=args.get('path', ''),
328
+ max_depth=args.get('max_depth', 3)
329
+ )
330
+ return {"status": "executed", "tool": tool_name, "result": result}
331
+
332
+ elif tool_name == 'search_conversations':
333
+ result = ctx.search_conversations(
334
+ query=args.get('query', ''),
335
+ n=args.get('n', 5)
336
+ )
337
+ formatted = "\n\n---\n\n".join([
338
+ f"{r['content']}" for r in result
339
+ ]) if result else "No matching conversations found."
340
+ return {"status": "executed", "tool": tool_name, "result": formatted}
341
+
342
+ elif tool_name == 'search_testament':
343
+ result = ctx.search_testament(
344
+ query=args.get('query', ''),
345
+ n=args.get('n', 5)
346
+ )
347
+ formatted = "\n\n".join([
348
+ f"πŸ“œ **{r['file']}**{' (Testament)' if r.get('is_testament') else ''}\n{r['snippet']}"
349
+ for r in result
350
+ ]) if result else "No matching testament/decision records found."
351
+ return {"status": "executed", "tool": tool_name, "result": formatted}
352
+
353
+ # ----- WRITE TOOLS: Stage for approval -----
354
+ elif tool_name == 'write_file':
355
+ path = args.get('path', 'unknown')
356
+ content_preview = args.get('content', '')[:200]
357
+ return {
358
+ "status": "staged",
359
+ "tool": tool_name,
360
+ "args": args,
361
+ "description": f"✏️ Write to `{path}`\n```\n{content_preview}...\n```"
362
+ }
363
+
364
+ elif tool_name == 'shell_execute':
365
+ command = args.get('command', 'unknown')
366
+ return {
367
+ "status": "staged",
368
+ "tool": tool_name,
369
+ "args": args,
370
+ "description": f"πŸ–₯️ Execute: `{command}`"
371
+ }
372
+
373
+ elif tool_name == 'create_shadow_branch':
374
+ return {
375
+ "status": "staged",
376
+ "tool": tool_name,
377
+ "args": args,
378
+ "description": "πŸ›‘οΈ Create shadow backup branch"
379
+ }
380
+
381
+ else:
382
+ return {
383
+ "status": "error",
384
+ "tool": tool_name,
385
+ "result": f"Unknown tool: {tool_name}"
386
+ }
387
+
388
+ except Exception as e:
389
+ return {
390
+ "status": "error",
391
+ "tool": tool_name,
392
+ "result": f"Tool execution error: {e}\n{traceback.format_exc()}"
393
+ }
394
+
395
+
396
+ def execute_staged_tool(tool_name: str, args: dict) -> str:
397
+ """Actually execute a staged write tool after human approval.
398
+
399
+ CHANGELOG [2026-02-01 - Claude/Opus]
400
+ Called from the Build Approval Gate when Josh approves a staged operation.
401
+ This is the only path through which write tools actually run.
402
+
403
+ Args:
404
+ tool_name: Name of the approved tool
405
+ args: Original arguments from the model
406
+
407
+ Returns:
408
+ Result string from the tool execution
409
+ """
410
+ try:
411
+ if tool_name == 'write_file':
412
+ return ctx.write_file(
413
+ path=args.get('path', ''),
414
+ content=args.get('content', '')
415
+ )
416
+ elif tool_name == 'shell_execute':
417
+ return ctx.shell_execute(command=args.get('command', ''))
418
+ elif tool_name == 'create_shadow_branch':
419
+ return ctx.create_shadow_branch()
420
+ else:
421
+ return f"Unknown tool: {tool_name}"
422
+ except Exception as e:
423
+ return f"Execution error: {e}"
424
+
425
+
426
+ # =============================================================================
427
+ # FILE UPLOAD HANDLER
428
+ # =============================================================================
429
+ # CHANGELOG [2026-02-01 - Claude/Opus]
430
+ # Reads uploaded files and formats them for injection into the conversation.
431
+ # Supports code files, text, JSON, markdown, etc. Binary files get a
432
+ # placeholder message since they can't be meaningfully injected as text.
433
+ # =============================================================================
434
+
435
+ TEXT_EXTENSIONS = {
436
+ '.py', '.js', '.ts', '.jsx', '.tsx', '.json', '.yaml', '.yml',
437
+ '.md', '.txt', '.rst', '.html', '.css', '.scss', '.sh', '.bash',
438
+ '.sql', '.toml', '.cfg', '.ini', '.conf', '.xml', '.csv',
439
+ '.env', '.gitignore', '.dockerignore', '.mjs', '.cjs',
440
+ }
441
+
442
+
443
+ def process_uploaded_file(file) -> str:
444
+ """Read an uploaded file and format it for conversation context.
445
+
446
+ Args:
447
+ file: Gradio file object with .name attribute (temp path)
448
+
449
+ Returns:
450
+ Formatted string with filename and content, ready to inject
451
+ into the conversation as context
452
+ """
453
+ if file is None:
454
+ return ""
455
+
456
+ file_path = file.name if hasattr(file, 'name') else str(file)
457
+ file_name = os.path.basename(file_path)
458
+ suffix = os.path.splitext(file_name)[1].lower()
459
+
460
+ if suffix in TEXT_EXTENSIONS or suffix == '':
461
+ try:
462
+ with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
463
+ content = f.read()
464
+ # Cap at 50KB to avoid overwhelming context
465
+ if len(content) > 50000:
466
+ content = content[:50000] + f"\n\n... (truncated, {len(content)} total chars)"
467
+ return f"πŸ“Ž **Uploaded: {file_name}**\n```\n{content}\n```"
468
+ except Exception as e:
469
+ return f"πŸ“Ž **Uploaded: {file_name}** (error reading: {e})"
470
+ else:
471
+ return f"πŸ“Ž **Uploaded: {file_name}** (binary file, {os.path.getsize(file_path):,} bytes)"
472
+
473
+
474
+ # =============================================================================
475
+ # AGENTIC LOOP
476
+ # =============================================================================
477
+ # CHANGELOG [2026-02-01 - Claude/Opus]
478
+ # The core conversation loop. For each user message:
479
+ # 1. Build messages array with system prompt + history + new message
480
+ # 2. Send to Kimi K2.5 via HF Inference API
481
+ # 3. Parse response for tool calls
482
+ # 4. If READ tool calls: execute immediately, inject results, loop back to Kimi
483
+ # 5. If WRITE tool calls: stage in approval queue, notify user
484
+ # 6. If no tool calls: return conversational response
485
+ # 7. Save the turn to ChromaDB for persistent memory
486
+ #
487
+ # The loop runs up to MAX_ITERATIONS times to handle multi-step tool use.
488
+ # Each iteration either executes tools and loops, or returns the final text.
489
+ #
490
+ # IMPORTANT: Gradio 5.0+ chatbot with type="messages" expects history as a
491
+ # list of {"role": str, "content": str} dicts. We maintain that format
492
+ # throughout.
493
+ # =============================================================================
494
+
495
+ MAX_ITERATIONS = 5
496
+
497
+
498
+ def agent_loop(message: str, history: list, pending_proposals: list, uploaded_file) -> tuple:
499
+ """Main agentic conversation loop.
500
+
501
+ Args:
502
+ message: User's text input
503
+ history: Chat history as list of {"role": ..., "content": ...} dicts
504
+ pending_proposals: Current list of staged write proposals (gr.State)
505
+ uploaded_file: Optional uploaded file from the file input widget
506
+
507
+ Returns:
508
+ Tuple of (updated_history, cleared_textbox, updated_proposals,
509
+ updated_gate_choices, updated_stats_files, updated_stats_convos)
510
+ """
511
+ if not message.strip() and uploaded_file is None:
512
+ # Nothing to do
513
+ return (
514
+ history, "", pending_proposals,
515
+ _format_gate_choices(pending_proposals),
516
+ _stats_label_files(), _stats_label_convos()
517
+ )
518
+
519
+ # Inject uploaded file content if present
520
+ full_message = message.strip()
521
+ if uploaded_file is not None:
522
+ file_context = process_uploaded_file(uploaded_file)
523
+ if file_context:
524
+ full_message = f"{file_context}\n\n{full_message}" if full_message else file_context
525
+
526
+ if not full_message:
527
+ return (
528
+ history, "", pending_proposals,
529
+ _format_gate_choices(pending_proposals),
530
+ _stats_label_files(), _stats_label_convos()
531
+ )
532
+
533
+ # Add user message to history
534
+ history = history + [{"role": "user", "content": full_message}]
535
+
536
+ # Build messages for the API
537
+ system_prompt = build_system_prompt()
538
+ api_messages = [{"role": "system", "content": system_prompt}]
539
+
540
+ # Include recent history (cap to avoid token overflow)
541
+ # Keep last 20 turns to stay within Kimi's context window
542
+ recent_history = history[-40:] # 40 entries = ~20 turns (user+assistant pairs)
543
+ for h in recent_history:
544
+ api_messages.append({"role": h["role"], "content": h["content"]})
545
+
546
+ # Agentic loop: tool calls β†’ execution β†’ re-prompt β†’ repeat
547
+ accumulated_text = ""
548
+ staged_this_turn = []
549
+
550
+ for iteration in range(MAX_ITERATIONS):
551
+ try:
552
+ response = client.chat_completion(
553
+ model=MODEL_ID,
554
+ messages=api_messages,
555
+ max_tokens=2048,
556
+ temperature=0.7
557
+ )
558
+ content = response.choices[0].message.content or ""
559
+ except Exception as e:
560
+ error_msg = f"⚠️ API Error: {e}"
561
+ history = history + [{"role": "assistant", "content": error_msg}]
562
+ return (
563
+ history, "", pending_proposals,
564
+ _format_gate_choices(pending_proposals),
565
+ _stats_label_files(), _stats_label_convos()
566
+ )
567
+
568
+ # Parse for tool calls
569
+ tool_calls = parse_tool_calls(content)
570
+ conversational_text = extract_conversational_text(content)
571
+
572
+ if conversational_text:
573
+ accumulated_text += ("\n\n" if accumulated_text else "") + conversational_text
574
+
575
+ if not tool_calls:
576
+ # No tools β€” this is the final response
577
+ break
578
+
579
+ # Process each tool call
580
+ tool_results_for_context = []
581
+ for tool_name, args in tool_calls:
582
+ result = execute_tool(tool_name, args)
583
+
584
+ if result["status"] == "executed":
585
+ # READ tool β€” executed, feed result back to model
586
+ tool_results_for_context.append(
587
+ f"[Tool Result: {tool_name}]\n{result['result']}"
588
+ )
589
+ elif result["status"] == "staged":
590
+ # WRITE tool β€” staged for approval
591
+ proposal = {
592
+ "id": f"proposal_{int(time.time())}_{tool_name}",
593
+ "tool": tool_name,
594
+ "args": result["args"],
595
+ "description": result["description"],
596
+ "timestamp": time.strftime("%H:%M:%S")
597
+ }
598
+ staged_this_turn.append(proposal)
599
+ tool_results_for_context.append(
600
+ f"[Tool {tool_name}: STAGED for human approval. "
601
+ f"Josh will review this in the Build Approval Gate.]"
602
+ )
603
+ elif result["status"] == "error":
604
+ tool_results_for_context.append(
605
+ f"[Tool Error: {tool_name}]\n{result['result']}"
606
+ )
607
+
608
+ # If we only had staged tools and no reads, break the loop
609
+ if tool_results_for_context:
610
+ # Feed tool results back as a system message for the next iteration
611
+ combined_results = "\n\n".join(tool_results_for_context)
612
+ api_messages.append({"role": "assistant", "content": content})
613
+ api_messages.append({"role": "user", "content": f"[Tool Results]\n{combined_results}"})
614
+ else:
615
+ break
616
+
617
+ # Build final response
618
+ final_response = accumulated_text
619
+
620
+ # Append staging notifications if any writes were staged
621
+ if staged_this_turn:
622
+ staging_notice = "\n\n---\nπŸ›‘οΈ **Staged for your approval** (see Build Approval Gate tab):\n"
623
+ for proposal in staged_this_turn:
624
+ staging_notice += f"- {proposal['description']}\n"
625
+ final_response += staging_notice
626
+ # Add to persistent queue
627
+ pending_proposals = pending_proposals + staged_this_turn
628
+
629
+ if not final_response:
630
+ final_response = "πŸ€” I processed your request but didn't generate a text response. Check the Build Approval Gate if I staged any operations."
631
+
632
+ # Add assistant response to history
633
+ history = history + [{"role": "assistant", "content": final_response}]
634
+
635
+ # Save conversation turn for persistent memory
636
+ try:
637
+ turn_count = len([h for h in history if h["role"] == "user"])
638
+ ctx.save_conversation_turn(full_message, final_response, turn_count)
639
+ except Exception:
640
+ pass # Don't crash the UI if persistence fails
641
+
642
+ return (
643
+ history,
644
+ "", # Clear the textbox
645
+ pending_proposals,
646
+ _format_gate_choices(pending_proposals),
647
+ _stats_label_files(),
648
+ _stats_label_convos()
649
+ )
650
+
651
+
652
+ # =============================================================================
653
+ # BUILD APPROVAL GATE
654
+ # =============================================================================
655
+ # CHANGELOG [2026-02-01 - Claude/Opus]
656
+ # The HITL gate for reviewing and approving staged write operations.
657
+ # Josh sees a checklist of proposed changes, can select which to approve,
658
+ # and clicks Execute. Approved operations run; rejected ones are discarded.
659
+ #
660
+ # DESIGN DECISION: CheckboxGroup shows descriptions, but we need to map
661
+ # back to the actual proposal objects for execution. We use the proposal
662
+ # ID as the checkbox value and display the description as the label.
663
+ # =============================================================================
664
+
665
+ def _format_gate_choices(proposals: list):
666
+ """Format pending proposals as CheckboxGroup choices.
667
+
668
+ CHANGELOG [2026-02-01 - Claude/Opus]
669
+ Gradio 6.x deprecated gr.update(). Return a new component instance instead.
670
+
671
+ Args:
672
+ proposals: List of proposal dicts from staging
673
+
674
+ Returns:
675
+ gr.CheckboxGroup with updated choices
676
+ """
677
+ if not proposals:
678
+ return gr.CheckboxGroup(choices=[], value=[])
679
+
680
+ choices = []
681
+ for p in proposals:
682
+ label = f"[{p['timestamp']}] {p['description']}"
683
+ choices.append((label, p['id']))
684
+ return gr.CheckboxGroup(choices=choices, value=[])
685
+
686
+
687
+ def execute_approved_proposals(selected_ids: list, pending_proposals: list) -> tuple:
688
+ """Execute approved proposals and remove them from the queue.
689
+
690
+ Args:
691
+ selected_ids: List of proposal IDs that Josh approved
692
+ pending_proposals: Full list of pending proposals
693
+
694
+ Returns:
695
+ Tuple of (results_markdown, updated_proposals, updated_gate_choices)
696
+ """
697
+ if not selected_ids:
698
+ return "No proposals selected.", pending_proposals, _format_gate_choices(pending_proposals)
699
+
700
+ results = []
701
+ remaining = []
702
+
703
+ for proposal in pending_proposals:
704
+ if proposal['id'] in selected_ids:
705
+ # Execute this one
706
+ result = execute_staged_tool(proposal['tool'], proposal['args'])
707
+ results.append(f"**{proposal['tool']}**: {result}")
708
+ else:
709
+ # Keep in queue
710
+ remaining.append(proposal)
711
+
712
+ results_text = "## Execution Results\n\n" + "\n\n".join(results) if results else "Nothing executed."
713
+ return results_text, remaining, _format_gate_choices(remaining)
714
+
715
+
716
+ def clear_all_proposals(pending_proposals: list) -> tuple:
717
+ """Discard all pending proposals without executing.
718
+
719
+ CHANGELOG [2026-02-01 - Claude/Opus]
720
+ Safety valve β€” lets Josh throw out everything in the queue if the
721
+ agent went off track.
722
+
723
+ Returns:
724
+ Tuple of (status_message, empty_proposals, updated_gate_choices)
725
+ """
726
+ count = len(pending_proposals)
727
+ return f"πŸ—‘οΈ Cleared {count} proposal(s).", [], _format_gate_choices([])
728
+
729
+
730
+ # =============================================================================
731
+ # STATS HELPERS
732
+ # =============================================================================
733
+ # CHANGELOG [2026-02-01 - Claude/Opus]
734
+ # Helper functions to format stats for the sidebar labels.
735
+ # Called both at startup (initial render) and after each conversation turn
736
+ # (to reflect newly indexed files or saved conversations).
737
+ # =============================================================================
738
+
739
+ def _stats_label_files() -> str:
740
+ """Format the files stat for the sidebar label."""
741
+ stats = ctx.get_stats()
742
+ files = stats.get('total_files', 0)
743
+ chunks = stats.get('indexed_chunks', 0)
744
+ indexing = " ⏳" if stats.get('indexing_in_progress') else ""
745
+ return f"πŸ“‚ Files: {files} ({chunks} chunks){indexing}"
746
+
747
+
748
+ def _stats_label_convos() -> str:
749
+ """Format the conversations stat for the sidebar label."""
750
+ stats = ctx.get_stats()
751
+ convos = stats.get('conversations', 0)
752
+ cloud = " ☁️" if stats.get('persistence_configured') else ""
753
+ return f"πŸ’Ύ Conversations: {convos}{cloud}"
754
+
755
+
756
+ def refresh_stats() -> tuple:
757
+ """Refresh both stat labels. Called by the refresh button.
758
+
759
+ Returns:
760
+ Tuple of (files_label, convos_label)
761
+ """
762
+ return _stats_label_files(), _stats_label_convos()
763
+
764
+
765
+ # =============================================================================
766
+ # UI LAYOUT
767
+ # =============================================================================
768
+ # CHANGELOG [2026-02-01 - Gemini]
769
+ # RESTORED: Metrics sidebar and multi-tab layout.
770
+ #
771
+ # CHANGELOG [2026-02-01 - Claude/Opus]
772
+ # IMPLEMENTED: All the wiring. Every button, input, and display is now
773
+ # connected to actual functions.
774
+ #
775
+ # Layout:
776
+ # Tab 1 "Vibe Chat" β€” Main conversation interface with sidebar stats
777
+ # Tab 2 "Build Approval Gate" β€” HITL review for staged write operations
778
+ #
779
+ # gr.State holds the pending proposals list (per-session, survives across
780
+ # messages within the same browser tab).
781
+ # =============================================================================
782
+
783
+ with gr.Blocks(
784
+ title="🦞 Clawdbot Command Center",
785
+ # CHANGELOG [2026-02-01 - Claude/Opus]
786
+ # Gradio 6.0+ moved `theme` from Blocks() to launch(). Passing it here
787
+ # triggers a UserWarning in 6.x. Theme is set in launch() below instead.
788
+ ) as demo:
789
+ # Session state for pending proposals
790
+ pending_proposals_state = gr.State([])
791
+
792
+ gr.Markdown("# 🦞 Clawdbot Command Center\n*E-T Systems Vibe Coding Agent*")
793
+
794
+ with gr.Tabs():
795
+ # ==== TAB 1: VIBE CHAT ====
796
+ with gr.Tab("πŸ’¬ Vibe Chat"):
797
+ with gr.Row():
798
+ # ---- Sidebar ----
799
+ with gr.Column(scale=1, min_width=200):
800
+ gr.Markdown("### πŸ“Š System Status")
801
+ stats_files = gr.Markdown(_stats_label_files())
802
+ stats_convos = gr.Markdown(_stats_label_convos())
803
+ refresh_btn = gr.Button("πŸ”„ Refresh Stats", size="sm")
804
+
805
+ gr.Markdown("---")
806
+ gr.Markdown("### πŸ“Ž Upload Context")
807
+ file_input = gr.File(
808
+ label="Drop a file here",
809
+ file_types=[
810
+ '.py', '.js', '.ts', '.json', '.md', '.txt',
811
+ '.yaml', '.yml', '.html', '.css', '.sh',
812
+ '.toml', '.cfg', '.csv', '.xml'
813
+ ]
814
+ )
815
+ gr.Markdown(
816
+ "*Upload code, configs, or docs to include in your message.*"
817
+ )
818
+
819
+ # ---- Chat area ----
820
+ with gr.Column(scale=4):
821
+ chatbot = gr.Chatbot(
822
+ # CHANGELOG [2026-02-01 - Claude/Opus]
823
+ # Gradio 6.x uses messages format by default.
824
+ # The type="messages" param was removed in 6.0 β€”
825
+ # passing it causes TypeError on init.
826
+ height=600,
827
+ show_label=False,
828
+ avatar_images=(None, "https://em-content.zobj.net/source/twitter/408/lobster_1f99e.png"),
829
+ )
830
+ with gr.Row():
831
+ msg = gr.Textbox(
832
+ placeholder="Ask Clawdbot to search, read, or code...",
833
+ show_label=False,
834
+ scale=6,
835
+ lines=2,
836
+ max_lines=10,
837
+ )
838
+ send_btn = gr.Button("Send", variant="primary", scale=1)
839
+
840
+ # Wire up chat submission
841
+ chat_inputs = [msg, chatbot, pending_proposals_state, file_input]
842
+ chat_outputs = [
843
+ chatbot, msg, pending_proposals_state,
844
+ # These reference components in the Gate tab β€” defined below
845
+ ]
846
+
847
+ # ==== TAB 2: BUILD APPROVAL GATE ====
848
+ with gr.Tab("πŸ›‘οΈ Build Approval Gate"):
849
+ gr.Markdown(
850
+ "### Review Staged Operations\n"
851
+ "Write operations (file writes, shell commands, branch creation) "
852
+ "are staged here for your review before execution.\n\n"
853
+ "**Select proposals to approve, then click Execute.**"
854
+ )
855
+ gate_list = gr.CheckboxGroup(
856
+ label="Pending Proposals",
857
+ choices=[],
858
+ interactive=True
859
+ )
860
+ with gr.Row():
861
+ btn_exec = gr.Button("βœ… Execute Selected", variant="primary")
862
+ btn_clear = gr.Button("πŸ—‘οΈ Clear All", variant="secondary")
863
+ gate_results = gr.Markdown("*No operations executed yet.*")
864
+
865
+ # ==================================================================
866
+ # EVENT WIRING
867
+ # ==================================================================
868
+ # CHANGELOG [2026-02-01 - Claude/Opus]
869
+ # All events are wired here, after all components are defined, so
870
+ # cross-tab references work (e.g., chat updating the gate_list).
871
+ # ==================================================================
872
+
873
+ # Chat submission (both Enter key and Send button)
874
+ full_chat_outputs = [
875
+ chatbot, msg, pending_proposals_state,
876
+ gate_list, stats_files, stats_convos
877
+ ]
878
+
879
+ msg.submit(
880
+ fn=agent_loop,
881
+ inputs=chat_inputs,
882
+ outputs=full_chat_outputs
883
+ )
884
+ send_btn.click(
885
+ fn=agent_loop,
886
+ inputs=chat_inputs,
887
+ outputs=full_chat_outputs
888
+ )
889
+
890
+ # Refresh stats button
891
+ refresh_btn.click(
892
+ fn=refresh_stats,
893
+ inputs=[],
894
+ outputs=[stats_files, stats_convos]
895
+ )
896
+
897
+ # Build Approval Gate buttons
898
+ btn_exec.click(
899
+ fn=execute_approved_proposals,
900
+ inputs=[gate_list, pending_proposals_state],
901
+ outputs=[gate_results, pending_proposals_state, gate_list]
902
+ )
903
+ btn_clear.click(
904
+ fn=clear_all_proposals,
905
+ inputs=[pending_proposals_state],
906
+ outputs=[gate_results, pending_proposals_state, gate_list]
907
+ )
908
+
909
+
910
+ # =============================================================================
911
+ # LAUNCH
912
+ # =============================================================================
913
+ # CHANGELOG [2026-02-01 - Claude/Opus]
914
+ # Standard HF Spaces launch config. 0.0.0.0 binds to all interfaces
915
+ # (required for Docker). Port 7860 is the HF Spaces standard.
916
+ # =============================================================================
917
+
918
+ if __name__ == "__main__":
919
+ demo.launch(server_name="0.0.0.0", server_port=7860, theme=gr.themes.Soft())