upd
This commit is contained in:
13
.env.example
13
.env.example
@@ -10,11 +10,20 @@ MODEL=xiaomi/mimo-v2.6-flash
|
|||||||
# Optional: Tavily API key — enables the websearch and browse_url tools
|
# Optional: Tavily API key — enables the websearch and browse_url tools
|
||||||
TAVILY_API_KEY=tvly-dev-...
|
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/<workspace>/undo.json.
|
||||||
UNDO_FILE=.fido-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/<workspace>/suspends.
|
||||||
SUSPEND_DIR=.fido-suspends
|
SUSPEND_DIR=.fido-suspends
|
||||||
|
|
||||||
# Optional: Workspace root directory (defaults to cwd when running fido)
|
# Optional: Workspace root directory (defaults to cwd when running fido)
|
||||||
# ROOT_DIR=.
|
# 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
|
||||||
|
|||||||
42
README.md
42
README.md
@@ -3,6 +3,48 @@
|
|||||||
Fido is a console REPL assistant that inspects files, lists directories, searches file
|
Fido is a console REPL assistant that inspects files, lists directories, searches file
|
||||||
contents, applies changes, and runs shell commands (with user approval).
|
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 <this-repo> ~/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/<workspace>/ 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
|
## License
|
||||||
|
|
||||||
[0BSD](https://opensource.org/license/0bsd)
|
[0BSD](https://opensource.org/license/0bsd)
|
||||||
|
|||||||
174
install.sh
Executable file
174
install.sh
Executable file
@@ -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/<workspace>/.
|
||||||
|
|
||||||
|
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 <<EOF
|
||||||
|
|
||||||
|
✅ Fido installed.
|
||||||
|
|
||||||
|
Home: $FIDO_HOME
|
||||||
|
Launcher: $FIDO_HOME/bin/fido
|
||||||
|
Config: $FIDO_HOME/.env
|
||||||
|
State: $FIDO_HOME/state/<workspace>/ (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
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
"main": "src/fido.js",
|
"main": "src/fido.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node src/fido.js",
|
"start": "node src/fido.js",
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "node --test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@inquirer/core": "^10.3.2",
|
"@inquirer/core": "^10.3.2",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
createPrompt,
|
createPrompt,
|
||||||
useState,
|
useState,
|
||||||
useKeypress,
|
useKeypress,
|
||||||
|
useEffect,
|
||||||
usePrefix,
|
usePrefix,
|
||||||
isEnterKey,
|
isEnterKey,
|
||||||
isTabKey,
|
isTabKey,
|
||||||
@@ -55,9 +56,10 @@ function replaceBuffer(rl, nextValue) {
|
|||||||
|
|
||||||
export const autocompleteInput = createPrompt((config, done) => {
|
export const autocompleteInput = createPrompt((config, done) => {
|
||||||
const theme = makeTheme({}, config.theme);
|
const theme = makeTheme({}, config.theme);
|
||||||
|
const initial = typeof config.default === 'string' ? config.default : '';
|
||||||
const [status, setStatus] = useState('idle');
|
const [status, setStatus] = useState('idle');
|
||||||
const [value, setValue] = useState('');
|
const [value, setValue] = useState(initial);
|
||||||
const [term, setTerm] = useState(null); // frozen filter term while the menu is open
|
const [term, setTerm] = useState(termFrom(initial)); // frozen filter term while the menu is open
|
||||||
const [active, setActive] = useState(0); // highlighted suggestion index
|
const [active, setActive] = useState(0); // highlighted suggestion index
|
||||||
|
|
||||||
const prefix = usePrefix({ status, theme });
|
const prefix = usePrefix({ status, theme });
|
||||||
@@ -100,6 +102,13 @@ export const autocompleteInput = createPrompt((config, done) => {
|
|||||||
setActive(0);
|
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 message = theme.style.message(config.message);
|
||||||
const formattedValue = status === 'done' ? theme.style.answer(value) : value;
|
const formattedValue = status === 'done' ? theme.style.answer(value) : value;
|
||||||
|
|
||||||
|
|||||||
152
src/compact.js
152
src/compact.js
@@ -10,8 +10,18 @@
|
|||||||
* are never needed again. `compactPatchToolCalls` rewrites each `patch` tool
|
* are never needed again. `compactPatchToolCalls` rewrites each `patch` tool
|
||||||
* call in the transcript so its long text fields keep only a head/tail excerpt,
|
* call in the transcript so its long text fields keep only a head/tail excerpt,
|
||||||
* with a marker noting how many lines were elided.
|
* 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.
|
// Lines kept from the top and bottom of each long text field.
|
||||||
const HEAD_LINES = 5;
|
const HEAD_LINES = 5;
|
||||||
const TAIL_LINES = 5;
|
const TAIL_LINES = 5;
|
||||||
@@ -92,3 +102,145 @@ export function compactPatchToolCalls(messages) {
|
|||||||
}
|
}
|
||||||
return compacted;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,9 +22,10 @@ import dotenv from 'dotenv';
|
|||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
// Load .env from the fido project root (one level up from src/)
|
// 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
|
// 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 API_KEY = process.env.OPENROUTER_API_KEY;
|
||||||
export const MODEL = process.env.MODEL || 'xiaomi/mimo-v2.6-flash';
|
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 SUSPEND_DIR = process.env.SUSPEND_DIR || '.fido-suspends';
|
||||||
export const TAVILY_API_KEY = process.env.TAVILY_API_KEY;
|
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
|
// Root dir: first positional arg wins, then env ROOT_DIR, then cwd
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
export const ROOT_DIR = path.resolve(
|
export const ROOT_DIR = path.resolve(
|
||||||
|
|||||||
106
src/fido.js
106
src/fido.js
@@ -22,13 +22,13 @@
|
|||||||
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import { editor, input, select } from '@inquirer/prompts';
|
import { editor, input, select, confirm } from '@inquirer/prompts';
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
import { markedTerminal } from 'marked-terminal';
|
import { markedTerminal } from 'marked-terminal';
|
||||||
|
|
||||||
import { LLMOrbiter, OpenRouterProvider } from 'llmorbiter';
|
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 { listDir } from './dir-listing.js';
|
||||||
import { loadUndoLog, getUndoLog } from './undo.js';
|
import { loadUndoLog, getUndoLog } from './undo.js';
|
||||||
import { loadSuspends, addSuspend, getSuspends } from './suspend.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 { printToolCall, emitGenericToolResult } from './tools/notify.js';
|
||||||
import { autocompleteInput } from './autocomplete-input.js';
|
import { autocompleteInput } from './autocomplete-input.js';
|
||||||
import { COMMANDS, findCommand } from './slash-commands.js';
|
import { COMMANDS, findCommand } from './slash-commands.js';
|
||||||
import { compactPatchToolCalls } from './compact.js';
|
import { compactPatchToolCalls, invalidateStaleReadResults } from './compact.js';
|
||||||
|
|
||||||
// ─── Markdown → console renderer ───────────────────────────────────────────────
|
// ─── Markdown → console renderer ───────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -221,11 +221,23 @@ function printToolError(name, error) {
|
|||||||
console.log(chalk.red(` ✖ Tool error [${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}`];
|
const parts = [`in=${u.input}`, `out=${u.output}`];
|
||||||
if (u.cached) parts.push(`cached=${u.cached}`);
|
if (u.cached) parts.push(`cached=${u.cached}`);
|
||||||
if (u.cost) parts.push(`cost=$${u.cost.toFixed(5)}`);
|
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) {
|
function formatWhen(iso) {
|
||||||
@@ -271,10 +283,20 @@ async function main() {
|
|||||||
loadSuspends();
|
loadSuspends();
|
||||||
printBanner();
|
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 });
|
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, {
|
const orbiter = new LLMOrbiter(provider, {
|
||||||
onStatus: (s) => printStatus(s),
|
onStatus: (s) => printStatus(s),
|
||||||
|
onReasoning: (text) => { turnReasoning = text; },
|
||||||
onToolCall: (c) => {
|
onToolCall: (c) => {
|
||||||
const notify = toolCallNotifications[c.name];
|
const notify = toolCallNotifications[c.name];
|
||||||
if (notify) notify(c.args);
|
if (notify) notify(c.args);
|
||||||
@@ -287,20 +309,26 @@ async function main() {
|
|||||||
printToolResult(r.name, r.result);
|
printToolResult(r.name, r.result);
|
||||||
},
|
},
|
||||||
onToolError: (e) => printToolError(e.name, e.error),
|
onToolError: (e) => printToolError(e.name, e.error),
|
||||||
onUsage: (u) => printUsage(u),
|
onUsage: (u) => printUsage(u, usageTotals),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Conversation state: persisted across turns
|
// Conversation state: persisted across turns
|
||||||
const systemPrompt = buildSystemPrompt();
|
const systemPrompt = buildSystemPrompt();
|
||||||
let messages = [{ role: 'system', content: systemPrompt }];
|
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) {
|
while (true) {
|
||||||
let userInput;
|
let userInput;
|
||||||
try {
|
try {
|
||||||
userInput = await autocompleteInput({
|
userInput = await autocompleteInput({
|
||||||
message: chalk.bold.cyan('You'),
|
message: chalk.bold.cyan('You'),
|
||||||
|
default: draft,
|
||||||
theme: {
|
theme: {
|
||||||
prefix: chalk.cyan('›'),
|
prefix: chalk.cyan('›'),
|
||||||
style: {
|
style: {
|
||||||
@@ -314,6 +342,7 @@ async function main() {
|
|||||||
console.log(chalk.dim('\n Goodbye! 🐕\n'));
|
console.log(chalk.dim('\n Goodbye! 🐕\n'));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
draft = '';
|
||||||
|
|
||||||
if (!userInput.trim()) continue;
|
if (!userInput.trim()) continue;
|
||||||
|
|
||||||
@@ -436,6 +465,15 @@ async function main() {
|
|||||||
|
|
||||||
console.log('');
|
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 {
|
try {
|
||||||
const result = await orbiter.run({
|
const result = await orbiter.run({
|
||||||
prompt: userInput,
|
prompt: userInput,
|
||||||
@@ -450,11 +488,61 @@ async function main() {
|
|||||||
// Token savings: the patch tool's arguments carry whole file bodies
|
// Token savings: the patch tool's arguments carry whole file bodies
|
||||||
// (search/replace). Once applied they are dead weight, so shrink each
|
// (search/replace). Once applied they are dead weight, so shrink each
|
||||||
// patch tool call in the transcript to a head/tail excerpt.
|
// 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);
|
printAssistant(result.content);
|
||||||
} catch (err) {
|
} 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user