Memory that turns Claude into a counterpart: a local-first memory layer modeled on human memory. Wakes each session with what matters, remembers by meaning…

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

counterparts delete).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.
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.
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.
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.
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:
| module | what it does |
|---|---|
| store | One SQLite database, plus a rebuildable search cache and daily snapshots |
| encode | Filters what comes in: strips credentials, drops junk, scores what's new |
| remember | Takes what the AI writes during and at the end of a session |
| recall | Finds related memories as you talk |
| associate | Links memories that come up together |
| schemas | Beliefs about people and things, revised slowly |
| self | Identity, the journal, and the start-of-session briefing |
| prospective | Reminders for later, triggered by a date or a topic |
| sleep | Nightly housekeeping: fading, pruning, merging, promoting |
| physics | The math for strength and decay |
More: docs/ELI5.md (the whole system in plain words) and docs/module-map.md.
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.
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.
MIT. Contributions are welcome; see CONTRIBUTING.md.
hooks/sidebar/hooks/full.tsx 22 lines1/**
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}
22hooks/sidebar/hooks/register.tsx 1421 lines1/**
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/sidebar/hooks/speak-first.tsx 194 lines1/**
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}
194hooks/sidebar/hooks/body.tsx 74 lines1/**
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
74hooks/sidebar/hooks/desktop.tsx 62 lines1/**
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}
62hooks/sidebar/hooks/feed.ts 847 lines1/**
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}
847hooks/sidebar/hooks/mechanisms.ts 94 lines1/**
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';
94hooks/sidebar/hooks/fox.ts 640 lines1/**
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}
640hooks/sidebar/hooks/fox-svg.ts 42 lines1/**
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}
42hooks/sidebar/hooks/pane.tsx 117 lines1/**
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}
117hooks/sidebar/hooks/scopes.ts 204 lines1/**
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}
204hooks/sidebar/hooks/sections.ts 627 lines1/**
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