Skip to content

Commit 68e1adf

Browse files
jgravelleclaude
andcommitted
Add real-time token savings counter to all tool responses
Adds a lightweight persistent token savings tracker (~/.code-index/_savings.json) that records cumulative tokens saved across sessions. Every tool response now includes tokens_saved (this call) and total_tokens_saved (running total) in _meta. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent bf52bad commit 68e1adf

7 files changed

Lines changed: 145 additions & 7 deletions

File tree

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 = "0.2.3"
3+
version = "0.2.4"
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: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Storage package for index save/load operations."""
22

33
from .index_store import CodeIndex, IndexStore, INDEX_VERSION
4+
from .token_tracker import record_savings, get_total_saved, estimate_savings
45

5-
__all__ = ["CodeIndex", "IndexStore", "INDEX_VERSION"]
6+
__all__ = ["CodeIndex", "IndexStore", "INDEX_VERSION",
7+
"record_savings", "get_total_saved", "estimate_savings"]
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""Persistent token savings tracker.
2+
3+
Records cumulative tokens saved across all tool calls by comparing
4+
raw file sizes against actual MCP response sizes.
5+
6+
Stored in ~/.code-index/_savings.json — a single small JSON file.
7+
No API calls, no file reads — only os.stat for file sizes.
8+
"""
9+
10+
import json
11+
import os
12+
from pathlib import Path
13+
from typing import Optional
14+
15+
16+
_SAVINGS_FILE = "_savings.json"
17+
_BYTES_PER_TOKEN = 4 # ~4 bytes per token (rough but consistent)
18+
19+
20+
def _savings_path(base_path: Optional[str] = None) -> Path:
21+
root = Path(base_path) if base_path else Path.home() / ".code-index"
22+
root.mkdir(parents=True, exist_ok=True)
23+
return root / _SAVINGS_FILE
24+
25+
26+
def record_savings(tokens_saved: int, base_path: Optional[str] = None) -> int:
27+
"""Add tokens_saved to the running total. Returns new cumulative total."""
28+
path = _savings_path(base_path)
29+
try:
30+
data = json.loads(path.read_text()) if path.exists() else {}
31+
except Exception:
32+
data = {}
33+
34+
total = data.get("total_tokens_saved", 0) + max(0, tokens_saved)
35+
data["total_tokens_saved"] = total
36+
37+
try:
38+
path.write_text(json.dumps(data))
39+
except Exception:
40+
pass
41+
42+
return total
43+
44+
45+
def get_total_saved(base_path: Optional[str] = None) -> int:
46+
"""Return the current cumulative total without modifying it."""
47+
path = _savings_path(base_path)
48+
try:
49+
return json.loads(path.read_text()).get("total_tokens_saved", 0)
50+
except Exception:
51+
return 0
52+
53+
54+
def estimate_savings(raw_bytes: int, response_bytes: int) -> int:
55+
"""Estimate tokens saved: (raw - response) / bytes_per_token."""
56+
return max(0, (raw_bytes - response_bytes) // _BYTES_PER_TOKEN)

src/jcodemunch_mcp/tools/get_file_outline.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
"""Get file outline - symbols in a specific file."""
22

3+
import os
34
import time
45
from typing import Optional
56

6-
from ..storage import IndexStore
7+
from ..storage import IndexStore, record_savings, estimate_savings
78
from ..parser import build_symbol_tree
89

910

@@ -66,6 +67,17 @@ def get_file_outline(
6667

6768
elapsed = (time.perf_counter() - start) * 1000
6869

70+
# Token savings: raw file size vs outline response size
71+
raw_bytes = 0
72+
try:
73+
raw_file = store._content_dir(owner, name) / file_path
74+
raw_bytes = os.path.getsize(raw_file)
75+
except OSError:
76+
pass
77+
response_bytes = sum(s.get("byte_length", 0) for s in file_symbols)
78+
tokens_saved = estimate_savings(raw_bytes, response_bytes)
79+
total_saved = record_savings(tokens_saved)
80+
6981
return {
7082
"repo": f"{owner}/{name}",
7183
"file": file_path,
@@ -74,6 +86,8 @@ def get_file_outline(
7486
"_meta": {
7587
"timing_ms": round(elapsed, 1),
7688
"symbol_count": len(symbols_output),
89+
"tokens_saved": tokens_saved,
90+
"total_tokens_saved": total_saved,
7791
},
7892
}
7993

src/jcodemunch_mcp/tools/get_repo_outline.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from collections import Counter
66
from typing import Optional
77

8-
from ..storage import IndexStore
8+
from ..storage import IndexStore, record_savings, estimate_savings
99

1010

1111
def get_repo_outline(
@@ -57,6 +57,17 @@ def get_repo_outline(
5757
for sym in index.symbols:
5858
kind_counts[sym.get("kind", "unknown")] += 1
5959

60+
# Token savings: sum of all raw file sizes (user would need to read all files)
61+
raw_bytes = 0
62+
content_dir = store._content_dir(owner, name)
63+
for f in index.source_files:
64+
try:
65+
raw_bytes += os.path.getsize(content_dir / f)
66+
except OSError:
67+
pass
68+
tokens_saved = estimate_savings(raw_bytes, 0)
69+
total_saved = record_savings(tokens_saved)
70+
6071
elapsed = (time.perf_counter() - start) * 1000
6172

6273
return {
@@ -69,5 +80,7 @@ def get_repo_outline(
6980
"symbol_kinds": dict(kind_counts.most_common()),
7081
"_meta": {
7182
"timing_ms": round(elapsed, 1),
83+
"tokens_saved": tokens_saved,
84+
"total_tokens_saved": total_saved,
7285
},
7386
}

src/jcodemunch_mcp/tools/get_symbol.py

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
"""Get symbol source code."""
22

33
import hashlib
4+
import os
45
import time
56
from typing import Optional
67

7-
from ..storage import IndexStore
8+
from ..storage import IndexStore, record_savings, estimate_savings
89

910

1011
def _make_meta(timing_ms: float, **kwargs) -> dict:
@@ -93,6 +94,18 @@ def get_symbol(
9394
stored_hash = symbol.get("content_hash", "")
9495
meta["content_verified"] = actual_hash == stored_hash if stored_hash else None
9596

97+
# Token savings: raw file size vs symbol byte length
98+
raw_bytes = 0
99+
try:
100+
raw_file = store._content_dir(owner, name) / symbol["file"]
101+
raw_bytes = os.path.getsize(raw_file)
102+
except OSError:
103+
pass
104+
tokens_saved = estimate_savings(raw_bytes, symbol.get("byte_length", 0))
105+
total_saved = record_savings(tokens_saved)
106+
meta["tokens_saved"] = tokens_saved
107+
meta["total_tokens_saved"] = total_saved
108+
96109
elapsed = (time.perf_counter() - start) * 1000
97110

98111
result = {
@@ -172,10 +185,30 @@ def get_symbols(
172185
"source": source or ""
173186
})
174187

188+
# Token savings: unique file sizes vs sum of symbol byte_lengths
189+
raw_bytes = 0
190+
seen_files: set = set()
191+
response_bytes = 0
192+
for symbol_id in symbol_ids:
193+
symbol = index.get_symbol(symbol_id)
194+
if not symbol:
195+
continue
196+
f = symbol["file"]
197+
if f not in seen_files:
198+
seen_files.add(f)
199+
try:
200+
raw_bytes += os.path.getsize(store._content_dir(owner, name) / f)
201+
except OSError:
202+
pass
203+
response_bytes += symbol.get("byte_length", 0)
204+
tokens_saved = estimate_savings(raw_bytes, response_bytes)
205+
total_saved = record_savings(tokens_saved)
206+
175207
elapsed = (time.perf_counter() - start) * 1000
176208

177209
return {
178210
"symbols": symbols,
179211
"errors": errors,
180-
"_meta": _make_meta(elapsed, symbol_count=len(symbols)),
212+
"_meta": _make_meta(elapsed, symbol_count=len(symbols),
213+
tokens_saved=tokens_saved, total_tokens_saved=total_saved),
181214
}

src/jcodemunch_mcp/tools/search_symbols.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
"""Search symbols across repository."""
22

3+
import os
34
import time
45
from typing import Optional
56

6-
from ..storage import IndexStore, CodeIndex
7+
from ..storage import IndexStore, CodeIndex, record_savings, estimate_savings
78

89

910
def search_symbols(
@@ -74,6 +75,23 @@ def search_symbols(
7475
"score": score
7576
})
7677

78+
# Token savings: files containing matches vs symbol byte_lengths of results
79+
raw_bytes = 0
80+
seen_files: set = set()
81+
response_bytes = 0
82+
content_dir = store._content_dir(owner, name)
83+
for sym in results[:max_results]:
84+
f = sym["file"]
85+
if f not in seen_files:
86+
seen_files.add(f)
87+
try:
88+
raw_bytes += os.path.getsize(content_dir / f)
89+
except OSError:
90+
pass
91+
response_bytes += sym.get("byte_length", 0)
92+
tokens_saved = estimate_savings(raw_bytes, response_bytes)
93+
total_saved = record_savings(tokens_saved)
94+
7795
elapsed = (time.perf_counter() - start) * 1000
7896

7997
return {
@@ -85,6 +103,8 @@ def search_symbols(
85103
"timing_ms": round(elapsed, 1),
86104
"total_symbols": len(index.symbols),
87105
"truncated": len(results) > max_results,
106+
"tokens_saved": tokens_saved,
107+
"total_tokens_saved": total_saved,
88108
},
89109
}
90110

0 commit comments

Comments
 (0)