From 663449544ecafb5302b22030b150eac4deab49f0 Mon Sep 17 00:00:00 2001 From: Shivering Ocelot Date: Fri, 9 Oct 2026 17:14:58 -0500 Subject: [PATCH] more tools --- src/tools.js | 335 +++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 309 insertions(+), 26 deletions(-) diff --git a/src/tools.js b/src/tools.js index f0d5240..8957709 100644 --- a/src/tools.js +++ b/src/tools.js @@ -13,6 +13,10 @@ import * as fs from 'fs'; import * as path from 'path'; const MAX_READ_BYTES = 10 * 1024; +const MAX_SEARCH_FILE_BYTES = 1024 * 1024; + +/** Directories never walked by the seeking tools. */ +const DEFAULT_IGNORES = new Set(['.git', 'node_modules']); /* ------------------------------------------------------------------ * * Permissions @@ -46,6 +50,105 @@ function isAllowed(target, allowed) { ); } +/* ------------------------------------------------------------------ * + * Helpers + * ------------------------------------------------------------------ */ + +/** + * Compile a glob into an anchored regular expression. + * + * `*` any run of characters except `/` + * `?` a single character except `/` + * `**` any run of characters, including `/` (also matches none as a directory) + * + * @param {string} glob + * @returns {RegExp} + */ +function globToRegExp(glob) { + let out = ''; + for (let i = 0; i < glob.length; i++) { + const char = glob[i]; + if (char === '*') { + if (glob[i + 1] === '*') { + out += '.*'; + i++; + if (glob[i + 1] === '/') i++; // `**/x` matches `x` + } else { + out += '[^/]*'; + } + } else if (char === '?') { + out += '[^/]'; + } else { + out += char.replace(/[.+^${}()|[\]\\]/g, '\\$&'); + } + } + return new RegExp(`^${out}$`); +} + +/** + * Collect the absolute path of every file beneath `dir`, pruning + * ignored names and anything outside the allow-list. + * + * @param {string} dir + * @param {?string[]} allowed + * @param {string[]} out + */ +function walkFiles(dir, allowed, out) { + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (DEFAULT_IGNORES.has(entry.name)) continue; + const abs = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (isAllowed(abs, allowed)) walkFiles(abs, allowed, out); + } else if (entry.isFile()) { + out.push(abs); + } + } +} + +/** Read a file as UTF-8, or `null` if binary, oversized or unreadable. */ +function readText(abs) { + try { + const stat = fs.statSync(abs); + if (!stat.isFile() || stat.size > MAX_SEARCH_FILE_BYTES) return null; + const buf = fs.readFileSync(abs); + if (buf.includes(0)) return null; // crude binary sniff + return buf.toString('utf8'); + } catch { + return null; + } +} + +/** + * Apply one search/replace edit to `content`, throwing when the search + * does not occur. Shared by `UpdateFile` and `MultiEdit`. + * + * @param {string} content + * @param {{search: string, replace?: string, regex?: boolean, global?: boolean}} edit + * @returns {string} + */ +function applyEdit(content, { search, replace = '', regex, global }) { + if (search == null || search === '') throw new Error('search must not be empty'); + + if (regex) { + const re = new RegExp(search, global ? 'g' : ''); + if (!re.test(content)) throw new Error(`no match for /${search}/`); + return content.replace(re, replace); + } + + if (!content.includes(search)) { + throw new Error(`no match for ${JSON.stringify(search)}`); + } + if (global) return content.split(search).join(replace); + const at = content.indexOf(search); + return content.slice(0, at) + replace + content.slice(at + search.length); +} + /* ------------------------------------------------------------------ * * Tool base class * ------------------------------------------------------------------ */ @@ -87,7 +190,7 @@ export class Tool { } /* ------------------------------------------------------------------ * - * Default tools + * Seeking tools * ------------------------------------------------------------------ */ class ReadFile extends Tool { @@ -122,6 +225,159 @@ class ReadFile extends Tool { } } +class ListDir extends Tool { + static scope = 'read'; + static description = 'List a directory. Directories are suffixed with "/".'; + static parameters = { + type: 'object', + properties: { + path: { type: 'string' }, + }, + required: [], + }; + + run({ path: dir }) { + const abs = this.resolve(dir || '.', 'read'); + + return fs + .readdirSync(abs, { withFileTypes: true }) + .map((entry) => (entry.isDirectory() ? `${entry.name}/` : entry.name)); + } +} + +class FindFiles extends Tool { + static scope = 'read'; + static description = + 'Find files by glob pattern, relative to the root. `*` and `?` stay ' + + 'within a path segment, `**` crosses them.'; + static parameters = { + type: 'object', + properties: { + pattern: { + type: 'string', + description: 'Glob the path (or file name) must match, e.g. "src/**/*.js".', + }, + path: { type: 'string', description: 'Directory to search; defaults to the root.' }, + limit: { type: 'number', description: 'Maximum paths to return (default 200).' }, + }, + required: ['pattern'], + }; + + run({ pattern, path: dir = '.', limit = 200 }) { + const abs = this.resolve(dir, 'read'); + const match = globToRegExp(pattern); + // A bare pattern such as "*.js" is matched against the file name. + const byName = !pattern.includes('/'); + + const files = []; + walkFiles(abs, this.allow, files); + + const hits = files + .map((file) => path.relative(this.root, file)) + .filter((rel) => match.test(byName ? path.basename(rel) : rel)) + .sort(); + + if (hits.length > limit) { + return [...hits.slice(0, limit), `… ${hits.length - limit} more`]; + } + return hits; + } +} + +class SearchFiles extends Tool { + static scope = 'read'; + static description = + 'Search file contents with a regular expression. Returns lines as ' + + '"path:line: text".'; + static parameters = { + type: 'object', + properties: { + pattern: { type: 'string', description: 'JavaScript regular expression.' }, + path: { type: 'string', description: 'Directory to search; defaults to the root.' }, + glob: { type: 'string', description: 'Restrict to matching files (default "**/*").' }, + ignoreCase: { type: 'boolean' }, + context: { type: 'number', description: 'Lines of context around each match.' }, + maxResults: { type: 'number', description: 'Maximum result lines (default 100).' }, + output: { + type: 'string', + enum: ['content', 'files', 'count'], + description: '"content" (default), "files" for names, "count" per file.', + }, + }, + required: ['pattern'], + }; + + run({ + pattern, + path: dir = '.', + glob = '**/*', + ignoreCase, + context = 0, + maxResults = 100, + output = 'content', + }) { + const abs = this.resolve(dir, 'read'); + + let re; + try { + re = new RegExp(pattern, ignoreCase ? 'i' : ''); + } catch (err) { + throw new Error(`invalid pattern: ${err.message}`); + } + + const match = globToRegExp(glob); + const byName = !glob.includes('/'); + + const files = []; + walkFiles(abs, this.allow, files); + + const selected = files + .map((file) => path.relative(this.root, file)) + .filter((rel) => match.test(byName ? path.basename(rel) : rel)) + .sort(); + + const results = []; + for (const rel of selected) { + if (results.length >= maxResults) break; + + const text = readText(path.resolve(this.root, rel)); + if (text === null) continue; + + const lines = text.split('\n'); + const hits = []; + for (let i = 0; i < lines.length; i++) if (re.test(lines[i])) hits.push(i); + if (hits.length === 0) continue; + + if (output === 'files') { + results.push(rel); + continue; + } + if (output === 'count') { + results.push(`${rel}: ${hits.length}`); + continue; + } + + const emit = new Set(); + for (const i of hits) { + const from = Math.max(0, i - context); + const to = Math.min(lines.length - 1, i + context); + for (let j = from; j <= to; j++) emit.add(j); + } + for (const j of [...emit].sort((a, b) => a - b)) { + if (results.length >= maxResults) break; + results.push(`${rel}:${j + 1}: ${lines[j]}`); + } + } + + if (results.length >= maxResults) results.push('… truncated'); + return results.length > 0 ? results : ['no matches']; + } +} + +/* ------------------------------------------------------------------ * + * Updating tools + * ------------------------------------------------------------------ */ + class WriteFile extends Tool { static scope = 'write'; static description = 'Write the entire content of a file.'; @@ -144,54 +400,73 @@ class WriteFile extends Tool { class UpdateFile extends Tool { static scope = 'write'; - static description = 'Replace `search` with `replace` in a file.'; + static description = + 'Replace `search` with `replace` in a file. Fails when the search ' + + 'does not occur.'; static parameters = { type: 'object', properties: { path: { type: 'string' }, search: { type: 'string' }, replace: { type: 'string' }, - global: { type: 'boolean' }, + global: { type: 'boolean', description: 'Replace every occurrence.' }, + regex: { type: 'boolean', description: 'Treat `search` as a regular expression.' }, }, required: ['path', 'search'], }; - run({ path: file, search, replace = '', global }) { + run({ path: file, search, replace = '', global, regex }) { const abs = this.resolve(file, 'write'); const content = fs.readFileSync(abs, 'utf8'); - - let updated; - if (global) { - updated = content.split(search).join(replace); - } else { - const at = content.indexOf(search); - updated = - at < 0 - ? content - : content.slice(0, at) + replace + content.slice(at + search.length); - } - fs.writeFileSync(abs, updated); + fs.writeFileSync(abs, applyEdit(content, { search, replace, global, regex })); return `updated ${file}`; } } -class ListDir extends Tool { - static scope = 'read'; - static description = 'List a directory. Directories are suffixed with "/".'; +class MultiEdit extends Tool { + static scope = 'write'; + static description = + 'Apply several search/replace edits to one file atomically: either ' + + 'every edit matches and the file is written, or nothing changes.'; static parameters = { type: 'object', properties: { path: { type: 'string' }, + edits: { + type: 'array', + description: 'Edits applied in order, each seeing the previous result.', + items: { + type: 'object', + properties: { + search: { type: 'string' }, + replace: { type: 'string' }, + global: { type: 'boolean' }, + regex: { type: 'boolean' }, + }, + required: ['search'], + }, + }, }, - required: [], + required: ['path', 'edits'], }; - run({ path: dir }) { - const abs = this.resolve(dir || '.', 'read'); + run({ path: file, edits }) { + const abs = this.resolve(file, 'write'); + const original = fs.readFileSync(abs, 'utf8'); - return fs - .readdirSync(abs, { withFileTypes: true }) - .map((entry) => (entry.isDirectory() ? `${entry.name}/` : entry.name)); + const list = Array.isArray(edits) ? edits : []; + let content = original; + list.forEach((edit, i) => { + try { + content = applyEdit(content, edit); + } catch (err) { + throw new Error(`edit ${i + 1} failed: ${err.message}`); + } + }); + + if (content === original) return `no changes to ${file}`; + fs.writeFileSync(abs, content); + return `updated ${file} (${list.length} edits)`; } } @@ -201,7 +476,15 @@ class ListDir extends Tool { /** Defaults, keyed by the name inferred from each class. */ const DEFAULT_TOOLS = Object.fromEntries( - [ReadFile, WriteFile, UpdateFile, ListDir].map((Cls) => [kebab(Cls.name), Cls]) + [ + ReadFile, + ListDir, + FindFiles, + SearchFiles, + WriteFile, + UpdateFile, + MultiEdit, + ].map((Cls) => [kebab(Cls.name), Cls]) ); /**