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 { VERSION } from "./config.js"; import type { SessionManager } from "./session-manager.js"; /** * TUI Color scheme (matching exact RGB values from TUI components) */ const COLORS = { // Backgrounds userMessageBg: "rgb(52, 53, 65)", // Dark slate toolPendingBg: "rgb(40, 40, 50)", // Dark blue-gray toolSuccessBg: "rgb(40, 50, 40)", // Dark green toolErrorBg: "rgb(60, 40, 40)", // Dark red bodyBg: "rgb(24, 24, 30)", // Very dark background containerBg: "rgb(30, 30, 36)", // Slightly lighter container // Text colors (matching chalk colors) text: "rgb(229, 229, 231)", // Light gray (close to white) textDim: "rgb(161, 161, 170)", // Dimmed gray cyan: "rgb(103, 232, 249)", // Cyan for paths green: "rgb(34, 197, 94)", // Green for success red: "rgb(239, 68, 68)", // Red for errors yellow: "rgb(234, 179, 8)", // Yellow for warnings italic: "rgb(161, 161, 170)", // Gray italic for thinking }; /** * Escape HTML special characters */ function escapeHtml(text: string): string { return text .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } /** * Shorten path with tilde notation */ function shortenPath(path: string): string { const home = homedir(); if (path.startsWith(home)) { return "~" + path.slice(home.length); } return path; } /** * Replace tabs with 3 spaces */ function replaceTabs(text: string): string { return text.replace(/\t/g, " "); } /** * Format tool execution matching TUI ToolExecutionComponent */ function formatToolExecution( toolName: string, args: any, result?: ToolResultMessage, ): { html: string; bgColor: string } { let html = ""; const isError = result?.isError || false; const bgColor = result ? (isError ? COLORS.toolErrorBg : COLORS.toolSuccessBg) : COLORS.toolPendingBg; // Get text output from result const getTextOutput = (): string => { if (!result) return ""; const textBlocks = result.content.filter((c) => c.type === "text"); return textBlocks.map((c: any) => c.text).join("\n"); }; // Format based on tool type (matching TUI logic exactly) if (toolName === "bash") { const command = args?.command || ""; html = `
$ ${escapeHtml(command || "...")}
`; if (result) { const output = getTextOutput().trim(); if (output) { const lines = output.split("\n"); const maxLines = 5; const displayLines = lines.slice(0, maxLines); const remaining = lines.length - maxLines; if (remaining > 0) { // Truncated output - make it expandable html += '"; } else { // Short output - show all html += '
'; for (const line of displayLines) { html += `
${escapeHtml(line)}
`; } html += "
"; } } } } else if (toolName === "read") { const path = shortenPath(args?.file_path || args?.path || ""); html = `
read ${escapeHtml(path || "...")}
`; if (result) { const output = getTextOutput(); const lines = output.split("\n"); const maxLines = 10; const displayLines = lines.slice(0, maxLines); const remaining = lines.length - maxLines; if (remaining > 0) { // Truncated output - make it expandable html += '"; } else { // Short output - show all html += '
'; for (const line of displayLines) { html += `
${escapeHtml(replaceTabs(line))}
`; } html += "
"; } } } else if (toolName === "write") { const path = shortenPath(args?.file_path || args?.path || ""); const fileContent = args?.content || ""; const lines = fileContent ? fileContent.split("\n") : []; const totalLines = lines.length; html = `
write ${escapeHtml(path || "...")}`; if (totalLines > 10) { html += ` (${totalLines} lines)`; } html += "
"; if (fileContent) { const maxLines = 10; const displayLines = lines.slice(0, maxLines); const remaining = lines.length - maxLines; if (remaining > 0) { // Truncated output - make it expandable html += '"; } else { // Short output - show all html += '
'; for (const line of displayLines) { html += `
${escapeHtml(replaceTabs(line))}
`; } html += "
"; } } if (result) { const output = getTextOutput().trim(); if (output) { html += `
${escapeHtml(output)}
`; } } } else if (toolName === "edit") { const path = shortenPath(args?.file_path || args?.path || ""); html = `
edit ${escapeHtml(path || "...")}
`; // Show diff if available from result.details.diff 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)}
`; } } } else { // Generic tool html = `
${escapeHtml(toolName)}
`; html += `
${escapeHtml(JSON.stringify(args, null, 2))}
`; if (result) { const output = getTextOutput(); if (output) { html += `
${escapeHtml(output)}
`; } } } return { html, bgColor }; } /** * Format timestamp for display */ 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" }); } /** * Format model change event */ function formatModelChange(event: any): string { const timestamp = formatTimestamp(event.timestamp); const timestampHtml = timestamp ? `
${timestamp}
` : ""; const modelInfo = `${event.provider}/${event.modelId}`; return `
${timestampHtml}
Switched to model: ${escapeHtml(modelInfo)}
`; } /** * Format a message as HTML (matching TUI component styling) */ function formatMessage(message: Message, toolResultsMap: Map): string { let html = ""; const timestamp = (message as any).timestamp; const timestampHtml = timestamp ? `
${formatTimestamp(timestamp)}
` : ""; 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: any) => c.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}` : ""; // Render text and thinking content 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, "
")}
`; } } // Render tool calls with their results 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, toolResult); html += `
${toolHtml}
`; } } // Show error/abort status if no tool calls const hasToolCalls = assistantMsg.content.some((c) => c.type === "toolCall"); if (!hasToolCalls) { if (assistantMsg.stopReason === "aborted") { html += '
Aborted
'; } else if (assistantMsg.stopReason === "error") { const errorMsg = assistantMsg.errorMessage || "Unknown error"; html += `
Error: ${escapeHtml(errorMsg)}
`; } } // Close the assistant message wrapper if we opened one if (timestampHtml) { html += "
"; } } return html; } /** * Export session to a self-contained HTML file matching TUI visual style */ export function exportSessionToHtml(sessionManager: SessionManager, state: AgentState, outputPath?: string): string { const sessionFile = sessionManager.getSessionFile(); const timestamp = new Date().toISOString(); // Use pi-session- prefix + session filename + .html if no output path provided if (!outputPath) { const sessionBasename = basename(sessionFile, ".jsonl"); outputPath = `pi-session-${sessionBasename}.html`; } // Read and parse session data const sessionContent = readFileSync(sessionFile, "utf8"); const lines = sessionContent.trim().split("\n"); let sessionHeader: any = null; const messages: Message[] = []; const toolResultsMap = new Map(); const sessionEvents: any[] = []; // Track all events including model changes const modelsUsed = new Set(); // Track unique models used // Cumulative token and cost stats const tokenStats = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, }; const costStats = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, }; for (const line of lines) { try { const entry = JSON.parse(line); if (entry.type === "session") { sessionHeader = entry; // Track initial model from session header if (entry.modelId) { const modelInfo = entry.provider ? `${entry.provider}/${entry.modelId}` : entry.modelId; modelsUsed.add(modelInfo); } } else if (entry.type === "message") { messages.push(entry.message); sessionEvents.push(entry); // Build map of tool call ID to result if (entry.message.role === "toolResult") { toolResultsMap.set(entry.message.toolCallId, entry.message); } // Accumulate token and cost stats from assistant messages if (entry.message.role === "assistant" && entry.message.usage) { const usage = entry.message.usage; tokenStats.input += usage.input || 0; tokenStats.output += usage.output || 0; tokenStats.cacheRead += usage.cacheRead || 0; tokenStats.cacheWrite += usage.cacheWrite || 0; if (usage.cost) { costStats.input += usage.cost.input || 0; costStats.output += usage.cost.output || 0; costStats.cacheRead += usage.cost.cacheRead || 0; costStats.cacheWrite += usage.cost.cacheWrite || 0; } } } else if (entry.type === "model_change") { sessionEvents.push(entry); // Track model from model change event if (entry.modelId) { const modelInfo = entry.provider ? `${entry.provider}/${entry.modelId}` : entry.modelId; modelsUsed.add(modelInfo); } } } catch { // Skip malformed lines } } // Calculate message stats (matching session command) const userMessages = messages.filter((m) => m.role === "user").length; const assistantMessages = messages.filter((m) => m.role === "assistant").length; const toolResultMessages = messages.filter((m) => m.role === "toolResult").length; const totalMessages = messages.length; // Count tool calls from assistant messages let toolCallsCount = 0; for (const message of messages) { if (message.role === "assistant") { const assistantMsg = message as AssistantMessage; toolCallsCount += assistantMsg.content.filter((c) => c.type === "toolCall").length; } } // Get last assistant message for context percentage calculation (skip aborted messages) const lastAssistantMessage = messages .slice() .reverse() .find((m) => m.role === "assistant" && (m as AssistantMessage).stopReason !== "aborted") as | AssistantMessage | undefined; // Calculate context percentage from last message (input + output + cacheRead + cacheWrite) const contextTokens = lastAssistantMessage ? lastAssistantMessage.usage.input + lastAssistantMessage.usage.output + lastAssistantMessage.usage.cacheRead + lastAssistantMessage.usage.cacheWrite : 0; // Get the model info from the last assistant message const lastModel = lastAssistantMessage?.model || state.model?.id || "unknown"; const lastProvider = lastAssistantMessage?.provider || ""; const lastModelInfo = lastProvider ? `${lastProvider}/${lastModel}` : lastModel; const contextWindow = state.model?.contextWindow || 0; const contextPercent = contextWindow > 0 ? ((contextTokens / contextWindow) * 100).toFixed(1) : "0.0"; // Generate messages HTML (including model changes in chronological order) let messagesHtml = ""; for (const event of sessionEvents) { if (event.type === "message" && event.message.role !== "toolResult") { // Skip toolResult messages as they're rendered with their tool calls messagesHtml += formatMessage(event.message, toolResultsMap); } else if (event.type === "model_change") { messagesHtml += formatModelChange(event); } } // Generate HTML (matching TUI aesthetic) const html = ` Session Export - ${basename(sessionFile)}

pi v${VERSION}

Session: ${escapeHtml(sessionHeader?.id || "unknown")}
Date: ${sessionHeader?.timestamp ? new Date(sessionHeader.timestamp).toLocaleString() : timestamp}
Models: ${ Array.from(modelsUsed) .map((m) => escapeHtml(m)) .join(", ") || escapeHtml(sessionHeader?.model || state.model.id) }

Messages

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

Tokens & Cost

Input: ${tokenStats.input.toLocaleString()} tokens
Output: ${tokenStats.output.toLocaleString()} tokens
Cache Read: ${tokenStats.cacheRead.toLocaleString()} tokens
Cache Write: ${tokenStats.cacheWrite.toLocaleString()} tokens
Total: ${(tokenStats.input + tokenStats.output + tokenStats.cacheRead + tokenStats.cacheWrite).toLocaleString()} tokens
Input Cost: $${costStats.input.toFixed(4)}
Output Cost: $${costStats.output.toFixed(4)}
Cache Read Cost: $${costStats.cacheRead.toFixed(4)}
Cache Write Cost: $${costStats.cacheWrite.toFixed(4)}
Total Cost: $${(costStats.input + costStats.output + costStats.cacheRead + costStats.cacheWrite).toFixed(4)}
Context Usage: ${contextTokens.toLocaleString()} / ${contextWindow.toLocaleString()} tokens (${contextPercent}%) - ${escapeHtml(lastModelInfo)}
System Prompt
${escapeHtml(sessionHeader?.systemPrompt || state.systemPrompt)}
Available Tools
${state.tools .map( (tool) => `
${escapeHtml(tool.name)} - ${escapeHtml(tool.description)}
`, ) .join("")}
${messagesHtml}
`; // Write HTML file writeFileSync(outputPath, html, "utf8"); return outputPath; } /** * Parsed session data structure for HTML generation */ interface ParsedSessionData { sessionId: string; timestamp: string; cwd?: string; systemPrompt?: string; modelsUsed: Set; messages: Message[]; toolResultsMap: Map; sessionEvents: any[]; tokenStats: { input: number; output: number; cacheRead: number; cacheWrite: number }; costStats: { input: number; output: number; cacheRead: number; cacheWrite: number }; tools?: { name: string; description: string }[]; isStreamingFormat?: boolean; } /** * Parse session manager format (type: "session", "message", "model_change") */ 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) { try { const entry = JSON.parse(line); if (entry.type === "session") { data.sessionId = entry.id || "unknown"; data.timestamp = entry.timestamp || data.timestamp; data.cwd = entry.cwd; data.systemPrompt = entry.systemPrompt; if (entry.modelId) { const modelInfo = entry.provider ? `${entry.provider}/${entry.modelId}` : entry.modelId; data.modelsUsed.add(modelInfo); } } else if (entry.type === "message") { data.messages.push(entry.message); data.sessionEvents.push(entry); if (entry.message.role === "toolResult") { data.toolResultsMap.set(entry.message.toolCallId, entry.message); } if (entry.message.role === "assistant" && entry.message.usage) { const usage = entry.message.usage; data.tokenStats.input += usage.input || 0; data.tokenStats.output += usage.output || 0; data.tokenStats.cacheRead += usage.cacheRead || 0; data.tokenStats.cacheWrite += usage.cacheWrite || 0; if (usage.cost) { data.costStats.input += usage.cost.input || 0; data.costStats.output += usage.cost.output || 0; data.costStats.cacheRead += usage.cost.cacheRead || 0; data.costStats.cacheWrite += usage.cost.cacheWrite || 0; } } } else if (entry.type === "model_change") { data.sessionEvents.push(entry); if (entry.modelId) { const modelInfo = entry.provider ? `${entry.provider}/${entry.modelId}` : entry.modelId; data.modelsUsed.add(modelInfo); } } } catch { // Skip malformed lines } } return data; } /** * Parse streaming event format (type: "agent_start", "message_start", "message_end", etc.) */ 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; // Track messages by collecting message_end events (which have the final state) for (const line of lines) { try { const entry = JSON.parse(line); if (entry.type === "message_end" && entry.message) { const msg = entry.message; data.messages.push(msg); data.sessionEvents.push({ type: "message", message: msg, timestamp: msg.timestamp }); // Build tool results map if (msg.role === "toolResult") { data.toolResultsMap.set(msg.toolCallId, msg); } // Track models and accumulate stats from assistant messages if (msg.role === "assistant") { if (msg.model) { const modelInfo = msg.provider ? `${msg.provider}/${msg.model}` : msg.model; data.modelsUsed.add(modelInfo); } if (msg.usage) { data.tokenStats.input += msg.usage.input || 0; data.tokenStats.output += msg.usage.output || 0; data.tokenStats.cacheRead += msg.usage.cacheRead || 0; data.tokenStats.cacheWrite += msg.usage.cacheWrite || 0; if (msg.usage.cost) { data.costStats.input += msg.usage.cost.input || 0; data.costStats.output += msg.usage.cost.output || 0; data.costStats.cacheRead += msg.usage.cost.cacheRead || 0; data.costStats.cacheWrite += msg.usage.cost.cacheWrite || 0; } } } // Use first message timestamp as session timestamp if (!timestampSet && msg.timestamp) { data.timestamp = new Date(msg.timestamp).toISOString(); timestampSet = true; } } } catch { // Skip malformed lines } } // Generate a session ID from the timestamp data.sessionId = `stream-${data.timestamp.replace(/[:.]/g, "-")}`; return data; } /** * Detect the format of a session file by examining the first valid JSON line */ function detectFormat(lines: string[]): "session-manager" | "streaming-events" | "unknown" { for (const line of lines) { try { const entry = JSON.parse(line); if (entry.type === "session") return "session-manager"; if (entry.type === "agent_start" || entry.type === "message_start" || entry.type === "turn_start") { return "streaming-events"; } } catch { // Skip malformed lines } } return "unknown"; } /** * Generate HTML from parsed session data */ function generateHtml(data: ParsedSessionData, inputFilename: string): string { // Calculate message stats const userMessages = data.messages.filter((m) => m.role === "user").length; const assistantMessages = data.messages.filter((m) => m.role === "assistant").length; // Count tool calls from assistant messages let toolCallsCount = 0; for (const message of data.messages) { if (message.role === "assistant") { const assistantMsg = message as AssistantMessage; toolCallsCount += assistantMsg.content.filter((c) => c.type === "toolCall").length; } } // Get last assistant message for context info 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; // Generate messages HTML let messagesHtml = ""; for (const event of data.sessionEvents) { if (event.type === "message" && event.message.role !== "toolResult") { messagesHtml += formatMessage(event.message, data.toolResultsMap); } else if (event.type === "model_change") { messagesHtml += formatModelChange(event); } } // Tools section (only if tools info available) const toolsHtml = data.tools ? `
Available Tools
${data.tools.map((tool) => `
${escapeHtml(tool.name)} - ${escapeHtml(tool.description)}
`).join("")}
` : ""; // System prompt section (only if available) const systemPromptHtml = data.systemPrompt ? `
System Prompt
${escapeHtml(data.systemPrompt)}
` : ""; return ` Session Export - ${escapeHtml(inputFilename)}

pi 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: ${contextTokens.toLocaleString()} tokens (last turn) - ${escapeHtml(lastModelInfo)}
${systemPromptHtml} ${toolsHtml} ${ data.isStreamingFormat ? `
Note: This session was reconstructed from raw agent event logs, which do not contain system prompt or tool definitions.
` : "" }
${messagesHtml}
`; } /** * Export a session file to HTML (standalone, without AgentState or SessionManager) * Auto-detects format: session manager format or streaming event format */ export function exportFromFile(inputPath: string, outputPath?: string): string { if (!existsSync(inputPath)) { throw new Error(`File not found: ${inputPath}`); } const content = readFileSync(inputPath, "utf8"); const lines = content .trim() .split("\n") .filter((l) => l.trim()); if (lines.length === 0) { throw new Error(`Empty file: ${inputPath}`); } const format = detectFormat(lines); if (format === "unknown") { throw new Error(`Unknown session file format: ${inputPath}`); } const data = format === "session-manager" ? parseSessionManagerFormat(lines) : parseStreamingEventFormat(lines); // Generate output path if not provided if (!outputPath) { const inputBasename = basename(inputPath, ".jsonl"); outputPath = `pi-session-${inputBasename}.html`; } const html = generateHtml(data, basename(inputPath)); writeFileSync(outputPath, html, "utf8"); return outputPath; }