Agent memory that works on its own: the mistake-brain skill and its hooks ship inside (log, route, promote, dream, recall), and a pixel Claude with a brain…

Seven mods, each installed on its own. They also work side by side: each draws its part of the band above the prompt, and the others' parts stay.
| Mod | What it does |
|---|---|
| clawd-hud | A pixel spark above the prompt, usage batteries, a prompt-cache timer and a context meter |
| task-hud | A to-do progress bar, and a pane with a pixel creature for each sub-agent |
| followups | Claude flags what it notices, asks whether to track it, and keeps a to-do you can export |
| rtl-toggle | Fixes the reading order of lines that mix English and Hebrew |
| goal-run | Asks for a goal when a session starts, has Claude plan it, and runs a pixel Claude to 100% as the tasks get done |
| brain-hud | Agent memory with its skill built in: mistake-brain logs and routes lessons, a pixel Claude with a brain reacts to each entry, and a pane draws the memory as a graph |
| factory | /factory <feature>: a spec, your approval of its plan, a build on its own branch, checks, parallel reviews and an approver, then Claude opens the PR |
Add the marketplace once, then install the ones you want:
/plugin marketplace add Amitro123/claudemod
/plugin install <mod>@claudemod
The installer may say some options are "not yet set". That's fine: every option has a default, and the mods run on those until you change one with /plugin configure <mod>@claudemod.
If you added this marketplace before it was renamed from clawd-hud, remove it with /plugin marketplace remove clawd-hud, add it again, and reinstall your mods as <mod>@claudemod.

A Claude Code plugin that puts a little animated spark above the prompt, with a pixel battery for each of your usage limits, and adds a prompt-cache timer and a context meter beside the model picker. It works on personal plans and on enterprise seats.
Above the prompt (the AbovePrompt band)
| Mood | When | Scenes |
|---|---|---|
idle | waiting for you | drifting with a firefly, juggling, watering a sprout |
working | a turn is running | at the terminal, stacking blocks, pondering, sending out sub-agents |
working > 90s | a hard task | a smoke break on a crate joins the rotation |
loop | the same tool with the same input 3× in one turn | riding a coaster loop it can't get out of |
done | the turn answered | fireworks and a hop (a lightbulb if the task was hard) |
relax | 4 min after done | stargazing on a hill, a pool float, cocoa by a snowy window |
error | API error or refusal | goes gray under a little storm cloud, then idle |
| Personal (Pro / Max) | Enterprise | |
|---|---|---|
| Batteries | 5h window, 7d weekly | 7d weekly, mo monthly spend cap ("over by …" past 100%) |
| Cost | not shown | $ spent this session |
| "resets in" | the API's reset time | the API's reset time; for the spend cap without one, the 1st of next month |
Cache timer (auto) | 1 hour | 5 minutes |
Beside the model picker (the SessionMode slot)
ctx 34% · 68k/200k.In a terminal (which can't draw SVG) the same readings show up as a text spark ✦(•_•) and ▰▰▰▱▱ batteries. A one-line summary also goes to the status bar.
The spark shares both places with the other mods: what they draw in the band shows under its row, and whatever sits beside the model picker stays next to the bolt and capsule.
/plugin install clawd-hud@claudemod
Set with claude plugin configure clawd-hud@claudemod, or in the plugin settings:
| Option | Values | Default | What it does |
|---|---|---|---|
plan | auto, personal, enterprise | auto | Which limits to show. auto switches to enterprise as soon as the account reports a monthly spend limit (spend_limit). |
cacheTtl | auto, 5m, 1h | auto | The cache timer's length. auto is 1h on personal plans and 5m on enterprise. |
hooks/register.tsx hooks session.start, session.measure, turn.start, tool.call, turn.complete and ui.render, keeps its state in atoms, and redraws the spark 8 times a second while it moves (working, the loop, fireworks, the storm) and less often while it idles or rests: 4 times a second for the SVG scenes, once a second for the terminal's text face.hooks/scene.ts paints one frame of a mood on an 88×20 pixel stage. Every motion is a plain function of the time since the mood began, so a redraw lands on the same frame and never restarts an animation.hooks/gauges.ts draws the batteries, the bolt and the capsule as plain SVG, so the demo renderer uses the exact same art.tests/plan.test.tsx checks the personal and enterprise views, the spend-cap overflow and both cache timers. Run it with claude plugin test plugins/clawd-hud.0.6.2: the idle and resting SVG scenes redraw 4 times a second instead of once, so they move smoothly; the terminal stays at once a second. The terminal face's idle blink shows again (every 4 seconds).
0.6.1: plays well with the other mods: their bands, and other changes beside the model picker, are no longer hidden. Idle redraws drop from 8 a second to 1. No "clawd-hud loaded" toast on every start.
0.6.0: a new look. The spark mascot, its scenes, the stage and the batteries, bolt and capsule are all new, drawn by a new animation engine. Everything else carries over: plans, timers, loop detection, the hard-task and eureka moments.
0.5.0: renamed from pixel-buddy to clawd-hud (install again; old settings don't carry over). Enterprise mode. The loop detector's tool.call hook got a registration .catch, so it can never block a tool call.
0.4.1: only a real answer celebrates (a refusal gets the error mood). The cache countdown flips to "cold" even after the machine slept. Timers no longer stack up when session.start fires again. No more "resets in NaNm".
0.4.0: the hard-task smoke break, the eureka lightbulb and the loop coaster. 0.3.0: the error mood.

<sub>The creatures, the task bar and the progress lines are drawn by the mod's own art.ts; the window around them is a mock-up of the desktop app.</sub>
/plugin install task-hud@claudemod
TodoWrite, or TaskCreate/TaskUpdate): it's titled with the prompt that started the work, fills as each to-do gets ticked off (Tasks 4/5 · 80%), and shows the one in progress (Now: Write the script). The title survives a reload of the mod. When it shares the band with clawd-hud, it draws above the spark. A finished list clears on your next prompt./task-hud. Each agent gets a pixel creature in its own color that walks while it works. Its card shows the task's description, the agent type and model (general-purpose · Opus 5.5), its tokens and run time, the current step (Step 3 · Reading pre-outline.md), the steps before it and a progress line. Cards are grouped into Running and Finished. A finished agent closes its eyes and lists what it did (Wrote script.md). A failed one turns gray. An agent sent back to work gets its card back, or a new one if its old card was already dropped. Clear drops the finished cards.Nobody knows ahead of time how many steps a sub-agent will take, so a running agent's line creeps toward 90% as it works and fills only when it finishes. The token count is the size of the agent's context on its latest request.
Tests: claude plugin test plugins/task-hud.

<sub>A mock-up of version 0.5.0, not a screenshot. The dialog is Claude Code's own question dialog, so it looks like the one in your app rather than exactly like this. The pane follows the mod's drawing: orange open, blue in progress, green done, 🐞 for a bug, and an item offered from a sub-agent's open questions. A dialog closed without an answer leaves the item in a band above the prompt (Add to to-do / Skip / Add all), which isn't shown here.</sub>
While Claude works, it often notices things outside the task: a flaky test, a stale doc, a bug in nearby code. Those get lost unless somebody writes them down. This mod writes them down.
/plugin install followups@claudemod
flag tool and a short system-prompt section on when to use it: things outside the current task that need attention later. Claude doesn't fix them unless you ask.autoDetect option (claude plugin configure followups@claudemod).Unsure-about, Open questions or Open items, as the model router's routed-worker writes), each line in it is offered in the band as soon as the report comes back. This needs no model, so it works even where the after-answer look can't run. A section that says none offers nothing, and the turn's end-of-turn check doesn't stop Claude again for items already offered.not verified, untested, known gap, open questions, לא נבדק, פער, שאלות פתוחות, ...; Open questions: none doesn't count; not follow-up, this mod's own word) and it never called flag that turn, the turn is stopped once with a nudge to flag each item or say there are none. It reads the reply whatever its source, so findings relayed from a subagent's report count; phrases in code blocks and inline code don't. It stops at most once per turn, never when flag was called (even if you turned the item down), and only in interactive sessions (not -p or the SDK). Limits: it is a phrase match, so a reply that leaves something open in other words is missed (the second look above still reads it), and a phrase used in passing can stop a turn needlessly. Add phrases to STOP_PHRASES in hooks/register.tsx; turn it off with the stopCheck option.a), Skip (s) and Add all, so it isn't lost.○ open, blue ◐ in progress, green struck-through ✓ done. A red 🐞 marks bugs (Claude passes kind: "bug" to flag for broken behavior; /followup adds a plain task). ✓ Done / ↺ Reopen toggles an item, ✕ removes it, and /followup <text> adds one by hand. The header counts open, in progress and done. The list is saved per project and comes back in the next session (lists saved by older versions are migrated). Two sessions open in the same project don't overwrite each other: each save merges with what the other saved, so items added, changed or removed in either one are kept that way. When the project has open or in-progress items, the pane opens by itself as the session starts; the openOnStart option turns that off. The list is saved on the machine running Claude Code, so it carries over between local sessions but not between cloud sessions, whose container is gone when they end: use Export to → Memory for that.done tool with that id and the item turns green. done is idempotent, rejects unknown ids, and the prompt tells Claude not to use it for unrelated work. If the message can't be sent, the items go back to open, so you can start them again. Pressing Add to to-do twice keeps the item once.| Button | Writes to | Why |
|---|---|---|
| Memory | CLAUDE.md | Claude reads it at the start of every session |
| Docs | docs/FOLLOW-UPS.md | a page of its own |
| README | README.md, under Known issues & follow-ups | visible to everyone |
The export is a markdown checklist ([ ] open, [x] done, in-progress items tagged _(in progress)_, bugs _(bug)_) between <!-- followups:start --> and <!-- followups:end --> markers. Exporting again replaces that section in place, and the rest of the file is left alone.
Tests: claude plugin test plugins/followups.
| Toggle off | Toggle on |
|---|---|
![]() | ![]() |
A line that starts in English but is mostly Hebrew (or Arabic, or another right-to-left script) can render out of order, with the Hebrew words shifted. This mod fixes those lines in your messages and Claude's.
/plugin install rtl-toggle@claudemod
עברית: פעיל / עברית: כבוי) turns the fix on or off for the session. /rtl does the same on every surface, and /rtl on, /rtl off and /rtl status set or report it.dir or alignment prop, so alignment comes only from the RLM. That works where the surface picks the direction per block (the desktop app). In a terminal it depends on the terminal's own bidi support. Messages already on screen keep their layout until they're drawn again.It also lives in its own repo, Amitro123/RTL-claude-mod, which has the full rules and the performance notes.
Tests: claude plugin test plugins/rtl-toggle.

<sub>Mock-ups, not screenshots: the track and the runner are drawn by the mod's own art.ts, and the band, the pane and the dialogs follow its layout. The dialogs are Claude Code's own, so yours will look like your app's.</sub>
/plugin install goal-run@claudemod

plan tool. Each task has a priority (high, medium or low) and the tasks it depends on. The plan is ordered highest priority first, except that a task comes after what it needs: tests come after the code they test, and say so (needs 1).update tool, and takes the first pending task in the list's current order. Each of your prompts while the goal is open carries the plan's state, so Claude keeps track of where it is.
add tool with a priority and what the task needs. The task goes in among the tasks that haven't started, by priority and after anything it needs. A dialog shows where it lands and what that does to the percentage (It goes in as #4, before "Document the endpoint". Progress 50% → 40%), and you choose Add it or Not now. Done and in-progress tasks stay where they are. The goal's text doesn't change, but the plan grows, so the percentage drops to match the work added./session-goal <text> sets a goal at any time, /session-goal shows it, and /session-goal clear drops it. Claude Code has its own /goal, so the mod's command has a different name.askOnStart (on / off) turns the start-of-session question off./factory plan-approved. If you already have a goal of your own that isn't done, it stays: the factory's plan isn't set as the goal, and the factory asks for approval in its own band. A factory plan starts fresh, without an earlier goal's done tasks.Tests: claude plugin test plugins/goal-run.

<sub>The scenes and the brain map are the mod's own SVG output; the windows around them are mock-ups. The data is a real project's memory, with one new lesson just routed into Projects (✦).</sub>
brain-hud gives Claude a memory that lasts between sessions, and shows it. The mistake-brain skill ships inside it, with its hooks. Claude logs what happened to MISTAKES.md (a mistake, a success, a decision or a handoff). It routes those entries into PARA folders under .agent-brain/shared/ (Projects, Areas, Resources, Archives), promotes a lesson that repeats into a rule (with your approval), and now and then reorganizes the memory ("dream"). The mod shows that memory as it grows. Nothing else needs installing.
/plugin install brain-hud@claudemod
Then, in each project that should remember: /brain-hud:mistake-brain init, or Set up memory in the Brain pane (/brain). It creates MISTAKES.md, the PARA folders, the index .agent-brain/shared/MEMORY.md and the standing rules. A CLAUDE.md tracked in git is left as it is; the instruction goes into .claude/rules/mistake-brain.md.
/brain-hud:mistake-brain whooks/register.tsx 491 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Brain, EntryType, Folder, Offer, Pulse, PulseKind } from '../types'
5import { faceText, KIND_COLOR, SCENE, sceneSvg } from './art'
6import { GRAPH, graphSvg, routeOf } from './graph'
7import type { Theme } from './graph'
8import { diffBrain, FOLDERS, SKILL, lessonCount, logPrompt, paraStats, parseMemory, parseMistakes, unrouted, withListing } from './brain'
9import type { ParaFile } from './brain'
10import { GOAL_REACHED, parseDoneResult, parseGoalClosedNote, parseGoalPrompt, parseStartPrompt, toolText } from './texts'
11
12const brain = atom({ plugin: 'brain-hud', key: 'brain' } as const, null as Brain | null)
13const pulse = atom({ plugin: 'brain-hud', key: 'pulse' } as const, null as Pulse | null)
14const frame = atom({ plugin: 'brain-hud', key: 'frame' } as const, 0)
15const offers = atom({ plugin: 'brain-hud', key: 'offers' } as const, [] as Offer[])
16const open = atom({ plugin: 'brain-hud', key: 'open' } as const, ['Inbox'] as string[])
17const canFollow = atom({ plugin: 'brain-hud', key: 'canFollow' } as const, false)
18const started = atom({ plugin: 'brain-hud', key: 'started' } as const, [] as { id: string; title: string; isBug: boolean }[])
19const goalText = atom({ plugin: 'brain-hud', key: 'goalText' } as const, '')
20const paneShown = atom({ plugin: 'brain-hud', key: 'paneShown' } as const, false)
21const more = atom({ plugin: 'brain-hud', key: 'more' } as const, '')
22const theme = atom({ plugin: 'brain-hud', key: 'theme' } as const, 'dark' as Theme)
23const routeLater = atom({ plugin: 'brain-hud', key: 'routeLater' } as const, 0)
24
25const PANE = 'brain-hud'
26const PANE_TITLE = 'Brain'
27const MISTAKES = 'MISTAKES.md'
28// The shared index first; projects set up before it was shared keep a per-harness one.
29const MEMORY_PATHS = ['.agent-brain/shared/MEMORY.md', '.claude/agent-memory/claude/MEMORY.md']
30const PARA_ROOT = '.agent-brain/shared'
31const POLL_MS = 4_000
32const PULSE_MS = 6_000
33const FRAME_MS = 160
34const INBOX_SHOWN = 3
35// Unrouted entries from which the band suggests routing them.
36const ROUTE_AT = 3
37const ACCENT = '#c96f97'
38
39const FOLLOWUPS_DONE = 'mcp__followups__done'
40const GOAL_PLAN = 'mcp__goal-run__plan'
41const GOAL_UPDATE = 'mcp__goal-run__update'
42
43const VERB: Record<PulseKind, string> = {
44 mistake: 'Logged a mistake',
45 success: 'Logged a success',
46 decision: 'Logged a decision',
47 handoff: 'Left a handoff',
48 route: 'Filed into memory',
49 promote: 'Promoted a rule',
50 dream: 'Dreamed',
51}
52
53const timers: { cancel: () => void }[] = []
54let anim: { cancel: () => void } | null = null
55// The files' last modification times: a change made outside a tool call (the skill's own hooks,
56// a script) is caught by the poll. A reload reads them again once, which shows nothing new.
57let mtimes = ''
58// Refreshes run one at a time, so an Edit and the poll that see the same change show it once.
59let refreshing: Promise<void> = Promise.resolve()
60
61const lessons = (n: number) => `${n} ${n === 1 ? 'lesson' : 'lessons'}`
62const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
63
64async function rootPath($: EngineInterface, rel: string): Promise<string> {
65 return `${(await $.session.root()).replace(/[\\/]$/, '')}/${rel}`
66}
67
68async function readOr($: EngineInterface, rel: string): Promise<string | null> {
69 return $.fs.read(await rootPath($, rel)).then(t => String(t), () => null)
70}
71
72// The memory as it stands on disk, or null where the project has no mistake-brain memory.
73async function scan($: EngineInterface): Promise<Brain | null> {
74 const mistakes = await readOr($, MISTAKES)
75 let memory: string | null = null
76 let index: string | null = null
77 for (const p of MEMORY_PATHS) {
78 memory = await readOr($, p)
79 if (memory !== null) {
80 index = p
81 break
82 }
83 }
84 if (mistakes === null && memory === null) return null
85 const parsed = parseMemory(memory ?? '')
86 // Each PARA file is read too, so its lessons are counted from the file itself.
87 const onDisk: Partial<Record<Folder, ParaFile[]>> = {}
88 for (const f of FOLDERS) {
89 const list = await $.fs.list(await rootPath($, `${PARA_ROOT}/${f}`)).catch(() => [])
90 const files: ParaFile[] = []
91 for (const x of list.filter(x => x.kind === 'file' && x.name.endsWith('.md'))) {
92 const text = await readOr($, `${PARA_ROOT}/${f}/${x.name}`)
93 files.push(text === null ? { file: x.name, lessons: -1, updated: '', summary: '' } : paraStats(x.name, text))
94 }
95 onDisk[f] = files
96 }
97 return { ...parsed, folders: withListing(parsed.folders, onDisk), entries: parseMistakes(mistakes ?? ''), index }
98}
99
100async function stamp($: EngineInterface): Promise<string> {
101 const parts: string[] = []
102 for (const p of [MISTAKES, ...MEMORY_PATHS]) {
103 const st = await $.fs.stat(await rootPath($, p)).catch(() => null)
104 parts.push(st ? `${st.mtimeMs}:${st.size}` : '-')
105 }
106 // The PARA files too: a lesson can land there with no change to the index.
107 for (const f of FOLDERS) {
108 const list = await $.fs.list(await rootPath($, `${PARA_ROOT}/${f}`)).catch(() => [])
109 parts.push(list.map(x => `${x.name}:${x.mtimeMs}:${x.size}`).sort().join(','))
110 }
111 return parts.join('|')
112}
113
114// Reads the memory again and shows what changed, if anything did.
115function refresh($: EngineInterface): Promise<void> {
116 refreshing = refreshing.then(() => reread($), () => reread($))
117 return refreshing
118}
119
120async function reread($: EngineInterface) {
121 mtimes = await stamp($)
122 const before = await read($, brain)
123 const after = await scan($)
124 await update($, brain, () => after)
125 // Routed down below the count Later or Route now saw: the reminder counts from there.
126 if (after) await update($, routeLater, n => Math.min(n, unrouted(after)))
127 const ev = after ? diffBrain(before, after) : null
128 if (ev) await show($, ev)
129}
130
131async function show($: EngineInterface, ev: Omit<Pulse, 'at'>) {
132 const t = await $.clock.now()
133 await update($, pulse, () => ({ ...ev, at: t }))
134 await update($, frame, () => t)
135 // The pane opens by itself once a session; after that the band alone shows a change.
136 if (!(await read($, paneShown))) {
137 await update($, paneShown, () => true)
138 void $.ui.open({ id: PANE, title: PANE_TITLE })
139 }
140 anim?.cancel()
141 anim = $.clock.every(FRAME_MS, async () => {
142 const now = await $.clock.now()
143 const p = await read($, pulse)
144 if (!p || now - p.at >= PULSE_MS) {
145 anim?.cancel()
146 anim = null
147 await update($, pulse, () => null)
148 return
149 }
150 await update($, frame, () => now)
151 })
152}
153
154async function offer($: EngineInterface, o: Offer) {
155 if (!(await read($, brain))) return
156 await update($, offers, list => (list.some(x => x.key === o.key) ? list : [...list, o].slice(-3)))
157}
158
159async function logIt($: EngineInterface, o: Offer, type: EntryType) {
160 await update($, offers, list => list.filter(x => x.key !== o.key))
161 // Not awaited: the prompt waits for the session to be idle, and the press shouldn't.
162 void $.prompt.submit({ text: logPrompt(type, o.title, o.detail) }).catch(async () => {
163 await update($, offers, list => (list.some(x => x.key === o.key) ? list : [o, ...list]))
164 $.ui.toast(`Could not ask Claude to log it; try again, or /${SKILL} log`)
165 })
166}
167
168// A follow-up Claude finished may be worth remembering: a bug as a mistake, else a success.
169async function followupDone($: EngineInterface, rawId: unknown, result: string) {
170 const m = parseDoneResult(result)
171 if (!m) return
172 const id = String(rawId ?? '')
173 const item = (await read($, started)).find(x => x.id === id)
174 const types: EntryType[] = item?.isBug ? ['mistake', 'success', 'decision'] : ['success', 'decision', 'mistake']
175 await offer($, { key: `fu:${id || m.title}`, from: 'followups', title: m.title, detail: m.note, types })
176}
177
178async function goalPlanned($: EngineInterface, rawGoal: unknown) {
179 const text = String(rawGoal ?? '').trim()
180 if (text) await update($, goalText, () => text)
181}
182
183// A reached goal: a success to repeat, a handoff for what comes next, or the decisions it took.
184async function goalUpdated($: EngineInterface, result: string) {
185 if (result.includes(GOAL_REACHED)) await offerGoal($, (await read($, goalText)) || 'the session goal')
186}
187
188// Routing is the skill's: run it as /brain-hud:mistake-brain route, or ask Claude to where the
189// command can't be run from here. The reminder then waits until more entries come in.
190async function routeNow($: EngineInterface, count: number) {
191 await update($, routeLater, () => count)
192 runSkill($, 'route', 'Route the unrouted MISTAKES.md entries')
193}
194
195// Sets up the memory in a project that has none: the skill's init.
196function setUp($: EngineInterface) {
197 runSkill($, 'init', "Set up mistake-brain memory in this project")
198}
199
200function runSkill($: EngineInterface, sub: string, ask: string) {
201 void $.command.run({ command: SKILL, args: sub }).catch(() =>
202 $.prompt.submit({ text: `${ask} with /${SKILL} ${sub}.` }).catch(() => {
203 $.ui.toast(`Could not start it; run /${SKILL} ${sub}`)
204 }),
205 )
206}
207
208// One reached-goal offer at a time, whichever signal comes first.
209async function offerGoal($: EngineInterface, text: string) {
210 await offer($, { key: 'goal', from: 'goal-run', title: `Reached the goal: ${text}`, detail: '', types: ['success', 'handoff', 'decision'] })
211}
212
213async function toFollowup($: EngineInterface, title: string) {
214 await $.command.run({ command: 'followup', args: title }).then(
215 () => $.ui.toast(`Added to the to-do list: ${clip(title, 60)}`),
216 () => $.ui.toast('The followups mod is needed to add it to a to-do list'),
217 )
218}
219
220async function checkFollow($: EngineInterface) {
221 if (await read($, canFollow)) return
222 const names = (await $.command.list().catch(() => [])).map(c => c.name)
223 if (names.includes('followup')) await update($, canFollow, () => true)
224}
225
226// The graph's palette follows the theme set in /config; dark when it can't be read.
227async function readTheme($: EngineInterface) {
228 const row = (await $.config.list().catch(() => [])).find(r => r.key === 'theme')
229 const value = String((row as { value?: unknown } | undefined)?.value ?? '')
230 await update($, theme, () => (/light/i.test(value) ? 'light' : 'dark'))
231}
232
233async function toggle($: EngineInterface, name: string) {
234 await update($, open, l => (l.includes(name) ? l.filter(x => x !== name) : [...l, name]))
235}
236
237export const register: Register = on => {
238 on('session.start', async ($, e, next) => {
239 for (const timer of timers.splice(0)) timer.cancel()
240 anim?.cancel()
241 anim = null
242 await $.command.register({ name: 'brain', description: "Show the agent's project memory (mistake-brain)" })
243 await readTheme($)
244 await refresh($)
245 timers.push($.clock.every(POLL_MS, async () => {
246 if ((await stamp($)) !== mtimes) await refresh($)
247 }))
248 return next(e)
249 })
250
251 on('session.end', async ($, e, next) => {
252 for (const timer of timers.splice(0)) timer.cancel()
253 anim?.cancel()
254 anim = null
255 return next(e)
256 }).catch(($, e, next) => next(e))
257
258 on('command.run', { command: 'brain' }, async ($) => {
259 await readTheme($)
260 await refresh($)
261 await $.ui.open({ id: PANE, title: PANE_TITLE })
262 return { text: (await read($, brain)) ? 'Opened the Brain pane.' : `No mistake-brain memory in this project yet: /${SKILL} init sets one up.` }
263 })
264
265 on('turn.start', async ($, e, next) => {
266 await checkFollow($)
267 // A theme changed in /config mid-session reaches the graph from the next prompt on.
268 await readTheme($)
269 return next(e)
270 }).catch(($, e, next) => next(e))
271
272 // A write to the memory shows at once; the poll catches the rest.
273 on('tool.call', async ($, e, next) => {
274 const r = await next(e)
275 if (!['Edit', 'Write', 'MultiEdit', 'NotebookEdit'].includes(e.tool)) return r
276 const path = String((e as unknown as { file_path?: unknown }).file_path ?? '')
277 if (/(^|[\\/])MISTAKES\.md$|[\\/]\.agent-brain[\\/]|[\\/]agent-memory[\\/]|[\\/]\.claude[\\/]rules[\\/]/.test(path)) await refresh($)
278 return r
279 }).catch(($, e, next) => next(e))
280
281 // ---------- the other mods ----------
282
283 // followups' Start names the items it hands Claude, remembered so their "done" can be offered;
284 // goal-run's start prompt names the goal.
285 on('prompt.submit', async ($, e, next) => {
286 if (e.origin.kind === 'plugin' && e.origin.name === 'followups') {
287 const items = parseStartPrompt(e.text).map(i => ({ id: i.id, title: i.title, isBug: Boolean(i.isBug) }))
288 if (items.length) await update($, started, l => [...l.filter(x => !items.some(i => i.id === x.id)), ...items].slice(-20))
289 }
290 if (e.origin.kind === 'plugin' && e.origin.name === 'goal-run') {
291 const goal = parseGoalPrompt(e.text)
292 if (goal) await update($, goalText, () => goal)
293 }
294 return next(e)
295 }).catch(($, e, next) => next(e))
296
297 // The other mods' tools are answered by their owners' tool.call hooks, which don't call next: a
298 // hook here on those tools runs only while brain-hud sits above the owner, an order the engine
299 // doesn't promise. So each one is also read from the classic PostToolUse event, which sees every
300 // call whatever the order; an offer is made once, whichever comes first.
301 on('tool.call', { tool: FOLLOWUPS_DONE }, async ($, e, next) => {
302 const r = await next(e)
303 await followupDone($, (e as unknown as { id?: unknown }).id, String(r.result ?? ''))
304 // goal-run notes on the same result when this item closed the goal's last task (seen when
305 // goal-run's hook runs beneath this one).
306 for (const c of r.context ?? []) {
307 const goal = parseGoalClosedNote(c)
308 if (goal) await offerGoal($, goal)
309 }
310 return r
311 }).catch(($, e, next) => next(e))
312
313 on('tool.call', { tool: GOAL_PLAN }, async ($, e, next) => {
314 const r = await next(e)
315 await goalPlanned($, (e as unknown as { goal?: unknown }).goal)
316 return r
317 }).catch(($, e, next) => next(e))
318
319 on('tool.call', { tool: GOAL_UPDATE }, async ($, e, next) => {
320 const r = await next(e)
321 await goalUpdated($, String(r.result ?? ''))
322 return r
323 }).catch(($, e, next) => next(e))
324
325 on('classic.PostToolUse', async ($, e, next) => {
326 const r = await next(e)
327 const input = (e.tool_input ?? {}) as { id?: unknown; goal?: unknown }
328 const text = toolText(e.tool_response)
329 if (e.tool_name === FOLLOWUPS_DONE) await followupDone($, input.id, text)
330 else if (e.tool_name === GOAL_PLAN) await goalPlanned($, input.goal)
331 else if (e.tool_name === GOAL_UPDATE) await goalUpdated($, text)
332 return r
333 }).catch(($, e, next) => next(e))
334
335
336 // ---------- the band ----------
337
338 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
339 const p = await read($, pulse)
340 const pending = await read($, offers)
341 const b = await read($, brain)
342 const toRoute = b ? unrouted(b) : 0
343 const remind = toRoute >= ROUTE_AT && toRoute > (await read($, routeLater))
344 if (e.props.hasSurvey || (!p && !pending.length && !remind)) return next(e)
345 const { Box, Button, Text } = $.ui.resolve(e)
346 const Svg = e.surface === 'terminal' ? null : $.ui.resolve(e as typeof e & { surface: 'desktop' }).Svg
347 const t = await read($, frame)
348 const below = await next(e)
349 const o = pending[0]
350 // The suggested type alone, until "…" asks for the others.
351 const showAll = Boolean(o) && (await read($, more)) === o!.key
352 return (
353 <Box flexDirection="column">
354 {p ? (
355 <Box flexDirection="row" gap={1} paddingX={1} alignItems="center">
356 {Svg ? (
357 <Svg source={sceneSvg(p.kind, Math.floor((t - p.at) / FRAME_MS))} alt={`${VERB[p.kind]}: ${p.title}`} width={SCENE.width} height={SCENE.height} />
358 ) : (
359 <Text bold color={KIND_COLOR[p.kind]}>{faceText(p.kind)}</Text>
360 )}
361 <Box flexDirection="column">
362 <Text bold color={KIND_COLOR[p.kind]}>{`${VERB[p.kind]}${p.folder ? ` → ${p.folder}` : ''}`}</Text>
363 <Text dimColor wrap="truncate-end">{clip(p.title, 90)}</Text>
364 </Box>
365 </Box>
366 ) : null}
367 {o ? (
368 <Box flexDirection="column" paddingX={1}>
369 <Text bold color={ACCENT} wrap="truncate-end">{`🧠 Worth remembering? ${o.title}`}</Text>
370 <Box flexDirection="row" gap={1}>
371 {(showAll ? o.types : o.types.slice(0, 1)).map((type, i) => (
372 <Button key={`log-${type}`} label={`Log as ${type}`} {...(i === 0 ? { hotkey: 'l' } : {})} variant={i === 0 ? 'primary' : undefined} onPress={() => logIt($, o, type)} />
373 ))}
374 {showAll ? null : <Button key="log-more" label="…" hotkey="m" onPress={() => update($, more, () => o.key)} />}
375 <Button key="log-skip" label="Not now" onPress={() => update($, offers, l => l.filter(x => x.key !== o.key))} />
376 </Box>
377 </Box>
378 ) : null}
379 {remind && !o ? (
380 <Box flexDirection="row" gap={1} paddingX={1} alignItems="center">
381 <Text bold color={ACCENT}>{`🧠 ${toRoute} entries to route`}</Text>
382 <Button key="route-now" label="Route now" hotkey="r" variant="primary" onPress={() => routeNow($, toRoute)} />
383 <Button key="route-later" label="Later" onPress={() => update($, routeLater, () => toRoute)} />
384 </Box>
385 ) : null}
386 {below}
387 </Box>
388 )
389 })
390
391 // ---------- the pane: the memory as a tree ----------
392
393 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
394 const { Box, Button, Text } = $.ui.resolve(e)
395 const Svg = e.surface === 'terminal' ? null : $.ui.resolve(e as typeof e & { surface: 'desktop' }).Svg
396 const b = await read($, brain)
397 if (!b) {
398 return (
399 <Box flexDirection="column" gap={1}>
400 <Text bold color={ACCENT}>🧠 Brain</Text>
401 <Text dimColor>{`No MISTAKES.md or .agent-brain/ memory in this project yet. /${SKILL} init sets one up: the inbox, the PARA folders and the index.`}</Text>
402 <Box flexDirection="row">
403 <Button key="init" label="Set up memory" variant="primary" onPress={() => setUp($)} />
404 </Box>
405 </Box>
406 )
407 }
408 const opened = await read($, open)
409 const p = await read($, pulse)
410 const follow = await read($, canFollow)
411 const isOpen = (name: string) => opened.includes(name)
412 const arrow = (name: string) => (isOpen(name) ? '▾' : '▸')
413 // What the graph lights up: the file a lesson just went into, and the entry just logged or
414 // routed, travelling from the inbox to its file for the first two seconds of a route.
415 const t = p ? (await read($, frame)) - p.at : 0
416 const freshAt = p ? b.entries.findIndex(en => en.title === p.title) : -1
417 const travelling = p?.kind === 'route' && freshAt >= 0 && routeOf(b.entries[freshAt]!) && t < 2_000 ? { entry: freshAt, t: Math.max(0.05, t / 2_000) } : undefined
418 const fresh = (title: string) => (p && (p.title === title || p.file === title) ? '✦ ' : '')
419 const waiting = unrouted(b)
420 // Where the index came from, said whenever it explains what the graph shows.
421 const missing = FOLDERS.flatMap(f => b.folders[f].filter(l => !l.isIndexed).map(l => l.file))
422 const unindexed = missing.length
423 const problem = b.index === null
424 ? `No index found: neither ${MEMORY_PATHS.join(' nor ')} exists, so no lessons are counted.`
425 : unindexed
426 ? `${unindexed} PARA file${unindexed === 1 ? ' is' : 's are'} not in ${b.index}: ${missing.slice(0, 3).join(', ')}${unindexed > 3 ? ', …' : ''}`
427 : ''
428
429 return (
430 <Box flexDirection="column" gap={1}>
431 <Box flexDirection="row" gap={1}>
432 <Text bold color={ACCENT}>{`🧠 ${lessons(lessonCount(b))}`}</Text>
433 <Text dimColor>{`· ${waiting} unrouted`}</Text>
434 </Box>
435 {problem ? <Text color="#d97706" wrap="truncate-end">{`⚠ ${problem}`}</Text> : null}
436 {Svg ? (
437 <Svg
438 source={graphSvg(b, { theme: await read($, theme), ...(p?.file ? { hot: p.file } : {}), ...(freshAt >= 0 ? { fresh: freshAt } : {}), ...(travelling ? { travelling } : {}) })}
439 alt={`Memory graph: MEMORY.md, ${FOLDERS.map(f => `${f} ${b.folders[f].length} files`).join(', ')}; ${b.entries.length} entries, ${waiting} still to route`}
440 width={GRAPH.width}
441 height={GRAPH.height}
442 />
443 ) : null}
444 <Box flexDirection="column">
445 <Button key="f-Inbox" label={`${arrow('Inbox')} 📥 Inbox · MISTAKES.md (${b.entries.length})`} plain onPress={() => toggle($, 'Inbox')} />
446 {isOpen('Inbox')
447 ? b.entries.slice(0, INBOX_SHOWN).map((en, i) => (
448 <Box key={`e-${i}`} flexDirection="row" justifyContent="space-between" gap={1}>
449 <Box flexDirection="row" gap={1}>
450 <Text>{i === Math.min(INBOX_SHOWN, b.entries.length) - 1 ? ' └─' : ' ├─'}</Text>
451 <Text color={KIND_COLOR[en.type]}>{`● ${en.type}`}</Text>
452 <Text wrap="truncate-end">{`${fresh(en.title)}${clip(en.title, 70)}`}</Text>
453 </Box>
454 <Box flexDirection="row" gap={1}>
455 <Text dimColor>{en.status}</Text>
456 {en.type === 'handoff' && follow ? <Button key={`fu-${i}`} label="+ to-do" plain onPress={() => toFollowup($, en.title)} /> : null}
457 </Box>
458 </Box>
459 ))
460 : null}
461 {isOpen('Inbox') && b.entries.length > INBOX_SHOWN ? <Text dimColor>{` … ${b.entries.length - INBOX_SHOWN} older`}</Text> : null}
462 {FOLDERS.map(f => {
463 const files = b.folders[f]
464 const n = files.reduce((k, l) => k + l.lessons, 0)
465 const label = f === 'Archives' && !files.length ? `🗄 Archives · ${b.archives} files` : `${arrow(f)} ${f === 'Archives' ? '🗄' : '📁'} ${f} (${files.length})${n ? ` · ${lessons(n)}` : ''}`
466 return (
467 <Box key={`d-${f}`} flexDirection="column">
468 {files.length ? <Button key={`f-${f}`} label={label} plain onPress={() => toggle($, f)} /> : <Text dimColor>{` ${label}`}</Text>}
469 {isOpen(f)
470 ? files.map((l, i) => (
471 <Box key={`l-${f}-${i}`} flexDirection="column">
472 <Box flexDirection="row" gap={1}>
473 <Text>{i === files.length - 1 ? ' └─' : ' ├─'}</Text>
474 <Text color={p?.file === l.file ? '#d4a017' : undefined} bold={p?.file === l.file}>{`${fresh(l.file)}${l.file}`}</Text>
475 <Text dimColor>{l.isIndexed ? [l.lessons ? lessons(l.lessons) : '', l.updated].filter(Boolean).join(' · ') : 'not in MEMORY.md'}</Text>
476 </Box>
477 {l.summary && p?.file === l.file ? <Text dimColor wrap="truncate-end">{` ${clip(l.summary, 110)}`}</Text> : null}
478 </Box>
479 ))
480 : null}
481 </Box>
482 )
483 })}
484 {b.rules.length ? <Text dimColor>{` ⚖ Rules · ${b.rules.map(r => clip(r, 50)).join(' · ')}`}</Text> : null}
485 <Text dimColor wrap="truncate-end">{` index: ${b.index ?? 'none found'}`}</Text>
486 </Box>
487 </Box>
488 )
489 })
490}
491hooks/art.ts 128 lines1// The pixel art: Claude with a brain on its head, reacting to what just happened to the memory, as
2// one SVG string; the terminal's text version; and the brain map for the pane's header.
3
4import type { PulseKind } from '../types'
5
6const BODY = '#d97757'
7const EYE = '#1d1f2b'
8const BRAIN = '#f2a7c3'
9const FOLD = '#c96f97'
10const CELL = 4
11
12export const KIND_COLOR: Record<PulseKind, string> = {
13 mistake: '#e5484d',
14 success: '#30a46c',
15 decision: '#3b7dd8',
16 handoff: '#f5a524',
17 route: '#8e6cd8',
18 promote: '#d97757',
19 dream: '#6e7ab8',
20}
21
22type Px = [x: number, y: number, fill: string]
23
24// 13×13 cells: a brain of 4 rows over the body. Eyes close in a dream; the brain flashes red for a
25// mistake and glows green for a success.
26const BRAIN_ROWS = ['...bbbbbbb...', '..bbfbbbfbb..', '..bfbbfbbfb..', '...bbbbbbb...']
27const HEAD = ['..ooooooooo..', '..ooeooooeo..', '..ooeooooeo..', '..ooooooooo..', '..ooooooooo..']
28
29export function claudeCells(kind: PulseKind, frame: number): Px[] {
30 const px: Px[] = []
31 const lit = frame % 2 === 0
32 const brain = kind === 'mistake' && lit ? '#ff8a8f' : kind === 'success' && lit ? '#b8f0c8' : BRAIN
33 BRAIN_ROWS.forEach((row, y) => {
34 for (let x = 0; x < row.length; x++) {
35 if (row[x] === 'b') px.push([x, y, brain])
36 if (row[x] === 'f') px.push([x, y, FOLD])
37 }
38 })
39 const asleep = kind === 'dream'
40 HEAD.forEach((row, y) => {
41 for (let x = 0; x < row.length; x++) {
42 if (row[x] === 'o') px.push([x, y + 4, BODY])
43 if (row[x] === 'e' && !(asleep && y === 1)) px.push([x, y + 4, asleep ? BODY : EYE])
44 }
45 })
46 if (asleep) px.push([4, 6, EYE], [5, 6, EYE], [9, 6, EYE], [10, 6, EYE])
47 // Arms: one raised to hand over a note or stamp a rule, else at the sides.
48 const raised = kind === 'handoff' || kind === 'promote' || kind === 'decision'
49 px.push([0, 7, BODY], [1, 7, BODY])
50 px.push(raised ? [12, 5, BODY] : [12, 7, BODY], raised ? [11, 6, BODY] : [11, 7, BODY])
51 // Legs.
52 for (const x of [4, 8]) px.push([x, 9, BODY], [x, 10, BODY])
53 return px
54}
55
56export const SCENE = { width: 200, height: 56 }
57
58const rect = (x: number, y: number, w: number, h: number, fill: string, extra = '') =>
59 `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${fill}"${extra}/>`
60
61// A folder icon at (x, y).
62const folderIcon = (x: number, y: number, fill: string) =>
63 `<path d="M${x} ${y + 3}h7l2 2h11v13h-20z" fill="${fill}"/><path d="M${x} ${y + 7}h20v11h-20z" fill="${fill}" fill-opacity="0.75"/>`
64
65// Claude on the left; to its right, what it does for this kind of event.
66export function sceneSvg(kind: PulseKind, frame: number): string {
67 const { width: w, height: h } = SCENE
68 const color = KIND_COLOR[kind]
69 const ox = 8
70 const oy = h - 11 * CELL - 2
71 let s = ''
72 if (kind === 'success') s += `<circle cx="${ox + 26}" cy="${oy + 8}" r="${frame % 2 ? 16 : 13}" fill="${color}" fill-opacity="0.18"/>`
73 for (const [x, y, fill] of claudeCells(kind, frame)) s += rect(ox + x * CELL, oy + y * CELL, CELL, CELL, fill)
74 const ax = 72
75 const t = frame % 8
76 if (kind === 'mistake') {
77 // A spark off the brain, then a plaster across it.
78 s += `<path d="M${ox + 44} ${oy - 1}l-5 8h4l-3 7 8-10h-4l3-5z" fill="${color}" opacity="${frame % 2 ? 1 : 0.4}"/>`
79 s += rect(ox + 10, oy + 4, 16, 4, '#f6e2b3', ' transform="rotate(-18 ' + (ox + 18) + ' ' + (oy + 6) + ')"')
80 } else if (kind === 'success') {
81 s += `<path d="M${ax + 10} ${12 - (t % 2)}l3 7h7l-6 4 2 7-6-4-6 4 2-7-6-4h7z" fill="#f5c84b"/>`
82 } else if (kind === 'decision') {
83 // A signpost: two arms, the chosen one lit.
84 s += rect(ax + 10, 14, 3, 36, '#8b8fa3')
85 s += `<path d="M${ax + 13} 16h22l5 4-5 4h-22z" fill="${t < 4 ? color : '#8b8fa3'}"/>`
86 s += `<path d="M${ax + 10} 28h-20l-5 4 5 4h20z" fill="${t < 4 ? '#8b8fa3' : color}"/>`
87 } else if (kind === 'handoff') {
88 // A note travelling from Claude's hand to the right.
89 const nx = ax + (t * 12)
90 s += rect(nx, 18 - (t % 2) * 2, 14, 11, '#fff7d6', ` stroke="${color}" stroke-width="1"`)
91 s += rect(nx + 3, 21 - (t % 2) * 2, 8, 1, color) + rect(nx + 3, 24 - (t % 2) * 2, 6, 1, color)
92 } else if (kind === 'route') {
93 // Notes fly from the brain into a folder.
94 s += folderIcon(w - 40, 22, color)
95 for (let i = 0; i < 3; i++) {
96 const p = ((t + i * 3) % 8) / 8
97 const x = ox + 40 + p * (w - 80 - ox)
98 const y = oy + 4 - Math.sin(p * Math.PI) * 14
99 s += rect(Math.round(x), Math.round(y), 7, 6, '#fff7d6', ` stroke="${color}" stroke-width="0.8"`)
100 }
101 } else if (kind === 'promote') {
102 // A stamp coming down on a rule sheet.
103 const down = t < 4 ? t * 3 : 12
104 s += rect(ax + 4, 28, 34, 22, '#fff7d6', ` stroke="#8b8fa3" stroke-width="1"`)
105 s += rect(ax + 9, 34, 22, 2, '#8b8fa3') + rect(ax + 9, 40, 16, 2, '#8b8fa3')
106 s += rect(ax + 14, 4 + down, 14, 6, color) + rect(ax + 19, down - 4, 4, 9, '#5b4636')
107 if (t >= 4) s += `<text x="${ax + 21}" y="47" font-size="8" font-weight="700" fill="${color}" text-anchor="middle">RULE</text>`
108 } else {
109 // Dreaming: Zs rising.
110 for (let i = 0; i < 3; i++) {
111 const y = 30 - ((t + i * 3) % 8) * 3
112 s += `<text x="${ox + 50 + i * 8}" y="${y}" font-size="${8 + i * 2}" font-weight="700" fill="${color}" fill-opacity="${0.4 + i * 0.2}">z</text>`
113 }
114 }
115 return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${s}</svg>`
116}
117
118const FACE: Record<PulseKind, string> = {
119 mistake: '🧠ϟ(>_<)',
120 success: '🧠✦(^_^)',
121 decision: '🧠 (•_•)⇄',
122 handoff: '🧠 (•_•)✉',
123 route: '🧠 (•_•)→📁',
124 promote: '🧠 (•̀_•́)⚖',
125 dream: '🧠 (-_-) z',
126}
127export const faceText = (kind: PulseKind) => FACE[kind]
128hooks/graph.ts 178 lines1// The memory as a knowledge graph: MEMORY.md at the centre, the four PARA folders around it, each
2// folder's files, and every MISTAKES.md entry as a dot linked to the file it was routed to.
3// Entries not routed yet float in an inbox cluster with no link. A fixed radial layout, so the
4// graph never jumps between refreshes.
5
6import type { Brain, Entry, EntryType, Folder, Lesson } from '../types'
7
8export type Theme = 'dark' | 'light'
9export const GRAPH = { width: 640, height: 400 }
10
11const FOLDERS: Folder[] = ['Projects', 'Areas', 'Resources', 'Archives']
12const HUB_ANGLE: Record<Folder, number> = { Projects: -140, Areas: -40, Resources: 40, Archives: 140 }
13const HUB_R = 104
14// What a large memory is cut to, so nothing runs off the canvas or piles up: files per folder (the
15// ones with the most lessons, and the one just written to), dots per file, dots in the inbox.
16const MAX_FILES = 7
17const MAX_RINGS = 3
18const PER_RING = 18
19const MAX_INBOX = 30
20
21// Where file j of n sits: in a column on its folder's outer side (Projects and Archives left, Areas
22// and Resources right), spread evenly down that folder's half of the canvas, its label facing out.
23// A column keeps every label on its own row, however many files a folder holds (up to MAX_FILES).
24const COLUMN_X = 150
25function filePos(cx: number, cy: number, folder: Folder, j: number, n: number) {
26 const right = folder === 'Areas' || folder === 'Resources'
27 const top = folder === 'Projects' || folder === 'Areas'
28 const [lo, hi] = top ? [14, cy - 12] : [cy + 14, GRAPH.height - 26]
29 const y = n === 1 ? (top ? hi - 20 : lo + 20) : lo + ((hi - lo) * (j + 0.5)) / n
30 return { x: right ? cx + COLUMN_X : cx - COLUMN_X, y, deg: right ? 0 : 180 }
31}
32const HUB_COLOR: Record<Folder, string> = { Projects: '#a78bfa', Areas: '#38bdf8', Resources: '#34d399', Archives: '#94a3b8' }
33const TYPE_COLOR: Record<EntryType, string> = { mistake: '#f87171', success: '#4ade80', decision: '#60a5fa', handoff: '#fbbf24' }
34
35const PALETTE = {
36 dark: { bg: '#0b0e14', edge: '#94a3b8', label: '#e2e8f0', dim: '#64748b', core: '#f8fafc', ring: '#1e293b' },
37 light: { bg: '#fbfaf7', edge: '#64748b', label: '#1e293b', dim: '#94a3b8', core: '#334155', ring: '#e2e8f0' },
38}
39
40const rad = (deg: number) => (deg * Math.PI) / 180
41const at = (x: number, y: number, deg: number, r: number) => [x + Math.cos(rad(deg)) * r, y + Math.sin(rad(deg)) * r] as const
42const f1 = (n: number) => n.toFixed(1)
43const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<')
44const short = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
45
46// The files a folder shows, in the index's order: the most lessons first past MAX_FILES, and the
47// file just written to always.
48export function shownFiles(files: Lesson[], hot?: string): Lesson[] {
49 if (files.length <= MAX_FILES) return files
50 const keep = new Set([...files].sort((a, b) => b.lessons - a.lessons).slice(0, MAX_FILES).map(l => l.file))
51 if (hot && files.some(l => l.file === hot) && !keep.has(hot)) {
52 const least = [...files].filter(l => keep.has(l.file)).sort((a, b) => a.lessons - b.lessons)[0]!
53 keep.delete(least.file)
54 keep.add(hot)
55 }
56 return files.filter(l => keep.has(l.file))
57}
58
59// Where an entry was routed: its folder and file name, from "Project: .agent-brain/…/Projects/x.md".
60export function routeOf(e: Entry): { folder: Folder; file: string } | null {
61 const m = /(Projects|Areas|Resources|Archives)\/([^/\s]+\.md)/.exec(e.route)
62 return m ? { folder: m[1] as Folder, file: m[2]! } : null
63}
64
65export function graphSvg(b: Brain, opts: { theme: Theme; hot?: string; fresh?: number; travelling?: { entry: number; t: number } }): string {
66 const { width: w, height: h } = GRAPH
67 const P = PALETTE[opts.theme]
68 const cx = w / 2
69 const cy = h / 2 - 14
70 let edges = ''
71 let nodes = ''
72 let labels = ''
73
74 // The files each folder holds, with the entries routed to them.
75 const routed = new Map<string, { e: Entry; i: number }[]>()
76 const inbox: { e: Entry; i: number }[] = []
77 // Routed somewhere the graph has no node for (a rule, a path it can't read): counted, not drawn as
78 // waiting, so the inbox matches the unrouted count.
79 let elsewhere = 0
80 b.entries.forEach((e, i) => {
81 if (e.status === 'unrouted') return inbox.push({ e, i })
82 const r = routeOf(e)
83 if (!r) return elsewhere++
84 const k = `${r.folder}/${r.file}`
85 routed.set(k, [...(routed.get(k) ?? []), { e, i }])
86 })
87
88 for (const folder of FOLDERS) {
89 const [hx, hy] = at(cx, cy, HUB_ANGLE[folder], HUB_R)
90 const color = HUB_COLOR[folder]
91 const all = b.folders[folder]
92 const files = shownFiles(all, opts.hot)
93 edges += `<line x1="${cx}" y1="${cy}" x2="${f1(hx)}" y2="${f1(hy)}" stroke="${color}" stroke-opacity="0.45" stroke-width="1.6"/>`
94 // Files fan out beyond the hub, away from the centre.
95 files.forEach((l, j) => {
96 const { x: fx, y: fy, deg } = filePos(cx, cy, folder, j, files.length)
97 const fr = 4 + Math.sqrt(Math.max(1, l.lessons)) * 1.4
98 const hot = opts.hot === l.file
99 edges += `<line x1="${f1(hx)}" y1="${f1(hy)}" x2="${f1(fx)}" y2="${f1(fy)}" stroke="${color}" stroke-opacity="0.35" stroke-width="1"/>`
100 // Its entries: a ring of dots, linked by short spokes.
101 const mine = routed.get(`${folder}/${l.file}`) ?? []
102 const dots = mine.slice(0, PER_RING * MAX_RINGS)
103 dots.forEach(({ e, i }, k) => {
104 const ring = Math.floor(k / PER_RING)
105 const n = Math.min(PER_RING, dots.length - ring * PER_RING)
106 const [ex, ey] = at(fx, fy, (360 / n) * (k % PER_RING) + ring * 10, fr + 10 + ring * 8)
107 edges += `<line x1="${f1(fx)}" y1="${f1(fy)}" x2="${f1(ex)}" y2="${f1(ey)}" stroke="${P.edge}" stroke-opacity="0.18" stroke-width="0.6"/>`
108 if (i === opts.fresh) nodes += `<circle cx="${f1(ex)}" cy="${f1(ey)}" r="8" fill="${TYPE_COLOR[e.type]}" fill-opacity="0.35" filter="url(#glow)"/>`
109 nodes += `<circle cx="${f1(ex)}" cy="${f1(ey)}" r="2.4" fill="${TYPE_COLOR[e.type]}"/>`
110 })
111 if (hot) nodes += `<circle cx="${f1(fx)}" cy="${f1(fy)}" r="${f1(fr + 7)}" fill="${color}" fill-opacity="0.25" filter="url(#glow)"/>`
112 nodes += `<circle cx="${f1(fx)}" cy="${f1(fy)}" r="${f1(fr)}" fill="${color}" fill-opacity="${hot ? 1 : 0.85}" stroke="${P.bg}" stroke-width="1.5"/>`
113 // The label hangs on the far side of the file, along the angle it sits at.
114 const rings = Math.ceil(dots.length / PER_RING)
115 const gap = fr + (rings ? 14 + rings * 8 : 8)
116 const [lx, ly] = at(fx, fy, deg, gap)
117 const c = Math.cos(rad(deg))
118 const anchor = c > 0.35 ? 'start' : c < -0.35 ? 'end' : 'middle'
119 const dy = anchor === 'middle' ? (Math.sin(rad(deg)) > 0 ? 8 : -12) : 0
120 labels += `<text x="${f1(lx)}" y="${f1(ly + dy)}" font-size="9.5" fill="${hot ? color : P.label}" font-weight="${hot ? 700 : 500}" text-anchor="${anchor}">${esc(short(l.file.replace(/\.md$/, ''), 24))}</text>`
121 labels += `<text x="${f1(lx)}" y="${f1(ly + dy + 11)}" font-size="8.5" fill="${P.dim}" text-anchor="${anchor}">${l.isIndexed ? `${l.lessons} lesson${l.lessons === 1 ? '' : 's'}` : 'not in MEMORY.md'}</text>`
122 })
123 // The hub.
124 nodes += `<circle cx="${f1(hx)}" cy="${f1(hy)}" r="11" fill="${P.bg}" stroke="${color}" stroke-width="2"/>`
125 nodes += `<circle cx="${f1(hx)}" cy="${f1(hy)}" r="4" fill="${color}"/>`
126 const up = Math.sin(rad(HUB_ANGLE[folder])) < 0
127 labels += `<text x="${f1(hx)}" y="${f1(hy + (up ? -17 : 24))}" font-size="10" font-weight="700" fill="${color}" text-anchor="middle">${folder}${all.length ? (all.length > files.length ? ` · +${all.length - files.length} more` : '') : ' · empty'}</text>`
128 }
129
130 // The inbox: entries waiting to be routed, unlinked, below the centre.
131 const ix = cx
132 const iy = h - 34
133 inbox.slice(0, MAX_INBOX).forEach(({ e, i }, k) => {
134 const col = k % 10
135 const row = Math.floor(k / 10)
136 if (i === opts.fresh) nodes += `<circle cx="${f1(ix - (Math.min(10, inbox.length) - 1) * 4.5 + col * 9)}" cy="${f1(iy + row * 9)}" r="10" fill="${TYPE_COLOR[e.type]}" fill-opacity="0.35" filter="url(#glow)"/>`
137 nodes += `<circle cx="${f1(ix - (Math.min(10, inbox.length) - 1) * 4.5 + col * 9)}" cy="${f1(iy + row * 9)}" r="3" fill="${TYPE_COLOR[e.type]}" stroke="${TYPE_COLOR[e.type]}" stroke-opacity="0.35" stroke-width="3"/>`
138 })
139 labels += `<text x="${ix}" y="${iy - 10}" font-size="9" fill="${P.dim}" text-anchor="middle">inbox · ${inbox.length} to route${elsewhere ? ` · ${elsewhere} routed outside the folders` : ''}</text>`
140 if (inbox.length > MAX_INBOX) labels += `<text x="${f1(ix + 52)}" y="${iy + 21}" font-size="9" fill="${P.dim}">+${inbox.length - MAX_INBOX}</text>`
141
142 // Rules promoted from the memory, above the centre.
143 b.rules.slice(0, 3).forEach((r, k) => {
144 const [rx, ry] = [cx - 60 + k * 60, 26]
145 edges += `<line x1="${cx}" y1="${cy}" x2="${rx}" y2="${ry}" stroke="${P.edge}" stroke-opacity="0.3" stroke-dasharray="2 3"/>`
146 nodes += `<rect x="${rx - 7}" y="${ry - 7}" width="14" height="14" rx="3" fill="#f59e0b"/>`
147 labels += `<text x="${rx}" y="${ry + 18}" font-size="8" fill="${P.dim}" text-anchor="middle">${esc(short(r, 18))}</text>`
148 })
149
150 // The centre: MEMORY.md, the index both harnesses read.
151 nodes += `<circle cx="${cx}" cy="${cy}" r="22" fill="${P.core}" fill-opacity="0.08" filter="url(#glow)"/>`
152 nodes += `<circle cx="${cx}" cy="${cy}" r="14" fill="${P.bg}" stroke="${P.core}" stroke-width="2"/>`
153 nodes += `<circle cx="${cx}" cy="${cy}" r="5" fill="${P.core}"/>`
154 labels += `<text x="${cx}" y="${cy + 30}" font-size="10" font-weight="700" fill="${P.label}" text-anchor="middle">MEMORY.md</text>`
155
156 // A new entry travelling from the inbox to its file.
157 if (opts.travelling) {
158 const e = b.entries[opts.travelling.entry]
159 const r = e && routeOf(e)
160 if (e && r) {
161 const files = shownFiles(b.folders[r.folder], opts.hot)
162 const j = Math.max(0, files.findIndex(l => l.file === r.file))
163 const { x: fx, y: fy } = filePos(cx, cy, r.folder, j, files.length)
164 const t = opts.travelling.t
165 // Along a curve through the centre's side, from the inbox to the file.
166 const qx = (ix + fx) / 2 - 60
167 const qy = (iy + fy) / 2
168 const x = (1 - t) ** 2 * ix + 2 * (1 - t) * t * qx + t ** 2 * fx
169 const y = (1 - t) ** 2 * iy + 2 * (1 - t) * t * qy + t ** 2 * fy
170 edges += `<path d="M${ix} ${iy} Q${f1(qx)} ${f1(qy)} ${f1(fx)} ${f1(fy)}" fill="none" stroke="${TYPE_COLOR[e.type]}" stroke-opacity="0.5" stroke-width="1.2" stroke-dasharray="3 3"/>`
171 nodes += `<circle cx="${f1(x)}" cy="${f1(y)}" r="9" fill="${TYPE_COLOR[e.type]}" fill-opacity="0.3" filter="url(#glow)"/><circle cx="${f1(x)}" cy="${f1(y)}" r="4" fill="${TYPE_COLOR[e.type]}"/>`
172 }
173 }
174
175 const defs = `<defs><filter id="glow" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="4"/></filter></defs>`
176 return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${defs}<rect width="${w}" height="${h}" rx="10" fill="${P.bg}"/>${edges}${nodes}${labels}</svg>`
177}
178hooks/brain.ts 173 lines1// Reading the memory mistake-brain keeps: MISTAKES.md (the inbox) and the shared MEMORY.md index of
2// the PARA folders. Pure functions over text, so the hooks module only fetches files.
3
4import type { Brain, Entry, EntryType, Folder, Lesson, Pulse } from '../types'
5
6
7// The mistake-brain skill ships in this plugin: its command is the plugin's.
8export const SKILL = 'brain-hud:mistake-brain'
9export const FOLDERS: Folder[] = ['Projects', 'Areas', 'Resources', 'Archives']
10const TYPES: EntryType[] = ['mistake', 'success', 'decision', 'handoff']
11
12const HEADING = /^## \[(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2})\]\s+(.+)$/
13const FIELD = /^- \*\*(Type|Status|PARA route):\*\*\s*(.*)$/
14
15// Every entry, in file order (newest first, as the skill writes them). An entry without a Type is
16// an older one: a mistake, per the format spec.
17export function parseMistakes(text: string): Entry[] {
18 const out: Entry[] = []
19 let cur: Entry | null = null
20 for (const raw of text.split('\n')) {
21 const line = raw.trim()
22 const head = HEADING.exec(line)
23 if (head) {
24 cur = { at: head[1]!, title: head[2]!.trim(), type: 'mistake', status: 'unrouted', route: '' }
25 out.push(cur)
26 continue
27 }
28 const field = cur && FIELD.exec(line)
29 if (!cur || !field) continue
30 const value = field[2]!.trim()
31 if (field[1] === 'Type') {
32 const t = value.toLowerCase() as EntryType
33 if (TYPES.includes(t)) cur.type = t
34 } else if (field[1] === 'Status') cur.status = value.toLowerCase()
35 else cur.route = value
36 }
37 return out
38}
39
40// The file an index line names: a path, a `path` in backticks, or a markdown link [name](path).
41const fileOf = (ref: string) => (/\]\(([^)]+)\)/.exec(ref)?.[1] ?? ref).replace(/[`*]/g, '').replace(/^.*[\\/]/, '')
42
43const empty = (): Record<Folder, Lesson[]> => ({ Projects: [], Areas: [], Resources: [], Archives: [] })
44
45// MEMORY.md: `- [Project] Projects/x.md — summary (N lessons, last updated YYYY-MM-DD)` per file
46// under its folder's heading, an archive count, the promoted rules, and the last dream's date.
47export function parseMemory(text: string): Omit<Brain, 'entries'> {
48 const folders = empty()
49 const rules: string[] = []
50 let archives = 0
51 let lastDream: string | null = null
52 let section = ''
53 for (const raw of text.split('\n')) {
54 const line = raw.trim()
55 const head = /^## (.+)$/.exec(line)
56 if (head) {
57 section = head[1]!.trim()
58 continue
59 }
60 const count = /^Archives:\s*(\d+)\s+files?/i.exec(line)
61 if (count) archives = Number(count[1])
62 if (FOLDERS.includes(section as Folder)) {
63 const m = /^- \[[^\]]+\]\s+(\S+)\s+[—–-]\s+(.*)$/.exec(line)
64 if (!m) continue
65 const tail = /\s*\((\d+) lessons?(?:,\s*last updated (\d{4}-\d{2}-\d{2}))?\)\s*$/.exec(m[2]!)
66 folders[section as Folder].push({
67 file: fileOf(m[1]!),
68 summary: (tail ? m[2]!.slice(0, tail.index) : m[2]!).trim(),
69 lessons: tail ? Number(tail[1]) : 0,
70 updated: tail?.[2] ?? '',
71 isIndexed: true,
72 })
73 } else if (/^Rules promoted/i.test(section)) {
74 if (/^- /.test(line) && !/^- _\(/.test(line)) rules.push(line.slice(2).trim())
75 } else if (/^Last dream/i.test(section) && !lastDream) {
76 lastDream = /\*\*(\d{4}-\d{2}-\d{2})\*\*/.exec(line)?.[1] ?? null
77 }
78 }
79 return { folders, archives, rules, lastDream }
80}
81
82// What a PARA file itself says: one lesson per `## [YYYY-MM-DD] title` heading, the newest date
83// among those and its `- Added:` lines, and the first paragraph under the title as a summary.
84export type ParaFile = { file: string; lessons: number; updated: string; summary: string }
85export function paraStats(file: string, text: string): ParaFile {
86 let lessons = 0
87 let updated = ''
88 let summary = ''
89 let isTitle = false
90 for (const raw of text.split('\n')) {
91 const line = raw.trim()
92 const head = /^## \[(\d{4}-\d{2}-\d{2})/.exec(line)
93 const added = /^- Added:\s*(\d{4}-\d{2}-\d{2})/.exec(line)
94 if (head) lessons++
95 for (const d of [head?.[1], added?.[1]]) if (d && d > updated) updated = d
96 if (/^# /.test(line)) isTitle = true
97 else if (isTitle && !summary && line && !line.startsWith('#')) summary = line
98 if (/^## /.test(line)) isTitle = false
99 }
100 return { file, lessons, updated, summary }
101}
102
103// The index joined with what is on disk. A file's lessons and date are counted from the file (the
104// index's own numbers can be missing or stale); its summary is the index's, else the file's. A
105// PARA file the index doesn't name still shows, marked.
106export function withListing(folders: Record<Folder, Lesson[]>, onDisk: Partial<Record<Folder, (string | ParaFile)[]>>): Record<Folder, Lesson[]> {
107 const out = empty()
108 for (const f of FOLDERS) {
109 const disk = new Map((onDisk[f] ?? []).map(d => (typeof d === 'string' ? { file: d, lessons: -1, updated: '', summary: '' } : d)).map(d => [d.file, d]))
110 const counted = (l: Lesson): Lesson => {
111 const d = disk.get(l.file)
112 return d && d.lessons >= 0 ? { ...l, lessons: d.lessons, updated: d.updated || l.updated, summary: l.summary || d.summary } : l
113 }
114 const named = new Set(folders[f].map(l => l.file))
115 const extra = [...disk.values()]
116 .filter(d => !named.has(d.file))
117 // Archives are only counted in MEMORY.md, never listed: one there isn't missing from it.
118 .map(d => ({ file: d.file, summary: d.summary, lessons: Math.max(0, d.lessons), updated: d.updated, isIndexed: f === 'Archives' }))
119 out[f] = [...folders[f].map(counted), ...extra]
120 }
121 return out
122}
123
124export const lessonCount = (b: Brain) => FOLDERS.reduce((n, f) => n + b.folders[f].reduce((k, l) => k + l.lessons, 0), 0)
125export const unrouted = (b: Brain) => b.entries.filter(e => e.status === 'unrouted').length
126
127// An entry's identity: its time and type, numbered when several share them. Not its title, so
128// rewording an entry doesn't read as a new one.
129function keysOf(entries: Entry[]): string[] {
130 const seen = new Map<string, number>()
131 return entries.map(e => {
132 const k = `${e.at} ${e.type}`
133 const n = seen.get(k) ?? 0
134 seen.set(k, n + 1)
135 return `${k} #${n}`
136 })
137}
138
139// What changed between two readings, as the one thing worth showing: a new entry first, then a
140// dream, a promoted rule, or a lesson routed into a folder.
141export function diffBrain(a: Brain | null, b: Brain): Omit<Pulse, 'at'> | null {
142 if (!a) return null
143 // Entries are newest first, so the numbering runs from the oldest to stay put as new ones arrive.
144 const ka = keysOf([...a.entries].reverse()).reverse()
145 const kb = keysOf([...b.entries].reverse()).reverse()
146 const known = new Set(ka)
147 const fresh = b.entries.find((_, i) => !known.has(kb[i]!))
148 if (fresh) return { kind: fresh.type, title: fresh.title }
149 if (b.lastDream && b.lastDream !== a.lastDream) return { kind: 'dream', title: `the memory was reorganized (${b.lastDream})` }
150 const rule = b.rules.find(r => !a.rules.includes(r))
151 if (rule) return { kind: 'promote', title: rule }
152 for (const folder of FOLDERS) {
153 const before = new Map(a.folders[folder].map(l => [l.file, l.lessons]))
154 const grown = b.folders[folder].find(l => !before.has(l.file) || l.lessons > before.get(l.file)!)
155 if (grown) return { kind: 'route', title: grown.file, folder, file: grown.file }
156 }
157 const was = new Map(a.entries.map((e, i) => [ka[i]!, e.status]))
158 const routed = b.entries.find((e, i) => e.status !== 'unrouted' && was.get(kb[i]!) === 'unrouted')
159 if (routed) return { kind: 'route', title: routed.title }
160 return null
161}
162
163
164// What the person is asked to have Claude log: through the skill, so the entry keeps its format.
165export function logPrompt(type: EntryType, title: string, detail: string): string {
166 return [
167 `Log this to MISTAKES.md with /${SKILL} log, as Type: ${type}.`,
168 `- ${title}`,
169 ...(detail ? [`- ${detail}`] : []),
170 'Fill the fields for that type from what happened in this session; keep it short.',
171 ].join('\n')
172}
173hooks/texts.ts 68 lines1// GENERATED from shared/texts.ts by tools/sync-texts.mjs: edit that file, not this one.
2// Wording one mod writes and another reads: followups' Start prompt and done result, goal-run's
3// start prompt, reached-goal toast and result. Edit it here, then run `node tools/sync-texts.mjs`:
4// a mod can import nothing outside its own folder, so each one keeps a copy in hooks/texts.ts.
5
6export type StartItem = { id: string; title: string; isBug?: boolean }
7
8// followups: one line per item it hands Claude when the person presses Start.
9export const startLine = (i: { id: string; title: string; kind?: string; detail?: string; where?: string }) =>
10 `- id ${i.id}: ${i.title}${i.kind === 'bug' ? ' (bug)' : ''}${i.detail ? ` — ${i.detail}` : ''}${i.where ? ` [where: ${i.where}]` : ''}`
11
12export function parseStartPrompt(text: string): StartItem[] {
13 const out: StartItem[] = []
14 for (const line of text.split('\n')) {
15 const m = /^- id (\S+): (.+?)( \(bug\))?(?: — .*?)?(?: \[where: .*\])?$/.exec(line.trim())
16 if (m) out.push({ id: m[1]!, title: m[2]!.trim().slice(0, 120), ...(m[3] ? { isBug: true } : {}) })
17 }
18 return out
19}
20
21// A tool's answer as text, however it arrives: a string, MCP content blocks, or `{ content }`
22// (what a classic PostToolUse hook gets as tool_response).
23export function toolText(response: unknown): string {
24 if (typeof response === 'string') return response
25 const blocks = Array.isArray(response) ? response : (response as { content?: unknown } | null)?.content
26 if (Array.isArray(blocks)) return blocks.map(b => String((b as { text?: unknown })?.text ?? '')).join('\n')
27 return response == null ? '' : JSON.stringify(response)
28}
29
30// followups: what its done tool answers.
31export const doneResult = (title: string, note: string) => `Marked "${title}" done${note ? ` (${note})` : ''}.`
32
33export function parseDoneResult(text: string): { title: string; note: string } | null {
34 const m = /^Marked "(.+)" done(?: \((.*)\))?\.$/.exec(text)
35 return m ? { title: m[1]!, note: m[2] ?? '' } : null
36}
37
38// goal-run: the prompt that hands Claude the goal, the toast when it is reached (on every path), and
39// the update tool's words for it.
40export const goalPrompt = (text: string) => `My goal for this session: ${text}`
41export const parseGoalPrompt = (text: string) => /^My goal for this session: (.+)$/m.exec(text)?.[1]?.trim() ?? null
42export const goalReachedToast = (text: string) => `Goal reached: ${text}`
43export const GOAL_REACHED = 'The goal is reached.'
44// goal-run: the note it adds to followups' done result when that to-do item closed the goal's last task.
45export const goalClosedNote = (text: string) => `That closed the last task of the session goal "${text}". ${GOAL_REACHED}`
46export const parseGoalClosedNote = (text: string) => /^That closed the last task of the session goal "(.+)"\. /.exec(text)?.[1] ?? null
47
48// goal-run ↔ factory: a plan the factory's spec-writer wrote is submitted with source "factory".
49// goal-run then lets the factory's builder do the work, and answers the plan tool in these words;
50// the factory reads them to go on to the build or back to the spec. A plan approved later in the
51// Goal pane is passed on with the command /factory plan-approved.
52export const FACTORY_SOURCE = 'factory' as const
53export const FACTORY_PLAN_APPROVED_ARG = 'plan-approved'
54export const PLAN_APPROVED_FOR_FACTORY = "Approved. The factory's builder does the work: don't start the tasks yourself, and don't propose another plan."
55export const planChangesForFactory = (said: string) => `The person wants changes: ${said}\nThe factory's spec-writer revises the spec; don't propose a plan yourself.`
56export const PLAN_WAITING_FOR_FACTORY = "The person hasn't answered; the plan waits for approval in the Goal pane, and the factory waits with it."
57// The person has a goal of their own: goal-run keeps it, and the factory asks for approval itself.
58export const PLAN_KEPT_FOR_FACTORY = "The person has a session goal of their own, so the factory's plan is not set as the goal. The factory asks the person to approve it in its own band; don't propose a plan or start the tasks."
59
60// Only the factory's own wording counts: a regular plan's "Approved. …" or "The person wants changes
61// to the plan" is the main loop's, not the factory's.
62export function parsePlanOutcome(text: string): { approved: true } | { changes: string } | { kept: true } | null {
63 if (text.startsWith(PLAN_APPROVED_FOR_FACTORY)) return { approved: true }
64 if (text.startsWith(PLAN_KEPT_FOR_FACTORY)) return { kept: true }
65 const m = /^The person wants changes: ([\s\S]*?)\nThe factory's spec-writer revises the spec/.exec(text)
66 return m ? { changes: m[1]!.trim() } : null
67}
68types/index.d.ts 49 lines1// The four kinds of MISTAKES.md entry, as mistake-brain's format spec names them.
2export type EntryType = 'mistake' | 'success' | 'decision' | 'handoff'
3// One MISTAKES.md entry, from its heading and fields; `at` is the heading's "YYYY-MM-DD HH:MM".
4export type Entry = { at: string; title: string; type: EntryType; status: string; route: string }
5export type Folder = 'Projects' | 'Areas' | 'Resources' | 'Archives'
6// One PARA file: what MEMORY.md says of it, or only that it's on disk (`isIndexed` false).
7export type Lesson = { file: string; summary: string; lessons: number; updated: string; isIndexed: boolean }
8export type Brain = {
9 entries: Entry[]
10 folders: Record<Folder, Lesson[]>
11 // MEMORY.md counts archives rather than listing them.
12 archives: number
13 rules: string[]
14 lastDream: string | null
15 // The MEMORY.md the index was read from, or null when none was found.
16 index?: string | null
17}
18// What just happened to the memory: a new entry of a kind, or a step of the skill's pipeline.
19export type PulseKind = EntryType | 'route' | 'promote' | 'dream'
20export type Pulse = { kind: PulseKind; title: string; at: number; folder?: Folder; file?: string }
21// Work another mod finished, offered to memory: a done follow-up, a reached goal.
22export type Offer = { key: string; from: 'followups' | 'goal-run'; title: string; detail: string; types: EntryType[] }
23
24declare module 'claude-code' {
25 interface PluginState {
26 'brain-hud': {
27 brain: Brain | null
28 pulse: Pulse | null
29 frame: number
30 offers: Offer[]
31 // Folders opened in the pane.
32 open: string[]
33 // Whether followups' /followup command is there, for a handoff's "Add to to-do".
34 canFollow: boolean
35 // Follow-ups started from the to-do list (id → title, bug or not), and the goal being run.
36 started: { id: string; title: string; isBug: boolean }[]
37 goalText: string
38 // Whether the pane already opened by itself this session.
39 paneShown: boolean
40 // The offer whose other types "…" opened.
41 more: string
42 // The graph's palette, from the theme in /config.
43 theme: 'dark' | 'light'
44 // The unrouted count the person said "Later" to: the reminder waits until there are more.
45 routeLater: number
46 }
47 }
48}
49