SLOPSHOPPER

recall

Recall Memory-as-a-Service — persistent memory, semantic search, and context management across Claude Code sessions. Connects to recallmcp.com or self-hosted…

newbandguardnetworktimer
★ 174v1.18.8MITupdated 2026-10-04joseairosa/recall/claude-plugin
A shopper browsing a rack in a slop shop
README

Recall Plugin for Claude Code

Persistent memory, semantic search, and context management across Claude Code sessions.

Installation

From Marketplace

/plugin install recall@marketplace-name

Manual (Local)

claude --plugin-dir /path/to/plugin/recall

Or copy to your plugins directory:

cp -r plugin/recall ~/.claude/plugins/recall

Configuration

Run the setup command after installing the plugin:

/recall:setup

This will prompt for your API key (found at https://recallmcp.com/dashboard/keys), save it to ~/.claude/recall/config.json, and verify the connection.

Alternatively, set your API key as an environment variable:

export RECALL_API_KEY="sk-your-api-key-here"

Optionally set a custom server URL (defaults to https://recallmcp.com):

export RECALL_SERVER_URL="https://your-instance.example.com"

To use a config file other than ~/.claude/recall/config.json, name it in RECALL_CONFIG_FILE. The scripts and the mod both read it. It is a test hook first (pointing a session at a test instance or a local stand-in without touching your own config), and also serves a second account.

What's Included

MCP Server (.mcp.json)

Connects to the Recall MCP server at recallmcp.com (or self-hosted). Provides 21 primary tools:

  • set_workspace, get_workspace -- workspace management
  • store_memory, search_memories, recall_relevant_context -- memory CRUD
  • auto_session_start, summarize_session -- session lifecycle (protocol self-teaching built-in)
  • check_duplicate -- pre-write deduplication check
  • import_conversations -- bulk import from Claude Code, ChatGPT, Slack exports
  • memory_graph -- relationships with temporal validity (valid_from/valid_to, invalidate)
  • workflow, rlm_process -- advanced workflows
  • And more

Claude Code Mod (hooks/register.js, Claude Code 2.1.287 or later)

hooks/hooks.json names register.js under modules, so Claude Code runs it inside its own process. In the terminal and the Desktop app it:

  • draws Recall's row in the band above the prompt: its name in bold, the workspace, then dim detail. The row stands on its own, with or without other plugins' rows: Recall agentspend · 3 saved this session · 1.18 The workspace starts at the same column as the values in other products' rows (2 cells in, then a 9-cell name column). The band keeps one blank row above it, once, whichever products draw in it, and only when it has a row to spare: in a short window the rows come first. Under a survey Recall draws nothing there;
  • records a failing shell command (as observe.sh does) without starting a script for each one;
  • confirms the workspace and runs a Recall call once more when it fails because the session lost its workspace.

A mod's MCP call asks for permission like any other. The mod makes one only for the retry, so it asks for set_workspace the first time a session loses its workspace, unless that is allowed. To allow it, add "mcp__recall-remote__set_workspace" to permissions.allow in your settings.

While it runs it refreshes ~/.claude/recall/mod-heartbeat-<session id> every 15 seconds. observe.sh stands down only while that file is fresh, and takes over again if the mod stops.

Recall shows once: statusline.sh leaves its Recall segment out, from the first render, when Claude Code is 2.1.287 or later, the mods rollout flag Claude Code caches in .claude.json is on, and Claude Code loads a recall plugin at 1.18.0 or later with hooks/register.js (an enabled install, or a folder in CLAUDE_CODE_PLUGIN_DIRS). Otherwise the segment shows as before. A status line set up before the plugin (~/.claude/plugins/recall/scripts/statusline.sh) is kept at the plugin's version by session-start.sh. VS Code's chat panel, claude -p, older Claude Code, --bare and --safe-mode do not run mods, so the scripts work there as before.

Getting it on an existing install (nothing else to install):

claude plugin marketplace update recall-claude-plugin
claude plugin update recall@recall-claude-plugin

Then restart Claude Code, or run /reload-plugins. Claude Code auto-updates only official marketplaces by default; to get new Recall versions on their own, turn auto-update on in /plugin → Marketplaces → recall-claude-plugin → Enable auto-update. Until then, when a newer Recall is out, the row above the prompt says /plugin marketplace update recall-claude-plugin, then /plugin update recall. To check: claude --version is 2.1.287 or later, and /plugin lists recall among the active mods.

Tests: cd plugin/recall && claude plugin test (Claude Code's own test kit, no session or network).

Lifecycle Hooks (hooks/hooks.json)

  • SessionStart — injects relevant memory context at session start
  • PostToolUse, PostToolUseFailure — records a failing Bash command with an output excerpt (stands down while the mod runs). A command that exits non-zero fires PostToolUseFailure. Secrets in the command and its output are replaced with [REDACTED] first (hooks/redact.js, the same list as observe.sh's).
  • PreCompact — saves state marker before context compaction
  • Stop — deregisters session and polls for pending events

RLM Agents (agents/)

  • context-loader — loads large files into RLM for chunk-based processing
  • result-aggregator — aggregates results from RLM processing chains
  • task-decomposer — decomposes complex tasks into RLM-processable chunks

Commands (commands/)

  • /setup — configure your Recall API key and verify the connection
  • /decompose — decompose a large file or task using RLM
  • /load-context — load content into RLM memory for processing
  • /rlm-status — check status of active RLM execution chains

Status Line (scripts/statusline.sh)

Shows memory count and version info, where the mod does not draw (see above). Add to ~/.claude/settings.json manually:

{
  "statusLine": {
    "command": "bash \"~/.claude/plugins/recall/scripts/statusline.sh\"",
    "type": "command",
    "padding": 0
  }
}

Migrating from MCP + Hooks Setup

If you previously used Recall via the install script (scripts/install.sh):

  1. Install this plugin
  2. Remove Recall hooks from ~/.claude/settings.json (SessionStart, PostToolUse, PreCompact, Stop entries referencing recall/hooks/)
  3. Remove the statusLine entry (or update path to plugin location)
  4. Remove ~/.claude/recall/ directory
  5. Set RECALL_API_KEY environment variable

Links

Source 2 files
hooks/register.js 392 lines
1// Recall's Claude Code mod (Recall 1.18.0, Claude Code 2.1.287 or later; older versions do not load it and keep
2// running the scripts in ../scripts unchanged).
3//
4// In Claude Code's own process, it:
5// - records a failing shell command (what scripts/observe.sh records), without starting a process per tool call;
6// - confirms the workspace when a Recall call fails because the session lost it, and runs that call once more;
7// - draws Recall's row in the band above the prompt, beside other mods' rows (scripts/statusline.sh's segment).
8//
9// While the mod runs it refreshes a heartbeat file for its session, ~/.claude/recall/mod-heartbeat-<session id>.
10// observe.sh stands down only while that heartbeat is fresh, so a mod that unloads mid-session (a reload error, a
11// crash, a policy change) leaves it in charge again. statusline.sh decides from what is installed instead (1.18.1):
12// where this mod draws, it leaves the Recall segment out from the first render.
13//
14// The API key is read from ~/.claude/recall/config.json (or RECALL_API_KEY) and only ever goes in the
15// Authorization header: nothing here logs it, draws it or returns it to Claude. Secrets in a failing command or its
16// output are redacted before the memory leaves the machine (./redact.js, the same list as observe.sh's).
17
18import { redact } from './redact.js'
19
20/** Recall's MCP server, as .mcp.json names it. */
21const SERVER = 'recall-remote'
22const DEFAULT_URL = 'https://recallmcp.com'
23/** How often the heartbeat is written and the queue is sent. */
24const TICK_MS = 15_000
25/** How often Recall's recent activity and latest version are read for the band. */
26const STATUS_MS = 30_000
27/** The most failures held while Recall cannot be reached; older ones are dropped first. */
28const QUEUE_MAX = 50
29/** The error a Recall call answers with once its session has lost the workspace (see rules/recall.md). */
30const WORKSPACE_LOST = /WORKSPACE_NOT_CONFIRMED|restored workspace/i
31/** A failing command's output, as observe.sh detects it. */
32const FAILED = /(^error:|npm ERR!|FAILED|command not found|non-zero exit|exit code [1-9])/im
33/** Recall calls that store a memory, counted in the band. */
34const STORES = /__(store_memory|quick_store_decision)$/
35
36// Shared by the hooks below; a module reload starts them again.
37let config
38let workspace
39let version = ''
40let heartbeatFile = ''
41let queue = []
42let stored = 0
43let lastError = ''
44let confirmed = false
45let activity
46let latest = ''
47
48/**
49 * The API key and the server URL: config.json first, as scripts/lib/config.sh reads them. RECALL_CONFIG_FILE names
50 * another config file, as it does for the scripts (a test, or a second account).
51 */
52async function loadConfig($) {
53  const home = await $.env.get('HOME')
54  let file = {}
55  try {
56    file = JSON.parse(await $.fs.read((await $.env.get('RECALL_CONFIG_FILE')) || home + '/.claude/recall/config.json'))
57  } catch {
58    // No config file: the environment may still hold a key.
59  }
60  const apiKey = file.api_key || (await $.env.get('RECALL_API_KEY')) || ''
61  const url = file.server_url || (await $.env.get('RECALL_SERVER_URL')) || DEFAULT_URL
62  return { home, apiKey, url: String(url).replace(/\/+$/, '') }
63}
64
65/** The git remote without a user or token in it, as lib/config.sh sends it. */
66export function cleanRemote(remote) {
67  return remote ? String(remote).replace(/^(https?:\/\/)[^/@]*@/i, '$1') : ''
68}
69
70/** The project the session files under: the repository's main working tree, or the session's root outside git. */
71async function loadWorkspace($) {
72  const repo = await $.session.repo().catch(() => null)
73  if (repo) return { path: repo.root, git_remote: cleanRemote(repo.remote) }
74  return { path: await $.session.root(), git_remote: '' }
75}
76
77/** The memory a finished shell command is worth, by observe.sh's rule: only a failure, with its output. */
78export function observation(e, result) {
79  if (e.tool !== 'Bash' || !e.command || !result || result.deny) return undefined
80  const out = result.result && typeof result.result === 'object' ? result.result.stderr || result.result.stdout || '' : result.text || ''
81  const excerpt = String(out).slice(0, 500)
82  if (!excerpt || !FAILED.test(excerpt)) return undefined
83  // Redacted whole, then cut, so a secret is never split into a piece too short to match.
84  const output = redact(String(out).slice(0, 20000)).slice(0, 300)
85  return { content: '[Bash error] ' + redact(e.command).slice(0, 200) + '\nOutput: ' + output, importance: 6 }
86}
87
88/** Newer by semver numbers, as statusline.sh compares. */
89export function newer(a, b) {
90  const x = String(a).split('.').map((n) => parseInt(n, 10) || 0)
91  const y = String(b).split('.').map((n) => parseInt(n, 10) || 0)
92  for (let i = 0; i < 3; i += 1) if ((x[i] || 0) !== (y[i] || 0)) return (x[i] || 0) > (y[i] || 0)
93  return false
94}
95
96function headers() {
97  return {
98    'Content-Type': 'application/json',
99    Authorization: 'Bearer ' + config.apiKey,
100    'X-Recall-Workspace': workspace ? workspace.path : '',
101    'X-Recall-Git-Remote': workspace ? workspace.git_remote : '',
102  }
103}
104
105/** Send what is queued. Runs on the timer and at session end, never on a tool's path. */
106async function flush($) {
107  if (!config || !config.apiKey || queue.length === 0) return
108  const batch = queue
109  queue = []
110  for (const [i, item] of batch.entries()) {
111    let ok = false
112    try {
113      const response = await $.http.fetch(config.url + '/api/memories', {
114        method: 'POST',
115        headers: headers(),
116        body: JSON.stringify({ content: item.content, context_type: 'information', importance: item.importance, tags: ['auto-hook', 'bash', 'error'], is_global: false }),
117      })
118      ok = response.ok
119      lastError = ok ? '' : 'Recall answered ' + response.status
120    } catch {
121      lastError = 'Recall unreachable'
122    }
123    if (ok) stored += 1
124    else {
125      // Keep this one and the rest for the next round, behind what came since.
126      queue = [...batch.slice(i), ...queue].slice(-QUEUE_MAX)
127      break
128    }
129  }
130  $.ui.invalidate('ui.render')
131}
132
133/** Tell observe.sh and statusline.sh that the mod is running for this session. */
134async function beat($) {
135  if (!heartbeatFile) return
136  await $.fs.write(heartbeatFile, String(Math.floor((await $.clock.now()) / 1000)))
137}
138
139/** Recall's recent activity and latest version, as statusline.sh reads them from /api/status. */
140async function readStatus($) {
141  if (!config || !config.apiKey) return
142  try {
143    const response = await $.http.fetch(config.url + '/api/status', { headers: { Authorization: 'Bearer ' + config.apiKey } })
144    if (!response.ok) return
145    const data = JSON.parse(response.text).data || {}
146    const now = await $.clock.now()
147    activity = data.label && Number.isFinite(Number(data.elapsed_s)) ? { label: String(data.label), at: now - Number(data.elapsed_s) * 1000 } : activity
148    latest = data.latest_version ? String(data.latest_version) : latest
149    $.ui.invalidate('ui.render')
150  } catch {
151    // The band keeps what it last knew.
152  }
153}
154
155/** Confirm this session's workspace with Recall's MCP server. */
156async function confirmWorkspace($) {
157  if (!workspace) workspace = await loadWorkspace($)
158  const r = await $.mcp.call(SERVER, 'set_workspace', workspace)
159  confirmed = !r.isError
160  $.ui.invalidate('ui.render')
161  return confirmed
162}
163
164function ago(ms) {
165  const s = Math.max(0, Math.round(ms / 1000))
166  return s < 2 ? 'just now' : s < 60 ? s + 's ago' : Math.round(s / 60) + 'm ago'
167}
168
169// The band's ledger layout, shared with FlockTab's and Foley's rows (design "A · Ledger", picked 2026-10-03): one row
170// per product, the name in a fixed column, then the product's key value, then dim detail joined by " · ".
171/** The name column, in terminal cells, so every product's key value lines up down the band. */
172const NAME_COLUMNS = 9
173/** Recall's colour for its name in the band, from the shared design. */
174const RECALL_COLOUR = '#9db8f2'
175/** Cells between the terminal's edge and the row: where Claude Code 2.1.288 starts the status line (measured). */
176const LEFT_INSET = 2
177/** Narrower than this, the detail keeps only its first part ("3 saved"). */
178const WIDE_COLUMNS = 100
179/** The marketplace Recall installs from, as `/plugin marketplace update` takes it. */
180const MARKETPLACE = 'recall-claude-plugin'
181
182/**
183 * Recall's row, from what the mod knows now: the workspace's name as the key value, then detail. A warning only
184 * when something failed (a store did not reach Recall, Recall unreachable). Nothing about the workspace before
185 * Claude confirms it: "not confirmed" read as an error.
186 */
187export function bandRow(state, columns = WIDE_COLUMNS) {
188  const wide = columns >= WIDE_COLUMNS
189  const detail = []
190  if (state.stored > 0) detail.push(state.stored + (wide ? ' saved this session' : ' saved'))
191  if (wide && state.activity && state.now - state.activity.at < 60_000) detail.push(state.activity.label + ' (' + ago(state.now - state.activity.at) + ')')
192  if (wide && state.version) detail.push(String(state.version).split('.').slice(0, 2).join('.'))
193  // Claude Code auto-updates only official marketplaces by default, so most installs see a stale listing until the
194  // marketplace is refreshed: the hint does that first. Narrow, the refresh alone (in a session it also updates
195  // what came from that marketplace).
196  if (state.latest && state.version && newer(state.latest, state.version)) {
197    detail.push(wide ? 'update ' + state.latest + ': /plugin marketplace update ' + MARKETPLACE + ', then /plugin update recall' : '/plugin marketplace update ' + MARKETPLACE)
198  }
199  const warning = []
200  if (state.queued > 0) warning.push(state.queued + ' waiting')
201  if (state.error) warning.push(state.error)
202  const key = String((state.workspace && state.workspace.path) || '').split('/').filter(Boolean).pop() || 'no workspace'
203  return { key, detail: wide ? detail : detail.slice(0, 1), warning }
204}
205
206export function register(on) {
207  on('session.start', async ($, e, next) => {
208    config = await loadConfig($)
209    workspace = await loadWorkspace($)
210    try {
211      version = JSON.parse(await $.fs.read($.plugin.root + '/.claude-plugin/plugin.json')).version || ''
212    } catch {
213      version = ''
214    }
215    const id = await $.session.id()
216    heartbeatFile = /^[A-Za-z0-9_-]+$/.test(id) ? config.home + '/.claude/recall/mod-heartbeat-' + id : ''
217    if (config.apiKey) {
218      await beat($).catch(() => undefined)
219      // Off the start path: the first prompt waits for none of these. No set_workspace here: a mod's MCP call
220      // asks for permission like any other, so it would ask every session. The model's own set_workspace, the
221      // first thing rules/recall.md has it do, confirms the workspace (the tool.call hook below sees it).
222      $.clock.after(0, () => {
223        readStatus($).catch(() => undefined)
224      })
225      $.clock.every(TICK_MS, () => {
226        beat($).catch(() => undefined)
227        flush($).catch(() => undefined)
228        $.ui.invalidate('ui.render')
229      })
230      $.clock.every(STATUS_MS, () => readStatus($).catch(() => undefined))
231    }
232    return next(e)
233  })
234
235  // A short session (claude -p) can end before the timer: send what is left, best effort (1.5 s for all
236  // session.end hooks together).
237  on('session.end', async ($, e, next) => {
238    await flush($).catch(() => undefined)
239    return next(e)
240  })
241
242  // observe.sh in process: after the command has run, queue it if it failed. The result goes back to Claude
243  // at once and unchanged; sending happens on the timer.
244  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
245    const result = await next(e)
246    const item = config && config.apiKey ? observation(e, result) : undefined
247    if (item) queue = [...queue, item].slice(-QUEUE_MAX)
248    return result
249  })
250
251  // The "restored workspace" rule (rules/recall.md) as code: a Recall call that failed because the session lost
252  // its workspace confirms the workspace and runs once more. set_workspace itself is never retried.
253  on('tool.call', { tool: /^mcp__recall-remote__/ }, async ($, e, next) => {
254    const first = await next(e)
255    const ok = (r) => Boolean(r && !r.deny && !(r.result && r.result.isError) && !WORKSPACE_LOST.test(String(r.text || '')))
256    if (e.tool === 'mcp__' + SERVER + '__set_workspace') {
257      if (ok(first)) confirmed = true
258      return first
259    }
260    let out = first
261    if (first && !first.deny && WORKSPACE_LOST.test(String(first.text || ''))) {
262      let again = false
263      try {
264        again = await confirmWorkspace($)
265      } catch {
266        again = false
267      }
268      if (again) {
269        $.ui.log('workspace confirmed again; the call was retried')
270        out = await next(e)
271      }
272    }
273    if (STORES.test(e.tool) && ok(out)) {
274      stored += 1
275      $.ui.invalidate('ui.render')
276    }
277    return out
278  })
279
280  // Recall's row in the band above the prompt. It stands on its own: no other mod has to be there, and the rows
281  // other mods draw stay, below it. Under a survey, or with nothing of its own to draw, the band is theirs as drawn.
282  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
283    const props = e.props || {}
284    if (!config || !config.apiKey || props.hasSurvey) return next(e)
285    const theirs = withoutTopGap(await next(e))
286    const columns = props.bodyColumns || (e.viewport && e.viewport.columns) || WIDE_COLUMNS
287    const row = bandRow({ version, stored, workspace, activity, now: await $.clock.now(), queued: queue.length, latest, error: lastError }, columns)
288    const elements = $.ui.resolve(e)
289    // One blank row above the band, only when the band has a row to spare for it; squeezed, the rows win.
290    const gap = typeof props.maxRows === 'number' && props.maxRows >= rowsOf(bandTree(elements, row, theirs), columns) + 1
291    return bandTree(elements, row, theirs, gap)
292  })
293}
294
295/**
296 * The band's one blank row above it, shared by every product that follows the same rule (FlockTab, Recall, Foley,
297 * none reading another's files): each wraps its rows and the inner mods' rows in a Box with marginTop 1, and takes
298 * the inner tree's own top margin away, so only the outermost one stays. This takes it away.
299 */
300export function withoutTopGap(node) {
301  if (Array.isArray(node)) return node.length > 0 ? [withoutTopGap(node[0]), ...node.slice(1)] : node
302  if (!node || typeof node !== 'object' || node.type !== 'Box' || !node.props || !('marginTop' in node.props)) return node
303  const { marginTop: _gap, ...props } = node.props
304  return { ...node, props }
305}
306
307/**
308 * How many rows a band tree takes at `columns` cells, the rule every product uses to decide whether the gap fits.
309 *
310 * rowsOf v2 final: one rule for FlockTab, Recall and Foley.
311 * Children: read node.children; if undefined, read node.props.children.
312 * Not drawable: null, undefined, false, '', [], a Text with empty text, a Box that counts 0 rows.
313 * Every Box:
314 * - Its marginTop adds to its rows, whether it is a row or a column, empty or not.
315 * - Its paddingLeft narrows its inside: paddingLeft, else paddingX, else padding.
316 * - Vertical padding adds to its content rows: paddingTop + paddingBottom, else 2 x paddingY, else 2 x padding.
317 * - A numeric height sets the content rows to at least that height.
318 * - With no drawable children: 0 content rows, plus its vertical padding, height and marginTop.
319 * Column Box: its children's rows add up, each counted at the inside width.
320 * Row Box (any Box that is not a column): children with a numeric width are counted in that width; a Text whose wrap
321 * starts with "truncate" is its own 1 line and does not join the others' text; the text of the other children wraps
322 * in (inside - their fixed widths). It takes the tallest, at least 1.
323 * Text: a wrap that starts with "truncate" is 1 line. Otherwise ceil(length / width), at least 1. A string or number
324 * is a Text.
325 * Every width is at least 1 cell. Height is border-box: a Box's rows are max(content + vertical padding, height),
326 * plus its marginTop.
327 */
328export function rowsOf(node, columns) {
329  const width = Math.max(1, columns || 0)
330  const num = (v) => typeof v === 'number'
331  const kids = (n) => {
332    const c = n.children !== undefined ? n.children : n.props && n.props.children
333    return c === undefined ? [] : [].concat(c)
334  }
335  const text = (n) => (typeof n === 'string' || num(n) ? String(n) : Array.isArray(n) ? n.map(text).join('') : n && typeof n === 'object' ? text(kids(n)) : '')
336  if (node === null || node === undefined || node === false || node === '') return 0
337  if (Array.isArray(node)) return node.reduce((sum, child) => sum + rowsOf(child, width), 0)
338  if (typeof node !== 'object') return Math.max(1, Math.ceil(String(node).length / width))
339  const props = node.props || {}
340  if (node.type !== 'Box') {
341    const length = text(node).length
342    if (length === 0) return 0
343    return String(props.wrap || '').startsWith('truncate') ? 1 : Math.max(1, Math.ceil(length / width))
344  }
345  const top = num(props.marginTop) ? props.marginTop : 0
346  const left = num(props.paddingLeft) ? props.paddingLeft : num(props.paddingX) ? props.paddingX : num(props.padding) ? props.padding : 0
347  const inside = Math.max(1, width - left)
348  const vertical =
349    num(props.paddingTop) || num(props.paddingBottom)
350      ? (props.paddingTop || 0) + (props.paddingBottom || 0)
351      : num(props.paddingY)
352        ? 2 * props.paddingY
353        : num(props.padding)
354          ? 2 * props.padding
355          : 0
356  const fixed = (c) => c && typeof c === 'object' && !Array.isArray(c) && c.props && num(c.props.width)
357  const children = kids(node).filter((c) => rowsOf(c, fixed(c) ? c.props.width : inside) > 0)
358  let content = 0
359  if (children.length > 0 && props.flexDirection === 'column') {
360    content = children.reduce((sum, c) => sum + rowsOf(c, inside), 0)
361  } else if (children.length > 0) {
362    const widths = children.filter(fixed)
363    const truncated = (c) => c && typeof c === 'object' && c.type !== 'Box' && c.props && String(c.props.wrap || '').startsWith('truncate')
364    const rest = text(children.filter((c) => !fixed(c) && !truncated(c))).length
365    const wrapped = Math.ceil(rest / Math.max(1, inside - widths.reduce((sum, c) => sum + c.props.width, 0)))
366    content = Math.max(1, wrapped, ...widths.map((c) => rowsOf(c, c.props.width)))
367  }
368  return top + Math.max(content + vertical, num(props.height) ? props.height : 0)
369}
370
371/**
372 * Recall's row as a tree: the name in its own column, then one line of the key value, the dim detail, and a
373 * warning only for a failure. The value starts right after the name column, no gap, as FlockTab's and Foley's do:
374 * inset 2 + name 9 = column 11. `gap` puts the band's blank row above it (marginTop 1). Only props from the
375 * reference's Elements table (Text: color, bold, dimColor; Box: flex layout, width, margin, padding): the engine
376 * refuses a whole tree with one prop an element does not take, such as `key` on Text.
377 */
378export function bandTree({ Box, Text }, row, theirs, gap = false) {
379  const value = [row.key]
380  if (row.detail.length) value.push(Text({ dimColor: true, children: [' · ' + row.detail.join(' · ')] }))
381  if (row.warning.length) value.push(Text({ color: 'yellow', children: [' · ' + row.warning.join(' · ')] }))
382  const mine = Box({
383    flexDirection: 'row',
384    paddingLeft: LEFT_INSET,
385    children: [
386      Box({ width: NAME_COLUMNS, flexShrink: 0, children: [Text({ bold: true, color: RECALL_COLOUR, children: ['Recall'] })] }),
387      Box({ flexShrink: 1, children: [Text({ children: value })] }),
388    ],
389  })
390  return Box({ flexDirection: 'column', ...(gap ? { marginTop: 1 } : {}), children: theirs ? [mine, theirs] : [mine] })
391}
392
hooks/redact.js 28 lines
1// Secrets in a failing command or its output never leave this machine: each match becomes [REDACTED] before a memory
2// is sent. The list is the same as REDACT_PATTERNS in scripts/observe.sh (hooks.test.sh compares them, and runs both
3// against tests/redaction-fixtures.js). Each pattern is [source, flags], written so JavaScript, jq (Oniguruma) and
4// Python read it alike: a named group "k" is text to keep in front of [REDACTED], and \x27 stands for a single quote.
5// Bare hex is never redacted, so commit SHAs survive.
6export const PATTERNS = [
7  ["-----BEGIN [A-Z ]*PRIVATE KEY-----(?:[\\s\\S]*?-----END [A-Z ]*PRIVATE KEY-----|[\\s\\S]*)", ""],
8  ["(?<k>authorization\\s*[:=]\\s*)(?:(?:bearer|basic|token|digest)\\s+)?[^\\s\"\\x27,;]+", "i"],
9  ["(?<k>\\bbearer\\s+)[A-Za-z0-9._~+/=-]{8,}", "i"],
10  ["(?<k>\\b[a-z][a-z0-9+.-]*://)[^/\\s:@\"\\x27]+:[^/\\s@\"\\x27]+(?=@)", "i"],
11  ["(?<k>\\b[a-z0-9_.-]*?(?:token|secret|password|passwd|api[_-]?key|access[_-]?key|private[_-]?key)=[\"\\x27]?)[^\\s\"\\x27&;,)]+", "i"],
12  ["(?<k>(?:^|\\s)--?(?:[a-z0-9]+-)*(?:token|secret|password|passwd|pass|api-?key|access-?key)(?:=|\\s+)[\"\\x27]?)[^\\s\"\\x27]+", "i"],
13  ["\\b(?:sk-ant-|sk-|ghp_|gho_|ghu_|ghs_|ghr_|github_pat_|xox[abpr]-|crsr_|ft_live_|ft_test_|ft_run_|rk_live_|rk_test_|sk_live_|sk_test_|whsec_)[A-Za-z0-9_.-]{8,}", ""],
14  ["\\bAKIA[0-9A-Z]{16}\\b", ""]
15]
16
17/** The text with every secret replaced by [REDACTED]. */
18export function redact(text) {
19  let out = String(text)
20  for (const [source, flags] of PATTERNS) {
21    out = out.replace(new RegExp(source, 'g' + flags), (...args) => {
22      const groups = args[args.length - 1]
23      return (groups && typeof groups === 'object' && groups.k ? groups.k : '') + '[REDACTED]'
24    })
25  }
26  return out
27}
28