Compacts an idle Claude Code session just before its prompt cache expires, so coming back to it doesn't re-send the whole conversation at full price.

<h1 align="center"><img src="assets/wordmark.svg" width="600" alt="Claude Cache Compactor: Claude Code Dept. of Sanitation, pickup 58 minutes after your last request"></h1> <img alt="Claude Code plugin" src="https://img.shields.io/badge/Claude%20Code-plugin-6e7781?style=flat-square&labelColor=30363d"> <img alt="Requires Claude Code 2.1.294 or later" src="https://img.shields.io/badge/requires-2.1.294%2B-6e7781?style=flat-square&labelColor=30363d"> <img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-6e7781?style=flat-square&labelColor=30363d">
<video src="https://github.com/user-attachments/assets/d20cc53f-7970-4dc2-b845-a9cf7e9bfe90" width="720" controls muted playsinline></video>
On a Claude subscription, Claude Code keeps your conversation in Anthropic's prompt cache for an hour, and every request resets that hour. Step away for longer and your next prompt sends the whole conversation again, written back to the cache at twice the normal input price.
Cache Compactor compacts the session two minutes before the cache expires, while compacting is still cheap:
compacted after 58 min idle, before the prompt cache expires: 152k → 9k tokens (48k read from the cache).This is an independent plugin, not an Anthropic product.
Needs Claude Code 2.1.294 or later.
/plugin marketplace add htxryan/claude-cache-compactor
/plugin install cache-compactor@claude-cache-compactor
To try it without installing: claude --plugin-dir path/to/claude-cache-compactor.
There's nothing to set up: it runs in every interactive session.
/cache-compactor:off. The status line shows auto-compact off until /cache-compactor:on turns it back on. It stays off when you resume that session; /clear starts the next conversation with it on./cache-compactor:status says when the session will be compacted, or why it won't be, and what happened last time./context), compacting costs about as much as it saves. See minTokens.--continue, --resume) count from their last response, so one resumed within the hour still gets compacted. One whose last event was a compaction waits for your next prompt.claude -p) are never compacted.sequenceDiagram
participant You
participant Plugin as Cache Compactor
participant Claude as Claude Code
You->>Claude: prompt
Claude->>Claude: requests to Claude, each resetting the cache's hour
Claude-->>Plugin: turn.complete
Plugin->>Plugin: timer for 58 min after the last request
alt you send another prompt
Claude-->>Plugin: turn.start cancels the timer
else the session stays idle
Plugin->>Claude: $.session.compact()
Claude-->>You: Conversation compacted
Plugin-->>You: compacted after 58 min idle…
end
How long the cache lasts. Claude Code uses a one-hour cache on a Claude subscription and five minutes otherwise. The plugin works out each session's cache by the same rules, in this order:
DISABLE_PROMPT_CACHING (or the variable for the session's model) turns caching off, and the plugin with it.FORCE_PROMPT_CACHING_5M, then CLAUDE_CODE_PROMPT_CACHE_TTL, then the promptCacheTtl setting, then ENABLE_PROMPT_CACHING_1H.CLAUDE_CODE_USE_*) use five minutes.By default the plugin acts only on one-hour caches: on a five-minute cache it would compact after every short pause. Set minCacheTtlSeconds to 300 to take those sessions too. They then compact 5 minutes minus leadSeconds after each request: three minutes by default. To give an API-key session a one-hour cache, set Claude Code's own promptCacheTtl to 1h; the plugin follows it.
Set them in /plugin → cache-compactor, or in ~/.claude/settings.json.
minCacheTtlSeconds: the shortest prompt cache the plugin acts on at all: 3600 by default, so only one-hour caches. 300 or less also takes five-minute caches. It never changes when the plugin compacts, which is always the cache's lifetime minus leadSeconds.leadSeconds: how long before the cache expires to compact: 120 by default. It leaves time for the compaction's own request to start while the cache is warm.minTokens: conversations smaller than this are left alone: 30000 by default, measured like the Messages row of /context. 0 compacts every idle session.instructions: what the summary should keep, as you would type after /compact. Empty uses Claude Code's own.{
"pluginConfigs": {
"cache-compactor@claude-cache-compactor": {
"options": {
"minCacheTtlSeconds": 3600, // act only on caches this long; 300 also takes five-minute ones
"leadSeconds": 120, // compact this long before the cache expires
"minTokens": 30000, // 0 compacts every idle session
"instructions": "" // e.g. "Keep the open TODOs and file paths."
}
}
}
}
/compact. instructions steers what it keeps.PreCompact hook can veto it. It is skipped, and the next turn sets the timer again.The plugin is a mod: TypeScript in hooks/register.ts. Besides these events, it reads only Claude Code's prompt-caching variables (listed above), the promptCacheTtl setting, the session's model, its usage figures and, when a session is resumed, its transcript. In its own store it keeps the ids of sessions whose last event was a compaction, and of sessions you turned it off for. It sends nothing anywhere: its one request is the compaction, made by Claude Code.
turn.start: cancels the timer while a turn runs.turn.step: notes when each main-conversation request starts. Subagents' requests have a cache of their own.turn.complete: sets the timer, counted from the turn's last request.session.compact: notes any compaction of the main conversation as the last thing that happened.classic.SessionStart: counts a resumed session from its last response, unless a compaction came after it.session.end: clears the timer on /clear.prompt.submit: names invalid settings, once.command.run: answers /cache-compactor:status, :off and :on.claude plugin test . # 40 tests
/compact reads the cache.hooks/register.ts 268 lines1import type { EngineInterface, Register, Timer } from 'claude-code'
2
3import { check, compactedText, describe, dueAt, expiresAt, offText, onText, readSettings, resolveCache } from '../compact/plan'
4import type { Cache, Last, Settings, State } from '../compact/plan'
5
6const message = (err: unknown) => (err instanceof Error ? err.message : String(err))
7
8// Per-session bookkeeping, reset when the session ends (/clear, /resume).
9type Session = State & { timer: Timer | null; due: number | null; last: Last | null; cache: Cache | null; gen: number; marked: boolean }
10
11function cancel(ss: Session): void {
12 ss.timer?.cancel()
13 ss.timer = null
14 ss.due = null
15}
16
17// A headless run (claude -p) ends with its answer: nobody comes back to it.
18async function interactive($: EngineInterface): Promise<boolean> {
19 return (await $.session.surfaces()).length > 0
20}
21
22// How long this session's cache lasts, from the variables and setting Claude
23// Code reads for prompt caching, the model, and the plan's usage windows.
24async function readCache($: EngineInterface, s: Settings): Promise<Cache> {
25 const env = {
26 DISABLE_PROMPT_CACHING: await $.env.get('DISABLE_PROMPT_CACHING'),
27 DISABLE_PROMPT_CACHING_FABLE: await $.env.get('DISABLE_PROMPT_CACHING_FABLE'),
28 DISABLE_PROMPT_CACHING_OPUS: await $.env.get('DISABLE_PROMPT_CACHING_OPUS'),
29 DISABLE_PROMPT_CACHING_SONNET: await $.env.get('DISABLE_PROMPT_CACHING_SONNET'),
30 DISABLE_PROMPT_CACHING_HAIKU: await $.env.get('DISABLE_PROMPT_CACHING_HAIKU'),
31 FORCE_PROMPT_CACHING_5M: await $.env.get('FORCE_PROMPT_CACHING_5M'),
32 CLAUDE_CODE_PROMPT_CACHE_TTL: await $.env.get('CLAUDE_CODE_PROMPT_CACHE_TTL'),
33 ENABLE_PROMPT_CACHING_1H: await $.env.get('ENABLE_PROMPT_CACHING_1H'),
34 CLAUDE_CODE_USE_BEDROCK: await $.env.get('CLAUDE_CODE_USE_BEDROCK'),
35 CLAUDE_CODE_USE_VERTEX: await $.env.get('CLAUDE_CODE_USE_VERTEX'),
36 CLAUDE_CODE_USE_FOUNDRY: await $.env.get('CLAUDE_CODE_USE_FOUNDRY'),
37 }
38 const settings = await $.settings.read()
39 const usage = await $.session.usage().catch(() => null)
40 return resolveCache(s, { env, promptCacheTtl: settings.promptCacheTtl, model: await $.session.model(), rateLimits: usage?.rateLimits ?? [] })
41}
42
43// Session ids under a key of the plugin's store (newest 100 kept), so a
44// resumed session knows: `compacted`, whose last event was a compaction, and
45// `off`, turned off with /cache-compactor:off.
46async function stored($: EngineInterface, key: 'compacted' | 'off'): Promise<boolean> {
47 const map = (await $.store.get(key)) as Record<string, number> | undefined
48 return !!map && (await $.session.id()) in map
49}
50
51async function store($: EngineInterface, key: 'compacted' | 'off', value: boolean): Promise<void> {
52 const id = await $.session.id()
53 const map = { ...(((await $.store.get(key)) as Record<string, number> | undefined) ?? {}) }
54 delete map[id]
55 if (value) map[id] = await $.clock.now()
56 await $.store.set(key, Object.fromEntries(Object.entries(map).slice(-100)))
57}
58
59async function remember($: EngineInterface, ss: Session, compacted: boolean): Promise<void> {
60 ss.marked = compacted
61 await store($, 'compacted', compacted)
62}
63
64async function compactedLast($: EngineInterface): Promise<boolean> {
65 if (await stored($, 'compacted')) return true
66 // A session compacted before this plugin knew it: the summary, then only
67 // the replies compaction kept, no prompt of yours.
68 const messages = await $.session.messages()
69 const summary = messages.findLastIndex(m => m.role === 'user' && m.text.includes('continued from a previous conversation'))
70 return summary >= 0 && !messages.slice(summary + 1).some(m => m.role === 'user' && m.text.trim() !== '')
71}
72
73// The conversation's size, the Messages row of /context (estimated locally, no
74// request): the system prompt and tools stay whatever compaction does.
75async function conversationTokens($: EngineInterface): Promise<number | undefined> {
76 const usage = await $.session.usage({ breakdown: 'summary' }).catch(() => null)
77 return usage?.context.breakdown?.categories.find(c => c.name === 'Messages')?.tokens
78}
79
80// Sets the timer for the cache's last minutes, counted from the last request.
81// It reads everything first and decides after, with `settle` applied then: a
82// turn that started meanwhile (`gen` moved on) leaves it unset.
83async function arm($: EngineInterface, ss: Session, s: Settings, settle: () => void = () => {}): Promise<void> {
84 const gen = ss.gen
85 const isInteractive = await interactive($)
86 const cache = await readCache($, s)
87 const now = await $.clock.now()
88 if (ss.gen !== gen) return
89 settle()
90 cancel(ss)
91 ss.cache = cache
92 if (ss.off || ss.anchor === null || ss.lastWasCompact || ss.turning || !isInteractive || cache.ttlMs === null) return
93 // A resumed session whose cache has already expired has nothing left to save.
94 if (now >= expiresAt(ss.anchor, cache.ttlMs)) return
95 const at = dueAt(ss.anchor, cache.ttlMs, s)
96 ss.due = at
97 ss.timer = $.clock.after(Math.max(0, at - now), () => void fire($, ss, s, at))
98}
99
100// The timer went off: compact, unless something changed since it was set.
101async function fire($: EngineInterface, ss: Session, s: Settings, at: number): Promise<void> {
102 if (ss.due !== at) return
103 ss.timer = null
104 ss.due = null
105 const now = await $.clock.now()
106 const tokens = await conversationTokens($)
107 const verdict = check(ss, now, tokens, ss.cache ?? { ttlMs: null, why: 'unknown' }, s)
108 if (!verdict.compact) {
109 ss.last = { at: now, outcome: 'skipped', reason: verdict.reason }
110 $.ui.log(`didn't compact: ${verdict.reason}.`, { to: 'debug' })
111 return
112 }
113 const idleMs = now - ss.anchor!
114 $.ui.status('compacting before the prompt cache expires…')
115 try {
116 const result = await $.session.compact(s.instructions ? { instructions: s.instructions } : {})
117 if (result.skip !== undefined) {
118 // Another plugin or a PreCompact hook vetoed it; the engine says why.
119 ss.last = { at: now, outcome: 'skipped', reason: `a hook skipped it (${result.skip})` }
120 return
121 }
122 ss.lastWasCompact = true
123 await remember($, ss, true)
124 const done: Last = { at: now, outcome: 'compacted', idleMs, before: result.tokensBefore, after: result.tokensAfter, cacheRead: result.usage?.cache_read_input_tokens }
125 ss.last = done
126 $.ui.log(compactedText(done))
127 } catch (err) {
128 // A prompt sent while it was getting ready: that turn sets a new timer.
129 const reason = ss.turning ? 'a turn started first' : message(err)
130 ss.last = { at: now, outcome: 'failed', reason }
131 if (!ss.turning) $.ui.log(`couldn't compact before the prompt cache expires: ${reason}`)
132 } finally {
133 $.ui.status(undefined)
134 }
135}
136
137// A resumed session: still off if it was turned off, and its cache may still
138// be warm, so the timer counts from its last response, unless a compaction
139// came after it.
140async function resumed($: EngineInterface, ss: Session, s: Settings, secondsSinceResponse: number | undefined): Promise<void> {
141 if (await stored($, 'off')) {
142 ss.off = true
143 $.ui.status('auto-compact off')
144 }
145 if (secondsSinceResponse === undefined || ss.turning) return
146 if (await compactedLast($)) {
147 ss.lastWasCompact = ss.marked = true
148 return
149 }
150 const anchor = (await $.clock.now()) - secondsSinceResponse * 1000
151 await arm($, ss, s, () => {
152 ss.anchor = anchor
153 ss.lastWasCompact = false
154 })
155}
156
157export const register: Register = (on, options) => {
158 const invalid: string[] = []
159 const s = readSettings(options, invalid)
160 let warned = false // invalid settings were named once in this process
161 const ss: Session = { anchor: null, lastWasCompact: false, turning: false, timer: null, due: null, last: null, cache: null, gen: 0, marked: false, off: false }
162
163 // /cache-compactor:status (commands/status.md), answered here without the model.
164 on('command.run', { command: 'cache-compactor:status' }, async $ => ({
165 text: describe({ state: ss, now: await $.clock.now(), due: ss.due, last: ss.last, cache: ss.cache, interactive: await interactive($) }),
166 })).catch(() => ({ text: "cache-compactor couldn't read its status." }))
167
168 // /cache-compactor:off and :on (commands/off.md, on.md), for this session
169 // only: kept across --resume, gone with /clear (a new conversation).
170 on('command.run', { command: 'cache-compactor:off' }, async $ => {
171 const was = ss.off
172 ss.off = true
173 cancel(ss)
174 await store($, 'off', true)
175 $.ui.status('auto-compact off')
176 return { text: offText(was) }
177 }).catch(() => ({ text: "cache-compactor couldn't turn itself off." }))
178
179 on('command.run', { command: 'cache-compactor:on' }, async $ => {
180 const was = !ss.off
181 ss.off = false
182 await store($, 'off', false)
183 $.ui.status(undefined)
184 await arm($, ss, s)
185 const now = await $.clock.now()
186 // Past the moment it would have compacted: you are here, so wait for your next prompt.
187 if (ss.due !== null && ss.due <= now) {
188 cancel(ss)
189 return { text: onText(was, 'The timer starts after your next prompt.') }
190 }
191 return { text: onText(was, describe({ state: ss, now, due: ss.due, last: null, cache: ss.cache, interactive: await interactive($) })) }
192 }).catch(() => ({ text: "cache-compactor couldn't turn itself on." }))
193
194 on('prompt.submit', async ($, e, next) => {
195 if (invalid.length && !warned) {
196 warned = true
197 $.ui.log(`ignoring invalid settings: ${invalid.join('; ')}.`)
198 }
199 return next(e)
200 }).catch(($, e, next) => next(e))
201
202 // A main-conversation turn: no timer while it runs, and what it says next
203 // is not a compaction. (Subagents' runs raise no turn.start.)
204 on('turn.start', async ($, e, next) => {
205 cancel(ss)
206 ss.gen += 1
207 ss.turning = true
208 ss.lastWasCompact = false
209 if (ss.marked) await remember($, ss, false)
210 return next(e)
211 }).catch(($, e, next) => next(e))
212
213 // Each main-conversation request reads or writes the cache, so its start is
214 // the moment the cache's hour counts from. Subagents' requests have a cache
215 // of their own.
216 on('turn.step', async function* ($, e, next) {
217 if (e.agentId === undefined) ss.anchor = await $.clock.now()
218 return yield* next(e)
219 })
220
221 // The response is in, or the turn was interrupted or failed: start the timer
222 // from the turn's last request.
223 on('turn.complete', async ($, e, next) => {
224 const result = await next(e)
225 if (e.agentId === undefined) {
226 await arm($, ss, s, () => {
227 ss.turning = false
228 ss.lastWasCompact = false
229 })
230 }
231 return result
232 })
233
234 // Any compaction of the main conversation (/compact, the automatic one, a
235 // plugin's) counts as the last thing that happened until the next turn.
236 on('session.compact', async ($, e, next) => {
237 const result = await next(e)
238 if (e.agentId === undefined && e.trigger !== 'precompute' && result.skip === undefined) {
239 ss.lastWasCompact = true
240 if (!ss.turning) cancel(ss)
241 await remember($, ss, true)
242 }
243 return result
244 }).catch(($, e, next) => next(e))
245
246 // A resumed session: its settings and timer, then the event passes on unchanged.
247 on('classic.SessionStart', async ($, e, next) => {
248 if (e.source === 'resume' || e.source === 'fork') await resumed($, ss, s, e.seconds_since_last_response)
249 return next(e)
250 }).catch(($, e, next) => next(e))
251
252 // /clear, or /resume of another conversation, ends the session without a
253 // new session.start.
254 on('session.end', async ($, e, next) => {
255 cancel(ss)
256 ss.gen += 1
257 ss.anchor = null
258 ss.lastWasCompact = false
259 ss.turning = false
260 ss.last = null
261 ss.cache = null
262 ss.marked = false
263 if (ss.off) $.ui.status(undefined)
264 ss.off = false
265 return next(e)
266 })
267}
268compact/plan.ts 186 lines1// Pure timing logic: how long the cache lasts, when to compact, whether to,
2// and how to say what happened. No `$` here.
3
4export type Ttl = '5m' | '1h'
5
6export type Settings = {
7 minCacheTtlMs: number // sessions whose cache lasts less are left alone
8 leadMs: number // how long before it expires to compact
9 minTokens: number // smaller conversations are left alone
10 instructions: string // what the summary should keep, as typed after /compact
11}
12
13export const DEFAULTS = { minCacheTtlSeconds: 3600, leadSeconds: 120, minTokens: 30000 } as const
14
15const TTL_MS: Record<Ttl, number> = { '5m': 5 * 60_000, '1h': 60 * 60_000 }
16
17// A number setting in a range; anything else falls back to its default and is
18// named in `invalid`.
19function num(name: string, value: unknown, fallback: number, ok: (n: number) => boolean, invalid: string[]): number {
20 if (value === undefined || value === null || value === '') return fallback
21 const n = Number(value)
22 if (Number.isFinite(n) && ok(n)) return n
23 invalid.push(`${name} "${String(value)}" (using ${fallback})`)
24 return fallback
25}
26
27const asTtl = (v: unknown): Ttl | null => {
28 const t = typeof v === 'string' ? v.trim().toLowerCase() : ''
29 return t === '5m' || t === '1h' ? t : null
30}
31
32export function readSettings(options: Readonly<Record<string, unknown>>, invalid: string[]): Settings {
33 const minCacheTtlSeconds = num('minCacheTtlSeconds', options.minCacheTtlSeconds, DEFAULTS.minCacheTtlSeconds, n => n >= 0, invalid)
34 const leadSeconds = num('leadSeconds', options.leadSeconds, DEFAULTS.leadSeconds, n => n >= 0 && n < 3600, invalid)
35 const minTokens = num('minTokens', options.minTokens, DEFAULTS.minTokens, n => n >= 0, invalid)
36 const instructions = typeof options.instructions === 'string' ? options.instructions.trim() : ''
37 return { minCacheTtlMs: minCacheTtlSeconds * 1000, leadMs: leadSeconds * 1000, minTokens, instructions }
38}
39
40// How long this session's cache lasts, and how that was decided. `ttlMs` is
41// null when there is nothing to do: caching is off, the cache lasts less than
42// minCacheTtlSeconds, or no shorter than leadSeconds.
43export type Cache = { ttlMs: number | null; why: string }
44
45// What the session tells about its cache: Claude Code's environment variables
46// and settings, its model, and the rate-limit windows its last response
47// reported (a Claude subscription has five_hour and seven_day ones).
48export type Signals = {
49 env: Readonly<Record<string, string | undefined>>
50 promptCacheTtl: unknown
51 model: string
52 rateLimits: readonly { kind: string; percentUsed: number }[]
53}
54
55const on = (v: string | undefined) => v !== undefined && /^(1|true|yes|on)$/i.test(v.trim())
56
57const FAMILIES = ['fable', 'opus', 'sonnet', 'haiku']
58
59// Claude Code's rules, from https://code.claude.com/docs/en/prompt-caching:
60// one hour on a subscription within plan usage, five minutes with an API key,
61// a cloud provider or usage credits, and the variables and setting first.
62export function resolveCache(s: Settings, sig: Signals): Cache {
63 const family = FAMILIES.find(f => sig.model.toLowerCase().includes(f))
64 const off = ['DISABLE_PROMPT_CACHING', ...(family ? [`DISABLE_PROMPT_CACHING_${family.toUpperCase()}`] : [])].find(name => on(sig.env[name]))
65 if (off) return { ttlMs: null, why: `prompt caching is off (${off})` }
66 const [ttl, why] = autoTtl(sig)
67 const ttlMs = TTL_MS[ttl]
68 if (ttlMs < s.minCacheTtlMs) {
69 return { ttlMs: null, why: `the prompt cache lasts only ${duration(ttlMs)} here (${why}), under minCacheTtlSeconds (${s.minCacheTtlMs / 1000}). Set minCacheTtlSeconds to ${ttlMs / 1000} or less to compact these sessions too` }
70 }
71 if (s.leadMs >= ttlMs) return { ttlMs: null, why: `leadSeconds (${s.leadMs / 1000}) is no shorter than this session's ${duration(ttlMs)} prompt cache` }
72 return { ttlMs, why }
73}
74
75function autoTtl(sig: Signals): [Ttl, string] {
76 const { env } = sig
77 if (on(env.FORCE_PROMPT_CACHING_5M)) return ['5m', 'FORCE_PROMPT_CACHING_5M']
78 const fromEnv = asTtl(env.CLAUDE_CODE_PROMPT_CACHE_TTL)
79 if (fromEnv) return [fromEnv, `CLAUDE_CODE_PROMPT_CACHE_TTL is ${fromEnv}`]
80 const fromSettings = asTtl(sig.promptCacheTtl)
81 if (fromSettings) return [fromSettings, `promptCacheTtl is ${fromSettings}`]
82 if (on(env.ENABLE_PROMPT_CACHING_1H)) return ['1h', 'ENABLE_PROMPT_CACHING_1H']
83 const provider = ['CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY'].find(name => on(env[name]))
84 if (provider) return ['5m', provider]
85 const plan = sig.rateLimits.filter(r => r.kind === 'five_hour' || r.kind === 'seven_day')
86 if (plan.some(r => r.percentUsed >= 100)) return ['5m', 'past your plan’s usage limit, on usage credits']
87 if (plan.length) return ['1h', 'Claude subscription']
88 return ['5m', 'no Claude plan usage reported, so an API key']
89}
90
91// When to compact, and when the cache expires, counted from the start of the
92// last main-conversation request: that request is what last read or wrote the
93// cache.
94export const dueAt = (anchor: number, ttlMs: number, s: Settings): number => anchor + ttlMs - s.leadMs
95export const expiresAt = (anchor: number, ttlMs: number): number => anchor + ttlMs
96
97export type State = {
98 anchor: number | null // start of the last main-conversation request
99 lastWasCompact: boolean // nothing has happened since the last compaction
100 turning: boolean // a turn is running
101 off: boolean // turned off for this session (/cache-compactor:off)
102}
103
104// Whether to compact now; when not, why.
105export function check(state: State, now: number, tokens: number | undefined, cache: Cache, s: Settings): { compact: true } | { compact: false; reason: string } {
106 if (state.off) return { compact: false, reason: 'it is turned off for this session' }
107 if (state.anchor === null) return { compact: false, reason: 'nothing has been sent to Claude yet' }
108 if (state.lastWasCompact) return { compact: false, reason: 'the last thing that happened was a compaction' }
109 if (state.turning) return { compact: false, reason: 'a turn is running' }
110 if (cache.ttlMs === null) return { compact: false, reason: cache.why }
111 if (now >= expiresAt(state.anchor, cache.ttlMs)) {
112 return { compact: false, reason: `the cache had already expired (${duration(now - state.anchor)} since the last request, so the computer was probably asleep)` }
113 }
114 if (tokens !== undefined && tokens < s.minTokens) {
115 return { compact: false, reason: `the conversation is small (${k(tokens)} tokens, under minTokens ${k(s.minTokens)})` }
116 }
117 return { compact: true }
118}
119
120// What happened the last time the timer went off.
121export type Last =
122 | { at: number; outcome: 'compacted'; idleMs: number; before?: number; after?: number; cacheRead?: number }
123 | { at: number; outcome: 'skipped' | 'failed'; reason: string }
124
125// The line shown after compacting.
126export function compactedText(last: Extract<Last, { outcome: 'compacted' }>): string {
127 const sizes = last.before !== undefined && last.after !== undefined ? `: ${k(last.before)} → ${k(last.after)} tokens` : ''
128 const cache =
129 last.cacheRead === undefined ? '' : last.cacheRead > 0 ? ` (${k(last.cacheRead)} read from the cache)` : ' (nothing was read from the cache)'
130 return `compacted after ${duration(last.idleMs)} idle, before the prompt cache expires${sizes}${cache}.`
131}
132
133export const ON_AGAIN = '/cache-compactor:on turns it back on.'
134
135// What /cache-compactor:off and :on say.
136export const offText = (was: boolean) => `Automatic compaction is ${was ? 'already' : 'now'} off for this session. ${ON_AGAIN}`
137export const onText = (was: boolean, status: string) =>
138 `Automatic compaction is ${was ? 'already on' : 'on again'} for this session. ${status}`
139
140// What /cache-compactor:status says.
141export function describe(args: { state: State; now: number; due: number | null; last: Last | null; cache: Cache | null; interactive: boolean }): string {
142 const { state, now, due, last, cache } = args
143 const lines: string[] = []
144 if (state.off) {
145 lines.push(`Off for this session: ${ON_AGAIN}`)
146 } else if (due !== null && cache?.ttlMs) {
147 lines.push(`Compacts in ${duration(Math.max(0, due - now))} if the session stays idle: the prompt cache expires ${duration(cache.ttlMs)} after the last request (${cache.why}).`)
148 } else if (!args.interactive) {
149 lines.push('Not scheduled: nothing draws this session (a headless run).')
150 } else if (state.turning) {
151 lines.push('Not scheduled while a turn runs: the timer starts when it ends.')
152 } else if (state.lastWasCompact) {
153 lines.push('Not scheduled: the last thing that happened was a compaction. The timer starts again after your next prompt.')
154 } else if (state.anchor === null) {
155 lines.push('Not scheduled: nothing has been sent to Claude yet.')
156 } else if (cache?.ttlMs === null) {
157 lines.push(`Off: ${cache.why}.`)
158 } else if (cache) {
159 lines.push(`Not scheduled: the cache expired ${duration(now - expiresAt(state.anchor, cache.ttlMs))} ago.`)
160 } else {
161 lines.push('Not scheduled.')
162 }
163 if (last) {
164 const ago = `${duration(now - last.at)} ago`
165 lines.push(
166 last.outcome === 'compacted'
167 ? `Last: ${compactedText(last).replace(/^compacted/, `compacted ${ago}`)}`
168 : `Last: ${last.outcome === 'skipped' ? "didn't compact" : "couldn't compact"} ${ago}: ${last.reason}.`,
169 )
170 }
171 return lines.join('\n')
172}
173
174// 142000 is "142k"; under 1000, the number itself.
175export const k = (n: number): string => (n < 1000 ? String(Math.round(n)) : `${Math.round(n / 1000)}k`)
176
177// 45 s, 58 min, 1 h 5 min.
178export function duration(ms: number): string {
179 const sec = Math.round(ms / 1000)
180 if (sec < 60) return `${sec} s`
181 const min = Math.round(sec / 60)
182 if (min < 60) return `${min} min`
183 const h = Math.floor(min / 60)
184 return min % 60 ? `${h} h ${min % 60} min` : `${h} h`
185}
186