This commit is contained in:
seb
2026-09-22 21:40:13 +02:00
parent ab2f953e06
commit 73e7f7e7f8
6 changed files with 246 additions and 35 deletions

94
src/compact.js Normal file
View 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;
}

View File

@@ -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

View File

@@ -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) {

View File

@@ -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;
}

View File

@@ -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.');
}
/**