feat(export): full transcript export + JSON dump (/export json)

The markdown export used to include only plain user/assistant text — every tool
call and tool result was dropped, so a shared transcript lost half the work.

- exportSession.ts: the markdown export now includes tool calls (fenced JSON),
  tool results (fenced), fallback tool_result blocks, and multimodal user
  content (text + [image attached] placeholders). Adds sessionToJson() — the
  full record (messages verbatim + meta + exportedAt) for cross-machine
  replay/sharing. exportSession() takes a format; defaultExportFilename() picks
  .md/.json.
- App.tsx: /export json [file] selects the JSON dump; /export [file] stays the
  markdown transcript. The export prompt remembers the chosen format.
- exportSession.test.ts: 8 cases (markdown includes tool calls/results, tool-
  only assistant turn, JSON shape, filename extensions, file writes, auto-name).
- /help + README: document /export json.

Verified: typecheck clean, build 268.34 KB, 292 tests pass (+8).
This commit is contained in:
kim
2026-08-21 13:56:05 +09:00
parent b2a7d1a0f0
commit 5438780a55
5 changed files with 160 additions and 26 deletions
+1 -1
View File
@@ -127,7 +127,7 @@ Note: even models with genuine native tool-calling support occasionally emit a t
/hooks show configured hooks per lifecycle event
/skills show installed skills; /<skill-name> [request] invokes one directly
/compact summarize the conversation now to free up context
/export [file] save the conversation as markdown — opens an editable filename prompt (default: locode-export-<timestamp>.md)
/export [file] save the conversation as markdown (or /export json [file] for a full JSON dump incl. tool calls/results)
/import <path> [caption] attach a local file or image to your next message
/clear clear conversation history
/help show this help
+87
View File
@@ -0,0 +1,87 @@
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, it, expect, afterEach, beforeEach } from "vitest";
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions";
import { exportSession, sessionToJson, sessionToMarkdown, defaultExportFilename } from "./exportSession.js";
const meta = { model: "test-model", createdAt: "2026-01-01T00:00:00.000Z" };
const messages: ChatCompletionMessageParam[] = [
{ role: "user", content: "hello" },
{ role: "assistant", content: "let me check", tool_calls: [{ id: "call_1", type: "function", function: { name: "read_file", arguments: '{"path":"a.ts"}' } }] },
{ role: "tool", tool_call_id: "call_1", content: "file contents" },
{ role: "assistant", content: "done" },
];
describe("sessionToMarkdown (full transcript)", () => {
it("includes user text, assistant text, tool calls, and tool results", () => {
const md = sessionToMarkdown(messages, meta);
expect(md).toContain("### You");
expect(md).toContain("hello");
expect(md).toContain("let me check");
expect(md).toContain("#### Tool calls");
expect(md).toContain('"name": "read_file"');
expect(md).toContain("#### Tool result");
expect(md).toContain("file contents");
expect(md).toContain("done");
});
it("does not drop a tool-only assistant turn (no text)", () => {
const md = sessionToMarkdown(
[{ role: "assistant", content: null, tool_calls: [{ id: "c", type: "function", function: { name: "grep", arguments: "{}" } }] } as ChatCompletionMessageParam],
meta,
);
expect(md).toContain("#### Tool calls");
expect(md).toContain('"name": "grep"');
});
});
describe("sessionToJson", () => {
it("produces a JSON object with meta, exportedAt, and the verbatim messages", () => {
const json = sessionToJson(messages, meta);
const parsed = JSON.parse(json);
expect(parsed.model).toBe("test-model");
expect(parsed.exportedAt).toBeTruthy();
expect(parsed.messages).toHaveLength(4);
expect(parsed.messages[1].tool_calls[0].function.name).toBe("read_file");
});
});
describe("defaultExportFilename", () => {
it("defaults to a .md extension", () => {
expect(defaultExportFilename()).toMatch(/\.md$/);
});
it("uses .json for the json format", () => {
expect(defaultExportFilename("json")).toMatch(/\.json$/);
});
});
describe("exportSession", () => {
let cwd: string;
beforeEach(() => {
cwd = mkdtempSync(path.join(os.tmpdir(), "locode-export-"));
});
afterEach(() => {
rmSync(cwd, { recursive: true, force: true });
});
it("writes a markdown file by default", async () => {
const resolved = await exportSession(messages, meta, cwd, "out.md");
const content = readFileSync(resolved, "utf-8");
expect(content).toContain("# locode conversation");
expect(content).toContain("hello");
});
it("writes a JSON file when format is json", async () => {
const resolved = await exportSession(messages, meta, cwd, "out.json", "json");
const content = readFileSync(resolved, "utf-8");
const parsed = JSON.parse(content);
expect(parsed.messages).toHaveLength(4);
});
it("auto-generates a filename with the right extension when none given", async () => {
const resolved = await exportSession(messages, meta, cwd, undefined, "json");
expect(resolved).toMatch(/\.json$/);
});
});
+59 -19
View File
@@ -3,16 +3,41 @@ import path from "node:path";
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions";
import { writeFileAtomic } from "../utils/writeFileAtomic.js";
/** A markdown export is meant to be read as prose, so only plain user/assistant text turns are
* included — raw tool-call/tool-result payloads and fallback-mode `tool_result` blocks are
* internal bookkeeping, not conversation content (unlike session resume, which does replay them
* as their own history items — see replayHistory.ts — since that's an interactive transcript). */
/** Renders one message as a markdown section for a FULL transcript export — including tool
* calls and their results, which the old prose-only export dropped. A tool-call assistant turn
* lists each call as a fenced JSON block; a tool-result message is rendered as a fenced result.
* Multimodal user content (text + image parts) is reduced to its text parts plus an
* `[image attached]` placeholder. Returns null only for genuinely empty turns. */
function messageSection(m: ChatCompletionMessageParam): string | null {
if (m.role === "user" && typeof m.content === "string" && !m.content.startsWith("```tool_result")) {
return `### You\n\n${m.content}`;
if (m.role === "user") {
if (typeof m.content === "string") {
if (m.content.startsWith("```tool_result")) {
// A fallback-mode tool result block — render it verbatim under a Tool result heading.
return `#### Tool result\n\n${m.content}`;
}
return `### You\n\n${m.content}`;
}
if (Array.isArray(m.content)) {
const parts = m.content.map((p) => (p.type === "text" ? p.text : "[image attached]")).join("\n");
return parts.trim() ? `### You\n\n${parts}` : null;
}
return null;
}
if (m.role === "assistant" && typeof m.content === "string" && m.content) {
return `### Assistant\n\n${m.content}`;
if (m.role === "assistant") {
const text = typeof m.content === "string" ? m.content : "";
const calls = (m as { tool_calls?: { id: string; function: { name: string; arguments: string } }[] }).tool_calls;
const parts: string[] = [];
if (text.trim()) parts.push(`### Assistant\n\n${text}`);
if (calls && calls.length) {
const block = calls.map((c) => `{"name": "${c.function.name}", "arguments": ${c.function.arguments}}`).join("\n");
parts.push(`#### Tool calls\n\n` + "```json\n" + block + "\n```");
}
return parts.length ? parts.join("\n\n") : null;
}
if (m.role === "tool") {
const tm = m as { content?: string; tool_call_id?: string };
const body = typeof tm.content === "string" ? tm.content : JSON.stringify(tm.content);
return `#### Tool result${tm.tool_call_id ? ` (${tm.tool_call_id})` : ""}\n\n` + "```\n" + body + "\n```";
}
return null;
}
@@ -22,6 +47,8 @@ export interface ExportMeta {
createdAt: string;
}
export type ExportFormat = "markdown" | "json";
export function sessionToMarkdown(messages: ChatCompletionMessageParam[], meta: ExportMeta): string {
const header = [
"# locode conversation",
@@ -34,22 +61,35 @@ export function sessionToMarkdown(messages: ChatCompletionMessageParam[], meta:
return [header, ...sections].join("\n\n");
}
export function defaultExportFilename(): string {
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
return `locode-export-${stamp}.md`;
/** A JSON export is the full record (messages verbatim + metadata), suitable for cross-machine
* replay/sharing or feeding into another tool. The markdown export is for humans. */
export function sessionToJson(messages: ChatCompletionMessageParam[], meta: ExportMeta): string {
return JSON.stringify({ ...meta, exportedAt: new Date().toISOString(), messages }, null, 2);
}
/** Writes the conversation to a markdown file and returns the resolved absolute path.
* `target` may be a bare filename, a relative path, or an absolute path; a bare directory
* (or nothing at all) falls back to an auto-generated filename inside `cwd`. Written atomically
export function defaultExportFilename(format: ExportFormat = "markdown"): string {
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
return `locode-export-${stamp}.${format === "json" ? "json" : "md"}`;
}
/** Writes the conversation to a file and returns the resolved absolute path. `target` may be a bare
* filename, a relative path, or an absolute path; a bare directory (or nothing at all) falls back
* to an auto-generated filename inside `cwd`. `format` selects a human markdown transcript
* (default, now including tool calls/results) or a machine-readable JSON dump. Written atomically
* (temp file + rename), matching sessionStore's saves, so a crash mid-export can't leave a
* truncated file. */
export async function exportSession(messages: ChatCompletionMessageParam[], meta: ExportMeta, cwd: string, target?: string): Promise<string> {
const filename = target?.trim() || defaultExportFilename();
export async function exportSession(
messages: ChatCompletionMessageParam[],
meta: ExportMeta,
cwd: string,
target?: string,
format: ExportFormat = "markdown",
): Promise<string> {
const filename = target?.trim() || defaultExportFilename(format);
let resolved = path.isAbsolute(filename) ? filename : path.resolve(cwd, filename);
if (existsSync(resolved) && statSync(resolved).isDirectory()) {
resolved = path.join(resolved, defaultExportFilename());
resolved = path.join(resolved, defaultExportFilename(format));
}
await writeFileAtomic(resolved, sessionToMarkdown(messages, meta));
await writeFileAtomic(resolved, format === "json" ? sessionToJson(messages, meta) : sessionToMarkdown(messages, meta));
return resolved;
}
}
+12 -5
View File
@@ -30,7 +30,7 @@ import { resolveAutoCompactThreshold, resolveMaxIterations } from "../../config/
import { KNOWN_BACKENDS, type BackendName } from "../../config/defaults.js";
import { getMcpStatuses, reconnectMcpServers } from "../../mcp/manager.js";
import type { PermissionDecision, PermissionMode } from "../../permissions/types.js";
import { defaultExportFilename, exportSession } from "../../persistence/exportSession.js";
import { defaultExportFilename, exportSession, type ExportFormat } from "../../persistence/exportSession.js";
import { loadMergedHooks } from "../../hooks/config.js";
import { expandCommandTemplate } from "../../plugins/expandTemplate.js";
import { getLoadedPlugins, getPluginCommandCollisions } from "../../plugins/registry.js";
@@ -135,7 +135,7 @@ export function App({
);
const [inputValue, setInputValue] = useState("");
const [permission, setPermission] = useState<PendingPermission | null>(null);
const [exportPrompt, setExportPrompt] = useState<{ defaultName: string } | null>(null);
const [exportPrompt, setExportPrompt] = useState<{ defaultName: string; format: ExportFormat } | null>(null);
// Mouse-wheel tracking (xterm ?1000h) lets the wheel scroll the transcript, but it ALSO
// captures mouse events so the terminal can't select/drag text to copy it. Default off — copy/
// drag is more important than wheel scroll, and PageUp/PageDown already scroll. Toggle with
@@ -859,8 +859,15 @@ export function App({
return;
}
if (trimmed.startsWith("/export")) {
const arg = trimmed.slice("/export".length).trim();
setExportPrompt({ defaultName: arg || defaultExportFilename() });
const rest = trimmed.slice("/export".length).trim();
// "/export json [file]" -> JSON dump; "/export [file]" -> markdown transcript.
let fmt: ExportFormat = "markdown";
let arg = rest;
if (rest === "json" || rest.startsWith("json ")) {
fmt = "json";
arg = rest === "json" ? "" : rest.slice("json".length).trim();
}
setExportPrompt({ defaultName: arg || defaultExportFilename(fmt), format: fmt });
return;
}
if (trimmed.startsWith("/import")) {
@@ -1095,7 +1102,7 @@ export function App({
return;
}
try {
const resolved = await exportSession(session.messages, { model: session.model, createdAt: session.createdAt }, cwd, trimmedName);
const resolved = await exportSession(session.messages, { model: session.model, createdAt: session.createdAt }, cwd, trimmedName, exportPrompt?.format ?? "markdown");
push({ kind: "notice", text: `Exported conversation to ${resolved}` });
} catch (err) {
push({ kind: "notice", text: `Export failed: ${(err as Error).message}`, isError: true });
+1 -1
View File
@@ -21,7 +21,7 @@ const HELP_LINES = [
" /hooks show configured hooks per lifecycle event",
" /skills show installed skills; /<skill-name> [request] invokes one directly",
" /compact summarize the conversation now to free up context",
" /export [file] save the conversation as markdown — opens an editable filename prompt (default: locode-export-<timestamp>.md)",
"save the conversation as markdown (or /export json [file] for a JSON dump) — editable filename prompt",
" /import <path> [caption] attach a local file or image to your next message",
" /clear clear conversation history",
" /help show this help",