SLOPSHOPPER

radar

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…

newpaneguardcommandtoaststatus
v0.1.0MITupdated 2026-10-04pourya7/claude-code-mods/radar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · radar
│ ┃ RADAR · 0 MEMORIES ✕ › fix the failing auth test and add an audit log call │ ┃ ▄▄▀▀▀▀▀▄▄ RADAR · 0 MEMORIES │ ┃ ▀▀▀▀▀▀▀▀▀▀▀ 0 PINGS THIS SESSION ⏺ Read(src/auth.ts) │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀ SCANNING ⎿ Read 6 lines │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀ /Users/dev/.claude/projects/- ⏺ Update(src/auth.ts) │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀ ⎿ Added 2 lines, removed 1 line │ ┃ ▀▀▀▀▀▀▀▀▀▀▀ [ RELOAD ] ⏺ Bash(bun test) │ ┃ ▀▀▀▀▀ ⎿ 3 pass, 1 fail │ ┃ ──────────────────────────────────────────── │ ┃ NO PINGS YET · THE SCOPE IS QUIET ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /radar │ ⎿ radar: RADAR · 0 MEMORIES │ ⎿ radar: 0 memories · 0 pings this session │ ⎿ radar: Folders: /Users/dev/.claude/projects/-work-app/memory │ ⎿ radar: no memory files with name/description frontmatter found │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ radar: RADAR 0 ◉ 0 PINGS

Draws

Pane · RADAR · 0 MEMORIES
▄▄▀▀▀▀▀▄▄ RADAR · 0 MEMORIES ▀▀▀▀▀▀▀▀▀▀▀ 0 PINGS THIS SESSION ▀▀▀▀▀▀▀▀▀▀▀▀▀ SCANNING ▀▀▀▀▀▀▀▀▀▀▀▀▀ /Users/dev/.claude/projects/-work-app/memory ▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀ [ RELOAD ] ▀▀▀▀▀ ──────────────────────────────────────────────────────── NO PINGS YET · THE SCOPE IS QUIET
README

radar

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

radar pinging a memory on git diff, and the model backing the file up before git checkout --

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.

Install

/plugin marketplace add pourya7/claude-code-mods
/plugin install radar@claude-code-mods

How it behaves

  • Sources. radar reads every *.md file directly inside each memory folder (subfolders are not searched):
  • userConfig.memoryDirs, if you set it;
  • otherwise autoMemoryDirectory from your settings;
  • otherwise ~/.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.
  • A memory is a file whose frontmatter has name and description. It can also have:
  • triggers:, a list of regexes. They are case-insensitive. Use single quotes so backslashes survive.
  • rule lines: any line in the body that contains never, always, don't, do not or must. radar keeps up to 5 of them, each cut to 240 characters.
  ---
  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.
  • Not a memory. A file with no frontmatter at all, such as the 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.
  • Indexing. radar reads the folders on 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.
  • What a call is about:
  • Bash: the command.
  • Edit, Write, Read, NotebookEdit, Glob and Grep: the path and the pattern.
  • WebFetch and WebSearch: the URL and the query.
  • MCP tools (mcp__*): the tool's name and every string in its arguments.
  • Any other tool: only its file_path, notebook_path, path, url, query and pattern arguments. Agent and Skill have none of these, so their prompts are never matched.
  • Matching is deterministic. No model is called. For each call:
  • Triggers first. A memory whose trigger regex matches the call's text is a match.
  • Then keywords. radar splits the name and description into words. It drops stop-words, words shorter than 3 letters, numbers, and words that more than max(3, 10%) of your memories share. A memory matches when the call shares at least 2 of its remaining words. Memories that share more words rank higher.
  • Attaching. The call always runs first (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.
  • Calls on the memory files are left alone. A call whose text names a path inside one of the indexed folders (absolute or ~/...), 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.
  • Once per turn. A memory is attached at most once per main-loop turn. Subagent calls are matched too, and they count toward the same turn. A call that another hook refuses gets nothing.
  • Pings. Each attachment toasts 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.

Commands

CommandWhat it does
/radarOpens the RADAR pane and replies with the same summary as text.
/radar listThe summary as text only: folders, memories, recent pings and skipped files.
/radar reloadReads the memory folders again.

Configuration (userConfig)

FieldTypeDefaultMeaning
memoryDirsstring""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.
toastbooleantrueToast 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 UI

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.

Permissions

NetworkRuns processesFilesCalls a modelAuto-submits promptsData 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.

Limits

  • After, not before. The memory arrives with the call's result, so the model reads it after that call has run. It shapes the next step, but it cannot stop the call. To block a call, use a rule enforcer such as tripwire.
  • Keywords are a heuristic. A memory whose words don't appear in the command, path or URL will be missed, and a memory that shares two common words can be attached when it doesn't apply. Add 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.
  • The default folder is worked out from the session's project root. In a linked git worktree that can differ from the folder the engine itself uses for memory. If radar shows 0 memories there, set memoryDirs.
  • Frontmatter is a small subset of YAML: top-level 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.
  • Size. radar reads at most 2,000 files across all folders and only reads top-level files. A file over 4 MiB cannot be read and is reported as skipped. Every call's text, Bash commands included, is cut to its first 4,000 characters before matching, so a trigger only sees that much. The nested-repeat check on triggers is a heuristic: keep trigger regexes simple.
  • Reload. A hot reload or a change to radar's /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.

Development

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.

Source 6 files
hooks/register.tsx 280 lines
1import { 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}
280
hooks/match.ts 122 lines
1// 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}
122
hooks/memory.ts 162 lines
1// 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}
162
hooks/pixels.ts 105 lines
1/**
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}
105
hooks/text.ts 59 lines
1// 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'}`
59
types/index.d.ts 44 lines
1// 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