Compare commits

..

2 Commits

Author SHA1 Message Date
seb
e416ca09c1 u 2026-09-23 06:03:21 +02:00
seb
9f61a05e8b Add file browser, tests, and bin entrypoint 2026-09-23 06:01:42 +02:00
21 changed files with 937 additions and 116 deletions

View File

@@ -27,6 +27,34 @@ fido /path/to/project # work on a specific directory
Set `OPENROUTER_API_KEY` (and optionally `TAVILY_API_KEY`) in `~/.fido/.env`. Set `OPENROUTER_API_KEY` (and optionally `TAVILY_API_KEY`) in `~/.fido/.env`.
### File mentions
While typing a message, press **`Ctrl+O`** to open an in-prompt file browser.
It is `.gitignore`-aware and hides dot-directories, just like `list_files`.
| Key | Action |
| ----------- | -------------------------------------------------- |
| `↑` / `↓` | Move the highlight |
| `Enter` | Descend into a directory, or open the line selector|
| `Tab` | Mention the highlighted file (whole file) |
| `Backspace` | Go to the parent directory |
| `Esc` | Cancel back to the prompt |
In the line selector, `↑`/`↓` move the cursor, `v` starts/ends a range, `g`/`G`
jump to the first/last line, and `Enter` inserts the mention.
The mention is plain text spliced in at the cursor; the model reads the file
itself via `read_file` (it is a *reference*, not embedded contents):
| Selection | Inserted text |
| ----------- | ----------------------- |
| Whole file | `@src/fido.js` |
| Line range | `@src/fido.js:120-145` |
| Single line | `@src/fido.js:120` |
Paths are relative to the workspace root with forward slashes; line numbers are
1-based (the model maps them to `read_file`'s 0-indexed `seek`).
### Layout ### Layout
``` ```

61
bin/fido Executable file
View File

@@ -0,0 +1,61 @@
#!/usr/bin/env bash
#
# fido — launcher for the Fido coding assistant.
#
# Installed to $FIDO_HOME/bin/fido (default ~/.fido/bin/fido) by install.sh, but
# this file also works when run straight from a source checkout (bin/fido ->
# ../src/fido.js).
#
# All per-user state is kept under $FIDO_HOME:
# $FIDO_HOME/.env your API keys / model config
# $FIDO_HOME/state/<workspace>/undo.json the undo log for that workspace
# $FIDO_HOME/state/<workspace>/suspends/ /suspend snapshots for it
#
# Every workspace you run fido in gets its own state folder, so undo/suspend
# history never mixes between projects — and never litters the projects
# themselves (no more .fido-undo.json / .fido-suspends in your repos).
set -euo pipefail
FIDO_HOME="${FIDO_HOME:-$HOME/.fido}"
# ── Load user config ──────────────────────────────────────────────────────────
# Exported vars win over any .env inside the workspace, because dotenv (used by
# src/config.js) never overwrites variables already present in the environment.
if [ -f "$FIDO_HOME/.env" ]; then
set -a
# shellcheck disable=SC1090
. "$FIDO_HOME/.env"
set +a
fi
# ── Resolve the app directory ─────────────────────────────────────────────────
if [ -f "$FIDO_HOME/app/src/fido.js" ]; then
APP_DIR="$FIDO_HOME/app"
else
# Running from a source checkout: this script lives in <root>/bin/.
self_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
APP_DIR="$(cd "$self_dir/.." && pwd)"
fi
# ── Per-workspace state under $FIDO_HOME/state/<slug>/ ────────────────────────
# The workspace is the first argument (like the app itself: `fido /path/to/proj`),
# falling back to the current directory.
workspace="${1:-$PWD}"
if [ -d "$workspace" ]; then
workspace="$(cd "$workspace" && pwd)"
else
workspace="$PWD"
fi
# Human-readable, filesystem-safe slug + a short hash to guarantee uniqueness.
slug="$(printf '%s' "$workspace" | sed -e 's,^/,,' -e 's,/,_,g' -e 's,[^A-Za-z0-9._-],-,g')"
[ -n "$slug" ] || slug="root"
sum="$(printf '%s' "$workspace" | cksum | cut -d' ' -f1)"
state_dir="$FIDO_HOME/state/${slug}-${sum}"
mkdir -p "$state_dir/suspends"
export UNDO_FILE="$state_dir/undo.json"
export SUSPEND_DIR="$state_dir/suspends"
exec node "$APP_DIR/src/fido.js" "$@"

View File

@@ -14,6 +14,12 @@
* *
* The filter term is frozen while the menu is open, so completing a command * The filter term is frozen while the menu is open, so completing a command
* with Tab does not collapse the list — repeated ↑/↓ + Tab still works. * with Tab does not collapse the list — repeated ↑/↓ + Tab still works.
*
* It also hosts an in-prompt **file browser** (opened with Ctrl+O) with two
* modes: browse and line-select. The user picks a whole file (`@path`) or a
* line range (`@path:start-end`), and the reference is spliced into the draft
* at the caret. See `file-browser.js` for the pure helpers and PLAN.md for the
* design.
*/ */
import chalk from 'chalk'; import chalk from 'chalk';
@@ -22,18 +28,30 @@ import {
useState, useState,
useKeypress, useKeypress,
useEffect, useEffect,
useRef,
usePrefix, usePrefix,
isEnterKey, isEnterKey,
isTabKey, isTabKey,
isUpKey, isUpKey,
isDownKey, isDownKey,
isBackspaceKey,
makeTheme, makeTheme,
} from '@inquirer/core'; } from '@inquirer/core';
import { COMMANDS, filterCommands } from './slash-commands.js'; import { COMMANDS, filterCommands } from './slash-commands.js';
import {
listEntries,
readLines,
formatMention,
normalizeRange,
windowStart,
parentOf,
} from './file-browser.js';
const MAX_SUGGESTIONS = 6; const MAX_SUGGESTIONS = 6;
const NAME_WIDTH = Math.max(...COMMANDS.map((c) => c.name.length)); const NAME_WIDTH = Math.max(...COMMANDS.map((c) => c.name.length));
const BROWSE_WINDOW = 12; // visible rows in the file browser
const LINE_WINDOW = 18; // visible rows in the line selector
/** Show the inline menu only while the first word is being typed (no space yet). */ /** Show the inline menu only while the first word is being typed (no space yet). */
function termFrom(line) { function termFrom(line) {
@@ -62,13 +80,159 @@ export const autocompleteInput = createPrompt((config, done) => {
const [term, setTerm] = useState(termFrom(initial)); // 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
// ── File-browser state ──────────────────────────────────────────────
// mode: 'input' (normal editing) | 'browse' (file picker) | 'lines' (line selector)
const [mode, setMode] = useState('input');
const [cwd, setCwd] = useState(''); // browse dir, relative to ROOT_DIR ('' = root)
const [entries, setEntries] = useState([]); // listing for `cwd`
const [browseActive, setBrowseActive] = useState(0);
const [selFile, setSelFile] = useState(null); // file chosen in 'lines' mode
const [fileLines, setFileLines] = useState([]);
const [fileBinary, setFileBinary] = useState(false);
const [cursor, setCursor] = useState(0); // current line index in 'lines' mode
const [anchor, setAnchor] = useState(null); // range-selection anchor (line index) or null
const [notice, setNotice] = useState(''); // dim status/notice line
// The draft (and caret) captured when the browser opened, so a cancel can
// restore the untouched message and an insert knows where to splice.
const draftRef = useRef('');
const caretRef = useRef(0);
// Mirror `mode` into a ref so the SIGINT guard (installed once) sees it.
const modeRef = useRef('input');
modeRef.current = mode;
const prefix = usePrefix({ status, theme }); const prefix = usePrefix({ status, theme });
const suggestions = term === null ? [] : filterCommands(term).slice(0, MAX_SUGGESTIONS); const suggestions = term === null ? [] : filterCommands(term).slice(0, MAX_SUGGESTIONS);
const count = suggestions.length; const count = suggestions.length;
// ── Browser helpers ─────────────────────────────────────────────────
const openDir = (rel) => {
setCwd(rel);
setEntries(listEntries(rel));
setBrowseActive(0);
setNotice('');
};
const openFile = (rel) => {
const { lines, binary, error } = readLines(rel);
if (error) {
setNotice(`could not read ${rel}`);
setEntries(listEntries(cwd)); // refresh in case it moved/vanished
return;
}
setSelFile(rel);
setFileLines(lines);
setFileBinary(binary);
setCursor(0);
setAnchor(null);
setNotice(binary ? 'binary file — whole-file mention only' : '');
setMode('lines');
};
const cancelToInput = (rl) => {
replaceBuffer(rl, draftRef.current);
const back = draftRef.current.length - caretRef.current;
for (let i = 0; i < back; i++) rl.write(null, { ctrl: true, name: 'b' });
setMode('input');
setNotice('');
};
// Splice `text` into the captured draft at the captured caret, leaving the
// caret just after the inserted text, then return to normal input mode.
const insertMention = (rl, text) => {
const draft = draftRef.current;
const pos = Math.min(caretRef.current, draft.length);
const before = draft.slice(0, pos);
const after = draft.slice(pos);
const pad = before.length && !/\s$/.test(before) ? ' ' : '';
const next = before + pad + text + after;
replaceBuffer(rl, next);
for (let i = 0; i < after.length; i++) rl.write(null, { ctrl: true, name: 'b' });
setValue(next);
setTerm(termFrom(next));
setActive(0);
setMode('input');
setNotice('');
};
useKeypress((key, rl) => { useKeypress((key, rl) => {
if (status !== 'idle') return; if (status !== 'idle') return;
// ── Browse mode ─────────────────────────────────────────────────
if (mode === 'browse') {
const n = entries.length;
if (isUpKey(key)) {
if (n) setBrowseActive((browseActive - 1 + n) % n);
return;
}
if (isDownKey(key)) {
if (n) setBrowseActive((browseActive + 1) % n);
return;
}
if (isEnterKey(key)) {
const entry = entries[browseActive];
if (!entry) return;
if (entry.type === 'f') openFile(entry.rel);
else openDir(entry.rel); // dir or synthetic '..'
return;
}
if (isTabKey(key)) {
const entry = entries[browseActive];
if (entry && entry.type === 'f') insertMention(rl, formatMention(entry.rel));
return;
}
if (isBackspaceKey(key)) {
if (cwd) openDir(parentOf(cwd));
return;
}
if (key.name === 'escape') cancelToInput(rl);
return; // swallow every other key so the draft is untouched
}
// ── Line-select mode ────────────────────────────────────────────
if (mode === 'lines') {
const n = fileLines.length;
if (isUpKey(key)) {
setCursor(Math.max(0, cursor - 1));
return;
}
if (isDownKey(key)) {
setCursor(Math.max(0, Math.min(n - 1, cursor + 1)));
return;
}
if (!fileBinary && n > 0) {
if (key.name === 'v') { setAnchor(anchor === null ? cursor : null); return; }
if (key.name === 'g') { setCursor(0); return; }
if (key.name === 'G') { setCursor(n - 1); return; }
}
if (isEnterKey(key)) {
let text;
if (anchor !== null && n > 0) {
const { start, end } = normalizeRange(anchor, cursor);
text = formatMention(selFile, start, end);
} else {
text = formatMention(selFile);
}
insertMention(rl, text);
return;
}
if (key.name === 'escape' || isBackspaceKey(key)) {
setMode('browse');
setNotice('');
}
return;
}
// ── Input mode (normal editing) ─────────────────────────────────
// Ctrl+O opens the file browser at the workspace root.
if (key.ctrl && key.name === 'o') {
draftRef.current = rl.line;
caretRef.current = rl.cursor;
openDir('');
setMode('browse');
return;
}
if (isEnterKey(key)) { if (isEnterKey(key)) {
const answer = value || rl.line; const answer = value || rl.line;
setValue(answer); setValue(answer);
@@ -109,9 +273,80 @@ export const autocompleteInput = createPrompt((config, done) => {
if (initial) rl.write(initial); if (initial) rl.write(initial);
}, []); }, []);
// While browsing, Ctrl+C should return to the prompt, not abort the whole
// prompt. @inquirer/core installs its own SIGINT listener that rejects the
// prompt; wrap it so it only fires when we're in normal input mode.
useEffect((rl) => {
const existing = rl.listeners('SIGINT');
rl.removeAllListeners('SIGINT');
const guarded = (...args) => {
if (modeRef.current === 'input') existing.forEach((fn) => fn.apply(rl, args));
};
rl.on('SIGINT', guarded);
return () => {
rl.removeAllListeners('SIGINT');
existing.forEach((fn) => rl.on('SIGINT', fn));
};
}, []);
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;
// ── File-browser view ───────────────────────────────────────────────
if (mode === 'browse') {
const n = entries.length;
const start = windowStart(browseActive, n, BROWSE_WINDOW);
const end = Math.min(n, start + BROWSE_WINDOW);
const rows = [];
for (let i = start; i < end; i++) {
const e = entries[i];
const marker = i === browseActive ? chalk.cyan('❯') : ' ';
let label;
if (e.type === 'parent') label = chalk.dim('../');
else if (e.type === 'd') label = chalk.bold.cyan(`${e.name}/`);
else label = e.name;
rows.push(` ${marker} ${label}`);
}
const where = cwd ? `${cwd}/` : '(workspace root)';
const out = [
` ${chalk.bold.cyan('📁 File browser')} ${chalk.dim(where)}`,
rows.length ? rows.join('\n') : chalk.dim(' (empty directory)'),
];
if (notice) out.push(chalk.dim(` ${notice}`));
out.push(chalk.dim(' ↑/↓ move · Enter open · Tab mention · Backspace up · Esc cancel'));
return out.join('\n');
}
// ── Line-selector view ──────────────────────────────────────────────
if (mode === 'lines') {
const n = fileLines.length;
const out = [` ${chalk.bold.cyan(selFile)} ${chalk.dim('— select lines')}`];
if (fileBinary) {
out.push(chalk.dim(' (binary file — Enter mentions the whole file)'));
} else if (n === 0) {
out.push(chalk.dim(' (empty file — Enter mentions the whole file)'));
} else {
const start = windowStart(cursor, n, LINE_WINDOW);
const end = Math.min(n, start + LINE_WINDOW);
const width = String(n).length;
const lo = anchor === null ? null : Math.min(anchor, cursor);
const hi = anchor === null ? null : Math.max(anchor, cursor);
const rows = [];
for (let i = start; i < end; i++) {
const cur = i === cursor ? chalk.cyan('❯') : ' ';
const mark = lo !== null && i >= lo && i <= hi ? chalk.cyan('│') : ' ';
const num = chalk.dim(String(i + 1).padStart(width));
const text = fileLines[i].replace(/\t/g, ' ');
rows.push(` ${cur} ${num} ${mark} ${i === cursor ? chalk.white(text) : text}`);
}
out.push(rows.join('\n'));
}
if (notice) out.push(chalk.dim(` ${notice}`));
out.push(chalk.dim(' ↑/↓ move · v mark · g/G top/bottom · Enter insert · Esc back'));
return out.join('\n');
}
// ── Input view (unchanged) ──────────────────────────────────────────
const lines = suggestions.map((c, i) => { const lines = suggestions.map((c, i) => {
const marker = i === active % count ? chalk.cyan('❯') : ' '; const marker = i === active % count ? chalk.cyan('❯') : ' ';
return ` ${chalk.cyan(marker)} ${chalk.bold(c.name.padEnd(NAME_WIDTH + 2))} ${chalk.dim(c.description)}`; return ` ${chalk.cyan(marker)} ${chalk.bold(c.name.padEnd(NAME_WIDTH + 2))} ${chalk.dim(c.description)}`;

View File

@@ -97,38 +97,43 @@ function buildSystemPrompt() {
dirSection += `\n ... (${truncated} more entries not shown — use list_files to explore)`; dirSection += `\n ... (${truncated} more entries not shown — use list_files to explore)`;
} }
return `You are Fido, a coding assistant. return `You're Fido, a coding assistant. Inspect files, list dirs, edit code precisely, run shell cmds in the workspace.
You inspect files, list directories, apply precise code changes, and run shell commands within the workspace.
## Workspace ## Workspace
Root directory: ${ROOT_DIR} Root: ${ROOT_DIR}
Total files: ${total} Total files: ${total}
## Workspace root (${total} entries): ## Workspace root (${total} entries):
${dirSection} ${dirSection}
## Available Tools & Guidelines ## Tools & rules of thumb
- \`read_file(filename, seek, limit)\`: Inspect file contents and line numbers before modifying them. - \`read_file(filename, seek, limit)\`: read file w/ line nums. Inspect before editing.
- \`list_files(dirname, recursive, seek, limit, includeDotDirs)\`: Explore workspace folders (respects .gitignore; dot-directories such as .git are excluded unless includeDotDirs=true). - \`list_files(dirname, recursive, seek, limit, includeDotDirs)\`: list dir (non-recursive default). Honors .gitignore; dot-dirs (.git) hidden unless includeDotDirs=true. recursive=true walks subtree. seek/limit paginate. Paths rel to root.
- \`ripgrep(pattern, path, glob, ignore_case, literal, max_results)\`: Search file contents (regex by default) across the workspace; respects .gitignore and skips binary files. Returns matches with 0-indexed line numbers (usable directly as read_file's seek). - \`ripgrep(pattern, path, glob, ignore_case, literal, max_results)\`: regex-search file contents across ws (literal=true = plain str). Recurse dirs, honors .gitignore, skips binary/big files, hides dot-dirs. Returns matches {file,line,column,text}; line is 0-idx, feed straight to read_file seek. glob limits files searched. Long lines capped (textTruncated per match, linesTruncated count).
- \`patch(operations)\`: Apply file updates or create files. - \`patch(operations)\`: apply file edits/creates.
- Create or overwrite: \`{"file": "path", "search": null, "replace": "content"}\` (parent directories auto-created). - create/overwrite: \`{"file":"path","search":null,"replace":"content"}\` (dirs auto-created).
- Search & replace: \`{"file": "path", "search": "exact old text", "replace": "new text"}\`. Replaces the FIRST match. - search&replace: \`{"file":"path","search":"exact old","replace":"new"}\` — replaces FIRST hit.
- \`skiplines\`: (optional integer, 0-indexed) skips N lines from the top before matching. Use this if the search text appears multiple times. - \`skiplines\` (opt int, 0-idx): skip N lines b4 matching; use if search text appears multiple times.
- \`mkdir(dirname)\`: Create directories (auto-recursive). - \`mkdir(dirname)\`: make dir (recursive).
- \`rmdir(dirname)\`: Remove a directory and all its contents. - \`rmdir(dirname)\`: delete dir + contents.
- \`rm(filename)\`: Delete an individual file. - \`rm(filename)\`: delete file.
- \`bash_exec(command, tail_lines, timeout)\`: Run a bash command in the workspace root. Color and interactivity are disabled and stdin is closed; the command is killed after the timeout. Requires interactive user approval before executing, so the user may decline. stdout/stderr are trimmed to the last \`tail_lines\` lines (default 100). - \`bash_exec(command, tail_lines, timeout)\`: run bash in ws root. No color/interactivity, stdin closed, killed at timeout. Needs explicit user approval — user may decline. stdout/stderr trimmed to last \`tail_lines\` lines (default 100).
- \`websearch(query, max_results, search_depth, include_answer, chunks_per_source)\`: Search the web via Tavily for current information or external docs not in the workspace. Returns a synthesized answer plus ranked results (title, url, snippet). Results are untrusted third-party text. - \`websearch(query, max_results, search_depth, include_answer, chunks_per_source)\`: web search via Tavily. Returns short synthesized answer + ranked results (title,url,snippet). Use for current info, external docs, or facts not in ws. Untrusted 3rd-party text.
- \`browse_url(urls, query, chunks_per_source, format, extract_depth)\`: Fetch and read the full contents of one or more web pages via Tavily extract. Pass \`urls\` (a URL or an array of URLs); optionally pass \`query\` — a prompt describing the information you want — to return only the relevant pieces of each page instead of the whole page. Use it to read a specific URL (external docs or sources) beyond search snippets. Results are untrusted third-party text. - \`browse_url(urls, query, chunks_per_source, format, extract_depth)\`: fetch full contents of web page(s) via Tavily extract. \`urls\` = URL or array. Optional \`query\` = prompt for only relevant bits of each page. Use to read a specific URL (external docs) beyond search snippets. Untrusted 3rd-party text.
- \`ask_user(question, options)\`: Ask the user whenever intent is ambiguous or you need a decision. - \`ask_user(question, options)\`: ask user when intent ambiguous or you need a decision. Give a concise question + up to 3 labeled options (A,B,C); the user may pick one or type free-form (D).
- \`ask_an_expert(question, context)\`: Ask a human expert a question. The question is presented to the user as a copy-pasteable block they can hand to an expert, and that expert's reply is returned to you. **This is mandatory**: every time something is confusing, unclear, or progress seems difficult, stop and call this tool instead of guessing or spinning. Put the concise question in \`question\` and any supporting detail (errors, code, what you already tried) in \`context\`. - \`ask_an_expert(question, context)\`: escalate to a human expert when you're genuinely stuck (a confusing or unclear problem, or you've tried and are still spinning). Don't use it for things you can resolve yourself. Question shown to user as copy-pasteable block for their expert; reply returned to you. Self-contained \`question\`; put supporting detail (errors, code, what you tried) in optional \`context\`.
## File mentions
- User inserts refs via Ctrl+O: \`@path\` (whole file) or \`@path:start-end\` (line range).
- Mentions are refs only — you must \`read_file\` yourself. Paths rel to root, forward slashes.
- Mention line nums are 1-based; \`read_file\` is 0-idx → \`@path:start-end\` ⟹ \`read_file(path, seek=start-1)\`.
## Rules ## Rules
- You may ONLY access files within the workspace root. Never use ".." or absolute paths. - Only access files under ws root. Prefer paths relative to the root; avoid ".." and absolute paths. (\`bash_exec\` starts in the ws root, so relative paths resolve there.)
- Always inspect files with \`read_file\` before patching to ensure exact search string and indentation matches. - Always \`read_file\` before patching (confirm exact search text + indentation).
- Prefer targeted SEARCH/REPLACE operations over rewriting entire files. - Prefer targeted SEARCH/REPLACE over rewriting whole files.
- Multiple operations in a single \`patch\` call execute sequentially. - Multiple \`patch\` ops in one call run sequentially.
- Undo is tracked automatically per operation and managed externally by the user. - Undo tracked per op, managed externally by user.
`; `;
} }
@@ -321,7 +326,7 @@ async function main() {
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.')); 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')); console.log(chalk.dim(' Ctrl+O add a file mention. 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 // Restored into the prompt after a Ctrl+C cancels a running turn, so the
// message can be edited and resent instead of retyped. // message can be edited and resent instead of retyped.

142
src/file-browser.js Normal file
View File

@@ -0,0 +1,142 @@
'use strict';
/**
* File-browser helpers for prompt file mentions.
*
* Pure, terminal-free helpers used by the in-prompt file browser in
* `autocomplete-input.js`. Keeping them here means the listing, mention
* formatting and range math can be unit-tested without a TTY.
*
* Everything path-related stays relative to `ROOT_DIR` and uses forward
* slashes, matching the rest of the system (`list_files`, `read_file`,
* `ripgrep`). File reads are constrained to the workspace via `resolveSafe`.
*/
import fs from 'fs';
import path from 'path';
import { ROOT_DIR } from './config.js';
import { listDir } from './dir-listing.js';
import { resolveSafe } from './sandbox.js';
import { splitLines } from './lines.js';
/** Normalize a workspace-relative path to forward slashes, no trailing slash. */
function normalizeRel(rel) {
return String(rel || '').replace(/\\/g, '/').replace(/\/+$/, '');
}
/** Return the parent of a workspace-relative directory path ('' at the root). */
export function parentOf(cwd) {
const rel = normalizeRel(cwd);
if (!rel) return '';
const idx = rel.lastIndexOf('/');
return idx === -1 ? '' : rel.slice(0, idx);
}
/** Basename of a workspace-relative path. */
export function baseOf(rel) {
const clean = normalizeRel(rel);
const idx = clean.lastIndexOf('/');
return idx === -1 ? clean : clean.slice(idx + 1);
}
/**
* List the entries of a workspace-relative directory for the browser.
*
* Directories come first (then files), each alphabetically. A synthetic
* `..` entry is prepended unless we are already at the workspace root.
* Respects `.gitignore` and hides dot-directories by delegating to `listDir`.
*
* Returns an array of `{ name, rel, type }` where `type` is one of
* `'parent' | 'd' | 'f'` and `rel` is relative to `ROOT_DIR` (no trailing
* slash; '' for the parent entry at depth 1).
*/
export function listEntries(cwd = '') {
const rel = normalizeRel(cwd);
const dir = rel ? path.join(ROOT_DIR, rel) : ROOT_DIR;
const { entries } = listDir(dir, { seek: 0, limit: Number.MAX_SAFE_INTEGER });
const dirs = entries.filter((e) => e.type === 'd');
const files = entries.filter((e) => e.type === 'f');
const byName = (a, b) => a.path.localeCompare(b.path);
dirs.sort(byName);
files.sort(byName);
const out = [];
if (rel) out.push({ name: '..', rel: parentOf(rel), type: 'parent' });
for (const e of dirs) {
const childRel = normalizeRel(e.path);
out.push({ name: baseOf(childRel), rel: childRel, type: 'd' });
}
for (const e of files) {
const childRel = normalizeRel(e.path);
out.push({ name: baseOf(childRel), rel: childRel, type: 'f' });
}
return out;
}
/**
* Read a workspace-relative file as an array of lines (split on `\n`).
*
* Returns `{ lines, binary }`. An empty file yields `[]`. A file containing a
* NUL byte in its first 8000 bytes is reported as binary with no lines, so the
* line selector can fall back to a whole-file mention without garbling output.
* Read/permission errors yield `{ lines: [], binary: false, error: true }`
* rather than throwing, so the browser can refresh and keep going.
*/
export function readLines(relPath) {
let abs;
try {
abs = resolveSafe(relPath);
} catch {
return { lines: [], binary: false, error: true };
}
let buf;
try {
buf = fs.readFileSync(abs);
} catch {
return { lines: [], binary: false, error: true };
}
if (buf.subarray(0, 8000).includes(0)) {
return { lines: [], binary: true };
}
const text = buf.toString('utf8');
const lines = splitLines(text);
return { lines, binary: false };
}
/**
* Format a mention for insertion into the prompt.
*
* formatMention('a/b.js') -> '@a/b.js'
* formatMention('a/b.js', 10, 10) -> '@a/b.js:10'
* formatMention('a/b.js', 10, 20) -> '@a/b.js:10-20'
*/
export function formatMention(relPath, start, end) {
const p = normalizeRel(relPath);
if (start === undefined || start === null) return `@${p}`;
if (end === undefined || end === null || end === start) return `@${p}:${start}`;
return `@${p}:${start}-${end}`;
}
/**
* Normalize a marked range from two 0-based line indices to the 1-based
* `{ start, end }` shown to the user (start <= end).
*/
export function normalizeRange(anchor, cursor) {
const a = Math.min(anchor, cursor);
const b = Math.max(anchor, cursor);
return { start: a + 1, end: b + 1 };
}
/**
* Top index of the visible window of `size` rows that keeps `cursor` in view.
* Returns 0 when everything fits.
*/
export function windowStart(cursor, total, size) {
if (total <= size) return 0;
let start = cursor - Math.floor(size / 2);
if (start < 0) start = 0;
if (start + size > total) start = total - size;
return start;
}

22
src/lines.js Normal file
View File

@@ -0,0 +1,22 @@
'use strict';
/**
* Line-splitting helper shared by `read_file` and the file browser.
*
* Splitting on '\n' naively counts a trailing newline as the start of an extra
* empty line: "a\nb\nc\n" would become 4 elements. Editors and `wc -l` treat
* the final newline as *terminating* the last line, so the file has 3 lines -
* the same as the non-terminated variant "a\nb\nc". This helper matches that
* convention so callers stay internally consistent.
*
* An empty file yields no lines.
*/
export function splitLines(text) {
if (text.length === 0) return [];
const lines = text.split('\n');
// A trailing '\n' produces a final '' element that is a terminator, not a
// line; drop it. A file ending in a real blank line ("a\n\n") still keeps
// that blank line ("a", "").
if (lines[lines.length - 1] === '') lines.pop();
return lines;
}

View File

@@ -8,14 +8,10 @@ import { emitToolCall, clip } from './notify.js';
export const askExpertTool = defineTool({ export const askExpertTool = defineTool({
name: 'ask_an_expert', name: 'ask_an_expert',
description: description:
'Ask expert a question. You ' + 'Escalate to a human expert when you are genuinely stuck (a confusing or unclear problem, or ' +
'MUST use it every time something is confusing, unclear, or progress ' + 'you have tried and are still spinning). Do not use it for things you can resolve yourself. ' +
'seems difficult—do not keep guessing or spinning. The question is ' + 'Question shown to user as copy-pasteable block for their expert; reply returned to you. ' +
'presented to the user as a copy-pasteable block that they can hand to an ' + 'Self-contained `question`; put supporting detail (errors, code, what you tried) in optional `context`.',
'expert, and their reply (the expert\'s answer) is returned to you. ' +
'Provide a self-contained `question`; put any supporting detail the ' +
'expert will need (error messages, code, what you already tried) in the ' +
'optional `context`.',
parameters: { parameters: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
@@ -23,13 +19,12 @@ export const askExpertTool = defineTool({
properties: { properties: {
question: { question: {
type: 'string', type: 'string',
description: 'The question to put to the expert. Keep it concise and specific.', description: 'Question for the expert. Concise & specific.',
}, },
context: { context: {
type: 'string', type: 'string',
description: description:
'Optional supporting detail the expert needs to answer well ' + 'Opt supporting detail the expert needs (errors, code, what you tried).',
'(error messages, relevant code, what you already tried, etc.).',
}, },
}, },
}, },

View File

@@ -8,10 +8,8 @@ import { emitToolCall, clip } from './notify.js';
export const askUserTool = defineTool({ export const askUserTool = defineTool({
name: 'ask_user', name: 'ask_user',
description: description:
'Ask the user a question when you need clarification or a decision. ' + 'Ask user when you need clarification/decision. Give concise question + up to 3 labeled options (A,B,C); ' +
'Provide a concise question and up to 3 labelled options (A, B, C). ' + 'user may pick one or type free-form (D). Use instead of guessing when intent is ambiguous.',
'The user can pick an option or type a free-form answer (D). ' +
'Use this instead of guessing whenever intent is ambiguous.',
parameters: { parameters: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
@@ -19,11 +17,11 @@ export const askUserTool = defineTool({
properties: { properties: {
question: { question: {
type: 'string', type: 'string',
description: 'The question to ask the user.', description: 'Question for the user.',
}, },
options: { options: {
type: 'array', type: 'array',
description: 'Up to 3 short option labels for the user to choose from.', description: 'Up to 3 short option labels.',
items: { type: 'string' }, items: { type: 'string' },
maxItems: 3, maxItems: 3,
}, },

View File

@@ -240,11 +240,10 @@ function runCommand(command, timeoutMs) {
export const bashExecTool = defineTool({ export const bashExecTool = defineTool({
name: 'bash_exec', name: 'bash_exec',
description: description:
'Run a bash command in the workspace root and return its output. ' + 'Run a bash cmd in ws root, return output. ' +
'The command runs with color output and interactivity disabled, with stdin closed, ' + 'No color/interactivity, stdin closed, killed at timeout. ' +
'and is killed if it exceeds the timeout. ' + 'IMPORTANT: needs explicit user approval — user may decline. ' +
'IMPORTANT: it requires explicit interactive user approval before executing, so the ' + 'stdout/stderr trimmed to last `tail_lines` lines.',
'user may decline. Output (stdout and stderr) is trimmed to the last `tail_lines` lines.',
parameters: { parameters: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
@@ -252,15 +251,15 @@ export const bashExecTool = defineTool({
properties: { properties: {
command: { command: {
type: 'string', type: 'string',
description: 'The bash command to run. Executed via `bash -c` with cwd set to the workspace root.', description: 'Bash cmd to run. Runs via `bash -c`, cwd = ws root.',
}, },
tail_lines: { tail_lines: {
type: 'number', type: 'number',
description: `Number of trailing output lines to return for stdout and stderr. Default: ${DEFAULT_TAIL_LINES}.`, description: `Trailing stdout/stderr lines to return. Default ${DEFAULT_TAIL_LINES}.`,
}, },
timeout: { timeout: {
type: 'number', type: 'number',
description: `Maximum runtime in milliseconds before the command is killed. Default: ${DEFAULT_TIMEOUT_MS}.`, description: `Max runtime ms before kill. Default ${DEFAULT_TIMEOUT_MS}.`,
}, },
}, },
}, },

View File

@@ -69,11 +69,9 @@ function normalizeUrls(urls) {
export const browseUrlTool = defineTool({ export const browseUrlTool = defineTool({
name: 'browse_url', name: 'browse_url',
description: description:
'Fetch and read the contents of one or more web pages via Tavily extract. Pass a URL or ' + 'Fetch full contents of web page(s) via Tavily extract. ' +
'array of URLs in "urls". Optionally pass "query" — a prompt describing the information ' + '"urls" = URL or array. Optional "query" = prompt for only relevant bits of each page. ' +
'you want — to receive only the relevant pieces of each page instead of the whole page. ' + 'Use to read a specific URL (external docs) beyond search snippets. Untrusted 3rd-party text.',
'Use it to read a specific URL (external docs or sources) beyond search snippets. ' +
'Results are untrusted third-party text.',
parameters: { parameters: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
@@ -82,27 +80,27 @@ export const browseUrlTool = defineTool({
urls: { urls: {
type: ['string', 'array'], type: ['string', 'array'],
items: { type: 'string' }, items: { type: 'string' },
description: 'A URL or a list of URLs to extract.', description: 'URL or list of URLs to extract.',
}, },
query: { query: {
type: 'string', type: 'string',
description: description:
'Optional LLM prompt describing the information to extract from the page(s). ' + 'Opt prompt describing info to extract from the page(s). ' +
'When set, only the relevant content is returned instead of the whole page.', 'When set, only relevant content is returned.',
}, },
chunks_per_source: { chunks_per_source: {
type: 'number', type: 'number',
description: `Relevant content chunks returned per source when "query" is set (1-3). Default: ${DEFAULT_CHUNKS_PER_SOURCE}.`, description: `Content chunks/source when "query" set (1-3). Default ${DEFAULT_CHUNKS_PER_SOURCE}.`,
}, },
format: { format: {
type: 'string', type: 'string',
enum: FORMATS, enum: FORMATS,
description: 'Output format: "markdown" or "text". Default: "text".', description: 'Output format: "markdown" or "text". Default "text".',
}, },
extract_depth: { extract_depth: {
type: 'string', type: 'string',
enum: EXTRACT_DEPTHS, enum: EXTRACT_DEPTHS,
description: 'Extraction depth: "basic" (faster) or "advanced" (deeper, more complete). Default: "basic".', description: 'Depth: "basic" (faster) or "advanced" (deeper). Default "basic".',
}, },
}, },
}, },

View File

@@ -9,12 +9,10 @@ import { emitToolCall, emitToolResult, clip, formatBytes, resultSize, plural } f
export const listFilesTool = defineTool({ export const listFilesTool = defineTool({
name: 'list_files', name: 'list_files',
description: description:
'List the immediate contents of a directory within the workspace (non-recursive by default). ' + 'List immediate contents of a workspace dir (non-recursive default). ' +
'Entries matching .gitignore patterns (and node_modules/) are excluded automatically. ' + 'Honors .gitignore; node_modules/ skipped. ' +
'Dot-directories (names starting with ".", e.g. .git) are excluded by default; ' + 'Dot-dirs (".git") hidden unless includeDotDirs=true. recursive=true walks subtree. ' +
'set includeDotDirs=true to include them. ' + 'seek/limit paginate. Paths rel to root.',
'Set recursive=true to walk the full subtree. ' +
'Use seek/limit for pagination. Paths are relative to the workspace root.',
parameters: { parameters: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
@@ -22,23 +20,23 @@ export const listFilesTool = defineTool({
properties: { properties: {
dirname: { dirname: {
type: 'string', type: 'string',
description: 'Directory path relative to workspace root. Use "." for root.', description: 'Dir path rel to root. "." = root.',
}, },
recursive: { recursive: {
type: 'boolean', type: 'boolean',
description: 'If true, walk the full subtree. Default: false (immediate children only).', description: 'true = walk subtree. Default false.',
}, },
seek: { seek: {
type: 'number', type: 'number',
description: 'Number of entries to skip (for pagination). Default: 0.', description: 'Entries to skip (paging). Default 0.',
}, },
limit: { limit: {
type: 'number', type: 'number',
description: 'Maximum number of entries to return. Default: 50.', description: 'Max entries. Default 50.',
}, },
includeDotDirs: { includeDotDirs: {
type: 'boolean', type: 'boolean',
description: 'If true, include dot-directories (e.g. .git). Default: false.', description: 'true = include dot-dirs (.git). Default false.',
}, },
}, },
}, },

View File

@@ -8,7 +8,7 @@ import { emitToolCall, clip } from './notify.js';
export const mkdirTool = defineTool({ export const mkdirTool = defineTool({
name: 'mkdir', name: 'mkdir',
description: 'Create a directory in the workspace (automatically recursive if parent directories do not exist).', description: 'Make a dir in ws (recursive if parents missing).',
parameters: { parameters: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
@@ -16,7 +16,7 @@ export const mkdirTool = defineTool({
properties: { properties: {
dirname: { dirname: {
type: 'string', type: 'string',
description: 'Directory path relative to workspace root.', description: 'Dir path rel to root.',
}, },
}, },
}, },

View File

@@ -166,12 +166,12 @@ export function applyOperations(operations) {
export const patchTool = defineTool({ export const patchTool = defineTool({
name: 'patch', name: 'patch',
description: `Apply one or more patch operations to files in the workspace. description: `Apply one or more edit ops to files.
Each operation has: Each op:
- file: (required) relative path to target file - file: (req) rel path to target
- search: string to be replaced (exact match). If null, creating/overwriting the entire file (with automatic directory creation). - search: exact text to replace; null = create/overwrite whole file (dirs auto-created)
- replace: string to write - replace: text to write
- skiplines: (optional) line number offset to skip before matching (0-indexed) if there are multiple occurrences, since patch replaces the first hit. - skiplines: (opt) 0-idx offset to skip before matching; use when search text occurs multiple times (patch replaces first hit)
Example create file: Example create file:
[{"file": "src/utils.js", "search": null, "replace": "export const x = 1;\\n", "skiplines": null}] [{"file": "src/utils.js", "search": null, "replace": "export const x = 1;\\n", "skiplines": null}]
@@ -185,7 +185,7 @@ Example search/replace:
properties: { properties: {
operations: { operations: {
type: 'array', type: 'array',
description: 'List of patch operations to apply sequentially.', description: 'Ops to apply in order.',
items: { items: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
@@ -193,19 +193,19 @@ Example search/replace:
properties: { properties: {
file: { file: {
type: 'string', type: 'string',
description: 'Relative path to file.', description: 'Rel path.',
}, },
search: { search: {
type: ['string', 'null'], type: ['string', 'null'],
description: 'String to be replaced (exact match). If null, writes/creates the entire file.', description: 'Exact text to replace. null = write/create whole file.',
}, },
replace: { replace: {
type: 'string', type: 'string',
description: 'String to write.', description: 'Text to write.',
}, },
skiplines: { skiplines: {
type: ['number', 'null'], type: ['number', 'null'],
description: 'Optional line number offset to skip before searching for search text.', description: 'Opt 0-idx offset to skip before matching search.',
}, },
}, },
}, },

View File

@@ -3,13 +3,14 @@
import fs from 'fs'; import fs from 'fs';
import { defineTool, ToolError } from 'llmorbiter'; import { defineTool, ToolError } from 'llmorbiter';
import { resolveSafe } from '../sandbox.js'; import { resolveSafe } from '../sandbox.js';
import { splitLines } from '../lines.js';
import { emitToolCall, emitToolResult, clip, formatBytes, resultSize, plural } from './notify.js'; import { emitToolCall, emitToolResult, clip, formatBytes, resultSize, plural } from './notify.js';
export const readFileTool = defineTool({ export const readFileTool = defineTool({
name: 'read_file', name: 'read_file',
description: description:
'Read a file from the workspace. Use seek/limit to read in chunks. ' + 'Read a workspace file. seek/limit chunk it. ' +
'Returns truncation info when the file is larger than the limit.', 'Returns truncation info if file bigger than limit.',
parameters: { parameters: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
@@ -17,15 +18,15 @@ export const readFileTool = defineTool({
properties: { properties: {
filename: { filename: {
type: 'string', type: 'string',
description: 'File path relative to workspace root.', description: 'File path rel to root.',
}, },
seek: { seek: {
type: 'number', type: 'number',
description: 'Line number to start reading from (0-indexed). Default: 0.', description: 'Start line, 0-idx. Default 0.',
}, },
limit: { limit: {
type: 'number', type: 'number',
description: 'Maximum number of lines to return. Default: 200.', description: 'Max lines. Default 200.',
}, },
}, },
}, },
@@ -37,7 +38,7 @@ export const readFileTool = defineTool({
if (!fs.statSync(absPath).isFile()) { if (!fs.statSync(absPath).isFile()) {
throw new ToolError(`"${filename}" is not a file.`); throw new ToolError(`"${filename}" is not a file.`);
} }
const allLines = fs.readFileSync(absPath, 'utf8').split('\n'); const allLines = splitLines(fs.readFileSync(absPath, 'utf8'));
const total = allLines.length; const total = allLines.length;
const slice = allLines.slice(seek, seek + limit); const slice = allLines.slice(seek, seek + limit);
const truncated = total - (seek + slice.length); const truncated = total - (seek + slice.length);

View File

@@ -99,13 +99,10 @@ function isInDotDirectory(rel) {
export const ripgrepTool = defineTool({ export const ripgrepTool = defineTool({
name: 'ripgrep', name: 'ripgrep',
description: description:
'Search file contents across the workspace for a pattern (regex by default). ' + 'Regex-search file contents across ws (literal=true = plain str). ' +
'Recurses into directories, honors .gitignore rules, skips binary/large files, ' + 'Recurses dirs, honors .gitignore, skips binary/big files, hides dot-dirs (.git) by default. ' +
'and ignores dot-directories (e.g. ".git") by default. ' + 'Returns matches {file,line,column,text}; line is 0-idx, feed straight to read_file seek. ' +
'Returns matches as { file, line, column, text }, where "line" is 0-indexed so it can be ' + 'glob limits files searched. Long lines capped (textTruncated per match, linesTruncated count).',
'passed straight to read_file\'s seek. Use glob to restrict which files are searched. ' +
'Long matching lines are capped in "text" (surfaced via each match\'s textTruncated flag ' +
'and the result\'s linesTruncated count).',
parameters: { parameters: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
@@ -113,27 +110,27 @@ export const ripgrepTool = defineTool({
properties: { properties: {
pattern: { pattern: {
type: 'string', type: 'string',
description: 'Regex (or literal string when literal=true) to search for.', description: 'Regex (literal str when literal=true) to find.',
}, },
path: { path: {
type: 'string', type: 'string',
description: 'File or directory to search, relative to the workspace root. Default: "."', description: 'File/dir to search, rel to root. Default "."',
}, },
glob: { glob: {
type: 'string', type: 'string',
description: 'Only search files whose path matches this glob, e.g. "*.js" or "src/**/*.ts".', description: 'Only files whose path matches glob, e.g. "*.js" or "src/**/*.ts".',
}, },
ignore_case: { ignore_case: {
type: 'boolean', type: 'boolean',
description: 'Case-insensitive search. Default: false.', description: 'Case-insensitive. Default false.',
}, },
literal: { literal: {
type: 'boolean', type: 'boolean',
description: 'Treat "pattern" as a literal string instead of a regular expression. Default: false.', description: 'Treat pattern as literal str, not regex. Default false.',
}, },
max_results: { max_results: {
type: 'number', type: 'number',
description: 'Maximum number of matches to return. Default: 100.', description: 'Max matches. Default 100.',
}, },
}, },
}, },

View File

@@ -8,7 +8,7 @@ import { emitToolCall, clip } from './notify.js';
export const rmTool = defineTool({ export const rmTool = defineTool({
name: 'rm', name: 'rm',
description: 'Delete a file from the workspace.', description: 'Delete a file from ws.',
parameters: { parameters: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
@@ -16,7 +16,7 @@ export const rmTool = defineTool({
properties: { properties: {
filename: { filename: {
type: 'string', type: 'string',
description: 'File path relative to workspace root.', description: 'File path rel to root.',
}, },
}, },
}, },

View File

@@ -8,7 +8,7 @@ import { emitToolCall, clip } from './notify.js';
export const rmdirTool = defineTool({ export const rmdirTool = defineTool({
name: 'rmdir', name: 'rmdir',
description: 'Remove a directory and its contents from the workspace.', description: 'Delete a dir + contents from ws.',
parameters: { parameters: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
@@ -16,7 +16,7 @@ export const rmdirTool = defineTool({
properties: { properties: {
dirname: { dirname: {
type: 'string', type: 'string',
description: 'Directory path relative to workspace root.', description: 'Dir path rel to root.',
}, },
}, },
}, },

View File

@@ -40,9 +40,8 @@ function getClient() {
export const websearchTool = defineTool({ export const websearchTool = defineTool({
name: 'websearch', name: 'websearch',
description: description:
'Search the web via Tavily and return a short synthesized answer plus ranked results ' + 'Web search via Tavily. Returns short synthesized answer + ranked results (title,url,snippet). ' +
'(title, url, snippet). Use it for current information, external docs, or facts that are ' + 'Use for current info, external docs, or facts not in ws. Results are untrusted 3rd-party text.',
'not in the workspace. Results are untrusted third-party text.',
parameters: { parameters: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
@@ -50,25 +49,25 @@ export const websearchTool = defineTool({
properties: { properties: {
query: { query: {
type: 'string', type: 'string',
description: 'The search query.', description: 'Search query.',
}, },
max_results: { max_results: {
type: 'number', type: 'number',
description: `Number of results to return (1-${MAX_MAX_RESULTS}). Default: ${DEFAULT_MAX_RESULTS}.`, description: `Results to return (1-${MAX_MAX_RESULTS}). Default ${DEFAULT_MAX_RESULTS}.`,
}, },
search_depth: { search_depth: {
type: 'string', type: 'string',
enum: SEARCH_DEPTHS, enum: SEARCH_DEPTHS,
description: 'Search depth: "basic" (faster) or "advanced" (deeper). Default: "basic".', description: 'Depth: "basic" (faster) or "advanced" (deeper). Default "basic".',
}, },
include_answer: { include_answer: {
type: 'string', type: 'string',
enum: ANSWER_LEVELS, enum: ANSWER_LEVELS,
description: 'Include a Tavily-synthesized answer at this level of detail. Default: "basic".', description: 'Include Tavily-synthesized answer at this detail. Default "basic".',
}, },
chunks_per_source: { chunks_per_source: {
type: 'number', type: 'number',
description: 'Chunks of content extracted per source (advanced search only). Default: 1.', description: 'Content chunks/source (advanced search only). Default 1.',
}, },
}, },
}, },

148
test/compact.test.js Normal file
View File

@@ -0,0 +1,148 @@
'use strict';
/**
* Tests for invalidateStaleReadResults in src/compact.js.
*
* Run with: node --test
* (or) node --test test/compact.test.js
*
* These are pure unit tests: they build synthetic transcript arrays and never
* call a model or touch the network. Importing compact.js also loads config.js
* (dotenv + ROOT_DIR) which is harmless and needs no API key.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { invalidateStaleReadResults } from '../src/compact.js';
// A file body long enough that the replacement marker is strictly smaller.
const BODY = Array.from({ length: 40 }, (_, i) => `line ${i} of the file body`).join('\n');
// Assistant message declaring a tool call.
const call = (id, name, args) => ({
role: 'assistant',
tool_calls: [{ id, function: { name, arguments: JSON.stringify(args) } }],
});
// A read_file tool result carrying a whole file body.
const read = (id, filename, content = BODY) =>
({ role: 'tool', tool_call_id: id, content });
// A patch tool result. `applied` is the array of mutation tags the real
// applyOperations emits, e.g. ["PATCH in src/a.js"].
const patchResult = (id, applied) =>
({ role: 'tool', tool_call_id: id, content: JSON.stringify({ applied }) });
const isMarker = (content) => {
try {
const parsed = JSON.parse(content);
return typeof parsed.note === 'string' && parsed.note.includes('overwritten meanwhile');
} catch {
return false;
}
};
test('read before a later patch is invalidated', () => {
const messages = [
call('r1', 'read_file', { filename: 'src/a.js' }),
read('r1', 'src/a.js'),
call('p1', 'patch', { operations: [{ file: 'src/a.js', search: 'x', replace: 'y' }] }),
patchResult('p1', ['PATCH in src/a.js']),
];
const n = invalidateStaleReadResults(messages);
assert.equal(n, 1);
assert.ok(isMarker(messages[1].content), 'read result replaced with marker');
assert.equal(JSON.parse(messages[1].content).filename, 'src/a.js');
});
test('read after the last patch is left intact (still matches disk)', () => {
const messages = [
call('p1', 'patch', { operations: [{ file: 'src/a.js', search: 'x', replace: 'y' }] }),
patchResult('p1', ['PATCH in src/a.js']),
call('r1', 'read_file', { filename: 'src/a.js' }),
read('r1', 'src/a.js'),
];
assert.equal(invalidateStaleReadResults(messages), 0);
assert.equal(messages[3].content, BODY);
});
test('a file no patch ever wrote is left intact', () => {
const messages = [
call('r1', 'read_file', { filename: 'src/untouched.js' }),
read('r1', 'src/untouched.js'),
call('p1', 'patch', { operations: [{ file: 'src/a.js', search: 'x', replace: 'y' }] }),
patchResult('p1', ['PATCH in src/a.js']),
];
assert.equal(invalidateStaleReadResults(messages), 0);
assert.equal(messages[1].content, BODY);
});
test('a failed patch (no applied entries) invalidates nothing', () => {
const messages = [
call('r1', 'read_file', { filename: 'src/a.js' }),
read('r1', 'src/a.js'),
call('p1', 'patch', { operations: [{ file: 'src/a.js', search: 'x', replace: 'y' }] }),
patchResult('p1', []), // failure => nothing actually written
];
assert.equal(invalidateStaleReadResults(messages), 0);
assert.equal(messages[1].content, BODY);
});
test('partial failure: appliedSoFar still invalidates a written file', () => {
const messages = [
call('r1', 'read_file', { filename: 'src/a.js' }),
read('r1', 'src/a.js'),
call('p1', 'patch', { operations: [{ file: 'src/a.js', search: 'x', replace: 'y' }] }),
{ role: 'tool', tool_call_id: 'p1', content: JSON.stringify({ appliedSoFar: ['CREATE/WRITE src/a.js'], error: 'boom' }) },
];
assert.equal(invalidateStaleReadResults(messages), 1);
assert.ok(isMarker(messages[1].content));
});
test('CREATE/WRITE tag is recognised', () => {
const messages = [
call('r1', 'read_file', { filename: 'src/new.js' }),
read('r1', 'src/new.js'),
call('p1', 'patch', { operations: [{ file: 'src/new.js', search: null, replace: 'hi' }] }),
patchResult('p1', ['CREATE/WRITE src/new.js']),
];
assert.equal(invalidateStaleReadResults(messages), 1);
assert.ok(isMarker(messages[1].content));
});
test('path canonicalization: ./src/a.js matches src/a.js', () => {
const messages = [
call('r1', 'read_file', { filename: './src/a.js' }),
read('r1', './src/a.js'),
call('p1', 'patch', { operations: [{ file: 'src/a.js', search: 'x', replace: 'y' }] }),
patchResult('p1', ['PATCH in src/a.js']),
];
assert.equal(invalidateStaleReadResults(messages), 1);
assert.ok(isMarker(messages[1].content));
});
test('size guard: a tiny read is never traded for a longer marker', () => {
const messages = [
call('r1', 'read_file', { filename: 'src/a.js' }),
read('r1', 'src/a.js', 'tiny'),
call('p1', 'patch', { operations: [{ file: 'src/a.js', search: 'x', replace: 'y' }] }),
patchResult('p1', ['PATCH in src/a.js']),
];
assert.equal(invalidateStaleReadResults(messages), 0);
assert.equal(messages[1].content, 'tiny');
});
test('idempotent: a second pass changes nothing', () => {
const messages = [
call('r1', 'read_file', { filename: 'src/a.js' }),
read('r1', 'src/a.js'),
call('p1', 'patch', { operations: [{ file: 'src/a.js', search: 'x', replace: 'y' }] }),
patchResult('p1', ['PATCH in src/a.js']),
];
assert.equal(invalidateStaleReadResults(messages), 1);
const afterFirst = messages[1].content;
assert.equal(invalidateStaleReadResults(messages), 0);
assert.equal(messages[1].content, afterFirst);
});

146
test/file-browser.test.js Normal file
View File

@@ -0,0 +1,146 @@
'use strict';
/**
* Tests for the file-browser helpers in src/file-browser.js.
*
* Run with: node --test
* (or) node --test test/file-browser.test.js
*
* Pure unit tests except for `listEntries`/`readLines`, which read the real
* workspace (ROOT_DIR defaults to the process cwd, i.e. the repo root).
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import {
listEntries,
readLines,
formatMention,
normalizeRange,
windowStart,
parentOf,
baseOf,
} from '../src/file-browser.js';
// ─── mention formatting ───────────────────────────────────────────────────────
test('formatMention: whole file', () => {
assert.equal(formatMention('a/b.js'), '@a/b.js');
});
test('formatMention: single line collapses start==end', () => {
assert.equal(formatMention('a/b.js', 10, 10), '@a/b.js:10');
assert.equal(formatMention('a/b.js', 10, null), '@a/b.js:10');
});
test('formatMention: line range', () => {
assert.equal(formatMention('a/b.js', 10, 20), '@a/b.js:10-20');
});
test('formatMention: normalizes backslashes and trailing slashes', () => {
assert.equal(formatMention('a\\b.js', 1, 2), '@a/b.js:1-2');
assert.equal(formatMention('src/', 1, 2), '@src:1-2');
});
// ─── range normalization ──────────────────────────────────────────────────────
test('normalizeRange: anchor above cursor', () => {
assert.deepEqual(normalizeRange(2, 5), { start: 3, end: 6 });
});
test('normalizeRange: anchor below cursor', () => {
assert.deepEqual(normalizeRange(5, 2), { start: 3, end: 6 });
});
test('normalizeRange: single line', () => {
assert.deepEqual(normalizeRange(4, 4), { start: 5, end: 5 });
});
// ─── windowing ────────────────────────────────────────────────────────────────
test('windowStart: everything fits', () => {
assert.equal(windowStart(0, 5, 200), 0);
assert.equal(windowStart(4, 5, 200), 0);
});
test('windowStart: centers around the cursor', () => {
assert.equal(windowStart(100, 1000, 20), 90);
});
test('windowStart: clamps at the top', () => {
assert.equal(windowStart(0, 1000, 20), 0);
assert.equal(windowStart(3, 1000, 20), 0);
});
test('windowStart: clamps at the bottom', () => {
assert.equal(windowStart(999, 1000, 20), 980);
});
// ─── path helpers ─────────────────────────────────────────────────────────────
test('parentOf / baseOf', () => {
assert.equal(parentOf(''), '');
assert.equal(parentOf('src'), '');
assert.equal(parentOf('src/tools'), 'src');
assert.equal(baseOf('src/tools'), 'tools');
assert.equal(baseOf('README.md'), 'README.md');
});
// ─── listing (real workspace) ─────────────────────────────────────────────────
test('listEntries: dirs before files, dot-dirs and gitignored hidden', () => {
const entries = listEntries('');
const names = entries.map((e) => e.name);
// node_modules is hardcoded-ignored, .git is a dot-dir: both must be absent.
assert.ok(!names.includes('node_modules'), 'node_modules hidden');
assert.ok(!names.includes('.git'), '.git hidden');
// A root listing has no synthetic parent entry.
assert.ok(!names.includes('..'), 'no parent entry at root');
// src/ is a directory and README.md a file: directory must come first.
const srcIdx = names.indexOf('src');
const readmeIdx = names.indexOf('README.md');
assert.ok(srcIdx !== -1, 'src listed');
assert.ok(readmeIdx !== -1, 'README.md listed');
assert.ok(srcIdx < readmeIdx, 'directories sort before files');
const src = entries.find((e) => e.name === 'src');
assert.equal(src.type, 'd');
assert.equal(src.rel, 'src');
const readme = entries.find((e) => e.name === 'README.md');
assert.equal(readme.type, 'f');
assert.equal(readme.rel, 'README.md');
});
test('listEntries: nested listing prepends a parent entry', () => {
const entries = listEntries('src');
assert.equal(entries[0].name, '..');
assert.equal(entries[0].type, 'parent');
assert.equal(entries[0].rel, '');
assert.ok(entries.some((e) => e.name === 'fido.js' && e.type === 'f'));
});
// ─── reading (real workspace) ─────────────────────────────────────────────────
test('readLines: reads a real file into lines', () => {
const { lines, binary } = readLines('README.md');
assert.equal(binary, false);
assert.ok(Array.isArray(lines));
assert.ok(lines.length > 0);
assert.ok(lines[0].includes('FIDO'));
});
test('readLines: rejects paths that escape the workspace', () => {
const res = readLines('../secrets.txt');
assert.equal(res.error, true);
assert.deepEqual(res.lines, []);
});
test('readLines: missing file reports error, not a throw', () => {
const res = readLines('does/not/exist.js');
assert.equal(res.error, true);
assert.deepEqual(res.lines, []);
});

49
test/lines.test.js Normal file
View File

@@ -0,0 +1,49 @@
'use strict';
/**
* Tests for splitLines in src/lines.js.
*
* Run with: node --test
* (or) node --test test/lines.test.js
*
* The helper must count lines the way editors and `wc -l` do: a trailing
* newline terminates the final line rather than starting an extra empty one.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { splitLines } from '../src/lines.js';
test('splitLines: empty file has no lines', () => {
assert.deepEqual(splitLines(''), []);
});
test('splitLines: no trailing newline is unchanged', () => {
assert.deepEqual(splitLines('a\nb\nc'), ['a', 'b', 'c']);
});
test('splitLines: trailing newline does not add a phantom line', () => {
// The reported bug: this used to yield 4 elements (['a','b','c','']).
assert.deepEqual(splitLines('a\nb\nc\n'), ['a', 'b', 'c']);
assert.equal(splitLines('a\nb\nc\n').length, 3);
});
test('splitLines: terminated and non-terminated agree on line count', () => {
assert.equal(splitLines('a\nb\nc\n').length, splitLines('a\nb\nc').length);
});
test('splitLines: a real trailing blank line is preserved', () => {
assert.deepEqual(splitLines('a\n\n'), ['a', '']);
assert.equal(splitLines('a\n\n').length, 2);
});
test('splitLines: a lone newline is one empty line', () => {
assert.deepEqual(splitLines('\n'), ['']);
assert.equal(splitLines('\n').length, 1);
});
test('splitLines: multiple trailing newlines keep the real blank lines', () => {
assert.deepEqual(splitLines('a\n\n\n'), ['a', '', '']);
assert.equal(splitLines('a\n\n\n').length, 3);
});