SLOPSHOPPER

recording-mode

/rec before you record: masks keys, personal details, and business figures on screen and keeps private files closed (/rec strict, /rec off, /rec config)

newbandspinnerrowsguardcommand
★ 8v1.0.0MITupdated 2026-10-02nateherkai/claude-code-mods/recording-mode
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · recording-mode
› fix the failing auth test and add an audit log call ╭────────────────────╮ │ recording-mode │ ⏺ Read(src/auth.ts) │ Recording mode on. │ ⎿ 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 › /rec ● REC ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
● REC ⟨Claude Code's own drawing⟩
README

Claude Code mods

Four mods for Claude Code by Nate Herk. 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

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 nateherkai/claude-code-mods

Then install the mods you want, one command each:

claude plugin install cache-keeper@nateherk-mods
claude plugin install recording-mode@nateherk-mods
claude plugin install goal-meter@nateherk-mods
claude plugin install collision-guard@nateherk-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 nateherk-mods, then claude plugin update <mod>@nateherk-mods, then open a new chat. Installs are copies, so nothing changes until you update.

Remove: claude plugin uninstall <mod>@nateherk-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, 4 hours by default, with a small ping 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.
  • /cache shows status and settings: ttl 5|60|auto, guard on|off, big 150k, alerts on|off.

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 2 files
hooks/register.mjs 279 lines
1// Recording Mode: /rec before you hit record.
2// While it is on, in every Claude Code session on this machine:
3// - keys, .env values, emails, names, phone numbers, addresses, ID and account numbers,
4//   money, and business figures are masked wherever the transcript draws text
5// - results from mail, chat, tasks, files, calendar, and finance tools draw as hidden
6// - Claude can't open private files (.env files, credentials, Claude's memory, finance
7//   documents, and anything you list in the config file) or finance tools, and each
8//   prompt tells it to keep plans and figures out of its replies
9// - a pulsing red ● REC above the prompt reminds you it's on
10// /rec strict also masks every large figure and percentage. /rec off turns it all off.
11// /rec config makes ~/.claude/mods-data/recording-mode/config.json for your own name,
12// the people to hide, your private folders, and extra tools.
13// It changes what is drawn and what Claude may open; the note to Claude is the one thing it adds.
14
15import { makeMasker, deepMask, hideText, privatePathHit, toolCallTargets, secretValuesFromEnv, businessSource, financeTool } from './privacy.mjs'
16
17const FLAG_PATH = '/.claude/mods-data/recording.json'
18const CONFIG_PATH = '/.claude/mods-data/recording-mode/config.json'
19
20const CONFIG_TEMPLATE = {
21  keepNames: ['Your Name'],
22  names: ['A Teammate', 'A Client'],
23  privatePaths: ['clients/', 'finance/', 'my-private-notes.md'],
24  businessTools: [],
25  closedTools: [],
26}
27
28const CONFIG_HELP = [
29  'keepNames: your own name (and your brand), shown even while recording.',
30  'names: people whose names are always masked (teammates, clients). Names in emails and contact fields are learned on their own.',
31  'privatePaths: folders or file names Claude may not open while recording. Any part of a path, any case.',
32  'businessTools: tool or command names (any part) whose results draw as hidden, beyond the built-in list.',
33  'closedTools: tool names (any part) Claude may not call while recording, beyond the built-in finance tools.',
34]
35
36// Drawn as a plain image: an interactive Svg sits in an iframe that paints an
37// opaque white box in the dark theme and reloads on every band redraw.
38// SMIL still runs in an image. One fading dot, no ring, so a restart barely shows.
39const REC_RED = '#e5484d'
40const REC_SVG =
41  '<svg xmlns="http://www.w3.org/2000/svg" width="54" height="18" viewBox="0 0 54 18">' +
42  `<circle cx="8" cy="9" r="5" fill="${REC_RED}"><animate attributeName="opacity" values="1;0.35;1" dur="1.6s" repeatCount="indefinite" calcMode="spline" keyTimes="0;0.5;1" keySplines="0.45 0 0.55 1;0.45 0 0.55 1"/></circle>` +
43  `<text x="19" y="13.2" font-family="-apple-system,'Segoe UI',system-ui,sans-serif" font-size="12" font-weight="700" letter-spacing="0.8" fill="${REC_RED}">REC</text>` +
44  '</svg>'
45
46let home = ''
47let cwd = ''
48let commandName = 'rec'
49let rec = { on: false, strict: false, since: 0 }
50let values = [] // .env secret values: kept in memory only, never written anywhere
51let config = { keepNames: [], names: [], privatePaths: [], businessTools: [], closedTools: [] }
52let mask = (s) => s
53let blocked = 0
54let noteSent = false
55const businessCalls = new Set() // tool_use_ids whose results are business data
56
57function strings(v) {
58  return Array.isArray(v) ? v.filter((x) => typeof x === 'string' && x.trim()).map((x) => x.trim()).slice(0, 500) : []
59}
60
61function noteOn() {
62  const keep = config.keepNames.length ? ` (other than ${config.keepNames.join(', ')})` : ''
63  return (
64    `Recording mode is on: this screen is being recorded for a public video. In replies and in tool commands, leave out people's names${keep}, emails, phone numbers, addresses, account numbers, dollar amounts, revenue, profit, pricing, compensation, and other business figures, and internal plans, strategy, deals, hiring, and team matters. ` +
65    'Write placeholders like [name], [amount], or [internal plan] instead and keep summaries high level. Private files and finance tools stay closed until recording stops.'
66  )
67}
68const NOTE_OFF = 'Recording mode is off now. The earlier recording-mode note no longer applies.'
69
70function apply() {
71  mask = rec.on ? makeMasker({ strict: rec.strict, values, names: config.names, keepNames: config.keepNames }) : (s) => s
72}
73
74async function load($) {
75  await loadEnvValues($)
76  await loadConfig($)
77}
78
79function unload() {
80  values = []
81  businessCalls.clear()
82}
83
84async function loadConfig($) {
85  try {
86    const path = home + CONFIG_PATH
87    if (!(await $.fs.exists(path))) return
88    const raw = JSON.parse(await $.fs.read(path))
89    config = {
90      keepNames: strings(raw.keepNames),
91      names: strings(raw.names),
92      privatePaths: strings(raw.privatePaths),
93      businessTools: strings(raw.businessTools),
94      closedTools: strings(raw.closedTools),
95    }
96  } catch {
97    // a broken config file: the built-in rules still apply
98  }
99}
100
101function parentDirs(path, levels) {
102  const parts = String(path || '').replace(/\\/g, '/').split('/')
103  const out = []
104  for (let i = parts.length; i > 0 && out.length <= levels; i--) out.push(parts.slice(0, i).join('/'))
105  return out.filter(Boolean)
106}
107
108async function loadEnvValues($) {
109  const found = []
110  for (const dir of parentDirs(cwd, 3)) {
111    for (const name of ['.env', '.env.local']) {
112      const p = dir + '/' + name
113      try {
114        if (await $.fs.exists(p)) found.push(...secretValuesFromEnv(await $.fs.read(p)))
115      } catch {
116        // unreadable: patterns still catch the common key formats
117      }
118    }
119  }
120  values = [...new Set(found)].sort((a, b) => b.length - a.length)
121}
122
123async function readFlag($) {
124  try {
125    const path = home + FLAG_PATH
126    if (!(await $.fs.exists(path))) return { on: false, strict: false, since: 0 }
127    const flag = JSON.parse(await $.fs.read(path))
128    return { on: !!flag.on, strict: !!flag.strict, since: flag.since || 0 }
129  } catch {
130    return { on: false, strict: false, since: 0 }
131  }
132}
133
134// Another session may have turned recording on or off
135async function syncFlag($) {
136  const flag = await readFlag($)
137  if (flag.on === rec.on && flag.strict === rec.strict) return
138  rec = flag
139  if (rec.on) await load($)
140  else unload()
141  apply()
142  $.ui.invalidate('ui.render')
143}
144
145async function setFlag($, on, strict) {
146  rec = { on, strict: on && strict, since: on ? await $.clock.now() : 0 }
147  await $.fs.write(home + FLAG_PATH, JSON.stringify(rec))
148  if (rec.on) await load($)
149  else unload()
150  apply()
151  $.ui.invalidate('ui.render')
152}
153
154// /rec config: make the file the first time, then say where it is and what goes in it
155async function configText($) {
156  const path = home + CONFIG_PATH
157  let made = false
158  try {
159    if (!(await $.fs.exists(path))) {
160      await $.fs.write(path, JSON.stringify(CONFIG_TEMPLATE, null, 2) + '\n')
161      made = true
162    }
163  } catch {
164    return `Could not write ${path}. Make it by hand with this shape:\n${JSON.stringify(CONFIG_TEMPLATE, null, 2)}`
165  }
166  await loadConfig($)
167  apply()
168  return [
169    `${made ? 'Made' : 'Your'} recording config: ${path}`,
170    made ? 'It holds example values. Replace them with your own, save, and run /rec again.' : `Loaded: ${config.keepNames.length} kept name(s), ${config.names.length} hidden name(s), ${config.privatePaths.length} private path(s).`,
171    ...CONFIG_HELP,
172  ].join('\n')
173}
174
175async function registerCommand($) {
176  const spec = { name: 'rec', description: 'Recording mode: hide keys, personal details, business figures, and private files on screen (/rec strict, /rec off, /rec config)', argumentHint: '[strict|off|config]', immediate: true }
177  try {
178    await $.command.register(spec)
179    return 'rec'
180  } catch {
181    try {
182      await $.command.register({ ...spec, name: 'recording' })
183      return 'recording'
184    } catch {
185      return null
186    }
187  }
188}
189
190export function register(on) {
191  on('session.start', async ($, e, next) => {
192    home = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || ''
193    cwd = await $.session.cwd()
194    commandName = (await registerCommand($)) || commandName
195    await loadConfig($)
196    await syncFlag($)
197    $.clock.every(3000, () => syncFlag($).catch(() => {}))
198    return next(e)
199  })
200
201  on('command.run', { command: ['rec', 'recording'] }, async ($, e) => {
202    const arg = String(e.args || '').trim().toLowerCase()
203    if (arg === 'config') return { text: await configText($) }
204    if (arg === 'off' || (arg === '' && rec.on)) {
205      await setFlag($, false, false)
206      $.ui.toast('Recording mode off.' + (blocked ? ` It kept ${blocked} private file${blocked === 1 ? '' : 's'} closed.` : ''))
207      blocked = 0
208      return {}
209    }
210    const strict = arg === 'strict'
211    await setFlag($, true, strict)
212    $.ui.toast(`Recording mode on${strict ? ' (strict)' : ''}.`, { timeoutMs: 3000 })
213    return {}
214  })
215
216  // Claude reads a note beside each prompt while recording, and one more when it stops
217  on('prompt.submit', async ($, e, next) => {
218    const note = rec.on ? noteOn() : noteSent ? NOTE_OFF : null
219    if (!note) return next(e)
220    noteSent = rec.on
221    return next({ ...e, context: [...(e.context ?? []), note] })
222  })
223
224  // Private files and finance tools stay closed while recording
225  on('tool.call', async ($, e, next) => {
226    if (!rec.on) return next(e)
227    const hit = financeTool(e.tool, config.closedTools) ? 'finance tools' : privatePathHit(toolCallTargets(e).join('\n'), config.privatePaths)
228    if (!hit) {
229      if (businessSource(e.tool, e, config.businessTools)) businessCalls.add(e.tool_use_id)
230      return next(e)
231    }
232    blocked += 1
233    return {
234      deny: `Recording mode is on, so "${hit}" stays closed while the screen is being recorded. Continue without it, or ask the user to run /${commandName} off first.`,
235    }
236  })
237
238  // Text rows: prompts, replies, command output
239  on('ui.render', { component: ['UserMessage', 'AssistantMessage', 'CommandOutput'] }, async ($, e, next) => {
240    if (!rec.on || typeof e.props.text !== 'string') return next(e)
241    return next({ ...e, props: { ...e.props, text: mask(e.props.text) } })
242  })
243
244  // Tool rows: the call's input is masked; a business tool's result is hidden whole
245  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
246    if (!rec.on) return next(e)
247    const business = businessSource(e.props.tool, e.props.input, config.businessTools) || businessCalls.has(e.props.tool_use_id)
248    if (business) businessCalls.add(e.props.tool_use_id)
249    const props = { ...e.props, input: deepMask(e.props.input, mask) }
250    if (e.props.output !== undefined) props.output = deepMask(e.props.output, business ? hideText : mask)
251    return next({ ...e, props })
252  })
253
254  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
255    if (!rec.on) return next(e)
256    const business = businessSource(e.props.tool, null, config.businessTools) || businessCalls.has(e.props.tool_use_id)
257    return next({ ...e, props: { ...e.props, output: deepMask(e.props.output, business ? hideText : mask) } })
258  })
259
260  // Just the indicator: a pulsing red dot and REC
261  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
262    const below = await next(e)
263    if (e.props && e.props.hasSurvey) return below
264    if (!rec.on) return below
265    const el = $.ui.resolve(e)
266    const line = e.surface === 'terminal' || !el.Svg
267      ? el.Text({ color: 'red', bold: true, children: ['● REC'] })
268      : el.Svg({ source: REC_SVG, alt: 'REC', width: 54, height: 18 })
269    return el.Box({ flexDirection: 'column', children: below ? [line, below] : [line] })
270  })
271
272  // The footer label shows even when the band is collapsed
273  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
274    if (!rec.on) return next(e)
275    const modes = Array.isArray(e.props && e.props.modes) ? e.props.modes : []
276    return next({ ...e, props: { ...e.props, modes: ['● REC', ...modes] } })
277  })
278}
279
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