context-doctor 0.20.1 → 0.22.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,28 +2,60 @@
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
 
16
16
  ## Quick start
17
17
 
18
+ **See what it would save you first** (no install, reads your local Claude Code history):
19
+
18
20
  ```bash
21
+ npx context-doctor savings
22
+ ```
23
+
24
+ ```
25
+ What context-doctor finds in your Claude Code sessions (last 30 days, 29 sessions)
26
+ ────────────────────────────────────────────────────────────────
27
+ Input you were billed for $6,021 from the usage your transcripts record
28
+
29
+ 1. Compact when you come back $2,492 199 returns to a session over 150k tokens
30
+ after the prompt cache expired. Running /compact then, net of the compaction
31
+ itself, would have saved this. The every-prompt hook points these moments out;
32
+ it works in every Claude Code surface, the desktop app included.
33
+
34
+ 2. Autopilot n/a all 29 sessions ran in the desktop app, which
35
+ sets its own API address, so the autopilot proxy cannot sit in front of it.
36
+ (Had they run in the terminal or an IDE: $788, 12.9%.)
37
+ ```
38
+
39
+ 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.
40
+
41
+ **Then install it**, whichever way suits you:
42
+
43
+ ```bash
44
+ # Everything, every app it finds (Claude Code, Cursor, Codex, Claude Desktop)
19
45
  npm install -g context-doctor
20
- context-doctor install # hooks, MCP server and skill in every AI app it finds
46
+ context-doctor install
21
47
  context-doctor autopilot on # every new Claude Code session keeps its context lean
22
48
  ```
23
49
 
24
- Then start a new Claude Code session and work as usual. `context-doctor autopilot status` shows what it did; `context-doctor doctor` checks the whole setup. Everything is reversible: `context-doctor autopilot off`, `context-doctor uninstall`.
50
+ ```text
51
+ # Or as a Claude Code plugin, from inside Claude Code
52
+ /plugin marketplace add KushalP1/context-doctor
53
+ /plugin install context-doctor@context-doctor
54
+ ```
55
+
56
+ The plugin brings the every-prompt check, the MCP tools, and `/context-doctor:savings`, `/context-doctor:checkup` and `/context-doctor:autopilot`. It needs no npm step: the MCP server ships as one self-contained file, so it also works on Claude Code versions that do not install plugin dependencies.
25
57
 
26
- Just want a look first? `npx context-doctor session` profiles your latest Claude Code or Codex session in place, no install.
58
+ `context-doctor autopilot status` shows what autopilot did; `context-doctor doctor` checks the whole setup. Everything is reversible: `context-doctor autopilot off`, `context-doctor uninstall`, or `/plugin uninstall`.
27
59
 
28
60
  ```
29
61
  Where the tokens go
@@ -43,26 +75,36 @@ Findings (4)
43
75
 
44
76
  ## What it saves
45
77
 
46
- 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`.
78
+ 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.
79
+
80
+ **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.
81
+
82
+ | | |
83
+ |---|---|
84
+ | Net saving had you run `/compact` after the first reply | **$4,053 (about $914 a month)** |
85
+ | Returns where it paid off | 296 of 401 |
86
+ | Worst single case | −$2.30 (a session left a few messages later) |
87
+
88
+ 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.
89
+
90
+ **2. Autopilot** (terminal and IDE sessions, API apps). 130 days, 43 sessions, replayed through the shipped autopilot code with real timestamps:
47
91
 
48
92
  | | Without autopilot | With autopilot | Saved |
49
93
  |---|---|---|---|
50
94
  | Input tokens sent | 41.4 billion | 37.6 billion | **3.8 billion (9.2%)** |
51
95
  | Input cost at API list price | $48,962 | $44,268 | **$4,694 (9.8%)** |
52
- | Per 30 days | | | **~875 million tokens, ~$1,080** |
53
96
  | Sessions made more expensive | | | **0 of 43** |
54
97
 
55
- 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.
56
-
57
- 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.
98
+ 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.
58
99
 
59
- 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.
100
+ 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.
60
101
 
61
102
  ## What happens on each platform
62
103
 
63
104
  | Where you work | Automatic, every request | What you get on top |
64
105
  |---|---|---|
65
- | **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 | Status bar context meter, `/context-doctor` skill, `session`, `watch`, `report`, dashboard |
106
+ | **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 |
107
+ | **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 |
66
108
  | **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)) |
67
109
  | **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`) |
68
110
  | **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 |
@@ -75,6 +117,8 @@ Not claimed, because no process on your machine sends those requests: trimming i
75
117
  ## What's new
76
118
 
77
119
  - **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.
120
+ - **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.
121
+ - **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`.
78
122
  - **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.
79
123
  - **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`.
80
124
  - **0.18** `proxy --token` for putting the proxy on a public URL safely. **0.17** Claude Desktop: a `profile_context` the model can afford to call from chat, `.mcpb` bundle, standing preferences for web and mobile. **0.16** Codex. **0.15** Cursor.
@@ -138,6 +182,8 @@ Practical upshot: a developer who only wants cheaper, faster API calls never tou
138
182
  | Command | What it does |
139
183
  |---|---|
140
184
  | `context-doctor install` / `uninstall` | Wire (or remove) everything: MCP for Claude Desktop/Code/Cursor/Codex, the Agent Skill, the every-prompt hook |
185
+ | `context-doctor savings [--days n]` | What autopilot saves, or would have saved, on your own recent Claude Code sessions: replayed through the shipped code, against your actual billed usage. A bare `context-doctor` in a terminal shows the same |
186
+ | `context-doctor mcp [--http]` | The MCP server as a subcommand, for clients and registries that launch `npx -y context-doctor mcp` |
141
187
  | `context-doctor autopilot on\|off\|pause\|resume\|status` | Every new Claude Code session goes through the local proxy, which clears stale tool output only when the prompt cache is cold: measured 9.8% less input cost, no session worse |
142
188
  | `context-doctor instructions [--copy]` | The ~180-token standing rules (~120 on GPT) for claude.ai / ChatGPT preferences, for web and phones where no server runs |
143
189
  | `context-doctor analyze <file>` | Profile a conversation: token breakdown, findings, cost + latency estimates. `--fail-over-budget` exits 1 on a breach, for CI |
@@ -207,7 +253,7 @@ context-doctor autopilot pause # instant passthrough, nothing restarts
207
253
  context-doctor autopilot off # remove it
208
254
  ```
209
255
 
210
- `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.
256
+ `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.
211
257
 
212
258
  **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.
213
259
 
@@ -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";
@@ -179,24 +179,86 @@ async function waitHealthy(port, ms) {
179
179
  }
180
180
  }
181
181
  function readConfig(paths) {
182
+ // readJson returns {} for a missing file; only a file with a port is a config.
183
+ if (!existsSync(paths.config))
184
+ return undefined;
182
185
  try {
183
- return readJson(paths.config);
186
+ const cfg = readJson(paths.config);
187
+ return typeof cfg.port === "number" && cfg.port > 0 ? cfg : undefined;
184
188
  }
185
189
  catch {
186
190
  return undefined;
187
191
  }
188
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
+ }
189
251
  export function currentCli() {
190
252
  return join(dirname(fileURLToPath(import.meta.url)), "cli.js");
191
253
  }
192
254
  export async function autopilotOn(port = DEFAULT_AUTOPILOT_PORT, paths = autopilotPaths()) {
193
255
  const lines = [];
194
256
  const cli = currentCli();
195
- if (isEphemeralPath(cli)) {
257
+ if (isEphemeralPath(cli) || /[\\/]\.claude[\\/]plugins[\\/]/.test(cli)) {
196
258
  return {
197
259
  ok: false,
198
260
  lines: [
199
- "✗ Running from the npx cache, which npm deletes at will; a background service cannot point there.",
261
+ "✗ Running from the npx cache or a Claude Code plugin copy, which are replaced or deleted on update; a background service cannot point there.",
200
262
  " Install it once, then re-run: npm install -g context-doctor && context-doctor autopilot on",
201
263
  ],
202
264
  };
@@ -238,6 +300,7 @@ export async function autopilotOn(port = DEFAULT_AUTOPILOT_PORT, paths = autopil
238
300
  lines.push(`✓ Claude Code routed through it (env.ANTHROPIC_BASE_URL in ${paths.settings})`);
239
301
  lines.push(" Applies to Claude Code sessions started from now on (CLI, IDE, and the desktop app's Code tab).");
240
302
  lines.push(" Sessions already open keep their old route until restarted.");
303
+ lines.push(...reachNote());
241
304
  lines.push(` GPT apps on your own OpenAI key get the same: export OPENAI_BASE_URL=${proxyUrl(port)}/v1`);
242
305
  return { ok: true, lines };
243
306
  }
@@ -295,6 +358,8 @@ export async function autopilotStatus(paths = autopilotPaths()) {
295
358
  lines.push(`Since ${s.startedAt.slice(0, 16).replace("T", " ")} UTC: ${a.requests} requests, ${a.changedRequests} sent lighter`);
296
359
  lines.push(` ${a.resultsCleared} stale tool outputs cleared in ${a.batches} batches (${a.coldBatches} while the cache was cold anyway)`);
297
360
  lines.push(` ~${formatTokens(a.tokensRemoved)} tokens not sent`);
361
+ if (a.requests === 0)
362
+ lines.push(...reachNote(7).map((l) => l.replace(/^! /, " ")));
298
363
  const billed = s.upstreamInputTokens + s.upstreamCacheReadTokens + s.upstreamCacheWriteTokens;
299
364
  if (billed > 0)
300
365
  lines.push(` Billed input: ${formatTokens(billed)} (${Math.round((s.upstreamCacheReadTokens / billed) * 100)}% cache reads)`);
@@ -318,7 +383,7 @@ export async function ensureProxyUp(paths = autopilotPaths()) {
318
383
  return;
319
384
  if ((await health(cfg.port, 300)).ok)
320
385
  return;
321
- if (!existsSync(cfg.node) || !existsSync(cfg.cli))
386
+ if (typeof cfg.node !== "string" || typeof cfg.cli !== "string" || !existsSync(cfg.node) || !existsSync(cfg.cli))
322
387
  return;
323
388
  startDetached(serviceCommand(cfg.node, cfg.cli, cfg.port, paths), paths.log);
324
389
  await waitHealthy(cfg.port, 2500);
package/dist/cli.js CHANGED
@@ -21,7 +21,8 @@ import { listSessions, parseSessionFile } from "./session.js";
21
21
  import { runHook } from "./hook.js";
22
22
  import { buildImpactReport } from "./impact.js";
23
23
  import { measureTokenizer, renderTokenizer } from "./tokenizer-measure.js";
24
- import { autopilotOff, autopilotOn, autopilotPause, autopilotStatus, DEFAULT_AUTOPILOT_PORT } from "./autopilot.js";
24
+ import { autopilotOff, autopilotOn, autopilotPause, autopilotPaths, autopilotStatus, DEFAULT_AUTOPILOT_PORT } from "./autopilot.js";
25
+ import { estimateSavings, renderSavings } from "./savings.js";
25
26
  import { renderPreferences, copyToClipboard, CHAT_PREFERENCES } from "./preferences.js";
26
27
  import { recordLedger } from "./ledger.js";
27
28
  import { runDoctor } from "./doctor.js";
@@ -49,10 +50,16 @@ Usage:
49
50
  context-doctor install Wire the MCP server + skill into Claude Desktop,
50
51
  Claude Code, and Cursor automatically
51
52
  context-doctor uninstall Undo install
53
+ context-doctor --version Print the installed version
54
+ context-doctor savings [--days n] What autopilot saves (or would have) on YOUR recent
55
+ Claude Code sessions: replayed, priced as billed.
56
+ Also what a bare \`context-doctor\` shows
52
57
  context-doctor autopilot on|off|pause|resume|status
53
58
  Every new Claude Code session goes through the
54
59
  local proxy, which clears stale tool output only
55
60
  when the prompt cache is cold (never costs more)
61
+ context-doctor mcp [--http] The MCP server (same as context-doctor-mcp), for clients
62
+ that launch \`npx -y context-doctor mcp\`
56
63
  context-doctor instructions [--copy] Standing context rules to paste into claude.ai or
57
64
  ChatGPT preferences (works on web and mobile too)
58
65
  context-doctor session [file] Profile a Claude Code session transcript or a
@@ -142,6 +149,10 @@ function parseArgs(argv) {
142
149
  case "--help":
143
150
  console.log(HELP);
144
151
  process.exit(0);
152
+ case "-v":
153
+ case "--version":
154
+ console.log(packageVersion());
155
+ process.exit(0);
145
156
  case "--json":
146
157
  args.json = true;
147
158
  break;
@@ -211,6 +222,9 @@ function parseArgs(argv) {
211
222
  case "--token":
212
223
  args.token = argv[++i];
213
224
  break;
225
+ case "--days":
226
+ args.days = Number(argv[++i]);
227
+ break;
214
228
  case "--autopilot":
215
229
  args.autopilot = true;
216
230
  break;
@@ -266,6 +280,24 @@ function readInput(file) {
266
280
  return readFileSync(0, "utf8");
267
281
  return readFileSync(file, "utf8");
268
282
  }
283
+ /** The installed package's version, read from its package.json so it cannot drift. */
284
+ function packageVersion() {
285
+ try {
286
+ return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
287
+ }
288
+ catch {
289
+ return "unknown";
290
+ }
291
+ }
292
+ /** Any Claude Code transcript on this machine? One directory listing, no parsing. */
293
+ function hasClaudeSessions() {
294
+ try {
295
+ return listSessions(1).some((s) => s.path.includes(".claude"));
296
+ }
297
+ catch {
298
+ return false;
299
+ }
300
+ }
269
301
  async function main() {
270
302
  const args = parseArgs(process.argv.slice(2));
271
303
  if (args.command === "hook") {
@@ -459,6 +491,35 @@ async function main() {
459
491
  process.exitCode = 1;
460
492
  return;
461
493
  }
494
+ if (args.command === "savings" || (!args.command && process.stdout.isTTY && hasClaudeSessions())) {
495
+ // A bare `context-doctor` in a terminal, with Claude Code history on the
496
+ // machine, answers the question people install this for: what would it
497
+ // save me? Scripts and pipes still get the help text.
498
+ const progress = process.stderr.isTTY
499
+ ? (done, total) => process.stderr.write(`\rReplaying your Claude Code sessions through autopilot… ${done}/${total}`)
500
+ : undefined;
501
+ const report = estimateSavings(args.days ?? 30, {}, undefined, progress);
502
+ if (progress)
503
+ process.stderr.write("\r\x1b[K");
504
+ const on = existsSync(autopilotPaths().config);
505
+ if (args.json)
506
+ console.log(JSON.stringify(report, null, 2));
507
+ else {
508
+ console.log(renderSavings(report, on));
509
+ if (!args.command)
510
+ console.log("\nAll commands: context-doctor --help");
511
+ }
512
+ return;
513
+ }
514
+ if (args.command === "mcp") {
515
+ // `context-doctor mcp [--http ...]` is the MCP server, the same program as
516
+ // the context-doctor-mcp binary. Registries and clients that launch a
517
+ // package with `npx -y context-doctor <args>` can only reach the package's
518
+ // main binary, so the server has to be a subcommand of it too.
519
+ process.argv.splice(2, 1);
520
+ await import("./mcp.js");
521
+ return;
522
+ }
462
523
  if (args.command === "autopilot") {
463
524
  const sub = args.file ?? "status";
464
525
  const port = args.port ?? DEFAULT_AUTOPILOT_PORT;
@@ -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,174 @@
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
+ if (e?.type !== "assistant" || e.isSidechain)
64
+ continue;
65
+ const u = e.message?.usage;
66
+ if (!u)
67
+ continue;
68
+ const tokens = (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0);
69
+ if (tokens <= 0)
70
+ continue;
71
+ const lastReplyAt = Date.parse(e.timestamp);
72
+ if (!Number.isFinite(lastReplyAt))
73
+ return undefined;
74
+ const idle = now - lastReplyAt;
75
+ if (idle < idleMs || tokens < minTokens)
76
+ return undefined;
77
+ const model = e.message?.model;
78
+ const p = pricingFor(model);
79
+ return {
80
+ idleMs: idle,
81
+ tokens,
82
+ model,
83
+ lastReplyAt,
84
+ resumeUsd: p ? (tokens * 1.25 * p.inputPerM) / 1e6 : undefined,
85
+ perMessageUsd: p ? (tokens * 0.1 * p.inputPerM) / 1e6 : undefined,
86
+ };
87
+ }
88
+ }
89
+ catch {
90
+ /* unreadable transcript: no advice */
91
+ }
92
+ return undefined;
93
+ }
94
+ function duration(ms) {
95
+ const m = Math.round(ms / 60_000);
96
+ if (m < 90)
97
+ return `${m} minutes`;
98
+ const h = ms / 3_600_000;
99
+ return h < 48 ? `${h.toFixed(h < 10 ? 1 : 0)} hours` : `${Math.round(h / 24)} days`;
100
+ }
101
+ /** The note the hook hands the model: the numbers, and one sentence to say. */
102
+ export function renderColdResume(c) {
103
+ const cheaper = Math.max(0, Math.round((1 - AFTER_COMPACT_TOKENS / c.tokens) * 100));
104
+ const money = c.resumeUsd !== undefined
105
+ ? ` (~${formatUsd(c.resumeUsd)} at list price), and every later message re-reads it (~${formatUsd(c.perMessageUsd)} each)`
106
+ : ", and every later message re-reads it";
107
+ return [
108
+ `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}.`,
109
+ `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.`,
110
+ "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.",
111
+ ].join("\n");
112
+ }
113
+ /**
114
+ * Replay a transcript's billed usage and price what compacting at each cold
115
+ * resume would have saved. Each resume only counts the messages up to the
116
+ * next one (no double counting), and the compaction request is a cost.
117
+ */
118
+ export function coldResumeEvents(path, idleMs = COLD_IDLE_MS, minTokens = COLD_MIN_TOKENS) {
119
+ const reqs = [];
120
+ let lastId, model;
121
+ forEachLine(path, (line) => {
122
+ let e;
123
+ try {
124
+ e = JSON.parse(line);
125
+ }
126
+ catch {
127
+ return;
128
+ }
129
+ if (!e || e.isSidechain)
130
+ return;
131
+ if (e.type === "system" && e.subtype === "compact_boundary") {
132
+ reqs.push("compact");
133
+ return;
134
+ }
135
+ if (e.type !== "assistant" || !e.message || e.message.id === lastId)
136
+ return;
137
+ lastId = e.message.id;
138
+ const u = e.message.usage ?? {};
139
+ const total = (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0);
140
+ const at = Date.parse(e.timestamp);
141
+ if (total > 0 && Number.isFinite(at)) {
142
+ reqs.push({ at, total });
143
+ model ??= e.message.model;
144
+ }
145
+ });
146
+ const perM = pricingFor(model)?.inputPerM;
147
+ if (!perM)
148
+ return [];
149
+ const real = reqs.filter((r) => r !== "compact");
150
+ if (real.length < 2)
151
+ return [];
152
+ const events = [];
153
+ for (let i = 1; i < reqs.length; i++) {
154
+ const a = reqs[i - 1], b = reqs[i];
155
+ if (a === "compact" || b === "compact")
156
+ continue;
157
+ if (b.at - a.at <= idleMs || b.total < minTokens)
158
+ continue;
159
+ const removed = Math.max(0, b.total - AFTER_COMPACT_TOKENS);
160
+ let saved = 0, n = 0, prev = b.at;
161
+ for (const c of reqs.slice(i + 1)) {
162
+ if (c === "compact")
163
+ break;
164
+ if (c.at - prev > idleMs && c.total >= minTokens)
165
+ break;
166
+ saved += (c.at - prev > 3_600_000 ? 1.25 : 0.1) * removed;
167
+ prev = c.at;
168
+ n++;
169
+ }
170
+ const cost = 0.1 * b.total + OUTPUT_PRICE_RATIO * SUMMARY_OUTPUT_TOKENS + 1.25 * AFTER_COMPACT_TOKENS;
171
+ events.push({ at: b.at, tokens: b.total, messagesAfter: n, netUsd: ((saved - cost) * perM) / 1e6 });
172
+ }
173
+ return events;
174
+ }
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
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
14
14
  import { createHash } from "node:crypto";
15
+ import { homedir } from "node:os";
15
16
  import { join } from "node:path";
16
17
  import { recordLedger, statePath } from "./ledger.js";
17
18
  import { parseConversation } from "./parse.js";
@@ -19,7 +20,8 @@ import { profileConversation } from "./profile.js";
19
20
  import { parseSessionFile } from "./session.js";
20
21
  import { formatTokens, CHARS_PER_TOKEN } from "./tokens.js";
21
22
  import { ensureProxyUp } from "./autopilot.js";
22
- import { formatUsd } from "./pricing.js";
23
+ import { detectColdResume, renderColdResume } from "./coldresume.js";
24
+ import { formatUsd, pricingFor } from "./pricing.js";
23
25
  import { checkBudget, loadConfig } from "./config.js";
24
26
  /** Default nudge threshold; a project budget or env var can lower/raise it. */
25
27
  const DEFAULT_WARN_TOKENS = 80_000;
@@ -119,6 +121,15 @@ async function readStdin() {
119
121
  chunks.push(chunk);
120
122
  return Buffer.concat(chunks).toString("utf8");
121
123
  }
124
+ function settingsHasOurHook() {
125
+ try {
126
+ const hooks = JSON.parse(readFileSync(join(homedir(), ".claude", "settings.json"), "utf8"))?.hooks?.UserPromptSubmit;
127
+ return JSON.stringify(hooks ?? "").includes("context-doctor");
128
+ }
129
+ catch {
130
+ return false;
131
+ }
132
+ }
122
133
  export async function runHook() {
123
134
  // A hook must never break the user's prompt: any failure exits silently.
124
135
  try {
@@ -126,6 +137,12 @@ export async function runHook() {
126
137
  // so if the proxy died, start it now. One existsSync when autopilot is
127
138
  // off; a ~1ms localhost health check when it is on and healthy.
128
139
  const heal = ensureProxyUp().catch(() => undefined);
140
+ // Installed both ways (plugin and `context-doctor install`)? The plugin
141
+ // copy stays silent so the model is told once, not twice.
142
+ if (process.env.CLAUDE_PLUGIN_ROOT && settingsHasOurHook()) {
143
+ await heal;
144
+ return;
145
+ }
129
146
  const input = JSON.parse(await readStdin());
130
147
  await heal;
131
148
  const transcriptPath = input.transcript_path;
@@ -144,8 +161,28 @@ export async function runHook() {
144
161
  // expensive read. Heavy-but-quiet sessions cost one stat + tiny state read.
145
162
  const sessionId = input.session_id ?? transcriptPath;
146
163
  const prev = readSessionState(sessionId);
147
- 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);
148
184
  return;
185
+ }
149
186
  // Slow path (growth events only): full parse + profile.
150
187
  const parsed = parseSessionFile(transcriptPath);
151
188
  if (parsed.messageCount === 0)
@@ -157,18 +194,24 @@ export async function runHook() {
157
194
  const liveTokens = parsed.reportedInputTokens ?? profile.totalTokens;
158
195
  // Record this parse so the next prompts take fast path 2.
159
196
  const shouldWarn = liveTokens >= threshold && liveTokens >= prev.t * REGROWTH_FACTOR;
160
- writeSessionState(sessionId, { t: shouldWarn ? liveTokens : prev.t, b: sizeBytes });
197
+ writeSessionState(sessionId, { t: shouldWarn ? liveTokens : prev.t, b: sizeBytes, cr });
161
198
  recordLedger({ ev: "check", sid: sessionId.slice(0, 12), tok: liveTokens, warn: shouldWarn });
162
- if (!shouldWarn)
199
+ if (!shouldWarn) {
200
+ emit(notes);
163
201
  return;
202
+ }
164
203
  const windowPct = profile.contextWindow ? (liveTokens / profile.contextWindow) * 100 : undefined;
165
- const costPerCall = profile.cost && profile.totalTokens > 0
166
- ? (profile.cost.perCallUsd * liveTokens) / profile.totalTokens
167
- : 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;
168
211
  const lines = [
169
212
  `This session's context is at ~${formatTokens(liveTokens)} tokens` +
170
213
  (windowPct !== undefined ? ` (${windowPct.toFixed(0)}% of the window)` : "") +
171
- (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` : "") +
172
215
  ".",
173
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.",
174
217
  ];
@@ -184,12 +227,7 @@ export async function runHook() {
184
227
  if (liveTokens > threshold * 2) {
185
228
  lines.push("If it fits the flow, offer the user a compaction of the older history.");
186
229
  }
187
- console.log(JSON.stringify({
188
- hookSpecificOutput: {
189
- hookEventName: "UserPromptSubmit",
190
- additionalContext: `<context-doctor>\n${lines.join("\n")}\n</context-doctor>`,
191
- },
192
- }));
230
+ emit([...notes, ...lines]);
193
231
  }
194
232
  catch {
195
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
@@ -23,3 +23,5 @@ export * from "./preferences.js";
23
23
  export * from "./tokenizer-measure.js";
24
24
  export * from "./autoclear.js";
25
25
  export * from "./autopilot.js";
26
+ export * from "./savings.js";
27
+ export * from "./coldresume.js";
package/dist/index.js CHANGED
@@ -18,3 +18,5 @@ export * from "./preferences.js";
18
18
  export * from "./tokenizer-measure.js";
19
19
  export * from "./autoclear.js";
20
20
  export * from "./autopilot.js";
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.20.1" }, { instructions: SERVER_INSTRUCTIONS });
52
+ const server = new McpServer({ name: "context-doctor", version: "0.22.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.20.1";
50
+ export declare const PROXY_VERSION = "0.22.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.20.1";
23
+ export const PROXY_VERSION = "0.22.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 {
@@ -0,0 +1,61 @@
1
+ /**
2
+ * `context-doctor savings`: what autopilot would have saved on YOUR recent
3
+ * Claude Code sessions, measured, not modelled.
4
+ *
5
+ * Each session is replayed request by request, twice: as it was sent, and as
6
+ * autopilot (autoclear.ts, the shipped class) would have sent it. Both arms
7
+ * are priced the way the prompt cache bills (reads 0.1x, writes 1.25x, a
8
+ * cold cache after an idle gap longer than the request's own TTL). The
9
+ * difference between the arms is the saving. The baseline shown to the user
10
+ * is not an estimate: it is the input the sessions were actually billed for,
11
+ * read from the usage every transcript records.
12
+ */
13
+ import { type AutoClearOptions } from "./autoclear.js";
14
+ export interface SessionSavings {
15
+ path: string;
16
+ project: string;
17
+ model?: string;
18
+ requests: number;
19
+ /** Cache-weighted input actually billed (usage in the transcript), in base-input-token units. */
20
+ billedWeighted: number;
21
+ billedUsd: number;
22
+ /** Replay difference, in the same units. */
23
+ savedWeighted: number;
24
+ savedUsd: number;
25
+ /** Raw input tokens autopilot would not have sent. */
26
+ savedTokens: number;
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;
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;
43
+ export interface SavingsReport {
44
+ days: number;
45
+ sessions: SessionSavings[];
46
+ billedUsd: number;
47
+ savedUsd: number;
48
+ savedTokens: number;
49
+ billedTokens: number;
50
+ savedPct: number;
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;
57
+ }
58
+ /** Replay one Claude Code transcript. Undefined when it holds too few requests to mean anything. */
59
+ export declare function replaySession(path: string, options?: AutoClearOptions, since?: number): SessionSavings | undefined;
60
+ export declare function estimateSavings(days?: number, options?: AutoClearOptions, paths?: string[], onProgress?: (done: number, total: number) => void): SavingsReport;
61
+ export declare function renderSavings(r: SavingsReport, autopilotOn?: boolean): string;
@@ -0,0 +1,238 @@
1
+ /**
2
+ * `context-doctor savings`: what autopilot would have saved on YOUR recent
3
+ * Claude Code sessions, measured, not modelled.
4
+ *
5
+ * Each session is replayed request by request, twice: as it was sent, and as
6
+ * autopilot (autoclear.ts, the shipped class) would have sent it. Both arms
7
+ * are priced the way the prompt cache bills (reads 0.1x, writes 1.25x, a
8
+ * cold cache after an idle gap longer than the request's own TTL). The
9
+ * difference between the arms is the saving. The baseline shown to the user
10
+ * is not an estimate: it is the input the sessions were actually billed for,
11
+ * read from the usage every transcript records.
12
+ */
13
+ import { basename, dirname } from "node:path";
14
+ import { AutoClearer } from "./autoclear.js";
15
+ import { coldResumeEvents } from "./coldresume.js";
16
+ import { forEachLine, listSessions } from "./session.js";
17
+ import { pricingFor } from "./pricing.js";
18
+ import { estimateTokens, formatTokens } from "./tokens.js";
19
+ /** System prompt and tool schemas: sent on every request, identical in both arms. */
20
+ const FIXED_TOKENS = 54_000;
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
+ }
31
+ /** One arm of the replay: the history as sent, priced incrementally. */
32
+ function arm(model, clearer) {
33
+ const msgs = [];
34
+ const tok = [];
35
+ let total = 0, prevLen = 0, weighted = 0, raw = 0;
36
+ const size = (m) => estimateTokens(JSON.stringify(m), model);
37
+ return {
38
+ push(m) { msgs.push(structuredClone(m)); const k = size(m); tok.push(k); total += k; },
39
+ extend(blocks) {
40
+ const i = msgs.length - 1;
41
+ msgs[i].content.push(...structuredClone(blocks));
42
+ total -= tok[i];
43
+ tok[i] = size(msgs[i]);
44
+ total += tok[i];
45
+ },
46
+ request(now, cold, counted = true) {
47
+ let first = -1;
48
+ if (clearer) {
49
+ const body = { model, messages: msgs, system: [{ type: "text", text: "", cache_control: { type: "ephemeral", ttl: "1h" } }] };
50
+ first = clearer.apply(body, now).firstChanged;
51
+ }
52
+ if (first >= 0)
53
+ for (let i = first; i < msgs.length; i++) {
54
+ total -= tok[i];
55
+ tok[i] = size(msgs[i]);
56
+ total += tok[i];
57
+ }
58
+ const k = cold ? 0 : Math.min(prevLen, first >= 0 ? first : prevLen);
59
+ let read = 0;
60
+ for (let i = 0; i < k; i++)
61
+ read += tok[i];
62
+ const write = total - read;
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
+ }
67
+ prevLen = msgs.length;
68
+ },
69
+ get weighted() { return weighted; },
70
+ get raw() { return raw; },
71
+ };
72
+ }
73
+ /** Replay one Claude Code transcript. Undefined when it holds too few requests to mean anything. */
74
+ export function replaySession(path, options = {}, since = 0) {
75
+ let model;
76
+ let base = arm(undefined, null), auto = arm(undefined, new AutoClearer(options));
77
+ let baseW = 0, autoW = 0, baseRaw = 0, autoRaw = 0;
78
+ let entrypoint;
79
+ let billed = 0, billedRaw = 0, requests = 0, lastId, lastTs = null;
80
+ const flush = () => { baseW += base.weighted; autoW += auto.weighted; baseRaw += base.raw; autoRaw += auto.raw; };
81
+ forEachLine(path, (line) => {
82
+ let e;
83
+ try {
84
+ e = JSON.parse(line);
85
+ }
86
+ catch {
87
+ return;
88
+ }
89
+ if (!e || e.isSidechain)
90
+ return;
91
+ if (!entrypoint && typeof e.entrypoint === "string")
92
+ entrypoint = e.entrypoint;
93
+ if (e.type === "system" && e.subtype === "compact_boundary") {
94
+ // Compaction replaces the history in both arms alike: start fresh.
95
+ flush();
96
+ base = arm(model, null);
97
+ auto = arm(model, new AutoClearer(options));
98
+ return;
99
+ }
100
+ if (e.type === "assistant" && e.message) {
101
+ const m = e.message;
102
+ model = model ?? m.model;
103
+ const blocks = (Array.isArray(m.content) ? m.content : []).filter((b) => b?.type !== "thinking" && b?.type !== "redacted_thinking");
104
+ if (m.id !== lastId) {
105
+ lastId = m.id;
106
+ const u = m.usage ?? {};
107
+ const read = u.cache_read_input_tokens ?? 0, write = u.cache_creation_input_tokens ?? 0, fresh = u.input_tokens ?? 0;
108
+ if (read + write + fresh > 0) {
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
+ }
118
+ const cold = lastTs === null || now - lastTs > CLAUDE_CODE_TTL_MS;
119
+ base.request(now, cold, inWindow);
120
+ auto.request(now, cold, inWindow);
121
+ lastTs = now;
122
+ }
123
+ base.push({ role: "assistant", content: blocks });
124
+ auto.push({ role: "assistant", content: blocks });
125
+ }
126
+ else {
127
+ base.extend(blocks);
128
+ auto.extend(blocks);
129
+ }
130
+ }
131
+ else if (e.type === "user" && e.message) {
132
+ const c = e.message.content;
133
+ const msg = { role: "user", content: Array.isArray(c) ? c : [{ type: "text", text: String(c ?? "") }] };
134
+ base.push(msg);
135
+ auto.push(msg);
136
+ }
137
+ });
138
+ flush();
139
+ if (requests < 10 || billed <= 0)
140
+ return undefined;
141
+ // The replay decides the SHARE saved (both arms priced the same way); that
142
+ // share is applied to what was actually billed, so the dollars and tokens
143
+ // shown are fractions of the user's real usage, not of an estimate.
144
+ const perM = pricingFor(model)?.inputPerM ?? 0;
145
+ const cold = coldResumeEvents(path).filter((c) => c.at >= since);
146
+ const pct = baseW > 0 ? (baseW - autoW) / baseW : 0;
147
+ const rawPct = baseRaw > 0 ? (baseRaw - autoRaw) / baseRaw : 0;
148
+ const dir = basename(dirname(path));
149
+ return {
150
+ path,
151
+ project: dir.replace(/^-Users-[^-]+-/, "").replace(/^-/, "") || dir,
152
+ model,
153
+ requests,
154
+ billedWeighted: billed,
155
+ billedUsd: (billed / 1e6) * perM,
156
+ savedWeighted: pct * billed,
157
+ savedUsd: ((pct * billed) / 1e6) * perM,
158
+ savedTokens: rawPct * billedRaw,
159
+ savedPct: pct,
160
+ entrypoint,
161
+ autopilotReachable: autopilotReaches(entrypoint),
162
+ coldResumes: cold.length,
163
+ coldResumeNetUsd: cold.reduce((n, c) => n + c.netUsd, 0),
164
+ };
165
+ }
166
+ export function estimateSavings(days = 30, options = {}, paths, onProgress) {
167
+ const since = Date.now() - days * 86_400_000;
168
+ const targets = paths ?? listSessions(10_000).filter((s) => s.path.includes(".claude") && s.modifiedAt.getTime() >= since).map((s) => s.path);
169
+ const sessions = [];
170
+ for (const [i, p] of targets.entries()) {
171
+ onProgress?.(i, targets.length);
172
+ try {
173
+ const s = replaySession(p, options, since);
174
+ if (s)
175
+ sessions.push(s);
176
+ }
177
+ catch {
178
+ /* one unreadable transcript must not sink the report */
179
+ }
180
+ }
181
+ const sum = (k) => sessions.reduce((n, s) => n + s[k], 0);
182
+ const billedUsd = sum("billedUsd"), savedUsd = sum("savedUsd");
183
+ const billedW = sum("billedWeighted");
184
+ const savedPct = billedW > 0 ? sum("savedWeighted") / billedW : 0;
185
+ return {
186
+ days,
187
+ sessions: sessions.sort((a, b) => b.savedUsd - a.savedUsd),
188
+ billedUsd,
189
+ savedUsd,
190
+ savedTokens: sum("savedTokens"),
191
+ billedTokens: sessions.reduce((n, s) => n + s.billedWeighted, 0),
192
+ savedPct,
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"),
198
+ };
199
+ }
200
+ const tokens = (n) => (n >= 1e9 ? `${(n / 1e9).toFixed(1)}B` : formatTokens(n));
201
+ const usd = (n) => (n >= 100 ? `$${Math.round(n).toLocaleString("en-US")}` : `$${n.toFixed(2)}`);
202
+ export function renderSavings(r, autopilotOn = false) {
203
+ const lines = [];
204
+ if (r.sessions.length === 0) {
205
+ lines.push(`No Claude Code sessions with 10+ requests in the last ${r.days} days to replay.`);
206
+ lines.push("Use Claude Code for a while, then run this again: `context-doctor savings`.");
207
+ return lines.join("\n");
208
+ }
209
+ const n = r.sessions.length;
210
+ const allDesktop = r.unreachableSessions === n;
211
+ lines.push(`What context-doctor finds in your Claude Code sessions (last ${r.days} days, ${n} session${n === 1 ? "" : "s"})`);
212
+ lines.push("─".repeat(64));
213
+ lines.push(`Input you were billed for ${usd(r.billedUsd).padStart(10)} from the usage your transcripts record`);
214
+ lines.push("");
215
+ lines.push(`1. Compact when you come back ${usd(Math.max(0, r.coldResumeNetUsd)).padStart(10)} ${r.coldResumes} return${r.coldResumes === 1 ? "" : "s"} to a session over 150k tokens`);
216
+ lines.push(" after the prompt cache expired. Running /compact then, net of the compaction");
217
+ lines.push(" itself, would have saved this. The every-prompt hook points these moments out;");
218
+ lines.push(" it works in every Claude Code surface, the desktop app included.");
219
+ lines.push("");
220
+ if (allDesktop) {
221
+ lines.push(`2. Autopilot ${"n/a".padStart(10)} all ${n} sessions ran in the desktop app, which`);
222
+ lines.push(" sets its own API address, so the autopilot proxy cannot sit in front of it.");
223
+ lines.push(` (Had they run in the terminal or an IDE: ${usd(r.savedUsd)}, ${(r.savedPct * 100).toFixed(1)}%.)`);
224
+ }
225
+ else {
226
+ lines.push(`2. Autopilot ${usd(r.reachableUsd).padStart(10)} stale tool output cleared only when the cache`);
227
+ lines.push(` is cold (never costs more; ${r.worse} session${r.worse === 1 ? "" : "s"} made more expensive), ${tokens(r.savedTokens)} tokens not sent.`);
228
+ if (r.unreachableSessions > 0)
229
+ lines.push(` Counts terminal and IDE sessions only; ${r.unreachableSessions} desktop-app session${r.unreachableSessions === 1 ? "" : "s"} excluded (not reachable).`);
230
+ if (!autopilotOn)
231
+ lines.push(" Turn it on: context-doctor autopilot on");
232
+ }
233
+ lines.push("");
234
+ lines.push("Replayed request by request through the shipped code and priced as the prompt");
235
+ lines.push("cache bills, at list prices. On a subscription the same tokens come out of your");
236
+ lines.push("usage limit instead.");
237
+ return lines.join("\n");
238
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "context-doctor",
3
- "version": "0.20.1",
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.22.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",
@@ -46,6 +46,7 @@
46
46
  "files": [
47
47
  "dist",
48
48
  "!dist/test",
49
+ "!dist/mcp.bundle.js",
49
50
  "skills",
50
51
  "README.md",
51
52
  "LICENSE"
@@ -54,11 +55,12 @@
54
55
  "node": ">=20"
55
56
  },
56
57
  "scripts": {
57
- "build": "tsc && node -e \"const fs=require('fs');['dist/cli.js','dist/mcp.js'].forEach(f=>fs.chmodSync(f,0o755))\"",
58
+ "build": "tsc && node scripts/bundle-mcp.mjs && node -e \"const fs=require('fs');['dist/cli.js','dist/mcp.js'].forEach(f=>fs.chmodSync(f,0o755))\"",
58
59
  "prepublishOnly": "npm test",
59
60
  "dev": "tsc --watch",
60
61
  "test": "npm run build && node scripts/test.mjs",
61
- "build:mcpb": "npm run build && node scripts/build-mcpb.mjs"
62
+ "build:mcpb": "npm run build && node scripts/build-mcpb.mjs",
63
+ "version": "node scripts/sync-versions.mjs"
62
64
  },
63
65
  "dependencies": {
64
66
  "@modelcontextprotocol/sdk": "^1.0.0",
@@ -66,10 +68,12 @@
66
68
  },
67
69
  "devDependencies": {
68
70
  "@types/node": "^22.0.0",
71
+ "esbuild": "^0.25.12",
69
72
  "typescript": "^5.5.0"
70
73
  },
71
74
  "homepage": "https://github.com/KushalP1/context-doctor#readme",
72
75
  "bugs": {
73
76
  "url": "https://github.com/KushalP1/context-doctor/issues"
74
- }
77
+ },
78
+ "mcpName": "io.github.KushalP1/context-doctor"
75
79
  }