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.
- 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 +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 {
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
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
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
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
|
}
|
package/dist/coldresume.d.ts
CHANGED
|
@@ -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 (
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* the
|
|
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
|
package/dist/coldresume.js
CHANGED
|
@@ -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 (
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* the
|
|
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,
|
|
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
|
|
83
|
+
export function detectColdResume(path, now = Date.now(), idleMs, minTokens = COLD_MIN_TOKENS) {
|
|
54
84
|
try {
|
|
55
|
-
|
|
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
|
-
|
|
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 *
|
|
91
|
-
perMessageUsd: p ? (tokens *
|
|
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
|
|
235
|
+
export function coldResumeEvents(path, idleMs, minTokens = COLD_MIN_TOKENS, runRows) {
|
|
236
|
+
let ttl = HOUR_MS;
|
|
135
237
|
const reqs = [];
|
|
136
|
-
let
|
|
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
|
|
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
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 >
|
|
291
|
+
if (c.at - prev.at > gate(prev) && c.total >= minTokens)
|
|
181
292
|
break;
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
187
|
-
|
|
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
|
}
|
package/dist/compactquality.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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."
|