SLOPSHOPPER

counterparts

The Counterparts sidebar mod on its own, for `claude plugin test hooks/sidebar` and `tsc -p hooks/sidebar`. The plugin at the repository root loads the same…

newpanebandspinnerguardcommand
★ 3v0.1.0MITupdated 2026-10-15mlapeter/counterparts/hooks/sidebar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · counterparts
│ ┃ Counterparts ✕ › fix the failing auth test and add an audit log call │ ┃ ◉ COUNTERPARTS │ ┃ ⏺ Read(src/auth.ts) │ ┃ 🬹🬝🬬🬭 🬞🬻🬎🬎🬱 ⎿ Read 6 lines │ ┃ ▐🬎🬕🬬🬺🬎🬭🬭🬭🬭🬷🬎🬻🬝🬬▌🬲 ⏺ Update(src/auth.ts) │ ┃ ▐🬎🬻▌🬝🬎🬺🬹🬹🬹🬻🬝▌▌🬲▌█ ⎿ Added 2 lines, removed 1 line │ ┃ ▐█🬹🬆████████🬊🬹🬎▌█ ⏺ Bash(bun test) │ ┃ ▐🬡████████████🬹▌█ ⎿ 3 pass, 1 fail │ ┃ 🬦🬝██████████████▌█ │ ┃ 🬴🬫🬎██🬒🬝🬊██████🬝🬬🬬🬛█🬞🬭 ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ 🬊🬺🬬🬹🬹🬹🬹🬹🬹🬹🬹🬹🬹🬻🬎🬺🬕🬻🬀▐🬕🬊🬱 │ ┃ 🬂🬺🬊🬎🬹🬹███🬝🬎🬆🬻🬎🬟🬭🬻🬆█🬹█🬺 ✻ Worked for 42s · done 4:20 PM │ ┃ 🬻🬹🬹🬹🬹🬻🬝🬷🬺🬲🬬🬵🬝🬹🬹🬎🬬██▌ │ ┃ █🬎🬝🬆🬧🬗▌███🬺🬹🬨███🬻🬹🬝🬻 › /counterparts │ ┃ ▐🬬🬊🬬█🬻██🬎🬬🬺🬻▌🬨🬺██🬝🬨🬻 ⎿ counterparts: Counterparts sidebar opened. │ ┃ 🬉🬲🬬🬆🬹🬹🬊🬎🬨███▌▌🬹🬹🬹🬝🬻🬄 │ ┃ █▌🬝🬷█🬝🬻🬷🬎🬎🬎▌🬲🬎🬎🬆🬎🬀 │ ┃ 🬁🬎🬎🬎🬎🬎🬎🬎🬎🬎🬎🬆 │ ┃ ─────────────────────────────────────────── │ ┃ ▣ client module ./body.tsx │ ┃ ─────────────────────────────────────────── │ ┃ 5-minute intro · /counterparts:meet │ ┃ Dashboard · /counterparts:dashboard │ ┃ Pause here · say “pause memory here” │ ┃ Hide this · /counterparts quiet │ ┃ Hide the fox · /counterparts avatar off ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Counterparts
◉ COUNTERPARTS 🬹🬝🬬🬭 🬞🬻🬎🬎🬱 ▐🬎🬕🬬🬺🬎🬭🬭🬭🬭🬷🬎🬻🬝🬬▌🬲 ▐🬎🬻▌🬝🬎🬺🬹🬹🬹🬻🬝▌▌🬲▌█ ▐█🬹🬆████████🬊🬹🬎▌█ ▐🬡████████████🬹▌█ 🬦🬝██████████████▌█ 🬴🬫🬎██🬒🬝🬊██████🬝🬬🬬🬛█🬞🬭 🬊🬺🬬🬹🬹🬹🬹🬹🬹🬹🬹🬹🬹🬻🬎🬺🬕🬻🬀▐🬕🬊🬱 🬂🬺🬊🬎🬹🬹███🬝🬎🬆🬻🬎🬟🬭🬻🬆█🬹█🬺 🬻🬹🬹🬹🬹🬻🬝🬷🬺🬲🬬🬵🬝🬹🬹🬎🬬██▌ █🬎🬝🬆🬧🬗▌███🬺🬹🬨███🬻🬹🬝🬻 ▐🬬🬊🬬█🬻██🬎🬬🬺🬻▌🬨🬺██🬝🬨🬻 🬉🬲🬬🬆🬹🬹🬊🬎🬨███▌▌🬹🬹🬹🬝🬻🬄 █▌🬝🬷█🬝🬻🬷🬎🬎🬎▌🬲🬎🬎🬆🬎🬀 🬁🬎🬎🬎🬎🬎🬎🬎🬎🬎🬎🬆 ────────────────────────────────────────────────────── ▣ client module ./body.tsx ────────────────────────────────────────────────────── 5-minute intro · /counterparts:meet Dashboard · /counterparts:dashboard Pause here · say “pause memory here” Hide this · /counterparts quiet Hide the fox · /counterparts avatar off
README

Counterparts

Agents are great. What you're missing is a counterpart.

Counterparts gives your AI assistant its own memory, modeled on human memory. It writes its memories itself, and they fade and grow the way yours do. It runs today with Claude Code.

counterparts.ai

Install

You need bun 1.3 or newer, or Node 22.15 or newer. macOS and Linux; Windows not yet supported.

curl -fsSL https://bun.sh/install | bash     # if you don't have bun
bun add -g counterparts
counterparts

With Node instead: npm install -g counterparts, then counterparts. Whichever runtime runs the setup is the one Claude Code's hooks and memory tools are wired to.

counterparts walks you through setup: your name and connecting Claude Code. No API keys. Then restart Claude Code and check it:

counterparts doctor

To remove it, run counterparts uninstall. Your memory is kept unless you ask for it to be deleted. The full walkthrough is in docs/QUICKSTART.md.

The dashboard

What it does

  • It writes its own memories. At the end of a session it writes down what it learned, in its own words. Notes, not transcripts.
  • It forgets on purpose. Memories fade unless they come up again. What matters sticks; the rest lets go.
  • It knows who it has been. After the first few sessions, every session opens with a short "who I am, who you are, where we left off" that it wrote itself.
  • It keeps a journal. Each session becomes a chapter in a first-person account of your time together.
  • It changes its mind slowly. What it believes about you shifts only under real evidence, and it keeps the history of what it used to think.
  • Things come to mind. As you talk, related memories surface quietly, the way one thing reminds you of another.
  • It's yours. Everything lives on your machine in one SQLite file you can read, export or delete. No API keys, no accounts, nothing sent anywhere. The text of your conversations is kept only until it's written up: deleted 3 days after that, never kept more than 14 days, and never in a backup. You can delete the conversation text, the backups or the activity log on their own at any time (counterparts delete).

Who it's for

People interested in giving an AI its own sense of self and building a relationship with it over time. Tinkerers, researchers, writers, and anyone who talks to an assistant every day and wishes it remembered.

What it's not

It's not a work memory. It doesn't track your tasks, code conventions or project docs. Plenty of tools do that, including the memory built into ChatGPT and Claude.

Where it's going

Today's models are frozen, so memory is the only learning they do. Once a real memory works on top of a frozen model, the plan is to pair it with an open-weight model whose weights can change, so the AI can learn and grow, not just remember.

Using it

Once installed it runs on its own. A few commands are worth knowing:

counterparts dashboard          # look at the memory in your browser
counterparts ask "..."          # ask the memory a question
counterparts scope . --off      # don't remember this directory
counterparts export             # a copy of everything, encrypted by default
counterparts --help             # the rest

Memory is on for every directory by default. The first session in a new directory asks whether you want it there.

How it works

Two pieces connect it to Claude Code. Hooks load a briefing at the start of each session and bring up related memories as you talk. A small MCP server gives the AI tools to save what it learned, write a journal entry, and look things up.

The core doesn't depend on Claude Code. Each part is its own module, with a CONTRACT.md describing what it does:

modulewhat it does
storeOne SQLite database, plus a rebuildable search cache and daily snapshots
encodeFilters what comes in: strips credentials, drops junk, scores what's new
rememberTakes what the AI writes during and at the end of a session
recallFinds related memories as you talk
associateLinks memories that come up together
schemasBeliefs about people and things, revised slowly
selfIdentity, the journal, and the start-of-session briefing
prospectiveReminders for later, triggered by a date or a topic
sleepNightly housekeeping: fading, pruning, merging, promoting
physicsThe math for strength and decay

More: docs/ELI5.md (the whole system in plain words) and docs/module-map.md.

Status

Early. It's been the author's daily memory since September 2026, on bun and macOS; Node (22.15+) and Linux pass a smoke test and an install run but have no daily user yet. Claude Desktop has no Linux build, so on Linux it is Claude Code only. Expect rough edges, and please open an issue when you hit one.

There are no API keys, and nothing leaves your machine except through Claude Code itself. Search by meaning runs on a small model on your machine, and a session that ends before it's written up is written up by the next session in that project.

That small model finds close paraphrases ("my doctor" finds a note about a physician), but not a description that never names the thing: "a pet that purrs" won't find a note about your cat. Exact words are strongest, and a short memory matches better than one padded with names and details.

Background

Counterparts is the third version of a memory project. The earlier ones (claude-engram, engram, and bansai) were each used daily before the next was written. CONSTITUTION.md holds the principles.

License and contributing

MIT. Contributions are welcome; see CONTRIBUTING.md.

Source 21 files
hooks/full.tsx 22 lines
1/**
2 * The plugin's own build's hooks module: the sidebar (`register.tsx`) and the
3 * speak-first (`speak-first.tsx`) in one, since a plugin loads one hooks
4 * module (`hooks.json` "modules" takes one entry). The plugin directory's
5 * build names `register.tsx` alone.
6 */
7import type { Register } from 'claude-code'
8
9import { register as sidebar } from './register'
10import { register as speakFirst } from './speak-first'
11
12/**
13 * ORDER MATTERS: the hook registered first is the outer one on an event. The sidebar comes first, so its
14 * `command.run {command: 'counterparts'}` hook answers `/counterparts …` without `next`, and the speak-first's
15 * `command.run` observer beneath it never sees that command; the sidebar writes `$.state` `typedIn` instead,
16 * which the speak-first reads. Reordering would change who sees what on every shared event.
17 */
18export const register: Register = (on, options) => {
19  sidebar(on, options)
20  speakFirst(on, options)
21}
22
hooks/register.tsx 1421 lines
1/**
2 * The Counterparts sidebar (v1, the directory trim of 2026-10-14): what
3 * memory is doing, in 35 columns. It shows; it does not act.
4 *
5 * The fox (or no avatar) above the title, the day and the count; then what
6 * was put in front of Claude for the last message (Memories, said in full;
7 * Subconscious, titles only), what this session saved, how often each
8 * mechanism fired today, and the last dream; then a footer of instructions:
9 * how to start the intro, open the dashboard, pause memory here, or hide the
10 * pane (`words.ts#footerLines`). Closed (`quiet`), it is only a dim tail under
11 * the prompt, ending with how to open it again. `/counterparts` opens it and
12 * sets the avatar and the theme. The design is the round-3 mockups Mike chose
13 * on 2026-10-10 (`~/counterparts-notes/mockups/2026-10-10-mod-round3/`);
14 * ../NOTES.md says where the build differs and why. The fuller v0.2 (the
15 * brain, the folder switch, the intro and dashboard buttons) is kept on the
16 * branch `sidebar-full-0.3.22`.
17 *
18 * Where things come from (no network, 2026-10-14):
19 *   - the status file Counterparts writes for it (`sidebar.json` beside the
20 *     configuration, next to `scopes.json`; `./status.ts`), read-only, on a
21 *     timer while the pane shows: which memory wrote it (another one's
22 *     numbers are never shown), day and count, times fired today, the last
23 *     dream, the activity log's newest rows (a mechanism's firings, this
24 *     session's rows on a resume) and the memories they name (opened in
25 *     place). Core's own processes write it: the worker after each turn, the
26 *     memory server after its tool calls and when the log moves;
27 *   - the scope registry file the hooks read (`scopes.json` beside the
28 *     configuration, read-only, `./scopes.ts`): where this folder stands, for
29 *     the paused banner and the status line;
30 *   - this session: the recall block the classic UserPromptSubmit hook
31 *     injects (Memories, Subconscious), and `tool.call` on remember (or
32 *     its old name note) / session_end (Saved) and on recall by id (`↗ opened`).
33 *
34 * No command is run, no tool is called, no process is started and nothing is
35 * fetched from here (the directory build's policy read, 2026-10-14), and
36 * nothing is a link: the footer says `/counterparts:dashboard`. The speak-first, which submits one prompt once per install, is
37 * `speak-first.tsx`, a hooks module of its own that only the full build
38 * carries.
39 *
40 * The engine lets `$` pass only to functions declared at the top of this
41 * file, reads a `$.state` source only from an atom declared here, and reads
42 * the surface modules (`<Client module=…>`) off this file's source, so those
43 * live here: every function that touches the engine, the atoms, the hooks.
44 * Everything else is beside it, with no `$`. The shared core: `state.ts`
45 * (timings, `run`), `words.ts` (what the sidebar says), `status.ts` (the
46 * status file, read), `feed.ts` (the log's rows and the recall block, read), `sections.ts` (the body's
47 * sections, laid out), `mechanisms.ts`, `scopes.ts`, `theme.ts` (the colours).
48 * The views: `pane.tsx` (the pane's tree on every surface), `desktop.tsx` (the
49 * header), `fox.ts` with `fox-art.ts` and `cells.ts` (the fox), `width.ts`
50 * and `tail.ts`, and `body.tsx` (the body, a Client surface module).
51 */
52
53import { atom, read, update } from 'claude-code'
54import type { EngineInterface, Register } from 'claude-code'
55
56import type {
57  SidebarAvatar,
58  SidebarDream,
59  SidebarLatest,
60  SidebarMind,
61  SidebarRow,
62  SidebarSaved,
63  SidebarScope,
64  SidebarTheme,
65  SidebarToday,
66  SidebarView,
67} from '../types'
68import type { BodyProps } from './body'
69import { header } from './desktop'
70import {
71  RULES,
72  classify,
73  dateOf,
74  decisionFor,
75  firingIds,
76  firingTitle,
77  firstSentence,
78  mechFirings,
79  mechsOf,
80  openedIds,
81  otherStore,
82  ourTool,
83  parseRecallBlock,
84  parseGlance,
85  parseOnboardingMarker,
86  parseStoreMarker,
87  savedItems,
88  surfacedIds,
89  titled,
90} from './feed'
91import type { DashDream, DashEvent, Firing, RecallBlock } from './feed'
92import { MECHS, mechById } from './mechanisms'
93import type { MechId } from './mechanisms'
94import {
95  IMAGE_COLS,
96  IMAGE_ROWS,
97  IMAGE_X0,
98  STAGE_COLS,
99  STAGE_MIN_PANE_ROWS,
100  TIER_ENV,
101  advance,
102  fineCells,
103  halfCells,
104  newDirector,
105  normDetail,
106  pickTier,
107  PNG_DIRS,
108  pngName,
109  runs,
110  stage,
111  stageRows,
112  stepKey,
113  trigger,
114} from './fox'
115import type { FoxDetail, FoxEvent, FoxTier, Step } from './fox'
116import { foxSvg } from './fox-svg'
117import { footer, footerRows, frame, pausedBanner, plainBody } from './pane'
118import type { PaneEls } from './pane'
119import { lookup, parentOf, parseRegistry, settingsHookConfig } from './scopes'
120import type { RegistryMode } from './scopes'
121import { layoutBody } from './sections'
122import type { Line } from './sections'
123import {
124  FIRING_MS,
125  NAMES_NOTHING,
126  OPEN_COLUMNS,
127  POLL_IDLE_MS,
128  POLL_MS,
129  ROOMY_ROWS,
130  SCOPES_FILE_TTL_MS,
131  SCOPE_RECHECK_MS,
132  UNKNOWN_SCOPE,
133  quiet,
134  run,
135} from './state'
136import { NOT_CARRIED, STATUS_FILE, keepsBefore, memoryIn, parseStatus, rowsNamed, rowsSince } from './status'
137import type { Status } from './status'
138import { CLASSIC_HINT, NARROW_HINT, TAIL_HINT, TAIL_SPARE, narrowHint, quietTail } from './tail'
139import { P, SURFACE, applyTheme, normTheme } from './theme'
140import { cells } from './width'
141import {
142  bodyOf,
143  isAbsolutePath,
144  isPaused,
145  memoriesCount,
146  memoryMeta,
147  normAvatar,
148  normView,
149  resumeWords,
150  statusFor,
151  storedWords,
152  words,
153} from './words'
154
155// ── constants ───────────────────────────────────────────────────────────────
156
157const PANE = 'counterparts'
158const TITLE = 'Counterparts'
159
160// ── state (the contract: ../types/index.d.ts) ──────────────────────────────
161
162const pulseA = atom({ plugin: 'counterparts', key: 'pulse' } as const, null)
163const dashA = atom({ plugin: 'counterparts', key: 'dash' } as const, 'unknown')
164const mindA = atom({ plugin: 'counterparts', key: 'mind' } as const, null)
165const savedA = atom({ plugin: 'counterparts', key: 'saved' } as const, [])
166const todayA = atom({ plugin: 'counterparts', key: 'today' } as const, null)
167const dreamA = atom({ plugin: 'counterparts', key: 'dream' } as const, null)
168const openA = atom({ plugin: 'counterparts', key: 'open' } as const, null)
169const mechA = atom({ plugin: 'counterparts', key: 'mech' } as const, null)
170const focusA = atom({ plugin: 'counterparts', key: 'focus' } as const, null)
171const latestA = atom({ plugin: 'counterparts', key: 'latest' } as const, null)
172const firingA = atom({ plugin: 'counterparts', key: 'firing' } as const, null)
173const scopeA = atom({ plugin: 'counterparts', key: 'scope' } as const, UNKNOWN_SCOPE)
174const viewA = atom({ plugin: 'counterparts', key: 'view' } as const, 'sidebar')
175const brainA = atom({ plugin: 'counterparts', key: 'brain' } as const, 'fox')
176const themeA = atom({ plugin: 'counterparts', key: 'theme' } as const, 'tron')
177const placedA = atom({ plugin: 'counterparts', key: 'placed' } as const, false)
178/** The person ran `/counterparts …` in this session: written here, read by `speak-first.tsx` (which never sees it). */
179const typedInA = atom({ plugin: 'counterparts', key: 'typedIn' } as const, null)
180
181// ── the engine, through `$` ────────────────────────────────────────────────
182
183/**
184 * The status file, read as it stands (`status.ts`): found beside the
185 * registry (`scopesFile`, the hooks' own configuration's folder), read again
186 * only when its size or time changed, and kept for this module life. Null:
187 * not there (no session has written it yet), unreadable, or not a version
188 * this sidebar reads. Read only, no permission asked, and no network.
189 */
190async function readStatus($: EngineInterface): Promise<Status | null> {
191  const scopes = await scopesFile($, false)
192  if (scopes === null) return null
193  const file = `${parentOf(scopes)}/${STATUS_FILE}`
194  if (file !== run.statusFile) {
195    run.statusFile = file
196    run.statusStamp = ''
197    run.status = null
198  }
199  try {
200    if (!(await $.fs.exists(file))) {
201      run.statusStamp = ''
202      run.status = null
203      return null
204    }
205    const st = await $.fs.stat(file)
206    const stamp = `${String(st.size)}:${String(st.mtimeMs)}`
207    if (stamp === run.statusStamp && run.status !== null) return run.status
208    const text = await $.fs.read(file)
209    const parsed = typeof text === 'string' ? parseStatus(text) : null
210    // A file caught half-written (never by its writers, who rename a whole one in) or of another version:
211    // the last good one stands until the next read.
212    if (parsed === null) return run.status
213    run.statusStamp = stamp
214    // Two writers racing: an older file of the same store over a newer one is not shown (the log only grows),
215    // unless it is newer by more than a race makes it — a store put back (`keepsBefore`).
216    const before = run.status
217    if (keepsBefore(before, parsed)) return before
218    run.status = parsed
219    return parsed
220  } catch {
221    return run.status
222  }
223}
224
225/**
226 * WHICH STORE (core #459): the status file must be the store's this session's
227 * wake named. Both known and different: everything shown from it is dropped
228 * (the pulse, today's counts, the last dream) and nothing more is shown until
229 * it matches again; the Mechanisms section says so. Either unknown (no marker
230 * yet, an older core, a store with no id yet): carry on.
231 */
232async function storeMatches($: EngineInterface, s: Status): Promise<boolean> {
233  const other = otherStore(run.wakeStore, s.storeId)
234  if (other !== run.foreign) {
235    run.foreign = other
236    if (other) {
237      await update($, dashA, () => 'other')
238      await update($, pulseA, () => null)
239      await update($, todayA, () => null)
240      await update($, dreamA, () => null)
241    }
242    // Either way the next look takes all of it again, from the right store.
243    run.lastSeq = -1
244  }
245  return !other
246}
247
248async function refreshStatus($: EngineInterface): Promise<void> {
249  $.ui.status(statusFor(await read($, scopeA)))
250}
251
252/** The mechanisms new rows prove: the header's dot lights in the newest one's colour for a moment (`firing`). */
253async function fire($: EngineInterface, ids: readonly MechId[]): Promise<void> {
254  if (ids.length === 0) return
255  const now = await $.clock.now()
256  await update($, firingA, () => ({ ids: [...ids], at: now }))
257  $.clock.after(FIRING_MS + 100, () => $.ui.invalidate('ui.render'))
258}
259
260async function setLatest($: EngineInterface, text: string, mech: MechId, body?: string): Promise<void> {
261  await setLatestAt($, text, mech, await $.clock.now(), body)
262}
263
264async function setLatestAt($: EngineInterface, text: string, mech: MechId, at: number, body?: string): Promise<void> {
265  const next: SidebarLatest = body === undefined ? { text, mech, at } : { text, mech, at, body }
266  await update($, latestA, l => (l !== null && l.at > at ? l : next))
267}
268
269/** Day, count, and when the file was written. */
270async function showPulse($: EngineInterface, s: Status): Promise<void> {
271  const before = await read($, pulseA)
272  const p = s.pulse
273  if (before === null || before.day !== p.day || before.memories !== p.memories || before.lastSeq !== p.lastSeq || before.at !== s.writtenAt) {
274    await update($, pulseA, () => ({ day: p.day, memories: p.memories, lastSeq: p.lastSeq, at: s.writtenAt }))
275  }
276}
277
278/**
279 * Times each mechanism fired today, as the file counted them (`firedToday`,
280 * rows on the store's calendar day). A file written before this clock's
281 * midnight counts a day that is over: none yet today, until the next file.
282 */
283async function showToday($: EngineInterface, s: Status): Promise<void> {
284  const now = await $.clock.now()
285  const date = dateOf(now)
286  const counts: SidebarToday['counts'] = {}
287  const over = dateOf(s.writtenAt) !== date
288  for (const m of MECHS) {
289    const v = s.today?.counts[m.id]
290    counts[m.id] = typeof v === 'number' ? (over ? 0 : v) : m.notBuilt === true ? null : 0
291  }
292  const next: SidebarToday = { date: over || s.today === null ? date : s.today.date, counts, source: 'dashboard' as const }
293  const before = await read($, todayA)
294  if (before === null || JSON.stringify(before) !== JSON.stringify(next)) await update($, todayA, () => next)
295}
296
297/**
298 * The newest dream, in its own voice: its first sentence and what follows. No
299 * "what changed that night" counts (Mike, 2026-10-10): the room goes to the
300 * dream itself.
301 */
302async function showDream($: EngineInterface, s: Status): Promise<void> {
303  const d = s.dreams[0]
304  if (d === undefined || typeof d.journal !== 'string' || d.journal.trim() === '') {
305    if ((await read($, dreamA)) !== null) await update($, dreamA, () => null)
306    return
307  }
308  const atOf = rowsNamed(s, 'dream.journaled').find(e => e.subject === d.id)?.at ?? null
309  const { first, rest } = firstSentence(d.journal)
310  const next: SidebarDream = { id: d.id, at: atOf, date: d.date, first, rest }
311  const before = await read($, dreamA)
312  if (before === null || before.id !== next.id || before.at !== next.at || before.rest !== next.rest) {
313    await update($, dreamA, () => next)
314  }
315  // A dream newer than anything this session did is the newest thing (the tail).
316  const at = next.at
317  if (at !== null) {
318    const latest = await read($, latestA)
319    if (latest === null || latest.at < at) await setLatestAt($, words('dreamed', next.first), 'dreaming', at)
320  }
321}
322
323/**
324 * One look at the status file. A new one: the pulse, today's counts and the
325 * last dream as it says them; the rows newer than the last look's light the
326 * header's dot and move the fox. Says whether anything new arrived.
327 */
328async function poll($: EngineInterface): Promise<boolean> {
329  if (run.polling) return false
330  run.polling = true
331  try {
332    const s = await readStatus($)
333    if (s === null) {
334      // No file yet: the first session's memory server or its first turn writes one.
335      if ((await read($, dashA)) !== 'down') await update($, dashA, () => 'down')
336      return false
337    }
338    if (!(await storeMatches($, s))) return false
339    if ((await read($, dashA)) !== 'up') await update($, dashA, () => 'up')
340    // Every look, the same file or not: past this clock's midnight its counts are yesterday's.
341    await showToday($, s)
342    if (run.lastSeq >= 0 && s.pulse.lastSeq === run.lastSeq && (await read($, pulseA))?.at === s.writtenAt) return false
343    const first = run.lastSeq < 0
344    const events = first ? [] : rowsSince(s, run.lastSeq)
345    run.lastSeq = s.pulse.lastSeq
346    await showPulse($, s)
347    await showDream($, s)
348    if (!run.seeded) {
349      run.seeded = true
350      await seedSession($, s).catch(() => undefined)
351    }
352    // Memories still named by the words Claude was shown: their titles, now the turn's row is in.
353    const mind = await read($, mindA)
354    if (mind !== null && mind.surfaced.some(r => r.id === null)) await titleSurfaced($, s, mind.turn)
355    const rows = events.map(e => classify(e, run.session, run.startedAt)).filter((r): r is SidebarRow => r !== null)
356    const lit = [...new Set(rows.filter(r => r.who !== 'other').sort((a, b) => b.at - a.at).flatMap(mechsOf))]
357    await fire($, lit)
358    // The fox dreams while a dream runs (one begun, none ended since), in any session or at night.
359    const dreams = events.filter(e => e.name === 'dream.begun' || e.name === 'dream.journaled' || e.name === 'dream.changed' || e.name === 'dream.undone')
360    const lastDream = dreams.sort((a, b) => a.seq - b.seq).at(-1)
361    if (lastDream !== undefined) await foxEvent($, lastDream.name === 'dream.begun' ? 'dream' : 'dreamEnd')
362    return first || events.length > 0
363  } catch {
364    return false
365  } finally {
366    run.polling = false
367  }
368}
369
370/** The pane is placed and the one shown: the only time the status file is read. */
371async function paneShown($: EngineInterface): Promise<boolean> {
372  try {
373    const pane = (await $.ui.panes()).find(p => p.id === PANE)
374    return pane !== undefined && pane.isShown && pane.isPlaced
375  } catch {
376    return false
377  }
378}
379
380/** Reads the status file while the pane shows, faster while things happen; stops when it doesn't (a drawing starts it again). */
381async function pollLoop($: EngineInterface): Promise<void> {
382  if ((await read($, viewA)) !== 'sidebar' || !(await paneShown($))) {
383    run.pollArmed = false
384    stopStage()
385    return
386  }
387  const moved = await poll($)
388  await refreshScope($)
389  run.quietPolls = moved ? 0 : run.quietPolls + 1
390  $.clock.after(run.quietPolls >= 4 ? POLL_IDLE_MS : POLL_MS, () => quiet(pollLoop($)))
391}
392
393function armPolling($: EngineInterface, delayMs: number): void {
394  if (run.pollArmed) return
395  run.pollArmed = true
396  $.clock.after(delayMs, () => quiet(pollLoop($)))
397}
398
399// ── this session's own: the recall block, the memory tools ─────────────────
400
401/**
402 * The recall block for the person's message: Memories (the "Came to mind"
403 * lane, said in full) and Subconscious (the "Quietly available" lane). The
404 * block names a surfaced memory by its gist only; its title comes from the
405 * turn's decision row (its `surfaced` ids) once the status file carries it,
406 * at the next look. Until then the gist stands: it is the text Claude was shown.
407 */
408async function onRecallBlock($: EngineInterface, block: RecallBlock): Promise<void> {
409  if (block.surfaced.length + block.footnotes.length === 0) return
410  const at = await $.clock.now()
411  const mind: SidebarMind = {
412    turn: block.turn,
413    at,
414    surfaced: block.surfaced.map(g => ({ id: null, title: g })),
415    footnotes: block.footnotes.map(f => ({ id: f.id, title: f.title })),
416  }
417  await update($, mindA, () => mind)
418  await update($, openA, o => (o !== null && (o.key.startsWith('mem:') || o.key.startsWith('sub:')) ? null : o))
419  const firstSurfaced = mind.surfaced[0]
420  const firstFoot = mind.footnotes[0]
421  if (firstSurfaced !== undefined) await setLatest($, words('remembered', firstSurfaced.title), 'retrieval')
422  else if (firstFoot !== undefined) await setLatest($, words('subconscious', firstFoot.title), 'retrieval')
423  await fire($, ['retrieval'])
424  await foxEvent($, 'came')
425  if (block.surfaced.length > 0 && run.status !== null && !run.foreign) await titleSurfaced($, run.status, block.turn)
426}
427
428/** The surfaced memories' ids and titles, from the turn's decision row in the status file, when it is there yet. */
429async function titleSurfaced($: EngineInterface, s: Status, turn: number): Promise<void> {
430  if (run.session === '') run.session = await $.session.id()
431  const row = decisionFor(rowsNamed(s, 'recall.decision', 40), run.session, turn)
432  if (row === undefined) return
433  const ids = surfacedIds(row)
434  const mind = await read($, mindA)
435  if (mind === null || mind.turn !== turn) return
436  const surfaced = mind.surfaced.map((r, i) => ({ id: ids[i] ?? r.id, title: memoryTitle(s, ids[i]) ?? r.title }))
437  await update($, mindA, m => (m === null || m.turn !== turn ? m : { ...m, surfaced }))
438  const first = surfaced[0]
439  const latest = await read($, latestA)
440  if (first !== undefined && latest !== null && latest.text.startsWith('remembered: ') && latest.at >= mind.at) {
441    await update($, latestA, l => (l === null ? l : { ...l, text: words('remembered', first.title) }))
442  }
443}
444
445/** A memory's title, from the status file, or null. */
446function memoryTitle(s: Status | null, id: string | null | undefined): string | null {
447  if (id === null || id === undefined) return null
448  const m = memoryIn(s, id)
449  return m !== null && m.title !== '' ? m.title : null
450}
451
452/**
453 * On the first look, when this module has seen nothing of the session yet (a
454 * resumed session, a pane opened after a reload that lost nothing but the
455 * module): Memories, Subconscious and Saved from the status file's rows for
456 * the session. The session is this one; a preview or a check may name another
457 * with `COUNTERPARTS_SIDEBAR_SESSION` (the term-loop shots do, since their
458 * session sends no message).
459 */
460async function seedSession($: EngineInterface, s: Status): Promise<void> {
461  const preview = ((await $.env.get('COUNTERPARTS_SIDEBAR_SESSION')) ?? '').trim()
462  // The pane can draw (and this run, once a module life) before session.start has named the session.
463  const session = preview !== '' ? preview : run.session !== '' ? run.session : await $.session.id().catch(() => '')
464  if (session === '') return
465  const [mind0, saved0] = await Promise.all([read($, mindA), read($, savedA)])
466  const ofSession = (e: DashEvent): boolean => e.detail?.some(d => (d.key === 'session' || d.key === 'actorId') && d.value === session) === true
467  if (mind0 === null) {
468    const decision = rowsNamed(s, 'recall.decision').filter(ofSession).find(e => classify(e)?.mechs.includes('retrieval') === true)
469    if (decision !== undefined) {
470      const surfaced = surfacedIds(decision)
471      let footnotes: string[] = []
472      try {
473        const list: unknown = JSON.parse(decision.detail?.find(d => d.key === 'footnotes')?.value ?? '[]')
474        if (Array.isArray(list)) footnotes = list.map(x => (x as { id?: unknown })?.id).filter((x): x is string => typeof x === 'string')
475      } catch {
476        footnotes = []
477      }
478      const ref = (id: string) => ({ id, title: memoryTitle(s, id) ?? id })
479      const turn = Number(decision.detail?.find(d => d.key === 'turn')?.value ?? 0)
480      const mind: SidebarMind = {
481        turn: Number.isFinite(turn) ? turn : 0,
482        at: decision.at,
483        surfaced: surfaced.map(ref),
484        footnotes: footnotes.map(ref),
485      }
486      if (mind.surfaced.length + mind.footnotes.length > 0) await update($, mindA, m => m ?? mind)
487    }
488  }
489  if (saved0.length === 0) {
490    const settles = rowsNamed(s, 'contradiction.settled')
491    const mine = rowsNamed(s, 'gate.deposit').filter(e => ofSession(e) && Number(e.detail?.find(d => d.key === 'accepted')?.value ?? 0) > 0)
492    const items = mine.slice(0, 12).map((e): SidebarSaved | null => {
493      const row = classify(e)
494      const id = row?.memory?.id ?? null
495      if (id === null) return null
496      const settle = settles.find(x => ofSession(x) && titled(x.detail?.find(d => d.key === 'holds')?.value).id === id && x.detail?.some(d => d.key === 'how' && d.value !== 'open') === true)
497      const over = settle === undefined ? null : titled(settle.detail?.find(d => d.key === 'over')?.value)
498      const title = memoryTitle(s, id) ?? row?.memory?.title ?? id
499      const replaces = over === null || over.id === null ? null : (memoryTitle(s, over.id) ?? over.title)
500      return { key: `seq:${String(e.seq)}`, id, title, at: e.at, replaces, replacesId: over?.id ?? null, kind: over === null ? 'new' : 'update' }
501    })
502    const list = items.filter((x): x is SidebarSaved => x !== null).sort((a, b) => b.at - a.at)
503    if (list.length > 0) await update($, savedA, sv => (sv.length === 0 ? list : sv))
504  }
505  // The newest of what was found, as the tail says it, unless something newer is known.
506  const [mind, saved] = await Promise.all([read($, mindA), read($, savedA)])
507  const said = mind?.surfaced[0] !== undefined ? words('remembered', mind.surfaced[0].title) : mind?.footnotes[0] !== undefined ? words('subconscious', mind.footnotes[0].title) : null
508  const first = saved[0]
509  if (first !== undefined && (mind === null || first.at > mind.at)) await setLatestAt($, storedWords(first.title), 'salience', first.at, bodyOf(first))
510  else if (mind !== null && said !== null) await setLatestAt($, said, 'retrieval', mind.at)
511}
512
513/** What a memory tool saved: "Saved this session", newest first; an update's old title from the status file when it has it. */
514async function onSaved($: EngineInterface, items: readonly SidebarSaved[]): Promise<void> {
515  if (items.length === 0) return
516  await update($, savedA, list => [...[...items].reverse(), ...list])
517  await foxEvent($, 'note')
518  const first = items[0]
519  if (first !== undefined) {
520    await setLatest($, items.length === 1 ? storedWords(first.title) : storedWords(first.title, items.length), 'salience', bodyOf(first))
521  }
522  await fire($, items.some(i => i.kind === 'update') ? ['salience', 'reconsolidation'] : ['salience'])
523  for (const it of items) {
524    const title = memoryTitle(run.foreign ? null : run.status, it.replacesId)
525    if (title === null) continue
526    await update($, savedA, list => list.map(x => (x.key === it.key ? { ...x, replaces: title } : x)))
527  }
528}
529
530/** A `recall` that opened memories by address: the footnotes it names get `↗ opened`. */
531async function onOpened($: EngineInterface, ids: readonly string[], handle: string | null): Promise<void> {
532  const mind = await read($, mindA)
533  if (mind === null) return
534  const hit = (f: { id: string | null; title: string }): boolean => (f.id !== null && ids.includes(f.id)) || (handle !== null && f.title.trim().toLowerCase() === handle.toLowerCase())
535  if (!mind.footnotes.some(hit) && !mind.surfaced.some(hit)) return
536  await update($, mindA, m =>
537    m === null ? m : { ...m, footnotes: m.footnotes.map(f => (hit(f) ? { ...f, opened: true } : f)), surfaced: m.surfaced.map(r => (hit(r) ? { ...r, opened: true } : r)) },
538  )
539  const named = mind.footnotes.find(hit) ?? mind.surfaced.find(hit)
540  if (named !== undefined) await setLatest($, words('opened', named.title), 'retrieval')
541}
542
543// ── a click in the body ────────────────────────────────────────────────────
544
545/**
546 * Opens a memory in place, every time, whatever its length: its title, text
547 * and kind from the status file (which carries the memories its rows name,
548 * each text's start). A click never opens the dashboard by itself (Mike,
549 * 2026-10-10): the layout shows up to 20 lines and ends a longer text, or one
550 * the file cut, with `more`, the one link to the memory's card there. One the
551 * file does not carry shows its title and that link. Clicked again, it closes.
552 */
553async function openMemory($: EngineInterface, key: string, id: string | null, fallback: string): Promise<void> {
554  const cur = await read($, openA)
555  if (cur !== null && cur.key === key) {
556    await update($, openA, () => null)
557    return
558  }
559  const at = await $.clock.now()
560  if (id === null) {
561    // Named only by the words Claude was shown (the turn's row not read yet): those words, in place.
562    await update($, openA, () => ({ key, id: null, status: 'ready' as const, title: fallback, text: '', meta: null, at }))
563    return
564  }
565  const s = run.foreign ? null : await readStatus($)
566  const m = memoryIn(s, id)
567  if (m === null) {
568    const gone = s !== null && id in s.memories
569    await update($, openA, () => ({ key, id, status: 'ready' as const, title: fallback, text: gone ? 'no longer in memory' : NOT_CARRIED, meta: null, at, cut: !gone }))
570    return
571  }
572  await update($, openA, () => ({ key, id, status: 'ready' as const, title: m.title || fallback, text: m.text.trim(), meta: memoryMeta(m), at, cut: m.cut }))
573}
574
575/**
576 * A mechanism in "Mechanisms today": what its recent firings were about, from
577 * its panel rows in the status file, grouped by what and when: the memories
578 * they name, with their titles, and for a dream's rows the dream's own changes
579 * (which name the memories its row only counts). A firing that names nothing
580 * is left out (`feed.ts#mechFirings`).
581 */
582async function openMech($: EngineInterface, id: MechId): Promise<void> {
583  const cur = await read($, mechA)
584  await update($, openA, o => (o !== null && o.key.startsWith('ev:') ? null : o))
585  if (cur !== null && cur.id === id) {
586    await update($, mechA, () => null)
587    return
588  }
589  const at = await $.clock.now()
590  const s = run.foreign ? null : await readStatus($)
591  if (s === null) {
592    await update($, mechA, () => ({ id, status: 'error' as const, events: [], at }))
593    return
594  }
595  const panel = s.panels[id] ?? []
596  const dreams = panel.some(e => e.name.startsWith('dream.')) ? s.dreams : []
597  let firings = mechFirings(panel, id, dreams, 8)
598  // The panel shows four lines, its repeats merged into one (Salience's deposits all narrate alike, so one
599  // memory). A mechanism that fired today and names fewer than three there reads the file's other rows too.
600  const want = Math.min(3, (await read($, todayA))?.counts[id] ?? 0)
601  if (firings.length < want) firings = deeperFirings(s, id, panel, dreams)
602  const titles: Record<string, string> = {}
603  for (const x of new Set(firings.flatMap(firingIds))) {
604    const t = memoryTitle(s, x)
605    if (t !== null) titles[x] = t
606  }
607  const events = firings.flatMap(f => {
608    const title = firingTitle(f, titles)
609    return title === null ? [] : [{ key: f.key, seq: f.seq, at: f.at, word: f.word, title, memoryId: f.memoryId, text: f.text }]
610  })
611  await update($, mechA, () => ({ id, status: 'ready' as const, events, at }))
612}
613
614/** A mechanism's firings from its panel's rows and the file's rows of its events: only when the panel named too few. */
615function deeperFirings(s: Status, id: MechId, panel: readonly DashEvent[], dreams: readonly DashDream[]): Firing[] {
616  const names = new Set(RULES.filter(r => r.mech === id && !NAMES_NOTHING.has(r.name)).map(r => r.name))
617  const seen = new Set<number>()
618  const rows = [...panel, ...s.events.filter(e => names.has(e.name))].filter(e => (seen.has(e.seq) ? false : (seen.add(e.seq), true)))
619  return mechFirings(rows, id, dreams, 8)
620}
621
622/** A click on a row of the body, by the key the layout gave it. */
623async function clickBody($: EngineInterface, key: string): Promise<void> {
624  // (The `more` that ends a cut text is plain words, with no link: a click on it is its row's.)
625  if (key.startsWith('head:')) {
626    const section = key.slice(5)
627    await update($, focusA, f => (f === section ? null : section))
628    return
629  }
630  if (key === 'dream' || key === 'glance') {
631    const at = await $.clock.now()
632    const cur = await read($, openA)
633    await update($, openA, () => (cur?.key === key ? null : { key, id: null, status: 'ready' as const, title: null, text: '', meta: null, at }))
634    return
635  }
636  if (key.startsWith('mech:')) {
637    const id = key.slice(5) as MechId
638    if (mechById(id) !== undefined && mechById(id)?.notBuilt !== true) await openMech($, id)
639    return
640  }
641  const mind = await read($, mindA)
642  if (key.startsWith('mem:') || key.startsWith('sub:')) {
643    const list = key.startsWith('mem:') ? mind?.surfaced : mind?.footnotes
644    const r = list?.[Number(key.slice(4))]
645    if (r !== undefined) await openMemory($, key, r.id, r.title)
646    return
647  }
648  if (key.startsWith('saved:')) {
649    const it = (await read($, savedA)).find(s => `saved:${s.key}` === key)
650    if (it !== undefined) await openMemory($, key, it.id, it.title)
651    return
652  }
653  if (key.startsWith('ev:')) {
654    const mech = await read($, mechA)
655    const ev = mech?.events.find(e => `ev:${e.key}` === key)
656    if (ev === undefined) return
657    if (ev.memoryId !== null) await openMemory($, key, ev.memoryId, ev.title)
658    else {
659      const cur = await read($, openA)
660      const at = await $.clock.now()
661      await update($, openA, () => (cur?.key === key ? null : { key, id: null, status: 'ready' as const, title: null, text: '', meta: null, at }))
662    }
663    return
664  }
665}
666
667// ── the folder's switch ────────────────────────────────────────────────────
668
669/** A file's text, or null when it is not there or can't be read. */
670async function readText($: EngineInterface, path: string): Promise<string | null> {
671  try {
672    if (!(await $.fs.exists(path))) return null
673    const text = await $.fs.read(path)
674    return typeof text === 'string' ? text : null
675  } catch {
676    return null
677  }
678}
679
680/**
681 * The registry this session's hooks read, found as they find it
682 * (`config-path.ts`): the wired hook's `--config` (the user's settings, then
683 * this folder's), else `COUNTERPARTS_CONFIG`, else
684 * `~/.counterparts/claude-code.json`; `scopes.json` beside it. Read-only, no
685 * permission asked. Found again after five minutes, or when asked to.
686 */
687async function scopesFile($: EngineInterface, fresh: boolean): Promise<string | null> {
688  const now = await $.clock.now()
689  if (!fresh && run.scopesFile !== null && now - run.scopesFileAt < SCOPES_FILE_TTL_MS) return run.scopesFile
690  const home = ((await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? '').trim()
691  const moved = ((await $.env.get('CLAUDE_CONFIG_DIR')) ?? '').trim()
692  const settings = [
693    ...(moved.length > 0 ? [`${moved}/settings.json`] : home.length > 0 ? [`${home}/.claude/settings.json`] : []),
694    ...(run.cwd.length > 0 ? [`${run.cwd}/.claude/settings.json`, `${run.cwd}/.claude/settings.local.json`] : []),
695  ]
696  let named: string | null = null
697  for (const file of settings) {
698    const text = await readText($, file)
699    const config = text === null ? undefined : settingsHookConfig(text)
700    if (typeof config === 'string') {
701      named = config
702      break
703    }
704  }
705  if (named === null) {
706    const env = ((await $.env.get('COUNTERPARTS_CONFIG')) ?? '').trim()
707    named = env.length > 0 ? env : home.length > 0 ? `${home}/.counterparts/claude-code.json` : null
708  }
709  // A named configuration that is not absolute is refused by the hooks too: nothing to read.
710  run.scopesFile = named !== null && isAbsolutePath(named) ? `${parentOf(named)}/scopes.json` : null
711  run.scopesFileAt = now
712  return run.scopesFile
713}
714
715/** `scopes.ts#canonicalScopePath`: the deepest existing ancestor realpathed, the rest kept. */
716async function canonicalPath($: EngineInterface, path: string): Promise<string> {
717  const known = run.canon.get(path)
718  if (known !== undefined) return known
719  let head = path.length > 1 ? path.replace(/[/\\]+$/, '') : path
720  const tail: string[] = []
721  for (let i = 0; i < 64; i += 1) {
722    let real: string | undefined
723    try {
724      real = (await $.fs.stat(head, { resolve: true })).realPath
725    } catch {
726      real = undefined
727    }
728    if (real !== undefined) {
729      if (tail.length === 0) {
730        if (run.canon.size > 200) run.canon.clear()
731        run.canon.set(path, real)
732        return real
733      }
734      return `${real.replace(/[/\\]+$/, '')}/${tail.join('/')}`
735    }
736    const parent = parentOf(head)
737    if (parent === head || parent.length === 0) break
738    tail.unshift(head.slice(parent.length).replace(/^[/\\]+/, ''))
739    head = parent
740  }
741  return path
742}
743
744/** Where this folder stands, from the registry file: null when it can't be read (no home folder, no folder, a read refused). */
745async function readScopeFile($: EngineInterface, fresh: boolean): Promise<SidebarScope | null> {
746  if (run.cwd.length === 0) return null
747  const file = await scopesFile($, fresh)
748  if (file === null) return null
749  let entries: Record<string, RegistryMode> = {}
750  try {
751    // Absent is ordinary: every folder is unset. Present and unparseable reads
752    // the same way, as the hooks read it (`scopes.ts#readScopes`).
753    if (await $.fs.exists(file)) entries = parseRegistry(String(await $.fs.read(file)))
754  } catch {
755    return null
756  }
757  const target = await canonicalPath($, run.cwd)
758  const list = await Promise.all(
759    Object.entries(entries).map(async ([key, mode]) => ({ key, mode, canonical: await canonicalPath($, key) })),
760  )
761  const v = lookup(list, target)
762  return { mode: v.mode, own: v.canonical !== null && v.canonical === target, setBy: v.matched, dir: target, error: null, busy: false, unread: null }
763}
764
765/**
766 * Reads this folder's registry entry and shows any change: at session start,
767 * on each status poll while the pane is drawn, once a minute otherwise, and
768 * on every `/counterparts`. A pause made in another session shows here within
769 * one of those. No tool is called and nothing is logged.
770 */
771async function refreshScope($: EngineInterface, fresh = false): Promise<void> {
772  // Before session.start (the band may draw first) there is no folder yet: the state stays as it is, not "unreadable".
773  if (run.scopeReading || run.cwd.length === 0) return
774  run.scopeReading = true
775  try {
776    const found = await readScopeFile($, fresh).catch(() => null)
777    const before = await read($, scopeA)
778    const next: SidebarScope = found ?? (before.mode === 'unknown' ? { ...before, unread: 'unreadable' } : before)
779    if (next.mode === before.mode && next.own === before.own && next.setBy === before.setBy && next.dir === before.dir && next.unread === before.unread && before.error === null) return
780    await update($, scopeA, () => next)
781    await refreshStatus($)
782  } finally {
783    run.scopeReading = false
784  }
785}
786
787// ── views and the avatar ─────────────────────────────────────────────
788
789/**
790 * Opens (or re-opens) the pane and asks for a fresh drawing of it. Measured
791 * live (v0.1): a pane closed by hand and opened again is drawn from the
792 * terminal's settled evaluation, so this module's render hook never runs and
793 * the fox, stopped at the close, would stay frozen.
794 */
795async function openPane($: EngineInterface): Promise<{ isPlaced: boolean }> {
796  await setView($, 'sidebar')
797  // The desktop asks for a narrower pane: its font fits a fifth more in a cell's width (Mike, 2026-10-13).
798  const opened = await $.ui.open({ id: PANE, title: TITLE, columns: run.surface === 'desktop' ? OPEN_COLUMNS - 4 : OPEN_COLUMNS })
799  await update($, placedA, () => opened.isPlaced)
800  $.ui.invalidate('ui.render')
801  return { isPlaced: opened.isPlaced }
802}
803
804/** Closes the pane for the quiet view: the tail draws again. */
805async function closeQuiet($: EngineInterface): Promise<void> {
806  await setView($, 'quiet')
807  stopStage()
808  await update($, placedA, () => false)
809  await $.ui.close({ id: PANE })
810  $.ui.invalidate('ui.render')
811}
812
813/** The view, for this session and (in `$.store`) the next. */
814async function setView($: EngineInterface, view: SidebarView): Promise<void> {
815  if ((await read($, viewA)) !== view) await update($, viewA, () => view)
816  await $.store.set('view', view)
817  if (view !== 'sidebar') stopStage()
818}
819
820/** The avatar, for every session (`$.store` `brain`, its old name): the fox or none. */
821async function setAvatar($: EngineInterface, mode: SidebarAvatar): Promise<void> {
822  await $.store.set('brain', mode)
823  if ((await read($, brainA)) !== mode) await update($, brainA, () => mode)
824  if (mode !== 'fox') stopStage()
825  $.ui.invalidate('ui.render')
826}
827
828/** The pane's colours, for every session (`$.store`): tron or cozy. The Pane hook applies it at each drawing. */
829async function setTheme($: EngineInterface, name: SidebarTheme): Promise<void> {
830  await $.store.set('theme', name)
831  if ((await read($, themeA)) !== name) await update($, themeA, () => name)
832}
833
834/**
835 * Opened without being asked: only where it docks as a sidebar, and only when
836 * the stored view is the sidebar. Measured live (v0.1): the band draws BEFORE
837 * `session.start` has read the stored view, so the view (and the theme) is
838 * read from the store here.
839 */
840async function openUnasked($: EngineInterface): Promise<void> {
841  const view = normView(await $.store.get('view'))
842  if ((await read($, viewA)) !== view) await update($, viewA, () => view)
843  const theme = normTheme(await $.store.get('theme'))
844  if ((await read($, themeA)) !== theme) await update($, themeA, () => theme)
845  if (view !== 'sidebar') return
846  const opened = await openPane($)
847  if (!opened.isPlaced && run.interactive) {
848    run.hint = true
849    $.ui.invalidate('ui.render')
850  }
851}
852
853/** A refused blit is a Raster gone or not mounted yet: ask for a fresh drawing, a few times at most until one is taken. */
854function redrawAfterRefusal($: EngineInterface): void {
855  if (run.redraws >= 3) return
856  run.redraws += 1
857  $.ui.invalidate('ui.render')
858}
859
860// ── the fox (fox.ts holds what it does and how each frame looks) ──
861
862function stopStage(): void {
863  stage.gen += 1
864  stage.live = false
865}
866
867/** Something real happened: the fox's moment for it plays now, if the stage is up (else the next drawing shows where it is). */
868async function foxEvent($: EngineInterface, ev: FoxEvent): Promise<void> {
869  const now = await $.clock.now()
870  stage.director ??= newDirector(now)
871  trigger(stage.director, ev, now)
872  if (stage.live) {
873    stage.gen += 1
874    const gen = stage.gen
875    $.clock.after(0, () => quiet(stageStep($, gen)))
876  }
877}
878
879/**
880 * What is on screen next, then one wait until it changes: no timer runs in
881 * between (a still hold is one wait of 4–9 s). A change on a Raster or an
882 * Image is a blit; in sextants (Text) or on the desktop (an Svg) a redraw of
883 * the pane. A pane no longer shown stops it (the next drawing starts it). A
884 * refused blit stops it; an Image refused twice in a row because it draws
885 * its alt here leaves `auto` drawing sextants or half-blocks for the session. Any other
886 * refusal (not mounted yet, another size, the terminal not yet asked) is not
887 * counted: it says nothing about pictures.
888 */
889/** What the engine's blit refusal says when an Image draws its alt (Claude Code 2.1.300). */
890const ALT_REFUSAL = 'draws its alt'
891
892async function stageStep($: EngineInterface, gen: number): Promise<void> {
893  if (gen !== stage.gen || !stage.live) return
894  const now = await $.clock.now()
895  stage.director ??= newDirector(now)
896  const { step, next } = advance(stage.director, now)
897  const key = stepKey(step)
898  if (key !== stage.shown) {
899    if (!(await paneShown($))) {
900      stopStage()
901      return
902    }
903    if (gen !== stage.gen) return
904    stage.shown = key
905    const tier = stage.tier
906    if (tier === 'basic' || tier === 'image') {
907      const args =
908        tier === 'basic'
909          ? { requestId: PANE, key: 'stage', cells: halfCells(step), columns: STAGE_COLS, rows: stageRows('basic') }
910          : { requestId: PANE, key: 'stage', source: pngFile(step), columns: IMAGE_COLS, rows: IMAGE_ROWS }
911      // Not awaited: the next wait is set below at once, whatever the blit's answer.
912      quiet(
913        (async () => {
914          let r: Awaited<ReturnType<EngineInterface['ui']['blit']>>
915          try {
916            r = await $.ui.blit(args)
917          } catch {
918            if (gen === stage.gen) stopStage()
919            return
920          }
921          if (gen !== stage.gen) return
922          if (r.deny === undefined) {
923            run.redraws = 0
924            if (tier === 'image') stage.imageDenies = 0
925            return
926          }
927          if (tier === 'image' && r.deny.includes(ALT_REFUSAL)) {
928            stage.imageDenies += 1
929            if (stage.imageDenies >= 2) stage.noImages = true
930          }
931          stopStage()
932          redrawAfterRefusal($)
933        })(),
934      )
935    } else $.ui.invalidate('ui.render')
936  }
937  $.clock.after(Math.max(16, next - now), () => quiet(stageStep($, gen)))
938}
939
940/** Starts the stage's timer under a new generation, once its element is mounted. */
941function startStage($: EngineInterface): void {
942  if (stage.live) return
943  stage.gen += 1
944  stage.live = true
945  const gen = stage.gen
946  $.clock.after(0, () => quiet(stageStep($, gen)))
947}
948
949/**
950 * The desktop's pane, as wide as the person drags it (it keeps their width, not the one a plugin asks for;
951 * measured 2026-10-13): the fox shows down to this many columns, scaled to fit, where the terminal needs
952 * STAGE_COLS. `w` is the last drawing's width.
953 */
954const STAGE_MIN_PROSE_COLS = 22
955const prosePane = { w: STAGE_COLS }
956
957/** The Image's source for a step: the PNG file, which the terminal reads itself. */
958function pngFile(step: Step): { file: string; format: 'png' } {
959  return { file: `${stage.pngDir ?? ''}/${pngName(step)}`, format: 'png' }
960}
961
962/** The folder of the fox's PNGs under the plugin's, or null when neither place holds them. */
963async function findPngDir($: EngineInterface): Promise<string | null> {
964  const root = $.plugin.root.replace(/[/\\]+$/, '')
965  for (const rel of PNG_DIRS) {
966    const dir = `${root}/${rel}`
967    try {
968      if (await $.fs.exists(`${dir}/idle.png`)) return dir
969    } catch {
970      // not readable: the next place
971    }
972  }
973  return null
974}
975
976/** A PNG's bytes (base64) for the desktop's Svg, read once from the plugin's folder; '' when it can't be. */
977async function pngData($: EngineInterface, name: string): Promise<string> {
978  const known = stage.pngData.get(name)
979  if (known !== undefined) return known
980  let data = ''
981  if (stage.pngDir !== null) {
982    try {
983      data = (await $.fs.read(`${stage.pngDir}/${name}`, { as: 'bytes' })).base64
984    } catch {
985      data = ''
986    }
987  }
988  stage.pngData.set(name, data)
989  return data
990}
991
992/** The stage's element in a tier, for the step on screen (`png`: the desktop's picture, base64). */
993function stageElement(els: ReturnType<EngineInterface['ui']['resolve']>, tier: FoxTier, step: Step, png: string): JSX.Element {
994  if (tier === 'basic' && 'Raster' in els) {
995    const { Raster } = els
996    return <Raster key="stage" columns={STAGE_COLS} rows={stageRows('basic')} cells={halfCells(step)} />
997  }
998  if (tier === 'image' && 'Image' in els) {
999    const { Box, Image } = els
1000    return (
1001      <Box key="stage-box" flexDirection="row" paddingLeft={IMAGE_X0}>
1002        <Image key="stage" source={pngFile(step)} columns={IMAGE_COLS} rows={IMAGE_ROWS} alt="a small fox" />
1003      </Box>
1004    )
1005  }
1006  if (tier === 'svg' && 'Svg' in els) {
1007    const { Svg } = els
1008    const svg = foxSvg(step, png)
1009    // A pane dragged narrower than the fox: the fox scales down with it (about 7 px a cell on the desktop).
1010    const k = Math.min(1, (prosePane.w * 7) / svg.width)
1011    return <Svg key="stage" source={svg.source} alt="a small fox" width={Math.round(svg.width * k)} height={Math.round(svg.height * k)} />
1012  }
1013  // fine: each row as runs of one colour pair; a cell with no colour is the panel's.
1014  const { Box, Text } = els
1015  return (
1016    <Box key="stage" flexDirection="column" width={STAGE_COLS}>
1017      {fineCells(step).map((line, y) => (
1018        <Box key={`s${String(y)}`} flexDirection="row">
1019          {runs(line).map((r, i) => (
1020            <Text key={String(i)} {...(r.fg === null ? {} : { color: r.fg })} {...(r.bg === null ? {} : { backgroundColor: r.bg })}>
1021              {r.ch}
1022            </Text>
1023          ))}
1024        </Box>
1025      ))}
1026    </Box>
1027  )
1028}
1029
1030/** The fox's detail, for every session (`$.store` `avatarDetail`): auto, images, fine or basic. */
1031async function setDetail($: EngineInterface, detail: FoxDetail): Promise<void> {
1032  await $.store.set('avatarDetail', detail)
1033  stage.detail = detail
1034  stage.noImages = false
1035  stage.imageDenies = 0
1036  stopStage()
1037  stage.shown = ''
1038  $.ui.invalidate('ui.render')
1039}
1040
1041/** A drawing of the pane: the status file is read from here on, until the pane stops showing. */
1042function wake($: EngineInterface): void {
1043  if (run.hint) {
1044    run.hint = false
1045    $.ui.invalidate('ui.render')
1046  }
1047  armPolling($, run.lastSeq >= 0 ? POLL_MS : 0)
1048  if (!run.woke) {
1049    run.woke = true
1050    quiet(refreshScope($))
1051  }
1052  // Drawn, it is placed: the tail under the prompt stands down (written after the drawing, never during it).
1053  $.clock.after(0, () => {
1054    quiet(
1055      (async () => {
1056        if (!(await read($, placedA))) await update($, placedA, () => true)
1057      })(),
1058    )
1059  })
1060}
1061
1062/**
1063 * The body's lines, with no link on any of them (2026-10-14): the `more` that
1064 * ends a cut text is words, and a click on it is its row's. The rest is on the
1065 * dashboard, which `/counterparts:dashboard` opens (the footer says so).
1066 */
1067function plainLines(lines: readonly Line[]): Line[] {
1068  return lines.map(l => {
1069    if (l.link === undefined) return l
1070    const { link: _dropped, ...rest } = l
1071    return rest
1072  })
1073}
1074
1075// ── hooks ───────────────────────────────────────────────────────────────────
1076
1077export const register: Register = on => {
1078  on('session.start', async ($, e, next) => {
1079    run.interactive = e.isInteractive
1080    run.cwd = e.cwd
1081    run.session = await $.session.id()
1082    if (run.startedAt === 0) run.startedAt = await $.clock.now()
1083    await $.command.register({
1084      name: 'counterparts',
1085      description: 'Counterparts sidebar: open it, make it quiet, or set the avatar or the theme',
1086      argumentHint: '[quiet | avatar animal|off | avatar detail auto|images|fine|basic | theme tron|cozy | resume]',
1087    })
1088    // (A `claudeMemory` or `claudeMemorySwitch` left in the store by an older version is never read.)
1089    const [viewPref, brainPref, themePref, detailPref] = await Promise.all([
1090      $.store.get('view'),
1091      $.store.get('brain'),
1092      $.store.get('theme'),
1093      $.store.get('avatarDetail'),
1094    ])
1095    // A new module life's fox starts fresh: still, awake, trying pictures again where they may show.
1096    stage.director = null
1097    stage.noImages = false
1098    stage.imageDenies = 0
1099    stage.detail = normDetail(detailPref)
1100    // The PNGs ship as files beside the module (art/fox/): found once; none found, the terminal draws no pictures.
1101    stage.pngDir = await findPngDir($)
1102    stage.pngData.clear()
1103    if (stage.pngDir === null) stage.noImages = true
1104    // Pictures: tried, and the engine says. Where they can't show, whether the font draws sextants, read once (fox.ts).
1105    // (Each name spelled out: the engine lists the variables a module reads.)
1106    const envVals = await Promise.all([
1107      $.env.get('TERM_PROGRAM').catch(() => undefined),
1108      $.env.get('TERM').catch(() => undefined),
1109      $.env.get('KITTY_WINDOW_ID').catch(() => undefined),
1110      $.env.get('WEZTERM_EXECUTABLE').catch(() => undefined),
1111    ])
1112    stage.env = Object.fromEntries(TIER_ENV.map((k, i) => [k, envVals[i]]).filter(([, v]) => typeof v === 'string'))
1113    await update($, themeA, () => normTheme(themePref))
1114    await update($, viewA, () => normView(viewPref))
1115    await update($, brainA, () => normAvatar(brainPref))
1116    // Nothing is opened or drawn here, and the status file is not read. The pane
1117    // opens unasked from the first drawing of the band above the prompt, which
1118    // says whether this surface docks a pane; the fox and the status reads start
1119    // with the pane's own first drawing. One thing is read: where this folder
1120    // stands, so a session started in a paused folder says so at once.
1121    quiet(refreshStatus($))
1122    if (run.interactive) {
1123      // Where this folder stands, now and every few minutes.
1124      $.clock.after(0, () => quiet(refreshScope($)))
1125      run.scopeTimer?.cancel()
1126      run.scopeTimer = $.clock.every(SCOPE_RECHECK_MS, () => quiet(refreshScope($)))
1127    }
1128    return next(e)
1129  })
1130
1131  on('command.run', { command: 'counterparts' }, async ($, e) => {
1132    // This hook answers without `next`, so no hook beneath sees the command: the speak-first learns it from here.
1133    const kind = (e.origin as { kind?: string } | undefined)?.kind
1134    if (kind === 'composer' || kind === 'bridge') {
1135      if (run.session === '') run.session = await $.session.id()
1136      await update($, typedInA, () => run.session)
1137    }
1138    const [verb = '', arg = ''] = e.args.trim().split(/\s+/)
1139    await refreshScope($, true)
1140    // `strip` (dropped 2026-10-10) is quiet now: an older term-loop's put-back can still type it.
1141    if (verb === 'quiet' || verb === 'hide' || verb === 'rail' || verb === 'strip') {
1142      await closeQuiet($)
1143      return { text: 'Counterparts quiet: the newest thing shows dim after the line under the prompt. /counterparts opens the sidebar.' }
1144    }
1145    if (verb === 'avatar') {
1146      if (arg === 'detail') {
1147        const d = e.args.trim().split(/\s+/)[2] ?? ''
1148        if (d !== 'auto' && d !== 'images' && d !== 'fine' && d !== 'basic') {
1149          return { text: `counterparts avatar detail: auto, images, fine or basic (now ${stage.detail}, ${stage.tier === 'image' ? 'drawn as a picture' : stage.tier === 'fine' ? 'drawn in sextants' : stage.tier === 'svg' ? 'drawn as a drawing' : 'drawn in half-blocks'}).` }
1150        }
1151        await setDetail($, d)
1152        return {
1153          text:
1154            d === 'auto'
1155              ? 'The fox is drawn the best way this terminal shows: a picture, else sextants, else half-blocks.'
1156              : d === 'images'
1157                ? 'The fox is drawn as a picture (kitty, Ghostty); a terminal that cannot shows its name instead.'
1158                : d === 'fine'
1159                  ? 'The fox is drawn in sextants (six pixels a cell), for a font that has them.'
1160                  : 'The fox is drawn in half-blocks, which every terminal shows.',
1161        }
1162      }
1163      if (arg === 'animal' || arg === 'fox') {
1164        await setAvatar($, 'fox')
1165        return { text: 'The avatar is the fox: it sits still, now and then blinks or looks around, naps when nothing happens, and shows what memory is doing.' }
1166      }
1167      if (arg === 'off') {
1168        await setAvatar($, 'off')
1169        return { text: 'No avatar: the header is two lines.' }
1170      }
1171      return { text: 'counterparts avatar: animal or off; or detail auto, images, fine or basic.' }
1172    }
1173    if (verb === 'theme') {
1174      if (arg !== 'tron' && arg !== 'cozy') return { text: `counterparts theme: tron or cozy (now ${normTheme(await read($, themeA))}).` }
1175      await setTheme($, arg)
1176      return { text: arg === 'cozy' ? 'Theme cozy: walnut, parchment, leaf green and pumpkin, in every session.' : 'Theme tron: black, cyan and amber, in every session.' }
1177    }
1178    if (verb === 'resume') {
1179      // v1 changes no setting from here: it says where the folder stands (the registry file, read above) and how to resume.
1180      return { text: resumeWords(await read($, scopeA)) }
1181    }
1182    if (verb !== '' && verb !== 'open' && verb !== 'sidebar') {
1183      return { text: 'Usage: /counterparts [quiet | avatar animal|off | avatar detail auto|images|fine|basic | theme tron|cozy | resume]. With nothing after it, opens the sidebar.' }
1184    }
1185    // Asked for: placed at any width, whatever view it was left in.
1186    const opened = await openPane($)
1187    return { text: opened.isPlaced ? 'Counterparts sidebar opened.' : 'Counterparts sidebar is open but this surface does not place panes.' }
1188  })
1189
1190  // Saved this session, and footnotes Claude opened: the memory tools, after a call that answered without error
1191  on('tool.call', async ($, e, next) => {
1192    const name = String(e.tool)
1193    const ours = ourTool(name)
1194    // The fox dreams while this session's dream runs.
1195    if (ours?.tool === 'dream') await foxEvent($, 'dream')
1196    let ran: Awaited<ReturnType<typeof next>>
1197    try {
1198      ran = await next(e)
1199    } finally {
1200      if (ours?.tool === 'dream') await foxEvent($, 'dreamEnd')
hooks/speak-first.tsx 194 lines
1/**
2 * SPEAK FIRST, a hooks module of its own (moved out of `register.tsx`,
3 * 2026-10-14): once per store, at an interactive start, while the wake's
4 * onboarding marker says `not_started`, it submits MEET_PROMPT, which the
5 * engine runs once the session is idle, so Claude opens the first meeting.
6 *
7 * The plugin's own build lists it beside the sidebar (`hooks/hooks.json`,
8 * "modules"); the plugin directory's build lists only the sidebar, which then
9 * offers the meeting by its "Start the 5-minute intro" button alone. Neither
10 * module imports the other: they share pure helpers (`feed.ts`, `words.ts`,
11 * `state.ts`'s constants) and, through the engine, two things of the plugin's:
12 *   - `$.state` `counterparts.scope`, where this folder stands, which the
13 *     sidebar reads from the registry and this module only reads (alone, it
14 *     stays unknown, and the speak-first never fires: it never acts on an
15 *     unknown folder);
16 *   - `$.store` MEET_FLAG, the once-per-install mark this module writes
17 *     (`{ session, at }`, or `{ settled }` once core moved past not_started),
18 *     which the sidebar reads to hide its button while this session's
19 *     speak-first is the introduction.
20 *
21 * Never headless, never where the folder is paused, off, observer or unknown,
22 * never once the person has typed a prompt or run a slash command (or is
23 * typing one), and never over a binding mark. The mark is written before the
24 * submit, so a crash between them loses the trigger rather than repeating it.
25 * A speak-first still queued when the person's own prompt goes first is
26 * dropped: their prompt comes first, and core offers the meeting in that reply.
27 *
28 * KEYED TO THE STORE without a store id: core says `not_started` only for a
29 * store that never got past it (`start-fresh` resets it). So every known state
30 * past it is written over the mark as `{ settled }`, and a settled mark doesn't
31 * block the speak-first: a `not_started` after it is a new store, which gets
32 * its own first meeting.
33 */
34
35import { atom, read } from 'claude-code'
36import type { EngineInterface, Register } from 'claude-code'
37
38import type { SidebarMeet } from '../types'
39import { canMeet, parseOnboardingMarker } from './feed'
40import { MEET_FLAG, MEET_PROMPT, SCOPE_RECHECK_MS, UNKNOWN_SCOPE, quiet } from './state'
41import { firedIn } from './words'
42
43/** The sidebar's view of this folder (`register.tsx` writes it); read here, never written. */
44const scopeA = atom({ plugin: 'counterparts', key: 'scope' } as const, UNKNOWN_SCOPE)
45
46/**
47 * The session in which the person ran `/counterparts …` (the sidebar writes it: its command hook answers
48 * without passing the command on, so this module's `command.run` hook never sees that one).
49 */
50const typedInA = atom({ plugin: 'counterparts', key: 'typedIn' } as const, null)
51
52/** How often a start that could not decide yet (the folder or the marker not read) looks again, and for how long. */
53const RETRY_MS = 1000
54const RETRIES = 120
55
56/** What this module life knows. */
57const sf = {
58  interactive: false,
59  session: '',
60  /** Where the first meeting stands, from the wake's marker; unknown until one says. */
61  meet: 'unknown' as SidebarMeet,
62  /** The person sent a prompt of their own, or ran a slash command, this session. */
63  typed: false,
64  /**
65   * The person sent a PROMPT of their own, not only a command: only this drops a speak-first
66   * already queued, since only a prompt gets a reply where core can offer the meeting.
67   */
68  prompted: false,
69  /** The speak-first was considered this module life (it goes out at most once per install). */
70  tried: false,
71  /** This module life submitted the speak-first. */
72  spoke: false,
73  retry: null as { cancel: () => void } | null,
74}
75
76/** The marker's state, taken in: past not_started settles a binding mark (a new store's not_started speaks again). */
77async function takeMeet($: EngineInterface, meet: SidebarMeet): Promise<void> {
78  if (meet === 'unknown') return
79  sf.meet = meet
80  if (meet === 'not_started') return
81  const mark = (await $.store.get(MEET_FLAG)) as { settled?: unknown } | undefined
82  if (mark?.settled !== meet) await $.store.set(MEET_FLAG, { settled: meet, at: await $.clock.now() })
83}
84
85/** One look: speaks first when every condition holds, else waits for what is missing or stands down for good. */
86async function speakFirst($: EngineInterface): Promise<void> {
87  if (!sf.interactive || sf.tried) return
88  const scope = await read($, scopeA)
89  // Not read yet: a later look tries again.
90  if (scope.mode === 'unknown' && scope.unread === null) return
91  // No state yet: the wake's marker, when it comes, tries again (no marker at all: an older core, nothing).
92  if (sf.meet === 'unknown') return
93  sf.tried = true
94  stopRetry()
95  if (sf.session === '') sf.session = await $.session.id()
96  if ((await read($, typedInA)) === sf.session) sf.typed = true
97  if (sf.meet !== 'not_started' || sf.typed || !canMeet(scope.mode)) return
98  if (firedIn(await $.store.get(MEET_FLAG)) !== null) return
99  // The last looks, right before: no prompt sent or typed, no other session got there meanwhile.
100  if (sf.typed || (await read($, typedInA)) === sf.session || (await turnsSoFar($)) > 0) return
101  if ((await draftNow($)).trim() !== '') return
102  if (firedIn(await $.store.get(MEET_FLAG)) !== null) return
103  await $.store.set(MEET_FLAG, { session: sf.session, at: await $.clock.now() })
104  sf.spoke = true
105  // Not awaited inside a hook: the engine queues it until the session is idle.
106  $.clock.after(0, () => quiet($.prompt.submit({ text: MEET_PROMPT })))
107}
108
109async function turnsSoFar($: EngineInterface): Promise<number> {
110  try {
111    return await $.session.turns()
112  } catch {
113    return 1
114  }
115}
116
117async function draftNow($: EngineInterface): Promise<string> {
118  try {
119    return (await $.prompt.read()).text
120  } catch {
121    return ''
122  }
123}
124
125function stopRetry(): void {
126  sf.retry?.cancel()
127  sf.retry = null
128}
129
130export const register: Register = on => {
131  // (Each hook here carries a matcher: the sidebar's own hooks on these events carry none, and two of one
132  // plugin on one event with none are refused when both modules load as one.)
133  on('session.start', { isInteractive: true }, async ($, e, next) => {
134    sf.interactive = e.isInteractive
135    sf.session = await $.session.id()
136    if (sf.interactive && !sf.tried) {
137      // The sidebar reads where this folder stands as the session starts; look once it has, and again a
138      // few times while either that or the wake's marker is missing.
139      $.clock.after(0, () => quiet(speakFirst($)))
140      stopRetry()
141      let left = RETRIES
142      sf.retry = $.clock.every(RETRY_MS, () => {
143        left -= 1
144        if (sf.tried) stopRetry()
145        else if (left > 0) quiet(speakFirst($))
146        else {
147          // Still undecided after two minutes (the folder's state or the marker late): once a minute after,
148          // as the sidebar's own scope recheck used to carry it.
149          stopRetry()
150          sf.retry = $.clock.every(SCOPE_RECHECK_MS, () => (sf.tried ? stopRetry() : quiet(speakFirst($))))
151        }
152      })
153    }
154    return next(e)
155  })
156
157  // A slash command the person ran (any: /model, /counterparts…) counts as having spoken: no
158  // command starts a turn, so `session.turns()` would not see it.
159  // (`/counterparts …` never reaches here: the sidebar answers it without `next`, and says so in `typedIn`.)
160  on('command.run', { origin: { kind: /^(composer|bridge)$/ } }, async ($, e, next) => {
161    sf.typed = true
162    return next(e)
163  }).catch(($, e, next) => next(e))
164
165  // A prompt the person sent: the speak-first stands down for good in this session. If it was
166  // already queued behind it, it is dropped: their prompt comes first, and core offers the
167  // meeting in that reply instead.
168  on('prompt.submit', { origin: { kind: /^(composer|bridge|plugin)$/ } }, async ($, e, next) => {
169    const kind = e.origin.kind
170    if (kind === 'composer' || kind === 'bridge') sf.typed = sf.prompted = true
171    if (kind === 'plugin' && e.origin.name === 'counterparts' && e.text === MEET_PROMPT && sf.prompted && sf.spoke) {
172      return { drop: 'the person spoke first' }
173    }
174    return next(e)
175  }).catch(($, e, next) => next(e))
176
177  // The wake's onboarding marker: where the first meeting stands, then a look.
178  on('session.append', { door: 'hook-context', origin: { kind: 'hook', event: 'SessionStart' } }, async ($, e, next) => {
179    const stored = await next(e)
180    if (e.agentId !== undefined) return stored
181    try {
182      const text = e.message.content.map(b => (b.type === 'text' && typeof b.text === 'string' ? b.text : '')).join('\n')
183      const meet = parseOnboardingMarker(text)
184      if (meet !== null) {
185        await takeMeet($, meet)
186        $.clock.after(0, () => quiet(speakFirst($)))
187      }
188    } catch {
189      // the row is stored whatever this module made of it
190    }
191    return stored
192  }).catch(($, e, next) => next(e))
193}
194
hooks/body.tsx 74 lines
1/**
2 * The pane's body as a `Client` surface module: the lines `./sections.ts`
3 * laid out, drawn as they are, and a click on one posted back to the hooks
4 * module by the key it carries (`{ click: 'mem:0' }`). Nothing here is a
5 * link (2026-10-14): the hooks module hands every row over with none, so the
6 * `more` that ends a cut text is plain words and a click on it is the row's.
7 *
8 * Why not Buttons: the engine inverts a Button under the pointer and the
9 * focus, so moving the mouse over a list lit row after row (v0.1, NOTES).
10 * Here nothing changes under a passing pointer; a click on any row of an item
11 * is a click on the item. No `$` here, and no state: what is open lives in
12 * the hooks module's `$.state`, which draws this again.
13 */
14import type { ClientModule, ClientSurface } from 'claude-code'
15
16import type { Line } from './sections'
17
18/** `prose`: the surface sets text in its own font (desktop): a heading's `─` rule becomes space that pushes the right part to the edge. */
19export type BodyProps = { lines: Line[]; prose?: boolean }
20
21const RULE = /^ ?─+$/
22
23const Body: ClientModule<BodyProps, never> = (props, surface: ClientSurface<never>) => {
24  const { Box, Text } = surface.elements
25  surface.onPointer(ev => {
26    if (ev.type !== 'down' || (ev.button !== undefined && ev.button !== 'left')) return
27    const l = props.lines[ev.y]
28    // a row's link (none is drawn now) would take the click from its column on
29    if (l?.link !== undefined && ev.x >= l.link.from) {
30      surface.post({ click: l.link.key })
31      return
32    }
33    if (l?.key !== undefined) surface.post({ click: l.key })
34  })
35  return (
36    <Box flexDirection="column">
37      {props.lines.map((l, y) =>
38        l.segs.length === 0 ? (
39          <Text key={`l${String(y)}`}> </Text>
40        ) : (
41          <Box key={`l${String(y)}`} flexDirection="row">
42            {l.segs.map((s, i) =>
43              props.prose === true && (s.grow === true || RULE.test(s.t)) ? (
44                <Box key={String(i)} flexGrow={1} />
45              ) : props.prose === true && s.fill !== undefined ? (
46                // a share of its column, drawn in half blocks clipped to that share: whatever width the app's
47                // font gives `▄`, the bar is the right length, half a row tall like the terminal's
48                <Box key={String(i)} width={s.of ?? s.fill} overflow="hidden">
49                  {/* one row tall and clipped, no `wrap`: a truncating Text drew `…` at the bar's end */}
50                  {s.fill > 0 ? (
51                    <Box width={`${String(Math.round((100 * s.fill) / Math.max(1, s.of ?? s.fill)))}%`} height={1} overflow="hidden">
52                      <Text color={s.c}>{'▄'.repeat(2 * (s.of ?? s.fill))}</Text>
53                    </Box>
54                  ) : null}
55                </Box>
56              ) : props.prose === true && s.cw !== undefined ? (
57                <Box key={String(i)} width={s.cw}>
58                  <Text color={s.c} {...(s.b === true ? { bold: true } : {})}>{s.t}</Text>
59                </Box>
60              ) : (
61                <Text key={String(i)} color={s.c} {...(s.b === true ? { bold: true } : {})} {...(s.i === true ? { italic: true } : {})}>
62                  {props.prose === true && i > 0 && RULE.test(l.segs[i - 1]?.t ?? '') ? s.t.replace(/^ /, '') : s.t}
63                </Text>
64              ),
65            )}
66          </Box>
67        ),
68      )}
69    </Box>
70  )
71}
72
73export default Body
74
hooks/desktop.tsx 62 lines
1/**
2 * The header, on every surface and with the avatar on or off: a dot before
3 * the title, lit in the stage colour of whatever fires; the count and the day
4 * on the right. With the fox up, the stage follows it (`register.tsx`). Two
5 * rows: the title row, then a blank row. No `$`.
6 *
7 * (Until v1, 2026-10-14, the terminal could draw a braille brain beside the
8 * title instead; it is gone, and this header is the only one.)
9 */
10
11import type { MechId } from './mechanisms'
12import { hex, stageOf } from './mechanisms'
13import { titleButton } from './pane'
14import type { PaneEls } from './pane'
15import { P, SURFACE } from './theme'
16import { cells } from './width'
17
18/** The title row's cells: `◉ COUNTERPARTS ↗`. */
19const TITLE_CELLS = 16
20
21/**
22 * What the title row says on its right: the memories and the day on one line (Mike, 2026-10-13: "save a
23 * line"), shortened to what fits beside the title: `1511 memories · day 22`, `1511 · day 22`, `day 22`.
24 * A surface with its own font fits about a fifth more than its cells say.
25 */
26export function headRight(day: number | null, count: string, w: number): string {
27  const room = Math.floor((w - TITLE_CELLS - 1) * (SURFACE.prose ? 1.2 : 1))
28  const d = day === null ? '' : `day ${String(day)}`
29  const n = count.replace(/ memories?$/, '')
30  for (const t of [[count, d], [n, d], [d]]) {
31    const s = t.filter(x => x !== '').join(' · ')
32    if (cells(s) <= room) return s
33  }
34  return d
35}
36
37/**
38 * The header's `◉` while something fires: the mechanism's stage colour, but
39 * never Retrieval's amber, which in this pane says only a warning (Mike,
40 * 2026-10-10): cyan then.
41 */
42export function litDot(id: MechId): string {
43  const st = stageOf(id)
44  return st.id === 'retrieval' ? P.accent : hex(st.col, 0.95)
45}
46
47/** The header and the blank row under it: two rows. */
48export function header(
49  els: PaneEls,
50  s: { w: number; paused: boolean; lit: MechId | null; day: number | null; count: string },
51): JSX.Element[] {
52  const { Box, Text } = els
53  const { w } = s
54  return [
55    <Box key="head1" flexDirection="row" justifyContent="space-between" width={w}>
56      {titleButton(els, { lead: true, paused: s.paused, dot: s.lit === null ? null : litDot(s.lit) })}
57      <Text color={P.dim}>{headRight(s.day, s.count, w)}</Text>
58    </Box>,
59    <Text key="head-gap"> </Text>,
60  ]
61}
62
hooks/feed.ts 847 lines
1/**
2 * What the sidebar reads, turned into its own terms: the dashboard's event
3 * feed (which mechanisms each event proves, and whose it is), the recall block
4 * the classic UserPromptSubmit hook injects, a `recall` facts answer, the
5 * memory tools' calls this session, a dream, and a mechanism's recent firings.
6 * Pure: no `$`.
7 *
8 * WHICH EVENTS ARE A MECHANISM FIRING is the dashboard's table
9 * (`src/adapters/mechanism-evidence.ts`, MECHANISM_EVIDENCE): an event name,
10 * sometimes a condition on its payload, and the mechanism it proves. One event
11 * can prove several: a dream that merged near-copies and wrote a gist proves
12 * Dreaming, Interference, Consolidation and Gist. This file keeps a copy of
13 * the names and conditions, not an import — a hooks module reaches nothing
14 * outside its plugin folder but through `$`. The repository's bun test
15 * `test/sidebar-mechanism-drift.test.ts` fails when the copy and the table
16 * disagree.
17 */
18
19import type { SidebarMeet, SidebarOnboarding, SidebarRow, SidebarSaved } from '../types';
20import { MECHS, mechById } from './mechanisms';
21import type { MechId } from './mechanisms';
22import { cells, ellipsizeCells, headCells } from './width';
23
24/** One event the sidebar read (the contract's SidebarRow). */
25export type FeedRow = SidebarRow;
26
27/** One event as the dashboard's activity feed narrates it (the status file carries these rows). */
28export type DashEvent = {
29  seq: number;
30  at: number;
31  day?: number;
32  name: string;
33  text: string;
34  subject?: string | null;
35  detail?: readonly { key: string; value: string }[];
36};
37
38function detail(e: DashEvent, key: string): string | undefined {
39  return e.detail?.find(d => d.key === key)?.value;
40}
41function num(e: DashEvent, key: string): number {
42  const v = Number(detail(e, key));
43  return Number.isFinite(v) ? v : 0;
44}
45/**
46 * Of the memories a deposit or a turn names, the ones carrying a recorded
47 * feeling, with their feeling words (`detail.felt`, JSON `[{ id, words }]`,
48 * 2026-10-14). The status file's writer reads them off the store; a row
49 * without it carries none.
50 */
51export function feltOf(e: DashEvent): { id: string; words: string[] }[] {
52  try {
53    const list: unknown = JSON.parse(detail(e, 'felt') ?? '[]');
54    if (!Array.isArray(list)) return [];
55    return list.flatMap(x => {
56      if (x === null || typeof x !== 'object') return [];
57      const { id, words } = x as { id?: unknown; words?: unknown };
58      if (typeof id !== 'string' || !id.startsWith('mem_')) return [];
59      return [{ id, words: Array.isArray(words) ? words.filter((w): w is string => typeof w === 'string' && w !== '') : [] }];
60    });
61  } catch {
62    return [];
63  }
64}
65
66/** `"Title…" [mem_abc]` → the title and the id. */
67export function titled(value: string | null | undefined): { title: string | null; id: string | null } {
68  if (value === undefined || value === null) return { title: null, id: null };
69  const m = /^"([\s\S]*)"\s*\[([A-Za-z]+_[A-Za-z0-9]+)\]$/.exec(value.trim());
70  if (m !== null) return { title: m[1] ?? null, id: m[2] ?? null };
71  const bare = /\[([A-Za-z]+_[A-Za-z0-9]+)\]/.exec(value);
72  return { title: null, id: bare?.[1] ?? null };
73}
74
75type Rule = { name: string; mech: MechId; word: string; when?: (e: DashEvent) => boolean };
76
77/**
78 * The event names that prove a mechanism fired, in the dashboard's words. An
79 * event counts when any rule for its name holds. The FIRST that holds gives
80 * it its word and colour (`mech`); EVERY one that holds lights (`mechs`).
81 */
82export const RULES: readonly Rule[] = [
83  { name: 'gate.deposit', mech: 'salience', word: 'kept', when: e => num(e, 'accepted') > 0 },
84  { name: 'gate.chunk', mech: 'salience', word: 'kept', when: e => num(e, 'accepted') > 0 },
85  { name: 'recall.decision', mech: 'retrieval', word: 'recalled', when: e => num(e, 'surfacedCount') + num(e, 'footnoteCount') > 0 },
86  // Emotion (2026-10-14): a memory saved with a feeling, a turn that brought one
87  // carrying a feeling to mind (`felt`). The mood match, off by decision since
88  // 2026-10-10, still lights it the day it is back on.
89  { name: 'gate.deposit', mech: 'emotional', word: 'saved with a feeling', when: e => feltOf(e).length > 0 },
90  { name: 'recall.decision', mech: 'emotional', word: 'came to mind', when: e => feltOf(e).length > 0 },
91  { name: 'recall.decision', mech: 'emotional', word: 'brought closer', when: e => num(e, 'moodMatched') > 0 },
92  // A memory a link put in front of the model (2026-10-11, Group 4).
93  { name: 'recall.decision', mech: 'association', word: 'brought to mind by a link', when: e => num(e, 'linksReached') > 0 },
94  { name: 'mcp.recall', mech: 'retrieval', word: 'looked up' },
95  { name: 'recall.credit', mech: 'retrieval', word: 'strengthened', when: e => num(e, 'credited') > 0 },
96  // Links LAID, not rows rewritten (2026-10-11, Group 4): `deposited`.
97  { name: 'associate.flush', mech: 'association', word: 'linked', when: e => num(e, 'deposited') > 0 },
98  { name: 'prospective.plain', mech: 'prospective', word: 'reminded' },
99  { name: 'prospective.fire', mech: 'prospective', word: 'reminded' },
100  { name: 'band.transition', mech: 'decay', word: 'faded', when: e => detail(e, 'site') === 'decay' && detail(e, 'direction') !== 'up' },
101  { name: 'band.transition', mech: 'consolidation', word: 'rose', when: e => detail(e, 'site') === 'consolidate' },
102  { name: 'memory.pruned', mech: 'decay', word: 'let go' },
103  { name: 'sleep.cycle', mech: 'decay', word: 'faded', when: e => num(e, 'faded') > 0 },
104  { name: 'band.promoted', mech: 'consolidation', word: 'became core' },
105  { name: 'memory.merged', mech: 'consolidation', word: 'merged' },
106  { name: 'dream.journaled', mech: 'dreaming', word: 'dreamed' },
107  // The replay's faint trails, laid as the dream opens (2026-10-11, Group 4).
108  { name: 'dream.begun', mech: 'association', word: 'linked', when: e => num(e, 'replayLinks') > 0 },
109  // A change the dream made, or a core suggestion (`applied` counts both).
110  { name: 'dream.changed', mech: 'dreaming', word: 'dreamed', when: e => num(e, 'applied') > 0 || num(e, 'nominate-core') > 0 },
111  { name: 'dream.changed', mech: 'episodic-semantic', word: 'dreamed a pattern', when: e => num(e, 'gist') > 0 },
112  { name: 'dream.changed', mech: 'interference', word: 'merged in a dream', when: e => num(e, 'merge') > 0 },
113  { name: 'dream.changed', mech: 'consolidation', word: 'merged in a dream', when: e => num(e, 'merge') > 0 },
114  { name: 'contradiction.flagged', mech: 'interference', word: 'flagged' },
115  // A new memory read as the newer state of an older one (Group 2, the slot detector).
116  { name: 'contradiction.proposed', mech: 'interference', word: 'noticed' },
117  // DECLARED settles are reconsolidation's; a confirmed slot (a detected door) is
118  // interference's; a dream merge's rows count as the merge (Group 2b, review 10 C6).
119  { name: 'contradiction.settled', mech: 'reconsolidation', word: 'settled', when: e => detail(e, 'source') !== 'slot' && detail(e, 'source') !== 'dream-merge' },
120  { name: 'contradiction.settled', mech: 'interference', word: 'replaced', when: e => detail(e, 'source') === 'slot' },
121  { name: 'revision.pressure', mech: 'reconsolidation', word: 'weighed' },
122];
123
124/** The names worth asking the dashboard for one by one. */
125export const RULE_NAMES: readonly string[] = [...new Set(RULES.map(r => r.name))];
126
127/** The rules `e` proves, one a mechanism, the event's own first; none when it proves nothing. */
128export function provedBy(e: DashEvent): Rule[] {
129  const out: Rule[] = [];
130  for (const r of RULES) {
131    if (r.name !== e.name || (r.when !== undefined && !r.when(e))) continue;
132    if (!out.some(o => o.mech === r.mech)) out.push(r);
133  }
134  return out;
135}
136
137/** A row's mechanisms, its own first (a row from before rows carried `mechs`: its own alone). */
138export function mechsOf(r: SidebarRow): MechId[] {
139  return Array.isArray(r.mechs) && r.mechs.length > 0 ? r.mechs : [r.mech];
140}
141
142/**
143 * The events that belong to no session: the night's sleep and dreams, and
144 * what they settle (fading, merging, promoting). Everything else carries the
145 * session it happened in (`detail.session`), or is a session's boundary work.
146 */
147const NIGHT = new Set([
148  'dream.journaled', 'dream.changed', 'band.transition', 'memory.pruned', 'memory.merged',
149  'band.promoted', 'sleep.cycle', 'contradiction.flagged', 'contradiction.settled', 'revision.pressure',
150]);
151
152/** The titles a recall narration quotes (“…”). */
153export function quotedTitles(text: string): string[] {
154  return [...text.matchAll(/“([^”]+)”/g)].map(m => m[1] ?? '').filter(t => t.length > 0);
155}
156
157/**
158 * A dashboard event in the sidebar's terms, or null when it proves no
159 * mechanism. `session` is this session's id: its own events are `here`, the
160 * night's `night`, other sessions' `other` (they never light this header's dot).
161 *
162 * WHOSE IT IS. An event's `session` (or, for a settle a session made, its
163 * `actorId`) names it. A look-up (`mcp.recall`), a reminder
164 * (`prospective.*`) and a link flush (`associate.flush`) name none yet (a
165 * core follow-up: add `session` to those payloads, and the read below takes
166 * it): one of those from after this session began (`since`) is taken as this
167 * session's, one from before as another's.
168 */
169export function classify(e: DashEvent, session = '', since = 0): FeedRow | null {
170  const proved = provedBy(e);
171  const rule = proved[0];
172  if (rule === undefined) return null;
173  const m = mechById(rule.mech);
174  const short = m?.short ?? rule.mech;
175  // A settle or a revision a session made names it as its actor, not as `session`.
176  const bySession = detail(e, 'actor') === 'session';
177  const from = detail(e, 'session') ?? (bySession ? detail(e, 'actorId') : undefined);
178  const mine = session !== '';
179  const who: FeedRow['who'] =
180    NIGHT.has(e.name) && !bySession
181      ? 'night'
182      : from !== undefined
183        ? mine && from === session ? 'here' : 'other'
184        : e.at >= since ? 'here' : 'other';
185  // The memory it is about: a deposit's `memoryId`, a fade's or a promotion's subject, a settle's `over`.
186  const named = [detail(e, 'memoryId'), e.subject ?? undefined, detail(e, 'over'), detail(e, 'id'), detail(e, 'targetId')]
187    .map(titled)
188    .find(t => t.id !== null && t.id.startsWith('mem_'));
189  const dream = e.name.startsWith('dream.') && typeof e.subject === 'string' && /^drm_[A-Za-z0-9]+$/.test(e.subject) ? e.subject : undefined;
190  const base: FeedRow = {
191    id: `seq:${String(e.seq)}`,
192    mech: rule.mech,
193    mechs: proved.map(r => r.mech),
194    word: rule.word,
195    at: e.at,
196    text: e.text,
197    more: [],
198    who,
199    line: `${rule.word} · ${e.text}`,
200    ...(named === undefined || named.id === null ? {} : { memory: { id: named.id, title: named.title } }),
201    ...(dream === undefined ? {} : { dream }),
202  };
203  if (rule.name === 'gate.deposit' || rule.name === 'gate.chunk') {
204    const accepted = num(e, 'accepted');
205    const text = named?.title ?? (accepted > 1 ? `${String(accepted)} memories written down` : 'a memory');
206    return { ...base, text, line: `kept · ${text}`, ...(named?.id ? { twinIds: [`mem:${named.id}`] } : {}) };
207  }
208  if (rule.name === 'recall.decision') {
209    const turn = detail(e, 'turn');
210    const n = num(e, 'surfacedCount') + num(e, 'footnoteCount');
211    const said = n > 0 ? `${String(n)} came to mind` : `${String(num(e, 'moodMatched'))} brought closer by a matching mood`;
212    return {
213      ...base,
214      text: said,
215      more: quotedTitles(e.text),
216      line: said,
217      ...(from !== undefined && turn !== undefined ? { twinIds: [`turn:${from}:${turn}`] } : {}),
218    };
219  }
220  return base;
221}
222
223/** A recall decision's surfaced ids, strongest first (`detail.surfaced`, a JSON list of `{ id }`). */
224export function surfacedIds(e: DashEvent): string[] {
225  try {
226    const list: unknown = JSON.parse(detail(e, 'surfaced') ?? '[]');
227    if (!Array.isArray(list)) return [];
228    return list.map(x => (x !== null && typeof x === 'object' ? (x as { id?: unknown }).id : undefined)).filter((x): x is string => typeof x === 'string');
229  } catch {
230    return [];
231  }
232}
233
234/** The decision row for this session's turn `turn`, among a few of the newest. */
235export function decisionFor(events: readonly DashEvent[], session: string, turn: number): DashEvent | undefined {
236  return events.find(e => e.name === 'recall.decision' && detail(e, 'session') === session && Number(detail(e, 'turn')) === turn);
237}
238
239// ── times fired today, counted from the feed (a dashboard older than v0.2) ──
240
241/**
242 * Times each mechanism fired on the calendar day `date` among `rows`, as the
243 * dashboard counts `firedToday`: rows, not amounts; a dream's rows once.
244 * `start` is a count so far, so a poll's new rows add to it; `dreams` the
245 * dreams already counted, so a dream's second row adds nothing. The caller
246 * hands in each row once (the cold read stops at the seq the polls go on from).
247 */
248export function countToday(
249  rows: readonly SidebarRow[],
250  date: string,
251  dateOf: (at: number) => string,
252  start: Partial<Record<MechId, number | null>> = {},
253  dreams: readonly string[] = [],
254): { counts: Partial<Record<MechId, number | null>>; dreams: string[] } {
255  const counts: Partial<Record<MechId, number | null>> = { ...start };
256  for (const m of MECHS) if (counts[m.id] === undefined) counts[m.id] = m.notBuilt ? null : 0;
257  const seenDreams = new Set(dreams);
258  const fresh = new Map<string, Set<MechId>>();
259  for (const r of rows) {
260    if (dateOf(r.at) !== date) continue;
261    for (const m of mechsOf(r)) {
262      if (r.dream !== undefined) {
263        // One dream, one firing of each mechanism it proves: its journal and its changes rows are one.
264        const k = `${r.dream}:${m}`;
265        if (seenDreams.has(k)) continue;
266        const set = fresh.get(r.dream) ?? new Set<MechId>();
267        if (set.has(m)) continue;
268        set.add(m);
269        fresh.set(r.dream, set);
270      }
271      const c = counts[m];
272      if (typeof c === 'number') counts[m] = c + 1;
273    }
274  }
275  for (const [d, ms] of fresh) for (const m of ms) seenDreams.add(`${d}:${m}`);
276  return { counts, dreams: [...seenDreams] };
277}
278
279// ── a mechanism's recent firings (its panel's `activity`) ─────────────────
280
281/** A piece of a firing's sentence: plain words, or a memory (its id, and the title the row carried, maybe cut). */
282export type FiringPart = string | { id: string | null; title: string | null };
283
284/** One thing a firing was about, before its titles are read in full: what the opened mechanism lists. */
285export type Firing = {
286  /** Unique among the list: `<seq>.<n>`. */
287  key: string;
288  seq: number;
289  at: number;
290  /** The group's word (`faded`, `came to mind`, `merged in a dream`). */
291  word: string;
292  /** The sentence, a memory's title or a few words around two. */
293  parts: FiringPart[];
294  /** The memory a click opens in place; null opens `text` instead. */
295  memoryId: string | null;
296  /** What opening a firing that names no memory shows. */
297  text: string;
298};
299
300function idList(e: DashEvent, key: string): string[] {
301  try {
302    const list: unknown = JSON.parse(detail(e, key) ?? '[]');
303    if (!Array.isArray(list)) return [];
304    return list
305      .map(x => (typeof x === 'string' ? x : x !== null && typeof x === 'object' ? (x as { id?: unknown }).id : undefined))
306      .filter((x): x is string => typeof x === 'string' && x.startsWith('mem_'));
307  } catch {
308    return [];
309  }
310}
311
312/** Which of a dream's changes prove `mech`: merges for Interference and Consolidation, patterns for Gist. */
313const DREAM_ACTION: Partial<Record<MechId, string>> = { interference: 'merge', consolidation: 'merge', 'episodic-semantic': 'gist' };
314
315/**
316 * What each firing of `mech` among `events` was about, newest first: the
317 * memories it names, never its counts (Mike, 2026-10-10: "1 came to mind"
318 * says nothing). A firing that names nothing is left out:
319 *   - a recall's decision: each memory that came to mind or sat in the
320 *     footnotes (`surfaced`, `footnotes` ids); a credit: each memory it
321 *     strengthened (`ids`);
322 *   - Emotion's deposits and turns: only the memories that carry a feeling
323 *     (`felt`), each as its title and its feeling words (`Title (curious)`);
324 *   - a deposit, a fade, a rise, a promotion, a prune, a reminder: its memory;
325 *   - a settle: which memory holds over which; revision pressure: which
326 *     argued with which;
327 *   - a dream: its journal's first sentence (Dreaming); its merges
328 *     (Interference, Consolidation) and patterns (Gist) from the dreams'
329 *     changes, which name memories the event row only counts;
330 *   - left out, nothing to name: a look-up (`mcp.recall` counts what it
331 *     opened), a link flush (`associate.flush` counts pairs), a turn a link
332 *     reached recall on (`recall.decision`'s `linksReached` is a count), a
333 *     dream's replay links (`dream.begun` counts them), a flagged
334 *     contradiction (a pair id), a sleep cycle's fades (counts).
335 */
336export function mechFirings(events: readonly DashEvent[], mech: MechId, dreams: readonly DashDream[] = [], limit = 8): Firing[] {
337  const out: Firing[] = [];
338  const push = (e: DashEvent, word: string, parts: FiringPart[], memoryId: string | null, text = e.text): void => {
339    if (out.length < limit) out.push({ key: `${String(e.seq)}.${String(out.length)}`, seq: e.seq, at: e.at, word, parts, memoryId, text });
340  };
341  for (const e of [...events].sort((a, b) => b.seq - a.seq)) {
342    if (out.length >= limit) break;
343    const rule = provedBy(e).find(r => r.mech === mech);
344    if (rule === undefined) continue;
345    // A turn a link reached recall on counts only (2026-10-11): its row does not
346    // say WHICH shown memory the link brought, so naming them all would be wrong.
347    if (e.name === 'recall.decision' && mech === 'association') continue;
348    // Emotion names only the memories that carry a feeling, each with its words.
349    const felt = mech === 'emotional' ? feltOf(e) : [];
350    if (felt.length > 0) {
351      const quoted = quotedTitles(e.text);
352      const own = e.name === 'gate.deposit' ? classify(e)?.memory : undefined;
353      felt.forEach((f, i) => {
354        const title = own?.id === f.id ? own.title : quoted.length === felt.length ? (quoted[i] ?? null) : null;
355        push(e, rule.word, f.words.length > 0 ? [{ id: f.id, title }, ` (${f.words.join(', ')})`] : [{ id: f.id, title }], f.id);
356      });
357      continue;
358    }
359    if (e.name === 'recall.decision' || e.name === 'recall.credit') {
360      const ids = e.name === 'recall.decision' ? [...surfacedIds(e), ...idList(e, 'footnotes')] : idList(e, 'ids');
361      const quoted = quotedTitles(e.text);
362      const word = e.name === 'recall.decision' && rule.word === 'recalled' ? 'came to mind' : rule.word;
363      ids.forEach((id, i) => push(e, word, [{ id, title: quoted.length === ids.length ? (quoted[i] ?? null) : null }], id));
364      continue;
365    }
366    if (e.name === 'dream.journaled' || e.name === 'dream.changed') {
367      const d = dreams.find(x => x.id === e.subject);
368      if (d === undefined) continue;
369      if (e.name === 'dream.journaled') {
370        if (typeof d.journal !== 'string' || d.journal.trim() === '') continue;
371        push(e, rule.word, [firstSentence(d.journal).first], null, d.journal.replace(/\s+/g, ' ').trim());
372        continue;
373      }
374      const action = DREAM_ACTION[mech];
375      if (action === undefined) continue;
376      for (const c of d.changes ?? []) {
377        const m = c.action === action && c.undone !== true ? c.memories?.[0] : undefined;
378        if (m === undefined || m.confidential === true) continue;
379        push(e, rule.word, [{ id: m.id, title: m.text }], m.id);
380      }
381      continue;
382    }
383    if (e.name === 'contradiction.settled' || e.name === 'contradiction.proposed') {
384      const holds = titled(detail(e, 'holds'));
385      const over = titled(detail(e, 'over'));
386      if (holds.id === null || over.id === null) continue;
387      push(e, rule.word, [holds, e.name === 'contradiction.proposed' ? ' may be the newer state of ' : ' holds over ', over], holds.id);
388      continue;
389    }
390    if (e.name === 'revision.pressure') {
391      const target = titled(detail(e, 'targetId'));
392      const by = titled(detail(e, 'challengerId'));
393      if (target.id === null || by.id === null) continue;
394      push(e, rule.word, [by, ' argued with ', target], target.id);
395      continue;
396    }
397    if (e.name === 'mcp.recall' || e.name === 'associate.flush' || e.name === 'contradiction.flagged' || e.name === 'sleep.cycle') continue;
398    const row = classify(e);
399    if (row?.memory === undefined) continue;
400    push(e, rule.word, [{ id: row.memory.id, title: row.memory.title }], row.memory.id);
401  }
402  return out;
403}
404
405/** The ids a firing names, for reading their titles whole. */
406export function firingIds(f: Firing): string[] {
407  return f.parts.flatMap(p => (typeof p === 'string' || p.id === null ? [] : [p.id]));
408}
409
410/**
411 * A firing's sentence, with each memory's title read in full where it was
412 * (`titles`, by id), else the one the row carried; null when a memory has no
413 * title at all (it is left out rather than shown as an id).
414 */
415export function firingTitle(f: Firing, titles: Readonly<Record<string, string>>): string | null {
416  let s = '';
417  for (const p of f.parts) {
418    if (typeof p === 'string') {
419      s += p;
420      continue;
421    }
422    const t = (p.id === null ? undefined : titles[p.id]) ?? p.title;
423    if (t === null || t === '') return null;
424    s += t;
425  }
426  return s;
427}
428
429// ── the recall block ────────────────────────────────────────────────────────
430
431export type RecallBlock = { turn: number; surfaced: string[]; footnotes: { title: string; id: string | null }[] };
432
433/**
434 * The block `core/recall/render.ts` composes, found in any text that carries
435 * it (the engine wraps hook context in a system reminder). Null when absent
436 * or when it is the quiet turn's empty string. A surfaced line is the
437 * memory's gist, as Claude read it; a footnote line is its title and id.
438 */
439export function parseRecallBlock(text: string): RecallBlock | null {
440  const open = /<!--\s*counterparts:recall t=(\d+)\s*-->/.exec(text);
441  if (open === null) return null;
442  const rest = text.slice(open.index + open[0].length);
443  const end = rest.search(/<!--\s*counterparts:recall\/end/);
444  const body = end >= 0 ? rest.slice(0, end) : rest;
445  const surfaced: string[] = [];
446  const footnotes: { title: string; id: string | null }[] = [];
447  let lane: 'none' | 'surfaced' | 'footnote' = 'none';
448  for (const raw of body.split('\n')) {
449    const line = raw.trim();
450    if (line === 'Came to mind:') { lane = 'surfaced'; continue; }
451    if (line.startsWith('Quietly available')) { lane = 'footnote'; continue; }
452    if (!line.startsWith('- ')) continue;
453    const item = line.slice(2).trim();
454    if (lane === 'surfaced') surfaced.push(item);
455    else if (lane === 'footnote') {
456      // A footnote can lead with what it is to another memory ("Earlier (now [id]): ", "Unsettled — may be out of
457      // date, see [id]: ") and trail one ("(earlier: [id])", "(corrects [id])"): the title is what's between,
458      // the id the first one after the lead (the sidebar showed "Earlier (now ): … [mem_…]", 2026-10-13).
459      // The line is `[Linked: |Journal: ][standing lead: ]Title [id][ (pointers)]` (core/recall/render.ts), so the
460      // memory's own id is the one at the end, before any trailing pointers; the lead may also be "Corrected by
461      // [id]" or "Replaced by [id]", alone or joined with "; ", and may sit behind a Linked:/Journal: word.
462      const end = /^(.*)\s*\[([A-Za-z]+_[A-Za-z0-9]+)\](?:\s*\([^()]*\))?$/.exec(item);
463      const m = end ?? /^(.*?)\s*\[([A-Za-z]+_[A-Za-z0-9]+)\]/.exec(item);
464      const front = m === null ? item : (m[1] ?? '');
465      const frame = /^(?:(?:Linked|Journal):\s+)*/.exec(front)?.[0] ?? '';
466      const after = front.slice(frame.length);
467      const lead = /^(Earlier|Unsettled|Corrected by|Replaced by)\b[^:]*?\[[A-Za-z]+_[A-Za-z0-9]+\][^:]*:\s*/.exec(after);
468      const word = lead === null ? '' : `${(lead[1] ?? '').replace(/ by$/, '')}: `;
469      const title = `${frame}${word}${lead === null ? after : after.slice(lead[0].length)}`.trim();
470      footnotes.push({ title, id: m === null ? null : (m[2] ?? null) });
471    }
472  }
473  const turn = Number(open[1]);
474  return { turn: Number.isFinite(turn) ? turn : 0, surfaced, footnotes };
475}
476
477// ── the memory tools, as the model calls them ──────────────────────────────
478
479/** The two names the memory server's tools go by: the npm install's, the plugin's. */
480export const SERVER_SPELLINGS = ['counterparts', 'plugin_counterparts_counterparts'] as const;
481
482/** `mcp__counterparts__remember` → `{ server: 'counterparts', tool: 'remember' }`, ours only. */
483export function ourTool(name: string): { server: string; tool: string } | null {
484  for (const server of SERVER_SPELLINGS) {
485    const prefix = `mcp__${server}__`;
486    if (name.startsWith(prefix)) return { server, tool: name.slice(prefix.length) };
487  }
488  return null;
489}
490
491function firstLine(s: unknown, max = 240): string | null {
492  if (typeof s !== 'string') return null;
493  const line = s.trim().split('\n')[0] ?? '';
494  if (line.length === 0) return null;
495  return line.length > max ? `${line.slice(0, max - 1)}…` : line;
496}
497
498/** A tool result's JSON payload (the server sends it whole as text). */
499export function payloadOf(text: string | undefined): Record<string, unknown> | null {
500  if (typeof text !== 'string') return null;
501  try {
502    const v: unknown = JSON.parse(text);
503    return v !== null && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
504  } catch {
505    return null;
506  }
507}
508
509/** `remember` was `note` until 2026-10-10; the old name still answers, so both save. */
510const REMEMBER = new Set(['remember', 'note']);
511
512function memoryId(v: unknown): string | null {
513  return typeof v === 'string' && /^mem_[A-Za-z0-9]+$/.test(v.trim()) ? v.trim() : null;
514}
515
516/**
517 * What a memory tool call that answered without error saved, as "Saved this
518 * session" items in the order written; none for any other tool (a chapter
519 * included: a journal entry), and for a call that stored nothing. A `remember` (or old `note`) or a `session_end` entry with `updates`
520 * replaces that memory (unless `how: open`, which keeps both); its title is
521 * read later, from the dashboard.
522 */
523export function savedItems(tool: string, args: Record<string, unknown>, resultText: string | undefined, at: number, seq: number): SidebarSaved[] {
524  const ours = ourTool(tool);
525  if (ours === null) return [];
526  const p = payloadOf(resultText);
527  if (p !== null && p['stored'] === false) return [];
528  const item = (i: number, title: string, id: string | null, src: Record<string, unknown>): SidebarSaved => {
529    const settled = p?.['settled'] as { ok?: unknown; held?: unknown } | undefined;
530    const refused = REMEMBER.has(ours.tool) && settled !== undefined && (settled.ok === false || settled.held === true);
531    const replacesId = src['how'] === 'open' || refused ? null : memoryId(src['updates']);
532    const body = typeof src['text'] === 'string' ? src['text'].replace(/\s+/g, ' ').trim() : '';
533    return {
534      key: `live:${String(seq)}:${String(i)}`,
535      id,
536      title,
537      at,
538      replaces: null,
539      replacesId,
540      kind: replacesId === null ? 'new' : 'update',
541      ...(body === '' ? {} : { text: body }),
542    };
543  };
544  if (REMEMBER.has(ours.tool)) {
545    const title = firstLine(args['title']) ?? firstLine(args['text']) ?? 'a note';
546    return [item(0, title, memoryId(p?.['id']), args)];
547  }
548  // A chapter is a journal entry, not a memory saved: left out of Saved this session (Mike, 2026-10-10).
549  if (ours.tool === 'session_end') {
550    const list = Array.isArray(args['memories']) ? (args['memories'] as unknown[]) : [];
551    if (typeof p?.['deposited'] === 'number' && p['deposited'] === 0) return [];
552    return list.flatMap((m, i) => {
553      const rec = m !== null && typeof m === 'object' ? (m as Record<string, unknown>) : {};
554      const title = firstLine(rec['title']) ?? firstLine(rec['text']);
555      return title === null ? [] : [item(i, title, null, rec)];
556    });
557  }
558  return [];
559}
560
561/** The ids a `recall` call opened by address: its `ids`, or a `handle` that is an id. */
562export function openedIds(tool: string, args: Record<string, unknown>): { ids: string[]; handle: string | null } {
563  const ours = ourTool(tool);
564  if (ours === null || ours.tool !== 'recall') return { ids: [], handle: null };
565  const ids = Array.isArray(args['ids']) ? (args['ids'] as unknown[]).map(memoryId).filter((x): x is string => x !== null) : [];
566  const handle = typeof args['handle'] === 'string' && args['handle'].trim().length > 0 ? args['handle'].trim() : null;
567  const asId = memoryId(handle);
568  return asId === null ? { ids, handle } : { ids: [...ids, asId], handle: null };
569}
570
571// ── a dream ────────────────────────────────────────────────────────────────
572
573/** A dream as the status file carries it, the part the sidebar reads. */
574export type DashDream = {
575  id: string;
576  date: string | null;
577  day: number;
578  state?: string;
579  journal: string | null;
580  counts?: Record<string, number>;
581  changes?: { action: string; said: string; undone?: boolean; memories?: { id: string; text: string; confidential?: boolean }[] }[];
582};
583
584/** The journal's first sentence, and what follows it. */
585export function firstSentence(journal: string): { first: string; rest: string } {
586  const text = journal.replace(/\s+/g, ' ').trim();
587  const m = /^(.+?[.!?])(?:["”’)]*)(?=\s|$)/.exec(text);
588  if (m === null) return { first: text, rest: '' };
589  const first = text.slice(0, m[0].length).trim();
590  return { first, rest: text.slice(m[0].length).trim() };
591}
592
593// ── dates ──────────────────────────────────────────────────────────────────
594
595/** `10-09` or `2025-10-09` as `Oct 9` (with the year when one is given). */
596export function shortDay(s: string): string {
597  const m = /^(?:(\d{4})-)?(\d{2})-(\d{2})$/.exec(s.trim());
598  if (m === null) return '';
599  const month = MONTHS[Number(m[2]) - 1];
600  if (month === undefined) return '';
601  return `${month} ${String(Number(m[3]))}${m[1] === undefined ? '' : ` ${m[1]}`}`;
602}
603
604// ── words and times ────────────────────────────────────────────────────────
605
606/** Word-wrap into lines of at most `w` terminal cells (a wide character counts two; a word longer than a line is cut). */
607export function wrap(text: string, w: number): string[] {
608  const out: string[] = [];
609  let line = '';
610  for (const word0 of text.split(/\s+/).filter(Boolean)) {
611    let word = word0;
612    while (cells(word) > w) {
613      if (line) { out.push(line); line = ''; }
614      const head = headCells(word, w) || (Array.from(word)[0] ?? '');
615      out.push(head);
616      word = word.slice(head.length);
617    }
618    if (!word) continue;
619    if ((line ? cells(line) + 1 : 0) + cells(word) > w) { out.push(line); line = word; }
620    else line = line ? `${line} ${word}` : word;
621  }
622  if (line) out.push(line);
623  return out;
624}
625
626/** A folder as a person says it: a home folder (`/Users/<name>`, `/home/<name>`) as `~`. */
627export function shortDir(dir: string | null): string {
628  if (dir === null) return 'this folder';
629  return dir.replace(/^\/(?:Users|home)\/[^/]+(?=\/|$)/, '~');
630}
631
632/** `s` cut to `w` terminal cells, with an ellipsis. */
633export function ellipsize(s: string, w: number): string {
634  return ellipsizeCells(s, w);
635}
636
637const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
638
639/** The local calendar day of `at`, `YYYY-MM-DD`. */
640export function dateOf(at: number): string {
641  const d = new Date(at);
642  return `${String(d.getFullYear())}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
643}
644
645/**
646 * The time a section's heading carries: `10:25`, `8:07` (a 12-hour clock with
647 * no am or pm, as the mockups have it: every time it shows is recent), or
648 * `Oct 9` before today (`Jan 5 2025` in another year).
649 */
650export function hm(at: number, now: number): string {
651  const d = new Date(at);
652  if (dateOf(at) !== dateOf(now)) return humanDay(dateOf(at), now);
653  const h = d.getHours() % 12;
654  return `${String(h === 0 ? 12 : h)}:${String(d.getMinutes()).padStart(2, '0')}`;
655}
656
657/** `2026-10-09` as `Oct 9`, or `Jan 5 2025` when it is not this year (by `now`). */
658export function humanDay(iso: string, now: number): string {
659  const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
660  const month = m === null ? undefined : MONTHS[Number(m[2]) - 1];
661  if (m === null || month === undefined) return iso;
662  const year = m[1] ?? '';
663  return `${month} ${String(Number(m[3]))}${year === dateOf(now).slice(0, 4) ? '' : ` ${year}`}`;
664}
665
666/** Words that, after `10-20`, make it a range of something rather than a day. */
667const RANGE_UNITS =
668  'lines?|cells?|columns?|cols?|rows?|items?|mins?|minutes?|secs?|seconds?|s|ms|hours?|hrs?|h|days?|weeks?|months?|years?|pages?|steps?|tests?|files?|words?|chars?|characters?|points?|pts?|px|k|x|times?|percent|people|memories|frames?|fps|of';
669
670/**
671 * A short date as the titles write it (`(10-09)`, `10-09: `, `… 10-09`),
672 * rewritten as `<year>-10-09` so the ISO rules below say it: two digits a
673 * side, a real month and day, standing alone. Not a date, so left as written:
674 * part of a longer number or name (`2026-10-09` itself, `#10-09`, `v10-09`,
675 * `0.10-09`, `10-09-2026`, `10-09.5`), a time (`10:09`, `10-09:30`), a range of something
676 * (`10-20 lines`, `10-20%`), an impossible day (`13-09`, `02-30`). The year
677 * is this one: a title's short date is recent, and none is shown for it.
678 */
679function isoShortDates(title: string, year: string): string {
680  if (!/\d{2}-\d{2}/.test(title)) return title;
681  const re = new RegExp(`(?<![\\p{L}\\p{N}_#./:$~-])(\\d{2})-(\\d{2})(?![\\p{L}\\p{N}_/%-])(?![.:]\\d)(?!\\s*(?:${RANGE_UNITS})\\b)`, 'gu');
682  return title.replace(re, (whole, mm: string, dd: string) => {
683    const m = Number(mm);
684    const d = Number(dd);
685    const days = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][m - 1];
686    return days === undefined || d < 1 || d > days ? whole : `${year}-${mm}-${dd}`;
687  });
688}
689
690/**
691 * A title as the sidebar shows it (never the stored text): an ISO date that
692 * only says today is dropped with what holds it (`(2026-10-10)`,
693 * `2026-10-10: `, `, 2026-10-10)`), `on 2026-10-10` reads `today` and `on
694 * 2026-10-10 morning` `this morning`; any
695 * other ISO date is short and human, `Oct 9`, with the year only when it is
696 * not this one (`Jan 5 2025`). Mike, 2026-10-10. A short date (`(10-09)`,
697 * `10-09: `) reads the same way, as this year's (`isoShortDates`).
698 */
699export function humanDates(title: string, now: number): string {
700  const today = dateOf(now);
701  title = isoShortDates(title, today.slice(0, 4));
702  if (!/\d{4}-\d{2}-\d{2}/.test(title)) return title;
703  const t = today.replace(/-/g, '\\-');
704  const out = title
705    .replace(new RegExp(`\\s*\\(\\s*${t}\\s*\\)`, 'g'), '')
706    .replace(new RegExp(`^\\s*${t}\\s*[:·—–-]\\s*`), '')
707    .replace(new RegExp(`\\s*[:·—–-]\\s*${t}\\s*$`), '')
708    .replace(new RegExp(`\\s*[,;]\\s*${t}(?=\\s*\\))`, 'g'), '')
709    .replace(new RegExp(`\\(\\s*${t}\\s*[,;]\\s*`, 'g'), '(')
710    .replace(new RegExp(`\\b(?:on\\s+)?${t}\\s+(morning|afternoon|evening|night)\\b`, 'g'), 'this $1')
711    .replace(new RegExp(`\\b(?:on|dated)\\s+${t}\\b`, 'g'), 'today')
712    .replace(new RegExp(`\\b${t}\\b`, 'g'), 'today')
713    .replace(/\b(\d{4}-\d{2}-\d{2})\b/g, (_m, iso: string) => humanDay(iso, now))
714    .replace(/\s{2,}/g, ' ')
715    .trim();
716  return out === '' ? title : out;
717}
718
719// ── the first meeting: core's onboarding state ─────────────────────────────
720
721const MEET_STATES: readonly SidebarMeet[] = ['not_started', 'offered', 'later', 'done', 'skipped'];
722
723/** Not read, or read as nothing the sidebar knows: it does nothing about the meeting. */
724export const UNKNOWN_ONBOARDING: SidebarOnboarding = { meet: 'unknown', where: null, style: null };
725
726/**
727 * Core's first-meeting state as a JSON object (`{ meet, where, style }`), read
728 * strictly: anything but an object whose `meet` is one of the five states is
729 * unknown, so a core from before onboarding (a 404 page, an error body, a
730 * renamed field) never moves the sidebar.
731 */
732export function parseOnboarding(text: string): SidebarOnboarding {
733  let raw: unknown;
734  try {
735    raw = JSON.parse(text);
736  } catch {
737    return UNKNOWN_ONBOARDING;
738  }
739  if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return UNKNOWN_ONBOARDING;
740  const rec = raw as Record<string, unknown>;
741  const meet = rec['meet'];
742  if (typeof meet !== 'string' || !(MEET_STATES as readonly string[]).includes(meet)) return UNKNOWN_ONBOARDING;
743  const str = (v: unknown): string | null => (typeof v === 'string' && v.length > 0 ? v : null);
744  return { meet: meet as SidebarMeet, where: str(rec['where']), style: str(rec['style']) };
745}
746
747/**
748 * THE ONE PLACE THE WAKE'S MARKER IS SPELLED. Core writes it on its own line
749 * in every SessionStart wake (done included; `start-fresh` resets it to
750 * not_started): `<!-- counterparts:onboarding meet=<state> -->`. No marker:
751 * an older core, the state unknown. Change the text here if core's changes.
752 */
753export const ONBOARDING_MARKER = /<!--\s*counterparts:onboarding\s+meet=([a-z_]+)\s*-->/;
754
755/** The state the wake's marker names, or null: no marker, or a state the sidebar doesn't know. */
756export function parseOnboardingMarker(text: string): SidebarMeet | null {
757  const state = ONBOARDING_MARKER.exec(text)?.[1];
758  return state !== undefined && (MEET_STATES as readonly string[]).includes(state) ? (state as SidebarMeet) : null;
759}
760
761/**
762 * WHICH STORE (core #459, 2026-10-12): the wake carries
763 * `<!-- counterparts:store id=st_… -->` on its own line, and the status file
764 * names the store that wrote it (`storeId`). Two stores on one machine can
765 * share one configuration folder, so the sidebar shows the file's numbers only
766 * when the two agree; either unknown (no marker, an older core, a store whose
767 * id is not minted yet: null) is "can't tell", and it carries on.
768 */
769export const STORE_MARKER = /<!--\s*counterparts:store\s+id=(st_[0-9a-f]{16})\s*-->/
770
771/** The store the wake names, or null. */
772export function parseStoreMarker(text: string): string | null {
773  return STORE_MARKER.exec(text)?.[1] ?? null
774}
775
776/** Whether a status file written by `dash`'s store belongs to another store than the wake's: only when both are known and differ. */
777export function otherStore(wake: string | null, dash: string | null): boolean {
778  return wake !== null && dash !== null && wake !== dash
779}
780
781/**
782 * A folder where a first meeting makes sense: Counterparts on by its own
783 * entry or a parent's, or unset (on by default). Not paused, off or
784 * observer (nothing new is kept there), and not unknown (never acted on as on).
785 */
786export function canMeet(folderMode: string): boolean {
787  return folderMode === 'on' || folderMode === 'unset';
788}
789
790// ── a glance before anything happens: the wake's own lines ─────────────────
791
792/** What the sidebar shows before the session has done anything: a heading and one short text. */
793export type Glance = { head: string; text: string }
794
795/**
796 * THE ONE PLACE THE WAKE'S GLANCE LINES ARE SPELLED (2026-10-15, Mike: show
797 * the first that has something, in this order). Core writes them in every
798 * SessionStart wake: `Last here: …` (handoff/last-here.ts), `Where I left off
799 * in this directory (…): …` (handoff/index.ts), the `Coming up:` heading
800 * (self/briefing.ts FRAMING.horizon) with `- <date> · <text>` items, and
801 * `Yesterday, <date>: …` (handoff/last-here.ts). Change these if core's change;
802 * a wake without them shows no glance, nothing else.
803 */
804const GLANCE_LAST = /^Last here:\s*(.+)$/m
805const GLANCE_LEFT_OFF = /^Where I left off in this directory \([^)]*\):\s*(.+)$/m
806const GLANCE_COMING = /^Coming up:\s*\n-\s*(.+)$/m
807const GLANCE_YESTERDAY = /^Yesterday, ([^:]+):\s*(.+)$/m
808/**
809 * A Coming up item's date, every way core's `datePrefix` (self/briefing.ts)
810 * writes it: `due D`, `was due D`, `due D, every May 14`, `[by ]L (due D…)`,
811 * `[by ]L (was due D…)`, `[by ]L (of H)`, `[by ]L` — then ` · `. The date it
812 * is about comes out alone: the due date where there is one, else the learned.
813 */
814const COMING_DATE = /^(?:by\s+)?(?:(?:was due|due)\s+(\d{4}-\d{2}-\d{2})(?:,[^·]*)?|(\d{4}-\d{2}-\d{2})(?:\s*\((was due|due|of)\s+(\d{4}-\d{2}-\d{2})[^)]*\))?)\s*·\s*/
815
816/** Memory, chapter and handoff ids, and the asides that only point at them: not for the person. */
817function forPerson(text: string): string {
818  return text
819    .replace(/\((?:earlier|the whole of it is|expand)[^)]*\)/gi, '')
820    .replace(/\[?\b(?:mem|epi|sch|ctr)_[0-9a-f]{6,}\b\]?,?/g, '')
821    .replace(/\(\s*\)/g, '')
822    .replace(/[;,]\s*(?=[;,.]|$)/g, '')
823    .replace(/\s+([.,;])/g, '$1')
824    .replace(/\s{2,}/g, ' ')
825    .trim()
826}
827
828/** The first glance line the wake has, in Mike's order: where things were left, what's coming up, yesterday. */
829export function parseGlance(text: string): Glance | null {
830  for (const re of [GLANCE_LAST, GLANCE_LEFT_OFF]) {
831    const m = re.exec(text)?.[1]
832    if (m !== undefined && forPerson(m).length > 0) return { head: 'Last here', text: forPerson(m) }
833  }
834  const coming = GLANCE_COMING.exec(text)?.[1]
835  if (coming !== undefined) {
836    const t = forPerson(coming.replace(COMING_DATE, (_m, lone?: string, learned?: string, word?: string, inner?: string) =>
837      `${lone ?? (word === 'of' ? learned : inner ?? learned) ?? ''} · `))
838    if (t.length > 0) return { head: 'Coming up', text: t }
839  }
840  const y = GLANCE_YESTERDAY.exec(text)
841  if (y !== null) {
842    const t = forPerson(y[2] ?? '').replace(/^[\s;,]*(?:and \d+ more)?[.;\s]*$/, '')
843    if (t.length > 0) return { head: `Yesterday, ${y[1] ?? ''}`.trim(), text: t }
844  }
845  return null
846}
847
hooks/mechanisms.ts 94 lines
1/**
2 * The twelve mechanisms by the website's names, in the website's four stage
3 * colours.
4 *
5 * Lineage: the stage colours are the site's (`counterparts-site` regions.ts
6 * FAMILIES); the short names and taglines are the approved mockup's
7 * (`~/counterparts-notes/mockups/2026-10-09-mod/index.html`, `MECHS`, which
8 * took them from the site). The order and the ids are the dashboard's
9 * (`src/adapters/mechanism-evidence.ts`), so a row of the dashboard's feed
10 * and a dot here mean the same mechanism. Pure data: no `$`, no engine.
11 */
12
13import type { SidebarMechId } from '../types';
14
15export type Rgb = readonly [number, number, number];
16
17export type StageId = 'encoding' | 'storage' | 'retrieval' | 'transformation';
18
19export type MechId = SidebarMechId;
20
21export type Stage = { readonly id: StageId; readonly label: string; readonly col: Rgb };
22
23/** The website's four stages; colours as 0..1 floats. */
24export const STAGES: Readonly<Record<StageId, Stage>> = {
25  encoding: { id: 'encoding', label: 'Encoding', col: [0.0, 0.898, 1.0] },
26  storage: { id: 'storage', label: 'Storage', col: [0.5, 0.66, 1.0] },
27  retrieval: { id: 'retrieval', label: 'Retrieval', col: [1.0, 0.79, 0.3] },
28  transformation: { id: 'transformation', label: 'Transformation', col: [0.7, 0.53, 1.0] },
29};
30
31export type Mech = {
32  readonly id: MechId;
33  readonly stage: StageId;
34  /** The website's short name, the legend's word. */
35  readonly short: string;
36  /** The website's one line: what slides open under the legend. */
37  readonly site: string;
38  /** Drawn hollow: not built. */
39  readonly notBuilt?: true;
40};
41
42export const MECHS: readonly Mech[] = [
43  { id: 'salience', stage: 'encoding', short: 'Salience',
44    site: 'Not everything that happens is worth keeping. Something has to decide.' },
45  { id: 'emotional', stage: 'encoding', short: 'Emotion',
46    site: "The moments that mattered are the ones you keep. Feeling is the encoder's thumb on the scale." },
47  { id: 'decay', stage: 'storage', short: 'Forgetting',
48    site: "Forgetting isn't a failure of memory. It's one of its jobs." },
49  { id: 'interference', stage: 'storage', short: 'Interference',
50    site: 'New memories crowd out old ones. Old ones distort new ones. They compete.' },
51  { id: 'retrieval', stage: 'retrieval', short: 'Retrieval',
52    site: 'Every time you remember something, you make it a little stronger.' },
53  { id: 'association', stage: 'retrieval', short: 'Association',
54    site: 'Recalling one thing pulls its neighbours along with it.' },
55  { id: 'prospective', stage: 'retrieval', short: 'Prospective',
56    site: 'Remembering to do something later: the memory that fires itself at the right moment.' },
57  { id: 'consolidation', stage: 'transformation', short: 'Consolidation',
58    site: "Memories aren't saved when they're made. They're rebuilt, offline, while you sleep." },
59  { id: 'dreaming', stage: 'transformation', short: 'Dreaming',
60    site: 'Overnight, the day is replayed: near-copies merge and what belongs together gets linked.' },
61  { id: 'reconsolidation', stage: 'transformation', short: 'Reconsolidation',
62    site: "Recall makes a memory briefly editable. Then it's re-stored, sometimes rewritten." },
63  { id: 'episodic-semantic', stage: 'transformation', short: 'Gist',
64    site: '“I met her on Tuesday” slowly becomes “I know her”: the event fades, the meaning stays.' },
65  { id: 'schema', stage: 'transformation', short: 'Schemas', notBuilt: true,
66    site: "New facts don't land on blank ground. They're folded into what you already believe." },
67];
68
69const BY_ID = new Map<string, Mech>(MECHS.map(m => [m.id, m]));
70
71export function mechById(id: string): Mech | undefined {
72  return BY_ID.get(id);
73}
74
75export function stageOf(id: MechId): Stage {
76  const m = BY_ID.get(id);
77  return STAGES[m === undefined ? 'encoding' : m.stage];
78}
79
80/** A 0..1 colour as `#rrggbb`, scaled by `k` and clipped. */
81export function hex(col: Rgb, k = 1): string {
82  const c = (v: number): string => Math.max(0, Math.min(255, Math.round(v * 255 * k))).toString(16).padStart(2, '0');
83  return `#${c(col[0])}${c(col[1])}${c(col[2])}`;
84}
85
86/**
87 * How to open the dashboard, said where it is down: the plugin's own command.
88 * Every install that draws this sidebar has it (the sidebar ships in the
89 * plugin), beside the npm install too. Until 2026-10-12 an npm install was
90 * told the console's `counterparts dashboard` instead; a stranger's read
91 * found two ways to say one thing, and the slash command works everywhere.
92 */
93export const PLUGIN_DASHBOARD_ROUTE = '/counterparts:dashboard';
94
hooks/fox.ts 640 lines
1/**
2 * The fox at the top of the sidebar (2026-10-12): which way this surface
3 * draws it, what it does when, and each frame in that way. Pure: no `$`, no
4 * engine. The timer that drives it lives in `register.tsx`.
5 *
6 * ONE DESIGN, FOUR WAYS TO DRAW IT. The art (`fox-art.ts`, the art agent's
7 * data module, and the PNGs in `../art/fox/`, built from its export by
8 * `tools/fox-art/build.ts`) holds every frame three times over, and the
9 * moments and schedule once:
10 *
11 * - `image`: an `Image` of the PNG file, which the terminal reads itself
12 *   (`{ file, format: 'png' }`), where the terminal draws pictures
13 *   (kitty, Ghostty). Elsewhere an Image draws its alt text, dim, so `auto`
14 *   tries it first and a blit refused because the Image draws its alt drops
15 *   to `fine` or `basic` for the session (register.tsx).
16 * - `fine`: sextants (U+1FB00, 2 x 3 pixels a cell, two colours a cell), where a
17 *   picture can't show and the font has them (iTerm2, WezTerm, VS Code, Ghostty, kitty).
18 *   A `Raster` takes only BMP code points and sextants are not BMP, so this
19 *   tier is rows of `Text` runs, and a frame change is a redraw of the pane.
20 * - `basic`: half-blocks (`▀`, two pixels a cell) on a `Raster`, everywhere
21 *   else (Apple Terminal, tmux, anything unknown). A frame change is a blit.
22 * - `svg`: the desktop app (no Raster, no Image): an `Svg` of rects from the
23 *   half-block pixels, with the PNG frame over them as a data URI (the rects
24 *   are what shows if the surface drops the image, or the file can't be read);
25 *   the hooks module reads each PNG's bytes once (`$.fs.read`) and keeps them.
26 *
27 * `/counterparts avatar detail auto|images|fine|basic` overrides the choice.
28 */
29
30import { encodeCells } from './cells'
31import { FOX_FINAL } from './fox-art'
32
33const ART = FOX_FINAL
34
35/** A Raster cell's colour that means the terminal's own default (RasterProps). */
36const DEFAULT_COLOR = 0x01000000
37
38// ── which way to draw it ────────────────────────────────────────────────────
39
40/** `svg`: the desktop and the other remote surfaces, drawn as an Svg (`fox-svg.ts`). */
41export type FoxTier = 'image' | 'fine' | 'basic' | 'svg'
42
43/** The person's override (`$.store` `avatarDetail`): `auto` tries a picture and lets the engine say. */
44export type FoxDetail = 'auto' | 'images' | 'fine' | 'basic'
45
46export function normDetail(v: unknown): FoxDetail {
47  return v === 'images' || v === 'fine' || v === 'basic' ? v : 'auto'
48}
49
50/**
51 * The environment variables the fallback reads, once at session start: which
52 * terminals' fonts draw sextants. Pictures need none (the engine says); these
53 * four only choose sextants over half-blocks where a picture can't show
54 * (Mike, 2026-10-15: the best fox each terminal can draw).
55 */
56export const TIER_ENV = ['TERM_PROGRAM', 'TERM', 'KITTY_WINDOW_ID', 'WEZTERM_EXECUTABLE'] as const
57
58export type TierEnv = Partial<Record<(typeof TIER_ENV)[number], string>>
59
60const present = (v: string | undefined): boolean => v !== undefined && v !== ''
61
62/** Whether this terminal's font has sextants: the ones known to draw them. */
63export function sextantsLikely(env: TierEnv): boolean {
64  const p = env.TERM_PROGRAM ?? ''
65  if (p === 'iTerm.app' || p === 'WezTerm' || p === 'vscode' || p === 'ghostty') return true
66  return env.TERM === 'xterm-kitty' || env.TERM === 'xterm-ghostty' || present(env.KITTY_WINDOW_ID) || present(env.WEZTERM_EXECUTABLE)
67}
68
69/**
70 * The tier for a drawing: the desktop and the other remote surfaces draw the
71 * Svg (`fox-svg.ts`, a file of its own); a terminal, the override or else a
72 * picture first. Whether pictures show is not guessed: the engine
73 * draws an `Image`'s pixels where the terminal can and its alt elsewhere, and
74 * says so by refusing the blit. `noImages`: an Image here drew its alt, so
75 * `auto` draws the next best: sextants where the terminal's font is known to
76 * have them (`sextantsLikely`), else half-blocks, which every terminal draws.
77 * `hasPngs` false (no PNG folder found): never a picture, whatever was asked.
78 */
79export function pickTier(surface: string, detail: FoxDetail, env: TierEnv = {}, noImages = false, hasPngs = true): FoxTier {
80  if (surface !== 'terminal') return 'svg'
81  if (detail === 'images' && hasPngs) return 'image'
82  if (detail === 'fine') return 'fine'
83  if (detail === 'basic') return 'basic'
84  if (detail === 'auto' && !noImages && hasPngs) return 'image'
85  return sextantsLikely(env) ? 'fine' : 'basic'
86}
87
88// ── the stage's footprint ───────────────────────────────────────────────────
89
90/** The stage is the pane's body width, edge to edge. */
91export const STAGE_COLS = 34
92
93/** Below this many pane rows the stage would crowd the body out: the plain header instead. */
94export const STAGE_MIN_PANE_ROWS = 30
95
96/** The Image's box in cells: the PNG's aspect at a cell twice as tall as wide, about the sextant fox's footprint. */
97export const IMAGE_COLS = 30
98export const IMAGE_ROWS = 16
99
100/** Rows the stage takes in a tier. */
101export function stageRows(tier: FoxTier): number {
102  if (tier === 'basic') return Math.ceil(ART.half.box[1] / 2)
103  if (tier === 'fine') return ART.sextant.rows
104  if (tier === 'image') return IMAGE_ROWS
105  return 0 // the Svg sizes itself; the desktop counts no rows
106}
107
108/** Where the fox's box starts on the 34-column stage: centred with room for the effects beside its head. */
109export const HALF_X0 = 1
110export const FINE_X0 = 2
111export const IMAGE_X0 = 2
112
113// ── what it does when ───────────────────────────────────────────────────────
114
115export type FxRef = readonly [name: string, place: string]
116export type Step = { frame: string; ms: number; fx: readonly FxRef[]; moment: string }
117
118type Moment = readonly (readonly [string, number, ...FxRef[]])[]
119const MOMENTS = ART.moments as unknown as Readonly<Record<string, Moment>>
120const SCHEDULE = ART.schedule
121
122export function momentSteps(name: string): Step[] {
123  return (MOMENTS[name] ?? []).map(([frame, ms, ...fx]) => ({ frame, ms, fx, moment: name }))
124}
125
126/** A frame and its effects, as one key: what is on screen. */
127export function stepKey(s: Pick<Step, 'frame' | 'fx'>): string {
128  return [s.frame, ...s.fx.map(f => f[0])].join('+')
129}
130
131/**
132 * The schedule (round 6's design, the art's `schedule`; round 8's polish,
133 * Mike 2026-10-13): a still hold of 4–9 s, then one small moment at random by
134 * weight (a blink, the tail tip's flick, an ear, a glance; rarely the whole
135 * tail or a yawn). The first hold after the fox appears is short and ends in
136 * the tail tip's flick. After 45 s with nothing happening he lies down (the
137 * loaf: `lieIn`, the pose changing behind shut eyes), keeps small moments
138 * lying (`lieWeights`), and at 2 min settles into a nap from there
139 * (`lieNapIn`), which a prompt (or anything real) wakes. The real-state
140 * moments: `came` (something came to mind), `note` (a save), `hello` (the
141 * first meeting) and `dream` (while a dream runs, until it ends); each gets
142 * him up first. A hold is one still frame: no breathing in between (the
143 * owner's round 2: calmer).
144 */
145export type Director = {
146  mode: 'idle' | 'lie' | 'nap' | 'dream'
147  queue: Step[]
148  cur: Step
149  /** When `cur` ends. */
150  until: number
151  idleSince: number
152  napSince: number
153  dreamSince: number
154  /** The first hold is still to come: a short one, then the tail tip's flick. */
155  fresh: boolean
156}
157
158const STILL: Step = { frame: 'idle', ms: 0, fx: [], moment: 'rest' }
159const STILL_LIE: Step = { frame: 'lie0', ms: 0, fx: [], moment: 'lieRest' }
160
161export function newDirector(now: number): Director {
162  return { mode: 'idle', queue: [], cur: STILL, until: now, idleSince: now, napSince: 0, dreamSince: 0, fresh: true }
163}
164
165/** A dream that never said it ended stops after this long. */
166export const DREAM_MAX_MS = 20 * 60000
167
168function pick(rand: () => number, weights: Readonly<Record<string, number>>, fallback: string): string {
169  const total = Object.values(weights).reduce((a, b) => a + b, 0)
170  let r = rand() * total
171  for (const [k, v] of Object.entries(weights)) {
172    r -= v
173    if (r < 0) return k
174  }
175  return fallback
176}
177
178/** How a frame holds him: sitting, lying (the loaf) or asleep. A real event gets him up from where he is. */
179export function poseOf(frame: string): 'sit' | 'lie' | 'asleep' {
180  if (frame === 'nap0' || frame === 'nap1') return 'asleep'
181  return frame.startsWith('lie') ? 'lie' : 'sit'
182}
183
184/**
185 * A still hold in `range`, then `moment`. One that would run past `deadline`
186 * (the mode changes there) is the still alone, up to it: the change always
187 * starts from the still frame, never from a moment's last frame.
188 */
189function holdThen(d: Director, still: Step, moment: string, now: number, deadline: number, range: readonly number[], rand: () => number): void {
190  const lo = range[0] ?? 0, hi = range[1] ?? lo
191  const hold = Math.round(lo + rand() * (hi - lo))
192  const steps = momentSteps(moment)
193  const end = now + hold + steps.reduce((a, s) => a + s.ms, 0)
194  if (end >= deadline) d.queue.push({ ...still, ms: Math.max(1, deadline - now) })
195  else d.queue.push({ ...still, ms: hold }, ...steps)
196}
197
198function plan(d: Director, now: number, rand: () => number): void {
199  if (d.mode === 'dream') {
200    if (now - d.dreamSince > DREAM_MAX_MS) {
201      d.mode = 'idle'
202      d.idleSince = now
203      d.queue.push(...momentSteps('napOut'))
204      return
205    }
206    d.queue.push(...momentSteps('dream'))
207    return
208  }
209  if (d.mode === 'nap') {
210    if (now - d.napSince > SCHEDULE.napMaxMs) {
211      d.mode = 'idle'
212      d.idleSince = now
213      d.queue.push(...momentSteps('napOut'))
214      return
215    }
216    d.queue.push(...momentSteps('nap'))
217    return
218  }
219  const napAt = d.idleSince + SCHEDULE.napAfterMs
220  if (d.mode === 'lie') {
221    if (now >= napAt) {
222      d.mode = 'nap'
223      d.napSince = now
224      d.queue.push(...momentSteps('lieNapIn'))
225      return
226    }
227    holdThen(d, STILL_LIE, pick(rand, SCHEDULE.lieWeights, 'lieBlink'), now, napAt, SCHEDULE.holdMs, rand)
228    return
229  }
230  // Sitting. Straight to a nap only when lying down was skipped (a pane hidden a long while, catching up).
231  if (now >= napAt) {
232    d.mode = 'nap'
233    d.napSince = now
234    d.queue.push(...momentSteps('napIn'))
235    return
236  }
237  const lieAt = d.idleSince + SCHEDULE.lieAfterMs
238  if (now >= lieAt) {
239    d.mode = 'lie'
240    d.queue.push(...momentSteps('lieIn'))
241    return
242  }
243  if (d.fresh) {
244    d.fresh = false
245    holdThen(d, STILL, SCHEDULE.first, now, lieAt, SCHEDULE.firstMs, rand)
246    return
247  }
248  holdThen(d, STILL, pick(rand, SCHEDULE.weights, 'blink'), now, lieAt, SCHEDULE.holdMs, rand)
249}
250
251/**
252 * The step on screen at `now`, and when what is on screen next changes (the
253 * timer's one wake). Steps that end in the past are skipped, not replayed.
254 */
255export function advance(d: Director, now: number, rand: () => number = Math.random): { step: Step; next: number } {
256  let guard = 0
257  while (now >= d.until && guard++ < 200) {
258    if (d.queue.length === 0) plan(d, now, rand)
259    const s = d.queue.shift()
260    if (s === undefined) break
261    d.cur = s
262    d.until = Math.max(d.until, now - 1) + s.ms
263  }
264  // The next change: queued steps that show the same thing join `cur`. What follows is planned ahead
265  // from when `cur` ends, so a moment that ends on the frame the next hold shows (`came`'s idle,
266  // `lieIn`'s lie0) is one wake, not two.
267  const key = stepKey(d.cur)
268  let ahead = 0
269  while (ahead++ < 20) {
270    if (d.queue.length === 0) plan(d, d.until, rand)
271    const s = d.queue[0]
272    if (s === undefined || stepKey(s) !== key) break
273    d.queue.shift()
274    d.until += s.ms
275  }
276  return { step: d.cur, next: d.until }
277}
278
279export type FoxEvent = 'came' | 'note' | 'hello' | 'dream' | 'dreamEnd' | 'prompt'
280
281/**
282 * What gets him up from the pose on screen, so an event's first frame never
283 * jumps: from the nap a stretch (`napOut`), from lying a blink up to sitting
284 * (`lieOut`), and mid-moment sitting (a glance, the tail) a blink back to the
285 * sitting face (`bridge`).
286 */
287function getUp(d: Director): Step[] {
288  const pose = poseOf(d.cur.frame)
289  if (pose === 'asleep') return momentSteps('napOut')
290  if (pose === 'lie') return momentSteps('lieOut')
291  const f = d.cur.frame
292  return f === 'idle' || f === 'blink' || f === 'drowsy' ? [] : momentSteps('bridge')
293}
294
295/** Something real happened: its moment plays at once, once he is up (from a nap or lying down); a dream holds until it ends. */
296export function trigger(d: Director, ev: FoxEvent, now: number): void {
297  const pose = poseOf(d.cur.frame)
298  if (ev === 'dream') {
299    if (d.mode === 'dream') return
300    d.queue = pose === 'asleep' ? [] : momentSteps(pose === 'lie' ? 'lieNapIn' : 'napIn')
301    d.mode = 'dream'
302    d.dreamSince = now
303    d.until = now
304    return
305  }
306  if (ev === 'dreamEnd') {
307    if (d.mode !== 'dream') return
308    d.mode = 'idle'
309    d.idleSince = now
310    d.queue = momentSteps('napOut')
311    d.until = now
312    return
313  }
314  // While a dream runs, it is what the fox does.
315  if (d.mode === 'dream') return
316  d.idleSince = now
317  if (ev === 'prompt') {
318    // Awake and sitting, nothing on screen changes; a lie-down or nap on its way is called off.
319    if (d.mode === 'idle' && pose === 'sit') return
320    d.mode = 'idle'
321    d.queue = pose === 'sit' ? [] : getUp(d)
322    d.until = now
323    return
324  }
325  // Lying, something coming to mind perks him up where he lies, then he sits up (round 8's lab).
326  const moment = ev === 'came' && pose === 'lie' ? [...momentSteps('cameLie'), ...momentSteps('lieOut')] : [...getUp(d), ...momentSteps(ev)]
327  d.queue = [...moment, ...(ev === 'note' ? momentSteps('note') : [])]
328  d.mode = 'idle'
329  d.until = now
330}
331
332// ── colours ─────────────────────────────────────────────────────────────────
333
334function hexNum(c: string): number {
335  const h = c.replace('#', '')
336  const full = h.length === 3 ? [...h].map(x => x + x).join('') : h
337  return parseInt(full, 16)
338}
339
340export function hex6(c: string): string {
341  return `#${hexNum(c).toString(16).padStart(6, '0')}`
342}
343
344const FX_PAL = ART.fxPalette as Readonly<Record<string, string>>
345
346/** An effect sprite's pixels laid over a pixel grid at (x, y); its letters are `fxPalette`'s. */
347function overlay(px: (string | null)[], W: number, H: number, rows: readonly string[], x0: number, y0: number): void {
348  rows.forEach((row, y) => {
349    ;[...row].forEach((ch, x) => {
350      const c = FX_PAL[ch]
351      const X = x0 + x, Y = y0 + y
352      if (c === undefined || X < 0 || X >= W || Y < 0 || Y >= H) return
353      px[Y * W + X] = c
354    })
355  })
356}
357
358// ── basic: half-blocks on a Raster ──────────────────────────────────────────
359
360/** The half-block tier's pixels on the stage (STAGE_COLS x rows*2), null where transparent. */
361export function halfPixels(step: Pick<Step, 'frame' | 'fx'>, cols = STAGE_COLS): (string | null)[] {
362  const H = stageRows('basic') * 2
363  const px: (string | null)[] = new Array<string | null>(cols * H).fill(null)
364  const frames = ART.half.frames as Readonly<Record<string, readonly string[]>>
365  const rows = frames[step.frame] ?? frames['idle'] ?? []
366  const pal = ART.half.palette as readonly (string | null)[]
367  const y0 = H - rows.length
368  rows.forEach((row, y) => {
369    ;[...row].forEach((ch, x) => {
370      if (ch === '.') return
371      const X = HALF_X0 + x, Y = y0 + y
372      if (X < 0 || X >= cols || Y < 0) return
373      px[Y * cols + X] = pal[parseInt(ch, 36)] ?? null
374    })
375  })
376  const fxRows = ART.half.fx as unknown as Readonly<Record<string, readonly string[]>>
377  const places = ART.half.fxPlace as Readonly<Record<string, readonly number[]>>
378  for (const [name, place] of step.fx) {
379    const p = places[place]
380    const r = fxRows[name]
381    if (p !== undefined && r !== undefined) overlay(px, cols, H, r, HALF_X0 + (p[0] ?? 0), y0 + (p[1] ?? 0))
382  }
383  return px
384}
385
386/**
387 * The frame as a Raster's cells: `▀` with fg the top pixel and bg the bottom.
388 * A transparent pixel is the terminal's default colour, never the panel's hex:
389 * a Raster's colours come out at 4 bits a channel, and a panel colour given to
390 * cells shows as a box (theme.ts, `panel`). Top transparent: `▄` instead.
391 */
392export function halfCells(step: Pick<Step, 'frame' | 'fx'>, cols = STAGE_COLS): string {
393  const rows = stageRows('basic')
394  const px = halfPixels(step, cols)
395  const out = new Uint32Array(cols * rows * 3)
396  for (let r = 0; r < rows; r++) {
397    for (let x = 0; x < cols; x++) {
398      const top = px[r * 2 * cols + x] ?? null
399      const bot = px[(r * 2 + 1) * cols + x] ?? null
400      const o = (r * cols + x) * 3
401      if (top === null && bot === null) {
402        out[o] = 0x20
403        out[o + 1] = DEFAULT_COLOR
404        out[o + 2] = DEFAULT_COLOR
405      } else if (top === null) {
406        out[o] = 0x2584
407        out[o + 1] = bot === null ? DEFAULT_COLOR : hexNum(bot)
408        out[o + 2] = DEFAULT_COLOR
409      } else {
410        out[o] = 0x2580
411        out[o + 1] = hexNum(top)
412        out[o + 2] = bot === null ? DEFAULT_COLOR : hexNum(bot)
413      }
414    }
415  }
416  return encodeCells(out)
417}
418
419// ── fine: sextants as Text runs ─────────────────────────────────────────────
420
421/** A sextant pattern (bit i: row i >> 1, column i & 1) as its glyph; 0, 21, 42 and 63 are the space and the block glyphs. */
422export function sextantGlyph(v: number): string {
423  if (v <= 0) return ' '
424  if (v >= 63) return '█'
425  if (v === 21) return '▌'
426  if (v === 42) return '▐'
427  const idx = v - 1 - (v > 21 ? 1 : 0) - (v > 42 ? 1 : 0)
428  return String.fromCodePoint(0x1fb00 + idx)
429}
430
431/** The reverse: a glyph's pattern, or null for a glyph that is not one. */
432export function sextantBits(ch: string): number | null {
433  if (ch === ' ') return 0
434  if (ch === '█') return 63
435  if (ch === '▌') return 21
436  if (ch === '▐') return 42
437  const cp = ch.codePointAt(0) ?? 0
438  if (cp < 0x1fb00 || cp > 0x1fb3b) return null
439  let v = cp - 0x1fb00 + 1
440  if (v >= 21) v += 1
441  if (v >= 42) v += 1
442  return v
443}
444
445/** One cell: its glyph and colours; null colours are the panel's (no colour given). */
446export type FineCell = { ch: string; fg: string | null; bg: string | null }
447
448function dist(a: string, b: string): number {
449  const x = hexNum(a), y = hexNum(b)
450  return Math.abs((x >> 16) - (y >> 16)) + Math.abs(((x >> 8) & 255) - ((y >> 8) & 255)) + Math.abs((x & 255) - (y & 255))
451}
452
453/** Six pixels (null transparent) as one cell in at most two colours: the two most frequent; any third goes to the nearer. */
454export function fineCell(six: readonly (string | null)[]): FineCell {
455  const counts = new Map<string | null, number>()
456  for (const p of six) counts.set(p, (counts.get(p) ?? 0) + 1)
457  const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1]).map(e => e[0])
458  const keep = ranked.slice(0, 2)
459  const solid = keep.filter((c): c is string => c !== null)
460  const near = (p: string | null): string | null => {
461    if (keep.includes(p)) return p
462    if (p === null || solid.length === 0) return solid[0] ?? null
463    return solid.reduce((best, c) => (dist(p, c) < dist(p, best) ? c : best))
464  }
465  const px = six.map(near)
466  const a = keep.find(c => c !== null) ?? null
467  if (a === null) return { ch: ' ', fg: null, bg: null }
468  const b = keep.find(c => c !== a) ?? null
469  let v = 0
470  px.forEach((p, i) => {
471    if (p === a) v |= 1 << i
472  })
473  return { ch: sextantGlyph(v), fg: a, bg: b }
474}
475
476/**
477 * A Text run's colour comes out in the 256-colour palette on the owner's iTerm
478 * (fox trial, 2026-10-12): the art's rust `#a42` drew `#af5f5f`, a washed-out
479 * pink, and its creams went white. That is chalk's rgb-to-256 rounding
480 * (`round(c / 255 * 5)` a channel, which lifts every low channel a step). So
481 * the sextant tier gives Text hand-picked xterm-256 colours (TEXT_256, the art
482 * colour to the one it should land on), each spelt so that rounding lands on
483 * it exactly, as does a nearest-colour mapping, and a truecolor terminal draws
484 * nearly the same (`textHex`). The Raster (half-blocks) and the picture keep
485 * the art's colours. A colour not in the table (black, the outline, white) is
486 * passed as it is.
487 */
488export const TEXT_256: Readonly<Record<string, string>> = {
489  '#332211': '#262626', // darkest brown (else an olive edge)
490  '#443322': '#3a3a3a', // dark brown (else a grey)
491  '#663322': '#5f0000', // shadow
492  '#772222': '#5f0000', // shadow, red
493  '#883322': '#af5f00', // fur, dark
494  '#995544': '#af5f00', // fur, shaded
495  '#aa4422': '#d75f00', // fur: most of the fox
496  '#aa7766': '#d7875f', // fur, muted
497  '#bb4422': '#d75f00', // fur, bright
498  '#cc9988': '#d7af87', // light fur
499  '#ccbb99': '#d7af87', // shaded cream
500  '#eeddbb': '#ffd7af', // cream
501  '#eeddcc': '#ffd7af', // cream chest
502  '#ffbb44': '#ffaf00', // gold: the pencil
503  '#ee8888': '#ffaf87', // pink: nose, tongue, inner ear
504  '#775533': '#875f00', // grey-brown (else olive)
505  '#99bbee': '#87afd7', // the z
506  '#ffee77': '#ffd75f', // a spark
507  '#ffffee': '#ffffd7', // the pencil's white
508  '#cc99ff': '#af87ff', // the dream
509}
510
511/** The 256-colour cube's levels, and the value each is spelt with so chalk's rounding and the nearest both land on it. */
512const LEVEL_SPELT: Readonly<Record<number, number>> = { 0: 0, 95: 76, 135: 127, 175: 175, 215: 215, 255: 255 }
513
514/** An art colour as a Text run's colour: its TEXT_256 colour, spelt to survive a 256-colour terminal; else as it is. */
515export function textHex(c: string | null): string | null {
516  if (c === null) return null
517  const target = TEXT_256[hex6(c)]
518  if (target === undefined) return c
519  const n = hexNum(target)
520  const ch = [n >> 16, (n >> 8) & 255, n & 255]
521  // A grey (all three equal) takes chalk's grey ramp, which lands on itself.
522  if (ch[0] === ch[1] && ch[1] === ch[2]) return target
523  return `#${ch.map(v => (LEVEL_SPELT[v] ?? v).toString(16).padStart(2, '0')).join('')}`
524}
525
526/** The sextant tier's cells on the stage: STAGE_COLS x the art's rows, effects laid over in sextant pixels. */
527export function fineCells(step: Pick<Step, 'frame' | 'fx'>, cols = STAGE_COLS): FineCell[][] {
528  const S = ART.sextant
529  const rows = S.rows
530  const W = cols * 2, H = rows * 3
531  const px: (string | null)[] = new Array<string | null>(W * H).fill(null)
532  const frames = S.frames as unknown as Readonly<Record<string, { glyphs: readonly string[]; fg: readonly string[]; bg: readonly string[] }>>
533  const f = frames[step.frame] ?? frames['idle']
534  const pal = S.palette as readonly string[]
535  // The cells name the art's palette from 1 ('1' is the outline, `#000`; 'j', 19, the last). Read from 0
536  // until 2026-10-13, every sextant colour was the next one along (the fur drew #a76, not #a42) and the
537  // notepad's 'j' fell off the end and drew nothing.
538  const colour = (ch: string | undefined): string | null => (ch === undefined || ch === '.' ? null : (pal[parseInt(ch, 36) - 1] ?? null))
539  f?.glyphs.forEach((line, r) => {
540    const glyphs = [...line]
541    const fgs = [...(f.fg[r] ?? '')]
542    const bgs = [...(f.bg[r] ?? '')]
543    glyphs.forEach((ch, c) => {
544      const bits = sextantBits(ch) ?? 0
545      const fg = colour(fgs[c]), bg = colour(bgs[c])
546      for (let i = 0; i < 6; i++) {
547        const X = (FINE_X0 + c) * 2 + (i & 1), Y = r * 3 + (i >> 1)
548        if (X < W) px[Y * W + X] = bits & (1 << i) ? fg : bg
549      }
550    })
551  })
552  const fxRows = S.fx as unknown as Readonly<Record<string, readonly string[]>>
553  const places = S.fxPlace as Readonly<Record<string, readonly number[]>>
554  for (const [name, place] of step.fx) {
555    const p = places[place]
556    const r = fxRows[name]
557    if (p !== undefined && r !== undefined) overlay(px, W, H, r, FINE_X0 * 2 + (p[0] ?? 0), p[1] ?? 0)
558  }
559  const out: FineCell[][] = []
560  for (let r = 0; r < rows; r++) {
561    const line: FineCell[] = []
562    for (let c = 0; c < cols; c++) {
563      const six: (string | null)[] = []
564      for (let i = 0; i < 6; i++) six.push(px[(r * 3 + (i >> 1)) * W + c * 2 + (i & 1)] ?? null)
565      const cell = fineCell(six)
566      line.push({ ch: cell.ch, fg: textHex(cell.fg), bg: textHex(cell.bg) })
567    }
568    out.push(line)
569  }
570  return out
571}
572
573/** A row of cells as runs of one colour pair: what a row of Text elements draws. */
574export function runs(line: readonly FineCell[]): FineCell[] {
575  const out: FineCell[] = []
576  for (const cell of line) {
577    const last = out[out.length - 1]
578    // A blank cell (a space, no background) joins any run with no background; a run of blanks takes the next colour.
579    const blank = cell.ch === ' ' && cell.bg === null
580    const lastBlank = last !== undefined && last.fg === null && last.bg === null
581    if (last !== undefined && last.bg === cell.bg && (last.fg === cell.fg || blank || lastBlank)) {
582      last.ch += cell.ch
583      if (lastBlank) last.fg = cell.fg
584    } else out.push({ ...cell })
585  }
586  return out
587}
588
589// ── image: the PNG files ────────────────────────────────────────────────────
590
591/**
592 * The PNGs `tools/fox-art/build.ts` writes to `art/fox/<key>.png`: every frame, and every frame + effects pair a
593 * moment draws (`frame+fx+fx`, as `stepKey` spells it).
594 */
595export const PNG_FRAMES: ReadonlySet<string> = new Set([
596  ...Object.keys(ART.png.frames),
597  ...Object.values(MOMENTS).flatMap(m => m.filter(s => s.length > 2).map(([frame, , ...fx]) => stepKey({ frame, fx }))),
598])
599
600/** Where the PNGs sit, from the plugin's folder: the repository's plugin, or this folder as a plugin of its own. */
601export const PNG_DIRS = ['hooks/sidebar/art/fox', 'art/fox'] as const
602
603/** The PNG's file name for a step: the frame with its effects, or the frame alone, or the idle frame. */
604export function pngName(step: Pick<Step, 'frame' | 'fx'>): string {
605  const key = stepKey(step)
606  return `${PNG_FRAMES.has(key) ? key : PNG_FRAMES.has(step.frame) ? step.frame : 'idle'}.png`
607}
608
609/** The scale the build writes the PNGs at: art pixels x 3. */
610export const PNG_SCALE = 3
611
612/** The PNGs' pixel size, for a test. */
613export const PNG_SIZE = [ART.png.box[0] * PNG_SCALE, ART.png.box[1] * PNG_SCALE] as const
614
615// ── the module's own ────────────────────────────────────────────────────────
616
617/**
618 * Whether the stage is mounted and its timer running, what is on screen, the
619 * timer's generation (a stale timer stops), the tier it was drawn in, and the
620 * schedule. The timer is one `$.clock.after` per change of what is on screen.
621 */
622export const stage = {
623  live: false,
624  gen: 0,
625  /** stepKey of what the mounted stage shows. */
626  shown: '',
627  tier: 'basic' as FoxTier,
628  director: null as Director | null,
629  env: {} as TierEnv,
630  detail: 'auto' as FoxDetail,
631  /** An Image here drew its alt: `auto` draws sextants or half-blocks for the rest of the session. */
632  noImages: false,
633  /** Image blits refused in a row. */
634  imageDenies: 0,
635  /** The folder holding the PNGs (absolute), found at session start; null: none found, no pictures. */
636  pngDir: null as string | null,
637  /** Each PNG's bytes (base64) once read, for the desktop's Svg; '' for one that could not be read. */
638  pngData: new Map<string, string>(),
639}
640
hooks/fox-svg.ts 42 lines
1/**
2 * The desktop's fox: the PNG frame inside an SVG document, the desktop's one
3 * picture element (`Svg`, drawn as an image, so the document needs its
4 * namespace). A file of its own: it draws, and reads nothing.
5 */
6
7import { FOX_FINAL } from './fox-art'
8import { HALF_X0, STAGE_COLS, halfPixels, hex6, stageRows } from './fox'
9import type { Step } from './fox'
10
11/** CSS pixels a half-block pixel takes on the desktop. */
12export const SVG_PX = 6
13
14/** The desktop's drawing: rects from the half-block pixels, run-length per row, then the PNG (`png`, base64; '' for none) over them. */
15export function foxSvg(step: Pick<Step, 'frame' | 'fx'>, png: string): { source: string; width: number; height: number } {
16  const cols = STAGE_COLS
17  const H = stageRows('basic') * 2
18  const px = halfPixels(step, cols)
19  const rects: string[] = []
20  for (let y = 0; y < H; y++) {
21    let x = 0
22    while (x < cols) {
23      const c = px[y * cols + x] ?? null
24      if (c === null) {
25        x++
26        continue
27      }
28      let n = 1
29      while (x + n < cols && px[y * cols + x + n] === c) n++
30      rects.push(`<rect x="${String(x)}" y="${String(y)}" width="${String(n)}" height="1" fill="${hex6(c)}"/>`)
31      x += n
32    }
33  }
34  // The PNG's box over the half-block fox's (the art lines both up: the same footprint, feet on the bottom line).
35  const [bw, bh] = FOX_FINAL.half.box
36  const image = png === '' ? '' : `<image x="${String(HALF_X0)}" y="${String(H - bh)}" width="${String(bw + 2)}" height="${String(bh)}" preserveAspectRatio="xMidYMax meet" style="image-rendering:pixelated" href="data:image/png;base64,${png}"/>`
37  // The picture alone when there is one: the half-block fox under it showed as a dark ghost edge on the
38  // desktop's light page (2026-10-13); it stays only as the fallback for a frame with no PNG.
39  const source = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${String(cols)} ${String(H)}" shape-rendering="crispEdges">${image === '' ? rects.join('') : image}</svg>`
40  return { source, width: cols * SVG_PX, height: H * SVG_PX }
41}
42
hooks/pane.tsx 117 lines
1/**
2 * The pane's tree, on every surface: the title (the header in `desktop.tsx`
3 * places it), a paused folder's banner, the body, and the footer of
4 * instructions. No `$`: the Pane hook in `register.tsx` reads the state and
5 * builds the body (its `Client` tag must stay in the hooks module's own
6 * source).
7 *
8 * v1 (the directory trim, 2026-10-14): nothing in the pane acts. No Button
9 * runs a command or calls a tool: the intro row, the folder switch and its
10 * confirm, and the view and avatar switches are gone; the footer says how to
11 * do each instead (`words.ts#footerLines`). Nothing is a link either
12 * (2026-10-14): COUNTERPARTS is its name, and the dashboard is
13 * `/counterparts:dashboard`, which the footer says.
14 */
15
16import type { Elements } from 'claude-code'
17
18import type { SidebarScope } from '../types'
19import { shortDir } from './feed'
20import { clip } from './sections'
21import type { Line } from './sections'
22import { P, bg } from './theme'
23import { RESUME_SAY, footerLines } from './words'
24import type { FooterShow } from './words'
25
26/** The constructors the pane draws with: every surface has these. */
27export type PaneEls = Pick<Elements['terminal'], 'Box' | 'Text'>
28
29// ── the title, which the header places ──
30
31/** COUNTERPARTS, plain text on every surface (no link, 2026-10-14). `lead` puts a dot before it. */
32export function titleButton(els: PaneEls, o: { lead: boolean; paused: boolean; dot: string | null }): JSX.Element {
33  const { Box, Text } = els
34  const words = [
35    ...(o.lead ? [<Text key="dot" color={o.dot ?? P.accentDim}>◉ </Text>] : []),
36    <Text key="name" color={o.paused ? P.dim : P.accent} bold>COUNTERPARTS</Text>,
37  ]
38  return (
39    <Box key="title" flexDirection="row">
40      <Text>{words}</Text>
41    </Box>
42  )
43}
44
45// ── under the header ──
46
47/** A paused (or off) folder says so before anything else in the pane: three rows, said, not pressed. */
48export function pausedBanner(els: PaneEls, scope: SidebarScope, w: number): JSX.Element[] {
49  const { Text } = els
50  const off = scope.mode === 'off'
51  const how = off
52    ? scope.own ? 'ask Claude to turn it back on' : `off by ${shortDir(scope.setBy)}`
53    : scope.own ? RESUME_SAY : `paused by ${shortDir(scope.setBy)}`
54  return [
55    <Text key="paused" color={P.accent2} bold>{clip(off ? '◌ Counterparts off here' : '⏸ Counterparts paused here', w)}</Text>,
56    <Text key="paused-how" color={P.accent2}>{clip(how, w)}</Text>,
57    <Text key="paused-gap"> </Text>,
58  ]
59}
60
61// ── the footer: how to do what the pane doesn't ──
62
63/** The footer's rows at `w` (`fit`: the cells its text may take, wider on a surface with its own font): a rule (or a blank) and the lines. */
64export function footerRows(fit: number, paused = false, show?: FooterShow): number {
65  return 1 + footerLines(fit, paused, show).length
66}
67
68export function footer(els: PaneEls, s: { w: number; fit: number; prose: boolean; paused: boolean; show?: FooterShow }): JSX.Element {
69  const { Box, Text } = els
70  const { w, fit, prose, paused, show } = s
71  return (
72    <Box key="footer" flexDirection="column" width={w}>
73      {prose ? <Text> </Text> : <Text color={P.faint}>{'─'.repeat(w)}</Text>}
74      {footerLines(fit, paused, show).map((l, i) => (
75        <Text key={`f${String(i)}`}>
76          <Text color={P.mid}>{l.label}</Text>
77          {l.how === '' ? null : <Text color={P.hi}>{l.how}</Text>}
78        </Text>
79      ))}
80    </Box>
81  )
82}
83
84/** The body on a surface with no Client modules (mobile, an editor): the same lines, not clickable. */
85export function plainBody(els: PaneEls, lines: readonly Line[]): JSX.Element {
86  const { Box, Text } = els
87  return (
88    <Box key="body" flexDirection="column">
89      {lines.map((l: Line, y: number) => (
90        <Text key={`b${String(y)}`} color={l.segs[0]?.c ?? P.text}>{l.segs.map(s => s.t).join('') || ' '}</Text>
91      ))}
92    </Box>
93  )
94}
95
96/**
97 * The pane's frame: the rows in a column, the panel's colour behind them, at least as tall as the pane.
98 * `top` (the fox's stage): rows above, the last of them (the stage) drawn edge to edge, outside the padding.
99 */
100export function frame(els: PaneEls, W: number, fill: number, rows: JSX.Element[], top: JSX.Element[] | null = null): JSX.Element {
101  const { Box } = els
102  if (top !== null && top.length > 0) {
103    return (
104      <Box flexDirection="column" width={W} minHeight={fill} {...bg(P.panel)}>
105        <Box key="stage-head" flexDirection="column" paddingX={1}>{top.slice(0, -1)}</Box>
106        {top[top.length - 1]}
107        <Box key="stage-rest" flexDirection="column" paddingX={1} flexGrow={1}>{rows}</Box>
108      </Box>
109    )
110  }
111  return (
112    <Box flexDirection="column" paddingX={1} width={W} minHeight={fill} {...bg(P.panel)}>
113      {rows}
114    </Box>
115  )
116}
117
hooks/scopes.ts 204 lines
1/**
2 * Where this folder stands, read from the scope registry FILE, the one the
3 * hooks and the memory server read (`<config dir>/scopes.json`), so the sidebar
4 * never needs the `scope` tool to read. The tool goes through the permission
5 * check, and in auto mode with no allow rule the sidebar never made that read:
6 * a session started in a paused folder showed `?` until clicked (2026-10-09).
7 * An allow rule for `scope` would let the model pause folders, so the file it
8 * is. The sidebar writes nothing (v1): Claude's `scope` tool does, when asked.
9 *
10 * A copy of the core's rules (a mod can't import from outside its folder):
11 *
12 * - **Which file** (`src/adapters/config-path.ts`, `scopes.ts#scopesPath`): the
13 *   wired hook's own `--config <path>`, else `COUNTERPARTS_CONFIG`, else
14 *   `~/.counterparts/claude-code.json`; the registry sits beside it.
15 * - **What it says** (`scopes.ts#parseRegistry`): `version` 1, `scopes` keyed
16 *   by absolute path. A file that is not a registry at all reads as no entries
17 *   (every folder unset); a bad entry is skipped by itself.
18 * - **Which entry governs** (`scopes.ts#lookupScope`): the longest ancestor,
19 *   segment-aware (`/a/b` never governs `/a/bc`), both sides canonical; a tie
20 *   between two keys for one folder goes to the more restrictive.
21 *
22 * Pure: the caller does the reading and the path resolving.
23 */
24
25export type RegistryMode = 'on' | 'observer' | 'off' | 'paused'
26
27const MODES: readonly string[] = ['on', 'observer', 'off', 'paused']
28
29/** `scopes.ts#rankOf`: paused and off are one rank. */
30function rank(mode: RegistryMode): number {
31  return mode === 'observer' ? 1 : mode === 'off' || mode === 'paused' ? 2 : 0
32}
33
34/** The entries a registry file holds, by key as written; a file that is not a registry holds none. */
35export function parseRegistry(text: string): Record<string, RegistryMode> {
36  let raw: unknown
37  try {
38    raw = JSON.parse(text)
39  } catch {
40    return {}
41  }
42  if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return {}
43  const rec = raw as Record<string, unknown>
44  if (rec['version'] !== 1) return {}
45  const scopes = rec['scopes']
46  if (scopes === null || typeof scopes !== 'object' || Array.isArray(scopes)) return {}
47  const out: Record<string, RegistryMode> = {}
48  for (const [key, value] of Object.entries(scopes as Record<string, unknown>)) {
49    if (!key.startsWith('/')) continue // a pseudo-scope (`claude-desktop:`) or not a path
50    if (value === null || typeof value !== 'object' || Array.isArray(value)) continue
51    const entry = value as Record<string, unknown>
52    const mode = entry['mode']
53    const since = entry['since']
54    const resumeTo = entry['resumeTo']
55    const note = entry['note']
56    if (typeof mode !== 'string' || !MODES.includes(mode)) continue
57    if (typeof since !== 'string' || since.length === 0) continue
58    if (resumeTo !== undefined && resumeTo !== 'on' && resumeTo !== 'observer') continue
59    if (note !== undefined && typeof note !== 'string') continue
60    out[key] = mode as RegistryMode
61  }
62  return out
63}
64
65/** Is `dir` at or under `key`? Segment-aware. */
66export function under(key: string, dir: string): boolean {
67  if (key === dir) return true
68  return dir.startsWith(key.endsWith('/') ? key : `${key}/`)
69}
70
71/**
72 * The entry that governs `target` (canonical), from entries whose keys
73 * `canon` has made canonical: its mode and its key, or unset.
74 */
75export function lookup(
76  entries: readonly { key: string; canonical: string; mode: RegistryMode }[],
77  target: string,
78): { mode: RegistryMode | 'unset'; matched: string | null; canonical: string | null } {
79  let best: { key: string; canonical: string; mode: RegistryMode } | null = null
80  for (const e of entries) {
81    if (!under(e.canonical, target)) continue
82    if (best === null || e.canonical.length > best.canonical.length) best = e
83    else if (e.canonical === best.canonical && rank(e.mode) > rank(best.mode)) best = e
84  }
85  return best === null ? { mode: 'unset', matched: null, canonical: null } : { mode: best.mode, matched: best.key, canonical: best.canonical }
86}
87
88/** `runtime.ts#shellTokens`: whitespace, honouring double quotes (the only quoting `install` writes). */
89export function shellTokens(command: string): string[] {
90  const out: string[] = []
91  let cur = ''
92  let quoted = false
93  let any = false
94  for (const ch of command) {
95    if (ch === '"') {
96      quoted = !quoted
97      any = true
98    } else if (!quoted && /\s/.test(ch)) {
99      if (any) out.push(cur)
100      cur = ''
101      any = false
102    } else {
103      cur += ch
104      any = true
105    }
106  }
107  if (any) out.push(cur)
108  return out
109}
110
111const HOOK_ENTRY = /(^|[/\\])adapters[/\\]claude-code[/\\]bin[/\\]hook\.(ts|mjs)$/
112const SHELL_OPERATOR = /&&|\|\||;|\||>|<|`|\$\(/
113
114/** `host-wiring.ts#isBinaryName`: the single binary, not the old per-mode shims. */
115function isBinaryName(exe: string): boolean {
116  const name = exe.split(/[\\/]/).pop() ?? ''
117  return /^counterparts([-.][\w.-]*)?$/i.test(name) && !/^counterparts-(hook|mcp|dashboard)(\.exe)?$/i.test(name)
118}
119
120/**
121 * What a settings hook command says about the configuration, read the way
122 * `host-wiring.ts#readOurHook` reads what it runs: undefined when it does not
123 * run our hook; else the `--config` after the hook (a Bun `--config=` BEFORE
124 * the script is Bun's own), or null when it names none.
125 */
126export function hookConfig(command: string): string | null | undefined {
127  if (SHELL_OPERATOR.test(command)) return undefined
128  const tokens = shellTokens(command)
129  const exe = tokens[0] ?? ''
130  if (exe.length === 0) return undefined
131  const isFlag = (t: string | undefined): boolean => t !== undefined && t.length > 1 && t.startsWith('-')
132  let at = 1
133  if (/(^|[/\\])counterparts-hook$/.test(exe)) {
134    // the old per-mode shim: what follows it is the hook's own
135  } else if (isBinaryName(exe)) {
136    while (isFlag(tokens[at])) at += 1
137    if (tokens[at] !== 'hook') return undefined
138    at += 1
139  } else {
140    let ran = false
141    let entry = false
142    for (; at < tokens.length; at += 1) {
143      const t = tokens[at] ?? ''
144      if (isFlag(t)) {
145        if (t === '--import') at += 1
146        continue
147      }
148      if (t === 'run' && !ran) {
149        ran = true
150        continue
151      }
152      if (!HOOK_ENTRY.test(t)) return undefined
153      entry = true
154      at += 1
155      break
156    }
157    if (!entry) return undefined
158  }
159  const rest = tokens.slice(at)
160  for (let i = 0; i < rest.length; i += 1) {
161    const t = rest[i] ?? ''
162    if (t === '--config') return rest[i + 1] ?? ''
163    if (t.startsWith('--config=')) return t.slice('--config='.length)
164  }
165  return null
166}
167
168/**
169 * The configuration the first of our hooks in a settings file names: a path,
170 * null for ours with none, undefined for no hook of ours in it.
171 */
172export function settingsHookConfig(text: string): string | null | undefined {
173  let raw: unknown
174  try {
175    raw = JSON.parse(text)
176  } catch {
177    return undefined
178  }
179  const hooks = (raw as { hooks?: unknown } | null)?.hooks
180  if (hooks === null || typeof hooks !== 'object' || Array.isArray(hooks)) return undefined
181  let found: string | null | undefined
182  for (const groups of Object.values(hooks as Record<string, unknown>)) {
183    if (!Array.isArray(groups)) continue
184    for (const group of groups) {
185      const entries = (group as { hooks?: unknown } | null)?.hooks
186      if (!Array.isArray(entries)) continue
187      for (const entry of entries) {
188        const command = (entry as { command?: unknown } | null)?.command
189        if (typeof command !== 'string') continue
190        const c = hookConfig(command)
191        if (typeof c === 'string') return c
192        if (c === null) found = null
193      }
194    }
195  }
196  return found
197}
198
199/** The folder a path sits in, by either separator. */
200export function parentOf(path: string): string {
201  const cut = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
202  return cut <= 0 ? path.slice(0, cut + 1) || '/' : path.slice(0, cut)
203}
204
hooks/sections.ts 627 lines
1/**
2 * The pane's body, laid out as lines: Memories, Subconscious, Saved this
3 * session, Mechanisms today and Last Dream, at the
4 * pane's width, folded to its height. Pure: no `$`, no engine. The hooks
5 * module hands the result to the `Client` surface module (`./body.tsx`),
6 * which draws the lines and reads clicks by row.
7 *
8 * Lineage: the round-3 mockups Mike chose on 2026-10-10
9 * (`~/counterparts-notes/mockups/2026-10-10-mod-round3/compose/compose3.ts`):
10 * its palette, its heading (`Label ──── right`), its wrap and clip, its
11 * half-height bars and its fold ladder, with the round-3 decisions that
12 * override its PNGs (FEEDBACK-all.md): "Memories ──── 10:25" and
13 * "Subconscious ────" are two headings; each saved item starts with its stage
14 * dot on its first line only, wrapped lines back at the left edge.
15 *
16 * Mike's rules, everywhere: width is scarce, so no decoration that takes a
17 * column across many lines (no quote rules, gutters, hanging indents or
18 * bullets on list items); space goes to information, never to saying what
19 * isn't shown (no "+N more" line); a click opens an item in place, every
20 * time, and never opens the dashboard by itself (v0.2 polish, 2026-10-10).
21 */
22
23import type {
24  SidebarDream,
25  SidebarMechOpen,
26  SidebarMemoryRef,
27  SidebarMind,
28  SidebarOpen,
29  SidebarSaved,
30  SidebarToday,
31} from '../types'
32import { dateOf, hm, humanDates } from './feed'
33import type { Glance } from './feed'
34import { MECHS, STAGES, hex, stageOf } from './mechanisms'
35import type { MechId } from './mechanisms'
36import { STATUS_STALE_MS, agoWords } from './status'
37import { P } from './theme'
38import { cells, headCells } from './width'
39
40/** Bars a shade under the site's full stage colour. */
41const BAR_K = 0.84
42
43/**
44 * THE OPENED CAP: a click opens any item in place (Mike, 2026-10-10: "click
45 * = expand in place, same every time"), showing its text whole up to this
46 * many lines at the pane's width; longer, 20 lines, the last ending with a
47 * cyan `more` that opens that memory on the dashboard (`#memories?id=`).
48 * Twenty lines of 32 columns is about 600 characters, near the median
49 * memory (626, measured on 77 of the live store's newest, 2026-10-10).
50 */
51export const OPEN_MAX_LINES = 20
52
53/** The word that ends a cut opened text, a link to the rest: drawn cyan, no icon, on the same line. */
54export const MORE = 'more'
55
56/**
57 * Every item in Memories, Subconscious and Saved this session (and a
58 * mechanism's firing) takes up to this many lines: when they don't all fit,
59 * fewer show, never one cut shorter (Mike, 2026-10-10, replacing
60 * Subconscious's "one line, up to four").
61 */
62const ITEM_LINES = 3
63/**
64 * The most saved items the list draws (the seed reads as many): a long
65 * session's dozens would only lengthen the fold ladder, a step an item, and
66 * every drawing walks it.
67 */
68const SAVED_MAX = 12
69
70/**
71 * One run of a line. On a prose surface (BodyProps.prose) three more say how it is laid out rather than
72 * spelled with spaces: `cw`, a column that many cells wide; `fill` of `of`, a bar that share of a column `of` cells wide;
73 * `grow`, the space that pushes what follows to the edge.
74 */
75export type Seg = { t: string; c: string; b?: true; i?: true; cw?: number; fill?: number; of?: number; grow?: true }
76/**
77 * One row of the body: its pieces, and what a click on it names; `link`, a
78 * click from column `from` (0-based, in cells) to the row's end names
79 * another key instead (the `more` at the end of a cut text). The hooks
80 * module hands every row over without one (no links, 2026-10-14): `more` is
81 * words, the rest of the text is on the dashboard (`/counterparts:dashboard`).
82 */
83export type Line = { segs: Seg[]; key?: string; link?: { from: number; key: string } }
84
85const seg = (t: string, c: string, o: { b?: boolean; i?: boolean } = {}): Seg => ({ t, c, ...(o.b ? { b: true as const } : {}), ...(o.i ? { i: true as const } : {}) })
86const line = (segs: Seg[], key?: string): Line => (key === undefined ? { segs } : { segs, key })
87const BLANK: Line = { segs: [] }
88
89// ── text ───────────────────────────────────────────────────────────────────
90
91export const TRAIL = /[\s,;:·—–-]+$/
92
93/**
94 * Word wrap to `width` cells (the first line to `first`); past `maxLines`
95 * the last line ends at a word with `…`. A word longer than a line is cut.
96 */
97export function wrapN(text: string, width: number, maxLines = 99, first = width): string[] {
98  const words = text.split(/\s+/).filter(Boolean)
99  const lines: string[] = []
100  const room = (): number => Math.max(1, lines.length === 0 ? first : width)
101  let i = 0
102  while (i < words.length) {
103    const w = room()
104    if (lines.length === maxLines - 1) {
105      const rest = words.slice(i).join(' ')
106      if (cells(rest) <= w) {
107        lines.push(rest)
108        return lines
109      }
110      let s = ''
111      for (let j = i; j < words.length; j++) {
112        const t = s === '' ? (words[j] ?? '') : `${s} ${words[j] ?? ''}`
113        if (cells(`${t.replace(TRAIL, '')}…`) > w) break
114        s = t
115      }
116      if (s === '') s = headCells(words[i] ?? '', w - 1)
117      lines.push(`${s.replace(TRAIL, '')}…`)
118      return lines
119    }
120    let cur = words[i] ?? ''
121    if (cells(cur) > w) {
122      const head = headCells(cur, w)
123      lines.push(head)
124      words[i] = cur.slice(head.length)
125      continue
126    }
127    i += 1
128    while (i < words.length && cells(cur) + 1 + cells(words[i] ?? '') <= w) {
129      cur += ` ${words[i] ?? ''}`
130      i += 1
131    }
132    lines.push(cur)
133  }
134  return lines
135}
136
137/** One line: the whole text if it fits, else cut mid-word with `…`, so the line is used to its last cell. */
138export function clip(s: string, w: number): string {
139  if (cells(s) <= w) return s
140  let cut = headCells(s, Math.max(0, w - 1))
141  const next = s.slice(cut.length, cut.length + 1)
142  // a stub of one or two letters reads worse than nothing: end at the word before it
143  const sp = cut.lastIndexOf(' ')
144  if (next !== ' ' && sp > 0 && cells(cut) - sp - 1 <= 2) cut = cut.slice(0, sp)
145  return `${cut.replace(TRAIL, '')}…`
146}
147
148/** `Label ──── right`: the label bold, a faint rule, the right dim (folded: `right ›`, a little brighter). */
149export function heading(label: string, right: string, w: number, o: { folded?: boolean; key?: string } = {}): Line {
150  const r = o.folded === true ? `${right} ›`.trim() : right
151  const segs: Seg[] = [seg(label, P.head, { b: true })]
152  const ruleN = w - cells(label) - 1 - (r === '' ? 0 : cells(r) + 1)
153  if (ruleN >= 1) segs.push(seg(` ${'─'.repeat(ruleN)}`, P.faint))
154  if (r !== '') segs.push(seg(ruleN >= 1 ? ` ${r}` : ` ${r}`, o.folded === true ? P.mid : P.dim))
155  return line(segs, o.key)
156}
157
158/** Lines of plain text in one colour, each carrying the key. */
159function plain(texts: readonly string[], c: string, key?: string, o: { b?: boolean; i?: boolean } = {}): Line[] {
160  return texts.map(t => line([seg(t, c, o)], key))
161}
162
163// ── what the body is drawn from ──────────────────────────────────────────────
164
165export type BodyState = {
166  /** Text columns (the pane's body less its padding). */
167  w: number
168  /** Rows the body may take before the footer. */
169  rows: number
170  now: number
171  mind: SidebarMind | null
172  saved: readonly SidebarSaved[]
173  today: SidebarToday | null
174  dream: SidebarDream | null
175  open: SidebarOpen | null
176  mech: SidebarMechOpen | null
177  focus: string | null
178  dash: 'unknown' | 'up' | 'down' | 'other'
179  /** When the status file the numbers come from was written (null: none read): an old one says its age. */
180  statusAt?: number | null
181  /**
182   * A surface that sets text in its own font (the desktop): a blank row between items, and the chart's rows as
183   * a name column, a solid bar and a count at the edge, since padding with spaces lines nothing up there.
184   */
185  prose?: boolean
186  /** The pane's own text width in cells, when `w` was widened for a prose surface's narrower font (bars use it). */
187  cellsW?: number
188  /** The wake's first glance line (Last here, Coming up, Yesterday), or null. */
189  glance?: Glance | null
190}
191
192/** How far each section is folded: the ladder's knobs. */
193type Fold = {
194  /** Memories items shown (each whole, up to ITEM_LINES). */
195  memShow: number
196  /** An opened item's text, in lines. */
197  text: number
198  /** Subconscious items shown; 0 folds it to its heading. */
199  sub: number
200  savedShow: number
201  savedFolded: boolean
202  chartFolded: boolean
203  events: number
204  dreamLines: number
205  excerpt: number
206  dreamFolded: boolean
207  mindFolded: boolean
208  /** The glance (Last here, Coming up, Yesterday) left out: the first thing to give way. */
209  glanceOff: boolean
210}
211
212const FULL: Fold = {
213  memShow: 99, text: OPEN_MAX_LINES, sub: 4, savedShow: SAVED_MAX, savedFolded: false, chartFolded: false,
214  events: 6, dreamLines: 3, excerpt: OPEN_MAX_LINES, dreamFolded: false, mindFolded: false, glanceOff: false,
215}
216
217/** One fold of the ladder; `fold` when it folds a section to its heading. */
218type Step = { label: string; section: string; apply: (f: Fold) => Fold; fold: boolean }
219const step = (label: string, section: string, apply: (f: Fold) => Fold): Step => ({ label, section, apply, fold: label.endsWith('folded') })
220const savedTo = (n: number) => step(`saved shows ${String(n)}`, 'saved', f => ({ ...f, savedShow: Math.min(f.savedShow, n) }))
221const savedFold = step('saved folded', 'saved', f => ({ ...f, savedFolded: true }))
222const subTo = (n: number) => step(`subconscious ${String(n)}`, 'sub', f => ({ ...f, sub: Math.min(f.sub, n) }))
223const dreamTo = (n: number) => step(`dream ${String(n)} lines`, 'dream', f => ({ ...f, dreamLines: Math.min(f.dreamLines, n) }))
224const excerptTo = (n: number) => step(`dream excerpt ${String(n)} lines`, 'dream', f => ({ ...f, excerpt: Math.min(f.excerpt, n) }))
225const textTo = (n: number) => step(`opened text ${String(n)} lines`, 'open', f => ({ ...f, text: Math.min(f.text, n) }))
226const eventsTo = (n: number) => step(`events ${String(n)}`, 'chart', f => ({ ...f, events: Math.min(f.events, n) }))
227const memTo = (n: number) => step(`memories shows ${String(n)}`, 'mind', f => ({ ...f, memShow: Math.min(f.memShow, n) }))
228const chartFold = step('chart folded', 'chart', f => ({ ...f, chartFolded: true }))
229const mindFold = step('memories folded', 'mind', f => ({ ...f, mindFolded: true, sub: 0 }))
230const dreamFold = step('dream folded', 'dream', f => ({ ...f, dreamFolded: true }))
231const glanceGone = step('glance off', 'glance', f => ({ ...f, glanceOff: true }))
232/**
233 * Saved items, fewer one at a time (down to `to`), each keeping its two
234 * lines; `savedFold` then folds the section to its heading. Never one line each: Mike would rather read
235 * fewer titles whole than many cut short (his standing complaint about v0.1,
236 * restated for v0.2 on 2026-10-10), so how many fit is the room's to decide,
237 * not a cut.
238 */
239function savedFewer(n: number, to = 1): Step[] {
240  const out: Step[] = []
241  for (let k = n - 1; k >= Math.max(1, to); k--) out.push(savedTo(k))
242  return out
243}
244
245/** A mechanism's firings, fewer one at a time, each keeping its lines. */
246const EVENTS_DOWN = [eventsTo(5), eventsTo(4), eventsTo(3), eventsTo(2)]
247
248/**
249 * WHAT FOLDS FIRST, per what is open (compose3.ts's ladder, Mike approved
250 * its 160x48 result, less its "saved 1 line each" step): saved items go
251 * fewer, then the section folds to its heading; then the dream shortens, the
252 * subconscious shows fewer; the chart and the memories fold last. An opened
253 * item keeps its room longest: the rest folds around it.
254 */
255function ladders(s: BodyState): Readonly<Record<'default' | 'open' | 'mech' | 'dream', readonly Step[]>> {
256  const n = Math.min(s.saved.length, SAVED_MAX)
257  const saved = [...savedFewer(n), savedFold]
258  return {
259    default: [glanceGone, ...saved, dreamTo(2), subTo(3), subTo(2), dreamTo(1), subTo(1), memTo(2), subTo(0), memTo(1), chartFold, mindFold, dreamFold],
260    // An opened item's text is the last to give way (cut, it ends with `more`); the dream (two rows) folds
261    // before the chart (twelve).
262    open: [glanceGone, ...saved, dreamTo(1), subTo(1), subTo(0), dreamFold, chartFold, textTo(14), textTo(10), textTo(8), textTo(5)],
263    mech: [glanceGone, ...saved, dreamTo(1), mindFold, ...EVENTS_DOWN, dreamFold, eventsTo(1)],
264    // The dream the person opened keeps its words while the saved list goes down to two items; past that its
265    // excerpt shortens before the last two go (the round-3 160x48 frame: two saved, the dream's first sentence).
266    dream: [glanceGone, chartFold, ...savedFewer(n, 2), excerptTo(14), excerptTo(10), excerptTo(7), excerptTo(5), excerptTo(3), ...(n > 1 ? [savedTo(1)] : []), savedFold, subTo(1), subTo(0), mindFold],
267  }
268}
269
270// ── the sections ─────────────────────────────────────────────────────────────
271
272/**
273 * `text` wrapped to `w`, at most `max` lines. Cut, the last line ends at a
274 * word with `…` and, when `moreKey` names where the rest is, a cyan `more`
275 * on that same line (its cells a link to `moreKey`).
276 */
277function textLines(text: string, w: number, max: number, c: string, key: string, moreKey: string | null, o: { b?: boolean; i?: boolean } = {}, cut = false): Line[] {
278  const all = wrapN(text, w)
279  if (all.length <= max && !(cut && moreKey !== null)) return plain(all, c, key, o)
280  if (moreKey === null) return plain(wrapN(text, w, max), c, key, o)
281  const head = all.slice(0, Math.max(0, Math.min(all.length, max) - 1))
282  const room = w - cells(MORE) - 1
283  const last = wrapN(all.slice(head.length).join(' '), room, 1)[0] ?? ''
284  return [
285    ...plain(head, c, key, o),
286    { segs: [seg(last, c, o), seg(' ', c), seg(MORE, P.accent)], key, link: { from: cells(last) + 1, key: moreKey } },
287  ]
288}
289
290/**
291 * An opened memory in place: its title in full (bold, after `dot` on its
292 * first line when given), its text (up to the cap, then `more`), what it is,
293 * and `extra` lines (a saved update's `replaces …`).
294 */
295function opened(s: BodyState, key: string, fallbackTitle: string, f: Fold, dot?: Seg, extra: Line[] = []): Line[] {
296  const o = s.open
297  const title = humanDates(o?.title ?? fallbackTitle, s.now)
298  const out = wrapN(title, s.w, 4, dot === undefined ? s.w : s.w - cells(dot.t)).map((t, i) =>
299    line([...(i === 0 && dot !== undefined ? [dot] : []), seg(t, P.hi, { b: true })], key),
300  )
301  if (o === null || o.key !== key) return out
302  if (o.status === 'loading') return [...out, line([seg('opening…', P.dim)], key), ...extra]
303  if (o.status === 'error') return [...out, ...plain(wrapN(o.text || "couldn't read it", s.w, 2), P.dim, key), ...extra]
304  if (o.text !== '') out.push(...textLines(o.text, s.w, f.text, P.text, key, o.id === null ? null : `more:${o.id}`, {}, o.cut === true))
305  if (o.meta !== null) out.push(line([seg(o.meta, P.dim)], key))
306  out.push(...extra)
307  return out
308}
309
310function memories(s: BodyState, f: Fold): Line[] {
311  const m = s.mind
312  if (m === null || m.surfaced.length === 0) return []
313  const time = hm(m.at, s.now)
314  if (f.mindFolded && s.open?.key.startsWith('mem:') !== true) return [heading('Memories', time, s.w, { folded: true, key: 'head:mind' })]
315  const out: Line[] = [heading('Memories', time, s.w, { key: 'head:mind' })]
316  const openIdx = s.open?.key.startsWith('mem:') === true ? Number(s.open.key.slice(4)) : -1
317  m.surfaced.slice(0, Math.max(f.memShow, openIdx + 1, 1)).forEach((r, i) => {
318    const key = `mem:${String(i)}`
319    if (s.open?.key === key) out.push(...opened(s, key, r.title, f))
320    else out.push(...plain(wrapN(humanDates(r.title, s.now), s.w, ITEM_LINES), P.hi, key))
321  })
322  return out
323}
324
325/** A footnote's title, up to ITEM_LINES lines; `↗ opened` after it when Claude opened it, on its last line when it fits. */
326function subTitle(r: SidebarMemoryRef, key: string, w: number, now: number): Line[] {
327  const c = r.opened === true ? P.text : P.mid
328  const title = humanDates(r.title, now)
329  if (r.opened !== true) return plain(wrapN(title, w, ITEM_LINES), c, key)
330  const mark = [seg(' ', P.dim), seg('↗', P.accent), seg(' opened', P.dim)]
331  const markW = cells(' ↗ opened')
332  let ls = wrapN(title, w, ITEM_LINES)
333  const lastW = cells(ls[ls.length - 1] ?? '')
334  if (lastW + markW > w) {
335    if (ls.length < ITEM_LINES) return [...plain(ls, c, key), line(mark.slice(1), key)]
336    // the third line gives the mark its room
337    ls = [...ls.slice(0, -1), ...wrapN(wrapN(title, w).slice(ls.length - 1).join(' '), w - markW, 1)]
338  }
339  return ls.map((t, i) => line([seg(t, c), ...(i === ls.length - 1 ? mark : [])], key))
340}
341
342function subconscious(s: BodyState, f: Fold): Line[] {
343  const m = s.mind
344  if (m === null || m.footnotes.length === 0) return []
345  // The time goes here only when Memories, which carries it, is not drawn.
346  const time = m.surfaced.length === 0 ? hm(m.at, s.now) : ''
347  const openIdx = s.open?.key.startsWith('sub:') === true ? Number(s.open.key.slice(4)) : -1
348  const n = Math.min(m.footnotes.length, Math.max(f.sub, openIdx + 1))
349  if (n === 0) {
350    const right = time === '' ? String(m.footnotes.length) : `${String(m.footnotes.length)} · ${time}`
351    return [heading('Subconscious', right, s.w, { folded: true, key: 'head:mind' })]
352  }
353  const out: Line[] = [heading('Subconscious', time, s.w, { key: 'head:mind' })]
354  m.footnotes.slice(0, n).forEach((r, i) => {
355    const key = `sub:${String(i)}`
356    out.push(...(s.open?.key === key ? opened(s, key, r.title, f) : subTitle(r, key, s.w, s.now)))
357  })
358  return out
359}
360
361function stageDot(kind: SidebarSaved['kind']): string {
362  return hex(kind === 'new' ? STAGES.encoding.col : STAGES.transformation.col)
363}
364
365function saved(s: BodyState, f: Fold): Line[] {
366  // A chapter is a journal entry, not a saved memory (a state written by an earlier copy may hold one).
367  const list = s.saved.filter(it => (it.kind as string) !== 'chapter')
368  const n = list.length
369  if (n === 0) return []
370  if (f.savedFolded && s.open?.key.startsWith('saved:') !== true) return [heading('Saved this session', String(n), s.w, { folded: true, key: 'head:saved' })]
371  const out: Line[] = [heading('Saved this session', String(n), s.w, { key: 'head:saved' })]
372  // An opened item is never folded away: the list shows at least down to it.
373  const openIdx = list.findIndex(it => s.open?.key === `saved:${it.key}`)
374  for (const it of list.slice(0, Math.min(Math.max(f.savedShow, openIdx + 1), n))) {
375    const key = `saved:${it.key}`
376    if (s.open?.key === key) {
377      // What an update replaces shows only opened (Mike, 2026-10-10: no `replaces` line in the list).
378      const extra = it.kind === 'update' ? plain(wrapN(`replaces ${humanDates(it.replaces ?? 'an earlier memory', s.now)}`, s.w, 3), P.dim, key) : []
379      out.push(...opened(s, key, it.title, f, seg('● ', stageDot(it.kind)), extra))
380    } else {
381      // The stage dot on the first line only; wrapped lines go back to the left edge, up to three, one colour.
382      wrapN(humanDates(it.title, s.now), s.w, ITEM_LINES, s.w - 2).forEach((t, i) => {
383        out.push(line([...(i === 0 ? [seg('● ', stageDot(it.kind))] : []), seg(t, P.hi)], key))
384      })
385    }
386  }
387  return out
388}
389
390/**
391 * `▄▄▄▖`: a half-height bar in half-cell steps, at least one half. On a log
392 * scale (2026-10-15, Mike, lightly held): one mechanism firing 144 times left
393 * every other bar a sliver, so 1, 10 and 100 are now even steps; the count
394 * beside the bar says the number.
395 */
396export function bar(v: number, max: number, width: number): string {
397  const share = Math.log1p(Math.max(0, v)) / Math.log1p(Math.max(1, max))
398  const halves = Math.max(1, Math.round(share * width * 2))
399  return '▄'.repeat(Math.floor(halves / 2)) + (halves % 2 === 1 ? '▖' : '')
400}
401
402/** The Mechanisms section before any session has written the status file (`status.ts`). */
403export const WAITING = 'Waiting for the first session: memory writes what it did here after a turn.'
404
405/** What shows in place of another store's numbers. */
406export const OTHER_STORE = 'The status file is another memory’s.'
407
408function chart(s: BodyState, f: Fold): Line[] {
409  const t = s.today
410  if (t === null) {
411    if (s.dash === 'down') {
412      // No status file yet (a new install, before its first turn): calm, dim, nothing to do.
413      return [heading('Mechanisms today', '', s.w), ...plain(wrapN(WAITING, s.w), P.dim)]
414    }
415    if (s.dash === 'other') {
416      // The status file is another store's (core #459): none of its numbers here.
417      return [heading('Mechanisms today', '', s.w), ...plain(wrapN(OTHER_STORE, s.w), P.dim)]
418    }
419    return [heading('Mechanisms today', '', s.w), line([seg('reading…', P.dim)])]
420  }
421  const fired = MECHS.filter(m => (t.counts[m.id] ?? 0) > 0).length
422  if (f.chartFolded) return [heading('Mechanisms today', `${String(fired)} fired`, s.w, { folded: true, key: 'head:chart' })]
423  const NAME = 16
424  const CNT = 4
425  const barW = Math.max(4, (s.cellsW ?? s.w) - NAME - CNT)
426  const max = Math.max(1, ...MECHS.map(m => t.counts[m.id] ?? 0))
427  // A file no writer has refreshed in an hour (no session running its memory server): it says how old it is.
428  const old = s.statusAt !== null && s.statusAt !== undefined && s.now - s.statusAt > STATUS_STALE_MS
429  const out: Line[] = [heading('Mechanisms today', old ? agoWords(s.now - (s.statusAt ?? s.now)) : 'times fired', s.w, { key: 'head:chart' })]
430  for (const m of MECHS) {
431    const v = t.counts[m.id]
432    const key = `mech:${m.id}`
433    const picked = s.mech?.id === m.id
434    // Not built yet: not drawn until it is (the fox trial, 2026-10-12).
435    if (m.notBuilt === true) continue
436    if (v === null) {
437      out.push(line([seg(m.short.padEnd(NAME), P.dim), seg(clip('○ not built yet', s.w - NAME), P.dim)], key))
438      continue
439    }
440    const n = v ?? 0
441    const name = seg(clip(m.short, NAME - 1).padEnd(NAME), n === 0 ? P.dim : picked ? P.hi : P.text, { b: picked })
442    const b = n > 0 ? bar(n, max, barW) : ''
443    const cs = String(n)
444    const count = seg(cs, n === 0 ? P.dim : picked ? P.hi : P.mid)
445    if (s.prose === true) {
446      const len = cells(b)
447      out.push(line([{ ...name, t: name.t.trimEnd(), cw: NAME }, { t: '', c: hex(stageOf(m.id).col, BAR_K), fill: len, of: barW }, { t: '', c: P.dim, grow: true as const }, count], key))
448    } else {
449      const pad = Math.max(1, s.w - NAME - cells(b) - cs.length)
450      out.push(line([name, seg(b, hex(stageOf(m.id).col, BAR_K)), seg(' '.repeat(pad), P.dim), count], key))
451    }
452    if (picked) out.push(...mechEvents(s, f))
453  }
454  return out
455}
456
457/** The opened mechanism's recent firings, grouped under what happened and when (`faded at 8:07:`), each title a line. */
458function mechEvents(s: BodyState, f: Fold): Line[] {
459  const m = s.mech
460  if (m === null) return []
461  if (m.status === 'loading') return [line([seg('reading…', P.dim)], `mech:${m.id}`)]
462  if (m.status === 'error') return plain(wrapN('nothing to show yet: no status file', s.w), P.dim, `mech:${m.id}`)
463  if (m.events.length === 0) {
464    // Association's firings (link flushes) count the pairs they linked and name none yet: say what it does instead.
465    const said = m.id === 'association' ? "links memories used together; it doesn't record which yet" : 'nothing lately names a memory'
466    return plain(wrapN(said, s.w), P.dim, `mech:${m.id}`)
467  }
468  const out: Line[] = []
469  let last = ''
470  for (const e of m.events.slice(0, f.events)) {
471    const when = hm(e.at, s.now)
472    const head = `${e.word} ${when.includes(':') ? 'at ' : ''}${when}:`
473    if (head !== last) {
474      out.push(line([seg(clip(head, s.w), P.mid)], `mech:${m.id}`))
475      last = head
476    }
477    const key = `ev:${e.key}`
478    if (s.open?.key === key) {
479      if (e.memoryId === null) {
480        out.push(...plain(wrapN(humanDates(e.title, s.now), s.w, 4), P.hi, key, { b: true }))
481        const rest = e.text.startsWith(e.title) ? e.text.slice(e.title.length).trim() : e.text
482        if (rest !== '') out.push(...textLines(rest, s.w, f.text, P.text, key, null))
483      } else out.push(...opened(s, key, e.title, f))
484    } else {
485      // no dot or bullet between firings: a wrapped line a shade softer says where one ends
486      wrapN(humanDates(e.title, s.now), s.w, ITEM_LINES).forEach((t, i) => out.push(line([seg(t, i === 0 ? P.text : P.mid)], key)))
487    }
488  }
489  return out
490}
491
492function dream(s: BodyState, f: Fold): Line[] {
493  const d = s.dream
494  if (d === null) return []
495  // its journal row's time; failing that its date before today, and nothing for today (no made-up time)
496  const when = d.at !== null ? hm(d.at, s.now) : d.date !== null && d.date !== dateOf(s.now) ? hm(new Date(`${d.date}T12:00:00`).getTime(), s.now) : ''
497  if (f.dreamFolded) return [heading('Last Dream', when, s.w, { folded: true, key: 'head:dream' })]
498  const out: Line[] = [heading('Last Dream', when, s.w, { key: 'dream' })]
499  if (s.open?.key !== 'dream') return [...out, ...plain(wrapN(d.first, s.w, f.dreamLines), P.text, 'dream', { i: true })]
500  // Opened: the dream itself, as far as the room goes (no "what changed" counts: Mike, 2026-10-10).
501  const body = f.excerpt <= 3 || d.rest === '' ? d.first : `${d.first} ${d.rest}`
502  out.push(...plain(wrapN(body, s.w, f.excerpt), P.text, 'dream', { i: true }))
503  return out
504}
505
506const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
507
508/** A `Coming up` item's leading date in words: `Today · …`, `Tomorrow · …`, else `Oct 20 · …`. */
509export function leadDate(text: string, now: number): string {
510  return text.replace(/^(\d{4})-(\d{2})-(\d{2}) · /, (whole, y: string, m: string, d: string) => {
511    const day = `${y}-${m}-${d}`
512    if (day === dateOf(now)) return 'Today · '
513    if (day === dateOf(now + 86_400_000)) return 'Tomorrow · '
514    const name = MONTHS[Number(m) - 1]
515    return name === undefined ? whole : `${name} ${String(Number(d))} · `
516  })
517}
518
519/** Lines the glance may take. */
520const GLANCE_LINES = 3
521
522/** Before anything happens: where things were left, what's coming up, or yesterday (the wake's own lines). */
523function glance(s: BodyState, f: Fold): Line[] {
524  const g = s.glance
525  const opened = s.open?.key === 'glance'
526  // Opened (a click, as the dream opens), it is the one opened text, and shortens only as an opened text does.
527  if (g === null || g === undefined || (f.glanceOff && !opened)) return []
528  return [heading(g.head, '', s.w, { key: 'glance' }), ...plain(wrapN(humanDates(leadDate(g.text, s.now), s.now), s.w, opened ? f.text : GLANCE_LINES), P.text, 'glance')]
529}
530
531// ── the ladder ───────────────────────────────────────────────────────────────
532
533function sectionsOf(s: BodyState, f: Fold): Line[][] {
534  return [memories(s, f), subconscious(s, f), saved(s, f), chart(s, f), dream(s, f), glance(s, f)].filter(x => x.length > 0)
535}
536
537function total(secs: readonly Line[][], gaps: boolean): number {
538  return secs.reduce((a, r) => a + r.length, 0) + (gaps ? Math.max(0, secs.length - 1) : 0)
539}
540
541/** An item's key: Memories, Subconscious and Saved rows (a mechanism's rows stay together). */
542const ITEM_KEY = /^(mem|sub|saved):/
543
544/** A blank row wherever one item ends and the next begins. */
545function spaced(sec: readonly Line[]): Line[] {
546  const out: Line[] = []
547  sec.forEach((l, i) => {
548    const prev = sec[i - 1]?.key
549    if (prev !== undefined && l.key !== undefined && prev !== l.key && ITEM_KEY.test(prev) && ITEM_KEY.test(l.key)) out.push(BLANK)
550    out.push(l)
551  })
552  return out
553}
554
555function joined(secs: readonly Line[][], gaps: boolean): Line[] {
556  const out: Line[] = []
557  secs.forEach((sec, i) => {
558    if (gaps && i > 0) out.push(BLANK)
559    out.push(...sec)
560  })
561  return out
562}
563
564/** Which ladder: what is open decides what keeps its room. */
565function ladderFor(s: BodyState): readonly Step[] {
566  const l = ladders(s)
567  if (s.mech !== null) return l.mech
568  if (s.open?.key === 'dream') return l.dream
569  if (s.open !== null) return l.open
570  return l.default
571}
572
573/**
574 * The body: the least folding that fits with a blank row between sections;
575 * failing that, without the blank rows; then any fold the last one made
576 * unnecessary is opened again, the most important first. A section the
577 * person opened from its folded heading (`focus`) is never folded to its
578 * heading again (it may still shorten). Past the ladder's end the body is as
579 * it stands and the pane scrolls.
580 */
581export function layoutBody(s: BodyState): { lines: Line[]; folds: string[] } {
582  // The focused section never folds to its heading, and shortens only after every other section has folded.
583  const mine = (st: Step): boolean => st.section === s.focus || (s.focus === 'mind' && st.section === 'sub')
584  const ladder = ladderFor(s).filter(st => !(mine(st) && (st.fold || st.label === 'subconscious 0')))
585  const steps = [...ladder.filter(st => !mine(st)), ...ladder.filter(mine)]
586  const build = (on: ReadonlySet<number>): Line[][] => {
587    const secs = sectionsOf(s, steps.reduce((f, st, i) => (on.has(i) ? st.apply(f) : f), FULL))
588    return s.prose === true ? secs.map(spaced) : secs
589  }
590  const fit = (gaps: boolean): { on: Set<number>; secs: Line[][] } | null => {
591    for (let k = 0; k <= steps.length; k++) {
592      let on = new Set(Array.from({ length: k }, (_, i) => i))
593      let secs = build(on)
594      if (total(secs, gaps) > s.rows) continue
595      for (let i = k - 2; i >= 0; i--) {
596        const less = new Set(on)
597        less.delete(i)
598        const b = build(less)
599        if (total(b, gaps) <= s.rows) {
600          on = less
601          secs = b
602        }
603      }
604      return { on, secs }
605    }
606    return null
607  }
608  for (const gaps of [true, false]) {
609    const got = fit(gaps)
610    if (got !== null) return { lines: joined(got.secs, gaps), folds: [...got.on].sort((a, b) => a - b).map(i => steps[i]?.label ?? '') }
611  }
612  const all = new Set(steps.map((_, i) => i))
613  return { lines: joined(build(all), false), folds: steps.map(st => st.label) }
614}
615
616/** The lines an item's text takes at width `w`. */
617export function openedLines(text: string, w: number): number {
618  return text === '' ? 0 : wrapN(text, w).length
619}
620
621/** For tests: the body's text, a line a string. */
622export function textOf(lines: readonly Line[]): string[] {
623  return lines.map(l => l.segs.map(x => x.t).join(''))
624}
625
626export type { MechId }
627