Keeps an idle session's prompt cache warm, or compacts it just before it expires, whichever is cheaper, and says on the status line whether the next prompt is…

<img alt="claude-modpack: eight Claude Code mods, each measured on real tasks before it shipped, built to work together. statusline, smart-compact, delegate, lsp-plus, cells, eta, seek, route." src="assets/banner-light.svg" width="880">
<h3>Install in Claude Code</h3>
/plugin install modpack --marketplace HarzerHeribert/claude-modpack
Answer y, then run /modpack:setup to set everything up step by step.<br> Prefer doing it by hand? SETUP.md has every step.
Eight free add-ons that work on their own. One command installs them, and there is nothing new to learn.<br> Every number on this page was measured, including where it doesn't help.
Install · What you get · Honest numbers · The site: ideas and evidence · Lab notes · Questions
<img alt="Animated: the cost per job with and without the pack, as bars that grow: in a long session $0.44 against $0.29 (−33%; measured twice, −26% one day and −40% the next), bigger jobs in a fresh session $0.55 against $0.51 (−8%), quick questions in a fresh session $0.23 against $0.24 (+2%, about even: +5% one day, −4% the next); then four numbers: −33% cost per job in a long session, −21% steps for the same work, −22% cost of a feature a cheaper model builds from your plan, 224 of 224 results correct." src="assets/numbers.gif" width="880">
Every time Claude takes a step (reads a file, makes an edit, runs the tests), it reads your whole conversation again. That re-reading is most of what a session costs, and it grows the longer you work. So the cheapest step is the one Claude never has to take.
The pack takes out steps that need no thinking. Several edits and their test run as one step. A search for a name in your code comes back with the exact answer. And when you approve a plan, a cheaper model does the building while Claude checks the result. The work comes out the same; Claude just gets there in fewer round trips.
<img alt="A real Claude Code session: Claude starts the stress tests in the background; above the prompt a row shows them with a bar, a percentage and a finish time, climbing from 8% to 100%; under the prompt the status line shows the model, the context, the plan's 5-hour and weekly use and a green ready dot." src="assets/demo-band.gif" width="880">
One line under the prompt: the model, how full the conversation is, and how much of your plan's 5-hour and weekly limits you have used, with when each one resets. Long jobs get a progress bar and a finish time above the prompt. And when you step away, your session is kept ready, or tidied up before it goes cold, whichever costs less; the line tells you which.
These are the statusline, eta and smart-compact mods.
<img alt="A real Claude Code session: asked to add two docstrings and run the whole test suite, Claude runs one cell; an orange card shows the read, the two edits, the diff and the test run live, and the tests pass: 1990 passed." src="assets/demo-cells.gif" width="880">
Claude makes several edits and runs the tests as one step, and you watch each part happen in a live card. Every edit still goes through Claude Code's own edit tool: you see each change, and /rewind undoes it.
That's the cells mod.
<img alt="A real Claude Code session: in plan mode Claude plans a new click.Duration parameter type; the plan is approved; Claude hands it to a Sonnet worker, whose orange card shows its steps live with a row above the prompt (hand-off 1, sonnet medium, requests and cost); then Claude checks the diff and reports: the new type works and the full suite passes." src="assets/demo-delegate.gif" width="880">
When you approve a plan, Claude hands the building to Sonnet, a cheaper model, and checks its work before reporting back. In our tests that made a feature 22% cheaper and new tests 26% cheaper, with every result correct (corrected 16 Oct: worker costs were undercounted; it was 27% and 33%). You can watch the helper work in its card, and /delegate shows every step it took.
That's the delegate mod.
A small, fast classifier reads each prompt you send. Only when it is feature or test work is Claude told to hand it straight to delegate's worker: the worker reads the code and builds, delegate checks the result itself, and Claude reports after two steps of its own. On a TypeScript feature that made the job 51% cheaper and 42% faster than the pack alone, with every result correct (corrected 16 Oct: worker costs were undercounted; it was 57%); everything else stays with Claude. The classifier is an outside service (TypeSafe's Jev or ScaleDown), so this is off until you turn it on and give it a key: from then on, the start of each prompt goes to it, in the US, to be classified.
That's the route mod.
When Claude searches your code for a name, it gets the exact list of places that use it, from the same engine your editor uses, instead of reading through search results. Renames and "who calls this?" questions take fewer steps. New in 0.5.0: Go, Kotlin and Ruby code, shell scripts and Terraform. Run /lsp-plus:setup once in your project to turn it on.
That's the lsp-plus mod.
Before Claude changes anything, it looks around your code, and everything it reads stays in the conversation, read again with every later step. Beside the first prompt you send in a session, and again as the session grows, the pack passes on your preference to look around in few, lean steps: the searches it can already name in one go, the lines that matter rather than whole files, nothing read twice. Against the pack without it, that made jobs 12% cheaper in a fresh session, 16% on a TypeScript library and 10 to 16% in long sessions, at the same quality. It runs on your machine and sends nothing anywhere.
That's the seek mod.
<img alt="A real Claude Code session: /mods lists the pack's mods (recorded with the six of 0.4.0, before route and seek) and opens a pane of cards; the status line is turned off and disappears from under the prompt, then turned on again." src="assets/demo-mods.gif" width="880">
/mods lists the eight and turns any of them on or off, right away, with a click or by typing, such as /mods statusline off. When something can't fully work on your computer, the pack tells you once, with the command that fixes it. When another plugin's permission or notification hook hangs (Warp's does outside the Warp terminal), the pack stops waiting for it after 10 seconds, so Claude Code can't freeze, and tells you which one it was. It keeps itself up to date (or only lets you know, if you prefer).
That's the modpack plugin, which installs the other eight.
In Claude Code, type:
/plugin install modpack --marketplace HarzerHeribert/claude-modpack
Answer y to add the marketplace, then pick where to install it. That installs all eight. Then, in the project you work in:
/modpack:setup
It checks your machine, asks once what you want on (route stays off unless you say yes), sets each mod up in order, including the language servers for the languages your project uses, and checks that each one works. Done. /mods shows what's on.
By hand instead: SETUP.md has every step.
Each mod installs on its own the same way, for example /plugin install smart-compact --marketplace HarzerHeribert/claude-modpack. lsp-plus, delegate and cells build on Claude Code's LSP tool, which needs a language server for each language: Language servers lists them.
We measured the pack on Claude Opus 5.5 doing seven real jobs in an open-source project (click), from a one-line lookup to building a feature, on two days, 13 and 15 October. Every result was checked by hidden tests. The same jobs ran with the pack and without it, and we compared what they cost at API prices.
| Cost per job | Without | With the pack | |
|---|---|---|---|
| In a long session (Claude has already read the whole project) | $0.44 | $0.29 | −33% |
| Bigger jobs in a long session (a feature, tests, a rename) | $0.91 | $0.57 | −37% |
| In a fresh session | $0.37 | $0.35 | −4% |
| Bigger jobs in a fresh session | $0.55 | $0.51 | −8%, and 15% faster |
| Quick questions in a fresh session | $0.23 | $0.24 | +2%, about even |
<img alt="Cost per task, plain Claude Code against the modpack, in a fresh session and in a session already holding 216k tokens." src="assets/cost-light.svg" width="880">
<img alt="Model requests per task, plain Claude Code against the modpack: 3.2 against 5.6 in a session already holding 216k tokens." src="assets/requests-light.svg" width="880">
<img alt="Cost per task, Opus alone against a plan handed to a worker: feature work 22% less, tests 26% less." src="assets/delegate-light.svg" width="880">
How we measured, every run's record, and the scripts to run it all again are in benchmarks/, starting with SUITE.md. What we tried that did not make it, a 10,000-star code graph among it, is in the lab notes (as Markdown). Not measured: what smart-compact saves you while you're away (worked out from prices, not tested) and Windows.
Will Claude's work get worse? No. In every run we graded, 730 in all, the result was correct with the pack and without, and every one was graded again for this release. The pack changes how many steps Claude takes to get there, and in our tests the results came out the same.
Is it safe? It's open source (MIT), so every line is here to read. Code that Claude runs inside a cell runs in a sandbox with no network access. Edits go through Claude Code's own edit tool, so your usual permission prompts and /rewind still apply. The pack sends nothing anywhere except a once-a-day check on GitHub for a new release, which you can turn off, and, only if you turn route on, the start of each prompt to the classifier you chose (TypeSafe or ScaleDown).
Does the pack add anything to my prompts? Your prompt itself is never changed. Beside it, Claude can get a short note in your name: seek's setting to look around in few, lean steps (with the first prompt of a session, and again after a compaction), and, only if you turn route on, the order to hand feature or test work to the worker; delegate gives the same order when you approve a plan of 20 lines or more. A subagent Claude starts gets cells' and seek's settings at the end of its task, since your prompts never reach it. /mods seek off and /mods route off stop theirs; subagents: false in cells or seek stops theirs for subagents.
I'm on a Pro or Max plan. Does this help me? You don't pay per job, but every step counts against your 5-hour and weekly limits. We measured cost at API prices, not plan usage, so we can't promise a number. Fewer, lighter steps should leave more of your limits, and the status line shows exactly where you stand.
Does it work in the desktop app? Yes, cards, progress and the status line included. One limit: in auto mode the desktop app doesn't let an add-on start a helper model, so plans aren't handed off there (turn route off there too); in manual mode, or in the terminal, they are. Windows hasn't been tested yet.
How do I turn it off? /mods switches each part off or on, right away. /plugin removes them.
Were any of these numbers wrong? Yes, the hand-off ones. On 16 October we found that our bench had counted a worker's output only as each of its responses began, so every saving that involved a worker came out too large: a planned feature was −27% and is −22%. They are corrected here, the old and new number side by side in CORRECTION-1016.md. The pack-against-plain numbers had no workers and stand.
What does it cost? Nothing. It's free and open source.
One line under the prompt, drawn in Claude Code's own hint row so the mode indicator and the status share it:
⏵⏵ auto mode on Opus 5.5 xhigh 412k ━━━━━━────── 5h 34% ↺2h14 7d 12% ↺3d ● ready · cold in 44m
$.statusline.set({ id, priority, parts, short?, side? }) and lists statusline among its dependencies. A part is text, a countdown ({ until, prefix?, warnMs? }, counted by the line) or a spinner ({ spin: true }), so a mod sends a segment when it changes, not every second. $.statusline.remove({ id }) takes it off./rc) from the desktop app, the web or the phone gets no mod drawing and no status text as of Claude Code 2.1.296.| Option | Default | |
|---|---|---|
enabled | true | false turns the mod off; /mods does it for all of the pack. |
placement | line | tail: appended dim to Claude Code's hint. band: above the prompt. off. |
limits | always | half: a plan window only once it is half used. off: never. |
animate | true | The highlight along the gauge. |
An idle session's prompt cache expires after its TTL: 1 hour on a subscription, 5 minutes in overage or on an API key. The next prompt then writes the whole context to the cache again, at 1.25x (5 min) or 2x (1 h) the input price. For a 400k-token Opus 5.5 session on the 1-hour cache:
| Option | Cost |
|---|---|
| Let it expire, re-cache on return | $3.20 |
| One keep-alive (re-read the cached prefix; a read resets the TTL) | $0.09 |
| Compact before it expires | ~$0.87, plus detail lost to the summary |
A few minutes before the cache expires (2.5 min on the 1-hour TTL, 45 s on the 5-minute one), the smart strategy:
compactPenaltyUsd. It stops after maxIdleHours.minSavingsUsd of re-cache cost, or below minCompactTokens of context, it never compacts.It never compacts while the prompt holds a draft or agents are running; it sends a keep-alive to hold the cache instead. If the machine sleeps past the deadline, it marks the cache expired and does nothing. A failed action is retried twice, 20 s apart. The TTL is detected from the plan and corrected from the cache hits and misses it observes.
On statusline's line it says whether the session is ready to prompt or has gone cold: ● ready, · cold in m:ss (amber in the last five minutes), · compacts in … when an idle compaction is near, ○ cold, or what it is doing (compacting, keeping warm, paused). /cache shows the live figures and plan; /cache pause, /cache resume and /cache keepalive control it.
| Option | Default | |
|---|---|---|
enabled | true | false turns the mod off; /mods does it for all of the pack. |
strategy | smart | compact: compact at the first expiry. keepalive: never compact. off: status line only. |
cacheTtl | auto | 1h or 5m to override detection. |
minSavingsUsd | 0.25 | Below this re-cache cost, the cache is left to expire. |
compactPenaltyUsd | 0.5 | Higher keeps the cache warm longer before compacting. |
minCompactTokens | 100000 | Never compacts a smaller context. |
maxIdleHours | 6 | Stops keep-alives after this long idle. |
compactInstructions | empty | Passed to idle compactions, like /compact <text>. |
Prices are list prices, so on a subscription they are API-equivalent estimates, not a bill.
Every request of the main model re-reads its whole context: at 600k tokens on Opus 5.5 that is about $0.12 a step before any work is done. A worker reads only its own small context. Claude calls hand_off with a task that stands alone, its kind (read: changes no file; mechanical: the task spells out every change; build: something is left to work out), the symbols or file:line places it touches (focus), and for edits a check command. The kind picks the worker, so Claude has no model to weigh: Haiku at low effort for read and mechanical work, Sonnet at medium for build work.
read, with Edit and Write for mechanical and build. The call waits for it inside the tool call (one request of the main model) and returns the report with the evidence; with background it returns at once and the report arrives as a message. A follow-up within 50 minutes goes to the same worker, its reading still in its context.~/.claude/mods/delegate/log.jsonl; /delegate sums it up and opens a side pane. While a worker runs, its card is drawn like a cell (the task, a row per step, the state and cost), and with eta loaded it is a row above the prompt too.Measured on 120 Opus runs, all correct: a feature or tests cost 23–35% less handed off; lookups, traces, explanations and small edits cost more. Opus does not hand off unless told to, so ask for it as an instruction: "hand the tests to a worker". An order the mod gives works as well as yours (8 of 8 runs, 22–26% less), so when you approve a plan of 20 lines or more, delegate gives that order: plan in plan mode, approve, and a Sonnet worker builds it. Corrected 16 Oct: worker costs were undercounted; these were 27–37% and 27–33%.
A verified hand-off (0.3.0). The worker ends its report with a VERIFY block: the command that runs only its new tests, and what it left open. d
hooks/register.tsx 565 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ModelUsage, PluginOptions, Register } from 'claude-code'
3
4import type { SmartCompactCache, SmartCompactWorld } from '../types'
5import { READY_ID, plainText, readySegment } from './line'
6import {
7 DEFAULT_LEARNED,
8 TTL_5M,
9 addEvidence,
10 costsOf,
11 decide,
12 duration,
13 ema,
14 marginMs,
15 observeTtl,
16 pricingFor,
17 resolveTtl,
18 tokens,
19 usd,
20 writeMultiplier,
21} from './policy'
22import type { Decision, Learned, Policy, Situation, Strategy, TtlEvidence, TtlSetting } from './policy'
23
24type Engine = EngineInterface
25
26type Options = {
27 policy: Policy
28 cacheTtl: TtlSetting
29 instructions: string
30}
31
32const INITIAL_CACHE: SmartCompactCache = {
33 phase: 'none',
34 ttlMs: TTL_5M,
35 ttlSource: 'api',
36 plan: 'none',
37 why: '',
38 keptAlive: 0,
39 keptAliveUsd: 0,
40 savedUsd: 0,
41 isPaused: false,
42}
43
44const INITIAL_WORLD: SmartCompactWorld = {
45 isWorking: false,
46 limits: [],
47 isAutoCompactOn: true,
48}
49
50const CACHE = atom({ plugin: 'smart-compact', key: 'cache' } as const, INITIAL_CACHE)
51const WORLD = atom({ plugin: 'smart-compact', key: 'world' } as const, INITIAL_WORLD)
52
53const LEARNED_KEY = 'learned'
54const EVIDENCE_KEY = 'ttlEvidence'
55const SAVED_KEY = 'savedUsd'
56
57const KEEPALIVE_PROMPT =
58 'Automated prompt-cache keep-alive, not a message from the user. Reply with the single word: ok'
59
60function pick<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
61 return allowed.find(a => a === value) ?? fallback
62}
63
64function num(value: unknown, fallback: number): number {
65 return typeof value === 'number' && Number.isFinite(value) ? value : fallback
66}
67
68function readOptions(o: PluginOptions): Options {
69 const strategies = ['smart', 'compact', 'keepalive', 'off'] as const
70
71 return {
72 policy: {
73 strategy: pick<Strategy>(o.strategy, strategies, 'smart'),
74 minSavingsUsd: num(o.minSavingsUsd, 0.25),
75 compactPenaltyUsd: num(o.compactPenaltyUsd, 0.5),
76 minCompactTokens: num(o.minCompactTokens, 100_000),
77 maxIdleMs: num(o.maxIdleHours, 6) * 60 * 60_000,
78 },
79 cacheTtl: pick<TtlSetting>(o.cacheTtl, ['auto', '5m', '1h'], 'auto'),
80 instructions: typeof o.compactInstructions === 'string' ? o.compactInstructions : '',
81 }
82}
83
84/** What a request cost at list price, from the counts the API reported. */
85function spend(usage: ModelUsage, model: string | undefined, ttlMs: number): number {
86 const p = pricingFor(model)
87 const w = writeMultiplier(ttlMs)
88
89 return (
90 (usage.input_tokens * p.input +
91 usage.cache_creation_input_tokens * p.input * w +
92 usage.cache_read_input_tokens * p.cacheRead +
93 usage.output_tokens * p.output) /
94 1e6
95 )
96}
97
98function promptTokens(usage: ModelUsage): number {
99 return usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
100}
101
102let options: Options = readOptions({})
103
104let learned: Learned = DEFAULT_LEARNED
105let evidence: TtlEvidence | undefined
106let isDraft = false
107let isActing = false
108let isTicking = false
109let isAfterCompact = false
110let lastPublished = ''
111/** Without the statusline mod, the segment is the session's status text. */
112let isOwnText = false
113/** Failed attempts at the current edge, and when to try again. */
114let failures = 0
115let retryAt: number | undefined
116
117async function getCache($: Engine): Promise<SmartCompactCache> {
118 return { ...INITIAL_CACHE, ...(await read($, CACHE)) }
119}
120
121async function getWorld($: Engine): Promise<SmartCompactWorld> {
122 return { ...INITIAL_WORLD, ...(await read($, WORLD)) }
123}
124
125function setCache($: Engine, change: (c: SmartCompactCache) => Partial<SmartCompactCache>) {
126 return update($, CACHE, value => {
127 const c = { ...INITIAL_CACHE, ...value }
128 return { ...c, ...change(c) }
129 })
130}
131
132function setWorld($: Engine, change: (w: SmartCompactWorld) => Partial<SmartCompactWorld>) {
133 return update($, WORLD, value => {
134 const w = { ...INITIAL_WORLD, ...value }
135 return { ...w, ...change(w) }
136 })
137}
138
139function policyFor(world: SmartCompactWorld): Policy {
140 // Auto-compaction switched off reads as "never compact for me".
141 const isCompactionOff = !world.isAutoCompactOn && options.policy.strategy === 'smart'
142 return isCompactionOff ? { ...options.policy, strategy: 'keepalive' } : options.policy
143}
144
145function situation(c: SmartCompactCache, at: number, canCompact: boolean): Situation {
146 return {
147 contextTokens: c.contextTokens ?? 0,
148 ttlMs: c.ttlMs,
149 pricing: pricingFor(c.model),
150 learned,
151 keptAliveUsd: c.keptAliveUsd,
152 idleMs: at - (c.idleSince ?? at),
153 canCompact,
154 }
155}
156
157async function addSaved($: Engine, amount: number) {
158 if (amount === 0) return
159
160 const lifetime = num(await $.store.get(SAVED_KEY), 0)
161 await $.store.set(SAVED_KEY, lifetime + amount)
162 await setCache($, c => ({ savedUsd: c.savedUsd + amount }))
163}
164
165async function load($: Engine) {
166 const stored = await $.store.get(LEARNED_KEY)
167 const seen = await $.store.get(EVIDENCE_KEY)
168
169 if (stored !== null && typeof stored === 'object') {
170 learned = { ...DEFAULT_LEARNED, ...(stored as Partial<Learned>) }
171 }
172 if (seen !== null && typeof seen === 'object') {
173 evidence = seen as TtlEvidence
174 }
175}
176
177async function refreshWorld($: Engine, isDeep: boolean) {
178 const usage = await $.session.usage(isDeep ? { breakdown: 'summary' } : undefined)
179 const breakdown = usage.context.breakdown
180 await setWorld($, () => ({
181 limits: usage.rateLimits.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt === undefined ? undefined : Date.parse(l.resetsAt) })),
182 ...(breakdown === undefined ? {} : { isAutoCompactOn: breakdown.isAutoCompactEnabled }),
183 }))
184 // After a reload or a resume the cache has a size before any response.
185 const known = usage.context.tokens
186 if (known !== undefined) {
187 await setCache($, c => (c.contextTokens === undefined && c.phase === 'none' ? { contextTokens: known } : {}))
188 }
189}
190
191/** Settles the TTL and what will happen at the TTL's edge, for the line. */
192async function replan($: Engine) {
193 const now = await $.clock.now()
194 const world = await getWorld($)
195 const ttl = resolveTtl(options.cacheTtl, evidence, world.limits, now)
196 const c = await getCache($)
197
198 if (c.phase !== 'warm' || c.warmAt === undefined || c.contextTokens === undefined) {
199 await setCache($, () => ({ ttlMs: ttl.ms, ttlSource: ttl.source, actAt: undefined }))
200 return
201 }
202
203 const edge = c.warmAt + ttl.ms - marginMs(ttl.ms)
204 const actAt = retryAt !== undefined && retryAt > edge ? retryAt : edge
205 const d = decide(policyFor(world), situation({ ...c, ttlMs: ttl.ms }, actAt, true))
206
207 await setCache($, () => ({ ttlMs: ttl.ms, ttlSource: ttl.source, actAt, plan: d.action, why: d.why }))
208}
209
210async function keepAlive($: Engine, c: SmartCompactCache, d: Decision) {
211 const startedAt = await $.clock.now()
212 const r = await $.model.fork({ prompt: KEEPALIVE_PROMPT })
213
214 if (!r.isAnswered && r.reason === 'nothing-to-fork') {
215 await setCache($, () => ({ phase: 'none', plan: 'none', actAt: undefined }))
216 return
217 }
218 if (!r.isAnswered && (r.reason === 'api-error' || r.reason === 'aborted')) {
219 throw new Error(r.reason === 'api-error' ? `API error ${r.status}` : 'aborted')
220 }
221
222 const n = c.contextTokens ?? 0
223 const cost = spend(r.usage, c.model, c.ttlMs)
224 const credit = (x: SmartCompactCache) => ({
225 lapseUsd: x.credit?.lapseUsd ?? d.costs.lapse,
226 spentUsd: (x.credit?.spentUsd ?? 0) + cost,
227 })
228
229 if (r.usage.cache_read_input_tokens >= 0.8 * n) {
230 await setCache($, x => ({
231 phase: 'warm',
232 warmAt: startedAt,
233 keptAlive: x.keptAlive + 1,
234 keptAliveUsd: x.keptAliveUsd + cost,
235 credit: credit(x),
236 }))
237 } else {
238 // The entry was gone before its time: the TTL is shorter than assumed.
239 if (c.ttlMs > TTL_5M) {
240 evidence = addEvidence(evidence, '5m', startedAt)
241 await $.store.set(EVIDENCE_KEY, evidence)
242 }
243 const isRewritten = r.usage.cache_creation_input_tokens >= 0.8 * n
244 await setCache($, x => ({
245 phase: isRewritten ? 'warm' : 'cold',
246 warmAt: isRewritten ? startedAt : x.warmAt,
247 keptAliveUsd: x.keptAliveUsd + cost,
248 credit: undefined,
249 }))
250 await addSaved($, -cost - (c.credit?.spentUsd ?? 0))
251 $.ui.log(`smart-compact: the cache had already expired; keep-alive cost ${usd(cost)}`)
252 }
253}
254
255/** Compacts; false when a hook vetoed it, so this edge is left alone. */
256async function compactNow($: Engine, c: SmartCompactCache, d: Decision): Promise<boolean> {
257 const startedAt = await $.clock.now()
258 const r = await $.session.compact(options.instructions ? { instructions: options.instructions } : {})
259
260 if (r.skip !== undefined) {
261 await setCache($, () => ({ phase: 'warm', plan: 'lapse', why: `compaction skipped: ${r.skip}`, actAt: undefined }))
262 return false
263 }
264
265 const cost = r.usage === undefined ? d.costs.compact : spend(r.usage, c.model, c.ttlMs)
266 const left = (c.warmAt ?? startedAt) + c.ttlMs - startedAt
267
268 await setCache($, x => ({
269 phase: 'compacted',
270 credit: { lapseUsd: x.credit?.lapseUsd ?? d.costs.lapse, spentUsd: (x.credit?.spentUsd ?? 0) + cost },
271 }))
272 $.ui.log(
273 `smart-compact: compacted ${tokens(c.contextTokens ?? 0)}` +
274 (r.tokensAfter === undefined ? '' : ` → ${tokens(r.tokensAfter)}`) +
275 ` while idle, ${duration(left)} before the cache expired` +
276 ` (re-caching it would have cost ${usd(d.costs.lapse)})`,
277 )
278 return true
279}
280
281async function act($: Engine, c: SmartCompactCache, world: SmartCompactWorld, now: number) {
282 const agents = await $.agent.list().catch(() => [])
283 const isAgentBusy = agents.some(a => a.status === 'running' || a.status === 'pending' || a.status === 'waiting')
284 const d = decide(policyFor(world), situation(c, now, !isDraft && !isAgentBusy))
285
286 if (d.action !== 'keepalive' && d.action !== 'compact') {
287 // Nothing worth doing: the cache lapses, and so does what was spent.
288 await setCache($, () => ({ plan: d.action, why: d.why, actAt: undefined }))
289 return
290 }
291
292 isActing = true
293 await setCache($, () => ({ phase: 'acting', plan: d.action, why: d.why }))
294 await publish($).catch(() => undefined)
295
296 let isDone = false
297
298 try {
299 isDone = d.action === 'keepalive' ? (await keepAlive($, c, d), true) : await compactNow($, c, d)
300 failures = 0
301 retryAt = undefined
302 } catch (err) {
303 // Back off, and give the edge up after three tries.
304 failures += 1
305 retryAt = failures < 3 ? now + 20_000 : undefined
306 const reason = err instanceof Error ? err.message : String(err)
307 await setCache($, x => ({
308 phase: x.phase === 'acting' ? 'warm' : x.phase,
309 actAt: retryAt,
310 ...(retryAt === undefined ? { plan: 'lapse' as const, why: `${d.action} failed: ${reason}` } : {}),
311 }))
312 $.ui.log(`smart-compact: ${d.action} failed (${reason})${retryAt === undefined ? '; giving up' : '; retrying in 20s'}`)
313 } finally {
314 isActing = false
315 await publish($).catch(() => undefined)
316 }
317
318 if (isDone) await replan($)
319}
320
321/**
322 * Puts the segment on the statusline mod's line when it changed. Without that mod the same words
323 * are the session's status text, said again every tick so the countdown moves.
324 */
325async function publish($: Engine) {
326 const now = await $.clock.now()
327 const segment = readySegment(await getCache($), { now, isWorking: (await getWorld($)).isWorking })
328 const key = JSON.stringify(segment ?? null)
329 if (key === lastPublished && !isOwnText) return
330 try {
331 if (segment === undefined) await $.statusline.remove({ id: READY_ID })
332 else await $.statusline.set(segment)
333 if (isOwnText) $.ui.status(undefined)
334 isOwnText = false
335 } catch {
336 // No statusline mod in this session: the session's status text says it.
337 $.ui.status(segment === undefined ? undefined : plainText(segment, now))
338 isOwnText = segment !== undefined
339 }
340 lastPublished = key
341}
342
343async function tick($: Engine) {
344 if (isTicking || isActing) return
345 isTicking = true
346
347 try {
348 const now = await $.clock.now()
349 const c = await getCache($)
350 const world = await getWorld($)
351 if (c.phase === 'warm' && c.warmAt !== undefined && !world.isWorking) {
352 const expiry = c.warmAt + c.ttlMs
353
354 if (now >= expiry) {
355 // Asleep past the edge, or nothing was worth doing: count what was spent.
356 await setCache($, () => ({ phase: 'cold', plan: 'none', why: 'expired', actAt: undefined, credit: undefined }))
357 await addSaved($, -(c.credit?.spentUsd ?? 0))
358 return
359 }
360
361 const isOn = !c.isPaused && options.policy.strategy !== 'off'
362 if (isOn && c.actAt !== undefined && now >= c.actAt) {
363 await act($, c, world, now)
364 return
365 }
366
367 }
368
369 } finally {
370 isTicking = false
371 await publish($).catch(() => undefined)
372 }
373}
374
375async function onMainResponse($: Engine, startedAt: number, index: number, usage: ModelUsage & { model: string }) {
376 const n = promptTokens(usage)
377 const c = await getCache($)
378
379 const isSameMain = c.phase !== 'compacted' && c.model === usage.model
380 if (index === 0 && isSameMain && c.warmAt !== undefined && c.contextTokens !== undefined) {
381 const seen = observeTtl(startedAt - c.warmAt, c.contextTokens, usage)
382 if (seen !== undefined) {
383 evidence = addEvidence(evidence, seen, startedAt)
384 await $.store.set(EVIDENCE_KEY, evidence)
385 }
386 }
387
388 // The person is back: settle what keeping the cache cost against a lapse.
389 if (index === 0 && c.credit !== undefined) {
390 const p = pricingFor(usage.model)
391 const rewrite = (usage.cache_creation_input_tokens * p.input * writeMultiplier(c.ttlMs)) / 1e6
392 const isHit = usage.cache_read_input_tokens >= 0.5 * (c.contextTokens ?? 0)
393 const saved =
394 c.phase === 'compacted'
395 ? c.credit.lapseUsd - c.credit.spentUsd - rewrite
396 : isHit
397 ? c.credit.lapseUsd - c.credit.spentUsd
398 : -c.credit.spentUsd
399 await addSaved($, saved)
400 }
401
402 if (isAfterCompact) {
403 isAfterCompact = false
404 learned = { ...learned, postCompactTokens: ema(learned.postCompactTokens, n) }
405 await $.store.set(LEARNED_KEY, learned)
406 }
407
408 await setCache($, () => ({ phase: 'warm', model: usage.model, contextTokens: n, warmAt: startedAt, credit: undefined }))
409}
410
411export const register: Register = (on, rawOptions) => {
412 if (rawOptions.enabled === false) return
413 options = readOptions(rawOptions)
414 learned = DEFAULT_LEARNED
415 evidence = undefined
416 failures = 0
417 retryAt = undefined
418
419 on('session.start', async ($, e, next) => {
420 await $.command.register({
421 name: 'cache',
422 description: 'Prompt cache: what idling costs, and what smart-compact will do about it',
423 argumentHint: '[pause | resume | keepalive]',
424 })
425
426 await load($)
427 // A reload mid-action leaves no action running.
428 await setCache($, c => (c.phase === 'acting' ? { phase: 'warm' } : {}))
429 await refreshWorld($, true).catch(() => undefined)
430 await replan($)
431 $.clock.every(1_000, () => void tick($))
432
433 return next(e)
434 })
435
436 on('classic.SessionStart', async ($, e, next) => {
437 const seconds = e.seconds_since_last_response
438 const isResumed = e.source === 'resume' || e.source === 'fork'
439
440 if (isResumed && seconds !== undefined && e.context_tokens) {
441 const now = await $.clock.now()
442 await setCache($, () => ({
443 phase: e.prompt_cache_likely_expired ? 'cold' : 'warm',
444 warmAt: now - seconds * 1000,
445 contextTokens: e.context_tokens,
446 model: e.model,
447 idleSince: now - seconds * 1000,
448 }))
449 await replan($)
450 }
451
452 return next(e)
453 }).catch(($, e, next) => next(e))
454
455 on('session.end', { reason: 'clear' }, async ($, e, next) => {
456 await setCache($, c => ({ ...INITIAL_CACHE, savedUsd: c.savedUsd, isPaused: c.isPaused }))
457 return next(e)
458 })
459
460 on('turn.start', async ($, e, next) => {
461 failures = 0
462 retryAt = undefined
463 await setWorld($, () => ({ isWorking: true }))
464 await setCache($, () => ({ actAt: undefined, idleSince: undefined, keptAlive: 0, keptAliveUsd: 0 }))
465 await publish($).catch(() => undefined)
466 return next(e)
467 })
468
469 on('turn.step', async function* ($, e, next) {
470 const startedAt = await $.clock.now()
471 const result = yield* next(e)
472
473 if (e.agentId === undefined && result.usage !== null) {
474 await onMainResponse($, startedAt, e.index, result.usage)
475 }
476
477 return result
478 })
479
480 on('turn.complete', async ($, e, next) => {
481 const result = await next(e)
482 if (e.agentId !== undefined) return result
483
484 const now = await $.clock.now()
485 await setWorld($, () => ({ isWorking: false }))
486 await setCache($, () => ({ idleSince: now, keptAlive: 0, keptAliveUsd: 0 }))
487 await refreshWorld($, false)
488 await replan($)
489
490 return result
491 })
492
493 on('session.compact', async ($, e, next) => {
494 const result = await next(e)
495 if (e.agentId !== undefined || e.trigger === 'precompute' || result.skip !== undefined) return result
496
497 if (result.usage !== undefined) {
498 const prompt = promptTokens(result.usage)
499 learned = {
500 summaryTokens: ema(learned.summaryTokens, result.usage.output_tokens),
501 postCompactTokens: learned.postCompactTokens,
502 compactCacheShare:
503 prompt > 0 ? ema(learned.compactCacheShare, result.usage.cache_read_input_tokens / prompt) : learned.compactCacheShare,
504 }
505 await $.store.set(LEARNED_KEY, learned)
506 }
507
508 isAfterCompact = true
509 await setCache($, () => ({ phase: 'compacted', contextTokens: undefined, warmAt: undefined, plan: 'none', why: '', actAt: undefined }))
510 // The window was re-read: the threshold may move with it.
511 await refreshWorld($, true).catch(() => undefined)
512
513 return result
514 }).catch(($, e, next) => next(e))
515
516 on('command.run', { command: 'cache' }, async ($, e) => {
517 const arg = e.args.trim().toLowerCase()
518
519 if (arg === 'pause' || arg === 'resume') {
520 await setCache($, () => ({ isPaused: arg === 'pause' }))
521 return { text: arg === 'pause' ? 'smart-compact paused for this session.' : 'smart-compact resumed.' }
522 }
523
524 const now = await $.clock.now()
525 const c = await getCache($)
526 const world = await getWorld($)
527
528 if (arg === 'keepalive') {
529 if (c.phase !== 'warm' || world.isWorking) return { text: 'Nothing to keep warm right now.' }
530 const d = decide({ ...options.policy, strategy: 'keepalive', minSavingsUsd: 0 }, situation(c, now, false))
531 await keepAlive($, c, d)
532 await replan($)
533 const after = await getCache($)
534 return { text: `Kept warm: ${duration(after.warmAt === undefined ? 0 : after.warmAt + after.ttlMs - now)} left.` }
535 }
536
537 const p = pricingFor(c.model)
538 const lifetime = num(await $.store.get(SAVED_KEY), 0)
539 const lines = [
540 `smart-compact · ${options.policy.strategy}${c.isPaused ? ' (paused)' : ''} · ${p.label} · TTL ${duration(c.ttlMs)} (${c.ttlSource})`,
541 ]
542
543 if (c.contextTokens !== undefined && c.warmAt !== undefined && c.phase === 'warm') {
544 const costs = costsOf(c.contextTokens, c.ttlMs, p, learned)
545 lines.push(`cache warm, ${duration(c.warmAt + c.ttlMs - now)} left · ${tokens(c.contextTokens)} tokens`)
546 if (c.actAt !== undefined) lines.push(`next ${c.plan} in ${duration(c.actAt - now)}: ${c.why}`)
547 lines.push(
548 `costs lapse ${usd(costs.lapse)} · keep-alive ${usd(costs.keepAlive)} · ` +
549 `compact ${usd(costs.compact)} + ${usd(options.policy.compactPenaltyUsd)} for lost detail`,
550 )
551 } else {
552 lines.push(`cache ${c.phase}${c.contextTokens === undefined ? '' : ` · ${tokens(c.contextTokens)} tokens`}`)
553 }
554
555 if (c.keptAlive > 0) lines.push(`idle ${c.keptAlive} keep-alive(s), ${usd(c.keptAliveUsd)}`)
556 lines.push(`saved ${usd(Math.max(0, c.savedUsd))} this session · ${usd(Math.max(0, lifetime))} all time (list price)`)
557 lines.push(
558 `learned summary ~${tokens(learned.summaryTokens)} · after ~${tokens(learned.postCompactTokens)} · ` +
559 `compaction cache hit ${Math.round(learned.compactCacheShare * 100)}%`,
560 )
561
562 return { text: lines.join('\n') }
563 })
564}
565hooks/line.ts 62 lines1// smart-compact's segment on the status line: whether the next prompt reads a
2// warm cache, so it is cheap, or a cold one. The statusline mod draws it at the
3// right edge, where the eye lands; what changes by itself (the countdown, the
4// spinner while the mod acts, the blink before it does) is declared so, and the
5// line moves it. Without the statusline mod, the same words are the session's
6// status text.
7
8import type { SmartCompactCache } from '../types'
9import { duration } from './policy'
10
11export type Tone = 'accent' | 'good' | 'text' | 'label' | 'faint' | 'warn' | 'alert'
12
13/** The statusline mod's part shapes: text (that may blink), a countdown, a spinner. */
14export type ReadyPart =
15 | { text: string; tone: Tone; blink?: { from: number; to: number } }
16 | { until: number; tone: Tone; prefix?: string; warnMs?: number }
17 | { spin: true; tone: Tone }
18
19export type ReadySegment = { id: string; priority: number; parts: ReadyPart[]; short?: ReadyPart[]; side?: 'start' | 'end'; calm?: boolean }
20
21export const READY_ID = 'smart-compact:ready'
22
23const text = (t: string, tone: Tone): ReadyPart => ({ text: t, tone })
24
25/**
26 * 'ready' while the next prompt reads a warm cache, with what ends that once it will (a lapse, or
27 * a compaction within ten minutes); while smart-compact keeps it warm, nothing more. Cold is
28 * amber: the next prompt pays to read the whole context again. Undefined before any request.
29 */
30export function readySegment(cache: SmartCompactCache, m: { now: number; isWorking: boolean }): ReadySegment | undefined {
31 const of = (parts: ReadyPart[], short?: ReadyPart[], calm = false): ReadySegment => ({ id: READY_ID, priority: 0, side: 'end', parts, ...(short ? { short } : {}), ...(calm ? { calm } : {}) })
32 // Past its edge a warm cache is cold, whether or not the tick has said so.
33 const isExpired = cache.warmAt !== undefined && cache.warmAt + cache.ttlMs <= m.now
34
35 if (cache.isPaused) return of([text('◌', 'faint'), text(' paused', 'label')])
36 if (cache.phase === 'acting') return of([{ spin: true, tone: 'accent' }, text(cache.plan === 'compact' ? ' compacting' : ' keeping warm', 'label')])
37 if (cache.phase === 'compacted') return of([text('◌', 'accent'), text(' compacted', 'label')])
38 if (cache.phase === 'cold' || (cache.phase === 'warm' && !m.isWorking && isExpired)) return of([text('○', 'warn'), text(' cold', 'warn')])
39 if (cache.phase === 'warm' && m.isWorking) return of([text('●', 'good')], undefined, true)
40 if (cache.phase !== 'warm' || cache.warmAt === undefined) return undefined
41
42 const edge = cache.warmAt + cache.ttlMs
43 // A blink in the last twenty seconds before the mod acts.
44 const dot: ReadyPart = cache.actAt === undefined ? text('●', 'good') : { text: '●', tone: 'good', blink: { from: cache.actAt - 20_000, to: cache.actAt } }
45 const short = [dot, text(' ready', 'label')]
46 // Routine: warm with more than five minutes to go and nothing about to be done.
47 const isCalm = edge - m.now > 5 * 60_000
48
49 if (cache.plan === 'compact' && cache.actAt !== undefined && cache.actAt - m.now < 10 * 60_000) {
50 return of([...short, { until: cache.actAt, prefix: ' · compacts in ', tone: 'label' }], short)
51 }
52 if (cache.plan !== 'keepalive' && cache.plan !== 'compact') {
53 return of([...short, { until: edge, prefix: ' · cold in ', tone: 'label', warnMs: 5 * 60_000 }], short, isCalm)
54 }
55 return of(short, undefined, isCalm)
56}
57
58/** The segment as one plain string at 'now': what the session's status text says without the statusline mod. */
59export function plainText(segment: ReadySegment, now: number): string {
60 return segment.parts.map(p => ('spin' in p ? '◐' : 'until' in p ? (p.prefix ?? '') + duration(p.until - now) : p.text)).join('')
61}
62hooks/policy.ts 313 lines1// What to do with an idle session's prompt cache just before it expires.
2//
3// Three options, priced in list-price USD:
4// lapse let it expire; the next prompt re-writes the whole prefix
5// keepalive re-send the prefix once (a fork); a cache read refreshes the TTL
6// compact summarize while the cache is still warm; the next prompt then
7// re-writes only the small summary
8//
9// Keep-alives are cheap but recur every TTL, compaction is a one-off but loses
10// detail. So `smart` keeps the cache warm until the keep-alives would have
11// cost as much as compacting (plus a price on the lost detail), then compacts:
12// the ski-rental rule, never worse than twice the best choice in hindsight.
13
14export const TTL_5M = 5 * 60_000
15export const TTL_1H = 60 * 60_000
16
17/** List prices, USD per million tokens (Anthropic API, cached 2026-10-06). */
18export type Pricing = {
19 label: string
20 input: number
21 output: number
22 cacheRead: number
23 /** Haiku 5.5 charges more once a prompt passes 100K tokens. */
24 long?: { above: number; input: number; output: number; cacheRead: number }
25}
26
27const PRICES: ReadonlyArray<readonly [RegExp, Pricing]> = [
28 [/(fable|mythos)-5-1/, { label: 'Fable 5.1', input: 10, output: 50, cacheRead: 0.25 }],
29 [/(fable|mythos)-5/, { label: 'Fable 5', input: 10, output: 50, cacheRead: 1 }],
30 [/opus-5-5/, { label: 'Opus 5.5', input: 4, output: 20, cacheRead: 0.2 }],
31 [/opus-5/, { label: 'Opus 5', input: 5, output: 25, cacheRead: 0.5 }],
32 [/opus-4-(\d)/, { label: 'Opus 4', input: 5, output: 25, cacheRead: 0.5 }],
33 [/sonnet-5-5/, { label: 'Sonnet 5.5', input: 2, output: 10, cacheRead: 0.2 }],
34 [/sonnet-5/, { label: 'Sonnet 5', input: 2, output: 10, cacheRead: 0.2 }],
35 [/sonnet-4/, { label: 'Sonnet 4', input: 3, output: 15, cacheRead: 0.3 }],
36 [
37 /haiku-5-5/,
38 {
39 label: 'Haiku 5.5',
40 input: 0.1,
41 output: 0.5,
42 cacheRead: 0.01,
43 long: { above: 100_000, input: 0.5, output: 2.5, cacheRead: 0.05 },
44 },
45 ],
46 [/haiku/, { label: 'Haiku 4.5', input: 1, output: 5, cacheRead: 0.1 }],
47]
48
49const FALLBACK: Pricing = { label: 'Claude', input: 5, output: 25, cacheRead: 0.5 }
50
51export function pricingFor(model: string | undefined): Pricing {
52 const id = (model ?? '').toLowerCase().replace(/\./g, '-')
53 const hit = PRICES.find(([pattern]) => pattern.test(id))
54
55 if (hit) {
56 // Opus 4.x: name the minor version the id carries.
57 const minor = /opus-4-(\d)/.exec(id)?.[1]
58 return minor ? { ...hit[1], label: `Opus 4.${minor}` } : hit[1]
59 }
60
61 return { ...FALLBACK, label: model ?? FALLBACK.label }
62}
63
64/** Cache writes cost 1.25x input on the 5-minute TTL, 2x on the 1-hour one. */
65export function writeMultiplier(ttlMs: number): number {
66 return ttlMs > TTL_5M ? 2 : 1.25
67}
68
69function perToken(p: Pricing, promptTokens: number) {
70 const r = p.long && promptTokens > p.long.above ? p.long : p
71 return { input: r.input / 1e6, output: r.output / 1e6, cacheRead: r.cacheRead / 1e6 }
72}
73
74/** What compaction has been seen to produce, learned across sessions. */
75export type Learned = {
76 /** Output tokens of a compaction (the summary, with its thinking). */
77 summaryTokens: number
78 /** Context of the first request after a compaction. */
79 postCompactTokens: number
80 /** Share of a compaction's prompt the cache served (0..1). */
81 compactCacheShare: number
82}
83
84export const DEFAULT_LEARNED: Learned = {
85 summaryTokens: 8_000,
86 postCompactTokens: 40_000,
87 compactCacheShare: 0.9,
88}
89
90/** A keep-alive asks for one word; adaptive thinking may add a little. */
91export const KEEPALIVE_OUTPUT_TOKENS = 300
92
93export type Costs = {
94 /** Re-writing the whole prefix when the user comes back to a cold cache. */
95 lapse: number
96 /** One keep-alive: a cache read of the prefix and a one-word reply. */
97 keepAlive: number
98 /** Compacting now, and re-writing the summary when the user comes back. */
99 compact: number
100}
101
102export function costsOf(
103 contextTokens: number,
104 ttlMs: number,
105 pricing: Pricing,
106 learned: Learned,
107): Costs {
108 const w = writeMultiplier(ttlMs)
109 const at = perToken(pricing, contextTokens)
110 const after = Math.min(learned.postCompactTokens, contextTokens)
111 const share = Math.min(1, Math.max(0, learned.compactCacheShare))
112
113 const compactRequest =
114 contextTokens * (share * at.cacheRead + (1 - share) * w * at.input) +
115 learned.summaryTokens * at.output
116 const rewriteSummary = after * w * perToken(pricing, after).input
117
118 return {
119 lapse: contextTokens * w * at.input,
120 keepAlive: contextTokens * at.cacheRead + KEEPALIVE_OUTPUT_TOKENS * at.output,
121 compact: compactRequest + rewriteSummary,
122 }
123}
124
125export type Strategy = 'smart' | 'compact' | 'keepalive' | 'off'
126
127export type Policy = {
128 strategy: Strategy
129 /** Below this re-cache cost, let the cache lapse: not worth acting on. */
130 minSavingsUsd: number
131 /** What losing the conversation's detail to a summary is worth avoiding. */
132 compactPenaltyUsd: number
133 /** Never compact a context smaller than this. */
134 minCompactTokens: number
135 /** Stop keeping the cache warm after this long idle. */
136 maxIdleMs: number
137}
138
139export type Situation = {
140 contextTokens: number
141 ttlMs: number
142 pricing: Pricing
143 learned: Learned
144 /** Spent on keep-alives since the session went idle. */
145 keptAliveUsd: number
146 idleMs: number
147 /** False while the prompt holds a draft or agents still run. */
148 canCompact: boolean
149}
150
151export type Action = 'keepalive' | 'compact' | 'lapse' | 'none'
152
153export type Decision = { action: Action; why: string; costs: Costs }
154
155export function decide(policy: Policy, s: Situation): Decision {
156 const costs = costsOf(s.contextTokens, s.ttlMs, s.pricing, s.learned)
157 const answer = (action: Action, why: string): Decision => ({ action, why, costs })
158
159 if (policy.strategy === 'off') {
160 return answer('none', 'off')
161 }
162
163 if (costs.lapse < policy.minSavingsUsd) {
164 return answer('lapse', `re-caching costs only ${usd(costs.lapse)}`)
165 }
166
167 const compactAll = costs.compact + policy.compactPenaltyUsd
168 const compactPays =
169 policy.strategy !== 'keepalive' &&
170 s.contextTokens >= policy.minCompactTokens &&
171 compactAll < costs.lapse
172 const affords = (budget: number) => s.keptAliveUsd + costs.keepAlive <= budget
173 const bridge = 'compaction waits for the draft or the agents'
174
175 if (policy.strategy === 'compact') {
176 if (compactPays && s.canCompact) {
177 return answer('compact', `saves ~${usd(costs.lapse - costs.compact)}`)
178 }
179 if (compactPays && affords(costs.lapse - compactAll)) {
180 return answer('keepalive', bridge)
181 }
182 return answer('lapse', compactPays ? 'compaction blocked' : 'compaction would not pay')
183 }
184
185 // Rent (keep warm) until renting has cost what buying (compacting) would.
186 const budget = compactPays ? compactAll : costs.lapse / 2
187
188 if (s.idleMs < policy.maxIdleMs && affords(budget)) {
189 return answer('keepalive', `${usd(costs.keepAlive)} keeps ${usd(costs.lapse)} of cache`)
190 }
191 if (compactPays && s.canCompact) {
192 return answer('compact', `kept warm for ${usd(s.keptAliveUsd)}; compacting saves the rest`)
193 }
194 if (compactPays && affords(costs.lapse)) {
195 return answer('keepalive', bridge)
196 }
197
198 return answer('lapse', 'keeping it warm no longer pays')
199}
200
201/** When to act: early enough that the request starts before the entry lapses. */
202export function marginMs(ttlMs: number): number {
203 return ttlMs > TTL_5M ? 150_000 : 45_000
204}
205
206export type TtlSetting = 'auto' | '5m' | '1h'
207
208export type TtlEvidence = { ttl: '5m' | '1h'; at: number; count: number }
209
210export type RateLimit = { kind: string; percentUsed: number }
211
212export type Ttl = { ms: number; source: string }
213
214const EVIDENCE_TTL_MS = 14 * 24 * 60 * 60_000
215
216/**
217 * The TTL the session's requests use. Claude Code caches subscription traffic
218 * for an hour and drops to five minutes in overage; API-key traffic defaults
219 * to five minutes. What a cache hit or miss after a gap showed wins over that.
220 */
221export function resolveTtl(
222 setting: TtlSetting,
223 evidence: TtlEvidence | undefined,
224 limits: readonly RateLimit[],
225 now: number,
226): Ttl {
227 if (setting !== 'auto') {
228 return { ms: setting === '1h' ? TTL_1H : TTL_5M, source: 'set' }
229 }
230
231 const plan = limits.filter(l => l.kind === 'five_hour' || l.kind === 'seven_day')
232
233 if (plan.some(l => l.percentUsed >= 100)) {
234 return { ms: TTL_5M, source: 'overage' }
235 }
236
237 const isFresh = evidence !== undefined && now - evidence.at < EVIDENCE_TTL_MS
238 const isTrusted = isFresh && (evidence.ttl === '1h' || evidence.count >= 2)
239
240 if (isTrusted) {
241 return { ms: evidence.ttl === '1h' ? TTL_1H : TTL_5M, source: 'observed' }
242 }
243
244 return plan.length > 0 ? { ms: TTL_1H, source: 'plan' } : { ms: TTL_5M, source: 'api' }
245}
246
247export type CacheUsage = {
248 cache_read_input_tokens: number
249 cache_creation_input_tokens: number
250}
251
252/**
253 * What a request after a gap says about the TTL: a hit past five minutes
254 * can only be the hour-long one; a full re-write well inside the hour points
255 * at five minutes (or an invalidated prefix, hence two sightings are needed).
256 */
257export function observeTtl(
258 gapMs: number,
259 previousTokens: number,
260 usage: CacheUsage,
261): '5m' | '1h' | undefined {
262 if (previousTokens < 4_096) {
263 return undefined
264 }
265
266 const read = usage.cache_read_input_tokens / previousTokens
267 const wrote = usage.cache_creation_input_tokens / previousTokens
268
269 if (gapMs > TTL_5M + 60_000 && read >= 0.5) {
270 return '1h'
271 }
272 if (gapMs > TTL_5M + 15_000 && gapMs < TTL_1H - 5 * 60_000 && read < 0.2 && wrote >= 0.5) {
273 return '5m'
274 }
275
276 return undefined
277}
278
279export function addEvidence(
280 previous: TtlEvidence | undefined,
281 ttl: '5m' | '1h',
282 now: number,
283): TtlEvidence {
284 const count = previous?.ttl === ttl ? previous.count + 1 : 1
285 return { ttl, at: now, count }
286}
287
288/** Moves a learned average toward a new sighting. */
289export function ema(previous: number, sample: number, weight = 0.3): number {
290 return Math.round((previous * (1 - weight) + sample * weight) * 1000) / 1000
291}
292
293export function usd(value: number): string {
294 if (value < 0.01) return '<$0.01'
295 return value < 10 ? `$${value.toFixed(2)}` : `$${Math.round(value)}`
296}
297
298export function tokens(n: number): string {
299 if (n < 1_000) return `${n}`
300 if (n < 100_000) return `${(n / 1_000).toFixed(n < 10_000 ? 1 : 0).replace(/\.0$/, '')}k`
301 if (n < 1_000_000) return `${Math.round(n / 1_000)}k`
302 return `${(n / 1_000_000).toFixed(2).replace(/0$/, '')}M`
303}
304
305export function duration(ms: number): string {
306 const s = Math.max(0, Math.round(ms / 1000))
307 if (s < 600) return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
308 const m = Math.round(s / 60)
309 if (m < 60) return `${m}m`
310 const h = Math.floor(m / 60)
311 return m % 60 === 0 ? `${h}h` : `${h}h${String(m % 60).padStart(2, '0')}`
312}
313types/index.d.ts 54 lines1export type SmartCompactPhase = 'none' | 'warm' | 'acting' | 'cold' | 'compacted'
2
3export type SmartCompactPlan = 'keepalive' | 'compact' | 'lapse' | 'none'
4
5/** The main thread's prompt cache, as the controller tracks it. */
6export type SmartCompactCache = {
7 phase: SmartCompactPhase
8 /** API id of the model that answered last, e.g. `claude-opus-5-5`. */
9 model?: string
10 /** Prompt tokens of the main thread's last request: what a lapse re-writes. */
11 contextTokens?: number
12 /** When the last request that read or wrote the prefix started (ms). */
13 warmAt?: number
14 ttlMs: number
15 ttlSource: string
16 /** What happens at `actAt`, and why. */
17 plan: SmartCompactPlan
18 why: string
19 actAt?: number
20 /** When the session last went idle, and what keeping it warm has cost since. */
21 idleSince?: number
22 keptAlive: number
23 keptAliveUsd: number
24 /**
25 * What lapsing would have cost and what the mod spent instead, credited to
26 * `savedUsd` only once the person comes back and the next request runs.
27 */
28 credit?: { lapseUsd: number; spentUsd: number }
29 /** This session's savings, net of keep-alives and compactions. */
30 savedUsd: number
31 /** `/cache pause`: draw, but never act. */
32 isPaused: boolean
33}
34
35export type SmartCompactLimit = { kind: string; percentUsed: number; resetsAt?: number }
36
37/** What the controller reads of the session beside its cache. */
38export type SmartCompactWorld = {
39 isWorking: boolean
40 /** Off reads as "never compact for me". */
41 isAutoCompactOn: boolean
42 /** The plan's windows: a subscription's cache lives an hour, an API key's five minutes. */
43 limits: SmartCompactLimit[]
44}
45
46declare module 'claude-code' {
47 interface PluginState {
48 'smart-compact': {
49 cache: SmartCompactCache
50 world: SmartCompactWorld
51 }
52 }
53}
54