context-doctor 0.21.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,14 +2,14 @@
2
2
 
3
3
  [![CI](https://github.com/KushalP1/context-doctor/actions/workflows/ci.yml/badge.svg)](https://github.com/KushalP1/context-doctor/actions) [![npm](https://img.shields.io/npm/v/context-doctor)](https://www.npmjs.com/package/context-doctor) [![npm downloads](https://img.shields.io/npm/dm/context-doctor)](https://www.npmjs.com/package/context-doctor) [![license: MIT](https://img.shields.io/badge/license-MIT-blue)](./LICENSE) ![macOS | Linux | Windows](https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20Windows-lightgrey)
4
4
 
5
- **Keep every AI session's context lean, automatically, without ever making it more expensive.**
5
+ **Keep every AI session's context lean, and see exactly what it costs you, without ever making it more expensive.**
6
6
 
7
- Long agent sessions fill up with tool output nobody reads again: file dumps, shell logs, search results, screenshots. You pay for all of it on every request, the model gets slower, and it drifts as the window fills. `context-doctor` measures that, and with **autopilot** it removes it from every Claude Code (and GPT API) request on your machine, only at moments when doing so costs nothing extra.
7
+ Long agent sessions fill up with tool output nobody reads again, and a 500k-token session re-reads all of it on every message. The most expensive moment is coming back after lunch: the prompt cache has expired, so the next message re-sends everything at full price. `context-doctor` measures this on your own sessions and acts on it at the moments that pay.
8
8
 
9
- - **9.8% less input cost, no session worse.** Replaying 130 days of the author's real Claude Code use (43 sessions) through the shipped code: **3.8 billion input tokens not sent, $4,694 saved at API list price, about $1,080 a month**, and not one session more expensive. Up to 37.7% on a long session. [What it saves →](#what-it-saves)
10
- - **Counts Claude correctly.** Current Claude models pack 2.75 characters per token, not the 4 most tools assume; estimates built on 4 undercount Claude by about 40%. The ratios here were measured from the API's own counts, and `context-doctor accuracy` re-checks them on yours. [How →](#why-token-counts-are--and-where-they-are-exact)
11
- - **Works where you work:** Claude Code, Cursor, Codex, Claude Desktop, any Anthropic or OpenAI API app, VS Code, CI. macOS, Linux and Windows, Node 20+.
12
- - **Local and keyless.** No account, no telemetry, no API key. Your own login passes through untouched. MIT.
9
+ - **Tells you when to `/compact`, at the moment it pays.** When you come back to a large session after the cache expired, the every-prompt hook gives the model the numbers and it offers `/compact` once. Replayed over 133 days of the author's Claude Code history: 401 such returns, and compacting then would have saved **$4,053 net at list price, about $914 a month**. It works in every Claude Code surface, including the desktop app. [What it saves →](#what-it-saves)
10
+ - **Autopilot for terminal and IDE sessions and API apps:** a local proxy that clears stale tool output only when the prompt cache is cold, so it never costs more. **9.8% less input cost, no session made more expensive**, in the same replay. (The desktop app's Code tab sets its own API address, so autopilot cannot reach it; `doctor` tells you if that is your case.)
11
+ - **Counts Claude correctly.** Current Claude models pack 2.75 characters per token, not the 4 most tools assume, so estimates built on 4 undercount Claude by about 40%. The ratios were measured from the API's own counts; `context-doctor accuracy` re-checks them on yours.
12
+ - **Works where you work:** Claude Code (terminal, IDE, desktop app, plugin), Cursor, Codex, Claude Desktop chat, any Anthropic or OpenAI API app, VS Code, CI. macOS, Linux and Windows, Node 20+. Local, keyless, no telemetry, MIT.
13
13
 
14
14
  Built and maintained by [gAI Ventures](https://gai.ventures).
15
15
 
@@ -21,16 +21,9 @@ Built and maintained by [gAI Ventures](https://gai.ventures).
21
21
  npx context-doctor savings
22
22
  ```
23
23
 
24
- ```
25
- What autopilot would have saved you (last 30 days, 53 Claude Code sessions)
26
- ────────────────────────────────────────────────────────
27
- Input you were billed for $11,205 (from the usage your transcripts record)
28
- Autopilot would have cut $1,396 12.5%, 1.2B tokens not sent
29
- Sessions made more expensive 0
30
- Biggest win $503 tech-Whatsit (31% of that session)
31
- ```
24
+ <img src="https://raw.githubusercontent.com/KushalP1/context-doctor/main/assets/savings.svg" alt="npx context-doctor savings: input billed, what /compact at cold resumes and autopilot would have saved" width="640">
32
25
 
33
- That is the author's machine. Yours is computed the same way: every recent session replayed request by request through the shipped autopilot code, against what your transcripts show you were actually billed.
26
+ That is the author's machine: every session there runs in the desktop app, so the first line is the one that applies. Yours is computed the same way, from your own transcripts: every session replayed request by request through the shipped code, against what you were actually billed. `context-doctor savings --share` prints a few lines with totals only (no project names or paths) if you want to post your number.
34
27
 
35
28
  **Then install it**, whichever way suits you:
36
29
 
@@ -69,26 +62,36 @@ Findings (4)
69
62
 
70
63
  ## What it saves
71
64
 
72
- Measured, not modelled: every Claude Code session on the author's machine from 18 May to 25 September 2026 (43 sessions, 130 days, mostly Opus 5 and Fable 5 with the 1M window) was replayed request by request through the shipped autopilot code, with the real timestamps, and priced the way the prompt cache bills it (cached reads 0.1x, writes 1.25x). Run it on your own history with `node scripts/replay-autopilot.mjs`.
65
+ Measured, not modelled, on every Claude Code session on the author's machine (mostly Opus 5 and Fable 5 with the 1M window), priced the way the prompt cache bills it (cached reads 0.1x, writes 1.25x) at API list prices. `context-doctor savings` runs the same measurement on yours.
66
+
67
+ **1. Compacting when you come back** (every Claude Code surface, desktop app included). 133 days, 401 returns to a session over 150k tokens after more than 65 idle minutes; median context at the return, 517k tokens.
68
+
69
+ | | |
70
+ |---|---|
71
+ | Net saving had you run `/compact` after the first reply | **$4,053 (about $914 a month)** |
72
+ | Returns where it paid off | 296 of 401 |
73
+ | Worst single case | −$2.30 (a session left a few messages later) |
74
+
75
+ Each return counts only the messages up to the next return, and the compaction request is charged as a cost. Not counted: files the model may re-read after compacting, and what the summary leaves out. That is why this is advice with the numbers attached, not something done for you.
76
+
77
+ **2. Autopilot** (terminal and IDE sessions, API apps). 130 days, 43 sessions, replayed through the shipped autopilot code with real timestamps:
73
78
 
74
79
  | | Without autopilot | With autopilot | Saved |
75
80
  |---|---|---|---|
76
81
  | Input tokens sent | 41.4 billion | 37.6 billion | **3.8 billion (9.2%)** |
77
82
  | Input cost at API list price | $48,962 | $44,268 | **$4,694 (9.8%)** |
78
- | Per 30 days | | | **~875 million tokens, ~$1,080** |
79
83
  | Sessions made more expensive | | | **0 of 43** |
80
84
 
81
- How it spreads: the median session saves 1.9%, the best 37.7%. Short sessions barely change, because they rarely pile up 20k tokens of stale tool output before they end. Long sessions are where the money is: on this machine 94% of input cost came from requests above 200k tokens, and those are the requests autopilot makes smaller. Savings scale with how long your sessions run and how much they read, so a lighter user saves proportionally less, and never pays more.
82
-
83
- On a Claude subscription you do not pay list price; the same tokens come out of your usage limit instead. Anthropic does not publish how limits weight cached tokens, so read the dollar column as the size of the effect, not as your bill. The token column holds either way, and every request that is 9% smaller is also faster to first token and further from auto-compaction.
85
+ The median session saves 1.9%, the best 37.7%: long sessions are where the money is (94% of input cost on that machine came from requests above 200k tokens). These sessions ran in the desktop app, which autopilot cannot reach, so for the author this is what the same sessions would have saved in a terminal; `context-doctor savings` separates the two for you.
84
86
 
85
- What is not counted here: the proxy's full optimizer for your own API apps, the hook's guidance to the model, and fixes you make from `session` findings. Those save more on top, but they depend on what the model or you do with the advice, so they are not in this table.
87
+ On a Claude subscription you do not pay list price; the same tokens come out of your usage limit instead. Anthropic does not publish how limits weight cached tokens, so read the dollars as the size of the effect, not as your bill.
86
88
 
87
89
  ## What happens on each platform
88
90
 
89
91
  | Where you work | Automatic, every request | What you get on top |
90
92
  |---|---|---|
91
- | **Claude Code** (terminal, VS Code, JetBrains, desktop app's Code tab) | **Autopilot** clears stale tool output (cold cache only, never more expensive). **Hook** on every prompt warns the model with the real context size and its largest waste | Install via npm or as a **plugin** (`/plugin marketplace add KushalP1/context-doctor`). Status bar context meter, `/context-doctor:savings`, `session`, `watch`, `report`, dashboard |
93
+ | **Claude Code** in the terminal, VS Code or JetBrains | **Autopilot** clears stale tool output (cold cache only, never more expensive). **Hook** on every prompt: real context size, largest waste, and a `/compact` offer when you return to a large session after the cache expired |
94
+ | **Claude Code** in the desktop app's Code tab | **Hook**, as above, including the cold-resume `/compact` offer. Not autopilot: the app sets its own API address and ignores the one in settings | Same install options | Install via npm or as a **plugin** (`/plugin marketplace add KushalP1/context-doctor`). Status bar context meter, `/context-doctor:savings`, `session`, `watch`, `report`, dashboard |
92
95
  | **Cursor** (agent) | Cursor runs Claude Code's hooks, so the same every-prompt check fires inside Cursor | MCP tools, editor status bar extension, `cursor` profiler. With your own OpenAI key, autopilot too via a tokened tunnel ([how](#putting-the-proxy-on-a-public-url-cursor-with-your-own-openai-key-remote-apps)) |
93
96
  | **Codex** (ChatGPT app's Codex tab, IDE extension, CLI) | Every-prompt hook with the API's own token counts | MCP tools, skill, `session` reads Codex rollouts. On an API key, autopilot too (`OPENAI_BASE_URL`) |
94
97
  | **Your own apps on the Anthropic or OpenAI API** | Autopilot on `/v1/messages`, `/v1/chat/completions` and `/v1/responses` (`ANTHROPIC_BASE_URL` / `OPENAI_BASE_URL`), or the full optimizing proxy | Exact usage and cache hit rates in `/stats`, prompt-cache placement advice |
@@ -101,6 +104,7 @@ Not claimed, because no process on your machine sends those requests: trimming i
101
104
  ## What's new
102
105
 
103
106
  - **0.20 Autopilot**: stale tool output cleared from every Claude Code request, only when the prompt cache is cold, so it cannot cost more (measured: 9.8% less input cost, ~$1,080 a month on the author's usage, no session worse); runs as a login service on macOS, Linux and Windows; now also for GPT via OpenAI's Chat Completions and Responses APIs.
107
+ - **0.22 Honest about where each lever works, and a new one that works everywhere**: the every-prompt hook now offers `/compact` when you return to a large session after the cache expired (measured: ~$914 a month on the author's history), including in the desktop app. Found and fixed: autopilot cannot reach the desktop app's Code tab, and `doctor` now says so instead of reporting ✓; `savings` counted whole-file history as "last 30 days"; the hook quoted per-message cost at the uncached rate (10x too high). `--version` added.
104
108
  - **0.21 See it before you install it**: `npx context-doctor savings` replays your own recent sessions through autopilot and shows what it would have saved, against what you were actually billed. Install as a **Claude Code plugin** from inside Claude Code. Ready for the official **MCP Registry** (`io.github.KushalP1/context-doctor`), which every release now publishes to; any MCP client can launch it as `npx -y context-doctor mcp`.
105
109
  - **0.20.1 Releases that finish themselves**: one tag publishes to npm and creates a GitHub release with the Claude Desktop bundle (signed when a certificate is configured); the editor extension is ready for the VS Code Marketplace and Open VSX.
106
110
  - **0.19 Measured Claude tokenizer**: estimates were 40% low for Claude; fixed from the API's own counts, with a per-model check in `accuracy`.
@@ -236,7 +240,7 @@ context-doctor autopilot pause # instant passthrough, nothing restarts
236
240
  context-doctor autopilot off # remove it
237
241
  ```
238
242
 
239
- `autopilot on` runs the local proxy as a background service (launchd on macOS, a systemd user service on Linux, a logon task on Windows), waits until it answers, and only then points Claude Code at it through `env.ANTHROPIC_BASE_URL` in `~/.claude/settings.json`. Every Claude Code session started afterwards, in the terminal, an IDE, or the desktop app's Code tab, sends its requests through it. Your login (subscription or API key) passes through untouched.
243
+ `autopilot on` runs the local proxy as a background service (launchd on macOS, a systemd user service on Linux, a logon task on Windows), waits until it answers, and only then points Claude Code at it through `env.ANTHROPIC_BASE_URL` in `~/.claude/settings.json`. Every Claude Code session started afterwards in a terminal or an IDE sends its requests through it. **Not the desktop app's Code tab:** it runs Claude Code "host-orchestrated", sets `ANTHROPIC_BASE_URL` from its own account configuration, and drops the same key from settings files (checked in Claude Code 2.1.284). `autopilot on`, `autopilot status` and `doctor` look at which surface your recent sessions ran in and warn when autopilot cannot reach them; for desktop sessions the cold-resume advice is the lever that works. Your login (subscription or API key) passes through untouched.
240
244
 
241
245
  **What it does to each request:** once old tool output adds up to 20k+ tokens (file reads, shell output, search and web results, screenshots inside them), it replaces that output with a one-line note, keeping the 3 most recent results. The tool call stays in the history, so the model can simply run it again if it needs the output. Your messages, its answers, answers you gave to its questions, subagent reports and MCP results are never touched.
242
246
 
package/dist/autoclear.js CHANGED
@@ -54,7 +54,9 @@ export const CLEARABLE_TOOLS = new Set([
54
54
  const IMAGE_TOKENS = 1600;
55
55
  const HOUR = 3_600_000;
56
56
  export function clearedNote(tokens) {
57
- return `[context-doctor: old tool output cleared to keep context lean (~${tokens} tokens). Run the tool again if you need it.]`;
57
+ // Not "run it again": a cleared Bash result may come from a command with
58
+ // side effects (a push, a delete), and repeating that would be wrong.
59
+ return `[context-doctor: old tool output cleared to keep context lean (~${tokens} tokens). The call above shows what ran; repeat it only if it is read-only, otherwise ask the user.]`;
58
60
  }
59
61
  function resultTokens(content, model) {
60
62
  if (typeof content === "string")
@@ -40,6 +40,10 @@ export interface Health {
40
40
  version?: string;
41
41
  }
42
42
  export declare function health(port: number, timeoutMs?: number): Promise<Health>;
43
+ /** Which Claude Code surfaces ran recent sessions: entrypoint -> count. */
44
+ export declare function recentSurfaces(days?: number, home?: string): Map<string, number>;
45
+ /** Lines explaining which recent sessions autopilot can reach, or nothing when all can. */
46
+ export declare function reachNote(days?: number): string[];
43
47
  export declare function currentCli(): string;
44
48
  export declare function autopilotOn(port?: number, paths?: AutopilotPaths): Promise<{
45
49
  ok: boolean;
package/dist/autopilot.js CHANGED
@@ -19,7 +19,7 @@
19
19
  * backend, not an API base URL).
20
20
  */
21
21
  import { execFileSync, spawn } from "node:child_process";
22
- import { existsSync, mkdirSync, openSync, rmSync, writeFileSync } from "node:fs";
22
+ import { existsSync, mkdirSync, openSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
23
23
  import { homedir, platform } from "node:os";
24
24
  import { dirname, join } from "node:path";
25
25
  import { fileURLToPath } from "node:url";
@@ -190,6 +190,64 @@ function readConfig(paths) {
190
190
  return undefined;
191
191
  }
192
192
  }
193
+ /** Which Claude Code surfaces ran recent sessions: entrypoint -> count. */
194
+ export function recentSurfaces(days = 30, home = homedir()) {
195
+ const out = new Map();
196
+ const root = join(home, ".claude", "projects");
197
+ const since = Date.now() - days * 86_400_000;
198
+ let dirs = [];
199
+ try {
200
+ dirs = readdirSync(root);
201
+ }
202
+ catch {
203
+ return out;
204
+ }
205
+ for (const d of dirs) {
206
+ let files = [];
207
+ try {
208
+ files = readdirSync(join(root, d)).filter((f) => f.endsWith(".jsonl"));
209
+ }
210
+ catch {
211
+ continue;
212
+ }
213
+ for (const f of files) {
214
+ const p = join(root, d, f);
215
+ try {
216
+ if (statSync(p).mtimeMs < since)
217
+ continue;
218
+ const head = readFileSync(p, "utf8").slice(0, 64 * 1024);
219
+ const m = head.match(/"entrypoint":"([^"]+)"/);
220
+ const ep = m ? m[1] : "unknown";
221
+ out.set(ep, (out.get(ep) ?? 0) + 1);
222
+ }
223
+ catch { /* unreadable: skip */ }
224
+ }
225
+ }
226
+ return out;
227
+ }
228
+ /** Lines explaining which recent sessions autopilot can reach, or nothing when all can. */
229
+ export function reachNote(days = 30) {
230
+ const surfaces = recentSurfaces(days);
231
+ let desktop = 0, other = 0;
232
+ // Sessions with no recorded surface are evidence of nothing either way.
233
+ for (const [ep, n] of surfaces) {
234
+ if (ep.startsWith("claude-desktop"))
235
+ desktop += n;
236
+ else if (ep !== "unknown")
237
+ other += n;
238
+ }
239
+ if (desktop === 0)
240
+ return [];
241
+ const lines = [
242
+ `! ${desktop} of your ${desktop + other} Claude Code sessions in the last ${days} days (with a recorded surface) ran in the desktop app. The desktop app`,
243
+ " sets its own API address and ignores the one in settings.json, so autopilot cannot reach those sessions.",
244
+ " It applies to Claude Code in a terminal (`claude`) and in IDEs. For desktop sessions, the every-prompt",
245
+ " hook points out when /compact would pay off (see `context-doctor savings`).",
246
+ ];
247
+ if (other === 0)
248
+ lines.push(" Every recent session of yours is a desktop one: autopilot will not change anything until you use the terminal or an IDE.");
249
+ return lines;
250
+ }
193
251
  export function currentCli() {
194
252
  return join(dirname(fileURLToPath(import.meta.url)), "cli.js");
195
253
  }
@@ -242,6 +300,7 @@ export async function autopilotOn(port = DEFAULT_AUTOPILOT_PORT, paths = autopil
242
300
  lines.push(`✓ Claude Code routed through it (env.ANTHROPIC_BASE_URL in ${paths.settings})`);
243
301
  lines.push(" Applies to Claude Code sessions started from now on (CLI, IDE, and the desktop app's Code tab).");
244
302
  lines.push(" Sessions already open keep their old route until restarted.");
303
+ lines.push(...reachNote());
245
304
  lines.push(` GPT apps on your own OpenAI key get the same: export OPENAI_BASE_URL=${proxyUrl(port)}/v1`);
246
305
  return { ok: true, lines };
247
306
  }
@@ -299,6 +358,8 @@ export async function autopilotStatus(paths = autopilotPaths()) {
299
358
  lines.push(`Since ${s.startedAt.slice(0, 16).replace("T", " ")} UTC: ${a.requests} requests, ${a.changedRequests} sent lighter`);
300
359
  lines.push(` ${a.resultsCleared} stale tool outputs cleared in ${a.batches} batches (${a.coldBatches} while the cache was cold anyway)`);
301
360
  lines.push(` ~${formatTokens(a.tokensRemoved)} tokens not sent`);
361
+ if (a.requests === 0)
362
+ lines.push(...reachNote(7).map((l) => l.replace(/^! /, " ")));
302
363
  const billed = s.upstreamInputTokens + s.upstreamCacheReadTokens + s.upstreamCacheWriteTokens;
303
364
  if (billed > 0)
304
365
  lines.push(` Billed input: ${formatTokens(billed)} (${Math.round((s.upstreamCacheReadTokens / billed) * 100)}% cache reads)`);
package/dist/cli.js CHANGED
@@ -22,7 +22,7 @@ import { runHook } from "./hook.js";
22
22
  import { buildImpactReport } from "./impact.js";
23
23
  import { measureTokenizer, renderTokenizer } from "./tokenizer-measure.js";
24
24
  import { autopilotOff, autopilotOn, autopilotPause, autopilotPaths, autopilotStatus, DEFAULT_AUTOPILOT_PORT } from "./autopilot.js";
25
- import { estimateSavings, renderSavings } from "./savings.js";
25
+ import { estimateSavings, renderSavings, renderShare } from "./savings.js";
26
26
  import { renderPreferences, copyToClipboard, CHAT_PREFERENCES } from "./preferences.js";
27
27
  import { recordLedger } from "./ledger.js";
28
28
  import { runDoctor } from "./doctor.js";
@@ -50,9 +50,12 @@ Usage:
50
50
  context-doctor install Wire the MCP server + skill into Claude Desktop,
51
51
  Claude Code, and Cursor automatically
52
52
  context-doctor uninstall Undo install
53
- context-doctor savings [--days n] What autopilot saves (or would have) on YOUR recent
54
- Claude Code sessions: replayed, priced as billed.
55
- Also what a bare \`context-doctor\` shows
53
+ context-doctor --version Print the installed version
54
+ context-doctor savings [--days n] [--share [--copy]]
55
+ What your recent Claude Code sessions cost, and what
56
+ /compact at cold resumes and autopilot would save,
57
+ replayed and priced as billed. --share: totals only,
58
+ to paste. Also what a bare \`context-doctor\` shows
56
59
  context-doctor autopilot on|off|pause|resume|status
57
60
  Every new Claude Code session goes through the
58
61
  local proxy, which clears stale tool output only
@@ -148,6 +151,10 @@ function parseArgs(argv) {
148
151
  case "--help":
149
152
  console.log(HELP);
150
153
  process.exit(0);
154
+ case "-v":
155
+ case "--version":
156
+ console.log(packageVersion());
157
+ process.exit(0);
151
158
  case "--json":
152
159
  args.json = true;
153
160
  break;
@@ -173,19 +180,19 @@ function parseArgs(argv) {
173
180
  args.strategies.push(argv[++i]);
174
181
  break;
175
182
  case "--keep-recent":
176
- args.keepRecent = Number(argv[++i]);
183
+ args.keepRecent = numArg("--keep-recent", argv[++i], { min: 0, integer: true });
177
184
  break;
178
185
  case "--max-tool-tokens":
179
- args.maxToolTokens = Number(argv[++i]);
186
+ args.maxToolTokens = numArg("--max-tool-tokens", argv[++i], { min: 1, integer: true });
180
187
  break;
181
188
  case "--port":
182
- args.port = Number(argv[++i]);
189
+ args.port = numArg("--port", argv[++i], { min: 0, max: 65535, integer: true });
183
190
  break;
184
191
  case "--interval-ms":
185
- args.intervalMs = Number(argv[++i]);
192
+ args.intervalMs = numArg("--interval-ms", argv[++i], { min: 100, integer: true });
186
193
  break;
187
194
  case "--limit":
188
- args.limit = Number(argv[++i]);
195
+ args.limit = numArg("--limit", argv[++i], { min: 1, integer: true });
189
196
  break;
190
197
  case "--task":
191
198
  args.task = argv[++i];
@@ -197,7 +204,7 @@ function parseArgs(argv) {
197
204
  args.existing = argv[++i];
198
205
  break;
199
206
  case "--budget":
200
- args.budgetUsd = Number(argv[++i]);
207
+ args.budgetUsd = numArg("--budget", argv[++i], { min: 0 });
201
208
  break;
202
209
  case "--dry-run":
203
210
  args.dryRun = true;
@@ -205,6 +212,9 @@ function parseArgs(argv) {
205
212
  case "--copy":
206
213
  args.copy = true;
207
214
  break;
215
+ case "--share":
216
+ args.share = true;
217
+ break;
208
218
  case "--allow-dirty":
209
219
  args.allowDirty = true;
210
220
  break;
@@ -218,7 +228,7 @@ function parseArgs(argv) {
218
228
  args.token = argv[++i];
219
229
  break;
220
230
  case "--days":
221
- args.days = Number(argv[++i]);
231
+ args.days = numArg("--days", argv[++i], { min: 1 });
222
232
  break;
223
233
  case "--autopilot":
224
234
  args.autopilot = true;
@@ -275,6 +285,30 @@ function readInput(file) {
275
285
  return readFileSync(0, "utf8");
276
286
  return readFileSync(file, "utf8");
277
287
  }
288
+ /** The installed package's version, read from its package.json so it cannot drift. */
289
+ function packageVersion() {
290
+ try {
291
+ return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
292
+ }
293
+ catch {
294
+ return "unknown";
295
+ }
296
+ }
297
+ /**
298
+ * A numeric flag's value, or exit 1 with a message naming the flag. Number()
299
+ * alone turned `--days abc` into "the last NaN days" and a bad --port into a
300
+ * random port.
301
+ */
302
+ function numArg(flag, raw, { min, max, integer } = {}) {
303
+ const n = Number(raw);
304
+ const ok = raw !== undefined && raw.trim() !== "" && Number.isFinite(n) && (!integer || Number.isInteger(n)) && (min === undefined || n >= min) && (max === undefined || n <= max);
305
+ if (!ok) {
306
+ const range = min !== undefined && max !== undefined ? ` between ${min} and ${max}` : min !== undefined ? ` of at least ${min}` : "";
307
+ console.error(`${flag} needs ${integer ? "a whole number" : "a number"}${range}; got ${raw === undefined ? "nothing" : JSON.stringify(raw)}.`);
308
+ process.exit(1);
309
+ }
310
+ return n;
311
+ }
278
312
  /** Any Claude Code transcript on this machine? One directory listing, no parsing. */
279
313
  function hasClaudeSessions() {
280
314
  try {
@@ -490,6 +524,12 @@ async function main() {
490
524
  const on = existsSync(autopilotPaths().config);
491
525
  if (args.json)
492
526
  console.log(JSON.stringify(report, null, 2));
527
+ else if (args.share) {
528
+ const text = renderShare(report);
529
+ console.log(text);
530
+ if (args.copy)
531
+ console.log(copyToClipboard(text) ? "\n(copied to the clipboard)" : "\n(no clipboard tool found; copy the lines above)");
532
+ }
493
533
  else {
494
534
  console.log(renderSavings(report, on));
495
535
  if (!args.command)
@@ -568,6 +608,11 @@ async function main() {
568
608
  return; // server keeps the process alive
569
609
  }
570
610
  if (!args.command || !args.file) {
611
+ const known = ["analyze", "optimize"];
612
+ if (args.command && !known.includes(args.command)) {
613
+ console.error(`Unknown command "${args.command}". Run \`context-doctor --help\` for the list.`);
614
+ process.exit(1);
615
+ }
571
616
  console.log(HELP);
572
617
  process.exit(args.command ? 1 : 0);
573
618
  }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Cold resume: coming back to a large session after the prompt cache expired.
3
+ *
4
+ * Claude Code caches the conversation for an hour on a subscription (five
5
+ * minutes on the API). Come back later and the next message re-sends the
6
+ * whole context at the cache-WRITE rate (1.25x), and every message after it
7
+ * re-reads it (0.1x each). On a 500k-token session that is the most expensive
8
+ * moment of the day, and the one moment `/compact` is cheapest to accept:
9
+ * the cache is being rebuilt anyway.
10
+ *
11
+ * Measured on the author's 133 days of Claude Code history: 401 returns to a
12
+ * session over 150k tokens after more than 65 idle minutes (median context
13
+ * 517k). Compacting right after the first reply, counting the compaction
14
+ * request itself and only the messages up to the next such return, would have
15
+ * saved $4,053 at list price, about $914 a month; it paid off in 296 of 401
16
+ * cases, and the worst single loss was $2.30 (sessions left after a few more
17
+ * messages). So this advises, with the numbers, and the user decides.
18
+ *
19
+ * It works where the proxy cannot: the desktop app's Code tab runs Claude Code
20
+ * host-orchestrated and drops a settings-file ANTHROPIC_BASE_URL, but it runs
21
+ * the every-prompt hook, which is where this is called from.
22
+ */
23
+ /** Just past Claude Code's one-hour cache, as Claude Code's own idle clearing uses. */
24
+ export declare const COLD_IDLE_MS: number;
25
+ /** Below this, a rebuilt cache is cheap and compaction is not worth the interruption. */
26
+ export declare const COLD_MIN_TOKENS = 150000;
27
+ export interface ColdResume {
28
+ idleMs: number;
29
+ /** Context size at the last request, from the API's own usage. */
30
+ tokens: number;
31
+ model?: string;
32
+ /** Timestamp of the last assistant reply: identifies this idle period. */
33
+ lastReplyAt: number;
34
+ /** This message's cost to rebuild the cache, at list price. */
35
+ resumeUsd?: number;
36
+ /** Each later message's cost to re-read the context, at list price. */
37
+ perMessageUsd?: number;
38
+ }
39
+ /**
40
+ * Is the prompt being submitted now a return to a large, cache-cold session?
41
+ * Reads only the transcript's tail. Undefined when not (or when unreadable).
42
+ */
43
+ export declare function detectColdResume(path: string, now?: number, idleMs?: number, minTokens?: number): ColdResume | undefined;
44
+ /** The note the hook hands the model: the numbers, and one sentence to say. */
45
+ export declare function renderColdResume(c: ColdResume): string;
46
+ export interface ColdResumeEvent {
47
+ at: number;
48
+ tokens: number;
49
+ /** Messages until the session ended, was compacted, or went cold again. */
50
+ messagesAfter: number;
51
+ /** Net USD compaction after the first reply would have saved (negative: cost). */
52
+ netUsd: number;
53
+ }
54
+ /**
55
+ * Replay a transcript's billed usage and price what compacting at each cold
56
+ * resume would have saved. Each resume only counts the messages up to the
57
+ * next one (no double counting), and the compaction request is a cost.
58
+ */
59
+ export declare function coldResumeEvents(path: string, idleMs?: number, minTokens?: number): ColdResumeEvent[];
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Cold resume: coming back to a large session after the prompt cache expired.
3
+ *
4
+ * Claude Code caches the conversation for an hour on a subscription (five
5
+ * minutes on the API). Come back later and the next message re-sends the
6
+ * whole context at the cache-WRITE rate (1.25x), and every message after it
7
+ * re-reads it (0.1x each). On a 500k-token session that is the most expensive
8
+ * moment of the day, and the one moment `/compact` is cheapest to accept:
9
+ * the cache is being rebuilt anyway.
10
+ *
11
+ * Measured on the author's 133 days of Claude Code history: 401 returns to a
12
+ * session over 150k tokens after more than 65 idle minutes (median context
13
+ * 517k). Compacting right after the first reply, counting the compaction
14
+ * request itself and only the messages up to the next such return, would have
15
+ * saved $4,053 at list price, about $914 a month; it paid off in 296 of 401
16
+ * cases, and the worst single loss was $2.30 (sessions left after a few more
17
+ * messages). So this advises, with the numbers, and the user decides.
18
+ *
19
+ * It works where the proxy cannot: the desktop app's Code tab runs Claude Code
20
+ * host-orchestrated and drops a settings-file ANTHROPIC_BASE_URL, but it runs
21
+ * the every-prompt hook, which is where this is called from.
22
+ */
23
+ import { closeSync, openSync, readSync, statSync } from "node:fs";
24
+ import { forEachLine } from "./session.js";
25
+ import { pricingFor, formatUsd } from "./pricing.js";
26
+ import { formatTokens } from "./tokens.js";
27
+ /** Just past Claude Code's one-hour cache, as Claude Code's own idle clearing uses. */
28
+ export const COLD_IDLE_MS = 65 * 60_000;
29
+ /** Below this, a rebuilt cache is cheap and compaction is not worth the interruption. */
30
+ export const COLD_MIN_TOKENS = 150_000;
31
+ /** What a compacted session restarts from: system prompt and tools plus the summary. */
32
+ const AFTER_COMPACT_TOKENS = 45_000;
33
+ /** Output tokens a compaction summary costs, and output's price relative to input. */
34
+ const SUMMARY_OUTPUT_TOKENS = 8_000;
35
+ const OUTPUT_PRICE_RATIO = 5;
36
+ function tailLines(path, bytes = 256 * 1024) {
37
+ const size = statSync(path).size;
38
+ const n = Math.min(size, bytes);
39
+ const fd = openSync(path, "r");
40
+ const buf = Buffer.alloc(n);
41
+ try {
42
+ readSync(fd, buf, 0, n, size - n);
43
+ }
44
+ finally {
45
+ closeSync(fd);
46
+ }
47
+ return buf.toString("utf8").split("\n").reverse();
48
+ }
49
+ /**
50
+ * Is the prompt being submitted now a return to a large, cache-cold session?
51
+ * Reads only the transcript's tail. Undefined when not (or when unreadable).
52
+ */
53
+ export function detectColdResume(path, now = Date.now(), idleMs = COLD_IDLE_MS, minTokens = COLD_MIN_TOKENS) {
54
+ try {
55
+ for (const line of tailLines(path)) {
56
+ let e;
57
+ try {
58
+ e = JSON.parse(line);
59
+ }
60
+ catch {
61
+ continue;
62
+ }
63
+ // Compacted since the last reply: the old usage no longer describes the
64
+ // context, and there is nothing large left to warn about.
65
+ if (e?.type === "system" && e.subtype === "compact_boundary")
66
+ return undefined;
67
+ if (e?.isCompactSummary)
68
+ return undefined;
69
+ if (e?.type !== "assistant" || e.isSidechain)
70
+ continue;
71
+ const u = e.message?.usage;
72
+ if (!u)
73
+ continue;
74
+ const tokens = (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0);
75
+ if (tokens <= 0)
76
+ continue;
77
+ const lastReplyAt = Date.parse(e.timestamp);
78
+ if (!Number.isFinite(lastReplyAt))
79
+ return undefined;
80
+ const idle = now - lastReplyAt;
81
+ if (idle < idleMs || tokens < minTokens)
82
+ return undefined;
83
+ const model = e.message?.model;
84
+ const p = pricingFor(model);
85
+ return {
86
+ idleMs: idle,
87
+ tokens,
88
+ model,
89
+ lastReplyAt,
90
+ resumeUsd: p ? (tokens * 1.25 * p.inputPerM) / 1e6 : undefined,
91
+ perMessageUsd: p ? (tokens * 0.1 * p.inputPerM) / 1e6 : undefined,
92
+ };
93
+ }
94
+ }
95
+ catch {
96
+ /* unreadable transcript: no advice */
97
+ }
98
+ return undefined;
99
+ }
100
+ function duration(ms) {
101
+ const m = Math.round(ms / 60_000);
102
+ if (m < 90)
103
+ return `${m} minutes`;
104
+ const h = ms / 3_600_000;
105
+ return h < 48 ? `${h.toFixed(h < 10 ? 1 : 0)} hours` : `${Math.round(h / 24)} days`;
106
+ }
107
+ /** The note the hook hands the model: the numbers, and one sentence to say. */
108
+ export function renderColdResume(c) {
109
+ const cheaper = Math.max(0, Math.round((1 - AFTER_COMPACT_TOKENS / c.tokens) * 100));
110
+ const money = c.resumeUsd !== undefined
111
+ ? ` (~${formatUsd(c.resumeUsd)} at list price), and every later message re-reads it (~${formatUsd(c.perMessageUsd)} each)`
112
+ : ", and every later message re-reads it";
113
+ return [
114
+ `This session was idle for ${duration(c.idleMs)}, longer than the prompt cache lasts, so this message re-sends the whole ~${formatTokens(c.tokens)}-token context at the full cache-write rate${money}.`,
115
+ `If the user is going to keep working in this session, running /compact now restarts it from a short summary and makes each later message roughly ${cheaper}% cheaper; the cache is being rebuilt anyway, so now is when compaction costs least.`,
116
+ "Begin your reply with one short sentence offering that (only the user can run /compact), then answer the request normally. Do not raise it again this session unless it goes idle again.",
117
+ ].join("\n");
118
+ }
119
+ /**
120
+ * Replay a transcript's billed usage and price what compacting at each cold
121
+ * resume would have saved. Each resume only counts the messages up to the
122
+ * next one (no double counting), and the compaction request is a cost.
123
+ */
124
+ export function coldResumeEvents(path, idleMs = COLD_IDLE_MS, minTokens = COLD_MIN_TOKENS) {
125
+ const reqs = [];
126
+ let lastId, model;
127
+ forEachLine(path, (line) => {
128
+ let e;
129
+ try {
130
+ e = JSON.parse(line);
131
+ }
132
+ catch {
133
+ return;
134
+ }
135
+ if (!e || e.isSidechain)
136
+ return;
137
+ if (e.type === "system" && e.subtype === "compact_boundary") {
138
+ reqs.push("compact");
139
+ return;
140
+ }
141
+ if (e.type !== "assistant" || !e.message || e.message.id === lastId)
142
+ return;
143
+ lastId = e.message.id;
144
+ const u = e.message.usage ?? {};
145
+ const total = (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0);
146
+ const at = Date.parse(e.timestamp);
147
+ if (total > 0 && Number.isFinite(at)) {
148
+ reqs.push({ at, total });
149
+ model ??= e.message.model;
150
+ }
151
+ });
152
+ const perM = pricingFor(model)?.inputPerM;
153
+ if (!perM)
154
+ return [];
155
+ const real = reqs.filter((r) => r !== "compact");
156
+ if (real.length < 2)
157
+ return [];
158
+ const events = [];
159
+ for (let i = 1; i < reqs.length; i++) {
160
+ const a = reqs[i - 1], b = reqs[i];
161
+ if (a === "compact" || b === "compact")
162
+ continue;
163
+ if (b.at - a.at <= idleMs || b.total < minTokens)
164
+ continue;
165
+ const removed = Math.max(0, b.total - AFTER_COMPACT_TOKENS);
166
+ let saved = 0, n = 0, prev = b.at;
167
+ for (const c of reqs.slice(i + 1)) {
168
+ if (c === "compact")
169
+ break;
170
+ if (c.at - prev > idleMs && c.total >= minTokens)
171
+ break;
172
+ saved += (c.at - prev > 3_600_000 ? 1.25 : 0.1) * removed;
173
+ prev = c.at;
174
+ n++;
175
+ }
176
+ const cost = 0.1 * b.total + OUTPUT_PRICE_RATIO * SUMMARY_OUTPUT_TOKENS + 1.25 * AFTER_COMPACT_TOKENS;
177
+ events.push({ at: b.at, tokens: b.total, messagesAfter: n, netUsd: ((saved - cost) * perM) / 1e6 });
178
+ }
179
+ return events;
180
+ }
package/dist/doctor.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * check, so "it doesn't work" becomes a single pasteable diagnosis. Always
6
6
  * exits 0 — absence of an app is a note, not a failure.
7
7
  */
8
- import { autopilotPaths, health, proxyUrl } from "./autopilot.js";
8
+ import { autopilotPaths, health, proxyUrl, recentSurfaces } from "./autopilot.js";
9
9
  import { spawn } from "node:child_process";
10
10
  import { existsSync, readFileSync } from "node:fs";
11
11
  import { homedir, platform } from "node:os";
@@ -226,12 +226,24 @@ export async function runDoctor() {
226
226
  }
227
227
  catch { /* reported below */ }
228
228
  const paused = existsSync(paths.pauseFile);
229
- checks.push(h.ok && h.autopilot && routed
230
- ? { label: "Autopilot", status: "ok", detail: `proxy up on ${proxyUrl(cfg.port)}, Claude Code routed through it${paused ? " (PAUSED: passthrough)" : ""}` }
231
- : { label: "Autopilot", status: "fail", detail: !h.ok ? `proxy not answering on ${proxyUrl(cfg.port)}: the next Claude Code prompt restarts it; or run: context-doctor autopilot on` : "settings.json no longer routes Claude Code to the proxy: run context-doctor autopilot on" });
229
+ // Routed in settings is not the same as reached: the desktop app ignores it.
230
+ let seen = -1;
231
+ try {
232
+ const st = (await fetch(`${proxyUrl(cfg.port)}/stats`, { signal: AbortSignal.timeout(800) }).then((r) => r.json()));
233
+ seen = st.autopilot?.requests ?? -1;
234
+ }
235
+ catch { /* stats are best effort */ }
236
+ const surfaces = recentSurfaces(7);
237
+ const known = [...surfaces.keys()].filter((ep) => ep !== "unknown");
238
+ const desktopOnly = known.length > 0 && known.every((ep) => ep.startsWith("claude-desktop"));
239
+ checks.push(h.ok && h.autopilot && routed && seen === 0 && desktopOnly
240
+ ? { label: "Autopilot", status: "warn", detail: "running, but it has seen 0 requests: your recent Claude Code sessions all ran in the desktop app, which ignores settings.json's API address. Autopilot only reaches terminal and IDE sessions" }
241
+ : h.ok && h.autopilot && routed
242
+ ? { label: "Autopilot", status: "ok", detail: `proxy up on ${proxyUrl(cfg.port)}, Claude Code routed through it${seen >= 0 ? ` (${seen} requests since start)` : ""}${paused ? " (PAUSED: passthrough)" : ""}` }
243
+ : { label: "Autopilot", status: "fail", detail: !h.ok ? `proxy not answering on ${proxyUrl(cfg.port)}: the next Claude Code prompt restarts it; or run: context-doctor autopilot on` : "settings.json no longer routes Claude Code to the proxy: run context-doctor autopilot on" });
232
244
  }
233
245
  }
234
- const mark = { ok: "✓", fail: "✗", skip: "–" };
246
+ const mark = { ok: "✓", warn: "!", fail: "✗", skip: "–" };
235
247
  console.log("CONTEXT DOCTOR — self-check");
236
248
  console.log("═".repeat(56));
237
249
  for (const c of checks) {
package/dist/hook.js CHANGED
@@ -20,7 +20,8 @@ import { profileConversation } from "./profile.js";
20
20
  import { parseSessionFile } from "./session.js";
21
21
  import { formatTokens, CHARS_PER_TOKEN } from "./tokens.js";
22
22
  import { ensureProxyUp } from "./autopilot.js";
23
- import { formatUsd } from "./pricing.js";
23
+ import { detectColdResume, renderColdResume } from "./coldresume.js";
24
+ import { formatUsd, pricingFor } from "./pricing.js";
24
25
  import { checkBudget, loadConfig } from "./config.js";
25
26
  /** Default nudge threshold; a project budget or env var can lower/raise it. */
26
27
  const DEFAULT_WARN_TOKENS = 80_000;
@@ -160,8 +161,28 @@ export async function runHook() {
160
161
  // expensive read. Heavy-but-quiet sessions cost one stat + tiny state read.
161
162
  const sessionId = input.session_id ?? transcriptPath;
162
163
  const prev = readSessionState(sessionId);
163
- if (prev.b > 0 && sizeBytes < prev.b * REGROWTH_FACTOR)
164
+ // Cold resume: back on a large session after the cache expired. Checked
165
+ // before the growth gate, because a return after idle involves no growth.
166
+ // Once per idle period; the tail read is ~1 ms and only on large files.
167
+ const notes = [];
168
+ const cold = detectColdResume(transcriptPath);
169
+ let cr = prev.cr;
170
+ if (cold && cold.lastReplyAt !== prev.cr) {
171
+ notes.push(renderColdResume(cold));
172
+ cr = cold.lastReplyAt;
173
+ recordLedger({ ev: "cold_resume", sid: sessionId.slice(0, 12), tok: cold.tokens, model: cold.model });
174
+ }
175
+ const emit = (lines) => {
176
+ if (lines.length === 0)
177
+ return;
178
+ console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: `<context-doctor>\n${lines.join("\n")}\n</context-doctor>` } }));
179
+ };
180
+ if (prev.b > 0 && sizeBytes < prev.b * REGROWTH_FACTOR) {
181
+ if (cr !== prev.cr)
182
+ writeSessionState(sessionId, { ...prev, cr });
183
+ emit(notes);
164
184
  return;
185
+ }
165
186
  // Slow path (growth events only): full parse + profile.
166
187
  const parsed = parseSessionFile(transcriptPath);
167
188
  if (parsed.messageCount === 0)
@@ -173,18 +194,24 @@ export async function runHook() {
173
194
  const liveTokens = parsed.reportedInputTokens ?? profile.totalTokens;
174
195
  // Record this parse so the next prompts take fast path 2.
175
196
  const shouldWarn = liveTokens >= threshold && liveTokens >= prev.t * REGROWTH_FACTOR;
176
- writeSessionState(sessionId, { t: shouldWarn ? liveTokens : prev.t, b: sizeBytes });
197
+ writeSessionState(sessionId, { t: shouldWarn ? liveTokens : prev.t, b: sizeBytes, cr });
177
198
  recordLedger({ ev: "check", sid: sessionId.slice(0, 12), tok: liveTokens, warn: shouldWarn });
178
- if (!shouldWarn)
199
+ if (!shouldWarn) {
200
+ emit(notes);
179
201
  return;
202
+ }
180
203
  const windowPct = profile.contextWindow ? (liveTokens / profile.contextWindow) * 100 : undefined;
181
- const costPerCall = profile.cost && profile.totalTokens > 0
182
- ? (profile.cost.perCallUsd * liveTokens) / profile.totalTokens
183
- : undefined;
204
+ // Agent sessions run on the prompt cache: a message normally re-reads the
205
+ // context at the cached rate (0.1x) and pays the full rate only when the
206
+ // cache has expired. Quoting the uncached figure alone overstated the
207
+ // per-message cost tenfold (fixed in 0.22).
208
+ const pricing = pricingFor(parsed.model);
209
+ const cachedUsd = pricing ? (liveTokens * pricing.cacheReadPerM) / 1e6 : undefined;
210
+ const coldUsd = pricing ? (liveTokens * pricing.inputPerM * 1.25) / 1e6 : undefined;
184
211
  const lines = [
185
212
  `This session's context is at ~${formatTokens(liveTokens)} tokens` +
186
213
  (windowPct !== undefined ? ` (${windowPct.toFixed(0)}% of the window)` : "") +
187
- (costPerCall !== undefined ? `, costing ~${formatUsd(costPerCall)} of input per message` : "") +
214
+ (cachedUsd !== undefined ? `: each message re-reads it for ~${formatUsd(cachedUsd)} from the prompt cache, ~${formatUsd(coldUsd)} when the cache has expired` : "") +
188
215
  ".",
189
216
  "Practice context hygiene from here on: summarize large tool results instead of keeping them verbatim, reference earlier content rather than re-reading or re-quoting it, and keep responses lean.",
190
217
  ];
@@ -200,12 +227,7 @@ export async function runHook() {
200
227
  if (liveTokens > threshold * 2) {
201
228
  lines.push("If it fits the flow, offer the user a compaction of the older history.");
202
229
  }
203
- console.log(JSON.stringify({
204
- hookSpecificOutput: {
205
- hookEventName: "UserPromptSubmit",
206
- additionalContext: `<context-doctor>\n${lines.join("\n")}\n</context-doctor>`,
207
- },
208
- }));
230
+ emit([...notes, ...lines]);
209
231
  }
210
232
  catch {
211
233
  /* silent — never disturb the prompt */
package/dist/impact.js CHANGED
@@ -130,8 +130,11 @@ export async function buildImpactReport(proxyPort = 8787) {
130
130
  const p = profileConversation(parseConversation(parsed.conversationJson), parsed.model);
131
131
  totalTokens += p.totalTokens;
132
132
  totalWaste += p.totalEstSavings;
133
- if (p.cost)
134
- totalWasteUsd += p.cost.savingsPerCallUsd;
133
+ // Claude Code sessions run on the prompt cache, so recoverable tokens
134
+ // cost the cached rate on each message, not the full input rate.
135
+ const pr = pricingFor(p.model);
136
+ if (p.cost && pr)
137
+ totalWasteUsd += p.cost.savingsPerCallUsd * (pr.cacheReadPerM / pr.inputPerM);
135
138
  const wastePct = p.totalTokens > 0 ? Math.round((p.totalEstSavings / p.totalTokens) * 100) : 0;
136
139
  // Ledger sids are the session UUID's first 12 chars (= filename prefix).
137
140
  const sid = (s.path.split("/").pop() ?? "").slice(0, 12);
package/dist/index.d.ts CHANGED
@@ -24,3 +24,4 @@ export * from "./tokenizer-measure.js";
24
24
  export * from "./autoclear.js";
25
25
  export * from "./autopilot.js";
26
26
  export * from "./savings.js";
27
+ export * from "./coldresume.js";
package/dist/index.js CHANGED
@@ -19,3 +19,4 @@ export * from "./tokenizer-measure.js";
19
19
  export * from "./autoclear.js";
20
20
  export * from "./autopilot.js";
21
21
  export * from "./savings.js";
22
+ export * from "./coldresume.js";
package/dist/install.js CHANGED
@@ -470,6 +470,7 @@ export function runInstall(options = {}) {
470
470
  else {
471
471
  console.log("\nDone. Restart the apps to pick up the new tools, then try:");
472
472
  console.log(' "What\'s eating my context?" — or paste a conversation and ask for a profile.');
473
+ console.log("See what it is worth on your own sessions: context-doctor savings");
473
474
  console.log("Chat apps (Claude Desktop, claude.ai, ChatGPT, phones) have no hook. To make the rules standing there:");
474
475
  console.log(" context-doctor instructions --copy then paste into the app's preferences (it tells you where).");
475
476
  }
package/dist/ledger.d.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  */
18
18
  export interface LedgerEntry {
19
19
  ts: number;
20
- ev?: "check" | "optimize" | "proxy" | "rollup";
20
+ ev?: "check" | "optimize" | "proxy" | "rollup" | "cold_resume";
21
21
  sid?: string;
22
22
  tok?: number;
23
23
  warn?: boolean;
package/dist/mcp.js CHANGED
@@ -49,7 +49,7 @@ const STRATEGY_IDS = ["dedupe", "trim-tool-results", "trim-tool-calls", "strip-b
49
49
  * recommended pattern.
50
50
  */
51
51
  function createServer() {
52
- const server = new McpServer({ name: "context-doctor", version: "0.21.0" }, { instructions: SERVER_INSTRUCTIONS });
52
+ const server = new McpServer({ name: "context-doctor", version: "0.23.0" }, { instructions: SERVER_INSTRUCTIONS });
53
53
  server.tool("profile_context", "Profile an LLM conversation or prompt: token breakdown, largest blocks, and actionable findings about wasted context (duplicates, oversized pastes or tool results, base64 blobs, long history). Two inputs, pass ONE: `conversation` (full OpenAI/Anthropic JSON or raw text, for agents, files and proxies) or `sketch` (for chat apps such as Claude Desktop or ChatGPT where you cannot export the conversation: the turn count plus the few blocks that matter, ~120 tokens to write). Call it whenever the user asks about token usage, context size, cost, speed or limits, and on your own once the conversation passes ~30 turns or holds 3+ large pastes. Act on the top finding in your reply.", {
54
54
  conversation: z.string().optional().describe("Conversation JSON (OpenAI or Anthropic format, or bare message array) or raw prompt text. Omit in chat apps and pass `sketch`."),
55
55
  sketch: z.object({
package/dist/proxy.d.ts CHANGED
@@ -47,7 +47,7 @@ export interface ProxyOptions extends OptimizeOptions {
47
47
  openaiUpstream?: string;
48
48
  }
49
49
  /** Reported by /health so `autopilot status` can tell an outdated service from a current one. */
50
- export declare const PROXY_VERSION = "0.21.0";
50
+ export declare const PROXY_VERSION = "0.23.0";
51
51
  /**
52
52
  * Remove a leading `/t/<token>` from a request path, or return undefined when
53
53
  * the prefix is absent or the token differs. The comparison is constant time
package/dist/proxy.js CHANGED
@@ -20,7 +20,7 @@ import { formatTokens, CHARS_PER_TOKEN, providerFor } from "./tokens.js";
20
20
  import { formatUsd, inputCostUsd, pricingFor } from "./pricing.js";
21
21
  import { recordLedger } from "./ledger.js";
22
22
  /** Reported by /health so `autopilot status` can tell an outdated service from a current one. */
23
- export const PROXY_VERSION = "0.21.0";
23
+ export const PROXY_VERSION = "0.23.0";
24
24
  /** Connection-level headers that must not be forwarded. */
25
25
  const SKIP_REQUEST_HEADERS = new Set(["host", "content-length", "connection", "transfer-encoding", "accept-encoding", "expect"]);
26
26
  const SKIP_RESPONSE_HEADERS = new Set(["content-length", "content-encoding", "transfer-encoding", "connection"]);
@@ -169,8 +169,9 @@ export function startProxy(opts = {}) {
169
169
  stats.optimizedRequests++;
170
170
  stats.tokensSaved += r.tokensRemoved;
171
171
  const pricing = pricingFor(typeof parsed.model === "string" ? parsed.model : undefined);
172
+ // What autopilot removes would mostly have been cached reads: price it so.
172
173
  if (pricing)
173
- stats.estUsdSaved += inputCostUsd(r.tokensRemoved, pricing);
174
+ stats.estUsdSaved += (r.tokensRemoved / 1e6) * pricing.cacheReadPerM;
174
175
  note = `autopilot: ${formatTokens(r.tokensRemoved)} tokens of stale tool output not sent (${r.reason})`;
175
176
  }
176
177
  else {
package/dist/savings.d.ts CHANGED
@@ -25,7 +25,21 @@ export interface SessionSavings {
25
25
  /** Raw input tokens autopilot would not have sent. */
26
26
  savedTokens: number;
27
27
  savedPct: number;
28
+ /** Claude Code surface that ran the session ("cli", "claude-vscode", "claude-desktop", ...). */
29
+ entrypoint?: string;
30
+ /** Can a settings-file ANTHROPIC_BASE_URL route this surface through autopilot? */
31
+ autopilotReachable: boolean;
32
+ /** Returns to this session after the cache expired, and what /compact then would have saved. */
33
+ coldResumes: number;
34
+ coldResumeNetUsd: number;
28
35
  }
36
+ /**
37
+ * The desktop app runs Claude Code "host-orchestrated": it sets
38
+ * ANTHROPIC_BASE_URL itself and drops the same key from settings files, so
39
+ * the autopilot proxy cannot sit in front of it (checked in Claude Code
40
+ * 2.1.284). Terminal and IDE sessions read settings normally.
41
+ */
42
+ export declare function autopilotReaches(entrypoint?: string): boolean;
29
43
  export interface SavingsReport {
30
44
  days: number;
31
45
  sessions: SessionSavings[];
@@ -35,8 +49,18 @@ export interface SavingsReport {
35
49
  billedTokens: number;
36
50
  savedPct: number;
37
51
  worse: number;
52
+ /** Autopilot's share in sessions it can actually reach. */
53
+ reachableUsd: number;
54
+ unreachableSessions: number;
55
+ coldResumes: number;
56
+ coldResumeNetUsd: number;
38
57
  }
39
58
  /** Replay one Claude Code transcript. Undefined when it holds too few requests to mean anything. */
40
- export declare function replaySession(path: string, options?: AutoClearOptions): SessionSavings | undefined;
59
+ export declare function replaySession(path: string, options?: AutoClearOptions, since?: number): SessionSavings | undefined;
41
60
  export declare function estimateSavings(days?: number, options?: AutoClearOptions, paths?: string[], onProgress?: (done: number, total: number) => void): SavingsReport;
42
61
  export declare function renderSavings(r: SavingsReport, autopilotOn?: boolean): string;
62
+ /**
63
+ * `savings --share`: a few lines people can paste or screenshot. Totals only:
64
+ * no project names, paths or session ids, so sharing it leaks nothing.
65
+ */
66
+ export declare function renderShare(r: SavingsReport): string;
package/dist/savings.js CHANGED
@@ -12,12 +12,22 @@
12
12
  */
13
13
  import { basename, dirname } from "node:path";
14
14
  import { AutoClearer } from "./autoclear.js";
15
+ import { coldResumeEvents } from "./coldresume.js";
15
16
  import { forEachLine, listSessions } from "./session.js";
16
17
  import { pricingFor } from "./pricing.js";
17
18
  import { estimateTokens, formatTokens } from "./tokens.js";
18
19
  /** System prompt and tool schemas: sent on every request, identical in both arms. */
19
20
  const FIXED_TOKENS = 54_000;
20
21
  const CLAUDE_CODE_TTL_MS = 3_600_000;
22
+ /**
23
+ * The desktop app runs Claude Code "host-orchestrated": it sets
24
+ * ANTHROPIC_BASE_URL itself and drops the same key from settings files, so
25
+ * the autopilot proxy cannot sit in front of it (checked in Claude Code
26
+ * 2.1.284). Terminal and IDE sessions read settings normally.
27
+ */
28
+ export function autopilotReaches(entrypoint) {
29
+ return !(entrypoint ?? "").startsWith("claude-desktop");
30
+ }
21
31
  /** One arm of the replay: the history as sent, priced incrementally. */
22
32
  function arm(model, clearer) {
23
33
  const msgs = [];
@@ -33,7 +43,7 @@ function arm(model, clearer) {
33
43
  tok[i] = size(msgs[i]);
34
44
  total += tok[i];
35
45
  },
36
- request(now, cold) {
46
+ request(now, cold, counted = true) {
37
47
  let first = -1;
38
48
  if (clearer) {
39
49
  const body = { model, messages: msgs, system: [{ type: "text", text: "", cache_control: { type: "ephemeral", ttl: "1h" } }] };
@@ -50,8 +60,10 @@ function arm(model, clearer) {
50
60
  for (let i = 0; i < k; i++)
51
61
  read += tok[i];
52
62
  const write = total - read;
53
- weighted += (cold ? FIXED_TOKENS * 1.25 : FIXED_TOKENS * 0.1) + read * 0.1 + write * 1.25;
54
- raw += FIXED_TOKENS + total;
63
+ if (counted) {
64
+ weighted += (cold ? FIXED_TOKENS * 1.25 : FIXED_TOKENS * 0.1) + read * 0.1 + write * 1.25;
65
+ raw += FIXED_TOKENS + total;
66
+ }
55
67
  prevLen = msgs.length;
56
68
  },
57
69
  get weighted() { return weighted; },
@@ -59,10 +71,11 @@ function arm(model, clearer) {
59
71
  };
60
72
  }
61
73
  /** Replay one Claude Code transcript. Undefined when it holds too few requests to mean anything. */
62
- export function replaySession(path, options = {}) {
74
+ export function replaySession(path, options = {}, since = 0) {
63
75
  let model;
64
76
  let base = arm(undefined, null), auto = arm(undefined, new AutoClearer(options));
65
77
  let baseW = 0, autoW = 0, baseRaw = 0, autoRaw = 0;
78
+ let entrypoint;
66
79
  let billed = 0, billedRaw = 0, requests = 0, lastId, lastTs = null;
67
80
  const flush = () => { baseW += base.weighted; autoW += auto.weighted; baseRaw += base.raw; autoRaw += auto.raw; };
68
81
  forEachLine(path, (line) => {
@@ -75,6 +88,8 @@ export function replaySession(path, options = {}) {
75
88
  }
76
89
  if (!e || e.isSidechain)
77
90
  return;
91
+ if (!entrypoint && typeof e.entrypoint === "string")
92
+ entrypoint = e.entrypoint;
78
93
  if (e.type === "system" && e.subtype === "compact_boundary") {
79
94
  // Compaction replaces the history in both arms alike: start fresh.
80
95
  flush();
@@ -91,13 +106,18 @@ export function replaySession(path, options = {}) {
91
106
  const u = m.usage ?? {};
92
107
  const read = u.cache_read_input_tokens ?? 0, write = u.cache_creation_input_tokens ?? 0, fresh = u.input_tokens ?? 0;
93
108
  if (read + write + fresh > 0) {
94
- billed += fresh + read * 0.1 + write * 1.25;
95
- billedRaw += fresh + read + write;
96
- requests++;
97
109
  const now = Date.parse(e.timestamp) || 0;
110
+ // History before the window still shapes the replay's state; only
111
+ // requests inside the window are billed and counted.
112
+ const inWindow = now >= since;
113
+ if (inWindow) {
114
+ billed += fresh + read * 0.1 + write * 1.25;
115
+ billedRaw += fresh + read + write;
116
+ requests++;
117
+ }
98
118
  const cold = lastTs === null || now - lastTs > CLAUDE_CODE_TTL_MS;
99
- base.request(now, cold);
100
- auto.request(now, cold);
119
+ base.request(now, cold, inWindow);
120
+ auto.request(now, cold, inWindow);
101
121
  lastTs = now;
102
122
  }
103
123
  base.push({ role: "assistant", content: blocks });
@@ -122,6 +142,7 @@ export function replaySession(path, options = {}) {
122
142
  // share is applied to what was actually billed, so the dollars and tokens
123
143
  // shown are fractions of the user's real usage, not of an estimate.
124
144
  const perM = pricingFor(model)?.inputPerM ?? 0;
145
+ const cold = coldResumeEvents(path).filter((c) => c.at >= since);
125
146
  const pct = baseW > 0 ? (baseW - autoW) / baseW : 0;
126
147
  const rawPct = baseRaw > 0 ? (baseRaw - autoRaw) / baseRaw : 0;
127
148
  const dir = basename(dirname(path));
@@ -136,6 +157,10 @@ export function replaySession(path, options = {}) {
136
157
  savedUsd: ((pct * billed) / 1e6) * perM,
137
158
  savedTokens: rawPct * billedRaw,
138
159
  savedPct: pct,
160
+ entrypoint,
161
+ autopilotReachable: autopilotReaches(entrypoint),
162
+ coldResumes: cold.length,
163
+ coldResumeNetUsd: cold.reduce((n, c) => n + c.netUsd, 0),
139
164
  };
140
165
  }
141
166
  export function estimateSavings(days = 30, options = {}, paths, onProgress) {
@@ -145,7 +170,7 @@ export function estimateSavings(days = 30, options = {}, paths, onProgress) {
145
170
  for (const [i, p] of targets.entries()) {
146
171
  onProgress?.(i, targets.length);
147
172
  try {
148
- const s = replaySession(p, options);
173
+ const s = replaySession(p, options, since);
149
174
  if (s)
150
175
  sessions.push(s);
151
176
  }
@@ -166,6 +191,10 @@ export function estimateSavings(days = 30, options = {}, paths, onProgress) {
166
191
  billedTokens: sessions.reduce((n, s) => n + s.billedWeighted, 0),
167
192
  savedPct,
168
193
  worse: sessions.filter((s) => s.savedPct < -1e-9).length,
194
+ reachableUsd: sessions.filter((s) => s.autopilotReachable).reduce((n, s) => n + s.savedUsd, 0),
195
+ unreachableSessions: sessions.filter((s) => !s.autopilotReachable).length,
196
+ coldResumes: sum("coldResumes"),
197
+ coldResumeNetUsd: sum("coldResumeNetUsd"),
169
198
  };
170
199
  }
171
200
  const tokens = (n) => (n >= 1e9 ? `${(n / 1e9).toFixed(1)}B` : formatTokens(n));
@@ -177,22 +206,58 @@ export function renderSavings(r, autopilotOn = false) {
177
206
  lines.push("Use Claude Code for a while, then run this again: `context-doctor savings`.");
178
207
  return lines.join("\n");
179
208
  }
180
- const title = autopilotOn ? "What autopilot saves on your sessions" : "What autopilot would have saved you";
181
- lines.push(`${title} (last ${r.days} days, ${r.sessions.length} Claude Code session${r.sessions.length === 1 ? "" : "s"})`);
182
- lines.push("─".repeat(56));
183
- lines.push(`Input you were billed for ${usd(r.billedUsd).padStart(10)} (from the usage your transcripts record)`);
184
- lines.push(`Autopilot would have cut ${usd(r.savedUsd).padStart(10)} ${(r.savedPct * 100).toFixed(1)}%, ${tokens(r.savedTokens)} tokens not sent`);
185
- lines.push(`Sessions made more expensive ${String(r.worse).padStart(10)}`);
186
- const best = r.sessions[0];
187
- if (best && best.savedUsd > 0)
188
- lines.push(`Biggest win ${usd(best.savedUsd).padStart(10)} ${best.project} (${(best.savedPct * 100).toFixed(0)}% of that session)`);
209
+ const n = r.sessions.length;
210
+ const allDesktop = r.unreachableSessions === n;
211
+ const pct = (x) => (r.billedUsd > 0 ? ` (${Math.round((x / r.billedUsd) * 100)}%)` : "");
212
+ // Kept within 80 columns: this is most people's first screen of the tool.
213
+ lines.push("What context-doctor finds in your Claude Code sessions");
214
+ lines.push(`Last ${r.days} days, ${n} session${n === 1 ? "" : "s"}, priced at list prices`);
215
+ lines.push("─".repeat(60));
216
+ lines.push(`Input you were billed for ${usd(r.billedUsd).padStart(9)} from your transcripts' usage`);
189
217
  lines.push("");
190
- lines.push("Replayed request by request through the shipped autopilot code and priced as");
191
- lines.push("the prompt cache bills. List prices; on a subscription the same tokens come");
192
- lines.push("out of your usage limit instead.");
193
- if (!autopilotOn) {
194
- lines.push("");
195
- lines.push("Turn it on for every new session: context-doctor autopilot on");
218
+ lines.push(`1. Compact when you come back ${usd(Math.max(0, r.coldResumeNetUsd)).padStart(9)}${pct(Math.max(0, r.coldResumeNetUsd))}`);
219
+ lines.push(` ${r.coldResumes} return${r.coldResumes === 1 ? "" : "s"} to a session over 150k tokens after the prompt cache`);
220
+ lines.push(" expired. Running /compact then, net of the compaction itself, would");
221
+ lines.push(" have saved this. The every-prompt hook points these moments out, in");
222
+ lines.push(" every Claude Code surface, the desktop app included.");
223
+ lines.push("");
224
+ if (allDesktop) {
225
+ lines.push(`2. Autopilot ${"n/a".padStart(9)}`);
226
+ lines.push(` All ${n} sessions ran in the desktop app, which sets its own API`);
227
+ lines.push(" address, so the autopilot proxy cannot sit in front of them. In a");
228
+ lines.push(` terminal or an IDE they would have saved ${usd(r.savedUsd)} (${(r.savedPct * 100).toFixed(1)}%).`);
229
+ }
230
+ else {
231
+ lines.push(`2. Autopilot ${usd(r.reachableUsd).padStart(9)}${pct(r.reachableUsd)}`);
232
+ lines.push(" Stale tool output cleared only when the cache is cold, so it never");
233
+ lines.push(` costs more (${r.worse} session${r.worse === 1 ? "" : "s"} made more expensive); ${tokens(r.savedTokens)} tokens not sent.`);
234
+ if (r.unreachableSessions > 0)
235
+ lines.push(` Terminal and IDE sessions only; ${r.unreachableSessions} desktop-app session${r.unreachableSessions === 1 ? "" : "s"} not reachable.`);
236
+ if (!autopilotOn)
237
+ lines.push(" Turn it on: context-doctor autopilot on");
238
+ }
239
+ lines.push("");
240
+ lines.push("Replayed request by request through the shipped code and priced as the");
241
+ lines.push("prompt cache bills. On a subscription the same tokens come out of your");
242
+ lines.push("usage limit instead. Share yours: context-doctor savings --share");
243
+ return lines.join("\n");
244
+ }
245
+ /**
246
+ * `savings --share`: a few lines people can paste or screenshot. Totals only:
247
+ * no project names, paths or session ids, so sharing it leaks nothing.
248
+ */
249
+ export function renderShare(r) {
250
+ if (r.sessions.length === 0)
251
+ return "context-doctor found no Claude Code sessions to measure yet.";
252
+ const lines = [
253
+ `My Claude Code input, last ${r.days} days: ${usd(r.billedUsd)} at list price (${r.sessions.length} sessions).`,
254
+ ];
255
+ if (r.coldResumeNetUsd > 0) {
256
+ lines.push(`Running /compact when I came back to a big session after the cache expired would have saved ${usd(r.coldResumeNetUsd)} (${Math.round((r.coldResumeNetUsd / r.billedUsd) * 100)}%, ${r.coldResumes} times).`);
257
+ }
258
+ if (r.reachableUsd > 0) {
259
+ lines.push(`Clearing stale tool output only when the cache is cold would have cut another ${usd(r.reachableUsd)}, with no session made more expensive.`);
196
260
  }
261
+ lines.push("Measured from my own transcripts with context-doctor: npx context-doctor savings");
197
262
  return lines.join("\n");
198
263
  }
package/dist/sketch.js CHANGED
@@ -128,7 +128,7 @@ export function profileSketch(sketch) {
128
128
  severity: totalTokens > 100_000 ? "high" : "warn",
129
129
  estSavings: Math.max(0, totalTokens - afterHandoff),
130
130
  message: `${turns} turns: every new message re-reads ~${formatTokens(totalTokens)}.`,
131
- action: `Offer a handoff: write a ≤${HANDOFF_SUMMARY_TOKENS}-token summary (decisions, current state, open items, key identifiers) for the user to start a new chat with. A fresh chat re-reads ~${formatTokens(afterHandoff)} instead.`,
131
+ action: `Offer a handoff: write a ≤${HANDOFF_SUMMARY_TOKENS}-token summary (decisions, current state, open items, key identifiers) for the user to start a new chat with. A fresh chat re-reads ~${formatTokens(afterHandoff)} of conversation instead, plus the app's own system prompt, which every chat carries.`,
132
132
  });
133
133
  }
134
134
  const contextWindow = contextWindowFor(sketch.model);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "context-doctor",
3
- "version": "0.21.0",
4
- "description": "Keep AI coding sessions lean automatically: clears stale tool output from every Claude Code and GPT API request, only when it costs nothing extra (measured 9.8% less input, no session worse). Token profiler, hooks and MCP tools for Claude Code, Cursor, Codex and Claude Desktop.",
3
+ "version": "0.23.0",
4
+ "description": "See what your Claude Code sessions cost and cut it: offers /compact when you return to a large session after the cache expired (every surface, desktop app included), and a cache-safe autopilot for terminal, IDE and API traffic. Token profiler, hooks, MCP tools and a Claude Code plugin.",
5
5
  "keywords": [
6
6
  "agent",
7
7
  "ai-agents",