/** * KHAELOR * File: src/session/sdiff.ts * Description: Structured diff between two session runs — turns, tool calls, files, tokens (v2 design §2). * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import type { DurableEvent } from "./events.js"; import { buildFileChangeSet, buildUsageTotals } from "./projections.js"; export interface SessionRunSummary { sessionId: string; models: string[]; agentTurns: number; toolCalls: number; filesModified: string[]; inputTokens: number; outputTokens: number; cacheReadTokens: number; verifyFailures: number; outcome: "completed" | "failed" | "open"; } /** Fold one session's durable stream into the /sdiff row (real usage only — Rule #4). */ export function summarizeSessionRun( sessionId: string, events: readonly DurableEvent[], ): SessionRunSummary { const usage = buildUsageTotals(events); const files = buildFileChangeSet(events); const models = new Set(); let agentTurns = 0; let toolCalls = 0; let verifyFailures = 0; let outcome: SessionRunSummary["outcome"] = "open"; for (const event of events) { switch (event.type) { case "model.request-started": models.add(event.payload.model); break; case "model.response-completed": agentTurns += 1; break; case "tool.requested": toolCalls += 1; break; case "verify.result": if (!event.payload.ok) verifyFailures += 1; break; case "task.completed": outcome = "completed"; break; case "task.failed": outcome = "failed"; break; default: break; } } return { sessionId, models: [...models], agentTurns, toolCalls, filesModified: [...files.changes.keys()].sort(), inputTokens: usage.totals.inputTokens, outputTokens: usage.totals.outputTokens, cacheReadTokens: usage.totals.cacheReadTokens, verifyFailures, outcome, }; } function formatTokens(n: number): string { if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; return String(n); } function row(label: string, a: string, b: string, widths: [number, number, number]): string { return ` ${label.padEnd(widths[0])} ${a.padEnd(widths[1])} ${b.padEnd(widths[2])}`.trimEnd(); } /** Render the /sdiff table: two runs side by side (v2 §2 — "run it twice, diff the runs"). */ export function renderSessionDiff(a: SessionRunSummary, b: SessionRunSummary): string[] { const widths: [number, number, number] = [18, 28, 28]; const shortId = (id: string): string => id.slice(0, 12); const filesA = a.filesModified; const filesB = b.filesModified; const onlyA = filesA.filter((f) => !filesB.includes(f)); const onlyB = filesB.filter((f) => !filesA.includes(f)); const lines = [ row("", `${shortId(a.sessionId)} (${a.models.join(",") || "—"})`, `${shortId(b.sessionId)} (${b.models.join(",") || "—"})`, widths), row("agent turns", String(a.agentTurns), String(b.agentTurns), widths), row("tool calls", String(a.toolCalls), String(b.toolCalls), widths), row("files modified", String(filesA.length), String(filesB.length), widths), row( "tokens (in/out)", `${formatTokens(a.inputTokens)} / ${formatTokens(a.outputTokens)}`, `${formatTokens(b.inputTokens)} / ${formatTokens(b.outputTokens)}`, widths, ), row("cache reads", formatTokens(a.cacheReadTokens), formatTokens(b.cacheReadTokens), widths), row("verify failures", String(a.verifyFailures), String(b.verifyFailures), widths), row("outcome", a.outcome, b.outcome, widths), ]; if (onlyA.length > 0) lines.push(` files only in A: ${onlyA.join(", ")}`); if (onlyB.length > 0) lines.push(` files only in B: ${onlyB.join(", ")}`); const common = filesA.filter((f) => filesB.includes(f)); if (common.length > 0) lines.push(` files in both: ${common.join(", ")}`); return lines; }