@raquezha/notrace 0.1.1 → 0.2.0

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.
Files changed (80) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/notrace/index.d.ts +34 -0
  3. package/dist/notrace/index.js +144 -118
  4. package/dist/notrace/report-app/__tests__/analytics.test.d.ts +1 -0
  5. package/dist/notrace/report-app/__tests__/analytics.test.js +35 -0
  6. package/dist/notrace/report-app/__tests__/card.test.d.ts +1 -0
  7. package/dist/notrace/report-app/__tests__/card.test.js +26 -0
  8. package/dist/notrace/report-app/__tests__/event.test.d.ts +1 -0
  9. package/dist/notrace/report-app/__tests__/event.test.js +20 -0
  10. package/dist/notrace/report-app/__tests__/format.test.d.ts +1 -0
  11. package/dist/notrace/report-app/__tests__/format.test.js +41 -0
  12. package/dist/notrace/report-app/__tests__/report.test.d.ts +1 -0
  13. package/dist/notrace/report-app/__tests__/report.test.js +31 -0
  14. package/dist/notrace/report-app/analytics.d.ts +3 -0
  15. package/dist/notrace/report-app/analytics.js +78 -0
  16. package/dist/notrace/report-app/client.d.ts +2 -0
  17. package/dist/notrace/report-app/client.js +105 -0
  18. package/dist/notrace/report-app/components/card.d.ts +4 -0
  19. package/dist/notrace/report-app/components/card.js +36 -0
  20. package/dist/notrace/report-app/components/dashboard.d.ts +1 -0
  21. package/dist/notrace/report-app/components/dashboard.js +16 -0
  22. package/dist/notrace/report-app/components/event.d.ts +5 -0
  23. package/dist/notrace/report-app/components/event.js +42 -0
  24. package/dist/notrace/report-app/components/message.d.ts +2 -0
  25. package/dist/notrace/report-app/components/message.js +43 -0
  26. package/dist/notrace/report-app/dashboard-report.d.ts +1 -0
  27. package/dist/notrace/report-app/dashboard-report.js +6 -0
  28. package/dist/notrace/report-app/escape.d.ts +1 -0
  29. package/dist/notrace/report-app/escape.js +10 -0
  30. package/dist/notrace/report-app/format.d.ts +13 -0
  31. package/dist/notrace/report-app/format.js +102 -0
  32. package/dist/notrace/report-app/report.d.ts +1 -0
  33. package/dist/notrace/report-app/report.js +29 -0
  34. package/dist/notrace/report-app/shell.d.ts +5 -0
  35. package/dist/notrace/report-app/shell.js +19 -0
  36. package/dist/notrace/report-app/styles.d.ts +1 -0
  37. package/dist/notrace/report-app/styles.js +431 -0
  38. package/dist/notrace/report-app/types.d.ts +28 -0
  39. package/dist/notrace/report-app/types.js +1 -0
  40. package/extensions/notrace/__tests__/ghost-session.test.ts +103 -0
  41. package/extensions/notrace/__tests__/helpers.ts +11 -0
  42. package/extensions/notrace/__tests__/lock-race.test.ts +176 -0
  43. package/extensions/notrace/__tests__/usage-normalization.test.ts +80 -0
  44. package/extensions/notrace/index.ts +160 -124
  45. package/extensions/notrace/report-app/__tests__/analytics.test.ts +41 -0
  46. package/extensions/notrace/report-app/__tests__/card.test.ts +29 -0
  47. package/extensions/notrace/report-app/__tests__/event.test.ts +23 -0
  48. package/extensions/notrace/report-app/__tests__/format.test.ts +46 -0
  49. package/extensions/notrace/report-app/__tests__/report.test.ts +33 -0
  50. package/extensions/notrace/report-app/analytics.ts +79 -0
  51. package/extensions/notrace/report-app/client.ts +106 -0
  52. package/extensions/notrace/report-app/components/card.ts +38 -0
  53. package/extensions/notrace/report-app/components/dashboard.ts +17 -0
  54. package/extensions/notrace/report-app/components/event.ts +39 -0
  55. package/extensions/notrace/report-app/components/message.ts +39 -0
  56. package/extensions/notrace/report-app/dashboard-report.ts +7 -0
  57. package/extensions/notrace/report-app/escape.ts +10 -0
  58. package/extensions/notrace/report-app/format.ts +107 -0
  59. package/extensions/notrace/report-app/report.ts +33 -0
  60. package/extensions/notrace/report-app/shell.ts +24 -0
  61. package/extensions/notrace/report-app/styles.ts +431 -0
  62. package/extensions/notrace/report-app/types.ts +35 -0
  63. package/package.json +4 -2
  64. package/templates/dashboard.sample.html +103 -63
  65. package/templates/dashboard.sample.json +73 -10
  66. package/templates/render-samples.mjs +119 -1
  67. package/templates/session.sample.html +125 -168
  68. package/templates/session.sample.json +66 -7
  69. package/templates/sessions/019ed2ee-1000-76ee-b353-000000000001/notrace.html +125 -163
  70. package/templates/sessions/019ed2ee-1000-76ee-b353-000000000001/notrace.json +50 -0
  71. package/templates/sessions/019ed2ee-1001-76ee-b353-000000000002/notrace.html +125 -162
  72. package/templates/sessions/019ed2ee-1001-76ee-b353-000000000002/notrace.json +50 -0
  73. package/templates/sessions/019ed2ee-1002-76ee-b353-000000000003/notrace.html +125 -163
  74. package/templates/sessions/019ed2ee-1002-76ee-b353-000000000003/notrace.json +50 -0
  75. package/templates/sessions/019ed2ee-massive/notrace.html +498 -0
  76. package/templates/sessions/019ed2ee-massive/notrace.json +14660 -0
  77. package/tsconfig.json +1 -1
  78. package/dist/notrace/renderer.d.ts +0 -4
  79. package/dist/notrace/renderer.js +0 -800
  80. package/extensions/notrace/renderer.ts +0 -810
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # @raquezha/notrace
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 382b624: Add model usage breakdowns, switch insights, lazy timeline rendering, and refreshed report samples.
8
+
9
+ ### Patch Changes
10
+
11
+ - 04d574e: Refactor notrace report rendering into modular report-app files and add focused regression coverage without changing package dependencies or entrypoints.
12
+
13
+ ## 0.1.2
14
+
15
+ ### Patch Changes
16
+
17
+ - a0f076e: Fix notrace index lock behavior under contention and add regression coverage for lock handling and usage normalization.
18
+ - 894da0a: Add Vitest scaffolding and extract `handleSessionShutdown` for testability with no intended runtime behavior change.
19
+ - 9d6c8b2: Skip writing per-session notrace artifacts for ghost sessions and add regression coverage for ghost vs non-ghost shutdown behavior.
20
+
3
21
  ## 0.1.1
4
22
 
5
23
  ### Patch Changes
@@ -1,2 +1,36 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import type { NotraceCaptureMode, NotraceEvent, NotraceExtensionTelemetry } from "./types.js";
3
+ import { type WorkflowAdapter } from "./adapters.js";
4
+ type UsageLike = {
5
+ input?: number;
6
+ output?: number;
7
+ inputTokens?: number;
8
+ outputTokens?: number;
9
+ cacheRead?: number;
10
+ cacheWrite?: number;
11
+ cacheReadTokens?: number;
12
+ cacheWriteTokens?: number;
13
+ totalTokens?: number;
14
+ cost?: {
15
+ input?: number;
16
+ output?: number;
17
+ cacheRead?: number;
18
+ cacheWrite?: number;
19
+ total?: number;
20
+ };
21
+ };
22
+ export declare function normalizeUsage(raw: unknown): Required<Pick<UsageLike, "inputTokens" | "outputTokens" | "cacheReadTokens" | "cacheWriteTokens" | "totalTokens">> & {
23
+ totalCostUsd: number;
24
+ };
25
+ export type SessionShutdownDeps = {
26
+ events: NotraceEvent[];
27
+ startTime: number;
28
+ traceId: string;
29
+ extensionTelemetry: Map<string, NotraceExtensionTelemetry>;
30
+ captureMode: NotraceCaptureMode;
31
+ notraceDir: string;
32
+ adapter: WorkflowAdapter;
33
+ };
34
+ export declare function handleSessionShutdown(e: any, ctx: any, deps: SessionShutdownDeps): Promise<void>;
2
35
  export default function (pi: ExtensionAPI): void;
36
+ export {};
@@ -1,9 +1,10 @@
1
- import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync, chmodSync } from "node:fs";
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync, chmodSync, rmSync } from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import * as os from "node:os";
4
4
  import { execSync } from "node:child_process";
5
5
  import { getActiveAdapter } from "./adapters.js";
6
- import { generateHtmlReport, generateDashboardHtml } from "./renderer.js";
6
+ import { generateHtmlReport } from "./report-app/report.js";
7
+ import { generateDashboardHtml } from "./report-app/dashboard-report.js";
7
8
  const REDACTED = "[REDACTED by notrace]";
8
9
  const SENSITIVE_VALUE_RE = /(bearer\s+[a-z0-9._~+/=-]{12,}|sk-[a-z0-9_-]{16,}|gh[pousr]_[a-z0-9_]{16,}|AKIA[0-9A-Z]{16})/gi;
9
10
  const TELEMETRY_CHANNEL = "notrace.telemetry.extension";
@@ -40,14 +41,18 @@ function sanitizeTraceValue(value) {
40
41
  function asNumber(value) {
41
42
  return typeof value === "number" && Number.isFinite(value) ? value : 0;
42
43
  }
43
- function normalizeUsage(raw) {
44
+ export function normalizeUsage(raw) {
44
45
  const usage = (raw && typeof raw === "object" ? raw : {});
46
+ const inputTokens = asNumber(usage.inputTokens ?? usage.input);
47
+ const outputTokens = asNumber(usage.outputTokens ?? usage.output);
48
+ const cacheReadTokens = asNumber(usage.cacheReadTokens ?? usage.cacheRead);
49
+ const cacheWriteTokens = asNumber(usage.cacheWriteTokens ?? usage.cacheWrite);
45
50
  return {
46
- inputTokens: asNumber(usage.inputTokens ?? usage.input),
47
- outputTokens: asNumber(usage.outputTokens ?? usage.output),
48
- cacheReadTokens: asNumber(usage.cacheReadTokens ?? usage.cacheRead),
49
- cacheWriteTokens: asNumber(usage.cacheWriteTokens ?? usage.cacheWrite),
50
- totalTokens: asNumber(usage.totalTokens),
51
+ inputTokens,
52
+ outputTokens,
53
+ cacheReadTokens,
54
+ cacheWriteTokens,
55
+ totalTokens: usage.totalTokens == null ? inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens : asNumber(usage.totalTokens),
51
56
  totalCostUsd: asNumber(usage.cost?.total),
52
57
  };
53
58
  }
@@ -164,6 +169,129 @@ function createIndexEntry(record, htmlPath, recordPath) {
164
169
  },
165
170
  };
166
171
  }
172
+ export async function handleSessionShutdown(e, ctx, deps) {
173
+ const shutdownReason = typeof e?.reason === "string" ? e.reason : null;
174
+ const endedAt = Date.now();
175
+ const context = deps.adapter.getContext(ctx.cwd);
176
+ const finalTraceId = ctx.sessionManager?.getSessionId?.() || deps.traceId;
177
+ const outputDir = path.join(deps.notraceDir, "sessions", finalTraceId.replace(/[^a-z0-9]/gi, "-"));
178
+ const repositoryName = path.basename(ctx.cwd);
179
+ let branchName = null;
180
+ try {
181
+ branchName = execSync("git branch --show-current", { cwd: ctx.cwd, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8", timeout: 1000 }).trim() || null;
182
+ }
183
+ catch {
184
+ // not a git repo or no commits yet
185
+ }
186
+ const recordPath = path.join(outputDir, "notrace.json");
187
+ let mergedEvents = deps.events;
188
+ let originalStartedAt = deps.startTime;
189
+ let originalTask = null;
190
+ if (existsSync(recordPath)) {
191
+ try {
192
+ const oldRecord = readJsonFile(recordPath, null);
193
+ if (Array.isArray(oldRecord.events)) {
194
+ mergedEvents = [...oldRecord.events, ...deps.events];
195
+ }
196
+ if (oldRecord.session?.startedAt) {
197
+ originalStartedAt = new Date(oldRecord.session.startedAt).getTime();
198
+ }
199
+ if (oldRecord.task) {
200
+ originalTask = oldRecord.task;
201
+ }
202
+ }
203
+ catch (err) {
204
+ // ignore parse errors
205
+ }
206
+ }
207
+ const activity = collectActivity(mergedEvents, originalStartedAt, endedAt);
208
+ // Do not index purely empty ghost sessions
209
+ const isGhostSession = activity.llmCallCount === 0 && activity.toolCallCount === 0 && activity.totals.totalTokens === 0;
210
+ const telemetry = Object.fromEntries([...deps.extensionTelemetry.entries()].sort(([a], [b]) => a.localeCompare(b)));
211
+ const record = {
212
+ kind: "notrace-run",
213
+ schemaVersion: SCHEMA_VERSION,
214
+ traceId: finalTraceId,
215
+ repository: {
216
+ name: repositoryName,
217
+ cwd: ctx.cwd,
218
+ branch: branchName,
219
+ },
220
+ session: {
221
+ id: finalTraceId,
222
+ startedAt: new Date(originalStartedAt).toISOString(),
223
+ endedAt: new Date(endedAt).toISOString(),
224
+ durationMs: activity.durationMs,
225
+ shutdownReason,
226
+ },
227
+ task: toTaskInfo(context) || originalTask,
228
+ captureMode: deps.captureMode,
229
+ conditions: buildConditions(mergedEvents, telemetry),
230
+ activity,
231
+ telemetry: { extensions: telemetry },
232
+ events: mergedEvents,
233
+ };
234
+ validateRunRecord(record);
235
+ const htmlPath = path.join(outputDir, "notrace.html");
236
+ if (!isGhostSession) {
237
+ const html = generateHtmlReport(record);
238
+ mkdirSync(outputDir, { recursive: true });
239
+ writePrivateFileAtomic(htmlPath, html);
240
+ writePrivateFileAtomic(recordPath, `${JSON.stringify(record, null, 2)}\n`);
241
+ }
242
+ const indexPath = path.join(deps.notraceDir, "index.json");
243
+ const lockPath = `${indexPath}.lock`;
244
+ let lockAcquired = false;
245
+ for (let i = 0; i < 20; i++) {
246
+ try {
247
+ writeFileSync(lockPath, `${process.pid}`, { flag: "wx" });
248
+ lockAcquired = true;
249
+ break;
250
+ }
251
+ catch {
252
+ const t = Date.now();
253
+ while (Date.now() - t < 50) { } // busy wait 50ms
254
+ }
255
+ }
256
+ if (!lockAcquired) {
257
+ // Could not get exclusive access to the index after retrying. Skip the
258
+ // index/dashboard update rather than racing another process's
259
+ // read-modify-write on index.json. The per-session record and HTML
260
+ // report were already written above and are not affected.
261
+ console.warn(`[notrace] Could not acquire index lock, skipping index update for ${finalTraceId}`);
262
+ }
263
+ else {
264
+ try {
265
+ const existing = readJsonFile(indexPath, { sessions: [] });
266
+ let sessions = Array.isArray(existing.sessions) ? existing.sessions.filter((s) => s.sessionId !== finalTraceId) : [];
267
+ if (!isGhostSession) {
268
+ sessions.push(createIndexEntry(record, htmlPath, recordPath));
269
+ }
270
+ writePrivateFileAtomic(indexPath, `${JSON.stringify({ sessions }, null, 2)}\n`);
271
+ writePrivateFileAtomic(path.join(deps.notraceDir, "index.html"), generateDashboardHtml(sessions, {}));
272
+ }
273
+ finally {
274
+ if (existsSync(lockPath)) {
275
+ try {
276
+ rmSync(lockPath);
277
+ }
278
+ catch { }
279
+ }
280
+ }
281
+ }
282
+ if (context && !isGhostSession) {
283
+ const displayPath = htmlPath.startsWith(os.homedir())
284
+ ? `~${htmlPath.slice(os.homedir().length)}`
285
+ : htmlPath;
286
+ deps.adapter.attach(context, {
287
+ html: displayPath,
288
+ record: recordPath
289
+ });
290
+ }
291
+ if (!isGhostSession) {
292
+ console.log(`\n\x1b[1m\x1b[38;5;208m[notrace] Session Retrospective: file://${htmlPath}\x1b[0m\n`);
293
+ }
294
+ }
167
295
  function normalizeTelemetryPayload(raw) {
168
296
  if (!raw || typeof raw !== "object")
169
297
  return null;
@@ -194,7 +322,6 @@ export default function (pi) {
194
322
  const startTime = Date.now();
195
323
  let traceId = "";
196
324
  let activeLlmPayload = null;
197
- let shutdownReason = null;
198
325
  const extensionTelemetry = new Map();
199
326
  currentMode = getInitialMode();
200
327
  if (typeof pi.events?.on === "function") {
@@ -249,115 +376,14 @@ export default function (pi) {
249
376
  }
250
377
  });
251
378
  pi.on("session_shutdown", async (e, ctx) => {
252
- shutdownReason = typeof e?.reason === "string" ? e.reason : null;
253
- const endedAt = Date.now();
254
- const adapter = getActiveAdapter(ctx.cwd);
255
- const context = adapter.getContext(ctx.cwd);
256
- const notraceDir = process.env.NOTRACE_DIR || path.join(os.homedir(), ".notrace");
257
- const finalTraceId = ctx.sessionManager?.getSessionId?.() || traceId;
258
- const outputDir = path.join(notraceDir, "sessions", finalTraceId.replace(/[^a-z0-9]/gi, "-"));
259
- const repositoryName = path.basename(ctx.cwd);
260
- let branchName = null;
261
- try {
262
- branchName = execSync("git branch --show-current", { cwd: ctx.cwd, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8", timeout: 1000 }).trim() || null;
263
- }
264
- catch {
265
- // not a git repo or no commits yet
266
- }
267
- const recordPath = path.join(outputDir, "notrace.json");
268
- let mergedEvents = events;
269
- let originalStartedAt = startTime;
270
- let originalTask = null;
271
- if (existsSync(recordPath)) {
272
- try {
273
- const oldRecord = readJsonFile(recordPath, null);
274
- if (Array.isArray(oldRecord.events)) {
275
- mergedEvents = [...oldRecord.events, ...events];
276
- }
277
- if (oldRecord.session?.startedAt) {
278
- originalStartedAt = new Date(oldRecord.session.startedAt).getTime();
279
- }
280
- if (oldRecord.task) {
281
- originalTask = oldRecord.task;
282
- }
283
- }
284
- catch (err) {
285
- // ignore parse errors
286
- }
287
- }
288
- const activity = collectActivity(mergedEvents, originalStartedAt, endedAt);
289
- // Do not index purely empty ghost sessions
290
- const isGhostSession = activity.llmCallCount === 0 && activity.toolCallCount === 0 && activity.totals.totalTokens === 0;
291
- const telemetry = Object.fromEntries([...extensionTelemetry.entries()].sort(([a], [b]) => a.localeCompare(b)));
292
- const record = {
293
- kind: "notrace-run",
294
- schemaVersion: SCHEMA_VERSION,
295
- traceId: finalTraceId,
296
- repository: {
297
- name: repositoryName,
298
- cwd: ctx.cwd,
299
- branch: branchName,
300
- },
301
- session: {
302
- id: finalTraceId,
303
- startedAt: new Date(originalStartedAt).toISOString(),
304
- endedAt: new Date(endedAt).toISOString(),
305
- durationMs: activity.durationMs,
306
- shutdownReason,
307
- },
308
- task: toTaskInfo(context) || originalTask,
379
+ await handleSessionShutdown(e, ctx, {
380
+ events,
381
+ startTime,
382
+ traceId,
383
+ extensionTelemetry,
309
384
  captureMode: currentMode,
310
- conditions: buildConditions(mergedEvents, telemetry),
311
- activity,
312
- telemetry: { extensions: telemetry },
313
- events: mergedEvents,
314
- };
315
- validateRunRecord(record);
316
- const html = generateHtmlReport(record);
317
- mkdirSync(outputDir, { recursive: true });
318
- const htmlPath = path.join(outputDir, "notrace.html");
319
- writePrivateFileAtomic(htmlPath, html);
320
- writePrivateFileAtomic(recordPath, `${JSON.stringify(record, null, 2)}\n`);
321
- const indexPath = path.join(notraceDir, "index.json");
322
- const lockPath = `${indexPath}.lock`;
323
- let lockAcquired = false;
324
- for (let i = 0; i < 20; i++) {
325
- try {
326
- writeFileSync(lockPath, `${process.pid}`, { flag: "wx" });
327
- lockAcquired = true;
328
- break;
329
- }
330
- catch {
331
- const t = Date.now();
332
- while (Date.now() - t < 50) { } // busy wait 50ms
333
- }
334
- }
335
- try {
336
- const existing = readJsonFile(indexPath, { sessions: [] });
337
- let sessions = Array.isArray(existing.sessions) ? existing.sessions.filter((s) => s.sessionId !== finalTraceId) : [];
338
- if (!isGhostSession) {
339
- sessions.push(createIndexEntry(record, htmlPath, recordPath));
340
- }
341
- writePrivateFileAtomic(indexPath, `${JSON.stringify({ sessions }, null, 2)}\n`);
342
- writePrivateFileAtomic(path.join(notraceDir, "index.html"), generateDashboardHtml(sessions, {}));
343
- }
344
- finally {
345
- if (lockAcquired && existsSync(lockPath)) {
346
- try {
347
- import("node:fs").then(fs => fs.rmSync ? fs.rmSync(lockPath) : fs.unlinkSync(lockPath));
348
- }
349
- catch { }
350
- }
351
- }
352
- if (context) {
353
- const displayPath = htmlPath.startsWith(os.homedir())
354
- ? `~${htmlPath.slice(os.homedir().length)}`
355
- : htmlPath;
356
- adapter.attach(context, {
357
- html: displayPath,
358
- record: recordPath
359
- });
360
- }
361
- console.log(`\n\x1b[1m\x1b[38;5;208m[notrace] Session Retrospective: file://${htmlPath}\x1b[0m\n`);
385
+ notraceDir: process.env.NOTRACE_DIR || path.join(os.homedir(), ".notrace"),
386
+ adapter: getActiveAdapter(ctx.cwd),
387
+ });
362
388
  });
363
389
  }
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { buildModelSummary, buildModelSwitches, groupByModel } from "../analytics.js";
3
+ const events = [
4
+ { type: "llm_completion", model: "a", provider: "x", timestamp: 1000, usage: { input: 10, output: 5, totalTokens: 15, cost: { total: 1 } } },
5
+ { type: "llm_completion", model: "a", provider: "x", timestamp: "2026-06-17T17:00:02.000Z", usage: { input: 20, output: 10, totalTokens: 30, cost: { total: 2 } }, errorMessage: "oops" },
6
+ { type: "llm_completion", model: "b", provider: "y", timestamp: "2026-06-17T17:00:03.000Z", usage: { input: 40, output: 20, cost: { total: 3 } } }, // missing totalTokens
7
+ ];
8
+ describe("report-app analytics", () => {
9
+ it("groups model usage and falls back when totalTokens is missing", () => {
10
+ const grouped = groupByModel(events);
11
+ expect(grouped.a.count).toBe(2);
12
+ expect(grouped.a.inputTokens).toBe(30);
13
+ expect(grouped.a.errors).toBe(1);
14
+ // b is missing totalTokens, should fallback to input + output (40 + 20)
15
+ expect(grouped.b.totalTokens).toBe(60);
16
+ });
17
+ it("tracks model switches and normalizes string timestamps", () => {
18
+ const switches = buildModelSwitches(events);
19
+ expect(switches).toHaveLength(1);
20
+ expect(switches[0].from).toBe("a");
21
+ expect(switches[0].to).toBe("b");
22
+ expect(switches[0].providerChanged).toBe(true);
23
+ // timestamp Delta between "2026-06-17T17:00:02.000Z" and "2026-06-17T17:00:03.000Z" is 1000ms
24
+ expect(switches[0].timeDelta).toBe(1000);
25
+ // falls back to input + output when totalTokens is missing
26
+ expect(switches[0].tokens).toBe(60);
27
+ });
28
+ it("builds summary", () => {
29
+ const summary = buildModelSummary(events);
30
+ expect(summary.firstModel).toBe("a");
31
+ expect(summary.finalModel).toBe("b");
32
+ expect(summary.switchCount).toBe(1);
33
+ expect(summary.uniqueModels).toBe(2);
34
+ });
35
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { renderCollapsibleSection, renderKeyValueList, renderToolResultHtml, renderToolUseHtml } from "../components/card.js";
3
+ describe("report-app card", () => {
4
+ it("renders tool use and result cards", () => {
5
+ const useHtml = renderToolUseHtml("bash", { command: "echo hi" });
6
+ const resultHtml = renderToolResultHtml("tool-1", { stdout: "ok" }, true);
7
+ expect(useHtml).toContain("chat-tool-use");
8
+ expect(useHtml).toContain("bash");
9
+ expect(resultHtml).toContain("Tool Result: tool-1");
10
+ expect(resultHtml).toContain("color: var(--err);");
11
+ });
12
+ it("escapes injected content", () => {
13
+ const html = renderToolUseHtml("<script>alert(1)</script>", "<script>alert(1)</script>");
14
+ expect(html).toContain("&lt;script&gt;alert(1)&lt;/script&gt;");
15
+ expect(html).not.toContain("<script>alert(1)</script>");
16
+ });
17
+ it("renders collapsible sections and key-value lists", () => {
18
+ const section = renderCollapsibleSection("Models", "<div>body</div>", true);
19
+ const kv = renderKeyValueList([["A", "B"], ["Empty", ""]]);
20
+ expect(section).toContain("panel collapsible");
21
+ expect(section).toContain(" open");
22
+ expect(kv).toContain("kv-list");
23
+ expect(kv).toContain("Empty");
24
+ expect(kv).toContain(">-<");
25
+ });
26
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,20 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { renderEventCard } from "../components/event.js";
3
+ describe("report-app event", () => {
4
+ it("escapes hostile content in lazy event body attributes", () => {
5
+ const hostileEvent = {
6
+ type: "llm_completion",
7
+ outputContent: '<img src=x onerror=alert(1)><script>alert(1)</script>" autofocus onfocus=alert(1)',
8
+ timestamp: 1000
9
+ };
10
+ const html = renderEventCard(hostileEvent);
11
+ // The data-lazy-event-body attribute should contain the URI-encoded then HTML-escaped string.
12
+ // It must NOT contain the raw dangerous strings.
13
+ expect(html).not.toContain("<script>alert(1)</script>");
14
+ expect(html).not.toContain('onerror=alert(1)');
15
+ expect(html).toContain("data-lazy-event-body=");
16
+ // Double check that the encoded payload still holds the data safely.
17
+ expect(html).toContain("alert(1)"); // uri encoded form might just be alert(1) but the < > and " will be %3C %3E %22
18
+ expect(html).toContain("%26lt%3Bscript%26gt%3Balert(1)%26lt%3B%2Fscript%26gt%3B");
19
+ });
20
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,41 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { formatDateDay, formatMs, formatTelemetryStatus, formatTimeMinutes, formatTimeSeconds, formatTokens, formatUsd, parseDate, taskDisplay, workflowClassName, workflowDisplayName } from "../format.js";
3
+ describe("report-app format", () => {
4
+ it("parses valid dates and rejects invalid ones", () => {
5
+ expect(parseDate("2026-06-17T17:00:00Z")?.toISOString()).toBe("2026-06-17T17:00:00.000Z");
6
+ expect(parseDate("nope")).toBeNull();
7
+ });
8
+ it("formats date and time pieces", () => {
9
+ expect(formatDateDay("2026-06-17T17:00:00Z")).toBe("2026-06-17");
10
+ expect(formatTimeMinutes("2026-06-17T17:00:00Z")).toMatch(/^17:00|\d{2}:\d{2}$/);
11
+ expect(formatTimeSeconds("2026-06-17T17:00:00Z")).toMatch(/^17:00:00|\d{2}:\d{2}:\d{2}$/);
12
+ });
13
+ it("formats workflow labels and classes", () => {
14
+ expect(workflowDisplayName("norpiv")).toBe("RPIV");
15
+ expect(workflowDisplayName("research")).toBe("Research");
16
+ expect(workflowDisplayName(undefined)).toBe("Generic");
17
+ expect(workflowClassName("norpiv")).toBe("workflow-rpiv");
18
+ expect(workflowClassName("research")).toBe("workflow-research");
19
+ expect(workflowClassName(undefined)).toBe("workflow-generic");
20
+ });
21
+ it("preserves old taskDisplay semantics for direct and nested shapes", () => {
22
+ expect(taskDisplay({ workflow: "research", id: "branch:main" })).toBe("Branch main");
23
+ expect(taskDisplay({ task: { workflow: "norpiv", id: "NR-101" } })).toBe("NR-101");
24
+ expect(taskDisplay({ workflow: "generic" })).toBe("General session");
25
+ expect(taskDisplay({ workflow: "weird" })).toBe("No active task");
26
+ });
27
+ it("formats usd, tokens, durations, and telemetry status", () => {
28
+ expect(formatUsd(0)).toBe("$0");
29
+ expect(formatUsd(1.234567)).toBe("$1.23457");
30
+ expect(formatTokens(999)).toBe("999");
31
+ expect(formatTokens(1500)).toBe("1.5k");
32
+ expect(formatTokens(2_500_000)).toBe("2.50M");
33
+ expect(formatMs(0)).toBe("-");
34
+ expect(formatMs(15_000)).toBe("15s");
35
+ expect(formatMs(125_000)).toBe("2m 5s");
36
+ expect(formatMs(3_780_000)).toBe("1h 3m");
37
+ expect(formatTelemetryStatus("active")).toBe("Active");
38
+ expect(formatTelemetryStatus("loaded-disabled")).toBe("Loaded disabled");
39
+ expect(formatTelemetryStatus(undefined)).toBe("Unknown");
40
+ });
41
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,31 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { generateHtmlReport } from "../report.js";
3
+ const record = {
4
+ traceId: "session-1",
5
+ repository: { name: "nothing", branch: "main" },
6
+ session: { id: "session-1", startedAt: "2026-06-17T17:00:00Z", durationMs: 15000 },
7
+ task: { workflow: "research", id: "branch:main" },
8
+ captureMode: "full",
9
+ conditions: { providers: ["anthropic"] },
10
+ activity: {
11
+ llmCallCount: 1,
12
+ toolCallCount: 1,
13
+ toolErrorCount: 0,
14
+ durationMs: 15000,
15
+ totals: { inputTokens: 10, outputTokens: 5, cacheReadTokens: 0, cacheWriteTokens: 0, totalTokens: 15, totalCostUsd: 0.01 },
16
+ },
17
+ telemetry: { extensions: {} },
18
+ events: [
19
+ { type: "llm_completion", model: "claude", provider: "anthropic", timestamp: 1710000000000, inputPayload: { messages: [{ role: "user", content: "hi" }] }, outputContent: "hello", usage: { totalTokens: 15, cost: { total: 0.01 } } },
20
+ ],
21
+ };
22
+ describe("report-app report", () => {
23
+ it("renders structural markers", () => {
24
+ const html = generateHtmlReport(record);
25
+ expect(html).toContain("Session retrospective");
26
+ expect(html).toContain("Run Summary");
27
+ expect(html).toContain("Timeline");
28
+ expect(html).toContain("Branch main");
29
+ expect(html).toContain("claude");
30
+ });
31
+ });
@@ -0,0 +1,3 @@
1
+ export declare function groupByModel(events: any[]): Record<string, any>;
2
+ export declare function buildModelSwitches(events: any[]): any[];
3
+ export declare function buildModelSummary(events: any[]): any;
@@ -0,0 +1,78 @@
1
+ export function groupByModel(events) {
2
+ const models = {};
3
+ for (const ev of events) {
4
+ if (ev.type !== "llm_completion")
5
+ continue;
6
+ const name = ev.model || "unknown";
7
+ if (!models[name]) {
8
+ models[name] = { count: 0, inputTokens: 0, outputTokens: 0, totalTokens: 0, cost: 0, cacheRead: 0, cacheWrite: 0, errors: 0 };
9
+ }
10
+ const m = models[name];
11
+ m.count++;
12
+ if (ev.usage) {
13
+ const input = Number(ev.usage.inputTokens || ev.usage.input || 0);
14
+ const output = Number(ev.usage.outputTokens || ev.usage.output || 0);
15
+ const cacheR = Number(ev.usage.cacheReadTokens || ev.usage.cacheRead || 0);
16
+ const cacheW = Number(ev.usage.cacheWriteTokens || ev.usage.cacheWrite || 0);
17
+ m.inputTokens += input;
18
+ m.outputTokens += output;
19
+ m.cacheRead += cacheR;
20
+ m.cacheWrite += cacheW;
21
+ m.totalTokens += Number(ev.usage.totalTokens || (input + output + cacheR + cacheW));
22
+ m.cost += Number(ev.usage.cost?.total || 0);
23
+ }
24
+ if (ev.errorMessage)
25
+ m.errors++;
26
+ }
27
+ return models;
28
+ }
29
+ export function buildModelSwitches(events) {
30
+ const switches = [];
31
+ let lastModel = null;
32
+ let lastProvider = null;
33
+ let lastTime = null;
34
+ let completionIndex = 0;
35
+ for (const ev of events) {
36
+ if (ev.type !== "llm_completion")
37
+ continue;
38
+ completionIndex++;
39
+ const currentModel = ev.model || "unknown";
40
+ const currentProvider = ev.provider || "unknown";
41
+ const currentTimestamp = ev.timestamp ? new Date(ev.timestamp).getTime() : 0;
42
+ const validTimestamp = Number.isNaN(currentTimestamp) ? 0 : currentTimestamp;
43
+ if (lastModel && lastModel !== currentModel) {
44
+ const input = Number(ev.usage?.inputTokens || ev.usage?.input || 0);
45
+ const output = Number(ev.usage?.outputTokens || ev.usage?.output || 0);
46
+ const cacheR = Number(ev.usage?.cacheReadTokens || ev.usage?.cacheRead || 0);
47
+ const cacheW = Number(ev.usage?.cacheWriteTokens || ev.usage?.cacheWrite || 0);
48
+ switches.push({
49
+ index: completionIndex,
50
+ from: lastModel,
51
+ to: currentModel,
52
+ fromProvider: lastProvider || "unknown",
53
+ toProvider: currentProvider,
54
+ providerChanged: (lastProvider || "unknown") !== currentProvider,
55
+ timestamp: validTimestamp,
56
+ timeDelta: lastTime ? validTimestamp - lastTime : 0,
57
+ cost: Number(ev.usage?.cost?.total || 0),
58
+ tokens: Number(ev.usage?.totalTokens || (input + output + cacheR + cacheW))
59
+ });
60
+ }
61
+ lastModel = currentModel;
62
+ lastProvider = currentProvider;
63
+ lastTime = validTimestamp;
64
+ }
65
+ return switches;
66
+ }
67
+ export function buildModelSummary(events) {
68
+ const completions = events.filter((ev) => ev.type === "llm_completion");
69
+ if (!completions.length)
70
+ return null;
71
+ const uniqueModels = new Set(completions.map((ev) => ev.model || "unknown"));
72
+ return {
73
+ firstModel: completions[0]?.model || "unknown",
74
+ finalModel: completions[completions.length - 1]?.model || "unknown",
75
+ switchCount: buildModelSwitches(events).length,
76
+ uniqueModels: uniqueModels.size,
77
+ };
78
+ }
@@ -0,0 +1,2 @@
1
+ export declare const COPY_SCRIPT = "(() => {\n document.querySelectorAll('[data-copy-value]').forEach((button) => {\n button.addEventListener('click', async () => {\n const value = button.getAttribute('data-copy-value') || '';\n try {\n if (navigator.clipboard?.writeText) {\n await navigator.clipboard.writeText(value);\n } else {\n const textarea = document.createElement('textarea');\n textarea.value = value;\n textarea.style.position = 'fixed';\n textarea.style.opacity = '0';\n document.body.appendChild(textarea);\n textarea.focus();\n textarea.select();\n document.execCommand('copy');\n textarea.remove();\n }\n const previous = button.innerHTML;\n button.classList.add('copied');\n button.innerHTML = '<svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" aria-hidden=\"true\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M20 6 9 17l-5-5\"></path></svg>';\n setTimeout(() => {\n button.classList.remove('copied');\n button.innerHTML = previous;\n }, 1400);\n } catch {\n button.textContent = 'ERR';\n }\n });\n });\n\n document.querySelectorAll('details[data-lazy-event-body]').forEach((details) => {\n details.addEventListener('toggle', () => {\n if (!details.open) return;\n if (details.querySelector('.event-body')) return;\n const html = decodeURIComponent(details.getAttribute('data-lazy-event-body') || '');\n details.insertAdjacentHTML('beforeend', html);\n }, { once: false });\n });\n\n const topBtn = document.querySelector('.back-to-top');\n if (topBtn) {\n const syncTopButton = () => {\n const scrollable = document.documentElement.scrollHeight > window.innerHeight + 24;\n const show = scrollable && window.scrollY > 200;\n topBtn.classList.toggle('visible', show);\n };\n window.addEventListener('scroll', syncTopButton, { passive: true });\n window.addEventListener('resize', syncTopButton);\n syncTopButton();\n }\n })();";
2
+ export declare const DASHBOARD_SORT_SCRIPT = "(() => {\n const table = document.querySelector('[data-dashboard-table]');\n if (!table) return;\n const tbody = table.querySelector('tbody');\n if (!tbody) return;\n const buttons = Array.from(document.querySelectorAll('[data-sort-key]'));\n let currentKey = 'index';\n let currentDir = 'desc';\n\n function icon(dir) {\n return dir === 'asc' ? '\u2191' : '\u2193';\n }\n\n function updateState() {\n buttons.forEach(btn => {\n const key = btn.getAttribute('data-sort-key');\n const state = btn.querySelector('.sort-state');\n if (!state) return;\n state.textContent = key === currentKey ? icon(currentDir) : '';\n });\n }\n\n function compare(a, b, key) {\n if (key === 'index' || key === 'started' || key === 'tokens' || key === 'cost') {\n return Number(a.dataset[key] || 0) - Number(b.dataset[key] || 0);\n }\n return String(a.dataset[key] || '').localeCompare(String(b.dataset[key] || ''));\n }\n\n function sortBy(key) {\n const rows = Array.from(tbody.querySelectorAll('tr'));\n rows.sort((a, b) => {\n const result = compare(a, b, key);\n return currentDir === 'asc' ? result : -result;\n });\n rows.forEach(row => tbody.appendChild(row));\n updateState();\n }\n\n buttons.forEach(btn => {\n btn.addEventListener('click', () => {\n const key = btn.getAttribute('data-sort-key') || 'index';\n if (currentKey === key) currentDir = currentDir === 'asc' ? 'desc' : 'asc';\n else {\n currentKey = key;\n currentDir = key === 'workflow' ? 'asc' : 'desc';\n }\n sortBy(currentKey);\n });\n });\n\n sortBy(currentKey);\n })();";