@retrace-dev/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +5 -0
- package/dist/chain.d.ts +25 -0
- package/dist/chain.js +70 -0
- package/dist/explain.d.ts +8 -0
- package/dist/explain.js +30 -0
- package/dist/export.d.ts +53 -0
- package/dist/export.js +85 -0
- package/dist/gdrive.d.ts +32 -0
- package/dist/gdrive.js +129 -0
- package/dist/github.d.ts +30 -0
- package/dist/github.js +112 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +12 -0
- package/dist/lineage.d.ts +50 -0
- package/dist/lineage.js +150 -0
- package/dist/report.d.ts +6 -0
- package/dist/report.js +79 -0
- package/dist/router.d.ts +136 -0
- package/dist/router.js +391 -0
- package/dist/schema.d.ts +708 -0
- package/dist/schema.js +177 -0
- package/dist/signing.d.ts +13 -0
- package/dist/signing.js +55 -0
- package/dist/status.d.ts +49 -0
- package/dist/status.js +85 -0
- package/dist/store.d.ts +66 -0
- package/dist/store.js +84 -0
- package/dist/ui-html.d.ts +1 -0
- package/dist/ui-html.js +2 -0
- package/package.json +26 -0
- package/ui/retrace.html +479 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const UI_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\" />\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n<title>Retrace</title>\n<style>\n :root {\n --bg: #0f1115; --panel: #171a21; --panel-2: #1e222b; --line: #2a2f3a; --text: #e6e8ee; --muted: #8b93a7;\n --human: #f2a93b; --agent: #5aa9ff; --system: #9aa3b5; --ok: #3ccb7f; --bad: #ff5c6c; --accent: #c7b6ff;\n --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n }\n @media (prefers-color-scheme: light) {\n :root { --bg: #f6f7fa; --panel: #ffffff; --panel-2: #f0f2f6; --line: #dfe3ea; --text: #16181d; --muted: #616a7d;\n --human: #c77800; --agent: #1f6fe0; --system: #6b7385; --ok: #178f4f; --bad: #d1303f; --accent: #6b4fd8; }\n }\n * { box-sizing: border-box; }\n body { margin: 0; font: 14px/1.45 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; background: var(--bg); color: var(--text); }\n header { display: flex; align-items: center; gap: 12px; padding: 10px 16px; border-bottom: 1px solid var(--line); background: var(--panel); position: sticky; top: 0; z-index: 5; flex-wrap: wrap; }\n header h1 { font-size: 16px; margin: 0 8px 0 0; letter-spacing: .04em; }\n header h1 span { color: var(--accent); }\n select, input, button { font: inherit; color: var(--text); background: var(--panel-2); border: 1px solid var(--line); border-radius: 6px; padding: 6px 8px; }\n input { min-width: 140px; }\n header button { padding: 6px 9px; }\n button { cursor: pointer; }\n button.primary { background: var(--accent); color: #111; border-color: transparent; font-weight: 600; }\n .badge { display: inline-flex; align-items: center; gap: 6px; padding: 3px 8px; border-radius: 999px; font-size: 12px; border: 1px solid var(--line); background: var(--panel-2); }\n .badge.ok { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 40%, transparent); }\n .badge.bad { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 40%, transparent); }\n .dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; }\n main { display: grid; grid-template-columns: minmax(0, 1fr) 420px; gap: 0; height: calc(100vh - 53px); }\n @media (max-width: 900px) { main { grid-template-columns: 1fr; height: auto; } #detail { border-left: 0; border-top: 1px solid var(--line); } }\n #timeline { overflow: auto; padding: 16px 20px 40px; }\n #detail { border-left: 1px solid var(--line); background: var(--panel); overflow: auto; padding: 16px; }\n .legend { display: flex; gap: 14px; color: var(--muted); font-size: 12px; margin-bottom: 12px; align-items: center; flex-wrap: wrap; }\n .legend .dot.human { background: var(--human); } .legend .dot.agent { background: var(--agent); } .legend .dot.system { background: var(--system); }\n .day { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .08em; margin: 18px 0 8px 0; }\n .ev { display: grid; grid-template-columns: 64px 18px 1fr; gap: 10px; align-items: start; padding: 8px 8px; border-radius: 8px; cursor: pointer; position: relative; }\n .ev:hover { background: var(--panel-2); }\n .ev.sel { background: var(--panel-2); outline: 1px solid var(--accent); }\n .ev .t { color: var(--muted); font-family: var(--mono); font-size: 12px; padding-top: 2px; }\n .ev .rail { position: relative; height: 100%; }\n .ev .rail::before { content: \"\"; position: absolute; left: 8px; top: -10px; bottom: -14px; width: 2px; background: var(--line); }\n .ev .node { position: absolute; left: 3px; top: 3px; width: 12px; height: 12px; border-radius: 50%; border: 2px solid var(--bg); box-shadow: 0 0 0 1px var(--line); }\n .ev.human .node { background: var(--human); } .ev.agent .node { background: var(--agent); border-radius: 3px; } .ev.system .node { background: var(--system); }\n .ev .who { font-weight: 600; }\n .ev.human .who { color: var(--human); } .ev.agent .who { color: var(--agent); } .ev.system .who { color: var(--system); }\n .ev .verb { color: var(--accent); }\n .ev .art { font-family: var(--mono); font-size: 12.5px; }\n .ev .why { color: var(--muted); font-size: 12.5px; margin-top: 2px; }\n .ev .link { color: var(--muted); font-size: 11px; }\n .chip { display: inline-block; font-family: var(--mono); font-size: 11px; background: var(--panel-2); border: 1px solid var(--line); border-radius: 4px; padding: 1px 6px; margin-right: 4px; cursor: pointer; }\n .chip:hover { border-color: var(--accent); }\n .chip.more { color: var(--accent); border-style: dashed; }\n .chip .role { font-style: normal; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); margin-right: 4px; }\n #detail h2 { font-size: 14px; margin: 0 0 8px; color: var(--muted); font-weight: 500; text-transform: uppercase; letter-spacing: .08em; }\n .kv { display: grid; grid-template-columns: 62px 1fr; gap: 6px 10px; margin-bottom: 14px; }\n .kv .k { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .06em; padding-top: 2px; }\n .kv .v { word-break: break-word; }\n .kv .v.mono { font-family: var(--mono); font-size: 12px; }\n pre { background: var(--panel-2); border: 1px solid var(--line); border-radius: 6px; padding: 8px; font-family: var(--mono); font-size: 12px; overflow: auto; max-height: 240px; margin: 4px 0 0; white-space: pre-wrap; }\n .why-chain { border-left: 2px solid var(--line); padding-left: 10px; margin: 6px 0 14px 4px; }\n .why-chain .step { margin: 6px 0; font-size: 13px; }\n .why-chain .step .who { font-weight: 600; }\n .why-chain .step.human .who { color: var(--human); } .why-chain .step.agent .who { color: var(--agent); }\n .why-chain .step .arrow { color: var(--muted); font-size: 11px; }\n .empty { color: var(--muted); padding: 40px 0; text-align: center; }\n .stat { display: flex; gap: 16px; color: var(--muted); font-size: 12px; margin-bottom: 8px; flex-wrap: wrap; }\n .stat b { color: var(--text); font-weight: 600; }\n #conn { display: none; gap: 8px; align-items: center; }\n #conn.open { display: flex; }\n .hash { font-family: var(--mono); font-size: 11px; color: var(--muted); }\n a { color: var(--accent); }\n .seg { display: inline-flex; border: 1px solid var(--line); border-radius: 6px; overflow: hidden; }\n .seg button { border: 0; border-radius: 0; padding: 6px 10px; background: var(--panel-2); color: var(--muted); }\n .seg button.on { background: var(--accent); color: #111; font-weight: 600; }\n #graph { display: none; overflow: auto; position: relative; }\n #graph.on { display: block; } #timeline.off { display: none; }\n #graph svg { display: block; min-width: 100%; }\n .gctl { position: sticky; top: 0; left: 0; z-index: 2; width: 100%; display: flex; gap: 12px; align-items: center; padding: 10px 16px; background: color-mix(in srgb, var(--bg) 85%, transparent); backdrop-filter: blur(4px); font-size: 12px; color: var(--muted); flex-wrap: wrap; }\n .gctl label { display: inline-flex; gap: 4px; align-items: center; }\n .gnode { cursor: pointer; }\n .gnode rect, .gnode ellipse, .gnode polygon { fill: var(--panel-2); stroke: var(--line); stroke-width: 1.2; }\n .gnode:hover rect, .gnode:hover ellipse, .gnode:hover polygon, .gnode.sel rect, .gnode.sel ellipse, .gnode.sel polygon { stroke: var(--accent); stroke-width: 2; }\n .gnode.k-task rect { stroke: var(--human); } .gnode.k-commit rect { stroke-dasharray: 3 2; } .gnode.k-pr rect { stroke: var(--ok); } .gnode.k-command rect { stroke: var(--system); }\n .gnode.actor.human ellipse { stroke: var(--human); } .gnode.actor.agent polygon { stroke: var(--agent); } .gnode.actor.system ellipse { stroke: var(--system); }\n .gnode text { fill: var(--text); font-size: 11.5px; font-family: var(--mono); pointer-events: none; }\n .gnode text.sub { fill: var(--muted); font-size: 10px; font-family: system-ui, sans-serif; }\n .gedge { fill: none; stroke: var(--muted); stroke-width: 1.2; opacity: .75; }\n .gedge.derived { stroke: var(--accent); stroke-width: 2.2; opacity: 1; }\n .gedge.touched { stroke-dasharray: 3 3; opacity: .45; }\n .gedge.dim { opacity: .12; } .gnode.dim { opacity: .25; }\n .glabel { fill: var(--muted); font-size: 9.5px; }\n</style>\n</head>\n<body>\n<header>\n <h1>RE<span>TRACE</span></h1>\n <select id=\"project\" title=\"Project\"></select>\n <input id=\"q\" placeholder=\"search\u2026\" style=\"width:150px\" />\n <select id=\"ftype\"><option value=\"\">all actors</option><option value=\"human\">humans</option><option value=\"agent\">agents</option><option value=\"system\">system</option></select>\n <select id=\"faction\"><option value=\"\">any action</option></select>\n <span class=\"seg\"><button id=\"vTimeline\" class=\"on\">Timeline</button><button id=\"vGraph\">Graph</button></span>\n <span id=\"verify\" class=\"badge\"><span class=\"dot\"></span><span>verifying\u2026</span></span>\n <span id=\"status\" class=\"badge\" title=\"Capture completeness and causal coverage\">capture\u2026</span>\n <span id=\"sharedBadge\" class=\"badge\" style=\"display:none\" title=\"Read-only shared view\">\uD83D\uDD17 shared view</span>\n <button id=\"report\" title=\"Printable report (save as PDF)\">Report</button>\n <button id=\"export\" title=\"Download signed JSON bundle\">Export</button>\n <button id=\"share\" title=\"Create a read-only share link\">Share</button>\n <button id=\"refresh\" title=\"Reload\">\u21BB</button>\n <button id=\"toggleConn\" title=\"Connection settings\">\u2699</button>\n <span id=\"conn\"><input id=\"apiUrl\" placeholder=\"API URL (blank = same origin)\" /><input id=\"apiTok\" placeholder=\"token\" type=\"password\" /><button id=\"connect\" class=\"primary\">Connect</button><button id=\"loadJson\">Load JSON\u2026</button><input type=\"file\" id=\"file\" accept=\"application/json\" style=\"display:none\" /></span>\n</header>\n<div id=\"shareDlg\" style=\"display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:20;align-items:center;justify-content:center\">\n <div style=\"background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:18px;width:min(520px,92vw)\">\n <h3 style=\"margin:0 0 10px;font-size:15px\">Create read-only share link</h3>\n <div class=\"link\" style=\"margin-bottom:8px\">Anyone with the link can view (not edit) the events in scope, verify the chain, and download the signed export / report.</div>\n <div style=\"display:grid;gap:8px\">\n <label>Scope <select id=\"shScope\"><option value=\"project\">whole project</option><option value=\"artifact\">current artifact filter</option></select></label>\n <label>Label <input id=\"shLabel\" placeholder=\"e.g. Jab counter \u2014 client review\" style=\"width:100%\"></label>\n <label>Expires <select id=\"shExp\"><option value=\"\">never</option><option value=\"7\">7 days</option><option value=\"30\">30 days</option><option value=\"90\">90 days</option></select></label>\n <div id=\"shResult\" class=\"link\" style=\"word-break:break-all\"></div>\n <div style=\"display:flex;gap:8px;justify-content:flex-end\"><button id=\"shCancel\">Close</button><button id=\"shCreate\" class=\"primary\">Create link</button></div>\n </div>\n </div>\n</div>\n<main>\n <section id=\"timeline\"><div class=\"empty\">Loading\u2026</div></section>\n <section id=\"graph\">\n <div class=\"gctl\">\n <span><b id=\"gStat\"></b></span>\n <label><input type=\"checkbox\" id=\"gActors\"> show people & agents</label>\n <label><input type=\"checkbox\" id=\"gFlow\" checked> causal flow edges</label>\n <span style=\"margin-left:auto\">\u2501 derived_from \u2192 causal flow \u2508 touched \u00B7 click a node to filter the timeline</span>\n <button id=\"gDot\" title=\"Download Graphviz DOT\">DOT</button><button id=\"gMmd\" title=\"Copy Mermaid\">Mermaid</button>\n </div>\n <div id=\"gsvg\"></div>\n </section>\n <aside id=\"detail\"><div class=\"empty\">Select an event to see who / what / when / where / why / how.</div></aside>\n</main>\n<script>\n(() => {\n const $ = (s) => document.querySelector(s);\n const state = { events: [], project: null, projects: [], sel: null, filterArtifact: null, filterActor: null, verify: null, status: null, offline: false, expanded: new Set(), newestFirst: true };\n const params = new URLSearchParams(location.search);\n if (params.get(\"order\") === \"asc\") state.newestFirst = false;\n let apiBase = params.get(\"api\") ?? \"\";\n let token = params.get(\"token\") ?? \"\";\n $(\"#apiUrl\").value = apiBase; $(\"#apiTok\").value = token;\n // Shared mode: /s/<id> \u2192 all reads go through /s/<id>/*, scope locked server-side\n const shareMatch = location.pathname.match(/\\/s\\/([^/]+)/);\n const shareId = shareMatch ? shareMatch[1] : null;\n if (shareId) {\n document.body.classList.add(\"shared\");\n $(\"#sharedBadge\").style.display = \"\";\n [\"#share\", \"#toggleConn\", \"#project\"].forEach((s) => ($(s).style.display = \"none\"));\n }\n const P = {\n projects: () => shareId ? api(`/s/${shareId}/meta`).then((m) => [m.project]) : api(\"/projects\"),\n events: (p) => shareId ? api(`/s/${shareId}/events?limit=5000`) : api(`/projects/${encodeURIComponent(p)}/events?limit=5000`),\n verify: (p) => shareId ? api(`/s/${shareId}/verify`) : api(`/projects/${encodeURIComponent(p)}/verify`),\n status: (p) => shareId ? Promise.resolve(null) : api(`/projects/${encodeURIComponent(p)}/status`),\n exportUrl: (p, art) => shareId ? `${apiBase}/s/${shareId}/export` : `${apiBase}/projects/${encodeURIComponent(p)}/export?${new URLSearchParams({ ...(art ? { artifact_id: art } : {}), ...(token ? { token } : {}) })}`,\n reportUrl: (p, art) => shareId ? `${apiBase}/s/${shareId}/report` : `${apiBase}/projects/${encodeURIComponent(p)}/report?${new URLSearchParams({ ...(art ? { artifact_id: art } : {}), ...(token ? { token } : {}) })}`,\n };\n\n const esc = (s) => String(s ?? \"\").replace(/[&<>\"']/g, (c) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" }[c]));\n const fmtTime = (iso) => new Date(iso).toLocaleTimeString([], { hour: \"2-digit\", minute: \"2-digit\", second: \"2-digit\" });\n const fmtDay = (iso) => new Date(iso).toLocaleDateString([], { weekday: \"short\", year: \"numeric\", month: \"short\", day: \"numeric\" });\n const actorName = (a) => a.display_name || a.id;\n const fmtDur = (ms) => ms < 1000 ? `${ms} ms` : ms < 60000 ? `${(ms / 1000).toFixed(ms < 10000 ? 1 : 0)} s` : ms < 3600000 ? `${Math.round(ms / 60000)} min` : `${(ms / 3600000).toFixed(1)} h`;\n const actorTag = (a) => a.type === \"agent\" ? (a.model ? ` \u00B7 ${a.model}` : \" \u00B7 agent\") : a.type === \"system\" ? \" \u00B7 system\" : \"\";\n const verbOf = (e) => e.action === \"other\" ? (e.action_detail || \"acted on\") : e.action;\n const artLabel = (a) => a.label || a.id;\n // PROV role of a ref within its event: in (used) \u00B7 out (generated) \u00B7 in/out (both); no marker when unspecified.\n const roleMark = (r) => r === \"used\" ? \"in\" : r === \"generated\" ? \"out\" : r === \"both\" ? \"in/out\" : \"\";\n const roleTitle = (r) => r === \"used\" ? \" \u00B7 used (input)\" : r === \"generated\" ? \" \u00B7 generated (output)\" : r === \"both\" ? \" \u00B7 used and generated\" : \"\";\n const roleTag = (a) => roleMark(a.role) ? `<i class=\"role\">${roleMark(a.role)}</i>` : \"\";\n const whereText = (loc) => loc ? [loc.system, loc.environment, loc.path || loc.url, loc.device, loc.client].filter(Boolean).join(\" \u00B7 \") : \"\";\n // Second WHERE line: the run-identity fields. Deliberately NOT in whereText, which the timeline reuses and which\n // must stay a readable one-liner. session/workspace are clickable \u2014 they drop into the search box, which already\n // matches the whole event JSON, so one click shows everything from that agent run or that isolated worktree.\n const findable = (v) => `<span class=\"chip\" data-q=\"${esc(v)}\" title=\"search for ${esc(v)}\">${esc(v)}</span>`;\n const whereExtra = (loc) => {\n const bits = [];\n if (loc.ide) bits.push(`in <b>${esc(loc.ide)}</b>`);\n if (loc.workspace) bits.push(`workspace ${findable(loc.workspace)}`);\n if (loc.session) bits.push(`session ${findable(loc.session)}`);\n if (loc.surface) bits.push(loc.surface === \"tty\" ? \"at a terminal\" : \"no terminal \u00B7 agent-driven\");\n return bits.length ? `<div class=\"link\">${bits.join(\" \u00B7 \")}</div>` : \"\";\n };\n // Latest known label per artifact id: the label on the last event (by seq) that carries one.\n // Used wherever the UI names an artifact; the event detail pane keeps each event's as-at label.\n const latestLabels = (events) => { const m = new Map(); for (const e of [...events].sort((a, b) => a.seq - b.seq)) for (const a of e.artifacts) if (a.label) m.set(a.id, a.label); return m; };\n\n async function api(path) {\n const url = (apiBase || \"\") + path;\n const res = await fetch(url, { headers: token ? { authorization: `Bearer ${token}` } : {} });\n if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);\n return res.json();\n }\n\n async function loadProjects() {\n state.projects = await P.projects();\n const sel = $(\"#project\");\n sel.innerHTML = state.projects.map((p) => `<option>${esc(p)}</option>`).join(\"\") || \"<option value=''>(no projects yet)</option>\";\n const want = params.get(\"project\");\n state.project = state.projects.includes(want) ? want : state.projects[0] ?? null;\n if (state.project) sel.value = state.project;\n }\n\n async function loadEvents() {\n if (state.offline) return render();\n if (!state.project) { state.events = []; return render(); }\n const [events, verify, status] = await Promise.all([P.events(state.project), P.verify(state.project).catch(() => null), P.status(state.project).catch(() => null)]);\n state.events = events; state.verify = verify; state.status = status;\n render();\n }\n\n function localVerifyBadge() {\n const b = $(\"#verify\"); const v = state.verify;\n if (!v) { b.className = \"badge\"; b.lastElementChild.textContent = state.offline ? \"offline snapshot\" : \"not verified\"; return; }\n b.className = \"badge \" + (v.ok ? \"ok\" : \"bad\");\n b.lastElementChild.textContent = v.ok ? `chain intact \u00B7 ${v.checked} events` : `chain BROKEN at #${v.first_bad_seq}`;\n b.title = v.reason || \"\";\n }\n\n function statusBadge() {\n const b = $(\"#status\"), s = state.status;\n if (!s?.capture || !s?.causality) { b.style.display = state.offline || shareId ? \"none\" : \"\"; b.textContent = \"capture unknown\"; return; }\n b.style.display = \"\";\n const gaps = s.capture.unlinked_commits + s.causality.broken_links + s.capture.agent_events_without_model;\n b.className = \"badge \" + (gaps ? \"bad\" : \"ok\");\n b.textContent = `${s.causality.coverage_pct}% causal \u00B7 ${s.capture.unlinked_commits} unlinked commits`;\n b.title = `${s.capture.instructions_without_followup} instructions without follow-up \u00B7 ${s.capture.agent_events_without_model} agent events missing model \u00B7 ${s.capture.artifact_refs_without_role} artifact refs missing role`;\n }\n\n function filtered(opts = {}) {\n const q = $(\"#q\").value.trim().toLowerCase();\n const t = $(\"#ftype\").value; const a = $(\"#faction\").value;\n return state.events.filter((e) => {\n if (t && e.actor.type !== t) return false;\n if (a && e.action !== a) return false;\n if (!opts.ignoreArtifact && state.filterArtifact && !e.artifacts.some((x) => x.id === state.filterArtifact)) return false;\n if (state.filterActor && e.actor.id !== state.filterActor) return false;\n if (q && !JSON.stringify(e).toLowerCase().includes(q)) return false;\n return true;\n });\n }\n\n function render() {\n localVerifyBadge();\n statusBadge();\n const labels = latestLabels(state.events); const labelOf = (a) => labels.get(a.id) || artLabel(a);\n const acts = [...new Set(state.events.map((e) => e.action))].sort();\n const fa = $(\"#faction\"); const cur = fa.value;\n fa.innerHTML = `<option value=\"\">any action</option>` + acts.map((a) => `<option ${a === cur ? \"selected\" : \"\"}>${esc(a)}</option>`).join(\"\");\n const list = filtered(); if (state.newestFirst) list.reverse();\n const humans = new Set(state.events.filter((e) => e.actor.type === \"human\").map((e) => e.actor.id)).size;\n const agents = new Set(state.events.filter((e) => e.actor.type === \"agent\").map((e) => e.actor.id)).size;\n const arts = new Set(state.events.flatMap((e) => e.artifacts.map((a) => a.id))).size;\n let html = `<div class=\"stat\"><span><b>${state.events.length}</b> events</span><span><b>${humans}</b> humans</span><span><b>${agents}</b> agents</span><span><b>${arts}</b> artifacts</span>` +\n (state.filterArtifact ? `<span>artifact: <span class=\"chip\" data-clear=\"artifact\" title=\"${esc(state.filterArtifact)}\">${esc(labels.get(state.filterArtifact) || state.filterArtifact)} \u2715</span></span>` : \"\") +\n (state.filterActor ? `<span>actor: <span class=\"chip\" data-clear=\"actor\">${esc(state.filterActor)} \u2715</span></span>` : \"\") + `</div>`;\n html += `<div class=\"legend\"><span><i class=\"dot human\" style=\"display:inline-block\"></i> human</span><span><i class=\"dot agent\" style=\"display:inline-block;border-radius:2px\"></i> agent</span><span><i class=\"dot system\" style=\"display:inline-block\"></i> system</span><span style=\"margin-left:auto\"><span class=\"chip\" data-order=\"1\" title=\"flip order\">${state.newestFirst ? \"newest first \u25BE\" : \"oldest first \u25B4\"}</span></span></div>`;\n if (!list.length) html += `<div class=\"empty\">No events${state.events.length ? \" match these filters\" : \" yet \u2014 log one with the MCP tool <code>retrace_log</code> or POST /events\"}.</div>`;\n let lastDay = \"\";\n for (const e of list) {\n const day = fmtDay(e.timestamp);\n if (day !== lastDay) { html += `<div class=\"day\">${esc(day)}</div>`; lastDay = day; }\n const MAX = 6; const open = state.expanded.has(e.id); const shown = open ? e.artifacts : e.artifacts.slice(0, MAX); const extra = e.artifacts.length - shown.length;\n const arts = shown.map((a) => `<span class=\"chip\" data-art=\"${esc(a.id)}\" title=\"${esc(a.id + roleTitle(a.role))}\">${roleTag(a)}${esc(labelOf(a))}</span>`).join(\"\") +\n (extra > 0 ? `<span class=\"chip more\" data-expand=\"${esc(e.id)}\" title=\"show all ${e.artifacts.length} artifacts\">+${extra} more</span>` : open && e.artifacts.length > MAX ? `<span class=\"chip more\" data-expand=\"${esc(e.id)}\" title=\"collapse\">show less</span>` : \"\");\n const where = e.location?.path || e.location?.url || e.location?.system || \"\";\n const how = e.method?.tool ? `via ${esc(e.method.tool)}` : \"\";\n html += `<div class=\"ev ${e.actor.type} ${state.sel === e.id ? \"sel\" : \"\"}\" data-id=\"${esc(e.id)}\">\n <div class=\"t\">${fmtTime(e.timestamp)}<br><span class=\"link\">#${e.seq}</span></div>\n <div class=\"rail\"><div class=\"node\"></div></div>\n <div>\n <div><span class=\"who\" data-actor=\"${esc(e.actor.id)}\">${esc(actorName(e.actor))}</span><span class=\"link\">${esc(actorTag(e.actor))}${e.actor.on_behalf_of ? \" \u00B7 for \" + esc(e.actor.on_behalf_of) : \"\"}</span> <span class=\"verb\">${esc(verbOf(e))}</span> <span class=\"art\">${arts}</span> <span class=\"link\">${where ? \"@ \" + esc(where) + \" \" : \"\"}${how}</span></div>\n ${e.intent ? `<div class=\"why\">${esc(e.intent)}</div>` : \"\"}\n ${e.caused_by ? `<div class=\"link\">\u21B3 caused by <a href=\"#\" data-goto=\"${esc(e.caused_by)}\">${esc(e.caused_by.slice(0, 16))}\u2026</a></div>` : \"\"}\n </div></div>`;\n }\n $(\"#timeline\").innerHTML = html;\n if (state.sel && !state.events.some((e) => e.id === state.sel)) state.sel = null;\n renderDetail();\n renderGraph();\n }\n\n function whyChain(id) {\n const byId = new Map(state.events.map((e) => [e.id, e]));\n const chain = []; const seen = new Set(); let cur = byId.get(id);\n while (cur && !seen.has(cur.id) && chain.length < 30) { seen.add(cur.id); chain.push(cur); cur = cur.caused_by ? byId.get(cur.caused_by) : null; }\n return chain;\n }\n function effects(id) { return state.events.filter((e) => e.caused_by === id); }\n\n function renderDetail() {\n const e = state.events.find((x) => x.id === state.sel);\n const d = $(\"#detail\");\n if (!e) { d.innerHTML = `<div class=\"empty\">Select an event to see who / what / when / where / why / how.</div>`; return; }\n const kv = (k, v, mono) => v ? `<div class=\"k\">${k}</div><div class=\"v ${mono ? \"mono\" : \"\"}\">${v}</div>` : \"\";\n // Every section always renders: absent data says \"(not recorded)\" instead of vanishing,\n // and a section that fails to render falls back on its own \u2014 it never blanks the rest of the pane.\n const sect = (k, fn, mono) => { let v; try { v = fn(); } catch { v = `<span class=\"link\">(could not render \u2014 see raw JSON)</span>`; } return kv(k, v || `<span class=\"link\">(not recorded)</span>`, mono); };\n let chainHtml, kidsHtml = \"\", kids = [];\n try {\n const chain = whyChain(e.id).slice(1); kids = effects(e.id);\n const parentMissing = e.caused_by && !state.events.some((x) => x.id === e.caused_by);\n chainHtml = parentMissing ? `<div class=\"link\" style=\"margin-bottom:12px\">caused by <span class=\"mono\">${esc(e.caused_by)}</span> \u2014 outside this view's scope</div>` : chain.length ? `<div class=\"why-chain\">` + chain.map((c) => `<div class=\"step ${c.actor.type}\"><span class=\"arrow\">\u21B3 because</span> <span class=\"who\">${esc(actorName(c.actor))}</span> ${esc(verbOf(c))} ${esc(c.artifacts.map(artLabel).join(\", \"))}${c.intent ? ` \u2014 <i>${esc(c.intent)}</i>` : \"\"} <a href=\"#\" data-goto=\"${esc(c.id)}\" class=\"link\">#${c.seq}</a></div>`).join(\"\") + `</div>` : `<div class=\"link\" style=\"margin-bottom:12px\">no causal parent recorded \u2014 this is a root event</div>`;\n kidsHtml = kids.length ? `<div class=\"why-chain\">` + kids.map((c) => `<div class=\"step ${c.actor.type}\"><span class=\"arrow\">\u2192 led to</span> <span class=\"who\">${esc(actorName(c.actor))}</span> ${esc(verbOf(c))} ${esc(c.artifacts.map(artLabel).join(\", \"))} <a href=\"#\" data-goto=\"${esc(c.id)}\" class=\"link\">#${c.seq}</a></div>`).join(\"\") + `</div>` : \"\";\n } catch { chainHtml = `<div class=\"link\" style=\"margin-bottom:12px\">(could not render the causal chain \u2014 see raw JSON)</div>`; }\n let raw; try { raw = JSON.stringify(e, null, 2); } catch { raw = \"(not serializable)\"; }\n d.innerHTML = `\n <h2>Event #${e.seq}</h2>\n <div class=\"kv\">\n ${sect(\"Who\", () => `<span style=\"color:var(--${e.actor.type});font-weight:600\">${esc(actorName(e.actor))}</span> <span class=\"link\">${esc(e.actor.type)}${e.actor.model ? \" \u00B7 \" + esc(e.actor.model) : \"\"}${e.actor.version ? \" \" + esc(e.actor.version) : \"\"}${e.actor.on_behalf_of ? \"<br>on behalf of \" + esc(e.actor.on_behalf_of) : \"\"}</span>`)}\n ${sect(\"What\", () => `<b>${esc(verbOf(e))}</b> ${e.artifacts.map((a) => `<span class=\"chip\" data-art=\"${esc(a.id)}\" title=\"${esc(a.id + roleTitle(a.role))}\">${roleTag(a)}${esc(artLabel(a))}</span>${a.kind && a.kind !== \"file\" ? `<span class=\"link\">${esc(a.kind)}</span>` : \"\"}${a.derived_from?.length ? `<div class=\"link\">derived from ${esc(a.derived_from.join(\", \"))}</div>` : \"\"}`).join(\" \")}${e.change?.summary ? `<div>${esc(e.change.summary)}</div>` : \"\"}${e.change?.diff ? `<pre>${esc(e.change.diff)}</pre>` : \"\"}${e.change?.after_hash ? `<div class=\"hash\">after ${esc(e.change.after_hash)}</div>` : \"\"}`)}\n ${sect(\"When\", () => `${esc(new Date(e.timestamp).toLocaleString())}${e.duration_ms != null ? ` <span class=\"link\">\u00B7 took ${fmtDur(e.duration_ms)}</span>` : \"\"}<div class=\"link\">received ${esc(new Date(e.received_at).toLocaleString())}</div>`)}\n ${sect(\"Where\", () => e.location ? esc(whereText(e.location)) + whereExtra(e.location) : \"\")}\n ${sect(\"Why\", () => e.intent ? esc(e.intent) : \"\")}\n ${sect(\"How\", () => e.method ? `${e.method.tool ? `<b>${esc(e.method.tool)}</b> ` : \"\"}${e.method.automated != null ? `<span class=\"link\">${e.method.automated ? \"automated\" : \"manual\"}</span>` : \"\"}${e.method.instruction ? `<div class=\"link\">instruction: ${esc(e.method.instruction)}</div>` : \"\"}${e.method.tokens != null ? `<div class=\"link\">${e.method.tokens} tokens${e.method.cost_usd != null ? ` \u00B7 $${e.method.cost_usd}` : \"\"}</div>` : \"\"}${e.method.params ? `<pre>${esc(JSON.stringify(e.method.params, null, 2))}</pre>` : \"\"}` : \"\")}\n ${sect(\"Tags\", () => e.tags?.length ? e.tags.map((t) => `<span class=\"chip\">${esc(t)}</span>`).join(\"\") : \"\")}\n </div>\n <h2>Causal chain</h2>${chainHtml}\n ${kids.length ? `<h2>Consequences</h2>${kidsHtml}` : \"\"}\n <h2>Integrity</h2>\n <div class=\"kv\"><div class=\"k\">id</div><div class=\"v mono\">${esc(e.id)}</div><div class=\"k\">hash</div><div class=\"v hash\">${esc(e.hash)}</div><div class=\"k\">prev</div><div class=\"v hash\">${esc(e.prev_hash)}</div></div>\n <details><summary class=\"link\">raw JSON</summary><pre>${esc(raw)}</pre></details>`;\n }\n\n // ---------- lineage graph ----------\n function buildLineage(events, opts = {}) {\n const nodes = new Map(), edges = new Map(), byId = new Map(events.map((e) => [e.id, e]));\n const sorted = [...events].sort((a, b) => a.seq - b.seq);\n const art = (id, label, kind, seq) => { let n = nodes.get(\"a:\" + id); if (!n) { n = { id, type: \"artifact\", label: label || id, kind, events: 0, first_seq: seq, last_seq: seq, actors: [], actions: {} }; nodes.set(\"a:\" + id, n); } if (label) n.label = label; if (kind && !n.kind) n.kind = kind; return n; };\n const addEdge = (from, to, type, via) => { if (from === to) return; const k = `${type}|${from}|${to}`; let ed = edges.get(k); if (!ed) { ed = { from, to, type, weight: 0, via: [] }; edges.set(k, ed); } ed.weight++; if (ed.via.length < 5) ed.via.push(via); };\n for (const e of sorted) {\n for (const a of e.artifacts) {\n const n = art(a.id, a.label, a.kind, e.seq); n.events++; n.last_seq = e.seq; if (!n.actors.includes(e.actor.id)) n.actors.push(e.actor.id); n.actions[e.action] = (n.actions[e.action] || 0) + 1;\n for (const src of a.derived_from || []) { art(src, undefined, undefined, e.seq); addEdge(src, a.id, \"derived\", e.id); }\n if (opts.includeActors) { let u = nodes.get(\"u:\" + e.actor.id); if (!u) { u = { id: e.actor.id, type: \"actor\", label: actorName(e.actor), kind: e.actor.type, events: 0, first_seq: e.seq, last_seq: e.seq }; nodes.set(\"u:\" + e.actor.id, u); } u.events++; addEdge(\"u:\" + e.actor.id, a.id, \"touched\", e.id); }\n }\n if (e.caused_by && opts.flow !== false) { const p = byId.get(e.caused_by); if (p) for (const pa of p.artifacts) for (const ca of e.artifacts) if (pa.id !== ca.id) addEdge(pa.id, ca.id, \"flow\", e.id); }\n }\n return { nodes: [...nodes.values()], edges: [...edges.values()] };\n }\n function layerLineage(l) {\n const ids = l.nodes.filter((n) => n.type === \"artifact\").map((n) => n.id);\n const inc = new Map(ids.map((id) => [id, []])); for (const e of l.edges) if (e.type !== \"touched\" && inc.has(e.to) && inc.has(e.from)) inc.get(e.to).push(e.from);\n const seq = new Map(l.nodes.map((n) => [n.id, n.first_seq])); const layer = new Map(), visiting = new Set();\n const depth = (id) => { if (layer.has(id)) return layer.get(id); if (visiting.has(id)) return 0; visiting.add(id); let d = 0; for (const p of inc.get(id) || []) if ((seq.get(p) ?? 0) <= (seq.get(id) ?? 0)) d = Math.max(d, depth(p) + 1); visiting.delete(id); layer.set(id, d); return d; };\n for (const id of ids) depth(id); return layer;\n }\n let lastLineage = null;\n function renderGraph() {\n if (!$(\"#graph\").classList.contains(\"on\")) return;\n const events = filtered({ ignoreArtifact: true }); // artifact filter highlights in the graph instead of hiding\n const l = buildLineage(events, { includeActors: $(\"#gActors\").checked, flow: $(\"#gFlow\").checked });\n lastLineage = l;\n const arts = l.nodes.filter((n) => n.type === \"artifact\"), actors = l.nodes.filter((n) => n.type === \"actor\");\n $(\"#gStat\").textContent = `${arts.length} artifacts \u00B7 ${l.edges.filter((e) => e.type !== \"touched\").length} lineage edges${actors.length ? ` \u00B7 ${actors.length} actors` : \"\"}`;\n if (!arts.length) { $(\"#gsvg\").innerHTML = `<div class=\"empty\">No artifacts in view.</div>`; return; }\n const layer = layerLineage(l);\n const W = 190, H = 46, GX = 70, GY = 16, PAD = 24, ACT_W = 140;\n const cw0 = $(\"#graph\").clientWidth || 1000;\n // connected artifacts: columns = layers, rows by first_seq. Isolated artifacts (no lineage edges): packed grid below.\n const linked = new Set(); for (const e of l.edges) if (e.type !== \"touched\") { linked.add(e.from); linked.add(e.to); }\n const conn = arts.filter((n) => linked.has(n.id)), iso = arts.filter((n) => !linked.has(n.id));\n const cols = new Map();\n for (const n of conn) { const c = layer.get(n.id) || 0; if (!cols.has(c)) cols.set(c, []); cols.get(c).push(n); }\n for (const arr of cols.values()) arr.sort((a, b) => a.first_seq - b.first_seq);\n const pos = new Map(); let maxRows = 0;\n const offX = actors.length ? ACT_W + GX : 0;\n for (const [c, arr] of cols) { maxRows = Math.max(maxRows, arr.length); arr.forEach((n, i) => pos.set(n.id, { x: PAD + offX + c * (W + GX), y: PAD + i * (H + GY), w: W, h: H })); }\n actors.sort((a, b) => a.first_seq - b.first_seq).forEach((n, i) => pos.set(\"u:\" + n.id, { x: PAD, y: PAD + i * (H + GY), w: ACT_W, h: H }));\n maxRows = Math.max(maxRows, actors.length);\n let width = PAD * 2 + offX + Math.max(cols.size, 0) * (W + GX) - (cols.size ? GX : 0);\n let height = PAD * 2 + maxRows * (H + GY) - GY;\n let isoLabelY = 0;\n if (iso.length) {\n const perRow = Math.max(1, Math.floor((cw0 - PAD * 2 + 30) / (W + 30)));\n const y0 = (conn.length || actors.length) ? height + 10 : PAD; isoLabelY = y0 + 12;\n iso.sort((a, b) => b.events - a.events).forEach((n, i) => pos.set(n.id, { x: PAD + (i % perRow) * (W + 30), y: y0 + 24 + Math.floor(i / perRow) * (H + GY), w: W, h: H }));\n const rows = Math.ceil(iso.length / perRow);\n height = y0 + 24 + rows * (H + GY) - GY + PAD; width = Math.max(width, PAD * 2 + Math.min(iso.length, perRow) * (W + 30) - 30);\n }\n const trunc = (t, n) => (t.length > n ? \"\u2026\" + t.slice(-(n - 1)) : t);\n const cw = $(\"#graph\").clientWidth || width; const fit = width > cw && width < cw * 1.5; // gently scale to fit when slightly too wide, otherwise scroll\n let svg = `<svg xmlns=\"http://www.w3.org/2000/svg\" ${fit ? `width=\"100%\"` : `width=\"${width}\"`} height=\"${fit ? Math.round(Math.max(height, 120) * cw / width) : Math.max(height, 120)}\" viewBox=\"0 0 ${width} ${Math.max(height, 120)}\" preserveAspectRatio=\"xMinYMin meet\"><defs><marker id=\"arr\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\"><path d=\"M0 0L10 5L0 10z\" fill=\"context-stroke\"/></marker></defs>`;\n const selArt = state.filterArtifact;\n const connected = new Set(); if (selArt) for (const e of l.edges) { if (e.from === selArt) connected.add(e.to); if (e.to === selArt) connected.add(e.from); }\n for (const e of l.edges) {\n const a = pos.get(e.from), b = pos.get(e.to); if (!a || !b) continue;\n const x1 = a.x + a.w, y1 = a.y + a.h / 2, x2 = b.x, y2 = b.y + b.h / 2; const dx = Math.max(40, (x2 - x1) / 2);\n const back = x2 <= x1; const d = back ? `M${x1} ${y1} C ${x1 + 60} ${y1}, ${x2 - 60} ${y2}, ${x2} ${y2}` : `M${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;\n const dim = selArt && e.from !== selArt && e.to !== selArt ? \" dim\" : \"\";\n svg += `<path class=\"gedge ${e.type}${dim}\" d=\"${d}\" marker-end=\"url(#arr)\"><title>${esc(e.type)} \u00D7${e.weight}\\n${esc(e.from)} \u2192 ${esc(e.to)}</title></path>`;\n if (e.type === \"flow\" && e.weight > 1) svg += `<text class=\"glabel\" x=\"${(x1 + x2) / 2}\" y=\"${(y1 + y2) / 2 - 4}\" text-anchor=\"middle\">\u00D7${e.weight}</text>`;\n }\n if (iso.length) svg += `<text class=\"glabel\" x=\"${PAD}\" y=\"${isoLabelY}\" style=\"font-size:11px\">${conn.length ? \"no lineage links yet\" : \"artifacts (no lineage links yet \u2014 add derived_from or caused_by to connect them)\"} \u00B7 ${iso.length}</text>`;\n for (const n of arts) {\n const p = pos.get(n.id); const dim = selArt && n.id !== selArt && !connected.has(n.id) ? \" dim\" : \"\"; const sel = selArt === n.id ? \" sel\" : \"\";\n const sub = `${n.events} ev \u00B7 ${n.actors.length} actor${n.actors.length === 1 ? \"\" : \"s\"}${n.kind ? \" \u00B7 \" + n.kind : \"\"}`;\n svg += `<g class=\"gnode k-${esc(n.kind || \"x\")}${dim}${sel}\" data-art=\"${esc(n.id)}\" transform=\"translate(${p.x},${p.y})\"><rect width=\"${p.w}\" height=\"${p.h}\" rx=\"6\"/><text x=\"10\" y=\"18\">${esc(trunc(n.label, 26))}</text><text class=\"sub\" x=\"10\" y=\"35\">${esc(sub)}</text><title>${esc(n.id)}\\n${esc(Object.entries(n.actions).map(([k, v]) => k + \" \u00D7\" + v).join(\", \"))}\\nby ${esc(n.actors.join(\", \"))}</title></g>`;\n }\n for (const n of actors) {\n const p = pos.get(\"u:\" + n.id); const shape = n.kind === \"agent\" ? `<polygon points=\"10,0 ${p.w - 10},0 ${p.w},${p.h / 2} ${p.w - 10},${p.h} 10,${p.h} 0,${p.h / 2}\"/>` : `<ellipse cx=\"${p.w / 2}\" cy=\"${p.h / 2}\" rx=\"${p.w / 2}\" ry=\"${p.h / 2}\"/>`;\n svg += `<g class=\"gnode actor ${esc(n.kind)}\" data-actor=\"${esc(n.id)}\" transform=\"translate(${p.x},${p.y})\">${shape}<text x=\"${p.w / 2}\" y=\"20\" text-anchor=\"middle\">${esc(trunc(n.label, 18))}</text><text class=\"sub\" x=\"${p.w / 2}\" y=\"35\" text-anchor=\"middle\">${esc(n.kind)} \u00B7 ${n.events} ev</text><title>${esc(n.id)}</title></g>`;\n }\n svg += `</svg>`;\n $(\"#gsvg\").innerHTML = svg;\n }\n function setView(v) { const g = v === \"graph\"; $(\"#graph\").classList.toggle(\"on\", g); $(\"#timeline\").classList.toggle(\"off\", g); $(\"#vGraph\").classList.toggle(\"on\", g); $(\"#vTimeline\").classList.toggle(\"on\", !g); if (g) renderGraph(); }\n $(\"#vTimeline\").addEventListener(\"click\", () => setView(\"timeline\"));\n $(\"#vGraph\").addEventListener(\"click\", () => setView(\"graph\"));\n $(\"#gActors\").addEventListener(\"change\", renderGraph); $(\"#gFlow\").addEventListener(\"change\", renderGraph);\n function lineageDot(l) { const q = JSON.stringify; const out = [\"digraph retrace {\", \" rankdir=LR; node [fontname=Helvetica, fontsize=10];\"]; for (const n of l.nodes) out.push(` ${q((n.type === \"actor\" ? \"u:\" : \"\") + n.id)} [label=${q(n.label + (n.type === \"artifact\" ? \"\\\\n\" + n.events + \" ev\" : \"\"))}, shape=${n.type === \"actor\" ? \"ellipse\" : \"box\"}${n.type === \"actor\" ? \", style=dashed\" : \"\"}];`); for (const e of l.edges) out.push(` ${q(e.from)} -> ${q(e.to)}${e.type === \"derived\" ? \" [penwidth=2]\" : e.type === \"touched\" ? \" [style=dashed,color=gray]\" : \"\"};`); out.push(\"}\"); return out.join(\"\\n\"); }\n function lineageMermaid(l) { const ids = new Map(); let i = 0; const nid = (s) => { if (!ids.has(s)) ids.set(s, \"n\" + i++); return ids.get(s); }; const out = [\"graph LR\"]; for (const n of l.nodes) { const k = (n.type === \"actor\" ? \"u:\" : \"\") + n.id; const lbl = (n.label + (n.type === \"artifact\" ? ` (${n.events})` : \"\")).replace(/\"/g, \"'\"); out.push(n.type === \"actor\" ? ` ${nid(k)}([\"${lbl}\"])` : ` ${nid(k)}[\"${lbl}\"]`); } for (const e of l.edges) out.push(` ${nid(e.from)} ${e.type === \"derived\" ? \"==>\" : e.type === \"flow\" ? \"-->\" : \"-.->\"} ${nid(e.to)}`); return out.join(\"\\n\"); }\n $(\"#gDot\").addEventListener(\"click\", () => lastLineage && dl(new Blob([lineageDot(lastLineage)], { type: \"text/vnd.graphviz\" }), `retrace-${state.project}-lineage.dot`));\n $(\"#gMmd\").addEventListener(\"click\", () => { if (!lastLineage) return; const t = lineageMermaid(lastLineage); navigator.clipboard?.writeText(t); $(\"#gMmd\").textContent = \"copied\"; setTimeout(() => ($(\"#gMmd\").textContent = \"Mermaid\"), 1200); });\n if (params.get(\"view\") === \"graph\") setTimeout(() => setView(\"graph\"), 0);\n\n // events\n document.addEventListener(\"click\", (ev) => {\n const t = ev.target.closest(\"[data-order],[data-expand],[data-art],[data-actor],[data-goto],[data-clear],[data-q],.ev\");\n if (!t) return;\n if (t.dataset.order) { ev.stopPropagation(); state.newestFirst = !state.newestFirst; return render(); }\n if (t.dataset.expand) { ev.stopPropagation(); const id = t.dataset.expand; state.expanded.has(id) ? state.expanded.delete(id) : state.expanded.add(id); return render(); }\n if (t.dataset.art) { state.filterArtifact = t.dataset.art; ev.stopPropagation(); return render(); }\n if (t.dataset.actor) { state.filterActor = t.dataset.actor; ev.stopPropagation(); return render(); }\n if (t.dataset.q) { $(\"#q\").value = t.dataset.q; ev.stopPropagation(); return render(); }\n if (t.dataset.goto) { ev.preventDefault(); state.sel = t.dataset.goto; state.filterArtifact = null; state.filterActor = null; render(); document.querySelector(`.ev[data-id=\"${CSS.escape(state.sel)}\"]`)?.scrollIntoView({ block: \"center\", behavior: \"smooth\" }); return; }\n if (t.dataset.clear) { if (t.dataset.clear === \"artifact\") state.filterArtifact = null; else state.filterActor = null; return render(); }\n if (t.classList.contains(\"ev\")) { state.sel = t.dataset.id; render(); }\n });\n $(\"#project\").addEventListener(\"change\", (e) => { state.project = e.target.value; state.sel = null; state.filterArtifact = null; state.filterActor = null; loadEvents().catch(showErr); });\n [\"#q\", \"#ftype\", \"#faction\"].forEach((s) => $(s).addEventListener(\"input\", render));\n $(\"#refresh\").addEventListener(\"click\", () => boot());\n $(\"#report\").addEventListener(\"click\", () => window.open(P.reportUrl(state.project, state.filterArtifact), \"_blank\"));\n $(\"#export\").addEventListener(\"click\", async () => {\n if (state.offline) { const blob = new Blob([JSON.stringify({ format: \"retrace-export/1\", scope: { project: state.project }, events: state.events }, null, 2)], { type: \"application/json\" }); return dl(blob, `${state.project}-events.json`); }\n const res = await fetch(P.exportUrl(state.project, state.filterArtifact), { headers: token ? { authorization: `Bearer ${token}` } : {} });\n dl(await res.blob(), `retrace-${state.project}${state.filterArtifact ? \"-\" + state.filterArtifact.replace(/[^\\w.-]+/g, \"_\") : \"\"}.json`);\n });\n function dl(blob, name) { const a = document.createElement(\"a\"); a.href = URL.createObjectURL(blob); a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 5000); }\n $(\"#share\").addEventListener(\"click\", () => { $(\"#shScope\").value = state.filterArtifact ? \"artifact\" : \"project\"; $(\"#shScope\").querySelector('[value=artifact]').disabled = !state.filterArtifact; $(\"#shResult\").textContent = \"\"; $(\"#shareDlg\").style.display = \"flex\"; });\n $(\"#shCancel\").addEventListener(\"click\", () => ($(\"#shareDlg\").style.display = \"none\"));\n $(\"#shCreate\").addEventListener(\"click\", async () => {\n const body = { artifact_id: $(\"#shScope\").value === \"artifact\" ? state.filterArtifact : undefined, label: $(\"#shLabel\").value.trim() || undefined, expires_in_days: Number($(\"#shExp\").value) || undefined };\n try {\n const res = await fetch(`${apiBase}/projects/${encodeURIComponent(state.project)}/share`, { method: \"POST\", headers: { \"content-type\": \"application/json\", ...(token ? { authorization: `Bearer ${token}` } : {}) }, body: JSON.stringify(body) });\n const r = await res.json(); if (!res.ok) throw new Error(r.error || res.status);\n const u = r.url.startsWith(\"http\") ? r.url : location.origin + r.url;\n $(\"#shResult\").innerHTML = `Link: <a href=\"${esc(u)}\" target=\"_blank\">${esc(u)}</a> <button id=\"shCopy\">copy</button><br>Report: <a href=\"${esc(u)}/report\" target=\"_blank\">${esc(u)}/report</a>${r.share.expires_at ? `<br>expires ${esc(new Date(r.share.expires_at).toLocaleString())}` : \"\"}`;\n $(\"#shCopy\").addEventListener(\"click\", () => navigator.clipboard?.writeText(u));\n } catch (e) { $(\"#shResult\").textContent = \"Failed: \" + e.message; }\n });\n $(\"#toggleConn\").addEventListener(\"click\", () => $(\"#conn\").classList.toggle(\"open\"));\n $(\"#connect\").addEventListener(\"click\", () => { apiBase = $(\"#apiUrl\").value.trim().replace(/\\/$/, \"\"); token = $(\"#apiTok\").value.trim(); state.offline = false; boot(); });\n $(\"#loadJson\").addEventListener(\"click\", () => $(\"#file\").click());\n $(\"#file\").addEventListener(\"change\", async (e) => {\n const f = e.target.files[0]; if (!f) return;\n const data = JSON.parse(await f.text());\n const events = Array.isArray(data) ? data : data.events ?? [];\n state.offline = true; state.events = events.sort((a, b) => a.seq - b.seq); state.verify = null; state.status = null;\n state.projects = [...new Set(events.map((x) => x.project))]; state.project = state.projects[0] ?? null;\n $(\"#project\").innerHTML = state.projects.map((p) => `<option>${esc(p)}</option>`).join(\"\");\n render();\n });\n function showErr(err) { $(\"#timeline\").innerHTML = `<div class=\"empty\">Could not load: ${esc(err.message)}<br><br>Open \u2699 to set the API URL / token, or load a JSON export.</div>`; $(\"#conn\").classList.add(\"open\"); }\n async function boot() { try { await loadProjects(); await loadEvents(); } catch (err) { showErr(err); } }\n boot();\n setInterval(() => { if (!state.offline && document.visibilityState === \"visible\") loadEvents().catch(() => {}); }, 15000);\n})();\n</script>\n</body>\n</html>\n";
|
package/dist/ui-html.js
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
// GENERATED by scripts/embed-ui.mjs — edit ui/retrace.html instead.
|
|
2
|
+
export const UI_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\" />\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n<title>Retrace</title>\n<style>\n :root {\n --bg: #0f1115; --panel: #171a21; --panel-2: #1e222b; --line: #2a2f3a; --text: #e6e8ee; --muted: #8b93a7;\n --human: #f2a93b; --agent: #5aa9ff; --system: #9aa3b5; --ok: #3ccb7f; --bad: #ff5c6c; --accent: #c7b6ff;\n --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n }\n @media (prefers-color-scheme: light) {\n :root { --bg: #f6f7fa; --panel: #ffffff; --panel-2: #f0f2f6; --line: #dfe3ea; --text: #16181d; --muted: #616a7d;\n --human: #c77800; --agent: #1f6fe0; --system: #6b7385; --ok: #178f4f; --bad: #d1303f; --accent: #6b4fd8; }\n }\n * { box-sizing: border-box; }\n body { margin: 0; font: 14px/1.45 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; background: var(--bg); color: var(--text); }\n header { display: flex; align-items: center; gap: 12px; padding: 10px 16px; border-bottom: 1px solid var(--line); background: var(--panel); position: sticky; top: 0; z-index: 5; flex-wrap: wrap; }\n header h1 { font-size: 16px; margin: 0 8px 0 0; letter-spacing: .04em; }\n header h1 span { color: var(--accent); }\n select, input, button { font: inherit; color: var(--text); background: var(--panel-2); border: 1px solid var(--line); border-radius: 6px; padding: 6px 8px; }\n input { min-width: 140px; }\n header button { padding: 6px 9px; }\n button { cursor: pointer; }\n button.primary { background: var(--accent); color: #111; border-color: transparent; font-weight: 600; }\n .badge { display: inline-flex; align-items: center; gap: 6px; padding: 3px 8px; border-radius: 999px; font-size: 12px; border: 1px solid var(--line); background: var(--panel-2); }\n .badge.ok { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 40%, transparent); }\n .badge.bad { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 40%, transparent); }\n .dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; }\n main { display: grid; grid-template-columns: minmax(0, 1fr) 420px; gap: 0; height: calc(100vh - 53px); }\n @media (max-width: 900px) { main { grid-template-columns: 1fr; height: auto; } #detail { border-left: 0; border-top: 1px solid var(--line); } }\n #timeline { overflow: auto; padding: 16px 20px 40px; }\n #detail { border-left: 1px solid var(--line); background: var(--panel); overflow: auto; padding: 16px; }\n .legend { display: flex; gap: 14px; color: var(--muted); font-size: 12px; margin-bottom: 12px; align-items: center; flex-wrap: wrap; }\n .legend .dot.human { background: var(--human); } .legend .dot.agent { background: var(--agent); } .legend .dot.system { background: var(--system); }\n .day { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .08em; margin: 18px 0 8px 0; }\n .ev { display: grid; grid-template-columns: 64px 18px 1fr; gap: 10px; align-items: start; padding: 8px 8px; border-radius: 8px; cursor: pointer; position: relative; }\n .ev:hover { background: var(--panel-2); }\n .ev.sel { background: var(--panel-2); outline: 1px solid var(--accent); }\n .ev .t { color: var(--muted); font-family: var(--mono); font-size: 12px; padding-top: 2px; }\n .ev .rail { position: relative; height: 100%; }\n .ev .rail::before { content: \"\"; position: absolute; left: 8px; top: -10px; bottom: -14px; width: 2px; background: var(--line); }\n .ev .node { position: absolute; left: 3px; top: 3px; width: 12px; height: 12px; border-radius: 50%; border: 2px solid var(--bg); box-shadow: 0 0 0 1px var(--line); }\n .ev.human .node { background: var(--human); } .ev.agent .node { background: var(--agent); border-radius: 3px; } .ev.system .node { background: var(--system); }\n .ev .who { font-weight: 600; }\n .ev.human .who { color: var(--human); } .ev.agent .who { color: var(--agent); } .ev.system .who { color: var(--system); }\n .ev .verb { color: var(--accent); }\n .ev .art { font-family: var(--mono); font-size: 12.5px; }\n .ev .why { color: var(--muted); font-size: 12.5px; margin-top: 2px; }\n .ev .link { color: var(--muted); font-size: 11px; }\n .chip { display: inline-block; font-family: var(--mono); font-size: 11px; background: var(--panel-2); border: 1px solid var(--line); border-radius: 4px; padding: 1px 6px; margin-right: 4px; cursor: pointer; }\n .chip:hover { border-color: var(--accent); }\n .chip.more { color: var(--accent); border-style: dashed; }\n .chip .role { font-style: normal; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); margin-right: 4px; }\n #detail h2 { font-size: 14px; margin: 0 0 8px; color: var(--muted); font-weight: 500; text-transform: uppercase; letter-spacing: .08em; }\n .kv { display: grid; grid-template-columns: 62px 1fr; gap: 6px 10px; margin-bottom: 14px; }\n .kv .k { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .06em; padding-top: 2px; }\n .kv .v { word-break: break-word; }\n .kv .v.mono { font-family: var(--mono); font-size: 12px; }\n pre { background: var(--panel-2); border: 1px solid var(--line); border-radius: 6px; padding: 8px; font-family: var(--mono); font-size: 12px; overflow: auto; max-height: 240px; margin: 4px 0 0; white-space: pre-wrap; }\n .why-chain { border-left: 2px solid var(--line); padding-left: 10px; margin: 6px 0 14px 4px; }\n .why-chain .step { margin: 6px 0; font-size: 13px; }\n .why-chain .step .who { font-weight: 600; }\n .why-chain .step.human .who { color: var(--human); } .why-chain .step.agent .who { color: var(--agent); }\n .why-chain .step .arrow { color: var(--muted); font-size: 11px; }\n .empty { color: var(--muted); padding: 40px 0; text-align: center; }\n .stat { display: flex; gap: 16px; color: var(--muted); font-size: 12px; margin-bottom: 8px; flex-wrap: wrap; }\n .stat b { color: var(--text); font-weight: 600; }\n #conn { display: none; gap: 8px; align-items: center; }\n #conn.open { display: flex; }\n .hash { font-family: var(--mono); font-size: 11px; color: var(--muted); }\n a { color: var(--accent); }\n .seg { display: inline-flex; border: 1px solid var(--line); border-radius: 6px; overflow: hidden; }\n .seg button { border: 0; border-radius: 0; padding: 6px 10px; background: var(--panel-2); color: var(--muted); }\n .seg button.on { background: var(--accent); color: #111; font-weight: 600; }\n #graph { display: none; overflow: auto; position: relative; }\n #graph.on { display: block; } #timeline.off { display: none; }\n #graph svg { display: block; min-width: 100%; }\n .gctl { position: sticky; top: 0; left: 0; z-index: 2; width: 100%; display: flex; gap: 12px; align-items: center; padding: 10px 16px; background: color-mix(in srgb, var(--bg) 85%, transparent); backdrop-filter: blur(4px); font-size: 12px; color: var(--muted); flex-wrap: wrap; }\n .gctl label { display: inline-flex; gap: 4px; align-items: center; }\n .gnode { cursor: pointer; }\n .gnode rect, .gnode ellipse, .gnode polygon { fill: var(--panel-2); stroke: var(--line); stroke-width: 1.2; }\n .gnode:hover rect, .gnode:hover ellipse, .gnode:hover polygon, .gnode.sel rect, .gnode.sel ellipse, .gnode.sel polygon { stroke: var(--accent); stroke-width: 2; }\n .gnode.k-task rect { stroke: var(--human); } .gnode.k-commit rect { stroke-dasharray: 3 2; } .gnode.k-pr rect { stroke: var(--ok); } .gnode.k-command rect { stroke: var(--system); }\n .gnode.actor.human ellipse { stroke: var(--human); } .gnode.actor.agent polygon { stroke: var(--agent); } .gnode.actor.system ellipse { stroke: var(--system); }\n .gnode text { fill: var(--text); font-size: 11.5px; font-family: var(--mono); pointer-events: none; }\n .gnode text.sub { fill: var(--muted); font-size: 10px; font-family: system-ui, sans-serif; }\n .gedge { fill: none; stroke: var(--muted); stroke-width: 1.2; opacity: .75; }\n .gedge.derived { stroke: var(--accent); stroke-width: 2.2; opacity: 1; }\n .gedge.touched { stroke-dasharray: 3 3; opacity: .45; }\n .gedge.dim { opacity: .12; } .gnode.dim { opacity: .25; }\n .glabel { fill: var(--muted); font-size: 9.5px; }\n</style>\n</head>\n<body>\n<header>\n <h1>RE<span>TRACE</span></h1>\n <select id=\"project\" title=\"Project\"></select>\n <input id=\"q\" placeholder=\"search…\" style=\"width:150px\" />\n <select id=\"ftype\"><option value=\"\">all actors</option><option value=\"human\">humans</option><option value=\"agent\">agents</option><option value=\"system\">system</option></select>\n <select id=\"faction\"><option value=\"\">any action</option></select>\n <span class=\"seg\"><button id=\"vTimeline\" class=\"on\">Timeline</button><button id=\"vGraph\">Graph</button></span>\n <span id=\"verify\" class=\"badge\"><span class=\"dot\"></span><span>verifying…</span></span>\n <span id=\"status\" class=\"badge\" title=\"Capture completeness and causal coverage\">capture…</span>\n <span id=\"sharedBadge\" class=\"badge\" style=\"display:none\" title=\"Read-only shared view\">🔗 shared view</span>\n <button id=\"report\" title=\"Printable report (save as PDF)\">Report</button>\n <button id=\"export\" title=\"Download signed JSON bundle\">Export</button>\n <button id=\"share\" title=\"Create a read-only share link\">Share</button>\n <button id=\"refresh\" title=\"Reload\">↻</button>\n <button id=\"toggleConn\" title=\"Connection settings\">⚙</button>\n <span id=\"conn\"><input id=\"apiUrl\" placeholder=\"API URL (blank = same origin)\" /><input id=\"apiTok\" placeholder=\"token\" type=\"password\" /><button id=\"connect\" class=\"primary\">Connect</button><button id=\"loadJson\">Load JSON…</button><input type=\"file\" id=\"file\" accept=\"application/json\" style=\"display:none\" /></span>\n</header>\n<div id=\"shareDlg\" style=\"display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:20;align-items:center;justify-content:center\">\n <div style=\"background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:18px;width:min(520px,92vw)\">\n <h3 style=\"margin:0 0 10px;font-size:15px\">Create read-only share link</h3>\n <div class=\"link\" style=\"margin-bottom:8px\">Anyone with the link can view (not edit) the events in scope, verify the chain, and download the signed export / report.</div>\n <div style=\"display:grid;gap:8px\">\n <label>Scope <select id=\"shScope\"><option value=\"project\">whole project</option><option value=\"artifact\">current artifact filter</option></select></label>\n <label>Label <input id=\"shLabel\" placeholder=\"e.g. Jab counter — client review\" style=\"width:100%\"></label>\n <label>Expires <select id=\"shExp\"><option value=\"\">never</option><option value=\"7\">7 days</option><option value=\"30\">30 days</option><option value=\"90\">90 days</option></select></label>\n <div id=\"shResult\" class=\"link\" style=\"word-break:break-all\"></div>\n <div style=\"display:flex;gap:8px;justify-content:flex-end\"><button id=\"shCancel\">Close</button><button id=\"shCreate\" class=\"primary\">Create link</button></div>\n </div>\n </div>\n</div>\n<main>\n <section id=\"timeline\"><div class=\"empty\">Loading…</div></section>\n <section id=\"graph\">\n <div class=\"gctl\">\n <span><b id=\"gStat\"></b></span>\n <label><input type=\"checkbox\" id=\"gActors\"> show people & agents</label>\n <label><input type=\"checkbox\" id=\"gFlow\" checked> causal flow edges</label>\n <span style=\"margin-left:auto\">━ derived_from → causal flow ┈ touched · click a node to filter the timeline</span>\n <button id=\"gDot\" title=\"Download Graphviz DOT\">DOT</button><button id=\"gMmd\" title=\"Copy Mermaid\">Mermaid</button>\n </div>\n <div id=\"gsvg\"></div>\n </section>\n <aside id=\"detail\"><div class=\"empty\">Select an event to see who / what / when / where / why / how.</div></aside>\n</main>\n<script>\n(() => {\n const $ = (s) => document.querySelector(s);\n const state = { events: [], project: null, projects: [], sel: null, filterArtifact: null, filterActor: null, verify: null, status: null, offline: false, expanded: new Set(), newestFirst: true };\n const params = new URLSearchParams(location.search);\n if (params.get(\"order\") === \"asc\") state.newestFirst = false;\n let apiBase = params.get(\"api\") ?? \"\";\n let token = params.get(\"token\") ?? \"\";\n $(\"#apiUrl\").value = apiBase; $(\"#apiTok\").value = token;\n // Shared mode: /s/<id> → all reads go through /s/<id>/*, scope locked server-side\n const shareMatch = location.pathname.match(/\\/s\\/([^/]+)/);\n const shareId = shareMatch ? shareMatch[1] : null;\n if (shareId) {\n document.body.classList.add(\"shared\");\n $(\"#sharedBadge\").style.display = \"\";\n [\"#share\", \"#toggleConn\", \"#project\"].forEach((s) => ($(s).style.display = \"none\"));\n }\n const P = {\n projects: () => shareId ? api(`/s/${shareId}/meta`).then((m) => [m.project]) : api(\"/projects\"),\n events: (p) => shareId ? api(`/s/${shareId}/events?limit=5000`) : api(`/projects/${encodeURIComponent(p)}/events?limit=5000`),\n verify: (p) => shareId ? api(`/s/${shareId}/verify`) : api(`/projects/${encodeURIComponent(p)}/verify`),\n status: (p) => shareId ? Promise.resolve(null) : api(`/projects/${encodeURIComponent(p)}/status`),\n exportUrl: (p, art) => shareId ? `${apiBase}/s/${shareId}/export` : `${apiBase}/projects/${encodeURIComponent(p)}/export?${new URLSearchParams({ ...(art ? { artifact_id: art } : {}), ...(token ? { token } : {}) })}`,\n reportUrl: (p, art) => shareId ? `${apiBase}/s/${shareId}/report` : `${apiBase}/projects/${encodeURIComponent(p)}/report?${new URLSearchParams({ ...(art ? { artifact_id: art } : {}), ...(token ? { token } : {}) })}`,\n };\n\n const esc = (s) => String(s ?? \"\").replace(/[&<>\"']/g, (c) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" }[c]));\n const fmtTime = (iso) => new Date(iso).toLocaleTimeString([], { hour: \"2-digit\", minute: \"2-digit\", second: \"2-digit\" });\n const fmtDay = (iso) => new Date(iso).toLocaleDateString([], { weekday: \"short\", year: \"numeric\", month: \"short\", day: \"numeric\" });\n const actorName = (a) => a.display_name || a.id;\n const fmtDur = (ms) => ms < 1000 ? `${ms} ms` : ms < 60000 ? `${(ms / 1000).toFixed(ms < 10000 ? 1 : 0)} s` : ms < 3600000 ? `${Math.round(ms / 60000)} min` : `${(ms / 3600000).toFixed(1)} h`;\n const actorTag = (a) => a.type === \"agent\" ? (a.model ? ` · ${a.model}` : \" · agent\") : a.type === \"system\" ? \" · system\" : \"\";\n const verbOf = (e) => e.action === \"other\" ? (e.action_detail || \"acted on\") : e.action;\n const artLabel = (a) => a.label || a.id;\n // PROV role of a ref within its event: in (used) · out (generated) · in/out (both); no marker when unspecified.\n const roleMark = (r) => r === \"used\" ? \"in\" : r === \"generated\" ? \"out\" : r === \"both\" ? \"in/out\" : \"\";\n const roleTitle = (r) => r === \"used\" ? \" · used (input)\" : r === \"generated\" ? \" · generated (output)\" : r === \"both\" ? \" · used and generated\" : \"\";\n const roleTag = (a) => roleMark(a.role) ? `<i class=\"role\">${roleMark(a.role)}</i>` : \"\";\n const whereText = (loc) => loc ? [loc.system, loc.environment, loc.path || loc.url, loc.device, loc.client].filter(Boolean).join(\" · \") : \"\";\n // Second WHERE line: the run-identity fields. Deliberately NOT in whereText, which the timeline reuses and which\n // must stay a readable one-liner. session/workspace are clickable — they drop into the search box, which already\n // matches the whole event JSON, so one click shows everything from that agent run or that isolated worktree.\n const findable = (v) => `<span class=\"chip\" data-q=\"${esc(v)}\" title=\"search for ${esc(v)}\">${esc(v)}</span>`;\n const whereExtra = (loc) => {\n const bits = [];\n if (loc.ide) bits.push(`in <b>${esc(loc.ide)}</b>`);\n if (loc.workspace) bits.push(`workspace ${findable(loc.workspace)}`);\n if (loc.session) bits.push(`session ${findable(loc.session)}`);\n if (loc.surface) bits.push(loc.surface === \"tty\" ? \"at a terminal\" : \"no terminal · agent-driven\");\n return bits.length ? `<div class=\"link\">${bits.join(\" · \")}</div>` : \"\";\n };\n // Latest known label per artifact id: the label on the last event (by seq) that carries one.\n // Used wherever the UI names an artifact; the event detail pane keeps each event's as-at label.\n const latestLabels = (events) => { const m = new Map(); for (const e of [...events].sort((a, b) => a.seq - b.seq)) for (const a of e.artifacts) if (a.label) m.set(a.id, a.label); return m; };\n\n async function api(path) {\n const url = (apiBase || \"\") + path;\n const res = await fetch(url, { headers: token ? { authorization: `Bearer ${token}` } : {} });\n if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);\n return res.json();\n }\n\n async function loadProjects() {\n state.projects = await P.projects();\n const sel = $(\"#project\");\n sel.innerHTML = state.projects.map((p) => `<option>${esc(p)}</option>`).join(\"\") || \"<option value=''>(no projects yet)</option>\";\n const want = params.get(\"project\");\n state.project = state.projects.includes(want) ? want : state.projects[0] ?? null;\n if (state.project) sel.value = state.project;\n }\n\n async function loadEvents() {\n if (state.offline) return render();\n if (!state.project) { state.events = []; return render(); }\n const [events, verify, status] = await Promise.all([P.events(state.project), P.verify(state.project).catch(() => null), P.status(state.project).catch(() => null)]);\n state.events = events; state.verify = verify; state.status = status;\n render();\n }\n\n function localVerifyBadge() {\n const b = $(\"#verify\"); const v = state.verify;\n if (!v) { b.className = \"badge\"; b.lastElementChild.textContent = state.offline ? \"offline snapshot\" : \"not verified\"; return; }\n b.className = \"badge \" + (v.ok ? \"ok\" : \"bad\");\n b.lastElementChild.textContent = v.ok ? `chain intact · ${v.checked} events` : `chain BROKEN at #${v.first_bad_seq}`;\n b.title = v.reason || \"\";\n }\n\n function statusBadge() {\n const b = $(\"#status\"), s = state.status;\n if (!s?.capture || !s?.causality) { b.style.display = state.offline || shareId ? \"none\" : \"\"; b.textContent = \"capture unknown\"; return; }\n b.style.display = \"\";\n const gaps = s.capture.unlinked_commits + s.causality.broken_links + s.capture.agent_events_without_model;\n b.className = \"badge \" + (gaps ? \"bad\" : \"ok\");\n b.textContent = `${s.causality.coverage_pct}% causal · ${s.capture.unlinked_commits} unlinked commits`;\n b.title = `${s.capture.instructions_without_followup} instructions without follow-up · ${s.capture.agent_events_without_model} agent events missing model · ${s.capture.artifact_refs_without_role} artifact refs missing role`;\n }\n\n function filtered(opts = {}) {\n const q = $(\"#q\").value.trim().toLowerCase();\n const t = $(\"#ftype\").value; const a = $(\"#faction\").value;\n return state.events.filter((e) => {\n if (t && e.actor.type !== t) return false;\n if (a && e.action !== a) return false;\n if (!opts.ignoreArtifact && state.filterArtifact && !e.artifacts.some((x) => x.id === state.filterArtifact)) return false;\n if (state.filterActor && e.actor.id !== state.filterActor) return false;\n if (q && !JSON.stringify(e).toLowerCase().includes(q)) return false;\n return true;\n });\n }\n\n function render() {\n localVerifyBadge();\n statusBadge();\n const labels = latestLabels(state.events); const labelOf = (a) => labels.get(a.id) || artLabel(a);\n const acts = [...new Set(state.events.map((e) => e.action))].sort();\n const fa = $(\"#faction\"); const cur = fa.value;\n fa.innerHTML = `<option value=\"\">any action</option>` + acts.map((a) => `<option ${a === cur ? \"selected\" : \"\"}>${esc(a)}</option>`).join(\"\");\n const list = filtered(); if (state.newestFirst) list.reverse();\n const humans = new Set(state.events.filter((e) => e.actor.type === \"human\").map((e) => e.actor.id)).size;\n const agents = new Set(state.events.filter((e) => e.actor.type === \"agent\").map((e) => e.actor.id)).size;\n const arts = new Set(state.events.flatMap((e) => e.artifacts.map((a) => a.id))).size;\n let html = `<div class=\"stat\"><span><b>${state.events.length}</b> events</span><span><b>${humans}</b> humans</span><span><b>${agents}</b> agents</span><span><b>${arts}</b> artifacts</span>` +\n (state.filterArtifact ? `<span>artifact: <span class=\"chip\" data-clear=\"artifact\" title=\"${esc(state.filterArtifact)}\">${esc(labels.get(state.filterArtifact) || state.filterArtifact)} ✕</span></span>` : \"\") +\n (state.filterActor ? `<span>actor: <span class=\"chip\" data-clear=\"actor\">${esc(state.filterActor)} ✕</span></span>` : \"\") + `</div>`;\n html += `<div class=\"legend\"><span><i class=\"dot human\" style=\"display:inline-block\"></i> human</span><span><i class=\"dot agent\" style=\"display:inline-block;border-radius:2px\"></i> agent</span><span><i class=\"dot system\" style=\"display:inline-block\"></i> system</span><span style=\"margin-left:auto\"><span class=\"chip\" data-order=\"1\" title=\"flip order\">${state.newestFirst ? \"newest first ▾\" : \"oldest first ▴\"}</span></span></div>`;\n if (!list.length) html += `<div class=\"empty\">No events${state.events.length ? \" match these filters\" : \" yet — log one with the MCP tool <code>retrace_log</code> or POST /events\"}.</div>`;\n let lastDay = \"\";\n for (const e of list) {\n const day = fmtDay(e.timestamp);\n if (day !== lastDay) { html += `<div class=\"day\">${esc(day)}</div>`; lastDay = day; }\n const MAX = 6; const open = state.expanded.has(e.id); const shown = open ? e.artifacts : e.artifacts.slice(0, MAX); const extra = e.artifacts.length - shown.length;\n const arts = shown.map((a) => `<span class=\"chip\" data-art=\"${esc(a.id)}\" title=\"${esc(a.id + roleTitle(a.role))}\">${roleTag(a)}${esc(labelOf(a))}</span>`).join(\"\") +\n (extra > 0 ? `<span class=\"chip more\" data-expand=\"${esc(e.id)}\" title=\"show all ${e.artifacts.length} artifacts\">+${extra} more</span>` : open && e.artifacts.length > MAX ? `<span class=\"chip more\" data-expand=\"${esc(e.id)}\" title=\"collapse\">show less</span>` : \"\");\n const where = e.location?.path || e.location?.url || e.location?.system || \"\";\n const how = e.method?.tool ? `via ${esc(e.method.tool)}` : \"\";\n html += `<div class=\"ev ${e.actor.type} ${state.sel === e.id ? \"sel\" : \"\"}\" data-id=\"${esc(e.id)}\">\n <div class=\"t\">${fmtTime(e.timestamp)}<br><span class=\"link\">#${e.seq}</span></div>\n <div class=\"rail\"><div class=\"node\"></div></div>\n <div>\n <div><span class=\"who\" data-actor=\"${esc(e.actor.id)}\">${esc(actorName(e.actor))}</span><span class=\"link\">${esc(actorTag(e.actor))}${e.actor.on_behalf_of ? \" · for \" + esc(e.actor.on_behalf_of) : \"\"}</span> <span class=\"verb\">${esc(verbOf(e))}</span> <span class=\"art\">${arts}</span> <span class=\"link\">${where ? \"@ \" + esc(where) + \" \" : \"\"}${how}</span></div>\n ${e.intent ? `<div class=\"why\">${esc(e.intent)}</div>` : \"\"}\n ${e.caused_by ? `<div class=\"link\">↳ caused by <a href=\"#\" data-goto=\"${esc(e.caused_by)}\">${esc(e.caused_by.slice(0, 16))}…</a></div>` : \"\"}\n </div></div>`;\n }\n $(\"#timeline\").innerHTML = html;\n if (state.sel && !state.events.some((e) => e.id === state.sel)) state.sel = null;\n renderDetail();\n renderGraph();\n }\n\n function whyChain(id) {\n const byId = new Map(state.events.map((e) => [e.id, e]));\n const chain = []; const seen = new Set(); let cur = byId.get(id);\n while (cur && !seen.has(cur.id) && chain.length < 30) { seen.add(cur.id); chain.push(cur); cur = cur.caused_by ? byId.get(cur.caused_by) : null; }\n return chain;\n }\n function effects(id) { return state.events.filter((e) => e.caused_by === id); }\n\n function renderDetail() {\n const e = state.events.find((x) => x.id === state.sel);\n const d = $(\"#detail\");\n if (!e) { d.innerHTML = `<div class=\"empty\">Select an event to see who / what / when / where / why / how.</div>`; return; }\n const kv = (k, v, mono) => v ? `<div class=\"k\">${k}</div><div class=\"v ${mono ? \"mono\" : \"\"}\">${v}</div>` : \"\";\n // Every section always renders: absent data says \"(not recorded)\" instead of vanishing,\n // and a section that fails to render falls back on its own — it never blanks the rest of the pane.\n const sect = (k, fn, mono) => { let v; try { v = fn(); } catch { v = `<span class=\"link\">(could not render — see raw JSON)</span>`; } return kv(k, v || `<span class=\"link\">(not recorded)</span>`, mono); };\n let chainHtml, kidsHtml = \"\", kids = [];\n try {\n const chain = whyChain(e.id).slice(1); kids = effects(e.id);\n const parentMissing = e.caused_by && !state.events.some((x) => x.id === e.caused_by);\n chainHtml = parentMissing ? `<div class=\"link\" style=\"margin-bottom:12px\">caused by <span class=\"mono\">${esc(e.caused_by)}</span> — outside this view's scope</div>` : chain.length ? `<div class=\"why-chain\">` + chain.map((c) => `<div class=\"step ${c.actor.type}\"><span class=\"arrow\">↳ because</span> <span class=\"who\">${esc(actorName(c.actor))}</span> ${esc(verbOf(c))} ${esc(c.artifacts.map(artLabel).join(\", \"))}${c.intent ? ` — <i>${esc(c.intent)}</i>` : \"\"} <a href=\"#\" data-goto=\"${esc(c.id)}\" class=\"link\">#${c.seq}</a></div>`).join(\"\") + `</div>` : `<div class=\"link\" style=\"margin-bottom:12px\">no causal parent recorded — this is a root event</div>`;\n kidsHtml = kids.length ? `<div class=\"why-chain\">` + kids.map((c) => `<div class=\"step ${c.actor.type}\"><span class=\"arrow\">→ led to</span> <span class=\"who\">${esc(actorName(c.actor))}</span> ${esc(verbOf(c))} ${esc(c.artifacts.map(artLabel).join(\", \"))} <a href=\"#\" data-goto=\"${esc(c.id)}\" class=\"link\">#${c.seq}</a></div>`).join(\"\") + `</div>` : \"\";\n } catch { chainHtml = `<div class=\"link\" style=\"margin-bottom:12px\">(could not render the causal chain — see raw JSON)</div>`; }\n let raw; try { raw = JSON.stringify(e, null, 2); } catch { raw = \"(not serializable)\"; }\n d.innerHTML = `\n <h2>Event #${e.seq}</h2>\n <div class=\"kv\">\n ${sect(\"Who\", () => `<span style=\"color:var(--${e.actor.type});font-weight:600\">${esc(actorName(e.actor))}</span> <span class=\"link\">${esc(e.actor.type)}${e.actor.model ? \" · \" + esc(e.actor.model) : \"\"}${e.actor.version ? \" \" + esc(e.actor.version) : \"\"}${e.actor.on_behalf_of ? \"<br>on behalf of \" + esc(e.actor.on_behalf_of) : \"\"}</span>`)}\n ${sect(\"What\", () => `<b>${esc(verbOf(e))}</b> ${e.artifacts.map((a) => `<span class=\"chip\" data-art=\"${esc(a.id)}\" title=\"${esc(a.id + roleTitle(a.role))}\">${roleTag(a)}${esc(artLabel(a))}</span>${a.kind && a.kind !== \"file\" ? `<span class=\"link\">${esc(a.kind)}</span>` : \"\"}${a.derived_from?.length ? `<div class=\"link\">derived from ${esc(a.derived_from.join(\", \"))}</div>` : \"\"}`).join(\" \")}${e.change?.summary ? `<div>${esc(e.change.summary)}</div>` : \"\"}${e.change?.diff ? `<pre>${esc(e.change.diff)}</pre>` : \"\"}${e.change?.after_hash ? `<div class=\"hash\">after ${esc(e.change.after_hash)}</div>` : \"\"}`)}\n ${sect(\"When\", () => `${esc(new Date(e.timestamp).toLocaleString())}${e.duration_ms != null ? ` <span class=\"link\">· took ${fmtDur(e.duration_ms)}</span>` : \"\"}<div class=\"link\">received ${esc(new Date(e.received_at).toLocaleString())}</div>`)}\n ${sect(\"Where\", () => e.location ? esc(whereText(e.location)) + whereExtra(e.location) : \"\")}\n ${sect(\"Why\", () => e.intent ? esc(e.intent) : \"\")}\n ${sect(\"How\", () => e.method ? `${e.method.tool ? `<b>${esc(e.method.tool)}</b> ` : \"\"}${e.method.automated != null ? `<span class=\"link\">${e.method.automated ? \"automated\" : \"manual\"}</span>` : \"\"}${e.method.instruction ? `<div class=\"link\">instruction: ${esc(e.method.instruction)}</div>` : \"\"}${e.method.tokens != null ? `<div class=\"link\">${e.method.tokens} tokens${e.method.cost_usd != null ? ` · $${e.method.cost_usd}` : \"\"}</div>` : \"\"}${e.method.params ? `<pre>${esc(JSON.stringify(e.method.params, null, 2))}</pre>` : \"\"}` : \"\")}\n ${sect(\"Tags\", () => e.tags?.length ? e.tags.map((t) => `<span class=\"chip\">${esc(t)}</span>`).join(\"\") : \"\")}\n </div>\n <h2>Causal chain</h2>${chainHtml}\n ${kids.length ? `<h2>Consequences</h2>${kidsHtml}` : \"\"}\n <h2>Integrity</h2>\n <div class=\"kv\"><div class=\"k\">id</div><div class=\"v mono\">${esc(e.id)}</div><div class=\"k\">hash</div><div class=\"v hash\">${esc(e.hash)}</div><div class=\"k\">prev</div><div class=\"v hash\">${esc(e.prev_hash)}</div></div>\n <details><summary class=\"link\">raw JSON</summary><pre>${esc(raw)}</pre></details>`;\n }\n\n // ---------- lineage graph ----------\n function buildLineage(events, opts = {}) {\n const nodes = new Map(), edges = new Map(), byId = new Map(events.map((e) => [e.id, e]));\n const sorted = [...events].sort((a, b) => a.seq - b.seq);\n const art = (id, label, kind, seq) => { let n = nodes.get(\"a:\" + id); if (!n) { n = { id, type: \"artifact\", label: label || id, kind, events: 0, first_seq: seq, last_seq: seq, actors: [], actions: {} }; nodes.set(\"a:\" + id, n); } if (label) n.label = label; if (kind && !n.kind) n.kind = kind; return n; };\n const addEdge = (from, to, type, via) => { if (from === to) return; const k = `${type}|${from}|${to}`; let ed = edges.get(k); if (!ed) { ed = { from, to, type, weight: 0, via: [] }; edges.set(k, ed); } ed.weight++; if (ed.via.length < 5) ed.via.push(via); };\n for (const e of sorted) {\n for (const a of e.artifacts) {\n const n = art(a.id, a.label, a.kind, e.seq); n.events++; n.last_seq = e.seq; if (!n.actors.includes(e.actor.id)) n.actors.push(e.actor.id); n.actions[e.action] = (n.actions[e.action] || 0) + 1;\n for (const src of a.derived_from || []) { art(src, undefined, undefined, e.seq); addEdge(src, a.id, \"derived\", e.id); }\n if (opts.includeActors) { let u = nodes.get(\"u:\" + e.actor.id); if (!u) { u = { id: e.actor.id, type: \"actor\", label: actorName(e.actor), kind: e.actor.type, events: 0, first_seq: e.seq, last_seq: e.seq }; nodes.set(\"u:\" + e.actor.id, u); } u.events++; addEdge(\"u:\" + e.actor.id, a.id, \"touched\", e.id); }\n }\n if (e.caused_by && opts.flow !== false) { const p = byId.get(e.caused_by); if (p) for (const pa of p.artifacts) for (const ca of e.artifacts) if (pa.id !== ca.id) addEdge(pa.id, ca.id, \"flow\", e.id); }\n }\n return { nodes: [...nodes.values()], edges: [...edges.values()] };\n }\n function layerLineage(l) {\n const ids = l.nodes.filter((n) => n.type === \"artifact\").map((n) => n.id);\n const inc = new Map(ids.map((id) => [id, []])); for (const e of l.edges) if (e.type !== \"touched\" && inc.has(e.to) && inc.has(e.from)) inc.get(e.to).push(e.from);\n const seq = new Map(l.nodes.map((n) => [n.id, n.first_seq])); const layer = new Map(), visiting = new Set();\n const depth = (id) => { if (layer.has(id)) return layer.get(id); if (visiting.has(id)) return 0; visiting.add(id); let d = 0; for (const p of inc.get(id) || []) if ((seq.get(p) ?? 0) <= (seq.get(id) ?? 0)) d = Math.max(d, depth(p) + 1); visiting.delete(id); layer.set(id, d); return d; };\n for (const id of ids) depth(id); return layer;\n }\n let lastLineage = null;\n function renderGraph() {\n if (!$(\"#graph\").classList.contains(\"on\")) return;\n const events = filtered({ ignoreArtifact: true }); // artifact filter highlights in the graph instead of hiding\n const l = buildLineage(events, { includeActors: $(\"#gActors\").checked, flow: $(\"#gFlow\").checked });\n lastLineage = l;\n const arts = l.nodes.filter((n) => n.type === \"artifact\"), actors = l.nodes.filter((n) => n.type === \"actor\");\n $(\"#gStat\").textContent = `${arts.length} artifacts · ${l.edges.filter((e) => e.type !== \"touched\").length} lineage edges${actors.length ? ` · ${actors.length} actors` : \"\"}`;\n if (!arts.length) { $(\"#gsvg\").innerHTML = `<div class=\"empty\">No artifacts in view.</div>`; return; }\n const layer = layerLineage(l);\n const W = 190, H = 46, GX = 70, GY = 16, PAD = 24, ACT_W = 140;\n const cw0 = $(\"#graph\").clientWidth || 1000;\n // connected artifacts: columns = layers, rows by first_seq. Isolated artifacts (no lineage edges): packed grid below.\n const linked = new Set(); for (const e of l.edges) if (e.type !== \"touched\") { linked.add(e.from); linked.add(e.to); }\n const conn = arts.filter((n) => linked.has(n.id)), iso = arts.filter((n) => !linked.has(n.id));\n const cols = new Map();\n for (const n of conn) { const c = layer.get(n.id) || 0; if (!cols.has(c)) cols.set(c, []); cols.get(c).push(n); }\n for (const arr of cols.values()) arr.sort((a, b) => a.first_seq - b.first_seq);\n const pos = new Map(); let maxRows = 0;\n const offX = actors.length ? ACT_W + GX : 0;\n for (const [c, arr] of cols) { maxRows = Math.max(maxRows, arr.length); arr.forEach((n, i) => pos.set(n.id, { x: PAD + offX + c * (W + GX), y: PAD + i * (H + GY), w: W, h: H })); }\n actors.sort((a, b) => a.first_seq - b.first_seq).forEach((n, i) => pos.set(\"u:\" + n.id, { x: PAD, y: PAD + i * (H + GY), w: ACT_W, h: H }));\n maxRows = Math.max(maxRows, actors.length);\n let width = PAD * 2 + offX + Math.max(cols.size, 0) * (W + GX) - (cols.size ? GX : 0);\n let height = PAD * 2 + maxRows * (H + GY) - GY;\n let isoLabelY = 0;\n if (iso.length) {\n const perRow = Math.max(1, Math.floor((cw0 - PAD * 2 + 30) / (W + 30)));\n const y0 = (conn.length || actors.length) ? height + 10 : PAD; isoLabelY = y0 + 12;\n iso.sort((a, b) => b.events - a.events).forEach((n, i) => pos.set(n.id, { x: PAD + (i % perRow) * (W + 30), y: y0 + 24 + Math.floor(i / perRow) * (H + GY), w: W, h: H }));\n const rows = Math.ceil(iso.length / perRow);\n height = y0 + 24 + rows * (H + GY) - GY + PAD; width = Math.max(width, PAD * 2 + Math.min(iso.length, perRow) * (W + 30) - 30);\n }\n const trunc = (t, n) => (t.length > n ? \"…\" + t.slice(-(n - 1)) : t);\n const cw = $(\"#graph\").clientWidth || width; const fit = width > cw && width < cw * 1.5; // gently scale to fit when slightly too wide, otherwise scroll\n let svg = `<svg xmlns=\"http://www.w3.org/2000/svg\" ${fit ? `width=\"100%\"` : `width=\"${width}\"`} height=\"${fit ? Math.round(Math.max(height, 120) * cw / width) : Math.max(height, 120)}\" viewBox=\"0 0 ${width} ${Math.max(height, 120)}\" preserveAspectRatio=\"xMinYMin meet\"><defs><marker id=\"arr\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\"><path d=\"M0 0L10 5L0 10z\" fill=\"context-stroke\"/></marker></defs>`;\n const selArt = state.filterArtifact;\n const connected = new Set(); if (selArt) for (const e of l.edges) { if (e.from === selArt) connected.add(e.to); if (e.to === selArt) connected.add(e.from); }\n for (const e of l.edges) {\n const a = pos.get(e.from), b = pos.get(e.to); if (!a || !b) continue;\n const x1 = a.x + a.w, y1 = a.y + a.h / 2, x2 = b.x, y2 = b.y + b.h / 2; const dx = Math.max(40, (x2 - x1) / 2);\n const back = x2 <= x1; const d = back ? `M${x1} ${y1} C ${x1 + 60} ${y1}, ${x2 - 60} ${y2}, ${x2} ${y2}` : `M${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;\n const dim = selArt && e.from !== selArt && e.to !== selArt ? \" dim\" : \"\";\n svg += `<path class=\"gedge ${e.type}${dim}\" d=\"${d}\" marker-end=\"url(#arr)\"><title>${esc(e.type)} ×${e.weight}\\n${esc(e.from)} → ${esc(e.to)}</title></path>`;\n if (e.type === \"flow\" && e.weight > 1) svg += `<text class=\"glabel\" x=\"${(x1 + x2) / 2}\" y=\"${(y1 + y2) / 2 - 4}\" text-anchor=\"middle\">×${e.weight}</text>`;\n }\n if (iso.length) svg += `<text class=\"glabel\" x=\"${PAD}\" y=\"${isoLabelY}\" style=\"font-size:11px\">${conn.length ? \"no lineage links yet\" : \"artifacts (no lineage links yet — add derived_from or caused_by to connect them)\"} · ${iso.length}</text>`;\n for (const n of arts) {\n const p = pos.get(n.id); const dim = selArt && n.id !== selArt && !connected.has(n.id) ? \" dim\" : \"\"; const sel = selArt === n.id ? \" sel\" : \"\";\n const sub = `${n.events} ev · ${n.actors.length} actor${n.actors.length === 1 ? \"\" : \"s\"}${n.kind ? \" · \" + n.kind : \"\"}`;\n svg += `<g class=\"gnode k-${esc(n.kind || \"x\")}${dim}${sel}\" data-art=\"${esc(n.id)}\" transform=\"translate(${p.x},${p.y})\"><rect width=\"${p.w}\" height=\"${p.h}\" rx=\"6\"/><text x=\"10\" y=\"18\">${esc(trunc(n.label, 26))}</text><text class=\"sub\" x=\"10\" y=\"35\">${esc(sub)}</text><title>${esc(n.id)}\\n${esc(Object.entries(n.actions).map(([k, v]) => k + \" ×\" + v).join(\", \"))}\\nby ${esc(n.actors.join(\", \"))}</title></g>`;\n }\n for (const n of actors) {\n const p = pos.get(\"u:\" + n.id); const shape = n.kind === \"agent\" ? `<polygon points=\"10,0 ${p.w - 10},0 ${p.w},${p.h / 2} ${p.w - 10},${p.h} 10,${p.h} 0,${p.h / 2}\"/>` : `<ellipse cx=\"${p.w / 2}\" cy=\"${p.h / 2}\" rx=\"${p.w / 2}\" ry=\"${p.h / 2}\"/>`;\n svg += `<g class=\"gnode actor ${esc(n.kind)}\" data-actor=\"${esc(n.id)}\" transform=\"translate(${p.x},${p.y})\">${shape}<text x=\"${p.w / 2}\" y=\"20\" text-anchor=\"middle\">${esc(trunc(n.label, 18))}</text><text class=\"sub\" x=\"${p.w / 2}\" y=\"35\" text-anchor=\"middle\">${esc(n.kind)} · ${n.events} ev</text><title>${esc(n.id)}</title></g>`;\n }\n svg += `</svg>`;\n $(\"#gsvg\").innerHTML = svg;\n }\n function setView(v) { const g = v === \"graph\"; $(\"#graph\").classList.toggle(\"on\", g); $(\"#timeline\").classList.toggle(\"off\", g); $(\"#vGraph\").classList.toggle(\"on\", g); $(\"#vTimeline\").classList.toggle(\"on\", !g); if (g) renderGraph(); }\n $(\"#vTimeline\").addEventListener(\"click\", () => setView(\"timeline\"));\n $(\"#vGraph\").addEventListener(\"click\", () => setView(\"graph\"));\n $(\"#gActors\").addEventListener(\"change\", renderGraph); $(\"#gFlow\").addEventListener(\"change\", renderGraph);\n function lineageDot(l) { const q = JSON.stringify; const out = [\"digraph retrace {\", \" rankdir=LR; node [fontname=Helvetica, fontsize=10];\"]; for (const n of l.nodes) out.push(` ${q((n.type === \"actor\" ? \"u:\" : \"\") + n.id)} [label=${q(n.label + (n.type === \"artifact\" ? \"\\\\n\" + n.events + \" ev\" : \"\"))}, shape=${n.type === \"actor\" ? \"ellipse\" : \"box\"}${n.type === \"actor\" ? \", style=dashed\" : \"\"}];`); for (const e of l.edges) out.push(` ${q(e.from)} -> ${q(e.to)}${e.type === \"derived\" ? \" [penwidth=2]\" : e.type === \"touched\" ? \" [style=dashed,color=gray]\" : \"\"};`); out.push(\"}\"); return out.join(\"\\n\"); }\n function lineageMermaid(l) { const ids = new Map(); let i = 0; const nid = (s) => { if (!ids.has(s)) ids.set(s, \"n\" + i++); return ids.get(s); }; const out = [\"graph LR\"]; for (const n of l.nodes) { const k = (n.type === \"actor\" ? \"u:\" : \"\") + n.id; const lbl = (n.label + (n.type === \"artifact\" ? ` (${n.events})` : \"\")).replace(/\"/g, \"'\"); out.push(n.type === \"actor\" ? ` ${nid(k)}([\"${lbl}\"])` : ` ${nid(k)}[\"${lbl}\"]`); } for (const e of l.edges) out.push(` ${nid(e.from)} ${e.type === \"derived\" ? \"==>\" : e.type === \"flow\" ? \"-->\" : \"-.->\"} ${nid(e.to)}`); return out.join(\"\\n\"); }\n $(\"#gDot\").addEventListener(\"click\", () => lastLineage && dl(new Blob([lineageDot(lastLineage)], { type: \"text/vnd.graphviz\" }), `retrace-${state.project}-lineage.dot`));\n $(\"#gMmd\").addEventListener(\"click\", () => { if (!lastLineage) return; const t = lineageMermaid(lastLineage); navigator.clipboard?.writeText(t); $(\"#gMmd\").textContent = \"copied\"; setTimeout(() => ($(\"#gMmd\").textContent = \"Mermaid\"), 1200); });\n if (params.get(\"view\") === \"graph\") setTimeout(() => setView(\"graph\"), 0);\n\n // events\n document.addEventListener(\"click\", (ev) => {\n const t = ev.target.closest(\"[data-order],[data-expand],[data-art],[data-actor],[data-goto],[data-clear],[data-q],.ev\");\n if (!t) return;\n if (t.dataset.order) { ev.stopPropagation(); state.newestFirst = !state.newestFirst; return render(); }\n if (t.dataset.expand) { ev.stopPropagation(); const id = t.dataset.expand; state.expanded.has(id) ? state.expanded.delete(id) : state.expanded.add(id); return render(); }\n if (t.dataset.art) { state.filterArtifact = t.dataset.art; ev.stopPropagation(); return render(); }\n if (t.dataset.actor) { state.filterActor = t.dataset.actor; ev.stopPropagation(); return render(); }\n if (t.dataset.q) { $(\"#q\").value = t.dataset.q; ev.stopPropagation(); return render(); }\n if (t.dataset.goto) { ev.preventDefault(); state.sel = t.dataset.goto; state.filterArtifact = null; state.filterActor = null; render(); document.querySelector(`.ev[data-id=\"${CSS.escape(state.sel)}\"]`)?.scrollIntoView({ block: \"center\", behavior: \"smooth\" }); return; }\n if (t.dataset.clear) { if (t.dataset.clear === \"artifact\") state.filterArtifact = null; else state.filterActor = null; return render(); }\n if (t.classList.contains(\"ev\")) { state.sel = t.dataset.id; render(); }\n });\n $(\"#project\").addEventListener(\"change\", (e) => { state.project = e.target.value; state.sel = null; state.filterArtifact = null; state.filterActor = null; loadEvents().catch(showErr); });\n [\"#q\", \"#ftype\", \"#faction\"].forEach((s) => $(s).addEventListener(\"input\", render));\n $(\"#refresh\").addEventListener(\"click\", () => boot());\n $(\"#report\").addEventListener(\"click\", () => window.open(P.reportUrl(state.project, state.filterArtifact), \"_blank\"));\n $(\"#export\").addEventListener(\"click\", async () => {\n if (state.offline) { const blob = new Blob([JSON.stringify({ format: \"retrace-export/1\", scope: { project: state.project }, events: state.events }, null, 2)], { type: \"application/json\" }); return dl(blob, `${state.project}-events.json`); }\n const res = await fetch(P.exportUrl(state.project, state.filterArtifact), { headers: token ? { authorization: `Bearer ${token}` } : {} });\n dl(await res.blob(), `retrace-${state.project}${state.filterArtifact ? \"-\" + state.filterArtifact.replace(/[^\\w.-]+/g, \"_\") : \"\"}.json`);\n });\n function dl(blob, name) { const a = document.createElement(\"a\"); a.href = URL.createObjectURL(blob); a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 5000); }\n $(\"#share\").addEventListener(\"click\", () => { $(\"#shScope\").value = state.filterArtifact ? \"artifact\" : \"project\"; $(\"#shScope\").querySelector('[value=artifact]').disabled = !state.filterArtifact; $(\"#shResult\").textContent = \"\"; $(\"#shareDlg\").style.display = \"flex\"; });\n $(\"#shCancel\").addEventListener(\"click\", () => ($(\"#shareDlg\").style.display = \"none\"));\n $(\"#shCreate\").addEventListener(\"click\", async () => {\n const body = { artifact_id: $(\"#shScope\").value === \"artifact\" ? state.filterArtifact : undefined, label: $(\"#shLabel\").value.trim() || undefined, expires_in_days: Number($(\"#shExp\").value) || undefined };\n try {\n const res = await fetch(`${apiBase}/projects/${encodeURIComponent(state.project)}/share`, { method: \"POST\", headers: { \"content-type\": \"application/json\", ...(token ? { authorization: `Bearer ${token}` } : {}) }, body: JSON.stringify(body) });\n const r = await res.json(); if (!res.ok) throw new Error(r.error || res.status);\n const u = r.url.startsWith(\"http\") ? r.url : location.origin + r.url;\n $(\"#shResult\").innerHTML = `Link: <a href=\"${esc(u)}\" target=\"_blank\">${esc(u)}</a> <button id=\"shCopy\">copy</button><br>Report: <a href=\"${esc(u)}/report\" target=\"_blank\">${esc(u)}/report</a>${r.share.expires_at ? `<br>expires ${esc(new Date(r.share.expires_at).toLocaleString())}` : \"\"}`;\n $(\"#shCopy\").addEventListener(\"click\", () => navigator.clipboard?.writeText(u));\n } catch (e) { $(\"#shResult\").textContent = \"Failed: \" + e.message; }\n });\n $(\"#toggleConn\").addEventListener(\"click\", () => $(\"#conn\").classList.toggle(\"open\"));\n $(\"#connect\").addEventListener(\"click\", () => { apiBase = $(\"#apiUrl\").value.trim().replace(/\\/$/, \"\"); token = $(\"#apiTok\").value.trim(); state.offline = false; boot(); });\n $(\"#loadJson\").addEventListener(\"click\", () => $(\"#file\").click());\n $(\"#file\").addEventListener(\"change\", async (e) => {\n const f = e.target.files[0]; if (!f) return;\n const data = JSON.parse(await f.text());\n const events = Array.isArray(data) ? data : data.events ?? [];\n state.offline = true; state.events = events.sort((a, b) => a.seq - b.seq); state.verify = null; state.status = null;\n state.projects = [...new Set(events.map((x) => x.project))]; state.project = state.projects[0] ?? null;\n $(\"#project\").innerHTML = state.projects.map((p) => `<option>${esc(p)}</option>`).join(\"\");\n render();\n });\n function showErr(err) { $(\"#timeline\").innerHTML = `<div class=\"empty\">Could not load: ${esc(err.message)}<br><br>Open ⚙ to set the API URL / token, or load a JSON export.</div>`; $(\"#conn\").classList.add(\"open\"); }\n async function boot() { try { await loadProjects(); await loadEvents(); } catch (err) { showErr(err); } }\n boot();\n setInterval(() => { if (!state.offline && document.visibilityState === \"visible\") loadEvents().catch(() => {}); }, 15000);\n})();\n</script>\n</body>\n</html>\n";
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@retrace-dev/core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Schemas, hash chains, causal status, exports, and renderers for Retrace provenance",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"engines": { "node": ">=20" },
|
|
9
|
+
"license": "Apache-2.0",
|
|
10
|
+
"repository": { "type": "git", "url": "git+https://github.com/jordandru/retrace.git", "directory": "packages/core" },
|
|
11
|
+
"homepage": "https://github.com/jordandru/retrace#readme",
|
|
12
|
+
"bugs": { "url": "https://github.com/jordandru/retrace/issues" },
|
|
13
|
+
"publishConfig": { "access": "public" },
|
|
14
|
+
"exports": {
|
|
15
|
+
".": "./dist/index.js"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "node scripts/embed-ui.mjs && tsc -p tsconfig.json",
|
|
19
|
+
"prepack": "npm run build",
|
|
20
|
+
"test": "node --test dist/*.test.js"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"zod": "^3.23.8"
|
|
24
|
+
},
|
|
25
|
+
"files": ["dist", "!dist/*.test.js", "!dist/*.test.d.ts", "ui", "README.md"]
|
|
26
|
+
}
|