SLOPSHOPPER

cache-compactor

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.

newstatusprompttimer
v0.1.1MITupdated 2026-10-09htxryan/claude-cache-compactor
A shopper browsing a rack in a slop shop
README

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

  • The hour counts from the session's last request, so every prompt and response pushes it back.
  • It compacts once. If the last thing that happened was a compaction (yours, Claude Code's automatic one or its own), it waits for your next prompt.
  • It says what it did: 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.

Install

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.

Use

There's nothing to set up: it runs in every interactive session.

  • Turn it off for one 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.
  • See what's scheduled: /cache-compactor:status says when the session will be compacted, or why it won't be, and what happened last time.
  • Small conversations are left alone: under 30k tokens (the Messages row of /context), compacting costs about as much as it saves. See minTokens.
  • Only while Claude Code runs: the timer lives in the Claude Code process. If the computer sleeps through the cache's last minutes, it wakes to an expired cache and leaves the session alone.
  • Resumed sessions (--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.
  • Headless runs (claude -p) are never compacted.

How it works

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:

  1. DISABLE_PROMPT_CACHING (or the variable for the session's model) turns caching off, and the plugin with it.
  2. FORCE_PROMPT_CACHING_5M, then CLAUDE_CODE_PROMPT_CACHE_TTL, then the promptCacheTtl setting, then ENABLE_PROMPT_CACHING_1H.
  3. Bedrock, Vertex and Foundry (CLAUDE_CODE_USE_*) use five minutes.
  4. Otherwise, a Claude subscription is told by the plan's usage windows the last response reported: one hour, or five minutes once a window is past 100% and Claude Code draws on usage credits. With no plan windows, it's an API key: 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.

Settings

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

Costs and limits

  • A compaction is a request. It reads the system prompt and tools from the cache, but Claude Code sends the conversation itself uncached, then a summary comes back. That is why small conversations are skipped.
  • It pays off when you come back. Your next prompt then re-caches a short summary instead of the whole conversation. If you never return to the session, the compaction was spent for nothing.
  • Compaction loses detail. The summary replaces the conversation, as with /compact. instructions steers what it keeps.
  • A turn waiting on you (a permission prompt, a question) is still running, so there is no compaction, and the cache can expire under it.
  • Updating or reloading the plugin mid-session clears its timer until your next prompt.
  • Another plugin or a PreCompact hook can veto it. It is skipped, and the next turn sets the timer again.

What it hooks

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

Sources

  • How Claude Code uses prompt caching: the TTLs, the variables and settings, and how /compact reads the cache.
  • Prompt caching and pricing: cache writes cost 1.25× base input for five minutes and 2× for an hour; reads cost 0.1×, or 0.05× on the 5.5 models. The diagram uses Opus 5.5 list prices.

Source 2 files
hooks/register.ts 268 lines
1import 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}
268
compact/plan.ts 186 lines
1// 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