Measures how warm the prompt cache stays across your idle gaps, and refreshes it before it expires.

ember is a Claude Code mod that measures how warm your prompt cache stays across idle pauses, and refreshes the cache shortly before it expires. It changes no request, prompt, or tool call, and it never submits a prompt. Its only model calls are the refresh forks, which are on by default.
Verified. A live session showed that the refresh keeps the main cache warm through a long pause without a new full write. Results can differ by account and plan, and each refresh spends plan usage or API credit.
Tested with Claude Code v2.1.306.
To install ember, run /plugin install ember --marketplace davegomez/mods in a Claude Code session. For the shell and the desktop app, see Install a mod.
In this tutorial, we start a session with ember, send 2 short prompts, and read the cache report. It takes about 3 minutes.
Reply with the word hello.
/ember. A pane opens. Its Status section shows the TTL that ember uses, for example TTL 60m (ttlMinutes option). The Cache hit by gap type table has 1 row, first, with 1 request.Close at the top right of the pane. Reply with the word again.
/ember again. Notice the new user row. Its hit ratio is high, because the second request read the first request's prefix from the cache.Close.You have measured your own prompt cache. From now on, each pause between your prompts adds a row to the report. When you stay idle for close to the TTL, ember refreshes the cache before it expires.
~/.claude/settings.json.ember@davegomez. {
"pluginConfigs": {
"ember@davegomez": {
"options": { "refresh": false }
}
}
}
ember still logs each pause when the refresh is off, so /ember-refresh can tell you later whether to turn it on.
To change when the refresh runs instead, set refreshLeadSeconds in the same options object. See Options.
ember cannot see your plan, so it assumes 60 minutes unless a Claude Code setting says otherwise.
"promptCacheTtl": "5m" in ~/.claude/settings.json, or set the ttlMinutes option to 5 in the install screen or in /config. At 5 minutes the refresh stays on unless you set the refreshShortTtl option to false."promptCacheTtl": "1h". Cache writes then cost 2 times the input price instead of 1.25 times.Run /ember to check the result. The TTL row of the Status section names the TTL and its source.
session_id from stdin.live object of that session: session_id=$(jq -r '.session_id // empty')
for f in "$HOME"/.claude/plugins/store/ember_*.json; do
live=$(jq -c --arg k "session:$session_id" '.[$k].live // empty' "$f" 2>/dev/null) && [ -n "$live" ] && break
done
echo "$live" | jq -r --argjson now "$(date +%s)" \
'"\(.ttlMinutes * 60 - ($now - .lastStartMs / 1000) | floor)s left · \(.prefix) cached · hit \(.hit * 100 | round)%"'
live is missing, or when jq fails. A file can be half written while you read it.If your shell runs with set -f, use find instead of the glob. If you use the VS Code extension, skip this guide: ember sets a status line there for you.
/ember-refresh has at least 20 returned pauses./ember-refresh.Recommendation box at the top of the pane.refreshLeadSeconds to that value.refreshShortTtl to false, set it in the same options object. If it says to set it to true, set it to true or remove it. If it says to keep refreshShortTtl on, change nothing.Not enough data, wait for more pauses.Refresh age table. Misses at ages below your TTL mean that the real TTL is shorter. Set the TTL for your plan.Idle compaction section, read it before you change the idleCompaction setting. It estimates the cost of "idleCompaction": false from your own pauses. See Keep the cache of a long conversation.A live session verified the refresh on 1 account. This guide checks it on yours.
This guide spends plan usage or API credit: each probe is 1 fork.
/ember-probe. Expect a toast with hit and a read of at least 90%. Note the fork steps seen count: 0 means that fork requests do not reach turn.step./ember-probe again. Expect a hit again, because the first probe restarted the TTL./ember. Check that the user row has a high hit ratio and that the agent and first rows did not grow.CLAUDE_CODE_PROMPT_CACHE_TTL=5m, finish a turn, wait 6 minutes, and run /ember-probe. Expect a miss with a read near 0./ember-refresh and read the refresh rows.If steps 3 to 5 pass, the refresh works on your account.
On the 60-minute TTL, Claude Code can compact an idle conversation about 54 minutes after the last request.
/ember-refresh shows Idle compaction as not seen in its Status section./ember-refresh and read its idle compaction section. It estimates what "idleCompaction": false costs on your own pauses, and it recommends whether to change the setting."idleCompaction": false in ~/.claude/settings.json.| Command | What it does | | :- | :- | | /ember | Opens the ember pane with the cache report. | | /ember-refresh | Opens the ember refresh pane with the refresh analysis across all stored sessions. |
Each pane closes with a click on Close at its top right, with q while the pane has the keyboard, or with Esc. | /ember-probe | Sends 1 refresh now and shows its result in a toast. It does not count toward the cap or the circuit breaker. |
You can run all 3 commands while Claude works. /ember-probe then refuses with a turn is running, and while a refresh runs, with a refresh is running.
/ember paneThe first row holds the title ember, the tabs 1: This session and 2: All sessions, and q: Close at the right. The tabs show only when other sessions are stored. Keys 1 and 2 switch them.
Below it come 3 sections, each under a bold title. They stand side by side in a wide pane and wrap to the next row in a narrow one.
| Section | What it shows | | :- | :- | | Status | TTL <minutes>m (<source>); Observed TTL warm up to <duration> · cold from <duration>, with – for a bound not yet seen; Compactions <n>; Model switches <n>. | | Cache hit by gap type | A table Gap type, Requests, Hit with each gap type that has requests, or No requests yet. | | Cache hit by user gap | A table User gap, Requests, Hit with each user gap band that has requests, or No user gaps yet. |
Labels are dim and values bold. A hit ratio is green from 80%, yellow from 50%, and red below 50%. Example in a 70-column pane:
ember 1: This session 2: All sessions q: Close
Status
TTL 60m (ttlMinutes option)
Observed TTL warm up to 2m · cold from –
Compactions 0
Model switches 0
Cache hit by gap type Cache hit by user gap
Gap type Requests Hit User gap Requests Hit
first 1 90% 1-5m 1 98%
user 1 98%
Gap types:
| Gap type | The request came | | :- | :- | | first | first in the session, after a reload, or after /clear, /resume, or /branch | | user | first in a turn, after you sent a prompt | | ask | after Claude asked you a question with AskUserQuestion | | subagent | after a subagent ran | | tool | after any other tool call in the turn | | compact | first after a compaction | | model-switch | first after a change of model | | agent | from a subagent |
The hit ratio is cache read / (input + cache read + cache creation), weighted by tokens, in both tables. The user gap bands are <1m, 1-5m, 5-15m, 15-30m, 30-60m, and >60m, measured from the end of the previous response. Bands recorded before version 0.3.0 hold no token totals, so their hit shows –. A user request is warm when its cache read is at least half of the previous main prefix, and cold when it is less. The observed TTL bounds are the longest warm gap and the shortest cold gap, measured from the start of the previous request.
/ember-refresh paneThe first row holds the title ember refresh and q: Close at the right. Below it comes the recommendation, in a box as wide as its text, then any idle compaction warning. The pane groups the other rows into sections, each under a bold title. The sections stand side by side where the pane is wide enough, and wrap to the next row where it is not:
| Section | What it shows | | :- | :- | | Status | Whether the refresh is on, the lead, the TTL and its source, whether ember has seen idle compaction run and how many pauses it left to it, and the sample size. | | Refreshes | The refresh hits, misses, refusals, and hit rate, then hits and misses by refresh age. | | Savings | The net saving, measured on the refreshes ember ran, and under Not refreshed the reasons that pauses were not refreshed. Idle compaction is among the reasons only when Status has no Idle compaction row. | | Idle time (survival) | The Kaplan-Meier share of pauses still idle at 5m, 30m, 55m, 1h, 2h, and 8h, with exits censored. | | Lead options | For each lead and for off: Saved/pause, the expected saving per pause; Replay cost, the cost of the recorded pauses replayed under that policy; and Saved vs off, the saving against off in that replay. The replay covers every pause in the sample, skipped ones included, and counts each refresh as a hit, so Saved vs off can exceed Net saved. |
Values are bold. Green marks a hit and a positive saving, red a miss and a negative saving, yellow a refusal, a refresh that is off, and a warning. Caveats are dim, under the value they qualify. In the Lead options table, ▸ marks the recommended row, and • and (current) in the name mark the policy that runs now. When the recommended row is also the current one, (current) still shows. Durations drop a zero part: 5m, 1h, but 59m59s and 1h05m. The table keeps its numbers whole in a narrow pane and shortens the policy names instead.
Idle time (survival) and Lead options show only with at least 20 returned pauses. Below that, the recommendation says Not enough data. Example on a sample log, in a 70-column pane:
ember refresh q: Close
╭─────────────────────────────────────────────────────────────────╮
│ Recommendation: keep refresh on, keep the current lead (lead 5m │
│ (auto)): about 10k tokens saved per pause versus refresh off. │
╰─────────────────────────────────────────────────────────────────╯
Status
Refresh on
Lead 5m (auto)
TTL 60m (ttlMinutes option)
Idle compaction not seen, ember refreshes long conversations too
Sample 30 pauses (28 returned, 2 exits) · 5 refreshes · 4
sessions
Refreshes
5 hit · 0 miss · 0 refused · hit rate 100%
Refresh age Hit Miss
30-60m 4 0
>60m 1 0
Savings
Net saved ~620k tokens (4 cold rewrites avoided)
API-price estimate in input-token units: Anthropic
does not publish how fork cache reads count toward
plan limits.
Idle time (survival)
Share of pauses still idle after each time
Idle for 5m 30m 55m 1h 2h 8h
Still idle 83% 20% 20% 20% 10% 7%
Lead options
Policy Saved/pause Replay cost Saved vs off
off – 1.0M –
lead 1m 10k 420k 620k
lead 2m 10k 420k 620k
▸ lead 5m (auto, current) 10k 420k 620k
lead 10m 10k 430k 610k
▸ recommended · • current
Saved vs off replays every pause in the sample, skipped ones too, as
if each refresh hit; Net saved counts only the refreshes ember ran.
The Idle compaction row of Status shows only at a 60-minute TTL with the refresh on. It is one of:
not seen, ember refreshes long conversations tooseen <YYYY-MM-DD>, ember skips the pauses it applies toWhen ember left any pause to idle compaction, the row adds the count: · <n> pauses skipped, or · <n> pauses skipped earlier when idle compaction is not seen now.
If some skipped pauses lasted at least 54 minutes and no idle compaction was recorded in them, the warning under the recommendation says <n> skipped pauses lasted past 54m with no idle compaction recorded; ember now refreshes such pauses until it sees idle compaction run.
The pane ends with an Idle compaction section when ember left pauses to idle compaction and either has fewer than 20 returned ones, or recorded idle compaction in some that lasted at least 54 minutes. This happens on the 60-minute TTL only. With fewer than 20 returned pauses, the section says there is not enough data and to keep idle compaction on. Otherwise it counts only the pauses that lasted at least 54 minutes with idle compaction recorded, because shorter pauses cost the same either way. For those pauses it shows:
"idleCompaction": false, with refresh at your lead and with no refresh.A pause that ember rescued counts as a refreshed pause, not in this section. When the refresh is off, the section recommends to keep idle compaction on. It never claims a saving against idle compaction, and it does not price what a summary loses. ember never changes the setting. Example on a sample log:
Idle compaction
6 of 21 pauses lasted past 54m with idle compaction recorded;
shorter pauses cost the same either way.
With "idleCompaction": false, which keeps the full conversation:
refresh at lead 5m (auto) 500k · no refresh 2.5M tokens.
Idle compaction on the same pauses: at least 150k tokens, one cache
read of the conversation for each of the 6 pauses past 54m.
Not measured: the summary's output and its write at return.
Keeping the full conversation costs at most 350k tokens more (58k per
pause).
Not priced: what a summary loses (the detail of the conversation).
╭────────────────────────────────────────────────────────────────────╮
│ Recommendation: keep idle compaction on unless the full │
│ conversation is worth about 58k tokens per pause to you. If you │
│ set "idleCompaction": false, keep refresh on: it costs 2.0M tokens │
│ less than no refresh. │
╰────────────────────────────────────────────────────────────────────╯
| Option | Type | Default | Values | | :- | :- | :- | :- | | refresh | Boolean | true | false turns the refresh off. | | refreshLeadSeconds | number | 0 | 0 is the automatic lead. Otherwise 10 to 3600 seconds before expiry. A lead that leaves less than 10 s of TTL falls back to automatic. | | refreshShortTtl | Boolean | true | false stops the refresh when the TTL is 5 minutes or less. Any other value counts as true. | | ttlMinutes | number | 60 | 1 to 1440. Used only when no setting in TTL resolution applies. |
The install screen of /plugin install shows refreshLeadSeconds and ttlMinutes as empty fields. An empty field uses the default.
Set the options in the install screen, in /config, or under pluginConfigs in ~/.claude/settings.json.
ember takes the first match, in Claude Code's own order:
FORCE_PROMPT_CACHING_5M=1: 5 minutes.CLAUDE_CODE_PROMPT_CACHE_TTL set to 5m or 1h.promptCacheTtl setting set to 5m or 1h.ENABLE_PROMPT_CACHING_1H=1: 60 minutes.ttlMinutes option.Other values, and a read that fails, go to the next rule. ember reads the TTL at session start and after /clear, /resume, and /branch. After a refresh miss that read less than half of the prefix, the session uses 5 minutes, shown as TTL 5m (refresh miss).
| Rule | Value | | :- | :- | | When | While the main thread is idle: no turn, no main request, and no refresh running. | | Request | $.model.fork with the prompt Reply with the single word ok. | | Automatic lead | min(5 min, TTL / 5): 5 minutes before expiry for a 60-minute TTL, 1 minute for 5 minutes. | | Expiry | TTL after the start of the last request that read the cache: the main request, or the last refresh that hit. | | Late limit | No refresh starts in the last 5 s before expiry. | | Cap per pause | floor(write weight / 0.1) refreshes: 20 for 60 minutes, 12 for 5 minutes. | | Hit | Cache read at least 90% of the last main prefix. | | Miss | Any other result. A miss stops the refresh for that pause. | | Circuit breaker | 2 misses in a row stop the refresh for the session, with 1 toast. | | Refused call | An API error, an abort, or a rejected call retries after 30 s, at most 3 times per pause. nothing-to-fork stops the pause. | | No refresh | Refresh off; claude -p or the Agent SDK; a turn stopped with Esc; a prefix of 0; a compaction in or before the pause; the circuit breaker; a pause left to idle compaction, until the rescue; a TTL of 5 minutes or less while refreshShortTtl is false, also after a refresh miss sets the TTL to 5 minutes. ember logs that pause with the reason short-ttl, so /ember-refresh can evaluate it. | | Idle compaction | ember skips a pause with the reason idle compaction only when all of these hold: the TTL is 60 minutes, the session is interactive, idle compaction is not turned off ("idleCompaction": false, "autoCompactEnabled": false, or DISABLE_AUTO_COMPACT set to 1 or true), the prefix is at least 90% of the idle compaction threshold, and ember has seen idle compaction run on this computer. The threshold is 200,000 tokens, or CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS with a floor of 100,000. Without that evidence, ember refreshes these pauses at the usual lead. At any other TTL, no pause is skipped for idle compaction. | | Rescue | In a skipped pause, if no compaction is seen by 57 m 30 s after the last cache read, ember deletes the evidence and refreshes then. The pause then continues like any other: refreshes at the lead, the cap, and the circuit breaker. Later pauses refresh until ember sees idle compaction run again. A manual compaction in a skipped pause stops the rescue. | | Compaction after a rescue | If idle compaction runs after a rescue, the refreshes stop for that pause, and ember records the evidence again. |
| Where | What | | :- | :- | | Toast | Once per idle pause, when the time left first falls to the automatic lead: cache goes cold in ~4m59s (142k tokens). Not shown while a request runs, when the prefix is 0, while a refresh will still run before expiry, or while a pause waits for the idle compaction rescue. | | Toast | When the circuit breaker trips: ember: cache refresh paused after 2 misses in a row. Run /ember-refresh for details. | | Status line | VS Code only: idle 12m03s · last hit 97% · 142k cached · cold in ~47m57s, or session hit 95% while a main request runs. |
The first test ran on 2026-10-20, Claude Code v2.1.305, 1 Claude subscription account, TTL 60m (ttlMinutes option). 4 sessions, 12 main requests.
| Gap type | Requests | Hit | | :- | :- | :- | | first | 5 | 62.6% | | user | 2 | 98.4% | | tool | 5 | 93.3% | | All | 12 | 83.0% |
The 2 user pauses fell in the 5-15m and 15-30m bands, and both stayed warm. The longest warm pause was 29m03s, measured from the start of the previous request. That is longer than a 5-minute TTL, so the 60-minute TTL applied on this account. No cold user pause was recorded, so the test gives no upper bound for the TTL.
This first test ran no refresh. The 2 pauses that the refresh log recorded ended with a session exit, so no refresh was due. The second test covers the refresh.
The second test ran on Claude Code v2.1.306, with the installed copy, on the same account and a 60-minute TTL. It recorded 1 pause, from 2026-10-20 22:25 to 2026-10-21 05:16, 6 h 51 min. The cached conversation was 133,373 tokens.
| Measure | Result | | :- | :- | | Timed refreshes | 7, 55 minutes apart | | Cache read per refresh | 133,373 tokens (100%) | | Cache write per refresh | 443 tokens | | Uncached input per refresh | 16 tokens | | Misses | 0 | | Next message after the pause | Warm | | Fork steps seen | 0 | | Net saving | About 152k tokens |
The 7 refreshes cost about 102k tokens, and the warm read at return about 13k, against a cold rewrite of 2 × 133,373 tokens. The saving is in input-token units at API prices. A fork step count of 0 means that fork requests do not reach the turn.step event. No /ember-probe ran in this test. A later pause of 69.7 minutes in the same session, with a 238k-token cache, went cold: ember skipped it because it assumed idle compaction would run, and no compaction ran. This led to the rescue in the refresh policy.
The sample is small: 2 tests on 1 account. Run /ember and /ember-refresh to see your own numbers.
ember
hooks/register.tsx 881 lines1import type {
2 EngineInterface,
3 ModelForkResult,
4 Register,
5 Timer,
6 TurnStepInput,
7 TurnStepResult,
8} from "claude-code";
9
10import { analyze } from "./analysis.ts";
11import { pane } from "./pane.tsx";
12import type {
13 End,
14 Fired,
15 IdleCompactInputs,
16 Judged,
17 Pause,
18 RefreshConfig,
19 RefreshState,
20} from "./refresh.ts";
21import {
22 applyRefresh,
23 COLD_READ_SHARE,
24 closePause,
25 effectiveTtl,
26 emptyRefresh,
27 idleCompactionApplies,
28 idleCompactionLapsed,
29 isIdleCompactionEvidence,
30 isRefreshPlanned,
31 judgeFork,
32 openPause,
33 parseIdleSeen,
34 planRefresh,
35 REFRESH_PROMPT,
36 refreshConfig,
37 skipCode,
38} from "./refresh.ts";
39import type { Agg, Main, StatusView, Ttl } from "./stats.ts";
40import {
41 emptyAgg,
42 emptyMain,
43 keysToPrune,
44 mergeAggs,
45 noteCompaction,
46 observe,
47 parseAgg,
48 percent,
49 report,
50 resolveTtl,
51 statusText,
52 tokens,
53 validTtlMinutes,
54 warning,
55 withLive,
56 withRefreshLive,
57 withSavedPct,
58 withTtl,
59} from "./stats.ts";
60
61const KEY_PREFIX = "session:";
62/** Store-wide, read by every session: when idle compaction was last seen running on this machine. */
63const IDLE_KEY = "idleCompactionSeenMs";
64const PANE = "ember";
65const REFRESH_PANE = "ember-refresh";
66
67type Tab = "session" | "all";
68
69type Live = {
70 optionMinutes: number;
71 ttl: Ttl;
72 agg: Agg;
73 main: Main;
74 /** The measured session's store key; undefined until its aggregate is read. */
75 key?: string;
76 /** Bumped at each session bind; a sample started under an older one is dropped. */
77 gen: number;
78 isInFlight: boolean;
79 /** What the status line shows now; null until the first refresh sets or clears it. */
80 shown: string | undefined | null;
81 /** The main request start the cold warning was last shown for. */
82 warnedStartMs?: number;
83 timer?: Timer;
84 /** The latest unsaved aggregate per key, written one `store.set` at a time. */
85 pending: Map<string, Agg>;
86 isWriting: boolean;
87 /** The other sessions' aggregates, read when the pane opens and when tab 2 is pressed. */
88 others: Agg[];
89 tab: Tab;
90 config: RefreshConfig;
91 idle: IdleCompactInputs;
92 /** The store-wide idle compaction evidence as last read or written; undefined: not seen. */
93 idleSeenMs?: number;
94 isInteractive: boolean;
95 /** The running main turn, from its `turn.start` to its `turn.complete`. */
96 turnId?: string;
97 /** Main-conversation compactions running now: no refresh starts while one may replace the prefix. */
98 compacting: number;
99 /** ember's own fork while it runs, with the steps that reached `turn.step` meanwhile. */
100 refreshing?: Fired;
101 /** The clock at the last tick, for the places that cannot wait for it. */
102 nowMs: number;
103};
104
105const stateOf = (live: Live): RefreshState => live.agg.refresh ?? emptyRefresh;
106const ttlOf = (live: Live, state = live.agg.refresh): Ttl =>
107 effectiveTtl(live.ttl, state);
108
109/** Re-reads the store-wide evidence, never awaited by a hook; a local write that lands meanwhile wins. */
110async function readIdleSeen($: EngineInterface, live: Live): Promise<void> {
111 const before = live.idleSeenMs;
112 try {
113 const seen = parseIdleSeen(await $.store.get(IDLE_KEY));
114 if (live.idleSeenMs !== before || seen === before) return;
115 live.idleSeenMs = seen;
116 $.ui.invalidate("ui.render");
117 } catch {
118 $.ui.log("ember: could not read idle compaction evidence", { to: "debug" });
119 }
120}
121
122/** Sets or clears the store-wide evidence; never awaited. */
123function writeIdleSeen(
124 $: EngineInterface,
125 live: Live,
126 seenMs: number | undefined,
127): void {
128 live.idleSeenMs = seenMs;
129 const write =
130 seenMs === undefined
131 ? $.store.delete(IDLE_KEY)
132 : $.store.set(IDLE_KEY, seenMs);
133 write.catch(() =>
134 $.ui.log("ember: could not save idle compaction evidence", { to: "debug" }),
135 );
136}
137
138/** Closes the pause; one left to idle compaction that outlasted its time with none seen clears the evidence. */
139function close(
140 $: EngineInterface,
141 live: Live,
142 s: RefreshState,
143 end: End,
144 atMs: number,
145): RefreshState {
146 if (
147 s.pause !== undefined &&
148 idleCompactionLapsed(s.pause, s.pause.returnMs ?? atMs)
149 )
150 writeIdleSeen($, live, undefined);
151 return closePause(s, end, atMs);
152}
153
154/** Binds the current session: its key, its stored aggregate or an empty one, and a fresh main thread. */
155async function bind($: EngineInterface, live: Live): Promise<void> {
156 endPause($, live, "exit", live.nowMs);
157 const gen = ++live.gen;
158 live.key = undefined;
159 live.agg = emptyAgg(0);
160 live.main = emptyMain;
161 live.others = [];
162 live.turnId = undefined;
163 $.ui.invalidate("ui.render");
164 try {
165 const key = `${KEY_PREFIX}${await $.session.id()}`;
166 const stored =
167 parseAgg(await $.store.get(key)) ?? emptyAgg(await $.clock.now());
168 if (gen !== live.gen) return;
169 live.key = key;
170 // A pause left open by a process that ended without a session end: an exit at its last write.
171 const open = stored.refresh?.pause;
172 live.agg =
173 open === undefined || stored.refresh === undefined
174 ? stored
175 : {
176 ...stored,
177 refresh: close(
178 $,
179 live,
180 stored.refresh,
181 "exit",
182 Math.max(open.startMs, stored.updatedAt),
183 ),
184 };
185 if (live.agg !== stored) save($, live);
186 } catch {
187 $.ui.log("ember: could not load its stats", { to: "debug" });
188 }
189}
190
191/** Reads the TTL and idle compaction inputs; each read that fails counts as unset, so the next rule applies. */
192async function readTtl($: EngineInterface, live: Live): Promise<void> {
193 const unset = () => undefined;
194 const [
195 force5m,
196 envTtl,
197 settingTtl,
198 enable1h,
199 idleSetting,
200 autoCompact,
201 disableAuto,
202 minTokens,
203 ] = await Promise.all([
204 $.env.get("FORCE_PROMPT_CACHING_5M").catch(unset),
205 $.env.get("CLAUDE_CODE_PROMPT_CACHE_TTL").catch(unset),
206 $.settings.read({ key: "promptCacheTtl" }).catch(unset),
207 $.env.get("ENABLE_PROMPT_CACHING_1H").catch(unset),
208 $.settings.read({ key: "idleCompaction" }).catch(unset),
209 $.settings.read({ key: "autoCompactEnabled" }).catch(unset),
210 $.env.get("DISABLE_AUTO_COMPACT").catch(unset),
211 $.env.get("CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS").catch(unset),
212 ]);
213 live.ttl = resolveTtl({
214 ...(force5m === undefined ? {} : { force5m }),
215 ...(envTtl === undefined ? {} : { envTtl }),
216 settingTtl,
217 ...(enable1h === undefined ? {} : { enable1h }),
218 optionMinutes: live.optionMinutes,
219 });
220 live.idle = {
221 setting: idleSetting,
222 autoCompact,
223 ...(disableAuto === undefined ? {} : { disableAuto }),
224 ...(minTokens === undefined ? {} : { minTokens }),
225 };
226}
227
228async function prune($: EngineInterface, live: Live): Promise<void> {
229 try {
230 const entries: [string, unknown][] = [];
231 for (const key of await $.store.keys()) {
232 if (key.startsWith(KEY_PREFIX))
233 entries.push([key, await $.store.get(key)]);
234 }
235 for (const key of keysToPrune(entries, live.key, await $.clock.now()))
236 await $.store.delete(key);
237 } catch {
238 $.ui.log("ember: could not prune its stats", { to: "debug" });
239 }
240}
241
242/** Queues the current aggregate; never awaited, so no hook waits for the store. */
243function save($: EngineInterface, live: Live): void {
244 if (live.key === undefined) return;
245 live.pending.set(live.key, live.agg);
246 flush($, live);
247}
248
249/** Writes the oldest pending key's latest aggregate, then the next, with one `store.set` in flight. */
250function flush($: EngineInterface, live: Live): void {
251 const first = live.pending.entries().next();
252 if (live.isWriting || first.done === true) return;
253 const [key, agg] = first.value;
254 live.pending.delete(key);
255 live.isWriting = true;
256 $.store
257 .set(key, agg)
258 .catch(() => $.ui.log("ember: could not save its stats", { to: "debug" }))
259 .finally(() => {
260 live.isWriting = false;
261 flush($, live);
262 });
263}
264
265/** Puts the TTL into the stored live state; saves only when it changed. */
266function applyTtl($: EngineInterface, live: Live): void {
267 const agg = withTtl(live.agg, ttlOf(live).minutes);
268 if (agg === live.agg) return;
269 live.agg = agg;
270 save($, live);
271}
272
273/** Sets the refresh state with the live fields the status line reads, then saves. */
274function setRefresh(
275 $: EngineInterface,
276 live: Live,
277 state: RefreshState,
278 nowMs: number,
279): void {
280 const agg = withRefreshLive(
281 {
282 ...live.agg,
283 updatedAt: Math.max(live.agg.updatedAt, nowMs),
284 refresh: state,
285 },
286 state.pause?.refreshes ?? 0,
287 state.saved,
288 );
289 live.agg = withTtl(agg, ttlOf(live, state).minutes);
290 save($, live);
291}
292
293/** Closes an open pause (an exit, or a return whose warmth is unknown). */
294function endPause(
295 $: EngineInterface,
296 live: Live,
297 end: End,
298 nowMs: number,
299): void {
300 const s = stateOf(live);
301 if (s.pause === undefined || live.key === undefined) return;
302 setRefresh(
303 $,
304 live,
305 close($, live, s, end, Math.max(s.pause.startMs, nowMs)),
306 nowMs,
307 );
308}
309
310async function record(
311 $: EngineInterface,
312 live: Live,
313 e: TurnStepInput,
314 result: TurnStepResult,
315 startedMs: number,
316 gen: number,
317): Promise<void> {
318 try {
319 const endedMs = await $.clock.now();
320 if (gen !== live.gen) return;
321 const before = live.agg;
322 const out = observe(live.agg, live.main, {
323 ...(e.agentId === undefined ? {} : { agentId: e.agentId }),
324 index: e.index,
325 startedMs,
326 endedMs,
327 usage: result.usage,
328 toolNames: result.toolUses.map((use) => use.name),
329 });
330 live.agg =
331 out.main === live.main
332 ? out.agg
333 : withLive(out.agg, out.main, ttlOf(live).minutes);
334 live.main = out.main;
335 // The first main step after the person came back tells whether the cache was still warm.
336 const p = stateOf(live).pause;
337 let state = stateOf(live);
338 if (e.agentId === undefined && p?.returnMs !== undefined) {
339 const read = result.usage?.cache_read_input_tokens;
340 const end: End =
341 read === undefined
342 ? "unknown"
343 : read >= COLD_READ_SHARE * p.prefix
344 ? "warm"
345 : "cold";
346 state = close($, live, state, end, endedMs);
347 live.agg = { ...live.agg, refresh: state };
348 }
349 live.agg = withSavedPct(
350 withRefreshLive(live.agg, state.pause?.refreshes ?? 0, state.saved),
351 );
352 if (live.agg !== before) {
353 save($, live);
354 $.ui.invalidate("ui.render");
355 }
356 } catch {
357 $.ui.log("ember: could not record a sample", { to: "debug" });
358 }
359}
360
361async function compacted(
362 $: EngineInterface,
363 live: Live,
364 isIdle: boolean,
365): Promise<void> {
366 try {
367 const nowMs = await $.clock.now();
368 const out = noteCompaction(live.agg, live.main, nowMs);
369 live.agg = out.agg;
370 live.main = out.main;
371 // A new transcript replaces the cached prefix: refreshing the old one would buy nothing. A pause left to
372 // idle compaction keeps its reason when that compaction ran, or after a return, so /ember-refresh can replay it.
373 const p = stateOf(live).pause;
374 if (
375 p !== undefined &&
376 (p.skip !== skipCode("idle compaction") ||
377 (!isIdle && p.returnMs === undefined))
378 )
379 live.agg = {
380 ...live.agg,
381 refresh: {
382 ...stateOf(live),
383 pause: { ...p, skip: skipCode("compacted") },
384 },
385 };
386 save($, live);
387 } catch {
388 $.ui.log("ember: could not record a compaction", { to: "debug" });
389 }
390}
391
392/** Records idle compaction seen in the open pause, there and store-wide. */
393async function idleCompacted(
394 $: EngineInterface,
395 live: Live,
396 pauseStartMs: number,
397): Promise<void> {
398 try {
399 const nowMs = await $.clock.now();
400 writeIdleSeen($, live, nowMs);
401 const s = stateOf(live);
402 if (s.pause?.startMs === pauseStartMs)
403 setRefresh(
404 $,
405 live,
406 { ...s, pause: { ...s.pause, idleCompactMs: nowMs } },
407 nowMs,
408 );
409 } catch {
410 $.ui.log("ember: could not record an idle compaction", { to: "debug" });
411 }
412}
413
414/** Opens the pause after a main turn ends; a return the turn's steps did not settle closes as unknown first. */
415function turnEnded(
416 $: EngineInterface,
417 live: Live,
418 isAborted: boolean,
419 nowMs: number,
420): void {
421 let s = stateOf(live);
422 if (s.pause !== undefined) s = close($, live, s, "unknown", nowMs);
423 const { lastStartMs, lastEndMs, lastPrefix = 0 } = live.main;
424 if (lastStartMs !== undefined && lastEndMs !== undefined) {
425 const ttl = ttlOf(live, s);
426 const pause = openPause(
427 { startMs: lastStartMs, endMs: lastEndMs, prefix: lastPrefix },
428 {
429 config: live.config,
430 ttlMinutes: ttl.minutes,
431 source: ttl.source,
432 isInteractive: live.isInteractive,
433 isAborted,
434 isIdleCompaction:
435 live.idleSeenMs !== undefined &&
436 idleCompactionApplies(
437 live.idle,
438 ttl.minutes,
439 live.isInteractive,
440 lastPrefix,
441 ),
442 isCompactPending: live.main.isCompactPending,
443 isPaused: s.isPaused,
444 },
445 );
446 s = { ...s, pause };
447 }
448 setRefresh($, live, s, nowMs);
449}
450
451/** Starts a refresh when the open pause asks for one and the main thread is idle: no turn, no request, no fork. */
452function maybeRefresh($: EngineInterface, live: Live, nowMs: number): void {
453 const s = stateOf(live);
454 const p = s.pause;
455 if (
456 p === undefined ||
457 live.key === undefined ||
458 live.refreshing !== undefined ||
459 live.turnId !== undefined ||
460 live.isInFlight ||
461 live.compacting > 0
462 )
463 return;
464 const plan = planRefresh(p, nowMs);
465 if (plan.kind === "stop")
466 setRefresh(
467 $,
468 live,
469 { ...s, pause: { ...p, skip: skipCode(plan.skip) } },
470 nowMs,
471 );
472 else if (plan.kind === "fire") void forkRefresh($, live, p, nowMs, false);
473 else if (plan.kind === "rescue") {
474 // Idle compaction did not come in time: the evidence is stale, and the cache is refreshed before it expires.
475 writeIdleSeen($, live, undefined);
476 setRefresh($, live, { ...s, pause: plan.pause }, nowMs);
477 void forkRefresh($, live, plan.pause, nowMs, false);
478 }
479}
480
481function probeText(j: Judged, prefix: number, run: Fired): string {
482 if (j.outcome === "none") return "ember probe: nothing to fork yet";
483 const steps = `fork steps seen ${run.steps} (${run.agentSteps} with an agent id)`;
484 if (j.outcome === "refused")
485 return `ember probe: the fork was refused · ${steps}`;
486 return `ember probe: ${j.outcome} · read ${percent(j.read, prefix)} of ${tokens(prefix)} · created ${tokens(j.creation)} · ${steps}`;
487}
488
489/** Sends one fork over the main transcript and folds its usage in. It cannot be aborted, so it is never started mid-turn. */
490async function forkRefresh(
491 $: EngineInterface,
492 live: Live,
493 pause: Pause,
494 firedMs: number,
495 isProbe: boolean,
496): Promise<void> {
497 const gen = live.gen;
498 const run: Fired = { pause, firedMs, steps: 0, agentSteps: 0, isProbe };
499 live.refreshing = run;
500 let result: ModelForkResult | undefined;
501 try {
502 result = await $.model.fork({ prompt: REFRESH_PROMPT });
503 } catch {
504 result = undefined;
505 } finally {
506 if (live.refreshing === run) live.refreshing = undefined;
507 }
508 if (gen !== live.gen) return;
509 try {
510 const s = stateOf(live);
511 const isCurrent =
512 s.pause !== undefined && s.pause.startMs === pause.startMs;
513 const j = judgeFork(result, pause.prefix);
514 const out = applyRefresh(s, run, j, isCurrent);
515 if (out.isHit && isCurrent && live.main.lastStartMs !== undefined) {
516 // The read restarted the entry's TTL: the countdown and the next TTL evidence run from here.
517 live.main = {
518 ...live.main,
519 lastStartMs: Math.max(live.main.lastStartMs, firedMs),
520 };
521 live.agg = withLive(live.agg, live.main, ttlOf(live, out.state).minutes);
522 }
523 setRefresh($, live, out.state, Math.max(live.nowMs, firedMs));
524 $.ui.invalidate("ui.render");
525 if (out.isPausedNow) {
526 $.ui.log(
527 "ember: cache refresh paused for this session after 2 misses in a row",
528 { to: "debug" },
529 );
530 $.ui.toast(
531 "ember: cache refresh paused after 2 misses in a row. Run /ember-refresh for details.",
532 );
533 }
534 if (isProbe) $.ui.toast(probeText(j, pause.prefix, run));
535 } catch {
536 $.ui.log("ember: could not record a refresh", { to: "debug" });
537 }
538}
539
540function viewOf(live: Live, nowMs: number): StatusView {
541 return {
542 nowMs,
543 ttlMs: ttlOf(live).minutes * 60_000,
544 isInFlight: live.isInFlight,
545 main: live.main,
546 session: live.agg,
547 };
548}
549
550async function tick($: EngineInterface, live: Live): Promise<void> {
551 try {
552 const nowMs = await $.clock.now();
553 live.nowMs = nowMs;
554 maybeRefresh($, live, nowMs);
555 const view = viewOf(live, nowMs);
556 // A terminal status line command can read the stored live state; VS Code runs none, so this stands in there.
557 const isVscode = (await $.session.surfaces()).includes("vscode");
558 const text = isVscode ? statusText(view) : undefined;
559 if (text !== live.shown) {
560 live.shown = text;
561 $.ui.status(text);
562 }
563 // While a refresh will run before expiry, the cache is not going cold: no warning. Read after the await,
564 // so a refresh that ended meanwhile counts.
565 const warn = isRefreshPlanned(stateOf(live).pause)
566 ? undefined
567 : warning(viewOf(live, nowMs), live.warnedStartMs);
568 if (warn !== undefined) {
569 live.warnedStartMs = live.main.lastStartMs;
570 $.ui.toast(warn);
571 }
572 } catch {
573 // The next tick tries again.
574 }
575}
576
577/** Reads the other sessions' aggregates, then redraws; a failed read leaves none, so the pane shows this session. */
578async function loadOthers($: EngineInterface, live: Live): Promise<void> {
579 const gen = live.gen;
580 let others: Agg[] = [];
581 try {
582 for (const key of await $.store.keys()) {
583 if (!key.startsWith(KEY_PREFIX) || key === live.key) continue;
584 const agg = parseAgg(await $.store.get(key));
585 if (agg !== undefined) others.push(agg);
586 }
587 } catch {
588 others = [];
589 $.ui.log("ember: could not read other sessions", { to: "debug" });
590 }
591 if (gen !== live.gen) return;
592 live.others = others;
593 $.ui.invalidate("ui.render");
594}
595
596const analysisOf = (live: Live) =>
597 analyze({
598 config: live.config,
599 ttl: ttlOf(live),
600 ...(live.idleSeenMs === undefined ? {} : { idleSeenMs: live.idleSeenMs }),
601 sessions: [
602 { state: stateOf(live), updatedAt: live.agg.updatedAt, isCurrent: true },
603 ...live.others.map((a) => ({
604 state: a.refresh ?? emptyRefresh,
605 updatedAt: a.updatedAt,
606 isCurrent: false,
607 })),
608 ],
609 });
610
611export const register: Register = (on, options) => {
612 const optionMinutes = validTtlMinutes(options.ttlMinutes);
613 const live: Live = {
614 optionMinutes,
615 ttl: { minutes: optionMinutes, source: "ttlMinutes option" },
616 agg: emptyAgg(0),
617 main: emptyMain,
618 gen: 0,
619 isInFlight: false,
620 shown: null,
621 pending: new Map(),
622 isWriting: false,
623 others: [],
624 tab: "session",
625 config: refreshConfig(options),
626 idle: {},
627 isInteractive: false,
628 compacting: 0,
629 nowMs: 0,
630 };
631
632 on("session.start", async ($, e, next) => {
633 live.isInteractive = e.isInteractive;
634 void readIdleSeen($, live);
635 await Promise.all([bind($, live), readTtl($, live)]);
636 applyTtl($, live);
637 $.clock.after(0, () => void prune($, live));
638 live.timer?.cancel();
639 live.timer = $.clock.every(1000, () => void tick($, live));
640 const taken = (name: string) => () =>
641 $.ui.log(`ember: /${name} is taken; it is not available`, {
642 to: "debug",
643 });
644 await $.command
645 .register({
646 name: "ember",
647 description:
648 "Open a pane with prompt cache hit ratios by gap type, this session and all sessions",
649 immediate: true,
650 })
651 .catch(taken("ember"));
652 await $.command
653 .register({
654 name: "ember-refresh",
655 description:
656 "Open a pane that analyzes the cache refresh log and recommends a lead time or off",
657 immediate: true,
658 })
659 .catch(taken("ember-refresh"));
660 await $.command
661 .register({
662 name: "ember-probe",
663 description:
664 "Spends usage: refresh the prompt cache once now and show what the fork read",
665 immediate: true,
666 })
667 .catch(taken("ember-probe"));
668 return next(e);
669 });
670
671 // session.start does not fire after /clear, /resume or /branch; this does.
672 on(
673 "classic.SessionStart",
674 { source: ["clear", "resume", "fork"] },
675 async ($, e, next) => {
676 void readIdleSeen($, live);
677 await Promise.all([bind($, live), readTtl($, live)]);
678 applyTtl($, live);
679 return next(e);
680 },
681 ).catch((_, e, next) => next(e));
682
683 on("session.end", async ($, e, next) => {
684 try {
685 endPause($, live, "exit", await $.clock.now());
686 } catch {
687 $.ui.log("ember: could not close the idle pause", { to: "debug" });
688 }
689 return next(e);
690 });
691
692 on("turn.start", async ($, e, next) => {
693 // ember's own fork, if it raises a turn of its own, is not the person coming back.
694 if (
695 e.agentId === undefined &&
696 !(live.refreshing !== undefined && e.text === REFRESH_PROMPT)
697 ) {
698 live.turnId = e.turnId;
699 // Another session may have seen or cleared idle compaction; read before this turn's pause opens.
700 void readIdleSeen($, live);
701 try {
702 const nowMs = await $.clock.now();
703 const s = stateOf(live);
704 if (s.pause !== undefined && s.pause.returnMs === undefined)
705 live.agg = {
706 ...live.agg,
707 refresh: { ...s, pause: { ...s.pause, returnMs: nowMs } },
708 };
709 } catch {
710 $.ui.log("ember: could not note a return", { to: "debug" });
711 }
712 }
713 return next(e);
714 });
715
716 on("turn.complete", async ($, e, next) => {
717 if (e.agentId === undefined && e.turnId === live.turnId) {
718 live.turnId = undefined;
719 try {
720 turnEnded($, live, e.isAborted, await $.clock.now());
721 } catch {
722 $.ui.log("ember: could not open an idle pause", { to: "debug" });
723 }
724 }
725 return next(e);
726 });
727
728 on("turn.step", async function* ($, e, next) {
729 const run = live.refreshing;
730 // A step outside the running main turn while ember's fork runs is the fork's: counted, never measured.
731 if (run !== undefined && e.turnId !== live.turnId) {
732 run.steps++;
733 if (e.agentId !== undefined) run.agentSteps++;
734 return yield* next(e);
735 }
736 const gen = live.gen;
737 const startedMs = await $.clock.now();
738 const isMain = e.agentId === undefined;
739 if (isMain) live.isInFlight = true;
740 try {
741 const result = yield* next(e);
742 if (startedMs !== undefined)
743 await record($, live, e, result, startedMs, gen);
744 return result;
745 } finally {
746 if (isMain) live.isInFlight = false;
747 }
748 });
749
750 on("session.compact", async ($, e, next) => {
751 const p = stateOf(live).pause;
752 const isIdle = isIdleCompactionEvidence(
753 e,
754 p,
755 live.turnId !== undefined || live.isInFlight,
756 );
757 const isMain = e.agentId === undefined;
758 if (isMain) live.compacting++;
759 try {
760 const result = await next(e);
761 // Only a main-thread compaction that installed a new transcript resets the main cache prefix.
762 if (isMain && e.trigger !== "precompute" && result.skip === undefined)
763 await compacted($, live, isIdle);
764 if (isIdle && p !== undefined && result.skip === undefined)
765 await idleCompacted($, live, p.startMs);
766 return result;
767 } finally {
768 if (isMain) live.compacting--;
769 }
770 }).catch((_, e, next) => next(e));
771
772 on("ui.render", { component: "Pane" }, ($, e, next) => {
773 if (e.requestId !== PANE && e.requestId !== REFRESH_PANE) return next(e);
774 const id = e.requestId;
775 const onClose = () => {
776 $.ui
777 .close({ id })
778 .catch(() =>
779 $.ui.log("ember: could not close its pane", { to: "debug" }),
780 );
781 };
782 if (id === REFRESH_PANE)
783 return pane(
784 { title: "ember refresh", sections: analysisOf(live), onClose },
785 $.ui.resolve(e),
786 );
787 const hasOthers = live.others.length > 0;
788 const isAll = hasOthers && live.tab === "all";
789 const pick = (tab: Tab) => {
790 live.tab = tab;
791 $.ui.invalidate("ui.render");
792 if (tab === "all") void loadOthers($, live);
793 };
794 return pane(
795 {
796 title: "ember",
797 tabs: hasOthers
798 ? [
799 {
800 key: "tab-session",
801 label: "This session",
802 hotkey: "1",
803 isActive: !isAll,
804 onPress: () => pick("session"),
805 },
806 {
807 key: "tab-all",
808 label: "All sessions",
809 hotkey: "2",
810 isActive: isAll,
811 onPress: () => pick("all"),
812 },
813 ]
814 : [],
815 sections: report(
816 isAll ? mergeAggs([live.agg, ...live.others]) : live.agg,
817 ttlOf(live),
818 ),
819 onClose,
820 },
821 $.ui.resolve(e),
822 );
823 });
824
825 on("command.run", { command: "ember" }, async ($) => {
826 void loadOthers($, live);
827 try {
828 await $.ui.open({
829 id: PANE,
830 title: "ember",
831 focus: true,
832 closeOnEscape: true,
833 // The tallest narrow drawing: header, status and both tables stacked; inline the frame fits a shorter tree.
834 rows: 26,
835 });
836 } catch {
837 $.ui.log("ember: could not open its pane", { to: "debug" });
838 }
839 return {};
840 });
841
842 on("command.run", { command: "ember-refresh" }, async ($) => {
843 void loadOthers($, live);
844 try {
845 await $.ui.open({
846 id: REFRESH_PANE,
847 title: "ember refresh",
848 focus: true,
849 closeOnEscape: true,
850 // Its report runs long (survival, lead options, idle compaction); the layout caps the ask at what it spares.
851 rows: 40,
852 });
853 } catch {
854 $.ui.log("ember: could not open its refresh pane", { to: "debug" });
855 }
856 return {};
857 });
858
859 // The live probe: one refresh now, on the same path as the timed ones. It spends usage, so it runs only when asked.
860 on("command.run", { command: "ember-probe" }, async ($) => {
861 const p = stateOf(live).pause;
862 const why =
863 p === undefined || p.returnMs !== undefined
864 ? "no idle pause yet; finish a turn first"
865 : p.prefix <= 0
866 ? "the last request cached nothing"
867 : live.refreshing !== undefined
868 ? "a refresh is running"
869 : live.turnId !== undefined || live.isInFlight
870 ? "a turn is running"
871 : undefined;
872 if (why !== undefined || p === undefined) {
873 $.ui.toast(`ember probe: ${why ?? ""}`);
874 return {};
875 }
876 const nowMs = await $.clock.now();
877 $.clock.after(0, () => void forkRefresh($, live, p, nowMs, true));
878 return {};
879 });
880};
881hooks/analysis.ts 762 lines1import type {
2 PauseRec,
3 RefreshConfig,
4 RefreshRec,
5 RefreshState,
6} from "./refresh.ts";
7import {
8 decodeRec,
9 ENDS,
10 IDLE_COMPACTION_TTL_MINUTES,
11 IDLE_COMPACTION_TTL_SHARE,
12 leadMs,
13 maxRefreshes,
14 OUTCOMES,
15 READ_WEIGHT,
16 refreshesAt,
17 SHORT_TTL_MINUTES,
18 SKIPS,
19 writeWeight,
20} from "./refresh.ts";
21import type { Block, Cell, Line, Section, Table, Tone } from "./sections.ts";
22import { bold, dim, line, pick, plain } from "./sections.ts";
23import { BANDS, band, duration, percent, tokens, ttlLine } from "./stats.ts";
24
25const MIN = 60_000;
26/** Below this many returned pauses the survival estimate near the TTL rests on a handful of points. */
27export const MIN_RETURNS = 20;
28/** A lead must save at least this many tokens per pause more than the current one to be recommended over it. */
29export const MIN_GAIN_TOKENS = 500;
30const LEAD_CANDIDATES_S = [60, 120, 300, 600];
31const KM_POINTS_MS = [5, 30, 55, 60, 120, 480].map((m) => m * MIN);
32
33/** A lead in ms, or off. */
34export type Policy = number | "off";
35
36/** One pause as the replay sees it: the cache age when it ended, and whether the person came back then. */
37export type Trace = {
38 ageMs: number;
39 isReturn: boolean;
40 ttlMinutes: number;
41 prefix: number;
42 isIdleCompacted?: boolean;
43};
44
45/**
46 * A pause's cache cost under a policy, in prefix units: 0.1 per refresh, then 0.1 for a warm return or the
47 * write weight for a cold one; an exit pays no return. Refresh k runs at k x (TTL - lead), up to the cap.
48 */
49export function pauseCost(
50 policy: Policy,
51 ttlMinutes: number,
52 endAgeMs: number,
53 isReturn: boolean,
54): number {
55 const ttlMs = ttlMinutes * MIN;
56 let cost = 0;
57 let warmUntil = ttlMs;
58 if (policy !== "off") {
59 const stepMs = ttlMs - policy;
60 for (
61 let k = 1;
62 k <= maxRefreshes(ttlMinutes) && k * stepMs < endAgeMs;
63 k++
64 ) {
65 cost += READ_WEIGHT;
66 warmUntil = k * stepMs + ttlMs;
67 }
68 }
69 if (!isReturn) return cost;
70 return cost + (endAgeMs < warmUntil ? READ_WEIGHT : writeWeight(ttlMinutes));
71}
72
73/** The offline optimum, knowing the end: nothing for an exit, else the cheaper of refreshing at each expiry or going cold. */
74export function optimumCost(
75 ttlMinutes: number,
76 endAgeMs: number,
77 isReturn: boolean,
78): number {
79 if (!isReturn) return 0;
80 const refreshes = Math.floor(endAgeMs / (ttlMinutes * MIN));
81 return Math.min(
82 READ_WEIGHT * (refreshes + 1),
83 refreshes === 0 ? Infinity : writeWeight(ttlMinutes),
84 );
85}
86
87/** Total cost in tokens of a trace set: each pause's prefix-unit cost times its prefix. */
88export function replay(
89 traces: readonly Trace[],
90 policyOf: (t: Trace) => Policy,
91): number {
92 return traces.reduce(
93 (sum, t) =>
94 sum +
95 pauseCost(policyOf(t), t.ttlMinutes, t.ageMs, t.isReturn) * t.prefix,
96 0,
97 );
98}
99
100export const optimum = (traces: readonly Trace[]) =>
101 traces.reduce(
102 (sum, t) => sum + optimumCost(t.ttlMinutes, t.ageMs, t.isReturn) * t.prefix,
103 0,
104 );
105
106export type KmPoint = { tMs: number; s: number };
107
108/** Kaplan-Meier survival of "not yet returned" over cache age; exits are censored. Ties put returns before exits. */
109export function kaplanMeier(
110 traces: readonly Pick<Trace, "ageMs" | "isReturn">[],
111): KmPoint[] {
112 const sorted = [...traces].sort(
113 (a, b) => a.ageMs - b.ageMs || Number(b.isReturn) - Number(a.isReturn),
114 );
115 const out: KmPoint[] = [];
116 let atRisk = sorted.length;
117 let s = 1;
118 for (let i = 0; i < sorted.length; ) {
119 const t = sorted[i]?.ageMs ?? 0;
120 let events = 0;
121 let leaving = 0;
122 while (i < sorted.length && sorted[i]?.ageMs === t) {
123 if (sorted[i]?.isReturn === true) events++;
124 leaving++;
125 i++;
126 }
127 if (events > 0) {
128 s *= 1 - events / atRisk;
129 out.push({ tMs: t, s });
130 }
131 atRisk -= leaving;
132 }
133 return out;
134}
135
136export const survivalAt = (km: readonly KmPoint[], tMs: number) =>
137 km.filter((p) => p.tMs <= tMs).at(-1)?.s ?? 1;
138
139/**
140 * Expected cost per pause in prefix units under the survival curve: each return mass at its age, and the mass
141 * left past the last return charged every refresh the policy would make with no return (the worst case).
142 */
143export function expectedCost(
144 km: readonly KmPoint[],
145 policy: Policy,
146 ttlMinutes: number,
147): number {
148 let prev = 1;
149 let sum = 0;
150 for (const p of km) {
151 sum += (prev - p.s) * pauseCost(policy, ttlMinutes, p.tMs, true);
152 prev = p.s;
153 }
154 return sum + prev * pauseCost(policy, ttlMinutes, Infinity, false);
155}
156
157export type SessionLog = {
158 state: RefreshState;
159 updatedAt: number;
160 isCurrent: boolean;
161};
162
163type Decoded = {
164 pauses: PauseRec[];
165 refreshes: RefreshRec[];
166 open: (Trace & { skip: number })[];
167};
168
169function decode(sessions: readonly SessionLog[]): Decoded {
170 const out: Decoded = { pauses: [], refreshes: [], open: [] };
171 for (const s of sessions) {
172 for (const raw of s.state.log) {
173 const r = decodeRec(raw);
174 if (r?.kind === "pause") out.pauses.push(r);
175 else if (r?.kind === "refresh") out.refreshes.push(r);
176 }
177 // An open pause of another session never closed (the process ended): an exit at its last write.
178 const p = s.state.pause;
179 if (!s.isCurrent && p !== undefined)
180 out.open.push({
181 ageMs: Math.max(0, s.updatedAt - p.anchorMs),
182 isReturn: false,
183 ttlMinutes: p.ttlMinutes,
184 prefix: p.prefix,
185 skip: p.skip,
186 isIdleCompacted: p.idleCompactMs !== undefined,
187 });
188 }
189 return out;
190}
191
192const EXIT = ENDS.indexOf("exit");
193const IDLE = SKIPS.indexOf("idle compaction");
194
195export const tracesOf = (
196 pauses: readonly PauseRec[],
197 open: readonly Trace[],
198): Trace[] => [
199 ...pauses.map((p) => ({
200 ageMs: p.ageMs,
201 isReturn: p.end !== EXIT,
202 ttlMinutes: p.ttlMinutes,
203 prefix: p.prefix,
204 isIdleCompacted: p.idleCompacted === 1,
205 })),
206 ...open,
207];
208
209/** The policy the defaults run at a TTL: the automatic lead, or off at a short TTL when opted out. */
210export const defaultPolicy = (
211 ttlMinutes: number,
212 isShortTtlOn = true,
213): Policy =>
214 ttlMinutes > SHORT_TTL_MINUTES || isShortTtlOn
215 ? leadMs(ttlMinutes, 0)
216 : "off";
217
218const signed = (n: number) =>
219 n < 0 ? `-${tokens(Math.round(-n))}` : tokens(Math.round(n));
220const leadName = (ms: number) => duration(ms);
221
222const advice = (text: string) =>
223 pick(bold("Recommendation:"), plain(` ${text}`));
224/** A tone for a count that is only news when above 0. */
225const toneIf = (n: number, tone: Tone) => (n > 0 ? tone : undefined);
226const signTone = (n: number): Tone | undefined =>
227 n > 0 ? "good" : n < 0 ? "bad" : undefined;
228const signedCell = (n: number): Cell => {
229 const tone = signTone(Math.round(n));
230 return tone === undefined ? { text: signed(n) } : { text: signed(n), tone };
231};
232
233/** The report's first rows; when the short-TTL rule stops refresh it says so, not `on`. */
234export function statusLines(
235 config: RefreshConfig,
236 ttl: { minutes: number; source: string },
237): Line[] {
238 const isStopped = config.isOn && !refreshesAt(config, ttl.minutes);
239 return [
240 {
241 label: "Refresh",
242 runs:
243 config.isOn && !isStopped
244 ? [bold("on", "good")]
245 : [
246 bold("off", "warn"),
247 ...(isStopped
248 ? [dim(` at TTL ${ttl.minutes}m (refreshShortTtl: false)`)]
249 : []),
250 ],
251 },
252 {
253 label: "Lead",
254 runs: [
255 bold(leadName(leadMs(ttl.minutes, config.leadSeconds))),
256 ...(config.leadSeconds === 0 ? [dim(" (auto)")] : []),
257 ],
258 },
259 ttlLine(ttl),
260 ];
261}
262
263/**
264 * The pauses ember left to idle compaction: a warning for the report's top, and the section's blocks. X, Y and Z
265 * cover only the long ones where it was recorded running; a long one with none recorded is named in the warning
266 * instead (ember now refreshes those), and with no long pause recorded there is no section. With
267 * `"idleCompaction": false` (the full conversation kept): X, refresh at the configured lead, and Y, no refresh (one cold rewrite at return). Idle compaction on the same pauses costs
268 * at least Z, one cache read of the conversation per pause that reaches its time; its summary's output and the
269 * write at return report no usage ember can see, so X - Z bounds the extra cost of the full conversation from above.
270 * X, Y and Z cover only the pauses that reach idle compaction's time: a shorter pause costs the same under both settings.
271 */
272export function idleCompactionBlocks(
273 traces: readonly Trace[],
274 config: RefreshConfig,
275): { warning: Block[]; blocks: Block[] } {
276 const returns = traces.filter((t) => t.isReturn).length;
277 const runsAt = duration(
278 IDLE_COMPACTION_TTL_SHARE * IDLE_COMPACTION_TTL_MINUTES * MIN,
279 );
280 const long = traces.filter(
281 (t) => t.ageMs >= IDLE_COMPACTION_TTL_SHARE * t.ttlMinutes * MIN,
282 );
283 const compacted = long.filter((t) => t.isIdleCompacted === true);
284 const missed = long.length - compacted.length;
285 const top: Block[] =
286 missed > 0
287 ? [
288 line({
289 text: `${missed} skipped pauses lasted past ${runsAt} with no idle compaction recorded; ember now refreshes such pauses until it sees idle compaction run.`,
290 tone: "warn",
291 }),
292 ]
293 : [];
294 if (traces.length === 0 || (returns >= MIN_RETURNS && compacted.length === 0))
295 return { warning: top, blocks: [] };
296 if (returns < MIN_RETURNS)
297 return {
298 warning: top,
299 blocks: [
300 pick(
301 plain(
302 `Not enough data for an idle compaction recommendation: ${returns} returned pauses, ${MIN_RETURNS} needed. Keep idle compaction on.`,
303 ),
304 ),
305 ],
306 };
307 const scope = [
308 line(
309 bold(`${compacted.length} of ${traces.length} pauses`),
310 plain(
311 ` lasted past ${runsAt} with idle compaction recorded; shorter pauses cost the same either way.`,
312 ),
313 ),
314 ];
315 const z = compacted.reduce((sum, t) => sum + READ_WEIGHT * t.prefix, 0);
316 const y = replay(compacted, () => "off");
317 const zLine: Block = {
318 line: {
319 runs: [
320 plain("Idle compaction on the same pauses: at least "),
321 bold(`${tokens(Math.round(z))} tokens`),
322 plain(
323 `, one cache read of the conversation for each of the ${compacted.length} pauses past ${runsAt}.`,
324 ),
325 ],
326 note: "Not measured: the summary's output and its write at return.",
327 },
328 };
329 const lost =
330 "Not priced: what a summary loses (the detail of the conversation).";
331 if (!config.isOn) {
332 const blocks = [
333 ...scope,
334 line(
335 plain(
336 'Refresh is off, so with "idleCompaction": false each of these pauses that outlasts the cache ends in a cold rewrite: replay ',
337 ),
338 bold(`${tokens(Math.round(y))} tokens`),
339 plain("."),
340 ),
341 zLine,
342 line(dim(lost)),
343 advice("keep idle compaction on."),
344 ];
345 return { warning: top, blocks };
346 }
347 const lead = leadMs(IDLE_COMPACTION_TTL_MINUTES, config.leadSeconds);
348 const x = replay(compacted, (t) => leadMs(t.ttlMinutes, config.leadSeconds));
349 const perPause = tokens(Math.round((x - z) / compacted.length));
350 const keepRefresh =
351 y > x
352 ? ` If you set "idleCompaction": false, keep refresh on: it costs ${tokens(Math.round(y - x))} tokens less than no refresh.`
353 : "";
354 const blocks = [
355 ...scope,
356 line(
357 plain(
358 `With "idleCompaction": false, which keeps the full conversation: refresh at lead ${leadName(lead)}${lead === leadMs(IDLE_COMPACTION_TTL_MINUTES, 0) ? " (auto)" : ""} `,
359 ),
360 bold(tokens(Math.round(x))),
361 plain(" · no refresh "),
362 bold(tokens(Math.round(y))),
363 plain(" tokens."),
364 ),
365 zLine,
366 x > z
367 ? {
368 line: {
369 runs: [
370 plain("Keeping the full conversation costs at most "),
371 bold(`${tokens(Math.round(x - z))} tokens`, "bad"),
372 plain(` more (${perPause} per pause).`),
373 ],
374 note: lost,
375 },
376 }
377 : {
378 line: {
379 runs: [
380 plain(
381 "Keeping the full conversation cost no more than idle compaction's own reads on these pauses.",
382 ),
383 ],
384 note: lost,
385 },
386 },
387 x > z
388 ? advice(
389 `keep idle compaction on unless the full conversation is worth about ${perPause} tokens per pause to you.${keepRefresh}`,
390 )
391 : advice(
392 `"idleCompaction": false keeps the full conversation for no more tokens than idle compaction on these pauses; set it in ~/.claude/settings.json if you want that.${keepRefresh}`,
393 ),
394 ];
395 return { warning: top, blocks };
396}
397
398export type AnalysisView = {
399 config: RefreshConfig;
400 ttl: { minutes: number; source: string };
401 sessions: readonly SessionLog[];
402 idleSeenMs?: number;
403};
404
405/**
406 * At the 1 h TTL with refresh on: whether ember has seen idle compaction run, which decides if it skips long
407 * conversations, and how many pauses it left to idle compaction; not seen, those were skipped before.
408 */
409function idleSeenLine(v: AnalysisView, skipped: number): Line | undefined {
410 if (!v.config.isOn || v.ttl.minutes !== IDLE_COMPACTION_TTL_MINUTES)
411 return undefined;
412 const isSeen = v.idleSeenMs !== undefined;
413 return {
414 label: "Idle compaction",
415 runs: [
416 ...(v.idleSeenMs === undefined
417 ? [bold("not seen"), plain(", ember refreshes long conversations too")]
418 : [
419 bold(`seen ${new Date(v.idleSeenMs).toISOString().slice(0, 10)}`),
420 plain(", ember skips the pauses it applies to"),
421 ]),
422 ...(skipped > 0
423 ? [
424 plain(" · "),
425 bold(String(skipped)),
426 plain(` pauses skipped${isSeen ? "" : " earlier"}`),
427 ]
428 : []),
429 ],
430 };
431}
432
433/**
434 * The `/ember-refresh` report as sections: an untitled one first with the recommendation and any idle compaction
435 * warning, then status, refreshes by age, savings, survival, lead options, idle compaction.
436 */
437export function analyze(v: AnalysisView): Section[] {
438 const { pauses, refreshes, open } = decode(v.sessions);
439 const traces = tracesOf(pauses, open);
440 const skipped = tracesOf(
441 pauses.filter((p) => p.skip === IDLE),
442 open.filter((t) => t.skip === IDLE),
443 );
444 const idle = idleCompactionBlocks(skipped, v.config);
445 const idleSection: Section[] =
446 idle.blocks.length > 0
447 ? [{ title: "Idle compaction", blocks: idle.blocks }]
448 : [];
449 const lead = (advised: Block): Section => ({
450 blocks: [advised, ...idle.warning],
451 });
452 const returns = traces.filter((t) => t.isReturn).length;
453 const ttl = v.ttl.minutes;
454 const currentLead = leadMs(ttl, v.config.leadSeconds);
455 const seen = idleSeenLine(v, skipped.length);
456 const status: Section = {
457 title: "Status",
458 blocks: [
459 ...statusLines(v.config, v.ttl),
460 ...(seen === undefined ? [] : [seen]),
461 {
462 label: "Sample",
463 runs: [
464 bold(`${traces.length} pauses`),
465 plain(` (${returns} returned, ${traces.length - returns} exits) · `),
466 bold(`${refreshes.length} refreshes`),
467 plain(" · "),
468 bold(`${v.sessions.length} sessions`),
469 ],
470 },
471 ].map((l) => ({ line: l })),
472 };
473
474 const count = (o: string) =>
475 refreshes.filter((r) => r.outcome === OUTCOMES.indexOf(o as never)).length;
476 const hits = count("hit");
477 const misses = count("miss");
478 const refused = count("refused");
479 const outcomes: Block[] = [
480 line(
481 bold(String(hits), toneIf(hits, "good")),
482 plain(" hit · "),
483 bold(String(misses), toneIf(misses, "bad")),
484 plain(" miss · "),
485 bold(String(refused), toneIf(refused, "warn")),
486 plain(" refused · hit rate "),
487 bold(percent(hits, hits + misses)),
488 ),
489 ];
490
491 const judged = refreshes.filter(
492 (r) => r.outcome !== OUTCOMES.indexOf("refused"),
493 );
494 if (judged.length > 0) {
495 const rows = BANDS.flatMap((b) => {
496 const inBand = judged.filter((r) => band(r.ageMs) === b);
497 const m = inBand.filter(
498 (r) => r.outcome === OUTCOMES.indexOf("miss"),
499 ).length;
500 const h = inBand.length - m;
501 return inBand.length > 0
502 ? [
503 {
504 cells: [
505 { text: b },
506 h > 0
507 ? { text: String(h), tone: "good" as const }
508 : { text: "0" },
509 m > 0
510 ? { text: String(m), tone: "bad" as const }
511 : { text: "0" },
512 ],
513 },
514 ]
515 : [];
516 });
517 outcomes.push({ table: { head: ["Refresh age", "Hit", "Miss"], rows } });
518 const firstMiss = Math.min(
519 ...judged
520 .filter(
521 (r) =>
522 r.outcome === OUTCOMES.indexOf("miss") && r.read < 0.5 * r.prefix,
523 )
524 .map((r) => r.ageMs),
525 );
526 if (Number.isFinite(firstMiss) && firstMiss < ttl * MIN) {
527 outcomes.push(
528 line({
529 text: `A refresh found the cache gone at ${duration(firstMiss)}, under the ${ttl}m TTL: the real TTL looks shorter (usage credits or an API key give 5m). Check ttlMinutes and promptCacheTtl.`,
530 tone: "warn",
531 }),
532 );
533 }
534 }
535
536 // The running totals outlive the ring buffer; an open pause has paid its refreshes but not settled yet.
537 const saved = v.sessions.reduce(
538 (sum, x) => sum + x.state.saved - (x.state.pause?.cost ?? 0),
539 0,
540 );
541 const avoided = pauses.filter(
542 (p) =>
543 p.end === ENDS.indexOf("warm") &&
544 p.refreshes > 0 &&
545 p.ageMs >= p.ttlMinutes * MIN,
546 ).length;
547 const savings: Block[] = [
548 {
549 line: {
550 label: "Net saved",
551 runs: [
552 bold(`~${signed(saved)} tokens`, signTone(Math.round(saved))),
553 plain(` (${avoided} cold rewrites avoided)`),
554 ],
555 note: "API-price estimate in input-token units: Anthropic does not publish how fork cache reads count toward plan limits.",
556 },
557 },
558 ];
559 // The Status row counts the idle compaction skips when it shows.
560 const skips = SKIPS.map(
561 (name, i) => [name, pauses.filter((p) => p.skip === i).length] as const,
562 ).filter(
563 ([name, n]) =>
564 n > 0 &&
565 name !== "none" &&
566 (name !== "idle compaction" || seen === undefined),
567 );
568 if (skips.length > 0)
569 savings.push({
570 line: {
571 label: "Not refreshed",
572 runs: skips.flatMap(([name, n], i) => [
573 plain(`${i > 0 ? " · " : ""}${name} `),
574 bold(String(n)),
575 ]),
576 },
577 });
578 const head: Section[] = [
579 status,
580 { title: "Refreshes", blocks: outcomes },
581 { title: "Savings", blocks: savings },
582 ];
583
584 if (returns < MIN_RETURNS) {
585 return [
586 lead(
587 pick(
588 plain(
589 `Not enough data for a recommendation: ${returns} returned pauses, ${MIN_RETURNS} needed. Keep the defaults.`,
590 ),
591 ),
592 ),
593 ...head,
594 ...idleSection,
595 ];
596 }
597
598 const km = kaplanMeier(traces);
599 const survival: Section = {
600 title: "Idle time (survival)",
601 blocks: [
602 line(dim("Share of pauses still idle after each time")),
603 {
604 table: {
605 head: ["Idle for", ...KM_POINTS_MS.map((t) => duration(t))],
606 rows: [
607 {
608 cells: [
609 { text: "Still idle" },
610 ...KM_POINTS_MS.map((t) => ({
611 text: percent(survivalAt(km, t), 1),
612 })),
613 ],
614 },
615 ],
616 },
617 },
618 ],
619 };
620
621 const meanPrefix = traces.reduce((s, t) => s + t.prefix, 0) / traces.length;
622 const candidates = [
623 ...new Set([
624 currentLead,
625 leadMs(ttl, 0),
626 ...LEAD_CANDIDATES_S.map((s) => leadMs(ttl, s)),
627 ]),
628 ].sort((a, b) => a - b);
629 const policies: Policy[] = ["off", ...candidates];
630 const offReplay = replay(traces, () => "off");
631 const offKm = expectedCost(km, "off", ttl);
632 const rows = policies.map((policy) => {
633 const total = replay(traces, (t) =>
634 policy === "off"
635 ? "off"
636 : t.ttlMinutes === ttl
637 ? policy
638 : defaultPolicy(t.ttlMinutes, v.config.isShortTtlOn),
639 );
640 const perPause = (offKm - expectedCost(km, policy, ttl)) * meanPrefix;
641 return { policy, total, perPause, savedReplay: offReplay - total };
642 });
643 const running = refreshesAt(v.config, ttl) ? currentLead : "off";
644 const name = (p: Policy, isRow = false) => {
645 const tags = [
646 ...(p !== "off" && p === leadMs(ttl, 0) ? ["auto"] : []),
647 ...(isRow && p === running ? ["current"] : []),
648 ];
649 return `${p === "off" ? "off" : `lead ${leadName(p)}`}${tags.length > 0 ? ` (${tags.join(", ")})` : ""}`;
650 };
651 const advised = recommend(rows, currentLead, v.config, ttl, name);
652 const none = { text: "–" };
653 const options: Table = {
654 head: ["Policy", "Saved/pause", "Replay cost", "Saved vs off"],
655 rows: rows.map((r) => ({
656 cells: [
657 { text: name(r.policy, true) },
658 r.policy === "off" ? none : signedCell(r.perPause),
659 { text: tokens(Math.round(r.total)) },
660 r.policy === "off" ? none : signedCell(r.savedReplay),
661 ],
662 isPick: r.policy === advised.policy,
663 isCurrent: r.policy === running,
664 })),
665 legend: "▸ recommended · • current",
666 };
667 return [
668 lead(advised.block),
669 ...head,
670 survival,
671 {
672 title: "Lead options",
673 blocks: [
674 { table: options },
675 line(
676 dim(
677 "Saved vs off replays every pause in the sample, skipped ones too, as if each refresh hit; Net saved counts only the refreshes ember ran.",
678 ),
679 ),
680 ],
681 },
682 ...idleSection,
683 ];
684}
685
686type Row = { policy: Policy; perPause: number; savedReplay: number };
687
688/**
689 * The best lead by the survival estimate; the current lead stays unless the best saves more than 1% and at least
690 * MIN_GAIN_TOKENS per pause more. Off when it saves nothing or the replay of the same pauses disagrees. At a short
691 * TTL it speaks to `refreshShortTtl`.
692 */
693function recommend(
694 rows: readonly Row[],
695 currentLead: number,
696 config: RefreshConfig,
697 ttl: number,
698 name: (p: Policy) => string,
699): { policy: Policy; block: Block } {
700 const on = rows.filter((r) => r.policy !== "off");
701 const best = on.reduce((a, b) => (b.perPause > a.perPause + 1e-9 ? b : a));
702 const current = on.find((r) => r.policy === currentLead) ?? best;
703 // Within 1% of the best, or a gain under the minimum while the current lead pays, the configured lead stays: no churn for noise.
704 const gain = best.perPause - current.perPause;
705 const isCurrentPaying = current.perPause > 0 && current.savedReplay > 0;
706 const pick =
707 gain <= Math.abs(best.perPause) * 0.01 ||
708 (isCurrentPaying && gain < MIN_GAIN_TOKENS)
709 ? current
710 : best;
711 const isShort = ttl <= SHORT_TTL_MINUTES;
712 if (pick.perPause <= 0 || pick.savedReplay <= 0) {
713 if (isShort) {
714 const keep = config.isShortTtlOn
715 ? "set refreshShortTtl to false"
716 : "keep refreshShortTtl off (refreshShortTtl: false)";
717 return {
718 policy: "off",
719 block: advice(
720 `${keep}. At a ${ttl}m TTL the refreshes cost more than the cold rewrites they avoid.`,
721 ),
722 };
723 }
724 return {
725 policy: "off",
726 block: advice(
727 "turn refresh off (refresh: false). On these pauses it costs more than the cold rewrites it avoids.",
728 ),
729 };
730 }
731 const lead = pick.policy === "off" ? 0 : pick.policy;
732 const isKept = pick === current;
733 const setting = isKept
734 ? "keep the current lead"
735 : `set refreshLeadSeconds to ${Math.round(lead / 1000)}`;
736 const turnOn = config.isOn
737 ? "keep refresh on"
738 : "turn refresh on (refresh: true)";
739 const head = !isShort
740 ? turnOn
741 : config.isShortTtlOn
742 ? `${turnOn}, keep refreshShortTtl on`
743 : `set refreshShortTtl to true${config.isOn ? "" : " and refresh to true"}`;
744 return {
745 policy: pick.policy,
746 block: {
747 pick: {
748 runs: [
749 bold("Recommendation:"),
750 plain(` ${head}, ${setting} (${name(pick.policy)}): about `),
751 bold(`${signed(isKept ? pick.perPause : gain)} tokens`, "good"),
752 plain(
753 isKept
754 ? " saved per pause versus refresh off."
755 : ` per pause more than ${name(current.policy)}.`,
756 ),
757 ],
758 },
759 },
760 };
761}
762hooks/pane.tsx 221 lines1import type {
2 BoxProps,
3 ButtonProps,
4 ElementConstructor,
5 RenderElement,
6 TextProps,
7} from "claude-code";
8
9import type {
10 Block,
11 Cell,
12 Line,
13 Run,
14 Section,
15 Table,
16 Tone,
17} from "./sections.ts";
18
19type Elements = {
20 Box: ElementConstructor<BoxProps>;
21 Button: ElementConstructor<ButtonProps>;
22 Text: ElementConstructor<TextProps>;
23};
24
25export type Tab = {
26 key: string;
27 label: string;
28 hotkey: string;
29 isActive: boolean;
30 onPress: () => void;
31};
32
33/** A pane: its title, the tabs when there is a choice, the sections, and what the Close button does. */
34export type PaneView = {
35 title: string;
36 tabs?: readonly Tab[];
37 sections: readonly Section[];
38 onClose: () => void;
39};
40
41/** Theme keys, so the colors follow the person's theme. */
42const COLOR: Record<Tone, string> = {
43 good: "success",
44 bad: "error",
45 warn: "warning",
46};
47/** Fits the longest label, `Idle compaction`, and a gap. */
48const LABEL_WIDTH = 17;
49
50const paint = (tone?: Tone, isBold = false, isDim = false): TextProps => ({
51 ...(tone === undefined ? {} : { color: COLOR[tone] }),
52 ...(isBold ? { weight: "bold" as const } : {}),
53 ...(isDim ? { dimColor: true } : {}),
54});
55
56/** Each column as wide as its widest cell, so a table needs no more room than its text. */
57export const columnWidths = (t: Table): number[] =>
58 t.head.map((h, i) =>
59 Math.max(h.length, ...t.rows.map((r) => r.cells[i]?.text.length ?? 0)),
60 );
61
62/**
63 * An ember pane: a header row (title, tabs, Close), any untitled section under it, then the titled sections side by
64 * side, wrapping to the next row where the pane is too narrow; each section a bold title, label/value rows, `Box`
65 * tables, a recommendation boxed as wide as its text.
66 */
67export function pane(
68 { title, tabs = [], sections, onClose }: PaneView,
69 { Box, Button, Text }: Elements,
70): RenderElement {
71 const runs = (rs: readonly Run[]) =>
72 rs.map((r) => (
73 <Text {...paint(r.tone, r.style === "bold", r.style === "dim")}>
74 {r.text}
75 </Text>
76 ));
77
78 const line = (l: Line) => (
79 <Box flexDirection="column">
80 <Box flexDirection="row">
81 {l.label === undefined ? null : (
82 <Box width={LABEL_WIDTH} flexShrink={0}>
83 <Text dimColor>{l.label}</Text>
84 </Box>
85 )}
86 <Text>{runs(l.runs)}</Text>
87 </Box>
88 {l.note === undefined ? null : (
89 <Box paddingLeft={l.label === undefined ? 0 : LABEL_WIDTH}>
90 <Text dimColor>{l.note}</Text>
91 </Box>
92 )}
93 </Box>
94 );
95
96 const table = (t: Table) => {
97 const widths = columnWidths(t);
98 const hasMarks = t.rows.some(
99 (r) => r.isPick === true || r.isCurrent === true,
100 );
101 // The name column gives way first in a narrow pane; the numbers keep their width.
102 const row = (
103 cells: readonly Cell[],
104 isBold: boolean,
105 mark: RenderElement,
106 ) => (
107 <Box flexDirection="row" gap={2}>
108 {hasMarks ? (
109 <Box width={1} flexShrink={0}>
110 {mark}
111 </Box>
112 ) : null}
113 {cells.map((c, i) => (
114 <Box
115 width={widths[i] ?? 0}
116 flexShrink={i === 0 ? 1 : 0}
117 justifyContent={i === 0 ? "flex-start" : "flex-end"}
118 >
119 <Text
120 wrap="truncate-end"
121 tabular={i > 0}
122 {...paint(c.tone, isBold)}
123 >
124 {c.text}
125 </Text>
126 </Box>
127 ))}
128 </Box>
129 );
130 return (
131 <Box flexDirection="column">
132 {row(
133 t.head.map((text) => ({ text })),
134 true,
135 <Text> </Text>,
136 )}
137 {t.rows.map((r) =>
138 row(
139 r.cells,
140 r.isPick === true,
141 r.isPick === true ? (
142 <Text weight="bold" color="suggestion">
143 ▸
144 </Text>
145 ) : r.isCurrent === true ? (
146 <Text weight="bold">•</Text>
147 ) : (
148 <Text> </Text>
149 ),
150 ),
151 )}
152 {t.legend === undefined ? null : <Text dimColor>{t.legend}</Text>}
153 </Box>
154 );
155 };
156
157 const block = (b: Block) =>
158 "table" in b ? (
159 table(b.table)
160 ) : "pick" in b ? (
161 <Box
162 borderStyle="round"
163 borderColor="suggestion"
164 paddingX={1}
165 alignSelf="flex-start"
166 >
167 <Text>{runs(b.pick.runs)}</Text>
168 </Box>
169 ) : (
170 line(b.line)
171 );
172
173 return (
174 <Box flexDirection="column" gap={1}>
175 <Box flexDirection="row" justifyContent="space-between" gap={2}>
176 <Box flexDirection="row" gap={2}>
177 <Text weight="bold" color="claude">
178 {title}
179 </Text>
180 {tabs.map((t) => (
181 <Button
182 key={t.key}
183 label={t.label}
184 hotkey={t.hotkey}
185 plain
186 dimColor={!t.isActive}
187 onPress={t.onPress}
188 />
189 ))}
190 </Box>
191 {/* biome-ignore lint/a11y/useValidAriaRole: the engine's Button role, not ARIA; a desktop draws its native close control. */}
192 <Button
193 key="close"
194 label="Close"
195 hotkey="q"
196 plain
197 role="dismiss"
198 onPress={onClose}
199 />
200 </Box>
201 {sections
202 .filter((s) => s.title === undefined)
203 .map((s) => (
204 <Box flexDirection="column">{s.blocks.map(block)}</Box>
205 ))}
206 <Box flexDirection="row" flexWrap="wrap" columnGap={4} rowGap={1}>
207 {sections
208 .filter((s) => s.title !== undefined)
209 .map((s) => (
210 <Box flexDirection="column">
211 <Text weight="bold" color="claude">
212 {s.title}
213 </Text>
214 {s.blocks.map(block)}
215 </Box>
216 ))}
217 </Box>
218 </Box>
219 );
220}
221hooks/refresh.ts 625 lines1import type { ModelForkResult } from "claude-code";
2
3const MIN = 60_000;
4
5/** A cache read costs 0.1 of an input token; prices below are in input-token units. */
6export const READ_WEIGHT = 0.1;
7/** Output tokens cost 5 input tokens on current Claude models; an assumption of the cost estimate. */
8export const OUTPUT_WEIGHT = 5;
9/** A refresh hits when its cache read is at least this share of the last main prefix. */
10export const HIT_READ_SHARE = 0.9;
11/** A refresh read under this share of the prefix found the entry gone: TTL evidence. */
12export const COLD_READ_SHARE = 0.5;
13export const MISS_LIMIT = 2;
14export const MAX_TRIES = 3;
15export const RETRY_MS = 30_000;
16/** No refresh starts this close to expiry: it would reach the API after the entry lapsed. */
17export const LATE_MARGIN_MS = 5_000;
18export const MIN_LEAD_SECONDS = 10;
19export const MAX_LEAD_SECONDS = 3600;
20/** Records kept per session: about 2.4 kB, so 500 sessions stay near 1.2 MB of the store's 4 MiB. */
21export const LOG_LIMIT = 32;
22export const REFRESH_PROMPT = "Reply with the single word ok.";
23
24export const SOURCES = [
25 "ttlMinutes option",
26 "FORCE_PROMPT_CACHING_5M",
27 "CLAUDE_CODE_PROMPT_CACHE_TTL",
28 "promptCacheTtl",
29 "ENABLE_PROMPT_CACHING_1H",
30 "refresh miss",
31] as const;
32export const SKIPS = [
33 "none",
34 "off",
35 "no prefix",
36 "idle compaction",
37 "paused",
38 "interrupted",
39 "compacted",
40 "nothing to fork",
41 "not interactive",
42 "capped",
43 "late",
44 "refused",
45 "missed",
46 "short-ttl",
47] as const;
48export const ENDS = ["warm", "cold", "exit", "unknown"] as const;
49export const OUTCOMES = ["hit", "miss", "refused"] as const;
50
51export type Skip = (typeof SKIPS)[number];
52export type End = (typeof ENDS)[number];
53export type Outcome = (typeof OUTCOMES)[number];
54export const skipCode = (s: Skip) => SKIPS.indexOf(s);
55export const endCode = (e: End) => ENDS.indexOf(e);
56export const sourceCode = (source: string) =>
57 Math.max(0, SOURCES.indexOf(source as (typeof SOURCES)[number]));
58
59/** At or under this TTL each refresh buys only a few minutes, so `refreshShortTtl: false` can turn refresh off here. */
60export const SHORT_TTL_MINUTES = 5;
61
62export type RefreshConfig = {
63 isOn: boolean;
64 leadSeconds: number;
65 isShortTtlOn: boolean;
66};
67
68/** The options as validated: refresh is on unless `false`; short-TTL refresh on unless `false`; a lead outside 10 to 3600 s is 0, the automatic lead. */
69export function refreshConfig(options: {
70 refresh?: unknown;
71 refreshLeadSeconds?: unknown;
72 refreshShortTtl?: unknown;
73}): RefreshConfig {
74 const lead = options.refreshLeadSeconds;
75 const isValid =
76 typeof lead === "number" &&
77 Number.isFinite(lead) &&
78 lead >= MIN_LEAD_SECONDS &&
79 lead <= MAX_LEAD_SECONDS;
80 return {
81 isOn: options.refresh !== false,
82 leadSeconds: isValid ? lead : 0,
83 isShortTtlOn: options.refreshShortTtl !== false,
84 };
85}
86
87/** Whether refresh runs at this TTL: on, and the TTL is above 5 min or short-TTL refresh is not turned off. */
88export const refreshesAt = (config: RefreshConfig, ttlMinutes: number) =>
89 config.isOn && (ttlMinutes > SHORT_TTL_MINUTES || config.isShortTtlOn);
90
91/** The API's write price for the TTL: 1.25 for the 5-minute cache, 2 for the 1-hour one. */
92export const writeWeight = (ttlMinutes: number) => (ttlMinutes <= 5 ? 1.25 : 2);
93
94/** Ski rental: refresh while the pause's refresh reads cost at most one cold rewrite (20 at 1 h, 12 at 5 min). */
95export const maxRefreshes = (ttlMinutes: number) =>
96 Math.floor(writeWeight(ttlMinutes) / READ_WEIGHT + 1e-9);
97
98export const autoLeadMs = (ttlMinutes: number) =>
99 Math.min(5 * MIN, (ttlMinutes * MIN) / 5);
100
101/** The asked lead when it leaves at least 10 s of TTL before it, else the automatic one. */
102export function leadMs(ttlMinutes: number, leadSeconds: number): number {
103 const asked = leadSeconds * 1000;
104 return asked > 0 && asked <= ttlMinutes * MIN - MIN_LEAD_SECONDS * 1000
105 ? asked
106 : autoLeadMs(ttlMinutes);
107}
108
109/** One idle pause of the main thread, numbers only. */
110export type Pause = {
111 /** The end of the last main response: the human pause runs from here. */
112 startMs: number;
113 /** The start of the main request that wrote the cache entry. */
114 anchorMs: number;
115 /** The start of the last request that read the entry: the TTL runs from here. */
116 readMs: number;
117 ttlMinutes: number;
118 source: number;
119 leadMs: number;
120 prefix: number;
121 /** Refreshes that reached the API (hit or miss). */
122 refreshes: number;
123 /** Refused fork calls in a row. */
124 tries: number;
125 retryAtMs?: number;
126 /** What the refreshes cost, input-token units. */
127 cost: number;
128 /** Why no more refreshes run in this pause; 0 while they may. */
129 skip: number;
130 /** When the person's next turn started. */
131 returnMs?: number;
132 /** When an idle compaction of the main conversation was seen in this pause. */
133 idleCompactMs?: number;
134};
135
136/** The refresh state a session stores. */
137export type RefreshState = {
138 log: number[][];
139 missStreak: number;
140 isPaused: boolean;
141 /** The smallest refresh age that found the entry gone. */
142 coldAtMs?: number;
143 /** Net input tokens saved this session (negative: refresh cost more). */
144 saved: number;
145 pause?: Pause;
146};
147
148export const emptyRefresh: RefreshState = {
149 log: [],
150 missStreak: 0,
151 isPaused: false,
152 saved: 0,
153};
154
155const PAUSE_FIELDS = [
156 "startMs",
157 "lenMs",
158 "ageMs",
159 "ttlMinutes",
160 "source",
161 "leadMs",
162 "prefix",
163 "refreshes",
164 "end",
165 "skip",
166 "cost",
167 "saved",
168 "idleCompacted",
169] as const;
170const REFRESH_FIELDS = [
171 "startMs",
172 "ageMs",
173 "ttlMinutes",
174 "source",
175 "leadMs",
176 "prefix",
177 "read",
178 "creation",
179 "input",
180 "output",
181 "outcome",
182 "steps",
183 "agentSteps",
184 "isProbe",
185] as const;
186type Fields<F extends readonly string[]> = { [K in F[number]]: number };
187export type PauseRec = { kind: "pause" } & Fields<typeof PAUSE_FIELDS>;
188export type RefreshRec = { kind: "refresh" } & Fields<typeof REFRESH_FIELDS>;
189export type Rec = PauseRec | RefreshRec;
190
191export function encodeRec(r: Rec): number[] {
192 return r.kind === "pause"
193 ? [0, ...PAUSE_FIELDS.map((f) => Math.round(r[f]))]
194 : [1, ...REFRESH_FIELDS.map((f) => Math.round(r[f]))];
195}
196
197const isNum = (v: unknown): v is number =>
198 typeof v === "number" && Number.isFinite(v);
199
200export function decodeRec(v: unknown): Rec | undefined {
201 if (!Array.isArray(v) || !v.every(isNum)) return undefined;
202 const [kind, ...stored] = v as number[];
203 // A pause record written before `idleCompacted` existed: none was recorded.
204 const rest =
205 kind === 0 && stored.length === PAUSE_FIELDS.length - 1
206 ? [...stored, 0]
207 : stored;
208 const fields =
209 kind === 0 ? PAUSE_FIELDS : kind === 1 ? REFRESH_FIELDS : undefined;
210 if (fields === undefined || rest.length !== fields.length) return undefined;
211 return {
212 kind: kind === 0 ? "pause" : "refresh",
213 ...Object.fromEntries(fields.map((f, i) => [f, rest[i]])),
214 } as Rec;
215}
216
217/** Appends a record, keeping the newest LOG_LIMIT. */
218export const pushLog = (log: readonly number[][], r: Rec): number[][] =>
219 [...log, encodeRec(r)].slice(-LOG_LIMIT);
220
221/** Idle compaction runs only on the 1-hour cache. */
222export const IDLE_COMPACTION_TTL_MINUTES = 60;
223
224export type IdleCompactInputs = {
225 setting?: unknown;
226 autoCompact?: unknown;
227 disableAuto?: string;
228 minTokens?: string;
229};
230
231/** Idle compaction fires at this share of the TTL: `fireAtFraction`'s default in Claude Code 2.1.306, about 54 min on the 1 h cache. */
232export const IDLE_COMPACTION_TTL_SHARE = 0.9;
233
234/**
235 * Whether Claude Code's idle compaction may run in this pause (1 h TTL, interactive, not turned off, context at
236 * its threshold). A server flag can still keep it off, so ember yields to it only once it has seen it run.
237 * The prefix stands in for the context size; 90% of the threshold counts, so a near miss yields too.
238 */
239export function idleCompactionApplies(
240 i: IdleCompactInputs,
241 ttlMinutes: number,
242 isInteractive: boolean,
243 prefix: number,
244): boolean {
245 if (
246 ttlMinutes !== IDLE_COMPACTION_TTL_MINUTES ||
247 !isInteractive ||
248 i.setting === false ||
249 i.autoCompact === false
250 )
251 return false;
252 if (i.disableAuto === "1" || i.disableAuto === "true") return false;
253 const asked = Number(i.minTokens);
254 const threshold =
255 Number.isInteger(asked) && asked > 0 ? Math.max(100_000, asked) : 200_000;
256 return prefix >= 0.9 * threshold;
257}
258
259/** A main-conversation compaction that Claude Code started on its own while the person was away: idle compaction. */
260export function isIdleCompactionEvidence(
261 e: { trigger: string; agentId?: string },
262 p: Pause | undefined,
263 isTurnRunning: boolean,
264): boolean {
265 return (
266 e.agentId === undefined &&
267 (e.trigger === "auto" || e.trigger === "precompute") &&
268 p !== undefined &&
269 p.returnMs === undefined &&
270 !isTurnRunning
271 );
272}
273
274/** The latest share of the TTL idle compaction fires at: Claude Code 2.1.306 clamps `fireAtFraction` to 0.5 to 0.95. */
275export const IDLE_COMPACTION_LATEST_SHARE = 0.95;
276/** Slack past that latest time before ember decides idle compaction did not run. */
277export const RESCUE_SLACK_MS = 30_000;
278
279/** When idle compaction must have fired, measured like the timed refreshes from the last read: 57 m 30 s on the 1 h cache. */
280export const rescueAtMs = (p: Pause): number =>
281 p.readMs +
282 IDLE_COMPACTION_LATEST_SHARE * p.ttlMinutes * MIN +
283 RESCUE_SLACK_MS;
284
285/** A pause left to idle compaction, still open with none seen: ember rescues it at `rescueAtMs` if none comes. */
286const awaitsIdleCompaction = (p: Pause): boolean =>
287 p.skip === skipCode("idle compaction") &&
288 p.ttlMinutes === IDLE_COMPACTION_TTL_MINUTES &&
289 p.idleCompactMs === undefined;
290
291/** A pause left to idle compaction that outlasted its latest time with none seen: the evidence no longer holds. */
292export const idleCompactionLapsed = (p: Pause, endMs: number): boolean =>
293 awaitsIdleCompaction(p) && endMs >= rescueAtMs(p);
294
295/** The stored time idle compaction was last seen; anything but a valid timestamp is none. */
296export const parseIdleSeen = (v: unknown): number | undefined =>
297 isNum(v) && v > 0 && v <= 8.64e15 ? v : undefined;
298
299export type PauseContext = {
300 config: RefreshConfig;
301 ttlMinutes: number;
302 source: string;
303 isInteractive: boolean;
304 isAborted: boolean;
305 isIdleCompaction: boolean;
306 isCompactPending: boolean;
307 isPaused: boolean;
308};
309
310/** Opens the pause after a main turn: logged always, refreshed only when nothing skips it. */
311export function openPause(
312 last: { startMs: number; endMs: number; prefix: number },
313 c: PauseContext,
314): Pause {
315 const skip: Skip = !c.config.isOn
316 ? "off"
317 : !refreshesAt(c.config, c.ttlMinutes)
318 ? "short-ttl"
319 : !c.isInteractive
320 ? "not interactive"
321 : c.isAborted
322 ? "interrupted"
323 : last.prefix <= 0
324 ? "no prefix"
325 : c.isCompactPending
326 ? "compacted"
327 : c.isPaused
328 ? "paused"
329 : c.isIdleCompaction
330 ? "idle compaction"
331 : "none";
332 return {
333 startMs: last.endMs,
334 anchorMs: last.startMs,
335 readMs: last.startMs,
336 ttlMinutes: c.ttlMinutes,
337 source: sourceCode(c.source),
338 leadMs: leadMs(c.ttlMinutes, c.config.leadSeconds),
339 prefix: last.prefix,
340 refreshes: 0,
341 tries: 0,
342 cost: 0,
343 skip: skipCode(skip),
344 };
345}
346
347/** `rescue`: idle compaction did not come; clear the evidence and refresh now with `pause`, a refreshable pause from here on. */
348export type Plan =
349 | { kind: "wait" }
350 | { kind: "fire" }
351 | { kind: "stop"; skip: Skip }
352 | { kind: "rescue"; pause: Pause };
353
354/** What the pause asks for now. The caller checks that the main thread is idle, nothing compacts, and no refresh runs. */
355export function planRefresh(p: Pause, nowMs: number): Plan {
356 if (p.returnMs !== undefined) return { kind: "wait" };
357 const expiresMs = p.readMs + p.ttlMinutes * MIN;
358 if (awaitsIdleCompaction(p)) {
359 // Past the late limit a fork would only rewrite the entry; closing the pause clears the evidence instead.
360 const isDue =
361 nowMs >= rescueAtMs(p) &&
362 nowMs < expiresMs - LATE_MARGIN_MS &&
363 p.refreshes < maxRefreshes(p.ttlMinutes);
364 return isDue
365 ? { kind: "rescue", pause: { ...p, skip: skipCode("none") } }
366 : { kind: "wait" };
367 }
368 if (p.skip !== 0) return { kind: "wait" };
369 if (p.refreshes >= maxRefreshes(p.ttlMinutes))
370 return { kind: "stop", skip: "capped" };
371 // A machine that slept past the slot finds the entry gone: a fork then would only rewrite it.
372 if (nowMs >= expiresMs - LATE_MARGIN_MS)
373 return { kind: "stop", skip: "late" };
374 if (nowMs < expiresMs - p.leadMs || nowMs < (p.retryAtMs ?? 0))
375 return { kind: "wait" };
376 return { kind: "fire" };
377}
378
379/** True while a refresh (or the idle compaction rescue) will still run before this pause's entry expires, so no cold warning is due. */
380export const isRefreshPlanned = (p: Pause | undefined): boolean =>
381 p !== undefined &&
382 p.returnMs === undefined &&
383 (awaitsIdleCompaction(p) ||
384 (p.skip === 0 && p.refreshes < maxRefreshes(p.ttlMinutes)));
385
386export type Judged = {
387 outcome: Outcome | "none";
388 read: number;
389 creation: number;
390 input: number;
391 output: number;
392};
393
394/** Reads a fork's result: hit, miss, refused (no answer to judge), or none (nothing to fork, no request made). */
395export function judgeFork(
396 r: ModelForkResult | undefined,
397 prefix: number,
398): Judged {
399 const zero = { read: 0, creation: 0, input: 0, output: 0 };
400 if (r === undefined) return { outcome: "refused", ...zero };
401 if (!r.isAnswered && r.reason === "nothing-to-fork")
402 return { outcome: "none", ...zero };
403 const u = r.usage;
404 const tokens = {
405 read: u.cache_read_input_tokens,
406 creation: u.cache_creation_input_tokens,
407 input: u.input_tokens,
408 output: u.output_tokens,
409 };
410 if (!r.isAnswered && (r.reason === "api-error" || r.reason === "aborted"))
411 return { outcome: "refused", ...tokens };
412 // Creation is not checked: the fork also caches the last main response, which lies outside the prefix.
413 const isHit = prefix > 0 && tokens.read >= HIT_READ_SHARE * prefix;
414 return { outcome: isHit ? "hit" : "miss", ...tokens };
415}
416
417export const refreshCost = (j: Judged, ttlMinutes: number) =>
418 READ_WEIGHT * j.read +
419 writeWeight(ttlMinutes) * j.creation +
420 j.input +
421 OUTPUT_WEIGHT * j.output;
422
423export type Fired = {
424 pause: Pause;
425 firedMs: number;
426 steps: number;
427 agentSteps: number;
428 isProbe: boolean;
429};
430
431export type Applied = {
432 state: RefreshState;
433 isHit: boolean;
434 isPausedNow: boolean;
435};
436
437/**
438 * Folds one refresh into the session state. `isCurrent`: its pause is still the open one; otherwise the
439 * person came back while it ran, and only its record and cost remain. A probe moves no cap or breaker.
440 */
441export function applyRefresh(
442 s: RefreshState,
443 f: Fired,
444 j: Judged,
445 isCurrent: boolean,
446): Applied {
447 const p = isCurrent && s.pause !== undefined ? s.pause : f.pause;
448 const none: Applied = { state: s, isHit: false, isPausedNow: false };
449 if (j.outcome === "none")
450 return isCurrent
451 ? {
452 ...none,
453 state: { ...s, pause: { ...p, skip: skipCode("nothing to fork") } },
454 }
455 : none;
456 const ageMs = f.firedMs - f.pause.readMs;
457 const cost = refreshCost(j, p.ttlMinutes);
458 const log = pushLog(s.log, {
459 kind: "refresh",
460 startMs: f.firedMs,
461 ageMs,
462 ttlMinutes: p.ttlMinutes,
463 source: p.source,
464 leadMs: p.leadMs,
465 prefix: p.prefix,
466 read: j.read,
467 creation: j.creation,
468 input: j.input,
469 output: j.output,
470 outcome: OUTCOMES.indexOf(j.outcome),
471 steps: f.steps,
472 agentSteps: f.agentSteps,
473 isProbe: f.isProbe ? 1 : 0,
474 });
475 const isGone = j.outcome === "miss" && j.read < COLD_READ_SHARE * p.prefix;
476 const coldAtMs = isGone ? Math.min(ageMs, s.coldAtMs ?? ageMs) : s.coldAtMs;
477 let out: RefreshState = {
478 ...s,
479 log,
480 ...(coldAtMs === undefined ? {} : { coldAtMs }),
481 };
482 if (!isCurrent)
483 return {
484 state: { ...out, saved: out.saved - cost },
485 isHit: false,
486 isPausedNow: false,
487 };
488
489 if (j.outcome === "refused") {
490 const tries = p.tries + 1;
491 const pause = {
492 ...p,
493 tries,
494 retryAtMs: f.firedMs + RETRY_MS,
495 ...(tries >= MAX_TRIES && !f.isProbe
496 ? { skip: skipCode("refused") }
497 : {}),
498 };
499 return { state: { ...out, pause }, isHit: false, isPausedNow: false };
500 }
501 const isHit = j.outcome === "hit";
502 const counted = f.isProbe ? 0 : 1;
503 const pause: Pause = {
504 ...p,
505 refreshes: p.refreshes + counted,
506 tries: 0,
507 cost: p.cost + cost,
508 ...(isHit
509 ? { readMs: Math.max(p.readMs, f.firedMs) }
510 : { skip: skipCode("missed") }),
511 };
512 if (f.isProbe)
513 return {
514 state: { ...out, pause: isHit ? pause : { ...pause, skip: p.skip } },
515 isHit,
516 isPausedNow: false,
517 };
518 const missStreak = isHit ? 0 : s.missStreak + 1;
519 const isPausedNow = !s.isPaused && missStreak >= MISS_LIMIT;
520 out = { ...out, pause, missStreak, isPaused: s.isPaused || isPausedNow };
521 return { state: out, isHit, isPausedNow };
522}
523
524/** Closes the open pause into a record: a return (warm, cold, unknown) or an exit, and settles its savings. */
525export function closePause(
526 s: RefreshState,
527 end: End,
528 atMs: number,
529): RefreshState {
530 const p = s.pause;
531 if (p === undefined) return s;
532 const endMs = p.returnMs ?? atMs;
533 const ageMs = endMs - p.anchorMs;
534 const wouldBeCold = ageMs >= p.ttlMinutes * MIN;
535 const avoided =
536 end === "warm" && wouldBeCold && p.refreshes > 0
537 ? (writeWeight(p.ttlMinutes) - READ_WEIGHT) * p.prefix
538 : 0;
539 const saved = avoided - p.cost;
540 const log = pushLog(s.log, {
541 kind: "pause",
542 startMs: p.startMs,
543 lenMs: Math.max(0, endMs - p.startMs),
544 ageMs: Math.max(0, ageMs),
545 ttlMinutes: p.ttlMinutes,
546 source: p.source,
547 leadMs: p.leadMs,
548 prefix: p.prefix,
549 refreshes: p.refreshes,
550 end: endCode(end),
551 skip: p.skip,
552 cost: p.cost,
553 saved,
554 idleCompacted: p.idleCompactMs === undefined ? 0 : 1,
555 });
556 const { pause: _closed, ...rest } = s;
557 return { ...rest, log, saved: s.saved + saved };
558}
559
560/** The TTL to plan with: after a refresh found the entry gone before the resolved TTL, the 5-minute one. */
561export function effectiveTtl<T extends { minutes: number; source: string }>(
562 ttl: T,
563 s: RefreshState | undefined,
564): T | { minutes: number; source: string } {
565 const cold = s?.coldAtMs;
566 return cold !== undefined && ttl.minutes > 5 && cold < ttl.minutes * MIN
567 ? { minutes: 5, source: "refresh miss" }
568 : ttl;
569}
570
571const optNum = (v: unknown, min = 0) => (isNum(v) && v >= min ? v : undefined);
572
573function parsePause(v: unknown): Pause | undefined {
574 if (typeof v !== "object" || v === null) return undefined;
575 const o = v as Record<string, unknown>;
576 const keys = [
577 "startMs",
578 "anchorMs",
579 "readMs",
580 "ttlMinutes",
581 "source",
582 "leadMs",
583 "prefix",
584 "refreshes",
585 "tries",
586 "cost",
587 "skip",
588 ] as const;
589 const nums = keys.map((k) => optNum(o[k]));
590 if (nums.some((n) => n === undefined) || (o.ttlMinutes as number) <= 0)
591 return undefined;
592 const p = Object.fromEntries(keys.map((k, i) => [k, nums[i]])) as Omit<
593 Pause,
594 "retryAtMs" | "returnMs" | "idleCompactMs"
595 >;
596 const retryAtMs = optNum(o.retryAtMs);
597 const returnMs = optNum(o.returnMs);
598 const idleCompactMs = optNum(o.idleCompactMs);
599 return {
600 ...p,
601 ...(retryAtMs === undefined ? {} : { retryAtMs }),
602 ...(returnMs === undefined ? {} : { returnMs }),
603 ...(idleCompactMs === undefined ? {} : { idleCompactMs }),
604 };
605}
606
607/** Reads a stored refresh state defensively; bad records are dropped. */
608export function parseRefresh(v: unknown): RefreshState | undefined {
609 if (typeof v !== "object" || v === null) return undefined;
610 const o = v as Record<string, unknown>;
611 const log = (Array.isArray(o.log) ? o.log : [])
612 .filter((r) => decodeRec(r) !== undefined)
613 .slice(-LOG_LIMIT) as number[][];
614 const coldAtMs = optNum(o.coldAtMs);
615 const pause = parsePause(o.pause);
616 return {
617 log,
618 missStreak: optNum(o.missStreak) ?? 0,
619 isPaused: o.isPaused === true,
620 saved: isNum(o.saved) ? o.saved : 0,
621 ...(coldAtMs === undefined ? {} : { coldAtMs }),
622 ...(pause === undefined ? {} : { pause }),
623 };
624}
625hooks/stats.ts 635 lines1import type { TurnUsage } from "claude-code";
2import type { RefreshState } from "./refresh.ts";
3import { parseRefresh } from "./refresh.ts";
4import type { Block, Line, Section, Tone } from "./sections.ts";
5import { bold, dim, line, plain } from "./sections.ts";
6
7export const BUCKETS = [
8 "first",
9 "user",
10 "ask",
11 "subagent",
12 "tool",
13 "compact",
14 "model-switch",
15 "agent",
16] as const;
17export type Bucket = (typeof BUCKETS)[number];
18
19export const BANDS = [
20 "<1m",
21 "1-5m",
22 "5-15m",
23 "15-30m",
24 "30-60m",
25 ">60m",
26] as const;
27export type Band = (typeof BANDS)[number];
28const BAND_UPPER_MS = [1, 5, 15, 30, 60].map((minutes) => minutes * 60_000);
29
30export const DEFAULT_TTL_MINUTES = 60;
31export const PRUNE_AFTER_MS = 30 * 24 * 60 * 60_000;
32export const MAX_SESSIONS = 500;
33/** A `user` request that read at least this share of the previous main prefix found the cache warm. */
34const WARM_PREFIX_SHARE = 0.5;
35
36export type Totals = { n: number; read: number; total: number };
37/** Token sums over every measured request, main thread and subagents, for the cost share. */
38export type Sums = { input: number; read: number; created: number };
39
40/** What one session stores: aggregates only, never prompt text, paths or tool inputs. */
41export type Agg = {
42 updatedAt: number;
43 buckets: Record<Bucket, Totals>;
44 /** Per `user` gap band, like `buckets`. Before 0.3.0 a band held its count alone. */
45 bands: Record<Band, Totals>;
46 /** Largest cache age at a `user` request that read >= half the previous prefix. */
47 warmMaxMs?: number;
48 /** Smallest cache age at a `user` request that read < half the previous prefix. */
49 coldMinMs?: number;
50 compactions: number;
51 modelSwitches: number;
52 sums: Sums;
53 /** The last main request with usage and the resolved TTL, for the status line script; never merged. */
54 live?: CacheLive;
55 /** Cache refresh: the open pause, the breaker, the record ring buffer; never merged. */
56 refresh?: RefreshState;
57};
58
59/**
60 * `hit` is 0 to 1; `prefix` is cache read + cache creation tokens. `refreshes`: refreshes in the current
61 * pause; `savedTokens`: the session's net saving from them. Each is left out while 0. `savedPct`: the
62 * session's cache saving as a cost share, left out before any token.
63 */
64export type CacheLive = {
65 lastStartMs: number;
66 ttlMinutes: number;
67 prefix: number;
68 hit: number;
69 refreshes?: number;
70 savedTokens?: number;
71 savedPct?: number;
72};
73
74/** The main thread's last measured request, which the next one is classified against. */
75export type Main = {
76 /** When it started: the cache's TTL runs from here. */
77 lastStartMs?: number;
78 /** When its response ended: the human pause runs from here. */
79 lastEndMs?: number;
80 lastModel?: string;
81 lastToolNames: readonly string[];
82 lastHit?: number;
83 /** Its cached prefix: cache read + cache creation tokens. */
84 lastPrefix?: number;
85 isCompactPending: boolean;
86};
87
88export type Step = {
89 agentId?: string;
90 index: number;
91 startedMs: number;
92 endedMs: number;
93 usage: TurnUsage | null;
94 toolNames: readonly string[];
95};
96
97export const emptyMain: Main = { lastToolNames: [], isCompactPending: false };
98
99const zeros = <K extends string>(keys: readonly K[]) =>
100 Object.fromEntries(
101 keys.map((k) => [k, { n: 0, read: 0, total: 0 }]),
102 ) as Record<K, Totals>;
103const addTotals = (x: Totals, y: Totals): Totals => ({
104 n: x.n + y.n,
105 read: x.read + y.read,
106 total: x.total + y.total,
107});
108
109export function emptyAgg(updatedAt: number): Agg {
110 return {
111 updatedAt,
112 buckets: zeros(BUCKETS),
113 bands: zeros(BANDS),
114 compactions: 0,
115 modelSwitches: 0,
116 sums: { input: 0, read: 0, created: 0 },
117 };
118}
119
120export function validTtlMinutes(value: unknown): number {
121 return typeof value === "number" && value >= 1 && value <= 1440
122 ? value
123 : DEFAULT_TTL_MINUTES;
124}
125
126export type TtlInputs = {
127 force5m?: string;
128 envTtl?: string;
129 settingTtl?: unknown;
130 enable1h?: string;
131 optionMinutes: number;
132};
133export type Ttl = { minutes: number; source: string };
134
135const ttlWord = (v: unknown) => (v === "5m" ? 5 : v === "1h" ? 60 : undefined);
136
137/** Claude Code's main-conversation TTL order; the plan default is not visible, so the option stands in. */
138export function resolveTtl(i: TtlInputs): Ttl {
139 if (i.force5m === "1")
140 return { minutes: 5, source: "FORCE_PROMPT_CACHING_5M" };
141 const env = ttlWord(i.envTtl);
142 if (env !== undefined)
143 return { minutes: env, source: "CLAUDE_CODE_PROMPT_CACHE_TTL" };
144 const setting = ttlWord(i.settingTtl);
145 if (setting !== undefined)
146 return { minutes: setting, source: "promptCacheTtl" };
147 if (i.enable1h === "1")
148 return { minutes: 60, source: "ENABLE_PROMPT_CACHING_1H" };
149 return { minutes: i.optionMinutes, source: "ttlMinutes option" };
150}
151
152export function cacheTokens(
153 usage: TurnUsage | null,
154): { read: number; total: number } | undefined {
155 if (usage === null) return undefined;
156 const total =
157 usage.input_tokens +
158 usage.cache_read_input_tokens +
159 usage.cache_creation_input_tokens;
160 return total > 0 ? { read: usage.cache_read_input_tokens, total } : undefined;
161}
162
163export function band(gapMs: number): Band {
164 const i = BAND_UPPER_MS.findIndex((upper) => gapMs < upper);
165 return BANDS[i === -1 ? BANDS.length - 1 : i] as Band;
166}
167
168export function classify(
169 main: Main,
170 index: number,
171 isModelSwitch: boolean,
172): Bucket {
173 if (main.lastEndMs === undefined) return "first";
174 if (main.isCompactPending) return "compact";
175 if (isModelSwitch) return "model-switch";
176 if (index === 0) return "user";
177 if (main.lastToolNames.includes("AskUserQuestion")) return "ask";
178 if (main.lastToolNames.includes("Agent")) return "subagent";
179 return "tool";
180}
181
182function addSample(
183 agg: Agg,
184 bucket: Bucket,
185 t: { read: number; total: number },
186 now: number,
187): Agg {
188 return {
189 ...agg,
190 updatedAt: now,
191 buckets: {
192 ...agg.buckets,
193 [bucket]: addTotals(agg.buckets[bucket], { n: 1, ...t }),
194 },
195 };
196}
197
198function addSums(agg: Agg, u: TurnUsage): Agg {
199 const s = agg.sums;
200 return {
201 ...agg,
202 sums: {
203 input: s.input + u.input_tokens,
204 read: s.read + u.cache_read_input_tokens,
205 created: s.created + u.cache_creation_input_tokens,
206 },
207 };
208}
209
210function addBand(
211 agg: Agg,
212 gapMs: number,
213 t: { read: number; total: number },
214): Agg {
215 const name = band(gapMs);
216 return {
217 ...agg,
218 bands: { ...agg.bands, [name]: addTotals(agg.bands[name], { n: 1, ...t }) },
219 };
220}
221
222function addTtlEvidence(agg: Agg, ageMs: number, isWarm: boolean): Agg {
223 return isWarm
224 ? { ...agg, warmMaxMs: Math.max(ageMs, agg.warmMaxMs ?? ageMs) }
225 : { ...agg, coldMinMs: Math.min(ageMs, agg.coldMinMs ?? ageMs) };
226}
227
228/** Folds one finished model request into the aggregates and the main-thread state. */
229export function observe(
230 agg: Agg,
231 main: Main,
232 step: Step,
233): { agg: Agg; main: Main } {
234 const t = cacheTokens(step.usage);
235 // No usage or a zero denominator: no measurable request, so nothing moves.
236 if (t === undefined || step.usage === null) return { agg, main };
237 if (step.agentId !== undefined)
238 return {
239 agg: addSums(addSample(agg, "agent", t, step.endedMs), step.usage),
240 main,
241 };
242
243 const model = step.usage.model;
244 const prefix =
245 step.usage.cache_read_input_tokens + step.usage.cache_creation_input_tokens;
246 const next: Main = {
247 lastStartMs: step.startedMs,
248 lastEndMs: step.endedMs,
249 lastModel: model,
250 lastToolNames: step.toolNames,
251 lastHit: t.read / t.total,
252 lastPrefix: prefix,
253 isCompactPending: false,
254 };
255
256 const isModelSwitch =
257 main.lastModel !== undefined && model !== main.lastModel;
258 const bucket = classify(main, step.index, isModelSwitch);
259 let out = addSums(addSample(agg, bucket, t, step.endedMs), step.usage);
260 if (isModelSwitch) out = { ...out, modelSwitches: out.modelSwitches + 1 };
261 if (bucket === "user" && main.lastEndMs !== undefined)
262 out = addBand(out, step.startedMs - main.lastEndMs, t);
263 const prevPrefix = main.lastPrefix ?? 0;
264 if (bucket === "user" && main.lastStartMs !== undefined && prevPrefix > 0) {
265 out = addTtlEvidence(
266 out,
267 step.startedMs - main.lastStartMs,
268 t.read >= WARM_PREFIX_SHARE * prevPrefix,
269 );
270 }
271 return { agg: out, main: next };
272}
273
274export function noteCompaction(
275 agg: Agg,
276 main: Main,
277 nowMs: number,
278): { agg: Agg; main: Main } {
279 return {
280 agg: { ...agg, updatedAt: nowMs, compactions: agg.compactions + 1 },
281 main: { ...main, isCompactPending: true },
282 };
283}
284
285/** Sets `live` from the main thread's last request; the same aggregate before one with usage. */
286export function withLive(agg: Agg, main: Main, ttlMinutes: number): Agg {
287 const { lastStartMs, lastHit, lastPrefix = 0 } = main;
288 if (lastStartMs === undefined || lastHit === undefined) return agg;
289 return {
290 ...agg,
291 live: { lastStartMs, ttlMinutes, prefix: lastPrefix, hit: lastHit },
292 };
293}
294
295/** Sets the live refresh count and saving, each left out while 0; the same aggregate when unchanged. */
296export function withRefreshLive(
297 agg: Agg,
298 refreshes: number,
299 savedTokens: number,
300): Agg {
301 const live = agg.live;
302 if (live === undefined) return agg;
303 const saved = Math.round(savedTokens);
304 if ((live.refreshes ?? 0) === refreshes && (live.savedTokens ?? 0) === saved)
305 return agg;
306 const { refreshes: _r, savedTokens: _s, ...base } = live;
307 return {
308 ...agg,
309 live: {
310 ...base,
311 ...(refreshes === 0 ? {} : { refreshes }),
312 ...(saved === 0 ? {} : { savedTokens: saved }),
313 },
314 };
315}
316
317/** Sets the stored live TTL and the saving it weighs; the same aggregate when there is none or it is unchanged. */
318export function withTtl(agg: Agg, ttlMinutes: number): Agg {
319 return agg.live === undefined || agg.live.ttlMinutes === ttlMinutes
320 ? agg
321 : withSavedPct({ ...agg, live: { ...agg.live, ttlMinutes } });
322}
323
324/**
325 * The cost share the cache saved, 0 to 100, below 0 when writes cost more than reads saved: a read costs 0.1
326 * of an input token, a write 1.25 at a 5-minute TTL and 2 above it. Undefined before any token.
327 */
328export function savedPct(sums: Sums, ttlMinutes: number): number | undefined {
329 const uncached = sums.input + sums.read + sums.created;
330 if (uncached <= 0) return undefined;
331 const actual =
332 sums.input + 0.1 * sums.read + (ttlMinutes <= 5 ? 1.25 : 2) * sums.created;
333 return Math.round(((uncached - actual) * 100) / uncached);
334}
335
336/** Sets `live.savedPct` from the sums, left out before any token; the same aggregate when unchanged. */
337export function withSavedPct(agg: Agg): Agg {
338 const live = agg.live;
339 if (live === undefined) return agg;
340 const pct = savedPct(agg.sums, live.ttlMinutes);
341 if (live.savedPct === pct) return agg;
342 const { savedPct: _p, ...base } = live;
343 return {
344 ...agg,
345 live: { ...base, ...(pct === undefined ? {} : { savedPct: pct }) },
346 };
347}
348
349const isRecord = (v: unknown): v is Record<string, unknown> =>
350 typeof v === "object" && v !== null && !Array.isArray(v);
351const count = (v: unknown): number =>
352 typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : 0;
353/** A band stored before 0.3.0 is a bare count: its requests, with no tokens and so no hit ratio. */
354const totalsOf = (v: unknown): Totals =>
355 isRecord(v)
356 ? { n: count(v.n), read: count(v.read), total: count(v.total) }
357 : { n: count(v), read: 0, total: 0 };
358const optMs = (v: unknown): number | undefined =>
359 typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : undefined;
360
361function parseLive(v: unknown): CacheLive | undefined {
362 if (!isRecord(v)) return undefined;
363 const lastStartMs = optMs(v.lastStartMs);
364 const prefix = optMs(v.prefix);
365 const ttlMinutes = optMs(v.ttlMinutes);
366 const hit = optMs(v.hit);
367 if (
368 lastStartMs === undefined ||
369 prefix === undefined ||
370 ttlMinutes === undefined ||
371 ttlMinutes === 0 ||
372 hit === undefined ||
373 hit > 1
374 )
375 return undefined;
376 const refreshes = optMs(v.refreshes);
377 const savedTokens =
378 typeof v.savedTokens === "number" && Number.isFinite(v.savedTokens)
379 ? v.savedTokens
380 : undefined;
381 const savedPct =
382 typeof v.savedPct === "number" && Number.isFinite(v.savedPct)
383 ? v.savedPct
384 : undefined;
385 return {
386 lastStartMs,
387 ttlMinutes,
388 prefix,
389 hit,
390 ...(refreshes ? { refreshes } : {}),
391 ...(savedTokens ? { savedTokens } : {}),
392 ...(savedPct === undefined ? {} : { savedPct }),
393 };
394}
395
396/** Reads a stored aggregate defensively; undefined when it is not one. */
397export function parseAgg(value: unknown): Agg | undefined {
398 if (!isRecord(value) || typeof value.updatedAt !== "number") return undefined;
399 const buckets = isRecord(value.buckets) ? value.buckets : {};
400 const bands = isRecord(value.bands) ? value.bands : {};
401 const sums = isRecord(value.sums) ? value.sums : {};
402 const agg = emptyAgg(value.updatedAt);
403 for (const b of BUCKETS) agg.buckets[b] = totalsOf(buckets[b]);
404 for (const b of BANDS) agg.bands[b] = totalsOf(bands[b]);
405 const warmMaxMs = optMs(value.warmMaxMs);
406 const coldMinMs = optMs(value.coldMinMs);
407 const live = parseLive(value.live);
408 const refresh = parseRefresh(value.refresh);
409 return {
410 ...agg,
411 ...(warmMaxMs === undefined ? {} : { warmMaxMs }),
412 ...(coldMinMs === undefined ? {} : { coldMinMs }),
413 ...(live === undefined ? {} : { live }),
414 ...(refresh === undefined ? {} : { refresh }),
415 compactions: count(value.compactions),
416 modelSwitches: count(value.modelSwitches),
417 sums: {
418 input: count(sums.input),
419 read: count(sums.read),
420 created: count(sums.created),
421 },
422 };
423}
424
425const minOf = (a?: number, b?: number) =>
426 a === undefined ? b : b === undefined ? a : Math.min(a, b);
427const maxOf = (a?: number, b?: number) =>
428 a === undefined ? b : b === undefined ? a : Math.max(a, b);
429
430export function mergeAggs(aggs: readonly Agg[]): Agg {
431 return aggs.reduce<Agg>((sum, a) => {
432 const out = emptyAgg(Math.max(sum.updatedAt, a.updatedAt));
433 for (const b of BUCKETS)
434 out.buckets[b] = addTotals(sum.buckets[b], a.buckets[b]);
435 for (const b of BANDS) out.bands[b] = addTotals(sum.bands[b], a.bands[b]);
436 const warmMaxMs = maxOf(sum.warmMaxMs, a.warmMaxMs);
437 const coldMinMs = minOf(sum.coldMinMs, a.coldMinMs);
438 return {
439 ...out,
440 ...(warmMaxMs === undefined ? {} : { warmMaxMs }),
441 ...(coldMinMs === undefined ? {} : { coldMinMs }),
442 compactions: sum.compactions + a.compactions,
443 modelSwitches: sum.modelSwitches + a.modelSwitches,
444 sums: {
445 input: sum.sums.input + a.sums.input,
446 read: sum.sums.read + a.sums.read,
447 created: sum.sums.created + a.sums.created,
448 },
449 };
450 }, emptyAgg(0));
451}
452
453/**
454 * The session keys to delete: each unreadable or untouched for 30 days, then the oldest
455 * until at most MAX_SESSIONS remain with the current one, which is never picked.
456 */
457export function keysToPrune(
458 entries: readonly (readonly [string, unknown])[],
459 currentKey: string | undefined,
460 nowMs: number,
461): string[] {
462 const stale: string[] = [];
463 const kept: { key: string; updatedAt: number }[] = [];
464 for (const [key, value] of entries) {
465 if (key === currentKey) continue;
466 const agg = parseAgg(value);
467 if (agg === undefined || nowMs - agg.updatedAt > PRUNE_AFTER_MS)
468 stale.push(key);
469 else kept.push({ key, updatedAt: agg.updatedAt });
470 }
471 // One place stays free for the current session, stored yet or not.
472 const excess = kept.length - (MAX_SESSIONS - 1);
473 const oldest =
474 excess > 0
475 ? kept.sort((a, b) => a.updatedAt - b.updatedAt).slice(0, excess)
476 : [];
477 return [...stale, ...oldest.map((e) => e.key)];
478}
479
480export function percent(read: number, total: number): string {
481 return total > 0 ? `${Math.round((read / total) * 100)}%` : "–";
482}
483
484const pad = (n: number) => String(n).padStart(2, "0");
485
486export function duration(ms: number): string {
487 const s = Math.max(0, Math.floor(ms / 1000));
488 const h = Math.floor(s / 3600);
489 const m = Math.floor((s % 3600) / 60);
490 if (h > 0) return m > 0 ? `${h}h${pad(m)}m` : `${h}h`;
491 if (m === 0) return `${s % 60}s`;
492 return s % 60 > 0 ? `${m}m${pad(s % 60)}s` : `${m}m`;
493}
494
495export function tokens(n: number): string {
496 if (n < 1000) return String(n);
497 const k = Math.round(n / 1000);
498 return k < 1000 ? `${k}k` : `${(n / 1_000_000).toFixed(1)}M`;
499}
500
501function sessionTotals(agg: Agg): { read: number; total: number } {
502 return Object.values(agg.buckets).reduce(
503 (s, t) => ({ read: s.read + t.read, total: s.total + t.total }),
504 { read: 0, total: 0 },
505 );
506}
507
508export type StatusView = {
509 nowMs: number;
510 ttlMs: number;
511 isInFlight: boolean;
512 main: Main;
513 session: Agg;
514};
515
516export function statusText(v: StatusView): string | undefined {
517 if (v.isInFlight) {
518 const t = sessionTotals(v.session);
519 return t.total > 0 ? `session hit ${percent(t.read, t.total)}` : undefined;
520 }
521 if (
522 v.main.lastStartMs === undefined ||
523 v.main.lastEndMs === undefined ||
524 v.main.lastHit === undefined
525 )
526 return undefined;
527 const idleMs = v.nowMs - v.main.lastEndMs;
528 const leftMs = v.ttlMs - (v.nowMs - v.main.lastStartMs);
529 const cold = leftMs > 0 ? `cold in ~${duration(leftMs)}` : "likely cold";
530 const size =
531 (v.main.lastPrefix ?? 0) > 0
532 ? ` · ${tokens(v.main.lastPrefix ?? 0)} cached`
533 : "";
534 return `idle ${duration(idleMs)} · last hit ${percent(v.main.lastHit, 1)}${size} · ${cold}`;
535}
536
537const MAX_WARN_LEAD_MS = 5 * 60_000;
538
539/**
540 * The toast to show while idle once the time left falls to min(5 min, TTL / 5) and is above 0;
541 * undefined when already warned for this request (`warnedStartMs`), in flight, or no prefix.
542 */
543export function warning(
544 v: StatusView,
545 warnedStartMs?: number,
546): string | undefined {
547 const { lastStartMs, lastEndMs, lastPrefix = 0 } = v.main;
548 if (
549 v.isInFlight ||
550 lastStartMs === undefined ||
551 lastEndMs === undefined ||
552 lastPrefix <= 0 ||
553 lastStartMs === warnedStartMs
554 )
555 return undefined;
556 const leftMs = v.ttlMs - (v.nowMs - lastStartMs);
557 if (leftMs <= 0 || leftMs > Math.min(MAX_WARN_LEAD_MS, v.ttlMs / 5))
558 return undefined;
559 return `cache goes cold in ~${duration(leftMs)} (${tokens(lastPrefix)} tokens)`;
560}
561
562const orDash = (ms?: number) => (ms === undefined ? "–" : duration(ms));
563
564/** A hit rate (rounded, as shown) from this percent is good: green. */
565export const GOOD_HIT_PCT = 80;
566/** Below this the rate is low: red, between the two yellow. Half, as a request that reads less than half its prefix found the cache cold. */
567export const LOW_HIT_PCT = WARM_PREFIX_SHARE * 100;
568
569/** The tone of a token-weighted hit rate; none when there are no tokens to weigh. */
570export function hitTone(read: number, total: number): Tone | undefined {
571 if (total <= 0) return undefined;
572 const pct = Math.round((read / total) * 100);
573 return pct >= GOOD_HIT_PCT ? "good" : pct >= LOW_HIT_PCT ? "warn" : "bad";
574}
575
576/** A table of the rows with requests and their token-weighted hit ratio; `empty` when there are none. */
577function hitTable<K extends string>(
578 head: string,
579 keys: readonly K[],
580 totals: Record<K, Totals>,
581 empty: string,
582): Block {
583 const rows = keys
584 .filter((k) => totals[k].n > 0)
585 .map((k) => {
586 const { n, read, total } = totals[k];
587 const text = percent(read, total);
588 const tone = hitTone(read, total);
589 return {
590 cells: [
591 { text: k },
592 { text: String(n) },
593 tone === undefined ? { text } : { text, tone },
594 ],
595 };
596 });
597 return rows.length > 0
598 ? { table: { head: [head, "Requests", "Hit"], rows } }
599 : line(plain(empty));
600}
601
602export const ttlLine = (ttl: Ttl): Line => ({
603 label: "TTL",
604 runs: [bold(`${ttl.minutes}m`), dim(` (${ttl.source})`)],
605});
606
607/** The `/ember` report for one scope: the TTL, its observed bounds and the counts, then the buckets and the user gap bands with requests. */
608export function report(agg: Agg, ttl: Ttl): Section[] {
609 const status: Line[] = [
610 ttlLine(ttl),
611 {
612 label: "Observed TTL",
613 runs: [
614 plain("warm up to "),
615 bold(orDash(agg.warmMaxMs)),
616 plain(" · cold from "),
617 bold(orDash(agg.coldMinMs)),
618 ],
619 },
620 { label: "Compactions", runs: [bold(String(agg.compactions))] },
621 { label: "Model switches", runs: [bold(String(agg.modelSwitches))] },
622 ];
623 return [
624 { title: "Status", blocks: status.map((l) => ({ line: l })) },
625 {
626 title: "Cache hit by gap type",
627 blocks: [hitTable("Gap type", BUCKETS, agg.buckets, "No requests yet.")],
628 },
629 {
630 title: "Cache hit by user gap",
631 blocks: [hitTable("User gap", BANDS, agg.bands, "No user gaps yet.")],
632 },
633 ];
634}
635hooks/sections.ts 22 lines1/** What a number means to the reader: the pane draws good green, bad red, warn yellow. */
2export type Tone = "good" | "bad" | "warn";
3/** A piece of a line: `bold` sets a value apart, `dim` a label or a caveat. */
4export type Run = { text: string; style?: "bold" | "dim"; tone?: Tone };
5/** One row of the report: a label in its own column, the runs, and a dim caveat under them. */
6export type Line = { label?: string; runs: Run[]; note?: string };
7export type Cell = { text: string; tone?: Tone };
8export type TableRow = { cells: Cell[]; isPick?: boolean; isCurrent?: boolean };
9/** The first column names the row; the others are numbers. */
10export type Table = { head: string[]; rows: TableRow[]; legend?: string };
11/** `pick` is a recommendation, drawn apart from the rest. */
12export type Block = { line: Line } | { table: Table } | { pick: Line };
13/** A section with no title leads the pane, above the titled ones. */
14export type Section = { title?: string; blocks: Block[] };
15
16export const bold = (text: string, tone?: Tone): Run =>
17 tone === undefined ? { text, style: "bold" } : { text, style: "bold", tone };
18export const dim = (text: string): Run => ({ text, style: "dim" });
19export const plain = (text: string): Run => ({ text });
20export const line = (...runs: Run[]): Block => ({ line: { runs } });
21export const pick = (...runs: Run[]): Block => ({ pick: { runs } });
22