diff --git a/src/cli/program/register.status-health-sessions.ts b/src/cli/program/register.status-health-sessions.ts index 3a3d81abcf3..9efc3e5bcb8 100644 --- a/src/cli/program/register.status-health-sessions.ts +++ b/src/cli/program/register.status-health-sessions.ts @@ -1,5 +1,6 @@ import type { Command } from "commander"; import { healthCommand } from "../../commands/health.js"; +import { sessionExportCommand } from "../../commands/session-export.js"; import { sessionsCleanupCommand } from "../../commands/sessions-cleanup.js"; import { sessionsCommand } from "../../commands/sessions.js"; import { statusCommand } from "../../commands/status.js"; @@ -213,4 +214,36 @@ export function registerStatusHealthSessionsCommands(program: Command) { ); }); }); + + sessionsCmd + .command("export") + .description("Export a session transcript to markdown or JSON") + .requiredOption("--session ", "Session key to export (e.g. main, group-xyz)") + .option("--agent ", "Agent id (default: configured default agent)") + .option("--format ", "Output format: md or json", "md") + .option("--output ", "Write to file instead of stdout") + .addHelpText( + "after", + () => + `\n${theme.heading("Examples:")}\n${formatHelpExamples([ + ["openclaw sessions export --session main", "Export main session as markdown."], + ["openclaw sessions export --session main --format json", "Export as JSON."], + ["openclaw sessions export --session main --output chat.md", "Write to file."], + ["openclaw sessions export --session main --agent work", "Export from a specific agent."], + ])}`, + ) + .action(async (opts) => { + const format = opts.format === "json" ? "json" : "md"; + await runCommandWithRuntime(defaultRuntime, async () => { + await sessionExportCommand( + { + sessionKey: opts.session as string, + agentId: opts.agent as string | undefined, + format: format as "md" | "json", + output: opts.output as string | undefined, + }, + defaultRuntime, + ); + }); + }); } diff --git a/src/commands/session-export.test.ts b/src/commands/session-export.test.ts new file mode 100644 index 00000000000..ee50a42880a --- /dev/null +++ b/src/commands/session-export.test.ts @@ -0,0 +1,256 @@ +import type { + SessionHeader, + SessionMessageEntry, + CompactionEntry, +} from "@mariozechner/pi-coding-agent"; +import { describe, expect, it } from "vitest"; +import { sessionEntriesToMarkdown } from "./session-export.js"; + +function makeHeader(overrides?: Partial): SessionHeader { + return { + type: "session", + version: 3, + id: "test-session-123", + timestamp: "2026-03-20T10:30:00.000Z", + cwd: "/tmp/test", + ...overrides, + }; +} + +function makeUserEntry(text: string, ts = 1742470200000): SessionMessageEntry { + return { + type: "message", + id: `msg-${Math.random().toString(36).slice(2, 8)}`, + parentId: null, + timestamp: new Date(ts).toISOString(), + message: { + role: "user" as const, + content: text, + timestamp: ts, + }, + }; +} + +function makeAssistantEntry( + text: string, + ts = 1742470260000, + toolCalls?: Array<{ name: string; arguments: Record }>, +): SessionMessageEntry { + const content: Array< + | { type: "text"; text: string } + | { type: "toolCall"; id: string; name: string; arguments: Record } + > = []; + + if (text) { + content.push({ type: "text" as const, text }); + } + + if (toolCalls) { + for (const tc of toolCalls) { + content.push({ + type: "toolCall" as const, + id: `tc-${Math.random().toString(36).slice(2, 8)}`, + name: tc.name, + arguments: tc.arguments, + }); + } + } + + return { + type: "message", + id: `msg-${Math.random().toString(36).slice(2, 8)}`, + parentId: null, + timestamp: new Date(ts).toISOString(), + message: { + role: "assistant" as const, + content, + api: "anthropic-messages" as const, + provider: "anthropic", + model: "claude-sonnet-4-5-20250514", + usage: { + input: 100, + output: 50, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 150, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop" as const, + timestamp: ts, + }, + }; +} + +function makeToolResultEntry( + toolName: string, + text: string, + ts = 1742470280000, + isError = false, +): SessionMessageEntry { + return { + type: "message", + id: `msg-${Math.random().toString(36).slice(2, 8)}`, + parentId: null, + timestamp: new Date(ts).toISOString(), + message: { + role: "toolResult" as const, + toolCallId: `tc-${Math.random().toString(36).slice(2, 8)}`, + toolName, + content: [{ type: "text" as const, text }], + isError, + timestamp: ts, + }, + }; +} + +describe("sessionEntriesToMarkdown", () => { + it("renders header with session id and start time", () => { + const md = sessionEntriesToMarkdown(makeHeader(), []); + expect(md).toContain("# Session: test-session-123"); + expect(md).toContain("**Started:**"); + expect(md).toContain("**Messages:** 0"); + }); + + it("renders a simple user-assistant exchange", () => { + const entries = [ + makeUserEntry("Hello, what is the weather?"), + makeAssistantEntry("I can help with that. Let me check."), + ]; + const md = sessionEntriesToMarkdown(makeHeader(), entries); + expect(md).toContain("**User**"); + expect(md).toContain("Hello, what is the weather?"); + expect(md).toContain("**Assistant**"); + expect(md).toContain("I can help with that. Let me check."); + expect(md).toContain("**Messages:** 2"); + }); + + it("collapses tool calls into details blocks with tool name", () => { + const entries = [ + makeAssistantEntry("Checking...", 1742470260000, [ + { name: "weather.get", arguments: { city: "SF" } }, + ]), + ]; + const md = sessionEntriesToMarkdown(makeHeader(), entries); + expect(md).toContain("
"); + expect(md).toContain("Tool call: weather.get"); + expect(md).toContain("weather.get"); + expect(md).toContain("
"); + }); + + it("collapses tool results into details blocks", () => { + const entries = [makeToolResultEntry("weather.get", "72F, sunny")]; + const md = sessionEntriesToMarkdown(makeHeader(), entries); + expect(md).toContain("
"); + expect(md).toContain("Tool result: weather.get"); + expect(md).toContain("72F, sunny"); + }); + + it("marks error tool results", () => { + const entries = [makeToolResultEntry("api.call", "Connection timeout", 1742470280000, true)]; + const md = sessionEntriesToMarkdown(makeHeader(), entries); + expect(md).toContain("(error)"); + }); + + it("truncates long tool results with ellipsis", () => { + const longText = "x".repeat(1000); + const entries = [makeToolResultEntry("read", longText)]; + const md = sessionEntriesToMarkdown(makeHeader(), entries); + expect(md).toContain("..."); + expect(md).not.toContain("x".repeat(501)); + }); + + it("handles compaction entries", () => { + const compaction: CompactionEntry = { + type: "compaction", + id: "comp-1", + parentId: null, + timestamp: new Date().toISOString(), + summary: "Compacted earlier messages", + firstKeptEntryId: "msg-1", + tokensBefore: 50000, + }; + const md = sessionEntriesToMarkdown(makeHeader(), [ + compaction as unknown as SessionMessageEntry, + ]); + expect(md).toContain("*[Session compacted]*"); + }); + + it("handles empty session", () => { + const md = sessionEntriesToMarkdown(makeHeader(), []); + expect(md).toContain("# Session: test-session-123"); + expect(md).toContain("**Messages:** 0"); + }); + + it("handles null header", () => { + const md = sessionEntriesToMarkdown(null, [makeUserEntry("test")]); + expect(md).toContain("# Session: unknown"); + }); + + it("handles legacy assistant messages with string content", () => { + const entry: SessionMessageEntry = { + type: "message", + id: "msg-legacy", + parentId: null, + timestamp: new Date(1742470260000).toISOString(), + message: { + role: "assistant" as const, + content: "This is a legacy string response" as never, + api: "anthropic-messages" as const, + provider: "anthropic", + model: "claude-sonnet-4-5-20250514", + usage: { + input: 100, + output: 50, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 150, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop" as const, + timestamp: 1742470260000, + }, + }; + const md = sessionEntriesToMarkdown(makeHeader(), [entry]); + expect(md).toContain("**Assistant**"); + expect(md).toContain("This is a legacy string response"); + }); + + it("escapes triple backticks in tool result output", () => { + const entries = [makeToolResultEntry("bash", "output:\n```\nsome code\n```\nend")]; + const md = sessionEntriesToMarkdown(makeHeader(), entries); + // The inner backticks from the tool output should be escaped + expect(md).toContain("\\`\\`\\`"); + expect(md).not.toContain("output:\n```\n"); + }); + + it("truncates long tool results to 500 characters", () => { + const longText = "x".repeat(1000); + const entries = [makeToolResultEntry("read", longText)]; + const md = sessionEntriesToMarkdown(makeHeader(), entries); + expect(md).toContain("..."); + const codeBlockMatch = md.match(/```\n([\s\S]*?)\n```/); + expect(codeBlockMatch).toBeTruthy(); + // 500 chars + "..." = 503 + expect(codeBlockMatch![1]!.length).toBeLessThanOrEqual(503); + }); + + it("handles user message with image content", () => { + const entry: SessionMessageEntry = { + type: "message", + id: "msg-img", + parentId: null, + timestamp: new Date().toISOString(), + message: { + role: "user" as const, + content: [ + { type: "text" as const, text: "What is this?" }, + { type: "image" as const, data: "base64data", mimeType: "image/png" }, + ], + timestamp: Date.now(), + }, + }; + const md = sessionEntriesToMarkdown(makeHeader(), [entry]); + expect(md).toContain("What is this?"); + expect(md).toContain("[Image]"); + }); +}); diff --git a/src/commands/session-export.ts b/src/commands/session-export.ts new file mode 100644 index 00000000000..f42b2edab85 --- /dev/null +++ b/src/commands/session-export.ts @@ -0,0 +1,234 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { AssistantMessage, ToolResultMessage, UserMessage } from "@mariozechner/pi-ai"; +import type { + SessionEntry as PiSessionEntry, + SessionHeader, + SessionMessageEntry, +} from "@mariozechner/pi-coding-agent"; +import { SessionManager } from "@mariozechner/pi-coding-agent"; +import { + resolveDefaultSessionStorePath, + resolveSessionFilePath, + resolveSessionFilePathOptions, +} from "../config/sessions/paths.js"; +import { loadSessionStore } from "../config/sessions/store.js"; +import type { RuntimeEnv } from "../runtime.js"; + +export type SessionExportFormat = "md" | "json"; + +export interface SessionExportOptions { + sessionKey: string; + agentId?: string; + format: SessionExportFormat; + output?: string; +} + +function formatTime(ts: number): string { + const d = new Date(ts); + return d.toLocaleString("en-US", { + hour: "numeric", + minute: "2-digit", + hour12: true, + }); +} + +function extractUserText(msg: UserMessage): string { + if (typeof msg.content === "string") { + return msg.content; + } + return msg.content + .map((block) => { + if (block.type === "text") { + return block.text; + } + if (block.type === "image") { + return "[Image]"; + } + return ""; + }) + .filter(Boolean) + .join("\n"); +} + +function extractAssistantText(msg: AssistantMessage): { + text: string; + toolCalls: Array<{ name: string; body: string }>; +} { + // Legacy messages may have string content instead of an array + if (typeof msg.content === "string") { + return { text: msg.content, toolCalls: [] }; + } + + const textParts: string[] = []; + const toolCalls: Array<{ name: string; body: string }> = []; + + for (const block of msg.content) { + if (block.type === "text") { + textParts.push(block.text); + } else if (block.type === "toolCall") { + const argsStr = JSON.stringify(block.arguments, null, 2); + toolCalls.push({ name: block.name, body: `${block.name}(${argsStr})` }); + } + // Skip thinking blocks in export + } + + return { text: textParts.join("\n"), toolCalls }; +} + +function extractToolResultText(msg: ToolResultMessage): string { + return msg.content + .map((block) => { + if (block.type === "text") { + return block.text; + } + if (block.type === "image") { + return "[Image]"; + } + return ""; + }) + .filter(Boolean) + .join("\n"); +} + +function escapeCodeFence(text: string): string { + return text.replace(/```/g, "\\`\\`\\`"); +} + +export function sessionEntriesToMarkdown( + header: SessionHeader | null, + entries: PiSessionEntry[], +): string { + const lines: string[] = []; + + // Header + const sessionId = header?.id ?? "unknown"; + const startTime = header?.timestamp ? new Date(header.timestamp).toLocaleString() : "unknown"; + lines.push(`# Session: ${sessionId}`); + lines.push(""); + lines.push(`**Started:** ${startTime}`); + + const messageEntries = entries.filter((e): e is SessionMessageEntry => e.type === "message"); + const messageCount = messageEntries.filter( + (e) => (e.message as UserMessage | AssistantMessage | ToolResultMessage).role !== "toolResult", + ).length; + lines.push(`**Messages:** ${messageCount}`); + lines.push(""); + lines.push("---"); + lines.push(""); + + for (const entry of entries) { + if (entry.type !== "message") { + if (entry.type === "compaction") { + lines.push(`*[Session compacted]*`); + lines.push(""); + } + continue; + } + + const msgEntry = entry; + const msg = msgEntry.message as UserMessage | AssistantMessage | ToolResultMessage; + const time = formatTime(msg.timestamp); + + if (msg.role === "user") { + const text = extractUserText(msg); + lines.push(`**User** (${time}):`); + lines.push(text); + lines.push(""); + } else if (msg.role === "assistant") { + const { text, toolCalls } = extractAssistantText(msg); + lines.push(`**Assistant** (${time}):`); + if (text) { + lines.push(text); + } + for (const tc of toolCalls) { + lines.push(""); + lines.push("
"); + lines.push(`Tool call: ${tc.name}`); + lines.push(""); + lines.push("```"); + lines.push(escapeCodeFence(tc.body)); + lines.push("```"); + lines.push(""); + lines.push("
"); + } + lines.push(""); + } else if (msg.role === "toolResult") { + const text = extractToolResultText(msg); + const truncated = text.length > 500 ? text.slice(0, 500) + "..." : text; + if (truncated) { + lines.push("
"); + lines.push( + `Tool result: ${msg.toolName}${msg.isError ? " (error)" : ""}`, + ); + lines.push(""); + lines.push("```"); + lines.push(escapeCodeFence(truncated)); + lines.push("```"); + lines.push(""); + lines.push("
"); + lines.push(""); + } + } + } + + return lines.join("\n"); +} + +export async function sessionExportCommand( + options: SessionExportOptions, + runtime: RuntimeEnv, +): Promise { + const { sessionKey, agentId, format, output } = options; + + // Resolve session file path + const storePath = resolveDefaultSessionStorePath(agentId); + const sessionStore = loadSessionStore(storePath); + const entry = sessionStore[sessionKey]; + + if (!entry?.sessionId) { + runtime.error(`Session not found: ${sessionKey}`); + runtime.exit(1); + return; + } + + const sessionFile = resolveSessionFilePath( + entry.sessionId, + entry, + resolveSessionFilePathOptions({ agentId }), + ); + + if (!fs.existsSync(sessionFile)) { + runtime.error(`Session file not found: ${sessionFile}`); + runtime.exit(1); + return; + } + + // Load session + const sessionManager = SessionManager.open(sessionFile); + const entries = sessionManager.getEntries(); + const header = sessionManager.getHeader(); + + let result: string; + + if (format === "json") { + result = JSON.stringify({ header, entries }, null, 2); + } else { + result = sessionEntriesToMarkdown(header, entries); + } + + if (output) { + const outputPath = path.resolve( + output.startsWith("~") ? output.replace("~", os.homedir()) : output, + ); + const outputDir = path.dirname(outputPath); + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + fs.writeFileSync(outputPath, result, "utf-8"); + runtime.log(`Exported session "${sessionKey}" to ${outputPath}`); + } else { + process.stdout.write(result); + } +}