822 lines
31 KiB
Python
822 lines
31 KiB
Python
#!/usr/bin/env python3
|
|
"""Timeline viewer for Hive LLM debug JSONL sessions.
|
|
|
|
Sister script to ``llm_debug_log_visualizer.py``. Where that one renders
|
|
turn-by-turn cards, this one renders a chronological event timeline so a
|
|
developer can click any event (user input, tool use, tool result, assistant
|
|
text) and inspect the *raw* request payload that was sent to the LLM at that
|
|
moment — system prompt, full tool schemas, full messages array.
|
|
|
|
Usage:
|
|
uv run --no-project scripts/llm_timeline_viewer.py
|
|
uv run --no-project scripts/llm_timeline_viewer.py --session <execution_id>
|
|
uv run --no-project scripts/llm_timeline_viewer.py --port 8080
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import http.server
|
|
import json
|
|
import urllib.parse
|
|
import webbrowser
|
|
from collections import defaultdict
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
@dataclass
|
|
class SessionSummary:
|
|
execution_id: str
|
|
log_file: str
|
|
start_timestamp: str
|
|
end_timestamp: str
|
|
turn_count: int
|
|
streams: list[str]
|
|
nodes: list[str]
|
|
models: list[str]
|
|
|
|
|
|
def _parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument(
|
|
"--logs-dir",
|
|
type=Path,
|
|
default=Path.home() / ".hive" / "llm_logs",
|
|
help="Directory containing Hive LLM debug JSONL files.",
|
|
)
|
|
parser.add_argument("--session", help="Execution ID to select initially.")
|
|
parser.add_argument("--limit-files", type=int, default=200)
|
|
parser.add_argument("--port", type=int, default=0)
|
|
parser.add_argument("--no-open", action="store_true")
|
|
parser.add_argument("--include-tests", action="store_true")
|
|
return parser.parse_args()
|
|
|
|
|
|
def _format_timestamp(raw: str) -> str:
|
|
if not raw:
|
|
return "-"
|
|
try:
|
|
return datetime.fromisoformat(raw).strftime("%Y-%m-%d %H:%M:%S")
|
|
except ValueError:
|
|
return raw
|
|
|
|
|
|
def _is_test_session(execution_id: str, records: list[dict[str, Any]]) -> bool:
|
|
if execution_id.startswith("<MagicMock"):
|
|
return True
|
|
models = {
|
|
str(r.get("token_counts", {}).get("model", "")) for r in records if isinstance(r.get("token_counts"), dict)
|
|
}
|
|
models.discard("")
|
|
if models and models <= {"mock"}:
|
|
return True
|
|
if not models:
|
|
return True
|
|
return False
|
|
|
|
|
|
def _discover_session_summaries(logs_dir: Path, limit_files: int, include_tests: bool) -> list[SessionSummary]:
|
|
if not logs_dir.exists():
|
|
raise FileNotFoundError(f"log directory not found: {logs_dir}")
|
|
|
|
files = sorted(
|
|
[p for p in logs_dir.iterdir() if p.is_file() and p.suffix == ".jsonl"],
|
|
key=lambda p: p.stat().st_mtime,
|
|
reverse=True,
|
|
)[:limit_files]
|
|
|
|
by_session: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
for path in files:
|
|
try:
|
|
with path.open(encoding="utf-8") as handle:
|
|
for raw_line in handle:
|
|
line = raw_line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
payload = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
eid = str(payload.get("execution_id") or "").strip()
|
|
if not eid:
|
|
continue
|
|
minimal = {
|
|
"timestamp": payload.get("timestamp", ""),
|
|
"iteration": payload.get("iteration", 0),
|
|
"stream_id": payload.get("stream_id", ""),
|
|
"node_id": payload.get("node_id", ""),
|
|
"token_counts": payload.get("token_counts", {}),
|
|
"_log_file": str(path),
|
|
}
|
|
by_session[eid].append(minimal)
|
|
except OSError:
|
|
continue
|
|
|
|
if not include_tests:
|
|
by_session = {eid: recs for eid, recs in by_session.items() if not _is_test_session(eid, recs)}
|
|
|
|
summaries: list[SessionSummary] = []
|
|
for eid, recs in by_session.items():
|
|
recs.sort(key=lambda r: (str(r.get("timestamp", "")), r.get("iteration", 0)))
|
|
first, last = recs[0], recs[-1]
|
|
summaries.append(
|
|
SessionSummary(
|
|
execution_id=eid,
|
|
log_file=str(first.get("_log_file", "")),
|
|
start_timestamp=str(first.get("timestamp", "")),
|
|
end_timestamp=str(last.get("timestamp", "")),
|
|
turn_count=len(recs),
|
|
streams=sorted({str(r.get("stream_id", "")) for r in recs if r.get("stream_id")}),
|
|
nodes=sorted({str(r.get("node_id", "")) for r in recs if r.get("node_id")}),
|
|
models=sorted(
|
|
{
|
|
str(r.get("token_counts", {}).get("model", ""))
|
|
for r in recs
|
|
if isinstance(r.get("token_counts"), dict) and r.get("token_counts", {}).get("model")
|
|
}
|
|
),
|
|
)
|
|
)
|
|
|
|
summaries.sort(key=lambda s: s.start_timestamp, reverse=True)
|
|
return summaries
|
|
|
|
|
|
def _load_session_data(logs_dir: Path, session_id: str, limit_files: int) -> list[dict[str, Any]] | None:
|
|
if not logs_dir.exists():
|
|
return None
|
|
|
|
files = sorted(
|
|
[p for p in logs_dir.iterdir() if p.is_file() and p.suffix == ".jsonl"],
|
|
key=lambda p: p.stat().st_mtime,
|
|
reverse=True,
|
|
)[:limit_files]
|
|
|
|
records: list[dict[str, Any]] = []
|
|
for path in files:
|
|
try:
|
|
with path.open(encoding="utf-8") as handle:
|
|
for line_number, raw_line in enumerate(handle, start=1):
|
|
line = raw_line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
payload = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
payload = {
|
|
"timestamp": "",
|
|
"execution_id": "",
|
|
"_parse_error": f"{path.name}:{line_number}",
|
|
"_raw_line": line,
|
|
}
|
|
if str(payload.get("execution_id") or "").strip() == session_id:
|
|
payload["_log_file"] = str(path)
|
|
records.append(payload)
|
|
except OSError:
|
|
continue
|
|
|
|
if not records:
|
|
return None
|
|
records.sort(key=lambda r: (str(r.get("timestamp", "")), r.get("iteration", 0)))
|
|
return records
|
|
|
|
|
|
def _render_html(summaries: list[SessionSummary], initial_session_id: str) -> str:
|
|
summaries_data = [
|
|
{
|
|
"execution_id": s.execution_id,
|
|
"log_file": s.log_file,
|
|
"start_timestamp": s.start_timestamp,
|
|
"end_timestamp": s.end_timestamp,
|
|
"start_display": _format_timestamp(s.start_timestamp),
|
|
"end_display": _format_timestamp(s.end_timestamp),
|
|
"turn_count": s.turn_count,
|
|
"streams": s.streams,
|
|
"nodes": s.nodes,
|
|
"models": s.models,
|
|
}
|
|
for s in summaries
|
|
]
|
|
initial = initial_session_id or (summaries[0].execution_id if summaries else "")
|
|
return f"""<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Hive LLM Timeline</title>
|
|
<style>
|
|
:root {{
|
|
--bg: #0f1115;
|
|
--panel: #161922;
|
|
--panel-2: #1c2030;
|
|
--line: #262b3a;
|
|
--ink: #e6e8ee;
|
|
--muted: #8a93a6;
|
|
--accent: #e07a48;
|
|
--accent-2: #6aa9ff;
|
|
--user: #2dd4bf;
|
|
--assistant: #c084fc;
|
|
--tool-use: #f59e0b;
|
|
--tool-result: #34d399;
|
|
--system: #94a3b8;
|
|
}}
|
|
* {{ box-sizing: border-box; }}
|
|
body {{
|
|
margin: 0;
|
|
background: var(--bg);
|
|
color: var(--ink);
|
|
font: 13px/1.5 ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
}}
|
|
.app {{
|
|
display: grid;
|
|
grid-template-columns: 280px 420px minmax(0, 1fr);
|
|
height: 100vh;
|
|
}}
|
|
.col {{
|
|
border-right: 1px solid var(--line);
|
|
overflow: auto;
|
|
min-width: 0;
|
|
}}
|
|
.col:last-child {{ border-right: 0; }}
|
|
.col-head {{
|
|
padding: 12px 14px;
|
|
border-bottom: 1px solid var(--line);
|
|
background: var(--panel);
|
|
position: sticky; top: 0; z-index: 1;
|
|
display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
|
|
}}
|
|
.col-head h2 {{ margin: 0; font-size: 13px; letter-spacing: 0.04em;
|
|
text-transform: uppercase; color: var(--muted); flex: 1; }}
|
|
.col-head input, .col-head select {{
|
|
background: var(--panel-2);
|
|
color: var(--ink);
|
|
border: 1px solid var(--line);
|
|
border-radius: 8px;
|
|
padding: 6px 10px;
|
|
font: inherit;
|
|
width: 100%;
|
|
}}
|
|
.session-list {{ padding: 8px; }}
|
|
.session {{
|
|
padding: 10px 12px;
|
|
border-radius: 10px;
|
|
cursor: pointer;
|
|
margin-bottom: 4px;
|
|
}}
|
|
.session:hover {{ background: var(--panel); }}
|
|
.session.active {{ background: linear-gradient(135deg, #2a1d14, #1a1410);
|
|
border: 1px solid #4a2e1c; }}
|
|
.session .sid {{ font-family: ui-monospace, Menlo, monospace; font-size: 11px;
|
|
word-break: break-all; }}
|
|
.session .meta {{ margin-top: 6px; color: var(--muted); font-size: 11px; }}
|
|
.timeline {{ padding: 4px 0; }}
|
|
.ev {{
|
|
padding: 8px 12px 8px 36px;
|
|
border-left: 2px solid var(--line);
|
|
margin-left: 14px;
|
|
position: relative;
|
|
cursor: pointer;
|
|
}}
|
|
.ev:hover {{ background: var(--panel); }}
|
|
.ev.active {{ background: var(--panel-2); }}
|
|
.ev::before {{
|
|
content: "";
|
|
position: absolute;
|
|
left: -7px; top: 14px;
|
|
width: 12px; height: 12px;
|
|
border-radius: 50%;
|
|
background: var(--line);
|
|
border: 2px solid var(--bg);
|
|
}}
|
|
.ev.kind-user::before {{ background: var(--user); }}
|
|
.ev.kind-assistant::before {{ background: var(--assistant); }}
|
|
.ev.kind-tool_use::before {{ background: var(--tool-use); }}
|
|
.ev.kind-tool_result::before {{ background: var(--tool-result); }}
|
|
.ev.kind-system::before {{ background: var(--system); }}
|
|
.ev .row {{ display: flex; gap: 8px; align-items: center; }}
|
|
.badge {{
|
|
display: inline-block;
|
|
padding: 2px 8px;
|
|
border-radius: 999px;
|
|
font-size: 10px;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
}}
|
|
.badge.kind-user {{ background: rgba(45, 212, 191, 0.16); color: var(--user); }}
|
|
.badge.kind-assistant {{ background: rgba(192, 132, 252, 0.16); color: var(--assistant); }}
|
|
.badge.kind-tool_use {{ background: rgba(245, 158, 11, 0.16); color: var(--tool-use); }}
|
|
.badge.kind-tool_result {{ background: rgba(52, 211, 153, 0.16); color: var(--tool-result); }}
|
|
.badge.kind-system {{ background: rgba(148, 163, 184, 0.16); color: var(--system); }}
|
|
.badge.err {{ background: rgba(239, 68, 68, 0.18); color: #fca5a5; }}
|
|
.ev .ts {{ color: var(--muted); font-size: 11px; margin-left: auto;
|
|
font-family: ui-monospace, Menlo, monospace; }}
|
|
.ev .iter {{ color: var(--muted); font-size: 11px;
|
|
font-family: ui-monospace, Menlo, monospace; }}
|
|
.ev .preview {{
|
|
margin-top: 4px;
|
|
color: var(--muted);
|
|
font-family: ui-monospace, Menlo, monospace;
|
|
font-size: 11px;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}}
|
|
.ev .turn-marker {{ color: var(--accent); font-weight: 700; }}
|
|
.raw {{ padding: 14px 18px; }}
|
|
.raw h3 {{ margin: 0 0 8px; font-size: 12px; color: var(--muted);
|
|
text-transform: uppercase; letter-spacing: 0.06em; }}
|
|
.raw section {{ margin-bottom: 18px; }}
|
|
.raw .head {{
|
|
display: flex; gap: 10px; align-items: center; margin-bottom: 12px;
|
|
flex-wrap: wrap;
|
|
}}
|
|
.raw .chip {{
|
|
background: var(--panel-2);
|
|
border: 1px solid var(--line);
|
|
border-radius: 999px;
|
|
padding: 4px 10px;
|
|
font-size: 11px;
|
|
color: var(--muted);
|
|
font-family: ui-monospace, Menlo, monospace;
|
|
}}
|
|
pre.json {{
|
|
margin: 0;
|
|
padding: 12px 14px;
|
|
background: var(--panel);
|
|
border: 1px solid var(--line);
|
|
border-radius: 10px;
|
|
max-height: 60vh;
|
|
overflow: auto;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
font-family: ui-monospace, Menlo, monospace;
|
|
font-size: 12px;
|
|
}}
|
|
pre.text {{
|
|
margin: 0;
|
|
padding: 12px 14px;
|
|
background: var(--panel);
|
|
border: 1px solid var(--line);
|
|
border-radius: 10px;
|
|
max-height: 60vh;
|
|
overflow: auto;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
font: inherit;
|
|
line-height: 1.55;
|
|
}}
|
|
.hl-msg {{
|
|
outline: 2px solid var(--accent);
|
|
border-radius: 8px;
|
|
background: rgba(224, 122, 72, 0.08);
|
|
}}
|
|
.msg {{
|
|
border: 1px solid var(--line);
|
|
border-radius: 10px;
|
|
padding: 10px 12px;
|
|
margin: 8px 0;
|
|
background: var(--panel-2);
|
|
}}
|
|
.msg-head {{ display: flex; gap: 8px; align-items: center; margin-bottom: 6px;
|
|
font-size: 11px; color: var(--muted); }}
|
|
.empty {{ color: var(--muted); padding: 24px; text-align: center;
|
|
border: 1px dashed var(--line); border-radius: 10px; margin: 14px; }}
|
|
details > summary {{ cursor: pointer; color: var(--accent-2); user-select: none; }}
|
|
details {{ margin-top: 6px; }}
|
|
.scroll-target {{ scroll-margin-top: 80px; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="app">
|
|
<div class="col">
|
|
<div class="col-head">
|
|
<h2>Sessions</h2>
|
|
<input id="sessionSearch" type="search" placeholder="Filter">
|
|
</div>
|
|
<div class="session-list" id="sessionList"></div>
|
|
</div>
|
|
<div class="col">
|
|
<div class="col-head">
|
|
<h2 id="timelineHead">Timeline</h2>
|
|
<select id="kindFilter">
|
|
<option value="">all events</option>
|
|
<option value="user">user input</option>
|
|
<option value="assistant">assistant text</option>
|
|
<option value="tool_use">tool use</option>
|
|
<option value="tool_result">tool result</option>
|
|
<option value="system">system</option>
|
|
</select>
|
|
</div>
|
|
<div class="timeline" id="timeline"></div>
|
|
</div>
|
|
<div class="col">
|
|
<div class="col-head"><h2>Raw context sent to LLM</h2></div>
|
|
<div class="raw" id="raw">
|
|
<div class="empty">Select an event on the timeline to view the raw request payload (system prompt, tool schemas, full messages array) for that LLM call.</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script id="session-summaries" type="application/json">{json.dumps(summaries_data, ensure_ascii=False)}</script>
|
|
<script>
|
|
const summaries = JSON.parse(document.getElementById("session-summaries").textContent);
|
|
const initialSessionId = {json.dumps(initial, ensure_ascii=False)};
|
|
const recordCache = {{}};
|
|
|
|
let activeSessionId = initialSessionId || (summaries[0] ? summaries[0].execution_id : "");
|
|
let activeRecords = [];
|
|
let activeEvents = [];
|
|
let activeEventId = null;
|
|
|
|
const $ = (id) => document.getElementById(id);
|
|
|
|
function escapeHtml(value) {{
|
|
return String(value == null ? "" : value)
|
|
.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
|
}}
|
|
function shortTime(iso) {{
|
|
// Normalize to local clock-time. Two formats land here:
|
|
// - "2026-04-29T18:24:08" (naive — older turn timestamps)
|
|
// - "2026-04-30T01:24:08+00:00" (UTC — tool_call start_timestamp)
|
|
// Per ISO 8601, naive = local, so `new Date(s)` interprets correctly.
|
|
if (!iso) return "";
|
|
const d = new Date(String(iso));
|
|
if (Number.isNaN(d.getTime())) {{
|
|
const m = String(iso).match(/T(\\d{{2}}:\\d{{2}}:\\d{{2}})/);
|
|
return m ? m[1] : String(iso);
|
|
}}
|
|
return d.toLocaleTimeString([], {{ hour12: false }});
|
|
}}
|
|
function preview(text, n = 140) {{
|
|
const s = String(text == null ? "" : text).replace(/\\s+/g, " ").trim();
|
|
return s.length > n ? s.slice(0, n) + "…" : s;
|
|
}}
|
|
function contentToText(content) {{
|
|
if (content == null) return "";
|
|
if (typeof content === "string") return content;
|
|
if (Array.isArray(content)) {{
|
|
return content.map((b) => {{
|
|
if (!b || typeof b !== "object") return "";
|
|
if (b.type === "text") return b.text || "";
|
|
if (b.type === "image_url") return "[image]";
|
|
if (b.type === "image") return "[image]";
|
|
if (b.type === "tool_use") return `[tool_use:${{b.name || ""}}]`;
|
|
if (b.type === "tool_result") {{
|
|
const c = b.content;
|
|
if (typeof c === "string") return c;
|
|
if (Array.isArray(c)) return contentToText(c);
|
|
return "[tool_result]";
|
|
}}
|
|
return JSON.stringify(b).slice(0, 200);
|
|
}}).join(" · ");
|
|
}}
|
|
try {{ return JSON.stringify(content).slice(0, 200); }} catch {{ return ""; }}
|
|
}}
|
|
|
|
function renderSessions() {{
|
|
const q = ($("sessionSearch").value || "").toLowerCase().trim();
|
|
const filtered = summaries.filter((s) => {{
|
|
if (!q) return true;
|
|
return [s.execution_id, s.start_display, ...(s.models || []), ...(s.nodes || [])]
|
|
.join("\\n").toLowerCase().includes(q);
|
|
}});
|
|
$("sessionList").innerHTML = filtered.map((s) => {{
|
|
const cls = s.execution_id === activeSessionId ? "session active" : "session";
|
|
const chips = [s.start_display, `${{s.turn_count}} turns`, ...(s.models || []).slice(0, 1)]
|
|
.filter(Boolean).map((c) => escapeHtml(c)).join(" · ");
|
|
return `<div class="${{cls}}" data-sid="${{escapeHtml(s.execution_id)}}">
|
|
<div class="sid">${{escapeHtml(s.execution_id)}}</div>
|
|
<div class="meta">${{chips}}</div>
|
|
</div>`;
|
|
}}).join("") || '<div class="empty">No sessions.</div>';
|
|
}}
|
|
|
|
/**
|
|
* Build the chronological event list from a session's turn records.
|
|
*
|
|
* Each record is one LLM call. To avoid re-emitting the same conversation
|
|
* messages on every turn, we diff: for turn N, only the messages added on
|
|
* top of turn N-1's `messages` are new events (these are the user inputs
|
|
* and tool_results that triggered turn N). Then we add an output event for
|
|
* the assistant text and one event per tool_call from this turn.
|
|
*/
|
|
function buildEvents(records) {{
|
|
const events = [];
|
|
// Dedupe by content-derived key, NOT array position. Context pruning
|
|
// (see agent_loop DS-13: prune at ~60% usage) shrinks the messages
|
|
// array between turns, so a position-based watermark drops messages
|
|
// added after pruning. Hashing each message lets us anchor every
|
|
// unique message to the FIRST turn it appeared in.
|
|
const seenMsg = new Set();
|
|
const stableKey = (m) => {{
|
|
const role = String(m.role || "");
|
|
let body = "";
|
|
try {{ body = typeof m.content === "string" ? m.content : JSON.stringify(m.content); }}
|
|
catch {{ body = ""; }}
|
|
// First 400 chars is enough to disambiguate distinct messages
|
|
// without holding huge strings in the Set.
|
|
return role + "\\x1f" + body.slice(0, 400) + "\\x1f" + body.length;
|
|
}};
|
|
for (let i = 0; i < records.length; i++) {{
|
|
const rec = records[i];
|
|
const msgs = Array.isArray(rec.messages) ? rec.messages : [];
|
|
for (let j = 0; j < msgs.length; j++) {{
|
|
const m = msgs[j];
|
|
const key = stableKey(m);
|
|
if (seenMsg.has(key)) continue;
|
|
seenMsg.add(key);
|
|
const role = String(m.role || "user");
|
|
let kind = "user";
|
|
if (role === "tool") kind = "tool_result";
|
|
else if (role === "system") kind = "system";
|
|
else if (role === "assistant") {{
|
|
// Assistant messages in input echo a prior turn's response,
|
|
// already represented by that turn's output events.
|
|
continue;
|
|
}}
|
|
events.push({{
|
|
id: `t${{i}}-m${{j}}`,
|
|
kind,
|
|
role,
|
|
label: kind === "tool_result" ? "tool_result" : role,
|
|
preview: preview(contentToText(m.content)),
|
|
timestamp: rec.timestamp || "",
|
|
iteration: rec.iteration ?? "?",
|
|
turnIndex: i,
|
|
messageIndex: j,
|
|
scrollTarget: `msg-${{j}}`,
|
|
}});
|
|
}}
|
|
// Assistant text output (if any).
|
|
const at = String(rec.assistant_text || "").trim();
|
|
if (at) {{
|
|
events.push({{
|
|
id: `t${{i}}-asst`,
|
|
kind: "assistant",
|
|
role: "assistant",
|
|
label: "assistant",
|
|
preview: preview(at),
|
|
timestamp: rec.timestamp || "",
|
|
iteration: rec.iteration ?? "?",
|
|
turnIndex: i,
|
|
messageIndex: -1,
|
|
scrollTarget: "assistant-text",
|
|
}});
|
|
}}
|
|
// Tool calls produced by this turn.
|
|
const tcs = Array.isArray(rec.tool_calls) ? rec.tool_calls : [];
|
|
for (let k = 0; k < tcs.length; k++) {{
|
|
const tc = tcs[k];
|
|
const name = tc.tool_name || (tc.function && tc.function.name) || "?";
|
|
let inputPreview = "";
|
|
try {{ inputPreview = preview(JSON.stringify(tc.tool_input || tc.input || {{}})); }} catch {{}}
|
|
events.push({{
|
|
id: `t${{i}}-tc${{k}}`,
|
|
kind: "tool_use",
|
|
role: "assistant",
|
|
label: `tool_use · ${{name}}`,
|
|
preview: inputPreview,
|
|
timestamp: tc.start_timestamp || rec.timestamp || "",
|
|
iteration: rec.iteration ?? "?",
|
|
turnIndex: i,
|
|
messageIndex: -1,
|
|
scrollTarget: `tool-call-${{k}}`,
|
|
toolName: name,
|
|
isError: !!tc.is_error,
|
|
}});
|
|
}}
|
|
}}
|
|
return events;
|
|
}}
|
|
|
|
function renderTimeline() {{
|
|
const head = $("timelineHead");
|
|
head.textContent = `Timeline${{activeRecords.length ? ` · ${{activeRecords.length}} turns · ${{activeEvents.length}} events` : ""}}`;
|
|
const filter = $("kindFilter").value;
|
|
const html = activeEvents.filter((e) => !filter || e.kind === filter).map((e) => {{
|
|
const errBadge = e.isError ? `<span class="badge err">err</span>` : "";
|
|
return `<div class="ev kind-${{e.kind}} ${{activeEventId === e.id ? "active" : ""}}" data-evid="${{escapeHtml(e.id)}}">
|
|
<div class="row">
|
|
<span class="badge kind-${{e.kind}}">${{escapeHtml(e.label)}}</span>
|
|
${{errBadge}}
|
|
<span class="iter">iter ${{escapeHtml(e.iteration)}}</span>
|
|
<span class="ts">${{escapeHtml(shortTime(e.timestamp))}}</span>
|
|
</div>
|
|
${{e.preview ? `<div class="preview">${{escapeHtml(e.preview)}}</div>` : ""}}
|
|
</div>`;
|
|
}}).join("") || '<div class="empty">No events for this filter.</div>';
|
|
$("timeline").innerHTML = html;
|
|
}}
|
|
|
|
function renderRaw(event) {{
|
|
if (!event) {{
|
|
$("raw").innerHTML = '<div class="empty">Select an event.</div>';
|
|
return;
|
|
}}
|
|
const rec = activeRecords[event.turnIndex];
|
|
if (!rec) {{
|
|
$("raw").innerHTML = '<div class="empty">Record not found.</div>';
|
|
return;
|
|
}}
|
|
const tc = rec.token_counts || {{}};
|
|
const messages = Array.isArray(rec.messages) ? rec.messages : [];
|
|
const tools = Array.isArray(rec.tools) ? rec.tools : [];
|
|
const toolsMissing = !rec.tools;
|
|
const sys = String(rec.system_prompt || "");
|
|
const ast = String(rec.assistant_text || "");
|
|
const tcs = Array.isArray(rec.tool_calls) ? rec.tool_calls : [];
|
|
|
|
const head = `
|
|
<div class="head">
|
|
<span class="chip">turn ${{event.turnIndex + 1}}/${{activeRecords.length}}</span>
|
|
<span class="chip">iter ${{escapeHtml(rec.iteration ?? "?")}}</span>
|
|
<span class="chip">${{escapeHtml(rec.timestamp || "")}}</span>
|
|
<span class="chip">node=${{escapeHtml(rec.node_id || "-")}}</span>
|
|
<span class="chip">model=${{escapeHtml(tc.model || "-")}}</span>
|
|
<span class="chip">in=${{escapeHtml(tc.input ?? "-")}} out=${{escapeHtml(tc.output ?? "-")}}</span>
|
|
<span class="chip">stop=${{escapeHtml(tc.stop_reason || "-")}}</span>
|
|
</div>`;
|
|
|
|
// Messages section: highlight the message this event refers to (if any).
|
|
const msgHtml = messages.map((m, idx) => {{
|
|
const hl = idx === event.messageIndex ? " hl-msg scroll-target" : "";
|
|
const role = String(m.role || "?");
|
|
return `<div class="msg${{hl}}" id="msg-${{idx}}">
|
|
<div class="msg-head">
|
|
<span class="badge kind-${{role === "tool" ? "tool_result" : (role === "assistant" ? "assistant" : (role === "system" ? "system" : "user"))}}">${{escapeHtml(role)}}</span>
|
|
<span class="iter">[${{idx}}]</span>
|
|
</div>
|
|
<pre class="json">${{escapeHtml(JSON.stringify(m.content, null, 2))}}</pre>
|
|
</div>`;
|
|
}}).join("") || '<div class="empty">No messages.</div>';
|
|
|
|
// Tool calls section (the assistant's outputs from this turn).
|
|
const tcsHtml = tcs.map((c, k) => {{
|
|
const hl = event.scrollTarget === `tool-call-${{k}}` ? " hl-msg scroll-target" : "";
|
|
const name = c.tool_name || (c.function && c.function.name) || "?";
|
|
return `<div class="msg${{hl}}" id="tool-call-${{k}}">
|
|
<div class="msg-head">
|
|
<span class="badge kind-tool_use">tool_use</span>
|
|
<span class="iter">${{escapeHtml(name)}}</span>
|
|
${{c.is_error ? '<span class="badge err">err</span>' : ""}}
|
|
</div>
|
|
<pre class="json">${{escapeHtml(JSON.stringify(c, null, 2))}}</pre>
|
|
</div>`;
|
|
}}).join("");
|
|
|
|
const astHl = event.scrollTarget === "assistant-text" ? " hl-msg scroll-target" : "";
|
|
const astHtml = ast ? `<div class="msg${{astHl}}" id="assistant-text">
|
|
<div class="msg-head"><span class="badge kind-assistant">assistant text</span></div>
|
|
<pre class="text">${{escapeHtml(ast)}}</pre>
|
|
</div>` : "";
|
|
|
|
const toolsNotice = toolsMissing
|
|
? '<div class="empty" style="margin:8px 0;">Tool schemas were not captured for this turn. Re-run the agent with the updated logger to populate this section.</div>'
|
|
: "";
|
|
|
|
$("raw").innerHTML = `
|
|
${{head}}
|
|
<section>
|
|
<h3>System prompt</h3>
|
|
<details ${{sys.length < 4000 ? "open" : ""}}><summary>${{sys.length}} chars</summary>
|
|
<pre class="text">${{escapeHtml(sys)}}</pre>
|
|
</details>
|
|
</section>
|
|
<section>
|
|
<h3>Tools (${{tools.length}})</h3>
|
|
${{toolsNotice}}
|
|
${{tools.length ? `<details><summary>show tool schemas</summary>
|
|
<pre class="json">${{escapeHtml(JSON.stringify(tools, null, 2))}}</pre>
|
|
</details>` : ""}}
|
|
</section>
|
|
<section>
|
|
<h3>Messages sent to LLM (${{messages.length}})</h3>
|
|
${{msgHtml}}
|
|
</section>
|
|
${{ast || tcs.length ? `<section>
|
|
<h3>Assistant response from this turn</h3>
|
|
${{astHtml}}
|
|
${{tcsHtml}}
|
|
</section>` : ""}}
|
|
`;
|
|
|
|
// Scroll to the highlighted target so the developer immediately sees it.
|
|
requestAnimationFrame(() => {{
|
|
const target = document.querySelector(".raw .scroll-target");
|
|
if (target) target.scrollIntoView({{ behavior: "smooth", block: "start" }});
|
|
}});
|
|
}}
|
|
|
|
async function loadSession(sid) {{
|
|
activeSessionId = sid;
|
|
activeEventId = null;
|
|
renderSessions();
|
|
$("timelineHead").textContent = "Timeline · loading…";
|
|
$("timeline").innerHTML = '<div class="empty">Loading…</div>';
|
|
let records = recordCache[sid];
|
|
if (!records) {{
|
|
const resp = await fetch(`/api/session/${{encodeURIComponent(sid)}}`);
|
|
records = resp.ok ? await resp.json() : [];
|
|
recordCache[sid] = records;
|
|
}}
|
|
if (activeSessionId !== sid) return;
|
|
activeRecords = records || [];
|
|
activeEvents = buildEvents(activeRecords);
|
|
renderTimeline();
|
|
$("raw").innerHTML = '<div class="empty">Click any event on the left to view the raw request payload.</div>';
|
|
history.replaceState(null, "", `#${{encodeURIComponent(sid)}}`);
|
|
}}
|
|
|
|
$("sessionList").addEventListener("click", (e) => {{
|
|
const card = e.target.closest(".session");
|
|
if (card) loadSession(card.dataset.sid);
|
|
}});
|
|
$("sessionSearch").addEventListener("input", renderSessions);
|
|
$("kindFilter").addEventListener("change", renderTimeline);
|
|
$("timeline").addEventListener("click", (e) => {{
|
|
const evEl = e.target.closest(".ev");
|
|
if (!evEl) return;
|
|
activeEventId = evEl.dataset.evid;
|
|
const ev = activeEvents.find((x) => x.id === activeEventId);
|
|
renderTimeline();
|
|
renderRaw(ev);
|
|
}});
|
|
|
|
renderSessions();
|
|
const hashSid = decodeURIComponent(window.location.hash.replace(/^#/, ""));
|
|
const known = new Set(summaries.map((s) => s.execution_id));
|
|
const boot = known.has(hashSid) ? hashSid : activeSessionId;
|
|
if (boot) loadSession(boot);
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
|
|
def _run_server(html: str, logs_dir: Path, limit_files: int, port: int, no_open: bool) -> None:
|
|
html_bytes = html.encode("utf-8")
|
|
cache: dict[str, list[dict[str, Any]]] = {}
|
|
|
|
class Handler(http.server.BaseHTTPRequestHandler):
|
|
def do_GET(self) -> None:
|
|
if self.path == "/":
|
|
self._respond(200, "text/html; charset=utf-8", html_bytes)
|
|
elif self.path.startswith("/api/session/"):
|
|
sid = urllib.parse.unquote(self.path[len("/api/session/") :])
|
|
records = cache.get(sid)
|
|
if records is None:
|
|
records = _load_session_data(logs_dir, sid, limit_files)
|
|
if records is not None:
|
|
cache[sid] = records
|
|
if records is None:
|
|
self._respond(404, "application/json", b"[]")
|
|
else:
|
|
body = json.dumps(records, ensure_ascii=False).encode("utf-8")
|
|
self._respond(200, "application/json", body)
|
|
else:
|
|
self.send_error(404)
|
|
|
|
def _respond(self, code: int, content_type: str, body: bytes) -> None:
|
|
self.send_response(code)
|
|
self.send_header("Content-Type", content_type)
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def log_message(self, format: str, *args: object) -> None:
|
|
pass
|
|
|
|
server = http.server.HTTPServer(("127.0.0.1", port), Handler)
|
|
actual_port = server.server_address[1]
|
|
url = f"http://127.0.0.1:{actual_port}"
|
|
print(f"Serving timeline viewer at {url} (Ctrl+C to stop)")
|
|
if not no_open:
|
|
webbrowser.open(url)
|
|
try:
|
|
server.serve_forever()
|
|
except KeyboardInterrupt:
|
|
print("\nStopped.")
|
|
finally:
|
|
server.server_close()
|
|
|
|
|
|
def main() -> int:
|
|
args = _parse_args()
|
|
logs_dir = args.logs_dir.expanduser()
|
|
summaries = _discover_session_summaries(logs_dir, args.limit_files, args.include_tests)
|
|
initial = args.session or (summaries[0].execution_id if summaries else "")
|
|
if initial and not any(s.execution_id == initial for s in summaries):
|
|
print(f"session not found: {initial}")
|
|
return 1
|
|
html = _render_html(summaries, initial)
|
|
_run_server(html, logs_dir, args.limit_files, args.port, args.no_open)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|