@inerrata-corporation/errata 2.0.17-dev.3571 → 2.0.17-dev.3577

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inerrata-corporation/errata",
3
- "version": "2.0.17-dev.3571",
3
+ "version": "2.0.17-dev.3577",
4
4
  "description": "errata - local-first observation engine for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "errata-minimap",
3
+ "version": "0.1.0",
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.",
6
+ "types": "./types/index.d.ts"
7
+ }
@@ -0,0 +1 @@
1
+ { "modules": ["./register.tsx"] }
@@ -0,0 +1,632 @@
1
+ import type { Burst, BurstNode, MapGraph, MapNode, Scope } from '../types'
2
+
3
+ export function relPath(path: string, cwd: string): string {
4
+ const p = path.replace(/\\/g, '/')
5
+ const root = cwd.replace(/\\/g, '/').replace(/\/$/, '')
6
+ return p.toLowerCase().startsWith(`${root.toLowerCase()}/`) ? p.slice(root.length + 1) : p
7
+ }
8
+
9
+
10
+ /**
11
+ * Every string leaf of a value: tool results and transcript rows nest their
12
+ * text differently per door. A string that is itself JSON (a hook's stdout
13
+ * carrying `hookSpecificOutput.additionalContext`) is kept and also opened.
14
+ */
15
+ export function textsOf(value: unknown, out: string[] = []): string[] {
16
+ if (typeof value === 'string') {
17
+ out.push(value)
18
+ if (/^\s*[{[]/.test(value)) {
19
+ try {
20
+ textsOf(JSON.parse(value), out)
21
+ } catch {
22
+ /* not JSON after all */
23
+ }
24
+ }
25
+ } else if (Array.isArray(value)) for (const v of value) textsOf(v, out)
26
+ else if (value && typeof value === 'object') for (const v of Object.values(value)) textsOf(v, out)
27
+ return out
28
+ }
29
+
30
+ /**
31
+ * The membrane a node lives in, from what a client can see. The daemon's MCP
32
+ * output drops the cloud's `stratum` attr (dual-burst `searchResultToCloudHits`),
33
+ * so the salted id prefix is the one project/team/org signal; `provenance` then
34
+ * only splits local (machine) from cloud (public: collective or corroborated).
35
+ */
36
+ export function scopeOf(id: string, provenance?: string): Scope | undefined {
37
+ if (id.startsWith('projn_')) return 'project'
38
+ if (id.startsWith('teamn_')) return 'team'
39
+ if (id.startsWith('orgn_')) return 'org'
40
+ if (provenance === 'collective' || provenance === 'corroborated') return 'public'
41
+ if (provenance === 'local') return 'machine'
42
+ return undefined
43
+ }
44
+
45
+ type BurstJson = {
46
+ seed?: string
47
+ results?: { id: string; label?: string; name?: string; score?: number; hops?: number; provenance?: string; via?: { edgeType?: string; parentId?: string } }[]
48
+ }
49
+
50
+ /** An `errata_burst` result (MCP JSON) → the neighborhood the agent just read. */
51
+ export function parseAgentBurst(texts: string[], seedHint: string): Burst | null {
52
+ for (const text of texts) {
53
+ let json: BurstJson
54
+ try {
55
+ json = JSON.parse(text) as BurstJson
56
+ } catch {
57
+ continue
58
+ }
59
+ if (!Array.isArray(json.results)) continue
60
+ const seedId = json.seed ?? seedHint
61
+ const nodes: BurstNode[] = json.results.map(r => ({
62
+ id: r.id,
63
+ label: r.label ?? 'Node',
64
+ name: r.name ?? r.id,
65
+ score: r.score ?? 0,
66
+ hops: Math.max(1, r.hops ?? 1),
67
+ parent: r.via?.parentId,
68
+ edge: r.via?.edgeType,
69
+ scope: scopeOf(r.id, r.provenance),
70
+ }))
71
+ return {
72
+ origin: 'agent',
73
+ seed: { id: seedId, label: 'Seed', name: seedHint || seedId },
74
+ nodes,
75
+ extra: [],
76
+ isEnriched: false,
77
+ at: Date.now(),
78
+ }
79
+ }
80
+ return null
81
+ }
82
+
83
+ /**
84
+ * An `errata_search` result → the hits that entered context, ringed around the
85
+ * query. Merged results carry `score`/`provenance`; local-only hits carry
86
+ * `pageRank` and no provenance (they are this machine's store).
87
+ */
88
+ export function parseAgentSearch(texts: string[], query: string): Burst | null {
89
+ for (const text of texts) {
90
+ let json: { results?: unknown[]; hits?: unknown[] }
91
+ try {
92
+ json = JSON.parse(text) as typeof json
93
+ } catch {
94
+ continue
95
+ }
96
+ const rows = (json.results ?? json.hits) as { id?: string; label?: string; name?: string; description?: string; score?: number; pageRank?: number; provenance?: string }[] | undefined
97
+ if (!Array.isArray(rows)) continue
98
+ const nodes: BurstNode[] = rows
99
+ .filter(r => typeof r.id === 'string')
100
+ .map((r, i) => ({
101
+ id: r.id!,
102
+ label: r.label ?? 'Node',
103
+ name: r.name ?? r.description ?? r.id!,
104
+ // Search ranks by order; a score of 0 (code hits) still keeps its place.
105
+ score: r.score && r.score > 0 ? r.score : 1 - i / (rows.length + 1),
106
+ hops: 1,
107
+ parent: `search:${query}`,
108
+ edge: 'MATCHES',
109
+ scope: scopeOf(r.id!, r.provenance ?? 'local'),
110
+ }))
111
+ if (nodes.length === 0) return null
112
+ return {
113
+ origin: 'agent',
114
+ seed: { id: `search:${query}`, label: 'Search', name: `“${query}”` },
115
+ nodes,
116
+ extra: [],
117
+ isEnriched: false,
118
+ at: Date.now(),
119
+ }
120
+ }
121
+ return null
122
+ }
123
+
124
+ /** `- [Label] description → … ([id]) to cite` — the daemon's file-recall block, one prior per line. */
125
+ const RECALL_LINE = /^- \[([A-Za-z]+)(?:;[^\]]*)?\] (.*?)\s+→\s+(.*)$/
126
+ const CITED = /\[([A-Za-z0-9_:.@/+-]+)\]/
127
+
128
+ /** The daemon's file recall (`N prior(s) recorded on this file`) → the priors it put in context, seeded at the file. */
129
+ export function parseFileRecall(text: string, file: string): Burst | null {
130
+ if (!/prior\(s\) recorded on this file/.test(text)) return null
131
+ const nodes: BurstNode[] = []
132
+ for (const line of text.split('\n')) {
133
+ const m = RECALL_LINE.exec(line.trim())
134
+ if (!m) continue
135
+ const id = CITED.exec(m[3]!)?.[1]
136
+ if (!id || nodes.some(n => n.id === id)) continue
137
+ // File recall reads the local store: machine-only unless the id is a salted membrane twin.
138
+ nodes.push({ id, label: m[1]!, name: m[2]!, score: 0, hops: 1, parent: `file:${file}`, edge: 'RECORDED_ON', scope: scopeOf(id, 'local') })
139
+ }
140
+ if (nodes.length === 0) return null
141
+ nodes.forEach((n, i) => (n.score = 1 - i / (nodes.length + 1)))
142
+ return {
143
+ origin: 'daemon-file',
144
+ seed: { id: `file:${file}`, label: 'File', name: file },
145
+ nodes,
146
+ extra: [],
147
+ isEnriched: false,
148
+ at: Date.now(),
149
+ }
150
+ }
151
+
152
+ export type HandleTable = Record<string, { id?: string; label?: string }>
153
+
154
+ /** Profile handles (stack, languages, tools) ride the same syntax but are not knowledge that entered context. */
155
+ const KNOWLEDGE = new Set(['Problem', 'Solution', 'RootCause', 'Pattern', 'AntiPattern', 'Technique', 'Vulnerability', 'Constraint'])
156
+ const RAW_ID = /`((?:dprob|dfix|dcause|dsol|projn|teamn|orgn|princ|motif)_[A-Za-z0-9_]+)`/
157
+ const HANDLE = /`\[([a-z0-9-]+)\]`/
158
+
159
+ /** The prompt-time `## Errata context` block → the priors primed for this prompt, resolved through priming-handles.json. */
160
+ export function parsePromptPrime(text: string, handles: HandleTable): Burst | null {
161
+ if (!text.includes('## Errata context')) return null
162
+ const nodes: BurstNode[] = []
163
+ // Only the Network section is cloud; its `[org · …]` chip names an own-org stratum, no chip = public.
164
+ let isNetwork = false
165
+ for (const raw of text.split('\n')) {
166
+ if (raw.startsWith('### ')) isNetwork = raw.includes('From the Errata Network')
167
+ const handle = HANDLE.exec(raw)?.[1]
168
+ const entry = handle ? handles[handle] : undefined
169
+ const id = entry?.id ?? RAW_ID.exec(raw)?.[1]
170
+ if (!id || nodes.some(n => n.id === id)) continue
171
+ const typed = /\*\*([A-Za-z]+):\*\*/.exec(raw)?.[1]
172
+ const label = entry?.label ?? typed ?? (id.startsWith('dprob') ? 'Problem' : 'Node')
173
+ if (!KNOWLEDGE.has(label)) continue
174
+ // `- **title** — \`id\`` (a title may hold its own " — ") or `**Pattern:** text \`[handle]\``.
175
+ const titled = /^\s*-\s*\*\*(?![A-Za-z]+:\*\*)(.+?)\*\*\s+—\s+`/.exec(raw)?.[1]
176
+ const name = (titled ?? raw.replace(/^\s*-\s*/, '').replace(/\*\*[A-Za-z]+:\*\*\s*/, '').split(/\s+`/)[0]!)
177
+ .replace(/\*\*/g, '')
178
+ .trim()
179
+ const chip = /`\[(project|team|org)\b/.exec(raw)?.[1] as Scope | undefined
180
+ const scope = scopeOf(id) ?? chip ?? (isNetwork ? 'public' : 'machine')
181
+ nodes.push({ id, label, name: name || id, score: 0, hops: 1, parent: 'prompt', edge: 'PRIMED', scope })
182
+ }
183
+ if (nodes.length === 0) return null
184
+ nodes.forEach((n, i) => (n.score = 1 - i / (nodes.length + 1)))
185
+ return {
186
+ origin: 'daemon-prompt',
187
+ seed: { id: 'prompt', label: 'Prompt', name: 'prompt context' },
188
+ nodes,
189
+ extra: [],
190
+ isEnriched: false,
191
+ at: Date.now(),
192
+ }
193
+ }
194
+
195
+ /** `node` of an `errata_neighbors` result: what the queried id is. */
196
+ export function seedNode(texts: string[]): { label: string; name: string } | undefined {
197
+ for (const text of texts) {
198
+ try {
199
+ const node = (JSON.parse(text) as { node?: { label?: string; name?: string } }).node
200
+ if (node?.name) return { label: node.label ?? 'Node', name: node.name }
201
+ } catch {
202
+ continue
203
+ }
204
+ }
205
+ return undefined
206
+ }
207
+
208
+ /**
209
+ * How the agent judged a prior in its own reply — the capture protocol's tags:
210
+ * `([h])` cites it, `(confirm:[h])` says it held, `(refute:[h] …)` says it did
211
+ * not, `(fix:[h])` says the agent's edit resolved it.
212
+ */
213
+ export type JudgmentKind = 'cite' | 'confirm' | 'fix' | 'refute'
214
+ export type Judgment = { kind: JudgmentKind; ref: string }
215
+
216
+ export const JUDGMENT_STYLE: Record<JudgmentKind, { glyph: string; color: number; verb: string }> = {
217
+ cite: { glyph: '◎', color: 0x56b6c2, verb: 'cited' },
218
+ confirm: { glyph: '✓', color: 0x98c379, verb: 'confirmed' },
219
+ fix: { glyph: '✓', color: 0x98c379, verb: 'resolved' },
220
+ refute: { glyph: '✗', color: 0xe06c75, verb: 'refuted' },
221
+ }
222
+
223
+ /** A stronger verdict on the same prior wins: refute › confirm/fix › cite. */
224
+ const STRENGTH: Record<JudgmentKind, number> = { cite: 0, confirm: 1, fix: 1, refute: 2 }
225
+
226
+ const VERDICT_TAG = /\((confirm|refute|fix|cause):\[([^\]\s]+)\]/g
227
+ const BARE_CITE = /\(\[([^\]\s]+)\]\)/g
228
+ const LINK_TAG = /\((?:instance|pattern|aids|builds-on|supersedes|alternative):([^)]*)\)/g
229
+ const BRACKETED = /\[([^\]\s]+)\]/g
230
+
231
+ /** Every prior the text judges, once each, at its strongest verdict. */
232
+ export function parseJudgments(text: string): Judgment[] {
233
+ const best = new Map<string, JudgmentKind>()
234
+ const note = (ref: string, kind: JudgmentKind) => {
235
+ const prev = best.get(ref)
236
+ if (!prev || STRENGTH[kind] > STRENGTH[prev]) best.set(ref, kind)
237
+ }
238
+ for (const m of text.matchAll(VERDICT_TAG)) {
239
+ // `(cause:[h] …)` explains a shown problem — it engages the prior, so it reads as a citation.
240
+ note(m[2]!, m[1] === 'cause' ? 'cite' : (m[1] as JudgmentKind))
241
+ }
242
+ for (const m of text.matchAll(BARE_CITE)) note(m[1]!, 'cite')
243
+ for (const m of text.matchAll(LINK_TAG)) for (const r of m[1]!.matchAll(BRACKETED)) note(r[1]!, 'cite')
244
+ return [...best].map(([ref, kind]) => ({ ref, kind }))
245
+ }
246
+
247
+ /** A real node id, not a placeholder like `[id]` or `[its-handle]` in prose about the protocol. */
248
+ const NODE_ID = /^(dprob|dfix|dcause|dsol|dtri|projn|teamn|orgn|princ|pat|motif_community|problem)_[A-Za-z0-9_]{6,}$/
249
+
250
+ /** The node a judgment names: a primed handle resolves through the table, a raw id stands; anything else is prose. */
251
+ export function resolveJudgmentRef(ref: string, handles: HandleTable): string | null {
252
+ return handles[ref]?.id ?? (NODE_ID.test(ref) ? ref : null)
253
+ }
254
+
255
+ /** `{ out, in }` of an `errata_neighbors` result → the ids it touches. */
256
+ export function neighborIds(texts: string[]): { id: string; type: string }[] {
257
+ for (const text of texts) {
258
+ try {
259
+ const json = JSON.parse(text) as { out?: { type: string; to?: { id: string } }[]; in?: { type: string; from?: { id: string } }[] }
260
+ if (!json.out && !json.in) continue
261
+ return [
262
+ ...(json.out ?? []).flatMap(e => (e.to ? [{ id: e.to.id, type: e.type }] : [])),
263
+ ...(json.in ?? []).flatMap(e => (e.from ? [{ id: e.from.id, type: e.type }] : [])),
264
+ ]
265
+ } catch {
266
+ continue
267
+ }
268
+ }
269
+ return []
270
+ }
271
+
272
+ /**
273
+ * Radial layout of one burst: the seed at the centre, one ring per hop, each
274
+ * node in its parent's angular sector, and within a ring a higher score sits
275
+ * closer in. Positions are in the unit square.
276
+ */
277
+ export function layoutBurst(burst: Burst): MapGraph {
278
+ const nodes: MapNode[] = [{ id: burst.seed.id, label: burst.seed.label, name: burst.seed.name, isSeed: true, score: 1, hops: 0, x: 0.5, y: 0.5 }]
279
+ const index = new Map<string, number>([[burst.seed.id, 0]])
280
+ const edges: [number, number, boolean][] = []
281
+ const maxHops = Math.max(1, ...burst.nodes.map(n => n.hops))
282
+ const scores = burst.nodes.map(n => n.score)
283
+ const [lo, hi] = [Math.min(...scores), Math.max(...scores)]
284
+ const norm = (s: number) => (hi > lo ? (s - lo) / (hi - lo) : 1)
285
+
286
+ const sector = new Map<string, [number, number]>([[burst.seed.id, [0, Math.PI * 2]]])
287
+ const byDepth = [...burst.nodes].sort((a, b) => a.hops - b.hops || b.score - a.score)
288
+ const childrenOf = new Map<string, BurstNode[]>()
289
+ for (const n of byDepth) {
290
+ const parent = n.parent && (n.parent === burst.seed.id || burst.nodes.some(m => m.id === n.parent)) ? n.parent : burst.seed.id
291
+ childrenOf.set(parent, [...(childrenOf.get(parent) ?? []), n])
292
+ }
293
+
294
+ const place = (parentId: string) => {
295
+ const kids = childrenOf.get(parentId) ?? []
296
+ const [a0, a1] = sector.get(parentId) ?? [0, Math.PI * 2]
297
+ const span = (a1 - a0) / Math.max(1, kids.length)
298
+ kids.forEach((n, i) => {
299
+ if (index.has(n.id)) return
300
+ const angle = a0 + span * (i + 0.5) - Math.PI / 2
301
+ const ring = n.hops / maxHops
302
+ const r = 0.48 * ring * (0.72 + 0.28 * (1 - norm(n.score)))
303
+ const at = nodes.length
304
+ nodes.push({ id: n.id, label: n.label, name: n.name, isSeed: false, score: n.score, hops: n.hops, scope: n.scope, x: 0.5 + r * Math.cos(angle), y: 0.5 + r * Math.sin(angle) })
305
+ index.set(n.id, at)
306
+ edges.push([index.get(parentId) ?? 0, at, true])
307
+ sector.set(n.id, [a0 + span * i, a0 + span * (i + 1)])
308
+ place(n.id)
309
+ })
310
+ }
311
+ place(burst.seed.id)
312
+
313
+ const seen = new Set(edges.map(([a, b]) => key(a, b)))
314
+ for (const [s, t] of burst.extra) {
315
+ const a = index.get(s), b = index.get(t)
316
+ if (a === undefined || b === undefined || a === b || seen.has(key(a, b))) continue
317
+ seen.add(key(a, b))
318
+ edges.push([a, b, false])
319
+ }
320
+ return { title: displayName(burst.seed.label, burst.seed.name), seedLabel: burst.seed.label, origin: burst.origin, nodes, edges, at: burst.at }
321
+ }
322
+
323
+ function key(a: number, b: number): string {
324
+ return a < b ? `${a},${b}` : `${b},${a}`
325
+ }
326
+
327
+ const DEFAULT = 0x01000000
328
+ // Bright enough to read on a dark terminal: the first cut's #3a3f4b vanished there.
329
+ const EDGE_WALK = 0x8a96b4
330
+ const EDGE_CROSS = 0x5c6680
331
+
332
+ /**
333
+ * An edge takes the colour of the membrane its far end lives in, so the map
334
+ * reads "how far did this prior travel to reach me": machine-only, the
335
+ * project / team / org membranes, or the public Errata Network.
336
+ */
337
+ export const SCOPE_COLOR: Record<Scope, number> = {
338
+ machine: 0x8a96b4, // slate — never left this machine
339
+ project: 0x5fa8ff, // blue
340
+ team: 0x3fc5b0, // teal
341
+ org: 0xf0b34a, // amber
342
+ public: 0xd27cf0, // magenta — The Errata Network
343
+ }
344
+ export const SCOPE_ORDER: Scope[] = ['machine', 'project', 'team', 'org', 'public']
345
+
346
+ /** The tier an edge is coloured by: its non-seed end, the farther-travelled when both say. */
347
+ export function edgeScope(a: MapNode, b: MapNode): Scope | undefined {
348
+ const ends = [a, b].filter(v => !v.isSeed && v.scope).map(v => v.scope!)
349
+ return ends.sort((p, q) => SCOPE_ORDER.indexOf(q) - SCOPE_ORDER.indexOf(p))[0]
350
+ }
351
+
352
+ function dim(color: number): number {
353
+ return (((color >> 16) & 255) * 0.6) << 16 | (((color >> 8) & 255) * 0.6) << 8 | ((color & 255) * 0.6)
354
+ }
355
+ const NAME = 0x9aa3b5
356
+
357
+ export const LABEL_COLOR: Record<string, number> = {
358
+ Problem: 0xe06c75,
359
+ Vulnerability: 0xff5f87,
360
+ Solution: 0x98c379,
361
+ RootCause: 0xd19a66,
362
+ Pattern: 0xc678dd,
363
+ AntiPattern: 0xbe5046,
364
+ Technique: 0x56b6c2,
365
+ Constraint: 0xe5c07b,
366
+ File: 0x61afef,
367
+ Function: 0x7fb8d8,
368
+ Method: 0x7fb8d8,
369
+ Module: 0xa9b1c4,
370
+ Package: 0xa9b1c4,
371
+ Const: 0x8fbfa0,
372
+ Variable: 0x8fbfa0,
373
+ Interface: 0xb4a7d6,
374
+ Class: 0xb4a7d6,
375
+ Type: 0xb4a7d6,
376
+ Comment: 0x7c8494,
377
+ }
378
+
379
+ export const LABEL_GLYPH: Record<string, number> = {
380
+ Problem: 0x25c6, // ◆
381
+ Vulnerability: 0x25c6,
382
+ Solution: 0x25cf, // ●
383
+ RootCause: 0x25b2, // ▲
384
+ Pattern: 0x2726, // ✦
385
+ AntiPattern: 0x2726,
386
+ Technique: 0x25c7, // ◇
387
+ Constraint: 0x25a0, // ■
388
+ File: 0x25aa, // ▪
389
+ // Code structure a burst walks through (CALLS / IMPORTS / DEFINES / CONTAINS).
390
+ Function: 0x0192, // ƒ
391
+ Method: 0x0192,
392
+ Module: 0x229e, // ⊞
393
+ Package: 0x229e,
394
+ Const: 0x2261, // ≡
395
+ Variable: 0x2261,
396
+ Interface: 0x25a1, // □
397
+ Class: 0x25a1,
398
+ Type: 0x25a1,
399
+ Comment: 0x00b6, // ¶
400
+ }
401
+
402
+ /** The name as the map shows it: a file path by its last two segments, a module by its last. */
403
+ export function displayName(label: string, name: string): string {
404
+ if (label === 'File' || /^[\w.@-]+(\/[\w.@-]+){2,}$/.test(name)) return name.split('/').slice(-2).join('/')
405
+ if (label === 'Module' || label === 'Package') return name.split('/').pop() ?? name
406
+ return name
407
+ }
408
+
409
+ export function glyphOf(label: string): number {
410
+ return LABEL_GLYPH[label] ?? 0x2022 // •
411
+ }
412
+
413
+ export function colorOf(label: string): number {
414
+ return LABEL_COLOR[label] ?? 0x6b7280
415
+ }
416
+
417
+ export function hex(color: number): string {
418
+ return `#${color.toString(16).padStart(6, '0')}`
419
+ }
420
+
421
+ const ORIGIN_SHORT = { agent: 'burst', 'daemon-file': 'recall', 'daemon-prompt': 'prime' } as const
422
+
423
+ /**
424
+ * The minified minimap for the status line, where the pane has no room: the
425
+ * seed, how much entered context, and a glyph tally by type, knowledge first.
426
+ * `errata ◉ src/engine.ts · burst 14 · ◆3 ●2 ƒ2 ⊞3 ≡2 □1 ¶1`
427
+ */
428
+ export function statusLine(graph: MapGraph, max = 100): string | undefined {
429
+ const delivered = graph.nodes.filter(n => !n.isSeed)
430
+ if (delivered.length === 0) return undefined
431
+ const counts = new Map<string, number>()
432
+ for (const n of delivered) counts.set(n.label, (counts.get(n.label) ?? 0) + 1)
433
+ const knowledge = (l: string) => (l in LABEL_GLYPH && !(l in CODE_LABELS) ? 0 : 1)
434
+ const tally = [...counts]
435
+ .sort((a, b) => knowledge(a[0]) - knowledge(b[0]) || b[1] - a[1])
436
+ .map(([label, n]) => `${String.fromCodePoint(glyphOf(label))}${n}`)
437
+ .join(' ')
438
+ const origin = graph.origin ? ORIGIN_SHORT[graph.origin] : 'burst'
439
+ const head = `errata ◉ ${graph.title} · ${origin} ${delivered.length} · `
440
+ const line = head + tally
441
+ return line.length <= max ? line : `${line.slice(0, max - 1)}…`
442
+ }
443
+
444
+ const CODE_LABELS = { File: 1, Function: 1, Method: 1, Module: 1, Package: 1, Const: 1, Variable: 1, Interface: 1, Class: 1, Type: 1, Comment: 1 }
445
+
446
+ /** How far the reveal has run: routes grow out ring by ring (`grow`), then names type out (`type`). Both 0..1. */
447
+ export type Frame = { grow: number; type: number }
448
+ export const FULL: Frame = { grow: 1, type: 1 }
449
+
450
+ /** What "transparent" fades toward: the background of the terminal theme. */
451
+ export const DARK_BG = 0x1a1b1e
452
+ export const LIGHT_BG = 0xf7f7f5
453
+
454
+ /** Fraction of a colour kept at the weakest signal: faint, never gone. */
455
+ const FLOOR = 0.28
456
+
457
+ /** Signal strength per node, 0..1 over this burst's score range; the seed is full strength. */
458
+ export function strengths(graph: MapGraph): number[] {
459
+ const scores = graph.nodes.filter(v => !v.isSeed).map(v => v.score)
460
+ const [lo, hi] = [Math.min(...scores), Math.max(...scores)]
461
+ return graph.nodes.map(v => (v.isSeed ? 1 : hi > lo ? (v.score - lo) / (hi - lo) : 1))
462
+ }
463
+
464
+ /** Blends `color` toward `bg` as signal weakens: strength 1 keeps it, 0 keeps FLOOR of it. */
465
+ export function fade(color: number, strength: number, bg: number): number {
466
+ const keep = FLOOR + (1 - FLOOR) * Math.max(0, Math.min(1, strength))
467
+ const ch = (c: number, shift: number) => Math.round(((c >> shift) & 255) * keep + ((bg >> shift) & 255) * (1 - keep))
468
+ return (ch(color, 16) << 16) | (ch(color, 8) << 8) | ch(color, 0)
469
+ }
470
+
471
+ /**
472
+ * The minimap as Raster cells (`[codePoint, fg, bg]` u32 triplets, row-major,
473
+ * base64) at one frame of the reveal. Positions and label slots are planned on
474
+ * the finished map, so an animated frame only ever draws a prefix of it —
475
+ * nothing jumps as it fills in.
476
+ */
477
+ export type RasterOptions = {
478
+ frame?: Frame
479
+ /** The theme background weak signal fades toward. */
480
+ bg?: number
481
+ /** A node the agent is bursting off: drawn inverted, its route to the seed lit. */
482
+ highlight?: string
483
+ /** Which half of the highlight's pulse this frame shows. */
484
+ isPulseOn?: boolean
485
+ /** Priors the agent just judged: each pulses in its verdict's colour. */
486
+ marks?: Record<string, JudgmentKind>
487
+ /** Which half of the marks' pulse this frame shows. */
488
+ isMarkOn?: boolean
489
+ }
490
+
491
+ const HIGHLIGHT = 0xffd75f
492
+
493
+ export function rasterize(graph: MapGraph, columns: number, rows: number, options: RasterOptions = {}): string {
494
+ const { frame = FULL, bg = DARK_BG, highlight, isPulseOn = true, marks = {}, isMarkOn = true } = options
495
+ const markColor = (id: string): number | undefined => {
496
+ const kind = marks[id]
497
+ return kind ? JUDGMENT_STYLE[kind].color : undefined
498
+ }
499
+ const words = new Uint32Array(columns * rows * 3)
500
+ for (let i = 0; i < columns * rows; i++) words.set([0x20, DEFAULT, DEFAULT], i * 3)
501
+ const occupied = new Uint8Array(columns * rows)
502
+ const cx = (x: number) => 1 + Math.round(x * Math.max(0, columns - 3))
503
+ const cy = (y: number) => Math.round(y * Math.max(0, rows - 1))
504
+ const put = (x: number, y: number, ch: number, fg: number, weight: number, cellBg = DEFAULT) => {
505
+ if (x < 0 || y < 0 || x >= columns || y >= rows) return false
506
+ const i = y * columns + x
507
+ if (occupied[i]! > weight) return false
508
+ occupied[i] = weight
509
+ words.set([ch, fg, cellBg], i * 3)
510
+ return true
511
+ }
512
+
513
+ const pos: [number, number][] = graph.nodes.map(v => [cx(v.x), cy(v.y)])
514
+ // Two nodes in one cell: walk the later one outward to the nearest free cell.
515
+ const taken = new Set<number>()
516
+ graph.nodes.forEach((_, i) => {
517
+ let [x, y] = pos[i]!
518
+ for (let r = 0; taken.has(y * columns + x) && r < 6; r++) {
519
+ const ring = [[1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [-1, -1], [1, -1], [-1, 1]].map(([dx, dy]) => [x + dx! * (r + 1), y + dy! * (r + 1)] as const)
520
+ const free = ring.find(([a, b]) => a >= 0 && b >= 0 && a < columns && b < rows && !taken.has(b * columns + a))
521
+ if (free) [x, y] = free
522
+ }
523
+ taken.add(y * columns + x)
524
+ pos[i] = [x, y]
525
+ })
526
+
527
+ const strength = strengths(graph)
528
+ // The highlighted node and the walk edges from it back to the seed.
529
+ const lit = graph.nodes.findIndex(v => v.id === highlight)
530
+ const route = new Set<number>()
531
+ for (let at = lit, guard = 0; at > 0 && guard < 16; guard++) {
532
+ const k = graph.edges.findIndex(([, c, isWalk]) => isWalk && c === at)
533
+ if (k < 0) break
534
+ route.add(k)
535
+ at = graph.edges[k]![0]
536
+ }
537
+ const depth = Math.max(1, ...graph.nodes.map(v => v.hops))
538
+ // How much of the walk edge INTO node i has grown: ring h fills during the h-th slice of `grow`.
539
+ const reach = (i: number) => (graph.nodes[i]!.isSeed ? 1 : Math.max(0, Math.min(1, frame.grow * depth - (graph.nodes[i]!.hops - 1))))
540
+
541
+ // Label slots, planned on the finished map, best first: nodes and earlier labels block, edges do not.
542
+ const blocked = new Set(pos.map(([x, y]) => y * columns + x))
543
+ const labels: { i: number; at: number; text: string }[] = []
544
+ const ranked = graph.nodes.map((v, i) => [v, i] as const).filter(([v]) => !v.isSeed).sort((p, q) => q[0].score - p[0].score)
545
+ for (const [v, i] of ranked) {
546
+ const [x, y] = pos[i]!
547
+ const free = (from: number, len: number) => {
548
+ if (from < 0 || from + len > columns) return false
549
+ for (let k = 0; k < len; k++) if (blocked.has(y * columns + from + k)) return false
550
+ return true
551
+ }
552
+ const fits = (len: number) => (free(x + 2, len) ? x + 2 : free(x - 1 - len, len) ? x - 1 - len : -1)
553
+ let text = shortName(displayName(v.label, v.name), 18)
554
+ while (text.length >= 4 && fits(text.length) < 0) text = shortName(text.replace(/~$/, ''), text.length - 2)
555
+ const at = text.length >= 4 ? fits(text.length) : -1
556
+ if (at < 0) continue
557
+ for (let k = 0; k < text.length; k++) blocked.add(y * columns + at + k)
558
+ labels.push({ i, at, text })
559
+ }
560
+
561
+ // Edges go down at braille resolution — 2×4 dots per cell — so a line of any
562
+ // slope is one continuous stroke. Per-cell box glyphs could not do that: a
563
+ // cell is ~2× taller than wide, so a visual diagonal steps two cells sideways
564
+ // per row and adjacent ╱╱ never join.
565
+ const dots = new Uint8Array(columns * rows)
566
+ const tone = new Uint8Array(columns * rows) // 2 = a walk edge crosses the cell, 1 = cross edges only
567
+ const hue = new Uint32Array(columns * rows) // the colour of the strongest edge through the cell
568
+ const plot = (px: number, py: number, weight: number, color: number) => {
569
+ const x = px >> 1, y = py >> 2
570
+ if (x < 0 || y < 0 || x >= columns || y >= rows) return
571
+ const i = y * columns + x
572
+ dots[i] = dots[i]! | BRAILLE_BIT[(py & 3) * 2 + (px & 1)]!
573
+ if (weight >= tone[i]!) hue[i] = color
574
+ tone[i] = Math.max(tone[i]!, weight)
575
+ }
576
+ for (const [k, [a, b, isWalk]] of graph.edges.entries()) {
577
+ // A walk edge grows parent → child; a cross edge fades in while names type.
578
+ const progress = isWalk ? reach(b) : frame.type
579
+ if (progress <= 0) continue
580
+ const weaker = graph.nodes[a]!.isSeed ? b : graph.nodes[b]!.isSeed ? a : strength[a]! < strength[b]! ? a : b
581
+ const scope = edgeScope(graph.nodes[a]!, graph.nodes[b]!)
582
+ const base = scope ? SCOPE_COLOR[scope] : EDGE_WALK
583
+ const color = route.has(k) ? HIGHLIGHT : fade(isWalk ? base : scope ? dim(base) : EDGE_CROSS, strength[weaker]!, bg)
584
+ let x0 = pos[a]![0] * 2 + 1, y0 = pos[a]![1] * 4 + 2
585
+ const x1 = pos[b]![0] * 2 + 1, y1 = pos[b]![1] * 4 + 2
586
+ const dx = Math.abs(x1 - x0), dy = -Math.abs(y1 - y0)
587
+ const sx = x0 < x1 ? 1 : -1, sy = y0 < y1 ? 1 : -1
588
+ const limit = Math.round(progress * Math.max(dx, -dy))
589
+ let err = dx + dy
590
+ // Cross edges are dashed so a walk edge reads as the path taken.
591
+ for (let step = 0; step <= limit; step++) {
592
+ if (isWalk || step % 4 < 2) plot(x0, y0, route.has(k) ? 4 : isWalk ? 2 : 1, color)
593
+ if (x0 === x1 && y0 === y1) break
594
+ const e2 = 2 * err
595
+ if (e2 >= dy) { err += dy; x0 += sx }
596
+ if (e2 <= dx) { err += dx; y0 += sy }
597
+ }
598
+ }
599
+ for (let i = 0; i < columns * rows; i++) {
600
+ if (dots[i]) put(i % columns, Math.floor(i / columns), 0x2800 + dots[i]!, hue[i]!, tone[i]!)
601
+ }
602
+
603
+ graph.nodes.forEach((v, i) => {
604
+ if (reach(i) < 1) return
605
+ const [x, y] = pos[i]!
606
+ if (v.isSeed) put(x, y, 0x25c9, 0xffd75f, 9) // ◉
607
+ else if (i === lit) put(x, y, glyphOf(v.label), isPulseOn ? bg : HIGHLIGHT, 10, isPulseOn ? HIGHLIGHT : DEFAULT)
608
+ else if (markColor(v.id) !== undefined) {
609
+ const c = markColor(v.id)!
610
+ put(x, y, glyphOf(v.label), isMarkOn ? bg : c, 10, isMarkOn ? c : DEFAULT)
611
+ }
612
+ else put(x, y, glyphOf(v.label), fade(colorOf(v.label), strength[i]!, bg), 8)
613
+ })
614
+
615
+ for (const { i, at, text } of labels) {
616
+ if (reach(i) < 1) continue
617
+ const shown = Math.floor(text.length * frame.type)
618
+ const color = i === lit ? HIGHLIGHT : markColor(graph.nodes[i]!.id) ?? fade(NAME, strength[i]!, bg)
619
+ for (let k = 0; k < shown; k++) put(at + k, pos[i]![1], text.codePointAt(k)!, color, 3)
620
+ }
621
+ return new Uint8Array(words.buffer).toBase64()
622
+ }
623
+
624
+ /** Unicode braille dot bits, indexed by `row * 2 + column` within the 2×4 cell (U+2800 + bits). */
625
+ const BRAILLE_BIT = [0x01, 0x08, 0x02, 0x10, 0x04, 0x20, 0x40, 0x80]
626
+
627
+ /** A name short enough to sit on the map: one printable width-1 run, the tail clipped. */
628
+ export function shortName(name: string, max: number): string {
629
+ const flat = name.replace(/\s+/g, ' ').replace(/[^\x20-\x7e]/g, '').trim()
630
+ if (max <= 1) return ''
631
+ return flat.length <= max ? flat : `${flat.slice(0, max - 1)}~`
632
+ }