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 1/2] 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); + } +} From c2c916b16da2bbf0f533b1fd07dda75b443d88b7 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Fri, 20 Mar 2026 09:13:14 -0700 Subject: [PATCH 2/2] fix(cli): address bot review feedback on session export - Handle legacy assistant string content in extractAssistantText (Codex P1) - Escape triple backticks in tool result/call code blocks (Codex P2) - Use runtime.log instead of console.log (Greptile P2) - Use os.homedir() instead of process.env.HOME for tilde expansion (Greptile P2) - Include tool name in tool call summary tags (Greptile P2) - Improve truncation test assertion directness (Greptile P2) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/commands/session-export.test.ts | 57 ++++++++++++++++++++++++++--- src/commands/session-export.ts | 29 +++++++++++---- 2 files changed, 73 insertions(+), 13 deletions(-) diff --git a/src/commands/session-export.test.ts b/src/commands/session-export.test.ts index 95068a4c8c0..ee50a42880a 100644 --- a/src/commands/session-export.test.ts +++ b/src/commands/session-export.test.ts @@ -124,7 +124,7 @@ describe("sessionEntriesToMarkdown", () => { expect(md).toContain("**Messages:** 2"); }); - it("collapses tool calls into details blocks", () => { + it("collapses tool calls into details blocks with tool name", () => { const entries = [ makeAssistantEntry("Checking...", 1742470260000, [ { name: "weather.get", arguments: { city: "SF" } }, @@ -132,7 +132,7 @@ describe("sessionEntriesToMarkdown", () => { ]; const md = sessionEntriesToMarkdown(makeHeader(), entries); expect(md).toContain("
"); - expect(md).toContain("Tool call"); + expect(md).toContain("Tool call: weather.get"); expect(md).toContain("weather.get"); expect(md).toContain("
"); }); @@ -151,13 +151,12 @@ describe("sessionEntriesToMarkdown", () => { expect(md).toContain("(error)"); }); - it("truncates long tool results", () => { + 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("..."); - // Should not contain the full 1000 chars - expect(md.length).toBeLessThan(longText.length); + expect(md).not.toContain("x".repeat(501)); }); it("handles compaction entries", () => { @@ -187,6 +186,54 @@ describe("sessionEntriesToMarkdown", () => { 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", diff --git a/src/commands/session-export.ts b/src/commands/session-export.ts index 78e03dbac96..f42b2edab85 100644 --- a/src/commands/session-export.ts +++ b/src/commands/session-export.ts @@ -1,4 +1,5 @@ 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 { @@ -51,16 +52,24 @@ function extractUserText(msg: UserMessage): string { .join("\n"); } -function extractAssistantText(msg: AssistantMessage): { text: string; toolCalls: string[] } { +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: 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(`${block.name}(${argsStr})`); + toolCalls.push({ name: block.name, body: `${block.name}(${argsStr})` }); } // Skip thinking blocks in export } @@ -83,6 +92,10 @@ function extractToolResultText(msg: ToolResultMessage): string { .join("\n"); } +function escapeCodeFence(text: string): string { + return text.replace(/```/g, "\\`\\`\\`"); +} + export function sessionEntriesToMarkdown( header: SessionHeader | null, entries: PiSessionEntry[], @@ -132,10 +145,10 @@ export function sessionEntriesToMarkdown( for (const tc of toolCalls) { lines.push(""); lines.push("
"); - lines.push(`Tool call`); + lines.push(`Tool call: ${tc.name}`); lines.push(""); lines.push("```"); - lines.push(tc); + lines.push(escapeCodeFence(tc.body)); lines.push("```"); lines.push(""); lines.push("
"); @@ -151,7 +164,7 @@ export function sessionEntriesToMarkdown( ); lines.push(""); lines.push("```"); - lines.push(truncated); + lines.push(escapeCodeFence(truncated)); lines.push("```"); lines.push(""); lines.push("
"); @@ -207,14 +220,14 @@ export async function sessionExportCommand( if (output) { const outputPath = path.resolve( - output.startsWith("~") ? output.replace("~", process.env.HOME ?? "") : output, + 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"); - console.log(`Exported session "${sessionKey}" to ${outputPath}`); + runtime.log(`Exported session "${sessionKey}" to ${outputPath}`); } else { process.stdout.write(result); }