Observes prompt-cache usage, keeps the session's and its finished subagents' caches warm, and runs 5m/1h TTL A/B experiments.

Claude Code mod(function hooks 插件):观察 prompt cache、在同一进程内保活、跑 5m / 1h TTL 的 A/B 实验并出报告。按《Claude Code 缓存保活与 5 分钟缓存 A/B 测试实施规范》v1.0 实现,针对 Claude Code 2.1.288 的 Mods API,2026-10-08 在 2.1.293 上复验(validate、test、transcript 解析)。
保活只能用 $.model.fork:缓存按模型和前缀存,换成 Haiku 或 $.model.complete({ cache: true }) 的请求是另一条 5 分钟缓存,续不了主会话的 1 小时条目。
/plugin install cache-keeper --marketplace wdsewdser-bit/claude-code-mods
| 命令 | 作用 | |
|---|---|---|
/cache-keeper status | 版本、模型、TTL(请求值 / 日志实测值)、缓存前缀、idle、meter | |
/cache-keeper start / stop | 开 / 关保活。默认开;stop 只关当前 session | |
| `/cache-keeper anchor [on | off]` | 查看 / 设置当前会话是否为锚点(见「锚点会话」);on 会顺带开启保活,off 不动保活开关 |
/cache-keeper probe | 立刻做一次 $.model.fork 探测,报告命中情况与 meter 前后值 | |
/cache-keeper agents | 子代理的 TTL、被跟踪的子代理和保活计数;agents ping 立刻给每个都保活一次 | |
/cache-keeper experiment preflight | 不花 token 的实验前检查 | |
/cache-keeper experiment <plan> | 在当前 session 跑一个实验 cell(见下) | |
/cache-keeper experiment abort | 中止当前实验 | |
/cache-keeper report | 生成 cache-events.csv、experiment-summary.json、cache-report.md |
保活默认开启,只在全部成立时发一次 $.model.fork({ prompt: 'Reply with OK only.' }):没有被 stop;没有 turn 在跑;最近一次主线程请求的缓存前缀(read + write)≥ minCacheReadTokens;距该请求开始已到阈值(5m:245 s,1h:3120 s)但未超过 TTL;meter 未到 maxRateLimitPercent;没有处于"待重建基线"。
maxIdleKeepalives,默认 3(1h TTL 下约覆盖 4 小时闲置),之后任缓存过期;设 0 = 按 API 单价的 break-even(Opus 5.5:5m 24 次、1h 39 次;Sonnet 5.5:5m 24 次)。这是 API 口径的启发式,不是套餐额度结论。maxConsecutiveProbeFailures 次 miss / 失败,或 5 小时额度超阈值:自动停止并提示。周额度不参与:它没用完时停保活只会换来一次整段重写,用完后请求失败,按失败次数停。/clear 后等待新基线。claude --resume 进程,不碰认证 token,不调用私有接口。子代理的缓存和主会话是两套:前缀不同,互相不续期;TTL 也分开,引擎默认给主会话以外的请求(子代理、workflow、后台请求)5 分钟。要 1 小时,在 ~/.claude/settings.json 里写 "subagentPromptCacheTtl": "1h",或设环境变量 CLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL=1h(优先于设置)。settings.json 只要有一处语法错误,整个文件都不生效,引擎不报错。
一个子代理跑完后,mod 按它最后一次请求计时,到阈值(1h:3120 s,5m:245 s)就用 $.session.send 把它唤起一次,只让它回 OK:这次请求读它自己的缓存前缀,TTL 重新计。每个子代理最多 subagentKeepalives 次(默认 2,1h 下约覆盖 2.7 小时;0 = 关),主模型再给它派活后重新计数。
minCacheReadTokens 以上的前缀;主保活被 stop、或 meter 到 maxRateLimitPercent 时不发。prompt.submit 把它丢掉,主模型不会被叫起来。transcript 里留一行 "Prompt dropped by a hook: cache-keeper kept subagent … warm"。maxConsecutiveProbeFailures 次就整个停掉子代理保活。subagents/agent-<id>.meta.json 认;引擎自己的 fork(compact 等)两处都没有,不会被唤起。agent 定义里单独写的 cacheTtl 这里看不到。请求值按引擎自己的优先级读取:FORCE_PROMPT_CACHING_5M > CLAUDE_CODE_PROMPT_CACHE_TTL > 设置项 promptCacheTtl > 自动(订阅内 1h)。
Mods API 的 usage 只有四个 token 计数,没有 5m/1h 拆分。所以实测值来自 transcript:每个 turn 结束后读 message.usage.cache_creation.ephemeral_5m/1h_input_tokens 和 message.diagnostics.cache_miss_reason,按"四个计数相同 + 时间最近"配到在线记录上。请求值与实测值不符时标 TTL_CONFIG_MISMATCH,该 run 不进入成本比较。没有请求值时,保活按实测值排程;两者都没有就不保活。
无人值守跑一组(CLI 需先 claude auth login 登录一次):
node "$HOME/.claude/skills/cache-keeper/scripts/run-experiments.ts" --dry-run
去掉 --dry-run 即开跑。默认组是 A、B 各一次加 C1 每个 TTL 三次(约 3.8 小时);--set smoke 只跑 A@5m(2 分钟),--set full 再加 C2 和 D(约 11.6 小时);--cells keeper:5m,keeper-control:1h --reps 3 自选。它为每个 cell 起一个全新的无头 session、设好 TTL、等 mod 写出 run 记录后关掉,第一个 cell 失败或 meter 到 85% 就停,最后生成报告。
手动跑单个 cell:每个 cell 用一个全新 session,TTL 在启动前定好:
& "$HOME\.claude\skills\cache-keeper\scripts\run-cell.ps1" -Ttl 5m
session 内先 /cache-keeper experiment preflight,再跑一个 plan:
| plan | 实验 | 时长 | 内容 |
|---|---|---|---|
ttl | A | 1.5 min | 建缓存,90 s 后 probe。5m 和 1h 各跑一次 |
renew | B | 12 min | 5m:4:00、8:00 保活,12:00 probe |
renew-control | B 对照 | 6.5 min | 5m:不保活,6:30 probe |
active | C1 | 33 min | 每 3 分钟一次 probe,共 11 次。5m 和 1h 各 3 次 |
intermittent | C2 | 50 min | 每 10 分钟一次 probe,共 5 次。5m 和 1h 各 3 次 |
keeper | D2 | 18 min | 5m:每 4:05 保活,18:00 probe |
keeper-control | D1 | 18 min | 不保活,18:00 probe。5m 是对照,1h 是"直接用 1h"基线 |
"建缓存"是提交一条带约 60k token 静态填充的消息(tokens=N 可调),填充以本次 run 的 nonce 开头,所以不同 run 不会读到彼此的缓存。benchmark probe 是真实 turn(写进 transcript,能拿到 5m/1h 拆分),keeper 步骤是 fork。
实验期间不要用这个账号做别的事:本 session 的 subagent、实验外的 turn、其他装了本 mod 的 Claude Code session 的请求都会让该 run 标记为 contaminated 并排除出主统计。网页和手机端的使用从这里看不到,需要自己保证。
所有 session 共用一个目录,默认 ~/.claude/cache-keeper/(dataDir 可改;写相对路径就落在各 session 的工作目录下):
~/.claude/cache-keeper/
cache-events.csv 每个请求一行(规范 §13 的字段,另加 result、cost_basis)
experiment-summary.json 按 cell 汇总 + 对比 + 结论
cache-report.md
raw/
events-<session>-<n>.jsonl 在线记录,含 meter 前后值
run-<实验>-<ttl>-r<n>-<session>.json
报告把两种成本分开:API 等价成本按官方单价(2026-10-08 核对,含 Haiku 5.5 按 prompt 长度的两档)由日志算;套餐额度只看 $.session.usage().rateLimits 的百分比增量。meter 判定要求每侧至少 3 次干净重复、且增量至少 2 个百分点,否则写"无法判断"。
离线工具(Node 24+,只读 transcript):
node scripts/analyze-transcript.ts <session.jsonl>
node scripts/analyze-transcript.ts --report <dataDir>
cost_basis 会标 assumed-*)。cache_miss_reason 看出来。node scripts/analyze-transcript.ts --tail 读尾部;没有 node 就拿不到拆分。claude plugin validate 和 claude plugin test。5 小时额度到 bridgeAtPercent(默认 90)时:给主会话发一条收尾消息(只完成当前一步、写交接、停下);之后按 TTL 持续保活到额度重置(不受 maxIdleKeepalives 限制,不设百分比停止线(额度再高也保,直到重置);距重置够近、缓存撑得到重置后的「继续」就不再多 ping);重置后约 20 秒自动发「继续」。
stop(命令或按钮)取消桥接。/cache-keeper start|stop,只作用于当前会话。weeklyHandoffAtPercent(默认 98,0 = 关)时,给最近 10 分钟内有用户消息、或仍在跑回合、或仍有后台任务的主会话发一条通知:写一份交接检查点(更新已有台账,没有就新建 HANDOFF.md),然后继续干活。每个周窗口只发一次;不叫停、不保活到重置、重置后不自动继续,由新对话按文档接手。与保活开关无关。bridgeEnabled、bridgeAtPercent、weeklyHandoffAtPercent。逻辑在 hooks/lib/bridge.ts,测试在 tests/bridge.test.ts。$.state 里发布 cache-keeper.cache:{ lastActivityAt, ttl, expiresAt, cachedPrefixTokens, isEnabled, pausedReason, bridgePhase }。expiresAt 之后缓存就是冷的;bridgePhase 为 wrapped 表示已发收尾、正在等 5 小时额度重置。别的 mod 把 cache-keeper 列进 dependencies 后用 $.state.get({ plugin: 'cache-keeper', key: 'cache' }) 读,只读。next)时,本 mod 的 session.compact 钩子可能收不到。另加两道判断,任一成立就按"已压缩"暂停保活,直到下一次真实请求再次命中:引擎存下压缩边界行(session.append,door: compaction);或保活前发现当前上下文不到所记前缀的一半。用途:让一个几乎空的会话长期挂着,靠保活让跨会话共享的系统前缀缓存不过期,别的会话开局就能命中它。
/cache-keeper anchor on(保活原本是关的则一并开启),或点输入框上方栏里的「设为锚点」;anchor off 或「取消锚点」只清掉锚点标记,不改保活开关;/cache-keeper anchor 查看当前状态。栏文字在锚点开启时带「锚点」。状态跨重载保存。maxIdleKeepalives 限制(status 里 budget 显示 unbounded),也没有 5 小时额度的百分比停止线(与额度桥接期间一致)。其余停止条件不变:被 stop、连续失败、压缩暂停、前缀小于 minCacheReadTokens。hooks/register.tsx 2021 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register, Timer } from 'claude-code'
3
4import {
5 describeBridge,
6 fiveHour,
7 handoffText,
8 initialBridge,
9 countInFlight,
10 isBackgroundBusy,
11 needsPing,
12 resetMs,
13 resumeText,
14 resumeVerdict,
15 sevenDay,
16 shouldHandoff,
17 shouldWrap,
18 windowKey,
19 wrapText,
20} from './lib/bridge.ts'
21import type { Bridge } from './lib/bridge.ts'
22
23import {
24 classifyUsage,
25 effectiveTtl,
26 isTtlMismatch,
27 observedTtl,
28 requestedSubagentTtl,
29 requestedTtl,
30} from './lib/classify.ts'
31import type { ObservedTtl, SubagentTtlRequest, TtlRequest } from './lib/classify.ts'
32import {
33 buildPrompt,
34 marker,
35 planDurationSec,
36 planNamed,
37 PLANS,
38 probePromptFor,
39} from './lib/experiment.ts'
40import type { Plan, StepKind } from './lib/experiment.ts'
41import {
42 cacheStateOf,
43 decide,
44 decideAgent,
45 describeKeeper,
46 hasShrunk,
47 initialKeeperState,
48 isOverStopLine,
49 unboundedView,
50} from './lib/keeper.ts'
51import type {
52 AgentTrack,
53 Decision,
54 KeeperConfig,
55 KeeperState,
56 RateLimit,
57} from './lib/keeper.ts'
58import { matchSplits, parseTranscript } from './lib/parser.ts'
59import {
60 apiEquivalentCost,
61 breakEvenKeepalives,
62 ZERO_USAGE,
63} from './lib/pricing.ts'
64import type { CacheSplit, Ttl, Usage } from './lib/pricing.ts'
65import { parseJsonl, renderReport, summarize, toCsv } from './lib/report.ts'
66import type { EventRow, RunRecord, RunStep } from './lib/report.ts'
67
68const COMMAND = 'cache-keeper'
69const TICK_MS = 5_000
70const CHUNK_ROWS = 1_000
71const MIN_VERSION = [2, 1, 242] as const
72const DEFAULT_FIXTURE_TOKENS = 60_000
73const STALE_MS = 14 * 24 * 3_600_000
74// One place for every session, whatever project it runs in.
75const DEFAULT_DATA_DIR = '~/.claude/cache-keeper'
76const METER_KEY = 'meter:last'
77// What the bar above the prompt reads: a write redraws it.
78const barAtom = atom({ plugin: 'cache-keeper', key: 'bar' } as const, '')
79// What another mod reads to tell a warm cache from a cold one.
80const cacheAtom = atom(
81 { plugin: 'cache-keeper', key: 'cache' } as const,
82 cacheStateOf(initialKeeperState(false), 'idle'),
83)
84const METER_CARRY_MS = 120_000
85// What a finished subagent is resumed with to have its cached prefix read
86// again. It answers in its own loop; the notice of that never reaches the model.
87const AGENT_PING =
88 'Cache keepalive from the cache-keeper plugin. Nothing has changed and nothing is asked of you: reply with OK only and call no tools.'
89const PING_TIMEOUT_MS = 120_000
90const QUIET_MS = 600_000
91
92type Config = KeeperConfig & {
93 enabled: boolean
94 dataDir: string
95 maxIdleKeepalives: number
96 subagentKeepalives: number
97 bridgeEnabled: boolean
98 bridgeAtPercent: number
99 weeklyHandoffAtPercent: number
100 probePrompt: string
101 writeCsv: boolean
102 writeJson: boolean
103 showStatus: boolean
104}
105
106type Tag = {
107 experimentId: string
108 replicate: number
109 note: StepKind
110 stepIndex: number
111}
112
113type Run = {
114 plan: string
115 experimentId: string
116 replicate: number
117 nonce: string
118 requestedAt: number
119 /** When the build request started; the plan's steps count from it. */
120 startedAt: number | null
121 next: number
122 isInFlight: boolean
123 meterStart: RateLimit[]
124 contamination: string[]
125 warnings: string[]
126 steps: RunStep[]
127 fixtureTokens: number
128}
129
130type Session = {
131 id: string
132 version: string
133 model: string
134 effort: string
135 requested: TtlRequest
136 subagent: SubagentTtlRequest
137 /** The subagents whose cache is worth keeping, by agent id. */
138 agents: Map<string, AgentTrack>
139 agentHits: number
140 agentMisses: number
141 agentFailures: number
142 agentStopNote: string | null
143 /** Pinged subagents whose notice of finishing is not for the model: id, when. */
144 quiet: Map<string, number>
145 observed: ObservedTtl
146 keeper: KeeperState
147 baseline: { model: string; effort: string } | null
148 transcriptPath: string | null
149 rows: EventRow[]
150 chunk: number
151 keeperHits: number
152 keeperMisses: number
153 run: Run | null
154 pending: (Tag & { marker: string; since: number }) | null
155 tags: Map<string, Tag>
156 stopNote: string | null
157 /** Whether the person, or an automatic stop, set the keeper on or off here. */
158 isEnabledDecided: boolean
159 lastStatus: string | undefined
160 heartbeatAt: number
161 /** The last user or assistant message of the main thread, in ms. */
162 lastRealAt: number | null
163 bridge: Bridge
164 /** No keepalive before this: the fork found no request of this process to share yet. */
165 forkRetryAt: number
166 /** Background tasks this main thread launched whose task-notification has not come back. */
167 backgroundPending: number
168 /** When the last of them was launched, in ms. */
169 backgroundSeenAt: number | null
170 /** An anchor session is kept warm without the idle cap, to hold a shared prefix. */
171 isAnchor: boolean
172 /** The expiry of an anchor's cache was already announced; not saved. */
173 isAnchorExpiryNoted: boolean
174}
175
176const isRecord = (value: unknown): value is Record<string, unknown> =>
177 typeof value === 'object' && value !== null && !Array.isArray(value)
178
179const numberOf = (value: unknown, fallback: number): number =>
180 typeof value === 'number' && Number.isFinite(value) ? value : fallback
181
182const stringOf = (value: unknown, fallback: string): string =>
183 typeof value === 'string' && value !== '' ? value : fallback
184
185const booleanOf = (value: unknown, fallback: boolean): boolean =>
186 typeof value === 'boolean' ? value : fallback
187
188const messageOf = (error: unknown): string =>
189 error instanceof Error ? error.message : String(error)
190
191const attempt = async <T,>(run: () => Promise<T>, fallback: T): Promise<T> => {
192 try {
193 return await run()
194 } catch {
195 return fallback
196 }
197}
198
199const iso = (ms: number): string => new Date(ms).toISOString()
200
201const minutes = (ms: number): string => {
202 const seconds = Math.max(0, Math.round(ms / 1000))
203
204 return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
205}
206
207const readConfig = (options: PluginOptions): Config => ({
208 enabled: booleanOf(options.enabled, true),
209 dataDir: stringOf(options.dataDir, DEFAULT_DATA_DIR).replace(/[\\/]+$/, ''),
210 minCacheReadTokens: numberOf(options.minCacheReadTokens, 40_000),
211 ttl5mKeepaliveAtSec: numberOf(options.ttl5mKeepaliveAtSec, 245),
212 ttl1hKeepaliveAtSec: numberOf(options.ttl1hKeepaliveAtSec, 3_120),
213 maxRateLimitPercent: numberOf(options.maxRateLimitPercent, 90),
214 maxConsecutiveProbeFailures: numberOf(options.maxConsecutiveProbeFailures, 2),
215 maxIdleKeepalives: numberOf(options.maxIdleKeepalives, 3),
216 bridgeEnabled: booleanOf(options.bridgeEnabled, true),
217 bridgeAtPercent: numberOf(options.bridgeAtPercent, 90),
218 weeklyHandoffAtPercent: numberOf(options.weeklyHandoffAtPercent, 98),
219 subagentKeepalives: numberOf(options.subagentKeepalives, 2),
220 probePrompt: stringOf(options.probePrompt, 'Reply with OK only.'),
221 writeCsv: booleanOf(options.writeCsv, true),
222 writeJson: booleanOf(options.writeJson, true),
223 showStatus: booleanOf(options.showStatus, true),
224})
225
226const usageOf = (row: EventRow): Usage => ({
227 input_tokens: row.input_tokens,
228 output_tokens: row.output_tokens,
229 cache_read_input_tokens: row.cache_read_input_tokens,
230 cache_creation_input_tokens: row.cache_creation_input_tokens,
231})
232
233const splitOf = (row: EventRow): CacheSplit | null =>
234 row.ephemeral_5m_input_tokens === null
235 ? null
236 : {
237 ephemeral_5m_input_tokens: row.ephemeral_5m_input_tokens,
238 ephemeral_1h_input_tokens: row.ephemeral_1h_input_tokens ?? 0,
239 }
240
241const isAtLeast = (version: string, floor: readonly number[]): boolean => {
242 const parts = version.split(/[.-]/).map(Number)
243 for (const [index, wanted] of floor.entries()) {
244 const have = parts[index] ?? 0
245 if (Number.isNaN(have)) return false
246 if (have !== wanted) return have > wanted
247 }
248
249 return true
250}
251
252// Module state: one session per load. register() resets it, as a reload does.
253let config: Config = readConfig({})
254let booting: Promise<Session> | undefined
255let carriedEnabled: boolean | undefined
256let timer: Timer | undefined
257let isTicking = false
258
259const debug = ($: EngineInterface, error: unknown): void => {
260 $.ui.log(`error: ${messageOf(error)}`, { to: 'debug' })
261}
262
263const eventsPath = (s: Session): string =>
264 `${config.dataDir}/raw/events-${s.id}-${s.chunk}.jsonl`
265
266const limitsNow = async ($: EngineInterface): Promise<RateLimit[]> =>
267 attempt(async () => [...(await $.session.usage()).rateLimits], [])
268
269const boot = async ($: EngineInterface): Promise<Session> => {
270 if (config.dataDir.startsWith('~')) {
271 const home =
272 (await attempt(() => $.env.get('USERPROFILE'), undefined)) ??
273 (await attempt(() => $.env.get('HOME'), undefined))
274 const under = `${home?.replace(/[\\/]+$/, '')}${config.dataDir.slice(1)}`
275 config = {
276 ...config,
277 dataDir:
278 home === undefined
279 ? '.cache-keeper'
280 : home.includes('\\')
281 ? under.replaceAll('/', '\\')
282 : under,
283 }
284 }
285 const id = await attempt(() => $.session.id(), 'unknown')
286 const version = await attempt(
287 async () => (await $.session.version()).version,
288 'unknown',
289 )
290 const settings = await attempt(
291 async () => (await $.settings.read()) as unknown as Record<string, unknown>,
292 {},
293 )
294 const force5m = await attempt(() => $.env.get('FORCE_PROMPT_CACHING_5M'), undefined)
295 const s: Session = {
296 id,
297 version,
298 model: await attempt(() => $.session.model(), 'unknown'),
299 effort: '',
300 requested: requestedTtl({
301 force5m,
302 envTtl: await attempt(() => $.env.get('CLAUDE_CODE_PROMPT_CACHE_TTL'), undefined),
303 settingTtl: settings.promptCacheTtl,
304 }),
305 subagent: requestedSubagentTtl({
306 force5m,
307 envTtl: await attempt(
308 () => $.env.get('CLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL'),
309 undefined,
310 ),
311 settingTtl: settings.subagentPromptCacheTtl,
312 enable1h: await attempt(() => $.env.get('ENABLE_PROMPT_CACHING_1H'), undefined),
313 }),
314 agents: new Map(),
315 agentHits: 0,
316 agentMisses: 0,
317 agentFailures: 0,
318 agentStopNote: null,
319 quiet: new Map(),
320 observed: null,
321 keeper: initialKeeperState(carriedEnabled ?? config.enabled),
322 baseline: null,
323 transcriptPath: null,
324 rows: [],
325 chunk: 0,
326 keeperHits: 0,
327 keeperMisses: 0,
328 run: null,
329 pending: null,
330 tags: new Map(),
331 stopNote: null,
332 isEnabledDecided: false,
333 lastStatus: undefined,
334 heartbeatAt: 0,
335 lastRealAt: null,
336 bridge: initialBridge(),
337 forkRetryAt: 0,
338 backgroundPending: 0,
339 backgroundSeenAt: null,
340 isAnchor: false,
341 isAnchorExpiryNoted: false,
342 }
343 carriedEnabled = undefined
344
345 const saved = await attempt(() => $.store.get(`s:${id}`), undefined)
346 if (isRecord(saved)) {
347 // The default decides until someone has: a session saved before a change
348 // of default follows the new one.
349 s.isEnabledDecided = saved.isEnabledDecided === true
350 if (s.isEnabledDecided) {
351 s.keeper.enabled = booleanOf(saved.enabled, s.keeper.enabled)
352 }
353 s.keeper.lastActivityAt =
354 typeof saved.lastActivityAt === 'number' ? saved.lastActivityAt : null
355 s.keeper.cachedPrefixTokens = numberOf(saved.cachedPrefixTokens, 0)
356 s.lastRealAt = typeof saved.lastRealAt === 'number' ? saved.lastRealAt : null
357 s.isAnchor = saved.isAnchor === true
358 s.backgroundPending = numberOf(saved.backgroundPending, 0)
359 s.backgroundSeenAt =
360 typeof saved.backgroundSeenAt === 'number' ? saved.backgroundSeenAt : null
361 if (isRecord(saved.bridge)) {
362 const b = saved.bridge
363 s.bridge = {
364 phase: b.phase === 'wrapped' ? 'wrapped' : 'idle',
365 window: typeof b.window === 'string' ? b.window : null,
366 resetAtMs: typeof b.resetAtMs === 'number' ? b.resetAtMs : null,
367 wrapAt: typeof b.wrapAt === 'number' ? b.wrapAt : null,
368 isTakenOver: b.isTakenOver === true,
369 wrappedAtPercent:
370 typeof b.wrappedAtPercent === 'number' ? b.wrappedAtPercent : null,
371 weeklyWindow: typeof b.weeklyWindow === 'string' ? b.weeklyWindow : null,
372 }
373 }
374 s.keeper.consecutiveFailures = numberOf(saved.consecutiveFailures, 0)
375 s.keeper.idleKeepalives = numberOf(saved.idleKeepalives, 0)
376 s.keeper.pausedReason =
377 typeof saved.pausedReason === 'string' ? saved.pausedReason : null
378 s.observed =
379 saved.observed === '5m' || saved.observed === '1h' || saved.observed === 'mixed'
380 ? saved.observed
381 : null
382 s.baseline = isRecord(saved.baseline)
383 ? {
384 model: stringOf(saved.baseline.model, ''),
385 effort: stringOf(saved.baseline.effort, ''),
386 }
387 : null
388 s.transcriptPath =
389 typeof saved.transcriptPath === 'string' ? saved.transcriptPath : null
390 s.chunk = numberOf(saved.chunk, 0)
391 s.keeperHits = numberOf(saved.keeperHits, 0)
392 s.keeperMisses = numberOf(saved.keeperMisses, 0)
393 s.model = stringOf(saved.model, s.model)
394 s.effort = stringOf(saved.effort, '')
395 s.stopNote = typeof saved.stopNote === 'string' ? saved.stopNote : null
396 s.agentHits = numberOf(saved.agentHits, 0)
397 s.agentMisses = numberOf(saved.agentMisses, 0)
398 s.agentFailures = numberOf(saved.agentFailures, 0)
399 s.agentStopNote =
400 typeof saved.agentStopNote === 'string' ? saved.agentStopNote : null
401 // A keepalive still in flight across a reload keeps its id, so the
402 // task-notification that answers it is still dropped.
403 for (const pair of Array.isArray(saved.quiet) ? saved.quiet : []) {
404 if (!Array.isArray(pair) || typeof pair[0] !== 'string') continue
405 s.quiet.set(pair[0], numberOf(pair[1], 0))
406 }
407 for (const one of Array.isArray(saved.agents) ? saved.agents : []) {
408 if (!isRecord(one) || typeof one.id !== 'string') continue
409 // A reload forgets what was in flight: the loop's next event says.
410 s.agents.set(one.id, {
411 lastActivityAt: numberOf(one.lastActivityAt, 0),
412 prefixTokens: numberOf(one.prefixTokens, 0),
413 model: stringOf(one.model, ''),
414 keepalives: numberOf(one.keepalives, 0),
415 isRunning: false,
416 pingAt: null,
417 isPingAnswered: false,
418 isPingMissed: false,
419 })
420 }
421 if (isRecord(saved.run)) {
422 // The step a reload interrupted is asked again; the run says so.
423 const run = saved.run as unknown as Run
424 s.run = {
425 ...run,
426 isInFlight: false,
427 warnings: run.isInFlight
428 ? [...run.warnings, 'the mod reloaded mid-step']
429 : run.warnings,
430 }
431 }
432 s.rows = parseJsonl<EventRow>(await attempt(() => $.fs.read(eventsPath(s)), ''))
433 }
434
435 const now = await attempt(() => $.clock.now(), 0)
436 for (const key of await attempt(() => $.store.keys(), [])) {
437 if (!key.startsWith('s:') && !key.startsWith('hb:')) continue
438 if (key === `s:${id}`) continue
439 const value = await attempt(() => $.store.get(key), undefined)
440 const at = isRecord(value) ? numberOf(value.at, 0) : 0
441 if (now - at > STALE_MS) await attempt(() => $.store.delete(key), undefined)
442 }
443
444 return s
445}
446
447const ensure = ($: EngineInterface): Promise<Session> => (booting ??= boot($))
448
449const save = async ($: EngineInterface, s: Session): Promise<void> => {
450 await attempt(
451 async () =>
452 $.store.set(`s:${s.id}`, {
453 at: await $.clock.now(),
454 enabled: s.keeper.enabled,
455 isEnabledDecided: s.isEnabledDecided,
456 lastActivityAt: s.keeper.lastActivityAt,
457 lastRealAt: s.lastRealAt,
458 isAnchor: s.isAnchor,
459 backgroundPending: s.backgroundPending,
460 backgroundSeenAt: s.backgroundSeenAt,
461 bridge: s.bridge,
462 cachedPrefixTokens: s.keeper.cachedPrefixTokens,
463 consecutiveFailures: s.keeper.consecutiveFailures,
464 idleKeepalives: s.keeper.idleKeepalives,
465 pausedReason: s.keeper.pausedReason,
466 observed: s.observed,
467 baseline: s.baseline,
468 transcriptPath: s.transcriptPath,
469 chunk: s.chunk,
470 keeperHits: s.keeperHits,
471 keeperMisses: s.keeperMisses,
472 model: s.model,
473 effort: s.effort,
474 stopNote: s.stopNote,
475 agentHits: s.agentHits,
476 agentMisses: s.agentMisses,
477 agentFailures: s.agentFailures,
478 agentStopNote: s.agentStopNote,
479 agents: [...s.agents].map(([id, agent]) => ({ id, ...agent })),
480 quiet: [...s.quiet],
481 run: s.run,
482 }),
483 undefined,
484 )
485}
486
487const flush = async ($: EngineInterface, s: Session): Promise<void> => {
488 if (s.rows.length === 0) return
489 await $.fs.write(
490 eventsPath(s),
491 `${s.rows.map(row => JSON.stringify(row)).join('\n')}\n`,
492 )
493}
494
495// Moves to a new chunk only between turns: the rows of the turn that just
496// ended must still be in memory for reconcile and stepDone, and rows already
497// moved to a finished chunk can no longer be filled in. Not during an
498// experiment, whose run is read back from the rows of its own steps.
499const rotateChunkIfNeeded = async ($: EngineInterface, s: Session): Promise<void> => {
500 if (s.run !== null || s.rows.length < CHUNK_ROWS) return
501 await flush($, s)
502 s.chunk += 1
503 s.rows = []
504}
505
506const syncKeeper = (s: Session): void => {
507 s.keeper.ttl = effectiveTtl(s.requested.ttl, s.observed)
508 s.keeper.isRunActive = s.run !== null
509 if (config.maxIdleKeepalives > 0) {
510 s.keeper.idleKeepaliveBudget = config.maxIdleKeepalives
511 } else {
512 s.keeper.idleKeepaliveBudget =
513 s.keeper.ttl === null
514 ? null
515 : (breakEvenKeepalives(s.model, s.keeper.ttl) ?? null)
516 }
517}
518
519const contaminate = (run: Run, cause: string): void => {
520 if (!run.contamination.includes(cause)) run.contamination.push(cause)
521}
522
523const rowOf = (
524 s: Session,
525 fields: Pick<EventRow, 'kind' | 'timestamp' | 'source'> & Partial<EventRow>,
526 usage: Usage,
527 tag: Tag | undefined,
528): EventRow => {
529 const isSubagent = fields.source === 'subagent'
530 const split: CacheSplit | null =
531 usage.cache_creation_input_tokens === 0
532 ? { ephemeral_5m_input_tokens: 0, ephemeral_1h_input_tokens: 0 }
533 : null
534 const model = fields.model ?? s.model
535 const cost = apiEquivalentCost(
536 model,
537 usage,
538 split,
539 isSubagent ? s.subagent.ttl : effectiveTtl(s.requested.ttl, s.observed),
540 )
541
542 return {
543 experiment_id: tag?.experimentId ?? '',
544 replicate: tag?.replicate ?? null,
545 session_id: s.id,
546 model,
547 effort: s.effort,
548 claude_code_version: s.version,
549 ttl_requested: s.requested.ttl ?? 'automatic',
550 ttl_observed: isSubagent ? '' : (s.observed ?? ''),
551 ...usage,
552 ephemeral_5m_input_tokens: split?.ephemeral_5m_input_tokens ?? null,
553 ephemeral_1h_input_tokens: split?.ephemeral_1h_input_tokens ?? null,
554 cache_miss_reason: '',
555 context_tokens: null,
556 context_window: null,
557 rate_limits_before: [],
558 rate_limits_after: [],
559 api_equivalent_usd: fields.kind === 'meter' ? null : cost.usd,
560 cost_basis: fields.kind === 'meter' ? '' : cost.basis,
561 contaminated: false,
562 notes: tag?.note ?? '',
563 result: '',
564 turn_id: '',
565 step_index: null,
566 agent_id: '',
567 duration_ms: null,
568 ...fields,
569 }
570}
571
572const readTranscript = async (
573 $: EngineInterface,
574 path: string,
575): Promise<string | null> => {
576 try {
577 return await $.fs.read(path)
578 } catch {
579 // over the read limit, or not written yet: the script reads its tail
580 }
581 const ran = await attempt(
582 () =>
583 $.process.run([
584 'node',
585 `${$.plugin.root}/scripts/analyze-transcript.ts`,
586 path,
587 '--tail',
588 '600',
589 ]),
590 undefined,
591 )
592
593 return ran !== undefined && ran.exitCode === 0 ? ran.stdout : null
594}
595
596// Fills in what only the transcript has: each cache write's 5m/1h split and
597// the API's cache-miss reason, matched to the rows recorded live.
598const reconcile = async ($: EngineInterface, s: Session): Promise<void> => {
599 if (s.transcriptPath === null) return
600 // matchSplits pairs a row only within 600 s of its request: an older row that is
601 // still open never will be, so it is not worth re-reading the transcript for.
602 const now = await $.clock.now()
603 const open = s.rows.filter(
604 row =>
605 row.kind === 'step' &&
606 row.source !== 'subagent' &&
607 row.ephemeral_5m_input_tokens === null &&
608 now - Date.parse(row.timestamp) <= 600_000,
609 )
610 if (open.length === 0) return
611 const jsonl = await readTranscript($, s.transcriptPath)
612 if (jsonl === null) return
613 const parsed = parseTranscript(jsonl)
614 const matches = matchSplits(
615 open.map(row => ({
616 timestamp: row.timestamp,
617 isSubagent: false,
618 usage: usageOf(row),
619 })),
620 parsed.requests,
621 )
622 for (const [index, row] of open.entries()) {
623 const match = matches[index]
624 if (match === null || match === undefined) continue
625 row.ephemeral_5m_input_tokens = match.split.ephemeral_5m_input_tokens
626 row.ephemeral_1h_input_tokens = match.split.ephemeral_1h_input_tokens
627 row.ttl_observed = observedTtl(match.split) ?? row.ttl_observed
628 row.cache_miss_reason = match.cacheMissReason ?? ''
629 const cost = apiEquivalentCost(row.model, usageOf(row), match.split, null)
630 row.api_equivalent_usd = cost.usd
631 row.cost_basis = cost.basis
632 if (!match.isSplitConsistent) {
633 row.notes = `${row.notes} SPLIT_INCONSISTENT`.trim()
634 }
635 }
636 const lastWrite = parsed.requests
637 .filter(request => !request.isSidechain)
638 .findLast(request => observedTtl(request.split) !== null)
639 if (lastWrite !== undefined) s.observed = observedTtl(lastWrite.split)
640 await flush($, s)
641}
642
643const stopKeeper = ($: EngineInterface, s: Session, reason: string): void => {
644 s.keeper.enabled = false
645 s.isEnabledDecided = true
646 s.stopNote = reason
647 s.bridge.phase = 'idle'
648 $.ui.toast(`cache-keeper stopped: ${reason}`)
649 $.ui.log(`keeper stopped: ${reason}`)
650}
651
652// One cache-sharing fork over the session's own transcript: the keepalive,
653// a manual probe, or an experiment's keeper step.
654const probe = async (
655 $: EngineInterface,
656 s: Session,
657 tag: Tag | undefined,
658 note: string,
659): Promise<EventRow> => {
660 s.keeper.isProbing = true
661 try {
662 const before = await limitsNow($)
663 const startedAt = await $.clock.now()
664 const reply = await $.model.fork({ prompt: config.probePrompt })
665 const endedAt = await $.clock.now()
666 const after = await attempt(() => $.session.usage(), undefined)
667 const usage: Usage = 'usage' in reply ? { ...reply.usage } : ZERO_USAGE
668 const result = classifyUsage(usage)
669 const failure = reply.isAnswered
670 ? ''
671 : reply.reason === 'api-error'
672 ? `api-error ${reply.status ?? ''} ${reply.error}`.trim()
673 : reply.reason
674 const refreshed =
675 usage.cache_read_input_tokens + usage.cache_creation_input_tokens
676 if (result === 'hit' || result === 'partial_hit') {
677 s.keeper.lastActivityAt = startedAt
678 s.keeper.cachedPrefixTokens = Math.max(s.keeper.cachedPrefixTokens, refreshed)
679 s.keeper.consecutiveFailures = 0
680 s.keeper.idleKeepalives += 1
681 s.keeperHits += 1
682 s.isAnchorExpiryNoted = false
683 } else if (!reply.isAnswered && reply.reason === 'nothing-to-fork') {
684 // A resumed or just-started process has no request of its own to fork yet: wait, never count it as a failure.
685 s.forkRetryAt = endedAt + 30_000
686 } else {
687 s.keeper.consecutiveFailures += 1
688 s.keeperMisses += 1
689 if (usage.cache_creation_input_tokens > 0) {
690 // The miss rewrote the prefix, so the entry is fresh from here.
691 s.keeper.lastActivityAt = startedAt
692 s.keeper.cachedPrefixTokens = refreshed
693 s.keeper.idleKeepalives += 1
694 }
695 }
696 const row = rowOf(
697 s,
698 {
699 kind: 'keeper',
700 timestamp: iso(startedAt),
701 source: 'keeper',
702 result,
703 notes: [note, failure].filter(Boolean).join(': '),
704 rate_limits_before: before,
705 rate_limits_after: after === undefined ? [] : [...after.rateLimits],
706 context_tokens: after?.context.tokens ?? null,
707 context_window: after?.context.window ?? null,
708 duration_ms: endedAt - startedAt,
709 },
710 usage,
711 tag,
712 )
713 s.rows.push(row)
714 await flush($, s)
715 await rememberMeter($, endedAt, after?.rateLimits ?? [])
716 await save($, s)
717
718 return row
719 } finally {
720 s.keeper.isProbing = false
721 }
722}
723
724// A subagent's request, as its turn.step showed it: the cache it left behind is
725// the one a later SendMessage to it reads. Answers the row's note.
726const agentStepped = (
727 s: Session,
728 id: string,
729 startedAt: number,
730 model: string,
731 usage: Usage,
732): string => {
733 const agent: AgentTrack = s.agents.get(id) ?? {
734 lastActivityAt: startedAt,
735 prefixTokens: 0,
736 model,
737 keepalives: 0,
738 isRunning: true,
739 pingAt: null,
740 isPingAnswered: false,
741 isPingMissed: false,
742 }
743 agent.lastActivityAt = startedAt
744 agent.prefixTokens =
745 usage.cache_read_input_tokens + usage.cache_creation_input_tokens
746 agent.model = model
747 agent.isRunning = true
748 s.agents.set(id, agent)
749 if (agent.pingAt === null) {
750 // A real run: its idle stretch starts over.
751 agent.keepalives = 0
752 return ''
753 }
754 if (!agent.isPingAnswered) {
755 agent.isPingAnswered = true
756 if (classifyUsage(usage) === 'miss') {
757 agent.isPingMissed = true
758 s.agentMisses += 1
759 s.agentFailures += 1
760 } else {
761 s.agentHits += 1
762 s.agentFailures = 0
763 }
764 }
765
766 return 'subagent keepalive'
767}
768
769const agentFinished = ($: EngineInterface, s: Session, id: string): void => {
770 const agent = s.agents.get(id)
771 if (agent === undefined) return
772 agent.isRunning = false
773 if (agent.pingAt === null) return
774 agent.pingAt = null
775 agent.isPingAnswered = false
776 if (!agent.isPingMissed) return
777 // The keepalive rewrote the entry instead of reading it: the TTL assumed for
778 // this subagent is wrong, and another keepalive would cost the same again.
779 s.agents.delete(id)
780 if (s.agentFailures >= config.maxConsecutiveProbeFailures) {
781 s.agentStopNote = `${s.agentFailures} consecutive subagent keepalives missed`
782 $.ui.toast(`cache-keeper stopped keeping subagents: ${s.agentStopNote}`)
783 $.ui.log(`subagent keeper stopped: ${s.agentStopNote}`)
784 }
785}
786
787// Whether the session saved a subagent under this id. The engine lists the
788// subagents it ran in the background; one that ran in the foreground is on no
789// list, and neither are the engine's own forks, which save nothing here.
790const isSavedSubagent = async (
791 $: EngineInterface,
792 s: Session,
793 id: string,
794): Promise<boolean> => {
795 if (s.transcriptPath === null) return false
796 const path = `${s.transcriptPath.replace(/.jsonl$/, '')}/subagents/agent-${id}.meta.json`
797 const meta = await attempt(
798 async () => JSON.parse(await $.fs.read(path)) as unknown,
799 undefined,
800 )
801
802 return isRecord(meta) && typeof meta.agentType === 'string'
803}
804
805// Resumes one finished subagent with the keepalive message: one the engine
806// lists as completed, or one it does not list whose transcript the session
807// saved. A running one waits; a failed or killed one is not resumed.
808const pingAgent = async (
809 $: EngineInterface,
810 s: Session,
811 id: string,
812 now: number,
813): Promise<string> => {
814 const agent = s.agents.get(id)
815 if (agent === undefined) return 'not tracked'
816 const listed = await attempt(() => $.agent.list(), [])
817 const status = listed.find(one => one.id === id)?.status
818 const isResumable =
819 status === 'completed' ||
820 (status === undefined && (await isSavedSubagent($, s, id)))
821 if (!isResumable) {
822 if (status !== 'running') s.agents.delete(id)
823 return status ?? 'not a subagent of this session'
824 }
825 agent.pingAt = now
826 agent.isPingAnswered = false
827 agent.isPingMissed = false
828 agent.keepalives += 1
829 s.quiet.set(id, now)
830 const sent = await attempt(
831 () => $.session.send({ to: { agentId: id }, text: AGENT_PING }),
832 undefined,
833 )
834 if (sent === undefined || !sent.isDelivered) {
835 s.agents.delete(id)
836 s.quiet.delete(id)
837 const why = sent?.reason ?? 'the send failed'
838 $.ui.log(`subagent ${id} keepalive not delivered: ${why}`)
839 return `not delivered (${why})`
840 }
841 await save($, s)
842
843 return 'sent'
844}
845
846const keepAgents = async (
847 $: EngineInterface,
848 s: Session,
849 now: number,
850): Promise<void> => {
851 for (const [id, at] of s.quiet) {
852 if (now - at > QUIET_MS) s.quiet.delete(id)
853 }
854 let due: string | undefined
855 for (const [id, agent] of s.agents) {
856 if (agent.pingAt !== null) {
857 // Delivered and never run: nothing will clear it.
858 if (!agent.isRunning && now - agent.pingAt > PING_TIMEOUT_MS) s.agents.delete(id)
859 continue
860 }
861 const decision = decideAgent(
862 agent,
863 now,
864 s.subagent.ttl,
865 config,
866 config.subagentKeepalives,
867 )
868 if (decision === 'forget') s.agents.delete(id)
869 else if (decision === 'fire') due ??= id
870 }
871 if (due === undefined || !s.keeper.enabled || s.agentStopNote !== null) return
872 const limits = await limitsNow($)
873 if (limits.some(limit => isOverStopLine(limit, config))) return
874 await pingAgent($, s, due, now)
875}
876
877const agentsText = (s: Session, now: number): string => {
878 const stopped = s.agentStopNote === null ? '' : ` (stopped: ${s.agentStopNote})`
879 const head = `subagents: TTL ${s.subagent.ttl} (${s.subagent.reason}) · keepalives per subagent ${config.subagentKeepalives}${stopped} · tracked ${s.agents.size} · hits ${s.agentHits} · misses ${s.agentMisses}`
880 const rows = [...s.agents].map(([id, agent]) => {
881 const state = agent.isRunning
882 ? 'running'
883 : `idle ${minutes(now - agent.lastActivityAt)}`
884 const flight = agent.pingAt === null ? '' : ' · keepalive in flight'
885
886 return ` ${id} · ${agent.model} · prefix ${agent.prefixTokens} · ${state} · keepalives ${agent.keepalives}${flight}`
887 })
888
889 return [head, ...rows].join('\n')
890}
891
892const otherSessionsSince = async (
893 $: EngineInterface,
894 s: Session,
895 since: number,
896): Promise<string[]> => {
897 const others: string[] = []
898 for (const key of await attempt(() => $.store.keys(), [])) {
899 if (!key.startsWith('hb:') || key === `hb:${s.id}`) continue
900 const value = await attempt(() => $.store.get(key), undefined)
901 if (isRecord(value) && numberOf(value.at, 0) >= since) {
902 others.push(key.slice(3, 11))
903 }
904 }
905
906 return others
907}
908
909const finishRun = async (
910 $: EngineInterface,
911 s: Session,
912 run: Run,
913): Promise<void> => {
914 await reconcile($, s)
915 const now = await $.clock.now()
916 const mine = s.rows.filter(
917 row =>
918 row.experiment_id === run.experimentId && row.replicate === run.replicate,
919 )
920 const build = mine.find(row => row.notes.startsWith('build'))
921 const observed = build === undefined ? null : observedTtl(splitOf(build))
922 if (isTtlMismatch(s.requested.ttl, observed)) {
923 run.warnings.push('TTL_CONFIG_MISMATCH')
924 }
925 if (build !== undefined && build.ephemeral_5m_input_tokens === null) {
926 run.warnings.push('TTL_UNVERIFIED: no transcript split for the build request')
927 }
928 if (
929 build !== undefined &&
930 build.cache_creation_input_tokens + build.cache_read_input_tokens <
931 run.fixtureTokens / 2
932 ) {
933 run.warnings.push('FIXTURE_SMALLER_THAN_ASKED')
934 }
935 for (const other of await otherSessionsSince($, s, run.requestedAt)) {
936 contaminate(run, `session ${other} made requests meanwhile`)
937 }
938 const isContaminated = run.contamination.length > 0
939 if (isContaminated) {
940 for (const row of mine) row.contaminated = true
941 }
942 const ttl = s.requested.ttl ?? 'automatic'
943 const record: RunRecord = {
944 experiment_id: run.experimentId,
945 plan: run.plan,
946 replicate: run.replicate,
947 ttl_requested: ttl,
948 ttl_reason: s.requested.reason,
949 ttl_observed: observed ?? '',
950 session_id: s.id,
951 model: s.model,
952 effort: s.effort,
953 claude_code_version: s.version,
954 started_at: iso(run.startedAt ?? run.requestedAt),
955 ended_at: iso(now),
956 meter_start: run.meterStart,
957 meter_end: await limitsNow($),
958 contaminated: isContaminated,
959 contamination: run.contamination,
960 warnings: run.warnings,
961 transcript_path: s.transcriptPath,
962 fixture_tokens: run.fixtureTokens,
963 steps: run.steps,
964 }
965 const path = `${config.dataDir}/raw/run-${run.experimentId}-${ttl}-r${run.replicate}-${s.id.slice(0, 8)}.json`
966 await $.fs.write(path, `${JSON.stringify(record, null, 2)}\n`)
967 s.run = null
968 await flush($, s)
969 await save($, s)
970 const verdict = isContaminated ? 'contaminated' : 'clean'
971 $.ui.toast(`cache-keeper: ${run.experimentId} r${run.replicate} finished (${verdict})`)
972 $.ui.log(
973 `${run.experimentId} r${run.replicate} finished (${verdict}); wrote ${path}. /cache-keeper report builds the report.`,
974 )
975}
976
977const abortRun = async (
978 $: EngineInterface,
979 s: Session,
980 why: string,
981): Promise<void> => {
982 const run = s.run
983 if (run === null) return
984 s.run = null
985 s.pending = null
986 await save($, s)
987 $.ui.toast(`cache-keeper: ${run.experimentId} aborted (${why})`)
988 $.ui.log(`${run.experimentId} r${run.replicate} aborted: ${why}`)
989}
990
991const advanceRun = async (
992 $: EngineInterface,
993 s: Session,
994 run: Run,
995 now: number,
996): Promise<void> => {
997 const plan = planNamed(run.plan)
998 if (plan === undefined) {
999 await abortRun($, s, 'unknown plan')
1000 return
1001 }
1002 if (run.isInFlight) {
1003 if (s.pending !== null && now - s.pending.since > 120_000) {
1004 await abortRun($, s, 'the step never started a turn')
1005 }
1006 return
1007 }
1008 if (s.keeper.isTurnRunning || s.keeper.isProbing) return
1009 const step = plan.steps[run.next]
1010 if (step === undefined) {
1011 await finishRun($, s, run)
1012 return
1013 }
1014 if (step.kind !== 'build') {
1015 if (run.startedAt === null) return
1016 if (now < run.startedAt + step.atSec * 1000) return
1017 }
1018 const tag: Tag = {
1019 experimentId: run.experimentId,
1020 replicate: run.replicate,
1021 note: step.kind,
1022 stepIndex: run.next,
1023 }
1024 run.isInFlight = true
1025 if (step.kind === 'keeper') {
1026 const row = await probe($, s, tag, 'keeper')
1027 run.steps.push({
1028 kind: 'keeper',
1029 plannedSec: step.atSec,
1030 actualSec: (Date.parse(row.timestamp) - (run.startedAt ?? now)) / 1000,
1031 result: row.result,
1032 cacheRead: row.cache_read_input_tokens,
1033 cacheCreation: row.cache_creation_input_tokens,
1034 })
1035 run.next += 1
1036 run.isInFlight = false
1037 await save($, s)
1038 return
1039 }
1040 const isBuild = step.kind === 'build'
1041 s.pending = {
1042 ...tag,
1043 marker: marker(step.kind, run.nonce, isBuild ? 0 : run.next),
1044 since: now,
1045 }
1046 await save($, s)
1047 const entered = await attempt(
1048 () =>
1049 $.prompt.submit({
1050 text: isBuild
1051 ? buildPrompt(run.nonce, run.fixtureTokens, config.probePrompt)
1052 : probePromptFor(run.nonce, run.next, config.probePrompt),
1053 asUser: true,
1054 }),
1055 undefined,
1056 )
1057 if (entered === undefined || 'drop' in entered) {
1058 await abortRun($, s, 'the prompt was not accepted')
1059 }
1060}
1061
1062const stepDone = async (
1063 $: EngineInterface,
1064 s: Session,
1065 run: Run,
1066 tag: Tag,
1067 turnId: string,
1068 reason: string,
1069): Promise<void> => {
1070 const plan = planNamed(run.plan)
1071 const rows = s.rows.filter(row => row.turn_id === turnId)
1072 const first = rows[0]
1073 const last = rows.at(-1)
1074 if (first === undefined || last === undefined) {
1075 await abortRun($, s, `the ${tag.note} turn got no response (${reason})`)
1076 return
1077 }
1078 if (reason !== 'answer') {
1079 run.warnings.push(`step ${tag.stepIndex} (${tag.note}) ended: ${reason}`)
1080 }
1081 if (rows.length > 1) {
1082 run.warnings.push(`step ${tag.stepIndex} (${tag.note}) took ${rows.length} requests`)
1083 }
1084 const at = Date.parse(first.timestamp)
1085 if (tag.note === 'build') run.startedAt = at
1086 run.steps.push({
1087 kind: tag.note,
1088 plannedSec: plan?.steps[tag.stepIndex]?.atSec ?? 0,
1089 actualSec: (at - (run.startedAt ?? at)) / 1000,
1090 result: last.result,
1091 cacheRead: last.cache_read_input_tokens,
1092 cacheCreation: last.cache_creation_input_tokens,
1093 })
1094 run.next = tag.stepIndex + 1
1095 run.isInFlight = false
1096}
1097
1098const runStatus = (s: Session, run: Run, now: number): string => {
1099 const plan = planNamed(run.plan)
1100 const step = plan?.steps[run.next]
1101 const total = plan?.steps.length ?? 0
1102 if (step === undefined) return `${run.experimentId} r${run.replicate} · finishing`
1103 const wait =
1104 run.startedAt === null
1105 ? ''
1106 : ` · next ${step.kind} in ${minutes(
1107 Math.ceil((run.startedAt + step.atSec * 1000 - now) / 15_000) * 15_000,
1108 )}`
1109
1110 return `${run.experimentId} r${run.replicate} · step ${run.next + 1}/${total}${wait}`
1111}
1112
1113const showStatus = ($: EngineInterface, s: Session, now: number): void => {
1114 if (!config.showStatus) return
1115 syncKeeper(s)
1116 let text: string
1117 if (s.run !== null) {
1118 text = runStatus(s, s.run, now)
1119 } else {
1120 const decision = decide(s.keeper, now, config, [])
1121 const coarse: Decision =
1122 decision.action === 'wait' && decision.dueInMs !== undefined
1123 ? { ...decision, dueInMs: Math.ceil(decision.dueInMs / 15_000) * 15_000 }
1124 : decision
1125 text = describeKeeper(s.keeper, coarse)
1126 }
1127 if (text === s.lastStatus) return
1128 s.lastStatus = text
1129 $.ui.status(text)
1130}
1131
1132// The weekly handoff, once per tick: with the weekly window nearly spent, the
1133// main session is asked once to leave a document a new conversation can pick
1134// up after the reset. It is not told to stop, and nothing resumes it.
1135const handoffStep = async (
1136 $: EngineInterface,
1137 s: Session,
1138 now: number,
1139): Promise<void> => {
1140 if (config.weeklyHandoffAtPercent <= 0 || s.lastRealAt === null) return
1141 const limits = await limitsNow($)
1142 const ask = shouldHandoff({
1143 isRunActive: s.run !== null,
1144 bridge: s.bridge,
1145 limits,
1146 now,
1147 lastRealAt: s.lastRealAt,
1148 ttl: effectiveTtl(s.requested.ttl, s.observed),
1149 atPercent: config.weeklyHandoffAtPercent,
1150 isBackgroundBusy: isBackgroundBusy(s.backgroundPending, s.backgroundSeenAt, now),
1151 isTurnRunning: s.keeper.isTurnRunning,
1152 })
1153 const limit = sevenDay(limits)
1154 const reset = resetMs(limit)
1155 if (!ask || limit === undefined || reset === null) return
1156 // Marked first: a failed send must not repeat every five seconds.
1157 s.bridge.weeklyWindow = windowKey(reset)
1158 await save($, s)
1159 const sent = await attempt(
1160 () => $.prompt.submit({ text: handoffText(limit.percentUsed, reset, now) }),
1161 undefined,
1162 )
1163 $.ui.toast(
1164 sent === undefined || 'drop' in sent
1165 ? 'cache-keeper: the weekly handoff message was not accepted'
1166 : `cache-keeper: weekly quota at ${limit.percentUsed}%, handoff document requested`,
1167 )
1168}
1169
1170// The quota bridge, once per tick: wrap up near the end of the 5-hour window,
1171// resume once it has reset. Only the main thread is ever messaged.
1172const bridgeStep = async (
1173 $: EngineInterface,
1174 s: Session,
1175 now: number,
1176): Promise<void> => {
1177 const b = s.bridge
1178 if (b.phase === 'wrapped') {
1179 if (!s.keeper.enabled) {
1180 b.phase = 'idle'
1181 await save($, s)
1182
1183 return
1184 }
1185 const verdict = resumeVerdict(b, now, await limitsNow($), config.bridgeAtPercent)
1186 if (verdict === 'wait') return
1187 b.phase = 'idle'
1188 await save($, s)
1189 if (verdict === 'resume') {
1190 const sent = await attempt(
1191 () => $.prompt.submit({ text: resumeText() }),
1192 undefined,
1193 )
1194 if (sent === undefined || 'drop' in sent) {
1195 $.ui.toast('cache-keeper: the resume message was not accepted')
1196 } else {
1197 $.ui.toast('cache-keeper: quota reset, session resumed')
1198 }
1199 }
1200hooks/lib/bridge.ts 243 lines1import type { RateLimit } from './keeper.ts'
2import { TTL_MS } from './keeper.ts'
3import type { Ttl } from './pricing.ts'
4
5/**
6 * The quota bridge: when the 5-hour window nears its end, the main session is
7 * told to wrap up, its cache is kept warm until the window resets, and it is
8 * told to carry on afterwards. Pure decisions live here; register.tsx acts.
9 */
10export type BridgePhase = 'idle' | 'wrapped'
11
12export type Bridge = {
13 phase: BridgePhase
14 /** The window (its resetsAt) the bridge last wrapped for: one wrap per window. */
15 window: string | null
16 resetAtMs: number | null
17 wrapAt: number | null
18 /** The person typed after the wrap-up: no automatic resume. */
19 isTakenOver: boolean
20 /** Percent when the wrap-up was sent. */
21 wrappedAtPercent: number | null
22 /** The weekly window (windowKey of its reset) the handoff notice was sent for. */
23 weeklyWindow: string | null
24}
25
26export const initialBridge = (): Bridge => ({
27 phase: 'idle',
28 window: null,
29 resetAtMs: null,
30 wrapAt: null,
31 isTakenOver: false,
32 wrappedAtPercent: null,
33 weeklyWindow: null,
34})
35
36/** Only a session worked in this recently counts as being worked: older ones are never woken. */
37export const ACTIVE_MS = 600_000
38
39/**
40 * Background work out for longer than this since the last sign of it (a launch,
41 * or a Stop hook's list that still named it) is taken as lost: a task whose
42 * notice never came back must not keep an idle session "worked in" forever.
43 */
44export const BACKGROUND_MAX_MS = 24 * 3_600_000
45
46/**
47 * How many background tasks the Stop hook's list still names as in flight. That
48 * list is the session's own: it includes tasks a Bash call moved to the
49 * background by itself, which no tool input shows.
50 */
51export const countInFlight = (tasks: readonly unknown[]): number => tasks.length
52
53/**
54 * Whether background work this session started is still out: some task has not
55 * sent its task-notification back, and it was last seen recently enough to
56 * trust. While it is out the session is waiting on that work, not idle, even
57 * though no message has come from the main thread for a while.
58 */
59export const isBackgroundBusy = (
60 pending: number,
61 seenAt: number | null,
62 now: number,
63): boolean => pending > 0 && seenAt !== null && now - seenAt < BACKGROUND_MAX_MS
64
65/** Wait this long after resetsAt before resuming, for the meter to move. */
66export const RESUME_DELAY_MS = 20_000
67/** If the meter still reads high this long after the reset, resume anyway. */
68export const RESUME_GRACE_MS = 300_000
69/** A cache this close to expiring at resume time still wants its last keepalive. */
70export const RESUME_SAFETY_MS = 90_000
71
72export const fiveHour = (limits: readonly RateLimit[]): RateLimit | undefined =>
73 limits.find(limit => limit.kind === 'five_hour')
74
75export const resetMs = (limit: RateLimit | undefined): number | null => {
76 if (limit?.resetsAt === undefined) return null
77 const at = Date.parse(limit.resetsAt)
78
79 return Number.isFinite(at) ? at : null
80}
81
82export const sevenDay = (limits: readonly RateLimit[]): RateLimit | undefined =>
83 limits.find(limit => limit.kind === 'seven_day')
84
85/** One window, by its reset to the minute: a reading's milliseconds may wander. */
86export const windowKey = (resetAtMs: number): string =>
87 String(Math.round(resetAtMs / 60_000))
88
89export type HandoffInput = {
90 isRunActive: boolean
91 bridge: Bridge
92 limits: readonly RateLimit[]
93 now: number
94 /** The last user or assistant message of the main thread, in ms. */
95 lastRealAt: number | null
96 ttl: Ttl | null
97 /** Weekly percent that asks for the handoff; 0 never asks. */
98 atPercent: number
99 /** Background work this session started is still out (see isBackgroundBusy). */
100 isBackgroundBusy: boolean
101 /** A main-thread turn is running now, however long it has been going. */
102 isTurnRunning: boolean
103}
104
105/**
106 * Whether the session is being worked in: background work is out, a turn is
107 * running, or the main thread spoke within the active window (never longer
108 * than the cache TTL). A session idle for longer than that is left alone.
109 */
110const isWorkedIn = (
111 lastRealAt: number,
112 ttl: Ttl | null,
113 now: number,
114 isBackgroundBusy: boolean,
115 isTurnRunning: boolean,
116): boolean =>
117 isBackgroundBusy ||
118 isTurnRunning ||
119 now - lastRealAt < Math.min(ACTIVE_MS, TTL_MS[ttl ?? '1h'])
120
121/**
122 * Whether to ask for a handoff document now: the weekly window is nearly
123 * spent, so the work will be picked up after its reset, days away, by a new
124 * conversation rather than by this one's long-expired cache. Once per weekly
125 * window, and only for a session being worked in, by the wrap-up's rule.
126 */
127export const shouldHandoff = (input: HandoffInput): boolean => {
128 if (input.atPercent <= 0 || input.isRunActive) return false
129 const limit = sevenDay(input.limits)
130 const reset = resetMs(limit)
131 if (limit === undefined || reset === null || reset <= input.now) return false
132 if (limit.percentUsed < input.atPercent) return false
133 if (windowKey(reset) === input.bridge.weeklyWindow) return false
134 if (input.lastRealAt === null) return false
135
136 return isWorkedIn(input.lastRealAt, input.ttl, input.now, input.isBackgroundBusy, input.isTurnRunning)
137}
138
139export type WrapInput = {
140 isEnabled: boolean
141 isBridgeOn: boolean
142 isRunActive: boolean
143 bridge: Bridge
144 limits: readonly RateLimit[]
145 now: number
146 /** The last user or assistant message of the main thread, in ms. */
147 lastRealAt: number | null
148 ttl: Ttl | null
149 atPercent: number
150 /** Background work this session started is still out (see isBackgroundBusy). */
151 isBackgroundBusy: boolean
152 /** A main-thread turn is running now (see isWorkedIn). */
153 isTurnRunning: boolean
154}
155
156/**
157 * Whether to send the wrap-up now: the window is at the threshold, resets in
158 * the future, this session was worked in within ACTIVE_MS or still has background
159 * work out (an idle session is never woken), and the window was not wrapped for already.
160 */
161export const shouldWrap = (input: WrapInput): boolean => {
162 if (!input.isEnabled || !input.isBridgeOn || input.isRunActive) return false
163 if (input.bridge.phase !== 'idle') return false
164 const limit = fiveHour(input.limits)
165 const reset = resetMs(limit)
166 if (limit === undefined || reset === null || reset <= input.now) return false
167 if (limit.percentUsed < input.atPercent) return false
168 if (limit.resetsAt === input.bridge.window) return false
169 if (input.lastRealAt === null) return false
170
171 return isWorkedIn(input.lastRealAt, input.ttl, input.now, input.isBackgroundBusy, input.isTurnRunning)
172}
173
174/**
175 * Whether a keepalive would still be needed before the resume message: the
176 * resume is itself a request, so a cache that outlives it needs no more pings.
177 */
178export const needsPing = (
179 bridge: Bridge,
180 lastActivityAt: number,
181 ttl: Ttl,
182): boolean => {
183 if (bridge.resetAtMs === null) return true
184
185 return (
186 bridge.resetAtMs + RESUME_DELAY_MS - lastActivityAt >=
187 TTL_MS[ttl] - RESUME_SAFETY_MS
188 )
189}
190
191export type ResumeVerdict = 'wait' | 'resume' | 'skip'
192
193/** What to do about the resume message at `now`. */
194export const resumeVerdict = (
195 bridge: Bridge,
196 now: number,
197 limits: readonly RateLimit[],
198 atPercent: number,
199): ResumeVerdict => {
200 if (bridge.phase !== 'wrapped' || bridge.resetAtMs === null) return 'wait'
201 if (now < bridge.resetAtMs + RESUME_DELAY_MS) return 'wait'
202 if (bridge.isTakenOver) return 'skip'
203 const limit = fiveHour(limits)
204 const isStillHigh = limit !== undefined && limit.percentUsed >= atPercent
205 if (isStillHigh && now < bridge.resetAtMs + RESUME_GRACE_MS) return 'wait'
206
207 return 'resume'
208}
209
210export const clockOf = (ms: number): string => {
211 const at = new Date(ms)
212 const two = (n: number): string => String(n).padStart(2, '0')
213
214 return `${two(at.getHours())}:${two(at.getMinutes())}`
215}
216
217export const spanOf = (ms: number): string => {
218 const minutes = Math.max(0, Math.round(ms / 60_000))
219 const hours = Math.floor(minutes / 60)
220
221 return hours === 0 ? `${minutes} 分钟` : `${hours} 小时 ${minutes % 60} 分钟`
222}
223
224export const wrapText = (percent: number, resetAtMs: number, now: number): string =>
225 `【cache-keeper 自动通知】5 小时额度已用 ${percent}%,约 ${spanOf(resetAtMs - now)}后(${clockOf(resetAtMs)})重置。请只快速完成当前这一步并收尾(保存进度、写好交接说明),不要开始新任务、不要再派子代理,然后停下等待。额度重置后会自动通知你继续。`
226
227const dayOf = (ms: number): string => {
228 const at = new Date(ms)
229
230 return `${at.getMonth() + 1} 月 ${at.getDate()} 日 ${clockOf(ms)}`
231}
232
233export const handoffText = (percent: number, resetAtMs: number, now: number): string =>
234 `【cache-keeper 自动通知】周额度已用 ${percent}%,约 ${spanOf(resetAtMs - now)}后(${dayOf(resetAtMs)})重置。请现在写一份交接检查点:更新已有的台账或交接文档,没有就在工作目录新建 HANDOFF.md,写清目标与授权范围、已完成并验证的结果、进行中的步骤和当前状态、下一步、关键文件位置和要跑的命令,让一个没有本对话上下文的新会话能直接接手。写完把文件路径告诉用户,然后继续手头的工作,之后每完成一个阶段就更新它。额度重置后由新对话按这份文档接手,不续用本对话。`
235
236export const resumeText = (): string =>
237 '【cache-keeper 自动通知】5 小时额度已重置。请接着刚才收尾的地方继续之前的工作。'
238
239export const describeBridge = (bridge: Bridge, now: number): string =>
240 bridge.phase === 'wrapped' && bridge.resetAtMs !== null
241 ? `bridge: waiting for reset in ${spanOf(bridge.resetAtMs - now)} (${clockOf(bridge.resetAtMs)})${bridge.isTakenOver ? ' · you took over: no auto-resume' : ''}`
242 : 'bridge: idle'
243hooks/lib/classify.ts 99 lines1import type { CacheSplit, Ttl, Usage } from './pricing.ts'
2
3export type ProbeResult = 'hit' | 'partial_hit' | 'miss' | 'cold'
4
5/**
6 * How much of a request's prompt the cache served. `cold` is a request that
7 * carried no prompt tokens at all (none was made, or it failed).
8 */
9export const classifyUsage = (usage: Usage): ProbeResult => {
10 const prompt =
11 usage.input_tokens +
12 usage.cache_read_input_tokens +
13 usage.cache_creation_input_tokens
14 if (prompt === 0) return 'cold'
15 const share = usage.cache_read_input_tokens / prompt
16 if (share >= 0.9) return 'hit'
17
18 return share <= 0.1 ? 'miss' : 'partial_hit'
19}
20
21export type TtlReason = 'force_5m_env' | 'env' | 'setting' | 'automatic'
22
23export type TtlRequest = { ttl: Ttl | null; reason: TtlReason }
24
25const isTtl = (value: unknown): value is Ttl => value === '5m' || value === '1h'
26
27const isOn = (value: string | undefined): boolean =>
28 value !== undefined && /^(1|true|yes|on)$/i.test(value.trim())
29
30/**
31 * The TTL the engine was asked for, in its own order of precedence:
32 * FORCE_PROMPT_CACHING_5M, then the TTL variable, then the `promptCacheTtl`
33 * setting. `automatic` leaves the choice to the engine (ttl null).
34 */
35export const requestedTtl = (source: {
36 force5m?: string
37 envTtl?: string
38 settingTtl?: unknown
39}): TtlRequest => {
40 if (isOn(source.force5m)) return { ttl: '5m', reason: 'force_5m_env' }
41 if (isTtl(source.envTtl)) return { ttl: source.envTtl, reason: 'env' }
42 if (isTtl(source.settingTtl)) {
43 return { ttl: source.settingTtl, reason: 'setting' }
44 }
45
46 return { ttl: null, reason: 'automatic' }
47}
48
49export type SubagentTtlRequest = {
50 ttl: Ttl
51 reason: Exclude<TtlReason, 'automatic'> | 'enable_1h_env' | 'default'
52}
53
54/**
55 * The TTL the engine uses outside the main conversation (subagents, workflows,
56 * helper requests): the same order with its own variable and setting, then
57 * ENABLE_PROMPT_CACHING_1H, and five minutes when nothing asks for more. An
58 * agent definition's own `cacheTtl` sits between the two and is not seen here.
59 */
60export const requestedSubagentTtl = (source: {
61 force5m?: string
62 envTtl?: string
63 settingTtl?: unknown
64 enable1h?: string
65}): SubagentTtlRequest => {
66 if (isOn(source.force5m)) return { ttl: '5m', reason: 'force_5m_env' }
67 if (isTtl(source.envTtl)) return { ttl: source.envTtl, reason: 'env' }
68 if (isTtl(source.settingTtl)) {
69 return { ttl: source.settingTtl, reason: 'setting' }
70 }
71 if (isOn(source.enable1h)) return { ttl: '1h', reason: 'enable_1h_env' }
72
73 return { ttl: '5m', reason: 'default' }
74}
75
76export type ObservedTtl = Ttl | 'mixed' | null
77
78/** Which cache a request wrote to, read off its transcript row's split. */
79export const observedTtl = (split: CacheSplit | null): ObservedTtl => {
80 if (split === null) return null
81 const wrote5m = split.ephemeral_5m_input_tokens > 0
82 const wrote1h = split.ephemeral_1h_input_tokens > 0
83 if (wrote5m && wrote1h) return 'mixed'
84 if (wrote5m) return '5m'
85
86 return wrote1h ? '1h' : null
87}
88
89/** The TTL to schedule by: what the log showed, else what was asked for. */
90export const effectiveTtl = (
91 requested: Ttl | null,
92 observed: ObservedTtl,
93): Ttl | null => (isTtl(observed) ? observed : requested)
94
95export const isTtlMismatch = (
96 requested: Ttl | null,
97 observed: ObservedTtl,
98): boolean => requested !== null && isTtl(observed) && observed !== requested
99hooks/lib/experiment.ts 160 lines1import type { Ttl } from './pricing.ts'
2
3export type StepKind = 'build' | 'probe' | 'keeper'
4
5export type PlanStep = {
6 /** Seconds after the build request started. */
7 atSec: number
8 kind: StepKind
9}
10
11export type Plan = {
12 /** What `/cache-keeper experiment <name>` takes. */
13 name: string
14 /** The experiment id rows and run records carry. */
15 id: string
16 title: string
17 /** The TTL the session must have been started with, when the plan needs one. */
18 requiresTtl: Ttl | null
19 steps: readonly PlanStep[]
20}
21
22const BUILD: PlanStep = { atSec: 0, kind: 'build' }
23
24const every = (
25 kind: StepKind,
26 periodSec: number,
27 times: number,
28): PlanStep[] =>
29 Array.from({ length: times }, (_, index) => ({
30 atSec: periodSec * (index + 1),
31 kind,
32 }))
33
34export const PLANS: readonly Plan[] = [
35 {
36 name: 'ttl',
37 id: 'A-ttl',
38 title: 'TTL takes effect: build the cache, probe after 90 s',
39 requiresTtl: null,
40 steps: [BUILD, { atSec: 90, kind: 'probe' }],
41 },
42 {
43 name: 'renew',
44 id: 'B-renew',
45 title: 'A 5m hit renews the TTL: keepalives at 4:00 and 8:00, probe at 12:00',
46 requiresTtl: '5m',
47 steps: [BUILD, ...every('keeper', 240, 2), { atSec: 720, kind: 'probe' }],
48 },
49 {
50 name: 'renew-control',
51 id: 'B-control',
52 title: 'Control for B: no keepalive, probe at 6:30',
53 requiresTtl: '5m',
54 steps: [BUILD, { atSec: 390, kind: 'probe' }],
55 },
56 {
57 name: 'active',
58 id: 'C1-active',
59 title: 'Active coding: 11 probes, 3 minutes apart',
60 requiresTtl: null,
61 steps: [BUILD, ...every('probe', 180, 11)],
62 },
63 {
64 name: 'intermittent',
65 id: 'C2-intermittent',
66 title: 'Intermittent coding: 5 probes, 10 minutes apart',
67 requiresTtl: null,
68 steps: [BUILD, ...every('probe', 600, 5)],
69 },
70 {
71 name: 'keeper',
72 id: 'D2-keeper',
73 title: '18 minutes idle with a keepalive every 4:05, then a probe',
74 requiresTtl: '5m',
75 steps: [BUILD, ...every('keeper', 245, 4), { atSec: 1080, kind: 'probe' }],
76 },
77 {
78 name: 'keeper-control',
79 id: 'D1-idle',
80 title: '18 minutes idle with no keepalive, then a probe',
81 requiresTtl: null,
82 steps: [BUILD, { atSec: 1080, kind: 'probe' }],
83 },
84]
85
86export const planNamed = (name: string): Plan | undefined =>
87 PLANS.find(plan => plan.name === name)
88
89export const planDurationSec = (plan: Plan): number =>
90 Math.max(...plan.steps.map(step => step.atSec))
91
92const WORDS = (
93 'the of and to in is that for it as was with be by on not he this are or ' +
94 'his from at which but have an had they you were their one all we can her ' +
95 'has there been if more when will would who so no out up into than them ' +
96 'time only some could these two may then do first any my now such like ' +
97 'our over man me even most made after also did many before must through ' +
98 'years where much your way well down should because each just those people ' +
99 'how too little state good very make world still own see men work long get ' +
100 'here between both life being under never day same another know while last ' +
101 'might us great old year off come since against go came right used take three'
102).split(' ')
103
104const seedOf = (text: string): number => {
105 let hash = 2166136261
106 for (let index = 0; index < text.length; index += 1) {
107 hash = Math.imul(hash ^ text.charCodeAt(index), 16777619)
108 }
109
110 return hash >>> 0 || 1
111}
112
113/**
114 * Static filler of about `targetTokens` tokens (one common word is about one
115 * token), the same for the same nonce and different from the first word on
116 * for another, so no two runs can be served each other's cache entry.
117 */
118export const buildFiller = (nonce: string, targetTokens: number): string => {
119 let state = seedOf(nonce)
120 const lines: string[] = []
121 let line: string[] = []
122 for (let index = 0; index < targetTokens; index += 1) {
123 state ^= state << 13
124 state ^= state >>> 17
125 state ^= state << 5
126 line.push(WORDS[(state >>> 0) % WORDS.length] ?? 'the')
127 if (line.length === 16) {
128 lines.push(line.join(' '))
129 line = []
130 }
131 }
132 if (line.length > 0) lines.push(line.join(' '))
133
134 return lines.join('\n')
135}
136
137export const marker = (kind: StepKind, nonce: string, index: number): string =>
138 `[cache-keeper:${kind}:${nonce}:${index}]`
139
140export const buildPrompt = (
141 nonce: string,
142 targetTokens: number,
143 probePrompt: string,
144): string =>
145 [
146 `${marker('build', nonce, 0)} The block below is static filler for a prompt-cache benchmark. Do not analyze, summarize or act on it, and do not call any tool.`,
147 '',
148 '<filler>',
149 buildFiller(nonce, targetTokens),
150 '</filler>',
151 '',
152 probePrompt,
153 ].join('\n')
154
155export const probePromptFor = (
156 nonce: string,
157 index: number,
158 probePrompt: string,
159): string => `${marker('probe', nonce, index)} ${probePrompt}`
160hooks/lib/keeper.ts 255 lines1import type { Ttl } from './pricing.ts'
2
3export type RateLimit = { kind: string; percentUsed: number; resetsAt?: string }
4
5export type KeeperConfig = {
6 minCacheReadTokens: number
7 ttl5mKeepaliveAtSec: number
8 ttl1hKeepaliveAtSec: number
9 maxRateLimitPercent: number
10 maxConsecutiveProbeFailures: number
11}
12
13export type KeeperState = {
14 enabled: boolean
15 isTurnRunning: boolean
16 isProbing: boolean
17 isRunActive: boolean
18 /** When the last request that touched the main cache STARTED, in ms. */
19 lastActivityAt: number | null
20 /** Cache read + written by that request: the prefix worth keeping. */
21 cachedPrefixTokens: number
22 consecutiveFailures: number
23 /** Keepalives since the last real request. */
24 idleKeepalives: number
25 /** Keepalives allowed per idle stretch; null when nothing bounds them. */
26 idleKeepaliveBudget: number | null
27 /** Set by a cache-invalidating change until a real request hits again. */
28 pausedReason: string | null
29 ttl: Ttl | null
30}
31
32export const TTL_MS: Record<Ttl, number> = { '5m': 300_000, '1h': 3_600_000 }
33
34export type WaitReason =
35 | 'disabled'
36 | 'experiment'
37 | 'busy'
38 | 'paused'
39 | 'ttl-unknown'
40 | 'no-baseline'
41 | 'prefix-too-small'
42 | 'expired'
43 | 'break-even'
44 | 'not-due'
45
46export type Decision =
47 | { action: 'fire' }
48 | { action: 'wait'; reason: WaitReason; dueInMs?: number }
49 | { action: 'stop'; reason: string }
50
51export const initialKeeperState = (enabled: boolean): KeeperState => ({
52 enabled,
53 isTurnRunning: false,
54 isProbing: false,
55 isRunActive: false,
56 lastActivityAt: null,
57 cachedPrefixTokens: 0,
58 consecutiveFailures: 0,
59 idleKeepalives: 0,
60 idleKeepaliveBudget: null,
61 pausedReason: null,
62 ttl: null,
63})
64
65export const keepaliveAtMs = (ttl: Ttl, config: KeeperConfig): number =>
66 (ttl === '5m' ? config.ttl5mKeepaliveAtSec : config.ttl1hKeepaliveAtSec) *
67 1000
68
69/**
70 * Whether a window stops the keeper. Only the 5-hour window does: the weekly
71 * one resets too far away to wait out, and while it still has room a stopped
72 * keeper costs a full rewrite of the prefix, the dearer way to spend it. Once
73 * it is spent the keepalives fail and the failure count stops the keeper.
74 */
75export const isOverStopLine = (limit: RateLimit, config: KeeperConfig): boolean =>
76 limit.kind === 'five_hour' && limit.percentUsed >= config.maxRateLimitPercent
77
78/**
79 * The keeper and config `decide` should see when pings are not capped: no idle
80 * budget, and, past the stop line, no 5-hour percent limit either. Every other
81 * stop condition, and the TTL expiry, stay as they were.
82 */
83export const unboundedView = (
84 state: KeeperState,
85 config: KeeperConfig,
86 { isUnboundedBudget, isPastStopLine }: {
87 isUnboundedBudget: boolean
88 isPastStopLine: boolean
89 },
90): { keeper: KeeperState; config: KeeperConfig } => ({
91 keeper: isUnboundedBudget ? { ...state, idleKeepaliveBudget: null } : state,
92 config: isPastStopLine
93 ? { ...config, maxRateLimitPercent: Number.POSITIVE_INFINITY }
94 : config,
95})
96
97/**
98 * Whether to refresh the cache now. A keepalive is only ever a read of a
99 * live entry: once the TTL has run out it would be a full rewrite, so the
100 * keeper waits for a real request instead.
101 */
102export const decide = (
103 state: KeeperState,
104 now: number,
105 config: KeeperConfig,
106 rateLimits: readonly RateLimit[],
107): Decision => {
108 if (!state.enabled) return { action: 'wait', reason: 'disabled' }
109 if (state.isRunActive) return { action: 'wait', reason: 'experiment' }
110 if (state.consecutiveFailures >= config.maxConsecutiveProbeFailures) {
111 return {
112 action: 'stop',
113 reason: `${state.consecutiveFailures} consecutive keepalives failed or missed`,
114 }
115 }
116 if (state.isTurnRunning || state.isProbing) {
117 return { action: 'wait', reason: 'busy' }
118 }
119 if (state.pausedReason !== null) return { action: 'wait', reason: 'paused' }
120 if (state.ttl === null) return { action: 'wait', reason: 'ttl-unknown' }
121 if (state.lastActivityAt === null) {
122 return { action: 'wait', reason: 'no-baseline' }
123 }
124 if (state.cachedPrefixTokens < config.minCacheReadTokens) {
125 return { action: 'wait', reason: 'prefix-too-small' }
126 }
127 const over = rateLimits.find(limit => isOverStopLine(limit, config))
128 if (over !== undefined) {
129 return {
130 action: 'stop',
131 reason: `${over.kind} is at ${over.percentUsed}% (limit ${config.maxRateLimitPercent}%)`,
132 }
133 }
134 const idle = now - state.lastActivityAt
135 if (idle >= TTL_MS[state.ttl]) return { action: 'wait', reason: 'expired' }
136 if (
137 state.idleKeepaliveBudget !== null &&
138 state.idleKeepalives >= state.idleKeepaliveBudget
139 ) {
140 return { action: 'wait', reason: 'break-even' }
141 }
142 const dueInMs = keepaliveAtMs(state.ttl, config) - idle
143 if (dueInMs > 0) return { action: 'wait', reason: 'not-due', dueInMs }
144
145 return { action: 'fire' }
146}
147
148/** The main cache as another mod reads it under `$.state`'s `cache`. */
149export type CacheState = {
150 lastActivityAt: number | null
151 ttl: Ttl | null
152 expiresAt: number | null
153 cachedPrefixTokens: number
154 isEnabled: boolean
155 pausedReason: string | null
156 bridgePhase: 'idle' | 'wrapped'
157}
158
159export const cacheStateOf = (
160 state: KeeperState,
161 bridgePhase: 'idle' | 'wrapped',
162): CacheState => ({
163 lastActivityAt: state.lastActivityAt,
164 ttl: state.ttl,
165 expiresAt:
166 state.lastActivityAt === null || state.ttl === null
167 ? null
168 : state.lastActivityAt + TTL_MS[state.ttl],
169 cachedPrefixTokens: state.cachedPrefixTokens,
170 isEnabled: state.enabled,
171 pausedReason: state.pausedReason,
172 bridgePhase,
173})
174
175/**
176 * Whether the conversation is now far smaller than the prefix the keeper
177 * tracks: it was compacted without the keeper's `session.compact` hook being
178 * reached (another mod answered the compaction), so a keepalive would only
179 * read a prefix nothing will ask for again.
180 */
181export const hasShrunk = (
182 contextTokens: number | undefined,
183 cachedPrefixTokens: number,
184): boolean =>
185 contextTokens !== undefined && contextTokens < cachedPrefixTokens / 2
186
187/** One subagent's cache, as its own requests showed it. */
188export type AgentTrack = {
189 /** When its last request STARTED, in ms. */
190 lastActivityAt: number
191 /** Cache read + written by that request. */
192 prefixTokens: number
193 model: string
194 /** Keepalives since its last real run. */
195 keepalives: number
196 isRunning: boolean
197 /** When a keepalive was sent that its loop has not finished answering. */
198 pingAt: number | null
199 isPingAnswered: boolean
200 /** That keepalive rewrote the entry instead of reading it. */
201 isPingMissed: boolean
202}
203
204export type AgentDecision = 'fire' | 'wait' | 'forget'
205
206/**
207 * Whether to refresh a finished subagent's cache now, by the main keeper's
208 * rule: only ever a read of a live entry, and `budget` times at most between
209 * two real runs. An entry past its TTL is forgotten.
210 */
211export const decideAgent = (
212 agent: AgentTrack,
213 now: number,
214 ttl: Ttl,
215 config: KeeperConfig,
216 budget: number,
217): AgentDecision => {
218 if (agent.isRunning || agent.pingAt !== null) return 'wait'
219 const idle = now - agent.lastActivityAt
220 if (idle >= TTL_MS[ttl]) return 'forget'
221 if (agent.prefixTokens < config.minCacheReadTokens) return 'wait'
222 if (agent.keepalives >= budget) return 'wait'
223
224 return idle >= keepaliveAtMs(ttl, config) ? 'fire' : 'wait'
225}
226
227const clock = (ms: number): string => {
228 const seconds = Math.max(0, Math.round(ms / 1000))
229
230 return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
231}
232
233const thousands = (tokens: number): string => `${Math.round(tokens / 1000)}k`
234
235/** The status line entry for a keeper outside an experiment. */
236export const describeKeeper = (
237 state: KeeperState,
238 decision: Decision,
239): string => {
240 const ttl = state.ttl ?? 'ttl?'
241 const prefix = thousands(state.cachedPrefixTokens)
242 if (decision.action === 'stop') return `cache-keeper stopped`
243 if (decision.action === 'fire') return `cache ${ttl} ${prefix} · keeping`
244 if (decision.reason === 'disabled') return `cache ${ttl} ${prefix} · observing`
245 if (decision.reason === 'not-due' && decision.dueInMs !== undefined) {
246 return `cache ${ttl} ${prefix} · keep in ${clock(decision.dueInMs)}`
247 }
248 const why =
249 decision.reason === 'paused'
250 ? `paused (${state.pausedReason})`
251 : decision.reason
252
253 return `cache ${ttl} ${prefix} · ${why}`
254}
255hooks/lib/parser.ts 260 lines1import type { CacheSplit, Usage } from './pricing.ts'
2
3/** One model request, as the transcript's rows for it agree on it. */
4export type TranscriptRequest = {
5 key: string
6 messageId: string | null
7 requestId: string | null
8 timestamp: string | null
9 sessionId: string | null
10 model: string
11 isSidechain: boolean
12 agentId: string | null
13 version: string | null
14 effort: string | null
15 usage: Usage
16 split: CacheSplit | null
17 isSplitConsistent: boolean
18 cacheMissReason: string | null
19 rowCount: number
20}
21
22export type ParseResult = {
23 requests: TranscriptRequest[]
24 assistantRows: number
25 parseErrors: number
26 /** Requests whose duplicate rows disagreed on usage. */
27 dedupeWarningCount: number
28 /** Requests whose usage contradicted its own cache split. */
29 inconsistentUsageCount: number
30}
31
32type Row = Record<string, unknown>
33
34const isRecord = (value: unknown): value is Row =>
35 typeof value === 'object' && value !== null && !Array.isArray(value)
36
37const count = (value: unknown): number =>
38 typeof value === 'number' && Number.isFinite(value) ? value : 0
39
40const text = (value: unknown): string | null =>
41 typeof value === 'string' && value !== '' ? value : null
42
43type Candidate = {
44 order: number
45 row: Row
46 message: Row
47 usage: Usage
48 split: CacheSplit | null
49 isTopNonZero: boolean
50 isConsistent: boolean
51 signature: string
52}
53
54const readCandidate = (row: Row, order: number): Candidate | undefined => {
55 const message = row.message
56 if (!isRecord(message) || !isRecord(message.usage)) return undefined
57 if (message.model === '<synthetic>') return undefined
58 const raw = message.usage
59 const usage: Usage = {
60 input_tokens: count(raw.input_tokens),
61 output_tokens: count(raw.output_tokens),
62 cache_read_input_tokens: count(raw.cache_read_input_tokens),
63 cache_creation_input_tokens: count(raw.cache_creation_input_tokens),
64 }
65 const nested = raw.cache_creation
66 const split: CacheSplit | null = isRecord(nested)
67 ? {
68 ephemeral_5m_input_tokens: count(nested.ephemeral_5m_input_tokens),
69 ephemeral_1h_input_tokens: count(nested.ephemeral_1h_input_tokens),
70 }
71 : null
72 const splitTotal =
73 split === null
74 ? 0
75 : split.ephemeral_5m_input_tokens + split.ephemeral_1h_input_tokens
76 const isTopNonZero =
77 usage.input_tokens +
78 usage.output_tokens +
79 usage.cache_read_input_tokens +
80 usage.cache_creation_input_tokens >
81 0
82
83 return {
84 order,
85 row,
86 message,
87 usage,
88 split,
89 isTopNonZero,
90 isConsistent:
91 split === null ||
92 Math.abs(usage.cache_creation_input_tokens - splitTotal) <= 1,
93 signature: JSON.stringify([usage, split]),
94 }
95}
96
97// The most complete row of a request: one with usage at all, then one whose
98// split adds up, then the furthest along the stream, then the last written.
99const better = (a: Candidate, b: Candidate): Candidate => {
100 if (a.isTopNonZero !== b.isTopNonZero) return a.isTopNonZero ? a : b
101 if (a.isConsistent !== b.isConsistent) return a.isConsistent ? a : b
102 if (a.usage.output_tokens !== b.usage.output_tokens) {
103 return a.usage.output_tokens > b.usage.output_tokens ? a : b
104 }
105
106 return a.order > b.order ? a : b
107}
108
109const missReason = (candidate: Candidate): string | null => {
110 const diagnostics = candidate.message.diagnostics
111 if (isRecord(diagnostics)) {
112 const found = text(diagnostics.cache_miss_reason)
113 if (found !== null) return found
114 }
115
116 return (
117 text(candidate.row.cache_miss_reason) ??
118 text(candidate.row.cacheMissReason)
119 )
120}
121
122/**
123 * Reads a session transcript (JSONL) into one entry per model request. The
124 * engine writes a row per content block, each repeating the request's usage,
125 * so rows are grouped by message id and request id and never summed.
126 */
127export const parseTranscript = (jsonl: string): ParseResult => {
128 const groups = new Map<string, Candidate[]>()
129 let assistantRows = 0
130 let parseErrors = 0
131 let order = 0
132 for (const line of jsonl.split('\n')) {
133 if (line.trim() === '') continue
134 let row: unknown
135 try {
136 row = JSON.parse(line)
137 } catch {
138 parseErrors += 1
139 continue
140 }
141 if (!isRecord(row) || row.type !== 'assistant') continue
142 const candidate = readCandidate(row, order)
143 order += 1
144 if (candidate === undefined) continue
145 assistantRows += 1
146 const messageId = text(candidate.message.id)
147 const requestId = text(row.requestId)
148 const key =
149 messageId !== null || requestId !== null
150 ? `${messageId ?? ''}|${requestId ?? ''}`
151 : `row|${text(row.uuid) ?? String(candidate.order)}`
152 const group = groups.get(key)
153 if (group === undefined) groups.set(key, [candidate])
154 else group.push(candidate)
155 }
156
157 const requests: TranscriptRequest[] = []
158 let dedupeWarningCount = 0
159 let inconsistentUsageCount = 0
160 for (const [key, group] of groups) {
161 const chosen = group.reduce(better)
162 const informative = group.filter(one => one.isTopNonZero)
163 if (new Set(informative.map(one => one.signature)).size > 1) {
164 dedupeWarningCount += 1
165 }
166 const splitTotal =
167 chosen.split === null
168 ? 0
169 : chosen.split.ephemeral_5m_input_tokens +
170 chosen.split.ephemeral_1h_input_tokens
171 // A row whose top-level counts are all zero while its split is not was
172 // not a free request: the split is the write.
173 const isZeroedWrite = !chosen.isTopNonZero && splitTotal > 0
174 if (!chosen.isConsistent || isZeroedWrite) inconsistentUsageCount += 1
175 requests.push({
176 key,
177 messageId: text(chosen.message.id),
178 requestId: text(chosen.row.requestId),
179 timestamp: text(chosen.row.timestamp),
180 sessionId: text(chosen.row.sessionId),
181 model: text(chosen.message.model) ?? 'unknown',
182 isSidechain: chosen.row.isSidechain === true,
183 agentId: text(chosen.row.agentId),
184 version: text(chosen.row.version),
185 effort: text(chosen.row.effort),
186 usage: isZeroedWrite
187 ? { ...chosen.usage, cache_creation_input_tokens: splitTotal }
188 : chosen.usage,
189 split: chosen.split,
190 isSplitConsistent: chosen.isConsistent && !isZeroedWrite,
191 cacheMissReason: missReason(chosen),
192 rowCount: group.length,
193 })
194 }
195
196 return {
197 requests,
198 assistantRows,
199 parseErrors,
200 dedupeWarningCount,
201 inconsistentUsageCount,
202 }
203}
204
205export type SplitMatch = {
206 split: CacheSplit
207 cacheMissReason: string | null
208 isSplitConsistent: boolean
209}
210
211type Unresolved = {
212 timestamp: string
213 isSubagent: boolean
214 usage: Usage
215}
216
217/**
218 * Pairs requests the mod saw live (four token counts, no split) with the
219 * transcript's rows for them: same four counts, same thread, at most ten
220 * minutes apart, the closest pairs first and each transcript request once.
221 */
222export const matchSplits = (
223 rows: readonly Unresolved[],
224 requests: readonly TranscriptRequest[],
225): (SplitMatch | null)[] => {
226 const pairs: { row: number; request: TranscriptRequest; gap: number }[] = []
227 for (const [index, row] of rows.entries()) {
228 const at = Date.parse(row.timestamp)
229 for (const request of requests) {
230 if (request.split === null || request.timestamp === null) continue
231 if (request.isSidechain !== row.isSubagent) continue
232 const usage = request.usage
233 const isSame =
234 usage.input_tokens === row.usage.input_tokens &&
235 usage.output_tokens === row.usage.output_tokens &&
236 usage.cache_read_input_tokens === row.usage.cache_read_input_tokens &&
237 usage.cache_creation_input_tokens ===
238 row.usage.cache_creation_input_tokens
239 const gap = Math.abs(Date.parse(request.timestamp) - at)
240 if (isSame && gap <= 600_000) pairs.push({ row: index, request, gap })
241 }
242 }
243 pairs.sort((a, b) => a.gap - b.gap)
244
245 const matches: (SplitMatch | null)[] = rows.map(() => null)
246 const used = new Set<string>()
247 for (const { row, request } of pairs) {
248 if (matches[row] !== null || used.has(request.key)) continue
249 if (request.split === null) continue
250 used.add(request.key)
251 matches[row] = {
252 split: request.split,
253 cacheMissReason: request.cacheMissReason,
254 isSplitConsistent: request.isSplitConsistent,
255 }
256 }
257
258 return matches
259}
260hooks/lib/pricing.ts 120 lines1export type Usage = {
2 input_tokens: number
3 output_tokens: number
4 cache_read_input_tokens: number
5 cache_creation_input_tokens: number
6}
7
8export type CacheSplit = {
9 ephemeral_5m_input_tokens: number
10 ephemeral_1h_input_tokens: number
11}
12
13export type Ttl = '5m' | '1h'
14
15export type Price = {
16 input: number
17 write5m: number
18 write1h: number
19 read: number
20 output: number
21}
22
23// USD per million tokens, platform.claude.com/docs/en/about-claude/pricing as
24// read on 2026-10-08. The API's prices: never a subscription's quota weights.
25// A third element is a prompt length the row only applies above: Haiku 5.5 is
26// priced by prompt length, so its long-prompt row comes first.
27const PRICES: readonly (readonly [RegExp, Price, number?])[] = [
28 [/opus-5-5/, { input: 4, write5m: 5, write1h: 8, read: 0.2, output: 20 }],
29 [/sonnet-5-5/, { input: 2, write5m: 2.5, write1h: 4, read: 0.1, output: 10 }],
30 [/haiku-5-5/, { input: 0.5, write5m: 0.625, write1h: 1, read: 0.05, output: 2.5 }, 100_000],
31 [/haiku-5-5/, { input: 0.1, write5m: 0.125, write1h: 0.2, read: 0.01, output: 0.5 }],
32 [/haiku-4-5/, { input: 1, write5m: 1.25, write1h: 2, read: 0.1, output: 5 }],
33]
34
35export const ZERO_USAGE: Usage = {
36 input_tokens: 0,
37 output_tokens: 0,
38 cache_read_input_tokens: 0,
39 cache_creation_input_tokens: 0,
40}
41
42export const priceFor = (model: string, promptTokens = 0): Price | undefined =>
43 PRICES.find(
44 ([pattern, , above]) =>
45 pattern.test(model) && (above === undefined || promptTokens > above),
46 )?.[1]
47
48export type CostBasis =
49 | 'split'
50 | 'assumed-5m'
51 | 'assumed-1h'
52 | 'no-cache-write'
53 | 'unpriced-model'
54
55export type Cost = { usd: number | null; basis: CostBasis }
56
57/**
58 * What the request would cost on the API. Cache writes are priced from the
59 * 5m/1h split when the transcript gave one, and `cache_creation_input_tokens`
60 * is then never added again; without a split the whole write is priced at
61 * `assumedTtl` (1h, the dearer, when even that is unknown).
62 */
63export const apiEquivalentCost = (
64 model: string,
65 usage: Usage,
66 split: CacheSplit | null,
67 assumedTtl: Ttl | null,
68): Cost => {
69 const price = priceFor(
70 model,
71 usage.input_tokens +
72 usage.cache_read_input_tokens +
73 usage.cache_creation_input_tokens,
74 )
75 if (price === undefined) return { usd: null, basis: 'unpriced-model' }
76 const base =
77 usage.input_tokens * price.input +
78 usage.cache_read_input_tokens * price.read +
79 usage.output_tokens * price.output
80 const splitTotal =
81 split === null
82 ? 0
83 : split.ephemeral_5m_input_tokens + split.ephemeral_1h_input_tokens
84 if (split !== null && splitTotal > 0) {
85 const writes =
86 split.ephemeral_5m_input_tokens * price.write5m +
87 split.ephemeral_1h_input_tokens * price.write1h
88 return { usd: (base + writes) / 1_000_000, basis: 'split' }
89 }
90 if (usage.cache_creation_input_tokens === 0) {
91 return { usd: base / 1_000_000, basis: 'no-cache-write' }
92 }
93 const isFiveMinute = assumedTtl === '5m'
94 const writes =
95 usage.cache_creation_input_tokens *
96 (isFiveMinute ? price.write5m : price.write1h)
97 return {
98 usd: (base + writes) / 1_000_000,
99 basis: isFiveMinute ? 'assumed-5m' : 'assumed-1h',
100 }
101}
102
103/**
104 * How many keepalive reads cost as much, on the API, as the one cache rewrite
105 * they avoid: (write - read) / read, whole reads. Undefined for an unpriced
106 * model.
107 */
108export const breakEvenKeepalives = (
109 model: string,
110 ttl: Ttl,
111): number | undefined => {
112 const price = priceFor(model)
113 if (price === undefined) return undefined
114 const write = ttl === '5m' ? price.write5m : price.write1h
115 // whole micro-dollars, so 4.8 / 0.2 is 24 and not 23.999
116 return Math.floor(
117 Math.round((write - price.read) * 1000) / Math.round(price.read * 1000),
118 )
119}
120hooks/lib/report.ts 717 lines1import type { RateLimit } from './keeper.ts'
2
3export type EventRow = {
4 kind: 'step' | 'keeper' | 'meter'
5 timestamp: string
6 experiment_id: string
7 replicate: number | null
8 session_id: string
9 source: 'user' | 'benchmark' | 'keeper' | 'subagent' | 'meter'
10 model: string
11 effort: string
12 claude_code_version: string
13 ttl_requested: string
14 ttl_observed: string
15 input_tokens: number
16 output_tokens: number
17 cache_read_input_tokens: number
18 cache_creation_input_tokens: number
19 ephemeral_5m_input_tokens: number | null
20 ephemeral_1h_input_tokens: number | null
21 cache_miss_reason: string
22 context_tokens: number | null
23 context_window: number | null
24 rate_limits_before: RateLimit[]
25 rate_limits_after: RateLimit[]
26 api_equivalent_usd: number | null
27 cost_basis: string
28 contaminated: boolean
29 notes: string
30 result: string
31 turn_id: string
32 step_index: number | null
33 agent_id: string
34 duration_ms: number | null
35}
36
37export type RunStep = {
38 kind: string
39 plannedSec: number
40 actualSec: number | null
41 result: string
42 cacheRead: number
43 cacheCreation: number
44}
45
46export type RunRecord = {
47 experiment_id: string
48 plan: string
49 replicate: number
50 ttl_requested: string
51 ttl_reason: string
52 ttl_observed: string
53 session_id: string
54 model: string
55 effort: string
56 claude_code_version: string
57 started_at: string
58 ended_at: string
59 meter_start: RateLimit[]
60 meter_end: RateLimit[]
61 contaminated: boolean
62 contamination: string[]
63 warnings: string[]
64 transcript_path: string | null
65 fixture_tokens: number
66 steps: RunStep[]
67}
68
69const CSV_COLUMNS = [
70 'timestamp',
71 'experiment_id',
72 'replicate',
73 'session_id',
74 'source',
75 'model',
76 'effort',
77 'claude_code_version',
78 'ttl_requested',
79 'ttl_observed',
80 'input_tokens',
81 'output_tokens',
82 'cache_read_input_tokens',
83 'cache_creation_input_tokens',
84 'ephemeral_5m_input_tokens',
85 'ephemeral_1h_input_tokens',
86 'cache_miss_reason',
87 'context_tokens',
88 'context_window',
89 'rate_limits_before_json',
90 'rate_limits_after_json',
91 'api_equivalent_usd',
92 'contaminated',
93 'notes',
94 'result',
95 'cost_basis',
96] as const
97
98const csvCell = (value: unknown): string => {
99 if (value === null || value === undefined) return ''
100 const cell = String(value)
101
102 return /[",\r\n]/.test(cell) ? `"${cell.replaceAll('"', '""')}"` : cell
103}
104
105export const toCsv = (rows: readonly EventRow[]): string => {
106 const lines = rows.map(row => {
107 const flat: Record<string, unknown> = {
108 ...row,
109 rate_limits_before_json: JSON.stringify(row.rate_limits_before),
110 rate_limits_after_json: JSON.stringify(row.rate_limits_after),
111 }
112
113 return CSV_COLUMNS.map(column => csvCell(flat[column])).join(',')
114 })
115
116 return `${[CSV_COLUMNS.join(','), ...lines].join('\n')}\n`
117}
118
119export const parseJsonl = <T>(jsonl: string): T[] => {
120 const rows: T[] = []
121 for (const line of jsonl.split('\n')) {
122 if (line.trim() === '') continue
123 try {
124 rows.push(JSON.parse(line) as T)
125 } catch {
126 // a row cut short by a crash mid-write
127 }
128 }
129
130 return rows
131}
132
133/**
134 * How far each window moved between two meter readings. A window that reset
135 * in between has no difference worth reading: null.
136 */
137export const meterDelta = (
138 before: readonly RateLimit[],
139 after: readonly RateLimit[],
140): Record<string, number | null> => {
141 const delta: Record<string, number | null> = {}
142 for (const end of after) {
143 const start = before.find(limit => limit.kind === end.kind)
144 if (start === undefined) continue
145 delta[end.kind] =
146 start.resetsAt === end.resetsAt
147 ? Math.round((end.percentUsed - start.percentUsed) * 10) / 10
148 : null
149 }
150
151 return delta
152}
153
154type Spread = {
155 mean: number | null
156 min: number | null
157 max: number | null
158 n: number
159 sum: number
160 /** Sample variance; 0 for fewer than two values. */
161 variance: number
162}
163
164const spread = (values: readonly number[]): Spread => {
165 if (values.length === 0) {
166 return { mean: null, min: null, max: null, n: 0, sum: 0, variance: 0 }
167 }
168 const sum = values.reduce((total, value) => total + value, 0)
169 const mean = sum / values.length
170 const squares = values.reduce((total, value) => total + (value - mean) ** 2, 0)
171
172 return {
173 mean,
174 min: Math.min(...values),
175 max: Math.max(...values),
176 n: values.length,
177 sum,
178 variance: values.length > 1 ? squares / (values.length - 1) : 0,
179 }
180}
181
182export type RunSummary = {
183 replicate: number
184 session_id: string
185 apiEquivalentUsd: number
186 rateLimitDelta: Record<string, number | null>
187 finalStep: string
188 ttlObserved: string
189 status: 'clean' | 'contaminated' | 'ttl-mismatch'
190}
191
192export type CellSummary = {
193 experiment: string
194 ttl: string
195 /** Clean runs: the only ones the totals and the comparisons count. */
196 n: number
197 excludedRuns: number
198 totals: {
199 cacheRead: number
200 cacheWrite5m: number
201 cacheWrite1h: number
202 cacheWriteUnsplit: number
203 input: number
204 output: number
205 apiEquivalentUsd: number
206 }
207 apiEquivalentUsdPerRun: Spread
208 rateLimitDelta: Record<string, Spread>
209 cacheHitRate: number | null
210 runs: RunSummary[]
211 warnings: string[]
212}
213
214const cellTtl = (run: RunRecord): string =>
215 run.ttl_requested === '5m' || run.ttl_requested === '1h'
216 ? run.ttl_requested
217 : run.ttl_observed || 'automatic'
218
219const statusOf = (run: RunRecord): RunSummary['status'] => {
220 if (run.warnings.includes('TTL_CONFIG_MISMATCH')) return 'ttl-mismatch'
221
222 return run.contaminated ? 'contaminated' : 'clean'
223}
224
225const summarizeCell = (
226 experiment: string,
227 ttl: string,
228 runs: readonly RunRecord[],
229 events: readonly EventRow[],
230): CellSummary => {
231 const totals = {
232 cacheRead: 0,
233 cacheWrite5m: 0,
234 cacheWrite1h: 0,
235 cacheWriteUnsplit: 0,
236 input: 0,
237 output: 0,
238 apiEquivalentUsd: 0,
239 }
240 const warnings = new Set<string>()
241 const summaries: RunSummary[] = []
242 const deltas: Record<string, number[]> = {}
243 const costs: number[] = []
244 for (const run of runs) {
245 const status = statusOf(run)
246 const rows = events.filter(
247 row =>
248 row.kind !== 'meter' &&
249 row.session_id === run.session_id &&
250 row.experiment_id === run.experiment_id &&
251 row.replicate === run.replicate,
252 )
253 const usd = rows.reduce((sum, row) => sum + (row.api_equivalent_usd ?? 0), 0)
254 const delta = meterDelta(run.meter_start, run.meter_end)
255 summaries.push({
256 replicate: run.replicate,
257 session_id: run.session_id,
258 apiEquivalentUsd: usd,
259 rateLimitDelta: delta,
260 finalStep: run.steps.at(-1)?.result ?? '',
261 ttlObserved: run.ttl_observed,
262 status,
263 })
264 for (const warning of run.warnings) warnings.add(warning)
265 if (status !== 'clean') {
266 for (const cause of run.contamination) warnings.add(`contaminated: ${cause}`)
267 continue
268 }
269 costs.push(usd)
270 for (const [kind, moved] of Object.entries(delta)) {
271 if (moved === null) warnings.add(`METER_WINDOW_RESET: ${kind}`)
272 else (deltas[kind] ??= []).push(moved)
273 }
274 for (const row of rows) {
275 totals.cacheRead += row.cache_read_input_tokens
276 totals.input += row.input_tokens
277 totals.output += row.output_tokens
278 totals.apiEquivalentUsd += row.api_equivalent_usd ?? 0
279 if (row.api_equivalent_usd === null) warnings.add('UNPRICED_MODEL')
280 if (row.ephemeral_5m_input_tokens === null) {
281 totals.cacheWriteUnsplit += row.cache_creation_input_tokens
282 if (row.cache_creation_input_tokens > 0) {
283 warnings.add('UNSPLIT_CACHE_WRITES: priced at the assumed TTL')
284 }
285 } else {
286 totals.cacheWrite5m += row.ephemeral_5m_input_tokens
287 totals.cacheWrite1h += row.ephemeral_1h_input_tokens ?? 0
288 }
289 }
290 }
291 const prompt =
292 totals.cacheRead +
293 totals.cacheWrite5m +
294 totals.cacheWrite1h +
295 totals.cacheWriteUnsplit +
296 totals.input
297
298 return {
299 experiment,
300 ttl,
301 n: costs.length,
302 excludedRuns: runs.length - costs.length,
303 totals,
304 apiEquivalentUsdPerRun: spread(costs),
305 rateLimitDelta: Object.fromEntries(
306 Object.entries(deltas).map(([kind, values]) => [kind, spread(values)]),
307 ),
308 cacheHitRate: prompt === 0 ? null : totals.cacheRead / prompt,
309 runs: summaries,
310 warnings: [...warnings].sort(),
311 }
312}
313
314export type ApiVerdict = 'a' | 'b' | 'tie' | 'insufficient'
315
316export type MeterVerdict =
317 | 'a'
318 | 'b'
319 | 'unclear'
320 | 'insufficient-replicates'
321 | 'insufficient-resolution'
322
323export type Comparison = {
324 id: string
325 title: string
326 a: { label: string; experiment: string; ttl: string }
327 b: { label: string; experiment: string; ttl: string }
328 api: { verdict: ApiVerdict; a: Spread; b: Spread }
329 meter: {
330 verdict: MeterVerdict
331 byKind: Record<string, { a: Spread; b: Spread }>
332 }
333}
334
335const COMPARISONS = [
336 {
337 id: 'W',
338 title: '只写缓存(建缓存 + 1 次 probe):5m 写入 vs 1h 写入',
339 a: { label: '5m', experiment: 'A-ttl', ttl: '5m' },
340 b: { label: '1h', experiment: 'A-ttl', ttl: '1h' },
341 },
342 {
343 id: 'C1',
344 title: '高频 active coding(约 3 分钟一次请求):5m vs 1h',
345 a: { label: '5m', experiment: 'C1-active', ttl: '5m' },
346 b: { label: '1h', experiment: 'C1-active', ttl: '1h' },
347 },
348 {
349 id: 'C2',
350 title: '间歇 coding(约 10 分钟一次请求):5m vs 1h',
351 a: { label: '5m', experiment: 'C2-intermittent', ttl: '5m' },
352 b: { label: '1h', experiment: 'C2-intermittent', ttl: '1h' },
353 },
354 {
355 id: 'D-keeper',
356 title: 'idle 18 分钟:5m + keeper vs 5m 无 keeper',
357 a: { label: '5m+keeper', experiment: 'D2-keeper', ttl: '5m' },
358 b: { label: '5m 无 keeper', experiment: 'D1-idle', ttl: '5m' },
359 },
360 {
361 id: 'D-1h',
362 title: 'idle 18 分钟:5m + keeper vs 1h 无 keeper',
363 a: { label: '5m+keeper', experiment: 'D2-keeper', ttl: '5m' },
364 b: { label: '1h 无 keeper', experiment: 'D1-idle', ttl: '1h' },
365 },
366] as const
367
368/** The replicates a meter verdict needs on each side. */
369export const MIN_REPLICATES = 3
370/** The percentage points a cell's runs must move a window by, in all, for its meter to say anything. */
371export const MIN_METER_POINTS = 2
372// The meter reports whole points, so one run's delta is off by up to a point
373// either way. Runs that all read the same are not that exact: their variance
374// is held to at least what rounding alone gives a difference of two readings.
375const ROUNDING_VARIANCE = 0.25
376/** How many standard errors apart two cells' mean deltas must be to call one cheaper. */
377const MIN_SEPARATION = 2.5
378
379const EMPTY: Spread = { mean: null, min: null, max: null, n: 0, sum: 0, variance: 0 }
380
381const compare = (
382 spec: (typeof COMPARISONS)[number],
383 cells: readonly CellSummary[],
384): Comparison => {
385 const cellOf = (side: { experiment: string; ttl: string }) =>
386 cells.find(
387 cell => cell.experiment === side.experiment && cell.ttl === side.ttl,
388 )
389 const a = cellOf(spec.a)
390 const b = cellOf(spec.b)
391 const costA = a?.apiEquivalentUsdPerRun ?? EMPTY
392 const costB = b?.apiEquivalentUsdPerRun ?? EMPTY
393 let api: ApiVerdict = 'insufficient'
394 if (costA.mean !== null && costB.mean !== null) {
395 if (costA.mean < costB.mean * 0.97) api = 'a'
396 else if (costB.mean < costA.mean * 0.97) api = 'b'
397 else api = 'tie'
398 }
399
400 const kinds = new Set([
401 ...Object.keys(a?.rateLimitDelta ?? {}),
402 ...Object.keys(b?.rateLimitDelta ?? {}),
403 ])
404 const byKind: Comparison['meter']['byKind'] = {}
405 const decided = new Set<'a' | 'b'>()
406 let hasReplicates = false
407 let hasResolution = false
408 for (const kind of kinds) {
409 const sideA = a?.rateLimitDelta[kind] ?? EMPTY
410 const sideB = b?.rateLimitDelta[kind] ?? EMPTY
411 byKind[kind] = { a: sideA, b: sideB }
412 if (sideA.n < MIN_REPLICATES || sideB.n < MIN_REPLICATES) continue
413 hasReplicates = true
414 if (Math.max(sideA.sum, sideB.sum) < MIN_METER_POINTS) continue
415 hasResolution = true
416 const error = Math.sqrt(
417 Math.max(sideA.variance, ROUNDING_VARIANCE) / sideA.n +
418 Math.max(sideB.variance, ROUNDING_VARIANCE) / sideB.n,
419 )
420 const apart = ((sideB.mean ?? 0) - (sideA.mean ?? 0)) / error
421 if (apart >= MIN_SEPARATION) decided.add('a')
422 else if (apart <= -MIN_SEPARATION) decided.add('b')
423 }
424 let meter: MeterVerdict = 'unclear'
425 if (!hasReplicates) meter = 'insufficient-replicates'
426 else if (!hasResolution) meter = 'insufficient-resolution'
427 else if (decided.size === 1) meter = [...decided][0] ?? 'unclear'
428
429 return {
430 id: spec.id,
431 title: spec.title,
432 a: spec.a,
433 b: spec.b,
434 api: { verdict: api, a: costA, b: costB },
435 meter: { verdict: meter, byKind },
436 }
437}
438
439export type Conclusion = { category: 1 | 2 | 3 | 4 | 5; text: string }
440
441const conclude = (comparisons: readonly Comparison[]): Conclusion => {
442 const rhythm = comparisons.filter(one => one.id === 'C1' || one.id === 'C2')
443 if (rhythm.every(one => one.api.verdict === 'insufficient')) {
444 return {
445 category: 5,
446 text: '数据不足,无法判断:C1/C2 还没有可比较的 5m 与 1h 干净 run。',
447 }
448 }
449 const api = new Set(
450 rhythm.map(one => one.api.verdict).filter(v => v === 'a' || v === 'b'),
451 )
452 const meter = new Set(
453 rhythm.map(one => one.meter.verdict).filter(v => v === 'a' || v === 'b'),
454 )
455 const isMeterBlind = rhythm.every(one =>
456 one.meter.verdict.startsWith('insufficient'),
457 )
458 if (isMeterBlind) {
459 const writes = comparisons.find(one => one.id === 'W')?.meter.verdict
460 const onWrites =
461 writes === 'a'
462 ? '只写缓存的对比(W)里,meter 显示 5m 写入比 1h 写入省。'
463 : writes === 'b'
464 ? '只写缓存的对比(W)里,meter 显示 1h 写入比 5m 写入省。'
465 : writes === 'unclear'
466 ? '只写缓存的对比(W)里,meter 没有显示出 5m 与 1h 写入的显著差异。'
467 : ''
468
469 return {
470 category: 5,
471 text: `C1/C2 上 Pro meter 的分辨率或重复次数不足,暂不能按工作节奏判断哪种 TTL 更省套餐额度;API 等价成本见下表,但不能替代 meter 结论。${onWrites}`,
472 }
473 }
474 if (api.size > 1 || meter.size > 1) {
475 return { category: 4, text: '结果随工作节奏而变化:见各对比的分项结论。' }
476 }
477 if (meter.has('b')) {
478 return { category: 3, text: '1h 更便宜:Pro meter 在已判定的工作节奏下偏向 1h。' }
479 }
480 if (meter.has('a')) {
481 return api.has('b')
482 ? { category: 4, text: 'API 等价成本与 Pro meter 的结论相反:见各对比的分项结论。' }
483 : { category: 1, text: '5m 在 API 等价成本和 Pro meter 两边都更便宜(限已判定的工作节奏)。' }
484 }
485 if (api.has('a')) {
486 return { category: 2, text: '5m 只在 API 等价成本上更便宜,Pro meter 无显著优势。' }
487 }
488
489 return {
490 category: api.has('b') ? 3 : 2,
491 text: api.has('b')
492 ? '1h 在 API 等价成本上更便宜;Pro meter 无显著差异。'
493 : 'API 等价成本与 Pro meter 均无显著差异。',
494 }
495}
496
497export type ObservationRow = {
498 session_id: string
499 source: string
500 ttl: string
501 requests: number
502 cacheRead: number
503 cacheWrite: number
504 input: number
505 output: number
506 apiEquivalentUsd: number
507}
508
509export type Summary = {
510 generatedAt: string
511 counts: { events: number; runs: number }
512 cells: CellSummary[]
513 comparisons: Comparison[]
514 conclusion: Conclusion
515 observation: ObservationRow[]
516}
517
518export const summarize = (
519 events: readonly EventRow[],
520 runs: readonly RunRecord[],
521 generatedAt: string,
522): Summary => {
523 const groups = new Map<string, RunRecord[]>()
524 for (const run of runs) {
525 const key = `${run.experiment_id}\u0000${cellTtl(run)}`
526 const group = groups.get(key)
527 if (group === undefined) groups.set(key, [run])
528 else group.push(run)
529 }
530 const cells = [...groups.entries()]
531 .map(([key, group]) => {
532 const [experiment = '', ttl = ''] = key.split('\u0000')
533
534 return summarizeCell(experiment, ttl, group, events)
535 })
536 .sort((x, y) => `${x.experiment}${x.ttl}`.localeCompare(`${y.experiment}${y.ttl}`))
537 const comparisons = COMPARISONS.map(spec => compare(spec, cells))
538
539 const observed = new Map<string, ObservationRow>()
540 for (const row of events) {
541 if (row.kind === 'meter' || row.experiment_id !== '') continue
542 const ttl = row.ttl_observed || row.ttl_requested
543 const key = `${row.session_id}\u0000${row.source}\u0000${ttl}`
544 const entry = observed.get(key) ?? {
545 session_id: row.session_id,
546 source: row.source,
547 ttl,
548 requests: 0,
549 cacheRead: 0,
550 cacheWrite: 0,
551 input: 0,
552 output: 0,
553 apiEquivalentUsd: 0,
554 }
555 entry.requests += 1
556 entry.cacheRead += row.cache_read_input_tokens
557 entry.cacheWrite += row.cache_creation_input_tokens
558 entry.input += row.input_tokens
559 entry.output += row.output_tokens
560 entry.apiEquivalentUsd += row.api_equivalent_usd ?? 0
561 observed.set(key, entry)
562 }
563
564 return {
565 generatedAt,
566 counts: { events: events.length, runs: runs.length },
567 cells,
568 comparisons,
569 conclusion: conclude(comparisons),
570 observation: [...observed.values()],
571 }
572}
573
574const usd = (value: number | null): string =>
575 value === null ? '-' : `$${value.toFixed(4)}`
576
577const points = (value: Spread): string =>
578 value.mean === null
579 ? '-'
580 : `${value.mean.toFixed(1)} (${value.min}~${value.max}, n=${value.n})`
581
582const API_WORDS: Record<ApiVerdict, string> = {
583 a: 'A 更便宜',
584 b: 'B 更便宜',
585 tie: '差异小于 3%',
586 insufficient: '数据不足',
587}
588
589const METER_WORDS: Record<MeterVerdict, string> = {
590 a: 'A 更省',
591 b: 'B 更省',
592 unclear: '区间重叠,无显著差异',
593 'insufficient-replicates': `每侧不足 ${MIN_REPLICATES} 次干净重复,无法判断`,
594 'insufficient-resolution': `该组各 run 的 meter 累计变化不足 ${MIN_METER_POINTS} 个百分点,分辨率不足`,
595}
596
597const table = (head: readonly string[], rows: readonly (readonly string[])[]): string =>
598 [
599 `| ${head.join(' | ')} |`,
600 `| ${head.map(() => '---').join(' | ')} |`,
601 ...rows.map(row => `| ${row.join(' | ')} |`),
602 ].join('\n')
603
604export const renderReport = (summary: Summary): string => {
605 const parts: string[] = [
606 '# cache-keeper 报告',
607 '',
608 `生成时间:${summary.generatedAt};事件 ${summary.counts.events} 条,实验 run ${summary.counts.runs} 个。`,
609 '',
610 '## 结论',
611 '',
612 `**类型 ${summary.conclusion.category}:${summary.conclusion.text}**`,
613 '',
614 '两种成本分开报告:API 等价成本按官方 API 单价由日志算出;套餐额度只看 `$.session.usage().rateLimits` 的真实百分比增量,不由 API 价格推算。',
615 '',
616 '## 对比',
617 '',
618 ]
619 for (const one of summary.comparisons) {
620 parts.push(
621 `### ${one.id}:${one.title}`,
622 '',
623 `A = ${one.a.label}(${one.a.experiment}),B = ${one.b.label}(${one.b.experiment})`,
624 '',
625 table(
626 ['成本口径', 'A', 'B', '判定'],
627 [
628 [
629 'API 等价 USD / run',
630 `${usd(one.api.a.mean)} (n=${one.api.a.n})`,
631 `${usd(one.api.b.mean)} (n=${one.api.b.n})`,
632 API_WORDS[one.api.verdict],
633 ],
634 ...Object.entries(one.meter.byKind).map(([kind, sides]) => [
635 `套餐 meter ${kind} 增量(百分点)`,
636 points(sides.a),
637 points(sides.b),
638 METER_WORDS[one.meter.verdict],
639 ]),
640 ...(Object.keys(one.meter.byKind).length === 0
641 ? [['套餐 meter', '-', '-', METER_WORDS[one.meter.verdict]]]
642 : []),
643 ],
644 ),
645 '',
646 )
647 }
648
649 parts.push('## 实验 cell', '')
650 if (summary.cells.length === 0) parts.push('还没有完成的实验 run。', '')
651 for (const cell of summary.cells) {
652 parts.push(
653 `### ${cell.experiment} · TTL ${cell.ttl}`,
654 '',
655 `干净 run ${cell.n} 个,排除 ${cell.excludedRuns} 个;cache 命中率 ${
656 cell.cacheHitRate === null ? '-' : `${(cell.cacheHitRate * 100).toFixed(1)}%`
657 };API 等价合计 ${usd(cell.totals.apiEquivalentUsd)}。`,
658 '',
659 table(
660 ['replicate', 'session', '状态', 'TTL 实测', '末步结果', 'API 等价 USD', 'meter 增量'],
661 cell.runs.map(run => [
662 String(run.replicate),
663 run.session_id.slice(0, 8),
664 run.status,
665 run.ttlObserved || '-',
666 run.finalStep || '-',
667 usd(run.apiEquivalentUsd),
668 Object.entries(run.rateLimitDelta)
669 .map(([kind, moved]) => `${kind} ${moved ?? 'reset'}`)
670 .join('; ') || '-',
671 ]),
672 ),
673 '',
674 table(
675 ['cache read', '5m write', '1h write', '未拆分 write', 'input', 'output'],
676 [
677 [
678 String(cell.totals.cacheRead),
679 String(cell.totals.cacheWrite5m),
680 String(cell.totals.cacheWrite1h),
681 String(cell.totals.cacheWriteUnsplit),
682 String(cell.totals.input),
683 String(cell.totals.output),
684 ],
685 ],
686 ),
687 '',
688 )
689 if (cell.warnings.length > 0) {
690 parts.push(...cell.warnings.map(warning => `- 警告:${warning}`), '')
691 }
692 }
693
694 parts.push('## 日常观察(非实验请求)', '')
695 parts.push(
696 summary.observation.length === 0
697 ? '无。'
698 : table(
699 ['session', '来源', 'TTL', '请求数', 'cache read', 'cache write', 'input', 'output', 'API 等价 USD'],
700 summary.observation.map(row => [
701 row.session_id.slice(0, 8),
702 row.source,
703 row.ttl,
704 String(row.requests),
705 String(row.cacheRead),
706 String(row.cacheWrite),
707 String(row.input),
708 String(row.output),
709 usd(row.apiEquivalentUsd),
710 ]),
711 ),
712 '',
713 )
714
715 return parts.join('\n')
716}
717types/index.d.ts 21 lines1declare module 'claude-code' {
2 interface PluginState {
3 'cache-keeper': {
4 bar: string
5 /** The main conversation's prompt cache as the keeper has it, for other mods to read. */
6 cache: {
7 /** When the last request or keepalive that touched the cache started, in ms. */
8 lastActivityAt: number | null
9 ttl: '5m' | '1h' | null
10 /** `lastActivityAt` plus the TTL: past it the cache is cold. */
11 expiresAt: number | null
12 cachedPrefixTokens: number
13 isEnabled: boolean
14 pausedReason: string | null
15 /** `wrapped`: the wrap-up was sent and the session waits for the 5-hour reset. */
16 bridgePhase: 'idle' | 'wrapped'
17 }
18 }
19 }
20}
21