Claude Code mod that counts down the prompt cache, compacts a large conversation just before it expires, and can push a reminder to your phone.

cache-timer is a Claude Code mod that shows how long the prompt cache of the current conversation stays warm, and compacts a large conversation just before that cache expires, so returning to it does not re-send the whole context at the cache-write price.
It runs only in Claude Code, which loads its function hooks (an early-access plugin API). Other agent tools do not load it.
54:12 ━━━━━━──── 1h · 612k/500k reads as the time left on the cache, a bar of the lifetime left, the cache lifetime, and the tokens the next request re-sends against the compaction threshold (the threshold is left out while auto-compaction is off). Icons on the right show whether auto-compaction and notifications are on; the gear opens a row with a switch and threshold picker (300k, 500k, or 700k) for compaction, and a switch and lead-time picker (10, 15, or 20 minutes) for notifications. In the terminal, click them or press ctrl+x tab to focus the band. The band shows in the terminal and in sessions the desktop app runs; viewers that attach over Remote Control, including the iOS app, do not draw mod UI.Mod check / 「Shall I release 0.2.0?」 / — cache expires in 10 min. It goes through Claude Code's own push notification, so it reaches the phone only while the session has Remote Control on and you are not active in it. A 5-minute cache does not notify./compact runs once the cache has the lead time left, at most once per idle period, and reports the result in a toast. A running turn keeps the cache warm on its own, so no compaction runs then. Sessions without a person at the prompt (the desktop app, the SDK, -p) do not let a mod compact directly, so there the mod runs /compact as if you typed it.prompt_cache.ttl, but the function-hook API does not expose it. After each turn the mod reads the tail of the session transcript, where each response records whether its cache write was 1-hour or 5-minute, and uses that. The transcript format is Claude Code's own and may change; when no write is found there, the mod falls back to inference: it starts from 1 hour and, after a gap of 5.5 to 58 minutes on the same model, a cache hit confirms 1 hour and a miss switches to 5 minutes. Pinning cache_ttl overrides both.Each field is a row under /config; a change there or in the band reloads the mod, and the countdown carries over the reload. Sessions the desktop app runs have no /config rows for plugins, so a change made in the band there is kept in the mod's own store and applies to later sessions too; a later /config change to the same field replaces it.
| Field | Default | Meaning |
|---|---|---|
auto_compact | true | Compact before expiry at all |
threshold_k | 500 | Compact only when the context holds at least this many thousand tokens |
notify | true | Send a push notification before a 1-hour cache expires |
notify_lead_minutes | 10 | Minutes before a 1-hour cache expires to notify |
cache_ttl | auto | auto (transcript, then inference), 1h, or 5m |
lead_seconds_1h | 300 | Seconds before a 1-hour cache expires to start compacting |
lead_seconds_5m | 60 | Seconds before a 5-minute cache expires to start compacting |
.claude-plugin/plugin.json Name and settings
hooks/hooks.json Names the hooks module
hooks/register.tsx Countdown, inference, compaction, and the band
hooks/register.test.tsx Mock-clock tests: claude plugin test plugins/cache-timer
types/index.d.ts Type of the cache clock the module keeps
MIT
hooks/register.tsx 530 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import type { CacheClock, CacheTtl } from '../types'
4
5const TTL_MS: Record<CacheTtl, number> = { '5m': 5 * 60_000, '1h': 60 * 60_000 }
6
7// A gap inside this window tells the two lifetimes apart: a 5m cache has
8// lapsed, a 1h cache has not.
9const INFER_MIN_GAP_MS = 5.5 * 60_000
10const INFER_MAX_GAP_MS = 58 * 60_000
11const INFER_MIN_TOKENS = 20_000
12
13const initial: CacheClock = {
14 lastAt: null,
15 tokens: 0,
16 model: null,
17 ttl: '1h',
18 isTtlObserved: false,
19 isTtlExact: false,
20 handledAt: null,
21 notifiedAt: null,
22 title: null,
23 question: null,
24}
25
26// The tail of the transcript read after each turn: enough to reach the
27// turn's last response, well under what one host command returns.
28const TRANSCRIPT_TAIL_BYTES = 512 * 1024
29
30/**
31 * The lifetime of the last main-conversation cache write recorded in the
32 * transcript tail, or null when none is found. The transcript format is
33 * Claude Code's own and may change; callers fall back to inference.
34 */
35export function ttlFromTranscriptTail(tail: string): CacheTtl | null {
36 const lines = tail.split('\n')
37
38 for (let i = lines.length - 1; i >= 0; i -= 1) {
39 let entry: unknown
40 try {
41 entry = JSON.parse(lines[i])
42 } catch {
43 continue
44 }
45 const row = entry as {
46 type?: string
47 isSidechain?: boolean
48 message?: { usage?: { cache_creation?: { ephemeral_1h_input_tokens?: number; ephemeral_5m_input_tokens?: number } } }
49 }
50 const written = row.type === 'assistant' && row.isSidechain !== true ? row.message?.usage?.cache_creation : undefined
51
52 if ((written?.ephemeral_1h_input_tokens ?? 0) > 0) {
53 return '1h'
54 }
55 if ((written?.ephemeral_5m_input_tokens ?? 0) > 0) {
56 return '5m'
57 }
58 }
59
60 return null
61}
62
63// The session's cache clock. Module state, saved to the plugin's store under
64// the session id on each change, so a reload (a /config change, a press in
65// the band) restores it in session.start instead of starting over.
66let clock: CacheClock = initial
67
68const storeKey = (sessionId: string): string => `clock:${sessionId}`
69
70const update = ($: EngineInterface, fn: (prev: CacheClock) => CacheClock): void => {
71 clock = fn(clock)
72 const saved = clock
73 void (async () => {
74 await $.store.set(storeKey(await $.session.id()), saved)
75 })().catch(() => {
76 // An unsaved clock only costs the countdown after a reload.
77 })
78}
79
80const QUESTION_MAX = 140
81
82/**
83 * The question a reply leaves the user with: its last sentence that ends in a
84 * question mark, or else its last paragraph, without Markdown marks and cut to
85 * fit a push notification. Null for an empty reply.
86 */
87export function questionOf(reply: string): string | null {
88 const plain = reply
89 .replace(/```[\s\S]*?```/g, ' ')
90 .replace(/[*_`#>]/g, '')
91 .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
92 const paragraphs = plain.split(/\n\s*\n/).map(p => p.replace(/\s+/g, ' ').trim()).filter(p => p !== '')
93
94 if (paragraphs.length === 0) {
95 return null
96 }
97
98 // A full stop inside a word (a version, a path) does not end a sentence.
99 const sentences = paragraphs.flatMap(p => p.split(/(?<=[。!?])|(?<=[.!?])\s+/)).map(t => t.trim()).filter(t => t !== '')
100 const asked = [...sentences].reverse().find(t => /[??]$/.test(t))
101 const text = asked ?? paragraphs[paragraphs.length - 1] ?? ''
102
103 return text.length > QUESTION_MAX ? `${text.slice(0, QUESTION_MAX - 1)}…` : text
104}
105
106/** What the band shows, set by the clock's tick. */
107type View = {
108 /** Time left (`54:12`), `expired`, `compacting…`, or `—` before a request. */
109 time: string
110 /** Lifetime and tokens against the threshold: `1h · 612k/500k`. */
111 detail: string
112 /** Share of the cache lifetime left, 0 to 1; null when no countdown runs. */
113 left: number | null
114}
115
116let view: View = { time: '—', detail: '', left: null }
117
118// Whether the band shows its settings row; per process, closed after a reload.
119let isExpanded = false
120
121const formatRemaining = (ms: number): string => {
122 const total = Math.max(0, Math.ceil(ms / 1000))
123 const minutes = Math.floor(total / 60)
124 const seconds = String(total % 60).padStart(2, '0')
125
126 return `${minutes}:${seconds}`
127}
128
129const formatK = (tokens: number): string => `${Math.round(tokens / 1000)}k`
130
131let isCompacting = false
132
133const setView = ($: EngineInterface, next: View): void => {
134 if (next.time !== view.time || next.detail !== view.detail || next.left !== view.left) {
135 view = next
136 $.ui.invalidate('ui.render')
137 }
138}
139
140const THRESHOLD_PRESETS_K = [300, 500, 700]
141const NOTIFY_LEAD_PRESETS_MIN = [10, 15, 20]
142
143const BAR_CELLS = 10
144
145// Icons for the desktop band; the terminal draws text instead.
146const ON = '#1D9E75'
147const OFF = '#888780'
148const icon = (paths: string, color: string): string =>
149 `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${paths}</svg>`
150const CLOCK = '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/>'
151const COMPRESS = '<path d="M5 9h4V5M15 5v4h4M5 15h4v4M15 19v-4h4"/>'
152const BELL = '<path d="M10 5a2 2 0 1 1 4 0a7 7 0 0 1 4 6v3a4 4 0 0 0 2 3H4a4 4 0 0 0 2-3v-3a7 7 0 0 1 4-6"/><path d="M9 17v1a3 3 0 0 0 6 0v-1"/>'
153const bar = (left: number): string =>
154 `<svg xmlns="http://www.w3.org/2000/svg" width="80" height="4" viewBox="0 0 80 4"><rect width="80" height="4" rx="2" fill="${OFF}" fill-opacity="0.35"/><rect width="${Math.round(80 * left)}" height="4" rx="2" fill="${left > 0.2 ? ON : '#BA7517'}"/></svg>`
155
156/** The `/config` key of one of this plugin's fields, as the engine names it. */
157async function configKey($: EngineInterface, field: string): Promise<string | undefined> {
158 const rows = await $.config.list()
159
160 return rows.find(row => row.key.startsWith('cache-timer') && row.key.endsWith(`.${field}`))?.key
161}
162
163// Settings changed in the band where `/config` has no rows for them (a session
164// the desktop app runs). Kept in the plugin store across sessions; a `/config`
165// change to the same field drops the entry.
166const OVERRIDES_KEY = 'settings'
167let overrides: Record<string, boolean | number> = {}
168
169const saveOverrides = async ($: EngineInterface): Promise<void> => {
170 try {
171 await $.store.set(OVERRIDES_KEY, overrides)
172 } catch {
173 // Unsaved: the change holds for this process only.
174 }
175}
176
177async function setOption($: EngineInterface, field: string, value: boolean | number): Promise<void> {
178 const key = await configKey($, field)
179
180 if (key === undefined) {
181 overrides = { ...overrides, [field]: value }
182 $.ui.invalidate('ui.render')
183 await saveOverrides($)
184
185 return
186 }
187
188 const result = await $.config.set({ key, value })
189
190 if ('deny' in result && result.deny !== undefined) {
191 $.ui.toast(`cache-timer: ${field} not changed (${result.deny})`)
192 }
193}
194
195// A session with no person at the prompt (-p, the SDK, and so every
196// desktop-app session) refuses $.session.compact(): compaction there runs as
197// a /compact prompt, which $.command.run queues as if the person typed it.
198let isHeadless = false
199
200async function queueCompact($: EngineInterface, s: CacheClock): Promise<void> {
201 // One attempt per idle period: the /compact that runs resets the clock
202 // through the session.compact hook; one that never runs is not repeated.
203 update($, prev => ({ ...prev, handledAt: s.lastAt }))
204
205 try {
206 await $.command.run({ command: 'compact' })
207 $.ui.toast(`cache-timer: ran /compact before cache expiry (${formatK(s.tokens)})`)
208 } catch (err) {
209 $.ui.toast(`cache-timer: could not run /compact (${String(err)})`)
210 }
211}
212
213async function compactBeforeExpiry($: EngineInterface, s: CacheClock): Promise<void> {
214 isCompacting = true
215 setView($, { time: 'compacting…', detail: formatK(s.tokens), left: null })
216
217 try {
218 if (isHeadless) {
219 await queueCompact($, s)
220
221 return
222 }
223
224 const result = await $.session.compact()
225
226 if (result.skip !== undefined) {
227 update($, prev => ({ ...prev, handledAt: s.lastAt }))
228 $.ui.toast(`cache-timer: auto-compaction skipped (${result.skip})`)
229 } else {
230 // The engine skips the caller's own session.compact hook, so the reset
231 // that hook does for other compactions happens here.
232 update($, prev => ({ ...prev, lastAt: null, tokens: result.tokensAfter ?? 0, handledAt: null }))
233 const after = result.tokensAfter === undefined ? '' : ` → ${formatK(result.tokensAfter)}`
234 $.ui.toast(`compacted before cache expiry: ${formatK(s.tokens)}${after}`)
235 }
236 } catch {
237 // A turn is running: compact rejects until it ends, and handledAt stays
238 // unset so a later tick tries again if the turn ends without a response.
239 } finally {
240 isCompacting = false
241 }
242}
243
244export const register: Register = (on, options) => {
245 const option = (field: string, fallback: boolean | number) => overrides[field] ?? options[field] ?? fallback
246 const isAutoCompact = () => option('auto_compact', true) !== false
247 const isNotify = () => option('notify', true) !== false
248 const notifyLeadMin = () => Number(option('notify_lead_minutes', 10))
249 const thresholdK = () => Number(option('threshold_k', 500))
250 const ttlMode = String(options.cache_ttl ?? 'auto')
251 const leadMs: Record<CacheTtl, number> = {
252 '1h': Number(options.lead_seconds_1h ?? 300) * 1000,
253 '5m': Number(options.lead_seconds_5m ?? 60) * 1000,
254 }
255
256 // Tokens against the compaction threshold while auto-compaction is on.
257 const tokensOf = (tokens: number): string => (isAutoCompact() ? `${formatK(tokens)}/${thresholdK()}k` : formatK(tokens))
258
259 const effectiveTtl = (s: CacheClock): { ttl: CacheTtl; label: string } => {
260 if (ttlMode === '1h' || ttlMode === '5m') {
261 return { ttl: ttlMode, label: ttlMode }
262 }
263
264 return { ttl: s.ttl, label: s.ttl }
265 }
266
267 on('turn.step', async function* ($, e, next) {
268 // The cache entry is refreshed when the request is processed, not when
269 // the response ends, so the countdown starts from the send time.
270 const sentAt = await $.clock.now()
271 const result = yield* next(e)
272 const usage = result.usage
273
274 if (e.agentId !== undefined || usage === null) {
275 return result
276 }
277
278 const tokens =
279 usage.input_tokens +
280 usage.cache_read_input_tokens +
281 usage.cache_creation_input_tokens +
282 usage.output_tokens
283
284 update($, prev => {
285 let { ttl, isTtlObserved } = prev
286 const gap = prev.lastAt === null ? 0 : sentAt - prev.lastAt
287 const isComparable =
288 !prev.isTtlExact &&
289 prev.lastAt !== null &&
290 prev.model === usage.model &&
291 prev.tokens >= INFER_MIN_TOKENS &&
292 gap >= INFER_MIN_GAP_MS &&
293 gap <= INFER_MAX_GAP_MS
294
295 if (isComparable) {
296 if (usage.cache_read_input_tokens >= prev.tokens * 0.5) {
297 ttl = '1h'
298 isTtlObserved = true
299 } else if (usage.cache_read_input_tokens <= prev.tokens * 0.1) {
300 ttl = '5m'
301 isTtlObserved = true
302 }
303 }
304
305 return { ...prev, lastAt: sentAt, tokens, model: usage.model, ttl, isTtlObserved }
306 })
307
308 return result
309 })
310
311 // Any compaction (manual, automatic, ours) replaces the cached prefix.
312 on('session.compact', async ($, e, next) => {
313 const result = await next(e)
314
315 if (e.trigger !== 'precompute' && result.messages !== undefined && e.agentId === undefined) {
316 update($, prev => ({
317 ...prev,
318 lastAt: null,
319 tokens: result.tokensAfter ?? 0,
320 handledAt: null,
321 }))
322 }
323
324 return result
325 })
326
327 // At the end of each main turn, take the exact lifetime from the transcript.
328 // The session's name, for the notification.
329 on('classic.UserPromptSubmit', async ($, e, next) => {
330 if (e.session_title !== undefined && e.session_title !== clock.title) {
331 update($, prev => ({ ...prev, title: e.session_title ?? null }))
332 }
333
334 return next(e)
335 })
336
337 on('classic.Stop', async ($, e, next) => {
338 const result = await next(e)
339 update($, prev => ({ ...prev, question: questionOf(e.last_assistant_message ?? '') }))
340
341 try {
342 const ran = await $.process.run(['tail', '-c', String(TRANSCRIPT_TAIL_BYTES), e.transcript_path])
343 const ttl = ran.exitCode === 0 ? ttlFromTranscriptTail(ran.stdout) : null
344
345 if (ttl !== null) {
346 update($, prev => ({ ...prev, ttl, isTtlObserved: true, isTtlExact: true }))
347 }
348 } catch {
349 // No tail command or no transcript: inference keeps the estimate.
350 }
351
352 return result
353 })
354
355 // A /clear or resume starts another conversation in the same process.
356 on('session.end', async ($, e, next) => {
357 try {
358 await $.store.delete(storeKey(e.sessionId))
359 } catch {
360 // A leftover entry is only read back by a session with this id.
361 }
362
363 if (e.reason === 'clear' || e.reason === 'resume') {
364 update($, prev => ({ ...initial, ttl: prev.ttl, isTtlObserved: prev.isTtlObserved, isTtlExact: prev.isTtlExact }))
365 }
366
367 return next(e)
368 })
369
370 // A change made in /config wins over one the band kept in the store.
371 on('config.set', async ($, e, next) => {
372 const result = await next(e)
373 const field = e.key.startsWith('cache-timer') ? e.key.slice(e.key.lastIndexOf('.') + 1) : undefined
374
375 if (field !== undefined && field in overrides && !('deny' in result && result.deny !== undefined)) {
376 const { [field]: _dropped, ...rest } = overrides
377 overrides = rest
378 await saveOverrides($)
379 }
380
381 return result
382 })
383
384 on('session.start', async ($, e, next) => {
385 isHeadless = !e.isInteractive
386
387 try {
388 const saved = await $.store.get(OVERRIDES_KEY)
389
390 if (saved !== null && typeof saved === 'object') {
391 overrides = saved as Record<string, boolean | number>
392 $.ui.invalidate('ui.render')
393 }
394 } catch {
395 // No saved settings: the manifest's values apply.
396 }
397
398 try {
399 const saved = (await $.store.get(storeKey(await $.session.id()))) as CacheClock | undefined
400
401 if (saved !== undefined) {
402 clock = { ...initial, ...saved }
403 }
404 } catch {
405 // Nothing saved: the next request sets the clock.
406 }
407
408 $.clock.every(1000, async () => {
409 if (isCompacting) {
410 return
411 }
412
413 const s = clock
414 const { ttl, label } = effectiveTtl(s)
415
416 if (s.lastAt === null) {
417 setView($, { time: '—', detail: s.tokens > 0 ? tokensOf(s.tokens) : '', left: null })
418
419 return
420 }
421
422 const remaining = TTL_MS[ttl] - ((await $.clock.now()) - s.lastAt)
423 const isLarge = isAutoCompact() && s.tokens >= thresholdK() * 1000
424
425 if (remaining <= 0) {
426 setView($, { time: 'expired', detail: `${label} · ${tokensOf(s.tokens)}`, left: 0 })
427
428 return
429 }
430
431 const willCompact = isLarge && s.handledAt !== s.lastAt
432 setView($, { time: formatRemaining(remaining), detail: `${label} · ${tokensOf(s.tokens)}`, left: remaining / TTL_MS[ttl] })
433
434 // A 1h cache only: a 5m cache would notify after nearly every reply.
435 if (isNotify() && ttl === '1h' && remaining <= notifyLeadMin() * 60_000 && s.notifiedAt !== s.lastAt) {
436 update($, prev => ({ ...prev, notifiedAt: s.lastAt }))
437 const message = [s.title, s.question === null ? null : `「${s.question}」`, `— cache expires in ${Math.ceil(remaining / 60_000)} min`]
438 .filter(line => line !== null && line !== '')
439 .join('\n')
440 void $.tool
441 .call({ tool: 'PushNotification', message, status: 'proactive' } as never)
442 .catch(() => {
443 // No push tool or a refused call: the band still shows the countdown.
444 })
445 }
446
447 if (isLarge && remaining <= leadMs[ttl] && s.handledAt !== s.lastAt) {
448 await compactBeforeExpiry($, s)
449 }
450 })
451
452 return next(e)
453 })
454
455 // The band above the prompt: the countdown, two state icons, and a gear that
456 // opens a row with the compaction and notification settings. A change
457 // writes the setting, which reloads the mod.
458 on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => {
459 // The mobile app has no Select; it has not drawn mod UI so far either.
460 if (e.props.hasSurvey || e.surface === 'mobile') {
461 return next(e)
462 }
463
464 const isDesktop = e.surface === 'desktop'
465 const { Box, Button, Select, Text } = $.ui.resolve(e)
466 const Svg = isDesktop ? $.ui.resolve(e).Svg : undefined
467 const auto = isAutoCompact()
468 const notify = isNotify()
469 const thresholdNow = thresholdK()
470 const leadNow = notifyLeadMin()
471 const thresholds = THRESHOLD_PRESETS_K.includes(thresholdNow) ? THRESHOLD_PRESETS_K : [...THRESHOLD_PRESETS_K, thresholdNow]
472 const leads = NOTIFY_LEAD_PRESETS_MIN.includes(leadNow) ? NOTIFY_LEAD_PRESETS_MIN : [...NOTIFY_LEAD_PRESETS_MIN, leadNow]
473 const filled = view.left === null ? 0 : Math.round(BAR_CELLS * view.left)
474
475 const state = (paths: string, isOn: boolean, word: string, alt: string) =>
476 Svg !== undefined ? (
477 <Svg source={icon(paths, isOn ? ON : OFF)} alt={`${alt} ${isOn ? 'on' : 'off'}`} width={14} height={14} />
478 ) : (
479 <Text color={isOn ? 'success' : 'inactive'}>{word}</Text>
480 )
481
482 return (
483 <Box flexDirection="column">
484 <Box flexDirection="row" justifyContent="space-between" gap={2}>
485 <Box flexDirection="row" alignItems="center" gap={1}>
486 {Svg !== undefined ? <Svg source={icon(CLOCK, OFF)} alt="cache" width={14} height={14} /> : <Text dimColor>cache</Text>}
487 <Text bold>{view.time}</Text>
488 {view.left === null ? null : Svg !== undefined ? (
489 <Svg source={bar(view.left)} alt={`${Math.round(view.left * 100)}% left`} width={80} height={4} />
490 ) : (
491 <Text>
492 <Text color={view.left > 0.2 ? 'success' : 'warning'}>{'━'.repeat(filled)}</Text>
493 <Text dimColor>{'─'.repeat(BAR_CELLS - filled)}</Text>
494 </Text>
495 )}
496 <Text dimColor>{view.detail}</Text>
497 </Box>
498 <Box flexDirection="row" alignItems="center" gap={1}>
499 {state(COMPRESS, auto, 'compact', 'auto-compact')}
500 {state(BELL, notify, 'notify', 'notify')}
501 <Button key="settings" label="⚙" plain dimColor={!isExpanded} onPress={() => {
502 isExpanded = !isExpanded
503 $.ui.invalidate('ui.render')
504 }} />
505 </Box>
506 </Box>
507 {isExpanded ? (
508 <Box flexDirection="row" alignItems="center" gap={1}>
509 <Button key="auto" label="compact" variant={auto ? 'primary' : 'secondary'} onPress={() => setOption($, 'auto_compact', !auto)} />
510 <Select
511 key="threshold"
512 options={thresholds.map(k => ({ value: String(k), label: `≥${k}k` }))}
513 value={String(thresholdNow)}
514 onSelect={(value: string) => setOption($, 'threshold_k', Number(value))}
515 />
516 <Text dimColor>│</Text>
517 <Button key="notify" label="notify" variant={notify ? 'primary' : 'secondary'} onPress={() => setOption($, 'notify', !notify)} />
518 <Select
519 key="notify_lead"
520 options={leads.map(m => ({ value: String(m), label: `${m}m before` }))}
521 value={String(leadNow)}
522 onSelect={(value: string) => setOption($, 'notify_lead_minutes', Number(value))}
523 />
524 </Box>
525 ) : null}
526 </Box>
527 )
528 })
529}
530types/index.d.ts 25 lines1export type CacheTtl = '5m' | '1h'
2
3export type CacheClock = {
4 /** When the last main-thread model request was sent (ms since epoch); null after a compaction or before the first response. */
5 lastAt: number | null
6 /** Prompt tokens the next request re-sends. */
7 tokens: number
8 /** Model that answered last; a switch forfeits the cache, so inference skips it. */
9 model: string | null
10 /** Cache lifetime, and whether it is confirmed (by the transcript or a hit or miss) or assumed. */
11 ttl: CacheTtl
12 isTtlObserved: boolean
13 /** True once the transcript reported the lifetime of a cache write; inference then stands aside. */
14 isTtlExact: boolean
15 /** The lastAt value an automatic compaction was already attempted for. */
16 handledAt: number | null
17 /** The lastAt value an expiry notification was already sent for. */
18 notifiedAt: number | null
19 /** The session's name, as the last prompt reported it. */
20 title: string | null
21 /** What the last reply asked the user, for the notification. */
22 question: string | null
23}
24
25