Skip to content

Commit c11a0a0

Browse files
jgravelleclaude
andcommitted
release: v1.108.129 — keystone-protected structural compression for get_ranked_context
Opt-in compress=True prunes low-signal lines from oversized symbol bodies so more relevant symbols fit the same token_budget, always preserving keystone lines (control-flow, returns/raises, signatures, constraint cues). Model-free structural Shannon-entropy signal; labeled read-only view, never an edit. Default compress=False is byte-identical. New retrieval/entropy_prune.py, wired into both default and fusion packing paths. NO INDEX_VERSION/tool-count change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a04df81 commit c11a0a0

7 files changed

Lines changed: 417 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,38 @@
22

33
All notable changes to jcodemunch-mcp are documented here.
44

5+
## [1.108.129] - 2026-07-14 - Keystone-protected structural compression for get_ranked_context
6+
7+
`get_ranked_context` gains an opt-in `compress=True` that fits **more** relevant
8+
symbols into the same token budget. When a symbol body is large, a model-free,
9+
structural pass prunes its lowest-signal lines and keeps the high-signal ones,
10+
while **always** preserving "keystone" lines — control flow, returns/raises,
11+
signatures, and decision/constraint/negation cues (`if`, `return`, `raise`,
12+
`must`, `!=`, `def`/`class`, …) whose removal could silently change meaning.
13+
Dropped runs collapse into an honest `… N low-signal line(s) elided …` marker,
14+
so the caller can never mistake a pruned view for the full body.
15+
16+
The signal is pure structure — normalized Shannon entropy of each line's token
17+
distribution, weighted by length — so it runs **headless, deterministic, and
18+
local**: no model, no network, no persisted state. The result is a labeled
19+
read-only VIEW, never an edit (consistent with the read-only charter). On the
20+
live jCodeMunch index at a 1,500-token budget, `compress=True` delivered 12
21+
symbols where the default fit 9 (three pruned to make room), same total tokens.
22+
23+
- New `retrieval/entropy_prune.py`: `prune_source` / `is_keystone` / `line_signal`
24+
(O(L), one token-cost measurement per line).
25+
- Wired into BOTH the default and `fusion=True` packing paths via a shared
26+
`_compressing_get_tokens` wrapper; pruned items carry `source_pruned`,
27+
`source_kept_lines`, `source_elided_lines`, `source_total_lines`,
28+
`source_is_pruned_view`.
29+
- **Default `compress=False` is byte-identical** to prior output. `compress` is
30+
compact-stripped so `core_compact` is unchanged; no new tool, no tool-count
31+
change, no INDEX_VERSION bump.
32+
- New `tests/test_v1_108_129.py` (13: pruner units + integration incl.
33+
default-off byte-identity, fits-more, keystones-survive, honest metadata,
34+
fusion path). ruff clean; ranked-context/encoding/schema-budget/dispatch-parity/
35+
server suites green.
36+
537
## [1.108.128] - 2026-07-13 - Never auto-bill a paid cloud provider from a bare env key
638

739
A bare cloud API key in the environment (e.g. `ANTHROPIC_API_KEY`) silently

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# jcodemunch-mcp — Project Brief
22

33
## Current State
4+
- **Version:** 1.108.129 — **Keystone-protected structural compression for `get_ranked_context` (opt-in `compress=True`; clean-room F-15, competitor unnamed in shipped artifacts).** Renovates an entropy/relevance-guided context-selection idea into jcm's read-only/local/model-free idiom: an oversized symbol body is pruned toward a per-item soft cap so MORE relevant symbols fit the same `token_budget`. New `retrieval/entropy_prune.py` — `prune_source(source, max_tokens, count_tokens)` ranks lines by `line_signal` (normalized Shannon token-entropy × length-weight), keeps the highest-signal lines, and ALWAYS keeps `is_keystone` lines (control-flow/return/raise/yield/assert, signatures `def`/`class`/`@`/`func`/etc., operators `!=`/`==`/`->`/`=>`, and NL constraint cues `must`/`only if`/`unless`/`never`/`require`); dropped runs collapse to an honest `… N low-signal line(s) elided …` marker. O(L) — one token-cost measurement per line; `max_tokens` is a SOFT cap (keystone protection + elision markers can exceed it slightly; the outer `_pack_budget` still enforces the hard total). Wired into BOTH the default and `fusion=True` paths via shared `_compressing_get_tokens(base_get_tokens, token_budget, registry)` (constants `_COMPRESS_TARGET_ITEMS=8`/`_COMPRESS_MIN_ITEM_TOKENS=120`) + `_prune_fields()`; pruned items carry `source_pruned`/`source_kept_lines`/`source_elided_lines`/`source_total_lines`/`source_is_pruned_view`. **Default `compress=False` is byte-identical**; param compact-stripped (`_COMPACT_STRIP_PARAMS["get_ranked_context"]`) so `core_compact` unchanged; NO new tool, NO tool-count change, NO INDEX_VERSION bump. Live-index smoke: 1500-tok budget delivered 12 symbols vs 9 default (3 pruned), same total. New `tests/test_v1_108_129.py` (13); ruff clean; ranked-context/negative-evidence/schema-budget/dispatch-parity/server suites green (150). Origin: a clean-room F-15 renovation of an Atelier-family runtime's entropy/perplexity context-compression stage (competitor unnamed in shipped artifacts, [[feedback_no_competitor_names_in_shipped_artifacts]]; intel kept in session memory); the versus.php warn-box conceded their selection stage was ahead — this closes it.
45
- **Version:** 1.108.128 — **Never auto-bill a paid cloud provider from a bare env key (money-safety, jjg-reported).** A bare `ANTHROPIC_API_KEY` (or any cloud key) in the environment silently enabled AI summarization under the default `use_ai_summaries="auto"` — `get_provider_name` (`summarizer/batch_summarize.py`) walked `_AUTO_DETECT_ORDER` and returned the first provider whose key was present, billing per-symbol on every index. Acute because an editor running on OAuth + an ambient key = quiet spend ("falls out of OAuth mode and eats balance"). Fix: new `_PAID_CLOUD_PROVIDERS` frozenset + `_auto_entry_is_paid_cloud()` (openai counts as paid only when `OPENAI_API_BASE` is off-localhost) + `_paid_summaries_allowed()`; the auto-detect loop now SKIPS a paid-cloud entry unless opted in, with a one-time `logger.warning` naming the exact setting. **Opt-in is explicit**: `summarizer_provider=<name>` (already bypasses auto-detect, unchanged) OR new `JCODEMUNCH_ALLOW_PAID_SUMMARIES=1` / `allow_paid_summaries` config key (added to DEFAULTS/CONFIG_TYPES/template/ENV_VAR_MAPPING). Free/local endpoints (localhost `OPENAI_API_BASE`) still auto-enable. Verified end-to-end: bare ANTHROPIC_API_KEY + default config → provider None, no summarizer, warning emitted; opt-in restores it. NO INDEX_VERSION bump. New `tests/test_v1_108_128.py` (9); 7 existing auto-detect tests updated to set the opt-in flag (they test the mapping/priority mechanism, which now requires opt-in). ruff clean; summarizer/config/guard suites green (270). **Sibling parity: jdoc auto-detects on bare `OPENAI_API_KEY` too (same footgun) — needs the same guard; jdata summarizer doesn't cloud-auto-detect (embeddings gated on explicit `*_EMBED_MODEL`).**
56
- **Version:** 1.108.127 — **Fix `index_repo` full re-index crash `'dict' object has no attribute 'summary'` (#367, @jgravelle).** A non-incremental `index_repo` re-index crashed while `index_folder` on the same repo succeeded. Root cause: loaded indexes carry symbols as **dicts** (`SQLiteIndexStore._build_index_from_rows` → `_row_to_symbol_dict`), but `index_repo`'s full-path summary-preservation map (`index_repo.py` ~L537) read them by ATTRIBUTE (`s.summary`/`s.file`/`s.name`/`s.kind`); `index_folder` (~L2171) already used dict access (`s["file"]`/`s.get("summary")`) — the divergence. The comprehension only fires when a prior index exists AND ≥1 file is unchanged (hash match) AND has a stored summary, so it needed a real re-index-with-summaries to trip (matching the report). Fixed to key access, mirroring `index_folder`. Incremental path was already clean (routes through `store.incremental_save`/`parse_and_prepare_incremental`, never attribute-accesses loaded symbols). Pure read path, NO INDEX_VERSION bump. New `tests/test_v1_108_127.py` (2: no-AttributeError full re-index over a pre-seeded index with mocked GitHub fetch + a loaded-symbols-are-dicts contract guard); verified the test fails with the pre-fix code. ruff clean; index_repo/server/storage suites green (96).
67
- **Version:** 1.108.126 — **Loud, persisted `max_folder_files` truncation (#366, @oderwat).** The folder walk cap (default 2000) silently dropped files: the index looked healthy while whole subdirectories were missing from `search_text`/`search_symbols`. The fresh full-index path already warned, but the incremental re-index path (the `.mjs`-verification scenario) + the "No changes detected" path were silent, and nothing was persisted so queries couldn't warn. Fix: (1) new persisted `file_cap_status` meta key (`{truncated, files_discovered, files_indexed, files_skipped_cap, max_folder_files}` or `{"truncated": False}`) — plumbed through `save_index`/`incremental_save`/`_write_meta`/`_build_index_from_rows`/`_patch_index_from_delta`/branch-compose + a new `CodeIndex.file_cap_status` field + a cheap metadata-only `SqliteStore.get_file_cap_status()` reader; **self-heals** (every full-walk save rewrites the status, so a re-index under a raised cap clears it); NO INDEX_VERSION bump (meta is k-v). (2) Two `index_folder` helpers `_file_cap_report`/`_attach_cap_report` surface `truncated: true` + counts + an actionable warning on ALL result paths (full, incremental, no-change). (3) `resolve_repo` surfaces `truncated`+counts+`truncation_warning` (session-start indexed check, cheapest high-value surface). (4) `search_text`/`search_symbols` attach `_meta.index_truncated` (new shared `retrieval/verdict.index_truncation_meta` helper; "thin result may be truncation, not absence"); attached before the LRU cache put so cache-hits carry it; no-op/byte-identical when not truncated (so the Replay golden baseline is unaffected). **Default cap deliberately UNCHANGED** — raising it silently trades one surprise (missing files) for another (huge slow index); now that truncation is loud it's a documented one-line config bump. config.jsonc template comment for `max_folder_files` updated. New `tests/test_v1_108_126.py` (8: report helpers, index-result/resolve/search surfacing, self-heal). ruff clean; CI green (9/9 lint+8-job matrix + Replay) BEFORE twine.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "jcodemunch-mcp"
3-
version = "1.108.128"
3+
version = "1.108.129"
44
description = "Token-efficient MCP server for source code exploration via tree-sitter AST parsing"
55
readme = "README.md"
66
requires-python = ">=3.10"
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
"""Structural, model-free context compression for token-budgeted assembly.
2+
3+
Renovates entropy/relevance-guided context selection into jCodeMunch's read-only,
4+
local idiom. Given an oversized symbol body and a per-item token cap, this ranks
5+
lines by a structural information signal (normalized Shannon entropy of the
6+
line's tokens, weighted by length), keeps the highest-signal lines, and ALWAYS
7+
keeps "keystone" lines — control-flow, returns/raises, signatures, and
8+
decision/constraint/negation cues whose removal could silently flip meaning.
9+
Everything else is elided, replaced by an honest ``… N low-signal line(s)
10+
elided …`` marker so the caller can never mistake a pruned view for the full body.
11+
12+
No model, no network, no state: the signal is pure structure (entropy of the
13+
token distribution), so this runs headless and deterministic. The result is a
14+
labeled VIEW, never an edit — consistent with the read-only charter. Callers opt
15+
in explicitly (``get_ranked_context(compress=True)``); default paths never invoke
16+
it, so their output is byte-identical.
17+
"""
18+
19+
import math
20+
import re
21+
from collections import Counter
22+
from dataclasses import dataclass
23+
24+
# Lines whose removal could silently change behavior — never elided. Covers
25+
# control flow, exits, boolean/comparison operators, and natural-language
26+
# constraint cues that carry the "what must hold" of a block.
27+
_KEYSTONE_RE = re.compile(
28+
r"\b(return|raise|yield|assert|if|elif|else|for|while|try|except|finally|"
29+
r"with|def|class|async|await|break|continue|throw|match|case|goto|guard)\b"
30+
r"|=>|->|:=|==|!=|<=|>=|&&|\|\|"
31+
r"|\b(?:must|only if|unless|never|always|require|ensure|shall)\b",
32+
re.IGNORECASE,
33+
)
34+
# Definition/signature lines — the contract of the symbol, always kept.
35+
_SIGNATURE_RE = re.compile(
36+
r"^\s*(?:@|def |class |func |function |fn |public |private |protected |"
37+
r"static |export |const |var |let |type |interface |struct |impl )"
38+
)
39+
# A decorator/annotation line is a keystone too (already covered by ``@`` above).
40+
41+
_TOKEN_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*|\S")
42+
43+
44+
@dataclass
45+
class PruneResult:
46+
"""Outcome of a keystone-protected prune. ``text`` is a labeled view."""
47+
48+
text: str
49+
total_lines: int
50+
kept_lines: int
51+
elided_lines: int
52+
is_pruned: bool
53+
54+
55+
def _token_entropy(text: str) -> float:
56+
"""Normalized Shannon entropy (0..1) of the line's token distribution."""
57+
toks = _TOKEN_RE.findall(text)
58+
n = len(toks)
59+
if n <= 1:
60+
return 0.0
61+
counts = Counter(toks)
62+
ent = -sum((c / n) * math.log2(c / n) for c in counts.values())
63+
return ent / math.log2(n)
64+
65+
66+
def line_signal(line: str) -> float:
67+
"""Per-line information signal: token entropy weighted by content length.
68+
69+
Dense, varied lines (real logic) outrank repetitive or near-empty ones. A
70+
long line of distinct tokens scores highest; boilerplate and padding lowest.
71+
"""
72+
stripped = line.strip()
73+
if not stripped:
74+
return 0.0
75+
return _token_entropy(line) * math.log2(len(stripped) + 2)
76+
77+
78+
def is_keystone(line: str) -> bool:
79+
"""True if the line must be preserved regardless of its information signal."""
80+
stripped = line.strip()
81+
if not stripped:
82+
return False
83+
if _SIGNATURE_RE.match(line):
84+
return True
85+
return bool(_KEYSTONE_RE.search(stripped))
86+
87+
88+
def _emit(lines: list[str], keep: list[bool]) -> str:
89+
"""Reconstruct the kept lines, collapsing dropped runs into elision markers."""
90+
out: list[str] = []
91+
run = 0
92+
for i, ln in enumerate(lines):
93+
if keep[i]:
94+
if run:
95+
out.append(f" # … {run} low-signal line(s) elided …")
96+
run = 0
97+
out.append(ln)
98+
else:
99+
run += 1
100+
if run:
101+
out.append(f" # … {run} low-signal line(s) elided …")
102+
return "\n".join(out)
103+
104+
105+
def prune_source(source: str, max_tokens: int, count_tokens) -> PruneResult:
106+
"""Keystone-protected prune of ``source`` toward ``max_tokens``.
107+
108+
Keeps every keystone line plus the highest-signal remaining lines that fit
109+
the budget. ``max_tokens`` is a SOFT per-item cap: keystone protection wins
110+
over the budget (correctness over compression), and elision markers add a
111+
few tokens, so the returned view may modestly exceed the cap — the caller's
112+
outer greedy packer still enforces the hard total. Returns the full source
113+
unchanged (``is_pruned=False``) when it already fits.
114+
115+
``count_tokens`` is the same token estimator the caller packs with, so the
116+
prune and the pack agree on cost. Cost is measured once per line (O(L)).
117+
"""
118+
lines = source.splitlines()
119+
if not lines:
120+
return PruneResult(source, 0, 0, 0, False)
121+
if count_tokens(source) <= max_tokens:
122+
return PruneResult(source, len(lines), len(lines), 0, False)
123+
124+
line_cost = [max(1, count_tokens(ln)) for ln in lines]
125+
keep = [False] * len(lines)
126+
used = 0
127+
optional: list[tuple[float, int, int]] = [] # (-signal, index, index) for stable sort
128+
for i, ln in enumerate(lines):
129+
if is_keystone(ln):
130+
keep[i] = True
131+
used += line_cost[i]
132+
else:
133+
# Blank lines are near-free structure; rank them lowest so real
134+
# content is admitted first, but let them fill leftover budget.
135+
optional.append((-line_signal(ln), i, i))
136+
137+
optional.sort() # highest signal first (negated), ties by original order
138+
for _, i, _ in optional:
139+
if used + line_cost[i] > max_tokens:
140+
continue
141+
keep[i] = True
142+
used += line_cost[i]
143+
144+
kept = sum(keep)
145+
elided = len(lines) - kept
146+
if elided == 0:
147+
return PruneResult(source, len(lines), kept, 0, False)
148+
return PruneResult(_emit(lines, keep), len(lines), kept, elided, True)

src/jcodemunch_mcp/server.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -578,7 +578,7 @@ async def _apply_model_announcement(model: str) -> dict:
578578
"max_source_bytes", "max_total_source_bytes",
579579
},
580580
"get_context_bundle": {"budget_strategy"},
581-
"get_ranked_context": {"detail_level"},
581+
"get_ranked_context": {"detail_level", "compress"},
582582
"get_blast_radius": {"cross_repo", "max_depth"},
583583
"get_endpoint_impact": {"include_infra"},
584584
"index_dependency": {"ecosystem", "max_files"},
@@ -3424,6 +3424,11 @@ def _build_tools_list() -> list[Tool]:
34243424
"description": "Enable multi-signal fusion (Weighted Reciprocal Rank) for ranking. Combines lexical, structural, and identity channels.",
34253425
"default": False,
34263426
},
3427+
"compress": {
3428+
"type": "boolean",
3429+
"description": "Keystone-protected structural compression: prune low-signal lines from oversized bodies so more relevant symbols fit the budget (control-flow/returns/signatures always kept). Model-free; pruned items carry source_pruned + line counts. Default False.",
3430+
"default": False,
3431+
},
34273432
},
34283433
"required": ["repo", "query"],
34293434
},
@@ -4878,6 +4883,7 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent] | CallToolR
48784883
include_kinds=arguments.get("include_kinds"),
48794884
scope=arguments.get("scope"),
48804885
fusion=arguments.get("fusion", False),
4886+
compress=arguments.get("compress", False),
48814887
storage_path=storage_path,
48824888
)
48834889
)

0 commit comments

Comments
 (0)