import fs from "fs/promises"; import type { ParsedMessage, ProgressSubtype } from "../../shared/types.js"; /** * Real Claude Code JSONL format (verified from actual session files): * * Every line has: { type, uuid?, timestamp?, ... } * * type="user" → { message: { role: "user", content: string | ContentBlock[] }, uuid, timestamp } * type="assistant" → { message: { role: "assistant", content: ContentBlock[] }, uuid, timestamp } * type="progress" → { data: { type, hookEvent?, status?, ... }, uuid, timestamp } * type="summary" → { summary: string, leafUuid } * type="file-history-snapshot" → { snapshot: { ... }, messageId } * type="system" → { subtype: "turn_duration", durationMs, ... } (metadata, not display) * type="queue-operation" → internal (not display) * * ContentBlock: { type: "text", text } | { type: "thinking", thinking } | { type: "tool_use", name, input } | { type: "tool_result", content, tool_use_id } */ interface ContentBlock { type: string; id?: string; text?: string; thinking?: string; name?: string; input?: Record; tool_use_id?: string; content?: string | ContentBlock[]; } interface RawLine { type?: string; uuid?: string; timestamp?: string; parentToolUseID?: string; message?: { role?: string; content?: string | ContentBlock[]; }; data?: Record; summary?: string; snapshot?: Record; subtype?: string; } export async function parseSession( filePath: string ): Promise { let content: string; try { content = await fs.readFile(filePath, "utf-8"); } catch { return []; } return parseSessionContent(content); } export function parseSessionContent(content: string): ParsedMessage[] { const messages: ParsedMessage[] = []; const lines = content.split("\n").filter((l) => l.trim()); for (let i = 0; i < lines.length; i++) { let parsed: RawLine; try { parsed = JSON.parse(lines[i]); } catch { continue; // Skip malformed lines } const extracted = extractMessages(parsed, i); messages.push(...extracted); } return messages; } function extractMessages(raw: RawLine, rawIndex: number): ParsedMessage[] { const messages: ParsedMessage[] = []; const type = raw.type; const uuid = raw.uuid || `generated-${rawIndex}`; const timestamp = raw.timestamp; // Progress/hook messages - content is in `data`, not `content` if (type === "progress") { const data = raw.data; const progressText = data ? formatProgressData(data) : "Progress event"; const dataType = typeof data?.type === "string" ? data.type : ""; const progressSubtype = deriveProgressSubtype(dataType); messages.push({ uuid, category: "hook_progress", content: progressText, timestamp, rawIndex, parentToolUseId: raw.parentToolUseID, progressSubtype, }); return messages; } // File history snapshot if (type === "file-history-snapshot") { messages.push({ uuid, category: "file_snapshot", content: JSON.stringify(raw.snapshot || raw, null, 2), timestamp, rawIndex, }); return messages; } // Summary message - text is in `summary` field, not `content` if (type === "summary") { messages.push({ uuid, category: "summary", content: raw.summary || "", timestamp, rawIndex, }); return messages; } // System metadata (turn_duration etc.) - skip, not user-facing if (type === "system" || type === "queue-operation") { return messages; } // User and assistant messages - content is in `message.content` const role = raw.message?.role; const content = raw.message?.content; if ((type === "user" || role === "user") && content !== undefined) { if (typeof content === "string") { const category = detectSystemReminder(content) ? "system_message" : "user_message"; messages.push({ uuid, category, content, timestamp, rawIndex, }); } else if (Array.isArray(content)) { for (const block of content) { if (block.type === "tool_result") { const resultText = typeof block.content === "string" ? block.content : Array.isArray(block.content) ? block.content .map((b: ContentBlock) => b.text || "") .join("\n") : JSON.stringify(block.content); messages.push({ uuid: `${uuid}-tr-${block.tool_use_id || rawIndex}`, category: "tool_result", content: resultText, toolUseId: block.tool_use_id, timestamp, rawIndex, }); } else if (block.type === "text") { const text = block.text || ""; const category = detectSystemReminder(text) ? "system_message" : "user_message"; messages.push({ uuid: `${uuid}-text-${rawIndex}`, category, content: text, timestamp, rawIndex, }); } } } return messages; } if ((type === "assistant" || role === "assistant") && content !== undefined) { if (typeof content === "string") { messages.push({ uuid, category: "assistant_text", content, timestamp, rawIndex, }); } else if (Array.isArray(content)) { for (let j = 0; j < content.length; j++) { const block = content[j]; const blockUuid = `${uuid}-${j}`; if (block.type === "thinking") { messages.push({ uuid: blockUuid, category: "thinking", content: block.thinking || block.text || "", timestamp, rawIndex, }); } else if (block.type === "text") { messages.push({ uuid: blockUuid, category: "assistant_text", content: block.text || "", timestamp, rawIndex, }); } else if (block.type === "tool_use") { messages.push({ uuid: blockUuid, category: "tool_call", content: `Tool: ${block.name}`, toolName: block.name, toolInput: JSON.stringify(block.input, null, 2), timestamp, rawIndex, toolUseId: block.id, }); } } } return messages; } return messages; } function formatProgressData(data: Record): string { const parts: string[] = []; if (data.hookEvent) parts.push(`Hook: ${data.hookEvent}`); if (data.hookName) parts.push(`${data.hookName}`); if (data.toolName) parts.push(`Tool: ${data.toolName}`); if (data.status) parts.push(`Status: ${data.status}`); if (data.serverName) parts.push(`Server: ${data.serverName}`); if (parts.length > 0) return parts.join(" | "); return JSON.stringify(data); } function detectSystemReminder(text: string): boolean { return text.includes("") || text.includes(""); } function deriveProgressSubtype(dataType: string): ProgressSubtype { if (dataType === "bash_progress") return "bash"; if (dataType === "mcp_progress") return "mcp"; if (dataType === "agent_progress") return "agent"; return "hook"; }