SLOPSHOPPER

cache-keeper

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

newbandguardcommandtoaststatus
★ 3v0.5.0MITupdated 2026-10-15wdsewdser-bit/claude-code-mods/cache-keeper
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cache-keeper
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /cache-keeper ⎿ cache-keeper: keeper on · cache ttl? 0k · ttl-unknown ⎿ cache-keeper: anchor off ⎿ cache-keeper: Claude Code 2.1.289 · model claude-opus-5-5 · effort unknown ⎿ cache-keeper: TTL requested: automatic (automatic) · observed in transcript: not yet · scheduling by: unknown ⎿ cache-keeper: cached prefix: 0 tokens (minimum 40000) · no request seen yet ⎿ cache-keeper: keepalives this idle stretch: 0 of 3 · hits 0 · misses 0 缓存保活:开 · cache ttl? 0k · ttl-unknown [ 开启保活 ] [ 关闭保活 ] [ 设为锚点 ] [ 取消锚点 ] ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ cache-keeper: cache ttl? 0k · ttl-unknown

Draws

Band
缓存保活:开 · cache ttl? 0k · ttl-unknown [ 开启保活 ] [ 关闭保活 ] [ 设为锚点 ] [ 取消锚点 ] ⟨Claude Code's own drawing⟩
README

cache-keeper

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 [onoff]`查看 / 设置当前会话是否为锚点(见「锚点会话」);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;没有处于"待重建基线"。

  • TTL 已过就不再发:那只会是一次整段重写,等下一次真实请求。
  • 一段 idle 内的保活次数有上限 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 小时额度超阈值:自动停止并提示。周额度不参与:它没用完时停保活只会换来一次整段重写,用完后请求失败,按失败次数停。
  • 模型或 effort 变化、compact 之后:暂停,直到下一次真实请求再次命中缓存。/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 时不发。
  • 保活的那一轮结束时引擎会给主会话发完成通知,mod 在 prompt.submit 把它丢掉,主模型不会被叫起来。transcript 里留一行 "Prompt dropped by a hook: cache-keeper kept subagent … warm"。
  • 子代理的对话里会多出保活的一问一答,各几十个 token。
  • 某次保活没命中(实际 TTL 比设想的短):不再保那个子代理;连续 maxConsecutiveProbeFailures 次就整个停掉子代理保活。
  • 后台子代理按引擎的列表认,前台子代理按 session 存下的 subagents/agent-<id>.meta.json 认;引擎自己的 fork(compact 等)两处都没有,不会被唤起。agent 定义里单独写的 cacheTtl 这里看不到。

TTL 的来源与验证

请求值按引擎自己的优先级读取: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实验时长内容
ttlA1.5 min建缓存,90 s 后 probe。5m 和 1h 各跑一次
renewB12 min5m:4:00、8:00 保活,12:00 probe
renew-controlB 对照6.5 min5m:不保活,6:30 probe
activeC133 min每 3 分钟一次 probe,共 11 次。5m 和 1h 各 3 次
intermittentC250 min每 10 分钟一次 probe,共 5 次。5m 和 1h 各 3 次
keeperD218 min5m:每 4:05 保活,18:00 probe
keeper-controlD118 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>

已知限制

  • fork 请求不写进 transcript,所以 keeper 行拿不到 5m/1h 拆分;它极少写缓存,写了就按该 session 的 TTL 计价(cost_basis 会标 assumed-*)。
  • 前台子代理运行期间主会话的 turn 还没结束,主保活不发;单个子代理连续跑过主会话的 TTL,回来那一步会整段重写。
  • system prompt / 工具定义的变化没有直接事件,只能通过随后请求的 miss 和 cache_miss_reason 看出来。
  • transcript 超过 4 MiB 时改用 node scripts/analyze-transcript.ts --tail 读尾部;没有 node 就拿不到拆分。
  • Mods API 仍是 early access,升级 Claude Code 后先跑 claude plugin validate 和 claude plugin test。

额度桥接(v0.4.0)

5 小时额度到 bridgeAtPercent(默认 90)时:给主会话发一条收尾消息(只完成当前一步、写交接、停下);之后按 TTL 持续保活到额度重置(不受 maxIdleKeepalives 限制,不设百分比停止线(额度再高也保,直到重置);距重置够近、缓存撑得到重置后的「继续」就不再多 ping);重置后约 20 秒自动发「继续」。

  • 只有最近 10 分钟(取它与缓存 TTL 中更短的一个)内有用户或助手消息、或仍在跑回合、或仍有后台任务的会话才会被唤醒;久未动的旧会话不会被叫起来。保活 fork 不算活动,不刷新计数。
  • 不给子代理发任何消息。每个窗口只收尾一次。
  • 收尾后你自己又发了消息 = 接管,重置后不再自动继续。stop(命令或按钮)取消桥接。
  • 输入框上方有一条状态栏,带「开启保活」「关闭保活」两个按钮,等同 /cache-keeper start|stop,只作用于当前会话。
  • 周额度到 weeklyHandoffAtPercent(默认 98,0 = 关)时,给最近 10 分钟内有用户消息、或仍在跑回合、或仍有后台任务的主会话发一条通知:写一份交接检查点(更新已有台账,没有就新建 HANDOFF.md),然后继续干活。每个周窗口只发一次;不叫停、不保活到重置、重置后不自动继续,由新对话按文档接手。与保活开关无关。
  • 选项:bridgeEnabled、bridgeAtPercent、weeklyHandoffAtPercent。逻辑在 hooks/lib/bridge.ts,测试在 tests/bridge.test.ts。

给其他 mod 读的缓存状态(v0.4.1)

  • $.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' }) 读,只读。
  • 别的 mod 回答了压缩(不调 next)时,本 mod 的 session.compact 钩子可能收不到。另加两道判断,任一成立就按"已压缩"暂停保活,直到下一次真实请求再次命中:引擎存下压缩边界行(session.append,door: compaction);或保活前发现当前上下文不到所记前缀的一半。

锚点会话(v0.5.0)

用途:让一个几乎空的会话长期挂着,靠保活让跨会话共享的系统前缀缓存不过期,别的会话开局就能命中它。

  • 开启:/cache-keeper anchor on(保活原本是关的则一并开启),或点输入框上方栏里的「设为锚点」;anchor off 或「取消锚点」只清掉锚点标记,不改保活开关;/cache-keeper anchor 查看当前状态。栏文字在锚点开启时带「锚点」。状态跨重载保存。
  • 与普通保活的差别:不受 maxIdleKeepalives 限制(status 里 budget 显示 unbounded),也没有 5 小时额度的百分比停止线(与额度桥接期间一致)。其余停止条件不变:被 stop、连续失败、压缩暂停、前缀小于 minCacheReadTokens。
  • 缓存一旦过期仍不会补写:弹一次提示「anchor cache expired」,之后要在该会话里手动发一条消息重建;下一次真实请求或保活命中后提示复位。
  • 只有锚点与目标会话的插件 / MCP / 工具集一致,系统前缀才相同、缓存才共享;两边不一致时锚点保的是另一份缓存。
Source 9 files
hooks/register.tsx 2021 lines
1import { 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    }
1200
hooks/lib/bridge.ts 243 lines
1import 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'
243
hooks/lib/classify.ts 99 lines
1import 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
99
hooks/lib/experiment.ts 160 lines
1import 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}`
160
hooks/lib/keeper.ts 255 lines
1import 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}
255
hooks/lib/parser.ts 260 lines
1import 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}
260
hooks/lib/pricing.ts 120 lines
1export 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}
120
hooks/lib/report.ts 717 lines
1import 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}
717
types/index.d.ts 21 lines
1declare 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