/** * 文件编辑工具(1 个) * * file_editor — 精准文件编辑,支持行替换/插入/删除/正则替换 * * 相比 write_file 的整文件覆盖,file_editor 支持精准定位修改, * 避免大文件全量重写,减少 token 消耗和出错风险。 * * @see docs/MetonaAI-Desktop 架构与交互设计.html — 9 个基础工具 */ import { readFile, writeFile, rename, mkdir } 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 { isProtectedWorkspaceFile, isPathWithinWorkspace } from './file-guard'; import { resolve } from 'path'; /** 安全校验:路径遍历防护 + 受保护文件拦截 */ function safeResolvePath(filePath: string, workspacePath: string): string { const resolved = resolve(workspacePath, filePath); if (!isPathWithinWorkspace(filePath, workspacePath)) { throw new Error(`Path traversal detected: ${filePath}`); } if (isProtectedWorkspaceFile(filePath, workspacePath)) { throw new Error('Access denied: MEMORY.md is managed by the memory system and cannot be accessed via file tools'); } return resolved; } 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.', 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")' }, }, required: ['file_path', 'operation'], }, category: MetonaToolCategory.FILESYSTEM, riskLevel: MetonaRiskLevel.MEDIUM, requiresPermission: true, timeoutMs: 15_000, }; 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'; // 文件必须存在(不支持创建新文件,请用 write_file) if (!existsSync(filePath)) { return { success: false, error: `File not found: ${args.file_path}. Use write_file to create new files.` }; } const originalContent = await readFile(filePath, 'utf-8'); const lines = originalContent.split('\n'); let newLines: string[]; let affectedRange: { start: number; end: number }; 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 }; 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 }; 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 }; 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 标志的正则统计匹配数 let replaceCount = 0; 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, }; } break; } default: return { success: false, error: `Unknown operation: ${operation}` }; } // 自动创建父目录 (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()}`; await writeFile(tmpPath, newContent, 'utf-8'); await rename(tmpPath, filePath); return { success: true, operation, file_path: args.file_path, affected_range: affectedRange, lines_before: lines.length, lines_after: newLines.length, bytes_written: Buffer.byteLength(newContent, 'utf-8'), }; } catch (err) { return { success: false, error: (err as Error).message }; } } }