upd
This commit is contained in:
33
src/config.js
Normal file
33
src/config.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration — loads .env and resolves runtime settings.
|
||||||
|
*
|
||||||
|
* Config via .env (place in the fido project root or the working directory):
|
||||||
|
* OPENROUTER_API_KEY=sk-or-...
|
||||||
|
* MODEL=xiaomi/mimo-v2.6-flash
|
||||||
|
* UNDO_FILE=.fido-undo.json
|
||||||
|
* ROOT_DIR=. (optional, defaults to cwd)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
// Load .env from the fido project root (one level up from src/)
|
||||||
|
dotenv.config({ path: path.resolve(__dirname, '..', '.env') });
|
||||||
|
// Also try cwd-relative .env
|
||||||
|
dotenv.config({ path: path.resolve(process.cwd(), '.env') });
|
||||||
|
|
||||||
|
export const API_KEY = process.env.OPENROUTER_API_KEY;
|
||||||
|
export const MODEL = process.env.MODEL || 'xiaomi/mimo-v2.6-flash';
|
||||||
|
export const UNDO_FILE = process.env.UNDO_FILE || '.fido-undo.json';
|
||||||
|
|
||||||
|
// Root dir: --dir flag wins, then env ROOT_DIR, then cwd
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const dirFlagIdx = args.indexOf('--dir');
|
||||||
|
export const ROOT_DIR = path.resolve(
|
||||||
|
(dirFlagIdx !== -1 && args[dirFlagIdx + 1]) || process.env.ROOT_DIR || process.cwd()
|
||||||
|
);
|
||||||
83
src/dir-listing.js
Normal file
83
src/dir-listing.js
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Directory listing helpers — .gitignore-aware listing with pagination.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import ignore from 'ignore';
|
||||||
|
import { ROOT_DIR } from './config.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an `ignore` filter that respects all .gitignore files found between
|
||||||
|
* ROOT_DIR and `dir` (inclusive), plus a hardcoded node_modules rule.
|
||||||
|
* Paths fed to ig.ignores() must be relative to ROOT_DIR and use forward slashes.
|
||||||
|
*/
|
||||||
|
export function buildIgnoreFilter(dir) {
|
||||||
|
const ig = ignore().add('node_modules/');
|
||||||
|
// Collect .gitignore files from ROOT_DIR down to dir
|
||||||
|
const rel = path.relative(ROOT_DIR, dir);
|
||||||
|
const parts = rel === '' ? [] : rel.split(path.sep);
|
||||||
|
const ancestors = [ROOT_DIR];
|
||||||
|
for (let i = 0; i < parts.length; i++) {
|
||||||
|
ancestors.push(path.join(ROOT_DIR, ...parts.slice(0, i + 1)));
|
||||||
|
}
|
||||||
|
for (const ancestor of ancestors) {
|
||||||
|
const gi = path.join(ancestor, '.gitignore');
|
||||||
|
if (fs.existsSync(gi)) {
|
||||||
|
try {
|
||||||
|
const rules = fs.readFileSync(gi, 'utf8');
|
||||||
|
// Rules in a nested .gitignore are relative to that directory;
|
||||||
|
// prefix them so they work relative to ROOT_DIR.
|
||||||
|
const prefix = path.relative(ROOT_DIR, ancestor).replace(/\\/g, '/');
|
||||||
|
const prefixed = rules
|
||||||
|
.split('\n')
|
||||||
|
.filter(l => l.trim() && !l.startsWith('#'))
|
||||||
|
.map(l => (prefix ? `${prefix}/${l.trim()}` : l.trim()));
|
||||||
|
ig.add(prefixed);
|
||||||
|
} catch { /* ignore read errors */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List entries of a directory, optionally recursive, respecting .gitignore.
|
||||||
|
* Returns { entries: Array<{path,type,size}>, total, truncated }
|
||||||
|
* - type: 'd' for directory, 'f' for file
|
||||||
|
* - size: file size in bytes (null for directories)
|
||||||
|
* All paths are relative to ROOT_DIR and use forward slashes.
|
||||||
|
*/
|
||||||
|
export function listDir(dir, { seek = 0, limit = 50, recursive = false } = {}) {
|
||||||
|
const ig = buildIgnoreFilter(dir);
|
||||||
|
const allEntries = [];
|
||||||
|
|
||||||
|
const collect = (current) => {
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(current, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const e of entries) {
|
||||||
|
const full = path.join(current, e.name);
|
||||||
|
const rel = path.relative(ROOT_DIR, full).replace(/\\/g, '/');
|
||||||
|
if (ig.ignores(e.isDirectory() ? rel + '/' : rel)) continue;
|
||||||
|
if (e.isDirectory()) {
|
||||||
|
allEntries.push({ path: rel + '/', type: 'd', size: null });
|
||||||
|
if (recursive) collect(full);
|
||||||
|
} else {
|
||||||
|
let size = null;
|
||||||
|
try { size = fs.statSync(full).size; } catch { /* skip */ }
|
||||||
|
allEntries.push({ path: rel, type: 'f', size });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
collect(dir);
|
||||||
|
|
||||||
|
const total = allEntries.length;
|
||||||
|
const sliced = allEntries.slice(seek, seek + limit);
|
||||||
|
const truncated = total - (seek + sliced.length);
|
||||||
|
return { entries: sliced, total, truncated: Math.max(0, truncated) };
|
||||||
|
}
|
||||||
38
src/sandbox.js
Normal file
38
src/sandbox.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sandbox — path resolution restricted to the workspace root.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import path from 'path';
|
||||||
|
import { ToolError } from 'llmorbiter';
|
||||||
|
import { ROOT_DIR } from './config.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a user-supplied path within the sandbox root.
|
||||||
|
* Throws ToolError if the path is absolute or escapes the root.
|
||||||
|
*/
|
||||||
|
export function resolveSafe(userPath) {
|
||||||
|
if (!userPath || typeof userPath !== 'string') {
|
||||||
|
throw new ToolError('Path must be a non-empty string.');
|
||||||
|
}
|
||||||
|
// Reject absolute paths so the user can't anchor to a filesystem root.
|
||||||
|
// Covers POSIX root, Windows rooted, and drive-letter forms. Paths must be
|
||||||
|
// relative to the workspace root.
|
||||||
|
if (path.isAbsolute(userPath) || /^[A-Za-z]:/.test(userPath)) {
|
||||||
|
throw new ToolError(
|
||||||
|
`Access denied: "${userPath}" is an absolute path. ` +
|
||||||
|
`You may only access files within the workspace using paths relative to the workspace root.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const resolved = path.resolve(ROOT_DIR, userPath);
|
||||||
|
// Must stay inside root (defense in depth; also catches ".." traversal).
|
||||||
|
const rootNorm = ROOT_DIR.endsWith(path.sep) ? ROOT_DIR : ROOT_DIR + path.sep;
|
||||||
|
if (resolved !== ROOT_DIR && !resolved.startsWith(rootNorm)) {
|
||||||
|
throw new ToolError(
|
||||||
|
`Access denied: "${userPath}" resolves outside the workspace root "${ROOT_DIR}". ` +
|
||||||
|
`You may only access files within the workspace.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
91
src/tools/ask-user.js
Normal file
91
src/tools/ask-user.js
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
import chalk from 'chalk';
|
||||||
|
import { input, select } from '@inquirer/prompts';
|
||||||
|
import { defineTool } from 'llmorbiter';
|
||||||
|
|
||||||
|
export const askUserTool = defineTool({
|
||||||
|
name: 'ask_user',
|
||||||
|
description:
|
||||||
|
'Ask the user a question when you need clarification or a decision. ' +
|
||||||
|
'Provide a concise question and up to 3 labelled options (A, B, C). ' +
|
||||||
|
'The user can pick an option or type a free-form answer (D). ' +
|
||||||
|
'Use this instead of guessing whenever intent is ambiguous.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ['question'],
|
||||||
|
properties: {
|
||||||
|
question: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'The question to ask the user.',
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
type: 'array',
|
||||||
|
description: 'Up to 3 short option labels for the user to choose from.',
|
||||||
|
items: { type: 'string' },
|
||||||
|
maxItems: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
invoke: async ({ question, options = [] }) => {
|
||||||
|
const capped = options.slice(0, 3);
|
||||||
|
const letters = ['A', 'B', 'C'];
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log(chalk.magenta.bold(' ❓ Fido asks:'));
|
||||||
|
console.log(chalk.white(' ' + question));
|
||||||
|
|
||||||
|
if (capped.length === 0) {
|
||||||
|
// No options — just free text
|
||||||
|
const answer = await input({
|
||||||
|
message: chalk.bold.magenta('Your answer'),
|
||||||
|
prefix: chalk.magenta('›'),
|
||||||
|
theme: {
|
||||||
|
prefix: chalk.magenta('›'),
|
||||||
|
style: { answer: chalk.white, message: chalk.bold.magenta },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log('');
|
||||||
|
return { answer };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build select choices: one per option + free-text
|
||||||
|
const choices = [
|
||||||
|
...capped.map((opt, i) => ({
|
||||||
|
name: `${letters[i]}) ${opt}`,
|
||||||
|
value: letters[i],
|
||||||
|
short: letters[i],
|
||||||
|
})),
|
||||||
|
{ name: 'D) Type my own answer…', value: '__custom__', short: 'D' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const selection = await select({
|
||||||
|
message: question,
|
||||||
|
choices,
|
||||||
|
prefix: chalk.magenta('›'),
|
||||||
|
theme: {
|
||||||
|
prefix: chalk.magenta('›'),
|
||||||
|
style: { message: chalk.bold.magenta },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let answer;
|
||||||
|
if (selection === '__custom__') {
|
||||||
|
answer = await input({
|
||||||
|
message: chalk.bold.magenta('Your answer'),
|
||||||
|
prefix: chalk.magenta('›'),
|
||||||
|
theme: {
|
||||||
|
prefix: chalk.magenta('›'),
|
||||||
|
style: { answer: chalk.white, message: chalk.bold.magenta },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const idx = letters.indexOf(selection);
|
||||||
|
answer = capped[idx];
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
return { answer };
|
||||||
|
},
|
||||||
|
});
|
||||||
33
src/tools/index.js
Normal file
33
src/tools/index.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool registry — one module per tool, aggregated here in registration order.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { listFilesTool } from './list-files.js';
|
||||||
|
import { readFileTool } from './read-file.js';
|
||||||
|
import { patchTool } from './patch.js';
|
||||||
|
import { mkdirTool } from './mkdir.js';
|
||||||
|
import { rmdirTool } from './rmdir.js';
|
||||||
|
import { rmTool } from './rm.js';
|
||||||
|
import { askUserTool } from './ask-user.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
listFilesTool,
|
||||||
|
readFileTool,
|
||||||
|
patchTool,
|
||||||
|
mkdirTool,
|
||||||
|
rmdirTool,
|
||||||
|
rmTool,
|
||||||
|
askUserTool,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const tools = [
|
||||||
|
listFilesTool,
|
||||||
|
readFileTool,
|
||||||
|
patchTool,
|
||||||
|
mkdirTool,
|
||||||
|
rmdirTool,
|
||||||
|
rmTool,
|
||||||
|
askUserTool,
|
||||||
|
];
|
||||||
60
src/tools/list-files.js
Normal file
60
src/tools/list-files.js
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
|
import { defineTool, ToolError } from 'llmorbiter';
|
||||||
|
import { resolveSafe } from '../sandbox.js';
|
||||||
|
import { listDir } from '../dir-listing.js';
|
||||||
|
|
||||||
|
export const listFilesTool = defineTool({
|
||||||
|
name: 'list_files',
|
||||||
|
description:
|
||||||
|
'List the immediate contents of a directory within the workspace (non-recursive by default). ' +
|
||||||
|
'Entries matching .gitignore patterns (and node_modules/) are excluded automatically. ' +
|
||||||
|
'Set recursive=true to walk the full subtree. ' +
|
||||||
|
'Use seek/limit for pagination. Paths are relative to the workspace root.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ['dirname'],
|
||||||
|
properties: {
|
||||||
|
dirname: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Directory path relative to workspace root. Use "." for root.',
|
||||||
|
},
|
||||||
|
recursive: {
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'If true, walk the full subtree. Default: false (immediate children only).',
|
||||||
|
},
|
||||||
|
seek: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Number of entries to skip (for pagination). Default: 0.',
|
||||||
|
},
|
||||||
|
limit: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Maximum number of entries to return. Default: 50.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
invoke: async ({ dirname, recursive = false, seek = 0, limit = 50 }) => {
|
||||||
|
if (!Number.isInteger(limit) || limit < 1) {
|
||||||
|
throw new ToolError(`"limit" must be a positive integer (received ${limit}).`);
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(seek) || seek < 0) {
|
||||||
|
throw new ToolError(`"seek" must be a non-negative integer (received ${seek}).`);
|
||||||
|
}
|
||||||
|
const absDir = resolveSafe(dirname);
|
||||||
|
if (!fs.existsSync(absDir)) {
|
||||||
|
throw new ToolError(`Directory not found: "${dirname}"`);
|
||||||
|
}
|
||||||
|
if (!fs.statSync(absDir).isDirectory()) {
|
||||||
|
throw new ToolError(`"${dirname}" is not a directory.`);
|
||||||
|
}
|
||||||
|
const { entries, total, truncated } = listDir(absDir, { seek, limit, recursive });
|
||||||
|
const result = { dirname, recursive, entries, total, returned: entries.length, seek };
|
||||||
|
if (truncated > 0) {
|
||||||
|
result.truncated = truncated;
|
||||||
|
result.truncationNote = `${truncated} entries not shown. Use seek=${seek + entries.length} to continue.`;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
});
|
||||||
34
src/tools/mkdir.js
Normal file
34
src/tools/mkdir.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
|
import { defineTool, ToolError } from 'llmorbiter';
|
||||||
|
import { resolveSafe } from '../sandbox.js';
|
||||||
|
import { pushUndo } from '../undo.js';
|
||||||
|
|
||||||
|
export const mkdirTool = defineTool({
|
||||||
|
name: 'mkdir',
|
||||||
|
description: 'Create a directory in the workspace (automatically recursive if parent directories do not exist).',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ['dirname'],
|
||||||
|
properties: {
|
||||||
|
dirname: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Directory path relative to workspace root.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
invoke: async ({ dirname }) => {
|
||||||
|
const absDir = resolveSafe(dirname);
|
||||||
|
if (fs.existsSync(absDir)) {
|
||||||
|
if (!fs.statSync(absDir).isDirectory()) {
|
||||||
|
throw new ToolError(`Path "${dirname}" already exists and is not a directory.`);
|
||||||
|
}
|
||||||
|
return { message: `Directory already exists: "${dirname}"`, created: false };
|
||||||
|
}
|
||||||
|
fs.mkdirSync(absDir, { recursive: true });
|
||||||
|
pushUndo({ op: 'DELETE_DIR', path: dirname });
|
||||||
|
return { message: `Directory created: "${dirname}"`, created: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
224
src/tools/patch.js
Normal file
224
src/tools/patch.js
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { defineTool, ToolError } from 'llmorbiter';
|
||||||
|
import { resolveSafe } from '../sandbox.js';
|
||||||
|
import { pushUndo } from '../undo.js';
|
||||||
|
|
||||||
|
// ─── Patch executor ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a list of file patch operations.
|
||||||
|
* Each operation is an object:
|
||||||
|
* - file: string (relative path)
|
||||||
|
* - search: string | null (string to be replaced; null or empty means create/overwrite file)
|
||||||
|
* - replace: string (string to write)
|
||||||
|
* - skiplines: number | null (optional: line number offset to start searching from, or lines to skip before matching)
|
||||||
|
*/
|
||||||
|
export function applyOperations(operations) {
|
||||||
|
if (!Array.isArray(operations) || operations.length === 0) {
|
||||||
|
return { success: false, error: 'operations must be a non-empty array of objects.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate all operations first (fast-fail)
|
||||||
|
for (let idx = 0; idx < operations.length; idx++) {
|
||||||
|
const op = operations[idx];
|
||||||
|
if (!op || typeof op !== 'object') {
|
||||||
|
return { success: false, error: `Operation at index ${idx} is not an object.` };
|
||||||
|
}
|
||||||
|
if (!op.file || typeof op.file !== 'string') {
|
||||||
|
return { success: false, error: `Operation at index ${idx} missing valid 'file' property.` };
|
||||||
|
}
|
||||||
|
if (op.replace === undefined || op.replace === null || typeof op.replace !== 'string') {
|
||||||
|
return { success: false, error: `Operation at index ${idx} ('${op.file}') missing valid 'replace' string.` };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
resolveSafe(op.file);
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, error: err.message, failedOp: op };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const applied = [];
|
||||||
|
for (const op of operations) {
|
||||||
|
const absPath = resolveSafe(op.file);
|
||||||
|
try {
|
||||||
|
if (op.search === null || op.search === undefined || op.search === '') {
|
||||||
|
// Create or overwrite file (with autodirectory creation)
|
||||||
|
let undoEntry;
|
||||||
|
if (fs.existsSync(absPath)) {
|
||||||
|
const existing = fs.readFileSync(absPath, 'utf8');
|
||||||
|
undoEntry = { op: 'RESTORE', path: op.file, content: existing };
|
||||||
|
} else {
|
||||||
|
undoEntry = { op: 'DELETE', path: op.file };
|
||||||
|
}
|
||||||
|
fs.mkdirSync(path.dirname(absPath), { recursive: true });
|
||||||
|
fs.writeFileSync(absPath, op.replace, 'utf8');
|
||||||
|
pushUndo(undoEntry);
|
||||||
|
applied.push(`CREATE/WRITE ${op.file}`);
|
||||||
|
} else {
|
||||||
|
// Search and replace (replace first hit, optionally respecting skiplines)
|
||||||
|
if (!fs.existsSync(absPath)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `File not found: "${op.file}"`,
|
||||||
|
failedOp: op,
|
||||||
|
appliedSoFar: applied,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const original = fs.readFileSync(absPath, 'utf8');
|
||||||
|
const isCrlf = original.includes('\r\n');
|
||||||
|
let matchIndex = -1;
|
||||||
|
let matchedLen = op.search.length;
|
||||||
|
|
||||||
|
// First try exact match
|
||||||
|
if (typeof op.skiplines === 'number' && op.skiplines > 0) {
|
||||||
|
let currentLine = 0;
|
||||||
|
let searchFromChar = 0;
|
||||||
|
for (let i = 0; i < original.length; i++) {
|
||||||
|
if (currentLine >= op.skiplines) {
|
||||||
|
searchFromChar = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (original[i] === '\n') {
|
||||||
|
currentLine++;
|
||||||
|
if (currentLine >= op.skiplines) {
|
||||||
|
searchFromChar = i + 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
matchIndex = original.indexOf(op.search, searchFromChar);
|
||||||
|
} else {
|
||||||
|
matchIndex = original.indexOf(op.search);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not found and line endings differ, try line-ending normalized match
|
||||||
|
if (matchIndex === -1 && (op.search.includes('\n') || original.includes('\r\n'))) {
|
||||||
|
const normSearch = op.search.replace(/\r\n/g, '\n');
|
||||||
|
const normOriginal = original.replace(/\r\n/g, '\n');
|
||||||
|
let normFromChar = 0;
|
||||||
|
|
||||||
|
if (typeof op.skiplines === 'number' && op.skiplines > 0) {
|
||||||
|
const linesBefore = normOriginal.split('\n').slice(0, op.skiplines);
|
||||||
|
normFromChar = linesBefore.join('\n').length + (linesBefore.length > 0 ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normMatchIndex = normOriginal.indexOf(normSearch, normFromChar);
|
||||||
|
if (normMatchIndex !== -1) {
|
||||||
|
// Map normalized index back to original offset
|
||||||
|
const prefixNorm = normOriginal.slice(0, normMatchIndex);
|
||||||
|
const numPrefixNewlines = (prefixNorm.match(/\n/g) || []).length;
|
||||||
|
matchIndex = isCrlf ? normMatchIndex + numPrefixNewlines : normMatchIndex;
|
||||||
|
|
||||||
|
const matchedNormText = normOriginal.slice(normMatchIndex, normMatchIndex + normSearch.length);
|
||||||
|
const numMatchedNewlines = (matchedNormText.match(/\n/g) || []).length;
|
||||||
|
matchedLen = isCrlf ? normSearch.length + numMatchedNewlines : normSearch.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchIndex === -1) {
|
||||||
|
const skipMsg = (typeof op.skiplines === 'number' && op.skiplines > 0)
|
||||||
|
? ` (after skipping ${op.skiplines} lines)`
|
||||||
|
: '';
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
`SEARCH/REPLACE failed in "${op.file}": search string not found${skipMsg}.\n` +
|
||||||
|
`Search string was:\n${op.search}\n\n` +
|
||||||
|
`Tip: use read_file to check current contents and line numbering of "${op.file}".`,
|
||||||
|
failedOp: op,
|
||||||
|
appliedSoFar: applied,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// If file uses CRLF, ensure replacement content uses CRLF as well
|
||||||
|
let replacementText = op.replace;
|
||||||
|
if (isCrlf && !replacementText.includes('\r\n') && replacementText.includes('\n')) {
|
||||||
|
replacementText = replacementText.replace(/\n/g, '\r\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated =
|
||||||
|
original.slice(0, matchIndex) +
|
||||||
|
replacementText +
|
||||||
|
original.slice(matchIndex + matchedLen);
|
||||||
|
|
||||||
|
fs.writeFileSync(absPath, updated, 'utf8');
|
||||||
|
pushUndo({ op: 'RESTORE', path: op.file, content: original });
|
||||||
|
applied.push(`PATCH in ${op.file}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Operation on "${op.file}" failed: ${err.message}`,
|
||||||
|
failedOp: op,
|
||||||
|
appliedSoFar: applied,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, applied, undoLogEntries: applied.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tool definition ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const patchTool = defineTool({
|
||||||
|
name: 'patch',
|
||||||
|
description: `Apply one or more patch operations to files in the workspace.
|
||||||
|
Each operation has:
|
||||||
|
- file: (required) relative path to target file
|
||||||
|
- search: string to be replaced (exact match). If null, creating/overwriting the entire file (with automatic directory creation).
|
||||||
|
- replace: string to write
|
||||||
|
- skiplines: (optional) line number offset to skip before matching (0-indexed) if there are multiple occurrences, since patch replaces the first hit.
|
||||||
|
|
||||||
|
Example create file:
|
||||||
|
[{"file": "src/utils.js", "search": null, "replace": "export const x = 1;\\n", "skiplines": null}]
|
||||||
|
|
||||||
|
Example search/replace:
|
||||||
|
[{"file": "src/utils.js", "search": "export const x = 1;", "replace": "export const x = 2;", "skiplines": null}]`,
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ['operations'],
|
||||||
|
properties: {
|
||||||
|
operations: {
|
||||||
|
type: 'array',
|
||||||
|
description: 'List of patch operations to apply sequentially.',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ['file', 'replace'],
|
||||||
|
properties: {
|
||||||
|
file: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Relative path to file.',
|
||||||
|
},
|
||||||
|
search: {
|
||||||
|
type: ['string', 'null'],
|
||||||
|
description: 'String to be replaced (exact match). If null, writes/creates the entire file.',
|
||||||
|
},
|
||||||
|
replace: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'String to write.',
|
||||||
|
},
|
||||||
|
skiplines: {
|
||||||
|
type: ['number', 'null'],
|
||||||
|
description: 'Optional line number offset to skip before searching for search text.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
invoke: async ({ operations }) => {
|
||||||
|
const result = applyOperations(operations);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new ToolError(
|
||||||
|
`Patch failed: ${result.error}` +
|
||||||
|
(result.appliedSoFar?.length ? `\nSuccessfully applied before failure: ${result.appliedSoFar.join(', ')}` : '')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
});
|
||||||
57
src/tools/read-file.js
Normal file
57
src/tools/read-file.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
|
import { defineTool, ToolError } from 'llmorbiter';
|
||||||
|
import { resolveSafe } from '../sandbox.js';
|
||||||
|
|
||||||
|
export const readFileTool = defineTool({
|
||||||
|
name: 'read_file',
|
||||||
|
description:
|
||||||
|
'Read a file from the workspace. Use seek/limit to read in chunks. ' +
|
||||||
|
'Returns truncation info when the file is larger than the limit.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ['filename'],
|
||||||
|
properties: {
|
||||||
|
filename: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'File path relative to workspace root.',
|
||||||
|
},
|
||||||
|
seek: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Line number to start reading from (0-indexed). Default: 0.',
|
||||||
|
},
|
||||||
|
limit: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Maximum number of lines to return. Default: 200.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
invoke: async ({ filename, seek = 0, limit = 200 }) => {
|
||||||
|
const absPath = resolveSafe(filename);
|
||||||
|
if (!fs.existsSync(absPath)) {
|
||||||
|
throw new ToolError(`File not found: "${filename}"`);
|
||||||
|
}
|
||||||
|
if (!fs.statSync(absPath).isFile()) {
|
||||||
|
throw new ToolError(`"${filename}" is not a file.`);
|
||||||
|
}
|
||||||
|
const allLines = fs.readFileSync(absPath, 'utf8').split('\n');
|
||||||
|
const total = allLines.length;
|
||||||
|
const slice = allLines.slice(seek, seek + limit);
|
||||||
|
const truncated = total - (seek + slice.length);
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
filename,
|
||||||
|
totalLines: total,
|
||||||
|
seek,
|
||||||
|
returnedLines: slice.length,
|
||||||
|
content: slice.join('\n'),
|
||||||
|
};
|
||||||
|
if (truncated > 0) {
|
||||||
|
result.truncated = truncated;
|
||||||
|
result.truncationNote = `${truncated} lines not shown. Use seek=${seek + slice.length} to continue.`;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
});
|
||||||
35
src/tools/rm.js
Normal file
35
src/tools/rm.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
|
import { defineTool, ToolError } from 'llmorbiter';
|
||||||
|
import { resolveSafe } from '../sandbox.js';
|
||||||
|
import { pushUndo } from '../undo.js';
|
||||||
|
|
||||||
|
export const rmTool = defineTool({
|
||||||
|
name: 'rm',
|
||||||
|
description: 'Delete a file from the workspace.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ['filename'],
|
||||||
|
properties: {
|
||||||
|
filename: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'File path relative to workspace root.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
invoke: async ({ filename }) => {
|
||||||
|
const absPath = resolveSafe(filename);
|
||||||
|
if (!fs.existsSync(absPath)) {
|
||||||
|
throw new ToolError(`File not found: "${filename}"`);
|
||||||
|
}
|
||||||
|
if (!fs.statSync(absPath).isFile()) {
|
||||||
|
throw new ToolError(`"${filename}" is not a file. Use rmdir to delete directories.`);
|
||||||
|
}
|
||||||
|
const content = fs.readFileSync(absPath, 'utf8');
|
||||||
|
fs.unlinkSync(absPath);
|
||||||
|
pushUndo({ op: 'CREATE', path: filename, content });
|
||||||
|
return { message: `File deleted: "${filename}"`, deleted: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
34
src/tools/rmdir.js
Normal file
34
src/tools/rmdir.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
|
import { defineTool, ToolError } from 'llmorbiter';
|
||||||
|
import { resolveSafe } from '../sandbox.js';
|
||||||
|
import { pushUndo } from '../undo.js';
|
||||||
|
|
||||||
|
export const rmdirTool = defineTool({
|
||||||
|
name: 'rmdir',
|
||||||
|
description: 'Remove a directory and its contents from the workspace.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ['dirname'],
|
||||||
|
properties: {
|
||||||
|
dirname: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Directory path relative to workspace root.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
invoke: async ({ dirname }) => {
|
||||||
|
const absDir = resolveSafe(dirname);
|
||||||
|
if (!fs.existsSync(absDir)) {
|
||||||
|
throw new ToolError(`Directory not found: "${dirname}"`);
|
||||||
|
}
|
||||||
|
if (!fs.statSync(absDir).isDirectory()) {
|
||||||
|
throw new ToolError(`"${dirname}" is not a directory. Use rm to delete files.`);
|
||||||
|
}
|
||||||
|
fs.rmSync(absDir, { recursive: true, force: true });
|
||||||
|
pushUndo({ op: 'DELETE_DIR_UNDO_NOTE', path: dirname, note: 'Directory was deleted; contents cannot be automatically restored.' });
|
||||||
|
return { message: `Directory removed: "${dirname}"`, removed: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
41
src/undo.js
Normal file
41
src/undo.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Undo log — JSON array, newest-first, persisted on every mutation.
|
||||||
|
* Entry kinds:
|
||||||
|
* RESTORE — previous content of an overwritten/patched file
|
||||||
|
* CREATE — content of a deleted file (used to re-create it)
|
||||||
|
* DELETE / DELETE_DIR — reverse operation marker
|
||||||
|
* DELETE_DIR_UNDO_NOTE — directory removal (contents not auto-restorable)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { ROOT_DIR, UNDO_FILE } from './config.js';
|
||||||
|
|
||||||
|
let undoLog = [];
|
||||||
|
|
||||||
|
export function loadUndoLog() {
|
||||||
|
const undoPath = path.resolve(ROOT_DIR, UNDO_FILE);
|
||||||
|
if (fs.existsSync(undoPath)) {
|
||||||
|
try {
|
||||||
|
undoLog = JSON.parse(fs.readFileSync(undoPath, 'utf8'));
|
||||||
|
} catch {
|
||||||
|
undoLog = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveUndoLog() {
|
||||||
|
const undoPath = path.resolve(ROOT_DIR, UNDO_FILE);
|
||||||
|
fs.writeFileSync(undoPath, JSON.stringify(undoLog, null, 2) + '\n', 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pushUndo(entry) {
|
||||||
|
undoLog.unshift(entry); // newest first
|
||||||
|
saveUndoLog();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUndoLog() {
|
||||||
|
return undoLog;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user