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

Gives Claude the context you would otherwise retype. Five parts, each on its own switch.
/plugin install recall --marketplace <owner>/<repo>
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.
| Setting | Default | Meaning |
|---|---|---|
memoryServer | empty (off) | MCP server name as /mcp lists it |
recallTool, recallArgs | recall, {"query": "{{query}}"} | tool and a JSON template; {{query}} is replaced in every string |
maxChars | 2000 | memory text added per prompt |
minWords | 6 | prompts with fewer words skip recall (short approvals pulled in unrelated memories) |
smartMentions | on | the @-mention context |
harvestCorrections | on | the correction harvester |
instructionsFile | CLAUDE.md | target of a drafted rule |
frictionFile | empty | markdown file to append friction lines to |
retainServer, retainTool, retainArgs | empty, retain, {"content": "{{text}}"} | where /remember saves; empty server keeps notes in the plugin store |
maxChars of extra input tokens on that turn.git commands per mention, plus a few hundred input tokens.harvestCorrections to spend nothing./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.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.hooks/register.tsx 379 lines1import { 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}
379hooks/recall.ts 217 lines1// 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}
217types/index.d.ts 15 lines1export 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