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

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.
package/errata.mjs CHANGED
@@ -45013,6 +45013,10 @@ function edgeTier(store, farId) {
45013
45013
  function byTier(store, items, farId) {
45014
45014
  return items.map((item2, i2) => ({ item: item2, i: i2, tier: edgeTier(store, farId(item2)) })).sort((a, b) => a.tier - b.tier || a.i - b.i).map((x) => x.item);
45015
45015
  }
45016
+ function localCommunityOf(node2) {
45017
+ const cid = node2.attrs["localCommunity"];
45018
+ return typeof cid === "string" ? cid : null;
45019
+ }
45016
45020
  function searchGraph(store, query, limit) {
45017
45021
  const q = query.trim();
45018
45022
  if (!q || !Number.isFinite(limit) || limit < 1) return [];
@@ -45550,7 +45554,7 @@ var init_mcp = __esm({
45550
45554
  },
45551
45555
  {
45552
45556
  name: "errata.neighbors",
45553
- description: "Return direct in/out edges of a node. Pass either a nodeId or a qname (e.g. ClassName.method) \u2014 resolved like the other nav verbs. Use after errata.search or errata.locate to walk the graph. A cloud id from search's collective blend (e.g. dprob_\u2026) that isn't in the local graph falls back to its collective neighborhood, tagged provenance:collective.",
45557
+ description: "Return direct in/out edges of a node. Pass either a nodeId or a qname (e.g. ClassName.method) \u2014 resolved like the other nav verbs. Use after errata.search or errata.locate to walk the graph. `node.community` (and each placed far end's `community`) is the local partition's community id, null when unplaced. A cloud id from search's collective blend (e.g. dprob_\u2026) that isn't in the local graph falls back to its collective neighborhood, tagged provenance:collective.",
45554
45558
  inputSchema: {
45555
45559
  type: "object",
45556
45560
  properties: {
@@ -45588,12 +45592,16 @@ var init_mcp = __esm({
45588
45592
  const p = e.attrs?.provenance;
45589
45593
  return p ? { provenance: p } : {};
45590
45594
  };
45595
+ const far = (n) => {
45596
+ const community = localCommunityOf(n);
45597
+ return { id: n.id, label: n.label, name: n.description, ...community ? { community } : {} };
45598
+ };
45591
45599
  const out2 = byTier(store, store.outEdges(id), (e) => e.to).slice(0, limit).map((e) => {
45592
45600
  const t = store.getNode(e.to);
45593
45601
  return {
45594
45602
  type: e.type,
45595
45603
  ...prov(e),
45596
- to: t ? { id: t.id, label: t.label, name: t.description } : { id: e.to }
45604
+ to: t ? far(t) : { id: e.to }
45597
45605
  };
45598
45606
  });
45599
45607
  const incoming = byTier(store, store.inEdges(id), (e) => e.from).slice(0, limit).map((e) => {
@@ -45601,12 +45609,12 @@ var init_mcp = __esm({
45601
45609
  return {
45602
45610
  type: e.type,
45603
45611
  ...prov(e),
45604
- from: f ? { id: f.id, label: f.label, name: f.description } : { id: e.from }
45612
+ from: f ? far(f) : { id: e.from }
45605
45613
  };
45606
45614
  });
45607
45615
  return {
45608
45616
  found: true,
45609
- node: { id: node2.id, label: node2.label, name: node2.description },
45617
+ node: { id: node2.id, label: node2.label, name: node2.description, community: localCommunityOf(node2) },
45610
45618
  out: out2,
45611
45619
  in: incoming
45612
45620
  };
@@ -83934,7 +83942,7 @@ function createWatchBreaker(deps) {
83934
83942
  }
83935
83943
 
83936
83944
  // src/engine.ts
83937
- var DAEMON_VERSION = true ? "2.0.21-dev.3993" : "2.0.0-alpha.0";
83945
+ var DAEMON_VERSION = true ? "2.0.21-dev.3996" : "2.0.0-alpha.0";
83938
83946
  var IGNORED_PATH = /[\\/](?:\.git|node_modules|\.errata|\.claude|\.codex|\.cursor|\.turbo|\.next|dist|coverage|test-results|playwright-report|__pycache__)(?:[\\/]|$)/;
83939
83947
  var IGNORED_NOISE = /castalia\.db|eventlog\.sqlite|turn-cursors/;
83940
83948
  var GIT_OP_MUTE_MS = 4e3;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inerrata-corporation/errata",
3
- "version": "2.0.21-dev.3993",
3
+ "version": "2.0.21-dev.3996",
4
4
  "description": "errata - local-first observation engine for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "errata-minimap",
3
- "version": "0.1.0",
3
+ "version": "0.3.1",
4
4
  "author": { "name": "inErrata", "url": "https://inerrata.ai" },
5
- "description": "Side pane: an errata graph minimap of the latest burst neighborhood that entered the agent's context.",
5
+ "description": "The errata library: the causal chains of the latest burst that entered the agent's context, in bookcases by technology with a catalog card — and errata's inline tags, tidied in the chat.",
6
6
  "types": "./types/index.d.ts"
7
7
  }
@@ -0,0 +1,421 @@
1
+ import type { Book, Bookcase, Catalog, Part, Shelf } from './chains'
2
+ import { bookHolding, callNumber, placeOf } from './chains'
3
+ import type { JudgmentKind } from './minimap'
4
+
5
+ /**
6
+ * The burst as a library, after the splash's Arched Library (SPL-library):
7
+ * BOOKCASES by technology, SHELVES by community, and every BOOK one causal
8
+ * chain standing upright — its spine banded top to bottom in the chain's own
9
+ * order, cause (gilt) over problem (oxblood) over fix (green). An upright
10
+ * spine cannot carry a title anyone could read, so the spines are a wordless
11
+ * glance — how many chains, how complete (a full chain stands tallest and
12
+ * thickest, a lone problem is a thin pamphlet), where they cluster — and the
13
+ * CATALOG CARD under the cases reads the book in focus in full.
14
+ *
15
+ * Two rooms, one token set, as on the site: the READING ROOM (parchment
16
+ * bindings, for a light terminal) and THE STACKS (the library after hours,
17
+ * for a dark one). Colours are lifted from apps/splash/src/network/network.css.
18
+ */
19
+ export type Room = 'reading' | 'stacks'
20
+
21
+ export type Binding = 'problem' | 'cause' | 'fix' | 'claim' | 'pattern' | 'code'
22
+
23
+ export type Palette = {
24
+ binding: Record<Binding, number>
25
+ /** Lettering on a wooden plate. */
26
+ clothInk: number
27
+ /** A book being burst off flashes gilt. */
28
+ gilt: number
29
+ /** The bookmark in the book the card reads: not gilt, which is the cause's own binding at night. */
30
+ ribbon: number
31
+ wood: number
32
+ /** The catalog card: its stock, its ink, and the ink of a part completed from the graph (not in context). */
33
+ card: number
34
+ cardInk: number
35
+ cardFaint: number
36
+ verdict: Record<JudgmentKind, number>
37
+ }
38
+
39
+ export const PALETTE: Record<Room, Palette> = {
40
+ reading: {
41
+ binding: { problem: 0x7a2e24, cause: 0x8a6a1f, fix: 0x2a4e3c, claim: 0x5c3a50, pattern: 0x3e5a73, code: 0x8a6e52 },
42
+ clothInk: 0xf7f2e8,
43
+ gilt: 0xc9a24a,
44
+ ribbon: 0x2b2620,
45
+ wood: 0x8a6e52,
46
+ card: 0xefe6d2,
47
+ cardInk: 0x2b2620,
48
+ cardFaint: 0x8c8170,
49
+ verdict: { cite: 0x3e5a73, confirm: 0x2a4e3c, fix: 0x2a4e3c, refute: 0x7a2e24 },
50
+ },
51
+ stacks: {
52
+ binding: { problem: 0xe08a74, cause: 0xe2c376, fix: 0xa7d0b0, claim: 0xcba3bf, pattern: 0x9dbcd8, code: 0xbba98c },
53
+ clothInk: 0xf7f2e8,
54
+ gilt: 0xe2c376,
55
+ ribbon: 0xf7f2e8,
56
+ wood: 0x6b543d,
57
+ card: 0x1f3a2d,
58
+ cardInk: 0xf7f2e8,
59
+ cardFaint: 0x93a89a,
60
+ verdict: { cite: 0x9dbcd8, confirm: 0xa7d0b0, fix: 0xa7d0b0, refute: 0xe08a74 },
61
+ },
62
+ }
63
+
64
+ const BINDING_OF: Record<string, Binding> = {
65
+ Problem: 'problem',
66
+ Vulnerability: 'problem',
67
+ AntiPattern: 'problem',
68
+ RootCause: 'cause',
69
+ Constraint: 'cause',
70
+ Solution: 'fix',
71
+ Technique: 'fix',
72
+ Claim: 'claim',
73
+ Pattern: 'pattern',
74
+ }
75
+
76
+ export function bindingOf(label: string): Binding {
77
+ return BINDING_OF[label] ?? 'code'
78
+ }
79
+
80
+ /** What the legend calls each binding. */
81
+ export const BINDING_NAME: Record<Binding, string> = {
82
+ problem: 'problem',
83
+ cause: 'cause',
84
+ fix: 'fix',
85
+ claim: 'claim',
86
+ pattern: 'pattern',
87
+ code: 'code',
88
+ }
89
+
90
+ /** The mark a part carries on the catalog card, and the status line's tally glyph. */
91
+ export const BINDING_GLYPH: Record<Binding, string> = {
92
+ problem: '◆',
93
+ cause: '▲',
94
+ fix: '●',
95
+ claim: '◇',
96
+ pattern: '✦',
97
+ code: '§',
98
+ }
99
+
100
+ export type LibraryFrame = { grow: number; type: number }
101
+
102
+ export type LibraryOptions = {
103
+ room?: Room
104
+ /** How far the reveal has run: books rise onto their shelves (`grow`), then the card inks in (`type`). */
105
+ frame?: LibraryFrame
106
+ /** The node whose book the card reads (a verdict's, the seed's); default: the seed's book, else the strongest. */
107
+ focus?: string
108
+ /** A node the agent is bursting off: its book flashes gilt. */
109
+ highlight?: string
110
+ isPulseOn?: boolean
111
+ /** Priors the agent just judged: their stamp flashes. */
112
+ marks?: Record<string, JudgmentKind>
113
+ isMarkOn?: boolean
114
+ /** Every recent verdict, stamped on the plank under its book like a date-due card. */
115
+ stamps?: Record<string, JudgmentKind>
116
+ }
117
+
118
+ /** Who gets the card's lines first when the chain will not fit: the problem, then its fix, then its cause. */
119
+ const PRIORITY = { problem: 0, fix: 1, cause: 2 } as const
120
+ const STAMP: Record<JudgmentKind, string> = { cite: '◎', confirm: '✓', fix: '✓', refute: '✗' }
121
+ const VERB: Record<JudgmentKind, string> = { cite: 'cited', confirm: 'confirmed', fix: 'resolved', refute: 'refuted' }
122
+ const DEFAULT = 0x01000000
123
+ const SPACE = 0x20
124
+
125
+ /** Book rows above each plank: a full chain stands 3.5 rows; the half-row above it holds the bookmark. */
126
+ const TALL_SHELF = 4
127
+ const SHORT_SHELF = 3
128
+ /** Cells a shelf keeps after its books for its plate. */
129
+ const PLATE = 15
130
+ /** Rows the catalog card never gives up: header, two lines of chain, footer. */
131
+ const MIN_CARD = 4
132
+ const MAX_CARD = 8
133
+
134
+ /** The focus book: an asked node's, else the seed's, else the strongest. */
135
+ export function focusBook(catalog: Catalog, focus?: string): Book | undefined {
136
+ return bookHolding(catalog, focus) ?? catalog.books.find(b => b.isSeed) ?? catalog.books[0]
137
+ }
138
+
139
+ /** Thin (one part) or thick (a chain of two or three). */
140
+ const widthOf = (b: Book) => (partsOf(b).length > 1 ? 2 : 1)
141
+
142
+ function partsOf(b: Book): [('cause' | 'problem' | 'fix'), Part][] {
143
+ return (['cause', 'problem', 'fix'] as const).flatMap(r => (b[r] ? [[r, b[r]] as [typeof r, Part]] : []))
144
+ }
145
+
146
+ type Placed = { bookcase: Bookcase; x: number; y: number; width: number; shelves: { shelf: Shelf; books: Book[]; hidden: number }[]; hidden: number }
147
+
148
+ /**
149
+ * Cases packed left to right, wrapping into another tier when the pane is
150
+ * narrow; each case as wide as its fullest shelf (books overflow into a `+n`
151
+ * on the plank), as tall as the rows allow (lower shelves give way first).
152
+ */
153
+ function placeCases(catalog: Catalog, columns: number, rows: number, zone: number): Placed[] {
154
+ const placed: Placed[] = []
155
+ let x = 0
156
+ let y = 0
157
+ let tierHeight = 0
158
+ for (const bookcase of catalog.cases) {
159
+ const fullest = Math.max(...bookcase.shelves.map(s => s.books.reduce((w, b) => w + widthOf(b), 0)))
160
+ // Room after the fullest shelf for its plate (" 1 miscellany "), and on the cornice for the tech.
161
+ const width = Math.min(columns, Math.max(bookcase.tech.length + 6, fullest + 4 + PLATE))
162
+ if (x > 0 && x + width > columns) {
163
+ x = 0
164
+ y += tierHeight
165
+ tierHeight = 0
166
+ }
167
+ const room = Math.floor((rows - y - 1) / (zone + 1))
168
+ if (room < 1) break
169
+ const kept = bookcase.shelves.slice(0, room)
170
+ const shelves = kept.map(shelf => {
171
+ const books: Book[] = []
172
+ let used = 0
173
+ for (const b of shelf.books) {
174
+ if (used + widthOf(b) > width - 4) break
175
+ books.push(b)
176
+ used += widthOf(b)
177
+ }
178
+ return { shelf, books, hidden: shelf.books.length - books.length }
179
+ })
180
+ const hidden = bookcase.shelves.slice(room).reduce((n, s) => n + s.books.length, 0)
181
+ placed.push({ bookcase, x, y, width, shelves, hidden })
182
+ tierHeight = Math.max(tierHeight, 1 + shelves.length * (zone + 1))
183
+ x += width + 1
184
+ }
185
+ return placed
186
+ }
187
+
188
+ /** Every book drawn, in reading order: the order the reveal raises them. */
189
+ function drawnBooks(placed: Placed[]): Book[] {
190
+ return placed.flatMap(p => p.shelves.flatMap(s => s.books))
191
+ }
192
+
193
+ /** Rows the cases take. */
194
+ function casesHeight(placed: Placed[], zone: number): number {
195
+ return placed.reduce((h, p) => Math.max(h, p.y + 1 + p.shelves.length * (zone + 1)), 0)
196
+ }
197
+
198
+ function shade(color: number, by: number): number {
199
+ const ch = (s: number) => Math.round(((color >> s) & 0xff) * (1 - by))
200
+ return (ch(16) << 16) | (ch(8) << 8) | ch(0)
201
+ }
202
+
203
+ /**
204
+ * The library as Raster cells (`[codePoint, fg, bg]` u32 triplets, row-major,
205
+ * base64). Every frame of the reveal draws a prefix of the finished picture,
206
+ * so nothing jumps as it fills: cases first, then each book rises onto its
207
+ * shelf in reading order, then the card inks in.
208
+ */
209
+ export function rasterizeLibrary(catalog: Catalog, columns: number, rows: number, options: LibraryOptions = {}): string {
210
+ const { room = 'stacks', frame = { grow: 1, type: 1 }, highlight, isPulseOn = true, marks = {}, isMarkOn = true, stamps = {} } = options
211
+ const p = PALETTE[room]
212
+ const words = new Uint32Array(columns * rows * 3)
213
+ for (let i = 0; i < columns * rows; i++) words.set([SPACE, DEFAULT, DEFAULT], i * 3)
214
+ const put = (x: number, y: number, ch: string | number, fg: number, bg = DEFAULT) => {
215
+ if (x >= 0 && y >= 0 && x < columns && y < rows) words.set([typeof ch === 'number' ? ch : ch.codePointAt(0)!, fg, bg], (y * columns + x) * 3)
216
+ }
217
+ const write = (x: number, y: number, text: string, fg: number, bg = DEFAULT) => {
218
+ let k = 0
219
+ for (const ch of text) put(x + k++, y, ch, fg, bg)
220
+ return x + k
221
+ }
222
+
223
+ const focus = focusBook(catalog, options.focus)
224
+ // The card asks for its chain in full (header, every wrapped line, footer); the cases get the rest, at least a third.
225
+ const need = focus ? 2 + partsOf(focus).reduce((n, [, part]) => n + wrap(part.name, columns - 5).length, 0) : 0
226
+ const card = Math.max(MIN_CARD, Math.min(need, Math.max(MAX_CARD, Math.round(rows * 0.6)), rows - Math.ceil(rows / 3)))
227
+ const zone = rows - card >= 1 + TALL_SHELF + 1 ? TALL_SHELF : SHORT_SHELF
228
+ const placed = placeCases(catalog, columns, rows - card, zone)
229
+ const order = drawnBooks(placed)
230
+ const pulled = bookHolding(catalog, highlight)
231
+ const scores = catalog.books.map(b => b.score).sort((a, b) => a - b)
232
+ const medianScore = scores[Math.floor(scores.length / 2)] ?? 0
233
+ const verdictOf = (b: Book) => {
234
+ for (const id of b.memberIds) if (marks[id]) return { kind: marks[id]!, isFresh: true }
235
+ for (const id of b.memberIds) if (stamps[id]) return { kind: stamps[id]!, isFresh: false }
236
+ return undefined
237
+ }
238
+
239
+ for (const c of placed) {
240
+ // The cornice, its plate naming the technology.
241
+ for (let x = c.x; x < c.x + c.width; x++) put(x, c.y, '▄', p.wood)
242
+ const plate = ` ${fit(c.bookcase.tech, c.width - 4)} `
243
+ write(c.x + 1, c.y, plate, p.clothInk, p.wood)
244
+ if (c.hidden > 0) write(c.x + c.width - String(c.hidden).length - 3, c.y, ` +${c.hidden}`, p.clothInk, p.wood)
245
+ c.shelves.forEach(({ shelf, books, hidden }, si) => {
246
+ const plank = c.y + 1 + si * (zone + 1) + zone
247
+ for (let y = plank - zone; y < plank; y++) {
248
+ put(c.x, y, '▕', p.wood)
249
+ put(c.x + c.width - 1, y, '▏', p.wood)
250
+ }
251
+ for (let x = c.x; x < c.x + c.width; x++) put(x, plank, '▀', p.wood)
252
+ let x = c.x + 2
253
+ for (const book of books) {
254
+ const shown = Math.max(0, Math.min(1, frame.grow * order.length - order.indexOf(book)))
255
+ drawBook(book, x, plank, shown)
256
+ const verdict = verdictOf(book)
257
+ if (verdict && shown >= 1) {
258
+ const color = p.verdict[verdict.kind]
259
+ const isFlashing = verdict.isFresh && isMarkOn
260
+ put(x, plank, STAMP[verdict.kind], isFlashing ? p.clothInk : color, isFlashing ? color : DEFAULT)
261
+ }
262
+ x += widthOf(book)
263
+ }
264
+ // The shelf's plate, after its books: its number and the name its community earns (chains.ts shelfName).
265
+ const tail = hidden > 0 ? ` +${hidden}` : ''
266
+ const room = c.x + c.width - 1 - (x + 1)
267
+ const label = ` ${shelf.number} ${fit(shelf.name, room - 4 - tail.length)}${tail} `
268
+ if (room >= 6) write(x + 1, plank, label.slice(0, room), p.clothInk, p.wood)
269
+ })
270
+ }
271
+
272
+ function drawBook(book: Book, x: number, plank: number, shown: number): void {
273
+ const parts = partsOf(book)
274
+ const full = 2 * zone - 1
275
+ // Height in half-rows: a full chain stands tallest, each missing part a half-row shorter; the weaker half of the shelf a half-row lower still.
276
+ const height = Math.max(3, full - (3 - parts.length) - (book.score < medianScore ? 1 : 0))
277
+ const risen = Math.round(height * shown)
278
+ const side = Math.max(1, Math.round(height / 4))
279
+ // Bands bottom → top: fix, problem, cause; the problem takes what the ends leave.
280
+ const bands: number[] = []
281
+ for (const [role, part] of [...parts].reverse()) {
282
+ const span = parts.length === 1 ? height : role === 'problem' ? height - side * (parts.length - 1) : side
283
+ const color = book === pulled && isPulseOn ? p.gilt : p.binding[bindingOf(part.label)]
284
+ for (let k = 0; k < span; k++) bands.push(color)
285
+ }
286
+ const width = widthOf(book)
287
+ for (let col = 0; col < width; col++) {
288
+ const colorAt = (h: number): number | undefined => {
289
+ if (h < risen) return col === 0 ? bands[h] : shade(bands[h]!, 0.38)
290
+ if (h === risen && book === focus && col === 0 && shown >= 1) return p.ribbon
291
+ return undefined
292
+ }
293
+ for (let r = 0; r < zone; r++) {
294
+ const lower = colorAt(2 * r)
295
+ const upper = colorAt(2 * r + 1)
296
+ const y = plank - 1 - r
297
+ if (width === 1) {
298
+ // A pamphlet: the left half of the cell, so neighbours never merge into one block.
299
+ if (lower !== undefined && upper !== undefined) put(x, y, upper === lower ? '▌' : '▀', upper, upper === lower ? DEFAULT : lower)
300
+ else if (lower !== undefined) put(x, y, '▖', lower)
301
+ else if (upper !== undefined) put(x, y, '▘', upper)
302
+ } else if (lower !== undefined && upper !== undefined) put(x + col, y, '▀', upper, lower)
303
+ else if (lower !== undefined) put(x + col, y, '▄', lower)
304
+ else if (upper !== undefined) put(x + col, y, '▀', upper)
305
+ }
306
+ }
307
+ }
308
+ if (focus) drawCard(focus, casesHeight(placed, zone))
309
+
310
+ function drawCard(book: Book, top: number): void {
311
+ const height = Math.min(rows - top, Math.max(card, need))
312
+ if (height < 3) return
313
+ for (let y = top; y < top + height; y++) for (let x = 0; x < columns; x++) put(x, y, SPACE, p.cardInk, p.card)
314
+ const inked = (text: string) => [...text].slice(0, Math.ceil([...text].length * frame.type)).join('')
315
+ const place = placeOf(catalog, book)
316
+ // Header: the call number in the catalog card's red, the case, the shelf.
317
+ const call = callNumber(catalog, book)
318
+ let x = write(1, top, inked(call), p.binding.problem, p.card)
319
+ x = write(x, top, inked(` ${place?.bookcase.tech ?? ''}`), p.cardInk, p.card)
320
+ const shelfText = place ? `shelf ${place.shelf.number} · ${place.shelf.name}` : ''
321
+ const room = columns - x - 3
322
+ if (room >= 10) {
323
+ const s = fit(shelfText, room)
324
+ write(columns - 1 - s.length, top, inked(s), p.cardFaint, p.card)
325
+ }
326
+ // Body: the chain in its order, each part wrapped; the problem gets spare lines first.
327
+ const parts = partsOf(book)
328
+ const body = height - 2
329
+ const textWidth = columns - 5
330
+ const wrapped = parts.map(([, part]) => wrap(part.name, textWidth))
331
+ const lines = parts.map(() => 0)
332
+ const byNeed = [...parts.keys()].sort((a, b) => PRIORITY[parts[a]![0]] - PRIORITY[parts[b]![0]])
333
+ let spare = body
334
+ while (spare > 0 && byNeed.some(i => lines[i]! < wrapped[i]!.length)) {
335
+ for (const i of byNeed) {
336
+ if (spare > 0 && lines[i]! < wrapped[i]!.length) {
337
+ lines[i]!++
338
+ spare--
339
+ }
340
+ }
341
+ }
342
+ let y = top + 1
343
+ parts.forEach(([, part], i) => {
344
+ const color = p.binding[bindingOf(part.label)]
345
+ const ink = part.isDelivered ? p.cardInk : p.cardFaint
346
+ const text = wrapped[i]!.slice(0, lines[i])
347
+ if (lines[i]! < wrapped[i]!.length && text.length > 0) text[text.length - 1] = clip(text[text.length - 1]!, textWidth)
348
+ text.forEach((line, k) => {
349
+ if (k === 0) put(2, y, BINDING_GLYPH[bindingOf(part.label)], color, p.card)
350
+ write(4, y, inked(line), ink, p.card)
351
+ y++
352
+ })
353
+ })
354
+ // Footer: where the chain is anchored, what else it holds, and the agent's verdict.
355
+ const footY = top + height - 1
356
+ const verdict = verdictOf(book)
357
+ const stamp = verdict ? ` ${STAMP[verdict.kind]} ${VERB[verdict.kind]} ` : ''
358
+ const more = [book.moreCauses > 0 ? `+${book.moreCauses} cause${book.moreCauses > 1 ? 's' : ''}` : '', book.moreFixes > 0 ? `+${book.moreFixes} fix${book.moreFixes > 1 ? 'es' : ''}` : '']
359
+ .filter(Boolean)
360
+ .join(' ')
361
+ const facts = [book.anchor ? `@ ${book.anchor}` : '', more, book.isCatalogued ? '' : 'cataloguing…'].filter(Boolean).join(' · ')
362
+ write(2, footY, inked(fit(facts, columns - 4 - stamp.length)), p.cardFaint, p.card)
363
+ if (verdict) {
364
+ const color = p.verdict[verdict.kind]
365
+ const isFlashing = verdict.isFresh && isMarkOn
366
+ write(columns - 1 - stamp.length, footY, stamp, isFlashing ? p.card : color, isFlashing ? color : p.card)
367
+ }
368
+ }
369
+
370
+ return new Uint8Array(words.buffer).toBase64()
371
+ }
372
+
373
+ /** One run of width-1 printable characters (a wide one would shift every cell after it). */
374
+ function flat(text: string): string {
375
+ return text
376
+ .replace(/[–—]/g, '-')
377
+ .replace(/×/g, 'x')
378
+ .replace(/→/g, '->')
379
+ .replace(/…/g, '...')
380
+ .replace(/[‘’]/g, "'")
381
+ .replace(/[“”]/g, '"')
382
+ .replace(/[^\x20-\x7e\s·]/g, '')
383
+ .replace(/\s+/g, ' ')
384
+ .trim()
385
+ }
386
+
387
+ function clip(text: string, max: number): string {
388
+ if (max <= 1) return ''
389
+ return text.length < max ? `${text}~` : `${text.slice(0, max - 1)}~`
390
+ }
391
+
392
+ /** A title that fits `max` cells, the tail clipped with `~`. */
393
+ export function fitTitle(name: string, max: number): string {
394
+ const f = flat(name)
395
+ if (max <= 1) return ''
396
+ return f.length <= max ? f : `${f.slice(0, max - 1)}~`
397
+ }
398
+ const fit = fitTitle
399
+
400
+ /** Word-wrapped lines of at most `width` cells; a word longer than a line is broken. */
401
+ export function wrap(text: string, width: number): string[] {
402
+ const lines: string[] = []
403
+ let line = ''
404
+ for (let word of flat(text).split(' ')) {
405
+ while (word.length > width) {
406
+ if (line) lines.push(line)
407
+ lines.push(word.slice(0, width))
408
+ word = word.slice(width)
409
+ line = ''
410
+ }
411
+ if (!word) continue
412
+ if (!line) line = word
413
+ else if (line.length + 1 + word.length <= width) line += ` ${word}`
414
+ else {
415
+ lines.push(line)
416
+ line = word
417
+ }
418
+ }
419
+ if (line) lines.push(line)
420
+ return lines
421
+ }