Skip to content

Commit f3690b4

Browse files
committed
release: v1.108.132 — receipt surfaces the persistent lifetime savings meter
1 parent 37bc4e8 commit f3690b4

5 files changed

Lines changed: 150 additions & 8 deletions

File tree

CHANGELOG.md

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

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

5+
## [1.108.132] - 2026-07-16 - receipt surfaces the lifetime savings meter
6+
7+
`receipt` scans local Claude Code transcripts, which are cleared on reinstall,
8+
so its windowed figure can badly understate a heavy user's real savings. It now
9+
also reads the persistent per-call meter (`_savings.json` under the index root,
10+
which survives reinstalls) and reports lifetime tokens saved plus their value at
11+
the selected model's input rate, alongside the windowed figure.
12+
13+
### Added
14+
- A "Lifetime savings (jCodeMunch meter)" section in the `receipt` text report
15+
and `--export json` output: cumulative tokens saved and their dollar value at
16+
the selected model rate. Shown even when the transcript window is empty.
17+
- `lifetime_meter()` reader (honors `CODE_INDEX_PATH`); returns None when the
18+
meter file is absent, unreadable, or empty.
19+
20+
### Changed
21+
- The empty-window message now explains the window is transcript-scoped and
22+
points at the lifetime meter as the durable record.
23+
24+
No INDEX_VERSION bump, no wire-shape change to any MCP tool.
25+
526
## [1.108.131] - 2026-07-16 - receipt/org-report --model choices derive from the price table (Fable selectable)
627

728
The `receipt` and `org-report` CLI subparsers in `server.py` built their

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.132 — **`receipt` surfaces the persistent lifetime savings meter.** `receipt` scans local Claude transcripts (cleared on reinstall → its windowed figure understates a heavy user; jjg's all-time transcript scan showed only 504 calls / $10.17 while his real cumulative was 34.3B tokens). Now also reads `_savings.json` under the index root (the per-call meter the MCP server writes on every call, survives Claude Code reinstalls) and reports lifetime tokens saved + dollar value at the selected model's INPUT rate, next to the windowed figure (shown even when the transcript window is empty). New `lifetime_meter(root=)` reader (honors CODE_INDEX_PATH; honest-None on absent/unreadable/zero); wired into `render_text` + `render_json` (`--export json` gains a `lifetime` block). Reconciles the "anemic transcript number vs big lifetime number" gap in ONE place — the fix for the Tim-presentation verifiability concern. New `TestLifetimeMeter` (9). NO INDEX_VERSION / wire change. (Related, NOT in the repo: jjg's personal `~/.claude/hooks/gsd-statusline.js` was corrected from `* 25.00` (Opus OUTPUT rate) to `* 5.00` (Opus INPUT) — input-token savings valued at input pricing; $857K→$171K, defensible.)
45
- **Version:** 1.108.131 — **`receipt`/`org-report` `--model` choices derive from the price table (Fable now selectable).** v1.108.130 added Fable to `cli/receipt.py`'s `_MODEL_PRICES_USD_PER_MTOK`, but `server.py`'s CLI dispatcher had a DUPLICATE hardcoded `--model` choices list (`{sonnet,opus,haiku}`) that gated `receipt --model fable` before it reached receipt.py's own parser (jjg hit this on the installed editable src — the code was live, the dispatcher wasn't updated). Both the `receipt` and `org-report` subparsers now build `choices=sorted(_MODEL_PRICES_USD_PER_MTOK)` from the single price table (imported locally in `main()`), so any priced model is selectable and the CLI can't drift from the rates. New parametrized end-to-end test in `tests/test_receipt.py` runs the server CLI for every priced model + asserts none rejected. NO INDEX_VERSION / tool-count / wire change.
56
- **Version:** 1.108.130 — **`receipt` CLI price table updated to current Anthropic pricing (+ Fable 5).** Anthropic has reduced input pricing across the Opus line since these models launched; the `receipt` model input-price table (`cli/receipt.py` `_MODEL_PRICES_USD_PER_MTOK`) now tracks current published rates (Opus $5 / Sonnet $3 / Haiku $1) and adds **Claude Fable 5 ($10)** as a selectable model + alternate comparison line (both derive from the table). Source comment cites the dated pricing source (anthropic.com/pricing 2026-06-24); `tests/test_receipt.py` pins the table to it (+ a Fable test). `receipt` values measured token savings at the selected model's current rate; the underlying token savings are unchanged. **Does NOT feed the public token-savings counter, which stores tokens and values them at display time.** jcm's own `storage/token_tracker.PRICING` was already at $5. Sibling parity: jdoc v1.97.0 / jdata v1.19.0 update the same constants in their `token_tracker.PRICING` (feeds per-response `_meta.cost_avoided`). NO INDEX_VERSION / tool-count / wire change. **Framing note: this tracks a vendor price REDUCTION (Anthropic cut Opus input pricing since launch) — NOT a correction of an inflated figure. Never describe it as "overstating."**
67
- **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.

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.131"
3+
version = "1.108.132"
44
description = "Token-efficient MCP server for source code exploration via tree-sitter AST parsing"
55
readme = "README.md"
66
requires-python = ">=3.10"

src/jcodemunch_mcp/cli/receipt.py

Lines changed: 70 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import datetime as _dt
2020
import io
2121
import json
22+
import os
2223
import sys
2324
from pathlib import Path
2425
from typing import Iterable, Optional
@@ -121,6 +122,33 @@ def _projects_root() -> Path:
121122
return Path.home() / ".claude" / "projects"
122123

123124

125+
def _index_root() -> Path:
126+
"""Index storage root (honors CODE_INDEX_PATH, default ~/.code-index)."""
127+
env = os.environ.get("CODE_INDEX_PATH")
128+
return Path(env) if env else Path.home() / ".code-index"
129+
130+
131+
def lifetime_meter(root: Optional[Path] = None) -> Optional[dict]:
132+
"""Read the persistent per-call savings meter (``_savings.json``).
133+
134+
This is the cumulative token savings the MCP server records on *every*
135+
tool call, stored under the index root. It survives Claude Code
136+
reinstalls (it does not live with the transcripts), so it reflects true
137+
lifetime usage even when the local transcript history the window ledger
138+
scans has been cleared. Byte-approximate estimate, like the community
139+
meter it feeds. Returns None when absent/unreadable/empty.
140+
"""
141+
path = (root or _index_root()) / "_savings.json"
142+
try:
143+
data = json.loads(path.read_text(encoding="utf-8"))
144+
except (OSError, ValueError):
145+
return None
146+
total = data.get("total_tokens_saved")
147+
if not isinstance(total, (int, float)) or total <= 0:
148+
return None
149+
return {"total_tokens_saved": int(total), "anon_id": data.get("anon_id")}
150+
151+
124152
def _result_byte_length(content) -> int:
125153
"""Return the byte length of a tool_result `content` field.
126154
@@ -286,7 +314,29 @@ def dollar_savings(savings_tokens: int, model: str) -> float:
286314
return (savings_tokens / 1_000_000.0) * rate
287315

288316

289-
def render_text(agg: dict, *, days: int, model: str, primary_only: bool = False) -> str:
317+
def _write_lifetime(out: "io.StringIO", meter: dict, model: str) -> None:
318+
"""Render the persistent lifetime-meter section."""
319+
total = meter["total_tokens_saved"]
320+
rate = _MODEL_PRICES_USD_PER_MTOK.get(
321+
model.lower(), _MODEL_PRICES_USD_PER_MTOK[_DEFAULT_MODEL]
322+
)
323+
usd = dollar_savings(total, model)
324+
out.write(" Lifetime savings (jCodeMunch meter, all-time):\n")
325+
out.write(f" Tokens saved: {total:>15,}\n")
326+
out.write(f" Value at {model.title()} pricing (${rate:.2f}/MTok input): ${usd:,.2f}\n")
327+
out.write(" Persistent per-call meter under the index root; survives\n")
328+
out.write(" Claude Code reinstalls. The windowed figure above only counts\n")
329+
out.write(" tool calls still present in local transcripts.\n\n")
330+
331+
332+
def render_text(
333+
agg: dict,
334+
*,
335+
days: int,
336+
model: str,
337+
primary_only: bool = False,
338+
meter: Optional[dict] = None,
339+
) -> str:
290340
"""Human-readable ledger output."""
291341
out = io.StringIO()
292342
totals = agg["totals"]
@@ -295,9 +345,13 @@ def render_text(agg: dict, *, days: int, model: str, primary_only: bool = False)
295345
out.write("=" * 56 + "\n\n")
296346

297347
if totals["calls"] == 0:
298-
out.write("No jcodemunch tool calls found in ~/.claude/projects/.\n")
299-
out.write("If you've been using jcodemunch, check that Claude Code is\n")
300-
out.write("writing transcripts to that directory (default behaviour).\n")
348+
out.write("No jcodemunch tool calls found in the scanned transcript\n")
349+
out.write("window (~/.claude/projects/). Local transcripts are cleared on\n")
350+
out.write("reinstall; the lifetime meter below is the durable record.\n\n")
351+
if meter:
352+
_write_lifetime(out, meter, model)
353+
else:
354+
out.write("No lifetime meter data yet (index root _savings.json).\n")
301355
return out.getvalue()
302356

303357
out.write(f" Tool calls: {totals['calls']:>12,}\n")
@@ -319,6 +373,9 @@ def render_text(agg: dict, *, days: int, model: str, primary_only: bool = False)
319373
out.write(f" ... at {other.title()} pricing (${other_rate:.2f}/MTok): ${other_dollars:,.2f}\n")
320374
out.write("\n")
321375

376+
if meter:
377+
_write_lifetime(out, meter, model)
378+
322379
if per_tool:
323380
out.write(" Top tools by savings:\n")
324381
ranked = sorted(
@@ -372,13 +429,18 @@ def render_csv(agg: dict) -> str:
372429
return out.getvalue()
373430

374431

375-
def render_json(agg: dict, *, model: str) -> str:
432+
def render_json(agg: dict, *, model: str, meter: Optional[dict] = None) -> str:
376433
payload = {
377434
"totals": agg["totals"],
378435
"per_tool": agg["per_tool"],
379436
"model": model,
380437
"savings_usd": dollar_savings(agg["totals"]["savings_tokens"], model),
381438
}
439+
if meter:
440+
payload["lifetime"] = {
441+
"tokens_saved": meter["total_tokens_saved"],
442+
"usd": dollar_savings(meter["total_tokens_saved"], model),
443+
}
382444
return json.dumps(payload, indent=2)
383445

384446

@@ -426,12 +488,13 @@ def main(argv: Optional[list[str]] = None) -> int:
426488
since = _dt.datetime.now(_dt.timezone.utc) - _dt.timedelta(days=args.days)
427489

428490
agg = aggregate(iter_calls(root, since=since))
491+
meter = lifetime_meter()
429492

430493
if args.export:
431494
target = Path(args.export)
432495
ext = target.suffix.lower()
433496
if ext == ".json":
434-
target.write_text(render_json(agg, model=args.model), encoding="utf-8")
497+
target.write_text(render_json(agg, model=args.model, meter=meter), encoding="utf-8")
435498
elif ext == ".csv":
436499
target.write_text(render_csv(agg), encoding="utf-8")
437500
else:
@@ -443,7 +506,7 @@ def main(argv: Optional[list[str]] = None) -> int:
443506
print(f"wrote {target}")
444507
return 0
445508

446-
sys.stdout.write(render_text(agg, days=args.days, model=args.model))
509+
sys.stdout.write(render_text(agg, days=args.days, model=args.model, meter=meter))
447510
return 0
448511

449512

tests/test_receipt.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
aggregate,
1818
dollar_savings,
1919
iter_calls,
20+
lifetime_meter,
2021
render_csv,
2122
render_explain,
2223
render_json,
@@ -271,6 +272,62 @@ def test_haiku_cheaper_than_sonnet(self):
271272
assert _MODEL_PRICES_USD_PER_MTOK["haiku"] < _MODEL_PRICES_USD_PER_MTOK["sonnet"]
272273

273274

275+
class TestLifetimeMeter:
276+
def test_reads_savings_file(self, tmp_path: Path):
277+
(tmp_path / "_savings.json").write_text(
278+
json.dumps({"total_tokens_saved": 34_317_586_613, "anon_id": "abc"}),
279+
encoding="utf-8",
280+
)
281+
m = lifetime_meter(root=tmp_path)
282+
assert m["total_tokens_saved"] == 34_317_586_613
283+
assert m["anon_id"] == "abc"
284+
285+
def test_missing_file_returns_none(self, tmp_path: Path):
286+
assert lifetime_meter(root=tmp_path) is None
287+
288+
def test_zero_returns_none(self, tmp_path: Path):
289+
(tmp_path / "_savings.json").write_text('{"total_tokens_saved": 0}', encoding="utf-8")
290+
assert lifetime_meter(root=tmp_path) is None
291+
292+
def test_corrupt_file_returns_none(self, tmp_path: Path):
293+
(tmp_path / "_savings.json").write_text("not json", encoding="utf-8")
294+
assert lifetime_meter(root=tmp_path) is None
295+
296+
def _agg(self):
297+
return aggregate([
298+
{"tool": "search_symbols", "result_tokens": 1000, "timestamp": "", "session_file": "x"},
299+
])
300+
301+
def test_render_text_includes_lifetime_at_input_rate(self):
302+
meter = {"total_tokens_saved": 34_317_586_613, "anon_id": "x"}
303+
out = render_text(self._agg(), days=0, model="opus", meter=meter)
304+
assert "Lifetime savings" in out
305+
assert "34,317,586,613" in out
306+
# 34.3B tokens x $5/MTok (Opus INPUT) = $171,587.93 — not the $25 output rate.
307+
assert "$171,587.93" in out
308+
309+
def test_render_text_no_meter_omits_lifetime(self):
310+
out = render_text(self._agg(), days=0, model="opus", meter=None)
311+
assert "Lifetime savings" not in out
312+
313+
def test_empty_transcripts_still_surfaces_meter(self):
314+
meter = {"total_tokens_saved": 34_317_586_613, "anon_id": "x"}
315+
out = render_text(aggregate([]), days=30, model="opus", meter=meter)
316+
assert "No jcodemunch tool calls found" in out
317+
assert "Lifetime savings" in out
318+
assert "34,317,586,613" in out
319+
320+
def test_render_json_includes_lifetime(self):
321+
meter = {"total_tokens_saved": 1_000_000, "anon_id": "x"}
322+
payload = json.loads(render_json(self._agg(), model="opus", meter=meter))
323+
assert payload["lifetime"]["tokens_saved"] == 1_000_000
324+
assert payload["lifetime"]["usd"] == pytest.approx(5.0)
325+
326+
def test_render_json_no_meter_omits_lifetime(self):
327+
payload = json.loads(render_json(self._agg(), model="opus", meter=None))
328+
assert "lifetime" not in payload
329+
330+
274331
class TestServerReceiptModelChoices:
275332
"""The `receipt`/`org-report` subparsers in server.py must derive their
276333
--model choices from the price table, not hardcode a subset. Guards the

0 commit comments

Comments
 (0)