Project Files
src / toolsProvider.ts
import { tool, type Tool, type ToolsProviderController } from "@lmstudio/sdk";
import { z } from "zod";
import { randomUUID } from "crypto";
import { existsSync } from "fs";
import { mkdir, writeFile, access, rm, readFile } from "fs/promises";
import { delimiter } from "path";
import { join, resolve, parse, relative, isAbsolute } from "path";
import { homedir } from "os";
import { execFile } from "child_process";
import { promisify } from "util";
const execFileAsync = promisify(execFile);
const TIMEOUT_MS = 30 * 60 * 1000;
const INSTALL_TIMEOUT_MS = 12 * 60 * 1000;
const MAX_BUFFER = 128 * 1024 * 1024;
const SUPPORTED_FORMATS = ["pdf", "docx", "txt", "md", "html", "odt", "rtf", "epub"] as const;
type SupportedFormat = typeof SUPPORTED_FORMATS[number];
type PythonCommand = {
command: string;
prefixArgs: string[];
};
function sanitizeBaseFilename(value: string | undefined, fallback: string): string {
const raw = (value || fallback || "document").trim();
const parsed = parse(raw);
const base = (parsed.name || raw || "document")
.replace(/[<>:"/\\|?*\x00-\x1F]/g, "_")
.replace(/\s+/g, "_")
.replace(/_+/g, "_")
.replace(/^\.+/, "")
.slice(0, 140);
return base || "document";
}
function normalizeFormats(format?: string, formats?: string[]): SupportedFormat[] {
const requested = Array.isArray(formats) && formats.length > 0 ? formats : (format ? [format] : ["pdf"]);
const result: SupportedFormat[] = [];
for (const f of requested) {
const value = String(f || "").toLowerCase().trim() as SupportedFormat;
if ((SUPPORTED_FORMATS as readonly string[]).includes(value) && !result.includes(value)) {
result.push(value);
}
}
return result.length ? result : ["pdf"];
}
function envPathRoots(name: string): string[] {
return String(process.env[name] || "")
.split(delimiter)
.map((p) => p.trim())
.filter((p) => p.length > 0)
.map((p) => resolve(p));
}
function isWithinPath(candidate: string, root: string): boolean {
const rel = relative(resolve(root), resolve(candidate));
return rel === "" || (rel.length > 0 && !rel.startsWith("..") && !isAbsolute(rel));
}
function requireWithinAllowedRoots(candidate: string, roots: string[], purpose: string): string {
const target = resolve(candidate);
const allowedRoots = roots.map((r) => resolve(r));
if (!allowedRoots.some((root) => isWithinPath(target, root))) {
throw new Error(
`Refusing ${purpose} outside allowed directories: ${target}. ` +
`Allowed roots: ${allowedRoots.join("; ") || "(none)"}`
);
}
return target;
}
function resolveSafeOutputDir(requested: string | undefined, defaultWorkingDir: string): string {
const defaultRoot = resolve(defaultWorkingDir);
if (!requested || requested.trim().length === 0) return defaultRoot;
return requireWithinAllowedRoots(
requested,
[defaultRoot, ...envPathRoots("LMSTUDIO_AI_TO_DOCUMENT_ALLOWED_OUTPUT_ROOTS")],
"output_dir",
);
}
function resolveSafeArchivesDir(requested: string): string {
const runtimeArchives = join(getRuntimeBaseDir(), "archives");
const vendorToolchain = join(getPluginRoot(), "vendor", "toolchain");
return requireWithinAllowedRoots(
requested,
[
runtimeArchives,
vendorToolchain,
...envPathRoots("LMSTUDIO_AI_TO_DOCUMENT_ALLOWED_ARCHIVE_ROOTS"),
],
"archives_dir",
);
}
function pythonEnv() {
return { ...process.env, PYTHONUTF8: "1", PYTHONIOENCODING: "utf-8" };
}
async function canRun(command: string, args: string[]): Promise<boolean> {
try {
await execFileAsync(command, args, { timeout: 8000, maxBuffer: MAX_BUFFER, windowsHide: true, env: pythonEnv() });
return true;
} catch {
return false;
}
}
async function findPython(): Promise<PythonCommand> {
const candidates: PythonCommand[] = [];
if (process.env.PYTHON && process.env.PYTHON.trim().length > 0) {
candidates.push({ command: process.env.PYTHON, prefixArgs: [] });
}
candidates.push(
{ command: "python", prefixArgs: [] },
{ command: "python3", prefixArgs: [] },
{ command: "py", prefixArgs: ["-3"] },
);
for (const candidate of candidates) {
if (await canRun(candidate.command, [...candidate.prefixArgs, "--version"])) {
return candidate;
}
}
throw new Error("Python was not found. Install Python 3.10+ or set the PYTHON environment variable to the path of python.exe.");
}
async function runPython(py: PythonCommand, args: string[], cwd?: string) {
return await execFileAsync(py.command, [...py.prefixArgs, ...args], {
cwd,
timeout: TIMEOUT_MS,
maxBuffer: MAX_BUFFER,
windowsHide: true,
env: pythonEnv(),
});
}
function venvPythonPath(venvDir: string): string {
return process.platform === "win32" ? join(venvDir, "Scripts", "python.exe") : join(venvDir, "bin", "python");
}
async function pathExists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch {
return false;
}
}
function getRuntimeBaseDir(): string {
return resolve(
process.env.LMSTUDIO_AI_TO_PDF_DOCX_ODT_EPUB_CACHE_DIR ||
process.env.LMSTUDIO_AI_TO_DOCUMENT_CACHE_DIR ||
join(homedir(), ".lmstudio", "ai-to-pdf-docx-odt-epub"),
);
}
async function cleanupTempFiles(paths: string[]) {
for (const p of paths) {
try {
await rm(p, { force: true, recursive: true });
} catch {
// Best effort cleanup. Temporary files are not the protagonist of this tedious little opera.
}
}
}
function getPluginRoot(): string {
const candidates = [
resolve(__dirname, ".."),
resolve(process.cwd()),
resolve(__dirname),
];
for (const candidate of candidates) {
if (existsSync(join(candidate, "scripts", "generate_document_verified.py"))) return candidate;
}
throw new Error(`scripts/generate_document_verified.py was not found. Checked roots: ${candidates.join(" | ")}`);
}
function getScriptPath(): string {
return join(getPluginRoot(), "scripts", "generate_document_verified.py");
}
function getRequirementsPath(): string {
return join(getPluginRoot(), "scripts", "requirements.txt");
}
function getOptionalRequirementsPath(): string {
return join(getPluginRoot(), "scripts", "optional-requirements.txt");
}
async function ensureOptionalPythonEngines(baseDir: string, venvPython: PythonCommand, autoInstallEngines: boolean): Promise<void> {
if (!autoInstallEngines) return;
const optionalRequirementsPath = getOptionalRequirementsPath();
if (!(await pathExists(optionalRequirementsPath))) return;
const marker = join(baseDir, ".optional-engines-v0.4.0.attempted");
if (await pathExists(marker)) return;
try {
await runPython(venvPython, ["-m", "pip", "install", "-r", optionalRequirementsPath], baseDir);
await writeFile(marker, "ok\n", "utf-8");
} catch (err: any) {
const msg = err?.stderr || err?.stdout || err?.message || String(err);
await writeFile(marker, `optional engine install failed; continuing with system tools/builtin fallback\n${msg}\n`, "utf-8");
}
}
async function ensurePythonEnvironment(baseDir: string, autoInstall: boolean, autoInstallEngines: boolean): Promise<string> {
const envDir = join(baseDir, ".venv");
const pyExe = venvPythonPath(envDir);
const scriptPath = getScriptPath();
const requirementsPath = getRequirementsPath();
if (!(await pathExists(pyExe))) {
if (!autoInstall) {
throw new Error(`Python venv is missing: ${envDir}. Call the tool with auto_install_dependencies=true or create the venv manually.`);
}
const systemPython = await findPython();
await mkdir(baseDir, { recursive: true });
await runPython(systemPython, ["-m", "venv", envDir], baseDir);
}
const venvPython: PythonCommand = { command: pyExe, prefixArgs: [] };
try {
await runPython(venvPython, [scriptPath, "--check-deps"], baseDir);
} catch (err: any) {
if (!autoInstall) {
throw new Error(`Python venv dependencies are missing. Run: "${pyExe}" -m pip install -r "${requirementsPath}". Error: ${err?.stderr || err?.message}`);
}
await runPython(venvPython, ["-m", "pip", "install", "--upgrade", "pip"], baseDir);
await runPython(venvPython, ["-m", "pip", "install", "-r", requirementsPath], baseDir);
await runPython(venvPython, [scriptPath, "--check-deps"], baseDir);
}
await ensureOptionalPythonEngines(baseDir, venvPython, autoInstall && autoInstallEngines);
return pyExe;
}
function shouldKeepPendingDocumentRequest(parsed: any): boolean {
if (!parsed) return true;
if (parsed.install_required) return true;
const files = Array.isArray(parsed.files) ? parsed.files : [];
// Preserve the user's original document request even if Python crashed before
// it could mark install_required. Otherwise the installer has nothing to resume,
// which is a very efficient way to annoy everyone involved.
return parsed.ok === false && files.length === 0;
}
function formatParsedResult(parsed: any): string {
const files = Array.isArray(parsed.files) ? parsed.files : [];
const discardedFiles = Array.isArray(parsed.discarded_files) ? parsed.discarded_files : [];
const verification = parsed.verification || {};
const cleanup = parsed.cleanup || {};
const ok = parsed.ok ? "PASS" : "FAIL";
const warnings = Array.isArray(verification.warnings) ? verification.warnings : [];
const errors = Array.isArray(verification.errors) ? verification.errors : [];
const filesLine = files.length
? `Files:\n${files.map((f: any) => `- ${String(f.format || "?").toUpperCase()}: ${f.path}${f.renderer ? ` [${f.renderer}]` : ""}`).join("\n")}`
: (discardedFiles.length
? `Files: none written; failed staged outputs were discarded (${discardedFiles.map((f: any) => String(f.format || "?").toUpperCase()).join(", ")})`
: "Files: none reported");
return [
`Document generation: ${ok}`,
parsed.renderer ? `Renderer: ${parsed.renderer}${parsed.math_renderer_requested ? ` (requested: ${parsed.math_renderer_requested})` : ""}` : undefined,
parsed.engine_plan ? `Engine plan: ${parsed.engine_plan.profile || "auto"} → ${parsed.engine_plan.selected_math_renderer || "auto"}${parsed.engine_plan.pandoc ? `; pandoc=${parsed.engine_plan.pandoc}` : ""}${parsed.engine_plan.tex_engine ? `; tex=${parsed.engine_plan.tex_engine}` : ""}` : undefined,
parsed.toolchain ? `Toolchain: ${parsed.toolchain.platform || "unknown"}; bin=${parsed.toolchain.bin_dir || ""}; pandoc=${parsed.toolchain.pandoc || "missing"}; tex=${parsed.toolchain.tex_engine || "missing"}` : undefined,
filesLine,
parsed.report_path ? `Verification report: ${parsed.report_path}` : "Verification report: not kept (temporary files cleaned)",
parsed.preview_dirs && Object.keys(parsed.preview_dirs).length ? `Rendered previews: ${JSON.stringify(parsed.preview_dirs)}` : "Rendered previews: not kept or not applicable",
`LaTeX/MathJax cleanup replacements: ${cleanup.replacements ?? 0}`,
cleanup.remaining_artifacts && cleanup.remaining_artifacts.length ? `Remaining markup artifacts: ${cleanup.remaining_artifacts.join("; ")}` : undefined,
cleanup.removed_workdir_artifacts && cleanup.removed_workdir_artifacts.length ? `Removed workdir artifacts: ${cleanup.removed_workdir_artifacts.length}` : undefined,
warnings.length ? `Warnings: ${warnings.join("; ")}` : undefined,
errors.length ? `Errors: ${errors.join("; ")}` : undefined,
parsed.error ? `Tool error: ${String(parsed.error)}` : undefined,
parsed.traceback ? `Traceback summary:\n${String(parsed.traceback).split("\n").slice(-12).join("\n")}` : undefined,
parsed.install_required ? `INSTALL REQUIRED: Academic PDF/DOCX/ODT/EPUB export needs document engines/fonts (Pandoc, Tectonic, Noto fonts, STIX Two Math). Non-academic fallback files were generated where possible. Ask exactly this single-action confirmation in the current conversation language: "${parsed.install_prompt || "To improve academic PDF/DOCX/ODT/EPUB export, install Pandoc, Tectonic, and fonts. Reply exactly “Install” or “Do not install”."}" Do not ask an "or continue/skip" question. If the user replies with only a generic yes/no after an ambiguous two-option question, ask the install_prompt again and do not call any tool. If the user clearly asks to install/download, immediately call install_document_engines with confirm=true, user_consent_quote set to the exact user reply, warm_cache=false, and resume_pending_document=true. Do not draft, explain, or recreate generate_document_verified arguments; the installer resumes the saved pending document automatically and overwrites the same filenames.` : undefined,
ok === "FAIL" ? (files.length
? "IMPORTANT FOR MODEL: The files were written, but the document did NOT pass verification. Tell the user the files exist and summarize the verification errors. Do NOT create _v2 retry files or call generate_document_verified again unless the user explicitly asks. If retrying, use the same filename and overwrite_existing=true."
: "IMPORTANT FOR MODEL: The document is NOT ready. No files were written. Report the missing dependency or ask to install document engines when install_required is present.") : undefined,
ok === "PASS" ? "Open the generated files using the full paths listed above. Local links in LM Studio chat may not always open directly." : undefined,
].filter(Boolean).join("\n");
}
function formatToolchainResult(parsed: any): string {
const status = parsed.toolchain || {};
const warnings = Array.isArray(parsed.warnings) ? parsed.warnings : [];
const errors = Array.isArray(parsed.errors) ? parsed.errors : [];
return [
`Toolchain install: ${parsed.ok ? "PASS" : "FAIL"}`,
parsed.already_installed ? "Existing document engines were detected and verified." : undefined,
`Platform: ${status.platform || "unknown"}`,
`Directory: ${status.toolchain_dir || ""}`,
`Bin: ${status.bin_dir || ""}`,
`Fonts: ${status.fonts_dir || ""}`,
Array.isArray(status.required_fonts_missing) && status.required_fonts_missing.length ? `Missing fonts: ${status.required_fonts_missing.join(", ")}` : "Required fonts: OK",
`Tectonic cache: ${status.tectonic_cache_dir || ""}${status.cache_warmed !== undefined ? `; warmed=${status.cache_warmed}` : ""}`,
`Pandoc: ${status.pandoc || "missing"}`,
`TeX engine: ${status.tex_engine || "missing"}`,
warnings.length ? `Warnings: ${warnings.join("; ")}` : undefined,
errors.length ? `Errors: ${errors.join("; ")}` : undefined,
parsed.ok ? "IMPORTANT FOR MODEL: Document engines are installed and available. If a pending document was resumed below, report the document result directly." : "IMPORTANT FOR MODEL: Document engines were NOT installed. Do not tell the user that installation succeeded. Report the error; the earlier fallback files remain available.",
].filter(Boolean).join("\n");
}
const PENDING_DOCUMENT_REQUEST_FILE = "pending-document-request.json";
function pendingDocumentRequestPath(baseDir: string): string {
return join(baseDir, PENDING_DOCUMENT_REQUEST_FILE);
}
async function savePendingDocumentRequest(baseDir: string, request: any): Promise<void> {
const pending = { ...request };
delete pending.response_path;
pending.auto_install_engines = false;
pending.overwrite_existing = true;
await writeFile(pendingDocumentRequestPath(baseDir), JSON.stringify(pending, null, 2), "utf-8");
}
async function clearPendingDocumentRequest(baseDir: string): Promise<void> {
await rm(pendingDocumentRequestPath(baseDir), { force: true }).catch(() => undefined);
}
async function resumePendingDocumentRequest(baseDir: string, pythonExe: string): Promise<string> {
const pendingPath = pendingDocumentRequestPath(baseDir);
if (!(await pathExists(pendingPath))) return "No pending document request was found to resume.";
let request: any;
try {
request = JSON.parse(await readFile(pendingPath, "utf-8"));
} catch (err: any) {
return `Could not read pending document request: ${err?.message || String(err)}`;
}
const requestId = randomUUID();
const runtimeDir = join(baseDir, "requests");
await mkdir(runtimeDir, { recursive: true });
const requestPath = join(runtimeDir, `${requestId}.json`);
const responsePath = join(runtimeDir, `${requestId}.result.json`);
request.response_path = responsePath;
request.auto_install_engines = false;
request.overwrite_existing = true;
await writeFile(requestPath, JSON.stringify(request, null, 2), "utf-8");
const scriptPath = getScriptPath();
try {
const result = await execFileAsync(pythonExe, [scriptPath, requestPath], {
cwd: request.output_dir || getPluginRoot(),
timeout: TIMEOUT_MS,
maxBuffer: MAX_BUFFER,
windowsHide: true,
env: pythonEnv(),
});
const stdout = (result.stdout || "").trim();
let parsed: any | null = null;
if (stdout.length > 0) {
try { parsed = JSON.parse(stdout); } catch {}
}
if (!parsed && await pathExists(responsePath)) {
try { parsed = JSON.parse(await readFile(responsePath, "utf-8")); } catch {}
}
await cleanupTempFiles([requestPath, responsePath]);
if (!parsed) return stdout || "Pending document generation completed, but no structured result was returned.";
if (!parsed.install_required) await clearPendingDocumentRequest(baseDir);
return formatParsedResult(parsed);
} catch (err: any) {
const stdout = err?.stdout ? String(err.stdout).trim() : "";
let parsed: any | null = null;
if (stdout.length > 0) {
try { parsed = JSON.parse(stdout); } catch {}
}
if (!parsed && await pathExists(responsePath)) {
try { parsed = JSON.parse(await readFile(responsePath, "utf-8")); } catch {}
}
await cleanupTempFiles([requestPath, responsePath]);
if (parsed) {
if (!parsed.install_required) await clearPendingDocumentRequest(baseDir);
return formatParsedResult(parsed);
}
return [
"Pending document generation failed after engine installation.",
err?.stderr ? `stderr:
${err.stderr}` : undefined,
err?.message ? `error:
${err.message}` : undefined,
].filter(Boolean).join("\n\n");
}
}
export async function toolsProvider(ctl: ToolsProviderController) {
const tools: Tool[] = [];
const generateDocumentVerified = tool({
name: "generate_document_verified",
description:
"MODEL MINI-PROMPT: Use this single tool for document generation. Do NOT call a separate preflight tool. Supported formats: PDF, DOCX, TXT, MD, HTML, ODT, RTF, EPUB. Prepare final Markdown once and call generate_document_verified once. This tool checks whether Pandoc/Tectonic/Noto/STIX are installed but NEVER downloads engines or fonts. If academic engines/fonts are missing, it still generates every requested format with the built-in non-academic fallback renderer, cleans verification/build leftovers from the working directory, stores the full request for later resumption, and returns INSTALL REQUIRED with a localized install_prompt. Then STOP and ask exactly the install_prompt in the same language as the conversation. Do not ask an 'or continue/skip' question; do not present numbered choices. Do NOT call install_document_engines until the user clearly writes an action reply like 'Установить', 'Скачать и установить', or 'Install'. If the user replies only with generic 'Да/Yes/OK' after an ambiguous two-option question, ask the install_prompt again and do not call any tool. If the user clearly asks to install/download, immediately call install_document_engines with confirm=true, user_consent_quote set to the exact reply, warm_cache=false, and resume_pending_document=true; do not explain, do not reason aloud, do not recreate the document arguments, and do not call generate_document_verified again. The installer resumes the saved pending request automatically and must overwrite the same filenames. If the user declines, report that fallback files were created but rich academic export was not installed. Never invent retry filenames such as *_v2; if a user explicitly asks for regeneration, use the same filename and overwrite_existing=true. For serious math/physics/chemistry, use valid LaTeX-style math and reaction notation in Markdown. Do not expose reasoning, drafts, or service comments.",
parameters: {
title: z.string().describe("Document title."),
markdown: z.string().describe("Complete final document body in Markdown. Do not include hidden reasoning, drafts, or service notes. LaTeX math is allowed and preferred for scientific documents."),
format: z.enum(SUPPORTED_FORMATS).optional().describe("Single output format. Ignored if formats is provided. Default: pdf."),
formats: z.array(z.enum(SUPPORTED_FORMATS)).optional().describe("One or more output formats. Use this for requests like 'save as PDF and DOCX'."),
filename: z.string().optional().describe("Base filename or filename with extension. The tool will create one file per requested format."),
output_dir: z.string().optional().describe("Optional output directory. If omitted, LM Studio working directory is used. For safety, custom paths must be inside the LM Studio working directory or an allowlisted root from LMSTUDIO_AI_TO_DOCUMENT_ALLOWED_OUTPUT_ROOTS."),
style: z.enum(["lecture", "report", "plain"]).optional().describe("Visual style for built-in rich formats. lecture is best for educational materials."),
math_renderer: z.enum(["auto", "pandoc", "mathjax", "katex", "mathml", "builtin"]).optional().describe("Scientific math rendering mode. auto uses Pandoc when math is detected; mathjax/katex affect HTML; pandoc forces Pandoc attempt; builtin skips Pandoc."),
strict: z.boolean().optional().describe("If true, fail when verification warnings remain. Default false."),
keep_previews: z.boolean().optional().describe("For PDF only: keep rendered PNG previews in a verification folder. Default false; failed verification does not keep previews unless this is true."),
keep_report: z.boolean().optional().describe("If true, keep the JSON verification report next to the output files. Default false; failed verification is returned in the tool result without leaving a report file."),
auto_install_dependencies: z.boolean().optional().describe("If true, create a local Python venv and install dependencies if needed. Default true."),
keep_failed_outputs: z.boolean().optional().describe("If true, write generated files even when verification fails, marking the result as FAIL. Default true, so users receive files even when verification reports issues."),
overwrite_existing: z.boolean().optional().describe("If true, overwrite the same base filenames on regeneration and normalize accidental retry suffixes like _2. Default true."),
},
implementation: async ({
title,
markdown,
format,
formats,
filename,
output_dir,
style,
strict,
keep_previews,
keep_report,
math_renderer,
auto_install_dependencies,
keep_failed_outputs,
overwrite_existing,
}) => {
const workingDir = resolveSafeOutputDir(output_dir, ctl.getWorkingDirectory());
await mkdir(workingDir, { recursive: true });
const runtimeBaseDir = getRuntimeBaseDir();
await mkdir(runtimeBaseDir, { recursive: true });
const requestId = randomUUID();
const runtimeDir = join(runtimeBaseDir, "requests");
await mkdir(runtimeDir, { recursive: true });
const requestedFormats = normalizeFormats(format, formats);
const baseFilename = sanitizeBaseFilename(filename, title || "document");
const requestPath = join(runtimeDir, `${requestId}.json`);
const responsePath = join(runtimeDir, `${requestId}.result.json`);
const autoInstall = auto_install_dependencies ?? true;
const autoInstallEngines = false; // Never download/install document engines from generation. Use install_document_engines only after explicit user consent.
const request = {
title,
markdown,
formats: requestedFormats,
filename_base: baseFilename,
output_dir: workingDir,
style: style || "lecture",
strict: strict ?? false,
keep_previews: keep_previews ?? false,
keep_report: keep_report ?? false,
math_renderer: math_renderer || "auto",
response_path: responsePath,
auto_install_engines: autoInstallEngines,
keep_failed_outputs: keep_failed_outputs ?? true,
overwrite_existing: overwrite_existing ?? true,
};
await writeFile(requestPath, JSON.stringify(request, null, 2), "utf-8");
const pythonExe = await ensurePythonEnvironment(runtimeBaseDir, autoInstall, autoInstallEngines);
const scriptPath = getScriptPath();
try {
const result = await execFileAsync(pythonExe, [scriptPath, requestPath], {
cwd: workingDir,
timeout: TIMEOUT_MS,
maxBuffer: MAX_BUFFER,
windowsHide: true,
env: pythonEnv(),
});
const stdout = (result.stdout || "").trim();
let parsed: any | null = null;
if (stdout.length > 0) {
try { parsed = JSON.parse(stdout); } catch {}
}
if (!parsed && await pathExists(responsePath)) {
try { parsed = JSON.parse(await readFile(responsePath, "utf-8")); } catch {}
}
await cleanupTempFiles([requestPath, responsePath]);
try { await rm(runtimeDir, { recursive: true, force: false }); } catch {}
if (!parsed) {
return stdout.length > 0
? `Document tool completed, but stdout was not JSON. Raw output:\n${stdout}`
: "Document tool completed, but no structured result was returned. Check LM Studio logs.";
}
if (shouldKeepPendingDocumentRequest(parsed)) await savePendingDocumentRequest(runtimeBaseDir, request);
else await clearPendingDocumentRequest(runtimeBaseDir);
return formatParsedResult(parsed);
} catch (err: any) {
const stderr = err?.stderr ? String(err.stderr) : "";
const stdout = err?.stdout ? String(err.stdout).trim() : "";
let parsed: any | null = null;
if (stdout.length > 0) {
try { parsed = JSON.parse(stdout); } catch {}
}
if (!parsed && await pathExists(responsePath)) {
try { parsed = JSON.parse(await readFile(responsePath, "utf-8")); } catch {}
}
await cleanupTempFiles([requestPath, responsePath]);
// The Python verifier exits with code 1 when verification fails. That is
// not a crash; it is exactly the useful result the model needs to see.
if (parsed) {
if (shouldKeepPendingDocumentRequest(parsed)) await savePendingDocumentRequest(runtimeBaseDir, request);
else await clearPendingDocumentRequest(runtimeBaseDir);
return formatParsedResult(parsed);
}
const msg = err?.message ? String(err.message) : String(err);
return [
"Document generation failed.",
`Python: ${pythonExe}`,
`Script: ${scriptPath}`,
`Request: ${requestPath}`,
stderr ? `stderr:\n${stderr}` : undefined,
stdout ? `stdout:\n${stdout}` : undefined,
`error:\n${msg}`,
].filter(Boolean).join("\n\n");
}
},
});
const installDocumentEngines = tool({
name: "install_document_engines",
description:
"Install document engines/fonts only after explicit user consent. The consent must clearly request installation/download, for example 'Установить', 'Скачать и установить', or 'Install'. A bare generic yes such as 'Да' or 'Yes' is ambiguous if the previous assistant asked a two-option/or question; in that case DO NOT call this tool, ask the exact install_prompt again and require 'Установить'/'Не устанавливать' or equivalent action wording. If this tool is called with an ambiguous quote, it returns CONSENT AMBIGUOUS and does not install anything. Do not explain, do not draft, do not call generate_document_verified again. After successful installation this tool automatically resumes the pending document by default when one exists, unless resume_pending_document=false is explicitly requested. Downloads or extracts only missing Pandoc, Tectonic, Noto fonts, and STIX Two Math components into the user's LM Studio data directory, then removes installer archive caches after extraction; STIX Two Math is copied from bundled vendor/toolchain/fonts first when present. Do not set warm_cache=true unless the user explicitly asks to prewarm TeX; cache warming can be slow on first run.",
parameters: {
confirm: z.boolean().optional().describe("Must be true only after the user explicitly agreed to download/extract document engines in the conversation."),
user_consent_quote: z.string().optional().describe("Paste the user's explicit consent message, for example 'Установить', 'Скачать и установить', 'Install', or 'Please install them'. Required. Do not invent this text. Bare generic yes/no replies such as 'Да' or 'Yes' are treated as ambiguous unless the quote itself clearly mentions installing/downloading."),
source: z.enum(["online", "local"]).optional().describe("online downloads official archives; local extracts from archives_dir. Default: online."),
platform: z.enum(["auto", "windows-x64", "linux-x64", "macos-x64", "macos-arm64"]).optional().describe("Target platform. Default auto. iOS is not supported by LM Studio desktop plugins."),
archives_dir: z.string().optional().describe("Directory containing pre-downloaded archives when source=local. For safety, it must be inside the plugin/vendor toolchain folder, the user toolchain archive cache, or an allowlisted root from LMSTUDIO_AI_TO_DOCUMENT_ALLOWED_ARCHIVE_ROOTS."),
warm_cache: z.boolean().optional().describe("If true, compile a small science TeX document to warm Tectonic cache. Default false because first-run Tectonic cache downloads can block the tool UI."),
force: z.boolean().optional().describe("If true, replace existing downloaded engines/fonts. Default false."),
auto_install_dependencies: z.boolean().optional().describe("If true, create Python venv and install Python dependencies before running the installer. Default true."),
resume_pending_document: z.boolean().optional().describe("If true, resume the last pending generate_document_verified request after successful install. Default true, so the user does not have to confirm regeneration twice."),
},
implementation: async ({ confirm, user_consent_quote, source, platform, archives_dir, warm_cache, force, auto_install_dependencies, resume_pending_document }) => {
const consentText = String(user_consent_quote || "").trim();
const normalizedConsent = consentText.toLowerCase();
const consentLooksExplicit = /^(установить|установи|скачать|скачай|скачать\s+и\s+установить|install|download|please\s+install|install\s+them|go\s+ahead\s+and\s+install|согласен\s+на\s+установку|согласна\s+на\s+установку|подтверждаю\s+установку)/i.test(normalizedConsent) || /(установ|скач|install|download|please\s+install|install\s+them|соглас.{0,20}установ|подтверж.{0,20}установ)/i.test(normalizedConsent);
const consentLooksDeclined = /^(не\s+устанавливать|не\s+устанавливай|не\s+скачивать|не\s+скачивай|не\s+надо|не\s+нужно|отмена|отменить|do\s+not\s+install|don't\s+install|do\s+not\s+download|don't\s+download|do\s+not|cancel|decline|declined)$/i.test(normalizedConsent) || /(не\s+(надо|нужно|устанавливай|устанавливать|скачивай|скачивать)|do\s+not\s+install|don't\s+install|do\s+not\s+download|don't\s+download)/i.test(normalizedConsent);
if (!confirm || !consentText) {
return [
"CONSENT MISSING: document engines are not installed yet.",
"Ask again in the current conversation language using the exact install_prompt and require an action reply such as \"Установить\" or \"Не устанавливать\".",
"Do not call generate_document_verified again; after a successful install, the installer will resume the pending document automatically by default.",
].join("\n");
}
if (consentLooksDeclined) {
return [
"CONSENT DECLINED: document engines/fonts were not installed.",
"Tell the user that the existing fallback files remain available, but academic PDF/DOCX/ODT/EPUB export was not installed.",
"Do not call generate_document_verified again unless the user explicitly asks to regenerate.",
].join("\n");
}
if (!consentLooksExplicit) {
return [
"CONSENT AMBIGUOUS: document engines/fonts were not installed.",
"The user's reply was generic or ambiguous and did not clearly request installation/download.",
"Ask the exact install_prompt again in the current conversation language and require an action reply. Do not call install_document_engines again until the user clearly asks to install/download. Do not call generate_document_verified again; after a successful install, the installer resumes the pending document automatically by default.",
].join("\n");
}
const runtimeBaseDir = getRuntimeBaseDir();
await mkdir(runtimeBaseDir, { recursive: true });
const autoInstall = auto_install_dependencies ?? true;
const pythonExe = await ensurePythonEnvironment(runtimeBaseDir, autoInstall, false);
const scriptPath = getScriptPath();
const args = [scriptPath, "--install-toolchain", "--source", source || "online", "--platform", platform || "auto"];
if (archives_dir && archives_dir.trim().length > 0) args.push("--archives-dir", resolveSafeArchivesDir(archives_dir));
if (warm_cache === true) args.push("--warm-cache");
if (force ?? false) args.push("--force");
try {
const result = await execFileAsync(pythonExe, args, {
cwd: getPluginRoot(),
timeout: INSTALL_TIMEOUT_MS,
maxBuffer: MAX_BUFFER,
windowsHide: true,
env: pythonEnv(),
});
const stdout = (result.stdout || "").trim();
let installText = stdout || "Toolchain installer completed without structured output.";
let parsedInstall: any | null = null;
try { parsedInstall = JSON.parse(stdout); installText = formatToolchainResult(parsedInstall); } catch {}
if (parsedInstall?.ok && (resume_pending_document ?? true)) {
const resumed = await resumePendingDocumentRequest(runtimeBaseDir, pythonExe);
return [installText, "", "---", "Resumed pending document generation after installing engines/fonts:", resumed].join("\n");
}
return installText;
} catch (err: any) {
const stdout = err?.stdout ? String(err.stdout).trim() : "";
if (stdout) {
try { return formatToolchainResult(JSON.parse(stdout)); } catch {}
}
return [
"Toolchain install failed.",
err?.stderr ? `stderr:\n${err.stderr}` : undefined,
err?.message ? `error:\n${err.message}` : undefined,
].filter(Boolean).join("\n\n");
}
},
});
tools.push(generateDocumentVerified);
tools.push(installDocumentEngines);
return tools;
}