src / tools / export_chat.ts
// src/tools/export_chat.ts
import { text, tool, type Tool, type ToolsProviderController } from "@lmstudio/sdk";
import { writeFile } from "fs/promises";
import { existsSync } from "fs";
import { basename, join } from "path";
import { configSchematics } from "../config";
import { getWorkingDirectoryInfo } from "../workingDirectoryInfo";
import { embedLocalImagesInMarkdown } from "../embedLocalImages";
import {
validateChatId,
ensureDir,
safeFileSize,
readJson,
convertMessagesToMarkdown,
} from "./common";
export function createExportChatTool(ctl: ToolsProviderController): Tool {
return tool({
name: "export_chat",
description: text`
Export the current chat to Markdown. Confirm success and file paths only.
`,
parameters: {},
implementation: async () => {
// Read per-chat config values
const cfg = ctl.getPluginConfig(configSchematics);
const shouldOverwrite = cfg.get("overwrite");
const includeThinking = cfg.get("includeThinking");
const includeToolCalls = cfg.get("includeToolCalls");
const emitMessagesJson = cfg.get("emitMessagesJson");
const embedImages = cfg.get("embedImages");
const workingDirectory = ctl.getWorkingDirectory();
const wdInfo = getWorkingDirectoryInfo(workingDirectory);
const chatId = basename(workingDirectory);
validateChatId(chatId);
if (!wdInfo.looksLikeLMStudioWorkingDir) {
throw new Error(
`Unexpected working directory layout.\n` +
`Working directory (raw): ${wdInfo.workingDirectoryRaw}\n` +
`Working directory (trimmed): ${wdInfo.workingDirectoryTrimmed}\n` +
`parentDirName: ${wdInfo.parentDirName}\n` +
`chatId: ${wdInfo.chatId} (valid13Digits=${wdInfo.chatIdValid13Digits})`
);
}
// Paths
const lmHome = wdInfo.lmHomeFromWorkingDir;
if (!lmHome) {
throw new Error(
`Unable to derive LM Studio home from working directory.\n` +
`Working directory (raw): ${wdInfo.workingDirectoryRaw}\n` +
`Working directory (trimmed): ${wdInfo.workingDirectoryTrimmed}\n` +
`parentDirName: ${wdInfo.parentDirName}\n` +
`chatId: ${wdInfo.chatId} (valid13Digits=${wdInfo.chatIdValid13Digits})`
);
}
const globalSource = join(lmHome, "conversations", `${chatId}.conversation.json`);
const destMd = join(workingDirectory, `${chatId}.conversation.md`);
const destMessagesJson = join(workingDirectory, `${chatId}.conversation.messages.json`);
// 1) Check source exists
if (!existsSync(globalSource)) {
throw new Error(
`Conversation file not found: ${globalSource}\n` +
`Working directory (raw): ${wdInfo.workingDirectoryRaw}\n` +
`Working directory (trimmed): ${wdInfo.workingDirectoryTrimmed}\n` +
`isAbsolute: ${wdInfo.isAbsolute}\n` +
`resolvedPath: ${wdInfo.resolvedPath}`
);
}
await ensureDir(workingDirectory);
// 2) Respect overwrite=false for rendered outputs
if (!shouldOverwrite && (existsSync(destMd) || (emitMessagesJson && existsSync(destMessagesJson)))) {
return {
ok: true,
skipped_conversion: true,
reason: "Destination exists and overwrite=false",
destination_markdown: destMd,
destination_messages_json: emitMessagesJson ? destMessagesJson : undefined,
};
}
// 3) Read source and convert to Markdown (and optional messages.json)
const data = await readJson(globalSource);
const messages = Array.isArray((data as any)?.messages) ? (data as any).messages : [];
let messagesJsonBytes = 0;
if (emitMessagesJson) {
const payload = JSON.stringify({ messages }, null, 2);
await writeFile(destMessagesJson, payload, "utf-8");
messagesJsonBytes = Buffer.byteLength(payload, "utf-8");
}
let markdown = convertMessagesToMarkdown(messages, {
includeThinking,
includeToolCalls,
embedImages,
});
if (embedImages) {
markdown = await embedLocalImagesInMarkdown(markdown, workingDirectory);
}
await writeFile(destMd, markdown.endsWith("\n") ? markdown : markdown + "\n", "utf-8");
const mdBytes = await safeFileSize(destMd);
return {
ok: true,
destination_markdown: destMd,
destination_messages_json: emitMessagesJson ? destMessagesJson : undefined,
bytes_markdown: mdBytes,
bytes_messages_json: emitMessagesJson ? messagesJsonBytes : undefined,
};
},
});
}