SLOPSHOPPER

collision-guard

Asks before Claude edits a file another open chat changed in the last 30 minutes: Proceed, Move to a worktree, or Cancel (/guard)

newguardcommandpromptprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · collision-guard
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /guard ⎿ collision-guard: Collision Guard is on. Before an edit, it asks when another open chat changed the same file in the last 30 min ⎿ collision-guard: No other open chat has edited a file in that window. ⎿ collision-guard: This chat changed 3 files: auth.ts, audit.ts, cache.ts. ⎿ collision-guard: This chat so far: asked 0, proceeded 0, moved to a worktree 0, cancelled 0. ⎿ collision-guard: Settings: /guard on|off, /guard window <minutes>. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

<h1 align="center">Awesome Claude Code Mods</h1>

<a id="showcase"></a>

Mod showcase

<table> <tr><td width="33%" align="center" valign="top"><p><a href="https://github.com/therahul-yo/clawdman"><img src="assets/showcase-01.gif" width="100%" alt="Clawdman: An animated companion that reacts to your coding session. Demo · 10×."></a></p><p><strong><a href="https://github.com/therahul-yo/clawdman">Clawdman</a></strong></p></td><td width="33%" align="center" valign="top"><p><a href="AgentMods/pi-agent/"><img src="assets/showcase-02.gif" width="100%" alt="Pi Agent: Run pi-powered models as native Claude Code subagents. Demo · 10×."></a></p><p><strong><a href="AgentMods/pi-agent/">Pi Agent</a></strong></p></td><td width="33%" align="center" valign="top"><p><a href="FocusMods/breathing-exercises/"><img src="assets/showcase-03.gif" width="100%" alt="Breathing Exercises: A guided breathing animation while Claude works."></a></p><p><strong><a href="FocusMods/breathing-exercises/">Breathing Exercises</a></strong></p></td></tr> <tr><td width="33%" align="center" valign="top"><p><a href="sources/creative-toolkit/"><img src="assets/showcase-04.png" width="100%" alt="Creative Toolkit: A collection of live panels, session tools and playful mods. Code Pet screenshot."></a></p><p><strong><a href="sources/creative-toolkit/">Creative Toolkit</a></strong></p></td><td width="33%" align="center" valign="top"><p><a href="GameMods/agent-cartoons/"><img src="assets/showcase-05.gif" width="100%" alt="Agent Cartoons: Coding activity becomes a cartoon in a choice of visual styles."></a></p><p><strong><a href="GameMods/agent-cartoons/">Agent Cartoons</a></strong></p></td><td width="33%" align="center" valign="top"><p><a href="https://github.com/adamholter/claude-subway-surfers"><img src="assets/showcase-06.gif" width="100%" alt="Subway Surfers Desktop: Gameplay beside Claude Desktop while it works. Desktop demo · 10×."></a></p><p><strong><a href="https://github.com/adamholter/claude-subway-surfers">Subway Surfers Desktop</a></strong></p></td></tr> <tr><td width="33%" align="center" valign="top"><p><a href="GameMods/dino-game/"><img src="assets/showcase-07.gif" width="100%" alt="Dino Game: Play the T-rex runner above your prompt."></a></p><p><strong><a href="GameMods/dino-game/">Dino Game</a></strong></p></td><td width="33%" align="center" valign="top"><p><a href="GameMods/spell-bar/"><img src="assets/showcase-08.gif" width="100%" alt="Spell Bar: An animated spell bar for your selected effort level."></a></p><p><strong><a href="GameMods/spell-bar/">Spell Bar</a></strong></p></td><td width="33%" align="center" valign="top"><p><a href="AgentMods/agent-dashboard/"><img src="assets/showcase-09.gif" width="100%" alt="Agent Dashboard: A live dashboard for context, costs, permissions and agents."></a></p><p><strong><a href="AgentMods/agent-dashboard/">Agent Dashboard</a></strong></p></td></tr> </table>

<a id="browse-by-category"></a>

<h2>Browse by category <img src="assets/category-magnifier.gif" width="52" height="40" alt="Clawd with a magnifying glass"></h2>

Usage Mods · Agent Mods · Planning Mods · File Mods · Git Mods · Safety Mods · Memory Mods · Interface Mods · Prompt Mods · Testing Mods · Web Mods · Focus Mods · Game Mods · Integration Mods

Source packages available here: 82 mods from 38 repositories, with their complete tracked files and notices. Local package links open inside this collection. Other entries currently lead to upstream references while the wider source import is reviewed.

Entries are grouped by their main purpose. Collections can contain several kinds of mods; hosted mod pages are categorized individually.

<a id="usage"></a>

Usage Mods

Browse Usage Mods

Track model usage, costs, effort and quotas.

  • Auto Effort · arthur-fontaine - Let Jev select Claude's reasoning effort for each prompt, adapting the configured effort level to the incoming request rather than choosing it manually every time.
  • Automatic Model and Effort Router · sofanaja44 - Choose a Claude model and reasoning effort according to task difficulty, with rules that limit unnecessary prompt-cache rewrites. Cost and cache statistics help you follow the routing decisions.
  • Braille Usage · kawase1295 - Check your current model, context usage and five-hour or weekly limits in compact text-based meters above the prompt.
  • Budget and Context Toolkit · Arunjay4213 - Track session spending, subscription quotas, cache use and per-turn token costs. Set budget limits that warn before exhaustion and stop tool calls when a configured limit is exceeded.
  • Budget Governor · ccdwyer - Set session and daily spending caps, monitor them above the prompt, and receive a wrap-up reminder at eighty percent. New prompts are refused once the cap is reached.
  • Cache, Context and Cost Panel · anthonyhungnguyen - Track token spending for the session and recent days, get context-limit warnings, and see what rebuilding the prompt cache cost. A completion alert announces long turns.
  • Clawd Usage Meters · Toshkee - Watch live context, rate-limit and cost meters above the prompt, accompanied by Clawd's wand animations and banner tricks as you work in Claude Code.
  • Cockpit and Bodyguard · Para-FR - Inspect session turns, tools, tokens, durations and changed files in a live cockpit. A companion guard blocks access to .env files and destructive Bash commands, displaying blocked-action alerts.
  • Context and Quota Forecast · imsalik - See a context forecast alongside five-hour and weekly rate-limit usage above the prompt. Reset countdowns help you understand when each plan allowance becomes available again.
  • Context Saver · AlmogBaku - Spot repeated tool calls and wasteful workflows during a Claude Code session. Review their measured time and context cost, then send a suggested correction with one click.
  • Context Weather and Usage · travisoa - Read context fullness as changing weather symbols alongside token totals and quota consumption. Recent-turn bars show how the conversation grew, helping you spot increasing pressure on the context window.
  • Cost and Output Token Band · Luma-Sa - Keep five-hour and seven-day usage limits, session cost and output-token totals visible in a band above the Claude Code prompt while you work.
  • Cost Meter · zaferayan - Track API-equivalent session cost above the prompt, including subagent usage, with a configurable budget warning. On subscription plans, the displayed amount estimates API pricing rather than your bill.
  • Desktop Next Steps and Limits · shichang4fun - See quota limits and reset countdowns above the prompt in Claude Code Desktop. After each turn, suggested next prompts can be sent with a click or copied for editing.
  • Desktop Session Status Bar · sgmonda - Keep five-hour and weekly usage limits, reset times, session clocks and turn duration visible in Claude Code Desktop. The bar also lists active subagents, background jobs and scheduled wakeups.
  • Desktop SVG Usage Meters · s-hiraoku - See context use and the five-hour and seven-day quota windows as SVG meters above the prompt in Claude Code Desktop. The compact display keeps capacity visible while you work.
  • Effort Guard · stefanochieli - Track context and token usage, review an effort log for each turn and receive an escalation alert after repeated command or test failures. It observes activity without blocking it.
  • Effort Router · totally-tim - Choose reasoning effort for each turn with Jev or a compatible classifier, and display the selected level. Shadow mode records the recommendation; enforce mode applies it to the session.
  • Effort Shortcut · daanqq - Cycle Claude's reasoning effort through low, medium, high, and extra-high with one command. Bind that command to a key to change effort quickly while skipping the maximum setting.
  • Focus Band · chaoshengsc - Keep your five-hour and weekly plan usage visible above the prompt. Progress bars, percentages, and reset times help you see how much of each allowance you have used.
  • Jet Router · jetsongdev - Experiment with per-turn reasoning effort recommendations while keeping your chosen model. Use fixed test decisions or opt into Jev classification, with observation and optional application modes.
  • Jev Claude Router · Flam1ngFir3ball - Let Jev choose the model and reasoning effort for each turn while considering the cost of switching. It also provides Jev-guided context compaction.
  • Jev Model and Effort Selection · unclecode - Observe or pin model and effort choices, or opt into automatic selection with Jev. The experimental router lets you compare recommendations before allowing them to steer the session.
  • Jev Model Router · satviksinha - Choose a model and reasoning effort for each turn using TypeSafe's Jev, through its direct API or Vercel AI Gateway. A visible routing decision shows which model will handle the work.
  • Jev Route · drewpayment - Choose a Claude model for each turn using Jev's estimate of what the task needs. Toggle routing with /route to balance model capability against cost.
  • Limitpace · fstandhartinger - Track subscription limits and spending pace through live usage bars. An optional delegation adviser can suggest using another provider as part of managing your available capacity.
  • Model and Effort Router · moritalous - Choose reasoning effort for each prompt while keeping the model fixed, or enable a separate router that selects Haiku, Sonnet, or Opus using Jev. Both tools adjust resources per turn.
  • Model Shortcuts · richkuo - Use keyboard shortcuts to switch models and adjust reasoning effort, with the active choices displayed in the prompt footer.
  • Netrunner HUD · ccdwyer - Watch context use, token activity, tool calls, costs, and job status in an animated cyberpunk dashboard. Git and test widgets keep related session information alongside the live usage gauges.
  • Next Steps and Usage Band · pawandeepdhall - Keep five-hour and weekly usage limits visible with reset countdowns, and choose from suggested next steps after each reply. Buttons also provide shortcuts for starting a new chat or pushing work.
  • Oxide Jev Model Router · Kiy-K - Use Jev to choose subagent models and adjust the main conversation's model and reasoning effort as work progresses. A built-in classifier provides a fallback without an API key.
  • Plan and Context Usage Line · KhadeerBasha1232 - Track five-hour and weekly plan limits, reset times, context usage, the active model and session cost in one line above the Claude Code prompt.
  • Plan Usage and Reset Times · jumoog - Keep five-hour and weekly plan usage visible above the prompt, together with each reset time. Check the current allowance windows without opening a separate usage screen.
  • Power Bottom Status Line · brianSchanbacher - See the model, folder, branch, context fill, cost, elapsed time, cache hits and live agents in a cross-platform status line at the bottom of Claude Code.
  • Project Spend and Cache Status · Sma1lboy - Track project spending alongside context usage, cache hits and the cache countdown. Inspect why a turn missed the cache, and open the project ledger with /usage.
  • Prompt Enhancement and Telemetry · MiguelMachado-dev - Inspect per-turn duration, first-token latency, token throughput, requests and stalls in a live band. An additional command rewrites a draft prompt with Sonnet and returns it for your review.
  • Prompt Footer Usage · WoBok - Keep context usage and rate-limit consumption visible in Claude Code's prompt footer, so you can check those readings without opening a separate usage view.
  • Prompt Usage Timeline · augiefra - Compare five-hour and weekly usage with elapsed time, inspect token bars for recent prompts and watch context status above the prompt. Labels are available in English or French.
  • Quota and Cost Pills · Sh0ckWaveZero - Track five-hour and seven-day rate limits, session tokens and cost in compact indicators above the prompt. The band can sit alongside other status bands.
  • Quota Reserve · chrisns - Reserve part of your five-hour and weekly quota for later work. The mod pauses at configured thresholds, asks whether to continue, and releases the reserve shortly before the reset.
  • Rate-Limit History Band · mohammed-alsalhi - Follow rate-limit consumption since each window last reset, including five-hour, weekly, and per-model weekly allowances. A band above the prompt graphs how usage accumulates over time.
  • Retro Usage Band · iamkhalid2 - Keep your plan's five-hour and weekly usage limits in view through a slim retro-style band above the prompt. Check both allowances as you work.
  • Sessclone · NotTahaAli - Send Claude Code usage to a SessClone deployment so laptop, cloud and CI sessions can contribute to a shared team ledger of usage and cost.
  • Session and Weekly Usage Band · Gooner44 - See session, weekly and Fable 5.1 usage together in a band above the Claude Code prompt, keeping these separate usage readings visible while you work.
  • Session Cockpit · nvr0x5 - Track context, spending, usage limits, reset countdowns and burn rates above the prompt. Plan progress and active subagents share the same cockpit, with display options for terminal and Desktop sessions.
  • Session Cost and Remaining Usage · HydrowZer - See the current session cost and remaining usage in a band above the Claude Code prompt, keeping spending and available capacity visible beside your conversation.
  • Session Dashboard and Alerts · lucenity0 - Follow turn durations, token counts, files touched, and tool activity through a timer and dashboard. Companion mods notify you when long turns finish and block destructive commands or edits to secrets.
  • Session Internals Dashboard · tomstagl - Inspect context, tokens, cost, limits, tools and agents in a live side-pane dashboard. An accompanying insights interface lets the session answer questions using the displayed measurements.
  • Session Sidebar · richard-parayno - Toggle a sidebar showing usage limits, context size, prompt-cache expiry, current activity and touched files. It keeps the session's resource picture visible beside the conversation.
  • Stack and Wait Time · ShahriarBijoy - Follow tasks, subagents, rate limits and monthly cost in a stacked progress band. A companion vocabulary game uses German words related to the session's topic while Claude works.
  • Terminal Usage Monitor · ilkayGkbdk - Monitor context fill, rate limits, token speed, cost, and active subagents in a live terminal dashboard. The display brings the session's resource use together in one place.
  • Turn Receipts and Error Cues · roaringsoul404 - Receive a receipt after each turn listing touched files, commands, tokens and cost. A companion animation turns the spinner into a pixel-art failure cue when a tool call fails.
  • Usage and Active Agent Dashboard · quango2304 - Follow usage bars, limits, session cost, cache expiry and active time alongside the agents currently running. The combined display makes resource consumption visible while work is underway.
  • Usage and Cache Warmth Band · Vosssa - See five-hour and weekly usage, context fill and prompt-cache warmth together above the Claude Code prompt, keeping the main resource readings visible while you work.
  • Usage and Spend Chips · kreddevils18 - Track five-hour and weekly allowances, session cost, and spending history through colored chips above the prompt. Open /usage-mod for a fuller view of the usage details.
  • Usage Line · Steven-ODell - See five-hour, weekly and context usage in the prompt footer, including whether consumption is ahead of or behind an even pace through the usage window.
  • Usage Log · tanuu5 - Record five-hour and weekly usage after each turn, then compare consumption with your chosen pace in a graph. The separate Clawd Dance mod can also show that pacing above the prompt.
  • Usage Report · Schweem - Monitor context usage, five-hour and weekly quotas, and session cost in the status line, with warnings as you approach your limits.
  • Usage Wrapup · DaKev - Ask running agents to wrap up when your remaining plan allowance gets low, helping them finish their current work before the usage limit interrupts it.
  • Weekly Usage and Spend Board · aycandv - Compare spending by model with a weekly-limit pace gauge, using a sliding ticker in the terminal or a strip and details pane in the desktop app.

<a id="agents"></a>

Agent Mods

Browse Agent Mods

Coordinate agents and follow their actual work.

  • 5dive Telemetry Toolkit · 5dive-ai - Connect Claude Code sessions to 5dive's task and gate controls, with a panel showing assignments and token budgets. Optional telemetry, compaction and tool-call policies support coordinated agent work.
  • Agent Control and Document Desk · masahide - Send messages and interruption requests to Claude through the agentctl command line and collect acknowledgments. A companion document desk gathers questionnaire answers and detailed document-review comments in a browser, then delivers them to Claude.
  • Agent Dashboard · scasella - Monitor the main agent and its subagents in a live dashboard that brings together model usage, permission requests, task activity and session logs.
  • Agent Flow · Charlie0113-T - Open /flow to follow a live tree of the session's subagents and teammates beside the transcript. A text view is available where panes cannot be displayed.
  • Agent Models and Peek · alex2481kobe - Show each subagent's model and reasoning effort beside its task in the native agent list. A companion viewer displays images inline, using real pixels or a terminal-compatible block
Source 5 files
hooks/register.mjs 245 lines
1// Collision Guard: before Claude edits or writes a file, it checks whether
2// another open chat changed that file in the last 30 minutes. If one did, it
3// asks in the engine's own question dialog: Proceed, Move to a worktree (only
4// when the file is tracked in git, since a worktree carries tracked files
5// only), or Cancel. The choice goes back to Claude as the edit's result.
6//
7// Why: with several chats open in one repo, two of them can end up changing the
8// same file without knowing about each other.
9// Zero tokens: each chat keeps a small ledger of the files it changed, one file
10// per chat under ~/.claude/mods-data/collision-guard/, and reads the others'
11// before each edit. Nothing is added to the prompt.
12
13import { normPath, chatName } from './coach.mjs'
14import { clip, basename, minutes } from './fmt.mjs'
15import { makeMasker } from './privacy.mjs'
16
17const MIN = 60000
18const DIR = '/.claude/mods-data/collision-guard'
19
20const settings = {
21  on: true,
22  windowMin: 30, // another chat's edit this recent counts
23  liveMin: 15, // a chat whose ledger hasn't moved this long is closed
24  ignore: ['/.claude/projects/', '/.claude/mods-data/'], // memory and mod data: many chats write these on purpose
25}
26
27// This chat's ledger
28const S = { id: '', cwd: '', title: '', files: {} }
29let home = ''
30let dir = ''
31let now = 0
32let commandName = 'guard'
33let wrote = false
34let rec = { on: false, strict: false }
35let mask = (s) => s
36// "key|otherChat" -> the other chat's edit you said Proceed to; a newer edit asks again
37const allowed = new Map()
38const stats = { asked: 0, proceeded: 0, worktree: 0, cancelled: 0 }
39
40function own() {
41  return `${dir}/${S.id}.json`
42}
43
44async function writeOwn($, ended) {
45  if (!S.id || !dir) return
46  for (const [key, f] of Object.entries(S.files)) if (now - f.at > 2 * settings.windowMin * MIN) delete S.files[key]
47  if (!wrote && !ended && Object.keys(S.files).length === 0) return // a chat that never edits leaves no file
48  try {
49    await $.fs.write(own(), JSON.stringify({ id: S.id, title: S.title, cwd: S.cwd, updatedAt: now, ended: !!ended, files: ended ? {} : S.files }))
50    wrote = true
51  } catch {
52    // a locked file: the next edit or heartbeat writes it again
53  }
54}
55
56async function readOthers($) {
57  const out = []
58  let entries = []
59  try {
60    entries = await $.fs.list(dir)
61  } catch {
62    return out
63  }
64  for (const entry of entries) {
65    if (!entry.name.endsWith('.json') || entry.name === S.id + '.json') continue
66    try {
67      const v = JSON.parse(await $.fs.read(`${dir}/${entry.name}`))
68      if (!v || v.ended || now - (v.updatedAt || 0) > settings.liveMin * MIN) continue
69      out.push(v)
70    } catch {
71      // half-written or gone: skip it this time
72    }
73  }
74  return out
75}
76
77async function readRecording($) {
78  try {
79    const p = home + '/.claude/mods-data/recording.json'
80    if (!(await $.fs.exists(p))) rec = { on: false, strict: false }
81    else {
82      const flag = JSON.parse(await $.fs.read(p))
83      rec = { on: !!flag.on, strict: !!flag.on && !!flag.strict }
84    }
85  } catch {
86    rec = { on: false, strict: false }
87  }
88  mask = rec.on ? makeMasker({ strict: rec.strict }) : (s) => s
89}
90
91// The most recent edit of this file by another open chat, unless you already said Proceed to it
92function collisionFor(key, others) {
93  let best = null
94  for (const o of others) {
95    const f = o.files && o.files[key]
96    if (!f || now - f.at > settings.windowMin * MIN) continue
97    if ((allowed.get(key + '|' + o.id) || 0) >= f.at) continue
98    if (!best || f.at > best.at) best = { other: o, at: f.at }
99  }
100  return best
101}
102
103function agoText(ms) {
104  return ms < MIN ? 'just now' : `${minutes(ms)} ago`
105}
106
107// A worktree checks out tracked files only, so it helps only when git tracks this one
108async function isTracked($, raw) {
109  try {
110    const r = await $.process.run(['git', '-C', S.cwd, 'ls-files', '--error-unmatch', '--', raw], { timeoutMs: 5000 })
111    return r.exitCode === 0
112  } catch {
113    return false
114  }
115}
116
117async function decide($, raw, key, hit) {
118  const file = basename(raw)
119  const when = agoText(now - hit.at)
120  const surfaces = await $.session.surfaces()
121  if (!surfaces.length) {
122    // nobody to ask (a -p run): let it through rather than break an unattended job
123    $.ui.log(`collision-guard: another chat edited ${file} ${when}; nobody to ask, so the edit went ahead`, { to: 'debug' })
124    return null
125  }
126  const options = ['Proceed']
127  if (await isTracked($, raw)) options.push('Move to a worktree')
128  options.push('Cancel')
129  stats.asked += 1
130  let answer = 'Cancel'
131  try {
132    const who = rec.on ? 'Another open chat' : `Another open chat, "${clip(mask(chatName(hit.other)), 60)}",`
133    answer = await $.ui.ask(`${who} edited ${file} ${when}. Edit it here too?`, { options, header: 'Collision' })
134  } catch {
135    answer = 'Cancel' // dismissed
136  }
137  const what = `another open Claude Code chat ("${clip(chatName(hit.other), 80)}") edited ${raw} ${when}`
138  if (answer === 'Proceed') {
139    allowed.set(key + '|' + hit.other.id, hit.at)
140    stats.proceeded += 1
141    return null
142  }
143  if (answer === 'Move to a worktree') {
144    stats.worktree += 1
145    return { deny: `Not edited: ${what}. The user wants this chat's work moved into a git worktree so the two chats stop changing the same files. First list the files you already changed in this checkout (they stay here), then use the EnterWorktree tool and make this change inside the worktree.` }
146  }
147  stats.cancelled += 1
148  if (answer === 'Cancel') {
149    return { deny: `Not edited: ${what}, and the user cancelled this edit. Don't change this file again. Tell the user what you were about to change and ask how to proceed.` }
150  }
151  // text typed under "Other"
152  return { deny: `Not edited: ${what}. The user answered: "${clip(answer, 400)}". Follow that before touching this file again.` }
153}
154
155async function statusText($) {
156  now = await $.clock.now()
157  const others = await readOthers($)
158  const lines = [`Collision Guard is ${settings.on ? 'on' : 'off'}. Before an edit, it asks when another open chat changed the same file in the last ${settings.windowMin} minutes.`]
159  const busy = others
160    .map((o) => ({ o, n: Object.values(o.files || {}).filter((f) => now - f.at <= settings.windowMin * MIN).length }))
161    .filter((x) => x.n > 0)
162  if (busy.length) {
163    lines.push(`Other open chats with recent edits: ${busy.map((x) => `${rec.on ? 'a chat' : `"${clip(mask(chatName(x.o)), 40)}"`} (${x.n} file${x.n === 1 ? '' : 's'})`).join(', ')}.`)
164  } else lines.push('No other open chat has edited a file in that window.')
165  const mine = Object.values(S.files).filter((f) => now - f.at <= settings.windowMin * MIN)
166  lines.push(mine.length ? `This chat changed ${mine.length} file${mine.length === 1 ? '' : 's'}: ${mine.slice(-5).map((f) => basename(f.path)).join(', ')}.` : 'This chat has not changed a file in that window.')
167  lines.push(`This chat so far: asked ${stats.asked}, proceeded ${stats.proceeded}, moved to a worktree ${stats.worktree}, cancelled ${stats.cancelled}.`)
168  lines.push(`Settings: /${commandName} on|off, /${commandName} window <minutes>.`)
169  return lines.join('\n')
170}
171
172export function register(on) {
173  on('session.start', async ($, e, next) => {
174    now = await $.clock.now()
175    home = ((await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || '').replace(/\\/g, '/')
176    dir = home + DIR
177    S.id = await $.session.id()
178    S.cwd = await $.session.cwd()
179    const saved = await $.store.get('settings')
180    if (saved && typeof saved === 'object') Object.assign(settings, saved)
181    for (const name of ['guard', 'collision-guard']) {
182      try {
183        await $.command.register({ name, description: 'Collision Guard: asks before editing a file another open chat just changed', argumentHint: '[on|off|window <minutes>]', immediate: true })
184        commandName = name
185        break
186      } catch {
187        // taken: try the next name
188      }
189    }
190    // the ledger's heartbeat: other chats treat a ledger quiet for 15 minutes as closed
191    $.clock.every(60000, async () => {
192      now = await $.clock.now()
193      await writeOwn($)
194    })
195    return next(e)
196  })
197
198  on('session.end', async ($, e, next) => {
199    now = await $.clock.now()
200    if (wrote) await writeOwn($, true)
201    return next(e)
202  })
203
204  // The first prompt names this chat in other chats' questions
205  on('prompt.submit', async ($, e, next) => {
206    if (!S.title && e.origin && ['composer', 'bridge', 'sdk'].includes(e.origin.kind) && e.text) S.title = clip(e.text, 80)
207    return next(e)
208  })
209
210  on('tool.call', { tool: ['Edit', 'Write', 'NotebookEdit'] }, async ($, e, next) => {
211    if (!settings.on || !dir) return next(e)
212    const raw = String(e.file_path || e.notebook_path || '')
213    const key = normPath(raw, S.cwd)
214    if (!key || settings.ignore.some((s) => key.includes(s.toLowerCase()))) return next(e)
215    now = await $.clock.now()
216    const hit = collisionFor(key, await readOthers($))
217    if (hit) {
218      await readRecording($)
219      const refusal = await decide($, raw, key, hit)
220      if (refusal) return refusal
221      now = await $.clock.now()
222    }
223    // recorded before the edit runs, so a chat editing at the same moment sees it
224    S.files[key] = { path: raw, at: now }
225    await writeOwn($)
226    return next(e)
227  })
228
229  on('command.run', { command: ['guard', 'collision-guard'] }, async ($, e) => {
230    const [key, value] = String(e.args || '').trim().split(/\s+/)
231    const k = (key || '').toLowerCase()
232    if (k === 'on' || k === 'off') {
233      settings.on = k === 'on'
234      await $.store.set('settings', settings)
235      return { text: `Collision Guard ${settings.on ? 'on' : 'off'} in every chat that starts from now (and this one).` }
236    }
237    if (k === 'window' && Number(value) > 0) {
238      settings.windowMin = Math.round(Number(value))
239      await $.store.set('settings', settings)
240      return { text: `Collision Guard now counts another chat's edits from the last ${settings.windowMin} minutes.` }
241    }
242    return { text: await statusText($) }
243  })
244}
245
hooks/coach.mjs 325 lines
1// Shared source. Copied into each mod's hooks/ folder by _dev/sync-shared.mjs.
2// The Session Coach (Command Center's Coach tab) and the Collision Guard: pure
3// functions over what each open chat reports in its heartbeat. No mods API calls.
4
5import { clip, basename, tokens, minutes, usd } from './fmt.mjs'
6import { rewriteCost } from './pricing.mjs'
7
8const MIN = 60000
9
10// One spelling per file, so two chats' edits compare: forward slashes, no . or
11// .., and lowercase on Windows, where C:\X and c:/x are the same file
12export function normPath(p, cwd) {
13  let s = String(p || '').trim().replace(/\\/g, '/')
14  if (!s) return ''
15  if (/^\/[a-zA-Z]\//.test(s)) s = s[1] + ':' + s.slice(2) // Git Bash /c/Users is C:/Users
16  const isAbs = /^[A-Za-z]:\//.test(s) || s.startsWith('/')
17  if (!isAbs && cwd) s = String(cwd).replace(/\\/g, '/').replace(/\/+$/, '') + '/' + s
18  const parts = []
19  for (const part of s.split('/')) {
20    if (part === '.' || (part === '' && parts.length)) continue
21    if (part === '..') {
22      if (parts.length > 1) parts.pop()
23      continue
24    }
25    parts.push(part)
26  }
27  s = parts.join('/')
28  return /^[A-Za-z]:/.test(s) ? s.toLowerCase() : s
29}
30
31// A path shown relative to a root when it sits inside it
32export function relTo(path, root) {
33  const p = String(path || '').replace(/\\/g, '/')
34  const r = String(root || '').replace(/\\/g, '/').replace(/\/+$/, '')
35  if (r && p.toLowerCase().startsWith(r.toLowerCase() + '/')) return p.slice(r.length + 1)
36  return p
37}
38
39// Where Claude Code keeps a chat's transcript: the folder is the working
40// directory with every character but letters and digits turned into "-"
41export function transcriptPath(home, cwd, id) {
42  if (!home || !cwd || !id) return ''
43  return `${home}/.claude/projects/${String(cwd).replace(/[^A-Za-z0-9]/g, '-')}/${id}.jsonl`
44}
45
46// `git rev-parse --abbrev-ref HEAD --show-toplevel --git-dir --git-common-dir`
47// prints four lines; a worktree's git dir differs from the common one
48export function gitInfo(stdout, cwd) {
49  const lines = String(stdout || '').split(/\r?\n/).map((l) => l.trim()).filter(Boolean)
50  if (lines.length < 4) return null
51  const [branch, root, gitDir, common] = lines
52  return { branch, root, worktree: normPath(gitDir, cwd) !== normPath(common, cwd) }
53}
54
55// A chat's name for people: its first prompt, without the "<folder> · " prefix
56export function chatName(hb) {
57  const name = String(hb?.title || String(hb?.label || '').replace(/^[^·]*·\s*/, '')).trim()
58  return name || basename(hb?.cwd) || 'a chat'
59}
60
61function lastActive(c) {
62  return c.activeAt || c.lastDone?.at || 0
63}
64
65// ---------- rules: facts the Coach sees without AI ----------
66
67// Two open chats changed the same file recently, or a big chat sits idle long
68// enough that its prompt cache has expired
69export function ruleTips(chats, now, opts = {}) {
70  const overlapMs = (opts.overlapMin ?? 60) * MIN
71  const parkIdleMs = (opts.parkIdleMin ?? 60) * MIN
72  const parkTokens = opts.parkTokens ?? 250000
73  const live = (chats || []).filter((c) => c && c.id)
74  const tips = []
75  for (let i = 0; i < live.length; i++) {
76    for (let j = i + 1; j < live.length; j++) {
77      const a = live[i]
78      const b = live[j]
79      const recentA = new Map((a.edits || []).filter((e) => now - e.at <= overlapMs).map((e) => [e.key, e]))
80      const shared = (b.edits || []).filter((e) => now - e.at <= overlapMs && recentA.has(e.key))
81      if (!shared.length) continue
82      const lastA = Math.max(...shared.map((e) => recentA.get(e.key).at))
83      const lastB = Math.max(...shared.map((e) => e.at))
84      // the chat that touched the shared files last is the one that walked in
85      const later = lastB >= lastA ? b : a
86      const earlier = later === a ? b : a
87      // fold into the chat that's further along
88      const into = (earlier.turns || 0) >= (later.turns || 0) ? earlier : later
89      const from = into === a ? b : a
90      const untracked = shared.filter((e) => e.tracked === false || recentA.get(e.key).tracked === false).map((e) => e.path)
91      tips.push({
92        id: 'overlap:' + [a.id, b.id].sort().join('+'),
93        kind: 'overlap',
94        source: 'rule',
95        at: Math.max(lastA, lastB),
96        chats: [earlier.id, later.id],
97        files: shared.map((e) => e.path),
98        untracked,
99        actions: [...(untracked.length ? [] : [{ kind: 'worktree', target: later.id }]), { kind: 'merge', from: from.id, into: into.id }],
100      })
101    }
102  }
103  for (const c of live) {
104    if (c.state === 'working' || c.state === 'waiting') continue
105    const since = lastActive(c)
106    if (!since || !c.ctx || c.ctx < parkTokens || now - since < parkIdleMs) continue
107    tips.push({
108      id: 'park:' + c.id,
109      kind: 'park',
110      source: 'rule',
111      at: since,
112      chats: [c.id],
113      ctx: c.ctx,
114      idleMs: now - since,
115      cost: rewriteCost(c.ctx, c.model),
116      actions: [{ kind: 'handoff', target: c.id }],
117    })
118  }
119  return tips
120}
121
122// ---------- the AI pass ----------
123
124export const AI_KINDS = ['merge', 'split', 'worktree', 'park', 'focus']
125
126export const COACH_SYSTEM = `You coach one person who runs many Claude Code chats at once. You get a card for each open chat. Suggest how to organize them. Reply with JSON only, no prose and no code fence, in this shape:
127{"tips":[{"kind":"merge","chats":["c1","c2"],"into":"c2","target":"c1","title":"...","why":"..."}],"groups":[{"name":"...","chats":["c1","c2"]}]}
128
129Kinds:
130- merge: two or three chats whose prompts describe the same output (the same file, video, page, or document), or one redoing the other's work. "into" is the chat to keep (further along or more context). Chats on different parts of one project are not a merge: put them in the same group instead. A thin card (no prompts or files) is not evidence for a merge.
131- split: one chat's recent prompts started a task unrelated to its first prompt, and its context is large (over 150k tokens). "target" is that chat. Name the new task in the title.
132- worktree: two chats change files in the same project folder at the same time and could overwrite each other. "target" is the chat that should move. Never for chats that only read.
133- park: idle 60 minutes or more with over 200k tokens of context. "target" is that chat.
134- focus: too many chats working at once on unrelated things, or several waiting on the person. At most one.
135
136Rules:
137- 0 to 5 tips, most useful first. No tip beats a weak one. Never invent chats; use only the card ids.
138- The person never sees the card ids, so title and why name chats by what they do, never as c1 or c2.
139- title: under 70 characters, plain words.
140- why: one or two sentences under 200 characters that cite evidence from the cards (prompts, file names, minutes, tokens).
141- groups: put every chat in exactly one group of related work, 2 to 6 groups, names under 24 characters.
142- Write plainly. No em dashes, no hype.`
143
144// One card per chat, with short ids (c1, c2, ...) so the reply can't invent sessions
145export function coachCards(chats, now) {
146  const ids = new Map()
147  const blocks = (chats || []).map((c, i) => {
148    const sid = 'c' + (i + 1)
149    ids.set(sid, c.id)
150    const lines = [`${sid}: ${clip(chatName(c), 90)}`]
151    let state
152    if (c.state === 'working') state = `working (${c.activity || 'thinking'})`
153    else if (c.state === 'waiting') state = `waiting on the person (${c.waitingFor || 'a question'})`
154    else state = lastActive(c) ? `idle ${minutes(now - lastActive(c))}` : 'idle, no prompt yet'
155    lines.push(`  state: ${state}`)
156    const folder = String(c.cwd || '').replace(/\\/g, '/')
157    lines.push(`  folder: ${folder}${c.repo ? ` (git branch ${c.repo.branch}${c.repo.worktree ? ', in a worktree' : ''})` : ''}`)
158    lines.push(c.ctx ? `  context: ${tokens(c.ctx)} tokens, ${c.turns || 0} turns` : `  turns: ${c.turns || 0}`)
159    const prompts = (c.prompts || []).map((p) => `"${clip(p.text, 140)}"`)
160    if (prompts.length) lines.push(`  recent prompts, oldest first: ${prompts.join(' | ')}`)
161    if (c.lastDone?.text) lines.push(`  last answer began: "${clip(c.lastDone.text, 140)}"`)
162    const files = (c.edits || []).slice(-8).map((e) => relTo(e.path, c.repo?.root || c.cwd))
163    if (files.length) lines.push(`  files it changed: ${files.join(', ')}`)
164    return lines.join('\n')
165  })
166  const names = new Map((chats || []).map((c) => [c.id, chatName(c)]))
167  return { text: `${blocks.length} open chats:\n\n${blocks.join('\n\n')}`, ids, names }
168}
169
170// Change detection for the automatic pass: who's open, what they were asked,
171// what they touched, and context in 100k steps
172export function coachBasis(chats) {
173  return (chats || [])
174    .map((c) => [c.id, chatName(c), (c.prompts || []).at(-1)?.at || 0, (c.edits || []).length, Math.round((c.ctx || 0) / 100000)].join('|'))
175    .sort()
176    .join('\n')
177}
178
179function plain(text, max) {
180  return clip(String(text || '').replace(/\s*[—–]\s*/g, ', '), max)
181}
182
183// The model's JSON, checked: known kinds, real chats only, short text. Card ids
184// it slipped into the words ("c3") become the chat's name, since the person
185// never sees the cards. `names` maps a session id to its name.
186export function parseCoachReply(text, ids, names = new Map()) {
187  const words = (s, max) => plain(String(s || '').replace(/\b(c\d+)\b/g, (m) => (ids.has(m) && names.has(ids.get(m)) ? `"${clip(names.get(ids.get(m)), 40)}"` : m)), max)
188  const s = String(text || '')
189  const start = s.indexOf('{')
190  const end = s.lastIndexOf('}')
191  if (start < 0 || end <= start) return null
192  let v
193  try {
194    v = JSON.parse(s.slice(start, end + 1))
195  } catch {
196    return null
197  }
198  if (!v || typeof v !== 'object') return null
199  const real = (x) => ids.get(String(x || '').trim())
200  const tips = []
201  for (const t of Array.isArray(v.tips) ? v.tips : []) {
202    const kind = String(t?.kind || '').toLowerCase()
203    if (!AI_KINDS.includes(kind)) continue
204    const chats = [...new Set((Array.isArray(t.chats) ? t.chats : []).map(real).filter(Boolean))]
205    // nobody folds four chats into one: a merge that wide is a group, not a tip
206    const named = Array.isArray(t.chats) ? t.chats.length : 0
207    if (!chats.length || (kind === 'merge' && (chats.length < 2 || named > 3))) continue
208    const title = words(t.title, 90)
209    if (!title) continue
210    const tip = { id: `ai:${kind}:${[...chats].sort().join('+')}`, kind, source: 'ai', chats, title, why: words(t.why, 260) }
211    if (kind === 'merge') tip.into = chats.includes(real(t.into)) ? real(t.into) : chats[0]
212    if (kind === 'worktree' || kind === 'split' || kind === 'park') tip.target = chats.includes(real(t.target)) ? real(t.target) : chats[chats.length - 1]
213    tips.push(tip)
214    if (tips.length >= 5) break
215  }
216  const groups = []
217  const placed = new Set()
218  for (const g of Array.isArray(v.groups) ? v.groups : []) {
219    const chats = (Array.isArray(g?.chats) ? g.chats : []).map(real).filter((id) => id && !placed.has(id))
220    const name = plain(g?.name, 24)
221    if (!name || !chats.length) continue
222    chats.forEach((id) => placed.add(id))
223    groups.push({ name, chats })
224    if (groups.length >= 8) break
225  }
226  return { tips, groups }
227}
228
229// What each tip's buttons do
230export function tipActions(tip) {
231  if (tip.actions) return tip.actions
232  if (tip.kind === 'merge') return tip.chats.filter((id) => id !== tip.into).slice(0, 2).map((from) => ({ kind: 'merge', from, into: tip.into }))
233  if (tip.kind === 'worktree') return [{ kind: 'worktree', target: tip.target }]
234  if (tip.kind === 'split') return [{ kind: 'split', target: tip.target, topic: tip.title }]
235  if (tip.kind === 'park') return [{ kind: 'handoff', target: tip.target }]
236  return []
237}
238
239// Rule tips are facts and come first. An AI tip about chats that closed, or one
240// a rule already covers (same chats), is dropped.
241export function mergeTips(rule, ai, liveIds, dismissed) {
242  const live = new Set(liveIds)
243  const done = dismissed || {}
244  const covered = new Set(rule.map((t) => [...t.chats].sort().join('+')))
245  const out = [...rule]
246  for (const t of ai || []) {
247    if (!t.chats.every((id) => live.has(id))) continue
248    if (covered.has([...t.chats].sort().join('+')) && t.kind !== 'split' && t.kind !== 'focus') continue
249    out.push(t)
250  }
251  return out.filter((t) => !done[t.id])
252}
253
254// ---------- words on screen ----------
255
256const TAGS = { overlap: 'SAME FILES', park: 'PARK', merge: 'MERGE', split: 'SPLIT', worktree: 'WORKTREE', focus: 'FOCUS' }
257
258export function describeTip(tip, byId, now) {
259  const name = (id, n = 40) => `"${clip(chatName(byId.get(id) || { title: 'a closed chat' }), n)}"`
260  const tag = TAGS[tip.kind] || tip.kind.toUpperCase()
261  if (tip.kind === 'overlap') {
262    const files = tip.files.map(basename)
263    const list = files.length > 2 ? `${files.slice(0, 2).join(', ')} and ${files.length - 2} more` : files.join(' and ')
264    const why = tip.untracked.length
265      ? `Two chats changing the same files overwrite each other. ${basename(tip.untracked[0])} isn't tracked in git, so a worktree wouldn't carry it: fold one chat into the other instead.`
266      : 'Two chats changing the same files overwrite each other. Give the newer chat its own worktree, or fold one chat into the other.'
267    return { tag, title: `${name(tip.chats[0], 32)} and ${name(tip.chats[1], 32)} both edited ${list}`, why, when: tip.at }
268  }
269  if (tip.kind === 'park' && tip.source === 'rule') {
270    return {
271      tag,
272      title: `${name(tip.chats[0])} holds ${tokens(tip.ctx)} of context and has sat idle ${minutes(tip.idleMs)}`,
273      why: `Its prompt cache has expired, so the next message there rewrites it (about ${usd(tip.cost)}). If you're done with it, close it. If not, hand it off now so the next step starts small.`,
274      when: 0,
275    }
276  }
277  return { tag, title: tip.title, why: tip.why, when: 0 }
278}
279
280export function actionLabel(action, byId) {
281  const name = (id) => `"${clip(chatName(byId.get(id) || { title: 'a closed chat' }), 24)}"`
282  if (action.kind === 'worktree') return `worktree ${name(action.target)}`
283  if (action.kind === 'merge') return `merge into ${name(action.into)}`
284  if (action.kind === 'handoff') return `hand off ${name(action.target)}`
285  if (action.kind === 'split') return `split ${name(action.target)}`
286  return action.kind
287}
288
289// The prompt a button puts in a chat's box. `relay` is what another chat sends
290// on when the target runs older mod code and can't take it directly.
291export function actionPrompt(action, tip, byId, transcript) {
292  const name = (id) => `"${clip(chatName(byId.get(id) || { title: 'another chat' }), 80)}"`
293  if (action.kind === 'worktree') {
294    const other = tip.chats.find((id) => id !== action.target)
295    const files = (tip.files || []).map((f) => relTo(f, byId.get(action.target)?.cwd))
296    const text = `Another open Claude Code chat (${name(other)}) is changing the same files as this one${files.length ? `: ${files.join(', ')}` : ''}. Move the rest of this chat's work into a git worktree so the two stop overwriting each other. First list the files you already changed here (they stay in the main checkout), then use the EnterWorktree tool and continue there. Tell me the worktree's path and branch.`
297    return { target: action.target, text, relay: text }
298  }
299  if (action.kind === 'merge') {
300    const where = transcript
301      ? ` Its transcript is ${transcript}. It's JSONL, so read the tail, not the whole file: its user prompts and your final answers.`
302      : ' Ask me to paste its handoff.'
303    const text = `Fold another Claude Code chat into this one. It works on the same thing: ${name(action.from)}.${where} Sum up in five bullets what it did and what's left, then carry on with that work here without redoing what's finished. When you're done, tell me it's safe to close the other chat.`
304    return { target: action.into, text, relay: text }
305  }
306  if (action.kind === 'handoff') {
307    return { target: action.target, text: '/handoff', relay: 'Run the session-handoff skill now so this chat\'s work is saved before I close it.' }
308  }
309  if (action.kind === 'split') {
310    const text = `This chat has picked up a separate task: ${action.topic}. Write a short handoff for only that part (the goal, what's done, the key files, and the next step) so I can start it in a fresh chat. Don't keep working on it here.`
311    return { target: action.target, text, relay: text }
312  }
313  return null
314}
315
316// For a chat on older mod code: ask Claude here to find it and pass the prompt on
317export function relayPrompt(targetHb, relay) {
318  return `Find my other Claude Code chat whose first prompt was "${clip(chatName(targetHb), 120)}" (search_session_transcripts or list_sessions will find it). Send it this message with SendMessage, word for word, then tell me it arrived:\n\n${relay}`
319}
320
321export function organizePrompt(groups, byId) {
322  const lines = groups.map((g) => `${g.name}:\n${g.chats.map((id) => `- "${clip(chatName(byId.get(id) || {}), 100)}"`).join('\n')}`)
323  return `Organize my Claude Code chats in the Desktop sidebar into these groups with your sidebar tools (create_group, move_sessions). Find each chat with list_sessions or search_session_transcripts by its first prompt. Show me the plan first and wait for my yes. Don't archive or delete anything.\n\n${lines.join('\n\n')}`
324}
325
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/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