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 VFS files. Operations apply in order. ' + 'Mode: search null/omitted = create or overwrite (directories auto-created). ' + 'search provided = replace first verbatim occurrence (read file with read_file first). ' + 'SKILL files: root-only ".SKILL" (line 1 is title, or provide title field).', strict: false, parameters: { type: 'object', additionalProperties: false, required: ['operations'], properties: { operations: { type: 'array', description: 'Ordered patch operations.', items: { type: 'object', additionalProperties: false, required: ['file', 'replace'], properties: { file: { type: 'string', description: 'VFS path (e.g. "notes.md", "pipeline.SKILL"). SKILL files must be root-level (no "/").', }, search: { type: ['string', 'null'], description: 'Exact text to replace. null/omitted = overwrite or create file.', }, replace: { type: 'string', description: 'Replacement text.', }, title: { type: 'string', description: 'Optional line-1 title for SKILL files.', }, skiplines: { type: 'integer', description: 'Lines to skip before searching.', }, }, }, }, }, }, 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'; } }