From e416ca09c19faade3cb4e594a03ccc799c763397 Mon Sep 17 00:00:00 2001 From: seb Date: Wed, 23 Sep 2026 06:01:11 +0200 Subject: [PATCH] u --- src/fido.js | 55 +++++++++++++++++++++-------------------- src/file-browser.js | 3 ++- src/lines.js | 22 +++++++++++++++++ src/tools/ask-expert.js | 17 +++++-------- src/tools/ask-user.js | 10 +++----- src/tools/bash-exec.js | 15 ++++++----- src/tools/browse-url.js | 20 +++++++-------- src/tools/list-files.js | 20 +++++++-------- src/tools/mkdir.js | 4 +-- src/tools/patch.js | 22 ++++++++--------- src/tools/read-file.js | 13 +++++----- src/tools/ripgrep.js | 23 ++++++++--------- src/tools/rm.js | 4 +-- src/tools/rmdir.js | 4 +-- src/tools/websearch.js | 15 ++++++----- test/lines.test.js | 49 ++++++++++++++++++++++++++++++++++++ 16 files changed, 177 insertions(+), 119 deletions(-) create mode 100644 src/lines.js create mode 100644 test/lines.test.js diff --git a/src/fido.js b/src/fido.js index c150e1d..a72f754 100644 --- a/src/fido.js +++ b/src/fido.js @@ -97,42 +97,43 @@ function buildSystemPrompt() { dirSection += `\n ... (${truncated} more entries not shown — use list_files to explore)`; } - return `You are Fido, a coding assistant. -You inspect files, list directories, apply precise code changes, and run shell commands within the workspace. + return `You're Fido, a coding assistant. Inspect files, list dirs, edit code precisely, run shell cmds in the workspace. ## Workspace -Root directory: ${ROOT_DIR} +Root: ${ROOT_DIR} Total files: ${total} ## Workspace root (${total} entries): ${dirSection} -## Available Tools & Guidelines -- \`read_file(filename, seek, limit)\`: Inspect file contents and line numbers before modifying them. -- \`list_files(dirname, recursive, seek, limit, includeDotDirs)\`: Explore workspace folders (respects .gitignore; dot-directories such as .git are excluded unless includeDotDirs=true). -- \`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). -- \`patch(operations)\`: Apply file updates or create files. - - Create or overwrite: \`{"file": "path", "search": null, "replace": "content"}\` (parent directories auto-created). - - Search & replace: \`{"file": "path", "search": "exact old text", "replace": "new text"}\`. Replaces the FIRST match. - - \`skiplines\`: (optional integer, 0-indexed) skips N lines from the top before matching. Use this if the search text appears multiple times. -- \`mkdir(dirname)\`: Create directories (auto-recursive). -- \`rmdir(dirname)\`: Remove a directory and all its contents. -- \`rm(filename)\`: Delete an individual 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). -- \`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. -- \`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. -- \`ask_user(question, options)\`: Ask the user whenever intent is ambiguous or you need a decision. -- \`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\`. +## Tools & rules of thumb +- \`read_file(filename, seek, limit)\`: read file w/ line nums. Inspect before editing. +- \`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)\`: 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 edits/creates. + - create/overwrite: \`{"file":"path","search":null,"replace":"content"}\` (dirs auto-created). + - search&replace: \`{"file":"path","search":"exact old","replace":"new"}\` — replaces FIRST hit. + - \`skiplines\` (opt int, 0-idx): skip N lines b4 matching; use if search text appears multiple times. +- \`mkdir(dirname)\`: make dir (recursive). +- \`rmdir(dirname)\`: delete dir + contents. +- \`rm(filename)\`: delete file. +- \`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)\`: 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 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 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)\`: 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 -- 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). -- 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. -- 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)\`. +- 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 -- You may ONLY access files within the workspace root. Never use ".." or absolute paths. -- Always inspect files with \`read_file\` before patching to ensure exact search string and indentation matches. -- Prefer targeted SEARCH/REPLACE operations over rewriting entire files. -- Multiple operations in a single \`patch\` call execute sequentially. -- Undo is tracked automatically per operation and managed externally by the user. +- 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 \`read_file\` before patching (confirm exact search text + indentation). +- Prefer targeted SEARCH/REPLACE over rewriting whole files. +- Multiple \`patch\` ops in one call run sequentially. +- Undo tracked per op, managed externally by user. `; } diff --git a/src/file-browser.js b/src/file-browser.js index 701b188..e9476df 100644 --- a/src/file-browser.js +++ b/src/file-browser.js @@ -18,6 +18,7 @@ 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) { @@ -100,7 +101,7 @@ export function readLines(relPath) { return { lines: [], binary: true }; } const text = buf.toString('utf8'); - const lines = text.length === 0 ? [] : text.split('\n'); + const lines = splitLines(text); return { lines, binary: false }; } diff --git a/src/lines.js b/src/lines.js new file mode 100644 index 0000000..cfdd4b8 --- /dev/null +++ b/src/lines.js @@ -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; +} diff --git a/src/tools/ask-expert.js b/src/tools/ask-expert.js index a304243..c127c2f 100644 --- a/src/tools/ask-expert.js +++ b/src/tools/ask-expert.js @@ -8,14 +8,10 @@ import { emitToolCall, clip } from './notify.js'; export const askExpertTool = defineTool({ name: 'ask_an_expert', description: - 'Ask expert a question. You ' + - 'MUST use it every time something is confusing, unclear, or progress ' + - 'seems difficult—do not keep guessing or spinning. The question is ' + - 'presented to the user as a copy-pasteable block that they can hand to an ' + - '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`.', + 'Escalate to a human expert when you are genuinely stuck (a confusing or unclear problem, or ' + + 'you have tried and are still spinning). Do not 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`.', parameters: { type: 'object', additionalProperties: false, @@ -23,13 +19,12 @@ export const askExpertTool = defineTool({ properties: { question: { type: 'string', - description: 'The question to put to the expert. Keep it concise and specific.', + description: 'Question for the expert. Concise & specific.', }, context: { type: 'string', description: - 'Optional supporting detail the expert needs to answer well ' + - '(error messages, relevant code, what you already tried, etc.).', + 'Opt supporting detail the expert needs (errors, code, what you tried).', }, }, }, diff --git a/src/tools/ask-user.js b/src/tools/ask-user.js index f8177d6..931127f 100644 --- a/src/tools/ask-user.js +++ b/src/tools/ask-user.js @@ -8,10 +8,8 @@ import { emitToolCall, clip } from './notify.js'; export const askUserTool = defineTool({ name: 'ask_user', description: - 'Ask the user a question when you need clarification or a decision. ' + - 'Provide a concise question and up to 3 labelled options (A, B, C). ' + - 'The user can pick an option or type a free-form answer (D). ' + - 'Use this instead of guessing whenever intent is ambiguous.', + 'Ask user when you need clarification/decision. Give concise question + up to 3 labeled options (A,B,C); ' + + 'user may pick one or type free-form (D). Use instead of guessing when intent is ambiguous.', parameters: { type: 'object', additionalProperties: false, @@ -19,11 +17,11 @@ export const askUserTool = defineTool({ properties: { question: { type: 'string', - description: 'The question to ask the user.', + description: 'Question for the user.', }, options: { 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' }, maxItems: 3, }, diff --git a/src/tools/bash-exec.js b/src/tools/bash-exec.js index 15b9b0d..14a919a 100644 --- a/src/tools/bash-exec.js +++ b/src/tools/bash-exec.js @@ -240,11 +240,10 @@ function runCommand(command, timeoutMs) { export const bashExecTool = defineTool({ name: 'bash_exec', description: - 'Run a bash command in the workspace root and return its output. ' + - 'The command runs with color output and interactivity disabled, with stdin closed, ' + - 'and is killed if it exceeds the timeout. ' + - 'IMPORTANT: it requires explicit interactive user approval before executing, so the ' + - 'user may decline. Output (stdout and stderr) is trimmed to the last `tail_lines` lines.', + 'Run a bash cmd in ws root, return output. ' + + 'No color/interactivity, stdin closed, killed at timeout. ' + + 'IMPORTANT: needs explicit user approval — user may decline. ' + + 'stdout/stderr trimmed to last `tail_lines` lines.', parameters: { type: 'object', additionalProperties: false, @@ -252,15 +251,15 @@ export const bashExecTool = defineTool({ properties: { command: { 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: { 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: { 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}.`, }, }, }, diff --git a/src/tools/browse-url.js b/src/tools/browse-url.js index 973ade6..42971a7 100644 --- a/src/tools/browse-url.js +++ b/src/tools/browse-url.js @@ -69,11 +69,9 @@ function normalizeUrls(urls) { export const browseUrlTool = defineTool({ name: 'browse_url', description: - 'Fetch and read the contents of one or more web pages via Tavily extract. Pass a URL or ' + - 'array of URLs in "urls". Optionally pass "query" — a prompt describing the information ' + - 'you want — to receive 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.', + '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.', parameters: { type: 'object', additionalProperties: false, @@ -82,27 +80,27 @@ export const browseUrlTool = defineTool({ urls: { type: ['string', 'array'], items: { type: 'string' }, - description: 'A URL or a list of URLs to extract.', + description: 'URL or list of URLs to extract.', }, query: { type: 'string', description: - 'Optional LLM prompt describing the information to extract from the page(s). ' + - 'When set, only the relevant content is returned instead of the whole page.', + 'Opt prompt describing info to extract from the page(s). ' + + 'When set, only relevant content is returned.', }, chunks_per_source: { 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: { type: 'string', enum: FORMATS, - description: 'Output format: "markdown" or "text". Default: "text".', + description: 'Output format: "markdown" or "text". Default "text".', }, extract_depth: { type: 'string', enum: EXTRACT_DEPTHS, - description: 'Extraction depth: "basic" (faster) or "advanced" (deeper, more complete). Default: "basic".', + description: 'Depth: "basic" (faster) or "advanced" (deeper). Default "basic".', }, }, }, diff --git a/src/tools/list-files.js b/src/tools/list-files.js index 9375240..44b427b 100644 --- a/src/tools/list-files.js +++ b/src/tools/list-files.js @@ -9,12 +9,10 @@ import { emitToolCall, emitToolResult, clip, formatBytes, resultSize, plural } f export const listFilesTool = defineTool({ name: 'list_files', description: - 'List the immediate contents of a directory within the workspace (non-recursive by default). ' + - 'Entries matching .gitignore patterns (and node_modules/) are excluded automatically. ' + - 'Dot-directories (names starting with ".", e.g. .git) are excluded by default; ' + - 'set includeDotDirs=true to include them. ' + - 'Set recursive=true to walk the full subtree. ' + - 'Use seek/limit for pagination. Paths are relative to the workspace root.', + 'List immediate contents of a workspace dir (non-recursive default). ' + + 'Honors .gitignore; node_modules/ skipped. ' + + 'Dot-dirs (".git") hidden unless includeDotDirs=true. recursive=true walks subtree. ' + + 'seek/limit paginate. Paths rel to root.', parameters: { type: 'object', additionalProperties: false, @@ -22,23 +20,23 @@ export const listFilesTool = defineTool({ properties: { dirname: { type: 'string', - description: 'Directory path relative to workspace root. Use "." for root.', + description: 'Dir path rel to root. "." = root.', }, recursive: { type: 'boolean', - description: 'If true, walk the full subtree. Default: false (immediate children only).', + description: 'true = walk subtree. Default false.', }, seek: { type: 'number', - description: 'Number of entries to skip (for pagination). Default: 0.', + description: 'Entries to skip (paging). Default 0.', }, limit: { type: 'number', - description: 'Maximum number of entries to return. Default: 50.', + description: 'Max entries. Default 50.', }, includeDotDirs: { type: 'boolean', - description: 'If true, include dot-directories (e.g. .git). Default: false.', + description: 'true = include dot-dirs (.git). Default false.', }, }, }, diff --git a/src/tools/mkdir.js b/src/tools/mkdir.js index c90421c..d5d0d51 100644 --- a/src/tools/mkdir.js +++ b/src/tools/mkdir.js @@ -8,7 +8,7 @@ import { emitToolCall, clip } from './notify.js'; export const mkdirTool = defineTool({ 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: { type: 'object', additionalProperties: false, @@ -16,7 +16,7 @@ export const mkdirTool = defineTool({ properties: { dirname: { type: 'string', - description: 'Directory path relative to workspace root.', + description: 'Dir path rel to root.', }, }, }, diff --git a/src/tools/patch.js b/src/tools/patch.js index 9ff8ce8..b5f01f8 100644 --- a/src/tools/patch.js +++ b/src/tools/patch.js @@ -166,12 +166,12 @@ export function applyOperations(operations) { export const patchTool = defineTool({ name: 'patch', - description: `Apply one or more patch operations to files in the workspace. -Each operation has: -- file: (required) relative path to target file -- search: string to be replaced (exact match). If null, creating/overwriting the entire file (with automatic directory creation). -- replace: string to write -- skiplines: (optional) line number offset to skip before matching (0-indexed) if there are multiple occurrences, since patch replaces the first hit. + description: `Apply one or more edit ops to files. +Each op: +- file: (req) rel path to target +- search: exact text to replace; null = create/overwrite whole file (dirs auto-created) +- replace: text to write +- skiplines: (opt) 0-idx offset to skip before matching; use when search text occurs multiple times (patch replaces first hit) Example create file: [{"file": "src/utils.js", "search": null, "replace": "export const x = 1;\\n", "skiplines": null}] @@ -185,7 +185,7 @@ Example search/replace: properties: { operations: { type: 'array', - description: 'List of patch operations to apply sequentially.', + description: 'Ops to apply in order.', items: { type: 'object', additionalProperties: false, @@ -193,19 +193,19 @@ Example search/replace: properties: { file: { type: 'string', - description: 'Relative path to file.', + description: 'Rel path.', }, search: { 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: { type: 'string', - description: 'String to write.', + description: 'Text to write.', }, skiplines: { 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.', }, }, }, diff --git a/src/tools/read-file.js b/src/tools/read-file.js index c3bb6e4..97bde43 100644 --- a/src/tools/read-file.js +++ b/src/tools/read-file.js @@ -3,13 +3,14 @@ import fs from 'fs'; import { defineTool, ToolError } from 'llmorbiter'; import { resolveSafe } from '../sandbox.js'; +import { splitLines } from '../lines.js'; import { emitToolCall, emitToolResult, clip, formatBytes, resultSize, plural } from './notify.js'; export const readFileTool = defineTool({ name: 'read_file', description: - 'Read a file from the workspace. Use seek/limit to read in chunks. ' + - 'Returns truncation info when the file is larger than the limit.', + 'Read a workspace file. seek/limit chunk it. ' + + 'Returns truncation info if file bigger than limit.', parameters: { type: 'object', additionalProperties: false, @@ -17,15 +18,15 @@ export const readFileTool = defineTool({ properties: { filename: { type: 'string', - description: 'File path relative to workspace root.', + description: 'File path rel to root.', }, seek: { type: 'number', - description: 'Line number to start reading from (0-indexed). Default: 0.', + description: 'Start line, 0-idx. Default 0.', }, limit: { 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()) { 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 slice = allLines.slice(seek, seek + limit); const truncated = total - (seek + slice.length); diff --git a/src/tools/ripgrep.js b/src/tools/ripgrep.js index 7edcdc9..5ba5aa6 100644 --- a/src/tools/ripgrep.js +++ b/src/tools/ripgrep.js @@ -99,13 +99,10 @@ function isInDotDirectory(rel) { export const ripgrepTool = defineTool({ name: 'ripgrep', description: - 'Search file contents across the workspace for a pattern (regex by default). ' + - 'Recurses into directories, honors .gitignore rules, skips binary/large files, ' + - 'and ignores dot-directories (e.g. ".git") by default. ' + - 'Returns matches as { file, line, column, text }, where "line" is 0-indexed so it can be ' + - '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).', + 'Regex-search file contents across ws (literal=true = plain str). ' + + 'Recurses dirs, honors .gitignore, skips binary/big files, hides dot-dirs (.git) by default. ' + + '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).', parameters: { type: 'object', additionalProperties: false, @@ -113,27 +110,27 @@ export const ripgrepTool = defineTool({ properties: { pattern: { type: 'string', - description: 'Regex (or literal string when literal=true) to search for.', + description: 'Regex (literal str when literal=true) to find.', }, path: { type: 'string', - description: 'File or directory to search, relative to the workspace root. Default: "."', + description: 'File/dir to search, rel to root. Default "."', }, glob: { 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: { type: 'boolean', - description: 'Case-insensitive search. Default: false.', + description: 'Case-insensitive. Default false.', }, literal: { 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: { type: 'number', - description: 'Maximum number of matches to return. Default: 100.', + description: 'Max matches. Default 100.', }, }, }, diff --git a/src/tools/rm.js b/src/tools/rm.js index e5719cd..f409c47 100644 --- a/src/tools/rm.js +++ b/src/tools/rm.js @@ -8,7 +8,7 @@ import { emitToolCall, clip } from './notify.js'; export const rmTool = defineTool({ name: 'rm', - description: 'Delete a file from the workspace.', + description: 'Delete a file from ws.', parameters: { type: 'object', additionalProperties: false, @@ -16,7 +16,7 @@ export const rmTool = defineTool({ properties: { filename: { type: 'string', - description: 'File path relative to workspace root.', + description: 'File path rel to root.', }, }, }, diff --git a/src/tools/rmdir.js b/src/tools/rmdir.js index 05b54d6..57b392f 100644 --- a/src/tools/rmdir.js +++ b/src/tools/rmdir.js @@ -8,7 +8,7 @@ import { emitToolCall, clip } from './notify.js'; export const rmdirTool = defineTool({ name: 'rmdir', - description: 'Remove a directory and its contents from the workspace.', + description: 'Delete a dir + contents from ws.', parameters: { type: 'object', additionalProperties: false, @@ -16,7 +16,7 @@ export const rmdirTool = defineTool({ properties: { dirname: { type: 'string', - description: 'Directory path relative to workspace root.', + description: 'Dir path rel to root.', }, }, }, diff --git a/src/tools/websearch.js b/src/tools/websearch.js index 47f303f..6e1bb57 100644 --- a/src/tools/websearch.js +++ b/src/tools/websearch.js @@ -40,9 +40,8 @@ function getClient() { export const websearchTool = defineTool({ name: 'websearch', description: - 'Search the web via Tavily and return a short synthesized answer plus ranked results ' + - '(title, url, snippet). Use it for current information, external docs, or facts that are ' + - 'not in the workspace. Results are untrusted third-party text.', + 'Web search via Tavily. Returns short synthesized answer + ranked results (title,url,snippet). ' + + 'Use for current info, external docs, or facts not in ws. Results are untrusted 3rd-party text.', parameters: { type: 'object', additionalProperties: false, @@ -50,25 +49,25 @@ export const websearchTool = defineTool({ properties: { query: { type: 'string', - description: 'The search query.', + description: 'Search query.', }, max_results: { 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: { type: 'string', enum: SEARCH_DEPTHS, - description: 'Search depth: "basic" (faster) or "advanced" (deeper). Default: "basic".', + description: 'Depth: "basic" (faster) or "advanced" (deeper). Default "basic".', }, include_answer: { type: 'string', 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: { type: 'number', - description: 'Chunks of content extracted per source (advanced search only). Default: 1.', + description: 'Content chunks/source (advanced search only). Default 1.', }, }, }, diff --git a/test/lines.test.js b/test/lines.test.js new file mode 100644 index 0000000..53b5b2c --- /dev/null +++ b/test/lines.test.js @@ -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); +});