// Normalizes JSONL traces from multiple harnesses (Claude Code, Pi, generic) // into a uniform Message[] for rendering. export function parseJsonl(raw) { const lines = raw.split('\n'); const messages = []; for (const line of lines) { const trimmed = line.trim(); if (!trimmed) continue; let obj; try { obj = JSON.parse(trimmed); } catch { continue; } const msg = normalize(obj); if (msg) messages.push(msg); } return messages; } function normalize(obj) { // Claude Code format: top-level object has type = 'user' | 'assistant' | 'system' // and a `message` field. if ( (obj.type === 'user' || obj.type === 'assistant' || obj.type === 'system') && obj.message ) { return fromClaude(obj); } // Pi mono format: `type === 'message'` with message.role inside. if (obj.type === 'message' && obj.message) { return fromPi(obj); } // Control / meta events. if (obj.type === 'session') { return { role: 'meta', title: `session${obj.cwd ? ' — ' + obj.cwd : ''}`, blocks: [], timestamp: obj.timestamp, }; } if (obj.type === 'model_change') { return { role: 'meta', title: `model → ${obj.modelId || obj.model || '?'}`, blocks: [], timestamp: obj.timestamp, }; } if (obj.type === 'thinking_level_change') { return { role: 'meta', title: `thinking level → ${obj.thinkingLevel}`, blocks: [], timestamp: obj.timestamp, }; } if (obj.type === 'session_info') { return { role: 'meta', title: obj.name || 'session info', blocks: [], timestamp: obj.timestamp, }; } if (obj.type === 'permission-mode') { return { role: 'meta', title: `permission mode → ${obj.permissionMode}`, blocks: [], }; } // Skip very noisy internal-only records. if (obj.type === 'file-history-snapshot' || obj.type === 'attachment') { return null; } // Generic fallback: drop as raw meta. return { role: 'meta', title: obj.type || 'event', blocks: [{ kind: 'raw', json: obj }], }; } function fromClaude(obj) { const role = obj.type; // 'user' | 'assistant' | 'system' const content = obj.message?.content; return { role, blocks: contentToBlocks(content, 'claude'), model: obj.message?.model, timestamp: obj.timestamp, }; } function fromPi(obj) { const innerRole = obj.message?.role; // 'user' | 'assistant' | 'toolResult' const content = obj.message?.content; const role = innerRole === 'toolResult' ? 'tool' : innerRole || 'unknown'; return { role, blocks: contentToBlocks(content, 'pi', obj.message), model: obj.message?.model, timestamp: obj.timestamp, }; } function contentToBlocks(content, harness, piMessage) { const blocks = []; if (typeof content === 'string') { blocks.push({ kind: 'text', text: content }); return blocks; } if (!Array.isArray(content)) return blocks; // Pi's toolResult wraps content in an array already; treat the whole thing // as a tool_result block. if (harness === 'pi' && piMessage?.role === 'toolResult') { blocks.push({ kind: 'tool_result', text: extractText(content), isError: !!piMessage.isError, toolCallId: piMessage.toolCallId, toolName: piMessage.toolName, }); return blocks; } for (const part of content) { if (!part || typeof part !== 'object') continue; const t = part.type; if (t === 'text') { blocks.push({ kind: 'text', text: part.text ?? '' }); } else if (t === 'thinking') { blocks.push({ kind: 'thinking', text: part.thinking ?? part.text ?? '' }); } else if (t === 'tool_use' || t === 'toolCall') { blocks.push({ kind: 'tool_call', name: part.name || 'tool', input: part.input ?? part.arguments ?? {}, id: part.id, }); } else if (t === 'tool_result' || t === 'toolResult') { blocks.push({ kind: 'tool_result', text: extractText(part.content), isError: !!(part.is_error ?? part.isError), toolCallId: part.tool_use_id ?? part.toolCallId, }); } else if (t === 'image') { blocks.push({ kind: 'image', source: part.source }); } else { blocks.push({ kind: 'raw', json: part }); } } return blocks; } function extractText(content) { if (content == null) return ''; if (typeof content === 'string') return content; if (Array.isArray(content)) { return content .map((c) => { if (!c) return ''; if (typeof c === 'string') return c; if (c.type === 'text') return c.text ?? ''; return JSON.stringify(c, null, 2); }) .join('\n'); } try { return JSON.stringify(content, null, 2); } catch { return String(content); } } export function toRawUrl(url) { // Accept both /blob/ and /resolve/ URLs. return url.replace('/blob/', '/resolve/'); }