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.
Files changed (68) hide show
  1. package/README.md +25 -25
  2. package/dist/autoclear.d.ts +58 -7
  3. package/dist/autoclear.js +155 -30
  4. package/dist/autopilot.d.ts +23 -3
  5. package/dist/autopilot.js +100 -29
  6. package/dist/cache.d.ts +6 -2
  7. package/dist/cache.js +48 -23
  8. package/dist/cacheplan.d.ts +5 -3
  9. package/dist/cacheplan.js +46 -8
  10. package/dist/calibration.d.ts +7 -2
  11. package/dist/calibration.js +14 -3
  12. package/dist/ci.js +25 -5
  13. package/dist/cli.js +61 -26
  14. package/dist/coldresume.d.ts +32 -7
  15. package/dist/coldresume.js +148 -34
  16. package/dist/compactquality.js +34 -12
  17. package/dist/compactwindow.js +59 -34
  18. package/dist/config.d.ts +6 -0
  19. package/dist/config.js +4 -1
  20. package/dist/cursor.d.ts +9 -0
  21. package/dist/cursor.js +53 -22
  22. package/dist/dashboard.d.ts +10 -0
  23. package/dist/dashboard.js +27 -2
  24. package/dist/diff.js +10 -3
  25. package/dist/doctor.js +133 -18
  26. package/dist/embed.d.ts +9 -0
  27. package/dist/embed.js +40 -5
  28. package/dist/experiment.d.ts +1 -0
  29. package/dist/experiment.js +6 -3
  30. package/dist/hook.js +23 -4
  31. package/dist/impact.d.ts +32 -0
  32. package/dist/impact.js +45 -6
  33. package/dist/install.d.ts +34 -2
  34. package/dist/install.js +223 -30
  35. package/dist/ledger.js +9 -2
  36. package/dist/mcp.d.ts +1 -1
  37. package/dist/mcp.js +2 -2
  38. package/dist/mcpschema.d.ts +16 -1
  39. package/dist/mcpschema.js +95 -24
  40. package/dist/optimize.js +149 -64
  41. package/dist/overhead.js +57 -27
  42. package/dist/pack.d.ts +11 -0
  43. package/dist/pack.js +75 -12
  44. package/dist/parse.js +42 -18
  45. package/dist/pricing.d.ts +57 -5
  46. package/dist/pricing.js +94 -8
  47. package/dist/proxy.d.ts +13 -2
  48. package/dist/proxy.js +258 -43
  49. package/dist/reads.d.ts +1 -1
  50. package/dist/reads.js +61 -24
  51. package/dist/report.d.ts +10 -0
  52. package/dist/report.js +12 -4
  53. package/dist/savings.d.ts +20 -8
  54. package/dist/savings.js +79 -34
  55. package/dist/session.d.ts +37 -0
  56. package/dist/session.js +143 -6
  57. package/dist/split.js +6 -1
  58. package/dist/statusline.d.ts +3 -1
  59. package/dist/statusline.js +29 -17
  60. package/dist/subagentpay.d.ts +5 -2
  61. package/dist/subagentpay.js +50 -16
  62. package/dist/subagents.d.ts +7 -3
  63. package/dist/subagents.js +28 -8
  64. package/dist/tasks.d.ts +22 -7
  65. package/dist/tasks.js +47 -20
  66. package/dist/wrap.d.ts +6 -0
  67. package/dist/wrap.js +142 -6
  68. package/package.json +1 -1
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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><true/>
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=always
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
- function removeService() {
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
- tryRun("launchctl", ["bootout", `gui/${uid()}`, sp.plist]);
141
- if (existsSync(sp.plist))
142
- rmSync(sp.plist);
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
- tryRun("systemctl", ["--user", "disable", "--now", "context-doctor-proxy.service"]);
146
- if (existsSync(sp.unit))
147
- rmSync(sp.unit);
148
- tryRun("systemctl", ["--user", "daemon-reload"]);
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
- tryRun("schtasks", ["/Delete", "/F", "/TN", "context-doctor-proxy"]);
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 tryRun("launchctl", ["kickstart", "-k", `gui/${uid()}/${LABEL}`]);
218
+ return exec("launchctl", ["kickstart", "-k", `gui/${uid()}/${LABEL}`]);
182
219
  if (os === "linux" && existsSync(sp.unit))
183
- return tryRun("systemctl", ["--user", "restart", "context-doctor-proxy.service"]);
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 prev = readConfig(paths);
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
- if (env?.ANTHROPIC_BASE_URL && cfg && env.ANTHROPIC_BASE_URL === proxyUrl(cfg.port)) {
357
- if (cfg.previousBaseUrl)
358
- env.ANTHROPIC_BASE_URL = cfg.previousBaseUrl;
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 ~10% of input, while a cache write bills at ~125%,
8
- * so a session that keeps invalidating its prefix pays more than one with no
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 ~10% of input, while a cache write bills at ~125%,
8
- * so a session that keeps invalidating its prefix pays more than one with no
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
- /** Cache pricing multipliers relative to the base input rate. */
14
- const CACHE_WRITE_MULTIPLIER = 1.25;
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
- cacheReadTokens += usage.cache_read_input_tokens ?? 0;
45
- cacheWriteTokens += usage.cache_creation_input_tokens ?? 0;
46
- uncachedTokens += usage.input_tokens ?? 0;
47
- if (typeof entry.message.model === "string")
48
- model = entry.message.model;
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
- const pricing = pricingFor(model);
62
- if (pricing) {
63
- const perM = (tokens, rate) => (tokens / 1_000_000) * rate;
64
- const readUsd = perM(cacheReadTokens, pricing.cacheReadPerM);
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
- lines.push(` read ${tok(usage.cacheReadTokens)} · written ${tok(usage.cacheWriteTokens)} · uncached ${tok(usage.uncachedTokens)}`);
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.25x input while a read costs 0.1x, so writes that
88
- // rival reads mean the prefix keeps changing and caching is losing money.
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
  }
@@ -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 (1,024 tokens; 2,048 on Haiku)
8
- * is not cached at all, a request may carry at most 4 breakpoints, and the
9
- * cache looks back at most 20 blocks from a breakpoint for an earlier hit.
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 (1,024 tokens; 2,048 on Haiku)
8
- * is not cached at all, a request may carry at most 4 breakpoints, and the
9
- * cache looks back at most 20 blocks from a breakpoint for an earlier hit.
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
- function blocksOf(req, model) {
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
- const blocks = blocksOf(req, model);
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 = /haiku/i.test(model ?? "") ? 2048 : 1024;
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) {
@@ -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
- /** Remember one exact-vs-heuristic observation. Best-effort; never throws. */
34
- export declare function recordCalibration(model: string | undefined, exactTokens: number, heuristicTokens: number): void;
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;
@@ -50,13 +50,22 @@ function readAll() {
50
50
  return {};
51
51
  }
52
52
  }
53
- /** Remember one exact-vs-heuristic observation. Best-effort; never throws. */
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. */