@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/errata.mjs +320 -172
- package/package.json +1 -1
- package/resources/claude-mods/errata-minimap/.claude-plugin/plugin.json +7 -0
- package/resources/claude-mods/errata-minimap/hooks/hooks.json +1 -0
- package/resources/claude-mods/errata-minimap/hooks/minimap.ts +632 -0
- package/resources/claude-mods/errata-minimap/hooks/register.tsx +484 -0
- package/resources/claude-mods/errata-minimap/tsconfig.json +3 -0
- package/resources/claude-mods/errata-minimap/types/index.d.ts +74 -0
package/package.json
CHANGED
|
@@ -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
|
+
}
|