/** * 文件编辑工具(1 个) * * file_editor — 精准文件编辑,支持行替换/插入/删除/正则替换 * * 相比 write_file 的整文件覆盖,file_editor 支持精准定位修改, * 避免大文件全量重写,减少 token 消耗和出错风险。 * * v0.3.2 优化: * - 使用共享 safeResolvePath(消除重复代码) * - 使用共享 extractErrorMessage、FILE_TOOL_TIMEOUT_MS、MAX_FILE_SIZE_BYTES * - 新增 dry_run 模式(预览变更不写入) * - 文件大小上限(防止 OOM) * - 统一 timeoutMs 为 15_000 * * @see docs/MetonaAI-Desktop 架构与交互设计.html — 9 个基础工具 */ import { readFile, writeFile, rename, mkdir, stat, unlink } from 'fs/promises'; import { dirname } from 'path'; import { existsSync } from 'fs'; import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool'; import type { MetonaToolDef } from '../../../harness/types'; import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types'; import { safeResolvePath, extractErrorMessage, MAX_FILE_SIZE_BYTES, FILE_TOOL_TIMEOUT_MS, } from './file-guard'; export class FileEditorTool implements IMetonaTool { readonly definition: MetonaToolDef = { name: 'file_editor', description: 'Edit a file with precision. Supports operations: replace (replace lines), insert (insert at line), delete (delete lines), regex (regex replace in range), find_replace (literal string replace, avoids regex ambiguity). More efficient than write_file for targeted edits. Supports dry_run mode for preview, multiline regex matching, and automatic .bak backup. File size limit: 10MB.', parameters: { type: 'object', properties: { file_path: { type: 'string', description: 'Path to the file to edit' }, operation: { type: 'string', description: 'Edit operation', enum: ['replace', 'insert', 'delete', 'regex', 'find_replace'], }, start_line: { type: 'number', description: 'Start line number (1-indexed). Used by replace/insert/delete/regex' }, end_line: { type: 'number', description: 'End line number (inclusive). Used by replace/delete/regex' }, content: { type: 'string', description: 'New content for replace/insert operations' }, pattern: { type: 'string', description: 'Regex pattern for regex operation' }, replacement: { type: 'string', description: 'Replacement string for regex operation' }, flags: { type: 'string', description: 'Regex flags (default "g"). Use "gm" for multiline, "gms" for multiline+dotall' }, multiline: { type: 'boolean', description: 'F2-6: Enable cross-line regex matching. When true, regex is applied to the joined content of the range (not line-by-line). Useful for replacing multi-line code blocks. Default false.' }, find: { type: 'string', description: 'F2-5: Literal string to find for find_replace operation (no regex interpretation)' }, replace: { type: 'string', description: 'F2-5: Replacement string for find_replace operation' }, replace_all: { type: 'boolean', description: 'F2-5: Replace all occurrences (true, default) or only the first (false). For find_replace operation.' }, backup: { type: 'boolean', description: 'F2-7: Save a .bak copy of the original file before editing (default false)' }, dry_run: { type: 'boolean', description: 'Preview mode: return the diff without writing to file (default false)' }, }, required: ['file_path', 'operation'], }, category: MetonaToolCategory.FILESYSTEM, riskLevel: MetonaRiskLevel.MEDIUM, requiresPermission: true, timeoutMs: FILE_TOOL_TIMEOUT_MS, }; async execute(args: Record, context: ToolExecutionContext): Promise { try { // F1.5: file_path 空值校验 if (!args.file_path || typeof args.file_path !== 'string') { return { success: false, error: 'file_path is required' }; } const filePath = safeResolvePath(args.file_path as string, context.workspacePath); const operation = args.operation as 'replace' | 'insert' | 'delete' | 'regex' | 'find_replace'; const dryRun = (args.dry_run as boolean) ?? false; // F2-7: backup 参数 — 编辑前保存 .bak 文件 const backup = (args.backup as boolean) ?? false; // 文件必须存在(不支持创建新文件,请用 write_file) if (!existsSync(filePath)) { return { success: false, error: `File not found: ${args.file_path}. Use write_file to create new files.` }; } // v0.3.2: 文件大小上限(防止 OOM) const stats = await stat(filePath); if (stats.size > MAX_FILE_SIZE_BYTES) { return { success: false, error: `File too large (${stats.size} bytes, max ${MAX_FILE_SIZE_BYTES} bytes). Consider using write_file to rewrite.`, }; } const originalContent = await readFile(filePath, 'utf-8'); const lines = originalContent.split('\n'); let newLines: string[]; let affectedRange: { start: number; end: number }; let replaceCount = 0; // v0.3.2: 统一在外层声明,供 dry_run 返回 switch (operation) { case 'replace': { const startLine = Math.max(1, (args.start_line as number) ?? 1); const endLine = Math.min(lines.length, (args.end_line as number) ?? startLine); if (endLine < startLine) { return { success: false, error: `end_line (${endLine}) must be >= start_line (${startLine})` }; } const content = (args.content as string) ?? ''; const contentLines = content.split('\n'); newLines = [ ...lines.slice(0, startLine - 1), ...contentLines, ...lines.slice(endLine), ]; affectedRange = { start: startLine, end: endLine }; replaceCount = endLine - startLine + 1; break; } case 'insert': { let startLine = Math.max(1, (args.start_line as number) ?? 1); startLine = Math.min(startLine, lines.length + 1); // 允许追加到末尾 const content = (args.content as string) ?? ''; const contentLines = content.split('\n'); newLines = [ ...lines.slice(0, startLine - 1), ...contentLines, ...lines.slice(startLine - 1), ]; affectedRange = { start: startLine, end: startLine + contentLines.length - 1 }; replaceCount = contentLines.length; break; } case 'delete': { const startLine = Math.max(1, (args.start_line as number) ?? 1); const endLine = Math.min(lines.length, (args.end_line as number) ?? startLine); if (endLine < startLine) { return { success: false, error: `end_line (${endLine}) must be >= start_line (${startLine})` }; } newLines = [ ...lines.slice(0, startLine - 1), ...lines.slice(endLine), ]; affectedRange = { start: startLine, end: endLine }; replaceCount = endLine - startLine + 1; break; } case 'regex': { const pattern = args.pattern as string; const replacement = (args.replacement as string) ?? ''; const flags = (args.flags as string) ?? 'g'; // F2-6: multiline 参数 — 支持跨行匹配 const multiline = (args.multiline as boolean) ?? false; if (!pattern) { return { success: false, error: 'Regex operation requires "pattern" parameter' }; } // F1.3: regex pattern 长度限制 if (pattern.length > 500) { return { success: false, error: 'Regex pattern too long (max 500 chars)' }; } const startLine = Math.max(1, (args.start_line as number) ?? 1); const endLine = Math.min(lines.length, (args.end_line as number) ?? lines.length); if (endLine < startLine) { return { success: false, error: `end_line (${endLine}) must be >= start_line (${startLine})` }; } // F1-1 修复: 强制 regex 含 g 标志,保证 match 计数与 replace 结果一致 const effectiveFlags = flags.includes('g') ? flags : flags + 'g'; let regex: RegExp; try { regex = new RegExp(pattern, effectiveFlags); } catch (err) { // F4-2: 统一用 extractErrorMessage 提取错误信息 return { success: false, error: `Invalid regex: ${extractErrorMessage(err)}` }; } if (multiline) { // F2-6: 跨行匹配 — 对整个目标内容做正则替换(支持多行代码块替换) const targetContent = lines.slice(startLine - 1, endLine).join('\n'); const matches = targetContent.match(regex); if (matches) replaceCount = matches.length; const replacedContent = targetContent.replace(regex, replacement); const replacedLines = replacedContent.split('\n'); newLines = [ ...lines.slice(0, startLine - 1), ...replacedLines, ...lines.slice(endLine), ]; } else { // F1-1 修复: 用同一个 regex 实例做 match 计数 + replace 替换(逐行) const targetLines = lines.slice(startLine - 1, endLine); const replacedLines = targetLines.map((line) => { const matches = line.match(regex); if (matches) replaceCount += matches.length; return line.replace(regex, replacement); }); newLines = [ ...lines.slice(0, startLine - 1), ...replacedLines, ...lines.slice(endLine), ]; } affectedRange = { start: startLine, end: endLine }; // 如果没有替换,返回提示 if (replaceCount === 0) { return { success: true, operation, file_path: args.file_path, message: 'No matches found for regex pattern', replacements: 0, affected_range: affectedRange, dry_run: dryRun, }; } break; } case 'find_replace': { // F2-5: 字面量字符串替换 — 不解析正则元字符,避免歧义 // 适用于精准替换代码片段(如函数名、配置值) const find = args.find as string; const replaceStr = (args.replace as string) ?? ''; const replaceAll = (args.replace_all as boolean) ?? true; if (find === undefined || find === null) { return { success: false, error: 'find_replace operation requires "find" parameter' }; } if (find.length === 0) { return { success: false, error: '"find" must not be empty' }; } if (replaceAll) { // 全局替换:用 split + join 统计匹配次数并替换 const parts = originalContent.split(find); replaceCount = parts.length - 1; const newContent = parts.join(replaceStr); newLines = newContent.split('\n'); } else { // 只替换第一个匹配 const idx = originalContent.indexOf(find); if (idx >= 0) { replaceCount = 1; const newContent = originalContent.slice(0, idx) + replaceStr + originalContent.slice(idx + find.length); newLines = newContent.split('\n'); } else { replaceCount = 0; newLines = lines; } } // find_replace 作用于整个文件,affectedRange 覆盖全部行 affectedRange = { start: 1, end: lines.length }; if (replaceCount === 0) { return { success: true, operation, file_path: args.file_path, message: 'No matches found for find pattern', replacements: 0, affected_range: affectedRange, dry_run: dryRun, }; } break; } default: return { success: false, error: `Unknown operation: ${operation}` }; } // v0.3.2: dry_run 模式 — 预览变更不写入 if (dryRun) { // v0.3.2 修复 WARN-1: 分模式计算 preview 范围 // - insert: affectedRange.end 是新文件行号,原文件无被替换内容 → original 为空 // - replace/delete/regex: affectedRange.end 是原文件行号,original/modified 取相同范围 let originalPreview: string; let modifiedPreview: string; if (operation === 'insert') { // insert: 原文件无被替换内容;新文件显示插入的内容 originalPreview = ''; modifiedPreview = newLines.slice( affectedRange.start - 1, Math.min(affectedRange.end, newLines.length), ).join('\n'); } else if (operation === 'find_replace') { // F2-5: find_replace 的 dry_run — 只预览第一个匹配附近的内容 // 避免大文件预览整个文件(affectedRange 覆盖全部行) const firstMatchIdx = originalContent.indexOf(args.find as string); if (firstMatchIdx >= 0) { const beforeMatch = originalContent.slice(0, firstMatchIdx); const matchLine = beforeMatch.split('\n').length; const contextRadius = 3; // 前后各 3 行 const previewStart = Math.max(1, matchLine - contextRadius); const previewEnd = Math.min(lines.length, matchLine + contextRadius); originalPreview = lines.slice(previewStart - 1, previewEnd).join('\n'); const lineDelta = newLines.length - lines.length; const modifiedEnd = Math.min(newLines.length, previewEnd + lineDelta); modifiedPreview = newLines.slice(previewStart - 1, Math.max(previewStart - 1, modifiedEnd)).join('\n'); } else { originalPreview = ''; modifiedPreview = ''; } } else { // replace/delete/regex: 原文件取 [start-1, end) 区间 originalPreview = lines.slice( affectedRange.start - 1, Math.min(affectedRange.end, lines.length), ).join('\n'); // 新文件取相同范围 + 行数差修正(replace 可能改变行数,delete 后该位置为空) const lineDelta = newLines.length - lines.length; const modifiedEnd = Math.min( affectedRange.end + lineDelta, newLines.length, ); modifiedPreview = newLines.slice( affectedRange.start - 1, Math.max(affectedRange.start - 1, modifiedEnd), ).join('\n'); } return { success: true, dry_run: true, operation, file_path: args.file_path, affected_range: affectedRange, replacements: replaceCount, lines_before: lines.length, lines_after: newLines.length, preview: { original: originalPreview, modified: modifiedPreview, }, }; } // 自动创建父目录 (F1.8: 异步 mkdir) const parentDir = dirname(filePath); if (!existsSync(parentDir)) { await mkdir(parentDir, { recursive: true }); } // F2-7: backup 模式 — 编辑前保存 .bak 文件 // 注意:备份在写入前进行,确保即使写入失败也有原始备份 let backupPath: string | null = null; if (backup) { backupPath = `${filePath}.bak`; await writeFile(backupPath, originalContent, 'utf-8'); } const newContent = newLines.join('\n'); // F1.7: 原子写入 - 临时文件 + rename const tmpPath = `${filePath}.tmp_${Date.now()}`; try { await writeFile(tmpPath, newContent, 'utf-8'); await rename(tmpPath, filePath); } catch (err) { // 原子写入失败,清理临时文件 if (existsSync(tmpPath)) { try { await unlink(tmpPath); } catch { /* 忽略清理错误 */ } } throw err; } return { success: true, operation, file_path: args.file_path, affected_range: affectedRange, replacements: replaceCount, lines_before: lines.length, lines_after: newLines.length, bytes_written: Buffer.byteLength(newContent, 'utf-8'), backup_path: backupPath, // F2-7: 备份文件路径(null 表示未备份) }; } catch (err) { return { success: false, error: extractErrorMessage(err) }; } } }