From 5aab24998dce1b25b227446ccfc69482d8bd1f77 Mon Sep 17 00:00:00 2001 From: seb Date: Wed, 23 Sep 2026 00:50:44 +0200 Subject: [PATCH] upd --- .env.example | 13 ++- README.md | 42 +++++++++ install.sh | 174 ++++++++++++++++++++++++++++++++++++++ package.json | 2 +- src/autocomplete-input.js | 13 ++- src/compact.js | 152 +++++++++++++++++++++++++++++++++ src/config.js | 13 ++- src/fido.js | 106 +++++++++++++++++++++-- 8 files changed, 499 insertions(+), 16 deletions(-) create mode 100755 install.sh diff --git a/.env.example b/.env.example index c429cc1..f110d58 100644 --- a/.env.example +++ b/.env.example @@ -10,11 +10,20 @@ MODEL=xiaomi/mimo-v2.6-flash # Optional: Tavily API key — enables the websearch and browse_url tools TAVILY_API_KEY=tvly-dev-... -# Optional: File to persist the undo log (relative to workspace root) +# Optional: File to persist the undo log (relative to workspace root). +# When run via the ~/.fido/bin/fido launcher this is overridden to a +# per-workspace path under ~/.fido/state//undo.json. UNDO_FILE=.fido-undo.json -# Optional: Directory for /suspend snapshots, one JSON file each (relative to workspace root) +# Optional: Directory for /suspend snapshots, one JSON file each (relative to workspace root). +# When run via the ~/.fido/bin/fido launcher this is overridden to a +# per-workspace path under ~/.fido/state//suspends. SUSPEND_DIR=.fido-suspends # Optional: Workspace root directory (defaults to cwd when running fido) # ROOT_DIR=. + +# Optional: Set to 1 to log compaction stats each turn (patch tool calls +# compacted / stale read_file results invalidated). Useful for observing the +# between-turns transcript compaction; unset keeps normal runs quiet. +# COMPACT_DEBUG=1 diff --git a/README.md b/README.md index cf72f23..09c0fa0 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,48 @@ Fido is a console REPL assistant that inspects files, lists directories, searches file contents, applies changes, and runs shell commands (with user approval). +## Install + +Installs into `~/.fido` and adds a `fido` launcher to your `PATH` via `~/.bashrc`: + +```sh +git clone ~/src/fidoca +cd ~/src/fidoca +./install.sh +source ~/.bashrc +``` + +The installer needs the sibling `llmorbiter` checkout (this project depends on it +via `file:../llmorbiter`); it is bundled into `~/.fido/llmorbiter`. Override its +location with `LLMORBITER_SRC=/path/to/llmorbiter`. + +Then, from any project directory: + +```sh +fido # work on the current directory +fido /path/to/project # work on a specific directory +``` + +Set `OPENROUTER_API_KEY` (and optionally `TAVILY_API_KEY`) in `~/.fido/.env`. + +### Layout + +``` +~/.fido/ + app/ the fido app (this repo) + llmorbiter/ bundled dependency + bin/fido the launcher (added to PATH) + .env your config / API keys + state// per-workspace state + undo.json + suspends/ +``` + +Undo and `/suspend` state is stored **per workspace** under `~/.fido/state/`, so it +never mixes between projects and never litters your repositories. + +To remove everything: `./install.sh --uninstall`. + ## License [0BSD](https://opensource.org/license/0bsd) diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..4484212 --- /dev/null +++ b/install.sh @@ -0,0 +1,174 @@ +#!/usr/bin/env bash +# +# install.sh — install Fido into ~/.fido and wire it into your shell. +# +# Usage: +# ./install.sh # install (or re-install) into $FIDO_HOME (default ~/.fido) +# FIDO_HOME=/opt/fido ./install.sh +# LLMORBITER_SRC=/path/to/llmorbiter ./install.sh +# ./install.sh --uninstall # remove the install and the PATH entry +# ./install.sh --help +# +# Environment: +# FIDO_HOME install directory (default: ~/.fido) +# LLMORBITER_SRC path to the llmorbiter checkout (default: sibling of this repo) +# BASHRC shell rc file to edit (default: ~/.bashrc) +# FIDO_SKIP_DEPS=1 skip `npm install` (useful for testing) +# +# What it does: +# 1. Copies the app into $FIDO_HOME/app +# 2. Bundles the llmorbiter dep $FIDO_HOME/llmorbiter (required by "file:../llmorbiter") +# 3. Copies your config to $FIDO_HOME/.env +# 4. Installs the launcher at $FIDO_HOME/bin/fido +# 5. Runs `npm install` for the app and for llmorbiter +# 6. Adds $FIDO_HOME/bin to PATH in ~/.bashrc +# +# All undo/suspend state lives under $FIDO_HOME/state//. + +set -euo pipefail + +FIDO_HOME="${FIDO_HOME:-$HOME/.fido}" + +# Directory of this script = the repo root. +SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# llmorbiter is a sibling checkout by default; override with LLMORBITER_SRC. +LLMORBITER_SRC="${LLMORBITER_SRC:-$SRC_DIR/../llmorbiter}" + +BASHRC="${BASHRC:-$HOME/.bashrc}" +PATH_MARKER_BEGIN="# >>> fido >>>" +PATH_MARKER_END="# <<< fido <<<" + +info() { printf '\033[36m▸\033[0m %s\n' "$*"; } +warn() { printf '\033[33m!\033[0m %s\n' "$*" >&2; } +die() { printf '\033[31m✖\033[0m %s\n' "$*" >&2; exit 1; } + +usage() { + sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//' +} + +# Copy a source tree, skipping build/state noise and dotfile cruft. +copy_tree() { + local src="$1" dst="$2" + mkdir -p "$dst" + if command -v rsync >/dev/null 2>&1; then + rsync -a --delete \ + --exclude 'node_modules' --exclude '.git' \ + --exclude '.env' --exclude '.fido-*' \ + "$src/" "$dst/" + else + local tmp + tmp="$(mktemp -d)" + tar -C "$src" \ + --exclude=./node_modules --exclude=./.git \ + --exclude=./.env --exclude='./.fido-*' \ + -cf - . | tar -C "$tmp" -xf - + rm -rf "$dst" + mkdir -p "$dst" + cp -a "$tmp/." "$dst/" + rm -rf "$tmp" + fi +} + +add_to_path() { + if [ -f "$BASHRC" ] && grep -qF "$PATH_MARKER_BEGIN" "$BASHRC"; then + info "PATH entry already present in $BASHRC" + return + fi + { + printf '\n%s\n' "$PATH_MARKER_BEGIN" + printf '# Added by the Fido installer — makes the "fido" launcher available.\n' + printf 'export PATH="%s/bin:$PATH"\n' "$FIDO_HOME" + printf '%s\n' "$PATH_MARKER_END" + } >> "$BASHRC" + info "Added $FIDO_HOME/bin to PATH in $BASHRC" +} + +remove_from_path() { + [ -f "$BASHRC" ] || return + if grep -qF "$PATH_MARKER_BEGIN" "$BASHRC"; then + sed -i "/^# >>> fido >>>$/,/^# <<< fido <<<$/d" "$BASHRC" + info "Removed PATH entry from $BASHRC" + fi +} + +do_install() { + command -v node >/dev/null 2>&1 || die "node not found in PATH — install Node.js first." + command -v npm >/dev/null 2>&1 || die "npm not found in PATH." + + [ -f "$SRC_DIR/src/fido.js" ] || die "src/fido.js not found — run this script from the Fido repo root." + [ -f "$SRC_DIR/bin/fido" ] || die "bin/fido not found — is this a complete checkout?" + [ -d "$LLMORBITER_SRC" ] || die "llmorbiter dependency not found at: $LLMORBITER_SRC +Set LLMORBITER_SRC=/path/to/llmorbiter and re-run." + + info "Installing Fido into $FIDO_HOME" + mkdir -p "$FIDO_HOME/bin" "$FIDO_HOME/state" + + info "Copying app -> $FIDO_HOME/app" + copy_tree "$SRC_DIR" "$FIDO_HOME/app" + + info "Bundling llmorbiter -> $FIDO_HOME/llmorbiter" + copy_tree "$LLMORBITER_SRC" "$FIDO_HOME/llmorbiter" + + # Config: never clobber an existing install's .env (users edit it in place). + if [ -f "$FIDO_HOME/.env" ]; then + info "Keeping existing $FIDO_HOME/.env" + elif [ -f "$SRC_DIR/.env" ]; then + cp "$SRC_DIR/.env" "$FIDO_HOME/.env" + info "Copied .env -> $FIDO_HOME/.env" + else + cp "$SRC_DIR/.env.example" "$FIDO_HOME/.env" + warn "No .env found — wrote $FIDO_HOME/.env from .env.example." + warn "Edit it and set OPENROUTER_API_KEY before running fido." + fi + + info "Installing launcher -> $FIDO_HOME/bin/fido" + cp "$SRC_DIR/bin/fido" "$FIDO_HOME/bin/fido" + chmod +x "$FIDO_HOME/bin/fido" + + if [ "${FIDO_SKIP_DEPS:-0}" = "1" ]; then + warn "FIDO_SKIP_DEPS=1 — skipping npm install." + else + info "Installing dependencies (npm install) — this may take a moment…" + ( cd "$FIDO_HOME/llmorbiter" && npm install --no-audit --no-fund ) + ( cd "$FIDO_HOME/app" && npm install --no-audit --no-fund ) + fi + + add_to_path + + cat </ (undo.json + suspends/) + + Activate in this shell: source "$BASHRC" + (or just open a new terminal.) + + Then run, from any project directory: + fido # work on the current directory + fido /path/to/project # work on a specific directory + + Remember to set OPENROUTER_API_KEY in $FIDO_HOME/.env. +EOF +} + +do_uninstall() { + remove_from_path + if [ -d "$FIDO_HOME" ]; then + info "Removing $FIDO_HOME" + rm -rf "$FIDO_HOME" + else + warn "$FIDO_HOME does not exist — nothing to remove." + fi + info "Fido uninstalled. Open a new shell (or re-source $BASHRC) to drop the PATH entry." +} + +case "${1:-}" in + ""|--install|-i) do_install ;; + --uninstall|-u) do_uninstall ;; + --help|-h) usage ;; + *) die "Unknown argument: $1 (try --help)" ;; +esac diff --git a/package.json b/package.json index 2274ade..4017315 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "main": "src/fido.js", "scripts": { "start": "node src/fido.js", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "node --test" }, "dependencies": { "@inquirer/core": "^10.3.2", diff --git a/src/autocomplete-input.js b/src/autocomplete-input.js index 091ae65..217539f 100644 --- a/src/autocomplete-input.js +++ b/src/autocomplete-input.js @@ -21,6 +21,7 @@ import { createPrompt, useState, useKeypress, + useEffect, usePrefix, isEnterKey, isTabKey, @@ -55,9 +56,10 @@ function replaceBuffer(rl, nextValue) { export const autocompleteInput = createPrompt((config, done) => { const theme = makeTheme({}, config.theme); + const initial = typeof config.default === 'string' ? config.default : ''; const [status, setStatus] = useState('idle'); - const [value, setValue] = useState(''); - const [term, setTerm] = useState(null); // frozen filter term while the menu is open + const [value, setValue] = useState(initial); + const [term, setTerm] = useState(termFrom(initial)); // frozen filter term while the menu is open const [active, setActive] = useState(0); // highlighted suggestion index const prefix = usePrefix({ status, theme }); @@ -100,6 +102,13 @@ export const autocompleteInput = createPrompt((config, done) => { setActive(0); }); + // Prefill the editable readline buffer (and mirror it into `value`) when an + // initial value is supplied — used to restore a message that a Ctrl+C + // cancelled back into the prompt so it can be edited and resent. + useEffect((rl) => { + if (initial) rl.write(initial); + }, []); + const message = theme.style.message(config.message); const formattedValue = status === 'done' ? theme.style.answer(value) : value; diff --git a/src/compact.js b/src/compact.js index b202d06..764e669 100644 --- a/src/compact.js +++ b/src/compact.js @@ -10,8 +10,18 @@ * 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. + * + * `invalidateStaleReadResults` attacks the same problem from the other side: + * a `read_file` result carries a whole file body, and once a later `patch` + * writes that file the body no longer matches what is on disk. Keeping it is + * both a token waste and a correctness hazard (the model may reason from stale + * contents). The stale result is replaced by a short "file was overwritten + * meanwhile" marker that tells the model to re-read if it needs the file. */ +import path from 'path'; +import { ROOT_DIR } from './config.js'; + // Lines kept from the top and bottom of each long text field. const HEAD_LINES = 5; const TAIL_LINES = 5; @@ -92,3 +102,145 @@ export function compactPatchToolCalls(messages) { } return compacted; } + +// ─── Stale read_file results ──────────────────────────────────────────────────── + +// Marker carried by a replaced read_file result. Detected verbatim so repeated +// passes over the transcript stay idempotent (an already-invalidated result is +// left as-is instead of being wrapped again). +const STALE_MARKER = 'file was overwritten meanwhile'; + +// Explanatory text carried by the replacement. Like the omission marker above, +// this is written by the memory-management system, not by the model — the note +// says so explicitly so the model does not mistake it for its own earlier +// output or for real file contents. +const STALE_NOTE = + `${STALE_MARKER} — stale contents elided by the memory management system ` + + 'to save tokens; re-read with read_file for the current contents'; + +// `applyOperations` tags each change it makes as either an overwrite or a +// search/replace; both mean the file body a prior read returned is now stale. +const APPLIED_RE = /^(?:CREATE\/WRITE|PATCH in) (.+)$/; + +/** + * Canonical key for a workspace-relative path, so a path the model wrote as + * "src/x.js" matches one written as "./src/x.js" or with different separators. + */ +function canonicalPath(p) { + if (typeof p !== 'string' || !p) return null; + try { + return path.resolve(ROOT_DIR, p); + } catch { + return p; + } +} + +/** The replacement payload handed to the model in place of a stale read. */ +function staleReadResult(filename) { + return JSON.stringify({ filename, note: STALE_NOTE }); +} + +/** + * Extract the files a `patch` result actually wrote. Both successful results + * (`applied`) and partially-failed ones (`appliedSoFar`) list the mutations, so + * files that were never touched are correctly left alone. Returns canonical + * path keys; an unparseable result yields an empty list. + */ +function filesWrittenByPatch(resultContent) { + let res = resultContent; + if (typeof res === 'string') { + try { + res = JSON.parse(res); + } catch { + return []; + } + } + if (!res || typeof res !== 'object') return []; + const applied = Array.isArray(res.applied) + ? res.applied + : Array.isArray(res.appliedSoFar) + ? res.appliedSoFar + : []; + const files = new Set(); + for (const entry of applied) { + if (typeof entry !== 'string') continue; + const hit = APPLIED_RE.exec(entry); + if (hit) { + const key = canonicalPath(hit[1]); + if (key) files.add(key); + } + } + return [...files]; +} + +/** + * Replace `read_file` tool results in `messages` with a short "file was + * overwritten meanwhile" marker whenever a later `patch` wrote the same file. + * + * A read result is stale only if the file changed *after* it was read, so we + * compare transcript positions: a read is invalidated when a successful patch + * write sits at a later index. Reads taken after their last patch (which still + * reflect disk) are left untouched, as are reads for files no patch ever wrote. + * + * The rewrite only fires when it strictly shrinks the payload, so a tiny read + * result is never traded for a longer marker. Repeated passes are idempotent. + * Returns the number of results invalidated. + */ +export function invalidateStaleReadResults(messages) { + if (!Array.isArray(messages)) return 0; + + // Index every tool call so a tool result can be mapped back to (name, args). + const callInfo = new Map(); + 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 || !tc.id) continue; + let args = {}; + try { + args = JSON.parse(tc.function?.arguments || '{}'); + } catch { + // Malformed arguments: the call stays indexed but argless. + } + callInfo.set(tc.id, { name: tc.function?.name, args }); + } + } + + // Every place a patch wrote a file, keyed by transcript position. + const writes = []; + for (let i = 0; i < messages.length; i++) { + const m = messages[i]; + if (!m || m.role !== 'tool') continue; + if (callInfo.get(m.tool_call_id)?.name !== 'patch') continue; + const files = filesWrittenByPatch(m.content); + if (files.length) writes.push({ index: i, files }); + } + if (writes.length === 0) return 0; + + let invalidated = 0; + for (let i = 0; i < messages.length; i++) { + const m = messages[i]; + if (!m || m.role !== 'tool') continue; + const info = callInfo.get(m.tool_call_id); + if (info?.name !== 'read_file') continue; + + const key = canonicalPath(info.args?.filename); + if (!key) continue; + + const content = typeof m.content === 'string' ? m.content : ''; + // Already replaced on an earlier pass — leave it alone. + if (content.includes(STALE_MARKER)) continue; + + // Stale only if some patch wrote this file after the read happened. + const stale = writes.some((w) => w.index > i && w.files.includes(key)); + if (!stale) continue; + + const replacement = staleReadResult(info.args.filename); + // Never grow the payload: only swap when the marker is actually smaller. + if (replacement.length >= content.length) continue; + + m.content = replacement; + invalidated++; + } + return invalidated; +} + diff --git a/src/config.js b/src/config.js index 47e83b3..cfeadcf 100644 --- a/src/config.js +++ b/src/config.js @@ -22,9 +22,10 @@ import dotenv from 'dotenv'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Load .env from the fido project root (one level up from src/) -dotenv.config({ path: path.resolve(__dirname, '..', '.env') }); +// `quiet: true` suppresses dotenv's "injected env (N) from .env" banner. +dotenv.config({ path: path.resolve(__dirname, '..', '.env'), quiet: true }); // Also try cwd-relative .env -dotenv.config({ path: path.resolve(process.cwd(), '.env') }); +dotenv.config({ path: path.resolve(process.cwd(), '.env'), quiet: true }); export const API_KEY = process.env.OPENROUTER_API_KEY; export const MODEL = process.env.MODEL || 'xiaomi/mimo-v2.6-flash'; @@ -32,6 +33,14 @@ export const UNDO_FILE = process.env.UNDO_FILE || '.fido-undo.json'; export const SUSPEND_DIR = process.env.SUSPEND_DIR || '.fido-suspends'; export const TAVILY_API_KEY = process.env.TAVILY_API_KEY; +// When set to a truthy value (1/true/yes/on), the REPL logs how many transcript +// entries each between-turns compaction pass rewrote. Useful for verifying that +// stale read_file results are being elided, and that repeated passes are +// idempotent (they report 0). Off by default so normal runs stay quiet. +export const COMPACT_DEBUG = ['1', 'true', 'yes', 'on'].includes( + (process.env.COMPACT_DEBUG || '').toLowerCase() +); + // Root dir: first positional arg wins, then env ROOT_DIR, then cwd const args = process.argv.slice(2); export const ROOT_DIR = path.resolve( diff --git a/src/fido.js b/src/fido.js index 3461df5..d0fb5ac 100644 --- a/src/fido.js +++ b/src/fido.js @@ -22,13 +22,13 @@ import path from 'path'; import chalk from 'chalk'; -import { editor, input, select } from '@inquirer/prompts'; +import { editor, input, select, confirm } from '@inquirer/prompts'; import { marked } from 'marked'; import { markedTerminal } from 'marked-terminal'; import { LLMOrbiter, OpenRouterProvider } from 'llmorbiter'; -import { API_KEY, MODEL, UNDO_FILE, SUSPEND_DIR, ROOT_DIR } from './config.js'; +import { API_KEY, MODEL, UNDO_FILE, SUSPEND_DIR, ROOT_DIR, COMPACT_DEBUG } from './config.js'; import { listDir } from './dir-listing.js'; import { loadUndoLog, getUndoLog } from './undo.js'; import { loadSuspends, addSuspend, getSuspends } from './suspend.js'; @@ -36,7 +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'; +import { compactPatchToolCalls, invalidateStaleReadResults } from './compact.js'; // ─── Markdown → console renderer ─────────────────────────────────────────────── @@ -221,11 +221,23 @@ function printToolError(name, error) { console.log(chalk.red(` ✖ Tool error [${name}]: ${error}`)); } -function printUsage(u) { +function printUsage(u, totals) { const parts = [`in=${u.input}`, `out=${u.output}`]; if (u.cached) parts.push(`cached=${u.cached}`); if (u.cost) parts.push(`cost=$${u.cost.toFixed(5)}`); - console.log(chalk.dim(` 📊 ${parts.join(' ')}`)); + let line = ` 📊 ${parts.join(' ')}`; + if (totals) { + totals.calls += 1; + totals.input += u.input || 0; + totals.output += u.output || 0; + totals.cached += u.cached || 0; + totals.cost += u.cost || 0; + const acc = [`in=${totals.input}`, `out=${totals.output}`]; + if (totals.cached) acc.push(`cached=${totals.cached}`); + if (totals.cost) acc.push(`cost=$${totals.cost.toFixed(5)}`); + line += ` [(${totals.calls}) ${acc.join(', ')}]`; + } + console.log(chalk.dim(line)); } function formatWhen(iso) { @@ -271,10 +283,20 @@ async function main() { loadSuspends(); printBanner(); + // Most recent reasoning the model streamed during the active turn. The + // provider reports reasoning cumulatively, so we keep the latest value. If a + // turn is cancelled mid-flight this is offered back to the user so it can be + // preserved in the transcript instead of being discarded. + let turnReasoning = ''; + const provider = new OpenRouterProvider({ apiKey: API_KEY }); + // Running totals across the session, echoed after each per-call usage line. + const usageTotals = { calls: 0, input: 0, output: 0, cached: 0, cost: 0 }; + const orbiter = new LLMOrbiter(provider, { onStatus: (s) => printStatus(s), + onReasoning: (text) => { turnReasoning = text; }, onToolCall: (c) => { const notify = toolCallNotifications[c.name]; if (notify) notify(c.args); @@ -287,20 +309,26 @@ async function main() { printToolResult(r.name, r.result); }, onToolError: (e) => printToolError(e.name, e.error), - onUsage: (u) => printUsage(u), + onUsage: (u) => printUsage(u, usageTotals), }); // Conversation state: persisted across turns const systemPrompt = buildSystemPrompt(); let messages = [{ role: 'system', content: systemPrompt }]; - console.log(chalk.dim(' Type your request below. Type / for commands, /paste for multiline. Press Ctrl+C to exit.\n')); + console.log(chalk.dim(' Type your request below. Type / for commands, /paste for multiline.')); + console.log(chalk.dim(' Ctrl+C exits at the prompt; while the model is running it cancels the turn.\n')); + + // Restored into the prompt after a Ctrl+C cancels a running turn, so the + // message can be edited and resent instead of retyped. + let draft = ''; while (true) { let userInput; try { userInput = await autocompleteInput({ message: chalk.bold.cyan('You'), + default: draft, theme: { prefix: chalk.cyan('›'), style: { @@ -314,6 +342,7 @@ async function main() { console.log(chalk.dim('\n Goodbye! 🐕\n')); break; } + draft = ''; if (!userInput.trim()) continue; @@ -436,6 +465,15 @@ async function main() { console.log(''); + // No prompt owns the terminal while the model works, so a Ctrl+C would + // otherwise hit Node's default handler and kill the process. Catch it + // here instead: abort the active run and fall back through to the prompt + // (prefilled with the message) rather than exiting. + // Fresh reasoning buffer for this turn. + turnReasoning = ''; + + const onSigint = () => orbiter.abort('cancelled by user'); + process.on('SIGINT', onSigint); try { const result = await orbiter.run({ prompt: userInput, @@ -450,11 +488,61 @@ async function main() { // 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); + const compactedPatches = compactPatchToolCalls(messages); + // And the other half of the same problem: a read_file result carries a + // whole file body that a later patch made stale. Replace it with a + // short "file was overwritten meanwhile" marker so the old contents are + // not re-sent every turn (nor mistaken for the current file). + const invalidatedReads = invalidateStaleReadResults(messages); + if (COMPACT_DEBUG) { + console.log(chalk.dim( + ` [compact] patch tool calls compacted: ${compactedPatches} · ` + + `stale read results invalidated: ${invalidatedReads}` + )); + } printAssistant(result.content); } catch (err) { - console.log(chalk.red(`\n ✖ Error: ${err.message}\n`)); + if (err && err.name === 'AbortError') { + // Ctrl+C cancelled the run — put the message back in the prompt + // so it can be edited and resent. The aborted transcript is + // discarded, so `messages` is untouched and the turn is not lost. + draft = userInput; + console.log(chalk.dim('\n ⏹ Cancelled — your message is back in the prompt; edit it and press Enter to resend.\n')); + + // If the model already produced reasoning before the cancel, + // offer to keep it. Kept reasoning is added to the transcript + // exactly like normal reasoning (an assistant message carrying + // the reasoning text), tagged with a marker at the end so it's + // clear the turn was cut short. + const reasoning = turnReasoning.trim(); + if (reasoning) { + let keep = false; + try { + keep = await confirm({ + message: chalk.bold.cyan('Keep the reasoning the model produced before cancelling?'), + default: true, + theme: { + prefix: chalk.cyan('›'), + style: { message: chalk.bold.cyan }, + }, + }); + } catch { + keep = false; // prompt dismissed (e.g. Ctrl+C) — don't keep + } + if (keep) { + messages.push({ + role: 'assistant', + content: `${reasoning}\n---USER-CANCELLED---`, + }); + console.log(chalk.dim(' ↗ Reasoning kept in the transcript.\n')); + } + } + } else { + console.log(chalk.red(`\n ✖ Error: ${err.message}\n`)); + } + } finally { + process.removeListener('SIGINT', onSigint); } } }