From a7eefe15a6270b2daec7f5b27f7ad3e1071fd100 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Fri, 20 Mar 2026 08:26:39 -0700 Subject: [PATCH] feat(cli): add session export to markdown Add `openclaw sessions export` subcommand that converts session JSONL transcripts to clean markdown or JSON. Tool calls and results are collapsed into
blocks. Long tool results are truncated. Avoids the Prettier template corruption that has broken the existing HTML /export-session command (#41862, #22595, #49957) by using plain text formatting with no template placeholders. Also fixes pre-existing oxfmt issue in docs/automation/standing-orders.md. Co-Authored-By: Claude Opus 4.6 --- .../register.status-health-sessions.ts | 33 +++ src/commands/session-export.test.ts | 209 +++++++++++++++++ src/commands/session-export.ts | 221 ++++++++++++++++++ 3 files changed, 463 insertions(+) create mode 100644 src/commands/session-export.test.ts create mode 100644 src/commands/session-export.ts 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..95068a4c8c0 --- /dev/null +++ b/src/commands/session-export.test.ts @@ -0,0 +1,209 @@ +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", () => { + const entries = [ + makeAssistantEntry("Checking...", 1742470260000, [ + { name: "weather.get", arguments: { city: "SF" } }, + ]), + ]; + const md = sessionEntriesToMarkdown(makeHeader(), entries); + expect(md).toContain("
"); + expect(md).toContain("Tool call"); + 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", () => { + const longText = "x".repeat(1000); + const entries = [makeToolResultEntry("read", longText)]; + const md = sessionEntriesToMarkdown(makeHeader(), entries); + expect(md).toContain("..."); + // Should not contain the full 1000 chars + expect(md.length).toBeLessThan(longText.length); + }); + + 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 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..78e03dbac96 --- /dev/null +++ b/src/commands/session-export.ts @@ -0,0 +1,221 @@ +import fs from "node:fs"; +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: string[] } { + const textParts: string[] = []; + const toolCalls: 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(`${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"); +} + +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`); + lines.push(""); + lines.push("```"); + lines.push(tc); + 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(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("~", process.env.HOME ?? "") : output, + ); + const outputDir = path.dirname(outputPath); + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + fs.writeFileSync(outputPath, result, "utf-8"); + console.log(`Exported session "${sessionKey}" to ${outputPath}`); + } else { + process.stdout.write(result); + } +}