src / edits.ts
src / edits.ts
// Changes to files. The model can only propose: an exact passage to replace (which must appear
// exactly once), or a new file. Nothing touches the disk until the user applies the proposal
// with a /files command. Applying backs up the original first, writes through a temporary file,
// reads the result back to confirm it, and records the change, so /files undo can restore it.
import * as fs from "node:fs";
import * as path from "node:path";
import { copiedFrom } from "./copies";
import { FillEntry, fillForm, findFont, FormField, layoutForm } from "./forms";
import { isBinary } from "./reading";
import { InstallRecord, PluginInfo, RELOAD_NOTE, install, pluginAt, restoreInstall, testedOk } from "./dev";
import { Project, ProjectCommand, runApproved } from "./projects";
import { Grant, isInside, isSecretFile, isWriteProtected, realPath, samePath, Scope } from "./scope";
import { appendLog, dataDir, readJson, sha256, stamp, writeFileAtomic, writeJson } from "./store";
export interface Proposal {
id: number;
kind: "edit" | "create" | "install" | "run" | "copy" | "form";
path: string;
grant: string;
find?: string;
replace?: string;
content?: string;
// For a copy: the source paths and the destination folder.
sources?: string[];
dest?: string;
reason: string;
createdAt: string;
}
interface Applied {
id: number;
kind: "edit" | "create" | "install" | "copy" | "form";
path: string;
backup: string;
writtenSha: string;
install?: InstallRecord;
// For a copy: the files it created, so undo can remove exactly those.
created?: string[];
// For a filled form: whether it replaced an earlier filled copy (kept in backup) or was new.
replaced?: boolean;
reason: string;
appliedAt: string;
}
const MAX_NEW_FILE = 1024 * 1024;
const files = () => ({ pending: path.join(dataDir(), "pending.json"), applied: path.join(dataDir(), "applied.json") });
const loadPending = () => readJson<{ next: number; items: Proposal[] }>(files().pending, { next: 1, items: [] });
const loadApplied = () => readJson<Applied[]>(files().applied, []);
const lf = (s: string) => s.replace(/\r\n/g, "\n");
function occurrences(haystack: string, needle: string): number {
let n = 0;
for (let i = haystack.indexOf(needle); i !== -1; i = haystack.indexOf(needle, i + needle.length)) n++;
return n;
}
function readText(file: string): { text: string; crlf: boolean } | { error: string } {
let buf: Buffer;
try {
buf = fs.readFileSync(file);
} catch {
return { error: `${file} does not exist.` };
}
if (isBinary(buf)) return { error: `${file} is a binary file; only text files can be edited.` };
const text = buf.toString("utf8");
return { text, crlf: text.includes("\r\n") };
}
// The changed passage with two lines of context, in the file's own line numbers.
export function preview(p: Proposal): string {
if (p.kind === "run") return [`#${p.id} run "${p.find}" in ${p.path}`, `command: ${p.replace}`, `reason: ${p.reason}`].join("\n");
if (p.kind === "copy") return [`#${p.id} copy ${(p.sources ?? []).length} file(s) into ${p.dest ?? p.path}`, `reason: ${p.reason}`].join("\n");
if (p.kind === "form") return formPreview(p);
if (p.kind === "install") return [`#${p.id} install ${p.find ?? "the plugin"} into LM Studio from ${p.path}`, `reason: ${p.reason}`].join("\n");
if (p.kind === "create") {
const lines = lf(p.content ?? "").split("\n");
const shown = lines.slice(0, 40).map((l, i) => `+ ${String(i + 1).padStart(4)}| ${l}`);
return [`#${p.id} new file ${p.path}`, `reason: ${p.reason}`, ...shown, ...(lines.length > 40 ? [` ... ${lines.length - 40} more line(s)`] : [])].join("\n");
}
const current = readText(p.path);
const find = lf(p.find ?? "");
const head = [`#${p.id} edit ${p.path}`, `reason: ${p.reason}`];
if ("error" in current) return [...head, `(the file cannot be read now: ${current.error})`].join("\n");
const text = lf(current.text);
const at = text.indexOf(find);
if (at === -1) return [...head, "(the passage is no longer in the file)"].join("\n");
const startLine = text.slice(0, at).split("\n").length;
const all = text.split("\n");
const oldLines = find.split("\n");
const newLines = lf(p.replace ?? "").split("\n");
const out = [...head];
for (let i = Math.max(1, startLine - 2); i < startLine; i++) out.push(` ${String(i).padStart(4)}| ${all[i - 1]}`);
oldLines.forEach((l, i) => out.push(`- ${String(startLine + i).padStart(4)}| ${l}`));
newLines.forEach((l) => out.push(`+ | ${l}`));
const after = startLine + oldLines.length;
for (let i = after; i < Math.min(all.length + 1, after + 2); i++) out.push(` ${String(i).padStart(4)}| ${all[i - 1]}`);
return out.join("\n");
}
function store(p: Omit<Proposal, "id" | "createdAt">): Proposal {
const pending = loadPending();
const proposal: Proposal = { ...p, id: pending.next, createdAt: stamp() };
pending.items.push(proposal);
pending.next++;
writeJson(files().pending, pending);
return proposal;
}
const APPROVAL_NEXT = (id: number) =>
`next: Nothing has changed yet. Propose all of this step's changes now, then ask the user to send /files apply ${id} (with the other numbers). If more rounds will follow, say so, and that /files autoapply <folder> skips asking.`;
export function proposeEdit(scope: Scope, input: string, find: string, replace: string, reason: string): string {
const r = scope.resolve(input, "write");
if ("error" in r) return `status: error\nreason: ${r.error}`;
if (!find) return "status: error\nreason: The passage to replace is empty. Copy the exact text to change from read_file, without the line numbers.";
if (find === replace) return "status: error\nreason: The replacement is the same as the passage, so there is nothing to change.";
const current = readText(r.path);
if ("error" in current) return `status: error\nreason: ${current.error}${current.error.endsWith("does not exist.") ? " To make a new file, use propose_new_file." : ""}`;
const n = occurrences(lf(current.text), lf(find));
if (n === 0) {
const waiting = loadPending().items.find((p) => samePath(p.path, r.path) && lf(p.kind === "create" ? p.content ?? "" : p.replace ?? "").includes(lf(find)));
if (waiting) {
return `status: error\nreason: That passage is only in proposal #${waiting.id}, which has not been applied yet, so it is not in the file.\nnext: Ask the user to apply #${waiting.id} first (/files apply ${waiting.id}), then propose this edit again. Or fold this change into a new version of #${waiting.id}.`;
}
return "status: error\nreason: That passage is not in the file. It must match the file exactly, including spaces and line breaks, and must not include the line numbers that read_file shows.\nnext: Read the file again and copy the passage exactly.";
}
if (n > 1) return `status: error\nreason: That passage appears ${n} times in the file, so it is not clear which to change.\nnext: Include a few more lines around it so it appears only once.`;
const proposal = store({ kind: "edit", path: r.path, grant: r.grant.folder, find, replace, reason: (reason ?? "").trim().slice(0, 300) || "(no reason given)" });
return ["status: proposed", preview(proposal), APPROVAL_NEXT(proposal.id)].join("\n");
}
export function proposeNewFile(scope: Scope, input: string, content: string, reason: string): string {
const r = scope.resolve(input, "write");
if ("error" in r) return `status: error\nreason: ${r.error}`;
if (fs.existsSync(r.path)) return `status: error\nreason: ${r.path} already exists.\nnext: To change it, read it and use propose_edit.`;
if (Buffer.byteLength(content ?? "", "utf8") > MAX_NEW_FILE) return "status: error\nreason: The new file would be larger than 1 MB.";
const copy = copiedFrom(content ?? "", r.path);
if (copy) {
const keepsName = path.basename(copy.source).toLowerCase() === path.basename(r.path).toLowerCase();
return [
"status: refused",
`reason: This is ${copy.exact ? "an exact copy" : "a near copy"} of ${copy.source}, which you read. Retyping a file costs many tokens, can be cut off partway, and can drop lines.`,
`next: Call copy_files with sources ["${copy.source}"] and destination "${path.dirname(r.path)}"; it copies the file byte for byte${keepsName ? "" : ` under its own name, ${path.basename(copy.source)}`}.${copy.exact ? "" : " Then propose the differences with propose_edit once the copy is applied."}`,
].join("\n");
}
const proposal = store({ kind: "create", path: r.path, grant: r.grant.folder, content: content ?? "", reason: (reason ?? "").trim().slice(0, 300) || "(no reason given)" });
return ["status: proposed", preview(proposal), APPROVAL_NEXT(proposal.id)].join("\n");
}
// Proposes installing a plugin's current code into LM Studio. Checked now and again when applied.
export function proposeInstall(scope: Scope, input: string, reason: string): string {
const info = pluginAt(scope, input);
if ("error" in info) return `status: error\nreason: ${info.error}`;
const tested = testedOk(info);
if (!tested.ok) return `status: error\nreason: ${tested.reason}`;
const proposal = store({ kind: "install", path: info.root, grant: info.root, find: info.id, reason: (reason ?? "").trim().slice(0, 300) || "(no reason given)" });
return [
"status: proposed",
preview(proposal),
`checked: ${tested.note}`,
`next: Nothing has been installed yet. Tell the user to send /files apply ${proposal.id} to install it, or /files reject ${proposal.id} to drop it. After it is installed, ${RELOAD_NOTE.charAt(0).toLowerCase() + RELOAD_NOTE.slice(1)} Then try the changed tool yourself and read the plugin's log with run_script (script "lmstudio-log") to see what it did.`,
].join("\n");
}
// Proposes running a project command that changes something (a build, a deploy). Made with
// /files apply; it cannot be undone, so the proposal shows the exact command.
export function proposeRun(project: Project, cmd: ProjectCommand, reason: string): string {
const proposal = store({ kind: "run", path: project.root, grant: project.root, find: cmd.name, replace: cmd.command, reason: (reason ?? "").trim().slice(0, 300) || "(no reason given)" });
return [
"status: proposed",
preview(proposal),
`next: Nothing has run yet. Tell the user what this does and that they can send /files apply ${proposal.id} to run it, or /files reject ${proposal.id} to drop it. A command cannot be undone by /files undo.`,
].join("\n");
}
const MAX_COPY_FILES = 2000;
const MAX_COPY_BYTES = 50 * 1024 * 1024;
// The files a copy would create: a single file, or every file under a folder, skipping the
// folders simpleFiles never touches (dot-folders, node_modules, secret files) and anything the
// user excluded. Returns each source file with where it would land under dest.
function planCopy(scope: Scope, sources: string[], destDir: string): { pairs: Array<{ from: string; to: string }>; bytes: number } | { error: string } {
const pairs: Array<{ from: string; to: string }> = [];
let bytes = 0;
const add = (from: string, to: string): string | null => {
if (isSecretFile(from)) return null; // silently skipped
let stat: fs.Stats;
try {
stat = fs.statSync(from);
} catch {
return `${from} does not exist.`;
}
bytes += stat.size;
if (pairs.length >= MAX_COPY_FILES) return `More than ${MAX_COPY_FILES} files would be copied. Copy a smaller set.`;
if (bytes > MAX_COPY_BYTES) return `More than ${Math.round(MAX_COPY_BYTES / 1024 / 1024)} MB would be copied. Copy a smaller set.`;
pairs.push({ from, to });
return null;
};
const walk = (dir: string, under: string): string | null => {
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch (e) {
return `${dir} could not be read (${(e as NodeJS.ErrnoException).code ?? "error"}).`;
}
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isSymbolicLink()) continue;
if (e.isDirectory()) {
if (e.name.startsWith(".") || e.name.toLowerCase() === "node_modules") continue;
if (scope.excludes.some((x) => isInside(realPath(full), x))) continue;
const err = walk(full, path.join(under, e.name));
if (err) return err;
} else if (e.isFile()) {
const err = add(full, path.join(under, e.name));
if (err) return err;
}
}
return null;
};
for (const src of sources) {
const r = scope.resolve(src, "read");
if ("error" in r) return { error: r.error };
let stat: fs.Stats;
try {
stat = fs.statSync(r.path);
} catch {
return { error: `${r.path} does not exist.` };
}
if (stat.isDirectory()) {
const err = walk(r.path, path.join(destDir, path.basename(r.path)));
if (err) return { error: err };
} else {
const err = add(r.path, path.join(destDir, path.basename(r.path)));
if (err) return { error: err };
}
}
return { pairs, bytes };
}
// Proposes copying files or folders into a destination folder, both inside granted folders.
// The bytes are copied directly on apply; their contents never pass through the model.
export function proposeCopy(scope: Scope, sources: string[], dest: string, reason: string): string {
const srcList = (sources ?? []).map((s) => (s ?? "").trim()).filter(Boolean);
if (!srcList.length) return "status: error\nreason: No source files or folders were given to copy.";
const d = scope.resolve(dest, "write");
if ("error" in d) return `status: error\nreason: ${d.error}`;
if (isWriteProtected(path.relative(d.grant.folder, d.path))) return "status: error\nreason: The destination is inside .git or node_modules, which simpleFiles never writes to.";
const plan = planCopy(scope, srcList, d.path);
if ("error" in plan) return `status: error\nreason: ${plan.error}`;
if (!plan.pairs.length) return "status: error\nreason: Nothing to copy (the sources are empty or only hold files simpleFiles skips).";
const clash = plan.pairs.find((pr) => fs.existsSync(pr.to));
if (clash) return `status: error\nreason: ${clash.to} already exists. Copy to a destination that does not already contain these files, so nothing is overwritten.`;
const proposal = store({ kind: "copy", path: d.path, grant: d.grant.folder, sources: srcList, dest: d.path, reason: (reason ?? "").trim().slice(0, 300) || "(no reason given)" });
const shown = plan.pairs.slice(0, 20).map((pr) => ` ${path.relative(d.grant.folder, pr.to)}`);
return [
"status: proposed",
`#${proposal.id} copy ${plan.pairs.length} file(s) into ${d.path}`,
`reason: ${proposal.reason}`,
...shown,
...(plan.pairs.length > 20 ? [` ... ${plan.pairs.length - 20} more`] : []),
`next: Nothing has been copied yet. Tell the user to send /files apply ${proposal.id} to copy, or /files reject ${proposal.id} to drop it. The files are copied exactly, byte for byte.`,
].join("\n");
}
function applyCopy(scope: Scope, p: Proposal): string {
const d = scope.resolve(p.dest ?? p.path, "write");
if ("error" in d) return `#${p.id} not copied: ${d.error}`;
// Re-plan from the recorded sources so the set is current and the folder layout is preserved,
// and refuse if anything now clashes.
const plan = planCopy(scope, p.sources ?? [], d.path);
if ("error" in plan) return `#${p.id} not copied: ${plan.error}`;
const created: string[] = [];
try {
for (const pr of plan.pairs) {
if (fs.existsSync(pr.to)) return `#${p.id} not copied: ${pr.to} exists now.`;
fs.mkdirSync(path.dirname(pr.to), { recursive: true });
fs.copyFileSync(pr.from, pr.to);
created.push(pr.to);
}
} catch (e) {
for (const f of created) try { fs.unlinkSync(f); } catch { /* best effort */ }
return `#${p.id} not copied: ${(e as Error).message}. Nothing was left behind.`;
}
const applied = loadApplied();
applied.push({ id: p.id, kind: "copy", path: d.path, backup: "", writtenSha: "", created, reason: p.reason, appliedAt: stamp() });
writeJson(files().applied, applied);
appendLog(`#${p.id} copied ${created.length} file(s) into ${d.path}: ${p.reason}`);
return `#${p.id} copied: ${created.length} file(s) into ${d.path}`;
}
export function pendingList(): Proposal[] {
return loadPending().items;
}
function backupDir(grant: string, id: number): string {
const label = `${path.basename(grant) || "root"}-${sha256(grant.toLowerCase()).slice(0, 6)}`;
const when = stamp().replace(/[: ]/g, (c) => (c === " " ? "_" : ""));
return path.join(dataDir(), "backups", label, `${when}_#${id}`);
}
async function applyInstall(scope: Scope, p: Proposal): Promise<string> {
const info = pluginAt(scope, p.path);
if ("error" in info) return `#${p.id} not installed: ${info.error}`;
const result = await install(info as PluginInfo);
if ("error" in result) return `#${p.id} not installed: ${result.error}`;
const applied = loadApplied();
applied.push({ id: p.id, kind: "install", path: info.root, backup: result.backup, writtenSha: "", install: result, reason: p.reason, appliedAt: stamp() });
writeJson(files().applied, applied);
return `#${p.id} installed: ${info.id} ${result.version} into ${result.installed}. ${RELOAD_NOTE} /files undo puts the previous version back.`;
}
async function applyRun(scope: Scope, p: Proposal): Promise<string> {
const result = await runApproved(scope, p.path, p.find ?? "", p.replace ?? "");
if ("error" in result) return `#${p.id} not run: ${result.error}`;
return `#${p.id} ran "${p.find}" in ${p.path}: ${result.passed ? "it succeeded" : "it failed"}\n${result.summary}`;
}
function applyOne(scope: Scope, p: Proposal): string {
// The grant must still stand and the path must still resolve inside it.
const r = scope.resolve(p.path, "write");
if ("error" in r) return `#${p.id} not applied: ${r.error}`;
if (!samePath(p.path, r.path)) return `#${p.id} not applied: the path now leads somewhere else (${r.path}).`;
const rel = path.relative(r.grant.folder, r.path);
const backup = backupDir(r.grant.folder, p.id);
let expected: string;
let mode: number | undefined;
if (p.kind === "edit") {
const current = readText(r.path);
if ("error" in current) return `#${p.id} not applied: ${current.error}`;
const text = lf(current.text);
const find = lf(p.find ?? "");
const n = occurrences(text, find);
if (n !== 1) return `#${p.id} not applied: the passage now appears ${n} times in the file (it changed since the proposal).`;
const at = text.indexOf(find);
const next = text.slice(0, at) + lf(p.replace ?? "") + text.slice(at + find.length);
expected = current.crlf ? next.replace(/\n/g, "\r\n") : next;
mode = fs.statSync(r.path).mode;
fs.mkdirSync(path.dirname(path.join(backup, rel)), { recursive: true });
fs.copyFileSync(r.path, path.join(backup, rel));
} else {
if (fs.existsSync(r.path)) return `#${p.id} not applied: ${r.path} exists now.`;
expected = p.content ?? "";
fs.mkdirSync(backup, { recursive: true });
fs.mkdirSync(path.dirname(r.path), { recursive: true });
}
fs.writeFileSync(path.join(backup, "change.json"), JSON.stringify({ ...p, file: rel }, null, 2));
writeFileAtomic(r.path, expected, mode);
const written = fs.readFileSync(r.path, "utf8");
if (written !== expected) {
if (p.kind === "edit") writeFileAtomic(r.path, fs.readFileSync(path.join(backup, rel)));
return `#${p.id} not applied: the file did not read back as written, so the original was restored.`;
}
const applied = loadApplied();
applied.push({ id: p.id, kind: p.kind as "edit" | "create", path: r.path, backup, writtenSha: sha256(written), reason: p.reason, appliedAt: stamp() });
writeJson(files().applied, applied);
appendLog(`#${p.id} ${p.kind === "edit" ? "edited" : "created"} ${r.path}: ${p.reason} (backup: ${backup})`);
return `#${p.id} applied: ${p.kind === "edit" ? "edited" : "created"} ${r.path}`;
}
// Applies the given proposals, or all of them, in the order they were made.
export async function apply(scope: Scope, which: number[] | "all"): Promise<string> {
const pending = loadPending();
const chosen = which === "all" ? pending.items : pending.items.filter((p) => which.includes(p.id));
const missing = which === "all" ? [] : which.filter((id) => !pending.items.some((p) => p.id === id));
if (!chosen.length) return missing.length ? `No pending proposal #${missing.join(", #")}.` : "Nothing is waiting to be applied.";
const lines: string[] = [];
for (const p of chosen) {
try {
lines.push(
p.kind === "install" ? await applyInstall(scope, p) : p.kind === "run" ? await applyRun(scope, p) : p.kind === "copy" ? applyCopy(scope, p) : p.kind === "form" ? await applyForm(scope, p) : applyOne(scope, p)
);
} catch (e) {
lines.push(`#${p.id} not applied: ${(e as Error).message}`);
}
}
const done = new Set(chosen.map((p) => p.id));
pending.items = pending.items.filter((p) => !done.has(p.id));
writeJson(files().pending, pending);
for (const id of missing) lines.push(`No pending proposal #${id}.`);
return lines.join("\n");
}
export function reject(which: number[] | "all"): string {
const pending = loadPending();
const before = pending.items.length;
pending.items = which === "all" ? [] : pending.items.filter((p) => !which.includes(p.id));
writeJson(files().pending, pending);
const n = before - pending.items.length;
return n ? `Dropped ${n} proposal(s).` : "No matching proposal was waiting.";
}
// Reverses the most recent applied change, if the file is still as simpleFiles left it.
export function undo(): string {
const applied = loadApplied();
const last = applied[applied.length - 1];
if (!last) return "There is no applied change to undo.";
if (last.kind === "copy") {
const created = last.created ?? [];
let removed = 0;
for (const f of created) {
try {
fs.unlinkSync(f);
removed++;
} catch {
// already gone or changed; leave it
}
}
// Remove folders left empty by the removal, deepest first.
const dirs = [...new Set(created.map((f) => path.dirname(f)))].sort((a, b) => b.length - a.length);
for (const dir of dirs) {
try {
if (fs.readdirSync(dir).length === 0) fs.rmdirSync(dir);
} catch {
// not empty or gone
}
}
applied.pop();
writeJson(files().applied, applied);
appendLog(`undid copy #${last.id}: removed ${removed} file(s)`);
return `Undid #${last.id}: removed ${removed} copied file(s). Any of those files you changed since are left in place.`;
}
if (last.kind === "form") {
let current: Buffer | null = null;
try {
current = fs.readFileSync(last.path);
} catch {
// gone
}
if (!current || sha256(current) !== last.writtenSha) {
return `#${last.id} was not undone: ${last.path} has changed since simpleFiles wrote it (or is gone).${last.backup ? ` The earlier copy is in ${last.backup}.` : ""}`;
}
if (last.replaced) {
writeFileAtomic(last.path, fs.readFileSync(path.join(last.backup, path.basename(last.path))));
} else {
const keep = path.join(last.backup, "undone", path.basename(last.path));
fs.mkdirSync(path.dirname(keep), { recursive: true });
fs.copyFileSync(last.path, keep);
fs.unlinkSync(last.path);
}
applied.pop();
writeJson(files().applied, applied);
appendLog(`undid form #${last.id} on ${last.path}`);
return last.replaced ? `Undid #${last.id}: ${last.path} is back to the answers it had before.` : `Undid #${last.id}: the filled copy ${last.path} was removed (a copy is kept in ${path.join(last.backup, "undone")}).`;
}
if (last.kind === "install" && last.install) {
const done = restoreInstall(last.install);
applied.pop();
writeJson(files().applied, applied);
appendLog(`undid install #${last.id}: ${done}`);
return `Undid install #${last.id}: ${done}. ${RELOAD_NOTE}`;
}
let current: Buffer | null = null;
try {
current = fs.readFileSync(last.path);
} catch {
// gone
}
if (!current || sha256(current.toString("utf8")) !== last.writtenSha) {
return `#${last.id} was not undone: ${last.path} has changed since simpleFiles wrote it (or is gone), so restoring the backup could lose that work. The backup is in ${last.backup}.`;
}
const meta = readJson<{ file: string }>(path.join(last.backup, "change.json"), { file: path.basename(last.path) });
if (last.kind === "edit") {
writeFileAtomic(last.path, fs.readFileSync(path.join(last.backup, meta.file)));
} else {
const keep = path.join(last.backup, "undone", meta.file);
fs.mkdirSync(path.dirname(keep), { recursive: true });
fs.copyFileSync(last.path, keep);
fs.unlinkSync(last.path);
}
applied.pop();
writeJson(files().applied, applied);
appendLog(`undid #${last.id} on ${last.path}`);
return last.kind === "edit"
? `Undid #${last.id}: ${last.path} is back as it was before.`
: `Undid #${last.id}: the new file ${last.path} was removed (a copy is kept in ${path.join(last.backup, "undone")}).`;
}
export function appliedList(): Applied[] {
return loadApplied();
}
export type { Grant };
// --- Filled forms -----------------------------------------------------------------------------
export interface FormAnswer {
field: number;
value: string | boolean;
english?: string;
}
const YES = /^(true|yes|y|x|checked|on|1|ja|jā|oui|si|sí|sim|da|tak|ano|igen|kyllä|jah|evet)$/i;
const NO = /^(false|no|n|unchecked|off|0|nein|nē|ne|non|não|nie|nem|ei|nei|hayır|)$/i;
// A copy filled earlier by simpleFiles can be filled further in place; any other form gets a
// new filled copy beside it, so the original is never written to.
function filledEarlier(file: string): boolean {
return loadApplied().some((a) => a.kind === "form" && samePath(a.path, file));
}
export function defaultFilledPath(file: string): string {
const ext = path.extname(file);
return path.join(path.dirname(file), `${path.basename(file, ext)}-filled${ext || ".pdf"}`);
}
// Checks the answers against the form, fills a copy in memory to be sure every answer fits, and
// stores the proposal. The filled copy is written only when the user applies it.
export async function proposeForm(scope: Scope, input: string, answers: FormAnswer[], saveAs: string | undefined, reason: string): Promise<string> {
const r = scope.resolve(input, "read");
if ("error" in r) return `status: error\nreason: ${r.error}`;
if (!/\.pdf$/i.test(r.path)) return "status: error\nreason: Only PDF forms can be filled.";
let bytes: Buffer;
try {
bytes = fs.readFileSync(r.path);
} catch {
return `status: error\nreason: ${r.path} does not exist.`;
}
const layout = await layoutForm(bytes);
if ("error" in layout) return `status: error\nreason: ${layout.error}`;
if (!layout.fields.length) return "status: error\nreason: This PDF has no fillable fields.\nnext: Tell the user the form cannot be filled in place; they can fill it in a PDF editor.";
if (!answers?.length) return "status: error\nreason: No answers were given.\nnext: Call fill_form with answers, each a field number from read_form and its value.";
const problems: string[] = [];
const entries: FillEntry[] = [];
const groups = new Map<string, number>();
for (const a of answers) {
const f: FormField | undefined = layout.fields.find((x) => x.id === Number(a.field));
if (!f) {
problems.push(`#${a.field}: there is no field with that number; read_form numbers them.`);
continue;
}
if (f.type === "signature") {
problems.push(`#${f.id} is a signature, which the user signs by hand; leave it out.`);
continue;
}
let value: string | boolean;
if (f.type === "check" || f.type === "choice") {
if (typeof a.value === "boolean") value = a.value;
else if (YES.test(String(a.value).trim())) value = true;
else if (NO.test(String(a.value).trim())) value = false;
else {
problems.push(`#${f.id} is a box: give true to tick it or false to leave it empty.`);
continue;
}
if (f.type === "choice" && value === true) {
const other = groups.get(f.name);
if (other !== undefined) {
problems.push(`#${other} and #${f.id} are choices of the same question; tick only one.`);
continue;
}
groups.set(f.name, f.id);
}
} else {
if (typeof a.value === "boolean") {
problems.push(`#${f.id} is a text field: give the text to write.`);
continue;
}
value = String(a.value);
}
entries.push({ id: f.id, name: f.name, type: f.type, onValue: f.onValue, value, label: f.option ? `${f.label} / ${f.option}` : f.label, english: a.english?.trim() || undefined });
}
if (problems.length) return ["status: error", "reason: Some answers cannot go in as given; nothing was proposed.", ...problems.map((p) => ` ${p}`), "next: Fix these and call fill_form again with all the answers."].join("\n");
const font = findFont();
if (!font) return "status: error\nreason: No font with the letters these forms need (such as Arial) was found on this computer.";
const trial = await fillForm(bytes, entries, font);
if ("errors" in trial) return ["status: error", "reason: Some answers do not fit; nothing was proposed.", ...trial.errors.map((e) => ` ${e}`), "next: Shorten those answers (or move detail to a supplement form) and call fill_form again with all the answers."].join("\n");
let dest: string;
if (saveAs?.trim()) {
const d = scope.resolve(saveAs, "write");
if ("error" in d) return `status: error\nreason: ${d.error}`;
dest = d.path;
} else {
dest = filledEarlier(r.path) ? r.path : defaultFilledPath(r.path);
}
if (!/\.pdf$/i.test(dest)) return "status: error\nreason: save_as must end in .pdf.";
const w = scope.resolve(dest, "write");
if ("error" in w) return `status: error\nreason: ${w.error}`;
if (fs.existsSync(w.path) && !filledEarlier(w.path)) {
return `status: error\nreason: ${w.path} already exists and was not filled by simpleFiles, so it is not overwritten.\nnext: Give save_as a new file name, or, if that file is the filled copy, fill it directly.`;
}
// A corrected proposal for the same form replaces the ones it covers, so the user is not left
// with stale answers waiting to be applied by mistake.
const ids = new Set(entries.map((e) => e.id));
const stale = pendingList()
.filter((p) => p.kind === "form" && samePath(p.path, w.path) && samePath((p.sources ?? [])[0] ?? "", r.path))
.filter((p) => {
try {
return (JSON.parse(p.content ?? "[]") as FillEntry[]).every((e) => ids.has(e.id));
} catch {
return false;
}
})
.map((p) => p.id);
if (stale.length) reject(stale);
const proposal = store({ kind: "form", path: w.path, grant: w.grant.folder, sources: [r.path], content: JSON.stringify(entries), reason: (reason ?? "").trim().slice(0, 300) || "(no reason given)" });
const replaced = stale.length ? [`replaces: ${stale.map((i) => `#${i}`).join(", ")}, which this proposal covers; those were dropped.`] : [];
return ["status: proposed", formPreview(proposal), ...replaced, APPROVAL_NEXT(proposal.id).replace("Nothing has changed yet.", "Nothing has been written yet. Show the user each answer in their language and in the form's language, so they can check the translation.")].join("\n");
}
function formPreview(p: Proposal): string {
let entries: FillEntry[] = [];
try {
entries = JSON.parse(p.content ?? "[]");
} catch {
// shown without answers
}
const src = (p.sources ?? [])[0] ?? "";
const lines = [
`#${p.id} fill ${entries.length} field(s) of ${src}`,
samePath(src, p.path) ? `into: the same filled copy (its earlier answers are kept)` : `into: ${p.path} (a new copy; the original is not changed)`,
`reason: ${p.reason}`,
];
for (const e of entries) {
const shown = typeof e.value === "boolean" ? (e.value ? "ticked" : "not ticked") : `"${e.value.length > 160 ? `${e.value.slice(0, 160)}...` : e.value}"`;
lines.push(` #${e.id} ${e.label.slice(0, 70) || e.name}: ${shown}${e.english ? ` [${e.english}]` : ""}`);
}
return lines.join("\n");
}
async function applyForm(scope: Scope, p: Proposal): Promise<string> {
const src = (p.sources ?? [])[0];
const r = src ? scope.resolve(src, "read") : { error: "The proposal has no form." };
if ("error" in r) return `#${p.id} not applied: ${r.error}`;
const w = scope.resolve(p.path, "write");
if ("error" in w) return `#${p.id} not applied: ${w.error}`;
const exists = fs.existsSync(w.path);
if (exists && !filledEarlier(w.path)) return `#${p.id} not applied: ${w.path} exists now and was not filled by simpleFiles.`;
const font = findFont();
if (!font) return `#${p.id} not applied: no font with the needed letters was found.`;
const entries = JSON.parse(p.content ?? "[]") as FillEntry[];
const result = await fillForm(fs.readFileSync(r.path), entries, font);
if ("errors" in result) return `#${p.id} not applied: ${result.errors.join(" ")}`;
const backup = backupDir(w.grant.folder, p.id);
fs.mkdirSync(backup, { recursive: true });
if (exists) fs.copyFileSync(w.path, path.join(backup, path.basename(w.path)));
fs.writeFileSync(path.join(backup, "change.json"), JSON.stringify({ ...p, file: path.basename(w.path) }, null, 2));
const out = Buffer.from(result.bytes);
writeFileAtomic(w.path, out);
if (sha256(fs.readFileSync(w.path)) !== sha256(out)) return `#${p.id} not applied: the filled copy did not read back as written.`;
const applied = loadApplied();
applied.push({ id: p.id, kind: "form", path: w.path, backup, writtenSha: sha256(out), replaced: exists, reason: p.reason, appliedAt: stamp() });
writeJson(files().applied, applied);
appendLog(`#${p.id} filled ${entries.length} field(s) into ${w.path}: ${p.reason} (backup: ${backup})`);
return `#${p.id} applied: filled ${entries.length} field(s) into ${w.path}`;
}
// Changes to files. The model can only propose: an exact passage to replace (which must appear
// exactly once), or a new file. Nothing touches the disk until the user applies the proposal
// with a /files command. Applying backs up the original first, writes through a temporary file,
// reads the result back to confirm it, and records the change, so /files undo can restore it.
import * as fs from "node:fs";
import * as path from "node:path";
import { copiedFrom } from "./copies";
import { FillEntry, fillForm, findFont, FormField, layoutForm } from "./forms";
import { isBinary } from "./reading";
import { InstallRecord, PluginInfo, RELOAD_NOTE, install, pluginAt, restoreInstall, testedOk } from "./dev";
import { Project, ProjectCommand, runApproved } from "./projects";
import { Grant, isInside, isSecretFile, isWriteProtected, realPath, samePath, Scope } from "./scope";
import { appendLog, dataDir, readJson, sha256, stamp, writeFileAtomic, writeJson } from "./store";
export interface Proposal {
id: number;
kind: "edit" | "create" | "install" | "run" | "copy" | "form";
path: string;
grant: string;
find?: string;
replace?: string;
content?: string;
// For a copy: the source paths and the destination folder.
sources?: string[];
dest?: string;
reason: string;
createdAt: string;
}
interface Applied {
id: number;
kind: "edit" | "create" | "install" | "copy" | "form";
path: string;
backup: string;
writtenSha: string;
install?: InstallRecord;
// For a copy: the files it created, so undo can remove exactly those.
created?: string[];
// For a filled form: whether it replaced an earlier filled copy (kept in backup) or was new.
replaced?: boolean;
reason: string;
appliedAt: string;
}
const MAX_NEW_FILE = 1024 * 1024;
const files = () => ({ pending: path.join(dataDir(), "pending.json"), applied: path.join(dataDir(), "applied.json") });
const loadPending = () => readJson<{ next: number; items: Proposal[] }>(files().pending, { next: 1, items: [] });
const loadApplied = () => readJson<Applied[]>(files().applied, []);
const lf = (s: string) => s.replace(/\r\n/g, "\n");
function occurrences(haystack: string, needle: string): number {
let n = 0;
for (let i = haystack.indexOf(needle); i !== -1; i = haystack.indexOf(needle, i + needle.length)) n++;
return n;
}
function readText(file: string): { text: string; crlf: boolean } | { error: string } {
let buf: Buffer;
try {
buf = fs.readFileSync(file);
} catch {
return { error: `${file} does not exist.` };
}
if (isBinary(buf)) return { error: `${file} is a binary file; only text files can be edited.` };
const text = buf.toString("utf8");
return { text, crlf: text.includes("\r\n") };
}
// The changed passage with two lines of context, in the file's own line numbers.
export function preview(p: Proposal): string {
if (p.kind === "run") return [`#${p.id} run "${p.find}" in ${p.path}`, `command: ${p.replace}`, `reason: ${p.reason}`].join("\n");
if (p.kind === "copy") return [`#${p.id} copy ${(p.sources ?? []).length} file(s) into ${p.dest ?? p.path}`, `reason: ${p.reason}`].join("\n");
if (p.kind === "form") return formPreview(p);
if (p.kind === "install") return [`#${p.id} install ${p.find ?? "the plugin"} into LM Studio from ${p.path}`, `reason: ${p.reason}`].join("\n");
if (p.kind === "create") {
const lines = lf(p.content ?? "").split("\n");
const shown = lines.slice(0, 40).map((l, i) => `+ ${String(i + 1).padStart(4)}| ${l}`);
return [`#${p.id} new file ${p.path}`, `reason: ${p.reason}`, ...shown, ...(lines.length > 40 ? [` ... ${lines.length - 40} more line(s)`] : [])].join("\n");
}
const current = readText(p.path);
const find = lf(p.find ?? "");
const head = [`#${p.id} edit ${p.path}`, `reason: ${p.reason}`];
if ("error" in current) return [...head, `(the file cannot be read now: ${current.error})`].join("\n");
const text = lf(current.text);
const at = text.indexOf(find);
if (at === -1) return [...head, "(the passage is no longer in the file)"].join("\n");
const startLine = text.slice(0, at).split("\n").length;
const all = text.split("\n");
const oldLines = find.split("\n");
const newLines = lf(p.replace ?? "").split("\n");
const out = [...head];
for (let i = Math.max(1, startLine - 2); i < startLine; i++) out.push(` ${String(i).padStart(4)}| ${all[i - 1]}`);
oldLines.forEach((l, i) => out.push(`- ${String(startLine + i).padStart(4)}| ${l}`));
newLines.forEach((l) => out.push(`+ | ${l}`));
const after = startLine + oldLines.length;
for (let i = after; i < Math.min(all.length + 1, after + 2); i++) out.push(` ${String(i).padStart(4)}| ${all[i - 1]}`);
return out.join("\n");
}
function store(p: Omit<Proposal, "id" | "createdAt">): Proposal {
const pending = loadPending();
const proposal: Proposal = { ...p, id: pending.next, createdAt: stamp() };
pending.items.push(proposal);
pending.next++;
writeJson(files().pending, pending);
return proposal;
}
const APPROVAL_NEXT = (id: number) =>
`next: Nothing has changed yet. Propose all of this step's changes now, then ask the user to send /files apply ${id} (with the other numbers). If more rounds will follow, say so, and that /files autoapply <folder> skips asking.`;
export function proposeEdit(scope: Scope, input: string, find: string, replace: string, reason: string): string {
const r = scope.resolve(input, "write");
if ("error" in r) return `status: error\nreason: ${r.error}`;
if (!find) return "status: error\nreason: The passage to replace is empty. Copy the exact text to change from read_file, without the line numbers.";
if (find === replace) return "status: error\nreason: The replacement is the same as the passage, so there is nothing to change.";
const current = readText(r.path);
if ("error" in current) return `status: error\nreason: ${current.error}${current.error.endsWith("does not exist.") ? " To make a new file, use propose_new_file." : ""}`;
const n = occurrences(lf(current.text), lf(find));
if (n === 0) {
const waiting = loadPending().items.find((p) => samePath(p.path, r.path) && lf(p.kind === "create" ? p.content ?? "" : p.replace ?? "").includes(lf(find)));
if (waiting) {
return `status: error\nreason: That passage is only in proposal #${waiting.id}, which has not been applied yet, so it is not in the file.\nnext: Ask the user to apply #${waiting.id} first (/files apply ${waiting.id}), then propose this edit again. Or fold this change into a new version of #${waiting.id}.`;
}
return "status: error\nreason: That passage is not in the file. It must match the file exactly, including spaces and line breaks, and must not include the line numbers that read_file shows.\nnext: Read the file again and copy the passage exactly.";
}
if (n > 1) return `status: error\nreason: That passage appears ${n} times in the file, so it is not clear which to change.\nnext: Include a few more lines around it so it appears only once.`;
const proposal = store({ kind: "edit", path: r.path, grant: r.grant.folder, find, replace, reason: (reason ?? "").trim().slice(0, 300) || "(no reason given)" });
return ["status: proposed", preview(proposal), APPROVAL_NEXT(proposal.id)].join("\n");
}
export function proposeNewFile(scope: Scope, input: string, content: string, reason: string): string {
const r = scope.resolve(input, "write");
if ("error" in r) return `status: error\nreason: ${r.error}`;
if (fs.existsSync(r.path)) return `status: error\nreason: ${r.path} already exists.\nnext: To change it, read it and use propose_edit.`;
if (Buffer.byteLength(content ?? "", "utf8") > MAX_NEW_FILE) return "status: error\nreason: The new file would be larger than 1 MB.";
const copy = copiedFrom(content ?? "", r.path);
if (copy) {
const keepsName = path.basename(copy.source).toLowerCase() === path.basename(r.path).toLowerCase();
return [
"status: refused",
`reason: This is ${copy.exact ? "an exact copy" : "a near copy"} of ${copy.source}, which you read. Retyping a file costs many tokens, can be cut off partway, and can drop lines.`,
`next: Call copy_files with sources ["${copy.source}"] and destination "${path.dirname(r.path)}"; it copies the file byte for byte${keepsName ? "" : ` under its own name, ${path.basename(copy.source)}`}.${copy.exact ? "" : " Then propose the differences with propose_edit once the copy is applied."}`,
].join("\n");
}
const proposal = store({ kind: "create", path: r.path, grant: r.grant.folder, content: content ?? "", reason: (reason ?? "").trim().slice(0, 300) || "(no reason given)" });
return ["status: proposed", preview(proposal), APPROVAL_NEXT(proposal.id)].join("\n");
}
// Proposes installing a plugin's current code into LM Studio. Checked now and again when applied.
export function proposeInstall(scope: Scope, input: string, reason: string): string {
const info = pluginAt(scope, input);
if ("error" in info) return `status: error\nreason: ${info.error}`;
const tested = testedOk(info);
if (!tested.ok) return `status: error\nreason: ${tested.reason}`;
const proposal = store({ kind: "install", path: info.root, grant: info.root, find: info.id, reason: (reason ?? "").trim().slice(0, 300) || "(no reason given)" });
return [
"status: proposed",
preview(proposal),
`checked: ${tested.note}`,
`next: Nothing has been installed yet. Tell the user to send /files apply ${proposal.id} to install it, or /files reject ${proposal.id} to drop it. After it is installed, ${RELOAD_NOTE.charAt(0).toLowerCase() + RELOAD_NOTE.slice(1)} Then try the changed tool yourself and read the plugin's log with run_script (script "lmstudio-log") to see what it did.`,
].join("\n");
}
// Proposes running a project command that changes something (a build, a deploy). Made with
// /files apply; it cannot be undone, so the proposal shows the exact command.
export function proposeRun(project: Project, cmd: ProjectCommand, reason: string): string {
const proposal = store({ kind: "run", path: project.root, grant: project.root, find: cmd.name, replace: cmd.command, reason: (reason ?? "").trim().slice(0, 300) || "(no reason given)" });
return [
"status: proposed",
preview(proposal),
`next: Nothing has run yet. Tell the user what this does and that they can send /files apply ${proposal.id} to run it, or /files reject ${proposal.id} to drop it. A command cannot be undone by /files undo.`,
].join("\n");
}
const MAX_COPY_FILES = 2000;
const MAX_COPY_BYTES = 50 * 1024 * 1024;
// The files a copy would create: a single file, or every file under a folder, skipping the
// folders simpleFiles never touches (dot-folders, node_modules, secret files) and anything the
// user excluded. Returns each source file with where it would land under dest.
function planCopy(scope: Scope, sources: string[], destDir: string): { pairs: Array<{ from: string; to: string }>; bytes: number } | { error: string } {
const pairs: Array<{ from: string; to: string }> = [];
let bytes = 0;
const add = (from: string, to: string): string | null => {
if (isSecretFile(from)) return null; // silently skipped
let stat: fs.Stats;
try {
stat = fs.statSync(from);
} catch {
return `${from} does not exist.`;
}
bytes += stat.size;
if (pairs.length >= MAX_COPY_FILES) return `More than ${MAX_COPY_FILES} files would be copied. Copy a smaller set.`;
if (bytes > MAX_COPY_BYTES) return `More than ${Math.round(MAX_COPY_BYTES / 1024 / 1024)} MB would be copied. Copy a smaller set.`;
pairs.push({ from, to });
return null;
};
const walk = (dir: string, under: string): string | null => {
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch (e) {
return `${dir} could not be read (${(e as NodeJS.ErrnoException).code ?? "error"}).`;
}
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isSymbolicLink()) continue;
if (e.isDirectory()) {
if (e.name.startsWith(".") || e.name.toLowerCase() === "node_modules") continue;
if (scope.excludes.some((x) => isInside(realPath(full), x))) continue;
const err = walk(full, path.join(under, e.name));
if (err) return err;
} else if (e.isFile()) {
const err = add(full, path.join(under, e.name));
if (err) return err;
}
}
return null;
};
for (const src of sources) {
const r = scope.resolve(src, "read");
if ("error" in r) return { error: r.error };
let stat: fs.Stats;
try {
stat = fs.statSync(r.path);
} catch {
return { error: `${r.path} does not exist.` };
}
if (stat.isDirectory()) {
const err = walk(r.path, path.join(destDir, path.basename(r.path)));
if (err) return { error: err };
} else {
const err = add(r.path, path.join(destDir, path.basename(r.path)));
if (err) return { error: err };
}
}
return { pairs, bytes };
}
// Proposes copying files or folders into a destination folder, both inside granted folders.
// The bytes are copied directly on apply; their contents never pass through the model.
export function proposeCopy(scope: Scope, sources: string[], dest: string, reason: string): string {
const srcList = (sources ?? []).map((s) => (s ?? "").trim()).filter(Boolean);
if (!srcList.length) return "status: error\nreason: No source files or folders were given to copy.";
const d = scope.resolve(dest, "write");
if ("error" in d) return `status: error\nreason: ${d.error}`;
if (isWriteProtected(path.relative(d.grant.folder, d.path))) return "status: error\nreason: The destination is inside .git or node_modules, which simpleFiles never writes to.";
const plan = planCopy(scope, srcList, d.path);
if ("error" in plan) return `status: error\nreason: ${plan.error}`;
if (!plan.pairs.length) return "status: error\nreason: Nothing to copy (the sources are empty or only hold files simpleFiles skips).";
const clash = plan.pairs.find((pr) => fs.existsSync(pr.to));
if (clash) return `status: error\nreason: ${clash.to} already exists. Copy to a destination that does not already contain these files, so nothing is overwritten.`;
const proposal = store({ kind: "copy", path: d.path, grant: d.grant.folder, sources: srcList, dest: d.path, reason: (reason ?? "").trim().slice(0, 300) || "(no reason given)" });
const shown = plan.pairs.slice(0, 20).map((pr) => ` ${path.relative(d.grant.folder, pr.to)}`);
return [
"status: proposed",
`#${proposal.id} copy ${plan.pairs.length} file(s) into ${d.path}`,
`reason: ${proposal.reason}`,
...shown,
...(plan.pairs.length > 20 ? [` ... ${plan.pairs.length - 20} more`] : []),
`next: Nothing has been copied yet. Tell the user to send /files apply ${proposal.id} to copy, or /files reject ${proposal.id} to drop it. The files are copied exactly, byte for byte.`,
].join("\n");
}
function applyCopy(scope: Scope, p: Proposal): string {
const d = scope.resolve(p.dest ?? p.path, "write");
if ("error" in d) return `#${p.id} not copied: ${d.error}`;
// Re-plan from the recorded sources so the set is current and the folder layout is preserved,
// and refuse if anything now clashes.
const plan = planCopy(scope, p.sources ?? [], d.path);
if ("error" in plan) return `#${p.id} not copied: ${plan.error}`;
const created: string[] = [];
try {
for (const pr of plan.pairs) {
if (fs.existsSync(pr.to)) return `#${p.id} not copied: ${pr.to} exists now.`;
fs.mkdirSync(path.dirname(pr.to), { recursive: true });
fs.copyFileSync(pr.from, pr.to);
created.push(pr.to);
}
} catch (e) {
for (const f of created) try { fs.unlinkSync(f); } catch { /* best effort */ }
return `#${p.id} not copied: ${(e as Error).message}. Nothing was left behind.`;
}
const applied = loadApplied();
applied.push({ id: p.id, kind: "copy", path: d.path, backup: "", writtenSha: "", created, reason: p.reason, appliedAt: stamp() });
writeJson(files().applied, applied);
appendLog(`#${p.id} copied ${created.length} file(s) into ${d.path}: ${p.reason}`);
return `#${p.id} copied: ${created.length} file(s) into ${d.path}`;
}
export function pendingList(): Proposal[] {
return loadPending().items;
}
function backupDir(grant: string, id: number): string {
const label = `${path.basename(grant) || "root"}-${sha256(grant.toLowerCase()).slice(0, 6)}`;
const when = stamp().replace(/[: ]/g, (c) => (c === " " ? "_" : ""));
return path.join(dataDir(), "backups", label, `${when}_#${id}`);
}
async function applyInstall(scope: Scope, p: Proposal): Promise<string> {
const info = pluginAt(scope, p.path);
if ("error" in info) return `#${p.id} not installed: ${info.error}`;
const result = await install(info as PluginInfo);
if ("error" in result) return `#${p.id} not installed: ${result.error}`;
const applied = loadApplied();
applied.push({ id: p.id, kind: "install", path: info.root, backup: result.backup, writtenSha: "", install: result, reason: p.reason, appliedAt: stamp() });
writeJson(files().applied, applied);
return `#${p.id} installed: ${info.id} ${result.version} into ${result.installed}. ${RELOAD_NOTE} /files undo puts the previous version back.`;
}
async function applyRun(scope: Scope, p: Proposal): Promise<string> {
const result = await runApproved(scope, p.path, p.find ?? "", p.replace ?? "");
if ("error" in result) return `#${p.id} not run: ${result.error}`;
return `#${p.id} ran "${p.find}" in ${p.path}: ${result.passed ? "it succeeded" : "it failed"}\n${result.summary}`;
}
function applyOne(scope: Scope, p: Proposal): string {
// The grant must still stand and the path must still resolve inside it.
const r = scope.resolve(p.path, "write");
if ("error" in r) return `#${p.id} not applied: ${r.error}`;
if (!samePath(p.path, r.path)) return `#${p.id} not applied: the path now leads somewhere else (${r.path}).`;
const rel = path.relative(r.grant.folder, r.path);
const backup = backupDir(r.grant.folder, p.id);
let expected: string;
let mode: number | undefined;
if (p.kind === "edit") {
const current = readText(r.path);
if ("error" in current) return `#${p.id} not applied: ${current.error}`;
const text = lf(current.text);
const find = lf(p.find ?? "");
const n = occurrences(text, find);
if (n !== 1) return `#${p.id} not applied: the passage now appears ${n} times in the file (it changed since the proposal).`;
const at = text.indexOf(find);
const next = text.slice(0, at) + lf(p.replace ?? "") + text.slice(at + find.length);
expected = current.crlf ? next.replace(/\n/g, "\r\n") : next;
mode = fs.statSync(r.path).mode;
fs.mkdirSync(path.dirname(path.join(backup, rel)), { recursive: true });
fs.copyFileSync(r.path, path.join(backup, rel));
} else {
if (fs.existsSync(r.path)) return `#${p.id} not applied: ${r.path} exists now.`;
expected = p.content ?? "";
fs.mkdirSync(backup, { recursive: true });
fs.mkdirSync(path.dirname(r.path), { recursive: true });
}
fs.writeFileSync(path.join(backup, "change.json"), JSON.stringify({ ...p, file: rel }, null, 2));
writeFileAtomic(r.path, expected, mode);
const written = fs.readFileSync(r.path, "utf8");
if (written !== expected) {
if (p.kind === "edit") writeFileAtomic(r.path, fs.readFileSync(path.join(backup, rel)));
return `#${p.id} not applied: the file did not read back as written, so the original was restored.`;
}
const applied = loadApplied();
applied.push({ id: p.id, kind: p.kind as "edit" | "create", path: r.path, backup, writtenSha: sha256(written), reason: p.reason, appliedAt: stamp() });
writeJson(files().applied, applied);
appendLog(`#${p.id} ${p.kind === "edit" ? "edited" : "created"} ${r.path}: ${p.reason} (backup: ${backup})`);
return `#${p.id} applied: ${p.kind === "edit" ? "edited" : "created"} ${r.path}`;
}
// Applies the given proposals, or all of them, in the order they were made.
export async function apply(scope: Scope, which: number[] | "all"): Promise<string> {
const pending = loadPending();
const chosen = which === "all" ? pending.items : pending.items.filter((p) => which.includes(p.id));
const missing = which === "all" ? [] : which.filter((id) => !pending.items.some((p) => p.id === id));
if (!chosen.length) return missing.length ? `No pending proposal #${missing.join(", #")}.` : "Nothing is waiting to be applied.";
const lines: string[] = [];
for (const p of chosen) {
try {
lines.push(
p.kind === "install" ? await applyInstall(scope, p) : p.kind === "run" ? await applyRun(scope, p) : p.kind === "copy" ? applyCopy(scope, p) : p.kind === "form" ? await applyForm(scope, p) : applyOne(scope, p)
);
} catch (e) {
lines.push(`#${p.id} not applied: ${(e as Error).message}`);
}
}
const done = new Set(chosen.map((p) => p.id));
pending.items = pending.items.filter((p) => !done.has(p.id));
writeJson(files().pending, pending);
for (const id of missing) lines.push(`No pending proposal #${id}.`);
return lines.join("\n");
}
export function reject(which: number[] | "all"): string {
const pending = loadPending();
const before = pending.items.length;
pending.items = which === "all" ? [] : pending.items.filter((p) => !which.includes(p.id));
writeJson(files().pending, pending);
const n = before - pending.items.length;
return n ? `Dropped ${n} proposal(s).` : "No matching proposal was waiting.";
}
// Reverses the most recent applied change, if the file is still as simpleFiles left it.
export function undo(): string {
const applied = loadApplied();
const last = applied[applied.length - 1];
if (!last) return "There is no applied change to undo.";
if (last.kind === "copy") {
const created = last.created ?? [];
let removed = 0;
for (const f of created) {
try {
fs.unlinkSync(f);
removed++;
} catch {
// already gone or changed; leave it
}
}
// Remove folders left empty by the removal, deepest first.
const dirs = [...new Set(created.map((f) => path.dirname(f)))].sort((a, b) => b.length - a.length);
for (const dir of dirs) {
try {
if (fs.readdirSync(dir).length === 0) fs.rmdirSync(dir);
} catch {
// not empty or gone
}
}
applied.pop();
writeJson(files().applied, applied);
appendLog(`undid copy #${last.id}: removed ${removed} file(s)`);
return `Undid #${last.id}: removed ${removed} copied file(s). Any of those files you changed since are left in place.`;
}
if (last.kind === "form") {
let current: Buffer | null = null;
try {
current = fs.readFileSync(last.path);
} catch {
// gone
}
if (!current || sha256(current) !== last.writtenSha) {
return `#${last.id} was not undone: ${last.path} has changed since simpleFiles wrote it (or is gone).${last.backup ? ` The earlier copy is in ${last.backup}.` : ""}`;
}
if (last.replaced) {
writeFileAtomic(last.path, fs.readFileSync(path.join(last.backup, path.basename(last.path))));
} else {
const keep = path.join(last.backup, "undone", path.basename(last.path));
fs.mkdirSync(path.dirname(keep), { recursive: true });
fs.copyFileSync(last.path, keep);
fs.unlinkSync(last.path);
}
applied.pop();
writeJson(files().applied, applied);
appendLog(`undid form #${last.id} on ${last.path}`);
return last.replaced ? `Undid #${last.id}: ${last.path} is back to the answers it had before.` : `Undid #${last.id}: the filled copy ${last.path} was removed (a copy is kept in ${path.join(last.backup, "undone")}).`;
}
if (last.kind === "install" && last.install) {
const done = restoreInstall(last.install);
applied.pop();
writeJson(files().applied, applied);
appendLog(`undid install #${last.id}: ${done}`);
return `Undid install #${last.id}: ${done}. ${RELOAD_NOTE}`;
}
let current: Buffer | null = null;
try {
current = fs.readFileSync(last.path);
} catch {
// gone
}
if (!current || sha256(current.toString("utf8")) !== last.writtenSha) {
return `#${last.id} was not undone: ${last.path} has changed since simpleFiles wrote it (or is gone), so restoring the backup could lose that work. The backup is in ${last.backup}.`;
}
const meta = readJson<{ file: string }>(path.join(last.backup, "change.json"), { file: path.basename(last.path) });
if (last.kind === "edit") {
writeFileAtomic(last.path, fs.readFileSync(path.join(last.backup, meta.file)));
} else {
const keep = path.join(last.backup, "undone", meta.file);
fs.mkdirSync(path.dirname(keep), { recursive: true });
fs.copyFileSync(last.path, keep);
fs.unlinkSync(last.path);
}
applied.pop();
writeJson(files().applied, applied);
appendLog(`undid #${last.id} on ${last.path}`);
return last.kind === "edit"
? `Undid #${last.id}: ${last.path} is back as it was before.`
: `Undid #${last.id}: the new file ${last.path} was removed (a copy is kept in ${path.join(last.backup, "undone")}).`;
}
export function appliedList(): Applied[] {
return loadApplied();
}
export type { Grant };
// --- Filled forms -----------------------------------------------------------------------------
export interface FormAnswer {
field: number;
value: string | boolean;
english?: string;
}
const YES = /^(true|yes|y|x|checked|on|1|ja|jā|oui|si|sí|sim|da|tak|ano|igen|kyllä|jah|evet)$/i;
const NO = /^(false|no|n|unchecked|off|0|nein|nē|ne|non|não|nie|nem|ei|nei|hayır|)$/i;
// A copy filled earlier by simpleFiles can be filled further in place; any other form gets a
// new filled copy beside it, so the original is never written to.
function filledEarlier(file: string): boolean {
return loadApplied().some((a) => a.kind === "form" && samePath(a.path, file));
}
export function defaultFilledPath(file: string): string {
const ext = path.extname(file);
return path.join(path.dirname(file), `${path.basename(file, ext)}-filled${ext || ".pdf"}`);
}
// Checks the answers against the form, fills a copy in memory to be sure every answer fits, and
// stores the proposal. The filled copy is written only when the user applies it.
export async function proposeForm(scope: Scope, input: string, answers: FormAnswer[], saveAs: string | undefined, reason: string): Promise<string> {
const r = scope.resolve(input, "read");
if ("error" in r) return `status: error\nreason: ${r.error}`;
if (!/\.pdf$/i.test(r.path)) return "status: error\nreason: Only PDF forms can be filled.";
let bytes: Buffer;
try {
bytes = fs.readFileSync(r.path);
} catch {
return `status: error\nreason: ${r.path} does not exist.`;
}
const layout = await layoutForm(bytes);
if ("error" in layout) return `status: error\nreason: ${layout.error}`;
if (!layout.fields.length) return "status: error\nreason: This PDF has no fillable fields.\nnext: Tell the user the form cannot be filled in place; they can fill it in a PDF editor.";
if (!answers?.length) return "status: error\nreason: No answers were given.\nnext: Call fill_form with answers, each a field number from read_form and its value.";
const problems: string[] = [];
const entries: FillEntry[] = [];
const groups = new Map<string, number>();
for (const a of answers) {
const f: FormField | undefined = layout.fields.find((x) => x.id === Number(a.field));
if (!f) {
problems.push(`#${a.field}: there is no field with that number; read_form numbers them.`);
continue;
}
if (f.type === "signature") {
problems.push(`#${f.id} is a signature, which the user signs by hand; leave it out.`);
continue;
}
let value: string | boolean;
if (f.type === "check" || f.type === "choice") {
if (typeof a.value === "boolean") value = a.value;
else if (YES.test(String(a.value).trim())) value = true;
else if (NO.test(String(a.value).trim())) value = false;
else {
problems.push(`#${f.id} is a box: give true to tick it or false to leave it empty.`);
continue;
}
if (f.type === "choice" && value === true) {
const other = groups.get(f.name);
if (other !== undefined) {
problems.push(`#${other} and #${f.id} are choices of the same question; tick only one.`);
continue;
}
groups.set(f.name, f.id);
}
} else {
if (typeof a.value === "boolean") {
problems.push(`#${f.id} is a text field: give the text to write.`);
continue;
}
value = String(a.value);
}
entries.push({ id: f.id, name: f.name, type: f.type, onValue: f.onValue, value, label: f.option ? `${f.label} / ${f.option}` : f.label, english: a.english?.trim() || undefined });
}
if (problems.length) return ["status: error", "reason: Some answers cannot go in as given; nothing was proposed.", ...problems.map((p) => ` ${p}`), "next: Fix these and call fill_form again with all the answers."].join("\n");
const font = findFont();
if (!font) return "status: error\nreason: No font with the letters these forms need (such as Arial) was found on this computer.";
const trial = await fillForm(bytes, entries, font);
if ("errors" in trial) return ["status: error", "reason: Some answers do not fit; nothing was proposed.", ...trial.errors.map((e) => ` ${e}`), "next: Shorten those answers (or move detail to a supplement form) and call fill_form again with all the answers."].join("\n");
let dest: string;
if (saveAs?.trim()) {
const d = scope.resolve(saveAs, "write");
if ("error" in d) return `status: error\nreason: ${d.error}`;
dest = d.path;
} else {
dest = filledEarlier(r.path) ? r.path : defaultFilledPath(r.path);
}
if (!/\.pdf$/i.test(dest)) return "status: error\nreason: save_as must end in .pdf.";
const w = scope.resolve(dest, "write");
if ("error" in w) return `status: error\nreason: ${w.error}`;
if (fs.existsSync(w.path) && !filledEarlier(w.path)) {
return `status: error\nreason: ${w.path} already exists and was not filled by simpleFiles, so it is not overwritten.\nnext: Give save_as a new file name, or, if that file is the filled copy, fill it directly.`;
}
// A corrected proposal for the same form replaces the ones it covers, so the user is not left
// with stale answers waiting to be applied by mistake.
const ids = new Set(entries.map((e) => e.id));
const stale = pendingList()
.filter((p) => p.kind === "form" && samePath(p.path, w.path) && samePath((p.sources ?? [])[0] ?? "", r.path))
.filter((p) => {
try {
return (JSON.parse(p.content ?? "[]") as FillEntry[]).every((e) => ids.has(e.id));
} catch {
return false;
}
})
.map((p) => p.id);
if (stale.length) reject(stale);
const proposal = store({ kind: "form", path: w.path, grant: w.grant.folder, sources: [r.path], content: JSON.stringify(entries), reason: (reason ?? "").trim().slice(0, 300) || "(no reason given)" });
const replaced = stale.length ? [`replaces: ${stale.map((i) => `#${i}`).join(", ")}, which this proposal covers; those were dropped.`] : [];
return ["status: proposed", formPreview(proposal), ...replaced, APPROVAL_NEXT(proposal.id).replace("Nothing has changed yet.", "Nothing has been written yet. Show the user each answer in their language and in the form's language, so they can check the translation.")].join("\n");
}
function formPreview(p: Proposal): string {
let entries: FillEntry[] = [];
try {
entries = JSON.parse(p.content ?? "[]");
} catch {
// shown without answers
}
const src = (p.sources ?? [])[0] ?? "";
const lines = [
`#${p.id} fill ${entries.length} field(s) of ${src}`,
samePath(src, p.path) ? `into: the same filled copy (its earlier answers are kept)` : `into: ${p.path} (a new copy; the original is not changed)`,
`reason: ${p.reason}`,
];
for (const e of entries) {
const shown = typeof e.value === "boolean" ? (e.value ? "ticked" : "not ticked") : `"${e.value.length > 160 ? `${e.value.slice(0, 160)}...` : e.value}"`;
lines.push(` #${e.id} ${e.label.slice(0, 70) || e.name}: ${shown}${e.english ? ` [${e.english}]` : ""}`);
}
return lines.join("\n");
}
async function applyForm(scope: Scope, p: Proposal): Promise<string> {
const src = (p.sources ?? [])[0];
const r = src ? scope.resolve(src, "read") : { error: "The proposal has no form." };
if ("error" in r) return `#${p.id} not applied: ${r.error}`;
const w = scope.resolve(p.path, "write");
if ("error" in w) return `#${p.id} not applied: ${w.error}`;
const exists = fs.existsSync(w.path);
if (exists && !filledEarlier(w.path)) return `#${p.id} not applied: ${w.path} exists now and was not filled by simpleFiles.`;
const font = findFont();
if (!font) return `#${p.id} not applied: no font with the needed letters was found.`;
const entries = JSON.parse(p.content ?? "[]") as FillEntry[];
const result = await fillForm(fs.readFileSync(r.path), entries, font);
if ("errors" in result) return `#${p.id} not applied: ${result.errors.join(" ")}`;
const backup = backupDir(w.grant.folder, p.id);
fs.mkdirSync(backup, { recursive: true });
if (exists) fs.copyFileSync(w.path, path.join(backup, path.basename(w.path)));
fs.writeFileSync(path.join(backup, "change.json"), JSON.stringify({ ...p, file: path.basename(w.path) }, null, 2));
const out = Buffer.from(result.bytes);
writeFileAtomic(w.path, out);
if (sha256(fs.readFileSync(w.path)) !== sha256(out)) return `#${p.id} not applied: the filled copy did not read back as written.`;
const applied = loadApplied();
applied.push({ id: p.id, kind: "form", path: w.path, backup, writtenSha: sha256(out), replaced: exists, reason: p.reason, appliedAt: stamp() });
writeJson(files().applied, applied);
appendLog(`#${p.id} filled ${entries.length} field(s) into ${w.path}: ${p.reason} (backup: ${backup})`);
return `#${p.id} applied: filled ${entries.length} field(s) into ${w.path}`;
}