code-context-control 2.28.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. cli/__init__.py +1 -0
  2. cli/_hook_utils.py +99 -0
  3. cli/c3.py +6152 -0
  4. cli/commands/__init__.py +1 -0
  5. cli/commands/common.py +312 -0
  6. cli/commands/parser.py +286 -0
  7. cli/docs.html +3178 -0
  8. cli/edits.html +878 -0
  9. cli/hook_auto_snapshot.py +142 -0
  10. cli/hook_c3_signal.py +61 -0
  11. cli/hook_c3read.py +116 -0
  12. cli/hook_edit_ledger.py +213 -0
  13. cli/hook_edit_unlock.py +170 -0
  14. cli/hook_filter.py +130 -0
  15. cli/hook_ghost_files.py +238 -0
  16. cli/hook_pretool_enforce.py +334 -0
  17. cli/hook_read.py +200 -0
  18. cli/hook_session_stats.py +62 -0
  19. cli/hook_terse_advisor.py +190 -0
  20. cli/hub.html +3764 -0
  21. cli/hub_server.py +1619 -0
  22. cli/mcp_proxy.py +428 -0
  23. cli/mcp_server.py +660 -0
  24. cli/server.py +2985 -0
  25. cli/tools/__init__.py +4 -0
  26. cli/tools/_helpers.py +65 -0
  27. cli/tools/agent.py +1165 -0
  28. cli/tools/compress.py +215 -0
  29. cli/tools/delegate.py +1184 -0
  30. cli/tools/edit.py +313 -0
  31. cli/tools/edits.py +118 -0
  32. cli/tools/filter.py +285 -0
  33. cli/tools/impact.py +163 -0
  34. cli/tools/memory.py +469 -0
  35. cli/tools/read.py +224 -0
  36. cli/tools/search.py +337 -0
  37. cli/tools/session.py +95 -0
  38. cli/tools/shell.py +193 -0
  39. cli/tools/status.py +306 -0
  40. cli/tools/validate.py +310 -0
  41. cli/ui/api.js +36 -0
  42. cli/ui/app.js +207 -0
  43. cli/ui/components/chat.js +758 -0
  44. cli/ui/components/dashboard.js +689 -0
  45. cli/ui/components/edits.js +220 -0
  46. cli/ui/components/instructions.js +481 -0
  47. cli/ui/components/memory.js +626 -0
  48. cli/ui/components/sessions.js +606 -0
  49. cli/ui/components/settings.js +1404 -0
  50. cli/ui/components/sidebar.js +156 -0
  51. cli/ui/icons.js +51 -0
  52. cli/ui/shared.js +119 -0
  53. cli/ui/theme.js +22 -0
  54. cli/ui.html +168 -0
  55. cli/ui_legacy.html +6797 -0
  56. cli/ui_nano.html +503 -0
  57. code_context_control-2.28.0.dist-info/METADATA +248 -0
  58. code_context_control-2.28.0.dist-info/RECORD +150 -0
  59. code_context_control-2.28.0.dist-info/WHEEL +5 -0
  60. code_context_control-2.28.0.dist-info/entry_points.txt +4 -0
  61. code_context_control-2.28.0.dist-info/licenses/LICENSE +201 -0
  62. code_context_control-2.28.0.dist-info/top_level.txt +5 -0
  63. core/__init__.py +75 -0
  64. core/config.py +269 -0
  65. core/ide.py +188 -0
  66. oracle/__init__.py +1 -0
  67. oracle/config.py +75 -0
  68. oracle/oracle.html +3900 -0
  69. oracle/oracle_server.py +663 -0
  70. oracle/services/__init__.py +1 -0
  71. oracle/services/c3_bridge.py +210 -0
  72. oracle/services/chat_engine.py +1103 -0
  73. oracle/services/chat_store.py +155 -0
  74. oracle/services/cross_memory.py +154 -0
  75. oracle/services/federated_graph.py +463 -0
  76. oracle/services/health_checker.py +117 -0
  77. oracle/services/insight_engine.py +307 -0
  78. oracle/services/memory_reader.py +106 -0
  79. oracle/services/memory_writer.py +182 -0
  80. oracle/services/ollama_bridge.py +332 -0
  81. oracle/services/project_scanner.py +87 -0
  82. oracle/services/review_agent.py +206 -0
  83. services/__init__.py +1 -0
  84. services/activity_log.py +93 -0
  85. services/agent_base.py +124 -0
  86. services/agents.py +1529 -0
  87. services/auto_memory.py +407 -0
  88. services/bench/__init__.py +6 -0
  89. services/bench/external/__init__.py +29 -0
  90. services/bench/external/aider_polyglot.py +405 -0
  91. services/bench/external/swe_bench.py +485 -0
  92. services/benchmark_dashboard.py +596 -0
  93. services/claude_md.py +785 -0
  94. services/compressor.py +592 -0
  95. services/context_snapshot.py +356 -0
  96. services/conversation_store.py +870 -0
  97. services/doc_index.py +537 -0
  98. services/e2e_benchmark.py +2884 -0
  99. services/e2e_evaluator.py +396 -0
  100. services/e2e_tasks.py +743 -0
  101. services/edit_ledger.py +459 -0
  102. services/embedding_index.py +341 -0
  103. services/error_reporting.py +123 -0
  104. services/file_memory.py +734 -0
  105. services/hub_service.py +585 -0
  106. services/indexer.py +712 -0
  107. services/memory.py +318 -0
  108. services/memory_consolidator.py +538 -0
  109. services/memory_graph.py +382 -0
  110. services/memory_grounder.py +304 -0
  111. services/memory_scorer.py +246 -0
  112. services/metrics.py +86 -0
  113. services/notifications.py +209 -0
  114. services/ollama_client.py +201 -0
  115. services/output_filter.py +488 -0
  116. services/parser.py +1238 -0
  117. services/project_manager.py +579 -0
  118. services/protocol.py +306 -0
  119. services/proxy_state.py +152 -0
  120. services/retrieval_broker.py +129 -0
  121. services/router.py +414 -0
  122. services/runtime.py +326 -0
  123. services/session_benchmark.py +1945 -0
  124. services/session_manager.py +1026 -0
  125. services/session_preloader.py +251 -0
  126. services/text_index.py +90 -0
  127. services/tool_classifier.py +176 -0
  128. services/transcript_index.py +340 -0
  129. services/validation_cache.py +155 -0
  130. services/vector_store.py +299 -0
  131. services/version_tracker.py +271 -0
  132. services/watcher.py +192 -0
  133. tui/__init__.py +0 -0
  134. tui/backend.py +59 -0
  135. tui/main.py +145 -0
  136. tui/screens/__init__.py +1 -0
  137. tui/screens/benchmark_view.py +109 -0
  138. tui/screens/claudemd_view.py +46 -0
  139. tui/screens/compress_view.py +52 -0
  140. tui/screens/index_view.py +74 -0
  141. tui/screens/init_view.py +82 -0
  142. tui/screens/mcp_view.py +73 -0
  143. tui/screens/optimize_view.py +41 -0
  144. tui/screens/pipe_view.py +46 -0
  145. tui/screens/projects_view.py +355 -0
  146. tui/screens/search_view.py +55 -0
  147. tui/screens/session_view.py +143 -0
  148. tui/screens/stats.py +158 -0
  149. tui/screens/ui_view.py +54 -0
  150. tui/theme.tcss +335 -0
services/protocol.py ADDED
@@ -0,0 +1,306 @@
1
+ """
2
+ Compression Protocol
3
+
4
+ A custom encoding/decoding scheme for prompts and context that reduces token usage
5
+ by converting natural language into compressed shorthand.
6
+
7
+ Features:
8
+ - Action shorthand (READ -> R, FIX -> FX, CREATE -> CR, etc.)
9
+ - Path abbreviation
10
+ - Common phrase compression
11
+ - Project-specific dictionary building
12
+ - Reversible encoding
13
+ """
14
+ import json
15
+ import re
16
+ from pathlib import Path
17
+
18
+ from core import measure_savings
19
+
20
+ # Core action dictionary
21
+ ACTION_CODES = {
22
+ # File operations
23
+ "read": "R", "write": "W", "create": "CR", "delete": "DEL",
24
+ "edit": "ED", "modify": "MOD", "update": "UPD", "rename": "RN",
25
+ "move": "MV", "copy": "CP",
26
+ # Code operations
27
+ "fix": "FX", "debug": "DBG", "refactor": "RFT", "optimize": "OPT",
28
+ "test": "TST", "add": "ADD", "remove": "RM", "implement": "IMP",
29
+ "extract": "EXT", "inline": "INL", "wrap": "WRP",
30
+ # Analysis
31
+ "explain": "EXP", "analyze": "ANL", "review": "REV",
32
+ "find": "FND", "search": "SRCH", "list": "LST", "show": "SHW",
33
+ "compare": "CMP", "check": "CHK",
34
+ # Common qualifiers
35
+ "the": "", "this": "", "that": "", "a": "", "an": "",
36
+ "please": "", "can you": "", "could you": "", "would you": "",
37
+ "i want to": "", "i need to": "", "i'd like to": "",
38
+ "help me": "", "go ahead and": "",
39
+ }
40
+
41
+ # Common programming terms
42
+ TERM_CODES = {
43
+ "function": "fn", "variable": "var", "constant": "const",
44
+ "class": "cls", "method": "mth", "property": "prop",
45
+ "interface": "ifc", "type": "typ", "enum": "enm",
46
+ "component": "cmp", "module": "mod", "package": "pkg",
47
+ "import": "imp", "export": "exp", "default": "def",
48
+ "parameter": "param", "argument": "arg", "return": "ret",
49
+ "async": "asc", "await": "awt", "promise": "prom",
50
+ "error": "err", "exception": "exc", "warning": "warn",
51
+ "database": "db", "query": "qry", "schema": "sch",
52
+ "request": "req", "response": "res", "middleware": "mw",
53
+ "authentication": "auth", "authorization": "authz",
54
+ "configuration": "cfg", "environment": "env",
55
+ "typescript": "TS", "javascript": "JS", "python": "PY",
56
+ "react": "RCT", "node": "ND",
57
+ "line": "L", "file": "F", "directory": "D",
58
+ "string": "str", "number": "num", "boolean": "bool",
59
+ "array": "arr", "object": "obj", "null": "nil",
60
+ "undefined": "undef",
61
+ }
62
+
63
+ # Common phrase patterns
64
+ PHRASE_PATTERNS = [
65
+ (r"please read the file (.+?) and", r"R:\1"),
66
+ (r"can you (?:please )?look at (.+)", r"R:\1"),
67
+ (r"fix the (?:bug|error|issue) (?:in|on|at) (.+?)(?:\s+(?:where|on|at)\s+line\s+(\d+))?", r"FX:\1 L\2"),
68
+ (r"create a (?:new )?(.+?) (?:file|component|module) (?:called|named) (.+)", r"CR:\2.\1"),
69
+ (r"add (.+?) to (.+)", r"ADD:\1 IN:\2"),
70
+ (r"remove (.+?) from (.+)", r"RM:\1 FROM:\2"),
71
+ (r"refactor (.+?) to (.+)", r"RFT:\1 TO:\2"),
72
+ (r"move (.+?) to (.+)", r"MV:\1 TO:\2"),
73
+ (r"rename (.+?) to (.+)", r"RN:\1 TO:\2"),
74
+ (r"implement (.+?) in (.+)", r"IMP:\1 IN:\2"),
75
+ (r"there(?:'s| is) (?:a |an )?(?:bug|error|issue|problem) (?:in|with|on) (.+)", r"FX:\1"),
76
+ (r"on line (\d+)", r"L\1"),
77
+ (r"the (.+?) (?:is|are) (?:not working|broken|failing)", r"FX:\1"),
78
+ (r"(?:the |)(.+?) (?:doesn't|does not|isn't|is not) (?:work|working)", r"FX:\1"),
79
+ ]
80
+
81
+ # Reverse lookup for decoding
82
+ REVERSE_ACTIONS = {v: k for k, v in ACTION_CODES.items() if v}
83
+ REVERSE_TERMS = {v: k for k, v in TERM_CODES.items()}
84
+
85
+
86
+ class CompressionProtocol:
87
+ """Encode/decode natural language prompts to compressed shorthand."""
88
+
89
+ def __init__(self, project_path: str = "", custom_dict_path: str = ".c3/dictionary.json"):
90
+ self.project_path = Path(project_path) if project_path else Path(".")
91
+ self.dict_path = self.project_path / custom_dict_path
92
+ self.custom_dict = self._load_custom_dict()
93
+ self.path_aliases = {}
94
+
95
+ def encode(self, text: str) -> dict:
96
+ """Encode natural language to compressed format."""
97
+ original = text
98
+ compressed = text.lower().strip()
99
+
100
+ # Step 1: Apply phrase patterns
101
+ for pattern, replacement in PHRASE_PATTERNS:
102
+ compressed = re.sub(pattern, replacement, compressed, flags=re.IGNORECASE)
103
+
104
+ # Step 2: Remove filler words
105
+ for word in ["please", "can you", "could you", "would you", "i want to",
106
+ "i need to", "i'd like to", "help me", "go ahead and",
107
+ "the", "this", "that"]:
108
+ compressed = re.sub(rf'\b{re.escape(word)}\b', '', compressed, flags=re.IGNORECASE)
109
+
110
+ # Step 3: Apply action codes
111
+ for word, code in ACTION_CODES.items():
112
+ if code: # Skip empty replacements (already removed filler words)
113
+ compressed = re.sub(rf'\b{re.escape(word)}\b', code, compressed, flags=re.IGNORECASE)
114
+
115
+ # Step 4: Apply term codes
116
+ for word, code in TERM_CODES.items():
117
+ compressed = re.sub(rf'\b{re.escape(word)}\b', code, compressed, flags=re.IGNORECASE)
118
+
119
+ # Step 5: Apply custom dictionary
120
+ for word, code in self.custom_dict.items():
121
+ compressed = re.sub(rf'\b{re.escape(word)}\b', code, compressed, flags=re.IGNORECASE)
122
+
123
+ # Step 6: Compress paths
124
+ compressed = self._compress_paths(compressed)
125
+
126
+ # Step 7: Clean up whitespace
127
+ compressed = re.sub(r'\s+', ' ', compressed).strip()
128
+
129
+ savings = measure_savings(original, compressed)
130
+ savings["original"] = original
131
+ savings["compressed"] = compressed
132
+
133
+ return savings
134
+
135
+ def decode(self, compressed: str) -> str:
136
+ """Decode compressed format back to readable text."""
137
+ text = compressed
138
+
139
+ # Decode action codes (R: -> Read file)
140
+ action_patterns = {
141
+ r'\bR:': "Read file ",
142
+ r'\bW:': "Write to ",
143
+ r'\bCR:': "Create ",
144
+ r'\bDEL:': "Delete ",
145
+ r'\bED:': "Edit ",
146
+ r'\bFX:': "Fix ",
147
+ r'\bDBG:': "Debug ",
148
+ r'\bRFT:': "Refactor ",
149
+ r'\bOPT:': "Optimize ",
150
+ r'\bTST:': "Test ",
151
+ r'\bADD:': "Add ",
152
+ r'\bRM:': "Remove ",
153
+ r'\bIMP:': "Implement ",
154
+ r'\bEXP:': "Explain ",
155
+ r'\bANL:': "Analyze ",
156
+ r'\bREV:': "Review ",
157
+ r'\bFND:': "Find ",
158
+ r'\bSRCH:': "Search for ",
159
+ r'\bMV:': "Move ",
160
+ r'\bRN:': "Rename ",
161
+ r'\bCMP:': "Compare ",
162
+ r'\bCHK:': "Check ",
163
+ }
164
+
165
+ for pattern, replacement in action_patterns.items():
166
+ text = re.sub(pattern, replacement, text)
167
+
168
+ # Decode term codes
169
+ for code, term in REVERSE_TERMS.items():
170
+ text = re.sub(rf'\b{re.escape(code)}\b', term, text)
171
+
172
+ # Decode line references
173
+ text = re.sub(r'\bL(\d+)', r'on line \1', text)
174
+
175
+ # Decode modifiers
176
+ text = re.sub(r'\bIN:', 'in ', text)
177
+ text = re.sub(r'\bTO:', 'to ', text)
178
+ text = re.sub(r'\bFROM:', 'from ', text)
179
+
180
+ # Clean up
181
+ text = re.sub(r'\s+', ' ', text).strip()
182
+ text = text[0].upper() + text[1:] if text else text
183
+
184
+ return text
185
+
186
+ def _compress_paths(self, text: str) -> str:
187
+ """Compress file paths using aliases."""
188
+ # Auto-detect common path prefixes
189
+ path_pattern = r'(?:src|lib|app|components|pages|utils|hooks|services|api|config)(?:/\w+)+'
190
+ paths = re.findall(path_pattern, text)
191
+
192
+ for path in paths:
193
+ parts = path.split('/')
194
+ if len(parts) > 2:
195
+ # Keep first and last parts
196
+ compressed_path = f"{parts[0]}/../{parts[-1]}"
197
+ text = text.replace(path, compressed_path)
198
+
199
+ return text
200
+
201
+ def _load_custom_dict(self) -> dict:
202
+ """Load project-specific custom dictionary."""
203
+ if self.dict_path.exists():
204
+ try:
205
+ with open(self.dict_path, encoding='utf-8') as f:
206
+ return json.load(f)
207
+ except Exception:
208
+ pass
209
+ return {}
210
+
211
+ def save_custom_dict(self):
212
+ """Save custom dictionary to disk."""
213
+ self.dict_path.parent.mkdir(parents=True, exist_ok=True)
214
+ with open(self.dict_path, 'w', encoding='utf-8') as f:
215
+ json.dump(self.custom_dict, f, indent=2)
216
+
217
+ def add_custom_term(self, term: str, code: str):
218
+ """Add a project-specific term to the dictionary."""
219
+ self.custom_dict[term.lower()] = code
220
+ self.save_custom_dict()
221
+
222
+ def build_project_dictionary(self) -> dict:
223
+ """Auto-build a project-specific dictionary from codebase analysis."""
224
+ if not self.project_path.exists():
225
+ return {}
226
+
227
+ # Find commonly used terms in the project
228
+ term_freq = {}
229
+ skip_dirs = {'node_modules', '.git', '__pycache__', '.c3', 'venv'}
230
+ code_exts = {'.py', '.js', '.ts', '.tsx', '.jsx', '.r', '.R'}
231
+
232
+ for fpath in self.project_path.rglob('*'):
233
+ if not fpath.is_file() or fpath.suffix not in code_exts:
234
+ continue
235
+ if any(skip in fpath.parts for skip in skip_dirs):
236
+ continue
237
+
238
+ try:
239
+ content = fpath.read_text(errors='replace')
240
+ except Exception:
241
+ continue
242
+
243
+ # Extract identifiers
244
+ identifiers = re.findall(r'\b[a-zA-Z_]\w{5,}\b', content)
245
+ for ident in identifiers:
246
+ lower = ident.lower()
247
+ if lower not in ACTION_CODES and lower not in TERM_CODES:
248
+ term_freq[lower] = term_freq.get(lower, 0) + 1
249
+
250
+ # Generate codes for frequent terms
251
+ frequent = sorted(term_freq.items(), key=lambda x: x[1], reverse=True)[:30]
252
+ new_entries = {}
253
+
254
+ for term, freq in frequent:
255
+ if freq >= 5: # Only for terms appearing 5+ times
256
+ # Generate abbreviation
257
+ if len(term) > 6:
258
+ code = term[:3].upper()
259
+ # Ensure uniqueness
260
+ suffix = 1
261
+ while code in TERM_CODES.values() or code in new_entries.values():
262
+ code = term[:3].upper() + str(suffix)
263
+ suffix += 1
264
+ new_entries[term] = code
265
+
266
+ # Merge with existing custom dict
267
+ self.custom_dict.update(new_entries)
268
+ self.save_custom_dict()
269
+
270
+ return new_entries
271
+
272
+ def get_protocol_header(self) -> str:
273
+ """
274
+ Generate a compression protocol header to include in system prompt.
275
+ This tells Claude how to interpret compressed messages.
276
+ """
277
+ header = """# C3 Compression Protocol
278
+ When you see compressed shorthand, decode using:
279
+ ## Actions: R=Read W=Write CR=Create FX=Fix DBG=Debug RFT=Refactor OPT=Optimize TST=Test ADD=Add RM=Remove IMP=Implement
280
+ ## Modifiers: L=Line F=File D=Directory IN=in TO=to FROM=from
281
+ ## Terms: fn=function cls=class cmp=component mod=module cfg=config auth=authentication db=database
282
+ ## Format: ACTION:target [MODIFIER:value] [context]
283
+ ## Example: "FX:src/auth.ts L47 TS err missing onClick prop" = "Fix the TypeScript error on line 47 of src/auth.ts where the onClick prop is missing"
284
+ """
285
+
286
+ # Add custom dictionary if exists
287
+ if self.custom_dict:
288
+ custom_section = "## Project-specific: " + ' '.join(
289
+ f"{v}={k}" for k, v in list(self.custom_dict.items())[:20]
290
+ )
291
+ header += custom_section + "\n"
292
+
293
+ return header
294
+
295
+ def batch_encode(self, texts: list) -> list:
296
+ """Encode multiple texts at once."""
297
+ return [self.encode(t) for t in texts]
298
+
299
+ def get_stats(self) -> dict:
300
+ """Get compression protocol statistics."""
301
+ return {
302
+ "built_in_actions": len(ACTION_CODES),
303
+ "built_in_terms": len(TERM_CODES),
304
+ "custom_terms": len(self.custom_dict),
305
+ "total_codes": len(ACTION_CODES) + len(TERM_CODES) + len(self.custom_dict),
306
+ }
@@ -0,0 +1,152 @@
1
+ """Sliding window state tracker for the MCP proxy.
2
+
3
+ Maintains rolling conversation context: recent tool calls, files, decisions,
4
+ and detected goal. Generates a compact context line (~50 tokens) that gets
5
+ injected into tool responses so Claude retains state awareness across turns.
6
+ """
7
+ import re
8
+ from collections import deque
9
+
10
+
11
+ class ProxyState:
12
+ """Rolling conversation state tracker."""
13
+
14
+ def __init__(self, window_size: int = 10):
15
+ self.tool_calls: deque = deque(maxlen=window_size)
16
+ self.recent_files: deque = deque(maxlen=5)
17
+ self.recent_decisions: deque = deque(maxlen=3)
18
+ self.current_goal: str = ""
19
+
20
+ # ── Recording ──────────────────────────────────────────
21
+
22
+ def record_tool_call(self, tool_name: str, args: dict,
23
+ response_text: str = "") -> None:
24
+ """Record a tool call with a 1-line summary."""
25
+ summary = self._summarize_call(tool_name, args, response_text)
26
+ self.tool_calls.append({"name": tool_name, "summary": summary})
27
+
28
+ # Extract file paths from args
29
+ self._extract_files(args)
30
+
31
+ # Detect decisions from session_log
32
+ if tool_name == "c3_session_log" and args.get("event_type") == "decision":
33
+ decision = args.get("data", "")[:80]
34
+ if decision:
35
+ self.recent_decisions.append(decision)
36
+
37
+ # Update goal from tool args
38
+ self._detect_goal(tool_name, args)
39
+
40
+ def record_user_text(self, text: str) -> None:
41
+ """Extract goal hints from user/tool text."""
42
+ # Look for intent patterns
43
+ patterns = [
44
+ (r"(?:fix|debug|resolve)\s+(.{5,40})", "fix"),
45
+ (r"(?:add|implement|create)\s+(.{5,40})", "add"),
46
+ (r"(?:refactor|clean|simplify)\s+(.{5,40})", "refactor"),
47
+ (r"(?:find|search|look\s+for)\s+(.{5,40})", "find"),
48
+ (r"(?:understand|explain|how\s+does)\s+(.{5,40})", "understand"),
49
+ ]
50
+ for pattern, verb in patterns:
51
+ m = re.search(pattern, text, re.IGNORECASE)
52
+ if m:
53
+ self.current_goal = f"{verb} {m.group(1).strip()}"
54
+ break
55
+
56
+ # ── Output ─────────────────────────────────────────────
57
+
58
+ def get_context_line(self) -> str:
59
+ """Generate compact context summary for injection."""
60
+ parts = []
61
+
62
+ # Last tool calls
63
+ if self.tool_calls:
64
+ recent = list(self.tool_calls)[-3:]
65
+ call_strs = [c["summary"] for c in recent]
66
+ parts.append(f"Last: {', '.join(call_strs)}")
67
+
68
+ # Current goal
69
+ if self.current_goal:
70
+ parts.append(f"Goal: {self.current_goal}")
71
+
72
+ # Recent files
73
+ if self.recent_files:
74
+ files = [f.split("/")[-1] for f in self.recent_files]
75
+ parts.append(f"Files: {', '.join(files)}")
76
+
77
+ if not parts:
78
+ return ""
79
+ return f"\n[Context: {'. '.join(parts)}]"
80
+
81
+ def get_recent_tool_names(self) -> list[str]:
82
+ """Return names of recent tool calls for classifier input."""
83
+ return [c["name"] for c in self.tool_calls]
84
+
85
+ def get_recent_text(self) -> str:
86
+ """Return recent context text for classifier keyword matching."""
87
+ parts = []
88
+ if self.current_goal:
89
+ parts.append(self.current_goal)
90
+ for c in self.tool_calls:
91
+ parts.append(c["summary"])
92
+ parts.extend(self.recent_decisions)
93
+ return " ".join(parts)
94
+
95
+ # ── Internal ───────────────────────────────────────────
96
+
97
+ def _summarize_call(self, tool_name: str, args: dict,
98
+ response_text: str) -> str:
99
+ """Create a compact 1-line summary of a tool call."""
100
+ short_name = tool_name.replace("c3_", "")
101
+
102
+ # Summarize based on tool type
103
+ if tool_name == "c3_search":
104
+ query = args.get("query", "")[:30]
105
+ # Count results from response
106
+ count = response_text.count("## ") if response_text else "?"
107
+ return f"{short_name}({query!r}, {count} results)"
108
+
109
+ elif tool_name == "c3_compress":
110
+ fp = args.get("file_path", "").split("/")[-1]
111
+ return f"{short_name}({fp})"
112
+
113
+ elif tool_name in ("c3_remember", "c3_recall", "c3_memory_query"):
114
+ text = args.get("fact", args.get("query", ""))[:30]
115
+ return f"{short_name}({text!r})"
116
+
117
+ elif tool_name == "c3_session_log":
118
+ etype = args.get("event_type", "")
119
+ data = args.get("data", "")[:20]
120
+ return f"{short_name}({etype}: {data})"
121
+
122
+ elif tool_name in ("c3_extract", "c3_filter"):
123
+ fp = args.get("file_path", "").split("/")[-1]
124
+ return f"{short_name}({fp})"
125
+
126
+ else:
127
+ # Generic: show first string arg
128
+ for v in args.values():
129
+ if isinstance(v, str) and len(v) > 2:
130
+ return f"{short_name}({v[:25]})"
131
+ return f"{short_name}()"
132
+
133
+ def _extract_files(self, args: dict) -> None:
134
+ """Extract file paths from tool args."""
135
+ for key in ("file_path", "path"):
136
+ val = args.get(key)
137
+ if val and isinstance(val, str):
138
+ # Normalize and deduplicate
139
+ if val not in self.recent_files:
140
+ self.recent_files.append(val)
141
+
142
+ def _detect_goal(self, tool_name: str, args: dict) -> None:
143
+ """Infer user goal from tool usage patterns."""
144
+ if tool_name == "c3_search":
145
+ query = args.get("query", "")
146
+ if query and not self.current_goal:
147
+ self.current_goal = f"investigate {query[:40]}"
148
+ elif tool_name == "c3_session_log":
149
+ if args.get("event_type") == "decision":
150
+ data = args.get("data", "")[:40]
151
+ if data:
152
+ self.current_goal = data
@@ -0,0 +1,129 @@
1
+ """Unified retrieval broker across facts, conversations, files, sessions, and snapshots."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from services.text_index import TextIndex
9
+
10
+
11
+ class MemoryRetrievalBroker:
12
+ """Normalizes retrieval across the project's memory sources."""
13
+
14
+ def __init__(self, project_path: str, memory_store, conversation_store=None, file_memory=None, snapshots=None):
15
+ self.project_path = Path(project_path)
16
+ self.memory_store = memory_store
17
+ self.conversation_store = conversation_store
18
+ self.file_memory = file_memory
19
+ self.snapshots = snapshots
20
+ self._session_index = TextIndex()
21
+ self._session_meta = {}
22
+ self._session_dirty = True
23
+
24
+ def mark_sessions_dirty(self):
25
+ self._session_dirty = True
26
+
27
+ def search(self, query: str, top_k: int = 5) -> dict:
28
+ self._session_dirty = True
29
+ self._ensure_session_index()
30
+
31
+ fact_results = self.memory_store.recall(query, top_k=top_k)
32
+ conversation_results = self.conversation_store.search(query, limit=top_k) if self.conversation_store else []
33
+ file_results = self.file_memory.search(query, top_k=top_k) if self.file_memory else []
34
+ snapshot_results = self.snapshots.search(query, top_k=top_k) if self.snapshots else []
35
+
36
+ session_hits = []
37
+ for session_id, score in self._session_index.search(query, top_k=top_k):
38
+ meta = self._session_meta.get(session_id)
39
+ if not meta:
40
+ continue
41
+ session_hits.append({**meta, "score": round(score, 4)})
42
+
43
+ merged = []
44
+ for fact in fact_results:
45
+ merged.append({
46
+ "kind": "fact",
47
+ "id": fact["id"],
48
+ "title": fact.get("category", "fact"),
49
+ "text": fact.get("fact", ""),
50
+ "score": float(fact.get("score", 0.0)),
51
+ "payload": fact,
52
+ })
53
+ for convo in conversation_results:
54
+ merged.append({
55
+ "kind": "conversation",
56
+ "id": convo["turn_key"],
57
+ "title": convo.get("session_title", convo.get("session_id", "")),
58
+ "text": convo.get("snippet") or convo.get("text", ""),
59
+ "score": float(convo.get("score", 0.0)),
60
+ "payload": convo,
61
+ })
62
+ for session in session_hits:
63
+ merged.append({
64
+ "kind": "session",
65
+ "id": session["session_id"],
66
+ "title": session.get("summary") or session.get("session_id", ""),
67
+ "text": session.get("summary", ""),
68
+ "score": float(session.get("score", 0.0)),
69
+ "payload": session,
70
+ })
71
+ for file_hit in file_results:
72
+ merged.append({
73
+ "kind": "file",
74
+ "id": file_hit["path"],
75
+ "title": file_hit["path"],
76
+ "text": file_hit.get("summary") or "",
77
+ "score": float(file_hit.get("score", 0.0)),
78
+ "payload": file_hit,
79
+ })
80
+ for snap in snapshot_results:
81
+ merged.append({
82
+ "kind": "snapshot",
83
+ "id": snap["snapshot_id"],
84
+ "title": snap.get("task_description", ""),
85
+ "text": snap.get("task_description", ""),
86
+ "score": float(snap.get("score", 0.0)),
87
+ "payload": snap,
88
+ })
89
+
90
+ merged.sort(key=lambda item: item["score"], reverse=True)
91
+ return {
92
+ "facts": fact_results,
93
+ "conversations": conversation_results[:top_k],
94
+ "sessions": session_hits[:top_k],
95
+ "files": file_results[:top_k],
96
+ "snapshots": snapshot_results[:top_k],
97
+ "results": merged[:top_k * 3],
98
+ }
99
+
100
+ def _ensure_session_index(self):
101
+ if not self._session_dirty:
102
+ return
103
+ session_dir = self.project_path / ".c3" / "sessions"
104
+ docs = {}
105
+ meta = {}
106
+ if session_dir.exists():
107
+ for path in sorted(session_dir.glob("session_*.json"), reverse=True):
108
+ try:
109
+ with open(path, encoding="utf-8") as handle:
110
+ session = json.load(handle)
111
+ except Exception:
112
+ continue
113
+ session_id = session.get("id") or session.get("session_id")
114
+ if not session_id:
115
+ continue
116
+ text_parts = [session.get("description", ""), session.get("summary", "")]
117
+ for decision in session.get("decisions", []):
118
+ text_parts.append(decision.get("decision", ""))
119
+ text_parts.append(decision.get("reasoning", ""))
120
+ text_parts.extend(session.get("context_notes", []))
121
+ docs[session_id] = " ".join(part for part in text_parts if part)
122
+ meta[session_id] = {
123
+ "session_id": session_id,
124
+ "started": session.get("started", ""),
125
+ "summary": session.get("summary", ""),
126
+ }
127
+ self._session_index.rebuild(docs)
128
+ self._session_meta = meta
129
+ self._session_dirty = False