@warlock.js/ai-panoptic 5.2.3 → 5.2.4
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/cjs/index.cjs.map +1 -1
- package/esm/collector/collector.mjs.map +1 -1
- package/esm/collector/extract-span-attributes.mjs.map +1 -1
- package/esm/collector/report-to-span.mjs.map +1 -1
- package/esm/config/apply-panoptic-config.mjs.map +1 -1
- package/esm/dashboard/dashboard.mjs.map +1 -1
- package/esm/dashboard/parse-query.mjs.map +1 -1
- package/esm/dashboard/serve.mjs.map +1 -1
- package/esm/dashboard/trace-filter.mjs.map +1 -1
- package/esm/dashboard/ui.html.mjs.map +1 -1
- package/esm/exporters/console/format-span-io.mjs.map +1 -1
- package/esm/exporters/file/file-exporter.mjs.map +1 -1
- package/esm/exporters/langfuse/langfuse-exporter.mjs.map +1 -1
- package/esm/exporters/otel/otel-exporter.mjs.map +1 -1
- package/esm/panoptic/panoptic-middleware.mjs.map +1 -1
- package/esm/panoptic/panoptic.mjs.map +1 -1
- package/esm/register.mjs.map +1 -1
- package/esm/store/cache-trace-store.mjs.map +1 -1
- package/esm/store/in-memory-trace-store.mjs.map +1 -1
- package/esm/store/sum-usage.d.mts.map +1 -1
- package/package.json +4 -4
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ui.html.mjs","names":[],"sources":["../../../../../../../ai-panoptic/src/dashboard/ui.html.ts"],"sourcesContent":["import { WARLOCK_LOGO_DATA_URI } from \"./warlock-logo\";\n\n/**\n * Build the single self-contained dashboard HTML page. No external\n * assets, no bundler, no framework — one inlined string that polls the\n * read-only JSON API (`{basePath}api/aggregate`, `{basePath}api/traces`,\n * `{basePath}api/traces/:id`) and renders:\n *\n * - the {@link TraceAggregate} headline counts (traces / completed /\n * failed / cancelled / tokens in·out·total / cost);\n * - a newest-first trace list as clickable master rows, each led by a\n * colour-coded, title-cased type label (Supervisor / Agent / Tool / …);\n * - a two-pane drawer: a collapsible call tree on the left (the nested\n * span hierarchy) and the selected node's detail on the right — rich\n * input/output, token breakdown, and a metadata panel (session id,\n * ids, version, attributes).\n *\n * Span input/output is rendered structurally (chat bubbles / key-value /\n * Markdown), durations in seconds, tokens as ↓input · ↑output · total.\n * Theme is light / dark / system (persisted to localStorage). All UI\n * state — selected trace, selected span, collapsed nodes — lives in JS,\n * NOT the DOM, so the 2s poll never disturbs an open drawer.\n *\n * The list is filterable entirely client-side over the polled traces: a\n * free-text search (name + session), status / type / session filter\n * chips, an \"errors only\" header toggle, and an optional group-by-session\n * view with collapsible headers. Each tree node and trace row carries a\n * cost heatmap accent scaled to the trace's most expensive node, with a\n * small legend. The drawer's left pane toggles between the nested call\n * TREE and a Gantt TIMELINE (span offset from root start + duration,\n * critical path highlighted). The selected trace and span are reflected\n * in the URL hash (`#trace=&span=`) and re-opened from it on load — so a\n * drawer view is shareable/bookmarkable. A live socket tail is a noted\n * follow-up; this pass stays on the 2s poll.\n *\n * `basePath` and `title` are baked in at serve time. The page is\n * intentionally dependency-free vanilla JS so it works offline.\n *\n * @param basePath Normalized mount path ending in `/` (e.g. `\"/\"`).\n * @param title Header title shown in the page.\n */\nexport function dashboardHtml(\n basePath: string,\n title: string,\n evaluateEnabled: boolean = false,\n evaluateDefaultInstructions: string = \"\",\n): string {\n const apiBase = `${basePath}api`;\n const safeTitle = escapeHtml(title);\n\n return `<!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>${safeTitle}</title>\n<script>\n(function () {\n try {\n var m = localStorage.getItem(\"panoptic-theme\") || \"system\";\n var light = m === \"light\" || (m === \"system\" && window.matchMedia && window.matchMedia(\"(prefers-color-scheme: light)\").matches);\n document.documentElement.setAttribute(\"data-theme\", light ? \"light\" : \"dark\");\n } catch (e) {}\n})();\n</script>\n<style>\n :root {\n color-scheme: dark;\n --bg: #0b0d10; --surface: #101317; --surface2: #14171c; --panel: #0d1014;\n --border: #23272e; --border2: #2d333b;\n --text: #e6e8eb; --text2: #adbac7; --dim: #8b949e;\n --sel-bg: #0f1722; --sel-border: #316dca;\n --ty-agent: #539bf5; --ty-tool: #c297ff; --ty-model: #4cc2b0; --ty-prim: #e3b341; --ty-other: #adbac7;\n --tok-in: #58a6ff; --tok-out: #3fb950; --tok-total: #b899ff; --cost: #e3b341;\n --ok: #56d364; --ok-bg: #0f2e1d; --fail: #f85149; --fail-bg: #3a1416; --cancel: #d29922; --cancel-bg: #332701; --other: #8b949e; --other-bg: #1c2128;\n --code-bg: #0b0d10; --inline-bg: #1c2128; --link: #539bf5;\n }\n :root[data-theme=\"light\"] {\n color-scheme: light;\n --bg: #ffffff; --surface: #f6f8fa; --surface2: #eef1f4; --panel: #ffffff;\n --border: #d0d7de; --border2: #afb8c1;\n --text: #1f2328; --text2: #3b4350; --dim: #636c76;\n --sel-bg: #ddf4ff; --sel-border: #0969da;\n --ty-agent: #0969da; --ty-tool: #8250df; --ty-model: #0f7d6b; --ty-prim: #9a6700; --ty-other: #57606a;\n --tok-in: #0969da; --tok-out: #1a7f37; --tok-total: #8250df; --cost: #9a6700;\n --ok: #1a7f37; --ok-bg: #dafbe1; --fail: #cf222e; --fail-bg: #ffebe9; --cancel: #9a6700; --cancel-bg: #fff8c5; --other: #57606a; --other-bg: #eaeef2;\n --code-bg: #f6f8fa; --inline-bg: #eaeef2; --link: #0969da;\n }\n * { box-sizing: border-box; }\n body { margin: 0; font: 14px/1.5 ui-sans-serif, system-ui, sans-serif; background: var(--bg); color: var(--text); }\n header { padding: 14px 20px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 12px; }\n header img.logo { height: 26px; width: 26px; display: block; flex: none; }\n header h1 { font-size: 16px; margin: 0; font-weight: 600; }\n header .meta { color: var(--dim); font-size: 12px; }\n .theme { margin-left: auto; display: flex; gap: 2px; border: 1px solid var(--border); border-radius: 8px; padding: 2px; }\n .theme button { background: transparent; border: none; color: var(--dim); cursor: pointer; font-size: 14px; line-height: 1; padding: 4px 8px; border-radius: 6px; }\n .theme button:hover { color: var(--text); }\n .theme button.active { background: var(--surface2); color: var(--text); }\n .stats { display: flex; flex-wrap: wrap; gap: 10px; padding: 14px 20px; border-bottom: 1px solid var(--border); }\n .stat { background: var(--surface2); border: 1px solid var(--border); border-radius: 8px; padding: 8px 12px; min-width: 84px; }\n .stat .label { color: var(--dim); font-size: 11px; text-transform: uppercase; letter-spacing: .04em; }\n .stat .value { font-size: 18px; font-weight: 600; }\n main { padding: 12px 20px 40px; }\n\n .trace-row { border: 1px solid var(--border); border-radius: 8px; margin-bottom: 8px; background: var(--surface); padding: 9px 12px; display: flex; align-items: center; gap: 9px; cursor: pointer; transition: background .12s ease, border-color .12s ease; }\n .trace-row:hover { background: var(--surface2); border-color: var(--border2); }\n .trace-row.selected { border-color: var(--sel-border); background: var(--sel-bg); }\n .rname { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n .rprompt { font-size: 11px; color: var(--ty-agent); border: 1px solid var(--border2); border-radius: 6px; padding: 1px 6px; white-space: nowrap; }\n .rright { margin-left: auto; display: flex; align-items: center; gap: 6px; white-space: nowrap; }\n .chev { color: var(--dim); font-size: 16px; font-style: normal; }\n\n .tylabel { font-weight: 600; flex: none; }\n .ty-agent { color: var(--ty-agent); } .ty-tool { color: var(--ty-tool); } .ty-model { color: var(--ty-model); } .ty-prim { color: var(--ty-prim); } .ty-other { color: var(--ty-other); }\n .badge { font-size: 11px; padding: 2px 8px; border-radius: 999px; font-weight: 600; flex: none; }\n .badge.completed { background: var(--ok-bg); color: var(--ok); }\n .badge.failed { background: var(--fail-bg); color: var(--fail); }\n .badge.cancelled { background: var(--cancel-bg); color: var(--cancel); }\n .badge.other { background: var(--other-bg); color: var(--other); }\n .sdot { width: 7px; height: 7px; border-radius: 999px; display: inline-block; flex: none; }\n .sdot-completed { background: var(--ok); } .sdot-failed { background: var(--fail); } .sdot-cancelled { background: var(--cancel); } .sdot-other { background: var(--other); }\n .dim { color: var(--dim); font-size: 12px; }\n .tok { white-space: nowrap; font-size: 12px; }\n .tok-in { color: var(--tok-in); } .tok-out { color: var(--tok-out); } .tok-total { color: var(--tok-total); }\n .cost { color: var(--cost); font-weight: 600; font-size: 12px; white-space: nowrap; }\n\n .io-body { margin-bottom: 6px; }\n .piol { font-size: 11px; text-transform: uppercase; letter-spacing: .05em; margin: 11px 0 3px; }\n .messages { display: flex; flex-direction: column; gap: 8px; }\n .msg-collapse { border: 1px solid var(--border); border-radius: 8px; background: var(--surface); }\n .msg-collapse > summary { cursor: pointer; padding: 7px 10px; list-style: none; font-size: 12px; display: flex; align-items: center; gap: 6px; }\n .msg-collapse > summary::-webkit-details-marker { display: none; }\n .msg-collapse > summary::before { content: \"\\\\25B8\"; color: var(--dim); }\n .msg-collapse[open] > summary::before { content: \"\\\\25BE\"; }\n .msg-collapse[open] > summary { border-bottom: 1px solid var(--border); }\n .msg-collapse .messages { padding: 8px; }\n .msg-collapse .msg-count { font-weight: 600; color: var(--text2); }\n .msg { border: 1px solid var(--border); border-radius: 8px; overflow: hidden; background: var(--surface); }\n .msg-role { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .05em; padding: 4px 10px; color: var(--text2); background: var(--surface2); border-bottom: 1px solid var(--border); }\n .msg-role.role-system { color: var(--cancel); } .msg-role.role-user { color: var(--ty-agent); } .msg-role.role-assistant { color: var(--ok); } .msg-role.role-tool { color: var(--ty-tool); }\n .msg-content { padding: 8px 10px; }\n .kv { display: grid; grid-template-columns: max-content 1fr; gap: 2px 12px; align-items: start; }\n .kv-row { display: contents; }\n .kv-k { color: var(--dim); font-family: ui-monospace, monospace; font-size: 12px; padding: 2px 0; white-space: nowrap; }\n .kv-v { font-size: 13px; min-width: 0; overflow: auto; padding: 1px 0; word-break: break-word; }\n .part { border-left: 2px solid var(--border2); padding-left: 8px; margin: 4px 0; }\n .part-label { font-size: 11px; text-transform: uppercase; color: var(--dim); margin-bottom: 2px; }\n .md-h { font-weight: 700; margin: 8px 0 4px; }\n .md-h1 { font-size: 16px; } .md-h2 { font-size: 14px; } .md-h3 { font-size: 13px; color: var(--text2); } .md-h4 { font-size: 12px; color: var(--dim); }\n .md-p { margin: 4px 0; white-space: pre-wrap; word-break: break-word; }\n .io-body ul, .msg-content ul { margin: 4px 0; padding-left: 18px; }\n pre.code { background: var(--code-bg); border: 1px solid var(--border); border-radius: 6px; padding: 8px; overflow: auto; max-height: 320px; font-size: 12px; white-space: pre; margin: 6px 0; }\n pre.mini { background: var(--code-bg); border: 1px solid var(--border); border-radius: 6px; padding: 6px 8px; overflow: auto; max-height: 200px; font-size: 12px; margin: 0; }\n code { background: var(--inline-bg); border-radius: 4px; padding: 1px 4px; font-family: ui-monospace, monospace; font-size: 12px; }\n a { color: var(--link); }\n .empty { color: var(--dim); padding: 30px; text-align: center; }\n\n .backdrop { position: fixed; inset: 0; background: rgba(0,0,0,.5); opacity: 0; pointer-events: none; transition: opacity .18s ease; z-index: 40; }\n .backdrop.open { opacity: 1; pointer-events: auto; }\n .drawer { position: fixed; top: 0; right: 0; bottom: 0; width: min(760px, 96vw); background: var(--panel); border-left: 1px solid var(--border); transform: translateX(100%); transition: transform .18s ease; z-index: 50; display: flex; flex-direction: column; box-shadow: -16px 0 40px rgba(0,0,0,.4); }\n .drawer.open { transform: translateX(0); }\n .drawer-head { padding: 12px 14px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 9px; flex-wrap: wrap; }\n .drawer-close { margin-left: auto; background: transparent; border: 1px solid var(--border); color: var(--dim); border-radius: 6px; cursor: pointer; font-size: 13px; line-height: 1; padding: 5px 9px; }\n .drawer-close:hover { color: var(--text); border-color: var(--border2); }\n .drawer-body { flex: 1; overflow: hidden; }\n .dsplit { display: flex; height: 100%; }\n .dtree { flex: 0 0 44%; overflow: auto; padding: 8px 6px; border-right: 1px solid var(--border); }\n .ddetail { flex: 1; overflow: auto; padding: 10px 14px; min-width: 0; }\n .tnode { display: flex; align-items: center; gap: 7px; padding: 5px 7px; border-radius: 6px; cursor: pointer; font-size: 13px; border: 1px solid transparent; }\n .tnode:hover { background: var(--surface); }\n .tnode.selected { background: var(--sel-bg); border-color: var(--sel-border); }\n .tname { color: var(--text2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n .tmeta { margin-left: auto; color: var(--dim); font-size: 12px; white-space: nowrap; }\n .twisty { color: var(--dim); font-size: 11px; width: 12px; text-align: center; flex: none; }\n .tkids { margin-left: 10px; padding-left: 9px; border-left: 1px solid var(--border); }\n .crumb { font-size: 12px; color: var(--dim); margin-bottom: 6px; word-break: break-all; }\n .crumb .sep { color: var(--border2); }\n .dhead-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 4px; }\n .meta-sec { margin-top: 14px; border-top: 1px solid var(--border); padding-top: 8px; }\n .meta-title { font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: var(--dim); margin-bottom: 6px; }\n\n /* Toolbar: search box + filter chips over the trace list. */\n .toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 10px 20px; border-bottom: 1px solid var(--border); }\n .search { flex: 1 1 220px; min-width: 160px; background: var(--surface2); border: 1px solid var(--border); color: var(--text); border-radius: 8px; padding: 7px 10px; font: inherit; }\n .search:focus { outline: none; border-color: var(--sel-border); }\n .chips { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }\n .chip { background: var(--surface2); border: 1px solid var(--border); color: var(--text2); border-radius: 999px; padding: 4px 11px; font-size: 12px; font-weight: 600; cursor: pointer; transition: background .12s ease, border-color .12s ease, color .12s ease; }\n .chip:hover { color: var(--text); border-color: var(--border2); }\n .chip.active { background: var(--sel-bg); border-color: var(--sel-border); color: var(--text); }\n .chip-clear { color: var(--dim); border-style: dashed; }\n .chip-group { display: inline-flex; gap: 6px; align-items: center; }\n .chip-group .gl { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: var(--dim); }\n .toggle { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; font-size: 12px; color: var(--text2); user-select: none; }\n .toggle input { accent-color: var(--sel-border); }\n .toggle select { background: var(--surface2); color: var(--text); border: 1px solid var(--border); border-radius: 6px; padding: 2px 6px; font-size: 12px; cursor: pointer; }\n .toggle select:hover { border-color: var(--border2); }\n\n /* Session grouping headers. */\n .sgroup { margin-bottom: 10px; }\n .sgroup-head { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-radius: 8px; background: var(--surface2); border: 1px solid var(--border); cursor: pointer; margin-bottom: 6px; }\n .sgroup-head:hover { border-color: var(--border2); }\n .sgroup-tw { color: var(--dim); font-size: 11px; width: 12px; text-align: center; flex: none; }\n .sgroup-id { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n .sgroup-count { margin-left: auto; color: var(--dim); font-size: 12px; }\n .sgroup-body { padding-left: 6px; }\n\n /* Per-type aggregate stats panel (a CSS-grid table above the trace list). */\n .stats-table { margin: 0 0 14px; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; font-size: 12px; }\n .strow { display: grid; grid-template-columns: 1.5fr 0.7fr 1fr 0.8fr 0.8fr 1fr 1fr; gap: 10px; align-items: center; padding: 6px 12px; border-bottom: 1px solid var(--border); }\n .strow:last-child { border-bottom: none; }\n .strow.sthead { background: var(--surface2); color: var(--dim); font-weight: 600; }\n .strow > span { text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n .strow > .stc-type { text-align: left; }\n .stc-fail.fail { color: var(--fail); }\n\n /* Cost heatmap: a left accent bar tinted by relative rollup cost. */\n .trace-row { position: relative; }\n .tnode { position: relative; }\n .heat { position: absolute; left: 0; top: 3px; bottom: 3px; width: 3px; border-radius: 2px; background: var(--cost); }\n\n /* Timeline / waterfall view in the drawer. */\n .dview { display: flex; gap: 4px; padding: 6px 8px; border-bottom: 1px solid var(--border); }\n .dview button { background: transparent; border: 1px solid var(--border); color: var(--dim); cursor: pointer; font-size: 12px; padding: 4px 10px; border-radius: 6px; }\n .dview button:hover { color: var(--text); }\n .dview button.active { background: var(--surface2); color: var(--text); border-color: var(--border2); }\n .legend { margin-left: auto; display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--dim); }\n .legend .grad { width: 64px; height: 8px; border-radius: 4px; background: linear-gradient(90deg, var(--surface2), var(--cost)); border: 1px solid var(--border); }\n .gantt { padding: 8px 10px; }\n\n /* Evaluate — the drawer's one write action (config-gated). */\n .eval-btn { background: transparent; border: 1px solid var(--border); color: var(--text2); cursor: pointer; font-size: 12px; padding: 4px 10px; border-radius: 6px; margin: 10px 0 0; }\n .eval-btn:hover:not(:disabled) { color: var(--text); border-color: var(--border2); }\n .eval-btn:disabled { opacity: .6; cursor: default; }\n .eval-panel { margin-top: 8px; padding: 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface2); }\n .eval-panel textarea { width: 100%; min-height: 64px; background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 6px; padding: 8px; font: 12px/1.5 ui-sans-serif, system-ui, sans-serif; resize: vertical; }\n .eval-actions { display: flex; align-items: center; gap: 8px; margin-top: 8px; }\n .eval-error { color: var(--fail); font-size: 12px; }\n .eval-result { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border); font-size: 12px; }\n .eval-score { font-weight: 600; }\n .eval-issues { margin: 6px 0 0; padding-left: 18px; }\n .grow { display: flex; align-items: center; gap: 8px; padding: 2px 0; font-size: 12px; cursor: pointer; border-radius: 4px; }\n .grow:hover { background: var(--surface); }\n .grow.selected { background: var(--sel-bg); }\n .glabel { flex: 0 0 38%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text2); }\n .gtrack { position: relative; flex: 1; height: 14px; background: var(--surface2); border-radius: 4px; overflow: hidden; }\n .gbar { position: absolute; top: 2px; bottom: 2px; border-radius: 3px; background: var(--ty-agent); min-width: 2px; }\n .gbar.crit { background: var(--fail); }\n .gbar.bar-completed { background: var(--ty-agent); } .gbar.bar-failed { background: var(--fail); } .gbar.bar-cancelled { background: var(--cancel); } .gbar.bar-other { background: var(--other); }\n .gdur { flex: 0 0 auto; color: var(--dim); white-space: nowrap; min-width: 44px; text-align: right; }\n .gantt-legend { display: flex; gap: 14px; margin-top: 8px; font-size: 11px; color: var(--dim); }\n .gantt-legend .ck { display: inline-flex; align-items: center; gap: 5px; }\n .gantt-legend .sw { width: 12px; height: 8px; border-radius: 2px; display: inline-block; }\n\n @media (max-width: 560px) {\n .dsplit { flex-direction: column; }\n .dtree { flex: none; max-height: 42vh; border-right: none; border-bottom: 1px solid var(--border); }\n .glabel { flex-basis: 30%; }\n }\n</style>\n</head>\n<body>\n<header>\n <img class=\"logo\" src=\"${WARLOCK_LOGO_DATA_URI}\" alt=\"Warlock\" />\n <h1>${safeTitle}</h1>\n <span class=\"meta\" id=\"meta\">connecting…</span>\n <div class=\"theme\" id=\"theme\" role=\"group\" aria-label=\"Theme\" style=\"margin-left:auto\">\n <button type=\"button\" data-theme-set=\"light\" title=\"Light\" aria-label=\"Light theme\">☀</button>\n <button type=\"button\" data-theme-set=\"dark\" title=\"Dark\" aria-label=\"Dark theme\">☾</button>\n <button type=\"button\" data-theme-set=\"system\" title=\"System\" aria-label=\"System theme\">◐</button>\n </div>\n</header>\n<div class=\"stats\" id=\"stats\"></div>\n<div class=\"toolbar\" id=\"toolbar\">\n <input class=\"search\" id=\"search\" type=\"search\" placeholder=\"Search name or session…\" aria-label=\"Search traces\" autocomplete=\"off\" />\n <div class=\"chips\" id=\"status-chips\" role=\"group\" aria-label=\"Filter by status\"></div>\n <div class=\"chips\" id=\"type-chips\" role=\"group\" aria-label=\"Filter by type\"></div>\n <div class=\"chips\" id=\"session-chips\" role=\"group\" aria-label=\"Filter by session\"></div>\n <div class=\"chips\" id=\"prompt-chips\" role=\"group\" aria-label=\"Filter by prompt version\"></div>\n <label class=\"toggle\" id=\"group-wrap\" title=\"Group the trace list (mutually exclusive)\">\n Group\n <select id=\"group-by\" aria-label=\"Group the trace list\">\n <option value=\"\">None</option>\n <option value=\"session\">Session</option>\n <option value=\"prompt\">Prompt</option>\n <option value=\"type\">Type</option>\n </select>\n </label>\n <label class=\"toggle\" id=\"stats-wrap\" title=\"Show a per-type aggregate stats panel (count, failure rate, p50/p95 latency, tokens, cost)\">\n <input type=\"checkbox\" id=\"show-stats\" /> Stats\n </label>\n <button class=\"chip chip-clear\" id=\"clear-filters\" type=\"button\" title=\"Clear all filters\">Clear</button>\n</div>\n<div id=\"stats-panel\"></div>\n<main id=\"traces\"><div class=\"empty\">Loading…</div></main>\n\n<div class=\"backdrop\" id=\"backdrop\"></div>\n<aside class=\"drawer\" id=\"drawer\" aria-hidden=\"true\" aria-label=\"Trace detail\">\n <div class=\"drawer-head\" id=\"drawer-head\"></div>\n <div class=\"drawer-body\" id=\"drawer-body\"></div>\n</aside>\n\n<script>\n(function () {\n var API = ${JSON.stringify(apiBase)};\n var EVALUATE_ENABLED = ${JSON.stringify(evaluateEnabled)};\n var BT = String.fromCharCode(96);\n var EVALUATE_DEFAULT_INSTRUCTIONS = ${encodeForInlineScript(evaluateDefaultInstructions)};\n\n // Carry the ?token= the page itself was loaded with onto every\n // subsequent poll as an Authorization header — otherwise the API calls\n // below inherit no auth and 401 forever once authToken is configured\n // (the initial page load is the only request the URL's query string\n // naturally reaches; the server only honors ?token= on that one route,\n // see serve.ts). The token itself is kept in its own nested closure,\n // not a plain var sitting alongside the rest of this file's top-level\n // state, so it isn't trivially reachable from other code sharing this\n // script's outer scope; only the fetchAuthed function it returns is.\n var fetchAuthed = (function () {\n var TOKEN = new URLSearchParams(window.location.search).get(\"token\");\n return function fetchAuthed(url, options) {\n var opts = options || {};\n var headers = opts.headers || {};\n if (TOKEN) headers = Object.assign({}, headers, { Authorization: \"Bearer \" + TOKEN });\n return fetch(url, Object.assign({}, opts, { headers: headers }));\n };\n })();\n\n var state = {\n traces: [], selectedId: null, selectedSpanId: null, collapsed: {}, sig: null,\n // Client-side filter state (search box + chips + errors-only header toggle).\n filter: { text: \"\", statuses: {}, types: {}, sessionId: null, promptKey: null, errorsOnly: false },\n groupBySession: false, // session-grouping list toggle\n groupByPrompt: false, // prompt-version-grouping list toggle\n groupByType: false, // root-type-grouping list toggle\n showStats: false, // per-type aggregate-stats panel toggle (independent of grouping)\n collapsedGroups: {}, // collapsed group headers (session, prompt, or type)\n view: \"tree\", // drawer left pane: \"tree\" | \"timeline\"\n hashApplied: false, // guards one-time deep-link open on load\n evaluate: {} // per-span evaluate UI state, keyed by spanId\n };\n\n var STATUS_FILTERS = [\"completed\", \"failed\", \"cancelled\"];\n var TYPE_FILTERS = [\"agent\", \"tool\", \"model\", \"supervisor\", \"team\", \"workflow\", \"orchestrator\", \"planner\", \"batch\", \"callback\"];\n var ERROR_STATUSES = { failed: 1, cancelled: 1 };\n var NO_SESSION_KEY = \"(no session)\";\n var NO_PROMPT_KEY = \"(no prompt)\";\n var NO_TYPE_KEY = \"(no type)\";\n\n var statusClass = function (s) {\n if (s === \"completed\" || s === \"failed\" || s === \"cancelled\") return s;\n return \"other\";\n };\n var esc = function (v) {\n return String(v).replace(/[&<>\"']/g, function (c) {\n return { \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" }[c];\n });\n };\n var num = function (n) { return (n == null ? 0 : n).toLocaleString(); };\n var fmt = function (v) {\n if (v == null) return \"\";\n if (typeof v === \"string\") return v;\n try { return JSON.stringify(v, null, 2); } catch (e) { return String(v); }\n };\n\n function dur(ms) {\n if (ms == null) return \"—\";\n if (ms >= 1000) return (ms / 1000).toFixed(1) + \"s\";\n if (ms >= 1) return Math.round(ms) + \"ms\";\n return ms.toFixed(1) + \"ms\";\n }\n\n function tokens(u) {\n if (!u) return \"\";\n var i = u.input || 0, o = u.output || 0, t = (u.total != null) ? u.total : (i + o);\n if (i === 0 && o === 0 && t === 0) return \"\"; // nothing to show — hide the row\n return '<span class=\"tok\">'\n + '<span class=\"tok-in\" title=\"input tokens\">↓ ' + num(i) + \"</span> \"\n + '<span class=\"tok-out\" title=\"output tokens\">↑ ' + num(o) + \"</span> \"\n + '<span class=\"tok-total\" title=\"total tokens\">' + num(t) + \" total</span></span>\";\n }\n\n var TYPE_LABELS = {\n agent: \"Agent\", tool: \"Tool\", model: \"Model\", supervisor: \"Supervisor\",\n workflow: \"Workflow\", orchestrator: \"Orchestrator\", team: \"Team\",\n planner: \"Planner\", prompt: \"Prompt\", guardrail: \"Guardrail\"\n };\n var PRIMITIVES = { supervisor: 1, workflow: 1, orchestrator: 1, team: 1, planner: 1 };\n function typeClass(type) {\n if (PRIMITIVES[type]) return \"ty-prim\";\n if (type === \"agent\") return \"ty-agent\";\n if (type === \"tool\") return \"ty-tool\";\n if (type === \"model\") return \"ty-model\";\n return \"ty-other\";\n }\n function typeLabel(type) {\n var t = type || \"node\";\n var label = TYPE_LABELS[t] || (t.charAt(0).toUpperCase() + t.slice(1));\n return '<span class=\"tylabel ' + typeClass(t) + '\">' + esc(label) + \"</span>\";\n }\n // Plain-text (no span wrapper) capitalized labels for bare-text sites —\n // group headers, filter-chip labels — where the colored typeLabel span is\n // not wanted. Underlying keys/classes/filter values stay raw lowercase.\n function typeText(type) {\n var t = type || \"node\";\n return TYPE_LABELS[t] || (t.charAt(0).toUpperCase() + t.slice(1));\n }\n function statusText(s) {\n return s ? String(s).charAt(0).toUpperCase() + String(s).slice(1) : s;\n }\n function statusDot(s) { return '<span class=\"sdot sdot-' + statusClass(s) + '\" title=\"' + esc(s) + '\"></span>'; }\n\n function findTrace(id) {\n for (var i = 0; i < state.traces.length; i++) if (state.traces[i].traceId === id) return state.traces[i];\n return null;\n }\n function findSpan(span, id) {\n if (span.spanId === id) return span;\n var k = span.children || [];\n for (var i = 0; i < k.length; i++) { var r = findSpan(k[i], id); if (r) return r; }\n return null;\n }\n function findPath(span, id, acc) {\n var p = (acc || []).concat([span]);\n if (span.spanId === id) return p;\n var k = span.children || [];\n for (var i = 0; i < k.length; i++) { var r = findPath(k[i], id, p); if (r) return r; }\n return null;\n }\n function countSpans(span) {\n var n = 1, k = span.children || [];\n for (var i = 0; i < k.length; i++) n += countSpans(k[i]);\n return n;\n }\n function traceSig(t) { return t.root.status + \"|\" + countSpans(t.root) + \"|\" + t.duration + \"|\" + (t.usage && t.usage.total); }\n // Sum every priced lane of a cost object into one USD number.\n function costSumObj(c) {\n if (!c) return 0;\n return (c.input || 0) + (c.output || 0) + (c.cachedInput || 0) + (c.cachedOutput || 0) + (c.reasoning || 0);\n }\n // The cost a single span directly carries (on its rolled-up usage).\n function usageCost(usage) { return usage ? costSumObj(usage.cost) : 0; }\n // Rollup-aware subtree cost: take a node's own cost when it has one\n // (it already rolls up its trips); otherwise sum the children. This\n // avoids double-counting on wrapper nodes (workflow/supervisor roots\n // carry tokens but no cost, so we descend to the priced agent/model).\n function rollupCost(span) {\n var own = usageCost(span.usage);\n if (own > 0) return own;\n var sum = 0;\n (span.children || []).forEach(function (c) { sum += rollupCost(c); });\n return sum;\n }\n function traceCost(t) {\n var explicit = costSumObj(t.cost);\n return explicit > 0 ? explicit : rollupCost(t.root);\n }\n // Format a USD amount; tiny per-node costs need more decimals to read.\n function money(n) {\n if (!n) return \"$0\";\n return \"$\" + (n < 0.01 ? n.toFixed(6) : n.toFixed(4));\n }\n\n // --- Client-side filtering / grouping / heatmap ----------------------\n // These mirror the pure, unit-tested helpers in trace-filter.ts. Keep\n // the two in sync: trace-filter.ts is the spec, this is its inlined twin.\n function anySelected(map) {\n for (var k in map) { if (map[k]) return true; }\n return false;\n }\n function matchesFilter(t) {\n var f = state.filter, root = t.root;\n if (f.errorsOnly && !ERROR_STATUSES[root.status]) return false;\n if (anySelected(f.statuses) && !f.statuses[root.status]) return false;\n if (anySelected(f.types) && !f.types[root.type]) return false;\n if (f.sessionId && t.sessionId !== f.sessionId) return false;\n if (f.promptKey && tracePromptKey(t) !== f.promptKey) return false;\n var text = (f.text || \"\").trim().toLowerCase();\n if (text) {\n var hay = (String(root.name) + \" \" + (t.sessionId || \"\")).toLowerCase();\n if (hay.indexOf(text) === -1) return false;\n }\n return true;\n }\n function filteredTraces() {\n var out = [];\n for (var i = 0; i < state.traces.length; i++) if (matchesFilter(state.traces[i])) out.push(state.traces[i]);\n return out;\n }\n function groupBySession(list) {\n var order = [], byKey = {};\n for (var i = 0; i < list.length; i++) {\n var key = list[i].sessionId || NO_SESSION_KEY;\n if (!byKey[key]) { byKey[key] = []; order.push(key); }\n byKey[key].push(list[i]);\n }\n return order.map(function (k) { return { sessionId: k, traces: byKey[k] }; });\n }\n // Group by prompt version (name@version) — the second group-by dimension\n // beside session. Mirrors groupByPrompt in trace-filter.ts. Unlinked runs\n // bucket under NO_PROMPT_KEY so they stay visible.\n function groupByPrompt(list) {\n var order = [], byKey = {};\n for (var i = 0; i < list.length; i++) {\n var key = tracePromptKey(list[i]) || NO_PROMPT_KEY;\n if (!byKey[key]) { byKey[key] = []; order.push(key); }\n byKey[key].push(list[i]);\n }\n return order.map(function (k) { return { groupKey: k, traces: byKey[k] }; });\n }\n // Group by root type (agent/workflow/supervisor/planner/…) — the coarsest\n // group-by dimension. Mirrors groupByType in trace-filter.ts. root.type is\n // always present, so the NO_TYPE_KEY bucket is only a defensive fallback.\n function typeGroups(list) {\n var order = [], byKey = {};\n for (var i = 0; i < list.length; i++) {\n var key = (list[i].root && list[i].root.type) || NO_TYPE_KEY;\n if (!byKey[key]) { byKey[key] = []; order.push(key); }\n byKey[key].push(list[i]);\n }\n return order.map(function (k) { return { groupKey: k, traces: byKey[k] }; });\n }\n // Inlined twins of percentile + aggregateByType in trace-filter.ts (the\n // spec). Power the per-type stats panel from the same filtered list the\n // trace view renders, so the panel honors active filters with no API call.\n function percentile(values, p) {\n if (!values.length) return 0;\n var sorted = values.slice().sort(function (a, b) { return a - b; });\n var rank = Math.ceil((p / 100) * sorted.length) - 1;\n var index = Math.min(Math.max(rank, 0), sorted.length - 1);\n return sorted[index];\n }\n function aggregateByType(list) {\n var order = [], byKey = {};\n for (var i = 0; i < list.length; i++) {\n var key = (list[i].root && list[i].root.type) || NO_TYPE_KEY;\n if (!byKey[key]) { byKey[key] = []; order.push(key); }\n byKey[key].push(list[i]);\n }\n return order.map(function (k) {\n var bucket = byKey[k];\n var durations = [], failed = 0, tokens = 0, cost = 0;\n for (var j = 0; j < bucket.length; j++) {\n var t = bucket[j];\n durations.push(t.duration);\n if (ERROR_STATUSES[t.root.status]) failed += 1;\n tokens += (t.usage && t.usage.total) || 0;\n cost += traceCost(t);\n }\n return {\n type: k, count: bucket.length, failed: failed,\n failRate: bucket.length ? failed / bucket.length : 0,\n p50: percentile(durations, 50), p95: percentile(durations, 95),\n tokens: tokens, cost: cost\n };\n });\n }\n // Per-type aggregate panel above the trace list. Honors the active filters\n // (computed over filteredTraces) and re-renders on every poll/filter tick.\n function renderStatsPanel() {\n var host = document.getElementById(\"stats-panel\");\n if (!host) return;\n var stats = state.showStats ? aggregateByType(filteredTraces()) : [];\n if (!stats.length) { host.innerHTML = \"\"; return; }\n var head = '<div class=\"strow sthead\">'\n + '<span class=\"stc-type\">Type</span><span>Count</span><span>Failed</span>'\n + '<span>p50</span><span>p95</span><span>Tokens</span><span>Cost</span></div>';\n var rows = stats.map(function (s) {\n var failTxt = s.failed\n ? s.failed + \" (\" + Math.round(s.failRate * 100) + \"%)\"\n : \"0\";\n return '<div class=\"strow\">'\n + '<span class=\"stc-type\">' + typeLabel(s.type) + \"</span>\"\n + '<span>' + num(s.count) + \"</span>\"\n + '<span class=\"stc-fail' + (s.failed ? \" fail\" : \"\") + '\">' + failTxt + \"</span>\"\n + '<span class=\"dim\">' + dur(s.p50) + \"</span>\"\n + '<span class=\"dim\">' + dur(s.p95) + \"</span>\"\n + '<span>' + num(s.tokens) + \"</span>\"\n + '<span class=\"cost\">' + money(s.cost) + \"</span>\"\n + \"</div>\";\n }).join(\"\");\n host.innerHTML = '<div class=\"stats-table\">' + head + rows + \"</div>\";\n }\n // Largest single-node rollup cost in a subtree — heatmap denominator.\n function maxNodeCost(span) {\n var max = rollupCost(span);\n (span.children || []).forEach(function (c) { var m = maxNodeCost(c); if (m > max) max = m; });\n return max;\n }\n // Intensity in [0,1] of a node's cost vs the trace max. Free trace → 0.\n function heatIntensity(nodeCost, maxCost) {\n if (maxCost <= 0 || nodeCost <= 0) return 0;\n var r = nodeCost / maxCost;\n return r > 1 ? 1 : r;\n }\n // The distinct sessionIds present across the polled traces, first-seen\n // order, capped so the chip row never overflows the toolbar.\n function presentSessions() {\n var seen = {}, out = [];\n for (var i = 0; i < state.traces.length && out.length < 12; i++) {\n var s = state.traces[i].sessionId;\n if (s && !seen[s]) { seen[s] = 1; out.push(s); }\n }\n return out;\n }\n // The root types actually present in the polled traces — so the type filter\n // chips show only what exists (no dead \"Tool\"/\"Model\"/\"Batch\"/… chips), in\n // canonical TYPE_FILTERS order. An active-but-aged-out selection stays so\n // the filter is never stranded with no chip to clear it.\n function presentTypes() {\n var seen = {};\n for (var i = 0; i < state.traces.length; i++) {\n var t = state.traces[i].root && state.traces[i].root.type;\n if (t) seen[t] = 1;\n }\n for (var k in state.filter.types) { if (state.filter.types[k]) seen[k] = 1; }\n var out = [];\n for (var j = 0; j < TYPE_FILTERS.length; j++) {\n if (seen[TYPE_FILTERS[j]]) { out.push(TYPE_FILTERS[j]); delete seen[TYPE_FILTERS[j]]; }\n }\n for (var x in seen) { out.push(x); }\n return out;\n }\n // The distinct prompt name@version keys present across the polled traces,\n // first-seen order, capped so the chip row never overflows the toolbar.\n function presentPrompts() {\n var seen = {}, out = [];\n for (var i = 0; i < state.traces.length && out.length < 12; i++) {\n var p = tracePromptKey(state.traces[i]);\n if (p && !seen[p]) { seen[p] = 1; out.push(p); }\n }\n return out;\n }\n\n // (S5) Markdown link URLs come from captured prompt/tool text — attacker-\n // influenced content — so only http:, https:, mailto: and relative/anchor\n // URLs may become an href; javascript:, data:, vbscript:, etc. must not.\n // The check runs on the URL as the browser will act on it: undo the\n // entities esc() introduced (the HTML parser decodes them exactly once in\n // the attribute), strip the control chars / whitespace browsers ignore\n // when parsing a scheme (java\\\\tscript:), and lowercase. Returns the\n // original (still-escaped) URL when safe, or null to drop the link.\n function sanitizeHref(url) {\n var probe = url.replace(/&(amp|lt|gt|quot|#39);/g, function (m, name) {\n return { amp: \"&\", lt: \"<\", gt: \">\", quot: '\"', \"#39\": \"'\" }[name];\n });\n probe = probe.replace(/[\\\\u0000-\\\\u0020\\\\u007f]+/g, \"\").toLowerCase();\n if (/^[a-z][a-z0-9+.-]*:/.test(probe)) {\n return /^(https?|mailto):/.test(probe) ? url : null;\n }\n if (/^[\\\\/\\\\\\\\]{2}/.test(probe)) return null; // scheme-relative smuggles a foreign host\n return url;\n }\n function mdInline(s) {\n s = s.replace(/\\\\*\\\\*([^*]+)\\\\*\\\\*/g, \"<strong>$1</strong>\");\n var codeRe = new RegExp(BT + \"([^\" + BT + \"]+)\" + BT, \"g\");\n s = s.replace(codeRe, \"<code>$1</code>\");\n s = s.replace(/\\\\[([^\\\\]]+)\\\\]\\\\(([^)]+)\\\\)/g, function (m, label, url) {\n var href = sanitizeHref(url);\n if (href === null) return label; // unsafe scheme: label as plain text, no <a>\n return '<a href=\"' + href + '\" target=\"_blank\" rel=\"noopener\">' + label + \"</a>\";\n });\n return s;\n }\n function mdToHtml(raw) {\n var src = esc(String(raw));\n var fence = BT + BT + BT;\n var html = \"\", idx = 0;\n while (true) {\n var start = src.indexOf(fence, idx);\n if (start === -1) { html += mdBlocks(src.slice(idx)); break; }\n html += mdBlocks(src.slice(idx, start));\n var nl = src.indexOf(\"\\\\n\", start + 3);\n var bodyStart = (nl === -1) ? start + 3 : nl + 1;\n var end = src.indexOf(fence, bodyStart);\n if (end === -1) { html += mdBlocks(src.slice(start)); break; }\n html += '<pre class=\"code\">' + src.slice(bodyStart, end).replace(/\\\\n$/, \"\") + \"</pre>\";\n idx = end + 3;\n }\n return html;\n }\n function mdBlocks(src) {\n var lines = src.split(\"\\\\n\"), html = \"\", inList = false;\n function closeList() { if (inList) { html += \"</ul>\"; inList = false; } }\n for (var i = 0; i < lines.length; i++) {\n var ln = lines[i];\n var h = ln.match(/^(#{1,4})\\\\s+(.*)$/);\n if (h) { closeList(); html += '<div class=\"md-h md-h' + h[1].length + '\">' + mdInline(h[2]) + \"</div>\"; continue; }\n var li = ln.match(/^\\\\s*[-*]\\\\s+(.*)$/);\n if (li) { if (!inList) { html += \"<ul>\"; inList = true; } html += \"<li>\" + mdInline(li[1]) + \"</li>\"; continue; }\n if (ln.trim() === \"\") { closeList(); continue; }\n closeList();\n html += '<div class=\"md-p\">' + mdInline(ln) + \"</div>\";\n }\n closeList();\n return html;\n }\n function renderKv(obj) {\n var keys = Object.keys(obj);\n if (!keys.length) return '<span class=\"dim\">{}</span>';\n return '<div class=\"kv\">' + keys.map(function (k) {\n var v = obj[k], vs;\n if (v === null || v === undefined) vs = '<span class=\"dim\">null</span>';\n else if (typeof v === \"object\") vs = '<pre class=\"mini\">' + esc(fmt(v)) + \"</pre>\";\n else vs = esc(String(v));\n return '<div class=\"kv-row\"><span class=\"kv-k\">' + esc(k) + '</span><span class=\"kv-v\">' + vs + \"</span></div>\";\n }).join(\"\") + \"</div>\";\n }\n function isMessageArray(v) {\n return Array.isArray(v) && v.length > 0 && v.every(function (m) { return m && typeof m === \"object\" && typeof m.role === \"string\"; });\n }\n function renderContent(c) {\n if (c == null) return '<span class=\"dim\">—</span>';\n if (typeof c === \"string\") return mdToHtml(c);\n if (Array.isArray(c)) return c.map(renderPart).join(\"\");\n if (typeof c === \"object\") return renderKv(c);\n return esc(String(c));\n }\n function renderPart(p) {\n if (p == null) return \"\";\n if (typeof p === \"string\") return mdToHtml(p);\n if (p.type === \"text\" && typeof p.text === \"string\") return mdToHtml(p.text);\n return '<div class=\"part\"><div class=\"part-label\">' + esc(p.type || \"part\") + \"</div>\" + renderKv(p) + \"</div>\";\n }\n function previewText(c) {\n if (c == null) return \"\";\n if (typeof c === \"string\") return c.replace(/\\\\s+/g, \" \").slice(0, 70);\n if (Array.isArray(c)) {\n for (var i = 0; i < c.length; i++) {\n var p = c[i];\n if (typeof p === \"string\") return p.slice(0, 70);\n if (p && p.type === \"text\" && p.text) return String(p.text).slice(0, 70);\n }\n return \"\";\n }\n try { return JSON.stringify(c).slice(0, 70); } catch (e) { return \"\"; }\n }\n function renderMsg(m) {\n var role = m.role || \"msg\";\n var body = (m.content !== undefined) ? renderContent(m.content) : renderKv(m);\n return '<div class=\"msg\"><div class=\"msg-role role-' + esc(role) + '\">' + esc(role) + '</div><div class=\"msg-content\">' + body + \"</div></div>\";\n }\n function renderMessages(arr) {\n var inner = '<div class=\"messages\">' + arr.map(renderMsg).join(\"\") + \"</div>\";\n // Short threads render inline; a long history (10-15+ messages) collapses\n // behind a <details> so it doesn't blow up the detail pane — the summary\n // shows the count + a preview of the latest message; click to expand.\n if (arr.length <= 6) return inner;\n var last = arr[arr.length - 1] || {};\n var preview = (last.role ? last.role + \": \" : \"\") + previewText(last.content);\n return '<details class=\"msg-collapse\"><summary><span class=\"msg-count\">' + arr.length\n + ' messages</span> <span class=\"dim\">' + esc(preview) + \"</span></summary>\" + inner + \"</details>\";\n }\n function smartValue(v) {\n if (v === null || v === undefined) return '<span class=\"dim\">—</span>';\n if (typeof v === \"string\") {\n var t = v.trim();\n if (t.charAt(0) === \"{\" || t.charAt(0) === \"[\") { try { return smartValue(JSON.parse(t)); } catch (e) {} }\n return mdToHtml(v);\n }\n if (isMessageArray(v)) return renderMessages(v);\n if (Array.isArray(v)) {\n return '<div class=\"kv\">' + v.map(function (item, i) {\n var vs = (item && typeof item === \"object\") ? renderKv(item) : esc(String(item));\n return '<div class=\"kv-row\"><span class=\"kv-k\">' + i + '</span><span class=\"kv-v\">' + vs + \"</span></div>\";\n }).join(\"\") + \"</div>\";\n }\n if (typeof v === \"object\") return renderKv(v);\n return esc(String(v));\n }\n\n function renderStats(a) {\n var cost = a.cost ? (((a.cost.input || 0) + (a.cost.output || 0) + (a.cost.cachedInput || 0) + (a.cost.cachedOutput || 0)).toFixed(4)) : \"—\";\n var u = a.usage || {};\n var cells = [\n [\"traces\", num(a.traces), null],\n [\"completed\", num(a.completed), null],\n [\"failed\", num(a.failed), null],\n [\"cancelled\", num(a.cancelled), null],\n [\"↓ tokens in\", num(u.input), \"var(--tok-in)\"],\n [\"↑ tokens out\", num(u.output), \"var(--tok-out)\"],\n [\"tokens total\", num(u.total), \"var(--tok-total)\"],\n [\"cost (usd)\", cost, null]\n ];\n document.getElementById(\"stats\").innerHTML = cells.map(function (c) {\n var st = c[2] ? ' style=\"color:' + c[2] + '\"' : \"\";\n return '<div class=\"stat\"><div class=\"label\"' + st + \">\" + c[0] + '</div><div class=\"value\"' + st + \">\" + esc(c[1]) + \"</div></div>\";\n }).join(\"\");\n }\n\n // Per-row heat accent: tint the left edge by the trace's own cost\n // relative to the most expensive trace currently in the (filtered) list.\n function heatStyle(intensity) {\n if (intensity <= 0) return \"\";\n var op = (0.12 + intensity * 0.88).toFixed(3);\n return ' style=\"--heat-op:' + op + '\"';\n }\n function heatBar(intensity) {\n if (intensity <= 0) return \"\";\n var op = (0.12 + intensity * 0.88).toFixed(3);\n return '<span class=\"heat\" style=\"opacity:' + op + '\" title=\"relative cost\"></span>';\n }\n function renderRow(t, maxTraceCost) {\n var sel = t.traceId === state.selectedId ? \" selected\" : \"\";\n var intensity = heatIntensity(traceCost(t), maxTraceCost);\n var pk = tracePromptKey(t);\n return '<div class=\"trace-row' + sel + '\" data-id=\"' + esc(t.traceId) + '\">'\n + heatBar(intensity)\n + typeLabel(t.root.type)\n + '<span class=\"badge ' + statusClass(t.root.status) + '\">' + esc(statusText(t.root.status)) + \"</span>\"\n + '<span class=\"rname\">' + esc(t.root.name) + \"</span>\"\n + (pk ? '<span class=\"rprompt\" title=\"prompt version\">' + esc(pk) + \"</span>\" : \"\")\n + (t.sessionId ? '<span class=\"dim\">' + esc(t.sessionId) + \"</span>\" : \"\")\n + '<span class=\"rright\">' + tokens(t.usage) + '<span class=\"dim\">· ' + dur(t.duration) + \"</span></span>\"\n + '<i class=\"chev\">›</i>'\n + \"</div>\";\n }\n function maxTraceCostOf(list) {\n var max = 0;\n for (var i = 0; i < list.length; i++) { var c = traceCost(list[i]); if (c > max) max = c; }\n return max;\n }\n // Render grouped buckets. \"groups\" is the normalized list each group-by\n // dimension produces — a groupKey + its traces. Shared by session, prompt,\n // and type. Optional \"labelFn\" maps the raw groupKey to a display label and\n // switches the header to the compact \"Label (N)\" form (used by type, whose\n // keys are friendly enums); session/prompt omit it and keep their raw id\n // plus the right-aligned \"N trace(s)\" count. data-group stays the RAW key\n // so collapse state keys consistently regardless of the display label.\n function renderGroups(groups, maxTraceCost, labelFn) {\n return groups.map(function (g) {\n var collapsed = !!state.collapsedGroups[g.groupKey];\n var idAndCount = labelFn\n ? '<span class=\"sgroup-id\">' + esc(labelFn(g.groupKey)) + \" (\" + g.traces.length + \")</span>\"\n : '<span class=\"sgroup-id\">' + esc(g.groupKey) + \"</span>\"\n + '<span class=\"sgroup-count\">' + g.traces.length + \" trace(s)</span>\";\n var head = '<div class=\"sgroup-head\" data-group=\"' + esc(g.groupKey) + '\">'\n + '<span class=\"sgroup-tw\">' + (collapsed ? \"▸\" : \"▾\") + \"</span>\"\n + idAndCount + \"</div>\";\n var body = collapsed ? \"\" : '<div class=\"sgroup-body\">'\n + g.traces.map(function (t) { return renderRow(t, maxTraceCost); }).join(\"\") + \"</div>\";\n return '<div class=\"sgroup\">' + head + body + \"</div>\";\n }).join(\"\");\n }\n // Normalize a session group ({sessionId,…}) to the shared {groupKey,…} shape.\n function sessionGroups(list) {\n return groupBySession(list).map(function (g) { return { groupKey: g.sessionId, traces: g.traces }; });\n }\n function renderList() {\n var list = filteredTraces();\n // Keep the per-type stats panel in sync — runs on every poll + filter\n // change, before the empty-state early return below.\n renderStatsPanel();\n var host = document.getElementById(\"traces\");\n if (!list.length) {\n host.innerHTML = state.traces.length\n ? '<div class=\"empty\">No traces match the current filters.</div>'\n : '<div class=\"empty\">No traces yet. Run an observed flow and they will appear here.</div>';\n return;\n }\n var maxTraceCost = maxTraceCostOf(list);\n // One grouping dimension renders at a time. The toggles are kept mutually\n // exclusive in their change handlers, so this precedence chain (most\n // specific → coarsest: prompt → session → type) only ever matches one.\n var html;\n if (state.groupByPrompt) html = renderGroups(groupByPrompt(list), maxTraceCost);\n else if (state.groupBySession) html = renderGroups(sessionGroups(list), maxTraceCost);\n else if (state.groupByType) html = renderGroups(typeGroups(list), maxTraceCost, typeText);\n else html = list.map(function (t) { return renderRow(t, maxTraceCost); }).join(\"\");\n host.innerHTML = html;\n document.getElementById(\"meta\").textContent = list.length + \" of \" + state.traces.length + \" trace(s) · live\";\n }\n\n // --- Filter UI rendering ---------------------------------------------\n function renderChips() {\n var f = state.filter;\n document.getElementById(\"status-chips\").innerHTML =\n '<span class=\"chip-group\"><span class=\"gl\">status</span>'\n + STATUS_FILTERS.map(function (s) {\n return '<button type=\"button\" class=\"chip' + (f.statuses[s] ? \" active\" : \"\") + '\" data-status=\"' + esc(s) + '\">' + esc(statusText(s)) + \"</button>\";\n }).join(\"\")\n + '<button type=\"button\" class=\"chip' + (f.errorsOnly ? \" active\" : \"\") + '\" data-errors=\"1\" title=\"Show only failed / cancelled traces\">Errors only</button>'\n + \"</span>\";\n var types = presentTypes();\n document.getElementById(\"type-chips\").innerHTML = types.length\n ? '<span class=\"chip-group\"><span class=\"gl\">type</span>'\n + types.map(function (ty) {\n return '<button type=\"button\" class=\"chip' + (f.types[ty] ? \" active\" : \"\") + '\" data-type=\"' + esc(ty) + '\">' + esc(typeText(ty)) + \"</button>\";\n }).join(\"\") + \"</span>\"\n : \"\";\n var sessions = presentSessions();\n document.getElementById(\"session-chips\").innerHTML = sessions.length\n ? '<span class=\"chip-group\"><span class=\"gl\">session</span>'\n + sessions.map(function (s) {\n return '<button type=\"button\" class=\"chip' + (f.sessionId === s ? \" active\" : \"\") + '\" data-session=\"' + esc(s) + '\">' + esc(s) + \"</button>\";\n }).join(\"\") + \"</span>\"\n : \"\";\n // Prompt-version filter chips — one per distinct name@version seen.\n // Hidden entirely until a named-prompt run shows up, so the toolbar stays\n // clean for projects that don't use the ai.prompts registry.\n var prompts = presentPrompts();\n document.getElementById(\"prompt-chips\").innerHTML = prompts.length\n ? '<span class=\"chip-group\"><span class=\"gl\">prompt</span>'\n + prompts.map(function (p) {\n return '<button type=\"button\" class=\"chip' + (f.promptKey === p ? \" active\" : \"\") + '\" data-prompt=\"' + esc(p) + '\">' + esc(p) + \"</button>\";\n }).join(\"\") + \"</span>\"\n : \"\";\n }\n\n // Heatmap denominator for the currently rendered trace tree. Set by\n // renderDrawer before each renderTree pass so node accents are scaled\n // against the most expensive node in this trace.\n var currentTreeMax = 0;\n function renderTree(span) {\n var hasKids = span.children && span.children.length;\n var collapsed = !!state.collapsed[span.spanId];\n var sel = span.spanId === state.selectedSpanId ? \" selected\" : \"\";\n var tw = hasKids ? (collapsed ? \"▸\" : \"▾\") : \"·\";\n var node = '<div class=\"tnode' + sel + '\" data-span=\"' + esc(span.spanId) + '\">'\n + heatBar(heatIntensity(rollupCost(span), currentTreeMax))\n + '<span class=\"twisty\"' + (hasKids ? ' data-toggle=\"' + esc(span.spanId) + '\"' : \"\") + \">\" + tw + \"</span>\"\n + typeLabel(span.type)\n + '<span class=\"tname\">' + esc(span.name) + \"</span>\"\n + statusDot(span.status)\n + '<span class=\"tmeta\">' + dur(span.duration) + \"</span>\"\n + \"</div>\";\n var kids = (hasKids && !collapsed) ? '<div class=\"tkids\">' + span.children.map(renderTree).join(\"\") + \"</div>\" : \"\";\n return node + kids;\n }\n\n // --- Timeline / waterfall (Gantt) ------------------------------------\n // Flatten the span tree to a depth-first ordered list, each entry\n // carrying its offset (ms from root start) + duration, so we can lay\n // out concurrency without re-walking. parseTs tolerates a missing/bad\n // startedAt by falling back to 0 so a bad clock never NaNs the bars.\n function parseTs(s) { var n = Date.parse(s); return isNaN(n) ? 0 : n; }\n // Render an ISO timestamp for the detail drawer as a readable, locale-\n // unambiguous local time — \"28 Jun 2026 03:16 PM\" (named month so there is\n // no M/D vs D/M confusion; minute precision). Falls back to the raw string\n // on a bad clock.\n var MONTHS = [\"Jan\", \"Feb\", \"Mar\", \"Apr\", \"May\", \"Jun\", \"Jul\", \"Aug\", \"Sep\", \"Oct\", \"Nov\", \"Dec\"];\n function pad2(n) { return (n < 10 ? \"0\" : \"\") + n; }\n function fmtTs(iso) {\n if (!iso) return \"—\";\n var d = new Date(iso);\n if (isNaN(d.getTime())) return String(iso);\n var h = d.getHours();\n var ampm = h >= 12 ? \"PM\" : \"AM\";\n var h12 = h % 12;\n if (h12 === 0) h12 = 12;\n return d.getDate() + \" \" + MONTHS[d.getMonth()] + \" \" + d.getFullYear()\n + \" \" + pad2(h12) + \":\" + pad2(d.getMinutes()) + \" \" + ampm;\n }\n function flattenSpans(root) {\n var base = parseTs(root.startedAt);\n var rows = [];\n (function walk(span, depth) {\n var start = parseTs(span.startedAt) - base;\n if (start < 0) start = 0;\n var d = (span.duration != null) ? span.duration : 0;\n rows.push({ span: span, depth: depth, offset: start, duration: d, end: start + d });\n (span.children || []).forEach(function (c) { walk(c, depth + 1); });\n })(root, 0);\n return rows;\n }\n // Critical path: from the root, repeatedly step into the child whose\n // end time is latest (the one that pushed the parent's finish). Marks\n // the spans that determine total wall-clock time.\n function criticalPath(root) {\n var crit = {};\n (function walk(span) {\n crit[span.spanId] = 1;\n var kids = span.children || [];\n if (!kids.length) return;\n var pick = null, pe = -1;\n for (var i = 0; i < kids.length; i++) {\n var e = parseTs(kids[i].startedAt) + ((kids[i].duration != null) ? kids[i].duration : 0);\n if (e > pe) { pe = e; pick = kids[i]; }\n }\n if (pick) walk(pick);\n })(root);\n return crit;\n }\n function renderGantt(root) {\n var rows = flattenSpans(root);\n var span0 = rows.length ? rows[0] : null;\n var total = 0;\n rows.forEach(function (r) { if (r.end > total) total = r.end; });\n if (total <= 0) total = (span0 && span0.duration) || 1;\n var crit = criticalPath(root);\n var body = rows.map(function (r) {\n var leftPct = (r.offset / total) * 100;\n var widthPct = Math.max((r.duration / total) * 100, 0.6);\n var sel = r.span.spanId === state.selectedSpanId ? \" selected\" : \"\";\n var isCrit = crit[r.span.spanId] ? \" crit\" : \"\";\n var barClass = \"gbar bar-\" + statusClass(r.span.status) + (isCrit ? \" crit\" : \"\");\n var pad = \"padding-left:\" + (r.depth * 10) + \"px\";\n return '<div class=\"grow' + sel + '\" data-span=\"' + esc(r.span.spanId) + '\">'\n + '<span class=\"glabel\" style=\"' + pad + '\" title=\"' + esc(r.span.name) + '\">' + esc(r.span.name) + \"</span>\"\n + '<span class=\"gtrack\"><span class=\"' + barClass + '\" style=\"left:' + leftPct.toFixed(2) + \"%;width:\" + widthPct.toFixed(2) + '%\"></span></span>'\n + '<span class=\"gdur\">' + dur(r.duration) + \"</span>\"\n + \"</div>\";\n }).join(\"\");\n var legend = '<div class=\"gantt-legend\">'\n + '<span class=\"ck\"><span class=\"sw\" style=\"background:var(--fail)\"></span> critical path</span>'\n + '<span class=\"ck\"><span class=\"sw\" style=\"background:var(--ty-agent)\"></span> span (offset + duration)</span>'\n + \"</div>\";\n return '<div class=\"gantt\">' + (body || '<div class=\"dim\">No spans.</div>') + legend + \"</div>\";\n }\n\n // The name@version of the named prompt this span's run resolved, read\n // from the collector's prompt-version-linkage attributes. Returns null when\n // the run carried no named prompt. Mirrors tracePromptKey in trace-filter.ts.\n function spanPromptKey(span) {\n var a = span && span.attributes;\n if (!a || typeof a !== \"object\") return null;\n var name = a[\"agent.promptName\"];\n if (typeof name !== \"string\" || !name.length) return null;\n var ver = a[\"agent.promptVersion\"];\n var vl = (typeof ver === \"string\" && ver.length) ? ver : \"1\";\n return name + \"@\" + vl;\n }\n function tracePromptKey(t) { return spanPromptKey(t.root); }\n\n // Humanize a metadata key for display: split dot.notation + camelCase,\n // Title-case each word, upcase \"id\". e.g. \"supervisor.terminatedBy\" →\n // \"Supervisor Terminated By\", \"span id\" → \"Span ID\", \"agent.trips\" →\n // \"Agent Trips\". Underlying attribute keys are untouched.\n // NB: this whole script is a template literal — regex backslash classes\n // MUST be double-escaped (\\\\s, not \\s) or \"\\\\s\" collapses to a literal \"s\".\n function humanizeKey(key) {\n return String(key).split(/[.\\\\s]+/).map(function (seg) {\n return seg.replace(/([a-z0-9])([A-Z])/g, \"$1 $2\").split(/\\\\s+/).map(function (w) {\n if (!w) return w;\n if (w.toLowerCase() === \"id\") return \"ID\";\n return w.charAt(0).toUpperCase() + w.slice(1);\n }).join(\" \");\n }).join(\" \");\n }\n\n function renderMeta(span, trace) {\n var rows = [];\n // Absolute wall-clock span — the head shows only the elapsed duration, so\n // surface when this node actually started/ended for log correlation.\n rows.push([\"started\", fmtTs(span.startedAt)]);\n rows.push([\"ended\", fmtTs(span.endedAt)]);\n var sid = span.sessionId || trace.sessionId;\n if (sid) rows.push([\"session\", sid]);\n // Prompt-version linkage: surface the resolved named prompt as one clean\n // name@version row right under session, so the panel reads it as a\n // first-class dimension rather than two raw attribute keys.\n var pk = spanPromptKey(span);\n if (pk) rows.push([\"prompt\", pk]);\n if (span.version) rows.push([\"version\", span.version]);\n rows.push([\"span id\", span.spanId]);\n if (span.parentSpanId) rows.push([\"parent\", span.parentSpanId]);\n rows.push([\"trace id\", span.traceId || trace.traceId]);\n var attrs = span.attributes;\n // Skip the two raw prompt keys — already shown as the clean prompt row.\n if (attrs && typeof attrs === \"object\") Object.keys(attrs).forEach(function (k) {\n if (k === \"agent.promptName\" || k === \"agent.promptVersion\") return;\n rows.push([k, attrs[k]]);\n });\n var kv = rows.map(function (r) {\n var v = r[1];\n var vs = (v && typeof v === \"object\") ? '<pre class=\"mini\">' + esc(fmt(v)) + \"</pre>\" : esc(String(v));\n return '<div class=\"kv-row\"><span class=\"kv-k\">' + esc(humanizeKey(r[0])) + '</span><span class=\"kv-v\">' + vs + \"</span></div>\";\n }).join(\"\");\n return '<div class=\"meta-sec\"><div class=\"meta-title\">metadata</div><div class=\"kv\">' + kv + \"</div></div>\";\n }\n\n // --- Evaluate: grade a span's last captured system prompt -------------\n // Config-gated (EVALUATE_ENABLED) — the drawer's only write action, POSTing\n // to a route that itself only exists when the server was configured with\n // evaluate. UI state lives in state.evaluate, keyed by spanId, so it\n // survives a re-render (e.g. switching to a sibling span and back).\n function extractLastSystemPrompt(span) {\n if (!Array.isArray(span.input)) return null;\n for (var i = span.input.length - 1; i >= 0; i--) {\n var m = span.input[i];\n if (m && typeof m === \"object\" && m.role === \"system\" && typeof m.content === \"string\") return m.content;\n }\n return null;\n }\n function evalState(spanId) {\n return state.evaluate[spanId] || (state.evaluate[spanId] = {\n open: false, instructions: EVALUATE_DEFAULT_INSTRUCTIONS, status: \"idle\", result: null, error: null\n });\n }\n function evalResultHtml(result) {\n var score = typeof result.score === \"number\" ? Math.round(result.score * 100) + \"%\" : \"n/a\";\n var issues = (result.issues || []).map(function (i) { return \"<li>\" + esc(i) + \"</li>\"; }).join(\"\");\n return '<div class=\"eval-result\"><span class=\"eval-score\">Score: ' + score + \"</span>\"\n + (issues ? '<ul class=\"eval-issues\">' + issues + \"</ul>\" : \"\") + \"</div>\";\n }\n function evalSectionHtml(span) {\n if (!EVALUATE_ENABLED) return \"\";\n var sysPrompt = extractLastSystemPrompt(span);\n if (!sysPrompt) return \"\";\n var st = evalState(span.spanId);\n var btn = '<button type=\"button\" class=\"eval-btn\" data-evaluate-toggle=\"' + esc(span.spanId) + '\">'\n + (st.open ? \"Hide evaluate\" : \"Evaluate system prompt\") + \"</button>\";\n if (!st.open) return btn;\n var running = st.status === \"running\";\n return btn + '<div class=\"eval-panel\">'\n + '<textarea id=\"eval-instructions\" placeholder=\"Grading instructions (optional — falls back to the configured default)\"' + (running ? \" disabled\" : \"\") + \">\" + esc(st.instructions || \"\") + \"</textarea>\"\n + '<div class=\"eval-actions\">'\n + '<button type=\"button\" class=\"eval-btn\" data-evaluate-run=\"' + esc(span.spanId) + '\"' + (running ? \" disabled\" : \"\") + \">\" + (running ? \"Evaluating…\" : \"Run\") + \"</button>\"\n + (st.error ? '<span class=\"eval-error\">' + esc(st.error) + \"</span>\" : \"\")\n + \"</div>\"\n + (st.result ? evalResultHtml(st.result) : \"\")\n + \"</div>\";\n }\n function rerenderDetail() {\n var t = findTrace(state.selectedId);\n if (!t) return;\n var sel = findSpan(t.root, state.selectedSpanId) || t.root;\n document.getElementById(\"drawer-detail\").innerHTML = renderDetail(sel, t);\n }\n function toggleEvalPanel(spanId) {\n evalState(spanId).open = !evalState(spanId).open;\n rerenderDetail();\n }\n function runEvaluate(traceId, spanId) {\n var st = evalState(spanId);\n var textarea = document.getElementById(\"eval-instructions\");\n if (textarea) st.instructions = textarea.value;\n st.status = \"running\"; st.error = null;\n rerenderDetail();\n fetchAuthed(API + \"/traces/\" + encodeURIComponent(traceId) + \"/spans/\" + encodeURIComponent(spanId) + \"/evaluate\", {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({ instructions: st.instructions })\n }).then(function (r) {\n return r.json().then(function (data) { return { ok: r.ok, data: data }; });\n }).then(function (res) {\n if (res.ok) { st.status = \"done\"; st.result = res.data; st.error = null; }\n else { st.status = \"error\"; st.error = (res.data && (res.data.message || res.data.error)) || \"evaluate failed\"; st.result = null; }\n rerenderDetail();\n }).catch(function () {\n st.status = \"error\"; st.error = \"network error\"; st.result = null;\n rerenderDetail();\n });\n }\n\n function renderDetail(span, trace) {\n var path = findPath(trace.root, span.spanId) || [span];\n var crumb = path.map(function (p, i) { return (i ? '<span class=\"sep\"> › </span>' : \"\") + \"<span>\" + esc(p.name) + \"</span>\"; }).join(\"\");\n var io = \"\";\n if (span.input !== undefined) io += '<div class=\"piol\" style=\"color:var(--tok-in)\">input</div><div class=\"io-body\">' + smartValue(span.input) + \"</div>\";\n if (span.output !== undefined) io += '<div class=\"piol\" style=\"color:var(--tok-out)\">output</div><div class=\"io-body\">' + smartValue(span.output) + \"</div>\";\n if (span.error) io += '<div class=\"piol\" style=\"color:var(--fail)\">error</div><div class=\"io-body\">' + smartValue(span.error) + \"</div>\";\n\n // Tokens + cost line — omitted entirely when the node has neither\n // (e.g. a free tool with zero usage), so the panel stays uncluttered.\n var tk = tokens(span.usage);\n var c = rollupCost(span);\n var metaLine = (tk || c > 0)\n ? '<div style=\"margin-bottom:4px\">' + tk + (c > 0 ? '<span class=\"cost\">' + (tk ? \" · \" : \"\") + money(c) + \"</span>\" : \"\") + \"</div>\"\n : \"\";\n\n return '<div class=\"crumb\">' + crumb + \"</div>\"\n + '<div class=\"dhead-row\">' + typeLabel(span.type) + '<span style=\"font-weight:600\">' + esc(span.name) + \"</span>\"\n + '<span class=\"badge ' + statusClass(span.status) + '\">' + esc(statusText(span.status)) + \"</span>\"\n + '<span class=\"dim\">· ' + dur(span.duration) + \"</span></div>\"\n + metaLine\n + io\n + evalSectionHtml(span, trace)\n + renderMeta(span, trace);\n }\n\n function headHtml(t) {\n var cost = traceCost(t);\n return typeLabel(t.root.type)\n + '<span class=\"badge ' + statusClass(t.root.status) + '\">' + esc(statusText(t.root.status)) + \"</span>\"\n + '<span style=\"font-weight:600\">' + esc(t.root.name) + \"</span>\"\n + '<span class=\"dim\">· ' + dur(t.duration) + \"</span>\"\n + tokens(t.usage)\n + (cost > 0 ? '<span class=\"cost\">· ' + money(cost) + \"</span>\" : \"\")\n + '<button class=\"drawer-close\" type=\"button\" title=\"Close (Esc)\">✕ Close</button>';\n }\n\n function viewSwitcher() {\n var tree = state.view === \"tree\" ? \" active\" : \"\";\n var tl = state.view === \"timeline\" ? \" active\" : \"\";\n return '<div class=\"dview\" id=\"drawer-view\">'\n + '<button type=\"button\" class=\"' + tree.trim() + '\" data-view=\"tree\">Tree</button>'\n + '<button type=\"button\" class=\"' + tl.trim() + '\" data-view=\"timeline\">Timeline</button>'\n + '<span class=\"legend\" title=\"Node colour = relative cost\"><span>cost</span><span class=\"grad\"></span></span>'\n + \"</div>\";\n }\n function leftPaneHtml(t) {\n if (state.view === \"timeline\") return renderGantt(t.root);\n currentTreeMax = maxNodeCost(t.root);\n return renderTree(t.root);\n }\n function renderDrawer(t) {\n document.getElementById(\"drawer-head\").innerHTML = headHtml(t);\n var sel = findSpan(t.root, state.selectedSpanId) || t.root;\n document.getElementById(\"drawer-body\").innerHTML =\n '<div class=\"dsplit\"><div class=\"dtree\" id=\"drawer-tree\">' + viewSwitcher() + leftPaneHtml(t) + \"</div>\"\n + '<div class=\"ddetail\" id=\"drawer-detail\">' + renderDetail(sel, t) + \"</div></div>\";\n }\n // Re-render only the left pane (after a view switch) without disturbing\n // the detail panel or scroll position of the detail side.\n function renderLeftPane() {\n var t = findTrace(state.selectedId);\n if (!t) return;\n document.getElementById(\"drawer-tree\").innerHTML = viewSwitcher() + leftPaneHtml(t);\n }\n\n function selectSpan(id) {\n state.selectedSpanId = id;\n var t = findTrace(state.selectedId);\n if (!t) return;\n document.getElementById(\"drawer-detail\").innerHTML = renderDetail(findSpan(t.root, id) || t.root, t);\n // Highlight in whichever left pane is active (tree nodes or Gantt rows).\n var nodes = document.querySelectorAll(\".tnode[data-span], .grow[data-span]\");\n for (var i = 0; i < nodes.length; i++) {\n if (nodes[i].getAttribute(\"data-span\") === id) nodes[i].classList.add(\"selected\");\n else nodes[i].classList.remove(\"selected\");\n }\n writeHash();\n }\n function toggleSpan(id) {\n state.collapsed[id] = !state.collapsed[id];\n var t = findTrace(state.selectedId);\n if (t) renderLeftPane();\n }\n function setView(v) {\n if (state.view === v) return;\n state.view = v;\n renderLeftPane();\n }\n\n function openTrace(id, spanId) {\n state.selectedId = id;\n var t = findTrace(id);\n if (t) {\n state.selectedSpanId = (spanId && findSpan(t.root, spanId)) ? spanId : t.root.spanId;\n state.collapsed = {};\n renderDrawer(t);\n state.sig = traceSig(t);\n }\n document.getElementById(\"drawer\").classList.add(\"open\");\n document.getElementById(\"drawer\").setAttribute(\"aria-hidden\", \"false\");\n document.getElementById(\"backdrop\").classList.add(\"open\");\n markSelectedRow();\n writeHash();\n }\n function closeDrawer() {\n state.selectedId = null; state.selectedSpanId = null; state.sig = null;\n document.getElementById(\"drawer\").classList.remove(\"open\");\n document.getElementById(\"drawer\").setAttribute(\"aria-hidden\", \"true\");\n document.getElementById(\"backdrop\").classList.remove(\"open\");\n markSelectedRow();\n writeHash();\n }\n\n // --- Deep-links: reflect the open trace + span in the URL hash -------\n // #trace=<id>&span=<id>. Written on open/close/select; read on load and\n // on manual hash edits (back/forward). A guard flag stops writeHash from\n // re-triggering our own hashchange handler in a loop.\n var suppressHash = false;\n function writeHash() {\n var h = \"\";\n if (state.selectedId) {\n h = \"#trace=\" + encodeURIComponent(state.selectedId);\n if (state.selectedSpanId && state.selectedSpanId !== state.selectedId) {\n h += \"&span=\" + encodeURIComponent(state.selectedSpanId);\n }\n }\n suppressHash = true;\n try {\n if (history && history.replaceState) history.replaceState(null, \"\", h || (location.pathname + location.search));\n else location.hash = h;\n } catch (e) { location.hash = h; }\n suppressHash = false;\n }\n function readHash() {\n var raw = (location.hash || \"\").replace(/^#/, \"\");\n var out = { trace: null, span: null };\n raw.split(\"&\").forEach(function (kv) {\n var i = kv.indexOf(\"=\");\n if (i === -1) return;\n var k = kv.slice(0, i), v = decodeURIComponent(kv.slice(i + 1));\n if (k === \"trace\") out.trace = v;\n else if (k === \"span\") out.span = v;\n });\n return out;\n }\n // Open whatever the hash points at, if that trace is loaded. Returns\n // true when it acted so the caller can mark the one-time load as done.\n function applyHash() {\n var h = readHash();\n if (!h.trace) {\n if (state.selectedId) closeDrawer();\n return true;\n }\n if (!findTrace(h.trace)) return false; // not polled yet — retry next poll\n openTrace(h.trace, h.span || undefined);\n return true;\n }\n function markSelectedRow() {\n var rows = document.querySelectorAll(\".trace-row\");\n for (var i = 0; i < rows.length; i++) {\n if (rows[i].getAttribute(\"data-id\") === state.selectedId) rows[i].classList.add(\"selected\");\n else rows[i].classList.remove(\"selected\");\n }\n }\n\n document.getElementById(\"traces\").addEventListener(\"click\", function (e) {\n var head = e.target.closest ? e.target.closest(\".sgroup-head\") : null;\n if (head) {\n var g = head.getAttribute(\"data-group\");\n state.collapsedGroups[g] = !state.collapsedGroups[g];\n renderList();\n return;\n }\n var row = e.target.closest ? e.target.closest(\".trace-row\") : null;\n if (row) openTrace(row.getAttribute(\"data-id\"));\n });\n document.getElementById(\"drawer-head\").addEventListener(\"click\", function (e) {\n if (e.target.closest && e.target.closest(\".drawer-close\")) closeDrawer();\n });\n document.getElementById(\"drawer-body\").addEventListener(\"click\", function (e) {\n var vb = e.target.closest ? e.target.closest(\"[data-view]\") : null;\n if (vb) { setView(vb.getAttribute(\"data-view\")); return; }\n var tog = e.target.closest ? e.target.closest(\"[data-toggle]\") : null;\n if (tog) { toggleSpan(tog.getAttribute(\"data-toggle\")); return; }\n var evalToggle = e.target.closest ? e.target.closest(\"[data-evaluate-toggle]\") : null;\n if (evalToggle) { toggleEvalPanel(evalToggle.getAttribute(\"data-evaluate-toggle\")); return; }\n var evalRun = e.target.closest ? e.target.closest(\"[data-evaluate-run]\") : null;\n if (evalRun && state.selectedId) { runEvaluate(state.selectedId, evalRun.getAttribute(\"data-evaluate-run\")); return; }\n var node = e.target.closest ? e.target.closest(\".tnode[data-span], .grow[data-span]\") : null;\n if (node) selectSpan(node.getAttribute(\"data-span\"));\n });\n // Track the instructions textarea live (no re-render on keystroke, so\n // typing never loses focus/cursor position).\n document.getElementById(\"drawer-body\").addEventListener(\"input\", function (e) {\n if (e.target && e.target.id === \"eval-instructions\" && state.selectedSpanId) {\n evalState(state.selectedSpanId).instructions = e.target.value;\n }\n });\n document.getElementById(\"backdrop\").addEventListener(\"click\", closeDrawer);\n document.addEventListener(\"keydown\", function (e) { if (e.key === \"Escape\" || e.keyCode === 27) closeDrawer(); });\n\n // --- Filter / toolbar wiring -----------------------------------------\n function toggleMapKey(map, key) { if (map[key]) delete map[key]; else map[key] = 1; }\n document.getElementById(\"status-chips\").addEventListener(\"click\", function (e) {\n if (!e.target.closest) return;\n // The \"Errors only\" shortcut chip lives in the status group now.\n if (e.target.closest(\"[data-errors]\")) {\n state.filter.errorsOnly = !state.filter.errorsOnly;\n renderChips(); renderList();\n return;\n }\n var b = e.target.closest(\"[data-status]\");\n if (!b) return;\n toggleMapKey(state.filter.statuses, b.getAttribute(\"data-status\"));\n renderChips(); renderList();\n });\n document.getElementById(\"type-chips\").addEventListener(\"click\", function (e) {\n var b = e.target.closest ? e.target.closest(\"[data-type]\") : null;\n if (!b) return;\n toggleMapKey(state.filter.types, b.getAttribute(\"data-type\"));\n renderChips(); renderList();\n });\n document.getElementById(\"session-chips\").addEventListener(\"click\", function (e) {\n var b = e.target.closest ? e.target.closest(\"[data-session]\") : null;\n if (!b) return;\n var s = b.getAttribute(\"data-session\");\n state.filter.sessionId = (state.filter.sessionId === s) ? null : s;\n renderChips(); renderList();\n });\n document.getElementById(\"prompt-chips\").addEventListener(\"click\", function (e) {\n var b = e.target.closest ? e.target.closest(\"[data-prompt]\") : null;\n if (!b) return;\n var p = b.getAttribute(\"data-prompt\");\n state.filter.promptKey = (state.filter.promptKey === p) ? null : p;\n renderChips(); renderList();\n });\n document.getElementById(\"search\").addEventListener(\"input\", function (e) {\n state.filter.text = e.target.value || \"\";\n renderList();\n });\n // One mutually-exclusive grouping dimension, chosen from the Group dropdown.\n // \"\" = no grouping; renderList's precedence chain only ever matches one.\n // Switching dimensions drops stale collapsed-header keys.\n function setGrouping(dim) {\n state.groupBySession = dim === \"session\";\n state.groupByPrompt = dim === \"prompt\";\n state.groupByType = dim === \"type\";\n state.collapsedGroups = {};\n renderList();\n }\n document.getElementById(\"group-by\").addEventListener(\"change\", function (e) {\n setGrouping(e.target.value);\n });\n document.getElementById(\"show-stats\").addEventListener(\"change\", function (e) {\n state.showStats = !!e.target.checked;\n renderStatsPanel();\n });\n document.getElementById(\"clear-filters\").addEventListener(\"click\", function () {\n state.filter = { text: \"\", statuses: {}, types: {}, sessionId: null, promptKey: null, errorsOnly: false };\n document.getElementById(\"search\").value = \"\";\n renderChips(); renderList();\n });\n\n // Back/forward or a manual hash edit re-syncs the open trace/span.\n window.addEventListener(\"hashchange\", function () {\n if (suppressHash) return;\n applyHash();\n });\n\n var THEME_KEY = \"panoptic-theme\";\n var mql = window.matchMedia ? window.matchMedia(\"(prefers-color-scheme: light)\") : null;\n function applyTheme(mode) {\n try { localStorage.setItem(THEME_KEY, mode); } catch (e) {}\n var light = mode === \"light\" || (mode === \"system\" && mql && mql.matches);\n document.documentElement.setAttribute(\"data-theme\", light ? \"light\" : \"dark\");\n var btns = document.querySelectorAll(\"[data-theme-set]\");\n for (var i = 0; i < btns.length; i++) btns[i].classList.toggle(\"active\", btns[i].getAttribute(\"data-theme-set\") === mode);\n }\n document.getElementById(\"theme\").addEventListener(\"click\", function (e) {\n var b = e.target.closest ? e.target.closest(\"[data-theme-set]\") : null;\n if (b) applyTheme(b.getAttribute(\"data-theme-set\"));\n });\n if (mql && mql.addEventListener) mql.addEventListener(\"change\", function () {\n var cur = \"system\";\n try { cur = localStorage.getItem(THEME_KEY) || \"system\"; } catch (e) {}\n if (cur === \"system\") applyTheme(\"system\");\n });\n var savedTheme = \"system\";\n try { savedTheme = localStorage.getItem(THEME_KEY) || \"system\"; } catch (e) {}\n applyTheme(savedTheme);\n\n function poll() {\n Promise.all([\n fetchAuthed(API + \"/aggregate\").then(function (r) { return r.json(); }),\n fetchAuthed(API + \"/traces\").then(function (r) { return r.json(); })\n ]).then(function (res) {\n renderStats(res[0]);\n state.traces = res[1] || [];\n document.getElementById(\"meta\").textContent = state.traces.length + \" trace(s) · live\";\n renderChips();\n renderList();\n // Open whatever the URL hash deep-links to, once the target trace\n // has actually arrived in a poll (it may not be in the first batch).\n if (!state.hashApplied) {\n if (applyHash()) state.hashApplied = true;\n } else if (state.selectedId) {\n var t = findTrace(state.selectedId);\n if (t) { var sig = traceSig(t); if (sig !== state.sig) { renderDrawer(t); state.sig = sig; } }\n }\n }).catch(function (e) {\n document.getElementById(\"meta\").textContent = \"disconnected\";\n });\n }\n\n // FOLLOW-UP: a live socket tail (SSE / WebSocket push) is out of scope\n // for this pass; the dashboard stays on the 2s JSON poll below. When\n // added, it should reuse renderList/renderDrawer and keep the poll as a\n // reconnect fallback.\n poll();\n setInterval(poll, 2000);\n})();\n</script>\n</body>\n</html>`;\n}\n\n/** Escape a string for safe interpolation into static HTML text. */\nfunction escapeHtml(value: string): string {\n return value\n .replace(/&/g, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\")\n .replace(/\"/g, \""\")\n .replace(/'/g, \"'\");\n}\n\n/**\n * Encode free-form text (e.g. `evaluate.instructions`, which an operator\n * could type anything into) as a JS EXPRESSION that reconstructs it at\n * runtime, WITHOUT ever emitting a literal backtick into the served page —\n * the client script is itself built from a TS template literal, so a raw\n * backtick in the output would be a real syntax hazard (see the `BT =\n * String.fromCharCode(96)` construction already in the client script for\n * the same reason). `JSON.stringify` alone doesn't escape backticks (they\n * aren't JSON-significant), so a value containing one is split around it\n * and rejoined with the client's own `BT` constant. No backticks in the\n * input ⇒ a single plain `JSON.stringify(value)` — no unnecessary\n * concatenation in the common case.\n */\nfunction encodeForInlineScript(value: string): string {\n return value\n .split(\"`\")\n .map(part => JSON.stringify(part))\n .join(\" + BT + \");\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAyCA,SAAgB,cACd,UACA,OACA,kBAA2B,OAC3B,8BAAsC,IAC9B;CACR,MAAM,UAAU,GAAG,SAAS;CAC5B,MAAM,YAAY,WAAW,KAAK;CAElC,OAAO;;;;;SAKA,UAAU;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;2BA+MQ,sBAAsB;QACzC,UAAU;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cAwCJ,KAAK,UAAU,OAAO,EAAE;2BACX,KAAK,UAAU,eAAe,EAAE;;wCAEnB,sBAAsB,2BAA2B,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA2oC3F;;AAGA,SAAS,WAAW,OAAuB;CACzC,OAAO,MACJ,QAAQ,MAAM,OAAO,EACrB,QAAQ,MAAM,MAAM,EACpB,QAAQ,MAAM,MAAM,EACpB,QAAQ,MAAM,QAAQ,EACtB,QAAQ,MAAM,OAAO;AAC1B;;;;;;;;;;;;;;AAeA,SAAS,sBAAsB,OAAuB;CACpD,OAAO,MACJ,MAAM,GAAG,EACT,KAAI,SAAQ,KAAK,UAAU,IAAI,CAAC,EAChC,KAAK,UAAU;AACpB"}
|
|
1
|
+
{"version":3,"file":"ui.html.mjs","names":[],"sources":["../../../../../../../ai-panoptic/src/dashboard/ui.html.ts"],"sourcesContent":["import { WARLOCK_LOGO_DATA_URI } from \"./warlock-logo\";\n\n/**\n * Build the single self-contained dashboard HTML page. No external\n * assets, no bundler, no framework — one inlined string that polls the\n * read-only JSON API (`{basePath}api/aggregate`, `{basePath}api/traces`,\n * `{basePath}api/traces/:id`) and renders:\n *\n * - the {@link TraceAggregate} headline counts (traces / completed /\n * failed / cancelled / tokens in·out·total / cost);\n * - a newest-first trace list as clickable master rows, each led by a\n * colour-coded, title-cased type label (Supervisor / Agent / Tool / …);\n * - a two-pane drawer: a collapsible call tree on the left (the nested\n * span hierarchy) and the selected node's detail on the right — rich\n * input/output, token breakdown, and a metadata panel (session id,\n * ids, version, attributes).\n *\n * Span input/output is rendered structurally (chat bubbles / key-value /\n * Markdown), durations in seconds, tokens as ↓input · ↑output · total.\n * Theme is light / dark / system (persisted to localStorage). All UI\n * state — selected trace, selected span, collapsed nodes — lives in JS,\n * NOT the DOM, so the 2s poll never disturbs an open drawer.\n *\n * The list is filterable entirely client-side over the polled traces: a\n * free-text search (name + session), status / type / session filter\n * chips, an \"errors only\" header toggle, and an optional group-by-session\n * view with collapsible headers. Each tree node and trace row carries a\n * cost heatmap accent scaled to the trace's most expensive node, with a\n * small legend. The drawer's left pane toggles between the nested call\n * TREE and a Gantt TIMELINE (span offset from root start + duration,\n * critical path highlighted). The selected trace and span are reflected\n * in the URL hash (`#trace=&span=`) and re-opened from it on load — so a\n * drawer view is shareable/bookmarkable. A live socket tail is a noted\n * follow-up; this pass stays on the 2s poll.\n *\n * `basePath` and `title` are baked in at serve time. The page is\n * intentionally dependency-free vanilla JS so it works offline.\n *\n * @param basePath Normalized mount path ending in `/` (e.g. `\"/\"`).\n * @param title Header title shown in the page.\n */\nexport function dashboardHtml(\n basePath: string,\n title: string,\n evaluateEnabled: boolean = false,\n evaluateDefaultInstructions: string = \"\",\n): string {\n const apiBase = `${basePath}api`;\n const safeTitle = escapeHtml(title);\n\n return `<!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>${safeTitle}</title>\n<script>\n(function () {\n try {\n var m = localStorage.getItem(\"panoptic-theme\") || \"system\";\n var light = m === \"light\" || (m === \"system\" && window.matchMedia && window.matchMedia(\"(prefers-color-scheme: light)\").matches);\n document.documentElement.setAttribute(\"data-theme\", light ? \"light\" : \"dark\");\n } catch (e) {}\n})();\n</script>\n<style>\n :root {\n color-scheme: dark;\n --bg: #0b0d10; --surface: #101317; --surface2: #14171c; --panel: #0d1014;\n --border: #23272e; --border2: #2d333b;\n --text: #e6e8eb; --text2: #adbac7; --dim: #8b949e;\n --sel-bg: #0f1722; --sel-border: #316dca;\n --ty-agent: #539bf5; --ty-tool: #c297ff; --ty-model: #4cc2b0; --ty-prim: #e3b341; --ty-other: #adbac7;\n --tok-in: #58a6ff; --tok-out: #3fb950; --tok-total: #b899ff; --cost: #e3b341;\n --ok: #56d364; --ok-bg: #0f2e1d; --fail: #f85149; --fail-bg: #3a1416; --cancel: #d29922; --cancel-bg: #332701; --other: #8b949e; --other-bg: #1c2128;\n --code-bg: #0b0d10; --inline-bg: #1c2128; --link: #539bf5;\n }\n :root[data-theme=\"light\"] {\n color-scheme: light;\n --bg: #ffffff; --surface: #f6f8fa; --surface2: #eef1f4; --panel: #ffffff;\n --border: #d0d7de; --border2: #afb8c1;\n --text: #1f2328; --text2: #3b4350; --dim: #636c76;\n --sel-bg: #ddf4ff; --sel-border: #0969da;\n --ty-agent: #0969da; --ty-tool: #8250df; --ty-model: #0f7d6b; --ty-prim: #9a6700; --ty-other: #57606a;\n --tok-in: #0969da; --tok-out: #1a7f37; --tok-total: #8250df; --cost: #9a6700;\n --ok: #1a7f37; --ok-bg: #dafbe1; --fail: #cf222e; --fail-bg: #ffebe9; --cancel: #9a6700; --cancel-bg: #fff8c5; --other: #57606a; --other-bg: #eaeef2;\n --code-bg: #f6f8fa; --inline-bg: #eaeef2; --link: #0969da;\n }\n * { box-sizing: border-box; }\n body { margin: 0; font: 14px/1.5 ui-sans-serif, system-ui, sans-serif; background: var(--bg); color: var(--text); }\n header { padding: 14px 20px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 12px; }\n header img.logo { height: 26px; width: 26px; display: block; flex: none; }\n header h1 { font-size: 16px; margin: 0; font-weight: 600; }\n header .meta { color: var(--dim); font-size: 12px; }\n .theme { margin-left: auto; display: flex; gap: 2px; border: 1px solid var(--border); border-radius: 8px; padding: 2px; }\n .theme button { background: transparent; border: none; color: var(--dim); cursor: pointer; font-size: 14px; line-height: 1; padding: 4px 8px; border-radius: 6px; }\n .theme button:hover { color: var(--text); }\n .theme button.active { background: var(--surface2); color: var(--text); }\n .stats { display: flex; flex-wrap: wrap; gap: 10px; padding: 14px 20px; border-bottom: 1px solid var(--border); }\n .stat { background: var(--surface2); border: 1px solid var(--border); border-radius: 8px; padding: 8px 12px; min-width: 84px; }\n .stat .label { color: var(--dim); font-size: 11px; text-transform: uppercase; letter-spacing: .04em; }\n .stat .value { font-size: 18px; font-weight: 600; }\n main { padding: 12px 20px 40px; }\n\n .trace-row { border: 1px solid var(--border); border-radius: 8px; margin-bottom: 8px; background: var(--surface); padding: 9px 12px; display: flex; align-items: center; gap: 9px; cursor: pointer; transition: background .12s ease, border-color .12s ease; }\n .trace-row:hover { background: var(--surface2); border-color: var(--border2); }\n .trace-row.selected { border-color: var(--sel-border); background: var(--sel-bg); }\n .rname { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n .rprompt { font-size: 11px; color: var(--ty-agent); border: 1px solid var(--border2); border-radius: 6px; padding: 1px 6px; white-space: nowrap; }\n .rright { margin-left: auto; display: flex; align-items: center; gap: 6px; white-space: nowrap; }\n .chev { color: var(--dim); font-size: 16px; font-style: normal; }\n\n .tylabel { font-weight: 600; flex: none; }\n .ty-agent { color: var(--ty-agent); } .ty-tool { color: var(--ty-tool); } .ty-model { color: var(--ty-model); } .ty-prim { color: var(--ty-prim); } .ty-other { color: var(--ty-other); }\n .badge { font-size: 11px; padding: 2px 8px; border-radius: 999px; font-weight: 600; flex: none; }\n .badge.completed { background: var(--ok-bg); color: var(--ok); }\n .badge.failed { background: var(--fail-bg); color: var(--fail); }\n .badge.cancelled { background: var(--cancel-bg); color: var(--cancel); }\n .badge.other { background: var(--other-bg); color: var(--other); }\n .sdot { width: 7px; height: 7px; border-radius: 999px; display: inline-block; flex: none; }\n .sdot-completed { background: var(--ok); } .sdot-failed { background: var(--fail); } .sdot-cancelled { background: var(--cancel); } .sdot-other { background: var(--other); }\n .dim { color: var(--dim); font-size: 12px; }\n .tok { white-space: nowrap; font-size: 12px; }\n .tok-in { color: var(--tok-in); } .tok-out { color: var(--tok-out); } .tok-total { color: var(--tok-total); }\n .cost { color: var(--cost); font-weight: 600; font-size: 12px; white-space: nowrap; }\n\n .io-body { margin-bottom: 6px; }\n .piol { font-size: 11px; text-transform: uppercase; letter-spacing: .05em; margin: 11px 0 3px; }\n .messages { display: flex; flex-direction: column; gap: 8px; }\n .msg-collapse { border: 1px solid var(--border); border-radius: 8px; background: var(--surface); }\n .msg-collapse > summary { cursor: pointer; padding: 7px 10px; list-style: none; font-size: 12px; display: flex; align-items: center; gap: 6px; }\n .msg-collapse > summary::-webkit-details-marker { display: none; }\n .msg-collapse > summary::before { content: \"\\\\25B8\"; color: var(--dim); }\n .msg-collapse[open] > summary::before { content: \"\\\\25BE\"; }\n .msg-collapse[open] > summary { border-bottom: 1px solid var(--border); }\n .msg-collapse .messages { padding: 8px; }\n .msg-collapse .msg-count { font-weight: 600; color: var(--text2); }\n .msg { border: 1px solid var(--border); border-radius: 8px; overflow: hidden; background: var(--surface); }\n .msg-role { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .05em; padding: 4px 10px; color: var(--text2); background: var(--surface2); border-bottom: 1px solid var(--border); }\n .msg-role.role-system { color: var(--cancel); } .msg-role.role-user { color: var(--ty-agent); } .msg-role.role-assistant { color: var(--ok); } .msg-role.role-tool { color: var(--ty-tool); }\n .msg-content { padding: 8px 10px; }\n .kv { display: grid; grid-template-columns: max-content 1fr; gap: 2px 12px; align-items: start; }\n .kv-row { display: contents; }\n .kv-k { color: var(--dim); font-family: ui-monospace, monospace; font-size: 12px; padding: 2px 0; white-space: nowrap; }\n .kv-v { font-size: 13px; min-width: 0; overflow: auto; padding: 1px 0; word-break: break-word; }\n .part { border-left: 2px solid var(--border2); padding-left: 8px; margin: 4px 0; }\n .part-label { font-size: 11px; text-transform: uppercase; color: var(--dim); margin-bottom: 2px; }\n .md-h { font-weight: 700; margin: 8px 0 4px; }\n .md-h1 { font-size: 16px; } .md-h2 { font-size: 14px; } .md-h3 { font-size: 13px; color: var(--text2); } .md-h4 { font-size: 12px; color: var(--dim); }\n .md-p { margin: 4px 0; white-space: pre-wrap; word-break: break-word; }\n .io-body ul, .msg-content ul { margin: 4px 0; padding-left: 18px; }\n pre.code { background: var(--code-bg); border: 1px solid var(--border); border-radius: 6px; padding: 8px; overflow: auto; max-height: 320px; font-size: 12px; white-space: pre; margin: 6px 0; }\n pre.mini { background: var(--code-bg); border: 1px solid var(--border); border-radius: 6px; padding: 6px 8px; overflow: auto; max-height: 200px; font-size: 12px; margin: 0; }\n code { background: var(--inline-bg); border-radius: 4px; padding: 1px 4px; font-family: ui-monospace, monospace; font-size: 12px; }\n a { color: var(--link); }\n .empty { color: var(--dim); padding: 30px; text-align: center; }\n\n .backdrop { position: fixed; inset: 0; background: rgba(0,0,0,.5); opacity: 0; pointer-events: none; transition: opacity .18s ease; z-index: 40; }\n .backdrop.open { opacity: 1; pointer-events: auto; }\n .drawer { position: fixed; top: 0; right: 0; bottom: 0; width: min(760px, 96vw); background: var(--panel); border-left: 1px solid var(--border); transform: translateX(100%); transition: transform .18s ease; z-index: 50; display: flex; flex-direction: column; box-shadow: -16px 0 40px rgba(0,0,0,.4); }\n .drawer.open { transform: translateX(0); }\n .drawer-head { padding: 12px 14px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 9px; flex-wrap: wrap; }\n .drawer-close { margin-left: auto; background: transparent; border: 1px solid var(--border); color: var(--dim); border-radius: 6px; cursor: pointer; font-size: 13px; line-height: 1; padding: 5px 9px; }\n .drawer-close:hover { color: var(--text); border-color: var(--border2); }\n .drawer-body { flex: 1; overflow: hidden; }\n .dsplit { display: flex; height: 100%; }\n .dtree { flex: 0 0 44%; overflow: auto; padding: 8px 6px; border-right: 1px solid var(--border); }\n .ddetail { flex: 1; overflow: auto; padding: 10px 14px; min-width: 0; }\n .tnode { display: flex; align-items: center; gap: 7px; padding: 5px 7px; border-radius: 6px; cursor: pointer; font-size: 13px; border: 1px solid transparent; }\n .tnode:hover { background: var(--surface); }\n .tnode.selected { background: var(--sel-bg); border-color: var(--sel-border); }\n .tname { color: var(--text2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n .tmeta { margin-left: auto; color: var(--dim); font-size: 12px; white-space: nowrap; }\n .twisty { color: var(--dim); font-size: 11px; width: 12px; text-align: center; flex: none; }\n .tkids { margin-left: 10px; padding-left: 9px; border-left: 1px solid var(--border); }\n .crumb { font-size: 12px; color: var(--dim); margin-bottom: 6px; word-break: break-all; }\n .crumb .sep { color: var(--border2); }\n .dhead-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 4px; }\n .meta-sec { margin-top: 14px; border-top: 1px solid var(--border); padding-top: 8px; }\n .meta-title { font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: var(--dim); margin-bottom: 6px; }\n\n /* Toolbar: search box + filter chips over the trace list. */\n .toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 10px 20px; border-bottom: 1px solid var(--border); }\n .search { flex: 1 1 220px; min-width: 160px; background: var(--surface2); border: 1px solid var(--border); color: var(--text); border-radius: 8px; padding: 7px 10px; font: inherit; }\n .search:focus { outline: none; border-color: var(--sel-border); }\n .chips { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }\n .chip { background: var(--surface2); border: 1px solid var(--border); color: var(--text2); border-radius: 999px; padding: 4px 11px; font-size: 12px; font-weight: 600; cursor: pointer; transition: background .12s ease, border-color .12s ease, color .12s ease; }\n .chip:hover { color: var(--text); border-color: var(--border2); }\n .chip.active { background: var(--sel-bg); border-color: var(--sel-border); color: var(--text); }\n .chip-clear { color: var(--dim); border-style: dashed; }\n .chip-group { display: inline-flex; gap: 6px; align-items: center; }\n .chip-group .gl { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: var(--dim); }\n .toggle { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; font-size: 12px; color: var(--text2); user-select: none; }\n .toggle input { accent-color: var(--sel-border); }\n .toggle select { background: var(--surface2); color: var(--text); border: 1px solid var(--border); border-radius: 6px; padding: 2px 6px; font-size: 12px; cursor: pointer; }\n .toggle select:hover { border-color: var(--border2); }\n\n /* Session grouping headers. */\n .sgroup { margin-bottom: 10px; }\n .sgroup-head { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-radius: 8px; background: var(--surface2); border: 1px solid var(--border); cursor: pointer; margin-bottom: 6px; }\n .sgroup-head:hover { border-color: var(--border2); }\n .sgroup-tw { color: var(--dim); font-size: 11px; width: 12px; text-align: center; flex: none; }\n .sgroup-id { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n .sgroup-count { margin-left: auto; color: var(--dim); font-size: 12px; }\n .sgroup-body { padding-left: 6px; }\n\n /* Per-type aggregate stats panel (a CSS-grid table above the trace list). */\n .stats-table { margin: 0 0 14px; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; font-size: 12px; }\n .strow { display: grid; grid-template-columns: 1.5fr 0.7fr 1fr 0.8fr 0.8fr 1fr 1fr; gap: 10px; align-items: center; padding: 6px 12px; border-bottom: 1px solid var(--border); }\n .strow:last-child { border-bottom: none; }\n .strow.sthead { background: var(--surface2); color: var(--dim); font-weight: 600; }\n .strow > span { text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n .strow > .stc-type { text-align: left; }\n .stc-fail.fail { color: var(--fail); }\n\n /* Cost heatmap: a left accent bar tinted by relative rollup cost. */\n .trace-row { position: relative; }\n .tnode { position: relative; }\n .heat { position: absolute; left: 0; top: 3px; bottom: 3px; width: 3px; border-radius: 2px; background: var(--cost); }\n\n /* Timeline / waterfall view in the drawer. */\n .dview { display: flex; gap: 4px; padding: 6px 8px; border-bottom: 1px solid var(--border); }\n .dview button { background: transparent; border: 1px solid var(--border); color: var(--dim); cursor: pointer; font-size: 12px; padding: 4px 10px; border-radius: 6px; }\n .dview button:hover { color: var(--text); }\n .dview button.active { background: var(--surface2); color: var(--text); border-color: var(--border2); }\n .legend { margin-left: auto; display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--dim); }\n .legend .grad { width: 64px; height: 8px; border-radius: 4px; background: linear-gradient(90deg, var(--surface2), var(--cost)); border: 1px solid var(--border); }\n .gantt { padding: 8px 10px; }\n\n /* Evaluate — the drawer's one write action (config-gated). */\n .eval-btn { background: transparent; border: 1px solid var(--border); color: var(--text2); cursor: pointer; font-size: 12px; padding: 4px 10px; border-radius: 6px; margin: 10px 0 0; }\n .eval-btn:hover:not(:disabled) { color: var(--text); border-color: var(--border2); }\n .eval-btn:disabled { opacity: .6; cursor: default; }\n .eval-panel { margin-top: 8px; padding: 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface2); }\n .eval-panel textarea { width: 100%; min-height: 64px; background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 6px; padding: 8px; font: 12px/1.5 ui-sans-serif, system-ui, sans-serif; resize: vertical; }\n .eval-actions { display: flex; align-items: center; gap: 8px; margin-top: 8px; }\n .eval-error { color: var(--fail); font-size: 12px; }\n .eval-result { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border); font-size: 12px; }\n .eval-score { font-weight: 600; }\n .eval-issues { margin: 6px 0 0; padding-left: 18px; }\n .grow { display: flex; align-items: center; gap: 8px; padding: 2px 0; font-size: 12px; cursor: pointer; border-radius: 4px; }\n .grow:hover { background: var(--surface); }\n .grow.selected { background: var(--sel-bg); }\n .glabel { flex: 0 0 38%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text2); }\n .gtrack { position: relative; flex: 1; height: 14px; background: var(--surface2); border-radius: 4px; overflow: hidden; }\n .gbar { position: absolute; top: 2px; bottom: 2px; border-radius: 3px; background: var(--ty-agent); min-width: 2px; }\n .gbar.crit { background: var(--fail); }\n .gbar.bar-completed { background: var(--ty-agent); } .gbar.bar-failed { background: var(--fail); } .gbar.bar-cancelled { background: var(--cancel); } .gbar.bar-other { background: var(--other); }\n .gdur { flex: 0 0 auto; color: var(--dim); white-space: nowrap; min-width: 44px; text-align: right; }\n .gantt-legend { display: flex; gap: 14px; margin-top: 8px; font-size: 11px; color: var(--dim); }\n .gantt-legend .ck { display: inline-flex; align-items: center; gap: 5px; }\n .gantt-legend .sw { width: 12px; height: 8px; border-radius: 2px; display: inline-block; }\n\n @media (max-width: 560px) {\n .dsplit { flex-direction: column; }\n .dtree { flex: none; max-height: 42vh; border-right: none; border-bottom: 1px solid var(--border); }\n .glabel { flex-basis: 30%; }\n }\n</style>\n</head>\n<body>\n<header>\n <img class=\"logo\" src=\"${WARLOCK_LOGO_DATA_URI}\" alt=\"Warlock\" />\n <h1>${safeTitle}</h1>\n <span class=\"meta\" id=\"meta\">connecting…</span>\n <div class=\"theme\" id=\"theme\" role=\"group\" aria-label=\"Theme\" style=\"margin-left:auto\">\n <button type=\"button\" data-theme-set=\"light\" title=\"Light\" aria-label=\"Light theme\">☀</button>\n <button type=\"button\" data-theme-set=\"dark\" title=\"Dark\" aria-label=\"Dark theme\">☾</button>\n <button type=\"button\" data-theme-set=\"system\" title=\"System\" aria-label=\"System theme\">◐</button>\n </div>\n</header>\n<div class=\"stats\" id=\"stats\"></div>\n<div class=\"toolbar\" id=\"toolbar\">\n <input class=\"search\" id=\"search\" type=\"search\" placeholder=\"Search name or session…\" aria-label=\"Search traces\" autocomplete=\"off\" />\n <div class=\"chips\" id=\"status-chips\" role=\"group\" aria-label=\"Filter by status\"></div>\n <div class=\"chips\" id=\"type-chips\" role=\"group\" aria-label=\"Filter by type\"></div>\n <div class=\"chips\" id=\"session-chips\" role=\"group\" aria-label=\"Filter by session\"></div>\n <div class=\"chips\" id=\"prompt-chips\" role=\"group\" aria-label=\"Filter by prompt version\"></div>\n <label class=\"toggle\" id=\"group-wrap\" title=\"Group the trace list (mutually exclusive)\">\n Group\n <select id=\"group-by\" aria-label=\"Group the trace list\">\n <option value=\"\">None</option>\n <option value=\"session\">Session</option>\n <option value=\"prompt\">Prompt</option>\n <option value=\"type\">Type</option>\n </select>\n </label>\n <label class=\"toggle\" id=\"stats-wrap\" title=\"Show a per-type aggregate stats panel (count, failure rate, p50/p95 latency, tokens, cost)\">\n <input type=\"checkbox\" id=\"show-stats\" /> Stats\n </label>\n <button class=\"chip chip-clear\" id=\"clear-filters\" type=\"button\" title=\"Clear all filters\">Clear</button>\n</div>\n<div id=\"stats-panel\"></div>\n<main id=\"traces\"><div class=\"empty\">Loading…</div></main>\n\n<div class=\"backdrop\" id=\"backdrop\"></div>\n<aside class=\"drawer\" id=\"drawer\" aria-hidden=\"true\" aria-label=\"Trace detail\">\n <div class=\"drawer-head\" id=\"drawer-head\"></div>\n <div class=\"drawer-body\" id=\"drawer-body\"></div>\n</aside>\n\n<script>\n(function () {\n var API = ${JSON.stringify(apiBase)};\n var EVALUATE_ENABLED = ${JSON.stringify(evaluateEnabled)};\n var BT = String.fromCharCode(96);\n var EVALUATE_DEFAULT_INSTRUCTIONS = ${encodeForInlineScript(evaluateDefaultInstructions)};\n\n // Carry the ?token= the page itself was loaded with onto every\n // subsequent poll as an Authorization header — otherwise the API calls\n // below inherit no auth and 401 forever once authToken is configured\n // (the initial page load is the only request the URL's query string\n // naturally reaches; the server only honors ?token= on that one route,\n // see serve.ts). The token itself is kept in its own nested closure,\n // not a plain var sitting alongside the rest of this file's top-level\n // state, so it isn't trivially reachable from other code sharing this\n // script's outer scope; only the fetchAuthed function it returns is.\n var fetchAuthed = (function () {\n var TOKEN = new URLSearchParams(window.location.search).get(\"token\");\n return function fetchAuthed(url, options) {\n var opts = options || {};\n var headers = opts.headers || {};\n if (TOKEN) headers = Object.assign({}, headers, { Authorization: \"Bearer \" + TOKEN });\n return fetch(url, Object.assign({}, opts, { headers: headers }));\n };\n })();\n\n var state = {\n traces: [], selectedId: null, selectedSpanId: null, collapsed: {}, sig: null,\n // Client-side filter state (search box + chips + errors-only header toggle).\n filter: { text: \"\", statuses: {}, types: {}, sessionId: null, promptKey: null, errorsOnly: false },\n groupBySession: false, // session-grouping list toggle\n groupByPrompt: false, // prompt-version-grouping list toggle\n groupByType: false, // root-type-grouping list toggle\n showStats: false, // per-type aggregate-stats panel toggle (independent of grouping)\n collapsedGroups: {}, // collapsed group headers (session, prompt, or type)\n view: \"tree\", // drawer left pane: \"tree\" | \"timeline\"\n hashApplied: false, // guards one-time deep-link open on load\n evaluate: {} // per-span evaluate UI state, keyed by spanId\n };\n\n var STATUS_FILTERS = [\"completed\", \"failed\", \"cancelled\"];\n var TYPE_FILTERS = [\"agent\", \"tool\", \"model\", \"supervisor\", \"team\", \"workflow\", \"orchestrator\", \"planner\", \"batch\", \"callback\"];\n var ERROR_STATUSES = { failed: 1, cancelled: 1 };\n var NO_SESSION_KEY = \"(no session)\";\n var NO_PROMPT_KEY = \"(no prompt)\";\n var NO_TYPE_KEY = \"(no type)\";\n\n var statusClass = function (s) {\n if (s === \"completed\" || s === \"failed\" || s === \"cancelled\") return s;\n return \"other\";\n };\n var esc = function (v) {\n return String(v).replace(/[&<>\"']/g, function (c) {\n return { \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" }[c];\n });\n };\n var num = function (n) { return (n == null ? 0 : n).toLocaleString(); };\n var fmt = function (v) {\n if (v == null) return \"\";\n if (typeof v === \"string\") return v;\n try { return JSON.stringify(v, null, 2); } catch (e) { return String(v); }\n };\n\n function dur(ms) {\n if (ms == null) return \"—\";\n if (ms >= 1000) return (ms / 1000).toFixed(1) + \"s\";\n if (ms >= 1) return Math.round(ms) + \"ms\";\n return ms.toFixed(1) + \"ms\";\n }\n\n function tokens(u) {\n if (!u) return \"\";\n var i = u.input || 0, o = u.output || 0, t = (u.total != null) ? u.total : (i + o);\n if (i === 0 && o === 0 && t === 0) return \"\"; // nothing to show — hide the row\n return '<span class=\"tok\">'\n + '<span class=\"tok-in\" title=\"input tokens\">↓ ' + num(i) + \"</span> \"\n + '<span class=\"tok-out\" title=\"output tokens\">↑ ' + num(o) + \"</span> \"\n + '<span class=\"tok-total\" title=\"total tokens\">' + num(t) + \" total</span></span>\";\n }\n\n var TYPE_LABELS = {\n agent: \"Agent\", tool: \"Tool\", model: \"Model\", supervisor: \"Supervisor\",\n workflow: \"Workflow\", orchestrator: \"Orchestrator\", team: \"Team\",\n planner: \"Planner\", prompt: \"Prompt\", guardrail: \"Guardrail\"\n };\n var PRIMITIVES = { supervisor: 1, workflow: 1, orchestrator: 1, team: 1, planner: 1 };\n function typeClass(type) {\n if (PRIMITIVES[type]) return \"ty-prim\";\n if (type === \"agent\") return \"ty-agent\";\n if (type === \"tool\") return \"ty-tool\";\n if (type === \"model\") return \"ty-model\";\n return \"ty-other\";\n }\n function typeLabel(type) {\n var t = type || \"node\";\n var label = TYPE_LABELS[t] || (t.charAt(0).toUpperCase() + t.slice(1));\n return '<span class=\"tylabel ' + typeClass(t) + '\">' + esc(label) + \"</span>\";\n }\n // Plain-text (no span wrapper) capitalized labels for bare-text sites —\n // group headers, filter-chip labels — where the colored typeLabel span is\n // not wanted. Underlying keys/classes/filter values stay raw lowercase.\n function typeText(type) {\n var t = type || \"node\";\n return TYPE_LABELS[t] || (t.charAt(0).toUpperCase() + t.slice(1));\n }\n function statusText(s) {\n return s ? String(s).charAt(0).toUpperCase() + String(s).slice(1) : s;\n }\n function statusDot(s) { return '<span class=\"sdot sdot-' + statusClass(s) + '\" title=\"' + esc(s) + '\"></span>'; }\n\n function findTrace(id) {\n for (var i = 0; i < state.traces.length; i++) if (state.traces[i].traceId === id) return state.traces[i];\n return null;\n }\n function findSpan(span, id) {\n if (span.spanId === id) return span;\n var k = span.children || [];\n for (var i = 0; i < k.length; i++) { var r = findSpan(k[i], id); if (r) return r; }\n return null;\n }\n function findPath(span, id, acc) {\n var p = (acc || []).concat([span]);\n if (span.spanId === id) return p;\n var k = span.children || [];\n for (var i = 0; i < k.length; i++) { var r = findPath(k[i], id, p); if (r) return r; }\n return null;\n }\n function countSpans(span) {\n var n = 1, k = span.children || [];\n for (var i = 0; i < k.length; i++) n += countSpans(k[i]);\n return n;\n }\n function traceSig(t) { return t.root.status + \"|\" + countSpans(t.root) + \"|\" + t.duration + \"|\" + (t.usage && t.usage.total); }\n // Sum every priced lane of a cost object into one USD number.\n function costSumObj(c) {\n if (!c) return 0;\n return (c.input || 0) + (c.output || 0) + (c.cachedInput || 0) + (c.cachedOutput || 0) + (c.reasoning || 0);\n }\n // The cost a single span directly carries (on its rolled-up usage).\n function usageCost(usage) { return usage ? costSumObj(usage.cost) : 0; }\n // Rollup-aware subtree cost: take a node's own cost when it has one\n // (it already rolls up its trips); otherwise sum the children. This\n // avoids double-counting on wrapper nodes (workflow/supervisor roots\n // carry tokens but no cost, so we descend to the priced agent/model).\n function rollupCost(span) {\n var own = usageCost(span.usage);\n if (own > 0) return own;\n var sum = 0;\n (span.children || []).forEach(function (c) { sum += rollupCost(c); });\n return sum;\n }\n function traceCost(t) {\n var explicit = costSumObj(t.cost);\n return explicit > 0 ? explicit : rollupCost(t.root);\n }\n // Format a USD amount; tiny per-node costs need more decimals to read.\n function money(n) {\n if (!n) return \"$0\";\n return \"$\" + (n < 0.01 ? n.toFixed(6) : n.toFixed(4));\n }\n\n // --- Client-side filtering / grouping / heatmap ----------------------\n // These mirror the pure, unit-tested helpers in trace-filter.ts. Keep\n // the two in sync: trace-filter.ts is the spec, this is its inlined twin.\n function anySelected(map) {\n for (var k in map) { if (map[k]) return true; }\n return false;\n }\n function matchesFilter(t) {\n var f = state.filter, root = t.root;\n if (f.errorsOnly && !ERROR_STATUSES[root.status]) return false;\n if (anySelected(f.statuses) && !f.statuses[root.status]) return false;\n if (anySelected(f.types) && !f.types[root.type]) return false;\n if (f.sessionId && t.sessionId !== f.sessionId) return false;\n if (f.promptKey && tracePromptKey(t) !== f.promptKey) return false;\n var text = (f.text || \"\").trim().toLowerCase();\n if (text) {\n var hay = (String(root.name) + \" \" + (t.sessionId || \"\")).toLowerCase();\n if (hay.indexOf(text) === -1) return false;\n }\n return true;\n }\n function filteredTraces() {\n var out = [];\n for (var i = 0; i < state.traces.length; i++) if (matchesFilter(state.traces[i])) out.push(state.traces[i]);\n return out;\n }\n function groupBySession(list) {\n var order = [], byKey = {};\n for (var i = 0; i < list.length; i++) {\n var key = list[i].sessionId || NO_SESSION_KEY;\n if (!byKey[key]) { byKey[key] = []; order.push(key); }\n byKey[key].push(list[i]);\n }\n return order.map(function (k) { return { sessionId: k, traces: byKey[k] }; });\n }\n // Group by prompt version (name@version) — the second group-by dimension\n // beside session. Mirrors groupByPrompt in trace-filter.ts. Unlinked runs\n // bucket under NO_PROMPT_KEY so they stay visible.\n function groupByPrompt(list) {\n var order = [], byKey = {};\n for (var i = 0; i < list.length; i++) {\n var key = tracePromptKey(list[i]) || NO_PROMPT_KEY;\n if (!byKey[key]) { byKey[key] = []; order.push(key); }\n byKey[key].push(list[i]);\n }\n return order.map(function (k) { return { groupKey: k, traces: byKey[k] }; });\n }\n // Group by root type (agent/workflow/supervisor/planner/…) — the coarsest\n // group-by dimension. Mirrors groupByType in trace-filter.ts. root.type is\n // always present, so the NO_TYPE_KEY bucket is only a defensive fallback.\n function typeGroups(list) {\n var order = [], byKey = {};\n for (var i = 0; i < list.length; i++) {\n var key = (list[i].root && list[i].root.type) || NO_TYPE_KEY;\n if (!byKey[key]) { byKey[key] = []; order.push(key); }\n byKey[key].push(list[i]);\n }\n return order.map(function (k) { return { groupKey: k, traces: byKey[k] }; });\n }\n // Inlined twins of percentile + aggregateByType in trace-filter.ts (the\n // spec). Power the per-type stats panel from the same filtered list the\n // trace view renders, so the panel honors active filters with no API call.\n function percentile(values, p) {\n if (!values.length) return 0;\n var sorted = values.slice().sort(function (a, b) { return a - b; });\n var rank = Math.ceil((p / 100) * sorted.length) - 1;\n var index = Math.min(Math.max(rank, 0), sorted.length - 1);\n return sorted[index];\n }\n function aggregateByType(list) {\n var order = [], byKey = {};\n for (var i = 0; i < list.length; i++) {\n var key = (list[i].root && list[i].root.type) || NO_TYPE_KEY;\n if (!byKey[key]) { byKey[key] = []; order.push(key); }\n byKey[key].push(list[i]);\n }\n return order.map(function (k) {\n var bucket = byKey[k];\n var durations = [], failed = 0, tokens = 0, cost = 0;\n for (var j = 0; j < bucket.length; j++) {\n var t = bucket[j];\n durations.push(t.duration);\n if (ERROR_STATUSES[t.root.status]) failed += 1;\n tokens += (t.usage && t.usage.total) || 0;\n cost += traceCost(t);\n }\n return {\n type: k, count: bucket.length, failed: failed,\n failRate: bucket.length ? failed / bucket.length : 0,\n p50: percentile(durations, 50), p95: percentile(durations, 95),\n tokens: tokens, cost: cost\n };\n });\n }\n // Per-type aggregate panel above the trace list. Honors the active filters\n // (computed over filteredTraces) and re-renders on every poll/filter tick.\n function renderStatsPanel() {\n var host = document.getElementById(\"stats-panel\");\n if (!host) return;\n var stats = state.showStats ? aggregateByType(filteredTraces()) : [];\n if (!stats.length) { host.innerHTML = \"\"; return; }\n var head = '<div class=\"strow sthead\">'\n + '<span class=\"stc-type\">Type</span><span>Count</span><span>Failed</span>'\n + '<span>p50</span><span>p95</span><span>Tokens</span><span>Cost</span></div>';\n var rows = stats.map(function (s) {\n var failTxt = s.failed\n ? s.failed + \" (\" + Math.round(s.failRate * 100) + \"%)\"\n : \"0\";\n return '<div class=\"strow\">'\n + '<span class=\"stc-type\">' + typeLabel(s.type) + \"</span>\"\n + '<span>' + num(s.count) + \"</span>\"\n + '<span class=\"stc-fail' + (s.failed ? \" fail\" : \"\") + '\">' + failTxt + \"</span>\"\n + '<span class=\"dim\">' + dur(s.p50) + \"</span>\"\n + '<span class=\"dim\">' + dur(s.p95) + \"</span>\"\n + '<span>' + num(s.tokens) + \"</span>\"\n + '<span class=\"cost\">' + money(s.cost) + \"</span>\"\n + \"</div>\";\n }).join(\"\");\n host.innerHTML = '<div class=\"stats-table\">' + head + rows + \"</div>\";\n }\n // Largest single-node rollup cost in a subtree — heatmap denominator.\n function maxNodeCost(span) {\n var max = rollupCost(span);\n (span.children || []).forEach(function (c) { var m = maxNodeCost(c); if (m > max) max = m; });\n return max;\n }\n // Intensity in [0,1] of a node's cost vs the trace max. Free trace → 0.\n function heatIntensity(nodeCost, maxCost) {\n if (maxCost <= 0 || nodeCost <= 0) return 0;\n var r = nodeCost / maxCost;\n return r > 1 ? 1 : r;\n }\n // The distinct sessionIds present across the polled traces, first-seen\n // order, capped so the chip row never overflows the toolbar.\n function presentSessions() {\n var seen = {}, out = [];\n for (var i = 0; i < state.traces.length && out.length < 12; i++) {\n var s = state.traces[i].sessionId;\n if (s && !seen[s]) { seen[s] = 1; out.push(s); }\n }\n return out;\n }\n // The root types actually present in the polled traces — so the type filter\n // chips show only what exists (no dead \"Tool\"/\"Model\"/\"Batch\"/… chips), in\n // canonical TYPE_FILTERS order. An active-but-aged-out selection stays so\n // the filter is never stranded with no chip to clear it.\n function presentTypes() {\n var seen = {};\n for (var i = 0; i < state.traces.length; i++) {\n var t = state.traces[i].root && state.traces[i].root.type;\n if (t) seen[t] = 1;\n }\n for (var k in state.filter.types) { if (state.filter.types[k]) seen[k] = 1; }\n var out = [];\n for (var j = 0; j < TYPE_FILTERS.length; j++) {\n if (seen[TYPE_FILTERS[j]]) { out.push(TYPE_FILTERS[j]); delete seen[TYPE_FILTERS[j]]; }\n }\n for (var x in seen) { out.push(x); }\n return out;\n }\n // The distinct prompt name@version keys present across the polled traces,\n // first-seen order, capped so the chip row never overflows the toolbar.\n function presentPrompts() {\n var seen = {}, out = [];\n for (var i = 0; i < state.traces.length && out.length < 12; i++) {\n var p = tracePromptKey(state.traces[i]);\n if (p && !seen[p]) { seen[p] = 1; out.push(p); }\n }\n return out;\n }\n\n // (S5) Markdown link URLs come from captured prompt/tool text — attacker-\n // influenced content — so only http:, https:, mailto: and relative/anchor\n // URLs may become an href; javascript:, data:, vbscript:, etc. must not.\n // The check runs on the URL as the browser will act on it: undo the\n // entities esc() introduced (the HTML parser decodes them exactly once in\n // the attribute), strip the control chars / whitespace browsers ignore\n // when parsing a scheme (java\\\\tscript:), and lowercase. Returns the\n // original (still-escaped) URL when safe, or null to drop the link.\n function sanitizeHref(url) {\n var probe = url.replace(/&(amp|lt|gt|quot|#39);/g, function (m, name) {\n return { amp: \"&\", lt: \"<\", gt: \">\", quot: '\"', \"#39\": \"'\" }[name];\n });\n probe = probe.replace(/[\\\\u0000-\\\\u0020\\\\u007f]+/g, \"\").toLowerCase();\n if (/^[a-z][a-z0-9+.-]*:/.test(probe)) {\n return /^(https?|mailto):/.test(probe) ? url : null;\n }\n if (/^[\\\\/\\\\\\\\]{2}/.test(probe)) return null; // scheme-relative smuggles a foreign host\n return url;\n }\n function mdInline(s) {\n s = s.replace(/\\\\*\\\\*([^*]+)\\\\*\\\\*/g, \"<strong>$1</strong>\");\n var codeRe = new RegExp(BT + \"([^\" + BT + \"]+)\" + BT, \"g\");\n s = s.replace(codeRe, \"<code>$1</code>\");\n s = s.replace(/\\\\[([^\\\\]]+)\\\\]\\\\(([^)]+)\\\\)/g, function (m, label, url) {\n var href = sanitizeHref(url);\n if (href === null) return label; // unsafe scheme: label as plain text, no <a>\n return '<a href=\"' + href + '\" target=\"_blank\" rel=\"noopener\">' + label + \"</a>\";\n });\n return s;\n }\n function mdToHtml(raw) {\n var src = esc(String(raw));\n var fence = BT + BT + BT;\n var html = \"\", idx = 0;\n while (true) {\n var start = src.indexOf(fence, idx);\n if (start === -1) { html += mdBlocks(src.slice(idx)); break; }\n html += mdBlocks(src.slice(idx, start));\n var nl = src.indexOf(\"\\\\n\", start + 3);\n var bodyStart = (nl === -1) ? start + 3 : nl + 1;\n var end = src.indexOf(fence, bodyStart);\n if (end === -1) { html += mdBlocks(src.slice(start)); break; }\n html += '<pre class=\"code\">' + src.slice(bodyStart, end).replace(/\\\\n$/, \"\") + \"</pre>\";\n idx = end + 3;\n }\n return html;\n }\n function mdBlocks(src) {\n var lines = src.split(\"\\\\n\"), html = \"\", inList = false;\n function closeList() { if (inList) { html += \"</ul>\"; inList = false; } }\n for (var i = 0; i < lines.length; i++) {\n var ln = lines[i];\n var h = ln.match(/^(#{1,4})\\\\s+(.*)$/);\n if (h) { closeList(); html += '<div class=\"md-h md-h' + h[1].length + '\">' + mdInline(h[2]) + \"</div>\"; continue; }\n var li = ln.match(/^\\\\s*[-*]\\\\s+(.*)$/);\n if (li) { if (!inList) { html += \"<ul>\"; inList = true; } html += \"<li>\" + mdInline(li[1]) + \"</li>\"; continue; }\n if (ln.trim() === \"\") { closeList(); continue; }\n closeList();\n html += '<div class=\"md-p\">' + mdInline(ln) + \"</div>\";\n }\n closeList();\n return html;\n }\n function renderKv(obj) {\n var keys = Object.keys(obj);\n if (!keys.length) return '<span class=\"dim\">{}</span>';\n return '<div class=\"kv\">' + keys.map(function (k) {\n var v = obj[k], vs;\n if (v === null || v === undefined) vs = '<span class=\"dim\">null</span>';\n else if (typeof v === \"object\") vs = '<pre class=\"mini\">' + esc(fmt(v)) + \"</pre>\";\n else vs = esc(String(v));\n return '<div class=\"kv-row\"><span class=\"kv-k\">' + esc(k) + '</span><span class=\"kv-v\">' + vs + \"</span></div>\";\n }).join(\"\") + \"</div>\";\n }\n function isMessageArray(v) {\n return Array.isArray(v) && v.length > 0 && v.every(function (m) { return m && typeof m === \"object\" && typeof m.role === \"string\"; });\n }\n function renderContent(c) {\n if (c == null) return '<span class=\"dim\">—</span>';\n if (typeof c === \"string\") return mdToHtml(c);\n if (Array.isArray(c)) return c.map(renderPart).join(\"\");\n if (typeof c === \"object\") return renderKv(c);\n return esc(String(c));\n }\n function renderPart(p) {\n if (p == null) return \"\";\n if (typeof p === \"string\") return mdToHtml(p);\n if (p.type === \"text\" && typeof p.text === \"string\") return mdToHtml(p.text);\n return '<div class=\"part\"><div class=\"part-label\">' + esc(p.type || \"part\") + \"</div>\" + renderKv(p) + \"</div>\";\n }\n function previewText(c) {\n if (c == null) return \"\";\n if (typeof c === \"string\") return c.replace(/\\\\s+/g, \" \").slice(0, 70);\n if (Array.isArray(c)) {\n for (var i = 0; i < c.length; i++) {\n var p = c[i];\n if (typeof p === \"string\") return p.slice(0, 70);\n if (p && p.type === \"text\" && p.text) return String(p.text).slice(0, 70);\n }\n return \"\";\n }\n try { return JSON.stringify(c).slice(0, 70); } catch (e) { return \"\"; }\n }\n function renderMsg(m) {\n var role = m.role || \"msg\";\n var body = (m.content !== undefined) ? renderContent(m.content) : renderKv(m);\n return '<div class=\"msg\"><div class=\"msg-role role-' + esc(role) + '\">' + esc(role) + '</div><div class=\"msg-content\">' + body + \"</div></div>\";\n }\n function renderMessages(arr) {\n var inner = '<div class=\"messages\">' + arr.map(renderMsg).join(\"\") + \"</div>\";\n // Short threads render inline; a long history (10-15+ messages) collapses\n // behind a <details> so it doesn't blow up the detail pane — the summary\n // shows the count + a preview of the latest message; click to expand.\n if (arr.length <= 6) return inner;\n var last = arr[arr.length - 1] || {};\n var preview = (last.role ? last.role + \": \" : \"\") + previewText(last.content);\n return '<details class=\"msg-collapse\"><summary><span class=\"msg-count\">' + arr.length\n + ' messages</span> <span class=\"dim\">' + esc(preview) + \"</span></summary>\" + inner + \"</details>\";\n }\n function smartValue(v) {\n if (v === null || v === undefined) return '<span class=\"dim\">—</span>';\n if (typeof v === \"string\") {\n var t = v.trim();\n if (t.charAt(0) === \"{\" || t.charAt(0) === \"[\") { try { return smartValue(JSON.parse(t)); } catch (e) {} }\n return mdToHtml(v);\n }\n if (isMessageArray(v)) return renderMessages(v);\n if (Array.isArray(v)) {\n return '<div class=\"kv\">' + v.map(function (item, i) {\n var vs = (item && typeof item === \"object\") ? renderKv(item) : esc(String(item));\n return '<div class=\"kv-row\"><span class=\"kv-k\">' + i + '</span><span class=\"kv-v\">' + vs + \"</span></div>\";\n }).join(\"\") + \"</div>\";\n }\n if (typeof v === \"object\") return renderKv(v);\n return esc(String(v));\n }\n\n function renderStats(a) {\n var cost = a.cost ? (((a.cost.input || 0) + (a.cost.output || 0) + (a.cost.cachedInput || 0) + (a.cost.cachedOutput || 0)).toFixed(4)) : \"—\";\n var u = a.usage || {};\n var cells = [\n [\"traces\", num(a.traces), null],\n [\"completed\", num(a.completed), null],\n [\"failed\", num(a.failed), null],\n [\"cancelled\", num(a.cancelled), null],\n [\"↓ tokens in\", num(u.input), \"var(--tok-in)\"],\n [\"↑ tokens out\", num(u.output), \"var(--tok-out)\"],\n [\"tokens total\", num(u.total), \"var(--tok-total)\"],\n [\"cost (usd)\", cost, null]\n ];\n document.getElementById(\"stats\").innerHTML = cells.map(function (c) {\n var st = c[2] ? ' style=\"color:' + c[2] + '\"' : \"\";\n return '<div class=\"stat\"><div class=\"label\"' + st + \">\" + c[0] + '</div><div class=\"value\"' + st + \">\" + esc(c[1]) + \"</div></div>\";\n }).join(\"\");\n }\n\n // Per-row heat accent: tint the left edge by the trace's own cost\n // relative to the most expensive trace currently in the (filtered) list.\n function heatStyle(intensity) {\n if (intensity <= 0) return \"\";\n var op = (0.12 + intensity * 0.88).toFixed(3);\n return ' style=\"--heat-op:' + op + '\"';\n }\n function heatBar(intensity) {\n if (intensity <= 0) return \"\";\n var op = (0.12 + intensity * 0.88).toFixed(3);\n return '<span class=\"heat\" style=\"opacity:' + op + '\" title=\"relative cost\"></span>';\n }\n function renderRow(t, maxTraceCost) {\n var sel = t.traceId === state.selectedId ? \" selected\" : \"\";\n var intensity = heatIntensity(traceCost(t), maxTraceCost);\n var pk = tracePromptKey(t);\n return '<div class=\"trace-row' + sel + '\" data-id=\"' + esc(t.traceId) + '\">'\n + heatBar(intensity)\n + typeLabel(t.root.type)\n + '<span class=\"badge ' + statusClass(t.root.status) + '\">' + esc(statusText(t.root.status)) + \"</span>\"\n + '<span class=\"rname\">' + esc(t.root.name) + \"</span>\"\n + (pk ? '<span class=\"rprompt\" title=\"prompt version\">' + esc(pk) + \"</span>\" : \"\")\n + (t.sessionId ? '<span class=\"dim\">' + esc(t.sessionId) + \"</span>\" : \"\")\n + '<span class=\"rright\">' + tokens(t.usage) + '<span class=\"dim\">· ' + dur(t.duration) + \"</span></span>\"\n + '<i class=\"chev\">›</i>'\n + \"</div>\";\n }\n function maxTraceCostOf(list) {\n var max = 0;\n for (var i = 0; i < list.length; i++) { var c = traceCost(list[i]); if (c > max) max = c; }\n return max;\n }\n // Render grouped buckets. \"groups\" is the normalized list each group-by\n // dimension produces — a groupKey + its traces. Shared by session, prompt,\n // and type. Optional \"labelFn\" maps the raw groupKey to a display label and\n // switches the header to the compact \"Label (N)\" form (used by type, whose\n // keys are friendly enums); session/prompt omit it and keep their raw id\n // plus the right-aligned \"N trace(s)\" count. data-group stays the RAW key\n // so collapse state keys consistently regardless of the display label.\n function renderGroups(groups, maxTraceCost, labelFn) {\n return groups.map(function (g) {\n var collapsed = !!state.collapsedGroups[g.groupKey];\n var idAndCount = labelFn\n ? '<span class=\"sgroup-id\">' + esc(labelFn(g.groupKey)) + \" (\" + g.traces.length + \")</span>\"\n : '<span class=\"sgroup-id\">' + esc(g.groupKey) + \"</span>\"\n + '<span class=\"sgroup-count\">' + g.traces.length + \" trace(s)</span>\";\n var head = '<div class=\"sgroup-head\" data-group=\"' + esc(g.groupKey) + '\">'\n + '<span class=\"sgroup-tw\">' + (collapsed ? \"▸\" : \"▾\") + \"</span>\"\n + idAndCount + \"</div>\";\n var body = collapsed ? \"\" : '<div class=\"sgroup-body\">'\n + g.traces.map(function (t) { return renderRow(t, maxTraceCost); }).join(\"\") + \"</div>\";\n return '<div class=\"sgroup\">' + head + body + \"</div>\";\n }).join(\"\");\n }\n // Normalize a session group ({sessionId,…}) to the shared {groupKey,…} shape.\n function sessionGroups(list) {\n return groupBySession(list).map(function (g) { return { groupKey: g.sessionId, traces: g.traces }; });\n }\n function renderList() {\n var list = filteredTraces();\n // Keep the per-type stats panel in sync — runs on every poll + filter\n // change, before the empty-state early return below.\n renderStatsPanel();\n var host = document.getElementById(\"traces\");\n if (!list.length) {\n host.innerHTML = state.traces.length\n ? '<div class=\"empty\">No traces match the current filters.</div>'\n : '<div class=\"empty\">No traces yet. Run an observed flow and they will appear here.</div>';\n return;\n }\n var maxTraceCost = maxTraceCostOf(list);\n // One grouping dimension renders at a time. The toggles are kept mutually\n // exclusive in their change handlers, so this precedence chain (most\n // specific → coarsest: prompt → session → type) only ever matches one.\n var html;\n if (state.groupByPrompt) html = renderGroups(groupByPrompt(list), maxTraceCost);\n else if (state.groupBySession) html = renderGroups(sessionGroups(list), maxTraceCost);\n else if (state.groupByType) html = renderGroups(typeGroups(list), maxTraceCost, typeText);\n else html = list.map(function (t) { return renderRow(t, maxTraceCost); }).join(\"\");\n host.innerHTML = html;\n document.getElementById(\"meta\").textContent = list.length + \" of \" + state.traces.length + \" trace(s) · live\";\n }\n\n // --- Filter UI rendering ---------------------------------------------\n function renderChips() {\n var f = state.filter;\n document.getElementById(\"status-chips\").innerHTML =\n '<span class=\"chip-group\"><span class=\"gl\">status</span>'\n + STATUS_FILTERS.map(function (s) {\n return '<button type=\"button\" class=\"chip' + (f.statuses[s] ? \" active\" : \"\") + '\" data-status=\"' + esc(s) + '\">' + esc(statusText(s)) + \"</button>\";\n }).join(\"\")\n + '<button type=\"button\" class=\"chip' + (f.errorsOnly ? \" active\" : \"\") + '\" data-errors=\"1\" title=\"Show only failed / cancelled traces\">Errors only</button>'\n + \"</span>\";\n var types = presentTypes();\n document.getElementById(\"type-chips\").innerHTML = types.length\n ? '<span class=\"chip-group\"><span class=\"gl\">type</span>'\n + types.map(function (ty) {\n return '<button type=\"button\" class=\"chip' + (f.types[ty] ? \" active\" : \"\") + '\" data-type=\"' + esc(ty) + '\">' + esc(typeText(ty)) + \"</button>\";\n }).join(\"\") + \"</span>\"\n : \"\";\n var sessions = presentSessions();\n document.getElementById(\"session-chips\").innerHTML = sessions.length\n ? '<span class=\"chip-group\"><span class=\"gl\">session</span>'\n + sessions.map(function (s) {\n return '<button type=\"button\" class=\"chip' + (f.sessionId === s ? \" active\" : \"\") + '\" data-session=\"' + esc(s) + '\">' + esc(s) + \"</button>\";\n }).join(\"\") + \"</span>\"\n : \"\";\n // Prompt-version filter chips — one per distinct name@version seen.\n // Hidden entirely until a named-prompt run shows up, so the toolbar stays\n // clean for projects that don't use the ai.prompts registry.\n var prompts = presentPrompts();\n document.getElementById(\"prompt-chips\").innerHTML = prompts.length\n ? '<span class=\"chip-group\"><span class=\"gl\">prompt</span>'\n + prompts.map(function (p) {\n return '<button type=\"button\" class=\"chip' + (f.promptKey === p ? \" active\" : \"\") + '\" data-prompt=\"' + esc(p) + '\">' + esc(p) + \"</button>\";\n }).join(\"\") + \"</span>\"\n : \"\";\n }\n\n // Heatmap denominator for the currently rendered trace tree. Set by\n // renderDrawer before each renderTree pass so node accents are scaled\n // against the most expensive node in this trace.\n var currentTreeMax = 0;\n function renderTree(span) {\n var hasKids = span.children && span.children.length;\n var collapsed = !!state.collapsed[span.spanId];\n var sel = span.spanId === state.selectedSpanId ? \" selected\" : \"\";\n var tw = hasKids ? (collapsed ? \"▸\" : \"▾\") : \"·\";\n var node = '<div class=\"tnode' + sel + '\" data-span=\"' + esc(span.spanId) + '\">'\n + heatBar(heatIntensity(rollupCost(span), currentTreeMax))\n + '<span class=\"twisty\"' + (hasKids ? ' data-toggle=\"' + esc(span.spanId) + '\"' : \"\") + \">\" + tw + \"</span>\"\n + typeLabel(span.type)\n + '<span class=\"tname\">' + esc(span.name) + \"</span>\"\n + statusDot(span.status)\n + '<span class=\"tmeta\">' + dur(span.duration) + \"</span>\"\n + \"</div>\";\n var kids = (hasKids && !collapsed) ? '<div class=\"tkids\">' + span.children.map(renderTree).join(\"\") + \"</div>\" : \"\";\n return node + kids;\n }\n\n // --- Timeline / waterfall (Gantt) ------------------------------------\n // Flatten the span tree to a depth-first ordered list, each entry\n // carrying its offset (ms from root start) + duration, so we can lay\n // out concurrency without re-walking. parseTs tolerates a missing/bad\n // startedAt by falling back to 0 so a bad clock never NaNs the bars.\n function parseTs(s) { var n = Date.parse(s); return isNaN(n) ? 0 : n; }\n // Render an ISO timestamp for the detail drawer as a readable, locale-\n // unambiguous local time — \"28 Jun 2026 03:16 PM\" (named month so there is\n // no M/D vs D/M confusion; minute precision). Falls back to the raw string\n // on a bad clock.\n var MONTHS = [\"Jan\", \"Feb\", \"Mar\", \"Apr\", \"May\", \"Jun\", \"Jul\", \"Aug\", \"Sep\", \"Oct\", \"Nov\", \"Dec\"];\n function pad2(n) { return (n < 10 ? \"0\" : \"\") + n; }\n function fmtTs(iso) {\n if (!iso) return \"—\";\n var d = new Date(iso);\n if (isNaN(d.getTime())) return String(iso);\n var h = d.getHours();\n var ampm = h >= 12 ? \"PM\" : \"AM\";\n var h12 = h % 12;\n if (h12 === 0) h12 = 12;\n return d.getDate() + \" \" + MONTHS[d.getMonth()] + \" \" + d.getFullYear()\n + \" \" + pad2(h12) + \":\" + pad2(d.getMinutes()) + \" \" + ampm;\n }\n function flattenSpans(root) {\n var base = parseTs(root.startedAt);\n var rows = [];\n (function walk(span, depth) {\n var start = parseTs(span.startedAt) - base;\n if (start < 0) start = 0;\n var d = (span.duration != null) ? span.duration : 0;\n rows.push({ span: span, depth: depth, offset: start, duration: d, end: start + d });\n (span.children || []).forEach(function (c) { walk(c, depth + 1); });\n })(root, 0);\n return rows;\n }\n // Critical path: from the root, repeatedly step into the child whose\n // end time is latest (the one that pushed the parent's finish). Marks\n // the spans that determine total wall-clock time.\n function criticalPath(root) {\n var crit = {};\n (function walk(span) {\n crit[span.spanId] = 1;\n var kids = span.children || [];\n if (!kids.length) return;\n var pick = null, pe = -1;\n for (var i = 0; i < kids.length; i++) {\n var e = parseTs(kids[i].startedAt) + ((kids[i].duration != null) ? kids[i].duration : 0);\n if (e > pe) { pe = e; pick = kids[i]; }\n }\n if (pick) walk(pick);\n })(root);\n return crit;\n }\n function renderGantt(root) {\n var rows = flattenSpans(root);\n var span0 = rows.length ? rows[0] : null;\n var total = 0;\n rows.forEach(function (r) { if (r.end > total) total = r.end; });\n if (total <= 0) total = (span0 && span0.duration) || 1;\n var crit = criticalPath(root);\n var body = rows.map(function (r) {\n var leftPct = (r.offset / total) * 100;\n var widthPct = Math.max((r.duration / total) * 100, 0.6);\n var sel = r.span.spanId === state.selectedSpanId ? \" selected\" : \"\";\n var isCrit = crit[r.span.spanId] ? \" crit\" : \"\";\n var barClass = \"gbar bar-\" + statusClass(r.span.status) + (isCrit ? \" crit\" : \"\");\n var pad = \"padding-left:\" + (r.depth * 10) + \"px\";\n return '<div class=\"grow' + sel + '\" data-span=\"' + esc(r.span.spanId) + '\">'\n + '<span class=\"glabel\" style=\"' + pad + '\" title=\"' + esc(r.span.name) + '\">' + esc(r.span.name) + \"</span>\"\n + '<span class=\"gtrack\"><span class=\"' + barClass + '\" style=\"left:' + leftPct.toFixed(2) + \"%;width:\" + widthPct.toFixed(2) + '%\"></span></span>'\n + '<span class=\"gdur\">' + dur(r.duration) + \"</span>\"\n + \"</div>\";\n }).join(\"\");\n var legend = '<div class=\"gantt-legend\">'\n + '<span class=\"ck\"><span class=\"sw\" style=\"background:var(--fail)\"></span> critical path</span>'\n + '<span class=\"ck\"><span class=\"sw\" style=\"background:var(--ty-agent)\"></span> span (offset + duration)</span>'\n + \"</div>\";\n return '<div class=\"gantt\">' + (body || '<div class=\"dim\">No spans.</div>') + legend + \"</div>\";\n }\n\n // The name@version of the named prompt this span's run resolved, read\n // from the collector's prompt-version-linkage attributes. Returns null when\n // the run carried no named prompt. Mirrors tracePromptKey in trace-filter.ts.\n function spanPromptKey(span) {\n var a = span && span.attributes;\n if (!a || typeof a !== \"object\") return null;\n var name = a[\"agent.promptName\"];\n if (typeof name !== \"string\" || !name.length) return null;\n var ver = a[\"agent.promptVersion\"];\n var vl = (typeof ver === \"string\" && ver.length) ? ver : \"1\";\n return name + \"@\" + vl;\n }\n function tracePromptKey(t) { return spanPromptKey(t.root); }\n\n // Humanize a metadata key for display: split dot.notation + camelCase,\n // Title-case each word, upcase \"id\". e.g. \"supervisor.terminatedBy\" →\n // \"Supervisor Terminated By\", \"span id\" → \"Span ID\", \"agent.trips\" →\n // \"Agent Trips\". Underlying attribute keys are untouched.\n // NB: this whole script is a template literal — regex backslash classes\n // MUST be double-escaped (\\\\s, not \\s) or \"\\\\s\" collapses to a literal \"s\".\n function humanizeKey(key) {\n return String(key).split(/[.\\\\s]+/).map(function (seg) {\n return seg.replace(/([a-z0-9])([A-Z])/g, \"$1 $2\").split(/\\\\s+/).map(function (w) {\n if (!w) return w;\n if (w.toLowerCase() === \"id\") return \"ID\";\n return w.charAt(0).toUpperCase() + w.slice(1);\n }).join(\" \");\n }).join(\" \");\n }\n\n function renderMeta(span, trace) {\n var rows = [];\n // Absolute wall-clock span — the head shows only the elapsed duration, so\n // surface when this node actually started/ended for log correlation.\n rows.push([\"started\", fmtTs(span.startedAt)]);\n rows.push([\"ended\", fmtTs(span.endedAt)]);\n var sid = span.sessionId || trace.sessionId;\n if (sid) rows.push([\"session\", sid]);\n // Prompt-version linkage: surface the resolved named prompt as one clean\n // name@version row right under session, so the panel reads it as a\n // first-class dimension rather than two raw attribute keys.\n var pk = spanPromptKey(span);\n if (pk) rows.push([\"prompt\", pk]);\n if (span.version) rows.push([\"version\", span.version]);\n rows.push([\"span id\", span.spanId]);\n if (span.parentSpanId) rows.push([\"parent\", span.parentSpanId]);\n rows.push([\"trace id\", span.traceId || trace.traceId]);\n var attrs = span.attributes;\n // Skip the two raw prompt keys — already shown as the clean prompt row.\n if (attrs && typeof attrs === \"object\") Object.keys(attrs).forEach(function (k) {\n if (k === \"agent.promptName\" || k === \"agent.promptVersion\") return;\n rows.push([k, attrs[k]]);\n });\n var kv = rows.map(function (r) {\n var v = r[1];\n var vs = (v && typeof v === \"object\") ? '<pre class=\"mini\">' + esc(fmt(v)) + \"</pre>\" : esc(String(v));\n return '<div class=\"kv-row\"><span class=\"kv-k\">' + esc(humanizeKey(r[0])) + '</span><span class=\"kv-v\">' + vs + \"</span></div>\";\n }).join(\"\");\n return '<div class=\"meta-sec\"><div class=\"meta-title\">metadata</div><div class=\"kv\">' + kv + \"</div></div>\";\n }\n\n // --- Evaluate: grade a span's last captured system prompt -------------\n // Config-gated (EVALUATE_ENABLED) — the drawer's only write action, POSTing\n // to a route that itself only exists when the server was configured with\n // evaluate. UI state lives in state.evaluate, keyed by spanId, so it\n // survives a re-render (e.g. switching to a sibling span and back).\n function extractLastSystemPrompt(span) {\n if (!Array.isArray(span.input)) return null;\n for (var i = span.input.length - 1; i >= 0; i--) {\n var m = span.input[i];\n if (m && typeof m === \"object\" && m.role === \"system\" && typeof m.content === \"string\") return m.content;\n }\n return null;\n }\n function evalState(spanId) {\n return state.evaluate[spanId] || (state.evaluate[spanId] = {\n open: false, instructions: EVALUATE_DEFAULT_INSTRUCTIONS, status: \"idle\", result: null, error: null\n });\n }\n function evalResultHtml(result) {\n var score = typeof result.score === \"number\" ? Math.round(result.score * 100) + \"%\" : \"n/a\";\n var issues = (result.issues || []).map(function (i) { return \"<li>\" + esc(i) + \"</li>\"; }).join(\"\");\n return '<div class=\"eval-result\"><span class=\"eval-score\">Score: ' + score + \"</span>\"\n + (issues ? '<ul class=\"eval-issues\">' + issues + \"</ul>\" : \"\") + \"</div>\";\n }\n function evalSectionHtml(span) {\n if (!EVALUATE_ENABLED) return \"\";\n var sysPrompt = extractLastSystemPrompt(span);\n if (!sysPrompt) return \"\";\n var st = evalState(span.spanId);\n var btn = '<button type=\"button\" class=\"eval-btn\" data-evaluate-toggle=\"' + esc(span.spanId) + '\">'\n + (st.open ? \"Hide evaluate\" : \"Evaluate system prompt\") + \"</button>\";\n if (!st.open) return btn;\n var running = st.status === \"running\";\n return btn + '<div class=\"eval-panel\">'\n + '<textarea id=\"eval-instructions\" placeholder=\"Grading instructions (optional — falls back to the configured default)\"' + (running ? \" disabled\" : \"\") + \">\" + esc(st.instructions || \"\") + \"</textarea>\"\n + '<div class=\"eval-actions\">'\n + '<button type=\"button\" class=\"eval-btn\" data-evaluate-run=\"' + esc(span.spanId) + '\"' + (running ? \" disabled\" : \"\") + \">\" + (running ? \"Evaluating…\" : \"Run\") + \"</button>\"\n + (st.error ? '<span class=\"eval-error\">' + esc(st.error) + \"</span>\" : \"\")\n + \"</div>\"\n + (st.result ? evalResultHtml(st.result) : \"\")\n + \"</div>\";\n }\n function rerenderDetail() {\n var t = findTrace(state.selectedId);\n if (!t) return;\n var sel = findSpan(t.root, state.selectedSpanId) || t.root;\n document.getElementById(\"drawer-detail\").innerHTML = renderDetail(sel, t);\n }\n function toggleEvalPanel(spanId) {\n evalState(spanId).open = !evalState(spanId).open;\n rerenderDetail();\n }\n function runEvaluate(traceId, spanId) {\n var st = evalState(spanId);\n var textarea = document.getElementById(\"eval-instructions\");\n if (textarea) st.instructions = textarea.value;\n st.status = \"running\"; st.error = null;\n rerenderDetail();\n fetchAuthed(API + \"/traces/\" + encodeURIComponent(traceId) + \"/spans/\" + encodeURIComponent(spanId) + \"/evaluate\", {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({ instructions: st.instructions })\n }).then(function (r) {\n return r.json().then(function (data) { return { ok: r.ok, data: data }; });\n }).then(function (res) {\n if (res.ok) { st.status = \"done\"; st.result = res.data; st.error = null; }\n else { st.status = \"error\"; st.error = (res.data && (res.data.message || res.data.error)) || \"evaluate failed\"; st.result = null; }\n rerenderDetail();\n }).catch(function () {\n st.status = \"error\"; st.error = \"network error\"; st.result = null;\n rerenderDetail();\n });\n }\n\n function renderDetail(span, trace) {\n var path = findPath(trace.root, span.spanId) || [span];\n var crumb = path.map(function (p, i) { return (i ? '<span class=\"sep\"> › </span>' : \"\") + \"<span>\" + esc(p.name) + \"</span>\"; }).join(\"\");\n var io = \"\";\n if (span.input !== undefined) io += '<div class=\"piol\" style=\"color:var(--tok-in)\">input</div><div class=\"io-body\">' + smartValue(span.input) + \"</div>\";\n if (span.output !== undefined) io += '<div class=\"piol\" style=\"color:var(--tok-out)\">output</div><div class=\"io-body\">' + smartValue(span.output) + \"</div>\";\n if (span.error) io += '<div class=\"piol\" style=\"color:var(--fail)\">error</div><div class=\"io-body\">' + smartValue(span.error) + \"</div>\";\n\n // Tokens + cost line — omitted entirely when the node has neither\n // (e.g. a free tool with zero usage), so the panel stays uncluttered.\n var tk = tokens(span.usage);\n var c = rollupCost(span);\n var metaLine = (tk || c > 0)\n ? '<div style=\"margin-bottom:4px\">' + tk + (c > 0 ? '<span class=\"cost\">' + (tk ? \" · \" : \"\") + money(c) + \"</span>\" : \"\") + \"</div>\"\n : \"\";\n\n return '<div class=\"crumb\">' + crumb + \"</div>\"\n + '<div class=\"dhead-row\">' + typeLabel(span.type) + '<span style=\"font-weight:600\">' + esc(span.name) + \"</span>\"\n + '<span class=\"badge ' + statusClass(span.status) + '\">' + esc(statusText(span.status)) + \"</span>\"\n + '<span class=\"dim\">· ' + dur(span.duration) + \"</span></div>\"\n + metaLine\n + io\n + evalSectionHtml(span, trace)\n + renderMeta(span, trace);\n }\n\n function headHtml(t) {\n var cost = traceCost(t);\n return typeLabel(t.root.type)\n + '<span class=\"badge ' + statusClass(t.root.status) + '\">' + esc(statusText(t.root.status)) + \"</span>\"\n + '<span style=\"font-weight:600\">' + esc(t.root.name) + \"</span>\"\n + '<span class=\"dim\">· ' + dur(t.duration) + \"</span>\"\n + tokens(t.usage)\n + (cost > 0 ? '<span class=\"cost\">· ' + money(cost) + \"</span>\" : \"\")\n + '<button class=\"drawer-close\" type=\"button\" title=\"Close (Esc)\">✕ Close</button>';\n }\n\n function viewSwitcher() {\n var tree = state.view === \"tree\" ? \" active\" : \"\";\n var tl = state.view === \"timeline\" ? \" active\" : \"\";\n return '<div class=\"dview\" id=\"drawer-view\">'\n + '<button type=\"button\" class=\"' + tree.trim() + '\" data-view=\"tree\">Tree</button>'\n + '<button type=\"button\" class=\"' + tl.trim() + '\" data-view=\"timeline\">Timeline</button>'\n + '<span class=\"legend\" title=\"Node colour = relative cost\"><span>cost</span><span class=\"grad\"></span></span>'\n + \"</div>\";\n }\n function leftPaneHtml(t) {\n if (state.view === \"timeline\") return renderGantt(t.root);\n currentTreeMax = maxNodeCost(t.root);\n return renderTree(t.root);\n }\n function renderDrawer(t) {\n document.getElementById(\"drawer-head\").innerHTML = headHtml(t);\n var sel = findSpan(t.root, state.selectedSpanId) || t.root;\n document.getElementById(\"drawer-body\").innerHTML =\n '<div class=\"dsplit\"><div class=\"dtree\" id=\"drawer-tree\">' + viewSwitcher() + leftPaneHtml(t) + \"</div>\"\n + '<div class=\"ddetail\" id=\"drawer-detail\">' + renderDetail(sel, t) + \"</div></div>\";\n }\n // Re-render only the left pane (after a view switch) without disturbing\n // the detail panel or scroll position of the detail side.\n function renderLeftPane() {\n var t = findTrace(state.selectedId);\n if (!t) return;\n document.getElementById(\"drawer-tree\").innerHTML = viewSwitcher() + leftPaneHtml(t);\n }\n\n function selectSpan(id) {\n state.selectedSpanId = id;\n var t = findTrace(state.selectedId);\n if (!t) return;\n document.getElementById(\"drawer-detail\").innerHTML = renderDetail(findSpan(t.root, id) || t.root, t);\n // Highlight in whichever left pane is active (tree nodes or Gantt rows).\n var nodes = document.querySelectorAll(\".tnode[data-span], .grow[data-span]\");\n for (var i = 0; i < nodes.length; i++) {\n if (nodes[i].getAttribute(\"data-span\") === id) nodes[i].classList.add(\"selected\");\n else nodes[i].classList.remove(\"selected\");\n }\n writeHash();\n }\n function toggleSpan(id) {\n state.collapsed[id] = !state.collapsed[id];\n var t = findTrace(state.selectedId);\n if (t) renderLeftPane();\n }\n function setView(v) {\n if (state.view === v) return;\n state.view = v;\n renderLeftPane();\n }\n\n function openTrace(id, spanId) {\n state.selectedId = id;\n var t = findTrace(id);\n if (t) {\n state.selectedSpanId = (spanId && findSpan(t.root, spanId)) ? spanId : t.root.spanId;\n state.collapsed = {};\n renderDrawer(t);\n state.sig = traceSig(t);\n }\n document.getElementById(\"drawer\").classList.add(\"open\");\n document.getElementById(\"drawer\").setAttribute(\"aria-hidden\", \"false\");\n document.getElementById(\"backdrop\").classList.add(\"open\");\n markSelectedRow();\n writeHash();\n }\n function closeDrawer() {\n state.selectedId = null; state.selectedSpanId = null; state.sig = null;\n document.getElementById(\"drawer\").classList.remove(\"open\");\n document.getElementById(\"drawer\").setAttribute(\"aria-hidden\", \"true\");\n document.getElementById(\"backdrop\").classList.remove(\"open\");\n markSelectedRow();\n writeHash();\n }\n\n // --- Deep-links: reflect the open trace + span in the URL hash -------\n // #trace=<id>&span=<id>. Written on open/close/select; read on load and\n // on manual hash edits (back/forward). A guard flag stops writeHash from\n // re-triggering our own hashchange handler in a loop.\n var suppressHash = false;\n function writeHash() {\n var h = \"\";\n if (state.selectedId) {\n h = \"#trace=\" + encodeURIComponent(state.selectedId);\n if (state.selectedSpanId && state.selectedSpanId !== state.selectedId) {\n h += \"&span=\" + encodeURIComponent(state.selectedSpanId);\n }\n }\n suppressHash = true;\n try {\n if (history && history.replaceState) history.replaceState(null, \"\", h || (location.pathname + location.search));\n else location.hash = h;\n } catch (e) { location.hash = h; }\n suppressHash = false;\n }\n function readHash() {\n var raw = (location.hash || \"\").replace(/^#/, \"\");\n var out = { trace: null, span: null };\n raw.split(\"&\").forEach(function (kv) {\n var i = kv.indexOf(\"=\");\n if (i === -1) return;\n var k = kv.slice(0, i), v = decodeURIComponent(kv.slice(i + 1));\n if (k === \"trace\") out.trace = v;\n else if (k === \"span\") out.span = v;\n });\n return out;\n }\n // Open whatever the hash points at, if that trace is loaded. Returns\n // true when it acted so the caller can mark the one-time load as done.\n function applyHash() {\n var h = readHash();\n if (!h.trace) {\n if (state.selectedId) closeDrawer();\n return true;\n }\n if (!findTrace(h.trace)) return false; // not polled yet — retry next poll\n openTrace(h.trace, h.span || undefined);\n return true;\n }\n function markSelectedRow() {\n var rows = document.querySelectorAll(\".trace-row\");\n for (var i = 0; i < rows.length; i++) {\n if (rows[i].getAttribute(\"data-id\") === state.selectedId) rows[i].classList.add(\"selected\");\n else rows[i].classList.remove(\"selected\");\n }\n }\n\n document.getElementById(\"traces\").addEventListener(\"click\", function (e) {\n var head = e.target.closest ? e.target.closest(\".sgroup-head\") : null;\n if (head) {\n var g = head.getAttribute(\"data-group\");\n state.collapsedGroups[g] = !state.collapsedGroups[g];\n renderList();\n return;\n }\n var row = e.target.closest ? e.target.closest(\".trace-row\") : null;\n if (row) openTrace(row.getAttribute(\"data-id\"));\n });\n document.getElementById(\"drawer-head\").addEventListener(\"click\", function (e) {\n if (e.target.closest && e.target.closest(\".drawer-close\")) closeDrawer();\n });\n document.getElementById(\"drawer-body\").addEventListener(\"click\", function (e) {\n var vb = e.target.closest ? e.target.closest(\"[data-view]\") : null;\n if (vb) { setView(vb.getAttribute(\"data-view\")); return; }\n var tog = e.target.closest ? e.target.closest(\"[data-toggle]\") : null;\n if (tog) { toggleSpan(tog.getAttribute(\"data-toggle\")); return; }\n var evalToggle = e.target.closest ? e.target.closest(\"[data-evaluate-toggle]\") : null;\n if (evalToggle) { toggleEvalPanel(evalToggle.getAttribute(\"data-evaluate-toggle\")); return; }\n var evalRun = e.target.closest ? e.target.closest(\"[data-evaluate-run]\") : null;\n if (evalRun && state.selectedId) { runEvaluate(state.selectedId, evalRun.getAttribute(\"data-evaluate-run\")); return; }\n var node = e.target.closest ? e.target.closest(\".tnode[data-span], .grow[data-span]\") : null;\n if (node) selectSpan(node.getAttribute(\"data-span\"));\n });\n // Track the instructions textarea live (no re-render on keystroke, so\n // typing never loses focus/cursor position).\n document.getElementById(\"drawer-body\").addEventListener(\"input\", function (e) {\n if (e.target && e.target.id === \"eval-instructions\" && state.selectedSpanId) {\n evalState(state.selectedSpanId).instructions = e.target.value;\n }\n });\n document.getElementById(\"backdrop\").addEventListener(\"click\", closeDrawer);\n document.addEventListener(\"keydown\", function (e) { if (e.key === \"Escape\" || e.keyCode === 27) closeDrawer(); });\n\n // --- Filter / toolbar wiring -----------------------------------------\n function toggleMapKey(map, key) { if (map[key]) delete map[key]; else map[key] = 1; }\n document.getElementById(\"status-chips\").addEventListener(\"click\", function (e) {\n if (!e.target.closest) return;\n // The \"Errors only\" shortcut chip lives in the status group now.\n if (e.target.closest(\"[data-errors]\")) {\n state.filter.errorsOnly = !state.filter.errorsOnly;\n renderChips(); renderList();\n return;\n }\n var b = e.target.closest(\"[data-status]\");\n if (!b) return;\n toggleMapKey(state.filter.statuses, b.getAttribute(\"data-status\"));\n renderChips(); renderList();\n });\n document.getElementById(\"type-chips\").addEventListener(\"click\", function (e) {\n var b = e.target.closest ? e.target.closest(\"[data-type]\") : null;\n if (!b) return;\n toggleMapKey(state.filter.types, b.getAttribute(\"data-type\"));\n renderChips(); renderList();\n });\n document.getElementById(\"session-chips\").addEventListener(\"click\", function (e) {\n var b = e.target.closest ? e.target.closest(\"[data-session]\") : null;\n if (!b) return;\n var s = b.getAttribute(\"data-session\");\n state.filter.sessionId = (state.filter.sessionId === s) ? null : s;\n renderChips(); renderList();\n });\n document.getElementById(\"prompt-chips\").addEventListener(\"click\", function (e) {\n var b = e.target.closest ? e.target.closest(\"[data-prompt]\") : null;\n if (!b) return;\n var p = b.getAttribute(\"data-prompt\");\n state.filter.promptKey = (state.filter.promptKey === p) ? null : p;\n renderChips(); renderList();\n });\n document.getElementById(\"search\").addEventListener(\"input\", function (e) {\n state.filter.text = e.target.value || \"\";\n renderList();\n });\n // One mutually-exclusive grouping dimension, chosen from the Group dropdown.\n // \"\" = no grouping; renderList's precedence chain only ever matches one.\n // Switching dimensions drops stale collapsed-header keys.\n function setGrouping(dim) {\n state.groupBySession = dim === \"session\";\n state.groupByPrompt = dim === \"prompt\";\n state.groupByType = dim === \"type\";\n state.collapsedGroups = {};\n renderList();\n }\n document.getElementById(\"group-by\").addEventListener(\"change\", function (e) {\n setGrouping(e.target.value);\n });\n document.getElementById(\"show-stats\").addEventListener(\"change\", function (e) {\n state.showStats = !!e.target.checked;\n renderStatsPanel();\n });\n document.getElementById(\"clear-filters\").addEventListener(\"click\", function () {\n state.filter = { text: \"\", statuses: {}, types: {}, sessionId: null, promptKey: null, errorsOnly: false };\n document.getElementById(\"search\").value = \"\";\n renderChips(); renderList();\n });\n\n // Back/forward or a manual hash edit re-syncs the open trace/span.\n window.addEventListener(\"hashchange\", function () {\n if (suppressHash) return;\n applyHash();\n });\n\n var THEME_KEY = \"panoptic-theme\";\n var mql = window.matchMedia ? window.matchMedia(\"(prefers-color-scheme: light)\") : null;\n function applyTheme(mode) {\n try { localStorage.setItem(THEME_KEY, mode); } catch (e) {}\n var light = mode === \"light\" || (mode === \"system\" && mql && mql.matches);\n document.documentElement.setAttribute(\"data-theme\", light ? \"light\" : \"dark\");\n var btns = document.querySelectorAll(\"[data-theme-set]\");\n for (var i = 0; i < btns.length; i++) btns[i].classList.toggle(\"active\", btns[i].getAttribute(\"data-theme-set\") === mode);\n }\n document.getElementById(\"theme\").addEventListener(\"click\", function (e) {\n var b = e.target.closest ? e.target.closest(\"[data-theme-set]\") : null;\n if (b) applyTheme(b.getAttribute(\"data-theme-set\"));\n });\n if (mql && mql.addEventListener) mql.addEventListener(\"change\", function () {\n var cur = \"system\";\n try { cur = localStorage.getItem(THEME_KEY) || \"system\"; } catch (e) {}\n if (cur === \"system\") applyTheme(\"system\");\n });\n var savedTheme = \"system\";\n try { savedTheme = localStorage.getItem(THEME_KEY) || \"system\"; } catch (e) {}\n applyTheme(savedTheme);\n\n function poll() {\n Promise.all([\n fetchAuthed(API + \"/aggregate\").then(function (r) { return r.json(); }),\n fetchAuthed(API + \"/traces\").then(function (r) { return r.json(); })\n ]).then(function (res) {\n renderStats(res[0]);\n state.traces = res[1] || [];\n document.getElementById(\"meta\").textContent = state.traces.length + \" trace(s) · live\";\n renderChips();\n renderList();\n // Open whatever the URL hash deep-links to, once the target trace\n // has actually arrived in a poll (it may not be in the first batch).\n if (!state.hashApplied) {\n if (applyHash()) state.hashApplied = true;\n } else if (state.selectedId) {\n var t = findTrace(state.selectedId);\n if (t) { var sig = traceSig(t); if (sig !== state.sig) { renderDrawer(t); state.sig = sig; } }\n }\n }).catch(function (e) {\n document.getElementById(\"meta\").textContent = \"disconnected\";\n });\n }\n\n // FOLLOW-UP: a live socket tail (SSE / WebSocket push) is out of scope\n // for this pass; the dashboard stays on the 2s JSON poll below. When\n // added, it should reuse renderList/renderDrawer and keep the poll as a\n // reconnect fallback.\n poll();\n setInterval(poll, 2000);\n})();\n</script>\n</body>\n</html>`;\n}\n\n/** Escape a string for safe interpolation into static HTML text. */\nfunction escapeHtml(value: string): string {\n return value\n .replace(/&/g, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\")\n .replace(/\"/g, \""\")\n .replace(/'/g, \"'\");\n}\n\n/**\n * Encode free-form text (e.g. `evaluate.instructions`, which an operator\n * could type anything into) as a JS EXPRESSION that reconstructs it at\n * runtime, WITHOUT ever emitting a literal backtick into the served page —\n * the client script is itself built from a TS template literal, so a raw\n * backtick in the output would be a real syntax hazard (see the `BT =\n * String.fromCharCode(96)` construction already in the client script for\n * the same reason). `JSON.stringify` alone doesn't escape backticks (they\n * aren't JSON-significant), so a value containing one is split around it\n * and rejoined with the client's own `BT` constant. No backticks in the\n * input ⇒ a single plain `JSON.stringify(value)` — no unnecessary\n * concatenation in the common case.\n */\nfunction encodeForInlineScript(value: string): string {\n return value\n .split(\"`\")\n .map(part => JSON.stringify(part))\n .join(\" + BT + \");\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAyCA,SAAgB,cACd,UACA,OACA,kBAA2B,OAC3B,8BAAsC,IAC9B;CACR,MAAM,UAAU,GAAG,SAAS;CAC5B,MAAM,YAAY,WAAW,KAAK;CAElC,OAAO;;;;;SAKA,UAAU;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;2BA+MQ,sBAAsB;QACzC,UAAU;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cAwCJ,KAAK,UAAU,OAAO,EAAE;2BACX,KAAK,UAAU,eAAe,EAAE;;wCAEnB,sBAAsB,2BAA2B,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA2oC3F;;AAGA,SAAS,WAAW,OAAuB;CACzC,OAAO,MACJ,QAAQ,MAAM,OAAO,CAAC,CACtB,QAAQ,MAAM,MAAM,CAAC,CACrB,QAAQ,MAAM,MAAM,CAAC,CACrB,QAAQ,MAAM,QAAQ,CAAC,CACvB,QAAQ,MAAM,OAAO;AAC1B;;;;;;;;;;;;;;AAeA,SAAS,sBAAsB,OAAuB;CACpD,OAAO,MACJ,MAAM,GAAG,CAAC,CACV,KAAI,SAAQ,KAAK,UAAU,IAAI,CAAC,CAAC,CACjC,KAAK,UAAU;AACpB"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"format-span-io.mjs","names":[],"sources":["../../../../../../../../ai-panoptic/src/exporters/console/format-span-io.ts"],"sourcesContent":["import type { TraceSpan } from \"../../contracts\";\n\n/** Default cap on how many characters of captured content a console line shows. */\nconst DEFAULT_IO_MAX_CHARS = 500;\n\n/**\n * Render a span's captured content ({@link TraceSpan.input} /\n * {@link TraceSpan.output}) as extra indented console lines, one level\n * below the span's own line:\n *\n * ```text\n * ok agent \"market-research\" — 1794ms, 224 tok, $0.0008\n * in: Research the market for Acme Coffee Roasters …\n * out: Demand is steady; specialty buyers skew premium …\n * ```\n *\n * Returns `[]` when the span carries no content — capture disabled, or a\n * composite node with no own I/O. Each value is stringified (JSON for\n * non-strings), whitespace-collapsed to stay scannable, and truncated to\n * `maxChars` (default {@link DEFAULT_IO_MAX_CHARS}) with an ellipsis. Use\n * the file exporter for the full, untruncated payload.\n *\n * @example\n * formatSpanIO(toolSpan, 1);\n * // [' in: {\"query\":\"specialty coffee demand\"}', ' out: {\"results\":[…]}']\n */\nexport function formatSpanIO(span: TraceSpan, depth = 0, maxChars = DEFAULT_IO_MAX_CHARS): string[] {\n const lines: string[] = [];\n const indent = \" \".repeat(depth + 1);\n\n if (span.input !== undefined) {\n lines.push(`${indent}in: ${preview(span.input, maxChars)}`);\n }\n\n if (span.output !== undefined) {\n lines.push(`${indent}out: ${preview(span.output, maxChars)}`);\n }\n\n return lines;\n}\n\n/**\n * One-line, length-capped preview of a captured value. Strings pass\n * through; everything else is JSON-stringified (falling back to\n * `String()` on a circular / unstringifiable value). Internal whitespace\n * is collapsed so the preview never breaks the tree layout.\n */\nfunction preview(value: unknown, maxChars: number): string {\n const text = typeof value === \"string\" ? value : stringify(value);\n const collapsed = text.replace(/\\s+/g, \" \").trim();\n\n return collapsed.length > maxChars ? `${collapsed.slice(0, maxChars)}…` : collapsed;\n}\n\nfunction stringify(value: unknown): string {\n try {\n return JSON.stringify(value) ?? String(value);\n } catch {\n return String(value);\n }\n}\n"],"mappings":";;AAGA,MAAM,uBAAuB;;;;;;;;;;;;;;;;;;;;;;AAuB7B,SAAgB,aAAa,MAAiB,QAAQ,GAAG,WAAW,sBAAgC;CAClG,MAAM,QAAkB,CAAC;CACzB,MAAM,SAAS,KAAK,OAAO,QAAQ,CAAC;CAEpC,IAAI,KAAK,UAAU,QACjB,MAAM,KAAK,GAAG,OAAO,OAAO,QAAQ,KAAK,OAAO,QAAQ,GAAG;CAG7D,IAAI,KAAK,WAAW,QAClB,MAAM,KAAK,GAAG,OAAO,OAAO,QAAQ,KAAK,QAAQ,QAAQ,GAAG;CAG9D,OAAO;AACT;;;;;;;AAQA,SAAS,QAAQ,OAAgB,UAA0B;CAEzD,MAAM,aADO,OAAO,UAAU,WAAW,QAAQ,UAAU,KAAK,
|
|
1
|
+
{"version":3,"file":"format-span-io.mjs","names":[],"sources":["../../../../../../../../ai-panoptic/src/exporters/console/format-span-io.ts"],"sourcesContent":["import type { TraceSpan } from \"../../contracts\";\n\n/** Default cap on how many characters of captured content a console line shows. */\nconst DEFAULT_IO_MAX_CHARS = 500;\n\n/**\n * Render a span's captured content ({@link TraceSpan.input} /\n * {@link TraceSpan.output}) as extra indented console lines, one level\n * below the span's own line:\n *\n * ```text\n * ok agent \"market-research\" — 1794ms, 224 tok, $0.0008\n * in: Research the market for Acme Coffee Roasters …\n * out: Demand is steady; specialty buyers skew premium …\n * ```\n *\n * Returns `[]` when the span carries no content — capture disabled, or a\n * composite node with no own I/O. Each value is stringified (JSON for\n * non-strings), whitespace-collapsed to stay scannable, and truncated to\n * `maxChars` (default {@link DEFAULT_IO_MAX_CHARS}) with an ellipsis. Use\n * the file exporter for the full, untruncated payload.\n *\n * @example\n * formatSpanIO(toolSpan, 1);\n * // [' in: {\"query\":\"specialty coffee demand\"}', ' out: {\"results\":[…]}']\n */\nexport function formatSpanIO(span: TraceSpan, depth = 0, maxChars = DEFAULT_IO_MAX_CHARS): string[] {\n const lines: string[] = [];\n const indent = \" \".repeat(depth + 1);\n\n if (span.input !== undefined) {\n lines.push(`${indent}in: ${preview(span.input, maxChars)}`);\n }\n\n if (span.output !== undefined) {\n lines.push(`${indent}out: ${preview(span.output, maxChars)}`);\n }\n\n return lines;\n}\n\n/**\n * One-line, length-capped preview of a captured value. Strings pass\n * through; everything else is JSON-stringified (falling back to\n * `String()` on a circular / unstringifiable value). Internal whitespace\n * is collapsed so the preview never breaks the tree layout.\n */\nfunction preview(value: unknown, maxChars: number): string {\n const text = typeof value === \"string\" ? value : stringify(value);\n const collapsed = text.replace(/\\s+/g, \" \").trim();\n\n return collapsed.length > maxChars ? `${collapsed.slice(0, maxChars)}…` : collapsed;\n}\n\nfunction stringify(value: unknown): string {\n try {\n return JSON.stringify(value) ?? String(value);\n } catch {\n return String(value);\n }\n}\n"],"mappings":";;AAGA,MAAM,uBAAuB;;;;;;;;;;;;;;;;;;;;;;AAuB7B,SAAgB,aAAa,MAAiB,QAAQ,GAAG,WAAW,sBAAgC;CAClG,MAAM,QAAkB,CAAC;CACzB,MAAM,SAAS,KAAK,OAAO,QAAQ,CAAC;CAEpC,IAAI,KAAK,UAAU,QACjB,MAAM,KAAK,GAAG,OAAO,OAAO,QAAQ,KAAK,OAAO,QAAQ,GAAG;CAG7D,IAAI,KAAK,WAAW,QAClB,MAAM,KAAK,GAAG,OAAO,OAAO,QAAQ,KAAK,QAAQ,QAAQ,GAAG;CAG9D,OAAO;AACT;;;;;;;AAQA,SAAS,QAAQ,OAAgB,UAA0B;CAEzD,MAAM,aADO,OAAO,UAAU,WAAW,QAAQ,UAAU,KAAK,EAC1C,CAAC,QAAQ,QAAQ,GAAG,CAAC,CAAC,KAAK;CAEjD,OAAO,UAAU,SAAS,WAAW,GAAG,UAAU,MAAM,GAAG,QAAQ,EAAE,KAAK;AAC5E;AAEA,SAAS,UAAU,OAAwB;CACzC,IAAI;EACF,OAAO,KAAK,UAAU,KAAK,KAAK,OAAO,KAAK;CAC9C,QAAQ;EACN,OAAO,OAAO,KAAK;CACrB;AACF"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"file-exporter.mjs","names":[],"sources":["../../../../../../../../ai-panoptic/src/exporters/file/file-exporter.ts"],"sourcesContent":["import { appendFile, mkdir } from \"node:fs/promises\";\nimport { dirname } from \"node:path\";\nimport type { ExporterContract, Trace } from \"../../contracts\";\nimport type { FileExporterOptions, TraceRecord } from \"./file-exporter.type\";\n\nconst EXPORTER_NAME = \"file\";\n\n/**\n * Zero-dependency {@link ExporterContract} that appends completed traces\n * to a JSON-Lines file (one JSON record per line by default). Buffers in\n * memory and flushes either every `flushEvery` traces or on an explicit\n * `flush()` / `shutdown()`, so a batch of traces costs one append.\n *\n * Useful as a durable local sink (replay traces later, ship the file to\n * a backend out of band) and as a test fixture for the pipeline without\n * a vendor SDK.\n *\n * @example\n * collector.use(fileExporter({ path: \"storage/traces.jsonl\" }));\n * // on shutdown:\n * await collector.shutdown(); // drains the buffer\n */\nexport function fileExporter(options: FileExporterOptions): ExporterContract {\n const writer = new FileTraceWriter(options);\n\n return {\n name: EXPORTER_NAME,\n async export(trace: Trace): Promise<void> {\n await writer.add(trace);\n },\n async flush(): Promise<void> {\n await writer.flush();\n },\n async shutdown(): Promise<void> {\n await writer.flush();\n },\n };\n}\n\n/**\n * Internal buffered writer for {@link fileExporter}. Owns the pending\n * trace buffer and the directory-created guard across the exporter's\n * lifetime; kept unexported so callers only ever see the factory.\n */\nclass FileTraceWriter {\n private readonly path: string;\n private readonly flushEvery: number;\n private readonly pretty: boolean;\n private buffer: TraceRecord[] = [];\n private directoryReady = false;\n\n public constructor(options: FileExporterOptions) {\n this.path = options.path;\n this.flushEvery = Math.max(1, options.flushEvery ?? 1);\n this.pretty = options.pretty ?? false;\n }\n\n /**\n * Buffer one trace and flush when the buffer reaches `flushEvery`.\n */\n public async add(trace: Trace): Promise<void> {\n this.buffer.push({\n type: \"trace\",\n exportedAt: new Date().toISOString(),\n trace,\n });\n\n if (this.buffer.length >= this.flushEvery) {\n await this.flush();\n }\n }\n\n /**\n * Serialize and append every buffered record, then clear the buffer.\n * No-op when nothing is pending so callers can flush defensively.\n */\n public async flush(): Promise<void> {\n if (this.buffer.length === 0) {\n return;\n }\n\n const pending = this.buffer;\n this.buffer = [];\n\n await this.ensureDirectory();\n\n const payload = pending.map((record) => this.serialize(record)).join(\"\");\n\n await appendFile(this.path, payload, \"utf8\");\n }\n\n /**\n * Create the parent directory once, lazily, on the first write. Stores\n * a guard so subsequent flushes skip the syscall.\n */\n private async ensureDirectory(): Promise<void> {\n if (this.directoryReady) {\n return;\n }\n\n await mkdir(dirname(this.path), { recursive: true });\n this.directoryReady = true;\n }\n\n /**\n * Render one record as a newline-terminated JSON string. Pretty mode\n * indents for human reading; compact mode keeps the file valid JSON\n * Lines (exactly one record per physical line).\n */\n private serialize(record: TraceRecord): string {\n const json = this.pretty\n ? JSON.stringify(record, undefined, 2)\n : JSON.stringify(record);\n\n return `${json}\\n`;\n }\n}\n"],"mappings":";;;;AAKA,MAAM,gBAAgB;;;;;;;;;;;;;;;;AAiBtB,SAAgB,aAAa,SAAgD;CAC3E,MAAM,SAAS,IAAI,gBAAgB,OAAO;CAE1C,OAAO;EACL,MAAM;EACN,MAAM,OAAO,OAA6B;GACxC,MAAM,OAAO,IAAI,KAAK;EACxB;EACA,MAAM,QAAuB;GAC3B,MAAM,OAAO,MAAM;EACrB;EACA,MAAM,WAA0B;GAC9B,MAAM,OAAO,MAAM;EACrB;CACF;AACF;;;;;;AAOA,IAAM,kBAAN,MAAsB;CACpB,AAAiB;CACjB,AAAiB;CACjB,AAAiB;CACjB,AAAQ,SAAwB,CAAC;CACjC,AAAQ,iBAAiB;CAEzB,AAAO,YAAY,SAA8B;EAC/C,KAAK,OAAO,QAAQ;EACpB,KAAK,aAAa,KAAK,IAAI,GAAG,QAAQ,cAAc,CAAC;EACrD,KAAK,SAAS,QAAQ,UAAU;CAClC;;;;CAKA,MAAa,IAAI,OAA6B;EAC5C,KAAK,OAAO,KAAK;GACf,MAAM;GACN,6BAAY,IAAI,KAAK,
|
|
1
|
+
{"version":3,"file":"file-exporter.mjs","names":[],"sources":["../../../../../../../../ai-panoptic/src/exporters/file/file-exporter.ts"],"sourcesContent":["import { appendFile, mkdir } from \"node:fs/promises\";\nimport { dirname } from \"node:path\";\nimport type { ExporterContract, Trace } from \"../../contracts\";\nimport type { FileExporterOptions, TraceRecord } from \"./file-exporter.type\";\n\nconst EXPORTER_NAME = \"file\";\n\n/**\n * Zero-dependency {@link ExporterContract} that appends completed traces\n * to a JSON-Lines file (one JSON record per line by default). Buffers in\n * memory and flushes either every `flushEvery` traces or on an explicit\n * `flush()` / `shutdown()`, so a batch of traces costs one append.\n *\n * Useful as a durable local sink (replay traces later, ship the file to\n * a backend out of band) and as a test fixture for the pipeline without\n * a vendor SDK.\n *\n * @example\n * collector.use(fileExporter({ path: \"storage/traces.jsonl\" }));\n * // on shutdown:\n * await collector.shutdown(); // drains the buffer\n */\nexport function fileExporter(options: FileExporterOptions): ExporterContract {\n const writer = new FileTraceWriter(options);\n\n return {\n name: EXPORTER_NAME,\n async export(trace: Trace): Promise<void> {\n await writer.add(trace);\n },\n async flush(): Promise<void> {\n await writer.flush();\n },\n async shutdown(): Promise<void> {\n await writer.flush();\n },\n };\n}\n\n/**\n * Internal buffered writer for {@link fileExporter}. Owns the pending\n * trace buffer and the directory-created guard across the exporter's\n * lifetime; kept unexported so callers only ever see the factory.\n */\nclass FileTraceWriter {\n private readonly path: string;\n private readonly flushEvery: number;\n private readonly pretty: boolean;\n private buffer: TraceRecord[] = [];\n private directoryReady = false;\n\n public constructor(options: FileExporterOptions) {\n this.path = options.path;\n this.flushEvery = Math.max(1, options.flushEvery ?? 1);\n this.pretty = options.pretty ?? false;\n }\n\n /**\n * Buffer one trace and flush when the buffer reaches `flushEvery`.\n */\n public async add(trace: Trace): Promise<void> {\n this.buffer.push({\n type: \"trace\",\n exportedAt: new Date().toISOString(),\n trace,\n });\n\n if (this.buffer.length >= this.flushEvery) {\n await this.flush();\n }\n }\n\n /**\n * Serialize and append every buffered record, then clear the buffer.\n * No-op when nothing is pending so callers can flush defensively.\n */\n public async flush(): Promise<void> {\n if (this.buffer.length === 0) {\n return;\n }\n\n const pending = this.buffer;\n this.buffer = [];\n\n await this.ensureDirectory();\n\n const payload = pending.map((record) => this.serialize(record)).join(\"\");\n\n await appendFile(this.path, payload, \"utf8\");\n }\n\n /**\n * Create the parent directory once, lazily, on the first write. Stores\n * a guard so subsequent flushes skip the syscall.\n */\n private async ensureDirectory(): Promise<void> {\n if (this.directoryReady) {\n return;\n }\n\n await mkdir(dirname(this.path), { recursive: true });\n this.directoryReady = true;\n }\n\n /**\n * Render one record as a newline-terminated JSON string. Pretty mode\n * indents for human reading; compact mode keeps the file valid JSON\n * Lines (exactly one record per physical line).\n */\n private serialize(record: TraceRecord): string {\n const json = this.pretty\n ? JSON.stringify(record, undefined, 2)\n : JSON.stringify(record);\n\n return `${json}\\n`;\n }\n}\n"],"mappings":";;;;AAKA,MAAM,gBAAgB;;;;;;;;;;;;;;;;AAiBtB,SAAgB,aAAa,SAAgD;CAC3E,MAAM,SAAS,IAAI,gBAAgB,OAAO;CAE1C,OAAO;EACL,MAAM;EACN,MAAM,OAAO,OAA6B;GACxC,MAAM,OAAO,IAAI,KAAK;EACxB;EACA,MAAM,QAAuB;GAC3B,MAAM,OAAO,MAAM;EACrB;EACA,MAAM,WAA0B;GAC9B,MAAM,OAAO,MAAM;EACrB;CACF;AACF;;;;;;AAOA,IAAM,kBAAN,MAAsB;CACpB,AAAiB;CACjB,AAAiB;CACjB,AAAiB;CACjB,AAAQ,SAAwB,CAAC;CACjC,AAAQ,iBAAiB;CAEzB,AAAO,YAAY,SAA8B;EAC/C,KAAK,OAAO,QAAQ;EACpB,KAAK,aAAa,KAAK,IAAI,GAAG,QAAQ,cAAc,CAAC;EACrD,KAAK,SAAS,QAAQ,UAAU;CAClC;;;;CAKA,MAAa,IAAI,OAA6B;EAC5C,KAAK,OAAO,KAAK;GACf,MAAM;GACN,6BAAY,IAAI,KAAK,EAAC,CAAC,YAAY;GACnC;EACF,CAAC;EAED,IAAI,KAAK,OAAO,UAAU,KAAK,YAC7B,MAAM,KAAK,MAAM;CAErB;;;;;CAMA,MAAa,QAAuB;EAClC,IAAI,KAAK,OAAO,WAAW,GACzB;EAGF,MAAM,UAAU,KAAK;EACrB,KAAK,SAAS,CAAC;EAEf,MAAM,KAAK,gBAAgB;EAE3B,MAAM,UAAU,QAAQ,KAAK,WAAW,KAAK,UAAU,MAAM,CAAC,CAAC,CAAC,KAAK,EAAE;EAEvE,MAAM,WAAW,KAAK,MAAM,SAAS,MAAM;CAC7C;;;;;CAMA,MAAc,kBAAiC;EAC7C,IAAI,KAAK,gBACP;EAGF,MAAM,MAAM,QAAQ,KAAK,IAAI,GAAG,EAAE,WAAW,KAAK,CAAC;EACnD,KAAK,iBAAiB;CACxB;;;;;;CAOA,AAAQ,UAAU,QAA6B;EAK7C,OAAO,GAJM,KAAK,SACd,KAAK,UAAU,QAAQ,QAAW,CAAC,IACnC,KAAK,UAAU,MAAM,EAEV;CACjB;AACF"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"langfuse-exporter.mjs","names":[],"sources":["../../../../../../../../ai-panoptic/src/exporters/langfuse/langfuse-exporter.ts"],"sourcesContent":["import type { ExporterContract, Trace, TraceSpan } from \"../../contracts\";\nimport type { AttributeValue } from \"../utils\";\nimport { GEN_AI_ATTRIBUTES, toGenAiAttributes, WARLOCK_ATTRIBUTES } from \"../utils\";\nimport type {\n LangfuseClientLike,\n LangfuseExporterOptions,\n LangfuseObservationBody,\n LangfuseObservationLevel,\n LangfuseObservationLike,\n LangfuseTraceBody,\n LangfuseTraceLike,\n} from \"./langfuse-exporter.type\";\n\nconst EXPORTER_NAME = \"langfuse\";\n\n// ============================================================\n// Lazily-loaded langfuse SDK (OPTIONAL peer)\n// ============================================================\n\nlet LangfuseSdk: typeof import(\"langfuse\");\nlet isModuleExists: boolean | null = null;\nlet loadingPromise: Promise<void> | undefined;\n\nconst LANGFUSE_INSTALL_INSTRUCTIONS = `\nThe Panoptic Langfuse exporter requires the langfuse package.\nInstall it with:\n\n npm install langfuse\n\nOr with your preferred package manager:\n\n pnpm add langfuse\n yarn add langfuse\n`.trim();\n\n/**\n * Settle the lazy import of `langfuse` once, concurrency-safe. Only\n * needed when the caller did not pass a ready `client`. A bare `catch`\n * flips the flag to `false`; the curated install string surfaces at use\n * time, never a raw module-resolution stack trace.\n */\nfunction loadLangfuse(): Promise<void> {\n if (isModuleExists !== null) {\n return Promise.resolve();\n }\n\n if (loadingPromise) {\n return loadingPromise;\n }\n\n loadingPromise = (async () => {\n try {\n LangfuseSdk = await import(\"langfuse\");\n isModuleExists = true;\n } catch {\n isModuleExists = false;\n }\n })();\n\n return loadingPromise;\n}\n\n/**\n * {@link ExporterContract} that maps Panoptic traces onto Langfuse\n * traces and observations. Lazily imports `langfuse` so it stays an\n * OPTIONAL peer — importing this module never forces the SDK to be\n * installed, and a missing SDK surfaces as a curated \"install this\"\n * error when the exporter first needs to build a client.\n *\n * The root {@link TraceSpan} becomes a Langfuse trace AND its top\n * observation (so the root execution's own tokens are metered, not just\n * its children's); every node is an observation — a `generation` when it\n * metered its OWN tokens (LLM-backed agents, supervisors), otherwise a\n * plain `span` (tools, callbacks, and composite nodes whose tokens came\n * only from children). Timing, status, and version map 1:1; each\n * generation reports its own token usage (rolled-up minus children) so\n * the trace total isn't double-counted; non-usage GenAI attributes ride\n * along as metadata, and captured content (under `captureContent`)\n * surfaces as native `input` / `output`.\n *\n * @example\n * collector.use(langfuseExporter({ publicKey: \"pk-...\", secretKey: \"sk-...\" }));\n * // or reuse an existing client:\n * collector.use(langfuseExporter({ client: myLangfuse }));\n */\nexport function langfuseExporter(options: LangfuseExporterOptions): ExporterContract {\n let client: LangfuseClientLike | undefined = options.client;\n\n if (!client) {\n loadLangfuse();\n }\n\n const resolveClient = async (): Promise<LangfuseClientLike> => {\n if (client) {\n return client;\n }\n\n await loadLangfuse();\n\n if (!isModuleExists) {\n throw new Error(LANGFUSE_INSTALL_INSTRUCTIONS);\n }\n\n client = new LangfuseSdk.Langfuse({\n publicKey: options.publicKey,\n secretKey: options.secretKey,\n baseUrl: options.baseUrl,\n }) as unknown as LangfuseClientLike;\n\n return client;\n };\n\n return {\n name: EXPORTER_NAME,\n async export(trace: Trace): Promise<void> {\n const activeClient = await resolveClient();\n emitTrace(activeClient, trace);\n },\n async flush(): Promise<void> {\n if (!client) {\n return;\n }\n\n await client.flushAsync();\n },\n async shutdown(): Promise<void> {\n if (!client) {\n return;\n }\n\n await client.shutdownAsync();\n },\n };\n}\n\n/**\n * Create the Langfuse trace from the root span, then recurse the\n * children into nested observations.\n */\nfunction emitTrace(client: LangfuseClientLike, trace: Trace): void {\n const root = trace.root;\n\n const traceBody: LangfuseTraceBody = {\n id: root.traceId,\n name: root.name,\n sessionId: trace.sessionId,\n version: root.version,\n timestamp: new Date(root.startedAt),\n metadata: langfuseMetadata(root),\n };\n\n // Surface the root's captured content at the TRACE level too — not only on\n // the root observation — so Langfuse's trace Preview shows the top-level\n // input/output instead of \"this trace received no input/output\". Present\n // only under content capture, and only for roots that carry I/O (an agent\n // or tool; a composite root like a planner has none of its own).\n if (root.input !== undefined) {\n traceBody.input = root.input;\n }\n\n if (root.output !== undefined) {\n traceBody.output = root.output;\n }\n\n const langfuseTrace = client.trace(traceBody);\n\n // Emit the ROOT as an observation too — not just its children — so the\n // root execution's OWN tokens are metered. Langfuse derives the trace\n // total by summing observation usage; leaving the root (often the single\n // top-level agent) as trace-only would drop its own spend. Children nest\n // under the root observation, mirroring the execution tree, and own-usage\n // metering telescopes the per-node sums back to the true trace total.\n emitObservation(langfuseTrace, root);\n}\n\n/**\n * Map one {@link TraceSpan} onto a Langfuse observation under `parent`,\n * then recurse its children. Spans that metered their OWN tokens become\n * `generation`s; everything else (tools, callbacks, composite nodes)\n * becomes a plain `span`.\n */\nfunction emitObservation(\n parent: LangfuseTraceLike | LangfuseObservationLike,\n span: TraceSpan,\n): void {\n const body: LangfuseObservationBody = {\n id: span.spanId,\n name: span.name,\n startTime: new Date(span.startedAt),\n endTime: new Date(span.endedAt),\n level: toLevel(span),\n statusMessage: span.error?.message,\n version: span.version,\n metadata: langfuseMetadata(span),\n };\n\n // Captured content (only present under `captureContent`) maps onto\n // Langfuse's native observation input/output.\n if (span.input !== undefined) {\n body.input = span.input;\n }\n\n if (span.output !== undefined) {\n body.output = span.output;\n }\n\n let observation: LangfuseObservationLike;\n\n // Classify + meter on OWN tokens (this node's rolled-up usage minus its\n // children's), not the subtree rollup. A composite node whose tokens\n // came only from descendants has zero own-usage and becomes a plain\n // span — so its children's tokens aren't counted twice in the trace\n // total Langfuse sums across observations.\n const own = ownUsage(span);\n\n if (own.total > 0) {\n body.usage = {\n input: own.input,\n output: own.output,\n total: own.total,\n unit: \"TOKENS\",\n };\n observation = parent.generation(body);\n } else {\n observation = parent.span(body);\n }\n\n for (const child of span.children) {\n emitObservation(observation, child);\n }\n\n // Explicitly end the observation. The body already carries `endTime`,\n // so this is idempotent — but the SDK only finalizes (and flushes) an\n // observation on `end()`, so without it long-lived clients can leave\n // observations open. Safe against the local `LangfuseObservationLike`\n // shape, which declares `end(body?)`.\n observation.end({ endTime: body.endTime });\n}\n\n/**\n * Own token usage for a span — its rolled-up {@link TraceSpan.usage}\n * minus the rolled-up usage of its direct children. `TraceSpan.usage` is\n * the subtree total (this node plus every descendant), so subtracting the\n * children leaves the tokens THIS node alone metered, clamped at zero\n * defensively.\n *\n * Langfuse sums observation usage into the trace total, so reporting\n * own-usage on each generation (rather than the subtree rollup) is what\n * keeps the trace total correct instead of multiply-counting nested\n * spans. A composite node with no own tokens (e.g. a workflow whose\n * tokens all came from agent children) yields `total: 0` and is emitted\n * as a plain span, not a generation.\n */\nfunction ownUsage(span: TraceSpan): { input: number; output: number; total: number } {\n let childInput = 0;\n let childOutput = 0;\n let childTotal = 0;\n\n for (const child of span.children) {\n childInput += child.usage.input;\n childOutput += child.usage.output;\n childTotal += child.usage.total;\n }\n\n return {\n input: Math.max(0, span.usage.input - childInput),\n output: Math.max(0, span.usage.output - childOutput),\n total: Math.max(0, span.usage.total - childTotal),\n };\n}\n\n/**\n * Token-usage + cost keys that the per-observation `usage` block already\n * carries authoritatively (as OWN usage). Omitting them from `metadata`\n * avoids a confusing contradiction — metadata would otherwise show the\n * rolled-up subtree totals next to an own-usage `usage` block. Cost is\n * omitted for the same reason; Langfuse prices the own tokens itself.\n */\nconst LANGFUSE_METADATA_OMIT = new Set<string>([\n GEN_AI_ATTRIBUTES.usageInputTokens,\n GEN_AI_ATTRIBUTES.usageOutputTokens,\n WARLOCK_ATTRIBUTES.totalTokens,\n WARLOCK_ATTRIBUTES.cachedTokens,\n WARLOCK_ATTRIBUTES.reasoningTokens,\n WARLOCK_ATTRIBUTES.costUsd,\n]);\n\n/**\n * Observation metadata — the GenAI attribute set minus the usage/cost\n * keys that live authoritatively on the `usage` block (see\n * {@link LANGFUSE_METADATA_OMIT}). Keeps model identity, report type,\n * version, session id, and any collector-set attributes.\n */\nfunction langfuseMetadata(span: TraceSpan): Record<string, AttributeValue> {\n const all = toGenAiAttributes(span);\n const metadata: Record<string, AttributeValue> = {};\n\n for (const [key, value] of Object.entries(all)) {\n if (!LANGFUSE_METADATA_OMIT.has(key)) {\n metadata[key] = value;\n }\n }\n\n return metadata;\n}\n\n/**\n * Map the Panoptic span status onto a Langfuse observation level —\n * failed/cancelled spans surface as `ERROR`, everything else `DEFAULT`.\n */\nfunction toLevel(span: TraceSpan): LangfuseObservationLevel {\n if (span.status === \"failed\" || span.status === \"cancelled\") {\n return \"ERROR\";\n }\n\n return \"DEFAULT\";\n}\n"],"mappings":";;;AAaA,MAAM,gBAAgB;AAMtB,IAAI;AACJ,IAAI,iBAAiC;AACrC,IAAI;AAEJ,MAAM,gCAAgC;;;;;;;;;;EAUpC,KAAK;;;;;;;AAQP,SAAS,eAA8B;CACrC,IAAI,mBAAmB,MACrB,OAAO,QAAQ,QAAQ;CAGzB,IAAI,gBACF,OAAO;CAGT,kBAAkB,YAAY;EAC5B,IAAI;GACF,cAAc,MAAM,OAAO;GAC3B,iBAAiB;EACnB,QAAQ;GACN,iBAAiB;EACnB;CACF,GAAG;CAEH,OAAO;AACT;;;;;;;;;;;;;;;;;;;;;;;;AAyBA,SAAgB,iBAAiB,SAAoD;CACnF,IAAI,SAAyC,QAAQ;CAErD,IAAI,CAAC,QACH,aAAa;CAGf,MAAM,gBAAgB,YAAyC;EAC7D,IAAI,QACF,OAAO;EAGT,MAAM,aAAa;EAEnB,IAAI,CAAC,gBACH,MAAM,IAAI,MAAM,6BAA6B;EAG/C,SAAS,IAAI,YAAY,SAAS;GAChC,WAAW,QAAQ;GACnB,WAAW,QAAQ;GACnB,SAAS,QAAQ;EACnB,CAAC;EAED,OAAO;CACT;CAEA,OAAO;EACL,MAAM;EACN,MAAM,OAAO,OAA6B;GAExC,UAAU,MADiB,cAAc,GACjB,KAAK;EAC/B;EACA,MAAM,QAAuB;GAC3B,IAAI,CAAC,QACH;GAGF,MAAM,OAAO,WAAW;EAC1B;EACA,MAAM,WAA0B;GAC9B,IAAI,CAAC,QACH;GAGF,MAAM,OAAO,cAAc;EAC7B;CACF;AACF;;;;;AAMA,SAAS,UAAU,QAA4B,OAAoB;CACjE,MAAM,OAAO,MAAM;CAEnB,MAAM,YAA+B;EACnC,IAAI,KAAK;EACT,MAAM,KAAK;EACX,WAAW,MAAM;EACjB,SAAS,KAAK;EACd,WAAW,IAAI,KAAK,KAAK,SAAS;EAClC,UAAU,iBAAiB,IAAI;CACjC;CAOA,IAAI,KAAK,UAAU,QACjB,UAAU,QAAQ,KAAK;CAGzB,IAAI,KAAK,WAAW,QAClB,UAAU,SAAS,KAAK;CAW1B,gBARsB,OAAO,MAAM,SAQP,GAAG,IAAI;AACrC;;;;;;;AAQA,SAAS,gBACP,QACA,MACM;CACN,MAAM,OAAgC;EACpC,IAAI,KAAK;EACT,MAAM,KAAK;EACX,WAAW,IAAI,KAAK,KAAK,SAAS;EAClC,SAAS,IAAI,KAAK,KAAK,OAAO;EAC9B,OAAO,QAAQ,IAAI;EACnB,eAAe,KAAK,OAAO;EAC3B,SAAS,KAAK;EACd,UAAU,iBAAiB,IAAI;CACjC;CAIA,IAAI,KAAK,UAAU,QACjB,KAAK,QAAQ,KAAK;CAGpB,IAAI,KAAK,WAAW,QAClB,KAAK,SAAS,KAAK;CAGrB,IAAI;CAOJ,MAAM,MAAM,SAAS,IAAI;CAEzB,IAAI,IAAI,QAAQ,GAAG;EACjB,KAAK,QAAQ;GACX,OAAO,IAAI;GACX,QAAQ,IAAI;GACZ,OAAO,IAAI;GACX,MAAM;EACR;EACA,cAAc,OAAO,WAAW,IAAI;CACtC,OACE,cAAc,OAAO,KAAK,IAAI;CAGhC,KAAK,MAAM,SAAS,KAAK,UACvB,gBAAgB,aAAa,KAAK;CAQpC,YAAY,IAAI,EAAE,SAAS,KAAK,QAAQ,CAAC;AAC3C;;;;;;;;;;;;;;;AAgBA,SAAS,SAAS,MAAmE;CACnF,IAAI,aAAa;CACjB,IAAI,cAAc;CAClB,IAAI,aAAa;CAEjB,KAAK,MAAM,SAAS,KAAK,UAAU;EACjC,cAAc,MAAM,MAAM;EAC1B,eAAe,MAAM,MAAM;EAC3B,cAAc,MAAM,MAAM;CAC5B;CAEA,OAAO;EACL,OAAO,KAAK,IAAI,GAAG,KAAK,MAAM,QAAQ,UAAU;EAChD,QAAQ,KAAK,IAAI,GAAG,KAAK,MAAM,SAAS,WAAW;EACnD,OAAO,KAAK,IAAI,GAAG,KAAK,MAAM,QAAQ,UAAU;CAClD;AACF;;;;;;;;AASA,MAAM,yBAAyB,IAAI,IAAY;CAC7C,kBAAkB;CAClB,kBAAkB;CAClB,mBAAmB;CACnB,mBAAmB;CACnB,mBAAmB;CACnB,mBAAmB;AACrB,CAAC;;;;;;;AAQD,SAAS,iBAAiB,MAAiD;CACzE,MAAM,MAAM,kBAAkB,IAAI;CAClC,MAAM,WAA2C,CAAC;CAElD,KAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,GAAG,GAC3C,IAAI,CAAC,uBAAuB,IAAI,GAAG,GACjC,SAAS,OAAO;CAIpB,OAAO;AACT;;;;;AAMA,SAAS,QAAQ,MAA2C;CAC1D,IAAI,KAAK,WAAW,YAAY,KAAK,WAAW,aAC9C,OAAO;CAGT,OAAO;AACT"}
|
|
1
|
+
{"version":3,"file":"langfuse-exporter.mjs","names":[],"sources":["../../../../../../../../ai-panoptic/src/exporters/langfuse/langfuse-exporter.ts"],"sourcesContent":["import type { ExporterContract, Trace, TraceSpan } from \"../../contracts\";\nimport type { AttributeValue } from \"../utils\";\nimport { GEN_AI_ATTRIBUTES, toGenAiAttributes, WARLOCK_ATTRIBUTES } from \"../utils\";\nimport type {\n LangfuseClientLike,\n LangfuseExporterOptions,\n LangfuseObservationBody,\n LangfuseObservationLevel,\n LangfuseObservationLike,\n LangfuseTraceBody,\n LangfuseTraceLike,\n} from \"./langfuse-exporter.type\";\n\nconst EXPORTER_NAME = \"langfuse\";\n\n// ============================================================\n// Lazily-loaded langfuse SDK (OPTIONAL peer)\n// ============================================================\n\nlet LangfuseSdk: typeof import(\"langfuse\");\nlet isModuleExists: boolean | null = null;\nlet loadingPromise: Promise<void> | undefined;\n\nconst LANGFUSE_INSTALL_INSTRUCTIONS = `\nThe Panoptic Langfuse exporter requires the langfuse package.\nInstall it with:\n\n npm install langfuse\n\nOr with your preferred package manager:\n\n pnpm add langfuse\n yarn add langfuse\n`.trim();\n\n/**\n * Settle the lazy import of `langfuse` once, concurrency-safe. Only\n * needed when the caller did not pass a ready `client`. A bare `catch`\n * flips the flag to `false`; the curated install string surfaces at use\n * time, never a raw module-resolution stack trace.\n */\nfunction loadLangfuse(): Promise<void> {\n if (isModuleExists !== null) {\n return Promise.resolve();\n }\n\n if (loadingPromise) {\n return loadingPromise;\n }\n\n loadingPromise = (async () => {\n try {\n LangfuseSdk = await import(\"langfuse\");\n isModuleExists = true;\n } catch {\n isModuleExists = false;\n }\n })();\n\n return loadingPromise;\n}\n\n/**\n * {@link ExporterContract} that maps Panoptic traces onto Langfuse\n * traces and observations. Lazily imports `langfuse` so it stays an\n * OPTIONAL peer — importing this module never forces the SDK to be\n * installed, and a missing SDK surfaces as a curated \"install this\"\n * error when the exporter first needs to build a client.\n *\n * The root {@link TraceSpan} becomes a Langfuse trace AND its top\n * observation (so the root execution's own tokens are metered, not just\n * its children's); every node is an observation — a `generation` when it\n * metered its OWN tokens (LLM-backed agents, supervisors), otherwise a\n * plain `span` (tools, callbacks, and composite nodes whose tokens came\n * only from children). Timing, status, and version map 1:1; each\n * generation reports its own token usage (rolled-up minus children) so\n * the trace total isn't double-counted; non-usage GenAI attributes ride\n * along as metadata, and captured content (under `captureContent`)\n * surfaces as native `input` / `output`.\n *\n * @example\n * collector.use(langfuseExporter({ publicKey: \"pk-...\", secretKey: \"sk-...\" }));\n * // or reuse an existing client:\n * collector.use(langfuseExporter({ client: myLangfuse }));\n */\nexport function langfuseExporter(options: LangfuseExporterOptions): ExporterContract {\n let client: LangfuseClientLike | undefined = options.client;\n\n if (!client) {\n loadLangfuse();\n }\n\n const resolveClient = async (): Promise<LangfuseClientLike> => {\n if (client) {\n return client;\n }\n\n await loadLangfuse();\n\n if (!isModuleExists) {\n throw new Error(LANGFUSE_INSTALL_INSTRUCTIONS);\n }\n\n client = new LangfuseSdk.Langfuse({\n publicKey: options.publicKey,\n secretKey: options.secretKey,\n baseUrl: options.baseUrl,\n }) as unknown as LangfuseClientLike;\n\n return client;\n };\n\n return {\n name: EXPORTER_NAME,\n async export(trace: Trace): Promise<void> {\n const activeClient = await resolveClient();\n emitTrace(activeClient, trace);\n },\n async flush(): Promise<void> {\n if (!client) {\n return;\n }\n\n await client.flushAsync();\n },\n async shutdown(): Promise<void> {\n if (!client) {\n return;\n }\n\n await client.shutdownAsync();\n },\n };\n}\n\n/**\n * Create the Langfuse trace from the root span, then recurse the\n * children into nested observations.\n */\nfunction emitTrace(client: LangfuseClientLike, trace: Trace): void {\n const root = trace.root;\n\n const traceBody: LangfuseTraceBody = {\n id: root.traceId,\n name: root.name,\n sessionId: trace.sessionId,\n version: root.version,\n timestamp: new Date(root.startedAt),\n metadata: langfuseMetadata(root),\n };\n\n // Surface the root's captured content at the TRACE level too — not only on\n // the root observation — so Langfuse's trace Preview shows the top-level\n // input/output instead of \"this trace received no input/output\". Present\n // only under content capture, and only for roots that carry I/O (an agent\n // or tool; a composite root like a planner has none of its own).\n if (root.input !== undefined) {\n traceBody.input = root.input;\n }\n\n if (root.output !== undefined) {\n traceBody.output = root.output;\n }\n\n const langfuseTrace = client.trace(traceBody);\n\n // Emit the ROOT as an observation too — not just its children — so the\n // root execution's OWN tokens are metered. Langfuse derives the trace\n // total by summing observation usage; leaving the root (often the single\n // top-level agent) as trace-only would drop its own spend. Children nest\n // under the root observation, mirroring the execution tree, and own-usage\n // metering telescopes the per-node sums back to the true trace total.\n emitObservation(langfuseTrace, root);\n}\n\n/**\n * Map one {@link TraceSpan} onto a Langfuse observation under `parent`,\n * then recurse its children. Spans that metered their OWN tokens become\n * `generation`s; everything else (tools, callbacks, composite nodes)\n * becomes a plain `span`.\n */\nfunction emitObservation(\n parent: LangfuseTraceLike | LangfuseObservationLike,\n span: TraceSpan,\n): void {\n const body: LangfuseObservationBody = {\n id: span.spanId,\n name: span.name,\n startTime: new Date(span.startedAt),\n endTime: new Date(span.endedAt),\n level: toLevel(span),\n statusMessage: span.error?.message,\n version: span.version,\n metadata: langfuseMetadata(span),\n };\n\n // Captured content (only present under `captureContent`) maps onto\n // Langfuse's native observation input/output.\n if (span.input !== undefined) {\n body.input = span.input;\n }\n\n if (span.output !== undefined) {\n body.output = span.output;\n }\n\n let observation: LangfuseObservationLike;\n\n // Classify + meter on OWN tokens (this node's rolled-up usage minus its\n // children's), not the subtree rollup. A composite node whose tokens\n // came only from descendants has zero own-usage and becomes a plain\n // span — so its children's tokens aren't counted twice in the trace\n // total Langfuse sums across observations.\n const own = ownUsage(span);\n\n if (own.total > 0) {\n body.usage = {\n input: own.input,\n output: own.output,\n total: own.total,\n unit: \"TOKENS\",\n };\n observation = parent.generation(body);\n } else {\n observation = parent.span(body);\n }\n\n for (const child of span.children) {\n emitObservation(observation, child);\n }\n\n // Explicitly end the observation. The body already carries `endTime`,\n // so this is idempotent — but the SDK only finalizes (and flushes) an\n // observation on `end()`, so without it long-lived clients can leave\n // observations open. Safe against the local `LangfuseObservationLike`\n // shape, which declares `end(body?)`.\n observation.end({ endTime: body.endTime });\n}\n\n/**\n * Own token usage for a span — its rolled-up {@link TraceSpan.usage}\n * minus the rolled-up usage of its direct children. `TraceSpan.usage` is\n * the subtree total (this node plus every descendant), so subtracting the\n * children leaves the tokens THIS node alone metered, clamped at zero\n * defensively.\n *\n * Langfuse sums observation usage into the trace total, so reporting\n * own-usage on each generation (rather than the subtree rollup) is what\n * keeps the trace total correct instead of multiply-counting nested\n * spans. A composite node with no own tokens (e.g. a workflow whose\n * tokens all came from agent children) yields `total: 0` and is emitted\n * as a plain span, not a generation.\n */\nfunction ownUsage(span: TraceSpan): { input: number; output: number; total: number } {\n let childInput = 0;\n let childOutput = 0;\n let childTotal = 0;\n\n for (const child of span.children) {\n childInput += child.usage.input;\n childOutput += child.usage.output;\n childTotal += child.usage.total;\n }\n\n return {\n input: Math.max(0, span.usage.input - childInput),\n output: Math.max(0, span.usage.output - childOutput),\n total: Math.max(0, span.usage.total - childTotal),\n };\n}\n\n/**\n * Token-usage + cost keys that the per-observation `usage` block already\n * carries authoritatively (as OWN usage). Omitting them from `metadata`\n * avoids a confusing contradiction — metadata would otherwise show the\n * rolled-up subtree totals next to an own-usage `usage` block. Cost is\n * omitted for the same reason; Langfuse prices the own tokens itself.\n */\nconst LANGFUSE_METADATA_OMIT = new Set<string>([\n GEN_AI_ATTRIBUTES.usageInputTokens,\n GEN_AI_ATTRIBUTES.usageOutputTokens,\n WARLOCK_ATTRIBUTES.totalTokens,\n WARLOCK_ATTRIBUTES.cachedTokens,\n WARLOCK_ATTRIBUTES.reasoningTokens,\n WARLOCK_ATTRIBUTES.costUsd,\n]);\n\n/**\n * Observation metadata — the GenAI attribute set minus the usage/cost\n * keys that live authoritatively on the `usage` block (see\n * {@link LANGFUSE_METADATA_OMIT}). Keeps model identity, report type,\n * version, session id, and any collector-set attributes.\n */\nfunction langfuseMetadata(span: TraceSpan): Record<string, AttributeValue> {\n const all = toGenAiAttributes(span);\n const metadata: Record<string, AttributeValue> = {};\n\n for (const [key, value] of Object.entries(all)) {\n if (!LANGFUSE_METADATA_OMIT.has(key)) {\n metadata[key] = value;\n }\n }\n\n return metadata;\n}\n\n/**\n * Map the Panoptic span status onto a Langfuse observation level —\n * failed/cancelled spans surface as `ERROR`, everything else `DEFAULT`.\n */\nfunction toLevel(span: TraceSpan): LangfuseObservationLevel {\n if (span.status === \"failed\" || span.status === \"cancelled\") {\n return \"ERROR\";\n }\n\n return \"DEFAULT\";\n}\n"],"mappings":";;;AAaA,MAAM,gBAAgB;AAMtB,IAAI;AACJ,IAAI,iBAAiC;AACrC,IAAI;AAEJ,MAAM,gCAAgC;;;;;;;;;;EAUpC,KAAK;;;;;;;AAQP,SAAS,eAA8B;CACrC,IAAI,mBAAmB,MACrB,OAAO,QAAQ,QAAQ;CAGzB,IAAI,gBACF,OAAO;CAGT,kBAAkB,YAAY;EAC5B,IAAI;GACF,cAAc,MAAM,OAAO;GAC3B,iBAAiB;EACnB,QAAQ;GACN,iBAAiB;EACnB;CACF,EAAC,CAAE;CAEH,OAAO;AACT;;;;;;;;;;;;;;;;;;;;;;;;AAyBA,SAAgB,iBAAiB,SAAoD;CACnF,IAAI,SAAyC,QAAQ;CAErD,IAAI,CAAC,QACH,aAAa;CAGf,MAAM,gBAAgB,YAAyC;EAC7D,IAAI,QACF,OAAO;EAGT,MAAM,aAAa;EAEnB,IAAI,CAAC,gBACH,MAAM,IAAI,MAAM,6BAA6B;EAG/C,SAAS,IAAI,YAAY,SAAS;GAChC,WAAW,QAAQ;GACnB,WAAW,QAAQ;GACnB,SAAS,QAAQ;EACnB,CAAC;EAED,OAAO;CACT;CAEA,OAAO;EACL,MAAM;EACN,MAAM,OAAO,OAA6B;GAExC,UAAU,MADiB,cAAc,GACjB,KAAK;EAC/B;EACA,MAAM,QAAuB;GAC3B,IAAI,CAAC,QACH;GAGF,MAAM,OAAO,WAAW;EAC1B;EACA,MAAM,WAA0B;GAC9B,IAAI,CAAC,QACH;GAGF,MAAM,OAAO,cAAc;EAC7B;CACF;AACF;;;;;AAMA,SAAS,UAAU,QAA4B,OAAoB;CACjE,MAAM,OAAO,MAAM;CAEnB,MAAM,YAA+B;EACnC,IAAI,KAAK;EACT,MAAM,KAAK;EACX,WAAW,MAAM;EACjB,SAAS,KAAK;EACd,WAAW,IAAI,KAAK,KAAK,SAAS;EAClC,UAAU,iBAAiB,IAAI;CACjC;CAOA,IAAI,KAAK,UAAU,QACjB,UAAU,QAAQ,KAAK;CAGzB,IAAI,KAAK,WAAW,QAClB,UAAU,SAAS,KAAK;CAW1B,gBARsB,OAAO,MAAM,SAQP,GAAG,IAAI;AACrC;;;;;;;AAQA,SAAS,gBACP,QACA,MACM;CACN,MAAM,OAAgC;EACpC,IAAI,KAAK;EACT,MAAM,KAAK;EACX,WAAW,IAAI,KAAK,KAAK,SAAS;EAClC,SAAS,IAAI,KAAK,KAAK,OAAO;EAC9B,OAAO,QAAQ,IAAI;EACnB,eAAe,KAAK,OAAO;EAC3B,SAAS,KAAK;EACd,UAAU,iBAAiB,IAAI;CACjC;CAIA,IAAI,KAAK,UAAU,QACjB,KAAK,QAAQ,KAAK;CAGpB,IAAI,KAAK,WAAW,QAClB,KAAK,SAAS,KAAK;CAGrB,IAAI;CAOJ,MAAM,MAAM,SAAS,IAAI;CAEzB,IAAI,IAAI,QAAQ,GAAG;EACjB,KAAK,QAAQ;GACX,OAAO,IAAI;GACX,QAAQ,IAAI;GACZ,OAAO,IAAI;GACX,MAAM;EACR;EACA,cAAc,OAAO,WAAW,IAAI;CACtC,OACE,cAAc,OAAO,KAAK,IAAI;CAGhC,KAAK,MAAM,SAAS,KAAK,UACvB,gBAAgB,aAAa,KAAK;CAQpC,YAAY,IAAI,EAAE,SAAS,KAAK,QAAQ,CAAC;AAC3C;;;;;;;;;;;;;;;AAgBA,SAAS,SAAS,MAAmE;CACnF,IAAI,aAAa;CACjB,IAAI,cAAc;CAClB,IAAI,aAAa;CAEjB,KAAK,MAAM,SAAS,KAAK,UAAU;EACjC,cAAc,MAAM,MAAM;EAC1B,eAAe,MAAM,MAAM;EAC3B,cAAc,MAAM,MAAM;CAC5B;CAEA,OAAO;EACL,OAAO,KAAK,IAAI,GAAG,KAAK,MAAM,QAAQ,UAAU;EAChD,QAAQ,KAAK,IAAI,GAAG,KAAK,MAAM,SAAS,WAAW;EACnD,OAAO,KAAK,IAAI,GAAG,KAAK,MAAM,QAAQ,UAAU;CAClD;AACF;;;;;;;;AASA,MAAM,yBAAyB,IAAI,IAAY;CAC7C,kBAAkB;CAClB,kBAAkB;CAClB,mBAAmB;CACnB,mBAAmB;CACnB,mBAAmB;CACnB,mBAAmB;AACrB,CAAC;;;;;;;AAQD,SAAS,iBAAiB,MAAiD;CACzE,MAAM,MAAM,kBAAkB,IAAI;CAClC,MAAM,WAA2C,CAAC;CAElD,KAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,GAAG,GAC3C,IAAI,CAAC,uBAAuB,IAAI,GAAG,GACjC,SAAS,OAAO;CAIpB,OAAO;AACT;;;;;AAMA,SAAS,QAAQ,MAA2C;CAC1D,IAAI,KAAK,WAAW,YAAY,KAAK,WAAW,aAC9C,OAAO;CAGT,OAAO;AACT"}
|