@inerrata-corporation/errata 2.0.21-dev.3993 → 2.0.21-dev.4001

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,44 +1,41 @@
1
1
  import { atom, read, update } from 'claude-code'
2
2
  import type { EngineInterface, Register, Timer } from 'claude-code'
3
3
 
4
- import type { Burst, MapGraph, Verdict } from '../types'
4
+ import type { Burst, ChainFacts, MapGraph, Verdict } from '../types'
5
+ import { BINDING_GLYPH, BINDING_NAME, bindingOf, fitTitle, focusBook, PALETTE, rasterizeLibrary } from './bookcase'
6
+ import type { Binding, LibraryFrame, LibraryOptions, Room } from './bookcase'
7
+ import { bookHolding, callNumber, catalogue, enrichTargets, parseNeighbors } from './chains'
8
+ import type { Catalog } from './chains'
9
+ import { colorOf, crossEdges, DARK_BG, glyphOf, layoutRadial, LIGHT_BG, rasterizeGraph, SCOPE_COLOR, SCOPE_ORDER, VERDICT_COLOR } from './graph'
10
+ import type { RasterOptions } from './graph'
5
11
  import {
6
- colorOf,
7
- DARK_BG,
8
- FULL,
9
- LIGHT_BG,
10
12
  displayName,
11
- glyphOf,
12
- hex,
13
13
  JUDGMENT_STYLE,
14
14
  layoutBurst,
15
- parseJudgments,
16
- resolveJudgmentRef,
17
- neighborIds,
18
15
  parseAgentBurst,
19
16
  parseAgentSearch,
20
17
  parseFileRecall,
18
+ parseJudgments,
21
19
  parsePromptPrime,
22
- rasterize,
23
- SCOPE_COLOR,
24
- SCOPE_ORDER,
25
- seedNode,
26
20
  relPath,
21
+ resolveJudgmentRef,
22
+ seedNode,
27
23
  shortName,
28
24
  statusLine,
29
25
  textsOf,
30
26
  } from './minimap'
31
- import type { Frame, HandleTable, JudgmentKind, RasterOptions } from './minimap'
27
+ import type { HandleTable, JudgmentKind } from './minimap'
28
+ import { hasTags, TAG_MODES, tidyTags } from './tags'
29
+ import type { TagMode } from './tags'
32
30
 
33
31
  const PANE = 'errata-minimap'
34
32
  const ERRATA = 'errata'
35
- const ENRICH_MAX = 8
36
- const TOP_ITEMS = 3
37
33
 
38
34
  const focus = atom({ plugin: 'errata-minimap', key: 'focus' } as const, '')
39
35
  const burst = atom({ plugin: 'errata-minimap', key: 'burst' } as const, null as Burst | null)
40
- const graph = atom({ plugin: 'errata-minimap', key: 'graph' } as const, { title: '', nodes: [], edges: [], at: 0 } as MapGraph)
36
+ const graph = atom({ plugin: 'errata-minimap', key: 'graph' } as const, { title: '', nodes: [], at: 0 } as MapGraph)
41
37
  const verdicts = atom({ plugin: 'errata-minimap', key: 'verdicts' } as const, [] as Verdict[])
38
+ const facts = atom({ plugin: 'errata-minimap', key: 'facts' } as const, {} as Record<string, ChainFacts | null>)
42
39
 
43
40
  const FILE_TOOLS = new Set(['Read', 'Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
44
41
  const BURST_FILE = /\.errata\/g\/burst\/([^/]+)/
@@ -46,60 +43,115 @@ const BURST_FILE = /\.errata\/g\/burst\/([^/]+)/
46
43
  const ORIGIN_LABEL = { agent: 'agent burst', 'daemon-file': 'file recall', 'daemon-prompt': 'prompt prime' } as const
47
44
 
48
45
  // Module-level on purpose: a reload starts by redrawing whatever burst the session holds.
49
- const pending = { isRefreshing: false, drawnAt: -1 }
46
+ const pending = { isRefreshing: false, drawnAt: -1, cataloguedAt: -1, askedFor: -1, asked: 0 }
47
+
48
+ /**
49
+ * `errata_neighbors` calls per burst: each completes and places one chain (its
50
+ * cause/fix beyond the burst, package, local community, patterns, anchor). Run
51
+ * after the first draw, a few per tick, so the shelves fill in without ever
52
+ * holding the first picture back.
53
+ */
54
+ const ENRICH_PER_BURST = 10
55
+ const ENRICH_PER_TICK = 4
56
+ /** Facts kept across bursts (the same chains recur); past this the cache keeps only the current burst's. */
57
+ const FACTS_KEPT = 300
50
58
 
51
59
  /**
52
60
  * Whether the pane is drawn decides the UI. Drawn — docked beside a fullscreen
53
- * transcript, or inline above the prompt — it is the full map; waiting undrawn
54
- * on a narrow terminal, behind another tab or closed by hand, the map collapses
55
- * into the status line. The engine does the seating: an unasked pane waits
56
- * below 144 columns and takes its place once the terminal widens. The pane is
57
- * never closed here — closing an inline one dropped that request, and outside
58
- * fullscreen every seat is inline, so the map stayed collapsed for the session.
61
+ * transcript, or inline above the prompt — it is the full library; waiting
62
+ * undrawn on a narrow terminal, behind another tab or closed by hand, it
63
+ * collapses into the status line. The engine does the seating: an unasked pane
64
+ * waits below 144 columns and takes its place once the terminal widens. The
65
+ * pane is never closed here — closing an inline one dropped that request, and
66
+ * outside fullscreen every seat is inline, so the map stayed collapsed for the
67
+ * session.
59
68
  */
60
69
  const view = { isShown: false, status: undefined as string | undefined }
61
70
 
62
- /** Rows an inline pane asks for: room for the map without burying the transcript. */
71
+ /** How the chat draws inline tags; kept in the plugin's store between sessions. */
72
+ const tags = { mode: 'margin' as TagMode }
73
+ const TAG_MODE_KEY = 'tagMode'
74
+
75
+ /**
76
+ * What the pane draws: the library (default) or the original radial graph.
77
+ * Same burst, same enrichment — the graph's cross edges come from the facts
78
+ * the library fetched. Kept in the plugin's store between sessions.
79
+ */
80
+ type PaneView = 'books' | 'graph'
81
+ const PANE_VIEWS: readonly PaneView[] = ['books', 'graph']
82
+ const pane = { view: 'books' as PaneView }
83
+ const PANE_VIEW_KEY = 'paneView'
84
+
85
+ /** Rows an inline pane asks for: room for a tier of bookcases and the card without burying the transcript. */
63
86
  const INLINE_ROWS = 20
64
87
 
65
- const GROW_MS = 1200
66
- const TYPE_MS = 600
88
+ const GROW_MS = 900
89
+ const TYPE_MS = 500
67
90
  const FRAME_MS = 33
68
91
  const HOLD_MS = 700
69
92
  const PULSE_MS = 180
70
93
 
71
94
  /**
72
- * The reveal and the highlight run on blits, not render passes: a new map's
73
- * routes grow out ring by ring, then its names type out; a prior the agent
74
- * bursts off pulses (its route to the seed lit) until the fresh results replace
75
- * the map. Only while docked — the status line has nothing to animate.
95
+ * The reveal and the highlight run on blits, not render passes: a new burst's
96
+ * books rise onto their shelves, then the catalog card inks in; a book the
97
+ * agent bursts off flashes gilt until the fresh results replace the shelves; a
98
+ * verdict turns the card to its book and flashes its stamp. Only while drawn —
99
+ * the status line has nothing to animate.
76
100
  */
77
101
  const MARK_MS = 4000
78
- /** How long a verdict stays in the footer and on the status line. */
102
+ /** How long a verdict stays stamped on its book's plank, and on the status line. */
79
103
  const VERDICT_SHOWN_MS = 10 * 60 * 1000
80
104
  const VERDICT_STATUS_MS = 60 * 1000
81
105
  const VERDICTS_KEPT = 6
82
106
 
107
+ const FULL: LibraryFrame = { grow: 1, type: 1 }
108
+
83
109
  const motion = {
84
- /** Priors the agent just judged → their verdict, pulsing until `markUntil`. */
110
+ /** Priors the agent just judged → their verdict, flashing until `markUntil`. */
85
111
  marks: {} as Record<string, JudgmentKind>,
86
112
  markUntil: 0,
87
113
  isMarkOn: true,
88
114
  graphAt: -1,
89
115
  start: 0,
90
- frame: FULL as Frame,
116
+ frame: FULL,
91
117
  highlight: undefined as string | undefined,
92
118
  highlightStart: 0,
93
119
  isPulseOn: true,
94
120
  raster: undefined as { columns: number; rows: number } | undefined,
95
- bg: DARK_BG,
121
+ room: 'stacks' as Room,
96
122
  timer: undefined as Timer | undefined,
123
+ /** The node whose book the card reads: the latest one judged on the shelves; unset = the seed's book. */
124
+ focus: undefined as string | undefined,
125
+ }
126
+
127
+ async function catalogOf($: EngineInterface, map: MapGraph): Promise<Catalog> {
128
+ return catalogue(map, await read($, facts))
129
+ }
130
+
131
+ async function stamps($: EngineInterface): Promise<Record<string, JudgmentKind>> {
132
+ const now = await $.clock.now()
133
+ const out: Record<string, JudgmentKind> = {}
134
+ for (const v of (await read($, verdicts)).filter(v => now - v.at < VERDICT_SHOWN_MS).reverse()) out[v.id] = v.kind
135
+ return out
136
+ }
137
+
138
+ async function libraryOptions($: EngineInterface, map: MapGraph): Promise<LibraryOptions> {
139
+ return {
140
+ room: motion.room,
141
+ frame: motion.graphAt === map.at ? motion.frame : FULL,
142
+ focus: motion.focus,
143
+ highlight: motion.highlight,
144
+ isPulseOn: motion.isPulseOn,
145
+ marks: motion.marks,
146
+ isMarkOn: motion.isMarkOn,
147
+ stamps: await stamps($),
148
+ }
97
149
  }
98
150
 
99
- function rasterOptions(map: MapGraph): RasterOptions {
151
+ function graphOptions(map: MapGraph): RasterOptions {
100
152
  return {
101
153
  frame: motion.graphAt === map.at ? motion.frame : FULL,
102
- bg: motion.bg,
154
+ bg: motion.room === 'reading' ? LIGHT_BG : DARK_BG,
103
155
  highlight: motion.highlight,
104
156
  isPulseOn: motion.isPulseOn,
105
157
  marks: motion.marks,
@@ -107,6 +159,18 @@ function rasterOptions(map: MapGraph): RasterOptions {
107
159
  }
108
160
  }
109
161
 
162
+ /** The pane's cells for the current view. */
163
+ async function paneCells($: EngineInterface, map: MapGraph, columns: number, rows: number): Promise<string> {
164
+ if (pane.view === 'graph') return rasterizeGraph(layoutRadial(map, crossEdges(map, await read($, facts))), columns, rows, graphOptions(map))
165
+ return rasterizeLibrary(await catalogOf($, map), columns, rows, await libraryOptions($, map))
166
+ }
167
+
168
+ /** Whether the current view shows this node (a book holding it, or a node on the graph). */
169
+ async function isOnView($: EngineInterface, map: MapGraph, id: string): Promise<boolean> {
170
+ if (pane.view === 'graph') return map.nodes.some(n => !n.isSeed && n.id === id)
171
+ return !!bookHolding(await catalogOf($, map), id)
172
+ }
173
+
110
174
  async function animate($: EngineInterface): Promise<void> {
111
175
  const now = await $.clock.now()
112
176
  const t = now - motion.start
@@ -119,9 +183,10 @@ async function animate($: EngineInterface): Promise<void> {
119
183
  if (isMarking) motion.isMarkOn = Math.floor((now - (motion.markUntil - MARK_MS)) / PULSE_MS) % 2 === 0
120
184
  else motion.marks = {}
121
185
  const map = await read($, graph)
122
- if (motion.raster && map.nodes.length > 0) {
186
+ if (motion.raster && map.nodes.length > 1) {
123
187
  const { columns, rows } = motion.raster
124
- await $.ui.blit({ requestId: PANE, key: 'map', columns, rows, cells: rasterize(map, columns, rows, rasterOptions(map)) }).catch(() => undefined)
188
+ const cells = await paneCells($, map, columns, rows)
189
+ await $.ui.blit({ requestId: PANE, key: 'map', columns, rows, cells }).catch(() => undefined)
125
190
  }
126
191
  if (!isRevealing && !motion.highlight && !isMarking) {
127
192
  motion.timer?.cancel()
@@ -133,10 +198,10 @@ function startMotion($: EngineInterface): void {
133
198
  motion.timer ??= $.clock.every(FRAME_MS, () => void animate($))
134
199
  }
135
200
 
136
- /** Lights the prior the agent is about to burst off, if the map shows it. */
201
+ /** Flashes the book the agent is about to burst off, if the shelves hold it. */
137
202
  async function highlightSeed($: EngineInterface, id: string): Promise<boolean> {
138
203
  const map = await read($, graph)
139
- if (!view.isShown || !map.nodes.some(n => !n.isSeed && n.id === id)) return false
204
+ if (!view.isShown || !(await isOnView($, map, id))) return false
140
205
  motion.highlight = id
141
206
  motion.highlightStart = await $.clock.now()
142
207
  motion.isPulseOn = true
@@ -144,7 +209,7 @@ async function highlightSeed($: EngineInterface, id: string): Promise<boolean> {
144
209
  return true
145
210
  }
146
211
 
147
- /** Holds the highlight at least HOLD_MS, so the target reads before the map moves on. */
212
+ /** Holds the highlight at least HOLD_MS, so the target reads before the shelves move on. */
148
213
  async function holdHighlight($: EngineInterface): Promise<void> {
149
214
  if (!motion.highlight) return
150
215
  const left = HOLD_MS - ((await $.clock.now()) - motion.highlightStart)
@@ -153,23 +218,33 @@ async function holdHighlight($: EngineInterface): Promise<void> {
153
218
 
154
219
  /**
155
220
  * The agent's reply judged some priors (cite / confirm / fix / refute tags):
156
- * record each, and pulse the ones on the map in their verdict's colour. Refs
157
- * that resolve to no node — `[id]` in prose ABOUT the protocol — are ignored.
221
+ * record each, flash the stamp of the ones on the shelves, and turn the card
222
+ * to the first book judged. Refs that resolve to no node — `[id]` in prose
223
+ * ABOUT the protocol — are ignored.
158
224
  */
159
225
  async function recordJudgments($: EngineInterface, text: string): Promise<void> {
160
226
  const judgments = parseJudgments(text)
161
227
  if (judgments.length === 0) return
162
228
  const handles = await primingHandles($)
163
229
  const map = await read($, graph)
230
+ const catalog = await catalogOf($, map)
164
231
  const now = await $.clock.now()
165
232
  const fresh: Verdict[] = []
233
+ let judged: string | undefined
166
234
  for (const j of judgments) {
167
235
  const id = resolveJudgmentRef(j.ref, handles)
168
236
  if (!id) continue
169
- const onMap = map.nodes.find(n => n.id === id)
170
- fresh.push({ id, kind: j.kind, name: onMap ? displayName(onMap.label, onMap.name) : j.ref, at: now })
171
- if (onMap) motion.marks[id] = j.kind
237
+ // A part the graph completed (not in the burst) is on the shelves too: judge it there.
238
+ const book = bookHolding(catalog, id)
239
+ const part = book && [book.cause, book.problem, book.fix].find(p => p?.id === id)
240
+ const node = map.nodes.find(n => n.id === id)
241
+ fresh.push({ id, kind: j.kind, name: part?.name ?? (node ? displayName(node.label, node.name) : j.ref), at: now })
242
+ if (book || (pane.view === 'graph' && (await isOnView($, map, id)))) {
243
+ motion.marks[id] = j.kind
244
+ judged ??= id
245
+ }
172
246
  }
247
+ if (judged) motion.focus = judged
173
248
  if (fresh.length === 0) return
174
249
  await update($, verdicts, list => [...fresh, ...list.filter(v => !fresh.some(f => f.id === v.id))].slice(0, VERDICTS_KEPT))
175
250
  if (Object.keys(motion.marks).length > 0 && view.isShown) {
@@ -179,9 +254,10 @@ async function recordJudgments($: EngineInterface, text: string): Promise<void>
179
254
  }
180
255
  }
181
256
 
182
- async function themeBackground($: EngineInterface): Promise<number> {
257
+ /** A light terminal theme reads in the reading room; a dark one in the stacks. */
258
+ async function themeRoom($: EngineInterface): Promise<Room> {
183
259
  const row = (await $.config.list().catch(() => [])).find(r => r.key === 'theme')
184
- return String(row?.value ?? '').includes('light') ? LIGHT_BG : DARK_BG
260
+ return String(row?.value ?? '').includes('light') ? 'reading' : 'stacks'
185
261
  }
186
262
 
187
263
  async function tick($: EngineInterface): Promise<void> {
@@ -207,15 +283,12 @@ async function syncLayout($: EngineInterface): Promise<void> {
207
283
  }
208
284
  }
209
285
 
210
- /**
211
- * Draws a burst the moment it lands and starts its reveal. Enrichment waits:
212
- * the first cut walked up to 12 neighbor calls before drawing anything, so an
213
- * MCP burst showed up seconds after the agent had already read it.
214
- */
286
+ /** Shelves a burst the moment it lands and starts its reveal; the seed's name and the chains' facts are looked up on later ticks. */
215
287
  async function draw($: EngineInterface, b: Burst): Promise<void> {
216
288
  const laid = layoutBurst(b)
217
- // Set the reveal up before the graph lands, so its first render is frame 0, not a flash of the finished map.
289
+ // Set the reveal up before the graph lands, so its first render is frame 0, not a flash of the full shelves.
218
290
  motion.highlight = undefined
291
+ motion.focus = undefined
219
292
  if (view.isShown) {
220
293
  motion.graphAt = laid.at
221
294
  motion.start = await $.clock.now()
@@ -226,7 +299,7 @@ async function draw($: EngineInterface, b: Burst): Promise<void> {
226
299
  pending.drawnAt = b.at
227
300
  }
228
301
 
229
- /** Catches up a burst not drawn yet, then fills in the real edges among what it delivered. */
302
+ /** Catches up a burst not drawn yet, names its seed (a burst names it by id), then catalogues its chains. */
230
303
  async function refresh($: EngineInterface): Promise<void> {
231
304
  if (pending.isRefreshing) return
232
305
  const current = await read($, burst)
@@ -234,21 +307,16 @@ async function refresh($: EngineInterface): Promise<void> {
234
307
  pending.isRefreshing = true
235
308
  try {
236
309
  if (current.at !== pending.drawnAt) await draw($, current)
237
- if (current.isEnriched) return
238
- // Background, after the draw: the seed's real name first (cheap, visible), then the cross edges.
239
- const named = (await resolveSeed($, current)) ?? current
240
- if (named !== current) {
310
+ if (!current.isEnriched) {
311
+ const named = { ...((await resolveSeed($, current)) ?? current), isEnriched: true }
312
+ // A newer burst landed while we asked: it is drawn already; drop this one.
241
313
  if ((await read($, burst))?.at !== current.at) return
242
314
  await update($, burst, () => named)
315
+ // Same `at`: the header renames in place, the reveal keeps running.
243
316
  await update($, graph, () => layoutBurst(named))
317
+ return
244
318
  }
245
- const enriched = { ...named, extra: await crossEdges($, named), isEnriched: true }
246
- // A newer burst landed while we walked: it is drawn already; drop this one.
247
- const latest = await read($, burst)
248
- if (latest && latest.at !== current.at) return
249
- await update($, burst, () => enriched)
250
- // Same `at`, same positions: the cross edges appear in place, the reveal keeps running.
251
- await update($, graph, () => layoutBurst(enriched))
319
+ await enrich($, current.at)
252
320
  } catch (err) {
253
321
  await update($, graph, g => ({ ...g, note: `minimap: ${String(err).slice(0, 80)}` }))
254
322
  } finally {
@@ -256,24 +324,35 @@ async function refresh($: EngineInterface): Promise<void> {
256
324
  }
257
325
  }
258
326
 
259
- async function crossEdges($: EngineInterface, b: Burst): Promise<[string, string][]> {
260
- const ids = new Set([b.seed.id, ...b.nodes.map(n => n.id)])
261
- const answers = await Promise.all(
262
- b.nodes.slice(0, ENRICH_MAX).map(n =>
263
- $.mcp.call(ERRATA, 'errata_neighbors', { nodeId: n.id, limit: 30 }).then(
264
- res => [n.id, res] as const,
265
- () => [n.id, undefined] as const,
266
- ),
267
- ),
268
- )
269
- const out: [string, string][] = []
270
- for (const [id, res] of answers) {
271
- if (!res || res.isError) continue
272
- for (const hop of neighborIds(textsOf(res.content))) {
273
- if (ids.has(hop.id) && hop.id !== id) out.push([id, hop.id])
274
- }
327
+ /**
328
+ * Completes and places a few more chains: one `errata_neighbors` call per
329
+ * chain, strongest first, at most ENRICH_PER_BURST for one burst. A lone fix's
330
+ * answer names its problem, which becomes a target of its own next time round
331
+ * (the problem is where the chain's package lives).
332
+ */
333
+ async function enrich($: EngineInterface, at: number): Promise<void> {
334
+ if (pending.cataloguedAt === at) return
335
+ const map = await read($, graph)
336
+ const known = await read($, facts)
337
+ const catalog = catalogue(map, known)
338
+ if (pending.askedFor !== at) Object.assign(pending, { askedFor: at, asked: 0 })
339
+ const budget = Math.min(ENRICH_PER_TICK, ENRICH_PER_BURST - pending.asked)
340
+ const targets = budget > 0 ? enrichTargets(catalog, known, budget) : []
341
+ if (targets.length === 0) {
342
+ pending.cataloguedAt = at
343
+ return
344
+ }
345
+ const heads = new Set(catalog.books.map(b => b.problem?.id ?? b.key))
346
+ for (const id of targets) {
347
+ pending.asked++
348
+ const res = await $.mcp.call(ERRATA, 'errata_neighbors', { nodeId: id, limit: 40 }).catch(() => undefined)
349
+ const answer = res && !res.isError ? parseNeighbors(textsOf(res.content)) : undefined
350
+ if ((await read($, burst))?.at !== at) return
351
+ await update($, facts, cache => {
352
+ const next = { ...cache, [id]: answer ?? null }
353
+ return Object.keys(next).length <= FACTS_KEPT ? next : Object.fromEntries(Object.entries(next).filter(([k]) => heads.has(k)))
354
+ })
275
355
  }
276
- return out
277
356
  }
278
357
 
279
358
  /** A burst names its seed by id (`file_fb8c…`): ask the graph what that node is. */
@@ -284,7 +363,7 @@ async function resolveSeed($: EngineInterface, b: Burst | null): Promise<Burst |
284
363
  return node ? { ...b, seed: { id: b.seed.id, label: node.label, name: node.name } } : b
285
364
  }
286
365
 
287
- /** Sets the burst the map shows and draws it now; the next tick enriches it. */
366
+ /** Sets the burst the shelves show and draws it now; the next tick names its seed. */
288
367
  async function show($: EngineInterface, next: Burst | null): Promise<void> {
289
368
  if (!next) return
290
369
  await update($, burst, () => next)
@@ -297,18 +376,32 @@ async function primingHandles($: EngineInterface): Promise<HandleTable> {
297
376
  return JSON.parse(String(text)) as HandleTable
298
377
  }
299
378
 
379
+ function isTagMode(value: unknown): value is TagMode {
380
+ return TAG_MODES.includes(value as TagMode)
381
+ }
382
+
383
+ function isPaneView(value: unknown): value is PaneView {
384
+ return PANE_VIEWS.includes(value as PaneView)
385
+ }
386
+
300
387
  export const register: Register = on => {
301
388
  on('session.start', async ($, e, next) => {
302
- await $.command.register({ name: 'minimap', description: 'Open the errata minimap: the latest burst neighborhood that entered context' })
389
+ await $.command.register({ name: 'minimap', description: 'Open the errata library: the causal chains of the latest burst, in bookcases by technology' })
390
+ await $.command.register({ name: 'errata-tags', description: 'How the chat draws errata inline tags: margin (marks), quiet (hidden) or raw' })
391
+ await $.command.register({ name: 'errata-view', description: 'Switch the errata pane between the library (books) and the graph' })
303
392
  void $.ui.open({ id: PANE, title: 'errata', rows: INLINE_ROWS })
304
- motion.bg = await themeBackground($)
393
+ motion.room = await themeRoom($)
394
+ const saved = await $.store.get(TAG_MODE_KEY).catch(() => undefined)
395
+ if (isTagMode(saved)) tags.mode = saved
396
+ const savedView = await $.store.get(PANE_VIEW_KEY).catch(() => undefined)
397
+ if (isPaneView(savedView)) pane.view = savedView
305
398
  $.clock.every(1500, () => void tick($))
306
399
  return next(e)
307
400
  })
308
401
 
309
402
  on('config.set', { key: 'theme' }, async ($, e, next) => {
310
403
  const done = await next(e)
311
- motion.bg = await themeBackground($)
404
+ motion.room = await themeRoom($)
312
405
  return done
313
406
  })
314
407
 
@@ -316,28 +409,56 @@ export const register: Register = on => {
316
409
  const opened = await $.ui.open({ id: PANE, title: 'errata', columns: 56, rows: INLINE_ROWS })
317
410
  pending.drawnAt = -1
318
411
  void refresh($)
319
- return { text: opened.isPlaced ? 'errata minimap opened.' : `minimap waits: ${opened.reason}` }
412
+ return { text: opened.isPlaced ? 'errata library opened.' : `the library waits: ${opened.reason}` }
413
+ })
414
+
415
+ // `/errata-tags` cycles margin → quiet → raw; `/errata-tags quiet` sets one. Display only: capture reads the stored reply.
416
+ on('command.run', { command: 'errata-tags' }, async ($, e) => {
417
+ const asked = e.args.trim().toLowerCase()
418
+ const nextMode = isTagMode(asked) ? asked : TAG_MODES[(TAG_MODES.indexOf(tags.mode) + 1) % TAG_MODES.length]!
419
+ tags.mode = nextMode
420
+ await $.store.set(TAG_MODE_KEY, nextMode)
421
+ $.ui.invalidate('ui.render')
422
+ const said = { margin: 'tags drawn as margin marks', quiet: 'tags hidden', raw: 'tags drawn as written' }[nextMode]
423
+ return { text: `errata: ${said} (${nextMode}). Capture is unchanged; it reads the stored reply.` }
424
+ })
425
+
426
+ // `/errata-view` flips books ↔ graph; `/errata-view graph` sets one. The burst stays; only the drawing changes.
427
+ on('command.run', { command: 'errata-view' }, async ($, e) => {
428
+ const asked = e.args.trim().toLowerCase()
429
+ const nextView = isPaneView(asked) ? asked : pane.view === 'books' ? 'graph' : 'books'
430
+ pane.view = nextView
431
+ motion.marks = {}
432
+ motion.focus = undefined
433
+ await $.store.set(PANE_VIEW_KEY, nextView)
434
+ $.ui.invalidate('ui.render')
435
+ return { text: nextView === 'books' ? 'errata pane: the library (books).' : 'errata pane: the graph.' }
436
+ })
437
+
438
+ // The agent's inline tags, tidied in the chat. The stored reply keeps them: the daemon harvests from the transcript.
439
+ on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
440
+ if (tags.mode === 'raw' || !hasTags(e.props.text)) return next(e)
441
+ return next({ ...e, props: { ...e.props, text: tidyTags(e.props.text, tags.mode) } })
320
442
  })
321
443
 
322
444
  // The agent bursts through MCP: the result IS the neighborhood that entered its context.
323
445
  on('tool.call', { tool: 'mcp__errata__errata_burst' }, async ($, e, next) => {
324
446
  const input = e as unknown as Record<string, unknown>
325
447
  const seed = String(input['nodeId'] ?? input['qname'] ?? '')
326
- // Bursting off a prior the map already shows: light it while the burst runs.
448
+ // Bursting off a prior the shelves already hold: pull it out while the burst runs.
327
449
  await highlightSeed($, seed)
328
450
  const ran = await next(e)
329
451
  if (ran.deny !== undefined || ran.isError) {
330
452
  motion.highlight = undefined
331
453
  return ran
332
454
  }
333
- // No MCP call before the draw: the seed's name is looked up on the next tick (refresh).
334
455
  const fresh = parseAgentBurst(textsOf([ran.text, ran.result]), seed)
335
456
  await holdHighlight($)
336
457
  await show($, fresh)
337
458
  return ran
338
459
  })
339
460
 
340
- // A search is a neighborhood too: the hits that entered context, ringed around the query.
461
+ // A search is a neighborhood too: the hits that entered context, catalogued under the query.
341
462
  on('tool.call', { tool: 'mcp__errata__errata_search' }, async ($, e, next) => {
342
463
  const ran = await next(e)
343
464
  if (ran.deny !== undefined || ran.isError) return ran
@@ -400,85 +521,161 @@ export const register: Register = on => {
400
521
  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
401
522
  const { Box, Text } = $.ui.resolve(e)
402
523
  const width = Math.max(24, e.props.bodyColumns)
403
- // Docked, the map takes the screen's height; inline it sits above the prompt and keeps to its row budget.
524
+ // Docked, the library takes the screen's height; inline it sits above the prompt and keeps to its row budget.
404
525
  const screenRows = e.viewport?.rows ?? 30
405
- const height = e.props.placement === 'inline' ? Math.max(14, Math.min(INLINE_ROWS, screenRows - 10)) : Math.max(14, screenRows - 4)
526
+ const height = e.props.placement === 'inline' ? Math.max(12, Math.min(INLINE_ROWS, screenRows - 10)) : Math.max(12, screenRows - 4)
406
527
  const map = await read($, graph)
528
+ const hex = (color: number) => `#${color.toString(16).padStart(6, '0')}`
529
+
530
+ if (pane.view === 'graph') {
531
+ const delivered = map.nodes.filter(n => !n.isSeed).sort((a, b) => b.score - a.score)
532
+ const counts = new Map<string, number>()
533
+ for (const n of delivered) counts.set(n.label, (counts.get(n.label) ?? 0) + 1)
534
+ const legend = [...counts].sort((a, b) => b[1] - a[1])
535
+ const legendRows = Math.ceil(legend.reduce((w, [l, c]) => w + l.length + String(c).length + 6, 0) / width)
536
+ const tiers = SCOPE_ORDER.filter(s => delivered.some(n => n.scope === s))
537
+ const now = await $.clock.now()
538
+ const recent = (await read($, verdicts)).filter(v => now - v.at < VERDICT_SHOWN_MS).slice(0, 3)
539
+ const topItems = Math.min(3, delivered.length)
540
+ const mapRows = Math.max(5, height - legendRows - (tiers.length > 0 ? 1 : 0) - topItems - recent.length - 2)
541
+ // Blits must match the mounted size: remember what this pass mounts.
542
+ motion.raster = { columns: width, rows: mapRows }
543
+ const cells = await paneCells($, map, width, mapRows)
544
+ return (
545
+ <Box flexDirection="column" width={width}>
546
+ <Text bold wrap="truncate-end">
547
+ {map.origin ? ORIGIN_LABEL[map.origin] : 'burst'}
548
+ <Text dimColor> · {delivered.length} in context · </Text>
549
+ <Text color="#ffd75f">◉ </Text>
550
+ <Text>{shortName(map.title, Math.max(8, width - 30))}</Text>
551
+ <Text dimColor> {map.seedLabel ?? ''}</Text>
552
+ </Text>
553
+ {delivered.length > 0 && e.surface === 'terminal' ? (
554
+ (() => {
555
+ const { Raster } = $.ui.resolve(e)
556
+ return <Raster key="map" columns={width} rows={mapRows} cells={cells} />
557
+ })()
558
+ ) : (
559
+ <Box height={mapRows}>
560
+ <Text dimColor>{map.note ?? (delivered.length === 0 ? 'no burst yet — touch a file or burst a seed' : 'the graph draws in the terminal')}</Text>
561
+ </Box>
562
+ )}
563
+ {tiers.length > 0 && (
564
+ <Text wrap="truncate-end">
565
+ {tiers.map(s => (
566
+ <Text>
567
+ <Text color={hex(SCOPE_COLOR[s])}>━━</Text>
568
+ <Text dimColor> {s} </Text>
569
+ </Text>
570
+ ))}
571
+ </Text>
572
+ )}
573
+ {legend.length > 0 && (
574
+ <Text wrap="wrap">
575
+ {legend.map(([label, count]) => (
576
+ <Text>
577
+ <Text color={hex(colorOf(label))}>{String.fromCodePoint(glyphOf(label))}</Text>
578
+ <Text dimColor> {count} {label} </Text>
579
+ </Text>
580
+ ))}
581
+ </Text>
582
+ )}
583
+ {recent.map(v => (
584
+ <Text wrap="truncate-end">
585
+ <Text color={hex(VERDICT_COLOR[v.kind])} bold>
586
+ {JUDGMENT_STYLE[v.kind].glyph} {JUDGMENT_STYLE[v.kind].verb}{' '}
587
+ </Text>
588
+ <Text>{v.name}</Text>
589
+ </Text>
590
+ ))}
591
+ {delivered.slice(0, topItems).map(n => (
592
+ <Text wrap="truncate-end">
593
+ <Text color={hex(colorOf(n.label))}>{String.fromCodePoint(glyphOf(n.label))} </Text>
594
+ <Text dimColor>{displayName(n.label, n.name)}</Text>
595
+ </Text>
596
+ ))}
597
+ </Box>
598
+ )
599
+ }
600
+
601
+ const catalog = await catalogOf($, map)
602
+ const palette = PALETTE[motion.room]
407
603
 
408
- const delivered = map.nodes.filter(n => !n.isSeed).sort((a, b) => b.score - a.score)
409
- const counts = new Map<string, number>()
410
- for (const n of delivered) counts.set(n.label, (counts.get(n.label) ?? 0) + 1)
411
- const legend = [...counts].sort((a, b) => b[1] - a[1])
412
- const legendRows = Math.ceil(legend.reduce((w, [l, c]) => w + l.length + String(c).length + 6, 0) / width)
413
- const tiers = SCOPE_ORDER.filter(s => delivered.some(n => n.scope === s))
604
+ const books = catalog.books
605
+ const bound = new Set(books.flatMap(b => b.memberIds))
606
+ const others = map.nodes.filter(n => !n.isSeed && !bound.has(n.id)).length
607
+ // The spines carry no words: a one-line key says what their bands mean.
608
+ const kinds = new Set<Binding>()
609
+ for (const b of books) for (const part of [b.cause, b.problem, b.fix]) if (part) kinds.add(bindingOf(part.label))
610
+ const legend = (['cause', 'problem', 'claim', 'fix'] as Binding[]).filter(b => kinds.has(b))
414
611
  const now = await $.clock.now()
415
- const recent = (await read($, verdicts)).filter(v => now - v.at < VERDICT_SHOWN_MS).slice(0, 3)
416
- const footRows =
417
- legendRows + (tiers.length > 0 ? 1 : 0) + Math.min(TOP_ITEMS, delivered.length) + recent.length
418
- const mapRows = Math.max(5, height - footRows - 2)
612
+ // A verdict on a prior not on the shelves has no plank to be stamped on: it gets a line.
613
+ const unshelved = (await read($, verdicts)).filter(v => now - v.at < VERDICT_SHOWN_MS && !bookHolding(catalog, v.id)).slice(0, 2)
614
+ const libraryRows = Math.max(8, height - 1 - (legend.length > 0 ? 1 : 0) - unshelved.length)
419
615
  // Blits must match the mounted size: remember what this pass mounts.
420
- motion.raster = { columns: width, rows: mapRows }
421
-
422
- const minimap =
423
- e.surface === 'terminal' && map.nodes.length > 0
424
- ? (() => {
425
- const { Raster } = $.ui.resolve(e)
426
- return <Raster key="map" columns={width} rows={mapRows} cells={rasterize(map, width, mapRows, rasterOptions(map))} />
427
- })()
428
- : (
429
- <Box height={mapRows}>
430
- <Text dimColor>
431
- {map.note ?? (map.nodes.length === 0 ? 'no burst yet — touch a file or burst a seed' : 'map draws in the terminal')}
616
+ motion.raster = { columns: width, rows: libraryRows }
617
+ const options = await libraryOptions($, map)
618
+ const focus = focusBook(catalog, options.focus)
619
+ const empty = others > 0 ? `no causal chains in this burst — ${others} other ${others === 1 ? 'hit' : 'hits'}` : 'the shelves are empty — touch a file or burst a seed'
620
+
621
+ const library =
622
+ books.length === 0 ? (
623
+ <Box height={libraryRows}>
624
+ <Text dimColor>{map.note ?? empty}</Text>
625
+ </Box>
626
+ ) : e.surface === 'terminal' ? (
627
+ (() => {
628
+ const { Raster } = $.ui.resolve(e)
629
+ return <Raster key="map" columns={width} rows={libraryRows} cells={rasterizeLibrary(catalog, width, libraryRows, options)} />
630
+ })()
631
+ ) : (
632
+ // No cell grid off the terminal: the catalog as a reading list, the book in focus first.
633
+ <Box flexDirection="column" height={libraryRows}>
634
+ {[...(focus ? [focus] : []), ...books.filter(b => b !== focus)].slice(0, libraryRows).map(b => {
635
+ const head = (b.problem ?? b.fix ?? b.cause)!
636
+ return (
637
+ <Text wrap="truncate-end">
638
+ <Text color={hex(palette.binding[bindingOf(head.label)])}>{BINDING_GLYPH[bindingOf(head.label)]} </Text>
639
+ <Text dimColor>{callNumber(catalog, b)} </Text>
640
+ <Text>{fitTitle(head.name, width - 12)}</Text>
432
641
  </Text>
433
- </Box>
434
- )
642
+ )
643
+ })}
644
+ </Box>
645
+ )
435
646
 
436
647
  return (
437
648
  <Box flexDirection="column" width={width}>
438
649
  <Text bold wrap="truncate-end">
439
- {map.origin ? ORIGIN_LABEL[map.origin] : 'burst'}
440
- <Text dimColor> · {delivered.length} in context · </Text>
441
- <Text color="#ffd75f">◉ </Text>
442
- <Text>{shortName(map.title, Math.max(8, width - 30))}</Text>
443
- <Text dimColor> {map.seedLabel ?? ''}</Text>
444
- </Text>
445
- {minimap}
446
- {tiers.length > 0 && (
447
- <Text wrap="truncate-end">
448
- {tiers.map(s => (
449
- <Text>
450
- <Text color={hex(SCOPE_COLOR[s])}>━━</Text>
451
- <Text dimColor> {s} </Text>
452
- </Text>
453
- ))}
650
+ <Text color={hex(palette.gilt)}>◉ </Text>
651
+ <Text>{shortName(map.title || 'errata library', Math.max(8, width - 44))}</Text>
652
+ <Text dimColor>
653
+ {' '}
654
+ {map.seedLabel ?? ''} · {map.origin ? ORIGIN_LABEL[map.origin] : 'burst'} · {books.length} {books.length === 1 ? 'chain' : 'chains'} ·{' '}
655
+ {catalog.cases.length} {catalog.cases.length === 1 ? 'case' : 'cases'}
454
656
  </Text>
455
- )}
657
+ </Text>
658
+ {library}
456
659
  {legend.length > 0 && (
457
- <Text wrap="wrap">
458
- {legend.map(([label, count]) => (
660
+ <Text wrap="truncate-end">
661
+ {legend.map(b => (
459
662
  <Text>
460
- <Text color={hex(colorOf(label))}>{String.fromCodePoint(glyphOf(label))}</Text>
461
- <Text dimColor> {count} {label} </Text>
663
+ <Text color={hex(palette.binding[b])}>█</Text>
664
+ <Text dimColor> {BINDING_NAME[b]} </Text>
462
665
  </Text>
463
666
  ))}
667
+ <Text dimColor>· spines read top to bottom · the card reads the marked book</Text>
464
668
  </Text>
465
669
  )}
466
- {recent.map(v => (
670
+ {unshelved.map(v => (
467
671
  <Text wrap="truncate-end">
468
- <Text color={hex(JUDGMENT_STYLE[v.kind].color)} bold>
672
+ <Text color={hex(palette.verdict[v.kind])} bold>
469
673
  {JUDGMENT_STYLE[v.kind].glyph} {JUDGMENT_STYLE[v.kind].verb}{' '}
470
674
  </Text>
471
- <Text>{v.name}</Text>
472
- </Text>
473
- ))}
474
- {delivered.slice(0, TOP_ITEMS).map(n => (
475
- <Text wrap="truncate-end">
476
- <Text color={hex(colorOf(n.label))}>{String.fromCodePoint(glyphOf(n.label))} </Text>
477
- <Text dimColor>{displayName(n.label, n.name)}</Text>
675
+ <Text>{shortName(v.name, width - 14)}</Text>
478
676
  </Text>
479
677
  ))}
480
678
  </Box>
481
679
  )
482
680
  })
483
681
  }
484
-