diff --git a/.env.example b/.env.example index e8a5cc2..c429cc1 100644 --- a/.env.example +++ b/.env.example @@ -13,8 +13,8 @@ TAVILY_API_KEY=tvly-dev-... # Optional: File to persist the undo log (relative to workspace root) UNDO_FILE=.fido-undo.json -# Optional: File to persist /suspend snapshots (relative to workspace root) -SUSPEND_FILE=.fido-suspends.json +# Optional: Directory for /suspend snapshots, one JSON file each (relative to workspace root) +SUSPEND_DIR=.fido-suspends # Optional: Workspace root directory (defaults to cwd when running fido) # ROOT_DIR=. diff --git a/src/compact.js b/src/compact.js new file mode 100644 index 0000000..b202d06 --- /dev/null +++ b/src/compact.js @@ -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; +} diff --git a/src/config.js b/src/config.js index 8530619..47e83b3 100644 --- a/src/config.js +++ b/src/config.js @@ -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 diff --git a/src/fido.js b/src/fido.js index df07be7..3461df5 100644 --- a/src/fido.js +++ b/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) { diff --git a/src/suspend.js b/src/suspend.js index a34a8d4..41271ab 100644 --- a/src/suspend.js +++ b/src/suspend.js @@ -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 ` 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 `/.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; } diff --git a/src/tools/bash-exec.js b/src/tools/bash-exec.js index 3f2f4e7..15b9b0d 100644 --- a/src/tools/bash-exec.js +++ b/src/tools/bash-exec.js @@ -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.'); } /**