code-context-control 2.123.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.123.0"
95
+ __version__ = "2.124.0"
96
96
 
97
97
 
98
98
  def _compress_file_cli(compressor, path, mode="smart", **kw):
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
@@ -331,7 +331,8 @@ def handle_read(file_path: str, symbols: Any = None, lines: Any = None,
331
331
 
332
332
  if not ranges:
333
333
  map_cached = not svc.file_memory.needs_update(rel_path)
334
- 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)
335
336
  if count_tokens(file_map) >= full_file_tokens():
336
337
  # A map that costs more than the file is worth nothing: serve the
337
338
  # file (docs/file-map.md § Small files).
@@ -341,6 +342,8 @@ def handle_read(file_path: str, symbols: Any = None, lines: Any = None,
341
342
  + "\n[map only — pass lines=[start,end] or symbols=[...] for exact source]"
342
343
  + maybe_related_facts(svc, rel_path, top_k=3, context="read"))
343
344
  map_tok = count_tokens(resp)
345
+ if finalize is None:
346
+ return resp
344
347
  return finalize_with_tokens(
345
348
  finalize, svc, "c3_read", {"file": file_path},
346
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.123.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=bec4nDHziMA74g4aEvg-_X0nQKCLmNNkTr2gslczpHg,416843
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
@@ -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
@@ -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=xZrKGABR-smsfGfg5CUfv77opA9EsOWnNhiYVj9hymQ,18421
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.123.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
@@ -218,8 +218,8 @@ services/edit_ledger.py,sha256=Tj8Q14o19VHQHpofRHdbZKKcVs17v-ctZ3LOeVTqvdQ,29362
218
218
  services/embedding_index.py,sha256=c85kAkekL7r-liiFEwqi7nJAQr4ZbJA36h0oMdiTurc,25156
219
219
  services/enforcement_policy.py,sha256=bB-lI7EZwWT5MhoNtkBjjixN4h7zAYUtVBWPMJ6aONo,17196
220
220
  services/error_reporting.py,sha256=HZ3ru8i5RLf8nq2R4iRnTs5sm1blUxknSbv5hdxuxs0,4139
221
- services/file_map.py,sha256=t5Ua4abJDuo-EZSX793MWiXg4uMqNAk1KxZrOffIm48,14500
222
- 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
223
223
  services/git_context.py,sha256=FFdA8Y46op4DYsUnZsXLTqxIjgxb0q_d-hkFTbosC8Q,9518
224
224
  services/hub_service.py,sha256=z2MZ2Rg9NLNXxQ17e05XGF_f6qSTDWLYtoC3WpuQfGU,2634
225
225
  services/index_store.py,sha256=SWHHKQB_wYww1ubtKgkLo5FFEqaWhPbw0OAN8x3gk9Q,16453
@@ -251,7 +251,7 @@ services/override_grants.py,sha256=BQVwD6WCxVORsUaqDWNToIalz2mStnJRMIR-SOxQzuQ,3
251
251
  services/override_policy.py,sha256=VC5H-FETUz4u6f4JM_q5jqT2HIb1MiFzhpdJyuBG72I,22369
252
252
  services/override_requests.py,sha256=H9lSUbfOjkjE1muztaQ58InqqsqhtnKIT-pF2VzvWkg,31148
253
253
  services/override_wake.py,sha256=HjSOXQTDi0irgeMyrTqngL7xLcSOOIgYsVW9yxod4ps,10363
254
- services/parser.py,sha256=4pklWjWICIVohRLghylS727idWCWyrUBWks1Xh4526o,62782
254
+ services/parser.py,sha256=S_YoKLS3r1xCUVHApXb3BTEfoRAzFhuo1_8XsG0qxVE,63505
255
255
  services/project_manager.py,sha256=uQh-GUvH5cgV5Ce9XYPINlQeqH6NvNdW8zrNFYRbBkw,37855
256
256
  services/project_runtime.py,sha256=yyftX48BFjq4B2KcR4KGszDAAa4xR6gn6j0SgTyro1s,11603
257
257
  services/protocol.py,sha256=CWZ0BWWd-jPui-11hZlvGgkNk18gMFhhLWbp-BP35Kk,12861
@@ -306,8 +306,8 @@ tui/screens/search_view.py,sha256=MMHjVdlk3HZSuDBSvq8IGrqv_Mh5Us6YqXQ80bcWSMk,19
306
306
  tui/screens/session_view.py,sha256=eZ1eDwHTvPOck1wCCviixtOaCxIkBT_95ytNNNriGNA,5991
307
307
  tui/screens/stats.py,sha256=p81PjzdaIv7hllb8f45-rlVe4lJZwSdIMqu7e86_u5s,6223
308
308
  tui/screens/ui_view.py,sha256=1QJCgLh2YfgWIpvzRG1KOGXYEaOYX6ojN61Azjf2oX0,2125
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,,
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/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,