code-context-control 2.122.0__py3-none-any.whl → 2.124.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.122.0"
95
+ __version__ = "2.124.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/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/mcp_server.py CHANGED
@@ -47,7 +47,6 @@ C3_VERSION = _read_version()
47
47
  # Tool handlers
48
48
  from cli.tools._helpers import maybe_related_facts, validate_file_path
49
49
  from cli.tools.agent import handle_agent
50
- from cli.tools.compress import handle_compress
51
50
  from cli.tools.delegate import handle_delegate
52
51
  from cli.tools.edit import handle_edit
53
52
  from cli.tools.filter import handle_filter
@@ -85,7 +84,7 @@ def _build_instructions(ide_name: str) -> str:
85
84
  return (
86
85
  "C3 — local code intelligence. Route by goal:\n"
87
86
  " FIND candidates → c3_search\n"
88
- " MAP a file → c3_read(file_path) with no symbols, then READ → c3_read(symbols=…); batch maps → c3_compress\n"
87
+ " MAP a file/dir → c3_read(file_path) with no symbols (comma paths batch), then READ → c3_read(symbols=…)\n"
89
88
  " EDIT code → c3_edit (always; it logs to the ledger automatically)\n"
90
89
  " VALIDATE after every edit → c3_validate\n"
91
90
  " BLAST RADIUS before shared-symbol edits → c3_impact\n"
@@ -598,22 +597,10 @@ async def c3_read(file_path: str, symbols: Any = None, lines: Any = None,
598
597
  return await asyncio.to_thread(handle_read, file_path, symbols, lines, include_docstrings, svc, finalize)
599
598
 
600
599
 
601
- @mcp.tool()
602
- async def c3_compress(file_path: str, mode: str = "map", ctx: Context = None) -> str:
603
- """MAP a file — one line per symbol with full signature and [La-Lb] range, ~10% of the
604
- file's tokens (read-only, plan-mode safe). Same map as c3_read(file_path) with no symbols;
605
- use this form for a comma-separated batch (up to 10). mode: map (default); other modes
606
- are legacy."""
607
- path_err = validate_file_path(file_path)
608
- if path_err:
609
- return f"[c3_compress:error] {path_err}"
610
- svc = _svc(ctx)
611
-
612
- def finalize(name, args, resp, summ, **kw):
613
- return _finalize_response(ctx, name, args, resp, summ, **kw)
614
-
615
- return await asyncio.to_thread(handle_compress, file_path, mode, svc, finalize, maybe_related_facts)
616
-
600
+ # c3_compress left the MCP surface in 2.124.0: c3_read(file_path) serves the
601
+ # same map, c3_read('a.py,b.py') the same batch, c3_read('<dir>') a directory.
602
+ # handle_compress stays importable for c3_project, the Oracle bridge and the
603
+ # CLI (docs/file-map.md § Retired modes).
617
604
 
618
605
  @mcp.tool()
619
606
  async def c3_validate(file_path: str, ctx: Context = None) -> str:
cli/tools/compress.py CHANGED
@@ -102,7 +102,7 @@ def _build_map(svc, rel: str) -> str:
102
102
  svc.file_memory.complete_updates(completed)
103
103
  if failed:
104
104
  svc.file_memory.complete_updates(failed, failed=True)
105
- return svc.file_memory.get_or_build_map(rel)
105
+ return svc.file_memory.get_or_build_map(rel, max_tokens=svc.file_memory.MAP_TOKEN_BUDGET)
106
106
 
107
107
 
108
108
  def _compress_single(file_path: str, requested: str, note: str, svc, finalize) -> str:
@@ -197,7 +197,8 @@ def _compress_batch(paths: list, requested: str, note: str, svc, finalize) -> st
197
197
  return fp, None, None, None, "not found"
198
198
  rel = str(full.resolve().relative_to(
199
199
  Path(svc.project_path).resolve())).replace("\\", "/")
200
- res = svc.file_memory.get_or_build_map(rel)
200
+ res = svc.file_memory.get_or_build_map(
201
+ rel, max_tokens=svc.file_memory.MAP_TOKEN_BUDGET)
201
202
  try:
202
203
  raw_tok = count_tokens(full.read_text(encoding="utf-8", errors="replace"))
203
204
  map_tok = count_tokens(res)
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
 
@@ -315,7 +331,8 @@ def handle_read(file_path: str, symbols: Any = None, lines: Any = None,
315
331
 
316
332
  if not ranges:
317
333
  map_cached = not svc.file_memory.needs_update(rel_path)
318
- file_map = svc.file_memory.get_or_build_map(rel_path)
334
+ file_map = svc.file_memory.get_or_build_map(
335
+ rel_path, max_tokens=svc.file_memory.MAP_TOKEN_BUDGET)
319
336
  if count_tokens(file_map) >= full_file_tokens():
320
337
  # A map that costs more than the file is worth nothing: serve the
321
338
  # file (docs/file-map.md § Small files).
@@ -325,6 +342,8 @@ def handle_read(file_path: str, symbols: Any = None, lines: Any = None,
325
342
  + "\n[map only — pass lines=[start,end] or symbols=[...] for exact source]"
326
343
  + maybe_related_facts(svc, rel_path, top_k=3, context="read"))
327
344
  map_tok = count_tokens(resp)
345
+ if finalize is None:
346
+ return resp
328
347
  return finalize_with_tokens(
329
348
  finalize, svc, "c3_read", {"file": file_path},
330
349
  resp, f"{full_file_tokens()}->{map_tok}tok",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-context-control
3
- Version: 2.122.0
3
+ Version: 2.124.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=DWhVK-bMa3_Tzz1cXdmjS0197VmE1b4Xb8HCtLa8-tw,416811
4
+ cli/c3.py,sha256=TRLSfHOBi-9AxQUAaXYq46tFgaBd4WMKw0WIBnFijHI,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
@@ -23,7 +23,7 @@ cli/hook_terse_advisor.py,sha256=W_zaTfX2akgX-ifuUTsLu2H1atM8XmWt6jpPoeU7Pm0,625
23
23
  cli/hub_server.py,sha256=4BxvRHCGNc87jOtWankOVjQLYNk-GhWI2OY1harC8ro,184548
24
24
  cli/hub_ui.html,sha256=nnGqWhWGCLfNisGUnoAlQFcBr6wPNsq-AEOR7uFtVkE,2942
25
25
  cli/mcp_proxy.py,sha256=r-Qhlie7gCtotex8yQUudCyBLRUK-IW6cNIW5RhBW0o,17648
26
- cli/mcp_server.py,sha256=MC5adrvIoENLBhV3y4OCrgHHySb-EAfqVngx9MZvZOQ,64241
26
+ cli/mcp_server.py,sha256=3oxd0sQisBVnWRVix9TDBPgv_w-9dc-MIClJEd6igws,63714
27
27
  cli/progress.py,sha256=qTkNy0howOD3hXraMo4ok3dYTpBW5yG_tenKfF4UDM0,1470
28
28
  cli/server.py,sha256=bsHvKGPXsBJi_MDLiw0IAEq8RWKIG56q72c7OKKTLug,173324
29
29
  cli/ui.html,sha256=xcdt74nlFEXx-0Bx6-Okw-WSVZPAXL0iukxU0ytI6CA,5694
@@ -36,12 +36,12 @@ 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=f8DNJlr49cVibvuOSyChHjytzbB30m7uXW3zWq8mVAU,10819
81
+ cli/tools/compress.py,sha256=YjYl0ariAAtIAyG2O-iSgweQXY2-THnl7JcNL83HXbI,10926
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=yLpJuxQnGRnEvX5lyVeQn2L56tz90dmilIaj1f4KmSk,18532
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.122.0.dist-info/licenses/LICENSE,sha256=l8Kh5QCNWNvR6kIt8L0BUZvc2LAFiHv2c-FnsGnUZf4,11301
126
+ code_context_control-2.124.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,8 +200,8 @@ 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
206
  services/compressor.py,sha256=TnszNgnikaWR2CgUe4wO8NQ2ayNzhMExY42IVPMV5w4,33222
207
207
  services/context_snapshot.py,sha256=2f_bxY3xX4ZC653ncYno8YSeSTFSavwBjrorytHA0Ns,16879
@@ -209,6 +209,7 @@ services/conversation_store.py,sha256=RKA0nk09VAI8Ip935kSVlQdF-cQEzFLrgHbZiEJ7G1
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
@@ -217,8 +218,8 @@ services/edit_ledger.py,sha256=Tj8Q14o19VHQHpofRHdbZKKcVs17v-ctZ3LOeVTqvdQ,29362
217
218
  services/embedding_index.py,sha256=c85kAkekL7r-liiFEwqi7nJAQr4ZbJA36h0oMdiTurc,25156
218
219
  services/enforcement_policy.py,sha256=bB-lI7EZwWT5MhoNtkBjjixN4h7zAYUtVBWPMJ6aONo,17196
219
220
  services/error_reporting.py,sha256=HZ3ru8i5RLf8nq2R4iRnTs5sm1blUxknSbv5hdxuxs0,4139
220
- services/file_map.py,sha256=t5Ua4abJDuo-EZSX793MWiXg4uMqNAk1KxZrOffIm48,14500
221
- services/file_memory.py,sha256=AckiClEKJeVJqCPoCTrqkCMDfHruVWhz2UZn3Df_7bU,33788
221
+ services/file_map.py,sha256=j37DTR-UE_PMNj7vG2HiHucpwpJNaKgiD94UNUU-UXE,17091
222
+ services/file_memory.py,sha256=4n2y6Sci4ThP1ob8dh68bEq80ui5I50iWpQjoIbGI84,38565
222
223
  services/git_context.py,sha256=FFdA8Y46op4DYsUnZsXLTqxIjgxb0q_d-hkFTbosC8Q,9518
223
224
  services/hub_service.py,sha256=z2MZ2Rg9NLNXxQ17e05XGF_f6qSTDWLYtoC3WpuQfGU,2634
224
225
  services/index_store.py,sha256=SWHHKQB_wYww1ubtKgkLo5FFEqaWhPbw0OAN8x3gk9Q,16453
@@ -250,7 +251,7 @@ services/override_grants.py,sha256=BQVwD6WCxVORsUaqDWNToIalz2mStnJRMIR-SOxQzuQ,3
250
251
  services/override_policy.py,sha256=VC5H-FETUz4u6f4JM_q5jqT2HIb1MiFzhpdJyuBG72I,22369
251
252
  services/override_requests.py,sha256=H9lSUbfOjkjE1muztaQ58InqqsqhtnKIT-pF2VzvWkg,31148
252
253
  services/override_wake.py,sha256=HjSOXQTDi0irgeMyrTqngL7xLcSOOIgYsVW9yxod4ps,10363
253
- services/parser.py,sha256=4pklWjWICIVohRLghylS727idWCWyrUBWks1Xh4526o,62782
254
+ services/parser.py,sha256=S_YoKLS3r1xCUVHApXb3BTEfoRAzFhuo1_8XsG0qxVE,63505
254
255
  services/project_manager.py,sha256=uQh-GUvH5cgV5Ce9XYPINlQeqH6NvNdW8zrNFYRbBkw,37855
255
256
  services/project_runtime.py,sha256=yyftX48BFjq4B2KcR4KGszDAAa4xR6gn6j0SgTyro1s,11603
256
257
  services/protocol.py,sha256=CWZ0BWWd-jPui-11hZlvGgkNk18gMFhhLWbp-BP35Kk,12861
@@ -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.122.0.dist-info/METADATA,sha256=-kj7pKeDDSrFJ_FyQa-f4EelTWd5cOrlYl4lJWIu4GQ,27067
309
- code_context_control-2.122.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
310
- code_context_control-2.122.0.dist-info/entry_points.txt,sha256=7kX_WUsDCF2hbXzvbNyscyaBb9AeA-DJY5v_5hN0DlU,93
311
- code_context_control-2.122.0.dist-info/top_level.txt,sha256=wRt41zBybVF3qAiNXHz9BURbkKvUvfhmWWtKMhaw6eE,29
312
- code_context_control-2.122.0.dist-info/RECORD,,
309
+ code_context_control-2.124.0.dist-info/METADATA,sha256=aoNZXvOHSJK4ylPra3DMz2K2FMUEkKIKiAYLOUYOWcg,27067
310
+ code_context_control-2.124.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
311
+ code_context_control-2.124.0.dist-info/entry_points.txt,sha256=7kX_WUsDCF2hbXzvbNyscyaBb9AeA-DJY5v_5hN0DlU,93
312
+ code_context_control-2.124.0.dist-info/top_level.txt,sha256=wRt41zBybVF3qAiNXHz9BURbkKvUvfhmWWtKMhaw6eE,29
313
+ code_context_control-2.124.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/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
services/file_map.py CHANGED
@@ -56,7 +56,7 @@ _PY_DEF = re.compile(r"^(?:async\s+)?def\s+[\w.]+\s*\((?P<params>.*)\)\s*(?:->\s
56
56
  _PY_CLASS = re.compile(r"^class\s+\w+\s*(?:\((?P<bases>.*)\))?\s*:?$")
57
57
  _GO_FUNC = re.compile(r"^func\s+(?:\((?P<recv>[^)]*)\)\s*)?\w+\s*\((?P<params>.*?)\)\s*(?P<ret>[^{]*?)\s*\{?$")
58
58
  _RS_FN = re.compile(r"^(?:pub(?:\([^)]*\))?\s+)?(?:async\s+|const\s+|unsafe\s+)*fn\s+\w+(?:<[^>]*>)?\s*\((?P<params>.*)\)\s*(?:->\s*(?P<ret>[^{]+?))?\s*(?:where\b.*)?\{?$")
59
- _JS_FUNC = re.compile(r"^(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s*\*?\s*\w*\s*(?:<[^>]*>)?\s*\((?P<params>.*)\)\s*(?::\s*(?P<ret>[^{]+?))?\s*\{?$")
59
+ _JS_FUNC = re.compile(r"^(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s*\*?\s*[\w$]*\s*(?:<[^>]*>)?\s*\((?P<params>.*)\)\s*(?::\s*(?P<ret>[^{]+?))?\s*\{?\s*$")
60
60
  _JS_ARROW = re.compile(r"^(?:export\s+)?(?:const|let|var)\s+\w+\s*(?::[^=]+)?=\s*(?:async\s+)?\((?P<params>.*)\)\s*(?::\s*(?P<ret>[^=]+?))?\s*=>")
61
61
  _JS_ARROW1 = re.compile(r"^(?:export\s+)?(?:const|let|var)\s+\w+\s*=\s*(?:async\s+)?(?P<params>\w+)\s*=>")
62
62
  _JS_CLASS = re.compile(r"^(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\s+\w+(?:<[^>]*>)?\s*(?:extends\s+(?P<base>[\w.<>, ]+?))?\s*(?:implements\s+(?P<impl>[\w.<>, ]+?))?\s*\{?$")
@@ -67,6 +67,40 @@ def _norm(text: str) -> str:
67
67
  return _WS.sub(" ", str(text or "")).strip()
68
68
 
69
69
 
70
+ def _declaration_head(sig: str) -> str:
71
+ """Cut a brace-language signature after its parameter list and return
72
+ type: the text up to the body's `{`, an arrow, or a `;`. Parentheses
73
+ are depth-matched, so a 400-char minified chunk holding several
74
+ declarations yields only the first one's head."""
75
+ i = sig.find("(")
76
+ if i < 0:
77
+ j = sig.find("{")
78
+ return sig if j < 0 else sig[:j].rstrip()
79
+ depth = 0
80
+ close = -1
81
+ for k in range(i, len(sig)):
82
+ ch = sig[k]
83
+ if ch == "(":
84
+ depth += 1
85
+ elif ch == ")":
86
+ depth -= 1
87
+ if depth == 0:
88
+ close = k
89
+ break
90
+ if close < 0:
91
+ return sig
92
+ rest = sig[close + 1:]
93
+ cut = len(rest)
94
+ for stop in ("{", ";"):
95
+ j = rest.find(stop)
96
+ if 0 <= j < cut:
97
+ cut = j
98
+ j = rest.find("=>")
99
+ if 0 <= j < cut:
100
+ cut = j + 2 # keep the arrow: the arrow-function grammar needs it
101
+ return (sig[:close + 1] + rest[:cut]).rstrip()
102
+
103
+
70
104
  def parse_signature(signature: str, language: str) -> dict:
71
105
  """Split a raw signature into params / ret / receiver / bases.
72
106
 
@@ -78,6 +112,10 @@ def parse_signature(signature: str, language: str) -> dict:
78
112
  if not sig:
79
113
  return {}
80
114
  lang = (language or "").lower()
115
+ if lang in ("javascript", "typescript", "go", "rust") and not sig.lstrip().startswith(
116
+ ("class ", "export class", "export default class", "abstract class",
117
+ "struct ", "pub struct", "enum ", "pub enum", "trait ", "pub trait", "impl ", "type ")):
118
+ sig = _declaration_head(sig)
81
119
  out: dict = {}
82
120
  if lang == "python":
83
121
  m = _PY_DEF.match(sig)
@@ -290,6 +328,25 @@ def render_map(record: dict, *, include_docs: bool = False,
290
328
  header = f"# {path} ({total_lines}L {language})".rstrip()
291
329
  body: list = []
292
330
 
331
+ shape = record.get("shape") or {}
332
+ parser = str(record.get("parser") or "")
333
+ minified = bool(shape.get("minified"))
334
+ if parser == "skipped" or shape.get("oversized"):
335
+ body.append(f"[map:not mapped] {shape.get('bytes', 0):,} bytes on {total_lines} lines, "
336
+ f"longest line {shape.get('longest_line', 0):,} chars — read with lines=[a,b]")
337
+ return "\n".join([header] + body)
338
+ if parser == "lexical":
339
+ body.append("[map:lexical-fallback] the parse overran its deadline; "
340
+ "symbols come from a line scan and may be incomplete")
341
+ if minified:
342
+ # A minified bundle's `var` soup is not structure; named declarations are.
343
+ soup = [s for s in sections if s.get("type") in ("variable", "constant")]
344
+ sections = [s for s in sections if s.get("type") not in ("variable", "constant")]
345
+ body.append(f"[map:minified] longest line {shape.get('longest_line', 0):,} chars; "
346
+ f"{len(soup)} var bindings omitted")
347
+ elif shape.get("generated"):
348
+ body.append("[map:generated] the file says it is generated")
349
+
293
350
  imports = [s for s in sections if s.get("type") == "import"]
294
351
  others = [s for s in sections if s.get("type") != "import"]
295
352
  if imports:
@@ -329,7 +386,9 @@ def render_map(record: dict, *, include_docs: bool = False,
329
386
  while kept and count_tokens("\n".join([header] + kept)) > max_tokens:
330
387
  kept.pop()
331
388
  dropped = len(body) - len(kept)
332
- text = "\n".join([header] + kept + [f"… {dropped} more symbols"])
389
+ text = "\n".join([header] + kept + [
390
+ f"… {dropped} more symbols (map budget {max_tokens} tokens; "
391
+ f"pass symbols=[…] or lines=[a,b] for the part you need)"])
333
392
  return text
334
393
 
335
394
 
services/file_memory.py CHANGED
@@ -195,6 +195,10 @@ class FileMemoryStore:
195
195
  "parser": parser,
196
196
  "sections": sections,
197
197
  }
198
+ shape = getattr(self, "_last_shape", None) or {}
199
+ if shape.get("minified") or shape.get("generated") or shape.get("oversized"):
200
+ record["shape"] = {k: shape[k] for k in ("minified", "generated", "oversized",
201
+ "longest_line", "bytes") if k in shape}
198
202
 
199
203
  self._save(rel_path, record)
200
204
  self._cache_map(rel_path, record)
@@ -213,17 +217,31 @@ class FileMemoryStore:
213
217
  return None
214
218
  return self._cache_map(rel_path, record)
215
219
 
216
- def get_or_build_map(self, rel_path: str) -> str:
217
- """Get map if cached, otherwise build it on-demand."""
220
+ #: Default token budget for a map served to the model (C4, 2.124.0). A
221
+ #: 250 KB generated module renders 117k tokens unbounded; the MCP
222
+ #: response cap is 25k. Over budget the map keeps its first lines and
223
+ #: says how many symbols it left out.
224
+ MAP_TOKEN_BUDGET = 6000
225
+
226
+ def get_or_build_map(self, rel_path: str, max_tokens: Optional[int] = None) -> str:
227
+ """Get map if cached, otherwise build it on-demand.
228
+
229
+ `max_tokens` renders a budgeted map (never cached); None serves the
230
+ cached full map.
231
+ """
218
232
  record = self.get(rel_path)
219
233
 
220
234
  # Check staleness
221
235
  if record and not self.needs_update(rel_path):
236
+ if max_tokens:
237
+ return render_map(record, max_tokens=max_tokens)
222
238
  return self._cache_map(rel_path, record)
223
239
 
224
240
  # Build fresh
225
241
  updated = self.update(rel_path)
226
242
  if updated:
243
+ if max_tokens:
244
+ return render_map(updated, max_tokens=max_tokens)
227
245
  return self._cache_map(rel_path, updated)
228
246
 
229
247
  return f"[file_map] Could not build map for {rel_path} — file not found or unreadable."
@@ -556,6 +574,39 @@ class FileMemoryStore:
556
574
  PARSER_TREE_SITTER = "tree_sitter"
557
575
  PARSER_REGEX = "regex"
558
576
  PARSER_GENERIC = "generic"
577
+ PARSER_LEXICAL = "lexical" # deadline or parse failure -> regex scan
578
+ PARSER_SKIPPED = "skipped" # minified / oversized: not parsed
579
+
580
+ #: Bounds for large files (C4, 2.124.0). A parse that overruns the
581
+ #: deadline is abandoned for the regex scan; a minified or oversized
582
+ #: file is not parsed at all.
583
+ PARSE_DEADLINE_S = 1.5
584
+ MAX_PARSE_BYTES = 2 * 1024 * 1024
585
+ MINIFIED_LINE_CHARS = 2000
586
+ MINIFIED_AVG_LINE_CHARS = 400
587
+ GENERATED_MARKERS = ("do not edit", "autogenerated", "auto-generated",
588
+ "@generated", "generated by", "generated file")
589
+
590
+ @classmethod
591
+ def classify_large(cls, content: str) -> dict:
592
+ """Cheap shape facts before any parse: {minified, generated, longest_line, oversized}."""
593
+ size = len(content)
594
+ longest = 0
595
+ n_lines = 0
596
+ for line in content.split("\n"):
597
+ n_lines += 1
598
+ if len(line) > longest:
599
+ longest = len(line)
600
+ avg = size / max(1, n_lines)
601
+ head = "\n".join(content.split("\n", 6)[:6]).lower()
602
+ return {
603
+ "bytes": size,
604
+ "lines": n_lines,
605
+ "longest_line": longest,
606
+ "minified": longest >= cls.MINIFIED_LINE_CHARS and avg >= cls.MINIFIED_AVG_LINE_CHARS,
607
+ "generated": any(m in head for m in cls.GENERATED_MARKERS),
608
+ "oversized": size >= cls.MAX_PARSE_BYTES,
609
+ }
559
610
 
560
611
  def _extract_sections(self, filepath: Path, content: str) -> list:
561
612
  """Extract structural sections with line ranges from source code."""
@@ -570,18 +621,56 @@ class FileMemoryStore:
570
621
  older single-value call keeps working.
571
622
  """
572
623
  ext = filepath.suffix.lower()
573
-
574
- # Try AST parser first
575
- ast_sections = extract_sections_ast(content, ext)
576
- if ast_sections is not None:
577
- return ast_sections, self.PARSER_TREE_SITTER
624
+ shape = self.classify_large(content)
625
+ self._last_shape = shape
626
+ if shape["oversized"]:
627
+ # Not worth a parse: the map says so and stops (docs/file-map.md
628
+ # § Large files). Minified files ARE parsed — bounded signature
629
+ # capture made that cheap — and the renderer drops their
630
+ # binding soup.
631
+ return [{"type": "content", "name": "(not mapped)",
632
+ "line_start": 1, "line_end": max(1, shape["lines"]),
633
+ "note": "oversized"}], self.PARSER_SKIPPED
578
634
 
579
635
  lines = content.splitlines()
580
636
  patterns = STRUCTURE_PATTERNS.get(ext, {})
581
637
 
638
+ # Try AST parser first, under a deadline. tree-sitter 0.26 has no
639
+ # parse timeout, so the parse runs on a worker and is abandoned
640
+ # (not killed) when it overruns; the regex scan answers instead.
641
+ ast_sections, overran = self._extract_ast_with_deadline(content, ext)
642
+ if ast_sections is not None:
643
+ return ast_sections, self.PARSER_TREE_SITTER
644
+ if overran and patterns:
645
+ secs, _ = self._extract_regex_sections(lines, ext, patterns)
646
+ return secs, self.PARSER_LEXICAL
647
+
582
648
  if not patterns:
583
649
  return self._extract_generic_sections(lines), self.PARSER_GENERIC
584
650
 
651
+ secs, _ = self._extract_regex_sections(lines, ext, patterns)
652
+ return secs, self.PARSER_REGEX
653
+
654
+ def _extract_ast_with_deadline(self, content: str, ext: str):
655
+ """(sections | None, overran). Runs extract_sections_ast on a worker
656
+ thread bounded by PARSE_DEADLINE_S; the thread is daemonic so an
657
+ abandoned parse cannot hold the process open."""
658
+ import concurrent.futures as _cf
659
+ pool = _cf.ThreadPoolExecutor(max_workers=1, thread_name_prefix="c3-parse")
660
+ try:
661
+ fut = pool.submit(extract_sections_ast, content, ext)
662
+ try:
663
+ return fut.result(timeout=self.PARSE_DEADLINE_S), False
664
+ except _cf.TimeoutError:
665
+ self._record_diag("parse_deadline", ext, f"{len(content)} bytes")
666
+ return None, True
667
+ except Exception:
668
+ return None, True
669
+ finally:
670
+ pool.shutdown(wait=False)
671
+
672
+ def _extract_regex_sections(self, lines: list, ext: str, patterns: dict) -> tuple:
673
+ """The regex/indentation extractor (the pre-tree-sitter path)."""
585
674
  sections = []
586
675
  i = 0
587
676
  current_class = None # Track current class for method nesting
@@ -674,6 +763,14 @@ class FileMemoryStore:
674
763
 
675
764
  base_indent = len(lines[start]) - len(lines[start].lstrip())
676
765
 
766
+ def _last_code_line(upto: int) -> int:
767
+ # 1-indexed line number of the last non-blank line before `upto`
768
+ # (0-indexed exclusive): blank lines belong to nobody.
769
+ j = upto
770
+ while j > start + 1 and not lines[j - 1].strip():
771
+ j -= 1
772
+ return j
773
+
677
774
  for i in range(start + 1, len(lines)):
678
775
  line = lines[i]
679
776
  stripped = line.strip()
@@ -681,11 +778,11 @@ class FileMemoryStore:
681
778
  continue
682
779
  current_indent = len(line) - len(line.lstrip())
683
780
  if current_indent <= base_indent:
684
- return i # 1-indexed
781
+ return _last_code_line(i) # 1-indexed
685
782
  # Check for decorators at same level (next function)
686
783
  if current_indent == base_indent and stripped.startswith('@'):
687
- return i
688
- return len(lines)
784
+ return _last_code_line(i)
785
+ return _last_code_line(len(lines))
689
786
 
690
787
  def _find_brace_block_end(self, lines: list, start: int) -> int:
691
788
  """Find end of a brace-delimited block.
services/parser.py CHANGED
@@ -57,6 +57,28 @@ PARSER_VERSION = "4" # 4: records carry parser attribution, no summary, posix p
57
57
 
58
58
  _WS_RUN = re.compile(r"\s+")
59
59
 
60
+ #: A signature is never longer than this. A 680 KB minified line used to be
61
+ #: copied into every one of its 20,000 symbols (176 s, an 11 MB record).
62
+ SIGNATURE_MAX_CHARS = 400
63
+
64
+
65
+ def _sig_line(lines: List[str], node) -> str:
66
+ """The node's first line from its start column, capped at SIGNATURE_MAX_CHARS."""
67
+ row, col = node.start_point
68
+ line = lines[row] if row < len(lines) else ""
69
+ chunk = line[col:col + SIGNATURE_MAX_CHARS] if len(line) - col > SIGNATURE_MAX_CHARS else line[col:]
70
+ if not chunk.strip():
71
+ chunk = line[:SIGNATURE_MAX_CHARS]
72
+ return chunk.strip()
73
+
74
+
75
+ def _sig_span(lines: List[str], start_row: int, end_row: int) -> str:
76
+ """Lines start_row..end_row joined, capped at SIGNATURE_MAX_CHARS."""
77
+ text = "\n".join(lines[start_row:end_row + 1]).strip()
78
+ if len(text) > SIGNATURE_MAX_CHARS:
79
+ text = text[:SIGNATURE_MAX_CHARS].rstrip() + "…"
80
+ return text
81
+
60
82
 
61
83
  def _node_text(node, lines: List[str]) -> str:
62
84
  """The source text of a node, joined across lines, whitespace-normalized."""
@@ -157,7 +179,7 @@ def _walk_python(node, lines: List[str], sections: List[Dict[str, Any]], parent_
157
179
  # Find the colon
158
180
  colon_node = next((c for c in child.children if c.type == ':'), None)
159
181
  sig_end = colon_node.end_point[0] if colon_node else sig_start
160
- signature = '\n'.join(lines[sig_start:sig_end+1]).strip()
182
+ signature = _sig_span(lines, sig_start, sig_end)
161
183
 
162
184
  section = {
163
185
  "type": "class",
@@ -186,7 +208,7 @@ def _walk_python(node, lines: List[str], sections: List[Dict[str, Any]], parent_
186
208
  sig_start = child.start_point[0]
187
209
  colon_node = next((c for c in child.children if c.type == ':'), None)
188
210
  sig_end = colon_node.end_point[0] if colon_node else sig_start
189
- signature = '\n'.join(lines[sig_start:sig_end+1]).strip()
211
+ signature = _sig_span(lines, sig_start, sig_end)
190
212
 
191
213
  is_async = any(c.type == 'async' for c in child.children)
192
214
 
@@ -232,7 +254,7 @@ def _walk_python(node, lines: List[str], sections: List[Dict[str, Any]], parent_
232
254
  "name": name,
233
255
  "line_start": child.start_point[0] + 1,
234
256
  "line_end": child.end_point[0] + 1,
235
- "signature": lines[child.start_point[0]].strip()
257
+ "signature": _sig_line(lines, child)
236
258
  })
237
259
 
238
260
  elif child.type == 'comment':
@@ -249,10 +271,10 @@ def _walk_python(node, lines: List[str], sections: List[Dict[str, Any]], parent_
249
271
  elif child.type in ('import_statement', 'import_from_statement'):
250
272
  section = {
251
273
  "type": "import",
252
- "name": lines[child.start_point[0]].strip(),
274
+ "name": _sig_line(lines, child),
253
275
  "line_start": child.start_point[0] + 1,
254
276
  "line_end": child.end_point[0] + 1,
255
- "signature": lines[child.start_point[0]].strip()
277
+ "signature": _sig_line(lines, child)
256
278
  }
257
279
  if not parent_section:
258
280
  sections.append(section)
@@ -294,7 +316,7 @@ def _walk_js_ts(node, lines: List[str], sections: List[Dict[str, Any]], parent_s
294
316
  name = lines[name_node.start_point[0]][name_node.start_point[1]:name_node.end_point[1]] if name_node else 'Unknown'
295
317
 
296
318
  # Simple signature heuristic: first line
297
- signature = lines[child.start_point[0]].strip()
319
+ signature = _sig_line(lines, child)
298
320
 
299
321
  t = child.type.split('_')[0]
300
322
  section = {
@@ -321,7 +343,7 @@ def _walk_js_ts(node, lines: List[str], sections: List[Dict[str, Any]], parent_s
321
343
  name_node = next((c for c in child.children if c.type in ('property_identifier', 'identifier', 'private_property_identifier')), None)
322
344
  name = lines[name_node.start_point[0]][name_node.start_point[1]:name_node.end_point[1]] if name_node else 'Unknown'
323
345
 
324
- signature = lines[child.start_point[0]].strip()
346
+ signature = _sig_line(lines, child)
325
347
 
326
348
  is_async = any(c.type == 'async' for c in child.children)
327
349
 
@@ -373,7 +395,7 @@ def _walk_js_ts(node, lines: List[str], sections: List[Dict[str, Any]], parent_s
373
395
  "name": name,
374
396
  "line_start": child.start_point[0] + 1,
375
397
  "line_end": child.end_point[0] + 1,
376
- "signature": lines[child.start_point[0]].strip()
398
+ "signature": _sig_line(lines, child)
377
399
  }
378
400
  if is_async: section["async"] = True
379
401
  doc = _extract_docstring_js(child, lines)
@@ -391,7 +413,7 @@ def _walk_js_ts(node, lines: List[str], sections: List[Dict[str, Any]], parent_s
391
413
  "name": name,
392
414
  "line_start": child.start_point[0] + 1,
393
415
  "line_end": child.end_point[0] + 1,
394
- "signature": lines[child.start_point[0]].strip()
416
+ "signature": _sig_line(lines, child)
395
417
  })
396
418
 
397
419
  elif child.type == 'comment':
@@ -408,10 +430,10 @@ def _walk_js_ts(node, lines: List[str], sections: List[Dict[str, Any]], parent_s
408
430
  elif child.type == 'import_statement':
409
431
  section = {
410
432
  "type": "import",
411
- "name": lines[child.start_point[0]].strip(),
433
+ "name": _sig_line(lines, child),
412
434
  "line_start": child.start_point[0] + 1,
413
435
  "line_end": child.end_point[0] + 1,
414
- "signature": lines[child.start_point[0]].strip()
436
+ "signature": _sig_line(lines, child)
415
437
  }
416
438
  if not parent_section:
417
439
  sections.append(section)
@@ -446,7 +468,7 @@ def _walk_html(node, lines: List[str], sections: List[Dict[str, Any]]):
446
468
  "name": f"{tag_name}: {text_content.strip()[:60]}",
447
469
  "line_start": child.start_point[0] + 1,
448
470
  "line_end": child.end_point[0] + 1,
449
- "signature": lines[child.start_point[0]].strip()
471
+ "signature": _sig_line(lines, child)
450
472
  })
451
473
 
452
474
  # Check for ID attribute
@@ -474,7 +496,7 @@ def _walk_html(node, lines: List[str], sections: List[Dict[str, Any]]):
474
496
  "tag": tag_name,
475
497
  "line_start": child.start_point[0] + 1,
476
498
  "line_end": child.end_point[0] + 1,
477
- "signature": lines[child.start_point[0]].strip()
499
+ "signature": _sig_line(lines, child)
478
500
  })
479
501
 
480
502
  # Recurse into element children
@@ -504,7 +526,7 @@ def _walk_markdown(node, lines: List[str], sections: List[Dict[str, Any]]):
504
526
  "name": f"{level}: {content_text.strip()[:60]}",
505
527
  "line_start": child.start_point[0] + 1,
506
528
  "line_end": child.end_point[0] + 1,
507
- "signature": lines[child.start_point[0]].strip()
529
+ "signature": _sig_line(lines, child)
508
530
  })
509
531
 
510
532
  # Recurse if needed (atx headings are top level usually, but just in case)
@@ -582,7 +604,7 @@ def _walk_go(node, lines: List[str], sections: List[Dict[str, Any]]):
582
604
  name_node = next((c for c in child.children if c.type == 'identifier' or c.type == 'field_identifier'), None)
583
605
  name = lines[name_node.start_point[0]][name_node.start_point[1]:name_node.end_point[1]] if name_node else 'Unknown'
584
606
 
585
- signature = lines[child.start_point[0]].strip()
607
+ signature = _sig_line(lines, child)
586
608
 
587
609
  receiver = ""
588
610
  if child.type == 'method_declaration':
@@ -609,7 +631,7 @@ def _walk_go(node, lines: List[str], sections: List[Dict[str, Any]]):
609
631
  "name": _node_text(ident, lines),
610
632
  "line_start": spec.start_point[0] + 1,
611
633
  "line_end": spec.end_point[0] + 1,
612
- "signature": lines[spec.start_point[0]].strip()
634
+ "signature": _sig_line(lines, spec)
613
635
  })
614
636
  elif child.type == 'type_declaration':
615
637
  # Drill into type specs
@@ -625,7 +647,7 @@ def _walk_go(node, lines: List[str], sections: List[Dict[str, Any]]):
625
647
  "name": name,
626
648
  "line_start": child.start_point[0] + 1,
627
649
  "line_end": child.end_point[0] + 1,
628
- "signature": lines[child.start_point[0]].strip()
650
+ "signature": _sig_line(lines, child)
629
651
  })
630
652
  _walk_go(child, lines, sections)
631
653
 
@@ -656,9 +678,9 @@ def _walk_rust(node, lines: List[str], sections: List[Dict[str, Any]]):
656
678
  sig.append(ln)
657
679
  if '{' in ln or ln.rstrip().endswith(';'):
658
680
  break
659
- signature = " ".join(x.strip() for x in sig)
681
+ signature = " ".join(x.strip() for x in sig)[:SIGNATURE_MAX_CHARS]
660
682
  else:
661
- signature = lines[child.start_point[0]].strip()
683
+ signature = _sig_line(lines, child)
662
684
  sections.append({
663
685
  "type": stype,
664
686
  "name": name,