Merge c2c916b16da2bbf0f533b1fd07dda75b443d88b7 into 6b4c24c2e55b5b4013277bd799525086f6a0c40f

This commit is contained in:
Matt Van Horn 2026-03-21 04:45:11 +00:00 committed by GitHub
commit 674e6d5cf0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 523 additions and 0 deletions

View File

@ -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 <key>", "Session key to export (e.g. main, group-xyz)")
.option("--agent <id>", "Agent id (default: configured default agent)")
.option("--format <fmt>", "Output format: md or json", "md")
.option("--output <path>", "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,
);
});
});
}

View File

@ -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>): 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<string, unknown> }>,
): SessionMessageEntry {
const content: Array<
| { type: "text"; text: string }
| { type: "toolCall"; id: string; name: string; arguments: Record<string, unknown> }
> = [];
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("<details>");
expect(md).toContain("<summary>Tool call: weather.get</summary>");
expect(md).toContain("weather.get");
expect(md).toContain("</details>");
});
it("collapses tool results into details blocks", () => {
const entries = [makeToolResultEntry("weather.get", "72F, sunny")];
const md = sessionEntriesToMarkdown(makeHeader(), entries);
expect(md).toContain("<details>");
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]");
});
});

View File

@ -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("<details>");
lines.push(`<summary>Tool call: ${tc.name}</summary>`);
lines.push("");
lines.push("```");
lines.push(escapeCodeFence(tc.body));
lines.push("```");
lines.push("");
lines.push("</details>");
}
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("<details>");
lines.push(
`<summary>Tool result: ${msg.toolName}${msg.isError ? " (error)" : ""}</summary>`,
);
lines.push("");
lines.push("```");
lines.push(escapeCodeFence(truncated));
lines.push("```");
lines.push("");
lines.push("</details>");
lines.push("");
}
}
}
return lines.join("\n");
}
export async function sessionExportCommand(
options: SessionExportOptions,
runtime: RuntimeEnv,
): Promise<void> {
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);
}
}