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…

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.
/cache adds the dollars as the API equivalent. Plan limits appear once one passes 80%.k in the terminal) or /keepwarm [hours|off]: see Keep warm for what it does and what it uses./handoff (if the session has one) or Cancel.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 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.
| Command | What it does | ||
|---|---|---|---|
/cache | The 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 150k | From 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.
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.
| Option | Default | What it does |
|---|---|---|
language | auto | auto follows Claude's response language from /config; en or uk set it |
claude plugin validate .
claude plugin test .hooks/register.mjs 671 lines1// 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}
671hooks/band.mjs 49 lines1// 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}
49hooks/pricing.mjs 104 lines1// 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}
104hooks/fmt.mjs 71 lines1// 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}
71hooks/i18n.mjs 42 lines1// 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}
42hooks/locales/en.mjs 86 lines1// 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}
86hooks/locales/uk.mjs 96 lines1// 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}
96types/index.d.ts 56 lines1// 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