u
This commit is contained in:
94
src/compact.js
Normal file
94
src/compact.js
Normal file
@@ -0,0 +1,94 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Transcript compaction — token-saving rewrites applied to the persisted
|
||||
* conversation history between turns.
|
||||
*
|
||||
* The `patch` tool's arguments embed entire file bodies: the `search` and
|
||||
* `replace` text of every operation. Once a patch has been applied those bodies
|
||||
* are pure dead weight — they are re-sent to the model on every later turn but
|
||||
* are never needed again. `compactPatchToolCalls` rewrites each `patch` tool
|
||||
* call in the transcript so its long text fields keep only a head/tail excerpt,
|
||||
* with a marker noting how many lines were elided.
|
||||
*/
|
||||
|
||||
// Lines kept from the top and bottom of each long text field.
|
||||
const HEAD_LINES = 5;
|
||||
const TAIL_LINES = 5;
|
||||
|
||||
// Explanatory text carried by the omission marker. The marker is inserted by
|
||||
// the memory-management system when long tool-call text is elided between turns
|
||||
// to save tokens. It is NOT written by the model, and the note says so
|
||||
// explicitly so the model does not mistake it for its own earlier output.
|
||||
const OMISSION_NOTE =
|
||||
'omitted from memory to save tokens — note to self: the Fido Agent did NOT ' +
|
||||
'write this note; the memory management system did';
|
||||
|
||||
/** Escape a string so it can be embedded literally inside a RegExp. */
|
||||
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
// Matches the omission marker line emitted below, so an already-compacted field
|
||||
// can be recognised and left alone (keeping repeated passes idempotent). Built
|
||||
// from OMISSION_NOTE so the pattern can never drift from the emitted marker.
|
||||
const OMISSION_MARKER_RE = new RegExp(`^… \\[\\d+ lines? ${escapeRegExp(OMISSION_NOTE)}\\] …$`);
|
||||
|
||||
/**
|
||||
* Keep the first `head` and last `tail` lines of `text`, inserting a marker that
|
||||
* notes how many lines were omitted. Text that is already short enough
|
||||
* (<= head + tail lines), or that already carries an omission marker, is
|
||||
* returned unchanged.
|
||||
*/
|
||||
export function excerptLines(text, head = HEAD_LINES, tail = TAIL_LINES) {
|
||||
if (typeof text !== 'string') return text;
|
||||
const lines = text.split('\n');
|
||||
if (lines.length <= head + tail) return text;
|
||||
// Already compacted — don't re-truncate (the marker itself would otherwise
|
||||
// be counted as an extra line and trimmed away on the next pass).
|
||||
if (lines.some((l) => OMISSION_MARKER_RE.test(l))) return text;
|
||||
const omitted = lines.length - head - tail;
|
||||
return [
|
||||
...lines.slice(0, head),
|
||||
`… [${omitted} line${omitted === 1 ? '' : 's'} ${OMISSION_NOTE}] …`,
|
||||
...lines.slice(-tail),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite every `patch` tool call in `messages` in place, replacing the long
|
||||
* `search` and `replace` fields of each operation with a head/tail excerpt.
|
||||
* Malformed arguments are left untouched. Returns the number of tool calls that
|
||||
* were actually compacted.
|
||||
*/
|
||||
export function compactPatchToolCalls(messages) {
|
||||
if (!Array.isArray(messages)) return 0;
|
||||
let compacted = 0;
|
||||
for (const m of messages) {
|
||||
if (!m || m.role !== 'assistant' || !Array.isArray(m.tool_calls)) continue;
|
||||
for (const tc of m.tool_calls) {
|
||||
if (tc?.function?.name !== 'patch' || typeof tc.function.arguments !== 'string') continue;
|
||||
let args;
|
||||
try {
|
||||
args = JSON.parse(tc.function.arguments);
|
||||
} catch {
|
||||
continue; // leave malformed arguments untouched
|
||||
}
|
||||
if (!args || !Array.isArray(args.operations)) continue;
|
||||
let changed = false;
|
||||
for (const op of args.operations) {
|
||||
if (!op || typeof op !== 'object') continue;
|
||||
for (const field of ['search', 'replace']) {
|
||||
const next = excerptLines(op[field]);
|
||||
if (next !== op[field]) {
|
||||
op[field] = next;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
tc.function.arguments = JSON.stringify(args);
|
||||
compacted++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return compacted;
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
* 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)
|
||||
* SUSPEND_DIR=.fido-suspends (optional, directory of /suspend snapshot files)
|
||||
* TAVILY_API_KEY=tvly-dev-... (optional, enables the websearch and browse_url tools)
|
||||
* ROOT_DIR=. (optional, defaults to cwd)
|
||||
*
|
||||
@@ -29,7 +29,7 @@ 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 SUSPEND_DIR = process.env.SUSPEND_DIR || '.fido-suspends';
|
||||
export const TAVILY_API_KEY = process.env.TAVILY_API_KEY;
|
||||
|
||||
// Root dir: first positional arg wins, then env ROOT_DIR, then cwd
|
||||
|
||||
11
src/fido.js
11
src/fido.js
@@ -28,7 +28,7 @@ import { markedTerminal } from 'marked-terminal';
|
||||
|
||||
import { LLMOrbiter, OpenRouterProvider } from 'llmorbiter';
|
||||
|
||||
import { API_KEY, MODEL, UNDO_FILE, SUSPEND_FILE, ROOT_DIR } from './config.js';
|
||||
import { API_KEY, MODEL, UNDO_FILE, SUSPEND_DIR, ROOT_DIR } from './config.js';
|
||||
import { listDir } from './dir-listing.js';
|
||||
import { loadUndoLog, getUndoLog } from './undo.js';
|
||||
import { loadSuspends, addSuspend, getSuspends } from './suspend.js';
|
||||
@@ -36,6 +36,7 @@ import { tools, toolCallNotifications, toolResultNotifications } from './tools/i
|
||||
import { printToolCall, emitGenericToolResult } from './tools/notify.js';
|
||||
import { autocompleteInput } from './autocomplete-input.js';
|
||||
import { COMMANDS, findCommand } from './slash-commands.js';
|
||||
import { compactPatchToolCalls } from './compact.js';
|
||||
|
||||
// ─── Markdown → console renderer ───────────────────────────────────────────────
|
||||
|
||||
@@ -141,7 +142,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(chalk.dim(` Suspends: ${path.resolve(ROOT_DIR, SUSPEND_DIR)}`));
|
||||
console.log('');
|
||||
}
|
||||
|
||||
@@ -391,7 +392,7 @@ async function main() {
|
||||
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`));
|
||||
console.log(chalk.dim(` ${count} snapshot${count === 1 ? '' : 's'} in ${SUSPEND_DIR}\n`));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -446,6 +447,10 @@ async function main() {
|
||||
|
||||
// Update conversation history for next turn
|
||||
messages = result.messages;
|
||||
// Token savings: the patch tool's arguments carry whole file bodies
|
||||
// (search/replace). Once applied they are dead weight, so shrink each
|
||||
// patch tool call in the transcript to a head/tail excerpt.
|
||||
compactPatchToolCalls(messages);
|
||||
|
||||
printAssistant(result.content);
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,49 +1,108 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Suspend snapshots — JSON array, newest-first, persisted on every save.
|
||||
* Suspend snapshots — one JSON file per snapshot, stored in a directory.
|
||||
*
|
||||
* `/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 }
|
||||
* Each snapshot is written to its own file in `SUSPEND_DIR`, named with a
|
||||
* sortable timestamp and a slug of the description so it can be opened and
|
||||
* edited individually:
|
||||
*
|
||||
* .fido-suspends/20240115-103000-fix-the-parser.json
|
||||
*
|
||||
* Each file contains: { description, createdAt, messages }
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { ROOT_DIR, SUSPEND_FILE } from './config.js';
|
||||
import { ROOT_DIR, SUSPEND_DIR } from './config.js';
|
||||
|
||||
let snapshots = [];
|
||||
|
||||
function suspendPath() {
|
||||
return path.resolve(ROOT_DIR, SUSPEND_FILE);
|
||||
function suspendDir() {
|
||||
return path.resolve(ROOT_DIR, SUSPEND_DIR);
|
||||
}
|
||||
|
||||
/** Filesystem-safe, sortable local timestamp: `YYYYMMDD-HHMMSS`. */
|
||||
function timestamp(date = new Date()) {
|
||||
const p = (n) => String(n).padStart(2, '0');
|
||||
return (
|
||||
`${date.getFullYear()}${p(date.getMonth() + 1)}${p(date.getDate())}` +
|
||||
`-${p(date.getHours())}${p(date.getMinutes())}${p(date.getSeconds())}`
|
||||
);
|
||||
}
|
||||
|
||||
/** Turn a description into a short, lowercase, filesystem-safe slug. */
|
||||
function slugify(text) {
|
||||
const slug = String(text ?? '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 60)
|
||||
.replace(/-+$/g, '');
|
||||
return slug || 'suspend';
|
||||
}
|
||||
|
||||
/** Non-colliding `<dir>/<base>.json` path (adds `-2`, `-3`, … if needed). */
|
||||
function uniquePath(dir, base) {
|
||||
let candidate = path.join(dir, `${base}.json`);
|
||||
let n = 2;
|
||||
while (fs.existsSync(candidate)) {
|
||||
candidate = path.join(dir, `${base}-${n}.json`);
|
||||
n++;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
/** Load every snapshot file from `SUSPEND_DIR`, newest first. */
|
||||
export function loadSuspends() {
|
||||
const p = suspendPath();
|
||||
if (fs.existsSync(p)) {
|
||||
snapshots = [];
|
||||
const dir = suspendDir();
|
||||
if (!fs.existsSync(dir)) return;
|
||||
|
||||
let names;
|
||||
try {
|
||||
names = fs.readdirSync(dir);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const loaded = [];
|
||||
for (const name of names) {
|
||||
if (!name.toLowerCase().endsWith('.json')) continue;
|
||||
const full = path.join(dir, name);
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(p, 'utf8'));
|
||||
snapshots = Array.isArray(data) ? data : [];
|
||||
if (!fs.statSync(full).isFile()) continue;
|
||||
const data = JSON.parse(fs.readFileSync(full, 'utf8'));
|
||||
if (data && typeof data === 'object') loaded.push(data);
|
||||
} catch {
|
||||
snapshots = [];
|
||||
// Skip unreadable or malformed snapshot files.
|
||||
}
|
||||
}
|
||||
|
||||
loaded.sort((a, b) =>
|
||||
String(b.createdAt ?? '').localeCompare(String(a.createdAt ?? '')),
|
||||
);
|
||||
snapshots = loaded;
|
||||
}
|
||||
|
||||
export function saveSuspends() {
|
||||
fs.writeFileSync(suspendPath(), JSON.stringify(snapshots, null, 2) + '\n', 'utf8');
|
||||
}
|
||||
|
||||
/** Capture `messages` under `description` (newest first) and persist. */
|
||||
/**
|
||||
* Capture `messages` under `description` (newest first) and write it to its own
|
||||
* file in `SUSPEND_DIR`. Returns the stored entry.
|
||||
*/
|
||||
export function addSuspend(description, messages) {
|
||||
const now = new Date();
|
||||
const entry = {
|
||||
description,
|
||||
createdAt: new Date().toISOString(),
|
||||
createdAt: now.toISOString(),
|
||||
messages,
|
||||
};
|
||||
const dir = suspendDir();
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const file = uniquePath(dir, `${timestamp(now)}-${slugify(description)}`);
|
||||
fs.writeFileSync(file, JSON.stringify(entry, null, 2) + '\n', 'utf8');
|
||||
snapshots.unshift(entry);
|
||||
saveSuspends();
|
||||
return entry;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,15 +11,16 @@
|
||||
* - Child processes run with color and interactivity disabled (see
|
||||
* NONINTERACTIVE_ENV) and with stdin closed, so a command can never block
|
||||
* on a terminal prompt.
|
||||
* - Every command requires interactive approval via the same inquirer/chalk
|
||||
* styling used by the ask_user tool.
|
||||
* - Every command requires interactive approval via a select menu (run once,
|
||||
* allow for the rest of this session, reject, or reject with a comment),
|
||||
* using the same inquirer/chalk styling as the ask_user tool.
|
||||
* - A hard timeout kills the whole process group; output is trimmed to the
|
||||
* last `tail_lines` lines before being returned to the model.
|
||||
*/
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
import chalk from 'chalk';
|
||||
import { confirm } from '@inquirer/prompts';
|
||||
import { input, select } from '@inquirer/prompts';
|
||||
import { defineTool, ToolError } from 'llmorbiter';
|
||||
import { ROOT_DIR } from '../config.js';
|
||||
import { emitToolCall, emitToolResult, clip } from './notify.js';
|
||||
@@ -80,6 +81,10 @@ function tailLines(text, maxLines) {
|
||||
};
|
||||
}
|
||||
|
||||
// Session-scoped approval: once the user answers "a", every later command runs
|
||||
// without prompting until fido exits.
|
||||
let sessionApproved = false;
|
||||
|
||||
/**
|
||||
* Ask the user to approve a command before it is executed.
|
||||
* Throws ToolError when the user declines or when no TTY is available.
|
||||
@@ -99,26 +104,74 @@ async function requestApproval(command, timeoutMs) {
|
||||
console.log(chalk.white(' ' + command.replace(/\r?\n/g, '\n ')));
|
||||
console.log('');
|
||||
|
||||
let approved = false;
|
||||
// A prior "yes for this session" skips the prompt entirely.
|
||||
if (sessionApproved) {
|
||||
console.log(chalk.dim(' ⚡ Auto-approved (allowed for this session).'));
|
||||
console.log('');
|
||||
return;
|
||||
}
|
||||
|
||||
let choice;
|
||||
try {
|
||||
approved = await confirm({
|
||||
choice = await select({
|
||||
message: chalk.bold.yellow('Approve running this command?'),
|
||||
default: false,
|
||||
choices: [
|
||||
{ name: 'Yes — run it once', value: 'Y' },
|
||||
{ name: 'Yes — allow for the rest of this session', value: 'a' },
|
||||
{ name: 'No — skip this command', value: 'n' },
|
||||
{ name: 'No — skip and leave a comment', value: 'c' },
|
||||
],
|
||||
default: 'Y',
|
||||
prefix: chalk.yellow('›'),
|
||||
theme: {
|
||||
prefix: chalk.yellow('›'),
|
||||
style: { message: chalk.bold.yellow },
|
||||
style: { message: chalk.bold.yellow, highlight: chalk.yellow.bold },
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Ctrl+C / EOF during the prompt → treat as a rejection.
|
||||
approved = false;
|
||||
choice = 'n';
|
||||
}
|
||||
|
||||
console.log('');
|
||||
if (!approved) {
|
||||
throw new ToolError('Command rejected by the user.');
|
||||
|
||||
if (choice === 'Y') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (choice === 'a') {
|
||||
sessionApproved = true;
|
||||
console.log(chalk.dim(' ✓ Allowed — shell commands will run without prompting for the rest of this session.'));
|
||||
console.log('');
|
||||
return;
|
||||
}
|
||||
|
||||
if (choice === 'c') {
|
||||
let comment = '';
|
||||
try {
|
||||
comment = (
|
||||
await input({
|
||||
message: chalk.bold.yellow('Reason (optional comment)'),
|
||||
theme: {
|
||||
prefix: chalk.yellow('›'),
|
||||
style: { answer: chalk.white, message: chalk.bold.yellow },
|
||||
},
|
||||
})
|
||||
).trim();
|
||||
} catch {
|
||||
// Ctrl+C / EOF during the comment prompt → reject with no comment.
|
||||
comment = '';
|
||||
}
|
||||
console.log('');
|
||||
throw new ToolError(
|
||||
comment
|
||||
? `Command rejected by the user. Comment: ${comment}`
|
||||
: 'Command rejected by the user.'
|
||||
);
|
||||
}
|
||||
|
||||
// 'n' or anything unexpected
|
||||
throw new ToolError('Command rejected by the user.');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user