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.
- cli/__init__.py +1 -0
- cli/_hook_utils.py +99 -0
- cli/c3.py +6152 -0
- cli/commands/__init__.py +1 -0
- cli/commands/common.py +312 -0
- cli/commands/parser.py +286 -0
- cli/docs.html +3178 -0
- cli/edits.html +878 -0
- cli/hook_auto_snapshot.py +142 -0
- cli/hook_c3_signal.py +61 -0
- cli/hook_c3read.py +116 -0
- cli/hook_edit_ledger.py +213 -0
- cli/hook_edit_unlock.py +170 -0
- cli/hook_filter.py +130 -0
- cli/hook_ghost_files.py +238 -0
- cli/hook_pretool_enforce.py +334 -0
- cli/hook_read.py +200 -0
- cli/hook_session_stats.py +62 -0
- cli/hook_terse_advisor.py +190 -0
- cli/hub.html +3764 -0
- cli/hub_server.py +1619 -0
- cli/mcp_proxy.py +428 -0
- cli/mcp_server.py +660 -0
- cli/server.py +2985 -0
- cli/tools/__init__.py +4 -0
- cli/tools/_helpers.py +65 -0
- cli/tools/agent.py +1165 -0
- cli/tools/compress.py +215 -0
- cli/tools/delegate.py +1184 -0
- cli/tools/edit.py +313 -0
- cli/tools/edits.py +118 -0
- cli/tools/filter.py +285 -0
- cli/tools/impact.py +163 -0
- cli/tools/memory.py +469 -0
- cli/tools/read.py +224 -0
- cli/tools/search.py +337 -0
- cli/tools/session.py +95 -0
- cli/tools/shell.py +193 -0
- cli/tools/status.py +306 -0
- cli/tools/validate.py +310 -0
- cli/ui/api.js +36 -0
- cli/ui/app.js +207 -0
- cli/ui/components/chat.js +758 -0
- cli/ui/components/dashboard.js +689 -0
- cli/ui/components/edits.js +220 -0
- cli/ui/components/instructions.js +481 -0
- cli/ui/components/memory.js +626 -0
- cli/ui/components/sessions.js +606 -0
- cli/ui/components/settings.js +1404 -0
- cli/ui/components/sidebar.js +156 -0
- cli/ui/icons.js +51 -0
- cli/ui/shared.js +119 -0
- cli/ui/theme.js +22 -0
- cli/ui.html +168 -0
- cli/ui_legacy.html +6797 -0
- cli/ui_nano.html +503 -0
- code_context_control-2.28.0.dist-info/METADATA +248 -0
- code_context_control-2.28.0.dist-info/RECORD +150 -0
- code_context_control-2.28.0.dist-info/WHEEL +5 -0
- code_context_control-2.28.0.dist-info/entry_points.txt +4 -0
- code_context_control-2.28.0.dist-info/licenses/LICENSE +201 -0
- code_context_control-2.28.0.dist-info/top_level.txt +5 -0
- core/__init__.py +75 -0
- core/config.py +269 -0
- core/ide.py +188 -0
- oracle/__init__.py +1 -0
- oracle/config.py +75 -0
- oracle/oracle.html +3900 -0
- oracle/oracle_server.py +663 -0
- oracle/services/__init__.py +1 -0
- oracle/services/c3_bridge.py +210 -0
- oracle/services/chat_engine.py +1103 -0
- oracle/services/chat_store.py +155 -0
- oracle/services/cross_memory.py +154 -0
- oracle/services/federated_graph.py +463 -0
- oracle/services/health_checker.py +117 -0
- oracle/services/insight_engine.py +307 -0
- oracle/services/memory_reader.py +106 -0
- oracle/services/memory_writer.py +182 -0
- oracle/services/ollama_bridge.py +332 -0
- oracle/services/project_scanner.py +87 -0
- oracle/services/review_agent.py +206 -0
- services/__init__.py +1 -0
- services/activity_log.py +93 -0
- services/agent_base.py +124 -0
- services/agents.py +1529 -0
- services/auto_memory.py +407 -0
- services/bench/__init__.py +6 -0
- services/bench/external/__init__.py +29 -0
- services/bench/external/aider_polyglot.py +405 -0
- services/bench/external/swe_bench.py +485 -0
- services/benchmark_dashboard.py +596 -0
- services/claude_md.py +785 -0
- services/compressor.py +592 -0
- services/context_snapshot.py +356 -0
- services/conversation_store.py +870 -0
- services/doc_index.py +537 -0
- services/e2e_benchmark.py +2884 -0
- services/e2e_evaluator.py +396 -0
- services/e2e_tasks.py +743 -0
- services/edit_ledger.py +459 -0
- services/embedding_index.py +341 -0
- services/error_reporting.py +123 -0
- services/file_memory.py +734 -0
- services/hub_service.py +585 -0
- services/indexer.py +712 -0
- services/memory.py +318 -0
- services/memory_consolidator.py +538 -0
- services/memory_graph.py +382 -0
- services/memory_grounder.py +304 -0
- services/memory_scorer.py +246 -0
- services/metrics.py +86 -0
- services/notifications.py +209 -0
- services/ollama_client.py +201 -0
- services/output_filter.py +488 -0
- services/parser.py +1238 -0
- services/project_manager.py +579 -0
- services/protocol.py +306 -0
- services/proxy_state.py +152 -0
- services/retrieval_broker.py +129 -0
- services/router.py +414 -0
- services/runtime.py +326 -0
- services/session_benchmark.py +1945 -0
- services/session_manager.py +1026 -0
- services/session_preloader.py +251 -0
- services/text_index.py +90 -0
- services/tool_classifier.py +176 -0
- services/transcript_index.py +340 -0
- services/validation_cache.py +155 -0
- services/vector_store.py +299 -0
- services/version_tracker.py +271 -0
- services/watcher.py +192 -0
- tui/__init__.py +0 -0
- tui/backend.py +59 -0
- tui/main.py +145 -0
- tui/screens/__init__.py +1 -0
- tui/screens/benchmark_view.py +109 -0
- tui/screens/claudemd_view.py +46 -0
- tui/screens/compress_view.py +52 -0
- tui/screens/index_view.py +74 -0
- tui/screens/init_view.py +82 -0
- tui/screens/mcp_view.py +73 -0
- tui/screens/optimize_view.py +41 -0
- tui/screens/pipe_view.py +46 -0
- tui/screens/projects_view.py +355 -0
- tui/screens/search_view.py +55 -0
- tui/screens/session_view.py +143 -0
- tui/screens/stats.py +158 -0
- tui/screens/ui_view.py +54 -0
- 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}"
|