This commit is contained in:
seb
2026-09-22 20:59:58 +02:00
parent 96d2b80b6c
commit ab2f953e06
23 changed files with 1678 additions and 65 deletions

View File

@@ -29,9 +29,10 @@ import {
makeTheme,
} from '@inquirer/core';
import { filterCommands } from './slash-commands.js';
import { COMMANDS, filterCommands } from './slash-commands.js';
const MAX_SUGGESTIONS = 6;
const NAME_WIDTH = Math.max(...COMMANDS.map((c) => c.name.length));
/** Show the inline menu only while the first word is being typed (no space yet). */
function termFrom(line) {
@@ -43,8 +44,8 @@ function termFrom(line) {
/**
* Replace readline's actual edit buffer, not just the visible terminal line.
* `rl.clearLine()` only erases terminal output; using it before `rl.write()`
* leaves the old buffer in place and turns a completion such as `/h` into
* `/h/help` internally.
* leaves the old buffer in place and turns a completion such as `/p` into
* `/p/paste` internally.
*/
function replaceBuffer(rl, nextValue) {
rl.write(null, { ctrl: true, name: 'a' });
@@ -104,7 +105,7 @@ export const autocompleteInput = createPrompt((config, done) => {
const lines = suggestions.map((c, i) => {
const marker = i === active % count ? chalk.cyan('❯') : ' ';
return ` ${chalk.cyan(marker)} ${chalk.bold(c.name.padEnd(7))} ${chalk.dim(c.description)}`;
return ` ${chalk.cyan(marker)} ${chalk.bold(c.name.padEnd(NAME_WIDTH + 2))} ${chalk.dim(c.description)}`;
});
if (count > 1) {
lines.push(chalk.dim(' ↑/↓ choose · Tab complete'));

View File

@@ -7,7 +7,12 @@
* OPENROUTER_API_KEY=sk-or-...
* MODEL=xiaomi/mimo-v2.6-flash
* UNDO_FILE=.fido-undo.json
* SUSPEND_FILE=.fido-suspends.json (optional, stores /suspend snapshots)
* TAVILY_API_KEY=tvly-dev-... (optional, enables the websearch and browse_url tools)
* ROOT_DIR=. (optional, defaults to cwd)
*
* The workspace root can also be given as the first command-line argument:
* node src/fido.js /path/to/project
*/
import path from 'path';
@@ -24,10 +29,11 @@ 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';
export const SUSPEND_FILE = process.env.SUSPEND_FILE || '.fido-suspends.json';
export const TAVILY_API_KEY = process.env.TAVILY_API_KEY;
// Root dir: --dir flag wins, then env ROOT_DIR, then cwd
// Root dir: first positional arg 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()
args[0] || process.env.ROOT_DIR || process.cwd()
);

View File

@@ -17,22 +17,70 @@
*
* Usage:
* node src/fido.js
* node src/fido.js --dir /path/to/project
* node src/fido.js /path/to/project
*/
import path from 'path';
import chalk from 'chalk';
import { editor } from '@inquirer/prompts';
import { editor, input, select } from '@inquirer/prompts';
import { marked } from 'marked';
import { markedTerminal } from 'marked-terminal';
import { LLMOrbiter, OpenRouterProvider } from 'llmorbiter';
import { API_KEY, MODEL, UNDO_FILE, ROOT_DIR } from './config.js';
import { API_KEY, MODEL, UNDO_FILE, SUSPEND_FILE, ROOT_DIR } from './config.js';
import { listDir } from './dir-listing.js';
import { loadUndoLog, getUndoLog } from './undo.js';
import { tools } from './tools/index.js';
import { loadSuspends, addSuspend, getSuspends } from './suspend.js';
import { tools, toolCallNotifications, toolResultNotifications } from './tools/index.js';
import { printToolCall, emitGenericToolResult } from './tools/notify.js';
import { autocompleteInput } from './autocomplete-input.js';
import { COMMANDS, findCommand } from './slash-commands.js';
// ─── Markdown → console renderer ───────────────────────────────────────────────
// Configure marked to emit ANSI-styled terminal output. Width accounts for the
// 2-space UI indent applied when printing the assistant's response.
marked.use(markedTerminal({ reflowText: true, width: 76, tab: 2 }));
// ─── Background-color blocks ───────────────────────────────────────────────────
//
// marked-terminal (and chalk) end every styled span with a full reset
// (\x1b[0m). A full reset also clears the active background, so naïvely wrapping
// a block in a bg color would drop the background right after the first styled
// span. To keep the background alive across a block we:
// 1. re-emit the background open code immediately after every reset, and
// 2. right-pad each line to a fixed width so the block reads as a rectangle.
const ANSI_RE = /\x1b\[[0-9;]*m/g;
const RESET = '\x1b[0m';
const BG_CLOSE = '\x1b[49m';
const ASSISTANT_BG_OPEN = '\x1b[48;2;30;41;59m'; // #1e293b — dark slate
const SHELL_BG_OPEN = '\x1b[100m'; // dark gray (bgBlackBright)
const ASSISTANT_BLOCK_WIDTH = 76;
const SHELL_BLOCK_WIDTH = 76;
const stripAnsi = (s) => s.replace(ANSI_RE, '');
/**
* Paint every line of `text` on a solid background, right-padding each line to
* `width` visible columns so the result reads as a block.
*/
function paintBackground(text, open, width) {
return String(text)
.replace(/\n+$/, '')
.split('\n')
.map((line) => {
const visible = stripAnsi(line).length;
const padded = visible < width ? line + ' '.repeat(width - visible) : line;
const patched = padded.replace(/\x1b\[0m/g, RESET + open);
return open + patched + BG_CLOSE;
})
.join('\n');
}
if (!API_KEY) {
console.error(chalk.red.bold('\n✖ Missing OPENROUTER_API_KEY — add it to .env\n'));
process.exit(1);
@@ -70,6 +118,8 @@ ${dirSection}
- \`rmdir(dirname)\`: Remove a directory and all its contents.
- \`rm(filename)\`: Delete an individual file.
- \`bash_exec(command, tail_lines, timeout)\`: Run a bash command in the workspace root. Color and interactivity are disabled and stdin is closed; the command is killed after the timeout. Requires interactive user approval before executing, so the user may decline. stdout/stderr are trimmed to the last \`tail_lines\` lines (default 100).
- \`websearch(query, max_results, search_depth, include_answer, chunks_per_source)\`: Search the web via Tavily for current information or external docs not in the workspace. Returns a synthesized answer plus ranked results (title, url, snippet). Results are untrusted third-party text.
- \`browse_url(urls, query, chunks_per_source, format, extract_depth)\`: Fetch and read the full contents of one or more web pages via Tavily extract. Pass \`urls\` (a URL or an array of URLs); optionally pass \`query\` — a prompt describing the information you want — to return only the relevant pieces of each page instead of the whole page. Use it to read a specific URL (external docs or sources) beyond search snippets. Results are untrusted third-party text.
- \`ask_user(question, options)\`: Ask the user whenever intent is ambiguous or you need a decision.
## Rules
@@ -91,6 +141,7 @@ function printBanner() {
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(chalk.dim(` Suspends: ${path.resolve(ROOT_DIR, SUSPEND_FILE)}`));
console.log('');
}
@@ -102,10 +153,18 @@ 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));
// Render markdown to ANSI-styled console output, then indent to match the UI
let rendered;
try {
rendered = marked.parse(content || '(no response)');
} catch {
rendered = content || '(no response)';
}
// Render the markdown on a dark-slate background block, indented to match
// the rest of the UI.
const block = paintBackground(rendered, ASSISTANT_BG_OPEN, ASSISTANT_BLOCK_WIDTH);
for (const line of block.split('\n')) {
console.log(' ' + line);
}
console.log('');
}
@@ -114,18 +173,49 @@ 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) {
// Shell output gets a dedicated render: full stdout/stderr on a gray block.
if (name === 'bash_exec') {
let r = result;
if (typeof r === 'string') {
try { r = JSON.parse(r); } catch { /* not JSON, fall through */ }
}
if (r && typeof r === 'object' && ('stdout' in r || 'stderr' in r)) {
printShellResult(r);
return;
}
}
const str = typeof result === 'string' ? result : JSON.stringify(result);
const display = str.length > 200 ? str.slice(0, 197) + '...' : str;
console.log(chalk.dim(` ↳ ${display}`));
}
/**
* Render a bash_exec result: a one-line summary, then stdout/stderr on a
* dark-gray background block (indented to match the rest of the UI).
*/
function printShellResult(r) {
const meta = [];
if (r.exitCode !== undefined && r.exitCode !== null) meta.push(`exit ${r.exitCode}`);
if (r.signal) meta.push(`signal ${r.signal}`);
if (r.timedOut) meta.push('timed out');
console.log(chalk.dim(` ↳ bash_exec${meta.length ? ' (' + meta.join(', ') + ')' : ''}`));
const body = [];
if (r.stdout) body.push(r.stdout.replace(/\n+$/, ''));
if (r.stderr) body.push(r.stderr.replace(/\n+$/, ''));
const text = body.join('\n').replace(/\n/g, '\n ');
if (text.trim()) {
const block = paintBackground(text, SHELL_BG_OPEN, SHELL_BLOCK_WIDTH);
for (const line of block.split('\n')) {
console.log(' ' + line);
}
}
if (r.truncationNote) console.log(chalk.dim(` ↳ ${r.truncationNote}`));
if (r.note) console.log(chalk.dim(` ↳ ${r.note}`));
}
function printToolError(name, error) {
console.log(chalk.red(` ✖ Tool error [${name}]: ${error}`));
}
@@ -137,6 +227,12 @@ function printUsage(u) {
console.log(chalk.dim(` 📊 ${parts.join(' ')}`));
}
function formatWhen(iso) {
if (!iso) return '';
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? '' : d.toLocaleString();
}
function printCommandHelp() {
const width = Math.max(...COMMANDS.map((c) => c.name.length));
console.log('');
@@ -171,14 +267,24 @@ async function promptMultiline(message = 'Compose or paste your text, then save
async function main() {
loadUndoLog();
loadSuspends();
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),
onToolCall: (c) => {
const notify = toolCallNotifications[c.name];
if (notify) notify(c.args);
else printToolCall(c.name, c.args);
},
onToolResult: (r) => {
const notifyResult = toolResultNotifications[r.name];
if (notifyResult) notifyResult(r.result);
else emitGenericToolResult(r.name, r.result);
printToolResult(r.name, r.result);
},
onToolError: (e) => printToolError(e.name, e.error),
onUsage: (u) => printUsage(u),
});
@@ -224,10 +330,6 @@ async function main() {
console.log(chalk.dim('\n Goodbye! 🐕\n'));
break;
}
if (command === '/help') {
printCommandHelp();
continue;
}
if (command === '/paste') {
try {
const pasted = await promptMultiline();
@@ -259,6 +361,78 @@ async function main() {
continue;
}
if (command === '/suspend') {
// Description is the text after the command; ask for one if omitted.
let description = userInput.trim().slice('/suspend'.length).trim();
if (!description) {
try {
description = (
await input({
message: chalk.bold.cyan('Short description for this suspend'),
theme: {
prefix: chalk.cyan('›'),
style: { answer: chalk.white, message: chalk.bold.cyan },
},
})
).trim();
} catch {
console.log(chalk.yellow('\n Suspend cancelled.\n'));
continue;
}
}
if (!description) {
console.log(chalk.yellow('\n No description given — suspend cancelled.\n'));
continue;
}
if (messages.length <= 1) {
console.log(chalk.yellow('\n Nothing to suspend yet — say something first.\n'));
continue;
}
addSuspend(description, messages);
const count = getSuspends().length;
console.log(chalk.green(`\n ⏸ Suspended: ${description}`));
console.log(chalk.dim(` ${count} snapshot${count === 1 ? '' : 's'} in ${SUSPEND_FILE}\n`));
continue;
}
if (command === '/recall') {
const suspends = getSuspends();
if (suspends.length === 0) {
console.log(chalk.yellow('\n Nothing to recall.\n'));
continue;
}
let choice;
try {
choice = await select({
message: chalk.bold.cyan('Recall a suspended conversation'),
choices: suspends.map((s, i) => ({
name: `${s.description} ${chalk.dim(formatWhen(s.createdAt))}`,
value: i,
short: s.description,
})),
theme: {
prefix: chalk.cyan('›'),
style: { message: chalk.bold.cyan, highlight: chalk.cyan.bold },
},
});
} catch {
console.log(chalk.yellow('\n Recall cancelled.\n'));
continue;
}
const snap = suspends[choice];
const restored = Array.isArray(snap.messages) ? snap.messages : [];
// Keep the freshly built system prompt (workspace may have changed)
// and restore the rest of the conversation verbatim.
messages = [
{ role: 'system', content: systemPrompt },
...restored.filter((m) => m.role !== 'system'),
];
const turns = messages.length - 1;
console.log(chalk.green(`\n ▶ Recalled: ${snap.description}`));
console.log(chalk.dim(` ${turns} message${turns === 1 ? '' : 's'} restored.\n`));
continue;
}
console.log('');
try {

View File

@@ -9,33 +9,22 @@
*/
export const COMMANDS = [
{ name: '/help', description: 'Show this list of commands' },
{ name: '/paste', description: 'Compose or paste multiline input in $EDITOR' },
{ name: '/edit', description: 'Alias for /paste' },
{ name: '/suspend', description: 'Save the current conversation under a short description' },
{ name: '/recall', description: 'Restore a previously suspended conversation' },
{ name: '/undo', description: 'Show recent file changes from the undo log' },
{ name: '/quit', description: 'Exit Fido' },
{ name: '/exit', description: 'Alias for /quit' },
];
/** Aliases resolve to a canonical command name at dispatch time. */
const ALIASES = {
'/edit': '/paste',
'/exit': '/quit',
};
/** Return the commands whose name starts with `term` (case-insensitive). */
export function filterCommands(term) {
const t = (term || '').toLowerCase();
return COMMANDS.filter((c) => c.name.toLowerCase().startsWith(t));
}
/**
* Resolve a raw input line to a canonical command name (e.g. "/edit" ->
* "/paste"), or `null` when the line is not a known directive.
*/
/** Resolve a raw input line to a command name, or `null` if unknown. */
export function findCommand(line) {
if (typeof line !== 'string' || !line.startsWith('/')) return null;
const name = line.trim().split(/\s+/)[0].toLowerCase();
if (ALIASES[name]) return ALIASES[name];
return COMMANDS.some((c) => c.name === name) ? name : null;
}

52
src/suspend.js Normal file
View File

@@ -0,0 +1,52 @@
'use strict';
/**
* Suspend snapshots — JSON array, newest-first, persisted on every save.
*
* `/suspend <description>` captures the current conversation state under a
* short description so it can be listed and restored later via `/recall`.
* Each entry is:
* { description, createdAt, messages }
*/
import fs from 'fs';
import path from 'path';
import { ROOT_DIR, SUSPEND_FILE } from './config.js';
let snapshots = [];
function suspendPath() {
return path.resolve(ROOT_DIR, SUSPEND_FILE);
}
export function loadSuspends() {
const p = suspendPath();
if (fs.existsSync(p)) {
try {
const data = JSON.parse(fs.readFileSync(p, 'utf8'));
snapshots = Array.isArray(data) ? data : [];
} catch {
snapshots = [];
}
}
}
export function saveSuspends() {
fs.writeFileSync(suspendPath(), JSON.stringify(snapshots, null, 2) + '\n', 'utf8');
}
/** Capture `messages` under `description` (newest first) and persist. */
export function addSuspend(description, messages) {
const entry = {
description,
createdAt: new Date().toISOString(),
messages,
};
snapshots.unshift(entry);
saveSuspends();
return entry;
}
export function getSuspends() {
return snapshots;
}

View File

@@ -3,6 +3,7 @@
import chalk from 'chalk';
import { input, select } from '@inquirer/prompts';
import { defineTool } from 'llmorbiter';
import { emitToolCall, clip } from './notify.js';
export const askUserTool = defineTool({
name: 'ask_user',
@@ -89,3 +90,10 @@ export const askUserTool = defineTool({
return { answer };
},
});
/**
* Call notification: the question being asked.
*/
export function notifyCall(args = {}) {
emitToolCall(askUserTool.name, clip(JSON.stringify(args.question ?? '')));
}

View File

@@ -22,6 +22,7 @@ import chalk from 'chalk';
import { confirm } from '@inquirer/prompts';
import { defineTool, ToolError } from 'llmorbiter';
import { ROOT_DIR } from '../config.js';
import { emitToolCall, emitToolResult, clip } from './notify.js';
const DEFAULT_TAIL_LINES = 100; // trailing lines of output returned to the model
const DEFAULT_TIMEOUT_MS = 30_000; // 30s hard limit per command
@@ -255,3 +256,25 @@ export const bashExecTool = defineTool({
return result;
},
});
/**
* Call notification: the command flattened to one line, plus a custom timeout
* when one is set. The approval prompt that follows shows the full command.
*/
export function notifyCall(args = {}) {
const { command, timeout } = args;
const oneLine = String(command ?? '').replace(/\s*\n\s*/g, ' ; ');
const extras = Number.isInteger(timeout) && timeout !== DEFAULT_TIMEOUT_MS ? ` timeout=${timeout}` : '';
emitToolCall(bashExecTool.name, clip(`${oneLine}${extras}`));
}
/**
* Result notification: the process exit status (or signal / timeout).
*/
export function notifyResult(result = {}) {
const parts = [];
if (result.exitCode !== undefined && result.exitCode !== null) parts.push(`exit ${result.exitCode}`);
else if (result.signal) parts.push(`signal ${result.signal}`);
if (result.timedOut) parts.push('timed out');
emitToolResult(bashExecTool.name, parts.join(' ') || 'done');
}

179
src/tools/browse-url.js Normal file
View File

@@ -0,0 +1,179 @@
'use strict';
/**
* browse_url — fetch and extract the contents of one or more web pages via
* Tavily, returning the page text (or just the relevant pieces when a `query`
* is supplied).
*
* Design notes:
* - Requires a Tavily API key (TAVILY_API_KEY in .env). The client is created
* lazily and cached, so a missing key surfaces as a clean ToolError only
* when the tool is actually used.
* - This tool reaches the network, so results are untrusted third-party text;
* the model should treat them accordingly.
* - `urls` accepts either a single URL string or an array of them; it is
* normalized to an array before being handed to Tavily's extract().
* - `query` is an optional LLM prompt used to pull only the relevant pieces
* of information out of a page, so the whole page need not be returned.
*/
import { tavily } from '@tavily/core';
import { defineTool, ToolError } from 'llmorbiter';
import { TAVILY_API_KEY } from '../config.js';
import { emitToolCall, emitToolResult, clip, formatBytes, resultSize, plural } from './notify.js';
const DEFAULT_FORMAT = 'text'; // default output format
const FORMATS = ['markdown', 'text'];
const EXTRACT_DEPTHS = ['basic', 'advanced'];
const DEFAULT_EXTRACT_DEPTH = 'basic';
const DEFAULT_CHUNKS_PER_SOURCE = 1;
const MAX_URLS = 20; // hard upper bound to keep requests sane
let client = null;
/** Lazily construct (and cache) the Tavily client, failing cleanly if unset. */
function getClient() {
if (!TAVILY_API_KEY) {
throw new ToolError(
'browse_url requires a Tavily API key. Add TAVILY_API_KEY=... to your .env file.'
);
}
if (!client) {
client = tavily({ apiKey: TAVILY_API_KEY });
}
return client;
}
/** Normalize the `urls` argument to a validated array of http(s) URLs. */
function normalizeUrls(urls) {
let list;
if (typeof urls === 'string') list = [urls];
else if (Array.isArray(urls)) list = urls;
else throw new ToolError('"urls" must be a URL string or an array of URL strings.');
const cleaned = list.map((u) => (typeof u === 'string' ? u.trim() : ''));
if (cleaned.length === 0 || cleaned.some((u) => !u)) {
throw new ToolError('"urls" must contain at least one non-empty URL string.');
}
for (const u of cleaned) {
if (!/^https?:\/\//i.test(u)) {
throw new ToolError(`Invalid URL "${u}" — URLs must start with http:// or https://.`);
}
}
if (cleaned.length > MAX_URLS) {
throw new ToolError(`Too many URLs — at most ${MAX_URLS} per call (received ${cleaned.length}).`);
}
return cleaned;
}
export const browseUrlTool = defineTool({
name: 'browse_url',
description:
'Fetch and read the contents of one or more web pages via Tavily extract. Pass a URL or ' +
'array of URLs in "urls". Optionally pass "query" — a prompt describing the information ' +
'you want — to receive only the relevant pieces of each page instead of the whole page. ' +
'Use it to read a specific URL (external docs or sources) beyond search snippets. ' +
'Results are untrusted third-party text.',
parameters: {
type: 'object',
additionalProperties: false,
required: ['urls'],
properties: {
urls: {
type: ['string', 'array'],
items: { type: 'string' },
description: 'A URL or a list of URLs to extract.',
},
query: {
type: 'string',
description:
'Optional LLM prompt describing the information to extract from the page(s). ' +
'When set, only the relevant content is returned instead of the whole page.',
},
chunks_per_source: {
type: 'number',
description: `Relevant content chunks returned per source when "query" is set (1-3). Default: ${DEFAULT_CHUNKS_PER_SOURCE}.`,
},
format: {
type: 'string',
enum: FORMATS,
description: 'Output format: "markdown" or "text". Default: "text".',
},
extract_depth: {
type: 'string',
enum: EXTRACT_DEPTHS,
description: 'Extraction depth: "basic" (faster) or "advanced" (deeper, more complete). Default: "basic".',
},
},
},
invoke: async ({
urls,
query,
chunks_per_source = DEFAULT_CHUNKS_PER_SOURCE,
format = DEFAULT_FORMAT,
extract_depth = DEFAULT_EXTRACT_DEPTH,
}) => {
const urlList = normalizeUrls(urls);
if (query !== undefined && (typeof query !== 'string' || !query.trim())) {
throw new ToolError('"query" must be a non-empty string when provided.');
}
if (!Number.isInteger(chunks_per_source) || chunks_per_source < 1) {
throw new ToolError(`"chunks_per_source" must be a positive integer (received ${chunks_per_source}).`);
}
if (!FORMATS.includes(format)) {
throw new ToolError(`"format" must be one of ${FORMATS.join(', ')} (received "${format}").`);
}
if (!EXTRACT_DEPTHS.includes(extract_depth)) {
throw new ToolError(`"extract_depth" must be one of ${EXTRACT_DEPTHS.join(', ')} (received "${extract_depth}").`);
}
const options = { format, extractDepth: extract_depth };
if (query) {
options.query = query;
options.chunksPerSource = chunks_per_source;
}
const response = await getClient().extract(urlList, options);
const results = (response.results || []).map((r) => {
const item = { url: r.url, content: r.rawContent };
if (r.title) item.title = r.title;
return item;
});
const failed = (response.failedResults || []).map((f) => ({
url: f.url,
error: f.error,
}));
const result = { urls: urlList, results };
if (query) result.query = query;
if (failed.length) result.failedResults = failed;
if (response.responseTime !== undefined) result.responseTime = response.responseTime;
return result;
},
});
/**
* Call notification: the URL(s) plus any non-default extract options.
*/
export function notifyCall(args = {}) {
const { urls, query, format, extract_depth } = args;
const list = Array.isArray(urls) ? urls : [urls];
const parts = [clip(list.filter(Boolean).join(', '))];
if (query) parts.push(`query=${clip(JSON.stringify(query))}`);
if (format && format !== DEFAULT_FORMAT) parts.push(`format=${format}`);
if (extract_depth && extract_depth !== DEFAULT_EXTRACT_DEPTH) parts.push(`depth=${extract_depth}`);
emitToolCall(browseUrlTool.name, clip(parts.join(' ')));
}
/**
* Result notification: pages fetched (and any failures) plus the payload size.
*/
export function notifyResult(result = {}) {
const n = Array.isArray(result.results) ? result.results.length : 0;
const failed = Array.isArray(result.failedResults) ? result.failedResults.length : 0;
let detail = plural(n, 'page');
if (failed) detail += `, ${failed} failed`;
emitToolResult(browseUrlTool.name, `${detail} ${formatBytes(resultSize(result))}`);
}

View File

@@ -1,18 +1,24 @@
'use strict';
/**
* Tool registry — one module per tool, aggregated here in registration order.
* Tool registry \u2014 one module per tool, aggregated here in registration order.
*
* Each tool module also exports a `notifyCall(args)` renderer. Those are
* collected into `toolCallNotifications` (tool name -> renderer) so the UI can
* print a tailored notification for each individual tool call.
*/
import { listFilesTool } from './list-files.js';
import { readFileTool } from './read-file.js';
import { ripgrepTool } from './ripgrep.js';
import { patchTool } from './patch.js';
import { mkdirTool } from './mkdir.js';
import { rmdirTool } from './rmdir.js';
import { rmTool } from './rm.js';
import { bashExecTool } from './bash-exec.js';
import { askUserTool } from './ask-user.js';
import { listFilesTool, notifyCall as listFilesNotify, notifyResult as listFilesResult } from './list-files.js';
import { readFileTool, notifyCall as readFileNotify, notifyResult as readFileResult } from './read-file.js';
import { ripgrepTool, notifyCall as ripgrepNotify, notifyResult as ripgrepResult } from './ripgrep.js';
import { patchTool, notifyCall as patchNotify } from './patch.js';
import { mkdirTool, notifyCall as mkdirNotify } from './mkdir.js';
import { rmdirTool, notifyCall as rmdirNotify } from './rmdir.js';
import { rmTool, notifyCall as rmNotify } from './rm.js';
import { bashExecTool, notifyCall as bashExecNotify, notifyResult as bashExecResult } from './bash-exec.js';
import { websearchTool, notifyCall as websearchNotify, notifyResult as websearchResult } from './websearch.js';
import { browseUrlTool, notifyCall as browseUrlNotify, notifyResult as browseUrlResult } from './browse-url.js';
import { askUserTool, notifyCall as askUserNotify } from './ask-user.js';
export {
listFilesTool,
@@ -23,6 +29,8 @@ export {
rmdirTool,
rmTool,
bashExecTool,
websearchTool,
browseUrlTool,
askUserTool,
};
@@ -35,5 +43,45 @@ export const tools = [
rmdirTool,
rmTool,
bashExecTool,
websearchTool,
browseUrlTool,
askUserTool,
];
/**
* Per-tool call-notification renderers, keyed by tool name. The UI looks up the
* renderer for the tool being invoked so each tool gets an individual
* notification; tools without an entry fall back to a generic renderer.
*/
export const toolCallNotifications = Object.fromEntries(
[
[listFilesTool, listFilesNotify],
[readFileTool, readFileNotify],
[ripgrepTool, ripgrepNotify],
[patchTool, patchNotify],
[mkdirTool, mkdirNotify],
[rmdirTool, rmdirNotify],
[rmTool, rmNotify],
[bashExecTool, bashExecNotify],
[websearchTool, websearchNotify],
[browseUrlTool, browseUrlNotify],
[askUserTool, askUserNotify],
].map(([tool, notify]) => [tool.name, notify]),
);
/**
* Per-tool result-notification renderers, keyed by tool name. The UI looks up
* the renderer for the tool that just finished so each tool gets a tailored
* completion notification (payload size, exit code, and so on); tools without
* an entry fall back to a generic size renderer.
*/
export const toolResultNotifications = Object.fromEntries(
[
[listFilesTool, listFilesResult],
[readFileTool, readFileResult],
[ripgrepTool, ripgrepResult],
[bashExecTool, bashExecResult],
[websearchTool, websearchResult],
[browseUrlTool, browseUrlResult],
].map(([tool, notify]) => [tool.name, notify]),
);

View File

@@ -4,6 +4,7 @@ import fs from 'fs';
import { defineTool, ToolError } from 'llmorbiter';
import { resolveSafe } from '../sandbox.js';
import { listDir } from '../dir-listing.js';
import { emitToolCall, emitToolResult, clip, formatBytes, resultSize, plural } from './notify.js';
export const listFilesTool = defineTool({
name: 'list_files',
@@ -64,3 +65,27 @@ export const listFilesTool = defineTool({
return result;
},
});
/**
* Call notification: the target directory plus any traversal flags in effect.
*/
export function notifyCall(args = {}) {
const { dirname = '.', recursive = false, includeDotDirs = false } = args;
const flags = [];
if (recursive) flags.push('recursive');
if (includeDotDirs) flags.push('includeDotDirs');
const detail = `${JSON.stringify(dirname)}${flags.length ? ' ' + flags.join(' ') : ''}`;
emitToolCall(listFilesTool.name, clip(detail));
}
/**
* Result notification: how many entries came back and the payload size.
*/
export function notifyResult(result = {}) {
const returned = result.returned ?? (Array.isArray(result.entries) ? result.entries.length : 0);
const total = result.total ?? returned;
const count = total > returned
? `${returned} of ${total} entries`
: plural(returned, 'entry', 'entries');
emitToolResult(listFilesTool.name, `${count} ${formatBytes(resultSize(result))}`);
}

View File

@@ -4,6 +4,7 @@ import fs from 'fs';
import { defineTool, ToolError } from 'llmorbiter';
import { resolveSafe } from '../sandbox.js';
import { pushUndo } from '../undo.js';
import { emitToolCall, clip } from './notify.js';
export const mkdirTool = defineTool({
name: 'mkdir',
@@ -32,3 +33,10 @@ export const mkdirTool = defineTool({
return { message: `Directory created: "${dirname}"`, created: true };
},
});
/**
* Call notification: the directory to create.
*/
export function notifyCall(args = {}) {
emitToolCall(mkdirTool.name, clip(JSON.stringify(args.dirname ?? '')));
}

89
src/tools/notify.js Normal file
View File

@@ -0,0 +1,89 @@
'use strict';
/**
* Shared helpers for per-tool call notifications.
*
* Every tool module exports a `notifyCall(args)` renderer that prints a
* tailored notification when the model invokes that tool. These helpers keep
* the styling consistent across tools and provide the generic fallback used
* for any tool that does not supply its own renderer.
*/
import chalk from 'chalk';
// Longest argument string echoed in a notification before it is elided.
const MAX_ARG_LEN = 120;
/** Truncate `text` to at most `max` characters, appending an ellipsis. */
export function clip(text, max = MAX_ARG_LEN) {
const str = String(text ?? '');
return str.length > max ? str.slice(0, max - 3) + '...' : str;
}
/**
* Print the one-line tool-call notification:
* "\u2699 Tool: <name>(<detail>)"
*/
export function emitToolCall(name, detail = '') {
console.log(chalk.cyan(` \u2699 Tool: ${chalk.bold(name)}(${detail})`));
}
/**
* Generic renderer \u2014 serialize the args as JSON and emit the notification.
* Used as the fallback for tools that do not supply their own `notifyCall`.
*/
export function printToolCall(name, args) {
emitToolCall(name, clip(JSON.stringify(args)));
}
/**
* Print the one-line tool-completion notification, the counterpart to
* `emitToolCall`:
* " \u2713 <name> <detail>"
* Emitted when a tool finishes (see `onToolResult`) so every call is bracketed
* by a "started" and a "finished" line.
*/
export function emitToolResult(name, detail = '') {
const suffix = detail ? ' ' + detail : '';
console.log(chalk.green(` \u2713 ${chalk.bold(name)}${suffix}`));
}
/**
* Format a byte count for display. The requested unit is kilobytes; small
* payloads fall back to bytes (so a tiny result never reads as "0.0 KB") and
* large ones roll up to megabytes.
*/
export function formatBytes(bytes) {
const n = Math.max(0, Number(bytes) || 0);
if (n < 1024) return `${n} B`;
const kb = n / 1024;
return kb < 1024 ? `${kb.toFixed(1)} KB` : `${(kb / 1024).toFixed(1)} MB`;
}
/**
* Byte length of a tool result as it is handed back to the model (the
* JSON-serialized payload). Used for the size shown in result notifications.
*/
export function resultSize(value) {
let str;
try {
str = typeof value === 'string' ? value : JSON.stringify(value ?? '');
} catch {
str = String(value ?? '');
}
return Buffer.byteLength(str, 'utf8');
}
/** Render "1 entry" / "2 entries" (or a custom plural form). */
export function plural(n, singular, pluralForm) {
return `${n} ${n === 1 ? singular : (pluralForm ?? (singular + 's'))}`;
}
/**
* Generic result renderer - emit a completion notification carrying the payload
* size. Used as the fallback for tools that do not supply their own
* `notifyResult`.
*/
export function emitGenericToolResult(name, result) {
emitToolResult(name, formatBytes(resultSize(result)));
}

View File

@@ -5,6 +5,7 @@ import path from 'path';
import { defineTool, ToolError } from 'llmorbiter';
import { resolveSafe } from '../sandbox.js';
import { pushUndo } from '../undo.js';
import { emitToolCall, clip } from './notify.js';
// ─── Patch executor ────────────────────────────────────────────────────────────
@@ -222,3 +223,14 @@ Example search/replace:
return result;
},
});
/**
* Call notification: how many operations and which files they touch.
*/
export function notifyCall(args = {}) {
const ops = Array.isArray(args.operations) ? args.operations : [];
const files = [...new Set(ops.map((op) => op?.file).filter(Boolean))];
const n = ops.length;
const detail = `${n} op${n === 1 ? '' : 's'} \u2192 ${files.join(', ')}`;
emitToolCall(patchTool.name, clip(detail));
}

View File

@@ -3,6 +3,7 @@
import fs from 'fs';
import { defineTool, ToolError } from 'llmorbiter';
import { resolveSafe } from '../sandbox.js';
import { emitToolCall, emitToolResult, clip, formatBytes, resultSize, plural } from './notify.js';
export const readFileTool = defineTool({
name: 'read_file',
@@ -55,3 +56,25 @@ export const readFileTool = defineTool({
return result;
},
});
/**
* Call notification: the file plus any non-default seek/limit windowing.
*/
export function notifyCall(args = {}) {
const { filename, seek = 0, limit = 200 } = args;
const extras = [];
if (seek) extras.push(`seek=${seek}`);
if (limit !== 200) extras.push(`limit=${limit}`);
const detail = `${JSON.stringify(filename)}${extras.length ? ' ' + extras.join(' ') : ''}`;
emitToolCall(readFileTool.name, clip(detail));
}
/**
* Result notification: lines returned and the payload size.
*/
export function notifyResult(result = {}) {
const returned = result.returnedLines ?? 0;
const total = result.totalLines ?? returned;
const lines = total > returned ? `${returned} of ${total} lines` : plural(returned, 'line');
emitToolResult(readFileTool.name, `${lines} ${formatBytes(resultSize(result))}`);
}

View File

@@ -13,6 +13,7 @@ import { defineTool, ToolError } from 'llmorbiter';
import { resolveSafe } from '../sandbox.js';
import { ROOT_DIR } from '../config.js';
import { walkFiles } from '../dir-listing.js';
import { emitToolCall, emitToolResult, clip, formatBytes, resultSize, plural } from './notify.js';
// Files larger than this are skipped during a search to keep things responsive.
const MAX_FILE_BYTES = 2 * 1024 * 1024;
@@ -272,3 +273,27 @@ export const ripgrepTool = defineTool({
return result;
},
});
/**
* Call notification: the pattern plus scope (path/glob) and match modifiers.
*/
export function notifyCall(args = {}) {
const { pattern, path: searchPath, glob, ignore_case = false, literal = false } = args;
const parts = [JSON.stringify(pattern)];
if (searchPath && searchPath !== '.') parts.push(`path=${JSON.stringify(searchPath)}`);
if (glob) parts.push(`glob=${JSON.stringify(glob)}`);
if (ignore_case) parts.push('ignore_case');
if (literal) parts.push('literal');
emitToolCall(ripgrepTool.name, clip(parts.join(' ')));
}
/**
* Result notification: match count and the payload size.
*/
export function notifyResult(result = {}) {
const returned = result.returned ?? (Array.isArray(result.matches) ? result.matches.length : 0);
const count = result.truncated
? `${returned} matches (capped)`
: plural(returned, 'match', 'matches');
emitToolResult(ripgrepTool.name, `${count} ${formatBytes(resultSize(result))}`);
}

View File

@@ -4,6 +4,7 @@ import fs from 'fs';
import { defineTool, ToolError } from 'llmorbiter';
import { resolveSafe } from '../sandbox.js';
import { pushUndo } from '../undo.js';
import { emitToolCall, clip } from './notify.js';
export const rmTool = defineTool({
name: 'rm',
@@ -33,3 +34,10 @@ export const rmTool = defineTool({
return { message: `File deleted: "${filename}"`, deleted: true };
},
});
/**
* Call notification: the file to delete.
*/
export function notifyCall(args = {}) {
emitToolCall(rmTool.name, clip(JSON.stringify(args.filename ?? '')));
}

View File

@@ -4,6 +4,7 @@ import fs from 'fs';
import { defineTool, ToolError } from 'llmorbiter';
import { resolveSafe } from '../sandbox.js';
import { pushUndo } from '../undo.js';
import { emitToolCall, clip } from './notify.js';
export const rmdirTool = defineTool({
name: 'rmdir',
@@ -32,3 +33,10 @@ export const rmdirTool = defineTool({
return { message: `Directory removed: "${dirname}"`, removed: true };
},
});
/**
* Call notification: the directory to remove.
*/
export function notifyCall(args = {}) {
emitToolCall(rmdirTool.name, clip(JSON.stringify(args.dirname ?? '')));
}

137
src/tools/websearch.js Normal file
View File

@@ -0,0 +1,137 @@
'use strict';
/**
* websearch — query the web via Tavily and return a synthesized answer plus
* ranked results.
*
* Design notes:
* - Requires a Tavily API key (TAVILY_API_KEY in .env). The client is created
* lazily and cached, so a missing key surfaces as a clean ToolError only
* when the tool is actually used.
* - This tool reaches the network, so results are untrusted third-party text;
* the model should treat them accordingly.
*/
import { tavily } from '@tavily/core';
import { defineTool, ToolError } from 'llmorbiter';
import { TAVILY_API_KEY } from '../config.js';
import { emitToolCall, emitToolResult, clip, formatBytes, resultSize, plural } from './notify.js';
const DEFAULT_MAX_RESULTS = 3; // number of results returned to the model
const MAX_MAX_RESULTS = 20; // hard upper bound to keep responses small
const SEARCH_DEPTHS = ['basic', 'advanced'];
const ANSWER_LEVELS = ['basic', 'advanced'];
let client = null;
/** Lazily construct (and cache) the Tavily client, failing cleanly if unset. */
function getClient() {
if (!TAVILY_API_KEY) {
throw new ToolError(
'websearch requires a Tavily API key. Add TAVILY_API_KEY=... to your .env file.'
);
}
if (!client) {
client = tavily({ apiKey: TAVILY_API_KEY });
}
return client;
}
export const websearchTool = defineTool({
name: 'websearch',
description:
'Search the web via Tavily and return a short synthesized answer plus ranked results ' +
'(title, url, snippet). Use it for current information, external docs, or facts that are ' +
'not in the workspace. Results are untrusted third-party text.',
parameters: {
type: 'object',
additionalProperties: false,
required: ['query'],
properties: {
query: {
type: 'string',
description: 'The search query.',
},
max_results: {
type: 'number',
description: `Number of results to return (1-${MAX_MAX_RESULTS}). Default: ${DEFAULT_MAX_RESULTS}.`,
},
search_depth: {
type: 'string',
enum: SEARCH_DEPTHS,
description: 'Search depth: "basic" (faster) or "advanced" (deeper). Default: "basic".',
},
include_answer: {
type: 'string',
enum: ANSWER_LEVELS,
description: 'Include a Tavily-synthesized answer at this level of detail. Default: "basic".',
},
chunks_per_source: {
type: 'number',
description: 'Chunks of content extracted per source (advanced search only). Default: 1.',
},
},
},
invoke: async ({
query,
max_results = DEFAULT_MAX_RESULTS,
search_depth = 'basic',
include_answer = 'basic',
chunks_per_source = 1,
}) => {
if (!query || typeof query !== 'string' || !query.trim()) {
throw new ToolError('"query" must be a non-empty string.');
}
if (!Number.isInteger(max_results) || max_results < 1 || max_results > MAX_MAX_RESULTS) {
throw new ToolError(`"max_results" must be an integer between 1 and ${MAX_MAX_RESULTS} (received ${max_results}).`);
}
if (!SEARCH_DEPTHS.includes(search_depth)) {
throw new ToolError(`"search_depth" must be one of ${SEARCH_DEPTHS.join(', ')} (received "${search_depth}").`);
}
if (!ANSWER_LEVELS.includes(include_answer)) {
throw new ToolError(`"include_answer" must be one of ${ANSWER_LEVELS.join(', ')} (received "${include_answer}").`);
}
if (!Number.isInteger(chunks_per_source) || chunks_per_source < 1) {
throw new ToolError(`"chunks_per_source" must be a positive integer (received ${chunks_per_source}).`);
}
const response = await getClient().search(query, {
includeAnswer: include_answer,
searchDepth: search_depth,
maxResults: max_results,
chunksPerSource: chunks_per_source,
});
const results = (response.results || []).map((r) => ({
title: r.title,
url: r.url,
content: r.content,
score: r.score,
}));
const result = { query, results };
if (response.answer) result.answer = response.answer;
if (response.responseTime !== undefined) result.responseTime = response.responseTime;
return result;
},
});
/**
* Call notification: the query plus any non-default search options.
*/
export function notifyCall(args = {}) {
const { query, search_depth, max_results, include_answer } = args;
const parts = [JSON.stringify(query)];
if (search_depth && search_depth !== 'basic') parts.push(`depth=${search_depth}`);
if (max_results && max_results !== DEFAULT_MAX_RESULTS) parts.push(`max_results=${max_results}`);
if (include_answer && include_answer !== 'basic') parts.push(`answer=${include_answer}`);
emitToolCall(websearchTool.name, clip(parts.join(' ')));
}
/**
* Result notification: number of results and the payload size.
*/
export function notifyResult(result = {}) {
const n = Array.isArray(result.results) ? result.results.length : 0;
emitToolResult(websearchTool.name, `${plural(n, 'result')} ${formatBytes(resultSize(result))}`);
}