feat: 升级至 v0.3.12 — 文件与代码类工具全面优化(16 项)
【阶段一 · 紧急修复 F1】 - F1-1 file_editor regex 计数 bug:强制 g 标志 + 同一 RegExp 实例做 match+replace - F1-2 code_search 统一 safeResolvePath(含路径遍历 + MEMORY.md 拦截) - F1-3 git_commit amend 模式:message 可选 + --no-edit 防止编辑器 hang - F1-4 write_file append 模式原子化:显式 open(O_APPEND)+write+fsync+close 【阶段二 · 增强现有工具 F2】 - F2-1 read_file 智能编码检测:BOM(UTF-8/UTF-16 LE/BE) + GBK 降级 - F2-2 read_file 支持 tail 模式(读取末尾 N 行,适用日志) - F2-3 search_files 二进制过滤 + 智能编码检测 - F2-4 search_files 多 glob 匹配(逗号分隔,如 *.ts,*.js) - F2-5 file_editor 新增 find_replace 操作(字面量替换,规避正则歧义) - F2-6 file_editor regex 支持跨行匹配(multiline 参数) - F2-7 file_editor 新增 backup 参数(编辑前 .bak 备份) 【阶段三 · 新增工具 F3】 - F3-1 file_move:双路径校验 + overwrite + 自动建父目录 + rename 原子 - F3-2 file_info:大小/时间/类型/编码/二进制/权限位 【阶段四 · 优化 F4】 - F4-1 diff_viewer Uint32Array→Uint16Array(省一半内存)+ safeResolvePath + 智能编码 - F4-2 错误处理统一:diff-viewer/file-editor/code-search catch 块改用 extractErrorMessage 【阶段五 · 验证 F5】 - tsc --noEmit 类型检查通过 - 人工审查通过:路径校验/错误处理/原子性/资源释放/权限策略/导出注册 工具数量:13 → 15(新增 file_move / file_info)
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> 生产级通用 AI Agent 智能体桌面应用
|
> 生产级通用 AI Agent 智能体桌面应用
|
||||||
|
|
||||||
[](./package.json)
|
[](./package.json)
|
||||||
[](./LICENSE)
|
[](./LICENSE)
|
||||||
[](https://www.electronjs.org/)
|
[](https://www.electronjs.org/)
|
||||||
[](https://react.dev/)
|
[](https://react.dev/)
|
||||||
|
|||||||
@@ -86,6 +86,12 @@ export const DEFAULT_POLICIES: PermissionPolicy[] = [
|
|||||||
|
|
||||||
// v0.3.2: 文件删除工具(1 个)— 破坏性操作,必须确认
|
// v0.3.2: 文件删除工具(1 个)— 破坏性操作,必须确认
|
||||||
{ toolName: 'delete_file', requiredLevel: PermissionLevel.WRITE, requireConfirmation: true, maxFrequency: 30 },
|
{ toolName: 'delete_file', requiredLevel: PermissionLevel.WRITE, requireConfirmation: true, maxFrequency: 30 },
|
||||||
|
|
||||||
|
// v0.3.3: 文件移动/重命名工具(1 个)— 可能覆盖目标,需确认
|
||||||
|
{ toolName: 'file_move', requiredLevel: PermissionLevel.WRITE, requireConfirmation: true, maxFrequency: 30 },
|
||||||
|
|
||||||
|
// v0.3.3: 文件信息查询工具(1 个)— 只读
|
||||||
|
{ toolName: 'file_info', requiredLevel: PermissionLevel.READ },
|
||||||
];
|
];
|
||||||
|
|
||||||
export class PolicyEngine {
|
export class PolicyEngine {
|
||||||
|
|||||||
@@ -12,12 +12,13 @@
|
|||||||
|
|
||||||
import { execFile } from 'child_process';
|
import { execFile } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import { resolve } from 'path';
|
|
||||||
import log from 'electron-log';
|
import log from 'electron-log';
|
||||||
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
|
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
|
||||||
import type { MetonaToolDef } from '../../../harness/types';
|
import type { MetonaToolDef } from '../../../harness/types';
|
||||||
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
|
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
|
||||||
import { isPathWithinWorkspace } from './file-guard';
|
// F1-2: 统一用 safeResolvePath(含 isPathWithinWorkspace + MEMORY.md 拦截)
|
||||||
|
// F4-2: 引入 extractErrorMessage 统一错误处理
|
||||||
|
import { safeResolvePath, extractErrorMessage } from './file-guard';
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
@@ -60,6 +61,9 @@ export class CodeSearchTool implements IMetonaTool {
|
|||||||
};
|
};
|
||||||
|
|
||||||
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
||||||
|
// F4-2: 外层 try-catch 防止 safeResolvePath 抛出异常向上传播
|
||||||
|
// (read_file/write_file/list_directory 均有外层 try-catch,code_search 此前缺失)
|
||||||
|
try {
|
||||||
const pattern = args.pattern as string;
|
const pattern = args.pattern as string;
|
||||||
if (typeof pattern !== 'string' || !pattern) {
|
if (typeof pattern !== 'string' || !pattern) {
|
||||||
return { results: [], count: 0, error: 'Pattern is required and must be a string' };
|
return { results: [], count: 0, error: 'Pattern is required and must be a string' };
|
||||||
@@ -69,7 +73,7 @@ export class CodeSearchTool implements IMetonaTool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const searchPath = args.path
|
const searchPath = args.path
|
||||||
? this.resolveSearchPath(args.path as string, context.workspacePath)
|
? safeResolvePath(args.path as string, context.workspacePath)
|
||||||
: context.workspacePath;
|
: context.workspacePath;
|
||||||
const fileGlob = args.file_glob as string | undefined;
|
const fileGlob = args.file_glob as string | undefined;
|
||||||
const caseSensitive = (args.case_sensitive as boolean) ?? false;
|
const caseSensitive = (args.case_sensitive as boolean) ?? false;
|
||||||
@@ -85,6 +89,9 @@ export class CodeSearchTool implements IMetonaTool {
|
|||||||
return this.searchWithRipgrep(pattern, searchPath, opts, context);
|
return this.searchWithRipgrep(pattern, searchPath, opts, context);
|
||||||
}
|
}
|
||||||
return this.searchWithJs(pattern, searchPath, opts, context);
|
return this.searchWithJs(pattern, searchPath, opts, context);
|
||||||
|
} catch (error) {
|
||||||
|
return { results: [], count: 0, error: extractErrorMessage(error), success: false };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 使用 ripgrep 子进程搜索 */
|
/** 使用 ripgrep 子进程搜索 */
|
||||||
@@ -217,12 +224,4 @@ export class CodeSearchTool implements IMetonaTool {
|
|||||||
limit: opts.maxResults,
|
limit: opts.maxResults,
|
||||||
}, context);
|
}, context);
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveSearchPath(filePath: string, workspacePath: string): string {
|
|
||||||
const resolved = resolve(workspacePath, filePath);
|
|
||||||
if (!isPathWithinWorkspace(filePath, workspacePath)) {
|
|
||||||
throw new Error(`Path traversal detected: ${filePath}`);
|
|
||||||
}
|
|
||||||
return resolved;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,11 +8,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { readFile } from 'fs/promises';
|
import { readFile } from 'fs/promises';
|
||||||
import { resolve } from 'path';
|
|
||||||
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
|
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
|
||||||
import type { MetonaToolDef } from '../../../harness/types';
|
import type { MetonaToolDef } from '../../../harness/types';
|
||||||
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
|
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
|
||||||
import { isProtectedWorkspaceFile, isPathWithinWorkspace } from './file-guard';
|
import {
|
||||||
|
safeResolvePath,
|
||||||
|
extractErrorMessage,
|
||||||
|
decodeBufferWithDetection,
|
||||||
|
} from './file-guard';
|
||||||
|
|
||||||
interface DiffLine {
|
interface DiffLine {
|
||||||
type: 'context' | 'added' | 'removed';
|
type: 'context' | 'added' | 'removed';
|
||||||
@@ -30,8 +33,9 @@ function computeDiff(oldLines: string[], newLines: string[]): DiffLine[] {
|
|||||||
const sm = oldSliced.length;
|
const sm = oldSliced.length;
|
||||||
const sn = newSliced.length;
|
const sn = newSliced.length;
|
||||||
|
|
||||||
// D4.1: 使用 Uint32Array 一维数组替代 number[][],节省内存
|
// F4-1: 使用 Uint16Array 一维数组替代 Uint32Array,节省一半内存
|
||||||
const lcs = new Uint32Array((sm + 1) * (sn + 1));
|
// LCS 长度最大值为 min(sm, sn) <= 5000,远小于 Uint16Array 上限 65535,安全
|
||||||
|
const lcs = new Uint16Array((sm + 1) * (sn + 1));
|
||||||
const idx = (i: number, j: number): number => i * (sn + 1) + j;
|
const idx = (i: number, j: number): number => i * (sn + 1) + j;
|
||||||
|
|
||||||
for (let i = 1; i <= sm; i++) {
|
for (let i = 1; i <= sm; i++) {
|
||||||
@@ -183,21 +187,24 @@ export class DiffViewerTool implements IMetonaTool {
|
|||||||
return { success: false, error: 'file_a and file_b are required for files mode' };
|
return { success: false, error: 'file_a and file_b are required for files mode' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 安全校验
|
// F4-1: 用 safeResolvePath 统一路径校验(含 isPathWithinWorkspace + MEMORY.md 拦截)
|
||||||
const pathA = resolve(context.workspacePath, fileA);
|
let pathA: string;
|
||||||
const pathB = resolve(context.workspacePath, fileB);
|
let pathB: string;
|
||||||
if (!isPathWithinWorkspace(fileA, context.workspacePath) || !isPathWithinWorkspace(fileB, context.workspacePath)) {
|
try {
|
||||||
return { success: false, error: 'Path traversal detected' };
|
pathA = safeResolvePath(fileA, context.workspacePath);
|
||||||
}
|
pathB = safeResolvePath(fileB, context.workspacePath);
|
||||||
if (isProtectedWorkspaceFile(fileA, context.workspacePath) || isProtectedWorkspaceFile(fileB, context.workspacePath)) {
|
} catch (error) {
|
||||||
return { success: false, error: 'Access denied: MEMORY.md is protected' };
|
return { success: false, error: extractErrorMessage(error) };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
contentA = await readFile(pathA, 'utf-8');
|
// F4-1: 用智能编码检测读取文件(支持 GBK/UTF-16 等非 UTF-8 编码)
|
||||||
contentB = await readFile(pathB, 'utf-8');
|
const bufferA = await readFile(pathA);
|
||||||
|
const bufferB = await readFile(pathB);
|
||||||
|
contentA = decodeBufferWithDetection(bufferA).content;
|
||||||
|
contentB = decodeBufferWithDetection(bufferB).content;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { success: false, error: `Failed to read files: ${(err as Error).message}` };
|
return { success: false, error: `Failed to read files: ${extractErrorMessage(err)}` };
|
||||||
}
|
}
|
||||||
labelA = fileA;
|
labelA = fileA;
|
||||||
labelB = fileB;
|
labelB = fileB;
|
||||||
@@ -252,7 +259,8 @@ export class DiffViewerTool implements IMetonaTool {
|
|||||||
diff_lines: diff.slice(0, 500),
|
diff_lines: diff.slice(0, 500),
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { success: false, error: (err as Error).message };
|
// F4-2: 统一错误处理,避免 non-Error 抛出值被 String 强转丢失信息
|
||||||
|
return { success: false, error: extractErrorMessage(err) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export class FileEditorTool implements IMetonaTool {
|
|||||||
readonly definition: MetonaToolDef = {
|
readonly definition: MetonaToolDef = {
|
||||||
name: 'file_editor',
|
name: 'file_editor',
|
||||||
description:
|
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.',
|
'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: {
|
parameters: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
@@ -41,14 +41,19 @@ export class FileEditorTool implements IMetonaTool {
|
|||||||
operation: {
|
operation: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
description: 'Edit operation',
|
description: 'Edit operation',
|
||||||
enum: ['replace', 'insert', 'delete', 'regex'],
|
enum: ['replace', 'insert', 'delete', 'regex', 'find_replace'],
|
||||||
},
|
},
|
||||||
start_line: { type: 'number', description: 'Start line number (1-indexed). Used by 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' },
|
end_line: { type: 'number', description: 'End line number (inclusive). Used by replace/delete/regex' },
|
||||||
content: { type: 'string', description: 'New content for replace/insert operations' },
|
content: { type: 'string', description: 'New content for replace/insert operations' },
|
||||||
pattern: { type: 'string', description: 'Regex pattern for regex operation' },
|
pattern: { type: 'string', description: 'Regex pattern for regex operation' },
|
||||||
replacement: { type: 'string', description: 'Replacement string for regex operation' },
|
replacement: { type: 'string', description: 'Replacement string for regex operation' },
|
||||||
flags: { type: 'string', description: 'Regex flags (default "g")' },
|
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)' },
|
dry_run: { type: 'boolean', description: 'Preview mode: return the diff without writing to file (default false)' },
|
||||||
},
|
},
|
||||||
required: ['file_path', 'operation'],
|
required: ['file_path', 'operation'],
|
||||||
@@ -66,8 +71,10 @@ export class FileEditorTool implements IMetonaTool {
|
|||||||
return { success: false, error: 'file_path is required' };
|
return { success: false, error: 'file_path is required' };
|
||||||
}
|
}
|
||||||
const filePath = safeResolvePath(args.file_path as string, context.workspacePath);
|
const filePath = safeResolvePath(args.file_path as string, context.workspacePath);
|
||||||
const operation = args.operation as 'replace' | 'insert' | 'delete' | 'regex';
|
const operation = args.operation as 'replace' | 'insert' | 'delete' | 'regex' | 'find_replace';
|
||||||
const dryRun = (args.dry_run as boolean) ?? false;
|
const dryRun = (args.dry_run as boolean) ?? false;
|
||||||
|
// F2-7: backup 参数 — 编辑前保存 .bak 文件
|
||||||
|
const backup = (args.backup as boolean) ?? false;
|
||||||
|
|
||||||
// 文件必须存在(不支持创建新文件,请用 write_file)
|
// 文件必须存在(不支持创建新文件,请用 write_file)
|
||||||
if (!existsSync(filePath)) {
|
if (!existsSync(filePath)) {
|
||||||
@@ -143,6 +150,8 @@ export class FileEditorTool implements IMetonaTool {
|
|||||||
const pattern = args.pattern as string;
|
const pattern = args.pattern as string;
|
||||||
const replacement = (args.replacement as string) ?? '';
|
const replacement = (args.replacement as string) ?? '';
|
||||||
const flags = (args.flags as string) ?? 'g';
|
const flags = (args.flags as string) ?? 'g';
|
||||||
|
// F2-6: multiline 参数 — 支持跨行匹配
|
||||||
|
const multiline = (args.multiline as boolean) ?? false;
|
||||||
if (!pattern) {
|
if (!pattern) {
|
||||||
return { success: false, error: 'Regex operation requires "pattern" parameter' };
|
return { success: false, error: 'Regex operation requires "pattern" parameter' };
|
||||||
}
|
}
|
||||||
@@ -156,27 +165,42 @@ export class FileEditorTool implements IMetonaTool {
|
|||||||
return { success: false, error: `end_line (${endLine}) must be >= start_line (${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;
|
let regex: RegExp;
|
||||||
try {
|
try {
|
||||||
regex = new RegExp(pattern, flags);
|
regex = new RegExp(pattern, effectiveFlags);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { success: false, error: `Invalid regex: ${(err as Error).message}` };
|
// F4-2: 统一用 extractErrorMessage 提取错误信息
|
||||||
|
return { success: false, error: `Invalid regex: ${extractErrorMessage(err)}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
// F1.4: 用带 g 标志的正则统计匹配数
|
if (multiline) {
|
||||||
const targetLines = lines.slice(startLine - 1, endLine);
|
// F2-6: 跨行匹配 — 对整个目标内容做正则替换(支持多行代码块替换)
|
||||||
const countRegex = new RegExp(pattern, flags.includes('g') ? flags : flags + 'g');
|
const targetContent = lines.slice(startLine - 1, endLine).join('\n');
|
||||||
const replacedLines = targetLines.map((line) => {
|
const matches = targetContent.match(regex);
|
||||||
const matches = line.match(countRegex);
|
if (matches) replaceCount = matches.length;
|
||||||
if (matches) replaceCount += matches.length;
|
const replacedContent = targetContent.replace(regex, replacement);
|
||||||
return line.replace(regex, replacement);
|
const replacedLines = replacedContent.split('\n');
|
||||||
});
|
|
||||||
|
|
||||||
newLines = [
|
newLines = [
|
||||||
...lines.slice(0, startLine - 1),
|
...lines.slice(0, startLine - 1),
|
||||||
...replacedLines,
|
...replacedLines,
|
||||||
...lines.slice(endLine),
|
...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 };
|
affectedRange = { start: startLine, end: endLine };
|
||||||
|
|
||||||
// 如果没有替换,返回提示
|
// 如果没有替换,返回提示
|
||||||
@@ -195,6 +219,56 @@ export class FileEditorTool implements IMetonaTool {
|
|||||||
break;
|
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:
|
default:
|
||||||
return { success: false, error: `Unknown operation: ${operation}` };
|
return { success: false, error: `Unknown operation: ${operation}` };
|
||||||
}
|
}
|
||||||
@@ -214,6 +288,24 @@ export class FileEditorTool implements IMetonaTool {
|
|||||||
affectedRange.start - 1,
|
affectedRange.start - 1,
|
||||||
Math.min(affectedRange.end, newLines.length),
|
Math.min(affectedRange.end, newLines.length),
|
||||||
).join('\n');
|
).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 {
|
} else {
|
||||||
// replace/delete/regex: 原文件取 [start-1, end) 区间
|
// replace/delete/regex: 原文件取 [start-1, end) 区间
|
||||||
originalPreview = lines.slice(
|
originalPreview = lines.slice(
|
||||||
@@ -254,6 +346,14 @@ export class FileEditorTool implements IMetonaTool {
|
|||||||
await mkdir(parentDir, { recursive: true });
|
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');
|
const newContent = newLines.join('\n');
|
||||||
// F1.7: 原子写入 - 临时文件 + rename
|
// F1.7: 原子写入 - 临时文件 + rename
|
||||||
const tmpPath = `${filePath}.tmp_${Date.now()}`;
|
const tmpPath = `${filePath}.tmp_${Date.now()}`;
|
||||||
@@ -277,6 +377,7 @@ export class FileEditorTool implements IMetonaTool {
|
|||||||
lines_before: lines.length,
|
lines_before: lines.length,
|
||||||
lines_after: newLines.length,
|
lines_after: newLines.length,
|
||||||
bytes_written: Buffer.byteLength(newContent, 'utf-8'),
|
bytes_written: Buffer.byteLength(newContent, 'utf-8'),
|
||||||
|
backup_path: backupPath, // F2-7: 备份文件路径(null 表示未备份)
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { success: false, error: extractErrorMessage(err) };
|
return { success: false, error: extractErrorMessage(err) };
|
||||||
|
|||||||
@@ -149,16 +149,93 @@ export function matchGlob(name: string, glob: string): boolean {
|
|||||||
return new RegExp(`^${pattern}$`, 'i').test(name);
|
return new RegExp(`^${pattern}$`, 'i').test(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* F2-4: 多 glob 匹配(逗号分隔)
|
||||||
|
*
|
||||||
|
* 支持 "*.ts,*.js,*.tsx" 形式的多 glob 匹配,任一匹配即通过。
|
||||||
|
* 单个 glob 时等价于 matchGlob。空字符串或空白字符串视为匹配所有。
|
||||||
|
*
|
||||||
|
* @param name 待匹配的文件名
|
||||||
|
* @param globStr 通配符模式字符串(支持逗号分隔多 glob)
|
||||||
|
* @returns 是否匹配任一 glob
|
||||||
|
*/
|
||||||
|
export function matchAnyGlob(name: string, globStr: string): boolean {
|
||||||
|
// 按逗号分割,去除空白,过滤空字符串
|
||||||
|
const globs = globStr.split(',').map((g) => g.trim()).filter((g) => g.length > 0);
|
||||||
|
if (globs.length === 0) return true; // 空字符串视为匹配所有
|
||||||
|
for (const g of globs) {
|
||||||
|
if (matchGlob(name, g)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 共享错误提取
|
* 共享错误提取
|
||||||
*
|
*
|
||||||
* 统一从 unknown 错误对象中提取 message 字符串
|
* 统一从 unknown 错误对象中提取 message 字符串,可选附加 stderr 信息
|
||||||
|
*
|
||||||
|
* F4-2: 支持 optional stderr 参数,用于 child_process 错误(git/command)
|
||||||
*
|
*
|
||||||
* @param error catch 块中的 unknown 错误
|
* @param error catch 块中的 unknown 错误
|
||||||
|
* @param includeStderr 是否尝试从 error.stderr 提取 stderr 信息(默认 false)
|
||||||
* @returns 错误消息字符串
|
* @returns 错误消息字符串
|
||||||
*/
|
*/
|
||||||
export function extractErrorMessage(error: unknown): string {
|
export function extractErrorMessage(error: unknown, includeStderr = false): string {
|
||||||
return error instanceof Error ? error.message : String(error);
|
if (error instanceof Error) {
|
||||||
|
if (includeStderr) {
|
||||||
|
const stderr = (error as Error & { stderr?: string }).stderr ?? '';
|
||||||
|
return stderr ? `${error.message}\n${stderr}` : error.message;
|
||||||
|
}
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* F2-1: 智能文件编码检测与解码
|
||||||
|
*
|
||||||
|
* 支持 BOM 检测(UTF-8 / UTF-16 LE / UTF-16 BE)和无 BOM 时的编码推断
|
||||||
|
* (UTF-8 strict → GBK → UTF-8 loose 三级降级)。
|
||||||
|
*
|
||||||
|
* 解决 Windows 中文环境 GBK 文件读取乱码问题,以及 UTF-16 文件读取问题。
|
||||||
|
*
|
||||||
|
* @param buffer 文件/命令输出的原始字节
|
||||||
|
* @returns 解码后的文本和检测到的编码名(utf-8 / utf-8-bom / utf-16le / utf-16be / gbk / utf-8-loose)
|
||||||
|
*/
|
||||||
|
export function decodeBufferWithDetection(buffer: Buffer): { content: string; encoding: string } {
|
||||||
|
if (buffer.length === 0) {
|
||||||
|
return { content: '', encoding: 'utf-8' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// BOM 检测
|
||||||
|
// UTF-8 BOM: EF BB BF
|
||||||
|
if (buffer.length >= 3 && buffer[0] === 0xEF && buffer[1] === 0xBB && buffer[2] === 0xBF) {
|
||||||
|
return { content: buffer.slice(3).toString('utf-8'), encoding: 'utf-8-bom' };
|
||||||
|
}
|
||||||
|
// UTF-16 LE BOM: FF FE
|
||||||
|
if (buffer.length >= 2 && buffer[0] === 0xFF && buffer[1] === 0xFE) {
|
||||||
|
return { content: buffer.slice(2).toString('utf16le'), encoding: 'utf-16le' };
|
||||||
|
}
|
||||||
|
// UTF-16 BE BOM: FE FF
|
||||||
|
if (buffer.length >= 2 && buffer[0] === 0xFE && buffer[1] === 0xFF) {
|
||||||
|
const body = buffer.slice(2);
|
||||||
|
// 偶数长度保护(UTF-16 每字符 2 字节)
|
||||||
|
const safe = body.length % 2 === 0 ? body : body.slice(0, body.length - 1);
|
||||||
|
const swapped = Buffer.from(safe); // 复制避免修改原 buffer
|
||||||
|
swapped.swap16(); // BE → LE 字节交换
|
||||||
|
return { content: swapped.toString('utf16le'), encoding: 'utf-16be' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 无 BOM:UTF-8 strict → GBK → UTF-8 loose 三级降级
|
||||||
|
try {
|
||||||
|
return { content: new TextDecoder('utf-8', { fatal: true }).decode(buffer), encoding: 'utf-8' };
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
return { content: new TextDecoder('gbk').decode(buffer), encoding: 'gbk' };
|
||||||
|
} catch {
|
||||||
|
return { content: buffer.toString('utf-8'), encoding: 'utf-8-loose' };
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
* @see standard/开发规范.md — 使用 fs/path 内置模块
|
* @see standard/开发规范.md — 使用 fs/path 内置模块
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { readFile, writeFile, readdir, stat, appendFile, mkdir, open, unlink, rmdir, rm, rename, type FileHandle } from 'fs/promises';
|
import { readFile, writeFile, readdir, stat, mkdir, open, unlink, rmdir, rm, rename, type FileHandle } from 'fs/promises';
|
||||||
import { join, relative, resolve, dirname } from 'path';
|
import { join, relative, resolve, dirname } from 'path';
|
||||||
import { existsSync } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
|
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
|
||||||
@@ -33,7 +33,9 @@ import {
|
|||||||
isPathWithinWorkspace,
|
isPathWithinWorkspace,
|
||||||
safeResolvePath,
|
safeResolvePath,
|
||||||
matchGlob,
|
matchGlob,
|
||||||
|
matchAnyGlob,
|
||||||
extractErrorMessage,
|
extractErrorMessage,
|
||||||
|
decodeBufferWithDetection,
|
||||||
MAX_FILE_SIZE_BYTES,
|
MAX_FILE_SIZE_BYTES,
|
||||||
FILE_TOOL_TIMEOUT_MS,
|
FILE_TOOL_TIMEOUT_MS,
|
||||||
MAX_LINE_LENGTH,
|
MAX_LINE_LENGTH,
|
||||||
@@ -83,13 +85,14 @@ export class ReadFileTool implements IMetonaTool {
|
|||||||
readonly definition: MetonaToolDef = {
|
readonly definition: MetonaToolDef = {
|
||||||
name: 'read_file',
|
name: 'read_file',
|
||||||
description:
|
description:
|
||||||
'Read the contents of a text file. Returns lines with offset/limit for large files. Auto-detects and rejects binary files (suggest view_image for images). File size limit: 10MB. Lines longer than 10000 chars are truncated.',
|
'Read the contents of a text file. Returns lines with offset/limit for large files. Auto-detects and rejects binary files (suggest view_image for images). File size limit: 10MB. Lines longer than 10000 chars are truncated. Smart encoding detection: supports UTF-8/UTF-8-BOM/UTF-16LE/UTF-16BE (BOM) and GBK/CP936 (fallback for Windows Chinese files). Returns the detected encoding in the response. Supports tail mode to read the last N lines (useful for logs).',
|
||||||
parameters: {
|
parameters: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
file_path: { type: 'string', description: 'Absolute or relative path to the file to read' },
|
file_path: { type: 'string', description: 'Absolute or relative path to the file to read' },
|
||||||
offset: { type: 'number', description: 'Start line number (1-indexed, default 1)' },
|
offset: { type: 'number', description: 'Start line number (1-indexed, default 1). Ignored if tail is specified.' },
|
||||||
limit: { type: 'number', description: 'Maximum lines to read (default 500, max 2000)' },
|
limit: { type: 'number', description: 'Maximum lines to read (default 500, max 2000). Ignored if tail is specified.' },
|
||||||
|
tail: { type: 'number', description: 'Read the last N lines from the file. Takes precedence over offset/limit. Useful for reading log tails. Max 2000.' },
|
||||||
},
|
},
|
||||||
required: ['file_path'],
|
required: ['file_path'],
|
||||||
},
|
},
|
||||||
@@ -104,6 +107,10 @@ export class ReadFileTool implements IMetonaTool {
|
|||||||
const filePath = safeResolvePath(args.file_path as string, context.workspacePath);
|
const filePath = safeResolvePath(args.file_path as string, context.workspacePath);
|
||||||
const offset = Math.max(1, (args.offset as number) ?? 1);
|
const offset = Math.max(1, (args.offset as number) ?? 1);
|
||||||
const limit = Math.min(2000, Math.max(1, (args.limit as number) ?? 500));
|
const limit = Math.min(2000, Math.max(1, (args.limit as number) ?? 500));
|
||||||
|
// F2-2: tail 模式 — 从文件末尾读取 N 行(优先于 offset/limit)
|
||||||
|
const tail = args.tail !== undefined
|
||||||
|
? Math.min(2000, Math.max(1, Math.floor(args.tail as number)))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
// v0.3.2: 文件存在性 + 大小预检(先 stat 再决定是否读取,避免大文件 OOM)
|
// v0.3.2: 文件存在性 + 大小预检(先 stat 再决定是否读取,避免大文件 OOM)
|
||||||
let stats;
|
let stats;
|
||||||
@@ -135,9 +142,27 @@ export class ReadFileTool implements IMetonaTool {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = await readFile(filePath, 'utf-8');
|
// F2-1: 智能编码检测 — 读取原始 Buffer 后用 BOM 检测 + UTF-8/GBK 降级
|
||||||
|
// 解决 Windows 中文环境 GBK 文件读取乱码,以及 UTF-16 文件读取问题
|
||||||
|
const buffer = await readFile(filePath);
|
||||||
|
const { content, encoding } = decodeBufferWithDetection(buffer);
|
||||||
const lines = content.split('\n');
|
const lines = content.split('\n');
|
||||||
const slicedLines = lines.slice(offset - 1, offset - 1 + limit);
|
|
||||||
|
// F2-2: 根据 tail 参数选择切片模式
|
||||||
|
let slicedLines: string[];
|
||||||
|
let truncated: boolean;
|
||||||
|
let startLine: number;
|
||||||
|
if (tail !== undefined) {
|
||||||
|
// tail 模式:从末尾取 tail 行
|
||||||
|
slicedLines = lines.slice(-tail);
|
||||||
|
truncated = lines.length > tail;
|
||||||
|
startLine = Math.max(1, lines.length - tail + 1);
|
||||||
|
} else {
|
||||||
|
// offset/limit 模式
|
||||||
|
slicedLines = lines.slice(offset - 1, offset - 1 + limit);
|
||||||
|
truncated = lines.length > offset - 1 + limit;
|
||||||
|
startLine = offset;
|
||||||
|
}
|
||||||
|
|
||||||
// v0.3.2: 超长行截断
|
// v0.3.2: 超长行截断
|
||||||
const processedLines = slicedLines.map((line) => truncateLine(line));
|
const processedLines = slicedLines.map((line) => truncateLine(line));
|
||||||
@@ -147,9 +172,12 @@ export class ReadFileTool implements IMetonaTool {
|
|||||||
content: processedLines.map((l) => l.text).join('\n'),
|
content: processedLines.map((l) => l.text).join('\n'),
|
||||||
total_lines: lines.length,
|
total_lines: lines.length,
|
||||||
returned_lines: slicedLines.length,
|
returned_lines: slicedLines.length,
|
||||||
truncated: lines.length > offset - 1 + limit,
|
truncated,
|
||||||
|
start_line: startLine, // F2-2: 返回实际起始行号
|
||||||
lines_truncated: truncatedLines,
|
lines_truncated: truncatedLines,
|
||||||
file_size: stats.size,
|
file_size: stats.size,
|
||||||
|
encoding, // F2-1: 检测到的编码(utf-8 / utf-8-bom / utf-16le / utf-16be / gbk / utf-8-loose)
|
||||||
|
mode: tail !== undefined ? 'tail' : 'offset', // F2-2: 读取模式
|
||||||
success: true,
|
success: true,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -207,13 +235,41 @@ export class WriteFileTool implements IMetonaTool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (mode === 'append') {
|
if (mode === 'append') {
|
||||||
// append 模式:直接 appendFile(append 本身是原子的)
|
// F1-4: append 模式增强 — 显式 open(O_APPEND) + write + fsync + close
|
||||||
await appendFile(filePath, content, 'utf-8');
|
// 修复 v0.3.2 注释错误:"append 本身是原子的"不准确
|
||||||
|
// 实际:fs.appendFile 内部是 open(O_APPEND) + write + close,存在:
|
||||||
|
// - 无 fsync:write 后数据在 page cache,崩溃可能丢失
|
||||||
|
// - 大内容(> 4KB)非完全原子:可能被分成多个 write
|
||||||
|
// 改进:显式 open + 循环 write + fsync + close,保证数据持久化到磁盘
|
||||||
|
// 保持 append 语义(O_APPEND 内核级追加),不改变并发行为
|
||||||
|
const fileExisted = existsSync(filePath);
|
||||||
|
const oldSize = fileExisted ? (await stat(filePath)).size : 0;
|
||||||
|
|
||||||
|
let fd: FileHandle | null = null;
|
||||||
|
try {
|
||||||
|
fd = await open(filePath, 'a');
|
||||||
|
// 'a' 模式下 write 追加到末尾(O_APPEND 内核级保证)
|
||||||
|
// 循环写入确保大内容完整写入(单次 write 可能不完整)
|
||||||
|
const buffer = Buffer.from(content, 'utf-8');
|
||||||
|
let totalWritten = 0;
|
||||||
|
while (totalWritten < buffer.length) {
|
||||||
|
const { bytesWritten } = await fd.write(buffer, totalWritten, buffer.length - totalWritten);
|
||||||
|
totalWritten += bytesWritten;
|
||||||
|
}
|
||||||
|
await fd.sync(); // fsync 保证数据持久化到磁盘
|
||||||
|
} finally {
|
||||||
|
if (fd) {
|
||||||
|
try { await fd.close(); } catch { /* 忽略关闭错误 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const newStats = await stat(filePath);
|
const newStats = await stat(filePath);
|
||||||
return {
|
return {
|
||||||
bytes_written: contentBytes,
|
bytes_written: contentBytes,
|
||||||
path: filePath,
|
path: filePath,
|
||||||
mode: 'append',
|
mode: 'append',
|
||||||
|
created: !fileExisted,
|
||||||
|
old_size: oldSize,
|
||||||
new_file_size: newStats.size,
|
new_file_size: newStats.size,
|
||||||
success: true,
|
success: true,
|
||||||
};
|
};
|
||||||
@@ -256,7 +312,7 @@ export class ListDirectoryTool implements IMetonaTool {
|
|||||||
properties: {
|
properties: {
|
||||||
dir_path: { type: 'string', description: 'Directory path (default: workspace root)' },
|
dir_path: { type: 'string', description: 'Directory path (default: workspace root)' },
|
||||||
depth: { type: 'number', description: 'Recursive depth (default 1, max 5)' },
|
depth: { type: 'number', description: 'Recursive depth (default 1, max 5)' },
|
||||||
glob: { type: 'string', description: 'Filename filter pattern (e.g., "*.ts")' },
|
glob: { type: 'string', description: 'Filename filter pattern. Supports comma-separated multi-glob (e.g., "*.ts" or "*.ts,*.js,*.tsx")' },
|
||||||
include_hidden: { type: 'boolean', description: 'Include hidden files/dirs starting with "." (default false)' },
|
include_hidden: { type: 'boolean', description: 'Include hidden files/dirs starting with "." (default false)' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -324,8 +380,8 @@ export class ListDirectoryTool implements IMetonaTool {
|
|||||||
await this.listDir(rootPath, fullPath, maxDepth, glob, includeHidden, currentDepth + 1, results, maxEntries);
|
await this.listDir(rootPath, fullPath, maxDepth, glob, includeHidden, currentDepth + 1, results, maxEntries);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// glob 过滤仅适用于文件
|
// F2-4: glob 过滤仅适用于文件,支持多 glob(逗号分隔,如 "*.ts,*.js")
|
||||||
if (glob && !matchGlob(entry.name, glob)) continue;
|
if (glob && !matchAnyGlob(entry.name, glob)) continue;
|
||||||
const stats = await stat(fullPath);
|
const stats = await stat(fullPath);
|
||||||
// v0.3.2: 添加 modified time(ISO 字符串)
|
// v0.3.2: 添加 modified time(ISO 字符串)
|
||||||
results.push({
|
results.push({
|
||||||
@@ -356,7 +412,7 @@ export class SearchFilesTool implements IMetonaTool {
|
|||||||
pattern: { type: 'string', description: 'Search pattern (regex for content, glob for filenames)' },
|
pattern: { type: 'string', description: 'Search pattern (regex for content, glob for filenames)' },
|
||||||
target: { type: 'string', description: '"content" (default) to search file contents, "files" to search filenames', enum: ['content', 'files'] },
|
target: { type: 'string', description: '"content" (default) to search file contents, "files" to search filenames', enum: ['content', 'files'] },
|
||||||
path: { type: 'string', description: 'Search directory (default: workspace root)' },
|
path: { type: 'string', description: 'Search directory (default: workspace root)' },
|
||||||
file_glob: { type: 'string', description: 'Limit to specific file types (e.g., "*.py")' },
|
file_glob: { type: 'string', description: 'Limit to specific file types. Supports comma-separated multi-glob (e.g., "*.py" or "*.ts,*.js,*.tsx")' },
|
||||||
limit: { type: 'number', description: 'Maximum results (default 50, max 200)' },
|
limit: { type: 'number', description: 'Maximum results (default 50, max 200)' },
|
||||||
context_lines: { type: 'number', description: 'Lines of context to show around content matches (default 0, max 5). Only for target="content".' },
|
context_lines: { type: 'number', description: 'Lines of context to show around content matches (default 0, max 5). Only for target="content".' },
|
||||||
include_hidden: { type: 'boolean', description: 'Include hidden files/dirs starting with "." (default false)' },
|
include_hidden: { type: 'boolean', description: 'Include hidden files/dirs starting with "." (default false)' },
|
||||||
@@ -443,7 +499,13 @@ export class SearchFilesTool implements IMetonaTool {
|
|||||||
const fileStats = await stat(filePath);
|
const fileStats = await stat(filePath);
|
||||||
if (fileStats.size > MAX_FILE_SIZE_BYTES) return;
|
if (fileStats.size > MAX_FILE_SIZE_BYTES) return;
|
||||||
|
|
||||||
const content = await readFile(filePath, 'utf-8');
|
// F2-3: 跳过二进制文件(避免读取乱码 + 提升性能)
|
||||||
|
// 空文件不算二进制,直接放行(size=0 时 isBinaryFile 内部 bytesRead=0 返回 false)
|
||||||
|
if (fileStats.size > 0 && await isBinaryFile(filePath)) return;
|
||||||
|
|
||||||
|
// F2-3: 用智能编码检测读取文件(支持 GBK 等非 UTF-8 编码)
|
||||||
|
const buffer = await readFile(filePath);
|
||||||
|
const { content } = decodeBufferWithDetection(buffer);
|
||||||
const lines = content.split('\n');
|
const lines = content.split('\n');
|
||||||
for (let i = 0; i < lines.length; i++) {
|
for (let i = 0; i < lines.length; i++) {
|
||||||
if (results.length >= limit) break;
|
if (results.length >= limit) break;
|
||||||
@@ -492,7 +554,8 @@ export class SearchFilesTool implements IMetonaTool {
|
|||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
await this.walkDir(fullPath, callback, fileGlob, includeHidden, workspacePath);
|
await this.walkDir(fullPath, callback, fileGlob, includeHidden, workspacePath);
|
||||||
} else {
|
} else {
|
||||||
if (fileGlob && !matchGlob(entry.name, fileGlob)) continue;
|
// F2-4: 支持多 glob(逗号分隔,如 "*.ts,*.js,*.tsx")
|
||||||
|
if (fileGlob && !matchAnyGlob(entry.name, fileGlob)) continue;
|
||||||
await callback(fullPath, entry.name);
|
await callback(fullPath, entry.name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -613,3 +676,210 @@ export class DeleteFileTool implements IMetonaTool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 6. file_move =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* F3-1: 文件移动/重命名工具
|
||||||
|
*
|
||||||
|
* 安全策略:
|
||||||
|
* 1. 源路径和目标路径都必须在工作空间内(双重 safeResolvePath 校验)
|
||||||
|
* 2. 受保护文件(MEMORY.md)禁止移动(safeResolvePath 内置拦截)
|
||||||
|
* 3. 自动创建目标父目录
|
||||||
|
* 4. 支持覆盖已存在文件(overwrite 参数)
|
||||||
|
* 5. 禁止移动工作空间根目录
|
||||||
|
* 6. rename 在同文件系统上是原子操作
|
||||||
|
*/
|
||||||
|
export class FileMoveTool implements IMetonaTool {
|
||||||
|
readonly definition: MetonaToolDef = {
|
||||||
|
name: 'file_move',
|
||||||
|
description:
|
||||||
|
'Move or rename a file or directory. Source and destination must be within workspace. Auto-creates destination parent directory. Use overwrite: true to replace existing destination. Atomic on same filesystem (uses rename).',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
source_path: { type: 'string', description: 'Path to the file/directory to move' },
|
||||||
|
destination_path: { type: 'string', description: 'Destination path' },
|
||||||
|
overwrite: { type: 'boolean', description: 'Overwrite if destination exists (default false)' },
|
||||||
|
},
|
||||||
|
required: ['source_path', 'destination_path'],
|
||||||
|
},
|
||||||
|
category: MetonaToolCategory.FILESYSTEM,
|
||||||
|
riskLevel: MetonaRiskLevel.MEDIUM,
|
||||||
|
requiresPermission: true,
|
||||||
|
timeoutMs: FILE_TOOL_TIMEOUT_MS,
|
||||||
|
};
|
||||||
|
|
||||||
|
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
||||||
|
try {
|
||||||
|
const sourcePath = args.source_path as string;
|
||||||
|
const destinationPath = args.destination_path as string;
|
||||||
|
const overwrite = (args.overwrite as boolean) ?? false;
|
||||||
|
|
||||||
|
if (!sourcePath || !destinationPath) {
|
||||||
|
return { error: 'source_path and destination_path are required', success: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// F3-1: 双路径校验(源和目标都必须在工作空间内 + 非 MEMORY.md)
|
||||||
|
let resolvedSource: string;
|
||||||
|
let resolvedDest: string;
|
||||||
|
try {
|
||||||
|
resolvedSource = safeResolvePath(sourcePath, context.workspacePath);
|
||||||
|
resolvedDest = safeResolvePath(destinationPath, context.workspacePath);
|
||||||
|
} catch (error) {
|
||||||
|
return { error: extractErrorMessage(error), success: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 禁止移动工作空间根目录
|
||||||
|
const workspaceRoot = resolve(context.workspacePath);
|
||||||
|
if (resolvedSource === workspaceRoot) {
|
||||||
|
return {
|
||||||
|
error: 'Cannot move workspace root directory',
|
||||||
|
source_path: sourcePath,
|
||||||
|
success: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 源必须存在
|
||||||
|
if (!existsSync(resolvedSource)) {
|
||||||
|
return { error: 'Source not found', path: sourcePath, success: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 目标已存在处理
|
||||||
|
let overwritten = false;
|
||||||
|
if (existsSync(resolvedDest)) {
|
||||||
|
if (!overwrite) {
|
||||||
|
return {
|
||||||
|
error: 'Destination already exists. Use overwrite: true to replace.',
|
||||||
|
destination_path: destinationPath,
|
||||||
|
success: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// overwrite: true — 删除目标
|
||||||
|
const destStats = await stat(resolvedDest);
|
||||||
|
if (destStats.isDirectory()) {
|
||||||
|
await rm(resolvedDest, { recursive: true, force: false });
|
||||||
|
} else {
|
||||||
|
await unlink(resolvedDest);
|
||||||
|
}
|
||||||
|
overwritten = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动创建目标父目录
|
||||||
|
const destParentDir = dirname(resolvedDest);
|
||||||
|
if (!existsSync(destParentDir)) {
|
||||||
|
await mkdir(destParentDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行移动(rename 在同文件系统上是原子操作)
|
||||||
|
await rename(resolvedSource, resolvedDest);
|
||||||
|
|
||||||
|
const stats = await stat(resolvedDest);
|
||||||
|
return {
|
||||||
|
source: sourcePath,
|
||||||
|
destination: destinationPath,
|
||||||
|
isDirectory: stats.isDirectory(),
|
||||||
|
overwritten,
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { error: extractErrorMessage(error), success: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 7. file_info =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* F3-2: 文件信息查询工具
|
||||||
|
*
|
||||||
|
* 返回文件的元信息:大小、时间戳、类型、编码检测、权限位。
|
||||||
|
* 只读操作,风险等级 SAFE。
|
||||||
|
*
|
||||||
|
* 编码检测只读取前 8KB,避免大文件 OOM。
|
||||||
|
*/
|
||||||
|
export class FileInfoTool implements IMetonaTool {
|
||||||
|
readonly definition: MetonaToolDef = {
|
||||||
|
name: 'file_info',
|
||||||
|
description:
|
||||||
|
'Get detailed file information: size, timestamps (created/modified/accessed), type (file/directory/symlink), encoding detection (UTF-8/UTF-16/GBK), binary check, and permission bits.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
file_path: { type: 'string', description: 'Path to the file or directory' },
|
||||||
|
},
|
||||||
|
required: ['file_path'],
|
||||||
|
},
|
||||||
|
category: MetonaToolCategory.FILESYSTEM,
|
||||||
|
riskLevel: MetonaRiskLevel.SAFE,
|
||||||
|
requiresPermission: false,
|
||||||
|
timeoutMs: FILE_TOOL_TIMEOUT_MS,
|
||||||
|
};
|
||||||
|
|
||||||
|
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
||||||
|
try {
|
||||||
|
const filePath = safeResolvePath(args.file_path as string, context.workspacePath);
|
||||||
|
|
||||||
|
if (!existsSync(filePath)) {
|
||||||
|
return { error: 'File not found', path: args.file_path, success: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = await stat(filePath);
|
||||||
|
const isDir = stats.isDirectory();
|
||||||
|
const isFile = stats.isFile();
|
||||||
|
const isSymlink = stats.isSymbolicLink();
|
||||||
|
|
||||||
|
const info: Record<string, unknown> = {
|
||||||
|
path: args.file_path,
|
||||||
|
absolute_path: filePath,
|
||||||
|
type: isDir ? 'directory' : isFile ? 'file' : isSymlink ? 'symlink' : 'unknown',
|
||||||
|
size: stats.size,
|
||||||
|
created: stats.birthtime.toISOString(),
|
||||||
|
modified: stats.mtime.toISOString(),
|
||||||
|
accessed: stats.atime.toISOString(),
|
||||||
|
mode: stats.mode.toString(8), // 八进制权限位
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// F3-2: 文件类型额外信息(编码 + 二进制检测)
|
||||||
|
// 只读前 8KB,避免大文件 OOM
|
||||||
|
if (isFile) {
|
||||||
|
let fd: FileHandle | null = null;
|
||||||
|
try {
|
||||||
|
fd = await open(filePath, 'r');
|
||||||
|
const buffer = Buffer.alloc(8192);
|
||||||
|
const { bytesRead } = await fd.read(buffer, 0, 8192, 0);
|
||||||
|
const actualBuffer = buffer.slice(0, bytesRead);
|
||||||
|
|
||||||
|
// 二进制检测(NULL 字节检查)
|
||||||
|
let isBinary = false;
|
||||||
|
for (let i = 0; i < bytesRead; i++) {
|
||||||
|
if (actualBuffer[i] === 0) {
|
||||||
|
isBinary = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info.is_binary = isBinary;
|
||||||
|
|
||||||
|
// 编码检测(仅非二进制文件)
|
||||||
|
if (!isBinary && bytesRead > 0) {
|
||||||
|
const { encoding } = decodeBufferWithDetection(actualBuffer);
|
||||||
|
info.encoding = encoding;
|
||||||
|
} else if (bytesRead === 0) {
|
||||||
|
info.encoding = 'utf-8'; // 空文件默认 UTF-8
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 读取失败,不报告编码信息
|
||||||
|
} finally {
|
||||||
|
if (fd) {
|
||||||
|
try { await fd.close(); } catch { /* 忽略关闭错误 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return info;
|
||||||
|
} catch (error) {
|
||||||
|
return { error: extractErrorMessage(error), success: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -340,11 +340,11 @@ export class GitLogTool implements IMetonaTool {
|
|||||||
export class GitCommitTool implements IMetonaTool {
|
export class GitCommitTool implements IMetonaTool {
|
||||||
readonly definition: MetonaToolDef = {
|
readonly definition: MetonaToolDef = {
|
||||||
name: 'git_commit',
|
name: 'git_commit',
|
||||||
description: 'Stage files and create a git commit. Supports amending the previous commit. Requires user confirmation.',
|
description: 'Stage files and create a git commit. Supports amending the previous commit. In amend mode, message is optional (omitting it keeps the original commit message). Requires user confirmation.',
|
||||||
parameters: {
|
parameters: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
message: { type: 'string', description: 'Commit message' },
|
message: { type: 'string', description: 'Commit message. Required for new commits. Optional in amend mode (omitting keeps original message).' },
|
||||||
files: {
|
files: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
items: { type: 'string', description: 'File path to stage' },
|
items: { type: 'string', description: 'File path to stage' },
|
||||||
@@ -352,7 +352,8 @@ export class GitCommitTool implements IMetonaTool {
|
|||||||
},
|
},
|
||||||
amend: { type: 'boolean', description: 'Amend the previous commit instead of creating a new one (default false)' },
|
amend: { type: 'boolean', description: 'Amend the previous commit instead of creating a new one (default false)' },
|
||||||
},
|
},
|
||||||
required: ['message'],
|
// F1-3: message 不再硬性 required — amend 模式下可省略(保留原 message)
|
||||||
|
// 校验在 execute 内根据 amend 标志动态进行
|
||||||
},
|
},
|
||||||
category: MetonaToolCategory.FILESYSTEM,
|
category: MetonaToolCategory.FILESYSTEM,
|
||||||
riskLevel: MetonaRiskLevel.MEDIUM,
|
riskLevel: MetonaRiskLevel.MEDIUM,
|
||||||
@@ -361,12 +362,17 @@ export class GitCommitTool implements IMetonaTool {
|
|||||||
};
|
};
|
||||||
|
|
||||||
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
||||||
const message = args.message as string;
|
const message = args.message as string | undefined;
|
||||||
const files = (args.files as string[] | undefined) ?? [];
|
const files = (args.files as string[] | undefined) ?? [];
|
||||||
const amend = (args.amend as boolean) ?? false;
|
const amend = (args.amend as boolean) ?? false;
|
||||||
|
|
||||||
if (!message || message.trim().length === 0) {
|
// F1-3: 非 amend 模式必须有 message;amend 模式可省略(保留原 message)
|
||||||
return { success: false, error: 'Commit message is required' };
|
if (!amend && (!message || message.trim().length === 0)) {
|
||||||
|
return { success: false, error: 'Commit message is required (use amend: true to reuse the previous message)' };
|
||||||
|
}
|
||||||
|
// amend 模式下若提供 message,需校验非空字符串
|
||||||
|
if (amend && message !== undefined && message.trim().length === 0) {
|
||||||
|
return { success: false, error: 'Commit message must not be empty (or omit message field to keep original)' };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -393,9 +399,16 @@ export class GitCommitTool implements IMetonaTool {
|
|||||||
const filesChanged = stagedOutput.split('\n').filter((l) => l.length > 0).length;
|
const filesChanged = stagedOutput.split('\n').filter((l) => l.length > 0).length;
|
||||||
|
|
||||||
// 3. 提交(commit 可能触发 hooks,给予更长超时)
|
// 3. 提交(commit 可能触发 hooks,给予更长超时)
|
||||||
|
// F1-3: amend 模式下若未提供 message,不加 -m 参数(保留原 message)
|
||||||
const commitArgs = ['commit'];
|
const commitArgs = ['commit'];
|
||||||
if (amend) commitArgs.push('--amend');
|
if (amend) commitArgs.push('--amend');
|
||||||
|
if (message && message.trim().length > 0) {
|
||||||
commitArgs.push('-m', message);
|
commitArgs.push('-m', message);
|
||||||
|
} else if (amend) {
|
||||||
|
// amend + 无 message + 无 --no-edit → git 会打开编辑器要求输入
|
||||||
|
// 加 --no-edit 明确表示保留原 message(防止 hang 等待编辑器)
|
||||||
|
commitArgs.push('--no-edit');
|
||||||
|
}
|
||||||
await runGit(commitArgs, context.workspacePath, 20_000);
|
await runGit(commitArgs, context.workspacePath, 20_000);
|
||||||
|
|
||||||
// 4. 获取提交 hash 和当前分支
|
// 4. 获取提交 hash 和当前分支
|
||||||
@@ -408,8 +421,10 @@ export class GitCommitTool implements IMetonaTool {
|
|||||||
success: true,
|
success: true,
|
||||||
commit,
|
commit,
|
||||||
branch,
|
branch,
|
||||||
message,
|
// F1-3: amend 模式下若未提供 message,返回值为空字符串(实际 message 保留原值)
|
||||||
|
message: message ?? (amend ? '(amended - original message preserved)' : ''),
|
||||||
filesChanged,
|
filesChanged,
|
||||||
|
amended: amend,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isNotGitRepoError(error)) {
|
if (isNotGitRepoError(error)) {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* 内置工具导出
|
* 内置工具导出
|
||||||
*
|
*
|
||||||
|
* v0.3.3: 从 27 个工具扩展到 29 个工具
|
||||||
|
* 新增:file_move, file_info
|
||||||
|
*
|
||||||
* v0.3.2: 从 26 个工具扩展到 27 个工具
|
* v0.3.2: 从 26 个工具扩展到 27 个工具
|
||||||
* 新增:delete_file
|
* 新增:delete_file
|
||||||
*
|
*
|
||||||
@@ -14,8 +17,8 @@
|
|||||||
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 9 个基础工具
|
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 9 个基础工具
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// 文件系统工具(5 个)
|
// 文件系统工具(7 个)
|
||||||
export { ReadFileTool, WriteFileTool, ListDirectoryTool, SearchFilesTool, DeleteFileTool } from './filesystem';
|
export { ReadFileTool, WriteFileTool, ListDirectoryTool, SearchFilesTool, DeleteFileTool, FileMoveTool, FileInfoTool } from './filesystem';
|
||||||
// v0.2.0: 精准文件编辑工具
|
// v0.2.0: 精准文件编辑工具
|
||||||
export { FileEditorTool } from './file-editor';
|
export { FileEditorTool } from './file-editor';
|
||||||
// v0.2.0: ripgrep 代码搜索工具
|
// v0.2.0: ripgrep 代码搜索工具
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ import {
|
|||||||
ViewImageTool,
|
ViewImageTool,
|
||||||
// v0.3.2 新增工具(1 个)
|
// v0.3.2 新增工具(1 个)
|
||||||
DeleteFileTool,
|
DeleteFileTool,
|
||||||
|
// v0.3.3 新增工具(2 个)
|
||||||
|
FileMoveTool,
|
||||||
|
FileInfoTool,
|
||||||
} from './harness/tools/built-in';
|
} from './harness/tools/built-in';
|
||||||
import { AuditLogHook, MemoryTriggerHook, PermissionCheckHook, RateLimitHook } from './harness/hooks';
|
import { AuditLogHook, MemoryTriggerHook, PermissionCheckHook, RateLimitHook } from './harness/hooks';
|
||||||
import { ConfirmationHook } from './harness/hooks/confirmation-hook';
|
import { ConfirmationHook } from './harness/hooks/confirmation-hook';
|
||||||
@@ -206,6 +209,10 @@ async function initialize(): Promise<void> {
|
|||||||
// v0.3.2: 文件删除工具(破坏性操作,HIGH + requireConfirmation)
|
// v0.3.2: 文件删除工具(破坏性操作,HIGH + requireConfirmation)
|
||||||
toolRegistry.registerBuiltin(new DeleteFileTool());
|
toolRegistry.registerBuiltin(new DeleteFileTool());
|
||||||
|
|
||||||
|
// v0.3.3: 文件移动/重命名工具 + 文件信息查询工具
|
||||||
|
toolRegistry.registerBuiltin(new FileMoveTool());
|
||||||
|
toolRegistry.registerBuiltin(new FileInfoTool());
|
||||||
|
|
||||||
// v0.2.0: 新增文件工具
|
// v0.2.0: 新增文件工具
|
||||||
toolRegistry.registerBuiltin(new FileEditorTool());
|
toolRegistry.registerBuiltin(new FileEditorTool());
|
||||||
toolRegistry.registerBuiltin(new CodeSearchTool());
|
toolRegistry.registerBuiltin(new CodeSearchTool());
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "metona-ai-desktop",
|
"name": "metona-ai-desktop",
|
||||||
"version": "0.3.11",
|
"version": "0.3.12",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "metona-ai-desktop",
|
"name": "metona-ai-desktop",
|
||||||
"version": "0.3.11",
|
"version": "0.3.12",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "metona-ai-desktop",
|
"name": "metona-ai-desktop",
|
||||||
"version": "0.3.11",
|
"version": "0.3.12",
|
||||||
"description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用",
|
"description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用",
|
||||||
"main": "dist-electron/main/main.js",
|
"main": "dist-electron/main/main.js",
|
||||||
"author": "Metona Team",
|
"author": "Metona Team",
|
||||||
|
|||||||
Reference in New Issue
Block a user