u
This commit is contained in:
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 {
|
||||
|
||||
Reference in New Issue
Block a user