/** * Canonical ledger session JSONL read primitives (shared owner). * Consumers (ticket-trajectory, analyst, …) must import here — no second parse kernel. */ import { readFile } from "node:fs/promises"; export type LedgerSessionRow = Record; /** * Loud JSONL failure that still retains rows parsed before the bad line. * Callers that only need the throw keep catching Error; owners that must * surface partial typed facts (e.g. first-frame timestamp) read prefixRows. */ export class LedgerSessionJsonlError extends Error { readonly path: string; readonly line: number; readonly prefixRows: readonly LedgerSessionRow[]; constructor( message: string, init: { readonly path: string; readonly line: number; readonly prefixRows: readonly LedgerSessionRow[]; }, ) { super(message); this.name = "LedgerSessionJsonlError"; this.path = init.path; this.line = init.line; this.prefixRows = init.prefixRows; } } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } /** * One physical JSONL line as the single kernel saw it. * `row` present = syntactically complete session object at that 1-based line. * `error` present = the line is complete but unreadable as a session object; * `raw` carries its original bytes so readers can preserve source evidence * instead of inventing content. The unfinished final fragment at EOF is not a * line — live tail is honest absence, never a bad line. */ export type LedgerSessionLine = { readonly line: number; readonly raw: string; readonly row?: LedgerSessionRow; readonly error?: string; }; /** * Sole line-level scan of a session volume (single parse kernel, two readings). * Keeps live-tail semantics: a malformed line is reported only when a record * terminator completed it; an unfinished final fragment at EOF ends the scan. * This reading never throws on a bad line — `readLedgerSessionJsonl` puts the * loud failure back on top for the consumers that require it. */ export async function readLedgerSessionJsonlLines( path: string, ): Promise { const text = await readFile(path, "utf8"); // split keeps a trailing empty segment iff text ends with "\n", so // index < lines.length - 1 means this segment was terminated. const lines = text.split("\n"); const out: LedgerSessionLine[] = []; for (let index = 0; index < lines.length; index += 1) { const raw = lines[index]!; if (!raw.trim()) continue; const lineNumber = index + 1; let row: unknown; try { row = JSON.parse(raw); } catch (error) { if (!(error instanceof SyntaxError)) throw error; const completedByTerminator = index < lines.length - 1; // unfinished fragment at EOF — not a line at all if (!completedByTerminator) break; out.push({ line: lineNumber, raw, error: `malformed JSONL record in ${path} at line ${lineNumber}: ${error.message}`, }); continue; } // Syntactically complete line: must be a session object. Silent omission // would under-count ledger evidence (failure honesty). if (!isRecord(row)) { const kind = row === null ? "null" : Array.isArray(row) ? "array" : typeof row; out.push({ line: lineNumber, raw, error: `complete non-object JSONL record in ${path} at line ${lineNumber}: expected object, got ${kind}`, }); continue; } out.push({ line: lineNumber, raw, row }); } return out; } /** * Read session JSONL with honest live-tail semantics: * a malformed line is tolerated only when it is an unfinished final * fragment at EOF (no record terminator after it). Any malformed line * completed by a line terminator must fail loudly with file and 1-based * line context — even when no non-empty record follows — never silently * under-count. * * Loud failures throw LedgerSessionJsonlError carrying prefixRows so the * single parse kernel can still expose facts obtained before the bad line. */ export async function readLedgerSessionJsonl(path: string): Promise { const rows: LedgerSessionRow[] = []; for (const line of await readLedgerSessionJsonlLines(path)) { if (line.row === undefined) { throw new LedgerSessionJsonlError(line.error ?? `unreadable JSONL record in ${path} at line ${line.line}`, { path, line: line.line, prefixRows: rows, }); } rows.push(line.row); } return rows; } /** First and last record timestamps in encounter order. */ export function extractSessionTimestampSpan( rows: readonly LedgerSessionRow[], ): { startedAt?: string; endedAt?: string } { let startedAt: string | undefined; let endedAt: string | undefined; for (const row of rows) { if (typeof row.timestamp !== "string" || !row.timestamp) continue; if (startedAt === undefined) startedAt = row.timestamp; endedAt = row.timestamp; } return { ...(startedAt !== undefined ? { startedAt } : {}), ...(endedAt !== undefined ? { endedAt } : {}), }; } /** * Ordered-unique model ids from session frames (first-seen order). * Sources (same faces ticket-trajectory already reads — single parse kernel): * - `model_change.modelId` * - assistant `message.model` * Blank / non-string values are skipped. Does not invent a default model. */ export function extractSessionModelSequence( rows: readonly LedgerSessionRow[], ): string[] { const seen = new Set(); const ordered: string[] = []; const push = (raw: string): void => { const model = raw.trim(); if (model === "" || seen.has(model)) return; seen.add(model); ordered.push(model); }; for (const row of rows) { if (row.type === "model_change" && typeof row.modelId === "string") { push(row.modelId); } const message = isRecord(row.message) ? row.message : undefined; if (message?.role === "assistant" && typeof message.model === "string") { push(message.model); } } return ordered; } /** First line of a bash `command` argument (sole owner of this summary). */ export function bashCommandFirstLine(command: string): string { const match = /^[^\r\n]*/.exec(command); return match?.[0] ?? ""; } export type SessionToolInterval = { readonly toolCallId: string; readonly toolName: string; readonly startedAt: string; readonly endedAt?: string; /** * Bash-only first-line command summary from `arguments.command`. * Omitted for non-bash tools and when the argument is absent/non-string. * Full multi-line bodies are never retained on this typed fact face. */ readonly command?: string; }; /** * Pair toolCall frames → toolResult frames by toolCallId. * Throws when a tool-bearing frame is structurally unreadable for association * (toolCall missing string id, toolResult missing string toolCallId). * Unpaired open calls remain without endedAt — that is incomplete, not unreadable. */ export function extractSessionToolIntervals( rows: readonly LedgerSessionRow[], ): SessionToolInterval[] { type Open = { toolCallId: string; toolName: string; startedAt: string; endedAt?: string; command?: string; }; const order: Open[] = []; const openById = new Map(); for (const row of rows) { const rowTimestamp = typeof row.timestamp === "string" ? row.timestamp : undefined; const message = isRecord(row.message) ? row.message : undefined; if (message?.role === "assistant" && Array.isArray(message.content)) { const callTimestamp = typeof message.timestamp === "string" && message.timestamp ? message.timestamp : rowTimestamp; for (const part of message.content) { if (!isRecord(part) || part.type !== "toolCall") continue; if (typeof part.id !== "string" || part.id.length === 0) { throw new Error("toolCall frame missing string id"); } if (typeof part.name !== "string" || part.name.length === 0) { throw new Error(`toolCall ${part.id} missing string name`); } if (callTimestamp === undefined || callTimestamp.length === 0) { throw new Error(`toolCall ${part.id} missing timestamp`); } if (openById.has(part.id)) { throw new Error(`duplicate toolCall id ${part.id}`); } const args = isRecord(part.arguments) ? part.arguments : undefined; // Ticket surface: only bash first-line summary is authorized here. const command = part.name === "bash" && args !== undefined && typeof args.command === "string" ? bashCommandFirstLine(args.command) : undefined; const interval: Open = { toolCallId: part.id, toolName: part.name, startedAt: callTimestamp, ...(command !== undefined ? { command } : {}), }; order.push(interval); openById.set(part.id, interval); } } if (message?.role === "toolResult") { if (typeof message.toolCallId !== "string" || message.toolCallId.length === 0) { throw new Error("toolResult frame missing string toolCallId"); } const resultTimestamp = typeof message.timestamp === "string" && message.timestamp ? message.timestamp : rowTimestamp; if (resultTimestamp === undefined || resultTimestamp.length === 0) { throw new Error(`toolResult ${message.toolCallId} missing timestamp`); } const open = openById.get(message.toolCallId); if (open === undefined) { // Result without a prior call is still associable as a closed interval // once a name is known; keep structural readability without inventing a call. const toolName = typeof message.toolName === "string" && message.toolName.length > 0 ? message.toolName : "unknown"; order.push({ toolCallId: message.toolCallId, toolName, startedAt: resultTimestamp, endedAt: resultTimestamp, }); continue; } if (open.endedAt !== undefined) { throw new Error(`duplicate toolResult for toolCallId ${message.toolCallId}`); } open.endedAt = resultTimestamp; } } return order.map((interval) => { const base = { toolCallId: interval.toolCallId, toolName: interval.toolName, startedAt: interval.startedAt, ...(interval.command !== undefined ? { command: interval.command } : {}), }; return interval.endedAt === undefined ? base : { ...base, endedAt: interval.endedAt }; }); } /** * One binding-owned interval inside a continuous volume (#636): * from the nearest preceding binding at-or-before `anchorIndex` through the * row before the next binding (or EOF). Gate/settlement/analyst share this * slice — do not fork a second interval scan. * * `closed` is true iff a later binding ended the interval before EOF of the * provided rows. On a damaged prefix parse, closed intervals are fully owned * by their run; open intervals (through prefix EOF) may have lost rows to the * bad line and stay honest-missing for full span/tools. */ export type BindingInterval = { readonly rows: readonly LedgerSessionRow[]; readonly closed: boolean; }; export function intervalRowsAroundAnchor( rows: readonly LedgerSessionRow[], anchorIndex: number, isBindingRow: (row: LedgerSessionRow) => boolean, ): BindingInterval { let start = 0; for (let i = anchorIndex; i >= 0; i -= 1) { if (isBindingRow(rows[i]!)) { start = i; break; } } let end = rows.length; for (let i = Math.max(anchorIndex, start) + 1; i < rows.length; i += 1) { if (isBindingRow(rows[i]!)) { end = i; break; } } return { rows: rows.slice(start, end), closed: end < rows.length }; }