Memory at the moment it matters: when a tool call touches what one of your memory files is about, radar attaches that memory's description and rules for the…

▄▄▀▀▀▀▀▄▄ █▀█ ▄▀█ █▀▄ ▄▀█ █▀█
▀▀▀▀▀▀▀▀▀▀▀ █▀▄ █▀█ █▄▀ █▀█ █▀▄
▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀ MEMORY AT THE MOMENT IT MATTERS
▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀

The problem: a memory file helps only if the model thinks of it at the right moment, and usually it doesn't. The study behind this library found at least 11 mistakes that recurred after they had been written down. Meanwhile ~2.7k tokens of memory index were loaded into every session, whether or not any of it applied.
radar indexes your memory files when the session starts. When a tool call touches what a memory is about, the call runs as normal, and radar attaches that memory's description and rule lines to the result. Only the model sees it, right next to the output of the command or edit that made it relevant. A lavender radar scope in the /radar pane pings each time. radar never edits a memory file.
/plugin marketplace add pourya7/claude-code-mods
/plugin install radar@claude-code-mods
*.md file directly inside each memory folder (subfolders are not searched):userConfig.memoryDirs, if you set it;autoMemoryDirectory from your settings;~/.claude/projects/<project>/memory, where <project> is the session's project root with every non-alphanumeric character turned into -. If CLAUDE_CONFIG_DIR is set, it replaces ~/.claude.name and description. It can also have:triggers:, a list of regexes. They are case-insensitive. Use single quotes so backslashes survive. ---
name: Never checkout a dirty file
description: copy it aside first; checkout -- throws away uncommitted work
triggers:
- 'git checkout --\s'
- 'git restore\b'
---
- Never run `git checkout -- <file>` on a file with changes.
- Always copy it aside first and read it back.
MEMORY.md index, is skipped quietly. A file whose frontmatter is broken is skipped too, and every broken file is counted in one toast: an unclosed ---, a missing name or description, a line that is not key: value, an unclosed quote or text after a closing quote, a trigger that is not a valid regex, or a trigger that nests a repeat inside a repeat (such as (\S+\s*)+), which can backtrack for minutes. /radar lists each skipped file and the reason.session.start and again on /radar reload or the pane's RELOAD button. session.start also fires again on a hot reload of the plugin and when you change radar's settings in /config, so those re-read the folders too. Edits you make to memory files during a session take effect after a reload.mcp__*): the tool's name and every string in its arguments.file_path, notebook_path, path, url, query and pattern arguments. Agent and Skill have none of these, so their prompts are never matched.await next(e)). Then up to 2 matching memories are attached as context that only the model reads: radar: your memory "Never checkout a dirty file" (/home/dev/.claude/projects/-work-app/memory/dirty.md) is about this call (trigger match).
copy it aside first; checkout -- throws away uncommitted work
Rules:
- Never run `git checkout -- <file>` on a file with changes.
- Always copy it aside first and read it back.
~/...), such as reading or editing a memory file, or the engine writing its own memory, is not matched at all. Otherwise a memory's own file name would ping that memory.RADAR ▸ <memory name>, unless toast is off. It also bumps the status line and adds a row to the pane. The pane keeps the last 20 pings.| Command | What it does |
|---|---|
/radar | Opens the RADAR pane and replies with the same summary as text. |
/radar list | The summary as text only: folders, memories, recent pings and skipped files. |
/radar reload | Reads the memory folders again. |
userConfig)| Field | Type | Default | Meaning |
|---|---|---|---|
memoryDirs | string | "" | Memory folders to index, separated by commas. ~ means your home folder. Leave it empty to use the auto-memory folder (see Sources). A configured folder that does not exist is reported once. |
toast | boolean | true | Toast RADAR ▸ <memory name> on each ping. With it off, only the status line and the pane change. |
You can change these in the /config menu, or under pluginConfigs.radar in your settings.
The /radar pane after two pings. The scope's beam turns one step with each ping, and the last three pings show as pink blips on the glass:
▄▄▀▀▀▀▀▄▄ RADAR · 274 MEMORIES
▀▀▀▀▀▀▀▀▀▀▀ 2 PINGS THIS SESSION
▀▀▀▀▀▀▀▀▀▀▀▀▀ SCANNING
▀▀▀▀▀▀▀▀▀▀▀▀▀ ~/.claude/projects/-Users-dev-work-app/memory
▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀ [ RELOAD ]
▀▀▀▀▀
────────────────────────────────────────────────────────────
09:41 BASH ● Docker stack capacity
09:05 BASH ◆ Never checkout a dirty file
This capture is plain text. In the terminal the scope is a navy disc with a lavender rim, a dark grey crosshair and range ring, a light grey beam with a lavender trail, and pink blips. In the ping rows, ◆ marks a trigger match and ● a keyword match. Every colour is from the PICO-8 palette.
The status line stays under 40 columns: RADAR 274 ◉ 3 PINGS. The /radar text reply carries the same summary as the pane, so in claude -p, which has no screen to draw on (the engine still counts the pane as placed), and on any surface that does not show panes, the text reply, the status line and the toasts are what you see.
| Network | Runs processes | Files | Calls a model | Auto-submits prompts | Data leaving the machine |
|---|---|---|---|---|---|
None. No $.http. | None. No $.process. | Reads only. $.fs.list and $.fs.read read the *.md files in the memory folders. $.settings.read reads autoMemoryDirectory. $.env.get reads HOME and CLAUDE_CONFIG_DIR. Nothing is written: no $.fs.write, no $.store. The index and pings live only for the session, in $.state. | No. Matching is regex and keywords; there is no $.model call. | No. | Only the memory text radar attaches as context. It goes to your own model, as part of the tool result in the conversation. |
triggers to the memories that matter most. The df cut-off (max(3, 10%)) means that with fewer than about 30 memories, a word must appear in 3 or fewer of them to count.memoryDirs.key: value lines, quoted or plain values (a trailing # comment is dropped), block scalars (description: > or | followed by indented lines, which are joined), and triggers as a block list (- '...'), a flow list (['a', 'b']) or a single value. Nested keys, such as metadata:, are ignored. Anchors, aliases, tags and multi-line flow lists are not read./config settings fires session.start again, so radar re-reads the folders and repeats the skipped-files toast if any are still broken. The pings and the ping count live in $.state and survive it.claude plugin validate radar
claude plugin test radar
Pure logic lives in hooks/memory.ts (the frontmatter and rule-line parser), hooks/match.ts (tokens, the index and ranking) and hooks/text.ts (folders, the context note and the status line). The scope sprite is in hooks/pixels.ts. hooks/register.tsx connects them to the engine. The tests feed an in-memory folder of memory files and mount the pane on both terminal and desktop.
hooks/register.tsx 280 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { RadarMemory, RadarPing } from '../types'
5import { buildIndex, callText, findMatches, touchesSources } from './match'
6import type { Match } from './match'
7import { parseMemory } from './memory'
8import type { ParsedMemory } from './memory'
9import { PALETTE, pixelRows, sweepGrid } from './pixels'
10import { clockText, contextText, defaultMemoryDir, memoryDirsOf, statusText, titleText } from './text'
11
12type Engine = EngineInterface
13
14const PLUGIN = 'radar'
15const PANE = 'radar'
16const MAX_PINGS = 20
17const MAX_FILES = 2000
18const MAX_LISTED = 40
19
20const memoriesAtom = atom({ plugin: 'radar', key: 'memories' } as const, [])
21const problemsAtom = atom({ plugin: 'radar', key: 'problems' } as const, [])
22const sourcesAtom = atom({ plugin: 'radar', key: 'sources' } as const, [])
23const pingsAtom = atom({ plugin: 'radar', key: 'pings' } as const, [])
24const pingCountAtom = atom({ plugin: 'radar', key: 'pingCount' } as const, 0)
25const attachedAtom = atom({ plugin: 'radar', key: 'attached' } as const, [])
26
27const USAGE = [
28 'usage: /radar open the RADAR pane',
29 ' /radar list the index, the recent pings and any skipped files, as text',
30 ' /radar reload read the memory folders again',
31].join('\n')
32
33const plural = (count: number, word: string, many = `${word}s`) => `${count} ${count === 1 ? word : many}`
34
35/** The tool's own arguments, without the envelope's keys (`consent` is the person's press text, not an argument). */
36const argumentsOf = (e: Record<string, unknown>): Record<string, unknown> => {
37 const { tool: _tool, tool_use_id: _id, agentId: _agent, consent: _consent, ...rest } = e
38 return rest
39}
40
41const showStatus = async ($: Engine) => {
42 $.ui.status(statusText((await read($, memoriesAtom)).length, await read($, pingCountAtom)))
43}
44
45const sourcesOf = async ($: Engine, option: unknown): Promise<{ dirs: string[]; isExplicit: boolean }> => {
46 const home = await $.env.get('HOME')
47 const explicit = memoryDirsOf(option, home)
48 if (explicit.length > 0) return { dirs: explicit, isExplicit: true }
49 const settings = await $.settings.read().catch(() => ({}))
50 const configDir = await $.env.get('CLAUDE_CONFIG_DIR')
51 const root = await $.session.root().catch(() => $.session.cwd())
52 return { dirs: [defaultMemoryDir(settings, home, configDir, root)], isExplicit: false }
53}
54
55/** Reads every *.md directly in each folder; the bad ones become problems and one toast. Never writes. */
56const loadIndex = async ($: Engine, option: unknown) => {
57 const { dirs, isExplicit } = await sourcesOf($, option)
58 const parsed: ParsedMemory[] = []
59 const badFiles: string[] = []
60 const missing: string[] = []
61 const seen = new Set<string>()
62 for (const dir of dirs) {
63 const entries = await $.fs.list(dir).catch(() => undefined)
64 if (entries === undefined) {
65 if (isExplicit) missing.push(dir)
66 continue
67 }
68 const files = entries
69 .filter(entry => (entry.kind === 'file' || entry.isLink) && entry.name.toLowerCase().endsWith('.md'))
70 .map(entry => `${dir}/${entry.name}`)
71 .sort()
72 for (const file of files) {
73 if (seen.has(file) || seen.size >= MAX_FILES) continue
74 seen.add(file)
75 let text: string
76 try {
77 text = await $.fs.read(file)
78 } catch {
79 badFiles.push(`${file.slice(file.lastIndexOf('/') + 1)}: could not be read`)
80 continue
81 }
82 const outcome = parseMemory(file, text)
83 if ('memory' in outcome) parsed.push(outcome.memory)
84 else if ('problem' in outcome) badFiles.push(outcome.problem)
85 }
86 }
87 const memories: RadarMemory[] = buildIndex(parsed)
88 await $.state.set({ plugin: 'radar', key: 'memories' }, memories)
89 await $.state.set({ plugin: 'radar', key: 'problems' }, [...badFiles, ...missing.map(dir => `folder not found: ${dir}`)])
90 await $.state.set({ plugin: 'radar', key: 'sources' }, dirs)
91 const parts = [
92 ...(badFiles.length > 0 ? [`${plural(badFiles.length, 'MEMORY FILE', 'MEMORY FILES')} SKIPPED`] : []),
93 ...(missing.length > 0 ? [`${plural(missing.length, 'FOLDER', 'FOLDERS')} NOT FOUND`] : []),
94 ]
95 if (parts.length > 0) $.ui.toast(`RADAR ▸ ${parts.join(' · ')} · /radar`)
96 await showStatus($)
97}
98
99const summaryText = async ($: Engine): Promise<string> => {
100 const memories = await read($, memoriesAtom)
101 const problems = await read($, problemsAtom)
102 const sources = await read($, sourcesAtom)
103 const pings = await read($, pingsAtom)
104 const count = await read($, pingCountAtom)
105 const lines = [titleText(memories.length), `${plural(memories.length, 'memory', 'memories')} · ${plural(count, 'ping')} this session`]
106 lines.push(`Folders: ${sources.join(', ') || '(none)'}`)
107 if (memories.length === 0) lines.push(' no memory files with name/description frontmatter found there')
108 for (const memory of memories.slice(0, MAX_LISTED)) lines.push(` ◉ ${memory.name}: ${memory.description}`)
109 if (memories.length > MAX_LISTED) lines.push(` … and ${memories.length - MAX_LISTED} more`)
110 if (pings.length > 0) {
111 lines.push('Recent pings:')
112 for (const ping of [...pings].reverse()) lines.push(` ${clockText(ping.at)} ${ping.tool.toUpperCase()} ${ping.memory}`)
113 }
114 if (problems.length > 0) {
115 lines.push(`Skipped (${problems.length}):`)
116 for (const problem of problems) lines.push(` ${problem}`)
117 }
118 return lines.join('\n')
119}
120
121const openPane = async ($: Engine) => {
122 try {
123 await $.ui.open({ id: PANE, title: titleText((await read($, memoriesAtom)).length) })
124 } catch {
125 // An open that throws (a surface or hook refused it) leaves the command's text reply, which says the same.
126 }
127}
128
129/** Claims the matches not yet attached this turn, records the pings, and returns the claimed ones. */
130const recordPings = async ($: Engine, tool: string, matches: readonly Match[], isToastOn: boolean): Promise<Match[]> => {
131 let claimed: Match[] = []
132 await update($, attachedAtom, attached => {
133 // update() may run this again on a version miss: the last run decides.
134 claimed = matches.filter(match => !attached.includes(match.memory.file))
135 return [...attached, ...claimed.map(match => match.memory.file)]
136 })
137 if (claimed.length === 0) return claimed
138 const at = await $.clock.now()
139 const fresh: RadarPing[] = claimed.map(match => ({ at, tool, memory: match.memory.name, reason: match.reason }))
140 await update($, pingsAtom, pings => [...pings, ...fresh].slice(-MAX_PINGS))
141 await update($, pingCountAtom, count => count + fresh.length)
142 if (isToastOn) for (const ping of fresh) $.ui.toast(`RADAR ▸ ${ping.memory}`)
143 await showStatus($)
144 return claimed
145}
146
147export const register: Register = (on, options) => {
148 const isToastOn = options.toast !== false
149
150 on('session.start', async ($, e, next) => {
151 try {
152 await $.command.register({
153 name: PLUGIN,
154 description: 'Show the memory radar: the index and recent pings; /radar reload re-reads the folders',
155 argumentHint: '[list | reload]',
156 })
157 } catch {
158 $.ui.toast('RADAR ▸ /radar could not be registered')
159 }
160 try {
161 await loadIndex($, options.memoryDirs)
162 } catch {
163 $.ui.toast('RADAR ▸ the memory folders could not be read')
164 }
165 return next(e)
166 })
167
168 on('turn.start', async ($, e, next) => {
169 try {
170 await $.state.set({ plugin: 'radar', key: 'attached' }, [])
171 } catch {
172 // A stale list only means a memory waits a turn to come back.
173 }
174 return next(e)
175 })
176
177 on('command.run', { command: PLUGIN }, async ($, e) => {
178 const word = e.args.trim().toLowerCase()
179 if (word === '') {
180 await openPane($)
181 return { text: await summaryText($) }
182 }
183 if (word === 'list') return { text: await summaryText($) }
184 if (word === 'reload') {
185 await loadIndex($, options.memoryDirs)
186 return { text: await summaryText($) }
187 }
188 return { text: USAGE }
189 })
190
191 on('tool.call', async ($, e, next) => {
192 let matches: Match[] = []
193 try {
194 const memories = await read($, memoriesAtom)
195 if (memories.length > 0) {
196 const text = callText(e.tool, argumentsOf(e as unknown as Record<string, unknown>))
197 // A call on the memory files themselves (a read, an edit, the engine's own memory writer) is not about them.
198 const isOnMemories = touchesSources(text, await read($, sourcesAtom), await $.env.get('HOME'))
199 matches = isOnMemories ? [] : findMatches(memories, text, await read($, attachedAtom))
200 }
201 } catch {
202 matches = []
203 }
204 const ran = await next(e)
205 if (matches.length === 0 || ran.deny !== undefined) return ran
206 let claimed: Match[]
207 try {
208 claimed = await recordPings($, e.tool, matches, isToastOn)
209 } catch {
210 return ran
211 }
212 if (claimed.length === 0) return ran
213 const notes = claimed.map(match => contextText(match.memory, match.reason, match.hits))
214 return { ...ran, context: [...(ran.context ?? []), ...notes] } as typeof ran
215 })
216
217 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
218 const { Box, Text, Button } = $.ui.resolve(e)
219 const memories = await read($, memoriesAtom)
220 const problems = await read($, problemsAtom)
221 const sources = await read($, sourcesAtom)
222 const pings = await read($, pingsAtom)
223 const count = await read($, pingCountAtom)
224 const width = Math.max(24, e.props.bodyColumns)
225 const scope = pixelRows(sweepGrid(count, Math.min(3, pings.length)))
226
227 return (
228 <Box flexDirection="column" width={width}>
229 <Box flexDirection="row" gap={2}>
230 <Box flexDirection="column">
231 {scope.map((runs, y) => (
232 <Box key={`scope-row-${y}`} flexDirection="row">
233 {runs.map(cell => (
234 <Text color={cell.color} backgroundColor={cell.backgroundColor}>
235 {cell.text}
236 </Text>
237 ))}
238 </Box>
239 ))}
240 </Box>
241 <Box flexDirection="column" flexShrink={1}>
242 <Text bold color={PALETTE.v}>
243 {titleText(memories.length)}
244 </Text>
245 <Text color={PALETTE.i}>{`${count} ${count === 1 ? 'PING' : 'PINGS'} THIS SESSION`}</Text>
246 <Text color={PALETTE.d}>SCANNING</Text>
247 {sources.map(dir => (
248 <Text color={PALETTE.l} wrap="truncate-start">
249 {dir}
250 </Text>
251 ))}
252 <Box flexDirection="row" marginTop={1}>
253 <Button key="radar-reload" label="RELOAD" onPress={() => loadIndex($, options.memoryDirs)} />
254 </Box>
255 </Box>
256 </Box>
257 <Text color={PALETTE.d}>{'─'.repeat(Math.min(width, 60))}</Text>
258 {pings.length === 0 && <Text color={PALETTE.l}>NO PINGS YET · THE SCOPE IS QUIET</Text>}
259 {[...pings].reverse().map((ping, i) => (
260 <Box key={`ping-${i}`} flexDirection="row">
261 <Text color={PALETTE.d}>{`${clockText(ping.at)} ${ping.tool.toUpperCase()} `}</Text>
262 <Text color={ping.reason === 'trigger' ? PALETTE.i : PALETTE.v}>{ping.reason === 'trigger' ? '◆ ' : '● '}</Text>
263 <Text color={PALETTE.w} wrap="truncate-end">
264 {ping.memory}
265 </Text>
266 </Box>
267 ))}
268 {problems.length > 0 && (
269 <Box flexDirection="column" marginTop={1}>
270 <Text bold color={PALETTE.o}>{`SKIPPED: ${problems.length}`}</Text>
271 {problems.map(problem => (
272 <Text color={PALETTE.o} wrap="truncate-end">{`▸ ${problem}`}</Text>
273 ))}
274 </Box>
275 )}
276 </Box>
277 )
278 })
279}
280hooks/match.ts 122 lines1// Deterministic matching of a tool call against the memory index: explicit
2// trigger regexes first, then overlap of distinctive keywords.
3import type { RadarMemory } from '../types'
4import type { ParsedMemory } from './memory'
5
6export type Match = {
7 memory: RadarMemory
8 reason: 'trigger' | 'keywords'
9 /** The shared keywords, in the call's order (empty for a trigger). */
10 hits: string[]
11}
12
13export const MAX_PER_CALL = 2
14const MIN_SHARED = 2
15const MAX_CALL_TEXT = 4000
16
17const STOP_WORDS = new Set(
18 (
19 'the and for with that this from into are was were has have had not but you your our its can will would should ' +
20 'could use used using uses when then than what which who how why all any each every one two new old get set run ' +
21 'runs ran make made does did done just only also more most very like out over after before about there here them ' +
22 'they their these those some such own same other via per too may might must never always dont don do does ask ' +
23 'first last file files thing things way ways need needs want wants keep kept see note notes memory memories rule ' +
24 'rules true false null none yes because while still even ever again back down off now yet well good bad know ' +
25 'http https www com org net mcp dev tmp usr bin var etc home users user'
26 ).split(' '),
27)
28
29/** Folds a plain plural ("worktrees") onto its singular; leaves "glass" alone. */
30const stem = (word: string): string => (word.length > 4 && word.endsWith('s') && !word.endsWith('ss') ? word.slice(0, -1) : word)
31
32export const tokenize = (text: string): string[] =>
33 text
34 .toLowerCase()
35 .split(/[^a-z0-9]+/)
36 .filter(word => word.length >= 3 && !/^\d+$/.test(word) && !STOP_WORDS.has(word))
37 .map(stem)
38 .filter(word => !STOP_WORDS.has(word))
39
40const PATH_KEYS = ['file_path', 'notebook_path', 'path', 'url', 'query', 'pattern'] as const
41
42const stringsOf = (value: unknown, out: string[]): void => {
43 if (typeof value === 'string') out.push(value)
44 else if (Array.isArray(value)) for (const item of value) stringsOf(item, out)
45 else if (typeof value === 'object' && value !== null) for (const item of Object.values(value)) stringsOf(item, out)
46}
47
48/**
49 * What a call is about: Bash's command, a file path, a URL, an MCP tool's name
50 * and string arguments. Always cut to MAX_CALL_TEXT, so a long command cannot
51 * make the trigger regexes slow.
52 */
53export const callText = (tool: string, args: Readonly<Record<string, unknown>>): string => {
54 if (tool === 'Bash' || tool === 'PowerShell') return typeof args.command === 'string' ? args.command.slice(0, MAX_CALL_TEXT) : ''
55 if (tool.startsWith('mcp__')) {
56 const parts = [tool]
57 stringsOf(args, parts)
58 return parts.join('\n').slice(0, MAX_CALL_TEXT)
59 }
60 const parts = PATH_KEYS.map(key => args[key]).filter((value): value is string => typeof value === 'string')
61 return parts.join('\n').slice(0, MAX_CALL_TEXT)
62}
63
64/**
65 * Turns parsed memories into the index: each keeps the tokens of its name and
66 * description that at most max(3, 10%) of the memories share, so a word
67 * every memory uses never counts as a match.
68 */
69export const buildIndex = (memories: readonly ParsedMemory[]): RadarMemory[] => {
70 const tokensOf = memories.map(memory => [...new Set(tokenize(`${memory.name} ${memory.description}`))])
71 const frequency = new Map<string, number>()
72 for (const tokens of tokensOf) for (const token of tokens) frequency.set(token, (frequency.get(token) ?? 0) + 1)
73 const limit = Math.max(3, Math.ceil(memories.length / 10))
74 return memories.map((memory, i) => ({
75 ...memory,
76 keywords: (tokensOf[i] ?? []).filter(token => (frequency.get(token) ?? 0) <= limit),
77 }))
78}
79
80const escapeRegex = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
81
82/**
83 * Whether the call reads or writes inside one of the indexed memory folders
84 * (absolute, or `~/...` when the folder is under `home`). Such a call is
85 * about the memory files themselves, so nothing is matched against it.
86 */
87export const touchesSources = (text: string, dirs: readonly string[], home: string | undefined): boolean =>
88 dirs.some(dir => {
89 const forms = [dir]
90 if (home !== undefined && home !== '' && dir.startsWith(`${home}/`)) forms.push(`~${dir.slice(home.length)}`)
91 return forms.some(form => new RegExp(`(?<![\\w.~/-])${escapeRegex(form)}(?=$|[/\\s'"\`])`).test(text))
92 })
93
94const triggerHit = (memory: RadarMemory, text: string): boolean =>
95 memory.triggers.some(source => {
96 try {
97 return new RegExp(source, 'i').test(text)
98 } catch {
99 return false
100 }
101 })
102
103/** The memories this call is about, best first, at most `limit`, skipping `exclude`d files. */
104export const findMatches = (
105 index: readonly RadarMemory[],
106 text: string,
107 exclude: readonly string[],
108 limit = MAX_PER_CALL,
109): Match[] => {
110 if (text.trim() === '') return []
111 const open = index.filter(memory => !exclude.includes(memory.file))
112 const triggered: Match[] = open.filter(memory => triggerHit(memory, text)).map(memory => ({ memory, reason: 'trigger', hits: [] }))
113 const callTokens = [...new Set(tokenize(text))]
114 const scored = open
115 .filter(memory => !triggered.some(hit => hit.memory.file === memory.file))
116 .map((memory, order) => ({ memory, order, hits: callTokens.filter(token => memory.keywords.includes(token)) }))
117 .filter(entry => entry.hits.length >= MIN_SHARED)
118 .sort((a, b) => b.hits.length - a.hits.length || a.order - b.order)
119 .map((entry): Match => ({ memory: entry.memory, reason: 'keywords', hits: entry.hits }))
120 return [...triggered, ...scored].slice(0, limit)
121}
122hooks/memory.ts 162 lines1// Reads one memory file: frontmatter `name`, `description` and optional
2// `triggers:` (regexes), plus body lines that state a rule. Never throws.
3
4export type ParsedMemory = {
5 file: string
6 name: string
7 description: string
8 triggers: string[]
9 rules: string[]
10}
11
12export type MemoryParse = { memory: ParsedMemory } | { problem: string } | { skip: true }
13
14const MAX_RULES = 5
15const MAX_RULE_LENGTH = 240
16const RULE_WORDS = /\b(never|always|don'?t|do not|must)\b/i
17
18const baseName = (path: string) => path.slice(path.lastIndexOf('/') + 1)
19
20class Malformed extends Error {}
21
22/** Where a quoted scalar starting at 0 closes, or -1. */
23const closingQuote = (text: string, quote: string): number => {
24 for (let i = 1; i < text.length; i++) {
25 const char = text[i]
26 if (quote === '"' && char === '\\') i++
27 else if (char === quote) {
28 if (quote === "'" && text[i + 1] === "'") i++
29 else return i
30 }
31 }
32 return -1
33}
34
35/** One YAML scalar: plain, 'single' ('' escapes a quote) or "double" (\" and \\ escape); a trailing # comment is dropped. */
36const unquote = (raw: string): string => {
37 const text = raw.trim()
38 const quote = text[0]
39 if (quote === "'" || quote === '"') {
40 const close = closingQuote(text, quote)
41 if (close < 0) throw new Malformed('has an unclosed quote')
42 const rest = text.slice(close + 1)
43 if (rest.trim() !== '' && !/^\s+#/.test(rest)) throw new Malformed('has text after a closing quote')
44 const inner = text.slice(1, close)
45 return quote === "'" ? inner.replace(/''/g, "'") : inner.replace(/\\(["\\])/g, '$1')
46 }
47 return text.replace(/\s+#.*$/, '')
48}
49
50/** `>` / `|` with optional chomping and indent digits: a block scalar header. */
51const BLOCK_SCALAR = /^[|>][+-]?\d*[+-]?$/
52
53/**
54 * A group that repeats something already repeated, such as `(\S+\s*)+` or
55 * `(a*)*`: the shape that backtracks exponentially. Matching runs on every
56 * tool call and cannot be interrupted, so such a trigger is refused.
57 */
58const NESTED_QUANTIFIER = /\((?:[^()]*(?:[+*]|\{\d*,\d*\}))[^()]*\)(?:[+*]|\{\d*,)/
59
60/** `[a, 'b', "c"]`: splits on commas outside quotes. */
61const flowList = (raw: string): string[] => {
62 const inner = raw.trim().slice(1, -1)
63 const items: string[] = []
64 let current = ''
65 let quote: string | undefined
66 for (const char of inner) {
67 if (quote !== undefined) {
68 if (char === quote) quote = undefined
69 } else if (char === "'" || char === '"') {
70 quote = char
71 } else if (char === ',') {
72 items.push(current)
73 current = ''
74 continue
75 }
76 current += char
77 }
78 if (quote !== undefined) throw new Malformed('has an unclosed quote')
79 if (current.trim() !== '') items.push(current)
80 return items.map(unquote).filter(item => item !== '')
81}
82
83const cleanRule = (line: string): string =>
84 line
85 .trim()
86 .replace(/^(?:[-*+>]|\d+[.)])\s+/, '')
87 .trim()
88 .slice(0, MAX_RULE_LENGTH)
89
90const rulesOf = (body: readonly string[]): string[] =>
91 body
92 .filter(line => RULE_WORDS.test(line))
93 .map(cleanRule)
94 .filter(line => line !== '')
95 .slice(0, MAX_RULES)
96
97export const parseMemory = (file: string, text: string): MemoryParse => {
98 const lines = text.replace(/^\uFEFF/, '').split(/\r?\n/)
99 if (lines[0]?.trim() !== '---') return { skip: true }
100 const close = lines.findIndex((line, i) => i > 0 && line.trim() === '---')
101 const problem = (reason: string): MemoryParse => ({ problem: `${baseName(file)}: ${reason}` })
102 if (close < 0) return problem('frontmatter is not closed with ---')
103
104 const front = lines.slice(1, close)
105 const fields: Record<string, string> = {}
106 const triggers: string[] = []
107 try {
108 let listKey: string | undefined
109 let block: { key: string; isFolded: boolean; lines: string[] } | undefined
110 const endBlock = () => {
111 if (block !== undefined) fields[block.key] = block.lines.join(block.isFolded ? ' ' : '\n')
112 block = undefined
113 }
114 for (const [offset, line] of front.entries()) {
115 const number = offset + 1
116 if (block !== undefined && (line.trim() === '' || /^\s/.test(line))) {
117 if (line.trim() !== '') block.lines.push(line.trim())
118 continue
119 }
120 if (line.trim() === '' || line.trim().startsWith('#')) continue
121 if (/^\s/.test(line)) {
122 const item = /^\s+-\s*(.*)$/.exec(line)
123 if (listKey === 'triggers' && item) triggers.push(unquote(item[1] ?? ''))
124 continue
125 }
126 endBlock()
127 const pair = /^([A-Za-z_][\w-]*)\s*:\s*(.*)$/.exec(line)
128 if (!pair) throw new Malformed(`line ${number} is not "key: value"`)
129 const key = pair[1] ?? ''
130 const value = (pair[2] ?? '').trim()
131 listKey = value === '' ? key : undefined
132 if (BLOCK_SCALAR.test(value.replace(/\s+#.*$/, ''))) {
133 if (key === 'triggers') throw new Malformed('triggers is a block scalar (| or >), not a list')
134 block = { key, isFolded: value.startsWith('>'), lines: [] }
135 } else if (key === 'triggers') {
136 if (value.startsWith('[') && value.endsWith(']')) triggers.push(...flowList(value))
137 else if (value !== '') triggers.push(unquote(value))
138 } else if (value !== '') {
139 fields[key] = unquote(value)
140 }
141 }
142 endBlock()
143 } catch (error) {
144 return problem(error instanceof Malformed ? error.message : 'frontmatter could not be read')
145 }
146
147 const name = fields.name?.trim() ?? ''
148 const description = fields.description?.trim() ?? ''
149 if (name === '') return problem('frontmatter has no name')
150 if (description === '') return problem('frontmatter has no description')
151 for (const trigger of triggers) {
152 try {
153 new RegExp(trigger, 'i')
154 } catch {
155 return problem(`bad trigger regex ${JSON.stringify(trigger)}`)
156 }
157 if (NESTED_QUANTIFIER.test(trigger)) return problem(`trigger ${JSON.stringify(trigger)} nests a repeat inside a repeat, which can hang matching`)
158 }
159
160 return { memory: { file, name, description, triggers, rules: rulesOf(lines.slice(close + 1)) } }
161}
162hooks/pixels.ts 105 lines1/**
2 * Half-block pixel art: a sprite is a grid of palette keys ('.' is
3 * transparent); two pixel rows fold into one text row, the top pixel as the
4 * `▀`'s color and the bottom one as its background.
5 */
6
7/** PICO-8, keyed by one letter. */
8export const PALETTE = {
9 k: '#000000', // black
10 n: '#1D2B53', // navy: the scope's glass
11 m: '#7E2553', // plum
12 g: '#008751', // green
13 b: '#AB5236', // brown
14 d: '#5F574F', // dark grey: crosshair and range ring
15 l: '#C2C3C7', // light grey: the beam
16 w: '#FFF1E8', // white
17 r: '#FF004D', // red
18 o: '#FFA300', // orange
19 y: '#FFEC27', // yellow
20 e: '#00E436', // lime
21 u: '#29ADFF', // blue
22 v: '#83769C', // lavender: radar's signature
23 i: '#FF77A8', // pink: a ping
24 c: '#FFCCAA', // peach
25} as const
26
27export type Run = { text: string; color?: string; backgroundColor?: string }
28
29const colorOf = (key: string | undefined): string | undefined =>
30 key === undefined || key === '.' ? undefined : (PALETTE as Record<string, string>)[key]
31
32const cellOf = (top: string | undefined, bottom: string | undefined): Run => {
33 if (top && bottom) return { text: '▀', color: top, backgroundColor: bottom }
34 if (top) return { text: '▀', color: top }
35 if (bottom) return { text: '▄', color: bottom }
36 return { text: ' ' }
37}
38
39/** Folds a grid into text rows of styled runs, merging neighbours of one style. */
40export const pixelRows = (grid: readonly string[]): Run[][] => {
41 const rows: Run[][] = []
42 const width = Math.max(0, ...grid.map(line => line.length))
43 for (let y = 0; y < grid.length; y += 2) {
44 const runs: Run[] = []
45 for (let x = 0; x < width; x += 1) {
46 const cell = cellOf(colorOf(grid[y]?.[x]), colorOf(grid[y + 1]?.[x]))
47 const last = runs[runs.length - 1]
48 const isSame = last !== undefined && last.text[0] === cell.text && last.color === cell.color && last.backgroundColor === cell.backgroundColor
49 if (last && isSame) last.text += cell.text
50 else runs.push({ ...cell })
51 }
52 rows.push(runs)
53 }
54 return rows
55}
56
57const SIZE = 13
58const CENTER = 6
59/** Where up to three recent pings glow, inside the glass. */
60const BLIPS: readonly (readonly [number, number])[] = [
61 [9, 4],
62 [3, 8],
63 [8, 9],
64]
65
66const beamCells = (frame: number): [number, number][] => {
67 const angle = ((((frame % 8) + 8) % 8) * Math.PI) / 4
68 const cells: [number, number][] = []
69 for (let r = 1; r <= 5; r += 1) {
70 cells.push([CENTER + Math.round(Math.cos(angle) * r), CENTER - Math.round(Math.sin(angle) * r)])
71 }
72 return cells
73}
74
75/**
76 * The scope: a 13 x 13 lavender-rimmed navy disc with a crosshair and range
77 * ring, the beam at frame * 45 degrees with a lavender trail behind it, and up
78 * to three pink blips; one transparent row under it makes 7 text rows.
79 */
80export const sweepGrid = (frame: number, blips: number): string[] => {
81 const grid: string[][] = []
82 for (let y = 0; y < SIZE; y += 1) {
83 const row: string[] = []
84 for (let x = 0; x < SIZE; x += 1) {
85 const distance = Math.hypot(x - CENTER, y - CENTER)
86 if (distance > 6.5) row.push('.')
87 else if (distance > 5.5) row.push('v')
88 else if (x === CENTER || y === CENTER || (distance > 2.5 && distance <= 3.4)) row.push('d')
89 else row.push('n')
90 }
91 grid.push(row)
92 }
93 const paint = (cells: readonly (readonly [number, number])[], key: string) => {
94 for (const [x, y] of cells) {
95 const row = grid[y]
96 if (row !== undefined && x >= 0 && x < SIZE) row[x] = key
97 }
98 }
99 paint(beamCells(frame - 1), 'v')
100 paint(beamCells(frame), 'l')
101 paint([[CENTER, CENTER]], 'w')
102 paint(BLIPS.slice(0, Math.max(0, Math.min(3, blips))), 'i')
103 return [...grid.map(row => row.join('')), '.'.repeat(SIZE)]
104}
105hooks/text.ts 59 lines1// Words radar shows: the model's context note, the status line, the pane's
2// rows, and where the memory folders are.
3import type { RadarMemory, RadarPing } from '../types'
4import type { Match } from './match'
5
6export const expandHome = (path: string, home: string | undefined): string =>
7 home !== undefined && (path === '~' || path.startsWith('~/')) ? `${home}${path.slice(1)}` : path
8
9/** `userConfig.memoryDirs`: folders separated by commas or newlines. */
10export const memoryDirsOf = (option: unknown, home: string | undefined): string[] =>
11 typeof option !== 'string'
12 ? []
13 : option
14 .split(/[,\n]/)
15 .map(part => part.trim())
16 .filter(part => part !== '')
17 .map(part => expandHome(part, home).replace(/(.)\/+$/, '$1'))
18
19/** The engine's folder name for a project: every non-alphanumeric character becomes a dash. */
20export const projectSlug = (root: string): string => root.replace(/[^a-zA-Z0-9]/g, '-')
21
22/** autoMemoryDirectory from settings, else <config dir>/projects/<slug>/memory. */
23export const defaultMemoryDir = (
24 settings: Readonly<Record<string, unknown>>,
25 home: string | undefined,
26 configDir: string | undefined,
27 root: string,
28): string => {
29 const configured = settings.autoMemoryDirectory
30 if (typeof configured === 'string' && configured.trim() !== '') return expandHome(configured.trim(), home)
31 const base = configDir !== undefined && configDir !== '' ? configDir : `${home ?? '~'}/.claude`
32 return `${base}/projects/${projectSlug(root)}/memory`
33}
34
35export const contextText = (memory: RadarMemory, reason: Match['reason'], hits: readonly string[] = []): string => {
36 const why = reason === 'trigger' ? 'trigger match' : `keyword match: ${hits.join(', ')}`
37 const lines = [`radar: your memory "${memory.name}" (${memory.file}) is about this call (${why}).`, memory.description]
38 if (memory.rules.length > 0) lines.push('Rules:', ...memory.rules.map(rule => `- ${rule}`))
39 return lines.join('\n')
40}
41
42const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? '' : 'S'}`
43
44export const statusText = (memories: number, pings: number): string => `RADAR ${memories} ◉ ${plural(pings, 'PING')}`
45
46const two = (value: number) => String(value).padStart(2, '0')
47
48export const clockText = (ms: number): string => {
49 const at = new Date(ms)
50 return `${two(at.getHours())}:${two(at.getMinutes())}`
51}
52
53export const pingText = (ping: RadarPing): string => `${clockText(ping.at)} ${ping.tool.toUpperCase()} ${ping.memory}`
54
55/** Newest first. */
56export const pingsText = (pings: readonly RadarPing[]): string[] => [...pings].reverse().map(pingText)
57
58export const titleText = (memories: number): string => `RADAR · ${memories} ${memories === 1 ? 'MEMORY' : 'MEMORIES'}`
59types/index.d.ts 44 lines1// radar's $.state contract. The index and the pings live for the session and
2// survive a hot reload; nothing goes to $.store and no memory file is written.
3
4export type RadarMemory = {
5 /** The file's absolute path: the memory's identity. */
6 file: string
7 name: string
8 description: string
9 /** Regex sources from the frontmatter's `triggers:`, each one compiled once to check it. */
10 triggers: string[]
11 /** Body lines that say never / always / don't / must. */
12 rules: string[]
13 /** Distinctive tokens of the name and description (the ones few memories share). */
14 keywords: string[]
15}
16
17export type RadarPing = {
18 /** When, in ms since the epoch. */
19 at: number
20 tool: string
21 /** The memory's name. */
22 memory: string
23 /** `trigger` or `keywords`: which rule matched. */
24 reason: 'trigger' | 'keywords'
25}
26
27declare module 'claude-code' {
28 interface PluginState {
29 radar: {
30 memories: RadarMemory[]
31 /** Files skipped at the last index, with the reason. */
32 problems: string[]
33 /** The folders the last index read. */
34 sources: string[]
35 /** Newest last, at most 20. */
36 pings: RadarPing[]
37 /** Every ping this session. */
38 pingCount: number
39 /** Files attached during the main loop's current turn. */
40 attached: string[]
41 }
42 }
43}
44