context-doctor 0.20.1 → 0.21.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
@@ -15,15 +15,41 @@ 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
+
20
+ ```bash
21
+ npx context-doctor savings
22
+ ```
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
+ ```
32
+
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.
34
+
35
+ **Then install it**, whichever way suits you:
36
+
18
37
  ```bash
38
+ # Everything, every app it finds (Claude Code, Cursor, Codex, Claude Desktop)
19
39
  npm install -g context-doctor
20
- context-doctor install # hooks, MCP server and skill in every AI app it finds
40
+ context-doctor install
21
41
  context-doctor autopilot on # every new Claude Code session keeps its context lean
22
42
  ```
23
43
 
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`.
44
+ ```text
45
+ # Or as a Claude Code plugin, from inside Claude Code
46
+ /plugin marketplace add KushalP1/context-doctor
47
+ /plugin install context-doctor@context-doctor
48
+ ```
49
+
50
+ 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
51
 
26
- Just want a look first? `npx context-doctor session` profiles your latest Claude Code or Codex session in place, no install.
52
+ `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
53
 
28
54
  ```
29
55
  Where the tokens go
@@ -62,7 +88,7 @@ What is not counted here: the proxy's full optimizer for your own API apps, the
62
88
 
63
89
  | Where you work | Automatic, every request | What you get on top |
64
90
  |---|---|---|
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 |
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 |
66
92
  | **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
93
  | **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
94
  | **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 +101,7 @@ Not claimed, because no process on your machine sends those requests: trimming i
75
101
  ## What's new
76
102
 
77
103
  - **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.
104
+ - **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
105
  - **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
106
  - **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
107
  - **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 +165,8 @@ Practical upshot: a developer who only wants cheaper, faster API calls never tou
138
165
  | Command | What it does |
139
166
  |---|---|
140
167
  | `context-doctor install` / `uninstall` | Wire (or remove) everything: MCP for Claude Desktop/Code/Cursor/Codex, the Agent Skill, the every-prompt hook |
168
+ | `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 |
169
+ | `context-doctor mcp [--http]` | The MCP server as a subcommand, for clients and registries that launch `npx -y context-doctor mcp` |
141
170
  | `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
171
  | `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
172
  | `context-doctor analyze <file>` | Profile a conversation: token breakdown, findings, cost + latency estimates. `--fail-over-budget` exits 1 on a breach, for CI |
package/dist/autopilot.js CHANGED
@@ -179,8 +179,12 @@ 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;
@@ -192,11 +196,11 @@ export function currentCli() {
192
196
  export async function autopilotOn(port = DEFAULT_AUTOPILOT_PORT, paths = autopilotPaths()) {
193
197
  const lines = [];
194
198
  const cli = currentCli();
195
- if (isEphemeralPath(cli)) {
199
+ if (isEphemeralPath(cli) || /[\\/]\.claude[\\/]plugins[\\/]/.test(cli)) {
196
200
  return {
197
201
  ok: false,
198
202
  lines: [
199
- "✗ Running from the npx cache, which npm deletes at will; a background service cannot point there.",
203
+ "✗ 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
204
  " Install it once, then re-run: npm install -g context-doctor && context-doctor autopilot on",
201
205
  ],
202
206
  };
@@ -318,7 +322,7 @@ export async function ensureProxyUp(paths = autopilotPaths()) {
318
322
  return;
319
323
  if ((await health(cfg.port, 300)).ok)
320
324
  return;
321
- if (!existsSync(cfg.node) || !existsSync(cfg.cli))
325
+ if (typeof cfg.node !== "string" || typeof cfg.cli !== "string" || !existsSync(cfg.node) || !existsSync(cfg.cli))
322
326
  return;
323
327
  startDetached(serviceCommand(cfg.node, cfg.cli, cfg.port, paths), paths.log);
324
328
  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,15 @@ 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 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
52
56
  context-doctor autopilot on|off|pause|resume|status
53
57
  Every new Claude Code session goes through the
54
58
  local proxy, which clears stale tool output only
55
59
  when the prompt cache is cold (never costs more)
60
+ context-doctor mcp [--http] The MCP server (same as context-doctor-mcp), for clients
61
+ that launch \`npx -y context-doctor mcp\`
56
62
  context-doctor instructions [--copy] Standing context rules to paste into claude.ai or
57
63
  ChatGPT preferences (works on web and mobile too)
58
64
  context-doctor session [file] Profile a Claude Code session transcript or a
@@ -211,6 +217,9 @@ function parseArgs(argv) {
211
217
  case "--token":
212
218
  args.token = argv[++i];
213
219
  break;
220
+ case "--days":
221
+ args.days = Number(argv[++i]);
222
+ break;
214
223
  case "--autopilot":
215
224
  args.autopilot = true;
216
225
  break;
@@ -266,6 +275,15 @@ function readInput(file) {
266
275
  return readFileSync(0, "utf8");
267
276
  return readFileSync(file, "utf8");
268
277
  }
278
+ /** Any Claude Code transcript on this machine? One directory listing, no parsing. */
279
+ function hasClaudeSessions() {
280
+ try {
281
+ return listSessions(1).some((s) => s.path.includes(".claude"));
282
+ }
283
+ catch {
284
+ return false;
285
+ }
286
+ }
269
287
  async function main() {
270
288
  const args = parseArgs(process.argv.slice(2));
271
289
  if (args.command === "hook") {
@@ -459,6 +477,35 @@ async function main() {
459
477
  process.exitCode = 1;
460
478
  return;
461
479
  }
480
+ if (args.command === "savings" || (!args.command && process.stdout.isTTY && hasClaudeSessions())) {
481
+ // A bare `context-doctor` in a terminal, with Claude Code history on the
482
+ // machine, answers the question people install this for: what would it
483
+ // save me? Scripts and pipes still get the help text.
484
+ const progress = process.stderr.isTTY
485
+ ? (done, total) => process.stderr.write(`\rReplaying your Claude Code sessions through autopilot… ${done}/${total}`)
486
+ : undefined;
487
+ const report = estimateSavings(args.days ?? 30, {}, undefined, progress);
488
+ if (progress)
489
+ process.stderr.write("\r\x1b[K");
490
+ const on = existsSync(autopilotPaths().config);
491
+ if (args.json)
492
+ console.log(JSON.stringify(report, null, 2));
493
+ else {
494
+ console.log(renderSavings(report, on));
495
+ if (!args.command)
496
+ console.log("\nAll commands: context-doctor --help");
497
+ }
498
+ return;
499
+ }
500
+ if (args.command === "mcp") {
501
+ // `context-doctor mcp [--http ...]` is the MCP server, the same program as
502
+ // the context-doctor-mcp binary. Registries and clients that launch a
503
+ // package with `npx -y context-doctor <args>` can only reach the package's
504
+ // main binary, so the server has to be a subcommand of it too.
505
+ process.argv.splice(2, 1);
506
+ await import("./mcp.js");
507
+ return;
508
+ }
462
509
  if (args.command === "autopilot") {
463
510
  const sub = args.file ?? "status";
464
511
  const port = args.port ?? DEFAULT_AUTOPILOT_PORT;
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";
@@ -119,6 +120,15 @@ async function readStdin() {
119
120
  chunks.push(chunk);
120
121
  return Buffer.concat(chunks).toString("utf8");
121
122
  }
123
+ function settingsHasOurHook() {
124
+ try {
125
+ const hooks = JSON.parse(readFileSync(join(homedir(), ".claude", "settings.json"), "utf8"))?.hooks?.UserPromptSubmit;
126
+ return JSON.stringify(hooks ?? "").includes("context-doctor");
127
+ }
128
+ catch {
129
+ return false;
130
+ }
131
+ }
122
132
  export async function runHook() {
123
133
  // A hook must never break the user's prompt: any failure exits silently.
124
134
  try {
@@ -126,6 +136,12 @@ export async function runHook() {
126
136
  // so if the proxy died, start it now. One existsSync when autopilot is
127
137
  // off; a ~1ms localhost health check when it is on and healthy.
128
138
  const heal = ensureProxyUp().catch(() => undefined);
139
+ // Installed both ways (plugin and `context-doctor install`)? The plugin
140
+ // copy stays silent so the model is told once, not twice.
141
+ if (process.env.CLAUDE_PLUGIN_ROOT && settingsHasOurHook()) {
142
+ await heal;
143
+ return;
144
+ }
129
145
  const input = JSON.parse(await readStdin());
130
146
  await heal;
131
147
  const transcriptPath = input.transcript_path;
package/dist/index.d.ts CHANGED
@@ -23,3 +23,4 @@ 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";
package/dist/index.js CHANGED
@@ -18,3 +18,4 @@ 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";
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.21.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.21.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.21.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"]);
@@ -0,0 +1,42 @@
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
+ }
29
+ export interface SavingsReport {
30
+ days: number;
31
+ sessions: SessionSavings[];
32
+ billedUsd: number;
33
+ savedUsd: number;
34
+ savedTokens: number;
35
+ billedTokens: number;
36
+ savedPct: number;
37
+ worse: number;
38
+ }
39
+ /** 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;
41
+ export declare function estimateSavings(days?: number, options?: AutoClearOptions, paths?: string[], onProgress?: (done: number, total: number) => void): SavingsReport;
42
+ export declare function renderSavings(r: SavingsReport, autopilotOn?: boolean): string;
@@ -0,0 +1,198 @@
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 { forEachLine, listSessions } from "./session.js";
16
+ import { pricingFor } from "./pricing.js";
17
+ import { estimateTokens, formatTokens } from "./tokens.js";
18
+ /** System prompt and tool schemas: sent on every request, identical in both arms. */
19
+ const FIXED_TOKENS = 54_000;
20
+ const CLAUDE_CODE_TTL_MS = 3_600_000;
21
+ /** One arm of the replay: the history as sent, priced incrementally. */
22
+ function arm(model, clearer) {
23
+ const msgs = [];
24
+ const tok = [];
25
+ let total = 0, prevLen = 0, weighted = 0, raw = 0;
26
+ const size = (m) => estimateTokens(JSON.stringify(m), model);
27
+ return {
28
+ push(m) { msgs.push(structuredClone(m)); const k = size(m); tok.push(k); total += k; },
29
+ extend(blocks) {
30
+ const i = msgs.length - 1;
31
+ msgs[i].content.push(...structuredClone(blocks));
32
+ total -= tok[i];
33
+ tok[i] = size(msgs[i]);
34
+ total += tok[i];
35
+ },
36
+ request(now, cold) {
37
+ let first = -1;
38
+ if (clearer) {
39
+ const body = { model, messages: msgs, system: [{ type: "text", text: "", cache_control: { type: "ephemeral", ttl: "1h" } }] };
40
+ first = clearer.apply(body, now).firstChanged;
41
+ }
42
+ if (first >= 0)
43
+ for (let i = first; i < msgs.length; i++) {
44
+ total -= tok[i];
45
+ tok[i] = size(msgs[i]);
46
+ total += tok[i];
47
+ }
48
+ const k = cold ? 0 : Math.min(prevLen, first >= 0 ? first : prevLen);
49
+ let read = 0;
50
+ for (let i = 0; i < k; i++)
51
+ read += tok[i];
52
+ 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;
55
+ prevLen = msgs.length;
56
+ },
57
+ get weighted() { return weighted; },
58
+ get raw() { return raw; },
59
+ };
60
+ }
61
+ /** Replay one Claude Code transcript. Undefined when it holds too few requests to mean anything. */
62
+ export function replaySession(path, options = {}) {
63
+ let model;
64
+ let base = arm(undefined, null), auto = arm(undefined, new AutoClearer(options));
65
+ let baseW = 0, autoW = 0, baseRaw = 0, autoRaw = 0;
66
+ let billed = 0, billedRaw = 0, requests = 0, lastId, lastTs = null;
67
+ const flush = () => { baseW += base.weighted; autoW += auto.weighted; baseRaw += base.raw; autoRaw += auto.raw; };
68
+ forEachLine(path, (line) => {
69
+ let e;
70
+ try {
71
+ e = JSON.parse(line);
72
+ }
73
+ catch {
74
+ return;
75
+ }
76
+ if (!e || e.isSidechain)
77
+ return;
78
+ if (e.type === "system" && e.subtype === "compact_boundary") {
79
+ // Compaction replaces the history in both arms alike: start fresh.
80
+ flush();
81
+ base = arm(model, null);
82
+ auto = arm(model, new AutoClearer(options));
83
+ return;
84
+ }
85
+ if (e.type === "assistant" && e.message) {
86
+ const m = e.message;
87
+ model = model ?? m.model;
88
+ const blocks = (Array.isArray(m.content) ? m.content : []).filter((b) => b?.type !== "thinking" && b?.type !== "redacted_thinking");
89
+ if (m.id !== lastId) {
90
+ lastId = m.id;
91
+ const u = m.usage ?? {};
92
+ const read = u.cache_read_input_tokens ?? 0, write = u.cache_creation_input_tokens ?? 0, fresh = u.input_tokens ?? 0;
93
+ if (read + write + fresh > 0) {
94
+ billed += fresh + read * 0.1 + write * 1.25;
95
+ billedRaw += fresh + read + write;
96
+ requests++;
97
+ const now = Date.parse(e.timestamp) || 0;
98
+ const cold = lastTs === null || now - lastTs > CLAUDE_CODE_TTL_MS;
99
+ base.request(now, cold);
100
+ auto.request(now, cold);
101
+ lastTs = now;
102
+ }
103
+ base.push({ role: "assistant", content: blocks });
104
+ auto.push({ role: "assistant", content: blocks });
105
+ }
106
+ else {
107
+ base.extend(blocks);
108
+ auto.extend(blocks);
109
+ }
110
+ }
111
+ else if (e.type === "user" && e.message) {
112
+ const c = e.message.content;
113
+ const msg = { role: "user", content: Array.isArray(c) ? c : [{ type: "text", text: String(c ?? "") }] };
114
+ base.push(msg);
115
+ auto.push(msg);
116
+ }
117
+ });
118
+ flush();
119
+ if (requests < 10 || billed <= 0)
120
+ return undefined;
121
+ // The replay decides the SHARE saved (both arms priced the same way); that
122
+ // share is applied to what was actually billed, so the dollars and tokens
123
+ // shown are fractions of the user's real usage, not of an estimate.
124
+ const perM = pricingFor(model)?.inputPerM ?? 0;
125
+ const pct = baseW > 0 ? (baseW - autoW) / baseW : 0;
126
+ const rawPct = baseRaw > 0 ? (baseRaw - autoRaw) / baseRaw : 0;
127
+ const dir = basename(dirname(path));
128
+ return {
129
+ path,
130
+ project: dir.replace(/^-Users-[^-]+-/, "").replace(/^-/, "") || dir,
131
+ model,
132
+ requests,
133
+ billedWeighted: billed,
134
+ billedUsd: (billed / 1e6) * perM,
135
+ savedWeighted: pct * billed,
136
+ savedUsd: ((pct * billed) / 1e6) * perM,
137
+ savedTokens: rawPct * billedRaw,
138
+ savedPct: pct,
139
+ };
140
+ }
141
+ export function estimateSavings(days = 30, options = {}, paths, onProgress) {
142
+ const since = Date.now() - days * 86_400_000;
143
+ const targets = paths ?? listSessions(10_000).filter((s) => s.path.includes(".claude") && s.modifiedAt.getTime() >= since).map((s) => s.path);
144
+ const sessions = [];
145
+ for (const [i, p] of targets.entries()) {
146
+ onProgress?.(i, targets.length);
147
+ try {
148
+ const s = replaySession(p, options);
149
+ if (s)
150
+ sessions.push(s);
151
+ }
152
+ catch {
153
+ /* one unreadable transcript must not sink the report */
154
+ }
155
+ }
156
+ const sum = (k) => sessions.reduce((n, s) => n + s[k], 0);
157
+ const billedUsd = sum("billedUsd"), savedUsd = sum("savedUsd");
158
+ const billedW = sum("billedWeighted");
159
+ const savedPct = billedW > 0 ? sum("savedWeighted") / billedW : 0;
160
+ return {
161
+ days,
162
+ sessions: sessions.sort((a, b) => b.savedUsd - a.savedUsd),
163
+ billedUsd,
164
+ savedUsd,
165
+ savedTokens: sum("savedTokens"),
166
+ billedTokens: sessions.reduce((n, s) => n + s.billedWeighted, 0),
167
+ savedPct,
168
+ worse: sessions.filter((s) => s.savedPct < -1e-9).length,
169
+ };
170
+ }
171
+ const tokens = (n) => (n >= 1e9 ? `${(n / 1e9).toFixed(1)}B` : formatTokens(n));
172
+ const usd = (n) => (n >= 100 ? `$${Math.round(n).toLocaleString("en-US")}` : `$${n.toFixed(2)}`);
173
+ export function renderSavings(r, autopilotOn = false) {
174
+ const lines = [];
175
+ if (r.sessions.length === 0) {
176
+ lines.push(`No Claude Code sessions with 10+ requests in the last ${r.days} days to replay.`);
177
+ lines.push("Use Claude Code for a while, then run this again: `context-doctor savings`.");
178
+ return lines.join("\n");
179
+ }
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)`);
189
+ 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");
196
+ }
197
+ return lines.join("\n");
198
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-doctor",
3
- "version": "0.20.1",
3
+ "version": "0.21.0",
4
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.",
5
5
  "keywords": [
6
6
  "agent",
@@ -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
  }