code-context-control 2.121.0__py3-none-any.whl → 2.123.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/c3.py CHANGED
@@ -92,7 +92,7 @@ console = Console() if HAS_RICH else None
92
92
  # Config
93
93
  CONFIG_DIR = ".c3"
94
94
  CONFIG_FILE = ".c3/config.json"
95
- __version__ = "2.121.0"
95
+ __version__ = "2.123.0"
96
96
 
97
97
 
98
98
  def _compress_file_cli(compressor, path, mode="smart", **kw):
@@ -271,7 +271,7 @@ _STALE_MCP_TOOLS = {
271
271
  "mcp__c3__c3_remember", # → c3_memory(action='add')
272
272
  "mcp__c3__c3_recall", # → c3_memory(action='recall')
273
273
  "mcp__c3__c3_sltm_add", # → c3_memory(action='add')
274
- "mcp__c3__c3_file_map", # → c3_compress(mode='map')
274
+ "mcp__c3__c3_file_map", # → c3_read(file_path) map
275
275
  "mcp__c3__c3_extract", # → c3_read
276
276
  "mcp__c3__c3_transcript_search", # → c3_search(action='transcript')
277
277
  "mcp__c3__c3_session_log", # → c3_session(action='log')
@@ -3971,7 +3971,7 @@ def cmd_benchmark(args):
3971
3971
  "prompt_budget_multiplier": round(_prompt_gain(comp_comp, comp_orig), 2),
3972
3972
  },
3973
3973
  "file_navigation": {
3974
- "description": "Use c3_compress(mode='map') to choose targeted reads instead of opening whole files blindly.",
3974
+ "description": "Use the c3_read file map to choose targeted reads instead of opening whole files blindly.",
3975
3975
  "performance_metric": "map success rate",
3976
3976
  "with_c3": {"tool": "c3_compress(mode='map')", "total_tokens": file_map_comp, "avg_latency_ms": round(_avg(file_map_c3_latencies), 2), "performance": round((file_map_successes / len(file_map_sample) * 100), 1) if file_map_sample else 0.0},
3977
3977
  "without_c3": {"approach": "read full files into context", "total_tokens": file_map_orig, "avg_latency_ms": round(_avg(file_map_baseline_latencies), 2), "performance": 100.0},
@@ -5005,7 +5005,7 @@ back to native tools as the task progresses.
5005
5005
 
5006
5006
  ## Quick Reference
5007
5007
  - **Search**: `c3_search(query=..., action='code|files|semantic')` — before Grep/Glob
5008
- - **Read**: `c3_compress(mode='map')` then `c3_read(symbols=...)` — before native Read
5008
+ - **Read**: `c3_read(file_path)` maps a file (or directory), then `c3_read(symbols=['Class.method'])` — before native Read
5009
5009
  - **Impact**: `c3_impact(target='symbol')` — blast-radius check before editing shared symbols
5010
5010
  - **Edit**: `c3_edit(file_path=..., old_string=..., new_string=...)` — before Edit/Write
5011
5011
  - **Validate**: `c3_validate(file_path=...)` — after every edit (pyright/tsc type check if installed)
cli/commands/common.py CHANGED
@@ -72,7 +72,7 @@ def cmd_index(args, deps: CommandDeps):
72
72
  def cmd_compress(args, deps: CommandDeps):
73
73
  """Compress a file and show results."""
74
74
  config = deps.load_config()
75
- mode = args.mode or "smart"
75
+ mode = args.mode or "map"
76
76
 
77
77
  compressor = deps.CodeCompressor(str(Path(config.get("project_path", ".")) / ".c3/cache"))
78
78
  result = compressor.compress_file(args.file, mode)
cli/commands/parser.py CHANGED
@@ -91,7 +91,8 @@ def build_parser(version: str, parse_cli_ide_arg):
91
91
 
92
92
  p_compress = subparsers.add_parser("compress", help="Compress a file")
93
93
  p_compress.add_argument("file", help="File to compress")
94
- p_compress.add_argument("--mode", choices=["map", "dense_map", "smart", "diff"], default="smart")
94
+ p_compress.add_argument("--mode", choices=["map", "smart", "structure", "outline"], default="map",
95
+ help="map (default) is the canonical file map; the others are signature summaries")
95
96
  p_compress.add_argument("--output", "-o", action="store_true", help="Show compressed output")
96
97
 
97
98
  p_context = subparsers.add_parser("context", help="Get relevant context for a query")
cli/guide/index.html CHANGED
@@ -636,7 +636,7 @@
636
636
  <span>Discover files / content — never start with Grep/Glob</span>
637
637
  </div>
638
638
  <div class="quick-item">
639
- <code>3. c3_compress(mode='map') + c3_read(symbols=…)</code>
639
+ <code>3. c3_read(file_path) + c3_read(symbols=…)</code>
640
640
  <span>Map then surgically read — never start with native Read</span>
641
641
  </div>
642
642
  <div class="quick-item">
cli/guide/workflow.html CHANGED
@@ -315,7 +315,7 @@
315
315
  <tr>
316
316
  <td><span class="badge badge-blue">3</span></td>
317
317
  <td><strong>Map + Read</strong></td>
318
- <td><code>c3_compress(mode='map')</code> → <code>c3_read(symbols=…)</code></td>
318
+ <td><code>c3_read(file_path)</code> (map) → <code>c3_read(symbols=…)</code></td>
319
319
  <td>Before reading any file content</td>
320
320
  </tr>
321
321
  <tr>
@@ -130,7 +130,7 @@ _VAULT_FILES = frozenset({"config.json", "secrets.enc", "cred_state.json",
130
130
  # Redirect messages per native tool
131
131
  _REDIRECTS = {
132
132
  "Read": (
133
- "Use c3_compress(file_path='...', mode='map') to map the file first, "
133
+ "Use c3_read(file_path='...') to map the file first (one line per symbol), "
134
134
  "then c3_read(file_path='...', symbols=['...']) for surgical extraction."
135
135
  ),
136
136
  "Grep": (
cli/tools/compress.py CHANGED
@@ -1,12 +1,12 @@
1
1
  """c3_compress — the canonical file map (services/file_map.render_map), one file or a batch.
2
2
 
3
- mode='map' (default) is the same text c3_read(file_path) serves. The other
4
- modes (dense_map, smart, diff, bug_scan, ast) are legacy and retire in 2.122.0."""
3
+ There is one mode, `map`, and it is the same text c3_read(file_path) serves.
4
+ The modes that existed before 2.122.0 (dense_map, smart, diff, bug_scan,
5
+ ast, structure, outline) were called twice in 3,932 tool calls; a request
6
+ for one of them is answered with the map plus a one-line notice so an old
7
+ prompt keeps working while it says so (docs/file-map.md § Retired modes).
8
+ """
5
9
 
6
- import json
7
- import shutil
8
- import subprocess
9
- import sys
10
10
  from concurrent.futures import ThreadPoolExecutor, as_completed
11
11
  from pathlib import Path
12
12
 
@@ -15,6 +15,24 @@ from cli.tools._helpers import finalize_with_tokens, show_token_ratios
15
15
  from core import count_tokens
16
16
  from services import access_guard
17
17
 
18
+ #: Every mode name c3_compress ever accepted, and what to say about it now.
19
+ RETIRED_MODES = {
20
+ "dense_map": "the map is dense now",
21
+ "smart": "the map carries full signatures",
22
+ "structure": "the map carries full signatures",
23
+ "outline": "the map carries full signatures",
24
+ "diff": "use `git diff` or the edit ledger (c3_edits)",
25
+ "bug_scan": "use c3_search(action='exact', query='except ') for handler hotspots",
26
+ "ast": "project architecture: codebase-memory-mcp `cli get_architecture`, or c3_status",
27
+ }
28
+ RETIRED_IN = "2.122.0"
29
+
30
+
31
+ def deprecation_line(requested: str) -> str:
32
+ hint = RETIRED_MODES.get(requested, "")
33
+ return (f"[compress:deprecated] mode '{requested}' retired in {RETIRED_IN} — "
34
+ f"map is the only mode ({hint}); see docs/file-map.md")
35
+
18
36
 
19
37
  def map_detail(svc, rel: str, requested_mode: str, *, cache_hit: bool,
20
38
  symbols: int = 0, fallback: str = "") -> dict:
@@ -24,6 +42,7 @@ def map_detail(svc, rel: str, requested_mode: str, *, cache_hit: bool,
24
42
  (tree_sitter | regex | generic, "" for a pre-2.120.0 record), cache_hit
25
43
  whether the record was already fresh. `symbols` is how many the caller
26
44
  asked for and `fallback` why a read served a map instead of source.
45
+ `deprecated_mode` (2.122.0) is set when a retired mode name was asked for.
27
46
  Flat so telemetry.aggregate_tool_telemetry can fold it without a schema.
28
47
  """
29
48
  record = None
@@ -44,225 +63,117 @@ def map_detail(svc, rel: str, requested_mode: str, *, cache_hit: bool,
44
63
  detail["symbols"] = int(symbols)
45
64
  if fallback:
46
65
  detail["fallback"] = fallback
66
+ if requested_mode not in ("map", "read"):
67
+ detail["deprecated_mode"] = requested_mode
47
68
  return detail
48
69
 
49
70
 
50
- def compressor_detail(res: dict, requested_mode: str) -> dict:
51
- """Telemetry `detail` for a CodeCompressor result (smart/diff/bug_scan…)."""
52
- backend = "large_fast_path" if res.get("fast_path") else "compressor"
53
- return {
54
- "requested_mode": requested_mode,
55
- "backend": backend,
56
- "actual_mode": str(res.get("mode") or ""),
57
- "cache_hit": bool(res.get("cache_hit")),
58
- }
59
-
60
-
61
- def _run_memory_mcp_cli(args: list, cwd: str, timeout: int = 30) -> tuple:
62
- """Run codebase-memory-mcp CLI and return (success, output_or_error)."""
63
- binary = shutil.which("codebase-memory-mcp")
64
- if not binary:
65
- return False, "not_installed"
66
- kwargs: dict = {}
67
- if sys.platform == "win32":
68
- kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
69
- try:
70
- result = subprocess.run(
71
- [binary] + args,
72
- capture_output=True, text=True, timeout=timeout,
73
- stdin=subprocess.DEVNULL, cwd=cwd, **kwargs,
74
- )
75
- if result.returncode == 0 and result.stdout.strip():
76
- return True, result.stdout.strip()
77
- return False, result.stderr.strip() or f"exit {result.returncode}"
78
- except subprocess.TimeoutExpired:
79
- return False, "timeout"
80
- except Exception as e:
81
- return False, str(e)
82
-
83
-
84
- def _compress_ast(file_path: str, svc, finalize, maybe_facts) -> str:
85
- """Use codebase-memory-mcp binary for AST knowledge-graph structural analysis."""
86
- cwd = str(svc.project_path)
87
- ok, out = _run_memory_mcp_cli(["cli", "get_architecture"], cwd)
88
-
89
- if not ok and out == "not_installed":
90
- return finalize(
91
- "c3_compress", {"file_path": file_path, "mode": "ast"},
92
- "[compress:ast] codebase-memory-mcp not installed.\n"
93
- "Install (macOS/Linux): curl -fsSL https://raw.githubusercontent.com/DeusData/codebase-memory-mcp/main/install.sh | bash\n"
94
- "Install (Windows): see https://github.com/DeusData/codebase-memory-mcp\n"
95
- "Tip: use c3_compress(mode='map') for C3's built-in structural analysis.",
96
- "not_installed",
97
- )
98
- if not ok:
99
- hint = "Run: codebase-memory-mcp index" if not out or "not indexed" in out.lower() else out
100
- return finalize(
101
- "c3_compress", {"file_path": file_path, "mode": "ast"},
102
- f"[compress:ast] {hint}\nTip: say 'Index this project' with codebase-memory-mcp active.",
103
- "not_indexed",
104
- )
105
-
106
- try:
107
- data = json.loads(out)
108
- except json.JSONDecodeError:
109
- return finalize("c3_compress", {"file_path": file_path, "mode": "ast"},
110
- f"[compress:ast] Unexpected output — try: codebase-memory-mcp index\n{out[:200]}",
111
- "error")
112
-
113
- lines = ["[compress:ast] codebase-memory-mcp knowledge graph"]
114
- if data.get("languages"):
115
- lines.append(f"Languages: {', '.join(str(l) for l in data['languages'])}")
116
- if data.get("entry_points"):
117
- eps = [str(e) for e in data["entry_points"][:6]]
118
- lines.append(f"Entry points: {', '.join(eps)}")
119
- if data.get("packages"):
120
- pkgs = [str(p) for p in data["packages"][:12]]
121
- lines.append(f"Packages: {', '.join(pkgs)}")
122
- if data.get("layers"):
123
- layers = [str(l) for l in data["layers"][:8]]
124
- lines.append(f"Layers: {', '.join(layers)}")
125
- if data.get("hotspots"):
126
- lines.append("Hotspots (high-churn):")
127
- for h in data["hotspots"][:6]:
128
- lines.append(f" {h}")
129
- if data.get("clusters"):
130
- lines.append(f"Clusters: {len(data['clusters'])} functional modules detected")
131
- if data.get("routes"):
132
- lines.append(f"Routes: {len(data['routes'])} HTTP endpoints")
133
-
134
- return finalize("c3_compress", {"file_path": file_path, "mode": "ast"},
135
- "\n".join(lines), "ast")
136
-
137
-
138
71
  def handle_compress(file_path: str, mode: str, svc,
139
72
  finalize, maybe_facts) -> str:
140
- # Validate mode
141
- valid_modes = ("map", "dense_map", "smart", "diff", "bug_scan", "ast")
142
- if mode == "ast":
143
- return _compress_ast(file_path, svc, finalize, maybe_facts)
144
- if mode not in valid_modes:
145
- # Graceful migration for removed modes
146
- if mode in ("structure", "outline"):
147
- mode = "map"
148
- else:
149
- return finalize("c3_compress", {"file_path": file_path, "mode": mode},
150
- f"[compress:error] Unknown mode '{mode}'. Use: {', '.join(valid_modes)}",
151
- "error")
73
+ requested = (mode or "map").strip() or "map"
74
+ if requested != "map" and requested not in RETIRED_MODES:
75
+ return finalize("c3_compress", {"file_path": file_path, "mode": requested},
76
+ f"[compress:error] Unknown mode '{requested}'. The only mode is 'map'.",
77
+ "error")
78
+ note = deprecation_line(requested) if requested != "map" else ""
152
79
 
153
80
  # Batch dispatch: comma-separated paths
154
81
  if "," in file_path:
155
82
  paths = [p.strip() for p in file_path.split(",") if p.strip()]
156
- return _compress_batch(paths, mode, svc, finalize, maybe_facts)
83
+ return _compress_batch(paths, requested, note, svc, finalize)
84
+
85
+ return _compress_single(file_path, requested, note, svc, finalize)
157
86
 
158
- return _compress_single(file_path, mode, svc, finalize, maybe_facts)
87
+
88
+ def _build_map(svc, rel: str) -> str:
89
+ """The canonical map for `rel`, draining any queued watcher updates first."""
90
+ queued = svc.file_memory.drain_queue()
91
+ completed = []
92
+ failed = []
93
+ for qp in queued[:10]:
94
+ try:
95
+ if svc.file_memory.update(qp):
96
+ completed.append(qp)
97
+ else:
98
+ failed.append(qp)
99
+ except Exception:
100
+ failed.append(qp)
101
+ if completed:
102
+ svc.file_memory.complete_updates(completed)
103
+ if failed:
104
+ svc.file_memory.complete_updates(failed, failed=True)
105
+ return svc.file_memory.get_or_build_map(rel)
159
106
 
160
107
 
161
- def _compress_single(file_path: str, mode: str, svc, finalize, maybe_facts) -> str:
162
- """Compress a single file."""
163
- # Access Guard: read verdict up front — covers the map/dense_map paths
164
- # that never reach compressor.compress_file (docs/access-guard.md §3).
108
+ def _compress_single(file_path: str, requested: str, note: str, svc, finalize) -> str:
109
+ """Map a single file."""
110
+ # Access Guard: read verdict up front (docs/access-guard.md §3).
165
111
  _verdict = access_guard.verdict(file_path, "read", svc.project_path)
166
112
  if _verdict.denial and not _grants.allow(svc, _verdict.denial,
167
113
  tool="c3_compress", op="read",
168
114
  path=file_path):
169
- return finalize("c3_compress", {"file_path": file_path, "mode": mode},
115
+ return finalize("c3_compress", {"file_path": file_path, "mode": requested},
170
116
  access_guard.refusal(_verdict.denial, file_path, "read"),
171
117
  "access-denied")
172
- # Mask Guard: the map/dense_map modes build their output through
173
- # file_memory, which reads the RAW file — so a masked path must never
174
- # reach them. Other modes fall through to compressor.compress_file, which
175
- # compresses the materialized view (docs/mask-guard.md §6, row 7).
176
- if _verdict.masked and mode in ("map", "dense_map"):
118
+ # Mask Guard: the map is built through file_memory, which reads the RAW
119
+ # file — a masked path is served from its view instead
120
+ # (docs/mask-guard.md §6, row 7).
121
+ if _verdict.masked:
177
122
  from services import mask_mirror
178
123
  try:
179
124
  view = mask_mirror.render_for_path(file_path, svc.project_path)
180
125
  except mask_mirror.MaskUnavailable as exc:
181
126
  return finalize("c3_compress",
182
- {"file_path": file_path, "mode": mode},
127
+ {"file_path": file_path, "mode": requested},
183
128
  f"{access_guard.TAG_MASK_UNSUPPORTED} {exc.message}",
184
129
  "mask-unavailable")
185
- return finalize("c3_compress", {"file_path": file_path, "mode": mode},
130
+ return finalize("c3_compress", {"file_path": file_path, "mode": requested},
186
131
  view.with_header(file_path), f"masked:{view.preset}")
187
132
 
188
133
  full = Path(svc.project_path) / file_path
189
134
  if not full.exists():
190
135
  full = Path(file_path)
191
-
192
- if mode in ("map", "dense_map"):
193
- if not full.exists():
194
- return ("[file_map:error] not found. To create a new file use "
195
- "c3_edit(file_path=..., old_string='', new_string=<content>).")
196
- rel = str(full.resolve().relative_to(Path(svc.project_path).resolve())).replace("\\", "/")
197
- # C0 measurement: was the record already fresh before this call?
198
- cache_hit = not svc.file_memory.needs_update(rel)
199
- queued = svc.file_memory.drain_queue()
200
- completed = []
201
- failed = []
202
- for qp in queued[:10]:
203
- try:
204
- if svc.file_memory.update(qp):
205
- completed.append(qp)
206
- else:
207
- failed.append(qp)
208
- except Exception:
209
- failed.append(qp)
210
- if completed:
211
- svc.file_memory.complete_updates(completed)
212
- if failed:
213
- svc.file_memory.complete_updates(failed, failed=True)
214
- res = (svc.file_memory.get_or_build_dense_map(rel)
215
- if mode == "dense_map"
216
- else svc.file_memory.get_or_build_map(rel))
217
- # Structured accounting: pass the (full-read baseline, map) pair via
218
- # record_tool_tokens() instead of a "raw->maptok" summary for the
219
- # legacy regex fallback to scrape.
220
- raw_tokens = None
221
- map_tokens = 0
222
- try:
223
- raw_text = full.read_text(encoding="utf-8", errors="replace")
224
- raw_tokens = count_tokens(raw_text)
225
- map_tokens = count_tokens(res)
226
- if map_tokens >= raw_tokens and not res.startswith("[file_map]"):
227
- # A map that costs more than the file is worth nothing
228
- # (docs/file-map.md § Small files).
229
- res = f"[compress:{file_path}] whole file — smaller than its map\n" + raw_text
230
- map_tokens = count_tokens(res)
231
- summary = mode
232
- except Exception:
233
- summary = "mapped"
234
- return finalize_with_tokens(
235
- finalize, svc, "c3_compress", {"file_path": file_path, "mode": mode},
236
- res, summary,
237
- raw_tokens=raw_tokens, optimized_tokens=map_tokens or None,
238
- response_tokens=map_tokens,
239
- detail=map_detail(svc, rel, mode, cache_hit=cache_hit))
240
-
136
+ if not full.exists():
137
+ return ("[file_map:error] not found. To create a new file use "
138
+ "c3_edit(file_path=..., old_string='', new_string=<content>).")
139
+ rel = str(full.resolve().relative_to(Path(svc.project_path).resolve())).replace("\\", "/")
140
+ # C0 measurement: was the record already fresh before this call?
141
+ cache_hit = not svc.file_memory.needs_update(rel)
142
+ res = _build_map(svc, rel)
143
+ raw_tokens = None
144
+ map_tokens = 0
241
145
  try:
242
- res = svc.compressor.compress_file(str(full), mode)
243
- except access_guard.AccessDenied as exc:
244
- return finalize("c3_compress", {"file_path": file_path, "mode": mode},
245
- exc.message, "access-denied")
246
- if "error" in res:
247
- return f"Error: {res['error']}"
248
- resp = res['compressed']
146
+ raw_text = full.read_text(encoding="utf-8", errors="replace")
147
+ raw_tokens = count_tokens(raw_text)
148
+ map_tokens = count_tokens(res)
149
+ if map_tokens >= raw_tokens and not res.startswith("[file_map]"):
150
+ # A map that costs more than the file is worth nothing
151
+ # (docs/file-map.md § Small files).
152
+ res = f"[compress:{file_path}] whole file — smaller than its map\n" + raw_text
153
+ map_tokens = count_tokens(res)
154
+ summary = "map"
155
+ except Exception:
156
+ summary = "mapped"
157
+ if note:
158
+ res = note + "\n" + res
159
+ map_tokens = count_tokens(res)
249
160
  return finalize_with_tokens(
250
- finalize, svc, "c3_compress", {"file_path": file_path},
251
- resp + maybe_facts(svc, Path(file_path).name), mode,
252
- raw_tokens=res.get('original_tokens'),
253
- optimized_tokens=res.get('compressed_tokens'),
254
- detail=compressor_detail(res, mode))
161
+ finalize, svc, "c3_compress", {"file_path": file_path, "mode": requested},
162
+ res, summary,
163
+ raw_tokens=raw_tokens, optimized_tokens=map_tokens or None,
164
+ response_tokens=map_tokens,
165
+ detail=map_detail(svc, rel, requested, cache_hit=cache_hit))
255
166
 
256
167
 
257
- def _compress_batch(paths: list, mode: str, svc, finalize, maybe_facts) -> str:
258
- """Compress multiple files in parallel, return combined report."""
168
+ def _compress_batch(paths: list, requested: str, note: str, svc, finalize) -> str:
169
+ """Map several files in parallel, return one combined report."""
259
170
  max_files = 10
260
171
  paths = paths[:max_files]
261
172
 
262
173
  results = {}
263
174
 
264
175
  def _do_one(fp):
265
- """Returns (fp, compressed_text, raw_tokens, optimized_tokens, error)."""
176
+ """Returns (fp, map_text, raw_tokens, map_tokens, error)."""
266
177
  try:
267
178
  # Access Guard: per-member read verdict — the S1 refusal becomes
268
179
  # this member's batch line; other members are still served.
@@ -271,8 +182,6 @@ def _compress_batch(paths: list, mode: str, svc, finalize, maybe_facts) -> str:
271
182
  return fp, None, None, None, access_guard.refusal(_v.denial, fp,
272
183
  "read")
273
184
  if _v.masked:
274
- # Serve this member from its view; map modes would otherwise
275
- # build from raw bytes via file_memory.
276
185
  from services import mask_mirror
277
186
  try:
278
187
  view = mask_mirror.render_for_path(fp, svc.project_path)
@@ -286,33 +195,23 @@ def _compress_batch(paths: list, mode: str, svc, finalize, maybe_facts) -> str:
286
195
  full = Path(fp)
287
196
  if not full.exists():
288
197
  return fp, None, None, None, "not found"
289
-
290
- if mode in ("map", "dense_map"):
291
- rel = str(full.resolve().relative_to(
292
- Path(svc.project_path).resolve())).replace("\\", "/")
293
- res = (svc.file_memory.get_or_build_dense_map(rel)
294
- if mode == "dense_map"
295
- else svc.file_memory.get_or_build_map(rel))
296
- try:
297
- raw_tok = count_tokens(full.read_text(encoding="utf-8", errors="replace"))
298
- map_tok = count_tokens(res)
299
- return fp, res, raw_tok, map_tok, None
300
- except Exception:
301
- return fp, res, None, None, None
302
- else:
303
- res = svc.compressor.compress_file(str(full), mode)
304
- if "error" in res:
305
- return fp, None, None, None, res["error"]
306
- return (fp, res["compressed"], res.get("original_tokens"),
307
- res.get("compressed_tokens"), None)
198
+ rel = str(full.resolve().relative_to(
199
+ Path(svc.project_path).resolve())).replace("\\", "/")
200
+ res = svc.file_memory.get_or_build_map(rel)
201
+ try:
202
+ raw_tok = count_tokens(full.read_text(encoding="utf-8", errors="replace"))
203
+ map_tok = count_tokens(res)
204
+ return fp, res, raw_tok, map_tok, None
205
+ except Exception:
206
+ return fp, res, None, None, None
308
207
  except Exception as e:
309
208
  return fp, None, None, None, str(e)
310
209
 
311
210
  with ThreadPoolExecutor(max_workers=min(len(paths), 8)) as pool:
312
211
  futures = {pool.submit(_do_one, fp): fp for fp in paths}
313
212
  for fut in as_completed(futures):
314
- fp, compressed, raw_tok, opt_tok, err = fut.result()
315
- results[fp] = (compressed, raw_tok, opt_tok, err)
213
+ fp, text, raw_tok, opt_tok, err = fut.result()
214
+ results[fp] = (text, raw_tok, opt_tok, err)
316
215
 
317
216
  ratios = show_token_ratios(svc)
318
217
  parts = []
@@ -321,8 +220,8 @@ def _compress_batch(paths: list, mode: str, svc, finalize, maybe_facts) -> str:
321
220
  total_opt = 0
322
221
  measured = 0
323
222
  for fp in paths:
324
- compressed, raw_tok, opt_tok, err = results.get(fp, (None, None, None, "unknown"))
325
- if compressed:
223
+ text, raw_tok, opt_tok, err = results.get(fp, (None, None, None, "unknown"))
224
+ if text:
326
225
  tag = ""
327
226
  if raw_tok is not None and opt_tok is not None:
328
227
  measured += 1
@@ -330,18 +229,23 @@ def _compress_batch(paths: list, mode: str, svc, finalize, maybe_facts) -> str:
330
229
  total_opt += opt_tok
331
230
  if ratios:
332
231
  tag = f" ({raw_tok}->{opt_tok}tok)"
333
- parts.append(f"## {fp}{tag}\n{compressed}")
232
+ parts.append(f"## {fp}{tag}\n{text}")
334
233
  total_ok += 1
335
234
  else:
336
235
  parts.append(f"## {fp} — ERROR: {err}")
337
236
 
338
- header = f"[compress:batch] {total_ok}/{len(paths)} files ({mode})"
237
+ header = f"[compress:batch] {total_ok}/{len(paths)} files (map)"
238
+ if note:
239
+ header = note + "\n" + header
339
240
  body = header + "\n\n" + "\n\n".join(parts)
241
+ detail = {"requested_mode": requested, "backend": "batch",
242
+ "files": len(paths), "ok": total_ok}
243
+ if requested != "map":
244
+ detail["deprecated_mode"] = requested
340
245
  return finalize_with_tokens(
341
246
  finalize, svc, "c3_compress",
342
- {"file_path": ",".join(paths), "mode": mode, "batch": True},
247
+ {"file_path": ",".join(paths), "mode": requested, "batch": True},
343
248
  body, f"batch {total_ok}/{len(paths)}",
344
249
  raw_tokens=total_raw if measured else None,
345
250
  optimized_tokens=total_opt if measured else None,
346
- detail={"requested_mode": mode, "backend": "batch",
347
- "files": len(paths), "ok": total_ok})
251
+ detail=detail)
cli/tools/read.py CHANGED
@@ -177,6 +177,22 @@ def handle_read(file_path: str, symbols: Any = None, lines: Any = None,
177
177
 
178
178
  rel_path = str(full.resolve().relative_to(Path(svc.project_path).resolve())).replace("\\", "/")
179
179
 
180
+ if full.is_dir():
181
+ # A directory maps to one line per file, budgeted
182
+ # (docs/file-map.md § Directories). `lines=<int>` sets the budget.
183
+ from services.dir_map import DEFAULT_MAX_TOKENS, render_directory_map
184
+ budget = DEFAULT_MAX_TOKENS
185
+ if isinstance(lines, int) and not isinstance(lines, bool) and lines > 0:
186
+ budget = lines
187
+ text, dir_detail = render_directory_map(svc, rel_path, max_tokens=budget)
188
+ tok = count_tokens(text)
189
+ if finalize is None:
190
+ return text
191
+ return finalize_with_tokens(
192
+ finalize, svc, "c3_read", {"file": file_path}, text,
193
+ f"dir:{dir_detail['files']}files->{tok}tok",
194
+ optimized_tokens=tok, response_tokens=tok, detail=dir_detail)
195
+
180
196
  # Resolve ranges
181
197
  ranges = []
182
198
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-context-control
3
- Version: 2.121.0
3
+ Version: 2.123.0
4
4
  Summary: Local MCP code-intelligence for AI coding tools: surgical search/read/edit, agent-config version history, path-level access + masking guards, and a multi-project hub.
5
5
  Author-email: Dimitri Tselenchuk <dtselenc@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -1,7 +1,7 @@
1
1
  cli/__init__.py,sha256=ec66drCZGNMRU4V6ov0zVhYZph1us12Vn8OvG_LJyRY,22
2
2
  cli/_hook_utils.py,sha256=e48F6FQloFReUIMtmWsih2t54j9SROzAaHtMkEuttDs,20489
3
3
  cli/_shell_writes.py,sha256=XzUNlUDljmg-wZxK03xHnRe36WeQxe4tpO9HKVFwP5o,5090
4
- cli/c3.py,sha256=33z6xHxi3hdsM-zyi4WAM8QGW9rjFuuas5rpc357tMA,416811
4
+ cli/c3.py,sha256=bec4nDHziMA74g4aEvg-_X0nQKCLmNNkTr2gslczpHg,416843
5
5
  cli/docs.html,sha256=X9M7qC7LWi8pPJNF-4yEM57jhuZ3WeTESECbdDOeNaA,143172
6
6
  cli/edits.html,sha256=UjAhoCmBmQ89cklGvJqzC6eyNP2tc8H6T-e01DVkLvE,43418
7
7
  cli/hook_access_guard.py,sha256=ba8-un6G9Ap8HCzx7nyhVq3c_G4sOtiVXRy0BzzX8-Y,23349
@@ -15,7 +15,7 @@ cli/hook_edit_ledger.py,sha256=3ooM6Lv83Ntq_aeyrZdn-0FlZSsE_CrWeuB_V-SEAJk,12105
15
15
  cli/hook_edit_unlock.py,sha256=PwPRuCQPARZZRtH6wb2Xm-6ADpfrC-0rXs2uRmHAAmg,6883
16
16
  cli/hook_filter.py,sha256=v8ZSznK5StTfYYqae1l4V2bm-e9sy2RFfpbNr5KwE2U,4988
17
17
  cli/hook_ghost_files.py,sha256=plbedFgIJ5tXEE_8USUE39RmyODh1UjIUFNRbUo4R8w,10291
18
- cli/hook_pretool_enforce.py,sha256=heNHckDbieUD0x6sKydusmkxWzliMbyN1SpD4518PQc,31323
18
+ cli/hook_pretool_enforce.py,sha256=k9jEyi7ip7MnsbBK3jE0JEcLEitHMg2iMWnX56XhaBY,31329
19
19
  cli/hook_prompt_recall.py,sha256=LHyubu36pf5h3hbLBe0yH-yGhAlwLJdsc7hfa7WB1h4,3471
20
20
  cli/hook_read.py,sha256=ZWZldgqGSNz78COhlgETQ7yxdOW5vWIIHUet9gZKhxk,8176
21
21
  cli/hook_session_stats.py,sha256=i1k7DM0RoQsHm5R8C8RUWvpEA7lzP-NPD95-fAARlnI,7273
@@ -30,18 +30,18 @@ cli/ui.html,sha256=xcdt74nlFEXx-0Bx6-Okw-WSVZPAXL0iukxU0ytI6CA,5694
30
30
  cli/ui_legacy.html,sha256=cI8tC6RKmE2NIJOcsu7CY-zT4VznjcbD6NTjxb_fvUY,378460
31
31
  cli/ui_nano.html,sha256=UAwQ6bbTOXAoGq191AZ7slhngR9edJSa3IhqpynveDg,27740
32
32
  cli/commands/__init__.py,sha256=0Z8MABNzwSFJGT4Xv9R5AJVR8XxraTsuVTz5b0bShmo,38
33
- cli/commands/common.py,sha256=zkxr0zW9KB600BlarJ2VXf7UOp2ak21R6uXZKdGvpyg,14413
34
- cli/commands/parser.py,sha256=d4nL6_UPeY4_Cb4ecAWjNyZBVvfdO450WFJavA2E9Fc,65353
33
+ cli/commands/common.py,sha256=X5sjog7O274vmmVOMe3pO2QzbBOdhp-nWKSj7wqi8SA,14411
34
+ cli/commands/parser.py,sha256=OQMrKiZzk3LVaCsn3dNEm34fXHnu5zxZvttMEoeJGcI,65466
35
35
  cli/guide/access.html,sha256=BelnEc4p7XgcH_ysTlMXL9ZHCD0O4CPLq3h5rvqPIh8,14790
36
36
  cli/guide/bitbucket.html,sha256=6zmHIv2-G314_HDqMCQV-ZAIy0-co0gaFs0_k2Q1kYc,35491
37
37
  cli/guide/credentials.html,sha256=801vO_cCqG7oHsa73yAGSoBDGZ4mcGsdx6D2uS1dwCQ,58196
38
38
  cli/guide/getting-started.html,sha256=VAKnff0tDFGPobVAihHW1-hjUfAkxQrpVJg6yKcWcbg,21653
39
- cli/guide/index.html,sha256=bn6c4QcekSaDa08gsD8UjkAM-Z6wBY2PI0loIV8zZ1Q,22874
39
+ cli/guide/index.html,sha256=nNM_gAi3e8SGEUV7jIsd8N3kmAP35BepwmjZ2HuJoSA,22869
40
40
  cli/guide/masking.html,sha256=Jz8fEwp5XwudUafn0Ymytofpf4PPWRpyxOHMl09dD5o,19203
41
41
  cli/guide/oracle.html,sha256=66utgqoOpHXxZBEopnw-Q-tvhoQOD82YckXW2xDTTJ4,25152
42
42
  cli/guide/shared.css,sha256=Mmm7W6aYxrkDq2RPOcyXiQFYGR-yB3auwX1uIWi6C74,15967
43
43
  cli/guide/tools.html,sha256=KrTwffccJrkxiEhn1TBdyN2qUzOuL_uIUxR0wH39pk0,92693
44
- cli/guide/workflow.html,sha256=yshn1oL7mScB_FmT99alxxpmuFWdlaQzRuUIk8JT-iE,64581
44
+ cli/guide/workflow.html,sha256=-VyLgULPDsucuN1N8D6Vc6g-VMzUIfLVpVah_pDekfk,64582
45
45
  cli/hub_ui/app.js,sha256=Gbk-2DxqUynrVqJbS8pNUEHmcCpjuUhqeDxWUWa6e_A,6984
46
46
  cli/hub_ui/state.js,sha256=yQXsN0G1YCvtlv6aoIs0iqY7DNfhQ0ybj7Ig9wbBuY4,7279
47
47
  cli/hub_ui/components/add_project.js,sha256=j5m0mX0gePcEYQW_0vodJkw5v8Vn2lQqPAI0miIGdv4,3182
@@ -78,7 +78,7 @@ cli/tools/agent.py,sha256=RmEEh5HhCZLvYu0gSRcwA_NENpd4iIYJW3exBzT8kVs,47983
78
78
  cli/tools/artifacts.py,sha256=SjazShDFIzOpAZW7uMv9itBktXnc6pqQKZeaen8GgzI,9379
79
79
  cli/tools/bitbucket.py,sha256=cni8Y1EoiNm9jcHEpzi2dgKFIT1-4ct0WOhpTs7ac3k,35812
80
80
  cli/tools/ci.py,sha256=fQ03RENywS5gqHjH26Fa4YiraBTvchlPcPSnVYwwKBs,19444
81
- cli/tools/compress.py,sha256=3uvtjH1OWCzM5XKpfth7xRnJjlMxUq-LZkql8KG7SvQ,15362
81
+ cli/tools/compress.py,sha256=f8DNJlr49cVibvuOSyChHjytzbB30m7uXW3zWq8mVAU,10819
82
82
  cli/tools/credentials.py,sha256=R8Errpa41vsq0Z_sb7K-fmJxVovzXDYUyniITLrFC24,19033
83
83
  cli/tools/delegate.py,sha256=yA2-ph53Y8XAJMpz7R5LwqXshRRD8bYljgN244RbHWA,62012
84
84
  cli/tools/edit.py,sha256=xnRRM2UUybvGvfSzXzIjWmt6_zNaCbkupiK1z-8K4jE,30127
@@ -92,7 +92,7 @@ cli/tools/locks.py,sha256=FvuNxToU-5ZE2m_C6fTrn1jKERoxR4cbtxNb_b8mXqA,5882
92
92
  cli/tools/memory.py,sha256=yDDRsEngeFcjb6nXUrhRZXuboasXfixJeyltKhDbZD4,24935
93
93
  cli/tools/override.py,sha256=LiZFgoDN-35NXVwldnYrcCzfBl8MhQQ3tKVVkOG5d_o,9217
94
94
  cli/tools/project.py,sha256=MWzi7tBTQ03p_6PavmG9ZLXZHO0IhjKEJNyB0oZWTFc,24248
95
- cli/tools/read.py,sha256=vQP-tfm3Mpx0y2vObPBOUeHsCdANacb_onzYJc0v-m8,17641
95
+ cli/tools/read.py,sha256=xZrKGABR-smsfGfg5CUfv77opA9EsOWnNhiYVj9hymQ,18421
96
96
  cli/tools/search.py,sha256=BZh3E7YggjCWOC1lJz0YNcRsdDLWsJiEmONtgUW2rEo,32397
97
97
  cli/tools/session.py,sha256=ailQiRqXhi9NcBB3XS3Dv72GEkEZaYivB0bqDlhmgTA,5202
98
98
  cli/tools/shell.py,sha256=Zcsocl8IXeL9DSQNewq_fXECSufTccUdwc-H3S2Alto,58818
@@ -123,7 +123,7 @@ cli/ui/components/sessions.js,sha256=FIKtil76B8tCkAmcFV7hlj6GQ_DCJK2jCzvEmdK7NBE
123
123
  cli/ui/components/settings.js,sha256=ATbAjBlVIwCNpxq7s191b49a_INQV38iwmySqtJLYwY,79066
124
124
  cli/ui/components/sidebar.js,sha256=K2ym2kUgpbyG-EBA_wBIIiqQY8qTatsiBZwzseMWuIQ,9939
125
125
  cli/ui/components/tasks.js,sha256=vyKQ3uwoppMwvdEaHlhWXW4oWcAisx4NveqzMhsYqHo,38438
126
- code_context_control-2.121.0.dist-info/licenses/LICENSE,sha256=l8Kh5QCNWNvR6kIt8L0BUZvc2LAFiHv2c-FnsGnUZf4,11301
126
+ code_context_control-2.123.0.dist-info/licenses/LICENSE,sha256=l8Kh5QCNWNvR6kIt8L0BUZvc2LAFiHv2c-FnsGnUZf4,11301
127
127
  core/__init__.py,sha256=TSDCEcM4V7gcZVM3w2ykJaqEUch4Dkon-rivV17T73s,2501
128
128
  core/codex_patch.py,sha256=CxvT_rQIwPNTRJZj9EHJmx0ZdOeSLxjiixuLI3Z7lPc,2107
129
129
  core/config.py,sha256=YmkcZwedz_lfDM0ZuI_f4xpe98ixPLcSQFcTCHgRiRg,19206
@@ -200,15 +200,16 @@ services/ci_intel.py,sha256=frr1jL-FXU_JNUoRW8TwnZo04fqUElAGGEsDEvgIeUs,7161
200
200
  services/ci_runner.py,sha256=egkFGaOjAa45KIMFbGX7108R2wUQwUuLgJ4Iq7ygx9I,40768
201
201
  services/ci_workflow.py,sha256=xfeUvzcm9GHiDdi7QAbg4SAvnzzuCCV3bmwdAMUTZKA,31133
202
202
  services/circuit_breaker.py,sha256=ES6PpjL40gfDhtK88GeS6GFzsz5EOEEkZsB-DzX_tbw,3179
203
- services/claude_md.py,sha256=jXvr0orZOkPYrVxEkf67beAj8EKPbB_M99mmxxMsPg4,61217
204
- services/codex_integration.py,sha256=dXlaVbQJrIrlZw5hZcDjhZVli3AvumnOTQTkl-_I-rg,7708
203
+ services/claude_md.py,sha256=U9Ogzktmqz1D75L_vEBuadNqddrhUY1v_e3KbPn46EE,61453
204
+ services/codex_integration.py,sha256=px7Mcyl8d3TlJxMyjzhuysuz0caaqWh023UvW2CdVjE,7716
205
205
  services/codex_transcript.py,sha256=TtxKiNkZ3vsWpaQRczaIGsA7wd8r24z_65Nnso2BNj0,3658
206
- services/compressor.py,sha256=oX5Tnn42syY_08_SWukEkRamwTH7qwQ-T6TGIlZNgJY,36148
206
+ services/compressor.py,sha256=TnszNgnikaWR2CgUe4wO8NQ2ayNzhMExY42IVPMV5w4,33222
207
207
  services/context_snapshot.py,sha256=2f_bxY3xX4ZC653ncYno8YSeSTFSavwBjrorytHA0Ns,16879
208
208
  services/conversation_store.py,sha256=RKA0nk09VAI8Ip935kSVlQdF-cQEzFLrgHbZiEJ7G1E,37518
209
209
  services/cred_audit.py,sha256=tdxnRzbB0c8nJ7dqA9HOJrcOeRWivEzq6yhwu6ksmZI,10240
210
210
  services/cred_telemetry.py,sha256=LwDd4x0Mu6YXeDUrgICHnX9o1Ym_4hoouRp98qx0zlI,10926
211
211
  services/credential_store.py,sha256=x8ejkSJAqjlUHwCdJ-AyOrLJwSpMCUmuq61nkBTrm-Y,68797
212
+ services/dir_map.py,sha256=r40hj8PN6lDnKGFjDsslsMQJhbdMkleIksHtYTw4sQA,6666
212
213
  services/doc_index.py,sha256=cIb1jk4GrKK7cLwD6phoc04TvKA7Ee94kKHXxB7tT_k,19223
213
214
  services/e2e_benchmark.py,sha256=5Z3rE42o08MKVbIX_769V0RX2yAzSVcApCvdAQ9YLAU,134988
214
215
  services/e2e_evaluator.py,sha256=FsB4vMzcFUcmicfIwRtA8JZ_XeMpuk2_-86F2iVNEDc,17783
@@ -305,8 +306,8 @@ tui/screens/search_view.py,sha256=MMHjVdlk3HZSuDBSvq8IGrqv_Mh5Us6YqXQ80bcWSMk,19
305
306
  tui/screens/session_view.py,sha256=eZ1eDwHTvPOck1wCCviixtOaCxIkBT_95ytNNNriGNA,5991
306
307
  tui/screens/stats.py,sha256=p81PjzdaIv7hllb8f45-rlVe4lJZwSdIMqu7e86_u5s,6223
307
308
  tui/screens/ui_view.py,sha256=1QJCgLh2YfgWIpvzRG1KOGXYEaOYX6ojN61Azjf2oX0,2125
308
- code_context_control-2.121.0.dist-info/METADATA,sha256=bLL-hxMFWw55dYOgBSwJqDy_Uq73ZXh1ZUmievzSMOU,27067
309
- code_context_control-2.121.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
310
- code_context_control-2.121.0.dist-info/entry_points.txt,sha256=7kX_WUsDCF2hbXzvbNyscyaBb9AeA-DJY5v_5hN0DlU,93
311
- code_context_control-2.121.0.dist-info/top_level.txt,sha256=wRt41zBybVF3qAiNXHz9BURbkKvUvfhmWWtKMhaw6eE,29
312
- code_context_control-2.121.0.dist-info/RECORD,,
309
+ code_context_control-2.123.0.dist-info/METADATA,sha256=GA2nhOCq4RoLbjrfyct4SK7y0hRqiShnQhpUZnD78DQ,27067
310
+ code_context_control-2.123.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
311
+ code_context_control-2.123.0.dist-info/entry_points.txt,sha256=7kX_WUsDCF2hbXzvbNyscyaBb9AeA-DJY5v_5hN0DlU,93
312
+ code_context_control-2.123.0.dist-info/top_level.txt,sha256=wRt41zBybVF3qAiNXHz9BURbkKvUvfhmWWtKMhaw6eE,29
313
+ code_context_control-2.123.0.dist-info/RECORD,,
services/claude_md.py CHANGED
@@ -32,7 +32,7 @@ When falling back, state which c3_* tool was attempted and why it was insufficie
32
32
  ## Workflow (follow this order — do not skip steps)
33
33
  1. **RECALL**: `c3_memory(action='recall')` — before any multi-step or context-dependent task. Large memory stores: use `index` first (compact list), then `fetch` for specific IDs
34
34
  2. **SEARCH FIRST**: `c3_search(action='code|files|semantic')` — before ANY file discovery or content search. Never start with Grep/Glob
35
- 3. **MAP before READ**: `c3_compress(mode='map')` then `c3_read(symbols=...|lines=...)` — for ANY file read. Never start with native Read. Use `mode='ast'` for knowledge-graph overview (requires codebase-memory-mcp)
35
+ 3. **MAP before READ**: `c3_read(file_path)` with no symbols/lines returns the file map (one line per symbol with signature and `[La-Lb]` range, ~10% of the file's tokens; a directory path maps its files), then `c3_read(symbols=['Class.method']|lines=[a,b])` for the source you need. Never start with native Read. `c3_compress` is the same map for a comma-separated batch (legacy; removed in 2.124.0)
36
36
  4. **IMPACT** (shared symbols): `c3_impact(target='symbol')` — blast-radius check before editing any function/class used across files
37
37
  5. **EDIT via C3**: `c3_edit(file_path, old_string, new_string, summary)` — for ALL edits. Parallel across files; `edits=[]` batch for same file
38
38
  6. **FILTER**: `c3_filter(text=...)` — for terminal output >10 lines or log files
@@ -55,7 +55,7 @@ In plan mode, all c3_* read tools (search, read, compress, filter, validate, sta
55
55
  ## Anti-patterns (DO NOT do these)
56
56
  - Starting with native file search/read/grep without a prior c3_* call
57
57
  - Using native Edit when c3_edit is available
58
- - Reading entire files when c3_compress + c3_read would be more surgical
58
+ - Reading entire files when a c3_read map + a symbol read would be more surgical
59
59
  - Skipping c3_validate after making edits"""
60
60
 
61
61
  # Pointer that replaces the embedded Project Context tree (v2.60.0).
@@ -73,14 +73,14 @@ C3_NANO_WORKFLOW = """\
73
73
  ## C3 Tools — MANDATORY
74
74
  Native tools BLOCKED unless c3_* called first. State reason when falling back.
75
75
  1. c3_search(action='code|files|semantic') — BEFORE any search/grep/glob
76
- 2. c3_compress(mode='map') then c3_read(symbols=...|lines=...) — BEFORE any file read
76
+ 2. c3_read(file_path) maps a file (or directory); then c3_read(symbols=['Class.method']|lines=[a,b]) — BEFORE any file read
77
77
  3. c3_edit(file_path, old_string, new_string, summary) — for ALL edits; edits=[{...}] batch
78
78
  4. c3_filter(text='...') — output >10 lines
79
79
  5. c3_validate(file_path) — after edits
80
80
  6. c3_session(action='log'|'snapshot') — decisions / before /clear
81
81
  7. `[c3-access:confirm]` = PAUSE, not block: a human approves this exact write (agent-config files — .mcp.json, instruction docs, .claude bodies — hold by default). Read the S8 tail: wait on the request it names (c3_override(action='wait', request_id='...', timeout_s=180)), then retry once. Never retry early, never route around it.
82
82
  Plan mode: all c3_* read tools work normally — skip edit/delegate steps.
83
- DO NOT: start with native Read/Grep/Glob/Edit, skip c3_validate, read full files without c3_compress."""
83
+ DO NOT: start with native Read/Grep/Glob/Edit, skip c3_validate, read full files without mapping them first."""
84
84
 
85
85
 
86
86
  # --- Per-IDE workflow adaptation ----------------------------------------------
@@ -8,7 +8,7 @@ CODEX_WORKFLOW = """# C3 — Codex workflow
8
8
 
9
9
  Use C3 for repository intelligence and audited changes:
10
10
  1. Recall relevant project facts with c3_memory; inspect .c3/MAP.md.
11
- 2. Find candidates with c3_search; map with c3_compress(mode='map'), then
11
+ 2. Find candidates with c3_search; map with c3_read(file_path) (no symbols), then
12
12
  read exact symbols or lines with c3_read. Check shared changes with c3_impact.
13
13
  3. Edit with c3_edit, validate with c3_validate, and record decisions with
14
14
  c3_session(action='log'). Preserve unrelated work.
services/compressor.py CHANGED
@@ -2,10 +2,12 @@
2
2
  AST-based Code Compression Service
3
3
 
4
4
  Parses source files into AST and generates compressed summaries:
5
- - Function/class signatures only (skip bodies)
6
- - Structural outline mode
7
- - Smart truncation with context preservation
8
- - Diff-only mode for edits
5
+ - "map": the canonical file map (services/file_map) — what the tools serve
6
+ - "structure" / "outline" / "smart": signature-only summaries kept for
7
+ internal callers (delegate, Hub REST, context snapshots, benchmarks)
8
+ - "summary": an LLM summary through the router, when one is configured
9
+
10
+ "diff" and "bug_scan" were retired in 2.122.0 (docs/file-map.md).
9
11
  """
10
12
  import hashlib
11
13
  import json
@@ -145,6 +147,30 @@ class CodeCompressor:
145
147
  if protected_files:
146
148
  self._protected_files.update(self._normalize_rel_path(p) for p in protected_files)
147
149
  self.router = router
150
+ self._sweep_retired_caches()
151
+
152
+ #: Modes the compressor no longer implements, and where to go instead.
153
+ RETIRED_MODES = {
154
+ "diff": "use `git diff` or the edit ledger",
155
+ "bug_scan": "use c3_search(action='exact', query='except ')",
156
+ "dense_map": "use mode='map'",
157
+ }
158
+
159
+ def _sweep_retired_caches(self) -> int:
160
+ """Delete the pre-2.122.0 diff caches: one full copy of every file
161
+ ever diffed, keyed by BASENAME (so every __init__.py collided).
162
+ Cheap — only diff wrote `*.cache`; after one sweep there are none."""
163
+ removed = 0
164
+ try:
165
+ for f in self.cache_dir.glob("*.cache"):
166
+ try:
167
+ f.unlink()
168
+ removed += 1
169
+ except OSError:
170
+ continue
171
+ except Exception:
172
+ pass
173
+ return removed
148
174
 
149
175
  @staticmethod
150
176
  def _normalize_rel_path(path: str) -> str:
@@ -192,13 +218,17 @@ class CodeCompressor:
192
218
  Compress a source file.
193
219
 
194
220
  Modes:
221
+ - "map": the canonical file map (services/file_map.render_map)
195
222
  - "structure": Function/class signatures + imports (most compressed)
196
223
  - "outline": Structure + docstrings + key comments
197
224
  - "smart": Adaptive - more detail for small files, less for large
198
- - "diff": Only changes since last seen (requires prior state)
199
225
  - "summary": High-level LLM summary (requires router)
200
- - "bug_scan": Structure map + annotated exception-handling hotspots with line numbers
226
+ Retired (2.122.0): "diff", "bug_scan", "dense_map" -> error dict.
201
227
  """
228
+ if mode in self.RETIRED_MODES:
229
+ return {"error": f"mode '{mode}' retired in 2.122.0 — "
230
+ f"{self.RETIRED_MODES[mode]} (docs/file-map.md)",
231
+ "compressed": "", "retired": True}
202
232
  # Access Guard: read verdict before any cache or disk access — denied
203
233
  # paths raise AccessDenied (docs/access-guard.md §3). Callers that
204
234
  # need string returns catch it and surface exc.message. Checked
@@ -241,8 +271,9 @@ class CodeCompressor:
241
271
  content_hash = hashlib.md5(content.encode()).hexdigest()
242
272
  ext = filepath.suffix.lower()
243
273
 
244
- # Check persistent cache (except for diff/summary which have their own logic)
245
- if mode not in ("diff", "summary"):
274
+ # Check persistent cache (summary has its own logic; map is served
275
+ # from file_memory's own cache below)
276
+ if mode not in ("summary", "map"):
246
277
  cache_key = f"{content_hash}_{mode}{ext}.json"
247
278
  if mask_view is not None:
248
279
  cache_key = f"mask-{mask_view.view_hash}_{cache_key}"
@@ -271,7 +302,10 @@ class CodeCompressor:
271
302
  # entries (keyed on content hash) still win. diff/summary are exempt:
272
303
  # diff is the recommended targeted alternative and summary delegates
273
304
  # to the router.
274
- if mode not in ("diff", "summary"):
305
+ if mode == "map":
306
+ return self._canonical_map(filepath, content)
307
+
308
+ if mode != "summary":
275
309
  line_count = content.count("\n")
276
310
  if content and not content.endswith("\n"):
277
311
  line_count += 1
@@ -285,9 +319,6 @@ class CodeCompressor:
285
319
  filepath, content, content_hash, ext, mode,
286
320
  byte_size, line_count)
287
321
 
288
- if mode == "diff":
289
- return self._diff_compress(filepath, content)
290
-
291
322
  if mode == "summary":
292
323
  if not self.router:
293
324
  return {"error": "Summary mode requires a router", "compressed": ""}
@@ -296,31 +327,6 @@ class CodeCompressor:
296
327
  result = f"# {filepath.name} — SUMMARY\n{summary}"
297
328
  return {"compressed": result, "mode": "summary", **measure_savings(content, result)}
298
329
 
299
- if mode == "bug_scan":
300
- # Structure map + exception-handling annotation pass
301
- structure = self._extract_structure(content, ext, "outline")
302
- exception_section = self._scan_exception_handlers(content)
303
- compressed_parts = [structure]
304
- if exception_section:
305
- compressed_parts.append(exception_section)
306
- compressed = "\n".join(compressed_parts)
307
- header = f"# {filepath.name} ({filepath.suffix}) — {len(content.splitlines())} lines [bug_scan]\n"
308
- result = header + compressed
309
- savings = measure_savings(content, result)
310
- savings["compressed"] = result
311
- savings["mode"] = "bug_scan"
312
- savings["filepath"] = str(filepath)
313
- self._file_hashes[str(filepath)] = content_hash
314
- cache_key = f"{content_hash}_bug_scan{ext}.json"
315
- self._mem_cache[cache_key] = dict(savings)
316
- cache_file = self.cache_dir / cache_key
317
- try:
318
- with open(cache_file, "w", encoding="utf-8") as f:
319
- json.dump(savings, f, indent=2)
320
- except Exception:
321
- pass
322
- return savings
323
-
324
330
  if mode == "smart":
325
331
  tokens = count_tokens(content)
326
332
  if tokens < 80:
@@ -354,8 +360,8 @@ class CodeCompressor:
354
360
  # Cache hash for diff mode
355
361
  self._file_hashes[str(filepath)] = content_hash
356
362
 
357
- # Persist to cache (except diff which uses its own file format)
358
- if mode not in ("diff", "summary"):
363
+ # Persist to cache
364
+ if mode != "summary":
359
365
  cache_key = f"{content_hash}_{mode}{ext}.json"
360
366
  # Store in memory first (fast path for repeat access)
361
367
  if len(self._mem_cache) >= self._MEM_CACHE_MAX:
@@ -648,51 +654,6 @@ class CodeCompressor:
648
654
 
649
655
  return None
650
656
 
651
- def _scan_exception_handlers(self, content: str) -> str:
652
- """Scan for exception-handling hotspots and return an annotated section.
653
-
654
- Returns a formatted block listing every bare/broad except clause with:
655
- - line number
656
- - the except line itself
657
- - the immediately enclosing function name (if detectable)
658
-
659
- Returns an empty string if no exception handlers are found.
660
- """
661
- lines = content.splitlines()
662
- # Patterns ranked from most to least problematic
663
- _EXCEPT_PATTERNS = [
664
- (re.compile(r"^\s*except\s*:"), "bare-except"),
665
- (re.compile(r"^\s*except\s+Exception\s*:"), "broad-except"),
666
- (re.compile(r"^\s*except\s+Exception\s+as\s+\w+\s*:"), "broad-except"),
667
- (re.compile(r"^\s*except\s+\("), "multi-except"),
668
- ]
669
- _FUNC_DEF = re.compile(r"^\s*(?:async\s+)?def\s+(\w+)")
670
-
671
- hits: list[str] = []
672
- # Track the most recently seen function name for context
673
- current_func = "<module>"
674
- for idx, line in enumerate(lines, start=1):
675
- m = _FUNC_DEF.match(line)
676
- if m:
677
- current_func = m.group(1)
678
- for pattern, label in _EXCEPT_PATTERNS:
679
- if pattern.match(line):
680
- # Show up to 2 continuation lines (body of the except block)
681
- body_lines = []
682
- for j in range(idx, min(idx + 2, len(lines))):
683
- body = lines[j].strip()
684
- if body and not body.startswith("except") and not body.startswith("try"):
685
- body_lines.append(body)
686
- body_preview = " | ".join(body_lines[:2]) if body_lines else ""
687
- suffix = f" → {body_preview}" if body_preview else ""
688
- hits.append(f" L{idx} [{label}] in `{current_func}`: {line.strip()}{suffix}")
689
- break # one label per line
690
-
691
- if not hits:
692
- return ""
693
- header = f"\n# Exception-handling hotspots ({len(hits)} found):"
694
- return header + "\n" + "\n".join(hits)
695
-
696
657
  def _generic_compress(self, content: str, ext: str) -> str:
697
658
  """Fallback compression for unknown languages."""
698
659
  lines = content.split('\n')
@@ -712,49 +673,33 @@ class CodeCompressor:
712
673
  kept.append(stripped)
713
674
  return '\n'.join(kept)
714
675
 
715
- def _diff_compress(self, filepath: Path, current_content: str) -> dict:
716
- """Generate diff-based compression against cached version."""
717
- cache_file = self.cache_dir / f"{filepath.name}.cache"
718
- current_hash = hashlib.md5(current_content.encode()).hexdigest()
676
+ def _canonical_map(self, filepath: Path, content: str) -> dict:
677
+ """mode="map": the same text c3_read / c3_compress serve.
719
678
 
720
- if cache_file.exists():
721
- cached = cache_file.read_text(encoding="utf-8", errors="replace")
722
- cached_hash = hashlib.md5(cached.encode()).hexdigest()
723
-
724
- if cached_hash == current_hash:
725
- result = f"# {filepath.name} — NO CHANGES"
726
- return {"compressed": result, "mode": "diff-unchanged", **measure_savings(current_content, result)}
727
-
728
- # Generate contextual diff
729
- diff = self._contextual_diff(cached.split('\n'), current_content.split('\n'), filepath.name)
730
- savings = measure_savings(current_content, diff)
731
- savings["compressed"] = diff
732
- savings["mode"] = "diff"
733
- else:
734
- # No cache — fall back to structure mode
735
- compressed = self._extract_structure(current_content, filepath.suffix.lower(), "structure")
736
- header = f"# {filepath.name} (FIRST SEEN) — {len(current_content.splitlines())} lines\n"
737
- result = header + compressed
738
- savings = measure_savings(current_content, result)
739
- savings["compressed"] = result
740
- savings["mode"] = "diff-first"
741
-
742
- # Update cache
743
- cache_file.write_text(current_content, encoding="utf-8")
679
+ Built from a throwaway FileMemoryStore-style extraction so callers
680
+ that only hold a compressor (Hub REST, `c3 compress`, delegate)
681
+ get the canonical map rather than a structure-only summary.
682
+ """
683
+ from services.file_map import render_map
684
+ from services.file_memory import LANG_MAP, FileMemoryStore
685
+ ext = filepath.suffix.lower()
686
+ sections, parser = FileMemoryStore._extract_sections_with_parser(
687
+ FileMemoryStore.__new__(FileMemoryStore), filepath, content)
688
+ record = {
689
+ "path": self._relative_to_project(filepath),
690
+ "lines": len(content.splitlines()),
691
+ "language": LANG_MAP.get(ext, ext.lstrip(".")),
692
+ "parser": parser,
693
+ "sections": sections,
694
+ }
695
+ result = render_map(record)
696
+ savings = measure_savings(content, result)
697
+ savings["compressed"] = result
698
+ savings["mode"] = "map"
699
+ savings["parser"] = parser
700
+ savings["filepath"] = str(filepath)
744
701
  return savings
745
702
 
746
- def _contextual_diff(self, old_lines: list, new_lines: list, filename: str) -> str:
747
- """Generate a contextual diff with surrounding structure."""
748
- import difflib
749
- differ = difflib.unified_diff(old_lines, new_lines, lineterm='', n=1)
750
- diff_text = '\n'.join(differ)
751
-
752
- if not diff_text.strip():
753
- return f"# {filename} — NO CHANGES"
754
-
755
- header = f"# {filename} — CHANGES ONLY\n"
756
- return header + diff_text
757
-
758
703
  def compress_directory(self, dirpath: str, mode: str = "smart",
759
704
  extensions: Optional[list] = None,
760
705
  max_files: int = 50) -> dict:
services/dir_map.py ADDED
@@ -0,0 +1,176 @@
1
+ """Directory maps — one line per file, budgeted, for `c3_read('<dir>')`.
2
+
3
+ # services/ (58 files, 41,203L)
4
+ compressor.py (809L python) — CodeCompressor; compress_file; STRUCTURE_PATTERNS
5
+ file_map.py (280L python) — render_map; parse_signature; parse_map; KINDS
6
+ …
7
+ … 12 more files
8
+
9
+ Files are ranked the way a reader would want them: recently edited first
10
+ (the edit ledger), then by how much structure they hold (symbol count),
11
+ then by path. The map is filled to a token budget and closes with how
12
+ many files it left out. Traversal is bounded — the scanner's SKIP_DIRS
13
+ and .gitignore pruning, never following symlinks, a hard file cap — and
14
+ structure comes from file_memory records, extracting on the fly only
15
+ for a bounded number of code files (docs/file-map.md § Directories).
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import time
20
+ from pathlib import Path
21
+ from typing import Optional
22
+
23
+ from core import count_tokens
24
+ from services.file_memory import CODE_EXTENSIONS, LANG_MAP
25
+ from services.scanner import iter_files
26
+
27
+ DEFAULT_MAX_TOKENS = 1500
28
+ MAX_FILES = 400 # hard traversal cap
29
+ MAX_EXTRACT_FILES = 120 # code files parsed on the fly, per call
30
+ EXTRACT_DEADLINE_S = 3.0 # then the rest show line counts only
31
+ MAX_EXTRACT_BYTES = 200 * 1024
32
+ SYMBOLS_PER_FILE = 6
33
+ LEDGER_LOOKBACK = 300 # recent ledger entries consulted for ranking
34
+
35
+
36
+ def _recent_edit_ranks(svc) -> dict:
37
+ """path -> recency rank (0 = most recent) from the edit ledger."""
38
+ ledger = getattr(svc, "edit_ledger", None)
39
+ if ledger is None:
40
+ return {}
41
+ try:
42
+ entries = ledger.get_history(limit=LEDGER_LOOKBACK)
43
+ except Exception:
44
+ return {}
45
+ ranks: dict = {}
46
+ for entry in entries or []:
47
+ f = str((entry or {}).get("file") or "").replace("\\", "/")
48
+ if f and f not in ranks:
49
+ ranks[f] = len(ranks)
50
+ return ranks
51
+
52
+
53
+ #: What a reader wants to see first on a one-line summary of a file.
54
+ _SYMBOL_PRIORITY = {"class": 0, "interface": 0, "struct": 0, "trait": 0, "enum": 0,
55
+ "function": 1, "method": 1, "type": 2, "heading": 2, "section": 2,
56
+ "constant": 3, "variable": 3}
57
+ _SKIP_SYMBOL_TYPES = {"import", "decorator", "comment", "content", "property", "impl"}
58
+
59
+
60
+ def _structural_sections(record: Optional[dict]) -> list:
61
+ return [s for s in (record or {}).get("sections") or []
62
+ if s.get("type") not in _SKIP_SYMBOL_TYPES
63
+ and str(s.get("name") or "").strip() not in ("", "(full file)")]
64
+
65
+
66
+ def _top_symbols(record: Optional[dict]) -> list:
67
+ """Up to SYMBOLS_PER_FILE names: classes first, then functions, then the
68
+ rest, constants last — each group in source order."""
69
+ picked = sorted(enumerate(_structural_sections(record)),
70
+ key=lambda iv: (_SYMBOL_PRIORITY.get(iv[1].get("type"), 2),
71
+ str(iv[1].get("name") or "").startswith("_"),
72
+ iv[0]))
73
+ names = []
74
+ for _, s in picked[:SYMBOLS_PER_FILE]:
75
+ name = str(s.get("name") or "").strip()
76
+ if s.get("type") == "heading":
77
+ name = name.split(":", 1)[-1].strip()
78
+ names.append(name)
79
+ return names
80
+
81
+
82
+ def _line_count(path: Path) -> int:
83
+ try:
84
+ with open(path, "rb") as fh:
85
+ return sum(1 for _ in fh)
86
+ except OSError:
87
+ return 0
88
+
89
+
90
+ def render_directory_map(svc, rel_dir: str, *, max_tokens: int = DEFAULT_MAX_TOKENS) -> tuple:
91
+ """Return (text, detail) for the directory `rel_dir` (project-relative, posix)."""
92
+ root = Path(svc.project_path).resolve()
93
+ rel_dir = str(rel_dir).replace("\\", "/").strip("/")
94
+ target = (root / rel_dir).resolve() if rel_dir else root
95
+ label = (rel_dir + "/") if rel_dir else "./"
96
+
97
+ files = []
98
+ capped = False
99
+ for fp in iter_files(target, max_files=MAX_FILES + 1):
100
+ if len(files) >= MAX_FILES:
101
+ capped = True
102
+ break
103
+ try:
104
+ files.append(fp.resolve().relative_to(root).as_posix())
105
+ except ValueError:
106
+ continue
107
+
108
+ store = getattr(svc, "file_memory", None)
109
+ recent = _recent_edit_ranks(svc)
110
+ started = time.monotonic()
111
+ extracted = 0
112
+ rows = []
113
+ total_lines = 0
114
+ for rel in files:
115
+ record = None
116
+ if store is not None:
117
+ try:
118
+ record = store.get(rel)
119
+ except Exception:
120
+ record = None
121
+ ext = Path(rel).suffix.lower()
122
+ if (record is None and ext in CODE_EXTENSIONS
123
+ and extracted < MAX_EXTRACT_FILES
124
+ and time.monotonic() - started < EXTRACT_DEADLINE_S):
125
+ try:
126
+ if (root / rel).stat().st_size <= MAX_EXTRACT_BYTES:
127
+ record = store.update(rel)
128
+ extracted += 1
129
+ except Exception:
130
+ record = None
131
+ lines = int((record or {}).get("lines") or 0) or _line_count(root / rel)
132
+ total_lines += lines
133
+ ext = Path(rel).suffix.lower()
134
+ lang = str((record or {}).get("language") or LANG_MAP.get(ext) or ext.lstrip(".") or "")
135
+ symbols = _top_symbols(record)
136
+ rows.append({
137
+ "rel": rel,
138
+ "name": rel[len(rel_dir) + 1:] if rel_dir and rel.startswith(rel_dir + "/") else rel,
139
+ "lines": lines,
140
+ "lang": lang,
141
+ "symbols": symbols,
142
+ "recent": recent.get(rel, 10 ** 6),
143
+ "nsym": len(_structural_sections(record)),
144
+ })
145
+
146
+ rows.sort(key=lambda r: (r["recent"], -r["nsym"], r["rel"]))
147
+
148
+ header = f"# {label} ({len(files)}{'+' if capped else ''} files, {total_lines:,}L)"
149
+ body = []
150
+ for r in rows:
151
+ line = f"{r['name']} ({r['lines']}L {r['lang']})".rstrip()
152
+ if r["symbols"]:
153
+ line += " — " + "; ".join(r["symbols"])
154
+ body.append(line)
155
+
156
+ kept = list(body)
157
+ while kept and count_tokens("\n".join([header] + kept)) > max_tokens:
158
+ kept.pop()
159
+ dropped = len(body) - len(kept)
160
+ parts = [header] + kept
161
+ if dropped:
162
+ parts.append(f"… {dropped} more files")
163
+ if capped:
164
+ parts.append(f"[dir_map] traversal capped at {MAX_FILES} files; map a subdirectory")
165
+ # In source order the reader gets recency first; say so once.
166
+ parts.append("[dir_map] recently edited first, then by structure; "
167
+ "c3_read('<file>') maps one file")
168
+ text = "\n".join(parts)
169
+ detail = {
170
+ "backend": "directory",
171
+ "files": len(files),
172
+ "listed": len(kept),
173
+ "extracted": extracted,
174
+ "capped": capped,
175
+ }
176
+ return text, detail