@raquezha/notrace 0.0.3 → 0.0.5

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.
@@ -2,6 +2,302 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
3
3
  import * as path from "node:path";
4
4
 
5
+ const REDACTED = "[REDACTED by notrace]";
6
+ const MAX_STRING_LENGTH = 20_000;
7
+ const MAX_ARRAY_ITEMS = 200;
8
+ const MAX_OBJECT_KEYS = 200;
9
+ const MAX_DEPTH = 8;
10
+
11
+ const SENSITIVE_KEY_RE = /(authorization|cookie|setcookie|password|passwd|pwd|secret|token|apikey|accesskey|accesskeyid|accessid|accesstoken|privatekey|session|credential|refreshtoken|idtoken)/i;
12
+ const SENSITIVE_VALUE_RE = /(bearer\s+[a-z0-9._~+/=-]{12,}|sk-[a-z0-9_-]{16,}|gh[pousr]_[a-z0-9_]{16,}|xox[baprs]-[a-z0-9-]{16,}|AKIA[0-9A-Z]{16})/gi;
13
+
14
+ type CaptureMode = "metadata" | "redacted" | "full";
15
+
16
+ function getCaptureMode(): CaptureMode {
17
+ const mode = process.env.NOTRACE_CAPTURE?.toLowerCase();
18
+ if (mode === "metadata" || mode === "full") return mode;
19
+ return "redacted";
20
+ }
21
+
22
+ function isSensitiveKey(key: string): boolean {
23
+ const normalized = key.replace(/[^a-z0-9]/gi, "");
24
+ if (/^(inputtokens|outputtokens|totaltokens|prompttokens|completiontokens|reasoningtokens|cachedtokens|cachecreationinputtokens|cachereadinputtokens|cost|total|input|output|prompt|completion|reasoning|read|write)$/i.test(normalized)) {
25
+ return false;
26
+ }
27
+ return SENSITIVE_KEY_RE.test(normalized);
28
+ }
29
+
30
+ function sanitizeUsageValue(usage: unknown): unknown {
31
+ if (getCaptureMode() === "full") return usage;
32
+ if (!usage || typeof usage !== "object") return sanitizeTraceValue(usage);
33
+
34
+ const source = usage as Record<string, unknown>;
35
+ const output: Record<string, unknown> = {};
36
+
37
+ for (const [key, value] of Object.entries(source)) {
38
+ if (typeof value === "number" || typeof value === "boolean" || value == null) {
39
+ output[key] = value;
40
+ } else if (typeof value === "object" && value) {
41
+ output[key] = sanitizeUsageValue(value);
42
+ } else {
43
+ output[key] = sanitizeTraceValue(value);
44
+ }
45
+ }
46
+
47
+ return output;
48
+ }
49
+
50
+ function redactString(value: string): string {
51
+ const redacted = value.replace(SENSITIVE_VALUE_RE, REDACTED);
52
+ if (redacted.length <= MAX_STRING_LENGTH) return redacted;
53
+ return `${redacted.slice(0, MAX_STRING_LENGTH)}\n…[truncated ${redacted.length - MAX_STRING_LENGTH} chars by notrace]`;
54
+ }
55
+
56
+ function sanitizeTraceValue(value: unknown, depth = 0, seen = new WeakSet<object>()): unknown {
57
+ if (getCaptureMode() === "full") return value;
58
+ if (value == null || typeof value === "number" || typeof value === "boolean") return value;
59
+ if (typeof value === "string") return redactString(value);
60
+ if (typeof value === "bigint") return value.toString();
61
+ if (typeof value === "function" || typeof value === "symbol") return `[${typeof value}]`;
62
+ if (depth >= MAX_DEPTH) return "[Max depth reached by notrace]";
63
+ if (typeof value !== "object") return String(value);
64
+ if (seen.has(value)) return "[Circular]";
65
+ seen.add(value);
66
+
67
+ if (Array.isArray(value)) {
68
+ const items = value.slice(0, MAX_ARRAY_ITEMS).map((item) => sanitizeTraceValue(item, depth + 1, seen));
69
+ if (value.length > MAX_ARRAY_ITEMS) items.push(`…[truncated ${value.length - MAX_ARRAY_ITEMS} items by notrace]`);
70
+ return items;
71
+ }
72
+
73
+ const output: Record<string, unknown> = {};
74
+ const entries = Object.entries(value as Record<string, unknown>);
75
+ for (const [key, item] of entries.slice(0, MAX_OBJECT_KEYS)) {
76
+ output[key] = isSensitiveKey(key) ? REDACTED : sanitizeTraceValue(item, depth + 1, seen);
77
+ }
78
+ if (entries.length > MAX_OBJECT_KEYS) output.__notrace_truncated__ = `${entries.length - MAX_OBJECT_KEYS} keys`;
79
+ return output;
80
+ }
81
+
82
+ function safeResolveUnder(baseDir: string, candidate: string): string | null {
83
+ const base = path.resolve(baseDir);
84
+ const resolved = path.resolve(base, candidate);
85
+ const relative = path.relative(base, resolved);
86
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)) ? resolved : null;
87
+ }
88
+
89
+ function escapeHtml(value: unknown): string {
90
+ return String(value).replace(/[&<>'"]/g, (char) => {
91
+ switch (char) {
92
+ case "&": return "&amp;";
93
+ case "<": return "&lt;";
94
+ case ">": return "&gt;";
95
+ case "'": return "&#39;";
96
+ case '"': return "&quot;";
97
+ default: return char;
98
+ }
99
+ });
100
+ }
101
+
102
+ type NotraceLocation = {
103
+ workflowDir: string;
104
+ notraceDir: string;
105
+ outputDir: string;
106
+ taskDir: string | null;
107
+ taskId: string | null;
108
+ taskPath: string | null;
109
+ };
110
+
111
+ type NotraceMetrics = {
112
+ totalTokens: number;
113
+ inputTokens: number;
114
+ outputTokens: number;
115
+ totalCost: number;
116
+ toolCallCount: number;
117
+ toolErrorCount: number;
118
+ llmCallCount: number;
119
+ turnCount: number;
120
+ models: string[];
121
+ providers: string[];
122
+ };
123
+
124
+ function ensureWorkflowDir(workflowDir: string): void {
125
+ if (existsSync(workflowDir)) return;
126
+ try {
127
+ mkdirSync(workflowDir, { recursive: true, mode: 0o700 });
128
+ } catch {}
129
+ }
130
+
131
+ function safePathSegment(value: string): string {
132
+ return value.replace(/[^a-zA-Z0-9._-]/g, "-").slice(0, 160) || `session-${Date.now()}`;
133
+ }
134
+
135
+ function getNotraceLocation(cwd: string, sessionId: string): NotraceLocation {
136
+ const workflowDir = path.resolve(cwd, ".workflow");
137
+ const notraceDir = path.resolve(cwd, ".notrace");
138
+ const outputDir = path.join(notraceDir, "sessions", safePathSegment(sessionId));
139
+ ensureWorkflowDir(workflowDir);
140
+
141
+ try {
142
+ const activeTaskJsonPath = path.join(workflowDir, "active_task.json");
143
+ if (existsSync(activeTaskJsonPath)) {
144
+ const content = JSON.parse(readFileSync(activeTaskJsonPath, "utf-8"));
145
+ const candidate = typeof content.taskPath === "string"
146
+ ? safeResolveUnder(cwd, content.taskPath)
147
+ : typeof content.active_task === "string"
148
+ ? safeResolveUnder(workflowDir, path.join("tasks", content.active_task))
149
+ : null;
150
+
151
+ if (candidate && safeResolveUnder(workflowDir, path.relative(workflowDir, candidate))) {
152
+ return {
153
+ workflowDir,
154
+ notraceDir,
155
+ outputDir,
156
+ taskDir: candidate,
157
+ taskId: typeof content.active_task === "string" ? content.active_task : path.basename(candidate),
158
+ taskPath: path.relative(cwd, candidate)
159
+ };
160
+ }
161
+ }
162
+ } catch {
163
+ // fallback
164
+ }
165
+
166
+ return {
167
+ workflowDir,
168
+ notraceDir,
169
+ outputDir,
170
+ taskDir: null,
171
+ taskId: null,
172
+ taskPath: null
173
+ };
174
+ }
175
+
176
+ function updateNotraceIndex(cwd: string, location: NotraceLocation, entry: Record<string, unknown>): void {
177
+ const indexPath = path.join(location.notraceDir, "index.json");
178
+ const base = {
179
+ schemaVersion: 1,
180
+ kind: "notrace-index",
181
+ workdir: ".",
182
+ sessions: [] as Record<string, unknown>[]
183
+ };
184
+
185
+ try {
186
+ mkdirSync(location.notraceDir, { recursive: true, mode: 0o700 });
187
+ const existing = existsSync(indexPath)
188
+ ? JSON.parse(readFileSync(indexPath, "utf-8"))
189
+ : base;
190
+ const sessions = Array.isArray(existing.sessions) ? existing.sessions : [];
191
+ const sessionId = entry.sessionId;
192
+ const nextSessions = sessions.filter((item: any) => item?.sessionId !== sessionId);
193
+ nextSessions.push(entry);
194
+ const next = {
195
+ ...base,
196
+ ...existing,
197
+ schemaVersion: 1,
198
+ kind: "notrace-index",
199
+ workdir: ".",
200
+ sessions: nextSessions
201
+ };
202
+ writeFileSync(indexPath, `${JSON.stringify(next, null, 2)}\n`, { encoding: "utf-8", mode: 0o600 });
203
+ } catch {
204
+ // keep notrace non-fatal if index update fails
205
+ }
206
+ }
207
+
208
+ function appendWorkLogEntry(taskDir: string, message: string): void {
209
+ const workMd = path.join(taskDir, "WORK.md");
210
+ if (!existsSync(workMd)) return;
211
+
212
+ try {
213
+ const text = readFileSync(workMd, "utf-8");
214
+ const entry = `- ${new Date().toISOString()}: ${message}`;
215
+
216
+ if (!/^(## )?\[LOG\]\s*$/m.test(text)) {
217
+ writeFileSync(workMd, `${text.trimEnd()}\n\n## [LOG]\n${entry}\n`, { encoding: "utf-8" });
218
+ return;
219
+ }
220
+
221
+ const lines = text.split("\n");
222
+ const logIndex = lines.findIndex((line) => /^(## )?\[LOG\]\s*$/.test(line));
223
+ if (logIndex === -1) return;
224
+
225
+ let nextSectionIndex = lines.length;
226
+ for (let i = logIndex + 1; i < lines.length; i++) {
227
+ if (/^(## )?\[[A-Z0-9_-]+\]\s*$/.test(lines[i])) {
228
+ nextSectionIndex = i;
229
+ break;
230
+ }
231
+ }
232
+
233
+ const before = lines.slice(0, nextSectionIndex);
234
+ const after = lines.slice(nextSectionIndex);
235
+ while (before.length > logIndex + 1 && before[before.length - 1]?.trim() === "") {
236
+ before.pop();
237
+ }
238
+ before.push(entry);
239
+
240
+ writeFileSync(workMd, `${[...before, ...after].join("\n").replace(/\n*$/, "\n")}`, { encoding: "utf-8" });
241
+ } catch {
242
+ // keep notrace non-fatal if WORK.md append fails
243
+ }
244
+ }
245
+
246
+ function collectMetrics(events: any[]): NotraceMetrics {
247
+ let totalTokens = 0;
248
+ let inputTokens = 0;
249
+ let outputTokens = 0;
250
+ let totalCost = 0;
251
+ let toolCallCount = 0;
252
+ let toolErrorCount = 0;
253
+ let llmCallCount = 0;
254
+ let turnCount = 0;
255
+ const models = new Set<string>();
256
+ const providers = new Set<string>();
257
+
258
+ for (const event of events) {
259
+ if (event.type === "turn_start") {
260
+ turnCount++;
261
+ continue;
262
+ }
263
+
264
+ if (event.type === "tool_start") {
265
+ toolCallCount++;
266
+ continue;
267
+ }
268
+
269
+ if (event.type === "tool_end") {
270
+ if (event.isError) toolErrorCount++;
271
+ continue;
272
+ }
273
+
274
+ if (event.type === "llm_completion") {
275
+ llmCallCount++;
276
+ if (typeof event.model === "string" && event.model) models.add(event.model);
277
+ if (typeof event.provider === "string" && event.provider) providers.add(event.provider);
278
+ if (event.usage) {
279
+ inputTokens += event.usage.input || 0;
280
+ outputTokens += event.usage.output || 0;
281
+ totalTokens += event.usage.totalTokens || 0;
282
+ totalCost += event.usage.cost?.total || 0;
283
+ }
284
+ }
285
+ }
286
+
287
+ return {
288
+ totalTokens,
289
+ inputTokens,
290
+ outputTokens,
291
+ totalCost,
292
+ toolCallCount,
293
+ toolErrorCount,
294
+ llmCallCount,
295
+ turnCount,
296
+ models: [...models],
297
+ providers: [...providers]
298
+ };
299
+ }
300
+
5
301
  /**
6
302
  * html-observability extension
7
303
  *
@@ -17,28 +313,6 @@ export default function (pi: ExtensionAPI) {
17
313
  let llmStartTime = 0;
18
314
  const activeToolTimes: Record<string, number> = {};
19
315
 
20
- // Helper to extract active task path from .workflow/active_task.json
21
- function getActiveTaskDir(cwd: string): string {
22
- try {
23
- const activeTaskJsonPath = path.join(cwd, ".workflow", "active_task.json");
24
- if (existsSync(activeTaskJsonPath)) {
25
- const content = JSON.parse(readFileSync(activeTaskJsonPath, "utf-8"));
26
- if (content.taskPath) {
27
- return path.resolve(cwd, content.taskPath);
28
- } else if (content.active_task) {
29
- return path.resolve(cwd, ".workflow", "tasks", content.active_task);
30
- }
31
- }
32
- } catch {
33
- // fallback
34
- }
35
- const defaultDir = path.join(cwd, ".workflow");
36
- if (!existsSync(defaultDir)) {
37
- try { mkdirSync(defaultDir, { recursive: true }); } catch {}
38
- }
39
- return defaultDir;
40
- }
41
-
42
316
  // 1. Session start
43
317
  pi.on("session_start", async (event, ctx) => {
44
318
  traceId = ctx.sessionManager.getSessionId() || `session-${Date.now()}`;
@@ -74,7 +348,7 @@ export default function (pi: ExtensionAPI) {
74
348
  type: "tool_start",
75
349
  toolCallId,
76
350
  toolName,
77
- args,
351
+ args: getCaptureMode() === "metadata" ? "[metadata-only capture]" : sanitizeTraceValue(args),
78
352
  timestamp: Date.now()
79
353
  });
80
354
  });
@@ -89,7 +363,7 @@ export default function (pi: ExtensionAPI) {
89
363
  type: "tool_end",
90
364
  toolCallId,
91
365
  toolName,
92
- result,
366
+ result: getCaptureMode() === "metadata" ? "[metadata-only capture]" : sanitizeTraceValue(result),
93
367
  isError,
94
368
  durationMs,
95
369
  timestamp: Date.now()
@@ -99,7 +373,7 @@ export default function (pi: ExtensionAPI) {
99
373
 
100
374
  // 6. LLM call start (capture payload)
101
375
  pi.on("before_provider_request", async (event, ctx) => {
102
- activeLlmPayload = event.payload;
376
+ activeLlmPayload = getCaptureMode() === "metadata" ? null : sanitizeTraceValue(event.payload);
103
377
  llmStartTime = Date.now();
104
378
  });
105
379
 
@@ -115,8 +389,8 @@ export default function (pi: ExtensionAPI) {
115
389
  model: message.model || "unknown",
116
390
  provider: message.provider || "unknown",
117
391
  inputPayload: activeLlmPayload,
118
- outputContent: message.content,
119
- usage: message.usage,
392
+ outputContent: getCaptureMode() === "metadata" ? "[metadata-only capture]" : sanitizeTraceValue(message.content),
393
+ usage: sanitizeUsageValue(message.usage),
120
394
  durationMs,
121
395
  timestamp: Date.now()
122
396
  });
@@ -129,56 +403,103 @@ export default function (pi: ExtensionAPI) {
129
403
  pi.on("session_shutdown", async (event, ctx) => {
130
404
  const sessionEndTime = Date.now();
131
405
  const totalDurationMs = sessionEndTime - sessionStartTime;
132
-
133
- const taskDir = getActiveTaskDir(ctx.cwd);
134
- const reportPath = path.join(taskDir, "notrace.html");
135
-
136
- // Gather statistics
137
- let totalTokens = 0;
138
- let inputTokens = 0;
139
- let outputTokens = 0;
140
- let totalCost = 0;
141
- let toolCallCount = 0;
142
- let llmCallCount = 0;
143
-
144
- events.forEach((e) => {
145
- if (e.type === "llm_completion") {
146
- llmCallCount++;
147
- if (e.usage) {
148
- inputTokens += e.usage.input || 0;
149
- outputTokens += e.usage.output || 0;
150
- totalTokens += e.usage.totalTokens || 0;
151
- totalCost += e.usage.cost?.total || 0;
152
- }
153
- } else if (e.type === "tool_start") {
154
- toolCallCount++;
155
- }
156
- });
406
+ const projectName = process.env.PHOENIX_PROJECT_NAME || "pi-coding-agent";
407
+ const captureMode = getCaptureMode();
408
+ const location = getNotraceLocation(ctx.cwd, traceId);
409
+ const reportPath = path.join(location.outputDir, "notrace.html");
410
+ const recordPath = path.join(location.outputDir, "notrace.json");
411
+ const reviewPath = path.join(location.outputDir, "notrace.review.json");
412
+ const metrics = collectMetrics(events);
413
+ const sessionFile = ctx.sessionManager.getSessionFile() || null;
157
414
 
158
415
  const htmlContent = generateHtmlReport({
159
416
  traceId,
160
- projectName: process.env.PHOENIX_PROJECT_NAME || "pi-coding-agent",
417
+ projectName,
161
418
  startTime: new Date(sessionStartTime).toISOString(),
162
419
  endTime: new Date(sessionEndTime).toISOString(),
163
420
  durationMs: totalDurationMs,
164
421
  metrics: {
165
- totalTokens,
166
- inputTokens,
167
- outputTokens,
168
- totalCost: totalCost.toFixed(5),
169
- toolCallCount,
170
- llmCallCount
422
+ totalTokens: metrics.totalTokens,
423
+ inputTokens: metrics.inputTokens,
424
+ outputTokens: metrics.outputTokens,
425
+ totalCost: metrics.totalCost.toFixed(5),
426
+ toolCallCount: metrics.toolCallCount,
427
+ llmCallCount: metrics.llmCallCount
171
428
  },
172
429
  events
173
430
  });
174
431
 
432
+ const runRecord = {
433
+ schemaVersion: 1,
434
+ kind: "notrace-run",
435
+ traceId,
436
+ runtime: "pi",
437
+ projectName,
438
+ captureMode,
439
+ session: {
440
+ id: traceId,
441
+ cwd: ctx.cwd,
442
+ file: sessionFile
443
+ },
444
+ task: location.taskId || location.taskPath
445
+ ? {
446
+ id: location.taskId,
447
+ path: location.taskPath
448
+ }
449
+ : null,
450
+ conditions: {
451
+ models: metrics.models,
452
+ providers: metrics.providers
453
+ },
454
+ activity: {
455
+ startTime: new Date(sessionStartTime).toISOString(),
456
+ endTime: new Date(sessionEndTime).toISOString(),
457
+ durationMs: totalDurationMs,
458
+ turnCount: metrics.turnCount,
459
+ llmCallCount: metrics.llmCallCount,
460
+ toolCallCount: metrics.toolCallCount,
461
+ toolErrorCount: metrics.toolErrorCount,
462
+ totals: {
463
+ totalTokens: metrics.totalTokens,
464
+ inputTokens: metrics.inputTokens,
465
+ outputTokens: metrics.outputTokens,
466
+ totalCostUsd: Number(metrics.totalCost.toFixed(5))
467
+ }
468
+ },
469
+ artifacts: {
470
+ htmlReportPath: path.relative(ctx.cwd, reportPath),
471
+ recordPath: path.relative(ctx.cwd, recordPath),
472
+ reviewPath: path.relative(ctx.cwd, reviewPath)
473
+ },
474
+ evidence: {
475
+ events
476
+ }
477
+ };
478
+
175
479
  try {
176
- writeFileSync(reportPath, htmlContent, "utf-8");
177
- // Output a nice clickable file:// link to the console for the user
178
- console.log(`\n📊 [notrace] Observability report generated:`);
179
- console.log(`👉 \x1b[36mfile://${reportPath}\x1b[0m\n`);
480
+ mkdirSync(location.outputDir, { recursive: true, mode: 0o700 });
481
+ writeFileSync(reportPath, htmlContent, { encoding: "utf-8", mode: 0o600 });
482
+ writeFileSync(recordPath, `${JSON.stringify(runRecord, null, 2)}\n`, { encoding: "utf-8", mode: 0o600 });
483
+ updateNotraceIndex(ctx.cwd, location, {
484
+ sessionId: traceId,
485
+ startedAt: new Date(sessionStartTime).toISOString(),
486
+ endedAt: new Date(sessionEndTime).toISOString(),
487
+ taskId: location.taskId,
488
+ taskPath: location.taskPath,
489
+ artifacts: {
490
+ record: path.relative(ctx.cwd, recordPath),
491
+ html: path.relative(ctx.cwd, reportPath),
492
+ review: path.relative(ctx.cwd, reviewPath)
493
+ }
494
+ });
495
+ if (location.taskDir && (location.taskId || location.taskPath)) {
496
+ appendWorkLogEntry(location.taskDir, `notrace captured artifacts: ${path.relative(location.taskDir, reportPath)}, ${path.relative(location.taskDir, recordPath)}`);
497
+ }
498
+ console.log(`\n📊 [notrace] Observability artifacts generated:`);
499
+ console.log(`👉 \x1b[36mfile://${reportPath}\x1b[0m`);
500
+ console.log(`🧾 \x1b[36mfile://${recordPath}\x1b[0m\n`);
180
501
  } catch (err: any) {
181
- console.warn(`[notrace] Failed to write HTML report: ${err?.message || err}`);
502
+ console.warn(`[notrace] Failed to write notrace artifacts: ${err?.message || err}`);
182
503
  }
183
504
  });
184
505
  }
@@ -199,313 +520,234 @@ function safeJsonForScript(value: any): string {
199
520
  // Returns a self-contained premium HTML template incorporating the design tokens
200
521
  function generateHtmlReport(data: any): string {
201
522
  const serializedData = safeJsonForScript(data);
523
+ const escapedTraceId = escapeHtml(data.traceId);
202
524
 
203
525
  return `<!DOCTYPE html>
204
526
  <html lang="en">
205
527
  <head>
206
528
  <meta charset="UTF-8">
207
529
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
208
- <title>notrace - ${data.traceId}</title>
209
- <link rel="preconnect" href="https://fonts.googleapis.com">
210
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
211
- <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=Source+Code+Pro:wght@400;500&display=swap" rel="stylesheet">
530
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'; connect-src 'none'">
531
+ <meta name="referrer" content="no-referrer">
532
+ <title>notrace - ${escapedTraceId}</title>
212
533
  <style>
213
534
  :root {
214
- --bg: #0b0b0e;
215
- --bg-panel: rgba(22, 22, 28, 0.45);
216
- --bg-card: rgba(30, 30, 38, 0.6);
217
- --border: rgba(255, 255, 255, 0.08);
218
- --border-hover: rgba(255, 255, 255, 0.15);
219
- --text: #e2e2e9;
220
- --text-muted: #9f9fa9;
221
- --accent: #8b5cf6;
222
- --accent-gradient: linear-gradient(135deg, #a78bfa, #8b5cf6);
223
- --success: #10b981;
224
- --error: #ef4444;
225
- --warning: #f59e0b;
226
- --info: #3b82f6;
227
- }
228
-
229
- * {
230
- box-sizing: border-box;
231
- margin: 0;
232
- padding: 0;
233
- }
535
+ --bg: #191613;
536
+ --bg-glow: #2a221c;
537
+ --panel: rgba(244, 237, 228, 0.055);
538
+ --panel-strong: rgba(244, 237, 228, 0.075);
539
+ --paper: #f6efe7;
540
+ --paper-soft: #e7dbce;
541
+ --text: #f5eee7;
542
+ --text-soft: #ddd0c2;
543
+ --text-muted: #b9ab9d;
544
+ --border: rgba(255, 255, 255, 0.09);
545
+ --border-strong: rgba(255, 255, 255, 0.16);
546
+ --accent: #d88462;
547
+ --accent-soft: rgba(216, 132, 98, 0.14);
548
+ --session: #8ab7ff;
549
+ --turn: #e0b46b;
550
+ --tool: #86cca4;
551
+ --error: #f08e8e;
552
+ --shadow: 0 24px 80px rgba(0, 0, 0, 0.34);
553
+ --card-shadow: 0 10px 28px rgba(0, 0, 0, 0.18);
554
+ }
555
+
556
+ * { box-sizing: border-box; margin: 0; padding: 0; }
234
557
 
235
558
  body {
236
- font-family: 'Outfit', sans-serif;
237
- background-color: var(--bg);
559
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
560
+ background:
561
+ radial-gradient(900px 420px at 50% -10%, rgba(216, 132, 98, 0.16), transparent 60%),
562
+ radial-gradient(680px 360px at 10% 0%, rgba(255, 255, 255, 0.03), transparent 60%),
563
+ linear-gradient(180deg, #171411 0%, #1b1714 100%);
238
564
  color: var(--text);
239
- line-height: 1.5;
240
- padding: 2rem;
565
+ line-height: 1.65;
566
+ padding: 14px 12px 40px;
241
567
  min-height: 100vh;
242
- background-image:
243
- radial-gradient(circle at 10% 20%, rgba(139, 92, 246, 0.08) 0%, transparent 40%),
244
- radial-gradient(circle at 90% 80%, rgba(59, 130, 246, 0.06) 0%, transparent 40%);
245
- background-attachment: fixed;
568
+ letter-spacing: 0.01em;
246
569
  }
247
570
 
248
- .container {
249
- max-width: 1100px;
250
- margin: 0 auto;
251
- }
571
+ .container { max-width: 920px; margin: 0 auto; }
572
+ header { margin-bottom: 24px; }
252
573
 
253
- header {
254
- display: flex;
255
- justify-content: space-between;
256
- align-items: center;
257
- margin-bottom: 2rem;
258
- padding-bottom: 1.5rem;
259
- border-bottom: 1px solid var(--border);
260
- }
261
-
262
- .brand {
263
- display: flex;
264
- align-items: center;
265
- gap: 0.75rem;
266
- }
267
-
268
- .brand h1 {
269
- font-size: 1.75rem;
270
- font-weight: 700;
271
- background: var(--accent-gradient);
272
- -webkit-background-clip: text;
273
- -webkit-text-fill-color: transparent;
274
- }
275
-
276
- .brand-tag {
277
- font-size: 0.75rem;
278
- text-transform: uppercase;
279
- letter-spacing: 0.05em;
280
- background: rgba(139, 92, 246, 0.15);
281
- color: #c084fc;
282
- padding: 0.2rem 0.5rem;
283
- border-radius: 4px;
284
- border: 1px solid rgba(167, 139, 250, 0.3);
285
- }
286
-
287
- .meta-time {
288
- font-size: 0.875rem;
289
- color: var(--text-muted);
290
- text-align: right;
291
- }
292
-
293
- .metrics-grid {
294
- display: grid;
295
- grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
296
- gap: 1rem;
297
- margin-bottom: 2.5rem;
574
+ .hero {
575
+ position: relative;
576
+ overflow: hidden;
577
+ background: linear-gradient(180deg, rgba(255,255,255,0.045), rgba(255,255,255,0.025));
578
+ border: 1px solid var(--border);
579
+ border-radius: 22px;
580
+ padding: 18px 16px 16px;
581
+ box-shadow: var(--shadow);
582
+ backdrop-filter: blur(10px);
298
583
  }
299
584
 
585
+ .hero::after {
586
+ content: "";
587
+ position: absolute;
588
+ inset: auto -10% -30% auto;
589
+ width: 260px;
590
+ height: 260px;
591
+ background: radial-gradient(circle, rgba(216,132,98,0.14), transparent 70%);
592
+ pointer-events: none;
593
+ }
594
+
595
+ .brand { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; position: relative; z-index: 1; }
596
+ .brand h1 { font-size: 2rem; line-height: 1; font-weight: 680; color: var(--text); letter-spacing: -0.03em; }
597
+ .brand-tag { font-size: 0.72rem; letter-spacing: 0.08em; text-transform: uppercase; color: #f4ccb9; background: var(--accent-soft); border: 1px solid rgba(216, 132, 98, 0.24); padding: 0.35rem 0.6rem; border-radius: 999px; }
598
+ .hero-subtitle { position: relative; z-index: 1; color: var(--text-soft); margin-bottom: 20px; max-width: 720px; font-size: 1rem; }
599
+ .hero-meta { position: relative; z-index: 1; display: flex; flex-wrap: wrap; gap: 10px; }
600
+ .meta-pill { display: inline-flex; align-items: center; gap: 6px; background: rgba(255,255,255,0.04); border: 1px solid var(--border); border-radius: 999px; padding: 0.48rem 0.82rem; font-size: 0.86rem; color: var(--text-muted); }
601
+ .meta-pill strong { color: var(--text); font-weight: 570; }
602
+
603
+ .metrics-grid { display: grid; grid-template-columns: 1fr; gap: 10px; margin: 16px 0 24px; }
300
604
  .metric-card {
301
- background: var(--bg-panel);
605
+ background: linear-gradient(180deg, rgba(255,255,255,0.045), rgba(255,255,255,0.028));
302
606
  border: 1px solid var(--border);
303
- padding: 1.25rem;
304
- border-radius: 12px;
305
- backdrop-filter: blur(12px);
306
- transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
307
- }
308
-
309
- .metric-card:hover {
310
- border-color: var(--border-hover);
311
- transform: translateY(-2px);
312
- box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
313
- }
314
-
315
- .metric-label {
316
- font-size: 0.75rem;
607
+ border-radius: 20px;
608
+ padding: 18px 16px;
609
+ box-shadow: var(--card-shadow);
610
+ transition: border-color 0.18s ease, transform 0.18s ease, background 0.18s ease;
611
+ }
612
+ .metric-card:hover { border-color: var(--border-strong); transform: translateY(-1px); background: linear-gradient(180deg, rgba(255,255,255,0.055), rgba(255,255,255,0.03)); }
613
+ .metric-label { font-size: 0.76rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-muted); margin-bottom: 7px; }
614
+ .metric-value { font-size: 1.42rem; font-weight: 640; color: var(--text); letter-spacing: -0.03em; }
615
+
616
+ .section-title {
617
+ font-size: 0.95rem;
618
+ font-weight: 620;
619
+ color: var(--paper-soft);
620
+ margin-bottom: 16px;
621
+ padding-left: 2px;
622
+ letter-spacing: 0.04em;
317
623
  text-transform: uppercase;
318
- letter-spacing: 0.05em;
319
- color: var(--text-muted);
320
- margin-bottom: 0.5rem;
321
624
  }
322
625
 
323
- .metric-value {
324
- font-size: 1.5rem;
325
- font-weight: 600;
326
- color: #fff;
327
- }
328
-
329
- .timeline {
330
- position: relative;
331
- padding-left: 2rem;
332
- border-left: 2px solid var(--border);
333
- margin-left: 1rem;
334
- }
335
-
336
- .timeline-event {
337
- position: relative;
338
- margin-bottom: 2rem;
339
- animation: slideIn 0.4s ease-out;
340
- }
341
-
342
- @keyframes slideIn {
343
- from { opacity: 0; transform: translateX(-10px); }
344
- to { opacity: 1; transform: translateX(0); }
345
- }
346
-
347
- .timeline-dot {
348
- position: absolute;
349
- left: calc(-2rem - 6px);
350
- top: 1.25rem;
351
- width: 10px;
352
- height: 10px;
353
- border-radius: 50%;
354
- background: var(--text-muted);
355
- box-shadow: 0 0 0 4px var(--bg);
356
- transition: all 0.3s ease;
357
- }
358
-
359
- /* Event color mappings */
360
- .timeline-event.session-start .timeline-dot { background: var(--info); box-shadow: 0 0 0 4px var(--bg), 0 0 8px var(--info); }
361
- .timeline-event.turn-start .timeline-dot { background: var(--accent); box-shadow: 0 0 0 4px var(--bg), 0 0 8px var(--accent); }
362
- .timeline-event.tool-call .timeline-dot { background: var(--warning); box-shadow: 0 0 0 4px var(--bg); }
363
- .timeline-event.tool-call.error .timeline-dot { background: var(--error); box-shadow: 0 0 0 4px var(--bg), 0 0 8px var(--error); }
364
- .timeline-event.llm-call .timeline-dot { background: var(--success); box-shadow: 0 0 0 4px var(--bg), 0 0 8px var(--success); }
626
+ .timeline { display: flex; flex-direction: column; gap: 16px; }
627
+ .timeline-event { position: relative; animation: slideIn 0.28s ease-out; }
628
+ @keyframes slideIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
629
+ .timeline-dot { display: none; }
365
630
 
366
631
  .card {
367
- background: var(--bg-panel);
632
+ position: relative;
633
+ overflow: hidden;
634
+ background: linear-gradient(180deg, rgba(255,255,255,0.045), rgba(255,255,255,0.024));
368
635
  border: 1px solid var(--border);
369
- border-radius: 12px;
370
- padding: 1.25rem;
371
- backdrop-filter: blur(12px);
372
- transition: border-color 0.2s;
373
- }
374
-
375
- .card:hover {
376
- border-color: var(--border-hover);
636
+ border-radius: 18px;
637
+ padding: 14px 14px 13px;
638
+ box-shadow: var(--card-shadow);
639
+ transition: border-color 0.18s ease, background 0.18s ease, transform 0.18s ease;
377
640
  }
378
-
379
- .card-header {
380
- display: flex;
381
- justify-content: space-between;
382
- align-items: center;
383
- cursor: pointer;
384
- user-select: none;
385
- }
386
-
387
- .card-title {
388
- display: flex;
389
- align-items: center;
390
- gap: 0.75rem;
391
- font-weight: 600;
392
- }
393
-
394
- .card-badge {
395
- font-size: 0.75rem;
396
- font-weight: 500;
397
- padding: 0.15rem 0.5rem;
398
- border-radius: 4px;
399
- }
400
-
401
- .badge-session { background: rgba(59, 130, 246, 0.15); color: #60a5fa; }
402
- .badge-turn { background: rgba(139, 92, 246, 0.15); color: #c084fc; }
403
- .badge-tool { background: rgba(245, 158, 11, 0.15); color: #fbbf24; }
404
- .badge-tool.error { background: rgba(239, 68, 68, 0.15); color: #f87171; }
405
- .badge-llm { background: rgba(16, 185, 129, 0.15); color: #34d399; }
406
-
407
- .card-time {
408
- font-size: 0.8rem;
409
- color: var(--text-muted);
410
- display: flex;
411
- align-items: center;
412
- gap: 0.5rem;
413
- }
414
-
415
- .arrow-icon {
416
- width: 16px;
417
- height: 16px;
418
- fill: none;
419
- stroke: currentColor;
420
- stroke-width: 2;
421
- stroke-linecap: round;
422
- stroke-linejoin: round;
423
- transition: transform 0.2s;
424
- }
425
-
426
- .expanded .arrow-icon {
427
- transform: rotate(90deg);
428
- }
429
-
430
- .card-body {
431
- margin-top: 1rem;
432
- padding-top: 1rem;
433
- border-top: 1px solid var(--border);
434
- display: none;
435
- }
436
-
437
- .expanded .card-body {
438
- display: block;
439
- }
440
-
641
+ .card::before {
642
+ content: "";
643
+ position: absolute;
644
+ left: 0;
645
+ top: 0;
646
+ bottom: 0;
647
+ width: 3px;
648
+ background: rgba(255,255,255,0.1);
649
+ }
650
+ .timeline-event.session-start .card::before { background: var(--session); }
651
+ .timeline-event.turn-start .card::before { background: var(--turn); }
652
+ .timeline-event.tool-start .card::before { background: var(--tool); }
653
+ .timeline-event.tool-start.error .card::before { background: var(--error); }
654
+ .timeline-event.llm-start .card::before { background: var(--accent); }
655
+ .card:hover { border-color: var(--border-strong); background: linear-gradient(180deg, rgba(255,255,255,0.055), rgba(255,255,255,0.028)); transform: translateY(-1px); }
656
+
657
+ .card-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; cursor: pointer; user-select: none; }
658
+ .card-title { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; font-weight: 600; color: var(--text); }
659
+ .card-title span:last-child { font-size: 1rem; letter-spacing: -0.01em; }
660
+ .card-badge { font-size: 0.69rem; font-weight: 650; letter-spacing: 0.08em; text-transform: uppercase; padding: 0.34rem 0.62rem; border-radius: 999px; border: 1px solid transparent; }
661
+ .badge-session { background: rgba(138, 183, 255, 0.14); color: var(--session); border-color: rgba(138, 183, 255, 0.24); }
662
+ .badge-turn { background: rgba(224, 180, 107, 0.14); color: var(--turn); border-color: rgba(224, 180, 107, 0.22); }
663
+ .badge-tool { background: rgba(134, 204, 164, 0.14); color: var(--tool); border-color: rgba(134, 204, 164, 0.22); }
664
+ .badge-tool.error { background: rgba(240, 142, 142, 0.15); color: var(--error); border-color: rgba(240, 142, 142, 0.24); }
665
+ .badge-llm { background: rgba(216, 132, 98, 0.14); color: #f2c2ae; border-color: rgba(216, 132, 98, 0.22); }
666
+ .card-time { flex-shrink: 0; font-size: 0.82rem; color: var(--text-muted); display: flex; align-items: center; gap: 8px; padding-top: 3px; }
667
+ .arrow-icon { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; transition: transform 0.2s; }
668
+ .expanded .arrow-icon { transform: rotate(90deg); }
669
+ .card-body { margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--border); display: none; }
670
+ .expanded .card-body { display: block; }
671
+
672
+ .detail-label { font-size: 0.76rem; font-weight: 650; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.08em; margin: 14px 0 8px; display: block; }
441
673
  .code-block {
442
- font-family: 'Source Code Pro', monospace;
443
- font-size: 0.875rem;
444
- background: rgba(0, 0, 0, 0.35);
445
- border: 1px solid var(--border);
446
- padding: 1rem;
447
- border-radius: 8px;
674
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
675
+ font-size: 0.84rem;
676
+ line-height: 1.62;
677
+ background: rgba(16, 14, 12, 0.64);
678
+ border: 1px solid rgba(255, 255, 255, 0.06);
679
+ padding: 14px;
680
+ border-radius: 16px;
448
681
  overflow-x: auto;
449
- margin-top: 0.5rem;
450
- color: #e2e2e9;
682
+ margin-top: 0.35rem;
683
+ color: #f1e9df;
451
684
  white-space: pre-wrap;
452
685
  }
453
686
 
454
- .messages-container {
455
- display: flex;
456
- flex-direction: column;
457
- gap: 0.75rem;
458
- }
459
-
687
+ .messages-container { display: flex; flex-direction: column; gap: 10px; margin-top: 8px; }
460
688
  .msg-row {
461
689
  display: flex;
462
690
  flex-direction: column;
463
- gap: 0.25rem;
464
- padding: 0.75rem;
465
- background: rgba(255, 255, 255, 0.02);
466
- border-radius: 6px;
467
- border-left: 3px solid var(--border);
468
- }
469
-
470
- .msg-row.user { border-left-color: var(--info); }
471
- .msg-row.assistant { border-left-color: var(--success); }
472
- .msg-row.system { border-left-color: var(--accent); }
473
-
474
- .msg-role {
475
- font-size: 0.75rem;
476
- font-weight: 600;
477
- text-transform: uppercase;
478
- letter-spacing: 0.05em;
479
- }
480
-
481
- .msg-row.user .msg-role { color: #60a5fa; }
482
- .msg-row.assistant .msg-role { color: #34d399; }
483
- .msg-row.system .msg-role { color: #c084fc; }
484
-
485
- .msg-text {
486
- font-size: 0.9rem;
487
- white-space: pre-wrap;
488
- }
489
-
490
- .duration-pill {
491
- font-size: 0.75rem;
492
- background: rgba(255, 255, 255, 0.06);
493
- padding: 0.1rem 0.4rem;
494
- border-radius: 4px;
691
+ gap: 4px;
692
+ padding: 12px 14px;
693
+ background: rgba(255,255,255,0.038);
694
+ border: 1px solid rgba(255,255,255,0.06);
695
+ border-radius: 18px;
696
+ }
697
+ .msg-row.user { background: rgba(138, 183, 255, 0.08); }
698
+ .msg-row.assistant { background: rgba(216, 132, 98, 0.08); }
699
+ .msg-row.system { background: rgba(224, 180, 107, 0.08); }
700
+ .msg-role { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-muted); }
701
+ .msg-text { font-size: 0.93rem; color: var(--text-soft); white-space: pre-wrap; }
702
+
703
+ .usage-row {
704
+ margin-top: 12px;
705
+ font-size: 0.84rem;
495
706
  color: var(--text-muted);
707
+ display: flex;
708
+ flex-wrap: wrap;
709
+ gap: 12px;
710
+ padding: 10px 12px;
711
+ border-radius: 14px;
712
+ background: rgba(255,255,255,0.03);
713
+ border: 1px solid rgba(255,255,255,0.05);
714
+ }
715
+ .usage-row strong { color: var(--text); font-weight: 620; }
716
+ .duration-pill { font-size: 0.74rem; background: rgba(255,255,255,0.06); padding: 0.2rem 0.46rem; border-radius: 999px; color: var(--text-muted); border: 1px solid rgba(255,255,255,0.06); }
717
+
718
+ @media (min-width: 640px) {
719
+ body { padding: 28px 18px 56px; }
720
+ .hero { border-radius: 28px; padding: 28px 28px 24px; }
721
+ .metrics-grid { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; margin: 18px 0 30px; }
722
+ .card { border-radius: 22px; padding: 18px 18px 16px; }
723
+ }
724
+
725
+ @media (max-width: 720px) {
726
+ .hero, .card, .metric-card { border-radius: 18px; }
727
+ .card-header { flex-direction: column; }
728
+ .card-time { width: 100%; justify-content: space-between; }
729
+ .brand { align-items: flex-start; flex-direction: column; gap: 8px; }
730
+ .brand h1 { font-size: 1.8rem; }
731
+ .hero-meta { flex-direction: column; align-items: stretch; }
732
+ .meta-pill { width: 100%; justify-content: space-between; }
733
+ .usage-row { flex-direction: column; gap: 6px; }
734
+ .code-block { font-size: 0.8rem; padding: 12px; }
496
735
  }
497
736
  </style>
498
737
  </head>
499
738
  <body>
500
739
  <div class="container">
501
740
  <header>
502
- <div class="brand">
503
- <h1>notrace</h1>
504
- <span class="brand-tag">Local Observability</span>
505
- </div>
506
- <div class="meta-time">
507
- <div>Session: <span id="sess-id"></span></div>
508
- <div id="sess-time" style="font-size: 0.8rem;"></div>
741
+ <div class="hero">
742
+ <div class="brand">
743
+ <h1>notrace</h1>
744
+ <span class="brand-tag">retrospective</span>
745
+ </div>
746
+ <p class="hero-subtitle">A local evidence view of the run: messages, tools, model calls, timing, and token cost.</p>
747
+ <div class="hero-meta">
748
+ <span class="meta-pill">Session <strong id="sess-id"></strong></span>
749
+ <span class="meta-pill">Started <strong id="sess-time"></strong></span>
750
+ </div>
509
751
  </div>
510
752
  </header>
511
753
 
@@ -532,7 +774,7 @@ function generateHtmlReport(data: any): string {
532
774
  </div>
533
775
  </div>
534
776
 
535
- <h2 style="margin-bottom: 1.5rem; font-weight: 600; font-size: 1.25rem; color: #fff;">Activity Flow</h2>
777
+ <h2 class="section-title">Activity flow</h2>
536
778
  <div class="timeline" id="timeline-container">
537
779
  <!-- Injected by JS -->
538
780
  </div>
@@ -541,18 +783,34 @@ function generateHtmlReport(data: any): string {
541
783
  <script>
542
784
  const traceData = ${serializedData};
543
785
 
786
+ function escapeHtml(value) {
787
+ return String(value ?? "").replace(/[&<>'"]/g, (char) => ({
788
+ "&": "&amp;",
789
+ "<": "&lt;",
790
+ ">": "&gt;",
791
+ "'": "&#39;",
792
+ '"': "&quot;"
793
+ }[char]));
794
+ }
795
+
796
+ function safeClassName(value, fallback = "unknown") {
797
+ const normalized = String(value ?? fallback).toLowerCase().replace(/[^a-z0-9_-]/g, "-");
798
+ return normalized || fallback;
799
+ }
800
+
801
+ function jsonText(value) {
802
+ return escapeHtml(typeof value === "string" ? value : JSON.stringify(value, null, 2));
803
+ }
804
+
544
805
  // Render Metrics
545
806
  document.getElementById("sess-id").textContent = traceData.traceId;
546
807
  document.getElementById("sess-time").textContent = new Date(traceData.startTime).toLocaleString();
547
808
  document.getElementById("val-duration").textContent = (traceData.durationMs / 1000).toFixed(2) + "s";
548
809
  document.getElementById("val-tokens").textContent = traceData.metrics.totalTokens.toLocaleString();
549
- document.getElementById("val-llms").textContent = traceData.metrics.toolCallCount; // Wait, actually traceData.metrics.llmCallCount
810
+ document.getElementById("val-llms").textContent = traceData.metrics.llmCallCount;
550
811
  document.getElementById("val-tools").textContent = traceData.metrics.toolCallCount;
551
812
  document.getElementById("val-cost").textContent = "$" + traceData.metrics.totalCost;
552
813
 
553
- // Correct the labels mapping if names swapped
554
- document.getElementById("val-llms").textContent = traceData.metrics.llmCallCount;
555
-
556
814
  // Process & Render Timeline
557
815
  const container = document.getElementById("timeline-container");
558
816
  const events = traceData.events;
@@ -612,19 +870,20 @@ function generateHtmlReport(data: any): string {
612
870
  // Render cards
613
871
  renderedEvents.forEach((ev, index) => {
614
872
  const evDiv = document.createElement("div");
615
- evDiv.className = \`timeline-event \${ev.type}-start \${ev.isError ? "error" : ""}\`;
873
+ const eventType = safeClassName(ev.type);
874
+ evDiv.className = \`timeline-event \${eventType}-start \${ev.isError ? "error" : ""}\`;
616
875
 
617
876
  let cardHtml = \`
618
877
  <div class="timeline-dot"></div>
619
878
  <div class="card" id="card-\${index}">
620
879
  <div class="card-header" onclick="toggleCard(\${index})">
621
880
  <div class="card-title">
622
- <span class="card-badge badge-\${ev.type} \${ev.isError ? "error" : ""}">\${ev.type.toUpperCase()}</span>
623
- <span>\${ev.title}</span>
624
- \${ev.durationMs ? \`<span class="duration-pill">\${(ev.durationMs / 1000).toFixed(2)}s</span>\` : ""}
881
+ <span class="card-badge badge-\${eventType} \${ev.isError ? "error" : ""}">\${escapeHtml(eventType.toUpperCase())}</span>
882
+ <span>\${escapeHtml(ev.title)}</span>
883
+ \${ev.durationMs ? \`<span class="duration-pill">\${escapeHtml((ev.durationMs / 1000).toFixed(2))}s</span>\` : ""}
625
884
  </div>
626
885
  <div class="card-time">
627
- <span>\${ev.time}</span>
886
+ <span>\${escapeHtml(ev.time)}</span>
628
887
  <svg class="arrow-icon" viewBox="0 0 24 24"><path d="M9 5l7 7-7 7"/></svg>
629
888
  </div>
630
889
  </div>
@@ -632,13 +891,13 @@ function generateHtmlReport(data: any): string {
632
891
  \`;
633
892
 
634
893
  if (ev.type === "session" || ev.type === "turn") {
635
- cardHtml += \`<div class="code-block">\${ev.body}</div>\`;
894
+ cardHtml += \`<div class="code-block">\${escapeHtml(ev.body)}</div>\`;
636
895
  } else if (ev.type === "tool") {
637
896
  cardHtml += \`
638
- <strong>Arguments:</strong>
639
- <div class="code-block">\${JSON.stringify(ev.args, null, 2)}</div>
640
- <strong style="margin-top: 1rem; display: block;">Result (\${ev.isError ? "Error" : "Success"}):</strong>
641
- <div class="code-block">\${typeof ev.result === "string" ? ev.result : JSON.stringify(ev.result, null, 2)}</div>
897
+ <span class="detail-label">Arguments</span>
898
+ <div class="code-block">\${jsonText(ev.args)}</div>
899
+ <span class="detail-label">Result (\${ev.isError ? "Error" : "Success"})</span>
900
+ <div class="code-block">\${jsonText(ev.result)}</div>
642
901
  \`;
643
902
  } else if (ev.type === "llm") {
644
903
  // Render system prompt and input messages if present
@@ -651,17 +910,18 @@ function generateHtmlReport(data: any): string {
651
910
  messagesHtml += \`
652
911
  <div class="msg-row system">
653
912
  <span class="msg-role">System Instruction</span>
654
- <span class="msg-text">\${instr}</span>
913
+ <span class="msg-text">\${escapeHtml(instr)}</span>
655
914
  </div>
656
915
  \`;
657
916
  }
658
917
  if (ev.payload.messages && Array.isArray(ev.payload.messages)) {
659
918
  ev.payload.messages.forEach(m => {
660
919
  const contentText = typeof m.content === "string" ? m.content : JSON.stringify(m.content);
920
+ const role = safeClassName(m.role, "message");
661
921
  messagesHtml += \`
662
- <div class="msg-row \${m.role}">
663
- <span class="msg-role">\${m.role}</span>
664
- <span class="msg-text">\${contentText}</span>
922
+ <div class="msg-row \${role}">
923
+ <span class="msg-role">\${escapeHtml(m.role)}</span>
924
+ <span class="msg-text">\${escapeHtml(contentText)}</span>
665
925
  </div>
666
926
  \`;
667
927
  });
@@ -670,16 +930,16 @@ function generateHtmlReport(data: any): string {
670
930
  messagesHtml += '</div>';
671
931
 
672
932
  cardHtml += \`
673
- <strong>Context Messages:</strong>
933
+ <span class="detail-label">Context messages</span>
674
934
  \${messagesHtml}
675
- <strong style="margin-top: 1.25rem; display: block;">Generated Response:</strong>
676
- <div class="code-block">\${JSON.stringify(ev.output, null, 2)}</div>
935
+ <span class="detail-label">Generated response</span>
936
+ <div class="code-block">\${jsonText(ev.output)}</div>
677
937
  \${ev.usage ? \`
678
- <div style="margin-top: 1rem; font-size: 0.85rem; color: var(--text-muted); display: flex; gap: 1.5rem;">
679
- <span>Input Tokens: <strong>\${ev.usage.input}</strong></span>
680
- <span>Output Tokens: <strong>\${ev.usage.output}</strong></span>
681
- <span>Total Tokens: <strong>\${ev.usage.totalTokens}</strong></span>
682
- <span>Cost: <strong>\$\${ev.usage.cost?.total.toFixed(5) || "0.00"}</strong></span>
938
+ <div class="usage-row">
939
+ <span>Input tokens <strong>\${escapeHtml(ev.usage.input ?? 0)}</strong></span>
940
+ <span>Output tokens <strong>\${escapeHtml(ev.usage.output ?? 0)}</strong></span>
941
+ <span>Total tokens <strong>\${escapeHtml(ev.usage.totalTokens ?? 0)}</strong></span>
942
+ <span>Cost <strong>\$\${escapeHtml(ev.usage.cost?.total?.toFixed?.(5) || "0.00")}</strong></span>
683
943
  </div>
684
944
  \` : ""}
685
945
  \`;
@@ -697,7 +957,7 @@ function generateHtmlReport(data: any): string {
697
957
  // Expand/Collapse controller
698
958
  function toggleCard(index) {
699
959
  const card = document.getElementById(\`card-\${index}\`);
700
- card.classList.toggle("expanded");
960
+ card?.classList.toggle("expanded");
701
961
  }
702
962
  </script>
703
963
  </body>