src / plugin.ts
src / plugin.ts
// simpleFiles as an LM Studio plugin: tools for the model to find, read and propose changes, and
// /files commands for the user to grant folders and to apply, reject or undo changes. The model
// cannot grant itself access or apply its own proposals; only a message the user typed can.
import { createConfigSchematics, tool, type BaseController, type ChatMessage, type PromptPreprocessorController, type Tool, type ToolsProviderController } from "@lmstudio/sdk";
import * as fs from "node:fs";
import * as path from "node:path";
import { z } from "zod";
import { isInsideLmstudio, lmstudioHome } from "./dev";
import { apply, appliedList, pendingList, preview, proposeCopy, proposeEdit, proposeForm, proposeInstall, proposeNewFile, proposeRun, reject, undo } from "./edits";
import { readFormReport } from "./forms";
import { attachmentsFolder, takeAttachments } from "./attachments";
import { anyProject, runScript, trust, trustedFolders, untrust } from "./projects";
import { handoffCommand, resumeCommand, writeHandoff } from "./handoff";
import { autoApplyCommand, autoApplyStatus, maybeAutoApply } from "./autoapply";
import { defaultRoots, NameIndex } from "./names";
import { markLine, recordShown } from "./localonly";
import { listFolder, readFile, searchInFiles } from "./reading";
import { folderInText, Grant, isInside, isSecretFile, realPath, samePath, Scope, tooBroad } from "./scope";
import { dataDir, readJson, writeJson } from "./store";
export const VERSION = "0.5.5";
// What each version gave the model, told once in the next message after an update, since a
// model in the middle of a long chat goes on with the tools it remembers.
const TOOL_NEWS: [string, string][] = [
["0.4.1", "copy_files copies files or folders byte for byte. Use it to copy, back up or archive files instead of reading them and proposing them as new files."],
["0.4.2", "propose_new_file turns away a file that copies one you read, and points to copy_files. write_handoff no longer needs a folder: it uses the folder of the latest changes, and can be a repository holding several projects."],
["0.4.3", "When the user turns on /files autoapply for a folder, your edits, new files, copies and builds there take effect at once, and the tool result says so; carry on without asking them to apply."],
["0.5.0", "read_form shows a fillable PDF form as text with its fields numbered in place, and fill_form writes a filled copy, so you can translate a form's questions for the user and fill in their answers in the form's language."],
];
export const globalConfigSchematics = createConfigSchematics()
.field(
"folders",
"string",
{
displayName: "Folders it may read and edit",
subtitle: "One folder per line, full paths. You can also grant folders from a chat with /files grant <folder>. A whole drive or your whole home folder is not accepted.",
isParagraph: true,
},
""
)
.field(
"searchRoots",
"string",
{
displayName: "Find file names in",
subtitle: "One folder per line. Empty means your home folder. Only names are indexed, never contents; hidden folders, app data and system folders are always skipped.",
isParagraph: true,
},
""
)
.field(
"excludes",
"string",
{
displayName: "Never look in",
subtitle: "One folder per line. Their files are not indexed, listed or read, even inside a granted folder.",
isParagraph: true,
},
""
)
.field(
"attachmentsFolder",
"string",
{
displayName: "Attachments folder",
subtitle: "Documents you attach to a chat are copied here so the model can work on them (LM Studio's own copy is out of its reach). Empty means Documents\\simpleFiles attachments; off turns it off.",
},
""
)
.field(
"devTools",
"boolean",
{
displayName: "Project commands",
subtitle: "In granted folders that hold a project (package.json, a Python project, a Makefile, an LM Studio plugin), the model can run the project's own commands: checks run in folders you trust with /files trust, and anything else waits for /files apply. Only offered when a granted folder holds a project.",
},
true
)
.build();
interface Settings {
folders: string[];
searchRoots: string[];
excludes: string[];
devTools?: boolean;
attachments?: string | null;
}
const lines = (v: unknown) =>
String(v ?? "")
.split(/\r?\n/)
.map((s) => s.trim().replace(/^["']|["']$/g, ""))
.filter(Boolean);
export function readSettings(ctl: Pick<BaseController, "getGlobalPluginConfig">): Settings {
try {
const c = ctl.getGlobalPluginConfig(globalConfigSchematics);
let devTools = true;
try {
devTools = c.get("devTools") !== false;
} catch {
// default on
}
let attachments: string | null = attachmentsFolder("");
try {
attachments = attachmentsFolder(String(c.get("attachmentsFolder") ?? ""));
} catch {
// older settings without the field: the default
}
return { folders: lines(c.get("folders")), searchRoots: lines(c.get("searchRoots")), excludes: lines(c.get("excludes")), devTools, attachments };
} catch {
return { folders: [], searchRoots: [], excludes: [] };
}
}
const log = (msg: string) => console.info(`[simpleFiles] ${msg}`);
const grantsFile = () => path.join(dataDir(), "grants.json");
const chatGrants = () => readJson<string[]>(grantsFile(), []);
// The folders in force: those in the settings and those granted from a chat, minus anything too
// broad or excluded. Problems are reported by /files status.
export function scopeFor(s: Settings): { scope: Scope; problems: string[] } {
const problems: string[] = [];
const excludes = s.excludes.map(realPath);
const grants: Grant[] = [];
const add = (folder: string, from: Grant["from"]) => {
if (tooBroad(folder)) return problems.push(`${folder} is a whole drive or your home folder, which is too broad to grant; grant the project folders inside it instead.`);
if (isInsideLmstudio(folder)) return problems.push(LMSTUDIO_REFUSED(folder));
if (!fs.existsSync(folder)) return problems.push(`${folder} does not exist.`);
if (excludes.some((e) => isInside(realPath(folder), e))) return problems.push(`${folder} is inside a folder in "Never look in".`);
grants.push({ folder, from });
};
for (const f of s.folders) add(f, "settings");
for (const f of chatGrants()) add(f, "chat");
// The attachments folder is granted once it exists, which is when something was attached.
if (s.attachments && fs.existsSync(s.attachments) && !grants.some((g) => samePath(realPath(g.folder), realPath(s.attachments!)))) add(s.attachments, "settings");
return { scope: new Scope(grants, excludes), problems };
}
// LM Studio's own folder holds every chat, the memory file and saved credentials. Nothing in it is
// granted; installing plugins and reading their logs have their own tools.
const LMSTUDIO_REFUSED = (folder: string) =>
`${folder} is inside LM Studio's own folder (${lmstudioHome()}), which holds your chats, memory and credentials, so it is not granted. Plugin installs and plugin logs have their own tools.`;
let index: { key: string; names: NameIndex } | null = null;
export function nameIndex(s: Settings): NameIndex {
const roots = (s.searchRoots.length ? s.searchRoots : defaultRoots()).map(realPath);
const excludes = s.excludes.map(realPath);
const key = JSON.stringify([roots, excludes]);
if (!index || index.key !== key) index = { key, names: new NameIndex(roots, excludes, log) };
index.names.refreshIfNeeded();
return index.names;
}
async function findFiles(s: Settings, query: string): Promise<string> {
const { scope } = scopeFor(s);
const names = nameIndex(s);
const found = names.find(query, 40);
if (found === null) {
return [
"status: index_building",
"reason: The file name index is still being built for the first time.",
scope.grants.length ? `next: Meanwhile, use list_folder or search_in_files, but only inside the granted folders: ${scope.grants.map((g) => g.folder).join(", ")}. Do not guess other paths. Or try find_files again in a minute.` : "next: Do not guess folder paths or list folders while the index builds. Try find_files again in a minute, or ask the user where the file is.",
].join("\n");
}
const shown = found.filter((p) => !isSecretFile(p)).slice(0, 25);
const meta = names.meta();
const out = [shown.length ? "status: ok" : "status: no_results", `index: ${meta?.count ?? 0} names${names.isBuilding() ? ", being refreshed" : ""}`];
if (shown.length) {
out.push("results (names only, nothing has been read):");
for (const p of shown) out.push(` ${p}${scope.grantFor(realPath(p)) ? "" : " (not granted)"}`);
out.push(
shown.some((p) => !scope.grantFor(realPath(p)))
? "next: Files marked (not granted) cannot be read yet. Tell the user which folder you need and ask them to send /files grant <folder>; do not guess its contents."
: "next: Read the right one with read_file."
);
} else out.push("next: Nothing matched. Try fewer or different words, part of the name, or an extension such as .md.");
return out.join("\n");
}
// Everything simpleFiles shows the model is from this computer, even a list of names. It is
// marked as such, and its fingerprints recorded, so a plugin that sends things out (simpleSearch)
// can keep it from leaving without the user's OK.
const SOURCE = "simplefiles";
function local(out: string): string {
recordShown(SOURCE, out);
return `${out}\n${markLine(SOURCE)}`;
}
export async function toolsProvider(ctl: ToolsProviderController): Promise<Tool[]> {
const s = readSettings(ctl);
const scope = () => scopeFor(s).scope;
nameIndex(s);
return [
tool({
name: "find_files",
description:
"Find files or folders by name anywhere in the user's files. Returns paths only; ones outside the granted folders are marked (not granted).",
parameters: { query: z.string().describe("Words from the name, or an extension, such as \"budget .xlsx\".") },
implementation: async ({ query }) => local(await findFiles(s, query)),
}),
tool({
name: "list_folder",
description: "List the files and subfolders of a folder inside the granted folders.",
parameters: { path: z.string().describe("Full path of the folder.") },
implementation: async ({ path: p }) => local(await listFolder(scope(), p)),
}),
tool({
name: "read_file",
description:
"Read a text file, up to 300 numbered lines at a time. Prefer narrow reads. The line numbers are not part of the file.",
parameters: {
path: z.string().describe("Full path of the file."),
start_line: z.number().int().min(1).optional().describe("Line to start from."),
line_count: z.number().int().min(1).max(300).optional().describe("How many lines, up to 300."),
from_end: z.boolean().optional().describe("Read the end of the file instead, such as a log."),
matching: z.string().optional().describe("Only lines containing this text."),
},
implementation: async ({ path: p, start_line, line_count, from_end, matching }) =>
local(await readFile(scope(), p, start_line ?? 1, line_count ?? 300, { fromEnd: from_end, matching: matching?.trim() || undefined })),
}),
tool({
name: "search_in_files",
description: "Search the text of files in a folder and its subfolders. Returns matching lines with file and line number.",
parameters: {
path: z.string().describe("Full path of the folder or file to search."),
text: z.string().describe("Text to look for (not case-sensitive)."),
},
implementation: async ({ path: p, text }) => local(await searchInFiles(scope(), p, text)),
}),
tool({
name: "propose_edit",
description:
"Propose replacing one exact passage of a text file. The passage must appear exactly once, copied from read_file without the line numbers.",
parameters: {
path: z.string().describe("Full path of the file."),
find: z.string().describe("The exact text to replace, as it is in the file now. Include enough lines that it appears only once."),
replace: z.string().describe("The text to put in its place."),
reason: z.string().describe("One sentence on what this change does, for the user and the change log."),
},
implementation: async ({ path: p, find, replace, reason }) => local(await maybeAutoApply(scope(), proposeEdit(scope(), p, find, replace, reason))),
}),
tool({
name: "propose_new_file",
description: "Propose a new text file. Cannot replace an existing one. To copy or archive existing files, use copy_files.",
parameters: {
path: z.string().describe("Full path of the new file."),
content: z.string().describe("The whole content of the new file."),
reason: z.string().describe("One sentence on what this file is for."),
},
implementation: async ({ path: p, content, reason }) => local(await maybeAutoApply(scope(), proposeNewFile(scope(), p, content, reason))),
}),
tool({
name: "copy_files",
description:
"Propose copying files or folders, byte for byte, without their contents passing through you. The way to copy, back up or archive.",
parameters: {
sources: z.array(z.string()).describe("Full paths of files or folders. Folders are copied whole, minus dot-folders, node_modules and secret files."),
dest: z.string().describe("Destination folder. Sources keep their names; nothing is overwritten."),
reason: z.string().describe("One sentence on what this copy is for."),
},
implementation: async ({ sources, dest, reason }) => local(await maybeAutoApply(scope(), proposeCopy(scope(), sources as string[], dest, reason))),
}),
tool({
name: "read_form",
description:
"Read a fillable PDF form as its printed text with each field marked in place and numbered ([#4 text], [#9 ☐], [#12 ○]). Use it to understand a form, translate its questions for the user, and fill it with fill_form.",
parameters: {
path: z.string().describe("Full path of the PDF."),
pages: z.string().optional().describe("Pages to show, such as 1 or 2-3. Leave empty for all."),
},
implementation: async ({ path: p, pages }) => local(await readFormReport(scope(), p, pages)),
}),
tool({
name: "fill_form",
description:
"Propose a filled copy of a PDF form: field numbers from read_form with the answers, written in the form's language. The original is never changed; a copy filled earlier is filled further in place. Text is sized to fit; an answer that cannot fit is refused. Signatures are left to the user.",
parameters: {
path: z.string().describe("Full path of the form (or of its filled copy, to add answers)."),
answers: z
.array(
z.object({
field: z.number().int().describe("The field number from read_form."),
value: z.union([z.string(), z.boolean()]).describe("Text in the form's language, or true/false to tick a box or choice."),
english: z.string().optional().describe("What the answer says in the user's language, for them to check."),
})
)
.describe("The answers to write."),
save_as: z.string().optional().describe("Full path for the filled copy. Leave empty to save it beside the form with -filled in its name."),
reason: z.string().describe("One sentence on what is being filled."),
},
implementation: async ({ path: p, answers, save_as, reason }) => local(await maybeAutoApply(scope(), await proposeForm(scope(), p, answers as never, save_as, reason))),
}),
tool({
name: "list_proposals",
description: "List changes waiting for approval and those applied recently. Check this rather than remembering.",
parameters: {},
implementation: async () => local(proposalsReport()),
}),
...(s.devTools === false || !anyProject(scope()) ? [] : [projectTool(scope), handoffTool(scope)]),
];
}
function proposalsReport(): string {
const pending = pendingList();
const applied = appliedList().slice(-8).reverse();
const out = ["status: ok", `waiting for approval: ${pending.length ? "" : "none"}`];
for (const p of pending) out.push(` #${p.id} ${p.kind} ${p.kind === "install" ? p.find ?? p.path : p.kind === "run" ? `"${p.find}" in ${p.path}` : p.kind === "copy" ? `${(p.sources ?? []).length} file(s) -> ${p.dest ?? p.path}` : p.path}: ${p.reason}`);
out.push(`applied recently (newest first): ${applied.length ? "" : "none"}`);
for (const a of applied) out.push(` #${a.id} ${a.kind} ${a.path} at ${a.appliedAt}`);
out.push("next: Only applied changes are in the files. Waiting ones need the user to send /files apply <number>.");
return out.join("\n");
}
// One tool for running a project's own commands: the model names a command the project declares,
// and never writes one.
function projectTool(scope: () => ReturnType<typeof scopeFor>["scope"]): Tool {
return tool({
name: "run_script",
description:
"Run a command the project declares: package.json scripts, pytest, Makefile targets, or for an LM Studio plugin, lmstudio-install and lmstudio-log. Leave script empty to list them. Checks run in trusted folders; anything else is proposed.",
parameters: {
folder: z.string().describe("The project's folder, inside a granted folder."),
script: z.string().optional().describe("The command's name from the list, such as test, build, pytest or lmstudio-install. Leave empty to list."),
reason: z.string().optional().describe("For a command that changes something: one sentence on why, for the user."),
},
implementation: async ({ folder, script, reason }) =>
local(await maybeAutoApply(scope(), await runScript(scope(), folder, script, reason, { proposeRun, proposeInstall: (root, why) => proposeInstall(scope(), root, why) }))),
});
}
function handoffTool(scope: () => ReturnType<typeof scopeFor>["scope"]): Tool {
return tool({
name: "write_handoff",
description:
"Record a handoff so a fresh chat can continue this work: the goal, what was ruled out, and the next step. The verified state is added automatically; the new chat loads it with /files resume.",
parameters: {
folder: z.string().optional().describe("The project or repository folder. Leave out to use the folder of the latest changes."),
goal: z.string().describe("What this work is trying to do, in a sentence or two."),
ruled_out: z.string().optional().describe("What was tried and did not work."),
next_step: z.string().optional().describe("The single concrete next step."),
},
implementation: async ({ folder, goal, ruled_out, next_step }) => local(writeHandoff(scope(), folder ?? "", goal, ruled_out ?? "", next_step ?? "")),
});
}
export const HELP = [
"simpleFiles commands:",
"/files status: granted folders, waiting proposals, recent changes and the name index",
"/files pending: show the changes waiting for approval",
"/files apply <number> | all: make a waiting change (or all of them)",
"/files reject <number> | all: drop a waiting change",
"/files keep <number>: leave a waiting change as it is",
"Several commands can go in one message, for example /files apply 4 5 /files reject 6.",
"/files undo: reverse the last applied change",
"/files grant <folder>: let the model read that folder and propose changes in it",
"/files revoke <folder>: take a chat grant back",
"/files reindex: rebuild the file name index now",
"/files trust <folder>: let the model run checks (tests, lint, type checks) in projects there without asking",
"/files untrust <folder>: make checks there ask first again",
"/files autoapply <folder> [time]: for a while (2h unless you add a time, such as 4h), edits, new files, copies and builds there take effect without /files apply; /files autoapply off ends it",
"/files handoff [folder]: show the handoff for a fresh chat to continue this work (the model writes it with write_handoff); without a folder, the folder of the latest changes",
"/files resume [folder]: in a new chat, load the handoff and the current state; without a folder, the newest handoff",
"Commands the model proposes to run, and plugin installs, are made with /files apply too; /files undo puts a plugin's previous version back.",
].join("\n");
// Reads which proposals a command means: numbers in any common form ("4", "#4", "4 and 5",
// "4, 5"), or "all", with other words around them allowed ("all please"). Exceptions are
// refused, since "all except 4" would otherwise read as "4".
const EXCEPTION = /\b(except|but|not|without|other than|apart from|besides)\b/i;
function ids(arg: string): number[] | "all" | { error: string } | null {
const text = arg.trim();
if (!text) return null;
if (EXCEPTION.test(text)) {
return { error: "Name exactly the numbers to use, for example /files apply 4 5, rather than an exception." };
}
const found = text.match(/\d+/g);
if (found) return [...new Set(found.map(Number))];
if (/\b(all|everything|both)\b/i.test(text)) return "all";
return null;
}
// The list of proposals at the start of an apply, reject or keep command ("4 5", "#4 and #5",
// "all"), and where it ends. Words after it are not part of the command.
const ID_LIST = /^\s*(all|everything|both|#?\d+(?:\s*(?:,|&|\band\b)?\s*#?\d+)*)\b\s*[.,;!]?/i;
const TAKES_IDS = new Set(["apply", "reject", "keep"]);
const TAKES_FOLDER = new Set(["grant", "revoke", "trust", "untrust", "handoff", "resume", "autoapply"]);
// One command's own text and the words after it that belong to the user's message instead.
function splitOne(line: string): { command: string; leftover: string } {
const m = /^\/files(?:\s+(\S+))?([\s\S]*)$/i.exec(line.trim());
if (!m || !m[1]) return { command: "/files", leftover: (m?.[2] ?? "").trim() };
const word = m[1].replace(/[.,;:!?]+$/, "").toLowerCase();
const args = m[2] ?? "";
if (TAKES_FOLDER.has(word)) return { command: `/files ${word} ${args.trim()}`.trim(), leftover: "" };
if (TAKES_IDS.has(word)) {
const list = ID_LIST.exec(args);
if (!list) return { command: `/files ${word}`, leftover: args.trim() };
const after = args.slice(list[0].length);
// "all except 4", "4 but not 5": keep the exception in the command so it is refused.
if (/^\s*(except|but|not|without|other than|apart from|besides)\b/i.test(after)) return { command: `/files ${word} ${args.trim()}`, leftover: "" };
return { command: `/files ${word} ${list[1]}`, leftover: after.trim() };
}
return { command: `/files ${word}`, leftover: args.trim() };
}
function status(s: Settings): string {
const { scope, problems } = scopeFor(s);
const out = [`simpleFiles ${VERSION}`, `data folder: ${dataDir()}`, "granted folders:"];
if (scope.grants.length) for (const g of scope.grants) out.push(` ${g.folder} (${g.from === "settings" ? "settings" : "granted in a chat"})`);
else out.push(" none yet: add folders in the plugin's settings or send /files grant <folder>");
for (const p of problems) out.push(`problem: ${p}`);
out.push(`attachments folder: ${s.attachments ? `${s.attachments}${fs.existsSync(s.attachments) ? "" : " (made when you first attach a document)"}` : "off"}`);
const trusted = trustedFolders();
out.push(`trusted for checks: ${trusted.length ? "" : "none"}`);
for (const t of trusted) out.push(` ${t}`);
const auto = autoApplyStatus();
out.push(`auto-apply: ${auto.length ? "" : "off"}`);
for (const a of auto) out.push(` ${a}`);
const pending = pendingList();
out.push(`waiting for approval: ${pending.length ? pending.map((p) => `#${p.id}`).join(", ") : "none"}`);
const applied = appliedList().slice(-5).reverse();
if (applied.length) {
out.push("recent changes (newest first; /files undo reverses the first):");
for (const a of applied) out.push(` #${a.id} ${a.appliedAt} ${a.kind === "edit" ? "edited" : a.kind === "install" ? "installed from" : a.kind === "copy" ? "copied into" : a.kind === "form" ? "filled" : "created"} ${a.path}`);
}
const names = nameIndex(s);
const meta = names.meta();
out.push(`name index: ${meta ? `${meta.count} names, built ${new Date(meta.builtAt).toLocaleString()}` : "not built yet"}${names.isBuilding() ? ", building now" : ""}`);
return out.join("\n");
}
export async function runCommand(s: Settings, text: string): Promise<string> {
const m = /^\s*\/files(?:\s+(\S+))?(?:\s+([\s\S]*))?$/i.exec(text);
if (!m) return HELP;
const command = (m[1] ?? "help").toLowerCase();
const arg = (m[2] ?? "").trim();
const { scope } = scopeFor(s);
switch (command) {
case "status":
return status(s);
case "pending": {
const pending = pendingList();
return pending.length ? pending.map(preview).join("\n\n") : "Nothing is waiting for approval.";
}
case "apply": {
const pending = pendingList();
const which = arg ? ids(arg) : pending.length === 1 ? [pending[0].id] : null;
if (which && typeof which === "object" && "error" in which) return which.error;
if (!which) return pending.length ? `Several changes are waiting (${pending.map((p) => `#${p.id}`).join(", ")}). Send /files apply <number> or /files apply all.` : "Nothing is waiting to be applied.";
return apply(scope, which);
}
case "reject": {
const which = arg ? ids(arg) : null;
if (which && typeof which === "object" && "error" in which) return which.error;
if (!which) return "Send /files reject <number> or /files reject all.";
return reject(which);
}
case "keep": {
const which = arg ? ids(arg) : null;
if (!which || (typeof which === "object" && "error" in which)) return "Nothing changed; the waiting changes stay waiting.";
const pending = pendingList();
const named = which === "all" ? pending.map((p) => p.id) : which;
const waiting = named.filter((id) => pending.some((p) => p.id === id));
return waiting.length ? `Left waiting: ${waiting.map((id) => `#${id}`).join(", ")}.` : "None of those are waiting.";
}
case "undo":
return undo();
case "grant": {
if (!arg) return "Send /files grant followed by the folder's full path.";
const named = folderInText(arg);
if (!named) return `${arg} is not a folder.`;
const folder = named.folder;
if (tooBroad(folder)) return `${folder} is a whole drive or your home folder, which is too broad. Grant the project folder inside it instead.`;
if (isInsideLmstudio(folder)) return LMSTUDIO_REFUSED(folder);
const grants = chatGrants();
if (!grants.some((g) => samePath(realPath(g), folder))) writeJson(grantsFile(), [...grants, folder]);
return `Granted: ${folder}\nThe model can now read files there and propose changes; every change still needs /files apply.${named.extra ? `\n(The words after the folder, "${named.extra}", are not part of the command.)` : ""}`;
}
case "revoke": {
if (!arg) return "Send /files revoke followed by the folder's full path.";
const folder = realPath(arg.replace(/^["']|["']$/g, ""));
const grants = chatGrants();
const left = grants.filter((g) => !samePath(realPath(g), folder));
if (left.length === grants.length) {
return s.folders.some((f) => samePath(realPath(f), folder))
? `${folder} is granted in the plugin's settings; remove it there.`
: `${folder} was not granted.`;
}
writeJson(grantsFile(), left);
return `Revoked: ${folder}`;
}
case "trust":
if (!arg) return "Send /files trust followed by the project folder's full path.";
return trust(scope, arg.replace(/^["']|["']$/g, ""));
case "untrust":
if (!arg) return "Send /files untrust followed by the folder's full path.";
return untrust(arg);
case "autoapply":
return autoApplyCommand(scope, arg);
case "handoff":
return handoffCommand(scope, arg);
case "resume":
return resumeCommand(scope, arg);
case "reindex":
nameIndex(s).refreshIfNeeded(true);
return "Rebuilding the file name index in the background. /files status shows its progress.";
default:
return HELP;
}
}
// Every /files command in a message, each with only its own words: "/files apply 4 5 /files
// reject 6" is two commands, and in "/files apply 28 and then show me the log" the words after
// 28 are the user's message, not part of the command. A command never runs past its line. The
// rest of the message is passed on to the model.
const COMMAND_AT = /(^|[\s.,;:!?(\["'])\/files\b/gi;
export function findCommands(text: string): { commands: string[]; rest: string } | null {
const starts: number[] = [];
for (const m of text.matchAll(COMMAND_AT)) starts.push((m.index ?? 0) + m[1].length);
if (!starts.length) return null;
const commands: string[] = [];
let rest = text.slice(0, starts[0]);
starts.forEach((start, i) => {
const segment = text.slice(start, i + 1 < starts.length ? starts[i + 1] : text.length);
const nl = segment.indexOf("\n");
const line = nl === -1 ? segment : segment.slice(0, nl);
const { command, leftover } = splitOne(line);
commands.push(command);
if (leftover && !/^[\s.,;:!?]*$/.test(leftover)) rest += (rest && !/\s$/.test(rest) ? " " : "") + leftover;
if (nl !== -1) rest += segment.slice(nl);
});
return { commands, rest: rest.replace(/[ \t]+$/gm, "").trim() };
}
// The first command only, kept for callers that handle one.
export function findCommand(text: string): { command: string; rest: string } | null {
const found = findCommands(text);
return found ? { command: found.commands[0], rest: found.rest } : null;
}
// Apply and reject naming the same change in one message cannot both be meant, so neither runs.
function conflict(commands: string[]): string | null {
const named = (verb: string) =>
commands
.filter((c) => c.toLowerCase().startsWith(`/files ${verb}`))
.map((c) => ids(c.slice(`/files ${verb}`.length)))
.filter((w): w is number[] | "all" => !!w && !(typeof w === "object" && "error" in w));
const applied = named("apply");
const rejected = named("reject");
if (!applied.length || !rejected.length) return null;
const all = (l: (number[] | "all")[]) => l.some((w) => w === "all");
const nums = (l: (number[] | "all")[]) => l.flatMap((w) => (w === "all" ? [] : w));
const both = all(applied) || all(rejected) ? ["all"] : nums(applied).filter((id) => nums(rejected).includes(id)).map((id) => `#${id}`);
if (!both.length) return null;
return `Nothing was applied or rejected: this message both applies and rejects ${both.join(", ")}. Send the commands again, naming each change only once.`;
}
// Answers /files commands typed by the user. The model only relays the result.
const newer = (a: string, b: string) => {
const pa = a.split(".").map(Number);
const pb = b.split(".").map(Number);
for (let i = 0; i < 3; i++) if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) > (pb[i] ?? 0);
return false;
};
// The tool news since the version the model last heard about, once, and only where files can be
// worked on. Before this existed nothing was recorded, so a missing record means 0.4.0.
export function toolNews(settings: Settings): string {
if (!scopeFor(settings).scope.grants.length) return "";
const f = path.join(dataDir(), "seen-version.json");
const seen = readJson<{ version?: string }>(f, {}).version ?? "0.4.0";
if (!newer(VERSION, seen)) return "";
try {
writeJson(f, { version: VERSION });
} catch {
return "";
}
const news = TOOL_NEWS.filter(([v]) => newer(v, seen) && !newer(v, VERSION)).map(([, t]) => t);
return news.length ? `[simpleFiles was updated to ${VERSION}. New for you: ${news.join(" ")}]` : "";
}
export async function preprocess(ctl: PromptPreprocessorController, userMessage: ChatMessage): Promise<string | ChatMessage> {
const settings = readSettings(ctl);
let attached: Awaited<ReturnType<typeof takeAttachments>> = null;
try {
const earlier = async () => new Set((await ctl.pullHistory()).getAllFiles(ctl.client).map((f) => f.identifier));
attached = await takeAttachments(ctl.client, userMessage, settings.attachments ?? null, earlier);
} catch (e) {
log(`attachments: ${(e as Error).message}`);
}
if (attached) recordShown(SOURCE, attached.note);
const news = toolNews(settings);
const extra = [attached?.note, news].filter(Boolean).map((t) => `\n\n${t}`).join("");
const found = findCommands((attached?.message ?? userMessage).getText());
if (!found) {
if (!extra) return userMessage;
const out = attached?.message ?? userMessage.asMutableCopy();
out.appendText(extra);
return out;
}
const clash = conflict(found.commands);
const results: string[] = [];
for (const command of found.commands) {
let result: string;
if (clash && /^\/files\s+(apply|reject)\b/i.test(command)) {
result = clash;
} else {
try {
result = await runCommand(settings, command);
} catch (e) {
result = `The command failed: ${(e as Error).message}`;
}
}
results.push(found.commands.length > 1 ? `> ${command}\n${result}` : result);
}
const result = [...new Set(results)].join("\n\n");
recordShown(SOURCE, result);
// After applying, undoing or rejecting, the model may be in the middle of a task that
// depended on it, so it may carry on. A handoff or resume asks the model to act on it.
// After anything else, showing the result is the reply.
const changes = found.commands.some((c) => /^\/files\s+(apply|reject|undo|trust|autoapply)\b/i.test(c));
const acts = found.commands.some((c) => /^\/files\s+(handoff|resume)\b/i.test(c));
const instruction = found.rest
? "Show the user the following exactly as written, in a code block, then answer the rest of their message, quoted after it."
: acts
? "Show the user the following exactly as written, in a code block, then do what the bracketed note at its end asks."
: changes
? "Show the user the following exactly as written, in a code block. Then, if you were in the middle of a task these changes belong to, continue it; otherwise add nothing else."
: "Show the user the following exactly as written, in a code block, and add nothing else. Do not call any tools: showing it is the whole reply.";
const out = attached?.message ?? userMessage.asMutableCopy();
out.replaceText(
`${markLine(SOURCE)}\n[simpleFiles handled ${found.commands.length > 1 ? `${found.commands.length} commands` : "a command"} from the user. ${instruction}]\n${result}${found.rest ? `\n\n[The user's message also said:]\n${found.rest}` : ""}${extra}`
);
return out;
}
// simpleFiles as an LM Studio plugin: tools for the model to find, read and propose changes, and
// /files commands for the user to grant folders and to apply, reject or undo changes. The model
// cannot grant itself access or apply its own proposals; only a message the user typed can.
import { createConfigSchematics, tool, type BaseController, type ChatMessage, type PromptPreprocessorController, type Tool, type ToolsProviderController } from "@lmstudio/sdk";
import * as fs from "node:fs";
import * as path from "node:path";
import { z } from "zod";
import { isInsideLmstudio, lmstudioHome } from "./dev";
import { apply, appliedList, pendingList, preview, proposeCopy, proposeEdit, proposeForm, proposeInstall, proposeNewFile, proposeRun, reject, undo } from "./edits";
import { readFormReport } from "./forms";
import { attachmentsFolder, takeAttachments } from "./attachments";
import { anyProject, runScript, trust, trustedFolders, untrust } from "./projects";
import { handoffCommand, resumeCommand, writeHandoff } from "./handoff";
import { autoApplyCommand, autoApplyStatus, maybeAutoApply } from "./autoapply";
import { defaultRoots, NameIndex } from "./names";
import { markLine, recordShown } from "./localonly";
import { listFolder, readFile, searchInFiles } from "./reading";
import { folderInText, Grant, isInside, isSecretFile, realPath, samePath, Scope, tooBroad } from "./scope";
import { dataDir, readJson, writeJson } from "./store";
export const VERSION = "0.5.5";
// What each version gave the model, told once in the next message after an update, since a
// model in the middle of a long chat goes on with the tools it remembers.
const TOOL_NEWS: [string, string][] = [
["0.4.1", "copy_files copies files or folders byte for byte. Use it to copy, back up or archive files instead of reading them and proposing them as new files."],
["0.4.2", "propose_new_file turns away a file that copies one you read, and points to copy_files. write_handoff no longer needs a folder: it uses the folder of the latest changes, and can be a repository holding several projects."],
["0.4.3", "When the user turns on /files autoapply for a folder, your edits, new files, copies and builds there take effect at once, and the tool result says so; carry on without asking them to apply."],
["0.5.0", "read_form shows a fillable PDF form as text with its fields numbered in place, and fill_form writes a filled copy, so you can translate a form's questions for the user and fill in their answers in the form's language."],
];
export const globalConfigSchematics = createConfigSchematics()
.field(
"folders",
"string",
{
displayName: "Folders it may read and edit",
subtitle: "One folder per line, full paths. You can also grant folders from a chat with /files grant <folder>. A whole drive or your whole home folder is not accepted.",
isParagraph: true,
},
""
)
.field(
"searchRoots",
"string",
{
displayName: "Find file names in",
subtitle: "One folder per line. Empty means your home folder. Only names are indexed, never contents; hidden folders, app data and system folders are always skipped.",
isParagraph: true,
},
""
)
.field(
"excludes",
"string",
{
displayName: "Never look in",
subtitle: "One folder per line. Their files are not indexed, listed or read, even inside a granted folder.",
isParagraph: true,
},
""
)
.field(
"attachmentsFolder",
"string",
{
displayName: "Attachments folder",
subtitle: "Documents you attach to a chat are copied here so the model can work on them (LM Studio's own copy is out of its reach). Empty means Documents\\simpleFiles attachments; off turns it off.",
},
""
)
.field(
"devTools",
"boolean",
{
displayName: "Project commands",
subtitle: "In granted folders that hold a project (package.json, a Python project, a Makefile, an LM Studio plugin), the model can run the project's own commands: checks run in folders you trust with /files trust, and anything else waits for /files apply. Only offered when a granted folder holds a project.",
},
true
)
.build();
interface Settings {
folders: string[];
searchRoots: string[];
excludes: string[];
devTools?: boolean;
attachments?: string | null;
}
const lines = (v: unknown) =>
String(v ?? "")
.split(/\r?\n/)
.map((s) => s.trim().replace(/^["']|["']$/g, ""))
.filter(Boolean);
export function readSettings(ctl: Pick<BaseController, "getGlobalPluginConfig">): Settings {
try {
const c = ctl.getGlobalPluginConfig(globalConfigSchematics);
let devTools = true;
try {
devTools = c.get("devTools") !== false;
} catch {
// default on
}
let attachments: string | null = attachmentsFolder("");
try {
attachments = attachmentsFolder(String(c.get("attachmentsFolder") ?? ""));
} catch {
// older settings without the field: the default
}
return { folders: lines(c.get("folders")), searchRoots: lines(c.get("searchRoots")), excludes: lines(c.get("excludes")), devTools, attachments };
} catch {
return { folders: [], searchRoots: [], excludes: [] };
}
}
const log = (msg: string) => console.info(`[simpleFiles] ${msg}`);
const grantsFile = () => path.join(dataDir(), "grants.json");
const chatGrants = () => readJson<string[]>(grantsFile(), []);
// The folders in force: those in the settings and those granted from a chat, minus anything too
// broad or excluded. Problems are reported by /files status.
export function scopeFor(s: Settings): { scope: Scope; problems: string[] } {
const problems: string[] = [];
const excludes = s.excludes.map(realPath);
const grants: Grant[] = [];
const add = (folder: string, from: Grant["from"]) => {
if (tooBroad(folder)) return problems.push(`${folder} is a whole drive or your home folder, which is too broad to grant; grant the project folders inside it instead.`);
if (isInsideLmstudio(folder)) return problems.push(LMSTUDIO_REFUSED(folder));
if (!fs.existsSync(folder)) return problems.push(`${folder} does not exist.`);
if (excludes.some((e) => isInside(realPath(folder), e))) return problems.push(`${folder} is inside a folder in "Never look in".`);
grants.push({ folder, from });
};
for (const f of s.folders) add(f, "settings");
for (const f of chatGrants()) add(f, "chat");
// The attachments folder is granted once it exists, which is when something was attached.
if (s.attachments && fs.existsSync(s.attachments) && !grants.some((g) => samePath(realPath(g.folder), realPath(s.attachments!)))) add(s.attachments, "settings");
return { scope: new Scope(grants, excludes), problems };
}
// LM Studio's own folder holds every chat, the memory file and saved credentials. Nothing in it is
// granted; installing plugins and reading their logs have their own tools.
const LMSTUDIO_REFUSED = (folder: string) =>
`${folder} is inside LM Studio's own folder (${lmstudioHome()}), which holds your chats, memory and credentials, so it is not granted. Plugin installs and plugin logs have their own tools.`;
let index: { key: string; names: NameIndex } | null = null;
export function nameIndex(s: Settings): NameIndex {
const roots = (s.searchRoots.length ? s.searchRoots : defaultRoots()).map(realPath);
const excludes = s.excludes.map(realPath);
const key = JSON.stringify([roots, excludes]);
if (!index || index.key !== key) index = { key, names: new NameIndex(roots, excludes, log) };
index.names.refreshIfNeeded();
return index.names;
}
async function findFiles(s: Settings, query: string): Promise<string> {
const { scope } = scopeFor(s);
const names = nameIndex(s);
const found = names.find(query, 40);
if (found === null) {
return [
"status: index_building",
"reason: The file name index is still being built for the first time.",
scope.grants.length ? `next: Meanwhile, use list_folder or search_in_files, but only inside the granted folders: ${scope.grants.map((g) => g.folder).join(", ")}. Do not guess other paths. Or try find_files again in a minute.` : "next: Do not guess folder paths or list folders while the index builds. Try find_files again in a minute, or ask the user where the file is.",
].join("\n");
}
const shown = found.filter((p) => !isSecretFile(p)).slice(0, 25);
const meta = names.meta();
const out = [shown.length ? "status: ok" : "status: no_results", `index: ${meta?.count ?? 0} names${names.isBuilding() ? ", being refreshed" : ""}`];
if (shown.length) {
out.push("results (names only, nothing has been read):");
for (const p of shown) out.push(` ${p}${scope.grantFor(realPath(p)) ? "" : " (not granted)"}`);
out.push(
shown.some((p) => !scope.grantFor(realPath(p)))
? "next: Files marked (not granted) cannot be read yet. Tell the user which folder you need and ask them to send /files grant <folder>; do not guess its contents."
: "next: Read the right one with read_file."
);
} else out.push("next: Nothing matched. Try fewer or different words, part of the name, or an extension such as .md.");
return out.join("\n");
}
// Everything simpleFiles shows the model is from this computer, even a list of names. It is
// marked as such, and its fingerprints recorded, so a plugin that sends things out (simpleSearch)
// can keep it from leaving without the user's OK.
const SOURCE = "simplefiles";
function local(out: string): string {
recordShown(SOURCE, out);
return `${out}\n${markLine(SOURCE)}`;
}
export async function toolsProvider(ctl: ToolsProviderController): Promise<Tool[]> {
const s = readSettings(ctl);
const scope = () => scopeFor(s).scope;
nameIndex(s);
return [
tool({
name: "find_files",
description:
"Find files or folders by name anywhere in the user's files. Returns paths only; ones outside the granted folders are marked (not granted).",
parameters: { query: z.string().describe("Words from the name, or an extension, such as \"budget .xlsx\".") },
implementation: async ({ query }) => local(await findFiles(s, query)),
}),
tool({
name: "list_folder",
description: "List the files and subfolders of a folder inside the granted folders.",
parameters: { path: z.string().describe("Full path of the folder.") },
implementation: async ({ path: p }) => local(await listFolder(scope(), p)),
}),
tool({
name: "read_file",
description:
"Read a text file, up to 300 numbered lines at a time. Prefer narrow reads. The line numbers are not part of the file.",
parameters: {
path: z.string().describe("Full path of the file."),
start_line: z.number().int().min(1).optional().describe("Line to start from."),
line_count: z.number().int().min(1).max(300).optional().describe("How many lines, up to 300."),
from_end: z.boolean().optional().describe("Read the end of the file instead, such as a log."),
matching: z.string().optional().describe("Only lines containing this text."),
},
implementation: async ({ path: p, start_line, line_count, from_end, matching }) =>
local(await readFile(scope(), p, start_line ?? 1, line_count ?? 300, { fromEnd: from_end, matching: matching?.trim() || undefined })),
}),
tool({
name: "search_in_files",
description: "Search the text of files in a folder and its subfolders. Returns matching lines with file and line number.",
parameters: {
path: z.string().describe("Full path of the folder or file to search."),
text: z.string().describe("Text to look for (not case-sensitive)."),
},
implementation: async ({ path: p, text }) => local(await searchInFiles(scope(), p, text)),
}),
tool({
name: "propose_edit",
description:
"Propose replacing one exact passage of a text file. The passage must appear exactly once, copied from read_file without the line numbers.",
parameters: {
path: z.string().describe("Full path of the file."),
find: z.string().describe("The exact text to replace, as it is in the file now. Include enough lines that it appears only once."),
replace: z.string().describe("The text to put in its place."),
reason: z.string().describe("One sentence on what this change does, for the user and the change log."),
},
implementation: async ({ path: p, find, replace, reason }) => local(await maybeAutoApply(scope(), proposeEdit(scope(), p, find, replace, reason))),
}),
tool({
name: "propose_new_file",
description: "Propose a new text file. Cannot replace an existing one. To copy or archive existing files, use copy_files.",
parameters: {
path: z.string().describe("Full path of the new file."),
content: z.string().describe("The whole content of the new file."),
reason: z.string().describe("One sentence on what this file is for."),
},
implementation: async ({ path: p, content, reason }) => local(await maybeAutoApply(scope(), proposeNewFile(scope(), p, content, reason))),
}),
tool({
name: "copy_files",
description:
"Propose copying files or folders, byte for byte, without their contents passing through you. The way to copy, back up or archive.",
parameters: {
sources: z.array(z.string()).describe("Full paths of files or folders. Folders are copied whole, minus dot-folders, node_modules and secret files."),
dest: z.string().describe("Destination folder. Sources keep their names; nothing is overwritten."),
reason: z.string().describe("One sentence on what this copy is for."),
},
implementation: async ({ sources, dest, reason }) => local(await maybeAutoApply(scope(), proposeCopy(scope(), sources as string[], dest, reason))),
}),
tool({
name: "read_form",
description:
"Read a fillable PDF form as its printed text with each field marked in place and numbered ([#4 text], [#9 ☐], [#12 ○]). Use it to understand a form, translate its questions for the user, and fill it with fill_form.",
parameters: {
path: z.string().describe("Full path of the PDF."),
pages: z.string().optional().describe("Pages to show, such as 1 or 2-3. Leave empty for all."),
},
implementation: async ({ path: p, pages }) => local(await readFormReport(scope(), p, pages)),
}),
tool({
name: "fill_form",
description:
"Propose a filled copy of a PDF form: field numbers from read_form with the answers, written in the form's language. The original is never changed; a copy filled earlier is filled further in place. Text is sized to fit; an answer that cannot fit is refused. Signatures are left to the user.",
parameters: {
path: z.string().describe("Full path of the form (or of its filled copy, to add answers)."),
answers: z
.array(
z.object({
field: z.number().int().describe("The field number from read_form."),
value: z.union([z.string(), z.boolean()]).describe("Text in the form's language, or true/false to tick a box or choice."),
english: z.string().optional().describe("What the answer says in the user's language, for them to check."),
})
)
.describe("The answers to write."),
save_as: z.string().optional().describe("Full path for the filled copy. Leave empty to save it beside the form with -filled in its name."),
reason: z.string().describe("One sentence on what is being filled."),
},
implementation: async ({ path: p, answers, save_as, reason }) => local(await maybeAutoApply(scope(), await proposeForm(scope(), p, answers as never, save_as, reason))),
}),
tool({
name: "list_proposals",
description: "List changes waiting for approval and those applied recently. Check this rather than remembering.",
parameters: {},
implementation: async () => local(proposalsReport()),
}),
...(s.devTools === false || !anyProject(scope()) ? [] : [projectTool(scope), handoffTool(scope)]),
];
}
function proposalsReport(): string {
const pending = pendingList();
const applied = appliedList().slice(-8).reverse();
const out = ["status: ok", `waiting for approval: ${pending.length ? "" : "none"}`];
for (const p of pending) out.push(` #${p.id} ${p.kind} ${p.kind === "install" ? p.find ?? p.path : p.kind === "run" ? `"${p.find}" in ${p.path}` : p.kind === "copy" ? `${(p.sources ?? []).length} file(s) -> ${p.dest ?? p.path}` : p.path}: ${p.reason}`);
out.push(`applied recently (newest first): ${applied.length ? "" : "none"}`);
for (const a of applied) out.push(` #${a.id} ${a.kind} ${a.path} at ${a.appliedAt}`);
out.push("next: Only applied changes are in the files. Waiting ones need the user to send /files apply <number>.");
return out.join("\n");
}
// One tool for running a project's own commands: the model names a command the project declares,
// and never writes one.
function projectTool(scope: () => ReturnType<typeof scopeFor>["scope"]): Tool {
return tool({
name: "run_script",
description:
"Run a command the project declares: package.json scripts, pytest, Makefile targets, or for an LM Studio plugin, lmstudio-install and lmstudio-log. Leave script empty to list them. Checks run in trusted folders; anything else is proposed.",
parameters: {
folder: z.string().describe("The project's folder, inside a granted folder."),
script: z.string().optional().describe("The command's name from the list, such as test, build, pytest or lmstudio-install. Leave empty to list."),
reason: z.string().optional().describe("For a command that changes something: one sentence on why, for the user."),
},
implementation: async ({ folder, script, reason }) =>
local(await maybeAutoApply(scope(), await runScript(scope(), folder, script, reason, { proposeRun, proposeInstall: (root, why) => proposeInstall(scope(), root, why) }))),
});
}
function handoffTool(scope: () => ReturnType<typeof scopeFor>["scope"]): Tool {
return tool({
name: "write_handoff",
description:
"Record a handoff so a fresh chat can continue this work: the goal, what was ruled out, and the next step. The verified state is added automatically; the new chat loads it with /files resume.",
parameters: {
folder: z.string().optional().describe("The project or repository folder. Leave out to use the folder of the latest changes."),
goal: z.string().describe("What this work is trying to do, in a sentence or two."),
ruled_out: z.string().optional().describe("What was tried and did not work."),
next_step: z.string().optional().describe("The single concrete next step."),
},
implementation: async ({ folder, goal, ruled_out, next_step }) => local(writeHandoff(scope(), folder ?? "", goal, ruled_out ?? "", next_step ?? "")),
});
}
export const HELP = [
"simpleFiles commands:",
"/files status: granted folders, waiting proposals, recent changes and the name index",
"/files pending: show the changes waiting for approval",
"/files apply <number> | all: make a waiting change (or all of them)",
"/files reject <number> | all: drop a waiting change",
"/files keep <number>: leave a waiting change as it is",
"Several commands can go in one message, for example /files apply 4 5 /files reject 6.",
"/files undo: reverse the last applied change",
"/files grant <folder>: let the model read that folder and propose changes in it",
"/files revoke <folder>: take a chat grant back",
"/files reindex: rebuild the file name index now",
"/files trust <folder>: let the model run checks (tests, lint, type checks) in projects there without asking",
"/files untrust <folder>: make checks there ask first again",
"/files autoapply <folder> [time]: for a while (2h unless you add a time, such as 4h), edits, new files, copies and builds there take effect without /files apply; /files autoapply off ends it",
"/files handoff [folder]: show the handoff for a fresh chat to continue this work (the model writes it with write_handoff); without a folder, the folder of the latest changes",
"/files resume [folder]: in a new chat, load the handoff and the current state; without a folder, the newest handoff",
"Commands the model proposes to run, and plugin installs, are made with /files apply too; /files undo puts a plugin's previous version back.",
].join("\n");
// Reads which proposals a command means: numbers in any common form ("4", "#4", "4 and 5",
// "4, 5"), or "all", with other words around them allowed ("all please"). Exceptions are
// refused, since "all except 4" would otherwise read as "4".
const EXCEPTION = /\b(except|but|not|without|other than|apart from|besides)\b/i;
function ids(arg: string): number[] | "all" | { error: string } | null {
const text = arg.trim();
if (!text) return null;
if (EXCEPTION.test(text)) {
return { error: "Name exactly the numbers to use, for example /files apply 4 5, rather than an exception." };
}
const found = text.match(/\d+/g);
if (found) return [...new Set(found.map(Number))];
if (/\b(all|everything|both)\b/i.test(text)) return "all";
return null;
}
// The list of proposals at the start of an apply, reject or keep command ("4 5", "#4 and #5",
// "all"), and where it ends. Words after it are not part of the command.
const ID_LIST = /^\s*(all|everything|both|#?\d+(?:\s*(?:,|&|\band\b)?\s*#?\d+)*)\b\s*[.,;!]?/i;
const TAKES_IDS = new Set(["apply", "reject", "keep"]);
const TAKES_FOLDER = new Set(["grant", "revoke", "trust", "untrust", "handoff", "resume", "autoapply"]);
// One command's own text and the words after it that belong to the user's message instead.
function splitOne(line: string): { command: string; leftover: string } {
const m = /^\/files(?:\s+(\S+))?([\s\S]*)$/i.exec(line.trim());
if (!m || !m[1]) return { command: "/files", leftover: (m?.[2] ?? "").trim() };
const word = m[1].replace(/[.,;:!?]+$/, "").toLowerCase();
const args = m[2] ?? "";
if (TAKES_FOLDER.has(word)) return { command: `/files ${word} ${args.trim()}`.trim(), leftover: "" };
if (TAKES_IDS.has(word)) {
const list = ID_LIST.exec(args);
if (!list) return { command: `/files ${word}`, leftover: args.trim() };
const after = args.slice(list[0].length);
// "all except 4", "4 but not 5": keep the exception in the command so it is refused.
if (/^\s*(except|but|not|without|other than|apart from|besides)\b/i.test(after)) return { command: `/files ${word} ${args.trim()}`, leftover: "" };
return { command: `/files ${word} ${list[1]}`, leftover: after.trim() };
}
return { command: `/files ${word}`, leftover: args.trim() };
}
function status(s: Settings): string {
const { scope, problems } = scopeFor(s);
const out = [`simpleFiles ${VERSION}`, `data folder: ${dataDir()}`, "granted folders:"];
if (scope.grants.length) for (const g of scope.grants) out.push(` ${g.folder} (${g.from === "settings" ? "settings" : "granted in a chat"})`);
else out.push(" none yet: add folders in the plugin's settings or send /files grant <folder>");
for (const p of problems) out.push(`problem: ${p}`);
out.push(`attachments folder: ${s.attachments ? `${s.attachments}${fs.existsSync(s.attachments) ? "" : " (made when you first attach a document)"}` : "off"}`);
const trusted = trustedFolders();
out.push(`trusted for checks: ${trusted.length ? "" : "none"}`);
for (const t of trusted) out.push(` ${t}`);
const auto = autoApplyStatus();
out.push(`auto-apply: ${auto.length ? "" : "off"}`);
for (const a of auto) out.push(` ${a}`);
const pending = pendingList();
out.push(`waiting for approval: ${pending.length ? pending.map((p) => `#${p.id}`).join(", ") : "none"}`);
const applied = appliedList().slice(-5).reverse();
if (applied.length) {
out.push("recent changes (newest first; /files undo reverses the first):");
for (const a of applied) out.push(` #${a.id} ${a.appliedAt} ${a.kind === "edit" ? "edited" : a.kind === "install" ? "installed from" : a.kind === "copy" ? "copied into" : a.kind === "form" ? "filled" : "created"} ${a.path}`);
}
const names = nameIndex(s);
const meta = names.meta();
out.push(`name index: ${meta ? `${meta.count} names, built ${new Date(meta.builtAt).toLocaleString()}` : "not built yet"}${names.isBuilding() ? ", building now" : ""}`);
return out.join("\n");
}
export async function runCommand(s: Settings, text: string): Promise<string> {
const m = /^\s*\/files(?:\s+(\S+))?(?:\s+([\s\S]*))?$/i.exec(text);
if (!m) return HELP;
const command = (m[1] ?? "help").toLowerCase();
const arg = (m[2] ?? "").trim();
const { scope } = scopeFor(s);
switch (command) {
case "status":
return status(s);
case "pending": {
const pending = pendingList();
return pending.length ? pending.map(preview).join("\n\n") : "Nothing is waiting for approval.";
}
case "apply": {
const pending = pendingList();
const which = arg ? ids(arg) : pending.length === 1 ? [pending[0].id] : null;
if (which && typeof which === "object" && "error" in which) return which.error;
if (!which) return pending.length ? `Several changes are waiting (${pending.map((p) => `#${p.id}`).join(", ")}). Send /files apply <number> or /files apply all.` : "Nothing is waiting to be applied.";
return apply(scope, which);
}
case "reject": {
const which = arg ? ids(arg) : null;
if (which && typeof which === "object" && "error" in which) return which.error;
if (!which) return "Send /files reject <number> or /files reject all.";
return reject(which);
}
case "keep": {
const which = arg ? ids(arg) : null;
if (!which || (typeof which === "object" && "error" in which)) return "Nothing changed; the waiting changes stay waiting.";
const pending = pendingList();
const named = which === "all" ? pending.map((p) => p.id) : which;
const waiting = named.filter((id) => pending.some((p) => p.id === id));
return waiting.length ? `Left waiting: ${waiting.map((id) => `#${id}`).join(", ")}.` : "None of those are waiting.";
}
case "undo":
return undo();
case "grant": {
if (!arg) return "Send /files grant followed by the folder's full path.";
const named = folderInText(arg);
if (!named) return `${arg} is not a folder.`;
const folder = named.folder;
if (tooBroad(folder)) return `${folder} is a whole drive or your home folder, which is too broad. Grant the project folder inside it instead.`;
if (isInsideLmstudio(folder)) return LMSTUDIO_REFUSED(folder);
const grants = chatGrants();
if (!grants.some((g) => samePath(realPath(g), folder))) writeJson(grantsFile(), [...grants, folder]);
return `Granted: ${folder}\nThe model can now read files there and propose changes; every change still needs /files apply.${named.extra ? `\n(The words after the folder, "${named.extra}", are not part of the command.)` : ""}`;
}
case "revoke": {
if (!arg) return "Send /files revoke followed by the folder's full path.";
const folder = realPath(arg.replace(/^["']|["']$/g, ""));
const grants = chatGrants();
const left = grants.filter((g) => !samePath(realPath(g), folder));
if (left.length === grants.length) {
return s.folders.some((f) => samePath(realPath(f), folder))
? `${folder} is granted in the plugin's settings; remove it there.`
: `${folder} was not granted.`;
}
writeJson(grantsFile(), left);
return `Revoked: ${folder}`;
}
case "trust":
if (!arg) return "Send /files trust followed by the project folder's full path.";
return trust(scope, arg.replace(/^["']|["']$/g, ""));
case "untrust":
if (!arg) return "Send /files untrust followed by the folder's full path.";
return untrust(arg);
case "autoapply":
return autoApplyCommand(scope, arg);
case "handoff":
return handoffCommand(scope, arg);
case "resume":
return resumeCommand(scope, arg);
case "reindex":
nameIndex(s).refreshIfNeeded(true);
return "Rebuilding the file name index in the background. /files status shows its progress.";
default:
return HELP;
}
}
// Every /files command in a message, each with only its own words: "/files apply 4 5 /files
// reject 6" is two commands, and in "/files apply 28 and then show me the log" the words after
// 28 are the user's message, not part of the command. A command never runs past its line. The
// rest of the message is passed on to the model.
const COMMAND_AT = /(^|[\s.,;:!?(\["'])\/files\b/gi;
export function findCommands(text: string): { commands: string[]; rest: string } | null {
const starts: number[] = [];
for (const m of text.matchAll(COMMAND_AT)) starts.push((m.index ?? 0) + m[1].length);
if (!starts.length) return null;
const commands: string[] = [];
let rest = text.slice(0, starts[0]);
starts.forEach((start, i) => {
const segment = text.slice(start, i + 1 < starts.length ? starts[i + 1] : text.length);
const nl = segment.indexOf("\n");
const line = nl === -1 ? segment : segment.slice(0, nl);
const { command, leftover } = splitOne(line);
commands.push(command);
if (leftover && !/^[\s.,;:!?]*$/.test(leftover)) rest += (rest && !/\s$/.test(rest) ? " " : "") + leftover;
if (nl !== -1) rest += segment.slice(nl);
});
return { commands, rest: rest.replace(/[ \t]+$/gm, "").trim() };
}
// The first command only, kept for callers that handle one.
export function findCommand(text: string): { command: string; rest: string } | null {
const found = findCommands(text);
return found ? { command: found.commands[0], rest: found.rest } : null;
}
// Apply and reject naming the same change in one message cannot both be meant, so neither runs.
function conflict(commands: string[]): string | null {
const named = (verb: string) =>
commands
.filter((c) => c.toLowerCase().startsWith(`/files ${verb}`))
.map((c) => ids(c.slice(`/files ${verb}`.length)))
.filter((w): w is number[] | "all" => !!w && !(typeof w === "object" && "error" in w));
const applied = named("apply");
const rejected = named("reject");
if (!applied.length || !rejected.length) return null;
const all = (l: (number[] | "all")[]) => l.some((w) => w === "all");
const nums = (l: (number[] | "all")[]) => l.flatMap((w) => (w === "all" ? [] : w));
const both = all(applied) || all(rejected) ? ["all"] : nums(applied).filter((id) => nums(rejected).includes(id)).map((id) => `#${id}`);
if (!both.length) return null;
return `Nothing was applied or rejected: this message both applies and rejects ${both.join(", ")}. Send the commands again, naming each change only once.`;
}
// Answers /files commands typed by the user. The model only relays the result.
const newer = (a: string, b: string) => {
const pa = a.split(".").map(Number);
const pb = b.split(".").map(Number);
for (let i = 0; i < 3; i++) if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) > (pb[i] ?? 0);
return false;
};
// The tool news since the version the model last heard about, once, and only where files can be
// worked on. Before this existed nothing was recorded, so a missing record means 0.4.0.
export function toolNews(settings: Settings): string {
if (!scopeFor(settings).scope.grants.length) return "";
const f = path.join(dataDir(), "seen-version.json");
const seen = readJson<{ version?: string }>(f, {}).version ?? "0.4.0";
if (!newer(VERSION, seen)) return "";
try {
writeJson(f, { version: VERSION });
} catch {
return "";
}
const news = TOOL_NEWS.filter(([v]) => newer(v, seen) && !newer(v, VERSION)).map(([, t]) => t);
return news.length ? `[simpleFiles was updated to ${VERSION}. New for you: ${news.join(" ")}]` : "";
}
export async function preprocess(ctl: PromptPreprocessorController, userMessage: ChatMessage): Promise<string | ChatMessage> {
const settings = readSettings(ctl);
let attached: Awaited<ReturnType<typeof takeAttachments>> = null;
try {
const earlier = async () => new Set((await ctl.pullHistory()).getAllFiles(ctl.client).map((f) => f.identifier));
attached = await takeAttachments(ctl.client, userMessage, settings.attachments ?? null, earlier);
} catch (e) {
log(`attachments: ${(e as Error).message}`);
}
if (attached) recordShown(SOURCE, attached.note);
const news = toolNews(settings);
const extra = [attached?.note, news].filter(Boolean).map((t) => `\n\n${t}`).join("");
const found = findCommands((attached?.message ?? userMessage).getText());
if (!found) {
if (!extra) return userMessage;
const out = attached?.message ?? userMessage.asMutableCopy();
out.appendText(extra);
return out;
}
const clash = conflict(found.commands);
const results: string[] = [];
for (const command of found.commands) {
let result: string;
if (clash && /^\/files\s+(apply|reject)\b/i.test(command)) {
result = clash;
} else {
try {
result = await runCommand(settings, command);
} catch (e) {
result = `The command failed: ${(e as Error).message}`;
}
}
results.push(found.commands.length > 1 ? `> ${command}\n${result}` : result);
}
const result = [...new Set(results)].join("\n\n");
recordShown(SOURCE, result);
// After applying, undoing or rejecting, the model may be in the middle of a task that
// depended on it, so it may carry on. A handoff or resume asks the model to act on it.
// After anything else, showing the result is the reply.
const changes = found.commands.some((c) => /^\/files\s+(apply|reject|undo|trust|autoapply)\b/i.test(c));
const acts = found.commands.some((c) => /^\/files\s+(handoff|resume)\b/i.test(c));
const instruction = found.rest
? "Show the user the following exactly as written, in a code block, then answer the rest of their message, quoted after it."
: acts
? "Show the user the following exactly as written, in a code block, then do what the bracketed note at its end asks."
: changes
? "Show the user the following exactly as written, in a code block. Then, if you were in the middle of a task these changes belong to, continue it; otherwise add nothing else."
: "Show the user the following exactly as written, in a code block, and add nothing else. Do not call any tools: showing it is the whole reply.";
const out = attached?.message ?? userMessage.asMutableCopy();
out.replaceText(
`${markLine(SOURCE)}\n[simpleFiles handled ${found.commands.length > 1 ? `${found.commands.length} commands` : "a command"} from the user. ${instruction}]\n${result}${found.rest ? `\n\n[The user's message also said:]\n${found.rest}` : ""}${extra}`
);
return out;
}