Spots sessions worth remembering, nudges once to run /memorize, and queues the ones that ended without it for the next session. Files into your wiki if you…

Four Claude Code mods, published as a plugin marketplace named claude-super-mods.
| Mod | What you get | ||||
|---|---|---|---|---|---|
| goal-meter 1.2.1 | A progress bar for /goal, built from Claude's own task plan. One line by default. /goals opens the full task list. | ||||
| cache-keeper 1.3.0 | A band above the prompt with the model, effort, context bar, plan limits (with the 5h reset) and cache state, so no status line is needed. Keeps a long chat's prompt cache warm (/keepwarm 2), asks before an expensive cold send, shows every local chat (/board), and hands a chat off (/handoff). Every compaction without its own instructions is told what to keep, and the band shows how much it saved. | ||||
| lean-explore 1.6.0 | Replaces the built-in Explore subagent (the one that does codebase searches) with a lean one: Haiku, low effort, Read/Grep/Glob only, short answers with file:line. It counts what it really did (searches, reads, tokens delegated to Haiku) from your own transcripts. `/lean-explore on\ | off\ | status\ | stats\ | rescan`. |
| memory-guard 0.2.2 | Spots sessions worth remembering (files written to output folders, MCP writes, meeting transcripts, decisions, commits, accepted plans, long chats) and nudges once to run /memorize. A session that ends without it is queued, and the next session in that project offers to catch up. Files into your wiki if you point it at one, else Claude Code's own auto-memory. Ships its own /memorize skill. |
A plugin cannot replace a built-in agent with an agent of its own (plugin agents get a lean-explore: prefix). So on the first session the mod writes ~/.claude/agents/Explore.md itself, marked with a comment line so it never touches an Explore.md you wrote.
Explore: Haiku · 6 searches counts this chat's Explore runs; hover it for a card with a Turn off button. Amber Explore: built-in means it is off, with a Turn on button. After a toggle it adds what the next session will use. It shares one row with the TL;DR button (the mods-row convention), chip on the left.Explore: Haiku or Explore: built-in, with the same next-session note./lean-explore stats prints all-time searches, reads and tokens delegated to Haiku (measured from usage figures; the "kept out of chat" number is an estimate at 4 characters per token). /lean-explore rescan rebuilds the totals from your transcripts./lean-explore status shows whether it is on, off, or blocked by your own Explore.md./lean-explore off renames it to Explore.md.off; the built-in Explore comes back./lean-explore on brings it back./lean-explore off, or delete ~/.claude/agents/Explore.md. Uninstalling the mod does not remove the file.When a search needs judgment (a review, a design call), ask for a general-purpose agent or pass model: "opus": Haiku finds things well but checks less of what it reads.
Each session scores a point per kind of durable work: a file written to an output folder (docs, reports, deliverables, notes), a write through any MCP tool, a meeting or transcript read, a decision in your prompt ("let's go with", "from now on"), a git commit, an accepted plan. Ten turns or more adds one. At 2 points it nudges once: a toast, a band above the prompt with Memorize now and Not this session, and /memorize as the prompt suggestion. A session that ends without /memorize goes on that project's queue. The next session there shows Catch up, and the hint line says · 3 unmemorized.
With no setup it files into Claude Code's auto-memory (~/.claude/projects/<project>/memory/ and its MEMORY.md). To use a wiki, or your own /memorize skill, add .claude/memory-guard.json to the project (or ~/.claude/memory-guard.json for all of them). Every field is optional:
{
"kb": { "path": "wiki", "index": "wiki/index.md", "log": "wiki/log.md", "rules": "wiki/CLAUDE.md" },
"skill": "memorize",
"threshold": 2,
"longSessionTurns": 10,
"outputDirs": ["docs", "reports", "deliverables", "notes"],
"signals": { "decision": false },
"extraSignals": [{ "name": "Tickets", "tool": "^mcp__.*linear.*__(create|update)" }, { "name": "Release", "bash": "npm publish" }],
"ui": { "band": true, "hint": true, "suggest": true }
}
kb: your knowledge base. The bundled skill reads its rules file and index, files pages there, and logs each one. A write to kb.log counts as memorized.skill: the skill the buttons run. Leave it out to use the bundled memory-guard:memorize.extraSignals: your own signals, by tool-name or Bash-command regex.On desktop a chip above the prompt shows Memory guard: on · 1 of 2 (green) or off (grey); hover it for a Turn off here / Turn on here button. The terminal footer says memory guard on or off. "ui": { "chip": false } hides both.
If it finds a wiki/ folder or an Obsidian vault with no config, it says so once. The band and hint line show in the terminal and the desktop app; elsewhere the toast and /memorize-queue still work.
goal-meter and cache-keeper are built on Nate Herk's claude-code-mods (MIT). goal-meter is a fork of his goal-meter 1.0.0, and cache-keeper is based on his cache-keeper 1.0.0. Each mod's CHANGES.md lists what differs from upstream. cache-keeper 1.3.0 also borrows three ideas from JayGPTPro's Smart PreCompact. MIT, see LICENSE.
Needs a Claude Code build with mods (function-hook plugins). Works on Windows, macOS and Linux.
claude plugin marketplace add bStyler/claude-mods
claude plugin install goal-meter@claude-super-mods
claude plugin install cache-keeper@claude-super-mods
claude plugin install lean-explore@claude-super-mods
claude plugin install memory-guard@claude-super-mods
Then quit Claude Code and start it again. /reload-plugins and /clear do not load mod code. Check: type /goals. It should open the goal meter pane.
If Nate Herk's nateherk-mods is installed on that machine, turn off its copies first (same names, same tools):
claude plugin disable goal-meter@nateherk-mods
claude plugin disable cache-keeper@nateherk-mods
cache-keeper's band replaces a custom status line. If you had one, remove the statusLine block from ~/.claude/settings.json so the same figures don't show twice.
goal-meter
| Command | Does |
|---|---|
/goal <what done looks like> | Starts a goal. Claude plans it as sized tasks and the bar tracks them. |
/goals | Full task list, plus other chats' goals from the last 12 hours. |
/goals compact / /goals full | One-line bar (default) or the full bar. Saved for every chat. |
/goals hide / /goals show | Hide the bar and footer label in every chat, or bring them back. |
/goals clear | Remove this chat's goal, running or finished. |
/goals strict on | No file edits in a /goal until Claude has written the plan. Useful for coding. |
The g key on the bar only works after Ctrl+X Tab focuses the bar. From the prompt, type /goals.
cache-keeper
| Command | Does |
|---|---|
/keepwarm 2 / /keepwarm off | Keep this chat's cache warm for N hours (max 24). |
/cache big 100k | Size at which it asks before a cold send (default 150k). |
/cache guard off | Turn the cold-send question off. |
/cache ttl 5 / 60 / auto | Cache window, if it guesses wrong. |
/board | Every local chat on one board. |
/handoff | Write a handoff and continue in a fresh chat. |
memory-guard
| Command | Does |
|---|---|
/memorize | Save what this session learned (bundled skill: memory-guard:memorize). |
/memorize queue | Catch up on the queued sessions from their transcripts. |
/memorize-queue / /memorize-queue clear | Show this session's score and the queue, or clear it. |
/memory-guard off / on / status | Stop or start watching this project; show where it files and what it counts. |
claude plugin marketplace update claude-super-mods
claude plugin update goal-meter@claude-super-mods
claude plugin update cache-keeper@claude-super-mods
claude plugin update lean-explore@claude-super-mods
claude plugin update memory-guard@claude-super-mods
Restart Claude Code afterwards. When changing code, raise version in the mod's .claude-plugin/plugin.json, or plugin update will not pick it up.
claude plugin uninstall goal-meter@claude-super-mods
claude plugin uninstall cache-keeper@claude-super-mods
claude plugin uninstall lean-explore@claude-super-mods
claude plugin uninstall memory-guard@claude-super-mods
claude plugin marketplace remove claude-super-mods
Saved state lives in ~/.claude/mods-data/ and can be deleted.
hooks/register.mjs 549 lines1// Memory Guard: spots sessions worth remembering and makes sure they get /memorize.
2// - A session scores a point per distinct signal: a file written to an output folder,
3// a write through an MCP tool, a meeting transcript read, a decision in a prompt, a
4// git commit, an accepted plan, any extraSignals of your own. A long session adds one.
5// - At the threshold, a session that never ran /memorize gets one nudge: a toast, a band
6// with "Memorize now" and "Not this session", and /memorize as the prompt suggestion.
7// - A session that ends that way joins this project's queue. The next session here is
8// told about it and offers "Catch up"; the hint line shows "· N unmemorized".
9// - Config: .claude/memory-guard.json in the project, ~/.claude/memory-guard.json for
10// defaults. "kb" points it at a wiki, "skill" hands /memorize to a skill of your own.
11// - Shows whether it is on: a chip above the prompt on desktop (hover for a Turn off /
12// Turn on button), a footer label in the terminal.
13// - Commands: /memorize-queue [clear], /memory-guard on|off|status.
14// - The mod sends no model requests of its own.
15
16import { makeMasker } from './privacy.mjs'
17
18const DEFAULTS = {
19 enabled: true,
20 threshold: 2,
21 longSessionTurns: 10,
22 kb: null,
23 skill: '',
24 outputDirs: ['docs', 'reports', 'deliverables', 'notes'],
25 signals: { outputFile: true, mcpWrite: true, transcriptRead: true, decision: true, commit: true, planAccepted: true, longSession: true },
26 extraSignals: [],
27 ui: { band: true, hint: true, suggest: true, chip: true },
28}
29const BUNDLED_SKILL = 'memory-guard:memorize'
30const CHIP_SCOPE = 'memory-guard-chip'
31const QUEUE_MAX = 20
32const WRITERS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit'])
33const MCP_WRITE = /(^|[_-])(create|update|add|send|post|publish|insert|append|move|delete|comment|write|edit|upsert|set|save)([_-]|$)/i
34const TRANSCRIPT = /meeting|transcript|recording/i
35const DECISION = /\b(let'?s go with|we('ve| have)? decided|decided to|from now on|going forward|always|never|remember (that|this)|that'?s wrong|don'?t ever)\b/i
36const LABELS = {
37 outputFile: 'an output file',
38 mcpWrite: 'an MCP write',
39 transcriptRead: 'a meeting transcript',
40 decision: 'a decision',
41 commit: 'a commit',
42 planAccepted: 'an accepted plan',
43}
44
45let home = ''
46let cwd = ''
47let project = ''
48let cfg = DEFAULTS
49let extra = []
50let override // true or false once /memory-guard on|off ran in this project, over the config
51let memDir = ''
52let ready = false
53let cur = null // this session's record, as last loaded or saved
54let queueCount = 0
55let startQueue = [] // the queue as this session found it, for the band
56let bandHidden = false
57let mask = (s) => s
58
59const n = (p) => String(p ?? '').replace(/\\/g, '/')
60const lower = (p) => n(p).toLowerCase()
61const isAbs = (p) => /^([A-Za-z]:)?\//.test(n(p))
62const resolve = (p) => (isAbs(p) ? n(p) : `${cwd}/${n(p).replace(/^\.\//, '')}`)
63const watched = () => (override === undefined ? cfg.enabled !== false : override)
64const skillName = () => String(cfg.skill || '').replace(/^\//, '') || BUNDLED_SKILL
65const label = (id) => LABELS[id] || id
66
67function merge(base, over) {
68 if (!over || typeof over !== 'object') return base
69 const out = { ...base, ...over }
70 for (const k of ['signals', 'ui']) out[k] = { ...base[k], ...(over[k] || {}) }
71 return out
72}
73
74function normalizeKb(kb) {
75 if (!kb) return null
76 if (typeof kb === 'string') return { path: kb }
77 return kb.path ? kb : null
78}
79
80async function readJson($, path) {
81 try {
82 if (!(await $.fs.exists(path))) return null
83 return JSON.parse(await $.fs.read(path))
84 } catch {
85 $.ui.log(`memory-guard: could not read ${path}, using defaults`, { to: 'debug' })
86 return null
87 }
88}
89
90async function readRecording($) {
91 const flag = await readJson($, `${home || '.'}/.claude/mods-data/recording.json`)
92 const on = !!(flag && flag.on)
93 mask = on ? makeMasker({ strict: !!flag.strict }) : (s) => s
94}
95
96function compileExtra(list, $) {
97 const out = []
98 for (const sig of Array.isArray(list) ? list : []) {
99 if (!sig || !sig.name) continue
100 try {
101 out.push({ name: String(sig.name), tool: sig.tool ? new RegExp(sig.tool) : null, bash: sig.bash ? new RegExp(sig.bash) : null })
102 } catch {
103 $.ui.log(`memory-guard: extraSignals "${sig.name}" has a bad pattern, skipped`)
104 }
105 }
106 return out
107}
108
109async function ensure($) {
110 const dir = n(await $.session.cwd()).replace(/\/+$/, '')
111 if (ready && dir === cwd) return
112 cwd = dir
113 project = lower(dir)
114 home = n((await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || '')
115 const user = await readJson($, `${home}/.claude/memory-guard.json`)
116 const local = await readJson($, `${cwd}/.claude/memory-guard.json`)
117 cfg = merge(merge(DEFAULTS, user), local)
118 cfg.kb = normalizeKb(cfg.kb)
119 extra = compileExtra(cfg.extraSignals, $)
120 const saved = await $.store.get(`off:${project}`)
121 override = typeof saved === 'boolean' ? !saved : undefined
122 // Claude Code keeps auto-memory beside the transcripts: projects/<cwd with every
123 // other character as "-">/memory. The transcript path, once seen, wins.
124 if (!memDir) memDir = `${home}/.claude/projects/${cwd.replace(/[^A-Za-z0-9]/g, '-')}/memory`
125 queueCount = (await readQueue($, project)).filter((q) => !cur || q.sessionId !== cur.id).length
126 await readRecording($)
127 ready = true
128}
129
130// ---------- the session record ----------
131
132function score(s) {
133 return s.signals.length + (cfg.signals.longSession && s.turns >= cfg.longSessionTurns ? 1 : 0)
134}
135
136async function load($) {
137 const id = await $.session.id()
138 if (cur && cur.id === id) return cur
139 cur = ((await $.store.get(`session:${id}`)) ?? {
140 id, signals: [], turns: 0, memorized: false, nudged: false, dismissed: false,
141 topic: '', transcript: '', project, startedAt: new Date().toISOString(),
142 })
143 return cur
144}
145
146async function save($, s) {
147 s.important = score(s) >= cfg.threshold
148 cur = s
149 await $.store.set(`session:${s.id}`, s)
150 $.ui.invalidate('ui.render')
151}
152
153async function readQueue($, key) {
154 return ((await $.store.get(`queue:${key}`)) ?? [])
155}
156
157async function clearQueue($) {
158 await $.store.set(`queue:${project}`, [])
159 queueCount = 0
160 startQueue = []
161}
162
163// ---------- what counts ----------
164
165function signalsFor(tool, e) {
166 const on = cfg.signals
167 const hits = []
168 if (on.outputFile && WRITERS.has(tool)) {
169 const path = lower(e.file_path ?? e.notebook_path)
170 const dirs = (cfg.outputDirs || []).map((d) => lower(d).replace(/^\.?\/|\/$/g, '')).filter(Boolean)
171 if (dirs.some((d) => path.includes(`/${d}/`) || path.startsWith(`${d}/`))) hits.push('outputFile')
172 }
173 if (tool.startsWith('mcp__')) {
174 const action = tool.slice(tool.lastIndexOf('__') + 2)
175 if (on.mcpWrite && MCP_WRITE.test(action)) hits.push('mcpWrite')
176 if (on.transcriptRead && TRANSCRIPT.test(action)) hits.push('transcriptRead')
177 }
178 if (on.commit && tool === 'Bash' && /\bgit\s+commit\b/.test(String(e.command ?? ''))) hits.push('commit')
179 if (on.planAccepted && tool === 'ExitPlanMode') hits.push('planAccepted')
180 for (const sig of extra) {
181 if ((sig.tool && sig.tool.test(tool)) || (sig.bash && tool === 'Bash' && sig.bash.test(String(e.command ?? '')))) hits.push(sig.name)
182 }
183 return hits
184}
185
186function isMemorizeName(name) {
187 const bare = String(name ?? '').replace(/^\//, '')
188 return bare === skillName() || bare === BUNDLED_SKILL || bare === 'memorize'
189}
190
191function isMemoryPath(path) {
192 const p = lower(path)
193 if (!p) return false
194 if (memDir && p.startsWith(lower(memDir) + '/')) return true
195 if (p.endsWith('/memory.md')) return true
196 return !!(cfg.kb && cfg.kb.log && p === lower(resolve(cfg.kb.log)))
197}
198
199// undefined: not a memorize; otherwise whether it covers the whole queue
200function memorizeCall(tool, e) {
201 if (tool === 'Skill' && isMemorizeName(e.skill)) return /^\s*queue\b/.test(String(e.args ?? ''))
202 if (WRITERS.has(tool) && isMemoryPath(e.file_path)) return false
203 if (tool === 'Bash') {
204 const cmd = String(e.command ?? '')
205 const target = cfg.kb && cfg.kb.log ? n(cfg.kb.log) : ''
206 if (/>>|\btee\b/.test(cmd) && (/MEMORY\.md/.test(cmd) || (target && n(cmd).includes(target)))) return false
207 }
208 return undefined
209}
210
211async function markMemorized($, s, coversQueue) {
212 s.memorized = true
213 if (coversQueue) await clearQueue($)
214 await save($, s)
215 $.ui.invalidate('ui.render')
216}
217
218// ---------- commands ----------
219
220async function queueText($) {
221 const s = await load($)
222 const queue = await readQueue($, project)
223 const head = `This session: score ${score(s)} of ${cfg.threshold} (${s.signals.map(label).join(', ') || 'no signals'}), memorized: ${s.memorized ? 'yes' : 'no'}.`
224 if (queue.length === 0) return `${head}\nQueue is empty.`
225 return [head, 'Waiting for /memorize:', ...queue.map((q) => `- ${q.startedAt.slice(0, 10)} ${q.topic || 'untitled'} (${q.signals.map(label).join(', ')})`)].join('\n')
226}
227
228async function statusText($) {
229 const s = await load($)
230 return [
231 `memory-guard is ${watched() ? 'on' : 'off'} in this project${override === undefined && cfg.enabled === false ? ' (disabled in config)' : ''}.`,
232 `Files into: ${cfg.kb ? `the knowledge base in ${cfg.kb.path}/, plus auto-memory` : 'auto-memory'} (${memDir}).`,
233 `/memorize runs: /${skillName()}.`,
234 `Threshold ${cfg.threshold}; this session scores ${score(s)} (${s.signals.map(label).join(', ') || 'no signals'}, ${s.turns} turns).`,
235 `Queue: ${queueCount} session(s).`,
236 ].join('\n')
237}
238
239async function turn($, want) {
240 const on = want === 'on'
241 override = on
242 await $.store.set(`off:${project}`, !on)
243 if (!on && cur) {
244 await $.store.delete(`session:${cur.id}`)
245 cur = null
246 }
247 $.ui.invalidate('ui.render')
248 return `memory-guard: ${on ? 'on' : 'off'} in this project.`
249}
250
251// ---------- drawing ----------
252
253// The desktop chip, always one row: green while it watches this project, grey when off.
254// Hovering shows a card with the score and a Turn off / Turn on button.
255function drawChip($, el) {
256 const { Box, Text, Button } = el
257 const on = watched()
258 const s = cur
259 const words = [`Memory guard: ${on ? 'on' : 'off'}`]
260 if (on && s) words.push(s.memorized ? 'saved' : `${score(s)} of ${cfg.threshold}`)
261 if (on && queueCount) words.push(`${queueCount} unmemorized`)
262 const chip = Text({ children: [Text({ color: on ? 'success' : 'inactive', children: ['●'] }), ` ${words.join(' · ')}`] })
263 const card = [Text({ bold: true, children: [on ? 'Watching this session for work worth remembering' : 'Not watching this project'] })]
264 if (on) card.push(Text({ children: [`It nudges once at ${cfg.threshold} points${s && s.signals.length ? `; so far: ${s.signals.map(label).join(', ')}` : ''}. /${skillName()} saves now.`] }))
265 card.push(Button({ key: 'memory-guard-toggle', label: on ? 'Turn off here' : 'Turn on here', plain: true, onPress: async () => { $.ui.toast(await turn($, on ? 'off' : 'on')) } }))
266 // A named hover scope, so the card opens for this chip alone, even on another mod's row.
267 const hoverCard = Box({ display: 'none', hover: { display: 'flex', scope: CHIP_SCOPE }, flexDirection: 'column', rowGap: 1, padding: 'sm', borderStyle: 'round', children: card })
268 return Box({ key: 'memory-guard-chip', flexDirection: 'column', hover: { scope: CHIP_SCOPE }, children: [chip, hoverCard] })
269}
270
271// The band row several mods share (`mods-row`, drawn by whichever draws first): lean-explore's
272// chip first, this one right after it, other mods' buttons at the right end. Drawings
273// beneath come from next(e) as plain { type, props, children } nodes.
274const SHARED_ROW = 'mods-row'
275
276function findKeyed(node, key) {
277 if (!node || typeof node !== 'object') return null
278 if (node.props && node.props.key === key) return node
279 for (const child of node.children ?? []) {
280 const hit = findKeyed(child, key)
281 if (hit) return hit
282 }
283 return null
284}
285
286function swapNode(node, target, next) {
287 if (node === target) return next
288 if (!node || typeof node !== 'object' || !node.children) return node
289 let changed = false
290 const children = node.children.map((child) => {
291 const out = swapNode(child, target, next)
292 if (out !== child) changed = true
293 return out
294 })
295 return changed ? { ...node, children } : node
296}
297
298function drawBand($, el) {
299 const { Box, Text, Button } = el
300 const s = cur
301 if (s && s.nudged && !s.memorized && !s.dismissed) {
302 return Box({
303 flexDirection: 'row', columnGap: 2, flexWrap: 'wrap',
304 children: [
305 Text({ children: [`memory-guard: this session has ${s.signals.map(label).join(', ') || 'a lot of work'}. Save what it learned?`] }),
306 Button({ key: 'memorize', label: 'Memorize now', onPress: () => runSkill($, '') }),
307 Button({ key: 'dismiss', label: 'Not this session', onPress: async () => {
308 const r = await load($)
309 r.dismissed = true
310 await save($, r)
311 $.ui.invalidate('ui.render')
312 } }),
313 ],
314 })
315 }
316 if (!bandHidden && startQueue.length && queueCount > 0) {
317 const rows = startQueue.slice(-3).map((q) => Text({ dimColor: true, wrap: 'truncate-end', children: [` ${q.startedAt.slice(0, 10)} ${mask(q.topic || 'untitled')}`] }))
318 return Box({
319 flexDirection: 'column',
320 children: [
321 Text({ children: [`memory-guard: ${queueCount} earlier session(s) here ended without /memorize.`] }),
322 ...rows,
323 Box({
324 flexDirection: 'row', columnGap: 2,
325 children: [
326 Button({ key: 'catchup', label: 'Catch up', onPress: () => runSkill($, 'queue') }),
327 Button({ key: 'clear', label: 'Clear', onPress: async () => { await clearQueue($); $.ui.invalidate('ui.render') } }),
328 Button({ key: 'hide', label: 'Hide', onPress: () => { bandHidden = true; $.ui.invalidate('ui.render') } }),
329 ],
330 }),
331 ],
332 })
333 }
334 return null
335}
336
337// The hooks on the path of a prompt, a tool call or a session start log a failure of the
338// mod's own and let the event go on as if the mod were absent.
339function logged($, where, err, value) {
340 $.ui.log(`memory-guard: ${where}: ${err && err.message ? err.message : err}`, { to: 'debug' })
341 return value
342}
343
344// The mod's own command.run skips its own hooks, so the press marks the session itself.
345async function runSkill($, args) {
346 await $.command.run({ command: skillName(), args })
347 await onMemorizeCommand($, { args })
348}
349
350async function onMemorizeCommand($, e) {
351 await ensure($)
352 if (!watched()) return
353 await markMemorized($, await load($), /^\s*queue\b/.test(String(e.args ?? '')))
354}
355
356// ---------- hooks ----------
357
358export const register = (on) => {
359 on('session.start', async ($, e, next) => {
360 const result = await next(e)
361 await $.command.register({ name: 'memorize-queue', description: 'Memory Guard: show or clear the sessions waiting for /memorize', argumentHint: '[clear]' })
362 await $.command.register({ name: 'memory-guard', description: 'Memory Guard: turn it on or off in this project, or show its status', argumentHint: 'on|off|status' })
363 return result
364 })
365
366 on('classic.SessionStart', async ($, e, next) => {
367 const result = await next(e)
368 return onSessionStart($, e, result).catch((err) => logged($, 'session start', err, result))
369 })
370
371 on('prompt.submit', async ($, e, next) => {
372 await onPrompt($, e).catch((err) => logged($, 'prompt', err))
373 return next(e)
374 })
375
376 on('tool.call', async ($, e, next) => {
377 const ran = await next(e)
378 if (ran?.deny !== undefined || ran?.isError === true) return ran
379 await onTool($, e).catch((err) => logged($, 'tool call', err))
380 return ran
381 })
382
383 // A skill run as a command, typed or pressed, may raise no prompt.submit or Skill call.
384 on('command.run', async ($, e, next) => {
385 if (isMemorizeName(e.command)) await onMemorizeCommand($, e).catch((err) => logged($, 'command', err))
386 return next(e)
387 })
388
389 on('turn.complete', async ($, e, next) => {
390 if (e.agentId) return next(e)
391 await ensure($)
392 if (!watched()) return next(e)
393 const s = await load($)
394 s.turns += 1
395 if (!s.memorized && !s.nudged && !s.dismissed && score(s) >= cfg.threshold) {
396 s.nudged = true
397 const what = s.signals.map(label).join(', ') || 'a long session'
398 $.ui.toast(`This session has ${what} - run /${skillName()} before you close.`)
399 if (cfg.ui.suggest) void Promise.resolve($.prompt.suggest({ text: `/${skillName()}` })).catch(() => {})
400 $.ui.invalidate('ui.render')
401 }
402 await save($, s)
403 return next(e)
404 })
405
406 on('session.end', async ($, e, next) => {
407 const k = `session:${e.sessionId}`
408 const s = await $.store.get(k)
409 if (s && s.important && !s.memorized && !s.dismissed && s.project) {
410 const queue = (await readQueue($, s.project)).filter((q) => q.sessionId !== e.sessionId)
411 queue.push({ sessionId: e.sessionId, startedAt: s.startedAt, topic: s.topic, signals: s.signals, transcript: s.transcript })
412 await $.store.set(`queue:${s.project}`, queue.slice(-QUEUE_MAX))
413 }
414 await $.store.delete(k)
415 return next(e)
416 })
417
418 on('command.run', { command: 'memorize-queue' }, async ($, e) => {
419 await ensure($)
420 if (String(e.args ?? '').trim() === 'clear') {
421 await clearQueue($)
422 $.ui.invalidate('ui.render')
423 return { text: 'memory-guard: queue cleared.' }
424 }
425 return { text: await queueText($) }
426 })
427
428 on('command.run', { command: 'memory-guard' }, async ($, e) => {
429 await ensure($)
430 const arg = String(e.args ?? '').trim().toLowerCase()
431 if (arg === 'off' || arg === 'on') {
432 if (arg === 'off') await load($)
433 return { text: await turn($, arg) }
434 }
435 return { text: await statusText($) }
436 })
437
438 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
439 const below = await next(e)
440 if (e.props && e.props.hasSurvey) return below
441 await ensure($)
442 const el = $.ui.resolve(e)
443 const desktop = e.surface === 'desktop'
444 const rows = []
445 let rest = below
446 if (desktop && cfg.ui.chip) {
447 const chip = drawChip($, el)
448 const shared = findKeyed(below, SHARED_ROW)
449 if (shared) {
450 const kids = shared.children ?? []
451 const at = kids.findIndex((k) => k && k.props && k.props.key === 'lean-explore') + 1
452 rest = swapNode(below, shared, { ...shared, children: [...kids.slice(0, at), chip, ...kids.slice(at)] })
453 } else {
454 // marginTop: the same gap the other rows in the band keep from Token Optimizer's card
455 rows.push(el.Box({ key: SHARED_ROW, flexDirection: 'row', alignItems: 'flex-start', columnGap: 2, marginTop: 'md', children: [chip, el.Box({ flexGrow: 1 })] }))
456 }
457 }
458 const drawn = watched() && cfg.ui.band ? drawBand($, el) : null
459 if (drawn) rows.push(desktop ? el.Box({ key: 'memory-guard', marginTop: 'md', children: [drawn] }) : drawn)
460 if (!rows.length) return rest
461 return el.Box({ flexDirection: 'column', children: rest ? [...rows, rest] : rows })
462 })
463
464 // The terminal gets a short footer label; on desktop the chip says it.
465 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
466 if (e.surface === 'desktop') return next(e)
467 await ensure($)
468 if (!cfg.ui.chip) return next(e)
469 const modes = Array.isArray(e.props && e.props.modes) ? e.props.modes : []
470 return next({ ...e, props: { ...e.props, modes: [...modes, `memory guard ${watched() ? 'on' : 'off'}`] } })
471 })
472
473 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
474 await ensure($)
475 if (!watched() || !cfg.ui.hint || queueCount === 0) return next(e)
476 const tail = `· ${queueCount} unmemorized`
477 const props = e.props || {}
478 return next({ ...e, props: { ...props, tail: props.tail ? `${props.tail} ${tail}` : tail } })
479 })
480}
481
482// Says once per project when a likely knowledge base sits here unconfigured.
483async function kbHint($) {
484 if (cfg.kb || (await $.store.get(`kbhint:${project}`))) return
485 let found = ''
486 if ((await $.fs.exists(`${cwd}/wiki/log.md`)) || (await $.fs.exists(`${cwd}/wiki/index.md`))) found = 'a wiki in wiki/'
487 else if (await $.fs.exists(`${cwd}/.obsidian`)) found = 'an Obsidian vault here'
488 if (!found) return
489 await $.store.set(`kbhint:${project}`, true)
490 $.ui.toast(`memory-guard: found ${found}. Set "kb" in .claude/memory-guard.json so /memorize files into it.`, { timeoutMs: 8000 })
491}
492
493async function onSessionStart($, e, result) {
494 await ensure($)
495 if (e.transcript_path) memDir = n(e.transcript_path).replace(/\/[^/]*$/, '') + '/memory'
496 if (!watched()) return result
497
498 const s = await load($)
499 if (e.transcript_path && !s.transcript) {
500 s.transcript = n(e.transcript_path)
501 await save($, s)
502 }
503 await kbHint($)
504
505 const queue = (await readQueue($, project)).filter((q) => q.sessionId !== s.id)
506 queueCount = queue.length
507 if (queue.length === 0) return result
508 startQueue = queue
509 $.ui.invalidate('ui.render')
510
511 const lines = queue.map((q) =>
512 `- ${q.startedAt.slice(0, 10)} "${q.topic || 'untitled'}" (${q.signals.map(label).join(', ')}) - ${q.transcript ? `transcript ${q.transcript}` : `session ${q.sessionId}`}`)
513 const note = [
514 `memory-guard: ${queue.length} earlier session(s) in this project ended without /memorize:`,
515 ...lines,
516 `Offer the user to run /${skillName()} queue, which files what those sessions learned from their transcripts. The queue clears when it runs.`,
517 ].join('\n')
518 $.ui.toast(`${queue.length} session(s) here not memorized - "Catch up" above the prompt, or ask Claude.`)
519 return { ...result, additionalContext: [...(result?.additionalContext ?? []), note] }
520}
521
522async function onPrompt($, e) {
523 await ensure($)
524 if (!watched()) return
525 const s = await load($)
526 const text = String(e.text ?? '').trim()
527 const typed = text.match(/^\/([\w:.-]+)\s*(.*)$/s)
528 const command = e.command ? e.command.command : typed && typed[1]
529 const args = e.command ? e.command.args : typed ? typed[2] : ''
530 if (!s.topic && text && !command) s.topic = text.replace(/\s+/g, ' ').slice(0, 80)
531 if (command && isMemorizeName(command)) return markMemorized($, s, /^\s*queue\b/.test(String(args ?? '')))
532 if (!command && cfg.signals.decision && DECISION.test(text) && !s.signals.includes('decision')) s.signals.push('decision')
533 await save($, s)
534}
535
536async function onTool($, e) {
537 await ensure($)
538 if (!watched()) return
539 const tool = String(e.tool)
540 const hits = signalsFor(tool, e)
541 const memorized = memorizeCall(tool, e)
542 if (hits.length === 0 && memorized === undefined) return
543
544 const s = await load($)
545 s.signals = [...new Set([...s.signals, ...hits])]
546 if (memorized !== undefined) await markMemorized($, s, memorized)
547 else await save($, s)
548}
549hooks/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