This commit is contained in:
seb
2026-09-22 17:13:54 +02:00
commit 54c50e6a00
5 changed files with 1248 additions and 0 deletions

655
src/fido.js Normal file
View File

@@ -0,0 +1,655 @@
'use strict';
/**
* Fido — Minimal Coding Assistant
*
* A sandboxed, interactive coding assistant using LLMOrbiter.
*
* Config via .env (place in c:\src\fido\ 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)
*
* Usage:
* node src/fido.js
* node src/fido.js --dir /path/to/project
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { createInterface } from 'readline';
import dotenv from 'dotenv';
import chalk from 'chalk';
import { input } from '@inquirer/prompts';
import { LLMOrbiter, OpenRouterProvider, defineTool, ToolError } from 'llmorbiter';
// ─── Bootstrap ─────────────────────────────────────────────────────────────────
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') });
const API_KEY = process.env.OPENROUTER_API_KEY;
const MODEL = process.env.MODEL || 'xiaomi/mimo-v2.6-flash';
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');
const ROOT_DIR = path.resolve(
(dirFlagIdx !== -1 && args[dirFlagIdx + 1]) || process.env.ROOT_DIR || process.cwd()
);
if (!API_KEY) {
console.error(chalk.red.bold('\n✖ Missing OPENROUTER_API_KEY — add it to .env\n'));
process.exit(1);
}
// ─── Sandbox ──────────────────────────────────────────────────────────────────
/**
* Resolve a user-supplied path within the sandbox root.
* Throws ToolError if the resolved path escapes the root.
*/
function resolveSafe(userPath) {
if (!userPath || typeof userPath !== 'string') {
throw new ToolError('Path must be a non-empty string.');
}
// Strip leading slashes/backslashes so the user can't anchor to filesystem root
const stripped = userPath.replace(/^[/\\]+/, '');
const resolved = path.resolve(ROOT_DIR, stripped);
// Must stay inside root
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;
}
// ─── Directory listing helper ──────────────────────────────────────────────────
/**
* Recursively list files under a directory, relative to ROOT_DIR.
* Returns { files: string[], total: number, truncated: number }
*/
function listFilesRecursive(dir, seek = 0, limit = 50) {
const allFiles = [];
const walk = (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 (e.isDirectory()) {
allFiles.push(rel + '/');
walk(full);
} else {
allFiles.push(rel);
}
}
};
walk(dir);
const total = allFiles.length;
const sliced = allFiles.slice(seek, seek + limit);
const truncated = total - (seek + sliced.length);
return { files: sliced, total, truncated: Math.max(0, truncated) };
}
// ─── System prompt builder ─────────────────────────────────────────────────────
function buildSystemPrompt() {
const { files, total, truncated } = listFilesRecursive(ROOT_DIR, 0, 50);
let dirSection = files.map(f => ` ${f}`).join('\n');
if (truncated > 0) {
dirSection += `\n ... (${truncated} more files not shown — use list_files to explore)`;
}
return `You are Fido, a minimal but capable coding assistant.
You can read files, list directories, and apply code patches within the workspace.
## Workspace
Root directory: ${ROOT_DIR}
Total files: ${total}
## Current directory listing (first 50 of ${total}):
${dirSection}
## Rules
- You may ONLY access files within the workspace root. Never use ".." or absolute paths.
- When editing files, prefer targeted SEARCH/REPLACE patches over full rewrites.
- After applying a patch, briefly confirm what you did.
- Keep responses concise and focused.
- Use the tools; do not emit raw file contents unless the user asks.
`;
}
// ─── Undo log ─────────────────────────────────────────────────────────────────
let undoLog = [];
function loadUndoLog() {
const undoPath = path.resolve(ROOT_DIR, UNDO_FILE);
if (fs.existsSync(undoPath)) {
try {
undoLog = JSON.parse(fs.readFileSync(undoPath, 'utf8'));
} catch {
undoLog = [];
}
}
}
function saveUndoLog() {
const undoPath = path.resolve(ROOT_DIR, UNDO_FILE);
fs.writeFileSync(undoPath, JSON.stringify(undoLog, null, 2) + '\n', 'utf8');
}
function pushUndo(entry) {
undoLog.unshift(entry); // newest first
saveUndoLog();
}
// ─── Patch parser ──────────────────────────────────────────────────────────────
/**
* Parse a patch string into an array of operations.
*
* Supported operations:
* <<<<< CREATE path
* ...content...
* >>>>>
*
* <<<<< CREATE_DIR path/
* >>>>>
*
* <<<<< SEARCH path
* <<<<<<< SEARCH
* old
* =======
* new
* >>>>>>> REPLACE
* >>>>>
*
* <<<<< DELETE path
* >>>>>
*
* <<<<< DELETE_DIR path/
* >>>>>
*/
function parsePatch(patchStr) {
const ops = [];
// Split on operation boundaries: lines starting with "<<<<<"
const opPattern = /^<<<<< (\w+) (.+)$/m;
// We'll scan line by line
const lines = patchStr.split('\n');
let i = 0;
while (i < lines.length) {
const line = lines[i].trimEnd();
const m = line.match(/^<<<<< (\w+) (.+)$/);
if (!m) { i++; continue; }
const opType = m[1].toUpperCase();
const opPath = m[2].trim();
i++;
// Collect body until ">>>>>" (5 right-angle brackets on its own line)
const bodyLines = [];
while (i < lines.length && lines[i].trimEnd() !== '>>>>>') {
bodyLines.push(lines[i]);
i++;
}
i++; // skip the ">>>>>"
const body = bodyLines.join('\n');
if (opType === 'CREATE') {
ops.push({ type: 'CREATE', path: opPath, content: body });
} else if (opType === 'CREATE_DIR') {
ops.push({ type: 'CREATE_DIR', path: opPath });
} else if (opType === 'DELETE') {
ops.push({ type: 'DELETE', path: opPath });
} else if (opType === 'DELETE_DIR') {
ops.push({ type: 'DELETE_DIR', path: opPath });
} else if (opType === 'SEARCH') {
// Parse inner <<<<<<< SEARCH / ======= / >>>>>>> REPLACE block
const searchMatch = body.match(/<<<<<<< SEARCH\n([\s\S]*?)\n=======\n([\s\S]*?)\n>>>>>>> REPLACE/);
if (!searchMatch) {
ops.push({ type: 'SEARCH', path: opPath, error: 'Malformed SEARCH block — expected <<<<<<< SEARCH / ======= / >>>>>>> REPLACE markers' });
} else {
ops.push({ type: 'SEARCH', path: opPath, search: searchMatch[1], replace: searchMatch[2] });
}
} else {
ops.push({ type: 'UNKNOWN', raw: line });
}
}
return ops;
}
// ─── Patch executor ────────────────────────────────────────────────────────────
function applyPatch(patchStr) {
const ops = parsePatch(patchStr);
if (ops.length === 0) {
return { success: false, error: 'No valid operations found in patch string. Check the patch format.' };
}
// Validate all paths first (fast-fail before any writes)
for (const op of ops) {
if (op.error) {
return { success: false, error: op.error, failedOp: op };
}
if (op.type === 'UNKNOWN') {
return { success: false, error: `Unrecognised operation: "${op.raw}"`, failedOp: op };
}
try {
resolveSafe(op.path);
} catch (err) {
return { success: false, error: err.message, failedOp: op };
}
}
const applied = [];
for (const op of ops) {
const absPath = resolveSafe(op.path);
try {
if (op.type === 'CREATE') {
// Build undo: delete the file (or restore if it existed)
let undoEntry;
if (fs.existsSync(absPath)) {
const existing = fs.readFileSync(absPath, 'utf8');
undoEntry = { op: 'RESTORE', path: op.path, content: existing };
} else {
undoEntry = { op: 'DELETE', path: op.path };
}
fs.mkdirSync(path.dirname(absPath), { recursive: true });
fs.writeFileSync(absPath, op.content, 'utf8');
pushUndo(undoEntry);
applied.push(`CREATE ${op.path}`);
} else if (op.type === 'CREATE_DIR') {
if (!fs.existsSync(absPath)) {
fs.mkdirSync(absPath, { recursive: true });
pushUndo({ op: 'DELETE_DIR', path: op.path });
}
applied.push(`CREATE_DIR ${op.path}`);
} else if (op.type === 'DELETE') {
if (!fs.existsSync(absPath)) {
return {
success: false,
error: `DELETE failed: file not found: "${op.path}"`,
failedOp: op,
appliedSoFar: applied,
};
}
const content = fs.readFileSync(absPath, 'utf8');
fs.unlinkSync(absPath);
pushUndo({ op: 'CREATE', path: op.path, content });
applied.push(`DELETE ${op.path}`);
} else if (op.type === 'DELETE_DIR') {
if (!fs.existsSync(absPath)) {
return {
success: false,
error: `DELETE_DIR failed: directory not found: "${op.path}"`,
failedOp: op,
appliedSoFar: applied,
};
}
fs.rmSync(absPath, { recursive: true, force: true });
pushUndo({ op: 'DELETE_DIR_UNDO_NOTE', path: op.path, note: 'Directory was deleted; contents cannot be automatically restored.' });
applied.push(`DELETE_DIR ${op.path}`);
} else if (op.type === 'SEARCH') {
if (!fs.existsSync(absPath)) {
return {
success: false,
error: `SEARCH/REPLACE failed: file not found: "${op.path}"`,
failedOp: op,
appliedSoFar: applied,
};
}
const original = fs.readFileSync(absPath, 'utf8');
if (!original.includes(op.search)) {
// Provide context to help the LLM fix the search string
return {
success: false,
error:
`SEARCH/REPLACE failed in "${op.path}": search text not found.\n` +
`Search string was:\n${op.search}\n\n` +
`Tip: use read_file to check the exact current content of "${op.path}" before retrying.`,
failedOp: op,
appliedSoFar: applied,
};
}
const updated = original.replace(op.search, op.replace);
fs.writeFileSync(absPath, updated, 'utf8');
pushUndo({ op: 'RESTORE', path: op.path, content: original });
applied.push(`SEARCH/REPLACE in ${op.path}`);
}
} catch (err) {
return {
success: false,
error: `Operation "${op.type} ${op.path}" failed: ${err.message}`,
failedOp: op,
appliedSoFar: applied,
};
}
}
return { success: true, applied, undoLogEntries: applied.length };
}
// ─── Tool definitions ──────────────────────────────────────────────────────────
const listFilesTool = defineTool({
name: 'list_files',
description:
'List files and directories within the workspace. Use seek/limit for pagination. ' +
'Paths are relative to the workspace root. Returns truncation info when results are cut.',
parameters: {
type: 'object',
additionalProperties: false,
required: ['dirname'],
properties: {
dirname: {
type: 'string',
description: 'Directory path relative to workspace root. Use "." for root.',
},
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, seek = 0, limit = 50 }) => {
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 { files, total, truncated } = listFilesRecursive(absDir, seek, limit);
const result = { dirname, files, total, returned: files.length, seek };
if (truncated > 0) {
result.truncated = truncated;
result.truncationNote = `${truncated} entries not shown. Use seek=${seek + files.length} to continue.`;
}
return result;
},
});
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;
},
});
const patchTool = defineTool({
name: 'patch',
description: `Apply file operations to the workspace. Supports CREATE, CREATE_DIR, SEARCH/REPLACE, DELETE, DELETE_DIR.
Patch format (one or more operations):
<<<<< CREATE path/to/new_file.py
# New file contents here
def hello():
return "world"
>>>>>
<<<<< CREATE_DIR path/to/new_folder/
>>>>>
<<<<< SEARCH path/to/existing_file.py
<<<<<<< SEARCH
old_code_line_1()
=======
new_code_line_1()
>>>>>>> REPLACE
>>>>>
<<<<< DELETE path/to/old_file.txt
>>>>>
<<<<< DELETE_DIR path/to/old_directory/
>>>>>
Rules:
- Paths must be relative to workspace root. No ".." or absolute paths.
- SEARCH text must match the file exactly (including whitespace/indentation).
- Multiple operations in one patch are applied in order; if one fails the rest are skipped.
- On failure, a structured error is returned explaining what went wrong so you can fix and retry.`,
parameters: {
type: 'object',
additionalProperties: false,
required: ['patch'],
properties: {
patch: {
type: 'string',
description: 'The patch string containing one or more operations.',
},
},
},
invoke: async ({ patch }) => {
const result = applyPatch(patch);
if (!result.success) {
// Return as a ToolError so LLMOrbiter feeds it back to the LLM
throw new ToolError(
`Patch failed: ${result.error}` +
(result.appliedSoFar?.length ? `\nSuccessfully applied before failure: ${result.appliedSoFar.join(', ')}` : '')
);
}
return result;
},
});
// ─── Console UI ────────────────────────────────────────────────────────────────
function printBanner() {
console.log('');
console.log(chalk.cyan.bold('╔══════════════════════════════════════════════╗'));
console.log(chalk.cyan.bold('║') + chalk.white.bold(' 🐕 FIDO — Coding Assistant ') + chalk.cyan.bold('║'));
console.log(chalk.cyan.bold('╚══════════════════════════════════════════════╝'));
console.log(chalk.dim(` Workspace: ${ROOT_DIR}`));
console.log(chalk.dim(` Model: ${MODEL}`));
console.log(chalk.dim(` Undo log: ${path.resolve(ROOT_DIR, UNDO_FILE)}`));
console.log('');
}
function printDivider() {
console.log(chalk.dim(' ' + '─'.repeat(50)));
}
function printAssistant(content) {
console.log('');
console.log(chalk.green.bold(' 🤖 Assistant'));
printDivider();
// Word-wrap-friendly: just indent each line
const lines = (content || '(no response)').split('\n');
for (const line of lines) {
console.log(chalk.white(' ' + line));
}
console.log('');
}
function printStatus(msg) {
process.stdout.write(chalk.yellow.dim(` ⚡ ${msg}\n`));
}
function printToolCall(name, args) {
const argStr = JSON.stringify(args);
const display = argStr.length > 120 ? argStr.slice(0, 117) + '...' : argStr;
console.log(chalk.cyan(` ⚙ Tool: ${chalk.bold(name)}(${display})`));
}
function printToolResult(name, result) {
const str = typeof result === 'string' ? result : JSON.stringify(result);
const display = str.length > 200 ? str.slice(0, 197) + '...' : str;
console.log(chalk.dim(` ↳ ${display}`));
}
function printToolError(name, error) {
console.log(chalk.red(` ✖ Tool error [${name}]: ${error}`));
}
function printUsage(u) {
const parts = [`in=${u.input}`, `out=${u.output}`];
if (u.cached) parts.push(`cached=${u.cached}`);
if (u.cost) parts.push(`cost=$${u.cost.toFixed(5)}`);
console.log(chalk.dim(` 📊 ${parts.join(' ')}`));
}
// ─── Main loop ─────────────────────────────────────────────────────────────────
async function main() {
loadUndoLog();
printBanner();
const provider = new OpenRouterProvider({ apiKey: API_KEY });
const orbiter = new LLMOrbiter(provider, {
onStatus: (s) => printStatus(s),
onToolCall: (c) => printToolCall(c.name, c.args),
onToolResult: (r) => printToolResult(r.name, r.result),
onToolError: (e) => printToolError(e.name, e.error),
onUsage: (u) => printUsage(u),
});
// Conversation state: persisted across turns
const systemPrompt = buildSystemPrompt();
let messages = [{ role: 'system', content: systemPrompt }];
console.log(chalk.dim(' Type your request below. Press Ctrl+C to exit.\n'));
while (true) {
let userInput;
try {
userInput = await input({
message: chalk.bold.cyan('You'),
prefix: chalk.cyan('›'),
theme: {
prefix: chalk.cyan('›'),
style: {
answer: chalk.white,
message: chalk.bold.cyan,
},
},
});
} catch {
// Ctrl+C / EOF
console.log(chalk.dim('\n Goodbye! 🐕\n'));
break;
}
if (!userInput.trim()) continue;
// Handle special commands
if (userInput.trim() === '/quit' || userInput.trim() === '/exit') {
console.log(chalk.dim('\n Goodbye! 🐕\n'));
break;
}
if (userInput.trim() === '/undo') {
if (undoLog.length === 0) {
console.log(chalk.yellow('\n Nothing to undo.\n'));
} else {
console.log(chalk.yellow('\n Undo log (newest first):'));
for (const e of undoLog.slice(0, 10)) {
console.log(chalk.dim(` ${e.op} ${e.path}`));
}
if (undoLog.length > 10) {
console.log(chalk.dim(` ... and ${undoLog.length - 10} more in ${UNDO_FILE}`));
}
console.log('');
}
continue;
}
console.log('');
try {
const result = await orbiter.run({
prompt: userInput,
messages,
model: MODEL,
tools: [listFilesTool, readFileTool, patchTool],
maxIterations: 20,
});
// Update conversation history for next turn
messages = result.messages;
printAssistant(result.content);
} catch (err) {
console.log(chalk.red(`\n ✖ Error: ${err.message}\n`));
}
}
}
main().catch((err) => {
console.error(chalk.red.bold('\nFATAL: ' + err.message));
process.exit(1);
});