Files
dashback/src/tools/patch.js
2026-11-03 06:20:06 +01:00

298 lines
13 KiB
JavaScript

import fs from 'fs';
import path from 'path';
import { defineTool, ToolError } from 'libllmorbiter';
import { isReadOnlyDatabasePath, isProtectedAdminVfsPath, prepareVfs } from './vfs.js';
import { requireSelectedCompanyAdmin } from '../db/companyuser.js';
import { isSkillPath, renderSkill, parseSkill, SKILL_EXT, skillFileName } from './skill.js';
import { TOOL_DESCRIPTIONS, PATCH_PARAMETER_DESCRIPTIONS } from './prompts.js';
import { getStore } from '../db/store.js';
/**
* patch — applies edit operations to files in the virtual filesystem (VFS).
*
* Deliberately the ONLY write path into the VFS. It is the counterpart to `read_file`:
* every `search` string is validated against the file as `read_file` returned it, and the
* resulting file is synced straight to `modelvfs` so the write survives a server restart.
*
* SKILL files get first-class support:
* - A `search` that does not match returns the file's current title so the model can see
* what it was actually editing.
* - `create`/`update` variants accept `{ title, markdown }` for a SKILL file, so a skill can
* be authored without hand-counting the leading blank line. Writing a title through this
* path always re-emits it as line 1, which is what `read_file` and `find_files` expect.
*/
const MAX_CHARS_PER_FILE = 2_000_000;
/**
* Extracts a `{ title, markdown }` object out of an op's `replace` value, if the caller
* supplied one. A SKILL op may pass either the object form or a raw markdown string.
*
* @param {object} op
* @returns {{ title?: string, markdown: string }|null}
*/
function extractSkillBody(op) {
const raw = op.replace;
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
if (typeof raw.markdown === 'string' || typeof raw.title === 'string') {
return {
title: typeof raw.title === 'string' ? raw.title : undefined,
markdown: typeof raw.markdown === 'string' ? raw.markdown : '',
};
}
return null;
}
if (typeof raw === 'string' && typeof op.title === 'string') {
return { title: op.title, markdown: raw };
}
return null;
}
/**
* Counts lines in a string the way `read_file` does, so `skiplines` offsets line up with
* the line numbers the model saw.
* @param {string} text
* @returns {string[]}
*/
function toLines(text) {
if (!text) return [];
const lines = text.split('\n');
if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
return lines;
}
/**
* Creates the patch tool bound to a company's virtual filesystem.
* @param {{ googleAuthId: number }} ctx
*/
export function createPatchTool({ googleAuthId }) {
return defineTool({
name: 'patch',
description: TOOL_DESCRIPTIONS.patch,
strict: false,
parameters: {
type: 'object',
additionalProperties: false,
required: ['operations'],
properties: {
operations: {
type: 'array',
description: PATCH_PARAMETER_DESCRIPTIONS.operations,
items: {
type: 'object',
additionalProperties: false,
required: ['file', 'replace'],
properties: {
file: {
type: 'string',
description: PATCH_PARAMETER_DESCRIPTIONS.file,
},
search: {
type: ['string', 'null'],
description: PATCH_PARAMETER_DESCRIPTIONS.search,
},
replace: {
type: 'string',
description: PATCH_PARAMETER_DESCRIPTIONS.replace,
},
title: {
type: ['string', 'null'],
description: PATCH_PARAMETER_DESCRIPTIONS.title,
},
skiplines: {
type: ['integer', 'null'],
description: PATCH_PARAMETER_DESCRIPTIONS.skiplines,
},
},
},
},
},
},
invoke: async ({ operations } = {}) => {
if (!Array.isArray(operations) || operations.length === 0) {
throw new ToolError('"operations" must be a non-empty array of objects.');
}
const vfsRoot = await prepareVfs({ googleAuthId });
/** Resolve + guard a VFS-relative path. Mirrors read_file exactly. */
const resolveInVfs = (p) => {
if (typeof p !== 'string' || !p.trim()) {
throw new ToolError('Each operation needs a non-empty "file" path.');
}
const clean = p.trim().replace(/^([a-zA-Z]:|\/)+/, '').replace(/^\.\/+/, '');
if (!clean) {
throw new ToolError('Each operation needs a non-empty "file" path.');
}
const abs = path.resolve(vfsRoot, clean);
const rel = path.relative(vfsRoot, abs);
if (rel.startsWith('..') || path.isAbsolute(rel)) {
throw new ToolError(`Access denied: path "${p}" escapes virtual filesystem boundary.`);
}
return { clean, abs };
};
// ── Validate every op before touching the filesystem ───────────
// Fast-fail means a malformed batch never leaves the VFS half-written.
const validated = [];
for (let i = 0; i < operations.length; i += 1) {
const op = operations[i];
if (!op || typeof op !== 'object') {
throw new ToolError(`Operation at index ${i} is not an object.`);
}
const { clean, abs } = resolveInVfs(op.file);
if (op.replace === undefined || op.replace === null) {
throw new ToolError(`Operation at index ${i} ("${clean}") is missing "replace".`);
}
// Writing into the read-only database image space is refused here rather than
// deep inside a file write, so the model gets the message before anything moved.
if (isReadOnlyDatabasePath(clean)) {
throw new ToolError(
`Access denied: "${clean}" matches the read-only database image pattern and cannot be written. ` +
'Save artifacts under a custom filename (e.g. "candidate.avif" or "images/candidate.avif").'
);
}
if (isProtectedAdminVfsPath(clean)) {
await requireSelectedCompanyAdmin(googleAuthId);
}
// A SKILL file must be flat — reject an attempted nested path up front.
const looksSkillByExt = clean.toLowerCase().endsWith(SKILL_EXT.toLowerCase());
if (looksSkillByExt && clean.includes('/')) {
throw new ToolError(
`"${clean}" is not a legal SKILL path: SKILL files are flat and must live at the VFS root. ` +
`Use "${skillFileName(path.posix.basename(clean))}" instead.`
);
}
if (looksSkillByExt && !isSkillPath(clean)) {
throw new ToolError(
`"${clean}" is not a legal SKILL file name: it needs a non-empty name before "${SKILL_EXT}".`
);
}
const skillBody = looksSkillByExt ? extractSkillBody(op) : null;
if (looksSkillByExt && !skillBody && typeof op.replace !== 'string') {
throw new ToolError(
`Operation at index ${i} ("${clean}") must set "replace" to a string or a { title, markdown } object.`
);
}
validated.push({ index: i, op, clean, abs, isSkill: looksSkillByExt, skillBody });
}
// ── Apply ──────────────────────────────────────────────────────
const applied = [];
const touchedSkills = new Set();
for (const v of validated) {
const { op, clean, abs, isSkill } = v;
const isCreate = op.search === null || op.search === undefined || op.search === '';
const exists = fs.existsSync(abs);
let updated;
if (isCreate) {
updated = isSkill && v.skillBody
? renderSkill({ title: v.skillBody.title ?? readExistingTitle(abs, exists), markdown: v.skillBody.markdown })
: typeof op.replace === 'string'
? op.replace
: JSON.stringify(op.replace, null, 2);
if (!updated.endsWith('\n')) updated += '\n';
} else {
if (!exists) {
throw new ToolError(
`File not found: "${clean}". Create it first with a "search": null operation, ` +
'or read it with read_file to get its exact current text.'
);
}
if (fs.statSync(abs).size > MAX_CHARS_PER_FILE) {
throw new ToolError(
`"${clean}" is larger than the ${MAX_CHARS_PER_FILE} character patch limit. ` +
'Use javascript_execute with fs.readFileSync to work on it.'
);
}
const original = fs.readFileSync(abs, 'utf8');
// CRLF-tolerant matching: a model copying text out of a read_file result
// must not fail purely because the stored file uses Windows line endings.
const haystack = original.includes('\r\n') ? original.replace(/\r\n/g, '\n') : original;
const needle = typeof op.search === 'string' ? op.search.replace(/\r\n/g, '\n') : '';
if (!needle) {
throw new ToolError(`Operation at index ${v.index} ("${clean}") has an empty "search". Use "search": null to overwrite the file.`);
}
let offset = 0;
const skip = Number.isInteger(op.skiplines) && op.skiplines > 0 ? op.skiplines : 0;
if (skip > 0) {
const lines = toLines(haystack);
if (skip >= lines.length) {
throw new ToolError(
`skiplines=${skip} is past the end of "${clean}" (${lines.length} lines).`
);
}
offset = lines.slice(0, skip).join('\n').length + 1;
}
const at = haystack.indexOf(needle, offset);
if (at === -1) {
const detail = isSkill
? ` Current title: "${readExistingTitle(abs, true)}".`
: '';
throw new ToolError(
`SEARCH/REPLACE failed in "${clean}": the search text was not found` +
(skip > 0 ? ` after skipping ${skip} line(s)` : '') + `.${detail} ` +
'Read the file again with read_file and copy its exact current text into "search".'
);
}
const replacement = isSkill && v.skillBody
? renderSkill({ title: v.skillBody.title, markdown: v.skillBody.markdown })
: (typeof op.replace === 'string' ? op.replace : JSON.stringify(op.replace, null, 2));
updated = haystack.slice(0, at) + replacement + haystack.slice(at + needle.length);
// Restore the file's original line endings if we normalised to match.
if (original.includes('\r\n')) {
updated = updated.replace(/\r?\n/g, '\r\n');
}
}
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, updated, 'utf8');
applied.push(`${isCreate ? (exists ? 'OVERWRITE' : 'CREATE') : 'PATCH'} ${clean}`);
if (isSkill) touchedSkills.add(clean);
}
const result = {
success: true,
applied,
filesChanged: applied.length,
};
if (touchedSkills.size > 0) {
result.skills = [...touchedSkills];
result.note = 'SKILL files are exempt from reset_vfs and persist until explicitly deleted.';
}
return result;
},
});
}
/**
* Reads a SKILL file's title line, tolerating a missing file.
* @param {string} abs
* @param {boolean} exists
* @returns {string}
*/
function readExistingTitle(abs, exists) {
if (!exists) return 'Untitled';
try {
return parseSkill(fs.readFileSync(abs, 'utf8')).title;
} catch {
return 'Untitled';
}
}