/** * Normalize a SessionEntry[] stream into NormalizedBlock[] (pi-vcc core/normalize, adapted). * * CENTRAL ADAPTATION (see saga gy5n0k.1 plan): pi-vcc's normalize consumes a flat `Message[]` * where tool results and bash executions are inline messages. Shard's data model (ARCH §10) * is row-based: tool results are separate `ToolResultEntry` rows paired by `tool_call_id`, * and bash is a `tool_call` named `'bash'` (no `bashExecution` message). So this normalize: * * Pass 1 — build `toolCallIndex: Map` from all MessageEntry * content ToolCallBlocks, plus a set of bash call ids that have a matching * ToolResultEntry (so unpaired bash falls back to a tool_call block). * Pass 2 — iterate entries in order: * - MessageEntry → walk content: text(user)→user, text(assistant)→assistant, * thinking→thinking, bash tool_call→skip if paired (materialized at result), * else emit tool_call fallback, other tool_call→tool_call block. * - ToolResultEntry → look up call by tool_call_id: bash→bash block * {command, output, exitCode}, else→tool_result block {name, text, isError}. * - CompactionEntry/CheckpointEntry → skipped (not part of the live block stream). * * Shard's NormalizedBlock schema has no `sourceIndex`/`redacted` — those pi-vcc fields are * dropped. `ThinkingBlock` payload is `.text` (not `.thinking`). */ import type { SessionEntry, MessageEntry, ToolResultEntry } from '@/types/session-entry'; import type { ToolResultContent } from '@/types/tool-result'; import type { NormalizedBlock } from '@/types/normalized-block'; import { sanitize } from './sanitize'; /** Render a ToolResultContent payload to a string for the normalized stream. */ const resultText = (content: ToolResultContent): string => { if (content.kind === 'text') return content.text; if (content.kind === 'json') return JSON.stringify(content.value); return content.message; // error }; interface CallInfo { name: string; args: Record; } /** * Pass 1: index every tool_call by id, and record which bash calls are paired with a * ToolResultEntry (so unpaired bash can fall back to a tool_call block at call position). */ const indexCalls = (entries: SessionEntry[]): { calls: Map; pairedBash: Set; } => { const calls = new Map(); const bashCallIds = new Set(); for (const e of entries) { if (e.type !== 'message') continue; for (const part of e.details.message.content) { if (part.type !== 'tool_call') continue; calls.set(part.id, { name: part.name, args: part.args }); if (part.name === 'bash') bashCallIds.add(part.id); } } const pairedBash = new Set(); for (const e of entries) { if (e.type !== 'tool_result') continue; if (bashCallIds.has(e.details.tool_call_id)) pairedBash.add(e.details.tool_call_id); } return { calls, pairedBash }; }; /** Normalize a single MessageEntry's content into blocks (pass 2, message position). */ const normalizeMessage = ( entry: MessageEntry, calls: Map, pairedBash: Set, ): NormalizedBlock[] => { const blocks: NormalizedBlock[] = []; const { message } = entry.details; const role = message.role; for (const part of message.content) { if (part.type === 'text') { const text = sanitize(part.text); if (role === 'user') { blocks.push({ kind: 'user', text }); } else { if (text) blocks.push({ kind: 'assistant', text }); } } else if (part.type === 'thinking') { // Saga p6w1nh Phase 6: carry the replay/redaction fields through normalization so // compaction preserves round-trip signatures (post-compaction turns can still replay // reasoning; the outbound transform downgrades/drops cross-model). blocks.push({ kind: 'thinking', text: sanitize(part.text), ...(part.thinkingSignature !== undefined ? { thinkingSignature: part.thinkingSignature } : undefined), ...(part.redacted !== undefined ? { redacted: part.redacted } : undefined), }); } else if (part.type === 'tool_call') { // Bash is materialized at result position when paired; emit a fallback tool_call // (so the command isn't lost) only when no result row follows. if (part.name === 'bash' && pairedBash.has(part.id)) continue; blocks.push({ kind: 'tool_call', name: part.name, args: part.args }); } } // Empty user message → preserve a user block (matches pi-vcc behavior). if (role === 'user' && blocks.length === 0) blocks.push({ kind: 'user', text: '' }); return blocks; }; /** Normalize a ToolResultEntry into a bash or tool_result block (pass 2, result position). */ const normalizeToolResult = ( entry: ToolResultEntry, calls: Map, ): NormalizedBlock | null => { const call = calls.get(entry.details.tool_call_id); // Unmatched result (call pruned/no longer present) — drop; nothing to name it. if (!call) return null; if (call.name === 'bash') { return { kind: 'bash', command: typeof call.args.command === 'string' ? call.args.command : '', output: resultText(entry.details.content), ...(entry.details.exit_code !== undefined ? { exitCode: entry.details.exit_code } : {}), }; } return { kind: 'tool_result', name: call.name, text: sanitize(resultText(entry.details.content)), isError: entry.details.is_error, }; }; export const normalize = (entries: SessionEntry[]): NormalizedBlock[] => { const { calls, pairedBash } = indexCalls(entries); const out: NormalizedBlock[] = []; for (const entry of entries) { if (entry.type === 'message') { out.push(...normalizeMessage(entry, calls, pairedBash)); } else if (entry.type === 'tool_result') { const block = normalizeToolResult(entry, calls); if (block) out.push(block); } // compaction / checkpoint entries are not part of the live block stream. } return out; };