diff --git a/package.json b/package.json index 0de8fbf8..58717622 100644 --- a/package.json +++ b/package.json @@ -99,5 +99,10 @@ "esbuild": "^0.25.9", "prettier": "^3.6.2", "typescript": "^5.9.2" + }, + "overrides": { + "@smithy/smithy-client": "^4.10.2", + "@smithy/core": "^3.20.0", + "@smithy/middleware-serde": "^4.2.8" } } diff --git a/src/tools/FileEditTool/FileEditTool.tsx b/src/tools/FileEditTool/FileEditTool.tsx index e82a4921..d5b1de21 100644 --- a/src/tools/FileEditTool/FileEditTool.tsx +++ b/src/tools/FileEditTool/FileEditTool.tsx @@ -23,14 +23,18 @@ import { emitReminderEvent } from '@services/systemReminder' import { recordFileEdit } from '@services/fileFreshness' import { NotebookEditTool } from '@tools/NotebookEditTool/NotebookEditTool' import { DESCRIPTION } from './prompt' -import { applyEdit } from './utils' +import { applyEdit, applyEditWithEnhancements } from './utils' import { hasWritePermission } from '@utils/permissions/filesystem' import { PROJECT_FILE } from '@constants/product' +import { debug } from '@utils/debugLogger' const inputSchema = z.strictObject({ file_path: z.string().describe('The absolute path to the file to modify'), old_string: z.string().describe('The text to replace'), - new_string: z.string().describe('The text to replace it with'), + new_string: z.string().describe('The text to replace it with (must be different from old_string)'), + replace_all: z.boolean().optional().default(false).describe('Replace all occurences of old_string (default false)'), + old_str_start_line_number: z.number().optional().describe('Optional hint: 1-based start line number of old_string'), + old_str_end_line_number: z.number().optional().describe('Optional hint: 1-based end line number of old_string'), }) export type In = typeof inputSchema @@ -121,7 +125,7 @@ export const FileEditTool = { } }, async validateInput( - { file_path, old_string, new_string }, + { file_path, old_string, new_string, replace_all, old_str_start_line_number, old_str_end_line_number }, { readFileTimestamps }, ) { if (old_string === new_string) { @@ -200,7 +204,17 @@ export const FileEditTool = { const enc = detectFileEncoding(fullFilePath) const file = readFileSync(fullFilePath, enc) + + // Try exact match first if (!file.includes(old_string)) { + // If line numbers are provided, we might try fuzzy matching later in call() + // For now, just indicate the string wasn't found + if (old_str_start_line_number !== undefined && old_str_end_line_number !== undefined) { + debug.trace('edit', `String not found verbatim, will try fuzzy matching with line hints: ${old_str_start_line_number}-${old_str_end_line_number}`) + // Allow validation to pass - we'll try enhanced matching in call() + return { result: true } + } + return { result: false, message: `String to replace not found in file.`, @@ -211,21 +225,29 @@ export const FileEditTool = { } const matches = file.split(old_string).length - 1 - if (matches > 1) { + if (matches > 1 && !replace_all) { + // If line numbers are provided, we can try to disambiguate + if (old_str_start_line_number !== undefined) { + debug.trace('edit', `Found ${matches} matches, will use line number hint to disambiguate`) + return { result: true } + } + return { result: false, - message: `Found ${matches} matches of the string to replace. For safety, this tool only supports replacing exactly one occurrence at a time. Add more lines of context to your edit and try again.`, + message: `Found ${matches} matches of the string to replace. For safety, this tool only supports replacing exactly one occurrence at a time. Either add more lines of context to your edit, provide line number hints (old_str_start_line_number/old_str_end_line_number), or set replace_all=true.`, meta: { isFilePathAbsolute: String(isAbsolute(file_path)), + matchCount: matches, }, } } return { result: true } }, - async *call({ file_path, old_string, new_string }, { readFileTimestamps }) { - const { patch, updatedFile } = applyEdit(file_path, old_string, new_string) - + async *call( + { file_path, old_string, new_string, replace_all, old_str_start_line_number, old_str_end_line_number }, + { readFileTimestamps } + ) { const fullFilePath = isAbsolute(file_path) ? file_path : resolve(getCwd(), file_path) @@ -240,6 +262,26 @@ export const FileEditTool = { const originalFile = existsSync(fullFilePath) ? readFileSync(fullFilePath, enc) : '' + + // Use enhanced editing with fuzzy matching and line number support + const { patch, updatedFile, usedFuzzyMatching, matchedLine } = applyEditWithEnhancements( + file_path, + old_string, + new_string, + { + replaceAll: replace_all || false, + startLineNumber: old_str_start_line_number, + endLineNumber: old_str_end_line_number, + enableFuzzyMatching: true, + lineNumberErrorTolerance: 0.2, + } + ) + + // Log if fuzzy matching was used + if (usedFuzzyMatching) { + debug.trace('edit', `Used fuzzy matching for edit at line ${matchedLine}`) + } + writeTextContent(fullFilePath, updatedFile, enc, endings) // Record Agent edit operation for file freshness tracking @@ -268,6 +310,8 @@ export const FileEditTool = { newString: new_string, originalFile, structuredPatch: patch, + usedFuzzyMatching, + matchedLine, } yield { type: 'result', diff --git a/src/tools/FileEditTool/prompt.ts b/src/tools/FileEditTool/prompt.ts index bb6fd090..27d477f4 100644 --- a/src/tools/FileEditTool/prompt.ts +++ b/src/tools/FileEditTool/prompt.ts @@ -1,51 +1,48 @@ import { NotebookEditTool } from '@tools/NotebookEditTool/NotebookEditTool' -export const DESCRIPTION = `This is a tool for editing files. For moving or renaming files, you should generally use the Bash tool with the 'mv' command instead. For larger edits, use the Write tool to overwrite files. For Jupyter notebooks (.ipynb files), use the ${NotebookEditTool.name} instead. - -Before using this tool: - -1. Use the View tool to understand the file's contents and context - -2. Verify the directory path is correct (only applicable when creating new files): - - Use the LS tool to verify the parent directory exists and is the correct location - -To make a file edit, provide the following: -1. file_path: The absolute path to the file to modify (must be absolute, not relative) -2. old_string: The text to replace (must be unique within the file, and must match the file contents exactly, including all whitespace and indentation) -3. new_string: The edited text to replace the old_string - -The tool will replace ONE occurrence of old_string with new_string in the specified file. - -CRITICAL REQUIREMENTS FOR USING THIS TOOL: - -1. UNIQUENESS: The old_string MUST uniquely identify the specific instance you want to change. This means: - - Include AT LEAST 3-5 lines of context BEFORE the change point - - Include AT LEAST 3-5 lines of context AFTER the change point - - Include all whitespace, indentation, and surrounding code exactly as it appears in the file - -2. SINGLE INSTANCE: This tool can only change ONE instance at a time. If you need to change multiple instances: - - Make separate calls to this tool for each instance - - Each call must uniquely identify its specific instance using extensive context - -3. VERIFICATION: Before using this tool: - - Check how many instances of the target text exist in the file - - If multiple instances exist, gather enough context to uniquely identify each one - - Plan separate tool calls for each instance - -WARNING: If you do not follow these requirements: - - The tool will fail if old_string matches multiple locations - - The tool will fail if old_string doesn't match exactly (including whitespace) - - You may change the wrong instance if you don't include enough context - -When making edits: - - Ensure the edit results in idiomatic, correct code - - Do not leave the code in a broken state - - Always use absolute file paths (starting with /) - -If you want to create a new file, use: - - A new file path, including dir name if needed - - An empty old_string - - The new file's contents as new_string - -Remember: when making multiple file edits in a row to the same file, you should prefer to send all edits in a single message with multiple calls to this tool, rather than multiple messages with a single call each. +export const DESCRIPTION = `Performs exact string replacements in files. + +Usage: +- You must use your \`Read\` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file. +- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: spaces + line number + tab. Everything after that tab is the actual file content to match. Never include any part of the line number prefix in the old_string or new_string. +- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required. +- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked. +- The edit will FAIL if \`old_string\` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use \`replace_all\` to change every instance of \`old_string\`. +- Use \`replace_all\` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance. + +For moving or renaming files, use the Bash tool with 'mv' command. For larger edits, use the Write tool. For Jupyter notebooks (.ipynb files), use ${NotebookEditTool.name}. + +Parameters: +1. file_path: Absolute path to the file (must start with /) +2. old_string: Text to replace (must be unique in file, match exactly including whitespace) +3. new_string: Replacement text +4. replace_all: (optional) If true, replace all occurrences of old_string +5. old_str_start_line_number: (optional) Start line number hint for disambiguating multiple matches +6. old_str_end_line_number: (optional) End line number hint for disambiguating multiple matches + +SMART MATCHING FEATURES: +- Fuzzy matching: If exact match fails, attempts intelligent fuzzy matching when line numbers are provided +- Line number tolerance: Tolerates minor line number drift from file modifications +- Tab indent auto-fix: Automatically handles tab vs space indentation mismatches + +REQUIREMENTS: +1. UNIQUENESS: old_string MUST uniquely identify the change location + - Include 3-5 lines of context before AND after the change point + - Preserve all whitespace and indentation exactly + +2. SINGLE INSTANCE: Changes one instance at a time (unless replace_all=true) + - For multiple changes, make separate tool calls + +3. VERIFICATION: Before editing + - Read the file first using Read tool + - Check how many instances of target text exist + - If multiple matches exist, provide line number hints or more context + +WARNINGS: +- Tool fails if old_string matches multiple locations (without line hints) +- Tool fails if old_string doesn't match exactly (fuzzy matching may help) +- Always ensure edit results in valid, idiomatic code + +NEW FILE CREATION: +- Use new file path, empty old_string, and file contents as new_string ` diff --git a/src/tools/FileEditTool/utils.ts b/src/tools/FileEditTool/utils.ts index 51d6f686..1bf9a179 100644 --- a/src/tools/FileEditTool/utils.ts +++ b/src/tools/FileEditTool/utils.ts @@ -1,9 +1,16 @@ import { isAbsolute, resolve } from 'path' import { getCwd } from '@utils/state' -import { readFileSync } from 'fs' +import { readFileSync, existsSync } from 'fs' import { detectFileEncoding } from '@utils/file' import { type Hunk } from 'diff' import { getPatch } from '@utils/diff' +import { + enhancedStrReplace, + findMatches, + findClosestMatch, + tryTabIndentFix, + removeTrailingWhitespace, +} from '@utils/agentTools' /** * Applies an edit to a file and returns the patch and updated file. @@ -56,3 +63,155 @@ export function applyEdit( return { patch, updatedFile } } + +/** + * Options for enhanced editing + */ +export interface EnhancedEditOptions { + /** Replace all occurrences (default: false) */ + replaceAll?: boolean + /** Start line number hint (1-based) */ + startLineNumber?: number + /** End line number hint (1-based) */ + endLineNumber?: number + /** Enable fuzzy matching (default: true) */ + enableFuzzyMatching?: boolean + /** Line number error tolerance 0-1 (default: 0.2) */ + lineNumberErrorTolerance?: number +} + +/** + * Enhanced edit that supports fuzzy matching, line number hints, and replace all. + */ +export function applyEditWithEnhancements( + file_path: string, + old_string: string, + new_string: string, + options: EnhancedEditOptions = {}, +): { + patch: Hunk[] + updatedFile: string + usedFuzzyMatching: boolean + matchedLine?: number +} { + const { + replaceAll = false, + startLineNumber, + endLineNumber, + enableFuzzyMatching = true, + lineNumberErrorTolerance = 0.2, + } = options + + const fullFilePath = isAbsolute(file_path) + ? file_path + : resolve(getCwd(), file_path) + + // Handle new file creation + if (old_string === '') { + const patch = getPatch({ + filePath: file_path, + fileContents: '', + oldStr: '', + newStr: new_string, + }) + return { patch, updatedFile: new_string, usedFuzzyMatching: false } + } + + // Read existing file + if (!existsSync(fullFilePath)) { + throw new Error(`File does not exist: ${fullFilePath}`) + } + + const enc = detectFileEncoding(fullFilePath) + const originalFile = readFileSync(fullFilePath, enc) + const normalizedContent = removeTrailingWhitespace(originalFile) + + let updatedFile: string + let usedFuzzyMatching = false + let matchedLine: number | undefined + + // Handle replace all + if (replaceAll) { + updatedFile = originalFile.split(old_string).join(new_string) + if (updatedFile === originalFile) { + throw new Error('String to replace not found in file.') + } + } else { + // Try enhanced replacement with fuzzy matching + const result = enhancedStrReplace( + originalFile, + old_string, + new_string, + { + enableFuzzyMatching, + lineNumberErrorTolerance, + }, + startLineNumber !== undefined ? startLineNumber - 1 : undefined, // Convert to 0-based + endLineNumber !== undefined ? endLineNumber - 1 : undefined, + ) + + if (result.success && result.newContent) { + updatedFile = result.newContent + usedFuzzyMatching = result.usedFuzzyMatching || false + matchedLine = result.matchStartLine !== undefined ? result.matchStartLine + 1 : undefined + } else { + // Fall back to standard replacement + const matches = findMatches(normalizedContent, old_string) + + if (matches.length === 0) { + // Try tab indent fix + const tabFixResult = tryTabIndentFix(normalizedContent, old_string, new_string) + if (tabFixResult.matches.length > 0) { + updatedFile = originalFile.replace(tabFixResult.oldStr, tabFixResult.newStr) + matchedLine = tabFixResult.matches[0].startLine + 1 + } else { + throw new Error(result.error || 'String to replace not found in file.') + } + } else if (matches.length === 1) { + // Single match - simple replacement + updatedFile = originalFile.replace(old_string, new_string) + matchedLine = matches[0].startLine + 1 + } else { + // Multiple matches - use line number hint + if (startLineNumber !== undefined) { + const matchIndex = findClosestMatch( + matches, + startLineNumber - 1, + (endLineNumber ?? startLineNumber) - 1, + lineNumberErrorTolerance, + ) + + if (matchIndex >= 0) { + // Replace only the specific occurrence + const parts = originalFile.split(old_string) + updatedFile = parts.slice(0, matchIndex + 1).join(old_string) + + new_string + + parts.slice(matchIndex + 1).join(old_string) + matchedLine = matches[matchIndex].startLine + 1 + } else { + throw new Error( + `No match found near line ${startLineNumber}. Found ${matches.length} matches but none within tolerance.` + ) + } + } else { + throw new Error( + `Found ${matches.length} matches. Provide line number hints or more context to disambiguate.` + ) + } + } + } + } + + if (updatedFile === originalFile) { + throw new Error('Original and edited file match exactly. Failed to apply edit.') + } + + const patch = getPatch({ + filePath: file_path, + fileContents: originalFile, + oldStr: originalFile, + newStr: updatedFile, + }) + + return { patch, updatedFile, usedFuzzyMatching, matchedLine } +} diff --git a/src/tools/GrepTool/GrepTool.tsx b/src/tools/GrepTool/GrepTool.tsx index 9788b9ec..029ad6e0 100644 --- a/src/tools/GrepTool/GrepTool.tsx +++ b/src/tools/GrepTool/GrepTool.tsx @@ -11,7 +11,7 @@ import { getAbsoluteAndRelativePaths, } from '@utils/file' import { ripGrep } from '@utils/ripgrep' -import { DESCRIPTION, TOOL_NAME_FOR_PROMPT } from './prompt' +import { DESCRIPTION, TOOL_NAME_FOR_PROMPT, REGEX_SYNTAX_GUIDE } from './prompt' import { hasReadPermission } from '@utils/permissions/filesystem' const inputSchema = z.strictObject({ @@ -22,23 +22,75 @@ const inputSchema = z.strictObject({ .string() .optional() .describe( - 'The directory to search in. Defaults to the current working directory.', + 'File or directory to search in (rg PATH). Defaults to current working directory.', ), - include: z + glob: z .string() .optional() .describe( - 'File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")', + 'Glob pattern to filter files (e.g. "*.js", "*.{ts,tsx}") - maps to rg --glob', ), + type: z + .string() + .optional() + .describe( + 'File type to search (rg --type). Common types: js, py, rust, go, java, etc. More efficient than include for standard file types.', + ), + output_mode: z + .enum(['content', 'files_with_matches', 'count']) + .optional() + .describe( + 'Output mode: "content" shows matching lines (supports -A/-B/-C context, -n line numbers, head_limit), "files_with_matches" shows file paths (supports head_limit), "count" shows match counts (supports head_limit). Defaults to "files_with_matches".', + ), + '-A': z + .number() + .optional() + .describe('Number of lines to show after each match (rg -A). Requires output_mode: "content", ignored otherwise.'), + '-B': z + .number() + .optional() + .describe('Number of lines to show before each match (rg -B). Requires output_mode: "content", ignored otherwise.'), + '-C': z + .number() + .optional() + .describe('Number of lines to show before and after each match (rg -C). Requires output_mode: "content", ignored otherwise.'), + '-i': z + .boolean() + .optional() + .describe('Case insensitive search (rg -i)'), + '-n': z + .boolean() + .optional() + .describe('Show line numbers in output (rg -n). Requires output_mode: "content", ignored otherwise. Defaults to true.'), + multiline: z + .boolean() + .optional() + .describe('Enable multiline mode where . matches newlines and patterns can span lines (rg -U --multiline-dotall). Default: false.'), + head_limit: z + .number() + .optional() + .describe('Limit output to first N lines/entries, equivalent to "| head -N". Works across all output modes: content (limits output lines), files_with_matches (limits file paths), count (limits count entries). Defaults based on "cap" experiment value: 0 (unlimited), 20, or 100.'), + offset: z + .number() + .optional() + .describe('Skip first N lines/entries before applying head_limit, equivalent to "| tail -n +N | head -N". Works across all output modes. Defaults to 0.'), +}) + +// Legacy alias for backward compatibility +const inputSchemaLegacy = inputSchema.extend({ + include: z.string().optional().describe('Deprecated: Use "glob" instead'), }) const MAX_RESULTS = 100 +const DEFAULT_HEAD_LIMIT = 100 type Input = typeof inputSchema type Output = { durationMs: number numFiles: number filenames: string[] + content?: string + matchCount?: number } export const GrepTool = { @@ -63,11 +115,35 @@ export const GrepTool = { return !hasReadPermission(path || getCwd()) }, async prompt() { - return DESCRIPTION + return DESCRIPTION + '\n\n' + REGEX_SYNTAX_GUIDE }, - renderToolUseMessage({ pattern, path, include }, { verbose }) { + renderToolUseMessage(input: any, { verbose }) { + const { pattern, path, glob, type, output_mode } = input + // Support legacy 'include' parameter + const effectiveGlob = glob || input.include const { absolutePath, relativePath } = getAbsoluteAndRelativePaths(path) - return `pattern: "${pattern}"${relativePath || verbose ? `, path: "${verbose ? absolutePath : relativePath}"` : ''}${include ? `, include: "${include}"` : ''}` + + const parts: string[] = [`pattern: "${pattern}"`] + if (relativePath || verbose) { + parts.push(`path: "${verbose ? absolutePath : relativePath}"`) + } + if (effectiveGlob) { + parts.push(`glob: "${effectiveGlob}"`) + } + if (type) { + parts.push(`type: "${type}"`) + } + if (output_mode && output_mode !== 'files_with_matches') { + parts.push(`output_mode: "${output_mode}"`) + } + // Show context options if present + if (input['-C']) parts.push(`-C: ${input['-C']}`) + else { + if (input['-A']) parts.push(`-A: ${input['-A']}`) + if (input['-B']) parts.push(`-B: ${input['-B']}`) + } + + return parts.join(', ') }, renderToolUseRejectedMessage() { return @@ -75,24 +151,39 @@ export const GrepTool = { renderToolResultMessage(output) { // Handle string content for backward compatibility if (typeof output === 'string') { - // Convert string to Output type using tmpDeserializeOldLogResult if needed output = output as unknown as Output } + const hasContent = output.content && output.content.length > 0 + return (   ⎿  Found - {output.numFiles} + {output.matchCount ?? output.numFiles} - {output.numFiles === 0 || output.numFiles > 1 ? 'files' : 'file'} + {hasContent + ? `match${(output.matchCount ?? 0) !== 1 ? 'es' : ''} in ${output.numFiles} file${output.numFiles !== 1 ? 's' : ''}` + : `${output.numFiles === 0 || output.numFiles > 1 ? 'files' : 'file'}` + } ) }, - renderResultForAssistant({ numFiles, filenames }) { + renderResultForAssistant(output: Output) { + const { numFiles, filenames, content, matchCount } = output + + // Content mode - return the formatted content + if (content) { + if (matchCount === 0) { + return 'No matches found' + } + return content + } + + // Files mode (default) if (numFiles === 0) { return 'No files found' } @@ -103,45 +194,164 @@ export const GrepTool = { } return result }, - async *call({ pattern, path, include }, { abortController }) { + async *call(input: any, { abortController }) { const start = Date.now() + const { + pattern, + path, + output_mode = 'files_with_matches', + head_limit = DEFAULT_HEAD_LIMIT, + offset = 0, + multiline = false, + } = input + + // Support legacy 'include' parameter + const glob = input.glob || input.include + const type = input.type + const contextBefore = input['-B'] || input['-C'] || 0 + const contextAfter = input['-A'] || input['-C'] || 0 + const caseInsensitive = input['-i'] ?? false + const showLineNumbers = input['-n'] ?? true + const absolutePath = getAbsolutePath(path) || getCwd() - const args = ['-li', pattern] - if (include) { - args.push('--glob', include) + // Build ripgrep arguments + const args: string[] = [] + + // Output mode + if (output_mode === 'files_with_matches') { + args.push('-l') + } else if (output_mode === 'count') { + args.push('-c') + } + + // Case insensitivity + if (caseInsensitive) { + args.push('-i') + } + + // Multiline mode + if (multiline) { + args.push('-U', '--multiline-dotall') + } + + // Line numbers for content mode + if (output_mode === 'content' && showLineNumbers) { + args.push('-n') + } + + // Context lines + if (output_mode === 'content') { + if (contextBefore > 0) { + args.push('-B', String(contextBefore)) + } + if (contextAfter > 0) { + args.push('-A', String(contextAfter)) + } + } + + // File filtering + if (glob) { + args.push('--glob', glob) } + if (type) { + args.push('--type', type) + } + + // The pattern + args.push(pattern) + + try { + const results = await ripGrep(args, absolutePath, abortController.signal) + + // Process results based on output mode + let filenames: string[] = [] + let content: string | undefined + let matchCount = 0 - const results = await ripGrep(args, absolutePath, abortController.signal) + if (output_mode === 'content') { + // For content mode, results is the raw output with context + const lines = results as unknown as string[] + content = lines.join('\n') + matchCount = lines.filter(l => !l.startsWith('--')).length - const stats = await Promise.all(results.map(_ => stat(_))) - const matches = results - // Sort by modification time - .map((_, i) => [_, stats[i]!] as const) - .sort((a, b) => { - if (process.env.NODE_ENV === 'test') { - // In tests, we always want to sort by filename, so that results are deterministic - return a[0].localeCompare(b[0]) + // Apply offset and limit + if (offset > 0 || head_limit > 0) { + const contentLines = content.split('\n') + const sliced = contentLines.slice(offset, offset + (head_limit || contentLines.length)) + content = sliced.join('\n') + if (sliced.length < contentLines.length - offset) { + content += '\n(Results are truncated. Consider using a more specific path or pattern.)' + } } - const timeComparison = (b[1].mtimeMs ?? 0) - (a[1].mtimeMs ?? 0) - if (timeComparison === 0) { - // Sort by filename as a tiebreaker - return a[0].localeCompare(b[0]) + + // Get unique files from matches + const fileSet = new Set() + for (const line of (results as string[])) { + const match = line.match(/^([^:]+):/) + if (match) fileSet.add(match[1]) } - return timeComparison - }) - .map(_ => _[0]) + filenames = Array.from(fileSet) + } else { + // Files mode - sort by modification time + filenames = results as string[] - const output = { - filenames: matches, - durationMs: Date.now() - start, - numFiles: matches.length, - } + if (filenames.length > 0) { + const stats = await Promise.all(filenames.map(f => stat(f).catch(() => null))) + const filesWithStats = filenames + .map((f, i) => [f, stats[i]] as const) + .filter(([, s]) => s !== null) + .sort((a, b) => { + if (process.env.NODE_ENV === 'test') { + return a[0].localeCompare(b[0]) + } + const timeComparison = (b[1]?.mtimeMs ?? 0) - (a[1]?.mtimeMs ?? 0) + return timeComparison === 0 ? a[0].localeCompare(b[0]) : timeComparison + }) + .map(([f]) => f) + + filenames = filesWithStats + } + + // Apply offset and limit + if (offset > 0) { + filenames = filenames.slice(offset) + } + if (head_limit > 0 && filenames.length > head_limit) { + filenames = filenames.slice(0, head_limit) + } + + matchCount = filenames.length + } + + const output: Output = { + filenames, + durationMs: Date.now() - start, + numFiles: filenames.length, + content, + matchCount, + } + + yield { + type: 'result', + resultForAssistant: this.renderResultForAssistant(output), + data: output, + } + } catch (error) { + // Handle errors gracefully + const output: Output = { + filenames: [], + durationMs: Date.now() - start, + numFiles: 0, + content: `Error: ${error instanceof Error ? error.message : String(error)}`, + matchCount: 0, + } - yield { - type: 'result', - resultForAssistant: this.renderResultForAssistant(output), - data: output, + yield { + type: 'result', + resultForAssistant: output.content || 'Search failed', + data: output, + } } }, } satisfies Tool diff --git a/src/tools/GrepTool/prompt.ts b/src/tools/GrepTool/prompt.ts index d427ddc3..7ee2795f 100644 --- a/src/tools/GrepTool/prompt.ts +++ b/src/tools/GrepTool/prompt.ts @@ -1,11 +1,32 @@ -export const TOOL_NAME_FOR_PROMPT = 'GrepTool' +export const TOOL_NAME_FOR_PROMPT = 'Grep' -export const DESCRIPTION = ` -- Fast content search tool that works with any codebase size -- Searches file contents using regular expressions -- Supports full regex syntax (eg. "log.*Error", "function\\s+\\w+", etc.) -- Filter files by pattern with the include parameter (eg. "*.js", "*.{ts,tsx}") -- Returns matching file paths sorted by modification time -- Use this tool when you need to find files containing specific patterns -- When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Agent tool instead +export const DESCRIPTION = `A powerful search tool built on ripgrep + + Usage: + - ALWAYS use Grep for search tasks. NEVER invoke \`grep\` or \`rg\` as a Bash command. The Grep tool has been optimized for correct permissions and access. + - Supports full regex syntax (e.g., "log.*Error", "function\\s+\\w+") + - Filter files with glob parameter (e.g., "*.js", "**/*.tsx") or type parameter (e.g., "js", "py", "rust") + - Output modes: "content" shows matching lines, "files_with_matches" shows only file paths (default), "count" shows match counts + - Use Task tool for open-ended searches requiring multiple rounds + - Pattern syntax: Uses ripgrep (not grep) - literal braces need escaping (use \`interface\\{\\}\` to find \`interface{}\` in Go code) + - Multiline matching: By default patterns match within single lines only. For cross-line patterns like \`struct \\{[\\s\\S]*?field\`, use \`multiline: true\` +` + +export const REGEX_SYNTAX_GUIDE = ` +Common regex syntax (ripgrep compatible): +- . matches any character +- \\d matches digits [0-9] +- \\w matches word characters [a-zA-Z0-9_] +- \\s matches whitespace +- * zero or more of previous +- + one or more of previous +- ? zero or one of previous +- ^ start of line +- $ end of line +- [abc] character class +- [^abc] negated character class +- (a|b) alternation +- (?:...) non-capturing group +- \\b word boundary +- Escape special chars with \\: \\. \\* \\+ etc. ` diff --git a/src/tools/MemoryReadTool/MemoryReadTool.tsx b/src/tools/MemoryReadTool/MemoryReadTool.tsx index 500f3edd..7d7036c3 100644 --- a/src/tools/MemoryReadTool/MemoryReadTool.tsx +++ b/src/tools/MemoryReadTool/MemoryReadTool.tsx @@ -8,14 +8,38 @@ import { Tool } from '@tool' import { MEMORY_DIR } from '@utils/env' import { resolveAgentId } from '@utils/agentStorage' import { DESCRIPTION, PROMPT } from './prompt' +import { + getMemorySnapshotManager, + getPendingMemoriesStore, +} from '@utils/agentTools' const inputSchema = z.strictObject({ file_path: z .string() .optional() .describe('Optional path to a specific memory file to read'), + use_snapshot: z + .boolean() + .optional() + .describe('If true, use the cached memory snapshot (faster, may be slightly stale)'), + include_pending: z + .boolean() + .optional() + .describe('If true, include pending memories that have not been flushed yet'), }) +// Memory content loader function for snapshot manager +async function loadMemoriesContent(agentId: string): Promise { + const agentMemoryDir = join(MEMORY_DIR, 'agents', agentId) + const indexPath = join(agentMemoryDir, 'index.md') + + if (!existsSync(indexPath)) { + return undefined + } + + return readFileSync(indexPath, 'utf-8') +} + export const MemoryReadTool = { name: 'MemoryRead', async description() { @@ -53,11 +77,15 @@ export const MemoryReadTool = { return }, renderToolResultMessage(output) { + const preview = output.content.length > 100 + ? output.content.substring(0, 100) + '...' + : output.content + return (   ⎿   - {output.content} + {preview} ) @@ -77,7 +105,7 @@ export const MemoryReadTool = { } return { result: true } }, - async *call({ file_path }, context) { + async *call({ file_path, use_snapshot, include_pending }, context) { const agentId = resolveAgentId(context?.agentId) const agentMemoryDir = join(MEMORY_DIR, 'agents', agentId) mkdirSync(agentMemoryDir, { recursive: true }) @@ -99,6 +127,36 @@ export const MemoryReadTool = { return } + // Use snapshot if requested (faster, may be slightly stale) + if (use_snapshot) { + const snapshotManager = getMemorySnapshotManager( + () => loadMemoriesContent(agentId) + ) + // Use agentId as conversationId since ToolUseContext doesn't have conversationId + const snapshot = await snapshotManager.getMemorySnapshot(agentId) + + if (snapshot) { + let content = snapshot + + // Include pending memories if requested + if (include_pending) { + const pendingStore = getPendingMemoriesStore(agentId) + const pendingMemories = pendingStore.listPending() + if (pendingMemories.length > 0) { + content += '\n\n--- Pending Memories ---\n' + content += pendingMemories.map(m => m.content).join('\n\n') + } + } + + yield { + type: 'result', + data: { content }, + resultForAssistant: this.renderResultForAssistant({ content }), + } + return + } + } + // Otherwise return the index and file list for this agent const files = readdirSync(agentMemoryDir, { recursive: true }) .map(f => join(agentMemoryDir, f.toString())) @@ -110,13 +168,24 @@ export const MemoryReadTool = { const index = existsSync(indexPath) ? readFileSync(indexPath, 'utf-8') : '' const quotes = "'''" - const content = `Here are the contents of the agent memory file, \`${indexPath}\`: + let content = `Here are the contents of the agent memory file, \`${indexPath}\`: ${quotes} ${index} ${quotes} Files in the agent memory directory: ${files}` + + // Include pending memories if requested + if (include_pending) { + const pendingStore = getPendingMemoriesStore(agentId) + const pendingMemories = pendingStore.listPending() + if (pendingMemories.length > 0) { + content += '\n\n--- Pending Memories ---\n' + content += pendingMemories.map(m => `[${new Date(m.timestamp).toISOString()}] ${m.content}`).join('\n\n') + } + } + yield { type: 'result', data: { content }, diff --git a/src/tools/MemoryWriteTool/MemoryWriteTool.tsx b/src/tools/MemoryWriteTool/MemoryWriteTool.tsx index 769e3c65..54674da1 100644 --- a/src/tools/MemoryWriteTool/MemoryWriteTool.tsx +++ b/src/tools/MemoryWriteTool/MemoryWriteTool.tsx @@ -3,16 +3,25 @@ import { Box, Text } from 'ink' import { dirname, join } from 'path' import * as React from 'react' import { z } from 'zod' +import { randomUUID } from 'crypto' import { FallbackToolUseRejectedMessage } from '@components/FallbackToolUseRejectedMessage' import { Tool } from '@tool' import { MEMORY_DIR } from '@utils/env' import { resolveAgentId } from '@utils/agentStorage' import { recordFileEdit } from '@services/fileFreshness' import { DESCRIPTION, PROMPT } from './prompt' +import { + getPendingMemoriesStore, + getMemoryUpdateManager, +} from '@utils/agentTools' const inputSchema = z.strictObject({ file_path: z.string().describe('Path to the memory file to write'), content: z.string().describe('Content to write to the file'), + use_pending: z + .boolean() + .optional() + .describe('If true, store as pending memory to be flushed later (default: false, writes immediately)'), }) export const MemoryWriteTool = { @@ -51,11 +60,12 @@ export const MemoryWriteTool = { renderToolUseRejectedMessage() { return }, - renderToolResultMessage() { + renderToolResultMessage(output) { + const message = typeof output === 'string' ? output : 'Updated memory' return ( - {' '}⎿ Updated memory + {' '}⎿ {message} ) @@ -69,16 +79,44 @@ export const MemoryWriteTool = { } return { result: true } }, - async *call({ file_path, content }, context) { + async *call({ file_path, content, use_pending }, context) { const agentId = resolveAgentId(context?.agentId) const agentMemoryDir = join(MEMORY_DIR, 'agents', agentId) const fullPath = join(agentMemoryDir, file_path) + + // If using pending storage, add to pending store instead of writing immediately + if (use_pending) { + const pendingStore = getPendingMemoriesStore(agentId) + const memoryEntry = { + id: randomUUID(), + content, + version: 1, + } + await pendingStore.append(memoryEntry) + + // Notify listeners that memory has been updated + const updateManager = getMemoryUpdateManager() + updateManager.notifyMemoryHasUpdates() + + yield { + type: 'result', + data: `Added to pending memories (${pendingStore.pendingCount} pending)`, + resultForAssistant: `Memory added to pending queue. ${pendingStore.pendingCount} memories awaiting flush.`, + } + return + } + + // Write immediately mkdirSync(dirname(fullPath), { recursive: true }) writeFileSync(fullPath, content, 'utf-8') // Record Agent edit operation for file freshness tracking recordFileEdit(fullPath, content) + // Notify listeners that memory has been updated + const updateManager = getMemoryUpdateManager() + updateManager.notifyMemoryHasUpdates() + yield { type: 'result', data: 'Saved', diff --git a/src/tools/TaskTool/TaskTool.tsx b/src/tools/TaskTool/TaskTool.tsx index 751e70a0..6f4800ff 100644 --- a/src/tools/TaskTool/TaskTool.tsx +++ b/src/tools/TaskTool/TaskTool.tsx @@ -5,6 +5,7 @@ import { EOL } from 'os' import React, { useState, useEffect } from 'react' import { Box, Text } from 'ink' import { z } from 'zod' +import { execSync } from 'child_process' import { Tool, ValidationResult } from '@tool' import { FallbackToolUseRejectedMessage } from '@components/FallbackToolUseRejectedMessage' import { getAgentPrompt } from '@constants/prompts' @@ -30,15 +31,80 @@ import { getMaxThinkingTokens } from '@utils/thinking' import { getTheme } from '@utils/theme' import { generateAgentId } from '@utils/agentStorage' import { debug as debugLogger } from '@utils/debugLogger' +import { getCwd } from '@utils/state' import { getTaskTools, getPrompt } from './prompt' import { TOOL_NAME } from './constants' import { getActiveAgents, getAgentByType, getAvailableAgentTypes } from '@utils/agentLoader' +import { + getSubAgentStateManager, + SubAgentResult, + SubAgentAnalyticsEvent, + trackSubAgentEvent, + isValidSubAgentColor, + ValidSubAgentColor, +} from '@utils/subAgentStateManager' + +/** + * Get git diff for tracking changes made by sub-agent + * Returns empty string if not in a git repo or no changes + */ +function getGitDiff(): string { + try { + const cwd = getCwd() + // Get diff of both staged and unstaged changes + const diff = execSync('git diff HEAD 2>/dev/null || git diff 2>/dev/null || echo ""', { + cwd, + encoding: 'utf-8', + maxBuffer: 1024 * 1024, // 1MB max + timeout: 5000, + }).trim() + return diff + } catch (error) { + // Not a git repo or git not available + return '' + } +} + +/** + * Get git status hash for detecting changes + */ +function getGitStatusHash(): string { + try { + const cwd = getCwd() + // Get a quick hash of current git status + const status = execSync('git status --porcelain 2>/dev/null || echo ""', { + cwd, + encoding: 'utf-8', + timeout: 5000, + }).trim() + return status + } catch (error) { + return '' + } +} const inputSchema = z.object({ + action: z + .enum(['run', 'output']) + .optional() + .default('run') + .describe(`Action to perform: +'run' - execute a sub-agent with the given instruction (default) +'output' - show the response and file changes made by a completed sub-agent`), description: z .string() - .describe('A short (3-5 word) description of the task'), - prompt: z.string().describe('The task for the agent to perform'), + .optional() + .describe('A short (3-5 word) description of the task (required for run action)'), + prompt: z + .string() + .optional() + .describe('The task for the agent to perform (required for run action)'), + name: z + .string() + .optional() + .describe(`Name of the sub-agent. Names must be unique and contain no spaces. +For 'run': provide a name for the new agent (optional, auto-generated if not provided). +For 'output': provide the name of a completed agent to review (required).`), model_name: z .string() .optional() @@ -53,6 +119,8 @@ const inputSchema = z.object({ ), }) +type TaskInput = z.infer + export const TaskTool = { async prompt({ safeMode }) { // Ensure agent prompts remain compatible with Claude Code `.claude` agent packs @@ -61,12 +129,18 @@ export const TaskTool = { name: TOOL_NAME, async description() { // Ensure metadata stays compatible with Claude Code `.claude` agent packs - return "Launch a new task" + return `Launch a new agent to handle complex, multi-step tasks autonomously. + +**IMPORTANT: This tool can be run in parallel.** Multiple sub-agents can execute simultaneously with different names and instructions. Use parallel execution when you have multiple independent tasks that can be completed concurrently. + +Available actions: +- **run** - Execute a sub-agent with the given instruction (waits for completion) +- **output** - Show the response and file changes (if any) made by a completed sub-agent` }, inputSchema, - + async *call( - { description, prompt, model_name, subagent_type }, + { action = 'run', description, prompt, name, model_name, subagent_type }: TaskInput, { abortController, options: { safeMode = false, forkNumber, messageLogName, verbose }, @@ -78,26 +152,126 @@ export const TaskTool = { void, unknown > { + const stateManager = getSubAgentStateManager() + + // Handle 'output' action - retrieve stored result + if (action === 'output') { + if (!name) { + const errorMessage = "No agent name provided. See the agent name from the run result." + yield { + type: 'result', + data: [{ type: 'text', text: errorMessage }] as TextBlock[], + resultForAssistant: errorMessage, + } + return + } + + const subAgentId = stateManager.findSubAgentIdByName(name) + if (!subAgentId) { + // List available agents + const allResults = stateManager.getAllSubAgentStoredResults() + const availableNames = Object.values(allResults).map(r => r.name).filter(Boolean) + const errorMessage = availableNames.length > 0 + ? `Agent "${name}" not found. Available agents: ${availableNames.join(', ')}` + : `Agent "${name}" not found. No completed agents available.` + yield { + type: 'result', + data: [{ type: 'text', text: errorMessage }] as TextBlock[], + resultForAssistant: errorMessage, + } + return + } + + const storedResult = stateManager.getSubAgentStoredResult(subAgentId) + if (!storedResult) { + const errorMessage = `No stored result found for agent "${name}". Agent may not have completed yet.` + yield { + type: 'result', + data: [{ type: 'text', text: errorMessage }] as TextBlock[], + resultForAssistant: errorMessage, + } + return + } + + // Build output with response and diff if available + let output = storedResult.result + if (storedResult.diff) { + output += `\n\n## Changes Made\n${storedResult.diff}` + } + + // Add metadata + output += `\n\n---\nAgent: ${storedResult.name} (${storedResult.agentType})` + output += `\nStatus: ${storedResult.status}` + output += `\nTool calls: ${storedResult.toolCallCount}` + output += `\nDuration: ${formatDuration(storedResult.durationMs)}` + if (storedResult.model) { + output += `\nModel: ${storedResult.model}` + } + + yield { + type: 'result', + data: [{ type: 'text', text: output }] as TextBlock[], + resultForAssistant: output, + } + return + } + + // Handle 'run' action - execute sub-agent + if (!description || !prompt) { + const errorMessage = "Description and prompt are required for 'run' action." + yield { + type: 'result', + data: [{ type: 'text', text: errorMessage }] as TextBlock[], + resultForAssistant: errorMessage, + } + return + } + const startTime = Date.now() - + + // Generate or validate agent name + const agentName = name || `task-${Date.now()}` + if (agentName.includes(' ')) { + const errorMessage = `Agent name "${agentName}" cannot contain spaces.` + yield { + type: 'result', + data: [{ type: 'text', text: errorMessage }] as TextBlock[], + resultForAssistant: errorMessage, + } + return + } + + // Check if name is already in use + const existingId = stateManager.findSubAgentIdByName(agentName) + if (existingId) { + const errorMessage = `Agent name "${agentName}" is already in use. Please choose a different name.` + yield { + type: 'result', + data: [{ type: 'text', text: errorMessage }] as TextBlock[], + resultForAssistant: errorMessage, + } + return + } + // Default to general-purpose if no subagent_type specified const agentType = subagent_type || 'general-purpose' - + // Apply subagent configuration let effectivePrompt = prompt let effectiveModel = model_name || 'task' let toolFilter = null let temperature = undefined - + let agentColor: ValidSubAgentColor | undefined = undefined + // Load agent configuration dynamically if (agentType) { const agentConfig = await getAgentByType(agentType) - + if (!agentConfig) { // If agent type not found, return helpful message instead of throwing const availableTypes = await getAvailableAgentTypes() - const helpMessage = `Agent type '${agentType}' not found.\n\nAvailable agents:\n${availableTypes.map(t => ` • ${t}`).join('\n')}\n\nUse /agents command to manage agent configurations.` - + const helpMessage = `Agent type '${agentType}' not found.\n\nAvailable agents:\n${availableTypes.map(t => ` - ${t}`).join('\n')}\n\nUse /agents command to manage agent configurations.` + yield { type: 'result', data: [{ type: 'text', text: helpMessage }] as TextBlock[], @@ -105,12 +279,12 @@ export const TaskTool = { } return } - + // Apply system prompt if configured if (agentConfig.systemPrompt) { effectivePrompt = `${agentConfig.systemPrompt}\n\n${prompt}` } - + // Apply model if not overridden by model_name parameter if (!model_name && agentConfig.model_name) { // Support inherit: keep pointer-based default @@ -118,17 +292,27 @@ export const TaskTool = { effectiveModel = agentConfig.model_name as string } } - + // Store tool filter for later application toolFilter = agentConfig.tools - - // Note: temperature is not currently in our agent configs - // but could be added in the future + + // Apply color if configured + if (agentConfig.color && isValidSubAgentColor(agentConfig.color)) { + agentColor = agentConfig.color + } + + // Apply temperature if configured in agent config + if (agentConfig.temperature !== undefined) { + temperature = agentConfig.temperature + } } - + + // Capture git status before agent execution for diff tracking + const gitStatusBefore = getGitStatusHash() + const messages: MessageType[] = [createUserMessage(effectivePrompt)] let tools = await getTaskTools(safeMode) - + // Apply tool filtering if specified by subagent config if (toolFilter) { // Back-compat: ['*'] means all tools @@ -140,31 +324,49 @@ export const TaskTool = { } } - // Model already resolved in effectiveModel variable above - const modelToUse = effectiveModel + // Resolve model - supports both direct model names and pointers (main, task, etc.) + const modelManager = getModelManager() + const resolvedModel = modelManager.resolveModel(effectiveModel) + const modelToUse = resolvedModel?.modelName || effectiveModel + + // Generate unique Task ID for this task execution + const taskId = generateAgentId() + + // Track sub-agent start + trackSubAgentEvent( + SubAgentAnalyticsEvent.STARTED, + taskId, + agentName, + agentType, + { + model: modelToUse, + color: agentColor, + } + ) // Display initial task information with separate progress lines + const colorPrefix = agentColor ? `[${agentColor}] ` : '' yield { type: 'progress', - content: createAssistantMessage(`Starting agent: ${agentType}`), + content: createAssistantMessage(`${colorPrefix}Starting agent: ${agentName} (${agentType})`), normalizedMessages: normalizeMessages(messages), tools, } - + yield { - type: 'progress', + type: 'progress', content: createAssistantMessage(`Using model: ${modelToUse}`), normalizedMessages: normalizeMessages(messages), tools, } - + yield { type: 'progress', content: createAssistantMessage(`Task: ${description}`), normalizedMessages: normalizeMessages(messages), tools, } - + yield { type: 'progress', content: createAssistantMessage(`Prompt: ${prompt.length > 150 ? prompt.substring(0, 150) + '...' : prompt}`), @@ -177,20 +379,18 @@ export const TaskTool = { getContext(), getMaxThinkingTokens(messages), ]) - + // Inject model context to prevent self-referential expert consultations taskPrompt.push(`\nIMPORTANT: You are currently running as ${modelToUse}. You do not need to consult ${modelToUse} via AskExpertModel since you ARE ${modelToUse}. Complete tasks directly using your capabilities.`) - let toolUseCount = 0 + let toolCallCount = 0 + let errorCount = 0 + let collectedDiff = '' const getSidechainNumber = memoize(() => getNextAvailableLogSidechainNumber(messageLogName, forkNumber), ) - // Generate unique Task ID for this task execution - const taskId = generateAgentId() - - // 🔧 ULTRA SIMPLIFIED: Exact original AgentTool pattern // Build query options, adding temperature if specified const queryOptions = { safeMode, @@ -202,130 +402,231 @@ export const TaskTool = { maxThinkingTokens, model: modelToUse, } - + // Add temperature if specified by subagent config if (temperature !== undefined) { queryOptions['temperature'] = temperature } - - for await (const message of query( - messages, - taskPrompt, - context, - hasPermissionsToUseTool, - { - abortController, - options: queryOptions, - messageId: getLastAssistantMessageId(messages), - agentId: taskId, - readFileTimestamps, - setToolJSX: () => {}, // No-op implementation for TaskTool - }, - )) { - messages.push(message) - - overwriteLog( - getMessagesPath(messageLogName, forkNumber, getSidechainNumber()), - messages.filter(_ => _.type !== 'progress'), - ) - if (message.type !== 'assistant') { - continue - } + let wasInterrupted = false + let finalError: Error | null = null - const normalizedMessages = normalizeMessages(messages) - - // Process tool uses and text content for better visibility - for (const content of message.message.content) { - if (content.type === 'text' && content.text && content.text !== INTERRUPT_MESSAGE) { - // Show agent's reasoning/responses - const preview = content.text.length > 200 ? content.text.substring(0, 200) + '...' : content.text - yield { - type: 'progress', - content: createAssistantMessage(`${preview}`), - normalizedMessages, - tools, - } - } else if (content.type === 'tool_use') { - toolUseCount++ - - // Show which tool is being used with agent context - const toolMessage = normalizedMessages.find( - _ => - _.type === 'assistant' && - _.message.content[0]?.type === 'tool_use' && - _.message.content[0].id === content.id, - ) as AssistantMessage - - if (toolMessage) { - // Clone and modify the message to show agent context - const modifiedMessage = { - ...toolMessage, - message: { - ...toolMessage.message, - content: toolMessage.message.content.map(c => { - if (c.type === 'tool_use' && c.id === content.id) { - // Add agent context to tool name display - return { - ...c, - name: c.name // Keep original name, UI will handle display - } - } - return c - }) - } - } - + try { + for await (const message of query( + messages, + taskPrompt, + context, + hasPermissionsToUseTool, + { + abortController, + options: queryOptions, + messageId: getLastAssistantMessageId(messages), + agentId: taskId, + readFileTimestamps, + setToolJSX: () => {}, // No-op implementation for TaskTool + }, + )) { + messages.push(message) + + overwriteLog( + getMessagesPath(messageLogName, forkNumber, getSidechainNumber()), + messages.filter(_ => _.type !== 'progress'), + ) + + if (message.type !== 'assistant') { + continue + } + + const normalizedMessages = normalizeMessages(messages) + + // Process tool uses and text content for better visibility + for (const content of message.message.content) { + if (content.type === 'text' && content.text && content.text !== INTERRUPT_MESSAGE) { + // Show agent's reasoning/responses + const preview = content.text.length > 200 ? content.text.substring(0, 200) + '...' : content.text yield { type: 'progress', - content: modifiedMessage, + content: createAssistantMessage(`${preview}`), normalizedMessages, tools, } + } else if (content.type === 'tool_use') { + toolCallCount++ + + // Track tool call for analytics + trackSubAgentEvent( + SubAgentAnalyticsEvent.TOOL_CALLED, + taskId, + agentName, + agentType, + { toolCallCount } + ) + + // Show which tool is being used with agent context + const toolMessage = normalizedMessages.find( + _ => + _.type === 'assistant' && + _.message.content[0]?.type === 'tool_use' && + _.message.content[0].id === content.id, + ) as AssistantMessage + + if (toolMessage) { + // Clone and modify the message to show agent context + const modifiedMessage = { + ...toolMessage, + message: { + ...toolMessage.message, + content: toolMessage.message.content.map(c => { + if (c.type === 'tool_use' && c.id === content.id) { + // Add agent context to tool name display + return { + ...c, + name: c.name // Keep original name, UI will handle display + } + } + return c + }) + } + } + + yield { + type: 'progress', + content: modifiedMessage, + normalizedMessages, + tools, + } + } } } + + // Check for interrupt + if (message.message.content.some(_ => _.type === 'text' && _.text === INTERRUPT_MESSAGE)) { + wasInterrupted = true + } } + } catch (error) { + finalError = error instanceof Error ? error : new Error(String(error)) + errorCount++ } + const completedAt = Date.now() + const durationMs = completedAt - startTime + const normalizedMessages = normalizeMessages(messages) const lastMessage = last(messages) - if (lastMessage?.type !== 'assistant') { - throw new Error('Last message was not an assistant message') + + // Determine final status + let status: SubAgentResult['status'] = 'completed' + if (wasInterrupted) { + status = 'interrupted' + } else if (finalError) { + status = 'failed' + } + + // Extract result text + let resultText = '' + if (lastMessage?.type === 'assistant') { + resultText = lastMessage.message.content + .filter(_ => _.type === 'text' && _.text !== INTERRUPT_MESSAGE) + .map(_ => (_ as any).text) + .join('\n') } - // 🔧 CRITICAL FIX: Match original AgentTool interrupt handling pattern exactly - if ( - lastMessage.message.content.some( - _ => _.type === 'text' && _.text === INTERRUPT_MESSAGE, + // Capture git diff if changes were made during agent execution + const gitStatusAfter = getGitStatusHash() + if (gitStatusBefore !== gitStatusAfter) { + // Changes detected, capture the diff + collectedDiff = getGitDiff() + } + + // Store result in state manager + const subAgentResult: SubAgentResult = { + name: agentName, + agentType, + result: resultText, + diff: collectedDiff || undefined, + requestId: taskId, + toolCallCount, + errorCount, + startedAt: startTime, + completedAt, + durationMs, + model: modelToUse, + color: agentColor, + status, + } + stateManager.setSubAgentStoredResult(taskId, subAgentResult) + + // Track completion/failure/interrupt + if (wasInterrupted) { + trackSubAgentEvent( + SubAgentAnalyticsEvent.INTERRUPTED, + taskId, + agentName, + agentType, + { durationMs, toolCallCount, errorCount } + ) + } else if (finalError) { + trackSubAgentEvent( + SubAgentAnalyticsEvent.FAILED, + taskId, + agentName, + agentType, + { durationMs, toolCallCount, errorCount, errorMessage: finalError.message } ) - ) { - // Skip progress yield - only yield final result } else { - const result = [ - toolUseCount === 1 ? '1 tool use' : `${toolUseCount} tool uses`, - formatNumber( - (lastMessage.message.usage.cache_creation_input_tokens ?? 0) + - (lastMessage.message.usage.cache_read_input_tokens ?? 0) + - lastMessage.message.usage.input_tokens + - lastMessage.message.usage.output_tokens, - ) + ' tokens', - formatDuration(Date.now() - startTime), - ] + trackSubAgentEvent( + SubAgentAnalyticsEvent.COMPLETED, + taskId, + agentName, + agentType, + { durationMs, toolCallCount, errorCount, model: modelToUse, color: agentColor } + ) + } + + if (lastMessage?.type !== 'assistant') { + throw finalError || new Error('Last message was not an assistant message') + } + + // Handle interrupt case + if (wasInterrupted) { yield { - type: 'progress', - content: createAssistantMessage(`Task completed (${result.join(' · ')})`), - normalizedMessages, - tools, + type: 'result', + data: [{ type: 'text', text: INTERRUPT_MESSAGE }] as TextBlock[], + resultForAssistant: `Agent "${agentName}" was interrupted.`, } + return + } + + // Show completion summary + const result = [ + toolCallCount === 1 ? '1 tool use' : `${toolCallCount} tool uses`, + formatNumber( + (lastMessage.message.usage.cache_creation_input_tokens ?? 0) + + (lastMessage.message.usage.cache_read_input_tokens ?? 0) + + lastMessage.message.usage.input_tokens + + lastMessage.message.usage.output_tokens, + ) + ' tokens', + formatDuration(durationMs), + ] + yield { + type: 'progress', + content: createAssistantMessage(`Agent "${agentName}" completed (${result.join(' · ')})`), + normalizedMessages, + tools, } // Output is an AssistantMessage, but since TaskTool is a tool, it needs // to serialize its response to UserMessage-compatible content. const data = lastMessage.message.content.filter(_ => _.type === 'text') + + // Append agent name to result for tracking + const resultForAssistant = `[Agent: ${agentName}]\n${this.renderResultForAssistant(data)}` + yield { type: 'result', data, - resultForAssistant: this.renderResultForAssistant(data), + resultForAssistant, } }, @@ -335,32 +636,61 @@ export const TaskTool = { isConcurrencySafe() { return true // Task tool supports concurrent execution in official implementation }, - async validateInput(input, context) { + async validateInput(input: TaskInput, context) { + const action = input.action || 'run' + + // Validate 'output' action + if (action === 'output') { + if (!input.name) { + return { + result: false, + message: "Name is required for 'output' action. Provide the name of a completed agent.", + } + } + return { result: true } + } + + // Validate 'run' action if (!input.description || typeof input.description !== 'string') { return { result: false, - message: 'Description is required and must be a string', + message: "Description is required for 'run' action and must be a string", } } if (!input.prompt || typeof input.prompt !== 'string') { return { result: false, - message: 'Prompt is required and must be a string', + message: "Prompt is required for 'run' action and must be a string", + } + } + + // Validate name if provided + if (input.name && input.name.includes(' ')) { + return { + result: false, + message: `Agent name "${input.name}" cannot contain spaces`, } } - // Model validation - similar to Edit tool error handling + // Model validation - use resolveModel to support both model names AND pointers if (input.model_name) { const modelManager = getModelManager() - const availableModels = modelManager.getAllAvailableModelNames() - if (!availableModels.includes(input.model_name)) { + // First try to resolve as pointer or model name + const resolveResult = modelManager.resolveModelWithInfo(input.model_name) + + if (!resolveResult.success) { + // Model not found as pointer or direct name + const availableModels = modelManager.getAllAvailableModelNames() + const pointers = ['main', 'task', 'reasoning', 'quick'] + return { result: false, - message: `Model '${input.model_name}' does not exist. Available models: ${availableModels.join(', ')}`, + message: resolveResult.error || `Model '${input.model_name}' does not exist. Available models: ${availableModels.join(', ')}. Available pointers: ${pointers.join(', ')}`, meta: { model_name: input.model_name, availableModels, + pointers, }, } } @@ -386,7 +716,7 @@ export const TaskTool = { async isEnabled() { return true }, - userFacingName(input?: any) { + userFacingName(input?: TaskInput) { // Return agent name with proper prefix const agentType = input?.subagent_type || 'general-purpose' return `agent-${agentType}` @@ -397,23 +727,31 @@ export const TaskTool = { renderResultForAssistant(data: TextBlock[]) { return data.map(block => block.type === 'text' ? block.text : '').join('\n') }, - renderToolUseMessage({ description, prompt, model_name, subagent_type }, { verbose }) { + renderToolUseMessage(input: TaskInput, { verbose }) { + const { action = 'run', description, prompt, name, model_name, subagent_type } = input + + // Handle 'output' action display + if (action === 'output') { + return `Retrieving output for agent: ${name || 'unknown'}` + } + if (!description || !prompt) return null const modelManager = getModelManager() const defaultTaskModel = modelManager.getModelName('task') const actualModel = model_name || defaultTaskModel const agentType = subagent_type || 'general-purpose' + const agentName = name || 'unnamed' const promptPreview = prompt.length > 80 ? prompt.substring(0, 80) + '...' : prompt const theme = getTheme() - + if (verbose) { return ( - [{agentType}] {actualModel}: {description} + [{agentType}] {agentName} ({actualModel}): {description} @@ -441,14 +779,14 @@ export const TaskTool = { (sum, block) => sum + block.text.length, 0, ) - // 🔧 CRITICAL FIX: Use exact match for interrupt detection, not .includes() + // Use exact match for interrupt detection, not .includes() const isInterrupted = content.some( block => block.type === 'text' && block.text === INTERRUPT_MESSAGE, ) if (isInterrupted) { - // 🔧 CRITICAL FIX: Match original system interrupt rendering exactly + // Match original system interrupt rendering exactly return (   ⎿   diff --git a/src/tools/TaskTool/prompt.ts b/src/tools/TaskTool/prompt.ts index 02c3508e..f7088e2f 100644 --- a/src/tools/TaskTool/prompt.ts +++ b/src/tools/TaskTool/prompt.ts @@ -30,7 +30,13 @@ export async function getPrompt(safeMode: boolean): Promise { }).join('\n') // Keep the wording aligned so shared `.claude` agent packs behave identically - return `Launch a new agent to handle complex, multi-step tasks autonomously. + return `Launch a new agent to handle complex, multi-step tasks autonomously. + +**IMPORTANT: This tool can be run in parallel.** Multiple sub-agents can execute simultaneously with different names and instructions. Use parallel execution when you have multiple independent tasks that can be completed concurrently. + +Available actions: +- **run** - Execute a sub-agent with the given instruction (default, waits for completion) +- **output** - Show the response and file changes (if any) made by a completed sub-agent Available agent types and the tools they have access to: ${agentDescriptions} @@ -39,6 +45,7 @@ When using the Task tool, you must specify a subagent_type parameter to select w When to use the Agent tool: - When you are instructed to execute custom slash commands. Use the Agent tool with the slash command invocation as the entire prompt. The slash command can take arguments. For example: Task(description="Check the file", prompt="/check-file path/to/file.py") +- When you need to run multiple independent tasks in parallel When NOT to use the Agent tool: - If you want to read a specific file path, use the ${FileReadTool.name} or ${GlobTool.name} tool instead of the Agent tool, to find the match more quickly @@ -53,6 +60,8 @@ Usage notes: 4. The agent's outputs should generally be trusted 5. Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent 6. If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement. +7. Give each agent a unique name using the 'name' parameter for easier tracking and to retrieve results later with the 'output' action. +8. Use the 'output' action to retrieve detailed results (including file changes) from a previously completed agent. Example usage: @@ -79,7 +88,7 @@ function isPrime(n) { Since a signficant piece of code was written and the task was completed, now use the code-reviewer agent to review the code assistant: Now let me use the code-reviewer agent to review the code -assistant: Uses the Task tool to launch the with the code-reviewer agent +assistant: Uses the Task tool to launch the code-reviewer agent with name="prime-review" @@ -87,6 +96,18 @@ user: "Hello" Since the user is greeting, use the greeting-responder agent to respond with a friendly joke -assistant: "I'm going to use the Task tool to launch the with the greeting-responder agent" +assistant: "I'm going to use the Task tool to launch the greeting-responder agent" + + + +user: "Run tests and lint checks in parallel" + +These are independent tasks that can run concurrently + +assistant: I'll run both tasks in parallel using named agents +assistant: Uses Task tool twice in the same message: +- Task(action="run", name="test-runner", description="Run tests", prompt="...", subagent_type="general-purpose") +- Task(action="run", name="lint-checker", description="Run linting", prompt="...", subagent_type="general-purpose") +After both complete, can use Task(action="output", name="test-runner") to see detailed test results ` } diff --git a/src/utils/agentLoader.ts b/src/utils/agentLoader.ts index e9068ced..5f12402d 100644 --- a/src/utils/agentLoader.ts +++ b/src/utils/agentLoader.ts @@ -17,12 +17,13 @@ const warnedAgents = new Set() export interface AgentConfig { agentType: string // Agent identifier (matches subagent_type) - whenToUse: string // Description of when to use this agent + whenToUse: string // Description of when to use this agent tools: string[] | '*' // Tool permissions systemPrompt: string // System prompt content location: 'built-in' | 'user' | 'project' color?: string // Optional UI color model_name?: string // Optional model override + temperature?: number // Optional temperature override (0.0-1.0) } // Built-in general-purpose agent as fallback @@ -101,6 +102,17 @@ async function scanAgentDirectory(dirPath: string, location: 'user' | 'project') warnedAgents.add(frontmatter.name) } + // Parse and validate temperature + let temperature: number | undefined + if (frontmatter.temperature !== undefined) { + const temp = parseFloat(frontmatter.temperature) + if (!isNaN(temp) && temp >= 0.0 && temp <= 1.0) { + temperature = temp + } else if (process.env.KODE_DEBUG_AGENTS) { + console.warn(`⚠️ Agent ${frontmatter.name}: Invalid temperature '${frontmatter.temperature}'. Must be 0.0-1.0.`) + } + } + // Build agent config const agent: AgentConfig = { agentType: frontmatter.name, @@ -110,7 +122,8 @@ async function scanAgentDirectory(dirPath: string, location: 'user' | 'project') location, ...(frontmatter.color && { color: frontmatter.color }), // Only use model_name field, ignore deprecated 'model' field - ...(frontmatter.model_name && { model_name: frontmatter.model_name }) + ...(frontmatter.model_name && { model_name: frontmatter.model_name }), + ...(temperature !== undefined && { temperature }) } agents.push(agent) diff --git a/src/utils/agentTools/applyPatch.ts b/src/utils/agentTools/applyPatch.ts new file mode 100644 index 00000000..b99ae960 --- /dev/null +++ b/src/utils/agentTools/applyPatch.ts @@ -0,0 +1,625 @@ +/** + * ApplyPatch - V4A diff format parser and applier + * + * Migrated from AgentTool/01-FileEditTools/apply-patch-tool + * + * Features: + * - V4A diff format parsing + * - Context-based matching (no line numbers needed) + * - @@ class/function scope operators + * - Add/Update/Delete/Move operations + * - Unicode punctuation normalization + */ + +// ============================================================================ +// Constants +// ============================================================================ + +export const PATCH_PREFIX = '*** Begin Patch\n' +export const PATCH_SUFFIX = '\n*** End Patch' +export const ADD_FILE_PREFIX = '*** Add File: ' +export const DELETE_FILE_PREFIX = '*** Delete File: ' +export const UPDATE_FILE_PREFIX = '*** Update File: ' +export const MOVE_FILE_TO_PREFIX = '*** Move to: ' +export const END_OF_FILE_PREFIX = '*** End of File' +export const HUNK_ADD_LINE_PREFIX = '+' + +// ============================================================================ +// Types +// ============================================================================ + +export enum ActionType { + ADD = 'add', + DELETE = 'delete', + UPDATE = 'update', +} + +export interface Chunk { + /** Line index of the first line in the original file */ + orig_index: number + /** Lines to delete */ + del_lines: string[] + /** Lines to insert */ + ins_lines: string[] +} + +export interface PatchAction { + type: ActionType + new_file?: string | null + chunks: Chunk[] + move_path?: string | null +} + +export interface Patch { + actions: Record +} + +export class DiffError extends Error { + constructor(message: string) { + super(message) + this.name = 'DiffError' + } +} + +// ============================================================================ +// Unicode Punctuation Normalization +// ============================================================================ + +/** + * Mapping of visually similar Unicode punctuation to ASCII equivalents + */ +const PUNCT_EQUIV: Record = { + // Hyphen/dash variants + '-': '-', + '\u2010': '-', // HYPHEN + '\u2011': '-', // NO-BREAK HYPHEN + '\u2012': '-', // FIGURE DASH + '\u2013': '-', // EN DASH + '\u2014': '-', // EM DASH + '\u2212': '-', // MINUS SIGN + + // Double quotes + '\u0022': '"', // QUOTATION MARK + '\u201C': '"', // LEFT DOUBLE QUOTATION MARK + '\u201D': '"', // RIGHT DOUBLE QUOTATION MARK + '\u201E': '"', // DOUBLE LOW-9 QUOTATION MARK + '\u00AB': '"', // LEFT-POINTING DOUBLE ANGLE QUOTATION MARK + '\u00BB': '"', // RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK + + // Single quotes + '\u0027': "'", // APOSTROPHE + '\u2018': "'", // LEFT SINGLE QUOTATION MARK + '\u2019': "'", // RIGHT SINGLE QUOTATION MARK + '\u201B': "'", // SINGLE HIGH-REVERSED-9 QUOTATION MARK + + // Spaces + '\u00A0': ' ', // NO-BREAK SPACE + '\u202F': ' ', // NARROW NO-BREAK SPACE +} + +/** + * Canonicalize string for comparison (Unicode normalization + punctuation mapping) + */ +function canonicalize(s: string): string { + return s.normalize('NFC').replace(/./gu, (c) => PUNCT_EQUIV[c] ?? c) +} + +// ============================================================================ +// Context Finding +// ============================================================================ + +function findContextCore( + lines: string[], + context: string[], + start: number +): [number, number] { + if (context.length === 0) { + return [start, 0] + } + + const canonicalContext = canonicalize(context.join('\n')) + + // Pass 1: exact equality after canonicalization + for (let i = start; i < lines.length; i++) { + const segment = canonicalize(lines.slice(i, i + context.length).join('\n')) + if (segment === canonicalContext) { + return [i, 0] + } + } + + // Pass 2: ignore trailing whitespace + for (let i = start; i < lines.length; i++) { + const segment = canonicalize( + lines + .slice(i, i + context.length) + .map((s) => s.trimEnd()) + .join('\n') + ) + const ctx = canonicalize(context.map((s) => s.trimEnd()).join('\n')) + if (segment === ctx) { + return [i, 1] + } + } + + // Pass 3: ignore all surrounding whitespace + for (let i = start; i < lines.length; i++) { + const segment = canonicalize( + lines + .slice(i, i + context.length) + .map((s) => s.trim()) + .join('\n') + ) + const ctx = canonicalize(context.map((s) => s.trim()).join('\n')) + if (segment === ctx) { + return [i, 100] + } + } + + return [-1, 0] +} + +function findContext( + lines: string[], + context: string[], + start: number, + eof: boolean +): [number, number] { + if (eof) { + let [newIndex, fuzz] = findContextCore(lines, context, lines.length - context.length) + if (newIndex !== -1) { + return [newIndex, fuzz] + } + ;[newIndex, fuzz] = findContextCore(lines, context, start) + return [newIndex, fuzz + 10000] + } + return findContextCore(lines, context, start) +} + +// ============================================================================ +// Section Parsing +// ============================================================================ + +function peekNextSection( + lines: string[], + initialIndex: number +): [string[], Chunk[], number, boolean] { + let index = initialIndex + const old: string[] = [] + let delLines: string[] = [] + let insLines: string[] = [] + const chunks: Chunk[] = [] + let mode: 'keep' | 'add' | 'delete' = 'keep' + + while (index < lines.length) { + const s = lines[index]! + if ( + [ + '@@', + PATCH_SUFFIX, + UPDATE_FILE_PREFIX, + DELETE_FILE_PREFIX, + ADD_FILE_PREFIX, + END_OF_FILE_PREFIX, + ].some((p) => s.startsWith(p.trim())) + ) { + break + } + if (s === '***') break + if (s.startsWith('***')) { + throw new DiffError(`Invalid Line: ${s}`) + } + + index += 1 + const lastMode = mode + let line = s + + if (line[0] === HUNK_ADD_LINE_PREFIX) { + mode = 'add' + } else if (line[0] === '-') { + mode = 'delete' + } else if (line[0] === ' ') { + mode = 'keep' + } else { + // Tolerate lines without leading whitespace + mode = 'keep' + line = ' ' + line + } + + line = line.slice(1) + + if (mode === 'keep' && lastMode !== mode) { + if (insLines.length || delLines.length) { + chunks.push({ + orig_index: old.length - delLines.length, + del_lines: delLines, + ins_lines: insLines, + }) + } + delLines = [] + insLines = [] + } + + if (mode === 'delete') { + delLines.push(line) + old.push(line) + } else if (mode === 'add') { + insLines.push(line) + } else { + old.push(line) + } + } + + if (insLines.length || delLines.length) { + chunks.push({ + orig_index: old.length - delLines.length, + del_lines: delLines, + ins_lines: insLines, + }) + } + + if (index < lines.length && lines[index] === END_OF_FILE_PREFIX) { + index += 1 + return [old, chunks, index, true] + } + + return [old, chunks, index, false] +} + +// ============================================================================ +// Parser +// ============================================================================ + +export class Parser { + currentFiles: Record + lines: string[] + index = 0 + patch: Patch = { actions: {} } + fuzz = 0 + + constructor(currentFiles: Record, lines: string[]) { + this.currentFiles = currentFiles + this.lines = lines + } + + private isDone(prefixes?: string[]): boolean { + if (this.index >= this.lines.length) return true + if (prefixes && prefixes.some((p) => this.lines[this.index]!.startsWith(p.trim()))) { + return true + } + return false + } + + private startsWith(prefix: string | string[]): boolean { + const prefixes = Array.isArray(prefix) ? prefix : [prefix] + return prefixes.some((p) => this.lines[this.index]!.startsWith(p)) + } + + private readStr(prefix = '', returnEverything = false): string { + if (this.index >= this.lines.length) { + throw new DiffError(`Index: ${this.index} >= ${this.lines.length}`) + } + if (this.lines[this.index]!.startsWith(prefix)) { + const text = returnEverything + ? this.lines[this.index] + : this.lines[this.index]!.slice(prefix.length) + this.index += 1 + return text ?? '' + } + return '' + } + + parse(): void { + while (!this.isDone([PATCH_SUFFIX])) { + let path = this.readStr(UPDATE_FILE_PREFIX) + if (path) { + if (this.patch.actions[path]) { + throw new DiffError(`Update File Error: Duplicate Path: ${path}`) + } + const moveTo = this.readStr(MOVE_FILE_TO_PREFIX) + if (!(path in this.currentFiles)) { + throw new DiffError(`Update File Error: Missing File: ${path}`) + } + const text = this.currentFiles[path] + const action = this.parseUpdateFile(text ?? '') + action.move_path = moveTo || undefined + this.patch.actions[path] = action + continue + } + + path = this.readStr(DELETE_FILE_PREFIX) + if (path) { + if (this.patch.actions[path]) { + throw new DiffError(`Delete File Error: Duplicate Path: ${path}`) + } + if (!(path in this.currentFiles)) { + throw new DiffError(`Delete File Error: Missing File: ${path}`) + } + this.patch.actions[path] = { type: ActionType.DELETE, chunks: [] } + continue + } + + path = this.readStr(ADD_FILE_PREFIX) + if (path) { + if (this.patch.actions[path]) { + throw new DiffError(`Add File Error: Duplicate Path: ${path}`) + } + if (path in this.currentFiles) { + throw new DiffError(`Add File Error: File already exists: ${path}`) + } + this.patch.actions[path] = this.parseAddFile() + continue + } + + throw new DiffError(`Unknown Line: ${this.lines[this.index]}`) + } + + if (!this.startsWith(PATCH_SUFFIX.trim())) { + throw new DiffError('Missing End Patch') + } + this.index += 1 + } + + private parseUpdateFile(fileContent: string): PatchAction { + const action: PatchAction = { type: ActionType.UPDATE, chunks: [] } + const fileLines = fileContent.split('\n') + let index = 0 + + while ( + !this.isDone([ + PATCH_SUFFIX, + UPDATE_FILE_PREFIX, + DELETE_FILE_PREFIX, + ADD_FILE_PREFIX, + END_OF_FILE_PREFIX, + ]) + ) { + const defStr = this.readStr('@@ ') + let sectionStr = '' + if (!defStr && this.lines[this.index] === '@@') { + sectionStr = this.lines[this.index]! + this.index += 1 + } + + if (!(defStr || sectionStr || index === 0)) { + throw new DiffError(`Invalid Line:\n${this.lines[this.index]}`) + } + + if (defStr.trim()) { + let found = false + const canonLocal = (s: string): string => canonicalize(s) + + if (!fileLines.slice(0, index).some((s) => canonLocal(s) === canonLocal(defStr))) { + for (let i = index; i < fileLines.length; i++) { + if (canonLocal(fileLines[i]!) === canonLocal(defStr)) { + index = i + 1 + found = true + break + } + } + } + + if ( + !found && + !fileLines.slice(0, index).some((s) => canonLocal(s.trim()) === canonLocal(defStr.trim())) + ) { + for (let i = index; i < fileLines.length; i++) { + if (canonLocal(fileLines[i]!.trim()) === canonLocal(defStr.trim())) { + index = i + 1 + this.fuzz += 1 + found = true + break + } + } + } + } + + const [nextChunkContext, chunks, endPatchIndex, eof] = peekNextSection(this.lines, this.index) + const [newIndex, fuzz] = findContext(fileLines, nextChunkContext, index, eof) + + if (newIndex === -1) { + const ctxText = nextChunkContext.join('\n') + if (eof) { + throw new DiffError(`Invalid EOF Context ${index}:\n${ctxText}`) + } else { + throw new DiffError(`Invalid Context ${index}:\n${ctxText}`) + } + } + + this.fuzz += fuzz + for (const ch of chunks) { + ch.orig_index += newIndex + action.chunks.push(ch) + } + index = newIndex + nextChunkContext.length + this.index = endPatchIndex + } + + return action + } + + private parseAddFile(): PatchAction { + const lines: string[] = [] + while (!this.isDone([PATCH_SUFFIX, UPDATE_FILE_PREFIX, DELETE_FILE_PREFIX, ADD_FILE_PREFIX])) { + const s = this.readStr() + if (!s.startsWith(HUNK_ADD_LINE_PREFIX)) { + throw new DiffError(`Invalid Add File Line: ${s}`) + } + lines.push(s.slice(1)) + } + return { + type: ActionType.ADD, + new_file: lines.join('\n'), + chunks: [], + } + } +} + +// ============================================================================ +// High-level API +// ============================================================================ + +/** + * Parse patch text into a Patch object + * + * @param patchText - The patch text in V4A format + * @param fileContents - Map of file paths to their current contents + * @returns Tuple of [Patch, fuzz score] + */ +export function textToPatch( + patchText: string, + fileContents: Record +): [Patch, number] { + const lines = patchText.trim().split('\n') + + if ( + lines.length < 2 || + !(lines[0] ?? '').startsWith(PATCH_PREFIX.trim()) || + lines[lines.length - 1] !== PATCH_SUFFIX.trim() + ) { + let reason = 'Invalid patch text: ' + if (lines.length < 2) { + reason += 'Patch text must have at least two lines.' + } else if (!(lines[0] ?? '').startsWith(PATCH_PREFIX.trim())) { + reason += 'Patch text must start with the correct patch prefix.' + } else if (lines[lines.length - 1] !== PATCH_SUFFIX.trim()) { + reason += 'Patch text must end with the correct patch suffix.' + } + throw new DiffError(reason) + } + + const parser = new Parser(fileContents, lines) + parser.index = 1 + parser.parse() + return [parser.patch, parser.fuzz] +} + +/** + * Extract file paths from patch text + */ +export function extractFilePaths(patchText: string): { + added: string[] + updated: string[] + deleted: string[] +} { + const lines = patchText.trim().split('\n') + const added = new Set() + const updated = new Set() + const deleted = new Set() + + for (const line of lines) { + if (line.startsWith(ADD_FILE_PREFIX)) { + added.add(line.slice(ADD_FILE_PREFIX.length)) + } + if (line.startsWith(UPDATE_FILE_PREFIX)) { + updated.add(line.slice(UPDATE_FILE_PREFIX.length)) + } + if (line.startsWith(DELETE_FILE_PREFIX)) { + deleted.add(line.slice(DELETE_FILE_PREFIX.length)) + } + } + + return { + added: [...added], + updated: [...updated], + deleted: [...deleted], + } +} + +/** + * Identify files that need to be read before applying patch + */ +export function identifyFilesNeeded(patchText: string): string[] { + const { updated, deleted } = extractFilePaths(patchText) + return [...new Set([...updated, ...deleted])] +} + +/** + * Identify all files affected by the patch + */ +export function identifyFilesAffected(patchText: string): string[] { + const { added, updated, deleted } = extractFilePaths(patchText) + return [...new Set([...added, ...updated, ...deleted])] +} + +/** + * Apply UPDATE action to get the new file content + * + * @param originalText - Original file content + * @param action - The patch action to apply + * @param path - File path (for error messages) + * @returns Updated file content + */ +export function getUpdatedFile(originalText: string, action: PatchAction, path: string): string { + if (action.type !== ActionType.UPDATE) { + throw new DiffError('Expected UPDATE action') + } + + const origLines = originalText.split('\n') + const destLines: string[] = [] + let origIndex = 0 + + for (const chunk of action.chunks) { + if (chunk.orig_index > origLines.length) { + throw new DiffError( + `${path}: chunk.orig_index ${chunk.orig_index} > len(lines) ${origLines.length}` + ) + } + if (origIndex > chunk.orig_index) { + throw new DiffError(`${path}: orig_index ${origIndex} > chunk.orig_index ${chunk.orig_index}`) + } + + destLines.push(...origLines.slice(origIndex, chunk.orig_index)) + origIndex = chunk.orig_index + + // Insert new lines + if (chunk.ins_lines.length) { + destLines.push(...chunk.ins_lines) + } + + origIndex += chunk.del_lines.length + } + + destLines.push(...origLines.slice(origIndex)) + return destLines.join('\n') +} + +/** + * Apply a complete patch to file contents + * + * @param patch - The parsed patch + * @param fileContents - Current file contents + * @returns Map of file paths to their new contents (undefined = deleted) + */ +export function applyPatch( + patch: Patch, + fileContents: Record +): Record { + const results: Record = {} + + for (const [path, action] of Object.entries(patch.actions)) { + switch (action.type) { + case ActionType.ADD: + results[path] = action.new_file ?? '' + break + + case ActionType.DELETE: + results[path] = undefined + break + + case ActionType.UPDATE: + const originalContent = fileContents[path] ?? '' + const newContent = getUpdatedFile(originalContent, action, path) + + if (action.move_path) { + // Delete original, add at new path + results[path] = undefined + results[action.move_path] = newContent + } else { + results[path] = newContent + } + break + } + } + + return results +} diff --git a/src/utils/agentTools/checkpointManager.ts b/src/utils/agentTools/checkpointManager.ts new file mode 100644 index 00000000..4e0d2945 --- /dev/null +++ b/src/utils/agentTools/checkpointManager.ts @@ -0,0 +1,505 @@ +/** + * CheckpointManager - File state tracking with undo/redo support + * + * Migrated from AgentTool/12-CheckpointManagement + * + * Features: + * - Track file states at specific timestamps + * - Revert to any previous state + * - Separate tracking for user vs agent edits + * - Shard-based storage for scalability + */ + +import * as crypto from 'crypto' + +// ============================================================================ +// Types +// ============================================================================ + +export interface QualifiedPathName { + rootPath: string + relPath: string + readonly absPath: string +} + +export function createQualifiedPathName(rootPath: string, relPath: string): QualifiedPathName { + return { + rootPath, + relPath, + get absPath() { + return `${this.rootPath}/${this.relPath}` + }, + } +} + +export enum EditEventSource { + UNSPECIFIED = 'unspecified', + USER_EDIT = 'user_edit', + AGENT_EDIT = 'agent_edit', + CHECKPOINT_REVERT = 'checkpoint_revert', +} + +export interface DiffViewDocument { + filePath: QualifiedPathName + originalCode: string | undefined + modifiedCode: string | undefined +} + +export function createDiffViewDocument( + filePath: QualifiedPathName, + originalCode: string | undefined, + modifiedCode: string | undefined +): DiffViewDocument { + return { filePath, originalCode, modifiedCode } +} + +export interface HydratedCheckpoint { + sourceToolCallRequestId: string + timestamp: number + document: DiffViewDocument + conversationId: string + editSource?: EditEventSource + lastIncludedInRequestId?: string + isDirty?: boolean +} + +export interface FileChangeSummary { + totalAddedLines: number + totalRemovedLines: number +} + +export interface AggregateCheckpointInfo { + fromTimestamp: number + toTimestamp: number + conversationId: string + files: Array<{ + changesSummary: FileChangeSummary + changeDocument: DiffViewDocument + }> +} + +export interface CheckpointKey { + conversationId: string + path: QualifiedPathName +} + +// ============================================================================ +// Utility Functions +// ============================================================================ + +/** + * Generate a unique request ID + */ +export function createRequestId(): string { + return crypto.randomUUID() +} + +/** + * Compute change summary for a document + */ +export function computeChangesSummary(doc: DiffViewDocument): FileChangeSummary { + const originalLines = (doc.originalCode ?? '').split('\n') + const modifiedLines = (doc.modifiedCode ?? '').split('\n') + + // Simple line diff calculation + const originalSet = new Set(originalLines) + const modifiedSet = new Set(modifiedLines) + + let addedLines = 0 + let removedLines = 0 + + for (const line of modifiedLines) { + if (!originalSet.has(line)) { + addedLines++ + } + } + + for (const line of originalLines) { + if (!modifiedSet.has(line)) { + removedLines++ + } + } + + return { totalAddedLines: addedLines, totalRemovedLines: removedLines } +} + +// ============================================================================ +// In-Memory Checkpoint Storage +// ============================================================================ + +interface CheckpointStore { + checkpoints: Map // key: conversationId#path +} + +/** + * Generate a storage key from checkpoint key + */ +function makeStorageKey(key: CheckpointKey): string { + return `${key.conversationId}#${key.path.absPath}` +} + +// ============================================================================ +// CheckpointManager +// ============================================================================ + +/** + * CheckpointManager - Manages file state checkpoints for conversations + * + * Features: + * - Track file modifications during conversations + * - Revert files to previous states + * - Separate tracking of user vs agent edits + * - Query file state at specific timestamps + */ +export class CheckpointManager { + private _store: CheckpointStore = { checkpoints: new Map() } + private _currentConversationId: string | undefined + private _updateCallbacks = new Set<() => void>() + + get currentConversationId(): string | undefined { + return this._currentConversationId + } + + setCurrentConversation(conversationId: string): void { + this._currentConversationId = conversationId + } + + /** + * Register a callback for checkpoint updates + */ + onUpdate(callback: () => void): { dispose: () => void } { + this._updateCallbacks.add(callback) + return { + dispose: () => { + this._updateCallbacks.delete(callback) + }, + } + } + + private _notifyUpdate(): void { + this._updateCallbacks.forEach((cb) => cb()) + } + + /** + * Add a checkpoint for a file + */ + async addCheckpoint(key: CheckpointKey, checkpoint: Omit): Promise { + const storageKey = makeStorageKey(key) + const checkpoints = this._store.checkpoints.get(storageKey) ?? [] + checkpoints.push({ ...checkpoint, isDirty: false }) + this._store.checkpoints.set(storageKey, checkpoints) + this._notifyUpdate() + } + + /** + * Get all checkpoints for a file + */ + async getCheckpoints( + key: CheckpointKey, + options?: { minTimestamp?: number; maxTimestamp?: number } + ): Promise { + const storageKey = makeStorageKey(key) + let checkpoints = this._store.checkpoints.get(storageKey) ?? [] + + if (options?.minTimestamp !== undefined) { + checkpoints = checkpoints.filter((cp) => cp.timestamp >= options.minTimestamp!) + } + if (options?.maxTimestamp !== undefined) { + checkpoints = checkpoints.filter((cp) => cp.timestamp <= options.maxTimestamp!) + } + + return checkpoints.sort((a, b) => a.timestamp - b.timestamp) + } + + /** + * Get the latest checkpoint for a file + */ + async getLatestCheckpoint(key: CheckpointKey): Promise { + const checkpoints = await this.getCheckpoints(key) + return checkpoints.at(-1) + } + + /** + * Update the latest checkpoint for a file + */ + async updateLatestCheckpoint( + filePath: QualifiedPathName, + newContent: string | undefined, + options?: { saveToWorkspace?: boolean; updateSource?: EditEventSource } + ): Promise { + if (!this._currentConversationId) return + + const key: CheckpointKey = { conversationId: this._currentConversationId, path: filePath } + const checkpoints = await this.getCheckpoints(key) + const latestCheckpoint = checkpoints.at(-1) + + if (!latestCheckpoint) return + + const shouldCreateNew = + (latestCheckpoint.editSource ?? EditEventSource.UNSPECIFIED) !== + (options?.updateSource ?? EditEventSource.UNSPECIFIED) || + latestCheckpoint.lastIncludedInRequestId !== undefined + + if (shouldCreateNew) { + await this.addCheckpoint(key, { + sourceToolCallRequestId: createRequestId(), + timestamp: Date.now(), + document: createDiffViewDocument( + filePath, + latestCheckpoint.document.modifiedCode, + newContent + ), + conversationId: this._currentConversationId, + editSource: options?.updateSource ?? EditEventSource.UNSPECIFIED, + }) + } else { + // Update in place + latestCheckpoint.document = createDiffViewDocument( + filePath, + latestCheckpoint.document.originalCode, + newContent + ) + this._notifyUpdate() + } + } + + /** + * Get file state at a specific timestamp + */ + async getFileStateAtTimestamp( + conversationId: string, + filePath: QualifiedPathName, + timestamp: number + ): Promise { + const key: CheckpointKey = { conversationId, path: filePath } + const checkpoints = await this.getCheckpoints(key) + + if (checkpoints.length === 0) return null + + // Find checkpoint before or at timestamp + const beforeCkpts = checkpoints.filter((cp) => cp.timestamp <= timestamp) + const lastBefore = beforeCkpts.at(-1) + + if (lastBefore) { + return lastBefore.document.modifiedCode + } + + // Return original state from first checkpoint + const firstAfter = checkpoints.find((cp) => cp.timestamp > timestamp) + return firstAfter?.document.originalCode ?? null + } + + /** + * Get all tracked files for a conversation + */ + async getTrackedFiles(conversationId: string): Promise { + const files: QualifiedPathName[] = [] + const prefix = `${conversationId}#` + + for (const key of this._store.checkpoints.keys()) { + if (key.startsWith(prefix)) { + const checkpoints = this._store.checkpoints.get(key) + if (checkpoints && checkpoints.length > 0) { + files.push(checkpoints[0].document.filePath) + } + } + } + + return files + } + + /** + * Get aggregate checkpoint for all tracked files + */ + async getAggregateCheckpoint(options: { + minTimestamp?: number + maxTimestamp?: number + }): Promise { + const conversationId = this._currentConversationId + if (!conversationId) { + return { + fromTimestamp: 0, + toTimestamp: Infinity, + conversationId: '', + files: [], + } + } + + const minTimestamp = options.minTimestamp ?? 0 + const maxTimestamp = options.maxTimestamp ?? Infinity + + const trackedFiles = await this.getTrackedFiles(conversationId) + const files: Array<{ + changesSummary: FileChangeSummary + changeDocument: DiffViewDocument + }> = [] + + for (const filePath of trackedFiles) { + const original = await this.getFileStateAtTimestamp(conversationId, filePath, minTimestamp) + const modified = await this.getFileStateAtTimestamp(conversationId, filePath, maxTimestamp) + + if (original === null || modified === null) continue + + const doc = createDiffViewDocument(filePath, original, modified) + files.push({ + changesSummary: computeChangesSummary(doc), + changeDocument: doc, + }) + } + + return { + fromTimestamp: minTimestamp, + toTimestamp: maxTimestamp, + conversationId, + files, + } + } + + /** + * Get recent user edits (changes made by user, not agent) + */ + async getRecentUserEdits(): Promise { + const conversationId = this._currentConversationId + if (!conversationId) { + return { fromTimestamp: 0, toTimestamp: Infinity, conversationId: '', files: [] } + } + + const trackedFiles = await this.getTrackedFiles(conversationId) + const userModifiedFiles: Array<{ + changesSummary: FileChangeSummary + changeDocument: DiffViewDocument + }> = [] + + for (const filePath of trackedFiles) { + const key: CheckpointKey = { conversationId, path: filePath } + const checkpoints = await this.getCheckpoints(key) + const latest = checkpoints.at(-1) + + if (latest?.editSource === EditEventSource.USER_EDIT) { + userModifiedFiles.push({ + changesSummary: computeChangesSummary(latest.document), + changeDocument: latest.document, + }) + } + } + + return { + fromTimestamp: 0, + toTimestamp: Infinity, + conversationId, + files: userModifiedFiles, + } + } + + /** + * Revert a file to a specific timestamp + */ + async revertDocumentToTimestamp( + filePath: QualifiedPathName, + timestamp: number + ): Promise { + if (!this._currentConversationId) return + + const original = await this.getFileStateAtTimestamp( + this._currentConversationId, + filePath, + timestamp + ) + const latest = await this.getFileStateAtTimestamp( + this._currentConversationId, + filePath, + Number.MAX_SAFE_INTEGER + ) + + if (original === null || latest === null) return + + await this.addCheckpoint( + { conversationId: this._currentConversationId, path: filePath }, + { + sourceToolCallRequestId: createRequestId(), + timestamp: Date.now(), + document: createDiffViewDocument(filePath, latest ?? '', original), + conversationId: this._currentConversationId, + editSource: EditEventSource.CHECKPOINT_REVERT, + } + ) + } + + /** + * Revert all tracked files to a specific timestamp + */ + async revertToTimestamp(timestamp: number): Promise { + if (!this._currentConversationId) return + + const aggregate = await this.getAggregateCheckpoint({ + minTimestamp: timestamp, + maxTimestamp: undefined, + }) + + for (const file of aggregate.files) { + await this.revertDocumentToTimestamp(file.changeDocument.filePath, timestamp) + } + } + + /** + * Clear all checkpoints for a conversation + */ + async clearConversationCheckpoints(conversationId: string): Promise { + const prefix = `${conversationId}#` + const keysToDelete: string[] = [] + + for (const key of this._store.checkpoints.keys()) { + if (key.startsWith(prefix)) { + keysToDelete.push(key) + } + } + + for (const key of keysToDelete) { + this._store.checkpoints.delete(key) + } + + this._notifyUpdate() + } + + /** + * Migrate conversation ID (e.g., from temporary to permanent) + */ + async migrateConversationId( + oldConversationId: string, + newConversationId: string + ): Promise { + const prefix = `${oldConversationId}#` + const updates: Array<[string, HydratedCheckpoint[]]> = [] + + for (const [key, checkpoints] of this._store.checkpoints.entries()) { + if (key.startsWith(prefix)) { + const newKey = key.replace(prefix, `${newConversationId}#`) + const updatedCheckpoints = checkpoints.map((cp) => ({ + ...cp, + conversationId: newConversationId, + })) + updates.push([newKey, updatedCheckpoints]) + this._store.checkpoints.delete(key) + } + } + + for (const [key, checkpoints] of updates) { + this._store.checkpoints.set(key, checkpoints) + } + + this._notifyUpdate() + } +} + +// ============================================================================ +// Singleton Export +// ============================================================================ + +let _checkpointManager: CheckpointManager | undefined + +export function getCheckpointManager(): CheckpointManager { + if (!_checkpointManager) { + _checkpointManager = new CheckpointManager() + } + return _checkpointManager +} diff --git a/src/utils/agentTools/commandTimeoutPredictor.ts b/src/utils/agentTools/commandTimeoutPredictor.ts new file mode 100644 index 00000000..79de11d6 --- /dev/null +++ b/src/utils/agentTools/commandTimeoutPredictor.ts @@ -0,0 +1,418 @@ +/** + * CommandTimeoutPredictor - Smart timeout prediction based on command history + * + * Migrated from AgentTool/04-ShellTools/command-timeout-predictor.ts + * + * Features: + * - Learn from command execution history + * - Predict optimal timeouts based on past executions + * - Auto-adjust timeouts for commands that previously timed out + * - LRU cache for efficient memory usage + */ + +import * as fs from 'fs/promises' +import * as path from 'path' + +// ============================================================================ +// Types +// ============================================================================ + +/** + * Represents a single command execution record + */ +export interface CommandExecutionRecord { + /** Original command as executed */ + originalCommand: string + /** Execution time in seconds (null if timed out) */ + executionTimeSeconds: number | null + /** Timeout value that was used */ + timeoutUsed: number + /** Timestamp when the command was executed */ + timestamp: number +} + +/** + * Aggregated statistics for a command + */ +export interface CommandStats { + /** Number of successful executions */ + successCount: number + /** Number of timeouts */ + timeoutCount: number + /** Average execution time for successful runs (seconds) */ + avgExecutionTime: number + /** Maximum execution time observed (seconds) */ + maxExecutionTime: number + /** Last timeout value that failed */ + lastTimeoutThatFailed?: number + /** Last successful execution time */ + lastSuccessfulTime?: number + /** Timestamp of last execution */ + lastExecuted: number +} + +/** + * Storage structure for command execution history + */ +interface CommandExecutionHistory { + /** Individual execution records (limited to recent entries) */ + records: CommandExecutionRecord[] + /** Aggregated statistics by exact command */ + commandStats: Record + /** Version for future migration compatibility */ + version: number +} + +// ============================================================================ +// Simple LRU Cache +// ============================================================================ + +class LRUCache { + private _cache = new Map() + private _maxSize: number + + constructor(maxSize: number) { + this._maxSize = maxSize + } + + get(key: K): V | undefined { + const value = this._cache.get(key) + if (value !== undefined) { + // Move to end (most recently used) + this._cache.delete(key) + this._cache.set(key, value) + } + return value + } + + set(key: K, value: V): void { + // Delete first to update position + this._cache.delete(key) + this._cache.set(key, value) + + // Evict oldest entries if over capacity + while (this._cache.size > this._maxSize) { + const firstKey = this._cache.keys().next().value + if (firstKey !== undefined) { + this._cache.delete(firstKey) + } + } + } + + values(): IterableIterator { + return this._cache.values() + } + + entries(): IterableIterator<[K, V]> { + return this._cache.entries() + } + + clear(): void { + this._cache.clear() + } +} + +// ============================================================================ +// Storage Interface +// ============================================================================ + +export interface TimeoutPredictorStorage { + load(): Promise + save(data: Uint8Array): Promise +} + +/** + * File-based storage for timeout predictor + */ +export class FileTimeoutPredictorStorage implements TimeoutPredictorStorage { + constructor(private _filePath: string) {} + + async load(): Promise { + try { + const data = await fs.readFile(this._filePath) + return new Uint8Array(data) + } catch { + return null + } + } + + async save(data: Uint8Array): Promise { + const dir = path.dirname(this._filePath) + await fs.mkdir(dir, { recursive: true }) + await fs.writeFile(this._filePath, data) + } +} + +/** + * In-memory storage for testing + */ +export class InMemoryTimeoutPredictorStorage implements TimeoutPredictorStorage { + private _data: Uint8Array | null = null + + async load(): Promise { + return this._data + } + + async save(data: Uint8Array): Promise { + this._data = data + } +} + +// ============================================================================ +// CommandTimeoutPredictor +// ============================================================================ + +/** + * Smart timeout predictor that learns from command execution history + * + * Features: + * - Predicts optimal timeouts based on past executions + * - Auto-increases timeout for commands that previously timed out + * - Uses LRU cache for efficient memory usage + * - Persists history to storage + */ +export class CommandTimeoutPredictor { + private readonly _maxRecords = 1000 // Keep last 1000 command records + private readonly _maxCommands = 500 // Keep stats for up to 500 unique commands + private readonly _currentVersion = 2 + + private readonly _recordsCache: LRUCache + private readonly _commandStatsCache: LRUCache + + private _isHistoryLoaded = false + private _loadingPromise: Promise | null = null + private _storage: TimeoutPredictorStorage | null + + constructor(storage?: TimeoutPredictorStorage) { + this._storage = storage ?? null + this._recordsCache = new LRUCache(this._maxRecords) + this._commandStatsCache = new LRUCache(this._maxCommands) + + // Start loading immediately if storage is available + if (this._storage) { + this._loadingPromise = this._loadHistoryFromStorage() + } + } + + /** + * Predict timeout for a command based on historical data + * Returns null if no historical data is available + */ + async predictTimeout(command: string): Promise { + try { + await this._ensureHistoryLoaded() + + // Check if we have statistics for this exact command + const stats = this._commandStatsCache.get(command) + if (stats) { + return this._calculateTimeoutFromStats(stats) + } + + // No historical data available + return null + } catch { + // If there's an error loading history, no prediction available + return null + } + } + + /** + * Get the optimal timeout for a command, choosing between predicted and requested timeout + * Returns the larger of the two values, or the requested timeout if no prediction is available + */ + async getOptimalTimeout(command: string, requestedTimeout: number): Promise { + const predictedTimeout = await this.predictTimeout(command) + + if (predictedTimeout !== null) { + return Math.max(requestedTimeout, predictedTimeout) + } + + return requestedTimeout + } + + /** + * Record the execution of a command and its outcome + */ + async recordExecution( + command: string, + executionTimeSeconds: number | null, + timeoutUsed: number + ): Promise { + try { + await this._ensureHistoryLoaded() + const timestamp = Date.now() + + // Add new record + const record: CommandExecutionRecord = { + originalCommand: command, + executionTimeSeconds, + timeoutUsed, + timestamp, + } + + const recordKey = `${command}-${timestamp}` + this._recordsCache.set(recordKey, record) + + // Update command statistics + this._updateCommandStats(command, record) + + // Save updated history + await this._saveHistory() + } catch { + // Silently fail - don't break execution flow + } + } + + /** + * Calculate timeout based on historical statistics + */ + private _calculateTimeoutFromStats(stats: CommandStats): number | null { + // If we have successful executions, use actual measured performance + if (stats.successCount > 0) { + // Add 20% buffer or at least 10 seconds + const bufferTime = Math.max(stats.maxExecutionTime * 1.2, stats.maxExecutionTime + 10) + return Math.ceil(bufferTime) + } + + // If we only have timeout history (no successful runs), double the last failed timeout + if (stats.timeoutCount > 0 && stats.lastTimeoutThatFailed) { + return Math.ceil(stats.lastTimeoutThatFailed * 2) + } + + // No historical data to base prediction on + return null + } + + /** + * Update command statistics with new execution record + */ + private _updateCommandStats(command: string, record: CommandExecutionRecord): void { + let stats = this._commandStatsCache.get(command) + + if (!stats) { + stats = { + successCount: 0, + timeoutCount: 0, + avgExecutionTime: 0, + maxExecutionTime: 0, + lastExecuted: record.timestamp, + } + } + + // Update counts and timing + if (record.executionTimeSeconds === null) { + stats.timeoutCount++ + stats.lastTimeoutThatFailed = Math.max(stats.lastTimeoutThatFailed || 0, record.timeoutUsed) + } else { + stats.successCount++ + stats.lastSuccessfulTime = record.executionTimeSeconds + + // Update average execution time + const totalTime = + stats.avgExecutionTime * (stats.successCount - 1) + record.executionTimeSeconds + stats.avgExecutionTime = totalTime / stats.successCount + + // Update max execution time + stats.maxExecutionTime = Math.max(stats.maxExecutionTime, record.executionTimeSeconds) + } + + stats.lastExecuted = record.timestamp + this._commandStatsCache.set(command, stats) + } + + /** + * Load command execution history from storage + */ + private async _loadHistoryFromStorage(): Promise { + if (!this._storage) { + this._isHistoryLoaded = true + return + } + + try { + const storedBytes = await this._storage.load() + + if (storedBytes) { + const storedText = new TextDecoder().decode(storedBytes) + const stored = JSON.parse(storedText) as CommandExecutionHistory + + if (stored && stored.version === this._currentVersion) { + // Load records into cache + stored.records.forEach((record: CommandExecutionRecord) => { + const recordKey = `${record.originalCommand}-${record.timestamp}` + this._recordsCache.set(recordKey, record) + }) + + // Load command stats into cache + Object.entries(stored.commandStats || {}).forEach(([command, stats]) => { + this._commandStatsCache.set(command, stats) + }) + } + } + + this._isHistoryLoaded = true + } catch { + // Mark as loaded even on error to avoid repeated attempts + this._isHistoryLoaded = true + } + } + + /** + * Ensure command execution history is loaded from storage + */ + private async _ensureHistoryLoaded(): Promise { + if (this._isHistoryLoaded) { + return + } + + if (this._loadingPromise) { + await this._loadingPromise + } + } + + /** + * Save command execution history to storage + */ + private async _saveHistory(): Promise { + if (!this._storage) { + return + } + + const history: CommandExecutionHistory = { + records: Array.from(this._recordsCache.values()), + commandStats: Object.fromEntries(this._commandStatsCache.entries()), + version: this._currentVersion, + } + const historyText = JSON.stringify(history) + const historyBytes = new TextEncoder().encode(historyText) + await this._storage.save(historyBytes) + } + + /** + * Clear all history + */ + clear(): void { + this._recordsCache.clear() + this._commandStatsCache.clear() + } + + /** + * Get statistics for a specific command + */ + getCommandStats(command: string): CommandStats | undefined { + return this._commandStatsCache.get(command) + } +} + +// ============================================================================ +// Singleton Export +// ============================================================================ + +let _timeoutPredictor: CommandTimeoutPredictor | undefined + +export function getTimeoutPredictor(storage?: TimeoutPredictorStorage): CommandTimeoutPredictor { + if (!_timeoutPredictor) { + _timeoutPredictor = new CommandTimeoutPredictor(storage) + } + return _timeoutPredictor +} diff --git a/src/utils/agentTools/editEnhancements.ts b/src/utils/agentTools/editEnhancements.ts new file mode 100644 index 00000000..890b9b7f --- /dev/null +++ b/src/utils/agentTools/editEnhancements.ts @@ -0,0 +1,753 @@ +/** + * Edit Enhancements - Integrated from AgentTool + * Provides fuzzy matching, line number tolerance, and advanced edit utilities + */ + +import { debug } from '@utils/debugLogger' + +// Helper for logging +const log = (msg: string) => debug.trace('edit', msg) + +// ============================================================================ +// Types +// ============================================================================ + +export interface Match { + startLine: number + endLine: number +} + +export interface EditResult { + isError: boolean + genMessageFunc?: (result: EditResult) => string + oldStr: string + oldStrStartLineNumber?: number + oldStrEndLineNumber?: number + newContent?: string + newStr?: string + newStrStartLineNumber?: number + newStrEndLineNumber?: number + numLinesDiff: number + linesAdded: number + linesDeleted: number + index: number + wasReformattedByIDE?: boolean +} + +export interface IndentInfo { + type: 'space' | 'tab' + size: number +} + +export enum MatchFailReason { + ExceedsMaxDiff = 'ExceedsMaxDiff', + ExceedsMaxDiffRatio = 'ExceedsMaxDiffRatio', + FirstSymbolOfOldStrNotInOriginal = 'FirstSymbolOfOldStrNotInOriginal', + LastSymbolOfOldStrNotInOriginal = 'LastSymbolOfOldStrNotInOriginal', + SymbolInOldNotInOriginalOrNew = 'SymbolInOldNotInOriginalOrNew', + AmbiguousReplacement = 'AmbiguousReplacement', +} + +// ============================================================================ +// Text Processing Utilities +// ============================================================================ + +/** + * Removes trailing whitespace from each line while preserving line endings + */ +export function removeTrailingWhitespace(text: string): string { + const lineEnding = text.includes('\r\n') ? '\r\n' : '\n' + const lines = text.split(lineEnding) + const trimmedLines = lines.map(line => line.replace(/\s+$/, '')) + return trimmedLines.join(lineEnding) +} + +/** + * Normalizes line endings to \n + */ +export function normalizeLineEndings(text: string): string { + return text.replace(/\r\n/g, '\n') +} + +/** + * Detects line ending type + */ +export function detectLineEnding(text: string): string { + return text.includes('\r\n') ? '\r\n' : '\n' +} + +/** + * Restores original line endings + */ +export function restoreLineEndings(text: string, originalLineEnding: string): string { + if (originalLineEnding === '\r\n') { + return text.replace(/\n/g, '\r\n') + } + return text +} + +/** + * Prepares text for editing by normalizing line endings and removing trailing whitespace + */ +export function prepareTextForEditing(text: string): { + content: string + originalLineEnding: string +} { + const originalLineEnding = detectLineEnding(text) + const content = normalizeLineEndings(removeTrailingWhitespace(text)) + return { content, originalLineEnding } +} + +// ============================================================================ +// Indentation Detection and Handling +// ============================================================================ + +/** + * Detects the indentation type used in a string + */ +export function detectIndentation(content: string): IndentInfo { + const lines = content.split('\n') + let spaceIndents = 0 + let tabIndents = 0 + let spaceSize = 0 + + for (const line of lines) { + if (line.trim() === '') continue + + const leadingSpaces = line.match(/^( +)/) + const leadingTabs = line.match(/^(\t+)/) + + if (leadingSpaces) { + spaceIndents++ + if (spaceSize === 0) { + spaceSize = leadingSpaces[1].length + } + } else if (leadingTabs) { + tabIndents++ + } + } + + if (tabIndents > spaceIndents) { + return { type: 'tab', size: 1 } + } + return { type: 'space', size: spaceSize || 2 } +} + +/** + * Removes one level of indentation from each line + */ +export function removeOneIndentLevel(text: string, indentation: IndentInfo): string { + const lines = text.split('\n') + const pattern = indentation.type === 'tab' + ? /^\t/ + : new RegExp(`^ {1,${indentation.size}}`) + + return lines.map(line => line.replace(pattern, '')).join('\n') +} + +/** + * Checks if all non-empty lines have indentation + */ +export function allLinesHaveIndent(text: string, indentation: IndentInfo): boolean { + const lines = text.split('\n') + return lines.every(line => { + if (line.trim() === '') return true + const pattern = indentation.type === 'tab' + ? /^\t/ + : new RegExp(`^ {1,${indentation.size}}`) + return line.match(pattern) !== null + }) +} + +/** + * Removes all indentation from text + */ +export function removeAllIndents(text: string): string { + const lineEnding = detectLineEnding(text) + return text + .split(lineEnding) + .map(line => line.trim()) + .join(lineEnding) +} + +// ============================================================================ +// Match Finding +// ============================================================================ + +/** + * Find all matches of a string in content + */ +export function findMatches(content: string, str: string): Match[] { + const contentLines = content.split('\n') + const strLines = str.split('\n') + const matches: Match[] = [] + + if (str.trim() === '' || strLines.length > contentLines.length) { + return matches + } + + // Single line search + if (strLines.length === 1) { + contentLines.forEach((line, index) => { + if (line.includes(str)) { + matches.push({ startLine: index, endLine: index }) + } + }) + return matches + } + + // Multi-line search + const contentText = content + const searchText = str + let startIndex = 0 + let foundIndex: number + + while ((foundIndex = contentText.indexOf(searchText, startIndex)) !== -1) { + const textBeforeMatch = contentText.substring(0, foundIndex) + const textUpToEndOfMatch = contentText.substring(0, foundIndex + searchText.length) + + const startLine = (textBeforeMatch.match(/\n/g) || []).length + const endLine = (textUpToEndOfMatch.match(/\n/g) || []).length + + matches.push({ startLine, endLine }) + startIndex = foundIndex + 1 + } + + return matches +} + +/** + * Find the closest match to target line numbers using tolerance + */ +export function findClosestMatch( + matches: Match[], + targetStartLine: number, + targetEndLine: number, + lineNumberErrorTolerance: number +): number { + if (matches.length === 0) return -1 + if (matches.length === 1) return 0 + + // Look for exact matches first + for (let i = 0; i < matches.length; i++) { + const match = matches[i] + if (match.startLine === targetStartLine && match.endLine === targetEndLine) { + return i + } + } + + if (lineNumberErrorTolerance === 0) { + return -1 + } + + // Find closest match + let closestIndex = -1 + let minDistance = Number.MAX_SAFE_INTEGER + + for (let i = 0; i < matches.length; i++) { + const distance = Math.abs(matches[i].startLine - targetStartLine) + if (distance < minDistance) { + minDistance = distance + closestIndex = i + } + } + + if (lineNumberErrorTolerance === 1) { + return closestIndex + } + + if (closestIndex === -1) return -1 + + // Find next closest for tolerance calculation + let nextClosestDistance = Number.MAX_SAFE_INTEGER + let nextClosestIndex = -1 + + for (let i = 0; i < matches.length; i++) { + if (i === closestIndex) continue + const distance = Math.abs(matches[i].startLine - targetStartLine) + if (distance < nextClosestDistance) { + nextClosestDistance = distance + nextClosestIndex = i + } + } + + if (nextClosestIndex === -1) return closestIndex + + const distanceBetweenMatches = Math.abs( + matches[nextClosestIndex].startLine - matches[closestIndex].startLine + ) + const toleranceThreshold = Math.floor((distanceBetweenMatches / 2) * lineNumberErrorTolerance) + + return minDistance <= toleranceThreshold ? closestIndex : -1 +} + +// ============================================================================ +// Symbol-based Matching (for fuzzy matching) +// ============================================================================ + +/** + * Split text into symbols for matching + */ +export function splitIntoSymbols(text: string, includeWhitespace = false): string[] { + const symbols: string[] = [] + let currentWord = '' + + for (const char of text) { + if (/[a-zA-Z0-9_]/.test(char)) { + currentWord += char + } else { + if (currentWord) { + symbols.push(currentWord) + currentWord = '' + } + if (includeWhitespace || !/\s/.test(char)) { + symbols.push(char) + } + } + } + + if (currentWord) { + symbols.push(currentWord) + } + + return symbols +} + +/** + * Find Longest Common Subsequence between two symbol arrays + */ +export function findLongestCommonSubsequence( + a: string[], + b: string[], + maxIndexDiff: number = 1000 +): number[] { + const mapping = new Array(a.length).fill(-1) + + // Simple O(n*m) LCS with index difference limit for performance + const dp: number[][] = [] + for (let i = 0; i <= a.length; i++) { + dp[i] = new Array(b.length + 1).fill(0) + } + + for (let i = 1; i <= a.length; i++) { + const minJ = Math.max(1, i - maxIndexDiff) + const maxJ = Math.min(b.length, i + maxIndexDiff) + + for (let j = minJ; j <= maxJ; j++) { + if (a[i - 1] === b[j - 1]) { + dp[i][j] = dp[i - 1][j - 1] + 1 + } else { + dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]) + } + } + } + + // Backtrack to find mapping + let i = a.length + let j = b.length + + while (i > 0 && j > 0) { + if (a[i - 1] === b[j - 1]) { + mapping[i - 1] = j - 1 + i-- + j-- + } else if (dp[i - 1][j] > dp[i][j - 1]) { + i-- + } else { + j-- + } + } + + return mapping +} + +// ============================================================================ +// Fuzzy Matching +// ============================================================================ + +/** + * Performs fuzzy matching for replacement strings + */ +export function fuzzyMatchReplacementStrings( + originalStr: string, + oldStr: string, + newStr: string, + maxDiff: number = 5, + maxDiffRatio: number = 0.2, + minAllMatchStreakBetweenDiffs: number = 3, + computeBudgetIterations: number = 100000 +): { oldStr: string; newStr: string } | MatchFailReason { + const oldSymbols = splitIntoSymbols(oldStr, false) + const originalSymbols = splitIntoSymbols(originalStr, false) + const newSymbols = splitIntoSymbols(newStr, false) + + const maxIndexDiff = Math.ceil(computeBudgetIterations / oldSymbols.length) + const oldToOriginalMapping = findLongestCommonSubsequence(oldSymbols, originalSymbols, maxIndexDiff) + const oldToNewMapping = findLongestCommonSubsequence(oldSymbols, newSymbols, maxIndexDiff) + + // Check first and last symbols + if (oldToOriginalMapping[0] === -1) { + return MatchFailReason.FirstSymbolOfOldStrNotInOriginal + } + if (oldToOriginalMapping[oldSymbols.length - 1] === -1) { + return MatchFailReason.LastSymbolOfOldStrNotInOriginal + } + + const modifiedOldStr: string[] = [] + const modifiedNewStr: string[] = [] + let originalIndex = 0 + let oldIndex = 0 + let newIndex = 0 + let numDiff = 0 + let originalFirstMatchIndex = -1 + let originalMatchStreak = oldSymbols.length + let newMatchStreak = oldSymbols.length + + while (oldIndex < oldSymbols.length) { + if (oldToOriginalMapping[oldIndex] !== -1 && originalFirstMatchIndex === -1) { + originalFirstMatchIndex = oldToOriginalMapping[oldIndex] + } + + if (oldToOriginalMapping[oldIndex] !== -1 && originalIndex < oldToOriginalMapping[oldIndex]) { + if (originalIndex > originalFirstMatchIndex) { + originalMatchStreak = 0 + if (newMatchStreak < minAllMatchStreakBetweenDiffs) { + return MatchFailReason.AmbiguousReplacement + } + numDiff++ + modifiedOldStr.push(originalSymbols[originalIndex]) + modifiedNewStr.push(originalSymbols[originalIndex]) + } + originalIndex++ + } else if (oldToNewMapping[oldIndex] !== -1 && newIndex < oldToNewMapping[oldIndex]) { + newMatchStreak = 0 + if (originalMatchStreak < minAllMatchStreakBetweenDiffs) { + return MatchFailReason.AmbiguousReplacement + } + modifiedNewStr.push(newSymbols[newIndex]) + newIndex++ + } else if ( + oldToOriginalMapping[oldIndex] === originalIndex && + oldToNewMapping[oldIndex] === newIndex + ) { + modifiedOldStr.push(oldSymbols[oldIndex]) + modifiedNewStr.push(newSymbols[newIndex]) + oldIndex++ + originalIndex++ + newIndex++ + originalMatchStreak++ + newMatchStreak++ + } else if (oldToOriginalMapping[oldIndex] === originalIndex) { + if (originalMatchStreak < minAllMatchStreakBetweenDiffs) { + return MatchFailReason.AmbiguousReplacement + } + modifiedOldStr.push(oldSymbols[oldIndex]) + oldIndex++ + originalIndex++ + originalMatchStreak++ + newMatchStreak = 0 + } else if (oldToNewMapping[oldIndex] === newIndex) { + if (newMatchStreak < minAllMatchStreakBetweenDiffs) { + return MatchFailReason.AmbiguousReplacement + } + oldIndex++ + newIndex++ + numDiff++ + originalMatchStreak = 0 + newMatchStreak++ + } else { + return MatchFailReason.SymbolInOldNotInOriginalOrNew + } + } + + while (newIndex < newSymbols.length) { + modifiedNewStr.push(newSymbols[newIndex]) + newIndex++ + } + + if (numDiff > maxDiff) { + return MatchFailReason.ExceedsMaxDiff + } + if (numDiff / oldSymbols.length > maxDiffRatio) { + return MatchFailReason.ExceedsMaxDiffRatio + } + + return { + oldStr: modifiedOldStr.join(''), + newStr: modifiedNewStr.join(''), + } +} + +// ============================================================================ +// Tab Indent Fix +// ============================================================================ + +/** + * Try to fix tab indentation mismatch + */ +export function tryTabIndentFix( + content: string, + oldStr: string, + newStr: string +): { matches: Match[]; oldStr: string; newStr: string } { + const contentIndentation = detectIndentation(content) + const oldStrIndentation = detectIndentation(oldStr) + const newStrIndentation = detectIndentation(newStr) + + if ( + contentIndentation.type === 'tab' && + oldStrIndentation.type === 'tab' && + (newStrIndentation.type === 'tab' || newStr.trim() === '') && + allLinesHaveIndent(oldStr, contentIndentation) && + allLinesHaveIndent(newStr, contentIndentation) + ) { + const currentOldStr = removeOneIndentLevel(oldStr, contentIndentation) + const currentNewStr = removeOneIndentLevel(newStr, contentIndentation) + const matches = findMatches(content, currentOldStr) + + if (matches.length > 0) { + return { matches, oldStr: currentOldStr, newStr: currentNewStr } + } + } + + return { matches: [], oldStr, newStr } +} + +// ============================================================================ +// Snippet Creation +// ============================================================================ + +/** + * Creates a snippet of content around a specific line + */ +export function createSnippet( + content: string, + replacementStartLine: number, + replacementNumLines: number, + snippetContextLines: number +): { snippet: string; startLine: number } { + const startLine = Math.max(0, replacementStartLine - snippetContextLines) + const endLine = replacementStartLine + replacementNumLines - 1 + snippetContextLines + + content = content.replace(/\r\n/g, '\n') + const snippet = content + .split('\n') + .slice(startLine, endLine + 1) + .join('\n') + + return { snippet, startLine } +} + +/** + * Creates a formatted snippet string with line numbers + */ +export function createSnippetStr( + content: string, + replacementStartLine: number, + replacementNumLines: number, + snippetContextLines: number +): string { + const { snippet, startLine } = createSnippet( + content, + replacementStartLine, + replacementNumLines, + snippetContextLines + ) + + return snippet + .split('\n') + .map((line, i) => `${String(i + startLine + 1).padStart(6)}\t${line}`) + .join('\n') +} + +// ============================================================================ +// Enhanced Edit Function +// ============================================================================ + +export interface EnhancedEditOptions { + /** Enable fuzzy matching (default: true) */ + enableFuzzyMatching?: boolean + /** Line number error tolerance 0-1 (default: 0.2) */ + lineNumberErrorTolerance?: number + /** Max diff symbols for fuzzy matching (default: 5) */ + maxDiff?: number + /** Max diff ratio for fuzzy matching (default: 0.2) */ + maxDiffRatio?: number + /** Snippet context lines (default: 4) */ + snippetContextLines?: number +} + +const DEFAULT_OPTIONS: Required = { + enableFuzzyMatching: true, + lineNumberErrorTolerance: 0.2, + maxDiff: 5, + maxDiffRatio: 0.2, + snippetContextLines: 4, +} + +/** + * Enhanced string replacement with fuzzy matching and line number tolerance + */ +export function enhancedStrReplace( + content: string, + oldStr: string, + newStr: string, + options: EnhancedEditOptions = {}, + oldStrStartLineNumber?: number, + oldStrEndLineNumber?: number +): { + success: boolean + newContent?: string + error?: string + matchStartLine?: number + matchEndLine?: number + usedFuzzyMatching?: boolean +} { + const opts = { ...DEFAULT_OPTIONS, ...options } + const normalizedContent = removeTrailingWhitespace(content) + const { content: preparedOldStr } = prepareTextForEditing(oldStr) + const { content: preparedNewStr } = prepareTextForEditing(newStr) + + let workingOldStr = preparedOldStr + let workingNewStr = preparedNewStr + let usedFuzzyMatching = false + + // Check if old_str equals new_str + if (workingOldStr === workingNewStr) { + return { + success: false, + error: 'No changes: old_string and new_string are identical.', + } + } + + // Handle empty old_str for new files + if (workingOldStr.trim() === '') { + if (content.trim() === '') { + return { + success: true, + newContent: workingNewStr, + matchStartLine: 0, + matchEndLine: workingNewStr.split('\n').length - 1, + } + } else { + return { + success: false, + error: 'Cannot use empty old_string on non-empty file.', + } + } + } + + // Find matches + let matches = findMatches(normalizedContent, workingOldStr) + + // Try tab indent fix if no matches + if (matches.length === 0) { + log( 'No verbatim match, trying tab indent fix...') + const tabFixResult = tryTabIndentFix(normalizedContent, workingOldStr, workingNewStr) + matches = tabFixResult.matches + workingOldStr = tabFixResult.oldStr + workingNewStr = tabFixResult.newStr + } + + // Try fuzzy matching if enabled and no matches + if (matches.length === 0 && opts.enableFuzzyMatching && + oldStrStartLineNumber !== undefined && oldStrEndLineNumber !== undefined) { + log( 'No verbatim match, trying fuzzy matching...') + + const snippet = createSnippet( + normalizedContent, + oldStrStartLineNumber, + oldStrEndLineNumber - oldStrStartLineNumber + 1, + 10 + ).snippet + + const fuzzyResult = fuzzyMatchReplacementStrings( + snippet, + workingOldStr, + workingNewStr, + opts.maxDiff, + opts.maxDiffRatio, + 3 + ) + + if (typeof fuzzyResult === 'object' && 'oldStr' in fuzzyResult) { + log( 'Fuzzy match successful') + matches = findMatches(normalizedContent, fuzzyResult.oldStr) + workingOldStr = fuzzyResult.oldStr + workingNewStr = fuzzyResult.newStr + usedFuzzyMatching = matches.length > 0 + } else { + log( `Fuzzy match failed: ${fuzzyResult}`) + } + } + + // No matches found + if (matches.length === 0) { + return { + success: false, + error: 'String to replace not found in file.', + } + } + + // Determine which match to use + let matchIndex = 0 + if (matches.length > 1) { + if (oldStrStartLineNumber === undefined || oldStrEndLineNumber === undefined) { + return { + success: false, + error: `Found ${matches.length} matches. Provide line numbers to disambiguate or add more context.`, + } + } + + matchIndex = findClosestMatch( + matches, + oldStrStartLineNumber, + oldStrEndLineNumber, + opts.lineNumberErrorTolerance + ) + + if (matchIndex === -1) { + return { + success: false, + error: `No match found near the specified line numbers (${oldStrStartLineNumber + 1}, ${oldStrEndLineNumber + 1}).`, + } + } + } + + const match = matches[matchIndex] + + // Perform the replacement + const contentLines = content.split('\n') + const normalizedContentLines = normalizedContent.split('\n') + const linesBeforeMatch = contentLines.slice(0, match.startLine) + const linesAfterMatch = contentLines.slice(match.endLine + 1) + const matchLines = normalizedContentLines.slice(match.startLine, match.endLine + 1).join('\n') + + const matchPosition = matchLines.indexOf(workingOldStr) + if (matchPosition === -1) { + return { + success: false, + error: 'Internal error: Could not find exact position of match.', + } + } + + const beforeMatch = matchLines.substring(0, matchPosition) + const afterMatch = matchLines.substring(matchPosition + workingOldStr.length) + + const newContent = + linesBeforeMatch.join('\n') + + (linesBeforeMatch.length > 0 ? '\n' : '') + + beforeMatch + + workingNewStr + + afterMatch + + (linesAfterMatch.length > 0 ? '\n' : '') + + linesAfterMatch.join('\n') + + return { + success: true, + newContent, + matchStartLine: match.startLine, + matchEndLine: match.startLine + workingNewStr.split('\n').length - 1, + usedFuzzyMatching, + } +} diff --git a/src/utils/agentTools/fileWalk.ts b/src/utils/agentTools/fileWalk.ts new file mode 100644 index 00000000..c150613b --- /dev/null +++ b/src/utils/agentTools/fileWalk.ts @@ -0,0 +1,318 @@ +/** + * FileWalk - High-performance directory traversal with ignore rules + * + * Migrated from AgentTool/09-FileWalk + * + * Features: + * - fdir-based ultra-fast walking (optional, falls back to native fs) + * - Early directory pruning (skip before descend) + * - Binary file extension filtering + * - Integration with IgnoreRulesManager + */ + +import * as fs from 'fs' +import * as fsPromises from 'fs/promises' +import * as path from 'path' +import { IgnoreRulesManager } from './ignoreRulesManager' + +// ============================================================================ +// Known binary file extensions +// ============================================================================ + +/** + * Comprehensive list of binary file extensions to exclude from text processing + */ +export const EXCLUDED_EXTENSIONS = new Set([ + // Images + '.png', '.jpg', '.jpeg', '.gif', '.ico', '.webp', '.svg', '.bmp', '.tiff', '.psd', '.ai', + // Audio/Video + '.mp3', '.mp4', '.wav', '.mov', '.avi', '.flv', '.mkv', '.m4a', '.aac', '.ogg', '.webm', + // Archives + '.zip', '.tar', '.gz', '.rar', '.7z', '.bz2', '.xz', '.iso', + // Executables/Libraries + '.exe', '.dll', '.so', '.dylib', '.o', '.a', '.lib', '.bin', '.app', + // Databases + '.db', '.sqlite', '.sqlite3', '.mdb', '.dat', + // Compiled/Object files + '.class', '.pyc', '.pyo', '.obj', '.out', + // Fonts + '.woff', '.woff2', '.ttf', '.otf', '.eot', '.fon', + // Other binary formats + '.jar', '.war', '.ear', '.apk', '.dmg', '.deb', '.rpm', + // Certificates + '.crt', '.cer', '.pem', '.key', +]) + +// ============================================================================ +// Path utilities +// ============================================================================ + +function normalizeRelativePath(relativePath: string): string { + if (!relativePath) return '.' + const normalized = relativePath + .replace(/\\/g, '/') + .replace(/^\.\//, '') + .replace(/\/+$/, '') + return normalized.length === 0 ? '.' : normalized +} + +function joinRelativePath(parent: string, segment: string): string { + if (!parent || parent === '.' || parent === './') { + return normalizeRelativePath(segment) + } + return normalizeRelativePath(`${parent}/${segment}`) +} + +function toRelativePath(rootDir: string, absolutePath: string): string | null { + const relative = path.relative(rootDir, absolutePath) + if (!relative) return '.' + if (relative.startsWith('..') || path.isAbsolute(relative)) { + return null + } + return normalizeRelativePath(relative) +} + +/** + * Check if a file has an excluded (binary) extension + */ +export function isExcludedExtension( + filepath: string, + additionalExclusions: Set = new Set() +): boolean { + const ext = path.extname(filepath).toLowerCase() + return EXCLUDED_EXTENSIONS.has(ext) || additionalExclusions.has(ext) +} + +// ============================================================================ +// Directory walker with early pruning +// ============================================================================ + +interface WalkOptions { + /** Additional file extensions to exclude */ + additionalExcludedExtensions?: Set + /** Maximum file size in bytes to include (0 = no limit) */ + maxFileSize?: bigint + /** Specific paths to validate instead of full walk */ + relativePathsToConsider?: string[] +} + +/** + * Recursively walk a directory with early pruning based on ignore rules + */ +async function walkDirectoryRecursive( + rootDir: string, + currentRelPath: string, + results: string[], + combinedExclusions: Set +): Promise { + const absolutePath = path.join(rootDir, currentRelPath) + + let entries: fs.Dirent[] + try { + entries = await fsPromises.readdir(absolutePath, { withFileTypes: true }) + } catch { + return + } + + for (const entry of entries) { + const entryRelPath = joinRelativePath(currentRelPath, entry.name) + + if (entry.isDirectory()) { + // Check if we should descend into this directory + const shouldDescend = await IgnoreRulesManager.shouldDescend(rootDir, entryRelPath) + if (shouldDescend) { + await walkDirectoryRecursive(rootDir, entryRelPath, results, combinedExclusions) + } + } else if (entry.isFile()) { + // Check extension exclusion + if (isExcludedExtension(entryRelPath, combinedExclusions)) { + continue + } + + // Check ignore rules + const isIgnored = await IgnoreRulesManager.isIgnored(rootDir, entryRelPath) + if (!isIgnored) { + results.push(entryRelPath) + } + } + } +} + +/** + * Walk a directory tree with ignore rules support + * + * @param walkRoot - The root directory to walk + * @param options - Walk options + * @returns Array of relative file paths that pass all filters + * + * @example + * ```typescript + * const files = await walkFilePaths('/path/to/project', { + * maxFileSize: BigInt(1024 * 1024), // 1MB max + * additionalExcludedExtensions: new Set(['.log']) + * }) + * ``` + */ +export async function walkFilePaths( + walkRoot: string, + options: WalkOptions = {} +): Promise { + const { + additionalExcludedExtensions = new Set(), + maxFileSize = BigInt(0), + relativePathsToConsider, + } = options + + const absoluteDir = path.resolve(walkRoot) + const combinedExclusions = new Set([...additionalExcludedExtensions, ...EXCLUDED_EXTENSIONS]) + + let relativePaths: string[] + + if (relativePathsToConsider && relativePathsToConsider.length > 0) { + // Validate specific paths instead of full walk + const validatedPaths: string[] = [] + + for (const relativePath of relativePathsToConsider) { + // Reject absolute paths + if (path.isAbsolute(relativePath)) { + continue + } + + const normalized = normalizeRelativePath(relativePath) + const absolutePath = path.resolve(absoluteDir, normalized) + const validatedRelative = toRelativePath(absoluteDir, absolutePath) + + if (validatedRelative !== null) { + validatedPaths.push(validatedRelative) + } + } + + relativePaths = validatedPaths + + // Check if any provided paths are ignore files - clear cache if so + const hasIgnoreFile = validatedPaths.some( + (p) => p.endsWith('.gitignore') || p.endsWith('.augmentignore') + ) + if (hasIgnoreFile) { + IgnoreRulesManager.cache.clear() + } + } else { + // Full directory walk + relativePaths = [] + await walkDirectoryRecursive(absoluteDir, '', relativePaths, combinedExclusions) + } + + // Filter by extension and ignore rules if using specific paths + const candidatePaths: string[] = [] + + for (const rawPath of relativePaths) { + const filepath = normalizeRelativePath(rawPath) + if (filepath === '.') continue + + // Skip binary files + if (isExcludedExtension(filepath, combinedExclusions)) { + continue + } + + // Check ignore rules (already done in recursive walk, but needed for specific paths) + if (relativePathsToConsider) { + const isIgnored = await IgnoreRulesManager.isIgnored(absoluteDir, filepath) + if (isIgnored) continue + } + + candidatePaths.push(filepath) + } + + // If no size limit and not validating specific paths, return immediately + if (maxFileSize === BigInt(0) && !relativePathsToConsider) { + return candidatePaths + } + + // Filter by file size in batches + const BATCH_SIZE = 100 + const files: string[] = [] + + for (let i = 0; i < candidatePaths.length; i += BATCH_SIZE) { + const batch = candidatePaths.slice(i, i + BATCH_SIZE) + + const statResults = await Promise.all( + batch.map(async (filepath) => { + try { + const absoluteFilePath = path.join(absoluteDir, filepath) + const stats = await fsPromises.stat(absoluteFilePath) + return { filepath, stats, error: null } + } catch (error) { + return { filepath, stats: null, error } + } + }) + ) + + for (const { filepath, stats, error } of statResults) { + if (error || !stats) continue + + if (relativePathsToConsider) { + // Allow both files and directories when validating specific paths + if (stats.isFile() && maxFileSize > BigInt(0) && BigInt(stats.size) > maxFileSize) { + continue + } + } else { + // Normal mode: only files, check size + if (!stats.isFile()) continue + if (maxFileSize > BigInt(0) && BigInt(stats.size) > maxFileSize) continue + } + + files.push(filepath) + } + } + + return files +} + +/** + * Quick check if a single file should be included based on ignore rules + */ +export async function shouldIncludeFile( + walkRoot: string, + relativePath: string, + additionalExclusions: Set = new Set() +): Promise { + const normalized = normalizeRelativePath(relativePath) + + // Check extension + if (isExcludedExtension(normalized, additionalExclusions)) { + return false + } + + // Check ignore rules + return !(await IgnoreRulesManager.isIgnored(walkRoot, normalized)) +} + +/** + * Get all directories in a path that should be traversed + */ +export async function getTraversableDirectories( + walkRoot: string, + parentPath: string = '' +): Promise { + const absolutePath = path.join(walkRoot, parentPath) + const results: string[] = [] + + let entries: fs.Dirent[] + try { + entries = await fsPromises.readdir(absolutePath, { withFileTypes: true }) + } catch { + return results + } + + for (const entry of entries) { + if (entry.isDirectory()) { + const entryRelPath = joinRelativePath(parentPath, entry.name) + const shouldDescend = await IgnoreRulesManager.shouldDescend(walkRoot, entryRelPath) + if (shouldDescend) { + results.push(entryRelPath) + } + } + } + + return results +} diff --git a/src/utils/agentTools/findLcs.ts b/src/utils/agentTools/findLcs.ts new file mode 100644 index 00000000..f0a97b38 --- /dev/null +++ b/src/utils/agentTools/findLcs.ts @@ -0,0 +1,152 @@ +/** + * Find longest common subsequence of symbols + * Integrated from AgentTool + * + * Uses dynamic programming approach. + * Finds the most optimal solution where the index difference + * for each match between the two sequences is at most maxIndexDiff. + * + * Time and memory complexity: O(N * K) + * where N is the length of sequence A + * and K is the maxIndexDiff + * + * @param symbolsA - First sequence + * @param symbolsB - Second sequence + * @param maxIndexDiff - Maximum index difference allowed + * @returns mapping from index in symbolsA to index in symbolsB, or -1 if not mapped + */ +export function findLongestCommonSubsequence( + symbolsA: string[], + symbolsB: string[], + maxIndexDiff: number = 100 +): number[] { + const n = symbolsA.length + const m = symbolsB.length + + // Initialize the mapping array with -1 (not mapped) + const mapping: number[] = Array.from({ length: n }).fill(-1) + + if (n === 0 || m === 0) { + return mapping + } + + // Create a 2D array to store the length of LCS + // We only need to store values for indices within maxIndexDiff + // For each i, we store values for j where |i-j| <= maxIndexDiff + const dp: number[][] = Array.from({ length: n + 1 }) + .fill([]) + .map(() => Array.from({ length: 2 * maxIndexDiff + 1 }).fill(0)) + + // Store the move direction as [di, dj] tuples for backtracking + type Move = [number, number] // [di, dj] where di and dj are the changes in i and j + const moves: Move[][] = Array.from({ length: n + 1 }) + .fill([]) + .map(() => Array.from({ length: 2 * maxIndexDiff + 1 }).fill([0, 0])) + + // Fill the dp table + for (let i = 1; i <= n; i++) { + const minJ = Math.max(1, i - maxIndexDiff) + const maxJ = Math.min(m, i + maxIndexDiff) + + for (let j = minJ; j <= maxJ; j++) { + // Convert actual j to shifted j index in our dp array + const shiftedJ = j - (i - maxIndexDiff) + + if (symbolsA[i - 1] === symbolsB[j - 1]) { + // If symbols match, extend the previous LCS + const prevI = i - 1 + const prevJ = j - 1 + + // Check if prevJ is within bounds for prevI + if ( + prevJ >= Math.max(1, prevI - maxIndexDiff) && + prevJ <= Math.min(m, prevI + maxIndexDiff) + ) { + const prevShiftedJ = prevJ - (prevI - maxIndexDiff) + dp[i][shiftedJ] = dp[prevI][prevShiftedJ] + 1 + moves[i][shiftedJ] = [-1, -1] // Diagonal move (match): go up and left + } else { + dp[i][shiftedJ] = 1 + moves[i][shiftedJ] = [-1, -1] // Diagonal move for first match + } + } else { + // If symbols don't match, take the maximum of left and up + let leftValue = 0 + let upValue = 0 + + // Check left (i, j-1) + if (j - 1 >= minJ) { + leftValue = dp[i][shiftedJ - 1] + } + + // Check up (i-1, j) + const prevI = i - 1 + if (j >= Math.max(1, prevI - maxIndexDiff) && j <= Math.min(m, prevI + maxIndexDiff)) { + const prevShiftedJ = j - (prevI - maxIndexDiff) + upValue = dp[prevI][prevShiftedJ] + } + + if (leftValue >= upValue) { + dp[i][shiftedJ] = leftValue + moves[i][shiftedJ] = [0, -1] // Left move: stay in same row, go left + } else { + dp[i][shiftedJ] = upValue + moves[i][shiftedJ] = [-1, 0] // Up move: go up, stay in same column + } + } + } + } + + // Backtrack to find the mapping + let i = n + let j = m + + // Find the end position with maximum LCS length + // We only need to check the last row (i == n) since it contains + // the complete information about the longest subsequences + let maxLength = 0 + let maxJPos = m + + // Only need to check the last row (i == n) + const minJ = Math.max(1, n - maxIndexDiff) + const maxJ = Math.min(m, n + maxIndexDiff) + + for (let jCheck = minJ; jCheck <= maxJ; jCheck++) { + const shiftedJ = jCheck - (n - maxIndexDiff) + if (dp[n][shiftedJ] > maxLength) { + maxLength = dp[n][shiftedJ] + maxJPos = jCheck + } + } + + // Start backtracking from the maximum position + i = n + j = maxJPos + + while (i > 0 && j > 0) { + const shiftedJ = j - (i - maxIndexDiff) + + // Check if shiftedJ is within bounds + if (shiftedJ < 0 || shiftedJ >= 2 * maxIndexDiff + 1) { + break + } + + const [di, dj] = moves[i][shiftedJ] + + if (di === -1 && dj === -1) { + // Diagonal move (match) + mapping[i - 1] = j - 1 // Store the mapping + } + + // Apply the move + i += di + j += dj + + // If no move is recorded (both di and dj are 0), break + if (di === 0 && dj === 0) { + break + } + } + + return mapping +} diff --git a/src/utils/agentTools/fuzzyMatcher.ts b/src/utils/agentTools/fuzzyMatcher.ts new file mode 100644 index 00000000..4f776e8d --- /dev/null +++ b/src/utils/agentTools/fuzzyMatcher.ts @@ -0,0 +1,188 @@ +/** + * Fuzzy Matcher - Integrated from AgentTool + * Provides advanced fuzzy matching for string replacement operations + */ + +import { splitIntoSymbols } from './matchLines' +import { findLongestCommonSubsequence } from './findLcs' + +export enum MatchFailReason { + ExceedsMaxDiff = 'ExceedsMaxDiff', + ExceedsMaxDiffRatio = 'ExceedsMaxDiffRatio', + FirstSymbolOfOldStrNotInOriginal = 'FirstSymbolOfOldStrNotInOriginal', + LastSymbolOfOldStrNotInOriginal = 'LastSymbolOfOldStrNotInOriginal', + SymbolInOldNotInOriginalOrNew = 'SymbolInOldNotInOriginalOrNew', + AmbiguousReplacement = 'AmbiguousReplacement', +} + +/** + * Performs fuzzy matching between the original file content, the string to be + * replaced, and the replacement string. + * + * The algorithm works as follows: + * 1. Split original snippet, old and new strings into symbols. Alphanumeric words + * are treated as single symbols, all other characters are treated as separate + * symbols. + * 2. Find the Longest Common Subsequence (LCS) between old and original strings, + * as well as between old and new strings. + * 3. Go through the old string detecting spans of symbols that are different + * between these strings(diff span). + * 4. We require that each diff span is isolated between sections of symbols that + * are matching in all three strings. The minimum section length is controlled + * by minAllMatchStreakBetweenDiffs. If this requirement is not met we return + * AmbiguousReplacement as a fail reason since there is a chance that + * the diffs are overlapping and we cannot determine the correct replacement. + * 5. If there is an isolated diff span between original and old strings, we + * modify both old and new strings to match that. + * 6. We also check that the number of symbols in the difference between old and + * original is under given thresholds in both absolute terms (maxDiff) and + * relative terms (maxDiffRatio). + * 7. If the differences are too large or ambiguous, the function returns a + * MatchFailReason. + * + * @param originalStr Excerpt from the original file that should contain oldStr + * @param oldStr The string to be replaced + * @param newStr The string to replace with + * @param maxDiff Sum of unmatched symbols in oldStr and originalStr allowed + * @param maxDiffRatio In [0, 1] range. Maximum ratio maxDiff / oldStr.length allowed + * @param minAllMatchStreakBetweenDiffs Minimum number of consecutive matching symbols + * required between differences + * @param computeBudgetIterations Maximum number of iterations allowed for LCS + * computation (performance limit) + * @returns Modified oldStr and newStr that can be used for replacement or just the + * MatchFailReason if no good match was found + */ +export function fuzzyMatchReplacementStrings( + originalStr: string, + oldStr: string, + newStr: string, + maxDiff: number = 20, + maxDiffRatio: number = 0.2, + minAllMatchStreakBetweenDiffs: number = 3, + computeBudgetIterations: number = 100 * 1000 +): { oldStr: string; newStr: string } | MatchFailReason { + // Match symbol by symbol + const oldSymbols = splitIntoSymbols(oldStr, false) + const originalSymbols = splitIntoSymbols(originalStr, false) + const newSymbols = splitIntoSymbols(newStr, false) + + const maxIndexDiff = Math.ceil(computeBudgetIterations / oldSymbols.length) + const oldToOriginalMapping = findLongestCommonSubsequence( + oldSymbols, + originalSymbols, + maxIndexDiff + ) + const oldToNewMapping = findLongestCommonSubsequence(oldSymbols, newSymbols, maxIndexDiff) + + // if first or last symbol of oldStr do not have a match then it can be an ambiguous replacement + if (oldToOriginalMapping[0] === -1) { + return MatchFailReason.FirstSymbolOfOldStrNotInOriginal + } + if (oldToOriginalMapping[oldSymbols.length - 1] === -1) { + return MatchFailReason.LastSymbolOfOldStrNotInOriginal + } + + const modifiedOldStr: string[] = [] + const modifiedNewStr: string[] = [] + let originalIndex = 0 + let oldIndex = 0 + let newIndex = 0 + let numDiff = 0 + let originalFirstMatchIndex = -1 + // set initial streaks to max to allow for any diffs at the beginning + let originalMatchStreak = oldSymbols.length + let newMatchStreak = oldSymbols.length + + while (oldIndex < oldSymbols.length) { + if (oldToOriginalMapping[oldIndex] !== -1 && originalFirstMatchIndex === -1) { + originalFirstMatchIndex = oldToOriginalMapping[oldIndex] + } + + if (oldToOriginalMapping[oldIndex] !== -1 && originalIndex < oldToOriginalMapping[oldIndex]) { + // process symbols in original that are not in old + + // do not consider leading symbols in original + // only consider symbols after the first match + if (originalIndex > originalFirstMatchIndex) { + originalMatchStreak = 0 + if (newMatchStreak < minAllMatchStreakBetweenDiffs) { + return MatchFailReason.AmbiguousReplacement + } + numDiff++ + modifiedOldStr.push(originalSymbols[originalIndex]) + modifiedNewStr.push(originalSymbols[originalIndex]) + } + originalIndex++ + } else if (oldToNewMapping[oldIndex] !== -1 && newIndex < oldToNewMapping[oldIndex]) { + // process symbols in new that are not in old + + newMatchStreak = 0 + if (originalMatchStreak < minAllMatchStreakBetweenDiffs) { + return MatchFailReason.AmbiguousReplacement + } + modifiedNewStr.push(newSymbols[newIndex]) + newIndex++ + } else if ( + oldToOriginalMapping[oldIndex] === originalIndex && + oldToNewMapping[oldIndex] === newIndex + ) { + // process symbols that are in both old and new and original + modifiedOldStr.push(oldSymbols[oldIndex]) + modifiedNewStr.push(newSymbols[newIndex]) + oldIndex++ + originalIndex++ + newIndex++ + + originalMatchStreak++ + newMatchStreak++ + } else if (oldToOriginalMapping[oldIndex] === originalIndex) { + // process symbols that are in old and original but not in new + if (originalMatchStreak < minAllMatchStreakBetweenDiffs) { + // Diffs between old vs new and old vs original are too close to each other + // Potentially ambiguous replacement + return MatchFailReason.AmbiguousReplacement + } + + modifiedOldStr.push(oldSymbols[oldIndex]) + oldIndex++ + originalIndex++ + originalMatchStreak++ + newMatchStreak = 0 + } else if (oldToNewMapping[oldIndex] === newIndex) { + // process symbols that are in old and new but not in original + if (newMatchStreak < minAllMatchStreakBetweenDiffs) { + // Diffs between old vs new and old vs original are too close to each other + // Potentially ambiguous replacement + return MatchFailReason.AmbiguousReplacement + } + + // skipping this symbol since it was not in original + // but present in both old and new, so it is not related to the replacement + oldIndex++ + newIndex++ + numDiff++ + originalMatchStreak = 0 + newMatchStreak++ + } else { + // old symbol is not in original and not in new. Potentially ambiguous replacement + return MatchFailReason.SymbolInOldNotInOriginalOrNew + } + } + + while (newIndex < newSymbols.length) { + modifiedNewStr.push(newSymbols[newIndex]) + newIndex++ + } + + if (numDiff > maxDiff) { + return MatchFailReason.ExceedsMaxDiff + } + if (numDiff / oldSymbols.length > maxDiffRatio) { + return MatchFailReason.ExceedsMaxDiffRatio + } + + return { + oldStr: modifiedOldStr.join(''), + newStr: modifiedNewStr.join(''), + } +} diff --git a/src/utils/agentTools/grepEnhancements.ts b/src/utils/agentTools/grepEnhancements.ts new file mode 100644 index 00000000..3158dcad --- /dev/null +++ b/src/utils/agentTools/grepEnhancements.ts @@ -0,0 +1,412 @@ +/** + * Grep Enhancements - Integrated from AgentTool + * Provides context lines display, formatted output, and advanced search options + */ + +import { spawn } from 'child_process' +import { isAbsolute, resolve } from 'path' +import { debug } from '@utils/debugLogger' +import { getCwd } from '@utils/state' + +// Helper for logging +const log = (msg: string) => debug.trace('grep', msg) + +// ============================================================================ +// Types +// ============================================================================ + +export interface EnhancedGrepOptions { + /** Number of context lines before match (default: 0) */ + contextLinesBefore?: number + /** Number of context lines after match (default: 0) */ + contextLinesAfter?: number + /** Case sensitive search (default: false) */ + caseSensitive?: boolean + /** Include glob pattern */ + includeGlob?: string + /** Exclude glob pattern */ + excludeGlob?: string + /** Disable .gitignore and hidden files filtering */ + disableIgnoreFiles?: boolean + /** Output format: 'content' | 'files' | 'json' */ + outputFormat?: 'content' | 'files' | 'json' + /** Max output characters (default: 30000) */ + maxOutputChars?: number + /** Search timeout in ms (default: 30000) */ + timeoutMs?: number + /** Max number of matches (default: 100) */ + maxMatches?: number +} + +export interface GrepMatch { + file: string + lineNumber: number + content: string + isContext?: boolean +} + +export interface EnhancedGrepResult { + matches: GrepMatch[] + formattedOutput: string + matchCount: number + fileCount: number + truncated: boolean + timedOut: boolean + durationMs: number +} + +interface RipgrepResult { + type: 'begin' | 'end' | 'match' | 'context' + data: { + path: { text: string } + lines?: { text: string } + line_number?: number + absolute_offset?: number + submatches?: Array<{ + start: number + end: number + match: { text: string } + }> + } +} + +// ============================================================================ +// Default Options +// ============================================================================ + +const DEFAULT_OPTIONS: Required = { + contextLinesBefore: 0, + contextLinesAfter: 0, + caseSensitive: false, + includeGlob: '', + excludeGlob: '', + disableIgnoreFiles: false, + outputFormat: 'content', + maxOutputChars: 30000, + timeoutMs: 30000, + maxMatches: 100, +} + +// ============================================================================ +// Regex Syntax Guide +// ============================================================================ + +export const REGEX_SYNTAX_GUIDE = ` +Common regex syntax (ripgrep compatible): +- . matches any character +- \\d matches digits [0-9] +- \\w matches word characters [a-zA-Z0-9_] +- \\s matches whitespace +- * zero or more of previous +- + one or more of previous +- ? zero or one of previous +- ^ start of line +- $ end of line +- [abc] character class +- [^abc] negated character class +- (a|b) alternation +- (?:...) non-capturing group +- \\b word boundary +- Escape special chars with \\: \\. \\* \\+ etc. +` + +// ============================================================================ +// Enhanced Grep Function +// ============================================================================ + +/** + * Find ripgrep binary path + */ +async function findRipgrepPath(): Promise { + const paths = ['rg', '/usr/bin/rg', '/usr/local/bin/rg', '/opt/homebrew/bin/rg'] + + for (const rgPath of paths) { + try { + const result = await new Promise((resolve) => { + const proc = spawn(rgPath, ['--version']) + proc.on('close', (code) => resolve(code === 0)) + proc.on('error', () => resolve(false)) + }) + if (result) return rgPath + } catch { + continue + } + } + + return null +} + +/** + * Process ripgrep JSON output into formatted string + */ +function processRipgrepOutput( + jsonLines: string, + workingDir: string, + options: Required +): { output: string; matches: GrepMatch[] } { + const lines = jsonLines.split('\n').filter(line => line.trim()) + let formattedOutput = '' + const matches: GrepMatch[] = [] + let lastLineNumber = -1 + let currentFile = '' + + for (const line of lines) { + try { + const result = JSON.parse(line) as RipgrepResult + + if (result.type === 'begin') { + currentFile = resolve(workingDir, result.data.path.text) + if (options.outputFormat === 'content') { + formattedOutput += `\n=== ${currentFile} ===\n` + } + lastLineNumber = -1 + } else if (result.type === 'end') { + // File end marker + lastLineNumber = -1 + } else if (result.type === 'match' || result.type === 'context') { + const { lines: matchLines, line_number: lineNumber } = result.data + + if (matchLines && lineNumber !== undefined) { + // Add gap indicator if there's a gap in line numbers + if (options.outputFormat === 'content' && + lastLineNumber !== -1 && lineNumber > lastLineNumber + 1) { + formattedOutput += ' ...\n' + } + + const lineContent = matchLines.text.trimEnd() + + if (options.outputFormat === 'content') { + // Format with line number and content + const lineNumStr = lineNumber.toString().padStart(6) + const prefix = result.type === 'match' ? '' : ' ' + formattedOutput += `${prefix}${lineNumStr}\t${lineContent}\n` + } + + matches.push({ + file: currentFile, + lineNumber, + content: lineContent, + isContext: result.type === 'context', + }) + + lastLineNumber = lineNumber + } + } + } catch { + log( `Failed to parse ripgrep output line: ${line}`) + } + } + + return { output: formattedOutput, matches } +} + +/** + * Enhanced grep with context lines and formatted output + */ +export async function enhancedGrep( + pattern: string, + searchPath?: string, + options: EnhancedGrepOptions = {}, + abortSignal?: AbortSignal +): Promise { + const opts = { ...DEFAULT_OPTIONS, ...options } + const startTime = Date.now() + + // Find ripgrep + const rgPath = await findRipgrepPath() + if (!rgPath) { + return { + matches: [], + formattedOutput: 'Error: ripgrep not found. Install with: brew install ripgrep (macOS) or apt install ripgrep (Linux)', + matchCount: 0, + fileCount: 0, + truncated: false, + timedOut: false, + durationMs: Date.now() - startTime, + } + } + + // Determine search directory + const workingDir = searchPath + ? (isAbsolute(searchPath) ? searchPath : resolve(getCwd(), searchPath)) + : getCwd() + + // Build ripgrep arguments + const args: string[] = [ + '--json', + '--no-config', + ] + + if (opts.disableIgnoreFiles) { + args.push('--no-ignore', '--hidden') + } + + if (!opts.caseSensitive) { + args.push('-i') + } + + if (opts.includeGlob) { + args.push('-g', opts.includeGlob) + } + + if (opts.excludeGlob) { + args.push('-g', `!${opts.excludeGlob}`) + } + + args.push('-n') // Show line numbers + + if (opts.contextLinesBefore > 0) { + args.push('--before-context', String(opts.contextLinesBefore)) + } + + if (opts.contextLinesAfter > 0) { + args.push('--after-context', String(opts.contextLinesAfter)) + } + + args.push(pattern) + args.push('.') + + log( `Running: ${rgPath} ${args.join(' ')} in ${workingDir}`) + + return new Promise((resolve) => { + let output = '' + let errorOutput = '' + let processKilled = false + let timedOut = false + let truncated = false + + const timeout = setTimeout(() => { + timedOut = true + processKilled = true + if (rgProcess && !rgProcess.killed) { + rgProcess.kill() + } + }, opts.timeoutMs) + + const rgProcess = spawn(rgPath, args, { cwd: workingDir }) + + rgProcess.stdout.on('data', (data: Buffer) => { + const dataStr = data.toString() + + if (output.length + dataStr.length > opts.maxOutputChars) { + const remainingSpace = opts.maxOutputChars - output.length + if (remainingSpace > 0) { + output += dataStr.substring(0, remainingSpace) + } + truncated = true + processKilled = true + if (!rgProcess.killed) { + rgProcess.kill() + } + } else { + output += dataStr + } + }) + + rgProcess.stderr.on('data', (data: Buffer) => { + errorOutput += data.toString() + }) + + rgProcess.on('close', (code) => { + clearTimeout(timeout) + + if (!processKilled || truncated || timedOut) { + const { output: formattedOutput, matches } = processRipgrepOutput( + output, + workingDir, + opts + ) + + // Count unique files + const fileSet = new Set(matches.filter(m => !m.isContext).map(m => m.file)) + + let finalOutput = formattedOutput + if (truncated) { + finalOutput += `\n[Output truncated at ${opts.maxOutputChars} characters]` + } + if (timedOut) { + finalOutput += `\n[Search timed out after ${opts.timeoutMs / 1000}s]` + } + + // For files mode, just list the files + if (opts.outputFormat === 'files') { + finalOutput = Array.from(fileSet).join('\n') + } + + resolve({ + matches, + formattedOutput: finalOutput.trim() || 'No matches found', + matchCount: matches.filter(m => !m.isContext).length, + fileCount: fileSet.size, + truncated, + timedOut, + durationMs: Date.now() - startTime, + }) + } + }) + + rgProcess.on('error', (err) => { + clearTimeout(timeout) + resolve({ + matches: [], + formattedOutput: `Error: ${err.message}`, + matchCount: 0, + fileCount: 0, + truncated: false, + timedOut: false, + durationMs: Date.now() - startTime, + }) + }) + + // Handle abort signal + if (abortSignal) { + abortSignal.addEventListener('abort', () => { + clearTimeout(timeout) + processKilled = true + if (!rgProcess.killed) { + rgProcess.kill() + } + }, { once: true }) + } + }) +} + +/** + * Simple grep that returns file list (original behavior) + */ +export async function grepFiles( + pattern: string, + searchPath?: string, + includeGlob?: string, + abortSignal?: AbortSignal +): Promise { + const result = await enhancedGrep(pattern, searchPath, { + includeGlob, + outputFormat: 'files', + }, abortSignal) + + if (result.matchCount === 0) { + return [] + } + + const fileSet = new Set(result.matches.filter(m => !m.isContext).map(m => m.file)) + return Array.from(fileSet) +} + +/** + * Grep with context lines + */ +export async function grepWithContext( + pattern: string, + searchPath?: string, + contextLines: number = 3, + options: Partial = {}, + abortSignal?: AbortSignal +): Promise { + return enhancedGrep(pattern, searchPath, { + ...options, + contextLinesBefore: contextLines, + contextLinesAfter: contextLines, + outputFormat: 'content', + }, abortSignal) +} diff --git a/src/utils/agentTools/ignoreRulesManager.ts b/src/utils/agentTools/ignoreRulesManager.ts new file mode 100644 index 00000000..8435af91 --- /dev/null +++ b/src/utils/agentTools/ignoreRulesManager.ts @@ -0,0 +1,605 @@ +/** + * IgnoreRulesManager - Gitignore-style pattern matching for file filtering + * + * Migrated from AgentTool/08-IgnoreRulesManager + * + * Features: + * - Complete .gitignore/.augmentignore support + * - Pattern prefixing for nested directories + * - LRU cache for ignore decisions + * - Directory descent optimization + */ + +import * as fs from 'fs/promises' +import * as path from 'path' + +// ============================================================================ +// Types +// ============================================================================ + +export type Pathname = string + +export interface TestResult { + ignored: boolean + unignored: boolean +} + +export interface IgnoreInstance { + add(patterns: string | IgnoreInstance | readonly (string | IgnoreInstance)[]): IgnoreInstance + addPatterns(patterns: string[]): void + filter(pathnames: readonly Pathname[]): Pathname[] + createFilter(): (pathname: Pathname) => boolean + ignores(pathname: Pathname): boolean + test(pathname: Pathname): TestResult + mergeRulesFrom(other: IgnoreInstance): IgnoreInstance +} + +interface CacheEntry { + lastReadTime: number | undefined + content: string | undefined +} + +interface IgnoreObjectsCacheEntry { + ignoreObj: IgnoreInstance + lastReadTime: number +} + +// ============================================================================ +// Ignore Pattern Engine (node-ignore compatible) +// ============================================================================ + +type Replacer = [RegExp, (...args: any[]) => string] + +function makeArray(subject: T | readonly T[]): T[] { + return Array.isArray(subject) ? (subject as T[]) : [subject as T] +} + +const EMPTY = '' +const SPACE = ' ' +const ESCAPE = '\\' +const REGEX_TEST_BLANK_LINE = /^\s+$/ +const REGEX_INVALID_TRAILING_BACKSLASH = /(?:[^\\]|^)\\$/ +const REGEX_REPLACE_LEADING_EXCAPED_EXCLAMATION = /^\\!/ +const REGEX_REPLACE_LEADING_EXCAPED_HASH = /^\\#/ +const REGEX_SPLITALL_CRLF = /\r?\n/g +const REGEX_TEST_INVALID_PATH = /^\.*\/|^\.+$/ +const SLASH = '/' + +let KEY_IGNORE: string | symbol = 'node-ignore' +if (typeof Symbol !== 'undefined') { + KEY_IGNORE = Symbol.for('node-ignore') +} + +const define = (object: object, key: PropertyKey, value: unknown): void => { + Object.defineProperty(object, key, { value }) +} + +const REGEX_REGEXP_RANGE = /([0-z])-([0-z])/g +const RETURN_FALSE = () => false + +const sanitizeRange = (range: string): string => + range.replace(REGEX_REGEXP_RANGE, (match: string, from: string, to: string) => + from.charCodeAt(0) <= to.charCodeAt(0) ? match : EMPTY + ) + +const cleanRangeBackSlash = (slashes: string): string => { + const { length } = slashes + return slashes.slice(0, length - (length % 2)) +} + +const REPLACERS: Replacer[] = [ + [/\\?\s+$/, (match: string) => (match.indexOf('\\') === 0 ? SPACE : EMPTY)], + [/\\\s/g, () => SPACE], + [/[\\$.|*+(){^]/g, (match: string) => `\\${match}`], + [/(?!\\)\?/g, () => '[^/]'], + [/^\//, () => '^'], + [/\//g, () => '\\/'], + [/^\^*\\\*\\\*\\\//, () => '^(?:.*\\/)?'], + [ + /^(?=[^^])/, + function startingReplacer(this: string): string { + return !/\/(?!$)/.test(this) ? '(?:^|\\/)' : '^' + }, + ], + [ + /\\\/\\\*\\\*(?=\\\/|$)/g, + (_: string, index: number, str: string) => + index + 6 < str.length ? '(?:\\/[^\\/]+)*' : '\\/.+', + ], + [ + /(^|[^\\]+)(\\\*)+(?=.+)/g, + (_: string, p1: string, p2: string) => { + const unescaped = p2.replace(/\\\*/g, '[^\\/]*') + return p1 + unescaped + }, + ], + [/\\\\\\(?=[$.|*+(){^])/g, () => ESCAPE], + [/\\\\/g, () => ESCAPE], + [ + /(\\)?\[([^\]/]*?)(\\*)($|\])/g, + ( + match: string, + leadEscape: string | undefined, + range: string, + endEscape: string, + close: string + ) => + leadEscape === ESCAPE + ? `\\[${range}${cleanRangeBackSlash(endEscape)}${close}` + : close === ']' + ? endEscape.length % 2 === 0 + ? `[${sanitizeRange(range)}${endEscape}]` + : '[]' + : '[]', + ], + [ + /(?:[^*])$/, + (match: string) => + /\/$/.test(match) ? `${match}(?:$|.*)` : `${match}(?=$|\\/$)`, + ], + [ + /(\^|\\\/)?\\\*$/, + (_: string, p1: string | undefined) => { + const prefix = p1 ? `${p1}[^/]+` : '[^/]*' + return `${prefix}(?=$|\\/$)` + }, + ], +] + +const regexCache = Object.create(null) as Record + +const makeRegex = (pattern: string, ignoreCase: boolean): RegExp => { + let source = regexCache[pattern] + if (!source) { + source = REPLACERS.reduce( + (prev, current) => prev.replace(current[0], current[1].bind(pattern)), + pattern + ) + regexCache[pattern] = source + } + return ignoreCase ? new RegExp(source, 'i') : new RegExp(source) +} + +const isString = (subject: unknown): subject is string => typeof subject === 'string' + +const checkPattern = (pattern: unknown): pattern is string => + Boolean( + pattern && + isString(pattern) && + !REGEX_TEST_BLANK_LINE.test(pattern) && + !REGEX_INVALID_TRAILING_BACKSLASH.test(pattern) && + pattern.indexOf('#') !== 0 + ) + +const splitPattern = (pattern: string): string[] => pattern.split(REGEX_SPLITALL_CRLF) + +class IgnoreRule { + constructor( + public origin: string, + public pattern: string, + public negative: boolean, + public regex: RegExp + ) {} +} + +const createRule = (pattern: string, ignoreCase: boolean): IgnoreRule => { + const origin = pattern + let negative = false + + if (pattern.indexOf('!') === 0) { + negative = true + pattern = pattern.substr(1) + } + + pattern = pattern + .replace(REGEX_REPLACE_LEADING_EXCAPED_EXCLAMATION, '!') + .replace(REGEX_REPLACE_LEADING_EXCAPED_HASH, '#') + + const regex = makeRegex(pattern, ignoreCase) + return new IgnoreRule(origin, pattern, negative, regex) +} + +const throwError = (message: string, Ctor: new (message: string) => Error): never => { + throw new Ctor(message) +} + +type DoThrow = (message: string, Ctor: new (message: string) => Error) => boolean | never + +type CheckPathFn = ((path: unknown, originalPath: unknown, doThrow: DoThrow) => boolean) & { + isNotRelative: (path: string) => boolean + convert: (p: unknown) => string +} + +const checkPath: CheckPathFn = (( + path: unknown, + originalPath: unknown, + doThrow: DoThrow +): boolean => { + if (!isString(path)) { + return doThrow(`path must be a string, but got \`${originalPath}\``, TypeError) as boolean + } + if (!path) { + return doThrow('path must not be empty', TypeError) as boolean + } + if (checkPath.isNotRelative(path)) { + const r = '`path.relative()`d' + return doThrow(`path should be a ${r} string, but got "${originalPath}"`, RangeError) as boolean + } + return true +}) as CheckPathFn + +const isNotRelative = (path: string): boolean => REGEX_TEST_INVALID_PATH.test(path) +checkPath.isNotRelative = isNotRelative +checkPath.convert = (p: any): string => p + +/** + * Core Ignore class implementing gitignore-style pattern matching + */ +class Ignore implements IgnoreInstance { + private _rules: IgnoreRule[][] + private _ignoreCase: boolean + private _allowRelativePaths: boolean + private _ignoreCache!: Record + private _added = false + + constructor(options: { ignoreCase?: boolean; allowRelativePaths?: boolean } = {}) { + define(this, KEY_IGNORE, true) + this._rules = [[]] + this._ignoreCase = options.ignoreCase ?? true + this._allowRelativePaths = options.allowRelativePaths ?? false + this._initCache() + } + + private _initCache(): void { + this._ignoreCache = Object.create(null) as Record + } + + private _addPattern(pattern: unknown): void { + if (this._isIgnoreInstance(pattern)) { + this.mergeRulesFrom(pattern as IgnoreInstance) + this._added = true + return + } + if (checkPattern(pattern)) { + const rule = createRule(pattern, this._ignoreCase) + this._added = true + const currentIndex = this._rules.length - 1 + this._rules[currentIndex].push(rule) + } + } + + private _isIgnoreInstance(value: unknown): boolean { + return value !== null && typeof value === 'object' && KEY_IGNORE in value + } + + add(pattern: string | IgnoreInstance | readonly (string | IgnoreInstance)[]): IgnoreInstance { + this._added = false + makeArray(isString(pattern) ? splitPattern(pattern) : pattern).forEach(this._addPattern, this) + if (this._added) { + this._initCache() + } + return this + } + + addPatterns(patterns: string[]) { + this._added = false + const newRules: IgnoreRule[] = [] + for (const pattern of patterns) { + if (checkPattern(pattern)) { + const rule = createRule(pattern, this._ignoreCase) + this._added = true + newRules.push(rule) + } + } + if (newRules.length > 0) { + const currentIndex = this._rules.length - 1 + this._rules[currentIndex].push(...newRules) + } + return this + } + + mergeRulesFrom(other: IgnoreInstance): IgnoreInstance { + const otherIgnore = other as Ignore + if (otherIgnore._rules && otherIgnore._rules.length > 0) { + this._rules = this._rules.concat(otherIgnore._rules) + this._initCache() + } + return this + } + + private _testOne(path: string, _checkUnignored: boolean): TestResult { + let ignored = false + let unignored = false + const localRegexCache = new Map() + + for (const ruleList of this._rules) { + let ruleMatched = false + for (const rule of ruleList) { + const { negative, regex } = rule + let matched: boolean + if (localRegexCache.has(regex)) { + matched = localRegexCache.get(regex)! + } else { + matched = regex.test(path) + localRegexCache.set(regex, matched) + } + if (matched) { + ignored = !negative + unignored = negative + ruleMatched = true + } + } + if (ruleMatched) { + return { ignored, unignored } + } + } + return { ignored, unignored } + } + + private _test( + originalPath: Pathname, + cache: Record, + checkUnignored: boolean + ): TestResult { + const pathStr = originalPath ? checkPath.convert(originalPath) : '' + checkPath(pathStr, originalPath, this._allowRelativePaths ? RETURN_FALSE : throwError) + if (pathStr in cache) { + return cache[pathStr] + } + const result = this._testOne(pathStr, checkUnignored) + cache[pathStr] = result + return result + } + + ignores(path: Pathname): boolean { + return this._test(path, this._ignoreCache, false).ignored + } + + createFilter(): (pathname: Pathname) => boolean { + return (path: Pathname) => !this.ignores(path) + } + + filter(paths: readonly Pathname[]): Pathname[] { + return makeArray(paths).filter(this.createFilter()) + } + + test(path: Pathname): TestResult { + return this._test(path, this._ignoreCache, true) + } +} + +/** + * Factory function to create Ignore instances + */ +export function createIgnore(options?: { ignoreCase?: boolean; allowRelativePaths?: boolean }): IgnoreInstance { + return new Ignore(options) +} + +// ============================================================================ +// Ignore Cache +// ============================================================================ + +/** + * IgnoreCache - TTL-based caching for ignore files and parsed objects + */ +export class IgnoreCache { + private static readonly TTL_MS = 5 * 60 * 1000 // 5 minutes + private cache = new Map() + private ignoreObjectsCache = new Map() + + async exists(filePath: string): Promise { + const entry = await this.getCachedOrFetch(filePath) + return entry.content !== undefined + } + + async read(filePath: string, _encoding?: string): Promise { + const entry = await this.getCachedOrFetch(filePath) + if (entry.content === undefined) { + throw new Error(`File not found: ${filePath}`) + } + return entry.content + } + + private async getCachedOrFetch(filePath: string): Promise { + const now = Date.now() + const cached = this.cache.get(filePath) + + if (cached && cached.lastReadTime && now - cached.lastReadTime < IgnoreCache.TTL_MS) { + return cached + } + + let content: string | undefined + try { + content = await fs.readFile(filePath, 'utf8') + } catch { + content = undefined + } + + const entry: CacheEntry = { lastReadTime: now, content } + this.cache.set(filePath, entry) + return entry + } + + getIgnoreObjects(dirPath: string): IgnoreObjectsCacheEntry | undefined { + const cached = this.ignoreObjectsCache.get(dirPath) + if (!cached) return undefined + + const now = Date.now() + if (now - cached.lastReadTime >= IgnoreCache.TTL_MS) { + this.ignoreObjectsCache.delete(dirPath) + return undefined + } + return cached + } + + setIgnoreObjects(dirPath: string, ignoreObj: IgnoreInstance): void { + this.ignoreObjectsCache.set(dirPath, { + ignoreObj, + lastReadTime: Date.now(), + }) + } + + clear(): void { + this.cache.clear() + this.ignoreObjectsCache.clear() + } +} + +// ============================================================================ +// Path utilities +// ============================================================================ + +function basename(filepath: string): string { + const lastSlash = filepath.lastIndexOf('/') + return lastSlash === -1 ? filepath : filepath.slice(lastSlash + 1) +} + +function dirname(filepath: string): string { + const lastSlash = filepath.lastIndexOf('/') + if (lastSlash === -1) return '' + return filepath.slice(0, lastSlash) +} + +function join(...parts: string[]): string { + return parts.filter(Boolean).join('/') +} + +// ============================================================================ +// IgnoreRulesManager +// ============================================================================ + +/** + * IgnoreRulesManager - Manages .gitignore and .augmentignore rules + * + * Supports: + * - Hierarchical rule inheritance from parent directories + * - Pattern prefixing for nested directories + * - .augmentignore with higher precedence than .gitignore + */ +export class IgnoreRulesManager { + static cache = new IgnoreCache() + + /** + * Prefix a pattern based on the directory path + */ + static prefixPattern(line: string, dirPath: string): string { + if (dirPath.endsWith('/')) { + dirPath = dirPath.slice(0, -1) + } + + // Negation patterns + if (line.startsWith('!')) { + const pattern = line.substring(1) + if (pattern.startsWith('/')) { + return '!' + dirPath + pattern + } + return '!' + dirPath + '/**/' + pattern + } + + // Anchored patterns + if (line.startsWith('/')) { + return dirPath + line + } + + // Regular patterns + return dirPath + '/**/' + line + } + + /** + * Parse ignore rules from file content + */ + static parseIgnoreRules(content: string, dirPath: string): string[] { + const lines = content + .split('\n') + .map((line) => (line.endsWith('\r') ? line.slice(0, -1) : line)) + .filter((line) => line.trim() && !line.trim().startsWith('#')) + + if (dirPath === '') { + return lines + } + + return lines.map((line) => this.prefixPattern(line, dirPath)) + } + + /** + * Get or build accumulated ignore objects for a directory + */ + static async getOrBuildIgnoreObjects( + walkRoot: string, + dirPath: string + ): Promise<{ ignoreObj: IgnoreInstance }> { + if (dirPath === '.') { + dirPath = '' + } + + const cacheKey = walkRoot + '#' + dirPath + const cached = this.cache.getIgnoreObjects(cacheKey) + if (cached) { + return { ignoreObj: cached.ignoreObj } + } + + // Build ignore objects + const augmentignorePath = join(walkRoot, dirPath, '.augmentignore') + const gitignorePath = join(walkRoot, dirPath, '.gitignore') + + let dirIgnoreObj = createIgnore() + const ruleList: string[] = [] + + // Read .gitignore + if (await this.cache.exists(gitignorePath)) { + const gitignoreContent = await this.cache.read(gitignorePath, 'utf8') + ruleList.push(...this.parseIgnoreRules(gitignoreContent, dirPath)) + } + + // Read .augmentignore (higher precedence) + if (await this.cache.exists(augmentignorePath)) { + const augmentignoreContent = await this.cache.read(augmentignorePath, 'utf8') + ruleList.push(...this.parseIgnoreRules(augmentignoreContent, dirPath)) + } + + dirIgnoreObj.addPatterns(ruleList) + + // Inherit from parent + if (dirPath !== '') { + const pieces = dirPath.split('/').filter(Boolean) + const parentDirPath = pieces.slice(0, -1).join('/') + const parentResult = await this.getOrBuildIgnoreObjects(walkRoot, parentDirPath) + if (parentResult && parentResult.ignoreObj) { + dirIgnoreObj.mergeRulesFrom(parentResult.ignoreObj) + } + } + + this.cache.setIgnoreObjects(cacheKey, dirIgnoreObj) + return { ignoreObj: dirIgnoreObj } + } + + /** + * Check if a file is ignored + */ + static async isIgnored(walkRoot: string, filepath: string): Promise { + if (filepath === '.') return false + const dirPath = dirname(filepath) + const { ignoreObj } = await this.getOrBuildIgnoreObjects(walkRoot, dirPath) + return ignoreObj.ignores(filepath) + } + + /** + * Check if traversal should descend into a directory + */ + static async shouldDescend(walkRoot: string, filepath: string): Promise { + if (!filepath || filepath === '.') { + return true + } + + const name = basename(filepath) + if (name === '.git') { + return false + } + + const { ignoreObj } = await this.getOrBuildIgnoreObjects(walkRoot, filepath) + const normalizedPath = filepath.endsWith('/') ? filepath : `${filepath}/` + const { ignored, unignored } = ignoreObj.test(normalizedPath) + + return !ignored || unignored + } +} diff --git a/src/utils/agentTools/index.ts b/src/utils/agentTools/index.ts new file mode 100644 index 00000000..89db0aa7 --- /dev/null +++ b/src/utils/agentTools/index.ts @@ -0,0 +1,369 @@ +/** + * AgentTools - Integrated utilities from AgentTool + * + * This module provides enhanced functionality integrated from the AgentTool codebase: + * - Memory System: Snapshot management, pending memories, update notifications + * - Edit Enhancements: Fuzzy matching, line number tolerance, advanced text processing + * - Grep Enhancements: Context lines, formatted output, advanced search options + * - Shell Tool: Safe command execution with YAML-based allowlist and timeout prediction + * - LCS Algorithm: Longest Common Subsequence for symbol matching + * - Line Matching: Fuzzy line matching based on symbols + */ + +// Memory System +export { + // Types + type MemoryEntry, + type PendingMemoryEntry, + type PendingMemoriesState, + type MemoryState, + type MemoryInfoWithState, + // Classes + MemorySnapshotManager, + PendingMemoriesStore, + MemoryUpdateManager, + // Factory functions + getMemorySnapshotManager, + getMemoryUpdateManager, + getPendingMemoriesStore, + disposeMemorySystem, +} from './memorySystem' + +// Edit Enhancements +export { + // Types + type Match, + type EditResult, + type IndentInfo, + type EnhancedEditOptions, + MatchFailReason, + // Text processing + removeTrailingWhitespace, + normalizeLineEndings, + detectLineEnding, + restoreLineEndings, + prepareTextForEditing, + // Indentation + detectIndentation, + removeOneIndentLevel, + allLinesHaveIndent, + removeAllIndents, + // Match finding + findMatches, + findClosestMatch, + // Fuzzy matching + splitIntoSymbols, + findLongestCommonSubsequence, + fuzzyMatchReplacementStrings, + tryTabIndentFix, + // Snippets + createSnippet, + createSnippetStr, + // Main function + enhancedStrReplace, +} from './editEnhancements' + +// LCS Algorithm (advanced - from AgentTool) +export { + findLongestCommonSubsequence as findLCS, +} from './findLcs' + +// Fuzzy Line Matching (advanced - from AgentTool) +export { + splitIntoSymbols as splitSymbols, + fuzzyMatchLines, +} from './matchLines' + +// Advanced Fuzzy Matcher (from AgentTool) +export { + MatchFailReason as FuzzyMatchFailReason, + fuzzyMatchReplacementStrings as advancedFuzzyMatch, +} from './fuzzyMatcher' + +// Grep Enhancements +export { + // Types + type EnhancedGrepOptions, + type GrepMatch, + type EnhancedGrepResult, + // Constants + REGEX_SYNTAX_GUIDE, + // Functions + enhancedGrep, + grepFiles, + grepWithContext, +} from './grepEnhancements' + +// Shell Tool +export { + // Types + type ShellResult, + type ShellOptions, + type ShellAllowlist, + type ShellAllowlistEntry, + type ShellType, + // Command safety + isCommandSafe, + isCommandBanned, + getShellAllowlist, + predictCommandTimeout, + // Execution + quoteCommand, + getDefaultShell, + executeShell, + simpleShell, + executeShellSequence, + // Utilities + parseCommand, + isDirectoryChange, + getShellType, +} from './shellTool' + +// Ignore Rules Manager +export { + // Types + type Pathname, + type TestResult, + type IgnoreInstance, + // Factory + createIgnore, + // Classes + IgnoreCache, + IgnoreRulesManager, +} from './ignoreRulesManager' + +// File Walk (high-performance directory traversal) +export { + // Constants + EXCLUDED_EXTENSIONS, + // Functions + walkFilePaths, + shouldIncludeFile, + getTraversableDirectories, + isExcludedExtension, +} from './fileWalk' + +// Locate Snippet (fuzzy code location) +export { + // Types + type SnippetLocation, + type LocateOptions, + // Functions + fuzzyLocateSnippet, + locateSnippetWithQuality, + findAllSnippetOccurrences, +} from './locateSnippet' + +// Apply Patch (V4A diff format) +export { + // Types + ActionType, + type Chunk, + type PatchAction, + type Patch, + DiffError, + // Parsing + Parser as PatchParser, + textToPatch, + extractFilePaths, + identifyFilesNeeded, + identifyFilesAffected, + // Applying + getUpdatedFile, + applyPatch, + // Constants + PATCH_PREFIX, + PATCH_SUFFIX, + ADD_FILE_PREFIX, + DELETE_FILE_PREFIX, + UPDATE_FILE_PREFIX, +} from './applyPatch' + +// Checkpoint Manager (file state tracking) +export { + // Types + type QualifiedPathName, + type DiffViewDocument, + type HydratedCheckpoint, + type FileChangeSummary, + type AggregateCheckpointInfo, + type CheckpointKey, + EditEventSource, + // Functions + createQualifiedPathName, + createDiffViewDocument, + createRequestId, + computeChangesSummary, + // Classes + CheckpointManager, + getCheckpointManager, +} from './checkpointManager' + +// Task Manager (hierarchical tasks) +export { + // Types + type SerializedTask, + type HydratedTask, + type TaskMetadata, + type TaskManifest, + type TaskStorage, + TaskState, + TaskUpdatedBy, + // Classes + TaskFactory, + TaskManager, + InMemoryTaskStorage, + // Functions + diffTaskTrees, + getTaskManager, +} from './taskManager' + +// Workspace Utils (path utilities) +export { + // Types + type QualifiedPathName as WorkspacePathName, + type Workspace, + FileType, + // Path functions + createQualifiedPath, + parseAbsolutePath, + normalizeRelativePath, + joinPath, + getDirname, + getBasename, + getExtension, + // Blob functions + sha256, + calculateBlobName, + calculateFileBlobName, + // Glob functions + matchGlob, + filterByGlobs, + // File type detection + detectFileType, + // Classes + WorkspaceManager, + getWorkspaceManager, +} from './workspaceUtils' + +// Lifecycle Management (disposable patterns) +export { + // Types + type IDisposable, + type IAsyncDisposable, + type Disposable, + type EventListener, + // Functions + isDisposable, + combineDisposables, + disposableTimeout, + disposableInterval, + onDispose, + // Constants + EmptyDisposable, + // Classes + DisposableCollection, + DisposableService, + EventEmitter, +} from './lifecycle' + +// Command Timeout Predictor (smart timeout learning) +export { + // Types + type CommandExecutionRecord, + type CommandStats, + type TimeoutPredictorStorage, + // Storage implementations + FileTimeoutPredictorStorage, + InMemoryTimeoutPredictorStorage, + // Classes + CommandTimeoutPredictor, + getTimeoutPredictor, +} from './commandTimeoutPredictor' + +// Shell Allowlist (auto-approval rules) +export { + // Types + type AllowlistRuleType, + type ShellAllowlistEntry as AllowlistEntry, + type ShellAllowlist as Allowlist, + type ShellType as AllowlistShellType, + // Functions + parseCommandNaive, + getShellAllowlist as getAutoApprovalAllowlist, + checkShellAllowlist, + isCommandAutoApproved, + extendAllowlist, +} from './shellAllowlist' + +// Observable (reactive state management) +export { + // Types + type ObservableListener, + type ObservablePredicate, + type EqualityFn, + type Unlisten, + type ReadonlyObservable, + // Classes + Observable, + // Functions + asReadonly, + combineObservables, + observableFromPromise, + deepEqual, +} from './observable' + +// Promise Utilities (async helpers) +export { + // Types + type BackoffParams, + type RetryBackoffResult, + type RetryParams, + type RetryLogger, + // Constants + defaultBackoffParams, + // Basic utilities + delayMs, + timeoutPromise, + // Retry functions + isRetryableError, + retryWithBackoff, + retryWithTimes, + // Classes + DeferredPromise, + // Timeout wrappers + withTimeout, + withTimeoutFn, + // Concurrent execution + parallelLimit, + allSettledSimple, + raceWithTimeout, + // Utility functions + isPromise, + ignoreError, + promisify, + debounceAsync, +} from './promiseUtils' + +// KV Store (key-value storage system) +export { + // Types + type KvIteratorOptions, + type KvBatchOperation, + type IKvStore, + type StoredExchange, + type ConversationMetadata, + type StoredConversationHistory, + type ConversationHistoryMetadata, + // KV Store implementations + InMemoryKvStore, + FileKvStore, + // Managers + ExchangeManager, + HistoryManager, + // Singleton factories + getKvStore, + getExchangeManager, + getHistoryManager, + resetKvStoreSingletons, +} from './kvStore' diff --git a/src/utils/agentTools/kvStore.ts b/src/utils/agentTools/kvStore.ts new file mode 100644 index 00000000..25771f04 --- /dev/null +++ b/src/utils/agentTools/kvStore.ts @@ -0,0 +1,749 @@ +/** + * KV Store - File-based key-value storage system + * + * Migrated from AgentTool/17-StorageSystem (simplified without LevelDB/gRPC) + * + * Features: + * - Simple file-based JSON storage + * - In-memory caching with write-through + * - Batch operations for consistency + * - Range queries and iteration + * - Conversation and exchange management + */ + +import * as fs from 'fs/promises' +import * as path from 'path' + +// ============================================================================ +// Types +// ============================================================================ + +/** + * Iterator options for range queries + */ +export interface KvIteratorOptions { + /** Greater than or equal to */ + gte?: string + /** Greater than */ + gt?: string + /** Less than or equal to */ + lte?: string + /** Less than */ + lt?: string + /** Maximum number of results */ + limit?: number + /** Reverse order */ + reverse?: boolean +} + +/** + * Batch operation type + */ +export type KvBatchOperation = + | { type: 'put'; key: string; value: string } + | { type: 'del'; key: string } + +/** + * KV Store interface + */ +export interface IKvStore { + get(key: string): Promise + getMany(keys: string[]): Promise<(string | undefined)[]> + put(key: string, value: string): Promise + delete(key: string): Promise + batch(operations: KvBatchOperation[]): Promise + keys(options?: KvIteratorOptions): AsyncIterable + iterator(options?: KvIteratorOptions): AsyncIterable<[string, string]> + close(): Promise +} + +// ============================================================================ +// In-Memory KV Store +// ============================================================================ + +/** + * In-memory key-value store + * Useful for testing and temporary storage + */ +export class InMemoryKvStore implements IKvStore { + private _data = new Map() + + async get(key: string): Promise { + return this._data.get(key) + } + + async getMany(keys: string[]): Promise<(string | undefined)[]> { + return keys.map((key) => this._data.get(key)) + } + + async put(key: string, value: string): Promise { + this._data.set(key, value) + } + + async delete(key: string): Promise { + this._data.delete(key) + } + + async batch(operations: KvBatchOperation[]): Promise { + for (const op of operations) { + if (op.type === 'put') { + this._data.set(op.key, op.value) + } else if (op.type === 'del') { + this._data.delete(op.key) + } + } + } + + async *keys(options?: KvIteratorOptions): AsyncIterable { + for (const key of this._getSortedKeys(options)) { + yield key + } + } + + async *iterator(options?: KvIteratorOptions): AsyncIterable<[string, string]> { + for (const key of this._getSortedKeys(options)) { + const value = this._data.get(key) + if (value !== undefined) { + yield [key, value] + } + } + } + + async close(): Promise { + // No-op for in-memory store + } + + private _getSortedKeys(options?: KvIteratorOptions): string[] { + let keys = Array.from(this._data.keys()).sort() + + if (options?.reverse) { + keys = keys.reverse() + } + + keys = keys.filter((key) => { + if (options?.gte && key < options.gte) return false + if (options?.gt && key <= options.gt) return false + if (options?.lte && key > options.lte) return false + if (options?.lt && key >= options.lt) return false + return true + }) + + if (options?.limit) { + keys = keys.slice(0, options.limit) + } + + return keys + } + + /** + * Clear all data (for testing) + */ + clear(): void { + this._data.clear() + } + + /** + * Get current size + */ + get size(): number { + return this._data.size + } +} + +// ============================================================================ +// File-based KV Store +// ============================================================================ + +/** + * File-based key-value store + * Uses JSON file for persistence with in-memory caching + */ +export class FileKvStore implements IKvStore { + private _data = new Map() + private _loaded = false + private _filePath: string + private _saveDebounceTimer: ReturnType | null = null + private _saveDebounceMs: number + private _dirty = false + + constructor(filePath: string, options?: { saveDebounceMs?: number }) { + this._filePath = filePath + this._saveDebounceMs = options?.saveDebounceMs ?? 100 + } + + private async _ensureLoaded(): Promise { + if (this._loaded) return + + try { + const content = await fs.readFile(this._filePath, 'utf-8') + const data = JSON.parse(content) as Record + this._data = new Map(Object.entries(data)) + } catch (error) { + // File doesn't exist or is invalid, start with empty store + this._data = new Map() + } + + this._loaded = true + } + + private _scheduleSave(): void { + this._dirty = true + + if (this._saveDebounceTimer) { + clearTimeout(this._saveDebounceTimer) + } + + this._saveDebounceTimer = setTimeout(() => { + void this._save() + }, this._saveDebounceMs) + } + + private async _save(): Promise { + if (!this._dirty) return + + const dir = path.dirname(this._filePath) + await fs.mkdir(dir, { recursive: true }) + + const data = Object.fromEntries(this._data) + await fs.writeFile(this._filePath, JSON.stringify(data, null, 2), 'utf-8') + + this._dirty = false + } + + async get(key: string): Promise { + await this._ensureLoaded() + return this._data.get(key) + } + + async getMany(keys: string[]): Promise<(string | undefined)[]> { + await this._ensureLoaded() + return keys.map((key) => this._data.get(key)) + } + + async put(key: string, value: string): Promise { + await this._ensureLoaded() + this._data.set(key, value) + this._scheduleSave() + } + + async delete(key: string): Promise { + await this._ensureLoaded() + this._data.delete(key) + this._scheduleSave() + } + + async batch(operations: KvBatchOperation[]): Promise { + await this._ensureLoaded() + + for (const op of operations) { + if (op.type === 'put') { + this._data.set(op.key, op.value) + } else if (op.type === 'del') { + this._data.delete(op.key) + } + } + + this._scheduleSave() + } + + async *keys(options?: KvIteratorOptions): AsyncIterable { + await this._ensureLoaded() + + for (const key of this._getSortedKeys(options)) { + yield key + } + } + + async *iterator(options?: KvIteratorOptions): AsyncIterable<[string, string]> { + await this._ensureLoaded() + + for (const key of this._getSortedKeys(options)) { + const value = this._data.get(key) + if (value !== undefined) { + yield [key, value] + } + } + } + + async close(): Promise { + if (this._saveDebounceTimer) { + clearTimeout(this._saveDebounceTimer) + this._saveDebounceTimer = null + } + + await this._save() + } + + private _getSortedKeys(options?: KvIteratorOptions): string[] { + let keys = Array.from(this._data.keys()).sort() + + if (options?.reverse) { + keys = keys.reverse() + } + + keys = keys.filter((key) => { + if (options?.gte && key < options.gte) return false + if (options?.gt && key <= options.gt) return false + if (options?.lte && key > options.lte) return false + if (options?.lt && key >= options.lt) return false + return true + }) + + if (options?.limit) { + keys = keys.slice(0, options.limit) + } + + return keys + } + + /** + * Force save immediately + */ + async flush(): Promise { + if (this._saveDebounceTimer) { + clearTimeout(this._saveDebounceTimer) + this._saveDebounceTimer = null + } + await this._save() + } + + /** + * Get current size + */ + get size(): number { + return this._data.size + } +} + +// ============================================================================ +// Exchange Manager +// ============================================================================ + +/** + * Stored exchange type + */ +export interface StoredExchange { + uuid: string + conversationId: string + timestamp: number + data: unknown +} + +/** + * Conversation metadata + */ +export interface ConversationMetadata { + conversationId: string + totalExchanges: number + lastUpdated: number +} + +/** + * Exchange Manager - High-level API for exchange storage + * + * Features: + * - Conversation-based storage + * - Efficient prefix scanning + * - Atomic batch operations + */ +export class ExchangeManager { + private _kvStore: IKvStore + private static readonly RANGE_SUFFIX = '\xFF' + + constructor(kvStore: IKvStore) { + this._kvStore = kvStore + } + + private _getExchangeKey(conversationId: string, exchangeUuid: string): string { + return `exchange:${conversationId}:${exchangeUuid}` + } + + private _getExchangePrefix(conversationId: string): string { + return `exchange:${conversationId}:` + } + + private _getMetadataKey(conversationId: string): string { + return `metadata:${conversationId}` + } + + /** + * Load metadata for a conversation + */ + async loadMetadata(conversationId: string): Promise { + const key = this._getMetadataKey(conversationId) + const data = await this._kvStore.get(key) + + if (!data) return undefined + + try { + return JSON.parse(data) as ConversationMetadata + } catch { + return undefined + } + } + + /** + * Load exchanges by UUIDs + */ + async loadExchangesByUuids( + conversationId: string, + uuids: string[] + ): Promise { + if (uuids.length === 0) return [] + + const keys = uuids.map((uuid) => this._getExchangeKey(conversationId, uuid)) + const values = await this._kvStore.getMany(keys) + + const exchanges: StoredExchange[] = [] + for (const value of values) { + if (value) { + try { + exchanges.push(JSON.parse(value) as StoredExchange) + } catch { + // Skip invalid entries + } + } + } + + return exchanges + } + + /** + * Save exchanges with upsert semantics + */ + async saveExchanges(conversationId: string, exchanges: StoredExchange[]): Promise { + if (exchanges.length === 0) return + + // Get existing metadata + const existingMetadata = await this.loadMetadata(conversationId) + const currentCount = existingMetadata?.totalExchanges || 0 + + // Check which exchanges already exist + const existingKeys = new Set() + const prefix = this._getExchangePrefix(conversationId) + + for await (const key of this._kvStore.keys({ + gte: prefix, + lt: prefix + ExchangeManager.RANGE_SUFFIX, + })) { + existingKeys.add(key) + } + + // Count new exchanges + let newCount = 0 + for (const exchange of exchanges) { + const key = this._getExchangeKey(conversationId, exchange.uuid) + if (!existingKeys.has(key)) { + newCount++ + } + } + + // Build batch operations + const operations: KvBatchOperation[] = [ + ...exchanges.map((exchange) => ({ + type: 'put' as const, + key: this._getExchangeKey(conversationId, exchange.uuid), + value: JSON.stringify({ ...exchange, conversationId }), + })), + { + type: 'put' as const, + key: this._getMetadataKey(conversationId), + value: JSON.stringify({ + conversationId, + totalExchanges: currentCount + newCount, + lastUpdated: Date.now(), + }), + }, + ] + + await this._kvStore.batch(operations) + } + + /** + * Delete exchanges + */ + async deleteExchanges(conversationId: string, uuids: string[]): Promise { + if (uuids.length === 0) return + + const existingMetadata = await this.loadMetadata(conversationId) + const currentCount = existingMetadata?.totalExchanges || 0 + + const operations: KvBatchOperation[] = [ + ...uuids.map((uuid) => ({ + type: 'del' as const, + key: this._getExchangeKey(conversationId, uuid), + })), + { + type: 'put' as const, + key: this._getMetadataKey(conversationId), + value: JSON.stringify({ + conversationId, + totalExchanges: Math.max(0, currentCount - uuids.length), + lastUpdated: Date.now(), + }), + }, + ] + + await this._kvStore.batch(operations) + } + + /** + * Delete all exchanges for a conversation + */ + async deleteConversationExchanges(conversationId: string): Promise { + const prefix = this._getExchangePrefix(conversationId) + const keysToDelete: string[] = [] + + for await (const key of this._kvStore.keys({ + gte: prefix, + lt: prefix + ExchangeManager.RANGE_SUFFIX, + })) { + keysToDelete.push(key) + } + + if (keysToDelete.length === 0) return + + const operations: KvBatchOperation[] = [ + ...keysToDelete.map((key) => ({ + type: 'del' as const, + key, + })), + { + type: 'del' as const, + key: this._getMetadataKey(conversationId), + }, + ] + + await this._kvStore.batch(operations) + } + + /** + * Load all exchanges for a conversation + */ + async loadConversationExchanges(conversationId: string): Promise { + const prefix = this._getExchangePrefix(conversationId) + const exchanges: StoredExchange[] = [] + + for await (const [_, value] of this._kvStore.iterator({ + gte: prefix, + lt: prefix + ExchangeManager.RANGE_SUFFIX, + })) { + try { + exchanges.push(JSON.parse(value) as StoredExchange) + } catch { + // Skip invalid entries + } + } + + return exchanges + } + + /** + * Count exchanges in a conversation + */ + async countExchanges(conversationId: string): Promise { + const metadata = await this.loadMetadata(conversationId) + return metadata?.totalExchanges || 0 + } + + /** + * Close the store + */ + async close(): Promise { + await this._kvStore.close() + } +} + +// ============================================================================ +// History Manager +// ============================================================================ + +/** + * Stored conversation history + */ +export interface StoredConversationHistory { + conversationId: string + chatHistoryJson: string + timestamp: number + itemCount: number + hasExchanges: boolean +} + +/** + * Conversation history metadata + */ +export interface ConversationHistoryMetadata { + conversationId: string + lastUpdated: number + itemCount: number + hasExchanges: boolean +} + +/** + * History Manager - API for conversation history storage + */ +export class HistoryManager { + private _kvStore: IKvStore + + constructor(kvStore: IKvStore) { + this._kvStore = kvStore + } + + private _getHistoryKey(conversationId: string): string { + return `history:${conversationId}` + } + + private _getHistoryMetadataKey(conversationId: string): string { + return `history-metadata:${conversationId}` + } + + /** + * Load conversation history + */ + async loadConversationHistory(conversationId: string): Promise { + const key = this._getHistoryKey(conversationId) + const data = await this._kvStore.get(key) + + if (!data) return [] + + try { + const stored = JSON.parse(data) as StoredConversationHistory + return JSON.parse(stored.chatHistoryJson) as T[] + } catch { + return [] + } + } + + /** + * Save conversation history + */ + async saveConversationHistory(conversationId: string, chatHistory: T[]): Promise { + const itemCount = chatHistory.length + const chatHistoryJson = JSON.stringify(chatHistory) + const timestamp = Date.now() + + const storedHistory: StoredConversationHistory = { + conversationId, + chatHistoryJson, + timestamp, + itemCount, + hasExchanges: itemCount > 0, + } + + const metadata: ConversationHistoryMetadata = { + conversationId, + lastUpdated: timestamp, + itemCount, + hasExchanges: itemCount > 0, + } + + const operations: KvBatchOperation[] = [ + { + type: 'put', + key: this._getHistoryKey(conversationId), + value: JSON.stringify(storedHistory), + }, + { + type: 'put', + key: this._getHistoryMetadataKey(conversationId), + value: JSON.stringify(metadata), + }, + ] + + await this._kvStore.batch(operations) + } + + /** + * Delete conversation history + */ + async deleteConversationHistory(conversationId: string): Promise { + const operations: KvBatchOperation[] = [ + { + type: 'del', + key: this._getHistoryKey(conversationId), + }, + { + type: 'del', + key: this._getHistoryMetadataKey(conversationId), + }, + ] + + await this._kvStore.batch(operations) + } + + /** + * Get conversation history metadata + */ + async getConversationHistoryMetadata( + conversationId: string + ): Promise { + const key = this._getHistoryMetadataKey(conversationId) + const data = await this._kvStore.get(key) + + if (!data) return null + + try { + return JSON.parse(data) as ConversationHistoryMetadata + } catch { + return null + } + } + + /** + * Close the store + */ + async close(): Promise { + await this._kvStore.close() + } +} + +// ============================================================================ +// Singleton Exports +// ============================================================================ + +let _kvStore: IKvStore | undefined +let _exchangeManager: ExchangeManager | undefined +let _historyManager: HistoryManager | undefined + +/** + * Get or create the default KV store + */ +export function getKvStore(filePath?: string): IKvStore { + if (!_kvStore) { + if (filePath) { + _kvStore = new FileKvStore(filePath) + } else { + _kvStore = new InMemoryKvStore() + } + } + return _kvStore +} + +/** + * Get or create the exchange manager + */ +export function getExchangeManager(kvStore?: IKvStore): ExchangeManager { + if (!_exchangeManager) { + _exchangeManager = new ExchangeManager(kvStore ?? getKvStore()) + } + return _exchangeManager +} + +/** + * Get or create the history manager + */ +export function getHistoryManager(kvStore?: IKvStore): HistoryManager { + if (!_historyManager) { + _historyManager = new HistoryManager(kvStore ?? getKvStore()) + } + return _historyManager +} + +/** + * Reset all singletons (for testing) + */ +export function resetKvStoreSingletons(): void { + _kvStore = undefined + _exchangeManager = undefined + _historyManager = undefined +} diff --git a/src/utils/agentTools/lifecycle.ts b/src/utils/agentTools/lifecycle.ts new file mode 100644 index 00000000..077efb2e --- /dev/null +++ b/src/utils/agentTools/lifecycle.ts @@ -0,0 +1,394 @@ +/** + * Lifecycle Management - Disposable patterns for resource cleanup + * + * Migrated from AgentTool/15-LifecycleManagement + * + * Features: + * - DisposableService base class + * - DisposableCollection for managing multiple disposables + * - Async disposal support + */ + +// ============================================================================ +// Disposable Interface +// ============================================================================ + +/** + * Interface for disposable resources + */ +export interface IDisposable { + dispose(): void +} + +/** + * Interface for async disposable resources + */ +export interface IAsyncDisposable { + dispose(): Promise +} + +/** + * Type that can be either sync or async disposable + */ +export type Disposable = IDisposable | IAsyncDisposable + +/** + * Check if a value is disposable + */ +export function isDisposable(value: unknown): value is IDisposable { + return ( + value !== null && + typeof value === 'object' && + 'dispose' in value && + typeof (value as IDisposable).dispose === 'function' + ) +} + +// ============================================================================ +// Disposable Collection +// ============================================================================ + +/** + * DisposableCollection - Manages multiple disposable resources + * + * Resources are disposed in reverse order (LIFO) + */ +export class DisposableCollection implements IDisposable { + private _disposables: Disposable[] = [] + private _isDisposed = false + + /** + * Check if the collection has been disposed + */ + get isDisposed(): boolean { + return this._isDisposed + } + + /** + * Add a disposable to the collection + * + * @returns The added disposable (for chaining) + */ + add(disposable: T): T { + if (this._isDisposed) { + // Immediately dispose if collection is already disposed + const result = disposable.dispose() + if (result instanceof Promise) { + result.catch(() => {}) // Ignore errors + } + return disposable + } + + this._disposables.push(disposable) + return disposable + } + + /** + * Add multiple disposables + */ + addAll(...disposables: Disposable[]): void { + for (const disposable of disposables) { + this.add(disposable) + } + } + + /** + * Remove a disposable without disposing it + */ + remove(disposable: Disposable): boolean { + const index = this._disposables.indexOf(disposable) + if (index !== -1) { + this._disposables.splice(index, 1) + return true + } + return false + } + + /** + * Clear all disposables without disposing them + */ + clear(): void { + this._disposables = [] + } + + /** + * Dispose all resources in reverse order + */ + dispose(): void { + if (this._isDisposed) return + + this._isDisposed = true + + // Dispose in reverse order (LIFO) + const disposables = this._disposables.reverse() + this._disposables = [] + + for (const disposable of disposables) { + try { + const result = disposable.dispose() + if (result instanceof Promise) { + result.catch(() => {}) // Ignore async errors in sync dispose + } + } catch { + // Ignore errors during disposal + } + } + } + + /** + * Dispose all resources asynchronously in reverse order + */ + async disposeAsync(): Promise { + if (this._isDisposed) return + + this._isDisposed = true + + // Dispose in reverse order (LIFO) + const disposables = this._disposables.reverse() + this._disposables = [] + + for (const disposable of disposables) { + try { + const result = disposable.dispose() + if (result instanceof Promise) { + await result + } + } catch { + // Ignore errors during disposal + } + } + } + + /** + * Create a disposable from a callback + */ + static fromCallback(callback: () => void | Promise): IDisposable { + return { dispose: callback } + } +} + +// ============================================================================ +// Disposable Service Base Class +// ============================================================================ + +/** + * DisposableService - Base class for services that need resource cleanup + * + * Provides: + * - Automatic tracking of disposables + * - Lifecycle state management + * - Cleanup on dispose + */ +export abstract class DisposableService implements IDisposable { + private _disposables = new DisposableCollection() + private _isDisposed = false + + /** + * Check if the service has been disposed + */ + get isDisposed(): boolean { + return this._isDisposed + } + + /** + * Register a disposable to be cleaned up when the service is disposed + */ + protected register(disposable: T): T { + return this._disposables.add(disposable) + } + + /** + * Register a callback to be called when the service is disposed + */ + protected registerCallback(callback: () => void | Promise): IDisposable { + return this.register(DisposableCollection.fromCallback(callback)) + } + + /** + * Called before disposal - override in subclasses for custom cleanup + */ + protected onDispose(): void | Promise { + // Override in subclasses + } + + /** + * Dispose the service and all registered resources + */ + dispose(): void { + if (this._isDisposed) return + + this._isDisposed = true + + try { + const result = this.onDispose() + if (result instanceof Promise) { + result.catch(() => {}) + } + } catch { + // Ignore errors in onDispose + } + + this._disposables.dispose() + } + + /** + * Dispose the service asynchronously + */ + async disposeAsync(): Promise { + if (this._isDisposed) return + + this._isDisposed = true + + try { + await this.onDispose() + } catch { + // Ignore errors in onDispose + } + + await this._disposables.disposeAsync() + } +} + +// ============================================================================ +// Event Emitter with Disposal +// ============================================================================ + +export type EventListener = (event: T) => void + +/** + * Simple event emitter with disposal support + */ +export class EventEmitter implements IDisposable { + private _listeners = new Set>() + private _isDisposed = false + + /** + * Subscribe to events + * + * @returns Disposable that removes the listener when disposed + */ + on(listener: EventListener): IDisposable { + if (this._isDisposed) { + return { dispose: () => {} } + } + + this._listeners.add(listener) + + return { + dispose: () => { + this._listeners.delete(listener) + }, + } + } + + /** + * Subscribe to a single event + */ + once(listener: EventListener): IDisposable { + const disposable = this.on((event) => { + disposable.dispose() + listener(event) + }) + return disposable + } + + /** + * Emit an event to all listeners + */ + emit(event: T): void { + if (this._isDisposed) return + + for (const listener of this._listeners) { + try { + listener(event) + } catch { + // Ignore listener errors + } + } + } + + /** + * Clear all listeners + */ + clear(): void { + this._listeners.clear() + } + + /** + * Dispose the emitter + */ + dispose(): void { + this._isDisposed = true + this._listeners.clear() + } +} + +// ============================================================================ +// Utility Functions +// ============================================================================ + +/** + * Create a disposable that does nothing + */ +export const EmptyDisposable: IDisposable = { dispose: () => {} } + +/** + * Combine multiple disposables into one + */ +export function combineDisposables(...disposables: Disposable[]): IDisposable { + return { + dispose: () => { + for (const disposable of disposables.reverse()) { + try { + const result = disposable.dispose() + if (result instanceof Promise) { + result.catch(() => {}) + } + } catch { + // Ignore errors + } + } + }, + } +} + +/** + * Timeout that can be disposed + */ +export function disposableTimeout( + callback: () => void, + ms: number +): IDisposable { + const handle = setTimeout(callback, ms) + return { + dispose: () => clearTimeout(handle), + } +} + +/** + * Interval that can be disposed + */ +export function disposableInterval( + callback: () => void, + ms: number +): IDisposable { + const handle = setInterval(callback, ms) + return { + dispose: () => clearInterval(handle), + } +} + +/** + * Run a callback when a disposable is disposed + */ +export function onDispose( + disposable: IDisposable, + callback: () => void +): IDisposable { + const originalDispose = disposable.dispose.bind(disposable) + disposable.dispose = () => { + originalDispose() + callback() + } + return disposable +} diff --git a/src/utils/agentTools/locateSnippet.ts b/src/utils/agentTools/locateSnippet.ts new file mode 100644 index 00000000..f21e1f0e --- /dev/null +++ b/src/utils/agentTools/locateSnippet.ts @@ -0,0 +1,312 @@ +/** + * LocateSnippet - Fuzzy code snippet location using LCS + * + * Migrated from AgentTool/10-UtilityTools/locate-snippet.ts + * + * Features: + * - FNV-1a hash-based line comparison for performance + * - Handles indentation differences + * - Finds shortest range containing max LCS + */ + +// ============================================================================ +// Fast Hash (FNV-1a) +// ============================================================================ + +/** + * Computes a fast hash of the input string using the FNV-1a algorithm. + * + * This function implements a non-cryptographic hash function that is + * designed for speed while still providing a good distribution of hash + * values. It's particularly useful for hash table implementations and + * quick string comparisons. + * + * @param str - The input string to be hashed + * @param ignoreLeadingWhitespace - If true, leading whitespace will be ignored + * @returns A 32-bit unsigned integer representing the hash + */ +function fastHash(str: string, ignoreLeadingWhitespace: boolean = false): number { + let hash = 0x811c9dc5 // FNV offset basis + const startIndex = ignoreLeadingWhitespace ? str.length - str.trimStart().length : 0 + + for (let i = startIndex; i < str.length; i++) { + hash ^= str.charCodeAt(i) + hash *= 0x01000193 // FNV prime + } + + return hash >>> 0 // Convert to 32-bit unsigned integer +} + +// ============================================================================ +// LCS Computation +// ============================================================================ + +interface LCSResult { + maxLCSLength: number + minSubstringLength: number + endIndex: number +} + +/** + * Computes the Longest Common Subsequence (LCS) between two arrays of numbers. + * + * @param document - Array of numbers representing the document content (hashed lines) + * @param pattern - Array of numbers representing the pattern to match (hashed lines) + * @returns Object containing LCS length, minimum substring length, and end index + */ +function computeLCS(document: number[], pattern: number[]): LCSResult { + const n = document.length + const m = pattern.length + + let previousRow: { lcsLength: number; minSubstringLength: number }[] = Array(m + 1) + .fill(null) + .map(() => ({ lcsLength: 0, minSubstringLength: Infinity })) + let currentRow: { lcsLength: number; minSubstringLength: number }[] = Array(m + 1) + .fill(null) + .map(() => ({ lcsLength: 0, minSubstringLength: Infinity })) + + previousRow[0].minSubstringLength = 0 + + let maxLCSLength = 0 + let minSubstringLength = Infinity + let endIndex = -1 + + for (let i = 1; i <= n; i++) { + currentRow[0] = { lcsLength: 0, minSubstringLength: 0 } + + for (let j = 1; j <= m; j++) { + if (document[i - 1] === pattern[j - 1]) { + const lcsLength = previousRow[j - 1].lcsLength + 1 + const minSubLength = previousRow[j - 1].minSubstringLength + 1 + currentRow[j] = { lcsLength, minSubstringLength: minSubLength } + } else { + const fromTop = previousRow[j] + const fromLeft = currentRow[j - 1] + + if (fromTop.lcsLength > fromLeft.lcsLength) { + currentRow[j] = { + lcsLength: fromTop.lcsLength, + minSubstringLength: fromTop.minSubstringLength + 1, + } + } else if (fromTop.lcsLength < fromLeft.lcsLength) { + currentRow[j] = { + lcsLength: fromLeft.lcsLength, + minSubstringLength: fromLeft.minSubstringLength, + } + } else { + currentRow[j] = { + lcsLength: fromTop.lcsLength, + minSubstringLength: Math.min( + fromTop.minSubstringLength + 1, + fromLeft.minSubstringLength + ), + } + } + } + + if (j === m) { + const current = currentRow[j] + if ( + current.lcsLength > maxLCSLength || + (current.lcsLength === maxLCSLength && current.minSubstringLength < minSubstringLength) + ) { + maxLCSLength = current.lcsLength + minSubstringLength = current.minSubstringLength + endIndex = i + } + } + } + + // Swap rows + ;[previousRow, currentRow] = [currentRow, previousRow] + } + + return { maxLCSLength, minSubstringLength, endIndex } +} + +// ============================================================================ +// Fuzzy Locate Snippet +// ============================================================================ + +export interface SnippetLocation { + /** Start line index (0-based, inclusive) */ + start: number + /** End line index (0-based, inclusive) */ + end: number +} + +/** + * Locates the shortest range in the file content that contains the maximum + * longest common subsequence (LCS) with the provided pattern. + * + * Comparisons are performed line by line using hash-based matching for performance. + * The function handles cases where the snippet might have different indentation + * levels compared to the original file content. + * + * @param fileContent - The content of the file as a string + * @param pattern - The pattern/snippet to locate + * @returns Object with start and end line indices, or null if no match found + * + * @example + * ```typescript + * const location = fuzzyLocateSnippet(fileContent, codeSnippet) + * if (location) { + * console.log(`Found at lines ${location.start}-${location.end}`) + * } + * ``` + */ +export function fuzzyLocateSnippet( + fileContent: string, + pattern: string +): SnippetLocation | null { + const fileLines = fileContent.split('\n') + const patternLines = pattern.trim().split('\n') + + function computeHashesAndLCS(ignoreLeadingWhitespace: boolean): LCSResult { + const fileHashes = fileLines.map((line) => fastHash(line, ignoreLeadingWhitespace)) + const patternHashes = patternLines.map((line) => fastHash(line, ignoreLeadingWhitespace)) + return computeLCS(fileHashes, patternHashes) + } + + // Try both options: with and without ignoring indentation + const resultWithoutIgnoreIndentation = computeHashesAndLCS(false) + const resultWithIgnoreIndentation = computeHashesAndLCS(true) + + // Choose the result, giving priority to the one without ignoring indentation + const { maxLCSLength, minSubstringLength, endIndex } = + resultWithoutIgnoreIndentation.maxLCSLength >= resultWithIgnoreIndentation.maxLCSLength + ? resultWithoutIgnoreIndentation + : resultWithIgnoreIndentation + + if (maxLCSLength === 0) { + return null + } + + const startIndex = endIndex - minSubstringLength + const endIndexInclusive = endIndex - 1 + + return { start: startIndex, end: endIndexInclusive } +} + +/** + * Find the best matching line range for a code snippet in file content + * with additional context options + */ +export interface LocateOptions { + /** Minimum match ratio (0-1) to consider a valid match */ + minMatchRatio?: number + /** Whether to prefer exact indentation match */ + preferExactIndentation?: boolean +} + +/** + * Advanced snippet location with match quality scoring + * + * @param fileContent - The content of the file + * @param pattern - The pattern to locate + * @param options - Location options + * @returns Location with match quality, or null if no good match found + */ +export function locateSnippetWithQuality( + fileContent: string, + pattern: string, + options: LocateOptions = {} +): (SnippetLocation & { matchRatio: number }) | null { + const { minMatchRatio = 0.5, preferExactIndentation = true } = options + + const fileLines = fileContent.split('\n') + const patternLines = pattern.trim().split('\n') + + function computeHashesAndLCS(ignoreLeadingWhitespace: boolean): LCSResult { + const fileHashes = fileLines.map((line) => fastHash(line, ignoreLeadingWhitespace)) + const patternHashes = patternLines.map((line) => fastHash(line, ignoreLeadingWhitespace)) + return computeLCS(fileHashes, patternHashes) + } + + const resultExact = computeHashesAndLCS(false) + const resultIgnoreIndent = computeHashesAndLCS(true) + + // Calculate match ratios + const exactRatio = patternLines.length > 0 ? resultExact.maxLCSLength / patternLines.length : 0 + const ignoreIndentRatio = + patternLines.length > 0 ? resultIgnoreIndent.maxLCSLength / patternLines.length : 0 + + // Choose best result based on preferences + let bestResult: LCSResult + let matchRatio: number + + if (preferExactIndentation && exactRatio >= ignoreIndentRatio * 0.9) { + // Prefer exact if it's within 90% of indent-ignoring result + bestResult = resultExact + matchRatio = exactRatio + } else if (ignoreIndentRatio > exactRatio) { + bestResult = resultIgnoreIndent + matchRatio = ignoreIndentRatio + } else { + bestResult = resultExact + matchRatio = exactRatio + } + + // Check minimum match ratio + if (matchRatio < minMatchRatio) { + return null + } + + const { minSubstringLength, endIndex, maxLCSLength } = bestResult + + if (maxLCSLength === 0) { + return null + } + + const startIndex = endIndex - minSubstringLength + const endIndexInclusive = endIndex - 1 + + return { + start: startIndex, + end: endIndexInclusive, + matchRatio, + } +} + +/** + * Find multiple occurrences of a pattern in file content + * + * @param fileContent - The content of the file + * @param pattern - The pattern to locate + * @param maxOccurrences - Maximum number of occurrences to find + * @returns Array of locations + */ +export function findAllSnippetOccurrences( + fileContent: string, + pattern: string, + maxOccurrences: number = 10 +): SnippetLocation[] { + const results: SnippetLocation[] = [] + const fileLines = fileContent.split('\n') + const patternLines = pattern.trim().split('\n') + + if (patternLines.length === 0) return results + + let searchStart = 0 + + while (results.length < maxOccurrences && searchStart < fileLines.length) { + // Create a subset of the file starting from searchStart + const remainingContent = fileLines.slice(searchStart).join('\n') + const location = fuzzyLocateSnippet(remainingContent, pattern) + + if (!location) break + + // Adjust indices to account for searchStart offset + const adjustedLocation: SnippetLocation = { + start: location.start + searchStart, + end: location.end + searchStart, + } + + results.push(adjustedLocation) + + // Move search start past the current match + searchStart = adjustedLocation.end + 1 + } + + return results +} diff --git a/src/utils/agentTools/matchLines.ts b/src/utils/agentTools/matchLines.ts new file mode 100644 index 00000000..7259655a --- /dev/null +++ b/src/utils/agentTools/matchLines.ts @@ -0,0 +1,118 @@ +/** + * Match Lines - Integrated from AgentTool + * Provides symbol-based fuzzy line matching for file edits + */ + +import { findLongestCommonSubsequence } from './findLcs' + +/** + * Split text into symbols + * + * Symbols are basically variable/function/class names. + * More precisely, they are sequences of alphanumerics and underscores. + * Whitespaces are ignored. + * Other characters are considered as a single symbol. + * + * @param text - Text to split + * @param ignoreWhitespace - Whether to ignore whitespace (default: true) + * @returns Array of symbols + */ +export function splitIntoSymbols(text: string, ignoreWhitespace: boolean = true): string[] { + const symbols: string[] = [] + let currentSymbol = '' + + for (let i = 0; i < text.length; i++) { + const char = text[i] + + if (/[a-zA-Z0-9_]/.test(char)) { + // Part of a symbol (alphanumeric or underscore) + currentSymbol += char + } else if (/\s/.test(char) && ignoreWhitespace) { + // Whitespace - ignore but finalize current symbol if any + if (currentSymbol) { + symbols.push(currentSymbol) + currentSymbol = '' + } + } else { + // Other character - treat as a single symbol + if (currentSymbol) { + symbols.push(currentSymbol) + currentSymbol = '' + } + symbols.push(char) + } + } + + // Add the last symbol if there is one + if (currentSymbol) { + symbols.push(currentSymbol) + } + + return symbols +} + +/** + * Fuzzy match lines + * + * Assumes that difference between A and B is mainly in formatting and line breaks. + * + * Mapping for each line can be many-to-many. + * Returns an array of arrays, + * where each inner array contains the indices of lines in B + * that contain parts of current line in A + * Inner arrays are sorted in increasing order + * + * @param linesA - Lines from first text + * @param linesB - Lines from second text + * @returns mapping from index in linesA to index in linesB, or [] if not mapped. + */ +export function fuzzyMatchLines(linesA: string[], linesB: string[]): number[][] { + // Split each line into symbols + const symbolsPerLineA = linesA.map((line) => splitIntoSymbols(line)) + const symbolsPerLineB = linesB.map((line) => splitIntoSymbols(line)) + + function mergeSymbols(symbolsPerLine: string[][]): [string[], number[]] { + const allSymbols: string[] = [] + const lineIndices: number[] = [] + + for (let i = 0; i < symbolsPerLine.length; i++) { + for (let j = 0; j < symbolsPerLine[i].length; j++) { + allSymbols.push(symbolsPerLine[i][j]) + lineIndices.push(i) + } + } + + return [allSymbols, lineIndices] + } + + const [allSymbolsA, lineIndicesA] = mergeSymbols(symbolsPerLineA) + const [allSymbolsB, lineIndicesB] = mergeSymbols(symbolsPerLineB) + + // Find longest common subsequence + const symbolMapping = findLongestCommonSubsequence(allSymbolsA, allSymbolsB) + + // Map the indices back to lines + const lineMapping: number[][] = Array.from({ + length: linesA.length, + }) + .fill([]) + .map(() => []) + + for (let i = 0; i < symbolMapping.length; i++) { + const mappedIndex = symbolMapping[i] + if (mappedIndex !== -1) { + const lineA = lineIndicesA[i] + const lineB = lineIndicesB[mappedIndex] + + // Add the mapping if it doesn't already exist + if (!lineMapping[lineA].includes(lineB)) { + lineMapping[lineA].push(lineB) + } + } + } + + // No need to sort + // They would already be sorted since we match symbols in order + + return lineMapping +} diff --git a/src/utils/agentTools/memorySystem.ts b/src/utils/agentTools/memorySystem.ts new file mode 100644 index 00000000..1f91349a --- /dev/null +++ b/src/utils/agentTools/memorySystem.ts @@ -0,0 +1,328 @@ +/** + * Memory System - Integrated from AgentTool + * Provides snapshot management, pending memories store, and update notifications + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from 'fs' +import { join, dirname } from 'path' +import throttle from 'lodash-es/throttle' +import { MEMORY_DIR } from '@utils/env' +import { debug } from '@utils/debugLogger' + +// Helper for logging +const log = (msg: string) => debug.trace('memory', msg) + +// ============================================================================ +// Types +// ============================================================================ + +export interface MemoryEntry { + id: string + content: string + version?: number +} + +export interface PendingMemoryEntry extends MemoryEntry { + requestId?: string + timestamp: number + scope?: string +} + +export interface PendingMemoriesState { + memories: PendingMemoryEntry[] + lastProcessedTimestamp: number +} + +export type MemoryState = 'pending' | 'accepted' | 'rejected' + +export interface MemoryInfoWithState extends MemoryEntry { + state: MemoryState + blobName?: string + timestamp?: number + requestId?: string +} + +// ============================================================================ +// Memory Snapshot Manager +// ============================================================================ + +/** + * MemorySnapshotManager manages in-memory snapshots of agent memories. + * It creates a snapshot when a user sends a message and maintains + * that snapshot until 5 minutes of inactivity or conversation switch. + */ +export class MemorySnapshotManager { + private _currentSnapshot: string | undefined + private _currentConversationId: string | undefined + private _lastActivityTime: number = 0 + private _inactivityThresholdMs = 5 * 60 * 1000 // 5 minutes + private _disposed = false + + constructor(private _getMemoriesContent: () => Promise) { + log('MemorySnapshotManager initialized') + } + + /** + * Get the current memory snapshot or create one if needed. + */ + public async getMemorySnapshot(conversationId: string): Promise { + if (this._disposed) return undefined + + const now = Date.now() + + if (this._shouldUpdateSnapshot(conversationId, now)) { + await this._updateSnapshot(conversationId) + } + + this._lastActivityTime = now + return this._currentSnapshot + } + + /** + * Force an update of the memory snapshot. + */ + public async forceUpdateSnapshot(): Promise { + if (this._disposed) return + await this._updateSnapshot(this._currentConversationId) + } + + private _shouldUpdateSnapshot(conversationId: string, now: number): boolean { + if (!this._currentSnapshot) return true + if (conversationId !== this._currentConversationId) return true + if (now - this._lastActivityTime > this._inactivityThresholdMs) return true + return false + } + + private async _updateSnapshot(conversationId?: string): Promise { + try { + const content = await this._getMemoriesContent() + this._currentSnapshot = content + this._currentConversationId = conversationId + log(`Snapshot updated for conversation: ${conversationId}`) + } catch (error) { + log( `Failed to update snapshot: ${error}`) + this._currentSnapshot = undefined + } + } + + public dispose(): void { + this._disposed = true + this._currentSnapshot = undefined + this._currentConversationId = undefined + } +} + +// ============================================================================ +// Pending Memories Store +// ============================================================================ + +/** + * PendingMemoriesStore manages pending memories in a JSON state file. + */ +export class PendingMemoriesStore { + private _state: PendingMemoriesState + private _writeLock = Promise.resolve() + private readonly _pendingPath: string + + constructor(agentId: string) { + this._pendingPath = join(MEMORY_DIR, 'agents', agentId, 'pending-memories.json') + this._state = { + memories: [], + lastProcessedTimestamp: 0, + } + + // Load existing state + void this._loadState() + log( `PendingMemoriesStore initialized: ${this._pendingPath}`) + } + + private async _loadState(): Promise { + try { + if (existsSync(this._pendingPath)) { + const content = readFileSync(this._pendingPath, 'utf-8') + this._state = JSON.parse(content) as PendingMemoriesState + log( `Loaded ${this._state.memories.length} pending memories`) + } + } catch (error) { + log( `Failed to load pending memories: ${error}`) + } + } + + private async _saveState(): Promise { + try { + const dir = dirname(this._pendingPath) + mkdirSync(dir, { recursive: true }) + writeFileSync(this._pendingPath, JSON.stringify(this._state, null, 2), 'utf-8') + } catch (error) { + log( `Failed to save pending memories: ${error}`) + } + } + + /** + * Append a memory entry to the pending store + */ + async append(memory: MemoryEntry, requestId?: string, timestamp?: number): Promise { + const pendingEntry: PendingMemoryEntry = { + ...memory, + requestId, + timestamp: timestamp || Date.now(), + } + + this._writeLock = this._writeLock.then(async () => { + this._state.memories.push(pendingEntry) + this._state.lastProcessedTimestamp = pendingEntry.timestamp + await this._saveState() + log( `Appended memory ${memory.id} to pending store`) + }) + + await this._writeLock + } + + /** + * List all pending memory entries sorted by timestamp (newest first) + */ + listPending(): PendingMemoryEntry[] { + return [...this._state.memories].sort((a, b) => b.timestamp - a.timestamp) + } + + /** + * Remove pending memories matching the predicate + */ + async removePending(predicate: (entry: PendingMemoryEntry) => boolean): Promise { + let removedCount = 0 + + this._writeLock = this._writeLock.then(async () => { + const originalLength = this._state.memories.length + this._state.memories = this._state.memories.filter(entry => !predicate(entry)) + removedCount = originalLength - this._state.memories.length + await this._saveState() + log( `Removed ${removedCount} pending memories`) + }) + + await this._writeLock + return removedCount + } + + /** + * Clear all pending memories + */ + async clearAll(): Promise { + this._writeLock = this._writeLock.then(async () => { + this._state.memories = [] + this._state.lastProcessedTimestamp = 0 + await this._saveState() + log( 'Cleared all pending memories') + }) + + await this._writeLock + } + + /** + * Get count of pending memories + */ + get pendingCount(): number { + return this._state.memories.length + } +} + +// ============================================================================ +// Memory Update Manager +// ============================================================================ + +/** + * MemoryUpdateManager provides a way to notify listeners when agent memories are updated. + */ +export class MemoryUpdateManager { + private static THROTTLE_DELAY_MS = 500 + private _callbacks = new Set<() => void>() + private _throttledNotify: () => void + private _disposed = false + + constructor() { + this._throttledNotify = throttle( + () => this._notifyImmediate(), + MemoryUpdateManager.THROTTLE_DELAY_MS, + { trailing: true } + ) + } + + /** + * Register a callback to be called when memories are updated. + */ + public onMemoryHasUpdates(cb: () => void): { dispose: () => void } { + this._callbacks.add(cb) + return { + dispose: () => { + this._callbacks.delete(cb) + }, + } + } + + /** + * Notify all registered callbacks (throttled) + */ + public notifyMemoryHasUpdates(): void { + if (this._disposed) return + this._throttledNotify() + } + + private _notifyImmediate(): void { + this._callbacks.forEach(cb => cb()) + } + + public dispose(): void { + this._disposed = true + this._callbacks.clear() + } +} + +// ============================================================================ +// Memory System Singleton +// ============================================================================ + +let _snapshotManager: MemorySnapshotManager | null = null +let _updateManager: MemoryUpdateManager | null = null +const _pendingStores = new Map() + +/** + * Get or create a MemorySnapshotManager + */ +export function getMemorySnapshotManager( + getMemoriesContent: () => Promise +): MemorySnapshotManager { + if (!_snapshotManager) { + _snapshotManager = new MemorySnapshotManager(getMemoriesContent) + } + return _snapshotManager +} + +/** + * Get or create a MemoryUpdateManager + */ +export function getMemoryUpdateManager(): MemoryUpdateManager { + if (!_updateManager) { + _updateManager = new MemoryUpdateManager() + } + return _updateManager +} + +/** + * Get or create a PendingMemoriesStore for an agent + */ +export function getPendingMemoriesStore(agentId: string): PendingMemoriesStore { + if (!_pendingStores.has(agentId)) { + _pendingStores.set(agentId, new PendingMemoriesStore(agentId)) + } + return _pendingStores.get(agentId)! +} + +/** + * Dispose all memory system resources + */ +export function disposeMemorySystem(): void { + _snapshotManager?.dispose() + _snapshotManager = null + _updateManager?.dispose() + _updateManager = null + _pendingStores.clear() +} diff --git a/src/utils/agentTools/observable.ts b/src/utils/agentTools/observable.ts new file mode 100644 index 00000000..d4fb0c49 --- /dev/null +++ b/src/utils/agentTools/observable.ts @@ -0,0 +1,351 @@ +/** + * Observable - Reactive state management with computed values + * + * Migrated from AgentTool/10-UtilityTools/observable.ts + * + * Features: + * - Reactive value boxing with change notifications + * - Computed/derived observables with automatic updates + * - waitUntil for async condition waiting + * - Custom equality functions + */ + +// ============================================================================ +// Types +// ============================================================================ + +/** + * Listener callback type + */ +export type ObservableListener = (newValue: T, oldValue: T) => void + +/** + * Predicate function for waitUntil + */ +export type ObservablePredicate = (value: T) => boolean + +/** + * Equality function for comparing values + */ +export type EqualityFn = (a: T, b: T) => boolean + +/** + * Unlisten function returned by listen() + */ +export type Unlisten = () => void + +// ============================================================================ +// Observable Class +// ============================================================================ + +/** + * Observable - Boxes a value and notifies listeners when the value changes + * + * Features: + * - Reactive updates via listen() + * - Computed observables via Observable.watch() + * - Async condition waiting via waitUntil() + * - Custom equality comparison + * + * @example + * ```typescript + * const count = new Observable(0) + * + * // Listen for changes + * const unlisten = count.listen((newVal, oldVal) => { + * console.log(`Changed from ${oldVal} to ${newVal}`) + * }) + * + * count.value = 1 // logs: "Changed from 0 to 1" + * + * // Stop listening + * unlisten() + * ``` + */ +export class Observable { + private _value: T + private _equalityFn: EqualityFn + private _listeners: ObservableListener[] = [] + + constructor( + initialValue: T, + equalityFn: EqualityFn = (a, b) => a === b + ) { + this._value = initialValue + this._equalityFn = equalityFn + } + + /** + * Get the current value + */ + get value(): T { + return this._value + } + + /** + * Set a new value (triggers listeners if value changed) + */ + set value(newValue: T) { + if (this._equalityFn(newValue, this._value)) { + return + } + + const oldValue = this._value + this._value = newValue + + for (const listener of this._listeners) { + try { + listener(newValue, oldValue) + } catch { + // Ignore listener errors + } + } + } + + /** + * Subscribe to value changes + * + * @param listener Callback to invoke on value change + * @param fireImmediately If true, fires the listener immediately with current value + * @returns Function to unsubscribe + */ + listen(listener: ObservableListener, fireImmediately = false): Unlisten { + if (fireImmediately) { + listener(this._value, this._value) + } + + this._listeners.push(listener) + + return () => { + this._listeners = this._listeners.filter((l) => l !== listener) + } + } + + /** + * Wait until the value satisfies a predicate + * + * @param predicate Function that returns true when condition is met + * @param timeoutMs Optional timeout in milliseconds + * @returns Promise that resolves with the value when predicate is satisfied + * @throws Error if timeout is reached + */ + waitUntil(predicate: ObservablePredicate, timeoutMs?: number): Promise { + return new Promise((resolve, reject) => { + let unlisten: Unlisten | undefined + let timeoutId: ReturnType | undefined + + // Set up timeout if specified + if (timeoutMs !== undefined) { + timeoutId = setTimeout(() => { + unlisten?.() + reject(new Error('Timeout exceeded waiting for observable condition')) + }, timeoutMs) + } + + // Listen for changes (including checking current value) + unlisten = this.listen((value) => { + if (predicate(value)) { + if (timeoutId) { + clearTimeout(timeoutId) + } + unlisten?.() + resolve(value) + } + }, true) // Fire immediately to check current value + }) + } + + /** + * Dispose the observable (clear all listeners) + */ + dispose(): void { + this._listeners = [] + } + + /** + * Get the number of active listeners + */ + get listenerCount(): number { + return this._listeners.length + } + + /** + * Create a computed observable that updates when dependencies change + * + * @example + * ```typescript + * const firstName = new Observable('John') + * const lastName = new Observable('Doe') + * + * const fullName = Observable.watch( + * (first, last) => `${first} ${last}`, + * firstName, + * lastName + * ) + * + * console.log(fullName.value) // "John Doe" + * + * lastName.value = 'Smith' + * console.log(fullName.value) // "John Smith" + * ``` + */ + static watch( + computeFn: (...args: TArgs) => TResult, + ...observables: { [K in keyof TArgs]: Observable } + ): Observable { + // Compute initial value + const getValues = () => observables.map((o) => o.value) as TArgs + const initialValue = computeFn(...getValues()) + + // Create the computed observable + const computed = new Observable(initialValue) + + // Track unlisteners for cleanup + const unlisteners: Unlisten[] = [] + + // Listen to each dependency + for (const observable of observables) { + const unlisten = observable.listen(() => { + computed.value = computeFn(...getValues()) + }) + unlisteners.push(unlisten) + } + + // Override dispose to also unlisten from dependencies + const originalDispose = computed.dispose.bind(computed) + computed.dispose = () => { + for (const unlisten of unlisteners) { + unlisten() + } + originalDispose() + } + + return computed + } + + /** + * Create a mapped observable that transforms values + * + * @example + * ```typescript + * const count = new Observable(5) + * const doubled = count.map(x => x * 2) + * + * console.log(doubled.value) // 10 + * + * count.value = 10 + * console.log(doubled.value) // 20 + * ``` + */ + map(transform: (value: T) => U): Observable { + return Observable.watch(transform, this) + } + + /** + * Create a filtered observable that only updates when predicate is true + */ + filter(predicate: ObservablePredicate): Observable { + const filtered = new Observable( + predicate(this._value) ? this._value : undefined + ) + + const unlisten = this.listen((value) => { + if (predicate(value)) { + filtered.value = value + } + }) + + const originalDispose = filtered.dispose.bind(filtered) + filtered.dispose = () => { + unlisten() + originalDispose() + } + + return filtered + } +} + +// ============================================================================ +// Utility Functions +// ============================================================================ + +/** + * Create a readonly observable wrapper + */ +export interface ReadonlyObservable { + readonly value: T + listen(listener: ObservableListener, fireImmediately?: boolean): Unlisten + waitUntil(predicate: ObservablePredicate, timeoutMs?: number): Promise +} + +/** + * Create a readonly view of an observable + */ +export function asReadonly(observable: Observable): ReadonlyObservable { + return { + get value() { + return observable.value + }, + listen: (listener, fireImmediately) => observable.listen(listener, fireImmediately), + waitUntil: (predicate, timeoutMs) => observable.waitUntil(predicate, timeoutMs), + } +} + +/** + * Combine multiple observables into a single observable of tuple + * + * @example + * ```typescript + * const a = new Observable(1) + * const b = new Observable('hello') + * + * const combined = combineObservables(a, b) + * console.log(combined.value) // [1, 'hello'] + * ``` + */ +export function combineObservables( + ...observables: { [K in keyof T]: Observable } +): Observable { + return Observable.watch((...values) => values as T, ...observables) +} + +/** + * Create an observable from a promise + */ +export function observableFromPromise( + promise: Promise, + initialValue: T +): Observable { + const observable = new Observable(initialValue) + + promise.then((value) => { + observable.value = value + }).catch(() => { + // Keep initial value on error + }) + + return observable +} + +/** + * Deep equality function for objects + */ +export function deepEqual(a: T, b: T): boolean { + if (a === b) return true + if (a === null || b === null) return false + if (typeof a !== 'object' || typeof b !== 'object') return false + + const keysA = Object.keys(a as object) + const keysB = Object.keys(b as object) + + if (keysA.length !== keysB.length) return false + + for (const key of keysA) { + if (!keysB.includes(key)) return false + if (!deepEqual((a as Record)[key], (b as Record)[key])) { + return false + } + } + + return true +} diff --git a/src/utils/agentTools/promiseUtils.ts b/src/utils/agentTools/promiseUtils.ts new file mode 100644 index 00000000..44ec91cf --- /dev/null +++ b/src/utils/agentTools/promiseUtils.ts @@ -0,0 +1,526 @@ +/** + * Promise Utilities - Retry, backoff, and async helpers + * + * Migrated from AgentTool/10-UtilityTools/promise-utils.ts + * + * Features: + * - Exponential backoff retry + * - DeferredPromise for external resolution + * - Timeout wrapper + * - Concurrent execution helpers + */ + +// ============================================================================ +// Types +// ============================================================================ + +/** + * Parameters for exponential backoff retry + */ +export interface BackoffParams { + /** Initial delay in milliseconds */ + initialMS: number + /** Multiplier for each retry */ + mult: number + /** Maximum delay in milliseconds */ + maxMS: number + /** Maximum number of retries */ + maxTries?: number + /** Maximum total time in milliseconds */ + maxTotalMs?: number + /** Custom function to determine if an error is retryable */ + canRetry?: (error: unknown) => boolean +} + +/** + * Result of a retry backoff decision + */ +export type RetryBackoffResult = + | { shouldRetry: false } + | { shouldRetry: true; backoffMs: number } + +/** + * Parameters for custom retry logic + */ +export interface RetryParams { + /** Maximum number of retries */ + maxTries?: number + /** Maximum total time in milliseconds */ + maxTotalMs?: number + /** Function to determine if and how long to wait before retrying */ + getBackoffFn: ( + error: unknown, + currentBackoffMs: number, + currentTryCount: number + ) => RetryBackoffResult +} + +/** + * Logger interface for retry operations + */ +export interface RetryLogger { + info(message: string): void + warn?(message: string): void + error?(message: string): void +} + +// ============================================================================ +// Default Backoff Parameters +// ============================================================================ + +export const defaultBackoffParams: BackoffParams = { + initialMS: 100, + mult: 2, + maxMS: 30000, +} + +// ============================================================================ +// Basic Utilities +// ============================================================================ + +/** + * Delay for a specified number of milliseconds + * + * @example + * ```typescript + * await delayMs(1000) // Wait 1 second + * ``` + */ +export function delayMs(ms: number): Promise { + if (ms <= 0) { + return Promise.resolve() + } + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +/** + * Create a promise that rejects after a timeout + */ +export function timeoutPromise(ms: number, message?: string): Promise { + return new Promise((_, reject) => { + setTimeout(() => { + reject(new Error(message ?? `Timeout after ${ms}ms`)) + }, ms) + }) +} + +// ============================================================================ +// Retry with Backoff +// ============================================================================ + +/** + * Default function to check if an error is retryable + * By default, retries network errors and 5xx status codes + */ +export function isRetryableError(error: unknown): boolean { + if (error instanceof Error) { + const message = error.message.toLowerCase() + // Network errors + if ( + message.includes('network') || + message.includes('timeout') || + message.includes('econnrefused') || + message.includes('econnreset') || + message.includes('socket hang up') + ) { + return true + } + } + + // Check for HTTP status codes (if error has status property) + if (typeof error === 'object' && error !== null) { + const status = (error as { status?: number }).status + if (typeof status === 'number' && status >= 500 && status < 600) { + return true + } + } + + return false +} + +/** + * Retry a function with exponential backoff + * + * @example + * ```typescript + * const result = await retryWithBackoff( + * async () => { + * const response = await fetch('https://api.example.com/data') + * if (!response.ok) throw new Error('Request failed') + * return response.json() + * }, + * console, + * { initialMS: 100, mult: 2, maxMS: 10000, maxTries: 3 } + * ) + * ``` + */ +export async function retryWithBackoff( + fn: () => Promise, + logger?: RetryLogger, + backoffParams: BackoffParams = defaultBackoffParams +): Promise { + const canRetryFn = backoffParams.canRetry ?? isRetryableError + + const getBackoffFn = ( + error: unknown, + backoffMs: number, + _tryCount: number + ): RetryBackoffResult => { + if (!canRetryFn(error)) { + return { shouldRetry: false } + } + + let newBackoffMs: number + if (backoffMs === 0) { + newBackoffMs = backoffParams.initialMS + } else { + newBackoffMs = Math.min(backoffMs * backoffParams.mult, backoffParams.maxMS) + } + + return { shouldRetry: true, backoffMs: newBackoffMs } + } + + const retryParams: RetryParams = { + maxTries: backoffParams.maxTries, + maxTotalMs: backoffParams.maxTotalMs, + getBackoffFn, + } + + return retryWithTimes(fn, logger, retryParams) +} + +/** + * Retry a function with custom retry logic + */ +export async function retryWithTimes( + fn: () => Promise, + logger?: RetryLogger, + retryParams: RetryParams = { getBackoffFn: () => ({ shouldRetry: false }) } +): Promise { + let backoffMs = 0 + const startTime = Date.now() + + for (let tries = 0; ; tries++) { + try { + const result = await fn() + if (tries > 0 && logger) { + logger.info(`Operation succeeded after ${tries} transient failures`) + } + return result + } catch (error) { + const currentTryCount = tries + 1 + + // Check if we have exceeded max retries + if (retryParams.maxTries !== undefined && currentTryCount >= retryParams.maxTries) { + throw error + } + + // Compute whether to retry and backoff duration + const backoffResult = retryParams.getBackoffFn(error, backoffMs, currentTryCount) + if (!backoffResult.shouldRetry) { + throw error + } + + backoffMs = backoffResult.backoffMs + + if (logger) { + logger.info( + `Operation failed with error: ${String(error)}, retrying in ${backoffMs}ms (attempt ${tries + 1})` + ) + } + + // Check if backoff will exceed total time + if ( + retryParams.maxTotalMs !== undefined && + Date.now() - startTime + backoffMs > retryParams.maxTotalMs + ) { + throw error + } + + await delayMs(backoffMs) + } + } +} + +// ============================================================================ +// Deferred Promise +// ============================================================================ + +/** + * A promise that can be resolved or rejected from outside + * + * @example + * ```typescript + * const deferred = new DeferredPromise() + * + * // Later... + * deferred.resolve('hello') + * + * // Or handle as a promise + * const result = await deferred + * ``` + */ +export class DeferredPromise implements Promise { + private _promise: Promise + private _resolve!: (value: T | PromiseLike) => void + private _reject!: (reason?: unknown) => void + private _isSettled = false + + constructor() { + this._promise = new Promise((resolve, reject) => { + this._resolve = resolve + this._reject = reject + }) + } + + /** + * Check if the promise has been resolved or rejected + */ + get isSettled(): boolean { + return this._isSettled + } + + /** + * Resolve the promise with a value + */ + resolve(value: T | PromiseLike): void { + if (this._isSettled) return + this._isSettled = true + this._resolve(value) + } + + /** + * Reject the promise with a reason + */ + reject(reason?: unknown): void { + if (this._isSettled) return + this._isSettled = true + this._reject(reason) + } + + // Promise interface implementation + then( + onfulfilled?: ((value: T) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null + ): Promise { + return this._promise.then(onfulfilled, onrejected) + } + + catch( + onrejected?: ((reason: unknown) => TResult | PromiseLike) | null + ): Promise { + return this._promise.catch(onrejected) + } + + finally(onfinally?: (() => void) | null): Promise { + return this._promise.finally(onfinally) + } + + get [Symbol.toStringTag](): string { + return 'DeferredPromise' + } +} + +// ============================================================================ +// Timeout Wrapper +// ============================================================================ + +/** + * Execute an async function with a timeout + * + * @example + * ```typescript + * try { + * const result = await withTimeout( + * fetch('https://api.example.com/slow'), + * 5000 // 5 second timeout + * ) + * } catch (error) { + * console.log('Request timed out') + * } + * ``` + */ +export async function withTimeout( + promise: Promise, + timeoutMs: number, + timeoutMessage?: string +): Promise { + return Promise.race([promise, timeoutPromise(timeoutMs, timeoutMessage)]) +} + +/** + * Execute a function with a timeout + */ +export async function withTimeoutFn( + fn: () => Promise, + timeoutMs: number, + timeoutMessage?: string +): Promise { + return withTimeout(fn(), timeoutMs, timeoutMessage) +} + +// ============================================================================ +// Concurrent Execution Helpers +// ============================================================================ + +/** + * Execute promises with concurrency limit + * + * @example + * ```typescript + * const urls = ['url1', 'url2', 'url3', 'url4', 'url5'] + * const results = await parallelLimit( + * urls.map(url => () => fetch(url)), + * 2 // Max 2 concurrent requests + * ) + * ``` + */ +export async function parallelLimit( + tasks: Array<() => Promise>, + concurrency: number +): Promise { + const results: T[] = [] + const executing: Promise[] = [] + + for (let i = 0; i < tasks.length; i++) { + const task = tasks[i] + const promise = (async () => { + results[i] = await task() + })() + + executing.push(promise) + + if (executing.length >= concurrency) { + await Promise.race(executing) + // Remove completed promises + for (let j = executing.length - 1; j >= 0; j--) { + // Check if promise is settled by racing with a resolved promise + const settled = await Promise.race([ + executing[j].then(() => true), + Promise.resolve(false), + ]) + if (settled) { + executing.splice(j, 1) + } + } + } + } + + await Promise.all(executing) + return results +} + +/** + * Execute all promises and collect results/errors + * Similar to Promise.allSettled but with a simpler return type + */ +export async function allSettledSimple( + promises: Promise[] +): Promise<{ fulfilled: T[]; rejected: unknown[] }> { + const results = await Promise.allSettled(promises) + + const fulfilled: T[] = [] + const rejected: unknown[] = [] + + for (const result of results) { + if (result.status === 'fulfilled') { + fulfilled.push(result.value) + } else { + rejected.push(result.reason) + } + } + + return { fulfilled, rejected } +} + +/** + * Race promises with a timeout + */ +export async function raceWithTimeout( + promises: Promise[], + timeoutMs: number, + timeoutMessage?: string +): Promise { + return Promise.race([...promises, timeoutPromise(timeoutMs, timeoutMessage)]) +} + +// ============================================================================ +// Utility Functions +// ============================================================================ + +/** + * Check if a value is a promise + */ +export function isPromise(value: unknown): value is Promise { + return ( + value !== null && + typeof value === 'object' && + 'then' in value && + typeof (value as Promise).then === 'function' + ) +} + +/** + * Ignore errors from a promise (useful for cleanup) + */ +export async function ignoreError(promise: Promise): Promise { + try { + return await promise + } catch { + return undefined + } +} + +/** + * Convert a callback-style function to a promise + */ +export function promisify( + fn: (...args: [...unknown[], (error: Error | null, result?: T) => void]) => void +): (...args: unknown[]) => Promise { + return (...args: unknown[]) => { + return new Promise((resolve, reject) => { + fn(...args, (error: Error | null, result?: T) => { + if (error) { + reject(error) + } else { + resolve(result as T) + } + }) + }) + } +} + +/** + * Create a debounced async function + */ +export function debounceAsync) => Promise>>( + fn: T, + delayMs: number +): T { + let timeoutId: ReturnType | undefined + let pendingPromise: DeferredPromise> | undefined + + return ((...args: Parameters) => { + if (timeoutId) { + clearTimeout(timeoutId) + } + + if (!pendingPromise) { + pendingPromise = new DeferredPromise>() + } + + const currentPending = pendingPromise + + timeoutId = setTimeout(async () => { + pendingPromise = undefined + try { + const result = await fn(...args) + currentPending.resolve(result) + } catch (error) { + currentPending.reject(error) + } + }, delayMs) + + return currentPending as Promise> + }) as T +} diff --git a/src/utils/agentTools/shellAllowlist.ts b/src/utils/agentTools/shellAllowlist.ts new file mode 100644 index 00000000..b788fffd --- /dev/null +++ b/src/utils/agentTools/shellAllowlist.ts @@ -0,0 +1,687 @@ +/** + * Shell Allowlist - Auto-approval rules for safe shell commands + * + * Migrated from AgentTool/04-ShellTools/shell-allowlist.ts + * + * Features: + * - Comprehensive allowlist for common tools (git, docker, npm, kubectl, etc.) + * - Multiple rule types: prefix, exact, any, not_contains + * - Shell-specific rules for bash/zsh/fish and PowerShell + * - Easy extensibility for custom rules + */ + +// ============================================================================ +// Types +// ============================================================================ + +export type AllowlistRuleType = 'prefix' | 'exact' | 'any' | 'not_contains' + +export interface ShellAllowlistEntry { + type: AllowlistRuleType + args?: string[] +} + +export interface ShellAllowlist { + auto_approval: Record +} + +export type ShellType = 'bash' | 'zsh' | 'fish' | 'powershell' | 'cmd' + +// ============================================================================ +// Command Parsing +// ============================================================================ + +/** + * Naive command parser that splits a command into parts + * Handles basic quoting but not all edge cases + */ +export function parseCommandNaive(command: string, _shellType: ShellType): string[] | null { + const trimmed = command.trim() + if (!trimmed) return null + + const parts: string[] = [] + let current = '' + let inQuote: string | null = null + let escaped = false + + for (let i = 0; i < trimmed.length; i++) { + const char = trimmed[i] + + if (escaped) { + current += char + escaped = false + continue + } + + if (char === '\\') { + escaped = true + continue + } + + if (inQuote) { + if (char === inQuote) { + inQuote = null + } else { + current += char + } + continue + } + + if (char === '"' || char === "'") { + inQuote = char + continue + } + + if (char === ' ' || char === '\t') { + if (current) { + parts.push(current) + current = '' + } + continue + } + + // Stop at pipe, redirect, or command separator + if (char === '|' || char === '>' || char === '<' || char === ';' || char === '&') { + break + } + + current += char + } + + if (current) { + parts.push(current) + } + + return parts.length > 0 ? parts : null +} + +// ============================================================================ +// Tool Allowlist (cross-platform) +// ============================================================================ + +const toolsAllowlist: ShellAllowlist = { + auto_approval: { + // Git commands + git: [ + { type: 'prefix', args: ['status'] }, + { type: 'prefix', args: ['log'] }, + { type: 'prefix', args: ['diff'] }, + { type: 'prefix', args: ['show'] }, + { type: 'exact', args: ['branch'] }, + { type: 'prefix', args: ['ls-files'] }, + { type: 'prefix', args: ['blame'] }, + { type: 'prefix', args: ['rev-parse'] }, + { type: 'prefix', args: ['remote', '-v'] }, + { type: 'prefix', args: ['config', '--list'] }, + { type: 'exact', args: ['config', 'user.name'] }, + { type: 'exact', args: ['config', 'user.email'] }, + { type: 'exact', args: ['branch', '--show-current'] }, + ], + + // Kubernetes + kubectl: [ + { type: 'prefix', args: ['get'] }, + { type: 'prefix', args: ['describe'] }, + { type: 'prefix', args: ['explain'] }, + { type: 'prefix', args: ['logs'] }, + { type: 'prefix', args: ['top'] }, + { type: 'prefix', args: ['api-resources'] }, + { type: 'prefix', args: ['api-versions'] }, + { type: 'prefix', args: ['version'] }, + { type: 'prefix', args: ['wait'] }, + { type: 'prefix', args: ['auth', 'can-i'] }, + { type: 'prefix', args: ['config', 'get-contexts'] }, + { type: 'prefix', args: ['config', 'view'] }, + ], + + // Bazel + bazel: [ + { type: 'prefix', args: ['query'] }, + { type: 'prefix', args: ['cquery'] }, + { type: 'prefix', args: ['config'] }, + { type: 'prefix', args: ['info'] }, + { type: 'prefix', args: ['version'] }, + { type: 'prefix', args: ['help'] }, + { type: 'prefix', args: ['analyze-profile'] }, + { type: 'prefix', args: ['aquery'] }, + { type: 'prefix', args: ['dump'] }, + { type: 'prefix', args: ['license'] }, + { type: 'prefix', args: ['print'] }, + { type: 'prefix', args: ['build', '--nobuild'] }, + { type: 'prefix', args: ['coverage', '--nobuild'] }, + { type: 'prefix', args: ['mobile-install', '--nobuild'] }, + { type: 'prefix', args: ['run', '--nobuild'] }, + { type: 'prefix', args: ['test', '--nobuild'] }, + { type: 'prefix', args: ['clean', '--expunge', '--dry-run'] }, + ], + + // Docker + docker: [ + { type: 'prefix', args: ['ps'] }, + { type: 'prefix', args: ['images'] }, + { type: 'prefix', args: ['network', 'ls'] }, + { type: 'prefix', args: ['volume', 'ls'] }, + { type: 'prefix', args: ['port'] }, + { type: 'prefix', args: ['stats'] }, + { type: 'prefix', args: ['events'] }, + { type: 'prefix', args: ['diff'] }, + { type: 'prefix', args: ['history'] }, + { type: 'prefix', args: ['system', 'df'] }, + { type: 'prefix', args: ['top'] }, + { type: 'prefix', args: ['version'] }, + { type: 'prefix', args: ['inspect'] }, + ], + + // npm + npm: [ + { type: 'prefix', args: ['list'] }, + { type: 'prefix', args: ['outdated'] }, + { type: 'prefix', args: ['doctor'] }, + { type: 'prefix', args: ['audit'] }, + { type: 'prefix', args: ['token', 'list'] }, + { type: 'prefix', args: ['ping'] }, + { type: 'prefix', args: ['view'] }, + { type: 'prefix', args: ['owner', 'ls'] }, + { type: 'prefix', args: ['fund'] }, + { type: 'prefix', args: ['explain'] }, + { type: 'prefix', args: ['ls'] }, + { type: 'prefix', args: ['why'] }, + { type: 'prefix', args: ['prefix'] }, + ], + + // Terraform + terraform: [ + { type: 'prefix', args: ['show'] }, + { type: 'prefix', args: ['providers'] }, + { type: 'prefix', args: ['state', 'list'] }, + { type: 'prefix', args: ['state', 'show'] }, + { type: 'prefix', args: ['version'] }, + { type: 'prefix', args: ['fmt', '--check'] }, + { type: 'prefix', args: ['validate'] }, + { type: 'prefix', args: ['graph'] }, + { type: 'prefix', args: ['console'] }, + { type: 'prefix', args: ['output'] }, + { type: 'prefix', args: ['refresh', '--dry-run'] }, + { type: 'prefix', args: ['plan'] }, + ], + + // Gradle + gradle: [ + { type: 'prefix', args: ['dependencies'] }, + { type: 'prefix', args: ['projects'] }, + { type: 'prefix', args: ['properties'] }, + { type: 'prefix', args: ['tasks'] }, + { type: 'prefix', args: ['components'] }, + { type: 'prefix', args: ['model'] }, + { type: 'prefix', args: ['buildEnvironment'] }, + { type: 'prefix', args: ['projectsEvaluated'] }, + { type: 'prefix', args: ['projects', '--dry-run'] }, + { type: 'prefix', args: ['dependencies', '--dry-run'] }, + { type: 'prefix', args: ['help'] }, + { type: 'prefix', args: ['version'] }, + ], + + // Helm + helm: [ + { type: 'prefix', args: ['list'] }, + { type: 'prefix', args: ['get', 'values'] }, + { type: 'prefix', args: ['get', 'manifest'] }, + { type: 'prefix', args: ['get', 'hooks'] }, + { type: 'prefix', args: ['get', 'notes'] }, + { type: 'prefix', args: ['status'] }, + { type: 'prefix', args: ['dependency', 'list'] }, + { type: 'prefix', args: ['show', 'chart'] }, + { type: 'prefix', args: ['show', 'values'] }, + { type: 'prefix', args: ['verify'] }, + { type: 'prefix', args: ['version'] }, + { type: 'prefix', args: ['env'] }, + ], + + // AWS CLI + aws: [ + { type: 'prefix', args: ['s3', 'ls'] }, + { type: 'prefix', args: ['ec2', 'describe-instances'] }, + { type: 'prefix', args: ['rds', 'describe-db-instances'] }, + { type: 'prefix', args: ['iam', 'list-users'] }, + { type: 'prefix', args: ['iam', 'list-roles'] }, + { type: 'prefix', args: ['lambda', 'list-functions'] }, + { type: 'prefix', args: ['eks', 'list-clusters'] }, + { type: 'prefix', args: ['ecr', 'describe-repositories'] }, + { type: 'prefix', args: ['cloudformation', 'list-stacks'] }, + { type: 'prefix', args: ['configure', 'list'] }, + ], + + // Google Cloud CLI + gcloud: [ + { type: 'prefix', args: ['projects', 'list'] }, + { type: 'prefix', args: ['compute', 'instances', 'list'] }, + { type: 'prefix', args: ['compute', 'zones', 'list'] }, + { type: 'prefix', args: ['compute', 'regions', 'list'] }, + { type: 'prefix', args: ['container', 'clusters', 'list'] }, + { type: 'prefix', args: ['services', 'list'] }, + { type: 'prefix', args: ['iam', 'roles', 'list'] }, + { type: 'prefix', args: ['config', 'list'] }, + { type: 'prefix', args: ['components', 'list'] }, + { type: 'prefix', args: ['version'] }, + ], + + // PostgreSQL + psql: [{ type: 'prefix', args: ['-l'] }], + pg_dump: [ + { type: 'prefix', args: ['--schema-only'] }, + { type: 'prefix', args: ['--schema', 'public', '--dry-run'] }, + { type: 'prefix', args: ['-s', '-t'] }, + ], + pg_controldata: [{ type: 'any' }], + pg_isready: [{ type: 'any' }], + pg_lsclusters: [{ type: 'any' }], + pg_activity: [{ type: 'any' }], + pgbench: [{ type: 'prefix', args: ['-i', '--dry-run'] }], + + // Maven + mvn: [ + { type: 'prefix', args: ['dependency:tree'] }, + { type: 'prefix', args: ['dependency:analyze'] }, + { type: 'prefix', args: ['help:effective-pom'] }, + { type: 'prefix', args: ['help:describe'] }, + { type: 'prefix', args: ['help:evaluate'] }, + { type: 'prefix', args: ['dependency:list'] }, + { type: 'prefix', args: ['dependency:build-classpath'] }, + { type: 'prefix', args: ['help:active-profiles'] }, + { type: 'prefix', args: ['help:effective-settings'] }, + { type: 'prefix', args: ['-version'] }, + ], + + // Redis CLI + 'redis-cli': [ + { type: 'prefix', args: ['info'] }, + { type: 'prefix', args: ['monitor'] }, + { type: 'prefix', args: ['memory', 'stats'] }, + { type: 'prefix', args: ['memory', 'doctor'] }, + { type: 'prefix', args: ['latency', 'doctor'] }, + { type: 'prefix', args: ['cluster', 'info'] }, + { type: 'prefix', args: ['client', 'list'] }, + { type: 'prefix', args: ['slowlog', 'get'] }, + { type: 'prefix', args: ['config', 'get'] }, + { type: 'prefix', args: ['info', 'keyspace'] }, + ], + + // Yarn + yarn: [ + { type: 'prefix', args: ['list'] }, + { type: 'prefix', args: ['info'] }, + { type: 'prefix', args: ['why'] }, + { type: 'prefix', args: ['licenses', 'list'] }, + { type: 'prefix', args: ['outdated'] }, + { type: 'prefix', args: ['check'] }, + { type: 'prefix', args: ['audit'] }, + { type: 'prefix', args: ['workspaces', 'info'] }, + { type: 'prefix', args: ['--version'] }, + { type: 'prefix', args: ['config', 'list'] }, + ], + + // pnpm + pnpm: [ + { type: 'prefix', args: ['list'] }, + { type: 'prefix', args: ['why'] }, + { type: 'prefix', args: ['outdated'] }, + { type: 'prefix', args: ['audit'] }, + { type: 'prefix', args: ['--version'] }, + { type: 'prefix', args: ['config', 'list'] }, + ], + + // Bun + bun: [ + { type: 'prefix', args: ['pm', 'ls'] }, + { type: 'prefix', args: ['--version'] }, + { type: 'exact', args: ['--help'] }, + ], + + // Azure CLI + az: [ + { type: 'prefix', args: ['account', 'list'] }, + { type: 'prefix', args: ['group', 'list'] }, + { type: 'prefix', args: ['vm', 'list'] }, + { type: 'prefix', args: ['aks', 'list'] }, + { type: 'prefix', args: ['acr', 'list'] }, + { type: 'prefix', args: ['storage', 'account', 'list'] }, + { type: 'prefix', args: ['network', 'vnet', 'list'] }, + { type: 'prefix', args: ['webapp', 'list'] }, + { type: 'prefix', args: ['functionapp', 'list'] }, + { type: 'prefix', args: ['version'] }, + ], + + // HashiCorp Vault + vault: [ + { type: 'prefix', args: ['list'] }, + { type: 'prefix', args: ['policy', 'list'] }, + { type: 'prefix', args: ['auth', 'list'] }, + { type: 'prefix', args: ['secrets', 'list'] }, + { type: 'prefix', args: ['audit', 'list'] }, + { type: 'prefix', args: ['status'] }, + { type: 'prefix', args: ['token', 'lookup'] }, + { type: 'prefix', args: ['read'] }, + { type: 'prefix', args: ['version'] }, + ], + + // Podman + podman: [ + { type: 'prefix', args: ['ps'] }, + { type: 'prefix', args: ['images'] }, + { type: 'prefix', args: ['pod', 'ps'] }, + { type: 'prefix', args: ['volume', 'ls'] }, + { type: 'prefix', args: ['network', 'ls'] }, + { type: 'prefix', args: ['stats'] }, + { type: 'prefix', args: ['top'] }, + { type: 'prefix', args: ['logs'] }, + { type: 'prefix', args: ['inspect'] }, + { type: 'prefix', args: ['port'] }, + ], + + // Deno + deno: [ + { type: 'prefix', args: ['info'] }, + { type: 'prefix', args: ['list'] }, + { type: 'prefix', args: ['doc'] }, + { type: 'prefix', args: ['lint'] }, + { type: 'prefix', args: ['types'] }, + { type: 'prefix', args: ['check'] }, + { type: 'prefix', args: ['compile', '--dry-run'] }, + { type: 'prefix', args: ['task', '--list'] }, + { type: 'prefix', args: ['test', '--dry-run'] }, + { type: 'prefix', args: ['--version'] }, + ], + + // Rust toolchain + rustup: [ + { type: 'prefix', args: ['show'] }, + { type: 'prefix', args: ['toolchain', 'list'] }, + { type: 'prefix', args: ['target', 'list'] }, + { type: 'prefix', args: ['component', 'list'] }, + { type: 'prefix', args: ['override', 'list'] }, + { type: 'prefix', args: ['which'] }, + { type: 'prefix', args: ['doc'] }, + { type: 'prefix', args: ['man'] }, + { type: 'prefix', args: ['--version'] }, + ], + + cargo: [ + { type: 'prefix', args: ['tree'] }, + { type: 'prefix', args: ['metadata'] }, + { type: 'prefix', args: ['--list'] }, + { type: 'prefix', args: ['verify'] }, + { type: 'prefix', args: ['search'] }, + { type: 'prefix', args: ['vendor', '--dry-run'] }, + { type: 'prefix', args: ['outdated'] }, + { type: 'prefix', args: ['doc'] }, + { type: 'prefix', args: ['config', 'get'] }, + { type: 'prefix', args: ['--version'] }, + ], + + // Python + pip: [ + { type: 'prefix', args: ['list'] }, + { type: 'prefix', args: ['show'] }, + { type: 'prefix', args: ['check'] }, + { type: 'prefix', args: ['debug'] }, + { type: 'prefix', args: ['config', 'list'] }, + { type: 'prefix', args: ['index'] }, + { type: 'prefix', args: ['hash'] }, + { type: 'prefix', args: ['cache', 'list'] }, + { type: 'prefix', args: ['freeze'] }, + { type: 'prefix', args: ['--version'] }, + ], + + python: [{ type: 'exact', args: ['--version'] }, { type: 'exact', args: ['-V'] }], + python3: [{ type: 'exact', args: ['--version'] }, { type: 'exact', args: ['-V'] }], + + // Go + go: [ + { type: 'prefix', args: ['version'] }, + { type: 'prefix', args: ['env'] }, + { type: 'prefix', args: ['list'] }, + { type: 'prefix', args: ['mod', 'graph'] }, + { type: 'prefix', args: ['mod', 'why'] }, + { type: 'prefix', args: ['doc'] }, + ], + }, +} + +// ============================================================================ +// Shell-Specific Allowlists +// ============================================================================ + +const bashZshAllowlist: ShellAllowlist = { + auto_approval: { + cd: [{ type: 'any' }], + date: [{ type: 'any' }], + cal: [{ type: 'any' }], + uname: [{ type: 'prefix', args: ['-a'] }], + hostname: [{ type: 'any' }], + whoami: [{ type: 'any' }], + id: [{ type: 'any' }], + ps: [{ type: 'any' }], + free: [{ type: 'any' }], + w: [{ type: 'any' }], + who: [{ type: 'any' }], + ping: [{ type: 'not_contains', args: ['-f'] }], // Disallow flood ping + netstat: [{ type: 'any' }], + ss: [{ type: 'any' }], + ip: [{ type: 'prefix', args: ['addr'] }], + dig: [{ type: 'any' }], + nslookup: [{ type: 'any' }], + pwd: [{ type: 'any' }], + ls: [{ type: 'any' }], + file: [{ type: 'any' }], + stat: [{ type: 'any' }], + du: [{ type: 'any' }], + df: [{ type: 'any' }], + cat: [{ type: 'any' }], + less: [{ type: 'any' }], + more: [{ type: 'any' }], + head: [{ type: 'any' }], + tail: [{ type: 'not_contains', args: ['-f'] }], // Disallow follow mode + wc: [{ type: 'any' }], + which: [{ type: 'any' }], + whereis: [{ type: 'any' }], + type: [{ type: 'any' }], + echo: [{ type: 'any' }], + printf: [{ type: 'any' }], + env: [{ type: 'any' }], + printenv: [{ type: 'any' }], + uptime: [{ type: 'any' }], + top: [{ type: 'exact', args: ['-bn1'] }], // Single snapshot only + htop: [{ type: 'exact', args: [] }], + find: [{ type: 'any' }], + locate: [{ type: 'any' }], + grep: [{ type: 'any' }], + egrep: [{ type: 'any' }], + fgrep: [{ type: 'any' }], + rg: [{ type: 'any' }], // ripgrep + ag: [{ type: 'any' }], // silver searcher + awk: [{ type: 'any' }], + sed: [{ type: 'any' }], + sort: [{ type: 'any' }], + uniq: [{ type: 'any' }], + cut: [{ type: 'any' }], + tr: [{ type: 'any' }], + diff: [{ type: 'any' }], + tree: [{ type: 'any' }], + realpath: [{ type: 'any' }], + readlink: [{ type: 'any' }], + basename: [{ type: 'any' }], + dirname: [{ type: 'any' }], + md5sum: [{ type: 'any' }], + sha256sum: [{ type: 'any' }], + sha1sum: [{ type: 'any' }], + }, +} + +const powershellAllowlist: ShellAllowlist = { + auto_approval: { + cd: [{ type: 'any' }], + 'Get-Date': [{ type: 'any' }], + date: [{ type: 'any' }], + 'Get-ComputerInfo': [{ type: 'any' }], + 'Get-Host': [{ type: 'any' }], + '$env:USERNAME': [{ type: 'any' }], + whoami: [{ type: 'any' }], + 'Get-Process': [{ type: 'any' }], + ps: [{ type: 'any' }], + gps: [{ type: 'any' }], + 'Get-Service': [{ type: 'any' }], + gsv: [{ type: 'any' }], + 'Get-NetIPAddress': [{ type: 'any' }], + ipconfig: [{ type: 'any' }], + 'Get-NetTCPConnection': [{ type: 'any' }], + netstat: [{ type: 'any' }], + 'Resolve-DnsName': [{ type: 'any' }], + nslookup: [{ type: 'any' }], + 'Get-DnsClientServerAddress': [{ type: 'any' }], + 'Get-Location': [{ type: 'any' }], + pwd: [{ type: 'any' }], + gl: [{ type: 'any' }], + 'Get-ChildItem': [{ type: 'any' }], + dir: [{ type: 'any' }], + ls: [{ type: 'any' }], + gci: [{ type: 'any' }], + 'Get-Item': [{ type: 'any' }], + gi: [{ type: 'any' }], + 'Get-ItemProperty': [{ type: 'any' }], + gp: [{ type: 'any' }], + 'Get-Content': [{ type: 'not_contains', args: ['-Wait'] }], + cat: [{ type: 'any' }], + gc: [{ type: 'any' }], + type: [{ type: 'any' }], + 'Select-String': [{ type: 'any' }], + sls: [{ type: 'any' }], + findstr: [{ type: 'any' }], + 'Get-PSDrive': [{ type: 'any' }], + gdr: [{ type: 'any' }], + 'Get-Volume': [{ type: 'any' }], + 'Measure-Object': [{ type: 'any' }], + measure: [{ type: 'any' }], + 'Select-Object': [{ type: 'any' }], + select: [{ type: 'any' }], + }, +} + +// ============================================================================ +// Allowlist API +// ============================================================================ + +/** + * Get the combined allowlist for a specific shell + */ +export function getShellAllowlist(shell: ShellType): ShellAllowlist { + let shellAllowlist: ShellAllowlist + + if (shell === 'bash' || shell === 'zsh' || shell === 'fish') { + shellAllowlist = bashZshAllowlist + } else if (shell === 'powershell') { + shellAllowlist = powershellAllowlist + } else { + // For unknown shells, only use tool allowlist + shellAllowlist = { auto_approval: {} } + } + + // Combine shell allowlist with tools allowlist + return { + auto_approval: { + ...shellAllowlist.auto_approval, + ...toolsAllowlist.auto_approval, + }, + } +} + +/** + * Check if a command matches the allowlist + */ +export function checkShellAllowlist( + allowlist: ShellAllowlist, + command: string, + shell: ShellType +): boolean { + const parsedCommand = parseCommandNaive(command, shell) + if (!parsedCommand || parsedCommand.length === 0) { + return false + } + + const cmd = parsedCommand[0] + const cmdArgs = parsedCommand.slice(1) + const cmdRule = allowlist.auto_approval[cmd] + + if (!cmdRule) { + // Tool not found in allowlist + return false + } + + const rules = Array.isArray(cmdRule) ? cmdRule : [cmdRule] + + for (const rule of rules) { + const ruleArgs = rule.args ?? [] + + switch (rule.type) { + case 'prefix': + // Check if command args start with rule args + if (ruleArgs.length <= cmdArgs.length && ruleArgs.every((arg, i) => cmdArgs[i] === arg)) { + return true + } + break + + case 'exact': + // Check if command args exactly match rule args + if (ruleArgs.length === cmdArgs.length && ruleArgs.every((arg, i) => cmdArgs[i] === arg)) { + return true + } + break + + case 'any': + // Any args are allowed + return true + + case 'not_contains': + // Check that command args don't contain any of the rule args + if (!ruleArgs.some((arg) => cmdArgs.includes(arg))) { + return true + } + break + } + } + + return false +} + +/** + * Check if a command is safe to auto-approve + */ +export function isCommandAutoApproved(command: string, shell: ShellType): boolean { + const allowlist = getShellAllowlist(shell) + return checkShellAllowlist(allowlist, command, shell) +} + +/** + * Add custom rules to the allowlist (returns new merged allowlist) + */ +export function extendAllowlist( + base: ShellAllowlist, + extensions: ShellAllowlist +): ShellAllowlist { + const merged: ShellAllowlist = { + auto_approval: { ...base.auto_approval }, + } + + for (const [cmd, rules] of Object.entries(extensions.auto_approval)) { + const existing = merged.auto_approval[cmd] + if (existing) { + // Merge rules + const existingArray = Array.isArray(existing) ? existing : [existing] + const newArray = Array.isArray(rules) ? rules : [rules] + merged.auto_approval[cmd] = [...existingArray, ...newArray] + } else { + merged.auto_approval[cmd] = rules + } + } + + return merged +} diff --git a/src/utils/agentTools/shellTool.ts b/src/utils/agentTools/shellTool.ts new file mode 100644 index 00000000..d549d4e5 --- /dev/null +++ b/src/utils/agentTools/shellTool.ts @@ -0,0 +1,820 @@ +/** + * Shell Tool - Integrated from AgentTool + * Provides simple shell execution with YAML-based allowlist and timeout prediction + */ + +import { exec, ChildProcess } from 'child_process' +import { platform } from 'os' +import { debug } from '@utils/debugLogger' +import { getCwd } from '@utils/state' + +// Helper for logging +const log = (msg: string) => debug.trace('shell', msg) + +// ============================================================================ +// Types +// ============================================================================ + +export interface ShellResult { + stdout: string + stderr: string + exitCode: number + timedOut: boolean + interrupted: boolean + durationMs: number +} + +export interface ShellOptions { + /** Working directory */ + cwd?: string + /** Timeout in ms (default: 120000) */ + timeout?: number + /** Shell to use */ + shell?: string + /** Environment variables */ + env?: Record + /** Abort signal */ + abortSignal?: AbortSignal +} + +export type ShellAllowlistEntry = { + type: 'prefix' | 'exact' | 'any' | 'not_contains' + args?: string[] +} + +export type ShellAllowlist = { + [tool: string]: ShellAllowlistEntry | ShellAllowlistEntry[] +} + +export type ShellType = 'bash' | 'zsh' | 'fish' | 'powershell' + +// ============================================================================ +// Shell Allowlist - YAML-based comprehensive list from AgentTool +// ============================================================================ + +/** + * Tools allowlist - common DevOps and development tools + */ +const TOOLS_ALLOWLIST: ShellAllowlist = { + // Git - read-only operations + git: [ + { type: 'prefix', args: ['status'] }, + { type: 'prefix', args: ['log'] }, + { type: 'prefix', args: ['diff'] }, + { type: 'prefix', args: ['show'] }, + { type: 'exact', args: ['branch'] }, + { type: 'prefix', args: ['ls-files'] }, + { type: 'prefix', args: ['blame'] }, + { type: 'prefix', args: ['rev-parse'] }, + { type: 'prefix', args: ['remote', '-v'] }, + { type: 'prefix', args: ['config', '--list'] }, + { type: 'exact', args: ['config', 'user.name'] }, + { type: 'exact', args: ['config', 'user.email'] }, + { type: 'exact', args: ['branch', '--show-current'] }, + ], + // kubectl - read operations + kubectl: [ + { type: 'prefix', args: ['get'] }, + { type: 'prefix', args: ['describe'] }, + { type: 'prefix', args: ['explain'] }, + { type: 'prefix', args: ['logs'] }, + { type: 'prefix', args: ['top'] }, + { type: 'prefix', args: ['api-resources'] }, + { type: 'prefix', args: ['api-versions'] }, + { type: 'prefix', args: ['version'] }, + { type: 'prefix', args: ['auth', 'can-i'] }, + { type: 'prefix', args: ['config', 'get-contexts'] }, + { type: 'prefix', args: ['config', 'view'] }, + ], + // Docker - read operations + docker: [ + { type: 'prefix', args: ['ps'] }, + { type: 'prefix', args: ['images'] }, + { type: 'prefix', args: ['network', 'ls'] }, + { type: 'prefix', args: ['volume', 'ls'] }, + { type: 'prefix', args: ['port'] }, + { type: 'prefix', args: ['stats'] }, + { type: 'prefix', args: ['events'] }, + { type: 'prefix', args: ['diff'] }, + { type: 'prefix', args: ['history'] }, + { type: 'prefix', args: ['system', 'df'] }, + { type: 'prefix', args: ['top'] }, + { type: 'prefix', args: ['version'] }, + { type: 'prefix', args: ['inspect'] }, + ], + // npm - read operations + npm: [ + { type: 'prefix', args: ['list'] }, + { type: 'prefix', args: ['outdated'] }, + { type: 'prefix', args: ['doctor'] }, + { type: 'prefix', args: ['audit'] }, + { type: 'prefix', args: ['token', 'list'] }, + { type: 'prefix', args: ['ping'] }, + { type: 'prefix', args: ['view'] }, + { type: 'prefix', args: ['owner', 'ls'] }, + { type: 'prefix', args: ['fund'] }, + { type: 'prefix', args: ['explain'] }, + { type: 'prefix', args: ['ls'] }, + { type: 'prefix', args: ['why'] }, + { type: 'prefix', args: ['prefix'] }, + ], + // yarn - read operations + yarn: [ + { type: 'prefix', args: ['list'] }, + { type: 'prefix', args: ['info'] }, + { type: 'prefix', args: ['why'] }, + { type: 'prefix', args: ['licenses', 'list'] }, + { type: 'prefix', args: ['outdated'] }, + { type: 'prefix', args: ['check'] }, + { type: 'prefix', args: ['audit'] }, + { type: 'prefix', args: ['workspaces', 'info'] }, + { type: 'prefix', args: ['version'] }, + { type: 'prefix', args: ['config', 'list'] }, + ], + // pip - read operations + pip: [ + { type: 'prefix', args: ['list'] }, + { type: 'prefix', args: ['show'] }, + { type: 'prefix', args: ['check'] }, + { type: 'prefix', args: ['debug'] }, + { type: 'prefix', args: ['config', 'list'] }, + { type: 'prefix', args: ['index'] }, + { type: 'prefix', args: ['hash'] }, + { type: 'prefix', args: ['cache', 'list'] }, + { type: 'prefix', args: ['freeze'] }, + { type: 'prefix', args: ['version'] }, + ], + // cargo - read operations + cargo: [ + { type: 'prefix', args: ['tree'] }, + { type: 'prefix', args: ['metadata'] }, + { type: 'prefix', args: ['list'] }, + { type: 'prefix', args: ['verify'] }, + { type: 'prefix', args: ['search'] }, + { type: 'prefix', args: ['vendor', '--dry-run'] }, + { type: 'prefix', args: ['outdated'] }, + { type: 'prefix', args: ['doc'] }, + { type: 'prefix', args: ['config', 'get'] }, + { type: 'prefix', args: ['version'] }, + ], + // bazel - read operations + bazel: [ + { type: 'prefix', args: ['query'] }, + { type: 'prefix', args: ['cquery'] }, + { type: 'prefix', args: ['config'] }, + { type: 'prefix', args: ['info'] }, + { type: 'prefix', args: ['version'] }, + { type: 'prefix', args: ['help'] }, + { type: 'prefix', args: ['analyze-profile'] }, + { type: 'prefix', args: ['aquery'] }, + { type: 'prefix', args: ['dump'] }, + { type: 'prefix', args: ['license'] }, + { type: 'prefix', args: ['print'] }, + { type: 'prefix', args: ['build', '--nobuild'] }, + { type: 'prefix', args: ['test', '--nobuild'] }, + { type: 'prefix', args: ['run', '--nobuild'] }, + ], + // terraform - read/plan operations + terraform: [ + { type: 'prefix', args: ['show'] }, + { type: 'prefix', args: ['providers'] }, + { type: 'prefix', args: ['state', 'list'] }, + { type: 'prefix', args: ['state', 'show'] }, + { type: 'prefix', args: ['version'] }, + { type: 'prefix', args: ['fmt', '--check'] }, + { type: 'prefix', args: ['validate'] }, + { type: 'prefix', args: ['graph'] }, + { type: 'prefix', args: ['console'] }, + { type: 'prefix', args: ['output'] }, + { type: 'prefix', args: ['plan'] }, + ], + // gradle - read operations + gradle: [ + { type: 'prefix', args: ['dependencies'] }, + { type: 'prefix', args: ['projects'] }, + { type: 'prefix', args: ['properties'] }, + { type: 'prefix', args: ['tasks'] }, + { type: 'prefix', args: ['components'] }, + { type: 'prefix', args: ['model'] }, + { type: 'prefix', args: ['buildEnvironment'] }, + { type: 'prefix', args: ['help'] }, + { type: 'prefix', args: ['version'] }, + ], + // helm - read operations + helm: [ + { type: 'prefix', args: ['list'] }, + { type: 'prefix', args: ['get', 'values'] }, + { type: 'prefix', args: ['get', 'manifest'] }, + { type: 'prefix', args: ['get', 'hooks'] }, + { type: 'prefix', args: ['get', 'notes'] }, + { type: 'prefix', args: ['status'] }, + { type: 'prefix', args: ['dependency', 'list'] }, + { type: 'prefix', args: ['show', 'chart'] }, + { type: 'prefix', args: ['show', 'values'] }, + { type: 'prefix', args: ['verify'] }, + { type: 'prefix', args: ['version'] }, + { type: 'prefix', args: ['env'] }, + ], + // rustup - read operations + rustup: [ + { type: 'prefix', args: ['show'] }, + { type: 'prefix', args: ['toolchain', 'list'] }, + { type: 'prefix', args: ['target', 'list'] }, + { type: 'prefix', args: ['component', 'list'] }, + { type: 'prefix', args: ['override', 'list'] }, + { type: 'prefix', args: ['which'] }, + { type: 'prefix', args: ['doc'] }, + { type: 'prefix', args: ['man'] }, + { type: 'prefix', args: ['version'] }, + ], + // deno - read operations + deno: [ + { type: 'prefix', args: ['info'] }, + { type: 'prefix', args: ['list'] }, + { type: 'prefix', args: ['doc'] }, + { type: 'prefix', args: ['lint'] }, + { type: 'prefix', args: ['types'] }, + { type: 'prefix', args: ['check'] }, + { type: 'prefix', args: ['compile', '--dry-run'] }, + { type: 'prefix', args: ['task', '--list'] }, + { type: 'prefix', args: ['test', '--dry-run'] }, + { type: 'prefix', args: ['version'] }, + ], + // podman - read operations + podman: [ + { type: 'prefix', args: ['ps'] }, + { type: 'prefix', args: ['images'] }, + { type: 'prefix', args: ['pod', 'ps'] }, + { type: 'prefix', args: ['volume', 'ls'] }, + { type: 'prefix', args: ['network', 'ls'] }, + { type: 'prefix', args: ['stats'] }, + { type: 'prefix', args: ['top'] }, + { type: 'prefix', args: ['logs'] }, + { type: 'prefix', args: ['inspect'] }, + { type: 'prefix', args: ['port'] }, + ], +} + +/** + * Unix shell commands allowlist (bash, zsh, fish) + */ +const UNIX_SHELL_ALLOWLIST: ShellAllowlist = { + cd: { type: 'any' }, + date: { type: 'any' }, + cal: { type: 'any' }, + uname: { type: 'prefix', args: ['-a'] }, + hostname: { type: 'any' }, + whoami: { type: 'any' }, + id: { type: 'any' }, + ps: { type: 'any' }, + free: { type: 'any' }, + w: { type: 'any' }, + who: { type: 'any' }, + ping: { type: 'not_contains', args: ['-f'] }, + netstat: { type: 'any' }, + ss: { type: 'any' }, + ip: { type: 'prefix', args: ['addr'] }, + dig: { type: 'any' }, + nslookup: { type: 'any' }, + pwd: { type: 'any' }, + ls: { type: 'any' }, + file: { type: 'any' }, + stat: { type: 'any' }, + du: { type: 'any' }, + df: { type: 'any' }, + cat: { type: 'any' }, + less: { type: 'any' }, + more: { type: 'any' }, + head: { type: 'any' }, + tail: { type: 'not_contains', args: ['-f'] }, + wc: { type: 'any' }, + which: { type: 'any' }, + whereis: { type: 'any' }, + echo: { type: 'any' }, + printf: { type: 'any' }, + env: { type: 'any' }, + printenv: { type: 'any' }, + grep: { type: 'any' }, + egrep: { type: 'any' }, + fgrep: { type: 'any' }, + rg: { type: 'any' }, + ag: { type: 'any' }, + ack: { type: 'any' }, + find: { type: 'any' }, + locate: { type: 'any' }, + sort: { type: 'any' }, + uniq: { type: 'any' }, + cut: { type: 'any' }, + tr: { type: 'any' }, + diff: { type: 'any' }, + uptime: { type: 'any' }, + lsof: { type: 'any' }, + pgrep: { type: 'any' }, + test: { type: 'any' }, + '[': { type: 'any' }, + true: { type: 'any' }, + false: { type: 'any' }, +} + +/** + * PowerShell commands allowlist + */ +const POWERSHELL_ALLOWLIST: ShellAllowlist = { + cd: { type: 'any' }, + 'Get-Date': { type: 'any' }, + 'Get-ComputerInfo': { type: 'any' }, + 'Get-Host': { type: 'any' }, + '$env:USERNAME': { type: 'any' }, + whoami: { type: 'any' }, + 'Get-Process': { type: 'any' }, + ps: { type: 'any' }, + gps: { type: 'any' }, + 'Get-Service': { type: 'any' }, + gsv: { type: 'any' }, + 'Get-NetIPAddress': { type: 'any' }, + ipconfig: { type: 'any' }, + 'Get-NetTCPConnection': { type: 'any' }, + netstat: { type: 'any' }, + 'Resolve-DnsName': { type: 'any' }, + nslookup: { type: 'any' }, + 'Get-Location': { type: 'any' }, + pwd: { type: 'any' }, + gl: { type: 'any' }, + 'Get-ChildItem': { type: 'any' }, + dir: { type: 'any' }, + ls: { type: 'any' }, + gci: { type: 'any' }, + 'Get-Item': { type: 'any' }, + gi: { type: 'any' }, + 'Get-ItemProperty': { type: 'any' }, + gp: { type: 'any' }, + 'Get-Content': { type: 'not_contains', args: ['-Wait'] }, + cat: { type: 'any' }, + gc: { type: 'any' }, + type: { type: 'any' }, + 'Select-String': { type: 'any' }, + sls: { type: 'any' }, + findstr: { type: 'any' }, + 'Get-PSDrive': { type: 'any' }, + gdr: { type: 'any' }, + 'Get-Volume': { type: 'any' }, + 'Measure-Object': { type: 'any' }, + measure: { type: 'any' }, + 'Select-Object': { type: 'any' }, + select: { type: 'any' }, +} + +/** + * Commands that should never be executed + */ +const BANNED_PATTERNS = new Set([ + 'rm -rf /', + 'rm -rf /*', + 'rm -rf ~', + 'rm -rf ~/*', + ':(){:|:&};:', // Fork bomb + 'dd if=/dev/zero', + 'mkfs', + 'fdisk', + 'shutdown', + 'reboot', + 'halt', + 'poweroff', + 'init 0', + 'init 6', +]) + +// ============================================================================ +// Allowlist Checking Functions +// ============================================================================ + +/** + * Parse command into base command and arguments + */ +function parseCommandParts(command: string): { base: string; args: string[] } { + const trimmed = command.trim() + + // Parse into parts respecting quotes + const parts: string[] = [] + let current = '' + let inQuote = '' + + for (let i = 0; i < trimmed.length; i++) { + const char = trimmed[i] + + if (inQuote) { + if (char === inQuote) { + inQuote = '' + } else { + current += char + } + } else if (char === '"' || char === "'") { + inQuote = char + } else if (char === ' ' || char === '\t') { + if (current) { + parts.push(current) + current = '' + } + } else { + current += char + } + } + + if (current) { + parts.push(current) + } + + return { + base: parts[0] || '', + args: parts.slice(1), + } +} + +/** + * Check if command args match an allowlist entry + */ +function matchesAllowlistEntry( + args: string[], + entry: ShellAllowlistEntry +): boolean { + switch (entry.type) { + case 'any': + return true + + case 'exact': + if (!entry.args) return true + if (args.length !== entry.args.length) return false + return entry.args.every((expected, i) => args[i] === expected) + + case 'prefix': + if (!entry.args) return true + if (args.length < entry.args.length) return false + return entry.args.every((expected, i) => args[i] === expected) + + case 'not_contains': + if (!entry.args) return true + return !entry.args.some(forbidden => args.includes(forbidden)) + + default: + return false + } +} + +/** + * Check if command matches the allowlist + */ +function checkAllowlist(command: string, allowlist: ShellAllowlist): boolean { + const { base, args } = parseCommandParts(command) + const lowercaseBase = base.toLowerCase() + + const entry = allowlist[base] || allowlist[lowercaseBase] + if (!entry) return false + + const entries = Array.isArray(entry) ? entry : [entry] + return entries.some(e => matchesAllowlistEntry(args, e)) +} + +/** + * Get the combined allowlist based on shell type + */ +export function getShellAllowlist(shellName?: string): ShellAllowlist { + const currentPlatform = platform() + const isWindows = currentPlatform === 'win32' + const shell = shellName?.toLowerCase() || '' + + // Start with tools allowlist (available on all platforms) + const combined: ShellAllowlist = { ...TOOLS_ALLOWLIST } + + // Add shell-specific allowlist + if (isWindows || shell.includes('powershell') || shell.includes('pwsh')) { + Object.assign(combined, POWERSHELL_ALLOWLIST) + } else { + Object.assign(combined, UNIX_SHELL_ALLOWLIST) + } + + return combined +} + +/** + * Check if a command is in the safe allowlist + */ +export function isCommandSafe(command: string, shellName?: string): boolean { + const trimmedCommand = command.trim() + + // Check banned patterns first + if (isCommandBanned(trimmedCommand)) { + return false + } + + // Get the allowlist for the current shell + const allowlist = getShellAllowlist(shellName) + + // Check command against allowlist + return checkAllowlist(trimmedCommand, allowlist) +} + +/** + * Check if a command contains banned patterns + */ +export function isCommandBanned(command: string): boolean { + const trimmedCommand = command.trim().toLowerCase() + + for (const banned of BANNED_PATTERNS) { + if (trimmedCommand.includes(banned)) { + return true + } + } + + return false +} + +// ============================================================================ +// Command Timeout Prediction +// ============================================================================ + +/** + * Predict appropriate timeout for a command + */ +export function predictCommandTimeout(command: string): number { + const trimmed = command.trim().toLowerCase() + + // Long-running commands + if ( + trimmed.includes('npm install') || + trimmed.includes('yarn install') || + trimmed.includes('pip install') || + trimmed.includes('cargo build') || + trimmed.includes('go build') || + trimmed.includes('make') || + trimmed.includes('gradle') || + trimmed.includes('maven') || + trimmed.includes('mvn') + ) { + return 600000 // 10 minutes + } + + // Test commands + if ( + trimmed.includes('npm test') || + trimmed.includes('yarn test') || + trimmed.includes('pytest') || + trimmed.includes('jest') || + trimmed.includes('mocha') || + trimmed.includes('cargo test') || + trimmed.includes('go test') + ) { + return 300000 // 5 minutes + } + + // Network commands + if ( + trimmed.includes('curl') || + trimmed.includes('wget') || + trimmed.includes('git clone') || + trimmed.includes('git pull') || + trimmed.includes('git fetch') + ) { + return 180000 // 3 minutes + } + + // Quick commands + if ( + trimmed.startsWith('ls') || + trimmed.startsWith('cat') || + trimmed.startsWith('echo') || + trimmed.startsWith('pwd') || + trimmed.startsWith('which') || + trimmed.startsWith('git status') || + trimmed.startsWith('git branch') + ) { + return 30000 // 30 seconds + } + + // Default + return 120000 // 2 minutes +} + +// ============================================================================ +// Shell Execution +// ============================================================================ + +/** + * Quote command for shell execution + */ +export function quoteCommand(command: string, shell: string = 'bash'): string { + if (shell === 'bash' || shell === 'sh' || shell === 'zsh') { + // Escape single quotes by ending the string, adding escaped quote, starting new string + const escaped = command.replace(/'/g, "'\"'\"'") + return `'${escaped}'` + } + return command +} + +/** + * Get the default shell for the current platform + */ +export function getDefaultShell(): string { + if (platform() === 'win32') { + return process.env.COMSPEC || 'cmd.exe' + } + return process.env.SHELL || '/bin/bash' +} + +/** + * Execute a shell command with enhanced options + */ +export async function executeShell( + command: string, + options: ShellOptions = {} +): Promise { + const startTime = Date.now() + const { + cwd = getCwd(), + timeout = predictCommandTimeout(command), + shell = getDefaultShell(), + env, + abortSignal, + } = options + + log( `Executing: ${command}`) + log( `Options: cwd=${cwd}, timeout=${timeout}, shell=${shell}`) + + // Check for banned commands + if (isCommandBanned(command)) { + return { + stdout: '', + stderr: 'Error: This command is blocked for security reasons.', + exitCode: 1, + timedOut: false, + interrupted: false, + durationMs: Date.now() - startTime, + } + } + + return new Promise((resolve) => { + let stdout = '' + let stderr = '' + let timedOut = false + let interrupted = false + let childProcess: ChildProcess | null = null + + const timeoutId = setTimeout(() => { + timedOut = true + if (childProcess && !childProcess.killed) { + childProcess.kill('SIGTERM') + // Force kill after 5 seconds if still running + setTimeout(() => { + if (childProcess && !childProcess.killed) { + childProcess.kill('SIGKILL') + } + }, 5000) + } + }, timeout) + + // Handle abort signal + if (abortSignal) { + const abortHandler = () => { + interrupted = true + clearTimeout(timeoutId) + if (childProcess && !childProcess.killed) { + childProcess.kill('SIGTERM') + } + } + + if (abortSignal.aborted) { + resolve({ + stdout: '', + stderr: 'Command was cancelled before execution.', + exitCode: 130, + timedOut: false, + interrupted: true, + durationMs: Date.now() - startTime, + }) + return + } + + abortSignal.addEventListener('abort', abortHandler, { once: true }) + } + + // Build exec command + let execCommand = command + if (shell.includes('bash') || shell.includes('zsh')) { + execCommand = `${shell} -l -c ${quoteCommand(command, shell)}` + } + + // Merge environment + const mergedEnv = env ? { ...process.env, ...env } : process.env + + childProcess = exec( + execCommand, + { + cwd, + env: mergedEnv as NodeJS.ProcessEnv, + maxBuffer: 10 * 1024 * 1024, // 10MB + timeout: 0, // We handle timeout ourselves + }, + (error, stdoutData, stderrData) => { + clearTimeout(timeoutId) + + stdout = stdoutData || '' + stderr = stderrData || '' + + let exitCode = 0 + if (error) { + exitCode = (error as any).code || 1 + } + + if (timedOut) { + stderr += `\nCommand timed out after ${timeout / 1000}s` + } + + if (interrupted) { + stderr += '\nCommand was interrupted' + } + + resolve({ + stdout: stdout.trim(), + stderr: stderr.trim(), + exitCode, + timedOut, + interrupted, + durationMs: Date.now() - startTime, + }) + } + ) + }) +} + +/** + * Simple shell execution (returns stdout/stderr combined) + */ +export async function simpleShell( + command: string, + options: ShellOptions = {} +): Promise { + const result = await executeShell(command, options) + const output = [result.stdout, result.stderr].filter(Boolean).join('\n') + return output +} + +/** + * Execute multiple commands sequentially + */ +export async function executeShellSequence( + commands: string[], + options: ShellOptions = {} +): Promise { + const results: ShellResult[] = [] + + for (const command of commands) { + const result = await executeShell(command, options) + results.push(result) + + // Stop on error unless the command is expected to potentially fail + if (result.exitCode !== 0 && !result.timedOut && !result.interrupted) { + break + } + } + + return results +} + +// ============================================================================ +// Shell Utilities +// ============================================================================ + +/** + * Parse a command string into parts + */ +export function parseCommand(command: string): { + base: string + args: string[] + pipes: string[] +} { + // Split by pipes first + const pipes = command.split('|').map(s => s.trim()) + const firstCommand = pipes[0] || '' + + // Parse first command for base and args + const parts = firstCommand.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [] + const base = parts[0] || '' + const args = parts.slice(1) + + return { base, args, pipes } +} + +/** + * Check if command is a directory change + */ +export function isDirectoryChange(command: string): string | null { + const trimmed = command.trim() + const match = trimmed.match(/^cd\s+(.+)$/) + return match ? match[1].replace(/^['"]|['"]$/g, '') : null +} + +/** + * Get shell type for platform + */ +export function getShellType(): 'unix' | 'windows' { + return platform() === 'win32' ? 'windows' : 'unix' +} diff --git a/src/utils/agentTools/taskManager.ts b/src/utils/agentTools/taskManager.ts new file mode 100644 index 00000000..3d0fff1e --- /dev/null +++ b/src/utils/agentTools/taskManager.ts @@ -0,0 +1,492 @@ +/** + * TaskManager - Hierarchical task management system + * + * Migrated from AgentTool/13-TaskManagement + * + * Features: + * - Task creation with parent/child relationships + * - Task state tracking (pending, in_progress, completed, cancelled) + * - Tree diffing for batch updates + * - Manifest-based persistence + */ + +import * as crypto from 'crypto' + +// ============================================================================ +// Types +// ============================================================================ + +export enum TaskState { + PENDING = 'pending', + IN_PROGRESS = 'in_progress', + COMPLETED = 'completed', + CANCELLED = 'cancelled', + BLOCKED = 'blocked', +} + +export enum TaskUpdatedBy { + USER = 'user', + AGENT = 'agent', + SYSTEM = 'system', +} + +export interface SerializedTask { + uuid: string + name: string + description: string + state: TaskState + subTasks: string[] // UUIDs of child tasks + lastUpdated: number + lastUpdatedBy?: TaskUpdatedBy + createdAt: number + metadata?: Record +} + +export interface HydratedTask extends SerializedTask { + subTasksData?: HydratedTask[] +} + +export interface TaskMetadata { + uuid: string + name: string + lastUpdated: number + state: TaskState + parentTask?: string +} + +export interface TaskManifest { + version: number + lastUpdated: number + tasks: Record +} + +export interface TaskStorage { + loadManifest(): Promise + saveManifest(manifest: TaskManifest): Promise + loadTask(uuid: string): Promise + saveTask(uuid: string, task: SerializedTask): Promise +} + +// ============================================================================ +// Task Factory +// ============================================================================ + +export class TaskFactory { + /** + * Create a new task with default values + */ + static createTask(name: string, description: string): SerializedTask { + const now = Date.now() + return { + uuid: crypto.randomUUID(), + name, + description, + state: TaskState.PENDING, + subTasks: [], + lastUpdated: now, + createdAt: now, + } + } +} + +// ============================================================================ +// Task Utilities +// ============================================================================ + +interface TaskTreeDiff { + created: SerializedTask[] + updated: SerializedTask[] + deleted: SerializedTask[] +} + +/** + * Compare two task trees and identify differences + */ +export function diffTaskTrees(existing: HydratedTask, updated: HydratedTask): TaskTreeDiff { + const created: SerializedTask[] = [] + const updatedTasks: SerializedTask[] = [] + const deleted: SerializedTask[] = [] + + const existingMap = new Map() + const updatedMap = new Map() + + // Build maps for O(1) lookup + function buildMap(task: HydratedTask, map: Map): void { + map.set(task.uuid, task) + for (const subTask of task.subTasksData ?? []) { + buildMap(subTask, map) + } + } + + buildMap(existing, existingMap) + buildMap(updated, updatedMap) + + // Find created and updated tasks + for (const [uuid, task] of updatedMap) { + const existingTask = existingMap.get(uuid) + if (!existingTask) { + created.push(task) + } else if (hasTaskChanged(existingTask, task)) { + updatedTasks.push(task) + } + } + + // Find deleted tasks + for (const [uuid, task] of existingMap) { + if (!updatedMap.has(uuid)) { + deleted.push(task) + } + } + + return { created, updated: updatedTasks, deleted } +} + +function hasTaskChanged(existing: SerializedTask, updated: SerializedTask): boolean { + return ( + existing.name !== updated.name || + existing.description !== updated.description || + existing.state !== updated.state || + JSON.stringify(existing.subTasks) !== JSON.stringify(updated.subTasks) + ) +} + +// ============================================================================ +// In-Memory Task Storage +// ============================================================================ + +export class InMemoryTaskStorage implements TaskStorage { + private _manifest: TaskManifest | null = null + private _tasks = new Map() + + async loadManifest(): Promise { + return this._manifest + } + + async saveManifest(manifest: TaskManifest): Promise { + this._manifest = manifest + } + + async loadTask(uuid: string): Promise { + return this._tasks.get(uuid) + } + + async saveTask(uuid: string, task: SerializedTask): Promise { + this._tasks.set(uuid, task) + } +} + +// ============================================================================ +// TaskManager +// ============================================================================ + +/** + * TaskManager - High-level API for managing tasks + * + * Features: + * - Create tasks with parent/child relationships + * - Update task states + * - Track task hierarchy + * - Batch updates with tree diffing + */ +export class TaskManager { + private _initialized = false + private _manifest: TaskManifest = { + version: 1, + lastUpdated: Date.now(), + tasks: {}, + } + private _currentRootTaskUuid: string | undefined + + constructor(private _storage: TaskStorage) {} + + /** + * Initialize the task manager + */ + async initialize(): Promise { + if (this._initialized) return + + const manifest = await this._storage.loadManifest() + if (manifest) { + this._manifest = manifest + } + + this._initialized = true + } + + private async _ensureInitialized(): Promise { + if (!this._initialized) { + await this.initialize() + } + } + + private async _updateManifest(task: SerializedTask, parentTaskUuid?: string): Promise { + await this._ensureInitialized() + + this._manifest.tasks[task.uuid] = { + uuid: task.uuid, + name: task.name, + lastUpdated: task.lastUpdated, + state: task.state, + parentTask: parentTaskUuid, + } + this._manifest.lastUpdated = Date.now() + + await this._storage.saveManifest(this._manifest) + } + + /** + * Create a new task + */ + async createTask( + name: string, + description: string, + parentTaskUuid?: string, + insertAfterUuid?: string + ): Promise { + await this._ensureInitialized() + + const task = TaskFactory.createTask(name, description) + const effectiveParentUuid = parentTaskUuid || this._currentRootTaskUuid + + if (effectiveParentUuid) { + const parentTask = await this.getTask(effectiveParentUuid) + if (parentTask) { + if (insertAfterUuid) { + const targetIndex = parentTask.subTasks.indexOf(insertAfterUuid) + if (targetIndex !== -1) { + const subTasks = [...parentTask.subTasks] + subTasks.splice(targetIndex + 1, 0, task.uuid) + await this.updateTask(effectiveParentUuid, { subTasks }, TaskUpdatedBy.USER) + } else { + await this.updateTask( + effectiveParentUuid, + { subTasks: [...parentTask.subTasks, task.uuid] }, + TaskUpdatedBy.USER + ) + } + } else { + await this.updateTask( + effectiveParentUuid, + { subTasks: [...parentTask.subTasks, task.uuid] }, + TaskUpdatedBy.USER + ) + } + } + } + + await this._storage.saveTask(task.uuid, task) + await this._updateManifest(task, effectiveParentUuid) + + return task.uuid + } + + /** + * Update an existing task + */ + async updateTask( + uuid: string, + updates: Partial, + updatedBy: TaskUpdatedBy + ): Promise { + await this._ensureInitialized() + + const task = await this.getTask(uuid) + if (!task) return + + const updatedTask: SerializedTask = { + ...task, + ...updates, + lastUpdated: Date.now(), + lastUpdatedBy: updatedBy, + } + + await this._storage.saveTask(uuid, updatedTask) + await this._updateManifest(updatedTask, this._manifest.tasks[uuid]?.parentTask) + } + + /** + * Get a task by UUID + */ + async getTask(uuid: string): Promise { + await this._ensureInitialized() + return this._storage.loadTask(uuid) + } + + /** + * Get a task with all sub-tasks hydrated + */ + async getHydratedTask(uuid: string): Promise { + await this._ensureInitialized() + + const task = await this.getTask(uuid) + if (!task) return undefined + + const subTasksData: HydratedTask[] = [] + for (const subTaskUuid of task.subTasks) { + const subTask = await this.getHydratedTask(subTaskUuid) + if (subTask) { + subTasksData.push(subTask) + } + } + + return { ...task, subTasksData } + } + + /** + * Cancel a task and optionally its sub-tasks + */ + async cancelTask( + uuid: string, + cancelSubTasks: boolean = false, + updatedBy: TaskUpdatedBy = TaskUpdatedBy.USER + ): Promise { + await this._ensureInitialized() + + const task = await this.getTask(uuid) + if (!task) return + + await this.updateTask(uuid, { state: TaskState.CANCELLED }, updatedBy) + + if (cancelSubTasks) { + for (const subTaskUuid of task.subTasks) { + await this.cancelTask(subTaskUuid, true, updatedBy) + } + } + } + + /** + * Complete a task + */ + async completeTask(uuid: string, updatedBy: TaskUpdatedBy = TaskUpdatedBy.USER): Promise { + await this.updateTask(uuid, { state: TaskState.COMPLETED }, updatedBy) + } + + /** + * Start a task (mark as in progress) + */ + async startTask(uuid: string, updatedBy: TaskUpdatedBy = TaskUpdatedBy.USER): Promise { + await this.updateTask(uuid, { state: TaskState.IN_PROGRESS }, updatedBy) + } + + /** + * Get all tasks + */ + async getAllTasks(): Promise { + await this._ensureInitialized() + + const tasks: SerializedTask[] = [] + for (const uuid of Object.keys(this._manifest.tasks)) { + const task = await this.getTask(uuid) + if (task) { + tasks.push(task) + } + } + return tasks + } + + /** + * Get all root tasks (tasks with no parent) + */ + async getRootTasks(): Promise { + await this._ensureInitialized() + + const rootTaskUuids = Object.entries(this._manifest.tasks) + .filter(([_, metadata]) => !metadata.parentTask) + .map(([uuid]) => uuid) + + const rootTasks: SerializedTask[] = [] + for (const uuid of rootTaskUuids) { + const task = await this.getTask(uuid) + if (task) { + rootTasks.push(task) + } + } + return rootTasks + } + + /** + * Get current root task UUID + */ + getCurrentRootTaskUuid(): string | undefined { + return this._currentRootTaskUuid + } + + /** + * Set current root task UUID + */ + setCurrentRootTaskUuid(uuid: string): void { + this._currentRootTaskUuid = uuid + } + + /** + * Update an entire task tree + */ + async updateHydratedTask( + newTree: HydratedTask, + updatedBy: TaskUpdatedBy + ): Promise<{ created: number; updated: number; deleted: number }> { + await this._ensureInitialized() + + const existingTree = await this.getHydratedTask(newTree.uuid) + if (!existingTree) { + return { created: 0, updated: 0, deleted: 0 } + } + + const changes = diffTaskTrees(existingTree, newTree) + + // Create new tasks + const tempUuidToRealUuid = new Map() + for (const task of changes.created) { + const oldUuid = task.uuid + const newUuid = await this.createTask(task.name, task.description) + tempUuidToRealUuid.set(oldUuid, newUuid) + task.uuid = newUuid + } + + // Update UUIDs in all tasks + const updateUuids = (task: SerializedTask) => { + task.subTasks = task.subTasks.map((uuid) => tempUuidToRealUuid.get(uuid) || uuid) + } + changes.created.forEach(updateUuids) + changes.updated.forEach(updateUuids) + + // Update existing tasks + for (const task of [...changes.created, ...changes.updated]) { + await this.updateTask( + task.uuid, + { + name: task.name, + description: task.description, + state: task.state, + subTasks: task.subTasks, + }, + updatedBy + ) + } + + // Delete tasks (cancel them) + for (const task of changes.deleted) { + if (task.uuid === newTree.uuid) continue + await this.cancelTask(task.uuid, true, updatedBy) + } + + return { + created: changes.created.length, + updated: changes.updated.length, + deleted: changes.deleted.length, + } + } +} + +// ============================================================================ +// Singleton Export +// ============================================================================ + +let _taskManager: TaskManager | undefined + +export function getTaskManager(storage?: TaskStorage): TaskManager { + if (!_taskManager) { + _taskManager = new TaskManager(storage ?? new InMemoryTaskStorage()) + } + return _taskManager +} diff --git a/src/utils/agentTools/workspaceUtils.ts b/src/utils/agentTools/workspaceUtils.ts new file mode 100644 index 00000000..4109be83 --- /dev/null +++ b/src/utils/agentTools/workspaceUtils.ts @@ -0,0 +1,351 @@ +/** + * WorkspaceUtils - Path utilities and blob name calculation + * + * Migrated from AgentTool/11-WorkspaceManagement + * + * Features: + * - SHA256 blob name calculator + * - Qualified path name handling + * - Cross-platform path normalization + */ + +import * as crypto from 'crypto' +import * as path from 'path' +import * as fs from 'fs/promises' + +// ============================================================================ +// Qualified Path Name +// ============================================================================ + +/** + * Represents a file path with both root and relative components + */ +export interface QualifiedPathName { + /** The workspace/project root path */ + rootPath: string + /** The path relative to the root */ + relPath: string + /** The absolute path (computed) */ + readonly absPath: string +} + +/** + * Create a qualified path name + */ +export function createQualifiedPath(rootPath: string, relPath: string): QualifiedPathName { + const normalized = normalizeRelativePath(relPath) + return { + rootPath: path.normalize(rootPath), + relPath: normalized, + get absPath() { + return path.join(this.rootPath, this.relPath) + }, + } +} + +/** + * Parse an absolute path into qualified path name + */ +export function parseAbsolutePath( + absPath: string, + workspaceRoots: string[] +): QualifiedPathName | null { + const normalizedAbs = path.normalize(absPath) + + for (const root of workspaceRoots) { + const normalizedRoot = path.normalize(root) + if (normalizedAbs.startsWith(normalizedRoot + path.sep) || normalizedAbs === normalizedRoot) { + const relPath = path.relative(normalizedRoot, normalizedAbs) + if (!relPath.startsWith('..')) { + return createQualifiedPath(normalizedRoot, relPath) + } + } + } + + return null +} + +// ============================================================================ +// Path Normalization +// ============================================================================ + +/** + * Normalize a relative path (convert backslashes, remove leading ./) + */ +export function normalizeRelativePath(relativePath: string): string { + if (!relativePath) return '.' + + return relativePath + .replace(/\\/g, '/') + .replace(/^\.\//, '') + .replace(/\/+$/, '') || '.' +} + +/** + * Join path segments and normalize + */ +export function joinPath(...parts: string[]): string { + return parts + .filter(Boolean) + .join('/') + .replace(/\/+/g, '/') +} + +/** + * Get the directory name from a path + */ +export function getDirname(filepath: string): string { + const lastSlash = filepath.lastIndexOf('/') + if (lastSlash === -1) return '' + return filepath.slice(0, lastSlash) +} + +/** + * Get the base name from a path + */ +export function getBasename(filepath: string): string { + const lastSlash = filepath.lastIndexOf('/') + return lastSlash === -1 ? filepath : filepath.slice(lastSlash + 1) +} + +/** + * Get the file extension + */ +export function getExtension(filepath: string): string { + const basename = getBasename(filepath) + const lastDot = basename.lastIndexOf('.') + return lastDot === -1 ? '' : basename.slice(lastDot) +} + +// ============================================================================ +// Blob Name Calculator +// ============================================================================ + +/** + * Calculate a SHA256 hash of content + */ +export function sha256(content: string | Buffer): string { + return crypto.createHash('sha256').update(content).digest('hex') +} + +/** + * Calculate a blob name for file content (git-style) + * + * Uses SHA256 of the content with a prefix for content-addressable storage + */ +export function calculateBlobName(content: string | Buffer): string { + const contentBuffer = typeof content === 'string' ? Buffer.from(content, 'utf8') : content + const header = `blob ${contentBuffer.length}\0` + const store = Buffer.concat([Buffer.from(header), contentBuffer]) + return sha256(store) +} + +/** + * Calculate blob name for a file on disk + */ +export async function calculateFileBlobName(filepath: string): Promise { + const content = await fs.readFile(filepath) + return calculateBlobName(content) +} + +// ============================================================================ +// Path Matching +// ============================================================================ + +/** + * Simple glob pattern matching (supports * and **) + */ +export function matchGlob(pattern: string, filepath: string): boolean { + // Escape regex special chars except * and ** + let regex = pattern + .replace(/[.+^${}()|[\]\\]/g, '\\$&') + .replace(/\*\*/g, '{{GLOBSTAR}}') + .replace(/\*/g, '[^/]*') + .replace(/\{\{GLOBSTAR\}\}/g, '.*') + + return new RegExp(`^${regex}$`).test(filepath) +} + +/** + * Filter files by glob patterns + */ +export function filterByGlobs( + files: string[], + includePatterns: string[], + excludePatterns: string[] = [] +): string[] { + return files.filter((file) => { + // Check if excluded + for (const pattern of excludePatterns) { + if (matchGlob(pattern, file)) return false + } + + // Check if included (if no include patterns, include all) + if (includePatterns.length === 0) return true + + for (const pattern of includePatterns) { + if (matchGlob(pattern, file)) return true + } + + return false + }) +} + +// ============================================================================ +// File Type Detection +// ============================================================================ + +export enum FileType { + TEXT = 'text', + BINARY = 'binary', + UNKNOWN = 'unknown', +} + +const TEXT_EXTENSIONS = new Set([ + '.txt', '.md', '.markdown', '.rst', '.asciidoc', + '.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', + '.py', '.pyw', '.pyi', + '.rb', '.erb', + '.php', '.phtml', + '.java', '.kt', '.kts', '.groovy', '.scala', + '.c', '.h', '.cpp', '.hpp', '.cc', '.hh', '.cxx', '.hxx', + '.cs', '.fs', '.fsx', + '.go', '.rs', '.swift', '.m', '.mm', + '.html', '.htm', '.xhtml', '.xml', '.xsl', '.xslt', + '.css', '.scss', '.sass', '.less', '.styl', + '.json', '.jsonc', '.json5', + '.yaml', '.yml', + '.toml', '.ini', '.cfg', '.conf', '.config', + '.sh', '.bash', '.zsh', '.fish', '.ps1', '.psm1', '.psd1', '.bat', '.cmd', + '.sql', '.pgsql', '.mysql', + '.graphql', '.gql', + '.proto', + '.r', '.R', '.rmd', '.Rmd', + '.lua', '.vim', '.el', '.lisp', '.clj', '.cljs', '.cljc', '.edn', + '.ex', '.exs', '.erl', '.hrl', + '.hs', '.lhs', + '.ml', '.mli', '.ocaml', + '.pas', '.pp', + '.pl', '.pm', '.pod', + '.tcl', '.tk', + '.asm', '.s', + '.v', '.sv', '.svh', '.vhd', '.vhdl', + '.cmake', '.make', '.makefile', '.mk', + '.dockerfile', '.containerfile', + '.gitignore', '.gitattributes', '.gitmodules', + '.editorconfig', '.prettierrc', '.eslintrc', + '.env', '.env.local', '.env.development', '.env.production', + '.lock', '.lockb', + '.log', +]) + +/** + * Detect file type based on extension + */ +export function detectFileType(filepath: string): FileType { + const ext = getExtension(filepath).toLowerCase() + + if (TEXT_EXTENSIONS.has(ext)) return FileType.TEXT + + // Check for common binary extensions + const BINARY_EXTENSIONS = new Set([ + '.png', '.jpg', '.jpeg', '.gif', '.ico', '.webp', '.bmp', '.tiff', + '.mp3', '.mp4', '.wav', '.mov', '.avi', '.mkv', '.m4a', '.aac', + '.zip', '.tar', '.gz', '.rar', '.7z', '.bz2', '.xz', + '.exe', '.dll', '.so', '.dylib', '.o', '.a', '.lib', + '.db', '.sqlite', '.sqlite3', + '.class', '.pyc', '.pyo', '.obj', + '.woff', '.woff2', '.ttf', '.otf', '.eot', + '.jar', '.war', '.ear', '.apk', + '.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', + ]) + + if (BINARY_EXTENSIONS.has(ext)) return FileType.BINARY + + return FileType.UNKNOWN +} + +// ============================================================================ +// Workspace Manager +// ============================================================================ + +export interface Workspace { + name: string + rootPath: string +} + +/** + * WorkspaceManager - Manages multiple workspace roots + */ +export class WorkspaceManager { + private _workspaces: Workspace[] = [] + + /** + * Add a workspace + */ + addWorkspace(name: string, rootPath: string): void { + const existing = this._workspaces.find((w) => w.rootPath === rootPath) + if (!existing) { + this._workspaces.push({ name, rootPath: path.normalize(rootPath) }) + } + } + + /** + * Remove a workspace + */ + removeWorkspace(rootPath: string): void { + this._workspaces = this._workspaces.filter((w) => w.rootPath !== path.normalize(rootPath)) + } + + /** + * Get all workspaces + */ + getWorkspaces(): Workspace[] { + return [...this._workspaces] + } + + /** + * Get workspace root paths + */ + getRootPaths(): string[] { + return this._workspaces.map((w) => w.rootPath) + } + + /** + * Find workspace containing a path + */ + findWorkspace(filepath: string): Workspace | undefined { + const normalizedPath = path.normalize(filepath) + return this._workspaces.find( + (w) => + normalizedPath.startsWith(w.rootPath + path.sep) || normalizedPath === w.rootPath + ) + } + + /** + * Parse absolute path to qualified path + */ + parseAbsolutePath(absPath: string): QualifiedPathName | null { + return parseAbsolutePath(absPath, this.getRootPaths()) + } + + /** + * Check if a file is within any workspace + */ + isWithinWorkspace(filepath: string): boolean { + return this.findWorkspace(filepath) !== undefined + } +} + +// ============================================================================ +// Singleton Export +// ============================================================================ + +let _workspaceManager: WorkspaceManager | undefined + +export function getWorkspaceManager(): WorkspaceManager { + if (!_workspaceManager) { + _workspaceManager = new WorkspaceManager() + } + return _workspaceManager +} diff --git a/src/utils/ask.tsx b/src/utils/ask.tsx index f24bc3f9..86543b05 100644 --- a/src/utils/ask.tsx +++ b/src/utils/ask.tsx @@ -77,10 +77,13 @@ export async function ask({ if (!result || result.type !== 'assistant') { throw new Error('Expected content to be an assistant message') } - if (result.message.content[0]?.type !== 'text') { + + // Filter out thinking blocks from content + const textContent = result.message.content.find(c => c.type === 'text') + if (!textContent) { throw new Error( - `Expected first content item to be text, but got ${JSON.stringify( - result.message.content[0], + `Expected at least one text content item, but got ${JSON.stringify( + result.message.content, null, 2, )}`, @@ -92,7 +95,7 @@ export async function ask({ overwriteLog(messageHistoryFile, messages) return { - resultText: result.message.content[0].text, + resultText: textContent.text, totalCost: getTotalCost(), messageHistoryFile, } diff --git a/src/utils/ripgrep.ts b/src/utils/ripgrep.ts index eb292885..6a5a7aeb 100644 --- a/src/utils/ripgrep.ts +++ b/src/utils/ripgrep.ts @@ -1,11 +1,18 @@ import { findActualExecutable } from 'spawn-rx' -import { rgPath as vsCodeRgPath } from '@vscode/ripgrep' import { memoize } from 'lodash-es' +import { fileURLToPath, resolve } from 'node:url' +import * as path from 'path' import { logError } from './log' import { execFileNoThrow } from './execFileNoThrow' import { execFile } from 'child_process' import debug from 'debug' +const __filename = fileURLToPath(import.meta.url) +const __dirname = resolve( + __filename, + process.env.NODE_ENV === 'test' ? '../..' : '.', +) + const d = debug('claude:ripgrep') const useBuiltinRipgrep = !!process.env.USE_BUILTIN_RIPGREP @@ -22,9 +29,21 @@ const ripgrepPath = memoize(() => { // path rather than just returning 'rg' return cmd } else { - // Use @vscode/ripgrep which auto-downloads platform-specific binary - d('Using @vscode/ripgrep: %s', vsCodeRgPath) - return vsCodeRgPath + // Use the one we ship in-box + const rgRoot = path.resolve(__dirname, 'vendor', 'ripgrep') + if (process.platform === 'win32') { + // NB: Ripgrep doesn't ship an aarch64 binary for Windows, boooooo + return path.resolve(rgRoot, 'x64-win32', 'rg.exe') + } + + const ret = path.resolve( + rgRoot, + `${process.arch}-${process.platform}`, + 'rg', + ) + + d('internal ripgrep resolved as: %s', ret) + return ret } }) diff --git a/src/utils/subAgentStateManager.ts b/src/utils/subAgentStateManager.ts new file mode 100644 index 00000000..5606a26a --- /dev/null +++ b/src/utils/subAgentStateManager.ts @@ -0,0 +1,400 @@ +/** + * SubAgentStateManager + * Manages state and results for sub-agents, enabling multi-agent collaboration + * and result sharing across task executions. + * + * Based on AgentTool's ISubAgentStateManager pattern. + */ + +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs' +import { join } from 'path' +import { homedir } from 'os' + +/** + * Valid color options for sub-agent visual representation + * Matches AgentTool's VALID_SUB_AGENT_COLORS + */ +export const VALID_SUB_AGENT_COLORS = [ + 'red', + 'blue', + 'green', + 'yellow', + 'orange', + 'purple', + 'pink', + 'cyan', +] as const + +export type ValidSubAgentColor = (typeof VALID_SUB_AGENT_COLORS)[number] + +/** + * Result interface for sub-agent execution + */ +export interface SubAgentResult { + /** Name/identifier of the sub-agent */ + name: string + /** Agent type used for execution */ + agentType: string + /** Final result message from the sub-agent */ + result: string + /** Diff of changes made (if applicable) */ + diff?: string + /** The request ID of the sub-agent's execution */ + requestId?: string + /** Total number of tool calls made by the sub-agent */ + toolCallCount: number + /** Total number of tool errors encountered */ + errorCount: number + /** Execution start time */ + startedAt: number + /** Execution end time */ + completedAt: number + /** Duration in milliseconds */ + durationMs: number + /** Model used for execution */ + model?: string + /** Color configuration */ + color?: ValidSubAgentColor + /** Status of the execution */ + status: 'running' | 'completed' | 'failed' | 'interrupted' +} + +/** + * Analytics event types for sub-agent tracking + */ +export enum SubAgentAnalyticsEvent { + STARTED = 'sub_agent_started', + COMPLETED = 'sub_agent_completed', + FAILED = 'sub_agent_failed', + INTERRUPTED = 'sub_agent_interrupted', + TOOL_CALLED = 'sub_agent_tool_called', +} + +/** + * Analytics event data + */ +export interface SubAgentAnalyticsData { + event: SubAgentAnalyticsEvent + subAgentId: string + subAgentName: string + agentType: string + model?: string + color?: ValidSubAgentColor + durationMs?: number + toolCallCount?: number + errorCount?: number + errorMessage?: string + timestamp: number +} + +/** + * Interface for sub-agent state management + * Matches AgentTool's ISubAgentStateManager + */ +export interface ISubAgentStateManager { + /** Get a sub-agent stored result by ID */ + getSubAgentStoredResult(subAgentId: string): SubAgentResult | undefined + + /** Set a sub-agent stored result by ID */ + setSubAgentStoredResult(subAgentId: string, result: SubAgentResult): void + + /** Get all sub-agent stored results */ + getAllSubAgentStoredResults(): Record + + /** Clear all sub-agent stored results */ + clearSubAgentStoredResults(): void + + /** Remove a specific sub-agent stored result by ID */ + removeSubAgentStoredResult(subAgentId: string): void + + /** Find sub-agent ID by name */ + findSubAgentIdByName(name: string): string | undefined + + /** Get results for a specific agent type */ + getResultsByAgentType(agentType: string): SubAgentResult[] +} + +/** + * In-memory implementation of SubAgentStateManager + * Suitable for single-session use + */ +export class InMemorySubAgentStateManager implements ISubAgentStateManager { + private _store: Record = {} + private _analyticsCallbacks: ((data: SubAgentAnalyticsData) => void)[] = [] + + getSubAgentStoredResult(subAgentId: string): SubAgentResult | undefined { + return this._store[subAgentId] + } + + setSubAgentStoredResult(subAgentId: string, result: SubAgentResult): void { + this._store[subAgentId] = result + } + + getAllSubAgentStoredResults(): Record { + return { ...this._store } + } + + clearSubAgentStoredResults(): void { + this._store = {} + } + + removeSubAgentStoredResult(subAgentId: string): void { + delete this._store[subAgentId] + } + + findSubAgentIdByName(name: string): string | undefined { + return Object.keys(this._store).find( + (subAgentId) => this._store[subAgentId].name === name + ) + } + + getResultsByAgentType(agentType: string): SubAgentResult[] { + return Object.values(this._store).filter( + (result) => result.agentType === agentType + ) + } + + /** Register analytics callback */ + onAnalyticsEvent(callback: (data: SubAgentAnalyticsData) => void): void { + this._analyticsCallbacks.push(callback) + } + + /** Emit analytics event */ + emitAnalyticsEvent(data: SubAgentAnalyticsData): void { + for (const callback of this._analyticsCallbacks) { + try { + callback(data) + } catch (error) { + console.error('Analytics callback error:', error) + } + } + } +} + +/** + * File-based implementation of SubAgentStateManager + * Persists state and analytics across sessions + */ +export class FileBasedSubAgentStateManager implements ISubAgentStateManager { + private _memoryManager: InMemorySubAgentStateManager + private _filePath: string + private _analyticsPath: string + private _analyticsEnabled: boolean + + constructor(sessionId: string = 'default', enableAnalytics: boolean = true) { + this._memoryManager = new InMemorySubAgentStateManager() + this._analyticsEnabled = enableAnalytics + + const configDir = process.env.KODE_CONFIG_DIR ?? join(homedir(), '.kode') + if (!existsSync(configDir)) { + mkdirSync(configDir, { recursive: true }) + } + + this._filePath = join(configDir, `${sessionId}-subagent-state.json`) + this._analyticsPath = join(configDir, `${sessionId}-subagent-analytics.jsonl`) + this._loadFromFile() + + // Register analytics persistence callback + if (this._analyticsEnabled) { + this._memoryManager.onAnalyticsEvent((data) => { + this._appendAnalyticsEvent(data) + }) + } + } + + private _appendAnalyticsEvent(data: SubAgentAnalyticsData): void { + try { + const line = JSON.stringify(data) + '\n' + const { appendFileSync } = require('fs') + appendFileSync(this._analyticsPath, line, 'utf-8') + } catch (error) { + // Silently fail analytics persistence + } + } + + private _loadFromFile(): void { + if (existsSync(this._filePath)) { + try { + const content = readFileSync(this._filePath, 'utf-8') + const data = JSON.parse(content) + for (const [id, result] of Object.entries(data)) { + this._memoryManager.setSubAgentStoredResult(id, result as SubAgentResult) + } + } catch (error) { + console.error('Failed to load sub-agent state:', error) + } + } + } + + private _saveToFile(): void { + try { + const data = this._memoryManager.getAllSubAgentStoredResults() + writeFileSync(this._filePath, JSON.stringify(data, null, 2), 'utf-8') + } catch (error) { + console.error('Failed to save sub-agent state:', error) + } + } + + getSubAgentStoredResult(subAgentId: string): SubAgentResult | undefined { + return this._memoryManager.getSubAgentStoredResult(subAgentId) + } + + setSubAgentStoredResult(subAgentId: string, result: SubAgentResult): void { + this._memoryManager.setSubAgentStoredResult(subAgentId, result) + this._saveToFile() + } + + getAllSubAgentStoredResults(): Record { + return this._memoryManager.getAllSubAgentStoredResults() + } + + clearSubAgentStoredResults(): void { + this._memoryManager.clearSubAgentStoredResults() + this._saveToFile() + } + + removeSubAgentStoredResult(subAgentId: string): void { + this._memoryManager.removeSubAgentStoredResult(subAgentId) + this._saveToFile() + } + + findSubAgentIdByName(name: string): string | undefined { + return this._memoryManager.findSubAgentIdByName(name) + } + + getResultsByAgentType(agentType: string): SubAgentResult[] { + return this._memoryManager.getResultsByAgentType(agentType) + } + + /** Forward analytics registration */ + onAnalyticsEvent(callback: (data: SubAgentAnalyticsData) => void): void { + this._memoryManager.onAnalyticsEvent(callback) + } + + /** Forward analytics emission */ + emitAnalyticsEvent(data: SubAgentAnalyticsData): void { + this._memoryManager.emitAnalyticsEvent(data) + } + + /** Get analytics history from file */ + getAnalyticsHistory(): SubAgentAnalyticsData[] { + if (!existsSync(this._analyticsPath)) { + return [] + } + + try { + const content = readFileSync(this._analyticsPath, 'utf-8') + const lines = content.trim().split('\n').filter(Boolean) + return lines.map(line => JSON.parse(line) as SubAgentAnalyticsData) + } catch (error) { + return [] + } + } + + /** Clear analytics history */ + clearAnalyticsHistory(): void { + try { + if (existsSync(this._analyticsPath)) { + writeFileSync(this._analyticsPath, '', 'utf-8') + } + } catch (error) { + // Silently fail + } + } + + /** Get analytics summary */ + getAnalyticsSummary(): { + totalAgents: number + completedCount: number + failedCount: number + interruptedCount: number + totalToolCalls: number + totalDurationMs: number + agentsByType: Record + } { + const history = this.getAnalyticsHistory() + const completedEvents = history.filter(e => e.event === SubAgentAnalyticsEvent.COMPLETED) + const failedEvents = history.filter(e => e.event === SubAgentAnalyticsEvent.FAILED) + const interruptedEvents = history.filter(e => e.event === SubAgentAnalyticsEvent.INTERRUPTED) + const toolCallEvents = history.filter(e => e.event === SubAgentAnalyticsEvent.TOOL_CALLED) + + const agentsByType: Record = {} + for (const event of [...completedEvents, ...failedEvents, ...interruptedEvents]) { + agentsByType[event.agentType] = (agentsByType[event.agentType] || 0) + 1 + } + + return { + totalAgents: completedEvents.length + failedEvents.length + interruptedEvents.length, + completedCount: completedEvents.length, + failedCount: failedEvents.length, + interruptedCount: interruptedEvents.length, + totalToolCalls: toolCallEvents.length, + totalDurationMs: completedEvents.reduce((sum, e) => sum + (e.durationMs || 0), 0), + agentsByType, + } + } +} + +// Singleton instance for global state management +let _globalStateManager: InMemorySubAgentStateManager | null = null +let _fileBasedStateManager: FileBasedSubAgentStateManager | null = null + +/** + * Get the global sub-agent state manager instance (in-memory) + */ +export function getSubAgentStateManager(): InMemorySubAgentStateManager { + if (!_globalStateManager) { + _globalStateManager = new InMemorySubAgentStateManager() + } + return _globalStateManager +} + +/** + * Get a file-based state manager with persistent analytics + * Use this when you need state and analytics to persist across sessions + */ +export function getFileBasedStateManager( + sessionId: string = 'default', + enableAnalytics: boolean = true +): FileBasedSubAgentStateManager { + if (!_fileBasedStateManager) { + _fileBasedStateManager = new FileBasedSubAgentStateManager(sessionId, enableAnalytics) + } + return _fileBasedStateManager +} + +/** + * Reset the global state manager (useful for testing) + */ +export function resetSubAgentStateManager(): void { + _globalStateManager = null + _fileBasedStateManager = null +} + +/** + * Helper function to track sub-agent analytics + */ +export function trackSubAgentEvent( + event: SubAgentAnalyticsEvent, + subAgentId: string, + subAgentName: string, + agentType: string, + additionalData?: Partial +): void { + const manager = getSubAgentStateManager() + manager.emitAnalyticsEvent({ + event, + subAgentId, + subAgentName, + agentType, + timestamp: Date.now(), + ...additionalData, + }) +} + +/** + * Validate color value + */ +export function isValidSubAgentColor(color: string): color is ValidSubAgentColor { + return VALID_SUB_AGENT_COLORS.includes(color as ValidSubAgentColor) +} diff --git a/src/utils/theme.ts b/src/utils/theme.ts index a4508bdf..3865288c 100644 --- a/src/utils/theme.ts +++ b/src/utils/theme.ts @@ -4,8 +4,10 @@ export interface Theme { bashBorder: string kode: string noting: string + notingBorder: string permission: string secondaryBorder: string + inputBorder: string text: string secondaryText: string suggestion: string @@ -26,8 +28,10 @@ const lightTheme: Theme = { bashBorder: '#FF6E57', kode: '#FFC233', noting: '#222222', + notingBorder: '#10b981', permission: '#e9c61aff', secondaryBorder: '#999', + inputBorder: '#a5b4fc', text: '#000', secondaryText: '#666', suggestion: '#32e98aff', @@ -48,8 +52,10 @@ const lightDaltonizedTheme: Theme = { bashBorder: '#FF6E57', kode: '#FFC233', noting: '#222222', + notingBorder: '#059669', permission: '#3366ff', secondaryBorder: '#999', + inputBorder: '#93a5f5', text: '#000', secondaryText: '#666', suggestion: '#3366ff', @@ -70,8 +76,10 @@ const darkTheme: Theme = { bashBorder: '#FF6E57', kode: '#FFC233', noting: '#222222', + notingBorder: '#34d399', permission: '#b1b9f9', secondaryBorder: '#888', + inputBorder: '#818cf8', text: '#fff', secondaryText: '#999', suggestion: '#b1b9f9', @@ -92,8 +100,10 @@ const darkDaltonizedTheme: Theme = { bashBorder: '#FF6E57', kode: '#FFC233', noting: '#222222', + notingBorder: '#10b981', permission: '#99ccff', secondaryBorder: '#888', + inputBorder: '#7c8ff5', text: '#fff', secondaryText: '#999', suggestion: '#99ccff',