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:
@@ -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
|
||||
|
||||
@@ -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$/);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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 });
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user