@inerrata-corporation/errata 2.0.17-dev.3571 → 2.0.17-dev.3576
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
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
import { atom, read, update } from 'claude-code'
|
|
2
|
+
import type { EngineInterface, Register, Timer } from 'claude-code'
|
|
3
|
+
|
|
4
|
+
import type { Burst, MapGraph, Verdict } from '../types'
|
|
5
|
+
import {
|
|
6
|
+
colorOf,
|
|
7
|
+
DARK_BG,
|
|
8
|
+
FULL,
|
|
9
|
+
LIGHT_BG,
|
|
10
|
+
displayName,
|
|
11
|
+
glyphOf,
|
|
12
|
+
hex,
|
|
13
|
+
JUDGMENT_STYLE,
|
|
14
|
+
layoutBurst,
|
|
15
|
+
parseJudgments,
|
|
16
|
+
resolveJudgmentRef,
|
|
17
|
+
neighborIds,
|
|
18
|
+
parseAgentBurst,
|
|
19
|
+
parseAgentSearch,
|
|
20
|
+
parseFileRecall,
|
|
21
|
+
parsePromptPrime,
|
|
22
|
+
rasterize,
|
|
23
|
+
SCOPE_COLOR,
|
|
24
|
+
SCOPE_ORDER,
|
|
25
|
+
seedNode,
|
|
26
|
+
relPath,
|
|
27
|
+
shortName,
|
|
28
|
+
statusLine,
|
|
29
|
+
textsOf,
|
|
30
|
+
} from './minimap'
|
|
31
|
+
import type { Frame, HandleTable, JudgmentKind, RasterOptions } from './minimap'
|
|
32
|
+
|
|
33
|
+
const PANE = 'errata-minimap'
|
|
34
|
+
const ERRATA = 'errata'
|
|
35
|
+
const ENRICH_MAX = 8
|
|
36
|
+
const TOP_ITEMS = 3
|
|
37
|
+
|
|
38
|
+
const focus = atom({ plugin: 'errata-minimap', key: 'focus' } as const, '')
|
|
39
|
+
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)
|
|
41
|
+
const verdicts = atom({ plugin: 'errata-minimap', key: 'verdicts' } as const, [] as Verdict[])
|
|
42
|
+
|
|
43
|
+
const FILE_TOOLS = new Set(['Read', 'Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
|
|
44
|
+
const BURST_FILE = /\.errata\/g\/burst\/([^/]+)/
|
|
45
|
+
|
|
46
|
+
const ORIGIN_LABEL = { agent: 'agent burst', 'daemon-file': 'file recall', 'daemon-prompt': 'prompt prime' } as const
|
|
47
|
+
|
|
48
|
+
// Module-level on purpose: a reload starts by redrawing whatever burst the session holds.
|
|
49
|
+
const pending = { isRefreshing: false, drawnAt: -1 }
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Where the pane sits decides the UI. Docked beside the transcript (a wide
|
|
53
|
+
* fullscreen terminal) it is the full map; anywhere else — waiting undrawn on a
|
|
54
|
+
* narrow terminal, behind another tab, closed — the map collapses into the
|
|
55
|
+
* status line. A pane the surface would only seat inline above the prompt is
|
|
56
|
+
* closed unless the person asked for it with /minimap.
|
|
57
|
+
*/
|
|
58
|
+
const view = { placement: undefined as 'dock' | 'inline' | undefined, isAsked: false, isDocked: false, status: undefined as string | undefined }
|
|
59
|
+
|
|
60
|
+
const GROW_MS = 1200
|
|
61
|
+
const TYPE_MS = 600
|
|
62
|
+
const FRAME_MS = 33
|
|
63
|
+
const HOLD_MS = 700
|
|
64
|
+
const PULSE_MS = 180
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The reveal and the highlight run on blits, not render passes: a new map's
|
|
68
|
+
* routes grow out ring by ring, then its names type out; a prior the agent
|
|
69
|
+
* bursts off pulses (its route to the seed lit) until the fresh results replace
|
|
70
|
+
* the map. Only while docked — the status line has nothing to animate.
|
|
71
|
+
*/
|
|
72
|
+
const MARK_MS = 4000
|
|
73
|
+
/** How long a verdict stays in the footer and on the status line. */
|
|
74
|
+
const VERDICT_SHOWN_MS = 10 * 60 * 1000
|
|
75
|
+
const VERDICT_STATUS_MS = 60 * 1000
|
|
76
|
+
const VERDICTS_KEPT = 6
|
|
77
|
+
|
|
78
|
+
const motion = {
|
|
79
|
+
/** Priors the agent just judged → their verdict, pulsing until `markUntil`. */
|
|
80
|
+
marks: {} as Record<string, JudgmentKind>,
|
|
81
|
+
markUntil: 0,
|
|
82
|
+
isMarkOn: true,
|
|
83
|
+
graphAt: -1,
|
|
84
|
+
start: 0,
|
|
85
|
+
frame: FULL as Frame,
|
|
86
|
+
highlight: undefined as string | undefined,
|
|
87
|
+
highlightStart: 0,
|
|
88
|
+
isPulseOn: true,
|
|
89
|
+
raster: undefined as { columns: number; rows: number } | undefined,
|
|
90
|
+
bg: DARK_BG,
|
|
91
|
+
timer: undefined as Timer | undefined,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function rasterOptions(map: MapGraph): RasterOptions {
|
|
95
|
+
return {
|
|
96
|
+
frame: motion.graphAt === map.at ? motion.frame : FULL,
|
|
97
|
+
bg: motion.bg,
|
|
98
|
+
highlight: motion.highlight,
|
|
99
|
+
isPulseOn: motion.isPulseOn,
|
|
100
|
+
marks: motion.marks,
|
|
101
|
+
isMarkOn: motion.isMarkOn,
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function animate($: EngineInterface): Promise<void> {
|
|
106
|
+
const now = await $.clock.now()
|
|
107
|
+
const t = now - motion.start
|
|
108
|
+
const isRevealing = motion.frame.type < 1
|
|
109
|
+
if (isRevealing) {
|
|
110
|
+
motion.frame = { grow: Math.min(1, t / GROW_MS), type: Math.max(0, Math.min(1, (t - GROW_MS) / TYPE_MS)) }
|
|
111
|
+
}
|
|
112
|
+
if (motion.highlight) motion.isPulseOn = Math.floor((now - motion.highlightStart) / PULSE_MS) % 2 === 0
|
|
113
|
+
const isMarking = Object.keys(motion.marks).length > 0 && now < motion.markUntil
|
|
114
|
+
if (isMarking) motion.isMarkOn = Math.floor((now - (motion.markUntil - MARK_MS)) / PULSE_MS) % 2 === 0
|
|
115
|
+
else motion.marks = {}
|
|
116
|
+
const map = await read($, graph)
|
|
117
|
+
if (motion.raster && map.nodes.length > 0) {
|
|
118
|
+
const { columns, rows } = motion.raster
|
|
119
|
+
await $.ui.blit({ requestId: PANE, key: 'map', columns, rows, cells: rasterize(map, columns, rows, rasterOptions(map)) }).catch(() => undefined)
|
|
120
|
+
}
|
|
121
|
+
if (!isRevealing && !motion.highlight && !isMarking) {
|
|
122
|
+
motion.timer?.cancel()
|
|
123
|
+
motion.timer = undefined
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function startMotion($: EngineInterface): void {
|
|
128
|
+
motion.timer ??= $.clock.every(FRAME_MS, () => void animate($))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Lights the prior the agent is about to burst off, if the map shows it. */
|
|
132
|
+
async function highlightSeed($: EngineInterface, id: string): Promise<boolean> {
|
|
133
|
+
const map = await read($, graph)
|
|
134
|
+
if (!view.isDocked || !map.nodes.some(n => !n.isSeed && n.id === id)) return false
|
|
135
|
+
motion.highlight = id
|
|
136
|
+
motion.highlightStart = await $.clock.now()
|
|
137
|
+
motion.isPulseOn = true
|
|
138
|
+
startMotion($)
|
|
139
|
+
return true
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Holds the highlight at least HOLD_MS, so the target reads before the map moves on. */
|
|
143
|
+
async function holdHighlight($: EngineInterface): Promise<void> {
|
|
144
|
+
if (!motion.highlight) return
|
|
145
|
+
const left = HOLD_MS - ((await $.clock.now()) - motion.highlightStart)
|
|
146
|
+
if (left > 0) await $.clock.sleep(left)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The agent's reply judged some priors (cite / confirm / fix / refute tags):
|
|
151
|
+
* record each, and pulse the ones on the map in their verdict's colour. Refs
|
|
152
|
+
* that resolve to no node — `[id]` in prose ABOUT the protocol — are ignored.
|
|
153
|
+
*/
|
|
154
|
+
async function recordJudgments($: EngineInterface, text: string): Promise<void> {
|
|
155
|
+
const judgments = parseJudgments(text)
|
|
156
|
+
if (judgments.length === 0) return
|
|
157
|
+
const handles = await primingHandles($)
|
|
158
|
+
const map = await read($, graph)
|
|
159
|
+
const now = await $.clock.now()
|
|
160
|
+
const fresh: Verdict[] = []
|
|
161
|
+
for (const j of judgments) {
|
|
162
|
+
const id = resolveJudgmentRef(j.ref, handles)
|
|
163
|
+
if (!id) continue
|
|
164
|
+
const onMap = map.nodes.find(n => n.id === id)
|
|
165
|
+
fresh.push({ id, kind: j.kind, name: onMap ? displayName(onMap.label, onMap.name) : j.ref, at: now })
|
|
166
|
+
if (onMap) motion.marks[id] = j.kind
|
|
167
|
+
}
|
|
168
|
+
if (fresh.length === 0) return
|
|
169
|
+
await update($, verdicts, list => [...fresh, ...list.filter(v => !fresh.some(f => f.id === v.id))].slice(0, VERDICTS_KEPT))
|
|
170
|
+
if (Object.keys(motion.marks).length > 0 && view.isDocked) {
|
|
171
|
+
motion.markUntil = now + MARK_MS
|
|
172
|
+
motion.isMarkOn = true
|
|
173
|
+
startMotion($)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function themeBackground($: EngineInterface): Promise<number> {
|
|
178
|
+
const row = (await $.config.list().catch(() => [])).find(r => r.key === 'theme')
|
|
179
|
+
return String(row?.value ?? '').includes('light') ? LIGHT_BG : DARK_BG
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function tick($: EngineInterface): Promise<void> {
|
|
183
|
+
await refresh($)
|
|
184
|
+
await syncLayout($)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function syncLayout($: EngineInterface): Promise<void> {
|
|
188
|
+
const mine = (await $.ui.panes()).find(p => p.id === PANE)
|
|
189
|
+
if (mine?.isPlaced && view.placement === 'inline' && !view.isAsked) {
|
|
190
|
+
await $.ui.close({ id: PANE })
|
|
191
|
+
view.placement = undefined
|
|
192
|
+
}
|
|
193
|
+
const isDocked = !!mine?.isPlaced && mine.isShown && view.placement === 'dock'
|
|
194
|
+
view.isDocked = isDocked
|
|
195
|
+
const latest = (await read($, verdicts))[0]
|
|
196
|
+
const now = await $.clock.now()
|
|
197
|
+
const verdictNote =
|
|
198
|
+
latest && now - latest.at < VERDICT_STATUS_MS
|
|
199
|
+
? ` · ${JUDGMENT_STYLE[latest.kind].glyph} ${JUDGMENT_STYLE[latest.kind].verb} ${shortName(latest.name, 32)}`
|
|
200
|
+
: ''
|
|
201
|
+
const base = isDocked ? undefined : statusLine(await read($, graph))
|
|
202
|
+
const text = base === undefined ? undefined : base + verdictNote
|
|
203
|
+
if (text !== view.status) {
|
|
204
|
+
view.status = text
|
|
205
|
+
$.ui.status(text)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Draws a burst the moment it lands and starts its reveal. Enrichment waits:
|
|
211
|
+
* the first cut walked up to 12 neighbor calls before drawing anything, so an
|
|
212
|
+
* MCP burst showed up seconds after the agent had already read it.
|
|
213
|
+
*/
|
|
214
|
+
async function draw($: EngineInterface, b: Burst): Promise<void> {
|
|
215
|
+
const laid = layoutBurst(b)
|
|
216
|
+
// Set the reveal up before the graph lands, so its first render is frame 0, not a flash of the finished map.
|
|
217
|
+
motion.highlight = undefined
|
|
218
|
+
if (view.isDocked) {
|
|
219
|
+
motion.graphAt = laid.at
|
|
220
|
+
motion.start = await $.clock.now()
|
|
221
|
+
motion.frame = { grow: 0, type: 0 }
|
|
222
|
+
startMotion($)
|
|
223
|
+
}
|
|
224
|
+
await update($, graph, () => laid)
|
|
225
|
+
pending.drawnAt = b.at
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Catches up a burst not drawn yet, then fills in the real edges among what it delivered. */
|
|
229
|
+
async function refresh($: EngineInterface): Promise<void> {
|
|
230
|
+
if (pending.isRefreshing) return
|
|
231
|
+
const current = await read($, burst)
|
|
232
|
+
if (!current) return
|
|
233
|
+
pending.isRefreshing = true
|
|
234
|
+
try {
|
|
235
|
+
if (current.at !== pending.drawnAt) await draw($, current)
|
|
236
|
+
if (current.isEnriched) return
|
|
237
|
+
// Background, after the draw: the seed's real name first (cheap, visible), then the cross edges.
|
|
238
|
+
const named = (await resolveSeed($, current)) ?? current
|
|
239
|
+
if (named !== current) {
|
|
240
|
+
if ((await read($, burst))?.at !== current.at) return
|
|
241
|
+
await update($, burst, () => named)
|
|
242
|
+
await update($, graph, () => layoutBurst(named))
|
|
243
|
+
}
|
|
244
|
+
const enriched = { ...named, extra: await crossEdges($, named), isEnriched: true }
|
|
245
|
+
// A newer burst landed while we walked: it is drawn already; drop this one.
|
|
246
|
+
const latest = await read($, burst)
|
|
247
|
+
if (latest && latest.at !== current.at) return
|
|
248
|
+
await update($, burst, () => enriched)
|
|
249
|
+
// Same `at`, same positions: the cross edges appear in place, the reveal keeps running.
|
|
250
|
+
await update($, graph, () => layoutBurst(enriched))
|
|
251
|
+
} catch (err) {
|
|
252
|
+
await update($, graph, g => ({ ...g, note: `minimap: ${String(err).slice(0, 80)}` }))
|
|
253
|
+
} finally {
|
|
254
|
+
pending.isRefreshing = false
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function crossEdges($: EngineInterface, b: Burst): Promise<[string, string][]> {
|
|
259
|
+
const ids = new Set([b.seed.id, ...b.nodes.map(n => n.id)])
|
|
260
|
+
const answers = await Promise.all(
|
|
261
|
+
b.nodes.slice(0, ENRICH_MAX).map(n =>
|
|
262
|
+
$.mcp.call(ERRATA, 'errata_neighbors', { nodeId: n.id, limit: 30 }).then(
|
|
263
|
+
res => [n.id, res] as const,
|
|
264
|
+
() => [n.id, undefined] as const,
|
|
265
|
+
),
|
|
266
|
+
),
|
|
267
|
+
)
|
|
268
|
+
const out: [string, string][] = []
|
|
269
|
+
for (const [id, res] of answers) {
|
|
270
|
+
if (!res || res.isError) continue
|
|
271
|
+
for (const hop of neighborIds(textsOf(res.content))) {
|
|
272
|
+
if (ids.has(hop.id) && hop.id !== id) out.push([id, hop.id])
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return out
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** A burst names its seed by id (`file_fb8c…`): ask the graph what that node is. */
|
|
279
|
+
async function resolveSeed($: EngineInterface, b: Burst | null): Promise<Burst | null> {
|
|
280
|
+
if (!b || b.seed.label !== 'Seed') return b
|
|
281
|
+
const res = await $.mcp.call(ERRATA, 'errata_neighbors', { nodeId: b.seed.id, limit: 1 }).catch(() => undefined)
|
|
282
|
+
const node = res && !res.isError ? seedNode(textsOf(res.content)) : undefined
|
|
283
|
+
return node ? { ...b, seed: { id: b.seed.id, label: node.label, name: node.name } } : b
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Sets the burst the map shows and draws it now; the next tick enriches it. */
|
|
287
|
+
async function show($: EngineInterface, next: Burst | null): Promise<void> {
|
|
288
|
+
if (!next) return
|
|
289
|
+
await update($, burst, () => next)
|
|
290
|
+
await draw($, next)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function primingHandles($: EngineInterface): Promise<HandleTable> {
|
|
294
|
+
const cwd = await $.session.cwd()
|
|
295
|
+
const text = await $.fs.read(`${cwd}/.errata/priming-handles.json`).catch(() => '{}')
|
|
296
|
+
return JSON.parse(String(text)) as HandleTable
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export const register: Register = on => {
|
|
300
|
+
on('session.start', async ($, e, next) => {
|
|
301
|
+
await $.command.register({ name: 'minimap', description: 'Open the errata minimap: the latest burst neighborhood that entered context' })
|
|
302
|
+
void $.ui.open({ id: PANE, title: 'errata' })
|
|
303
|
+
motion.bg = await themeBackground($)
|
|
304
|
+
$.clock.every(1500, () => void tick($))
|
|
305
|
+
return next(e)
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
on('config.set', { key: 'theme' }, async ($, e, next) => {
|
|
309
|
+
const done = await next(e)
|
|
310
|
+
motion.bg = await themeBackground($)
|
|
311
|
+
return done
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
on('command.run', { command: 'minimap' }, async $ => {
|
|
315
|
+
view.isAsked = true
|
|
316
|
+
const opened = await $.ui.open({ id: PANE, title: 'errata', columns: 56 })
|
|
317
|
+
pending.drawnAt = -1
|
|
318
|
+
void refresh($)
|
|
319
|
+
return { text: opened.isPlaced ? 'errata minimap opened.' : `minimap waits: ${opened.reason}` }
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
// The agent bursts through MCP: the result IS the neighborhood that entered its context.
|
|
323
|
+
on('tool.call', { tool: 'mcp__errata__errata_burst' }, async ($, e, next) => {
|
|
324
|
+
const input = e as unknown as Record<string, unknown>
|
|
325
|
+
const seed = String(input['nodeId'] ?? input['qname'] ?? '')
|
|
326
|
+
// Bursting off a prior the map already shows: light it while the burst runs.
|
|
327
|
+
await highlightSeed($, seed)
|
|
328
|
+
const ran = await next(e)
|
|
329
|
+
if (ran.deny !== undefined || ran.isError) {
|
|
330
|
+
motion.highlight = undefined
|
|
331
|
+
return ran
|
|
332
|
+
}
|
|
333
|
+
// No MCP call before the draw: the seed's name is looked up on the next tick (refresh).
|
|
334
|
+
const fresh = parseAgentBurst(textsOf([ran.text, ran.result]), seed)
|
|
335
|
+
await holdHighlight($)
|
|
336
|
+
await show($, fresh)
|
|
337
|
+
return ran
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
// A search is a neighborhood too: the hits that entered context, ringed around the query.
|
|
341
|
+
on('tool.call', { tool: 'mcp__errata__errata_search' }, async ($, e, next) => {
|
|
342
|
+
const ran = await next(e)
|
|
343
|
+
if (ran.deny !== undefined || ran.isError) return ran
|
|
344
|
+
const query = String((e as unknown as Record<string, unknown>)['query'] ?? '')
|
|
345
|
+
await show($, parseAgentSearch(textsOf([ran.text, ran.result]), query))
|
|
346
|
+
return ran
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
on('tool.call', async ($, e, next) => {
|
|
350
|
+
if (!FILE_TOOLS.has(String(e.tool))) return next(e)
|
|
351
|
+
const input = e as unknown as Record<string, unknown>
|
|
352
|
+
const abs = String(input['file_path'] ?? input['notebook_path'] ?? '')
|
|
353
|
+
const path = abs ? relPath(abs, await $.session.cwd()) : ''
|
|
354
|
+
// The daemon's file recall rides this call's hook context and names no file: remember which one.
|
|
355
|
+
if (path) await update($, focus, () => path)
|
|
356
|
+
|
|
357
|
+
const ran = await next(e)
|
|
358
|
+
if (!path || ran.deny !== undefined || ran.isError) return ran
|
|
359
|
+
|
|
360
|
+
// The agent bursting through the file surface: ask the same burst as structure.
|
|
361
|
+
const viaFile = BURST_FILE.exec(path)
|
|
362
|
+
if (e.tool === 'Read' && viaFile) {
|
|
363
|
+
const seed = decodeURIComponent(viaFile[1]!)
|
|
364
|
+
const args = /^[A-Za-z]+_[0-9a-f]{8,}|^(dprob|dfix|dcause|projn|motif)_/.test(seed) ? { nodeId: seed } : { qname: seed }
|
|
365
|
+
await highlightSeed($, seed)
|
|
366
|
+
const res = await $.mcp.call(ERRATA, 'errata_burst', args).catch(() => undefined)
|
|
367
|
+
const fresh = res && !res.isError ? parseAgentBurst(textsOf(res.content), seed) : null
|
|
368
|
+
await holdHighlight($)
|
|
369
|
+
if (fresh) await show($, fresh)
|
|
370
|
+
else motion.highlight = undefined
|
|
371
|
+
}
|
|
372
|
+
return ran
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
// The daemon's bursts arrive as injected rows: a file recall on a touch, the context block on a prompt.
|
|
376
|
+
on('session.append', async ($, e, next) => {
|
|
377
|
+
// The agent's own reply: its inline tags judge the priors it was shown.
|
|
378
|
+
// Text blocks only — tool inputs (a test fixture, a file being written) can
|
|
379
|
+
// carry tag syntax that judges nothing.
|
|
380
|
+
if (e.door === 'response' && !e.agentId) {
|
|
381
|
+
const said = e.message.content
|
|
382
|
+
.flatMap(b => {
|
|
383
|
+
const text: unknown = (b as { text?: unknown }).text
|
|
384
|
+
return b.type === 'text' && typeof text === 'string' ? [text] : []
|
|
385
|
+
})
|
|
386
|
+
.join('\n')
|
|
387
|
+
if (said) await recordJudgments($, said)
|
|
388
|
+
}
|
|
389
|
+
if (e.door !== 'response' && e.door !== 'prompt' && !e.agentId) {
|
|
390
|
+
const text = textsOf(e.message.content).join('\n')
|
|
391
|
+
if (text.includes('recorded on this file')) {
|
|
392
|
+
await show($, parseFileRecall(text, await read($, focus)))
|
|
393
|
+
} else if (text.includes('## Errata context')) {
|
|
394
|
+
await show($, parsePromptPrime(text, await primingHandles($)))
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return next(e)
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
|
|
401
|
+
const { Box, Text } = $.ui.resolve(e)
|
|
402
|
+
// The surface's word on where it seated us drives the status-line collapse (syncLayout).
|
|
403
|
+
if (e.surface === 'terminal') view.placement = e.props.placement
|
|
404
|
+
const width = Math.max(24, e.props.bodyColumns)
|
|
405
|
+
const height = Math.max(14, (e.viewport?.rows ?? 30) - 4)
|
|
406
|
+
const map = await read($, graph)
|
|
407
|
+
|
|
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))
|
|
414
|
+
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)
|
|
419
|
+
// 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')}
|
|
432
|
+
</Text>
|
|
433
|
+
</Box>
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
return (
|
|
437
|
+
<Box flexDirection="column" width={width}>
|
|
438
|
+
<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
|
+
))}
|
|
454
|
+
</Text>
|
|
455
|
+
)}
|
|
456
|
+
{legend.length > 0 && (
|
|
457
|
+
<Text wrap="wrap">
|
|
458
|
+
{legend.map(([label, count]) => (
|
|
459
|
+
<Text>
|
|
460
|
+
<Text color={hex(colorOf(label))}>{String.fromCodePoint(glyphOf(label))}</Text>
|
|
461
|
+
<Text dimColor> {count} {label} </Text>
|
|
462
|
+
</Text>
|
|
463
|
+
))}
|
|
464
|
+
</Text>
|
|
465
|
+
)}
|
|
466
|
+
{recent.map(v => (
|
|
467
|
+
<Text wrap="truncate-end">
|
|
468
|
+
<Text color={hex(JUDGMENT_STYLE[v.kind].color)} bold>
|
|
469
|
+
{JUDGMENT_STYLE[v.kind].glyph} {JUDGMENT_STYLE[v.kind].verb}{' '}
|
|
470
|
+
</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>
|
|
478
|
+
</Text>
|
|
479
|
+
))}
|
|
480
|
+
</Box>
|
|
481
|
+
)
|
|
482
|
+
})
|
|
483
|
+
}
|
|
484
|
+
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/** Who burst: the agent through the MCP tool or the `.errata/g/burst` file, or the daemon priming a file touch / a prompt. */
|
|
2
|
+
export type BurstOrigin = 'agent' | 'daemon-file' | 'daemon-prompt'
|
|
3
|
+
|
|
4
|
+
/** Which membrane a prior lives in: this machine only, or the project / team / org membrane, or the public Errata Network. */
|
|
5
|
+
export type Scope = 'machine' | 'project' | 'team' | 'org' | 'public'
|
|
6
|
+
|
|
7
|
+
export type BurstNode = {
|
|
8
|
+
id: string
|
|
9
|
+
label: string
|
|
10
|
+
name: string
|
|
11
|
+
/** Burst relevance; daemon-delivered nodes get a rank-derived stand-in. */
|
|
12
|
+
score: number
|
|
13
|
+
hops: number
|
|
14
|
+
/** The node the walk reached this one from, and by which edge. */
|
|
15
|
+
parent?: string
|
|
16
|
+
edge?: string
|
|
17
|
+
/** Absent when no client-visible signal says. */
|
|
18
|
+
scope?: Scope
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** One neighborhood that entered the agent's context. */
|
|
22
|
+
export type Burst = {
|
|
23
|
+
origin: BurstOrigin
|
|
24
|
+
seed: { id: string; label: string; name: string }
|
|
25
|
+
nodes: BurstNode[]
|
|
26
|
+
/** Real edges among the delivered nodes, found after the fact. */
|
|
27
|
+
extra: [string, string][]
|
|
28
|
+
isEnriched: boolean
|
|
29
|
+
at: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type MapNode = {
|
|
33
|
+
id: string
|
|
34
|
+
label: string
|
|
35
|
+
name: string
|
|
36
|
+
isSeed: boolean
|
|
37
|
+
score: number
|
|
38
|
+
/** Rings out from the seed (0 for the seed): the order the reveal grows in. */
|
|
39
|
+
hops: number
|
|
40
|
+
scope?: Scope
|
|
41
|
+
x: number
|
|
42
|
+
y: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type MapGraph = {
|
|
46
|
+
title: string
|
|
47
|
+
/** The seed's node type (File, Function, Prompt, ...). */
|
|
48
|
+
seedLabel?: string
|
|
49
|
+
origin?: BurstOrigin
|
|
50
|
+
nodes: MapNode[]
|
|
51
|
+
/** Index pairs into `nodes`; `true` = an edge of the walk, `false` = a cross edge. */
|
|
52
|
+
edges: [number, number, boolean][]
|
|
53
|
+
at: number
|
|
54
|
+
note?: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** One judgment the agent made on a prior in its reply: cited, confirmed, resolved or refuted. */
|
|
58
|
+
export type Verdict = {
|
|
59
|
+
id: string
|
|
60
|
+
kind: 'cite' | 'confirm' | 'fix' | 'refute'
|
|
61
|
+
name: string
|
|
62
|
+
at: number
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
declare module 'claude-code' {
|
|
66
|
+
interface PluginState {
|
|
67
|
+
'errata-minimap': {
|
|
68
|
+
focus: string
|
|
69
|
+
burst: Burst | null
|
|
70
|
+
graph: MapGraph
|
|
71
|
+
verdicts: Verdict[]
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|