This commit is contained in:
seb
2026-09-23 06:01:11 +02:00
parent 9f61a05e8b
commit e416ca09c1
16 changed files with 177 additions and 119 deletions

View File

@@ -97,42 +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 ## File mentions
- The user can insert plain-text references to workspace files into a message with Ctrl+O. They look like \`@path\` (whole file) or \`@path:start-end\` (a line range). - User inserts refs via Ctrl+O: \`@path\` (whole file) or \`@path:start-end\` (line range).
- A mention is only a reference — you must read the file yourself with \`read_file\`. The path is relative to the workspace root and uses forward slashes. - Mentions are refs only — you must \`read_file\` yourself. Paths rel to root, forward slashes.
- Line numbers in a mention are 1-based as a human counts them. \`read_file\` is 0-indexed, so map \`@path:start-end\` to \`read_file(path, seek=start-1)\`. - 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.
`; `;
} }

View File

@@ -18,6 +18,7 @@ import path from 'path';
import { ROOT_DIR } from './config.js'; import { ROOT_DIR } from './config.js';
import { listDir } from './dir-listing.js'; import { listDir } from './dir-listing.js';
import { resolveSafe } from './sandbox.js'; import { resolveSafe } from './sandbox.js';
import { splitLines } from './lines.js';
/** Normalize a workspace-relative path to forward slashes, no trailing slash. */ /** Normalize a workspace-relative path to forward slashes, no trailing slash. */
function normalizeRel(rel) { function normalizeRel(rel) {
@@ -100,7 +101,7 @@ export function readLines(relPath) {
return { lines: [], binary: true }; return { lines: [], binary: true };
} }
const text = buf.toString('utf8'); const text = buf.toString('utf8');
const lines = text.length === 0 ? [] : text.split('\n'); const lines = splitLines(text);
return { lines, binary: false }; return { lines, binary: false };
} }

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.',
}, },
}, },
}, },

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