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