SLOPSHOPPER

brain-hud

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…

newpanebandguardcommandtoast
★ 2v0.4.1MITupdated 2026-10-26Amitro123/claudemod/plugins/brain-hud
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · brain-hud
│ ┃ Brain ✕ › fix the failing auth test and add an audit log call │ ┃ 🧠 Brain │ ┃ ⏺ Read(src/auth.ts) │ ┃ No MISTAKES.md or .agent-brain/ memory in ⎿ Read 6 lines │ ┃ this project yet. /brain-hud:mistake-brain ⏺ Update(src/auth.ts) │ ┃ init sets one up: the inbox, the PARA ⎿ Added 2 lines, removed 1 line │ ┃ folders and the index. ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ [ Set up memory ] │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /brain │ ⎿ brain-hud: No mistake-brain memory in this project yet: /brain-h │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Brain
🧠 Brain No MISTAKES.md or .agent-brain/ memory in this project yet. /brain-hud:mistake-brain init sets one up: the inbox, the PARA folders and the index. [ Set up memory ]
README

claudemod: mods for Claude Code

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.

ModWhat it does
clawd-hudA pixel spark above the prompt, usage batteries, a prompt-cache timer and a context meter
task-hudA to-do progress bar, and a pane with a pixel creature for each sub-agent
followupsClaude flags what it notices, asks whether to track it, and keeps a to-do you can export
rtl-toggleFixes the reading order of lines that mix English and Hebrew
goal-runAsks for a goal when a session starts, has Claude plan it, and runs a pixel Claude to 100% as the tasks get done
brain-hudAgent 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.

clawd-hud: a pixel spark

clawd-hud demo

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.

What it shows

Above the prompt (the AbovePrompt band)

  • The spark on the left. What it does follows what Claude is doing:
MoodWhenScenes
idlewaiting for youdrifting with a firefly, juggling, watering a sprout
workinga turn is runningat the terminal, stacking blocks, pondering, sending out sub-agents
working > 90sa hard taska smoke break on a crate joins the rotation
loopthe same tool with the same input 3× in one turnriding a coaster loop it can't get out of
donethe turn answeredfireworks and a hop (a lightbulb if the task was hard)
relax4 min after donestargazing on a hill, a pool float, cocoa by a snowy window
errorAPI error or refusalgoes gray under a little storm cloud, then idle
  • A battery per usage window, showing what is left: green above 30%, amber above 10%, red below. Each has a "resets in …" countdown.
Personal (Pro / Max)Enterprise
Batteries5h window, 7d weekly7d weekly, mo monthly spend cap ("over by …" past 100%)
Costnot shown$ spent this session
"resets in"the API's reset timethe API's reset time; for the spend cap without one, the 1st of next month
Cache timer (auto)1 hour5 minutes

Beside the model picker (the SessionMode slot)

  • Cache bolt: counts down the prompt-cache TTL from the last response. Green while warm, amber in the last minute, gray once cold.
  • Context capsule: how full the context window is, e.g. 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.

Install

/plugin install clawd-hud@claudemod

Options

Set with claude plugin configure clawd-hud@claudemod, or in the plugin settings:

OptionValuesDefaultWhat it does
planauto, personal, enterpriseautoWhich limits to show. auto switches to enterprise as soon as the account reports a monthly spend limit (spend_limit).
cacheTtlauto, 5m, 1hautoThe cache timer's length. auto is 1h on personal plans and 5m on enterprise.

How it works

  • 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.

Changelog

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.

task-hud: to-dos and sub-agents

task-hud: the to-do bar above the prompt and the Sub-agents pane

<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
  • A progress bar above the prompt once Claude writes a to-do list (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.
  • A Sub-agents pane that opens the first time Claude sends out a sub-agent, and reopens with /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.

followups: don't lose what Claude notices

followups: Claude asks about a bug it noticed in the question dialog; the To-do pane shows open, in-progress and done items with Start, Done and Export to

<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
  • Claude flags what it notices. The mod gives Claude a 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.
  • A second look after each answer. Claude doesn't always remember to flag. After each of its answers (in the main conversation, 200 characters or longer), a small model (haiku) reads the answer, together with the reports any sub-agents handed back since the last answer (where the work, and its loose ends, often is), for problems mentioned but left open: something flaky, unverified, skipped or put off, or an open question or decision that wasn't settled. If it can't run (haiku not allowed on the account, an API error), one line in the transcript says so instead of it failing quietly. Up to 3 of those wait in the band above the prompt. Anything already tracked, or that you said isn't needed (remembered per project, across sessions), isn't offered again. It costs one small call per answer and never delays the end of a turn. Turn it off with the autoDetect option (claude plugin configure followups@claudemod).
  • A sub-agent's open questions, read directly. When a sub-agent's report has a section for what it left open (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.
  • A check when the turn ends. If Claude's final reply says something is unverified, untested or left open (a short English and Hebrew phrase list: 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.
  • Claude stops and asks you about each one in the question dialog: Claude noticed: "Fix the flaky upload test" (Fails 1 in 5 runs · tests/upload.test.ts:42). Add it to your follow-ups? You answer Add to to-do or Not needed, or type your own wording, which is kept as the title. Claude gets your answer back and carries on. If you close the dialog without answering, the item waits in a band above the prompt with Add to to-do (a), Skip (s) and Add all, so it isn't lost.
  • A To-do pane lists what you kept, colored by status: orange ○ 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.
  • ▶ Start (per open item, or Start all open) marks it in progress and submits a message to Claude with the item's id, title, detail and location, asking it to do the work. When it finishes, Claude calls the 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.
  • Export to:
ButtonWrites toWhy
MemoryCLAUDE.mdClaude reads it at the start of every session
Docsdocs/FOLLOW-UPS.mda page of its own
READMEREADME.md, under Known issues & follow-upsvisible 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.

rtl-toggle: Hebrew and English on one line

Toggle offToggle on
Toggle off: embedded Hebrew renders out of orderToggle on: embedded Hebrew renders in correct order

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
  • A button above the prompt (עברית: פעיל / עברית: כבוי) 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.
  • What it changes: a line that starts with an English letter but has at least as many RTL words as LTR words gets one invisible leading RLM (U+200F). That gives the line a right-to-left base direction and right alignment, like a line that starts in Hebrew. Nothing else in the text changes.
  • What it leaves alone: code blocks (fenced and indented), lines that already start in Hebrew, lines with no RTL text, and inline code, links and URLs, which aren't counted. Table rows are fixed cell by cell, except in the terminal.
  • Limits: the mod API has no 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.

goal-run: a goal for the session

goal-run: the question a session opens with, and the Goal pane at 40%, with each task's priority and tokens

<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

How a goal runs

goal-run in four steps: the start-of-session question; the plan by priority in the approval dialog, with the pane waiting at 0%; the runner at 40% with each task's tokens and a to-do item offered to the goal; the runner cheering at 100%

  1. The question. When a session starts, a band above the prompt asks Is there a goal you want to work on this session?, with a field to type it. No goal this time dismisses it. If an earlier session in this project left a goal unfinished, the band offers Continue, New goal or Not now instead.
  2. The plan, by priority. Your goal goes to Claude, which breaks it into 3 to 8 tasks with the 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).
  3. Your approval, and the pace. The plan is put to you in a dialog, each task with its priority. Pick Approve, run through, Approve, ask between tasks, or Change it (Claude revises and asks again). Nothing starts before you approve. If you close the dialog, the plan waits in the pane with both approve buttons.
  4. Run through: Claude goes from task to task on its own. If it tries to stop with tasks left, it's sent on once; it can still stop when it's blocked or needs your decision.
  5. Ask between tasks: after each task, a dialog asks "Add tests" is done (40%). Go on to "Document the endpoint"?, with Continue or Stop here.
  6. A button in the pane switches between the two while you work.
  7. The run. The Goal pane shows the goal, a track from 0 to 100%, and a pixel Claude in blue sneakers. It stands still between tasks, runs while one is in progress, and cheers at the flag at 100%. Claude marks each task in progress and done with the 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.
  8. What each task cost. Every model request, sub-agents' included, is counted against the task in progress, and the pane shows each task's tokens. Planning and work between tasks have a line of their own. The count is the new tokens of each request (output, input written to the cache, uncached input). Cache reads are left out: they are the context earlier requests already counted, and counting them again on every step would grow the number far past what the task added. It is not a price.

Steering it while it runs

goal-run while it runs: the plan by priority with the pace choice; a task added mid-goal, placed by priority; the question between tasks; running through, sent on to the next task

  • Reorder. ↑ ↓ in the pane move tasks that haven't started. A task moved up takes the tasks it needs along with it, and one moved down takes the tasks that need it, so dependencies stay first. Claude works the list as you leave it.
  • Add a task. Ask Claude for more work during the goal, and it uses the 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.
  • Re-plan. A new plan in the middle of a goal keeps the done tasks and their tokens.
  • Work from followups' to-do list. Pressing ▶ Start on a to-do item while a goal is open is a side job. Claude gets no goal reminder with it, so the two don't compete, and its tokens show on a line of their own, Outside the goal. The band above the prompt asks whether to Add to goal, showing what that does to the plan (grows to 6 tasks: 40% → 33%), or Keep it separate. Added, the item becomes a task in progress, carrying the tokens it used so far, and a bug comes in as high priority. It turns done when followups marks the item done. The side job ends when you send a prompt of your own.

Commands and options

  • /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.
  • The goal is saved per project on the machine running Claude Code, like followups' list, so it carries over between local sessions in the same folder. If two sessions in the folder each set a goal, the one set later is the one saved. The other session is told once, with a toast, that its progress isn't saved.
  • askOnStart (on / off) turns the start-of-session question off.
  • A plan from factory. When the factory's spec is put to you as the goal's plan, the dialog is titled Factory plan and offers Approve, start building or Change it. Approved, the factory's builder does the tasks and marks them, and the main Claude is told not to: there's no goal reminder on your prompts, no nudge at a stop and no question between tasks. Changes go back to the factory's spec-writer. Approving later in the pane runs /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.

brain-hud: the agent's memory, made visible

brain-hud: three of the scenes above the prompt, an offer from followups, and the Brain pane with the memory drawn as a knowledge graph

<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.

The skill, built in

  • The command is /brain-hud:mistake-brain w
Source 6 files
hooks/register.tsx 491 lines
1import { 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}
491
hooks/art.ts 128 lines
1// 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]
128
hooks/graph.ts 178 lines
1// 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, '&amp;').replace(/</g, '&lt;')
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}
178
hooks/brain.ts 173 lines
1// 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}
173
hooks/texts.ts 68 lines
1// 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}
68
types/index.d.ts 49 lines
1// 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