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