SLOPSHOPPER

cache-meter

Prompt cache status above the prompt: how long the cache stays warm, what a re-cache would cost, plan limits near the cap. Keeps a big cache warm on request…

newbandspinnercommandtoastprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cache-meter
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ cache-meter │ ⏺ Read(src/auth.ts) │ Keeping the cache warm until 5:53: ≈ 4 │ ⎿ Read 6 lines │ pings, each reads ≈ 97.4k. │ ⏺ 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 ⎿ cache-meter: cache-meter: 97.4k tokens in context, model opus-5-5, cache lives 60 min (subscription). ⎿ cache-meter: No request in this session yet, so the cache state is unknown. ⎿ cache-meter: Plan limits used: 5 h 31%. ⎿ cache-meter: Session cost so far: API equivalent $0.42. Re-cached 0 times. ⎿ cache-meter: Question before a cold send: on, for contexts from 150k tokens. Alerts: on. ⎿ cache-meter: Settings: /cache ttl 5|60|auto, /cache guard on|off, /cache big 150k, /cache alerts on|off. Keep warm: /keepwa Re-caching would cost ≈ 97.4k ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Re-caching would cost ≈ 97.4k ⟨Claude Code's own drawing⟩
README

cache-meter

English | Українська

A fork of the cache part of cache-keeper from nateherkai/claude-code-mods (33a936f, by Nate Herk), without its board, its handoff and its recording mode.

Every request re-reads the whole context. From the prompt cache that costs about a tenth of normal input. Once the cache expires (1 hour idle on a subscription, 5 minutes on the default API TTL), the next message writes the whole context again at 1.25x to 2x input. This mod shows where the cache stands and what a re-cache would cost.

What you see

  • A row above the prompt: the cache is warm, cooling or cold, and what re-caching would cost. On the API that is in dollars. On a subscription it is in tokens, since a subscription pays in plan limits rather than dollars; so is a model the mod has no prices for. /cache adds the dollars as the API equivalent. Plan limits appear once one passes 80%.
  • Keep warm, a button (k in the terminal) or /keepwarm [hours|off]: see Keep warm for what it does and what it uses.
  • A question before you send into a big cold cache (150k tokens by default): Send anyway, Compact and send, Run /handoff (if the session has one) or Cancel.
  • A toast when a big cache is about to cool.

How long the cache lives: 60 minutes on a subscription and 5 on the API, which the mod tells apart by whether Claude Code reports plan limits after the first answer. What it measures later corrects that, and so does Claude Code itself when the model changes. /cache says where the figure came from.

Keep warm

Keep warm stops the cache from going cold while you are away. Before each expiry it sends a tiny request that re-reads the cache, which restarts its timer. It runs for 4 hours by default and 24 at most; /keepwarm off or Stop keeping ends it sooner.

It is not free. Each ping re-reads the whole context: on the API that is a fraction of the input price, ≈ $0.04 for 200k tokens of Opus 5.5; on a subscription the same tokens count against your plan limits. A subscription's one-hour cache takes a ping every 52 minutes, about 4 in 4 hours; the API's 5-minute cache takes one every 3.5 minutes, about 70. When it starts, a toast estimates how many pings it will send and what each reads.

It stops by itself when the time is up, when a ping writes the cache instead of reading it (the cache had gone cold anyway), when a ping gets no reply, and once any plan limit window reaches 90%: pings should not use up the limit your next real message needs. At 90% or more it does not start.

Commands

CommandWhat it does
/cacheThe cache status and the settings
`/cache ttl 5\60\auto`The cache lifetime for this session only; auto goes back to what the session shows
`/cache guard on\off`The question before a cold send
/cache big 150kFrom how many tokens a context counts as big
`/cache alerts on\off`The toast before the cache cools
`/keepwarm [hours\off]`Keep the cache warm, or stop

If another plugin already has /cache or /keepwarm, the mod's commands are /cm-cache and /cm-keepwarm.

For other mods

The mod publishes the cache state in $.state as cache-meter.cache (see types/index.d.ts). handoff-relay and next-steps read it to show what their button costs on a cold cache. cache-meter itself depends on no other mod.

Options

OptionDefaultWhat it does
languageautoauto follows Claude's response language from /config; en or uk set it

Checks

claude plugin validate .
claude plugin test .
Source 8 files
hooks/register.mjs 671 lines
1// SPDX-License-Identifier: MIT
2// Cache Meter: watches this session's prompt cache, keeps a big cache warm on
3// request, and asks before a cold send. The cache part of Nate Herk's
4// cache-keeper (nateherkai/claude-code-mods), without its board, its handoff
5// and its recording mode.
6//
7// Why: each request re-reads the whole context. From the cache that costs about
8// a tenth of normal input. Once the cache expires (1 hour idle on a subscription,
9// 5 minutes on the default API TTL), the next message writes the whole context
10// again at 1.25x to 2x input. A cache read also restarts the timer, so a tiny
11// ping before expiry costs a fraction of a rewrite.
12//
13// Other mods read this one's state (`cache-meter.cache`, see types/index.d.ts);
14// it never depends on them. When a /handoff command exists in the session, the
15// cold-send question offers it.
16
17import { atom, read, update } from 'claude-code'
18import { joinBand } from './band.mjs'
19import { rewriteCost, requestCost, readCost, totalInput, cachedShare, priceFor, knownPrice, normalizeModel } from './pricing.mjs'
20import { tokens, usd } from './fmt.mjs'
21import { resolveLanguage, isLanguageKey } from './i18n.mjs'
22import en from './locales/en.mjs'
23import uk from './locales/uk.mjs'
24
25const MIN = 60000
26const TICK_EVERY = 30000
27const CACHE = { plugin: 'cache-meter', key: 'cache' }
28const HANDOFF = 'handoff'
29const LOCALES = { en, uk }
30// Keep warm stops, and does not start, once a plan limit window is this full
31const KEEP_WARM_LIMIT = 90
32// The words the person sees, in the language the `language` option picks (auto: Claude's own)
33let L = en
34let language = 'auto'
35let isLanguagePicked = false
36
37async function pickLanguage($) {
38  isLanguagePicked = true
39  let rows = []
40  if (language !== 'en' && language !== 'uk') {
41    try {
42      rows = await $.config.list()
43    } catch {
44      rows = [] // no /config here (a test, a -p run): English
45    }
46  }
47  L = LOCALES[resolveLanguage(language, rows)]
48}
49
50// This session, kept by the host in $.state so a reload of the code keeps it.
51// The shape tag declines a value an older version of this code wrote.
52const FRESH = {
53  model: '',
54  lastActivity: 0, // last main-loop request or keep-warm ping that touched the cache
55  ctx: 0,
56  costUsd: 0,
57  // How the account pays: 'subscription' once the engine reports plan limits,
58  // 'api' when a main-loop answer came without them
59  plan: 'unknown',
60  ttlMin: 60,
61  // Where ttlMin came from: default, subscription, api, measured or engine
62  ttlSource: 'default',
63  // /cache ttl 5|60, for this session only; 0 is auto
64  manualTtlMin: 0,
65  coldRestarts: [],
66  working: false,
67  keepWarm: false,
68  keepWarmUntil: 0,
69  pings: 0,
70  pingTokens: 0,
71  pingUsd: 0,
72  rateLimits: [],
73  justCompacted: false,
74  alerted: '',
75}
76const SESSION = atom({ plugin: 'cache-meter', key: 'session' }, FRESH, { shape: 'v1' })
77
78async function session($) {
79  return { ...FRESH, ...(await read($, SESSION)) }
80}
81
82function change($, fn) {
83  return update($, SESSION, (s) => fn({ ...FRESH, ...s }))
84}
85
86function minutes(ms) {
87  return L.minutes(Math.round((Number(ms) || 0) / 60000))
88}
89
90function clock(ts) {
91  const d = new Date(ts)
92  return d.getHours() + ':' + String(d.getMinutes()).padStart(2, '0')
93}
94
95function pings(n) {
96  return L.pings(n)
97}
98
99// Preferences kept across sessions in $.store. The cache lifetime is not one of them:
100// /cache ttl holds for the session it was typed in.
101const settings = { bigTokens: 150000, guard: true, alerts: true, keepWarmHours: 4 }
102const names = { cache: 'cache', keepwarm: 'keepwarm' }
103
104let now = 0
105let published = ''
106
107function ttlMin(s) {
108  return s.manualTtlMin || s.ttlMin
109}
110
111function ttlSource(s) {
112  return s.manualTtlMin ? 'manual' : s.ttlSource
113}
114
115// five_hour and seven_day are a subscription's windows; a gateway's spend_limit is not
116function isSubscription(limits) {
117  return limits.some((l) => l.kind === 'five_hour' || l.kind === 'seven_day')
118}
119
120// The plan decides the cache lifetime until a measurement or the engine says otherwise
121function withPlan(s, limits, hasAnswered) {
122  const plan = isSubscription(limits) ? 'subscription' : hasAnswered && s.plan === 'unknown' ? 'api' : s.plan
123  if (plan === s.plan) return s
124  const next = { ...s, plan }
125  if (s.ttlSource === 'default' || s.ttlSource === 'subscription' || s.ttlSource === 'api') {
126    next.ttlMin = plan === 'subscription' ? 60 : 5
127    next.ttlSource = plan
128  }
129  return next
130}
131
132// Dollars only where they are real: on the API, for a model with known prices.
133// A subscription pays in plan limits, so it sees tokens.
134function showsUsd(s) {
135  return s.plan === 'api' && Boolean(knownPrice(s.model))
136}
137
138// An amount in the band, the footer and the toasts: dollars on the API, tokens elsewhere
139function amount(s, tok, dollars) {
140  return showsUsd(s) ? usd(dollars) : L.tok(tokens(tok))
141}
142
143// An amount in /cache: on a subscription the tokens, with the dollars as the API equivalent
144function statusAmount(s, tok, dollars) {
145  if (showsUsd(s)) return usd(dollars)
146  if (s.plan === 'subscription' && knownPrice(s.model)) return `${L.tok(tokens(tok))} (${L.apiEquivalent(usd(dollars))})`
147  return L.tok(tokens(tok))
148}
149
150function rewrite(s) {
151  return { tokens: s.ctx, usd: rewriteCost(s.ctx, s.model, ttlMin(s)) }
152}
153
154function restarts(s) {
155  return {
156    times: L.times(s.coldRestarts.length),
157    tokens: s.coldRestarts.reduce((a, c) => a + c.tokens, 0),
158    usd: s.coldRestarts.reduce((a, c) => a + c.usd, 0),
159  }
160}
161
162// Plan limit windows as the API reports them: five_hour, seven_day, spend_limit
163function limitsText(limits) {
164  return limits.map((l) => `${L.limit[l.kind] || l.kind} ${Math.round(l.percentUsed)}%`).join(', ')
165}
166
167function limitsFrom(s, percent) {
168  return s.rateLimits.filter((l) => (l.percentUsed || 0) >= percent)
169}
170
171function limitTone(limits, extra) {
172  const top = Math.max(...limits.map((l) => l.percentUsed || 0))
173  if (top >= 95) return { ...extra, color: 'red', bold: true }
174  if (top >= 80) return { ...extra, color: 'yellow' }
175  return { ...extra, dimColor: true }
176}
177
178function msLeft(lastActivity, ttl) {
179  if (!lastActivity) return null
180  return ttl * MIN - (now - lastActivity)
181}
182
183function cacheState(s) {
184  const left = msLeft(s.lastActivity, ttlMin(s))
185  if (left === null) return { kind: 'unknown', left: 0 }
186  if (s.keepWarm) return { kind: 'kept', left }
187  if (left <= 0) return { kind: 'cold', left }
188  // Cooling: the last 5 minutes of an hour's cache, the last 2 of a 5-minute one
189  if (left <= Math.min(5 * MIN, ttlMin(s) * MIN * 0.4)) return { kind: 'cooling', left }
190  return { kind: 'warm', left }
191}
192
193function isBig(s) {
194  return s.ctx >= settings.bigTokens
195}
196
197// What other mods read: the cache's state, the context and the price of a rewrite.
198// Fields are only ever added, so a reader written for an older version keeps working.
199function snapshot(s) {
200  const ttl = ttlMin(s)
201  return {
202    kind: cacheState(s).kind,
203    ctx: s.ctx,
204    isBig: isBig(s),
205    rewriteUsd: Math.round(rewriteCost(s.ctx, s.model, ttl) * 100) / 100,
206    ttlMin: ttl,
207    model: priceFor(s.model).id,
208    rewriteTokens: s.ctx,
209    unit: showsUsd(s) ? 'usd' : 'tokens',
210  }
211}
212
213// Writes only on a change, so the readers redraw when the cache turns, not every tick
214async function publish($) {
215  const value = snapshot(await session($))
216  const json = JSON.stringify(value)
217  if (json === published) return
218  published = json
219  try {
220    await $.state.set(CACHE, value)
221  } catch {
222    published = '' // try again on the next change or tick
223  }
224}
225
226async function registerCommand($, name, description, argumentHint, immediate) {
227  const spec = immediate ? { name, description, argumentHint, immediate: true } : { name, description, argumentHint }
228  try {
229    await $.command.register(spec)
230    return name
231  } catch {
232    try {
233      await $.command.register({ ...spec, name: 'cm-' + name })
234      return 'cm-' + name
235    } catch {
236      return null
237    }
238  }
239}
240
241// The session's /handoff, if there is one (a skill, a plugin's command): never required
242async function handoffCommand($) {
243  try {
244    const commands = await $.command.list()
245    // The person's own /handoff first; else one a plugin brings, which may carry its prefix (handoff-relay:handoff)
246    if (commands.some((c) => c.name === HANDOFF)) return HANDOFF
247    const plugin = commands.find((c) => c.name.endsWith(':' + HANDOFF))
248    return plugin ? plugin.name : null
249  } catch {
250    return null
251  }
252}
253
254// How long after the last touch a keep-warm ping goes out
255function pingAfter(ttl) {
256  return ttl * MIN - (ttl >= 60 ? 8 * MIN : 90000)
257}
258
259// About how many pings keep the cache warm until `until`: one each time the cache nears expiry
260function pingsUntil(s, until) {
261  const period = pingAfter(ttlMin(s))
262  const first = (s.lastActivity || now) + period
263  return first > until ? 0 : Math.floor((until - first) / period) + 1
264}
265
266async function startKeepWarm($, hours) {
267  const s = await session($)
268  const full = limitsFrom(s, KEEP_WARM_LIMIT)
269  if (full.length) {
270    $.ui.toast(L.notKeeping(limitsText(full)), { timeoutMs: 8000 })
271    return
272  }
273  const until = now + hours * 60 * MIN
274  await change($, (x) => ({ ...x, keepWarm: true, keepWarmUntil: until }))
275  // Each ping re-reads the whole context: on a subscription that comes out of the plan limits
276  const each = showsUsd(s) ? L.eachCosts(usd(readCost(s.ctx, s.model))) : L.eachReads(L.tok(tokens(s.ctx)))
277  const count = pingsUntil(s, until)
278  // Over before the cache nears expiry: no ping will go out, so there is nothing to estimate
279  $.ui.toast(count ? L.keepingUntil(clock(until), pings(count), each) : L.keepingNoPing(clock(until)), { timeoutMs: 8000 })
280}
281
282async function stopKeepWarm($, why) {
283  const s = await session($)
284  if (!s.keepWarm) return
285  await change($, (x) => ({ ...x, keepWarm: false }))
286  // Before the first ping there is nothing to count
287  const text = s.pings ? L.stoppedKeeping(why, pings(s.pings), amount(s, s.pingTokens, s.pingUsd)) : L.stoppedKeepingNoPings(why)
288  $.ui.toast(text, { timeoutMs: 8000 })
289}
290
291async function keepWarmStep($) {
292  const s = await session($)
293  if (!s.keepWarm) return
294  if (now >= s.keepWarmUntil) return stopKeepWarm($, L.whyTimeUp)
295  const full = limitsFrom(s, KEEP_WARM_LIMIT)
296  if (full.length) return stopKeepWarm($, L.whyLimit(limitsText(full)))
297  if (s.working || !s.lastActivity) return
298  const ttl = ttlMin(s)
299  if (now - s.lastActivity < pingAfter(ttl)) return
300  let reply = null
301  try {
302    reply = await $.model.fork({ prompt: 'cache-meter keep-alive ping. Reply with only: ok' })
303  } catch {
304    reply = null
305  }
306  if (!reply || !reply.usage) {
307    return stopKeepWarm($, L.whyNoReply)
308  }
309  const u = reply.usage
310  // A ping that writes instead of reading did not hit the cache: stop paying for it
311  const isMiss = (u.cache_creation_input_tokens || 0) > 0.1 * Math.max(1, u.cache_read_input_tokens || 0)
312  const at = now
313  await change($, (x) => ({
314    ...x,
315    pings: x.pings + 1,
316    pingTokens: x.pingTokens + totalInput(u),
317    pingUsd: x.pingUsd + requestCost(u, x.model, ttl),
318    lastActivity: isMiss ? x.lastActivity : at,
319  }))
320  if (isMiss) return stopKeepWarm($, L.whyWrote(tokens(u.cache_creation_input_tokens)))
321}
322
323async function warnStep($) {
324  const s = await session($)
325  if (!settings.alerts || s.keepWarm || !isBig(s)) return
326  const st = cacheState(s)
327  if (st.kind !== 'cooling') return
328  const key = 'cool:' + s.lastActivity
329  if (s.alerted === key) return
330  await change($, (x) => ({ ...x, alerted: key }))
331  const r = rewrite(s)
332  $.ui.toast(L.coolingAlert(tokens(s.ctx), minutes(st.left), amount(s, r.tokens, r.usd), names.keepwarm), { timeoutMs: 15000 })
333}
334
335async function tick($) {
336  now = await $.clock.now()
337  await keepWarmStep($)
338  await warnStep($)
339  await publish($)
340  $.ui.invalidate('ui.render')
341}
342
343async function loadSettings($) {
344  const saved = await $.store.get('settings')
345  if (!saved || typeof saved !== 'object') return
346  // Up to 0.2.0 /cache ttl was saved for every later session; it now holds for one
347  const { ttlMin: _dropped, ...rest } = saved
348  Object.assign(settings, rest)
349  if ('ttlMin' in saved) await $.store.set('settings', settings)
350}
351
352function modelLabel(s) {
353  const price = knownPrice(s.model)
354  return price ? price.id : L.unpricedModel(normalizeModel(s.model) || '?')
355}
356
357function statusText(s) {
358  const st = cacheState(s)
359  const ttl = ttlMin(s)
360  const lines = []
361  const source = L.ttlSource[ttlSource(s)] || ttlSource(s)
362  lines.push(L.statusHead(tokens(s.ctx), modelLabel(s), ttl, source))
363  if (st.kind === 'unknown') lines.push(L.statusUnknown)
364  if (st.kind === 'warm' || st.kind === 'cooling') lines.push(L.statusWarm(minutes(st.left)))
365  const r = rewrite(s)
366  if (st.kind === 'cold') lines.push(L.statusCold(minutes(-st.left), statusAmount(s, r.tokens, r.usd)))
367  if (s.keepWarm) {
368    const until = clock(s.keepWarmUntil)
369    lines.push(s.pings ? L.statusKept(until, pings(s.pings), statusAmount(s, s.pingTokens, s.pingUsd)) : L.statusKeptNoPings(until))
370  }
371  if (s.rateLimits.length) lines.push(L.statusLimits(limitsText(s.rateLimits)))
372  const re = restarts(s)
373  const times = s.coldRestarts.length ? `${re.times} (≈ ${statusAmount(s, re.tokens, re.usd)})` : re.times
374  // The session's cost as the engine totals it: real on the API, an equivalent on a subscription
375  const cost = !knownPrice(s.model) || s.plan === 'unknown' ? null : s.plan === 'api' ? usd(s.costUsd) : L.apiEquivalent(usd(s.costUsd))
376  lines.push(cost ? L.statusCost(cost, times) : L.statusRestarts(times))
377  lines.push(L.statusGuard(L.onOff(settings.guard), tokens(settings.bigTokens), L.onOff(settings.alerts)))
378  lines.push(L.statusSettings(names.cache, names.keepwarm))
379  return lines.join('\n')
380}
381
382function parseTokens(text) {
383  const m = String(text || '').trim().toLowerCase().match(/^(\d+(?:\.\d+)?)\s*([km]?)$/)
384  if (!m) return null
385  return Math.round(Number(m[1]) * (m[2] === 'm' ? 1e6 : m[2] === 'k' ? 1e3 : 1))
386}
387
388export function register(on, options) {
389  language = options && options.language
390  if (language === 'en' || language === 'uk') L = LOCALES[language]
391
392  on('session.start', async ($, e, next) => {
393    now = await $.clock.now()
394    await pickLanguage($)
395    const model = await $.session.model()
396    await change($, (s) => ({ ...s, model: s.model || model }))
397    await loadSettings($)
398    names.cache = (await registerCommand($, 'cache', L.cacheDescription, '[ttl 5|60|auto] [guard on|off] [big 150k] [alerts on|off]')) || names.cache
399    names.keepwarm = (await registerCommand($, 'keepwarm', L.keepwarmDescription, L.keepwarmHint, true)) || names.keepwarm
400    $.clock.every(TICK_EVERY, () => tick($).catch(() => {}))
401    await publish($)
402    return next(e)
403  })
404
405  // Claude's language changed in /config: under auto, the mods follow it
406  on('config.set', async ($, e, next) => {
407    const r = await next(e)
408    if (isLanguageKey(e.key)) {
409      await pickLanguage($)
410      $.ui.invalidate('ui.render')
411    }
412    return r
413  })
414
415  // /clear, /resume and /branch start from an unknown cache. The process goes
416  // on under a new session id, and no session.start fires for it.
417  on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
418    await change($, (s) => ({ ...s, lastActivity: 0, keepWarm: false }))
419    await publish($)
420    return next(e)
421  })
422
423  // The engine names the cache lifetime when the model changes: the surest source there is
424  on('classic.PostModelSwitch', async ($, e, next) => {
425    const ttl = e.cache_ttl === '5m' ? 5 : e.cache_ttl === '1h' ? 60 : 0
426    if (ttl) await change($, (s) => ({ ...s, ttlMin: ttl, ttlSource: 'engine', model: e.to_model || s.model }))
427    await publish($)
428    return next(e)
429  })
430
431  // A cold send of a big context: ask first
432  on('prompt.submit', async ($, e, next) => {
433    now = await $.clock.now()
434    const s = await session($)
435    const left = msLeft(s.lastActivity, ttlMin(s))
436    const isCold = left !== null && left <= 0
437    const fromUser = e.origin && (e.origin.kind === 'composer' || e.origin.kind === 'bridge')
438    if (!settings.guard || !fromUser || !isCold || !isBig(s) || e.turnId) return next(e)
439    const r = rewrite(s)
440    const cost = amount(s, r.tokens, r.usd)
441    const handoff = await handoffCommand($)
442    const handoffLabel = L.runHandoff(handoff || HANDOFF)
443    const choices = [L.send, L.compact, ...(handoff ? [handoffLabel] : []), L.cancel]
444    let answer = L.send
445    try {
446      // The question already names the tokens; it adds dollars only where they are real
447      answer = await $.ui.ask(L.coldQuestion(minutes(-left), tokens(s.ctx), showsUsd(s) ? cost : null), choices)
448    } catch {
449      // nobody to ask (a -p run, or the dialog was dismissed): send as typed
450      return next(e)
451    }
452    if (answer === L.compact) {
453      try {
454        await $.session.compact()
455      } catch {
456        // compaction refused or failed: send anyway
457      }
458      return next(e)
459    }
460    if (answer === L.send) return next(e)
461    if (handoff && answer === handoffLabel) {
462      // Off the hook: a command started inside a hook the session waits on is refused
463      $.clock.after(50, () =>
464        $.command.run({ command: handoff, args: '' }).catch((err) => {
465          $.ui.toast(L.handoffFailed(handoff, String((err && err.message) || err).slice(0, 100)), { timeoutMs: 8000 })
466        }),
467      )
468      // The handoff turn still reads the whole context, so it pays this rewrite once;
469      // what it saves is every later turn in a context this big
470      return { drop: L.droppedForHandoff(handoff, cost) }
471    }
472    $.ui.toast(L.droppedToast, { timeoutMs: 8000 })
473    return { drop: L.dropped }
474  })
475
476  on('turn.start', async ($, e, next) => {
477    await change($, (s) => ({ ...s, working: true }))
478    return next(e)
479  })
480
481  // Each main-loop request: did it read the cache, or rewrite it?
482  on('turn.step', async function* ($, e, next) {
483    const startedAt = await $.clock.now()
484    const result = yield* next(e)
485    if (e.agentId || !result || !result.usage) return result
486    now = await $.clock.now()
487    const u = result.usage
488    const total = totalInput(u)
489    const written = u.cache_creation_input_tokens || 0
490    const at = now
491    await change($, (s) => {
492      const x = { ...s, model: u.model || s.model, justCompacted: false, ctx: total, lastActivity: at }
493      const gap = s.lastActivity ? startedAt - s.lastActivity : 0
494      if (s.justCompacted) {
495        // the first request after a compaction writes the new, shorter context: expected
496      } else if (s.lastActivity && total > 30000 && written / total > 0.5) {
497        // A rewrite after 5-60 idle minutes means this session runs on the 5-minute TTL
498        if (gap > 5.5 * MIN && gap < s.ttlMin * MIN) {
499          x.ttlMin = 5
500          x.ttlSource = 'measured'
501        }
502        x.coldRestarts = [...s.coldRestarts, { at, tokens: written, usd: rewriteCost(written, x.model, ttlMin(x)), gapMin: Math.round(gap / MIN) }]
503      } else if (s.lastActivity && total > 30000 && gap > 5.5 * MIN && cachedShare(u) > 0.8) {
504        // A hit after more than 5 idle minutes proves the 1-hour TTL
505        x.ttlMin = 60
506        x.ttlSource = 'measured'
507      }
508      return x
509    })
510    await publish($)
511    return result
512  })
513
514  on('turn.complete', async ($, e, next) => {
515    const r = await next(e)
516    if (e.agentId) return r
517    now = await $.clock.now()
518    let usage = null
519    try {
520      usage = await $.session.usage()
521    } catch {
522      // usage unavailable: keep the per-request figures
523    }
524    await change($, (s) => {
525      let x = { ...s, working: false }
526      if (!usage) return x
527      if (usage.context && usage.context.tokens) x.ctx = usage.context.tokens
528      if (usage.cost) x.costUsd = usage.cost.usd
529      const limits = Array.isArray(usage.rateLimits) ? usage.rateLimits : []
530      if (limits.length) x.rateLimits = limits
531      // After an answer the engine has had its say on plan limits: none means the API
532      return withPlan(x, limits, Boolean(x.lastActivity))
533    })
534    await publish($)
535    $.ui.invalidate('ui.render')
536    return r
537  })
538
539  on('session.compact', async ($, e, next) => {
540    const r = await next(e)
541    await change($, (s) => ({ ...s, justCompacted: true }))
542    return r
543  })
544
545  on('session.measure', async ($, e, next) => {
546    await change($, (s) => {
547      const x = { ...s }
548      if (e.context && e.context.tokens) x.ctx = e.context.tokens
549      if (e.cost) x.costUsd = e.cost.usd
550      if (Array.isArray(e.rateLimits) && e.rateLimits.length) x.rateLimits = e.rateLimits
551      return withPlan(x, x.rateLimits, false)
552    })
553    await publish($)
554    return next(e)
555  })
556
557  on('command.run', { command: ['cache', 'cm-cache'] }, async ($, e) => {
558    now = await $.clock.now()
559    const [key, value] = String(e.args || '').trim().toLowerCase().split(/\s+/)
560    if (key === 'ttl') {
561      const ttl = value === '5' ? 5 : value === '60' ? 60 : 0
562      await change($, (s) => ({ ...s, manualTtlMin: ttl }))
563    } else if (key === 'guard' || key === 'alerts') {
564      settings[key] = value !== 'off'
565      await $.store.set('settings', settings)
566    } else if (key === 'big') {
567      const n = parseTokens(value)
568      if (n) settings.bigTokens = n
569      await $.store.set('settings', settings)
570    }
571    if (key) {
572      await publish($)
573      $.ui.invalidate('ui.render')
574    }
575    return { text: statusText(await session($)) }
576  })
577
578  on('command.run', { command: ['keepwarm', 'cm-keepwarm'] }, async ($, e) => {
579    now = await $.clock.now()
580    const arg = String(e.args || '').trim().toLowerCase()
581    const s = await session($)
582    if (arg === 'off' || (arg === '' && s.keepWarm)) {
583      await stopKeepWarm($, L.whyOff)
584    } else {
585      const hours = Number(arg) > 0 ? Math.min(24, Number(arg)) : settings.keepWarmHours
586      await startKeepWarm($, hours)
587    }
588    await publish($)
589    $.ui.invalidate('ui.render')
590    return {}
591  })
592
593  // The band above the prompt: this session's cache at a glance
594  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
595    const below = await next(e)
596    if (e.props && e.props.hasSurvey) return below
597    const s = await session($)
598    if (!s.lastActivity && !s.keepWarm) return below
599    if (!isLanguagePicked) await pickLanguage($)
600    now = await $.clock.now()
601    const { Box, Text, Button } = $.ui.resolve(e)
602    const st = cacheState(s)
603    const big = isBig(s)
604    const parts = []
605    if (st.kind === 'kept') {
606      const until = clock(s.keepWarmUntil)
607      parts.push(Text({ color: 'cyan', children: [s.pings ? L.kept(until, pings(s.pings), amount(s, s.pingTokens, s.pingUsd)) : L.keptNoPings(until)] }))
608    }
609    // All is well, so only the dot is green and the words stay dim
610    else if (st.kind === 'warm') parts.push(Text({ children: [Text({ color: 'green', children: ['●'] }), Text({ dimColor: true, children: [L.warm(minutes(st.left))] })] }))
611    else if (st.kind === 'cooling') parts.push(Text({ color: 'yellow', bold: true, children: [L.cooling(minutes(st.left))] }))
612    else if (st.kind === 'cold') parts.push(Text(big ? { color: 'red', bold: true, children: [L.cold(minutes(-st.left))] } : { dimColor: true, children: [L.cold(minutes(-st.left))] }))
613    const r = rewrite(s)
614    const cost = amount(s, r.tokens, r.usd)
615    parts.push(st.kind === 'cold' && big
616      ? Text({ color: 'red', children: [L.nextRewrites(cost)] })
617      : Text({ dimColor: true, children: [L.rewriteWouldCost(cost)] }))
618    if (s.coldRestarts.length) {
619      const re = restarts(s)
620      parts.push(Text({ dimColor: true, children: [L.rewritten(re.times, amount(s, re.tokens, re.usd))] }))
621    }
622    // Plan limits only when one is close to running out
623    const high = limitsFrom(s, 80)
624    if (high.length) parts.push(Text(limitTone(high, { children: [L.limits(limitsText(high))] })))
625    // Keep warm is on offer whenever there is a warm cache to keep: quiet while there is time,
626    // loud once a big cache is about to cool. A real button like Handoff's; the letter only on the
627    // terminal, since a desktop draws a hotkey as a chip in front of the label
628    const key = e.surface === 'terminal' ? { hotkey: 'k' } : {}
629    const redraw = async (fn) => {
630      now = await $.clock.now()
631      await fn()
632      await publish($)
633      $.ui.invalidate('ui.render')
634    }
635    if (st.kind === 'warm' || st.kind === 'cooling') {
636      const isUrgent = st.kind === 'cooling' && big
637      parts.push(Button({ key: 'keepwarm', label: L.keepWarm, ...key, ...(isUrgent ? {} : { dimColor: true }), onPress: () => redraw(() => startKeepWarm($, settings.keepWarmHours)) }))
638    } else if (st.kind === 'kept') {
639      parts.push(Button({ key: 'keepwarm', label: L.stopKeeping, ...key, onPress: () => redraw(() => stopKeepWarm($, L.whyOff)) }))
640    }
641    // Every part, the button too, is set off from the next by the same dim bar
642    const row = parts.flatMap((p, i) => i ? [Text({ key: `bar${i}`, dimColor: true, children: ['│'] }), p] : [p])
643    const mine = Box({ key: 'cache-meter', flexDirection: 'row', columnGap: 1, children: row })
644    // Its own place in the band the mods of this repo share, whatever order they loaded in
645    return joinBand(Box, 'cache-meter', mine, below)
646  })
647
648  // A short label in the footer, only when there's something to act on
649  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
650    const label = footerLabel(await session($))
651    if (!label) return next(e)
652    const modes = Array.isArray(e.props && e.props.modes) ? e.props.modes : []
653    return next({ ...e, props: { ...e.props, modes: [...modes, label] } })
654  })
655}
656
657function footerLabel(s) {
658  const parts = []
659  const st = cacheState(s)
660  const big = isBig(s)
661  if (st.kind === 'kept') parts.push(L.footerKept)
662  else if (st.kind === 'cooling' && big) parts.push(L.footerCooling(minutes(st.left), names.keepwarm))
663  else if (st.kind === 'cold' && big) {
664    const r = rewrite(s)
665    parts.push(L.footerCold(amount(s, r.tokens, r.usd)))
666  }
667  const high = limitsFrom(s, 80)
668  if (high.length) parts.push(L.footerLimits(limitsText(high)))
669  return parts.join(', ')
670}
671
hooks/band.mjs 49 lines
1// SPDX-License-Identifier: MIT
2// The shared band above the prompt for this repository's mods. The file is the same in
3// every mod (scripts/sync-shared.sh puts the copy there), since a mod is also installed
4// alone, without its neighbours.
5//
6// The engine chains ui.render hooks, and the order of plugins in the chain is not
7// documented: it depends on how and in what order they were installed. So a mod does not
8// put its row "above" or "below" whatever came from further down; it places it in a shared
9// column by its own slot. Whoever is on top of the chain, the band comes out the same:
10// Handoff, cache, "What next?".
11
12export const BAND = 'prompt-band'
13const ROW = 'prompt-band-row:'
14
15// A row's slot in the band. Anything foreign (the engine's row or a mod from elsewhere) goes below ours.
16export const PLACE = { 'handoff-relay': 10, 'cache-meter': 20, 'next-steps': 30 }
17const FOREIGN = 999
18
19/** @param {any} row */
20function placeOf(row) {
21  const key = row && typeof row === 'object' && row.props ? String(row.props.key ?? '') : ''
22  return key.startsWith(ROW) ? Number(key.slice(ROW.length).split(':')[0]) : FOREIGN
23}
24
25// The rows already in the band below us, or whatever someone else drew.
26/** @param {(props: any) => any} Box @param {any} below @returns {any[]} */
27function rowsOf(Box, below) {
28  if (below === null || below === undefined || below === false) return []
29  if (typeof below === 'object' && below.type === 'Box' && below.props && below.props.key === BAND) {
30    return [...(below.children ?? [])]
31  }
32  return [Box({ key: `${ROW}${FOREIGN}:other`, flexDirection: 'column', children: [below] })]
33}
34
35// The band with mod `name`'s row in its slot. Half a line between rows: a whole one looks like an empty paragraph.
36/**
37 * @param {(props: any) => any} Box the column from the surface's table, $.ui.resolve(e).Box
38 * @param {'handoff-relay' | 'cache-meter' | 'next-steps'} name
39 * @param {any} mine this mod's row
40 * @param {any} below what next(e) returned
41 * @returns {any}
42 */
43export function joinBand(Box, name, mine, below) {
44  const rows = rowsOf(Box, below)
45  rows.push(Box({ key: `${ROW}${PLACE[name]}:${name}`, flexDirection: 'column', children: [mine] }))
46  rows.sort((a, b) => placeOf(a) - placeOf(b))
47  return Box({ key: BAND, flexDirection: 'column', rowGap: 0.5, children: rows })
48}
49
hooks/pricing.mjs 104 lines
1// SPDX-License-Identifier: MIT
2// From nateherkai/claude-code-mods (33a936f), where every mod carries a copy.
3
4// Dollars per million tokens, from the Claude API pricing table (cached 2026-09-25).
5// Cache writes cost 1.25x input on the 5-minute TTL and 2x input on the 1-hour TTL.
6const TABLE = [
7  ['fable-5-1', { input: 10, output: 50, read: 0.25 }],
8  ['mythos-5-1', { input: 10, output: 50, read: 0.25 }],
9  ['fable-5', { input: 10, output: 50, read: 1.0 }],
10  ['opus-5-5', { input: 4, output: 20, read: 0.2 }],
11  ['opus-5', { input: 5, output: 25, read: 0.5 }],
12  ['opus-4-8', { input: 5, output: 25, read: 0.5 }],
13  ['opus-4-7', { input: 5, output: 25, read: 0.5 }],
14  ['opus-4-6', { input: 5, output: 25, read: 0.5 }],
15  ['sonnet-5-5', { input: 2, output: 10, read: 0.2 }],
16  ['sonnet-5', { input: 2, output: 10, read: 0.2 }],
17  ['sonnet-4-6', { input: 3, output: 15, read: 0.3 }],
18  ['haiku-4-5', { input: 1, output: 5, read: 0.1 }],
19]
20
21const FAMILY = {
22  fable: 'fable-5-1',
23  mythos: 'mythos-5-1',
24  opus: 'opus-5-5',
25  sonnet: 'sonnet-5-5',
26  haiku: 'haiku-4-5',
27}
28
29export function normalizeModel(model) {
30  return String(model || '')
31    .toLowerCase()
32    .replace(/^claude-/, '')
33    .replace(/\[.*?\]/g, '')
34    .replace(/-\d{8}$/, '')
35    .trim()
36}
37
38// The model's own row of the table, or null for a model the table does not list. A family
39// guess (opus-6 priced as opus-5-5) is null too: what it shows would be made up.
40export function knownPrice(model) {
41  const id = normalizeModel(model)
42  const row = TABLE.find(([key]) => id === key || id.startsWith(key + '-') || id.startsWith(key))
43  return row ? { id: row[0], ...row[1] } : null
44}
45
46export function priceFor(model) {
47  const id = normalizeModel(model)
48  for (const [key, price] of TABLE) {
49    if (id === key || id.startsWith(key + '-') || id.startsWith(key)) {
50      // 'opus-5' must not swallow 'opus-5-5': the table lists longer ids first
51      return { id: key, ...price }
52    }
53  }
54  for (const [family, key] of Object.entries(FAMILY)) {
55    if (id.includes(family)) {
56      const found = TABLE.find(([k]) => k === key)
57      return { id: key, ...found[1] }
58    }
59  }
60  return { id: 'opus-5-5', input: 4, output: 20, read: 0.2 }
61}
62
63export function writeRate(model, ttlMinutes) {
64  const p = priceFor(model)
65  return p.input * (ttlMinutes >= 60 ? 2 : 1.25)
66}
67
68// usage: { input_tokens, output_tokens, cache_read_input_tokens, cache_creation_input_tokens }
69export function requestCost(usage, model, ttlMinutes = 60) {
70  if (!usage) return 0
71  const p = priceFor(model || usage.model)
72  const w = writeRate(model || usage.model, ttlMinutes)
73  return (
74    ((usage.input_tokens || 0) * p.input +
75      (usage.cache_read_input_tokens || 0) * p.read +
76      (usage.cache_creation_input_tokens || 0) * w +
77      (usage.output_tokens || 0) * p.output) /
78    1e6
79  )
80}
81
82export function rewriteCost(tokens, model, ttlMinutes = 60) {
83  return ((tokens || 0) * writeRate(model, ttlMinutes)) / 1e6
84}
85
86export function readCost(tokens, model) {
87  return ((tokens || 0) * priceFor(model).read) / 1e6
88}
89
90export function totalInput(usage) {
91  if (!usage) return 0
92  return (
93    (usage.input_tokens || 0) +
94    (usage.cache_read_input_tokens || 0) +
95    (usage.cache_creation_input_tokens || 0)
96  )
97}
98
99// Share of a request's input served from the cache, 0 to 1.
100export function cachedShare(usage) {
101  const total = totalInput(usage)
102  return total > 0 ? (usage.cache_read_input_tokens || 0) / total : 0
103}
104
hooks/fmt.mjs 71 lines
1// SPDX-License-Identifier: MIT
2// From nateherkai/claude-code-mods (33a936f), where every mod carries a copy.
3
4export function tokens(n) {
5  const v = Number(n) || 0
6  if (v >= 1e6) return (v / 1e6).toFixed(v >= 1e7 ? 0 : 1) + 'M'
7  if (v >= 1e3) return (v / 1e3).toFixed(v >= 1e5 ? 0 : 1) + 'k'
8  return String(Math.round(v))
9}
10
11export function usd(n) {
12  const v = Number(n) || 0
13  if (v === 0) return '$0'
14  if (v < 0.01) return '<$0.01'
15  if (v < 10) return '$' + v.toFixed(2)
16  return '$' + v.toFixed(0)
17}
18
19export function duration(ms) {
20  const s = Math.max(0, Math.round((Number(ms) || 0) / 1000))
21  if (s < 60) return s + 's'
22  const m = Math.floor(s / 60)
23  if (m < 60) return m + 'm' + String(s % 60).padStart(2, '0') + 's'
24  const h = Math.floor(m / 60)
25  return h + 'h' + String(m % 60).padStart(2, '0') + 'm'
26}
27
28export function minutes(ms) {
29  const m = Math.round((Number(ms) || 0) / 60000)
30  if (m <= 60) return m + 'm'
31  return Math.floor(m / 60) + 'h' + String(m % 60).padStart(2, '0') + 'm'
32}
33
34export function clock(ts) {
35  const d = new Date(ts)
36  let h = d.getHours()
37  const ampm = h >= 12 ? 'pm' : 'am'
38  h = h % 12 || 12
39  return h + ':' + String(d.getMinutes()).padStart(2, '0') + ampm
40}
41
42const SPARKS = '▁▂▃▄▅▆▇█'
43
44export function sparkline(values, width = 12) {
45  const vals = values.slice(-width)
46  if (vals.length === 0) return ''
47  const max = Math.max(...vals, 1)
48  return vals.map((v) => SPARKS[Math.min(7, Math.floor((v / max) * 7.999))]).join('')
49}
50
51export function bar(fraction, width = 20, full = '█', empty = '░') {
52  const f = Math.max(0, Math.min(1, Number(fraction) || 0))
53  const n = Math.round(f * width)
54  return full.repeat(n) + empty.repeat(width - n)
55}
56
57export function clip(text, max) {
58  const s = String(text ?? '').replace(/\s+/g, ' ').trim()
59  return s.length > max ? s.slice(0, Math.max(0, max - 1)) + '…' : s
60}
61
62export function pad(text, width) {
63  const s = String(text ?? '')
64  return s.length >= width ? s.slice(0, width) : s + ' '.repeat(width - s.length)
65}
66
67export function basename(path) {
68  const parts = String(path || '').split(/[\\/]+/).filter(Boolean)
69  return parts[parts.length - 1] || String(path || '')
70}
71
hooks/i18n.mjs 42 lines
1// SPDX-License-Identifier: MIT
2// The language of what a mod shows a person. The file is the same in every mod
3// (scripts/sync-shared.sh puts the copy there), since a mod is also installed alone,
4// without its neighbours.
5//
6// The mod option `language`: auto, en or uk. auto takes the language of Claude's replies
7// from /config (the language row), so one Claude Code setting sets the language for all
8// mods at once. A language the mods do not have, or none set, gives English.
9
10export const LANGUAGES = ['en', 'uk']
11
12/**
13 * The mods' language for a /config value: "ukrainian", "uk" or the word in Ukrainian give uk, anything else en.
14 * @param {unknown} value
15 * @returns {'en' | 'uk'}
16 */
17export function languageOf(value) {
18  const v = String(value ?? '').trim().toLowerCase()
19  return /^(uk|ua)\b|ukrain|україн|укр/.test(v) ? 'uk' : 'en'
20}
21
22/**
23 * The language for the mod option: en or uk as is, auto (or empty) by the language of Claude's replies.
24 * The mod reads the /config rows itself ($ is not passed to functions from another file).
25 * @param {unknown} option
26 * @param {readonly { key: string, value: unknown }[]} rows the rows of $.config.list(), or [] when they cannot be read
27 * @returns {'en' | 'uk'}
28 */
29export function resolveLanguage(option, rows) {
30  if (option === 'en' || option === 'uk') return option
31  const row = rows.find((r) => r.key === 'language') ?? rows.find((r) => isLanguageKey(r.key))
32  return languageOf(row?.value)
33}
34
35/**
36 * Whether a change to a /config row can change the mods' language.
37 * @param {string} key
38 */
39export function isLanguageKey(key) {
40  return !key.includes('.') && /language/i.test(key)
41}
42
hooks/locales/en.mjs 86 lines
1// SPDX-License-Identifier: MIT
2// Everything cache-meter shows a person, in English. Same keys as uk.mjs.
3
4const plural = (n, one, many) => (n === 1 ? one : many)
5
6export default {
7  minutes: (m) => (m <= 60 ? `${m} min` : `${Math.floor(m / 60)} h ${String(m % 60).padStart(2, '0')} min`),
8  times: (n) => `${n} ${plural(n, 'time', 'times')}`,
9  pings: (n) => `${n} ${plural(n, 'ping', 'pings')}`,
10  ttlSource: { default: 'default', subscription: 'subscription', api: 'API', measured: 'measured', engine: 'from the engine', manual: 'set by hand for this session' },
11  // An amount in tokens, where dollars would not be real: on a subscription or for a model without prices.
12  // Bare, as 200k: at these sizes tokens come in thousands, and no $ means they are not dollars
13  tok: (tokens) => tokens,
14  apiEquivalent: (usd) => `API equivalent ${usd}`,
15  unpricedModel: (model) => `${model}, prices unknown`,
16  limit: { five_hour: '5 h', seven_day: 'week', spend_limit: 'spend' },
17  onOff: (on) => (on ? 'on' : 'off'),
18
19  // The band above the prompt
20  keptNoPings: (until) => `◆ Keeping the cache warm until ${until}`,
21  kept: (until, pings, amount) => `◆ Keeping the cache warm until ${until}, ${pings} ${amount}`,
22  warm: (left) => ` Cache warm for ${left}`,
23  cooling: (left) => `◐ Cache cools in ${left}`,
24  cold: (ago) => `○ Cache went cold ${ago} ago`,
25  nextRewrites: (amount) => `Next message re-caches ≈ ${amount}`,
26  rewriteWouldCost: (amount) => `Re-caching would cost ≈ ${amount}`,
27  rewritten: (times, amount) => `Re-cached ${times}: ${amount}`,
28  limits: (text) => `Limits: ${text}`,
29  keepWarm: 'Keep warm',
30  stopKeeping: 'Stop keeping',
31
32  // The footer label
33  footerKept: 'keeping the cache warm',
34  footerCooling: (left, command) => `cache cools in ${left}, /${command}`,
35  footerCold: (amount) => `cache is cold, re-caching would cost ≈ ${amount}`,
36  footerLimits: (text) => `limits: ${text}`,
37
38  // Toasts
39  keepingUntil: (until, pings, each) => `Keeping the cache warm until ${until}: ≈ ${pings}, each ${each}.`,
40  keepingNoPing: (until) => `Keeping the cache warm until ${until}: it stays warm that long without a ping.`,
41  eachReads: (amount) => `reads ≈ ${amount}`,
42  eachCosts: (usd) => `costs ≈ ${usd}`,
43  notKeeping: (limits) => `Not keeping the cache warm: a plan limit is at 90% or more (${limits}), and each ping would use it further.`,
44  stoppedKeepingNoPings: (why) => `No longer keeping the cache warm: ${why}.`,
45  stoppedKeeping: (why, pings, amount) => `No longer keeping the cache warm: ${why}. That was ${pings} for ${amount}.`,
46  whyTimeUp: 'time is up',
47  whyOff: 'turned off',
48  whyNoReply: 'a ping got no reply, so the cache has most likely gone cold',
49  whyWrote: (tokens) => `a ping wrote ${tokens} tokens instead of reading the cache`,
50  whyLimit: (limits) => `a plan limit reached 90% (${limits})`,
51  coolingAlert: (tokens, left, amount, command) =>
52    `The ${tokens}-token cache cools in ${left}. Re-caching would cost ≈ ${amount}. Type /${command} or press Keep warm to keep it warm.`,
53
54  // The question before a cold send
55  send: 'Send anyway',
56  compact: 'Compact and send',
57  cancel: 'Cancel',
58  runHandoff: (command) => `Run /${command}`,
59  // usd is null where dollars would not be real; the token count already says how much
60  coldQuestion: (ago, tokens, usd) =>
61    `The cache went cold ${ago} ago. Sending now writes ${tokens} tokens of context into the cache again${usd ? ` (≈ ${usd})` : ''}. What now?`,
62  handoffFailed: (command, error) => `Could not run /${command}: ${error}`,
63  droppedForHandoff: (command, amount) =>
64    `Not sent: running /${command}. It re-caches the context once (≈ ${amount}), and the next session starts from a small context.`,
65  droppedToast: 'Not sent. A new session with a short handoff skips this re-caching.',
66  dropped: 'Cancelled: cache-meter stopped a re-cache',
67
68  // /cache and /keepwarm
69  cacheDescription: "Prompt cache status and cache-meter's settings",
70  keepwarmDescription: "Keep this session's cache warm (4 h by default), or /keepwarm off",
71  keepwarmHint: '[hours|off]',
72  statusHead: (tokens, model, ttl, source) => `cache-meter: ${tokens} tokens in context, model ${model}, cache lives ${ttl} min (${source}).`,
73  statusUnknown: 'No request in this session yet, so the cache state is unknown.',
74  statusWarm: (left) => `Cache warm for ≈ ${left}.`,
75  statusCold: (ago, amount) => `The cache went cold ${ago} ago. The next message re-caches it: ≈ ${amount}.`,
76  statusKeptNoPings: (until) => `Keeping the cache warm until ${until}: no ping yet.`,
77  statusKept: (until, pings, amount) => `Keeping the cache warm until ${until}: ${pings} so far, ≈ ${amount}.`,
78  statusLimits: (text) => `Plan limits used: ${text}.`,
79  statusCost: (cost, times) => `Session cost so far: ${cost}. Re-cached ${times}.`,
80  statusRestarts: (times) => `Re-cached ${times}.`,
81  statusGuard: (guard, tokens, alerts) =>
82    `Question before a cold send: ${guard}, for contexts from ${tokens} tokens. Alerts: ${alerts}.`,
83  statusSettings: (cache, keepwarm) =>
84    `Settings: /${cache} ttl 5|60|auto, /${cache} guard on|off, /${cache} big 150k, /${cache} alerts on|off. Keep warm: /${keepwarm} [hours|off].`,
85}
86
hooks/locales/uk.mjs 96 lines
1// SPDX-License-Identifier: MIT
2// Усе, що cache-meter показує людині, українською. Ключі ті самі, що в en.mjs.
3
4function plural(n, one, few, many) {
5  const a = Math.abs(n) % 100
6  const b = a % 10
7  if (a > 10 && a < 20) return many
8  if (b === 1) return one
9  if (b >= 2 && b <= 4) return few
10  return many
11}
12
13// A sentence that ends in an amount in tokens ends with the unit's own dot
14const dot = (text) => (text.endsWith('.') ? text : text + '.')
15
16export default {
17  minutes: (m) => (m <= 60 ? `${m} хв` : `${Math.floor(m / 60)} год ${String(m % 60).padStart(2, '0')} хв`),
18  times: (n) => `${n} ${plural(n, 'раз', 'рази', 'разів')}`,
19  pings: (n) => `${n} ${plural(n, 'пінг', 'пінги', 'пінгів')}`,
20  ttlSource: { default: 'типово', subscription: 'підписка', api: 'API', measured: 'виміряно', engine: 'від рушія', manual: 'задано вручну на цю сесію' },
21  // An amount in tokens, where dollars would not be real: on a subscription or for a model without prices.
22  // Bare, as 200k: at these sizes tokens come in thousands, and no $ means they are not dollars
23  tok: (tokens) => tokens,
24  apiEquivalent: (usd) => `API-еквівалент ${usd}`,
25  unpricedModel: (model) => `${model}, ціни невідомі`,
26  limit: { five_hour: '5 год', seven_day: 'тиждень', spend_limit: 'витрати' },
27  onOff: (on) => (on ? 'увімкнено' : 'вимкнено'),
28
29  // Смуга над полем вводу
30  keptNoPings: (until) => `◆ Тримаю кеш теплим до ${until}`,
31  kept: (until, pings, amount) => `◆ Тримаю кеш теплим до ${until}, ${pings} ${amount}`,
32  warm: (left) => ` Кеш теплий ще ${left}`,
33  cooling: (left) => `◐ Кеш охолоне за ${left}`,
34  cold: (ago) => `○ Кеш охолов ${ago} тому`,
35  nextRewrites: (amount) => `Наступне повідомлення перекешує ≈ ${amount}`,
36  rewriteWouldCost: (amount) => `Перекешування коштуватиме ≈ ${amount}`,
37  rewritten: (times, amount) => `Перекешовано ${times}: ${amount}`,
38  limits: (text) => `Ліміти: ${text}`,
39  keepWarm: 'Тримати теплим',
40  stopKeeping: 'Не тримати',
41
42  // Підпис у футері
43  footerKept: 'кеш тримаю теплим',
44  footerCooling: (left, command) => `кеш охолоне за ${left}, /${command}`,
45  footerCold: (amount) => `кеш охолов, перекешування коштуватиме ≈ ${amount}`,
46  footerLimits: (text) => `ліміти: ${text}`,
47
48  // Тости
49  keepingUntil: (until, pings, each) => dot(`Тримаю кеш теплим до ${until}: ≈ ${pings}, кожен ${each}`),
50  keepingNoPing: (until) => `Тримаю кеш теплим до ${until}: стільки він протримається й без пінгу.`,
51  eachReads: (amount) => `читає ≈ ${amount}`,
52  eachCosts: (usd) => `коштує ≈ ${usd}`,
53  notKeeping: (limits) => `Не тримаю кеш теплим: ліміт плану вже від 90% (${limits}), а кожен пінг витрачав би його далі.`,
54  stoppedKeepingNoPings: (why) => `Більше не тримаю кеш теплим: ${why}.`,
55  stoppedKeeping: (why, pings, amount) => dot(`Більше не тримаю кеш теплим: ${why}. Було ${pings} на ${amount}`),
56  whyTimeUp: 'вийшов час',
57  whyOff: 'вимкнено',
58  whyNoReply: 'пінг лишився без відповіді, тож кеш, схоже, уже охолов',
59  whyWrote: (tokens) => `пінг записав ${tokens} токенів замість того, щоб прочитати кеш`,
60  whyLimit: (limits) => `ліміт плану дійшов до 90% (${limits})`,
61  coolingAlert: (tokens, left, amount, command) =>
62    `Кеш на ${tokens} токенів охолоне за ${left}. Перекешування коштуватиме ≈ ${amount}. Набери /${command} або натисни «Тримати теплим», щоб він не охолов.`,
63
64  // Питання перед відправкою в охололий кеш
65  send: 'Надіслати все одно',
66  compact: 'Стиснути й надіслати',
67  cancel: 'Скасувати',
68  runHandoff: (command) => `Запустити /${command}`,
69  // usd is null where dollars would not be real; the token count already says how much
70  coldQuestion: (ago, tokens, usd) =>
71    `Кеш охолов ${ago} тому. Якщо надіслати зараз, ${tokens} токенів контексту запишуться в кеш заново${usd ? ` (≈ ${usd})` : ''}. Що робимо?`,
72  handoffFailed: (command, error) => `Не вдалося запустити /${command}: ${error}`,
73  droppedForHandoff: (command, amount) =>
74    `Не надіслано: запускаю /${command}. Він один раз перекешує контекст (≈ ${amount}), зате наступна сесія почнеться з малого контексту.`,
75  droppedToast: 'Не надіслано. Нова сесія з коротким хендофом обійдеться без цього перекешування.',
76  dropped: 'Скасовано: cache-meter зупинив перекешування',
77
78  // /cache і /keepwarm
79  cacheDescription: 'Стан кешу промпту і налаштування cache-meter',
80  keepwarmDescription: 'Тримати кеш цієї сесії теплим (типово 4 год) або /keepwarm off',
81  keepwarmHint: '[години|off]',
82  statusHead: (tokens, model, ttl, source) => `cache-meter: у контексті ${tokens} токенів, модель ${model}, кеш живе ${ttl} хв (${source}).`,
83  statusUnknown: 'У цій сесії ще не було запиту, тож стан кешу невідомий.',
84  statusWarm: (left) => `Кеш теплий ще ≈ ${left}.`,
85  statusCold: (ago, amount) => dot(`Кеш охолов ${ago} тому. Наступне повідомлення перекешує його: ≈ ${amount}`),
86  statusKeptNoPings: (until) => `Тримаю кеш теплим до ${until}: пінгів ще не було.`,
87  statusKept: (until, pings, amount) => dot(`Тримаю кеш теплим до ${until}: поки що ${pings}, ≈ ${amount}`),
88  statusLimits: (text) => `Використано лімітів плану: ${text}.`,
89  statusCost: (cost, times) => `Вартість сесії поки: ${cost}. Перекешовано ${times}.`,
90  statusRestarts: (times) => `Перекешовано ${times}.`,
91  statusGuard: (guard, tokens, alerts) =>
92    `Питання перед відправкою в охололий кеш: ${guard}, для контексту від ${tokens} токенів. Попередження: ${alerts}.`,
93  statusSettings: (cache, keepwarm) =>
94    `Налаштування: /${cache} ttl 5|60|auto, /${cache} guard on|off, /${cache} big 150k, /${cache} alerts on|off. Тримати теплим: /${keepwarm} [години|off].`,
95}
96
types/index.d.ts 56 lines
1// SPDX-License-Identifier: MIT
2// What cache-meter publishes for other mods to read. A reader must work
3// without it: when cache-meter is not installed, the value is absent.
4// Fields are only ever added; a reader written for an older version keeps working.
5export type CacheMeterKind = 'unknown' | 'warm' | 'cooling' | 'cold' | 'kept'
6
7export type CacheMeterCache = {
8  kind: CacheMeterKind
9  // Tokens in context, as the last request or measure reported them.
10  ctx: number
11  // ctx is at or above the /cache big threshold (150k by default).
12  isBig: boolean
13  // Dollars to write the whole context into the cache again, at list prices.
14  // A guess for a model the price table does not list: show it only when unit is 'usd'.
15  rewriteUsd: number
16  ttlMin: number
17  model: string
18  // Tokens a re-cache writes: the whole context. Since 0.3.0.
19  rewriteTokens?: number
20  // How cache-meter shows amounts, and how a neighbour in the band should: 'usd' on the API
21  // for a model with known prices, 'tokens' on a subscription or for an unpriced model.
22  // Since 0.3.0; absent from older versions, which always showed dollars.
23  unit?: 'usd' | 'tokens'
24}
25
26// cache-meter's own session record; other mods should read `cache`, not this.
27export type CacheMeterSession = {
28  model: string
29  lastActivity: number
30  ctx: number
31  costUsd: number
32  plan: 'unknown' | 'subscription' | 'api'
33  ttlMin: number
34  ttlSource: 'default' | 'subscription' | 'api' | 'measured' | 'engine'
35  manualTtlMin: number
36  coldRestarts: { at: number; tokens: number; usd: number; gapMin: number }[]
37  working: boolean
38  keepWarm: boolean
39  keepWarmUntil: number
40  pings: number
41  pingTokens: number
42  pingUsd: number
43  rateLimits: { kind: string; percentUsed: number }[]
44  justCompacted: boolean
45  alerted: string
46}
47
48declare module 'claude-code' {
49  interface PluginState {
50    'cache-meter': {
51      cache: CacheMeterCache
52      session: Shaped<CacheMeterSession>
53    }
54  }
55}
56