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

Persistent memory, semantic search, and context management across Claude Code sessions.
/plugin install recall@marketplace-name
claude --plugin-dir /path/to/plugin/recall
Or copy to your plugins directory:
cp -r plugin/recall ~/.claude/plugins/recall
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.
.mcp.json)Connects to the Recall MCP server at recallmcp.com (or self-hosted). Provides 21 primary tools:
set_workspace, get_workspace -- workspace managementstore_memory, search_memories, recall_relevant_context -- memory CRUDauto_session_start, summarize_session -- session lifecycle (protocol self-teaching built-in)check_duplicate -- pre-write deduplication checkimport_conversations -- bulk import from Claude Code, ChatGPT, Slack exportsmemory_graph -- relationships with temporal validity (valid_from/valid_to, invalidate)workflow, rlm_process -- advanced workflowshooks/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:
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;observe.sh does) without starting a script for each one;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).
hooks/hooks.json)[REDACTED] first (hooks/redact.js, the same list as observe.sh's).agents/)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 chainsscripts/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
}
}
If you previously used Recall via the install script (scripts/install.sh):
~/.claude/settings.json (SessionStart, PostToolUse, PreCompact, Stop entries referencing recall/hooks/)~/.claude/recall/ directoryRECALL_API_KEY environment variablehooks/register.js 392 lines1// 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}
392hooks/redact.js 28 lines1// 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