u
This commit is contained in:
317
src/tools/patch.js
Normal file
317
src/tools/patch.js
Normal file
@@ -0,0 +1,317 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { defineTool, ToolError } from 'libllmorbiter';
|
||||
import { isReadOnlyDatabasePath } from './vfs.js';
|
||||
import { isSkillPath, renderSkill, parseSkill, SKILL_EXT, skillFileName } from './skill.js';
|
||||
import { prepareVfs, syncVfsToDb } from './vfs.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:
|
||||
'Create, overwrite, or search-and-replace text in a virtual filesystem (VFS) file. ' +
|
||||
'This is the only way to write VFS files — javascript_execute writes stay for bulk binary artifacts.\n' +
|
||||
'Each op needs "file" and "replace"; "search" selects the mode:\n' +
|
||||
' - search omitted/null → create the file, or overwrite it entirely (directories are auto-created).\n' +
|
||||
' - search given → replace the FIRST occurrence of that exact text. "skiplines" skips N lines before\n' +
|
||||
' matching, which is how you target a later duplicate.\n' +
|
||||
'Create a SKILL file (title on line 1, markdown below; exempt from reset_vfs):\n' +
|
||||
'[{"file": "image-pipeline.SKILL", "search": null, "replace": "Image pipeline\\n\\n# Steps\\n1. …"}]\n' +
|
||||
'or, letting the tool render the title line for you:\n' +
|
||||
'[{"file": "image-pipeline.SKILL", "search": null, "title": "Image pipeline", "replace": "# Steps\\n1. …"}]\n' +
|
||||
'Read a file with read_file first and copy its text into "search" verbatim — the match is exact.',
|
||||
strict: false,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['operations'],
|
||||
properties: {
|
||||
operations: {
|
||||
type: 'array',
|
||||
description: 'Operations, applied in order. If one fails, earlier ones are kept and the error names the op.',
|
||||
items: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['file', 'replace'],
|
||||
properties: {
|
||||
file: {
|
||||
type: 'string',
|
||||
description:
|
||||
'VFS path, relative to the root, forward slashes (e.g. "notes.md", "image-pipeline.SKILL"). ' +
|
||||
'SKILL file names may not contain "/" — they live at the root.',
|
||||
},
|
||||
search: {
|
||||
type: ['string', 'null'],
|
||||
description: 'Exact existing text to replace. null/omitted = create or overwrite the whole file.',
|
||||
},
|
||||
replace: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Text to write. For a SKILL file you may pass "{ title, markdown }" and the title line is rendered for you.',
|
||||
},
|
||||
title: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Optional title for a SKILL file written with a raw markdown string in "replace". ' +
|
||||
'Becomes line 1 of the file.',
|
||||
},
|
||||
skiplines: {
|
||||
type: 'integer',
|
||||
description: 'Skip this many lines before searching for "search". Use when the text occurs more than once.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
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").'
|
||||
);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Persist immediately — unlike javascript_execute, there is no trailing sync, so a
|
||||
// text edit would otherwise live only in the on-disk cache until some other tool ran.
|
||||
try {
|
||||
await syncVfsToDb({ googleAuthId, vfsRoot });
|
||||
} catch (err) {
|
||||
console.error('[patch] failed to sync VFS to db:', err.message);
|
||||
throw new ToolError(
|
||||
`Patch applied to disk but could not be persisted: ${err.message}. ` +
|
||||
`Files changed: ${applied.join(', ')}.`
|
||||
);
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user