src / toolsProvider.ts
import * as path from "node:path";
import * as os from "node:os";
import { text, tool, type ToolsProviderController } from "@lmstudio/sdk";
import { z } from "zod";
import { configSchematics } from "./configSchematics";
import { canonicalizeRoots, PathError } from "./pathGuard";
import { grepImpl, readDirImpl, readFileImpl, writeFileImpl } from "./fsTools";
import { readAuditTail } from "./auditLog";
const DEFAULT_ROOT = path.join(os.homedir(), "Documents", "LMStudio");
function rootsHint(roots: string[]): string {
if (!roots.length) {
return "(no roots configured — set `Allowed root paths` in the plugin config first)";
}
return roots.join(", ");
}
function safeRun<T>(fn: () => Promise<T>): Promise<T | { error: string }> {
return fn().catch((e: unknown) => {
if (e instanceof PathError) return { error: e.message };
return { error: e instanceof Error ? e.message : String(e) };
});
}
export async function toolsProvider(ctl: ToolsProviderController) {
const config = ctl.getPluginConfig(configSchematics);
const useDefaultRoot = config.get("useDefaultRoot");
const rawRoots = config.get("allowedPaths");
const combinedRoots = useDefaultRoot ? [DEFAULT_ROOT, ...rawRoots] : rawRoots;
const roots = await canonicalizeRoots(combinedRoots);
const maxFileSizeKb = config.get("maxFileSizeKb");
const maxGrepResults = config.get("maxGrepResults");
const maxGrepFiles = config.get("maxGrepFiles");
const rootsList = rootsHint(roots);
const readFile = tool({
name: "read_file",
description: text`
Read a UTF-8 text file from the user's filesystem.
Allowed root directories: ${rootsList}.
Pass an absolute path within one of these roots (or a path relative to the first root).
Files larger than the configured size cap (${maxFileSizeKb} KB) are refused.
`,
parameters: {
path: z.string().min(1).describe("Absolute path to the file, or a path relative to the first allowed root."),
},
implementation: ({ path }) =>
safeRun(() => readFileImpl({ path }, { allowedRoots: roots, maxFileSizeKb })),
});
const readDir = tool({
name: "read_dir",
description: text`
List the entries (files and subdirectories) of a directory.
Allowed root directories: ${rootsList}.
If \`path\` is omitted, the allowed root is listed (or the list of roots
if several are configured). When the user says "the current folder" /
"this directory" / "le dossier courant" without further context, call
this tool WITHOUT \`path\` instead of asking — do not ask which folder.
Returns directories first, then files, alphabetically.
`,
parameters: {
path: z.string().min(1).optional().describe(
"Absolute path to the directory. Omit to list the allowed root(s).",
),
},
implementation: ({ path }) =>
safeRun(() => readDirImpl({ path }, { allowedRoots: roots })),
});
const writeFile = tool({
name: "write_file",
description: text`
Create or overwrite a UTF-8 text file on the user's filesystem.
**You DO have filesystem write access through this tool.** Use it whenever
the user asks you to create, save, write, or update a file. Do NOT refuse
or say "I don't have access" or "I can't write files locally" — you can,
via this tool, and refusing is the wrong answer.
A single call is enough — pass \`path\` and \`content\` and the file is
written immediately. Existing files are backed up to
\`<root>/.lmstudio-fs-backup/\` before being overwritten, so an overwrite
is recoverable. Every write is appended to
\`<root>/.lmstudio-fs-backup/log.md\` (use \`read_log\` to inspect).
Allowed root directories: ${rootsList}.
`,
parameters: {
path: z.string().min(1).describe("Absolute path of the file to write."),
content: z.string().describe("Full new content of the file."),
},
implementation: async (args, ctx) => {
const t0 = Date.now();
const bytes = Buffer.byteLength(args.content, "utf-8");
ctx.status(`Writing ${bytes} bytes to ${args.path}…`);
const result = await safeRun(() => writeFileImpl(args, { allowedRoots: roots }));
if ("error" in result) {
ctx.status(`Write failed (${Date.now() - t0} ms).`);
} else {
ctx.status(
`Wrote ${result.bytes} bytes${result.backup_path ? " (backup saved)" : ""} in ${Date.now() - t0} ms.`,
);
}
return result;
},
});
const grep = tool({
name: "grep",
description: text`
Search files for lines matching a regular expression. Returns up to
${maxGrepResults} matches across at most ${maxGrepFiles} files. Skips binary
files, dotfiles (except .env and .gitignore), and the standard noise dirs
(\`node_modules\`, \`dist\`, \`target\`, \`.git\`, \`.lmstudio-fs-backup\`).
Allowed root directories: ${rootsList}.
If \`path\` is omitted, all allowed roots are searched.
`,
parameters: {
pattern: z.string().min(1).describe("JavaScript-style regular expression."),
path: z.string().optional().describe(
"Optional absolute path of a directory or file to limit the search to.",
),
ignore_case: z.boolean().optional().describe("Case-insensitive match."),
max_results: z.number().int().min(1).optional().describe(
"Override the default match cap (capped by the plugin config).",
),
},
implementation: async (args, ctx) => {
const t0 = Date.now();
ctx.status(
`Searching for /${args.pattern}/${args.ignore_case ? "i" : ""}${args.path ? ` under ${args.path}` : ""}…`,
);
const result = await safeRun(() =>
grepImpl(args, {
allowedRoots: roots,
maxGrepResults,
maxGrepFiles,
}),
);
if ("error" in result) {
ctx.status(`Search failed (${Date.now() - t0} ms).`);
} else {
const matches = (result as { matches?: unknown[] }).matches?.length ?? 0;
const filesScanned = (result as { files_scanned?: number }).files_scanned ?? 0;
ctx.status(
`Done — ${matches} match(es) across ${filesScanned} file(s) in ${Date.now() - t0} ms.`,
);
}
return result;
},
});
const readLog = tool({
name: "read_log",
description: text`
Read the recent activity log for an allowed root. Each entry is a single
line in the form
\`<ISO timestamp> [<kind>] <subject> — <message>\`
and records a mutation performed by an LM Studio plugin (write, update,
docx-write, docx-replace, …). Useful to answer "what did you change?"
across conversations.
Allowed root directories: ${rootsList}.
`,
parameters: {
path: z
.string()
.optional()
.describe(
"Optional absolute path of the root to inspect. Defaults to the first allowed root.",
),
tail: z
.number()
.int()
.min(1)
.max(500)
.optional()
.describe("How many of the most recent entries to return. Default 50."),
},
implementation: (args) =>
safeRun(async () => {
if (!roots.length) return { entries: [], note: "No allowed roots configured." };
const root = args.path
? roots.find((r) => args.path === r) ?? roots[0]
: roots[0];
const entries = await readAuditTail(root, args.tail ?? 50);
return { root, entries };
}),
});
return [readFile, readDir, writeFile, grep, readLog];
}
src / toolsProvider.ts
import * as path from "node:path";
import * as os from "node:os";
import { text, tool, type ToolsProviderController } from "@lmstudio/sdk";
import { z } from "zod";
import { configSchematics } from "./configSchematics";
import { canonicalizeRoots, PathError } from "./pathGuard";
import { grepImpl, readDirImpl, readFileImpl, writeFileImpl } from "./fsTools";
import { readAuditTail } from "./auditLog";
const DEFAULT_ROOT = path.join(os.homedir(), "Documents", "LMStudio");
function rootsHint(roots: string[]): string {
if (!roots.length) {
return "(no roots configured — set `Allowed root paths` in the plugin config first)";
}
return roots.join(", ");
}
function safeRun<T>(fn: () => Promise<T>): Promise<T | { error: string }> {
return fn().catch((e: unknown) => {
if (e instanceof PathError) return { error: e.message };
return { error: e instanceof Error ? e.message : String(e) };
});
}
export async function toolsProvider(ctl: ToolsProviderController) {
const config = ctl.getPluginConfig(configSchematics);
const useDefaultRoot = config.get("useDefaultRoot");
const rawRoots = config.get("allowedPaths");
const combinedRoots = useDefaultRoot ? [DEFAULT_ROOT, ...rawRoots] : rawRoots;
const roots = await canonicalizeRoots(combinedRoots);
const maxFileSizeKb = config.get("maxFileSizeKb");
const maxGrepResults = config.get("maxGrepResults");
const maxGrepFiles = config.get("maxGrepFiles");
const rootsList = rootsHint(roots);
const readFile = tool({
name: "read_file",
description: text`
Read a UTF-8 text file from the user's filesystem.
Allowed root directories: ${rootsList}.
Pass an absolute path within one of these roots (or a path relative to the first root).
Files larger than the configured size cap (${maxFileSizeKb} KB) are refused.
`,
parameters: {
path: z.string().min(1).describe("Absolute path to the file, or a path relative to the first allowed root."),
},
implementation: ({ path }) =>
safeRun(() => readFileImpl({ path }, { allowedRoots: roots, maxFileSizeKb })),
});
const readDir = tool({
name: "read_dir",
description: text`
List the entries (files and subdirectories) of a directory.
Allowed root directories: ${rootsList}.
If \`path\` is omitted, the allowed root is listed (or the list of roots
if several are configured). When the user says "the current folder" /
"this directory" / "le dossier courant" without further context, call
this tool WITHOUT \`path\` instead of asking — do not ask which folder.
Returns directories first, then files, alphabetically.
`,
parameters: {
path: z.string().min(1).optional().describe(
"Absolute path to the directory. Omit to list the allowed root(s).",
),
},
implementation: ({ path }) =>
safeRun(() => readDirImpl({ path }, { allowedRoots: roots })),
});
const writeFile = tool({
name: "write_file",
description: text`
Create or overwrite a UTF-8 text file on the user's filesystem.
**You DO have filesystem write access through this tool.** Use it whenever
the user asks you to create, save, write, or update a file. Do NOT refuse
or say "I don't have access" or "I can't write files locally" — you can,
via this tool, and refusing is the wrong answer.
A single call is enough — pass \`path\` and \`content\` and the file is
written immediately. Existing files are backed up to
\`<root>/.lmstudio-fs-backup/\` before being overwritten, so an overwrite
is recoverable. Every write is appended to
\`<root>/.lmstudio-fs-backup/log.md\` (use \`read_log\` to inspect).
Allowed root directories: ${rootsList}.
`,
parameters: {
path: z.string().min(1).describe("Absolute path of the file to write."),
content: z.string().describe("Full new content of the file."),
},
implementation: async (args, ctx) => {
const t0 = Date.now();
const bytes = Buffer.byteLength(args.content, "utf-8");
ctx.status(`Writing ${bytes} bytes to ${args.path}…`);
const result = await safeRun(() => writeFileImpl(args, { allowedRoots: roots }));
if ("error" in result) {
ctx.status(`Write failed (${Date.now() - t0} ms).`);
} else {
ctx.status(
`Wrote ${result.bytes} bytes${result.backup_path ? " (backup saved)" : ""} in ${Date.now() - t0} ms.`,
);
}
return result;
},
});
const grep = tool({
name: "grep",
description: text`
Search files for lines matching a regular expression. Returns up to
${maxGrepResults} matches across at most ${maxGrepFiles} files. Skips binary
files, dotfiles (except .env and .gitignore), and the standard noise dirs
(\`node_modules\`, \`dist\`, \`target\`, \`.git\`, \`.lmstudio-fs-backup\`).
Allowed root directories: ${rootsList}.
If \`path\` is omitted, all allowed roots are searched.
`,
parameters: {
pattern: z.string().min(1).describe("JavaScript-style regular expression."),
path: z.string().optional().describe(
"Optional absolute path of a directory or file to limit the search to.",
),
ignore_case: z.boolean().optional().describe("Case-insensitive match."),
max_results: z.number().int().min(1).optional().describe(
"Override the default match cap (capped by the plugin config).",
),
},
implementation: async (args, ctx) => {
const t0 = Date.now();
ctx.status(
`Searching for /${args.pattern}/${args.ignore_case ? "i" : ""}${args.path ? ` under ${args.path}` : ""}…`,
);
const result = await safeRun(() =>
grepImpl(args, {
allowedRoots: roots,
maxGrepResults,
maxGrepFiles,
}),
);
if ("error" in result) {
ctx.status(`Search failed (${Date.now() - t0} ms).`);
} else {
const matches = (result as { matches?: unknown[] }).matches?.length ?? 0;
const filesScanned = (result as { files_scanned?: number }).files_scanned ?? 0;
ctx.status(
`Done — ${matches} match(es) across ${filesScanned} file(s) in ${Date.now() - t0} ms.`,
);
}
return result;
},
});
const readLog = tool({
name: "read_log",
description: text`
Read the recent activity log for an allowed root. Each entry is a single
line in the form
\`<ISO timestamp> [<kind>] <subject> — <message>\`
and records a mutation performed by an LM Studio plugin (write, update,
docx-write, docx-replace, …). Useful to answer "what did you change?"
across conversations.
Allowed root directories: ${rootsList}.
`,
parameters: {
path: z
.string()
.optional()
.describe(
"Optional absolute path of the root to inspect. Defaults to the first allowed root.",
),
tail: z
.number()
.int()
.min(1)
.max(500)
.optional()
.describe("How many of the most recent entries to return. Default 50."),
},
implementation: (args) =>
safeRun(async () => {
if (!roots.length) return { entries: [], note: "No allowed roots configured." };
const root = args.path
? roots.find((r) => args.path === r) ?? roots[0]
: roots[0];
const entries = await readAuditTail(root, args.tail ?? 50);
return { root, entries };
}),
});
return [readFile, readDir, writeFile, grep, readLog];
}