SLOPSHOPPER

cache-keeper

Keeps a big chat's prompt cache warm, warns before an expensive cold restart, shows every local chat on one board (/board), and hands a chat off to a fresh one…

newpanebandspinnerguardcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cache-keeper
│ ┃ Sessions ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ STATE SESSION CONTEXT CACHE │ cache-keeper │ │ ┃ COST AGENTS ⏺ Read(src/auth.ts) │ Keeping this cache warm until 8:53pm. A │ │ ┃ ○ idle app · fix the f… 97.4k 49 – ⎿ Read 6 lines │ ping reads the cache before it expires. │ │ ┃ └ warm until 8:53pm, 0 ping(s) ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ┃ $0 ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ 1 session · $0.42 total · 0 waiting on you · ⎿ 3 pass, 1 fail │ ┃ cold restarts $0 · keep warm reads the cache │ ┃ before it expires · Esc closes ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /cache │ ⎿ cache-keeper: Cache Keeper: 97.4k tokens in context on opus-5-5, │ ⎿ cache-keeper: No request yet this session, so the cache state is │ ⎿ cache-keeper: Plan limits used: 5h 31%. │ ⎿ cache-keeper: Session cost so far: $0.42. Cold restarts this ses │ ⎿ cache-keeper: Ledger, this chat: keep warm net $0 (spent $0, sav │ ⎿ cache-keeper: Cold-send guard: on for contexts over 150k tokens. │ │ │ ctx 97.4k │ rewrite ≈ $0.78 │ 5h 31% │ session $0.42 h: handoff ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
│ ctx 97.4k │ rewrite ≈ $0.78 │ 5h 31% │ session $0.42 h: handoff ⟨Claude Code's own drawing⟩
Pane · Sessions
STATE SESSION CONTEXT CACHE COST AGENTS ○ idle app · fix the f… 97.4k 49 – $0.42 └ warm until 8:53pm, 0 ping(s) $0 1 session · $0.42 total · 0 waiting on you · cold restarts $0 · keep warm reads the cache before it expires · Esc closes
README

Claude Code mods

Four mods for Claude Code. Mods are plugins that run inside Claude Code: they can draw on screen (a line above the prompt, a footer label, a side pane), add slash commands, and step in before Claude runs a tool. These four save money on big chats, keep private things off screen while you record, show how far along a /goal is, and stop two chats from editing the same file.

ModCommandsWhat it does
Cache Keeper/cache, /keepwarm, /board, /handoffShows whether a chat's prompt cache is warm or cold and what a cold restart would cost, keeps big chats warm, asks before an expensive cold send, shows every local chat on one board, and hands a chat off to a fresh one
Recording Mode/rec, /rec strict, /rec off, /rec configMasks keys, personal details, and business figures on screen and keeps private files closed while you record
Goal Meter/goalsA progress bar for /goal built from Claude's own task plan, with an ETA and every chat's goal in one pane
Collision Guard/guardAsks before Claude edits a file another open chat changed in the last 30 minutes

This is a continuation of Nate Herk's claude-code-mods (MIT), which is no longer developed. Everything up to the first commit is his; the rest of the history is ours. Cache Keeper grew the most, and Goal Meter has a bug fix. Recording Mode and Collision Guard are still as Nate wrote them. More mods can be added to this repo, because a repo like this is a plugin marketplace with one folder per mod.

Requirements

  • Claude Code 2.1.287 or later. Check with claude --version. The mods work in the terminal and in the Desktop app's Code tab.
  • Mods turned on for your account. Anthropic is turning mods on gradually. Run claude plugin test in any folder: "no hooks module to load" means mods can load, and "served off" means they can't yet. Nothing needs reinstalling once they're on.

Install

Add this repo as a plugin marketplace:

claude plugin marketplace add jurczykpawel/claude-code-mods

Then install the mods you want, one command each:

claude plugin install cache-keeper@techskillacademy-mods
claude plugin install recording-mode@techskillacademy-mods
claude plugin install goal-meter@techskillacademy-mods
claude plugin install collision-guard@techskillacademy-mods

Then open a new chat. A mod loads when a chat starts, and /reload-plugins does not reload it.

Update: run claude plugin marketplace update techskillacademy-mods, then claude plugin update <mod>@techskillacademy-mods, then open a new chat. Installs are copies, so nothing changes until you update.

Remove: claude plugin uninstall <mod>@techskillacademy-mods.

Cache Keeper

Every request re-reads the whole chat. From the prompt cache that costs a tenth of the normal input price. Once the cache expires (after 5 minutes or 1 hour idle, depending on your setup), the next message writes the whole chat into the cache again at up to 2x the input price. On a 400k-token chat that one message can cost a few dollars, while keeping the cache warm costs cents.

  • Line above the prompt: ● cache warm 42m │ ctx 412k │ rewrite ≈ $3.30 │ session $5.12. It turns yellow 5 minutes before the cache expires and shows a keep warm button. Your 5-hour and weekly plan limits show there too, yellow at 80% and red at 95%.
  • /keepwarm [hours|off]: keeps this chat's cache warm while you are away. It stops after N hours with no message from you (12 by default, up to 24); every message restarts that count. A small ping goes out shortly before it would expire. If a ping writes to the cache instead of reading it, keep warm turns itself off and tells you.
  • Cold-send guard: if you send a message into a chat over 150k tokens after its cache expired, it asks first: Send anyway, Compact first, or Cancel.
  • /board: every local chat in one pane, with chats waiting on you first, then working ones, then the ones whose cache cools soonest. Each row shows state, context size, cache, cost, and running agents, with a keep-warm button.
  • Handoff: the handoff button (or /handoff) runs the bundled /session-handoff skill, saves the handoff, then clears the chat and sends the handoff as the fresh chat's first prompt. A short fresh chat is far cheaper than a huge one.
  • Ledger, /cache stats: what keep warm cost against what cold restarts cost, over the last 30 days, shown only when you ask. Its first line is the version this chat loaded, so you can tell a chat that started before an update.
  • When compacting pays off, /cache compact: compares the price of compacting now with the price of carrying the long context, using your own measured numbers. /cache horizon auto|N sets how many turns ahead it counts.
  • Compact before keep warm ends, /cache endcompact 200k|off: when keep warm is about to hit its time limit on a big chat, Cache Keeper compacts while the cache is still warm instead of letting it expire and paying for a full rewrite.
  • Idle guard, /cache idleguard on|off: Claude Code can compact a chat on its own just before the 1-hour cache expires. While keep warm is on and nobody has written for 30 minutes, the guard vetoes that, and /cache stats shows how many times it did.
  • /cache autocompact 300k|off: asks before the cache expires whether to compact a chat above that size. /cache reset puts every setting back to its default.
  • /cache shows status and settings: ttl 5|60|auto, guard on|off, big 150k, alerts on|off.

For unattended agent sessions, set these variables in the session's environment before starting Claude Code:

CACHE_KEEPER_KEEPWARM_HOURS=24 CACHE_KEEPER_GUARD=off claude

CACHE_KEEPER_KEEPWARM_HOURS starts keep warm automatically for 1–72 hours, including on resume, clear, and fork. Every real message restarts that window; /keepwarm off stops it for the rest of that session. Missing or invalid values leave keep warm off. CACHE_KEEPER_GUARD=off sends cold messages without the cold-send dialog in this session only; other values use the saved guard setting. Neither variable changes saved settings. Manual /keepwarm still has its 24-hour maximum.

Prices come from the Claude API price list. On a subscription plan they show up as usage against your limits, not a bill.

Recording Mode

Run /rec before you hit record. It applies to every open chat within 3 seconds, and /rec off turns it off everywhere.

  • Masks on screen: API keys and tokens, every value in a .env file in the project folder or its parents, emails, names, phone numbers, street addresses, card, account, and ID numbers, money, and figures next to business words (revenue, margin, payroll, and so on). /rec strict also masks every large figure and percentage.
  • Hides whole results from mail, chat, task, calendar, file, CRM, and finance tools. Claude still gets the real result; only the screen changes.
  • Keeps files closed: .env files, credentials, SSH and cloud keys, Claude's memory folder, and finance documents (payroll, invoices, contracts, budgets, forecasts, tax files). Finance tools like QuickBooks stay closed too. Claude is told it can't open them while recording.
  • Tells Claude: each prompt carries a hidden note to keep names, figures, and internal plans out of its replies.
  • A red ● REC shows above the prompt and in the footer while it's on.

Make it yours with /rec config

/rec config creates ~/.claude/mods-data/recording-mode/config.json:

{
  "keepNames": ["Your Name"],
  "names": ["A Teammate", "A Client"],
  "privatePaths": ["clients/", "finance/", "my-private-notes.md"],
  "businessTools": [],
  "closedTools": []
}
  • keepNames: your own name (and your brand), shown even while recording.
  • names: people whose names are always masked. Names in emails and contact fields are picked up on their own.
  • privatePaths: folders or file names Claude may not open while recording. Any part of a path matches, in any case.
  • businessTools: extra tool or command names whose results draw as hidden.
  • closedTools: extra tool names Claude may not call while recording.

Save the file and run /rec again.

Limits: it changes what is drawn on screen and what Claude may open, not what is stored. Pattern masking can't catch everything written in plain words, so watch your footage before you publish. The chat titles in the Desktop app's sidebar can't be masked by a mod.

Goal Meter

Type /goal <what done looks like> as usual. Claude first writes its plan with the goal meter's task tool, every task sized S, M, or L, then marks each task started and done as it works.

  • Line above the prompt: 5 of 8 tasks · 69%, a progress bar, 38m elapsed · about 17m left (≈3:40pm) · plan grew 7 → 8 · 1 agent running, the last tasks done, what's running and which agent runs it, the next tasks, and the goal check's last "not met yet" reason. Press g for the full list.
  • Footer: ◎ goal 69% · ~17m, visible even when the line above the prompt is hidden.
  • Percent and ETA: the percentage is finished work out of the plan, with S, M, and L tasks counting 1, 2, and 3. The ETA uses this goal's own pace so far and appears once 2 tasks are done. When Claude finds more work, the plan grows and the bar can step back.
  • /goals: this chat's whole task list, plus every other chat's goal from the last 12 hours.
  • Settings: /goals hide|show (the line above the prompt), /goals strict on (no file edits in a goal before the plan exists), /goals clear.

It sends no extra requests to Claude. The task tool calls Claude makes are a few small ones per goal. A plan Claude makes without /goal shows the same way.

Collision Guard

When you run several chats in one project, two of them can end up changing the same file without knowing it. Before Claude edits or writes a file, Collision Guard checks whether another open chat changed that file in the last 30 minutes. If one did, it asks:

  • Proceed: edit anyway.
  • Move to a worktree: Claude moves this chat's work into a git worktree so the two chats stop touching the same files (offered when git tracks the file).
  • Cancel: Claude leaves the file alone and asks you how to go on.

/guard shows what it sees, /guard off turns it off, and /guard window 60 changes the 30-minute window. Each chat keeps a small list of the files it changed, and nothing is added to Claude's prompt.

Where the data lives

Everything stays on your machine, under ~/.claude/mods-data/: Cache Keeper's session heartbeats and saved handoffs, the recording flag and config, each chat's goal plan, and Collision Guard's file lists. The mods make no network calls of their own. Cache Keeper's keep-warm pings are small model requests, sent only while /keepwarm is on.

License

MIT. See LICENSE.

Source 10 files
hooks/register.mjs 1304 lines
1// Cache Keeper: watches this session's prompt cache, keeps big caches warm on
2// request, refuses a cold send without asking first, and shows every local
3// session on one board (/board).
4//
5// Why: each request re-reads the whole context. From the cache that costs about
6// a tenth of normal input. Once the cache expires (1 hour idle on a subscription,
7// 5 minutes on the default API TTL), the next message writes the whole context
8// again at 1.25x to 2x input. A cache read also restarts the timer, so a tiny
9// ping before expiry costs a fraction of a rewrite.
10
11import { rewriteCost, requestCost, totalInput, cachedShare, priceFor } from './pricing.mjs'
12import { tokens, usd, minutes, clock, clip, basename, pad } from './fmt.mjs'
13import { makeMasker } from './privacy.mjs'
14import { classifyColdRestart, settleKeepWarm, summarize, pruneEvents } from './ledger.mjs'
15import { VERSION } from './version.mjs'
16import { endCompactDue } from './endcompact.mjs'
17import { slideUntil, pingDue } from './keepwarmwindow.mjs'
18import { shouldBlockCompact, summarizeIdleBlocks, vetoResult, blockOverridden } from './idleguard.mjs'
19import { DEFAULT_PARAMS, advise, calibrate, gapShare, stepsPerTurn, horizonFrom, adviceSummary } from './compact.mjs'
20
21const MIN = 60000
22const HEARTBEAT_EVERY = 30000
23const LEDGER_DAYS = 30
24const PANE = 'cache-board'
25
26// This session
27const S = {
28  id: '',
29  cwd: '',
30  label: '',
31  model: '',
32  lastActivity: 0, // last main-loop request or keep-warm ping that touched the cache
33  ctx: 0,
34  window: 0,
35  costUsd: 0,
36  ttlMin: 60,
37  ttlSource: 'default',
38  coldRestarts: [],
39  // The ledger (ledger.mjs): what keep warm cost and saved, what cold restarts cost
40  lastRealAt: 0, // last main-loop request; pings don't count
41  episode: null, // pings since lastRealAt: { lastRealAt, pings, usd }, settled by the next request
42  pingReadTokens: 0, // cached tokens the pings read, for the measured cost per token
43  longestSleepMs: 0, // longest stall of the 30 s timer since lastRealAt (sleep, suspend)
44  pendingResume: false,
45  turns: 0, // messages this session: the base of the gap share and requests per message
46  steps: 0, // model requests of the main loop
47  longGaps: 0, // messages that arrived after a gap longer than the cache TTL
48  advisedCtx: 0, // context size the 'worth compacting' notice was last shown for
49  adviceTurns: null, // S.turns at this session's first notice; the outcome is written when the session ends
50  adviceCtx: 0,
51  adviceCompactedAfter: null, // messages between that notice and the first compaction after it
52  events: [], // this session id's ledger events, also in $.store as ledger:<id>
53  working: false,
54  turnId: '', // the main loop's running (or last) turn
55  waitingSince: 0,
56  waitingFor: '',
57  tool: '',
58  lastTurnEnd: 0,
59  keepWarm: false,
60  keepWarmUntil: 0,
61  keepWarmSpan: 0, // hours the limit is restarted to on each real message
62  pings: 0,
63  pingUsd: 0,
64  lastPingAt: 0,
65  warnedFor: 0,
66  agentsRunning: 0,
67  rateLimits: [],
68}
69
70const DEFAULTS = { bigTokens: 150000, guard: true, ttlMin: 0, alerts: true, keepWarmHours: 12, autoCompactTokens: 0, horizonTurns: 0, idleGuard: true, endCompactTokens: 200000 } // horizonTurns 0 = learn it from notices
71const settings = { ...DEFAULTS }
72const names = { cache: 'cache', keepwarm: 'keepwarm', board: 'board', handoff: 'handoff' }
73
74let now = 0
75let home = ''
76let guardOff = false
77let lastHeartbeat = 0
78let rec = { on: false, strict: false }
79let mask = (s) => s
80let requestOut = false // a main-loop model request is in flight; a turn that only waits has none
81let fleet = [] // heartbeats of every session, this one included
82const alerted = new Map() // sessionId -> state or warning key already announced
83let paneOpen = false
84let justCompacted = false
85let selfCompacting = false // our own compaction: the idle guard must let it through
86let lastBlock = null // { at, ctx } of the last idle compaction we vetoed
87let lastTickAt = 0
88let compactCache = null // { at, params, sources }: what advise() runs on, refreshed every few minutes
89const COMPACT_REFRESH = 10 * MIN
90let autoAsked = 0 // lastActivity the auto-compact question was already asked for
91let handoffPending = false
92
93// The handoff flow. When /session-handoff runs (the band's button, /handoff, a
94// typed command, or Claude calling the skill), the answer of the turn it starts
95// is captured and saved to a file. "clear and continue" then runs /clear and
96// sends the handoff as the fresh chat's first prompt.
97const HANDOFF_SKILL = /(^|:)session-handoff$/
98const H = {
99  armed: false,
100  notTurn: '', // a turn already running when the handoff was queued: not the handoff's
101  askAfter: false, // /handoff asks "clear and continue?" once the handoff is in
102  text: '',
103  path: '',
104  continuing: false,
105}
106
107function armHandoff(insideTurn) {
108  H.armed = true
109  H.notTurn = insideTurn ? '' : S.working ? S.turnId : ''
110}
111
112// The handoff without any preamble Claude put before its heading
113function handoffBody(answer) {
114  const text = String(answer || '').trim()
115  const i = text.indexOf('# Session Handoff')
116  return i > 0 ? text.slice(i) : text
117}
118
119function fileStamp(ms) {
120  const d = new Date(ms)
121  const two = (n) => String(n).padStart(2, '0')
122  return `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())}-${two(d.getHours())}${two(d.getMinutes())}`
123}
124
125async function saveHandoff($, text) {
126  const project = basename(S.cwd).replace(/[^A-Za-z0-9-]+/g, '-') || 'session'
127  const path = `${home || '.'}/.claude/mods-data/cache-keeper/handoffs/${fileStamp(now)}-${project}-${S.id.slice(0, 8)}.md`.replace(/\\/g, '/')
128  await $.fs.write(path, text + '\n')
129  return path
130}
131
132async function captureHandoff($, e) {
133  H.armed = false
134  H.notTurn = ''
135  const askAfter = H.askAfter
136  H.askAfter = false
137  const body = e.reason === 'answer' ? handoffBody(e.answer) : ''
138  if (body.length < 200) {
139    $.ui.toast('The handoff turn ended without a handoff, so nothing was saved.', { timeoutMs: 8000 })
140    return
141  }
142  H.text = body
143  try {
144    H.path = await saveHandoff($, body)
145  } catch {
146    H.path = ''
147  }
148  const saved = H.path ? `Handoff saved to ${H.path}.` : 'Handoff captured (the backup file could not be written).'
149  $.ui.toast(`${saved} Press c on the band or type /${names.handoff} continue to clear and continue.`, { timeoutMs: 15000 })
150  // Off the hook: the turn is ending, and a dialog would hold it open
151  if (askAfter) $.clock.after(300, () => offerContinue($).catch(() => {}))
152}
153
154async function offerContinue($) {
155  if (!H.text) return
156  let answer = ''
157  try {
158    answer = await $.ui.ask('Handoff saved. Clear this chat and continue with it in a fresh context?', ['Clear and continue', 'Keep this chat'])
159  } catch {
160    return // dismissed: the band button and /handoff continue still work
161  }
162  if (answer === 'Clear and continue') await clearAndContinue($)
163}
164
165function continuationPrompt() {
166  const where = H.path ? ` (saved at ${H.path})` : ''
167  return `Handoff from my previous session${where}:\n\n${H.text}`
168}
169
170async function clearAndContinue($) {
171  if (H.continuing) return
172  if (!H.text) {
173    $.ui.toast(`No handoff ready. Press h on the band or type /${names.handoff} first.`, { timeoutMs: 8000 })
174    return
175  }
176  if (S.working) {
177    $.ui.toast('Claude is still working. Clear and continue once this turn ends.', { timeoutMs: 8000 })
178    return
179  }
180  const text = continuationPrompt()
181  const path = H.path
182  H.continuing = true
183  $.ui.invalidate('ui.render')
184  try {
185    await $.command.run({ command: 'clear', args: '' })
186  } catch (err) {
187    H.continuing = false
188    $.ui.invalidate('ui.render')
189    $.ui.toast('Could not run /clear: ' + clip(String((err && err.message) || err), 100) + (path ? ` The handoff is saved at ${path}.` : ''), { timeoutMs: 10000 })
190    return
191  }
192  H.text = ''
193  H.path = ''
194  try {
195    await $.prompt.submit({ text, asUser: true })
196  } catch {
197    // Not sent: leave it in the prompt box for one Enter
198    let filled = false
199    try {
200      filled = !!(await $.prompt.fill({ text })).isFilled
201    } catch {
202      filled = false
203    }
204    $.ui.toast(filled ? 'Cleared. The handoff is in the prompt box: press Enter to send it.' : `Cleared, but the handoff could not be sent.${path ? ` It's saved at ${path}.` : ''}`, { timeoutMs: 10000 })
205  } finally {
206    H.continuing = false
207    $.ui.invalidate('ui.render')
208  }
209}
210
211// Runs the /session-handoff skill as if you typed it. The engine queues it
212// until Claude finishes the current turn.
213async function runHandoff($) {
214  if (handoffPending) {
215    $.ui.toast('Session handoff is already queued.')
216    return
217  }
218  try {
219    const commands = await $.command.list()
220    const cmd = commands.find((c) => c.name === 'session-handoff') || commands.find((c) => HANDOFF_SKILL.test(c.name))
221    if (!cmd) {
222      H.askAfter = false
223      $.ui.toast('No /session-handoff skill in this session.', { timeoutMs: 8000 })
224      return
225    }
226    handoffPending = true
227    armHandoff(false)
228    $.ui.invalidate('ui.render')
229    $.ui.toast(S.working ? 'Session handoff queued: it runs when Claude finishes this turn.' : 'Running /session-handoff.')
230    await $.command.run({ command: cmd.name, args: '' })
231  } catch (err) {
232    H.armed = false
233    H.askAfter = false
234    $.ui.toast('Could not start /session-handoff: ' + clip(String((err && err.message) || err), 100), { timeoutMs: 8000 })
235  } finally {
236    handoffPending = false
237    $.ui.invalidate('ui.render')
238  }
239}
240
241function ttlMin() {
242  return settings.ttlMin || S.ttlMin
243}
244
245// Plan limit windows as the API reports them: five_hour, seven_day, spend_limit
246const LIMIT_LABEL = { five_hour: '5h', seven_day: 'week', spend_limit: 'spend' }
247
248function limitsText(limits) {
249  return limits.map((l) => `${LIMIT_LABEL[l.kind] || l.kind} ${Math.round(l.percentUsed)}%`).join(' · ')
250}
251
252function limitTone(limits, extra) {
253  const top = Math.max(...limits.map((l) => l.percentUsed || 0))
254  if (top >= 95) return { ...extra, color: 'red', bold: true }
255  if (top >= 80) return { ...extra, color: 'yellow' }
256  return { ...extra, dimColor: true }
257}
258
259function money(v) {
260  return rec.strict ? '$•••' : usd(v)
261}
262
263function msLeft(lastActivity, ttl) {
264  if (!lastActivity) return null
265  return ttl * MIN - (now - lastActivity)
266}
267
268function cacheState(hb) {
269  const left = msLeft(hb.lastActivity, hb.ttlMin)
270  if (left === null) return { kind: 'unknown', left: 0 }
271  if (hb.keepWarm) return { kind: 'kept', left }
272  if (left <= 0) return { kind: 'cold', left }
273  if (left <= 5 * MIN) return { kind: 'cooling', left }
274  return { kind: 'warm', left }
275}
276
277function myHeartbeat() {
278  const coldUsd = S.coldRestarts.reduce((a, c) => a + c.usd, 0)
279  let state = 'idle'
280  if (S.waitingSince && now - S.waitingSince > 5000) state = 'waiting'
281  else if (S.working) state = 'working'
282  return {
283    id: S.id,
284    label: S.label,
285    cwd: S.cwd,
286    model: S.model,
287    state,
288    waitingFor: S.waitingFor,
289    tool: S.tool,
290    ctx: S.ctx,
291    window: S.window,
292    ttlMin: ttlMin(),
293    lastActivity: S.lastActivity,
294    keepWarm: S.keepWarm,
295    keepWarmUntil: S.keepWarmUntil,
296    pings: S.pings,
297    pingUsd: S.pingUsd,
298    costUsd: S.costUsd,
299    coldCount: S.coldRestarts.length,
300    coldUsd,
301    agentsRunning: S.agentsRunning,
302    lastTurnEnd: S.lastTurnEnd,
303    updatedAt: now,
304  }
305}
306
307async function registerCommand($, name, description, argumentHint, immediate) {
308  const spec = immediate ? { name, description, argumentHint, immediate: true } : { name, description, argumentHint }
309  try {
310    await $.command.register(spec)
311    return name
312  } catch {
313    try {
314      await $.command.register({ ...spec, name: 'ck-' + name })
315      return 'ck-' + name
316    } catch {
317      return null
318    }
319  }
320}
321
322async function readRecording($) {
323  try {
324    const path = (home || '.') + '/.claude/mods-data/recording.json'
325    if (!(await $.fs.exists(path))) {
326      rec = { on: false, strict: false }
327    } else {
328      const flag = JSON.parse(await $.fs.read(path))
329      rec = { on: !!flag.on, strict: !!flag.on && !!flag.strict }
330    }
331  } catch {
332    rec = { on: false, strict: false }
333  }
334  mask = rec.on ? makeMasker({ strict: rec.strict }) : (s) => s
335}
336
337async function heartbeat($, force) {
338  if (!S.id) return
339  if (!force && now - lastHeartbeat < 5000) return
340  lastHeartbeat = now
341  try {
342    const agents = await $.agent.list()
343    S.agentsRunning = agents.filter((a) => a.status === 'running').length
344  } catch {
345    // agent list unavailable: keep the last count
346  }
347  await $.store.set('hb:' + S.id, myHeartbeat())
348}
349
350async function readFleet($) {
351  const keys = (await $.store.keys()).filter((k) => k.startsWith('hb:'))
352  const list = []
353  for (const k of keys) {
354    const hb = await $.store.get(k)
355    if (!hb || typeof hb !== 'object') continue
356    if (hb.id === S.id) continue
357    // a session that stopped heartbeating an hour ago has ended or crashed
358    if (now - (hb.updatedAt || 0) > 60 * MIN) {
359      await $.store.delete(k)
360      continue
361    }
362    list.push({ ...hb, stale: now - (hb.updatedAt || 0) > 3 * MIN })
363  }
364  list.push({ ...myHeartbeat(), self: true })
365  fleet = list
366}
367
368// Commands another session's board sent this one, through the shared store
369async function applyRemoteCommands($) {
370  const c = await $.store.get('cmd:' + S.id)
371  if (!c) return
372  await $.store.delete('cmd:' + S.id)
373  if (c.keepWarm === true) startKeepWarm($, settings.keepWarmHours)
374  if (c.keepWarm === false) stopKeepWarm($, 'stopped from the board')
375}
376
377function startKeepWarm($, hours) {
378  S.keepWarm = true
379  S.keepWarmSpan = hours
380  S.keepWarmUntil = now + hours * 60 * MIN
381  $.ui.toast(`Keeping this cache warm until ${clock(S.keepWarmUntil)}. A ping reads the cache before it expires.`)
382}
383
384function stopKeepWarm($, why) {
385  if (!S.keepWarm) return
386  S.keepWarm = false
387  $.ui.toast(`Keep warm off (${why}). ${S.pings} ping(s), ${money(S.pingUsd)}.`, { timeoutMs: 8000 })
388}
389
390// The last ping would be followed by an expired cache and a full-price rewrite of
391// a big context: compact now, while the cache is warm, and stop pinging.
392async function compactInsteadOfLastPing($) {
393  const due = endCompactDue({
394    keepWarmOn: S.keepWarm,
395    working: S.working,
396    ttlMin: ttlMin(),
397    ctx: S.ctx,
398    threshold: settings.endCompactTokens,
399    lastActivity: S.lastActivity,
400    now,
401    keepWarmUntil: S.keepWarmUntil,
402    silentMs: S.lastRealAt ? now - S.lastRealAt : 0,
403  })
404  if (!due) return false
405  const ctx = S.ctx
406  selfCompacting = true
407  try {
408    await $.session.compact()
409  } catch (err) {
410    await recordEvent($, { at: now, kind: 'end-compact', v: VERSION, outcome: 'failed', ctx, why: String((err && err.message) || err).slice(0, 120) })
411    $.ui.toast('Keep warm could not compact before its time limit; pinging instead.', { timeoutMs: 8000 })
412    return false
413  } finally {
414    selfCompacting = false
415  }
416  await recordEvent($, { at: now, kind: 'end-compact', v: VERSION, outcome: 'compacted', ctx })
417  stopKeepWarm($, `compacted ${tokens(ctx)} tokens before the time limit`)
418  return true
419}
420
421async function keepWarmStep($) {
422  if (!S.keepWarm) return
423  if (now >= S.keepWarmUntil) {
424    if (await compactInsteadOfLastPing($)) return
425    return stopKeepWarm($, 'time limit reached')
426  }
427  const ttl = ttlMin()
428  if (!pingDue({ requestOut, lastActivity: S.lastActivity, now, ttlMin: ttl })) return
429  if (await compactInsteadOfLastPing($)) return
430  let reply = null
431  try {
432    reply = await $.model.fork({ prompt: 'cache-keeper keep-alive ping. Reply with only: ok' })
433  } catch {
434    reply = null
435  }
436  // Mid-turn the fork may have nothing to share yet: try again on the next tick
437  if (S.working && (!reply || reply.reason === 'nothing-to-fork' || reply.reason === 'aborted')) return
438  if (!reply || !reply.usage) {
439    return stopKeepWarm($, 'the ping got no answer, so the cache may already be cold')
440  }
441  const u = reply.usage
442  S.pings += 1
443  S.lastPingAt = now
444  const cost = requestCost(u, S.model, ttl)
445  S.pingUsd += cost
446  S.pingReadTokens += u.cache_read_input_tokens || 0
447  if (!S.episode) S.episode = { lastRealAt: S.lastRealAt, pings: 0, usd: 0 }
448  S.episode.pings += 1
449  S.episode.usd += cost
450  // A ping that writes instead of reading did not hit the cache: stop paying for it
451  if ((u.cache_creation_input_tokens || 0) > 0.1 * Math.max(1, u.cache_read_input_tokens || 0)) {
452    return stopKeepWarm($, `the ping wrote ${tokens(u.cache_creation_input_tokens)} tokens instead of reading the cache`)
453  }
454  S.lastActivity = now
455}
456
457function warnStep($) {
458  if (!settings.alerts) return
459  for (const hb of fleet) {
460    if (hb.stale || hb.keepWarm || (hb.ctx || 0) < settings.bigTokens) continue
461    const st = cacheState(hb)
462    if (st.kind !== 'cooling') continue
463    const key = 'cool:' + hb.lastActivity
464    if (alerted.get(hb.id) === key) continue
465    alerted.set(hb.id, key)
466    const cost = money(rewriteCost(hb.ctx, hb.model, hb.ttlMin))
467    const who = hb.self ? 'This session' : `"${mask(hb.label)}"`
468    const how = hb.self ? `type /${names.keepwarm} or press 1` : `open /${names.board} to keep it warm`
469    $.ui.toast(`${who}: ${tokens(hb.ctx)}-token cache goes cold in ${minutes(st.left)}. Rewriting it costs about ${cost}. ${how}.`, { timeoutMs: 15000 })
470  }
471  // Another session started waiting on a permission or a question
472  for (const hb of fleet) {
473    if (hb.self || hb.stale) continue
474    const prev = alerted.get('state:' + hb.id)
475    alerted.set('state:' + hb.id, hb.state)
476    if (hb.state === 'waiting' && prev && prev !== 'waiting') {
477      $.ui.toast(`"${mask(hb.label)}" is waiting on you${hb.waitingFor ? ': ' + hb.waitingFor : ''}`, { timeoutMs: 10000 })
478    }
479  }
480}
481
482// Auto-compact: while the cache is still warm a compaction reads the big context
483// at the cheap cache-read price; after it expires the same compaction pays a full
484// rewrite first. So near the end of the window, on a context over the threshold,
485// ask once whether to compact now. Off by default (/cache autocompact 300k).
486async function autoCompactStep($) {
487  if (!settings.autoCompactTokens || S.keepWarm || S.working || !S.lastActivity) return
488  if (S.ctx < settings.autoCompactTokens || autoAsked === S.lastActivity) return
489  const ttl = ttlMin()
490  const left = msLeft(S.lastActivity, ttl)
491  const lead = ttl >= 60 ? 10 * MIN : 2 * MIN
492  if (left === null || left <= 0 || left > lead) return
493  autoAsked = S.lastActivity
494  let answer = 'Not now'
495  try {
496    answer = await $.ui.ask(
497      `Cache goes cold in ${minutes(left)}. Compacting ${tokens(S.ctx)} tokens now reads them at the cache price; after that it costs about ${money(rewriteCost(S.ctx, S.model, ttl))} to rewrite first. Compact now?`,
498      ['Compact now', 'Not now'],
499    )
500  } catch {
501    return
502  }
503  if (answer !== 'Compact now') return
504  try {
505    await $.session.compact()
506  } catch {
507    $.ui.toast('Auto-compact did not run (a turn was in progress or compaction was refused).', { timeoutMs: 8000 })
508  }
509}
510
511// Ledger events go to this session's own store key, so sessions never overwrite
512// each other; anything older than LEDGER_DAYS is dropped on the way.
513async function recordEvent($, ev) {
514  S.events = pruneEvents([...S.events, ev], now - LEDGER_DAYS * 24 * 60 * MIN)
515  try {
516    if (S.id) await $.store.set('ledger:' + S.id, S.events)
517  } catch {
518    // the store refused: keep the event in memory, never fail the request it rides on
519  }
520}
521
522// Pings nobody came back for (the chat closed or cleared first) were spent for nothing
523async function abandonEpisode($) {
524  if (!S.episode) return
525  const ep = S.episode
526  S.episode = null
527  await recordEvent($, { at: now, kind: 'keepwarm', outcome: 'abandoned', pings: ep.pings, pingUsd: ep.usd, savedUsd: 0 })
528}
529
530async function readLedger($) {
531  const cutoff = now - LEDGER_DAYS * 24 * 60 * MIN
532  const all = []
533  for (const k of (await $.store.keys()).filter((key) => key.startsWith('ledger:'))) {
534    const list = k === 'ledger:' + S.id ? S.events : await $.store.get(k)
535    const kept = pruneEvents(Array.isArray(list) ? list : [], cutoff)
536    if (!kept.length && k !== 'ledger:' + S.id) await $.store.delete(k)
537    all.push(...kept)
538  }
539  return all
540}
541
542const COLD_LABELS = {
543  avoidable: 'avoidable with keep warm',
544  'not-worth': 'keep warm would have cost more',
545  asleep: 'computer asleep or Claude Code suspended',
546  'too-long': 'idle over 24 h',
547  'keepwarm-failed': 'keep warm was on and missed',
548}
549
550function ledgerLines(title, s) {
551  const k = s.keepWarm
552  const c = s.cold
553  const lines = [`${title}:`]
554  lines.push(`  Keep warm: ${k.pings} ping(s), spent ${money(k.pingUsd)}; saved ${money(k.savedUsd)} on ${k.saved} return(s) that would have been cold; net ${money(k.netUsd)}.`)
555  if (k.unneeded + k.failed + k.abandoned) lines.push(`    Pings spent for nothing ${money(k.wastedUsd)}: ${k.unneeded} unneeded (cache was still warm), ${k.failed} missed, ${k.abandoned} never came back.`)
556  lines.push(`  Cold restarts: ${c.count}, spent ${money(c.usd)}; keep warm could have saved ${money(c.avoidableUsd)} of it.`)
557  for (const [cat, v] of Object.entries(c.byCategory)) {
558    lines.push(`    ${COLD_LABELS[cat] || cat}: ${v.count}, ${money(v.usd)}${v.avoidableUsd ? `, saving ${money(v.avoidableUsd)}` : ''}`)
559  }
560  if (s.resume.count) lines.push(`  Resumed chats: ${s.resume.count}, spent ${money(s.resume.usd)} rewriting the cache (the chat was closed, nothing could keep it warm).`)
561  return lines
562}
563
564async function statsText($) {
565  const all = await readLedger($)
566  const day = 24 * 60 * MIN
567  const lines = [
568    `Cache Keeper ${VERSION} (the version this chat loaded; chats started before an update keep the old one).`,
569    ...ledgerLines('This chat', summarize(S.events)),
570    ...ledgerLines('Last 7 days, all chats', summarize(all, now - 7 * day)),
571    ...ledgerLines('Last 30 days, all chats', summarize(all, now - 30 * day)),
572  ]
573  if (S.episode) lines.push(`Open keep-warm stretch: ${S.episode.pings} ping(s), ${money(S.episode.usd)}, settled by your next message.`)
574  const adv = adviceSummary(all, now - 30 * day)
575  if (adv.shown) lines.push(`Compaction notices, last 30 days: ${adv.shown} shown, you compacted after ${adv.acted} (${Math.round(adv.actedShare * 100)}%); about ${Math.round(adv.medianRemaining)} message(s) were left after a notice.`)
576  const ig = summarizeIdleBlocks(all, now - 30 * day)
577  if (ig.blocked || ig.passed || ig.overridden || ig.legacy) lines.push(`Idle-compaction guard, last 30 days: blocked ${ig.blocked} (compacted anyway: ${ig.overridden})${ig.lastBlockedAt ? ` (last ${minutes(now - ig.lastBlockedAt)} ago)` : ''}; automatic compactions let through with keep warm on: ${ig.passed}.${ig.legacy ? ` Not counted: ${ig.legacy} older record(s) from versions before 1.3.2, which did not block anything.` : ''}`)
578  else lines.push('Idle-compaction guard, last 30 days: nothing to block yet (no automatic compaction while keep warm was on).')
579  const ec = all.filter((ev) => ev.kind === 'end-compact' && ev.at >= now - 30 * day)
580  if (ec.length) lines.push(`Compacted before keep warm ended, last 30 days: ${ec.filter((ev) => ev.outcome === 'compacted').length} of ${ec.length} attempt(s), contexts ${ec.map((ev) => tokens(ev.ctx)).join(', ')}.`)
581  lines.push('Prices are API list prices; on a subscription they are usage against your plan limits. Contexts under 30k tokens are not counted.')
582  return lines.join('\n')
583}
584
585// Per-session counters for the compaction advice: messages, requests, long gaps
586async function saveUsage($) {
587  try {
588    if (S.id) await $.store.set('usage:' + S.id, { at: now, turns: S.turns, steps: S.steps, longGaps: S.longGaps })
589  } catch {
590    // the store refused: the counters stay in memory
591  }
592}
593
594async function readUsageTotals($) {
595  const cutoff = now - LEDGER_DAYS * 24 * 60 * MIN
596  const total = { turns: S.turns, steps: S.steps, longGaps: S.longGaps }
597  for (const k of (await $.store.keys()).filter((key) => key.startsWith('usage:'))) {
598    if (k === 'usage:' + S.id) continue
599    const u = await $.store.get(k)
600    if (!u || typeof u !== 'object' || (u.at || 0) < cutoff) {
601      await $.store.delete(k)
602      continue
603    }
604    total.turns += u.turns || 0
605    total.steps += u.steps || 0
606    total.longGaps += u.longGaps || 0
607  }
608  return total
609}
610
611// A notice's outcome, written when its session ends or is cleared: how many
612// messages were still to come, and whether (and how soon) the owner compacted.
613async function flushAdvice($) {
614  if (S.adviceTurns === null) return
615  const compacted = S.adviceCompactedAfter !== null
616  const ev = { at: now, kind: 'advice', ctx: S.adviceCtx, remaining: Math.max(0, S.turns - S.adviceTurns), compacted, compactedAfter: S.adviceCompactedAfter }
617  S.adviceTurns = null
618  S.adviceCompactedAfter = null
619  await recordEvent($, ev)
620}
621
622// What advise() runs on: defaults, replaced by what this machine measured
623async function refreshCompactParams($) {
624  const usage = await readUsageTotals($)
625  const ledger = await readLedger($)
626  const cal = calibrate(ledger, DEFAULT_PARAMS)
627  const horizon = horizonFrom(ledger, DEFAULT_PARAMS.horizonTurns)
628  const gap = gapShare(usage, DEFAULT_PARAMS.gapShare)
629  compactCache = {
630    at: now,
631    usage,
632    ledger,
633    params: { ...cal.params, gapShare: gap.value, stepsPerTurn: stepsPerTurn(usage, DEFAULT_PARAMS.stepsPerTurn), horizonTurns: settings.horizonTurns > 0 ? settings.horizonTurns : horizon.value },
634    sources: { ...cal.sources, gapShare: gap.source, stepsPerTurn: usage.turns >= 30 ? 'measured' : 'default', horizonTurns: settings.horizonTurns > 0 ? 'set' : horizon.source },
635  }
636}
637
638function compactAdvice(warm, sendPending = false) {
639  if (!compactCache || !S.model) return null
640  return advise({ ctx: S.ctx, model: S.model, ttlMin: ttlMin(), params: compactCache.params, warm, sendPending, keepWarmOn: S.keepWarm })
641}
642
643// One notice when compacting starts to pay, and again only after the context doubled. Not in the bar: the figures are on demand in /cache compact.
644function compactNoticeStep($) {
645  if (!settings.alerts || S.working || !S.lastActivity) return
646  const st = cacheState(myHeartbeat())
647  if (st.kind !== 'warm' && st.kind !== 'cooling' && st.kind !== 'kept') return
648  if (S.advisedCtx && S.ctx < S.advisedCtx * 2) return
649  const a = compactAdvice(true)
650  if (!a || a.verdict !== 'compact') return
651  S.advisedCtx = S.ctx
652  if (S.adviceTurns === null) {
653    S.adviceTurns = S.turns
654    S.adviceCtx = S.ctx
655    S.adviceCompactedAfter = null
656  }
657  $.ui.toast(`Worth compacting now: ${tokens(S.ctx)} tokens. It costs about ${money(a.costUsd)} and pays back in about ${Math.max(1, Math.round(a.breakEvenTurns))} message(s). Details: /${names.cache} compact.`, { timeoutMs: 15000 })
658}
659
660const SOURCE = { default: 'assumed', measured: 'measured' }
661const HORIZON_SOURCE = { default: 'assumed', measured: 'median of the messages that were left after your earlier notices', set: 'set by you' }
662
663function compactText() {
664  if (!compactCache) return 'Not enough data yet: try again in a moment.'
665  if (!S.ctx || !S.model) return 'No request yet in this chat, so there is nothing to compact.'
666  const { params, sources, usage } = compactCache
667  const ttl = ttlMin()
668  const st = cacheState(myHeartbeat())
669  const warm = st.kind === 'warm' || st.kind === 'cooling' || st.kind === 'kept'
670  const a = compactAdvice(warm, !warm)
671  const pct = (v) => Math.round(v * 100) + '%'
672  const lines = []
673  const verdict = {
674    compact: `Worth compacting now.`,
675    wait: `Not yet: it would not pay back within ${params.horizonTurns} messages.`,
676    'too-small': `Not worth it: the chat is barely bigger than what a compaction leaves (${tokens(params.newCtxTokens)}).`,
677  }[a.verdict]
678  lines.push(`Compacting this chat (${tokens(S.ctx)} tokens): ${verdict}`)
679  lines.push(`  Cost ${money(a.costUsd)} ${warm ? '(cache warm: it reads the chat at the cache price)' : '(cache cold and your next message rewrites it anyway: only the summary and the new prefix are extra)'}.`)
680  lines.push(`  Saves about ${money(a.savingPerTurnUsd)} per message; pays back after about ${Number.isFinite(a.breakEvenTurns) ? Math.max(1, Math.round(a.breakEvenTurns)) : 'never'} message(s). Horizon: ${params.horizonTurns} message(s) still to come (${HORIZON_SOURCE[sources.horizonTurns]}; /${names.cache} horizon N|auto).`)
681  if (Number.isFinite(a.breakEvenCtx)) lines.push(`  At ${params.horizonTurns} messages it pays from about ${tokens(Math.round(a.breakEvenCtx))} tokens up.`)
682  if (warm && Number.isFinite(a.idleReturnThreshold) && a.idleReturnThreshold <= 1) {
683    lines.push(`  If you step away, compacting before the cache expires beats letting it expire when you come back with probability above ${pct(a.idleReturnThreshold)}.`)
684  }
685  lines.push(`Behind the figures (${SOURCE[sources.newCtxTokens]} = from defaults, measured = from your own compactions here):`)
686  lines.push(`  After a compaction the next request carries ${tokens(Math.round(params.newCtxTokens))} tokens (${SOURCE[sources.newCtxTokens]}), ${pct(params.rewriteShare)} of it written (${SOURCE[sources.rewriteShare]}), summary ${tokens(Math.round(params.summaryTokens))} tokens (${SOURCE[sources.summaryTokens]}).`)
687  lines.push(`  ${params.stepsPerTurn.toFixed(1)} model requests per message (${SOURCE[sources.stepsPerTurn]}), ${pct(params.gapShare)} of messages after a gap over ${ttl} min (${SOURCE[sources.gapShare]}${usage.turns ? `, ${usage.turns} messages seen` : ''}).`)
688  const adv = adviceSummary(compactCache.ledger || [])
689  if (adv.shown) lines.push(`Earlier notices: ${adv.shown}, you compacted after ${adv.acted} of them${adv.medianMessagesToAct !== null ? ` (usually ${Math.round(adv.medianMessagesToAct)} message(s) later)` : ''}; about ${Math.round(adv.medianRemaining)} message(s) were left after a notice.`)
690  lines.push('Money only: a summary loses detail, and this does not price that.')
691  return lines.join('\n')
692}
693
694async function tick($) {
695  now = await $.clock.now()
696  if (!compactCache || now - compactCache.at > COMPACT_REFRESH) await refreshCompactParams($).catch(() => {})
697  // The 30 s timer stalls while the machine sleeps or the process is suspended
698  if (lastTickAt && now - lastTickAt > 3 * HEARTBEAT_EVERY) {
699    S.longestSleepMs = Math.max(S.longestSleepMs, now - lastTickAt - HEARTBEAT_EVERY)
700  }
701  lastTickAt = now
702  await readRecording($)
703  await applyRemoteCommands($)
704  await keepWarmStep($)
705  autoCompactStep($).catch(() => {})
706  await heartbeat($, true)
707  await readFleet($)
708  warnStep($)
709  compactNoticeStep($)
710  $.ui.invalidate('ui.render')
711}
712
713async function startFromEnvironment($) {
714  guardOff = (await $.env.get('CACHE_KEEPER_GUARD')) === 'off'
715  const value = await $.env.get('CACHE_KEEPER_KEEPWARM_HOURS')
716  const hours = typeof value === 'string' && /^\d+(?:\.\d+)?$/.test(value) ? Number(value) : 0
717  if (hours >= 1 && hours <= 72) startKeepWarm($, hours)
718}
719
720async function loadSettings($) {
721  const saved = await $.store.get('settings')
722  if (saved && typeof saved === 'object') Object.assign(settings, saved)
723}
724
725function statusText() {
726  const st = cacheState(myHeartbeat())
727  const ttl = ttlMin()
728  const lines = []
729  lines.push(`Cache Keeper: ${tokens(S.ctx)} tokens in context on ${priceFor(S.model).id}, cache window ${ttl} min (${settings.ttlMin ? 'set by you' : S.ttlSource}).`)
730  if (st.kind === 'unknown') lines.push('No request yet this session, so the cache state is unknown.')
731  if (st.kind === 'warm' || st.kind === 'cooling') lines.push(`Cache is warm for about ${minutes(st.left)} more.`)
732  if (st.kind === 'cold') lines.push(`Cache went cold ${minutes(-st.left)} ago. The next message rewrites it for about ${money(rewriteCost(S.ctx, S.model, ttl))}.`)
733  if (S.keepWarm) lines.push(`Keep warm is on until ${clock(S.keepWarmUntil)}: ${S.pings} ping(s), ${money(S.pingUsd)} so far.`)
734  if (S.rateLimits.length) lines.push(`Plan limits used: ${limitsText(S.rateLimits)}.`)
735  lines.push(`Session cost so far: ${money(S.costUsd)}. Cold restarts this session: ${S.coldRestarts.length} (${money(S.coldRestarts.reduce((a, c) => a + c.usd, 0))}).`)
736  const led = summarize(S.events)
737  lines.push(`Ledger, this chat: keep warm net ${money(led.keepWarm.netUsd)} (spent ${money(led.keepWarm.pingUsd)}, saved ${money(led.keepWarm.savedUsd)}); cold restarts ${money(led.cold.usd)}, of which avoidable ${money(led.cold.avoidableUsd)}. Details: /${names.cache} stats.`)
738  lines.push(`Cold-send guard: ${settings.guard && !guardOff ? 'on' : 'off'} for contexts over ${tokens(settings.bigTokens)} tokens. Alerts: ${settings.alerts ? 'on' : 'off'}.`)
739  lines.push(`Idle-compaction guard: ${settings.idleGuard ? 'on (blocks Claude Code\'s own idle compaction while keep warm is on)' : 'off'}. /cache idleguard on|off.`)
740  lines.push(`Compact before keep warm ends: ${settings.endCompactTokens ? `on for contexts over ${tokens(settings.endCompactTokens)} tokens, when nobody wrote for 30 min and the last ping is due` : 'off'}. /cache endcompact 200k|off.`)
741  lines.push(`Auto-compact: ${settings.autoCompactTokens ? `asks near the end of the cache window on contexts over ${tokens(settings.autoCompactTokens)} tokens` : 'off'}.`)
742  lines.push(`Settings: /${names.cache} ttl 5|60|auto, /${names.cache} guard on|off, /${names.cache} big 150k, /${names.cache} alerts on|off, /${names.cache} autocompact 300k|off, /${names.cache} reset (back to defaults), /${names.cache} stats (what keep warm and cold restarts cost), /${names.cache} compact (is compacting worth it now), /${names.cache} horizon 15 (messages you expect to send). Board: /${names.board}.`)
743  return lines.join('\n')
744}
745
746function parseTokens(text) {
747  const m = String(text || '').trim().toLowerCase().match(/^(\d+(?:\.\d+)?)\s*([km]?)$/)
748  if (!m) return null
749  return Math.round(Number(m[1]) * (m[2] === 'm' ? 1e6 : m[2] === 'k' ? 1e3 : 1))
750}
751
752export function register(on) {
753  on('session.start', async ($, e, next) => {
754    now = await $.clock.now()
755    home = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || ''
756    S.id = await $.session.id()
757    S.cwd = await $.session.cwd()
758    S.label = basename(S.cwd)
759    S.model = await $.session.model()
760    await loadSettings($)
761    await startFromEnvironment($)
762    await readRecording($)
763    const saved = await $.store.get('ledger:' + S.id)
764    S.events = Array.isArray(saved) ? saved : []
765    names.cache = (await registerCommand($, 'cache', 'Cache Keeper status and settings', '[ttl 5|60|auto] [guard on|off] [big 150k] [alerts on|off] [autocompact 300k|off] [reset] [stats] [compact] [horizon 15]')) || names.cache
766    names.keepwarm = (await registerCommand($, 'keepwarm', 'Keep the prompt cache warm while you are away. Stops after N hours (default 12, max 24) with no message from you; off to stop now', '[hours|off]', true)) || names.keepwarm
767    names.board = (await registerCommand($, 'board', 'Every local Claude Code session: state, context, cache, cost', '', true)) || names.board
768    names.handoff = (await registerCommand($, 'handoff', 'Session handoff, then clear this chat and continue with it (/handoff continue)', '[continue]')) || names.handoff
769    $.clock.every(HEARTBEAT_EVERY, () => tick($).catch(() => {}))
770    await heartbeat($, true)
771    return next(e)
772  })
773
774  // /clear, /resume and /branch start from an unknown cache. The process goes
775  // on under a new session id, and no session.start fires for it.
776  on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
777    now = await $.clock.now()
778    await abandonEpisode($)
779    await flushAdvice($)
780    S.lastActivity = 0
781    S.lastRealAt = 0
782    S.longestSleepMs = 0
783    S.pendingResume = e.source === 'resume'
784    S.keepWarm = false
785    await startFromEnvironment($)
786    H.armed = false
787    try {
788      S.id = (await $.session.id()) || S.id
789    } catch {
790      // keep the old id
791    }
792    S.label = basename(S.cwd)
793    const saved = await $.store.get('ledger:' + S.id)
794    S.events = Array.isArray(saved) ? saved : []
795    return next(e)
796  })
797
798  on('session.end', async ($, e, next) => {
799    now = await $.clock.now()
800    await abandonEpisode($)
801    await flushAdvice($)
802    if (S.id) await $.store.delete('hb:' + S.id)
803    return next(e)
804  })
805
806  // The first prompt names the session on the board
807  on('prompt.submit', async ($, e, next) => {
808    now = await $.clock.now()
809    if (S.label === basename(S.cwd) && e.origin && e.origin.kind === 'composer' && e.text) {
810      S.label = basename(S.cwd) + ' · ' + clip(e.text, 40)
811    }
812    // A new message in this chat makes a waiting handoff out of date (its file stays)
813    if (H.text && !H.continuing && e.origin && e.origin.kind === 'composer' && !String(e.text || '').trim().startsWith('/')) {
814      H.text = ''
815      H.path = ''
816      $.ui.invalidate('ui.render')
817    }
818    const ttl = ttlMin()
819    const left = msLeft(S.lastActivity, ttl)
820    const isCold = left !== null && left <= 0
821    const fromUser = e.origin && (e.origin.kind === 'composer' || e.origin.kind === 'bridge')
822    if (guardOff || !settings.guard || !fromUser || !isCold || S.ctx < settings.bigTokens || e.turnId) return next(e)
823    const cost = money(rewriteCost(S.ctx, S.model, ttl))
824    let answer = 'Send anyway'
825    try {
826      answer = await $.ui.ask(
827        `Cache went cold ${minutes(-left)} ago. Sending now rewrites ${tokens(S.ctx)} tokens of context (about ${cost}). What should happen?`,
828        ['Send anyway', 'Compact first, then send', 'Cancel'],
829      )
830    } catch {
831      // nobody to ask (a -p run, or the dialog was dismissed): send as typed
832      return next(e)
833    }
834    if (answer === 'Compact first, then send') {
835      try {
836        await $.session.compact()
837      } catch {
838        // compaction refused or failed: send anyway
839      }
840      return next(e)
841    }
842    if (answer === 'Send anyway') return next(e)
843    $.ui.toast('Not sent. A fresh session with a short handoff avoids the rewrite entirely.', { timeoutMs: 8000 })
844    return { drop: 'Cancelled by cache-keeper before a cold cache rewrite' }
845  })
846
847  on('turn.start', async ($, e, next) => {
848    now = await $.clock.now()
849    S.working = true
850    if (!e.agentId) {
851      S.turns += 1
852      S.keepWarmUntil = slideUntil({ keepWarmOn: S.keepWarm, spanHours: S.keepWarmSpan, now, until: S.keepWarmUntil })
853      if (S.lastRealAt && now - S.lastRealAt > ttlMin() * MIN) S.longGaps += 1
854    }
855    if (e.turnId) S.turnId = e.turnId
856    await heartbeat($, true)
857    return next(e)
858  })
859
860  // Each main-loop request: did it read the cache, or rewrite it?
861  on('turn.step', async function* ($, e, next) {
862    const startedAt = await $.clock.now()
863    if (!e.agentId) requestOut = true
864    let result
865    try {
866      result = yield* next(e)
867    } finally {
868      if (!e.agentId) requestOut = false
869    }
870    if (e.agentId || !result || !result.usage) return result
871    now = await $.clock.now()
872    S.steps += 1
873    const u = result.usage
874    const total = totalInput(u)
875    const written = u.cache_creation_input_tokens || 0
876    const gap = S.lastActivity ? startedAt - S.lastActivity : 0
877    S.model = u.model || S.model
878    const afterCompact = justCompacted
879    justCompacted = false
880    const realGap = S.lastRealAt ? startedAt - S.lastRealAt : 0
881    // Settle the keep-warm stretch this request ends
882    let keepWarmMissed = false
883    if (S.episode && afterCompact) {
884      await abandonEpisode($)
885    } else if (S.episode) {
886      const settled = settleKeepWarm({ episode: S.episode, startedAt, ttlMin: ttlMin(), usage: u, model: S.model })
887      S.episode = null
888      keepWarmMissed = settled.outcome === 'failed'
889      await recordEvent($, { at: now, kind: 'keepwarm', ...settled })
890    }
891    // The first request of a resumed chat rewrites a cache nobody could keep warm
892    if (S.pendingResume && !afterCompact && total > 30000 && written / total > 0.5) {
893      await recordEvent($, { at: now, kind: 'resume', tokens: written, usd: rewriteCost(written, S.model, ttlMin()) })
894    }
895    S.pendingResume = false
896    if (afterCompact) {
897      // The first request after a compaction writes the new, shorter context: expected.
898      // Its size and write share are what the next compaction's cost is calibrated on.
899      if (total > 0) await recordEvent($, { at: now, kind: 'compact-rebuild', written, total })
900      S.advisedCtx = 0
901    } else if (S.lastActivity && total > 30000 && written / total > 0.5) {
902      // A rewrite after 5-60 idle minutes means this session runs on the 5-minute TTL
903      if (!settings.ttlMin && gap > 5.5 * MIN && gap < S.ttlMin * MIN) {
904        S.ttlMin = 5
905        S.ttlSource = 'measured'
906      }
907      S.coldRestarts.push({ at: now, tokens: written, usd: rewriteCost(written, S.model, ttlMin()), gapMin: Math.round(gap / MIN) })
908      const measured = S.pingReadTokens ? { usd: S.pingUsd, readTokens: S.pingReadTokens } : undefined
909      const c = classifyColdRestart({
910        gapMs: realGap || gap,
911        ttlMin: ttlMin(),
912        tokens: written,
913        model: S.model,
914        keepWarmOn: S.keepWarm || keepWarmMissed,
915        longestSleepMs: S.longestSleepMs,
916        measured,
917      })
918      await recordEvent($, { at: now, kind: 'cold', category: c.category, tokens: written, usd: c.usd, avoidableUsd: c.avoidableUsd, pingsNeeded: c.pingsNeeded, gapMin: Math.round((realGap || gap) / MIN) })
919    } else if (S.lastActivity && total > 30000 && gap > 5.5 * MIN && cachedShare(u) > 0.8) {
920      // A hit after more than 5 idle minutes proves the 1-hour TTL
921      if (!settings.ttlMin) {
922        S.ttlMin = 60
923        S.ttlSource = 'measured'
924      }
925    }
926    S.ctx = total
927    S.lastActivity = now
928    S.lastRealAt = now
929    S.longestSleepMs = 0
930    return result
931  })
932
933  on('turn.complete', async ($, e, next) => {
934    const r = await next(e)
935    if (e.agentId) return r
936    now = await $.clock.now()
937    S.working = false
938    S.waitingSince = 0
939    S.tool = ''
940    S.lastTurnEnd = now
941    await saveUsage($)
942    try {
943      const usage = await $.session.usage()
944      if (usage.context && usage.context.tokens) S.ctx = usage.context.tokens
945      if (usage.context && usage.context.window) S.window = usage.context.window
946      if (usage.cost) S.costUsd = usage.cost.usd
947      if (Array.isArray(usage.rateLimits) && usage.rateLimits.length) S.rateLimits = usage.rateLimits
948    } catch {
949      // usage unavailable: keep the per-request figures
950    }
951    if (H.armed && e.turnId !== H.notTurn) await captureHandoff($, e)
952    await heartbeat($, true)
953    $.ui.invalidate('ui.render')
954    return r
955  })
956
957  on('session.compact', async ($, e, next) => {
958    now = await $.clock.now()
959    if (e.trigger !== 'precompute' && !e.agentId && !selfCompacting) {
960      const silentMs = S.lastRealAt ? now - S.lastRealAt : 0
961      const d = shouldBlockCompact({ trigger: e.trigger, keepWarmOn: S.keepWarm, working: S.working, silentMs, enabled: settings.idleGuard })
962      if (d.block || (e.trigger === 'auto' && S.keepWarm)) {
963        const silentMin = Math.round(silentMs / MIN)
964        await recordEvent($, { at: now, kind: 'idle-compact', v: VERSION, outcome: d.block ? 'blocked' : 'passed', trigger: e.trigger, silentMin, ctx: S.ctx })
965        if (d.block) {
966          $.ui.toast(`Blocked Claude Code's idle compaction: keep warm is on and nobody wrote for ${silentMin} min. /cache stats shows the log.`, { timeoutMs: 10000 })
967          lastBlock = { at: now, ctx: S.ctx }
968          return vetoResult(d)
969        }
970      }
971    }
972    const before = cacheState(myHeartbeat())
973    const r = await next(e)
974    if (e.trigger === 'precompute' || e.agentId || !r || r.skip) return r
975    justCompacted = true
976    if (S.adviceTurns !== null && S.adviceCompactedAfter === null) S.adviceCompactedAfter = Math.max(0, S.turns - S.adviceTurns)
977    const u = r.usage
978    await recordEvent($, {
979      at: now,
980      kind: 'compact',
981      trigger: e.trigger,
982      before: r.tokensBefore || S.ctx,
983      after: r.tokensAfter || 0,
984      outputTokens: u ? u.output_tokens || 0 : 0,
985      readTokens: u ? u.cache_read_input_tokens || 0 : 0,
986      wroteTokens: u ? u.cache_creation_input_tokens || 0 : 0,
987      usd: u ? requestCost(u, S.model, ttlMin()) : 0,
988      warm: before.kind === 'warm' || before.kind === 'cooling' || before.kind === 'kept',
989    })
990    return r
991  })
992
993  on('session.measure', async ($, e, next) => {
994    if (e.context && e.context.tokens) {
995      S.ctx = e.context.tokens
996      if (lastBlock && blockOverridden(lastBlock, await $.clock.now(), S.ctx)) {
997        const b = lastBlock
998        lastBlock = null
999        await recordEvent($, { at: await $.clock.now(), kind: 'idle-compact', v: VERSION, outcome: 'overridden', trigger: 'auto', silentMin: 0, ctx: b.ctx })
1000        $.ui.toast('Claude Code compacted anyway: the block did not hold. See /cache stats.', { timeoutMs: 10000 })
1001      }
1002    }
1003    if (e.context && e.context.window) S.window = e.context.window
1004    if (e.cost) S.costUsd = e.cost.usd
1005    if (Array.isArray(e.rateLimits) && e.rateLimits.length) S.rateLimits = e.rateLimits
1006    return next(e)
1007  })
1008
1009  // Waiting on the person: a permission prompt or a question
1010  on('tool.check', async ($, e, next) => {
1011    const d = await next(e)
1012    if (d && d.decision === 'ask' && !S.waitingSince) {
1013      now = await $.clock.now()
1014      S.waitingSince = now
1015      S.waitingFor = 'permission for ' + e.tool
1016    }
1017    return d
1018  })
1019
1020  on('tool.call', async ($, e, next) => {
1021    if (!e.agentId) S.tool = e.tool
1022    // Claude calling the handoff skill itself: this turn's answer is the handoff
1023    if (!e.agentId && e.tool === 'Skill' && HANDOFF_SKILL.test(String(e.skill || ''))) armHandoff(true)
1024    if (e.tool === 'AskUserQuestion') {
1025      now = await $.clock.now()
1026      S.waitingSince = now
1027      S.waitingFor = 'a question'
1028    }
1029    try {
1030      return await next(e)
1031    } finally {
1032      if (S.waitingSince) {
1033        S.waitingSince = 0
1034        S.waitingFor = ''
1035      }
1036    }
1037  })
1038
1039  // /session-handoff from anywhere (typed, the band, /handoff): watch for its answer
1040  on('command.run', async ($, e, next) => {
1041    if (HANDOFF_SKILL.test(String(e.command || ''))) armHandoff(false)
1042    return next(e)
1043  })
1044
1045  // /handoff runs the handoff and then asks to clear and continue (the band's
1046  // buttons draw only in the terminal); /handoff continue does the second half.
1047  // Both run off the hook: a command started inside a hook the session is
1048  // waiting on is refused.
1049  on('command.run', { command: ['handoff', 'ck-handoff'] }, async ($, e) => {
1050    now = await $.clock.now()
1051    const arg = String(e.args || '').trim().toLowerCase()
1052    if (arg === 'continue' || arg === 'go') {
1053      if (!H.text) return { text: `No handoff ready yet. Type /${names.handoff} to make one.` }
1054      $.clock.after(50, () => clearAndContinue($).catch(() => {}))
1055      return { text: 'Clearing this chat and continuing with the handoff.' }
1056    }
1057    H.askAfter = true
1058    $.clock.after(50, () => runHandoff($).catch(() => {}))
1059    return { text: 'Running the session handoff. When it finishes, you can clear this chat and continue with it.' }
1060  })
1061
1062  on('command.run', { command: ['cache', 'ck-cache'] }, async ($, e) => {
1063    now = await $.clock.now()
1064    const [key, value] = String(e.args || '').trim().toLowerCase().split(/\s+/)
1065    if (key === 'stats') return { text: await statsText($) }
1066    if (key === 'compact') {
1067      await refreshCompactParams($)
1068      return { text: compactText() }
1069    }
1070    if (key === 'reset') {
1071      Object.assign(settings, DEFAULTS)
1072    } else if (key === 'ttl') {
1073      settings.ttlMin = value === '5' ? 5 : value === '60' ? 60 : 0
1074    } else if (key === 'guard' || key === 'alerts') {
1075      settings[key] = value !== 'off'
1076    } else if (key === 'big') {
1077      const n = parseTokens(value)
1078      if (n) settings.bigTokens = n
1079    } else if (key === 'horizon') {
1080      const n = Math.round(Number(value))
1081      if (value === 'auto') settings.horizonTurns = 0
1082      else if (n > 0) settings.horizonTurns = Math.min(500, n)
1083      S.advisedCtx = 0
1084      if (compactCache) compactCache.at = 0 // recompute with the new horizon
1085    } else if (key === 'endcompact') {
1086      settings.endCompactTokens = value === 'off' ? 0 : parseTokens(value) || settings.endCompactTokens
1087    } else if (key === 'idleguard') {
1088      settings.idleGuard = value !== 'off'
1089    } else if (key === 'autocompact') {
1090      settings.autoCompactTokens = value === 'off' ? 0 : parseTokens(value) || settings.autoCompactTokens
1091    }
1092    if (key) {
1093      await $.store.set('settings', settings)
1094      $.ui.invalidate('ui.render')
1095    }
1096    return { text: statusText() }
1097  })
1098
1099  on('command.run', { command: ['keepwarm', 'ck-keepwarm'] }, async ($, e) => {
1100    now = await $.clock.now()
1101    const arg = String(e.args || '').trim().toLowerCase()
1102    if (arg === 'off' || (arg === '' && S.keepWarm)) {
1103      stopKeepWarm($, 'turned off')
1104    } else {
1105      const hours = Number(arg) > 0 ? Math.min(24, Number(arg)) : settings.keepWarmHours
1106      startKeepWarm($, hours)
1107    }
1108    await heartbeat($, true)
1109    $.ui.invalidate('ui.render')
1110    return {}
1111  })
1112
1113  on('command.run', { command: ['board', 'ck-board'] }, async ($, e) => {
1114    now = await $.clock.now()
1115    await heartbeat($, true)
1116    await readFleet($)
1117    const surface = await $.session.surface()
1118    if (!surface) return { text: boardText() }
1119    paneOpen = true
1120    await $.ui.open({ id: PANE, title: 'Sessions', focus: true, closeOnEscape: true })
1121    return {}
1122  })
1123
1124  on('ui.close', async ($, e, next) => {
1125    if (e.id === PANE) paneOpen = false
1126    return next(e)
1127  })
1128
1129  // The band above the prompt: this session's cache at a glance
1130  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1131    const below = await next(e)
1132    if (e.props && e.props.hasSurvey) return below
1133    if (!S.lastActivity && !S.keepWarm) return below
1134    const { Box, Text, Button } = $.ui.resolve(e)
1135    const hb = myHeartbeat()
1136    const st = cacheState(hb)
1137    const ttl = ttlMin()
1138    const big = S.ctx >= settings.bigTokens
1139    const parts = []
1140    if (st.kind === 'kept') parts.push(Text({ color: 'cyan', children: [`◆ kept warm · ${S.pings} ping${S.pings === 1 ? '' : 's'} ${money(S.pingUsd)} · until ${clock(S.keepWarmUntil)}`] }))
1141    else if (st.kind === 'warm') parts.push(Text({ color: 'green', children: [`● cache warm ${minutes(st.left)}`] }))
1142    else if (st.kind === 'cooling') parts.push(Text({ color: 'yellow', bold: true, children: [`◐ cache cools in ${minutes(st.left)}`] }))
1143    else if (st.kind === 'cold') parts.push(Text(big ? { color: 'red', bold: true, children: [`○ cache cold ${minutes(-st.left)}`] } : { dimColor: true, children: [`○ cache cold ${minutes(-st.left)}`] }))
1144    parts.push(Text({ dimColor: true, children: [` │ ctx ${tokens(S.ctx)}`] }))
1145    if (st.kind === 'cold' && big) parts.push(Text({ color: 'red', children: [` │ next send rewrites it ≈ ${money(rewriteCost(S.ctx, S.model, ttl))}`] }))
1146    else parts.push(Text({ dimColor: true, children: [` │ rewrite ≈ ${money(rewriteCost(S.ctx, S.model, ttl))}`] }))
1147    if (S.rateLimits.length) parts.push(Text(limitTone(S.rateLimits, { children: [' │ ' + limitsText(S.rateLimits)] })))
1148    parts.push(Text({ dimColor: true, children: [` │ session ${money(S.costUsd)}`] }))
1149    if (S.coldRestarts.length) parts.push(Text({ dimColor: true, children: [` │ ${S.coldRestarts.length} cold restart${S.coldRestarts.length === 1 ? '' : 's'} ${money(S.coldRestarts.reduce((a, c) => a + c.usd, 0))}`] }))
1150    const row = [Box({ flexDirection: 'row', children: parts })]
1151    // Offered whenever the cache is still alive (warm or cooling), whatever its size
1152    // and however long it has left: a cold cache can't be kept, a ping would rewrite it.
1153    if (st.kind === 'warm' || st.kind === 'cooling') {
1154      row.push(Button({ key: 'keepwarm', label: `keep warm ${settings.keepWarmHours}h`, hotkey: '1', plain: true, onPress: async () => { now = await $.clock.now(); startKeepWarm($, settings.keepWarmHours); await heartbeat($, true); $.ui.invalidate('ui.render') } }))
1155    } else if (st.kind === 'kept') {
1156      row.push(Button({ key: 'keepwarm', label: 'stop warm', hotkey: '1', plain: true, onPress: async () => { now = await $.clock.now(); stopKeepWarm($, 'turned off'); await heartbeat($, true); $.ui.invalidate('ui.render') } }))
1157    }
1158    const left = Box({ flexDirection: 'row', columnGap: 2, children: row })
1159    // Right edge: one click runs /session-handoff (h while the band has the focus;
1160    // a letter never fires from the prompt box, unlike a digit). Once a handoff
1161    // is in, c clears this chat and sends it as the fresh chat's first prompt.
1162    const busy = handoffPending || H.armed
1163    const handoff = Button({ key: 'handoff', label: handoffPending ? 'handoff queued' : H.armed ? 'handoff running' : 'handoff', hotkey: 'h', plain: true, dimColor: busy, onPress: () => runHandoff($) })
1164    let right = handoff
1165    if (H.text || H.continuing) {
1166      const go = Button({ key: 'continue', label: H.continuing ? 'clearing' : 'clear and continue', hotkey: 'c', plain: true, onPress: () => clearAndContinue($) })
1167      right = Box({ flexDirection: 'row', columnGap: 2, children: [go, handoff] })
1168    }
1169    const mine = Box({ flexDirection: 'row', justifyContent: 'space-between', width: '100%', columnGap: 2, children: [left, right] })
1170    return Box({ flexDirection: 'column', children: below ? [mine, below] : [mine] })
1171  })
1172
1173  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
1174    if (e.requestId !== PANE) return next(e)
1175    return drawBoard($, e)
1176  })
1177
1178  // The band is terminal-only. The footer also draws in the Desktop app, so it
1179  // carries a short label, only when there's something to act on.
1180  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
1181    const label = footerLabel()
1182    if (!label) return next(e)
1183    const modes = Array.isArray(e.props && e.props.modes) ? e.props.modes : []
1184    return next({ ...e, props: { ...e.props, modes: [...modes, label] } })
1185  })
1186}
1187
1188function footerLabel() {
1189  const parts = []
1190  const st = cacheState(myHeartbeat())
1191  const big = S.ctx >= settings.bigTokens
1192  if (st.kind === 'kept') parts.push('cache kept warm')
1193  else if (st.kind === 'cooling' && big) parts.push(`cache cools in ${minutes(st.left)} · /keepwarm`)
1194  else if (st.kind === 'cold' && big) parts.push(`cache cold · rewrite ≈ ${money(rewriteCost(S.ctx, S.model, ttlMin()))}`)
1195  const high = S.rateLimits.filter((l) => (l.percentUsed || 0) >= 80)
1196  if (high.length) parts.push(limitsText(high))
1197  if (H.text) parts.push(`handoff ready · /${names.handoff} continue`)
1198  return parts.join(' · ')
1199}
1200
hooks/pricing.mjs 96 lines
1// Shared source. Copied into each mod's hooks/ folder by _dev/sync-shared.mjs.
2// Edit it here, then run: node mods/_dev/sync-shared.mjs
3
4// Dollars per million tokens, from the Claude API pricing table (cached 2026-09-25).
5// Cache writes cost 1.25x input on the 5-minute TTL and 2x input on the 1-hour TTL.
6const TABLE = [
7  ['fable-5-1', { input: 10, output: 50, read: 0.25 }],
8  ['mythos-5-1', { input: 10, output: 50, read: 0.25 }],
9  ['fable-5', { input: 10, output: 50, read: 1.0 }],
10  ['opus-5-5', { input: 4, output: 20, read: 0.2 }],
11  ['opus-5', { input: 5, output: 25, read: 0.5 }],
12  ['opus-4-8', { input: 5, output: 25, read: 0.5 }],
13  ['opus-4-7', { input: 5, output: 25, read: 0.5 }],
14  ['opus-4-6', { input: 5, output: 25, read: 0.5 }],
15  ['sonnet-5-5', { input: 2, output: 10, read: 0.2 }],
16  ['sonnet-5', { input: 2, output: 10, read: 0.2 }],
17  ['sonnet-4-6', { input: 3, output: 15, read: 0.3 }],
18  ['haiku-4-5', { input: 1, output: 5, read: 0.1 }],
19]
20
21const FAMILY = {
22  fable: 'fable-5-1',
23  mythos: 'mythos-5-1',
24  opus: 'opus-5-5',
25  sonnet: 'sonnet-5-5',
26  haiku: 'haiku-4-5',
27}
28
29export function normalizeModel(model) {
30  return String(model || '')
31    .toLowerCase()
32    .replace(/^claude-/, '')
33    .replace(/\[.*?\]/g, '')
34    .replace(/-\d{8}$/, '')
35    .trim()
36}
37
38export function priceFor(model) {
39  const id = normalizeModel(model)
40  for (const [key, price] of TABLE) {
41    if (id === key || id.startsWith(key + '-') || id.startsWith(key)) {
42      // 'opus-5' must not swallow 'opus-5-5': the table lists longer ids first
43      return { id: key, ...price }
44    }
45  }
46  for (const [family, key] of Object.entries(FAMILY)) {
47    if (id.includes(family)) {
48      const found = TABLE.find(([k]) => k === key)
49      return { id: key, ...found[1] }
50    }
51  }
52  return { id: 'opus-5-5', input: 4, output: 20, read: 0.2 }
53}
54
55export function writeRate(model, ttlMinutes) {
56  const p = priceFor(model)
57  return p.input * (ttlMinutes >= 60 ? 2 : 1.25)
58}
59
60// usage: { input_tokens, output_tokens, cache_read_input_tokens, cache_creation_input_tokens }
61export function requestCost(usage, model, ttlMinutes = 60) {
62  if (!usage) return 0
63  const p = priceFor(model || usage.model)
64  const w = writeRate(model || usage.model, ttlMinutes)
65  return (
66    ((usage.input_tokens || 0) * p.input +
67      (usage.cache_read_input_tokens || 0) * p.read +
68      (usage.cache_creation_input_tokens || 0) * w +
69      (usage.output_tokens || 0) * p.output) /
70    1e6
71  )
72}
73
74export function rewriteCost(tokens, model, ttlMinutes = 60) {
75  return ((tokens || 0) * writeRate(model, ttlMinutes)) / 1e6
76}
77
78export function readCost(tokens, model) {
79  return ((tokens || 0) * priceFor(model).read) / 1e6
80}
81
82export function totalInput(usage) {
83  if (!usage) return 0
84  return (
85    (usage.input_tokens || 0) +
86    (usage.cache_read_input_tokens || 0) +
87    (usage.cache_creation_input_tokens || 0)
88  )
89}
90
91// Share of a request's input served from the cache, 0 to 1.
92export function cachedShare(usage) {
93  const total = totalInput(usage)
94  return total > 0 ? (usage.cache_read_input_tokens || 0) / total : 0
95}
96
hooks/fmt.mjs 70 lines
1// Shared source. Copied into each mod's hooks/ folder by _dev/sync-shared.mjs.
2
3export function tokens(n) {
4  const v = Number(n) || 0
5  if (v >= 1e6) return (v / 1e6).toFixed(v >= 1e7 ? 0 : 1) + 'M'
6  if (v >= 1e3) return (v / 1e3).toFixed(v >= 1e5 ? 0 : 1) + 'k'
7  return String(Math.round(v))
8}
9
10export function usd(n) {
11  const v = Number(n) || 0
12  if (v === 0) return '$0'
13  if (v < 0.01) return '<$0.01'
14  if (v < 10) return '$' + v.toFixed(2)
15  return '$' + v.toFixed(0)
16}
17
18export function duration(ms) {
19  const s = Math.max(0, Math.round((Number(ms) || 0) / 1000))
20  if (s < 60) return s + 's'
21  const m = Math.floor(s / 60)
22  if (m < 60) return m + 'm' + String(s % 60).padStart(2, '0') + 's'
23  const h = Math.floor(m / 60)
24  return h + 'h' + String(m % 60).padStart(2, '0') + 'm'
25}
26
27export function minutes(ms) {
28  const m = Math.round((Number(ms) || 0) / 60000)
29  if (m <= 60) return m + 'm'
30  return Math.floor(m / 60) + 'h' + String(m % 60).padStart(2, '0') + 'm'
31}
32
33export function clock(ts) {
34  const d = new Date(ts)
35  let h = d.getHours()
36  const ampm = h >= 12 ? 'pm' : 'am'
37  h = h % 12 || 12
38  return h + ':' + String(d.getMinutes()).padStart(2, '0') + ampm
39}
40
41const SPARKS = '▁▂▃▄▅▆▇█'
42
43export function sparkline(values, width = 12) {
44  const vals = values.slice(-width)
45  if (vals.length === 0) return ''
46  const max = Math.max(...vals, 1)
47  return vals.map((v) => SPARKS[Math.min(7, Math.floor((v / max) * 7.999))]).join('')
48}
49
50export function bar(fraction, width = 20, full = '█', empty = '░') {
51  const f = Math.max(0, Math.min(1, Number(fraction) || 0))
52  const n = Math.round(f * width)
53  return full.repeat(n) + empty.repeat(width - n)
54}
55
56export function clip(text, max) {
57  const s = String(text ?? '').replace(/\s+/g, ' ').trim()
58  return s.length > max ? s.slice(0, Math.max(0, max - 1)) + '…' : s
59}
60
61export function pad(text, width) {
62  const s = String(text ?? '')
63  return s.length >= width ? s.slice(0, width) : s + ' '.repeat(width - s.length)
64}
65
66export function basename(path) {
67  const parts = String(path || '').split(/[\\/]+/).filter(Boolean)
68  return parts[parts.length - 1] || String(path || '')
69}
70
hooks/privacy.mjs 337 lines
1// Masks secrets, personal details, and business figures for screen recording.
2// Pure functions: no mods API calls here. The same file ships in each mod that
3// draws text, since a mod may import only its own files.
4
5const DOTS = '••••••••'
6const HIDE = '•••'
7const HIDDEN_OUTPUT = '••• hidden while recording'
8
9const SECRET_PATTERNS = [
10  /sk-ant-[A-Za-z0-9_-]{16,}/g,
11  /\bsk-(?:proj-|live-|test-|svcacct-)?[A-Za-z0-9_-]{20,}/g,
12  /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}/g,
13  /\bgithub_pat_[A-Za-z0-9_]{20,}/g,
14  /\bxox[abprs]-[A-Za-z0-9-]{10,}/g,
15  /\bAIza[0-9A-Za-z_-]{30,}/g,
16  /\bAKIA[0-9A-Z]{16}\b/g,
17  /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
18  /\b(?:hf|r8|pk_live|sk_live|rk_live|whsec|pat|key)_[A-Za-z0-9]{20,}/g,
19  /\bBearer\s+[A-Za-z0-9._~+/-]{20,}=*/gi,
20]
21
22// NAME=value or "name": "value" where the name looks like a credential
23const ASSIGNMENT =
24  /\b([A-Za-z0-9_.-]*(?:API[_-]?KEY|SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE[_-]?KEY|ACCESS[_-]?KEY|CLIENT[_-]?SECRET|AUTH)[A-Za-z0-9_.-]*)(["']?\s*[=:]\s*["']?)([^\s"'`,;}{]{6,})/gi
25
26const EMAIL_SRC = '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}'
27const EMAIL = new RegExp(`\\b${EMAIL_SRC}\\b`, 'g')
28
29// People: "Jane Doe <jane@x.com>", "displayName": "Jane Doe", and From:/Attendees: lines
30const NAME_WORD = "[A-Z][\\p{L}'’.-]*"
31const DISPLAY_NAME = new RegExp(`(["']?)(${NAME_WORD}(?:[ \\t]+${NAME_WORD}){0,3})\\1([ \\t]*<[ \\t]*${EMAIL_SRC}[ \\t]*>)`, 'gu')
32const PERSON_KEY =
33  /("(?:displayName|display_name|fullName|full_name|firstName|first_name|lastName|last_name|givenName|given_name|familyName|family_name|real_name|realName|senderName|sender_name|authorName|author_name|username|user_name)"\s*:\s*")([^"]{1,80})(")/g
34const PERSON_LINE = /^([ \t>*-]*(?:From|To|Cc|Bcc|Reply-To|Attendees?|Organizer|Invitees?|Guests?|Assignees?|Sender|Owner)[ \t]*:[ \t]*)(\S.*)$/gm
35
36// Personal details
37const PHONE = /(?<![\w.])(?:\+?1[\s.-]?)?\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}(?![\w.])/g
38const PHONE_INTL = /(?<![\w.])\+\d{1,3}[\s.-]?\(?\d{1,4}\)?(?:[\s.-]?\d{2,4}){2,4}(?![\w.])/g
39const SSN = /(?<![\w-])\d{3}-\d{2}-\d{4}(?![\w-])/g
40const EIN = /(?<![\w-])\d{2}-\d{7}(?![\w-])/g
41const CARD = /(?<![\w-])(?:4\d{3}|5[1-5]\d{2}|2[2-7]\d{2}|3[47]\d{2}|6(?:011|5\d{2}))(?:[ -]?\d{2,4}){3,4}(?![\w-])/g
42const IBAN = /\b[A-Z]{2}\d{2}(?: ?[A-Z0-9]{4}){3,7}(?: ?[A-Z0-9]{1,3})?\b/g
43const ACCOUNT = /\b((?:routing|account|acct|a\/c|aba|swift|bic|iban|passport|driver'?s license)(?:\s*(?:number|no\.?|num|#))?\s*[:#]?\s*)(\d[\d -]{3,}\d)/gi
44const CARD_ENDING = /\b((?:ending(?:\s+in)?|last\s+(?:4|four)(?:\s+digits)?)\s*[:#]?\s*)(\d{4})\b/gi
45const STREET = /\b\d{1,6}\s+(?:[NSEW]\.?\s+)?(?:[A-Z][\p{L}'.-]*\s+){1,3}(?:Street|St|Avenue|Ave|Road|Rd|Boulevard|Blvd|Drive|Dr|Lane|Ln|Way|Court|Ct|Place|Pl|Parkway|Pkwy|Highway|Hwy|Circle|Cir|Terrace|Ter|Trail|Trl|Square|Sq)\b\.?(?:,?\s+(?:Apt|Apartment|Suite|Ste|Unit|#)\.?\s*[\w-]+)?/gu
46const PO_BOX = /\bP\.?\s?O\.?\s+Box\s+\d+/gi
47const STATE_ZIP = /(,\s*(?:A[KLRZ]|C[AOT]|D[CE]|FL|GA|HI|I[ADLN]|K[SY]|LA|M[ADEINOST]|N[CDEHJMVY]|O[HKR]|PA|RI|S[CD]|T[NX]|UT|V[AT]|W[AIVY]))\s+\d{5}(?:-\d{4})?\b/g
48const BIRTH = /\b((?:DOB|D\.O\.B\.|date of birth|birth\s?date|birthday|born(?: on)?)\s*[:-]?\s*)([A-Za-z0-9 ,/.-]{4,20}\d)/gi
49const IPV4 = /(?<![\w.])((?:25[0-5]|2[0-4]\d|1?\d?\d)(?:\.(?:25[0-5]|2[0-4]\d|1?\d?\d)){3})(?![\w.])/g
50
51// Money in any common currency
52const MONEY = /(?:US|CA|AU)?[$€£¥]\s?\d[\d,]*(?:\.\d+)?(?:\s?(?:[kKmMbB]|bn|million|thousand|billion)\b)?/g
53const MONEY_WORD = /(?<![\w.])(?:(?:USD|EUR|GBP|CAD|AUD)\s?\d[\d,]*(?:\.\d+)?(?:\s?(?:[kKmMbB]|bn|million|thousand|billion)\b)?|\d[\d,]*(?:\.\d+)?(?:\s?(?:[kKmMbB]|bn|million|thousand|billion))?\s?(?:USD|EUR|GBP|CAD|AUD|dollars|bucks)\b)/gi
54
55// Figures near business words: "MRR is 84k", "40% margin", "profit share 20%"
56const FIN_WORD =
57  /\b(?:revenues?|mrr|arr|gmv|profits?|profit share|margins?|ebitda|income|earnings|payroll|salar(?:y|ies)|wages?|compensation|comp|bonus(?:es)?|equity|stakes?|ownership|valuation|runway|burn(?: rate)?|cash(?:flow)?|balances?|budgets?|spend(?:ing)?|pric(?:e|es|ing)|fees?|invoices?|payouts?|distributions?|dividends?|tax(?:es)?|sales|ltv|cac|aov|sponsor(?:s|ships?)?|cpm|rpm|retainers?|commissions?|royalt(?:y|ies)|refunds?|expenses?|debts?|loans?|funding|investments?|deals?|net|gross|paid|pays?|owed?|costs?)\b/gi
58const FIGURE = /(?<![\w.])\d[\d,]*(?:\.\d+)?(?:\s?(?:%|percent\b|[kKmMbB]\b|bn\b|million\b|thousand\b|billion\b|x\b))?/g
59const BIG_FIGURE = /(?<![\w.])(?:\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+(?:\.\d+)?\s?(?:%|percent\b|[kKmMbB]\b|bn\b|million\b|thousand\b|billion\b))/g
60
61// Single names that are also ordinary words, and names that stay visible (the channel's own)
62const COMMON_WORDS = new Set(
63  'Will Mark Grant Bill Rich Art Hope Faith Joy May June April August Summer Rose Page Chase Hunter Max Ray Dawn Sky Brook Lane Dean Gene Guy Frank Sterling Major Price Young King Long Little Wood Hill Stone Field Ford Hall Bell Rice Banks Case Cook Fox Gray Green Brown White Black Day Lee West North South Love Church Park Street Bishop Mason Miller Baker Carter Cole Wells Hart Moon Star Van Von De Del La Le Da Di St Mr Mrs Ms Dr Jr Sr Claude Code Team Support Admin Info Hello The And Ai Slack Gmail Google Calendar Meeting Sync Notes Update Review Weekly Daily Monday Tuesday Wednesday Thursday Friday Saturday Sunday'.split(' '),
64)
65const ROLE_LOCAL_PARTS = /^(?:info|hello|hi|team|support|help|admin|noreply|no-reply|donotreply|contact|sales|billing|accounts?|notifications?|news|newsletter|marketing|office|mail|security|privacy|legal|press|jobs|careers|hr|ops|dev|bot|alerts?|updates?|calendar|invites?)$/i
66
67// Files that stay closed while recording. Add your own folders and file names
68// in ~/.claude/mods-data/recording-mode/config.json ("privatePaths"); /rec config makes one.
69const PRIVATE_PATHS = [
70  /(^|[\\/\s"'`])\.env(\.[A-Za-z0-9_-]+)?(?=$|[\s"'`;|&)])/i,
71  /\.credentials\.json/i,
72  /[\\/]\.claude\.json\b/i,
73  /\.claude[\\/]projects[\\/][^\\/\s"'`]+[\\/]memory/i,
74  /(^|[\\/\s"'`])\.(?:ssh|aws|gnupg)(?=$|[\\/])/i,
75  /\bid_(?:rsa|ed25519|ecdsa)\b/i,
76  /(^|[\\/\s"'`])\.(?:netrc|npmrc|pypirc)\b/i,
77  /\.(?:pem|p12|pfx)\b/i,
78  /taxes?[-_ ]?20\d\d/i,
79  /[\\/_-](?:payroll|invoices?|contracts?|financials?|finances?|budgets?|forecasts?|cap[-_ ]?table|term[-_ ]?sheets?|offer[-_ ]?letters?|business[-_ ]?plans?)(?=$|[\\/._\s"'`;|&)-])/i,
80  /(^|[\s"'`])(?:payroll|invoices?|contracts?|financials?|finances?|budgets?|forecasts?|business[-_ ]?plans?)(?=[\\/.])/i,
81]
82
83// Tools whose results are business data: drawn as hidden while recording
84const BUSINESS_TOOL =
85  /(?:^|__|_)(?:clickup|slack|gmail|outlook|notion|asana|linear|jira|clay|qbo|quickbooks|xero|fireflies|hubspot|salesforce|stripe|calendar|gcal|google_drive|drive|sheets)|search_threads|get_thread|get_message|list_drafts|get_draft|read_file_content|download_file_content|search_files|list_recent_files|get_file_metadata|profit_loss|cash_flow|balance_sheet|payroll|session_transcripts|export_transcript/i
86const BUSINESS_COMMAND = /\bgws(?:\.cmd|\.exe)?\b|fireflies|quickbooks/i
87// Finance tools stay closed while recording
88const FINANCE_TOOL = /__(?:qbo_|quickbooks|profit_loss|cash_flow|balance_sheet|benchmarking_quickbooks|money_onboarding|company_info)/i
89
90function escapeRegExp(s) {
91  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
92}
93
94// Values from .env files: long enough to be secret, and not plain words or numbers.
95export function secretValuesFromEnv(text) {
96  const values = []
97  for (const line of String(text || '').split(/\r?\n/)) {
98    const m = line.match(/^\s*(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=\s*(.*)\s*$/)
99    if (!m) continue
100    let v = m[1].trim()
101    if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1)
102    if (v.length < 8) continue
103    if (/^(true|false|null|none|yes|no)$/i.test(v)) continue
104    if (/^\d+(\.\d+)?$/.test(v)) continue
105    if (/your[-_ ]?(key|token|secret)|here$|^<.*>$|^xxx/i.test(v)) continue
106    values.push(v)
107  }
108  return [...new Set(values)].sort((a, b) => b.length - a.length)
109}
110
111// The forms of one person's name that get masked: the full name, its slug, and each part that isn't an ordinary word
112function nameForms(full, keep) {
113  const clean = String(full || '').replace(/\s+/g, ' ').trim()
114  if (!clean || clean.length > 60 || keep.has(clean)) return []
115  const forms = new Set()
116  const parts = clean.split(/[ -]/).filter((p) => /^[A-Z][\p{L}'’.]*$/u.test(p) && p.length >= 2)
117  if (parts.length >= 2) {
118    forms.add(clean)
119    forms.add(clean.toLowerCase())
120    forms.add(clean.toLowerCase().replace(/ /g, '-'))
121    forms.add(clean.toLowerCase().replace(/ /g, '_'))
122  }
123  for (const p of parts) if (!COMMON_WORDS.has(p) && !keep.has(p)) forms.add(p)
124  return [...forms]
125}
126
127function namesFromEmail(email) {
128  const local = email.split('@')[0]
129  if (ROLE_LOCAL_PARTS.test(local)) return null
130  const parts = local.split(/[._+-]/).filter((p) => /^[a-z]{2,}$/i.test(p))
131  if (parts.length < 2 || parts.length > 4) return null
132  return parts.map((p) => p.charAt(0).toUpperCase() + p.slice(1).toLowerCase()).join(' ')
133}
134
135// Masks numbers in the same clause as a business word
136function maskFigures(text, strict) {
137  if (strict) text = text.replace(BIG_FIGURE, HIDE)
138  const ranges = []
139  for (const m of text.matchAll(FIN_WORD)) {
140    const s = m.index
141    const e = s + m[0].length
142    let a = Math.max(0, s - 30)
143    let b = Math.min(text.length, e + 45)
144    const before = text.slice(a, s)
145    const stop = Math.max(before.lastIndexOf('\n'), before.lastIndexOf(';'), before.search(/[.!?]\s[^.!?]*$/))
146    if (stop >= 0) a += stop + 1
147    const after = text.slice(e, b)
148    const end = after.search(/\n|;|[.!?](?:\s|$)/)
149    if (end >= 0) b = e + end
150    ranges.push([a, b])
151  }
152  if (!ranges.length) return text
153  return text.replace(FIGURE, (fig, offset) => {
154    if (/^(?:19|20)\d\d$/.test(fig)) return fig // a year
155    return ranges.some(([a, b]) => offset >= a && offset < b) ? HIDE : fig
156  })
157}
158
159function luhn(digits) {
160  let sum = 0
161  for (let i = 0; i < digits.length; i++) {
162    let d = Number(digits[digits.length - 1 - i])
163    if (i % 2 === 1) {
164      d *= 2
165      if (d > 9) d -= 9
166    }
167    sum += d
168  }
169  return sum % 10 === 0
170}
171
172function publicIp(ip) {
173  const [a, b] = ip.split('.').map(Number)
174  if (a === 10 || a === 127 || a === 0 || ip === '255.255.255.255') return false
175  if (a === 192 && b === 168) return false
176  if (a === 172 && b >= 16 && b <= 31) return false
177  if (a === 169 && b === 254) return false
178  return true
179}
180
181// Your own name and its parts stay visible
182function keepSet(keepNames) {
183  const keep = new Set()
184  for (const n of keepNames || []) {
185    const clean = String(n || '').replace(/\s+/g, ' ').trim()
186    if (!clean) continue
187    keep.add(clean)
188    keep.add(clean.toLowerCase())
189    keep.add(clean.toLowerCase().replace(/ /g, '-'))
190    for (const part of clean.split(/[ -]/)) if (part) keep.add(part)
191  }
192  return keep
193}
194
195// strict: also every large figure (1,250 / 18% / 40k) wherever it appears
196// names: people to mask; keepNames: names that stay visible (yours)
197export function makeMasker({ strict = false, values = [], names = [], keepNames = [] } = {}) {
198  const KEEP_NAMES = keepSet(keepNames)
199  const valueRe = values.length
200    ? new RegExp(values.map(escapeRegExp).join('|'), 'g')
201    : null
202  // Names: the roster passed in plus any learned from emails and contact fields. Memory only.
203  const people = new Set()
204  let nameRe = null
205  let dirty = false
206  const learn = (full) => {
207    if (people.size > 2000) return
208    for (const f of nameForms(full, KEEP_NAMES)) {
209      if (!people.has(f)) {
210        people.add(f)
211        dirty = true
212      }
213    }
214  }
215  for (const n of names) learn(n)
216  const nameRegex = () => {
217    if (dirty) {
218      const list = [...people].sort((a, b) => b.length - a.length).map(escapeRegExp)
219      nameRe = list.length ? new RegExp(`(?<![\\p{L}\\p{N}_])(?:${list.join('|')})(?:['’]s)?(?![\\p{L}\\p{N}_])`, 'gu') : null
220      dirty = false
221    }
222    return nameRe
223  }
224
225  return function mask(text) {
226    if (typeof text !== 'string' || text.length === 0) return text
227    let out = text
228    if (valueRe) out = out.replace(valueRe, DOTS)
229    for (const re of SECRET_PATTERNS) out = out.replace(re, DOTS)
230    out = out.replace(ASSIGNMENT, (_, name, sep) => name + sep + DOTS)
231
232    // people, learned before the emails that name them are masked
233    for (const m of out.matchAll(EMAIL)) {
234      const n = namesFromEmail(m[0])
235      if (n) learn(n)
236    }
237    out = out.replace(DISPLAY_NAME, (_, q, name, addr) => {
238      learn(name)
239      return KEEP_NAMES.has(name) ? q + name + q + addr : HIDE + addr
240    })
241    out = out.replace(PERSON_KEY, (_, head, value, tail) => {
242      learn(value)
243      return head + (KEEP_NAMES.has(value) ? value : HIDE) + tail
244    })
245    out = out.replace(PERSON_LINE, (line, label, value) => (KEEP_NAMES.has(value.trim()) ? line : label + HIDE))
246    out = out.replace(EMAIL, '•••@•••')
247    const re = nameRegex()
248    if (re) out = out.replace(re, HIDE)
249
250    // personal details
251    out = out.replace(CARD, (m) => {
252      const digits = m.replace(/\D/g, '')
253      return digits.length >= 13 && digits.length <= 19 && luhn(digits) ? '•••• •••• •••• ••••' : m
254    })
255    out = out.replace(SSN, '•••-••-••••')
256    out = out.replace(EIN, '••-•••••••')
257    out = out.replace(IBAN, HIDE)
258    out = out.replace(ACCOUNT, (_, head) => head + HIDE)
259    out = out.replace(CARD_ENDING, (_, head) => head + '••••')
260    out = out.replace(BIRTH, (_, head) => head + HIDE)
261    out = out.replace(STREET, HIDE)
262    out = out.replace(PO_BOX, HIDE)
263    out = out.replace(STATE_ZIP, (_, head) => head + ' •••••')
264    out = out.replace(PHONE, '•••-•••-••••')
265    out = out.replace(PHONE_INTL, '+•• •••')
266    out = out.replace(IPV4, (ip) => (publicIp(ip) ? '•••.•••.•••.•••' : ip))
267
268    // business figures
269    out = out.replace(MONEY, '$•••')
270    out = out.replace(MONEY_WORD, HIDE)
271    out = maskFigures(out, strict)
272    return out
273  }
274}
275
276// Masks every string inside plain data, keeping the shape.
277export function deepMask(value, mask, depth = 0) {
278  if (depth > 12) return value
279  if (typeof value === 'string') return mask(value)
280  if (Array.isArray(value)) return value.map((v) => deepMask(v, mask, depth + 1))
281  if (value && typeof value === 'object') {
282    const proto = Object.getPrototypeOf(value)
283    if (proto !== Object.prototype && proto !== null) return value
284    const out = {}
285    for (const [k, v] of Object.entries(value)) out[k] = deepMask(v, mask, depth + 1)
286    return out
287  }
288  return value
289}
290
291// Hides the readable text inside a business tool's result, keeping short tags (type: 'text') so the row still draws.
292export function hideText(s) {
293  if (typeof s !== 'string' || s.length === 0) return s
294  return s.length > 24 || /\s/.test(s) ? HIDDEN_OUTPUT : s
295}
296
297// extra: your own folder or file names from the config file, matched anywhere in a path, any case
298export function privatePathHit(text, extra = []) {
299  const s = String(text || '')
300  for (const re of PRIVATE_PATHS) {
301    const m = s.match(re)
302    if (m) return m[0].trim().replace(/^["'`\\/_-]/, '')
303  }
304  const flat = s.replace(/\\/g, '/').toLowerCase()
305  for (const item of extra) {
306    const needle = String(item || '').replace(/\\/g, '/').toLowerCase().trim()
307    if (needle && flat.includes(needle)) return String(item)
308  }
309  return null
310}
311
312// The strings in a tool call's arguments that could name a file or carry a command.
313export function toolCallTargets(e) {
314  const keys = ['file_path', 'path', 'pattern', 'glob', 'command', 'notebook_path', 'url']
315  const out = []
316  for (const k of keys) if (typeof e?.[k] === 'string') out.push(e[k])
317  return out
318}
319
320// Whether a tool's result is business data (mail, chat, tasks, files, calendar, finance)
321// extra: more tool or command names (any part of the name) whose results draw as hidden
322export function businessSource(tool, input, extra = []) {
323  const name = String(tool || '')
324  if (BUSINESS_TOOL.test(name)) return true
325  const cmd = input && typeof input.command === 'string' ? input.command : ''
326  const hay = (name + ' ' + cmd).toLowerCase()
327  if (extra.some((x) => x && hay.includes(String(x).toLowerCase()))) return true
328  const command = input && typeof input.command === 'string' ? input.command : ''
329  return command ? BUSINESS_COMMAND.test(command) : false
330}
331
332// extra: more tool names (any part of the name) that stay closed while recording
333export function financeTool(tool, extra = []) {
334  const name = String(tool || '')
335  return FINANCE_TOOL.test(name) || extra.some((x) => x && name.toLowerCase().includes(String(x).toLowerCase()))
336}
337
hooks/ledger.mjs 94 lines
1// The cache ledger: what keep warm cost and saved, what cold restarts cost and
2// how much of that keep warm could have saved. Pure functions, no `$`, so they
3// are tested on their own (node --test cache-keeper/tests/).
4//
5// T is the cache TTL. A ping fires at T minus a margin after the last request
6// and gives the cache another T. Prices come from pricing.mjs (API list prices;
7// on a subscription they are usage against plan limits, not a bill).
8
9import { rewriteCost, readCost, requestCost, cachedShare } from './pricing.mjs'
10
11const MIN = 60000
12export const MAX_KEEP_WARM_HOURS = 24
13
14// Same timing keepWarmStep uses: 8 minutes early on the 1-hour TTL, 90 s on 5 min
15export function pingMarginMs(ttlMin) {
16  return ttlMin >= 60 ? 8 * MIN : 90000
17}
18
19// Pings that would have carried the cache over an idle gap
20export function pingsNeeded(gapMs, ttlMin) {
21  const ttlMs = ttlMin * MIN
22  if (gapMs <= ttlMs) return 0
23  return Math.ceil((gapMs - ttlMs) / (ttlMs - pingMarginMs(ttlMin)))
24}
25
26// One ping over a context of ctxTokens. With pings measured in this session, use
27// their cost per cached token; otherwise a read of the context plus the ping's
28// own few tokens.
29export function pingCostUsd(ctxTokens, model, ttlMin, measured) {
30  if (measured && measured.readTokens > 0) return (measured.usd / measured.readTokens) * ctxTokens
31  return requestCost({ cache_read_input_tokens: ctxTokens, input_tokens: 60, output_tokens: 5 }, model, ttlMin)
32}
33
34// A cold restart: what it cost, and whether keep warm could have prevented it
35// for less. Categories are checked in order; only 'avoidable' claims a saving.
36export function classifyColdRestart({ gapMs, ttlMin, tokens, model, keepWarmOn = false, longestSleepMs = 0, measured }) {
37  const usd = rewriteCost(tokens, model, ttlMin)
38  const need = pingsNeeded(gapMs, ttlMin)
39  const keepWarmUsd = need * pingCostUsd(tokens, model, ttlMin, measured)
40  const base = { usd, pingsNeeded: need, keepWarmUsd, avoidableUsd: 0 }
41  if (keepWarmOn) return { ...base, category: 'keepwarm-failed' }
42  if (gapMs > MAX_KEEP_WARM_HOURS * 60 * MIN) return { ...base, category: 'too-long' }
43  if (longestSleepMs >= pingMarginMs(ttlMin)) return { ...base, category: 'asleep' }
44  if (keepWarmUsd >= usd) return { ...base, category: 'not-worth' }
45  return { ...base, category: 'avoidable', avoidableUsd: usd - keepWarmUsd }
46}
47
48// The first real request after a keep-warm stretch settles it. A hit saves the
49// rewrite only if the cache would have expired without the pings.
50export function settleKeepWarm({ episode, startedAt, ttlMin, usage, model }) {
51  const base = { pings: episode.pings, pingUsd: episode.usd, savedUsd: 0 }
52  if (cachedShare(usage) <= 0.8) return { ...base, outcome: 'failed' }
53  if (startedAt - episode.lastRealAt <= ttlMin * MIN) return { ...base, outcome: 'unneeded' }
54  const read = usage.cache_read_input_tokens || 0
55  return { ...base, outcome: 'saved', savedUsd: rewriteCost(read, model, ttlMin) - readCost(read, model) }
56}
57
58export function pruneEvents(events, cutoff) {
59  return events.filter((ev) => ev.at >= cutoff)
60}
61
62export function summarize(events, since = 0) {
63  const s = {
64    keepWarm: { pings: 0, pingUsd: 0, savedUsd: 0, wastedUsd: 0, netUsd: 0, saved: 0, unneeded: 0, failed: 0, abandoned: 0 },
65    cold: { count: 0, usd: 0, avoidableUsd: 0, byCategory: {} },
66    resume: { count: 0, usd: 0 },
67  }
68  for (const ev of events) {
69    if (ev.at < since) continue
70    if (ev.kind === 'keepwarm') {
71      const k = s.keepWarm
72      k.pings += ev.pings || 0
73      k.pingUsd += ev.pingUsd || 0
74      k.savedUsd += ev.savedUsd || 0
75      if (ev.outcome !== 'saved') k.wastedUsd += ev.pingUsd || 0
76      if (ev.outcome in k) k[ev.outcome] += 1
77    } else if (ev.kind === 'cold') {
78      const c = s.cold
79      c.count += 1
80      c.usd += ev.usd || 0
81      c.avoidableUsd += ev.avoidableUsd || 0
82      const cat = (c.byCategory[ev.category] ||= { count: 0, usd: 0, avoidableUsd: 0 })
83      cat.count += 1
84      cat.usd += ev.usd || 0
85      cat.avoidableUsd += ev.avoidableUsd || 0
86    } else if (ev.kind === 'resume') {
87      s.resume.count += 1
88      s.resume.usd += ev.usd || 0
89    }
90  }
91  s.keepWarm.netUsd = s.keepWarm.savedUsd - s.keepWarm.pingUsd
92  return s
93}
94
hooks/version.mjs 5 lines
1// The version this code ships as; keep in step with .claude-plugin/plugin.json
2// (tests/version.test.mjs fails when they differ). /cache stats prints it, so a
3// chat that started before an update is easy to tell from a fresh one.
4export const VERSION = '1.6.1'
5
hooks/endcompact.mjs 22 lines
1// Keep warm stops at its time limit and the cache then expires, so a big chat
2// ends up rewritten at full price on the next message. Just before the last ping
3// the cache is still warm, which is the cheapest moment to compact it: the
4// compaction request reads the context at the cache price instead of rewriting
5// it. Pure function, no `$`, tested on its own (node --test cache-keeper/tests/).
6
7import { pingMarginMs } from './ledger.mjs'
8
9const MIN = 60000
10const AWAY_MS = 30 * MIN // same silence the idle guard uses
11
12export function endCompactDue({ keepWarmOn, working, ttlMin, ctx, threshold, lastActivity, now, keepWarmUntil, silentMs }) {
13  if (!keepWarmOn || working || !threshold || ctx < threshold || !lastActivity) return false
14  if (silentMs < AWAY_MS) return false
15  const ttlMs = ttlMin * MIN
16  const interval = ttlMs - pingMarginMs(ttlMin) // ping to ping
17  if (now >= lastActivity + ttlMs) return false // already expired: compacting would rewrite at full price
18  if (now >= keepWarmUntil) return true // the limit hit while the cache was still warm
19  if (now < lastActivity + interval) return false // cache is fresh, nothing to do yet
20  return now + interval >= keepWarmUntil // the ping after this one would not happen
21}
22
hooks/keepwarmwindow.mjs 18 lines
1// The keep-warm time limit slides: every real message restarts the window, so
2// "24 h" means 24 h after the last time someone was here, not 24 h from the
3// moment it was switched on. Pure function, tested on its own.
4export function slideUntil({ keepWarmOn, spanHours, now, until }) {
5  if (!keepWarmOn || !(spanHours > 0)) return until
6  return Math.max(until, now + spanHours * 3600000)
7}
8
9import { pingMarginMs } from './ledger.mjs'
10
11// A ping is due once the cache has gone ttl minus the margin without a request.
12// A turn that is merely waiting (for an answer, a permission, a long command) sends
13// no requests, so only a request actually out holds the ping back.
14export function pingDue({ requestOut, lastActivity, now, ttlMin }) {
15  if (requestOut || !lastActivity) return false
16  return now - lastActivity >= ttlMin * 60000 - pingMarginMs(ttlMin)
17}
18
hooks/idleguard.mjs 49 lines
1// Claude Code compacts a long chat by itself once nobody has written for about 54
2// minutes (built-in idle compaction, ~90% of the 1 h cache). With keep warm on
3// that throws away a cache we are paying to hold, so this vetoes it. Pure
4// functions, no `$`, tested on their own (node --test cache-keeper/tests/).
5
6const MIN = 60000
7
8// Over normal pauses, under the built-in moment, so a compaction after this much
9// silence is the idle one and not auto-compact during work.
10export const IDLE_SILENCE_MS = 30 * MIN
11
12export function shouldBlockCompact({ trigger, keepWarmOn, working, silentMs, enabled }) {
13  if (!enabled || trigger !== 'auto' || !keepWarmOn || working) return { block: false }
14  if (silentMs < IDLE_SILENCE_MS) return { block: false }
15  return { block: true, reason: 'idle-with-keep-warm' }
16}
17
18export function summarizeIdleBlocks(events, since = 0) {
19  const s = { blocked: 0, passed: 0, overridden: 0, legacy: 0, lastBlockedAt: 0 }
20  for (const ev of events) {
21    if (ev.kind !== 'idle-compact' || ev.at < since) continue
22    if (!ev.v) {
23      s.legacy += 1 // written before 1.3.2, when the veto was rejected
24      continue
25    }
26    if (ev.outcome === 'blocked') {
27      s.blocked += 1
28      s.lastBlockedAt = Math.max(s.lastBlockedAt, ev.at)
29    } else if (ev.outcome === 'passed') s.passed += 1
30    else if (ev.outcome === 'overridden') s.overridden += 1
31  }
32  return s
33}
34
35// What session.compact must return to veto: { skip } with a non-empty string
36// reason. Anything else (true, an empty string) is rejected by Claude Code and
37// the compaction runs anyway.
38export function vetoResult(decision) {
39  return { skip: `cache-keeper: ${decision.reason}, keep warm is on and nobody has written for a long time` }
40}
41
42// A veto that Claude Code ignored shows up as the context collapsing soon after
43// it. `blocked` is { at, ctx } of the last veto; within 10 minutes a context
44// under half of what it was means the compaction ran anyway.
45export function blockOverridden(blocked, nowMs, ctxNow) {
46  if (!blocked || !blocked.ctx) return false
47  return nowMs - blocked.at <= 10 * MIN && ctxNow < blocked.ctx * 0.5
48}
49
hooks/compact.mjs 181 lines
1// When does compacting a session pay for itself? Pure functions, no `$`, tested
2// on their own (node --test cache-keeper/tests/).
3//
4// Notation (per token, USD): r cache read, w cache write at the session's TTL,
5// o output. C is the context now, N the context right after compacting, s the
6// tokens of the summary the model writes.
7//
8// Compacting costs, with the cache warm:
9//     C·r      the compaction request reads the whole context from the cache
10//   + s·o      the model writes the summary
11//   + N·w      the first message afterwards writes the new, short prefix
12// (with the cache cold and a message about to be sent anyway, the C·w rewrite is
13// paid either way, so only s·o + N·w is extra).
14//
15// It saves, on every later message, (C - N)·rho, where rho = k·r + q·g: k reads
16// of the smaller context (a message is k model requests, each reading the whole
17// context), plus the share q of messages that arrive after a gap longer than the
18// TTL, each of which would rewrite C instead of N (g = w, or two pings' worth of
19// reads when keep warm is on). Break-even is cost / saving.
20//
21// Money only. A summary loses detail, and this does not price that.
22
23import { priceFor, writeRate } from './pricing.mjs'
24
25// Measured in the owner's transcripts (2026-10-08, 73 compactions): the first
26// request after a compaction has 76k tokens in total (median; the transcript's own
27// "post" count of 18k leaves out the system prompt, tools and instructions that
28// every request carries, about 52k at the start of a session), and 80% of it is
29// written rather than read. The rest are assumptions until the mod measures them.
30export const DEFAULT_PARAMS = {
31  summaryTokens: 10000, // assumed: not in transcripts
32  newCtxTokens: 76000, // measured, median of 73 (min 54k)
33  rewriteShare: 0.8, // measured, median of 73 (p25 0.67, p75 0.89)
34  gapShare: 0.1, // assumed prior; shrinks toward what the mod measures
35  horizonTurns: 15, // a setting: messages you expect to send from here
36  stepsPerTurn: 12, // measured: median of 68 sessions (overall 13.8 requests per prompt)
37}
38
39const MEASURED_MIN = 3
40const GAP_PRIOR_WEIGHT = 20 // messages worth of belief in the prior
41const GAP_MEASURED_MIN = 30
42const STEPS_PRIOR_WEIGHT = 10 // messages worth of belief in the prior
43const PINGS_PER_GAP = 2 // keep warm over a typical long gap
44const TOO_SMALL_FACTOR = 1.2 // under 1.2x the compacted size there is under 17% to cut
45
46function prices(model, ttlMin) {
47  const p = priceFor(model)
48  return { r: p.read / 1e6, w: writeRate(model, ttlMin) / 1e6, o: p.output / 1e6 }
49}
50
51// Per-message saving factor: the reads of every request, plus the cost of the
52// long gaps it removes
53function rho({ model, ttlMin, params, keepWarmOn }) {
54  const { r, w } = prices(model, ttlMin)
55  const g = keepWarmOn ? PINGS_PER_GAP * r : w
56  return params.stepsPerTurn * r + params.gapShare * g
57}
58
59export function compactionCostUsd({ ctx, model, ttlMin, params, warm, sendPending = false }) {
60  const { r, w, o } = prices(model, ttlMin)
61  const requestUsd = !warm && sendPending ? 0 : ctx * (warm ? r : w)
62  const outputUsd = params.summaryTokens * o
63  const rebuildUsd = params.newCtxTokens * params.rewriteShare * w
64  return { requestUsd, outputUsd, rebuildUsd, totalUsd: requestUsd + outputUsd + rebuildUsd }
65}
66
67export function savingPerTurnUsd({ ctx, model, ttlMin, params, keepWarmOn = false }) {
68  return Math.max(0, ctx - params.newCtxTokens) * rho({ model, ttlMin, params, keepWarmOn })
69}
70
71export function breakEvenTurns(args) {
72  const saving = savingPerTurnUsd(args)
73  if (saving <= 0) return Infinity
74  return compactionCostUsd(args).totalUsd / saving
75}
76
77// The context size above which compacting (cache warm) pays back within `horizon`
78// messages: C·(H·rho - r) = N·H·rho + s·o + N·share·w
79export function breakEvenCtx({ model, ttlMin, params, horizon = params.horizonTurns, keepWarmOn = false }) {
80  const { r, w, o } = prices(model, ttlMin)
81  const rh = rho({ model, ttlMin, params, keepWarmOn }) * horizon
82  const den = rh - r
83  if (den <= 0) return Infinity
84  return (params.newCtxTokens * rh + params.summaryTokens * o + params.newCtxTokens * params.rewriteShare * w) / den
85}
86
87// Compacting just before the cache expires, against letting it expire and
88// rewriting on return: better if you come back with probability above this.
89// Above 1 means never; Infinity means the context is no bigger than the compacted one.
90export function idleReturnThreshold({ ctx, model, ttlMin, params }) {
91  const { r, w, o } = prices(model, ttlMin)
92  const gain = ctx * w - params.newCtxTokens * params.rewriteShare * w
93  if (ctx <= params.newCtxTokens || gain <= 0) return Infinity
94  return (ctx * r + params.summaryTokens * o) / gain
95}
96
97export function advise(args) {
98  const { ctx, params } = args
99  const costUsd = compactionCostUsd(args).totalUsd
100  const savingUsd = savingPerTurnUsd(args)
101  const turns = breakEvenTurns(args)
102  const out = {
103    costUsd,
104    savingPerTurnUsd: savingUsd,
105    breakEvenTurns: turns,
106    breakEvenCtx: breakEvenCtx(args),
107    idleReturnThreshold: idleReturnThreshold(args),
108  }
109  if (ctx < params.newCtxTokens * TOO_SMALL_FACTOR) return { ...out, verdict: 'too-small' }
110  return { ...out, verdict: turns <= params.horizonTurns ? 'compact' : 'wait' }
111}
112
113function median(values) {
114  const v = values.filter((x) => Number.isFinite(x)).sort((a, b) => a - b)
115  if (!v.length) return null
116  const mid = Math.floor(v.length / 2)
117  return v.length % 2 ? v[mid] : (v[mid - 1] + v[mid]) / 2
118}
119
120// Replace each default with the median of what the mod measured, once it has
121// MEASURED_MIN figures for it; say which are which.
122export function calibrate(events, defaults = DEFAULT_PARAMS) {
123  const params = { ...defaults }
124  const sources = { newCtxTokens: 'default', summaryTokens: 'default', rewriteShare: 'default' }
125  const compacts = events.filter((e) => e.kind === 'compact')
126  const rebuilds = events.filter((e) => e.kind === 'compact-rebuild' && e.total > 0)
127  // N is what the next request really carries: the first request after compacting
128  const after = rebuilds.map((e) => e.total)
129  const wrote = compacts.map((e) => e.outputTokens).filter((x) => x > 0)
130  const shares = rebuilds.map((e) => e.written / e.total)
131  if (after.length >= MEASURED_MIN) {
132    params.newCtxTokens = median(after)
133    sources.newCtxTokens = 'measured'
134  }
135  if (wrote.length >= MEASURED_MIN) {
136    params.summaryTokens = median(wrote)
137    sources.summaryTokens = 'measured'
138  }
139  if (shares.length >= MEASURED_MIN) {
140    params.rewriteShare = median(shares)
141    sources.rewriteShare = 'measured'
142  }
143  return { params, sources }
144}
145
146// Share of messages that arrive after a gap longer than the TTL, shrunk toward
147// the prior so a short history can't swing it.
148export function gapShare({ turns, longGaps }, prior = DEFAULT_PARAMS.gapShare) {
149  const value = (longGaps + GAP_PRIOR_WEIGHT * prior) / (turns + GAP_PRIOR_WEIGHT)
150  return { value, source: turns >= GAP_MEASURED_MIN ? 'measured' : 'default' }
151}
152
153// Model requests per message, shrunk toward the prior like gapShare
154export function stepsPerTurn({ turns, steps }, prior = DEFAULT_PARAMS.stepsPerTurn) {
155  return (steps + STEPS_PRIOR_WEIGHT * prior) / (turns + STEPS_PRIOR_WEIGHT)
156}
157
158// The horizon is the messages still to come once compacting is worth a look. Each
159// 'advice' event is one notice's outcome: `remaining` messages from the notice to
160// the end of that session, so a compaction that carried on afterwards doesn't cut
161// it short. Median once 3 are known, else the prior.
162export function horizonFrom(events, prior = DEFAULT_PARAMS.horizonTurns) {
163  const left = events.filter((e) => e.kind === 'advice' && Number.isFinite(e.remaining)).map((e) => e.remaining)
164  if (left.length < MEASURED_MIN) return { value: prior, source: 'default' }
165  return { value: Math.max(1, Math.round(median(left))), source: 'measured' }
166}
167
168// What came of the notices: how many, how often they led to a compaction, how
169// many messages later, how many messages were left.
170export function adviceSummary(events, since = 0) {
171  const adv = events.filter((e) => e.kind === 'advice' && (e.at === undefined || e.at >= since))
172  const acted = adv.filter((e) => e.compacted)
173  return {
174    shown: adv.length,
175    acted: acted.length,
176    actedShare: adv.length ? acted.length / adv.length : 0,
177    medianRemaining: median(adv.map((e) => e.remaining)),
178    medianMessagesToAct: median(acted.map((e) => e.compactedAfter)),
179  }
180}
181