SLOPSHOPPER

recall

Context you would otherwise retype: memory recall per prompt, smarter @-mentions, repeated-correction rules, a friction log and selection verbs

newpanebandguardcommandprompt
v0.1.1MITupdated 2026-10-26Egoushka/claude-mods/mods/recall
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · recall
│ ┃ Friction log ✕ › fix the failing auth test and add an audit log call │ ┃ No friction recorded in this project yet. │ ⏺ 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 │ │ › /friction │ ⎿ recall: Friction log opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Friction log
No friction recorded in this project yet.
Pane · recall-memories
No memories for the last prompt.
README

recall

Gives Claude the context you would otherwise retype. Five parts, each on its own switch.

/plugin install recall --marketplace <owner>/<repo>

What it does

Memory ghost. On every prompt you type, it calls a memory MCP tool with your prompt and adds the answer to what the model reads (never shown in the transcript). The band above the prompt shows "N memories" with a Show button that opens a pane listing them. Off while memoryServer is empty. The call is cut off after 3 seconds, so a slow server never stalls a prompt; a failing or empty answer adds nothing. The result is read as a JSON list (also {results|memories|items: [...]}) or, failing that, as paragraphs and bullets, and capped at maxChars.

Smart @-mentions. For @file, adds under the file: its last 5 commits (git log), likely test files (git-tracked files whose name holds the file's name and looks like a test: *.test.*, *_spec.*, test_*, *Tests.*, tests/ folders) and up to 8 files that mention its name (git grep -l -w). Outside a git repo it adds nothing. Name matching is a heuristic: a file named cache also matches cache_test, and a common word as a file name makes the "mentioned by" list noisy.

Correction harvester. After each prompt, a Haiku call asks whether you are correcting what the assistant just did. A correction is boiled down to one imperative line and stored per project. When a later correction matches an earlier one (word overlap, else a second Haiku call over the last 15), the band shows Rule candidate: <summary> with Draft rule (asks Claude to propose the rule as an edit to instructionsFile and wait for your confirmation) and Dismiss. Corrections are never written to your instructions file by the plugin itself.

Friction log. Records, with time and a short evidence line: tool calls denied (by any plugin in the chain, or by a permission denial), a call that errors a 2nd and a 5th time in a row, corrections, and interrupted turns. /friction opens the log in a pane. The log is kept per project in the plugin store (last 200); set frictionFile to also append markdown lines to a file.

Selection verbs. Select text with the mouse, then run /verify or /why (each puts a quoted request in the prompt box for you to edit and send; text after the command is appended) or /remember (saves the selection). With nothing selected they say so and do nothing; /remember some text saves the text you typed.

Settings

SettingDefaultMeaning
memoryServerempty (off)MCP server name as /mcp lists it
recallTool, recallArgsrecall, {"query": "{{query}}"}tool and a JSON template; {{query}} is replaced in every string
maxChars2000memory text added per prompt
minWords6prompts with fewer words skip recall (short approvals pulled in unrelated memories)
smartMentionsonthe @-mention context
harvestCorrectionsonthe correction harvester
instructionsFileCLAUDE.mdtarget of a drafted rule
frictionFileemptymarkdown file to append friction lines to
retainServer, retainTool, retainArgsempty, retain, {"content": "{{text}}"}where /remember saves; empty server keeps notes in the plugin store

Cost

  • Memory ghost: one MCP call per typed prompt, plus up to maxChars of extra input tokens on that turn.
  • Smart @-mentions: four short git commands per mention, plus a few hundred input tokens.
  • Correction harvester: one Haiku classification per prompt of 13+ characters once the assistant has spoken (about 300 input tokens). A correction adds one Haiku summary and sometimes one more Haiku comparison. Turn it off with harvestCorrections to spend nothing.
  • Friction log and selection verbs: no model calls.

Limits

  • Slash commands, queued and plugin-sent prompts are not recalled for or classified; only prompts typed in the prompt box (or sent from a phone) are.
  • /verify, /why and /remember read the mouse selection, which the engine reports only in the fullscreen terminal and in the desktop app. Elsewhere they say nothing is selected.
  • Permission denials reach the log through a classic.PermissionDenied hook, which an organization-managed Claude Code skips for plugins you install yourself. Denials by hooks (tool.call deny) are logged either way.
  • The plugin store has no per-project partition, so state is keyed by the session's start folder.
Source 3 files
hooks/register.tsx 379 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Candidate, FrictionEntry, FrictionKind, Recalled } from '../types'
5import {
6  brief,
7  describeCall,
8  draftRulePrompt,
9  extractMemories,
10  fillTemplate,
11  frictionLine,
12  isRepeatInFile,
13  likelyTests,
14  memoryContext,
15  mentionContext,
16  nextCandidate,
17  similarity,
18  stemOf,
19  verbPrompt,
20} from './recall'
21import type { Group } from './recall'
22
23const memories = atom({ plugin: 'recall', key: 'memories' } as const, null)
24const candidate = atom({ plugin: 'recall', key: 'candidate' } as const, null)
25const friction = atom({ plugin: 'recall', key: 'friction' } as const, [])
26
27const MEMORY_PANE = 'recall-memories'
28const FRICTION_PANE = 'recall-friction'
29const RECALL_TIMEOUT_MS = 3000
30const SAME_KIND_THRESHOLD = 0.6
31const MAX_FRICTION = 200
32const MAX_GROUPS = 50
33
34const text = (v: unknown, fallback = '') => (typeof v === 'string' ? v : fallback)
35
36type Settings = {
37  memoryServer: string
38  recallTool: string
39  recallArgs: string
40  maxChars: number
41  minWords: number
42  smartMentions: boolean
43  harvest: boolean
44  instructionsFile: string
45  frictionFile: string
46  retainServer: string
47  retainTool: string
48  retainArgs: string
49}
50
51// Resolves to the promise's value, or undefined when it fails or `ms` pass first.
52async function within<T>($: EngineInterface, work: Promise<T>, ms: number): Promise<T | undefined> {
53  const stop = new AbortController()
54  const timer = $.clock.sleep(ms, { signal: stop.signal }).then(
55    () => undefined,
56    () => undefined,
57  )
58  const done = work.then(
59    value => value,
60    () => undefined,
61  )
62  const winner = await Promise.race([done, timer])
63  stop.abort()
64  return winner
65}
66
67async function recallMemories($: EngineInterface, s: Settings, prompt: string): Promise<Recalled | null> {
68  const args = fillTemplate(s.recallArgs, 'query', prompt.slice(0, 1000))
69  if (!args) return null
70  const result = await within($, $.mcp.call(s.memoryServer, s.recallTool, args), RECALL_TIMEOUT_MS)
71  if (!result || result.isError) return null
72  const items = extractMemories(result, s.maxChars)
73  if (items.length === 0) return null
74  return { query: brief(prompt, 80), items, at: await $.clock.now() }
75}
76
77async function recordFriction($: EngineInterface, s: Settings, project: string, kind: FrictionKind, evidence: string, key?: string) {
78  const entry: FrictionEntry = { at: await $.clock.now(), kind, evidence: brief(evidence, 200) }
79  const list = await update($, friction, old => [...old, entry].slice(-MAX_FRICTION))
80  await $.store.set(`friction:${project}`, list).catch(() => undefined)
81  if (!s.frictionFile) return
82  const read = await $.fs.read(s.frictionFile).catch(() => '')
83  const old = typeof read === 'string' ? read : ''
84  if (key && isRepeatInFile(old, kind, brief(key, 1000).slice(0, 60), entry.at)) return
85  await $.fs.write(s.frictionFile, `${old}${frictionLine(entry.at, kind, entry.evidence)}\n`).catch(() => undefined)
86}
87
88const groupsKey = (project: string) => `corrections:${project}`
89
90async function loadGroups($: EngineInterface, project: string): Promise<Group[]> {
91  const v = await $.store.get(groupsKey(project))
92  return Array.isArray(v) ? (v as Group[]) : []
93}
94
95async function saveGroups($: EngineInterface, project: string, groups: Group[]) {
96  await $.store.set(groupsKey(project), groups.slice(-MAX_GROUPS))
97  const next = nextCandidate(groups)
98  await update($, candidate, () => (next ? { id: next.id, summary: next.summary } : null))
99}
100
101async function sameKind($: EngineInterface, groups: readonly Group[], summary: string): Promise<Group | undefined> {
102  const open = groups.filter(g => g.status !== 'dismissed')
103  const close = open.find(g => similarity(g.summary, summary) >= SAME_KIND_THRESHOLD)
104  if (close || open.length === 0) return close
105  const recent = open.slice(-15)
106  const reply = await $.model.complete({
107    model: 'haiku',
108    prompt:
109      `Known corrections:\n${recent.map((g, i) => `${i + 1}. ${g.summary}`).join('\n')}\n\nNew correction: ${summary}\n\n` +
110      'If the new one asks for the same thing as a known one, reply with that number; otherwise reply 0. Reply with the number only.',
111    timeoutMs: 8000,
112  })
113  if (!reply.isAnswered) return undefined
114  const n = Number.parseInt(reply.text.trim(), 10)
115  return Number.isInteger(n) && n >= 1 && n <= recent.length ? recent[n - 1] : undefined
116}
117
118// Runs in the background after a prompt: classify, summarize, group, maybe raise a rule candidate.
119async function harvest($: EngineInterface, s: Settings, project: string, prompt: string, assistant: string) {
120  const label = await $.model.classify(
121    `What the assistant just did or said:\n${brief(assistant, 600)}\n\nThe person's reply:\n${brief(prompt, 600)}\n\n` +
122      'Is the reply correcting something the assistant did (wrong approach, ignored instruction, repeated mistake)?',
123    ['correcting', 'not-correcting'],
124    { model: 'haiku' },
125  )
126  if (label !== 'correcting') return
127  const sum = await $.model.complete({
128    model: 'haiku',
129    prompt:
130      `The assistant did: ${brief(assistant, 600)}\nThe person corrected it: ${brief(prompt, 600)}\n\n` +
131      'State the general rule the person wants followed, as one imperative sentence of at most 14 words. No quotes, no preamble.',
132    timeoutMs: 8000,
133  })
134  if (!sum.isAnswered) return
135  const summary = brief(sum.text, 140)
136  if (!summary) return
137  const groups = await loadGroups($, project)
138  const hit = await sameKind($, groups, summary).catch(() => undefined)
139  const now = await $.clock.now()
140  if (hit) {
141    hit.count += 1
142    hit.at = now
143  } else groups.push({ id: `c${now}`, summary, count: 1, at: now, status: 'open' })
144  await saveGroups($, project, groups)
145  await recordFriction($, s, project, 'correction', summary)
146}
147
148async function setStatus($: EngineInterface, project: string, id: string, status: Group['status']) {
149  const groups = await loadGroups($, project)
150  const g = groups.find(one => one.id === id)
151  if (g) g.status = status
152  await saveGroups($, project, groups)
153}
154
155async function draftRule($: EngineInterface, s: Settings, project: string, c: Candidate) {
156  await setStatus($, project, c.id, 'drafted')
157  void $.prompt.submit({ text: draftRulePrompt(c.summary, s.instructionsFile) })
158}
159
160async function git($: EngineInterface, cwd: string, args: string[]): Promise<string[]> {
161  const r = await $.process.run(['git', '-C', cwd, ...args], { timeoutMs: 4000 }).catch(() => undefined)
162  if (!r || r.exitCode !== 0) return []
163  return r.stdout.split('\n').filter(Boolean)
164}
165
166async function mentionLookup($: EngineInterface, path: string): Promise<string | undefined> {
167  const dir = path.slice(0, path.lastIndexOf('/')) || '/'
168  const [root] = await git($, dir, ['rev-parse', '--show-toplevel'])
169  if (!root) return undefined
170  const rel = path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path
171  const stem = stemOf(rel)
172  const [commits, files, refs] = await Promise.all([
173    git($, root, ['log', '-n', '5', '--date=short', '--format=%h %ad %s', '--', rel]),
174    git($, root, ['ls-files']),
175    stem.length >= 3 ? git($, root, ['grep', '-l', '-w', '-F', '-e', stem, '--', '.', `:(exclude)${rel}`]) : Promise.resolve([] as string[]),
176  ])
177  const tests = likelyTests(files, rel)
178  return mentionContext(rel, commits, tests, refs.filter(f => !tests.includes(f)).slice(0, 8), stem)
179}
180
181type Args = Record<string, unknown>
182
183const failed = () => ({ text: 'recall: that command failed; try again.' })
184
185async function retain($: EngineInterface, s: Settings, project: string, selected: string): Promise<string> {
186  if (s.retainServer) {
187    const args = fillTemplate(s.retainArgs, 'text', selected)
188    if (!args) return 'recall: retainArgs is not a JSON object; nothing saved.'
189    const r = await within($, $.mcp.call(s.retainServer, s.retainTool, args as Args), 10000)
190    if (!r) return `recall: ${s.retainServer} did not answer in time; nothing saved.`
191    if (r.isError) return `recall: ${s.retainServer} reported an error; nothing saved.`
192    return `Saved to ${s.retainServer}.`
193  }
194  const key = `notes:${project}`
195  const old = await $.store.get(key)
196  const notes = Array.isArray(old) ? old : []
197  await $.store.set(key, [...notes, { at: await $.clock.now(), text: selected }].slice(-100))
198  return `Saved to the plugin store (${notes.length + 1} notes for this project). Set retainServer to save elsewhere.`
199}
200
201async function verb($: EngineInterface, s: Settings, project: string, kind: 'verify' | 'why' | 'remember', args: string) {
202  const selected = (await $.ui.selection())?.text.trim() ?? ''
203  if (!selected && !(kind === 'remember' && args.trim()))
204    return { text: `Nothing is selected. Select text with the mouse (fullscreen mode), then run /${kind}.` }
205  if (kind === 'remember') return { text: await retain($, s, project, selected || args.trim()) }
206  const draft = await $.prompt.read()
207  const body = verbPrompt(kind, selected, args)
208  const filled = await $.prompt.fill(draft.text.trim() ? { text: `\n\n${body}`, mode: 'append' } : { text: body })
209  return { text: filled.isFilled ? `Put a ${kind} request in the prompt box. Edit it, then press Enter.` : 'The prompt box could not take the text.' }
210}
211
212export const register: Register = (on, options) => {
213  const s: Settings = {
214    memoryServer: text(options.memoryServer).trim(),
215    recallTool: text(options.recallTool, 'recall'),
216    recallArgs: text(options.recallArgs, '{"query": "{{query}}"}'),
217    maxChars: typeof options.maxChars === 'number' && options.maxChars > 0 ? options.maxChars : 2000,
218    minWords: typeof options.minWords === 'number' && options.minWords >= 0 ? options.minWords : 6,
219    smartMentions: options.smartMentions !== false,
220    harvest: options.harvestCorrections !== false,
221    instructionsFile: text(options.instructionsFile, 'CLAUDE.md') || 'CLAUDE.md',
222    frictionFile: text(options.frictionFile).trim(),
223    retainServer: text(options.retainServer).trim(),
224    retainTool: text(options.retainTool, 'retain'),
225    retainArgs: text(options.retainArgs, '{"content": "{{text}}"}'),
226  }
227
228  // Per-session bookkeeping; a reload starts it over.
229  let project = ''
230  let lastPrompt = ''
231  const failures = new Map<string, number>()
232
233  on('session.start', async ($, e, next) => {
234    project = e.cwd
235    const saved = await $.store.get(`friction:${project}`)
236    await update($, friction, () => (Array.isArray(saved) ? (saved as FrictionEntry[]) : []))
237    await update($, memories, () => null)
238    const c = nextCandidate(await loadGroups($, project))
239    await update($, candidate, () => (c ? { id: c.id, summary: c.summary } : null))
240    for (const [name, description] of [
241      ['friction', 'Show the friction log: denied or failing tool calls, corrections, interrupted turns'],
242      ['verify', 'Put a request to verify the selected text in the prompt box'],
243      ['remember', 'Save the selected text to memory'],
244      ['why', 'Put a request to explain the selected text in the prompt box'],
245    ] as const)
246      await $.command.register({ name, description })
247    return next(e)
248  })
249
250  // Memory ghost and correction harvester both read the person's own prompts.
251  on('prompt.submit', async ($, e, next) => {
252    // The engine always stamps an origin; a bare one (an engine-less test) counts as typed.
253    const origin = e.origin as typeof e.origin | undefined
254    const typed = !origin || origin.kind === 'composer' || origin.kind === 'bridge' || (origin.kind === 'plugin' && origin.asUser === true)
255    const isPerson = typed && !e.command && !e.isMeta
256    if (!isPerson) return next(e)
257    lastPrompt = e.text
258
259    if (s.harvest && e.text.trim().length > 12) {
260      const messages = await $.session.messages().catch(() => [])
261      const assistant = [...messages].reverse().find(m => m.role === 'assistant')
262      const said = assistant ? `${assistant.text}\n${assistant.toolUses.map(t => describeCall(t.tool, t.input)).join('\n')}`.trim() : ''
263      if (said) void harvest($, s, project, e.text, said).catch(() => undefined)
264    }
265
266    if (!s.memoryServer || e.text.trim().split(/\s+/).length < s.minWords) return next(e)
267    const recalled = await recallMemories($, s, e.text).catch(() => null)
268    await update($, memories, () => recalled)
269    if (!recalled) return next(e)
270    return next({ ...e, context: [...(e.context ?? []), memoryContext(recalled.items)] })
271  }).catch(($, e, next) => next(e))
272
273  on('prompt.mention', async ($, e, next) => {
274    const result = await next(e)
275    if (!s.smartMentions || result.deny !== undefined || result.type === null) return result
276    const extra = await mentionLookup($, e.path).catch(() => undefined)
277    return extra ? { ...result, context: [...(result.context ?? []), extra] } : result
278  }).catch(($, e, next) => next(e))
279
280  // Friction: denied calls and calls that keep failing.
281  on('tool.call', async ($, e, next) => {
282    const ran = await next(e)
283    const about = describeCall(String(e.tool), e as unknown as Record<string, unknown>)
284    if (ran.deny !== undefined) {
285      await recordFriction($, s, project, 'denied', `${about}: ${ran.deny}`, ran.deny)
286    } else if (ran.isError) {
287      const n = (failures.get(about) ?? 0) + 1
288      failures.set(about, n)
289      if (n === 2 || n === 5) await recordFriction($, s, project, 'errors', `${about} failed ${n} times: ${ran.text ?? ''}`, about)
290    } else failures.delete(about)
291    return ran
292  }).catch(($, e, next) => next(e))
293
294  on('classic.PermissionDenied', async ($, e, next) => {
295    await recordFriction($, s, project, 'denied', `${describeCall(e.tool_name, (e.tool_input ?? {}) as Record<string, unknown>)}: ${e.reason}`)
296    return next(e)
297  }).catch(($, e, next) => next(e))
298
299  on('turn.complete', async ($, e, next) => {
300    if (e.reason === 'aborted') await recordFriction($, s, project, 'interrupt', `turn interrupted after: ${lastPrompt}`)
301    return next(e)
302  })
303
304  on('command.run', { command: 'friction' }, async $ => {
305    await $.ui.open({ id: FRICTION_PANE, title: 'Friction log', focus: true })
306    return { text: 'Friction log opened.' }
307  }).catch(failed)
308  on('command.run', { command: 'verify' }, ($, e) => verb($, s, project, 'verify', e.args)).catch(failed)
309  on('command.run', { command: 'why' }, ($, e) => verb($, s, project, 'why', e.args)).catch(failed)
310  on('command.run', { command: 'remember' }, ($, e) => verb($, s, project, 'remember', e.args)).catch(failed)
311
312  // The band composes: other mods' rows stay below ours.
313  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
314    const rest = await next(e)
315    if (e.props.hasSurvey) return rest
316    const recalled = await read($, memories)
317    const cand = await read($, candidate)
318    if (!recalled && !cand) return rest
319
320    const { Box, Button, Text } = $.ui.resolve(e)
321    return (
322      <Box flexDirection="column">
323        {recalled && (
324          <Box>
325            <Text dimColor>
326              {recalled.items.length} {recalled.items.length === 1 ? 'memory' : 'memories'}{' '}
327            </Text>
328            <Button key="show-memories" label="Show" onPress={() => $.ui.open({ id: MEMORY_PANE, title: 'Memories', focus: true })} />
329          </Box>
330        )}
331        {cand && (
332          <Box>
333            <Text dimColor>Rule candidate: {cand.summary} </Text>
334            <Button key="draft-rule" label="Draft rule" variant="primary" onPress={() => draftRule($, s, project, cand)} />
335            <Button key="dismiss-rule" label="Dismiss" onPress={() => setStatus($, project, cand.id, 'dismissed')} />
336          </Box>
337        )}
338        {rest}
339      </Box>
340    )
341  })
342
343  on('ui.render', { component: 'Pane', requestId: MEMORY_PANE }, async ($, e) => {
344    const { Box, Text } = $.ui.resolve(e)
345    const recalled = await read($, memories)
346    if (!recalled) return <Text dimColor>No memories for the last prompt.</Text>
347    return (
348      <Box flexDirection="column">
349        <Text dimColor>Recalled for: {recalled.query}</Text>
350        {recalled.items.map((item, i) => (
351          <Text>
352            {i + 1}. {brief(item, 400)}
353          </Text>
354        ))}
355      </Box>
356    )
357  })
358
359  on('ui.render', { component: 'Pane', requestId: FRICTION_PANE }, async ($, e) => {
360    const { Box, Text } = $.ui.resolve(e)
361    const list = await read($, friction)
362    const room = Math.max(1, (e.viewport?.rows ?? 24) - 4)
363    if (list.length === 0) return <Text dimColor>No friction recorded in this project yet.</Text>
364    return (
365      <Box flexDirection="column">
366        {[...list]
367          .reverse()
368          .slice(0, room)
369          .map(entry => (
370            <Text>
371              <Text dimColor>{new Date(entry.at).toISOString().slice(0, 16).replace('T', ' ')} </Text>
372              {entry.kind}: {entry.evidence}
373            </Text>
374          ))}
375      </Box>
376    )
377  })
378}
379
hooks/recall.ts 217 lines
1// Pure helpers: no engine, so the tests import them directly.
2
3type Json = string | number | boolean | null | Json[] | { [key: string]: Json }
4
5/** Parses an argument template and replaces `{{key}}` in every string. Undefined when it is not a JSON object. */
6export function fillTemplate(template: string, key: string, value: string): Record<string, unknown> | undefined {
7  let parsed: unknown
8  try {
9    parsed = JSON.parse(template)
10  } catch {
11    return undefined
12  }
13  if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return undefined
14  const token = `{{${key}}}`
15  const walk = (node: Json): Json => {
16    if (typeof node === 'string') return node.split(token).join(value)
17    if (Array.isArray(node)) return node.map(walk)
18    if (node && typeof node === 'object') return Object.fromEntries(Object.entries(node).map(([k, v]) => [k, walk(v)]))
19    return node
20  }
21  return walk(parsed as Json) as Record<string, unknown>
22}
23
24type McpLike = { content?: readonly { type: string; text?: string }[]; structuredContent?: unknown }
25
26const LIST_KEYS = ['results', 'memories', 'items', 'facts', 'data', 'matches']
27const TEXT_KEYS = ['text', 'content', 'memory', 'summary', 'fact', 'snippet', 'body']
28
29function listIn(value: unknown): unknown[] | undefined {
30  if (Array.isArray(value)) return value
31  if (value && typeof value === 'object') {
32    for (const key of LIST_KEYS) {
33      const inner = (value as Record<string, unknown>)[key]
34      if (Array.isArray(inner)) return inner
35    }
36  }
37  return undefined
38}
39
40function itemText(item: unknown): string {
41  if (typeof item === 'string') return item
42  if (item && typeof item === 'object') {
43    for (const key of TEXT_KEYS) {
44      const v = (item as Record<string, unknown>)[key]
45      if (typeof v === 'string' && v.trim()) return v
46    }
47  }
48  return JSON.stringify(item)
49}
50
51/** Memories out of an MCP result: a JSON list when the server sends one, else paragraphs and bullets. Capped at `maxChars` in all. */
52// JSON text, unwrapping a gateway's {"result": "<json>"} envelope; undefined for plain text.
53function parseWrapped(text: string): unknown {
54  let parsed: unknown
55  try {
56    parsed = JSON.parse(text)
57  } catch {
58    return undefined
59  }
60  const inner = typeof parsed === 'object' && parsed !== null ? (parsed as { result?: unknown }).result : undefined
61  if (typeof inner === 'string') {
62    try {
63      return JSON.parse(inner)
64    } catch {
65      return parsed
66    }
67  }
68  return parsed
69}
70
71export function extractMemories(result: McpLike, maxChars: number): string[] {
72  const text = (result.content ?? [])
73    .map(block => (block.type === 'text' ? (block.text ?? '') : ''))
74    .join('\n')
75    .trim()
76  let raw: string[] = []
77  const structured = listIn(result.structuredContent)
78  if (structured) raw = structured.map(itemText)
79  else {
80    const parsed = parseWrapped(text)
81    if (parsed !== undefined) {
82      // JSON with an error, or with no list in it, holds no memories; never pass it on as one.
83      if (typeof parsed === 'object' && parsed !== null && (parsed as { error?: unknown }).error) return []
84      raw = (listIn(parsed) ?? []).map(itemText)
85    } else if (text) {
86      raw = text.split(/\n\s*\n|\n(?=\s*(?:[-*•]|\d+[.)])\s)/).map(part => part.replace(/^\s*(?:[-*•]|\d+[.)])\s+/, ''))
87    }
88  }
89  const items: string[] = []
90  let used = 0
91  for (const part of raw.map(p => p.trim()).filter(Boolean)) {
92    const room = maxChars - used
93    if (room <= 0) break
94    items.push(part.length > room ? `${part.slice(0, Math.max(1, room - 1))}…` : part)
95    used += part.length
96  }
97  return items
98}
99
100export function memoryContext(items: readonly string[]): string {
101  return `Memories recalled for this prompt (may be stale; verify before relying on them):\n${items.map(i => `- ${i.replace(/\n+/g, ' ')}`).join('\n')}`
102}
103
104export const normalize = (text: string) =>
105  text
106    .toLowerCase()
107    .replace(/[^a-z0-9\s]/g, ' ')
108    .split(/\s+/)
109    .filter(word => word.length > 2)
110    .join(' ')
111
112/** Jaccard overlap of the words of two summaries. */
113export function similarity(a: string, b: string): number {
114  const x = new Set(normalize(a).split(' ').filter(Boolean))
115  const y = new Set(normalize(b).split(' ').filter(Boolean))
116  if (x.size === 0 || y.size === 0) return 0
117  let both = 0
118  for (const word of x) if (y.has(word)) both += 1
119  return both / (x.size + y.size - both)
120}
121
122export type Group = { id: string; summary: string; count: number; at: number; status: 'open' | 'dismissed' | 'drafted' }
123
124export function nextCandidate(groups: readonly Group[]): Group | undefined {
125  return groups.find(g => g.status === 'open' && g.count >= 2)
126}
127
128const TEST_DIR = /(^|\/)(tests?|__tests__|specs?)\//i
129const TEST_NAME = /([._-](test|spec)s?\.[a-z0-9]+$)|(^test_)|((Tests?|Specs?)\.[a-z0-9]+$)/
130
131/** Files whose name holds the mentioned file's stem and looks like a test. */
132export function likelyTests(files: readonly string[], rel: string, cap = 5): string[] {
133  const base = rel.split('/').pop() ?? rel
134  const stem = base.replace(/\.[^.]+$/, '').toLowerCase()
135  if (stem.length < 2) return []
136  return files
137    .filter(file => {
138      if (file === rel) return false
139      const name = file.split('/').pop() ?? file
140      return name.toLowerCase().includes(stem) && (TEST_DIR.test(file) || TEST_NAME.test(name))
141    })
142    .slice(0, cap)
143}
144
145export const stemOf = (path: string) => (path.split('/').pop() ?? path).replace(/\.[^.]+$/, '')
146
147export function mentionContext(label: string, commits: readonly string[], tests: readonly string[], refs: readonly string[], stem: string): string | undefined {
148  const parts: string[] = []
149  if (commits.length) parts.push(`Last ${commits.length} commits touching it:\n${commits.map(c => `- ${c}`).join('\n')}`)
150  if (tests.length) parts.push(`Likely test files (by name):\n${tests.map(t => `- ${t}`).join('\n')}`)
151  if (refs.length) parts.push(`Files that mention "${stem}" (git grep):\n${refs.map(r => `- ${r}`).join('\n')}`)
152  return parts.length ? `Context for @${label}, added by the recall plugin:\n${parts.join('\n\n')}` : undefined
153}
154
155export const brief = (text: string, max = 120) => {
156  const one = text.replace(/\s+/g, ' ').trim()
157  return one.length > max ? `${one.slice(0, max - 1)}…` : one
158}
159
160// Friction lines are stored and may be written to a file, so anything secret-shaped is masked first.
161export function maskSecrets(text: string): string {
162  return text
163    .replace(/\b([A-Z0-9_]*(?:SECRET|TOKEN|PASSWORD|PASSWD|API_KEY|APIKEY)[A-Z0-9_]*)\s*[=:]\s*\S+/gi, '$1=[masked]')
164    .replace(/\b(?:sk-[A-Za-z0-9_-]{20,}|gh[pousr]_[A-Za-z0-9]{20,}|github_pat_\w{20,}|glpat-[\w-]{20,}|xox[abprs]-[\w-]{10,}|AKIA[A-Z0-9]{16}|eyJ[\w-]{10,}\.[\w-]{10,}\.[\w-]{10,})/g, '[masked]')
165    .replace(/(-H\s+['"]?Authorization:\s*\w+\s+)\S+/gi, '$1[masked]')
166}
167
168/** One short line saying what a tool call was about. */
169export function describeCall(tool: string, input: Record<string, unknown>): string {
170  for (const key of ['command', 'file_path', 'path', 'url', 'pattern', 'query']) {
171    const v = input[key]
172    if (typeof v === 'string' && v) return `${tool} ${brief(maskSecrets(v), 80)}`
173  }
174  return tool
175}
176
177export function frictionLine(at: number, kind: string, evidence: string): string {
178  return `- ${new Date(at).toISOString().slice(0, 16).replace('T', ' ')} **${kind}**: ${evidence}`
179}
180
181// True when the file's recent lines already hold the same kind of event about `key` within `windowMs`:
182// one outage or one blocked command style then costs one line, not one per session per retry.
183export function isRepeatInFile(file: string, kind: string, key: string, at: number, windowMs = 3_600_000): boolean {
184  const marker = ` **${kind}**: `
185  for (const line of file.split('\n').slice(-200)) {
186    if (!line.includes(marker) || !line.includes(key)) continue
187    const when = Date.parse(`${line.slice(2, 18).replace(' ', 'T')}:00Z`)
188    if (Number.isFinite(when) && at - when < windowMs) return true
189  }
190  return false
191}
192
193const clip = (text: string, max: number) => (text.length > max ? `${text.slice(0, max)}…` : text)
194
195export function quoted(text: string): string {
196  return clip(text.trim(), 4000).replace(/^/gm, '> ')
197}
198
199export const VERB_PROMPTS = {
200  verify:
201    'Verify the claim quoted below. Check it against the code, files or commands that can settle it, then answer CONFIRMED, PARTIALLY CONFIRMED, REJECTED or UNKNOWN, each with the evidence.',
202  why: 'Explain why the quoted text below is the way it is: what in this conversation or the code led to it, what the alternatives were, and what would change it.',
203} as const
204
205export function verbPrompt(kind: keyof typeof VERB_PROMPTS, selected: string, args: string): string {
206  const extra = args.trim() ? `\n\n${args.trim()}` : ''
207  return `${VERB_PROMPTS[kind]}\n\n${quoted(selected)}${extra}\n`
208}
209
210export function draftRulePrompt(summary: string, file: string): string {
211  return (
212    `I have corrected you on the same thing more than once: "${summary}".\n\n` +
213    `Propose a rule that prevents it as an edit to ${file}: say where it goes and show the exact lines. ` +
214    'Keep it to one or two lines in the style of that file. Do not apply it until I confirm.'
215  )
216}
217
types/index.d.ts 15 lines
1export type Recalled = { query: string; items: string[]; at: number }
2export type Candidate = { id: string; summary: string }
3export type FrictionKind = 'denied' | 'errors' | 'correction' | 'interrupt'
4export type FrictionEntry = { at: number; kind: FrictionKind; evidence: string }
5
6declare module 'claude-code' {
7  interface PluginState {
8    recall: {
9      memories: Recalled | null
10      candidate: Candidate | null
11      friction: FrictionEntry[]
12    }
13  }
14}
15