SLOPSHOPPER

cache-refresher

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…

newpanebandcommandtoastmodel
★ 2v0.2.0MITupdated 2026-10-06olddonkey/cache-refresher
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cache-refresher
│ ┃ Prompt cache ✕ › fix the failing auth test and add an audit log call │ ┃ Nothing cached yet. The countdown starts │ ┃ after the next response. ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ──────────────────────────── ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Auto-refresh while away [ Turn on ] ⏺ Bash(bun test) │ ┃ Up to 12 refreshes [ − ] 12 [ + ] ⎿ 3 pass, 1 fail │ ┃ │ ┃ No history yet ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /cache-status │ ⎿ cache-refresher: Prompt cache: nothing cached yet. The countdown │ ⎿ cache-refresher: Auto-refresh: off (run /cache-auto on to keep │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Prompt cache
Nothing cached yet. The countdown starts after the next response. ──────────────────────────── Auto-refresh while away [ Turn on ] Up to 12 refreshes [ − ] 12 [ + ] No history yet
README

cache-refresher

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.

中文说明

What you get

  • A status bar above the prompt: a ring that drains with the time left, the countdown, how many tokens are cached and what a rebuild would cost.
  • A panel (/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.
  • Auto-refresh, off by default: shortly before the cache would expire, the mod sends one small request that reads the cache, which restarts its lifetime. It stops at the limit you set. When the model's price is known, it also stops before the refreshes would cost more than one expiry adds. Until a refresh has been seen to extend the cache, it sends only one per idle period.
  • 43 languages, the same set the Claude desktop app ships. The mod matches the language Claude is replying in; /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.

Commands

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

Examples

  • See what stepping away costs. After a long turn, run /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.
  • Keep the cache active over lunch. Run /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.
  • Extend the cache before a meeting. Run /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.

The arithmetic

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.

What it reads, stores and sends

  • Sends: a refresh is a request to the same model over your conversation's existing prefix, made through Claude Code ($.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.
  • Reads: the token usage of each main-conversation response; the text of each main-conversation reply as it passes, only to count its Chinese and Latin letters; the environment variables 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.
  • Stores, in the plugin's own local store: up to 100 records of cache activity (time, token counts, model, hit or miss), whether a refresh has been seen to extend the cache, the language Claude last replied in (en or zh), and a language you set yourself. No message text is stored.
  • No network requests of its own, no telemetry, no shell commands, no dependencies.

What each hook does

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 |

Requirements and limits

  • Claude Code v2.1.287 or later: this plugin is a mod. Tested on Claude Code 2.1.288 in the terminal and in the desktop app's Code tab. The mods API is new and may change.
  • Auto-refresh is per session and starts off. Until the mod has seen a refresh actually extend the cache, it sends one refresh per idle period and no more.
  • Only the main conversation is tracked. Subagents keep caches of their own.
  • After compaction the mod starts tracking afresh, and after a model switch it shows the cache as unavailable: what was cached no longer matches.

Install

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 .

Troubleshooting

  • No status bar above the prompt. It appears after the first response of a session, and only once at least 1,024 tokens are cached. Check that claude --version is 2.1.287 or later and that claude plugin list shows the plugin enabled.
  • The countdown uses the wrong lifetime. /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.
  • A refresh was refused, or reported a miss. A refused refresh sends nothing and costs nothing. A miss means the cache had already expired or no longer matched; that request was billed like any other, and your next message rebuilds the cache. /cache-ping force sends the request anyway, which rebuilds the cache now.
  • Auto-refresh sent one refresh and stopped. That is the trial: the rest, up to your limit, unlocks once a refresh has been seen to extend the cache.
  • The panel is in the wrong language. /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.
  • No ring in the terminal. The terminal draws the same information as text; the ring and the animations are the desktop app's.

Support

Questions, bugs and security concerns: open an issue at https://github.com/olddonkey/cache-refresher/issues.

Develop

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 费用。

  • 输入框上方的状态条:随时间变短的圆环、倒计时、已缓存的 token 数和过期后的重建成本。
  • 面板(/cache-panel 或状态条上的“详情 ›”):倒计时、过期重建与续期一次的成本对比、盈亏平衡点、自动续期开关与次数上限、“立即续期”按钮和最近几次请求的缓存命中情况。
  • 自动续期默认关闭,按会话生效。开启后会在缓存快过期时发送一次续期请求,次数不超过你设的上限;已知模型价格时,总成本也不会超过一次过期多花的钱。在确认续期有效之前,每次空闲期间只续期 1 次。
  • 支持 43 种语言(与 Claude 桌面应用一致),默认跟随对话语言;也可以用 /cache-lang 加语言代码手动设置。除中英文外的译文为机器翻译,未经母语者校对,欢迎指正。
  • 续期请求通过 Claude Code 发送到当前模型,内容是现有对话的前缀;除此之外插件不发起任何网络请求,也没有遥测。只在每条回复经过时统计中英文字符来判断语言,不保存回复内容,也不读取设置文件、对话记录或任何凭据。

需要 Claude Code v2.1.287 及以上。

License

MIT

Source 52 files
hooks/register.tsx 589 lines
1import { 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}
589
hooks/detect.ts 80 lines
1import { 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}
80
hooks/economics.ts 119 lines
1import 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}
119
hooks/messages.ts 529 lines
1import { 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(?![-_](?:hant|tw|hk|mo)(?:[-_.@]|$))(?:[-_]|$)|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}
529
hooks/model.ts 117 lines
1import 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}
117
hooks/report.ts 77 lines
1import { 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}
77
hooks/views.ts 705 lines
1import 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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}
705
hooks/locales/en.ts 147 lines
1import 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
147
hooks/locales/zh.ts 126 lines
1import 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}
126
hooks/locales/zh-Hant.ts 105 lines
1import 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}
105
hooks/locales/ja.ts 105 lines
1import 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}
105
hooks/locales/ko.ts 105 lines
1import 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