/** * 文件编辑工具(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). More efficient than write_file for targeted edits. Supports dry_run mode for preview. 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'], }, 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' }, 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")' }, 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'; const dryRun = (args.dry_run 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'; 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})` }; } let regex: RegExp; try { regex = new RegExp(pattern, flags); } catch (err) { return { success: false, error: `Invalid regex: ${(err as Error).message}` }; } // F1.4: 用带 g 标志的正则统计匹配数 const targetLines = lines.slice(startLine - 1, endLine); const countRegex = new RegExp(pattern, flags.includes('g') ? flags : flags + 'g'); const replacedLines = targetLines.map((line) => { const matches = line.match(countRegex); 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; } 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 { // 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 }); } 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'), }; } catch (err) { return { success: false, error: extractErrorMessage(err) }; } } }