context-doctor 0.12.2 → 0.13.1

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
@@ -39,11 +39,19 @@ export interface LoadedConfig {
39
39
  config: ContextDoctorConfig;
40
40
  /** Absolute path of the rc file, or undefined when none was found. */
41
41
  path?: string;
42
+ /** Settings that will be silently ignored, if any. */
43
+ warnings?: string[];
42
44
  }
43
45
  /**
44
- * Load the nearest config. Malformed rc files are reported (so a typo is not
45
- * silently ignored) but never throw — the tool keeps working with defaults.
46
+ * Report anything in an rc file that will be silently ignored.
47
+ *
48
+ * Every invalid value here fails quietly and looks like the feature not
49
+ * working: `"trim-tool-result"` (missing s) trims nothing, a negative
50
+ * keepRecent disables trimming entirely, and a budget written as a string is
51
+ * never compared against. For a tool whose whole job is measurement, silently
52
+ * doing nothing is the worst available behaviour.
46
53
  */
54
+ export declare function validateConfig(config: unknown, path: string): string[];
47
55
  export declare function loadConfig(startDir?: string, onWarn?: (msg: string) => void): LoadedConfig;
48
56
  export interface BudgetVerdict {
49
57
  /** True when any configured limit is exceeded. */
@@ -61,3 +69,19 @@ export declare function checkBudget(budget: ContextBudget | undefined, profile:
61
69
  perCallUsd: number;
62
70
  };
63
71
  }): BudgetVerdict;
72
+ /**
73
+ * Starting points for `.contextdoctorrc`.
74
+ *
75
+ * An empty rc file is technically valid and completely useless: nobody knows
76
+ * what a reasonable token budget is for their kind of work until they have
77
+ * blown through one. These encode the three shapes that actually differ —
78
+ * a chat product, a coding agent, and a batch pipeline — so a budget can be
79
+ * adopted in one command and tuned later.
80
+ */
81
+ export interface Preset {
82
+ id: string;
83
+ summary: string;
84
+ config: ContextDoctorConfig;
85
+ }
86
+ export declare const PRESETS: Preset[];
87
+ export declare function findPreset(id: string): Preset | undefined;
package/dist/config.js CHANGED
@@ -36,14 +36,88 @@ function candidatePaths(startDir) {
36
36
  * Load the nearest config. Malformed rc files are reported (so a typo is not
37
37
  * silently ignored) but never throw — the tool keeps working with defaults.
38
38
  */
39
+ /** Strategy ids the optimizer actually implements. */
40
+ const KNOWN_STRATEGIES = new Set(["dedupe", "trim-tool-results", "trim-tool-calls", "strip-base64", "prune-history"]);
41
+ const KNOWN_KEYS = new Set(["budget", "strategies", "keepRecent", "maxToolResultTokens", "routes", "model"]);
42
+ const KNOWN_BUDGET_KEYS = new Set(["maxTokens", "maxCostPerMessageUsd", "maxWindowPct"]);
43
+ /**
44
+ * Report anything in an rc file that will be silently ignored.
45
+ *
46
+ * Every invalid value here fails quietly and looks like the feature not
47
+ * working: `"trim-tool-result"` (missing s) trims nothing, a negative
48
+ * keepRecent disables trimming entirely, and a budget written as a string is
49
+ * never compared against. For a tool whose whole job is measurement, silently
50
+ * doing nothing is the worst available behaviour.
51
+ */
52
+ export function validateConfig(config, path) {
53
+ const warnings = [];
54
+ const where = (key) => `${path}: ${key}`;
55
+ if (!config || typeof config !== "object" || Array.isArray(config)) {
56
+ return [`${path}: expected a JSON object`];
57
+ }
58
+ const c = config;
59
+ for (const key of Object.keys(c)) {
60
+ if (!KNOWN_KEYS.has(key)) {
61
+ warnings.push(`${where(key)} is not a known setting — ignored (known: ${[...KNOWN_KEYS].join(", ")})`);
62
+ }
63
+ }
64
+ if (c.budget !== undefined) {
65
+ if (typeof c.budget !== "object" || c.budget === null || Array.isArray(c.budget)) {
66
+ warnings.push(`${where("budget")} must be an object — ignored`);
67
+ }
68
+ else {
69
+ const budget = c.budget;
70
+ for (const [key, value] of Object.entries(budget)) {
71
+ if (!KNOWN_BUDGET_KEYS.has(key)) {
72
+ warnings.push(`${where(`budget.${key}`)} is not a known budget limit — ignored`);
73
+ }
74
+ else if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
75
+ warnings.push(`${where(`budget.${key}`)} must be a positive number, got ${JSON.stringify(value)} — this limit will never trigger`);
76
+ }
77
+ }
78
+ if (typeof budget.maxWindowPct === "number" && budget.maxWindowPct > 100) {
79
+ warnings.push(`${where("budget.maxWindowPct")} is above 100 — a percentage of the context window cannot exceed 100`);
80
+ }
81
+ }
82
+ }
83
+ if (c.strategies !== undefined) {
84
+ if (!Array.isArray(c.strategies)) {
85
+ warnings.push(`${where("strategies")} must be an array — ignored`);
86
+ }
87
+ else {
88
+ for (const id of c.strategies) {
89
+ if (!KNOWN_STRATEGIES.has(String(id))) {
90
+ warnings.push(`${where("strategies")}: "${id}" is not a strategy — ignored (known: ${[...KNOWN_STRATEGIES].join(", ")})`);
91
+ }
92
+ }
93
+ }
94
+ }
95
+ for (const key of ["keepRecent", "maxToolResultTokens"]) {
96
+ const value = c[key];
97
+ if (value === undefined)
98
+ continue;
99
+ if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
100
+ warnings.push(`${where(key)} must be a positive whole number, got ${JSON.stringify(value)} — ignored`);
101
+ }
102
+ }
103
+ if (c.routes !== undefined && !Array.isArray(c.routes)) {
104
+ warnings.push(`${where("routes")} must be an array — ignored`);
105
+ }
106
+ return warnings;
107
+ }
39
108
  export function loadConfig(startDir = process.cwd(), onWarn) {
40
109
  for (const path of candidatePaths(startDir)) {
41
110
  if (!existsSync(path))
42
111
  continue;
43
112
  try {
44
113
  const parsed = JSON.parse(readFileSync(path, "utf8"));
45
- if (parsed && typeof parsed === "object")
46
- return { config: parsed, path };
114
+ // Arrays are objects too, hence the explicit check.
115
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
116
+ const warnings = validateConfig(parsed, path);
117
+ for (const warning of warnings)
118
+ onWarn?.(warning);
119
+ return { config: parsed, path, warnings };
120
+ }
47
121
  onWarn?.(`${path}: expected a JSON object — ignoring`);
48
122
  }
49
123
  catch (e) {
@@ -71,3 +145,43 @@ export function checkBudget(budget, profile) {
71
145
  }
72
146
  return { overBudget: breaches.length > 0, breaches, maxTokens: budget.maxTokens };
73
147
  }
148
+ export const PRESETS = [
149
+ {
150
+ id: "chat",
151
+ summary: "Interactive chat product — short contexts, latency matters most",
152
+ config: {
153
+ // Chat turns are small; a context this large means history is not being
154
+ // summarized, and every extra token is felt directly as time-to-first-token.
155
+ budget: { maxTokens: 30_000, maxWindowPct: 40, maxCostPerMessageUsd: 0.15 },
156
+ strategies: ["dedupe", "strip-base64"],
157
+ keepRecent: 10,
158
+ },
159
+ },
160
+ {
161
+ id: "agent",
162
+ summary: "Coding or tool-using agent — long runs, tool output dominates",
163
+ config: {
164
+ // Agents legitimately hold a lot of context, so the budget is generous;
165
+ // the tight controls go on tool traffic, which is where the waste is.
166
+ budget: { maxTokens: 150_000, maxWindowPct: 70, maxCostPerMessageUsd: 1.5 },
167
+ strategies: ["dedupe", "trim-tool-results", "strip-base64"],
168
+ keepRecent: 6,
169
+ maxToolResultTokens: 300,
170
+ },
171
+ },
172
+ {
173
+ id: "batch",
174
+ summary: "Batch or pipeline jobs — cost per call is the whole story",
175
+ config: {
176
+ // Nothing is interactive, so aggressive trimming costs nothing in feel
177
+ // and everything is multiplied by the number of items in the run.
178
+ budget: { maxTokens: 60_000, maxCostPerMessageUsd: 0.05 },
179
+ strategies: ["dedupe", "trim-tool-results", "trim-tool-calls", "strip-base64"],
180
+ keepRecent: 4,
181
+ maxToolResultTokens: 150,
182
+ },
183
+ },
184
+ ];
185
+ export function findPreset(id) {
186
+ return PRESETS.find((p) => p.id === id);
187
+ }
package/dist/dashboard.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * the server reads local files and answers only the loopback interface.
8
8
  */
9
9
  import http from "node:http";
10
- import { readLedger } from "./ledger.js";
10
+ import { foldTotals, readLedger } from "./ledger.js";
11
11
  import { listSessions, parseSessionFile } from "./session.js";
12
12
  import { parseConversation } from "./parse.js";
13
13
  import { profileConversation } from "./profile.js";
@@ -28,6 +28,9 @@ async function fetchProxyStats(port) {
28
28
  }
29
29
  export async function collectDashboardData(proxyPort = 8787) {
30
30
  const ledger = readLedger();
31
+ // Totals folded in when the ledger rotated; excluded from the daily series,
32
+ // which describes individual days rather than a carried-forward sum.
33
+ const carried = foldTotals(ledger.filter((e) => e.ev === "rollup"));
31
34
  const checks = ledger.filter((e) => e.ev === "check" || e.ev === undefined);
32
35
  const optimizes = ledger.filter((e) => e.ev === "optimize");
33
36
  // Observed shrinkage: a session getting SMALLER between two deep checks is a
@@ -38,14 +41,14 @@ export async function collectDashboardData(proxyPort = 8787) {
38
41
  continue;
39
42
  perSession.set(c.sid, [...(perSession.get(c.sid) ?? []), c.tok]);
40
43
  }
41
- let shrinkage = 0;
44
+ let shrinkage = carried.shrinkage;
42
45
  for (const toks of perSession.values()) {
43
46
  for (let i = 1; i < toks.length; i++)
44
47
  if (toks[i] < toks[i - 1])
45
48
  shrinkage += toks[i - 1] - toks[i];
46
49
  }
47
- const optimizeSaved = optimizes.reduce((s, e) => s + (e.saved ?? 0), 0);
48
- let usdSaved = 0;
50
+ const optimizeSaved = optimizes.reduce((s, e) => s + (e.saved ?? 0), 0) + carried.optimizeSaved;
51
+ let usdSaved = carried.optimizeUsd;
49
52
  for (const e of optimizes) {
50
53
  const pricing = pricingFor(e.model);
51
54
  if (pricing && e.saved)
@@ -93,8 +96,8 @@ export async function collectDashboardData(proxyPort = 8787) {
93
96
  totals: {
94
97
  tokensSaved: optimizeSaved + shrinkage + (proxy?.tokensSaved ?? 0),
95
98
  usdSaved: usdSaved + (proxy?.estUsdSaved ?? 0),
96
- checks: checks.length,
97
- warnings: checks.filter((c) => c.warn).length,
99
+ checks: checks.length + carried.checks,
100
+ warnings: checks.filter((c) => c.warn).length + carried.warnings,
98
101
  optimizeRuns: optimizes.length,
99
102
  },
100
103
  daily,
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/doctor.js CHANGED
@@ -11,6 +11,7 @@ import { homedir, platform } from "node:os";
11
11
  import { dirname, join } from "node:path";
12
12
  import { fileURLToPath } from "node:url";
13
13
  import { ledgerPath, recordLedger } from "./ledger.js";
14
+ import { loadConfig } from "./config.js";
14
15
  function claudeDesktopConfigPath() {
15
16
  switch (platform()) {
16
17
  case "darwin": return join(homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json");
@@ -120,6 +121,18 @@ export async function runDoctor() {
120
121
  catch {
121
122
  checks.push({ label: "Ledger", status: "fail", detail: `cannot write ${ledgerPath()}` });
122
123
  }
124
+ // Project config: a setting that is silently ignored looks exactly like the
125
+ // feature being broken, so name it here rather than leaving it to be guessed.
126
+ const loaded = loadConfig(process.cwd());
127
+ if (loaded.path) {
128
+ const warnings = loaded.warnings ?? [];
129
+ checks.push(warnings.length === 0
130
+ ? { label: "Project config", status: "ok", detail: `${loaded.path} — all settings understood` }
131
+ : { label: "Project config", status: "fail", detail: `${warnings.length} setting(s) will be ignored:\n` + warnings.map((w) => ` ${w}`).join("\n") });
132
+ }
133
+ else {
134
+ checks.push({ label: "Project config", status: "skip", detail: "no .contextdoctorrc (optional; create one with: context-doctor init <preset>)" });
135
+ }
123
136
  checks.push(await checkMcpHandshake());
124
137
  const mark = { ok: "✓", fail: "✗", skip: "–" };
125
138
  console.log("CONTEXT DOCTOR — self-check");
package/dist/hook.js CHANGED
@@ -10,7 +10,9 @@
10
10
  * Registered by `context-doctor install` under hooks.UserPromptSubmit in
11
11
  * ~/.claude/settings.json; removed by `context-doctor uninstall`.
12
12
  */
13
- import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
13
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
14
+ import { createHash } from "node:crypto";
15
+ import { join } from "node:path";
14
16
  import { recordLedger, statePath } from "./ledger.js";
15
17
  import { parseConversation } from "./parse.js";
16
18
  import { profileConversation } from "./profile.js";
@@ -43,6 +45,70 @@ const REGROWTH_FACTOR = 1.4;
43
45
  function minBytesForWarn(threshold) {
44
46
  return threshold * 4;
45
47
  }
48
+ /**
49
+ * State lives in one small file per session, not one shared map.
50
+ *
51
+ * The hook runs once per prompt in every Claude Code window, and people keep
52
+ * several open. With a shared JSON map, concurrent hooks each read the whole
53
+ * map and wrote it back, so the last writer erased everyone else: measured,
54
+ * 12 simultaneous sessions left 4 surviving entries. The cost of losing an
55
+ * entry is a repeated warning the regrowth gate exists to prevent, plus a full
56
+ * re-parse of a transcript that can be hundreds of megabytes.
57
+ *
58
+ * A process that only ever writes its own session's file cannot race another.
59
+ */
60
+ function stateDir() {
61
+ return statePath().replace(/\.json$/, "") + ".d";
62
+ }
63
+ function sessionStatePath(sessionId) {
64
+ // Session ids are usually uuids, but the fallback id is a filesystem path.
65
+ // Hashing keeps the filename valid whatever the id looks like.
66
+ return join(stateDir(), createHash("sha1").update(sessionId).digest("hex").slice(0, 16) + ".json");
67
+ }
68
+ function readSessionState(sessionId) {
69
+ try {
70
+ const raw = JSON.parse(readFileSync(sessionStatePath(sessionId), "utf8"));
71
+ if (typeof raw?.t === "number" && typeof raw?.b === "number")
72
+ return raw;
73
+ }
74
+ catch {
75
+ /* absent or half-written: treat as a first run */
76
+ }
77
+ // Migration: entries written by the shared-map versions are still useful.
78
+ try {
79
+ const legacy = JSON.parse(readFileSync(statePath(), "utf8"));
80
+ const entry = legacy[sessionId];
81
+ if (typeof entry === "number")
82
+ return { t: entry, b: 0 };
83
+ if (entry && typeof entry.t === "number")
84
+ return { t: entry.t, b: entry.b ?? 0 };
85
+ }
86
+ catch {
87
+ /* no legacy file */
88
+ }
89
+ return { t: 0, b: 0 };
90
+ }
91
+ /** Keep the directory from growing without bound as sessions come and go. */
92
+ const MAX_STATE_FILES = 200;
93
+ function writeSessionState(sessionId, state) {
94
+ const dir = stateDir();
95
+ mkdirSync(dir, { recursive: true });
96
+ writeFileSync(sessionStatePath(sessionId), JSON.stringify(state));
97
+ try {
98
+ const files = readdirSync(dir);
99
+ if (files.length <= MAX_STATE_FILES)
100
+ return;
101
+ const byAge = files
102
+ .map((f) => ({ f, t: statSync(join(dir, f)).mtimeMs }))
103
+ .sort((a, b) => b.t - a.t)
104
+ .slice(MAX_STATE_FILES);
105
+ for (const { f } of byAge)
106
+ rmSync(join(dir, f), { force: true });
107
+ }
108
+ catch {
109
+ /* pruning is housekeeping, never worth failing a prompt over */
110
+ }
111
+ }
46
112
  async function readStdin() {
47
113
  const chunks = [];
48
114
  for await (const chunk of process.stdin)
@@ -68,16 +134,7 @@ export async function runHook() {
68
134
  // since the last full parse, nothing new can trigger — exit without the
69
135
  // expensive read. Heavy-but-quiet sessions cost one stat + tiny state read.
70
136
  const sessionId = input.session_id ?? transcriptPath;
71
- let state = {};
72
- try {
73
- state = JSON.parse(readFileSync(statePath(), "utf8"));
74
- }
75
- catch {
76
- /* first run */
77
- }
78
- const rawPrev = state[sessionId];
79
- // Migrate pre-0.3.5 numeric entries ({tokens only}) to the new shape.
80
- const prev = typeof rawPrev === "number" ? { t: rawPrev, b: 0 } : rawPrev ?? { t: 0, b: 0 };
137
+ const prev = readSessionState(sessionId);
81
138
  if (prev.b > 0 && sizeBytes < prev.b * REGROWTH_FACTOR)
82
139
  return;
83
140
  // Slow path (growth events only): full parse + profile.
@@ -91,9 +148,7 @@ export async function runHook() {
91
148
  const liveTokens = parsed.reportedInputTokens ?? profile.totalTokens;
92
149
  // Record this parse so the next prompts take fast path 2.
93
150
  const shouldWarn = liveTokens >= threshold && liveTokens >= prev.t * REGROWTH_FACTOR;
94
- const nextState = { t: shouldWarn ? liveTokens : prev.t, b: sizeBytes };
95
- const entries = Object.entries({ ...state, [sessionId]: nextState });
96
- writeFileSync(statePath(), JSON.stringify(Object.fromEntries(entries.slice(-100))));
151
+ writeSessionState(sessionId, { t: shouldWarn ? liveTokens : prev.t, b: sizeBytes });
97
152
  recordLedger({ ev: "check", sid: sessionId.slice(0, 12), tok: liveTokens, warn: shouldWarn });
98
153
  if (!shouldWarn)
99
154
  return;
package/dist/impact.js CHANGED
@@ -8,7 +8,7 @@
8
8
  * session cannot be re-run without it. The report says so instead of inventing
9
9
  * a number.
10
10
  */
11
- import { readLedger } from "./ledger.js";
11
+ import { foldTotals, readLedger } from "./ledger.js";
12
12
  import { listSessions, parseSessionFile } from "./session.js";
13
13
  import { parseConversation } from "./parse.js";
14
14
  import { profileConversation } from "./profile.js";
@@ -32,6 +32,9 @@ export async function buildImpactReport(proxyPort = 8787) {
32
32
  lines.push("CONTEXT DOCTOR — impact report");
33
33
  lines.push("═".repeat(56));
34
34
  const ledger = readLedger();
35
+ // Rotation folds dropped entries into a rollup. Counting it is what keeps
36
+ // these lifetime totals from going backwards once the cap is hit.
37
+ const carried = foldTotals(ledger.filter((e) => e.ev === "rollup"));
35
38
  const checks = ledger.filter((e) => e.ev === "check" || e.ev === undefined);
36
39
  const optimizes = ledger.filter((e) => e.ev === "optimize");
37
40
  // Observed per-session reductions: when a session SHRANK between two deep
@@ -55,9 +58,9 @@ export async function buildImpactReport(proxyPort = 8787) {
55
58
  }
56
59
  reductionBySession.set(sid, reduction);
57
60
  }
58
- const totalReduction = [...reductionBySession.values()].reduce((a, b) => a + b, 0);
61
+ const totalReduction = [...reductionBySession.values()].reduce((a, b) => a + b, 0) + carried.shrinkage;
59
62
  // Optimize-event savings, split by model family (claude / gpt / other).
60
- const optimizeSaved = optimizes.reduce((s, e) => s + (e.saved ?? 0), 0);
63
+ const optimizeSaved = optimizes.reduce((s, e) => s + (e.saved ?? 0), 0) + carried.optimizeSaved;
61
64
  const savedByFamily = new Map();
62
65
  let optimizeUsd = 0;
63
66
  for (const e of optimizes) {
@@ -71,7 +74,7 @@ export async function buildImpactReport(proxyPort = 8787) {
71
74
  // Persisted checkpoints cover proxy runs that have since exited; the live
72
75
  // process reports whatever it has not checkpointed yet.
73
76
  const proxyEvents = ledger.filter((e) => e.ev === "proxy");
74
- const proxyHistoric = proxyEvents.reduce((s, e) => s + (e.saved ?? 0), 0);
77
+ const proxyHistoric = proxyEvents.reduce((s, e) => s + (e.saved ?? 0), 0) + carried.proxySaved;
75
78
  const proxySaved = proxyHistoric + (proxy?.tokensSaved ?? 0);
76
79
  // -- Headline: what context-doctor has saved ----------------------------------
77
80
  const totalSaved = proxySaved + optimizeSaved + totalReduction;
@@ -100,8 +103,8 @@ export async function buildImpactReport(proxyPort = 8787) {
100
103
  lines.push("Hygiene activity (every-prompt hook)");
101
104
  lines.push("─".repeat(56));
102
105
  if (checks.length > 0) {
103
- const warnings = checks.filter((e) => e.warn).length;
104
- lines.push(`${checks.length} deep context checks across ${bySession.size} session(s); ${warnings} warning(s) delivered to the model.`);
106
+ const warnings = checks.filter((e) => e.warn).length + carried.warnings;
107
+ lines.push(`${checks.length + carried.checks} deep context checks across ${bySession.size} session(s); ${warnings} warning(s) delivered to the model.`);
105
108
  lines.push("(Prompt-level fast checks are not logged — they cost ~1ms and leave no trace by design.)");
106
109
  }
107
110
  else {
package/dist/ledger.d.ts CHANGED
@@ -8,10 +8,16 @@
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
10
  * proxy — proxy savings checkpoint {ev: "proxy", saved, usd?, requests?}
11
+ * rollup — totals folded in on rotation {ev: "rollup", ...carried sums}
12
+ *
13
+ * The ledger is capped, and everything it feeds is a LIFETIME total. Simply
14
+ * dropping old lines made those totals go backwards — measured: 1,692,000
15
+ * tokens saved became 501,000 the moment the cap was hit. So rotation folds
16
+ * what it drops into a single rollup entry instead of discarding it.
11
17
  */
12
18
  export interface LedgerEntry {
13
19
  ts: number;
14
- ev?: "check" | "optimize" | "proxy";
20
+ ev?: "check" | "optimize" | "proxy" | "rollup";
15
21
  sid?: string;
16
22
  tok?: number;
17
23
  warn?: boolean;
@@ -20,7 +26,30 @@ export interface LedgerEntry {
20
26
  requests?: number;
21
27
  saved?: number;
22
28
  model?: string;
29
+ /** rollup only: sums carried forward from entries rotation removed. */
30
+ carried?: CarriedTotals;
31
+ }
32
+ /** Everything the reports total up, preserved across ledger rotation. */
33
+ export interface CarriedTotals {
34
+ optimizeSaved: number;
35
+ optimizeUsd: number;
36
+ proxySaved: number;
37
+ proxyUsd: number;
38
+ proxyRequests: number;
39
+ checks: number;
40
+ warnings: number;
41
+ /** Session shrinkage observed between consecutive checks. */
42
+ shrinkage: number;
43
+ /** Timestamp of the oldest folded entry, so reports can say "since". */
44
+ since?: number;
23
45
  }
46
+ /**
47
+ * Reduce a set of entries to the totals the reports care about.
48
+ *
49
+ * Absorbs existing rollups, so folding stays correct across any number of
50
+ * rotations rather than only the first.
51
+ */
52
+ export declare function foldTotals(entries: LedgerEntry[]): CarriedTotals;
24
53
  export declare function statePath(): string;
25
54
  export declare function ledgerPath(): string;
26
55
  export declare function recordLedger(entry: Omit<LedgerEntry, "ts">): void;
package/dist/ledger.js CHANGED
@@ -8,10 +8,68 @@
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
10
  * proxy — proxy savings checkpoint {ev: "proxy", saved, usd?, requests?}
11
+ * rollup — totals folded in on rotation {ev: "rollup", ...carried sums}
12
+ *
13
+ * The ledger is capped, and everything it feeds is a LIFETIME total. Simply
14
+ * dropping old lines made those totals go backwards — measured: 1,692,000
15
+ * tokens saved became 501,000 the moment the cap was hit. So rotation folds
16
+ * what it drops into a single rollup entry instead of discarding it.
11
17
  */
12
18
  import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
13
19
  import { homedir } from "node:os";
14
20
  import { dirname, join } from "node:path";
21
+ const EMPTY_CARRIED = {
22
+ optimizeSaved: 0, optimizeUsd: 0, proxySaved: 0, proxyUsd: 0,
23
+ proxyRequests: 0, checks: 0, warnings: 0, shrinkage: 0,
24
+ };
25
+ /**
26
+ * Reduce a set of entries to the totals the reports care about.
27
+ *
28
+ * Absorbs existing rollups, so folding stays correct across any number of
29
+ * rotations rather than only the first.
30
+ */
31
+ export function foldTotals(entries) {
32
+ const out = { ...EMPTY_CARRIED };
33
+ const perSession = new Map();
34
+ for (const e of entries) {
35
+ if (e.ev === "rollup" && e.carried) {
36
+ out.optimizeSaved += e.carried.optimizeSaved;
37
+ out.optimizeUsd += e.carried.optimizeUsd;
38
+ out.proxySaved += e.carried.proxySaved;
39
+ out.proxyUsd += e.carried.proxyUsd;
40
+ out.proxyRequests += e.carried.proxyRequests;
41
+ out.checks += e.carried.checks;
42
+ out.warnings += e.carried.warnings;
43
+ out.shrinkage += e.carried.shrinkage;
44
+ if (e.carried.since)
45
+ out.since = Math.min(out.since ?? e.carried.since, e.carried.since);
46
+ continue;
47
+ }
48
+ out.since = Math.min(out.since ?? e.ts, e.ts);
49
+ if (e.ev === "optimize") {
50
+ out.optimizeSaved += e.saved ?? 0;
51
+ out.optimizeUsd += e.usd ?? 0;
52
+ }
53
+ else if (e.ev === "proxy") {
54
+ out.proxySaved += e.saved ?? 0;
55
+ out.proxyUsd += e.usd ?? 0;
56
+ out.proxyRequests += e.requests ?? 0;
57
+ }
58
+ else {
59
+ out.checks++;
60
+ if (e.warn)
61
+ out.warnings++;
62
+ if (e.sid && typeof e.tok === "number")
63
+ perSession.set(e.sid, [...(perSession.get(e.sid) ?? []), e.tok]);
64
+ }
65
+ }
66
+ for (const toks of perSession.values()) {
67
+ for (let i = 1; i < toks.length; i++)
68
+ if (toks[i] < toks[i - 1])
69
+ out.shrinkage += toks[i - 1] - toks[i];
70
+ }
71
+ return out;
72
+ }
15
73
  export function statePath() {
16
74
  return process.env.CONTEXT_DOCTOR_HOOK_STATE ?? join(homedir(), ".claude", ".context-doctor-hook-state.json");
17
75
  }
@@ -24,10 +82,24 @@ export function recordLedger(entry) {
24
82
  // Claude-Desktop-only machines have no ~/.claude — create it so their
25
83
  // optimize events count in `context-doctor report` too.
26
84
  mkdirSync(dirname(path), { recursive: true });
27
- // Cap growth: past ~256KB keep the most recent 500 entries.
85
+ // Cap growth: past ~256KB keep the most recent 500 entries — but fold the
86
+ // dropped ones into a rollup first, or every lifetime total in the reports
87
+ // silently shrinks the moment a heavy user crosses the cap.
28
88
  if (existsSync(path) && statSync(path).size > 256 * 1024) {
29
89
  const lines = readFileSync(path, "utf8").trimEnd().split("\n");
30
- writeFileSync(path, lines.slice(-500).join("\n") + "\n");
90
+ const parse = (line) => {
91
+ try {
92
+ return [JSON.parse(line)];
93
+ }
94
+ catch {
95
+ return [];
96
+ }
97
+ };
98
+ const kept = lines.slice(-500);
99
+ const dropped = lines.slice(0, -500).flatMap(parse);
100
+ const carried = foldTotals(dropped);
101
+ const rollup = { ts: Date.now(), ev: "rollup", carried };
102
+ writeFileSync(path, [JSON.stringify(rollup), ...kept].join("\n") + "\n");
31
103
  }
32
104
  appendFileSync(path, JSON.stringify({ ts: Date.now(), ...entry }) + "\n");
33
105
  }
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.2" }, { instructions: SERVER_INSTRUCTIONS });
40
+ const server = new McpServer({ name: "context-doctor", version: "0.13.1" }, { 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
  }
@@ -154,11 +182,24 @@ export function optimizeConversation(input, options = {}) {
154
182
  }
155
183
  // -- dedupe: identical content beyond the first occurrence --------------------
156
184
  if (opts.strategies.includes("dedupe")) {
185
+ // The recent tail is what the model is actually answering. Replacing a
186
+ // message there with "identical to #0" is technically true and practically
187
+ // awful: through the proxy, a user who pastes the same document twice has
188
+ // their CURRENT question swapped for a pointer to a message ten turns back,
189
+ // and just sees a worse answer with no explanation. Older copies are fair
190
+ // game; the live turn is not.
191
+ const cutoff = stableCutoff(messages.length, opts.keepRecent);
157
192
  const seen = new Map();
158
193
  messages.forEach((m, i) => {
159
194
  const text = textOf(m.content);
160
195
  if (text.length < 300)
161
196
  return;
197
+ if (i >= cutoff) {
198
+ // Still record it, so a later duplicate can point back here.
199
+ if (!seen.has(hash(text)))
200
+ seen.set(hash(text), i);
201
+ return;
202
+ }
162
203
  const h = hash(text);
163
204
  const first = seen.get(h);
164
205
  if (first === undefined) {
@@ -172,7 +213,7 @@ export function optimizeConversation(input, options = {}) {
172
213
  }
173
214
  // -- trim-tool-results: shrink stale tool output ------------------------------
174
215
  if (opts.strategies.includes("trim-tool-results")) {
175
- const cutoff = messages.length - opts.keepRecent;
216
+ const cutoff = stableCutoff(messages.length, opts.keepRecent);
176
217
  messages.forEach((m, i) => {
177
218
  if (i >= cutoff || !isToolResultMessage(m))
178
219
  return;
@@ -192,7 +233,7 @@ export function optimizeConversation(input, options = {}) {
192
233
  }
193
234
  // -- trim-tool-calls: shrink the arguments of calls that already ran ----------
194
235
  if (opts.strategies.includes("trim-tool-calls")) {
195
- const cutoff = messages.length - opts.keepRecent;
236
+ const cutoff = stableCutoff(messages.length, opts.keepRecent);
196
237
  messages.forEach((m, i) => {
197
238
  if (i >= cutoff)
198
239
  return;
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.2",
3
+ "version": "0.13.1",
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/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"
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 dist/test/ledger.test.js"
46
46
  },
47
47
  "dependencies": {
48
48
  "@modelcontextprotocol/sdk": "^1.0.0",