@zenera/neo 1.1.12 → 1.1.14

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.
@@ -152,11 +152,22 @@ export class RateLimiter {
152
152
  return;
153
153
  }
154
154
  this.#pausedUntil = until;
155
+ this.#arm();
156
+ }
157
+ /**
158
+ * Node may run a timer a millisecond before its deadline, and a pump that
159
+ * finds the pause still in force grants nothing — so the wake-up re-arms
160
+ * itself rather than leaving the queue with nothing left to wake it.
161
+ */
162
+ #arm() {
155
163
  clearTimeout(this.#timer);
156
- this.#timer = setTimeout(() => {
157
- this.#timer = undefined;
164
+ this.#timer = undefined;
165
+ const remaining = this.#pausedUntil - Date.now();
166
+ if (remaining <= 0) {
158
167
  this.#pump();
159
- }, until - Date.now());
168
+ return;
169
+ }
170
+ this.#timer = setTimeout(() => this.#arm(), remaining);
160
171
  this.#timer.unref?.();
161
172
  }
162
173
  }
@@ -1,2 +1,2 @@
1
- export declare const CLIENT = "\nconst DATA = JSON.parse(document.getElementById('run-data').textContent);\nconst STATE = DATA.state;\nconst BLOBS = DATA.blobs;\nconst MEDIA = DATA.media || [];\nconst CUT = new Set(DATA.truncated || []);\n\n// --- payload access -------------------------------------------------------\n\nfunction blob(ref) {\n if (!ref || typeof ref.sha256 !== 'string') return '';\n const v = BLOBS[ref.sha256];\n if (v === undefined) return '[payload not resolved: ' + ref.sha256.slice(0, 12) + ']';\n return CUT.has(ref.sha256) ? v + '\\n\\n[\u2026 truncated, ' + ref.size + ' bytes total]' : v;\n}\n\nfunction pretty(raw) {\n try { return JSON.stringify(JSON.parse(raw), null, 2); } catch (e) { return raw; }\n}\n\n// --- flatten the trajectory ----------------------------------------------\n\nconst ENTRIES = [];\nconst BY_KEY = new Map();\n\nfunction covered(nodes) {\n const out = new Set();\n for (const n of nodes) if (n.type === 'compaction') for (const id of n.covers) out.add(id);\n return out;\n}\n\nfunction flatten(nodes, depth, branch) {\n const hidden = covered(nodes);\n const level = [];\n for (const node of nodes) {\n const e = {\n key: '', node: node, depth: depth, branch: branch,\n covered: hidden.has(node.id), children: []\n };\n // A join's branches ran between the fork and the join, so they are\n // numbered and listed there \u2014 which means recursing before this entry\n // takes its own number.\n if (node.type === 'join') {\n for (const b of node.branches) e.children.push({ name: b.name, entries: flatten(b.nodes, depth + 1, b.name) });\n }\n e.key = 'n' + ENTRIES.length;\n ENTRIES.push(e); BY_KEY.set(e.key, e); level.push(e);\n }\n return level;\n}\nconst ROOT = flatten(STATE.trajectory, 0, null);\n\n// --- labels ---------------------------------------------------------------\n\nfunction names(list) { return list.join(', '); }\n\n// The edges are what makes a recollection a subgraph rather than a hit list,\n// so they are shown even though the injected block already renders them.\nfunction edgeLines(edges) {\n if (!edges || !edges.length) return '';\n return '\\n\\n' + edges.map(function (e) {\n return e.source + ' --' + e.relation + '--> ' + e.target;\n }).join('\\n');\n}\n\nfunction base(path) { return path.split('/').pop() || path; }\n\nfunction label(n) {\n switch (n.type) {\n case 'user_input': return n.synthetic ? 'user input (synthetic)' : 'user input';\n case 'system_prompt': return 'system prompt'\n + (n.sources && n.sources.length ? ': ' + names(n.sources.map(function (s) { return base(s.path); })) : '');\n case 'load_skills': return 'skills: ' + names(n.skills.map(function (s) { return s.name; }));\n case 'memory_recall': return 'memory recall: ' + n.nodes.length + ' nodes';\n case 'memory_op': return 'memory ' + n.op + ': ' + n.nodes.length + ' nodes';\n case 'llm_call': return 'llm: ' + n.model + (n.toolCalls.length\n ? ' -> ' + names(n.toolCalls.map(function (c) { return c.name; })) : '');\n case 'tool_call': return 'call: ' + n.name;\n case 'tool_result': return 'result: ' + n.name + (n.isError ? ' (error)' : '');\n case 'handoff': return 'handoff: ' + n.from + ' -> ' + n.to;\n case 'fork': return 'fork: ' + names(n.branches.map(function (b) { return b.name; }));\n case 'join': return 'join: ' + names(n.branches.map(function (b) { return b.name + '=' + b.status; }));\n case 'compaction': return 'compaction: ' + n.reason + ' (' + n.covers.length + ' nodes)';\n case 'final_output': return 'final output';\n default: return n.type;\n }\n}\n\nfunction preview(n) {\n switch (n.type) {\n case 'user_input': {\n const t = n.content.find(function (p) { return p.type === 'text'; });\n return t ? blob(t.text) : '';\n }\n case 'system_prompt': return blob(n.prompt);\n case 'llm_call': return blob(n.text);\n case 'tool_call': return blob(n.args);\n case 'tool_result': return blob(n.result);\n case 'compaction': return blob(n.summary);\n case 'final_output': return blob(n.output);\n case 'memory_recall': return blob(n.content);\n case 'load_skills': return blob(n.content);\n default: return '';\n }\n}\n\n// --- DOM helpers ----------------------------------------------------------\n\nfunction el(tag, cls, text) {\n const e = document.createElement(tag);\n if (cls) e.className = cls;\n if (text !== undefined && text !== null) e.textContent = String(text);\n return e;\n}\n\nfunction block(title, tag) {\n const s = el('section', 'blk');\n const h = el('h3', null, title);\n if (tag) h.appendChild(el('span', 'tag', tag));\n s.appendChild(h);\n return s;\n}\n\nfunction textBlock(title, body, tag) {\n const s = block(title, tag);\n if (body) s.appendChild(el('pre', 'text', body));\n else s.appendChild(el('div', 'empty', '(empty)'));\n return s;\n}\n\nfunction foldBlock(title, body) {\n const s = block(title);\n const d = document.createElement('details');\n d.appendChild(el('summary', null, 'show'));\n d.appendChild(el('pre', null, body));\n s.appendChild(d);\n return s;\n}\n\n// An <img> never runs script, but the src is still pinned to shapes we\n// understand: a raster data: uri (svg is excluded on principle) or an http url.\nconst IMG_SUBTYPES = ['png', 'jpeg', 'jpg', 'gif', 'webp', 'avif', 'bmp'];\n\n/** Undoes the report's media hoisting; anything else passes through. */\nfunction mediaUrl(u) {\n if (typeof u !== 'string' || u.indexOf('media:') !== 0) return u;\n const v = MEDIA[Number(u.slice(6))];\n return v === undefined ? u : v;\n}\n\nfunction imageSrc(p) {\n if (p.type !== 'image') return null;\n const u = mediaUrl(p.url);\n if (typeof u !== 'string') return null;\n if (u.indexOf('https://') === 0 || u.indexOf('http://') === 0) return u;\n if (u.indexOf('data:image/') !== 0) return null;\n const sub = u.slice(11).split(/[;,]/)[0].toLowerCase();\n return IMG_SUBTYPES.indexOf(sub) < 0 ? null : u;\n}\n\n/** The rendered picture, or null when the part is not one we will inline. */\nfunction imageEl(p, thumb) {\n const src = imageSrc(p);\n if (!src) return null;\n const img = el('img', thumb ? 'media thumb' : 'media');\n img.src = src;\n img.alt = p.type + (p.mimeType ? ' ' + p.mimeType : '');\n img.loading = 'lazy';\n if (thumb) {\n img.title = 'click to enlarge';\n img.addEventListener('click', function () { img.classList.toggle('thumb'); });\n }\n return img;\n}\n\nfunction mediaBlock(title, p) {\n const tag = p.type + (p.mimeType ? ' \u00B7 ' + p.mimeType : '');\n const src = imageSrc(p);\n const img = imageEl(p, false);\n if (!img) return textBlock(title, mediaUrl(p.url), tag);\n const s = block(title, tag);\n s.appendChild(img);\n // The bytes stay reachable; they just no longer own the screen.\n const d = document.createElement('details');\n d.appendChild(el('summary', null, 'source'));\n d.appendChild(el('pre', null, src));\n s.appendChild(d);\n return s;\n}\n\n// A titled container for several blocks. Returns the element to append and the\n// body to fill, so callers never have to know the internal structure.\nfunction group(title, kind, tag) {\n const s = el('section', 'group ' + kind);\n const h = el('div', 'ghdr', title);\n if (tag) h.appendChild(el('span', 'tag', tag));\n s.appendChild(h);\n const body = el('div', 'body');\n s.appendChild(body);\n return { el: s, body: body };\n}\n\nfunction stat(key, value) {\n const d = el('span', 'stat', key + ' ');\n d.appendChild(el('b', null, value));\n return d;\n}\n\n// --- header ---------------------------------------------------------------\n\nfunction kv(key, value) {\n const d = el('div', 'kv');\n d.appendChild(document.createTextNode(key + ' '));\n d.appendChild(el('b', null, value));\n return d;\n}\n\n(function head() {\n const h = document.getElementById('head');\n const u = STATE.usage || {};\n const llm = ENTRIES.filter(function (e) { return e.node.type === 'llm_call'; }).length;\n h.appendChild(el('h1', null, DATA.title));\n h.appendChild(kv('run', STATE.runId));\n h.appendChild(kv('agent', STATE.agentName));\n h.appendChild(kv('phase', STATE.phase));\n h.appendChild(kv('nodes', ENTRIES.length));\n h.appendChild(kv('llm calls', llm));\n h.appendChild(kv('tokens', (u.inputTokens || 0) + ' in / ' + (u.outputTokens || 0) + ' out'));\n if (STATE.error) h.appendChild(kv('error', STATE.error));\n h.appendChild(kv('generated', DATA.generatedAt));\n})();\n\n// --- timing ---------------------------------------------------------------\n//\n// A node is stamped when it is appended, which is when the work behind it\n// finished. So the interval between a node and the one before it in the same\n// lane *is* that work: an llm_call's interval is the model latency, a\n// tool_result's is the tool. That gives every node a start and a duration\n// without storing either.\n//\n// Offsets are counted from the start of the turn, not of the run: in a\n// follow-up the interesting question is \"how long did this answer take\", and\n// counting from a conversation that began an hour ago answers nothing. A trunk\n// user_input opens a new turn; a branch inherits the turn that forked it, so\n// ten branches starting at one offset still read as parallel.\n\nfunction stampOf(node) {\n const t = Date.parse(node.ts);\n return isNaN(t) ? null : t;\n}\n\n(function time(level, lanePrev, turnStart) {\n let prev = lanePrev;\n let t0 = turnStart;\n level.forEach(function (e) {\n const ts = stampOf(e.node);\n // A real user turn resets the clock and starts at itself: the gap since\n // the previous run is waiting, not work. Synthetic inputs (a branch seed,\n // a re-injection) are not turns \u2014 same rule the kernel uses.\n const opensTurn = !e.branch && e.node.type === 'user_input' && !e.node.synthetic;\n if (opensTurn) t0 = ts;\n e.turnStart = t0 === null ? ts : t0;\n e.startMs = opensTurn || prev === null ? ts : prev;\n e.endMs = ts;\n // Branches run between the fork and the join, so they start where the\n // trunk left off \u2014 and the join's own duration is the whole parallel\n // section it waited on.\n if (e.node.type === 'join') {\n let slowest = null;\n e.children.forEach(function (br) {\n time(br.entries, prev, e.turnStart);\n // The branch as a whole gets the same shape as a node \u2014 start, end,\n // turn \u2014 so a reader sees how long *it* ran instead of having to\n // subtract two offsets to find out that ten branches were parallel.\n const last = br.entries[br.entries.length - 1];\n br.turnStart = e.turnStart;\n br.startMs = prev;\n br.endMs = last ? last.endMs : prev;\n if (br.entries.length) br.entries[0].branchSpan = br;\n // The join costs exactly its slowest branch, so that branch is the\n // only one worth optimizing: it gets marked and rendered lighter.\n if (br.entries.length && (!slowest || br.endMs > slowest.endMs)) slowest = br;\n });\n if (slowest) slowest.slowest = true;\n }\n prev = ts;\n });\n})(ROOT, null, null);\n\nfunction fmtSecs(ms) {\n const s = ms / 1000;\n if (s < 100) return s.toFixed(1) + 's';\n const m = Math.floor(s / 60);\n return m + 'm' + String(Math.floor(s - m * 60)).padStart(2, '0');\n}\n\n/** Offset of a node's start from the beginning of its turn. */\nfunction startedAt(e) {\n if (e.startMs === null || e.turnStart === null) return '';\n return fmtSecs(e.startMs - e.turnStart);\n}\n\n/** How long the work behind a node took. Blank when it is not worth a number. */\nfunction took(e) {\n if (e.startMs === null || e.endMs === null) return '';\n const ms = e.endMs - e.startMs;\n if (ms < 10) return '';\n return fmtDur(ms);\n}\n\nfunction fmtDur(ms) {\n return ms < 1000 ? Math.round(ms) + 'ms' : fmtSecs(ms);\n}\n\nfunction spanMs(e) {\n return e.startMs === null || e.endMs === null ? 0 : e.endMs - e.startMs;\n}\n\n// One colour per branch, stable for the life of the page. The trunk keeps the\n// neutral accent so branches are the thing that stands out.\nconst PALETTE = ['#6ea8fe', '#4ec9a0', '#e2b341', '#d1a6ff', '#f0776c',\n '#5fd0d8', '#f09a5c', '#a3d160', '#ef8ec0', '#9aa4ff'];\nconst BRANCH_COLOR = new Map();\nfunction branchColor(name) {\n if (!name) return null;\n if (!BRANCH_COLOR.has(name)) BRANCH_COLOR.set(name, PALETTE[BRANCH_COLOR.size % PALETTE.length]);\n return BRANCH_COLOR.get(name);\n}\n\n// --- timeline -------------------------------------------------------------\n\nconst listEl = document.getElementById('timeline');\nlet lastBranch = false;\nlet lastStamp = null;\n\nENTRIES.forEach(function (e, i) {\n // Branch histories are contiguous in the flattened order, so a change of\n // branch is a group boundary: label it and restart the elapsed column.\n if (e.branch !== lastBranch) {\n const sep = el('li', 'sep');\n const color = branchColor(e.branch);\n if (color) sep.style.setProperty('--bc', color);\n sep.appendChild(el('span', 'dot'));\n sep.appendChild(el('span', null, e.branch ? 'branch \u00B7 ' + e.branch : 'trunk'));\n if (e.branchSpan) {\n const bd = took(e.branchSpan);\n if (e.branchSpan.slowest) sep.classList.add('slow');\n if (bd) {\n sep.appendChild(el('span', 'dur',\n 't+' + startedAt(e.branchSpan) + ' \u00B7 ' + bd + (e.branchSpan.slowest ? ' \u00B7 slowest' : '')));\n }\n }\n listEl.appendChild(sep);\n lastBranch = e.branch;\n lastStamp = null;\n }\n\n const li = el('li', e.branch ? 'branch' : '');\n if (e.covered) li.classList.add('covered');\n const color = branchColor(e.branch);\n if (color) li.style.setProperty('--bc', color);\n li.dataset.key = e.key;\n\n const row = el('div', 'row');\n // Consecutive nodes often start at the same instant (a tool call is recorded\n // the moment the response carrying it is applied). Repeating the offset\n // would only bury the moments where time actually moved.\n const at = startedAt(e);\n row.appendChild(el('span', 't' + (at === lastStamp ? ' same' : ''), at === lastStamp ? '\u00B7' : at));\n lastStamp = at;\n row.appendChild(el('span', 'idx', '#' + (i + 1)));\n const badge = el('span', 'type ' + e.node.type\n + (e.node.type === 'tool_result' && e.node.isError ? ' err' : ''), e.node.type.replace(/_/g, ' '));\n row.appendChild(badge);\n const d = took(e);\n if (d) row.appendChild(el('span', 'dur', d));\n li.appendChild(row);\n li.appendChild(el('div', 'prev', label(e.node)\n + (preview(e.node) ? ' \u2014 ' + preview(e.node).slice(0, 90) : '')));\n li.addEventListener('click', function () { select(e.key, true); });\n listEl.appendChild(li);\n});\n\n// A group header is only worth showing while something in its group is.\nfunction applyFilter(q) {\n const rows = Array.prototype.slice.call(listEl.children);\n rows.forEach(function (li) {\n if (li.classList.contains('sep')) return;\n const e = BY_KEY.get(li.dataset.key);\n const hay = (label(e.node) + ' ' + e.node.type + ' ' + (e.branch || '')\n + ' ' + preview(e.node)).toLowerCase();\n li.hidden = q.length > 0 && hay.indexOf(q) === -1;\n });\n let sep = null, kept = false;\n rows.forEach(function (li) {\n if (li.classList.contains('sep')) {\n if (sep) sep.hidden = !kept;\n sep = li; kept = false;\n } else if (!li.hidden) kept = true;\n });\n if (sep) sep.hidden = !kept;\n}\n\ndocument.getElementById('filter').addEventListener('input', function (ev) {\n applyFilter(ev.target.value.toLowerCase());\n});\n\n// --- detail ---------------------------------------------------------------\n\nfunction message(m) {\n const wrap = el('div', 'msg ' + m.role + (m.isError ? ' err' : ''));\n const role = el('div', 'role');\n role.appendChild(el('span', null, m.role + (m.agent ? ' \u00B7 ' + m.agent : '')\n + (m.name ? ' \u00B7 ' + m.name : '')));\n if (m.callId) role.appendChild(el('span', 'badge', m.callId));\n wrap.appendChild(role);\n\n if (m.role === 'user' && Array.isArray(m.content)) {\n m.content.forEach(function (p) {\n if (p.type === 'text') { wrap.appendChild(el('pre', 'text', p.text)); return; }\n const img = imageEl(p, true);\n wrap.appendChild(img || el('pre', 'text', '[' + p.type + '] ' + mediaUrl(p.url)));\n });\n } else if (typeof m.content === 'string' && m.content) {\n wrap.appendChild(el('pre', 'text', m.role === 'tool' ? pretty(m.content) : m.content));\n } else if (!m.toolCalls) {\n wrap.appendChild(el('div', 'empty', '(no content)'));\n }\n\n (m.toolCalls || []).forEach(function (c) {\n const box = el('div', 'call');\n box.appendChild(el('div', 'name', c.name + '(' + c.id + ')'));\n box.appendChild(el('pre', null, pretty(c.args)));\n wrap.appendChild(box);\n });\n return wrap;\n}\n\nfunction requestBlocks(raw, into) {\n let req;\n try { req = JSON.parse(raw); } catch (e) {\n into.appendChild(textBlock('Request (unparsed)', raw));\n return;\n }\n if (req.system) into.appendChild(textBlock('System prompt', req.system)).classList.add('sys');\n\n const conv = block('Messages', (req.messages || []).length + ' messages');\n (req.messages || []).forEach(function (m) { conv.appendChild(message(m)); });\n if (!(req.messages || []).length) conv.appendChild(el('div', 'empty', '(none)'));\n into.appendChild(conv);\n\n const tools = block('Tools offered', (req.tools || []).length + ' tools');\n (req.tools || []).forEach(function (t) {\n const d = document.createElement('details');\n d.appendChild(el('summary', null, t.name + (t.description ? ' \u2014 ' + t.description : '')));\n d.appendChild(el('pre', null, JSON.stringify(t.parameters, null, 2)));\n tools.appendChild(d);\n });\n if (!(req.tools || []).length) tools.appendChild(el('div', 'empty', '(none)'));\n into.appendChild(tools);\n}\n\nfunction usageTag(u) {\n if (!u) return '';\n return (u.inputTokens || 0) + ' in (' + (u.cachedInputTokens || 0) + ' cached) / '\n + (u.outputTokens || 0) + ' out (' + (u.reasoningTokens || 0) + ' reasoning)';\n}\n\nfunction detailFor(e) {\n const n = e.node;\n const out = document.createDocumentFragment();\n\n const h = el('h2', 'dt');\n h.appendChild(el('span', 'type ' + n.type, n.type.replace(/_/g, ' ')));\n h.appendChild(el('span', null, label(n)));\n out.appendChild(h);\n\n const meta = el('div', 'meta');\n meta.appendChild(el('span', null, 'id ' + n.id));\n meta.appendChild(el('span', null, 'agent ' + n.agent));\n meta.appendChild(el('span', null, n.ts));\n meta.appendChild(el('span', null, 'started t+' + startedAt(e) + (took(e) ? ' \u00B7 took ' + took(e) : '')));\n if (e.branch) {\n const br = el('span', null, 'branch ' + e.branch);\n br.style.color = branchColor(e.branch);\n meta.appendChild(br);\n }\n if (e.covered) meta.appendChild(el('span', null, 'hidden by a later compaction'));\n out.appendChild(meta);\n\n switch (n.type) {\n case 'system_prompt':\n out.appendChild(textBlock('Prompt', blob(n.prompt)));\n // The editable half: which file to open when the instruction is wrong.\n (n.sources || []).forEach(function (s) {\n out.appendChild(textBlock(s.path, blob(s.content), 'prompt file'));\n });\n break;\n\n case 'user_input':\n n.content.forEach(function (p, i) {\n out.appendChild(p.type === 'text'\n ? textBlock('Text part ' + (i + 1), blob(p.text))\n : mediaBlock('Media part ' + (i + 1), p));\n });\n break;\n\n case 'load_skills':\n out.appendChild(textBlock('Skills', n.skills.map(function (s) {\n return s.name + (s.version ? '@' + s.version : '') + ' ' + s.contentHash.slice(0, 12)\n + (s.file ? ' ' + s.file : '');\n }).join('\\n'), n.provider));\n out.appendChild(textBlock('Injected instructions', blob(n.content)));\n out.appendChild(textBlock('Tools unlocked', n.toolNames.join('\\n')));\n break;\n\n case 'memory_recall':\n out.appendChild(textBlock('Query', JSON.stringify(n.query, null, 2),\n n.seeds.length + ' seed(s)'));\n out.appendChild(textBlock('Subgraph', n.nodes.map(function (m) {\n return m.id + ' ' + m.kind\n + (n.seeds.indexOf(m.id) >= 0 ? ' score=' + m.score.toFixed(3) : ' (stitched)');\n }).join('\\n') + edgeLines(n.edges)));\n out.appendChild(textBlock('Injected block', blob(n.content)));\n break;\n\n case 'memory_op':\n out.appendChild(textBlock('Operation', n.nodes.map(function (m) {\n return n.op + ' ' + m.id + ' ' + m.kind + ' rev ' + m.revision;\n }).join('\\n') + edgeLines(n.edges),\n n.files ? n.files + ' file(s)' : undefined));\n break;\n\n case 'llm_call': {\n const u = n.usage || {};\n const strip = el('div', 'stats');\n strip.appendChild(stat('model', n.model));\n strip.appendChild(stat('in', u.inputTokens || 0));\n strip.appendChild(stat('cached', u.cachedInputTokens || 0));\n strip.appendChild(stat('out', u.outputTokens || 0));\n if (u.reasoningTokens) strip.appendChild(stat('reasoning', u.reasoningTokens));\n strip.appendChild(stat('stop', n.stopReason || '?'));\n out.appendChild(strip);\n\n const req = group('Request', 'req', 'sent to ' + n.model);\n if (n.request) requestBlocks(blob(n.request), req.body);\n else {\n req.body.appendChild(el('div', 'empty',\n 'not recorded \u2014 construct the runner with { recordRequests: true } to capture it'));\n }\n out.appendChild(req.el);\n\n const res = group('Response', 'res', n.stopReason);\n if (n.thinking) res.body.appendChild(textBlock('Reasoning', blob(n.thinking)));\n res.body.appendChild(textBlock('Text', blob(n.text)));\n n.toolCalls.forEach(function (c) {\n res.body.appendChild(textBlock('Tool call \u00B7 ' + c.name, pretty(blob(c.args)), c.callId));\n });\n out.appendChild(res.el);\n\n out.appendChild(textBlock('Request digest', n.requestDigest || '(not recorded)'));\n break;\n }\n\n case 'tool_call':\n out.appendChild(textBlock('Arguments', pretty(blob(n.args)), n.name + ' \u00B7 ' + n.callId));\n break;\n\n case 'tool_result':\n out.appendChild(textBlock(n.isError ? 'Error result' : 'Result',\n pretty(blob(n.result)),\n n.name + (n.durationMs !== undefined ? ' \u00B7 ' + n.durationMs + 'ms' : '')));\n break;\n\n case 'handoff':\n out.appendChild(textBlock('Handoff', n.from + ' -> ' + n.to\n + (n.reason ? '\\n\\nreason: ' + n.reason : '')));\n break;\n\n case 'fork':\n n.branches.forEach(function (b) {\n out.appendChild(textBlock('Branch \u00B7 ' + b.name, blob(b.instructions),\n b.agent + ' \u00B7 ' + n.contextMode + ' \u00B7 ' + b.childRunId));\n });\n break;\n\n case 'join':\n n.branches.forEach(function (b, i) {\n const br = e.children[i];\n const bd = br ? took(br) : '';\n out.appendChild(textBlock('Branch \u00B7 ' + b.name,\n b.status === 'ok' ? blob(b.output) : (b.error || blob(b.output)),\n b.status + ' \u00B7 ' + b.agent + ' \u00B7 ' + b.nodes.length + ' nodes'\n + (bd ? ' \u00B7 ' + bd : '') + ' \u00B7 ' + usageTag(b.usage)));\n });\n break;\n\n case 'compaction': {\n out.appendChild(textBlock('Summary', blob(n.summary), n.reason));\n const cov = block('Replaces', n.covers.length + ' nodes');\n const line = el('div', 'empty', '');\n line.textContent = '';\n n.covers.forEach(function (id) {\n const hit = ENTRIES.find(function (x) { return x.node.id === id; });\n const b = el('button', 'link', hit ? label(hit.node) : id);\n if (hit) b.addEventListener('click', function () { select(hit.key, true); });\n line.appendChild(b);\n });\n cov.appendChild(line);\n out.appendChild(cov);\n out.appendChild(textBlock('Summarizer usage', usageTag(n.usage)));\n break;\n }\n\n case 'final_output':\n out.appendChild(textBlock('Output', blob(n.output)));\n if (n.parsed !== undefined) out.appendChild(textBlock('Parsed', JSON.stringify(n.parsed, null, 2)));\n break;\n }\n\n out.appendChild(foldBlock('Raw node', JSON.stringify(n, null, 2)));\n return out;\n}\n\nconst detailEl = document.getElementById('detail');\nconst statsEl = document.getElementById('stats');\nlet selected = null;\n\nfunction select(key, focus) {\n const e = BY_KEY.get(key);\n if (!e) return;\n selected = key;\n detailEl.replaceChildren(detailFor(e));\n Array.prototype.forEach.call(listEl.children, function (li) {\n li.classList.toggle('sel', li.dataset.key === key);\n });\n const li = listEl.querySelector('li[data-key=\"' + key + '\"]');\n if (li) li.scrollIntoView({ block: 'nearest' });\n if (focus) show('detail');\n detailEl.scrollTop = 0;\n}\n\n// --- stats ----------------------------------------------------------------\n//\n// Tokens are not fungible across models and seconds are not fungible across\n// lanes: a thousand tokens cost a different amount on a different model, and\n// branch time runs in parallel with other branch time. So every number here\n// is broken down by model and by agent, and the wall clock sits next to the\n// sums so the gap between them is visible instead of misleading.\n\nfunction bucket() {\n return { calls: 0, errors: 0, ms: 0, in: 0, cached: 0, out: 0, reasoning: 0 };\n}\n\nfunction bucketOf(map, key) {\n if (!map.has(key)) map.set(key, bucket());\n return map.get(key);\n}\n\nfunction addUsage(into, u) {\n if (!u) return;\n into.in += u.inputTokens || 0;\n into.cached += u.cachedInputTokens || 0;\n into.out += u.outputTokens || 0;\n into.reasoning += u.reasoningTokens || 0;\n}\n\nfunction num(n) { return n.toLocaleString('en-US'); }\n\nfunction table(cols, rows, foot) {\n const t = el('table', 'st');\n const head = document.createElement('thead');\n const hr = document.createElement('tr');\n cols.forEach(function (c) { hr.appendChild(el('th', null, c)); });\n head.appendChild(hr);\n t.appendChild(head);\n const body = document.createElement('tbody');\n rows.forEach(function (r) {\n const tr = document.createElement('tr');\n r.forEach(function (v) { tr.appendChild(el('td', null, v)); });\n body.appendChild(tr);\n });\n t.appendChild(body);\n if (foot) {\n const tf = document.createElement('tfoot');\n const fr = document.createElement('tr');\n foot.forEach(function (v) { fr.appendChild(el('td', null, v)); });\n tf.appendChild(fr);\n t.appendChild(tf);\n }\n return t;\n}\n\nconst TOKEN_COLS = ['calls', 'in', 'cached', 'out', 'reasoning', 'time', 'avg'];\n\nfunction tokenRow(name, b) {\n return [name, num(b.calls), num(b.in), num(b.cached), num(b.out), num(b.reasoning),\n fmtDur(b.ms), b.calls ? fmtDur(b.ms / b.calls) : '\u2014'];\n}\n\nfunction byTokens(a, b) { return (b[1].in + b[1].out) - (a[1].in + a[1].out); }\n\nfunction buildStats() {\n const byModel = new Map(), byAgent = new Map(), byTool = new Map(), byType = new Map();\n const all = bucket();\n let llmMs = 0, toolMs = 0, toolCalls = 0, from = null, to = null;\n\n ENTRIES.forEach(function (e) {\n const n = e.node;\n byType.set(n.type, (byType.get(n.type) || 0) + 1);\n if (e.startMs !== null && (from === null || e.startMs < from)) from = e.startMs;\n if (e.endMs !== null && (to === null || e.endMs > to)) to = e.endMs;\n\n // A join's usage is the sum of its branches, whose nodes are already in\n // this list, so counting it too would double every branch token. A\n // compaction has no model of its own but did burn a summarizer.\n if (n.type === 'llm_call' || n.type === 'compaction') {\n const ms = spanMs(e);\n const model = n.type === 'compaction' ? 'summarizer' : n.model;\n [bucketOf(byModel, model), bucketOf(byAgent, n.agent), all].forEach(function (b) {\n addUsage(b, n.usage);\n b.calls += 1;\n b.ms += ms;\n });\n llmMs += ms;\n return;\n }\n // The tool's own measurement when the runner took one; the gap to the\n // previous node otherwise.\n if (n.type === 'tool_result') {\n const b = bucketOf(byTool, n.name);\n const ms = n.durationMs !== undefined ? n.durationMs : spanMs(e);\n b.calls += 1;\n b.ms += ms;\n if (n.isError) b.errors += 1;\n toolCalls += 1;\n toolMs += ms;\n }\n });\n\n const pills = el('div', 'stats');\n pills.appendChild(stat('wall clock', from === null ? '\u2014' : fmtDur(to - from)));\n pills.appendChild(stat('in llm', fmtDur(llmMs)));\n pills.appendChild(stat('in tools', fmtDur(toolMs)));\n pills.appendChild(stat('llm calls', num(all.calls)));\n pills.appendChild(stat('tool calls', num(toolCalls)));\n pills.appendChild(stat('tokens', num(all.in) + ' in / ' + num(all.out) + ' out'));\n pills.appendChild(stat('cached', num(all.cached)));\n pills.appendChild(stat('nodes', num(ENTRIES.length)));\n statsEl.replaceChildren(pills);\n\n const note = el('div', 'hint', 'Branches run in parallel, so llm and tool time are sums '\n + 'over every lane and add up to more than the wall clock.');\n note.style.marginBottom = '14px';\n statsEl.appendChild(note);\n\n const models = Array.from(byModel.entries()).sort(byTokens);\n const modelBlock = block('Tokens by model', models.length + (models.length === 1 ? ' model' : ' models'));\n modelBlock.appendChild(table(['model'].concat(TOKEN_COLS),\n models.map(function (m) { return tokenRow(m[0], m[1]); }), tokenRow('total', all)));\n statsEl.appendChild(modelBlock);\n\n const agents = Array.from(byAgent.entries()).sort(byTokens);\n const agentBlock = block('Tokens by agent', agents.length + (agents.length === 1 ? ' agent' : ' agents'));\n agentBlock.appendChild(table(['agent'].concat(TOKEN_COLS),\n agents.map(function (a) { return tokenRow(a[0], a[1]); }), tokenRow('total', all)));\n statsEl.appendChild(agentBlock);\n\n const tools = Array.from(byTool.entries()).sort(function (a, b) { return b[1].ms - a[1].ms; });\n const toolBlock = block('Tool calls', num(toolCalls) + ' calls \u00B7 ' + fmtDur(toolMs));\n if (tools.length) {\n toolBlock.appendChild(table(['tool', 'calls', 'errors', 'time', 'avg'],\n tools.map(function (t) {\n const b = t[1];\n return [t[0], num(b.calls), b.errors ? num(b.errors) : '', fmtDur(b.ms),\n b.calls ? fmtDur(b.ms / b.calls) : '\u2014'];\n })));\n } else {\n toolBlock.appendChild(el('div', 'empty', '(none)'));\n }\n statsEl.appendChild(toolBlock);\n\n const types = Array.from(byType.entries()).sort(function (a, b) { return b[1] - a[1]; });\n const typeBlock = block('Nodes by type', num(ENTRIES.length) + ' nodes');\n typeBlock.appendChild(table(['type', 'count'],\n types.map(function (t) { return [t[0].replace(/_/g, ' '), num(t[1])]; })));\n statsEl.appendChild(typeBlock);\n}\n\n// --- views ----------------------------------------------------------------\n\n// Panes that hold a zoomable diagram, by view name. A pane laid out while it\n// was hidden has no width to fit against, so becoming visible is the moment it\n// gets a second chance.\nconst PANES = {};\n\nfunction show(view) {\n document.getElementById('graph').hidden = view !== 'graph';\n document.getElementById('agents').hidden = view !== 'agents';\n detailEl.hidden = view !== 'detail';\n statsEl.hidden = view !== 'stats';\n Array.prototype.forEach.call(document.getElementById('viewtabs').children, function (b) {\n b.classList.toggle('on', b.dataset.view === view);\n });\n if (PANES[view]) PANES[view].refit();\n}\nArray.prototype.forEach.call(document.getElementById('viewtabs').children, function (b) {\n b.addEventListener('click', function () { show(b.dataset.view); });\n});\n\n// --- zoom and pan ---------------------------------------------------------\n//\n// A diagram grows with what it describes, so a fixed scale is useless past a\n// dozen nodes. The canvas is moved with a CSS transform rather than scrolled:\n// that keeps zooming anchored on the pointer and costs no layout.\n//\n// Written as a factory because there are two of these \u2014 the run graph and the\n// architecture \u2014 and they must not share a scale, a pan or a drag.\n\nconst MIN_ZOOM = 0.1;\nconst MAX_ZOOM = 4;\n\nfunction panZoom(paneId) {\n const pane = document.getElementById(paneId);\n const viewportEl = pane.querySelector('.viewport');\n const canvasEl = pane.querySelector('.canvas');\n const zoomEl = pane.querySelector('.lvl');\n let zoom = 1, panX = 0, panY = 0;\n let natW = 0, natH = 0;\n let pendingFit = false;\n\n function applyView() {\n canvasEl.style.transform = 'translate(' + panX + 'px,' + panY + 'px) scale(' + zoom + ')';\n zoomEl.textContent = Math.round(zoom * 100) + '%';\n }\n\n function zoomAt(factor, cx, cy) {\n const next = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom * factor));\n const ratio = next / zoom;\n // Keep the point under the cursor fixed while the scale changes.\n panX = cx - (cx - panX) * ratio;\n panY = cy - (cy - panY) * ratio;\n zoom = next;\n applyView();\n }\n\n function zoomCenter(factor) {\n const r = viewportEl.getBoundingClientRect();\n zoomAt(factor, r.width / 2, r.height / 2);\n }\n\n function fit() {\n const r = viewportEl.getBoundingClientRect();\n if (!r.width || !natW || !natH) { pendingFit = true; return; }\n pendingFit = false;\n zoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM,\n Math.min((r.width - 32) / natW, (r.height - 32) / natH)));\n panX = (r.width - natW * zoom) / 2;\n panY = Math.max(16, (r.height - natH * zoom) / 2);\n applyView();\n }\n\n pane.querySelector('.gtools').addEventListener('click', function (ev) {\n const what = ev.target.dataset ? ev.target.dataset.zoom : null;\n if (what === 'in') zoomCenter(1.25);\n else if (what === 'out') zoomCenter(1 / 1.25);\n else if (what === 'fit') fit();\n else if (what === 'reset') { zoom = 1; panX = 16; panY = 16; applyView(); }\n });\n\n viewportEl.addEventListener('wheel', function (ev) {\n ev.preventDefault();\n const r = viewportEl.getBoundingClientRect();\n zoomAt(Math.exp(-ev.deltaY * 0.002), ev.clientX - r.left, ev.clientY - r.top);\n }, { passive: false });\n\n let dragging = false, dragMoved = false, startX = 0, startY = 0, baseX = 0, baseY = 0;\n\n viewportEl.addEventListener('pointerdown', function (ev) {\n if (ev.button !== 0) return;\n dragging = true; dragMoved = false;\n startX = ev.clientX; startY = ev.clientY; baseX = panX; baseY = panY;\n });\n viewportEl.addEventListener('pointermove', function (ev) {\n if (!dragging) return;\n const dx = ev.clientX - startX, dy = ev.clientY - startY;\n if (!dragMoved) {\n if (Math.abs(dx) + Math.abs(dy) <= 4) return;\n dragMoved = true;\n // Captured only once this is a drag: capturing on pointerdown would\n // retarget the following click to the viewport and no node would ever\n // receive it.\n viewportEl.setPointerCapture(ev.pointerId);\n viewportEl.classList.add('drag');\n }\n panX = baseX + dx; panY = baseY + dy;\n applyView();\n });\n function endDrag(ev) {\n if (!dragging) return;\n dragging = false;\n viewportEl.classList.remove('drag');\n if (viewportEl.hasPointerCapture(ev.pointerId)) viewportEl.releasePointerCapture(ev.pointerId);\n }\n viewportEl.addEventListener('pointerup', endDrag);\n viewportEl.addEventListener('pointercancel', endDrag);\n viewportEl.addEventListener('dblclick', function () { fit(); });\n window.addEventListener('resize', function () { if (pendingFit) fit(); });\n\n return {\n canvas: canvasEl,\n setNatural: function (w, h) { natW = w; natH = h; fit(); },\n refit: function () { if (pendingFit) fit(); },\n // A pan that ends on a node must not be read as a click on it.\n moved: function () { return dragMoved; }\n };\n}\n\nPANES.graph = panZoom('graph');\nPANES.agents = panZoom('agents');\n\n// --- diagram --------------------------------------------------------------\n\nconst CLASSES = {\n llm_call: 'llm', tool_call: 'tool', tool_result: 'tool', compaction: 'comp',\n fork: 'fork', join: 'fork', final_output: 'final', handoff: 'hand'\n};\n\n// A failed call is the one thing in a trace people look for, so it is coloured\n// by outcome rather than by type \u2014 the same red the sidebar badge uses.\nfunction nodeClass(n) {\n if (n.type === 'tool_result' && n.isError) return 'err';\n return CLASSES[n.type];\n}\n\n// Diagram labels are built from node types and identifiers only, and are\n// stripped to a conservative character set: nothing a model wrote can reach\n// the Mermaid parser, let alone the SVG.\nfunction safe(text) {\n return String(text).replace(/[^\\w .:\\-\\/>+=()]/g, ' ').slice(0, 60).trim();\n}\n\nfunction shape(e, i) {\n const n = e.node;\n // The timing is ours, not the run's: digits and a unit, appended after\n // sanitizing. It stays on one line because Mermaid's strict mode drops the\n // <br/> that would break it.\n const d = took(e);\n const text = '\"' + (i + 1) + '. ' + safe(label(n))\n + ' t+' + startedAt(e) + (d ? ' \u00B7 ' + d : '') + '\"';\n if (n.type === 'fork' || n.type === 'join') return e.key + '{{' + text + '}}';\n if (n.type === 'final_output') return e.key + '([' + text + '])';\n if (n.type === 'user_input' || n.type === 'system_prompt') return e.key + '[/' + text + '/]';\n return e.key + '[' + text + ']';\n}\n\nfunction chain(level, lines) {\n let prev = null;\n level.forEach(function (e) {\n const i = ENTRIES.indexOf(e);\n lines.push(' ' + shape(e, i));\n const cls = nodeClass(e.node);\n if (cls) lines.push(' class ' + e.key + ' ' + cls + ';');\n if (e.covered) lines.push(' class ' + e.key + ' covered;');\n if (prev) lines.push(' ' + prev.key + ' --> ' + e.key);\n\n if (e.node.type === 'join' && e.children.length) {\n // A branch hangs off the fork that opened it and rejoins at the join.\n const fork = level.find(function (x) {\n return x.node.type === 'fork' && x.node.callId === e.node.callId;\n });\n e.children.forEach(function (br, bi) {\n if (!br.entries.length) return;\n const bd = took(br);\n const sg = 'sg_' + e.key + '_' + bi;\n lines.push(' subgraph ' + sg + '[\"' + safe(br.name)\n + ' t+' + startedAt(br) + (bd ? ' \u00B7 ' + bd : '')\n + (br.slowest ? ' \u00B7 slowest' : '') + '\"]');\n lines.push(' direction TB');\n chain(br.entries, lines);\n lines.push(' end');\n // The one branch the join actually waited for, lifted out of the pack.\n if (br.slowest) lines.push(' style ' + sg + ' fill:#5b6070,stroke:#a9b2c6,color:#f2f4f8;');\n if (fork) lines.push(' ' + fork.key + ' -.-> ' + br.entries[0].key);\n lines.push(' ' + br.entries[br.entries.length - 1].key + ' -.-> ' + e.key);\n });\n }\n prev = e;\n });\n}\n\nfunction diagram() {\n const lines = ['flowchart TD'];\n lines.push(' classDef llm fill:#1e3357,stroke:#3d5f9e,color:#cfe0ff;');\n lines.push(' classDef tool fill:#1e3d33,stroke:#3d7a66,color:#c8f0e2;');\n lines.push(' classDef comp fill:#40331a,stroke:#7a6535,color:#f0dfb4;');\n lines.push(' classDef fork fill:#3a2450,stroke:#6b4b90,color:#e6d3ff;');\n lines.push(' classDef final fill:#14392c,stroke:#2f7a5c,color:#c6f5e0;');\n lines.push(' classDef hand fill:#3d2a1b,stroke:#7a5535,color:#f2d9c2;');\n lines.push(' classDef err fill:#4a1f1c,stroke:#8a3a33,color:#f0776c;');\n lines.push(' classDef covered opacity:0.45,stroke-dasharray:4 3;');\n chain(ROOT, lines);\n return lines.join('\\n');\n}\n\n// Mermaid refuses a diagram past either of these (defaults 500 edges and\n// 50000 characters, which a long run passes easily) and the pane then shows\n// only its error. Both are \"secure\" options, so initialize() is the one place\n// they can be raised from. The report is a file on disk, not a live page \u2014\n// a slow render beats a refused one.\nconst MAX_EDGES = 20000;\nconst MAX_TEXT_SIZE = 5000000;\n\n// One Mermaid, two diagrams. Loading it is the only network the page does, so\n// it happens once and both panes wait on the same promise.\nlet mermaidLib = null;\nfunction mermaid() {\n if (!mermaidLib) {\n mermaidLib = import(MERMAID_URL).then(function (m) {\n m.default.initialize({\n startOnLoad: false, theme: 'dark', securityLevel: 'strict',\n maxEdges: MAX_EDGES, maxTextSize: MAX_TEXT_SIZE,\n flowchart: { htmlLabels: false }\n });\n return m.default;\n });\n }\n return mermaidLib;\n}\n\n/** Renders one diagram into a pane and wires its nodes. False when it failed. */\nasync function draw(pane, id, source, onNode) {\n let lib;\n try {\n lib = await mermaid();\n } catch (err) {\n pane.canvas.replaceChildren(el('div', 'hint pad',\n 'Mermaid could not be loaded (offline?). The timeline on the left has the same nodes.'));\n return false;\n }\n try {\n const rendered = await lib.render(id, source);\n // Mermaid produced this markup from labels we sanitized above; no payload\n // text reaches it.\n pane.canvas.innerHTML = rendered.svg;\n } catch (err) {\n pane.canvas.replaceChildren(el('pre', null, 'diagram failed: ' + err.message + '\\n\\n' + source));\n return false;\n }\n const svg = pane.canvas.querySelector('svg');\n if (svg) {\n // Mermaid sizes the SVG to fit its container; we want its natural size so\n // the transform above is the only thing deciding scale.\n const box = svg.viewBox && svg.viewBox.baseVal;\n const w = (box && box.width) || svg.getBoundingClientRect().width;\n const h = (box && box.height) || svg.getBoundingClientRect().height;\n svg.removeAttribute('width');\n svg.removeAttribute('height');\n svg.style.maxWidth = 'none';\n svg.style.width = w + 'px';\n svg.style.height = h + 'px';\n pane.setNatural(w, h);\n }\n pane.canvas.querySelectorAll('.node').forEach(onNode);\n return true;\n}\n\nasync function drawGraph() {\n const ok = await draw(PANES.graph, 'run-graph', diagram(), function (g) {\n // Mermaid ids are '<renderId>-flowchart-<ourKey>-<seq>'; only the key matters.\n const m = /-(n\\d+)-\\d+$/.exec(g.id || '');\n const key = m ? m[1] : null;\n if (!key || !BY_KEY.has(key)) return;\n g.addEventListener('click', function () { if (!PANES.graph.moved()) select(key, true); });\n });\n if (!ok) show('detail');\n}\n\n// --- architecture ---------------------------------------------------------\n//\n// The run graph answers \"what happened\". This one answers \"what was wired up\",\n// which is the question a trajectory structurally cannot: the agent nobody\n// handed off to and the tool nobody called leave no node behind. The declared\n// wiring arrives with the report when a runner was there to describe itself;\n// otherwise the run is read backwards into the same shape, which is smaller\n// but never wrong.\n\nconst SEP = '::';\n\nfunction bump(map, key, entryKey) {\n const cur = map.get(key);\n if (cur) { cur.count += 1; return; }\n map.set(key, { count: 1, key: entryKey });\n}\n\n// What this run actually exercised, keyed the same way the diagram names\n// things \u2014 so drawing \"used\" is a lookup rather than a search.\nconst USED = {\n agents: new Map(), models: new Map(), tools: new Map(),\n skills: new Map(), handoffs: new Map(), memory: new Map(), forks: new Map()\n};\n\nENTRIES.forEach(function (e) {\n const n = e.node;\n bump(USED.agents, n.agent, e.key);\n if (n.type === 'llm_call') {\n if (!USED.models.has(n.agent)) USED.models.set(n.agent, n.model);\n } else if (n.type === 'tool_call') {\n bump(USED.tools, n.agent + SEP + n.name, e.key);\n } else if (n.type === 'load_skills') {\n n.skills.forEach(function (s) { bump(USED.skills, n.agent + SEP + s.name, e.key); });\n } else if (n.type === 'handoff') {\n bump(USED.handoffs, n.from + SEP + n.to, e.key);\n } else if (n.type === 'memory_recall' || n.type === 'memory_op') {\n bump(USED.memory, n.agent, e.key);\n } else if (n.type === 'fork') {\n n.branches.forEach(function (b) { bump(USED.forks, n.agent + SEP + b.agent, e.key); });\n }\n});\n\nfunction has(list, name) {\n return list.some(function (x) { return x.name === name; });\n}\n\n/** The run read backwards into the same shape. Used when none was supplied. */\nfunction observedArchitecture() {\n const byName = new Map();\n function agentOf(name) {\n if (!byName.has(name)) {\n byName.set(name, { name: name, tools: [], handoffs: [] });\n }\n return byName.get(name);\n }\n ENTRIES.forEach(function (e) {\n const n = e.node;\n const a = agentOf(n.agent);\n if (n.type === 'llm_call') {\n if (!a.model) a.model = n.model;\n } else if (n.type === 'tool_call') {\n // Handoff tools are drawn as edges between agents, never as tool nodes.\n const handoff = n.name.indexOf('transfer_to_') === 0;\n if (!handoff && !has(a.tools, n.name)) a.tools.push({ name: n.name });\n } else if (n.type === 'load_skills') {\n if (!a.skills) a.skills = { provider: n.provider, discovery: 'index', catalog: [] };\n n.skills.forEach(function (s) {\n if (has(a.skills.catalog, s.name)) return;\n // The unlocked tools are recorded per activation, not per skill, so\n // they can only be attributed when one skill loaded on its own.\n a.skills.catalog.push({\n name: s.name, version: s.version,\n toolNames: n.skills.length === 1 ? n.toolNames : undefined\n });\n });\n } else if (n.type === 'handoff') {\n const from = agentOf(n.from);\n agentOf(n.to);\n if (from.handoffs.indexOf(n.to) < 0) from.handoffs.push(n.to);\n } else if (n.type === 'memory_recall' || n.type === 'memory_op') {\n // A run shows that an agent reached the memory, never how wide its mask\n // was: nothing it did not read leaves a trace to read the mask off.\n if (!a.memory) a.memory = { access: 'read', sees: [], writes: [] };\n if (n.type === 'memory_op') a.memory.access = 'read-write';\n } else if (n.type === 'fork') {\n if (!a.fork) a.fork = { agents: [] };\n n.branches.forEach(function (b) {\n agentOf(b.agent);\n if (a.fork.agents.indexOf(b.agent) < 0) a.fork.agents.push(b.agent);\n });\n }\n });\n return { source: 'observed', agents: Array.from(byName.values()) };\n}\n\nconst ARCH = DATA.architecture || observedArchitecture();\n\n// Diagram key -> the first trajectory node that exercised the thing it names,\n// or null when nothing did. Clicking is only offered for the former.\nconst ARCH_KEYS = new Map();\nlet archSeq = 0;\nfunction archKey(used) {\n const k = 'x' + archSeq++;\n ARCH_KEYS.set(k, used ? used.key : null);\n return k;\n}\n\n/** An agent with more tools than this gets a \"+N more\" node instead of a wall. */\nconst MAX_TOOLS = 12;\n\nfunction times(used) {\n return used ? ' x' + used.count : '';\n}\n\nfunction archDiagram() {\n const lines = ['flowchart LR'];\n lines.push(' classDef agent fill:#1e3357,stroke:#3d5f9e,color:#cfe0ff;');\n lines.push(' classDef start fill:#14392c,stroke:#2f7a5c,color:#c6f5e0;');\n lines.push(' classDef tool fill:#1e3d33,stroke:#3d7a66,color:#c8f0e2;');\n lines.push(' classDef skill fill:#3a2450,stroke:#6b4b90,color:#e6d3ff;');\n lines.push(' classDef mem fill:#40331a,stroke:#7a6535,color:#f0dfb4;');\n lines.push(' classDef more fill:#1b202c,stroke:#39415a,color:#8a92a6;');\n lines.push(' classDef idle opacity:0.4,stroke-dasharray:4 3;');\n\n const startAgent = (STATE.spec && STATE.spec.startAgent) || STATE.agentName;\n const selfKey = new Map();\n // Minted up front so a hand-off can name an agent declared further down.\n ARCH.agents.forEach(function (a) {\n selfKey.set(a.name, archKey(USED.agents.get(a.name)));\n });\n\n ARCH.agents.forEach(function (a) {\n const self = selfKey.get(a.name);\n const live = USED.agents.get(a.name);\n // The model on the node is the declared one, or \u2014 when the report carries\n // no wiring \u2014 whatever this run was seen using.\n const model = a.model || USED.models.get(a.name);\n lines.push(' subgraph sg_' + self + '[\"' + safe(a.name)\n + (a.description ? ' - ' + safe(a.description) : '') + '\"]');\n lines.push(' direction LR');\n lines.push(' ' + self + '[[\"' + safe(a.name)\n + (model ? ' ' + safe(model) + (a.inheritedModel ? ' (inherited)' : '') : '')\n + '\"]]');\n lines.push(' class ' + self + ' ' + (a.name === startAgent ? 'start' : 'agent') + ';');\n if (!live) lines.push(' class ' + self + ' idle;');\n\n // Skills first: a skill owns tools, so its node has to exist before the\n // tools it unlocks can point back at it.\n const skillKey = new Map();\n const catalog = (a.skills && a.skills.catalog) || [];\n catalog.forEach(function (s) {\n const used = USED.skills.get(a.name + SEP + s.name);\n const k = archKey(used);\n skillKey.set(s.name, k);\n lines.push(' ' + k + '([\"' + safe(s.name) + (s.preload ? ' (preloaded)' : '')\n + times(used) + '\"])');\n lines.push(' class ' + k + ' skill;');\n if (!used) lines.push(' class ' + k + ' idle;');\n lines.push(' ' + self + ' --- ' + k);\n });\n if (a.skills && a.skills.unresolved) {\n const k = archKey(null);\n lines.push(' ' + k + '([\"catalog unavailable: ' + safe(a.skills.provider) + '\"])');\n lines.push(' class ' + k + ' skill;');\n lines.push(' class ' + k + ' idle;');\n lines.push(' ' + self + ' --- ' + k);\n }\n\n const shown = a.tools.slice(0, MAX_TOOLS);\n shown.forEach(function (t) {\n const used = USED.tools.get(a.name + SEP + t.name);\n const k = archKey(used);\n lines.push(' ' + k + '[\"' + safe(t.name) + times(used) + '\"]');\n lines.push(' class ' + k + ' tool;');\n if (!used) lines.push(' class ' + k + ' idle;');\n // A skill-owned tool hangs off the skill that unlocks it, not off the\n // agent: that edge is the whole point of a locked tool.\n const owner = t.skill ? skillKey.get(t.skill) : null;\n if (owner) lines.push(' ' + owner + ' -.-> ' + k);\n else lines.push(' ' + self + ' --- ' + k);\n });\n if (a.tools.length > shown.length) {\n const k = archKey(null);\n lines.push(' ' + k + '[\"+' + (a.tools.length - shown.length) + ' more tools\"]');\n lines.push(' class ' + k + ' more;');\n lines.push(' ' + self + ' --- ' + k);\n }\n\n if (a.memory) {\n const used = USED.memory.get(a.name);\n const k = archKey(used);\n const sees = Array.isArray(a.memory.sees) ? a.memory.sees : [a.memory.sees];\n lines.push(' ' + k + '[(\"memory ' + (a.memory.access === 'read' ? '(ro)' : '(rw)')\n + ' sees ' + safe(sees.join(' ') || '*') + '\")]');\n lines.push(' class ' + k + ' mem;');\n if (!used) lines.push(' class ' + k + ' idle;');\n lines.push(' ' + self + ' --- ' + k);\n }\n lines.push(' end');\n // Mermaid's default subgraph is a light box, which on a dark page reads as\n // the foreground rather than as the container. An agent nobody entered is\n // outlined rather than filled, so a dead corner of the wiring is visible\n // from across the diagram.\n lines.push(' style sg_' + self + ' fill:#12151d,color:#8a92a6,stroke:'\n + (live ? '#39415a;' : '#2b3346,stroke-dasharray:5 4;'));\n });\n\n // Edges between agents live outside every subgraph, so Mermaid routes them\n // between the boxes instead of trying to keep them inside one.\n ARCH.agents.forEach(function (a) {\n const self = selfKey.get(a.name);\n a.handoffs.forEach(function (to) {\n if (!selfKey.has(to)) return;\n const used = USED.handoffs.get(a.name + SEP + to);\n lines.push(' ' + self + (used ? ' -- \"handoff' + times(used) + '\" --> ' : ' -.-> ')\n + selfKey.get(to));\n });\n ((a.fork && a.fork.agents) || []).forEach(function (to) {\n if (!selfKey.has(to) || to === a.name) return;\n const used = USED.forks.get(a.name + SEP + to);\n lines.push(' ' + self + ' == \"fork' + times(used) + '\" ==> ' + selfKey.get(to));\n });\n });\n return lines.join('\\n');\n}\n\nfunction archLegend() {\n const agents = ARCH.agents.length;\n const tools = ARCH.agents.reduce(function (n, a) { return n + a.tools.length; }, 0);\n const skills = ARCH.agents.reduce(function (n, a) {\n return n + ((a.skills && a.skills.catalog.length) || 0);\n }, 0);\n const idle = ARCH.agents.filter(function (a) { return !USED.agents.has(a.name); }).length;\n return (ARCH.source === 'declared' ? 'declared' : 'observed only')\n + ' \\u00b7 ' + agents + ' agents \\u00b7 ' + tools + ' tools \\u00b7 ' + skills + ' skills'\n + (idle ? ' \\u00b7 ' + idle + ' idle' : '')\n + ' \\u00b7 dimmed = unused \\u00b7 click to jump';\n}\n\nasync function drawArch() {\n document.getElementById('alegend').textContent = archLegend();\n if (!ARCH.agents.length) {\n PANES.agents.canvas.replaceChildren(el('div', 'hint pad', 'No agents to draw.'));\n return;\n }\n await draw(PANES.agents, 'arch-graph', archDiagram(), function (g) {\n const m = /-(x\\d+)-\\d+$/.exec(g.id || '');\n const key = m && ARCH_KEYS.get(m[1]);\n if (!key) return;\n g.addEventListener('click', function () { if (!PANES.agents.moved()) select(key, true); });\n });\n}\n\ndrawGraph().then(drawArch);\nbuildStats();\nif (ENTRIES.length) select('n0', false);\n";
1
+ export declare const CLIENT = "\nconst DATA = JSON.parse(document.getElementById('run-data').textContent);\nconst STATE = DATA.state;\nconst BLOBS = DATA.blobs;\nconst MEDIA = DATA.media || [];\nconst CUT = new Set(DATA.truncated || []);\n\n// --- payload access -------------------------------------------------------\n\nfunction blob(ref) {\n if (!ref || typeof ref.sha256 !== 'string') return '';\n const v = BLOBS[ref.sha256];\n if (v === undefined) return '[payload not resolved: ' + ref.sha256.slice(0, 12) + ']';\n return CUT.has(ref.sha256) ? v + '\\n\\n[\u2026 truncated, ' + ref.size + ' bytes total]' : v;\n}\n\nfunction pretty(raw) {\n try { return JSON.stringify(JSON.parse(raw), null, 2); } catch (e) { return raw; }\n}\n\n// --- flatten the trajectory ----------------------------------------------\n\nconst ENTRIES = [];\nconst BY_KEY = new Map();\n\nfunction covered(nodes) {\n const out = new Set();\n for (const n of nodes) if (n.type === 'compaction') for (const id of n.covers) out.add(id);\n return out;\n}\n\nfunction flatten(nodes, depth, branch) {\n const hidden = covered(nodes);\n const level = [];\n for (const node of nodes) {\n const e = {\n key: '', node: node, depth: depth, branch: branch,\n covered: hidden.has(node.id), children: []\n };\n // A join's branches ran between the fork and the join, so they are\n // numbered and listed there \u2014 which means recursing before this entry\n // takes its own number.\n if (node.type === 'join') {\n for (const b of node.branches) e.children.push({ name: b.name, entries: flatten(b.nodes, depth + 1, b.name) });\n }\n e.key = 'n' + ENTRIES.length;\n ENTRIES.push(e); BY_KEY.set(e.key, e); level.push(e);\n }\n return level;\n}\nconst ROOT = flatten(STATE.trajectory, 0, null);\n\n// --- labels ---------------------------------------------------------------\n\nfunction names(list) { return list.join(', '); }\n\n// The edges are what makes a recollection a subgraph rather than a hit list,\n// so they are shown even though the injected block already renders them.\nfunction edgeLines(edges) {\n if (!edges || !edges.length) return '';\n return '\\n\\n' + edges.map(function (e) {\n return e.source + ' --' + e.relation + '--> ' + e.target;\n }).join('\\n');\n}\n\nfunction base(path) { return path.split('/').pop() || path; }\n\nfunction label(n) {\n switch (n.type) {\n case 'user_input': return n.synthetic ? 'user input (synthetic)' : 'user input';\n case 'system_prompt': return 'system prompt'\n + (n.sources && n.sources.length ? ': ' + names(n.sources.map(function (s) { return base(s.path); })) : '');\n case 'load_skills': return 'skills: ' + names(n.skills.map(function (s) { return s.name; }));\n case 'memory_recall': return 'memory recall: ' + n.nodes.length + ' nodes';\n case 'memory_op': return 'memory ' + n.op + ': ' + n.nodes.length + ' nodes';\n case 'llm_call': return 'llm: ' + n.model + (n.toolCalls.length\n ? ' -> ' + names(n.toolCalls.map(function (c) { return c.name; })) : '');\n case 'tool_call': return 'call: ' + n.name;\n case 'tool_result': return 'result: ' + n.name + (n.isError ? ' (error)' : '');\n case 'handoff': return 'handoff: ' + n.from + ' -> ' + n.to;\n case 'fork': return 'fork: ' + names(n.branches.map(function (b) { return b.name; }));\n case 'join': return 'join: ' + names(n.branches.map(function (b) { return b.name + '=' + b.status; }));\n case 'compaction': return 'compaction: ' + n.reason + ' (' + n.covers.length + ' nodes)';\n case 'final_output': return 'final output';\n default: return n.type;\n }\n}\n\nfunction preview(n) {\n switch (n.type) {\n case 'user_input': {\n const t = n.content.find(function (p) { return p.type === 'text'; });\n return t ? blob(t.text) : '';\n }\n case 'system_prompt': return blob(n.prompt);\n case 'llm_call': return blob(n.text);\n case 'tool_call': return blob(n.args);\n case 'tool_result': return blob(n.result);\n case 'compaction': return blob(n.summary);\n case 'final_output': return blob(n.output);\n case 'memory_recall': return blob(n.content);\n case 'load_skills': return blob(n.content);\n default: return '';\n }\n}\n\n// --- DOM helpers ----------------------------------------------------------\n\nfunction el(tag, cls, text) {\n const e = document.createElement(tag);\n if (cls) e.className = cls;\n if (text !== undefined && text !== null) e.textContent = String(text);\n return e;\n}\n\nfunction block(title, tag) {\n const s = el('section', 'blk');\n const h = el('h3', null, title);\n if (tag) h.appendChild(el('span', 'tag', tag));\n s.appendChild(h);\n return s;\n}\n\nfunction textBlock(title, body, tag) {\n const s = block(title, tag);\n if (body) s.appendChild(el('pre', 'text', body));\n else s.appendChild(el('div', 'empty', '(empty)'));\n return s;\n}\n\nfunction foldBlock(title, body) {\n const s = block(title);\n const d = document.createElement('details');\n d.appendChild(el('summary', null, 'show'));\n d.appendChild(el('pre', null, body));\n s.appendChild(d);\n return s;\n}\n\n// An <img> never runs script, but the src is still pinned to shapes we\n// understand: a raster data: uri (svg is excluded on principle) or an http url.\nconst IMG_SUBTYPES = ['png', 'jpeg', 'jpg', 'gif', 'webp', 'avif', 'bmp'];\n\n/** Undoes the report's media hoisting; anything else passes through. */\nfunction mediaUrl(u) {\n if (typeof u !== 'string' || u.indexOf('media:') !== 0) return u;\n const v = MEDIA[Number(u.slice(6))];\n return v === undefined ? u : v;\n}\n\nfunction imageSrc(p) {\n if (p.type !== 'image') return null;\n const u = mediaUrl(p.url);\n if (typeof u !== 'string') return null;\n if (u.indexOf('https://') === 0 || u.indexOf('http://') === 0) return u;\n if (u.indexOf('data:image/') !== 0) return null;\n const sub = u.slice(11).split(/[;,]/)[0].toLowerCase();\n return IMG_SUBTYPES.indexOf(sub) < 0 ? null : u;\n}\n\n/** The rendered picture, or null when the part is not one we will inline. */\nfunction imageEl(p, thumb) {\n const src = imageSrc(p);\n if (!src) return null;\n const img = el('img', thumb ? 'media thumb' : 'media');\n img.src = src;\n img.alt = p.type + (p.mimeType ? ' ' + p.mimeType : '');\n img.loading = 'lazy';\n if (thumb) {\n img.title = 'click to enlarge';\n img.addEventListener('click', function () { img.classList.toggle('thumb'); });\n }\n return img;\n}\n\nfunction mediaBlock(title, p) {\n const tag = p.type + (p.mimeType ? ' \u00B7 ' + p.mimeType : '');\n const src = imageSrc(p);\n const img = imageEl(p, false);\n if (!img) return textBlock(title, mediaUrl(p.url), tag);\n const s = block(title, tag);\n s.appendChild(img);\n // The bytes stay reachable; they just no longer own the screen.\n const d = document.createElement('details');\n d.appendChild(el('summary', null, 'source'));\n d.appendChild(el('pre', null, src));\n s.appendChild(d);\n return s;\n}\n\n// A titled container for several blocks. Returns the element to append and the\n// body to fill, so callers never have to know the internal structure.\nfunction group(title, kind, tag) {\n const s = el('section', 'group ' + kind);\n const h = el('div', 'ghdr', title);\n if (tag) h.appendChild(el('span', 'tag', tag));\n s.appendChild(h);\n const body = el('div', 'body');\n s.appendChild(body);\n return { el: s, body: body };\n}\n\nfunction stat(key, value) {\n const d = el('span', 'stat', key + ' ');\n d.appendChild(el('b', null, value));\n return d;\n}\n\n// --- header ---------------------------------------------------------------\n\nfunction kv(key, value) {\n const d = el('div', 'kv');\n d.appendChild(document.createTextNode(key + ' '));\n d.appendChild(el('b', null, value));\n return d;\n}\n\n(function head() {\n const h = document.getElementById('head');\n const u = STATE.usage || {};\n const llm = ENTRIES.filter(function (e) { return e.node.type === 'llm_call'; }).length;\n h.appendChild(el('h1', null, DATA.title));\n h.appendChild(kv('run', STATE.runId));\n h.appendChild(kv('agent', STATE.agentName));\n h.appendChild(kv('phase', STATE.phase));\n h.appendChild(kv('nodes', ENTRIES.length));\n h.appendChild(kv('llm calls', llm));\n h.appendChild(kv('tokens', (u.inputTokens || 0) + ' in / ' + (u.outputTokens || 0) + ' out'));\n if (STATE.error) h.appendChild(kv('error', STATE.error));\n h.appendChild(kv('generated', DATA.generatedAt));\n})();\n\n// --- timing ---------------------------------------------------------------\n//\n// A node is stamped when it is appended, which is when the work behind it\n// finished. So the interval between a node and the one before it in the same\n// lane *is* that work: an llm_call's interval is the model latency, a\n// tool_result's is the tool. That gives every node a start and a duration\n// without storing either.\n//\n// Offsets are counted from the start of the turn, not of the run: in a\n// follow-up the interesting question is \"how long did this answer take\", and\n// counting from a conversation that began an hour ago answers nothing. A trunk\n// user_input opens a new turn; a branch inherits the turn that forked it, so\n// ten branches starting at one offset still read as parallel.\n\nfunction stampOf(node) {\n const t = Date.parse(node.ts);\n return isNaN(t) ? null : t;\n}\n\n(function time(level, lanePrev, turnStart) {\n let prev = lanePrev;\n let t0 = turnStart;\n level.forEach(function (e) {\n const ts = stampOf(e.node);\n // A real user turn resets the clock and starts at itself: the gap since\n // the previous run is waiting, not work. Synthetic inputs (a branch seed,\n // a re-injection) are not turns \u2014 same rule the kernel uses.\n const opensTurn = !e.branch && e.node.type === 'user_input' && !e.node.synthetic;\n if (opensTurn) t0 = ts;\n e.turnStart = t0 === null ? ts : t0;\n e.startMs = opensTurn || prev === null ? ts : prev;\n e.endMs = ts;\n // Branches run between the fork and the join, so they start where the\n // trunk left off \u2014 and the join's own duration is the whole parallel\n // section it waited on.\n if (e.node.type === 'join') {\n let slowest = null;\n e.children.forEach(function (br) {\n time(br.entries, prev, e.turnStart);\n // The branch as a whole gets the same shape as a node \u2014 start, end,\n // turn \u2014 so a reader sees how long *it* ran instead of having to\n // subtract two offsets to find out that ten branches were parallel.\n const last = br.entries[br.entries.length - 1];\n br.turnStart = e.turnStart;\n br.startMs = prev;\n br.endMs = last ? last.endMs : prev;\n if (br.entries.length) br.entries[0].branchSpan = br;\n // The join costs exactly its slowest branch, so that branch is the\n // only one worth optimizing: it gets marked and rendered lighter.\n if (br.entries.length && (!slowest || br.endMs > slowest.endMs)) slowest = br;\n });\n if (slowest) slowest.slowest = true;\n }\n prev = ts;\n });\n})(ROOT, null, null);\n\nfunction fmtSecs(ms) {\n const s = ms / 1000;\n if (s < 100) return s.toFixed(1) + 's';\n const m = Math.floor(s / 60);\n return m + 'm' + String(Math.floor(s - m * 60)).padStart(2, '0');\n}\n\n/** Offset of a node's start from the beginning of its turn. */\nfunction startedAt(e) {\n if (e.startMs === null || e.turnStart === null) return '';\n return fmtSecs(e.startMs - e.turnStart);\n}\n\n/** How long the work behind a node took. Blank when it is not worth a number. */\nfunction took(e) {\n if (e.startMs === null || e.endMs === null) return '';\n const ms = e.endMs - e.startMs;\n if (ms < 10) return '';\n return fmtDur(ms);\n}\n\nfunction fmtDur(ms) {\n return ms < 1000 ? Math.round(ms) + 'ms' : fmtSecs(ms);\n}\n\nfunction spanMs(e) {\n return e.startMs === null || e.endMs === null ? 0 : e.endMs - e.startMs;\n}\n\n// One colour per branch, stable for the life of the page. The trunk keeps the\n// neutral accent so branches are the thing that stands out.\nconst PALETTE = ['#6ea8fe', '#4ec9a0', '#e2b341', '#d1a6ff', '#f0776c',\n '#5fd0d8', '#f09a5c', '#a3d160', '#ef8ec0', '#9aa4ff'];\nconst BRANCH_COLOR = new Map();\nfunction branchColor(name) {\n if (!name) return null;\n if (!BRANCH_COLOR.has(name)) BRANCH_COLOR.set(name, PALETTE[BRANCH_COLOR.size % PALETTE.length]);\n return BRANCH_COLOR.get(name);\n}\n\n// --- timeline -------------------------------------------------------------\n\nconst listEl = document.getElementById('timeline');\nlet lastBranch = false;\nlet lastStamp = null;\n\nENTRIES.forEach(function (e, i) {\n // Branch histories are contiguous in the flattened order, so a change of\n // branch is a group boundary: label it and restart the elapsed column.\n if (e.branch !== lastBranch) {\n const sep = el('li', 'sep');\n const color = branchColor(e.branch);\n if (color) sep.style.setProperty('--bc', color);\n sep.appendChild(el('span', 'dot'));\n sep.appendChild(el('span', null, e.branch ? 'branch \u00B7 ' + e.branch : 'trunk'));\n if (e.branchSpan) {\n const bd = took(e.branchSpan);\n if (e.branchSpan.slowest) sep.classList.add('slow');\n if (bd) {\n sep.appendChild(el('span', 'dur',\n 't+' + startedAt(e.branchSpan) + ' \u00B7 ' + bd + (e.branchSpan.slowest ? ' \u00B7 slowest' : '')));\n }\n }\n listEl.appendChild(sep);\n lastBranch = e.branch;\n lastStamp = null;\n }\n\n const li = el('li', e.branch ? 'branch' : '');\n if (e.covered) li.classList.add('covered');\n const color = branchColor(e.branch);\n if (color) li.style.setProperty('--bc', color);\n li.dataset.key = e.key;\n\n const row = el('div', 'row');\n // Consecutive nodes often start at the same instant (a tool call is recorded\n // the moment the response carrying it is applied). Repeating the offset\n // would only bury the moments where time actually moved.\n const at = startedAt(e);\n row.appendChild(el('span', 't' + (at === lastStamp ? ' same' : ''), at === lastStamp ? '\u00B7' : at));\n lastStamp = at;\n row.appendChild(el('span', 'idx', '#' + (i + 1)));\n const badge = el('span', 'type ' + e.node.type\n + (e.node.type === 'tool_result' && e.node.isError ? ' err' : ''), e.node.type.replace(/_/g, ' '));\n row.appendChild(badge);\n const d = took(e);\n if (d) row.appendChild(el('span', 'dur', d));\n li.appendChild(row);\n li.appendChild(el('div', 'prev', label(e.node)\n + (preview(e.node) ? ' \u2014 ' + preview(e.node).slice(0, 90) : '')));\n li.addEventListener('click', function () { select(e.key, true); });\n listEl.appendChild(li);\n});\n\n// A group header is only worth showing while something in its group is.\nfunction applyFilter(q) {\n const rows = Array.prototype.slice.call(listEl.children);\n rows.forEach(function (li) {\n if (li.classList.contains('sep')) return;\n const e = BY_KEY.get(li.dataset.key);\n const hay = (label(e.node) + ' ' + e.node.type + ' ' + (e.branch || '')\n + ' ' + preview(e.node)).toLowerCase();\n li.hidden = q.length > 0 && hay.indexOf(q) === -1;\n });\n let sep = null, kept = false;\n rows.forEach(function (li) {\n if (li.classList.contains('sep')) {\n if (sep) sep.hidden = !kept;\n sep = li; kept = false;\n } else if (!li.hidden) kept = true;\n });\n if (sep) sep.hidden = !kept;\n}\n\ndocument.getElementById('filter').addEventListener('input', function (ev) {\n applyFilter(ev.target.value.toLowerCase());\n});\n\n// --- detail ---------------------------------------------------------------\n\nfunction message(m) {\n const wrap = el('div', 'msg ' + m.role + (m.isError ? ' err' : ''));\n const role = el('div', 'role');\n role.appendChild(el('span', null, m.role + (m.agent ? ' \u00B7 ' + m.agent : '')\n + (m.name ? ' \u00B7 ' + m.name : '')));\n if (m.callId) role.appendChild(el('span', 'badge', m.callId));\n wrap.appendChild(role);\n\n if (m.role === 'user' && Array.isArray(m.content)) {\n m.content.forEach(function (p) {\n if (p.type === 'text') { wrap.appendChild(el('pre', 'text', p.text)); return; }\n const img = imageEl(p, true);\n wrap.appendChild(img || el('pre', 'text', '[' + p.type + '] ' + mediaUrl(p.url)));\n });\n } else if (typeof m.content === 'string' && m.content) {\n wrap.appendChild(el('pre', 'text', m.role === 'tool' ? pretty(m.content) : m.content));\n } else if (!m.toolCalls) {\n wrap.appendChild(el('div', 'empty', '(no content)'));\n }\n\n (m.toolCalls || []).forEach(function (c) {\n const box = el('div', 'call');\n box.appendChild(el('div', 'name', c.name + '(' + c.id + ')'));\n box.appendChild(el('pre', null, pretty(c.args)));\n wrap.appendChild(box);\n });\n return wrap;\n}\n\nfunction requestBlocks(raw, into) {\n let req;\n try { req = JSON.parse(raw); } catch (e) {\n into.appendChild(textBlock('Request (unparsed)', raw));\n return;\n }\n if (req.system) into.appendChild(textBlock('System prompt', req.system)).classList.add('sys');\n\n const conv = block('Messages', (req.messages || []).length + ' messages');\n (req.messages || []).forEach(function (m) { conv.appendChild(message(m)); });\n if (!(req.messages || []).length) conv.appendChild(el('div', 'empty', '(none)'));\n into.appendChild(conv);\n\n const tools = block('Tools offered', (req.tools || []).length + ' tools');\n (req.tools || []).forEach(function (t) {\n const d = document.createElement('details');\n d.appendChild(el('summary', null, t.name + (t.description ? ' \u2014 ' + t.description : '')));\n d.appendChild(el('pre', null, JSON.stringify(t.parameters, null, 2)));\n tools.appendChild(d);\n });\n if (!(req.tools || []).length) tools.appendChild(el('div', 'empty', '(none)'));\n into.appendChild(tools);\n}\n\nfunction usageTag(u) {\n if (!u) return '';\n return (u.inputTokens || 0) + ' in (' + (u.cachedInputTokens || 0) + ' cached) / '\n + (u.outputTokens || 0) + ' out (' + (u.reasoningTokens || 0) + ' reasoning)';\n}\n\nfunction detailFor(e) {\n const n = e.node;\n const out = document.createDocumentFragment();\n\n const h = el('h2', 'dt');\n h.appendChild(el('span', 'type ' + n.type, n.type.replace(/_/g, ' ')));\n h.appendChild(el('span', null, label(n)));\n out.appendChild(h);\n\n const meta = el('div', 'meta');\n meta.appendChild(el('span', null, 'id ' + n.id));\n meta.appendChild(el('span', null, 'agent ' + n.agent));\n meta.appendChild(el('span', null, n.ts));\n meta.appendChild(el('span', null, 'started t+' + startedAt(e) + (took(e) ? ' \u00B7 took ' + took(e) : '')));\n if (e.branch) {\n const br = el('span', null, 'branch ' + e.branch);\n br.style.color = branchColor(e.branch);\n meta.appendChild(br);\n }\n if (e.covered) meta.appendChild(el('span', null, 'hidden by a later compaction'));\n out.appendChild(meta);\n\n switch (n.type) {\n case 'system_prompt':\n out.appendChild(textBlock('Prompt', blob(n.prompt)));\n // The editable half: which file to open when the instruction is wrong.\n (n.sources || []).forEach(function (s) {\n out.appendChild(textBlock(s.path, blob(s.content), 'prompt file'));\n });\n break;\n\n case 'user_input':\n n.content.forEach(function (p, i) {\n out.appendChild(p.type === 'text'\n ? textBlock('Text part ' + (i + 1), blob(p.text))\n : mediaBlock('Media part ' + (i + 1), p));\n });\n break;\n\n case 'load_skills':\n out.appendChild(textBlock('Skills', n.skills.map(function (s) {\n return s.name + (s.version ? '@' + s.version : '') + ' ' + s.contentHash.slice(0, 12)\n + (s.file ? ' ' + s.file : '');\n }).join('\\n'), n.provider));\n out.appendChild(textBlock('Injected instructions', blob(n.content)));\n out.appendChild(textBlock('Tools unlocked', n.toolNames.join('\\n')));\n break;\n\n case 'memory_recall':\n out.appendChild(textBlock('Query', JSON.stringify(n.query, null, 2),\n n.seeds.length + ' seed(s)'));\n out.appendChild(textBlock('Subgraph', n.nodes.map(function (m) {\n return m.id + ' ' + m.kind\n + (n.seeds.indexOf(m.id) >= 0 ? ' score=' + m.score.toFixed(3) : ' (stitched)');\n }).join('\\n') + edgeLines(n.edges)));\n out.appendChild(textBlock('Injected block', blob(n.content)));\n break;\n\n case 'memory_op':\n out.appendChild(textBlock('Operation', n.nodes.map(function (m) {\n return n.op + ' ' + m.id + ' ' + m.kind + ' rev ' + m.revision;\n }).join('\\n') + edgeLines(n.edges),\n n.files ? n.files + ' file(s)' : undefined));\n break;\n\n case 'llm_call': {\n const u = n.usage || {};\n const strip = el('div', 'stats');\n strip.appendChild(stat('model', n.model));\n strip.appendChild(stat('in', u.inputTokens || 0));\n strip.appendChild(stat('cached', u.cachedInputTokens || 0));\n strip.appendChild(stat('out', u.outputTokens || 0));\n if (u.reasoningTokens) strip.appendChild(stat('reasoning', u.reasoningTokens));\n strip.appendChild(stat('stop', n.stopReason || '?'));\n out.appendChild(strip);\n\n const req = group('Request', 'req', 'sent to ' + n.model);\n if (n.request) requestBlocks(blob(n.request), req.body);\n else {\n req.body.appendChild(el('div', 'empty',\n 'not recorded \u2014 construct the runner with { recordRequests: true } to capture it'));\n }\n out.appendChild(req.el);\n\n const res = group('Response', 'res', n.stopReason);\n if (n.thinking) res.body.appendChild(textBlock('Reasoning', blob(n.thinking)));\n res.body.appendChild(textBlock('Text', blob(n.text)));\n n.toolCalls.forEach(function (c) {\n res.body.appendChild(textBlock('Tool call \u00B7 ' + c.name, pretty(blob(c.args)), c.callId));\n });\n out.appendChild(res.el);\n\n out.appendChild(textBlock('Request digest', n.requestDigest || '(not recorded)'));\n break;\n }\n\n case 'tool_call':\n out.appendChild(textBlock('Arguments', pretty(blob(n.args)), n.name + ' \u00B7 ' + n.callId));\n break;\n\n case 'tool_result':\n out.appendChild(textBlock(n.isError ? 'Error result' : 'Result',\n pretty(blob(n.result)),\n n.name + (n.durationMs !== undefined ? ' \u00B7 ' + n.durationMs + 'ms' : '')));\n break;\n\n case 'handoff':\n out.appendChild(textBlock('Handoff', n.from + ' -> ' + n.to\n + (n.reason ? '\\n\\nreason: ' + n.reason : '')));\n break;\n\n case 'fork':\n n.branches.forEach(function (b) {\n out.appendChild(textBlock('Branch \u00B7 ' + b.name, blob(b.instructions),\n b.agent + ' \u00B7 ' + n.contextMode + ' \u00B7 ' + b.childRunId));\n });\n break;\n\n case 'join':\n n.branches.forEach(function (b, i) {\n const br = e.children[i];\n const bd = br ? took(br) : '';\n out.appendChild(textBlock('Branch \u00B7 ' + b.name,\n b.status === 'ok' ? blob(b.output) : (b.error || blob(b.output)),\n b.status + ' \u00B7 ' + b.agent + ' \u00B7 ' + b.nodes.length + ' nodes'\n + (bd ? ' \u00B7 ' + bd : '') + ' \u00B7 ' + usageTag(b.usage)));\n });\n break;\n\n case 'compaction': {\n out.appendChild(textBlock('Summary', blob(n.summary), n.reason));\n const cov = block('Replaces', n.covers.length + ' nodes');\n const line = el('div', 'empty', '');\n line.textContent = '';\n n.covers.forEach(function (id) {\n const hit = ENTRIES.find(function (x) { return x.node.id === id; });\n const b = el('button', 'link', hit ? label(hit.node) : id);\n if (hit) b.addEventListener('click', function () { select(hit.key, true); });\n line.appendChild(b);\n });\n cov.appendChild(line);\n out.appendChild(cov);\n out.appendChild(textBlock('Summarizer usage', usageTag(n.usage)));\n break;\n }\n\n case 'final_output':\n out.appendChild(textBlock('Output', blob(n.output)));\n if (n.parsed !== undefined) out.appendChild(textBlock('Parsed', JSON.stringify(n.parsed, null, 2)));\n break;\n }\n\n out.appendChild(foldBlock('Raw node', JSON.stringify(n, null, 2)));\n return out;\n}\n\nconst detailEl = document.getElementById('detail');\nconst statsEl = document.getElementById('stats');\nlet selected = null;\n\nfunction select(key, focus) {\n const e = BY_KEY.get(key);\n if (!e) return;\n selected = key;\n detailEl.replaceChildren(detailFor(e));\n Array.prototype.forEach.call(listEl.children, function (li) {\n li.classList.toggle('sel', li.dataset.key === key);\n });\n const li = listEl.querySelector('li[data-key=\"' + key + '\"]');\n if (li) li.scrollIntoView({ block: 'nearest' });\n if (focus) show('detail');\n detailEl.scrollTop = 0;\n}\n\n// --- stats ----------------------------------------------------------------\n//\n// Tokens are not fungible across models and seconds are not fungible across\n// lanes: a thousand tokens cost a different amount on a different model, and\n// branch time runs in parallel with other branch time. So every number here\n// is broken down by model and by agent, and the wall clock sits next to the\n// sums so the gap between them is visible instead of misleading.\n\nfunction bucket() {\n return { calls: 0, errors: 0, ms: 0, in: 0, cached: 0, out: 0, reasoning: 0 };\n}\n\nfunction bucketOf(map, key) {\n if (!map.has(key)) map.set(key, bucket());\n return map.get(key);\n}\n\nfunction addUsage(into, u) {\n if (!u) return;\n into.in += u.inputTokens || 0;\n into.cached += u.cachedInputTokens || 0;\n into.out += u.outputTokens || 0;\n into.reasoning += u.reasoningTokens || 0;\n}\n\nfunction num(n) { return n.toLocaleString('en-US'); }\n\nfunction table(cols, rows, foot) {\n const t = el('table', 'st');\n const head = document.createElement('thead');\n const hr = document.createElement('tr');\n cols.forEach(function (c) { hr.appendChild(el('th', null, c)); });\n head.appendChild(hr);\n t.appendChild(head);\n const body = document.createElement('tbody');\n rows.forEach(function (r) {\n const tr = document.createElement('tr');\n r.forEach(function (v) { tr.appendChild(el('td', null, v)); });\n body.appendChild(tr);\n });\n t.appendChild(body);\n if (foot) {\n const tf = document.createElement('tfoot');\n const fr = document.createElement('tr');\n foot.forEach(function (v) { fr.appendChild(el('td', null, v)); });\n tf.appendChild(fr);\n t.appendChild(tf);\n }\n return t;\n}\n\nconst TOKEN_COLS = ['calls', 'in', 'cached', 'out', 'reasoning', 'time', 'avg'];\n\nfunction tokenRow(name, b) {\n return [name, num(b.calls), num(b.in), num(b.cached), num(b.out), num(b.reasoning),\n fmtDur(b.ms), b.calls ? fmtDur(b.ms / b.calls) : '\u2014'];\n}\n\nfunction byTokens(a, b) { return (b[1].in + b[1].out) - (a[1].in + a[1].out); }\n\nfunction buildStats() {\n const byModel = new Map(), byAgent = new Map(), byTool = new Map(), byType = new Map();\n const all = bucket();\n let llmMs = 0, toolMs = 0, toolCalls = 0, from = null, to = null;\n\n ENTRIES.forEach(function (e) {\n const n = e.node;\n byType.set(n.type, (byType.get(n.type) || 0) + 1);\n if (e.startMs !== null && (from === null || e.startMs < from)) from = e.startMs;\n if (e.endMs !== null && (to === null || e.endMs > to)) to = e.endMs;\n\n // A join's usage is the sum of its branches, whose nodes are already in\n // this list, so counting it too would double every branch token. A\n // compaction has no model of its own but did burn a summarizer.\n if (n.type === 'llm_call' || n.type === 'compaction') {\n const ms = spanMs(e);\n const model = n.type === 'compaction' ? 'summarizer' : n.model;\n [bucketOf(byModel, model), bucketOf(byAgent, n.agent), all].forEach(function (b) {\n addUsage(b, n.usage);\n b.calls += 1;\n b.ms += ms;\n });\n llmMs += ms;\n return;\n }\n // The tool's own measurement when the runner took one; the gap to the\n // previous node otherwise.\n if (n.type === 'tool_result') {\n const b = bucketOf(byTool, n.name);\n const ms = n.durationMs !== undefined ? n.durationMs : spanMs(e);\n b.calls += 1;\n b.ms += ms;\n if (n.isError) b.errors += 1;\n toolCalls += 1;\n toolMs += ms;\n }\n });\n\n const pills = el('div', 'stats');\n pills.appendChild(stat('wall clock', from === null ? '\u2014' : fmtDur(to - from)));\n pills.appendChild(stat('in llm', fmtDur(llmMs)));\n pills.appendChild(stat('in tools', fmtDur(toolMs)));\n pills.appendChild(stat('llm calls', num(all.calls)));\n pills.appendChild(stat('tool calls', num(toolCalls)));\n pills.appendChild(stat('tokens', num(all.in) + ' in / ' + num(all.out) + ' out'));\n pills.appendChild(stat('cached', num(all.cached)));\n pills.appendChild(stat('nodes', num(ENTRIES.length)));\n statsEl.replaceChildren(pills);\n\n const note = el('div', 'hint', 'Branches run in parallel, so llm and tool time are sums '\n + 'over every lane and add up to more than the wall clock.');\n note.style.marginBottom = '14px';\n statsEl.appendChild(note);\n\n const models = Array.from(byModel.entries()).sort(byTokens);\n const modelBlock = block('Tokens by model', models.length + (models.length === 1 ? ' model' : ' models'));\n modelBlock.appendChild(table(['model'].concat(TOKEN_COLS),\n models.map(function (m) { return tokenRow(m[0], m[1]); }), tokenRow('total', all)));\n statsEl.appendChild(modelBlock);\n\n const agents = Array.from(byAgent.entries()).sort(byTokens);\n const agentBlock = block('Tokens by agent', agents.length + (agents.length === 1 ? ' agent' : ' agents'));\n agentBlock.appendChild(table(['agent'].concat(TOKEN_COLS),\n agents.map(function (a) { return tokenRow(a[0], a[1]); }), tokenRow('total', all)));\n statsEl.appendChild(agentBlock);\n\n const tools = Array.from(byTool.entries()).sort(function (a, b) { return b[1].ms - a[1].ms; });\n const toolBlock = block('Tool calls', num(toolCalls) + ' calls \u00B7 ' + fmtDur(toolMs));\n if (tools.length) {\n toolBlock.appendChild(table(['tool', 'calls', 'errors', 'time', 'avg'],\n tools.map(function (t) {\n const b = t[1];\n return [t[0], num(b.calls), b.errors ? num(b.errors) : '', fmtDur(b.ms),\n b.calls ? fmtDur(b.ms / b.calls) : '\u2014'];\n })));\n } else {\n toolBlock.appendChild(el('div', 'empty', '(none)'));\n }\n statsEl.appendChild(toolBlock);\n\n const types = Array.from(byType.entries()).sort(function (a, b) { return b[1] - a[1]; });\n const typeBlock = block('Nodes by type', num(ENTRIES.length) + ' nodes');\n typeBlock.appendChild(table(['type', 'count'],\n types.map(function (t) { return [t[0].replace(/_/g, ' '), num(t[1])]; })));\n statsEl.appendChild(typeBlock);\n}\n\n// --- views ----------------------------------------------------------------\n\n// Panes that hold a zoomable diagram, by view name. A pane laid out while it\n// was hidden has no width to fit against, so becoming visible is the moment it\n// gets a second chance.\nconst PANES = {};\n\nfunction show(view) {\n document.getElementById('graph').hidden = view !== 'graph';\n document.getElementById('agents').hidden = view !== 'agents';\n document.getElementById('memory').hidden = view !== 'memory';\n detailEl.hidden = view !== 'detail';\n statsEl.hidden = view !== 'stats';\n Array.prototype.forEach.call(document.getElementById('viewtabs').children, function (b) {\n b.classList.toggle('on', b.dataset.view === view);\n });\n // The memory graph is the one view that costs a render nobody asked for, so\n // it waits until somebody asks.\n if (view === 'memory' && !memDrawn) { memDrawn = true; drawMemory(); }\n if (PANES[view]) PANES[view].refit();\n}\nArray.prototype.forEach.call(document.getElementById('viewtabs').children, function (b) {\n b.addEventListener('click', function () { show(b.dataset.view); });\n});\n\n// --- zoom and pan ---------------------------------------------------------\n//\n// A diagram grows with what it describes, so a fixed scale is useless past a\n// dozen nodes. The canvas is moved with a CSS transform rather than scrolled:\n// that keeps zooming anchored on the pointer and costs no layout.\n//\n// Written as a factory because there are two of these \u2014 the run graph and the\n// architecture \u2014 and they must not share a scale, a pan or a drag.\n\nconst MIN_ZOOM = 0.1;\nconst MAX_ZOOM = 4;\n\nfunction panZoom(paneId) {\n const pane = document.getElementById(paneId);\n const viewportEl = pane.querySelector('.viewport');\n const canvasEl = pane.querySelector('.canvas');\n const zoomEl = pane.querySelector('.lvl');\n let zoom = 1, panX = 0, panY = 0;\n let natW = 0, natH = 0;\n let pendingFit = false;\n\n function applyView() {\n canvasEl.style.transform = 'translate(' + panX + 'px,' + panY + 'px) scale(' + zoom + ')';\n zoomEl.textContent = Math.round(zoom * 100) + '%';\n }\n\n function zoomAt(factor, cx, cy) {\n const next = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom * factor));\n const ratio = next / zoom;\n // Keep the point under the cursor fixed while the scale changes.\n panX = cx - (cx - panX) * ratio;\n panY = cy - (cy - panY) * ratio;\n zoom = next;\n applyView();\n }\n\n function zoomCenter(factor) {\n const r = viewportEl.getBoundingClientRect();\n zoomAt(factor, r.width / 2, r.height / 2);\n }\n\n function fit() {\n const r = viewportEl.getBoundingClientRect();\n if (!r.width || !natW || !natH) { pendingFit = true; return; }\n pendingFit = false;\n zoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM,\n Math.min((r.width - 32) / natW, (r.height - 32) / natH)));\n panX = (r.width - natW * zoom) / 2;\n panY = Math.max(16, (r.height - natH * zoom) / 2);\n applyView();\n }\n\n pane.querySelector('.gtools').addEventListener('click', function (ev) {\n const what = ev.target.dataset ? ev.target.dataset.zoom : null;\n if (what === 'in') zoomCenter(1.25);\n else if (what === 'out') zoomCenter(1 / 1.25);\n else if (what === 'fit') fit();\n else if (what === 'reset') { zoom = 1; panX = 16; panY = 16; applyView(); }\n });\n\n viewportEl.addEventListener('wheel', function (ev) {\n ev.preventDefault();\n const r = viewportEl.getBoundingClientRect();\n zoomAt(Math.exp(-ev.deltaY * 0.002), ev.clientX - r.left, ev.clientY - r.top);\n }, { passive: false });\n\n let dragging = false, dragMoved = false, startX = 0, startY = 0, baseX = 0, baseY = 0;\n\n viewportEl.addEventListener('pointerdown', function (ev) {\n if (ev.button !== 0) return;\n dragging = true; dragMoved = false;\n startX = ev.clientX; startY = ev.clientY; baseX = panX; baseY = panY;\n });\n viewportEl.addEventListener('pointermove', function (ev) {\n if (!dragging) return;\n const dx = ev.clientX - startX, dy = ev.clientY - startY;\n if (!dragMoved) {\n if (Math.abs(dx) + Math.abs(dy) <= 4) return;\n dragMoved = true;\n // Captured only once this is a drag: capturing on pointerdown would\n // retarget the following click to the viewport and no node would ever\n // receive it.\n viewportEl.setPointerCapture(ev.pointerId);\n viewportEl.classList.add('drag');\n }\n panX = baseX + dx; panY = baseY + dy;\n applyView();\n });\n function endDrag(ev) {\n if (!dragging) return;\n dragging = false;\n viewportEl.classList.remove('drag');\n if (viewportEl.hasPointerCapture(ev.pointerId)) viewportEl.releasePointerCapture(ev.pointerId);\n }\n viewportEl.addEventListener('pointerup', endDrag);\n viewportEl.addEventListener('pointercancel', endDrag);\n viewportEl.addEventListener('dblclick', function () { fit(); });\n window.addEventListener('resize', function () { if (pendingFit) fit(); });\n\n return {\n canvas: canvasEl,\n setNatural: function (w, h) { natW = w; natH = h; fit(); },\n refit: function () { if (pendingFit) fit(); },\n // A pan that ends on a node must not be read as a click on it.\n moved: function () { return dragMoved; }\n };\n}\n\nPANES.graph = panZoom('graph');\nPANES.agents = panZoom('agents');\nPANES.memory = panZoom('memory');\n\n// --- diagram --------------------------------------------------------------\n\nconst CLASSES = {\n llm_call: 'llm', tool_call: 'tool', tool_result: 'tool', compaction: 'comp',\n fork: 'fork', join: 'fork', final_output: 'final', handoff: 'hand'\n};\n\n// A failed call is the one thing in a trace people look for, so it is coloured\n// by outcome rather than by type \u2014 the same red the sidebar badge uses.\nfunction nodeClass(n) {\n if (n.type === 'tool_result' && n.isError) return 'err';\n return CLASSES[n.type];\n}\n\n// Diagram labels are built from node types and identifiers only, and are\n// stripped to a conservative character set: nothing a model wrote can reach\n// the Mermaid parser, let alone the SVG.\nfunction safe(text) {\n return String(text).replace(/[^\\w .:\\-\\/>+=()]/g, ' ').slice(0, 60).trim();\n}\n\nfunction shape(e, i) {\n const n = e.node;\n // The timing is ours, not the run's: digits and a unit, appended after\n // sanitizing. It stays on one line because Mermaid's strict mode drops the\n // <br/> that would break it.\n const d = took(e);\n const text = '\"' + (i + 1) + '. ' + safe(label(n))\n + ' t+' + startedAt(e) + (d ? ' \u00B7 ' + d : '') + '\"';\n if (n.type === 'fork' || n.type === 'join') return e.key + '{{' + text + '}}';\n if (n.type === 'final_output') return e.key + '([' + text + '])';\n if (n.type === 'user_input' || n.type === 'system_prompt') return e.key + '[/' + text + '/]';\n return e.key + '[' + text + ']';\n}\n\nfunction chain(level, lines) {\n let prev = null;\n level.forEach(function (e) {\n const i = ENTRIES.indexOf(e);\n lines.push(' ' + shape(e, i));\n const cls = nodeClass(e.node);\n if (cls) lines.push(' class ' + e.key + ' ' + cls + ';');\n if (e.covered) lines.push(' class ' + e.key + ' covered;');\n if (prev) lines.push(' ' + prev.key + ' --> ' + e.key);\n\n if (e.node.type === 'join' && e.children.length) {\n // A branch hangs off the fork that opened it and rejoins at the join.\n const fork = level.find(function (x) {\n return x.node.type === 'fork' && x.node.callId === e.node.callId;\n });\n e.children.forEach(function (br, bi) {\n if (!br.entries.length) return;\n const bd = took(br);\n const sg = 'sg_' + e.key + '_' + bi;\n lines.push(' subgraph ' + sg + '[\"' + safe(br.name)\n + ' t+' + startedAt(br) + (bd ? ' \u00B7 ' + bd : '')\n + (br.slowest ? ' \u00B7 slowest' : '') + '\"]');\n lines.push(' direction TB');\n chain(br.entries, lines);\n lines.push(' end');\n // The one branch the join actually waited for, lifted out of the pack.\n if (br.slowest) lines.push(' style ' + sg + ' fill:#5b6070,stroke:#a9b2c6,color:#f2f4f8;');\n if (fork) lines.push(' ' + fork.key + ' -.-> ' + br.entries[0].key);\n lines.push(' ' + br.entries[br.entries.length - 1].key + ' -.-> ' + e.key);\n });\n }\n prev = e;\n });\n}\n\nfunction diagram() {\n const lines = ['flowchart TD'];\n lines.push(' classDef llm fill:#1e3357,stroke:#3d5f9e,color:#cfe0ff;');\n lines.push(' classDef tool fill:#1e3d33,stroke:#3d7a66,color:#c8f0e2;');\n lines.push(' classDef comp fill:#40331a,stroke:#7a6535,color:#f0dfb4;');\n lines.push(' classDef fork fill:#3a2450,stroke:#6b4b90,color:#e6d3ff;');\n lines.push(' classDef final fill:#14392c,stroke:#2f7a5c,color:#c6f5e0;');\n lines.push(' classDef hand fill:#3d2a1b,stroke:#7a5535,color:#f2d9c2;');\n lines.push(' classDef err fill:#4a1f1c,stroke:#8a3a33,color:#f0776c;');\n lines.push(' classDef covered opacity:0.45,stroke-dasharray:4 3;');\n chain(ROOT, lines);\n return lines.join('\\n');\n}\n\n// Mermaid refuses a diagram past either of these (defaults 500 edges and\n// 50000 characters, which a long run passes easily) and the pane then shows\n// only its error. Both are \"secure\" options, so initialize() is the one place\n// they can be raised from. The report is a file on disk, not a live page \u2014\n// a slow render beats a refused one.\nconst MAX_EDGES = 20000;\nconst MAX_TEXT_SIZE = 5000000;\n\n// One Mermaid, two diagrams. Loading it is the only network the page does, so\n// it happens once and both panes wait on the same promise.\nlet mermaidLib = null;\nfunction mermaid() {\n if (!mermaidLib) {\n mermaidLib = import(MERMAID_URL).then(function (m) {\n m.default.initialize({\n startOnLoad: false, theme: 'dark', securityLevel: 'strict',\n maxEdges: MAX_EDGES, maxTextSize: MAX_TEXT_SIZE,\n flowchart: { htmlLabels: false }\n });\n return m.default;\n });\n }\n return mermaidLib;\n}\n\n/** Renders one diagram into a pane and wires its nodes. False when it failed. */\nasync function draw(pane, id, source, onNode) {\n let lib;\n try {\n lib = await mermaid();\n } catch (err) {\n pane.canvas.replaceChildren(el('div', 'hint pad',\n 'Mermaid could not be loaded (offline?). The timeline on the left has the same nodes.'));\n return false;\n }\n try {\n const rendered = await lib.render(id, source);\n // Mermaid produced this markup from labels we sanitized above; no payload\n // text reaches it.\n pane.canvas.innerHTML = rendered.svg;\n } catch (err) {\n pane.canvas.replaceChildren(el('pre', null, 'diagram failed: ' + err.message + '\\n\\n' + source));\n return false;\n }\n const svg = pane.canvas.querySelector('svg');\n if (svg) {\n // Mermaid sizes the SVG to fit its container; we want its natural size so\n // the transform above is the only thing deciding scale.\n const box = svg.viewBox && svg.viewBox.baseVal;\n const w = (box && box.width) || svg.getBoundingClientRect().width;\n const h = (box && box.height) || svg.getBoundingClientRect().height;\n svg.removeAttribute('width');\n svg.removeAttribute('height');\n svg.style.maxWidth = 'none';\n svg.style.width = w + 'px';\n svg.style.height = h + 'px';\n pane.setNatural(w, h);\n }\n pane.canvas.querySelectorAll('.node').forEach(onNode);\n return true;\n}\n\nasync function drawGraph() {\n const ok = await draw(PANES.graph, 'run-graph', diagram(), function (g) {\n // Mermaid ids are '<renderId>-flowchart-<ourKey>-<seq>'; only the key matters.\n const m = /-(n\\d+)-\\d+$/.exec(g.id || '');\n const key = m ? m[1] : null;\n if (!key || !BY_KEY.has(key)) return;\n g.addEventListener('click', function () { if (!PANES.graph.moved()) select(key, true); });\n });\n if (!ok) show('detail');\n}\n\n// --- architecture ---------------------------------------------------------\n//\n// The run graph answers \"what happened\". This one answers \"what was wired up\",\n// which is the question a trajectory structurally cannot: the agent nobody\n// handed off to and the tool nobody called leave no node behind. The declared\n// wiring arrives with the report when a runner was there to describe itself;\n// otherwise the run is read backwards into the same shape, which is smaller\n// but never wrong.\n\nconst SEP = '::';\n\nfunction bump(map, key, entryKey) {\n const cur = map.get(key);\n if (cur) { cur.count += 1; return; }\n map.set(key, { count: 1, key: entryKey });\n}\n\n// What this run actually exercised, keyed the same way the diagram names\n// things \u2014 so drawing \"used\" is a lookup rather than a search.\nconst USED = {\n agents: new Map(), models: new Map(), tools: new Map(),\n skills: new Map(), handoffs: new Map(), memory: new Map(), forks: new Map()\n};\n\nENTRIES.forEach(function (e) {\n const n = e.node;\n bump(USED.agents, n.agent, e.key);\n if (n.type === 'llm_call') {\n if (!USED.models.has(n.agent)) USED.models.set(n.agent, n.model);\n } else if (n.type === 'tool_call') {\n bump(USED.tools, n.agent + SEP + n.name, e.key);\n } else if (n.type === 'load_skills') {\n n.skills.forEach(function (s) { bump(USED.skills, n.agent + SEP + s.name, e.key); });\n } else if (n.type === 'handoff') {\n bump(USED.handoffs, n.from + SEP + n.to, e.key);\n } else if (n.type === 'memory_recall' || n.type === 'memory_op') {\n bump(USED.memory, n.agent, e.key);\n } else if (n.type === 'fork') {\n n.branches.forEach(function (b) { bump(USED.forks, n.agent + SEP + b.agent, e.key); });\n }\n});\n\nfunction has(list, name) {\n return list.some(function (x) { return x.name === name; });\n}\n\n/** The run read backwards into the same shape. Used when none was supplied. */\nfunction observedArchitecture() {\n const byName = new Map();\n function agentOf(name) {\n if (!byName.has(name)) {\n byName.set(name, { name: name, tools: [], handoffs: [] });\n }\n return byName.get(name);\n }\n ENTRIES.forEach(function (e) {\n const n = e.node;\n const a = agentOf(n.agent);\n if (n.type === 'llm_call') {\n if (!a.model) a.model = n.model;\n } else if (n.type === 'tool_call') {\n // Handoff tools are drawn as edges between agents, never as tool nodes.\n const handoff = n.name.indexOf('transfer_to_') === 0;\n if (!handoff && !has(a.tools, n.name)) a.tools.push({ name: n.name });\n } else if (n.type === 'load_skills') {\n if (!a.skills) a.skills = { provider: n.provider, discovery: 'index', catalog: [] };\n n.skills.forEach(function (s) {\n if (has(a.skills.catalog, s.name)) return;\n // The unlocked tools are recorded per activation, not per skill, so\n // they can only be attributed when one skill loaded on its own.\n a.skills.catalog.push({\n name: s.name, version: s.version,\n toolNames: n.skills.length === 1 ? n.toolNames : undefined\n });\n });\n } else if (n.type === 'handoff') {\n const from = agentOf(n.from);\n agentOf(n.to);\n if (from.handoffs.indexOf(n.to) < 0) from.handoffs.push(n.to);\n } else if (n.type === 'memory_recall' || n.type === 'memory_op') {\n // A run shows that an agent reached the memory, never how wide its mask\n // was: nothing it did not read leaves a trace to read the mask off.\n if (!a.memory) a.memory = { access: 'read', sees: [], writes: [] };\n if (n.type === 'memory_op' && n.op !== 'load') a.memory.access = 'read-write';\n } else if (n.type === 'fork') {\n if (!a.fork) a.fork = { agents: [] };\n n.branches.forEach(function (b) {\n agentOf(b.agent);\n if (a.fork.agents.indexOf(b.agent) < 0) a.fork.agents.push(b.agent);\n });\n }\n });\n return { source: 'observed', agents: Array.from(byName.values()) };\n}\n\nconst ARCH = DATA.architecture || observedArchitecture();\n\n// Diagram key -> the first trajectory node that exercised the thing it names,\n// or null when nothing did. Clicking is only offered for the former.\nconst ARCH_KEYS = new Map();\nlet archSeq = 0;\nfunction archKey(used) {\n const k = 'x' + archSeq++;\n ARCH_KEYS.set(k, used ? used.key : null);\n return k;\n}\n\n/** An agent with more tools than this gets a \"+N more\" node instead of a wall. */\nconst MAX_TOOLS = 12;\n\nfunction times(used) {\n return used ? ' x' + used.count : '';\n}\n\nfunction archDiagram() {\n const lines = ['flowchart LR'];\n lines.push(' classDef agent fill:#1e3357,stroke:#3d5f9e,color:#cfe0ff;');\n lines.push(' classDef start fill:#14392c,stroke:#2f7a5c,color:#c6f5e0;');\n lines.push(' classDef tool fill:#1e3d33,stroke:#3d7a66,color:#c8f0e2;');\n lines.push(' classDef skill fill:#3a2450,stroke:#6b4b90,color:#e6d3ff;');\n lines.push(' classDef mem fill:#40331a,stroke:#7a6535,color:#f0dfb4;');\n lines.push(' classDef more fill:#1b202c,stroke:#39415a,color:#8a92a6;');\n lines.push(' classDef idle opacity:0.4,stroke-dasharray:4 3;');\n\n const startAgent = (STATE.spec && STATE.spec.startAgent) || STATE.agentName;\n const selfKey = new Map();\n // Minted up front so a hand-off can name an agent declared further down.\n ARCH.agents.forEach(function (a) {\n selfKey.set(a.name, archKey(USED.agents.get(a.name)));\n });\n\n ARCH.agents.forEach(function (a) {\n const self = selfKey.get(a.name);\n const live = USED.agents.get(a.name);\n // The model on the node is the declared one, or \u2014 when the report carries\n // no wiring \u2014 whatever this run was seen using.\n const model = a.model || USED.models.get(a.name);\n lines.push(' subgraph sg_' + self + '[\"' + safe(a.name)\n + (a.description ? ' - ' + safe(a.description) : '') + '\"]');\n lines.push(' direction LR');\n lines.push(' ' + self + '[[\"' + safe(a.name)\n + (model ? ' ' + safe(model) + (a.inheritedModel ? ' (inherited)' : '') : '')\n + '\"]]');\n lines.push(' class ' + self + ' ' + (a.name === startAgent ? 'start' : 'agent') + ';');\n if (!live) lines.push(' class ' + self + ' idle;');\n\n // Skills first: a skill owns tools, so its node has to exist before the\n // tools it unlocks can point back at it.\n const skillKey = new Map();\n const catalog = (a.skills && a.skills.catalog) || [];\n catalog.forEach(function (s) {\n const used = USED.skills.get(a.name + SEP + s.name);\n const k = archKey(used);\n skillKey.set(s.name, k);\n lines.push(' ' + k + '([\"' + safe(s.name) + (s.preload ? ' (preloaded)' : '')\n + times(used) + '\"])');\n lines.push(' class ' + k + ' skill;');\n if (!used) lines.push(' class ' + k + ' idle;');\n lines.push(' ' + self + ' --- ' + k);\n });\n if (a.skills && a.skills.unresolved) {\n const k = archKey(null);\n lines.push(' ' + k + '([\"catalog unavailable: ' + safe(a.skills.provider) + '\"])');\n lines.push(' class ' + k + ' skill;');\n lines.push(' class ' + k + ' idle;');\n lines.push(' ' + self + ' --- ' + k);\n }\n\n const shown = a.tools.slice(0, MAX_TOOLS);\n shown.forEach(function (t) {\n const used = USED.tools.get(a.name + SEP + t.name);\n const k = archKey(used);\n lines.push(' ' + k + '[\"' + safe(t.name) + times(used) + '\"]');\n lines.push(' class ' + k + ' tool;');\n if (!used) lines.push(' class ' + k + ' idle;');\n // A skill-owned tool hangs off the skill that unlocks it, not off the\n // agent: that edge is the whole point of a locked tool.\n const owner = t.skill ? skillKey.get(t.skill) : null;\n if (owner) lines.push(' ' + owner + ' -.-> ' + k);\n else lines.push(' ' + self + ' --- ' + k);\n });\n if (a.tools.length > shown.length) {\n const k = archKey(null);\n lines.push(' ' + k + '[\"+' + (a.tools.length - shown.length) + ' more tools\"]');\n lines.push(' class ' + k + ' more;');\n lines.push(' ' + self + ' --- ' + k);\n }\n\n if (a.memory) {\n const used = USED.memory.get(a.name);\n const k = archKey(used);\n const sees = Array.isArray(a.memory.sees) ? a.memory.sees : [a.memory.sees];\n lines.push(' ' + k + '[(\"memory ' + (a.memory.access === 'read' ? '(ro)' : '(rw)')\n + ' sees ' + safe(sees.join(' ') || '*') + '\")]');\n lines.push(' class ' + k + ' mem;');\n if (!used) lines.push(' class ' + k + ' idle;');\n lines.push(' ' + self + ' --- ' + k);\n }\n lines.push(' end');\n // Mermaid's default subgraph is a light box, which on a dark page reads as\n // the foreground rather than as the container. An agent nobody entered is\n // outlined rather than filled, so a dead corner of the wiring is visible\n // from across the diagram.\n lines.push(' style sg_' + self + ' fill:#12151d,color:#8a92a6,stroke:'\n + (live ? '#39415a;' : '#2b3346,stroke-dasharray:5 4;'));\n });\n\n // Edges between agents live outside every subgraph, so Mermaid routes them\n // between the boxes instead of trying to keep them inside one.\n ARCH.agents.forEach(function (a) {\n const self = selfKey.get(a.name);\n a.handoffs.forEach(function (to) {\n if (!selfKey.has(to)) return;\n const used = USED.handoffs.get(a.name + SEP + to);\n lines.push(' ' + self + (used ? ' -- \"handoff' + times(used) + '\" --> ' : ' -.-> ')\n + selfKey.get(to));\n });\n ((a.fork && a.fork.agents) || []).forEach(function (to) {\n if (!selfKey.has(to) || to === a.name) return;\n const used = USED.forks.get(a.name + SEP + to);\n lines.push(' ' + self + ' == \"fork' + times(used) + '\" ==> ' + selfKey.get(to));\n });\n });\n return lines.join('\\n');\n}\n\nfunction archLegend() {\n const agents = ARCH.agents.length;\n const tools = ARCH.agents.reduce(function (n, a) { return n + a.tools.length; }, 0);\n const skills = ARCH.agents.reduce(function (n, a) {\n return n + ((a.skills && a.skills.catalog.length) || 0);\n }, 0);\n const idle = ARCH.agents.filter(function (a) { return !USED.agents.has(a.name); }).length;\n return (ARCH.source === 'declared' ? 'declared' : 'observed only')\n + ' \\u00b7 ' + agents + ' agents \\u00b7 ' + tools + ' tools \\u00b7 ' + skills + ' skills'\n + (idle ? ' \\u00b7 ' + idle + ' idle' : '')\n + ' \\u00b7 dimmed = unused \\u00b7 click to jump';\n}\n\nasync function drawArch() {\n document.getElementById('alegend').textContent = archLegend();\n if (!ARCH.agents.length) {\n PANES.agents.canvas.replaceChildren(el('div', 'hint pad', 'No agents to draw.'));\n return;\n }\n await draw(PANES.agents, 'arch-graph', archDiagram(), function (g) {\n const m = /-(x\\d+)-\\d+$/.exec(g.id || '');\n const key = m && ARCH_KEYS.get(m[1]);\n if (!key) return;\n g.addEventListener('click', function () { if (!PANES.agents.moved()) select(key, true); });\n });\n}\n\n// --- memory ---------------------------------------------------------------\n//\n// Two questions in one picture: what the run took out of the memory, and what\n// it put back. The trajectory records ids, kinds and edges but no bodies, so\n// the text comes from the graph as the report was built \u2014 which is later than\n// the run, and the panel says so where it matters: a node can have moved on a\n// revision since, or be gone from the memory entirely.\n\nconst MEM = new Map();\nconst MEM_EDGES = [];\nconst MEM_EDGE_SEEN = new Set();\n\nfunction memOf(m) {\n let x = MEM.get(m.id);\n if (!x) {\n x = { id: m.id, kind: m.kind, read: false, added: false, gone: false, full: false,\n seed: false, score: null, revision: null, where: [] };\n MEM.set(m.id, x);\n }\n return x;\n}\n\n// The same edge comes back with every recall that crossed it; drawing it once\n// is the difference between a graph and a bundle of parallel arrows.\nfunction memEdge(e) {\n const k = e.source + '|' + e.relation + '|' + e.target;\n if (MEM_EDGE_SEEN.has(k)) return;\n MEM_EDGE_SEEN.add(k);\n MEM_EDGES.push(e);\n}\n\nENTRIES.forEach(function (entry) {\n const n = entry.node;\n if (n.type !== 'memory_recall' && n.type !== 'memory_op') return;\n n.nodes.forEach(function (m) {\n const x = memOf(m);\n if (x.where.indexOf(entry.key) < 0) x.where.push(entry.key);\n if (n.type === 'memory_recall') {\n x.read = true;\n if (n.seeds.indexOf(m.id) >= 0) {\n x.seed = true;\n if (x.score === null || m.score > x.score) x.score = m.score;\n }\n return;\n }\n x.revision = m.revision;\n if (n.op === 'commit') x.added = true;\n else if (n.op === 'forget') x.gone = true;\n else x.full = true;\n });\n (n.edges || []).forEach(memEdge);\n});\n\nconst MEM_SNAP = new Map();\n((DATA.memory && DATA.memory.nodes) || []).forEach(function (n) { MEM_SNAP.set(n.id, n); });\n// The graph knows joins the run never travelled \u2014 the edge between a task it\n// read and the artifact it then wrote is exactly the one worth seeing.\n((DATA.memory && DATA.memory.edges) || []).forEach(memEdge);\n\nconst MEM_ADJ = new Map();\nfunction memLink(id, other, rel, out) {\n if (!MEM_ADJ.has(id)) MEM_ADJ.set(id, []);\n MEM_ADJ.get(id).push({ other: other, rel: rel, out: out });\n}\nMEM_EDGES.forEach(function (e) {\n memLink(e.source, e.target, e.relation, true);\n memLink(e.target, e.source, e.relation, false);\n});\n\n// --- the memory diagram ---------------------------------------------------\n\nconst MEM_MAX = 300;\nconst MEM_SHAPES = { file: ['[/', '/]'], plan: ['{{', '}}'], operation: ['([', '])'] };\nconst MEM_PAINTED = new Set(\n ['task', 'plan', 'fact', 'snippet', 'file', 'operation', 'preference']);\nconst MEM_KEY = new Map();\nconst MEM_ID = new Map();\n\nfunction shortId(id) {\n return id.length > 12 ? id.slice(0, 4) + '\\u2026' + id.slice(-4) : id;\n}\n\nfunction clipText(text, n) {\n const flat = String(text === undefined || text === null ? '' : text).replace(/\\s+/g, ' ').trim();\n return flat.length > n ? flat.slice(0, n - 1) + '...' : flat;\n}\n\n/**\n * Text as at most 'max' lines of roughly 'width' characters. Mermaid will not\n * break a label, so a node is otherwise as wide as its sentence and the graph\n * is unreadable at any zoom that fits.\n */\nfunction memWrap(text, width, max) {\n const words = String(text === undefined || text === null ? '' : text)\n .replace(/\\s+/g, ' ').trim().split(' ');\n const out = [];\n let cur = '';\n let i = 0;\n for (; i < words.length; i++) {\n const w = words[i].length > width ? words[i].slice(0, width - 1) + '...' : words[i];\n if (!w) continue;\n if (!cur) cur = w;\n else if (cur.length + 1 + w.length <= width) cur += ' ' + w;\n else if (out.length + 1 < max) { out.push(cur); cur = w; }\n else break;\n }\n if (cur) out.push(cur);\n const last = out.length - 1;\n if (i < words.length && last >= 0 && out[last].slice(-3) !== '...') out[last] += '...';\n return out;\n}\n\n// Built from a closed-vocabulary kind, an id and a clip of text put through the\n// same sanitizer the run graph uses: nothing a model wrote reaches Mermaid.\n// Shaped like the memory page's labels \u2014 what it is on the first line, what it\n// says underneath \u2014 because run together there is no telling the three apart.\nfunction memLabel(x) {\n const snap = MEM_SNAP.get(x.id);\n const head = safe(x.kind || 'node') + ' \\u00b7 ' + shortId(safe(x.id));\n // Drawing the stored text under a node the model never opened would read as\n // context it had.\n if (memSaw(x) === 'id') return [head, '(id only)'].join('<br/>');\n return [head].concat(snap ? memWrap(snap.text, 28, 3).map(safe) : []).join('<br/>');\n}\n\n// How much of the node reached the model: a recall block carries one clipped\n// line each, memory_load returns whole bodies, and what the run wrote it wrote\n// in its own words. A node it only forgot was named by id and nothing more.\nfunction memSaw(x) {\n if (x.full || x.added) return 'full';\n if (x.read) return 'summary';\n return 'id';\n}\n\nconst MEM_SAW = {\n full: 'the whole text',\n summary: 'a clipped line, in the recall block',\n id: 'the id only \\u2014 never the content'\n};\n\n// Fill says what the node IS, outline says what the run DID with it. Painting\n// the fill by role instead made the whole graph one colour: nearly every node\n// in a run was read.\nfunction memRole(x, withAdded) {\n if (x.gone) return 'gone';\n if (x.added && withAdded) return 'added';\n return x.seed ? 'seed' : '';\n}\n\n/** A node the run only wrote is not part of \"what it read\", so it waits. */\nfunction memShown(withAdded) {\n const out = [];\n MEM.forEach(function (x) {\n if (!withAdded && x.added && !x.read && !x.gone) return;\n out.push(x);\n });\n return out;\n}\n\nfunction memDiagram(shown, withAdded) {\n const lines = ['flowchart LR'];\n lines.push(' classDef task fill:#1e3357,stroke:#3d5f9e,color:#cfe0ff;');\n lines.push(' classDef plan fill:#3a2450,stroke:#6b4b90,color:#e6d3ff;');\n lines.push(' classDef fact fill:#14392c,stroke:#2f7a5c,color:#c6f5e0;');\n lines.push(' classDef snippet fill:#1e3d33,stroke:#3d7a66,color:#c8f0e2;');\n lines.push(' classDef file fill:#40331a,stroke:#7a6535,color:#f0dfb4;');\n lines.push(' classDef operation fill:#3d2a1b,stroke:#7a5535,color:#f2d9c2;');\n lines.push(' classDef preference fill:#3d1f2c,stroke:#7a3b56,color:#f5b8cd;');\n // Defined last so the outline wins over the kind's own stroke.\n lines.push(' classDef seed stroke:#6ea8fe,stroke-width:3px;');\n lines.push(' classDef added stroke:#4ec9a0,stroke-width:3px;');\n lines.push(' classDef gone stroke:#f0776c,stroke-width:2px,stroke-dasharray:4 3,opacity:0.6;');\n MEM_KEY.clear();\n MEM_ID.clear();\n shown.forEach(function (x, i) {\n const key = 'm' + i;\n MEM_KEY.set(x.id, key);\n MEM_ID.set(key, x.id);\n const shape = MEM_SHAPES[x.kind] || ['(', ')'];\n lines.push(' ' + key + shape[0] + '\"' + memLabel(x) + '\"' + shape[1]);\n // Kinds are configurable, so naming a class that was never defined would\n // fail the whole render.\n if (MEM_PAINTED.has(x.kind)) lines.push(' class ' + key + ' ' + x.kind + ';');\n const role = memRole(x, withAdded);\n if (role) lines.push(' class ' + key + ' ' + role + ';');\n });\n MEM_EDGES.forEach(function (e) {\n const a = MEM_KEY.get(e.source), b = MEM_KEY.get(e.target);\n if (a && b) lines.push(' ' + a + ' -->|' + safe(e.relation.toLowerCase()) + '| ' + b);\n });\n return lines.join('\\n');\n}\n\n// The two counts that have an outline colour in the graph wear it here too.\nfunction memLegend(drawn) {\n let read = 0, added = 0, gone = 0;\n MEM.forEach(function (x) {\n if (x.read) read++;\n if (x.added) added++;\n if (x.gone) gone++;\n });\n const dot = ' \\u00b7 ';\n const out = [el('span', null, read + ' read'), document.createTextNode(dot),\n el('span', 'ok', added + ' written'), document.createTextNode(dot),\n el('span', 'bad', gone + ' forgotten')];\n if (drawn < MEM.size) out.push(document.createTextNode(dot + drawn + ' drawn'));\n if (!DATA.memory) out.push(document.createTextNode(dot + 'no memory attached: ids only'));\n return out;\n}\n\nconst memAddedEl = document.getElementById('madded');\nconst mdetailEl = document.getElementById('mdetail');\nconst memTab = document.querySelector('#viewtabs button[data-view=\"memory\"]');\nlet memDrawn = false;\nlet memSeq = 0;\nlet memSel = null;\n\nif (MEM.size) memTab.hidden = false;\nmemAddedEl.addEventListener('change', function () { drawMemory(); });\n\nfunction memKeyOf(g) {\n const m = /-(m\\d+)-\\d+$/.exec(g.id || '');\n return m ? m[1] : null;\n}\n\nfunction memMark() {\n const key = memSel === null ? null : MEM_KEY.get(memSel);\n PANES.memory.canvas.querySelectorAll('.node').forEach(function (g) {\n g.classList.toggle('picked', Boolean(key) && memKeyOf(g) === key);\n });\n}\n\nasync function drawMemory() {\n const withAdded = memAddedEl.checked;\n const shown = memShown(withAdded).slice(0, MEM_MAX);\n const count = document.getElementById('mcount');\n count.replaceChildren.apply(count, memLegend(shown.length));\n if (!shown.length) {\n PANES.memory.canvas.replaceChildren(el('div', 'hint pad',\n 'This run neither read nor wrote memory.'));\n return;\n }\n // A fresh render id each time: the checkbox redraws this pane, and Mermaid\n // keeps the element it was given.\n await draw(PANES.memory, 'mem-graph-' + (++memSeq), memDiagram(shown, withAdded), function (g) {\n const id = MEM_ID.get(memKeyOf(g));\n if (!id) return;\n g.addEventListener('click', function () { if (!PANES.memory.moved()) memSelect(id); });\n });\n memMark();\n}\n\n// --- the memory panel -----------------------------------------------------\n\nfunction memProp(dl, key, value) {\n if (value === undefined || value === null || value === '') return;\n dl.appendChild(el('dt', null, key));\n dl.appendChild(el('dd', null, value));\n}\n\nconst MEM_EXCUSE = {\n 'too-big': 'too big to inline \\u2014 read it from the memory directory',\n binary: 'binary, so there is nothing to show',\n missing: 'the graph records this file but it is not on disk',\n unreadable: 'on disk but could not be read'\n};\n\nfunction memSelect(id) {\n memSel = id;\n mdetailEl.replaceChildren(memDetail(id));\n mdetailEl.scrollTop = 0;\n memMark();\n}\n\nfunction memDetail(id) {\n const x = MEM.get(id) || { id: id, kind: '', where: [], score: null, revision: null };\n const snap = MEM_SNAP.get(id);\n const out = document.createDocumentFragment();\n\n const h = el('div', 'dt');\n const kind = x.kind || (snap && snap.kind) || '';\n h.appendChild(el('span', 'kind ' + kind, kind || 'node'));\n if (x.read) h.appendChild(el('span', 'badge', 'read'));\n if (x.full) h.appendChild(el('span', 'badge', 'loaded'));\n if (x.added) h.appendChild(el('span', 'badge ok', 'written'));\n if (x.gone) h.appendChild(el('span', 'badge bad', 'forgotten'));\n if (snap && snap.stale) h.appendChild(el('span', 'badge', 'superseded'));\n out.appendChild(h);\n\n // The id is what you carry to 'zen memory show', so it is whole and takeable.\n const idrow = el('div', 'idrow');\n idrow.appendChild(el('code', null, id));\n const copy = el('button', null, 'copy');\n copy.addEventListener('click', function () {\n navigator.clipboard.writeText(id).then(function () {\n copy.textContent = 'copied';\n setTimeout(function () { copy.textContent = 'copy'; }, 1200);\n }, function () { copy.textContent = 'blocked'; });\n });\n idrow.appendChild(copy);\n out.appendChild(idrow);\n\n const props = el('dl', 'props');\n memProp(props, 'model saw', MEM_SAW[memSaw(x)]);\n if (x.score !== null) memProp(props, 'score', x.score.toFixed(3) + (x.seed ? ' match' : ''));\n else if (x.read) memProp(props, 'reached', 'stitched in, not matched');\n if (snap) {\n memProp(props, 'audience', snap.audience.join(', ') || '\\u2014');\n // The run's revision and the current one are the same number until\n // something wrote this node again after the run.\n memProp(props, 'revision', x.revision !== null && x.revision !== snap.revision\n ? x.revision + ' then, ' + snap.revision + ' now' : String(snap.revision));\n memProp(props, 'recalled', snap.useCount + 'x \\u00b7 last ' + snap.lastUsedAt);\n memProp(props, 'created', snap.createdAt);\n if (snap.updatedAt !== snap.createdAt) memProp(props, 'updated', snap.updatedAt);\n } else if (x.revision !== null) {\n memProp(props, 'revision', String(x.revision));\n }\n out.appendChild(props);\n\n if (snap) {\n out.appendChild(textBlock('Text', snap.text));\n if (snap.metadata) {\n out.appendChild(textBlock('Metadata', JSON.stringify(snap.metadata, null, 2)));\n }\n if (snap.file) out.appendChild(memFile(snap));\n } else {\n const s = block('Text', DATA.memory ? 'gone' : 'not attached');\n s.appendChild(el('div', 'empty', DATA.memory\n ? 'no longer in the memory graph \\u2014 the run recorded its id, kind and edges, not its text'\n : 'this report carries no memory, so only what the run itself recorded is here'));\n out.appendChild(s);\n }\n\n const links = MEM_ADJ.get(id) || [];\n const ls = block('Links', String(links.length));\n if (!links.length) {\n ls.appendChild(el('div', 'empty', 'nothing points here and it points nowhere'));\n } else {\n const ul = el('ul', 'links');\n // Outgoing first: PRODUCED read forwards is the story of the node.\n links.slice().sort(function (a, b) { return (a.out ? 0 : 1) - (b.out ? 0 : 1); })\n .forEach(function (l) {\n const other = MEM_SNAP.get(l.other);\n const li = el('li');\n li.appendChild(el('span', 'rel', (l.out ? '\\u2192 ' : '\\u2190 ') + l.rel));\n li.appendChild(el('span', 'kind ' + (other ? other.kind : ''), other ? other.kind : '?'));\n const b = el('button', 'link',\n (other && clipText(other.text, 60)) || shortId(l.other));\n b.title = l.other;\n b.addEventListener('click', function () { memSelect(l.other); });\n li.appendChild(b);\n ul.appendChild(li);\n });\n ls.appendChild(ul);\n }\n out.appendChild(ls);\n\n // The way back to the trace: which turn reached for this, and what it did.\n const ws = block('In this run', x.where.length + ' node(s)');\n const line = el('div', 'empty', '');\n x.where.forEach(function (key) {\n const hit = BY_KEY.get(key);\n if (!hit) return;\n const b = el('button', 'link', label(hit.node));\n b.addEventListener('click', function () { select(key, true); });\n line.appendChild(b);\n });\n ws.appendChild(line);\n out.appendChild(ws);\n return out;\n}\n\nfunction memFile(snap) {\n const tag = snap.file.path + ' \\u00b7 ' + snap.file.bytes + ' B';\n const src = snap.image ? imageSrc({ type: 'image', url: snap.image }) : null;\n if (src) {\n const s = block('File', tag);\n const img = el('img', 'media');\n img.src = src;\n img.alt = snap.file.path;\n img.loading = 'lazy';\n s.appendChild(img);\n return s;\n }\n if (snap.content !== undefined && snap.content !== null) {\n return textBlock('File', snap.content, tag);\n }\n const s = block('File', tag);\n s.appendChild(el('div', 'empty', MEM_EXCUSE[snap.omitted] || 'not shown'));\n return s;\n}\n\ndrawGraph().then(drawArch);\nbuildStats();\nif (ENTRIES.length) select('n0', false);\n";
2
2
  //# sourceMappingURL=client.d.ts.map
@@ -803,11 +803,15 @@ const PANES = {};
803
803
  function show(view) {
804
804
  document.getElementById('graph').hidden = view !== 'graph';
805
805
  document.getElementById('agents').hidden = view !== 'agents';
806
+ document.getElementById('memory').hidden = view !== 'memory';
806
807
  detailEl.hidden = view !== 'detail';
807
808
  statsEl.hidden = view !== 'stats';
808
809
  Array.prototype.forEach.call(document.getElementById('viewtabs').children, function (b) {
809
810
  b.classList.toggle('on', b.dataset.view === view);
810
811
  });
812
+ // The memory graph is the one view that costs a render nobody asked for, so
813
+ // it waits until somebody asks.
814
+ if (view === 'memory' && !memDrawn) { memDrawn = true; drawMemory(); }
811
815
  if (PANES[view]) PANES[view].refit();
812
816
  }
813
817
  Array.prototype.forEach.call(document.getElementById('viewtabs').children, function (b) {
@@ -924,6 +928,7 @@ function panZoom(paneId) {
924
928
 
925
929
  PANES.graph = panZoom('graph');
926
930
  PANES.agents = panZoom('agents');
931
+ PANES.memory = panZoom('memory');
927
932
 
928
933
  // --- diagram --------------------------------------------------------------
929
934
 
@@ -1165,7 +1170,7 @@ function observedArchitecture() {
1165
1170
  // A run shows that an agent reached the memory, never how wide its mask
1166
1171
  // was: nothing it did not read leaves a trace to read the mask off.
1167
1172
  if (!a.memory) a.memory = { access: 'read', sees: [], writes: [] };
1168
- if (n.type === 'memory_op') a.memory.access = 'read-write';
1173
+ if (n.type === 'memory_op' && n.op !== 'load') a.memory.access = 'read-write';
1169
1174
  } else if (n.type === 'fork') {
1170
1175
  if (!a.fork) a.fork = { agents: [] };
1171
1176
  n.branches.forEach(function (b) {
@@ -1335,6 +1340,399 @@ async function drawArch() {
1335
1340
  });
1336
1341
  }
1337
1342
 
1343
+ // --- memory ---------------------------------------------------------------
1344
+ //
1345
+ // Two questions in one picture: what the run took out of the memory, and what
1346
+ // it put back. The trajectory records ids, kinds and edges but no bodies, so
1347
+ // the text comes from the graph as the report was built — which is later than
1348
+ // the run, and the panel says so where it matters: a node can have moved on a
1349
+ // revision since, or be gone from the memory entirely.
1350
+
1351
+ const MEM = new Map();
1352
+ const MEM_EDGES = [];
1353
+ const MEM_EDGE_SEEN = new Set();
1354
+
1355
+ function memOf(m) {
1356
+ let x = MEM.get(m.id);
1357
+ if (!x) {
1358
+ x = { id: m.id, kind: m.kind, read: false, added: false, gone: false, full: false,
1359
+ seed: false, score: null, revision: null, where: [] };
1360
+ MEM.set(m.id, x);
1361
+ }
1362
+ return x;
1363
+ }
1364
+
1365
+ // The same edge comes back with every recall that crossed it; drawing it once
1366
+ // is the difference between a graph and a bundle of parallel arrows.
1367
+ function memEdge(e) {
1368
+ const k = e.source + '|' + e.relation + '|' + e.target;
1369
+ if (MEM_EDGE_SEEN.has(k)) return;
1370
+ MEM_EDGE_SEEN.add(k);
1371
+ MEM_EDGES.push(e);
1372
+ }
1373
+
1374
+ ENTRIES.forEach(function (entry) {
1375
+ const n = entry.node;
1376
+ if (n.type !== 'memory_recall' && n.type !== 'memory_op') return;
1377
+ n.nodes.forEach(function (m) {
1378
+ const x = memOf(m);
1379
+ if (x.where.indexOf(entry.key) < 0) x.where.push(entry.key);
1380
+ if (n.type === 'memory_recall') {
1381
+ x.read = true;
1382
+ if (n.seeds.indexOf(m.id) >= 0) {
1383
+ x.seed = true;
1384
+ if (x.score === null || m.score > x.score) x.score = m.score;
1385
+ }
1386
+ return;
1387
+ }
1388
+ x.revision = m.revision;
1389
+ if (n.op === 'commit') x.added = true;
1390
+ else if (n.op === 'forget') x.gone = true;
1391
+ else x.full = true;
1392
+ });
1393
+ (n.edges || []).forEach(memEdge);
1394
+ });
1395
+
1396
+ const MEM_SNAP = new Map();
1397
+ ((DATA.memory && DATA.memory.nodes) || []).forEach(function (n) { MEM_SNAP.set(n.id, n); });
1398
+ // The graph knows joins the run never travelled — the edge between a task it
1399
+ // read and the artifact it then wrote is exactly the one worth seeing.
1400
+ ((DATA.memory && DATA.memory.edges) || []).forEach(memEdge);
1401
+
1402
+ const MEM_ADJ = new Map();
1403
+ function memLink(id, other, rel, out) {
1404
+ if (!MEM_ADJ.has(id)) MEM_ADJ.set(id, []);
1405
+ MEM_ADJ.get(id).push({ other: other, rel: rel, out: out });
1406
+ }
1407
+ MEM_EDGES.forEach(function (e) {
1408
+ memLink(e.source, e.target, e.relation, true);
1409
+ memLink(e.target, e.source, e.relation, false);
1410
+ });
1411
+
1412
+ // --- the memory diagram ---------------------------------------------------
1413
+
1414
+ const MEM_MAX = 300;
1415
+ const MEM_SHAPES = { file: ['[/', '/]'], plan: ['{{', '}}'], operation: ['([', '])'] };
1416
+ const MEM_PAINTED = new Set(
1417
+ ['task', 'plan', 'fact', 'snippet', 'file', 'operation', 'preference']);
1418
+ const MEM_KEY = new Map();
1419
+ const MEM_ID = new Map();
1420
+
1421
+ function shortId(id) {
1422
+ return id.length > 12 ? id.slice(0, 4) + '\\u2026' + id.slice(-4) : id;
1423
+ }
1424
+
1425
+ function clipText(text, n) {
1426
+ const flat = String(text === undefined || text === null ? '' : text).replace(/\\s+/g, ' ').trim();
1427
+ return flat.length > n ? flat.slice(0, n - 1) + '...' : flat;
1428
+ }
1429
+
1430
+ /**
1431
+ * Text as at most 'max' lines of roughly 'width' characters. Mermaid will not
1432
+ * break a label, so a node is otherwise as wide as its sentence and the graph
1433
+ * is unreadable at any zoom that fits.
1434
+ */
1435
+ function memWrap(text, width, max) {
1436
+ const words = String(text === undefined || text === null ? '' : text)
1437
+ .replace(/\\s+/g, ' ').trim().split(' ');
1438
+ const out = [];
1439
+ let cur = '';
1440
+ let i = 0;
1441
+ for (; i < words.length; i++) {
1442
+ const w = words[i].length > width ? words[i].slice(0, width - 1) + '...' : words[i];
1443
+ if (!w) continue;
1444
+ if (!cur) cur = w;
1445
+ else if (cur.length + 1 + w.length <= width) cur += ' ' + w;
1446
+ else if (out.length + 1 < max) { out.push(cur); cur = w; }
1447
+ else break;
1448
+ }
1449
+ if (cur) out.push(cur);
1450
+ const last = out.length - 1;
1451
+ if (i < words.length && last >= 0 && out[last].slice(-3) !== '...') out[last] += '...';
1452
+ return out;
1453
+ }
1454
+
1455
+ // Built from a closed-vocabulary kind, an id and a clip of text put through the
1456
+ // same sanitizer the run graph uses: nothing a model wrote reaches Mermaid.
1457
+ // Shaped like the memory page's labels — what it is on the first line, what it
1458
+ // says underneath — because run together there is no telling the three apart.
1459
+ function memLabel(x) {
1460
+ const snap = MEM_SNAP.get(x.id);
1461
+ const head = safe(x.kind || 'node') + ' \\u00b7 ' + shortId(safe(x.id));
1462
+ // Drawing the stored text under a node the model never opened would read as
1463
+ // context it had.
1464
+ if (memSaw(x) === 'id') return [head, '(id only)'].join('<br/>');
1465
+ return [head].concat(snap ? memWrap(snap.text, 28, 3).map(safe) : []).join('<br/>');
1466
+ }
1467
+
1468
+ // How much of the node reached the model: a recall block carries one clipped
1469
+ // line each, memory_load returns whole bodies, and what the run wrote it wrote
1470
+ // in its own words. A node it only forgot was named by id and nothing more.
1471
+ function memSaw(x) {
1472
+ if (x.full || x.added) return 'full';
1473
+ if (x.read) return 'summary';
1474
+ return 'id';
1475
+ }
1476
+
1477
+ const MEM_SAW = {
1478
+ full: 'the whole text',
1479
+ summary: 'a clipped line, in the recall block',
1480
+ id: 'the id only \\u2014 never the content'
1481
+ };
1482
+
1483
+ // Fill says what the node IS, outline says what the run DID with it. Painting
1484
+ // the fill by role instead made the whole graph one colour: nearly every node
1485
+ // in a run was read.
1486
+ function memRole(x, withAdded) {
1487
+ if (x.gone) return 'gone';
1488
+ if (x.added && withAdded) return 'added';
1489
+ return x.seed ? 'seed' : '';
1490
+ }
1491
+
1492
+ /** A node the run only wrote is not part of "what it read", so it waits. */
1493
+ function memShown(withAdded) {
1494
+ const out = [];
1495
+ MEM.forEach(function (x) {
1496
+ if (!withAdded && x.added && !x.read && !x.gone) return;
1497
+ out.push(x);
1498
+ });
1499
+ return out;
1500
+ }
1501
+
1502
+ function memDiagram(shown, withAdded) {
1503
+ const lines = ['flowchart LR'];
1504
+ lines.push(' classDef task fill:#1e3357,stroke:#3d5f9e,color:#cfe0ff;');
1505
+ lines.push(' classDef plan fill:#3a2450,stroke:#6b4b90,color:#e6d3ff;');
1506
+ lines.push(' classDef fact fill:#14392c,stroke:#2f7a5c,color:#c6f5e0;');
1507
+ lines.push(' classDef snippet fill:#1e3d33,stroke:#3d7a66,color:#c8f0e2;');
1508
+ lines.push(' classDef file fill:#40331a,stroke:#7a6535,color:#f0dfb4;');
1509
+ lines.push(' classDef operation fill:#3d2a1b,stroke:#7a5535,color:#f2d9c2;');
1510
+ lines.push(' classDef preference fill:#3d1f2c,stroke:#7a3b56,color:#f5b8cd;');
1511
+ // Defined last so the outline wins over the kind's own stroke.
1512
+ lines.push(' classDef seed stroke:#6ea8fe,stroke-width:3px;');
1513
+ lines.push(' classDef added stroke:#4ec9a0,stroke-width:3px;');
1514
+ lines.push(' classDef gone stroke:#f0776c,stroke-width:2px,stroke-dasharray:4 3,opacity:0.6;');
1515
+ MEM_KEY.clear();
1516
+ MEM_ID.clear();
1517
+ shown.forEach(function (x, i) {
1518
+ const key = 'm' + i;
1519
+ MEM_KEY.set(x.id, key);
1520
+ MEM_ID.set(key, x.id);
1521
+ const shape = MEM_SHAPES[x.kind] || ['(', ')'];
1522
+ lines.push(' ' + key + shape[0] + '"' + memLabel(x) + '"' + shape[1]);
1523
+ // Kinds are configurable, so naming a class that was never defined would
1524
+ // fail the whole render.
1525
+ if (MEM_PAINTED.has(x.kind)) lines.push(' class ' + key + ' ' + x.kind + ';');
1526
+ const role = memRole(x, withAdded);
1527
+ if (role) lines.push(' class ' + key + ' ' + role + ';');
1528
+ });
1529
+ MEM_EDGES.forEach(function (e) {
1530
+ const a = MEM_KEY.get(e.source), b = MEM_KEY.get(e.target);
1531
+ if (a && b) lines.push(' ' + a + ' -->|' + safe(e.relation.toLowerCase()) + '| ' + b);
1532
+ });
1533
+ return lines.join('\\n');
1534
+ }
1535
+
1536
+ // The two counts that have an outline colour in the graph wear it here too.
1537
+ function memLegend(drawn) {
1538
+ let read = 0, added = 0, gone = 0;
1539
+ MEM.forEach(function (x) {
1540
+ if (x.read) read++;
1541
+ if (x.added) added++;
1542
+ if (x.gone) gone++;
1543
+ });
1544
+ const dot = ' \\u00b7 ';
1545
+ const out = [el('span', null, read + ' read'), document.createTextNode(dot),
1546
+ el('span', 'ok', added + ' written'), document.createTextNode(dot),
1547
+ el('span', 'bad', gone + ' forgotten')];
1548
+ if (drawn < MEM.size) out.push(document.createTextNode(dot + drawn + ' drawn'));
1549
+ if (!DATA.memory) out.push(document.createTextNode(dot + 'no memory attached: ids only'));
1550
+ return out;
1551
+ }
1552
+
1553
+ const memAddedEl = document.getElementById('madded');
1554
+ const mdetailEl = document.getElementById('mdetail');
1555
+ const memTab = document.querySelector('#viewtabs button[data-view="memory"]');
1556
+ let memDrawn = false;
1557
+ let memSeq = 0;
1558
+ let memSel = null;
1559
+
1560
+ if (MEM.size) memTab.hidden = false;
1561
+ memAddedEl.addEventListener('change', function () { drawMemory(); });
1562
+
1563
+ function memKeyOf(g) {
1564
+ const m = /-(m\\d+)-\\d+$/.exec(g.id || '');
1565
+ return m ? m[1] : null;
1566
+ }
1567
+
1568
+ function memMark() {
1569
+ const key = memSel === null ? null : MEM_KEY.get(memSel);
1570
+ PANES.memory.canvas.querySelectorAll('.node').forEach(function (g) {
1571
+ g.classList.toggle('picked', Boolean(key) && memKeyOf(g) === key);
1572
+ });
1573
+ }
1574
+
1575
+ async function drawMemory() {
1576
+ const withAdded = memAddedEl.checked;
1577
+ const shown = memShown(withAdded).slice(0, MEM_MAX);
1578
+ const count = document.getElementById('mcount');
1579
+ count.replaceChildren.apply(count, memLegend(shown.length));
1580
+ if (!shown.length) {
1581
+ PANES.memory.canvas.replaceChildren(el('div', 'hint pad',
1582
+ 'This run neither read nor wrote memory.'));
1583
+ return;
1584
+ }
1585
+ // A fresh render id each time: the checkbox redraws this pane, and Mermaid
1586
+ // keeps the element it was given.
1587
+ await draw(PANES.memory, 'mem-graph-' + (++memSeq), memDiagram(shown, withAdded), function (g) {
1588
+ const id = MEM_ID.get(memKeyOf(g));
1589
+ if (!id) return;
1590
+ g.addEventListener('click', function () { if (!PANES.memory.moved()) memSelect(id); });
1591
+ });
1592
+ memMark();
1593
+ }
1594
+
1595
+ // --- the memory panel -----------------------------------------------------
1596
+
1597
+ function memProp(dl, key, value) {
1598
+ if (value === undefined || value === null || value === '') return;
1599
+ dl.appendChild(el('dt', null, key));
1600
+ dl.appendChild(el('dd', null, value));
1601
+ }
1602
+
1603
+ const MEM_EXCUSE = {
1604
+ 'too-big': 'too big to inline \\u2014 read it from the memory directory',
1605
+ binary: 'binary, so there is nothing to show',
1606
+ missing: 'the graph records this file but it is not on disk',
1607
+ unreadable: 'on disk but could not be read'
1608
+ };
1609
+
1610
+ function memSelect(id) {
1611
+ memSel = id;
1612
+ mdetailEl.replaceChildren(memDetail(id));
1613
+ mdetailEl.scrollTop = 0;
1614
+ memMark();
1615
+ }
1616
+
1617
+ function memDetail(id) {
1618
+ const x = MEM.get(id) || { id: id, kind: '', where: [], score: null, revision: null };
1619
+ const snap = MEM_SNAP.get(id);
1620
+ const out = document.createDocumentFragment();
1621
+
1622
+ const h = el('div', 'dt');
1623
+ const kind = x.kind || (snap && snap.kind) || '';
1624
+ h.appendChild(el('span', 'kind ' + kind, kind || 'node'));
1625
+ if (x.read) h.appendChild(el('span', 'badge', 'read'));
1626
+ if (x.full) h.appendChild(el('span', 'badge', 'loaded'));
1627
+ if (x.added) h.appendChild(el('span', 'badge ok', 'written'));
1628
+ if (x.gone) h.appendChild(el('span', 'badge bad', 'forgotten'));
1629
+ if (snap && snap.stale) h.appendChild(el('span', 'badge', 'superseded'));
1630
+ out.appendChild(h);
1631
+
1632
+ // The id is what you carry to 'zen memory show', so it is whole and takeable.
1633
+ const idrow = el('div', 'idrow');
1634
+ idrow.appendChild(el('code', null, id));
1635
+ const copy = el('button', null, 'copy');
1636
+ copy.addEventListener('click', function () {
1637
+ navigator.clipboard.writeText(id).then(function () {
1638
+ copy.textContent = 'copied';
1639
+ setTimeout(function () { copy.textContent = 'copy'; }, 1200);
1640
+ }, function () { copy.textContent = 'blocked'; });
1641
+ });
1642
+ idrow.appendChild(copy);
1643
+ out.appendChild(idrow);
1644
+
1645
+ const props = el('dl', 'props');
1646
+ memProp(props, 'model saw', MEM_SAW[memSaw(x)]);
1647
+ if (x.score !== null) memProp(props, 'score', x.score.toFixed(3) + (x.seed ? ' match' : ''));
1648
+ else if (x.read) memProp(props, 'reached', 'stitched in, not matched');
1649
+ if (snap) {
1650
+ memProp(props, 'audience', snap.audience.join(', ') || '\\u2014');
1651
+ // The run's revision and the current one are the same number until
1652
+ // something wrote this node again after the run.
1653
+ memProp(props, 'revision', x.revision !== null && x.revision !== snap.revision
1654
+ ? x.revision + ' then, ' + snap.revision + ' now' : String(snap.revision));
1655
+ memProp(props, 'recalled', snap.useCount + 'x \\u00b7 last ' + snap.lastUsedAt);
1656
+ memProp(props, 'created', snap.createdAt);
1657
+ if (snap.updatedAt !== snap.createdAt) memProp(props, 'updated', snap.updatedAt);
1658
+ } else if (x.revision !== null) {
1659
+ memProp(props, 'revision', String(x.revision));
1660
+ }
1661
+ out.appendChild(props);
1662
+
1663
+ if (snap) {
1664
+ out.appendChild(textBlock('Text', snap.text));
1665
+ if (snap.metadata) {
1666
+ out.appendChild(textBlock('Metadata', JSON.stringify(snap.metadata, null, 2)));
1667
+ }
1668
+ if (snap.file) out.appendChild(memFile(snap));
1669
+ } else {
1670
+ const s = block('Text', DATA.memory ? 'gone' : 'not attached');
1671
+ s.appendChild(el('div', 'empty', DATA.memory
1672
+ ? 'no longer in the memory graph \\u2014 the run recorded its id, kind and edges, not its text'
1673
+ : 'this report carries no memory, so only what the run itself recorded is here'));
1674
+ out.appendChild(s);
1675
+ }
1676
+
1677
+ const links = MEM_ADJ.get(id) || [];
1678
+ const ls = block('Links', String(links.length));
1679
+ if (!links.length) {
1680
+ ls.appendChild(el('div', 'empty', 'nothing points here and it points nowhere'));
1681
+ } else {
1682
+ const ul = el('ul', 'links');
1683
+ // Outgoing first: PRODUCED read forwards is the story of the node.
1684
+ links.slice().sort(function (a, b) { return (a.out ? 0 : 1) - (b.out ? 0 : 1); })
1685
+ .forEach(function (l) {
1686
+ const other = MEM_SNAP.get(l.other);
1687
+ const li = el('li');
1688
+ li.appendChild(el('span', 'rel', (l.out ? '\\u2192 ' : '\\u2190 ') + l.rel));
1689
+ li.appendChild(el('span', 'kind ' + (other ? other.kind : ''), other ? other.kind : '?'));
1690
+ const b = el('button', 'link',
1691
+ (other && clipText(other.text, 60)) || shortId(l.other));
1692
+ b.title = l.other;
1693
+ b.addEventListener('click', function () { memSelect(l.other); });
1694
+ li.appendChild(b);
1695
+ ul.appendChild(li);
1696
+ });
1697
+ ls.appendChild(ul);
1698
+ }
1699
+ out.appendChild(ls);
1700
+
1701
+ // The way back to the trace: which turn reached for this, and what it did.
1702
+ const ws = block('In this run', x.where.length + ' node(s)');
1703
+ const line = el('div', 'empty', '');
1704
+ x.where.forEach(function (key) {
1705
+ const hit = BY_KEY.get(key);
1706
+ if (!hit) return;
1707
+ const b = el('button', 'link', label(hit.node));
1708
+ b.addEventListener('click', function () { select(key, true); });
1709
+ line.appendChild(b);
1710
+ });
1711
+ ws.appendChild(line);
1712
+ out.appendChild(ws);
1713
+ return out;
1714
+ }
1715
+
1716
+ function memFile(snap) {
1717
+ const tag = snap.file.path + ' \\u00b7 ' + snap.file.bytes + ' B';
1718
+ const src = snap.image ? imageSrc({ type: 'image', url: snap.image }) : null;
1719
+ if (src) {
1720
+ const s = block('File', tag);
1721
+ const img = el('img', 'media');
1722
+ img.src = src;
1723
+ img.alt = snap.file.path;
1724
+ img.loading = 'lazy';
1725
+ s.appendChild(img);
1726
+ return s;
1727
+ }
1728
+ if (snap.content !== undefined && snap.content !== null) {
1729
+ return textBlock('File', snap.content, tag);
1730
+ }
1731
+ const s = block('File', tag);
1732
+ s.appendChild(el('div', 'empty', MEM_EXCUSE[snap.omitted] || 'not shown'));
1733
+ return s;
1734
+ }
1735
+
1338
1736
  drawGraph().then(drawArch);
1339
1737
  buildStats();
1340
1738
  if (ENTRIES.length) select('n0', false);
@@ -1,2 +1,2 @@
1
- export declare const CSS = "\n:root {\n --bg: #0f1115; --panel: #161922; --line: #262b36; --fg: #d7dbe3; --dim: #8a92a6;\n --accent: #6ea8fe; --ok: #4ec9a0; --err: #f0776c; --warn: #e2b341; --mono: ui-monospace, SFMono-Regular, Menlo, monospace;\n}\n* { box-sizing: border-box; }\nbody { margin: 0; background: var(--bg); color: var(--fg);\n font: 14px/1.5 system-ui, -apple-system, Segoe UI, sans-serif; }\nheader { padding: 12px 16px; border-bottom: 1px solid var(--line); background: var(--panel);\n display: flex; flex-wrap: wrap; gap: 8px 20px; align-items: baseline; }\nheader h1 { font-size: 15px; margin: 0 12px 0 0; font-weight: 600; }\nheader .kv { color: var(--dim); font-size: 12px; }\nheader .kv b { color: var(--fg); font-weight: 600; }\nmain { display: grid; grid-template-columns: 340px 1fr; height: calc(100vh - 49px); }\naside { border-right: 1px solid var(--line); overflow: auto; background: var(--panel); }\n.pad { padding: 10px; position: sticky; top: 0; background: var(--panel); border-bottom: 1px solid var(--line); }\n#filter { width: 100%; padding: 6px 8px; background: var(--bg); color: var(--fg);\n border: 1px solid var(--line); border-radius: 6px; }\n#timeline { list-style: none; margin: 0; padding: 4px 0; }\n#timeline li { padding: 6px 10px; border-left: 3px solid transparent; cursor: pointer; }\n#timeline li:hover { background: #1c2130; }\n#timeline li.sel { background: #1f2637; border-left-color: var(--accent); }\n#timeline li.covered { opacity: .45; }\n#timeline .row { display: flex; gap: 6px; align-items: baseline; }\n#timeline .t { color: var(--fg); font: 11px var(--mono); min-width: 38px; text-align: right; }\n#timeline .t.same { color: #3d4453; }\n#timeline .dur { margin-left: auto; color: var(--dim); font: 11px var(--mono); }\n#timeline .idx { color: var(--dim); font: 11px var(--mono); min-width: 22px; }\n#timeline .prev { color: var(--dim); font-size: 11px; white-space: nowrap;\n overflow: hidden; text-overflow: ellipsis; padding-left: 44px; }\n/* Branch histories are contiguous in this list, so a coloured edge plus a\n header is enough to stop ten parallel branches reading as one long run. */\n#timeline li.sep { cursor: default; padding: 10px 10px 4px; display: flex; gap: 7px;\n align-items: center; font-size: 11px; letter-spacing: .06em; text-transform: uppercase;\n color: var(--dim); border-left-color: transparent; }\n#timeline li.sep:hover { background: none; }\n#timeline li.sep .dot { width: 8px; height: 8px; border-radius: 2px;\n background: var(--bc, var(--dim)); }\n/* The group header shouts in uppercase; its elapsed time should not. */\n#timeline li.sep .dur { text-transform: none; letter-spacing: 0; }\n/* The branch the join actually waited for. */\n#timeline li.sep.slow { background: #232a38; color: var(--fg); }\n#timeline li.sep.slow .dur { color: var(--fg); }\n#timeline li.branch { padding-left: 20px; border-left-color: var(--bc, var(--line)); }\n#timeline li.branch.sel { border-left-color: var(--bc, var(--accent)); }\n#middle { display: flex; flex-direction: column; overflow: hidden; }\n.tabs { display: flex; gap: 4px; padding: 8px 12px; border-bottom: 1px solid var(--line); }\n.tabs button { background: none; border: 1px solid transparent; color: var(--dim);\n padding: 4px 12px; border-radius: 6px; cursor: pointer; font-size: 13px; }\n.tabs button.on { color: var(--fg); background: var(--panel); border-color: var(--line); }\n#detail { overflow: auto; padding: 16px; flex: 1; }\n#stats { overflow: auto; padding: 16px; flex: 1; }\ntable.st { width: 100%; border-collapse: collapse; font: 12px var(--mono); }\ntable.st th { text-align: right; color: var(--dim); font-weight: 600; padding: 6px 12px;\n border-bottom: 1px solid var(--line); white-space: nowrap; }\ntable.st td { text-align: right; padding: 5px 12px; border-bottom: 1px solid #1d2230;\n white-space: nowrap; }\ntable.st th:first-child, table.st td:first-child { text-align: left; }\ntable.st tbody tr:hover { background: #1c2130; }\ntable.st tbody tr:last-child td { border-bottom: none; }\n/* The total is a different kind of row, not one more of the same: it gets its\n own band so nobody reads it as another model or another agent. */\ntable.st tfoot td { color: var(--fg); font-weight: 600; background: #191e2a;\n border-top: 1px solid #2b3346; border-bottom: none; padding: 7px 12px; }\ntable.st tfoot td:first-child { color: var(--dim); text-transform: uppercase;\n letter-spacing: .07em; font-size: 11px; }\n/* Two panes hold a zoomable diagram (the run graph and the architecture), so\n everything about one is written for both. */\n.pane { display: flex; flex-direction: column; overflow: hidden; flex: 1; }\n/* An explicit display on the pane would otherwise beat the hidden attribute\n and both views would show at once. */\n.pane[hidden], #detail[hidden], #stats[hidden] { display: none; }\n.pane svg { max-width: none; display: block; }\n.pane .node { cursor: pointer; }\n/* A node with nothing behind it in this run is not clickable, and says so. */\n.pane .node.idle { cursor: default; }\n.gtools { display: flex; align-items: center; gap: 6px; padding: 6px 10px;\n border-bottom: 1px solid var(--line); background: var(--panel); }\n.gtools button { background: var(--bg); border: 1px solid var(--line); color: var(--fg);\n border-radius: 6px; padding: 2px 10px; cursor: pointer; font: 12px var(--mono); }\n.gtools button:hover { border-color: var(--accent); }\n.gtools .hint:first-of-type { margin-left: 8px; }\n.gtools .lvl { margin-left: auto; font-family: var(--mono); }\n/* The canvas is transformed, not scrolled: panning has to work past the edges\n of the diagram, which overflow:auto would forbid. */\n.viewport { flex: 1; overflow: hidden; position: relative; cursor: grab; touch-action: none;\n user-select: none; -webkit-user-select: none; }\n.viewport.drag { cursor: grabbing; }\n.canvas { position: absolute; top: 0; left: 0; transform-origin: 0 0; }\n.hint { color: var(--dim); font-size: 12px; }\n.hint.pad { padding: 16px; }\n.type { font-size: 11px; letter-spacing: .04em; text-transform: uppercase;\n padding: 1px 6px; border-radius: 4px; background: #232a38; color: var(--dim); }\n.type.llm_call { background: #1e3357; color: #9dc1ff; }\n.type.tool_call, .type.tool_result { background: #1e3d33; color: #8ee0c2; }\n.type.compaction { background: #40331a; color: var(--warn); }\n.type.fork, .type.join { background: #3a2450; color: #d1a6ff; }\n.type.final_output { background: #14392c; color: var(--ok); }\n.type.err { background: #4a1f1c; color: var(--err); }\nh2.dt { font-size: 16px; margin: 0 0 4px; display: flex; gap: 10px; align-items: center; }\n.meta { color: var(--dim); font-size: 12px; font-family: var(--mono); margin-bottom: 14px;\n display: flex; flex-wrap: wrap; gap: 4px 14px; }\nsection.blk { border: 1px solid var(--line); border-radius: 8px; margin-bottom: 12px;\n background: var(--panel); overflow: hidden; }\nsection.blk > h3 { margin: 0; padding: 7px 12px; font-size: 12px; font-weight: 600;\n color: var(--dim); border-bottom: 1px solid var(--line); letter-spacing: .03em; }\nsection.blk > h3 .tag { float: right; font-weight: 400; }\nsection.blk.sys { border-left: 3px solid var(--warn); }\n/* An LLM call has three things a reader wants to tell apart at a glance: what\n went in, what came back, and what it cost. Groups carry the first two. */\n.group { border: 1px solid var(--line); border-radius: 10px; margin-bottom: 14px;\n background: #12151d; overflow: hidden; }\n.group > .ghdr { display: flex; align-items: center; gap: 10px; padding: 8px 12px;\n font-size: 11px; font-weight: 700; letter-spacing: .09em; text-transform: uppercase;\n background: #1b2130; border-bottom: 1px solid var(--line); }\n.group > .ghdr .tag { margin-left: auto; font: 11px var(--mono); font-weight: 400;\n letter-spacing: 0; text-transform: none; color: var(--dim); }\n.group > .body { padding: 10px; }\n.group > .body > .blk:last-child { margin-bottom: 0; }\n.group.req { border-left: 3px solid var(--accent); }\n.group.req > .ghdr { color: #9dc1ff; }\n.group.res { border-left: 3px solid var(--ok); }\n.group.res > .ghdr { color: #8ee0c2; }\n.stats { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; }\n.stat { background: var(--panel); border: 1px solid var(--line); border-radius: 999px;\n padding: 3px 12px; font: 12px var(--mono); color: var(--dim); }\n.stat b { color: var(--fg); font-weight: 600; }\npre { margin: 0; padding: 10px 12px; font: 12px/1.55 var(--mono); white-space: pre-wrap;\n word-break: break-word; overflow-wrap: anywhere; }\npre.text { white-space: pre-wrap; }\nimg.media { display: block; max-width: 100%; max-height: 420px; margin: 10px 12px;\n border: 1px solid var(--line); border-radius: 6px; background: #0d1017; }\nimg.media.thumb { max-height: 88px; margin: 8px 12px; cursor: zoom-in; }\n.msg { border-top: 1px solid var(--line); border-left: 3px solid transparent; }\n.msg:first-of-type { border-top: none; }\n.msg > .role { padding: 5px 12px; font-size: 11px; text-transform: uppercase;\n letter-spacing: .05em; color: var(--dim); background: #1b202c;\n display: flex; justify-content: space-between; }\n.msg.user > .role { color: #9dc1ff; }\n.msg.user { border-left-color: #35507f; }\n.msg.assistant > .role { color: #c3a6ff; }\n.msg.assistant { border-left-color: #5a4380; }\n.msg.tool > .role { color: #8ee0c2; }\n.msg.tool { border-left-color: #2f6b58; }\n.msg.system > .role { color: var(--warn); }\n.msg.system { border-left-color: #7a6535; }\n.msg.err > .role { color: var(--err); }\n.msg.err { border-left-color: #8a3a33; }\n.call { border-top: 1px dashed var(--line); }\n.call .name { padding: 5px 12px; font: 12px var(--mono); color: #8ee0c2; }\ndetails > summary { cursor: pointer; padding: 6px 12px; color: var(--dim); font-size: 12px; }\n.badge { font: 11px var(--mono); color: var(--dim); }\n.badge.ok { color: var(--ok); } .badge.bad { color: var(--err); }\n.empty { padding: 10px 12px; color: var(--dim); font-size: 12px; font-style: italic; }\nbutton.link { background: none; border: none; color: var(--accent); cursor: pointer;\n font: 12px var(--mono); padding: 0 6px 0 0; }\n";
1
+ export declare const CSS = "\n:root {\n --bg: #0f1115; --panel: #161922; --line: #262b36; --fg: #d7dbe3; --dim: #8a92a6;\n --accent: #6ea8fe; --ok: #4ec9a0; --err: #f0776c; --warn: #e2b341; --mono: ui-monospace, SFMono-Regular, Menlo, monospace;\n}\n* { box-sizing: border-box; }\nbody { margin: 0; background: var(--bg); color: var(--fg);\n font: 14px/1.5 system-ui, -apple-system, Segoe UI, sans-serif; }\nheader { padding: 12px 16px; border-bottom: 1px solid var(--line); background: var(--panel);\n display: flex; flex-wrap: wrap; gap: 8px 20px; align-items: baseline; }\nheader h1 { font-size: 15px; margin: 0 12px 0 0; font-weight: 600; }\nheader .kv { color: var(--dim); font-size: 12px; }\nheader .kv b { color: var(--fg); font-weight: 600; }\nmain { display: grid; grid-template-columns: 340px 1fr; height: calc(100vh - 49px); }\naside { border-right: 1px solid var(--line); overflow: auto; background: var(--panel); }\n.pad { padding: 10px; position: sticky; top: 0; background: var(--panel); border-bottom: 1px solid var(--line); }\n#filter { width: 100%; padding: 6px 8px; background: var(--bg); color: var(--fg);\n border: 1px solid var(--line); border-radius: 6px; }\n#timeline { list-style: none; margin: 0; padding: 4px 0; }\n#timeline li { padding: 6px 10px; border-left: 3px solid transparent; cursor: pointer; }\n#timeline li:hover { background: #1c2130; }\n#timeline li.sel { background: #1f2637; border-left-color: var(--accent); }\n#timeline li.covered { opacity: .45; }\n#timeline .row { display: flex; gap: 6px; align-items: baseline; }\n#timeline .t { color: var(--fg); font: 11px var(--mono); min-width: 38px; text-align: right; }\n#timeline .t.same { color: #3d4453; }\n#timeline .dur { margin-left: auto; color: var(--dim); font: 11px var(--mono); }\n#timeline .idx { color: var(--dim); font: 11px var(--mono); min-width: 22px; }\n#timeline .prev { color: var(--dim); font-size: 11px; white-space: nowrap;\n overflow: hidden; text-overflow: ellipsis; padding-left: 44px; }\n/* Branch histories are contiguous in this list, so a coloured edge plus a\n header is enough to stop ten parallel branches reading as one long run. */\n#timeline li.sep { cursor: default; padding: 10px 10px 4px; display: flex; gap: 7px;\n align-items: center; font-size: 11px; letter-spacing: .06em; text-transform: uppercase;\n color: var(--dim); border-left-color: transparent; }\n#timeline li.sep:hover { background: none; }\n#timeline li.sep .dot { width: 8px; height: 8px; border-radius: 2px;\n background: var(--bc, var(--dim)); }\n/* The group header shouts in uppercase; its elapsed time should not. */\n#timeline li.sep .dur { text-transform: none; letter-spacing: 0; }\n/* The branch the join actually waited for. */\n#timeline li.sep.slow { background: #232a38; color: var(--fg); }\n#timeline li.sep.slow .dur { color: var(--fg); }\n#timeline li.branch { padding-left: 20px; border-left-color: var(--bc, var(--line)); }\n#timeline li.branch.sel { border-left-color: var(--bc, var(--accent)); }\n#middle { display: flex; flex-direction: column; overflow: hidden; }\n.tabs { display: flex; gap: 4px; padding: 8px 12px; border-bottom: 1px solid var(--line); }\n.tabs button { background: none; border: 1px solid transparent; color: var(--dim);\n padding: 4px 12px; border-radius: 6px; cursor: pointer; font-size: 13px; }\n.tabs button.on { color: var(--fg); background: var(--panel); border-color: var(--line); }\n#detail { overflow: auto; padding: 16px; flex: 1; }\n#stats { overflow: auto; padding: 16px; flex: 1; }\ntable.st { width: 100%; border-collapse: collapse; font: 12px var(--mono); }\ntable.st th { text-align: right; color: var(--dim); font-weight: 600; padding: 6px 12px;\n border-bottom: 1px solid var(--line); white-space: nowrap; }\ntable.st td { text-align: right; padding: 5px 12px; border-bottom: 1px solid #1d2230;\n white-space: nowrap; }\ntable.st th:first-child, table.st td:first-child { text-align: left; }\ntable.st tbody tr:hover { background: #1c2130; }\ntable.st tbody tr:last-child td { border-bottom: none; }\n/* The total is a different kind of row, not one more of the same: it gets its\n own band so nobody reads it as another model or another agent. */\ntable.st tfoot td { color: var(--fg); font-weight: 600; background: #191e2a;\n border-top: 1px solid #2b3346; border-bottom: none; padding: 7px 12px; }\ntable.st tfoot td:first-child { color: var(--dim); text-transform: uppercase;\n letter-spacing: .07em; font-size: 11px; }\n/* Two panes hold a zoomable diagram (the run graph and the architecture), so\n everything about one is written for both. */\n.pane { display: flex; flex-direction: column; overflow: hidden; flex: 1; }\n/* An explicit display on the pane would otherwise beat the hidden attribute\n and both views would show at once. */\n.pane[hidden], #detail[hidden], #stats[hidden] { display: none; }\n.pane svg { max-width: none; display: block; }\n.pane .node { cursor: pointer; }\n/* A node with nothing behind it in this run is not clickable, and says so. */\n.pane .node.idle { cursor: default; }\n.gtools { display: flex; align-items: center; gap: 6px; padding: 6px 10px;\n border-bottom: 1px solid var(--line); background: var(--panel); }\n.gtools button { background: var(--bg); border: 1px solid var(--line); color: var(--fg);\n border-radius: 6px; padding: 2px 10px; cursor: pointer; font: 12px var(--mono); }\n.gtools button:hover { border-color: var(--accent); }\n.gtools .hint:first-of-type { margin-left: 8px; }\n.gtools .lvl { margin-left: auto; font-family: var(--mono); }\n.gtools label.check { display: flex; align-items: center; gap: 5px; margin-left: 8px;\n color: var(--dim); font-size: 12px; cursor: pointer; user-select: none; }\n/* The memory view reads left to right: the graph is the index, the panel is\n the page. Putting the body under a tab of its own would mean losing the\n picture every time you asked what a node actually says. */\n#memory .split { display: flex; flex: 1; overflow: hidden; }\n#mdetail { width: 380px; flex: none; padding: 16px; overflow: auto;\n border-right: none; border-left: 1px solid var(--line); }\n#mdetail .dt { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 0 0 10px; }\n#mdetail .idrow { display: flex; gap: 8px; align-items: center; margin: 0 0 14px; }\n#mdetail .idrow code { flex: 1; min-width: 0; font: 12px var(--mono); color: var(--fg);\n background: var(--bg); border: 1px solid var(--line); border-radius: 6px;\n padding: 4px 8px; word-break: break-all; }\n#mdetail .idrow button { flex: 0 0 auto; background: var(--bg); border: 1px solid var(--line);\n color: var(--dim); border-radius: 6px; padding: 4px 9px; font: 11px var(--mono);\n cursor: pointer; }\n#mdetail .idrow button:hover { border-color: var(--accent); color: var(--fg); }\n#mdetail dl.props { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 5px 14px;\n margin: 0 0 14px; font: 12px var(--mono); }\n#mdetail dl.props dt { color: var(--dim); }\n#mdetail dl.props dd { margin: 0; word-break: break-word; }\n/* A block that shares the panel's own background has no edges to show. */\n#mdetail section.blk { background: var(--bg); }\n#mdetail ul.links { list-style: none; margin: 0; padding: 0; }\n#mdetail ul.links li { display: grid; grid-template-columns: 96px auto minmax(0, 1fr);\n gap: 8px; align-items: baseline; padding: 6px 12px; }\n#mdetail ul.links li + li { border-top: 1px solid var(--line); }\n#mdetail ul.links .rel { font: 11px var(--mono); color: var(--warn); white-space: nowrap; }\n#mdetail button.link { font: 12px/1.4 system-ui, sans-serif; padding: 0; text-align: left;\n overflow-wrap: anywhere; }\n#mdetail button.link:hover { text-decoration: underline; }\n#mcount .ok { color: var(--ok); }\n#mcount .bad { color: var(--err); }\n/* The kind palette is the memory page's, so a node reads the same in both. */\n.kind { font-size: 11px; letter-spacing: .04em; text-transform: uppercase;\n padding: 1px 6px; border-radius: 4px; background: #232a38; color: var(--dim); }\n.kind.task { background: #1e3357; color: #9dc1ff; }\n.kind.plan { background: #3a2450; color: #d1a6ff; }\n.kind.fact { background: #14392c; color: #8ee0c2; }\n.kind.snippet { background: #1e3d33; color: #8ee0c2; }\n.kind.file { background: #40331a; color: #f0dfb4; }\n.kind.operation { background: #3d2a1b; color: #f2d9c2; }\n.kind.preference { background: #3d1f2c; color: #f5b8cd; }\n/* Selection is a class rather than attributes written onto the shapes: the\n previous pick has to lose its outline without a redraw. White, because the\n accent already means \"the recall matched this one\". */\n#memory .node.picked rect, #memory .node.picked polygon,\n#memory .node.picked path, #memory .node.picked circle {\n stroke: var(--fg) !important; stroke-width: 4px !important; }\n/* The canvas is transformed, not scrolled: panning has to work past the edges\n of the diagram, which overflow:auto would forbid. */\n.viewport { flex: 1; overflow: hidden; position: relative; cursor: grab; touch-action: none;\n user-select: none; -webkit-user-select: none; }\n.viewport.drag { cursor: grabbing; }\n.canvas { position: absolute; top: 0; left: 0; transform-origin: 0 0; }\n.hint { color: var(--dim); font-size: 12px; }\n.hint.pad { padding: 16px; }\n.type { font-size: 11px; letter-spacing: .04em; text-transform: uppercase;\n padding: 1px 6px; border-radius: 4px; background: #232a38; color: var(--dim); }\n.type.llm_call { background: #1e3357; color: #9dc1ff; }\n.type.tool_call, .type.tool_result { background: #1e3d33; color: #8ee0c2; }\n.type.compaction { background: #40331a; color: var(--warn); }\n.type.fork, .type.join { background: #3a2450; color: #d1a6ff; }\n.type.final_output { background: #14392c; color: var(--ok); }\n.type.err { background: #4a1f1c; color: var(--err); }\nh2.dt { font-size: 16px; margin: 0 0 4px; display: flex; gap: 10px; align-items: center; }\n.meta { color: var(--dim); font-size: 12px; font-family: var(--mono); margin-bottom: 14px;\n display: flex; flex-wrap: wrap; gap: 4px 14px; }\nsection.blk { border: 1px solid var(--line); border-radius: 8px; margin-bottom: 12px;\n background: var(--panel); overflow: hidden; }\nsection.blk > h3 { margin: 0; padding: 7px 12px; font-size: 12px; font-weight: 600;\n color: var(--dim); border-bottom: 1px solid var(--line); letter-spacing: .03em; }\nsection.blk > h3 .tag { float: right; font-weight: 400; }\nsection.blk.sys { border-left: 3px solid var(--warn); }\n/* An LLM call has three things a reader wants to tell apart at a glance: what\n went in, what came back, and what it cost. Groups carry the first two. */\n.group { border: 1px solid var(--line); border-radius: 10px; margin-bottom: 14px;\n background: #12151d; overflow: hidden; }\n.group > .ghdr { display: flex; align-items: center; gap: 10px; padding: 8px 12px;\n font-size: 11px; font-weight: 700; letter-spacing: .09em; text-transform: uppercase;\n background: #1b2130; border-bottom: 1px solid var(--line); }\n.group > .ghdr .tag { margin-left: auto; font: 11px var(--mono); font-weight: 400;\n letter-spacing: 0; text-transform: none; color: var(--dim); }\n.group > .body { padding: 10px; }\n.group > .body > .blk:last-child { margin-bottom: 0; }\n.group.req { border-left: 3px solid var(--accent); }\n.group.req > .ghdr { color: #9dc1ff; }\n.group.res { border-left: 3px solid var(--ok); }\n.group.res > .ghdr { color: #8ee0c2; }\n.stats { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; }\n.stat { background: var(--panel); border: 1px solid var(--line); border-radius: 999px;\n padding: 3px 12px; font: 12px var(--mono); color: var(--dim); }\n.stat b { color: var(--fg); font-weight: 600; }\npre { margin: 0; padding: 10px 12px; font: 12px/1.55 var(--mono); white-space: pre-wrap;\n word-break: break-word; overflow-wrap: anywhere; }\npre.text { white-space: pre-wrap; }\nimg.media { display: block; max-width: 100%; max-height: 420px; margin: 10px 12px;\n border: 1px solid var(--line); border-radius: 6px; background: #0d1017; }\nimg.media.thumb { max-height: 88px; margin: 8px 12px; cursor: zoom-in; }\n.msg { border-top: 1px solid var(--line); border-left: 3px solid transparent; }\n.msg:first-of-type { border-top: none; }\n.msg > .role { padding: 5px 12px; font-size: 11px; text-transform: uppercase;\n letter-spacing: .05em; color: var(--dim); background: #1b202c;\n display: flex; justify-content: space-between; }\n.msg.user > .role { color: #9dc1ff; }\n.msg.user { border-left-color: #35507f; }\n.msg.assistant > .role { color: #c3a6ff; }\n.msg.assistant { border-left-color: #5a4380; }\n.msg.tool > .role { color: #8ee0c2; }\n.msg.tool { border-left-color: #2f6b58; }\n.msg.system > .role { color: var(--warn); }\n.msg.system { border-left-color: #7a6535; }\n.msg.err > .role { color: var(--err); }\n.msg.err { border-left-color: #8a3a33; }\n.call { border-top: 1px dashed var(--line); }\n.call .name { padding: 5px 12px; font: 12px var(--mono); color: #8ee0c2; }\ndetails > summary { cursor: pointer; padding: 6px 12px; color: var(--dim); font-size: 12px; }\n.badge { font: 11px var(--mono); color: var(--dim); }\n.badge.ok { color: var(--ok); } .badge.bad { color: var(--err); }\n.empty { padding: 10px 12px; color: var(--dim); font-size: 12px; font-style: italic; }\nbutton.link { background: none; border: none; color: var(--accent); cursor: pointer;\n font: 12px var(--mono); padding: 0 6px 0 0; }\n";
2
2
  //# sourceMappingURL=css.d.ts.map
@@ -87,6 +87,55 @@ table.st tfoot td:first-child { color: var(--dim); text-transform: uppercase;
87
87
  .gtools button:hover { border-color: var(--accent); }
88
88
  .gtools .hint:first-of-type { margin-left: 8px; }
89
89
  .gtools .lvl { margin-left: auto; font-family: var(--mono); }
90
+ .gtools label.check { display: flex; align-items: center; gap: 5px; margin-left: 8px;
91
+ color: var(--dim); font-size: 12px; cursor: pointer; user-select: none; }
92
+ /* The memory view reads left to right: the graph is the index, the panel is
93
+ the page. Putting the body under a tab of its own would mean losing the
94
+ picture every time you asked what a node actually says. */
95
+ #memory .split { display: flex; flex: 1; overflow: hidden; }
96
+ #mdetail { width: 380px; flex: none; padding: 16px; overflow: auto;
97
+ border-right: none; border-left: 1px solid var(--line); }
98
+ #mdetail .dt { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 0 0 10px; }
99
+ #mdetail .idrow { display: flex; gap: 8px; align-items: center; margin: 0 0 14px; }
100
+ #mdetail .idrow code { flex: 1; min-width: 0; font: 12px var(--mono); color: var(--fg);
101
+ background: var(--bg); border: 1px solid var(--line); border-radius: 6px;
102
+ padding: 4px 8px; word-break: break-all; }
103
+ #mdetail .idrow button { flex: 0 0 auto; background: var(--bg); border: 1px solid var(--line);
104
+ color: var(--dim); border-radius: 6px; padding: 4px 9px; font: 11px var(--mono);
105
+ cursor: pointer; }
106
+ #mdetail .idrow button:hover { border-color: var(--accent); color: var(--fg); }
107
+ #mdetail dl.props { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 5px 14px;
108
+ margin: 0 0 14px; font: 12px var(--mono); }
109
+ #mdetail dl.props dt { color: var(--dim); }
110
+ #mdetail dl.props dd { margin: 0; word-break: break-word; }
111
+ /* A block that shares the panel's own background has no edges to show. */
112
+ #mdetail section.blk { background: var(--bg); }
113
+ #mdetail ul.links { list-style: none; margin: 0; padding: 0; }
114
+ #mdetail ul.links li { display: grid; grid-template-columns: 96px auto minmax(0, 1fr);
115
+ gap: 8px; align-items: baseline; padding: 6px 12px; }
116
+ #mdetail ul.links li + li { border-top: 1px solid var(--line); }
117
+ #mdetail ul.links .rel { font: 11px var(--mono); color: var(--warn); white-space: nowrap; }
118
+ #mdetail button.link { font: 12px/1.4 system-ui, sans-serif; padding: 0; text-align: left;
119
+ overflow-wrap: anywhere; }
120
+ #mdetail button.link:hover { text-decoration: underline; }
121
+ #mcount .ok { color: var(--ok); }
122
+ #mcount .bad { color: var(--err); }
123
+ /* The kind palette is the memory page's, so a node reads the same in both. */
124
+ .kind { font-size: 11px; letter-spacing: .04em; text-transform: uppercase;
125
+ padding: 1px 6px; border-radius: 4px; background: #232a38; color: var(--dim); }
126
+ .kind.task { background: #1e3357; color: #9dc1ff; }
127
+ .kind.plan { background: #3a2450; color: #d1a6ff; }
128
+ .kind.fact { background: #14392c; color: #8ee0c2; }
129
+ .kind.snippet { background: #1e3d33; color: #8ee0c2; }
130
+ .kind.file { background: #40331a; color: #f0dfb4; }
131
+ .kind.operation { background: #3d2a1b; color: #f2d9c2; }
132
+ .kind.preference { background: #3d1f2c; color: #f5b8cd; }
133
+ /* Selection is a class rather than attributes written onto the shapes: the
134
+ previous pick has to lose its outline without a redraw. White, because the
135
+ accent already means "the recall matched this one". */
136
+ #memory .node.picked rect, #memory .node.picked polygon,
137
+ #memory .node.picked path, #memory .node.picked circle {
138
+ stroke: var(--fg) !important; stroke-width: 4px !important; }
90
139
  /* The canvas is transformed, not scrolled: panning has to work past the edges
91
140
  of the diagram, which overflow:auto would forbid. */
92
141
  .viewport { flex: 1; overflow: hidden; position: relative; cursor: grab; touch-action: none;
@@ -1,7 +1,7 @@
1
- import { type ReportOptions, type RunReport } from './report.ts';
2
- import type { AgentState } from '../state.ts';
3
1
  import type { PayloadResolver } from '../payload.ts';
4
- export { buildRunReport, MAX_BLOB_BYTES, MERMAID_URL, type ReportOptions, type RunReport, } from './report.ts';
2
+ import type { AgentState } from '../state.ts';
3
+ import { type ReportOptions, type RunReport } from './report.ts';
4
+ export { buildRunReport, MAX_BLOB_BYTES, MAX_MEMORY_FILE_BYTES, MERMAID_URL, type ReportOptions, type RunMemory, type RunReport, } from './report.ts';
5
5
  /** Report → one standalone HTML document. */
6
6
  export declare function renderReportHtml(report: RunReport, opts?: ReportOptions): string;
7
7
  /** The usual entry point: state in, HTML out. */
@@ -1,6 +1,6 @@
1
1
  import { page } from "./page.js";
2
2
  import { buildRunReport, MERMAID_URL } from "./report.js";
3
- export { buildRunReport, MAX_BLOB_BYTES, MERMAID_URL, } from "./report.js";
3
+ export { buildRunReport, MAX_BLOB_BYTES, MAX_MEMORY_FILE_BYTES, MERMAID_URL, } from "./report.js";
4
4
  /** Report → one standalone HTML document. */
5
5
  export function renderReportHtml(report, opts = {}) {
6
6
  return page(report, opts.mermaidUrl ?? MERMAID_URL);
@@ -29,6 +29,7 @@ export function page(report, mermaidUrl) {
29
29
  <button data-view="detail">Detail</button>
30
30
  <button data-view="stats">Stats</button>
31
31
  <button data-view="agents">Agents</button>
32
+ <button data-view="memory" hidden>Memory</button>
32
33
  </div>
33
34
  <div id="graph" class="pane">
34
35
  <div class="gtools">
@@ -54,6 +55,21 @@ export function page(report, mermaidUrl) {
54
55
  </div>
55
56
  <div class="viewport"><div class="canvas"><div class="hint pad">rendering…</div></div></div>
56
57
  </div>
58
+ <div id="memory" class="pane" hidden>
59
+ <div class="gtools">
60
+ <button data-zoom="out" title="zoom out">−</button>
61
+ <button data-zoom="in" title="zoom in">+</button>
62
+ <button data-zoom="fit" title="fit to window (double-click the canvas)">fit</button>
63
+ <button data-zoom="reset" title="actual size">1:1</button>
64
+ <label class="check"><input type="checkbox" id="madded"> show what it wrote</label>
65
+ <span class="hint" id="mcount"></span>
66
+ <span class="hint lvl">100%</span>
67
+ </div>
68
+ <div class="split">
69
+ <div class="viewport"><div class="canvas"><div class="hint pad">rendering…</div></div></div>
70
+ <aside id="mdetail"><div class="hint pad">Pick a node.</div></aside>
71
+ </div>
72
+ </div>
57
73
  </section>
58
74
  </main>
59
75
  <script id="run-data" type="application/json">${embedJson(report)}</script>
@@ -1,10 +1,16 @@
1
1
  import type { Architecture } from '../architecture.ts';
2
+ import { type ReportNode } from '../memory/report.ts';
3
+ import type { MemoryStore } from '../memory/store.ts';
4
+ import type { MemoryEdge } from '../memory/types.ts';
2
5
  import { type PayloadResolver } from '../payload.ts';
3
6
  import type { AgentState } from '../state.ts';
4
7
  /** Default per-payload inline cap. Keeps one runaway tool result from turning
5
8
  * a report into a file no editor will open. */
6
9
  export declare const MAX_BLOB_BYTES: number;
7
10
  export declare const MERMAID_URL = "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs";
11
+ /** Deliberately meaner than the memory page's own cap: this file already holds
12
+ * a whole run, and one remembered artifact should not decide whether it opens. */
13
+ export declare const MAX_MEMORY_FILE_BYTES: number;
8
14
  export interface ReportOptions {
9
15
  title?: string;
10
16
  /** per-payload inline cap; larger values are cut, with a visible marker */
@@ -22,6 +28,14 @@ export interface ReportOptions {
22
28
  * strictly smaller picture but never a wrong one.
23
29
  */
24
30
  architecture?: Architecture;
31
+ /**
32
+ * The memory to resolve recalled and committed nodes against. A trajectory
33
+ * records ids, kinds and edges but no bodies, so without this the memory
34
+ * view can draw the shape of what was read and not a word of it.
35
+ */
36
+ memory?: MemoryStore;
37
+ /** ceiling on an inlined remembered file; 0 to inline none */
38
+ maxMemoryFileBytes?: number;
25
39
  }
26
40
  /** Everything the page needs, and nothing that needs a store to interpret. */
27
41
  export interface RunReport {
@@ -44,6 +58,17 @@ export interface RunReport {
44
58
  media: string[];
45
59
  /** the declared wiring, when the caller had a runner to ask */
46
60
  architecture?: Architecture;
61
+ /**
62
+ * The memory nodes this run read or wrote, as the graph holds them *now* —
63
+ * which is later than the run. The page says so where it matters: a node
64
+ * can have moved on a revision since, or be gone from the graph entirely.
65
+ */
66
+ memory?: RunMemory;
67
+ }
68
+ export interface RunMemory {
69
+ dir: string;
70
+ nodes: ReportNode[];
71
+ edges: MemoryEdge[];
47
72
  }
48
73
  /**
49
74
  * Resolves a state into a self-describing report. Separate from rendering so a
@@ -1,3 +1,4 @@
1
+ import { buildMemorySlice } from "../memory/report.js";
1
2
  import { collectPayloads } from "../payload.js";
2
3
  // ---------------------------------------------------------------------------
3
4
  // Run inspector — a single HTML file that explains a run
@@ -12,6 +13,9 @@ import { collectPayloads } from "../payload.js";
12
13
  * a report into a file no editor will open. */
13
14
  export const MAX_BLOB_BYTES = 512 * 1024;
14
15
  export const MERMAID_URL = 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
16
+ /** Deliberately meaner than the memory page's own cap: this file already holds
17
+ * a whole run, and one remembered artifact should not decide whether it opens. */
18
+ export const MAX_MEMORY_FILE_BYTES = 64 * 1024;
15
19
  /** Base64 data uris only: the charset ends at the closing JSON quote. */
16
20
  const DATA_URL = /data:[\w.+-]+\/[\w.+-]+;base64,[A-Za-z0-9+/=]+/g;
17
21
  /** Below this a uri is cheaper to leave in place than to indirect through. */
@@ -57,6 +61,47 @@ export async function buildRunReport(state, payloads, opts = {}) {
57
61
  truncated,
58
62
  media,
59
63
  architecture: opts.architecture,
64
+ memory: opts.memory ? await remembered(opts.memory, state, media, opts) : undefined,
65
+ };
66
+ }
67
+ /** Every memory node the run touched, branches included. */
68
+ function memoryIds(nodes, into) {
69
+ for (const node of nodes) {
70
+ if (node.type === 'memory_recall' || node.type === 'memory_op') {
71
+ for (const n of node.nodes) {
72
+ into.add(n.id);
73
+ }
74
+ }
75
+ else if (node.type === 'join') {
76
+ for (const branch of node.branches) {
77
+ memoryIds(branch.nodes, into);
78
+ }
79
+ }
80
+ }
81
+ return into;
82
+ }
83
+ async function remembered(store, state, media, opts) {
84
+ const ids = memoryIds(state.trajectory, new Set());
85
+ if (!ids.size) {
86
+ return undefined;
87
+ }
88
+ let slice;
89
+ try {
90
+ slice = await buildMemorySlice(store, [...ids], {
91
+ maxContentBytes: opts.maxMemoryFileBytes ?? MAX_MEMORY_FILE_BYTES,
92
+ });
93
+ }
94
+ catch {
95
+ // A memory that cannot be read is a view the page does without; it is
96
+ // not a reason to lose the record of the run.
97
+ return undefined;
98
+ }
99
+ return {
100
+ dir: store.dir,
101
+ // Remembered images go through the same table as the run's own, so a
102
+ // picture the agent both saw and filed is carried once.
103
+ nodes: slice.nodes.map((n) => (n.image ? { ...n, image: hoistMedia(n.image, media) } : n)),
104
+ edges: slice.edges,
60
105
  };
61
106
  }
62
107
  //# sourceMappingURL=report.js.map
@@ -53,5 +53,17 @@ export interface MemoryReportOptions {
53
53
  /** override the clock, for tests */
54
54
  now?: () => string;
55
55
  }
56
+ /** Part of a memory, for a caller that already knows which part it wants. */
57
+ export interface MemorySlice {
58
+ nodes: ReportNode[];
59
+ /** only the edges with both ends inside the slice */
60
+ edges: MemoryEdge[];
61
+ }
62
+ /**
63
+ * The nodes named, built exactly as the whole graph is — same inlining, same
64
+ * excuses, same degree, which is counted over every edge rather than over the
65
+ * slice: a node's degree is a fact about the memory, not about the cut.
66
+ */
67
+ export declare function buildMemorySlice(store: MemoryStore, ids: readonly string[], opts?: Pick<MemoryReportOptions, 'maxContentBytes'>): Promise<MemorySlice>;
56
68
  export declare function buildMemoryReport(store: MemoryStore, opts?: MemoryReportOptions): Promise<MemoryReport>;
57
69
  //# sourceMappingURL=report.d.ts.map
@@ -25,25 +25,26 @@ const MIME = {
25
25
  webp: 'image/webp',
26
26
  svg: 'image/svg+xml',
27
27
  };
28
+ /**
29
+ * The nodes named, built exactly as the whole graph is — same inlining, same
30
+ * excuses, same degree, which is counted over every edge rather than over the
31
+ * slice: a node's degree is a fact about the memory, not about the cut.
32
+ */
33
+ export async function buildMemorySlice(store, ids, opts = {}) {
34
+ const graph = store.graph;
35
+ const all = graph.edges();
36
+ const wanted = new Set(ids);
37
+ return {
38
+ nodes: await inline(store, graph.nodes().filter((n) => wanted.has(n.id)), degrees(all), graph.superseded(), opts.maxContentBytes ?? MAX_CONTENT_BYTES),
39
+ edges: all.filter((e) => wanted.has(e.source) && wanted.has(e.target)),
40
+ };
41
+ }
28
42
  export async function buildMemoryReport(store, opts = {}) {
29
43
  const graph = store.graph;
30
44
  const nodes = graph.nodes();
31
45
  const edges = graph.edges();
32
- const stale = graph.superseded();
33
46
  const limit = opts.maxContentBytes ?? MAX_CONTENT_BYTES;
34
- const degree = new Map();
35
- for (const e of edges) {
36
- degree.set(e.source, (degree.get(e.source) ?? 0) + 1);
37
- degree.set(e.target, (degree.get(e.target) ?? 0) + 1);
38
- }
39
- // Files are read in parallel: one memory can hold hundreds, and they are
40
- // independent.
41
- const built = await Promise.all(nodes.map(async (n) => ({
42
- ...plain(n),
43
- stale: stale.has(n.id),
44
- degree: degree.get(n.id) ?? 0,
45
- ...(n.file ? await body(store.dir, n.file, limit) : {}),
46
- })));
47
+ const built = await inline(store, nodes, degrees(edges), graph.superseded(), limit);
47
48
  const vectors = store.vectors;
48
49
  return {
49
50
  title: opts.title ?? 'Memory',
@@ -59,6 +60,23 @@ export async function buildMemoryReport(store, opts = {}) {
59
60
  audiences: [...new Set(nodes.flatMap((n) => n.audience))].sort(),
60
61
  };
61
62
  }
63
+ function degrees(edges) {
64
+ const degree = new Map();
65
+ for (const e of edges) {
66
+ degree.set(e.source, (degree.get(e.source) ?? 0) + 1);
67
+ degree.set(e.target, (degree.get(e.target) ?? 0) + 1);
68
+ }
69
+ return degree;
70
+ }
71
+ /** Files are read in parallel: one memory can hold hundreds, independently. */
72
+ async function inline(store, nodes, degree, stale, limit) {
73
+ return Promise.all(nodes.map(async (n) => ({
74
+ ...plain(n),
75
+ stale: stale.has(n.id),
76
+ degree: degree.get(n.id) ?? 0,
77
+ ...(n.file ? await body(store.dir, n.file, limit) : {}),
78
+ })));
79
+ }
62
80
  /** The node's own fields, without the graph attributes graphology adds. */
63
81
  function plain(n) {
64
82
  return {
@@ -101,8 +101,23 @@ export function memoryTools(opts) {
101
101
  additionalProperties: false,
102
102
  },
103
103
  execute: async (args, tc) => {
104
+ const opId = memoryOpId(tc.state.runId, tc.callId);
104
105
  const loaded = await index.load(args.ids, binding.sees, tc.services.clock);
105
- return loaded.map((l) => ({
106
+ // Recorded because this is the only path that puts a whole body
107
+ // in the context: a recall block carries a clipped line.
108
+ const spec = {
109
+ kind: 'op',
110
+ op: 'load',
111
+ opId,
112
+ nodes: loaded.map((l) => ({
113
+ id: l.node.id,
114
+ kind: l.node.kind,
115
+ revision: l.node.revision,
116
+ })),
117
+ edges: [],
118
+ files: loaded.filter((l) => l.content !== undefined).length,
119
+ };
120
+ return withEffects(loaded.map((l) => ({
106
121
  id: l.node.id,
107
122
  kind: l.node.kind,
108
123
  text: l.node.text,
@@ -113,7 +128,7 @@ export function memoryTools(opts) {
113
128
  ? { path: l.node.file.path, bytes: l.node.file.bytes }
114
129
  : undefined,
115
130
  content: l.content,
116
- }));
131
+ })), { kind: 'memory_op', spec });
117
132
  },
118
133
  }),
119
134
  ];
@@ -271,6 +286,11 @@ export function memoryTools(opts) {
271
286
  const node = index.graph.get(id);
272
287
  return node ? [{ id, kind: node.kind, revision: node.revision }] : [];
273
288
  });
289
+ // Taken before the delete: afterwards nothing can say what this
290
+ // node hung from, and a report would draw it floating.
291
+ const links = new Map(args.ids
292
+ .flatMap((id) => index.graph.neighbors(id, binding.sees))
293
+ .map((e) => [`${e.source}|${e.relation}|${e.target}`, e]));
274
294
  try {
275
295
  await index.forget(args.ids, binding.sees);
276
296
  }
@@ -279,7 +299,14 @@ export function memoryTools(opts) {
279
299
  }
280
300
  return withEffects(`${MEMORY_FORGET_TOOL} ${before.length} nodes`, {
281
301
  kind: 'memory_op',
282
- spec: { kind: 'op', op: 'forget', opId, nodes: before, edges: [], files: 0 },
302
+ spec: {
303
+ kind: 'op',
304
+ op: 'forget',
305
+ opId,
306
+ nodes: before,
307
+ edges: [...links.values()],
308
+ files: 0,
309
+ },
283
310
  });
284
311
  },
285
312
  }));
@@ -169,10 +169,11 @@ export interface MemoryRecallSpec {
169
169
  }
170
170
  export interface MemoryOpSpec {
171
171
  kind: 'op';
172
- op: 'commit' | 'forget';
172
+ /** `load` is here rather than under recall: it hands the model whole bodies, and it writes. */
173
+ op: 'commit' | 'forget' | 'load';
173
174
  /** sha256(runId, callId) — deterministic, so a replay is deduplicated */
174
175
  opId: string;
175
- /** nodes this op created, changed or removed */
176
+ /** nodes this op created, changed, removed or read in full */
176
177
  nodes: {
177
178
  id: string;
178
179
  kind: string;
@@ -67,7 +67,7 @@ export interface MemoryRecallNode extends NodeBase {
67
67
  }
68
68
  export interface MemoryOpNode extends NodeBase {
69
69
  type: 'memory_op';
70
- op: 'commit' | 'forget';
70
+ op: 'commit' | 'forget' | 'load';
71
71
  /** sha256(runId, callId) — deterministic, so a replay is deduplicated */
72
72
  opId: string;
73
73
  nodes: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenera/neo",
3
- "version": "1.1.12",
3
+ "version": "1.1.14",
4
4
  "description": "A multi-agent runtime for Node.js: agents, models, tools, skills, memory and an append-only trajectory.",
5
5
  "keywords": [
6
6
  "agents",