context-doctor 0.9.0 → 0.11.0

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.
package/README.md CHANGED
@@ -88,10 +88,11 @@ Practical upshot: a developer who only wants cheaper, faster API calls never tou
88
88
  | Command | What it does |
89
89
  |---|---|
90
90
  | `context-doctor install` / `uninstall` | Wire (or remove) everything: MCP for Claude Desktop/Code/Cursor, the Agent Skill, the every-prompt hook |
91
- | `context-doctor analyze <file>` | Profile a conversation: token breakdown, findings, cost + latency estimates |
91
+ | `context-doctor analyze <file>` | Profile a conversation: token breakdown, findings, cost + latency estimates. `--fail-over-budget` exits 1 on a breach, for CI |
92
92
  | `context-doctor optimize <file>` | Apply the safe fixes; `--strategy prune-history` for consented lossy compaction |
93
- | `context-doctor session [file]` | Profile a Claude Code session transcript (defaults to your most recent; `--list` to browse) — also reads ChatGPT data exports (`conversations.json`) |
94
- | `context-doctor report` | Machine-wide impact report: exact proxy savings, hook activity, recoverable waste in recent sessions |
93
+ | `context-doctor session [file]` | Profile a Claude Code session: live context, findings, **measured tokens and prompt-cache economics**. Also reads ChatGPT data exports (`conversations.json`) |
94
+ | `context-doctor cursor [--list]` | Profile a chat from Cursor's local history (both storage formats) |
95
+ | `context-doctor report` | Machine-wide impact report (proxy savings persist across restarts): exact proxy savings, hook activity, recoverable waste in recent sessions |
95
96
  | `context-doctor proxy` | Always-on local proxy that optimizes every Anthropic/OpenAI API request in flight (`/stats` for cumulative savings) |
96
97
  | `context-doctor watch [file]` | Live monitor of a growing session/agent trace: token/cost line per change, findings as they appear |
97
98
  | `context-doctor doctor` | Self-check the whole installation — one pasteable ✓/✗ diagnosis with fixes |
@@ -245,6 +246,8 @@ const { conversation, tokensBefore, tokensAfter } = optimizeConversation(chatJso
245
246
  - **Oversized tool results** — the #1 context killer in agent loops
246
247
  - **Duplicate content** — the same doc/result pasted twice
247
248
  - **Near-duplicates** — the same doc re-pasted with different surrounding words (shingle similarity, ≥60%)
249
+ - **Repeated file reads** — the same file pulled in three or more times, every copy still in context
250
+ - **Retained error output** — stack traces and failed commands kept verbatim long after the fix landed
248
251
  - **Repeated identical tool calls** — a signal your agent forgot earlier results
249
252
  - **Base64 / binary blobs** in text content
250
253
  - **Long history** past the point where models track the middle
@@ -296,6 +299,36 @@ Drop a `.contextdoctorrc` in a project (or your home directory) and context-doct
296
299
 
297
300
  The nearest file wins (walking up from the working directory, then `~`). `analyze` and `session` print a budget verdict, the every-prompt hook uses `maxTokens` as its warning threshold and names the breach to the model, and `optimize`/`proxy` pick up the defaults when you do not pass flags.
298
301
 
302
+ ## Prompt-cache economics (Claude Code sessions)
303
+
304
+ Caching is the largest lever on LLM cost, and transcripts record exactly how it went — so `session` reports it as fact rather than estimate:
305
+
306
+ ```
307
+ Prompt cache: 95.6% of input served from cache across 1117 requests
308
+ read 558.6M · written 25.5M · uncached 2k
309
+ input cost $438.88 — caching saved $2481.89 against $2920.76 uncached (list prices)
310
+ ```
311
+
312
+ A cache read bills at ~10% of input while a write bills at ~125%, so a session that keeps invalidating its prefix can cost *more* than one with no caching at all. context-doctor warns on the two failure modes: a **low hit rate** (something early in the prompt changes every request) and **cache churn** (writes rivalling reads).
313
+
314
+ ## Enforce a budget in CI
315
+
316
+ ```bash
317
+ npx context-doctor analyze conversation.json --fail-over-budget
318
+ ```
319
+
320
+ Exits 1 when the `.contextdoctorrc` budget is breached, so a pull request can be gated on context size the same way it is gated on tests.
321
+
322
+ ## Sharing a profile safely
323
+
324
+ A profile quotes message previews and file paths, so pasting one into an issue pastes fragments of real work. `--redact` keeps every number and the finding structure but replaces content with `[redacted]` and masks paths:
325
+
326
+ ```bash
327
+ npx context-doctor session --redact
328
+ ```
329
+
330
+ `context-doctor doctor` is safe to paste as-is: it reports integration status, never conversation content.
331
+
299
332
  ## Performance: what context-doctor itself costs
300
333
 
301
334
  A tool that promises speed must be near-free. Measured overhead per touchpoint:
@@ -306,6 +339,7 @@ A tool that promises speed must be near-free. Measured overhead per touchpoint:
306
339
  | MCP server | Spawned once per app session | Tools run only when called; standing instructions cost **~110 tokens per conversation** — deliberately terse |
307
340
  | Proxy | Per API request | ~1–3ms of CPU (parse → optimize → re-serialize) against typical model latencies of hundreds of ms; responses stream through chunk-by-chunk, never buffered |
308
341
  | Skill | Loads only when relevant | ~1k tokens while active; its always-present description is ~60 tokens |
342
+ | Profiling a session | On demand, and on hook growth events | ~160ms for an 8.5MB / 1,855-message transcript (near-duplicate pairs that cannot clear the similarity bar are skipped without comparison) |
309
343
  | CLI / library | Only when you run it | Not in any hot path |
310
344
 
311
345
  Net effect is strongly negative overhead: the tokens these touchpoints save on every subsequent call dwarf what they cost.
@@ -344,6 +378,10 @@ Known gotcha: if `npm publish` fails with **`404 Not Found - PUT …/context-doc
344
378
 
345
379
  Also keep the MCP server version in `src/mcp.ts` in sync with `package.json`, and remember `dist/` is committed — run `npm run build` before committing so the CI dist-sync check passes.
346
380
 
381
+ ## Contributing
382
+
383
+ Issues and PRs welcome — see [CONTRIBUTING.md](./CONTRIBUTING.md) for the six rules that keep this tool trustworthy (no API keys, nothing leaves the machine, no silent data loss, measurements not guesses, the hot path stays cheap, tests with every change) and a list of good first issues. What is planned next lives in [ROADMAP.md](./ROADMAP.md).
384
+
347
385
  ## License
348
386
 
349
387
  MIT © [gAI Ventures](https://gai.ventures)
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Prompt-cache analysis for Claude Code sessions.
3
+ *
4
+ * Transcripts record what the API actually charged per request — including
5
+ * cache reads and cache writes — so cache behaviour can be reported as fact
6
+ * rather than estimated. This is usually the largest single lever on cost:
7
+ * a cached read bills at ~10% of input, while a cache write bills at ~125%,
8
+ * so a session that keeps invalidating its prefix pays more than one with no
9
+ * caching at all.
10
+ */
11
+ export interface CacheUsage {
12
+ requests: number;
13
+ cacheReadTokens: number;
14
+ cacheWriteTokens: number;
15
+ uncachedTokens: number;
16
+ /** Share of input tokens served from cache (0-1). */
17
+ hitRate: number;
18
+ model?: string;
19
+ /** What the input actually cost, at list prices. */
20
+ paidUsd?: number;
21
+ /** What the same input would have cost with no caching at all. */
22
+ uncachedUsd?: number;
23
+ /** paidUsd vs uncachedUsd — positive means caching is paying off. */
24
+ savedUsd?: number;
25
+ /** USD spent on cache writes; high values mean the prefix keeps changing. */
26
+ writeUsd?: number;
27
+ }
28
+ export declare function analyzeCacheUsage(transcriptPath: string): CacheUsage | null;
29
+ /** One-paragraph verdict for humans, or null when there is nothing to say. */
30
+ export declare function renderCacheReport(usage: CacheUsage | null): string | null;
package/dist/cache.js ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Prompt-cache analysis for Claude Code sessions.
3
+ *
4
+ * Transcripts record what the API actually charged per request — including
5
+ * cache reads and cache writes — so cache behaviour can be reported as fact
6
+ * rather than estimated. This is usually the largest single lever on cost:
7
+ * a cached read bills at ~10% of input, while a cache write bills at ~125%,
8
+ * so a session that keeps invalidating its prefix pays more than one with no
9
+ * caching at all.
10
+ */
11
+ import { readFileSync } from "node:fs";
12
+ import { pricingFor } from "./pricing.js";
13
+ /** Cache pricing multipliers relative to the base input rate. */
14
+ const CACHE_WRITE_MULTIPLIER = 1.25;
15
+ export function analyzeCacheUsage(transcriptPath) {
16
+ let raw;
17
+ try {
18
+ raw = readFileSync(transcriptPath, "utf8");
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ let requests = 0;
24
+ let cacheReadTokens = 0;
25
+ let cacheWriteTokens = 0;
26
+ let uncachedTokens = 0;
27
+ let model;
28
+ for (const line of raw.split("\n")) {
29
+ if (!line.trim())
30
+ continue;
31
+ let entry;
32
+ try {
33
+ entry = JSON.parse(line);
34
+ }
35
+ catch {
36
+ continue;
37
+ }
38
+ if (entry.type !== "assistant" || !entry.message)
39
+ continue;
40
+ const usage = entry.message.usage;
41
+ if (!usage)
42
+ continue;
43
+ requests++;
44
+ cacheReadTokens += usage.cache_read_input_tokens ?? 0;
45
+ cacheWriteTokens += usage.cache_creation_input_tokens ?? 0;
46
+ uncachedTokens += usage.input_tokens ?? 0;
47
+ if (typeof entry.message.model === "string")
48
+ model = entry.message.model;
49
+ }
50
+ if (requests === 0)
51
+ return null;
52
+ const totalInput = cacheReadTokens + cacheWriteTokens + uncachedTokens;
53
+ const usage = {
54
+ requests,
55
+ cacheReadTokens,
56
+ cacheWriteTokens,
57
+ uncachedTokens,
58
+ hitRate: totalInput > 0 ? cacheReadTokens / totalInput : 0,
59
+ model,
60
+ };
61
+ const pricing = pricingFor(model);
62
+ if (pricing) {
63
+ const perM = (tokens, rate) => (tokens / 1_000_000) * rate;
64
+ const readUsd = perM(cacheReadTokens, pricing.cacheReadPerM);
65
+ const writeUsd = perM(cacheWriteTokens, pricing.inputPerM * CACHE_WRITE_MULTIPLIER);
66
+ const freshUsd = perM(uncachedTokens, pricing.inputPerM);
67
+ usage.paidUsd = readUsd + writeUsd + freshUsd;
68
+ usage.uncachedUsd = perM(totalInput, pricing.inputPerM);
69
+ usage.savedUsd = usage.uncachedUsd - usage.paidUsd;
70
+ usage.writeUsd = writeUsd;
71
+ }
72
+ return usage;
73
+ }
74
+ /** One-paragraph verdict for humans, or null when there is nothing to say. */
75
+ export function renderCacheReport(usage) {
76
+ if (!usage)
77
+ return null;
78
+ const pct = (usage.hitRate * 100).toFixed(1);
79
+ const lines = [];
80
+ const fmt = (n) => (n >= 1 ? `$${n.toFixed(2)}` : `$${n.toFixed(3)}`);
81
+ const tok = (n) => n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : String(n);
82
+ lines.push(`Prompt cache: ${pct}% of input served from cache across ${usage.requests} requests`);
83
+ lines.push(` read ${tok(usage.cacheReadTokens)} · written ${tok(usage.cacheWriteTokens)} · uncached ${tok(usage.uncachedTokens)}`);
84
+ if (usage.paidUsd !== undefined && usage.uncachedUsd !== undefined && usage.savedUsd !== undefined) {
85
+ lines.push(` input cost ${fmt(usage.paidUsd)} — caching saved ${fmt(usage.savedUsd)} against ${fmt(usage.uncachedUsd)} uncached (list prices)`);
86
+ }
87
+ // A cache write costs 1.25x input while a read costs 0.1x, so writes that
88
+ // rival reads mean the prefix keeps changing and caching is losing money.
89
+ const writeShare = usage.cacheReadTokens > 0 ? usage.cacheWriteTokens / usage.cacheReadTokens : Infinity;
90
+ if (usage.hitRate < 0.5 && usage.requests > 5) {
91
+ lines.push(" ⚠ Low hit rate: something early in the prompt changes between requests. Keep the system prompt, tool list and reference docs byte-stable and put volatile content last.");
92
+ }
93
+ else if (writeShare > 0.5) {
94
+ lines.push(" ⚠ Cache churn: writes are large relative to reads, and a write costs 1.25x input against 0.1x for a read. The cached prefix is being rebuilt often.");
95
+ }
96
+ return lines.join("\n");
97
+ }
package/dist/cli.js CHANGED
@@ -26,6 +26,7 @@ import { exactTokenCount } from "./exact.js";
26
26
  import { checkBudget, loadConfig } from "./config.js";
27
27
  import { startDashboard } from "./dashboard.js";
28
28
  import { listCursorChats, parseCursorChat } from "./cursor.js";
29
+ import { analyzeCacheUsage, renderCacheReport } from "./cache.js";
29
30
  const HELP = `context-doctor — profile and optimize LLM context windows
30
31
 
31
32
  Usage:
@@ -63,7 +64,11 @@ Options:
63
64
  --model <name> Model name for window-size math (e.g. claude-sonnet-5, gpt-4o)
64
65
  --exact (analyze) Add an exact token count: Anthropic count-tokens API for
65
66
  Claude models (needs ANTHROPIC_API_KEY), tiktoken for GPT (if installed)
67
+ --redact Mask message previews and file paths in the report, so it can be
68
+ shared in a bug report without leaking conversation content
66
69
  --json Machine-readable output
70
+ --fail-over-budget (analyze/session) Exit 1 when the .contextdoctorrc budget is
71
+ exceeded — lets CI gate a pull request on context size
67
72
  --out <file> (optimize) Write result to file instead of stdout
68
73
  --strategy <id> (optimize) Strategy to run; repeatable.
69
74
  Available: dedupe, trim-tool-results, strip-base64, prune-history
@@ -86,7 +91,7 @@ Examples:
86
91
  export OPENAI_BASE_URL=http://localhost:8787/v1
87
92
  `;
88
93
  function parseArgs(argv) {
89
- const args = { json: false, strategies: [], list: false, exact: false };
94
+ const args = { json: false, strategies: [], list: false, exact: false, redact: false, failOverBudget: false };
90
95
  const positional = [];
91
96
  for (let i = 0; i < argv.length; i++) {
92
97
  const a = argv[i];
@@ -104,6 +109,12 @@ function parseArgs(argv) {
104
109
  case "--exact":
105
110
  args.exact = true;
106
111
  break;
112
+ case "--redact":
113
+ args.redact = true;
114
+ break;
115
+ case "--fail-over-budget":
116
+ args.failOverBudget = true;
117
+ break;
107
118
  case "--model":
108
119
  args.model = argv[++i];
109
120
  break;
@@ -148,7 +159,7 @@ function parseArgs(argv) {
148
159
  function printBudgetStatus(profile, loaded) {
149
160
  const budget = loaded.config.budget;
150
161
  if (!budget || !loaded.path)
151
- return;
162
+ return false;
152
163
  const verdict = checkBudget(budget, profile);
153
164
  console.log("");
154
165
  if (verdict.overBudget) {
@@ -159,6 +170,14 @@ function printBudgetStatus(profile, loaded) {
159
170
  else {
160
171
  console.log(`Within budget (${loaded.path}).`);
161
172
  }
173
+ return verdict.overBudget;
174
+ }
175
+ /** Exit 1 when the caller asked CI to fail on a breach. */
176
+ function applyBudgetGate(overBudget, failOverBudget) {
177
+ if (overBudget && failOverBudget) {
178
+ console.error("context-doctor: over budget (--fail-over-budget)");
179
+ process.exitCode = 1;
180
+ }
162
181
  }
163
182
  function readInput(file) {
164
183
  if (file === "-")
@@ -214,7 +233,7 @@ function main() {
214
233
  }
215
234
  else {
216
235
  console.log(`Cursor chat: ${chat.title ?? "(untitled)"}\nId: ${chat.composerId}\n`);
217
- console.log(renderProfile(profile));
236
+ console.log(renderProfile(profile, { redact: args.redact }));
218
237
  if (parsed.reportedInputTokens) {
219
238
  console.log("");
220
239
  console.log(`Measured context (reported by the API on the last request): ${parsed.reportedInputTokens} tokens.\n` +
@@ -261,7 +280,7 @@ function main() {
261
280
  console.log(`Note: ${parsed.compactedAway} earlier message(s) were compacted away and are NOT counted below — this is the live context the model still sees.`);
262
281
  }
263
282
  console.log("");
264
- console.log(renderProfile(profile));
283
+ console.log(renderProfile(profile, { redact: args.redact }));
265
284
  if (parsed.reportedInputTokens) {
266
285
  console.log("");
267
286
  console.log(`Measured context (reported by the API on the last request): ${parsed.reportedInputTokens} tokens.\n` +
@@ -269,7 +288,12 @@ function main() {
269
288
  "transcript does not record — so it is larger than the breakdown above, which covers\n" +
270
289
  "conversation messages only. Findings and savings apply to the messages.");
271
290
  }
272
- printBudgetStatus(profile, loadConfig(process.cwd(), (m) => console.error(`context-doctor: ${m}`)));
291
+ const cache = renderCacheReport(analyzeCacheUsage(path));
292
+ if (cache) {
293
+ console.log("");
294
+ console.log(cache);
295
+ }
296
+ applyBudgetGate(printBudgetStatus(profile, loadConfig(process.cwd(), (m) => console.error(`context-doctor: ${m}`))), args.failOverBudget);
273
297
  }
274
298
  return;
275
299
  }
@@ -320,9 +344,11 @@ function main() {
320
344
  if (args.command === "analyze") {
321
345
  const loaded = loadConfig(process.cwd(), (m) => console.error(`context-doctor: ${m}`));
322
346
  const profile = profileConversation(parseConversation(input), args.model ?? loaded.config.model);
323
- console.log(args.json ? JSON.stringify(profile, null, 2) : renderProfile(profile));
347
+ console.log(args.json ? JSON.stringify(profile, null, 2) : renderProfile(profile, { redact: args.redact }));
324
348
  if (!args.json)
325
- printBudgetStatus(profile, loaded);
349
+ applyBudgetGate(printBudgetStatus(profile, loaded), args.failOverBudget);
350
+ else
351
+ applyBudgetGate(checkBudget(loaded.config.budget, profile).overBudget, args.failOverBudget);
326
352
  if (args.exact) {
327
353
  void exactTokenCount(input, args.model).then((exact) => {
328
354
  if (exact.tokens !== undefined) {
package/dist/impact.js CHANGED
@@ -68,17 +68,24 @@ export async function buildImpactReport(proxyPort = 8787) {
68
68
  optimizeUsd += inputCostUsd(e.saved, pricing);
69
69
  }
70
70
  const proxy = await fetchProxyStats(proxyPort);
71
- const proxySaved = proxy?.tokensSaved ?? 0;
71
+ // Persisted checkpoints cover proxy runs that have since exited; the live
72
+ // process reports whatever it has not checkpointed yet.
73
+ const proxyEvents = ledger.filter((e) => e.ev === "proxy");
74
+ const proxyHistoric = proxyEvents.reduce((s, e) => s + (e.saved ?? 0), 0);
75
+ const proxySaved = proxyHistoric + (proxy?.tokensSaved ?? 0);
72
76
  // -- Headline: what context-doctor has saved ----------------------------------
73
77
  const totalSaved = proxySaved + optimizeSaved + totalReduction;
74
78
  lines.push("Tokens context-doctor saved (measured)");
75
79
  lines.push("─".repeat(56));
76
80
  lines.push(`TOTAL: ~${formatTokens(totalSaved)} tokens`);
77
81
  if (proxy) {
78
- lines.push(` · proxy (exact, current run): ${formatTokens(proxySaved)} across ${proxy.optimizedRequests}/${proxy.requests} requests ${formatUsd(proxy.estUsdSaved)}`);
82
+ lines.push(` · proxy (exact): ${formatTokens(proxySaved)} ${formatTokens(proxyHistoric)} from earlier runs, ` +
83
+ `${formatTokens(proxy.tokensSaved)} live across ${proxy.optimizedRequests}/${proxy.requests} requests ≈ ${formatUsd(proxy.estUsdSaved)}`);
79
84
  }
80
85
  else {
81
- lines.push(` · proxy: not running on :${proxyPort} (its exact savings appear here while it runs)`);
86
+ lines.push(proxyHistoric > 0
87
+ ? ` · proxy (exact, from ${proxyEvents.length} earlier run checkpoint(s)): ${formatTokens(proxyHistoric)} — not running now`
88
+ : ` · proxy: not running on :${proxyPort} (its exact savings appear here once it runs)`);
82
89
  }
83
90
  const familyNote = [...savedByFamily.entries()]
84
91
  .filter(([, v]) => v > 0)
package/dist/ledger.d.ts CHANGED
@@ -7,14 +7,17 @@
7
7
  * check — hook deep-parsed a session {ev?: undefined|"check", sid, tok, warn}
8
8
  * (pre-0.3.6 hook entries have no `ev` field; treated as checks)
9
9
  * optimize — an optimization was applied {ev: "optimize", src: "cli"|"mcp", saved, model?}
10
+ * proxy — proxy savings checkpoint {ev: "proxy", saved, usd?, requests?}
10
11
  */
11
12
  export interface LedgerEntry {
12
13
  ts: number;
13
- ev?: "check" | "optimize";
14
+ ev?: "check" | "optimize" | "proxy";
14
15
  sid?: string;
15
16
  tok?: number;
16
17
  warn?: boolean;
17
- src?: "cli" | "mcp";
18
+ src?: "cli" | "mcp" | "proxy";
19
+ usd?: number;
20
+ requests?: number;
18
21
  saved?: number;
19
22
  model?: string;
20
23
  }
package/dist/ledger.js CHANGED
@@ -7,6 +7,7 @@
7
7
  * check — hook deep-parsed a session {ev?: undefined|"check", sid, tok, warn}
8
8
  * (pre-0.3.6 hook entries have no `ev` field; treated as checks)
9
9
  * optimize — an optimization was applied {ev: "optimize", src: "cli"|"mcp", saved, model?}
10
+ * proxy — proxy savings checkpoint {ev: "proxy", saved, usd?, requests?}
10
11
  */
11
12
  import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
12
13
  import { homedir } from "node:os";
package/dist/mcp.js CHANGED
@@ -37,7 +37,7 @@ const STRATEGY_IDS = ["dedupe", "trim-tool-results", "strip-base64", "prune-hist
37
37
  * recommended pattern.
38
38
  */
39
39
  function createServer() {
40
- const server = new McpServer({ name: "context-doctor", version: "0.9.0" }, { instructions: SERVER_INSTRUCTIONS });
40
+ const server = new McpServer({ name: "context-doctor", version: "0.11.0" }, { instructions: SERVER_INSTRUCTIONS });
41
41
  server.tool("profile_context", "Profile an LLM conversation or prompt: token breakdown by category, largest messages, and actionable findings about wasted context (duplicates, oversized tool results, base64 blobs, cache-unfriendly ordering). Accepts OpenAI/Anthropic conversation JSON or raw text. Call this immediately whenever the user asks about token usage, context size, LLM cost, or latency — and proactively offer it once a conversation grows long or accumulates large pasted content.", {
42
42
  conversation: z.string().describe("Conversation JSON (OpenAI or Anthropic format, or bare message array) or raw prompt text"),
43
43
  model: z.string().optional().describe("Target model name for context-window math, e.g. claude-sonnet-5 or gpt-4o"),
package/dist/profile.d.ts CHANGED
@@ -12,7 +12,7 @@ export interface MessageProfile {
12
12
  preview: string;
13
13
  toolName?: string;
14
14
  }
15
- export type FindingId = "large_tool_result" | "duplicate_content" | "near_duplicate" | "repeated_tool_call" | "base64_blob" | "long_history" | "large_system_prompt" | "cache_ordering" | "near_window_limit";
15
+ export type FindingId = "large_tool_result" | "duplicate_content" | "near_duplicate" | "repeated_tool_call" | "repeated_file_read" | "retained_error_output" | "base64_blob" | "long_history" | "large_system_prompt" | "cache_ordering" | "near_window_limit";
16
16
  export interface Finding {
17
17
  id: FindingId;
18
18
  severity: "info" | "warn" | "high";
package/dist/profile.js CHANGED
@@ -84,6 +84,8 @@ function sampledShingles(text) {
84
84
  }
85
85
  return out;
86
86
  }
87
+ /** Similarity at or above which two messages count as near-duplicates. */
88
+ const SIMILARITY_THRESHOLD = 0.6;
87
89
  function jaccard(a, b) {
88
90
  if (a.size === 0 || b.size === 0)
89
91
  return 0;
@@ -149,6 +151,11 @@ export function profileConversation(conv, model) {
149
151
  .sort((a, b) => b.tokens - a.tokens)
150
152
  .slice(0, 150);
151
153
  const shingleSets = candidates.map((p) => sampledShingles(p.msg.text));
154
+ // Jaccard has a hard ceiling of |smaller| / |larger|: two shingle sets of
155
+ // very different sizes CANNOT reach the threshold, so those pairs are
156
+ // skipped without intersecting them. Exact, not heuristic — it changes
157
+ // runtime, never results.
158
+ const sizes = shingleSets.map((set) => set.size);
152
159
  const exactDup = new Set(findings.filter((f) => f.id === "duplicate_content").flatMap((f) => f.messages));
153
160
  for (let i = 0; i < candidates.length; i++) {
154
161
  for (let j = i + 1; j < candidates.length; j++) {
@@ -156,8 +163,12 @@ export function profileConversation(conv, model) {
156
163
  const b = candidates[j];
157
164
  if (exactDup.has(a.msg.index) && exactDup.has(b.msg.index))
158
165
  continue; // already flagged exactly
166
+ const small = Math.min(sizes[i], sizes[j]);
167
+ const large = Math.max(sizes[i], sizes[j]);
168
+ if (large === 0 || small / large < SIMILARITY_THRESHOLD)
169
+ continue; // cannot clear the bar
159
170
  const sim = jaccard(shingleSets[i], shingleSets[j]);
160
- if (sim >= 0.6) {
171
+ if (sim >= SIMILARITY_THRESHOLD) {
161
172
  const smaller = Math.min(a.tokens, b.tokens);
162
173
  const [first, second] = a.msg.index <= b.msg.index ? [a, b] : [b, a];
163
174
  findings.push({
@@ -194,6 +205,58 @@ export function profileConversation(conv, model) {
194
205
  });
195
206
  }
196
207
  }
208
+ // -- Same file read again and again -----------------------------------------
209
+ // The dominant waste in agent loops: a file re-read because its earlier
210
+ // contents scrolled out of attention, leaving several full copies in context.
211
+ {
212
+ const readsByPath = new Map();
213
+ for (const p of perMessage) {
214
+ if (p.msg.kind !== "tool_call" || !p.msg.toolCallText)
215
+ continue;
216
+ if (!/read|open|cat|view|get_file/i.test(p.msg.toolName ?? ""))
217
+ continue;
218
+ const match = /"(?:file_path|filePath|path|file)"\s*:\s*"([^"]{3,})"/.exec(p.msg.toolCallText);
219
+ if (!match)
220
+ continue;
221
+ const path = match[1];
222
+ readsByPath.set(path, [...(readsByPath.get(path) ?? []), p.msg.index]);
223
+ }
224
+ for (const [path, indexes] of readsByPath) {
225
+ if (indexes.length < 3)
226
+ continue;
227
+ // Each re-read pulls the file in again; all but the last are recoverable.
228
+ const resultTokens = perMessage
229
+ .filter((p) => p.msg.kind === "tool_result" && indexes.some((i) => p.msg.index === i + 1))
230
+ .reduce((sum, p) => sum + p.tokens, 0);
231
+ const savings = Math.round((resultTokens * (indexes.length - 1)) / indexes.length);
232
+ findings.push({
233
+ id: "repeated_file_read",
234
+ severity: savings > 4000 ? "high" : "warn",
235
+ estSavings: savings,
236
+ message: `${path.split("/").pop()} was read ${indexes.length} times (messages #${indexes.join(", #")}), keeping ${indexes.length} copies in context.`,
237
+ suggestion: "Re-read a file only after it changes; otherwise refer back to the copy already in context.",
238
+ messages: indexes,
239
+ });
240
+ }
241
+ }
242
+ // -- Failed tool output kept verbatim ---------------------------------------
243
+ // Stack traces and command failures are read once and never again, yet they
244
+ // are often the largest blocks in an agent transcript.
245
+ for (const p of perMessage) {
246
+ if (p.msg.kind !== "tool_result" || p.tokens < 500)
247
+ continue;
248
+ const looksFailed = /Traceback \(most recent call last\)|command not found|npm error|ERR!|\bexit code [1-9]|Exception in thread|FAILED|error TS\d+/i.test(p.msg.text);
249
+ if (!looksFailed)
250
+ continue;
251
+ findings.push({
252
+ id: "retained_error_output",
253
+ severity: "warn",
254
+ estSavings: Math.round(p.tokens * 0.85),
255
+ message: `Message #${p.msg.index} holds ~${p.tokens} tokens of failed tool output (errors, stack trace or non-zero exit).`,
256
+ suggestion: "Keep the one line that identifies the failure and drop the rest — a full trace has no value once the fix is understood.",
257
+ messages: [p.msg.index],
258
+ });
259
+ }
197
260
  // -- Base64 / binary blobs ---------------------------------------------------
198
261
  for (const p of perMessage) {
199
262
  if (BASE64_RE.test(p.msg.text)) {
package/dist/proxy.js CHANGED
@@ -15,6 +15,7 @@ import http from "node:http";
15
15
  import { optimizeConversation } from "./optimize.js";
16
16
  import { formatTokens } from "./tokens.js";
17
17
  import { formatUsd, inputCostUsd, pricingFor } from "./pricing.js";
18
+ import { recordLedger } from "./ledger.js";
18
19
  /** Connection-level headers that must not be forwarded. */
19
20
  const SKIP_REQUEST_HEADERS = new Set(["host", "content-length", "connection", "transfer-encoding", "accept-encoding", "expect"]);
20
21
  const SKIP_RESPONSE_HEADERS = new Set(["content-length", "content-encoding", "transfer-encoding", "connection"]);
@@ -208,6 +209,36 @@ export function startProxy(opts = {}) {
208
209
  res.end(JSON.stringify({ error: `context-doctor proxy: ${e.message}` }));
209
210
  }
210
211
  });
212
+ // Savings live in memory, so a restart would erase the record the dashboard
213
+ // and report draw on. Checkpoint the delta to the ledger periodically and on
214
+ // shutdown, so the history survives the process.
215
+ let checkpointedTokens = 0;
216
+ let checkpointedUsd = 0;
217
+ let checkpointedRequests = 0;
218
+ const checkpoint = () => {
219
+ const savedDelta = stats.tokensSaved - checkpointedTokens;
220
+ if (savedDelta <= 0)
221
+ return;
222
+ recordLedger({
223
+ ev: "proxy",
224
+ src: "proxy",
225
+ saved: savedDelta,
226
+ usd: Number((stats.estUsdSaved - checkpointedUsd).toFixed(6)),
227
+ requests: stats.optimizedRequests - checkpointedRequests,
228
+ });
229
+ checkpointedTokens = stats.tokensSaved;
230
+ checkpointedUsd = stats.estUsdSaved;
231
+ checkpointedRequests = stats.optimizedRequests;
232
+ };
233
+ const checkpointTimer = setInterval(checkpoint, 60_000);
234
+ checkpointTimer.unref?.(); // never hold the process open on our account
235
+ for (const signal of ["SIGINT", "SIGTERM"]) {
236
+ process.once(signal, () => {
237
+ checkpoint();
238
+ process.exit(0);
239
+ });
240
+ }
241
+ server.on("close", checkpoint);
211
242
  const host = opts.host ?? "127.0.0.1";
212
243
  server.listen(port, host, () => {
213
244
  console.error(`context-doctor proxy listening on http://${host}:${port}`);
package/dist/report.d.ts CHANGED
@@ -4,4 +4,12 @@
4
4
  * anywhere (terminals, issues, chat).
5
5
  */
6
6
  import { ContextProfile } from "./profile.js";
7
- export declare function renderProfile(profile: ContextProfile): string;
7
+ export interface RenderOptions {
8
+ /**
9
+ * Replace anything quoted from the conversation with a placeholder, so a
10
+ * profile can be pasted into a bug report without leaking content. Numbers
11
+ * and structure — the parts that make a report useful — are kept.
12
+ */
13
+ redact?: boolean;
14
+ }
15
+ export declare function renderProfile(profile: ContextProfile, options?: RenderOptions): string;
package/dist/report.js CHANGED
@@ -18,7 +18,13 @@ function bar(fraction, width = 28) {
18
18
  const filled = Math.round(fraction * width);
19
19
  return "█".repeat(filled) + "░".repeat(width - filled);
20
20
  }
21
- export function renderProfile(profile) {
21
+ /** Mask filesystem paths and quoted fragments inside a finding's text. */
22
+ function redactText(text) {
23
+ return text
24
+ .replace(/(?:\/[\w.@ -]+){2,}/g, "[path]")
25
+ .replace(/\b[\w.-]+\.(ts|tsx|js|jsx|py|go|rs|java|rb|md|json|ya?ml|sql|sh)\b/gi, "[file]");
26
+ }
27
+ export function renderProfile(profile, options = {}) {
22
28
  const lines = [];
23
29
  const p = profile;
24
30
  lines.push("CONTEXT DOCTOR — profile");
@@ -52,7 +58,9 @@ export function renderProfile(profile) {
52
58
  lines.push("─".repeat(56));
53
59
  for (const m of p.largestMessages) {
54
60
  const label = m.toolName ? `${m.kind}:${m.toolName}` : m.kind;
55
- lines.push(` #${m.index} [${label}] ~${formatTokens(m.tokens)} ${m.preview}`);
61
+ // The preview is the only place raw conversation text reaches the report.
62
+ const body = options.redact ? "[redacted]" : m.preview;
63
+ lines.push(` #${m.index} [${label}] ~${formatTokens(m.tokens)} ${body}`);
56
64
  }
57
65
  lines.push("");
58
66
  // Findings
@@ -61,7 +69,7 @@ export function renderProfile(profile) {
61
69
  lines.push("─".repeat(56));
62
70
  for (const f of p.findings) {
63
71
  const savings = f.estSavings > 0 ? ` [save ~${formatTokens(f.estSavings)}]` : "";
64
- lines.push(`${SEVERITY_MARK[f.severity]} ${f.message}${savings}`);
72
+ lines.push(`${SEVERITY_MARK[f.severity]} ${options.redact ? redactText(f.message) : f.message}${savings}`);
65
73
  lines.push(` → ${f.suggestion}`);
66
74
  }
67
75
  lines.push("");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-doctor",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "Profile and optimize LLM context windows. See what's eating your tokens and fix it — works with Claude, GPT, Gemini, and any MCP-capable AI app.",
5
5
  "keywords": [
6
6
  "llm",
@@ -42,7 +42,7 @@
42
42
  "build": "tsc && node -e \"const fs=require('fs');['dist/cli.js','dist/mcp.js'].forEach(f=>fs.chmodSync(f,0o755))\"",
43
43
  "prepublishOnly": "npm run build",
44
44
  "dev": "tsc --watch",
45
- "test": "npm run build && node --test dist/test/smoke.test.js dist/test/proxy.test.js dist/test/hook.test.js dist/test/mcp-http.test.js dist/test/doctor.test.js dist/test/watch.test.js dist/test/chatgpt-export.test.js dist/test/config.test.js dist/test/dashboard.test.js dist/test/cursor.test.js"
45
+ "test": "npm run build && node --test dist/test/smoke.test.js dist/test/proxy.test.js dist/test/hook.test.js dist/test/mcp-http.test.js dist/test/doctor.test.js dist/test/watch.test.js dist/test/chatgpt-export.test.js dist/test/config.test.js dist/test/dashboard.test.js dist/test/cursor.test.js dist/test/cache.test.js"
46
46
  },
47
47
  "dependencies": {
48
48
  "@modelcontextprotocol/sdk": "^1.0.0",