diff --git a/src/agents/base.ts b/src/agents/base.ts index 3b923e31..d0d33da4 100644 --- a/src/agents/base.ts +++ b/src/agents/base.ts @@ -9,12 +9,15 @@ import { CUSTOM_MODELS } from '../config/customModels.js'; import { getIterationTrailingMessage } from '../config/hintConfig.js'; import { getRateLimitForModel } from '../config/rateLimits.js'; import { getRetryConfig } from '../config/retryConfig.js'; -import { EditFile } from '../gadgets/EditFile.js'; +import { FileInsertContent } from '../gadgets/FileInsertContent.js'; +import { FileRemoveContent } from '../gadgets/FileRemoveContent.js'; +import { FileSearchAndReplace } from '../gadgets/FileSearchAndReplace.js'; import { Finish } from '../gadgets/Finish.js'; import { ListDirectory } from '../gadgets/ListDirectory.js'; import { ReadFile } from '../gadgets/ReadFile.js'; import { Sleep } from '../gadgets/Sleep.js'; import { CreatePR } from '../gadgets/github/index.js'; +import { initSessionState } from '../gadgets/sessionState.js'; import { Tmux } from '../gadgets/tmux.js'; import { TodoDelete, TodoUpdateStatus, TodoUpsert } from '../gadgets/todo/index.js'; import { @@ -274,6 +277,9 @@ function createAgentBuilderWithGadgets( llmCallLogger: import('../utils/llmLogging.js').LLMCallLogger, repoDir: string, ): BuilderType { + // Initialize session state for gadgets (e.g., Finish checks PR requirement for implementation) + initSessionState(agentType); + // Check if AU features should be enabled (repo has .au file at root) const auEnabled = existsSync(join(repoDir, '.au')); @@ -285,7 +291,14 @@ function createAgentBuilderWithGadgets( // Filesystem gadgets (read-only for planning) new ListDirectory(), new ReadFile(), - ...(isReadOnlyAgent ? [] : [new EditFile(), new WriteFile()]), + ...(isReadOnlyAgent + ? [] + : [ + new FileSearchAndReplace(), + new FileInsertContent(), + new FileRemoveContent(), + new WriteFile(), + ]), // Shell commands via tmux (no timeout issues) new Tmux(), new Sleep(), @@ -332,7 +345,7 @@ function createAgentBuilderWithGadgets( .withGadgets(...allGadgets); // Implementation agent uses sequential execution to ensure file operations - // are properly ordered (e.g., EditFile then ReadFile on same file) + // are properly ordered (e.g., FileSearchAndReplace then ReadFile on same file) if (agentType === 'implementation') { return builder.withGadgetExecutionMode('sequential'); } diff --git a/src/agents/prompts/templates/partials/rules-efficiency.eta b/src/agents/prompts/templates/partials/rules-efficiency.eta index 290c834e..e420fc76 100644 --- a/src/agents/prompts/templates/partials/rules-efficiency.eta +++ b/src/agents/prompts/templates/partials/rules-efficiency.eta @@ -2,97 +2,11 @@ When you encounter multiple errors (type errors, test failures, lint errors): 1. Read and analyze ALL errors before making any fixes -2. Fix ALL errors in a SINGLE response with multiple EditFile calls +2. Fix ALL errors in a SINGLE response with multiple gadget calls (FileSearchAndReplace, FileInsertContent, FileRemoveContent) 3. Run verification ONCE after all fixes -4. Repeat only if new errors appear +4. Repeat only if errors appear again NEVER fix errors one-by-one. This wastes iterations. Batch all fixes together. -### Debugging Persistent Test Failures (CRITICAL) - -If the SAME test fails 2-3 times after your fixes, STOP making more fixes. Instead, investigate: - -**When mocks aren't being called (e.g., "Number of calls: 0"):** -1. The problem is NOT your test setup - the code path isn't executing at all -2. Add `console.log` statements to the IMPLEMENTATION code to trace execution: - - At the function entry point - - Before conditional branches - - Inside the block that should call the mocked function -3. Run the test again and read the console output to find where execution stops -4. Fix the actual implementation issue, not the test - -**When assertions fail with wrong values:** -1. Add `console.log` to print actual values at key points -2. Check if your changes accidentally broke an existing code path -3. Verify dependencies are wired correctly (DI, imports, function parameters) - -**Loop Detection - recognize when you're stuck:** -- If you've edited the same file 3+ times for the same error: STOP -- If you've run `verify-final-vN` where N > 3: STOP -- Step back and ask: "Why isn't the code reaching this point?" -- Read the implementation code, not just the test - -**Debug-first approach:** -``` -1. Test fails → Add console.log to implementation -2. Run test → Read logs to understand flow -3. Identify root cause → Fix implementation (not test setup) -4. Remove console.log → Verify fix -``` - -Remember: Tests that "should" call mocks but don't are telling you the implementation path is wrong, not the mock setup. - -### Handling Breaking Changes (Critical) - -If you modify a function signature, class constructor, or API contract, -do not wait for the compiler/linter to report errors. -Immediately perform a codebase-wide search for all usages of that symbol and update all call sites -in a single batch. Avoid the 'fix-run-repeat' loop for known breaking changes. - -### Tooling & Verification - -When validating changes, always prioritize auto-fixing commands (e.g., format, lint --fix) before -running read-only checks. If a generated artifact (like a migration or lockfile) looks incorrect, -investigate the environment state rather than manually patching the output. - -### Acting on EditFile/WriteFile Results (CRITICAL) - -EditFile and WriteFile return structured output. You MUST read and act on it. - -**Status Codes:** -- `status=success` - Edit worked, but CHECK THE DIAGNOSTICS SECTION -- `status=failed` - Search content not found, USE THE SUGGESTIONS -- `status=error` - Operation failed (permissions, path), read error message - -**On status=failed (search not found):** - -The output includes a SUGGESTIONS section with similar content found in the file: -``` -SUGGESTIONS (similar content found): -Line 42 (85% similar): -```{what actually exists}``` -``` - -1. READ this section - it shows what the file actually contains -2. Adjust your search pattern to match the actual content -3. Common issues: whitespace differences, indentation, content changed by previous edit -4. NEVER retry the exact same search - that's a loop - -**On status=success (check diagnostics):** - -Success responses include TypeScript and Biome diagnostics: -``` -=== TypeScript Check === -{any type errors from your edit} - -=== Biome Lint === -{any lint issues from your edit} -``` - If diagnostics show issues, FIX THEM IMMEDIATELY before making more edits. Don't proceed to other files until the current file is clean. - -**Recovery Escalation:** -1. First failure: Use SUGGESTIONS to adjust search pattern -2. Second failure: Read entire file with ReadFile, understand actual structure -3. Third failure: Use WriteFile to replace the entire file content \ No newline at end of file diff --git a/src/agents/prompts/templates/planning.eta b/src/agents/prompts/templates/planning.eta index c889c598..24ae582a 100644 --- a/src/agents/prompts/templates/planning.eta +++ b/src/agents/prompts/templates/planning.eta @@ -127,7 +127,7 @@ Review the updated description and move to TODO when ready to implement! - `ReadFile` - Read file contents - `Tmux` - Run shell commands (for exploration: grep, find, etc.) -⚠️ **You do NOT have access to:** EditFile, WriteFile, CreatePR - these are reserved for the implementation agent. +⚠️ **You do NOT have access to:** FileSearchAndReplace, FileInsertContent, FileRemoveContent, WriteFile, CreatePR - these are reserved for the implementation agent. ## Rules diff --git a/src/agents/respond-to-review.ts b/src/agents/respond-to-review.ts index 9098c34c..aad03e14 100644 --- a/src/agents/respond-to-review.ts +++ b/src/agents/respond-to-review.ts @@ -8,7 +8,9 @@ import { getCompactionConfig } from '../config/compactionConfig.js'; import { getIterationTrailingMessage } from '../config/hintConfig.js'; import { getRateLimitForModel } from '../config/rateLimits.js'; import { getRetryConfig } from '../config/retryConfig.js'; -import { EditFile } from '../gadgets/EditFile.js'; +import { FileInsertContent } from '../gadgets/FileInsertContent.js'; +import { FileRemoveContent } from '../gadgets/FileRemoveContent.js'; +import { FileSearchAndReplace } from '../gadgets/FileSearchAndReplace.js'; import { ListDirectory } from '../gadgets/ListDirectory.js'; import { ReadFile } from '../gadgets/ReadFile.js'; import { Sleep } from '../gadgets/Sleep.js'; @@ -18,6 +20,7 @@ import { GetPRDiff, ReplyToReviewComment, } from '../gadgets/github/index.js'; +import { initSessionState } from '../gadgets/sessionState.js'; import { Tmux } from '../gadgets/tmux.js'; import { githubClient } from '../github/client.js'; import type { AgentInput, AgentResult, CascadeConfig, ProjectConfig } from '../types/index.js'; @@ -251,6 +254,9 @@ function createRespondToReviewAgentBuilder( llmCallLogger: import('../utils/llmLogging.js').LLMCallLogger, repoDir: string, ): BuilderType { + // Initialize session state for gadgets + initSessionState('respond-to-review'); + // Check if AU features should be enabled (repo has .au file at root) const auEnabled = existsSync(join(repoDir, '.au')); @@ -259,7 +265,9 @@ function createRespondToReviewAgentBuilder( // Filesystem gadgets new ListDirectory(), new ReadFile(), - new EditFile(), + new FileSearchAndReplace(), + new FileInsertContent(), + new FileRemoveContent(), new WriteFile(), // Shell commands via tmux new Tmux(), diff --git a/src/gadgets/EditFile.ts b/src/gadgets/EditFile.ts deleted file mode 100644 index 3de3aab5..00000000 --- a/src/gadgets/EditFile.ts +++ /dev/null @@ -1,825 +0,0 @@ -/** - * EditFile gadget - Edit files using search/replace with layered matching. - * - * Uses layered matching strategies: exact → whitespace → indentation → fuzzy - * This approach reduces edit errors by ~9x (per Aider benchmarks). - */ - -import { execSync } from 'node:child_process'; -import { readFileSync, realpathSync, writeFileSync } from 'node:fs'; -import { resolve, sep } from 'node:path'; - -import { Gadget, z } from 'llmist'; - -import { applyReplacement, findAllMatches, getMatchFailure } from './editfile/matcher.js'; -import { invalidateFileRead } from './readTracking.js'; - -const ALLOWED_PATHS = ['/tmp']; - -function validatePath(inputPath: string): string { - const cwd = process.cwd(); - const resolvedPath = resolve(cwd, inputPath); - - let finalPath: string; - try { - finalPath = realpathSync(resolvedPath); - } catch (error) { - const nodeError = error as NodeJS.ErrnoException; - if (nodeError.code === 'ENOENT') { - finalPath = resolvedPath; - } else { - throw error; - } - } - - // Check if within CWD - const cwdWithSep = cwd + sep; - if (finalPath.startsWith(cwdWithSep) || finalPath === cwd) { - return finalPath; - } - - // Check if within allowed paths - for (const allowedPath of ALLOWED_PATHS) { - const allowedWithSep = allowedPath + sep; - if (finalPath.startsWith(allowedWithSep) || finalPath === allowedPath) { - return finalPath; - } - } - - throw new Error( - `Path access denied: ${inputPath}. Path must be within working directory or allowed paths (${ALLOWED_PATHS.join(', ')})`, - ); -} - -export class EditFile extends Gadget({ - name: 'EditFile', - description: `Edit a file using one of three modes: - -**search_replace**: Search for content and replace it. -- Uses layered matching: exact → whitespace → indentation → fuzzy -- Reduces edit errors by ~9x - -**insert_at_line**: Insert content at a specific line number. -- Line numbers are 1-based -- Content is inserted BEFORE the specified line -- Use line beyond EOF to append at end - -**remove_lines**: Remove a range of lines from the file. -- Line numbers are 1-based and inclusive - -For multiple edits, call this gadget multiple times.`, - timeoutMs: 30000, - maxConcurrent: 1, // Sequential execution to prevent race conditions on file writes - schema: z.discriminatedUnion('mode', [ - // Mode 1: search_replace (current behavior) - z.object({ - mode: z.literal('search_replace'), - comment: z.string().min(1).describe('Brief rationale for this gadget call'), - filePath: z.string().describe('Path to the file to edit'), - search: z.string().min(1).describe('The content to search for'), - replace: z.string().describe('The content to replace with (empty to delete)'), - }), - - // Mode 2: insert_at_line - z.object({ - mode: z.literal('insert_at_line'), - comment: z.string().min(1).describe('Brief rationale for this gadget call'), - filePath: z.string().describe('Path to the file to edit'), - line: z - .number() - .int() - .min(1) - .describe('Line number to insert BEFORE (1-based). Use line beyond EOF to append.'), - content: z.string().describe('Content to insert (can be multiline)'), - }), - - // Mode 3: remove_lines - z.object({ - mode: z.literal('remove_lines'), - comment: z.string().min(1).describe('Brief rationale for this gadget call'), - filePath: z.string().describe('Path to the file to edit'), - startLine: z.number().int().min(1).describe('First line to remove (1-based, inclusive)'), - endLine: z.number().int().min(1).describe('Last line to remove (1-based, inclusive)'), - }), - ]), - examples: [ - { - params: { - mode: 'search_replace' as const, - comment: 'Increasing timeout from 1s to 5s to fix test flakiness', - filePath: 'src/config.ts', - search: 'timeout: 1000', - replace: 'timeout: 5000', - }, - output: `path=src/config.ts status=success matches=1 strategy=exact - -Replaced 1 occurrence. - -=== Edit 1 (lines 5-5) === ---- BEFORE --- - 1 | import { foo } from "bar"; - 2 | - 3 | const CONFIG = { - 4 | debug: false, -< 5 | timeout: 1000, - 6 | retries: 3, - 7 | }; - ---- AFTER --- - 1 | import { foo } from "bar"; - 2 | - 3 | const CONFIG = { - 4 | debug: false, -> 5 | timeout: 5000, - 6 | retries: 3, - 7 | }; - -=== TypeScript Check === -No type errors found. - -=== Biome Lint === -No lint issues found.`, - comment: 'Single edit with before/after context and diagnostics', - }, - { - params: { - mode: 'search_replace' as const, - comment: 'Updating retry constant per requirements', - filePath: 'src/constants.ts', - search: 'MAX_RETRIES = 3', - replace: 'MAX_RETRIES = 5', - }, - output: `path=src/constants.ts status=success matches=2 strategy=exact - -Replaced 2 occurrences. - -=== Edit 1 (lines 4-4) === ---- BEFORE --- - 1 | // API constants - 2 | export const API_URL = "https://api.example.com"; - 3 | -< 4 | export const MAX_RETRIES = 3; - 5 | export const TIMEOUT = 5000; - ---- AFTER --- - 1 | // API constants - 2 | export const API_URL = "https://api.example.com"; - 3 | -> 4 | export const MAX_RETRIES = 5; - 5 | export const TIMEOUT = 5000; - -=== Edit 2 (lines 12-12) === ---- BEFORE --- - 9 | // Client constants - 10 | export const CLIENT_URL = "https://client.example.com"; - 11 | -< 12 | export const MAX_RETRIES = 3; - 13 | export const CLIENT_TIMEOUT = 3000; - ---- AFTER --- - 9 | // Client constants - 10 | export const CLIENT_URL = "https://client.example.com"; - 11 | -> 12 | export const MAX_RETRIES = 5; - 13 | export const CLIENT_TIMEOUT = 3000; - -=== TypeScript Check === -No type errors found. - -=== Biome Lint === -No lint issues found.`, - comment: 'Multiple matches replaced with before/after for each', - }, - { - params: { - mode: 'search_replace' as const, - comment: 'Enabling feature flag for new functionality', - filePath: 'src/data.json', - search: '"enabled": false', - replace: '"enabled": true', - }, - output: `path=src/data.json status=success matches=1 strategy=exact - -Replaced 1 occurrence. - -=== Edit 1 (lines 3-3) === ---- BEFORE --- - 1 | { - 2 | "name": "example", -< 3 | "enabled": false, - 4 | "count": 0 - 5 | } - ---- AFTER --- - 1 | { - 2 | "name": "example", -> 3 | "enabled": true, - 4 | "count": 0 - 5 | }`, - comment: 'Non-TypeScript file (no diagnostics appended)', - }, - // insert_at_line examples - { - params: { - mode: 'insert_at_line' as const, - comment: 'Adding lodash import at top of file', - filePath: 'src/utils.ts', - line: 1, - content: "import _ from 'lodash';", - }, - output: `path=src/utils.ts mode=insert_at_line status=success - -Inserted 1 line at line 1. - ---- BEFORE (around line 1) --- -< 1 | import { foo } from 'bar'; - 2 | import { baz } from 'qux'; - 3 | - ---- AFTER --- -> 1 | import _ from 'lodash'; - 2 | import { foo } from 'bar'; - 3 | import { baz } from 'qux'; - 4 | - -=== TypeScript Check === -No type errors found. - -=== Biome Lint === -No lint issues found.`, - comment: 'Insert import at beginning of file', - }, - { - params: { - mode: 'insert_at_line' as const, - comment: 'Adding helper function in middle of file', - filePath: 'src/helpers.ts', - line: 10, - content: 'function validate(x: number): boolean {\n return x > 0;\n}', - }, - output: `path=src/helpers.ts mode=insert_at_line status=success - -Inserted 3 lines at line 10. - ---- BEFORE (around line 10) --- - 7 | } - 8 | - 9 | // Utils below -< 10 | function process(data: string) { - 11 | return data.trim(); - 12 | } - ---- AFTER --- - 7 | } - 8 | - 9 | // Utils below -> 10 | function validate(x: number): boolean { - 11 | return x > 0; - 12 | } - 13 | function process(data: string) { - 14 | return data.trim(); - 15 | } - -=== TypeScript Check === -No type errors found. - -=== Biome Lint === -No lint issues found.`, - comment: 'Insert multiline block in middle of file', - }, - { - params: { - mode: 'insert_at_line' as const, - comment: 'Appending export at end of file', - filePath: 'src/index.ts', - line: 999, - content: "export * from './newModule';", - }, - output: `path=src/index.ts mode=insert_at_line status=success - -Appended 1 line at end of file. - ---- BEFORE (end of file) --- - 3 | export * from './utils'; - 4 | export * from './helpers'; - 5 | - ---- AFTER --- - 3 | export * from './utils'; - 4 | export * from './helpers'; - 5 | -> 6 | export * from './newModule'; - -=== TypeScript Check === -No type errors found. - -=== Biome Lint === -No lint issues found.`, - comment: 'Append at end of file (line beyond EOF)', - }, - // remove_lines examples - { - params: { - mode: 'remove_lines' as const, - comment: 'Removing unused import', - filePath: 'src/app.ts', - startLine: 3, - endLine: 3, - }, - output: `path=src/app.ts mode=remove_lines status=success - -Removed 1 line (line 3). - ---- BEFORE --- - 1 | import { foo } from 'foo'; - 2 | import { bar } from 'bar'; -< 3 | import { unused } from 'unused'; - 4 | import { baz } from 'baz'; - 5 | - ---- AFTER --- - 1 | import { foo } from 'foo'; - 2 | import { bar } from 'bar'; - 3 | import { baz } from 'baz'; - 4 | - -=== TypeScript Check === -No type errors found. - -=== Biome Lint === -No lint issues found.`, - comment: 'Remove single line', - }, - { - params: { - mode: 'remove_lines' as const, - comment: 'Removing deprecated function', - filePath: 'src/legacy.ts', - startLine: 5, - endLine: 10, - }, - output: `path=src/legacy.ts mode=remove_lines status=success - -Removed 6 lines (lines 5-10). - ---- BEFORE --- - 2 | - 3 | export function keepThis() {} - 4 | -< 5 | /** @deprecated */ -< 6 | function oldFunc(x: number) { -< 7 | console.log('deprecated'); -< 8 | return x * 2; -< 9 | } -< 10 | - 11 | export function keepThisToo() {} - ---- AFTER --- - 2 | - 3 | export function keepThis() {} - 4 | - 5 | export function keepThisToo() {} - -=== TypeScript Check === -No type errors found. - -=== Biome Lint === -No lint issues found.`, - comment: 'Remove block of lines', - }, - { - params: { - mode: 'remove_lines' as const, - comment: 'Removing comment block from config', - filePath: 'config/settings.json', - startLine: 2, - endLine: 4, - }, - output: `path=config/settings.json mode=remove_lines status=success - -Removed 3 lines (lines 2-4). - ---- BEFORE --- - 1 | { -< 2 | "// NOTE": "Remove this later", -< 3 | "// TODO": "Clean up config", -< 4 | "// FIXME": "Legacy value", - 5 | "enabled": true, - 6 | "timeout": 5000 - 7 | } - ---- AFTER --- - 1 | { - 2 | "enabled": true, - 3 | "timeout": 5000 - 4 | }`, - comment: 'Remove from non-TS file (no diagnostics)', - }, - ], -}) { - override execute(params: this['params']): string { - // Validate and resolve path (shared across all modes) - const validatedPath = validatePath(params.filePath); - - // Dispatch to mode handler - switch (params.mode) { - case 'search_replace': - return this.handleSearchReplace( - params as Extract, - validatedPath, - ); - case 'insert_at_line': - return this.handleInsertAtLine( - params as Extract, - validatedPath, - ); - case 'remove_lines': - return this.handleRemoveLines( - params as Extract, - validatedPath, - ); - } - } - - private handleSearchReplace( - params: Extract, - validatedPath: string, - ): string { - const { filePath, search, replace } = params; - - // Read file content - let content: string; - try { - content = readFileSync(validatedPath, 'utf-8'); - } catch (error) { - const nodeError = error as NodeJS.ErrnoException; - if (nodeError.code === 'ENOENT') { - throw new Error(`File not found: ${filePath}`); - } - throw error; - } - - // Find ALL matches using layered strategies - const matches = findAllMatches(content, search); - - if (matches.length === 0) { - // No match found - throw with helpful suggestions - const failure = getMatchFailure(content, search); - throw new Error(this.formatFailure(filePath, search, failure)); - } - - // Store original content for before/after display - const originalLines = content.split('\n'); - - // Collect before contexts BEFORE applying any replacements - const beforeContexts: Array<{ - startLine: number; - endLine: number; - context: string; - }> = []; - for (const match of matches) { - beforeContexts.push({ - startLine: match.startLine, - endLine: match.endLine, - context: this.formatContext(originalLines, match.startLine, match.endLine, 5, '<'), - }); - } - - // Apply replacements in reverse order (to preserve indices) - let newContent = content; - const sortedMatches = [...matches].sort((a, b) => b.startIndex - a.startIndex); - for (const match of sortedMatches) { - newContent = applyReplacement(newContent, match, replace); - } - - // Write file - writeFileSync(validatedPath, newContent, 'utf-8'); - invalidateFileRead(validatedPath); - - // Build and return success output - return this.buildSearchReplaceOutput( - filePath, - validatedPath, - matches, - beforeContexts, - replace, - newContent, - ); - } - - private handleInsertAtLine( - params: Extract, - validatedPath: string, - ): string { - const { filePath, line, content: insertContent } = params; - - // Read file content - let content: string; - try { - content = readFileSync(validatedPath, 'utf-8'); - } catch (error) { - const nodeError = error as NodeJS.ErrnoException; - if (nodeError.code === 'ENOENT') { - // For insert, create empty file - content = ''; - } else { - throw error; - } - } - - const lines = content.split('\n'); - const insertLines = insertContent.split('\n'); - const insertAtEnd = line > lines.length; - const effectiveLine = Math.min(line, lines.length + 1); - - // Store before context - const beforeContext = this.formatContext(lines, effectiveLine, effectiveLine, 3, '<'); - - // Insert lines - const newLines = [ - ...lines.slice(0, effectiveLine - 1), - ...insertLines, - ...lines.slice(effectiveLine - 1), - ]; - - const newContent = newLines.join('\n'); - writeFileSync(validatedPath, newContent, 'utf-8'); - invalidateFileRead(validatedPath); - - // Build output - const output: string[] = [`path=${filePath} mode=insert_at_line status=success`, '']; - - if (insertAtEnd) { - output.push( - `Appended ${insertLines.length} line${insertLines.length > 1 ? 's' : ''} at end of file.`, - ); - } else { - output.push( - `Inserted ${insertLines.length} line${insertLines.length > 1 ? 's' : ''} at line ${effectiveLine}.`, - ); - } - - output.push( - '', - insertAtEnd - ? '--- BEFORE (end of file) ---' - : `--- BEFORE (around line ${effectiveLine}) ---`, - beforeContext || '(empty file)', - '', - '--- AFTER ---', - this.formatContext(newLines, effectiveLine, effectiveLine + insertLines.length - 1, 3), - ); - - if (filePath.endsWith('.ts') || filePath.endsWith('.tsx')) { - output.push('', this.runDiagnostics(validatedPath)); - } - - return output.join('\n'); - } - - private handleRemoveLines( - params: Extract, - validatedPath: string, - ): string { - const { filePath, startLine, endLine } = params; - - // Validate line range - if (startLine > endLine) { - throw new Error(`Invalid line range: startLine (${startLine}) > endLine (${endLine})`); - } - - // Read file content - let content: string; - try { - content = readFileSync(validatedPath, 'utf-8'); - } catch (error) { - const nodeError = error as NodeJS.ErrnoException; - if (nodeError.code === 'ENOENT') { - throw new Error(`File not found: ${filePath}`); - } - throw error; - } - - const lines = content.split('\n'); - - // Validate startLine - if (startLine > lines.length) { - throw new Error(`startLine (${startLine}) is beyond end of file (${lines.length} lines)`); - } - - const effectiveEndLine = Math.min(endLine, lines.length); - const removedCount = effectiveEndLine - startLine + 1; - - // Store before context with highlight on lines to remove - const beforeContext = this.formatContext(lines, startLine, effectiveEndLine, 3, '<'); - - // Remove lines - const newLines = [...lines.slice(0, startLine - 1), ...lines.slice(effectiveEndLine)]; - - const newContent = newLines.join('\n'); - writeFileSync(validatedPath, newContent, 'utf-8'); - invalidateFileRead(validatedPath); - - // Build output - const lineDesc = - removedCount === 1 ? `line ${startLine}` : `lines ${startLine}-${effectiveEndLine}`; - - const output: string[] = [ - `path=${filePath} mode=remove_lines status=success`, - '', - `Removed ${removedCount} line${removedCount > 1 ? 's' : ''} (${lineDesc}).`, - '', - '--- BEFORE ---', - beforeContext, - '', - '--- AFTER ---', - this.formatContext( - newLines, - Math.max(1, startLine - 1), - Math.min(newLines.length, startLine), - 3, - ) || '(empty file)', - ]; - - if (filePath.endsWith('.ts') || filePath.endsWith('.tsx')) { - output.push('', this.runDiagnostics(validatedPath)); - } - - return output.join('\n'); - } - - private buildSearchReplaceOutput( - filePath: string, - validatedPath: string, - matches: Array<{ - startLine: number; - endLine: number; - strategy: string; - }>, - beforeContexts: Array<{ - startLine: number; - endLine: number; - context: string; - }>, - replace: string, - newContent: string, - ): string { - const newLines = newContent.split('\n'); - const output: string[] = [ - `path=${filePath} status=success matches=${matches.length} strategy=${matches[0].strategy}`, - '', - `Replaced ${matches.length} occurrence${matches.length > 1 ? 's' : ''}.`, - ]; - - const replacementLineCount = replace.split('\n').length; - let cumulativeLineDelta = 0; - - for (let i = 0; i < matches.length; i++) { - const match = matches[i]; - const matchedLineCount = match.endLine - match.startLine + 1; - const lineDelta = replacementLineCount - matchedLineCount; - const afterStartLine = match.startLine + cumulativeLineDelta; - const afterEndLine = afterStartLine + replacementLineCount - 1; - - output.push( - '', - `=== Edit ${i + 1} (lines ${match.startLine}-${match.endLine}) ===`, - '--- BEFORE ---', - beforeContexts[i].context, - '', - '--- AFTER ---', - this.formatContext(newLines, afterStartLine, afterEndLine, 5), - ); - - cumulativeLineDelta += lineDelta; - } - - if (filePath.endsWith('.ts') || filePath.endsWith('.tsx')) { - output.push('', this.runDiagnostics(validatedPath)); - } - - return output.join('\n'); - } - - private formatFailure( - filePath: string, - search: string, - failure: { - reason: string; - suggestions: Array<{ - content: string; - lineNumber: number; - similarity: number; - }>; - nearbyContext: string; - }, - ): string { - const lines: string[] = [ - `ERROR: Search content NOT FOUND in file ${filePath}`, - '', - 'SEARCH CONTENT:', - '```', - search, - '```', - ]; - - if (failure.suggestions.length > 0) { - lines.push('', 'SUGGESTIONS (similar content found):'); - for (const suggestion of failure.suggestions) { - const percent = Math.round(suggestion.similarity * 100); - lines.push( - '', - `Line ${suggestion.lineNumber} (${percent}% similar):`, - '```', - suggestion.content, - '```', - ); - } - - if (failure.nearbyContext) { - lines.push('', 'CONTEXT:', failure.nearbyContext); - } - } - - return lines.join('\n'); - } - - private formatContext( - lines: string[], - startLine: number, - endLine: number, - contextLines = 5, - editMarker = '>', - ): string { - const rangeStart = Math.max(0, startLine - 1 - contextLines); - const rangeEnd = Math.min(lines.length, endLine + contextLines); - - const result: string[] = []; - for (let i = rangeStart; i < rangeEnd; i++) { - const lineNum = i + 1; - const isEdited = lineNum >= startLine && lineNum <= endLine; - const marker = isEdited ? editMarker : ' '; - const paddedNum = String(lineNum).padStart(4); - result.push(`${marker}${paddedNum} | ${lines[i]}`); - } - - return result.join('\n'); - } - - private runDiagnostics(filePath: string): string { - const sections: string[] = []; - - // TypeScript check - try { - execSync('npx tsc --noEmit --pretty false', { - encoding: 'utf-8', - cwd: process.cwd(), - timeout: 20000, - stdio: 'pipe', - }); - sections.push('=== TypeScript Check ==='); - sections.push('No type errors found.'); - } catch (error) { - const execError = error as { stdout?: string; stderr?: string }; - const output = [execError.stdout, execError.stderr].filter(Boolean).join('\n'); - // Filter to errors in the edited file, but keep full error messages - const lines = output.split('\n'); - const fileErrors: string[] = []; - let includeNext = false; - for (const line of lines) { - if (line.includes(filePath)) { - fileErrors.push(line); - includeNext = true; - } else if (includeNext && (line.startsWith(' ') || line === '')) { - fileErrors.push(line); - } else { - includeNext = false; - } - } - sections.push('=== TypeScript Check ==='); - sections.push(fileErrors.join('\n') || 'No type errors found.'); - } - - // Biome lint check - try { - execSync(`npx biome check "${filePath}"`, { - encoding: 'utf-8', - cwd: process.cwd(), - timeout: 10000, - stdio: 'pipe', - }); - sections.push(''); - sections.push('=== Biome Lint ==='); - sections.push('No lint issues found.'); - } catch (error) { - const execError = error as { stdout?: string; stderr?: string }; - // Biome outputs diagnostics to stdout, summary to stderr - capture both - const output = [execError.stdout, execError.stderr].filter(Boolean).join('\n'); - sections.push(''); - sections.push('=== Biome Lint ==='); - sections.push(output.trim() || 'No lint issues found.'); - } - - return sections.join('\n'); - } -} diff --git a/src/gadgets/FileInsertContent.ts b/src/gadgets/FileInsertContent.ts new file mode 100644 index 00000000..2a7b387a --- /dev/null +++ b/src/gadgets/FileInsertContent.ts @@ -0,0 +1,341 @@ +/** + * FileInsertContent gadget - Insert content at a specific line number. + * + * Line numbers are 1-based. Content is inserted BEFORE the specified line. + * Use a line beyond EOF to append at end. + */ + +import { readFileSync, writeFileSync } from 'node:fs'; + +import { Gadget, z } from 'llmist'; + +import { invalidateFileRead } from './readTracking.js'; +import { + formatContext, + runDiagnostics, + shouldRunDiagnostics, + validatePath, +} from './shared/index.js'; + +export class FileInsertContent extends Gadget({ + name: 'FileInsertContent', + description: `Insert content at a specific line number in a file. + +- Line numbers are 1-based +- Content is inserted BEFORE the specified line +- Use line beyond EOF to append at end`, + timeoutMs: 30000, + maxConcurrent: 1, // Sequential execution to prevent race conditions on file writes + schema: z.object({ + comment: z.string().min(1).describe('Brief rationale for this gadget call'), + filePath: z.string().describe('Path to the file to edit'), + line: z + .number() + .int() + .min(1) + .describe('Line number to insert BEFORE (1-based). Use line beyond EOF to append.'), + content: z.string().describe('Content to insert (can be multiline)'), + }), + examples: [ + // Example 1: Single-line import at top + { + params: { + comment: 'Adding lodash import at top of file', + filePath: 'src/utils.ts', + line: 1, + content: "import _ from 'lodash';", + }, + output: `path=src/utils.ts status=success + +Inserted 1 line at line 1. + +--- BEFORE (around line 1) --- +< 1 | import { foo } from 'bar'; + 2 | import { baz } from 'qux'; + 3 | + +--- AFTER --- +> 1 | import _ from 'lodash'; + 2 | import { foo } from 'bar'; + 3 | import { baz } from 'qux'; + 4 | + +=== TypeScript Check === +No type errors found. + +=== Biome Lint === +No lint issues found.`, + comment: 'Insert import at beginning of file', + }, + // Example 2: Multi-line function insertion + { + params: { + comment: 'Adding helper function in middle of file', + filePath: 'src/helpers.ts', + line: 10, + content: `function validate(x: number): boolean { + return x > 0; +}`, + }, + output: `path=src/helpers.ts status=success + +Inserted 3 lines at line 10. + +--- BEFORE (around line 10) --- + 7 | } + 8 | + 9 | // Utils below +< 10 | function process(data: string) { + 11 | return data.trim(); + 12 | } + +--- AFTER --- + 7 | } + 8 | + 9 | // Utils below +> 10 | function validate(x: number): boolean { + 11 | return x > 0; + 12 | } + 13 | function process(data: string) { + 14 | return data.trim(); + 15 | } + +=== TypeScript Check === +No type errors found. + +=== Biome Lint === +No lint issues found.`, + comment: 'Insert multiline block in middle of file', + }, + // Example 3: Inserting a full interface definition + { + params: { + comment: 'Adding User interface after imports', + filePath: 'src/types/user.ts', + line: 5, + content: `export interface User { + id: string; + email: string; + name: string; + createdAt: Date; + updatedAt: Date; + role: 'admin' | 'user' | 'guest'; + preferences: { + theme: 'light' | 'dark'; + notifications: boolean; + }; +}`, + }, + output: `path=src/types/user.ts status=success + +Inserted 12 lines at line 5. + +--- BEFORE (around line 5) --- + 2 | import type { BaseEntity } from './base'; + 3 | import type { Preferences } from './preferences'; + 4 | +< 5 | // User types will be defined here + 6 | + +--- AFTER --- + 2 | import type { BaseEntity } from './base'; + 3 | import type { Preferences } from './preferences'; + 4 | +> 5 | export interface User { +> 6 | id: string; +> 7 | email: string; +> 8 | name: string; +> 9 | createdAt: Date; +> 10 | updatedAt: Date; +> 11 | role: 'admin' | 'user' | 'guest'; +> 12 | preferences: { +> 13 | theme: 'light' | 'dark'; +> 14 | notifications: boolean; +> 15 | }; +> 16 | } + 17 | // User types will be defined here + 18 | + +=== TypeScript Check === +No type errors found. + +=== Biome Lint === +No lint issues found.`, + comment: 'Insert complete interface definition', + }, + // Example 4: Append at end of file + { + params: { + comment: 'Appending export at end of file', + filePath: 'src/index.ts', + line: 999, + content: "export * from './newModule';", + }, + output: `path=src/index.ts status=success + +Appended 1 line at end of file. + +--- BEFORE (end of file) --- + 3 | export * from './utils'; + 4 | export * from './helpers'; + 5 | + +--- AFTER --- + 3 | export * from './utils'; + 4 | export * from './helpers'; + 5 | +> 6 | export * from './newModule'; + +=== TypeScript Check === +No type errors found. + +=== Biome Lint === +No lint issues found.`, + comment: 'Append at end of file (line beyond EOF)', + }, + // Example 5: Inserting a complete class + { + params: { + comment: 'Adding Logger class implementation', + filePath: 'src/utils/logger.ts', + line: 8, + content: `export class Logger { + private readonly prefix: string; + private readonly level: LogLevel; + + constructor(prefix: string, level: LogLevel = 'info') { + this.prefix = prefix; + this.level = level; + } + + info(message: string, context?: Record): void { + this.log('info', message, context); + } + + error(message: string, context?: Record): void { + this.log('error', message, context); + } + + private log(level: LogLevel, message: string, context?: Record): void { + const timestamp = new Date().toISOString(); + console.log(JSON.stringify({ timestamp, level, prefix: this.prefix, message, ...context })); + } +}`, + }, + output: `path=src/utils/logger.ts status=success + +Inserted 22 lines at line 8. + +--- BEFORE (around line 8) --- + 5 | type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + 6 | + 7 | // Logger implementation +< 8 | export function createLogger(prefix: string) { + +--- AFTER --- + 5 | type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + 6 | + 7 | // Logger implementation +> 8 | export class Logger { +> 9 | private readonly prefix: string; +> 10 | private readonly level: LogLevel; +> 11 | +> 12 | constructor(prefix: string, level: LogLevel = 'info') { +> 13 | this.prefix = prefix; +> 14 | this.level = level; +> 15 | } +> 16 | +> 17 | info(message: string, context?: Record): void { +> 18 | this.log('info', message, context); +> 19 | } +> 20 | +> 21 | error(message: string, context?: Record): void { +> 22 | this.log('error', message, context); +> 23 | } +> 24 | +> 25 | private log(level: LogLevel, message: string, context?: Record): void { +> 26 | const timestamp = new Date().toISOString(); +> 27 | console.log(JSON.stringify({ timestamp, level, prefix: this.prefix, message, ...context })); +> 28 | } +> 29 | } + 30 | export function createLogger(prefix: string) { + +=== TypeScript Check === +No type errors found. + +=== Biome Lint === +No lint issues found.`, + comment: 'Insert complete class definition', + }, + ], +}) { + override execute(params: this['params']): string { + const { filePath, line, content: insertContent } = params; + + // Validate and resolve path + const validatedPath = validatePath(filePath); + + // Read file content + let content: string; + try { + content = readFileSync(validatedPath, 'utf-8'); + } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + if (nodeError.code === 'ENOENT') { + // For insert, create empty file + content = ''; + } else { + throw error; + } + } + + const lines = content.split('\n'); + const insertLines = insertContent.split('\n'); + const insertAtEnd = line > lines.length; + const effectiveLine = Math.min(line, lines.length + 1); + + // Store before context + const beforeContext = formatContext(lines, effectiveLine, effectiveLine, 3, '<'); + + // Insert lines + const newLines = [ + ...lines.slice(0, effectiveLine - 1), + ...insertLines, + ...lines.slice(effectiveLine - 1), + ]; + + const newContent = newLines.join('\n'); + writeFileSync(validatedPath, newContent, 'utf-8'); + invalidateFileRead(validatedPath); + + // Build output + const output: string[] = [`path=${filePath} status=success`, '']; + + if (insertAtEnd) { + output.push( + `Appended ${insertLines.length} line${insertLines.length > 1 ? 's' : ''} at end of file.`, + ); + } else { + output.push( + `Inserted ${insertLines.length} line${insertLines.length > 1 ? 's' : ''} at line ${effectiveLine}.`, + ); + } + + output.push( + '', + insertAtEnd + ? '--- BEFORE (end of file) ---' + : `--- BEFORE (around line ${effectiveLine}) ---`, + beforeContext || '(empty file)', + '', + '--- AFTER ---', + formatContext(newLines, effectiveLine, effectiveLine + insertLines.length - 1, 3), + ); + + if (shouldRunDiagnostics(filePath)) { + output.push('', runDiagnostics(validatedPath)); + } + + return output.join('\n'); + } +} diff --git a/src/gadgets/FileRemoveContent.ts b/src/gadgets/FileRemoveContent.ts new file mode 100644 index 00000000..217c8e1f --- /dev/null +++ b/src/gadgets/FileRemoveContent.ts @@ -0,0 +1,203 @@ +/** + * FileRemoveContent gadget - Remove a range of lines from a file. + * + * Line numbers are 1-based and inclusive. + */ + +import { readFileSync, writeFileSync } from 'node:fs'; + +import { Gadget, z } from 'llmist'; + +import { invalidateFileRead } from './readTracking.js'; +import { + formatContext, + runDiagnostics, + shouldRunDiagnostics, + validatePath, +} from './shared/index.js'; + +export class FileRemoveContent extends Gadget({ + name: 'FileRemoveContent', + description: `Remove a range of lines from a file. + +- Line numbers are 1-based and inclusive +- startLine and endLine define the range to remove`, + timeoutMs: 30000, + maxConcurrent: 1, // Sequential execution to prevent race conditions on file writes + schema: z.object({ + comment: z.string().min(1).describe('Brief rationale for this gadget call'), + filePath: z.string().describe('Path to the file to edit'), + startLine: z.number().int().min(1).describe('First line to remove (1-based, inclusive)'), + endLine: z.number().int().min(1).describe('Last line to remove (1-based, inclusive)'), + }), + examples: [ + // Example 1: Remove single line + { + params: { + comment: 'Removing unused import', + filePath: 'src/app.ts', + startLine: 3, + endLine: 3, + }, + output: `path=src/app.ts status=success + +Removed 1 line (line 3). + +--- BEFORE --- + 1 | import { foo } from 'foo'; + 2 | import { bar } from 'bar'; +< 3 | import { unused } from 'unused'; + 4 | import { baz } from 'baz'; + 5 | + +--- AFTER --- + 1 | import { foo } from 'foo'; + 2 | import { bar } from 'bar'; + 3 | import { baz } from 'baz'; + 4 | + +=== TypeScript Check === +No type errors found. + +=== Biome Lint === +No lint issues found.`, + comment: 'Remove single line', + }, + // Example 2: Remove block of lines (deprecated function) + { + params: { + comment: 'Removing deprecated function', + filePath: 'src/legacy.ts', + startLine: 5, + endLine: 10, + }, + output: `path=src/legacy.ts status=success + +Removed 6 lines (lines 5-10). + +--- BEFORE --- + 2 | + 3 | export function keepThis() {} + 4 | +< 5 | /** @deprecated */ +< 6 | function oldFunc(x: number) { +< 7 | console.log('deprecated'); +< 8 | return x * 2; +< 9 | } +< 10 | + 11 | export function keepThisToo() {} + +--- AFTER --- + 2 | + 3 | export function keepThis() {} + 4 | + 5 | export function keepThisToo() {} + +=== TypeScript Check === +No type errors found. + +=== Biome Lint === +No lint issues found.`, + comment: 'Remove block of lines', + }, + // Example 3: Remove from non-TS file + { + params: { + comment: 'Removing comment block from config', + filePath: 'config/settings.json', + startLine: 2, + endLine: 4, + }, + output: `path=config/settings.json status=success + +Removed 3 lines (lines 2-4). + +--- BEFORE --- + 1 | { +< 2 | "// NOTE": "Remove this later", +< 3 | "// TODO": "Clean up config", +< 4 | "// FIXME": "Legacy value", + 5 | "enabled": true, + 6 | "timeout": 5000 + 7 | } + +--- AFTER --- + 1 | { + 2 | "enabled": true, + 3 | "timeout": 5000 + 4 | }`, + comment: 'Remove from non-TS file (no diagnostics)', + }, + ], +}) { + override execute(params: this['params']): string { + const { filePath, startLine, endLine } = params; + + // Validate line range + if (startLine > endLine) { + throw new Error(`Invalid line range: startLine (${startLine}) > endLine (${endLine})`); + } + + // Validate and resolve path + const validatedPath = validatePath(filePath); + + // Read file content + let content: string; + try { + content = readFileSync(validatedPath, 'utf-8'); + } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + if (nodeError.code === 'ENOENT') { + throw new Error(`File not found: ${filePath}`); + } + throw error; + } + + const lines = content.split('\n'); + + // Validate startLine + if (startLine > lines.length) { + throw new Error(`startLine (${startLine}) is beyond end of file (${lines.length} lines)`); + } + + const effectiveEndLine = Math.min(endLine, lines.length); + const removedCount = effectiveEndLine - startLine + 1; + + // Store before context with highlight on lines to remove + const beforeContext = formatContext(lines, startLine, effectiveEndLine, 3, '<'); + + // Remove lines + const newLines = [...lines.slice(0, startLine - 1), ...lines.slice(effectiveEndLine)]; + + const newContent = newLines.join('\n'); + writeFileSync(validatedPath, newContent, 'utf-8'); + invalidateFileRead(validatedPath); + + // Build output + const lineDesc = + removedCount === 1 ? `line ${startLine}` : `lines ${startLine}-${effectiveEndLine}`; + + const output: string[] = [ + `path=${filePath} status=success`, + '', + `Removed ${removedCount} line${removedCount > 1 ? 's' : ''} (${lineDesc}).`, + '', + '--- BEFORE ---', + beforeContext, + '', + '--- AFTER ---', + formatContext( + newLines, + Math.max(1, startLine - 1), + Math.min(newLines.length, startLine), + 3, + ) || '(empty file)', + ]; + + if (shouldRunDiagnostics(filePath)) { + output.push('', runDiagnostics(validatedPath)); + } + + return output.join('\n'); + } +} diff --git a/src/gadgets/FileSearchAndReplace.ts b/src/gadgets/FileSearchAndReplace.ts new file mode 100644 index 00000000..4cd312b2 --- /dev/null +++ b/src/gadgets/FileSearchAndReplace.ts @@ -0,0 +1,439 @@ +/** + * FileSearchAndReplace gadget - Search for content and replace it. + * + * Uses layered matching strategies: exact → whitespace → indentation → fuzzy + * This approach reduces edit errors by ~9x (per Aider benchmarks). + */ + +import { readFileSync, writeFileSync } from 'node:fs'; + +import { Gadget, z } from 'llmist'; + +import { invalidateFileRead } from './readTracking.js'; +import { + applyReplacement, + findAllMatches, + formatContext, + getMatchFailure, + runDiagnostics, + shouldRunDiagnostics, + validatePath, +} from './shared/index.js'; + +export class FileSearchAndReplace extends Gadget({ + name: 'FileSearchAndReplace', + description: `Search for content in a file and replace it. + +Uses layered matching: exact → whitespace → indentation → fuzzy +This reduces edit errors by ~9x. + +All occurrences of the search content are replaced.`, + timeoutMs: 30000, + maxConcurrent: 1, // Sequential execution to prevent race conditions on file writes + schema: z.object({ + comment: z.string().min(1).describe('Brief rationale for this gadget call'), + filePath: z.string().describe('Path to the file to edit'), + search: z.string().min(1).describe('The content to search for'), + replace: z.string().describe('The content to replace with (empty to delete)'), + }), + examples: [ + // Example 1: Single-line replacement + { + params: { + comment: 'Increasing timeout from 1s to 5s to fix test flakiness', + filePath: 'src/config.ts', + search: 'timeout: 1000', + replace: 'timeout: 5000', + }, + output: `path=src/config.ts status=success matches=1 strategy=exact + +Replaced 1 occurrence. + +=== Edit 1 (lines 5-5) === +--- BEFORE --- + 1 | import { foo } from "bar"; + 2 | + 3 | const CONFIG = { + 4 | debug: false, +< 5 | timeout: 1000, + 6 | retries: 3, + 7 | }; + +--- AFTER --- + 1 | import { foo } from "bar"; + 2 | + 3 | const CONFIG = { + 4 | debug: false, +> 5 | timeout: 5000, + 6 | retries: 3, + 7 | }; + +=== TypeScript Check === +No type errors found. + +=== Biome Lint === +No lint issues found.`, + comment: 'Single edit with before/after context and diagnostics', + }, + // Example 2: Multi-line function replacement + { + params: { + comment: 'Replacing sync function with async implementation', + filePath: 'src/api/users.ts', + search: `function getUser(id: string): User | null { + const user = userCache.get(id); + return user || null; +}`, + replace: `async function getUser(id: string): Promise { + // Check cache first + const cached = userCache.get(id); + if (cached) return cached; + + // Fetch from database + const user = await db.users.findById(id); + if (user) { + userCache.set(id, user); + } + return user; +}`, + }, + output: `path=src/api/users.ts status=success matches=1 strategy=exact + +Replaced 1 occurrence. + +=== Edit 1 (lines 12-15) === +--- BEFORE --- + 9 | import { db } from '../db'; + 10 | import { userCache } from '../cache'; + 11 | +< 12 | function getUser(id: string): User | null { +< 13 | const user = userCache.get(id); +< 14 | return user || null; +< 15 | } + 16 | + 17 | function updateUser(id: string, data: Partial): void { + +--- AFTER --- + 9 | import { db } from '../db'; + 10 | import { userCache } from '../cache'; + 11 | +> 12 | async function getUser(id: string): Promise { +> 13 | // Check cache first +> 14 | const cached = userCache.get(id); +> 15 | if (cached) return cached; +> 16 | +> 17 | // Fetch from database +> 18 | const user = await db.users.findById(id); +> 19 | if (user) { +> 20 | userCache.set(id, user); +> 21 | } +> 22 | return user; +> 23 | } + 24 | + 25 | function updateUser(id: string, data: Partial): void { + +=== TypeScript Check === +No type errors found. + +=== Biome Lint === +No lint issues found.`, + comment: 'Replace entire function with new async implementation', + }, + // Example 3: Adding error handling to existing code + { + params: { + comment: 'Adding try-catch error handling to API call', + filePath: 'src/services/payment.ts', + search: `const result = await stripe.charges.create({ + amount: order.total, + currency: 'usd', + source: token, +}); +return result;`, + replace: `try { + const result = await stripe.charges.create({ + amount: order.total, + currency: 'usd', + source: token, + }); + return result; +} catch (error) { + logger.error('Payment failed', { orderId: order.id, error }); + throw new PaymentError('Failed to process payment', { cause: error }); +}`, + }, + output: `path=src/services/payment.ts status=success matches=1 strategy=exact + +Replaced 1 occurrence. + +=== Edit 1 (lines 24-29) === +--- BEFORE --- + 21 | async function processPayment(order: Order, token: string) { + 22 | validateOrder(order); + 23 | +< 24 | const result = await stripe.charges.create({ +< 25 | amount: order.total, +< 26 | currency: 'usd', +< 27 | source: token, +< 28 | }); +< 29 | return result; + 30 | } + +--- AFTER --- + 21 | async function processPayment(order: Order, token: string) { + 22 | validateOrder(order); + 23 | +> 24 | try { +> 25 | const result = await stripe.charges.create({ +> 26 | amount: order.total, +> 27 | currency: 'usd', +> 28 | source: token, +> 29 | }); +> 30 | return result; +> 31 | } catch (error) { +> 32 | logger.error('Payment failed', { orderId: order.id, error }); +> 33 | throw new PaymentError('Failed to process payment', { cause: error }); +> 34 | } + 35 | } + +=== TypeScript Check === +No type errors found. + +=== Biome Lint === +No lint issues found.`, + comment: 'Wrap existing code with try-catch error handling', + }, + // Example 4: Multiple occurrences + { + params: { + comment: 'Updating retry constant per requirements', + filePath: 'src/constants.ts', + search: 'MAX_RETRIES = 3', + replace: 'MAX_RETRIES = 5', + }, + output: `path=src/constants.ts status=success matches=2 strategy=exact + +Replaced 2 occurrences. + +=== Edit 1 (lines 4-4) === +--- BEFORE --- + 1 | // API constants + 2 | export const API_URL = "https://api.example.com"; + 3 | +< 4 | export const MAX_RETRIES = 3; + 5 | export const TIMEOUT = 5000; + +--- AFTER --- + 1 | // API constants + 2 | export const API_URL = "https://api.example.com"; + 3 | +> 4 | export const MAX_RETRIES = 5; + 5 | export const TIMEOUT = 5000; + +=== Edit 2 (lines 12-12) === +--- BEFORE --- + 9 | // Client constants + 10 | export const CLIENT_URL = "https://client.example.com"; + 11 | +< 12 | export const MAX_RETRIES = 3; + 13 | export const CLIENT_TIMEOUT = 3000; + +--- AFTER --- + 9 | // Client constants + 10 | export const CLIENT_URL = "https://client.example.com"; + 11 | +> 12 | export const MAX_RETRIES = 5; + 13 | export const CLIENT_TIMEOUT = 3000; + +=== TypeScript Check === +No type errors found. + +=== Biome Lint === +No lint issues found.`, + comment: 'Multiple matches replaced with before/after for each', + }, + // Example 5: Non-TypeScript file (no diagnostics) + { + params: { + comment: 'Enabling feature flag for new functionality', + filePath: 'src/data.json', + search: '"enabled": false', + replace: '"enabled": true', + }, + output: `path=src/data.json status=success matches=1 strategy=exact + +Replaced 1 occurrence. + +=== Edit 1 (lines 3-3) === +--- BEFORE --- + 1 | { + 2 | "name": "example", +< 3 | "enabled": false, + 4 | "count": 0 + 5 | } + +--- AFTER --- + 1 | { + 2 | "name": "example", +> 3 | "enabled": true, + 4 | "count": 0 + 5 | }`, + comment: 'Non-TypeScript file (no diagnostics appended)', + }, + ], +}) { + override execute(params: this['params']): string { + const { filePath, search, replace } = params; + + // Validate and resolve path + const validatedPath = validatePath(filePath); + + // Read file content + let content: string; + try { + content = readFileSync(validatedPath, 'utf-8'); + } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + if (nodeError.code === 'ENOENT') { + throw new Error(`File not found: ${filePath}`); + } + throw error; + } + + // Find ALL matches using layered strategies + const matches = findAllMatches(content, search); + + if (matches.length === 0) { + // No match found - throw with helpful suggestions + const failure = getMatchFailure(content, search); + throw new Error(this.formatFailure(filePath, search, failure)); + } + + // Store original content for before/after display + const originalLines = content.split('\n'); + + // Collect before contexts BEFORE applying any replacements + const beforeContexts: Array<{ + startLine: number; + endLine: number; + context: string; + }> = []; + for (const match of matches) { + beforeContexts.push({ + startLine: match.startLine, + endLine: match.endLine, + context: formatContext(originalLines, match.startLine, match.endLine, 5, '<'), + }); + } + + // Apply replacements in reverse order (to preserve indices) + let newContent = content; + const sortedMatches = [...matches].sort((a, b) => b.startIndex - a.startIndex); + for (const match of sortedMatches) { + newContent = applyReplacement(newContent, match, replace); + } + + // Write file + writeFileSync(validatedPath, newContent, 'utf-8'); + invalidateFileRead(validatedPath); + + // Build and return success output + return this.buildOutput(filePath, validatedPath, matches, beforeContexts, replace, newContent); + } + + private buildOutput( + filePath: string, + validatedPath: string, + matches: Array<{ + startLine: number; + endLine: number; + strategy: string; + }>, + beforeContexts: Array<{ + startLine: number; + endLine: number; + context: string; + }>, + replace: string, + newContent: string, + ): string { + const newLines = newContent.split('\n'); + const output: string[] = [ + `path=${filePath} status=success matches=${matches.length} strategy=${matches[0].strategy}`, + '', + `Replaced ${matches.length} occurrence${matches.length > 1 ? 's' : ''}.`, + ]; + + const replacementLineCount = replace.split('\n').length; + let cumulativeLineDelta = 0; + + for (let i = 0; i < matches.length; i++) { + const match = matches[i]; + const matchedLineCount = match.endLine - match.startLine + 1; + const lineDelta = replacementLineCount - matchedLineCount; + const afterStartLine = match.startLine + cumulativeLineDelta; + const afterEndLine = afterStartLine + replacementLineCount - 1; + + output.push( + '', + `=== Edit ${i + 1} (lines ${match.startLine}-${match.endLine}) ===`, + '--- BEFORE ---', + beforeContexts[i].context, + '', + '--- AFTER ---', + formatContext(newLines, afterStartLine, afterEndLine, 5), + ); + + cumulativeLineDelta += lineDelta; + } + + if (shouldRunDiagnostics(filePath)) { + output.push('', runDiagnostics(validatedPath)); + } + + return output.join('\n'); + } + + private formatFailure( + filePath: string, + search: string, + failure: { + reason: string; + suggestions: Array<{ + content: string; + lineNumber: number; + similarity: number; + }>; + nearbyContext: string; + }, + ): string { + const lines: string[] = [ + `ERROR: Search content NOT FOUND in file ${filePath}`, + '', + 'SEARCH CONTENT:', + '```', + search, + '```', + ]; + + if (failure.suggestions.length > 0) { + lines.push('', 'SUGGESTIONS (similar content found):'); + for (const suggestion of failure.suggestions) { + const percent = Math.round(suggestion.similarity * 100); + lines.push( + '', + `Line ${suggestion.lineNumber} (${percent}% similar):`, + '```', + suggestion.content, + '```', + ); + } + + if (failure.nearbyContext) { + lines.push('', 'CONTEXT:', failure.nearbyContext); + } + } + + return lines.join('\n'); + } +} diff --git a/src/gadgets/Finish.ts b/src/gadgets/Finish.ts index b32a6bca..ce1398e0 100644 --- a/src/gadgets/Finish.ts +++ b/src/gadgets/Finish.ts @@ -1,4 +1,5 @@ import { Gadget, TaskCompletionSignal, z } from 'llmist'; +import { getSessionState } from './sessionState.js'; export class Finish extends Gadget({ name: 'Finish', @@ -16,6 +17,16 @@ export class Finish extends Gadget({ ], }) { override execute(params: this['params']): never { + const state = getSessionState(); + + // For implementation agent, require PR creation + if (state.agentType === 'implementation' && !state.prCreated) { + throw new Error( + 'Cannot finish implementation session without creating a PR. ' + + 'You must call CreatePR to submit your changes before calling Finish.', + ); + } + throw new TaskCompletionSignal(params.comment); } } diff --git a/src/gadgets/WriteFile.ts b/src/gadgets/WriteFile.ts index 5c8c412e..9a870510 100644 --- a/src/gadgets/WriteFile.ts +++ b/src/gadgets/WriteFile.ts @@ -92,6 +92,46 @@ No type errors found. No lint issues found.`, comment: 'Write a TypeScript file with diagnostics output', }, + { + params: { + comment: 'Creating user service class', + filePath: 'src/services/userService.ts', + content: `import { db } from '../db'; +import type { User } from '../types'; + +export class UserService { + async getUser(id: string): Promise { + return db.users.findById(id); + } + + async createUser(data: Partial): Promise { + return db.users.create(data); + } + + async updateUser(id: string, data: Partial): Promise { + const user = await this.getUser(id); + if (!user) { + throw new Error(\`User not found: \${id}\`); + } + return db.users.update(id, data); + } + + async deleteUser(id: string): Promise { + await db.users.delete(id); + } +}`, + }, + output: `path=src/services/userService.ts + +Wrote 542 bytes + +=== TypeScript Check === +No type errors found. + +=== Biome Lint === +No lint issues found.`, + comment: 'Write a complete TypeScript file with multi-line content', + }, ], }) { override execute(params: this['params']): string { diff --git a/src/gadgets/github/CreatePR.ts b/src/gadgets/github/CreatePR.ts index cc474b3d..968af225 100644 --- a/src/gadgets/github/CreatePR.ts +++ b/src/gadgets/github/CreatePR.ts @@ -1,6 +1,7 @@ import { Gadget, z } from 'llmist'; import { githubClient } from '../../github/client.js'; import { runCommand } from '../../utils/repo.js'; +import { recordPRCreation } from '../sessionState.js'; export class CreatePR extends Gadget({ name: 'CreatePR', @@ -150,6 +151,10 @@ If hooks fail or timeout, the full output will be shown.`, draft: params.draft, }); const draftLabel = params.draft ? ' (draft)' : ''; + + // Record PR creation for session state (Finish gadget uses this to verify implementation completed) + recordPRCreation(pr.htmlUrl); + return `PR #${pr.number} created successfully${draftLabel}: ${pr.htmlUrl}`; } } diff --git a/src/gadgets/index.ts b/src/gadgets/index.ts index fb2b9f34..49edfe5d 100644 --- a/src/gadgets/index.ts +++ b/src/gadgets/index.ts @@ -1,3 +1,9 @@ +// File editing gadgets +export { FileSearchAndReplace } from './FileSearchAndReplace.js'; +export { FileInsertContent } from './FileInsertContent.js'; +export { FileRemoveContent } from './FileRemoveContent.js'; +export { WriteFile } from './WriteFile.js'; + // Trello gadgets export { ReadTrelloCard, diff --git a/src/gadgets/readTracking.ts b/src/gadgets/readTracking.ts index 622cd9ea..b047711a 100644 --- a/src/gadgets/readTracking.ts +++ b/src/gadgets/readTracking.ts @@ -39,7 +39,7 @@ export function markDirectoryListed(key: string): void { /** * Invalidate read tracking for a specific file. - * Called after EditFile or WriteFile modifies a file. + * Called after file editing gadgets or WriteFile modifies a file. */ export function invalidateFileRead(path: string): void { readFiles.delete(path); diff --git a/src/gadgets/sessionState.ts b/src/gadgets/sessionState.ts new file mode 100644 index 00000000..c8d72c0a --- /dev/null +++ b/src/gadgets/sessionState.ts @@ -0,0 +1,19 @@ +// Session-level state accessible to all gadgets +let sessionState = { + agentType: null as string | null, + prCreated: false, + prUrl: null as string | null, +}; + +export function initSessionState(agentType: string): void { + sessionState = { agentType, prCreated: false, prUrl: null }; +} + +export function recordPRCreation(prUrl: string): void { + sessionState.prCreated = true; + sessionState.prUrl = prUrl; +} + +export function getSessionState() { + return { ...sessionState }; +} diff --git a/src/gadgets/shared/diagnostics.ts b/src/gadgets/shared/diagnostics.ts new file mode 100644 index 00000000..6a6fd621 --- /dev/null +++ b/src/gadgets/shared/diagnostics.ts @@ -0,0 +1,80 @@ +/** + * Diagnostics runner for file editing gadgets. + * + * Runs TypeScript type checking and Biome linting after file modifications. + */ + +import { execSync } from 'node:child_process'; + +/** + * Run TypeScript and Biome diagnostics on a file. + * + * @param filePath The file path being checked (for filtering errors) + * @returns Formatted diagnostics output string + */ +export function runDiagnostics(filePath: string): string { + const sections: string[] = []; + + // TypeScript check + try { + execSync('npx tsc --noEmit --pretty false', { + encoding: 'utf-8', + cwd: process.cwd(), + timeout: 20000, + stdio: 'pipe', + }); + sections.push('=== TypeScript Check ==='); + sections.push('No type errors found.'); + } catch (error) { + const execError = error as { stdout?: string; stderr?: string }; + const output = [execError.stdout, execError.stderr].filter(Boolean).join('\n'); + // Filter to errors in the edited file, but keep full error messages + const lines = output.split('\n'); + const fileErrors: string[] = []; + let includeNext = false; + for (const line of lines) { + if (line.includes(filePath)) { + fileErrors.push(line); + includeNext = true; + } else if (includeNext && (line.startsWith(' ') || line === '')) { + fileErrors.push(line); + } else { + includeNext = false; + } + } + sections.push('=== TypeScript Check ==='); + sections.push(fileErrors.join('\n') || 'No type errors found.'); + } + + // Biome lint check + try { + execSync(`npx biome check "${filePath}"`, { + encoding: 'utf-8', + cwd: process.cwd(), + timeout: 10000, + stdio: 'pipe', + }); + sections.push(''); + sections.push('=== Biome Lint ==='); + sections.push('No lint issues found.'); + } catch (error) { + const execError = error as { stdout?: string; stderr?: string }; + // Biome outputs diagnostics to stdout, summary to stderr - capture both + const output = [execError.stdout, execError.stderr].filter(Boolean).join('\n'); + sections.push(''); + sections.push('=== Biome Lint ==='); + sections.push(output.trim() || 'No lint issues found.'); + } + + return sections.join('\n'); +} + +/** + * Check if a file should have diagnostics run. + * + * @param filePath The file path to check + * @returns True if the file is a TypeScript file + */ +export function shouldRunDiagnostics(filePath: string): boolean { + return filePath.endsWith('.ts') || filePath.endsWith('.tsx'); +} diff --git a/src/gadgets/shared/index.ts b/src/gadgets/shared/index.ts new file mode 100644 index 00000000..17248b94 --- /dev/null +++ b/src/gadgets/shared/index.ts @@ -0,0 +1,8 @@ +/** + * Shared utilities for file editing gadgets. + */ + +export * from './types.js'; +export * from './matcher.js'; +export * from './pathValidation.js'; +export * from './diagnostics.js'; diff --git a/src/gadgets/editfile/matcher.ts b/src/gadgets/shared/matcher.ts similarity index 94% rename from src/gadgets/editfile/matcher.ts rename to src/gadgets/shared/matcher.ts index 7ed2a179..3f59d716 100644 --- a/src/gadgets/editfile/matcher.ts +++ b/src/gadgets/shared/matcher.ts @@ -1,5 +1,5 @@ /** - * Layered matching algorithm for EditFile gadget. + * Layered matching algorithm for file editing gadgets. * * Tries strategies in order: exact → whitespace → indentation → fuzzy * This approach reduces edit errors by ~9x (per Aider benchmarks). @@ -478,3 +478,28 @@ function getContext(content: string, lineNumber: number, contextLines: number): return contextWithNumbers.join('\n'); } + +/** + * Format context lines around an edited range for output display. + */ +export function formatContext( + lines: string[], + startLine: number, + endLine: number, + contextLines = 5, + editMarker = '>', +): string { + const rangeStart = Math.max(0, startLine - 1 - contextLines); + const rangeEnd = Math.min(lines.length, endLine + contextLines); + + const result: string[] = []; + for (let i = rangeStart; i < rangeEnd; i++) { + const lineNum = i + 1; + const isEdited = lineNum >= startLine && lineNum <= endLine; + const marker = isEdited ? editMarker : ' '; + const paddedNum = String(lineNum).padStart(4); + result.push(`${marker}${paddedNum} | ${lines[i]}`); + } + + return result.join('\n'); +} diff --git a/src/gadgets/shared/pathValidation.ts b/src/gadgets/shared/pathValidation.ts new file mode 100644 index 00000000..7fae100c --- /dev/null +++ b/src/gadgets/shared/pathValidation.ts @@ -0,0 +1,53 @@ +/** + * Path validation for file editing gadgets. + * + * Validates that file paths are within the current working directory + * or allowed directories (e.g., /tmp). + */ + +import { realpathSync } from 'node:fs'; +import { resolve, sep } from 'node:path'; + +const ALLOWED_PATHS = ['/tmp']; + +/** + * Validate and resolve a file path. + * + * @param inputPath The input path (relative or absolute) + * @returns The validated absolute path + * @throws Error if the path is outside allowed directories + */ +export function validatePath(inputPath: string): string { + const cwd = process.cwd(); + const resolvedPath = resolve(cwd, inputPath); + + let finalPath: string; + try { + finalPath = realpathSync(resolvedPath); + } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + if (nodeError.code === 'ENOENT') { + finalPath = resolvedPath; + } else { + throw error; + } + } + + // Check if within CWD + const cwdWithSep = cwd + sep; + if (finalPath.startsWith(cwdWithSep) || finalPath === cwd) { + return finalPath; + } + + // Check if within allowed paths + for (const allowedPath of ALLOWED_PATHS) { + const allowedWithSep = allowedPath + sep; + if (finalPath.startsWith(allowedWithSep) || finalPath === allowedPath) { + return finalPath; + } + } + + throw new Error( + `Path access denied: ${inputPath}. Path must be within working directory or allowed paths (${ALLOWED_PATHS.join(', ')})`, + ); +} diff --git a/src/gadgets/editfile/types.ts b/src/gadgets/shared/types.ts similarity index 96% rename from src/gadgets/editfile/types.ts rename to src/gadgets/shared/types.ts index a04c3f92..1356da06 100644 --- a/src/gadgets/editfile/types.ts +++ b/src/gadgets/shared/types.ts @@ -1,5 +1,5 @@ /** - * Types for the EditFile gadget's layered matching algorithm. + * Types for the file editing gadgets' layered matching algorithm. */ /** diff --git a/src/triggers/index.ts b/src/triggers/index.ts index c4209b2e..4d497792 100644 --- a/src/triggers/index.ts +++ b/src/triggers/index.ts @@ -37,7 +37,7 @@ export function registerBuiltInTriggers(registry: TriggerRegistry): void { registry.register(new AttachmentAddedTrigger()); // GitHub: PR opened trigger (initial review on new PRs) - // DISABLED: Triggers respond-to-review which has EditFile - needs review + // DISABLED: Triggers respond-to-review which has file editing gadgets - needs review // registry.register(new PROpenedTrigger()); // GitHub: PR review comment trigger diff --git a/src/utils/interactive.ts b/src/utils/interactive.ts index f6d4898c..1624c30a 100644 --- a/src/utils/interactive.ts +++ b/src/utils/interactive.ts @@ -9,36 +9,57 @@ function horizontalLine(): string { } /** - * Display EditFile params with mode-specific formatting. + * Display FileSearchAndReplace params. */ -function displayEditFileParams(params: Record): void { +function displayFileSearchAndReplaceParams(params: Record): void { // Comment (rationale for the edit) if (params.comment) { console.log(chalk.dim('Comment: ') + chalk.white(String(params.comment))); } - // File path and mode + // File path if (params.filePath) { - const modeStr = params.mode ? chalk.yellow(`[${params.mode}]`) : ''; - console.log(`${chalk.dim('File: ')}${chalk.cyan(String(params.filePath))} ${modeStr}`); + console.log(`${chalk.dim('File: ')}${chalk.cyan(String(params.filePath))}`); console.log(horizontalLine()); } - // Mode-specific display - switch (params.mode) { - case 'search_replace': - displaySearchReplaceMode(params); - break; - case 'insert_at_line': - displayInsertAtLineMode(params); - break; - case 'remove_lines': - displayRemoveLinesMode(params); - break; - default: - // Fallback for unknown mode - show as search_replace for backwards compat - displaySearchReplaceMode(params); + displaySearchReplaceMode(params); +} + +/** + * Display FileInsertContent params. + */ +function displayFileInsertContentParams(params: Record): void { + // Comment (rationale for the edit) + if (params.comment) { + console.log(chalk.dim('Comment: ') + chalk.white(String(params.comment))); + } + + // File path + if (params.filePath) { + console.log(`${chalk.dim('File: ')}${chalk.cyan(String(params.filePath))}`); + console.log(horizontalLine()); + } + + displayInsertAtLineMode(params); +} + +/** + * Display FileRemoveContent params. + */ +function displayFileRemoveContentParams(params: Record): void { + // Comment (rationale for the edit) + if (params.comment) { + console.log(chalk.dim('Comment: ') + chalk.white(String(params.comment))); } + + // File path + if (params.filePath) { + console.log(`${chalk.dim('File: ')}${chalk.cyan(String(params.filePath))}`); + console.log(horizontalLine()); + } + + displayRemoveLinesMode(params); } /** @@ -119,11 +140,19 @@ export function displayGadgetCall( console.log(horizontalLine()); - // Special formatting for EditFile - if (name === 'EditFile') { - displayEditFileParams(params); - } else { - displayDefaultParams(params); + // Special formatting for file editing gadgets + switch (name) { + case 'FileSearchAndReplace': + displayFileSearchAndReplaceParams(params); + break; + case 'FileInsertContent': + displayFileInsertContentParams(params); + break; + case 'FileRemoveContent': + displayFileRemoveContentParams(params); + break; + default: + displayDefaultParams(params); } console.log(horizontalLine()); diff --git a/tests/unit/gadgets/editfile/matcher.test.ts b/tests/unit/gadgets/shared/matcher.test.ts similarity index 99% rename from tests/unit/gadgets/editfile/matcher.test.ts rename to tests/unit/gadgets/shared/matcher.test.ts index c17cf19c..0c3ec3b0 100644 --- a/tests/unit/gadgets/editfile/matcher.test.ts +++ b/tests/unit/gadgets/shared/matcher.test.ts @@ -3,7 +3,7 @@ import { applyReplacement, findMatch, getMatchFailure, -} from '../../../../src/gadgets/editfile/matcher.js'; +} from '../../../../src/gadgets/shared/matcher.js'; describe('Matcher', () => { describe('findMatch', () => {