From 890089784065bd2cc46197f1c350fe3f44ef914b Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 29 Nov 2025 23:05:07 +0100 Subject: [PATCH] feat(coding-agent): add --export CLI flag to convert session files to HTML Closes #80 --- packages/coding-agent/CHANGELOG.md | 2 + packages/coding-agent/README.md | 13 + packages/coding-agent/src/export-html.ts | 710 ++++++++++++++++++++++- packages/coding-agent/src/main.ts | 23 + 4 files changed, 747 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1dc620dd..a0065ea5 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,8 @@ ### Added +- **`--export` CLI Flag**: Export session files to self-contained HTML files from the command line. Auto-detects format (session manager format or streaming event format). Usage: `pi --export session.jsonl` or `pi --export session.jsonl output.html`. Note: Streaming event logs (from `--mode json`) don't contain system prompt or tool definitions, so those sections are omitted with a notice in the HTML. ([#80](https://github.com/badlogic/pi-mono/issues/80)) + - **Git Branch File Watcher**: Footer now auto-updates when the git branch changes externally (e.g., running `git checkout` in another terminal). Watches `.git/HEAD` for changes and refreshes the branch display automatically. ([#79](https://github.com/badlogic/pi-mono/pull/79) by [@fightbulc](https://github.com/fightbulc)) - **Read-Only Exploration Tools**: Added `grep`, `find`, and `ls` tools for safe code exploration without modification risk. These tools are available via the new `--tools` flag. diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 04218dee..2ada927e 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -780,6 +780,15 @@ Examples: - `--thinking high` - Start with high thinking level - `--thinking off` - Disable thinking even if saved setting was different +**--export ** +Export a session file to a self-contained HTML file and exit. Auto-detects format (session manager format or streaming event format). Optionally provide an output filename as the second argument. + +**Note:** When exporting streaming event logs (e.g., `pi-output.jsonl` from `--mode json`), the system prompt and tool definitions are not available since they are not recorded in the event stream. The exported HTML will include a notice about this. + +Examples: +- `--export session.jsonl` - Export to `pi-session-session.html` +- `--export session.jsonl output.html` - Export to custom filename + **--help, -h** Show help message @@ -834,6 +843,10 @@ pi --tools read,grep,find,ls -p "Review the architecture in src/" pi --tools read,bash,grep,find,ls \ --no-session \ -p "Use bash only for read-only operations. Read issue #74 with gh, then review the implementation" + +# Export a session file to HTML +pi --export ~/.pi/agent/sessions/--myproject--/session.jsonl +pi --export session.jsonl my-export.html ``` ## Tools diff --git a/packages/coding-agent/src/export-html.ts b/packages/coding-agent/src/export-html.ts index 4e2ebc03..470f2518 100644 --- a/packages/coding-agent/src/export-html.ts +++ b/packages/coding-agent/src/export-html.ts @@ -1,6 +1,6 @@ import type { AgentState } from "@mariozechner/pi-agent-core"; import type { AssistantMessage, Message, ToolResultMessage, UserMessage } from "@mariozechner/pi-ai"; -import { readFileSync, writeFileSync } from "fs"; +import { existsSync, readFileSync, writeFileSync } from "fs"; import { homedir } from "os"; import { basename, dirname, join } from "path"; import { fileURLToPath } from "url"; @@ -911,3 +911,711 @@ export function exportSessionToHtml(sessionManager: SessionManager, state: Agent 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; +} diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 04017bc6..1a9541ae 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -7,6 +7,7 @@ import { homedir } from "os"; import { dirname, extname, join, resolve } from "path"; import { fileURLToPath } from "url"; import { getChangelogPath, getNewEntries, parseChangelog } from "./changelog.js"; +import { exportFromFile } from "./export-html.js"; import { findModel, getApiKeyForModel, getAvailableModels } from "./model-config.js"; import { SessionManager } from "./session-manager.js"; import { SettingsManager } from "./settings-manager.js"; @@ -50,6 +51,7 @@ interface Args { models?: string[]; tools?: ToolName[]; print?: boolean; + export?: string; messages: string[]; fileArgs: string[]; } @@ -114,6 +116,8 @@ function parseArgs(args: string[]): Args { } } else if (arg === "--print" || arg === "-p") { result.print = true; + } else if (arg === "--export" && i + 1 < args.length) { + result.export = args[++i]; } else if (arg.startsWith("@")) { result.fileArgs.push(arg.slice(1)); // Remove @ prefix } else if (!arg.startsWith("-")) { @@ -237,6 +241,7 @@ ${chalk.bold("Options:")} --tools Comma-separated list of tools to enable (default: read,bash,edit,write) Available: read, bash, edit, write, grep, find, ls --thinking Set thinking level: off, minimal, low, medium, high + --export Export session file to HTML and exit --help, -h Show this help ${chalk.bold("Examples:")} @@ -273,6 +278,10 @@ ${chalk.bold("Examples:")} # Read-only mode (no file modifications possible) pi --tools read,grep,find,ls -p "Review the code in src/" + # Export a session file to HTML + pi --export ~/.pi/agent/sessions/--path--/session.jsonl + pi --export session.jsonl output.html + ${chalk.bold("Environment Variables:")} ANTHROPIC_API_KEY - Anthropic Claude API key ANTHROPIC_OAUTH_TOKEN - Anthropic OAuth token (alternative to API key) @@ -839,6 +848,20 @@ export async function main(args: string[]) { return; } + // Handle --export flag: convert session file to HTML and exit + if (parsed.export) { + try { + // Use first message as output path if provided + const outputPath = parsed.messages.length > 0 ? parsed.messages[0] : undefined; + const result = exportFromFile(parsed.export, outputPath); + console.log(`Exported to: ${result}`); + return; + } catch (error: any) { + console.error(chalk.red(`Error: ${error.message || "Failed to export session"}`)); + process.exit(1); + } + } + // Validate: RPC mode doesn't support @file arguments if (parsed.mode === "rpc" && parsed.fileArgs.length > 0) { console.error(chalk.red("Error: @file arguments are not supported in RPC mode"));