context-doctor 0.12.1 → 0.13.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
@@ -91,6 +91,9 @@ Practical upshot: a developer who only wants cheaper, faster API calls never tou
91
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; add `--strategy trim-tool-calls` for big inline file writes, `--strategy prune-history` for consented lossy compaction |
93
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 init [preset]` | Write a `.contextdoctorrc` from a preset (`chat`, `agent`, `batch`) — a budget you can adopt in one command and tune later |
95
+ | `context-doctor diff <before> <after>` | Compare two profiles: what moved by category, which findings were resolved or introduced, and what it saves in money and latency |
96
+ | `context-doctor accuracy` | How much of what you are billed for is visible in your transcript — the fixed harness baseline and the per-turn injected content neither you nor the profiler can see |
94
97
  | `context-doctor cursor [--list]` | Profile a chat from Cursor's local history (both storage formats) |
95
98
  | `context-doctor report` | Machine-wide impact report (proxy savings persist across restarts): exact proxy savings, hook activity, recoverable waste in recent sessions |
96
99
  | `context-doctor proxy` | Always-on local proxy that optimizes every Anthropic/OpenAI API request in flight (`/stats` for cumulative savings) |
@@ -244,12 +247,13 @@ const { conversation, tokensBefore, tokensAfter } = optimizeConversation(chatJso
244
247
  ## What it detects
245
248
 
246
249
  - **Oversized tool results** — the #1 context killer in agent loops
250
+ - **Oversized tool calls** — a `Write` or a `cat > file <<EOF` puts the whole file in context permanently. In file-heavy agent work these outweigh every tool result combined, and `--strategy trim-tool-calls` reclaims them
247
251
  - **Duplicate content** — the same doc/result pasted twice
248
252
  - **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
253
+ - **Repeated file reads** — the same file pulled in three or more times, every copy still in context. Counts shell reads too (`cat`, `head`, `tail`, `less`), which is where most of them hide in agent sessions
250
254
  - **Retained error output** — stack traces and failed commands kept verbatim long after the fix landed
251
255
  - **Repeated identical tool calls** — a signal your agent forgot earlier results
252
- - **Base64 / binary blobs** in text content
256
+ - **Base64 / binary blobs** in text content — checked by character distribution, not just alphabet, so hex digests and long identifiers are not mistaken for encoded binary
253
257
  - **Long history** past the point where models track the middle
254
258
  - **Cache-hostile ordering** — volatile content before stable content breaks prompt caching (Anthropic `cache_control`, OpenAI automatic prefix caching)
255
259
  - **Window pressure** — usage % against the target model's real context window
@@ -266,6 +270,8 @@ const { conversation, tokensBefore, tokensAfter } = optimizeConversation(chatJso
266
270
 
267
271
  `trim-tool-calls` is the big one for agent sessions. Writing a file through a tool call puts the entire file in context permanently, so in file-heavy work the calls outweigh every tool result combined — on a real 278k-token session, the default set reached 248k and adding `trim-tool-calls` reached 102k. It is opt-in because it edits what the model itself wrote.
268
272
 
273
+ **Optimization is cache-aware.** Prompt caches match a byte-identical prefix, so editing a message in the middle invalidates everything after it — and the naive "trim everything older than the last N messages" boundary moves every single turn. On a 25-turn agent conversation that invalidated the cached prefix on 22 of 24 turns, paying the 1.25x cache-write price on the whole prefix to save a few hundred tokens. The trim boundary is quantized so it holds still between steps (8 of 24 on the same fixture), while still reaching 15 of 20 tool results.
274
+
269
275
  Everything the optimizer does is inspectable: it prints exactly which messages changed and how many tokens each change saved.
270
276
 
271
277
  **Summarization without an API key:** when `prune-history` runs through the MCP tools, context-doctor hands a digest of the pruned turns back to the model that called it (the Claude/GPT already running in your app) and asks *it* to write the replacement summary — LLM-quality compaction, zero extra cost, no keys.
@@ -0,0 +1,37 @@
1
+ /**
2
+ * `context-doctor accuracy` — how much of what you are billed for is actually
3
+ * visible in your transcript.
4
+ *
5
+ * Every profile in this tool describes the CONVERSATION: the messages the
6
+ * transcript records. Your bill covers the whole request, which also carries
7
+ * the harness's system prompt, tool schemas, skills, and per-turn injected
8
+ * content that is never written to the transcript at all.
9
+ *
10
+ * Both numbers are correct; they answer different questions. This command
11
+ * measures the distance between them on your own sessions, so "why is my bill
12
+ * bigger than the profile?" has an answer with evidence behind it.
13
+ *
14
+ * WHAT THIS IS NOT: a tokenizer benchmark. It cannot be — the content behind
15
+ * the gap is unavailable to us, so the gap cannot be attributed to estimator
16
+ * drift. To measure the estimator itself, use `analyze --exact`, which counts
17
+ * the same bytes with the provider's own tokenizer.
18
+ */
19
+ export interface AccuracyReport {
20
+ sessionsScanned: number;
21
+ /** Turn-to-turn observations used. */
22
+ samples: number;
23
+ /** Median share of a turn's billed growth that the transcript accounts for. */
24
+ medianCoverage: number;
25
+ /** Median tokens per turn billed but absent from the transcript. */
26
+ medianInvisiblePerTurn: number;
27
+ /** Median fixed baseline: system prompt + tool schemas, before any turn. */
28
+ medianBaseline?: number;
29
+ }
30
+ /**
31
+ * @param limit How many recent sessions to sample.
32
+ * @param paths Explicit transcripts to measure instead of discovering them.
33
+ * Callers (and tests) that already know which files they mean
34
+ * should not have to impersonate a home directory to say so.
35
+ */
36
+ export declare function measureAccuracy(limit?: number, paths?: string[]): AccuracyReport;
37
+ export declare function renderAccuracy(report: AccuracyReport): string;
@@ -0,0 +1,124 @@
1
+ /**
2
+ * `context-doctor accuracy` — how much of what you are billed for is actually
3
+ * visible in your transcript.
4
+ *
5
+ * Every profile in this tool describes the CONVERSATION: the messages the
6
+ * transcript records. Your bill covers the whole request, which also carries
7
+ * the harness's system prompt, tool schemas, skills, and per-turn injected
8
+ * content that is never written to the transcript at all.
9
+ *
10
+ * Both numbers are correct; they answer different questions. This command
11
+ * measures the distance between them on your own sessions, so "why is my bill
12
+ * bigger than the profile?" has an answer with evidence behind it.
13
+ *
14
+ * WHAT THIS IS NOT: a tokenizer benchmark. It cannot be — the content behind
15
+ * the gap is unavailable to us, so the gap cannot be attributed to estimator
16
+ * drift. To measure the estimator itself, use `analyze --exact`, which counts
17
+ * the same bytes with the provider's own tokenizer.
18
+ */
19
+ import { estimateTokens, formatTokens, MESSAGE_OVERHEAD_TOKENS } from "./tokens.js";
20
+ import { parseConversation } from "./parse.js";
21
+ import { listSessions, parseSessionFile } from "./session.js";
22
+ function median(values) {
23
+ if (values.length === 0)
24
+ return 0;
25
+ const sorted = [...values].sort((a, b) => a - b);
26
+ const mid = Math.floor(sorted.length / 2);
27
+ return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
28
+ }
29
+ /** Below this, a turn is too small for the ratio to mean anything. */
30
+ const MIN_DELTA_TOKENS = 200;
31
+ /**
32
+ * @param limit How many recent sessions to sample.
33
+ * @param paths Explicit transcripts to measure instead of discovering them.
34
+ * Callers (and tests) that already know which files they mean
35
+ * should not have to impersonate a home directory to say so.
36
+ */
37
+ export function measureAccuracy(limit = 20, paths) {
38
+ const coverages = [];
39
+ const invisible = [];
40
+ const baselines = [];
41
+ let scanned = 0;
42
+ const targets = paths ? paths.map((path) => ({ path })) : listSessions(limit);
43
+ for (const info of targets) {
44
+ let parsed;
45
+ try {
46
+ parsed = parseSessionFile(info.path);
47
+ }
48
+ catch {
49
+ continue; // an unreadable session is not a measurement
50
+ }
51
+ const usage = parsed.usageSamples ?? [];
52
+ if (usage.length < 2)
53
+ continue;
54
+ scanned++;
55
+ // Estimate through the same parser the profiler uses, with the same
56
+ // per-message overhead — anything else would measure a different estimator.
57
+ const normalized = parseConversation(parsed.conversationJson).messages;
58
+ const estimateAt = (i) => {
59
+ const m = normalized[i];
60
+ return m ? estimateTokens(m.text) + MESSAGE_OVERHEAD_TOKENS : 0;
61
+ };
62
+ let baseline = usage[0].input;
63
+ for (let i = 0; i < usage[0].index; i++)
64
+ baseline -= estimateAt(i);
65
+ if (baseline > 0)
66
+ baselines.push(baseline);
67
+ for (let s = 1; s < usage.length; s++) {
68
+ const billed = usage[s].input - usage[s - 1].input;
69
+ if (billed < MIN_DELTA_TOKENS)
70
+ continue;
71
+ let visible = 0;
72
+ for (let i = usage[s - 1].index; i < usage[s].index; i++)
73
+ visible += estimateAt(i);
74
+ if (visible <= 0)
75
+ continue;
76
+ // A turn cannot be more than fully visible; ratios above 1 mean the
77
+ // billed figure moved for another reason (a compaction, a schema change).
78
+ const coverage = visible / billed;
79
+ if (coverage > 1)
80
+ continue;
81
+ coverages.push(coverage);
82
+ invisible.push(billed - visible);
83
+ }
84
+ }
85
+ return {
86
+ sessionsScanned: scanned,
87
+ samples: coverages.length,
88
+ medianCoverage: median(coverages),
89
+ medianInvisiblePerTurn: median(invisible),
90
+ medianBaseline: baselines.length ? median(baselines) : undefined,
91
+ };
92
+ }
93
+ export function renderAccuracy(report) {
94
+ const lines = [];
95
+ lines.push("CONTEXT DOCTOR — billed context vs what your transcript shows");
96
+ lines.push("═".repeat(56));
97
+ if (report.samples === 0) {
98
+ lines.push("No usable samples found.");
99
+ lines.push("");
100
+ lines.push("This reads input-token counts recorded in Claude Code transcripts, so it");
101
+ lines.push("needs local sessions with at least two assistant turns.");
102
+ return lines.join("\n");
103
+ }
104
+ const pct = (n) => `${(n * 100).toFixed(0)}%`;
105
+ lines.push(`${report.samples} turns across ${report.sessionsScanned} session(s).`);
106
+ lines.push("");
107
+ if (report.medianBaseline !== undefined) {
108
+ lines.push(`Fixed baseline ~${formatTokens(report.medianBaseline)} tokens before your first turn —`);
109
+ lines.push(" system prompt, tool schemas and skills, none of which the");
110
+ lines.push(" transcript stores.");
111
+ }
112
+ lines.push(`Transcript covers ${pct(report.medianCoverage)} of a typical turn's billed growth`);
113
+ lines.push(`Not in transcript ~${formatTokens(report.medianInvisiblePerTurn)} tokens per turn (injected reminders,`);
114
+ lines.push(" skill and file content, and other per-request additions)");
115
+ lines.push("");
116
+ lines.push("Both numbers are right. Profiles describe the conversation you can see and");
117
+ lines.push("act on; your bill covers the whole request. Trimming what the profile shows");
118
+ lines.push("still saves real money — it just starts from a higher floor than the profile");
119
+ lines.push("implies.");
120
+ lines.push("");
121
+ lines.push("This does NOT measure tokenizer drift: the missing content is not available");
122
+ lines.push("to compare against. For that, use `analyze --exact` (provider tokenizer).");
123
+ return lines.join("\n");
124
+ }
package/dist/cli.js CHANGED
@@ -7,7 +7,8 @@
7
7
  *
8
8
  * `-` reads from stdin, so you can pipe: `cat chat.json | context-doctor analyze -`
9
9
  */
10
- import { readFileSync, writeFileSync } from "node:fs";
10
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
11
+ import { join } from "node:path";
11
12
  import process from "node:process";
12
13
  import { parseConversation } from "./parse.js";
13
14
  import { profileConversation } from "./profile.js";
@@ -21,6 +22,9 @@ import { runHook } from "./hook.js";
21
22
  import { buildImpactReport } from "./impact.js";
22
23
  import { recordLedger } from "./ledger.js";
23
24
  import { runDoctor } from "./doctor.js";
25
+ import { measureAccuracy, renderAccuracy } from "./accuracy.js";
26
+ import { renderDiff } from "./diff.js";
27
+ import { findPreset, PRESETS, RC_FILENAME } from "./config.js";
24
28
  import { runWatch } from "./watch.js";
25
29
  import { exactTokenCount } from "./exact.js";
26
30
  import { checkBudget, loadConfig } from "./config.js";
@@ -48,6 +52,12 @@ Usage:
48
52
  MCP handshake) with one pasteable diagnosis
49
53
  context-doctor dashboard Local savings dashboard on 127.0.0.1 (--port n,
50
54
  default 8790) — charts from your own machine only
55
+ context-doctor init [preset] Write a .contextdoctorrc from a preset
56
+ (chat, agent, batch; no argument lists them)
57
+ context-doctor diff <before> <after> Compare two profiles: what moved, which findings
58
+ were resolved, and what it saves
59
+ context-doctor accuracy Measure the token heuristic against the API's own
60
+ counts recorded in your transcripts (--limit n)
51
61
  context-doctor watch [file] Live-monitor a growing session/agent trace: running
52
62
  token/cost line per change, new findings as they appear
53
63
  (--interval-ms n, default 2000)
@@ -76,6 +86,7 @@ Options:
76
86
  Default: dedupe, trim-tool-results, strip-base64 (lossless-ish set)
77
87
  --keep-recent <n> (optimize) Messages at the tail to leave untouched (default 6)
78
88
  --max-tool-tokens <n> (optimize) Token budget for trimmed tool results (default 300)
89
+ --limit <n> (accuracy) Sessions to sample (default 20)
79
90
  --port <n> (proxy) Port to listen on (default 8787)
80
91
  --host <addr> (proxy) Bind address (default 127.0.0.1; use 0.0.0.0 to expose)
81
92
  --config <file> (proxy) Per-route overrides: {"routes":[{"modelPrefix":"gpt","strategies":[...],
@@ -137,6 +148,9 @@ function parseArgs(argv) {
137
148
  case "--interval-ms":
138
149
  args.intervalMs = Number(argv[++i]);
139
150
  break;
151
+ case "--limit":
152
+ args.limit = Number(argv[++i]);
153
+ break;
140
154
  case "--host":
141
155
  args.host = argv[++i];
142
156
  break;
@@ -154,6 +168,7 @@ function parseArgs(argv) {
154
168
  }
155
169
  args.command = positional[0];
156
170
  args.file = positional[1];
171
+ args.positionals = positional.slice(1);
157
172
  return args;
158
173
  }
159
174
  /** Print budget status under a profile when a .contextdoctorrc defines one. */
@@ -203,6 +218,51 @@ function main() {
203
218
  void runDoctor();
204
219
  return;
205
220
  }
221
+ if (args.command === "init") {
222
+ const requested = args.positionals?.[0];
223
+ if (!requested || args.list) {
224
+ console.log("Presets for .contextdoctorrc — pick the one that matches your workload:\n");
225
+ for (const p of PRESETS)
226
+ console.log(` ${p.id.padEnd(7)} ${p.summary}`);
227
+ console.log("\nThen: context-doctor init <preset>");
228
+ return;
229
+ }
230
+ const preset = findPreset(requested);
231
+ if (!preset) {
232
+ console.error(`Unknown preset "${requested}". Available: ${PRESETS.map((p) => p.id).join(", ")}`);
233
+ process.exit(1);
234
+ }
235
+ const target = join(process.cwd(), RC_FILENAME);
236
+ if (existsSync(target)) {
237
+ console.error(`${target} already exists — edit it, or delete it first.`);
238
+ process.exit(1);
239
+ }
240
+ writeFileSync(target, JSON.stringify(preset.config, null, 2) + "\n");
241
+ console.log(`✓ Wrote ${target} (${preset.id}: ${preset.summary})`);
242
+ console.log(" Budgets are enforced by the every-prompt hook and reported by analyze/session.");
243
+ console.log(" Gate a pull request on it with: context-doctor analyze <file> --fail-over-budget");
244
+ return;
245
+ }
246
+ if (args.command === "diff") {
247
+ const [before, after] = args.positionals ?? [];
248
+ if (!before || !after) {
249
+ console.error("Usage: context-doctor diff <before> <after>");
250
+ process.exit(1);
251
+ }
252
+ try {
253
+ console.log(renderDiff(before, after, args.model));
254
+ }
255
+ catch (e) {
256
+ console.error(`Could not diff: ${e.message}`);
257
+ process.exit(1);
258
+ }
259
+ return;
260
+ }
261
+ if (args.command === "accuracy") {
262
+ const report = measureAccuracy(args.limit ?? 20);
263
+ console.log(args.json ? JSON.stringify(report, null, 2) : renderAccuracy(report));
264
+ return;
265
+ }
206
266
  if (args.command === "report") {
207
267
  void buildImpactReport(args.port).then((r) => console.log(r));
208
268
  return;
package/dist/config.d.ts CHANGED
@@ -61,3 +61,19 @@ export declare function checkBudget(budget: ContextBudget | undefined, profile:
61
61
  perCallUsd: number;
62
62
  };
63
63
  }): BudgetVerdict;
64
+ /**
65
+ * Starting points for `.contextdoctorrc`.
66
+ *
67
+ * An empty rc file is technically valid and completely useless: nobody knows
68
+ * what a reasonable token budget is for their kind of work until they have
69
+ * blown through one. These encode the three shapes that actually differ —
70
+ * a chat product, a coding agent, and a batch pipeline — so a budget can be
71
+ * adopted in one command and tuned later.
72
+ */
73
+ export interface Preset {
74
+ id: string;
75
+ summary: string;
76
+ config: ContextDoctorConfig;
77
+ }
78
+ export declare const PRESETS: Preset[];
79
+ export declare function findPreset(id: string): Preset | undefined;
package/dist/config.js CHANGED
@@ -71,3 +71,43 @@ export function checkBudget(budget, profile) {
71
71
  }
72
72
  return { overBudget: breaches.length > 0, breaches, maxTokens: budget.maxTokens };
73
73
  }
74
+ export const PRESETS = [
75
+ {
76
+ id: "chat",
77
+ summary: "Interactive chat product — short contexts, latency matters most",
78
+ config: {
79
+ // Chat turns are small; a context this large means history is not being
80
+ // summarized, and every extra token is felt directly as time-to-first-token.
81
+ budget: { maxTokens: 30_000, maxWindowPct: 40, maxCostPerMessageUsd: 0.15 },
82
+ strategies: ["dedupe", "strip-base64"],
83
+ keepRecent: 10,
84
+ },
85
+ },
86
+ {
87
+ id: "agent",
88
+ summary: "Coding or tool-using agent — long runs, tool output dominates",
89
+ config: {
90
+ // Agents legitimately hold a lot of context, so the budget is generous;
91
+ // the tight controls go on tool traffic, which is where the waste is.
92
+ budget: { maxTokens: 150_000, maxWindowPct: 70, maxCostPerMessageUsd: 1.5 },
93
+ strategies: ["dedupe", "trim-tool-results", "strip-base64"],
94
+ keepRecent: 6,
95
+ maxToolResultTokens: 300,
96
+ },
97
+ },
98
+ {
99
+ id: "batch",
100
+ summary: "Batch or pipeline jobs — cost per call is the whole story",
101
+ config: {
102
+ // Nothing is interactive, so aggressive trimming costs nothing in feel
103
+ // and everything is multiplied by the number of items in the run.
104
+ budget: { maxTokens: 60_000, maxCostPerMessageUsd: 0.05 },
105
+ strategies: ["dedupe", "trim-tool-results", "trim-tool-calls", "strip-base64"],
106
+ keepRecent: 4,
107
+ maxToolResultTokens: 150,
108
+ },
109
+ },
110
+ ];
111
+ export function findPreset(id) {
112
+ return PRESETS.find((p) => p.id === id);
113
+ }
package/dist/diff.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * `context-doctor diff <before> <after>` — compare two profiles.
3
+ *
4
+ * Optimization currently has to be taken on trust: you run it, a number
5
+ * changes, and nothing shows what actually moved. This puts two profiles side
6
+ * by side, so "we cut the context" becomes a claim with evidence — which
7
+ * categories shrank, which findings were resolved, and what it means in money
8
+ * and latency.
9
+ *
10
+ * Works on any two inputs `analyze` accepts, plus session transcripts, so it
11
+ * covers before/after an optimization and one session against another.
12
+ */
13
+ export declare function renderDiff(beforePath: string, afterPath: string, model?: string): string;
package/dist/diff.js ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * `context-doctor diff <before> <after>` — compare two profiles.
3
+ *
4
+ * Optimization currently has to be taken on trust: you run it, a number
5
+ * changes, and nothing shows what actually moved. This puts two profiles side
6
+ * by side, so "we cut the context" becomes a claim with evidence — which
7
+ * categories shrank, which findings were resolved, and what it means in money
8
+ * and latency.
9
+ *
10
+ * Works on any two inputs `analyze` accepts, plus session transcripts, so it
11
+ * covers before/after an optimization and one session against another.
12
+ */
13
+ import { readFileSync } from "node:fs";
14
+ import { parseConversation } from "./parse.js";
15
+ import { profileConversation } from "./profile.js";
16
+ import { parseSessionFile } from "./session.js";
17
+ import { formatTokens } from "./tokens.js";
18
+ import { formatUsd } from "./pricing.js";
19
+ /** Load either a conversation file or a Claude Code / ChatGPT transcript. */
20
+ function loadProfile(path, model) {
21
+ if (path.endsWith(".jsonl")) {
22
+ const session = parseSessionFile(path);
23
+ return profileConversation(parseConversation(session.conversationJson), model ?? session.model);
24
+ }
25
+ return profileConversation(parseConversation(readFileSync(path, "utf8")), model);
26
+ }
27
+ function signed(n) {
28
+ return n === 0 ? "±0" : `${n > 0 ? "+" : "−"}${formatTokens(Math.abs(n))}`;
29
+ }
30
+ /** A finding is "the same finding" when its id and message positions match. */
31
+ function findingKey(f) {
32
+ return `${f.id}:${f.messages.join(",")}`;
33
+ }
34
+ export function renderDiff(beforePath, afterPath, model) {
35
+ const before = loadProfile(beforePath, model);
36
+ const after = loadProfile(afterPath, model);
37
+ const lines = [];
38
+ lines.push("CONTEXT DOCTOR — diff");
39
+ lines.push("═".repeat(56));
40
+ lines.push(`before: ${beforePath}`);
41
+ lines.push(`after: ${afterPath}`);
42
+ lines.push("");
43
+ const delta = after.totalTokens - before.totalTokens;
44
+ const pct = before.totalTokens > 0 ? (delta / before.totalTokens) * 100 : 0;
45
+ lines.push(`Total: ${formatTokens(before.totalTokens)} → ${formatTokens(after.totalTokens)} tokens ` +
46
+ `(${signed(delta)}, ${pct >= 0 ? "+" : ""}${pct.toFixed(1)}%)`);
47
+ lines.push(`Messages: ${before.messageCount} → ${after.messageCount}`);
48
+ if (before.cost && after.cost) {
49
+ const costDelta = after.cost.per1kCallsUsd - before.cost.per1kCallsUsd;
50
+ const ttftDelta = after.cost.ttftSeconds - before.cost.ttftSeconds;
51
+ lines.push(`Cost: ${formatUsd(before.cost.per1kCallsUsd)} → ${formatUsd(after.cost.per1kCallsUsd)} per 1k calls ` +
52
+ `(${costDelta <= 0 ? "saves " : "costs "}${formatUsd(Math.abs(costDelta))}), ` +
53
+ `${Math.abs(ttftDelta).toFixed(1)}s ${ttftDelta <= 0 ? "faster" : "slower"} per call`);
54
+ }
55
+ lines.push("");
56
+ // Where the change actually landed.
57
+ const categories = Object.keys(before.categories).map((key) => ({ label: key, before: before.categories[key], after: after.categories[key] }));
58
+ const moved = categories.filter((c) => c.before !== c.after);
59
+ if (moved.length > 0) {
60
+ lines.push("Where it changed");
61
+ lines.push("─".repeat(56));
62
+ for (const c of moved.sort((a, b) => Math.abs(b.after - b.before) - Math.abs(a.after - a.before))) {
63
+ lines.push(` ${c.label.padEnd(14)} ${formatTokens(c.before).padStart(7)} → ${formatTokens(c.after).padStart(7)} ${signed(c.after - c.before)}`);
64
+ }
65
+ lines.push("");
66
+ }
67
+ // Findings resolved and introduced — the qualitative half of the story.
68
+ const beforeKeys = new Map(before.findings.map((f) => [findingKey(f), f]));
69
+ const afterKeys = new Map(after.findings.map((f) => [findingKey(f), f]));
70
+ const resolved = [...beforeKeys].filter(([k]) => !afterKeys.has(k)).map(([, f]) => f);
71
+ const introduced = [...afterKeys].filter(([k]) => !beforeKeys.has(k)).map(([, f]) => f);
72
+ lines.push(`Findings: ${before.findings.length} → ${after.findings.length}`);
73
+ lines.push("─".repeat(56));
74
+ for (const f of resolved.slice(0, 5))
75
+ lines.push(` ✓ resolved: ${f.message}`);
76
+ if (resolved.length > 5)
77
+ lines.push(` ✓ … and ${resolved.length - 5} more resolved`);
78
+ for (const f of introduced.slice(0, 5))
79
+ lines.push(` ✗ new: ${f.message}`);
80
+ if (introduced.length > 5)
81
+ lines.push(` ✗ … and ${introduced.length - 5} more introduced`);
82
+ if (resolved.length === 0 && introduced.length === 0)
83
+ lines.push(" (no change in findings)");
84
+ lines.push("");
85
+ const verdict = delta < 0
86
+ ? `Net improvement: ${formatTokens(-delta)} tokens removed, ${resolved.length} finding(s) resolved.`
87
+ : delta > 0
88
+ ? `Context grew by ${formatTokens(delta)} tokens.`
89
+ : "No change in total context.";
90
+ lines.push(verdict);
91
+ return lines.join("\n");
92
+ }
package/dist/mcp.js CHANGED
@@ -37,7 +37,7 @@ const STRATEGY_IDS = ["dedupe", "trim-tool-results", "trim-tool-calls", "strip-b
37
37
  * recommended pattern.
38
38
  */
39
39
  function createServer() {
40
- const server = new McpServer({ name: "context-doctor", version: "0.12.1" }, { instructions: SERVER_INSTRUCTIONS });
40
+ const server = new McpServer({ name: "context-doctor", version: "0.13.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/optimize.js CHANGED
@@ -38,6 +38,34 @@ function trimCallArguments(input, maxTokens) {
38
38
  }
39
39
  return out;
40
40
  }
41
+ /**
42
+ * Where trimming stops — quantized so the prompt cache survives.
43
+ *
44
+ * The obvious boundary, `length - keepRecent`, advances by one on every turn.
45
+ * That rewrites the message that just aged out of the recent window, and since
46
+ * prompt caches only match a byte-identical prefix, editing anything in the
47
+ * middle invalidates the cache from that point on. Measured on a 25-turn agent
48
+ * conversation, the naive boundary invalidated the cached prefix on 24 of 24
49
+ * turns: paying the 1.25x cache-write price every turn to save a few hundred
50
+ * tokens, which is a straight loss on any sizeable context.
51
+ *
52
+ * Quantizing means the boundary holds still for STEP turns at a time, so the
53
+ * prefix stays byte-identical in between and the cache is rebuilt once per
54
+ * STEP turns instead of once per turn. Older content is trimmed slightly later
55
+ * than it otherwise would be; that is much cheaper than losing the cache.
56
+ */
57
+ const TRIM_BOUNDARY_STEP = 10;
58
+ function stableCutoff(messageCount, keepRecent) {
59
+ const raw = messageCount - keepRecent;
60
+ if (raw <= 0)
61
+ return 0;
62
+ // Below one step there is nothing to quantize to except zero, which would
63
+ // silently disable trimming on every short conversation. Such a conversation
64
+ // has no long stable prefix worth protecting anyway.
65
+ if (raw < TRIM_BOUNDARY_STEP)
66
+ return raw;
67
+ return Math.floor(raw / TRIM_BOUNDARY_STEP) * TRIM_BOUNDARY_STEP;
68
+ }
41
69
  function hash(text) {
42
70
  return createHash("sha1").update(text.replace(/\s+/g, " ").trim()).digest("hex");
43
71
  }
@@ -127,6 +155,14 @@ export function optimizeConversation(input, options = {}) {
127
155
  if (!Array.isArray(messages)) {
128
156
  throw new Error("No `messages` array found in input");
129
157
  }
158
+ // Null / non-object entries occur in truncated and hand-edited files; every
159
+ // strategy below would throw on them. Drop them IN PLACE rather than working
160
+ // on a copy — prune-history splices this same array, and the returned
161
+ // conversation is the caller's original object.
162
+ for (let i = messages.length - 1; i >= 0; i--) {
163
+ if (!messages[i] || typeof messages[i] !== "object")
164
+ messages.splice(i, 1);
165
+ }
130
166
  const tokensBefore = messages.reduce((s, m) => s + estimateTokens(textOf(m.content)), 0);
131
167
  const applied = [];
132
168
  // -- strip-base64: replace inline blobs with a placeholder --------------------
@@ -164,7 +200,7 @@ export function optimizeConversation(input, options = {}) {
164
200
  }
165
201
  // -- trim-tool-results: shrink stale tool output ------------------------------
166
202
  if (opts.strategies.includes("trim-tool-results")) {
167
- const cutoff = messages.length - opts.keepRecent;
203
+ const cutoff = stableCutoff(messages.length, opts.keepRecent);
168
204
  messages.forEach((m, i) => {
169
205
  if (i >= cutoff || !isToolResultMessage(m))
170
206
  return;
@@ -184,7 +220,7 @@ export function optimizeConversation(input, options = {}) {
184
220
  }
185
221
  // -- trim-tool-calls: shrink the arguments of calls that already ran ----------
186
222
  if (opts.strategies.includes("trim-tool-calls")) {
187
- const cutoff = messages.length - opts.keepRecent;
223
+ const cutoff = stableCutoff(messages.length, opts.keepRecent);
188
224
  messages.forEach((m, i) => {
189
225
  if (i >= cutoff)
190
226
  return;
package/dist/parse.js CHANGED
@@ -62,7 +62,10 @@ function flattenContent(content) {
62
62
  }
63
63
  return { text, hasBinary, toolName, kind, toolCallText: toolCallText || undefined };
64
64
  }
65
- function normalizeMessage(raw, index) {
65
+ function normalizeMessage(rawInput, index) {
66
+ // A null or non-object entry appears in truncated and hand-edited files.
67
+ // Treat it as an empty message rather than throwing a stack at the user.
68
+ const raw = rawInput && typeof rawInput === "object" ? rawInput : {};
66
69
  const role = String(raw.role ?? "user");
67
70
  const flat = flattenContent(raw.content);
68
71
  let kind = flat.kind ?? (["system", "user", "assistant"].includes(role) ? role : "other");
@@ -73,8 +76,10 @@ function normalizeMessage(raw, index) {
73
76
  if (role === "tool") {
74
77
  kind = "tool_result";
75
78
  }
76
- const toolCalls = raw.tool_calls;
77
- if (Array.isArray(toolCalls) && toolCalls.length > 0) {
79
+ const rawToolCalls = raw.tool_calls;
80
+ // Entries can be null or malformed in hand-edited or truncated exports.
81
+ const toolCalls = Array.isArray(rawToolCalls) ? rawToolCalls.filter((tc) => tc && typeof tc === "object") : undefined;
82
+ if (toolCalls && toolCalls.length > 0) {
78
83
  kind = "tool_call";
79
84
  toolName = toolCalls[0]?.function?.name ?? toolCalls[0]?.name;
80
85
  const calls = toolCalls
@@ -118,7 +123,12 @@ export function parseConversation(input) {
118
123
  };
119
124
  }
120
125
  const obj = data;
121
- const rawMessages = obj.messages ?? [];
126
+ const rawField = obj.messages;
127
+ const rawMessages = Array.isArray(rawField)
128
+ ? rawField
129
+ : [];
130
+ // `messages` present but not an array is a malformed file, not an empty chat.
131
+ const malformedMessages = rawField != null && !Array.isArray(rawField);
122
132
  const messages = [];
123
133
  // Anthropic keeps the system prompt outside the messages array.
124
134
  if (obj.system != null) {
@@ -127,9 +137,11 @@ export function parseConversation(input) {
127
137
  }
128
138
  messages.push(...rawMessages.map((m, i) => normalizeMessage(m, i)));
129
139
  const isAnthropic = obj.system != null ||
130
- rawMessages.some((m) => Array.isArray(m.content) && m.content.some((b) => b?.type === "tool_use" || b?.type === "tool_result"));
131
- const parseWarning = messages.length === 0
132
- ? "This JSON has no `messages` array (and no `system`) it does not look like a conversation. Expected {\"messages\":[{\"role\":…,\"content\":…}]}."
133
- : undefined;
140
+ rawMessages.some((m) => Array.isArray(m?.content) && m.content.some((b) => b?.type === "tool_use" || b?.type === "tool_result"));
141
+ const parseWarning = malformedMessages
142
+ ? `\`messages\` is a ${typeof rawField}, not an arraythis file is malformed.`
143
+ : messages.length === 0
144
+ ? "This JSON has no `messages` array (and no `system`) — it does not look like a conversation. Expected {\"messages\":[{\"role\":…,\"content\":…}]}."
145
+ : undefined;
134
146
  return { sourceFormat: isAnthropic ? "anthropic" : "openai", parseWarning, messages };
135
147
  }
package/dist/profile.js CHANGED
@@ -96,6 +96,54 @@ function jaccard(a, b) {
96
96
  inter++;
97
97
  return inter / (a.size + b.size - inter);
98
98
  }
99
+ /**
100
+ * Shell commands that pull a file's contents into context. An agent that runs
101
+ * `cat config.json` has re-read that file just as surely as one that calls a
102
+ * Read tool — but the call looks like an opaque Bash invocation, so the
103
+ * repeated-read detector used to score shell-heavy sessions as clean when they
104
+ * were the worst offenders.
105
+ *
106
+ * Deliberately narrow: only commands whose whole purpose is to emit file
107
+ * contents, and only when the argument looks like a path rather than a flag or
108
+ * a glob. `grep` and `ls` are excluded — they summarize, they do not dump.
109
+ */
110
+ const FILE_DUMPING_COMMANDS = /\b(?:cat|bat|head|tail|less|more|nl)\s+((?:-[^\s;|&]+\s+)*)([^\s|;&><'"`]{3,})/g;
111
+ /**
112
+ * Does this token name a file, or is it the next shell word?
113
+ *
114
+ * `head -20; echo done` used to report a file called "echo": the flag pattern
115
+ * swallowed the separator and the following command became the target. A real
116
+ * path has a directory separator or an extension; a bare command word has
117
+ * neither.
118
+ */
119
+ function looksLikePath(token) {
120
+ if (/[*?$]/.test(token) || token.startsWith("-"))
121
+ return false; // glob or flag
122
+ if (token === "EOF" || token === "<<")
123
+ return false; // heredoc, which writes
124
+ if (!/^[\w./~@+-]+$/.test(token))
125
+ return false;
126
+ return token.includes("/") || /\.[A-Za-z0-9]{1,8}$/.test(token);
127
+ }
128
+ /** Extract every file path a tool call reads, whether by tool or by shell. */
129
+ function filesReadBy(toolName, toolCallText) {
130
+ // Explicit read-style tools name their target in a known argument.
131
+ if (/read|open|cat|view|get_file/i.test(toolName ?? "")) {
132
+ const match = /"(?:file_path|filePath|path|file)"\s*:\s*"([^"]{3,})"/.exec(toolCallText);
133
+ if (match)
134
+ return [match[1]];
135
+ }
136
+ if (!/bash|shell|terminal|exec|run_command/i.test(toolName ?? ""))
137
+ return [];
138
+ const paths = new Set();
139
+ FILE_DUMPING_COMMANDS.lastIndex = 0;
140
+ for (const match of toolCallText.matchAll(FILE_DUMPING_COMMANDS)) {
141
+ const candidate = match[2].replace(/\\+$/, "");
142
+ if (looksLikePath(candidate))
143
+ paths.add(candidate);
144
+ }
145
+ return [...paths];
146
+ }
99
147
  export function profileConversation(conv, model) {
100
148
  const perMessage = conv.messages.map((m) => ({
101
149
  msg: m,
@@ -231,13 +279,9 @@ export function profileConversation(conv, model) {
231
279
  for (const p of perMessage) {
232
280
  if (p.msg.kind !== "tool_call" || !p.msg.toolCallText)
233
281
  continue;
234
- if (!/read|open|cat|view|get_file/i.test(p.msg.toolName ?? ""))
235
- continue;
236
- const match = /"(?:file_path|filePath|path|file)"\s*:\s*"([^"]{3,})"/.exec(p.msg.toolCallText);
237
- if (!match)
238
- continue;
239
- const path = match[1];
240
- readsByPath.set(path, [...(readsByPath.get(path) ?? []), p.msg.index]);
282
+ for (const path of filesReadBy(p.msg.toolName, p.msg.toolCallText)) {
283
+ readsByPath.set(path, [...(readsByPath.get(path) ?? []), p.msg.index]);
284
+ }
241
285
  }
242
286
  for (const [path, indexes] of readsByPath) {
243
287
  if (indexes.length < 3)
package/dist/session.d.ts CHANGED
@@ -14,6 +14,13 @@ export interface SessionInfo {
14
14
  modifiedAt: Date;
15
15
  sizeBytes: number;
16
16
  }
17
+ /** One API-reported input size, positioned in the message array. */
18
+ export interface UsageSample {
19
+ /** Index into the live `messages` array of the assistant message reporting it. */
20
+ index: number;
21
+ /** input + cache-read + cache-creation tokens for that request. */
22
+ input: number;
23
+ }
17
24
  export interface ParsedSession {
18
25
  /**
19
26
  * The real input size of the most recent request, as reported by the API
@@ -30,6 +37,15 @@ export interface ParsedSession {
30
37
  * was compacted away so the difference can be shown rather than hidden.
31
38
  */
32
39
  compactedAway?: number;
40
+ /**
41
+ * Every API-reported input size in the transcript, tagged with its position
42
+ * in the live message array. Consecutive samples are what make key-free
43
+ * accuracy measurement possible: the harness's system prompt and tool
44
+ * schemas are constant between two calls, so the DIFFERENCE between two
45
+ * reported figures is the cost of the messages in between — directly
46
+ * comparable to what the heuristic estimates for those same messages.
47
+ */
48
+ usageSamples?: UsageSample[];
33
49
  /** Conversation JSON string in Anthropic-ish format, ready for parseConversation(). */
34
50
  conversationJson: string;
35
51
  title?: string;
package/dist/session.js CHANGED
@@ -142,6 +142,8 @@ export function parseSessionFile(path) {
142
142
  let lastCompactIndex = -1;
143
143
  /** Newest API-reported input size, if the transcript carries usage. */
144
144
  let reportedInputTokens;
145
+ /** Every reported size, positioned — the basis for `context-doctor accuracy`. */
146
+ const usageSamples = [];
145
147
  forEachLine(path, (line) => {
146
148
  if (!line.trim())
147
149
  return;
@@ -169,8 +171,10 @@ export function parseSessionFile(path) {
169
171
  const usage = message.usage;
170
172
  if (entry.type === "assistant" && usage) {
171
173
  const total = (usage.input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0);
172
- if (total > 0)
174
+ if (total > 0) {
173
175
  reportedInputTokens = total;
176
+ usageSamples.push({ index: messages.length, input: total });
177
+ }
174
178
  }
175
179
  if (entry.isCompactSummary)
176
180
  lastCompactIndex = messages.length;
@@ -188,6 +192,11 @@ export function parseSessionFile(path) {
188
192
  messageCount: live.length,
189
193
  compactedAway,
190
194
  reportedInputTokens,
195
+ // Samples before the compaction boundary describe a context that no longer
196
+ // exists; re-base the rest onto the live array.
197
+ usageSamples: usageSamples
198
+ .filter((u) => u.index >= compactedAway)
199
+ .map((u) => ({ index: u.index - compactedAway, input: u.input })),
191
200
  path,
192
201
  };
193
202
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-doctor",
3
- "version": "0.12.1",
3
+ "version": "0.13.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 dist/test/cache.test.js dist/test/session.test.js"
45
+ "test": "npm run build && node --test dist/test/smoke.test.js dist/test/proxy.test.js dist/test/proxy-abort.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 dist/test/session.test.js dist/test/accuracy.test.js dist/test/cache-stability.test.js"
46
46
  },
47
47
  "dependencies": {
48
48
  "@modelcontextprotocol/sdk": "^1.0.0",