Shows when your prompt cache expires and what rebuilding it would cost. Optional auto-refresh, off by default, keeps it active while you are away and uses a…

A Claude Code mod that shows when your conversation's prompt cache expires and what rebuilding it would cost. It can also refresh the cache for you while you are away: that is off by default, and each refresh uses a little of your plan usage or API credit.
Claude Code reuses the cached part of your conversation at a fraction of the input price. The cache entry lives for five minutes or one hour after it was last used. Come back after that and your next message writes the conversation to the cache again, at 1.25 to 2 times the normal input price. This mod puts that clock where you can see it and does the arithmetic for you.
/cache-panel, or "Details ›" on the status bar): the countdown, the cost of an expiry next to the cost of one refresh, the break-even point, the auto-refresh switch with its limit, a "Refresh now" button and whether recent requests hit the cache./cache-lang sets one. Apart from English and Chinese the translations are machine-made and have not been checked by native speakers: corrections are welcome as issues or pull requests.On the desktop app the ring and the panel are drawn and animated. In the terminal the same information is drawn in text.
| Command | What it does | | :- | :- | | /cache-status | Prints the cache's status, its lifetime and the source of that lifetime, and the cost figures | | /cache-panel | Opens the panel | | /cache-ping [force] | Refreshes the cache now. Refused when the cache has already expired, unless force | | /cache-auto [on\|off] [max] | Shows or changes the auto-refresh settings: on or off, and the maximum refreshes per idle period (default 12) | | /cache-lang [auto\|code] | Sets the display language by its code (for example ja, de, pt-BR), or goes back to matching the conversation |
/cache-status. It prints how long the cache has left, how many tokens it holds, and what rebuilding them would cost next to the cost of one refresh./cache-auto on 12, or open /cache-panel and press "Turn on". The status bar shows the count as 0/1 until a refresh has been seen to work and then up to your limit; the panel estimates how long the cache can be kept. Your next message resets the count./cache-ping, or press "Refresh now" in the panel. The reply says how many tokens were read from the cache and what the refresh cost, and the countdown starts over.A refresh reads the cached prefix at the cache-read price r, plus a few dozen tokens for the request itself. After an expiry the prefix is written again at the cache-write price w, which is w − r more than a hit would have cost. Refreshes are worth sending while their total stays under that difference, which gives at most about (w − r) / r refreshes in a row, and a refresh pays off whenever the chance you come back within its window is above about r / (w − r). For most models that is a few percent. /cache-status shows both numbers for your model and cache size.
Which lifetime applies is read from the environment variables Claude Code itself honors (FORCE_PROMPT_CACHING_5M, CLAUDE_CODE_PROMPT_CACHE_TTL, ENABLE_PROMPT_CACHING_1H). With none of them set, the mod assumes one hour on a subscription inside its plan limits and five minutes elsewhere, and corrects that assumption from what it observes.
Dollar figures are computed at API list prices. On a subscription they are an equivalent, not a charge.
$.model.fork). It goes where your messages already go and nowhere else. It is sent only when you press "Refresh now", run /cache-ping, or have turned auto-refresh on. Refreshes use your plan's usage or your API credit, a little each: the cached prefix at the read price plus a few dozen tokens.FORCE_PROMPT_CACHING_5M, CLAUDE_CODE_PROMPT_CACHE_TTL, ENABLE_PROMPT_CACHING_1H, LANG and LC_ALL; and your plan's rate-limit usage, to tell which lifetime applies. It does not read your settings files, your saved transcript, your files or any credential.en or zh), and a language you set yourself. No message text is stored.The mod registers these hooks. None of them changes what Claude is sent or what it answers.
| Hook | What it does | What it changes | | :- | :- | :- | | session.start | Starts a one-second timer that redraws the countdown and sends a refresh when one is due, and registers the five commands | Nothing: the event is passed on as it came | | turn.step | For the main conversation only, reads the finished step's token usage and counts the letters of its reply | Nothing: every chunk and the result are passed on untouched | | classic.SessionStart | On resume or fork, takes the time since the last response, the context size and the model, so the cache's state shows before the first turn; after compaction, forgets the old cache | Nothing: it adds no context and passes the event on | | classic.PostModelSwitch | Marks the tracked cache as no longer usable when the model changes | Nothing | | command.run | Answers its own five commands, matched by name | Only those commands; no other command reaches it | | ui.render (AbovePrompt) | Adds the one-line status bar under what is already above the prompt | Adds its own line; leaves the rest as it was, and stands aside while a survey is shown | | ui.render (Pane, id cache) | Draws the mod's own panel | Only its own panel |
claude plugin marketplace add olddonkey/cache-refresher
claude plugin install cache-refresher@olddonkey
To try it for one session from a checkout:
claude --plugin-dir .
claude --version is 2.1.287 or later and that claude plugin list shows the plugin enabled./cache-status shows the lifetime in force and where it came from (environment, observed or assumed). An assumed lifetime corrects itself the first time a gap longer than five minutes ends in a hit or a miss./cache-ping force sends the request anyway, which rebuilds the cache now./cache-lang followed by a language code sets one (/cache-lang xx lists the codes); /cache-lang auto goes back to matching the conversation. Closely related languages can be mistaken for one another on a short reply.Questions, bugs and security concerns: open an issue at https://github.com/olddonkey/cache-refresher/issues.
claude plugin validate .
claude plugin test
hooks/register.tsx wires the hooks; hooks/model.ts and hooks/economics.ts are pure logic; hooks/views.ts draws the status bar and the panel; hooks/messages.ts holds the two languages.
Each language is one file under hooks/locales/ plus an entry in the registry in hooks/messages.ts; tests/locales.test.ts holds every catalog to the English shape and to the room each panel line has.
一个 Claude Code mod:显示当前对话的提示缓存还有多久过期、过期后重建大约要花多少钱。还可以在你离开时自动续期:该功能默认关闭,每次续期会消耗少量套餐额度或产生 API 费用。
/cache-panel 或状态条上的“详情 ›”):倒计时、过期重建与续期一次的成本对比、盈亏平衡点、自动续期开关与次数上限、“立即续期”按钮和最近几次请求的缓存命中情况。/cache-lang 加语言代码手动设置。除中英文外的译文为机器翻译,未经母语者校对,欢迎指正。需要 Claude Code v2.1.287 及以上。
MIT
hooks/register.tsx 589 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ModelUsage, Register } from 'claude-code'
3
4import type { Snapshot, Ttl } from '../types'
5import { detect, follow } from './detect'
6import { PING_MARGIN_MS, TTL_MS, WRITE_MULTIPLIER, fmtRemaining, fmtTokens, fmtUsd, priceOf } from './economics'
7import { LANGS, MESSAGES, langFrom } from './messages'
8import type { Lang, LangSource } from './messages'
9import {
10 COLD_MODEL,
11 COLD_PING,
12 HIT,
13 LEAD_MS,
14 budgetOf,
15 coldWords,
16 extensionEvidence,
17 isTracked,
18 remainingOf,
19 verdictOf,
20} from './model'
21import type { Extension, Observation, Verdict, View } from './model'
22import { autoLines, report } from './report'
23import { bandView, paneView } from './views'
24import type { Motion } from './views'
25
26const DEFAULT_CAP = 12
27const MAX_CAP = 200
28
29const PANE = 'cache'
30
31const snap = atom({ plugin: 'cache-refresher', key: 'snapshot' } as const, null)
32const auto = atom({ plugin: 'cache-refresher', key: 'auto' } as const, {
33 isOn: false,
34 cap: DEFAULT_CAP,
35})
36
37const PING_PROMPT =
38 'Prompt-cache keep-alive from the cache-refresher plugin. Reply with exactly: ok'
39
40const OBSERVATIONS = 'observations'
41const MAX_OBSERVATIONS = 100
42const EXTENDS = 'pingExtends'
43const LANG = 'lang'
44const HEARD = 'langHeard'
45
46type TtlChoice = Pick<Snapshot, 'ttl' | 'ttlSource'>
47
48type PingResult = { text: string; verdict: Verdict | null }
49
50// What the environment pins the TTL to; undefined until first read.
51let configured: TtlChoice | null | undefined
52// The store's record of whether pings extend the cache; undefined until first read.
53let extension: Extension | undefined
54// The store's log of touches; undefined until first read.
55let observed: Observation[] | undefined
56// The language in force and what decided it; undefined until first read.
57let spoken: { lang: Lang; source: LangSource } | undefined
58// The languages of Claude's recent replies, and the store's last followed language until first read.
59let heard: Record<string, number> = {}
60let heardLang: Lang | null | undefined
61let isPinging = false
62// Main-thread requests under way: each touched the cache as it started.
63let inFlight = 0
64// What the panel's last button press came to, shown under its buttons.
65let lastAction = ''
66// The band's words as last drawn: the countdown redraws only when they change.
67let lastHead = ''
68// The language the open panel's title was last written in.
69let titledIn: Lang | undefined
70// When the panel was last opened: its entrance plays from that moment.
71let openedAt: number | undefined
72// The touch that last refilled the cache, and the share of its lifetime left before it: the ring sweeps back from there.
73let refill: { at: number; from: number } | undefined
74// A survey unmounts the band's frame; its next appearance needs a fresh timeline.
75let bandHidden = false
76let bandEpoch = 0
77// Old frames can leave after their replacements have had time to load.
78const bufferEnds = new Set<number>()
79
80function isOn(value: string | undefined): boolean {
81 return value === '1' || value === 'true'
82}
83
84// The person's own choice, then the language Claude last replied in, then the locale.
85async function speaking($: EngineInterface): Promise<{ lang: Lang; source: LangSource }> {
86 if (heardLang === undefined) heardLang = langFrom(await $.store.get(HEARD))
87 if (spoken === undefined) {
88 const pinned = langFrom(await $.store.get(LANG))
89 const fromTalk = pinned === null ? heardLang : null
90 const fromLocale =
91 pinned === null && fromTalk === null
92 ? (langFrom(await $.env.get('LC_ALL')) ?? langFrom(await $.env.get('LANG')))
93 : null
94 spoken =
95 pinned !== null
96 ? { lang: pinned, source: 'pinned' }
97 : fromTalk !== null
98 ? { lang: fromTalk, source: 'conversation' }
99 : fromLocale !== null
100 ? { lang: fromLocale, source: 'locale' }
101 : { lang: 'en', source: 'default' }
102 }
103
104 return spoken
105}
106
107/**
108 * Follows the language of a reply as it passes: its letters are counted, and only the language they say is kept.
109 * Code says nothing about a language, and a reply with too few letters says too little.
110 */
111async function hear($: EngineInterface, answer: string): Promise<void> {
112 const found = detect(answer)
113 if (found === null) return
114 const now = await speaking($)
115 const followed = follow(heard, found, heardLang ?? now.lang)
116 heard = followed.tally
117 const { lang } = followed
118 if (lang !== heardLang) {
119 heardLang = lang
120 await $.store.set(HEARD, lang)
121 }
122 if (now.source === 'pinned' || (now.source === 'conversation' && now.lang === lang)) return
123 spoken = { lang, source: 'conversation' }
124 // The band's words change with the language even while the clock stands still.
125 lastHead = ''
126 $.ui.invalidate('ui.render')
127}
128
129async function langOf($: EngineInterface): Promise<Lang> {
130 return (await speaking($)).lang
131}
132
133/** Pins a language, or with `auto` goes back to detecting it; false for a word that names neither. */
134async function setLang($: EngineInterface, word: string): Promise<boolean> {
135 if (word === 'auto') {
136 await $.store.delete(LANG)
137 spoken = undefined
138 } else {
139 const lang = langFrom(word)
140 if (lang === null) return false
141 await $.store.set(LANG, lang)
142 spoken = { lang, source: 'pinned' }
143 }
144 // The band's words change with the language even while the clock stands still.
145 lastHead = ''
146 $.ui.invalidate('ui.render')
147
148 return true
149}
150
151// What the environment pins the main conversation's TTL to, in Claude Code's own order of precedence.
152async function configuredTtl($: EngineInterface): Promise<TtlChoice | null> {
153 if (isOn(await $.env.get('FORCE_PROMPT_CACHING_5M'))) {
154 return { ttl: '5m', ttlSource: 'env' }
155 }
156 const fromEnv = await $.env.get('CLAUDE_CODE_PROMPT_CACHE_TTL')
157 if (fromEnv === '5m' || fromEnv === '1h') return { ttl: fromEnv, ttlSource: 'env' }
158 if (isOn(await $.env.get('ENABLE_PROMPT_CACHING_1H'))) {
159 return { ttl: '1h', ttlSource: 'env' }
160 }
161
162 return null
163}
164
165async function resolveTtl($: EngineInterface, previous: Snapshot | null): Promise<TtlChoice> {
166 if (configured === undefined) configured = await configuredTtl($)
167 if (configured !== null) return configured
168 if (previous?.ttlSource === 'observed') return { ttl: previous.ttl, ttlSource: 'observed' }
169 const { rateLimits } = await $.session.usage()
170 const plan = rateLimits.filter(one => one.kind === 'five_hour' || one.kind === 'seven_day')
171 // A subscription inside its plan's usage gets the hour; over it, or off a subscription, five minutes.
172 const isWithinPlan = plan.length > 0 && plan.every(one => one.percentUsed < 100)
173
174 return { ttl: isWithinPlan ? '1h' : '5m', ttlSource: 'assumed' }
175}
176
177async function observations($: EngineInterface): Promise<Observation[]> {
178 if (observed === undefined) {
179 const held = await $.store.get(OBSERVATIONS)
180 observed = Array.isArray(held) ? held : []
181 }
182
183 return observed
184}
185
186async function remember($: EngineInterface, observation: Observation): Promise<void> {
187 observed = [...(await observations($)), observation].slice(-MAX_OBSERVATIONS)
188 await $.store.set(OBSERVATIONS, observed)
189}
190
191async function knownExtension($: EngineInterface): Promise<Extension> {
192 if (extension === undefined) {
193 const held = await $.store.get(EXTENDS)
194 extension = typeof held === 'object' && held !== null ? (held as Extension) : {}
195 }
196
197 return extension
198}
199
200async function learnExtension($: EngineInterface, ttl: Ttl, doesExtend: boolean): Promise<void> {
201 extension = { ...(await knownExtension($)), [ttl]: doesExtend }
202 await $.store.set(EXTENDS, extension)
203}
204
205async function viewOf($: EngineInterface): Promise<View> {
206 return {
207 held: await read($, snap),
208 now: await $.clock.now(),
209 policy: await read($, auto),
210 known: await knownExtension($),
211 observations: await observations($),
212 }
213}
214
215/** What the drawings should be moving at this moment. */
216async function motionOf($: EngineInterface): Promise<Motion> {
217 const now = await $.clock.now()
218
219 return {
220 now,
221 openedAt: openedAt ?? null,
222 refill: refill ?? null,
223 isPinging,
224 bandEpoch,
225 onBufferEnd: at => { bufferEnds.add(at) },
226 }
227}
228
229/** Opens the panel, noting when. */
230async function openPane($: EngineInterface, title: string): Promise<void> {
231 openedAt = await $.clock.now()
232 await $.ui.open({ id: PANE, title, focus: true, closeOnEscape: true })
233}
234
235/** The share of its lifetime a cache had left at a moment, for the ring to sweep back from. */
236function shareLeft(held: Snapshot | null, at: number): number {
237 return isTracked(held) ? Math.max(0, remainingOf(held, at)) / TTL_MS[held.ttl] : 0
238}
239
240/** Takes one main-thread response's usage as the cache's new state. */
241async function recordStep(
242 $: EngineInterface,
243 startedAt: number,
244 index: number,
245 usage: ModelUsage & { model: string },
246): Promise<void> {
247 const previous = await read($, snap)
248 const served = usage.cache_read_input_tokens
249 let { ttl, ttlSource } = await resolveTtl($, previous)
250
251 if (isTracked(previous) && previous.coldReason === null && previous.model === usage.model) {
252 const gapMs = startedAt - previous.touchedAt
253 const verdict = verdictOf(served, previous.cachedTokens)
254 const evidence = extensionEvidence(previous, startedAt, verdict)
255 if (evidence !== undefined) await learnExtension($, previous.ttl, evidence)
256 // After a ping a miss may be the ping's doing, so only a turn's own gap speaks for the TTL.
257 const canInfer = ttlSource === 'assumed' || ttlSource === 'observed'
258 if (canInfer && verdict === 'hit' && gapMs > TTL_MS['5m'] + 30_000) {
259 ttl = '1h'
260 ttlSource = 'observed'
261 } else if (
262 canInfer &&
263 previous.touchedBy !== 'ping' &&
264 verdict === 'miss' &&
265 gapMs > TTL_MS['5m'] &&
266 gapMs < TTL_MS['1h']
267 ) {
268 ttl = '5m'
269 ttlSource = 'observed'
270 }
271 if (index === 0 || verdict !== 'hit') {
272 await remember($, {
273 at: startedAt,
274 kind: 'turn',
275 gapMs,
276 prevBy: previous.touchedBy,
277 ttl: previous.ttl,
278 cachedBefore: previous.cachedTokens,
279 read: served,
280 created: usage.cache_creation_input_tokens,
281 input: usage.input_tokens,
282 output: usage.output_tokens,
283 model: usage.model,
284 verdict,
285 })
286 }
287 }
288
289 await update($, snap, () => ({
290 touchedAt: startedAt,
291 touchedBy: 'turn' as const,
292 turnAt: startedAt,
293 pingsSinceTurn: 0,
294 cachedTokens: served + usage.cache_creation_input_tokens,
295 model: usage.model,
296 ttl,
297 ttlSource,
298 coldReason: null,
299 lastPing: previous?.lastPing ?? null,
300 }))
301 refill = { at: await $.clock.now(), from: shareLeft(previous, startedAt) }
302 $.ui.invalidate('ui.render')
303}
304
305/** Sends one keep-alive over the main conversation's prefix and says what it did. */
306async function ping($: EngineInterface, isForced: boolean): Promise<PingResult> {
307 const m = MESSAGES[await langOf($)]
308 const held = await read($, snap)
309 if (!isTracked(held)) return { text: m.pingNothing, verdict: null }
310 if (isPinging) return { text: m.pingBusy, verdict: null }
311 const startedAt = await $.clock.now()
312 if (remainingOf(held, startedAt) <= PING_MARGIN_MS && !isForced) {
313 return { text: m.pingCold(coldWords(m, held, startedAt), fmtTokens(held.cachedTokens)), verdict: null }
314 }
315
316 isPinging = true
317 // The ring shows the ping going out, and again what came of it.
318 $.ui.invalidate('ui.render')
319 try {
320 const reply = await $.model.fork({ prompt: PING_PROMPT })
321 if (!reply.isAnswered && reply.reason === 'nothing-to-fork') return { text: m.pingNoFork, verdict: null }
322 if (!reply.isAnswered && reply.reason === 'api-error') {
323 return { text: m.pingApiError(reply.error, reply.status), verdict: null }
324 }
325
326 const { usage } = reply
327 const lastPing = {
328 at: startedAt,
329 read: usage.cache_read_input_tokens,
330 created: usage.cache_creation_input_tokens,
331 input: usage.input_tokens,
332 output: usage.output_tokens,
333 }
334 if (lastPing.read + lastPing.created + lastPing.input === 0) return { text: m.pingCut, verdict: null }
335 const verdict = verdictOf(lastPing.read, held.cachedTokens)
336 const hasRewritten = verdict !== 'hit' && lastPing.created >= held.cachedTokens * HIT
337 const evidence = extensionEvidence(held, startedAt, verdict)
338 if (evidence !== undefined) await learnExtension($, held.ttl, evidence)
339 await remember($, {
340 at: startedAt,
341 kind: 'ping',
342 gapMs: startedAt - held.touchedAt,
343 prevBy: held.touchedBy,
344 ttl: held.ttl,
345 cachedBefore: held.cachedTokens,
346 read: lastPing.read,
347 created: lastPing.created,
348 input: lastPing.input,
349 output: lastPing.output,
350 model: held.model,
351 verdict,
352 })
353 await update($, snap, now => {
354 if (now === null) return now
355 const pingsSinceTurn = now.pingsSinceTurn + 1
356 if (verdict === 'hit' || hasRewritten) {
357 return { ...now, touchedAt: startedAt, touchedBy: 'ping' as const, pingsSinceTurn, coldReason: null, lastPing }
358 }
359
360 return { ...now, pingsSinceTurn, coldReason: COLD_PING, lastPing }
361 })
362 if (verdict === 'hit' || hasRewritten) refill = { at: await $.clock.now(), from: shareLeft(held, startedAt) }
363
364 const counts = m.counts({
365 read: fmtTokens(lastPing.read),
366 wrote: fmtTokens(lastPing.created),
367 input: lastPing.input,
368 output: lastPing.output,
369 })
370 const price = priceOf(held.model)
371 const cost = price
372 ? m.atListPrice(
373 fmtUsd(
374 (lastPing.read * price.read * price.input +
375 lastPing.created * WRITE_MULTIPLIER[held.ttl] * price.input +
376 lastPing.input * price.input +
377 lastPing.output * price.output) /
378 1e6,
379 ),
380 )
381 : ''
382 if (verdict === 'hit') return { text: m.pingHit(counts, cost, held.ttl), verdict }
383
384 return { text: hasRewritten ? m.pingRewrote(counts, cost) : m.pingMissed(counts, cost), verdict }
385 } finally {
386 isPinging = false
387 $.ui.invalidate('ui.render')
388 }
389}
390
391export const register: Register = on => {
392 on('session.start', async ($, e, next) => {
393 $.clock.every(1000, async () => {
394 const lang = await langOf($)
395 const m = MESSAGES[lang]
396 // An open panel keeps the title it was opened with, so it is renamed when the language moves on.
397 if (lang !== titledIn) {
398 titledIn = lang
399 const panes = await $.ui.panes()
400 if (panes.some(pane => pane.id === PANE && pane.title !== m.paneTitle)) {
401 await $.ui.open({ id: PANE, title: m.paneTitle })
402 }
403 }
404 const held = await read($, snap)
405 const now = await $.clock.now()
406 let bufferEnded = false
407 for (const at of bufferEnds) {
408 if (now >= at) {
409 bufferEnds.delete(at)
410 bufferEnded = true
411 }
412 }
413 // The countdown: redraw only when its words change.
414 const head = held === null ? '' : remainingOf(held, now) > 0 ? fmtRemaining(remainingOf(held, now)) : 'cold'
415 if (head !== lastHead || bufferEnded) {
416 lastHead = head
417 $.ui.invalidate('ui.render')
418 }
419
420 // The keep-alive. A request under way has already touched the cache.
421 if (held === null || isPinging || inFlight > 0) return
422 const policy = await read($, auto)
423 if (!policy.isOn) return
424 const remaining = remainingOf(held, now)
425 if (remaining <= PING_MARGIN_MS || remaining > LEAD_MS[held.ttl]) return
426 const budget = budgetOf(held, policy.cap, await knownExtension($))
427 if (held.pingsSinceTurn >= budget) return
428 const sent = await ping($, false)
429 $.ui.log(m.autoLog(held.pingsSinceTurn + 1, budget, sent.text))
430 if (sent.verdict !== 'hit') $.ui.toast(m.autoMissToast)
431 })
432 const m = MESSAGES[await langOf($)]
433 await $.command.register({ name: 'cache-status', description: m.cmdStatus, immediate: true })
434 await $.command.register({ name: 'cache-panel', description: m.cmdPanel, immediate: true })
435 await $.command.register({ name: 'cache-ping', description: m.cmdPing, argumentHint: '[force]' })
436 await $.command.register({
437 name: 'cache-auto',
438 description: m.cmdAuto,
439 argumentHint: m.cmdAutoHint,
440 immediate: true,
441 })
442 await $.command.register({
443 name: 'cache-lang',
444 description: m.cmdLang,
445 argumentHint: m.cmdLangHint,
446 immediate: true,
447 })
448
449 return next(e)
450 })
451
452 on('turn.step', async function* ($, e, next) {
453 // Subagents keep caches of their own; only the main conversation is tracked.
454 const isMain = e.agentId === undefined
455 const startedAt = await $.clock.now()
456 if (isMain) inFlight += 1
457 try {
458 const result = yield* next(e)
459 if (isMain && !isPinging && result.usage !== null) {
460 await hear($, result.answer)
461 await recordStep($, startedAt, e.index, result.usage)
462 }
463
464 return result
465 } finally {
466 if (isMain) inFlight -= 1
467 }
468 })
469
470 on('classic.SessionStart', async ($, e, next) => {
471 if (e.source === 'compact') {
472 // The compacted conversation shares no prefix with what was cached.
473 await update($, snap, () => null)
474 } else if (
475 (e.source === 'resume' || e.source === 'fork') &&
476 e.seconds_since_last_response !== undefined &&
477 e.context_tokens !== undefined
478 ) {
479 const now = await $.clock.now()
480 const choice = await resolveTtl($, null)
481 const touchedAt = now - e.seconds_since_last_response * 1000
482 const cachedTokens = e.context_tokens
483 const model = e.model ?? 'unknown'
484 await update($, snap, () => ({
485 touchedAt,
486 touchedBy: 'resume' as const,
487 turnAt: touchedAt,
488 pingsSinceTurn: 0,
489 cachedTokens,
490 model,
491 ...choice,
492 coldReason: null,
493 lastPing: null,
494 }))
495 }
496
497 return next(e)
498 })
499
500 on('classic.PostModelSwitch', async ($, e, next) => {
501 if (e.from_model !== e.to_model) {
502 const reason = COLD_MODEL + e.to_model
503 await update($, snap, now => (now === null ? now : { ...now, coldReason: reason }))
504 }
505
506 return next(e)
507 })
508
509 on('command.run', { command: 'cache-status' }, async $ => {
510 return { text: report(MESSAGES[await langOf($)], await viewOf($)) }
511 })
512
513 on('command.run', { command: 'cache-panel' }, async $ => {
514 await openPane($, MESSAGES[await langOf($)].paneTitle)
515
516 return {}
517 })
518
519 on('command.run', { command: 'cache-ping' }, async ($, e) => {
520 return { text: (await ping($, e.args.trim() === 'force')).text }
521 })
522
523 on('command.run', { command: 'cache-auto' }, async ($, e) => {
524 const m = MESSAGES[await langOf($)]
525 const words = e.args.trim().split(/\s+/).filter(Boolean)
526 const wanted = words.find(word => word === 'on' || word === 'off')
527 const cap = words.map(Number).find(n => Number.isInteger(n) && n > 0 && n <= MAX_CAP)
528 if (words.length > 0 && wanted === undefined && cap === undefined) return { text: m.autoUsage(MAX_CAP) }
529 if (words.length > 0) {
530 await update($, auto, now => ({
531 isOn: wanted === undefined ? true : wanted === 'on',
532 cap: cap ?? now.cap,
533 }))
534 }
535
536 return { text: [m.autoHeader, ...autoLines(m, await viewOf($)), m.autoNote].join('\n') }
537 })
538
539 on('command.run', { command: 'cache-lang' }, async ($, e) => {
540 const word = e.args.trim().toLowerCase()
541 if (word !== '' && !(await setLang($, word))) return { text: MESSAGES[await langOf($)].langUsage(LANGS.join(', ')) }
542 const now = await speaking($)
543 const m = MESSAGES[now.lang]
544
545 return { text: m.langNow(m.name, now.source) }
546 })
547
548 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
549 const view = await viewOf($)
550 const { held } = view
551 if (e.props.hasSurvey) {
552 bandHidden = true
553
554 return next(e)
555 }
556 if (!isTracked(held)) return next(e)
557 if (bandHidden) {
558 bandHidden = false
559 bandEpoch += 1
560 }
561 const m = MESSAGES[await langOf($)]
562 const beneath = await next(e)
563 const els = $.ui.resolve(e)
564 const band = bandView(els, e.surface, m, { ...view, held }, await motionOf($), () => {
565 void openPane($, m.paneTitle)
566 })
567
568 return els.Box({ flexDirection: 'column', children: [beneath, band] })
569 })
570
571 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
572 return paneView($.ui.resolve(e), e.surface, MESSAGES[await langOf($)], await viewOf($), lastAction, {
573 onToggle: () => {
574 void update($, auto, held => ({ ...held, isOn: !held.isOn }))
575 },
576 onCapDown: () => {
577 void update($, auto, held => ({ ...held, cap: Math.max(1, held.cap - 1) }))
578 },
579 onCapUp: () => {
580 void update($, auto, held => ({ ...held, cap: Math.min(MAX_CAP, held.cap + 1) }))
581 },
582 onPing: async () => {
583 lastAction = (await ping($, false)).text
584 $.ui.invalidate('ui.render')
585 },
586 }, e.props.bodyColumns, await motionOf($))
587 })
588}
589hooks/detect.ts 80 lines1import { LANGS, LOCALES } from './messages'
2import type { Lang, Locale, Script } from './messages'
3
4const SCRIPTS: Record<Script, RegExp> = {
5 latin: /\p{Script=Latin}/u,
6 han: /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u,
7 hangul: /\p{Script=Hangul}/u,
8 cyrillic: /\p{Script=Cyrillic}/u,
9 devanagari: /\p{Script=Devanagari}/u,
10 bengali: /\p{Script=Bengali}/u,
11 gujarati: /\p{Script=Gujarati}/u,
12 kannada: /\p{Script=Kannada}/u,
13 malayalam: /\p{Script=Malayalam}/u,
14 tamil: /\p{Script=Tamil}/u,
15 telugu: /\p{Script=Telugu}/u,
16 thai: /\p{Script=Thai}/u,
17 ethiopic: /\p{Script=Ethiopic}/u,
18}
19
20type Found = { lang: Lang; weight: number }
21
22/** File names can outnumber the letters of the language a reply is written in. */
23export function detect(answer: string): Found | null {
24 const prose = answer.replace(/(`{3,}|~{3,})[\s\S]*?\1/g, ' ').replace(/(`+)[^`\n]*?\1/g, ' ').slice(0, 600)
25 const counts: Record<string, number> = {}
26 const scripts = Object.entries(SCRIPTS) as [Script, RegExp][]
27 const letters: Partial<Record<Script, number>> = {}
28 let weight = 0
29 for (const char of prose) {
30 if (!/\p{L}/u.test(char)) continue
31 weight += 1
32 const script = scripts.find(([, pattern]) => pattern.test(char))?.[0]
33 if (script) letters[script] = (letters[script] ?? 0) + 1
34 }
35 if (weight < 40) return null
36 let script: Script | undefined
37 let largest = 0
38 for (const [group, count] of Object.entries(letters) as [Script, number][]) {
39 if (group !== 'latin' && count >= 20 && count / weight > 0.15 && count > largest) {
40 script = group
41 largest = count
42 }
43 }
44 if (script === undefined && (letters.latin ?? 0) >= 40) script = 'latin'
45 if (script === undefined) return null
46 const locales: readonly Locale[] = LOCALES.filter(locale => locale.script === script && !('variantOf' in locale))
47 if (locales.length === 1 && script !== 'latin') return { lang: locales[0]!.code as Lang, weight }
48
49 // Keep vowel signs with their letters; match whole words rather than parts of longer words.
50 const words = prose.toLowerCase().match(/\p{L}[\p{L}\p{M}]*/gu) ?? []
51 for (const locale of locales) {
52 const marks = locale.marks
53 ? prose.match(new RegExp(locale.marks.source, locale.marks.flags.replace(/g/g, '') + 'g'))?.length ?? 0
54 : 0
55 counts[locale.code] = marks + words.filter(word => locale.words?.includes(word)).length
56 }
57 const ranked = [...locales].sort((a, b) => counts[b.code]! - counts[a.code]!)
58 const top = ranked[0]
59 const hits = top ? counts[top.code]! : 0
60 const runner = ranked[1] ? counts[ranked[1].code]! : 0
61 const lang = top && hits >= 3 && hits >= 1.5 * runner ? top.code : locales.find(locale => locale.default)?.code
62
63 return lang ? { lang: lang as Lang, weight } : null
64}
65
66/** English-heavy replies need a sustained lead before turning a conversation back to English. */
67export function follow(tally: Record<string, number>, found: Found, current: Lang | undefined): { tally: Record<string, number>; lang: Lang } {
68 const next = Object.fromEntries(Object.entries(tally).map(([code, weight]) => [code, weight / 2]))
69 const locale: Locale | undefined = LOCALES.find(locale => locale.code === current)
70 const counted = locale?.variantOf === found.lang ? current! : found.lang
71 next[counted] = (next[counted] ?? 0) + found.weight
72 let lang = current ?? found.lang
73 for (const code of LANGS) {
74 if ((next[code] ?? 0) > (next[lang] ?? 0)) lang = code
75 }
76 if (lang === 'en' && current !== undefined && current !== 'en' && next.en! < 3 * (next[current] ?? 0)) lang = current
77
78 return { tally: next, lang }
79}
80hooks/economics.ts 119 lines1import type { Ttl } from '../types'
2
3export const TTL_MS: Record<Ttl, number> = { '5m': 5 * 60_000, '1h': 60 * 60_000 }
4
5// Cache writes cost this multiple of the base input price.
6export const WRITE_MULTIPLIER: Record<Ttl, number> = { '5m': 1.25, '1h': 2 }
7
8// A ping sent this close to expiry may start after the entry lapsed.
9export const PING_MARGIN_MS = 5_000
10
11// What a ping is assumed to add on top of the cache read until one has been measured.
12export const DEFAULT_PING_OVERHEAD = { input: 40, output: 60 }
13
14/** List prices in USD per million tokens; `read` is the cache-read multiplier on `input`. */
15export type Price = { input: number; output: number; read: number }
16
17// platform.claude.com/docs/en/about-claude/pricing, read 2026-10-03. First match wins.
18const PRICES: readonly (readonly [RegExp, Price])[] = [
19 [/(fable|mythos)-5-1/, { input: 10, output: 50, read: 0.025 }],
20 [/(fable|mythos)-5/, { input: 10, output: 50, read: 0.1 }],
21 [/opus-5-5/, { input: 4, output: 20, read: 0.05 }],
22 [/opus-5/, { input: 5, output: 25, read: 0.1 }],
23 [/opus-4-[5-8]/, { input: 5, output: 25, read: 0.1 }],
24 [/opus-4/, { input: 15, output: 75, read: 0.1 }],
25 [/sonnet-5/, { input: 2, output: 10, read: 0.1 }],
26 [/sonnet-4/, { input: 3, output: 15, read: 0.1 }],
27 [/haiku-4-5/, { input: 1, output: 5, read: 0.1 }],
28 [/haiku-3-5/, { input: 0.8, output: 4, read: 0.1 }],
29]
30
31export function priceOf(model: string): Price | null {
32 for (const [pattern, price] of PRICES) {
33 if (pattern.test(model)) return price
34 }
35
36 return null
37}
38
39export type Economics = {
40 /** One keep-alive: the cache read plus the ping's own tokens. */
41 pingUsd: number
42 /** Re-writing the whole prefix after a lapse. */
43 rewriteUsd: number
44 /** What a lapse costs over a hit: the rewrite less the read it replaces. */
45 lapseUsd: number
46 /** The most pings that still cost less than one lapse. */
47 maxPings: number
48 /** Keep pinging while the chance of a return within the next window is above this. */
49 hazardThreshold: number
50}
51
52export function economics(
53 price: Price,
54 ttl: Ttl,
55 cachedTokens: number,
56 overhead: { input: number; output: number } = DEFAULT_PING_OVERHEAD,
57): Economics {
58 const perToken = price.input / 1e6
59 const readUsd = cachedTokens * perToken * price.read
60 const rewriteUsd = cachedTokens * perToken * WRITE_MULTIPLIER[ttl]
61 const pingUsd = readUsd + overhead.input * perToken + (overhead.output * price.output) / 1e6
62 const lapseUsd = rewriteUsd - readUsd
63 const ratio = pingUsd > 0 ? lapseUsd / pingUsd : 0
64
65 return {
66 pingUsd,
67 rewriteUsd,
68 lapseUsd,
69 // The largest n with n * pingUsd strictly under lapseUsd.
70 maxPings: Math.max(0, Math.ceil(ratio - 1e-9) - 1),
71 hazardThreshold: lapseUsd > 0 ? pingUsd / lapseUsd : 1,
72 }
73}
74
75export function fmtTokens(tokens: number): string {
76 if (tokens >= 1e6) return `${(tokens / 1e6).toFixed(2)}M`
77 if (tokens >= 1000) return `${Math.round(tokens / 1000)}k`
78
79 return String(tokens)
80}
81
82export function fmtUsd(usd: number): string {
83 if (usd >= 1) return `$${usd.toFixed(2)}`
84 if (usd >= 0.01) return `$${usd.toFixed(3)}`
85
86 return `$${usd.toFixed(4)}`
87}
88
89/** The countdown as drawn: minutes far out, five-second steps closer in, seconds in the last minute. */
90export function fmtRemaining(ms: number): string {
91 if (ms <= 0) return 'expired'
92 const seconds = Math.ceil(ms / 1000)
93 if (seconds >= 600) return `${Math.ceil(seconds / 60)}m`
94 if (seconds >= 60) {
95 const stepped = Math.ceil(seconds / 5) * 5
96
97 return `${Math.floor(stepped / 60)}:${String(stepped % 60).padStart(2, '0')}`
98 }
99
100 return `${seconds}s`
101}
102
103export function fmtGap(ms: number): string {
104 const seconds = Math.round(ms / 1000)
105 if (seconds < 90) return `${seconds}s`
106 if (seconds < 5400) return `${(seconds / 60).toFixed(1)}m`
107
108 return `${(seconds / 3600).toFixed(1)}h`
109}
110
111/** A stretch of time for a sentence: whole minutes under an hour, then hours. */
112export function fmtSpan(ms: number): string {
113 const minutes = Math.round(ms / 60_000)
114 if (minutes < 60) return `${minutes}m`
115 const hours = minutes / 60
116
117 return hours < 10 ? `${hours.toFixed(1)}h` : `${Math.round(hours)}h`
118}
119hooks/messages.ts 529 lines1import { en } from './locales/en'
2import type { Messages } from './locales/en'
3import { zh } from './locales/zh'
4import { zhHant } from './locales/zh-Hant'
5import { ja } from './locales/ja'
6import { ko } from './locales/ko'
7import { es } from './locales/es'
8import { es419 } from './locales/es-419'
9import { fr } from './locales/fr'
10import { de } from './locales/de'
11import { ptBR } from './locales/pt-BR'
12import { it } from './locales/it'
13import { ru } from './locales/ru'
14import { uk } from './locales/uk'
15import { nl } from './locales/nl'
16import { sv } from './locales/sv'
17import { da } from './locales/da'
18import { nb } from './locales/nb'
19import { fi } from './locales/fi'
20import { tr } from './locales/tr'
21import { id } from './locales/id'
22import { vi } from './locales/vi'
23import { th } from './locales/th'
24import { fil } from './locales/fil'
25import { hi } from './locales/hi'
26import { bn } from './locales/bn'
27import { mr } from './locales/mr'
28import { gu } from './locales/gu'
29import { ta } from './locales/ta'
30import { te } from './locales/te'
31import { kn } from './locales/kn'
32import { ml } from './locales/ml'
33import { bho } from './locales/bho'
34import { sw } from './locales/sw'
35import { am } from './locales/am'
36import { ha } from './locales/ha'
37import { ig } from './locales/ig'
38import { yo } from './locales/yo'
39import { ny } from './locales/ny'
40import { om } from './locales/om'
41import { rn } from './locales/rn'
42import { rw } from './locales/rw'
43import { so } from './locales/so'
44import { wo } from './locales/wo'
45
46export type { Messages } from './locales/en'
47
48/** What decided the language: the person's pin, the language Claude replies in, the locale, or nothing yet. */
49export type LangSource = 'pinned' | 'conversation' | 'locale' | 'default'
50
51export type Script =
52 | 'latin'
53 | 'han'
54 | 'hangul'
55 | 'cyrillic'
56 | 'devanagari'
57 | 'bengali'
58 | 'gujarati'
59 | 'kannada'
60 | 'malayalam'
61 | 'tamil'
62 | 'telugu'
63 | 'thai'
64 | 'ethiopic'
65
66export type Locale = {
67 code: string
68 messages: Messages
69 pattern: RegExp
70 script: Script
71 marks?: RegExp
72 words?: readonly string[]
73 /** Text identifies the language, not this regional variant. */
74 variantOf?: string
75 /** A script can still say enough when its languages share their spelling. */
76 default?: boolean
77}
78
79// More specific languages precede general ones; English stays first for stable code lists.
80export const LOCALES = [
81 {
82 code: 'en',
83 messages: en,
84 pattern: /^(?:en(?:[-_]|$)|english\b)/,
85 script: 'latin',
86 words: [
87 'the', 'and', 'but', 'if', 'by', 'from', 'with', 'are', 'was', 'were', 'that', 'its',
88 'you', 'your', 'they', 'their', 'them', 'this', 'these', 'those', 'which', 'would',
89 'should', 'will', 'been',
90 ],
91 },
92 {
93 code: 'zh-Hant',
94 messages: zhHant,
95 pattern: /^(?:zh[-_](?:hant|tw|hk|mo)(?:[-_.@]|$)|繁體|繁体|traditional chinese\b)/,
96 script: 'han',
97 // Modern Japanese uses different forms for these frequent technical characters.
98 marks: /[們說讀寫錄證讓擇參權續傳轉邊處觀碼鏈鑰關壓覽夠總應廣產嚴實驗擴歸雙點數體會學國來檢樣虛獨對隱雜變與齊區舉據屬遞餘觸發啟從將臺灣絕繪聯聲徑營稱裝聽屆閱檔佈佔譯鐵]/u,
99 },
100 {
101 code: 'zh',
102 messages: zh,
103 pattern: /^(?:zh(?(?:[-_.@]|$))(?:[-_]|$)|chinese\b|mandarin\b|中文|简体|汉语|漢語)/,
104 script: 'han',
105 // Exclude shared Japanese forms such as 国, 会, 学, 来, 体, 点 and 数.
106 marks: /[这们为说语话该请读记录认识证让设计选择权连续传转换载运进过还远达边处观现线级统组织结终络网页码键链错针钥问闭关开缓压缩复览仅够并总额费资优势输获误则负应库广产严业务实验扩归双层属递减损触发启软绝绘联营译铁]/u,
107 default: true,
108 },
109 {
110 code: 'ja',
111 messages: ja,
112 pattern: /^(?:ja(?:[-_]|$)|japanese\b|日本語)/,
113 script: 'han',
114 marks: /[\p{Script=Hiragana}\p{Script=Katakana}]/u,
115 },
116 {
117 code: 'ko',
118 messages: ko,
119 pattern: /^(?:ko(?:[-_]|$)|korean\b|한국어)/,
120 script: 'hangul',
121 },
122 {
123 code: 'es',
124 messages: es,
125 pattern: /^(?:es(?:[-_]es(?:[-_.@]|$)|[.@]|$)|spanish\s*\(spain\)|español\s*\(españa\))/,
126 script: 'latin',
127 variantOf: 'es-419',
128 },
129 {
130 code: 'es-419',
131 messages: es419,
132 pattern: /^(?:es[-_](?!es(?:[-_.@]|$))(?:[a-z]{2}|\d{3})(?:[-_.@]|$)|spanish\b|español\b|castellano\b)/,
133 script: 'latin',
134 words: [
135 'el', 'los', 'las', 'ellos', 'él', 'ella', 'unas', 'unos', 'eso', 'nuestro', 'sus', 'tus',
136 'quien', 'pero', 'cuando', 'aunque', 'donde', 'hasta', 'hacia', 'sin', 'según',
137 'también', 'hay', 'esto', 'están',
138 ],
139 },
140 {
141 code: 'fr',
142 messages: fr,
143 pattern: /^(?:fr(?:[-_]|$)|french\b|français)/,
144 script: 'latin',
145 words: [
146 'elle', 'leurs', 'au', 'ses', 'aux', 'une', 'ce', 'cette', 'ces', 'est', 'sont',
147 'était', 'être', 'avec', 'dans', 'pour', 'par', 'sans', 'pas', 'donc', 'nous',
148 'vous', 'ils', 'leur', 'aussi',
149 ],
150 },
151 {
152 code: 'de',
153 messages: de,
154 pattern: /^(?:de(?:[-_]|$)|german\b|deutsch\b)/,
155 script: 'latin',
156 words: [
157 'der', 'die', 'das', 'den', 'dem', 'auch', 'ein', 'eine', 'einen', 'einem', 'und',
158 'oder', 'aber', 'wenn', 'weil', 'mit', 'ohne', 'für', 'von', 'zum', 'zur',
159 'ist', 'sind', 'nicht', 'wird',
160 ],
161 },
162 {
163 code: 'pt-BR',
164 messages: ptBR,
165 pattern: /^(?:pt(?:[-_]|$)|portuguese\b|português)/,
166 script: 'latin',
167 words: [
168 'os', 'um', 'uma', 'uns', 'umas', 'na', 'dos', 'nas', 'pelo', 'pela', 'pelos',
169 'pelas', 'com', 'não', 'são', 'estão', 'foi', 'foram', 'você', 'vocês', 'seu',
170 'nós', 'seus', 'suas', 'também',
171 ],
172 },
173 {
174 code: 'it',
175 messages: it,
176 pattern: /^(?:it(?:[-_]|$)|italian\b|italiano\b)/,
177 script: 'latin',
178 words: [
179 'il', 'gli', 'dello', 'della', 'degli', 'delle', 'allo', 'alla', 'agli', 'questo',
180 'nel', 'nello', 'nella', 'negli', 'nelle', 'sul', 'sulla', 'sugli', 'sulle',
181 'che', 'sono', 'può', 'perché', 'quindi', 'oppure',
182 ],
183 },
184 {
185 code: 'ru',
186 messages: ru,
187 pattern: /^(?:ru(?:[-_]|$)|russian\b|русский)/,
188 script: 'cyrillic',
189 marks: /[ыэъё]/iu,
190 default: true,
191 },
192 {
193 code: 'uk',
194 messages: uk,
195 pattern: /^(?:uk(?:[-_]|$)|ukrainian\b|українська)/,
196 script: 'cyrillic',
197 marks: /[іїєґ]/iu,
198 },
199 {
200 code: 'nl',
201 messages: nl,
202 pattern: /^(?:nl(?:[-_]|$)|dutch\b|nederlands\b)/,
203 script: 'latin',
204 words: [
205 'het', 'een', 'niet', 'voor', 'van', 'dat', 'dit', 'deze', 'hij', 'zij', 'wij',
206 'jij', 'je', 'jouw', 'ons', 'onze', 'hun', 'als', 'maar', 'ook', 'nog',
207 'omdat', 'zonder', 'wanneer', 'bij', 'zich', 'moet', 'heeft',
208 ],
209 },
210 {
211 code: 'sv',
212 messages: sv,
213 pattern: /^(?:sv(?:[-_]|$)|swedish\b|svenska\b)/,
214 script: 'latin',
215 words: [
216 'inte', 'och', 'jag', 'är', 'av', 'från', 'vad', 'hur', 'också', 'bara', 'mycket',
217 'detta', 'dessa', 'vilket', 'vilka', 'någon', 'något', 'några', 'eftersom',
218 'innan', 'även', 'utan', 'än', 'här', 'där', 'alltså', 'redan',
219 ],
220 },
221 {
222 code: 'da',
223 messages: da,
224 pattern: /^(?:da(?:[-_]|$)|danish\b|dansk\b)/,
225 script: 'latin',
226 // Ikke, og, jeg and er are shared with Bokmål; prefer contrasting forms.
227 words: [
228 'af', 'hvad', 'meget', 'nogen', 'noget', 'nogle', 'inden', 'selvom', 'uden',
229 'jer', 'jeres', 'hendes', 'mig', 'dig', 'sig', 'hinanden', 'hvornår', 'sådan',
230 'sådanne', 'måske', 'især', 'op', 'også', 'kun', 'hvilket',
231 ],
232 },
233 {
234 code: 'nb',
235 messages: nb,
236 pattern: /^(?:(?:nb|no|nn)(?:[-_]|$)|norwegian\b|norsk\b)/,
237 script: 'latin',
238 words: [
239 'av', 'hva', 'bare', 'mye', 'noen', 'noe', 'dere', 'deres', 'hennes', 'uten',
240 'etter', 'enn', 'dersom', 'slik', 'seg', 'meg', 'deg', 'oss', 'vårt', 'våre',
241 'ditt', 'mitt', 'vært', 'ble', 'blitt',
242 ],
243 },
244 {
245 code: 'fi',
246 messages: fi,
247 pattern: /^(?:fi(?:[-_]|$)|finnish\b|suomi\b)/,
248 script: 'latin',
249 words: [
250 'ja', 'ei', 'että', 'kun', 'jos', 'niin', 'mutta', 'myös', 'vain', 'vielä', 'jo',
251 'nyt', 'sitten', 'ennen', 'jälkeen', 'ilman', 'kanssa', 'sinä', 'sinun', 'minä',
252 'nämä', 'tämä', 'ovat', 'oli', 'ole', 'joka', 'jotka',
253 ],
254 },
255 {
256 code: 'tr',
257 messages: tr,
258 pattern: /^(?:tr(?:[-_]|$)|turkish\b|türkçe)/,
259 script: 'latin',
260 words: [
261 've', 'bir', 'bu', 'şu', 'ben', 'biz', 'siz', 'onlar', 'için', 'ile', 'ama',
262 'fakat', 'çünkü', 'eğer', 'değil', 'daha', 'çok', 'gibi', 'kadar', 'sonra',
263 'önce', 'henüz', 'artık', 'ise', 'ancak',
264 ],
265 },
266 {
267 code: 'id',
268 messages: id,
269 pattern: /^(?:(?:id|in)(?:[-_]|$)|indonesian\b|bahasa indonesia\b)/,
270 script: 'latin',
271 words: [
272 'yang', 'dan', 'tidak', 'ini', 'itu', 'dengan', 'untuk', 'dari', 'pada', 'akan',
273 'sudah', 'belum', 'jika', 'tetapi', 'karena', 'agar', 'anda', 'saya', 'kami',
274 'kita', 'mereka', 'juga', 'hanya', 'masih', 'tanpa',
275 ],
276 },
277 {
278 code: 'vi',
279 messages: vi,
280 pattern: /^(?:vi(?:[-_]|$)|vietnamese\b|tiếng việt)/,
281 script: 'latin',
282 // Leave shared plain accents to words: French and Portuguese use them too.
283 marks: /[ăâđêôơưảạằắẳẵặầấẩẫậẻẽẹềếểễệỉĩịỏọồốổỗộờớởỡợủũụừứửữựỳỷỹỵ]/iu,
284 words: [
285 'và', 'của', 'là', 'không', 'được', 'đã', 'đang', 'sẽ', 'có', 'này', 'đó',
286 'những', 'các', 'một', 'cho', 'với', 'trong', 'khi', 'nếu', 'nhưng', 'vì',
287 'để', 'bạn', 'chúng', 'chỉ',
288 ],
289 },
290 {
291 code: 'th',
292 messages: th,
293 pattern: /^(?:th(?:[-_]|$)|thai\b|ไทย)/,
294 script: 'thai',
295 },
296 {
297 code: 'fil',
298 messages: fil,
299 pattern: /^(?:(?:fil|tl)(?:[-_]|$)|filipino\b|tagalog\b)/,
300 script: 'latin',
301 words: [
302 'ang', 'ng', 'mga', 'ito', 'iyon', 'hindi', 'ay', 'sa', 'kung', 'kapag', 'dahil',
303 'upang', 'habang', 'naman', 'rin', 'din', 'lang', 'lamang', 'mo', 'mong',
304 'iyong', 'natin', 'namin', 'ninyo', 'sila', 'siya',
305 ],
306 },
307 {
308 code: 'hi',
309 messages: hi,
310 pattern: /^(?:hi(?:[-_]|$)|hindi\b|हिन्दी|हिंदी)/,
311 script: 'devanagari',
312 default: true,
313 words: [
314 'है', 'हैं', 'और', 'नहीं', 'में', 'का', 'की', 'को', 'से', 'पर', 'यह', 'वह',
315 'आप', 'आपका', 'आपकी', 'अपने', 'इस', 'उस', 'ये', 'वे', 'लिए', 'लेकिन',
316 'यदि', 'तो', 'कि',
317 ],
318 },
319 {
320 code: 'bn',
321 messages: bn,
322 pattern: /^(?:bn(?:[-_]|$)|bengali\b|bangla\b|বাংলা)/,
323 script: 'bengali',
324 },
325 {
326 code: 'mr',
327 messages: mr,
328 pattern: /^(?:mr(?:[-_]|$)|marathi\b|मराठी)/,
329 script: 'devanagari',
330 words: [
331 'आहे', 'आहेत', 'आणि', 'नाही', 'मध्ये', 'च्या', 'मुळे', 'पण', 'म्हणून',
332 'जर', 'तर', 'हे', 'ही', 'ते', 'तो', 'त्या', 'त्याचे', 'तुम्ही', 'तुमचे',
333 'आपण', 'आम्ही', 'मी', 'माझे', 'किंवा', 'असे',
334 ],
335 },
336 {
337 code: 'gu',
338 messages: gu,
339 pattern: /^(?:gu(?:[-_]|$)|gujarati\b|ગુજરાતી)/,
340 script: 'gujarati',
341 },
342 {
343 code: 'ta',
344 messages: ta,
345 pattern: /^(?:ta(?:[-_]|$)|tamil\b|தமிழ்)/,
346 script: 'tamil',
347 },
348 {
349 code: 'te',
350 messages: te,
351 pattern: /^(?:te(?:[-_]|$)|telugu\b|తెలుగు)/,
352 script: 'telugu',
353 },
354 {
355 code: 'kn',
356 messages: kn,
357 pattern: /^(?:kn(?:[-_]|$)|kannada\b|ಕನ್ನಡ)/,
358 script: 'kannada',
359 },
360 {
361 code: 'ml',
362 messages: ml,
363 pattern: /^(?:ml(?:[-_]|$)|malayalam\b|മലയാളം)/,
364 script: 'malayalam',
365 },
366 {
367 code: 'bho',
368 messages: bho,
369 pattern: /^(?:bho(?:[-_]|$)|bhojpuri\b|भोजपुरी)/,
370 script: 'devanagari',
371 words: [
372 'बा', 'बाड़े', 'बानी', 'बाड़ऽ', 'बाड़न', 'रहल', 'रहली', 'हमनी', 'आ', 'भा',
373 'रउआ', 'रउरा', 'ओकर', 'एकर', 'कवन', 'काहे', 'कइसन', 'नइखे', 'नाहीं',
374 'हई', 'होखे', 'संगे', 'खातिर', 'अइसन', 'तइसन', 'ओह', 'एह', 'त', 'तबे',
375 ],
376 },
377 {
378 code: 'sw',
379 messages: sw,
380 pattern: /^(?:sw(?:[-_]|$)|swahili\b|kiswahili\b)/,
381 script: 'latin',
382 // Inflected auxiliaries are common in replies; na, ni, kwa and possessives overlap with neighbours.
383 marks: /(?<![\p{L}\p{M}])(?:nime|nina|nita|tume|tuna|una|uta|haija|haiku|yame|ime)(?=\p{L})/iu,
384 words: [
385 'ikiwa', 'lakini', 'hivyo', 'kwamba', 'ambayo', 'ambazo', 'ambaye', 'hii', 'hiyo',
386 'hizi', 'hizo', 'wewe', 'wetu', 'yetu', 'kwenye', 'kutoka', 'baada', 'kabla',
387 'bado', 'sasa', 'pia', 'yote', 'unaweza', 'naweza', 'lilikuwa', 'ukitaka',
388 ],
389 },
390 {
391 code: 'am',
392 messages: am,
393 pattern: /^(?:am(?:[-_]|$)|amharic\b|አማርኛ)/,
394 script: 'ethiopic',
395 },
396 {
397 code: 'ha',
398 messages: ha,
399 pattern: /^(?:ha(?:[-_]|$)|hausa\b)/,
400 script: 'latin',
401 marks: /[ɓɗƙƴ]/iu,
402 words: [
403 'amma', 'idan', 'saboda', 'kuma', 'wannan', 'waɗannan', 'zai', 'iya',
404 'wanda', 'wadda', 'waɗanda', 'kowane', 'kowace', 'kowa', 'wani', 'wata', 'wasu',
405 'yanzu', 'tukuna', 'tsakanin', 'bayan', 'kafin', 'cikin', 'daga', 'ɗin', 'babu', 'duk', 'kawai',
406 ],
407 },
408 {
409 code: 'ig',
410 messages: ig,
411 pattern: /^(?:ig(?:[-_]|$)|igbo\b)/,
412 script: 'latin',
413 // Vietnamese shares the dotted vowels. The nwe auxiliary and hyphenated ga- future help separate it.
414 marks: /[ịọụṅ]|[iou]\u0323|n\u0307|nwe|(?<![\p{L}\p{M}])ga[-‐‑]/iu,
415 words: [
416 'anyị', 'onye', 'unu', 'ụnụ', 'gị', 'ụfọdụ', 'nke', 'ọ', 'bụ', 'abụghị',
417 'dị', 'adịghị', 'mgbe', 'tupu', 'ahụ', 'ugbu', 'nwere', 'nwekwara', 'ike',
418 'karịa', 'enweghị', 'agaghị', 'gaghị', 'ebe', 'ọzọ', 'ime', 'naanị', 'dịka',
419 'kemgbe', 'agbanyeghị', 'ị',
420 ],
421 },
422 {
423 code: 'yo',
424 messages: yo,
425 pattern: /^(?:yo(?:[-_]|$)|yoruba\b|yorùbá(?:\s|$))/,
426 script: 'latin',
427 marks: /[ẹọṣàáèéìíòóùú]|[aeiouẹọ][\u0300\u0301]|[eos]\u0323/iu,
428 words: [
429 'àwọn', 'ní', 'tí', 'sí', 'fún', 'pẹ̀lú', 'ṣùgbọ́n', 'nítorí', 'bí', 'bá',
430 'yóò', 'kò', 'kì', 'ń', 'ó', 'wọ́n', 'ẹ', 'yín', 'wa', 'rẹ̀', 'èyí', 'yìí',
431 'nínú', 'lẹ́yìn', 'ṣáájú', 'ṣe', 'lè', 'náà', 'báyìí', 'tó', 'tún', 'kí',
432 ],
433 },
434 {
435 code: 'ny',
436 messages: ny,
437 pattern: /^(?:ny(?:[-_]|$)|chichewa\b|chinyanja\b|nyanja\b)/,
438 script: 'latin',
439 words: [
440 'koma', 'ngati', 'chifukwa', 'choncho', 'kapena', 'kuti', 'pamene', 'pambuyo',
441 'ndipo', 'momwe', 'mungathe', 'ilili', 'tsopano', 'panobe', 'komanso', 'kokha',
442 'zonse', 'onse', 'inu', 'ine', 'ife', 'iwo', 'wanu', 'yanu', 'yathu', 'yawo',
443 'yomwe', 'omwe', 'palibe', 'ngakhale', 'zimene',
444 ],
445 },
446 {
447 code: 'om',
448 messages: om,
449 pattern: /^(?:om(?:[-_]|$)|oromo\b|afaan oromoo\b)/,
450 script: 'latin',
451 // Perfect -eera and ability -eessa are frequent; unrestricted doubled vowels would also claim Dutch.
452 marks: /\p{L}+(?:eera|eessa)(?![\p{L}\p{M}])/iu,
453 words: [
454 'fi', 'yookaan', 'garuu', 'yoo', 'waan', 'akka', 'kana', 'sana', 'miti',
455 'ati', 'isin', 'inni', 'isheen', 'isaan', 'nuti', 'kee', 'keessan', 'isaa',
456 'ishee', 'keessa', 'keessatti', 'irraa', 'booda', 'itti', 'isaas', 'ammallee',
457 ],
458 },
459 {
460 code: 'rn',
461 messages: rn,
462 pattern: /^(?:rn(?:[-_]|$)|kirundi\b|ikirundi\b|rundi\b)/,
463 script: 'latin',
464 // Shared auxiliaries count for both. Inflected ivy-/ntivy-, shasha and izokw- supply the contrast.
465 marks: /(?<![\p{L}\p{M}])(?:ivy|ntivy)(?=[aeiou])|shasha|izokw/iu,
466 words: [
467 'canke', 'nimba', 'ivyo', 'ico', 'ca', 'vyo', 'vyose', 'vyinshi',
468 'vyonyene', 'vyari', 'vyoba', 'vyacu', 'vyanyu', 'vyabo', 'vyayo', 'cacu',
469 'canyu', 'rero', 'haciye', 'gushika', 'ubu', 'uko', 'imeze', 'irakora', 'ntikora', 'ari', 'ubwo',
470 ],
471 },
472 {
473 code: 'rw',
474 messages: rw,
475 pattern: /^(?:rw(?:[-_]|$)|kinyarwanda\b|ikinyarwanda\b)/,
476 script: 'latin',
477 marks: /(?<![\p{L}\p{M}])(?:iby|ntiby|icy|cy)(?=[aeiou])|shy/iu,
478 words: [
479 'cyangwa', 'niba', 'ibyo', 'byose', 'icyo', 'byo', 'cyo',
480 'byaba', 'byari', 'byacu', 'byinshi', 'byonyine', 'cyane', 'ubwo', 'nyuma',
481 'mbere', 'bityo', 'kuko', 'ndetse', 'ntabwo', 'ntacyo', 'ubu', 'uko', 'imeze',
482 'irakora', 'ntikora', 'ari',
483 ],
484 },
485 {
486 code: 'so',
487 messages: so,
488 pattern: /^(?:so(?:[-_]|$)|somali\b|soomaali\b)/,
489 script: 'latin',
490 words: [
491 'waxaa', 'waxay', 'wuxuu', 'waxaan', 'waxaad', 'waxaana', 'waxaadna', 'wuu',
492 'sii', 'doonaa', 'kartaa', 'waa', 'haddii', 'laakiin', 'sidaas',
493 'sida', 'sababtoo', 'marka', 'markuu', 'markaas', 'hadda', 'weli', 'kadib',
494 'hor', 'gudahood', 'dhammaan', 'keliya', 'iyadoo', 'isaga', 'iyada', 'iyaga', 'adiga',
495 ],
496 },
497 {
498 code: 'wo',
499 messages: wo,
500 pattern: /^(?:wo(?:[-_]|$)|wolof\b)/,
501 script: 'latin',
502 words: [
503 'ñu', 'ñuy', 'ñi', 'ñoom', 'ñun', 'yow', 'yéen', 'nga', 'ngay', 'naa',
504 'ngir', 'ak', 'ci', 'bi', 'wi', 'yi', 'benn', 'bépp', 'yépp', 'walla',
505 'waaye', 'kon', 'gannaaw', 'bala', 'dina', 'dafa', 'dafay', 'mën', 'léegi', 'rekk', 'loolu',
506 ],
507 },
508] as const satisfies readonly Locale[]
509
510export type Lang = (typeof LOCALES)[number]['code']
511export const LANGS: Lang[] = LOCALES.map(locale => locale.code)
512export const MESSAGES: Record<Lang, Messages> = Object.fromEntries(
513 LOCALES.map(locale => [locale.code, locale.messages]),
514) as Record<Lang, Messages>
515
516function codeOf(hint: string): string {
517 return hint.replace(/@.*$/, '').replace(/\.utf-8$/, '').replace(/_/g, '-')
518}
519
520/** The catalog a language hint names: a locale (`zh_CN.UTF-8`), a code, or a language's name. */
521export function langFrom(hint: unknown): Lang | null {
522 if (typeof hint !== 'string') return null
523 const lower = hint.trim().toLowerCase()
524 const exact = LOCALES.find(locale => codeOf(locale.code.toLowerCase()) === codeOf(lower))
525 if (exact) return exact.code
526
527 return LOCALES.find(locale => locale.pattern.test(lower))?.code ?? null
528}
529hooks/model.ts 117 lines1import type { Auto, Snapshot, Ttl } from '../types'
2import { DEFAULT_PING_OVERHEAD, TTL_MS, economics, fmtGap, fmtRemaining, priceOf } from './economics'
3import type { Messages } from './messages'
4
5// A prefix below the smallest cacheable size is not worth tracking.
6export const MIN_TRACKED = 1024
7// Share of the cached prefix a request must read to count as a hit, and below which it is a miss.
8export const HIT = 0.9
9const MISS = 0.5
10
11// How long before expiry an auto ping goes out: as late as is safe, so each one buys the most time.
12export const LEAD_MS: Record<Ttl, number> = { '5m': 30_000, '1h': 5 * 60_000 }
13
14// Why a cache is cold whatever the clock says, as the snapshot keeps it: a code, never words.
15export const COLD_PING = 'ping-miss'
16export const COLD_MODEL = 'model:'
17
18export type Verdict = 'hit' | 'partial' | 'miss'
19
20/** One touch of the cache after a gap: what was expected and what the API served. */
21export type Observation = {
22 at: number
23 kind: 'turn' | 'ping'
24 gapMs: number
25 prevBy: Snapshot['touchedBy']
26 ttl: Ttl
27 cachedBefore: number
28 read: number
29 created: number
30 input: number
31 output: number
32 model: string
33 verdict: Verdict
34}
35
36/** Whether a ping has been seen to restart an entry's full lifetime, per TTL. */
37export type Extension = Partial<Record<Ttl, boolean>>
38
39/** Everything a drawing or a report of the cache is made from. */
40export type View = {
41 held: Snapshot | null
42 now: number
43 policy: Auto
44 known: Extension
45 observations: readonly Observation[]
46}
47
48export function isTracked(held: Snapshot | null): held is Snapshot {
49 return held !== null && held.cachedTokens >= MIN_TRACKED
50}
51
52export function verdictOf(served: number, cachedBefore: number): Verdict {
53 if (served >= cachedBefore * HIT) return 'hit'
54
55 return served < cachedBefore * MISS ? 'miss' : 'partial'
56}
57
58export function overheadOf(held: Snapshot): { input: number; output: number } {
59 return held.lastPing ?? DEFAULT_PING_OVERHEAD
60}
61
62export function remainingOf(held: Snapshot, now: number): number {
63 return held.coldReason === null ? held.touchedAt + TTL_MS[held.ttl] - now : 0
64}
65
66export function costsOf(held: Snapshot) {
67 const price = priceOf(held.model)
68
69 return price ? economics(price, held.ttl, held.cachedTokens, overheadOf(held)) : null
70}
71
72export function coldWords(m: Messages, held: Snapshot, now: number): string {
73 const reason = held.coldReason
74 if (reason === null) return m.expiredAgo(fmtGap(now - held.touchedAt - TTL_MS[held.ttl]))
75 if (reason === COLD_PING) return m.coldPing
76
77 return reason.startsWith(COLD_MODEL) ? m.coldModel(reason.slice(COLD_MODEL.length)) : reason
78}
79
80/**
81 * What a touch after a ping says about pings: true when it hit although the
82 * conversation's own last request was too long ago to explain it.
83 */
84export function extensionEvidence(previous: Snapshot, startedAt: number, verdict: Verdict): boolean | undefined {
85 if (previous.touchedBy !== 'ping') return undefined
86 const lifetime = TTL_MS[previous.ttl]
87 const isPastTurn = startedAt - previous.turnAt > lifetime + 30_000
88 const isWithinPing = startedAt - previous.touchedAt < lifetime
89 if (!isPastTurn || !isWithinPing || verdict === 'partial') return undefined
90
91 return verdict === 'hit'
92}
93
94/** The pings one idle stretch may spend: the person's cap, never past break-even, one until pings are proven. */
95export function budgetOf(held: Snapshot, cap: number, known: Extension): number {
96 const costs = costsOf(held)
97 const limit = costs ? Math.min(cap, costs.maxPings) : cap
98
99 return known[held.ttl] === true ? limit : Math.min(1, limit)
100}
101
102/** When the next auto ping goes out, in the catalog's words. */
103export function nextPingWords(m: Messages, held: Snapshot, budget: number, now: number): string {
104 const remaining = remainingOf(held, now)
105 if (remaining <= 0) return m.nextCold
106 if (budget - held.pingsSinceTurn <= 0) return m.nextSpent
107 const due = remaining - LEAD_MS[held.ttl]
108
109 return due > 0 ? m.nextIn(fmtRemaining(due)) : m.nextNow
110}
111
112export function extensionWords(m: Messages, ttl: Ttl, known: Extension): string {
113 if (known[ttl] === true) return m.extendsYes(ttl)
114
115 return known[ttl] === false ? m.extendsNo : m.extendsUnknown(ttl)
116}
117hooks/report.ts 77 lines1import { fmtGap, fmtRemaining, fmtTokens, fmtUsd } from './economics'
2import type { Messages } from './messages'
3import { budgetOf, coldWords, costsOf, extensionWords, isTracked, nextPingWords, remainingOf } from './model'
4import type { View } from './model'
5
6function pingCounts(m: Messages, ping: { read: number; created: number; input: number; output: number }): string {
7 return m.counts({
8 read: fmtTokens(ping.read),
9 wrote: fmtTokens(ping.created),
10 input: ping.input,
11 output: ping.output,
12 })
13}
14
15export function autoLines(m: Messages, view: View): string[] {
16 const { held, policy, known, now } = view
17 if (!policy.isOn) return [m.autoOff]
18 if (!isTracked(held)) return [m.autoWaiting(policy.cap)]
19 const costs = costsOf(held)
20 if (costs !== null && costs.maxPings === 0) return [m.autoOnNone(policy.cap)]
21 const budget = budgetOf(held, policy.cap, known)
22
23 return [
24 m.autoOn(held.pingsSinceTurn, budget, policy.cap, nextPingWords(m, held, budget, now)),
25 m.autoPings(extensionWords(m, held.ttl, known)),
26 ]
27}
28
29/** The whole state as text: what /cache-status prints where no panel can be drawn. */
30export function report(m: Messages, view: View): string {
31 const { held, now, observations } = view
32 if (!isTracked(held)) return [m.reportNothing, ...autoLines(m, view)].join('\n')
33 const remaining = remainingOf(held, now)
34 const lines = [
35 remaining > 0 ? m.reportWarm(fmtRemaining(remaining)) : m.reportCold(coldWords(m, held, now)),
36 m.reportTtl(held.ttl, held.ttlSource),
37 m.reportCached(fmtTokens(held.cachedTokens), held.model, held.touchedBy, fmtGap(now - held.touchedAt)),
38 ]
39 const costs = costsOf(held)
40 if (costs === null) {
41 lines.push(m.reportNoPrice)
42 } else {
43 lines.push(
44 m.reportLapse(fmtUsd(costs.lapseUsd), fmtUsd(costs.rewriteUsd)),
45 costs.maxPings === 0
46 ? m.reportPingNone(fmtUsd(costs.pingUsd), held.lastPing !== null)
47 : m.reportPing(fmtUsd(costs.pingUsd), held.lastPing !== null, costs.maxPings),
48 )
49 if (costs.maxPings > 0) lines.push(m.reportRule(held.ttl, (costs.hazardThreshold * 100).toFixed(1)))
50 }
51 if (held.lastPing) {
52 lines.push(m.reportLastPing(fmtGap(now - held.lastPing.at), pingCounts(m, held.lastPing)))
53 }
54 lines.push(...autoLines(m, view))
55 if (observations.length > 0) {
56 lines.push(
57 m.reportTouches,
58 ...observations
59 .slice(-8)
60 .map(one =>
61 m.reportTouch(
62 one.kind,
63 fmtGap(one.gapMs),
64 one.prevBy,
65 one.ttl,
66 one.verdict,
67 fmtTokens(one.read),
68 fmtTokens(one.cachedBefore),
69 ),
70 ),
71 )
72 }
73 lines.push(m.reportFooter)
74
75 return lines.join('\n')
76}
77hooks/views.ts 705 lines1import type { Elements, RenderElement, RenderNode } from 'claude-code'
2
3import type { Snapshot } from '../types'
4import { TTL_MS, fmtRemaining, fmtSpan, fmtTokens, fmtUsd } from './economics'
5import { MESSAGES } from './messages'
6import type { Messages } from './messages'
7import { LEAD_MS, budgetOf, costsOf, isTracked, remainingOf } from './model'
8import type { Verdict, View } from './model'
9
10type Els = Elements['terminal'] | Elements['desktop']
11type Surface = 'terminal' | 'desktop' | 'vscode' | 'mobile'
12
13// A warm cache is drawn warm and a cold one cold; nothing else carries color.
14const EMBER = '#D4622A'
15const SLATE = '#5F7D95'
16// For drawings that cannot follow the theme: a gray that reads on light and dark alike.
17const TRACK = 'rgba(128,128,128,0.3)'
18const VERDICT_HEX: Record<Verdict, string> = { hit: EMBER, partial: EMBER, miss: SLATE }
19const INK = '#1A1A18'
20const SECONDARY = '#66655F'
21type DrawingLocale = Pick<Messages, 'tag' | 'fonts'>
22
23// Measured on the desktop app: a docked pane leaves this column 314px wide, lays rows out in 8px steps,
24// and draws its buttons 24px tall. The panel is drawn to those numbers; a wider pane leaves the rest empty.
25export const PANEL_PIXELS = 314
26const PANEL_CELLS = 42
27// A label beside controls: what is left of the column once the stepper (88px) and the gap (16px) have theirs.
28export const LABEL_PIXELS = 200
29const HISTORY_PIXELS = 200
30const BAR_CELLS = 16
31const METER_CELLS = 28
32
33// The words inside a drawing follow the person's light or dark appearance, which only a drawing's own
34// style sheet can ask about: a mod is never told the theme. Each word also carries its light color as an
35// attribute, so a surface that drops the style sheet still draws it.
36function styleOf(fonts: string): string {
37 const font = `system-ui, -apple-system${fonts ? `, ${fonts}` : ''}, sans-serif`
38
39 return `<style>text{font-family:${font}}.ink{fill:${INK}}.sec{fill:${SECONDARY}}.track{stroke:#E3E1DA}.bar{fill:#C9C7BF}` +
40 '@media (prefers-color-scheme: dark){.ink{fill:#ECEBE6}.sec{fill:#A3A29C}.track{stroke:#45443F}.bar{fill:#5C5B55}}</style>'
41}
42
43// A frame runs its own timeline; redraws keep it until the state changes.
44// What arrives eases out; what repeats eases both ways.
45const EASE_OUT = '0.22 1 0.36 1'
46const EASE_BOTH = '0.45 0 0.55 1'
47const SWEEP_MS = 700
48const RIPPLE_DELAY_MS = 450
49const RIPPLE_MS = 750
50const TRAVEL_MS = 1100
51const BREATH_MS = 1800
52/** How long the panel's entrance plays, and a refill's sweep with its ripple. */
53export const ENTER_MS = 900
54export const REFILL_MS = RIPPLE_DELAY_MS + RIPPLE_MS
55
56/**
57 * The events a timeline starts from. Their identities stay put after their movements have finished.
58 */
59export type Motion = {
60 now: number
61 openedAt: number | null
62 /** The last refill, and the share of the lifetime left before it. */
63 refill: { at: number; from: number } | null
64 isPinging: boolean
65 bandEpoch: number
66 /** Ask the clock to redraw once the old frame can be removed. */
67 onBufferEnd?: (at: number) => void
68}
69
70export const STILL: Motion = { now: 0, openedAt: null, refill: null, isPinging: false, bandEpoch: 0 }
71
72export type PaneHandlers = {
73 onToggle: () => void
74 onCapDown: () => void
75 onCapUp: () => void
76 onPing: () => void
77}
78
79/** The surface's Svg where it draws one: the terminal's table takes an Svg and shows nothing for it. */
80function svgOf(els: Els, surface: Surface) {
81 return surface !== 'terminal' && 'Svg' in els ? els.Svg : null
82}
83
84function escapeXml(text: string): string {
85 return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
86}
87
88/** Width in terminal cells: a CJK character takes two. */
89function cells(text: string): number {
90 let count = 0
91 for (const char of text) count += char.codePointAt(0)! > 0x2e80 ? 2 : 1
92
93 return count
94}
95
96function isClosing(held: Snapshot, now: number): boolean {
97 const remaining = remainingOf(held, now)
98
99 return remaining > 0 && remaining < TTL_MS[held.ttl] / 5
100}
101
102const seconds = (ms: number) => (ms / 1000).toFixed(3)
103
104/** A value arriving where the drawing already has it: held at `from` for `delay`, then eased over `ms`. */
105function arrive(attribute: string, from: string, to: string, ms: number, delay = 0, freeze = true): string {
106 const values = delay > 0 ? `${from};${from};${to}` : `${from};${to}`
107 const times = delay > 0 ? `0;${(delay / (delay + ms)).toFixed(3)};1` : '0;1'
108 const splines = delay > 0 ? `0 0 1 1;${EASE_OUT}` : EASE_OUT
109
110 return `<animate attributeName="${attribute}" values="${values}" keyTimes="${times}" keySplines="${splines}" dur="${seconds(delay + ms)}s" begin="0s" calcMode="spline" fill="${freeze ? 'freeze' : 'remove'}"/>`
111}
112
113/** There and back from the last fifth until expiry. */
114function breathe(begin: number, end: number): string {
115 return `<animate attributeName="opacity" values="1;0.45;1" keyTimes="0;0.5;1" keySplines="${EASE_BOTH};${EASE_BOTH}" dur="${seconds(BREATH_MS)}s" begin="${seconds(begin)}s" end="${seconds(end)}s" calcMode="spline" repeatCount="indefinite"/>`
116}
117
118type Ring = {
119 center: number
120 radius: number
121 stroke: number
122 /** The share of the lifetime left. */
123 left: number
124 isCold: boolean
125 remaining: number
126 lifetime: number
127 isProtected: boolean
128 /** The share of the lifetime at which the next keep-alive goes out, or null with none planned. */
129 mark: number | null
130}
131
132/**
133 * The lifetime left as a ring's arc, clockwise from the top; a cold cache is a dashed ring.
134 * Its movements: drawn from nothing as the panel opens, swept back to full when a touch refills it with a
135 * ripple if that bought real time, a lit stretch travelling while a ping is out, a slow breath when closing.
136 */
137function ring(o: Ring, isPinging: boolean, sweepFrom: number | null, isRefill = false, enters = false): string {
138 const { center, radius, stroke } = o
139 const around = 2 * Math.PI * radius
140 const circle = `cx="${center}" cy="${center}" r="${radius}" fill="none"`
141 const cold = `<circle ${circle} stroke="${SLATE}" stroke-width="${Math.max(1.5, stroke * 0.6)}" stroke-dasharray="${(around / 16).toFixed(2)} ${(around / 16).toFixed(2)}"`
142 if (o.isCold) return `${cold}/>`
143 const left = Math.min(1, Math.max(0, o.left))
144 const dash = (share: number) => `${(around * share).toFixed(2)} ${around.toFixed(2)}`
145 const fromTop = `transform="rotate(-90 ${center} ${center})"`
146 const expires = `${seconds(o.remaining)}s`
147 // The sweep yields to the lifetime's linear arc at exactly the share it has reached by then.
148 const sweepMs = Math.min(SWEEP_MS, o.remaining)
149 const sweep = sweepFrom !== null
150 ? arrive('stroke-dasharray', dash(sweepFrom), dash(Math.max(0, left - sweepMs / o.lifetime)), sweepMs, 0, false)
151 : ''
152 const breath = !o.isProtected && !isPinging ? breathe(Math.max(0, o.remaining - o.lifetime / 5), o.remaining) : ''
153 const arc = `<circle ${circle} stroke="${EMBER}" stroke-width="${stroke}" stroke-linecap="round" stroke-dasharray="${dash(left)}" ${fromTop}${isPinging ? ' opacity="0.35"' : ''}><animate attributeName="stroke-dasharray" from="${dash(left)}" to="${dash(0)}" dur="${expires}" begin="0s" calcMode="linear" fill="freeze"/>${sweep}${breath}</circle>`
154
155 const traveller = isPinging
156 ? `<circle ${circle} stroke="${EMBER}" stroke-width="${stroke}" stroke-linecap="round" stroke-dasharray="${dash(0.16)}"><animateTransform attributeName="transform" type="rotate" from="0 ${center} ${center}" to="360 ${center} ${center}" dur="${seconds(TRAVEL_MS)}s" begin="0s" repeatCount="indefinite"/></circle>`
157 : ''
158
159 let ripple = ''
160 if (isRefill && sweepFrom !== null && left - sweepFrom > 0.1) {
161 const begin = `dur="${seconds(REFILL_MS)}s" begin="0s" calcMode="spline" fill="freeze"`
162 const at = (RIPPLE_DELAY_MS / REFILL_MS).toFixed(3)
163 const lit = (RIPPLE_DELAY_MS / REFILL_MS + 0.08).toFixed(3)
164 ripple =
165 `<circle ${circle} stroke="${EMBER}" stroke-width="${stroke}" opacity="0">` +
166 `<animate attributeName="opacity" values="0;0;0.45;0" keyTimes="0;${at};${lit};1" keySplines="0 0 1 1;0 0 1 1;${EASE_OUT}" ${begin}/>` +
167 `<animate attributeName="r" values="${radius};${radius};${(radius + stroke * 0.65).toFixed(2)}" keyTimes="0;${at};1" keySplines="0 0 1 1;${EASE_OUT}" ${begin}/>` +
168 `<animate attributeName="stroke-width" values="${stroke};${stroke};${(stroke / 6).toFixed(2)}" keyTimes="0;${at};1" keySplines="0 0 1 1;${EASE_OUT}" ${begin}/>` +
169 '</circle>'
170 }
171
172 // Where the arc will stand when the next keep-alive goes out: a white stud the arc burns down to.
173 let stud = ''
174 if (o.mark !== null && left >= o.mark && !isPinging) {
175 const angle = 2 * Math.PI * o.mark
176 const x = (center + radius * Math.sin(angle)).toFixed(2)
177 const y = (center - radius * Math.cos(angle)).toFixed(2)
178 const appear = enters ? arrive('opacity', '0', '1', 200, SWEEP_MS * 0.6) : ''
179 stud = `<circle cx="${x}" cy="${y}" r="${(stroke / 4).toFixed(2)}" fill="#FFFFFF">${appear}</circle>`
180 }
181
182 return `<g><set attributeName="visibility" to="hidden" begin="${expires}" fill="freeze"/><circle ${circle} class="track" stroke="${TRACK}" stroke-width="${stroke}"/>${ripple}${arc}${traveller}${stud}</g>${cold} visibility="hidden"><set attributeName="visibility" to="visible" begin="${expires}" fill="freeze"/></circle>`
183}
184
185/** The band's mark: the ring alone, small. */
186export function dialSvg(left: number, isCold: boolean, isClosing: boolean, motion: Motion = STILL, locale: DrawingLocale = MESSAGES.en): string {
187 const lifetime = TTL_MS['5m']
188 const drawn = ring({ center: 9, radius: 6.5, stroke: 3, left, isCold, remaining: left * lifetime, lifetime, isProtected: !isClosing, mark: null }, motion.isPinging, motion.refill?.from ?? null, motion.refill !== null)
189
190 return `<svg xmlns="http://www.w3.org/2000/svg" lang="${locale.tag}" viewBox="0 0 18 18">${styleOf(locale.fonts)}${drawn}</svg>`
191}
192
193type Card = {
194 figure: string
195 caption: string
196 isCold: boolean
197 locale?: DrawingLocale
198}
199
200/** The clock's words are an image beside the independently running ring. */
201export function cardSvg(o: Card): { source: string; height: number } {
202 const locale = o.locale ?? MESSAGES.en
203 const hex = o.isCold ? SLATE : EMBER
204
205 return {
206 height: 62,
207 source: `<svg xmlns="http://www.w3.org/2000/svg" lang="${locale.tag}" viewBox="0 0 238 62">${styleOf(locale.fonts)}<text x="0" y="31" font-size="38" font-weight="650" letter-spacing="-0.76" fill="${hex}">${escapeXml(o.figure)}</text><text x="0" y="56" font-size="14" class="sec" fill="${SECONDARY}">${escapeXml(o.caption)}</text></svg>`,
208 }
209}
210
211type Costs = { lapseLabel: string; lapseUsd: string; pingLabel: string; pingUsd: string; share: number; note: string }
212
213/** The lower 94px of the old card, with its coordinates measured from y=62. */
214function costsSvg(costs: Costs, locale: DrawingLocale, enters: boolean): string {
215 const width = PANEL_PIXELS
216 const labelWidth = Math.max(cells(costs.lapseLabel), cells(costs.pingLabel)) * 7
217 const barX = labelWidth + 12
218 const barWidth = Math.max(60, width - barX - 68)
219 const row = (top: number, label: string, usd: string, bar: string) =>
220 `<text x="0" y="${top + 15}" font-size="14" class="sec" fill="${SECONDARY}">${escapeXml(label)}</text>${bar}<text x="${width}" y="${top + 15}" font-size="14" font-weight="600" text-anchor="end" class="ink" fill="${INK}" style="font-variant-numeric:tabular-nums">${escapeXml(usd)}</text>`
221 const sliver = Math.max(3, Math.round(barWidth * Math.min(1, costs.share)))
222 // As the panel opens the two bars grow one after the other: the comparison is read in that order.
223 const grow = (to: number, ms: number, delay: number) =>
224 enters ? arrive('width', '0', String(to), ms, delay) : ''
225 const parts = [
226 row(20, costs.lapseLabel, costs.lapseUsd, `<rect x="${barX}" y="26" width="${barWidth}" height="8" rx="4" class="bar" fill="#C9C7BF">${grow(barWidth, 500, 150)}</rect>`),
227 row(48, costs.pingLabel, costs.pingUsd, `<rect x="${barX}" y="54" width="${sliver}" height="8" rx="${Math.min(4, sliver / 2)}" fill="${EMBER}">${grow(sliver, 250, 650)}</rect>`),
228 `<text x="0" y="88" font-size="12" class="sec" fill="${SECONDARY}">${escapeXml(costs.note)}</text>`,
229 ]
230
231 return `<svg xmlns="http://www.w3.org/2000/svg" lang="${locale.tag}" viewBox="0 0 ${width} 94">${styleOf(locale.fonts)}${parts.join('')}</svg>`
232}
233
234type Drawing = { id: number; source: string }
235type Buffered = { key: string; current: Drawing; previous?: Drawing; until: number }
236type RingDrawing = Buffered & {
237 at: number
238 ring: Ring
239 sweepFrom: number | null
240 refillAt: number | null
241 openedAt: number | null
242}
243
244let drawingId = 0
245const rings: Partial<Record<'band' | 'card', RingDrawing>> = {}
246let comparison: (Buffered & { openedAt: number | null; refillAt: number | null }) | undefined
247
248/** The same ease as the sweep's SMIL spline, sampled when another refill interrupts it. */
249function sweepShare(progress: number): number {
250 const curve = (t: number, a: number, b: number) => 3 * (1 - t) ** 2 * t * a + 3 * (1 - t) * t ** 2 * b + t ** 3
251 let low = 0
252 let high = 1
253 for (let i = 0; i < 20; i += 1) {
254 const t = (low + high) / 2
255 if (curve(t, 0.22, 0.36) < progress) low = t
256 else high = t
257 }
258
259 return curve((low + high) / 2, 1, 1)
260}
261
262/** Where the frame already on screen stands, including a sweep still arriving. */
263function drawnShare(drawn: RingDrawing, now: number): number {
264 const { ring: o, sweepFrom } = drawn
265 if (o.isCold) return 0
266 const elapsed = Math.max(0, now - drawn.at)
267 const sweepMs = Math.min(SWEEP_MS, o.remaining)
268 if (sweepFrom !== null && elapsed < sweepMs) {
269 const to = Math.max(0, o.left - sweepMs / o.lifetime)
270
271 return sweepFrom + (to - sweepFrom) * sweepShare(elapsed / sweepMs)
272 }
273
274 return Math.max(0, (o.remaining - elapsed) / o.lifetime)
275}
276
277/** A state's clock is read once, when its timeline is built, never to key a redraw. */
278function ringDrawing(site: 'band' | 'card', held: Snapshot, isProtected: boolean, motion: Motion, locale: DrawingLocale): RingDrawing {
279 const lifetime = TTL_MS[held.ttl]
280 const mark = isProtected ? LEAD_MS[held.ttl] / lifetime : null
281 const openedAt = site === 'card' ? motion.openedAt : null
282 const refillAt = motion.refill?.at ?? null
283 const key = JSON.stringify([held.touchedAt, held.ttl, held.coldReason, isProtected, mark, motion.isPinging, refillAt, openedAt, site === 'band' ? motion.bandEpoch : 0])
284 const previous = rings[site]
285 if (previous?.key === key) return previous
286
287 const now = motion.now
288 const remaining = Math.max(0, remainingOf(held, now))
289 const opens = site === 'card' && openedAt !== null && openedAt !== previous?.openedAt
290 const enters = opens && now - openedAt < ENTER_MS
291 const refills = motion.refill !== null && refillAt !== previous?.refillAt && now - motion.refill.at < REFILL_MS
292 const sweepFrom = refills ? previous ? drawnShare(previous, now) : motion.refill!.from : enters ? 0 : null
293 const o: Ring = {
294 center: site === 'band' ? 9 : 30,
295 radius: site === 'band' ? 6.5 : 23,
296 stroke: site === 'band' ? 3 : 6,
297 left: Math.min(1, remaining / lifetime),
298 isCold: remaining <= 0,
299 remaining,
300 lifetime,
301 isProtected,
302 mark: site === 'card' ? mark : null,
303 }
304 const id = ++drawingId
305 const size = site === 'band' ? 18 : 60
306 const until = previous && !opens ? now + (refills ? REFILL_MS : 1000) : now
307 const drawn: RingDrawing = {
308 key,
309 at: now,
310 ring: o,
311 sweepFrom,
312 refillAt,
313 openedAt,
314 current: { id, source: `<svg xmlns="http://www.w3.org/2000/svg" id="ring-${id}" lang="${locale.tag}" viewBox="0 0 ${size} ${size}">${styleOf(locale.fonts)}${ring(o, motion.isPinging, sweepFrom, refills, enters && !refills)}</svg>` },
315 previous: previous && !opens ? previous.current : undefined,
316 until,
317 }
318 rings[site] = drawn
319 if (drawn.previous) motion.onBufferEnd?.(until)
320
321 return drawn
322}
323
324function costsDrawing(costs: Costs, motion: Motion, locale: DrawingLocale): Buffered {
325 const still = costsSvg(costs, locale, false)
326 const key = JSON.stringify([still, motion.openedAt])
327 if (comparison?.key === key) return comparison
328 const opens = motion.openedAt !== null && comparison?.openedAt !== motion.openedAt
329 const enters = opens && motion.now - motion.openedAt! < ENTER_MS
330 const previous = opens ? undefined : comparison?.current
331 const refills = motion.refill !== null && comparison?.refillAt !== motion.refill.at && motion.now - motion.refill.at < REFILL_MS
332 const until = previous ? motion.now + (refills ? REFILL_MS : 1000) : motion.now
333 comparison = {
334 key,
335 openedAt: motion.openedAt,
336 refillAt: motion.refill?.at ?? null,
337 current: { id: ++drawingId, source: enters ? costsSvg(costs, locale, true) : still },
338 previous,
339 until,
340 }
341 if (previous) motion.onBufferEnd?.(until)
342
343 return comparison
344}
345
346/** Keep the loaded frame underneath while its replacement loads, then keep the replacement's key. */
347function bufferedSvg(els: Els, Svg: NonNullable<ReturnType<typeof svgOf>>, drawn: Buffered, now: number, width: number, height: number, alt: string): RenderElement {
348 const covers = drawn.previous !== undefined && now < drawn.until
349 const frame = (drawing: Drawing, above: boolean) => els.Box({
350 key: `drawing-${drawing.id}`,
351 ...(above ? { position: 'absolute' as const, top: 0, left: 0 } : {}),
352 children: [Svg({ alt, width, height, isInteractive: true, source: drawing.source })],
353 })
354
355 return els.Box({
356 position: 'relative',
357 flexDirection: 'column',
358 flexShrink: 0,
359 children: [...(covers ? [frame(drawn.previous!, false)] : []), frame(drawn.current, covers)],
360 })
361}
362
363type LabelStyle = 'title' | 'body' | 'count' | 'small'
364
365/** One line of words drawn in the card's own type, to sit beside a button: 20px tall, or 16px when small. */
366export function labelSvg(text: string, width: number, style: LabelStyle, locale: DrawingLocale = MESSAGES.en): { source: string; height: number } {
367 const isSmall = style === 'small'
368 const height = isSmall ? 16 : 20
369 const isInk = style === 'title' || style === 'count'
370 const x = style === 'count' ? width / 2 : 0
371 const anchor = style === 'count' ? 'middle' : 'start'
372 const weight = style === 'title' ? ' font-weight="600"' : ''
373 const numerals = style === 'count' ? ' style="font-variant-numeric:tabular-nums"' : ''
374
375 return {
376 height,
377 source: `<svg xmlns="http://www.w3.org/2000/svg" lang="${locale.tag}" viewBox="0 0 ${width} ${height}">${styleOf(locale.fonts)}<text x="${x}" y="${isSmall ? 12 : 14.5}" font-size="${isSmall ? 12 : 14}"${weight} text-anchor="${anchor}" class="${isInk ? 'ink' : 'sec'}" fill="${isInk ? INK : SECONDARY}"${numerals}>${escapeXml(text)}</text></svg>`,
378 }
379}
380
381/** How the cache has held: a dot per recent touch, then the sentence, set against the right edge. */
382export function historySvg(verdicts: readonly Verdict[], summary: string, locale: DrawingLocale = MESSAGES.en): { source: string; height: number } {
383 const width = HISTORY_PIXELS
384 // The sentence is anchored at the right edge; the dots sit to its left, by the width its 12px type takes.
385 const wordsWidth = cells(summary) * 6.2
386 const dotsWidth = verdicts.length * 9 - 3
387 const start = Math.max(0, width - wordsWidth - 8 - dotsWidth)
388 const dots = verdicts
389 .map((verdict, i) => `<circle cx="${(start + 3 + i * 9).toFixed(1)}" cy="8" r="3" fill="${VERDICT_HEX[verdict]}"/>`)
390 .join('')
391
392 return {
393 height: 16,
394 source: `<svg xmlns="http://www.w3.org/2000/svg" lang="${locale.tag}" viewBox="0 0 ${width} 16">${styleOf(locale.fonts)}${dots}<text x="${width}" y="12" font-size="12" text-anchor="end" class="sec" fill="${SECONDARY}">${escapeXml(summary)}</text></svg>`,
395 }
396}
397
398/** The one-line band above the prompt: the ring, the time left, what is at stake, and the way into the panel. */
399export function bandView(
400 els: Els,
401 surface: Surface,
402 m: Messages,
403 view: View & { held: Snapshot },
404 motion: Motion,
405 onDetails: () => void,
406): RenderElement {
407 const { Box, Text, Button } = els
408 const Svg = svgOf(els, surface)
409 const { held, now, policy, known } = view
410 const remaining = remainingOf(held, now)
411 const tokens = fmtTokens(held.cachedTokens)
412 const costs = costsOf(held)
413 const rebuild = costs ? fmtUsd(costs.rewriteUsd) : ''
414 const head = remaining > 0 ? fmtRemaining(remaining) : held.coldReason === null ? m.bandCold : m.bandUnusable
415 // With a keep-alive still to come the cache is not in danger, so nothing warns.
416 const budget = budgetOf(held, policy.cap, known)
417 const isSaved = policy.isOn && held.pingsSinceTurn < budget
418 const isAlarming = isClosing(held, now) && !isSaved
419 const detail =
420 remaining <= 0
421 ? m.bandColdDetail(tokens, rebuild)
422 : policy.isOn && budget > 0
423 ? m.bandAutoDetail(tokens, held.pingsSinceTurn, budget)
424 : m.bandWarmDetail(tokens, rebuild)
425 // The words stay the theme's own until something needs attention.
426 const emphasis = remaining <= 0 ? { dimColor: true } : isAlarming ? { color: 'warning' } : {}
427
428 return Box({
429 flexDirection: 'row',
430 columnGap: 1,
431 alignItems: 'center',
432 width: '100%',
433 children: [
434 Svg
435 ? bufferedSvg(els, Svg, ringDrawing('band', held, isSaved, motion, m), now, 18, 18, head)
436 : Text({ color: remaining > 0 ? EMBER : SLATE, children: [remaining > 0 ? '●' : '○'] }),
437 Text({ bold: true, ...emphasis, children: [head] }),
438 Text({ dimColor: true, wrap: 'truncate-end', children: [detail] }),
439 // The way into the panel sits at the band's far edge.
440 Box({ flexGrow: 1, children: [] }),
441 Button({ key: 'details', label: m.details, plain: true, dimColor: true, onPress: onDetails }),
442 ],
443 })
444}
445
446/** The panel: the display card, then the keep-alive switch, its budget, and how the cache has held. */
447export function paneView(
448 els: Els,
449 surface: Surface,
450 m: Messages,
451 view: View,
452 lastAction: string,
453 handlers: PaneHandlers,
454 columns: number,
455 motion: Motion = STILL,
456): RenderElement {
457 const { Box, Text, Button } = els
458 const Svg = svgOf(els, surface)
459 const { held, now, policy, known, observations } = view
460 const dim = (text: string) => Text({ dimColor: true, wrap: 'wrap', children: [text] })
461 const between = (children: RenderNode[]) =>
462 Box({ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', columnGap: 2, children })
463 // Words beside a control: drawn in the card's type where the surface draws, the surface's own text otherwise.
464 const words = (text: string, style: LabelStyle, width = LABEL_PIXELS): RenderNode => {
465 if (!Svg) {
466 return Text({ ...(style === 'title' ? { bold: true } : style === 'count' ? {} : { dimColor: true }), children: [text] })
467 }
468 const drawn = labelSvg(text, width, style, m)
469
470 return Svg({ alt: text, width, height: drawn.height, source: drawn.source })
471 }
472
473 const tracked = isTracked(held) ? held : null
474 const costs = tracked ? costsOf(tracked) : null
475 const remaining = tracked ? remainingOf(tracked, now) : 0
476 const budget = tracked ? budgetOf(tracked, policy.cap, known) : 0
477 const left = tracked ? Math.max(0, budget - tracked.pingsSinceTurn) : 0
478 const stretch = tracked ? TTL_MS[tracked.ttl] - LEAD_MS[tracked.ttl] : 0
479
480 // What is read: the time left and a lapse set against a ping.
481 const display: RenderNode[] = []
482 if (tracked) {
483 const tokens = fmtTokens(tracked.cachedTokens)
484 const planned = policy.isOn && remaining > 0 ? left : 0
485 const untilPing = remaining - LEAD_MS[tracked.ttl]
486 const figure = remaining > 0 ? fmtRemaining(remaining) : tracked.coldReason === null ? m.bandCold : m.bandUnusable
487 const caption =
488 remaining <= 0
489 ? m.heroCold(tokens, costs ? fmtUsd(costs.rewriteUsd) : '')
490 : motion.isPinging
491 ? m.heroPinging
492 : planned > 0
493 ? m.heroAuto(untilPing > 0 ? fmtRemaining(untilPing) : '', fmtSpan(remaining + planned * stretch))
494 : m.heroLeft(tokens)
495 const share = costs && costs.rewriteUsd > 0 ? costs.pingUsd / costs.rewriteUsd : 1
496 // The fuller sentence where it fits on the card's one line, the short one where it would not.
497 const rule = costs ? m.breakEvenRule(costs.maxPings, (costs.hazardThreshold * 100).toFixed(1)) : ''
498 const note = costs
499 ? costs.maxPings === 0
500 ? m.breakEvenNone
501 : cells(rule) * 6 <= PANEL_PIXELS ? rule : m.breakEven(costs.maxPings)
502 : ''
503
504 if (Svg) {
505 const card = cardSvg({ locale: m, figure, caption, isCold: remaining <= 0 })
506 // The plan, unlike its countdown, is a state: expiry is already in the ring's timeline.
507 const isSaved = policy.isOn && left > 0
508 const drawings: RenderNode[] = [Box({
509 flexDirection: 'row',
510 columnGap: 2,
511 alignItems: 'flex-start',
512 children: [
513 bufferedSvg(els, Svg, ringDrawing('card', tracked, isSaved, motion, m), now, 60, 60, figure),
514 Svg({ alt: `${figure} ${caption}`, width: 238, height: card.height, source: card.source }),
515 ],
516 })]
517 if (costs) {
518 const drawn = costsDrawing({
519 lapseLabel: m.rowLapse,
520 lapseUsd: fmtUsd(costs.rewriteUsd),
521 pingLabel: m.rowPing,
522 pingUsd: fmtUsd(costs.pingUsd),
523 share,
524 note,
525 }, motion, m)
526 drawings.push(bufferedSvg(els, Svg, drawn, now, PANEL_PIXELS, 94, `${m.rowLapse} ${fmtUsd(costs.rewriteUsd)} · ${m.rowPing} ${fmtUsd(costs.pingUsd)} · ${note}`))
527 }
528 display.push(
529 Box({ flexDirection: 'column', children: drawings }),
530 )
531 if (!costs) display.push(dim(m.noPrice))
532 } else {
533 const hex = remaining > 0 ? EMBER : SLATE
534 const lit = Math.round(METER_CELLS * Math.min(1, Math.max(0, remaining / TTL_MS[tracked.ttl])))
535 display.push(
536 Box({
537 flexDirection: 'column',
538 children: [
539 Box({
540 flexDirection: 'row',
541 columnGap: 1,
542 children: [
543 Text({ color: hex, children: [remaining > 0 ? '●' : '○'] }),
544 Text({ bold: true, children: [figure] }),
545 Text({ dimColor: true, children: [caption] }),
546 ],
547 }),
548 Box({
549 flexDirection: 'row',
550 children: [
551 Text({ color: hex, children: ['━'.repeat(lit)] }),
552 Text({ dimColor: true, children: ['━'.repeat(METER_CELLS - lit)] }),
553 ],
554 }),
555 ],
556 }),
557 )
558 if (costs) {
559 const labelCells = Math.max(cells(m.rowLapse), cells(m.rowPing)) + 1
560 const compare = (label: string, lengthShare: number, isAccent: boolean, usd: string) =>
561 Box({
562 flexDirection: 'row',
563 columnGap: 1,
564 children: [
565 Box({ width: labelCells, flexShrink: 0, children: [Text({ dimColor: true, children: [label] })] }),
566 Box({
567 flexGrow: 1,
568 children: [
569 Text({
570 ...(isAccent ? { color: EMBER } : { dimColor: true }),
571 children: ['━'.repeat(Math.max(1, Math.round(BAR_CELLS * Math.min(1, lengthShare))))],
572 }),
573 ],
574 }),
575 Text({ bold: true, children: [usd] }),
576 ],
577 })
578 display.push(
579 Box({
580 flexDirection: 'column',
581 children: [
582 compare(m.rowLapse, 1, false, fmtUsd(costs.rewriteUsd)),
583 compare(m.rowPing, share, true, fmtUsd(costs.pingUsd)),
584 dim(costs.maxPings === 0 ? m.breakEvenNone : m.breakEven(costs.maxPings)),
585 ],
586 }),
587 )
588 } else {
589 display.push(dim(m.noPrice))
590 }
591 }
592 } else {
593 display.push(dim(m.paneNothing))
594 }
595
596 // What is pressed: the switch, with its budget on the line beneath.
597 const isTrial = tracked !== null && known[tracked.ttl] !== true && policy.cap > 1
598 const plan = !tracked
599 ? m.autoPlanOff(policy.cap, '')
600 : left <= 0
601 ? costs !== null && costs.maxPings === 0 ? m.autoNotWorth : m.autoSpent
602 : isTrial
603 ? policy.isOn
604 ? m.autoTrialOn(tracked.pingsSinceTurn, policy.cap)
605 : m.autoTrialOff(policy.cap)
606 : policy.isOn
607 ? m.autoPlanOn(tracked.pingsSinceTurn, budget)
608 : m.autoPlanOff(left, fmtSpan(Math.max(0, remaining) + left * stretch))
609 const capWords = String(policy.cap)
610 const stepper = Box({
611 flexDirection: 'row',
612 columnGap: 1,
613 alignItems: 'center',
614 flexShrink: 0,
615 children: [
616 Button({ key: 'cap-down', label: '−', variant: 'secondary', onPress: handlers.onCapDown }),
617 words(capWords, 'count', capWords.length * 9 + 2),
618 Button({ key: 'cap-up', label: '+', variant: 'secondary', onPress: handlers.onCapUp }),
619 ],
620 })
621 const switchRows = [
622 between([
623 words(m.autoTitle, 'title'),
624 Button({
625 key: 'auto-toggle',
626 label: policy.isOn ? m.btnOff : m.btnOn,
627 variant: policy.isOn ? 'secondary' : 'primary',
628 onPress: handlers.onToggle,
629 }),
630 ]),
631 between([words(plan, 'body'), stepper]),
632 ]
633
634 // How the cache has held, beside the button that pings now; under it, what the last press did and the price basis.
635 const recent = observations.slice(-8)
636 const misses = recent.filter(one => one.verdict !== 'hit').length
637 const summary =
638 recent.length === 0 ? m.historyEmpty : misses === 0 ? m.historyAll(recent.length) : m.historySome(recent.length, misses)
639 let history: RenderNode
640 if (Svg) {
641 const drawn = historySvg(recent.map(one => one.verdict), summary, m)
642 history = Svg({ alt: summary, width: HISTORY_PIXELS, height: drawn.height, source: drawn.source })
643 } else {
644 history = Box({
645 flexDirection: 'row',
646 columnGap: 1,
647 flexShrink: 0,
648 children: [
649 ...recent.map(one => Text({ color: VERDICT_HEX[one.verdict], children: ['●'] })),
650 Text({ dimColor: true, children: [summary] }),
651 ],
652 })
653 }
654 const footer: RenderNode[] = [
655 tracked
656 ? between([Button({ key: 'ping', label: m.btnPing, variant: 'secondary', onPress: handlers.onPing }), history])
657 : history,
658 ]
659 if (lastAction) footer.push(dim(lastAction))
660 if (costs) {
661 footer.push(words(left > 0 ? m.priceNote(fmtUsd(left * costs.pingUsd)) : m.listPrice, 'small', PANEL_PIXELS))
662 }
663
664 if (!Svg) {
665 // A terminal counts in lines: one between sections, none inside them.
666 return Box({
667 flexDirection: 'column',
668 rowGap: 1,
669 paddingX: 1,
670 width: Math.max(24, Math.min(PANEL_CELLS, columns)),
671 children: [
672 ...display,
673 Text({ dimColor: true, children: ['─'.repeat(METER_CELLS)] }),
674 Box({ flexDirection: 'column', children: switchRows }),
675 Box({ flexDirection: 'column', children: footer }),
676 ],
677 })
678 }
679
680 // The desktop counts in 8px steps: three between sections, two under the rule, one between the rows of a section.
681 return Box({
682 flexDirection: 'column',
683 rowGap: 3,
684 paddingX: 1,
685 width: Math.max(24, Math.min(PANEL_CELLS, columns)),
686 children: [
687 ...display,
688 Box({
689 flexDirection: 'column',
690 rowGap: 2,
691 children: [
692 Svg({
693 alt: '—',
694 width: PANEL_PIXELS,
695 height: 1,
696 source: `<svg xmlns="http://www.w3.org/2000/svg" lang="${m.tag}" viewBox="0 0 ${PANEL_PIXELS} 1"><rect width="${PANEL_PIXELS}" height="1" fill="${TRACK}"/></svg>`,
697 }),
698 Box({ flexDirection: 'column', rowGap: 1, children: switchRows }),
699 ],
700 }),
701 Box({ flexDirection: 'column', rowGap: 1, children: footer }),
702 ],
703 })
704}
705hooks/locales/en.ts 147 lines1import type { Snapshot, Ttl } from '../../types'
2import type { LangSource } from '../messages'
3import type { Counts, Verdict } from './shared'
4
5// The words a person reads for what the contract keeps as short codes.
6const SOURCE_EN: Record<Snapshot['ttlSource'], string> = {
7 env: 'environment',
8 observed: 'observed',
9 assumed: 'assumed',
10}
11const BY_EN: Record<Snapshot['touchedBy'], string> = { turn: 'message', ping: 'refresh', resume: 'resumed session' }
12const VERDICT_EN: Record<Verdict, string> = { hit: 'hit', partial: 'partial hit', miss: 'miss' }
13
14function capitalize(word: string): string {
15 return word.charAt(0).toUpperCase() + word.slice(1)
16}
17
18// Every word the mod shows. A new language is one more catalog of this shape.
19// The vocabulary: a cache is active until it expires; a refresh restarts its lifetime; an expired cache is rebuilt.
20export const en = {
21 name: 'English',
22 tag: 'en',
23 fonts: '',
24
25 bandCold: 'Expired',
26 bandUnusable: 'Unavailable',
27 bandWarmDetail: (size: string, cost: string) =>
28 `${size} cached${cost && ` · ${cost} to rebuild if it expires`}`,
29 bandAutoDetail: (size: string, used: number, budget: number) =>
30 `${size} cached · auto-refresh ${used}/${budget}`,
31 bandColdDetail: (size: string, cost: string) => `next message rebuilds ${size} tokens${cost && ` (≈${cost})`}`,
32
33 cmdStatus: 'Show prompt cache status, time left and estimated rebuild cost',
34 cmdPing: 'Refresh the prompt cache now',
35 cmdAuto: 'View or change auto-refresh settings',
36 cmdAutoHint: '[on|off] [max refreshes]',
37 cmdLang: 'Set the display language',
38 cmdLangHint: '[auto|code]',
39
40 expiredAgo: (gap: string) => `expired ${gap} ago`,
41 coldPing: 'the last refresh missed',
42 coldModel: (model: string) => `model changed to ${model}`,
43
44 pingNothing: 'Nothing is cached yet, so there is nothing to refresh.',
45 pingBusy: 'A refresh is already in progress.',
46 pingCold: (why: string, size: string) =>
47 `Not sent: the cache is not active (${why}). A request now would rebuild all ${size} tokens at the cache-write price. Run /cache-ping force to send anyway.`,
48 pingNoFork: 'Not sent: this conversation has no response yet.',
49 pingApiError: (error: string, status: number | null) =>
50 `Refresh failed: API error (${error}, status ${status ?? 'none'}). The countdown was not reset.`,
51 pingCut: 'The refresh was interrupted before the API responded. The countdown was not reset.',
52 counts: (c: Counts) => `read ${c.read}, wrote ${c.wrote}, ${c.input} in, ${c.output} out`,
53 atListPrice: (usd: string) => ` ≈${usd} at list prices.`,
54 pingHit: (counts: string, cost: string, ttl: Ttl) => `Cache refreshed: ${counts}.${cost} Countdown reset to ${ttl}.`,
55 pingRewrote: (counts: string, cost: string) =>
56 `Cache miss. The cache was rebuilt: ${counts}.${cost}`,
57 pingMissed: (counts: string, cost: string) => `Cache miss: ${counts}.${cost} The cache was not extended.`,
58 autoLog: (n: number, budget: number, text: string) => `Auto-refresh ${n}/${budget}. ${text}`,
59 autoMissToast: 'Auto-refresh did not succeed. See /cache-status',
60
61 reportNothing: 'Prompt cache: nothing cached yet. The countdown starts after the next response.',
62 reportWarm: (left: string) => `Prompt cache: active, ${left} left.`,
63 reportCold: (why: string) => `Prompt cache: not active (${why}).`,
64 reportTtl: (ttl: Ttl, source: Snapshot['ttlSource']) => ` Lifetime: ${ttl} (${SOURCE_EN[source]})`,
65 reportCached: (size: string, model: string, by: Snapshot['touchedBy'], since: string) =>
66 ` Cached: ${size} tokens on ${model}, last used ${since} ago (${BY_EN[by]})`,
67 reportNoPrice: ' Cost: pricing unavailable for this model',
68 reportLapse: (lapse: string, rewrite: string) =>
69 ` If it expires: ${rewrite} to rebuild, ${lapse} more than a cache hit`,
70 reportPing: (ping: string, isMeasured: boolean, max: number) =>
71 ` One refresh: about ${ping} (token overhead ${isMeasured ? 'measured' : 'estimated'}). Up to ${max} in a row cost less than one rebuild`,
72 reportPingNone: (ping: string, isMeasured: boolean) =>
73 ` One refresh: about ${ping} (token overhead ${isMeasured ? 'measured' : 'estimated'}). That is more than an expiry would add, so refreshing does not pay off`,
74 reportRule: (ttl: Ttl, percent: string) =>
75 ` Rule of thumb: refreshing pays off if you are more than ${percent}% likely to return within ${ttl}`,
76 reportLastPing: (ago: string, counts: string) => ` Last refresh: ${ago} ago, ${counts}`,
77 reportTouches: 'Recent cache activity:',
78 reportTouch: (
79 kind: 'turn' | 'ping',
80 gap: string,
81 prevBy: Snapshot['touchedBy'],
82 ttl: Ttl,
83 verdict: Verdict,
84 read: string,
85 cached: string,
86 ) => ` ${capitalize(BY_EN[kind])}, ${gap} after a ${BY_EN[prevBy]} (${ttl}): ${VERDICT_EN[verdict]}, read ${read} of ${cached}`,
87 reportFooter: 'Costs are estimates at API list prices. On a subscription they reflect plan usage, not charges.',
88
89 autoHeader: 'Auto-refresh:',
90 autoOff: ' Auto-refresh: off (run /cache-auto on to keep the cache active while you are away)',
91 autoWaiting: (cap: number) => ` Auto-refresh: on, up to ${cap} per idle period. Waiting for the first response`,
92 autoOn: (used: number, budget: number, cap: number, next: string) =>
93 ` Auto-refresh: on, ${used} of ${budget} used since your last message (limit ${cap}). Next: ${next}`,
94 autoOnNone: (cap: number) =>
95 ` Auto-refresh: on (limit ${cap}), but not sending: a refresh would cost more than an expiry adds for this cache`,
96 nextCold: 'none, the cache is not active',
97 nextSpent: 'none, the limit for this idle period is used up',
98 nextIn: (time: string) => `in ${time}`,
99 nextNow: 'any moment now',
100 autoPings: (state: string) => ` Effect: ${state}`,
101 extendsYes: (ttl: Ttl) => `confirmed, a refresh restores the full ${ttl}`,
102 extendsNo: 'not confirmed. The last refresh was followed by a miss, so auto-refresh sends only one per idle period',
103 extendsUnknown: (ttl: Ttl) =>
104 `not confirmed yet. Auto-refresh sends one per idle period until a refresh is seen to restore the full ${ttl}`,
105 autoNote:
106 ' Note: refreshes are sent only while this app is running and your computer is awake, and each one counts toward your plan usage or API credit',
107 autoUsage: (max: number) => `Usage: /cache-auto [on|off] [max refreshes per idle period, 1-${max}]`,
108
109 details: 'Details ›',
110 cmdPanel: 'Open the prompt cache panel',
111 paneTitle: 'Prompt cache',
112 paneNothing: 'Nothing cached yet. The countdown starts after the next response.',
113 heroLeft: (size: string) => `left · ${size} tokens cached`,
114 heroAuto: (time: string, span: string) => `${time ? `refreshes in ${time}` : 'refreshing soon'} · up to ~${span}`,
115 heroCold: (size: string, cost: string) => `${size} tokens to rebuild${cost && ` (≈${cost})`}`,
116 heroPinging: 'Refreshing…',
117 rowLapse: 'If it expires',
118 rowPing: 'One refresh',
119 breakEven: (max: number) => `${max} refreshes ≈ 1 rebuild`,
120 breakEvenNone: 'Refreshing costs more than rebuilding here',
121 breakEvenRule: (max: number, percent: string) =>
122 `${max} refreshes ≈ 1 rebuild · worth it if you are ${percent}%+ likely to return`,
123 noPrice: 'Pricing unavailable for this model',
124 autoTitle: 'Auto-refresh while away',
125 btnOn: 'Turn on',
126 btnOff: 'Turn off',
127 autoPlanOff: (left: number, span: string) =>
128 `Up to ${left} ${left === 1 ? 'refresh' : 'refreshes'}${span && ` · ~${span}`}`,
129 autoPlanOn: (used: number, budget: number) => `${used}/${budget} used`,
130 autoTrialOff: (cap: number) => `1 trial, up to ${cap} if it works`,
131 autoTrialOn: (used: number, cap: number) => `Trial ${used}/1, up to ${cap} if it works`,
132 autoSpent: 'None left until next message',
133 autoNotWorth: 'Not worth it for this cache',
134 historyAll: (n: number) => (n === 1 ? 'Last request hit' : `Last ${n} all hit`),
135 historySome: (n: number, misses: number) => `${misses} of last ${n} missed`,
136 historyEmpty: 'No history yet',
137 btnPing: 'Refresh now',
138 listPrice: 'Estimates at API list prices',
139 priceNote: (cap: string) => `Est. max ${cap} · API list prices`,
140
141 langNow: (name: string, source: LangSource) =>
142 `Language: ${name} (${{ pinned: 'manual', conversation: 'matches the conversation', locale: 'matches your system', default: 'default' }[source]}).`,
143 langUsage: (codes: string) => `Usage: /cache-lang [auto|code]. Codes: ${codes}`,
144}
145
146export type Messages = typeof en
147hooks/locales/zh.ts 126 lines1import type { Snapshot } from '../../types'
2import type { Messages } from './en'
3import { makeSpan } from './shared'
4import type { Verdict } from './shared'
5
6const SOURCE_ZH: Record<Snapshot['ttlSource'], string> = {
7 env: '环境变量',
8 observed: '实测',
9 assumed: '默认',
10}
11const BY_ZH: Record<Snapshot['touchedBy'], string> = { turn: '消息', ping: '续期', resume: '恢复会话' }
12const VERDICT_ZH: Record<Verdict, string> = { hit: '命中', partial: '部分命中', miss: '未命中' }
13const spanZh = makeSpan({
14 units: { s: '秒', m: '分钟', h: '小时' },
15 clockMinute: '分',
16})
17
18// The same vocabulary: 有效 until it 过期; 续期 restarts the lifetime; a cache that is 已失效 gets 重建.
19export const zh: Messages = {
20 name: '中文',
21 tag: 'zh-Hans',
22 fonts: "'PingFang SC'",
23
24 bandCold: '已失效',
25 bandUnusable: '已失效',
26 bandWarmDetail: (size, cost) => `已缓存 ${size}${cost && ` · 过期重建约 ${cost}`}`,
27 bandAutoDetail: (size, used, budget) => `已缓存 ${size} · 自动续期 ${used}/${budget}`,
28 bandColdDetail: (size, cost) => `下条消息将重建 ${size} token${cost && `,约 ${cost}`}`,
29
30 cmdStatus: '查看提示缓存状态、剩余时间和预计重建成本',
31 cmdPing: '立即为提示缓存续期',
32 cmdAuto: '查看或设置自动续期',
33 cmdAutoHint: '[on|off] [次数上限]',
34 cmdLang: '设置显示语言',
35 cmdLangHint: '[auto|语言代码]',
36
37 expiredAgo: gap => `已过期 ${spanZh(gap)}`,
38 coldPing: '上次续期未命中',
39 coldModel: model => `模型已切换为 ${model}`,
40
41 pingNothing: '当前对话还没有缓存,无需续期。',
42 pingBusy: '正在续期,请稍候。',
43 pingCold: (why, size) =>
44 `未发送:缓存已失效(${why})。现在发送会按缓存写入价格重建全部 ${size} token。如仍要发送,请运行 /cache-ping force。`,
45 pingNoFork: '未发送:当前对话还没有回复。',
46 pingApiError: (error, status) => `续期失败:API 错误(${error},状态码 ${status ?? '无'})。倒计时未重置。`,
47 pingCut: '续期请求在 API 响应前中断,倒计时未重置。',
48 counts: c => `读取 ${c.read},写入 ${c.wrote},输入 ${c.input},输出 ${c.output}`,
49 atListPrice: usd => `按 API 标价约 ${usd}。`,
50 pingHit: (counts, cost, ttl) => `续期成功:${counts}。${cost}倒计时已重置为 ${spanZh(ttl)}。`,
51 pingRewrote: (counts, cost) => `未命中缓存,缓存已重建:${counts}。${cost}`,
52 pingMissed: (counts, cost) => `未命中缓存:${counts}。${cost}缓存未能续期。`,
53 autoLog: (n, budget, text) => `自动续期 ${n}/${budget}。${text}`,
54 autoMissToast: '自动续期未成功,详见 /cache-status',
55
56 reportNothing: '提示缓存:暂无缓存,下次回复后开始倒计时。',
57 reportWarm: left => `提示缓存:有效,剩余 ${spanZh(left)}。`,
58 reportCold: why => `提示缓存:已失效(${why})。`,
59 reportTtl: (ttl, source) => ` 有效期:${spanZh(ttl)}(${SOURCE_ZH[source]})`,
60 reportCached: (size, model, by, since) =>
61 ` 已缓存:${size} token,模型 ${model},上次使用:${spanZh(since)}前(${BY_ZH[by]})`,
62 reportNoPrice: ' 成本:暂无该模型的定价信息',
63 reportLapse: (lapse, rewrite) => ` 过期成本:重建约 ${rewrite},比命中缓存多 ${lapse}`,
64 reportPing: (ping, isMeasured, max) =>
65 ` 单次续期:约 ${ping}(请求开销为${isMeasured ? '实测' : '估算'}值),连续续期 ${max} 次以内都比过期重建划算`,
66 reportPingNone: (ping, isMeasured) =>
67 ` 单次续期:约 ${ping}(请求开销为${isMeasured ? '实测' : '估算'}值),比过期多花的钱还多,续期不划算`,
68 reportRule: (ttl, percent) => ` 建议:只要你在 ${spanZh(ttl)}内回来的概率高于 ${percent}%,续期就划算`,
69 reportLastPing: (ago, counts) => ` 上次续期:${spanZh(ago)}前,${counts}`,
70 reportTouches: '最近的缓存记录:',
71 reportTouch: (kind, gap, prevBy, ttl, verdict, read, cached) =>
72 ` ${BY_ZH[kind]},距上次${BY_ZH[prevBy]} ${spanZh(gap)}(有效期 ${spanZh(ttl)}):${VERDICT_ZH[verdict]},读取 ${read} / ${cached}`,
73 reportFooter: '以上金额按 API 标价估算;订阅用户消耗的是套餐额度。',
74
75 autoHeader: '自动续期:',
76 autoOff: ' 自动续期:已关闭(运行 /cache-auto on 可在离开时保持缓存有效)',
77 autoWaiting: cap => ` 自动续期:已开启,每次空闲期间最多 ${cap} 次;等待首次回复`,
78 autoOn: (used, budget, cap, next) =>
79 ` 自动续期:已开启,本次空闲期间已用 ${used}/${budget} 次(上限 ${cap} 次);下次续期:${next}`,
80 autoOnNone: cap => ` 自动续期:已开启(上限 ${cap} 次),但暂不发送:当前缓存较小,续期比过期多花的钱还多`,
81 nextCold: '无(缓存已失效)',
82 nextSpent: '无(本次空闲期间的次数已用完)',
83 nextIn: time => `${spanZh(time)}后`,
84 nextNow: '即将发送',
85 autoPings: state => ` 续期效果:${state}`,
86 extendsYes: ttl => `已验证,续期可重新获得 ${spanZh(ttl)}有效期`,
87 extendsNo: '未验证,续期后的下一次请求未命中,因此每次空闲期间只自动续期 1 次',
88 extendsUnknown: ttl => `待验证,确认续期能重新获得 ${spanZh(ttl)}有效期之前,每次空闲期间只自动续期 1 次`,
89 autoNote: ' 注意:仅在应用运行且电脑未休眠时才会续期;每次续期都会消耗套餐额度或产生 API 费用',
90 autoUsage: max => `用法:/cache-auto [on|off] [每次空闲期间的次数上限,1-${max}]`,
91
92 details: '详情 ›',
93 cmdPanel: '打开提示缓存面板',
94 paneTitle: '提示缓存',
95 paneNothing: '暂无缓存,下次回复后开始倒计时。',
96 heroLeft: size => `后过期 · 已缓存 ${size} token`,
97 heroAuto: (time, span) => `${time ? `${spanZh(time)}后续期` : '即将续期'} · 最长约 ${spanZh(span)}`,
98 heroCold: (size, cost) => `重建 ${size} token${cost && `,约 ${cost}`}`,
99 heroPinging: '正在续期…',
100 rowLapse: '过期重建',
101 rowPing: '续期一次',
102 breakEven: max => `续期 ${max} 次 ≈ 重建 1 次`,
103 breakEvenNone: '缓存较小,续期比过期重建更贵',
104 breakEvenRule: (max, percent) => `续期 ${max} 次 ≈ 重建 1 次 · 回来的概率高于 ${percent}% 就划算`,
105 noPrice: '暂无该模型的定价信息',
106 autoTitle: '离开时自动续期',
107 btnOn: '开启',
108 btnOff: '关闭',
109 autoPlanOff: (left, span) => `最多续期 ${left} 次${span && ` · 约 ${spanZh(span)}`}`,
110 autoPlanOn: (used, budget) => `已续期 ${used}/${budget} 次`,
111 autoTrialOff: cap => `先试 1 次,验证后上限 ${cap} 次`,
112 autoTrialOn: (used, cap) => `已试 ${used}/1,验证后上限 ${cap} 次`,
113 autoSpent: '次数已用完,发消息后重置',
114 autoNotWorth: '缓存较小,无需续期',
115 historyAll: n => (n === 1 ? '最近 1 次命中' : `最近 ${n} 次全部命中`),
116 historySome: (n, misses) => `${n} 次中 ${misses} 次未命中`,
117 historyEmpty: '暂无记录',
118 btnPing: '立即续期',
119 listPrice: '金额按 API 标价估算',
120 priceNote: cap => `预计最多 ${cap} · 按 API 标价估算`,
121
122 langNow: (name, source) =>
123 `显示语言:${name}(${{ pinned: '手动设置', conversation: '跟随对话', locale: '跟随系统', default: '默认' }[source]})。`,
124 langUsage: codes => `用法:/cache-lang [auto|语言代码]。可用代码:${codes}`,
125}
126hooks/locales/zh-Hant.ts 105 lines1import type { Snapshot } from '../../types'
2import type { Messages } from './en'
3import { makeSpan } from './shared'
4import type { Verdict } from './shared'
5
6const SOURCE_ZH_HANT: Record<Snapshot['ttlSource'], string> = { env: '環境變數', observed: '實測', assumed: '預設' }
7const BY_ZH_HANT: Record<Snapshot['touchedBy'], string> = { turn: '訊息', ping: '續期', resume: '恢復工作階段' }
8const VERDICT_ZH_HANT: Record<Verdict, string> = { hit: '命中', partial: '部分命中', miss: '未命中' }
9const spanZhHant = makeSpan({ units: { s: '秒', m: '分鐘', h: '小時' }, clockMinute: '分' })
10
11// 有效 → 過期;續期重設有效期,過期後重建。介面使用「你」。
12export const zhHant: Messages = {
13 name: '繁體中文',
14 tag: 'zh-Hant',
15 fonts: "'PingFang TC', 'Microsoft JhengHei'",
16
17 bandCold: '已過期',
18 bandUnusable: '無法使用',
19 bandWarmDetail: (size, cost) => `已快取 ${size}${cost && ` · 過期重建約 ${cost}`}`,
20 bandAutoDetail: (size, used, budget) => `已快取 ${size} · 自動續期 ${used}/${budget}`,
21 bandColdDetail: (size, cost) => `下則訊息將重建 ${size} token${cost && `,約 ${cost}`}`,
22 cmdStatus: '查看提示快取狀態、剩餘時間及預估重建成本',
23 cmdPing: '立即為提示快取續期',
24 cmdAuto: '查看或設定自動續期',
25 cmdAutoHint: '[on|off] [次數上限]',
26 cmdLang: '設定顯示語言',
27 cmdLangHint: '[auto|語言代碼]',
28 expiredAgo: gap => `已過期 ${spanZhHant(gap)}`,
29 coldPing: '上次續期未命中',
30 coldModel: model => `模型已改為 ${model}`,
31 pingNothing: '目前還沒有快取,無需續期。',
32 pingBusy: '正在續期。',
33 pingCold: (why, size) => `未傳送:快取無效(${why})。現在傳送會按快取寫入價格重建全部 ${size} token。如仍要傳送,請執行 /cache-ping force。`,
34 pingNoFork: '未傳送:此對話還沒有回覆。',
35 pingApiError: (error, status) => `續期失敗:API 錯誤(${error},狀態碼 ${status ?? '無'})。倒數計時未重設。`,
36 pingCut: '續期在 API 回應前中斷,倒數計時未重設。',
37 counts: c => `讀取 ${c.read},寫入 ${c.wrote},輸入 ${c.input},輸出 ${c.output}`,
38 atListPrice: usd => `按 API 定價約 ${usd}。`,
39 pingHit: (counts, cost, ttl) => `快取已續期:${counts}。${cost}倒數計時已重設為 ${spanZhHant(ttl)}。`,
40 pingRewrote: (counts, cost) => `快取未命中,已重建:${counts}。${cost}`,
41 pingMissed: (counts, cost) => `快取未命中:${counts}。${cost}快取有效期未延長。`,
42 autoLog: (n, budget, text) => `自動續期 ${n}/${budget}。${text}`,
43 autoMissToast: '自動續期未成功,詳見 /cache-status',
44 reportNothing: '提示快取:尚無快取,下次回覆後開始倒數計時。',
45 reportWarm: left => `提示快取:有效,剩餘 ${spanZhHant(left)}。`,
46 reportCold: why => `提示快取:無效(${why})。`,
47 reportTtl: (ttl, source) => ` 有效期:${spanZhHant(ttl)}(${SOURCE_ZH_HANT[source]})`,
48 reportCached: (size, model, by, since) => ` 已快取:${size} token,模型 ${model},上次使用於 ${spanZhHant(since)}前(${BY_ZH_HANT[by]})`,
49 reportNoPrice: ' 成本:尚無此模型的定價資訊',
50 reportLapse: (lapse, rewrite) => ` 若過期:重建約 ${rewrite},比快取命中多 ${lapse}`,
51 reportPing: (ping, isMeasured, max) => ` 單次續期:約 ${ping}(token 開銷為${isMeasured ? '實測' : '估算'}值)。連續續期 ${max} 次以內比重建便宜`,
52 reportPingNone: (ping, isMeasured) => ` 單次續期:約 ${ping}(token 開銷為${isMeasured ? '實測' : '估算'}值),比過期增加的成本更高,續期不划算`,
53 reportRule: (ttl, percent) => ` 參考:若你在 ${spanZhHant(ttl)}內回來的機率高於 ${percent}%,續期就划算`,
54 reportLastPing: (ago, counts) => ` 上次續期:${spanZhHant(ago)}前,${counts}`,
55 reportTouches: '最近的快取活動:',
56 reportTouch: (kind, gap, prevBy, ttl, verdict, read, cached) => ` ${BY_ZH_HANT[kind]},距上次${BY_ZH_HANT[prevBy]} ${spanZhHant(gap)}(有效期 ${spanZhHant(ttl)}):${VERDICT_ZH_HANT[verdict]},讀取 ${read} / ${cached}`,
57 reportFooter: '成本按 API 定價估算;訂閱方案反映的是方案用量,並非收費金額。',
58 autoHeader: '自動續期:',
59 autoOff: ' 自動續期:已關閉(執行 /cache-auto on 可在離開時保持快取有效)',
60 autoWaiting: cap => ` 自動續期:已開啟,每段閒置期間最多 ${cap} 次;等待首次回覆`,
61 autoOn: (used, budget, cap, next) => ` 自動續期:已開啟,上次訊息後已用 ${used}/${budget} 次(上限 ${cap} 次);下次:${spanZhHant(next)}`,
62 autoOnNone: cap => ` 自動續期:已開啟(上限 ${cap} 次),但不傳送:續期比此快取過期增加的成本更高`,
63 nextCold: '無,快取無效',
64 nextSpent: '無,本段閒置期間的次數已用完',
65 nextIn: time => `${spanZhHant(time)}後`,
66 nextNow: '即將傳送',
67 autoPings: state => ` 續期效果:${state}`,
68 extendsYes: ttl => `已確認,續期可恢復完整的 ${spanZhHant(ttl)}有效期`,
69 extendsNo: '未確認。上次續期後未命中,因此每段閒置期間只自動續期一次',
70 extendsUnknown: ttl => `尚未確認。確認續期能恢復完整的 ${spanZhHant(ttl)}有效期前,每段閒置期間只自動續期一次`,
71 autoNote: ' 注意:僅在應用程式執行且電腦未休眠時傳送續期;每次都會消耗方案用量或 API 額度',
72 autoUsage: max => `用法:/cache-auto [on|off] [每段閒置期間的次數上限,1-${max}]`,
73 details: '詳情 ›',
74 cmdPanel: '開啟提示快取面板',
75 paneTitle: '提示快取',
76 paneNothing: '尚無快取,下次回覆後開始倒數計時。',
77 heroLeft: size => `後過期 · 已快取 ${size} token`,
78 heroAuto: (time, span) => `${time ? `${spanZhHant(time)}後續期` : '即將續期'} · 最長約 ${spanZhHant(span)}`,
79 heroCold: (size, cost) => `重建 ${size} token${cost && `,約 ${cost}`}`,
80 heroPinging: '正在續期…',
81 rowLapse: '過期重建',
82 rowPing: '續期一次',
83 breakEven: max => `續期 ${max} 次 ≈ 重建一次`,
84 breakEvenNone: '此快取續期比重建更貴',
85 breakEvenRule: (max, percent) => `續期 ${max} 次 ≈ 重建一次 · 回來的機率高於 ${percent}% 就划算`,
86 noPrice: '尚無此模型的定價資訊',
87 autoTitle: '離開時自動續期',
88 btnOn: '開啟',
89 btnOff: '關閉',
90 autoPlanOff: (left, span) => `最多續期 ${left} 次${span && ` · 約 ${spanZhHant(span)}`}`,
91 autoPlanOn: (used, budget) => `已續期 ${used}/${budget} 次`,
92 autoTrialOff: cap => `先試一次,有效才增至 ${cap} 次`,
93 autoTrialOn: (used, cap) => `已試 ${used}/1,有效才增至 ${cap} 次`,
94 autoSpent: '次數已用完,傳訊息後重設',
95 autoNotWorth: '此快取續期不划算',
96 historyAll: n => `最近 ${n} 次${n === 1 ? '' : '全'}命中`,
97 historySome: (n, misses) => `${n} 次中 ${misses} 次未命中`,
98 historyEmpty: '尚無記錄',
99 btnPing: '立即續期',
100 listPrice: '金額按 API 定價估算',
101 priceNote: cap => `預估最多 ${cap} · API 定價`,
102 langNow: (name, source) => `顯示語言:${name}(${{ pinned: '手動設定', conversation: '跟隨對話', locale: '跟隨系統', default: '預設' }[source]})。`,
103 langUsage: codes => `用法:/cache-lang [auto|語言代碼]。可用代碼:${codes}`,
104}
105hooks/locales/ja.ts 105 lines1import type { Snapshot } from '../../types'
2import type { Messages } from './en'
3import { makeSpan } from './shared'
4import type { Verdict } from './shared'
5
6const SOURCE_JA: Record<Snapshot['ttlSource'], string> = { env: '環境変数', observed: '実測', assumed: '仮定' }
7const BY_JA: Record<Snapshot['touchedBy'], string> = { turn: 'メッセージ', ping: '延長', resume: 'セッション再開' }
8const VERDICT_JA: Record<Verdict, string> = { hit: 'ヒット', partial: '部分ヒット', miss: 'ミス' }
9const spanJa = makeSpan({ units: { s: '秒', m: '分', h: '時間' }, join: '', space: false })
10
11// 有効 → 期限切れ;延長で有効期間を戻し、期限切れ後は再構築。説明は「です・ます」。
12export const ja: Messages = {
13 name: '日本語',
14 tag: 'ja',
15 fonts: "'Hiragino Sans', 'Yu Gothic UI', 'Meiryo'",
16
17 bandCold: '期限切れ',
18 bandUnusable: '利用不可',
19 bandWarmDetail: (size, cost) => `${size} をキャッシュ${cost && ` · 期限切れ後の再構築は約 ${cost}`}`,
20 bandAutoDetail: (size, used, budget) => `${size} をキャッシュ · 自動延長 ${used}/${budget}`,
21 bandColdDetail: (size, cost) => `次のメッセージで ${size} トークンを再構築${cost && `(約 ${cost})`}`,
22 cmdStatus: 'プロンプトキャッシュの状態、残り時間、再構築の推定コストを表示',
23 cmdPing: 'プロンプトキャッシュの有効期間を今すぐ延長',
24 cmdAuto: '自動延長の設定を表示・変更',
25 cmdAutoHint: '[on|off] [延長回数の上限]',
26 cmdLang: '表示言語を設定',
27 cmdLangHint: '[auto|言語コード]',
28 expiredAgo: gap => `${spanJa(gap)}前に期限切れ`,
29 coldPing: '前回の延長でキャッシュミス',
30 coldModel: model => `モデルを ${model} に変更`,
31 pingNothing: 'まだキャッシュがないため、延長できません。',
32 pingBusy: '延長処理中です。',
33 pingCold: (why, size) => `未送信:キャッシュは有効ではありません(${why})。今送信すると、全 ${size} トークンがキャッシュ書き込み価格で再構築されます。送信するには /cache-ping force を実行してください。`,
34 pingNoFork: '未送信:この会話にはまだ応答がありません。',
35 pingApiError: (error, status) => `延長失敗:API エラー(${error}、ステータス ${status ?? 'なし'})。カウントダウンはリセットされていません。`,
36 pingCut: 'API の応答前に延長が中断されました。カウントダウンはリセットされていません。',
37 counts: c => `読み取り ${c.read}、書き込み ${c.wrote}、入力 ${c.input}、出力 ${c.output}`,
38 atListPrice: usd => `API 定価で約 ${usd}。`,
39 pingHit: (counts, cost, ttl) => `キャッシュを延長しました:${counts}。${cost}カウントダウンを ${spanJa(ttl)}にリセットしました。`,
40 pingRewrote: (counts, cost) => `キャッシュミス。キャッシュを再構築しました:${counts}。${cost}`,
41 pingMissed: (counts, cost) => `キャッシュミス:${counts}。${cost}有効期間は延長されていません。`,
42 autoLog: (n, budget, text) => `自動延長 ${n}/${budget}。${text}`,
43 autoMissToast: '自動延長に失敗しました。/cache-status を確認してください',
44 reportNothing: 'プロンプトキャッシュ:まだありません。次の応答後にカウントダウンが始まります。',
45 reportWarm: left => `プロンプトキャッシュ:有効、残り ${spanJa(left)}。`,
46 reportCold: why => `プロンプトキャッシュ:無効(${why})。`,
47 reportTtl: (ttl, source) => ` 有効期間:${spanJa(ttl)}(${SOURCE_JA[source]})`,
48 reportCached: (size, model, by, since) => ` キャッシュ:${model} の ${size} トークン、最終利用は ${spanJa(since)}前(${BY_JA[by]})`,
49 reportNoPrice: ' コスト:このモデルの価格は不明',
50 reportLapse: (lapse, rewrite) => ` 期限切れ時:再構築は ${rewrite}、キャッシュヒットより ${lapse}高くなります`,
51 reportPing: (ping, isMeasured, max) => ` 延長1回:約 ${ping}(トークンの追加分は${isMeasured ? '実測' : '推定'})。連続 ${max} 回までは再構築1回より安くなります`,
52 reportPingNone: (ping, isMeasured) => ` 延長1回:約 ${ping}(トークンの追加分は${isMeasured ? '実測' : '推定'})。期限切れによる追加コストより高いため、延長は割に合いません`,
53 reportRule: (ttl, percent) => ` 目安:${spanJa(ttl)}以内に戻る確率が ${percent}%を超えるなら延長がお得です`,
54 reportLastPing: (ago, counts) => ` 前回の延長:${spanJa(ago)}前、${counts}`,
55 reportTouches: '最近のキャッシュ利用:',
56 reportTouch: (kind, gap, prevBy, ttl, verdict, read, cached) => ` ${BY_JA[kind]}、前回の${BY_JA[prevBy]}から ${spanJa(gap)}後(${spanJa(ttl)}):${VERDICT_JA[verdict]}、${cached}中 ${read} を読み取り`,
57 reportFooter: 'コストは API 定価での推定値です。サブスクリプションでは請求額ではなく、プラン使用量を表します。',
58 autoHeader: '自動延長:',
59 autoOff: ' 自動延長:オフ(/cache-auto on で離席中もキャッシュを有効に保ちます)',
60 autoWaiting: cap => ` 自動延長:オン、アイドル期間ごとに最大 ${cap} 回。最初の応答を待っています`,
61 autoOn: (used, budget, cap, next) => ` 自動延長:オン、最後のメッセージ以降 ${used}/${budget} 回使用(上限 ${cap} 回)。次回:${spanJa(next)}`,
62 autoOnNone: cap => ` 自動延長:オン(上限 ${cap} 回)、送信なし:このキャッシュでは延長の方が期限切れの追加コストより高くなります`,
63 nextCold: 'なし、キャッシュは無効',
64 nextSpent: 'なし、今回のアイドル期間の上限に到達',
65 nextIn: time => `${spanJa(time)}後`,
66 nextNow: 'まもなく',
67 autoPings: state => ` 延長の効果:${state}`,
68 extendsYes: ttl => `確認済み、延長で有効期間が ${spanJa(ttl)}に戻ります`,
69 extendsNo: '未確認。前回の延長後にミスが発生したため、アイドル期間ごとに1回のみ送信します',
70 extendsUnknown: ttl => `未確認。有効期間が ${spanJa(ttl)}に戻ると確認できるまでは、アイドル期間ごとに1回のみ送信します`,
71 autoNote: ' 注意:アプリが起動中で、PC がスリープしていない間だけ送信します。毎回プラン使用量または API クレジットを消費します',
72 autoUsage: max => `使い方:/cache-auto [on|off] [アイドル期間ごとの延長上限、1-${max}]`,
73 details: '詳細 ›',
74 cmdPanel: 'プロンプトキャッシュのパネルを開く',
75 paneTitle: 'プロンプトキャッシュ',
76 paneNothing: 'まだキャッシュがありません。次の応答後にカウントダウンが始まります。',
77 heroLeft: size => `で期限切れ · ${size} トークン保存済み`,
78 heroAuto: (time, span) => `${time ? `${spanJa(time)}後に延長` : 'まもなく延長'} · 最長約 ${spanJa(span)}`,
79 heroCold: (size, cost) => `${size} トークン再構築${cost && `(約${cost})`}`,
80 heroPinging: '延長中…',
81 rowLapse: '期限切れ時',
82 rowPing: '延長1回',
83 breakEven: max => `延長 ${max} 回 ≈ 再構築1回`,
84 breakEvenNone: 'このキャッシュでは延長より再構築が安価',
85 breakEvenRule: (max, percent) => `延長 ${max} 回 ≈ 再構築1回 · 戻る確率 ${percent}%超ならお得`,
86 noPrice: 'このモデルの価格は不明',
87 autoTitle: '離席中に自動延長',
88 btnOn: 'オン',
89 btnOff: 'オフ',
90 autoPlanOff: (left, span) => `最大 ${left} 回${span && ` · 約 ${spanJa(span)}`}`,
91 autoPlanOn: (used, budget) => `${used}/${budget} 回使用済み`,
92 autoTrialOff: cap => `試行1回、成功なら最大 ${cap} 回`,
93 autoTrialOn: (used, cap) => `試行 ${used}/1、成功なら最大 ${cap} 回`,
94 autoSpent: '次のメッセージまで残り0回',
95 autoNotWorth: 'このキャッシュでは割高',
96 historyAll: n => `直近 ${n} 回${n === 1 ? '' : '全て'}ヒット`,
97 historySome: (n, misses) => `${n} 回中 ${misses} 回ミス`,
98 historyEmpty: '履歴はまだありません',
99 btnPing: '今すぐ延長',
100 listPrice: 'API 定価による推定',
101 priceNote: cap => `推定上限 ${cap} · API 定価`,
102 langNow: (name, source) => `表示言語:${name}(${{ pinned: '手動設定', conversation: '会話に合わせる', locale: 'システムに合わせる', default: '既定' }[source]})。`,
103 langUsage: codes => `使い方:/cache-lang [auto|言語コード]。コード:${codes}`,
104}
105hooks/locales/ko.ts 105 lines1import type { Snapshot } from '../../types'
2import type { Messages } from './en'
3import { makeSpan } from './shared'
4import type { Verdict } from './shared'
5
6const SOURCE_KO: Record<Snapshot['ttlSource'], string> = { env: '환경 변수', observed: '실측', assumed: '가정' }
7const BY_KO: Record<Snapshot['touchedBy'], string> = { turn: '메시지', ping: '갱신', resume: '세션 재개' }
8const VERDICT_KO: Record<Verdict, string> = { hit: '적중', partial: '부분 적중', miss: '미적중' }
9const spanKo = makeSpan({ units: { s: '초', m: '분', h: '시간' }, space: false })
10
11// 활성 → 만료; 갱신으로 유효 시간을 되돌리고, 만료 후에는 재구축. 설명은 합니다체.
12export const ko: Messages = {
13 name: '한국어',
14 tag: 'ko',
15 fonts: "'Apple SD Gothic Neo', 'Malgun Gothic'",
16
17 bandCold: '만료됨',
18 bandUnusable: '사용 불가',
19 bandWarmDetail: (size, cost) => `${size} 캐시됨${cost && ` · 만료 후 재구축 약 ${cost}`}`,
20 bandAutoDetail: (size, used, budget) => `${size} 캐시됨 · 자동 갱신 ${used}/${budget}`,
21 bandColdDetail: (size, cost) => `다음 메시지에서 ${size} 토큰 재구축${cost && ` (약 ${cost})`}`,
22 cmdStatus: '프롬프트 캐시 상태, 남은 시간, 예상 재구축 비용 표시',
23 cmdPing: '지금 프롬프트 캐시 갱신',
24 cmdAuto: '자동 갱신 설정 확인 또는 변경',
25 cmdAutoHint: '[on|off] [최대 갱신 횟수]',
26 cmdLang: '표시 언어 설정',
27 cmdLangHint: '[auto|언어 코드]',
28 expiredAgo: gap => `${spanKo(gap)} 전에 만료됨`,
29 coldPing: '지난 갱신에서 캐시 미적중',
30 coldModel: model => `모델이 ${model}(으)로 변경됨`,
31 pingNothing: '아직 캐시가 없어 갱신할 수 없습니다.',
32 pingBusy: '이미 갱신 중입니다.',
33 pingCold: (why, size) => `전송하지 않음: 캐시가 활성 상태가 아닙니다(${why}). 지금 요청하면 전체 ${size} 토큰을 캐시 쓰기 가격으로 재구축합니다. 그래도 전송하려면 /cache-ping force를 실행하세요.`,
34 pingNoFork: '전송하지 않음: 이 대화에는 아직 응답이 없습니다.',
35 pingApiError: (error, status) => `갱신 실패: API 오류(${error}, 상태 ${status ?? '없음'}). 카운트다운은 초기화되지 않았습니다.`,
36 pingCut: 'API 응답 전에 갱신이 중단되었습니다. 카운트다운은 초기화되지 않았습니다.',
37 counts: c => `읽기 ${c.read}, 쓰기 ${c.wrote}, 입력 ${c.input}, 출력 ${c.output}`,
38 atListPrice: usd => ` API 정가 기준 약 ${usd}.`,
39 pingHit: (counts, cost, ttl) => `캐시 갱신됨: ${counts}.${cost} 카운트다운을 ${spanKo(ttl)}으로 초기화했습니다.`,
40 pingRewrote: (counts, cost) => `캐시 미적중. 캐시를 재구축했습니다: ${counts}.${cost}`,
41 pingMissed: (counts, cost) => `캐시 미적중: ${counts}.${cost} 캐시 유효 시간이 연장되지 않았습니다.`,
42 autoLog: (n, budget, text) => `자동 갱신 ${n}/${budget}. ${text}`,
43 autoMissToast: '자동 갱신 실패. /cache-status를 확인하세요',
44 reportNothing: '프롬프트 캐시: 아직 캐시가 없습니다. 다음 응답 후 카운트다운이 시작됩니다.',
45 reportWarm: left => `프롬프트 캐시: 활성, ${spanKo(left)} 남음.`,
46 reportCold: why => `프롬프트 캐시: 비활성(${why}).`,
47 reportTtl: (ttl, source) => ` 유효 시간: ${spanKo(ttl)} (${SOURCE_KO[source]})`,
48 reportCached: (size, model, by, since) => ` 캐시됨: ${model}의 ${size} 토큰, 마지막 사용 ${spanKo(since)} 전 (${BY_KO[by]})`,
49 reportNoPrice: ' 비용: 이 모델의 가격 정보 없음',
50 reportLapse: (lapse, rewrite) => ` 만료 시: 재구축 ${rewrite}, 캐시 적중보다 ${lapse} 더 소요`,
51 reportPing: (ping, isMeasured, max) => ` 갱신 1회: 약 ${ping} (토큰 추가분 ${isMeasured ? '실측' : '추정'}). 연속 ${max}회까지는 재구축 1회보다 저렴합니다`,
52 reportPingNone: (ping, isMeasured) => ` 갱신 1회: 약 ${ping} (토큰 추가분 ${isMeasured ? '실측' : '추정'}). 만료에 따른 추가 비용보다 커서 갱신이 이득이 아닙니다`,
53 reportRule: (ttl, percent) => ` 기준: ${spanKo(ttl)} 안에 돌아올 확률이 ${percent}%를 넘으면 갱신이 이득입니다`,
54 reportLastPing: (ago, counts) => ` 지난 갱신: ${spanKo(ago)} 전, ${counts}`,
55 reportTouches: '최근 캐시 활동:',
56 reportTouch: (kind, gap, prevBy, ttl, verdict, read, cached) => ` ${BY_KO[kind]}, 이전 ${BY_KO[prevBy]} 이후 ${spanKo(gap)} (${spanKo(ttl)}): ${VERDICT_KO[verdict]}, ${cached} 중 ${read} 읽음`,
57 reportFooter: '비용은 API 정가 기준 추정치입니다. 구독에서는 청구액이 아닌 요금제 사용량을 나타냅니다.',
58 autoHeader: '자동 갱신:',
59 autoOff: ' 자동 갱신: 꺼짐 (자리를 비운 동안 캐시를 활성 상태로 유지하려면 /cache-auto on 실행)',
60 autoWaiting: cap => ` 자동 갱신: 켜짐, 유휴 기간당 최대 ${cap}회. 첫 응답 대기 중`,
61 autoOn: (used, budget, cap, next) => ` 자동 갱신: 켜짐, 마지막 메시지 이후 ${used}/${budget}회 사용 (한도 ${cap}회). 다음: ${spanKo(next)}`,
62 autoOnNone: cap => ` 자동 갱신: 켜짐 (한도 ${cap}회), 전송하지 않음: 이 캐시는 갱신 비용이 만료에 따른 추가 비용보다 큽니다`,
63 nextCold: '없음, 캐시 비활성',
64 nextSpent: '없음, 이번 유휴 기간의 한도 소진',
65 nextIn: time => `${spanKo(time)} 후`,
66 nextNow: '곧 전송',
67 autoPings: state => ` 갱신 효과: ${state}`,
68 extendsYes: ttl => `확인됨, 갱신하면 유효 시간이 ${spanKo(ttl)}으로 복원됩니다`,
69 extendsNo: '미확인. 지난 갱신 이후 캐시 미적중이 발생해 유휴 기간당 한 번만 전송합니다',
70 extendsUnknown: ttl => `아직 미확인. 갱신으로 유효 시간이 ${spanKo(ttl)}으로 복원됨을 확인할 때까지 유휴 기간당 한 번만 전송합니다`,
71 autoNote: ' 참고: 앱 실행 중이고 컴퓨터가 깨어 있을 때만 갱신을 전송하며, 매번 요금제 사용량 또는 API 크레딧을 소비합니다',
72 autoUsage: max => `사용법: /cache-auto [on|off] [유휴 기간당 최대 갱신 횟수, 1-${max}]`,
73 details: '자세히 ›',
74 cmdPanel: '프롬프트 캐시 패널 열기',
75 paneTitle: '프롬프트 캐시',
76 paneNothing: '아직 캐시가 없습니다. 다음 응답 후 카운트다운이 시작됩니다.',
77 heroLeft: size => `남음 · ${size} 토큰 캐시됨`,
78 heroAuto: (time, span) => `${time ? `${spanKo(time)} 후 갱신` : '곧 갱신'} · 최대 약 ${spanKo(span)}`,
79 heroCold: (size, cost) => `${size} 토큰 재구축${cost && ` (약 ${cost})`}`,
80 heroPinging: '갱신 중…',
81 rowLapse: '만료 시',
82 rowPing: '갱신 1회',
83 breakEven: max => `갱신 ${max}회 ≈ 재구축 1회`,
84 breakEvenNone: '이 캐시는 갱신보다 재구축이 저렴합니다',
85 breakEvenRule: (max, percent) => `갱신 ${max}회 ≈ 재구축 1회 · 돌아올 확률 ${percent}% 초과 시 이득`,
86 noPrice: '이 모델의 가격 정보 없음',
87 autoTitle: '자리 비울 때 자동 갱신',
88 btnOn: '켜기',
89 btnOff: '끄기',
90 autoPlanOff: (left, span) => `최대 ${left}회${span && ` · 약 ${spanKo(span)}`}`,
91 autoPlanOn: (used, budget) => `${used}/${budget}회 사용`,
92 autoTrialOff: cap => `1회 시험, 성공 시 최대 ${cap}회`,
93 autoTrialOn: (used, cap) => `시험 ${used}/1, 성공 시 최대 ${cap}회`,
94 autoSpent: '다음 메시지까지 남은 횟수 없음',
95 autoNotWorth: '이 캐시는 갱신이 이득 아님',
96 historyAll: n => `최근 ${n}회${n === 1 ? '' : ' 모두'} 적중`,
97 historySome: (n, misses) => `${n}회 중 ${misses}회 미적중`,
98 historyEmpty: '아직 기록 없음',
99 btnPing: '지금 갱신',
100 listPrice: 'API 정가 기준 추정치',
101 priceNote: cap => `예상 최대 ${cap} · API 정가`,
102 langNow: (name, source) => `표시 언어: ${name} (${{ pinned: '수동 설정', conversation: '대화에 맞춤', locale: '시스템에 맞춤', default: '기본값' }[source]}).`,
103 langUsage: codes => `사용법: /cache-lang [auto|언어 코드]. 코드: ${codes}`,
104}
105