298 lines
13 KiB
JavaScript
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';
|
|
}
|
|
} |