SLOPSHOPPER

memory-guard

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…

newbandspinnerguardcommandtoast
v0.2.2MITupdated 2026-10-22bStyler/claude-mods/memory-guard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · memory-guard
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /memorize-queue ⎿ memory-guard: This session: score 0 of 2 (no signals), memorized: no. ⎿ memory-guard: Queue is empty. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods

Four Claude Code mods, published as a plugin marketplace named claude-super-mods.

ModWhat you get
goal-meter 1.2.1A 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.0A 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.0Replaces 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.2Spots 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.

lean-explore

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.

  • Desktop app: a chip above the prompt. Green 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.
  • Terminal: a footer label, 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.
  • While the lean agent is on, the mod adds a short "Searching the codebase" note to the system prompt: delegate open-ended searches to Explore, read directly when the path is known. No CLAUDE.md edit needed.
  • /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.
  • Agent files load when a session starts, so every change applies from the next session.
  • Before uninstalling, run /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.

memory-guard

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.

Credit

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.

Install

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.

Commands

goal-meter

CommandDoes
/goal <what done looks like>Starts a goal. Claude plans it as sized tasks and the bar tracks them.
/goalsFull task list, plus other chats' goals from the last 12 hours.
/goals compact / /goals fullOne-line bar (default) or the full bar. Saved for every chat.
/goals hide / /goals showHide the bar and footer label in every chat, or bring them back.
/goals clearRemove this chat's goal, running or finished.
/goals strict onNo 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

CommandDoes
/keepwarm 2 / /keepwarm offKeep this chat's cache warm for N hours (max 24).
/cache big 100kSize at which it asks before a cold send (default 150k).
/cache guard offTurn the cold-send question off.
/cache ttl 5 / 60 / autoCache window, if it guesses wrong.
/boardEvery local chat on one board.
/handoffWrite a handoff and continue in a fresh chat.

memory-guard

CommandDoes
/memorizeSave what this session learned (bundled skill: memory-guard:memorize).
/memorize queueCatch up on the queued sessions from their transcripts.
/memorize-queue / /memorize-queue clearShow this session's score and the queue, or clear it.
/memory-guard off / on / statusStop or start watching this project; show where it files and what it counts.

Update

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.

Uninstall

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.

Source 2 files
hooks/register.mjs 549 lines
1// 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}
549
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