589c5b06fe
- Auto-fixed 70 lint errors (import sorting, aliased errors, datetime.UTC)
- Fixed 85 remaining errors manually:
- E501: wrapped long lines in queen_profiles, catalog, routes_credentials
- F821: added missing TYPE_CHECKING imports for AgentHost, ToolRegistry,
HookContext, HookResult; added runtime imports where needed
- F811: removed duplicate method definitions in queen_lifecycle_tools
- F841/B007: removed unused variables in discovery.py
- W291: removed trailing whitespace in queen nodes
- E402: moved import to top of queen_memory_v2.py
- Fixed AgentRuntime -> AgentHost in example template type annotations
- Reformatted 343 files with ruff format
1068 lines
34 KiB
Python
1068 lines
34 KiB
Python
#!/usr/bin/env python3
|
|
"""Open a browser-based viewer for Hive LLM debug JSONL sessions.
|
|
|
|
Starts a local HTTP server and loads session data on demand (one at a time).
|
|
|
|
Usage:
|
|
uv run --no-project scripts/llm_debug_log_visualizer.py
|
|
uv run --no-project scripts/llm_debug_log_visualizer.py --session <execution_id>
|
|
uv run --no-project scripts/llm_debug_log_visualizer.py --port 8080
|
|
uv run --no-project scripts/llm_debug_log_visualizer.py --output debug.html
|
|
"""
|
|
|
|
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 in the webpage.",
|
|
)
|
|
parser.add_argument(
|
|
"--output",
|
|
type=Path,
|
|
help="Optional HTML output path. Defaults to a temporary file.",
|
|
)
|
|
parser.add_argument(
|
|
"--limit-files",
|
|
type=int,
|
|
default=200,
|
|
help="Maximum number of newest log files to scan.",
|
|
)
|
|
parser.add_argument(
|
|
"--port",
|
|
type=int,
|
|
default=0,
|
|
help="Port for the local server (0 = auto-pick a free port).",
|
|
)
|
|
parser.add_argument(
|
|
"--no-open",
|
|
action="store_true",
|
|
help="Start the server but do not open a browser.",
|
|
)
|
|
parser.add_argument(
|
|
"--include-tests",
|
|
action="store_true",
|
|
help="Show test/mock sessions (hidden by default).",
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
def _safe_read_jsonl(path: Path) -> list[dict[str, Any]]:
|
|
records: list[dict[str, Any]] = []
|
|
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": "",
|
|
"assistant_text": "",
|
|
"_parse_error": f"{path.name}:{line_number}",
|
|
"_raw_line": line,
|
|
}
|
|
payload["_log_file"] = str(path)
|
|
records.append(payload)
|
|
except OSError as exc:
|
|
print(f"warning: failed to read {path}: {exc}")
|
|
return records
|
|
|
|
|
|
def _discover_records(logs_dir: Path, limit_files: int) -> list[dict[str, Any]]:
|
|
if not logs_dir.exists():
|
|
raise FileNotFoundError(f"log directory not found: {logs_dir}")
|
|
|
|
files = sorted(
|
|
[path for path in logs_dir.iterdir() if path.is_file() and path.suffix == ".jsonl"],
|
|
key=lambda path: path.stat().st_mtime,
|
|
reverse=True,
|
|
)[:limit_files]
|
|
|
|
records: list[dict[str, Any]] = []
|
|
for path in files:
|
|
records.extend(_safe_read_jsonl(path))
|
|
return records
|
|
|
|
|
|
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:
|
|
"""Return True for sessions that look like test artifacts."""
|
|
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("")
|
|
# Sessions that only used the mock LLM provider.
|
|
if models and models <= {"mock"}:
|
|
return True
|
|
# Sessions with no real model at all (empty string or missing).
|
|
if not models:
|
|
return True
|
|
return False
|
|
|
|
|
|
def _group_sessions(
|
|
records: list[dict[str, Any]],
|
|
*,
|
|
include_tests: bool = False,
|
|
) -> tuple[list[SessionSummary], dict[str, list[dict[str, Any]]]]:
|
|
by_session: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
for record in records:
|
|
execution_id = str(record.get("execution_id") or "").strip()
|
|
if execution_id:
|
|
by_session[execution_id].append(record)
|
|
|
|
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 execution_id, session_records in by_session.items():
|
|
session_records.sort(
|
|
key=lambda record: (
|
|
str(record.get("timestamp", "")),
|
|
record.get("iteration", 0),
|
|
)
|
|
)
|
|
first = session_records[0]
|
|
last = session_records[-1]
|
|
summaries.append(
|
|
SessionSummary(
|
|
execution_id=execution_id,
|
|
log_file=str(first.get("_log_file", "")),
|
|
start_timestamp=str(first.get("timestamp", "")),
|
|
end_timestamp=str(last.get("timestamp", "")),
|
|
turn_count=len(session_records),
|
|
streams=sorted({str(r.get("stream_id", "")) for r in session_records if r.get("stream_id")}),
|
|
nodes=sorted({str(r.get("node_id", "")) for r in session_records if r.get("node_id")}),
|
|
models=sorted(
|
|
{
|
|
str(r.get("token_counts", {}).get("model", ""))
|
|
for r in session_records
|
|
if isinstance(r.get("token_counts"), dict) and r.get("token_counts", {}).get("model")
|
|
}
|
|
),
|
|
)
|
|
)
|
|
|
|
summaries.sort(key=lambda summary: summary.start_timestamp, reverse=True)
|
|
return summaries, by_session
|
|
|
|
|
|
def _render_html(
|
|
summaries: list[SessionSummary],
|
|
initial_session_id: str,
|
|
) -> str:
|
|
summaries_data = [
|
|
{
|
|
"execution_id": summary.execution_id,
|
|
"log_file": summary.log_file,
|
|
"start_timestamp": summary.start_timestamp,
|
|
"end_timestamp": summary.end_timestamp,
|
|
"start_display": _format_timestamp(summary.start_timestamp),
|
|
"end_display": _format_timestamp(summary.end_timestamp),
|
|
"turn_count": summary.turn_count,
|
|
"streams": summary.streams,
|
|
"nodes": summary.nodes,
|
|
"models": summary.models,
|
|
}
|
|
for summary 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 Debug Viewer</title>
|
|
<style>
|
|
:root {{
|
|
--bg: #efe6d8;
|
|
--panel: rgba(255, 251, 245, 0.92);
|
|
--panel-strong: #fffdfa;
|
|
--ink: #1f1d19;
|
|
--muted: #6d6457;
|
|
--line: #ddceb6;
|
|
--accent: #b64a2b;
|
|
--accent-deep: #7a2813;
|
|
--sidebar: #2b211d;
|
|
--sidebar-soft: #3e302a;
|
|
--user: #0f766e;
|
|
--assistant: #7c3aed;
|
|
--tool: #9a3412;
|
|
--shadow: 0 18px 44px rgba(60, 39, 14, 0.12);
|
|
}}
|
|
* {{ box-sizing: border-box; }}
|
|
body {{
|
|
margin: 0;
|
|
color: var(--ink);
|
|
font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
background:
|
|
radial-gradient(circle at top left, rgba(182, 74, 43, 0.14), transparent 28rem),
|
|
linear-gradient(180deg, #f8f3ea 0%, var(--bg) 100%);
|
|
}}
|
|
.app {{
|
|
min-height: 100vh;
|
|
display: grid;
|
|
grid-template-columns: 340px minmax(0, 1fr);
|
|
}}
|
|
.sidebar {{
|
|
background:
|
|
linear-gradient(180deg, rgba(62, 48, 42, 0.96), rgba(29, 21, 18, 0.98));
|
|
color: white;
|
|
padding: 24px 18px;
|
|
position: sticky;
|
|
top: 0;
|
|
height: 100vh;
|
|
overflow: auto;
|
|
}}
|
|
.brand {{
|
|
margin-bottom: 20px;
|
|
}}
|
|
.brand h1 {{
|
|
margin: 0 0 6px;
|
|
font-size: 28px;
|
|
line-height: 1;
|
|
}}
|
|
.brand p {{
|
|
margin: 0;
|
|
color: rgba(255, 255, 255, 0.72);
|
|
line-height: 1.45;
|
|
}}
|
|
.sidebar input, .sidebar select {{
|
|
width: 100%;
|
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
|
border-radius: 16px;
|
|
background: rgba(255, 255, 255, 0.08);
|
|
color: white;
|
|
padding: 12px 14px;
|
|
margin: 10px 0;
|
|
}}
|
|
.sidebar input {{
|
|
width: 100%;
|
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
|
border-radius: 16px;
|
|
background: rgba(255, 255, 255, 0.08);
|
|
color: white;
|
|
padding: 12px 14px;
|
|
margin: 10px 0;
|
|
}}
|
|
.sidebar input::placeholder {{
|
|
color: rgba(255, 255, 255, 0.5);
|
|
}}
|
|
.setup-note {{
|
|
margin-top: 14px;
|
|
padding: 14px;
|
|
border-radius: 16px;
|
|
background: rgba(255, 255, 255, 0.07);
|
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
}}
|
|
.setup-note h3 {{
|
|
margin: 0 0 8px;
|
|
font-size: 14px;
|
|
}}
|
|
.setup-note p {{
|
|
margin: 0 0 10px;
|
|
color: rgba(255, 255, 255, 0.76);
|
|
line-height: 1.45;
|
|
font-size: 13px;
|
|
}}
|
|
.setup-note pre {{
|
|
margin: 0;
|
|
background: rgba(0, 0, 0, 0.24);
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
color: white;
|
|
}}
|
|
.session-list {{
|
|
display: grid;
|
|
gap: 10px;
|
|
margin-top: 16px;
|
|
}}
|
|
.session-card {{
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
background: rgba(255, 255, 255, 0.06);
|
|
color: white;
|
|
border-radius: 18px;
|
|
padding: 14px;
|
|
cursor: pointer;
|
|
text-align: left;
|
|
width: 100%;
|
|
}}
|
|
.session-card.active {{
|
|
background: linear-gradient(145deg, rgba(182, 74, 43, 0.96), rgba(122, 40, 19, 0.96));
|
|
border-color: rgba(255, 255, 255, 0.24);
|
|
}}
|
|
.session-card .sid {{
|
|
font-family: ui-monospace, "SFMono-Regular", Menlo, monospace;
|
|
font-size: 12px;
|
|
word-break: break-all;
|
|
opacity: 0.95;
|
|
}}
|
|
.session-card .meta {{
|
|
margin-top: 8px;
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 6px;
|
|
font-size: 12px;
|
|
color: rgba(255, 255, 255, 0.76);
|
|
}}
|
|
.session-card .meta span {{
|
|
border-radius: 999px;
|
|
background: rgba(255, 255, 255, 0.09);
|
|
padding: 4px 8px;
|
|
}}
|
|
.main {{
|
|
padding: 26px;
|
|
min-width: 0;
|
|
}}
|
|
.hero {{
|
|
background: linear-gradient(145deg, rgba(182, 74, 43, 0.96), rgba(122, 40, 19, 0.96));
|
|
color: white;
|
|
border-radius: 28px;
|
|
padding: 28px;
|
|
box-shadow: var(--shadow);
|
|
}}
|
|
.hero h2 {{
|
|
margin: 0 0 8px;
|
|
font-size: clamp(30px, 5vw, 46px);
|
|
line-height: 1.02;
|
|
}}
|
|
.hero code {{
|
|
display: inline-block;
|
|
margin-top: 4px;
|
|
padding: 4px 10px;
|
|
border-radius: 999px;
|
|
background: rgba(255, 255, 255, 0.14);
|
|
font-size: 13px;
|
|
word-break: break-all;
|
|
}}
|
|
.meta-grid {{
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
|
gap: 12px;
|
|
margin-top: 18px;
|
|
}}
|
|
.meta-card {{
|
|
border-radius: 16px;
|
|
padding: 14px;
|
|
background: rgba(255, 255, 255, 0.11);
|
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
|
}}
|
|
.meta-card .label {{
|
|
display: block;
|
|
font-size: 11px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.08em;
|
|
color: rgba(255, 255, 255, 0.68);
|
|
margin-bottom: 6px;
|
|
}}
|
|
.toolbar {{
|
|
display: flex;
|
|
gap: 12px;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
margin: 22px 0 18px;
|
|
}}
|
|
.toolbar input {{
|
|
flex: 1 1 320px;
|
|
min-width: 220px;
|
|
border: 1px solid var(--line);
|
|
border-radius: 999px;
|
|
padding: 12px 16px;
|
|
background: rgba(255, 255, 255, 0.9);
|
|
box-shadow: var(--shadow);
|
|
}}
|
|
.toolbar button {{
|
|
border: 0;
|
|
border-radius: 999px;
|
|
padding: 12px 16px;
|
|
background: var(--accent);
|
|
color: white;
|
|
cursor: pointer;
|
|
}}
|
|
.turn {{
|
|
background: var(--panel);
|
|
border: 1px solid rgba(121, 93, 44, 0.14);
|
|
border-radius: 24px;
|
|
padding: 20px;
|
|
margin: 18px 0;
|
|
box-shadow: var(--shadow);
|
|
backdrop-filter: blur(10px);
|
|
}}
|
|
.turn.hidden {{
|
|
display: none;
|
|
}}
|
|
.turn-head {{
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 10px;
|
|
flex-wrap: wrap;
|
|
margin-bottom: 14px;
|
|
}}
|
|
.turn-title {{
|
|
font-size: 24px;
|
|
font-weight: 700;
|
|
}}
|
|
.turn-meta {{
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
color: var(--muted);
|
|
font-size: 13px;
|
|
}}
|
|
.turn-meta span {{
|
|
background: #efe4d1;
|
|
border-radius: 999px;
|
|
padding: 6px 10px;
|
|
}}
|
|
details.block {{
|
|
margin-top: 12px;
|
|
border: 1px solid var(--line);
|
|
border-radius: 16px;
|
|
background: var(--panel-strong);
|
|
padding: 14px 16px;
|
|
}}
|
|
summary {{
|
|
cursor: pointer;
|
|
font-weight: 700;
|
|
}}
|
|
.message {{
|
|
margin-top: 12px;
|
|
border: 1px solid var(--line);
|
|
border-radius: 16px;
|
|
padding: 14px;
|
|
background: #fffdfa;
|
|
}}
|
|
.message-header {{
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
flex-wrap: wrap;
|
|
margin-bottom: 10px;
|
|
font-size: 13px;
|
|
color: var(--muted);
|
|
}}
|
|
.badge {{
|
|
display: inline-flex;
|
|
align-items: center;
|
|
padding: 4px 10px;
|
|
border-radius: 999px;
|
|
color: white;
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
}}
|
|
.badge-user {{ background: var(--user); }}
|
|
.badge-assistant {{ background: var(--assistant); }}
|
|
.badge-tool {{ background: var(--tool); }}
|
|
.badge-system {{ background: #334155; }}
|
|
pre {{
|
|
margin: 0;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
overflow-x: auto;
|
|
border-radius: 14px;
|
|
padding: 14px;
|
|
background: #faf5ec;
|
|
border: 1px solid #eee2cf;
|
|
font-family: ui-monospace, "SFMono-Regular", Menlo, monospace;
|
|
font-size: 13px;
|
|
line-height: 1.55;
|
|
}}
|
|
.tool-block {{
|
|
margin-top: 12px;
|
|
}}
|
|
.tool-name {{
|
|
font-weight: 700;
|
|
}}
|
|
.status {{
|
|
margin-left: auto;
|
|
padding: 4px 10px;
|
|
border-radius: 999px;
|
|
font-size: 11px;
|
|
text-transform: uppercase;
|
|
font-weight: 700;
|
|
}}
|
|
.status.ok {{
|
|
background: #dcfce7;
|
|
color: #166534;
|
|
}}
|
|
.status.error {{
|
|
background: #fee2e2;
|
|
color: #991b1b;
|
|
}}
|
|
.empty {{
|
|
padding: 32px;
|
|
color: var(--muted);
|
|
text-align: center;
|
|
border: 1px dashed var(--line);
|
|
border-radius: 18px;
|
|
background: rgba(255, 255, 255, 0.45);
|
|
}}
|
|
@media (max-width: 980px) {{
|
|
.app {{
|
|
grid-template-columns: 1fr;
|
|
}}
|
|
.sidebar {{
|
|
position: static;
|
|
height: auto;
|
|
}}
|
|
.main {{
|
|
padding-top: 14px;
|
|
}}
|
|
}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="app">
|
|
<aside class="sidebar">
|
|
<div class="brand">
|
|
<h1>Hive Debug</h1>
|
|
<p>Pick a session in the browser and inspect prompts,
|
|
inputs, outputs, and tool activity turn by turn.</p>
|
|
</div>
|
|
<input id="sessionSearch" type="search" placeholder="Filter sessions">
|
|
<div class="setup-note">
|
|
<h3>Logging status</h3>
|
|
<p>LLM turn logging is always on. If this list is empty,
|
|
run Hive once and refresh after the session produces turns.</p>
|
|
<pre>~/.hive/llm_logs</pre>
|
|
</div>
|
|
<div class="session-list" id="sessionList"></div>
|
|
</aside>
|
|
<main class="main">
|
|
<section class="hero">
|
|
<h2 id="heroTitle">LLM Debug Session</h2>
|
|
<code id="heroId"></code>
|
|
<div class="meta-grid" id="metaGrid"></div>
|
|
</section>
|
|
<div class="toolbar">
|
|
<input id="turnFilter" type="search"
|
|
placeholder="Filter by text, tool, role, model, or prompt">
|
|
<button type="button" id="expandAll">Expand all</button>
|
|
<button type="button" id="collapseAll">Collapse all</button>
|
|
</div>
|
|
<div id="turns"></div>
|
|
</main>
|
|
</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 recordCache = {{}};
|
|
const initialSessionId = {json.dumps(initial, ensure_ascii=False)};
|
|
|
|
const sessionSearch = document.getElementById("sessionSearch");
|
|
const sessionList = document.getElementById("sessionList");
|
|
const heroTitle = document.getElementById("heroTitle");
|
|
const heroId = document.getElementById("heroId");
|
|
const metaGrid = document.getElementById("metaGrid");
|
|
const turnsEl = document.getElementById("turns");
|
|
const turnFilter = document.getElementById("turnFilter");
|
|
|
|
let activeSessionId = initialSessionId || (summaries[0] ? summaries[0].execution_id : "");
|
|
|
|
function text(value) {{
|
|
return value == null ? "" : String(value);
|
|
}}
|
|
|
|
function escapeHtml(value) {{
|
|
return text(value)
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """);
|
|
}}
|
|
|
|
function prettyJson(value) {{
|
|
return escapeHtml(JSON.stringify(value, null, 2));
|
|
}}
|
|
|
|
function sessionMatches(summary, query) {{
|
|
if (!query) return true;
|
|
const haystack = [
|
|
summary.execution_id,
|
|
summary.start_display,
|
|
summary.end_display,
|
|
summary.log_file,
|
|
...(summary.streams || []),
|
|
...(summary.nodes || []),
|
|
...(summary.models || []),
|
|
].join("\\n").toLowerCase();
|
|
return haystack.includes(query);
|
|
}}
|
|
|
|
function renderSessionChooser() {{
|
|
const query = sessionSearch.value.trim().toLowerCase();
|
|
const filtered = summaries.filter((summary) => sessionMatches(summary, query));
|
|
|
|
sessionList.innerHTML = filtered
|
|
.map((summary) => {{
|
|
const active = summary.execution_id === activeSessionId ? " active" : "";
|
|
const chips = [
|
|
summary.start_display,
|
|
`${{summary.turn_count}} turns`,
|
|
...(summary.models || []).slice(0, 2),
|
|
];
|
|
return `
|
|
<button type="button"
|
|
class="session-card${{active}}"
|
|
data-session-id="${{escapeHtml(summary.execution_id)}}">
|
|
<div class="sid">${{escapeHtml(
|
|
summary.execution_id
|
|
)}}</div>
|
|
<div class="meta">${{chips.map(
|
|
(chip) => `<span>${{escapeHtml(chip)}}</span>`
|
|
).join("")}}</div>
|
|
</button>
|
|
`;
|
|
}})
|
|
.join("") || '<div class="empty">No matching sessions.</div>';
|
|
}}
|
|
|
|
function renderMetaCard(label, value) {{
|
|
return `<div class="meta-card"><span class="label">${{
|
|
escapeHtml(label)
|
|
}}</span>${{escapeHtml(value || "-")}}</div>`;
|
|
}}
|
|
|
|
function renderMessage(message, index) {{
|
|
const role = text(message.role || "unknown");
|
|
const content = text(message.content || "");
|
|
const toolCalls = message.tool_calls;
|
|
return `
|
|
<div class="message">
|
|
<div class="message-header">
|
|
<span class="badge badge-${{escapeHtml(role)}}">${{escapeHtml(role)}}</span>
|
|
<span>message ${{index}}</span>
|
|
</div>
|
|
${{
|
|
content
|
|
? `<pre>${{escapeHtml(content)}}</pre>`
|
|
: '<div class="empty">(empty message)</div>'
|
|
}}
|
|
${{
|
|
toolCalls
|
|
? `<details class="block"><summary>tool_calls</summary>` +
|
|
`<pre>${{prettyJson(toolCalls)}}</pre></details>`
|
|
: ""
|
|
}}
|
|
</div>
|
|
`;
|
|
}}
|
|
|
|
function renderToolCall(toolCall, index) {{
|
|
const name = text(toolCall.tool_name || (toolCall.function || {{}}).name || "unknown");
|
|
const error = !!toolCall.is_error;
|
|
return `
|
|
<div class="tool-block">
|
|
<div class="message-header">
|
|
<span class="badge badge-tool">tool ${{index}}</span>
|
|
<span class="tool-name">${{escapeHtml(name)}}</span>
|
|
<span class="status ${{error ? "error" : "ok"}}">${{error ? "error" : "ok"}}</span>
|
|
</div>
|
|
<pre>${{prettyJson(toolCall)}}</pre>
|
|
</div>
|
|
`;
|
|
}}
|
|
|
|
function renderTurn(record) {{
|
|
const tokenCounts = record.token_counts || {{}};
|
|
const messages = Array.isArray(record.messages) ? record.messages : [];
|
|
const toolCalls = Array.isArray(record.tool_calls) ? record.tool_calls : [];
|
|
const toolResults = Array.isArray(record.tool_results) ? record.tool_results : [];
|
|
const systemPrompt = text(record.system_prompt || "");
|
|
const assistantText = text(record.assistant_text || "");
|
|
const parseError = text(record._parse_error || "");
|
|
|
|
return `
|
|
<section class="turn">
|
|
<div class="turn-head">
|
|
<div class="turn-title">Iteration ${{escapeHtml(record.iteration ?? "?")}}</div>
|
|
<div class="turn-meta">
|
|
<span>${{escapeHtml(record.timestamp || "-")}}</span>
|
|
<span>node=${{escapeHtml(record.node_id || "-")}}</span>
|
|
<span>stream=${{escapeHtml(record.stream_id || "-")}}</span>
|
|
<span>model=${{escapeHtml(tokenCounts.model || "-")}}</span>
|
|
<span>stop=${{escapeHtml(tokenCounts.stop_reason || "-")}}</span>
|
|
<span>in=${{escapeHtml(tokenCounts.input ?? "-")}}</span>
|
|
<span>out=${{escapeHtml(tokenCounts.output ?? "-")}}</span>
|
|
</div>
|
|
</div>
|
|
${{
|
|
systemPrompt
|
|
? `<details class="block" open>` +
|
|
`<summary>System prompt</summary>` +
|
|
`<pre>${{escapeHtml(systemPrompt)}}</pre></details>`
|
|
: ""
|
|
}}
|
|
${{
|
|
messages.length
|
|
? `<details class="block" open>` +
|
|
`<summary>Input messages (${{messages.length}})` +
|
|
`</summary>${{messages.map((message, index) =>
|
|
renderMessage(message, index + 1)
|
|
).join("")}}</details>`
|
|
: ""
|
|
}}
|
|
<details class="block" open>
|
|
<summary>Assistant output</summary>
|
|
<pre>${{escapeHtml(assistantText)}}</pre>
|
|
</details>
|
|
${{
|
|
toolCalls.length
|
|
? `<details class="block" open>` +
|
|
`<summary>Tool calls (${{toolCalls.length}})` +
|
|
`</summary>${{toolCalls.map((toolCall, index) =>
|
|
renderToolCall(toolCall, index + 1)
|
|
).join("")}}</details>`
|
|
: ""
|
|
}}
|
|
${{
|
|
toolResults.length
|
|
? `<details class="block">` +
|
|
`<summary>Tool results (${{toolResults.length}})` +
|
|
`</summary><pre>${{prettyJson(toolResults)}}` +
|
|
`</pre></details>`
|
|
: ""
|
|
}}
|
|
${{
|
|
parseError
|
|
? `<details class="block">` +
|
|
`<summary>Parse error</summary>` +
|
|
`<pre>${{prettyJson(record)}}</pre></details>`
|
|
: ""
|
|
}}
|
|
</section>
|
|
`;
|
|
}}
|
|
|
|
async function fetchSession(sessionId) {{
|
|
if (recordCache[sessionId]) return recordCache[sessionId];
|
|
const resp = await fetch(`/api/session/${{encodeURIComponent(sessionId)}}`);
|
|
if (!resp.ok) return [];
|
|
const data = await resp.json();
|
|
recordCache[sessionId] = data;
|
|
return data;
|
|
}}
|
|
|
|
async function renderSession(sessionId) {{
|
|
activeSessionId = sessionId;
|
|
const summary = summaries.find((entry) => entry.execution_id === sessionId);
|
|
|
|
renderSessionChooser();
|
|
|
|
if (!summary) {{
|
|
heroTitle.textContent = "No session selected";
|
|
heroId.textContent = "";
|
|
metaGrid.innerHTML = "";
|
|
turnsEl.innerHTML = '<div class="empty">No session data available.</div>';
|
|
return;
|
|
}}
|
|
|
|
heroTitle.textContent = "LLM Debug Session";
|
|
heroId.textContent = summary.execution_id;
|
|
metaGrid.innerHTML = [
|
|
renderMetaCard("Started", summary.start_display),
|
|
renderMetaCard("Ended", summary.end_display),
|
|
renderMetaCard("Turns", String(summary.turn_count)),
|
|
renderMetaCard("Streams", (summary.streams || []).join(", ")),
|
|
renderMetaCard("Nodes", (summary.nodes || []).join(", ")),
|
|
renderMetaCard("Models", (summary.models || []).join(", ")),
|
|
renderMetaCard("Source file", summary.log_file),
|
|
].join("");
|
|
|
|
turnsEl.innerHTML = '<div class="empty">Loading session\u2026</div>';
|
|
const records = await fetchSession(sessionId);
|
|
if (activeSessionId !== sessionId) return;
|
|
turnsEl.innerHTML = records.length
|
|
? records.map((record) => renderTurn(record)).join("")
|
|
: '<div class="empty">This session has no turn records.</div>';
|
|
|
|
applyTurnFilter();
|
|
history.replaceState(null, "", `#${{encodeURIComponent(sessionId)}}`);
|
|
}}
|
|
|
|
function applyTurnFilter() {{
|
|
const query = turnFilter.value.trim().toLowerCase();
|
|
for (const turn of document.querySelectorAll(".turn")) {{
|
|
const visible = !query || turn.textContent.toLowerCase().includes(query);
|
|
turn.classList.toggle("hidden", !visible);
|
|
}}
|
|
}}
|
|
|
|
sessionSearch.addEventListener("input", renderSessionChooser);
|
|
sessionList.addEventListener("click", (event) => {{
|
|
const card = event.target.closest(".session-card");
|
|
if (!card) return;
|
|
renderSession(card.dataset.sessionId);
|
|
}});
|
|
turnFilter.addEventListener("input", applyTurnFilter);
|
|
document.getElementById("expandAll").addEventListener("click", () => {{
|
|
for (const details of document.querySelectorAll("details")) details.open = true;
|
|
}});
|
|
document.getElementById("collapseAll").addEventListener("click", () => {{
|
|
for (const details of document.querySelectorAll("details")) details.open = false;
|
|
}});
|
|
|
|
const hashSession = decodeURIComponent(window.location.hash.replace(/^#/, ""));
|
|
const knownIds = new Set(summaries.map((s) => s.execution_id));
|
|
const bootSession = knownIds.has(hashSession) ? hashSession : activeSessionId;
|
|
renderSessionChooser();
|
|
renderSession(bootSession);
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
|
|
def _sort_records(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
return sorted(
|
|
records,
|
|
key=lambda r: (str(r.get("timestamp", "")), r.get("iteration", 0)),
|
|
)
|
|
|
|
|
|
def _load_session_data(logs_dir: Path, session_id: str, limit_files: int) -> list[dict[str, Any]] | None:
|
|
"""Load records for a specific session on demand."""
|
|
if not logs_dir.exists():
|
|
return None
|
|
|
|
files = sorted(
|
|
[path for path in logs_dir.iterdir() if path.is_file() and path.suffix == ".jsonl"],
|
|
key=lambda path: path.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": "",
|
|
"assistant_text": "",
|
|
"_parse_error": f"{path.name}:{line_number}",
|
|
"_raw_line": line,
|
|
}
|
|
# Only include records for this session
|
|
if str(payload.get("execution_id") or "").strip() == session_id:
|
|
payload["_log_file"] = str(path)
|
|
records.append(payload)
|
|
except OSError:
|
|
continue
|
|
|
|
return _sort_records(records) if records else None
|
|
|
|
|
|
def _run_server(
|
|
html: str,
|
|
logs_dir: Path,
|
|
limit_files: int,
|
|
port: int,
|
|
no_open: bool,
|
|
) -> None:
|
|
html_bytes = html.encode("utf-8")
|
|
session_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/") :])
|
|
# Check cache first
|
|
if sid in session_cache:
|
|
records = session_cache[sid]
|
|
else:
|
|
records = _load_session_data(logs_dir, sid, limit_files)
|
|
if records is not None:
|
|
session_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 # silence per-request logs
|
|
|
|
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 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 _discover_session_summaries(logs_dir: Path, limit_files: int, include_tests: bool) -> list[SessionSummary]:
|
|
"""Discover only session summaries without loading full record data."""
|
|
if not logs_dir.exists():
|
|
raise FileNotFoundError(f"log directory not found: {logs_dir}")
|
|
|
|
files = sorted(
|
|
[path for path in logs_dir.iterdir() if path.is_file() and path.suffix == ".jsonl"],
|
|
key=lambda path: path.stat().st_mtime,
|
|
reverse=True,
|
|
)[:limit_files]
|
|
|
|
# Collect minimal info per session: just first/last records and metadata
|
|
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
|
|
execution_id = str(payload.get("execution_id") or "").strip()
|
|
if execution_id:
|
|
# Store minimal data for summary generation
|
|
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[execution_id].append(minimal)
|
|
except OSError:
|
|
continue
|
|
|
|
# Filter out test sessions if needed
|
|
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 execution_id, session_records in by_session.items():
|
|
session_records.sort(
|
|
key=lambda record: (
|
|
str(record.get("timestamp", "")),
|
|
record.get("iteration", 0),
|
|
)
|
|
)
|
|
first = session_records[0]
|
|
last = session_records[-1]
|
|
summaries.append(
|
|
SessionSummary(
|
|
execution_id=execution_id,
|
|
log_file=str(first.get("_log_file", "")),
|
|
start_timestamp=str(first.get("timestamp", "")),
|
|
end_timestamp=str(last.get("timestamp", "")),
|
|
turn_count=len(session_records),
|
|
streams=sorted({str(r.get("stream_id", "")) for r in session_records if r.get("stream_id")}),
|
|
nodes=sorted({str(r.get("node_id", "")) for r in session_records if r.get("node_id")}),
|
|
models=sorted(
|
|
{
|
|
str(r.get("token_counts", {}).get("model", ""))
|
|
for r in session_records
|
|
if isinstance(r.get("token_counts"), dict) and r.get("token_counts", {}).get("model")
|
|
}
|
|
),
|
|
)
|
|
)
|
|
|
|
summaries.sort(key=lambda summary: summary.start_timestamp, reverse=True)
|
|
return summaries
|
|
|
|
|
|
def main() -> int:
|
|
args = _parse_args()
|
|
logs_dir = args.logs_dir.expanduser()
|
|
|
|
# Only discover summaries, not full session data
|
|
summaries = _discover_session_summaries(logs_dir, args.limit_files, args.include_tests)
|
|
|
|
initial_session_id = args.session or (summaries[0].execution_id if summaries else "")
|
|
if initial_session_id and not any(s.execution_id == initial_session_id for s in summaries):
|
|
print(f"session not found: {initial_session_id}")
|
|
return 1
|
|
|
|
html_report = _render_html(summaries, initial_session_id)
|
|
|
|
if args.output:
|
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
args.output.write_text(html_report, encoding="utf-8")
|
|
print(args.output)
|
|
return 0
|
|
|
|
_run_server(html_report, logs_dir, args.limit_files, args.port, args.no_open)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|