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

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.
| Mod | Commands | What it does |
|---|---|---|
| Cache Keeper | /cache, /keepwarm, /board, /handoff | Shows 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 config | Masks keys, personal details, and business figures on screen and keeps private files closed while you record |
| Goal Meter | /goals | A progress bar for /goal built from Claude's own task plan, with an ETA and every chat's goal in one pane |
| Collision Guard | /guard | Asks before Claude edits a file another open chat changed in the last 30 minutes |
claude --version. The mods work in the terminal and in the Desktop app's Code tab.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.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.
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.
● 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./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 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.
Run /rec before you hit record. It applies to every open chat within 3 seconds, and /rec off turns it off everywhere.
.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..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./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": []
}
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.
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.
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.◎ goal 69% · ~17m, visible even when the line above the prompt is hidden./goals: this chat's whole task list, plus every other chat's goal from the last 12 hours./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.
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:
/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.
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.
MIT. See LICENSE.
hooks/register.mjs 279 lines1// 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}
279hooks/privacy.mjs 337 lines1// 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