Project Files
src / tools / export_doc.ts
// src/tools/export_doc.ts
import { text, tool, type Tool, type ToolsProviderController } from "@lmstudio/sdk";
import { writeFile, copyFile } from "fs/promises";
import { existsSync } from "fs";
import { basename, join, extname, resolve as resolvePath, isAbsolute, relative } from "path";
// @ts-ignore
import { z } from "zod";
import { globalConfigSchematics } from "../config.js";
import {
validateChatId,
ensureDir,
safeFileSize,
readJson,
convertMessagesToMarkdown,
} from "../services/chatExporter.js";
import { embedLocalImagesInMarkdown } from "../helpers/embedLocalImages.js";
import { slugify, buildFrontmatter } from "../helpers/frontmatter.js";
import {
getLMStudioWorkingDir,
resolveActiveLMStudioChatId,
setActiveChatContext,
} from "../core-bundle.mjs";
export function createExportDocTool(ctl: ToolsProviderController): Tool {
return tool({
name: "export_doc",
description: text`
Export the current chat conversation as a Markdown document into the notes directory.
Saves the document with a title-derived filename and playbook-compatible YAML frontmatter.
Images referenced in the chat are copied into the images/ subfolder (unless embedImages is true).
Confirm success and file path only.
`,
parameters: {
title: z.string().describe("Document title — used as the filename (slugified) and YAML frontmatter title."),
tags: z.array(z.string()).default([]).describe("Tags for the document frontmatter."),
},
implementation: async (args) => {
const getter: any =
(ctl as any).getGlobalPluginConfig || (ctl as any).getGlobalConfig;
const gcfg = getter ? getter.call(ctl, globalConfigSchematics) : null;
const notesDirectory: string = gcfg?.get("notesDirectory") ?? "";
if (!notesDirectory) {
return "Error: notesDirectory is not configured. Set it in the plugin global settings.";
}
const shouldOverwrite: boolean = gcfg?.get("exportOverwrite") ?? false;
const includeThinking: boolean = gcfg?.get("exportIncludeThinking") ?? false;
const includeToolCalls: boolean = gcfg?.get("exportIncludeToolCalls") ?? false;
const embedImages: boolean = gcfg?.get("exportEmbedImages") ?? false;
// Resolve chatId and working directory via core-bundle
const resolved = await resolveActiveLMStudioChatId();
if (!resolved.ok) {
return `Error: Could not identify active chat: ${(resolved as any).reason ?? "unknown"}`;
}
const chatId = resolved.chatId;
validateChatId(chatId);
const workingDirectory = getLMStudioWorkingDir(chatId);
setActiveChatContext({ chatId, workingDir: workingDirectory });
// Source conversation file: <lmHome>/conversations/<chatId>.conversation.json
// getLMStudioWorkingDir returns <lmHome>/working-directories/<chatId>
const lmHome = join(workingDirectory, "..", "..").replace(/\\/g, "/");
const globalSource = join(lmHome, "conversations", `${chatId}.conversation.json`);
if (!existsSync(globalSource)) {
return `Error: Conversation file not found: ${globalSource}`;
}
// Destination
const slug = slugify(args.title);
if (!slug) {
return "Error: Title must contain at least one word character.";
}
const filename = `${slug}.md`;
const destMd = join(notesDirectory, filename);
const imagesDir = join(notesDirectory, "images");
await ensureDir(notesDirectory);
await ensureDir(imagesDir);
if (!shouldOverwrite && existsSync(destMd)) {
return {
ok: true,
skipped: true,
reason: "Destination exists and exportOverwrite=false",
destination_markdown: destMd,
};
}
// Read source and convert to Markdown
const data = await readJson(globalSource);
const messages = Array.isArray((data as any)?.messages) ? (data as any).messages : [];
let markdown = convertMessagesToMarkdown(messages, {
includeThinking,
includeToolCalls,
embedImages,
});
if (embedImages) {
markdown = await embedLocalImagesInMarkdown(markdown, workingDirectory);
} else {
// Copy local images into imagesDir and rewrite links
markdown = await copyImagesToNotesDir(markdown, workingDirectory, imagesDir);
}
// Prepend playbook-compatible frontmatter
const now = new Date().toISOString();
const frontmatter = buildFrontmatter(args.title, args.tags, now);
const fullContent = frontmatter + markdown;
await writeFile(destMd, fullContent.endsWith("\n") ? fullContent : fullContent + "\n", "utf-8");
const mdBytes = await safeFileSize(destMd);
return {
ok: true,
destination_markdown: destMd,
bytes_markdown: mdBytes,
};
},
});
}
/**
* Copy images referenced in the markdown that reside in workingDirectory
* into imagesDir, and rewrite their links to relative `images/<filename>` paths.
* Non-local and already-external URLs are left untouched.
*/
async function copyImagesToNotesDir(
markdown: string,
workingDirectory: string,
imagesDir: string
): Promise<string> {
const re = /!\[([^\]]*)\]\(\s*(?:<([^>]+)>|([^\)\s]+))(?:\s+"([^"]*)")?\s*\)/g;
const copied = new Map<string, string>(); // absoluteSrc → destFilename
let out = "";
let lastIndex = 0;
for (;;) {
const m = re.exec(markdown);
if (!m) break;
const matchStart = m.index;
const matchText = m[0];
const alt = m[1] ?? "";
const url = (m[2] ?? m[3] ?? "").trim();
const title = typeof m[4] === "string" && m[4].length > 0 ? m[4] : null;
out += markdown.slice(lastIndex, matchStart);
lastIndex = matchStart + matchText.length;
// Skip external URLs and data URIs
if (!url || /^(data:|https?:|file:)/i.test(url)) {
out += matchText;
continue;
}
const absolutePath = isAbsolute(url) ? url : resolvePath(workingDirectory, url);
// Skip if not within workingDirectory
const rel = relative(workingDirectory, absolutePath);
if (rel.startsWith("..") || !existsSync(absolutePath)) {
out += matchText;
continue;
}
let destFilename = copied.get(absolutePath);
if (!destFilename) {
const base = basename(absolutePath);
const ext = extname(base);
const stem = base.slice(0, base.length - ext.length).replace(/ /g, "_");
let candidate = `${stem}${ext}`;
let i = 1;
while (existsSync(join(imagesDir, candidate)) && copied.get(absolutePath) !== candidate) {
candidate = `${stem}-${i}${ext}`;
i++;
}
destFilename = candidate;
copied.set(absolutePath, destFilename);
try {
await copyFile(absolutePath, join(imagesDir, destFilename));
} catch {
out += matchText;
continue;
}
}
const titlePart = title != null ? ` "${title.replace(/"/g, '\\"')}"` : "";
out += ``;
}
out += markdown.slice(lastIndex);
return out;
}