SLOPSHOPPER

ember

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

newpanecommandtoaststatusmodel
v0.4.2no licenseupdated 2026-10-22davegomez/mods/ember
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ember
│ ┃ ember ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ ember q: Close │ ember │ │ ┃ ⏺ Read(src/auth.ts) │ ember probe: no idle pause yet; finish a │ │ ┃ Status ⎿ Read 6 lines │ turn first │ │ ┃ TTL 60m (ttlMinutes option) ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ┃ Observed TTL warm up to – · cold from – ⎿ Added 2 lines, removed 1 line │ ┃ Compactions 0 ⏺ Bash(bun test) │ ┃ Model switches 0 ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /ember │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · ember
ember q: Close Status Cache hit by TTL 60m (ttlMinutes option) No requests y Observed TTL warm up to – · cold from – Compactions 0 Model switches 0
Pane · ember refresh
ember refresh q: Close ╭──────────────────────────────────────────────────────────╮ │ Not enough data for a recommendation: 0 returned pauses, │ │ 20 needed. Keep the defaults. │ ╰──────────────────────────────────────────────────────────╯ Status Refresh on Lead 5m (auto) TTL 60m (ttlMinutes option) Idle compaction not seen, ember refreshes long conversation Sample 0 pauses (0 returned, 0 exits) · 0 refreshe
README

ember

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.

Tutorial: see your cache warmth

In this tutorial, we start a session with ember, send 2 short prompts, and read the cache report. It takes about 3 minutes.

  1. If ember is not installed, install it as shown above. Then start a new Claude Code session.
  1. Send this prompt and wait for the answer:
   Reply with the word hello.
  1. Run /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.
  1. Click Close at the top right of the pane.
  1. Within 2 minutes, send a second prompt:
   Reply with the word again.
  1. Run /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.
  1. Click 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.

How-to guides

How to turn the refresh off

  1. Open ~/.claude/settings.json.
  2. Add the option under the plugin ID ember@davegomez.
   {
     "pluginConfigs": {
       "ember@davegomez": {
         "options": { "refresh": false }
       }
     }
   }
  1. Start a new session.

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.

How to set the TTL for your plan

ember cannot see your plan, so it assumes 60 minutes unless a Claude Code setting says otherwise.

  • If you use a Claude subscription within its usage, change nothing. Claude Code uses a 60-minute TTL.
  • If you use an API key, a cloud provider, or usage credits, Claude Code uses a 5-minute TTL. Set "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.
  • If you want the 60-minute TTL everywhere, set "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.

How to show the cache in your status line

  1. In your status line command, read session_id from stdin.
  2. Find the ember store file and read the 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)%"'
  1. Print nothing when the file, the key, or 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.

How to tune the refresh

  1. Use Claude Code as usual until /ember-refresh has at least 20 returned pauses.
  2. Run /ember-refresh.
  3. Read the Recommendation box at the top of the pane.
  4. If it says to turn the refresh off, turn it off.
  5. If it names a different lead, set refreshLeadSeconds to that value.
  6. If your TTL is 5 minutes or less and it says to set 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.
  7. If it says Not enough data, wait for more pauses.
  8. If the refresh paused itself after 2 misses, read the Refresh age table. Misses at ages below your TTL mean that the real TTL is shorter. Set the TTL for your plan.
  9. If the pane ends with an 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.

How to check that the refresh works

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.

  1. Start a new Claude Code session with ember installed.
  2. Build a cached prefix of at least 10k tokens, for example by asking Claude to read a large file, and let the turn finish.
  3. Within 1 minute, run /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.
  4. Stay idle for 10 minutes, then run /ember-probe again. Expect a hit again, because the first probe restarted the TTL.
  5. Send a one-word prompt, then run /ember. Check that the user row has a high hit ratio and that the agent and first rows did not grow.
  6. To check the TTL edge, start a session with CLAUDE_CODE_PROMPT_CACHE_TTL=5m, finish a turn, wait 6 minutes, and run /ember-probe. Expect a miss with a read near 0.
  7. Run /ember-refresh and read the refresh rows.

If steps 3 to 5 pass, the refresh works on your account.

How to keep the cache of a long conversation

On the 60-minute TTL, Claude Code can compact an idle conversation about 54 minutes after the last request.

  • If idle compaction does not run for your account, change nothing. ember already keeps the cache warm. /ember-refresh shows Idle compaction as not seen in its Status section.
  • If idle compaction runs, ember yields to it, and the conversation is replaced by a summary. To keep the full conversation instead, which ember then refreshes:
  1. Run /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.
  2. Set "idleCompaction": false in ~/.claude/settings.json.
  3. Start a new session.

Reference

Commands

| 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.

The /ember pane

The 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.

The /ember-refresh pane

The 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 too
  • seen <YYYY-MM-DD>, ember skips the pauses it applies to

When 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:

  • The cost of keeping the full conversation with "idleCompaction": false, with refresh at your lead and with no refresh.
  • A lower bound for idle compaction's own cost: 1 cache read of the conversation per pause. The summary's output and its write are not measured.
  • The extra cost of keeping the full conversation, as an upper bound, in total and per pause.
  • A recommendation.

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.                                              │
╰────────────────────────────────────────────────────────────────────╯

Options

| 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.

TTL resolution

ember takes the first match, in Claude Code's own order:

  1. FORCE_PROMPT_CACHING_5M=1: 5 minutes.
  2. CLAUDE_CODE_PROMPT_CACHE_TTL set to 5m or 1h.
  3. The promptCacheTtl setting set to 5m or 1h.
  4. ENABLE_PROMPT_CACHING_1H=1: 60 minutes.
  5. The 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).

Refresh policy

| 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. |

Notifications

| 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. |

Live test results

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.

Stored data

ember

Source 6 files
hooks/register.tsx 881 lines
1import 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};
881
hooks/analysis.ts 762 lines
1import 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}
762
hooks/pane.tsx 221 lines
1import 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}
221
hooks/refresh.ts 625 lines
1import 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}
625
hooks/stats.ts 635 lines
1import 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}
635
hooks/sections.ts 22 lines
1/** 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