Merge c2c916b16da2bbf0f533b1fd07dda75b443d88b7 into 6b4c24c2e55b5b4013277bd799525086f6a0c40f
This commit is contained in:
commit
674e6d5cf0
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
256
src/commands/session-export.test.ts
Normal file
256
src/commands/session-export.test.ts
Normal 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]");
|
||||
});
|
||||
});
|
||||
234
src/commands/session-export.ts
Normal file
234
src/commands/session-export.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user