Executor-Tyrant-Framework commited on
Commit
2e77d9c
Β·
verified Β·
1 Parent(s): 899326d

Upload 2 files

Browse files
Files changed (2) hide show
  1. app.py +903 -0
  2. recursive_context.py +750 -0
app.py ADDED
@@ -0,0 +1,903 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 history, "", pending_proposals, _format_gate_choices(pending_proposals), gr.update(), gr.update()
514
+
515
+ # Inject uploaded file content if present
516
+ full_message = message.strip()
517
+ if uploaded_file is not None:
518
+ file_context = process_uploaded_file(uploaded_file)
519
+ if file_context:
520
+ full_message = f"{file_context}\n\n{full_message}" if full_message else file_context
521
+
522
+ if not full_message:
523
+ return history, "", pending_proposals, _format_gate_choices(pending_proposals), gr.update(), gr.update()
524
+
525
+ # Add user message to history
526
+ history = history + [{"role": "user", "content": full_message}]
527
+
528
+ # Build messages for the API
529
+ system_prompt = build_system_prompt()
530
+ api_messages = [{"role": "system", "content": system_prompt}]
531
+
532
+ # Include recent history (cap to avoid token overflow)
533
+ # Keep last 20 turns to stay within Kimi's context window
534
+ recent_history = history[-40:] # 40 entries = ~20 turns (user+assistant pairs)
535
+ for h in recent_history:
536
+ api_messages.append({"role": h["role"], "content": h["content"]})
537
+
538
+ # Agentic loop: tool calls β†’ execution β†’ re-prompt β†’ repeat
539
+ accumulated_text = ""
540
+ staged_this_turn = []
541
+
542
+ for iteration in range(MAX_ITERATIONS):
543
+ try:
544
+ response = client.chat_completion(
545
+ model=MODEL_ID,
546
+ messages=api_messages,
547
+ max_tokens=2048,
548
+ temperature=0.7
549
+ )
550
+ content = response.choices[0].message.content or ""
551
+ except Exception as e:
552
+ error_msg = f"⚠️ API Error: {e}"
553
+ history = history + [{"role": "assistant", "content": error_msg}]
554
+ return (
555
+ history, "", pending_proposals,
556
+ _format_gate_choices(pending_proposals),
557
+ _stats_label_files(), _stats_label_convos()
558
+ )
559
+
560
+ # Parse for tool calls
561
+ tool_calls = parse_tool_calls(content)
562
+ conversational_text = extract_conversational_text(content)
563
+
564
+ if conversational_text:
565
+ accumulated_text += ("\n\n" if accumulated_text else "") + conversational_text
566
+
567
+ if not tool_calls:
568
+ # No tools β€” this is the final response
569
+ break
570
+
571
+ # Process each tool call
572
+ tool_results_for_context = []
573
+ for tool_name, args in tool_calls:
574
+ result = execute_tool(tool_name, args)
575
+
576
+ if result["status"] == "executed":
577
+ # READ tool β€” executed, feed result back to model
578
+ tool_results_for_context.append(
579
+ f"[Tool Result: {tool_name}]\n{result['result']}"
580
+ )
581
+ elif result["status"] == "staged":
582
+ # WRITE tool β€” staged for approval
583
+ proposal = {
584
+ "id": f"proposal_{int(time.time())}_{tool_name}",
585
+ "tool": tool_name,
586
+ "args": result["args"],
587
+ "description": result["description"],
588
+ "timestamp": time.strftime("%H:%M:%S")
589
+ }
590
+ staged_this_turn.append(proposal)
591
+ tool_results_for_context.append(
592
+ f"[Tool {tool_name}: STAGED for human approval. "
593
+ f"Josh will review this in the Build Approval Gate.]"
594
+ )
595
+ elif result["status"] == "error":
596
+ tool_results_for_context.append(
597
+ f"[Tool Error: {tool_name}]\n{result['result']}"
598
+ )
599
+
600
+ # If we only had staged tools and no reads, break the loop
601
+ if tool_results_for_context:
602
+ # Feed tool results back as a system message for the next iteration
603
+ combined_results = "\n\n".join(tool_results_for_context)
604
+ api_messages.append({"role": "assistant", "content": content})
605
+ api_messages.append({"role": "user", "content": f"[Tool Results]\n{combined_results}"})
606
+ else:
607
+ break
608
+
609
+ # Build final response
610
+ final_response = accumulated_text
611
+
612
+ # Append staging notifications if any writes were staged
613
+ if staged_this_turn:
614
+ staging_notice = "\n\n---\nπŸ›‘οΈ **Staged for your approval** (see Build Approval Gate tab):\n"
615
+ for proposal in staged_this_turn:
616
+ staging_notice += f"- {proposal['description']}\n"
617
+ final_response += staging_notice
618
+ # Add to persistent queue
619
+ pending_proposals = pending_proposals + staged_this_turn
620
+
621
+ if not final_response:
622
+ final_response = "πŸ€” I processed your request but didn't generate a text response. Check the Build Approval Gate if I staged any operations."
623
+
624
+ # Add assistant response to history
625
+ history = history + [{"role": "assistant", "content": final_response}]
626
+
627
+ # Save conversation turn for persistent memory
628
+ try:
629
+ turn_count = len([h for h in history if h["role"] == "user"])
630
+ ctx.save_conversation_turn(full_message, final_response, turn_count)
631
+ except Exception:
632
+ pass # Don't crash the UI if persistence fails
633
+
634
+ return (
635
+ history,
636
+ "", # Clear the textbox
637
+ pending_proposals,
638
+ _format_gate_choices(pending_proposals),
639
+ _stats_label_files(),
640
+ _stats_label_convos()
641
+ )
642
+
643
+
644
+ # =============================================================================
645
+ # BUILD APPROVAL GATE
646
+ # =============================================================================
647
+ # CHANGELOG [2026-02-01 - Claude/Opus]
648
+ # The HITL gate for reviewing and approving staged write operations.
649
+ # Josh sees a checklist of proposed changes, can select which to approve,
650
+ # and clicks Execute. Approved operations run; rejected ones are discarded.
651
+ #
652
+ # DESIGN DECISION: CheckboxGroup shows descriptions, but we need to map
653
+ # back to the actual proposal objects for execution. We use the proposal
654
+ # ID as the checkbox value and display the description as the label.
655
+ # =============================================================================
656
+
657
+ def _format_gate_choices(proposals: list) -> gr.update:
658
+ """Format pending proposals as CheckboxGroup choices.
659
+
660
+ Args:
661
+ proposals: List of proposal dicts from staging
662
+
663
+ Returns:
664
+ gr.update with choices list for the CheckboxGroup
665
+ """
666
+ if not proposals:
667
+ return gr.update(choices=[], value=[])
668
+
669
+ choices = []
670
+ for p in proposals:
671
+ label = f"[{p['timestamp']}] {p['description']}"
672
+ choices.append((label, p['id']))
673
+ return gr.update(choices=choices, value=[])
674
+
675
+
676
+ def execute_approved_proposals(selected_ids: list, pending_proposals: list) -> tuple:
677
+ """Execute approved proposals and remove them from the queue.
678
+
679
+ Args:
680
+ selected_ids: List of proposal IDs that Josh approved
681
+ pending_proposals: Full list of pending proposals
682
+
683
+ Returns:
684
+ Tuple of (results_markdown, updated_proposals, updated_gate_choices)
685
+ """
686
+ if not selected_ids:
687
+ return "No proposals selected.", pending_proposals, _format_gate_choices(pending_proposals)
688
+
689
+ results = []
690
+ remaining = []
691
+
692
+ for proposal in pending_proposals:
693
+ if proposal['id'] in selected_ids:
694
+ # Execute this one
695
+ result = execute_staged_tool(proposal['tool'], proposal['args'])
696
+ results.append(f"**{proposal['tool']}**: {result}")
697
+ else:
698
+ # Keep in queue
699
+ remaining.append(proposal)
700
+
701
+ results_text = "## Execution Results\n\n" + "\n\n".join(results) if results else "Nothing executed."
702
+ return results_text, remaining, _format_gate_choices(remaining)
703
+
704
+
705
+ def clear_all_proposals(pending_proposals: list) -> tuple:
706
+ """Discard all pending proposals without executing.
707
+
708
+ CHANGELOG [2026-02-01 - Claude/Opus]
709
+ Safety valve β€” lets Josh throw out everything in the queue if the
710
+ agent went off track.
711
+
712
+ Returns:
713
+ Tuple of (status_message, empty_proposals, updated_gate_choices)
714
+ """
715
+ count = len(pending_proposals)
716
+ return f"πŸ—‘οΈ Cleared {count} proposal(s).", [], _format_gate_choices([])
717
+
718
+
719
+ # =============================================================================
720
+ # STATS HELPERS
721
+ # =============================================================================
722
+ # CHANGELOG [2026-02-01 - Claude/Opus]
723
+ # Helper functions to format stats for the sidebar labels.
724
+ # Called both at startup (initial render) and after each conversation turn
725
+ # (to reflect newly indexed files or saved conversations).
726
+ # =============================================================================
727
+
728
+ def _stats_label_files() -> str:
729
+ """Format the files stat for the sidebar label."""
730
+ stats = ctx.get_stats()
731
+ files = stats.get('total_files', 0)
732
+ chunks = stats.get('indexed_chunks', 0)
733
+ indexing = " ⏳" if stats.get('indexing_in_progress') else ""
734
+ return f"πŸ“‚ Files: {files} ({chunks} chunks){indexing}"
735
+
736
+
737
+ def _stats_label_convos() -> str:
738
+ """Format the conversations stat for the sidebar label."""
739
+ stats = ctx.get_stats()
740
+ convos = stats.get('conversations', 0)
741
+ cloud = " ☁️" if stats.get('persistence_configured') else ""
742
+ return f"πŸ’Ύ Conversations: {convos}{cloud}"
743
+
744
+
745
+ def refresh_stats() -> tuple:
746
+ """Refresh both stat labels. Called by the refresh button.
747
+
748
+ Returns:
749
+ Tuple of (files_label, convos_label)
750
+ """
751
+ return _stats_label_files(), _stats_label_convos()
752
+
753
+
754
+ # =============================================================================
755
+ # UI LAYOUT
756
+ # =============================================================================
757
+ # CHANGELOG [2026-02-01 - Gemini]
758
+ # RESTORED: Metrics sidebar and multi-tab layout.
759
+ #
760
+ # CHANGELOG [2026-02-01 - Claude/Opus]
761
+ # IMPLEMENTED: All the wiring. Every button, input, and display is now
762
+ # connected to actual functions.
763
+ #
764
+ # Layout:
765
+ # Tab 1 "Vibe Chat" β€” Main conversation interface with sidebar stats
766
+ # Tab 2 "Build Approval Gate" β€” HITL review for staged write operations
767
+ #
768
+ # gr.State holds the pending proposals list (per-session, survives across
769
+ # messages within the same browser tab).
770
+ # =============================================================================
771
+
772
+ with gr.Blocks(
773
+ title="🦞 Clawdbot Command Center",
774
+ theme=gr.themes.Soft()
775
+ ) as demo:
776
+ # Session state for pending proposals
777
+ pending_proposals_state = gr.State([])
778
+
779
+ gr.Markdown("# 🦞 Clawdbot Command Center\n*E-T Systems Vibe Coding Agent*")
780
+
781
+ with gr.Tabs():
782
+ # ==== TAB 1: VIBE CHAT ====
783
+ with gr.Tab("πŸ’¬ Vibe Chat"):
784
+ with gr.Row():
785
+ # ---- Sidebar ----
786
+ with gr.Column(scale=1, min_width=200):
787
+ gr.Markdown("### πŸ“Š System Status")
788
+ stats_files = gr.Markdown(_stats_label_files())
789
+ stats_convos = gr.Markdown(_stats_label_convos())
790
+ refresh_btn = gr.Button("πŸ”„ Refresh Stats", size="sm")
791
+
792
+ gr.Markdown("---")
793
+ gr.Markdown("### πŸ“Ž Upload Context")
794
+ file_input = gr.File(
795
+ label="Drop a file here",
796
+ file_types=[
797
+ '.py', '.js', '.ts', '.json', '.md', '.txt',
798
+ '.yaml', '.yml', '.html', '.css', '.sh',
799
+ '.toml', '.cfg', '.csv', '.xml'
800
+ ]
801
+ )
802
+ gr.Markdown(
803
+ "*Upload code, configs, or docs to include in your message.*"
804
+ )
805
+
806
+ # ---- Chat area ----
807
+ with gr.Column(scale=4):
808
+ chatbot = gr.Chatbot(
809
+ type="messages",
810
+ height=600,
811
+ show_label=False,
812
+ avatar_images=(None, "https://em-content.zobj.net/source/twitter/408/lobster_1f99e.png"),
813
+ )
814
+ with gr.Row():
815
+ msg = gr.Textbox(
816
+ placeholder="Ask Clawdbot to search, read, or code...",
817
+ show_label=False,
818
+ scale=6,
819
+ lines=2,
820
+ max_lines=10,
821
+ )
822
+ send_btn = gr.Button("Send", variant="primary", scale=1)
823
+
824
+ # Wire up chat submission
825
+ chat_inputs = [msg, chatbot, pending_proposals_state, file_input]
826
+ chat_outputs = [
827
+ chatbot, msg, pending_proposals_state,
828
+ # These reference components in the Gate tab β€” defined below
829
+ ]
830
+
831
+ # ==== TAB 2: BUILD APPROVAL GATE ====
832
+ with gr.Tab("πŸ›‘οΈ Build Approval Gate"):
833
+ gr.Markdown(
834
+ "### Review Staged Operations\n"
835
+ "Write operations (file writes, shell commands, branch creation) "
836
+ "are staged here for your review before execution.\n\n"
837
+ "**Select proposals to approve, then click Execute.**"
838
+ )
839
+ gate_list = gr.CheckboxGroup(
840
+ label="Pending Proposals",
841
+ choices=[],
842
+ interactive=True
843
+ )
844
+ with gr.Row():
845
+ btn_exec = gr.Button("βœ… Execute Selected", variant="primary")
846
+ btn_clear = gr.Button("πŸ—‘οΈ Clear All", variant="secondary")
847
+ gate_results = gr.Markdown("*No operations executed yet.*")
848
+
849
+ # ==================================================================
850
+ # EVENT WIRING
851
+ # ==================================================================
852
+ # CHANGELOG [2026-02-01 - Claude/Opus]
853
+ # All events are wired here, after all components are defined, so
854
+ # cross-tab references work (e.g., chat updating the gate_list).
855
+ # ==================================================================
856
+
857
+ # Chat submission (both Enter key and Send button)
858
+ full_chat_outputs = [
859
+ chatbot, msg, pending_proposals_state,
860
+ gate_list, stats_files, stats_convos
861
+ ]
862
+
863
+ msg.submit(
864
+ fn=agent_loop,
865
+ inputs=chat_inputs,
866
+ outputs=full_chat_outputs
867
+ )
868
+ send_btn.click(
869
+ fn=agent_loop,
870
+ inputs=chat_inputs,
871
+ outputs=full_chat_outputs
872
+ )
873
+
874
+ # Refresh stats button
875
+ refresh_btn.click(
876
+ fn=refresh_stats,
877
+ inputs=[],
878
+ outputs=[stats_files, stats_convos]
879
+ )
880
+
881
+ # Build Approval Gate buttons
882
+ btn_exec.click(
883
+ fn=execute_approved_proposals,
884
+ inputs=[gate_list, pending_proposals_state],
885
+ outputs=[gate_results, pending_proposals_state, gate_list]
886
+ )
887
+ btn_clear.click(
888
+ fn=clear_all_proposals,
889
+ inputs=[pending_proposals_state],
890
+ outputs=[gate_results, pending_proposals_state, gate_list]
891
+ )
892
+
893
+
894
+ # =============================================================================
895
+ # LAUNCH
896
+ # =============================================================================
897
+ # CHANGELOG [2026-02-01 - Claude/Opus]
898
+ # Standard HF Spaces launch config. 0.0.0.0 binds to all interfaces
899
+ # (required for Docker). Port 7860 is the HF Spaces standard.
900
+ # =============================================================================
901
+
902
+ if __name__ == "__main__":
903
+ demo.launch(server_name="0.0.0.0", server_port=7860)
recursive_context.py ADDED
@@ -0,0 +1,750 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Recursive Context Manager for Clawdbot
3
+
4
+ CHANGELOG [2025-01-28 - Josh]
5
+ CREATED: Initial recursive context manager with ChromaDB vector search,
6
+ file reading, and conversation persistence. Based on MIT Recursive
7
+ Language Model technique for unlimited context.
8
+
9
+ CHANGELOG [2026-01-31 - Gemini]
10
+ ADDED: Phase 1 Orchestrator tools: create_shadow_branch, write_file, shell_execute.
11
+ ADDED: Documentation Scanner to mandate Living Changelog headers.
12
+ FIXED: PermissionError on /.cache by forcing ONNXMiniLM_L6_V2.DOWNLOAD_PATH.
13
+
14
+ CHANGELOG [2026-01-31 - Claude/Opus]
15
+ ADDED: get_stats() method β€” was called by app.py but never defined, causing
16
+ crash on startup. Returns dict with file counts, conversation counts,
17
+ collection sizes, and persistence status.
18
+ ADDED: list_files() method β€” directory exploration tool for the agent.
19
+ Returns tree of files/dirs at a given path relative to repo root.
20
+ ADDED: search_conversations() method β€” semantic search over saved conversation
21
+ history in ChromaDB. Essential for persistent memory across sessions.
22
+ ADDED: search_testament() method β€” searches for Testament/architectural decision
23
+ files and returns matching content. Falls back to codebase search if no
24
+ dedicated testament files exist.
25
+ ADDED: index_repository() method β€” actually indexes the repo into ChromaDB on
26
+ init. Without this, search_code() always returned empty because nothing
27
+ was ever added to the codebase collection. Runs in background thread to
28
+ avoid blocking startup.
29
+ PRESERVED: All existing functions from prior changelogs remain intact.
30
+ HFDatasetPersistence class, create_shadow_branch, write_file, shell_execute,
31
+ search_code, read_file, save_conversation_turn β€” all unchanged.
32
+ NOTE: get_stats() is critical β€” app.py calls it at module level during UI
33
+ construction AND in the system prompt. Missing it = instant crash.
34
+ """
35
+
36
+ from pathlib import Path
37
+ from typing import List, Dict, Optional, Tuple
38
+ import chromadb
39
+ from chromadb.config import Settings
40
+ from chromadb.utils.embedding_functions import ONNXMiniLM_L6_V2
41
+ import hashlib
42
+ import json
43
+ import os
44
+ import time
45
+ import threading
46
+ import subprocess
47
+ import re
48
+
49
+
50
+ # =============================================================================
51
+ # CHROMA DB PATH SELECTION
52
+ # =============================================================================
53
+ # CHANGELOG [2026-01-31 - Gemini]
54
+ # HF Spaces Docker containers wipe everything EXCEPT /data on restart.
55
+ # We prefer /data/chroma_db (persistent) but fall back to /workspace/chroma_db
56
+ # (ephemeral) if /data isn't writable.
57
+ # =============================================================================
58
+
59
+ def _select_chroma_path():
60
+ """HF Spaces Docker containers wipe everything EXCEPT /data on restart."""
61
+ data_path = Path("/data/chroma_db")
62
+ try:
63
+ data_path.mkdir(parents=True, exist_ok=True)
64
+ test_file = data_path / ".write_test"
65
+ test_file.write_text("test")
66
+ test_file.unlink()
67
+ return str(data_path)
68
+ except (OSError, PermissionError):
69
+ workspace_path = Path("/workspace/chroma_db")
70
+ workspace_path.mkdir(parents=True, exist_ok=True)
71
+ return str(workspace_path)
72
+
73
+
74
+ CHROMA_DB_PATH = _select_chroma_path()
75
+
76
+
77
+ # =============================================================================
78
+ # HF DATASET PERSISTENCE
79
+ # =============================================================================
80
+ # CHANGELOG [2026-01-31 - Gemini]
81
+ # Handles durable cloud storage via HF Dataset repository. Conversations
82
+ # survive Space restarts by backing up to a private dataset repo.
83
+ # =============================================================================
84
+
85
+ class HFDatasetPersistence:
86
+ """Handles durable cloud storage via your 1TB PRO Dataset repository."""
87
+
88
+ def __init__(self, repo_id: str = None):
89
+ from huggingface_hub import HfApi
90
+ self.api = HfApi()
91
+ self.repo_id = repo_id or os.getenv("MEMORY_REPO")
92
+ self.token = os.getenv("HF_TOKEN") or os.getenv("HUGGINGFACE_TOKEN")
93
+ self._repo_ready = False
94
+
95
+ if self.repo_id and self.token:
96
+ self._ensure_repo_exists()
97
+
98
+ def _ensure_repo_exists(self):
99
+ if self._repo_ready:
100
+ return
101
+ try:
102
+ self.api.repo_info(
103
+ repo_id=self.repo_id,
104
+ repo_type="dataset",
105
+ token=self.token
106
+ )
107
+ self._repo_ready = True
108
+ except Exception:
109
+ try:
110
+ self.api.create_repo(
111
+ repo_id=self.repo_id,
112
+ repo_type="dataset",
113
+ private=True,
114
+ token=self.token
115
+ )
116
+ self._repo_ready = True
117
+ except Exception:
118
+ pass
119
+
120
+ @property
121
+ def is_configured(self):
122
+ return bool(self.repo_id and self.token)
123
+
124
+ def save_conversations(self, data: List[Dict]):
125
+ if not self.is_configured:
126
+ return
127
+ temp = Path("/tmp/conv_backup.json")
128
+ temp.write_text(json.dumps(data, indent=2))
129
+ try:
130
+ self.api.upload_file(
131
+ path_or_fileobj=str(temp),
132
+ path_in_repo="conversations.json",
133
+ repo_id=self.repo_id,
134
+ repo_type="dataset",
135
+ token=self.token
136
+ )
137
+ except Exception:
138
+ pass
139
+
140
+ def load_conversations(self) -> List[Dict]:
141
+ if not self.is_configured:
142
+ return []
143
+ try:
144
+ from huggingface_hub import hf_hub_download
145
+ local_path = hf_hub_download(
146
+ repo_id=self.repo_id,
147
+ filename="conversations.json",
148
+ repo_type="dataset",
149
+ token=self.token
150
+ )
151
+ with open(local_path, 'r') as f:
152
+ return json.load(f)
153
+ except Exception:
154
+ return []
155
+
156
+
157
+ # =============================================================================
158
+ # RECURSIVE CONTEXT MANAGER
159
+ # =============================================================================
160
+
161
+ class RecursiveContextManager:
162
+ """Manages unlimited context and vibe-coding tools for E-T Systems.
163
+
164
+ CHANGELOG [2026-01-31 - Claude/Opus]
165
+ This is the core class. It provides:
166
+ - ChromaDB-backed semantic search over the codebase and conversations
167
+ - File read/write with changelog enforcement
168
+ - Shell execution for build tasks
169
+ - Shadow branching for safe experimentation
170
+ - Stats reporting for the UI sidebar
171
+ - Repository indexing (background thread on init)
172
+
173
+ ARCHITECTURE NOTE:
174
+ The class is initialized once at module level in app.py. That means
175
+ __init__ runs during import, so it MUST NOT block or crash. Heavy work
176
+ (like indexing the repo) is dispatched to a background thread.
177
+ get_stats() must return sensible defaults even before indexing completes.
178
+ """
179
+
180
+ # =========================================================================
181
+ # FILE EXTENSIONS TO INDEX
182
+ # =========================================================================
183
+ # CHANGELOG [2026-01-31 - Claude/Opus]
184
+ # Only index code/text files. Binary files, images, and large data files
185
+ # would pollute the vector space and waste embedding compute.
186
+ # =========================================================================
187
+ INDEXABLE_EXTENSIONS = {
188
+ '.py', '.js', '.ts', '.jsx', '.tsx', '.mjs', '.cjs',
189
+ '.json', '.yaml', '.yml', '.toml',
190
+ '.md', '.txt', '.rst',
191
+ '.html', '.css', '.scss',
192
+ '.sh', '.bash',
193
+ '.sql',
194
+ '.env.example', # Not .env itself β€” that's sensitive
195
+ '.gitignore', '.dockerignore',
196
+ '.cfg', '.ini', '.conf',
197
+ }
198
+
199
+ # Max file size to index (256KB). Larger files are likely generated/data.
200
+ MAX_INDEX_SIZE = 256 * 1024
201
+
202
+ def __init__(self, repo_path: str):
203
+ self.repo_path = Path(repo_path)
204
+ self.persistence = HFDatasetPersistence()
205
+
206
+ # =================================================================
207
+ # EMBEDDING CONFIG
208
+ # =================================================================
209
+ # CHANGELOG [2026-01-31 - Gemini]
210
+ # Fixes /.cache PermissionError. ChromaDB's ONNXMiniLM_L6_V2 tries
211
+ # to download model weights to ~/.cache. In Docker as UID 1000,
212
+ # that's /.cache (root-owned). We override DOWNLOAD_PATH to a
213
+ # writable directory.
214
+ # =================================================================
215
+ self.embedding_function = ONNXMiniLM_L6_V2()
216
+ cache_dir = os.getenv("CHROMA_CACHE_DIR", "/tmp/.cache/chroma")
217
+ self.embedding_function.DOWNLOAD_PATH = cache_dir
218
+ os.makedirs(cache_dir, exist_ok=True)
219
+
220
+ self.chroma_client = chromadb.PersistentClient(
221
+ path=CHROMA_DB_PATH,
222
+ settings=Settings(anonymized_telemetry=False, allow_reset=True)
223
+ )
224
+
225
+ c_name = self._get_collection_name()
226
+ self.collection = self.chroma_client.get_or_create_collection(
227
+ name=c_name,
228
+ embedding_function=self.embedding_function
229
+ )
230
+ self.conversations = self.chroma_client.get_or_create_collection(
231
+ name=f"conv_{c_name.split('_')[1]}",
232
+ embedding_function=self.embedding_function
233
+ )
234
+
235
+ # Restore conversations from cloud backup if local is empty
236
+ if self.conversations.count() == 0:
237
+ self._restore_from_cloud()
238
+
239
+ # =================================================================
240
+ # BACKGROUND INDEXING
241
+ # =================================================================
242
+ # CHANGELOG [2026-01-31 - Claude/Opus]
243
+ # Index the repository in a background thread so startup isn't
244
+ # blocked. The _indexing flag lets get_stats() report status.
245
+ # =================================================================
246
+ self._indexing = False
247
+ self._index_error = None
248
+ self._indexed_file_count = 0
249
+ if self.repo_path.exists() and self.repo_path.is_dir():
250
+ self._start_background_indexing()
251
+
252
+ def _restore_from_cloud(self):
253
+ """Restore conversation history from HF Dataset backup.
254
+
255
+ CHANGELOG [2026-01-31 - Gemini]
256
+ Called during init if the local ChromaDB conversations collection
257
+ is empty. Pulls from the cloud dataset repo to recover history
258
+ after a Space restart.
259
+ """
260
+ data = self.persistence.load_conversations()
261
+ for conv in data:
262
+ try:
263
+ self.conversations.add(
264
+ documents=[conv["document"]],
265
+ metadatas=[conv["metadata"]],
266
+ ids=[conv["id"]]
267
+ )
268
+ except Exception:
269
+ pass
270
+
271
+ def _get_collection_name(self) -> str:
272
+ """Generate a deterministic collection name from the repo path.
273
+
274
+ CHANGELOG [2025-01-28 - Josh]
275
+ Uses MD5 hash of repo path so different repos get different
276
+ collections within the same ChromaDB instance.
277
+ """
278
+ path_hash = hashlib.md5(str(self.repo_path).encode()).hexdigest()[:8]
279
+ return f"codebase_{path_hash}"
280
+
281
+ # =====================================================================
282
+ # REPOSITORY INDEXING
283
+ # =====================================================================
284
+ # CHANGELOG [2026-01-31 - Claude/Opus]
285
+ # Without indexing, search_code() always returns empty results because
286
+ # nothing is ever added to the ChromaDB codebase collection. This walks
287
+ # the repo, reads indexable files, chunks them, and upserts into ChromaDB.
288
+ #
289
+ # DESIGN DECISIONS:
290
+ # - Background thread: Don't block Gradio startup. Users can chat while
291
+ # indexing runs. get_stats() shows indexing progress.
292
+ # - Chunk by logical blocks: Split files into ~50-line chunks with overlap
293
+ # so semantic search finds relevant sections, not just file-level matches.
294
+ # - Upsert (not add): Safe to re-run. If the file was already indexed
295
+ # with the same content hash, ChromaDB skips it.
296
+ # - Skip .git, __pycache__, node_modules, venv: No value in indexing these.
297
+ #
298
+ # TESTED ALTERNATIVES (graveyard):
299
+ # - Indexing entire files as single documents: Poor search precision.
300
+ # A 500-line file matching on line 3 returns all 500 lines.
301
+ # - Line-by-line indexing: Too many tiny documents, poor semantic context.
302
+ # - Synchronous indexing: Blocks startup for 30+ seconds on large repos.
303
+ # =====================================================================
304
+
305
+ def _start_background_indexing(self):
306
+ """Kick off repo indexing in a daemon thread."""
307
+ self._indexing = True
308
+ thread = threading.Thread(target=self._index_repository, daemon=True)
309
+ thread.start()
310
+
311
+ def _index_repository(self):
312
+ """Walk the repo and index code files into ChromaDB.
313
+
314
+ Runs in background thread. Sets self._indexing = False when done.
315
+ """
316
+ try:
317
+ skip_dirs = {
318
+ '.git', '__pycache__', 'node_modules', 'venv', '.venv',
319
+ 'env', '.eggs', 'dist', 'build', '.next', '.nuxt',
320
+ 'chroma_db', '.chroma'
321
+ }
322
+ count = 0
323
+
324
+ for file_path in self.repo_path.rglob('*'):
325
+ # Skip directories and non-indexable files
326
+ if file_path.is_dir():
327
+ continue
328
+
329
+ # Skip files in excluded directories
330
+ if any(skip in file_path.parts for skip in skip_dirs):
331
+ continue
332
+
333
+ # Check extension
334
+ suffix = file_path.suffix.lower()
335
+ if suffix not in self.INDEXABLE_EXTENSIONS:
336
+ # Also allow extensionless files if they look like configs
337
+ if file_path.name not in {
338
+ 'Dockerfile', 'Makefile', 'Procfile',
339
+ '.gitignore', '.dockerignore', '.env.example'
340
+ }:
341
+ continue
342
+
343
+ # Check size
344
+ try:
345
+ if file_path.stat().st_size > self.MAX_INDEX_SIZE:
346
+ continue
347
+ except OSError:
348
+ continue
349
+
350
+ # Read and chunk the file
351
+ try:
352
+ content = file_path.read_text(encoding='utf-8', errors='ignore')
353
+ except (OSError, UnicodeDecodeError):
354
+ continue
355
+
356
+ if not content.strip():
357
+ continue
358
+
359
+ rel_path = str(file_path.relative_to(self.repo_path))
360
+ chunks = self._chunk_file(content, rel_path)
361
+
362
+ for chunk_id, chunk_text, chunk_meta in chunks:
363
+ try:
364
+ self.collection.upsert(
365
+ documents=[chunk_text],
366
+ metadatas=[chunk_meta],
367
+ ids=[chunk_id]
368
+ )
369
+ except Exception:
370
+ continue
371
+
372
+ count += 1
373
+ self._indexed_file_count = count
374
+
375
+ except Exception as e:
376
+ self._index_error = str(e)
377
+ finally:
378
+ self._indexing = False
379
+
380
+ def _chunk_file(self, content: str, rel_path: str) -> List[Tuple[str, str, dict]]:
381
+ """Split a file into overlapping chunks for better search precision.
382
+
383
+ CHANGELOG [2026-01-31 - Claude/Opus]
384
+ Returns list of (id, text, metadata) tuples ready for ChromaDB upsert.
385
+ Chunks are ~50 lines with 10-line overlap so context isn't lost at
386
+ chunk boundaries.
387
+
388
+ Args:
389
+ content: Full file text
390
+ rel_path: Path relative to repo root (used in metadata and IDs)
391
+
392
+ Returns:
393
+ List of (chunk_id, chunk_text, metadata_dict) tuples
394
+ """
395
+ lines = content.split('\n')
396
+ chunks = []
397
+ chunk_size = 50
398
+ overlap = 10
399
+
400
+ if len(lines) <= chunk_size:
401
+ # Small file β€” index as single chunk
402
+ content_hash = hashlib.md5(content.encode()).hexdigest()[:12]
403
+ chunk_id = f"{rel_path}::full::{content_hash}"
404
+ meta = {
405
+ 'path': rel_path,
406
+ 'chunk': 'full',
407
+ 'lines': f"1-{len(lines)}",
408
+ 'total_lines': len(lines)
409
+ }
410
+ chunks.append((chunk_id, content, meta))
411
+ else:
412
+ # Larger file β€” split into overlapping chunks
413
+ start = 0
414
+ chunk_num = 0
415
+ while start < len(lines):
416
+ end = min(start + chunk_size, len(lines))
417
+ chunk_text = '\n'.join(lines[start:end])
418
+ content_hash = hashlib.md5(chunk_text.encode()).hexdigest()[:12]
419
+ chunk_id = f"{rel_path}::chunk{chunk_num}::{content_hash}"
420
+ meta = {
421
+ 'path': rel_path,
422
+ 'chunk': f"chunk_{chunk_num}",
423
+ 'lines': f"{start + 1}-{end}",
424
+ 'total_lines': len(lines)
425
+ }
426
+ chunks.append((chunk_id, chunk_text, meta))
427
+ chunk_num += 1
428
+ start += chunk_size - overlap
429
+
430
+ return chunks
431
+
432
+ # =====================================================================
433
+ # STATS (NEW β€” was missing, caused crash)
434
+ # =====================================================================
435
+ # CHANGELOG [2026-01-31 - Claude/Opus]
436
+ # app.py calls ctx.get_stats() at module level during Gradio Block
437
+ # construction AND in the system prompt for every message. It expected
438
+ # a dict with 'conversations', 'total_files', etc. Without this method,
439
+ # the app crashes immediately on import.
440
+ #
441
+ # Returns safe defaults during indexing so the UI can render.
442
+ # =====================================================================
443
+
444
+ def get_stats(self) -> dict:
445
+ """Return system statistics for the UI sidebar and system prompt.
446
+
447
+ Returns:
448
+ dict with keys: total_files, indexed_chunks, conversations,
449
+ chroma_path, persistence_configured, indexing_in_progress,
450
+ index_error
451
+ """
452
+ return {
453
+ 'total_files': self._indexed_file_count,
454
+ 'indexed_chunks': self.collection.count(),
455
+ 'conversations': self.conversations.count(),
456
+ 'chroma_path': CHROMA_DB_PATH,
457
+ 'persistence_configured': self.persistence.is_configured,
458
+ 'indexing_in_progress': self._indexing,
459
+ 'index_error': self._index_error,
460
+ }
461
+
462
+ # =====================================================================
463
+ # PHASE 1 ORCHESTRATOR TOOLS (preserved from Gemini)
464
+ # =====================================================================
465
+
466
+ def create_shadow_branch(self):
467
+ """Creates a timestamped backup branch of the E-T Systems Space.
468
+
469
+ CHANGELOG [2026-01-31 - Gemini]
470
+ Safety net before any destructive operations. Creates a branch
471
+ named vibe-backup-YYYYMMDD-HHMMSS on the E-T Systems HF Space
472
+ so you can always roll back.
473
+ """
474
+ timestamp = time.strftime("%Y%m%d-%H%M%S")
475
+ branch_name = f"vibe-backup-{timestamp}"
476
+ try:
477
+ repo_id = os.getenv(
478
+ "ET_SYSTEMS_SPACE",
479
+ "Executor-Tyrant-Framework/Executor-Framworks_Full_VDB"
480
+ )
481
+ self.persistence.api.create_branch(
482
+ repo_id=repo_id,
483
+ branch=branch_name,
484
+ repo_type="space",
485
+ token=self.persistence.token
486
+ )
487
+ return f"πŸ›‘οΈ Shadow branch created: {branch_name}"
488
+ except Exception as e:
489
+ return f"⚠️ Shadow branch failed: {e}"
490
+
491
+ def write_file(self, path: str, content: str):
492
+ """Writes file strictly if valid CHANGELOG is present.
493
+
494
+ CHANGELOG [2026-01-31 - Gemini]
495
+ Enforces the living changelog pattern. Any code written by an agent
496
+ MUST include a CHANGELOG [YYYY-MM-DD - AgentName] header or the
497
+ write is rejected. This is non-negotiable for the E-T Systems
498
+ development workflow.
499
+
500
+ Args:
501
+ path: Relative path within the repo (e.g., "server/routes.ts")
502
+ content: Full file content (must contain CHANGELOG header)
503
+
504
+ Returns:
505
+ Success message or rejection reason
506
+ """
507
+ if not re.search(r"CHANGELOG \[\d{4}-\d{2}-\d{2} - \w+\]", content):
508
+ return "REJECTED: Missing mandatory CHANGELOG [YYYY-MM-DD - AgentName] header."
509
+
510
+ try:
511
+ full_path = self.repo_path / path
512
+ full_path.parent.mkdir(parents=True, exist_ok=True)
513
+ full_path.write_text(content)
514
+ return f"βœ… Successfully wrote {path}"
515
+ except Exception as e:
516
+ return f"Error writing file: {e}"
517
+
518
+ def shell_execute(self, command: str):
519
+ """Runs shell commands in the /workspace directory.
520
+
521
+ CHANGELOG [2026-01-31 - Gemini]
522
+ Used for build tasks, git operations, dependency installs, etc.
523
+ Timeout of 30 seconds prevents runaway processes. Captures both
524
+ stdout and stderr for full diagnostic output.
525
+
526
+ Args:
527
+ command: Shell command string to execute
528
+
529
+ Returns:
530
+ Combined stdout/stderr output or error message
531
+ """
532
+ try:
533
+ result = subprocess.run(
534
+ command, shell=True, capture_output=True, text=True,
535
+ cwd=self.repo_path, timeout=30
536
+ )
537
+ return f"STDOUT: {result.stdout}\nSTDERR: {result.stderr}"
538
+ except Exception as e:
539
+ return f"Execution Error: {e}"
540
+
541
+ # =====================================================================
542
+ # RECURSIVE SEARCH TOOLS
543
+ # =====================================================================
544
+
545
+ def search_code(self, query: str, n: int = 5) -> List[Dict]:
546
+ """Semantic search across the indexed codebase.
547
+
548
+ CHANGELOG [2025-01-28 - Josh]
549
+ Core tool for the MIT recursive context technique. The model calls
550
+ this to find relevant code without loading the entire repo into
551
+ context.
552
+
553
+ Args:
554
+ query: Natural language search query
555
+ n: Max number of results to return (default 5)
556
+
557
+ Returns:
558
+ List of dicts with 'file' (path) and 'snippet' (first 500 chars)
559
+ """
560
+ if self.collection.count() == 0:
561
+ return []
562
+ actual_n = min(n, self.collection.count())
563
+ res = self.collection.query(query_texts=[query], n_results=actual_n)
564
+ return [
565
+ {"file": m['path'], "snippet": d[:500]}
566
+ for d, m in zip(res['documents'][0], res['metadatas'][0])
567
+ ]
568
+
569
+ def read_file(self, path: str, start_line: int = None, end_line: int = None) -> str:
570
+ """Read a specific file, optionally a line range.
571
+
572
+ CHANGELOG [2025-01-28 - Josh]
573
+ Direct file access for when the model knows exactly what it needs.
574
+
575
+ CHANGELOG [2026-01-31 - Claude/Opus]
576
+ Added optional start_line/end_line params for reading specific
577
+ sections without loading entire large files into context.
578
+
579
+ Args:
580
+ path: Relative path within repo (e.g., "server/routes.ts")
581
+ start_line: Optional 1-based start line
582
+ end_line: Optional 1-based end line
583
+
584
+ Returns:
585
+ File contents (full or sliced) or "File not found." message
586
+ """
587
+ p = self.repo_path / path
588
+ if not p.exists():
589
+ return f"File not found: {path}"
590
+ try:
591
+ content = p.read_text(encoding='utf-8', errors='ignore')
592
+ if start_line is not None or end_line is not None:
593
+ lines = content.split('\n')
594
+ start = (start_line or 1) - 1 # Convert to 0-based
595
+ end = end_line or len(lines)
596
+ sliced = lines[start:end]
597
+ return '\n'.join(sliced)
598
+ return content
599
+ except Exception as e:
600
+ return f"Error reading {path}: {e}"
601
+
602
+ def list_files(self, path: str = "", max_depth: int = 3) -> str:
603
+ """List files and directories at a given path.
604
+
605
+ CHANGELOG [2026-01-31 - Claude/Opus]
606
+ Directory exploration tool. The agent needs to know what files exist
607
+ before it can read or search them. Returns a tree-formatted listing
608
+ up to max_depth levels deep.
609
+
610
+ Args:
611
+ path: Relative path within repo (default "" = repo root)
612
+ max_depth: How many levels deep to list (default 3)
613
+
614
+ Returns:
615
+ Formatted string showing directory tree
616
+ """
617
+ target = self.repo_path / path
618
+ if not target.exists():
619
+ return f"Path not found: {path}"
620
+ if not target.is_dir():
621
+ return f"Not a directory: {path}"
622
+
623
+ skip_dirs = {
624
+ '.git', '__pycache__', 'node_modules', 'venv', '.venv',
625
+ 'chroma_db', '.chroma', 'dist', 'build'
626
+ }
627
+
628
+ lines = [f"πŸ“‚ {path or '(repo root)'}"]
629
+
630
+ def _walk(dir_path: Path, prefix: str, depth: int):
631
+ if depth > max_depth:
632
+ return
633
+ try:
634
+ entries = sorted(dir_path.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))
635
+ except PermissionError:
636
+ return
637
+
638
+ for i, entry in enumerate(entries):
639
+ if entry.name in skip_dirs or entry.name.startswith('.'):
640
+ continue
641
+ is_last = (i == len(entries) - 1)
642
+ connector = "└── " if is_last else "β”œβ”€β”€ "
643
+ if entry.is_dir():
644
+ lines.append(f"{prefix}{connector}πŸ“ {entry.name}/")
645
+ extension = " " if is_last else "β”‚ "
646
+ _walk(entry, prefix + extension, depth + 1)
647
+ else:
648
+ size = entry.stat().st_size
649
+ size_str = f"{size:,}B" if size < 1024 else f"{size // 1024:,}KB"
650
+ lines.append(f"{prefix}{connector}πŸ“„ {entry.name} ({size_str})")
651
+
652
+ _walk(target, "", 1)
653
+ return '\n'.join(lines)
654
+
655
+ def search_conversations(self, query: str, n: int = 5) -> List[Dict]:
656
+ """Semantic search over past conversation history.
657
+
658
+ CHANGELOG [2026-01-31 - Claude/Opus]
659
+ This is how Clawdbot "remembers" past discussions. Conversations
660
+ are saved to ChromaDB via save_conversation_turn() and backed up
661
+ to the HF Dataset repo. This searches them semantically.
662
+
663
+ Args:
664
+ query: Natural language search query
665
+ n: Max results to return
666
+
667
+ Returns:
668
+ List of dicts with 'content' and 'metadata' from matched turns
669
+ """
670
+ if self.conversations.count() == 0:
671
+ return []
672
+ actual_n = min(n, self.conversations.count())
673
+ res = self.conversations.query(query_texts=[query], n_results=actual_n)
674
+ results = []
675
+ for doc, meta in zip(res['documents'][0], res['metadatas'][0]):
676
+ results.append({
677
+ 'content': doc[:1000], # Cap at 1000 chars per result
678
+ 'metadata': meta
679
+ })
680
+ return results
681
+
682
+ def search_testament(self, query: str, n: int = 5) -> List[Dict]:
683
+ """Search for Testament/architectural decision records.
684
+
685
+ CHANGELOG [2026-01-31 - Claude/Opus]
686
+ The Testament contains design decisions, constitutional principles,
687
+ and architectural rationale for E-T Systems. This searches for
688
+ testament-specific files first (TESTAMENT.md, DECISIONS.md, etc.),
689
+ then falls back to general codebase search filtered for decision-
690
+ related content.
691
+
692
+ Args:
693
+ query: What architectural decision to search for
694
+ n: Max results
695
+
696
+ Returns:
697
+ List of dicts with 'file' and 'snippet' from matching documents
698
+ """
699
+ # First, look for dedicated testament/decision files
700
+ testament_names = {
701
+ 'testament', 'decisions', 'adr', 'architecture',
702
+ 'principles', 'constitution', 'changelog', 'design'
703
+ }
704
+
705
+ testament_results = []
706
+ if self.collection.count() > 0:
707
+ # Search the codebase but prefer testament-like files
708
+ actual_n = min(n * 2, self.collection.count()) # Get extra, then filter
709
+ res = self.collection.query(query_texts=[query], n_results=actual_n)
710
+ for doc, meta in zip(res['documents'][0], res['metadatas'][0]):
711
+ path_lower = meta.get('path', '').lower()
712
+ # Check if this is a testament/decision file
713
+ is_testament = any(name in path_lower for name in testament_names)
714
+ testament_results.append({
715
+ 'file': meta['path'],
716
+ 'snippet': doc[:500],
717
+ 'is_testament': is_testament
718
+ })
719
+
720
+ # Sort: testament files first, then other matches
721
+ testament_results.sort(key=lambda r: (not r.get('is_testament', False)))
722
+ return testament_results[:n]
723
+
724
+ def save_conversation_turn(self, user_msg: str, assistant_msg: str, turn_id):
725
+ """Save a conversation turn to ChromaDB and cloud backup.
726
+
727
+ CHANGELOG [2025-01-28 - Josh]
728
+ Persistent memory across sessions. Every user/assistant exchange
729
+ gets embedded and stored in ChromaDB for semantic retrieval later.
730
+
731
+ CHANGELOG [2026-01-31 - Gemini]
732
+ Added cloud backup via HFDatasetPersistence so conversations survive
733
+ Space restarts.
734
+
735
+ Args:
736
+ user_msg: The user's message text
737
+ assistant_msg: The assistant's response text
738
+ turn_id: Conversation turn number for ordering
739
+ """
740
+ combined = f"USER: {user_msg}\n\nASSISTANT: {assistant_msg}"
741
+ unique_id = f"turn_{int(time.time())}_{turn_id}"
742
+ self.conversations.add(
743
+ documents=[combined],
744
+ metadatas=[{"turn": turn_id, "timestamp": int(time.time())}],
745
+ ids=[unique_id]
746
+ )
747
+ # Cloud backup
748
+ self.persistence.save_conversations([
749
+ {"document": combined, "metadata": {"turn": turn_id}, "id": unique_id}
750
+ ])