context-doctor 0.31.0 → 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 +17 -2
package/dist/ci.js CHANGED
@@ -10,7 +10,7 @@
10
10
  * Base contents come from `git show <base>:<path>`; nothing is checked out.
11
11
  */
12
12
  import { execFileSync } from "node:child_process";
13
- import { existsSync, readFileSync } from "node:fs";
13
+ import { readFileSync, statSync } from "node:fs";
14
14
  import { join, posix } from "node:path";
15
15
  import { estimateTokens, formatTokens } from "./tokens.js";
16
16
  import { formatUsd, pricingFor } from "./pricing.js";
@@ -76,13 +76,33 @@ function measure(paths, read) {
76
76
  visit(p, false, 0);
77
77
  return out;
78
78
  }
79
+ /** `-z` output: without it git quotes non-ASCII paths ("caf\303\251/CLAUDE.md") and they match nothing. */
80
+ const paths0 = (out) => (out ?? "").split("\0").filter(Boolean);
81
+ /** A file on disk, not a directory: `@src/components` names a folder, which Claude Code does not load. */
82
+ function isFile(p) {
83
+ try {
84
+ return statSync(p).isFile();
85
+ }
86
+ catch {
87
+ return false;
88
+ }
89
+ }
79
90
  export function ciReport(opts = {}) {
80
91
  const cwd = opts.cwd ?? process.cwd();
81
92
  const root = git(["rev-parse", "--show-toplevel"], cwd)?.trim() ?? cwd;
82
- const headList = (git(["ls-files", "--cached", "--others", "--exclude-standard"], root) ?? "").split("\n").filter(Boolean);
83
- const baseList = opts.base ? (git(["ls-tree", "-r", "--name-only", opts.base], root) ?? "").split("\n").filter(Boolean) : [];
84
- const head = measure(headList.filter((p) => agentFor(p)), (p) => (existsSync(join(root, p)) ? readFileSync(join(root, p), "utf8") : undefined));
85
- const base = opts.base ? measure(baseList.filter((p) => agentFor(p)), (p) => git(["show", `${opts.base}:${p}`], root)) : new Map();
93
+ // A base git cannot resolve (a typo, or a shallow clone that never fetched
94
+ // it) would read as an empty tree: every file "new", and a failure that
95
+ // blames the PR for the whole memory set. Stop and say so instead.
96
+ if (opts.base && git(["rev-parse", "--verify", "--quiet", `${opts.base}^{commit}`], root) === undefined) {
97
+ throw new Error(`--base ${opts.base} is not a commit in ${root}: check the name, or fetch it first (a shallow clone: git fetch --depth=1 origin ${opts.base})`);
98
+ }
99
+ const headList = paths0(git(["ls-files", "-z", "--cached", "--others", "--exclude-standard"], root));
100
+ const baseList = opts.base ? paths0(git(["ls-tree", "-r", "-z", "--name-only", opts.base], root)) : [];
101
+ // ls-tree -r lists blobs only, so a path outside it (a folder, a missing file) has no base content;
102
+ // `git show base:folder` would print the folder's listing and count it as an imported file.
103
+ const baseFiles = new Set(baseList);
104
+ const head = measure(headList.filter((p) => agentFor(p)), (p) => (isFile(join(root, p)) ? readFileSync(join(root, p), "utf8") : undefined));
105
+ const base = opts.base ? measure(baseList.filter((p) => agentFor(p)), (p) => (baseFiles.has(p) ? git(["show", `${opts.base}:${p}`], root) : undefined)) : new Map();
86
106
  const files = [...new Set([...head.keys(), ...base.keys()])].sort().map((p) => ({
87
107
  path: p,
88
108
  agent: agentFor(p) ?? "Claude Code",
package/dist/cli.js CHANGED
@@ -461,7 +461,9 @@ async function main() {
461
461
  const opts = { task: args.task, check: args.check, existing: args.existing, model: args.model, budgetUsd: args.budgetUsd, dryRun: args.dryRun, allowDirty: args.allowDirty };
462
462
  const result = runExperiment(opts);
463
463
  console.log(args.json ? JSON.stringify(result, null, 2) : renderExperiment(result, opts));
464
- if (result.refused)
464
+ // No arm completed (claude missing, or every run errored): nothing was
465
+ // compared, and a script must not read exit 0 as a result.
466
+ if (result.refused || (result.arms.length > 0 && result.arms.every((a) => a.error)))
465
467
  process.exitCode = 1;
466
468
  return;
467
469
  }
@@ -493,7 +495,9 @@ async function main() {
493
495
  if (args.command === "cursor") {
494
496
  let chats;
495
497
  try {
496
- chats = listCursorChats();
498
+ // An id is looked up among ALL chats: the default list keeps the 20
499
+ // largest, so a smaller chat's id used to fall through to the biggest one.
500
+ chats = listCursorChats(args.file && !args.list ? Infinity : undefined);
497
501
  }
498
502
  catch (e) {
499
503
  console.error(`Could not read Cursor history: ${e.message}`);
@@ -509,7 +513,12 @@ async function main() {
509
513
  }
510
514
  return;
511
515
  }
512
- const chat = args.file ? chats.find((c) => c.composerId === args.file) ?? chats[0] : chats[0];
516
+ // A named chat that does not exist is an error, never a silent profile of another one.
517
+ const chat = args.file ? chats.find((c) => c.composerId === args.file) : chats[0];
518
+ if (!chat) {
519
+ console.error(`No Cursor chat with id "${args.file}" (context-doctor cursor --list shows the ids).`);
520
+ process.exit(1);
521
+ }
513
522
  const parsed = parseCursorChat(chat);
514
523
  const profile = profileConversation(parseConversation(parsed.conversationJson), args.model ?? parsed.model);
515
524
  if (args.json) {
@@ -557,9 +566,16 @@ async function main() {
557
566
  const profile = profileConversation(parseConversation(parsed.conversationJson), args.model ?? parsed.model);
558
567
  if (args.json) {
559
568
  console.log(JSON.stringify({ session: { path: parsed.path, title: parsed.title, toolTimings: parsed.toolTimings ?? [], subagents: subagentReport(path) }, profile }, null, 2));
569
+ // --json is what CI pipes, so the gate must hold here too (as it does for
570
+ // analyze); it used to run only after the text report and never failed.
571
+ applyBudgetGate(checkBudget(loadConfig(process.cwd(), (m) => console.error(`context-doctor: ${m}`)).config.budget, profile).overBudget, args.failOverBudget);
560
572
  }
561
573
  else {
562
- console.log(`Session: ${parsed.title ?? "(untitled)"}\nFile: ${parsed.path}`);
574
+ // Under --redact the title (the user's first prompt) and the path (which
575
+ // names the project) are content too.
576
+ console.log(args.redact
577
+ ? "Session: [redacted]\nFile: [redacted]"
578
+ : `Session: ${parsed.title ?? "(untitled)"}\nFile: ${parsed.path}`);
563
579
  if (parsed.compactedAway) {
564
580
  console.log(`Note: ${parsed.compactedAway} earlier message(s) were compacted away and are NOT counted below — this is the live context the model still sees.`);
565
581
  }
@@ -583,7 +599,7 @@ async function main() {
583
599
  console.log("");
584
600
  console.log(timing);
585
601
  }
586
- const subs = renderSubagents(subagentReport(path), cacheUsage?.paidUsd);
602
+ const subs = renderSubagents(subagentReport(path), cacheUsage?.paidUsd, 5, { redact: args.redact });
587
603
  if (subs) {
588
604
  console.log("");
589
605
  console.log(subs);
@@ -698,7 +714,7 @@ async function main() {
698
714
  return;
699
715
  }
700
716
  if (args.command === "uninstall") {
701
- runUninstall();
717
+ await runUninstall();
702
718
  return;
703
719
  }
704
720
  if (args.command === "proxy") {
@@ -774,7 +790,15 @@ async function main() {
774
790
  return;
775
791
  }
776
792
  if (args.command === "ci") {
777
- const report = ciReport({ base: args.base, maxTokens: args.maxTokens, maxIncrease: args.maxIncrease });
793
+ let report;
794
+ try {
795
+ report = ciReport({ base: args.base, maxTokens: args.maxTokens, maxIncrease: args.maxIncrease });
796
+ }
797
+ catch (e) {
798
+ // A base git cannot resolve (a typo, a shallow clone) is a usage error, not a crash.
799
+ console.error(`context-doctor: ${e.message}`);
800
+ process.exit(1);
801
+ }
778
802
  console.log(args.json ? JSON.stringify(report, null, 2) : args.markdown ? renderCiMarkdown(report, { model: args.model, requestsPerDay: args.requestsPerDay }) : renderCiText(report));
779
803
  if (report.breaches.length)
780
804
  process.exitCode = 1;
@@ -847,26 +871,37 @@ async function main() {
847
871
  if (args.command === "analyze") {
848
872
  const loaded = loadConfig(process.cwd(), (m) => console.error(`context-doctor: ${m}`));
849
873
  const profile = profileConversation(parseConversation(input), args.model ?? loaded.config.model);
850
- console.log(args.json ? JSON.stringify(profile, null, 2) : renderProfile(profile, { redact: args.redact }));
851
- if (!args.json)
852
- applyBudgetGate(printBudgetStatus(profile, loaded), args.failOverBudget);
853
- else
874
+ // The exact count is awaited before anything prints: with --json it goes
875
+ // inside the one JSON object. Printed after it, as it once was, the extra
876
+ // lines made stdout invalid JSON for every script that parsed it.
877
+ const exact = args.exact ? await exactTokenCount(input, args.model) : undefined;
878
+ let drift;
879
+ let remembered = false;
880
+ if (exact?.tokens !== undefined) {
881
+ drift = profile.totalTokens > 0 ? Math.round(((exact.tokens - profile.totalTokens) / exact.tokens) * 100) : 0;
882
+ // Remember the comparison so the next estimate for this model family
883
+ // starts from the user's own ground truth instead of a constant.
884
+ const raw = profile.calibration ? profile.totalTokens / profile.calibration.factor : profile.totalTokens;
885
+ remembered = recordCalibration(args.model ?? profile.model, exact.tokens, raw);
886
+ }
887
+ if (args.json) {
888
+ const out = exact ? { ...profile, exact: { ...exact, driftPct: drift, calibrationRecorded: remembered } } : profile;
889
+ console.log(JSON.stringify(out, null, 2));
854
890
  applyBudgetGate(checkBudget(loaded.config.budget, profile).overBudget, args.failOverBudget);
855
- if (args.exact) {
856
- void exactTokenCount(input, args.model).then((exact) => {
857
- if (exact.tokens !== undefined) {
858
- const drift = profile.totalTokens > 0 ? Math.round(((exact.tokens - profile.totalTokens) / exact.tokens) * 100) : 0;
859
- console.log(`\nExact input tokens: ${exact.tokens} (${exact.source}) — heuristic was off by ${drift}%`);
860
- // Remember the comparison so the next estimate for this model family
861
- // starts from the user's own ground truth instead of a constant.
862
- const raw = profile.calibration ? profile.totalTokens / profile.calibration.factor : profile.totalTokens;
863
- recordCalibration(args.model ?? profile.model, exact.tokens, raw);
864
- console.log(`Remembered: future ${modelFamily(args.model ?? profile.model)} estimates on this machine are calibrated from this (CONTEXT_DOCTOR_NO_CALIBRATION=1 to disable).`);
865
- }
866
- else {
867
- console.log(`\nExact count unavailable: ${exact.note}`);
868
- }
869
- });
891
+ return;
892
+ }
893
+ console.log(renderProfile(profile, { redact: args.redact }));
894
+ applyBudgetGate(printBudgetStatus(profile, loaded), args.failOverBudget);
895
+ if (exact?.tokens !== undefined) {
896
+ console.log(`\nExact input tokens: ${exact.tokens} (${exact.source}) — heuristic was off by ${drift}%`);
897
+ // Only claim it when it happened: a sample outside 0.5-2x is rejected,
898
+ // and CONTEXT_DOCTOR_NO_CALIBRATION=1 stores nothing.
899
+ if (remembered) {
900
+ console.log(`Remembered: future ${modelFamily(args.model ?? profile.model)} estimates on this machine are calibrated from this (CONTEXT_DOCTOR_NO_CALIBRATION=1 to disable).`);
901
+ }
902
+ }
903
+ else if (exact) {
904
+ console.log(`\nExact count unavailable: ${exact.note}`);
870
905
  }
871
906
  return;
872
907
  }
@@ -3,10 +3,11 @@
3
3
  *
4
4
  * Claude Code caches the conversation for an hour on a subscription (five
5
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.
6
+ * whole context at the cache-WRITE rate (2x input for the 1-hour cache,
7
+ * 1.25x for 5 minutes), and every message after it re-reads it (the model's
8
+ * cache-read rate, 2.5-10% of input). On a 500k-token session that is the
9
+ * most expensive moment of the day, and the one moment `/compact` is
10
+ * cheapest to accept: the cache is being rebuilt anyway.
10
11
  *
11
12
  * Measured on the author's 133 days of Claude Code history: 401 returns to a
12
13
  * session over 150k tokens after more than 65 idle minutes (median context
@@ -14,7 +15,11 @@
14
15
  * request itself and only the messages up to the next such return, would have
15
16
  * saved $4,053 at list price, about $914 a month; it paid off in 296 of 401
16
17
  * 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
+ * messages). So this advises, with the numbers, and the user decides. (That
19
+ * replay priced reads at 0.1x and writes at 1.25x of the session's first
20
+ * model. At each model's own read rate, Opus 5.5 at 5% and Fable 5.1 at
21
+ * 2.5%, the re-reads compaction avoids cost far less: the author's last 7
22
+ * days went from $332 to $139.)
18
23
  *
19
24
  * It works where the proxy cannot: the desktop app's Code tab runs Claude Code
20
25
  * host-orchestrated and drops a settings-file ANTHROPIC_BASE_URL, but it runs
@@ -31,7 +36,7 @@ export interface ColdResume {
31
36
  model?: string;
32
37
  /** Timestamp of the last assistant reply: identifies this idle period. */
33
38
  lastReplyAt: number;
34
- /** This message's cost to rebuild the cache, at list price. */
39
+ /** This message's cost to rebuild the cache, at list price and the session's write TTL mix. */
35
40
  resumeUsd?: number;
36
41
  /** Each later message's cost to re-read the context, at list price. */
37
42
  perMessageUsd?: number;
@@ -41,6 +46,17 @@ export interface ColdResume {
41
46
  * Reads only the transcript's tail. Undefined when not (or when unreadable).
42
47
  */
43
48
  export declare function detectColdResume(path: string, now?: number, idleMs?: number, minTokens?: number): ColdResume | undefined;
49
+ /**
50
+ * What this session pays to write a token to the cache, relative to input:
51
+ * the 5-minute/1-hour mix of the main-thread writes in the transcript's
52
+ * tail (subagents write 5 minutes and are not this context). The tail, not
53
+ * the whole file, because it is the TTL the harness writes at now: on the
54
+ * author's machine Claude Code 2.1.295 (desktop app) moved main-thread
55
+ * writes from 1 hour to 5 minutes, and 7 of the 12 largest recent sessions
56
+ * switched mid-file. 1.25x when the tail records no split or no writes.
57
+ * Reads only the tail, so the every-prompt hook can afford it.
58
+ */
59
+ export declare function sessionWriteMultiplier(path: string): number;
44
60
  /**
45
61
  * The one line shown to the USER in the app (hook `systemMessage`). In the
46
62
  * first week the offer lived only inside the model's reply and was acted on
@@ -61,8 +77,17 @@ export interface ColdResumeEvent {
61
77
  * Replay a transcript's billed usage and price what compacting at each cold
62
78
  * resume would have saved. Each resume only counts the messages up to the
63
79
  * next one (no double counting), and the compaction request is a cost.
80
+ *
81
+ * Each request at its own model's prices: a session that moved from a $4 to
82
+ * a $10 model was priced at the first throughout, and one that opened with
83
+ * a "<synthetic>" row not at all. A re-write that never happened (a cold
84
+ * read of what compaction would have removed, the fresh context after it)
85
+ * at the session's own 5-minute/1-hour write mix, not 1.25x. `runRows` is
86
+ * shared by every transcript of one report (see replyCounter): a forked
87
+ * session's copy of its parent's history shapes the timeline, but a resume
88
+ * in it was counted under the transcript that met it first.
64
89
  */
65
- export declare function coldResumeEvents(path: string, idleMs?: number, minTokens?: number): ColdResumeEvent[];
90
+ export declare function coldResumeEvents(path: string, idleMs?: number, minTokens?: number, runRows?: Set<string>): ColdResumeEvent[];
66
91
  /**
67
92
  * How often the cold-resume offer was acted on: offers (ledger events) whose
68
93
  * session has a compaction within an hour after. `sessionPaths` maps the
@@ -3,10 +3,11 @@
3
3
  *
4
4
  * Claude Code caches the conversation for an hour on a subscription (five
5
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.
6
+ * whole context at the cache-WRITE rate (2x input for the 1-hour cache,
7
+ * 1.25x for 5 minutes), and every message after it re-reads it (the model's
8
+ * cache-read rate, 2.5-10% of input). On a 500k-token session that is the
9
+ * most expensive moment of the day, and the one moment `/compact` is
10
+ * cheapest to accept: the cache is being rebuilt anyway.
10
11
  *
11
12
  * Measured on the author's 133 days of Claude Code history: 401 returns to a
12
13
  * session over 150k tokens after more than 65 idle minutes (median context
@@ -14,25 +15,54 @@
14
15
  * request itself and only the messages up to the next such return, would have
15
16
  * saved $4,053 at list price, about $914 a month; it paid off in 296 of 401
16
17
  * 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
+ * messages). So this advises, with the numbers, and the user decides. (That
19
+ * replay priced reads at 0.1x and writes at 1.25x of the session's first
20
+ * model. At each model's own read rate, Opus 5.5 at 5% and Fable 5.1 at
21
+ * 2.5%, the re-reads compaction avoids cost far less: the author's last 7
22
+ * days went from $332 to $139.)
18
23
  *
19
24
  * It works where the proxy cannot: the desktop app's Code tab runs Claude Code
20
25
  * host-orchestrated and drops a settings-file ANTHROPIC_BASE_URL, but it runs
21
26
  * the every-prompt hook, which is where this is called from.
22
27
  */
23
28
  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";
29
+ import { forEachLine, replyCounter } from "./session.js";
30
+ import { cacheWriteSplit, pricingFor, formatUsd, writeMultiplier } from "./pricing.js";
31
+ import { formatTokens, realModel } from "./tokens.js";
27
32
  /** Just past Claude Code's one-hour cache, as Claude Code's own idle clearing uses. */
28
33
  export const COLD_IDLE_MS = 65 * 60_000;
34
+ /** Margin past a cache's lifetime before a return counts as cold. */
35
+ const COLD_MARGIN_MS = 5 * 60_000;
36
+ const HOUR_MS = 3_600_000;
37
+ const FIVE_MIN_MS = 5 * 60_000;
38
+ /**
39
+ * How long the cache entry a reply left behind lives: one hour when the reply
40
+ * wrote the 1-hour cache, five minutes when it wrote only the 5-minute one,
41
+ * and otherwise whatever the last write before it used (a read refreshes an
42
+ * entry at its own lifetime). Claude Code's main thread wrote the 1-hour cache
43
+ * until 2.1.295, which often writes the 5-minute one (in the author's last 3
44
+ * days, 1,307 main-thread replies wrote only 5-minute entries): a gate fixed
45
+ * at 65 minutes missed every return between 10 and 65 minutes to those
46
+ * sessions, cold as they were. Undefined when nothing says.
47
+ */
48
+ function writtenTtlMs(u) {
49
+ // Only an explicit split says: cacheWriteSplit books unsplit writes as
50
+ // 5-minute for pricing, which would make every old transcript look short-lived.
51
+ const split = u?.cache_creation;
52
+ if (!split)
53
+ return undefined;
54
+ if ((split.ephemeral_1h_input_tokens ?? 0) > 0)
55
+ return HOUR_MS;
56
+ if ((split.ephemeral_5m_input_tokens ?? 0) > 0)
57
+ return FIVE_MIN_MS;
58
+ return undefined;
59
+ }
29
60
  /** Below this, a rebuilt cache is cheap and compaction is not worth the interruption. */
30
61
  export const COLD_MIN_TOKENS = 150_000;
31
62
  /** What a compacted session restarts from: system prompt and tools plus the summary. */
32
63
  const AFTER_COMPACT_TOKENS = 45_000;
33
- /** Output tokens a compaction summary costs, and output's price relative to input. */
64
+ /** Output tokens a compaction summary costs, at the model's output price. */
34
65
  const SUMMARY_OUTPUT_TOKENS = 8_000;
35
- const OUTPUT_PRICE_RATIO = 5;
36
66
  function tailLines(path, bytes = 256 * 1024) {
37
67
  const size = statSync(path).size;
38
68
  const n = Math.min(size, bytes);
@@ -50,9 +80,11 @@ function tailLines(path, bytes = 256 * 1024) {
50
80
  * Is the prompt being submitted now a return to a large, cache-cold session?
51
81
  * Reads only the transcript's tail. Undefined when not (or when unreadable).
52
82
  */
53
- export function detectColdResume(path, now = Date.now(), idleMs = COLD_IDLE_MS, minTokens = COLD_MIN_TOKENS) {
83
+ export function detectColdResume(path, now = Date.now(), idleMs, minTokens = COLD_MIN_TOKENS) {
54
84
  try {
55
- for (const line of tailLines(path)) {
85
+ const lines = tailLines(path);
86
+ for (let i = 0; i < lines.length; i++) {
87
+ const line = lines[i];
56
88
  let e;
57
89
  try {
58
90
  e = JSON.parse(line);
@@ -78,17 +110,34 @@ export function detectColdResume(path, now = Date.now(), idleMs = COLD_IDLE_MS,
78
110
  if (!Number.isFinite(lastReplyAt))
79
111
  return undefined;
80
112
  const idle = now - lastReplyAt;
81
- if (idle < idleMs || tokens < minTokens)
113
+ // The cache lives as long as the last write said (see writtenTtlMs);
114
+ // with nothing recorded, the hour Claude Code used to write.
115
+ let ttl = writtenTtlMs(u);
116
+ for (let j = i + 1; ttl === undefined && j < lines.length; j++) {
117
+ try {
118
+ const earlier = JSON.parse(lines[j]);
119
+ if (earlier?.type === "assistant" && !earlier.isSidechain)
120
+ ttl = writtenTtlMs(earlier.message?.usage);
121
+ }
122
+ catch { /* partial line at the tail's start */ }
123
+ }
124
+ const gate = idleMs ?? (ttl ?? HOUR_MS) + COLD_MARGIN_MS;
125
+ if (idle < gate || tokens < minTokens)
82
126
  return undefined;
83
- const model = e.message?.model;
127
+ const model = realModel(e.message?.model);
84
128
  const p = pricingFor(model);
129
+ // The rebuild is written at the TTL this session writes at: Claude
130
+ // Code's main thread has mostly written the 1-hour cache (2x input,
131
+ // 97% of the author's main-thread writes), which a flat 1.25x
132
+ // underpriced by 37%. Re-reads at the model's own read rate (Opus 5.5
133
+ // reads at 5% of input, not 10%).
85
134
  return {
86
135
  idleMs: idle,
87
136
  tokens,
88
137
  model,
89
138
  lastReplyAt,
90
- resumeUsd: p ? (tokens * 1.25 * p.inputPerM) / 1e6 : undefined,
91
- perMessageUsd: p ? (tokens * 0.1 * p.inputPerM) / 1e6 : undefined,
139
+ resumeUsd: p ? (tokens * sessionWriteMultiplier(path) * p.inputPerM) / 1e6 : undefined,
140
+ perMessageUsd: p ? (tokens * p.cacheReadPerM) / 1e6 : undefined,
92
141
  };
93
142
  }
94
143
  }
@@ -97,6 +146,49 @@ export function detectColdResume(path, now = Date.now(), idleMs = COLD_IDLE_MS,
97
146
  }
98
147
  return undefined;
99
148
  }
149
+ /**
150
+ * What this session pays to write a token to the cache, relative to input:
151
+ * the 5-minute/1-hour mix of the main-thread writes in the transcript's
152
+ * tail (subagents write 5 minutes and are not this context). The tail, not
153
+ * the whole file, because it is the TTL the harness writes at now: on the
154
+ * author's machine Claude Code 2.1.295 (desktop app) moved main-thread
155
+ * writes from 1 hour to 5 minutes, and 7 of the 12 largest recent sessions
156
+ * switched mid-file. 1.25x when the tail records no split or no writes.
157
+ * Reads only the tail, so the every-prompt hook can afford it.
158
+ */
159
+ export function sessionWriteMultiplier(path) {
160
+ let writes5m = 0, writes1h = 0;
161
+ try {
162
+ const ids = new Set();
163
+ for (const line of tailLines(path)) {
164
+ if (!line.includes('"cache_creation_input_tokens"'))
165
+ continue;
166
+ let e;
167
+ try {
168
+ e = JSON.parse(line);
169
+ }
170
+ catch {
171
+ continue;
172
+ }
173
+ if (e?.type !== "assistant" || e.isSidechain || !e.message?.usage)
174
+ continue;
175
+ // One reply is several rows carrying the same usage.
176
+ const id = e.message.id;
177
+ if (typeof id === "string") {
178
+ if (ids.has(id))
179
+ continue;
180
+ ids.add(id);
181
+ }
182
+ const w = cacheWriteSplit(e.message.usage);
183
+ writes5m += w.fiveMinute;
184
+ writes1h += w.oneHour;
185
+ }
186
+ }
187
+ catch {
188
+ /* unreadable transcript: the API's default TTL */
189
+ }
190
+ return writeMultiplier(writes5m, writes1h);
191
+ }
100
192
  function duration(ms) {
101
193
  const m = Math.round(ms / 60_000);
102
194
  if (m < 90)
@@ -130,10 +222,21 @@ export function renderColdResume(c) {
130
222
  * Replay a transcript's billed usage and price what compacting at each cold
131
223
  * resume would have saved. Each resume only counts the messages up to the
132
224
  * next one (no double counting), and the compaction request is a cost.
225
+ *
226
+ * Each request at its own model's prices: a session that moved from a $4 to
227
+ * a $10 model was priced at the first throughout, and one that opened with
228
+ * a "<synthetic>" row not at all. A re-write that never happened (a cold
229
+ * read of what compaction would have removed, the fresh context after it)
230
+ * at the session's own 5-minute/1-hour write mix, not 1.25x. `runRows` is
231
+ * shared by every transcript of one report (see replyCounter): a forked
232
+ * session's copy of its parent's history shapes the timeline, but a resume
233
+ * in it was counted under the transcript that met it first.
133
234
  */
134
- export function coldResumeEvents(path, idleMs = COLD_IDLE_MS, minTokens = COLD_MIN_TOKENS) {
235
+ export function coldResumeEvents(path, idleMs, minTokens = COLD_MIN_TOKENS, runRows) {
236
+ let ttl = HOUR_MS;
135
237
  const reqs = [];
136
- let lastId, model;
238
+ let model, writes5m = 0, writes1h = 0;
239
+ const sighting = replyCounter(runRows);
137
240
  forEachLine(path, (line) => {
138
241
  let e;
139
242
  try {
@@ -148,20 +251,24 @@ export function coldResumeEvents(path, idleMs = COLD_IDLE_MS, minTokens = COLD_M
148
251
  reqs.push("compact");
149
252
  return;
150
253
  }
151
- if (e.type !== "assistant" || !e.message || e.message.id === lastId)
254
+ if (e.type !== "assistant" || !e.message)
255
+ return;
256
+ const seen = sighting(e);
257
+ if (seen === "repeat")
152
258
  return;
153
- lastId = e.message.id;
154
259
  const u = e.message.usage ?? {};
155
260
  const total = (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0);
156
261
  const at = Date.parse(e.timestamp);
157
- if (total > 0 && Number.isFinite(at)) {
158
- reqs.push({ at, total });
159
- model ??= e.message.model;
160
- }
262
+ if (!(total > 0) || !Number.isFinite(at))
263
+ return;
264
+ model = realModel(e.message.model) ?? model;
265
+ const w = cacheWriteSplit(u);
266
+ writes5m += w.fiveMinute;
267
+ writes1h += w.oneHour;
268
+ ttl = writtenTtlMs(u) ?? ttl;
269
+ reqs.push({ at, total, price: pricingFor(model), copy: seen === "copy", ttl });
161
270
  });
162
- const perM = pricingFor(model)?.inputPerM;
163
- if (!perM)
164
- return [];
271
+ const mix = writeMultiplier(writes5m, writes1h);
165
272
  const real = reqs.filter((r) => r !== "compact");
166
273
  if (real.length < 2)
167
274
  return [];
@@ -170,21 +277,28 @@ export function coldResumeEvents(path, idleMs = COLD_IDLE_MS, minTokens = COLD_M
170
277
  const a = reqs[i - 1], b = reqs[i];
171
278
  if (a === "compact" || b === "compact")
172
279
  continue;
173
- if (b.at - a.at <= idleMs || b.total < minTokens)
280
+ // Cold once the gap outlives the cache the previous request left behind.
281
+ const gate = (r) => idleMs ?? r.ttl + COLD_MARGIN_MS;
282
+ if (b.at - a.at <= gate(a) || b.total < minTokens)
283
+ continue;
284
+ if (b.copy || !b.price)
174
285
  continue;
175
286
  const removed = Math.max(0, b.total - AFTER_COMPACT_TOKENS);
176
- let saved = 0, n = 0, prev = b.at;
287
+ let saved = 0, n = 0, prev = b;
177
288
  for (const c of reqs.slice(i + 1)) {
178
289
  if (c === "compact")
179
290
  break;
180
- if (c.at - prev > idleMs && c.total >= minTokens)
291
+ if (c.at - prev.at > gate(prev) && c.total >= minTokens)
181
292
  break;
182
- saved += (c.at - prev > 3_600_000 ? 1.25 : 0.1) * removed;
183
- prev = c.at;
293
+ const p = c.price ?? b.price;
294
+ saved += removed * (c.at - prev.at > prev.ttl ? mix * p.inputPerM : p.cacheReadPerM);
295
+ prev = c;
184
296
  n++;
185
297
  }
186
- const cost = 0.1 * b.total + OUTPUT_PRICE_RATIO * SUMMARY_OUTPUT_TOKENS + 1.25 * AFTER_COMPACT_TOKENS;
187
- events.push({ at: b.at, tokens: b.total, messagesAfter: n, netUsd: ((saved - cost) * perM) / 1e6 });
298
+ // The compaction re-reads the context just rebuilt and writes a summary;
299
+ // the next request writes the fresh context.
300
+ const cost = b.total * b.price.cacheReadPerM + SUMMARY_OUTPUT_TOKENS * b.price.outputPerM + mix * AFTER_COMPACT_TOKENS * b.price.inputPerM;
301
+ events.push({ at: b.at, tokens: b.total, messagesAfter: n, netUsd: (saved - cost) / 1e6 });
188
302
  }
189
303
  return events;
190
304
  }
@@ -15,7 +15,7 @@
15
15
  * Compactions shaped by context-doctor's PreCompact hook are reported apart,
16
16
  * so its effect can be seen as it accumulates.
17
17
  */
18
- import { forEachLine, listSessions } from "./session.js";
18
+ import { forEachLine, listSessions, queuedUserPrompt } from "./session.js";
19
19
  import { readLedger } from "./ledger.js";
20
20
  import { estimateTokens, formatTokens } from "./tokens.js";
21
21
  const WINDOW = 15;
@@ -43,12 +43,26 @@ export function compactionQuality(days = 60, paths) {
43
43
  const shapedAt = readLedger().filter((e) => e.ev === "precompact").map((e) => e.ts);
44
44
  const q = { compactions: 0, extraRereadTokensPerCompaction: 0, sessions: 0, before: empty(), after: empty() };
45
45
  const shaped = { compactions: 0, before: empty(), after: empty() };
46
+ // A forked session copies its parent's compactions (same row uuid): one compaction, counted once.
47
+ const countedBoundaries = new Set();
46
48
  for (const path of files) {
47
49
  const reqs = [];
50
+ /** `at` is NaN when the row carries no timestamp. */
48
51
  const boundaries = [];
49
52
  let cur;
50
53
  // User messages between a compaction and the next request belong to the "after" side.
51
54
  let pendingUser = 0, pendingCorrections = 0;
55
+ const userMessage = (text) => {
56
+ const corr = CORRECTION.test(text.trim()) ? 1 : 0;
57
+ if (cur) {
58
+ cur.userMsgs++;
59
+ cur.corrections += corr;
60
+ }
61
+ else {
62
+ pendingUser++;
63
+ pendingCorrections += corr;
64
+ }
65
+ };
52
66
  const readIds = new Map();
53
67
  let lastId;
54
68
  forEachLine(path, (line) => {
@@ -63,10 +77,21 @@ export function compactionQuality(days = 60, paths) {
63
77
  return;
64
78
  const at = Date.parse(e.timestamp);
65
79
  if (e.type === "system" && e.subtype === "compact_boundary") {
66
- boundaries.push({ index: reqs.length, at: Number.isFinite(at) ? at : 0 });
80
+ const copy = typeof e.uuid === "string" && countedBoundaries.has(e.uuid);
81
+ if (typeof e.uuid === "string")
82
+ countedBoundaries.add(e.uuid);
83
+ if (!copy)
84
+ boundaries.push({ index: reqs.length, at });
67
85
  cur = undefined;
68
86
  return;
69
87
  }
88
+ // Typed while the agent worked: a user message recorded only as an
89
+ // attachment, and the likeliest moment for a "no, stop" to arrive.
90
+ const queued = queuedUserPrompt(e);
91
+ if (queued !== undefined) {
92
+ userMessage(typeof queued === "string" ? queued : queued.map((b) => (typeof b?.text === "string" ? b.text : "")).join(" "));
93
+ return;
94
+ }
70
95
  const c = e.message?.content;
71
96
  if (e.type === "assistant") {
72
97
  if (e.message?.id !== lastId && e.message?.usage) {
@@ -87,15 +112,7 @@ export function compactionQuality(days = 60, paths) {
87
112
  }
88
113
  else if (e.type === "user") {
89
114
  if (typeof c === "string" && !e.isMeta && !e.isCompactSummary) {
90
- const corr = CORRECTION.test(c.trim()) ? 1 : 0;
91
- if (cur) {
92
- cur.userMsgs++;
93
- cur.corrections += corr;
94
- }
95
- else {
96
- pendingUser++;
97
- pendingCorrections += corr;
98
- }
115
+ userMessage(c);
99
116
  }
100
117
  else if (cur && Array.isArray(c)) {
101
118
  for (const b of c) {
@@ -115,6 +132,10 @@ export function compactionQuality(days = 60, paths) {
115
132
  });
116
133
  let counted = false;
117
134
  for (const b of boundaries) {
135
+ // Each compaction by its own time: a session touched in the window
136
+ // brought in all its older compactions. Undated ones count with the file.
137
+ if (b.at < since)
138
+ continue;
118
139
  const before = reqs.slice(Math.max(0, b.index - WINDOW), b.index);
119
140
  const after = reqs.slice(b.index, b.index + WINDOW);
120
141
  if (before.length < 5 || after.length < 5)
@@ -167,7 +188,8 @@ export function renderCompactionQuality(q, days = 60) {
167
188
  const worse = a.error - b.error > 0.03 || a.correction - b.correction > 0.05;
168
189
  out.push("");
169
190
  if (forgets) {
170
- out.push(`Compacting forgets files: each compaction is followed by ~${formatTokens(q.extraRereadTokensPerCompaction)} tokens of extra re-reads. compact-window charges that to every compaction it simulates.`);
191
+ // Rounded: an average below 1k otherwise printed as "~237.46428571428572".
192
+ out.push(`Compacting forgets files: each compaction is followed by ~${formatTokens(Math.round(q.extraRereadTokensPerCompaction))} tokens of extra re-reads. compact-window charges that to every compaction it simulates.`);
171
193
  }
172
194
  out.push(worse
173
195
  ? "Sessions also work worse after compacting (more tool errors or corrections): compact less often, and let the PreCompact hook tell each summary what to keep."