import type { AgentState } from "@mariozechner/pi-agent-core"; import type { AssistantMessage, Message, ToolResultMessage, UserMessage } from "@mariozechner/pi-ai"; import { existsSync, readFileSync, writeFileSync } from "fs"; import { homedir } from "os"; import { basename } from "path"; import { APP_NAME, VERSION } from "./config.js"; import { type BashExecutionMessage, isBashExecutionMessage } from "./messages.js"; import type { SessionManager } from "./session-manager.js"; // ============================================================================ // Types // ============================================================================ interface MessageEvent { type: "message"; message: Message; timestamp?: number; } interface ModelChangeEvent { type: "model_change"; provider: string; modelId: string; timestamp?: number; } interface CompactionEvent { type: "compaction"; timestamp: string; summary: string; tokensBefore: number; } type SessionEvent = MessageEvent | ModelChangeEvent | CompactionEvent; interface ParsedSessionData { sessionId: string; timestamp: string; systemPrompt?: string; modelsUsed: Set; messages: Message[]; toolResultsMap: Map; sessionEvents: SessionEvent[]; tokenStats: { input: number; output: number; cacheRead: number; cacheWrite: number }; costStats: { input: number; output: number; cacheRead: number; cacheWrite: number }; tools?: { name: string; description: string }[]; contextWindow?: number; isStreamingFormat?: boolean; } // ============================================================================ // Color scheme (matching TUI) // ============================================================================ const COLORS = { userMessageBg: "rgb(52, 53, 65)", toolPendingBg: "rgb(40, 40, 50)", toolSuccessBg: "rgb(40, 50, 40)", toolErrorBg: "rgb(60, 40, 40)", userBashBg: "rgb(50, 48, 35)", // Faint yellow/brown for user-executed bash userBashErrorBg: "rgb(60, 45, 35)", // Slightly more orange for errors bodyBg: "rgb(24, 24, 30)", containerBg: "rgb(30, 30, 36)", text: "rgb(229, 229, 231)", textDim: "rgb(161, 161, 170)", cyan: "rgb(103, 232, 249)", green: "rgb(34, 197, 94)", red: "rgb(239, 68, 68)", yellow: "rgb(234, 179, 8)", }; // ============================================================================ // Utility functions // ============================================================================ function escapeHtml(text: string): string { return text .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function shortenPath(path: string): string { const home = homedir(); return path.startsWith(home) ? "~" + path.slice(home.length) : path; } function replaceTabs(text: string): string { return text.replace(/\t/g, " "); } function formatTimestamp(timestamp: number | string | undefined): string { if (!timestamp) return ""; const date = new Date(typeof timestamp === "string" ? timestamp : timestamp); return date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" }); } function formatExpandableOutput(lines: string[], maxLines: number): string { const displayLines = lines.slice(0, maxLines); const remaining = lines.length - maxLines; if (remaining > 0) { let out = '"; return out; } let out = '
'; for (const line of displayLines) { out += `
${escapeHtml(replaceTabs(line))}
`; } out += "
"; return out; } // ============================================================================ // Parsing functions // ============================================================================ function parseSessionManagerFormat(lines: string[]): ParsedSessionData { const data: ParsedSessionData = { sessionId: "unknown", timestamp: new Date().toISOString(), modelsUsed: new Set(), messages: [], toolResultsMap: new Map(), sessionEvents: [], tokenStats: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, costStats: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, }; for (const line of lines) { let entry: { type: string; [key: string]: unknown }; try { entry = JSON.parse(line) as { type: string; [key: string]: unknown }; } catch { continue; } switch (entry.type) { case "session": data.sessionId = (entry.id as string) || "unknown"; data.timestamp = (entry.timestamp as string) || data.timestamp; data.systemPrompt = entry.systemPrompt as string | undefined; if (entry.modelId) { const modelInfo = entry.provider ? `${entry.provider}/${entry.modelId}` : (entry.modelId as string); data.modelsUsed.add(modelInfo); } break; case "message": { const message = entry.message as Message; data.messages.push(message); data.sessionEvents.push({ type: "message", message, timestamp: entry.timestamp as number | undefined, }); if (message.role === "toolResult") { const toolResult = message as ToolResultMessage; data.toolResultsMap.set(toolResult.toolCallId, toolResult); } else if (message.role === "assistant") { const assistantMsg = message as AssistantMessage; if (assistantMsg.usage) { data.tokenStats.input += assistantMsg.usage.input || 0; data.tokenStats.output += assistantMsg.usage.output || 0; data.tokenStats.cacheRead += assistantMsg.usage.cacheRead || 0; data.tokenStats.cacheWrite += assistantMsg.usage.cacheWrite || 0; if (assistantMsg.usage.cost) { data.costStats.input += assistantMsg.usage.cost.input || 0; data.costStats.output += assistantMsg.usage.cost.output || 0; data.costStats.cacheRead += assistantMsg.usage.cost.cacheRead || 0; data.costStats.cacheWrite += assistantMsg.usage.cost.cacheWrite || 0; } } } break; } case "model_change": data.sessionEvents.push({ type: "model_change", provider: entry.provider as string, modelId: entry.modelId as string, timestamp: entry.timestamp as number | undefined, }); if (entry.modelId) { const modelInfo = entry.provider ? `${entry.provider}/${entry.modelId}` : (entry.modelId as string); data.modelsUsed.add(modelInfo); } break; case "compaction": data.sessionEvents.push({ type: "compaction", timestamp: entry.timestamp as string, summary: entry.summary as string, tokensBefore: entry.tokensBefore as number, }); break; } } return data; } function parseStreamingEventFormat(lines: string[]): ParsedSessionData { const data: ParsedSessionData = { sessionId: "unknown", timestamp: new Date().toISOString(), modelsUsed: new Set(), messages: [], toolResultsMap: new Map(), sessionEvents: [], tokenStats: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, costStats: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, isStreamingFormat: true, }; let timestampSet = false; for (const line of lines) { let entry: { type: string; message?: Message }; try { entry = JSON.parse(line) as { type: string; message?: Message }; } catch { continue; } if (entry.type === "message_end" && entry.message) { const msg = entry.message; data.messages.push(msg); data.sessionEvents.push({ type: "message", message: msg, timestamp: (msg as { timestamp?: number }).timestamp, }); if (msg.role === "toolResult") { const toolResult = msg as ToolResultMessage; data.toolResultsMap.set(toolResult.toolCallId, toolResult); } else if (msg.role === "assistant") { const assistantMsg = msg as AssistantMessage; if (assistantMsg.model) { const modelInfo = assistantMsg.provider ? `${assistantMsg.provider}/${assistantMsg.model}` : assistantMsg.model; data.modelsUsed.add(modelInfo); } if (assistantMsg.usage) { data.tokenStats.input += assistantMsg.usage.input || 0; data.tokenStats.output += assistantMsg.usage.output || 0; data.tokenStats.cacheRead += assistantMsg.usage.cacheRead || 0; data.tokenStats.cacheWrite += assistantMsg.usage.cacheWrite || 0; if (assistantMsg.usage.cost) { data.costStats.input += assistantMsg.usage.cost.input || 0; data.costStats.output += assistantMsg.usage.cost.output || 0; data.costStats.cacheRead += assistantMsg.usage.cost.cacheRead || 0; data.costStats.cacheWrite += assistantMsg.usage.cost.cacheWrite || 0; } } } if (!timestampSet && (msg as { timestamp?: number }).timestamp) { data.timestamp = new Date((msg as { timestamp: number }).timestamp).toISOString(); timestampSet = true; } } } data.sessionId = `stream-${data.timestamp.replace(/[:.]/g, "-")}`; return data; } function detectFormat(lines: string[]): "session-manager" | "streaming-events" | "unknown" { for (const line of lines) { try { const entry = JSON.parse(line) as { type: string }; if (entry.type === "session") return "session-manager"; if (entry.type === "agent_start" || entry.type === "message_start" || entry.type === "turn_start") { return "streaming-events"; } } catch {} } return "unknown"; } function parseSessionFile(content: string): ParsedSessionData { const lines = content .trim() .split("\n") .filter((l) => l.trim()); if (lines.length === 0) { throw new Error("Empty session file"); } const format = detectFormat(lines); if (format === "unknown") { throw new Error("Unknown session file format"); } return format === "session-manager" ? parseSessionManagerFormat(lines) : parseStreamingEventFormat(lines); } // ============================================================================ // HTML formatting functions // ============================================================================ function formatToolExecution( toolName: string, args: Record, result?: ToolResultMessage, ): { html: string; bgColor: string } { let html = ""; const isError = result?.isError || false; const bgColor = result ? (isError ? COLORS.toolErrorBg : COLORS.toolSuccessBg) : COLORS.toolPendingBg; const getTextOutput = (): string => { if (!result) return ""; const textBlocks = result.content.filter((c) => c.type === "text"); return textBlocks.map((c) => (c as { type: "text"; text: string }).text).join("\n"); }; switch (toolName) { case "bash": { const command = (args?.command as string) || ""; html = `
$ ${escapeHtml(command || "...")}
`; if (result) { const output = getTextOutput().trim(); if (output) { html += formatExpandableOutput(output.split("\n"), 5); } } break; } case "read": { const path = shortenPath((args?.file_path as string) || (args?.path as string) || ""); html = `
read ${escapeHtml(path || "...")}
`; if (result) { const output = getTextOutput(); if (output) { html += formatExpandableOutput(output.split("\n"), 10); } } break; } case "write": { const path = shortenPath((args?.file_path as string) || (args?.path as string) || ""); const fileContent = (args?.content as string) || ""; const lines = fileContent ? fileContent.split("\n") : []; html = `
write ${escapeHtml(path || "...")}`; if (lines.length > 10) { html += ` (${lines.length} lines)`; } html += "
"; if (fileContent) { html += formatExpandableOutput(lines, 10); } if (result) { const output = getTextOutput().trim(); if (output) { html += `
${escapeHtml(output)}
`; } } break; } case "edit": { const path = shortenPath((args?.file_path as string) || (args?.path as string) || ""); html = `
edit ${escapeHtml(path || "...")}
`; if (result?.details?.diff) { const diffLines = result.details.diff.split("\n"); html += '
'; for (const line of diffLines) { if (line.startsWith("+")) { html += `
${escapeHtml(line)}
`; } else if (line.startsWith("-")) { html += `
${escapeHtml(line)}
`; } else { html += `
${escapeHtml(line)}
`; } } html += "
"; } if (result) { const output = getTextOutput().trim(); if (output) { html += `
${escapeHtml(output)}
`; } } break; } default: { html = `
${escapeHtml(toolName)}
`; html += `
${escapeHtml(JSON.stringify(args, null, 2))}
`; if (result) { const output = getTextOutput(); if (output) { html += `
${escapeHtml(output)}
`; } } } } return { html, bgColor }; } function formatMessage(message: Message, toolResultsMap: Map): string { let html = ""; const timestamp = (message as { timestamp?: number }).timestamp; const timestampHtml = timestamp ? `
${formatTimestamp(timestamp)}
` : ""; // Handle bash execution messages (user-executed via ! command) if (isBashExecutionMessage(message)) { const bashMsg = message as unknown as BashExecutionMessage; const isError = bashMsg.cancelled || (bashMsg.exitCode !== 0 && bashMsg.exitCode !== null); const bgColor = isError ? COLORS.userBashErrorBg : COLORS.userBashBg; html += `
`; html += timestampHtml; html += `
$ ${escapeHtml(bashMsg.command)}
`; if (bashMsg.output) { const lines = bashMsg.output.split("\n"); html += formatExpandableOutput(lines, 10); } if (bashMsg.cancelled) { html += `
(cancelled)
`; } else if (bashMsg.exitCode !== 0 && bashMsg.exitCode !== null) { html += `
(exit ${bashMsg.exitCode})
`; } if (bashMsg.truncated && bashMsg.fullOutputPath) { html += `
Output truncated. Full output: ${escapeHtml(bashMsg.fullOutputPath)}
`; } html += `
`; return html; } if (message.role === "user") { const userMsg = message as UserMessage; let textContent = ""; if (typeof userMsg.content === "string") { textContent = userMsg.content; } else { const textBlocks = userMsg.content.filter((c) => c.type === "text"); textContent = textBlocks.map((c) => (c as { type: "text"; text: string }).text).join(""); } if (textContent.trim()) { html += `
${timestampHtml}${escapeHtml(textContent).replace(/\n/g, "
")}
`; } } else if (message.role === "assistant") { const assistantMsg = message as AssistantMessage; html += timestampHtml ? `
${timestampHtml}` : ""; for (const content of assistantMsg.content) { if (content.type === "text" && content.text.trim()) { html += `
${escapeHtml(content.text.trim()).replace(/\n/g, "
")}
`; } else if (content.type === "thinking" && content.thinking.trim()) { html += `
${escapeHtml(content.thinking.trim()).replace(/\n/g, "
")}
`; } } for (const content of assistantMsg.content) { if (content.type === "toolCall") { const toolResult = toolResultsMap.get(content.id); const { html: toolHtml, bgColor } = formatToolExecution( content.name, content.arguments as Record, toolResult, ); html += `
${toolHtml}
`; } } const hasToolCalls = assistantMsg.content.some((c) => c.type === "toolCall"); if (!hasToolCalls) { if (assistantMsg.stopReason === "aborted") { html += '
Aborted
'; } else if (assistantMsg.stopReason === "error") { html += `
Error: ${escapeHtml(assistantMsg.errorMessage || "Unknown error")}
`; } } if (timestampHtml) { html += "
"; } } return html; } function formatModelChange(event: ModelChangeEvent): string { const timestamp = formatTimestamp(event.timestamp); const timestampHtml = timestamp ? `
${timestamp}
` : ""; const modelInfo = `${event.provider}/${event.modelId}`; return `
${timestampHtml}
Switched to model: ${escapeHtml(modelInfo)}
`; } function formatCompaction(event: CompactionEvent): string { const timestamp = formatTimestamp(event.timestamp); const timestampHtml = timestamp ? `
${timestamp}
` : ""; const summaryHtml = escapeHtml(event.summary).replace(/\n/g, "
"); return `
${timestampHtml}
Context compacted from ${event.tokensBefore.toLocaleString()} tokens (click to expand summary)
Summary sent to model
${summaryHtml}
`; } // ============================================================================ // HTML generation // ============================================================================ function generateHtml(data: ParsedSessionData, filename: string): string { const userMessages = data.messages.filter((m) => m.role === "user").length; const assistantMessages = data.messages.filter((m) => m.role === "assistant").length; let toolCallsCount = 0; for (const message of data.messages) { if (message.role === "assistant") { toolCallsCount += (message as AssistantMessage).content.filter((c) => c.type === "toolCall").length; } } const lastAssistantMessage = data.messages .slice() .reverse() .find((m) => m.role === "assistant" && (m as AssistantMessage).stopReason !== "aborted") as | AssistantMessage | undefined; const contextTokens = lastAssistantMessage ? lastAssistantMessage.usage.input + lastAssistantMessage.usage.output + lastAssistantMessage.usage.cacheRead + lastAssistantMessage.usage.cacheWrite : 0; const lastModel = lastAssistantMessage?.model || "unknown"; const lastProvider = lastAssistantMessage?.provider || ""; const lastModelInfo = lastProvider ? `${lastProvider}/${lastModel}` : lastModel; const contextWindow = data.contextWindow || 0; const contextPercent = contextWindow > 0 ? ((contextTokens / contextWindow) * 100).toFixed(1) : null; let messagesHtml = ""; for (const event of data.sessionEvents) { switch (event.type) { case "message": if (event.message.role !== "toolResult") { messagesHtml += formatMessage(event.message, data.toolResultsMap); } break; case "model_change": messagesHtml += formatModelChange(event); break; case "compaction": messagesHtml += formatCompaction(event); break; } } const systemPromptHtml = data.systemPrompt ? `
System Prompt
${escapeHtml(data.systemPrompt)}
` : ""; const toolsHtml = data.tools ? `
Available Tools
${data.tools.map((tool) => `
${escapeHtml(tool.name)} - ${escapeHtml(tool.description)}
`).join("")}
` : ""; const streamingNotice = data.isStreamingFormat ? `
Note: This session was reconstructed from raw agent event logs, which do not contain system prompt or tool definitions.
` : ""; const contextUsageText = contextPercent ? `${contextTokens.toLocaleString()} / ${contextWindow.toLocaleString()} tokens (${contextPercent}%) - ${escapeHtml(lastModelInfo)}` : `${contextTokens.toLocaleString()} tokens (last turn) - ${escapeHtml(lastModelInfo)}`; return ` Session Export - ${escapeHtml(filename)}

${APP_NAME} v${VERSION}

Session:${escapeHtml(data.sessionId)}
Date:${new Date(data.timestamp).toLocaleString()}
Models:${ Array.from(data.modelsUsed) .map((m) => escapeHtml(m)) .join(", ") || "unknown" }

Messages

User:${userMessages}
Assistant:${assistantMessages}
Tool Calls:${toolCallsCount}

Tokens & Cost

Input:${data.tokenStats.input.toLocaleString()} tokens
Output:${data.tokenStats.output.toLocaleString()} tokens
Cache Read:${data.tokenStats.cacheRead.toLocaleString()} tokens
Cache Write:${data.tokenStats.cacheWrite.toLocaleString()} tokens
Total:${(data.tokenStats.input + data.tokenStats.output + data.tokenStats.cacheRead + data.tokenStats.cacheWrite).toLocaleString()} tokens
Input Cost:$${data.costStats.input.toFixed(4)}
Output Cost:$${data.costStats.output.toFixed(4)}
Cache Read Cost:$${data.costStats.cacheRead.toFixed(4)}
Cache Write Cost:$${data.costStats.cacheWrite.toFixed(4)}
Total Cost:$${(data.costStats.input + data.costStats.output + data.costStats.cacheRead + data.costStats.cacheWrite).toFixed(4)}
Context Usage:${contextUsageText}
${systemPromptHtml} ${toolsHtml} ${streamingNotice}
${messagesHtml}
`; } // ============================================================================ // Public API // ============================================================================ /** * Export session to HTML using SessionManager and AgentState. * Used by TUI's /export command. */ export function exportSessionToHtml(sessionManager: SessionManager, state: AgentState, outputPath?: string): string { const sessionFile = sessionManager.getSessionFile(); const content = readFileSync(sessionFile, "utf8"); const data = parseSessionFile(content); // Enrich with data from AgentState (tools, context window) data.tools = state.tools.map((t) => ({ name: t.name, description: t.description })); data.contextWindow = state.model?.contextWindow; if (!data.systemPrompt) { data.systemPrompt = state.systemPrompt; } if (!outputPath) { const sessionBasename = basename(sessionFile, ".jsonl"); outputPath = `${APP_NAME}-session-${sessionBasename}.html`; } const html = generateHtml(data, basename(sessionFile)); writeFileSync(outputPath, html, "utf8"); return outputPath; } /** * Export session file to HTML (standalone, without AgentState). * Auto-detects format: session manager format or streaming event format. * Used by CLI for exporting arbitrary session files. */ export function exportFromFile(inputPath: string, outputPath?: string): string { if (!existsSync(inputPath)) { throw new Error(`File not found: ${inputPath}`); } const content = readFileSync(inputPath, "utf8"); const data = parseSessionFile(content); if (!outputPath) { const inputBasename = basename(inputPath, ".jsonl"); outputPath = `${APP_NAME}-session-${inputBasename}.html`; } const html = generateHtml(data, basename(inputPath)); writeFileSync(outputPath, html, "utf8"); return outputPath; }