context-doctor 0.13.5 → 0.14.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
@@ -94,6 +94,7 @@ Practical upshot: a developer who only wants cheaper, faster API calls never tou
94
94
  | `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 |
95
95
  | `context-doctor session [file]` | Profile a Claude Code session: live context, findings, **measured tokens and prompt-cache economics**, and **where the wall clock went** per tool (from transcript timestamps, permission waits included and said so). Also reads ChatGPT data exports (`conversations.json`) |
96
96
  | `context-doctor init [preset]` | Write a `.contextdoctorrc` from a preset (`chat`, `agent`, `batch`) — a budget you can adopt in one command and tune later |
97
+ | `context-doctor experiment --task "…"` | Run one task twice from the same commit, in a fresh session and forked from an `--existing` one, same model and tools; compare bill, cache split, wall clock, and whether `--check` passed. The only command here that spends money, so it caps spend per arm and refuses a dirty tree |
97
98
  | `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 |
98
99
  | `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 |
99
100
  | `context-doctor cursor [--list]` | Profile a chat from Cursor's local history (both storage formats) |
@@ -102,6 +103,7 @@ Practical upshot: a developer who only wants cheaper, faster API calls never tou
102
103
  | `context-doctor watch [file]` | Live monitor of a growing session/agent trace: token/cost line per change, findings as they appear |
103
104
  | `context-doctor doctor` | Self-check the whole installation — one pasteable ✓/✗ diagnosis with fixes |
104
105
  | `context-doctor dashboard` | Local savings dashboard on 127.0.0.1: tokens saved per day, sessions by context in use vs recoverable, budget status |
106
+ | `context-doctor statusline` | Claude Code status bar: live context vs window, cache share, cost. Wired by `install --statusline`; never overwrites a statusLine you already have |
105
107
  | `context-doctor hook` | The every-prompt Claude Code hook (registered by `install`; you never run this yourself). Warning threshold tunable via `CONTEXT_DOCTOR_WARN_TOKENS` (default 80000) |
106
108
  | `context-doctor-mcp` | The MCP server itself — stdio by default (what the installer wires); `--http [--port 8808] [--host H]` serves streamable HTTP at `/mcp` for URL-based clients like ChatGPT developer-mode connectors |
107
109
 
@@ -168,7 +170,7 @@ The proxy dedupes repeated content, trims stale tool results, and strips base64
168
170
  [context-doctor] POST /v1/messages → 200 in 842ms | optimized 7.3k → 518 tokens (2 changes) | session total: 6.9k tokens ≈ $0.021 saved
169
171
  ```
170
172
 
171
- `GET http://localhost:8787/stats` returns cumulative savings (requests, tokens, estimated USD), **exact upstream usage** read from every response (JSON and SSE), and **prompt-cache advisories** — the proxy watches your real traffic and flags big stable prefixes missing `cache_control` or prefix churn that silently re-bills the cache. Per-model behavior via `--config`:
173
+ `GET http://localhost:8787/stats` returns cumulative savings (requests, tokens, estimated USD), **exact upstream usage** read from every response (JSON and SSE), and **prompt-cache advisories** — the proxy watches your real traffic and flags big stable prefixes missing `cache_control` or prefix churn that silently re-bills the cache. Per-model behavior via `--config`: The advisor now says *where*: for a large system/tools prefix it names the block to mark (the last tool definition, or the last system block), and when the older messages were byte-identical to the previous request it names the exact message to put `cache_control` on, with the token count that run is re-billing each turn. Anyone who already placed breakpoints is left alone.
172
174
 
173
175
  ```json
174
176
  { "routes": [{ "modelPrefix": "gpt", "strategies": ["strip-base64"], "keepRecent": 4 }] }
@@ -246,6 +248,57 @@ const { conversation, tokensBefore, tokensAfter } = optimizeConversation(chatJso
246
248
  });
247
249
  ```
248
250
 
251
+ ## Context health in Claude Code's status bar
252
+
253
+ ```bash
254
+ context-doctor install --statusline
255
+ ```
256
+
257
+ Claude Code shows the first line a `statusLine` command prints, on every refresh, while you type. With this on, that line is the number that matters:
258
+
259
+ ```
260
+ ctx 801k/1.0M ▮▮▮▮▮▮▮▮░░ 80% ⚠ · cache 100% · $12.34
261
+ ```
262
+
263
+ Live context against the model's window, a warning mark from 70%, the share served from cache, and the session's cost. It reads the size from the status payload when Claude Code provides it, and otherwise from the last 256KB of the transcript (about 1ms on a 20MB file; 80ms end to end including Node startup). It is opt-in and polite: there is only one status line, so it never overwrites one you already have, and `uninstall` removes only its own. Any failure prints nothing rather than an error.
264
+
265
+ This is the "editor status bar" roadmap item, delivered for the editor most users of this tool are actually in. A VS Code / Cursor extension for the same number remains future work.
266
+
267
+ ## Does a smaller context actually help? Measure it
268
+
269
+ Everything else in this tool measures what is *in* the context. None of it can tell you whether the task succeeded, so a smaller transcript can be a cheaper failure. `experiment` is the honest test:
270
+
271
+ ```bash
272
+ context-doctor experiment \
273
+ --task "Add a null check to parseHeader in src/parse.ts and make the tests pass" \
274
+ --check "npm test" \
275
+ --existing 9c6f9dc9-457b-4d09-bf6d-a499c2f2f919 \
276
+ --model sonnet --budget 1
277
+ ```
278
+
279
+ It runs the task in a **fresh** session, resets the tree to the starting commit, then runs it again **forked from your existing session** (`--resume … --fork-session`, so your real session is never touched), same model, same tools. For each arm it records what you were billed (input, cache read, cache write, output), cost, wall clock, turns, and whether your `--check` command passed, then puts them side by side:
280
+
281
+ ```
282
+ fresh existing
283
+ ──────────────────────────────────────────────────
284
+ billed input 20k 65k
285
+ of which cache read 0 60k
286
+ of which cache write 8.0k 4.0k
287
+ cost $0.110 $0.420
288
+ wall clock 4s 9s
289
+ check PASS FAIL (1)
290
+
291
+ Verdict: fresh was cheaper AND passed; existing failed the check. Clear win for fresh.
292
+ ```
293
+
294
+ The verdict line is the point: cheaper only counts if it also passed. Because this spends your Claude budget it caps spend per arm (`--budget`, default $1), refuses to start on a dirty tree (both arms must begin from one commit, and the tree is reset between them), and refuses to run from inside a Claude Code session, where the CLI cannot start. `--dry-run` shows the exact commands first.
295
+
296
+ ## Exact counts, and what they teach the estimator
297
+
298
+ The default token count is a chars-per-token heuristic so everything runs with no key and no tokenizer. Its error is content-dependent, and there is no honest way to fix that from transcripts alone (the billed number includes content the transcript never sees). `analyze --exact` fetches a true count for the exact bytes just estimated (Anthropic's count-tokens API with `ANTHROPIC_API_KEY`; tiktoken for GPT if installed) and prints the drift.
299
+
300
+ Since 0.13.9 it also **remembers the comparison**, per model family, on this machine, and later estimates for that family are scaled by it. Nothing about this is silent: the profile header says `estimates calibrated +12% from 3 exact count(s) you ran on this machine`. No exact count ever run means no calibration and unchanged numbers; out-of-range samples are ignored; `CONTEXT_DOCTOR_NO_CALIBRATION=1` returns to the raw heuristic.
301
+
249
302
  ## What it detects
250
303
 
251
304
  - **Oversized tool results** — the #1 context killer in agent loops
@@ -270,10 +323,12 @@ const { conversation, tokensBefore, tokensAfter } = optimizeConversation(chatJso
270
323
  | `trim-tool-calls` — shrink the arguments of calls that already ran | Mostly no | opt-in |
271
324
  | `prune-history` — collapse old turns into a stub for summarization | Yes | opt-in |
272
325
 
273
- `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.
326
+ `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, and that is measured, not cautious: across 42 real sessions, of 73 large writes 18 were later edited and **16 of those edits had no read in between**, meaning the model built the edit from its own earlier `Write` input, at a median distance of 43 messages. Trimming those would turn each into a failed edit plus a recovery read. In the offline optimizer the rest of the conversation is known, so exactly those writes are left intact and the 63% never touched again are trimmed; in the live proxy the future is not known, which is why it stays off there unless you turn it on.
274
327
 
275
328
  **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.
276
329
 
330
+ The step size is a trade-off, not a formula, and it is yours to set: `"trimBoundaryStep": 20` in `.contextdoctorrc` (default 10). Measured on a growing agent session at 400 turns: a step of 10 invalidated the cache on 21% of turns with ~2 stale results waiting on average; 20 gave 12% and ~4; 40 gave 10% and ~9. Adaptive steps were worse everywhere, because a step that changes size moves the boundary by itself. Heavy API users who lean on caching want a bigger step; interactive users who want stale output gone promptly want a smaller one.
331
+
277
332
  Everything the optimizer does is inspectable: it prints exactly which messages changed and how many tokens each change saved.
278
333
 
279
334
  **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,28 @@
1
+ /**
2
+ * Heuristic calibration from the user's own exact counts.
3
+ *
4
+ * The chars-per-token heuristic is what lets everything run with no key and
5
+ * no tokenizer, and its error is content-dependent: dense JSON tokenizes very
6
+ * differently from prose. There is no honest way to fix that from transcripts
7
+ * alone (the billed number includes content the transcript never sees). But
8
+ * when someone runs `analyze --exact`, they fetch a true count for the exact
9
+ * bytes the heuristic just estimated — the one clean comparison available.
10
+ *
11
+ * So that comparison is remembered, per model family, on this machine, and
12
+ * applied to later estimates for the same family. Nothing is applied silently:
13
+ * the profile carries the factor and the sample count, and the report prints
14
+ * them. No exact count ever run means no calibration, and the numbers are
15
+ * exactly what they were before.
16
+ */
17
+ export interface Calibration {
18
+ /** Multiply heuristic estimates by this. 1 means uncalibrated. */
19
+ factor: number;
20
+ samples: number;
21
+ }
22
+ export declare function calibrationPath(): string;
23
+ /** "claude", "gpt", "gemini", … — coarse on purpose; tokenizers are per vendor. */
24
+ export declare function modelFamily(model?: string): string;
25
+ /** Remember one exact-vs-heuristic observation. Best-effort; never throws. */
26
+ export declare function recordCalibration(model: string | undefined, exactTokens: number, heuristicTokens: number): void;
27
+ /** The factor to apply for a model, or 1 with zero samples when there is none. */
28
+ export declare function calibrationFor(model?: string): Calibration;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Heuristic calibration from the user's own exact counts.
3
+ *
4
+ * The chars-per-token heuristic is what lets everything run with no key and
5
+ * no tokenizer, and its error is content-dependent: dense JSON tokenizes very
6
+ * differently from prose. There is no honest way to fix that from transcripts
7
+ * alone (the billed number includes content the transcript never sees). But
8
+ * when someone runs `analyze --exact`, they fetch a true count for the exact
9
+ * bytes the heuristic just estimated — the one clean comparison available.
10
+ *
11
+ * So that comparison is remembered, per model family, on this machine, and
12
+ * applied to later estimates for the same family. Nothing is applied silently:
13
+ * the profile carries the factor and the sample count, and the report prints
14
+ * them. No exact count ever run means no calibration, and the numbers are
15
+ * exactly what they were before.
16
+ */
17
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
18
+ import { dirname, join } from "node:path";
19
+ import { statePath } from "./ledger.js";
20
+ /** Anything outside this is a bad sample, not a calibration. */
21
+ const MIN_FACTOR = 0.5;
22
+ const MAX_FACTOR = 2.0;
23
+ export function calibrationPath() {
24
+ return join(dirname(statePath()), ".context-doctor-calibration.json");
25
+ }
26
+ /** "claude", "gpt", "gemini", … — coarse on purpose; tokenizers are per vendor. */
27
+ export function modelFamily(model) {
28
+ const m = (model ?? "").toLowerCase();
29
+ if (m.includes("claude"))
30
+ return "claude";
31
+ if (/gpt|^o\d/.test(m))
32
+ return "gpt";
33
+ if (m.includes("gemini"))
34
+ return "gemini";
35
+ return m ? m.split(/[-_/]/)[0] : "unknown";
36
+ }
37
+ function readAll() {
38
+ try {
39
+ return JSON.parse(readFileSync(calibrationPath(), "utf8"));
40
+ }
41
+ catch {
42
+ return {};
43
+ }
44
+ }
45
+ /** Remember one exact-vs-heuristic observation. Best-effort; never throws. */
46
+ export function recordCalibration(model, exactTokens, heuristicTokens) {
47
+ if (!(exactTokens > 0) || !(heuristicTokens > 0))
48
+ return;
49
+ const ratio = exactTokens / heuristicTokens;
50
+ if (ratio < MIN_FACTOR || ratio > MAX_FACTOR)
51
+ return; // a broken sample must not poison the file
52
+ try {
53
+ const all = readAll();
54
+ const key = modelFamily(model);
55
+ const rec = all[key] ?? { exactSum: 0, heuristicSum: 0, samples: 0 };
56
+ all[key] = { exactSum: rec.exactSum + exactTokens, heuristicSum: rec.heuristicSum + heuristicTokens, samples: rec.samples + 1 };
57
+ mkdirSync(dirname(calibrationPath()), { recursive: true });
58
+ writeFileSync(calibrationPath(), JSON.stringify(all, null, 2));
59
+ }
60
+ catch {
61
+ /* calibration is a refinement; failing to save it must not fail the command */
62
+ }
63
+ }
64
+ /** The factor to apply for a model, or 1 with zero samples when there is none. */
65
+ export function calibrationFor(model) {
66
+ if (process.env.CONTEXT_DOCTOR_NO_CALIBRATION)
67
+ return { factor: 1, samples: 0 };
68
+ const rec = readAll()[modelFamily(model)];
69
+ if (!rec || rec.samples < 1 || rec.heuristicSum <= 0)
70
+ return { factor: 1, samples: 0 };
71
+ const factor = rec.exactSum / rec.heuristicSum;
72
+ if (!Number.isFinite(factor) || factor < MIN_FACTOR || factor > MAX_FACTOR)
73
+ return { factor: 1, samples: 0 };
74
+ return { factor, samples: rec.samples };
75
+ }
package/dist/cli.js CHANGED
@@ -24,9 +24,12 @@ import { recordLedger } from "./ledger.js";
24
24
  import { runDoctor } from "./doctor.js";
25
25
  import { measureAccuracy, renderAccuracy } from "./accuracy.js";
26
26
  import { renderDiff } from "./diff.js";
27
+ import { renderExperiment, runExperiment } from "./experiment.js";
28
+ import { runStatusLine } from "./statusline.js";
27
29
  import { findPreset, PRESETS, RC_FILENAME } from "./config.js";
28
30
  import { runWatch } from "./watch.js";
29
31
  import { exactTokenCount } from "./exact.js";
32
+ import { modelFamily, recordCalibration } from "./calibration.js";
30
33
  import { checkBudget, loadConfig } from "./config.js";
31
34
  import { startDashboard } from "./dashboard.js";
32
35
  import { listCursorChats, parseCursorChat } from "./cursor.js";
@@ -45,6 +48,9 @@ Usage:
45
48
  context-doctor session [file] Profile a Claude Code session transcript or a
46
49
  ChatGPT export (default: most recent; --list to browse)
47
50
  context-doctor cursor [--list] Profile a Cursor chat from its local history
51
+ context-doctor statusline Claude Code status bar line: live context size, cache
52
+ share, cost (wired by \`install --statusline\`; reads
53
+ the status JSON on stdin)
48
54
  context-doctor hook Claude Code UserPromptSubmit hook (installed
49
55
  automatically by \`install\`; reads hook JSON on stdin)
50
56
  context-doctor report Impact report: exact proxy savings, hook activity,
@@ -55,6 +61,9 @@ Usage:
55
61
  default 8790) — charts from your own machine only
56
62
  context-doctor init [preset] Write a .contextdoctorrc from a preset
57
63
  (chat, agent, batch; no argument lists them)
64
+ context-doctor experiment --task "<t>" Run one task twice from the same commit, in a fresh
65
+ session and forked from an --existing one; compare
66
+ bill, cache, time, and whether --check passed
58
67
  context-doctor diff <before> <after> Compare two profiles: what moved, which findings
59
68
  were resolved, and what it saves
60
69
  context-doctor accuracy Measure the token heuristic against the API's own
@@ -88,6 +97,13 @@ Options:
88
97
  --keep-recent <n> (optimize) Messages at the tail to leave untouched (default 6)
89
98
  --max-tool-tokens <n> (optimize) Token budget for trimmed tool results (default 300)
90
99
  --limit <n> (accuracy) Sessions to sample (default 20)
100
+ --check <cmd> (experiment) Command whose exit code is the pass/fail for each arm
101
+ --existing <id> (experiment) Session id to fork the second arm from (never mutated)
102
+ --budget <usd> (experiment) Spend cap per arm (default 1)
103
+ --dry-run (experiment) Print the claude commands and stop
104
+ --allow-dirty (experiment) Skip the clean-tree check (uncommitted changes will be lost)
105
+ --statusline (install) Also set Claude Code's statusLine to context-doctor (never
106
+ overwrites a statusLine you already have)
91
107
  --port <n> (proxy) Port to listen on (default 8787)
92
108
  --host <addr> (proxy) Bind address (default 127.0.0.1; use 0.0.0.0 to expose)
93
109
  --config <file> (proxy) Per-route overrides: {"routes":[{"modelPrefix":"gpt","strategies":[...],
@@ -104,7 +120,7 @@ Examples:
104
120
  export OPENAI_BASE_URL=http://localhost:8787/v1
105
121
  `;
106
122
  function parseArgs(argv) {
107
- const args = { json: false, strategies: [], list: false, exact: false, redact: false, failOverBudget: false };
123
+ const args = { json: false, strategies: [], list: false, exact: false, redact: false, failOverBudget: false, dryRun: false, allowDirty: false, statusLine: false };
108
124
  const positional = [];
109
125
  for (let i = 0; i < argv.length; i++) {
110
126
  const a = argv[i];
@@ -152,6 +168,27 @@ function parseArgs(argv) {
152
168
  case "--limit":
153
169
  args.limit = Number(argv[++i]);
154
170
  break;
171
+ case "--task":
172
+ args.task = argv[++i];
173
+ break;
174
+ case "--check":
175
+ args.check = argv[++i];
176
+ break;
177
+ case "--existing":
178
+ args.existing = argv[++i];
179
+ break;
180
+ case "--budget":
181
+ args.budgetUsd = Number(argv[++i]);
182
+ break;
183
+ case "--dry-run":
184
+ args.dryRun = true;
185
+ break;
186
+ case "--allow-dirty":
187
+ args.allowDirty = true;
188
+ break;
189
+ case "--statusline":
190
+ args.statusLine = true;
191
+ break;
155
192
  case "--host":
156
193
  args.host = argv[++i];
157
194
  break;
@@ -244,6 +281,22 @@ function main() {
244
281
  console.log(" Gate a pull request on it with: context-doctor analyze <file> --fail-over-budget");
245
282
  return;
246
283
  }
284
+ if (args.command === "statusline") {
285
+ void runStatusLine();
286
+ return;
287
+ }
288
+ if (args.command === "experiment") {
289
+ if (!args.task) {
290
+ console.error('Usage: context-doctor experiment --task "<what to do>" [--check "<cmd>"] [--existing <session-id>] [--model m] [--budget usd] [--dry-run]');
291
+ process.exit(1);
292
+ }
293
+ const opts = { task: args.task, check: args.check, existing: args.existing, model: args.model, budgetUsd: args.budgetUsd, dryRun: args.dryRun, allowDirty: args.allowDirty };
294
+ const result = runExperiment(opts);
295
+ console.log(args.json ? JSON.stringify(result, null, 2) : renderExperiment(result, opts));
296
+ if (result.refused)
297
+ process.exitCode = 1;
298
+ return;
299
+ }
247
300
  if (args.command === "diff") {
248
301
  const [before, after] = args.positionals ?? [];
249
302
  if (!before || !after) {
@@ -367,7 +420,7 @@ function main() {
367
420
  if (args.command === "install") {
368
421
  // Partial success is still installed, but not silent: any failed target
369
422
  // makes the exit code non-zero so automation can react.
370
- if (runInstall().failures.length > 0)
423
+ if (runInstall({ statusLine: args.statusLine }).failures.length > 0)
371
424
  process.exitCode = 1;
372
425
  return;
373
426
  }
@@ -396,6 +449,7 @@ function main() {
396
449
  strategies: args.strategies.length > 0 ? args.strategies : loadedRc.config.strategies,
397
450
  keepRecent: args.keepRecent ?? loadedRc.config.keepRecent,
398
451
  maxToolResultTokens: args.maxToolTokens ?? loadedRc.config.maxToolResultTokens,
452
+ trimBoundaryStep: loadedRc.config.trimBoundaryStep,
399
453
  });
400
454
  return; // server keeps the process alive
401
455
  }
@@ -424,6 +478,11 @@ function main() {
424
478
  if (exact.tokens !== undefined) {
425
479
  const drift = profile.totalTokens > 0 ? Math.round(((exact.tokens - profile.totalTokens) / exact.tokens) * 100) : 0;
426
480
  console.log(`\nExact input tokens: ${exact.tokens} (${exact.source}) — heuristic was off by ${drift}%`);
481
+ // Remember the comparison so the next estimate for this model family
482
+ // starts from the user's own ground truth instead of a constant.
483
+ const raw = profile.calibration ? profile.totalTokens / profile.calibration.factor : profile.totalTokens;
484
+ recordCalibration(args.model ?? profile.model, exact.tokens, raw);
485
+ console.log(`Remembered: future ${modelFamily(args.model ?? profile.model)} estimates on this machine are calibrated from this (CONTEXT_DOCTOR_NO_CALIBRATION=1 to disable).`);
427
486
  }
428
487
  else {
429
488
  console.log(`\nExact count unavailable: ${exact.note}`);
@@ -440,6 +499,7 @@ function main() {
440
499
  strategies: args.strategies.length > 0 ? args.strategies : loaded.config.strategies,
441
500
  keepRecent: args.keepRecent ?? loaded.config.keepRecent,
442
501
  maxToolResultTokens: args.maxToolTokens ?? loaded.config.maxToolResultTokens,
502
+ trimBoundaryStep: loaded.config.trimBoundaryStep,
443
503
  });
444
504
  }
445
505
  catch (e) {
package/dist/config.d.ts CHANGED
@@ -25,6 +25,8 @@ export interface ContextDoctorConfig {
25
25
  strategies?: StrategyId[];
26
26
  keepRecent?: number;
27
27
  maxToolResultTokens?: number;
28
+ /** Messages the trim boundary moves at a time; see OptimizeOptions. */
29
+ trimBoundaryStep?: number;
28
30
  /** Proxy per-model overrides, same shape as `proxy --config`. */
29
31
  routes?: Array<{
30
32
  modelPrefix: string;
package/dist/config.js CHANGED
@@ -38,7 +38,7 @@ function candidatePaths(startDir) {
38
38
  */
39
39
  /** Strategy ids the optimizer actually implements. */
40
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"]);
41
+ const KNOWN_KEYS = new Set(["budget", "strategies", "keepRecent", "maxToolResultTokens", "trimBoundaryStep", "routes", "model"]);
42
42
  const KNOWN_BUDGET_KEYS = new Set(["maxTokens", "maxCostPerMessageUsd", "maxWindowPct"]);
43
43
  /**
44
44
  * Report anything in an rc file that will be silently ignored.
@@ -92,7 +92,7 @@ export function validateConfig(config, path) {
92
92
  }
93
93
  }
94
94
  }
95
- for (const key of ["keepRecent", "maxToolResultTokens"]) {
95
+ for (const key of ["keepRecent", "maxToolResultTokens", "trimBoundaryStep"]) {
96
96
  const value = c[key];
97
97
  if (value === undefined)
98
98
  continue;
package/dist/doctor.js CHANGED
@@ -135,6 +135,25 @@ export async function runDoctor() {
135
135
  else {
136
136
  checks.push({ label: "Every-prompt hook", status: "skip", detail: "Claude Code not detected" });
137
137
  }
138
+ // Status line (opt-in, so absence is a note, not a failure)
139
+ if (existsSync(settingsPath)) {
140
+ try {
141
+ const settings = JSON.parse(readFileSync(settingsPath, "utf8"));
142
+ const cmd = settings.statusLine?.command;
143
+ if (cmd && /context-doctor|cli\.js"?\s+statusline/.test(cmd)) {
144
+ const missing = hookBinaryMissing(cmd);
145
+ checks.push(missing
146
+ ? { label: "Status line", status: "fail", detail: `configured, but ${missing} no longer exists — re-run: context-doctor install --statusline` }
147
+ : { label: "Status line", status: "ok", detail: "live context in Claude Code's status bar" });
148
+ }
149
+ else {
150
+ checks.push({ label: "Status line", status: "skip", detail: cmd ? "you have your own statusLine (left alone)" : "not enabled (optional: context-doctor install --statusline)" });
151
+ }
152
+ }
153
+ catch {
154
+ /* settings.json unreadable is already reported by the hook check */
155
+ }
156
+ }
138
157
  // Skill
139
158
  const skillPath = join(homedir(), ".claude", "skills", "context-doctor", "SKILL.md");
140
159
  checks.push(existsSync(skillPath)
@@ -0,0 +1,58 @@
1
+ /**
2
+ * `context-doctor experiment` — the fresh-vs-existing session harness.
3
+ *
4
+ * Everything else in this tool measures what is IN the context. None of it can
5
+ * say whether the task succeeded, so a smaller transcript can be a cheaper
6
+ * failure. This runs the same task twice against the same starting commit —
7
+ * once in a fresh session, once forked from an existing one — with the same
8
+ * model and tools, records what each was billed and how long it took, runs the
9
+ * same check command against each result, and puts the two side by side.
10
+ *
11
+ * It spends the user's Claude budget, so it refuses to start on a dirty tree,
12
+ * caps spend per arm, forks the existing session rather than mutating it, and
13
+ * resets the tree between arms only because it verified the tree was clean.
14
+ */
15
+ export interface ExperimentOptions {
16
+ task: string;
17
+ /** Shell command whose exit code decides pass/fail, e.g. "npm test". */
18
+ check?: string;
19
+ /** Session id to fork the "existing" arm from. Omit to run the fresh arm only. */
20
+ existing?: string;
21
+ model?: string;
22
+ /** Spend cap per arm, USD. */
23
+ budgetUsd?: number;
24
+ cwd?: string;
25
+ /** Print the commands and stop. */
26
+ dryRun?: boolean;
27
+ /** Skip the clean-tree requirement (you accept the reset that follows). */
28
+ allowDirty?: boolean;
29
+ }
30
+ export interface ArmResult {
31
+ arm: "fresh" | "existing";
32
+ sessionId?: string;
33
+ costUsd: number;
34
+ durationMs: number;
35
+ turns: number;
36
+ inputTokens: number;
37
+ cacheRead: number;
38
+ cacheWrite: number;
39
+ outputTokens: number;
40
+ /** Billed input the transcript reports on the last request, when found. */
41
+ liveContextTokens?: number;
42
+ check?: {
43
+ command: string;
44
+ passed: boolean;
45
+ exitCode: number;
46
+ };
47
+ diffStat?: string;
48
+ error?: string;
49
+ }
50
+ /** Build the argv for one arm — exported so the dry run and the tests see exactly what runs. */
51
+ export declare function claudeArgs(opts: ExperimentOptions, arm: "fresh" | "existing"): string[];
52
+ export declare function runExperiment(opts: ExperimentOptions): {
53
+ arms: ArmResult[];
54
+ startCommit?: string;
55
+ refused?: string;
56
+ treeClean?: boolean;
57
+ };
58
+ export declare function renderExperiment(result: ReturnType<typeof runExperiment>, opts: ExperimentOptions): string;
@@ -0,0 +1,214 @@
1
+ /**
2
+ * `context-doctor experiment` — the fresh-vs-existing session harness.
3
+ *
4
+ * Everything else in this tool measures what is IN the context. None of it can
5
+ * say whether the task succeeded, so a smaller transcript can be a cheaper
6
+ * failure. This runs the same task twice against the same starting commit —
7
+ * once in a fresh session, once forked from an existing one — with the same
8
+ * model and tools, records what each was billed and how long it took, runs the
9
+ * same check command against each result, and puts the two side by side.
10
+ *
11
+ * It spends the user's Claude budget, so it refuses to start on a dirty tree,
12
+ * caps spend per arm, forks the existing session rather than mutating it, and
13
+ * resets the tree between arms only because it verified the tree was clean.
14
+ */
15
+ import { execFileSync, spawnSync } from "node:child_process";
16
+ import { existsSync } from "node:fs";
17
+ import { homedir } from "node:os";
18
+ import { join } from "node:path";
19
+ import { parseSessionFile } from "./session.js";
20
+ import { formatTokens } from "./tokens.js";
21
+ import { formatUsd } from "./pricing.js";
22
+ /** Where a test can point at a stub instead of the real CLI. */
23
+ function claudeBinary() {
24
+ return process.env.CONTEXT_DOCTOR_CLAUDE_BIN ?? "claude";
25
+ }
26
+ function git(cwd, ...args) {
27
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
28
+ }
29
+ function num(v) {
30
+ return typeof v === "number" && Number.isFinite(v) ? v : 0;
31
+ }
32
+ /** Build the argv for one arm — exported so the dry run and the tests see exactly what runs. */
33
+ export function claudeArgs(opts, arm) {
34
+ const args = ["-p", opts.task, "--output-format", "json", "--permission-mode", "acceptEdits"];
35
+ if (opts.model)
36
+ args.push("--model", opts.model);
37
+ args.push("--max-budget-usd", String(opts.budgetUsd ?? 1));
38
+ if (arm === "existing" && opts.existing)
39
+ args.push("--resume", opts.existing, "--fork-session");
40
+ return args;
41
+ }
42
+ function findTranscript(sessionId, cwd) {
43
+ // Claude Code keys project dirs by the cwd with separators replaced.
44
+ const projectDir = join(homedir(), ".claude", "projects", cwd.replace(/[\\/:]/g, "-"));
45
+ const candidate = join(projectDir, `${sessionId}.jsonl`);
46
+ return existsSync(candidate) ? candidate : undefined;
47
+ }
48
+ function runArm(opts, arm, cwd) {
49
+ const started = Date.now();
50
+ const proc = spawnSync(claudeBinary(), claudeArgs(opts, arm), {
51
+ cwd,
52
+ encoding: "utf8",
53
+ env: { ...process.env, CLAUDECODE: undefined },
54
+ maxBuffer: 64 * 1024 * 1024,
55
+ });
56
+ const base = {
57
+ arm, costUsd: 0, durationMs: Date.now() - started, turns: 0,
58
+ inputTokens: 0, cacheRead: 0, cacheWrite: 0, outputTokens: 0,
59
+ };
60
+ if (proc.error)
61
+ return { ...base, error: `could not run ${claudeBinary()}: ${proc.error.message}` };
62
+ let parsed;
63
+ try {
64
+ parsed = JSON.parse(proc.stdout);
65
+ }
66
+ catch {
67
+ return { ...base, error: `claude did not return JSON (exit ${proc.status}): ${(proc.stderr || proc.stdout).slice(0, 300)}` };
68
+ }
69
+ const usage = parsed.usage ?? {};
70
+ const result = {
71
+ ...base,
72
+ sessionId: parsed.session_id,
73
+ costUsd: num(parsed.total_cost_usd),
74
+ durationMs: num(parsed.duration_ms) || base.durationMs,
75
+ turns: num(parsed.num_turns),
76
+ inputTokens: num(usage.input_tokens),
77
+ cacheRead: num(usage.cache_read_input_tokens),
78
+ cacheWrite: num(usage.cache_creation_input_tokens),
79
+ outputTokens: num(usage.output_tokens),
80
+ error: parsed.is_error ? `claude reported an error: ${String(parsed.result ?? "").slice(0, 300)}` : undefined,
81
+ };
82
+ if (parsed.session_id) {
83
+ const transcript = findTranscript(parsed.session_id, cwd);
84
+ if (transcript) {
85
+ try {
86
+ result.liveContextTokens = parseSessionFile(transcript).reportedInputTokens;
87
+ }
88
+ catch {
89
+ /* the numbers above stand on their own */
90
+ }
91
+ }
92
+ }
93
+ if (opts.check) {
94
+ const check = spawnSync(opts.check, { cwd, shell: true, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
95
+ result.check = { command: opts.check, passed: check.status === 0, exitCode: check.status ?? -1 };
96
+ }
97
+ try {
98
+ result.diffStat = git(cwd, "diff", "--stat") || "(no changes)";
99
+ }
100
+ catch {
101
+ /* not a git repo after all; the arm still ran */
102
+ }
103
+ return result;
104
+ }
105
+ export function runExperiment(opts) {
106
+ const cwd = opts.cwd ?? process.cwd();
107
+ if (process.env.CLAUDECODE && !opts.dryRun) {
108
+ return { arms: [], refused: "This runs the claude CLI, which refuses to start inside another Claude Code session. Run the experiment from a regular terminal." };
109
+ }
110
+ let startCommit;
111
+ let clean = false;
112
+ try {
113
+ startCommit = git(cwd, "rev-parse", "HEAD");
114
+ clean = git(cwd, "status", "--porcelain") === "";
115
+ }
116
+ catch {
117
+ return { arms: [], refused: `${cwd} is not a git repository. The experiment needs a commit to reset to between arms.` };
118
+ }
119
+ // A dry run touches nothing, so a dirty tree only needs mentioning.
120
+ if (opts.dryRun)
121
+ return { arms: [], startCommit, treeClean: clean };
122
+ if (!clean && !opts.allowDirty) {
123
+ return { arms: [], refused: "Working tree has uncommitted changes. Both arms must start from the same commit, and the tree is reset between them — commit or stash first (or pass --allow-dirty to accept losing those changes)." };
124
+ }
125
+ const arms = [];
126
+ const reset = () => {
127
+ // Safe only because the tree was verified clean (or the user opted in).
128
+ git(cwd, "reset", "--hard", startCommit);
129
+ git(cwd, "clean", "-fd");
130
+ };
131
+ arms.push(runArm(opts, "fresh", cwd));
132
+ reset();
133
+ if (opts.existing) {
134
+ arms.push(runArm(opts, "existing", cwd));
135
+ reset();
136
+ }
137
+ return { arms, startCommit };
138
+ }
139
+ export function renderExperiment(result, opts) {
140
+ const lines = [];
141
+ lines.push("CONTEXT DOCTOR — fresh vs existing session");
142
+ lines.push("═".repeat(56));
143
+ if (result.refused) {
144
+ lines.push(`Refused: ${result.refused}`);
145
+ return lines.join("\n");
146
+ }
147
+ lines.push(`task: ${opts.task}`);
148
+ if (result.startCommit)
149
+ lines.push(`commit: ${result.startCommit.slice(0, 12)} (both arms start here)`);
150
+ if (opts.model)
151
+ lines.push(`model: ${opts.model}`);
152
+ if (opts.check)
153
+ lines.push(`check: ${opts.check}`);
154
+ lines.push(`budget: ${formatUsd(opts.budgetUsd ?? 1)} per arm`);
155
+ if (opts.dryRun) {
156
+ lines.push("");
157
+ lines.push("Dry run. Commands that would execute:");
158
+ lines.push(` fresh: claude ${claudeArgs(opts, "fresh").map((a) => (a.includes(" ") ? JSON.stringify(a) : a)).join(" ")}`);
159
+ if (opts.existing)
160
+ lines.push(` existing: claude ${claudeArgs(opts, "existing").map((a) => (a.includes(" ") ? JSON.stringify(a) : a)).join(" ")}`);
161
+ lines.push(" (tree is reset to the start commit after each arm)");
162
+ if (result.treeClean === false)
163
+ lines.push(" NOTE: the tree is dirty right now; a real run would refuse unless you commit, stash, or pass --allow-dirty.");
164
+ return lines.join("\n");
165
+ }
166
+ lines.push("");
167
+ const col = (s, w = 14) => s.padStart(w);
168
+ const header = `${"".padEnd(22)}${col("fresh")}${opts.existing ? col("existing") : ""}`;
169
+ lines.push(header);
170
+ lines.push("─".repeat(header.length));
171
+ const row = (label, pick) => {
172
+ lines.push(`${label.padEnd(22)}${result.arms.map((a) => col(pick(a))).join("")}`);
173
+ };
174
+ row("billed input", (a) => formatTokens(a.inputTokens + a.cacheRead + a.cacheWrite));
175
+ row(" of which cache read", (a) => formatTokens(a.cacheRead));
176
+ row(" of which cache write", (a) => formatTokens(a.cacheWrite));
177
+ row("output tokens", (a) => formatTokens(a.outputTokens));
178
+ row("cost", (a) => formatUsd(a.costUsd));
179
+ row("wall clock", (a) => `${(a.durationMs / 1000).toFixed(0)}s`);
180
+ row("turns", (a) => String(a.turns));
181
+ if (result.arms.some((a) => a.liveContextTokens))
182
+ row("live context (last)", (a) => (a.liveContextTokens ? formatTokens(a.liveContextTokens) : "—"));
183
+ if (opts.check)
184
+ row("check", (a) => (a.check ? (a.check.passed ? "PASS" : `FAIL (${a.check.exitCode})`) : "—"));
185
+ lines.push("");
186
+ for (const a of result.arms) {
187
+ if (a.error)
188
+ lines.push(`${a.arm}: ${a.error}`);
189
+ if (a.diffStat && a.diffStat !== "(no changes)")
190
+ lines.push(`${a.arm} changed:\n${a.diffStat.split("\n").map((l) => " " + l).join("\n")}`);
191
+ }
192
+ // The verdict is the whole point: cheaper is only better if it also passed.
193
+ if (opts.existing && result.arms.length === 2 && opts.check) {
194
+ const [fresh, existing] = result.arms;
195
+ const cheaper = fresh.costUsd <= existing.costUsd ? fresh : existing;
196
+ const other = cheaper === fresh ? existing : fresh;
197
+ if (cheaper.check?.passed && !other.check?.passed) {
198
+ lines.push(`Verdict: ${cheaper.arm} was cheaper AND passed; ${other.arm} failed the check. Clear win for ${cheaper.arm}.`);
199
+ }
200
+ else if (cheaper.check?.passed && other.check?.passed) {
201
+ lines.push(`Verdict: both passed; ${cheaper.arm} was cheaper by ${formatUsd(Math.abs(fresh.costUsd - existing.costUsd))}.`);
202
+ }
203
+ else if (!cheaper.check?.passed && other.check?.passed) {
204
+ lines.push(`Verdict: ${cheaper.arm} was cheaper but FAILED the check. A smaller bill for a wrong answer is not a saving; ${other.arm} wins.`);
205
+ }
206
+ else {
207
+ lines.push("Verdict: neither arm passed the check. Cost comparison is moot until one does.");
208
+ }
209
+ }
210
+ else if (opts.check) {
211
+ lines.push("One arm only. Pass --existing <session-id> to compare against a forked existing session.");
212
+ }
213
+ return lines.join("\n");
214
+ }
package/dist/install.d.ts CHANGED
@@ -20,6 +20,13 @@ export declare function npxLauncher(platformName: string): {
20
20
  command: string;
21
21
  args: string[];
22
22
  };
23
+ /**
24
+ * Claude Code's status bar: a `statusLine` command whose stdout is shown while
25
+ * the user types. Opt-in, because there is only one status line and it may
26
+ * already be someone's own — this never overwrites a statusLine that is not
27
+ * ours. Returns what happened so install can print the truth.
28
+ */
29
+ export declare function installStatusLine(): "installed" | "already" | "kept-foreign" | "no-claude-code";
23
30
  /** Outcome of an install run, so the CLI can set a truthful exit code. */
24
31
  export interface InstallResult {
25
32
  /** Detected targets that could not be configured, with the reason. */
@@ -35,5 +42,7 @@ export interface InstallResult {
35
42
  * failed target is a lie that surfaces later as "the tools never showed up".
36
43
  * So: keep going, summarize, and return the failures for a non-zero exit.
37
44
  */
38
- export declare function runInstall(): InstallResult;
45
+ export declare function runInstall(options?: {
46
+ statusLine?: boolean;
47
+ }): InstallResult;
39
48
  export declare function runUninstall(): void;
package/dist/install.js CHANGED
@@ -122,14 +122,54 @@ function isEphemeralPath(path) {
122
122
  * prompt. `node` (not process.execPath) keeps it alive across Node upgrades.
123
123
  */
124
124
  function hookCommand() {
125
+ return cliCommand("hook");
126
+ }
127
+ /** The same durable command resolution, for any subcommand Claude Code runs for us. */
128
+ function cliCommand(subcommand) {
125
129
  const selfDir = dirname(fileURLToPath(import.meta.url));
126
130
  const localCli = join(selfDir, "cli.js");
127
131
  if (!isEphemeralPath(selfDir + sep) && existsSync(localCli))
128
- return `node "${localCli}" hook`;
132
+ return `node "${localCli}" ${subcommand}`;
129
133
  const global = binOnPath("context-doctor");
130
134
  if (global)
131
- return `"${global}" hook`;
132
- return "npx -y context-doctor hook";
135
+ return `"${global}" ${subcommand}`;
136
+ return `npx -y context-doctor ${subcommand}`;
137
+ }
138
+ /**
139
+ * Claude Code's status bar: a `statusLine` command whose stdout is shown while
140
+ * the user types. Opt-in, because there is only one status line and it may
141
+ * already be someone's own — this never overwrites a statusLine that is not
142
+ * ours. Returns what happened so install can print the truth.
143
+ */
144
+ export function installStatusLine() {
145
+ const settingsPath = join(homedir(), ".claude", "settings.json");
146
+ if (!existsSync(join(homedir(), ".claude")))
147
+ return "no-claude-code";
148
+ const settings = readJson(settingsPath);
149
+ const want = cliCommand("statusline");
150
+ const current = settings.statusLine?.command;
151
+ if (current === want)
152
+ return "already";
153
+ if (current && !isOurStatusLine(current))
154
+ return "kept-foreign";
155
+ settings.statusLine = { type: "command", command: want };
156
+ writeJsonWithBackup(settingsPath, settings);
157
+ return "installed";
158
+ }
159
+ function isOurStatusLine(command) {
160
+ return /context-doctor|cli\.js"?\s+statusline\s*$/.test(command);
161
+ }
162
+ function uninstallStatusLine() {
163
+ const settingsPath = join(homedir(), ".claude", "settings.json");
164
+ if (!existsSync(settingsPath))
165
+ return;
166
+ const settings = readJson(settingsPath);
167
+ const current = settings.statusLine?.command;
168
+ if (current && isOurStatusLine(current)) {
169
+ delete settings.statusLine;
170
+ writeJsonWithBackup(settingsPath, settings);
171
+ console.log("✓ Claude Code status line removed");
172
+ }
133
173
  }
134
174
  /** True when the hook had to fall back to npx — worth telling the user. */
135
175
  function hookUsesNpx() {
@@ -224,7 +264,7 @@ function installSkill() {
224
264
  * failed target is a lie that surfaces later as "the tools never showed up".
225
265
  * So: keep going, summarize, and return the failures for a non-zero exit.
226
266
  */
227
- export function runInstall() {
267
+ export function runInstall(options = {}) {
228
268
  const entry = serverEntry();
229
269
  const found = targets().filter((t) => t.detect());
230
270
  if (found.length === 0) {
@@ -274,6 +314,28 @@ export function runInstall() {
274
314
  console.error(`✗ Claude Code every-prompt hook: ${e.message}`);
275
315
  failures.push("Claude Code hook");
276
316
  }
317
+ if (options.statusLine) {
318
+ try {
319
+ switch (installStatusLine()) {
320
+ case "installed":
321
+ console.log("✓ Claude Code status line installed — live context size, cache share and cost while you type");
322
+ break;
323
+ case "already":
324
+ console.log("✓ Claude Code status line already installed");
325
+ break;
326
+ case "kept-foreign":
327
+ console.log("– Claude Code status line left alone: you already have your own statusLine command. Remove it first if you want ours.");
328
+ break;
329
+ case "no-claude-code":
330
+ console.log("– status line skipped: Claude Code not detected");
331
+ break;
332
+ }
333
+ }
334
+ catch (e) {
335
+ console.error(`✗ Claude Code status line: ${e.message}`);
336
+ failures.push("Claude Code status line");
337
+ }
338
+ }
277
339
  if (failures.length > 0) {
278
340
  console.log(`\nDone with ${failures.length} problem(s): ${failures.join(", ")}. See the ✗ lines above.`);
279
341
  console.log("Everything else was installed. Exit code is 1 so scripts can tell; fix the file(s) and re-run install.");
@@ -306,6 +368,7 @@ export function runUninstall() {
306
368
  console.log("✓ Agent Skill removed");
307
369
  }
308
370
  uninstallHook();
371
+ uninstallStatusLine();
309
372
  // Remove our bookkeeping files too — uninstall means gone.
310
373
  for (const file of [".context-doctor-hook-state.json", ".context-doctor-ledger.jsonl"]) {
311
374
  const p = join(homedir(), ".claude", file);
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.13.5" }, { instructions: SERVER_INSTRUCTIONS });
40
+ const server = new McpServer({ name: "context-doctor", version: "0.14.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"),
@@ -13,6 +13,15 @@ export interface OptimizeOptions {
13
13
  keepRecent?: number;
14
14
  /** Max tokens a trimmed tool result — or tool-call argument set — keeps. */
15
15
  maxToolResultTokens?: number;
16
+ /**
17
+ * How many messages the trim boundary moves at a time. Bigger steps keep the
18
+ * prompt cache alive longer but leave stale results in place longer.
19
+ * Measured on a growing agent session: at 400 turns a step of 10 invalidated
20
+ * the cache on 21% of turns with ~2 stale results waiting on average; 20
21
+ * gave 12% and ~4; 40 gave 10% and ~9. Adaptive steps were worse everywhere,
22
+ * because a step that changes size moves the boundary by itself. Default 10.
23
+ */
24
+ trimBoundaryStep?: number;
16
25
  }
17
26
  export interface AppliedChange {
18
27
  strategy: StrategyId;
package/dist/optimize.js CHANGED
@@ -9,10 +9,12 @@
9
9
  import { createHash } from "node:crypto";
10
10
  import { estimateTokens } from "./tokens.js";
11
11
  import { hasBase64Blob, stripBase64Blobs } from "./blob.js";
12
+ const TRIM_BOUNDARY_STEP = 10;
12
13
  const DEFAULTS = {
13
14
  strategies: ["dedupe", "trim-tool-results", "strip-base64"],
14
15
  keepRecent: 6,
15
16
  maxToolResultTokens: 300,
17
+ trimBoundaryStep: TRIM_BOUNDARY_STEP,
16
18
  };
17
19
  /**
18
20
  * Shrink the arguments of a tool call that has already run.
@@ -25,6 +27,42 @@ const DEFAULTS = {
25
27
  * Keys are preserved (so the call still reads as itself) and only long string
26
28
  * values are cut, with an explicit marker so nothing looks silently complete.
27
29
  */
30
+ /** The file a Write-like call creates, or null for anything else. */
31
+ function writtenPath(block) {
32
+ if (block?.name === "Write" && typeof block.input?.file_path === "string")
33
+ return block.input.file_path;
34
+ const cmd = block?.name === "Bash" ? String(block.input?.command ?? "") : "";
35
+ const heredoc = /(?:cat|tee)\s*>+\s*([^\s<]+)\s*<</.exec(cmd);
36
+ return heredoc ? heredoc[1] : null;
37
+ }
38
+ /**
39
+ * Does the model later Edit this file without Reading it first? If so it is
40
+ * relying on the Write input still being in context, and trimming that input
41
+ * would break the Edit.
42
+ */
43
+ function editedLaterWithoutRead(messages, writeIndex, path) {
44
+ if (!path)
45
+ return false;
46
+ for (let j = writeIndex + 1; j < messages.length; j++) {
47
+ const content = messages[j]?.content;
48
+ if (!Array.isArray(content))
49
+ continue;
50
+ for (const b of content) {
51
+ if (b?.type !== "tool_use")
52
+ continue;
53
+ const target = b.input?.file_path;
54
+ const cmd = b.name === "Bash" ? String(b.input?.command ?? "") : "";
55
+ const touchesPath = target === path || cmd.includes(path);
56
+ if (!touchesPath)
57
+ continue;
58
+ if (b.name === "Read" || /\b(cat|head|tail|sed|less)\b/.test(cmd))
59
+ return false; // it re-read: safe
60
+ if (b.name === "Edit" || b.name === "MultiEdit")
61
+ return true; // edited blind: keep the Write
62
+ }
63
+ }
64
+ return false;
65
+ }
28
66
  function trimCallArguments(input, maxTokens) {
29
67
  const budgetChars = maxTokens * 4;
30
68
  const out = {};
@@ -54,17 +92,16 @@ function trimCallArguments(input, maxTokens) {
54
92
  * STEP turns instead of once per turn. Older content is trimmed slightly later
55
93
  * than it otherwise would be; that is much cheaper than losing the cache.
56
94
  */
57
- const TRIM_BOUNDARY_STEP = 10;
58
- function stableCutoff(messageCount, keepRecent) {
95
+ function stableCutoff(messageCount, keepRecent, step = TRIM_BOUNDARY_STEP) {
59
96
  const raw = messageCount - keepRecent;
60
97
  if (raw <= 0)
61
98
  return 0;
62
99
  // Below one step there is nothing to quantize to except zero, which would
63
100
  // silently disable trimming on every short conversation. Such a conversation
64
101
  // has no long stable prefix worth protecting anyway.
65
- if (raw < TRIM_BOUNDARY_STEP)
102
+ if (raw < step)
66
103
  return raw;
67
- return Math.floor(raw / TRIM_BOUNDARY_STEP) * TRIM_BOUNDARY_STEP;
104
+ return Math.floor(raw / step) * step;
68
105
  }
69
106
  function hash(text) {
70
107
  return createHash("sha1").update(text.replace(/\s+/g, " ").trim()).digest("hex");
@@ -171,6 +208,7 @@ export function optimizeConversation(input, options = {}) {
171
208
  strategies: options.strategies ?? DEFAULTS.strategies,
172
209
  keepRecent: options.keepRecent ?? DEFAULTS.keepRecent,
173
210
  maxToolResultTokens: options.maxToolResultTokens ?? DEFAULTS.maxToolResultTokens,
211
+ trimBoundaryStep: options.trimBoundaryStep && options.trimBoundaryStep > 0 ? Math.floor(options.trimBoundaryStep) : DEFAULTS.trimBoundaryStep,
174
212
  };
175
213
  let data;
176
214
  try {
@@ -216,7 +254,7 @@ export function optimizeConversation(input, options = {}) {
216
254
  // their CURRENT question swapped for a pointer to a message ten turns back,
217
255
  // and just sees a worse answer with no explanation. Older copies are fair
218
256
  // game; the live turn is not.
219
- const cutoff = stableCutoff(messages.length, opts.keepRecent);
257
+ const cutoff = stableCutoff(messages.length, opts.keepRecent, opts.trimBoundaryStep);
220
258
  const seen = new Map();
221
259
  messages.forEach((m, i) => {
222
260
  const text = textOf(m.content);
@@ -241,7 +279,7 @@ export function optimizeConversation(input, options = {}) {
241
279
  }
242
280
  // -- trim-tool-results: shrink stale tool output ------------------------------
243
281
  if (opts.strategies.includes("trim-tool-results")) {
244
- const cutoff = stableCutoff(messages.length, opts.keepRecent);
282
+ const cutoff = stableCutoff(messages.length, opts.keepRecent, opts.trimBoundaryStep);
245
283
  messages.forEach((m, i) => {
246
284
  if (i >= cutoff || !isToolResultMessage(m))
247
285
  return;
@@ -266,7 +304,7 @@ export function optimizeConversation(input, options = {}) {
266
304
  }
267
305
  // -- trim-tool-calls: shrink the arguments of calls that already ran ----------
268
306
  if (opts.strategies.includes("trim-tool-calls")) {
269
- const cutoff = stableCutoff(messages.length, opts.keepRecent);
307
+ const cutoff = stableCutoff(messages.length, opts.keepRecent, opts.trimBoundaryStep);
270
308
  messages.forEach((m, i) => {
271
309
  if (i >= cutoff)
272
310
  return;
@@ -276,6 +314,14 @@ export function optimizeConversation(input, options = {}) {
276
314
  for (const b of m.content) {
277
315
  if (b?.type !== "tool_use" || b.input == null || typeof b.input !== "object")
278
316
  continue;
317
+ // Measured across 42 real sessions: of 73 large Writes, 18 were later
318
+ // Edited and 16 of those Edits had no Read in between — the model
319
+ // built old_string from its own Write input, at a median distance of
320
+ // 43 messages. Trimming such a Write turns that Edit into a failure
321
+ // plus a recovery Read. Offline we can see the future, so leave
322
+ // those alone; the 63% never touched again are still pure gain.
323
+ if (editedLaterWithoutRead(messages, i, writtenPath(b)))
324
+ continue;
279
325
  const before = estimateTokens(JSON.stringify(b.input));
280
326
  if (before <= opts.maxToolResultTokens)
281
327
  continue;
package/dist/profile.d.ts CHANGED
@@ -51,6 +51,14 @@ export interface ContextProfile {
51
51
  /** Present when the model has a known price. All figures are estimates. */
52
52
  cost?: CostEstimate;
53
53
  sourceFormat: string;
54
+ /**
55
+ * Present when estimates were scaled by a factor learned from this machine's
56
+ * own `--exact` counts for this model family. Absent means raw heuristic.
57
+ */
58
+ calibration?: {
59
+ factor: number;
60
+ samples: number;
61
+ };
54
62
  /** Propagated from parsing: input could not be read as a conversation. */
55
63
  parseWarning?: string;
56
64
  }
package/dist/profile.js CHANGED
@@ -6,6 +6,7 @@ import { createHash } from "node:crypto";
6
6
  import { contextWindowFor, estimateTokens, MESSAGE_OVERHEAD_TOKENS, providerFor } from "./tokens.js";
7
7
  import { estimatedTtftSeconds, inputCostUsd, pricingFor } from "./pricing.js";
8
8
  import { hasBase64Blob } from "./blob.js";
9
+ import { calibrationFor } from "./calibration.js";
9
10
  function categoryOf(m) {
10
11
  switch (m.kind) {
11
12
  case "system": return "system";
@@ -145,9 +146,11 @@ function filesReadBy(toolName, toolCallText) {
145
146
  return [...paths];
146
147
  }
147
148
  export function profileConversation(conv, model) {
149
+ // Learned from the user's own exact counts, if they ever fetched any.
150
+ const calibration = calibrationFor(model);
148
151
  const perMessage = conv.messages.map((m) => ({
149
152
  msg: m,
150
- tokens: estimateTokens(m.text) + MESSAGE_OVERHEAD_TOKENS,
153
+ tokens: Math.round(estimateTokens(m.text) * calibration.factor) + MESSAGE_OVERHEAD_TOKENS,
151
154
  }));
152
155
  const totalTokens = perMessage.reduce((sum, p) => sum + p.tokens, 0);
153
156
  const categories = {
@@ -445,6 +448,7 @@ export function profileConversation(conv, model) {
445
448
  totalEstSavings,
446
449
  cost,
447
450
  sourceFormat: conv.sourceFormat,
451
+ calibration: calibration.samples > 0 ? calibration : undefined,
448
452
  parseWarning: conv.parseWarning,
449
453
  };
450
454
  }
package/dist/proxy.js CHANGED
@@ -66,6 +66,8 @@ export function startProxy(opts = {}) {
66
66
  };
67
67
  /** Last stable-prefix fingerprint per model, for cache-invalidation advice. */
68
68
  const prefixFingerprints = new Map();
69
+ /** Per-message fingerprints of the previous request per model, for breakpoint placement. */
70
+ const messageFingerprints = new Map();
69
71
  const advise = (msg) => {
70
72
  if (stats.advice.includes(msg) || stats.advice.length >= 10)
71
73
  return;
@@ -117,14 +119,24 @@ export function startProxy(opts = {}) {
117
119
  strategies: route.strategies ?? opts.strategies,
118
120
  keepRecent: route.keepRecent ?? opts.keepRecent,
119
121
  maxToolResultTokens: route.maxToolResultTokens ?? opts.maxToolResultTokens,
122
+ trimBoundaryStep: opts.trimBoundaryStep,
120
123
  };
121
124
  }
122
125
  // Prompt-cache advisor (Anthropic requests): the proxy sees real
123
126
  // sequences, so cache-hostile patterns are observable facts here.
124
127
  if (url.startsWith("/v1/messages") && requestModel) {
125
128
  const stablePrefix = JSON.stringify(parsedBody.tools ?? null) + JSON.stringify(parsedBody.system ?? null);
126
- if (stablePrefix.length > 4000 && !body.includes("cache_control")) {
127
- advise(`~${Math.round(stablePrefix.length / 4)}+ tokens of stable system/tools on ${requestModel} without cache_control — adding a breakpoint would cut those to ~10% cost per call`);
129
+ const hasBreakpoint = body.includes("cache_control");
130
+ if (stablePrefix.length > 4000 && !hasBreakpoint) {
131
+ // Say WHERE, not just that. A breakpoint caches everything up
132
+ // to and including the block it sits on, so it belongs on the
133
+ // LAST stable block: the final tool definition if there are
134
+ // tools, otherwise the final system block.
135
+ const where = Array.isArray(parsedBody.tools) && parsedBody.tools.length > 0
136
+ ? `the last entry in "tools" (tools come before system in the cached prefix)`
137
+ : `the last block of "system"`;
138
+ advise(`~${Math.round(stablePrefix.length / 4)}+ tokens of stable system/tools on ${requestModel} without cache_control. ` +
139
+ `Add {"cache_control":{"type":"ephemeral"}} to ${where}; everything before it then bills at ~10% on every call`);
128
140
  }
129
141
  const fp = fnv1a(stablePrefix);
130
142
  const prev = prefixFingerprints.get(requestModel);
@@ -132,6 +144,31 @@ export function startProxy(opts = {}) {
132
144
  advise(`system/tools prefix changed between ${requestModel} requests — every change re-bills the whole cached prefix; keep it byte-stable`);
133
145
  }
134
146
  prefixFingerprints.set(requestModel, fp);
147
+ // Second breakpoint: the conversation itself. Between two
148
+ // consecutive requests the older messages are usually identical;
149
+ // that run is cacheable too, and it is what re-bills every turn
150
+ // when nothing marks it. Find the longest message prefix that
151
+ // survived from the previous request and point at its last message.
152
+ const msgs = Array.isArray(parsedBody.messages)
153
+ ? parsedBody.messages
154
+ : [];
155
+ const hashes = msgs.map((m) => fnv1a(JSON.stringify(m)));
156
+ const prevHashes = messageFingerprints.get(requestModel);
157
+ if (prevHashes && !hasBreakpoint) {
158
+ let stable = 0;
159
+ while (stable < hashes.length && stable < prevHashes.length && hashes[stable] === prevHashes[stable])
160
+ stable++;
161
+ if (stable >= 2) {
162
+ const stableChars = msgs.slice(0, stable).reduce((n, m) => n + JSON.stringify(m).length, 0);
163
+ const stableTokens = Math.round(stableChars / 4);
164
+ // Anthropic will not cache a prefix under ~1024 tokens (2048 on Haiku).
165
+ if (stableTokens >= 1024) {
166
+ advise(`messages #0-#${stable - 1} (~${stableTokens} tokens) were identical to the previous ${requestModel} request and carry no cache_control. ` +
167
+ `Put {"cache_control":{"type":"ephemeral"}} on the last content block of message #${stable - 1}; that run then reads from cache instead of re-billing each turn`);
168
+ }
169
+ }
170
+ }
171
+ messageFingerprints.set(requestModel, hashes);
135
172
  }
136
173
  }
137
174
  catch {
package/dist/report.js CHANGED
@@ -37,6 +37,11 @@ export function renderProfile(profile, options = {}) {
37
37
  lines.push("");
38
38
  }
39
39
  lines.push(`Total: ~${formatTokens(p.totalTokens)} tokens across ${p.messageCount} messages (${p.sourceFormat} format)`);
40
+ if (p.calibration) {
41
+ // Scaled numbers must say so, or they read as the raw heuristic.
42
+ const pct = Math.round((p.calibration.factor - 1) * 100);
43
+ lines.push(` estimates calibrated ${pct >= 0 ? "+" : ""}${pct}% from ${p.calibration.samples} exact count(s) you ran on this machine (analyze --exact)`);
44
+ }
40
45
  if (p.model) {
41
46
  const windowNote = p.contextWindow
42
47
  ? ` of ${formatTokens(p.contextWindow)} window (${p.usagePct.toFixed(1)}%)`
@@ -0,0 +1,44 @@
1
+ /**
2
+ * `context-doctor statusline` — live context health in Claude Code's status bar.
3
+ *
4
+ * Claude Code's `statusLine` setting runs a command on every refresh and shows
5
+ * its first line of stdout. That is "context health where the work happens"
6
+ * without an editor extension: the number that matters, visible while typing.
7
+ *
8
+ * It has to be fast and silent. Fast: the status payload on stdin carries the
9
+ * live context size in recent versions; when it does not, only the tail of the
10
+ * transcript is read (last 256KB, ~1ms on a 20MB file). Silent: any failure
11
+ * prints nothing at all, because a status line that shows an error is worse
12
+ * than one that shows nothing.
13
+ */
14
+ /** The parts of Claude Code's status payload this reads. All optional. */
15
+ interface StatusInput {
16
+ transcript_path?: string;
17
+ model?: {
18
+ id?: string;
19
+ display_name?: string;
20
+ };
21
+ cost?: {
22
+ total_cost_usd?: number;
23
+ };
24
+ context_window?: {
25
+ total_input_tokens?: number;
26
+ context_window_size?: number;
27
+ current_usage?: {
28
+ input_tokens?: number;
29
+ cache_read_input_tokens?: number;
30
+ cache_creation_input_tokens?: number;
31
+ };
32
+ };
33
+ }
34
+ interface LiveUsage {
35
+ tokens: number;
36
+ cacheShare?: number;
37
+ model?: string;
38
+ }
39
+ /** Newest assistant usage from the END of a transcript, without reading the file. */
40
+ export declare function tailUsage(path: string, tailBytes?: number): LiveUsage | undefined;
41
+ /** Build the status line, or null when there is nothing trustworthy to show. */
42
+ export declare function renderStatusLine(input: StatusInput): string | null;
43
+ export declare function runStatusLine(): Promise<void>;
44
+ export {};
@@ -0,0 +1,104 @@
1
+ /**
2
+ * `context-doctor statusline` — live context health in Claude Code's status bar.
3
+ *
4
+ * Claude Code's `statusLine` setting runs a command on every refresh and shows
5
+ * its first line of stdout. That is "context health where the work happens"
6
+ * without an editor extension: the number that matters, visible while typing.
7
+ *
8
+ * It has to be fast and silent. Fast: the status payload on stdin carries the
9
+ * live context size in recent versions; when it does not, only the tail of the
10
+ * transcript is read (last 256KB, ~1ms on a 20MB file). Silent: any failure
11
+ * prints nothing at all, because a status line that shows an error is worse
12
+ * than one that shows nothing.
13
+ */
14
+ import { closeSync, openSync, readSync, statSync } from "node:fs";
15
+ import { contextWindowFor, formatTokens } from "./tokens.js";
16
+ import { formatUsd } from "./pricing.js";
17
+ /** Newest assistant usage from the END of a transcript, without reading the file. */
18
+ export function tailUsage(path, tailBytes = 256 * 1024) {
19
+ try {
20
+ const size = statSync(path).size;
21
+ const n = Math.min(size, tailBytes);
22
+ const fd = openSync(path, "r");
23
+ const buf = Buffer.alloc(n);
24
+ try {
25
+ readSync(fd, buf, 0, n, size - n);
26
+ }
27
+ finally {
28
+ closeSync(fd);
29
+ }
30
+ for (const line of buf.toString("utf8").split("\n").reverse()) {
31
+ let entry;
32
+ try {
33
+ entry = JSON.parse(line);
34
+ }
35
+ catch {
36
+ continue; // the first line of a tail is usually a partial one
37
+ }
38
+ const usage = entry.type === "assistant" ? entry.message?.usage : undefined;
39
+ if (!usage)
40
+ continue;
41
+ const read = usage.cache_read_input_tokens ?? 0;
42
+ const tokens = (usage.input_tokens ?? 0) + read + (usage.cache_creation_input_tokens ?? 0);
43
+ if (tokens > 0)
44
+ return { tokens, cacheShare: read / tokens, model: entry.message?.model };
45
+ }
46
+ }
47
+ catch {
48
+ /* unreadable: the caller shows nothing */
49
+ }
50
+ return undefined;
51
+ }
52
+ /** Ten-cell bar; the visual half of the number. */
53
+ function bar(pct) {
54
+ const filled = Math.max(0, Math.min(10, Math.round(pct / 10)));
55
+ return "▮".repeat(filled) + "░".repeat(10 - filled);
56
+ }
57
+ /** Build the status line, or null when there is nothing trustworthy to show. */
58
+ export function renderStatusLine(input) {
59
+ let live;
60
+ const cw = input.context_window;
61
+ const fromPayload = cw?.current_usage
62
+ ? (cw.current_usage.input_tokens ?? 0) + (cw.current_usage.cache_read_input_tokens ?? 0) + (cw.current_usage.cache_creation_input_tokens ?? 0)
63
+ : cw?.total_input_tokens;
64
+ if (fromPayload && fromPayload > 0) {
65
+ const read = cw?.current_usage?.cache_read_input_tokens;
66
+ live = { tokens: fromPayload, cacheShare: read !== undefined ? read / fromPayload : undefined, model: input.model?.id };
67
+ }
68
+ else if (input.transcript_path) {
69
+ live = tailUsage(input.transcript_path);
70
+ }
71
+ if (!live)
72
+ return null;
73
+ const window = cw?.context_window_size ?? contextWindowFor(live.model ?? input.model?.id);
74
+ const parts = [];
75
+ if (window) {
76
+ const pct = (live.tokens / window) * 100;
77
+ parts.push(`ctx ${formatTokens(live.tokens)}/${formatTokens(window)} ${bar(pct)} ${pct.toFixed(0)}%${pct >= 70 ? " ⚠" : ""}`);
78
+ }
79
+ else {
80
+ parts.push(`ctx ${formatTokens(live.tokens)}`);
81
+ }
82
+ if (live.cacheShare !== undefined)
83
+ parts.push(`cache ${Math.round(live.cacheShare * 100)}%`);
84
+ if (input.cost?.total_cost_usd && input.cost.total_cost_usd > 0)
85
+ parts.push(formatUsd(input.cost.total_cost_usd));
86
+ return parts.join(" · ");
87
+ }
88
+ async function readStdin() {
89
+ const chunks = [];
90
+ for await (const chunk of process.stdin)
91
+ chunks.push(chunk);
92
+ return Buffer.concat(chunks).toString("utf8");
93
+ }
94
+ export async function runStatusLine() {
95
+ try {
96
+ const input = JSON.parse(await readStdin());
97
+ const line = renderStatusLine(input);
98
+ if (line)
99
+ console.log(line);
100
+ }
101
+ catch {
102
+ /* silent by design */
103
+ }
104
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-doctor",
3
- "version": "0.13.5",
3
+ "version": "0.14.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 test",
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 dist/test/accuracy.test.js dist/test/cache-stability.test.js dist/test/ledger.test.js"
45
+ "test": "npm run build && node scripts/test.mjs"
46
46
  },
47
47
  "dependencies": {
48
48
  "@modelcontextprotocol/sdk": "^1.0.0",