Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143/** * 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<tool_call_id, {name, args}>` 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<string, unknown>;}
/** * 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<string, CallInfo>; pairedBash: Set<string>;} => { const calls = new Map<string, CallInfo>(); const bashCallIds = new Set<string>(); 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<string>(); 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<string, CallInfo>, pairedBash: Set<string>,): 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<string, CallInfo>,): 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;};