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
cli/tools/read.py ADDED
@@ -0,0 +1,224 @@
1
+ """c3_read — Surgical symbol/line extraction from files.
2
+ Supports comma-separated paths with parallel extraction via ThreadPoolExecutor."""
3
+
4
+ import json
5
+ from concurrent.futures import ThreadPoolExecutor, as_completed
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from core import count_tokens
10
+
11
+
12
+ def _coerce_list(val: Any) -> list[str] | None:
13
+ """Coerce symbols from string/JSON to list. MCP clients sometimes serialize lists as strings."""
14
+ if val is None:
15
+ return None
16
+ if isinstance(val, list):
17
+ return val
18
+ if isinstance(val, str):
19
+ val = val.strip()
20
+ if val.startswith("["):
21
+ try:
22
+ parsed = json.loads(val)
23
+ if isinstance(parsed, list):
24
+ return [str(x) for x in parsed]
25
+ except (json.JSONDecodeError, ValueError):
26
+ pass
27
+ if val:
28
+ return [val]
29
+ return None
30
+
31
+
32
+ def handle_read(file_path: str, symbols: Any = None, lines: Any = None,
33
+ include_docstrings: bool = True, svc=None, finalize=None) -> str:
34
+ symbols = _coerce_list(symbols)
35
+ # Multi-file dispatch (parallel)
36
+ if "," in file_path:
37
+ paths = [p.strip() for p in file_path.split(",") if p.strip()]
38
+ if len(paths) == 1:
39
+ return handle_read(paths[0], symbols=symbols, lines=lines,
40
+ include_docstrings=include_docstrings,
41
+ svc=svc, finalize=finalize)
42
+ # Nested ThreadPoolExecutor workers don't inherit MCP's request_context
43
+ # contextvar, so calling the real `finalize` inside a worker raises
44
+ # 'NoneType'.lifespan_context. Use a no-op in workers and run the real
45
+ # finalize once on the combined result (still on the outer to_thread,
46
+ # which does propagate contextvars).
47
+ def _worker_finalize(name, args, resp, summ, **kw):
48
+ return resp
49
+
50
+ results = {}
51
+ with ThreadPoolExecutor(max_workers=min(len(paths), 4)) as pool:
52
+ futures = {
53
+ pool.submit(handle_read, p, symbols, lines,
54
+ include_docstrings, svc, _worker_finalize): p
55
+ for p in paths
56
+ }
57
+ for fut in as_completed(futures):
58
+ p = futures[fut]
59
+ try:
60
+ results[p] = fut.result()
61
+ except Exception as e:
62
+ results[p] = f"[read:error] {p}: {e}"
63
+ combined = "\n\n".join(results[p] for p in paths)
64
+ if finalize is None:
65
+ return combined
66
+ combined_tokens = count_tokens(combined)
67
+ return finalize("c3_read",
68
+ {"file": file_path, "symbols": symbols},
69
+ combined,
70
+ f"multi:{len(paths)}files->{combined_tokens}tok",
71
+ response_tokens=combined_tokens)
72
+
73
+ full = Path(svc.project_path) / file_path
74
+ if not full.exists():
75
+ full = Path(file_path)
76
+ if not full.exists():
77
+ return f"[read:error] File not found: {file_path}"
78
+
79
+ rel_path = str(full.resolve().relative_to(Path(svc.project_path).resolve())).replace("\\", "/")
80
+
81
+ # Resolve ranges
82
+ ranges = []
83
+ if lines:
84
+ if isinstance(lines, int):
85
+ line_specs = [lines]
86
+ elif isinstance(lines, (list, tuple)) and len(lines) == 2 and all(isinstance(x, int) for x in lines):
87
+ line_specs = [lines]
88
+ elif isinstance(lines, (list, tuple)):
89
+ line_specs = lines
90
+ else:
91
+ line_specs = []
92
+
93
+ for spec in line_specs:
94
+ if isinstance(spec, int):
95
+ ranges.append((spec, spec))
96
+ elif isinstance(spec, (list, tuple)) and len(spec) >= 2:
97
+ ranges.append((int(spec[0]), int(spec[1])))
98
+ elif isinstance(spec, (list, tuple)) and len(spec) == 1:
99
+ ranges.append((int(spec[0]), int(spec[0])))
100
+
101
+ # Ensure file_memory index is fresh.
102
+ # When the watcher is running, it handles updates in the background —
103
+ # only force-update if file_memory has no record at all (first access).
104
+ try:
105
+ watcher_active = (hasattr(svc, "watcher") and svc.watcher._observer.is_alive())
106
+ if watcher_active:
107
+ if not svc.file_memory.get(rel_path):
108
+ svc.file_memory.update(rel_path)
109
+ elif svc.file_memory.needs_update(rel_path):
110
+ svc.file_memory.update(rel_path)
111
+ except Exception:
112
+ pass
113
+
114
+ raw_text = full.read_text(encoding="utf-8", errors="replace")
115
+ content_lines = raw_text.splitlines()
116
+ # Lazy: only count full file tokens when needed for the summary string
117
+ _full_tok_cache = [None]
118
+
119
+ def full_file_tokens():
120
+ if _full_tok_cache[0] is None:
121
+ _full_tok_cache[0] = count_tokens(raw_text)
122
+ return _full_tok_cache[0]
123
+
124
+ if symbols:
125
+ matches = svc.file_memory.get_symbol_ranges(rel_path, symbols, return_matches=True)
126
+
127
+ # Check for ambiguity
128
+ disambiguation_msgs = []
129
+ for target in symbols:
130
+ if target.startswith('^') or target in ('<main>', '<globals>', '<imports>'):
131
+ continue
132
+ target_matches = [m for m in matches if m["target"] == target.lower() or m["target"] == target]
133
+ unique_names = set(m["match"] for m in target_matches)
134
+ if len(unique_names) > 1:
135
+ exact = [m for m in target_matches if m["match"].lower() == target.lower()]
136
+ if exact:
137
+ matches = [m for m in matches
138
+ if m["target"] != target and m["target"] != target.lower()
139
+ or m["match"].lower() == target.lower()]
140
+ else:
141
+ options = ", ".join(
142
+ f"{m['match']} (L{m['range'][0]}-L{m['range'][1]})" for m in target_matches)
143
+ disambiguation_msgs.append(
144
+ f"Ambiguous symbol '{target}'. Did you mean: {options}?")
145
+
146
+ if disambiguation_msgs:
147
+ resp = (f"[read:error] Ambiguous symbols found in {file_path}:\n"
148
+ + "\n".join(disambiguation_msgs)
149
+ + "\nTry using exact regex (e.g., '^symbol_name$') or the specific symbol name.")
150
+ return finalize("c3_read", {"file": file_path, "symbols": symbols},
151
+ resp, f"{full_file_tokens()}->0tok")
152
+
153
+ for m in matches:
154
+ ranges.append(m["range"])
155
+
156
+ if '<main>' in symbols or '<globals>' in symbols:
157
+ record = svc.file_memory.get(rel_path)
158
+ if record and "sections" in record:
159
+ covered = set()
160
+
161
+ def _mark(secs):
162
+ for s in secs:
163
+ covered.update(range(s["line_start"], s["line_end"] + 1))
164
+ if "children" in s:
165
+ _mark(s["children"])
166
+
167
+ _mark(record["sections"])
168
+ main_ranges = []
169
+ current_start = None
170
+ for i in range(1, len(content_lines) + 1):
171
+ if i not in covered:
172
+ if current_start is None:
173
+ current_start = i
174
+ else:
175
+ if current_start is not None:
176
+ main_ranges.append((current_start, i - 1))
177
+ current_start = None
178
+ if current_start is not None:
179
+ main_ranges.append((current_start, len(content_lines)))
180
+ ranges.extend(main_ranges)
181
+
182
+ if not ranges and symbols:
183
+ file_map = svc.file_memory.get_or_build_map(rel_path)
184
+ resp = f"[read:{file_path}] symbols not found: {symbols}. Showing file map:\n{file_map}"
185
+ return finalize("c3_read", {"file": file_path, "symbols": symbols},
186
+ resp, f"{full_file_tokens()}->{count_tokens(file_map)}tok")
187
+
188
+ if not ranges:
189
+ file_map = svc.file_memory.get_or_build_map(rel_path)
190
+ map_tok = count_tokens(file_map)
191
+ resp = file_map
192
+ return finalize("c3_read", {"file": file_path},
193
+ resp, f"{full_file_tokens()}->{map_tok}tok")
194
+ else:
195
+ # Sort and merge overlapping ranges
196
+ ranges.sort()
197
+ merged = []
198
+ if ranges:
199
+ curr_start, curr_end = ranges[0]
200
+ for next_start, next_end in ranges[1:]:
201
+ if next_start <= curr_end + 1:
202
+ curr_end = max(curr_end, next_end)
203
+ else:
204
+ merged.append((curr_start, curr_end))
205
+ curr_start, curr_end = next_start, next_end
206
+ merged.append((curr_start, curr_end))
207
+ ranges = merged
208
+ header = f"[read:{file_path}]"
209
+
210
+ parts = []
211
+ for start, end in ranges:
212
+ s_idx = max(0, start - 1)
213
+ e_idx = min(len(content_lines), end)
214
+ chunk = content_lines[s_idx:e_idx]
215
+ if len(ranges) > 1:
216
+ parts.append(f"--- L{start}-L{end} ---")
217
+ parts.extend(chunk)
218
+
219
+ final_content = "\n".join(parts)
220
+ tokens = count_tokens(final_content)
221
+ summary = f"{full_file_tokens()}->{tokens}tok" if tokens < full_file_tokens() else f"{tokens}tok"
222
+ resp = f"{final_content}"
223
+ return finalize("c3_read", {"file": file_path, "symbols": symbols}, resp, summary,
224
+ response_tokens=tokens)
cli/tools/search.py ADDED
@@ -0,0 +1,337 @@
1
+ """c3_search — Code, file, and transcript discovery."""
2
+
3
+ import re
4
+ import time
5
+ from concurrent.futures import ThreadPoolExecutor, as_completed
6
+ from pathlib import Path
7
+
8
+ from core import count_tokens
9
+
10
+ # Hard cap: responses above this are truncated to avoid filling context.
11
+ _RESPONSE_TOKEN_CAP = 2400
12
+
13
+
14
+ def _approx_tokens(text: str) -> int:
15
+ """Fast token estimate (~4 chars/token). Use for budget checks where exact
16
+ tiktoken counts are wasted work — the difference is <5% and noise."""
17
+ return len(text) >> 2
18
+
19
+
20
+ def _cap_response(resp: str) -> str:
21
+ """Truncate response if it exceeds the token cap."""
22
+ tok = count_tokens(resp)
23
+ if tok <= _RESPONSE_TOKEN_CAP:
24
+ return resp
25
+ # Binary-ish search: cut lines from the end until under budget
26
+ lines = resp.split("\n")
27
+ while len(lines) > 1:
28
+ lines = lines[:len(lines) * 3 // 4] # drop ~25% each iteration
29
+ candidate = "\n".join(lines) + "\n[truncated]"
30
+ if count_tokens(candidate) <= _RESPONSE_TOKEN_CAP:
31
+ return candidate
32
+ return "\n".join(lines[:20]) + "\n[truncated]"
33
+
34
+
35
+ def handle_search(query: str, action: str, top_k: int, max_tokens: int,
36
+ svc, finalize, maybe_facts, prefetch: bool = False) -> str:
37
+ top_k = max(1, min(int(top_k), 10))
38
+ max_tokens = min(max(200, int(max_tokens)), _RESPONSE_TOKEN_CAP)
39
+
40
+ if action == "exact":
41
+ return _exact_search(query, top_k, max_tokens, svc, finalize)
42
+
43
+ if action == "files":
44
+ resp = _files_search(query, top_k, svc, finalize)
45
+ if prefetch:
46
+ resp = _append_prefetch(resp, query, top_k, svc)
47
+ return _cap_response(resp)
48
+
49
+ if action == "transcript":
50
+ return _transcript_search(query, top_k, max_tokens, svc, finalize)
51
+
52
+ if action == "semantic":
53
+ return _semantic_search(query, top_k, max_tokens, svc, finalize, maybe_facts)
54
+
55
+ # Default: Code Search
56
+ resp = _code_search(query, top_k, max_tokens, svc, finalize, maybe_facts)
57
+ if prefetch:
58
+ resp = _append_prefetch(resp, query, top_k, svc)
59
+ return _cap_response(resp)
60
+
61
+
62
+ def _exact_search(query, top_k, max_tokens, svc, finalize):
63
+ try:
64
+ pat = re.compile(query)
65
+ except Exception as e:
66
+ return finalize("c3_search", {"action": "exact"},
67
+ f"[search:exact:error] Invalid regex: {e}", "error")
68
+
69
+ tracked = svc.file_memory.list_tracked()
70
+
71
+ def _scan_file(rel):
72
+ """Scan a single file for regex matches. Returns (rel, matches) or None."""
73
+ full = Path(svc.project_path) / rel
74
+ if not full.exists():
75
+ return None
76
+ try:
77
+ lines = full.read_text(encoding="utf-8", errors="replace").splitlines()
78
+ except Exception:
79
+ return None
80
+ file_matches = []
81
+ for i, line in enumerate(lines):
82
+ if pat.search(line):
83
+ start = max(0, i - 1)
84
+ end = min(len(lines), i + 2)
85
+ for j in range(start, end):
86
+ marker = ">" if j == i else " "
87
+ entry = f"{marker}L{j+1}: {lines[j][:200]}"
88
+ if entry not in file_matches:
89
+ file_matches.append(entry)
90
+ if file_matches and file_matches[-1] != "---":
91
+ file_matches.append("---")
92
+ return (rel, file_matches) if file_matches else None
93
+
94
+ # Parallel file scanning
95
+ matched_parts = []
96
+ file_count = 0
97
+ total_tokens = 0
98
+ with ThreadPoolExecutor(max_workers=min(len(tracked), 8)) as pool:
99
+ for result in pool.map(_scan_file, tracked):
100
+ if result is None:
101
+ continue
102
+ rel, file_matches = result
103
+ chunk = f"--- {rel} ---\n" + "\n".join(file_matches)
104
+ chunk_tokens = count_tokens(chunk)
105
+ if total_tokens + chunk_tokens > max_tokens and matched_parts:
106
+ break
107
+ file_count += 1
108
+ total_tokens += chunk_tokens
109
+ matched_parts.append(chunk)
110
+ if file_count >= top_k:
111
+ break
112
+
113
+ if not matched_parts:
114
+ return finalize("c3_search", {"action": "exact"},
115
+ f"[search:exact:{query}] 0 results", "0")
116
+
117
+ resp = "\n".join(matched_parts)
118
+ return finalize("c3_search", {"action": "exact"}, resp, f"{file_count}f",
119
+ response_tokens=total_tokens)
120
+
121
+
122
+ def _files_search(query, top_k, svc, finalize):
123
+ res = svc.indexer.search(query, top_k=top_k, include_content=False)
124
+ if not res:
125
+ return finalize("c3_search", {"action": "files"},
126
+ f"[search:files:{query}] 0 results", "0")
127
+ parts = []
128
+ _MAP_TOKEN_CAP = 600 # cap inline file map to avoid bloating response
129
+ for r in res:
130
+ meta = f"- {r['file']} (L{r['lines']})"
131
+ if r.get('name'):
132
+ meta += f" — contains {r['type']} '{r['name']}'"
133
+ if len(parts) == 0:
134
+ try:
135
+ rel = r['file'].replace("\\", "/")
136
+ watcher_active = (hasattr(svc, "watcher") and svc.watcher._observer.is_alive())
137
+ if not watcher_active and svc.file_memory.needs_update(rel):
138
+ svc.file_memory.update(rel)
139
+ fmap = svc.file_memory.get_or_build_map(rel)
140
+ if fmap and count_tokens(fmap) <= _MAP_TOKEN_CAP:
141
+ meta += f"\n {fmap.replace(chr(10), chr(10) + ' ')}"
142
+ elif fmap:
143
+ # Truncate large maps: keep first N lines
144
+ fmap_lines = fmap.split("\n")
145
+ truncated = []
146
+ tok = 0
147
+ for fl in fmap_lines:
148
+ tok += count_tokens(fl)
149
+ if tok > _MAP_TOKEN_CAP:
150
+ break
151
+ truncated.append(fl)
152
+ truncated.append(" [map truncated]")
153
+ meta += f"\n {chr(10).join(truncated).replace(chr(10), chr(10) + ' ')}"
154
+ except Exception:
155
+ pass
156
+ parts.append(meta)
157
+ return finalize("c3_search", {"action": "files"},
158
+ "\n".join(parts),
159
+ f"{len(res)}f")
160
+
161
+
162
+ def _transcript_search(query, top_k, max_tokens, svc, finalize):
163
+ sync_result = svc.convo_store.sync(source="all")
164
+ available = sync_result.get("available_sources", {})
165
+ available_names = [name for name, present in available.items() if present]
166
+ if not available_names:
167
+ resp = ("[transcript:unavailable] No supported transcript sources found for this project. "
168
+ "Supported sources: Claude Code, Gemini CLI, and imported transcripts under .c3/conversations/imports.")
169
+ return finalize("c3_search", {"action": "transcript"}, resp, "unavailable")
170
+
171
+ results = svc.convo_store.search(query, limit=max(top_k * 3, top_k))
172
+ if not results:
173
+ srcs = ",".join(sorted(available_names))
174
+ return finalize("c3_search", {"action": "transcript"},
175
+ f"[transcript:{query}] 0 results sources:{srcs}", "0")
176
+ parts = []
177
+ total_tokens = 0
178
+ emitted = 0
179
+ for r in results:
180
+ tokens = int(r.get("tokens", 0) or count_tokens(r.get("text", "")))
181
+ if total_tokens + tokens > max_tokens and parts:
182
+ break
183
+ total_tokens += tokens
184
+ ts_raw = r.get("ts", 0)
185
+ try:
186
+ ts_str = time.strftime("%Y-%m-%d", time.localtime(float(ts_raw))) if ts_raw else ""
187
+ except Exception:
188
+ ts_str = ""
189
+ source = r.get("source") or r.get("turn_source") or "manual"
190
+ role = r.get("role", "")
191
+ session_id = r.get("session_id", "")
192
+ header = f"--- {source}:{session_id} [{ts_str}] role:{role} score:{r['score']}"
193
+ text = r.get("text", "")
194
+ parts.extend([header, text])
195
+ emitted += 1
196
+ if emitted >= top_k:
197
+ break
198
+ resp = f"[transcript:{query}] {emitted}r,{total_tokens}tok\n" + "\n".join(parts)
199
+ return finalize("c3_search", {"action": "transcript"}, resp, f"{emitted}r")
200
+
201
+
202
+ def _semantic_search(query, top_k, max_tokens, svc, finalize, maybe_facts):
203
+ ei = getattr(svc, "embedding_index", None)
204
+ if not ei or not ei.ready:
205
+ # Fallback to TF-IDF code search when embeddings unavailable
206
+ return _code_search(query, top_k, max_tokens, svc, finalize, maybe_facts)
207
+
208
+ results = ei.search(query, top_k=top_k, max_tokens=max_tokens)
209
+ if not results:
210
+ return finalize("c3_search", {"query": query, "action": "semantic"},
211
+ f"[semantic:{query}] 0 results (falling back to code search)",
212
+ "0→fallback")
213
+
214
+ lines = []
215
+ total_tokens = 0
216
+ for r in results:
217
+ name = f" {r['name']}" if r.get('name') else ""
218
+ ref = f"--- {r['file']}:L{r['lines']}{name} ({r['type']})"
219
+ lines.extend([ref, r['content']] if r.get('content') else [ref])
220
+ total_tokens += r['tokens']
221
+
222
+ resp = "\n".join(lines)
223
+ resp += maybe_facts(svc, query, top_k=2)
224
+ return finalize("c3_search", {"query": query, "action": "semantic"}, resp,
225
+ f"{len(results)}r,{total_tokens}tok", response_tokens=total_tokens)
226
+
227
+
228
+ def _code_search(query, top_k, max_tokens, svc, finalize, maybe_facts):
229
+ results = svc.indexer.search(query, top_k=max(top_k + 1, top_k * 2),
230
+ max_tokens=max_tokens, include_content=True)
231
+ if not results:
232
+ return finalize("c3_search", {"query": query}, f"[search:{query}] 0 results", "0")
233
+
234
+ best_score = max((r.get("score", 0.0) for r in results), default=0.0)
235
+ if best_score > 0:
236
+ results = [r for r in results if r.get("score", 0.0) >= (best_score * 0.2)]
237
+
238
+ deduped = []
239
+ seen = set()
240
+ for r in results:
241
+ key = (r.get("file"), r.get("lines"))
242
+ if key not in seen:
243
+ seen.add(key)
244
+ deduped.append(r)
245
+ if len(deduped) >= top_k:
246
+ break
247
+
248
+ lines = []
249
+ total_tokens = 0
250
+ for r in deduped:
251
+ name = f" {r['name']}" if r['name'] else ""
252
+ ref = f"--- {r['file']}:L{r['lines']}{name} ({r['type']})"
253
+ lines.extend([ref, r['content']] if r.get('content') else [ref])
254
+ total_tokens += r['tokens']
255
+
256
+ resp = "\n".join(lines)
257
+ resp += maybe_facts(svc, query, top_k=2)
258
+ full_tokens = sum(r.get("file_tokens", r["tokens"]) for r in deduped)
259
+ summary = f"{full_tokens}->{total_tokens}tok" if total_tokens < full_tokens else f"{len(deduped)}r"
260
+ return finalize("c3_search", {"query": query, "top_k": top_k}, resp, summary,
261
+ response_tokens=total_tokens)
262
+
263
+
264
+ def _append_prefetch(resp: str, query: str, top_k: int, svc) -> str:
265
+ """Auto-compress top result files in parallel and append structural maps."""
266
+ # Extract file paths from the response
267
+ files = []
268
+ for line in resp.split("\n"):
269
+ if line.startswith("--- ") and ":L" in line:
270
+ # Pattern: --- path/file.py:L10-20 name (type,tok,s=0.123)
271
+ path = line[4:].split(":L")[0].strip()
272
+ if path and path not in files:
273
+ files.append(path)
274
+ elif line.startswith("- ") and " (L" in line:
275
+ # Pattern: - path/file.py (L123)
276
+ path = line[2:].split(" (L")[0].strip()
277
+ if path and path not in files:
278
+ files.append(path)
279
+
280
+ cfg = (svc.hybrid_config or {}).get("agent_workflows", {})
281
+ max_files = max(1, int(cfg.get("prefetch_max_files", 3)))
282
+ files = files[:max_files]
283
+
284
+ if not files:
285
+ return resp
286
+
287
+ maps = {}
288
+ uncached = []
289
+
290
+ # Fast path: check in-memory + file_memory map cache before spawning threads
291
+ for fp in files:
292
+ try:
293
+ fmap = svc.file_memory.get_map(fp.replace("\\", "/"))
294
+ if fmap:
295
+ maps[fp] = fmap
296
+ continue
297
+ except Exception:
298
+ pass
299
+ uncached.append(fp)
300
+
301
+ def compress_one(fp):
302
+ try:
303
+ full = str(Path(svc.project_path) / fp)
304
+ result = svc.compressor.compress_file(full, "map")
305
+ if isinstance(result, dict) and result.get("compressed"):
306
+ return fp, result["compressed"]
307
+ except Exception:
308
+ pass
309
+ return fp, None
310
+
311
+ if uncached:
312
+ with ThreadPoolExecutor(max_workers=min(len(uncached), 8)) as pool:
313
+ futures = {pool.submit(compress_one, f): f for f in uncached}
314
+ for fut in as_completed(futures):
315
+ fp, compressed = fut.result()
316
+ if compressed:
317
+ maps[fp] = compressed
318
+
319
+ if not maps:
320
+ return resp
321
+
322
+ # Budget: prefetch maps share remaining token headroom. Uses fast approximation —
323
+ # exact tiktoken cost is wasted work here (we only need an upper-bound check).
324
+ resp_tokens = _approx_tokens(resp)
325
+ remaining = max(400, _RESPONSE_TOKEN_CAP - resp_tokens)
326
+
327
+ prefetch_parts = [f"\n\n--- prefetched maps ({len(maps)} files) ---"]
328
+ used = 0
329
+ for fp in files:
330
+ if fp in maps:
331
+ m = maps[fp]
332
+ m_tok = _approx_tokens(m)
333
+ if used + m_tok > remaining:
334
+ break
335
+ prefetch_parts.append(f"## {fp}\n{m}")
336
+ used += m_tok
337
+ return resp + "\n".join(prefetch_parts)
cli/tools/session.py ADDED
@@ -0,0 +1,95 @@
1
+ """c3_session — Session lifecycle, snapshots, and logging."""
2
+
3
+ import re
4
+
5
+
6
+ def handle_session(action: str, data: str, reasoning: str, description: str,
7
+ summary: str, event_type: str, svc, finalize) -> str:
8
+ if action == "start":
9
+ if svc.session_mgr.current_session:
10
+ svc.session_mgr.save_session()
11
+ result = svc.session_mgr.start_session(description, source_system=svc.ide_name)
12
+ return finalize("c3_session", {"action": action},
13
+ f"[session:started] {result['session_id']}", result['session_id'])
14
+
15
+ if action == "save":
16
+ # Auto-memory: flush pending extractions and generate session summary.
17
+ if hasattr(svc, "auto_memory"):
18
+ try:
19
+ svc.auto_memory.on_session_end()
20
+ except Exception:
21
+ pass
22
+ svc.session_mgr._persist_budget()
23
+ result = svc.session_mgr.save_session(summary)
24
+ if "error" in result:
25
+ return f"Error: {result['error']}"
26
+ return finalize("c3_session", {"action": action},
27
+ f"[session:saved] {result['session_id']}", result['session_id'])
28
+
29
+ if action == "plan":
30
+ svc.session_mgr.log_decision(f"PLAN: {data}", reasoning)
31
+ svc.activity_log.log("plan", {"plan": data, "reasoning": reasoning})
32
+ svc.memory.remember(f"PLAN: {data}", "plan",
33
+ (svc.session_mgr.current_session or {}).get("id", ""))
34
+ return finalize("c3_session", {"action": action, "data": data[:80]},
35
+ "[plan:stored]", "ok")
36
+
37
+ if action == "log":
38
+ etype = event_type
39
+ if etype == "auto":
40
+ data_stripped = data.strip()
41
+ if re.match(r'^[\w./\\-]+\.(py|js|ts|tsx|jsx|rs|go|java|rb|css|html|md|json|yaml|yml|toml)\b',
42
+ data_stripped):
43
+ etype = "file_change"
44
+ else:
45
+ etype = "decision"
46
+ if etype == "file_change":
47
+ svc.session_mgr.log_file_change(data, "modified", reasoning)
48
+ svc.activity_log.log("file_change", {"file": data, "summary": reasoning})
49
+ else:
50
+ svc.session_mgr.log_decision(data, reasoning)
51
+ svc.activity_log.log("decision", {"decision": data, "reasoning": reasoning})
52
+ return finalize("c3_session", {"action": action, "data": data[:80]},
53
+ f"[logged:{etype}]", "ok")
54
+
55
+ if action == "snapshot":
56
+ # Auto-memory: flush pending extractions before capturing snapshot.
57
+ if hasattr(svc, "auto_memory"):
58
+ try:
59
+ svc.auto_memory.on_session_end()
60
+ except Exception:
61
+ pass
62
+ # summary = optional comma-separated working files to embed structural maps for
63
+ files = [f.strip() for f in summary.split(",") if f.strip()] if summary else []
64
+ compressor = getattr(svc, "compressor", None)
65
+ res = svc.snapshots.capture(svc.session_mgr, svc.memory, data or "", files, reasoning,
66
+ compressor=compressor)
67
+ msg = f"✓ snapshot {res['snapshot_id']} ({res['token_count']}tok). Ask user to /clear, then restore."
68
+ return finalize("c3_session", {"action": action}, msg, res['snapshot_id'])
69
+
70
+ if action == "restore":
71
+ res = svc.snapshots.restore(data or "latest", memory_store=svc.memory)
72
+ if "error" in res:
73
+ return f"[restore:error] {res['error']}"
74
+ svc.session_mgr.reset_budget(initial_tokens=res.get("tokens", 0))
75
+ briefing = res["briefing"]
76
+ return finalize("c3_session", {"action": action},
77
+ briefing, f"{res['snapshot_id']},{res['tokens']}tok")
78
+
79
+ if action == "compact":
80
+ res = svc.snapshots.capture(svc.session_mgr, svc.memory,
81
+ data or "auto-checkpoint before /clear")
82
+ if "error" in res:
83
+ return f"[compact:error] {res['error']}"
84
+ svc.session_mgr.reset_budget(initial_tokens=0)
85
+ msg = f"✓ compacted {res['snapshot_id']}. Budget reset. Ask user to /clear, then restore."
86
+ return finalize("c3_session", {"action": action}, msg, res['snapshot_id'])
87
+
88
+ if action == "convo_log":
89
+ sid = (svc.session_mgr.current_session or {}).get("id", "manual")
90
+ role = event_type if event_type != "auto" else "user"
91
+ if svc.convo_store:
92
+ svc.convo_store.add_turn(sid, role, data)
93
+ return finalize("c3_session", {"action": action}, "", "logged")
94
+
95
+ return f"[session:error] Unknown action: {action}"