src / autoapply.ts
src / autoapply.ts
// Auto-apply: for a while, in one folder the user names, the model's edits, new files, copies and
// builds take effect without a /files apply each time. Without it, a task that changes many files
// stops after every batch to ask, and the user ends up approving the same kind of change again and
// again. Every change is still backed up, logged and reversible with /files undo. Plugin installs
// and other commands always ask. Only the user can turn it on, with a /files command; it ends on
// its own after a set time or a set number of changes.
import * as path from "node:path";
import { apply, pendingList } from "./edits";
import { folderInText, isInside, realPath, samePath, Scope, tooBroad } from "./scope";
import { dataDir, readJson, writeJson } from "./store";
interface Window {
folder: string;
until: number;
used: number;
}
export const MAX_CHANGES = 60;
const DEFAULT_HOURS = 2;
const MAX_HOURS = 12;
// Project commands that only produce build output, so they may run with the edits that need them.
const BUILD_NAME = /^(build|compile|bundle)([:_-].+)?$/i;
const file = () => path.join(dataDir(), "autoapply.json");
const now = () => Date.now();
const active = (): Window[] => readJson<Window[]>(file(), []).filter((w) => w.until > now() && w.used < MAX_CHANGES);
const clock = (ms: number) => new Date(ms).toTimeString().slice(0, 5);
export function autoApplyStatus(): string[] {
return active().map((w) => `${w.folder} until ${clock(w.until)}, ${MAX_CHANGES - w.used} changes left`);
}
// How long a window lasts: "3h", "90m", "1.5 hours"; the default when none is given.
function duration(text: string): number | { error: string } {
const t = text.trim();
if (!t) return DEFAULT_HOURS * 3600_000;
const m = /^(\d+(?:\.\d+)?)\s*(h|hr|hrs|hours?|m|min|mins|minutes?)\b/i.exec(t);
if (!m) return { error: `"${t}" is not a length of time. Use, for example, 2h or 30m.` };
const ms = Number(m[1]) * (/^m/i.test(m[2]) ? 60_000 : 3600_000);
if (ms < 60_000) return { error: "That is too short." };
return Math.min(ms, MAX_HOURS * 3600_000);
}
// /files autoapply <folder> [time] | off [folder] | (nothing, to show what is on)
export function autoApplyCommand(scope: Scope, arg: string): string {
const text = arg.trim();
if (!text) {
const on = autoApplyStatus();
if (on.length) return `Auto-apply is on for:\n${on.map((l) => ` ${l}`).join("\n")}\nSend /files autoapply off to end it.`;
// With no folder named, the folder the waiting changes are in is the one meant, when there is just one.
const folders = [...new Set(pendingList().filter((p) => p.kind !== "install" && p.kind !== "run").map((p) => (p.kind === "copy" && p.dest ? p.dest : path.dirname(p.path))))];
if (folders.length === 1) {
const waiting = pendingList().map((p) => `#${p.id}`).join(", ");
return `${autoApplyCommand(scope, folders[0])}\nAlready waiting from before: ${waiting}. Send /files apply to apply them; new changes there apply on their own.`;
}
return folders.length
? `Auto-apply is off. Changes are waiting in more than one folder, so name one: /files autoapply <folder>.`
: "Auto-apply is off. Send /files autoapply <folder> to turn it on for that folder for 2 hours, or add a time, such as 4h.";
}
const off = /^off\b\s*(.*)$/i.exec(text);
if (off) {
const all = readJson<Window[]>(file(), []);
const named = off[1].trim() ? folderInText(off[1]) : null;
const left = named ? all.filter((w) => !samePath(w.folder, named.folder)) : [];
writeJson(file(), left.filter((w) => w.until > now()));
return named ? `Auto-apply is off for ${named.folder}. Changes there wait for /files apply again.` : "Auto-apply is off. Every change waits for /files apply again.";
}
const named = folderInText(text);
if (!named) return `${text} is not a folder.`;
const folder = named.folder;
const r = scope.resolve(folder, "write");
if ("error" in r) return r.error;
if (tooBroad(folder)) return `${folder} is too broad. Name the project folder.`;
const ms = duration(named.extra);
if (typeof ms !== "number") return ms.error;
const all = readJson<Window[]>(file(), []).filter((w) => w.until > now() && !samePath(w.folder, folder));
const until = now() + ms;
all.push({ folder, until, used: 0 });
writeJson(file(), all);
return [
`Auto-apply is on for ${folder} until ${clock(until)}, for up to ${MAX_CHANGES} changes.`,
"Edits, new files, copies and builds there take effect without /files apply. Each is backed up and listed by /files status; /files undo reverses the last one.",
"Plugin installs and other commands still ask. /files autoapply off ends it early.",
].join("\n");
}
function windowFor(p: { kind: string; path: string; find?: string }): Window | null {
if (p.kind === "install") return null;
if (p.kind === "run" && !BUILD_NAME.test(p.find ?? "")) return null;
const at = realPath(p.path);
return active().find((w) => isInside(at, w.folder)) ?? null;
}
// Called with a tool's "status: proposed" result. When auto-apply covers the proposal, applies it
// at once and says so; otherwise returns the result unchanged.
export async function maybeAutoApply(scope: Scope, out: string): Promise<string> {
if (!out.startsWith("status: proposed")) return out;
const id = Number(/#(\d+)/.exec(out)?.[1]);
const p = pendingList().find((x) => x.id === id);
if (!p) return out;
const w = windowFor(p);
if (!w) return out;
const result = await apply(scope, [id]);
const all = readJson<Window[]>(file(), []);
const mine = all.find((x) => samePath(x.folder, w.folder));
if (mine) mine.used++;
writeJson(file(), all);
const made = p.kind === "run" ? /^#\d+ ran /.test(result) : /^#\d+ (applied|copied)/.test(result);
const passed = p.kind !== "run" || /it succeeded/.test(result);
const left = Math.max(0, MAX_CHANGES - (mine?.used ?? w.used + 1));
const window = `Auto-apply is on for ${w.folder} until ${clock(w.until)} (${left} changes left)`;
return [
!made ? "status: error" : passed ? "status: applied" : "status: failed",
result,
!made
? "next: The change could not be made. Read the file again and propose it again."
: passed
? `note: ${window}, so this is already done. Carry on with the task; do not ask the user to apply it. The user can reverse the last change with /files undo.`
: `note: ${window}. The command ran and failed; fix the cause and run it again.`,
].join("\n");
}
// Auto-apply: for a while, in one folder the user names, the model's edits, new files, copies and
// builds take effect without a /files apply each time. Without it, a task that changes many files
// stops after every batch to ask, and the user ends up approving the same kind of change again and
// again. Every change is still backed up, logged and reversible with /files undo. Plugin installs
// and other commands always ask. Only the user can turn it on, with a /files command; it ends on
// its own after a set time or a set number of changes.
import * as path from "node:path";
import { apply, pendingList } from "./edits";
import { folderInText, isInside, realPath, samePath, Scope, tooBroad } from "./scope";
import { dataDir, readJson, writeJson } from "./store";
interface Window {
folder: string;
until: number;
used: number;
}
export const MAX_CHANGES = 60;
const DEFAULT_HOURS = 2;
const MAX_HOURS = 12;
// Project commands that only produce build output, so they may run with the edits that need them.
const BUILD_NAME = /^(build|compile|bundle)([:_-].+)?$/i;
const file = () => path.join(dataDir(), "autoapply.json");
const now = () => Date.now();
const active = (): Window[] => readJson<Window[]>(file(), []).filter((w) => w.until > now() && w.used < MAX_CHANGES);
const clock = (ms: number) => new Date(ms).toTimeString().slice(0, 5);
export function autoApplyStatus(): string[] {
return active().map((w) => `${w.folder} until ${clock(w.until)}, ${MAX_CHANGES - w.used} changes left`);
}
// How long a window lasts: "3h", "90m", "1.5 hours"; the default when none is given.
function duration(text: string): number | { error: string } {
const t = text.trim();
if (!t) return DEFAULT_HOURS * 3600_000;
const m = /^(\d+(?:\.\d+)?)\s*(h|hr|hrs|hours?|m|min|mins|minutes?)\b/i.exec(t);
if (!m) return { error: `"${t}" is not a length of time. Use, for example, 2h or 30m.` };
const ms = Number(m[1]) * (/^m/i.test(m[2]) ? 60_000 : 3600_000);
if (ms < 60_000) return { error: "That is too short." };
return Math.min(ms, MAX_HOURS * 3600_000);
}
// /files autoapply <folder> [time] | off [folder] | (nothing, to show what is on)
export function autoApplyCommand(scope: Scope, arg: string): string {
const text = arg.trim();
if (!text) {
const on = autoApplyStatus();
if (on.length) return `Auto-apply is on for:\n${on.map((l) => ` ${l}`).join("\n")}\nSend /files autoapply off to end it.`;
// With no folder named, the folder the waiting changes are in is the one meant, when there is just one.
const folders = [...new Set(pendingList().filter((p) => p.kind !== "install" && p.kind !== "run").map((p) => (p.kind === "copy" && p.dest ? p.dest : path.dirname(p.path))))];
if (folders.length === 1) {
const waiting = pendingList().map((p) => `#${p.id}`).join(", ");
return `${autoApplyCommand(scope, folders[0])}\nAlready waiting from before: ${waiting}. Send /files apply to apply them; new changes there apply on their own.`;
}
return folders.length
? `Auto-apply is off. Changes are waiting in more than one folder, so name one: /files autoapply <folder>.`
: "Auto-apply is off. Send /files autoapply <folder> to turn it on for that folder for 2 hours, or add a time, such as 4h.";
}
const off = /^off\b\s*(.*)$/i.exec(text);
if (off) {
const all = readJson<Window[]>(file(), []);
const named = off[1].trim() ? folderInText(off[1]) : null;
const left = named ? all.filter((w) => !samePath(w.folder, named.folder)) : [];
writeJson(file(), left.filter((w) => w.until > now()));
return named ? `Auto-apply is off for ${named.folder}. Changes there wait for /files apply again.` : "Auto-apply is off. Every change waits for /files apply again.";
}
const named = folderInText(text);
if (!named) return `${text} is not a folder.`;
const folder = named.folder;
const r = scope.resolve(folder, "write");
if ("error" in r) return r.error;
if (tooBroad(folder)) return `${folder} is too broad. Name the project folder.`;
const ms = duration(named.extra);
if (typeof ms !== "number") return ms.error;
const all = readJson<Window[]>(file(), []).filter((w) => w.until > now() && !samePath(w.folder, folder));
const until = now() + ms;
all.push({ folder, until, used: 0 });
writeJson(file(), all);
return [
`Auto-apply is on for ${folder} until ${clock(until)}, for up to ${MAX_CHANGES} changes.`,
"Edits, new files, copies and builds there take effect without /files apply. Each is backed up and listed by /files status; /files undo reverses the last one.",
"Plugin installs and other commands still ask. /files autoapply off ends it early.",
].join("\n");
}
function windowFor(p: { kind: string; path: string; find?: string }): Window | null {
if (p.kind === "install") return null;
if (p.kind === "run" && !BUILD_NAME.test(p.find ?? "")) return null;
const at = realPath(p.path);
return active().find((w) => isInside(at, w.folder)) ?? null;
}
// Called with a tool's "status: proposed" result. When auto-apply covers the proposal, applies it
// at once and says so; otherwise returns the result unchanged.
export async function maybeAutoApply(scope: Scope, out: string): Promise<string> {
if (!out.startsWith("status: proposed")) return out;
const id = Number(/#(\d+)/.exec(out)?.[1]);
const p = pendingList().find((x) => x.id === id);
if (!p) return out;
const w = windowFor(p);
if (!w) return out;
const result = await apply(scope, [id]);
const all = readJson<Window[]>(file(), []);
const mine = all.find((x) => samePath(x.folder, w.folder));
if (mine) mine.used++;
writeJson(file(), all);
const made = p.kind === "run" ? /^#\d+ ran /.test(result) : /^#\d+ (applied|copied)/.test(result);
const passed = p.kind !== "run" || /it succeeded/.test(result);
const left = Math.max(0, MAX_CHANGES - (mine?.used ?? w.used + 1));
const window = `Auto-apply is on for ${w.folder} until ${clock(w.until)} (${left} changes left)`;
return [
!made ? "status: error" : passed ? "status: applied" : "status: failed",
result,
!made
? "next: The change could not be made. Read the file again and propose it again."
: passed
? `note: ${window}, so this is already done. Carry on with the task; do not ask the user to apply it. The user can reverse the last change with /files undo.`
: `note: ${window}. The command ran and failed; fix the cause and run it again.`,
].join("\n");
}