src / toolsProvider.ts
import { text, tool, type Tool, type ToolsProviderController } from "@lmstudio/sdk";
import { execFile } from "node:child_process";
import { type Stats } from "node:fs";
import {
appendFile,
mkdir,
readdir,
readFile,
rename,
stat,
unlink,
writeFile,
} from "node:fs/promises";
import { homedir } from "node:os";
import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
import { TextDecoder } from "node:util";
import { z } from "zod";
type FileLineEnding = "\n" | "\r\n";
type FileSystemAction =
| "list_directory"
| "read_file"
| "read_chars"
| "write_file"
| "append_file"
| "replace_text"
| "replace_lines"
| "create_directory"
| "move_path"
| "delete_file"
| "search_text";
type ChartFieldType = "nominal" | "ordinal" | "quantitative" | "temporal";
type ChartType = "area" | "bar" | "line" | "pie" | "scatter";
type ChartValue = boolean | null | number | string;
interface WorkingContext {
rootPath: string;
}
interface ResolvedPath {
absolutePath: string;
displayPath: string;
statePath: string;
}
interface LoadedTextFile {
lineEnding: FileLineEnding;
mtimeMs: number;
normalizedText: string;
resolvedPath: ResolvedPath;
}
interface TrackedFileState {
mtimeMs: number;
stateKind: "read" | "write";
}
interface SearchMatch {
line: number;
text: string;
}
interface ChartEncodingField {
aggregate?: "count";
field?: string;
title?: string;
type: ChartFieldType | "quantitative";
}
interface VegaRuntimeModule {
parse: (spec: unknown) => unknown;
View: new (runtime: unknown, options: { renderer: "none" }) => {
finalize: () => void;
toSVG: () => Promise<string>;
};
}
interface VegaLiteRuntimeModule {
compile: (spec: unknown) => {
spec: unknown;
};
}
const strictUtf8Decoder = new TextDecoder("utf-8", { fatal: true });
const defaultReadLineCount = 250;
const maxReadLineCount = 1000;
const defaultReadCharCount = 4000;
const maxReadCharCount = 20_000;
const defaultListEntryCount = 200;
const maxListEntryCount = 1000;
const maxSearchFiles = 1000;
const maxSearchMatches = 100;
const defaultChartHeight = 360;
const defaultChartWidth = 640;
const maxChartDataRows = 5000;
const ignoredDirectoryNames = new Set([".git", "node_modules", "dist"]);
const importEsm = new Function("specifier", "return import(specifier)") as <T>(
specifier: string,
) => Promise<T>;
const fileSystemActionSchema = z.enum([
"list_directory",
"read_file",
"read_chars",
"write_file",
"append_file",
"replace_text",
"replace_lines",
"create_directory",
"move_path",
"delete_file",
"search_text",
]);
const chartDataValueSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]);
const chartTypeSchema = z.enum(["area", "bar", "line", "pie", "scatter"]);
const chartFieldTypeSchema = z.enum(["nominal", "ordinal", "quantitative", "temporal"]);
function toForwardSlashPath(path: string): string {
return path.replace(/\\/gu, "/");
}
function normalizeToLf(textValue: string): string {
return textValue.replace(/\r\n/gu, "\n").replace(/\r/gu, "\n");
}
function detectLineEnding(textValue: string): FileLineEnding {
return textValue.includes("\r\n") ? "\r\n" : "\n";
}
function applyLineEnding(textValue: string, lineEnding: FileLineEnding): string {
return lineEnding === "\n" ? textValue : textValue.replace(/\n/gu, lineEnding);
}
function isInsideRoot(rootPath: string, absolutePath: string): boolean {
const relativePath = relative(rootPath, absolutePath);
return (
relativePath.length === 0 ||
(!relativePath.startsWith("..") && !isAbsolute(relativePath))
);
}
function resolveWorkingPath(workingContext: WorkingContext, requestedPath: string): ResolvedPath {
const pathToResolve = requestedPath.trim().length === 0 ? "." : requestedPath;
const absolutePath = isAbsolute(pathToResolve)
? resolve(pathToResolve)
: resolve(workingContext.rootPath, pathToResolve);
if (!isInsideRoot(workingContext.rootPath, absolutePath)) {
throw new Error("Path must stay inside the LM Studio working directory.");
}
const relativePath = relative(workingContext.rootPath, absolutePath);
const displayPath = relativePath.length === 0 ? "." : toForwardSlashPath(relativePath);
return {
absolutePath,
displayPath,
statePath: toForwardSlashPath(absolutePath),
};
}
function expandHomePath(path: string): string {
if (path === "~") {
return homedir();
}
if (path.startsWith("~/")) {
return resolve(homedir(), path.slice(2));
}
return path;
}
function resolveHtmlFilePath(workingContext: WorkingContext, requestedPath: string): ResolvedPath {
const trimmedPath = requestedPath.trim();
if (trimmedPath.length === 0) {
throw new Error("Path must not be empty.");
}
const expandedPath = expandHomePath(trimmedPath);
const absolutePath = isAbsolute(expandedPath)
? resolve(expandedPath)
: resolve(workingContext.rootPath, expandedPath);
const relativePath = relative(workingContext.rootPath, absolutePath);
const displayPath = isInsideRoot(workingContext.rootPath, absolutePath)
? relativePath.length === 0
? "."
: toForwardSlashPath(relativePath)
: toForwardSlashPath(absolutePath);
return {
absolutePath,
displayPath,
statePath: toForwardSlashPath(absolutePath),
};
}
async function statIfExists(absolutePath: string): Promise<Stats | null> {
try {
return await stat(absolutePath);
} catch (error) {
const nodeError = error as NodeJS.ErrnoException;
if (nodeError.code === "ENOENT") {
return null;
}
throw error;
}
}
async function readTextFile(resolvedPath: ResolvedPath): Promise<LoadedTextFile> {
const fileStats = await statIfExists(resolvedPath.absolutePath);
if (fileStats === null) {
throw new Error(`${resolvedPath.displayPath} does not exist.`);
}
if (!fileStats.isFile()) {
throw new Error(`${resolvedPath.displayPath} is not a file.`);
}
const fileBuffer = await readFile(resolvedPath.absolutePath);
let textValue: string;
try {
textValue = strictUtf8Decoder.decode(fileBuffer);
} catch {
throw new Error(`${resolvedPath.displayPath} is not valid UTF-8 text.`);
}
return {
lineEnding: detectLineEnding(textValue),
mtimeMs: fileStats.mtimeMs,
normalizedText: normalizeToLf(textValue),
resolvedPath,
};
}
function splitLines(normalizedText: string): string[] {
if (normalizedText.length === 0) {
return [];
}
const lines = normalizedText.split("\n");
if (normalizedText.endsWith("\n")) {
lines.pop();
}
return lines;
}
function clampInteger(value: number | undefined, defaultValue: number, min: number, max: number) {
if (value === undefined) {
return defaultValue;
}
return Math.max(min, Math.min(max, value));
}
function renderReadLines(loadedTextFile: LoadedTextFile, startLine: number, lineCount: number) {
const lines = splitLines(loadedTextFile.normalizedText);
const startIndex = startLine - 1;
const selectedLines = lines.slice(startIndex, startIndex + lineCount);
const renderedLines = selectedLines.map((line, index) => `${startLine + index}: ${line}`);
const nextLine = startLine + selectedLines.length;
if (nextLine > lines.length) {
renderedLines.push("<EOF>");
} else {
renderedLines.push(
`Use read_file with path="${loadedTextFile.resolvedPath.displayPath}", start_line=${nextLine}, line_count=${Math.min(
lineCount,
lines.length - nextLine + 1,
)} to continue.`,
);
}
return renderedLines.join("\n");
}
function buildReplaceLinesText(
loadedTextFile: LoadedTextFile,
startLine: number,
endLineInclusive: number,
newContent: string,
): string {
const lines = splitLines(loadedTextFile.normalizedText);
if (startLine < 1) {
throw new Error("start_line must be at least 1.");
}
if (endLineInclusive < startLine) {
throw new Error("end_line_inclusive must be greater than or equal to start_line.");
}
if (endLineInclusive > lines.length) {
throw new Error(`end_line_inclusive exceeds file length (${lines.length} lines).`);
}
const replacementLines = splitLines(normalizeToLf(newContent));
lines.splice(startLine - 1, endLineInclusive - startLine + 1, ...replacementLines);
const nextText = lines.join("\n");
return loadedTextFile.normalizedText.endsWith("\n") && nextText.length > 0
? `${nextText}\n`
: nextText;
}
function ensureFreshForWrite(
trackedFileStates: Map<string, TrackedFileState>,
resolvedPath: ResolvedPath,
currentStats: Stats | null,
): string | null {
if (currentStats === null) {
return null;
}
const trackedState = trackedFileStates.get(resolvedPath.statePath);
if (trackedState === undefined) {
return "Refused: existing file has not been read by this tool session. Read it before writing.";
}
if (trackedState.stateKind === "write") {
return "Refused: file changed since the last read because of an earlier write. Read it again before writing.";
}
if (currentStats.mtimeMs > trackedState.mtimeMs) {
return "Refused: file was modified after it was last read. Read it again before writing.";
}
return null;
}
async function writeTextFileWithParents(
resolvedPath: ResolvedPath,
content: string,
): Promise<Stats> {
await mkdir(dirname(resolvedPath.absolutePath), { recursive: true });
await writeFile(resolvedPath.absolutePath, content, "utf-8");
const writtenStats = await statIfExists(resolvedPath.absolutePath);
if (writtenStats === null) {
throw new Error(`Expected ${resolvedPath.displayPath} to exist after writing.`);
}
return writtenStats;
}
async function listDirectory(
signal: AbortSignal,
resolvedPath: ResolvedPath,
recursive: boolean,
maxEntries: number,
): Promise<string> {
const targetStats = await statIfExists(resolvedPath.absolutePath);
if (targetStats === null) {
throw new Error(`${resolvedPath.displayPath} does not exist.`);
}
if (!targetStats.isDirectory()) {
throw new Error(`${resolvedPath.displayPath} is not a directory.`);
}
const renderedLines: string[] = [];
const queue: Array<{ absolutePath: string; depth: number; displayPrefix: string }> = [
{ absolutePath: resolvedPath.absolutePath, depth: 0, displayPrefix: "" },
];
let omitted = 0;
while (queue.length > 0 && renderedLines.length < maxEntries) {
signal.throwIfAborted();
const current = queue.shift()!;
const entries = (await readdir(current.absolutePath, { withFileTypes: true })).sort((left, right) => {
if (left.isDirectory() && !right.isDirectory()) {
return -1;
}
if (!left.isDirectory() && right.isDirectory()) {
return 1;
}
return left.name.localeCompare(right.name);
});
for (const entry of entries) {
signal.throwIfAborted();
if (renderedLines.length >= maxEntries) {
omitted += 1;
continue;
}
const isDirectory = entry.isDirectory();
const childAbsolutePath = resolve(current.absolutePath, entry.name);
const indent = " ".repeat(current.depth);
renderedLines.push(`${indent}${current.displayPrefix}${entry.name}${isDirectory ? "/" : ""}`);
if (recursive && isDirectory && !ignoredDirectoryNames.has(entry.name)) {
queue.push({
absolutePath: childAbsolutePath,
depth: current.depth + 1,
displayPrefix: "",
});
}
}
}
if (renderedLines.length === 0) {
return "<empty>";
}
if (omitted > 0 || queue.length > 0) {
renderedLines.push(`... ${omitted + queue.length} entries omitted`);
}
return renderedLines.join("\n");
}
async function collectSearchFiles(
signal: AbortSignal,
absolutePath: string,
files: string[],
): Promise<void> {
if (files.length >= maxSearchFiles) {
return;
}
signal.throwIfAborted();
const targetStats = await statIfExists(absolutePath);
if (targetStats === null) {
return;
}
if (targetStats.isFile()) {
files.push(absolutePath);
return;
}
if (!targetStats.isDirectory()) {
return;
}
const entries = await readdir(absolutePath, { withFileTypes: true });
for (const entry of entries) {
signal.throwIfAborted();
if (files.length >= maxSearchFiles) {
return;
}
if (entry.isDirectory() && ignoredDirectoryNames.has(entry.name)) {
continue;
}
await collectSearchFiles(signal, resolve(absolutePath, entry.name), files);
}
}
function buildSearchMatcher(searchTerm: string, regex: boolean): (line: string) => boolean {
if (!regex) {
return line => line.includes(searchTerm);
}
const matcher = new RegExp(searchTerm, "u");
return line => matcher.test(line);
}
async function searchText(
signal: AbortSignal,
workingContext: WorkingContext,
resolvedPath: ResolvedPath,
searchTerm: string,
regex: boolean,
): Promise<string> {
if (searchTerm.length === 0) {
throw new Error("search_term must not be empty.");
}
const files: string[] = [];
await collectSearchFiles(signal, resolvedPath.absolutePath, files);
const matchesByPath: Array<{ path: string; matches: SearchMatch[] }> = [];
const matcher = buildSearchMatcher(searchTerm, regex);
let totalMatches = 0;
for (const absolutePath of files) {
signal.throwIfAborted();
if (totalMatches >= maxSearchMatches) {
break;
}
const relativePath = relative(workingContext.rootPath, absolutePath);
const loadedTextFile = await readTextFile({
absolutePath,
displayPath: relativePath.length === 0 ? "." : toForwardSlashPath(relativePath),
statePath: toForwardSlashPath(absolutePath),
}).catch(() => null);
if (loadedTextFile === null) {
continue;
}
const fileMatches: SearchMatch[] = [];
splitLines(loadedTextFile.normalizedText).forEach((line, index) => {
if (totalMatches + fileMatches.length < maxSearchMatches && matcher(line)) {
fileMatches.push({ line: index + 1, text: line });
}
});
if (fileMatches.length > 0) {
totalMatches += fileMatches.length;
matchesByPath.push({ path: loadedTextFile.resolvedPath.displayPath, matches: fileMatches });
}
}
if (matchesByPath.length === 0) {
return "No matches.";
}
return matchesByPath
.map(item => {
const renderedMatches = item.matches
.map(match => ` ${match.line}: ${match.text}`)
.join("\n");
return `${item.path}\n${renderedMatches}`;
})
.join("\n\n");
}
async function runOpen(path: string, signal: AbortSignal): Promise<void> {
await new Promise<void>((resolvePromise, rejectPromise) => {
const childProcess = execFile("open", [path], error => {
signal.removeEventListener("abort", abortListener);
if (error !== null) {
rejectPromise(error);
return;
}
resolvePromise();
});
const abortListener = () => {
childProcess.kill();
rejectPromise(signal.reason instanceof Error ? signal.reason : new Error("Aborted."));
};
if (signal.aborted) {
abortListener();
return;
}
signal.addEventListener("abort", abortListener, { once: true });
});
}
async function runQuickLookThumbnail(
inputPath: string,
outputDirectory: string,
signal: AbortSignal,
): Promise<string> {
const generatedPath = join(outputDirectory, `${basename(inputPath)}.png`);
await new Promise<void>((resolvePromise, rejectPromise) => {
const childProcess = execFile(
"qlmanage",
["-t", "-s", "1200", "-o", outputDirectory, inputPath],
{
maxBuffer: 1024 * 1024,
timeout: 30_000,
},
error => {
signal.removeEventListener("abort", abortListener);
if (error !== null) {
rejectPromise(error);
return;
}
resolvePromise();
},
);
const abortListener = () => {
childProcess.kill();
rejectPromise(signal.reason instanceof Error ? signal.reason : new Error("Aborted."));
};
if (signal.aborted) {
abortListener();
return;
}
signal.addEventListener("abort", abortListener, { once: true });
});
await stat(generatedPath);
return generatedPath;
}
function getWorkingContext(ctl: ToolsProviderController): WorkingContext {
return { rootPath: resolve(ctl.getWorkingDirectory()) };
}
async function loadVegaModules(): Promise<{
vega: VegaRuntimeModule;
vegaLite: VegaLiteRuntimeModule;
}> {
const [vega, vegaLite] = await Promise.all([
importEsm<VegaRuntimeModule>("vega"),
importEsm<VegaLiteRuntimeModule>("vega-lite"),
]);
return { vega, vegaLite };
}
function inferChartFieldType(data: Array<Record<string, ChartValue>>, field: string): ChartFieldType {
const values = data
.map(row => row[field])
.filter((value): value is boolean | number | string => value !== null && value !== undefined);
if (values.length > 0 && values.every(value => typeof value === "number" && Number.isFinite(value))) {
return "quantitative";
}
return "nominal";
}
function hasField(data: Array<Record<string, ChartValue>>, field: string): boolean {
return data.some(row => Object.prototype.hasOwnProperty.call(row, field));
}
function buildYEncoding({
data,
field,
title,
type,
}: {
data: Array<Record<string, ChartValue>>;
field: string | undefined;
title: string | undefined;
type: ChartFieldType | undefined;
}): ChartEncodingField {
if (field === undefined) {
return { aggregate: "count", title: title ?? "Count", type: "quantitative" };
}
return {
field,
title,
type: type ?? inferChartFieldType(data, field),
};
}
function normalizeChartOutputPath(outputPath: string | undefined): string {
if (outputPath === undefined || outputPath.trim().length === 0) {
const timestamp = new Date().toISOString().replace(/[:.]/gu, "-");
return `charts/chart-${timestamp}.png`;
}
const trimmedPath = outputPath.trim();
if (extname(trimmedPath).length === 0) {
return `${trimmedPath}.png`;
}
return trimmedPath;
}
function buildMarkdownImageLink(altText: string, absolutePath: string): string {
const safeAltText = altText.replace(/[\]\r\n]/gu, " ").trim() || "chart";
const markdownPath = toForwardSlashPath(absolutePath);
const linkPath = /[\s()<>]/u.test(markdownPath)
? `<${markdownPath.replace(/>/gu, "%3E")}>`
: markdownPath;
return ``;
}
export async function toolsProvider(ctl: ToolsProviderController) {
const trackedFileStates = new Map<string, TrackedFileState>();
const tools: Tool[] = [];
const createHtmlFileTool = tool({
name: "create_html_file",
description: text`
Create a local .html file. Relative paths resolve from the current LM Studio working
directory. Absolute paths and ~/ paths are allowed.
When you create HTML, be extremely simple and terse. Prefer one small file, plain semantic
HTML, tiny CSS, no framework, no build step, no commentary, no placeholder sections, and no
clever abstractions. Write only the final complete HTML document.
`,
parameters: {
path: z
.string()
.describe("Relative, absolute, or ~/ path ending in .html or .htm."),
html: z
.string()
.describe("The complete HTML document. Keep it very short, direct, and self-contained."),
overwrite_existing: z
.boolean()
.optional()
.describe("Set true only when the user explicitly wants to replace an existing file."),
},
implementation: async ({ path, html, overwrite_existing }, { signal, status }) => {
signal.throwIfAborted();
const workingContext = getWorkingContext(ctl);
const resolvedPath = resolveHtmlFilePath(workingContext, path);
const extension = extname(resolvedPath.absolutePath).toLowerCase();
if (extension !== ".html" && extension !== ".htm") {
return "Error: create_html_file only writes .html or .htm files.";
}
const currentStats = await statIfExists(resolvedPath.absolutePath);
signal.throwIfAborted();
if (currentStats !== null && !currentStats.isFile()) {
return "Error: path exists but is not a file.";
}
if (currentStats !== null && overwrite_existing !== true) {
return "Error: file already exists. Set overwrite_existing=true only if the user asked to replace it.";
}
status(`Writing ${resolvedPath.displayPath}`);
const writtenStats = await writeTextFileWithParents(resolvedPath, html);
trackedFileStates.set(resolvedPath.statePath, {
mtimeMs: writtenStats.mtimeMs,
stateKind: "write",
});
return {
path: resolvedPath.displayPath,
bytes: Buffer.byteLength(html, "utf-8"),
note: "Created. Keep future HTML edits simple and terse.",
};
},
});
tools.push(createHtmlFileTool);
const fileSystemTool = tool({
name: "manipulate_file_system",
description: text`
Manipulate files inside the current LM Studio working directory. Paths may be relative, or
absolute paths that still resolve inside the working directory.
Actions:
- list_directory: path, optional recursive, max_entries
- read_file: path, optional start_line, line_count
- read_chars: path, optional start_char, char_count
- write_file: path, content; creates a new file or replaces an existing file after a prior read
- append_file: path, content
- replace_text: path, search_term, replacement, optional replace_all
- replace_lines: path, start_line, end_line_inclusive, content
- create_directory: path
- move_path: path, new_path
- delete_file: path, allow_delete=true
- search_text: path, search_term, optional regex
For existing-file writes and replacements, read the file first. After a write, read the file
again before the next write. This avoids editing stale content.
`,
parameters: {
action: fileSystemActionSchema.describe("Filesystem action to perform."),
path: z.string().describe("Path inside the LM Studio working directory."),
content: z.string().optional().describe("Text content for write, append, or replace_lines."),
search_term: z.string().optional().describe("Text or regex pattern for replace_text/search_text."),
replacement: z.string().optional().describe("Replacement text for replace_text."),
new_path: z.string().optional().describe("Destination path for move_path."),
start_line: z.number().int().optional().describe("1-indexed starting line."),
end_line_inclusive: z.number().int().optional().describe("1-indexed inclusive ending line."),
line_count: z.number().int().optional().describe("Maximum lines to read."),
start_char: z.number().int().optional().describe("0-indexed character offset."),
char_count: z.number().int().optional().describe("Maximum characters to read."),
recursive: z.boolean().optional().describe("Use recursive traversal for list_directory."),
regex: z.boolean().optional().describe("Treat search_term as a JavaScript regex."),
replace_all: z.boolean().optional().describe("Replace every text match for replace_text."),
overwrite_existing: z.boolean().optional().describe("Allow replacing an existing destination."),
allow_delete: z.boolean().optional().describe("Required true for delete_file."),
max_entries: z.number().int().optional().describe("Limit list_directory output entries."),
},
implementation: async (params, { signal, status }) => {
const action = params.action as FileSystemAction;
signal.throwIfAborted();
const workingContext = getWorkingContext(ctl);
const resolvedPath = resolveWorkingPath(workingContext, params.path);
switch (action) {
case "list_directory": {
status(`Listing ${resolvedPath.displayPath}`);
const maxEntries = clampInteger(
params.max_entries,
defaultListEntryCount,
1,
maxListEntryCount,
);
return await listDirectory(signal, resolvedPath, params.recursive ?? false, maxEntries);
}
case "read_file": {
status(`Reading ${resolvedPath.displayPath}`);
const loadedTextFile = await readTextFile(resolvedPath);
trackedFileStates.set(resolvedPath.statePath, {
mtimeMs: loadedTextFile.mtimeMs,
stateKind: "read",
});
const startLine = clampInteger(params.start_line, 1, 1, Number.MAX_SAFE_INTEGER);
const lineCount = clampInteger(
params.line_count,
defaultReadLineCount,
1,
maxReadLineCount,
);
return renderReadLines(loadedTextFile, startLine, lineCount);
}
case "read_chars": {
status(`Reading ${resolvedPath.displayPath}`);
const loadedTextFile = await readTextFile(resolvedPath);
trackedFileStates.set(resolvedPath.statePath, {
mtimeMs: loadedTextFile.mtimeMs,
stateKind: "read",
});
const startChar = clampInteger(params.start_char, 0, 0, Number.MAX_SAFE_INTEGER);
const charCount = clampInteger(
params.char_count,
defaultReadCharCount,
1,
maxReadCharCount,
);
return loadedTextFile.normalizedText.slice(startChar, startChar + charCount);
}
case "write_file": {
if (params.content === undefined) {
return "Error: write_file requires content.";
}
const currentStats = await statIfExists(resolvedPath.absolutePath);
if (currentStats !== null && params.overwrite_existing !== true) {
return "Error: file exists. Set overwrite_existing=true only if replacing is intended.";
}
const guardMessage =
currentStats === null
? null
: ensureFreshForWrite(trackedFileStates, resolvedPath, currentStats);
if (guardMessage !== null) {
return guardMessage;
}
status(`Writing ${resolvedPath.displayPath}`);
const existingTextFile = currentStats === null ? null : await readTextFile(resolvedPath);
const lineEnding = existingTextFile?.lineEnding ?? "\n";
const writtenStats = await writeTextFileWithParents(
resolvedPath,
applyLineEnding(normalizeToLf(params.content), lineEnding),
);
trackedFileStates.set(resolvedPath.statePath, {
mtimeMs: writtenStats.mtimeMs,
stateKind: "write",
});
return {
path: resolvedPath.displayPath,
action: currentStats === null ? "created" : "replaced",
bytes: Buffer.byteLength(params.content, "utf-8"),
};
}
case "append_file": {
if (params.content === undefined) {
return "Error: append_file requires content.";
}
status(`Appending ${resolvedPath.displayPath}`);
await mkdir(dirname(resolvedPath.absolutePath), { recursive: true });
await appendFile(resolvedPath.absolutePath, params.content, "utf-8");
const writtenStats = await statIfExists(resolvedPath.absolutePath);
if (writtenStats === null) {
throw new Error(`Expected ${resolvedPath.displayPath} to exist after appending.`);
}
trackedFileStates.set(resolvedPath.statePath, {
mtimeMs: writtenStats.mtimeMs,
stateKind: "write",
});
return { path: resolvedPath.displayPath, action: "appended" };
}
case "replace_text": {
if (params.search_term === undefined) {
return "Error: replace_text requires search_term.";
}
if (params.search_term.length === 0) {
return "Error: replace_text requires a non-empty search_term.";
}
if (params.replacement === undefined) {
return "Error: replace_text requires replacement.";
}
const currentStats = await statIfExists(resolvedPath.absolutePath);
const guardMessage = ensureFreshForWrite(trackedFileStates, resolvedPath, currentStats);
if (guardMessage !== null) {
return guardMessage;
}
status(`Replacing text in ${resolvedPath.displayPath}`);
const loadedTextFile = await readTextFile(resolvedPath);
const occurrences = params.replace_all === true ? "all" : "first";
const nextText =
params.replace_all === true
? loadedTextFile.normalizedText.split(params.search_term).join(params.replacement)
: loadedTextFile.normalizedText.replace(params.search_term, params.replacement);
if (nextText === loadedTextFile.normalizedText) {
return "No matching text found.";
}
const writtenStats = await writeTextFileWithParents(
resolvedPath,
applyLineEnding(nextText, loadedTextFile.lineEnding),
);
trackedFileStates.set(resolvedPath.statePath, {
mtimeMs: writtenStats.mtimeMs,
stateKind: "write",
});
return { path: resolvedPath.displayPath, action: "replaced_text", occurrences };
}
case "replace_lines": {
if (params.start_line === undefined || params.end_line_inclusive === undefined) {
return "Error: replace_lines requires start_line and end_line_inclusive.";
}
if (params.content === undefined) {
return "Error: replace_lines requires content.";
}
const currentStats = await statIfExists(resolvedPath.absolutePath);
const guardMessage = ensureFreshForWrite(trackedFileStates, resolvedPath, currentStats);
if (guardMessage !== null) {
return guardMessage;
}
status(`Replacing lines in ${resolvedPath.displayPath}`);
const loadedTextFile = await readTextFile(resolvedPath);
const nextText = buildReplaceLinesText(
loadedTextFile,
params.start_line,
params.end_line_inclusive,
params.content,
);
const writtenStats = await writeTextFileWithParents(
resolvedPath,
applyLineEnding(nextText, loadedTextFile.lineEnding),
);
trackedFileStates.set(resolvedPath.statePath, {
mtimeMs: writtenStats.mtimeMs,
stateKind: "write",
});
return {
path: resolvedPath.displayPath,
action: "replaced_lines",
start_line: params.start_line,
end_line_inclusive: params.end_line_inclusive,
};
}
case "create_directory": {
status(`Creating ${resolvedPath.displayPath}`);
await mkdir(resolvedPath.absolutePath, { recursive: true });
return { path: resolvedPath.displayPath, action: "created_directory" };
}
case "move_path": {
if (params.new_path === undefined) {
return "Error: move_path requires new_path.";
}
const newResolvedPath = resolveWorkingPath(workingContext, params.new_path);
const newPathStats = await statIfExists(newResolvedPath.absolutePath);
if (newPathStats !== null && params.overwrite_existing !== true) {
return "Error: destination exists. Set overwrite_existing=true only if replacing is intended.";
}
status(`Moving ${resolvedPath.displayPath}`);
await mkdir(dirname(newResolvedPath.absolutePath), { recursive: true });
await rename(resolvedPath.absolutePath, newResolvedPath.absolutePath);
trackedFileStates.delete(resolvedPath.statePath);
return {
from: resolvedPath.displayPath,
to: newResolvedPath.displayPath,
action: "moved",
};
}
case "delete_file": {
if (params.allow_delete !== true) {
return "Error: delete_file requires allow_delete=true.";
}
const currentStats = await statIfExists(resolvedPath.absolutePath);
if (currentStats === null) {
return "Error: file does not exist.";
}
if (!currentStats.isFile()) {
return "Error: delete_file only deletes files, not directories.";
}
status(`Deleting ${resolvedPath.displayPath}`);
await unlink(resolvedPath.absolutePath);
trackedFileStates.delete(resolvedPath.statePath);
return { path: resolvedPath.displayPath, action: "deleted_file" };
}
case "search_text": {
if (params.search_term === undefined) {
return "Error: search_text requires search_term.";
}
status(`Searching ${resolvedPath.displayPath}`);
return await searchText(
signal,
workingContext,
resolvedPath,
params.search_term,
params.regex ?? false,
);
}
}
},
});
tools.push(fileSystemTool);
const openInBrowserTool = tool({
name: "open_in_browser",
description: text`
Open a file or directory path using macOS open. For HTML files, this launches the default
browser. Relative paths resolve from the current LM Studio working directory. Absolute paths
and ~/ paths are allowed.
`,
parameters: {
path: z.string().describe("Relative, absolute, or ~/ path to pass to macOS open."),
},
implementation: async ({ path }, { signal, status }) => {
if (process.platform !== "darwin") {
return "Error: open_in_browser is only supported on macOS.";
}
signal.throwIfAborted();
const workingContext = getWorkingContext(ctl);
const resolvedPath = resolveHtmlFilePath(workingContext, path);
const pathStats = await statIfExists(resolvedPath.absolutePath);
if (pathStats === null) {
return "Error: path does not exist.";
}
status(`Opening ${resolvedPath.displayPath}`);
await runOpen(resolvedPath.absolutePath, signal);
return { path: resolvedPath.displayPath, action: "opened" };
},
});
tools.push(openInBrowserTool);
const generateChartTool = tool({
name: "generate_chart",
description: text`
Generate a simple chart as a PNG image in the current LM Studio working directory and return
a markdown image link. This tool builds a constrained Vega-Lite chart internally; do not pass
raw Vega or JavaScript expressions.
Defaults: chart_type=bar, width=640, height=360, output_path=charts/chart-<timestamp>.png.
For pie charts, x is the slice/category field and y is the value field; if y is omitted, the
tool counts rows per category. For scatter charts, y is required.
`,
parameters: {
data: z
.array(z.record(chartDataValueSchema))
.min(1)
.max(maxChartDataRows)
.describe("Rows of chart data. Values must be strings, numbers, booleans, or null."),
x: z.string().min(1).describe("Field name for the x/category dimension."),
y: z
.string()
.min(1)
.optional()
.describe("Optional numeric field for the y/value dimension. If omitted, rows are counted."),
chart_type: chartTypeSchema.optional().describe("Chart type. Default: bar."),
title: z.string().optional().describe("Optional chart title."),
x_title: z.string().optional().describe("Optional x-axis or category title."),
y_title: z.string().optional().describe("Optional y-axis or value title."),
x_type: chartFieldTypeSchema.optional().describe("Optional Vega-Lite field type for x."),
y_type: chartFieldTypeSchema.optional().describe("Optional Vega-Lite field type for y."),
color: z.string().min(1).optional().describe("Optional field name to use for color grouping."),
output_path: z
.string()
.optional()
.describe("Relative output path inside the working directory. .png is appended if omitted."),
width: z.number().int().min(200).max(2000).optional().describe("Chart width in pixels."),
height: z.number().int().min(160).max(1600).optional().describe("Chart height in pixels."),
},
implementation: async (
{
data,
x,
y,
chart_type,
title,
x_title,
y_title,
x_type,
y_type,
color,
output_path,
width,
height,
},
{ signal, status },
) => {
signal.throwIfAborted();
const resolvedChartType: ChartType = chart_type ?? "bar";
if (resolvedChartType === "scatter" && y === undefined) {
return "Error: scatter charts require y.";
}
if (!hasField(data, x)) {
return `Error: x field "${x}" does not exist in the data.`;
}
if (y !== undefined && !hasField(data, y)) {
return `Error: y field "${y}" does not exist in the data.`;
}
if (color !== undefined && !hasField(data, color)) {
return `Error: color field "${color}" does not exist in the data.`;
}
const workingContext = getWorkingContext(ctl);
if (process.platform !== "darwin") {
return "Error: PNG chart rendering currently requires macOS Quick Look.";
}
const normalizedOutputPath = normalizeChartOutputPath(output_path);
const resolvedOutputPath = resolveWorkingPath(workingContext, normalizedOutputPath);
if (extname(resolvedOutputPath.absolutePath).toLowerCase() !== ".png") {
return "Error: output_path must end in .png.";
}
const xEncoding: ChartEncodingField = {
field: x,
title: x_title,
type: x_type ?? inferChartFieldType(data, x),
};
const yEncoding = buildYEncoding({
data,
field: y,
title: y_title,
type: y_type,
});
const colorEncoding =
color === undefined
? undefined
: {
field: color,
type: inferChartFieldType(data, color),
};
const markByChartType = {
area: "area",
bar: "bar",
line: "line",
pie: "arc",
scatter: "point",
} satisfies Record<ChartType, string>;
const encoding =
resolvedChartType === "pie"
? {
color: colorEncoding ?? { field: x, title: x_title, type: xEncoding.type },
theta: yEncoding,
}
: {
color: colorEncoding,
x: xEncoding,
y: yEncoding,
};
const spec = {
$schema: "https://vega.github.io/schema/vega-lite/v6.json",
autosize: { contains: "padding", type: "fit" },
background: "white",
config: {
axis: { labelFontSize: 12, titleFontSize: 13 },
legend: { labelFontSize: 12, titleFontSize: 13 },
title: { fontSize: 16 },
view: { stroke: null },
},
data: { values: data },
encoding,
height: height ?? defaultChartHeight,
mark: { type: markByChartType[resolvedChartType], tooltip: true },
title,
width: width ?? defaultChartWidth,
};
status("Rendering chart");
const { vega, vegaLite } = await loadVegaModules();
signal.throwIfAborted();
const compiledSpec = vegaLite.compile(spec).spec;
const view = new vega.View(vega.parse(compiledSpec), { renderer: "none" });
const svg = await view.toSVG();
view.finalize();
status(`Writing ${resolvedOutputPath.displayPath}`);
const outputDirectory = dirname(resolvedOutputPath.absolutePath);
const tempSvgPath = join(
outputDirectory,
`.${basename(resolvedOutputPath.absolutePath, ".png")}-${Date.now()}.svg`,
);
await mkdir(outputDirectory, { recursive: true });
await writeFile(tempSvgPath, svg, "utf-8");
let generatedPngPath: string | null = null;
try {
generatedPngPath = await runQuickLookThumbnail(tempSvgPath, outputDirectory, signal);
await rename(generatedPngPath, resolvedOutputPath.absolutePath);
} finally {
await unlink(tempSvgPath).catch(() => undefined);
if (generatedPngPath !== null && generatedPngPath !== resolvedOutputPath.absolutePath) {
await unlink(generatedPngPath).catch(() => undefined);
}
}
const markdown = buildMarkdownImageLink(title ?? "chart", resolvedOutputPath.absolutePath);
return {
markdown,
path: resolvedOutputPath.displayPath,
absolute_path: resolvedOutputPath.absolutePath,
mime_type: "image/png",
$hint:
"Present the chart to the user by including the markdown image link exactly as returned. Do not rewrite it as a relative path and do not wrap it in a code block.",
chart_type: resolvedChartType,
rows: data.length,
};
},
});
tools.push(generateChartTool);
return tools;
}