context-doctor 0.31.1 → 0.32.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 +25 -25
- package/dist/autoclear.d.ts +58 -7
- package/dist/autoclear.js +155 -30
- package/dist/autopilot.d.ts +23 -3
- package/dist/autopilot.js +100 -29
- package/dist/cache.d.ts +6 -2
- package/dist/cache.js +48 -23
- package/dist/cacheplan.d.ts +5 -3
- package/dist/cacheplan.js +46 -8
- package/dist/calibration.d.ts +7 -2
- package/dist/calibration.js +14 -3
- package/dist/ci.js +25 -5
- package/dist/cli.js +61 -26
- package/dist/coldresume.d.ts +32 -7
- package/dist/coldresume.js +148 -34
- package/dist/compactquality.js +34 -12
- package/dist/compactwindow.js +59 -34
- package/dist/config.d.ts +6 -0
- package/dist/config.js +4 -1
- package/dist/cursor.d.ts +9 -0
- package/dist/cursor.js +53 -22
- package/dist/dashboard.d.ts +10 -0
- package/dist/dashboard.js +27 -2
- package/dist/diff.js +10 -3
- package/dist/doctor.js +133 -18
- package/dist/embed.d.ts +9 -0
- package/dist/embed.js +40 -5
- package/dist/experiment.d.ts +1 -0
- package/dist/experiment.js +6 -3
- package/dist/hook.js +23 -4
- package/dist/impact.d.ts +32 -0
- package/dist/impact.js +45 -6
- package/dist/install.d.ts +34 -2
- package/dist/install.js +223 -30
- package/dist/ledger.js +9 -2
- package/dist/mcp.d.ts +1 -1
- package/dist/mcp.js +2 -2
- package/dist/mcpschema.d.ts +16 -1
- package/dist/mcpschema.js +95 -24
- package/dist/optimize.js +149 -64
- package/dist/overhead.js +57 -27
- package/dist/pack.d.ts +11 -0
- package/dist/pack.js +75 -12
- package/dist/parse.js +42 -18
- package/dist/pricing.d.ts +57 -5
- package/dist/pricing.js +94 -8
- package/dist/proxy.d.ts +13 -2
- package/dist/proxy.js +258 -43
- package/dist/reads.d.ts +1 -1
- package/dist/reads.js +61 -24
- package/dist/report.d.ts +10 -0
- package/dist/report.js +12 -4
- package/dist/savings.d.ts +20 -8
- package/dist/savings.js +79 -34
- package/dist/session.d.ts +37 -0
- package/dist/session.js +143 -6
- package/dist/split.js +6 -1
- package/dist/statusline.d.ts +3 -1
- package/dist/statusline.js +29 -17
- package/dist/subagentpay.d.ts +5 -2
- package/dist/subagentpay.js +50 -16
- package/dist/subagents.d.ts +7 -3
- package/dist/subagents.js +28 -8
- package/dist/tasks.d.ts +22 -7
- package/dist/tasks.js +47 -20
- package/dist/wrap.d.ts +6 -0
- package/dist/wrap.js +142 -6
- package/package.json +1 -1
package/dist/autopilot.d.ts
CHANGED
|
@@ -30,13 +30,26 @@ export declare function autopilotPaths(home?: string): AutopilotPaths;
|
|
|
30
30
|
export declare function proxyUrl(port: number): string;
|
|
31
31
|
/** The command the service runs. Absolute paths: services start with an empty PATH. */
|
|
32
32
|
export declare function serviceCommand(node: string, cli: string, port: number, paths: AutopilotPaths): string[];
|
|
33
|
+
/**
|
|
34
|
+
* The service restarts the proxy when it fails, not when it exits 0. The
|
|
35
|
+
* proxy exits 0 when another context-doctor proxy already serves its port;
|
|
36
|
+
* with KeepAlive true (Restart=always) launchd restarted it into the same
|
|
37
|
+
* EADDRINUSE every 5 s, 45 times in one real log.
|
|
38
|
+
*/
|
|
33
39
|
export declare function launchdPlist(args: string[], log: string): string;
|
|
34
40
|
export declare function systemdUnit(args: string[], log: string): string;
|
|
35
41
|
export declare function windowsTaskCommand(args: string[]): string;
|
|
42
|
+
/**
|
|
43
|
+
* Runs a service-manager command, true on success. Injectable so tests can
|
|
44
|
+
* use a throwaway HOME without ever reaching the real launchd, systemd or
|
|
45
|
+
* Task Scheduler: those name the service globally (a label, a unit name, a
|
|
46
|
+
* task name), not per HOME.
|
|
47
|
+
*/
|
|
48
|
+
export type ServiceExec = (cmd: string, args: string[]) => boolean;
|
|
36
49
|
/** Version of the package a CLI path belongs to (dist/cli.js -> ../package.json). */
|
|
37
50
|
export declare function installedVersion(cli: string): string | undefined;
|
|
38
51
|
/** Restart the installed background service onto the current build (launchd, systemd). */
|
|
39
|
-
export declare function restartService(): boolean;
|
|
52
|
+
export declare function restartService(home?: string, exec?: ServiceExec): boolean;
|
|
40
53
|
export declare function startDetached(args: string[], log: string): void;
|
|
41
54
|
export interface Health {
|
|
42
55
|
ok: boolean;
|
|
@@ -44,6 +57,13 @@ export interface Health {
|
|
|
44
57
|
version?: string;
|
|
45
58
|
}
|
|
46
59
|
export declare function health(port: number, timeoutMs?: number): Promise<Health>;
|
|
60
|
+
/**
|
|
61
|
+
* The ANTHROPIC_BASE_URL `off` should restore, decided at `on`: the one an
|
|
62
|
+
* earlier `on` recorded, else what settings.json holds now, but never our own
|
|
63
|
+
* proxy's. With the config damaged, settings.json already holds the proxy URL
|
|
64
|
+
* from the last `on`, and recording that made `off` put the proxy URL back.
|
|
65
|
+
*/
|
|
66
|
+
export declare function baseUrlToRestore(paths: AutopilotPaths, port: number, current: string | null): string | null;
|
|
47
67
|
/** Which Claude Code surfaces ran recent sessions: entrypoint -> count. */
|
|
48
68
|
export declare function recentSurfaces(days?: number, home?: string): Map<string, number>;
|
|
49
69
|
/** Lines explaining which recent sessions autopilot can reach, or nothing when all can. */
|
|
@@ -53,11 +73,11 @@ export declare function autopilotOn(port?: number, paths?: AutopilotPaths): Prom
|
|
|
53
73
|
ok: boolean;
|
|
54
74
|
lines: string[];
|
|
55
75
|
}>;
|
|
56
|
-
export declare function autopilotOff(paths?: AutopilotPaths): Promise<string[]>;
|
|
76
|
+
export declare function autopilotOff(paths?: AutopilotPaths, exec?: ServiceExec): Promise<string[]>;
|
|
57
77
|
export declare function autopilotPause(paused: boolean, paths?: AutopilotPaths): string;
|
|
58
78
|
export declare function autopilotStatus(paths?: AutopilotPaths): Promise<string[]>;
|
|
59
79
|
/**
|
|
60
80
|
* Called by the every-prompt hook: if autopilot is on and the proxy is down,
|
|
61
81
|
* start it before the prompt's request goes out. Returns quickly either way.
|
|
62
82
|
*/
|
|
63
|
-
export declare function ensureProxyUp(paths?: AutopilotPaths): Promise<void>;
|
|
83
|
+
export declare function ensureProxyUp(paths?: AutopilotPaths, exec?: ServiceExec): Promise<void>;
|
package/dist/autopilot.js
CHANGED
|
@@ -44,6 +44,12 @@ export function proxyUrl(port) {
|
|
|
44
44
|
export function serviceCommand(node, cli, port, paths) {
|
|
45
45
|
return [node, cli, "proxy", "--autopilot", "--port", String(port), "--autopilot-state", paths.statePath, "--autopilot-pause-file", paths.pauseFile];
|
|
46
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* The service restarts the proxy when it fails, not when it exits 0. The
|
|
49
|
+
* proxy exits 0 when another context-doctor proxy already serves its port;
|
|
50
|
+
* with KeepAlive true (Restart=always) launchd restarted it into the same
|
|
51
|
+
* EADDRINUSE every 5 s, 45 times in one real log.
|
|
52
|
+
*/
|
|
47
53
|
export function launchdPlist(args, log) {
|
|
48
54
|
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
49
55
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
@@ -56,7 +62,7 @@ export function launchdPlist(args, log) {
|
|
|
56
62
|
${args.map((a) => ` <string>${esc(a)}</string>`).join("\n")}
|
|
57
63
|
</array>
|
|
58
64
|
<key>RunAtLoad</key><true/>
|
|
59
|
-
<key>KeepAlive</key><
|
|
65
|
+
<key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>
|
|
60
66
|
<key>ThrottleInterval</key><integer>5</integer>
|
|
61
67
|
<key>StandardOutPath</key><string>${esc(log)}</string>
|
|
62
68
|
<key>StandardErrorPath</key><string>${esc(log)}</string>
|
|
@@ -71,7 +77,7 @@ Description=context-doctor proxy (autopilot)
|
|
|
71
77
|
|
|
72
78
|
[Service]
|
|
73
79
|
ExecStart=${args.map(q).join(" ")}
|
|
74
|
-
Restart=
|
|
80
|
+
Restart=on-failure
|
|
75
81
|
RestartSec=2
|
|
76
82
|
StandardOutput=append:${log}
|
|
77
83
|
StandardError=append:${log}
|
|
@@ -89,6 +95,10 @@ function servicePaths(home = homedir()) {
|
|
|
89
95
|
unit: join(home, ".config", "systemd", "user", "context-doctor-proxy.service"),
|
|
90
96
|
};
|
|
91
97
|
}
|
|
98
|
+
/** The home directory a set of autopilot paths belongs to (<home>/.claude/settings.json). */
|
|
99
|
+
function homeOf(paths) {
|
|
100
|
+
return dirname(dirname(paths.settings));
|
|
101
|
+
}
|
|
92
102
|
function uid() {
|
|
93
103
|
return String(process.getuid?.() ?? "");
|
|
94
104
|
}
|
|
@@ -105,9 +115,9 @@ function tryRun(cmd, args) {
|
|
|
105
115
|
}
|
|
106
116
|
}
|
|
107
117
|
/** Install and start the background service for this platform. */
|
|
108
|
-
function installService(args, log) {
|
|
118
|
+
function installService(args, log, home = homedir()) {
|
|
109
119
|
const os = platform();
|
|
110
|
-
const sp = servicePaths();
|
|
120
|
+
const sp = servicePaths(home);
|
|
111
121
|
if (os === "darwin") {
|
|
112
122
|
mkdirSync(dirname(sp.plist), { recursive: true });
|
|
113
123
|
writeFileSync(sp.plist, launchdPlist(args, log));
|
|
@@ -133,24 +143,51 @@ function installService(args, log) {
|
|
|
133
143
|
startDetached(args, log);
|
|
134
144
|
return "detached process";
|
|
135
145
|
}
|
|
136
|
-
|
|
146
|
+
/**
|
|
147
|
+
* Stop and remove the service, but only one this HOME installed: launchd,
|
|
148
|
+
* systemd and Task Scheduler name it globally, so `off` run against another
|
|
149
|
+
* HOME (a test's throwaway one) would otherwise stop the real service. The
|
|
150
|
+
* plist or unit file is the proof on macOS and Linux; Windows has no file,
|
|
151
|
+
* so the caller says whether this HOME's autopilot config exists.
|
|
152
|
+
*/
|
|
153
|
+
function removeService(home, exec, windowsTaskOwned) {
|
|
137
154
|
const os = platform();
|
|
138
|
-
const sp = servicePaths();
|
|
155
|
+
const sp = servicePaths(home);
|
|
139
156
|
if (os === "darwin") {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
157
|
+
if (!existsSync(sp.plist))
|
|
158
|
+
return;
|
|
159
|
+
exec("launchctl", ["bootout", `gui/${uid()}`, sp.plist]);
|
|
160
|
+
rmSync(sp.plist);
|
|
143
161
|
}
|
|
144
162
|
else if (os === "linux") {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
163
|
+
if (!existsSync(sp.unit))
|
|
164
|
+
return;
|
|
165
|
+
exec("systemctl", ["--user", "disable", "--now", "context-doctor-proxy.service"]);
|
|
166
|
+
rmSync(sp.unit);
|
|
167
|
+
exec("systemctl", ["--user", "daemon-reload"]);
|
|
149
168
|
}
|
|
150
169
|
else if (os === "win32") {
|
|
151
|
-
|
|
170
|
+
if (windowsTaskOwned)
|
|
171
|
+
exec("schtasks", ["/Delete", "/F", "/TN", "context-doctor-proxy"]);
|
|
152
172
|
}
|
|
153
173
|
}
|
|
174
|
+
/**
|
|
175
|
+
* Start the installed service through its manager, when this HOME has one.
|
|
176
|
+
* False when there is none or the manager refused, so the caller can start
|
|
177
|
+
* the proxy itself. kickstart without -k (and systemctl start) leave a
|
|
178
|
+
* running proxy alone: a /health that was merely slow must not start a
|
|
179
|
+
* second copy next to the managed one.
|
|
180
|
+
*/
|
|
181
|
+
function startService(home, exec) {
|
|
182
|
+
const os = platform();
|
|
183
|
+
const sp = servicePaths(home);
|
|
184
|
+
if (os === "darwin" && existsSync(sp.plist)) {
|
|
185
|
+
return exec("launchctl", ["kickstart", `gui/${uid()}/${LABEL}`]) || exec("launchctl", ["bootstrap", `gui/${uid()}`, sp.plist]);
|
|
186
|
+
}
|
|
187
|
+
if (os === "linux" && existsSync(sp.unit))
|
|
188
|
+
return exec("systemctl", ["--user", "start", "context-doctor-proxy.service"]);
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
154
191
|
/** Version of the package a CLI path belongs to (dist/cli.js -> ../package.json). */
|
|
155
192
|
export function installedVersion(cli) {
|
|
156
193
|
try {
|
|
@@ -174,13 +211,13 @@ function restartDue(paths) {
|
|
|
174
211
|
}
|
|
175
212
|
}
|
|
176
213
|
/** Restart the installed background service onto the current build (launchd, systemd). */
|
|
177
|
-
export function restartService() {
|
|
214
|
+
export function restartService(home = homedir(), exec = tryRun) {
|
|
178
215
|
const os = platform();
|
|
179
|
-
const sp = servicePaths();
|
|
216
|
+
const sp = servicePaths(home);
|
|
180
217
|
if (os === "darwin" && existsSync(sp.plist))
|
|
181
|
-
return
|
|
218
|
+
return exec("launchctl", ["kickstart", "-k", `gui/${uid()}/${LABEL}`]);
|
|
182
219
|
if (os === "linux" && existsSync(sp.unit))
|
|
183
|
-
return
|
|
220
|
+
return exec("systemctl", ["--user", "restart", "context-doctor-proxy.service"]);
|
|
184
221
|
return false;
|
|
185
222
|
}
|
|
186
223
|
export function startDetached(args, log) {
|
|
@@ -210,6 +247,29 @@ async function waitHealthy(port, ms) {
|
|
|
210
247
|
await new Promise((r) => setTimeout(r, 250));
|
|
211
248
|
}
|
|
212
249
|
}
|
|
250
|
+
/**
|
|
251
|
+
* Whether a base URL is this autopilot's proxy: the configured port's URL, or,
|
|
252
|
+
* when the config exists but cannot be read (so the port is unknown), any URL
|
|
253
|
+
* of the exact shape autopilot writes, http://127.0.0.1:<port>.
|
|
254
|
+
*/
|
|
255
|
+
function isOurProxyUrl(url, port, paths) {
|
|
256
|
+
if (port !== undefined && url === proxyUrl(port))
|
|
257
|
+
return true;
|
|
258
|
+
const damaged = existsSync(paths.config) && readConfig(paths) === undefined;
|
|
259
|
+
return damaged && /^http:\/\/127\.0\.0\.1:\d+$/.test(url);
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* The ANTHROPIC_BASE_URL `off` should restore, decided at `on`: the one an
|
|
263
|
+
* earlier `on` recorded, else what settings.json holds now, but never our own
|
|
264
|
+
* proxy's. With the config damaged, settings.json already holds the proxy URL
|
|
265
|
+
* from the last `on`, and recording that made `off` put the proxy URL back.
|
|
266
|
+
*/
|
|
267
|
+
export function baseUrlToRestore(paths, port, current) {
|
|
268
|
+
const prev = readConfig(paths);
|
|
269
|
+
if (prev?.previousBaseUrl !== undefined)
|
|
270
|
+
return prev.previousBaseUrl;
|
|
271
|
+
return current && isOurProxyUrl(current, port, paths) ? null : current;
|
|
272
|
+
}
|
|
213
273
|
function readConfig(paths) {
|
|
214
274
|
// readJson returns {} for a missing file; only a file with a port is a config.
|
|
215
275
|
if (!existsSync(paths.config))
|
|
@@ -322,17 +382,16 @@ export async function autopilotOn(port = DEFAULT_AUTOPILOT_PORT, paths = autopil
|
|
|
322
382
|
}
|
|
323
383
|
}
|
|
324
384
|
const args = serviceCommand(process.execPath, cli, port, paths);
|
|
325
|
-
const how = installService(args, paths.log);
|
|
385
|
+
const how = installService(args, paths.log, homeOf(paths));
|
|
326
386
|
const h = await waitHealthy(port, 15_000);
|
|
327
387
|
if (!h.ok || !h.autopilot) {
|
|
328
|
-
removeService();
|
|
388
|
+
removeService(homeOf(paths), tryRun, true);
|
|
329
389
|
return { ok: false, lines: [`✗ The proxy did not come up on port ${port} (log: ${paths.log}). Nothing was changed in Claude Code's settings.`] };
|
|
330
390
|
}
|
|
331
391
|
lines.push(`✓ Proxy running with autopilot on ${proxyUrl(port)} (${how})`);
|
|
332
392
|
const settings = readJson(paths.settings);
|
|
333
393
|
const env = (settings.env ??= {});
|
|
334
|
-
const
|
|
335
|
-
const previousBaseUrl = prev?.previousBaseUrl !== undefined ? prev.previousBaseUrl : (env.ANTHROPIC_BASE_URL ?? null);
|
|
394
|
+
const previousBaseUrl = baseUrlToRestore(paths, port, env.ANTHROPIC_BASE_URL ?? null);
|
|
336
395
|
if (previousBaseUrl && previousBaseUrl !== proxyUrl(port)) {
|
|
337
396
|
lines.push(`! settings.json already routed Claude Code to ${previousBaseUrl}; autopilot forwards to api.anthropic.com instead. \`autopilot off\` restores it.`);
|
|
338
397
|
}
|
|
@@ -348,14 +407,19 @@ export async function autopilotOn(port = DEFAULT_AUTOPILOT_PORT, paths = autopil
|
|
|
348
407
|
lines.push(` GPT apps on your own OpenAI key get the same: export OPENAI_BASE_URL=${proxyUrl(port)}/v1`);
|
|
349
408
|
return { ok: true, lines };
|
|
350
409
|
}
|
|
351
|
-
export async function autopilotOff(paths = autopilotPaths()) {
|
|
410
|
+
export async function autopilotOff(paths = autopilotPaths(), exec = tryRun) {
|
|
352
411
|
const lines = [];
|
|
353
412
|
const cfg = readConfig(paths);
|
|
354
413
|
const settings = readJson(paths.settings);
|
|
355
414
|
const env = settings.env;
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
415
|
+
// With the config damaged its port is unknown; the proxy URL in settings
|
|
416
|
+
// must still go, or every new Claude Code session points at a proxy whose
|
|
417
|
+
// service is being removed.
|
|
418
|
+
const ours = env?.ANTHROPIC_BASE_URL ? isOurProxyUrl(env.ANTHROPIC_BASE_URL, cfg?.port, paths) : false;
|
|
419
|
+
if (env && ours) {
|
|
420
|
+
const restore = cfg?.previousBaseUrl && !isOurProxyUrl(cfg.previousBaseUrl, cfg.port, paths) ? cfg.previousBaseUrl : undefined;
|
|
421
|
+
if (restore)
|
|
422
|
+
env.ANTHROPIC_BASE_URL = restore;
|
|
359
423
|
else
|
|
360
424
|
delete env.ANTHROPIC_BASE_URL;
|
|
361
425
|
if (Object.keys(env).length === 0)
|
|
@@ -363,7 +427,7 @@ export async function autopilotOff(paths = autopilotPaths()) {
|
|
|
363
427
|
writeJsonWithBackup(paths.settings, settings);
|
|
364
428
|
lines.push("✓ Claude Code no longer routed through the proxy (new sessions)");
|
|
365
429
|
}
|
|
366
|
-
removeService();
|
|
430
|
+
removeService(homeOf(paths), exec, Boolean(cfg) || existsSync(paths.config));
|
|
367
431
|
if (existsSync(paths.config))
|
|
368
432
|
rmSync(paths.config);
|
|
369
433
|
if (existsSync(paths.pauseFile))
|
|
@@ -421,7 +485,7 @@ export async function autopilotStatus(paths = autopilotPaths()) {
|
|
|
421
485
|
* Called by the every-prompt hook: if autopilot is on and the proxy is down,
|
|
422
486
|
* start it before the prompt's request goes out. Returns quickly either way.
|
|
423
487
|
*/
|
|
424
|
-
export async function ensureProxyUp(paths = autopilotPaths()) {
|
|
488
|
+
export async function ensureProxyUp(paths = autopilotPaths(), exec = tryRun) {
|
|
425
489
|
const cfg = readConfig(paths);
|
|
426
490
|
if (!cfg)
|
|
427
491
|
return;
|
|
@@ -433,7 +497,14 @@ export async function ensureProxyUp(paths = autopilotPaths()) {
|
|
|
433
497
|
// once an hour so an install that cannot fix it never loops.
|
|
434
498
|
const installed = typeof cfg.cli === "string" ? installedVersion(cfg.cli) : undefined;
|
|
435
499
|
if (installed && h.version && h.version !== installed && restartDue(paths))
|
|
436
|
-
restartService();
|
|
500
|
+
restartService(homeOf(paths), exec);
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
// A service manager owns the proxy here: ask it. A detached copy started
|
|
504
|
+
// because /health missed 300 ms (a busy proxy, not a dead one) took the
|
|
505
|
+
// port from the managed job, which then crash-looped on EADDRINUSE.
|
|
506
|
+
if (startService(homeOf(paths), exec)) {
|
|
507
|
+
await waitHealthy(cfg.port, 2500);
|
|
437
508
|
return;
|
|
438
509
|
}
|
|
439
510
|
if (typeof cfg.node !== "string" || typeof cfg.cli !== "string" || !existsSync(cfg.node) || !existsSync(cfg.cli))
|
package/dist/cache.d.ts
CHANGED
|
@@ -4,14 +4,18 @@
|
|
|
4
4
|
* Transcripts record what the API actually charged per request — including
|
|
5
5
|
* cache reads and cache writes — so cache behaviour can be reported as fact
|
|
6
6
|
* rather than estimated. This is usually the largest single lever on cost:
|
|
7
|
-
* a cached read bills at
|
|
8
|
-
*
|
|
7
|
+
* a cached read bills at a tenth of input or less, while a cache write bills
|
|
8
|
+
* at 1.25x (2x at the 1-hour TTL Claude Code's main thread uses), so a
|
|
9
|
+
* session that keeps invalidating its prefix pays more than one with no
|
|
9
10
|
* caching at all.
|
|
10
11
|
*/
|
|
11
12
|
export interface CacheUsage {
|
|
13
|
+
/** API requests: each reply once, however many transcript rows it was written as. */
|
|
12
14
|
requests: number;
|
|
13
15
|
cacheReadTokens: number;
|
|
14
16
|
cacheWriteTokens: number;
|
|
17
|
+
/** The part of cacheWriteTokens written at the 1-hour TTL (2x input instead of 1.25x). */
|
|
18
|
+
cacheWrite1hTokens: number;
|
|
15
19
|
uncachedTokens: number;
|
|
16
20
|
/** Share of input tokens served from cache (0-1). */
|
|
17
21
|
hitRate: number;
|
package/dist/cache.js
CHANGED
|
@@ -4,14 +4,15 @@
|
|
|
4
4
|
* Transcripts record what the API actually charged per request — including
|
|
5
5
|
* cache reads and cache writes — so cache behaviour can be reported as fact
|
|
6
6
|
* rather than estimated. This is usually the largest single lever on cost:
|
|
7
|
-
* a cached read bills at
|
|
8
|
-
*
|
|
7
|
+
* a cached read bills at a tenth of input or less, while a cache write bills
|
|
8
|
+
* at 1.25x (2x at the 1-hour TTL Claude Code's main thread uses), so a
|
|
9
|
+
* session that keeps invalidating its prefix pays more than one with no
|
|
9
10
|
* caching at all.
|
|
10
11
|
*/
|
|
11
12
|
import { readFileSync } from "node:fs";
|
|
12
|
-
import { pricingFor } from "./pricing.js";
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
import { cacheWriteSplit, pricingFor, usageCost } from "./pricing.js";
|
|
14
|
+
import { replyCounter } from "./session.js";
|
|
15
|
+
import { realModel } from "./tokens.js";
|
|
15
16
|
export function analyzeCacheUsage(transcriptPath) {
|
|
16
17
|
let raw;
|
|
17
18
|
try {
|
|
@@ -23,8 +24,17 @@ export function analyzeCacheUsage(transcriptPath) {
|
|
|
23
24
|
let requests = 0;
|
|
24
25
|
let cacheReadTokens = 0;
|
|
25
26
|
let cacheWriteTokens = 0;
|
|
27
|
+
let cacheWrite1hTokens = 0;
|
|
26
28
|
let uncachedTokens = 0;
|
|
27
29
|
let model;
|
|
30
|
+
let priced = 0;
|
|
31
|
+
let paidUsd = 0;
|
|
32
|
+
let uncachedUsd = 0;
|
|
33
|
+
let writeUsd = 0;
|
|
34
|
+
// Claude Code writes ~2.2 rows per API reply, each carrying the reply's
|
|
35
|
+
// full usage: counting rows reported 1201 requests for 541 replies on a
|
|
36
|
+
// real session and more than doubled every total.
|
|
37
|
+
const sighting = replyCounter();
|
|
28
38
|
for (const line of raw.split("\n")) {
|
|
29
39
|
if (!line.trim())
|
|
30
40
|
continue;
|
|
@@ -40,12 +50,29 @@ export function analyzeCacheUsage(transcriptPath) {
|
|
|
40
50
|
const usage = entry.message.usage;
|
|
41
51
|
if (!usage)
|
|
42
52
|
continue;
|
|
53
|
+
// "<synthetic>" rows are messages Claude Code writes itself, not requests.
|
|
54
|
+
if (typeof entry.message.model === "string" && !realModel(entry.message.model))
|
|
55
|
+
continue;
|
|
56
|
+
if (sighting(entry) !== "new")
|
|
57
|
+
continue;
|
|
58
|
+
model = realModel(entry.message.model) ?? model;
|
|
43
59
|
requests++;
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
60
|
+
const read = usage.cache_read_input_tokens ?? 0;
|
|
61
|
+
const write = usage.cache_creation_input_tokens ?? 0;
|
|
62
|
+
const fresh = usage.input_tokens ?? 0;
|
|
63
|
+
cacheReadTokens += read;
|
|
64
|
+
cacheWriteTokens += write;
|
|
65
|
+
cacheWrite1hTokens += cacheWriteSplit(usage).oneHour;
|
|
66
|
+
uncachedTokens += fresh;
|
|
67
|
+
// Each request at its own model's price: a session can switch models.
|
|
68
|
+
const pricing = pricingFor(model);
|
|
69
|
+
if (pricing) {
|
|
70
|
+
const cost = usageCost(usage, pricing);
|
|
71
|
+
priced++;
|
|
72
|
+
paidUsd += cost.promptUsd;
|
|
73
|
+
writeUsd += cost.writeUsd;
|
|
74
|
+
uncachedUsd += ((fresh + read + write) * pricing.inputPerM) / 1_000_000;
|
|
75
|
+
}
|
|
49
76
|
}
|
|
50
77
|
if (requests === 0)
|
|
51
78
|
return null;
|
|
@@ -54,19 +81,15 @@ export function analyzeCacheUsage(transcriptPath) {
|
|
|
54
81
|
requests,
|
|
55
82
|
cacheReadTokens,
|
|
56
83
|
cacheWriteTokens,
|
|
84
|
+
cacheWrite1hTokens,
|
|
57
85
|
uncachedTokens,
|
|
58
86
|
hitRate: totalInput > 0 ? cacheReadTokens / totalInput : 0,
|
|
59
87
|
model,
|
|
60
88
|
};
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const writeUsd = perM(cacheWriteTokens, pricing.inputPerM * CACHE_WRITE_MULTIPLIER);
|
|
66
|
-
const freshUsd = perM(uncachedTokens, pricing.inputPerM);
|
|
67
|
-
usage.paidUsd = readUsd + writeUsd + freshUsd;
|
|
68
|
-
usage.uncachedUsd = perM(totalInput, pricing.inputPerM);
|
|
69
|
-
usage.savedUsd = usage.uncachedUsd - usage.paidUsd;
|
|
89
|
+
if (priced > 0) {
|
|
90
|
+
usage.paidUsd = paidUsd;
|
|
91
|
+
usage.uncachedUsd = uncachedUsd;
|
|
92
|
+
usage.savedUsd = uncachedUsd - paidUsd;
|
|
70
93
|
usage.writeUsd = writeUsd;
|
|
71
94
|
}
|
|
72
95
|
return usage;
|
|
@@ -80,18 +103,20 @@ export function renderCacheReport(usage) {
|
|
|
80
103
|
const fmt = (n) => (n >= 1 ? `$${n.toFixed(2)}` : `$${n.toFixed(3)}`);
|
|
81
104
|
const tok = (n) => n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : String(n);
|
|
82
105
|
lines.push(`Prompt cache: ${pct}% of input served from cache across ${usage.requests} requests`);
|
|
83
|
-
|
|
106
|
+
const oneHour = usage.cacheWrite1hTokens > 0 ? ` (${tok(usage.cacheWrite1hTokens)} at the 1-hour TTL)` : "";
|
|
107
|
+
lines.push(` read ${tok(usage.cacheReadTokens)} · written ${tok(usage.cacheWriteTokens)}${oneHour} · uncached ${tok(usage.uncachedTokens)}`);
|
|
84
108
|
if (usage.paidUsd !== undefined && usage.uncachedUsd !== undefined && usage.savedUsd !== undefined) {
|
|
85
109
|
lines.push(` input cost ${fmt(usage.paidUsd)} — caching saved ${fmt(usage.savedUsd)} against ${fmt(usage.uncachedUsd)} uncached (list prices)`);
|
|
86
110
|
}
|
|
87
|
-
// A cache write costs 1.
|
|
88
|
-
// rival reads mean the prefix keeps changing and caching is
|
|
111
|
+
// A cache write costs 1.25-2x input while a read costs 0.1x or less, so
|
|
112
|
+
// writes that rival reads mean the prefix keeps changing and caching is
|
|
113
|
+
// losing money.
|
|
89
114
|
const writeShare = usage.cacheReadTokens > 0 ? usage.cacheWriteTokens / usage.cacheReadTokens : Infinity;
|
|
90
115
|
if (usage.hitRate < 0.5 && usage.requests > 5) {
|
|
91
116
|
lines.push(" ⚠ Low hit rate: something early in the prompt changes between requests. Keep the system prompt, tool list and reference docs byte-stable and put volatile content last.");
|
|
92
117
|
}
|
|
93
118
|
else if (writeShare > 0.5) {
|
|
94
|
-
lines.push(" ⚠ Cache churn: writes are large relative to reads, and a write costs 1.25x input against 0.1x for a read. The cached prefix is being rebuilt often.");
|
|
119
|
+
lines.push(" ⚠ Cache churn: writes are large relative to reads, and a write costs 1.25x input (2x at the 1-hour TTL) against 0.1x or less for a read. The cached prefix is being rebuilt often.");
|
|
95
120
|
}
|
|
96
121
|
return lines.join("\n");
|
|
97
122
|
}
|
package/dist/cacheplan.d.ts
CHANGED
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
*
|
|
5
5
|
* A request is cached as a prefix in a fixed order: tools, then system, then
|
|
6
6
|
* messages. A breakpoint caches everything up to and including the block it
|
|
7
|
-
* sits on, a prefix under the model's minimum (
|
|
8
|
-
* is not cached at all, a request may carry at most 4
|
|
9
|
-
* cache looks back at most 20 blocks from a breakpoint
|
|
7
|
+
* sits on, a prefix under the model's minimum (512 to 4,096 tokens, see
|
|
8
|
+
* minCacheableTokens) is not cached at all, a request may carry at most 4
|
|
9
|
+
* breakpoints, and the cache looks back at most 20 blocks from a breakpoint
|
|
10
|
+
* for an earlier hit.
|
|
10
11
|
* The planner places breakpoints at the ends of the stable parts (last tool,
|
|
11
12
|
* last system block) and on the last block of the conversation, adds a
|
|
12
13
|
* middle anchor when the conversation is longer than the 20-block lookback,
|
|
@@ -38,6 +39,7 @@ export interface CachePlan {
|
|
|
38
39
|
usdWith?: number;
|
|
39
40
|
notes: string[];
|
|
40
41
|
}
|
|
42
|
+
export declare function minCacheableTokens(model?: string): number;
|
|
41
43
|
export declare function planCache(request: unknown, calls?: number, modelOverride?: string): {
|
|
42
44
|
plan: CachePlan;
|
|
43
45
|
apply: () => Record<string, any>;
|
package/dist/cacheplan.js
CHANGED
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
*
|
|
5
5
|
* A request is cached as a prefix in a fixed order: tools, then system, then
|
|
6
6
|
* messages. A breakpoint caches everything up to and including the block it
|
|
7
|
-
* sits on, a prefix under the model's minimum (
|
|
8
|
-
* is not cached at all, a request may carry at most 4
|
|
9
|
-
* cache looks back at most 20 blocks from a breakpoint
|
|
7
|
+
* sits on, a prefix under the model's minimum (512 to 4,096 tokens, see
|
|
8
|
+
* minCacheableTokens) is not cached at all, a request may carry at most 4
|
|
9
|
+
* breakpoints, and the cache looks back at most 20 blocks from a breakpoint
|
|
10
|
+
* for an earlier hit.
|
|
10
11
|
* The planner places breakpoints at the ends of the stable parts (last tool,
|
|
11
12
|
* last system block) and on the last block of the conversation, adds a
|
|
12
13
|
* middle anchor when the conversation is longer than the 20-block lookback,
|
|
@@ -21,11 +22,45 @@ import { estimateTokens, formatTokens } from "./tokens.js";
|
|
|
21
22
|
import { formatUsd, pricingFor } from "./pricing.js";
|
|
22
23
|
const LOOKBACK_BLOCKS = 20;
|
|
23
24
|
const MAX_BREAKPOINTS = 4;
|
|
24
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Smallest prefix Anthropic caches, per model (prompt-caching docs, Oct 2026).
|
|
27
|
+
* Not monotonic across generations: the 5.x models cache from 512 tokens,
|
|
28
|
+
* Opus 4.6/4.5 and Haiku 4.5 only from 4,096, so one number for every model
|
|
29
|
+
* (the old "1,024, 2,048 on Haiku") advised breakpoints that could never hit
|
|
30
|
+
* on some models and called cacheable prompts too small on others.
|
|
31
|
+
*/
|
|
32
|
+
const CACHE_MINIMUMS = {
|
|
33
|
+
"opus 5.5": 512, "opus 5.0": 512, "fable 5.0": 512, "fable 5.1": 512,
|
|
34
|
+
"mythos 5.0": 512, "mythos 5.1": 512, "sonnet 5.5": 512, "haiku 5.5": 512,
|
|
35
|
+
"opus 4.8": 1024, "sonnet 5.0": 1024, "sonnet 4.6": 1024, "sonnet 4.5": 1024,
|
|
36
|
+
"opus 4.1": 1024, "opus 4.0": 1024, "sonnet 4.0": 1024,
|
|
37
|
+
"opus 4.7": 2048, "haiku 3.5": 2048, "mythos preview": 2048,
|
|
38
|
+
"opus 4.6": 4096, "opus 4.5": 4096, "haiku 4.5": 4096,
|
|
39
|
+
};
|
|
40
|
+
/** For a model the table does not know: the most common minimum across generations. */
|
|
41
|
+
const DEFAULT_CACHE_MINIMUM = 1024;
|
|
42
|
+
export function minCacheableTokens(model) {
|
|
43
|
+
const m = (model ?? "").toLowerCase();
|
|
44
|
+
if (/mythos-preview/.test(m))
|
|
45
|
+
return CACHE_MINIMUMS["mythos preview"];
|
|
46
|
+
// Current ids: claude-<family>-<major>[-<minor>], optionally dated (claude-opus-4-20250514 is
|
|
47
|
+
// Opus 4.0, so a minor is one or two digits, never the 8-digit date) or with a platform
|
|
48
|
+
// prefix/suffix (anthropic.claude-...-v1:0, claude-...@20251101).
|
|
49
|
+
let key;
|
|
50
|
+
const cur = /(opus|sonnet|haiku|fable|mythos)-(\d+)(?:[-.](\d{1,2}))?(?!\d)/.exec(m);
|
|
51
|
+
if (cur)
|
|
52
|
+
key = `${cur[1]} ${cur[2]}.${cur[3] ?? "0"}`;
|
|
53
|
+
// Claude 3 ids put the version first: claude-3-5-haiku-20241022.
|
|
54
|
+
const old = /claude-(\d)(?:-(\d))?-(opus|sonnet|haiku)/.exec(m);
|
|
55
|
+
if (old)
|
|
56
|
+
key = `${old[3]} ${old[1]}.${old[2] ?? "0"}`;
|
|
57
|
+
return (key && CACHE_MINIMUMS[key]) || DEFAULT_CACHE_MINIMUM;
|
|
58
|
+
}
|
|
59
|
+
function blocksOf(req, model, longTtl = false) {
|
|
25
60
|
const out = [];
|
|
26
61
|
const mark = (obj) => (on) => {
|
|
27
62
|
if (on)
|
|
28
|
-
obj.cache_control = obj.cache_control ?? { type: "ephemeral" };
|
|
63
|
+
obj.cache_control = obj.cache_control ?? (longTtl ? { type: "ephemeral", ttl: "1h" } : { type: "ephemeral" });
|
|
29
64
|
else
|
|
30
65
|
delete obj.cache_control;
|
|
31
66
|
};
|
|
@@ -87,9 +122,13 @@ export function planCache(request, calls = 10, modelOverride) {
|
|
|
87
122
|
apply: () => req,
|
|
88
123
|
};
|
|
89
124
|
}
|
|
90
|
-
|
|
125
|
+
// Cache writes cost 1.25x input at the 5-minute TTL and 2x at one hour. A
|
|
126
|
+
// request that already uses the hour keeps it on the planned breakpoints, so
|
|
127
|
+
// the price below is the price of the request apply() returns.
|
|
128
|
+
const longTtl = /"ttl"\s*:\s*"1h"/.test(JSON.stringify(req));
|
|
129
|
+
const blocks = blocksOf(req, model, longTtl);
|
|
91
130
|
const total = blocks.reduce((s, b) => s + b.tokens, 0);
|
|
92
|
-
const minCacheable =
|
|
131
|
+
const minCacheable = minCacheableTokens(model);
|
|
93
132
|
const prefixAt = (i) => blocks.slice(0, i + 1).reduce((s, b) => s + b.tokens, 0);
|
|
94
133
|
const lastIndex = (section) => blocks.map((b) => b.section).lastIndexOf(section);
|
|
95
134
|
const picks = [];
|
|
@@ -118,7 +157,6 @@ export function planCache(request, calls = 10, modelOverride) {
|
|
|
118
157
|
notes.push(`The whole request is ~${formatTokens(total)} tokens, under the ${formatTokens(minCacheable)} minimum ${model ?? "this model"} caches: caching cannot help until it grows.`);
|
|
119
158
|
const cachedTokens = picks.length ? prefixAt(picks[picks.length - 1].i) : 0;
|
|
120
159
|
const price = pricingFor(model);
|
|
121
|
-
const longTtl = /"ttl"\s*:\s*"1h"/.test(JSON.stringify(req));
|
|
122
160
|
const writeMult = longTtl ? 2 : 1.25;
|
|
123
161
|
let usdWithout, usdWith;
|
|
124
162
|
if (price) {
|
package/dist/calibration.d.ts
CHANGED
|
@@ -30,7 +30,12 @@ export interface Calibration {
|
|
|
30
30
|
export declare function calibrationPath(): string;
|
|
31
31
|
/** "claude", "gpt", "gemini", … — coarse on purpose; tokenizers are per vendor. */
|
|
32
32
|
export declare function modelFamily(model?: string): string;
|
|
33
|
-
/**
|
|
34
|
-
|
|
33
|
+
/**
|
|
34
|
+
* Remember one exact-vs-heuristic observation. Best-effort; never throws.
|
|
35
|
+
* Returns whether it was saved, so the caller only claims "remembered" when
|
|
36
|
+
* it was: a rejected sample, the off switch, or a failed write all leave the
|
|
37
|
+
* file as it was.
|
|
38
|
+
*/
|
|
39
|
+
export declare function recordCalibration(model: string | undefined, exactTokens: number, heuristicTokens: number): boolean;
|
|
35
40
|
/** The factor to apply for a model, or 1 with zero samples when there is none. */
|
|
36
41
|
export declare function calibrationFor(model?: string): Calibration;
|
package/dist/calibration.js
CHANGED
|
@@ -50,13 +50,22 @@ function readAll() {
|
|
|
50
50
|
return {};
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* Remember one exact-vs-heuristic observation. Best-effort; never throws.
|
|
55
|
+
* Returns whether it was saved, so the caller only claims "remembered" when
|
|
56
|
+
* it was: a rejected sample, the off switch, or a failed write all leave the
|
|
57
|
+
* file as it was.
|
|
58
|
+
*/
|
|
54
59
|
export function recordCalibration(model, exactTokens, heuristicTokens) {
|
|
60
|
+
// The off switch means off: not applied AND not learned, or a user who set
|
|
61
|
+
// it would find a calibration waiting the day they unset it.
|
|
62
|
+
if (process.env.CONTEXT_DOCTOR_NO_CALIBRATION)
|
|
63
|
+
return false;
|
|
55
64
|
if (!(exactTokens > 0) || !(heuristicTokens > 0))
|
|
56
|
-
return;
|
|
65
|
+
return false;
|
|
57
66
|
const ratio = exactTokens / heuristicTokens;
|
|
58
67
|
if (ratio < MIN_FACTOR || ratio > MAX_FACTOR)
|
|
59
|
-
return; // a broken sample must not poison the file
|
|
68
|
+
return false; // a broken sample must not poison the file
|
|
60
69
|
try {
|
|
61
70
|
const all = readAll();
|
|
62
71
|
const key = modelFamily(model);
|
|
@@ -70,9 +79,11 @@ export function recordCalibration(model, exactTokens, heuristicTokens) {
|
|
|
70
79
|
};
|
|
71
80
|
mkdirSync(dirname(calibrationPath()), { recursive: true });
|
|
72
81
|
writeFileSync(calibrationPath(), JSON.stringify(all, null, 2));
|
|
82
|
+
return true;
|
|
73
83
|
}
|
|
74
84
|
catch {
|
|
75
85
|
/* calibration is a refinement; failing to save it must not fail the command */
|
|
86
|
+
return false;
|
|
76
87
|
}
|
|
77
88
|
}
|
|
78
89
|
/** The factor to apply for a model, or 1 with zero samples when there is none. */
|