u
This commit is contained in:
@@ -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'));
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
|
||||
214
src/fido.js
214
src/fido.js
@@ -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 {
|
||||
|
||||
@@ -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
52
src/suspend.js
Normal 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;
|
||||
}
|
||||
@@ -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 ?? '')));
|
||||
}
|
||||
|
||||
@@ -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
179
src/tools/browse-url.js
Normal 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))}`);
|
||||
}
|
||||
@@ -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]),
|
||||
);
|
||||
|
||||
@@ -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))}`);
|
||||
}
|
||||
|
||||
@@ -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
89
src/tools/notify.js
Normal 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)));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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))}`);
|
||||
}
|
||||
|
||||
@@ -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))}`);
|
||||
}
|
||||
|
||||
@@ -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 ?? '')));
|
||||
}
|
||||
|
||||
@@ -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
137
src/tools/websearch.js
Normal 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))}`);
|
||||
}
|
||||
Reference in New Issue
Block a user