SLOPSHOPPER

cache-keeper

Keeps the prompt cache warm while a session idles: every 50 minutes (configurable) one tiny request over the conversation prefix, so the entry does not lapse…

newbandcommandtoastmodeltimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cache-keeper
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /cache-keeper ⎿ cache-keeper: cache-keeper: on ⎿ cache-keeper: interval 50m; idle cap 4h00m ⎿ cache-keeper: last request 0s ago; next poke in 49m ⎿ cache-keeper: 0 pokes, 0 failed ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ♨ cache warm · next in 49m │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ♨ cache warm · next in 49m │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ ⟨Claude Code's own drawing⟩
README

cache-keeper

Keeps the prompt cache from lapsing while you are away from the keyboard.

Every request Claude Code sends to the API carries a long prefix: the system prompt, the tool definitions, the whole conversation. That prefix is written to the prompt cache, and later requests that hit it pay a fraction of the price and come back much faster. But the entry lives one hour: an hour after it was last used it is gone, and the next turn has to rewrite the entire context, slowly and at full price.

cache-keeper waits for the session to idle, then every so often (default 50 minutes) sends one line, "reply ok", over the same conversation prefix. The cache gets used once more and its lifetime extends by another hour.

♨ cache warm · next in 38m · 3 pokes · last hit 98k tok

Usage

Install it and it runs; nothing to configure. The countdown starts when the first turn ends. It never pokes during a turn, and every real request restarts the countdown from the moment the request went out (the cache lifetime counts from the request's start, not from the end of the reply).

CommandWhat it does
/cache-keeperStatus: interval, time to the next poke, pokes so far, last cache hit size
/cache-keeper nowPoke immediately (refused while a turn runs, since that turn refreshes the cache itself)
/cache-keeper offPause warming for this session
/cache-keeper onResume

Settings

Change them on the /plugin settings page or under pluginConfigs in ~/.claude/settings.json:

FieldDefaultMeaning
interval_minutes50How long the session may idle before a poke. The cache lapses after an hour, so this must be under 60; the mod clamps it to 5–59, leaving ten minutes for clock drift and API latency
max_idle_hours4Stop warming once this long has passed since the last real turn. 0 means never stop (see the cost section)
enabledtrueOff means the mod sends nothing at all
show_bandtrueDraw the countdown line above the prompt, in its own rounded frame stacked with the other mods' frames
languageautoUI language: auto reads LC_ALL, LC_MESSAGES, then LANG (zh* → Traditional Chinese, ja* → Japanese, anything else → English); or set en, zh-TW or ja explicitly
band_styleboxHow the line is framed: box (a rounded frame, dim normally and yellow while backing off after failed pokes), rule (a thin line beneath it), plain (text only)

How it works, and what it costs

Mechanism. The mod calls $.model.fork, which re-sends the main loop's last request exactly (same model, system prompt, tools and conversation) with one very short user message appended and no tools enabled. Because the prefix is byte-identical, the API serves it from the cache and resets the entry's one-hour timer. The reply's cache_read_input_tokens says how much was served; near zero means we were late, the entry had already lapsed, and this request rewrote it.

What one poke costs. The whole prefix at cache-read price, plus a few output tokens. The bigger the context, the dearer the poke.

Price ratios (Anthropic list prices, the model's base input price = 1):

ItemMultiplier
Cache read (a hit)0.1× on most models; 0.05× on Claude Opus 5.5 ($0.20/MTok); 0.025× on Claude Fable 5.1 ($0.25/MTok)
Cache write, 5-minute TTL1.25×
Cache write, 1-hour TTL (what Claude Code uses)2×

A hit resets the entry's timer at no extra charge, and the lifetime counts from the start of that request.

Worked example. A 100k-token context, a 50-minute interval, Claude Opus 5.5 (input $4/MTok, cache read $0.20/MTok).

  • One poke: 100k × $0.20/MTok ≈ $0.02.
  • Without warming, coming back after more than an hour: the next turn rewrites all 100k at the 1-hour write price, 100k × $8/MTok ≈ $0.80, with a visibly longer time to first token.
  • So: back after about an hour, one poke saves roughly $0.78. Back after four hours, four pokes cost $0.08 and the return saves $0.80, still worth it. Forgot the session overnight, $0.02 an hour burns for nothing. That is why max_idle_hours exists, and why it stops after four hours by default.
  • Other models scale with their ratios: at 0.1× a poke costs a tenth of base input, and a rewrite costs twice base input.

Worth it when the context is large (tens of thousands of tokens or more) and you often step away for ten minutes to an hour (reading, a meeting, waiting on CI). Not worth it when the context is small (there is little to save) or you leave for hours at a time (lower max_idle_hours, or turn it off).

Timeline (50-minute interval, 4-hour cap):

turn ends ─50m─▶ poke ─50m─▶ poke ─50m─▶ poke ─50m─▶ poke ─40m─▶ 4h reached, stop
   any new turn pulls this line back to its start

On failure. An API error (overload, rate limit) backs off: retry after a minute, then two, then four… up to one interval. The first failure shows a toast; later ones only go to the debug log. When there is no reply yet (a new session, or right after /clear) there is nothing to warm and the poke is skipped quietly.

Safety boundary

  • Reads and writes no files, runs no commands, opens no network connection. The one outward action is that single model request, through the session's own API client and credentials.
  • What goes out is the conversation prefix the session has already sent, plus one fixed line. No new information leaves your machine.
  • Only while idle: never during a turn; a running subagent does not affect main's countdown; past the idle cap it stops.
  • At most one poke per interval; a manual /cache-keeper now runs one at a time and never stacks.
  • claude -p and SDK sessions have nobody waiting, so they are never warmed.

What it does before you install it

claude plugin validate ./plugins/cache-keeper

Result (v0.1.0):

hooks: session.start, command.run{command=cache-keeper}, turn.start, turn.step, turn.complete,
       session.end, ui.render{component=AbovePrompt}
calls: $.clock.every, $.clock.now, $.command.register, $.env.get (via resolveLanguage),
       $.model.fork (via poke), $.state.get, $.state.set, $.ui.log (via log), $.ui.resolve,
       $.ui.toast (via toast)
env reads: LANG, LC_ALL, LC_MESSAGES
env writes: nothing
state: cache-keeper.state, cache-keeper.tickAt, cache-keeper.lang
  • $.model.fork: the one poke request; the only thing here that costs money
  • turn.step: read only, to learn that a real request just went out; no chunk or reply is changed
  • $.env.get: the three locale variables, to pick the UI language
  • No $.fs, $.process, $.http

Limits

  • Whether a poke hits is up to the API. Switching models (/model), changing the system prompt, or installing a plugin that changes the tool list all change the prefix, so the next poke is a rewrite and the band's "last hit" drops to near zero.
  • The interval is checked every 30 seconds, so a poke can land up to 30 seconds late.
  • The band is drawn on the terminal and in Claude Desktop's Code tab only; the warming itself runs in any interactive session.
  • Timing state lives in the session's $.state: /clear resets it, a hot reload keeps it, closing Claude Code drops it, and a fresh session counts from its first turn.

Development

claude --plugin-dir ./plugins/cache-keeper   # load once
claude plugin test ./plugins/cache-keeper     # run the tests (27, including the timeline, backoff and languages)

tsconfig.json depends on .claude-plugin/types/, the type declarations Claude Code writes when it loads the mod; they are not committed. UI strings live in hooks/i18n.ts, one dictionary per language, and a test checks that every language has every message.

Source 4 files
hooks/register.tsx 277 lines
1// cache-keeper: while a session idles, periodically send one tiny request over the
2// conversation prefix so the prompt cache does not lapse.
3//
4// - The cache lives one hour past its last use. Once the session has idled for one
5//   interval (default 50 minutes) we run `$.model.fork` with a one-word prompt: a fork
6//   re-sends the main loop's last request prefix, the API serves it from cache, and the
7//   entry's timer resets.
8// - Only while idle: never during a turn, every real request restarts the countdown, and
9//   we stop past the idle cap.
10// - Failures back off: 1m, 2m, 4m… up to one interval; `nothing-to-fork` (no reply yet)
11//   is skipped quietly.
12// - No files, no shell, no network: the one outward action is that model request, made
13//   through the session's own client.
14// - UI language: the `language` option, or `auto` from LC_ALL / LC_MESSAGES / LANG.
15
16import { atom, read, update } from 'claude-code'
17import type { Register } from 'claude-code'
18
19import { DEFAULT_LANG, resolveLang, t } from './i18n'
20import type { Lang } from './i18n'
21import {
22  EMPTY_STATE,
23  POKE_PROMPT,
24  TICK_MS,
25  TOAST_MS,
26  bandText,
27  isCacheMiss,
28  isDue,
29  isPastIdleCap,
30  nextBackoff,
31  parseConfig,
32  pokeText,
33  statusText,
34} from './logic'
35import type { BandStyle, KeeperConfig, KeeperState } from './logic'
36
37const state = atom({ plugin: 'cache-keeper', key: 'state' } as const, EMPTY_STATE)
38const tickAt = atom({ plugin: 'cache-keeper', key: 'tickAt' } as const, 0)
39const langState = atom({ plugin: 'cache-keeper', key: 'lang' } as const, DEFAULT_LANG)
40
41// Module-level: reset on hot reload. `register` re-reads the config; `inFlight` keeps one
42// environment from poking twice at once.
43let config: KeeperConfig = parseConfig(undefined)
44let lang: Lang = DEFAULT_LANG
45let inFlight = false
46
47function toast($: any, text: string): void {
48  try {
49    $.ui.toast(text, { timeoutMs: TOAST_MS })
50  } catch {}
51}
52
53function log($: any, text: string, toDebug = true): void {
54  try {
55    $.ui.log(text, toDebug ? { to: 'debug' } : undefined)
56  } catch {}
57}
58
59async function patch($: any, fn: (s: KeeperState) => Partial<KeeperState>): Promise<void> {
60  await update($, state, s => ({ ...s, ...fn(s) }))
61}
62
63type PokeOutcome = { ok: true; text: string } | { ok: false; text: string }
64
65/** One poke. Returns the text shown by /cache-keeper now. */
66async function poke($: any, origin: 'timer' | 'command'): Promise<PokeOutcome> {
67  if (inFlight) return { ok: false, text: t(lang, 'poke.busy') }
68  inFlight = true
69  const startedAt: number = await $.clock.now()
70  await patch($, () => ({ isPoking: true, lastAttemptAt: startedAt }))
71  try {
72    let r: any
73    try {
74      r = await $.model.fork({ prompt: POKE_PROMPT })
75    } catch (err) {
76      r = { isAnswered: false, reason: 'rejected', error: String(err) }
77    }
78    if (r?.isAnswered) {
79      const u = r.usage
80      const text = pokeText(u, lang)
81      // The cache lifetime counts from the request's start, so that is the time we record
82      await patch($, s => ({
83        lastRequestAt: startedAt,
84        pokes: s.pokes + 1,
85        lastCacheRead: u.cache_read_input_tokens,
86        lastInput: u.input_tokens + u.cache_creation_input_tokens,
87        backoffMs: 0,
88      }))
89      log($, `cache-keeper: ${origin} poke: ${text}`)
90      if (isCacheMiss(u) && origin === 'timer') log($, t(lang, 'log.miss'), false)
91      return { ok: true, text }
92    }
93    if (r?.reason === 'nothing-to-fork') {
94      // No reply yet (new session or right after /clear): nothing to warm, wait for a turn
95      await patch($, () => ({ lastRequestAt: null }))
96      return { ok: false, text: t(lang, 'poke.nothing') }
97    }
98    const why = r?.reason === 'api-error' ? t(lang, 'poke.apiError', { status: r.status ?? '', error: String(r.error) }) : String(r?.reason ?? 'unknown')
99    let firstFailure = false
100    await patch($, s => {
101      firstFailure = s.failures === 0
102      return { failures: s.failures + 1, backoffMs: nextBackoff(s.backoffMs, config.intervalMs) }
103    })
104    log($, `cache-keeper: poke failed: ${why}`)
105    if (firstFailure && origin === 'timer') toast($, t(lang, 'toast.failed', { why }))
106    return { ok: false, text: t(lang, 'poke.failed', { why }) }
107  } finally {
108    inFlight = false
109    await patch($, () => ({ isPoking: false }))
110  }
111}
112
113async function onTick($: any): Promise<void> {
114  const s = await read($, state)
115  if (!config.enabled || s.isPaused || s.lastRequestAt === null) return
116  const now: number = await $.clock.now()
117  if (config.showBand) await update($, tickAt, () => now)
118  if (s.isTurnRunning || s.isPoking || inFlight) return
119  if (isPastIdleCap(s.lastRealTurnAt, now, config.idleCapMs)) {
120    if (!s.isCapNoticed) {
121      await patch($, () => ({ isCapNoticed: true }))
122      log($, t(lang, 'log.cap'), false)
123    }
124    return
125  }
126  if (isDue(now, s, config.intervalMs)) await poke($, 'timer')
127}
128
129async function resolveLanguage($: any): Promise<Lang> {
130  const env: { LC_ALL?: string; LC_MESSAGES?: string; LANG?: string } = {}
131  try {
132    env.LC_ALL = await $.env.get('LC_ALL')
133    env.LC_MESSAGES = await $.env.get('LC_MESSAGES')
134    env.LANG = await $.env.get('LANG')
135  } catch {}
136  return resolveLang(config.language, env)
137}
138
139async function onSessionStart($: any, e: any, next: any) {
140  const out = await next(e)
141  lang = await resolveLanguage($)
142  await update($, langState, () => lang)
143  await $.command.register({
144    name: 'cache-keeper',
145    description: 'Prompt cache warming: show status; /cache-keeper now pokes once, off pauses, on resumes',
146    argumentHint: '[now|off|on]',
147  })
148  // A hot reload can leave isPoking stuck (the promise is gone): clear it. isTurnRunning stays; turn.complete ends it.
149  await patch($, () => ({ isPoking: false }))
150  // Nobody is waiting in `claude -p` or an SDK session: no warming there
151  if (e.isInteractive) {
152    $.clock.every(TICK_MS, () => {
153      void onTick($).catch(() => {})
154    })
155  }
156  return out
157}
158
159async function onCommand($: any, e: any) {
160  const arg = String(e.args ?? '').trim()
161  const now: number = await $.clock.now()
162  if (arg === '') return { text: statusText(await read($, state), config, now, lang) }
163  if (arg === 'off') {
164    await patch($, () => ({ isPaused: true }))
165    return { text: t(lang, 'cmd.off') }
166  }
167  if (arg === 'on') {
168    await patch($, () => ({ isPaused: false, isCapNoticed: false }))
169    return { text: t(lang, 'cmd.on') }
170  }
171  if (arg === 'now') {
172    if (!config.enabled) return { text: t(lang, 'cmd.disabled') }
173    const s = await read($, state)
174    if (s.isTurnRunning) return { text: t(lang, 'cmd.turnRunning') }
175    const r = await poke($, 'command')
176    return { text: t(lang, 'cmd.result', { text: r.text }) }
177  }
178  return { text: t(lang, 'cmd.usage') }
179}
180
181async function onAbovePrompt($: any, e: any, next: any) {
182  if (e.props?.hasSurvey || e.props?.isWorking) return next(e)
183  await read($, tickAt) // subscribe: redraw the countdown on every tick
184  const l = (await read($, langState)) as Lang
185  const text = bandText(await read($, state), config, await $.clock.now(), l)
186  if (text === null) return next(e)
187  const ui = $.ui.resolve(e)
188  const { Text } = ui
189  // AbovePrompt is a hook chain: draw our line in its frame, then what the plugins beneath drew
190  const below = await next(e)
191  const s = await read($, state)
192  return frameBand(
193    ui,
194    config.bandStyle,
195    s.backoffMs > 0,
196    e.props?.bodyColumns,
197    <Text wrap="truncate-end" dimColor>
198      {text}
199    </Text>,
200    below,
201  )
202}
203
204/**
205 * Frames this mod's band content per `band_style` and stacks the plugins beneath under it.
206 * `box`: a rounded frame (yellow when `isWarning`, here while backing off after failures);
207 * `rule`: a dim line beneath, only when another plugin drew something below; `plain`: the bare text.
208 */
209function frameBand(ui: { Box: any; Text: any }, style: BandStyle, isWarning: boolean, bodyColumns: number | undefined, content: any, below: any) {
210  const { Box, Text } = ui
211  const hasBelow = below !== null && below !== undefined && (below as { type?: string }).type !== 'engine'
212  const own =
213    style === 'box' ? (
214      <Box key="frame" flexDirection="column" borderStyle="round" borderDimColor={isWarning ? undefined : true} borderColor={isWarning ? 'yellow' : undefined} paddingX={1}>
215        {content}
216      </Box>
217    ) : style === 'rule' ? (
218      <Box key="frame" flexDirection="column">
219        {content}
220        {hasBelow ? <Text key="rule" dimColor>{'─'.repeat(Math.max(8, Math.min(bodyColumns ?? 60, 200)))}</Text> : null}
221      </Box>
222    ) : (
223      <Box key="frame" flexDirection="column">
224        {content}
225      </Box>
226    )
227  return (
228    <Box flexDirection="column">
229      {own}
230      {below}
231    </Box>
232  )
233}
234
235export const register: Register = (on, options) => {
236  config = parseConfig(options as Record<string, unknown> | undefined)
237
238  on('session.start', onSessionStart)
239  on('command.run', { command: 'cache-keeper' }, onCommand)
240
241  // A main-loop turn starts: note the "real activity" time; the idle cap counts from here
242  on('turn.start', async ($, e, next) => {
243    const now = await $.clock.now()
244    await patch($, () => ({ isTurnRunning: true, lastRealTurnAt: now, isCapNoticed: false }))
245    return next(e)
246  })
247
248  // Every real model request refreshes the cache: restart the countdown. The cache lifetime
249  // counts from the request's START, so we record when it went out, not when the reply ended
250  // (a long reply can take minutes). Subagent requests do not share main's prefix: skipped.
251  on('turn.step', async function* ($, e, next) {
252    if (e.agentId !== undefined) return yield* next(e)
253    const startedAt = await $.clock.now()
254    await patch($, () => ({ lastRequestAt: startedAt }))
255    return yield* next(e)
256  })
257
258  on('turn.complete', async ($, e, next) => {
259    if (e.agentId === undefined) {
260      const now = await $.clock.now()
261      // Only when turn.step saw no request (interrupted before the first one) fill in the end time
262      await patch($, s => ({ isTurnRunning: false, lastRequestAt: s.lastRequestAt ?? now }))
263    }
264    return next(e)
265  })
266
267  // /clear: the conversation is gone, and so is the cached prefix; counters stay, timing resets
268  on('session.end', async ($, e, next) => {
269    if (e.reason === 'clear') {
270      await patch($, () => ({ lastRequestAt: null, lastAttemptAt: null, backoffMs: 0, isTurnRunning: false, isCapNoticed: false }))
271    }
272    return next(e)
273  })
274
275  on('ui.render', { component: 'AbovePrompt' }, onAbovePrompt)
276}
277
hooks/i18n.ts 185 lines
1// cache-keeper UI strings in English, Traditional Chinese and Japanese.
2// Pure: no `$`. The language is resolved once at session.start (see register.tsx)
3// and passed into every text function in logic.ts.
4
5export type Lang = 'en' | 'zh-TW' | 'ja'
6export const LANGS: readonly Lang[] = ['en', 'zh-TW', 'ja']
7export const DEFAULT_LANG: Lang = 'en'
8
9export type Params = Record<string, string | number>
10type Msg = string | ((p: Params) => string)
11
12export type Messages = {
13  'band.warm': Msg
14  'band.next': Msg
15  'band.pokes': Msg
16  'band.lastHit': Msg
17  'band.backoff': Msg
18  'band.poking': Msg
19  'band.capped': Msg
20  'countdown.under1m': Msg
21  'status.enabled': Msg
22  'status.disabled': Msg
23  'status.paused': Msg
24  'status.interval': Msg
25  'status.noCap': Msg
26  'status.noRequest': Msg
27  'status.capped': Msg
28  'status.next': Msg
29  'status.counts': Msg
30  'status.lastHit': Msg
31  'poke.miss': Msg
32  'poke.hit': Msg
33  'poke.busy': Msg
34  'poke.nothing': Msg
35  'poke.failed': Msg
36  'poke.apiError': Msg
37  'toast.failed': Msg
38  'log.cap': Msg
39  'log.miss': Msg
40  'cmd.off': Msg
41  'cmd.on': Msg
42  'cmd.disabled': Msg
43  'cmd.turnRunning': Msg
44  'cmd.result': Msg
45  'cmd.usage': Msg
46}
47export type MessageKey = keyof Messages
48
49const en: Messages = {
50  'band.warm': '♨ cache warm',
51  'band.next': p => `next in ${p.t}`,
52  'band.pokes': p => `${p.n} ${p.n === 1 ? 'poke' : 'pokes'}`,
53  'band.lastHit': p => `last hit ${p.tok} tok`,
54  'band.backoff': p => `backing off (${p.n} failed)`,
55  'band.poking': '♨ warming…',
56  'band.capped': p => `♨ idle for ${p.d}, warming stopped (/cache-keeper now to poke by hand)`,
57  'countdown.under1m': 'under 1m',
58  'status.enabled': 'cache-keeper: on',
59  'status.disabled': 'cache-keeper: disabled (enabled is false in settings)',
60  'status.paused': 'cache-keeper: paused (/cache-keeper on to resume)',
61  'status.interval': p => `interval ${p.interval}; idle cap ${p.cap}`,
62  'status.noCap': 'none',
63  'status.noRequest': 'no model request yet; the countdown starts after the first turn ends',
64  'status.capped': 'idle cap reached, automatic warming stopped; /cache-keeper now pokes once by hand',
65  'status.next': p => `last request ${p.ago} ago; next poke in ${p.next}`,
66  'status.counts': p => `${p.pokes} pokes, ${p.failures} failed`,
67  'status.lastHit': p => `; last hit ${p.hit} tok (missed ${p.miss} tok)`,
68  'poke.miss': p => `cache miss (entry probably expired), rewrote ${p.tok} tok`,
69  'poke.hit': p => `cache hit ${p.hit} tok, missed ${p.miss} tok`,
70  'poke.busy': 'already warming',
71  'poke.nothing': 'nothing to warm yet (no reply in this conversation)',
72  'poke.failed': p => `poke failed: ${p.why}`,
73  'poke.apiError': p => `API error${p.status ? ` ${p.status}` : ''} (${p.error})`,
74  'toast.failed': p => `cache-keeper: poke failed (${p.why}), will retry later`,
75  'log.cap': 'cache-keeper: idle cap reached, warming stopped; /cache-keeper now pokes by hand',
76  'log.miss': 'cache-keeper: that poke missed the cache (entry probably expired); the prefix was rewritten',
77  'cmd.off': 'cache-keeper paused: no more pokes in this session. /cache-keeper on to resume.',
78  'cmd.on': 'cache-keeper resumed.',
79  'cmd.disabled': 'cache-keeper is disabled in settings; not poking.',
80  'cmd.turnRunning': 'A turn is running; not poking (the turn itself refreshes the cache).',
81  'cmd.result': p => `cache-keeper: ${p.text}`,
82  'cmd.usage': 'Usage: /cache-keeper (status), /cache-keeper now (poke now), /cache-keeper off / on (pause / resume)',
83}
84
85const zhTW: Messages = {
86  'band.warm': '♨ cache 保溫',
87  'band.next': p => `下次 ${p.t} 後`,
88  'band.pokes': p => `已戳 ${p.n} 次`,
89  'band.lastHit': p => `上次命中 ${p.tok} tok`,
90  'band.backoff': p => `退避中(失敗 ${p.n} 次)`,
91  'band.poking': '♨ 保溫中…',
92  'band.capped': p => `♨ 已閒置 ${p.d},停止保溫(/cache-keeper now 可手動)`,
93  'countdown.under1m': '不到 1m',
94  'status.enabled': 'cache-keeper:啟用中',
95  'status.disabled': 'cache-keeper:已停用(設定 enabled 為 false)',
96  'status.paused': 'cache-keeper:已暫停(/cache-keeper on 恢復)',
97  'status.interval': p => `保溫間隔 ${p.interval};閒置上限 ${p.cap}`,
98  'status.noCap': '無',
99  'status.noRequest': '還沒有任何模型請求,第一個 turn 結束後開始計時',
100  'status.capped': '已達閒置上限,停止自動保溫;/cache-keeper now 可手動戳一次',
101  'status.next': p => `上次請求 ${p.ago} 前;下次保溫 ${p.next} 後`,
102  'status.counts': p => `已戳 ${p.pokes} 次,失敗 ${p.failures} 次`,
103  'status.lastHit': p => `;上次命中 ${p.hit} tok(未命中 ${p.miss} tok)`,
104  'poke.miss': p => `這次沒命中快取(可能已過期),已重新寫入 ${p.tok} tok`,
105  'poke.hit': p => `快取命中 ${p.hit} tok,未命中 ${p.miss} tok`,
106  'poke.busy': '已經在保溫中',
107  'poke.nothing': '目前沒有可保溫的對話(還沒有任何回覆)',
108  'poke.failed': p => `保溫失敗:${p.why}`,
109  'poke.apiError': p => `API 錯誤${p.status ? ` ${p.status}` : ''}(${p.error})`,
110  'toast.failed': p => `cache-keeper:保溫失敗(${p.why}),稍後再試`,
111  'log.cap': 'cache-keeper:已達閒置上限,停止保溫;/cache-keeper now 可手動',
112  'log.miss': 'cache-keeper:這次沒命中快取(可能已過期),已重新寫入',
113  'cmd.off': 'cache-keeper 已暫停(本 session 不再保溫)。/cache-keeper on 恢復。',
114  'cmd.on': 'cache-keeper 已恢復。',
115  'cmd.disabled': 'cache-keeper 已在設定中停用,不戳。',
116  'cmd.turnRunning': '有 turn 進行中,不戳(它自己就會刷新快取)。',
117  'cmd.result': p => `cache-keeper:${p.text}`,
118  'cmd.usage': '用法:/cache-keeper(狀態)、/cache-keeper now(立刻戳)、/cache-keeper off / on(暫停/恢復)',
119}
120
121const ja: Messages = {
122  'band.warm': '♨ キャッシュ保温',
123  'band.next': p => `次は${p.t}後`,
124  'band.pokes': p => `${p.n}回送信`,
125  'band.lastHit': p => `前回ヒット ${p.tok} tok`,
126  'band.backoff': p => `バックオフ中(失敗${p.n}回)`,
127  'band.poking': '♨ 保温中…',
128  'band.capped': p => `♨ ${p.d} アイドルのため停止(/cache-keeper now で手動送信)`,
129  'countdown.under1m': '1m未満',
130  'status.enabled': 'cache-keeper:有効',
131  'status.disabled': 'cache-keeper:無効(設定の enabled が false)',
132  'status.paused': 'cache-keeper:一時停止中(/cache-keeper on で再開)',
133  'status.interval': p => `保温間隔 ${p.interval};アイドル上限 ${p.cap}`,
134  'status.noCap': 'なし',
135  'status.noRequest': 'まだモデルへのリクエストがありません。最初のターン終了後にカウント開始',
136  'status.capped': 'アイドル上限に達したため自動保温を停止。/cache-keeper now で手動送信できます',
137  'status.next': p => `前回のリクエストは${p.ago}前;次の保温は${p.next}後`,
138  'status.counts': p => `${p.pokes}回送信、失敗${p.failures}回`,
139  'status.lastHit': p => `;前回ヒット ${p.hit} tok(ミス ${p.miss} tok)`,
140  'poke.miss': p => `キャッシュミス(期限切れの可能性)、${p.tok} tok を再書き込み`,
141  'poke.hit': p => `キャッシュヒット ${p.hit} tok、ミス ${p.miss} tok`,
142  'poke.busy': 'すでに保温中です',
143  'poke.nothing': '保温できる会話がまだありません(応答がまだありません)',
144  'poke.failed': p => `保温失敗:${p.why}`,
145  'poke.apiError': p => `APIエラー${p.status ? ` ${p.status}` : ''}(${p.error})`,
146  'toast.failed': p => `cache-keeper:保温に失敗(${p.why})、後で再試行します`,
147  'log.cap': 'cache-keeper:アイドル上限に達したため保温を停止。/cache-keeper now で手動送信',
148  'log.miss': 'cache-keeper:キャッシュミス(期限切れの可能性)、プレフィックスを再書き込みしました',
149  'cmd.off': 'cache-keeper を一時停止しました(このセッションでは保温しません)。/cache-keeper on で再開。',
150  'cmd.on': 'cache-keeper を再開しました。',
151  'cmd.disabled': 'cache-keeper は設定で無効です。送信しません。',
152  'cmd.turnRunning': 'ターン実行中のため送信しません(ターン自体がキャッシュを更新します)。',
153  'cmd.result': p => `cache-keeper:${p.text}`,
154  'cmd.usage': '使い方:/cache-keeper(状態)、/cache-keeper now(今すぐ送信)、/cache-keeper off / on(一時停止/再開)',
155}
156
157export const MESSAGES: Record<Lang, Messages> = { en, 'zh-TW': zhTW, ja }
158
159/** Looks up a message in `lang`, falling back to English. */
160export function t(lang: Lang, key: MessageKey, params: Params = {}): string {
161  const m: Msg = MESSAGES[lang]?.[key] ?? MESSAGES.en[key]
162  return typeof m === 'function' ? m(params) : m
163}
164
165function fromLocale(value: string | undefined): Lang | null {
166  const v = (value ?? '').trim()
167  if (!v || v === 'C' || v === 'POSIX') return null
168  if (/^zh/i.test(v)) return 'zh-TW'
169  if (/^ja/i.test(v)) return 'ja'
170  return 'en'
171}
172
173/**
174 * The UI language: an explicit option wins; `auto` (or anything else) reads
175 * LC_ALL, then LC_MESSAGES, then LANG; nothing usable means English.
176 */
177export function resolveLang(option: unknown, env: { LC_ALL?: string; LC_MESSAGES?: string; LANG?: string }): Lang {
178  if (option === 'en' || option === 'zh-TW' || option === 'ja') return option
179  for (const v of [env.LC_ALL, env.LC_MESSAGES, env.LANG]) {
180    const found = fromLocale(v)
181    if (found) return found
182  }
183  return DEFAULT_LANG
184}
185
hooks/logic.ts 193 lines
1// cache-keeper pure functions: settings parsing, when to poke, backoff, the
2// idle cap, and the band / status texts. No `$` here; shared with the tests.
3
4import type { KeeperState } from '../types'
5import { t } from './i18n'
6import type { Lang } from './i18n'
7
8export type { KeeperState }
9export type { Lang }
10
11export const PLUGIN = 'cache-keeper'
12/** The timer looks every 30 seconds */
13export const TICK_MS = 30_000
14/** The prompt cache lives one hour past its last use */
15export const CACHE_TTL_MIN = 60
16export const DEFAULT_INTERVAL_MIN = 50
17export const MIN_INTERVAL_MIN = 5
18export const MAX_INTERVAL_MIN = 59
19export const DEFAULT_IDLE_CAP_HOURS = 4
20/** First failure waits a minute, then doubles, up to one interval */
21export const FIRST_BACKOFF_MS = 60_000
22export const TOAST_MS = 8000
23/** The one line sent to the model: short, and it does not invite a long reply */
24export const POKE_PROMPT = 'Reply with exactly the single word: ok'
25
26export const EMPTY_STATE: KeeperState = {
27  lastRequestAt: null,
28  lastRealTurnAt: null,
29  lastAttemptAt: null,
30  pokes: 0,
31  failures: 0,
32  lastCacheRead: null,
33  lastInput: null,
34  isPaused: false,
35  backoffMs: 0,
36  isPoking: false,
37  isTurnRunning: false,
38  isCapNoticed: false,
39}
40
41// ── Settings ────────────────────────────────────────────────────────────────
42
43export type KeeperConfig = {
44  enabled: boolean
45  intervalMs: number
46  /** 0 = no cap */
47  idleCapMs: number
48  showBand: boolean
49  /** The raw `language` option; resolved against the environment at session.start */
50  language: unknown
51  /** How the band line is framed (`band_style`) */
52  bandStyle: BandStyle
53}
54
55function num(v: unknown, fallback: number): number {
56  const n = typeof v === 'number' ? v : typeof v === 'string' && v.trim() !== '' ? Number(v) : NaN
57  return Number.isFinite(n) ? n : fallback
58}
59
60function bool(v: unknown, fallback: boolean): boolean {
61  if (typeof v === 'boolean') return v
62  if (v === 'true') return true
63  if (v === 'false') return false
64  return fallback
65}
66
67/** The interval is held to 5–59 minutes: past 60 the cache is already gone, under 5 is just spending */
68export function clampInterval(minutes: number): number {
69  if (!Number.isFinite(minutes)) return DEFAULT_INTERVAL_MIN
70  return Math.min(MAX_INTERVAL_MIN, Math.max(MIN_INTERVAL_MIN, Math.round(minutes)))
71}
72
73export function parseConfig(options: Readonly<Record<string, unknown>> | undefined): KeeperConfig {
74  const o = options ?? {}
75  const capHours = Math.max(0, num(o.max_idle_hours, DEFAULT_IDLE_CAP_HOURS))
76  return {
77    enabled: bool(o.enabled, true),
78    intervalMs: clampInterval(num(o.interval_minutes, DEFAULT_INTERVAL_MIN)) * 60_000,
79    idleCapMs: Math.round(capHours * 3_600_000),
80    showBand: bool(o.show_band, true),
81    language: o.language,
82    bandStyle: parseBandStyle(o.band_style),
83  }
84}
85
86// ── Timing ──────────────────────────────────────────────────────────────────
87
88/**
89 * When the next poke is due: normally one interval after the last request;
90 * while backing off, the backoff after the last attempt (whichever is later).
91 */
92export function nextPokeAt(lastRequestAt: number, lastAttemptAt: number | null, intervalMs: number, backoffMs: number): number {
93  const regular = lastRequestAt + intervalMs
94  if (backoffMs <= 0 || lastAttemptAt === null) return regular
95  return Math.max(regular, lastAttemptAt + backoffMs)
96}
97
98export function isDue(now: number, s: Pick<KeeperState, 'lastRequestAt' | 'lastAttemptAt' | 'backoffMs'>, intervalMs: number): boolean {
99  if (s.lastRequestAt === null) return false
100  return now >= nextPokeAt(s.lastRequestAt, s.lastAttemptAt, intervalMs, s.backoffMs)
101}
102
103/** Past the idle cap since the last real turn; a cap of 0 means no cap */
104export function isPastIdleCap(lastRealTurnAt: number | null, now: number, capMs: number): boolean {
105  if (capMs <= 0 || lastRealTurnAt === null) return false
106  return now - lastRealTurnAt >= capMs
107}
108
109/** Backoff after a failure: 1m, 2m, 4m… up to one interval */
110export function nextBackoff(prev: number, intervalMs: number): number {
111  const next = prev <= 0 ? FIRST_BACKOFF_MS : prev * 2
112  return Math.min(next, intervalMs)
113}
114
115export type UsageLike = { input_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }
116
117/** The poke missed the cache: under a quarter of the input was served from it (usually the entry had lapsed) */
118export function isCacheMiss(u: UsageLike): boolean {
119  const total = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
120  if (total <= 0) return false
121  return u.cache_read_input_tokens * 4 < total
122}
123
124// ── Text ────────────────────────────────────────────────────────────────────
125
126export function fmtTokens(n: number): string {
127  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
128  if (n >= 10_000) return `${Math.round(n / 1000)}k`
129  if (n >= 1000) return `${(n / 1000).toFixed(1)}k`
130  return `${n}`
131}
132
133export function duration(ms: number): string {
134  const s = Math.max(0, Math.floor(ms / 1000))
135  if (s < 60) return `${s}s`
136  if (s < 3600) return `${Math.floor(s / 60)}m`
137  return `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`
138}
139
140/** For countdowns: under a minute reads "under 1m" */
141export function countdown(ms: number, lang: Lang): string {
142  if (ms < 60_000) return t(lang, 'countdown.under1m')
143  return duration(ms)
144}
145
146/** The band line; null means no line */
147export function bandText(s: KeeperState, cfg: KeeperConfig, now: number, lang: Lang): string | null {
148  if (!cfg.enabled || !cfg.showBand || s.isPaused) return null
149  if (s.lastRequestAt === null) return null
150  if (s.isPoking) return t(lang, 'band.poking')
151  if (isPastIdleCap(s.lastRealTurnAt, now, cfg.idleCapMs)) return t(lang, 'band.capped', { d: duration(cfg.idleCapMs) })
152  const at = nextPokeAt(s.lastRequestAt, s.lastAttemptAt, cfg.intervalMs, s.backoffMs)
153  const parts = [t(lang, 'band.warm'), t(lang, 'band.next', { t: countdown(at - now, lang) })]
154  if (s.pokes > 0) parts.push(t(lang, 'band.pokes', { n: s.pokes }))
155  if (s.lastCacheRead !== null) parts.push(t(lang, 'band.lastHit', { tok: fmtTokens(s.lastCacheRead) }))
156  if (s.backoffMs > 0) parts.push(t(lang, 'band.backoff', { n: s.failures }))
157  return parts.join(' · ')
158}
159
160/** The /cache-keeper status text */
161export function statusText(s: KeeperState, cfg: KeeperConfig, now: number, lang: Lang): string {
162  const lines: string[] = []
163  lines.push(!cfg.enabled ? t(lang, 'status.disabled') : s.isPaused ? t(lang, 'status.paused') : t(lang, 'status.enabled'))
164  lines.push(t(lang, 'status.interval', { interval: duration(cfg.intervalMs), cap: cfg.idleCapMs > 0 ? duration(cfg.idleCapMs) : t(lang, 'status.noCap') }))
165  if (s.lastRequestAt === null) lines.push(t(lang, 'status.noRequest'))
166  else if (isPastIdleCap(s.lastRealTurnAt, now, cfg.idleCapMs)) lines.push(t(lang, 'status.capped'))
167  else {
168    const at = nextPokeAt(s.lastRequestAt, s.lastAttemptAt, cfg.intervalMs, s.backoffMs)
169    lines.push(t(lang, 'status.next', { ago: duration(now - s.lastRequestAt), next: countdown(at - now, lang) }))
170  }
171  const hit = s.lastCacheRead !== null ? t(lang, 'status.lastHit', { hit: fmtTokens(s.lastCacheRead), miss: fmtTokens(s.lastInput ?? 0) }) : ''
172  lines.push(`${t(lang, 'status.counts', { pokes: s.pokes, failures: s.failures })}${hit}`)
173  return lines.join('\n')
174}
175
176/** One poke's outcome (for /cache-keeper now and the log) */
177export function pokeText(u: UsageLike, lang: Lang): string {
178  return isCacheMiss(u)
179    ? t(lang, 'poke.miss', { tok: fmtTokens(u.cache_creation_input_tokens + u.input_tokens) })
180    : t(lang, 'poke.hit', { hit: fmtTokens(u.cache_read_input_tokens), miss: fmtTokens(u.input_tokens + u.cache_creation_input_tokens) })
181}
182
183
184// ── Band framing ───────────────────────────────────────────────────────────
185
186/** How the mod's line above the prompt is framed: a rounded box, a thin rule beneath, or bare text. */
187export type BandStyle = 'box' | 'rule' | 'plain'
188
189/** The `band_style` option; anything but `rule` or `plain` is the default box. */
190export function parseBandStyle(v: unknown): BandStyle {
191  return v === 'rule' || v === 'plain' ? v : 'box'
192}
193
types/index.d.ts 42 lines
1// cache-keeper data types and its $.state contract.
2
3/** This session's warming state; kept across hot reloads, partly reset on /clear */
4export type KeeperState = {
5  /** When the last real model request went out (main-loop turn.step, turn.complete, or our own poke); null = none yet */
6  lastRequestAt: number | null
7  /** When the last real turn started; the idle cap counts from here */
8  lastRealTurnAt: number | null
9  /** When we last tried to poke (success or failure); backoff counts from here */
10  lastAttemptAt: number | null
11  /** Successful pokes */
12  pokes: number
13  /** Failed pokes */
14  failures: number
15  /** Cache-read tokens of the last successful poke */
16  lastCacheRead: number | null
17  /** Uncached input tokens of the last successful poke (input + cache_creation) */
18  lastInput: number | null
19  /** True after /cache-keeper off */
20  isPaused: boolean
21  /** Current backoff in milliseconds; 0 = none */
22  backoffMs: number
23  /** A poke is in flight */
24  isPoking: boolean
25  /** A main-loop turn is running */
26  isTurnRunning: boolean
27  /** The idle-cap notice has been shown once */
28  isCapNoticed: boolean
29}
30
31declare module 'claude-code' {
32  interface PluginState {
33    'cache-keeper': {
34      state: KeeperState
35      /** Written on every timer tick, only so the countdown redraws */
36      tickAt: number
37      /** The resolved UI language: 'en', 'zh-TW' or 'ja' */
38      lang: string
39    }
40  }
41}
42