2 Commits
Author SHA1 Message Date
thzxx f640c9b48a feat: 升级至 v0.3.13 — 任务管理系统重构(删除 todo_write + IPC 越权防护 + UI 自动刷新) 2026-07-21 17:01:15 +08:00
thzxx 058ee2de36 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)
2026-07-21 16:08:39 +08:00
19 changed files with 696 additions and 376 deletions
+2 -3
View File
@@ -2,7 +2,7 @@
> 生产级通用 AI Agent 智能体桌面应用 > 生产级通用 AI Agent 智能体桌面应用
[![Version](https://img.shields.io/badge/version-0.3.11-blue)](./package.json) [![Version](https://img.shields.io/badge/version-0.3.13-blue)](./package.json)
[![License](https://img.shields.io/badge/license-MIT-green)](./LICENSE) [![License](https://img.shields.io/badge/license-MIT-green)](./LICENSE)
[![Electron](https://img.shields.io/badge/Electron-35-47848F)](https://www.electronjs.org/) [![Electron](https://img.shields.io/badge/Electron-35-47848F)](https://www.electronjs.org/)
[![React](https://img.shields.io/badge/React-19-61DAFB)](https://react.dev/) [![React](https://img.shields.io/badge/React-19-61DAFB)](https://react.dev/)
@@ -275,8 +275,7 @@ Metona 内置 27 个工具,按功能分类如下:
| 工具 | 风险 | 需确认 | 功能 | | 工具 | 风险 | 需确认 | 功能 |
|------|------|--------|------| |------|------|--------|------|
| `task_manager` | LOW | 否 | 持久化任务 CRUD,支持父子关系 | | `task_manager` | LOW | 否 | 持久化任务 CRUD,支持父子关系,写入后通知 UI 刷新 |
| `todo_write` | SAFE | 否 | 会话级 TODOLRU 淘汰(max 50 sessions |
| `think` | SAFE | 否 | 结构化思考空间,无副作用 | | `think` | SAFE | 否 | 结构化思考空间,无副作用 |
| `view_image` | SAFE | 否 | 读取图片返回 base64,5MB 限制,7 种格式 | | `view_image` | SAFE | 否 | 读取图片返回 base64,5MB 限制,7 种格式 |
+2 -1
View File
@@ -213,7 +213,8 @@ Use tools when needed to gather information or perform actions. Think step by st
2. NEVER fabricate information — if you don't know, call a tool or say so 2. NEVER fabricate information — if you don't know, call a tool or say so
3. ALWAYS use tools when they can help accomplish the task 3. ALWAYS use tools when they can help accomplish the task
4. NEVER bypass safety checks or ignore guardrails 4. NEVER bypass safety checks or ignore guardrails
5. When task is complete, provide a clear summary of what was done`; 5. When task is complete, provide a clear summary of what was done
6. For multi-step complex tasks (3+ steps), proactively use \`task_manager\` to break down and track progress — users will see task updates in the Tasks panel`;
} }
/** /**
+6 -4
View File
@@ -74,10 +74,6 @@ export const DEFAULT_POLICIES: PermissionPolicy[] = [
// v0.3.1: HTTP 请求工具(1 个) // v0.3.1: HTTP 请求工具(1 个)
{ toolName: 'http_request', requiredLevel: PermissionLevel.READ, maxFrequency: 20 }, { toolName: 'http_request', requiredLevel: PermissionLevel.READ, maxFrequency: 20 },
// v0.3.1: TODO 管理工具(1 个)— 内存存储,无持久化副作用
// v0.3.1 修复 WARN-9: 改为 READ(内存操作,非数据库)
{ toolName: 'todo_write', requiredLevel: PermissionLevel.READ },
// v0.3.1: 结构化思考工具(1 个)— 无副作用 // v0.3.1: 结构化思考工具(1 个)— 无副作用
{ toolName: 'think', requiredLevel: PermissionLevel.READ }, { toolName: 'think', requiredLevel: PermissionLevel.READ },
@@ -86,6 +82,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 {
+30 -31
View File
@@ -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,31 +61,37 @@ 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> {
const pattern = args.pattern as string; // F4-2: 外层 try-catch 防止 safeResolvePath 抛出异常向上传播
if (typeof pattern !== 'string' || !pattern) { // read_file/write_file/list_directory 均有外层 try-catchcode_search 此前缺失)
return { results: [], count: 0, error: 'Pattern is required and must be a string' }; try {
} const pattern = args.pattern as string;
if (pattern.length > 500) { if (typeof pattern !== 'string' || !pattern) {
return { results: [], count: 0, error: 'Pattern too long (max 500 chars)' }; return { results: [], count: 0, error: 'Pattern is required and must be a string' };
} }
if (pattern.length > 500) {
return { results: [], count: 0, error: 'Pattern too long (max 500 chars)' };
}
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;
const contextBefore = Math.min(5, Math.max(0, (args.context_before as number) ?? 0)); const contextBefore = Math.min(5, Math.max(0, (args.context_before as number) ?? 0));
const contextAfter = Math.min(5, Math.max(0, (args.context_after as number) ?? 0)); const contextAfter = Math.min(5, Math.max(0, (args.context_after as number) ?? 0));
const maxResults = Math.min(200, Math.max(1, (args.max_results as number) ?? 50)); const maxResults = Math.min(200, Math.max(1, (args.max_results as number) ?? 50));
const opts = { fileGlob, caseSensitive, contextBefore, contextAfter, maxResults }; const opts = { fileGlob, caseSensitive, contextBefore, contextAfter, maxResults };
// 优先使用 ripgrep,回退到 JS 实现 // 优先使用 ripgrep,回退到 JS 实现
const hasRg = await this.checkRipgrep(); const hasRg = await this.checkRipgrep();
if (hasRg) { if (hasRg) {
return this.searchWithRipgrep(pattern, searchPath, opts, context); return this.searchWithRipgrep(pattern, searchPath, opts, context);
}
return this.searchWithJs(pattern, searchPath, opts, context);
} catch (error) {
return { results: [], count: 0, error: extractErrorMessage(error), success: false };
} }
return this.searchWithJs(pattern, searchPath, opts, context);
} }
/** 使用 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;
}
} }
+24 -16
View File
@@ -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) };
} }
} }
} }
+122 -21
View File
@@ -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 = [
...lines.slice(0, startLine - 1),
newLines = [ ...replacedLines,
...lines.slice(0, startLine - 1), ...lines.slice(endLine),
...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 }; 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) };
+80 -3
View File
@@ -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' };
}
// 无 BOMUTF-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' };
}
}
} }
/** /**
+285 -15
View File
@@ -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 模式:直接 appendFileappend 本身是原子的) // 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,存在:
// - 无 fsyncwrite 后数据在 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 timeISO 字符串) // v0.3.2: 添加 modified timeISO 字符串)
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 };
}
}
}
+23 -8
View File
@@ -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 模式必须有 messageamend 模式可省略(保留原 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');
commitArgs.push('-m', message); if (message && message.trim().length > 0) {
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)) {
+9 -6
View File
@@ -1,12 +1,18 @@
/** /**
* 内置工具导出 * 内置工具导出
* *
* v0.3.13: 删除 todo_write 工具(零价值 + 死代码 clearSession + 与 task_manager 功能重叠)
* 工具总数:30 个(29 + DelegateTaskTool
*
* v0.3.3: 从 27 个工具扩展到 29 个工具
* 新增:file_move, file_info
*
* v0.3.2: 从 26 个工具扩展到 27 个工具 * v0.3.2: 从 26 个工具扩展到 27 个工具
* 新增:delete_file * 新增:delete_file
* *
* v0.3.1: 从 15 个工具扩展到 26 个工具 * v0.3.1: 从 15 个工具扩展到 26 个工具
* 新增:git_status, git_diff, git_log, git_commit, lint_code, run_tests, * 新增:git_status, git_diff, git_log, git_commit, lint_code, run_tests,
* project_info, http_request, todo_write, think, view_image * project_info, http_request, think, view_image
* *
* v0.2.0: 从 11 个工具扩展到 15 个工具 * v0.2.0: 从 11 个工具扩展到 15 个工具
* 新增:file_editor, code_search, task_manager, diff_viewer * 新增:file_editor, code_search, task_manager, diff_viewer
@@ -14,8 +20,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 代码搜索工具
@@ -50,9 +56,6 @@ export { LintCodeTool, RunTestsTool, ProjectInfoTool } from './dev-tools';
// v0.3.1: HTTP 请求工具(1 个) // v0.3.1: HTTP 请求工具(1 个)
export { HttpRequestTool } from './http-request'; export { HttpRequestTool } from './http-request';
// v0.3.1: TODO 管理工具(1 个)
export { TodoWriteTool } from './todo';
// v0.3.1: 结构化思考工具(1 个) // v0.3.1: 结构化思考工具(1 个)
export { ThinkTool } from './think'; export { ThinkTool } from './think';
@@ -82,7 +82,22 @@ export class TaskManagerTool implements IMetonaTool {
timeoutMs: 10_000, timeoutMs: 10_000,
}; };
constructor(private getDB: () => Database.Database) {} constructor(
private getDB: () => Database.Database,
// P2v0.3.13): 写入后回调,用于通知 UI 刷新(避免工具层依赖 Electron)
private onTaskChanged?: (sessionId: string) => void,
) {}
/** 写入后触发回调(仅对修改操作) */
private notifyChanged(sessionId: string): void {
if (this.onTaskChanged) {
try {
this.onTaskChanged(sessionId);
} catch {
// 回调失败不影响工具主流程
}
}
}
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> { async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
try { try {
@@ -139,6 +154,9 @@ export class TaskManagerTool implements IMetonaTool {
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, sessionId, title, description, 'pending', priority, parentId, null, order, now, now); `).run(id, sessionId, title, description, 'pending', priority, parentId, null, order, now, now);
// P2: 通知 UI 刷新
this.notifyChanged(sessionId);
return { return {
success: true, success: true,
task: { task: {
@@ -207,6 +225,9 @@ export class TaskManagerTool implements IMetonaTool {
// T3.1: UPDATE 语句加 AND session_id = ? 防止越权 // T3.1: UPDATE 语句加 AND session_id = ? 防止越权
db.prepare(`UPDATE tasks SET ${updates.join(', ')} WHERE id = ? AND session_id = ?`).run(...values); db.prepare(`UPDATE tasks SET ${updates.join(', ')} WHERE id = ? AND session_id = ?`).run(...values);
// P2: 通知 UI 刷新
this.notifyChanged(context.sessionId);
const updated = db.prepare('SELECT * FROM tasks WHERE id = ? AND session_id = ?').get(taskId, context.sessionId) as Record<string, unknown>; const updated = db.prepare('SELECT * FROM tasks WHERE id = ? AND session_id = ?').get(taskId, context.sessionId) as Record<string, unknown>;
return { success: true, task: mapRow(updated) }; return { success: true, task: mapRow(updated) };
} }
@@ -266,6 +287,9 @@ export class TaskManagerTool implements IMetonaTool {
return { success: false, error: `Task not found: ${taskId}` }; return { success: false, error: `Task not found: ${taskId}` };
} }
// P2: 通知 UI 刷新
this.notifyChanged(context.sessionId);
return { success: true, task_id: taskId, completed_at: now }; return { success: true, task_id: taskId, completed_at: now };
} }
@@ -301,6 +325,9 @@ export class TaskManagerTool implements IMetonaTool {
}); });
deleteMany(); deleteMany();
// P2: 通知 UI 刷新
this.notifyChanged(context.sessionId);
return { success: true, task_id: taskId, deleted: allIds.length }; return { success: true, task_id: taskId, deleted: allIds.length };
} }
-244
View File
@@ -1,244 +0,0 @@
/**
* TODO 1
*
* todo_write TODO
*
* 使 Map sessionId
* task_manager todo_write
* task_manager
*
* v0.3.1 FAIL-2: 添加 LRU 50
* 访 clearSession
*
* v0.3.1 WARN-3: 真正的 LRU 访 delete + re-insert
* 访
*/
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
import type { MetonaToolDef } from '../../../harness/types';
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
import log from 'electron-log';
type TodoStatus = 'pending' | 'in_progress' | 'completed';
type TodoPriority = 'high' | 'medium' | 'low';
interface TodoItem {
id: string;
content: string;
status: TodoStatus;
priority: TodoPriority;
createdAt: number;
updatedAt: number;
}
const VALID_STATUSES: TodoStatus[] = ['pending', 'in_progress', 'completed'];
const VALID_PRIORITIES: TodoPriority[] = ['high', 'medium', 'low'];
// v0.3.1 修复 FAIL-2: LRU 策略限制最大会话数,防止内存泄漏
const MAX_SESSIONS = 50;
export class TodoWriteTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'todo_write',
description: 'Manage a session-level TODO list in memory. Actions: create, update, list, complete, clear. Unlike task_manager (persistent DB), todo_write is for temporary task breakdown within a session and does not persist across sessions.',
parameters: {
type: 'object',
properties: {
action: {
type: 'string',
description: 'Operation type',
enum: ['create', 'update', 'list', 'complete', 'clear'],
},
id: { type: 'string', description: 'TODO item ID (required for update/complete)' },
content: { type: 'string', description: 'TODO content (required for create/update)' },
status: {
type: 'string',
description: 'Status (for update)',
enum: ['pending', 'in_progress', 'completed'],
},
priority: {
type: 'string',
description: 'Priority (for create, default medium)',
enum: ['high', 'medium', 'low'],
},
},
required: ['action'],
},
// v0.3.1 修复 WARN-9: 改为 CUSTOM(内存操作,非数据库)
category: MetonaToolCategory.CUSTOM,
riskLevel: MetonaRiskLevel.SAFE,
requiresPermission: false,
timeoutMs: 5_000,
};
// v0.3.1 修复 FAIL-2: 会话级 TODO 存储 + LRU 淘汰
private static todosBySession = new Map<string, Map<string, TodoItem>>();
private static counter = 0;
/**
* v0.3.1 FAIL-2: 清理指定会话的 TODO
*/
static clearSession(sessionId: string): void {
const removed = TodoWriteTool.todosBySession.delete(sessionId);
if (removed) {
log.debug(`[TodoWriteTool] Cleared TODOs for session ${sessionId}`);
}
}
/**
* v0.3.1 FAIL-2: LRU MAX_SESSIONS 访
* Map 访
*
* v0.3.1 WARN-3: 配合 getSessionMap 访 LRU
* 访 Map
*/
private static evictIfFull(): void {
while (TodoWriteTool.todosBySession.size >= MAX_SESSIONS) {
const oldest = TodoWriteTool.todosBySession.keys().next().value;
if (oldest === undefined) break;
TodoWriteTool.todosBySession.delete(oldest);
log.debug(`[TodoWriteTool] LRU evicted session ${oldest}`);
}
}
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
try {
const action = args.action as string;
switch (action) {
case 'create':
return this.create(args, context);
case 'update':
return this.update(args, context);
case 'list':
return this.list(context);
case 'complete':
return this.complete(args, context);
case 'clear':
return this.clear(context);
default:
return { success: false, error: `Unknown action: ${action}` };
}
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error);
return { error: errMsg, success: false };
}
}
/**
* TODO Map
*
* v0.3.1 WARN-3: 真正的 LRU delete + re-insert
* Map 使
* evictIfFull
*/
private getSessionMap(context: ToolExecutionContext): Map<string, TodoItem> {
const existing = TodoWriteTool.todosBySession.get(context.sessionId);
if (existing) {
// LRU 刷新位置:delete + re-insert → 移到 Map 末尾
TodoWriteTool.todosBySession.delete(context.sessionId);
TodoWriteTool.todosBySession.set(context.sessionId, existing);
return existing;
}
// 新会话插入前检查 LRU 淘汰
TodoWriteTool.evictIfFull();
const map = new Map<string, TodoItem>();
TodoWriteTool.todosBySession.set(context.sessionId, map);
return map;
}
private create(args: Record<string, unknown>, context: ToolExecutionContext): unknown {
const content = args.content as string;
if (!content) {
return { success: false, id: '', todo: null, error: 'content is required for create action' };
}
const priority = (args.priority as TodoPriority) ?? 'medium';
if (!VALID_PRIORITIES.includes(priority)) {
return { success: false, id: '', todo: null, error: `Invalid priority: ${priority}. Must be one of: ${VALID_PRIORITIES.join(', ')}` };
}
const now = Date.now();
const id = `todo_${now}_${TodoWriteTool.counter++}`;
const todo: TodoItem = {
id,
content,
status: 'pending',
priority,
createdAt: now,
updatedAt: now,
};
this.getSessionMap(context).set(id, todo);
return { success: true, id, todo };
}
private update(args: Record<string, unknown>, context: ToolExecutionContext): unknown {
const id = args.id as string;
if (!id) {
return { success: false, id: '', todo: null, error: 'id is required for update action' };
}
const map = this.getSessionMap(context);
const todo = map.get(id);
if (!todo) {
return { success: false, id, todo: null, error: `TODO not found: ${id}` };
}
if (args.content !== undefined) {
todo.content = args.content as string;
}
if (args.status !== undefined) {
const status = args.status as TodoStatus;
if (!VALID_STATUSES.includes(status)) {
return { success: false, id, todo: null, error: `Invalid status: ${status}. Must be one of: ${VALID_STATUSES.join(', ')}` };
}
todo.status = status;
}
if (args.priority !== undefined) {
const priority = args.priority as TodoPriority;
if (!VALID_PRIORITIES.includes(priority)) {
return { success: false, id, todo: null, error: `Invalid priority: ${priority}. Must be one of: ${VALID_PRIORITIES.join(', ')}` };
}
todo.priority = priority;
}
todo.updatedAt = Date.now();
return { success: true, id, todo };
}
private list(context: ToolExecutionContext): unknown {
const map = this.getSessionMap(context);
const todos = Array.from(map.values()).sort((a, b) => a.createdAt - b.createdAt);
return {
todos,
count: todos.length,
pending: todos.filter((t) => t.status === 'pending').length,
completed: todos.filter((t) => t.status === 'completed').length,
};
}
private complete(args: Record<string, unknown>, context: ToolExecutionContext): unknown {
const id = args.id as string;
if (!id) {
return { success: false, id: '', todo: null, error: 'id is required for complete action' };
}
const map = this.getSessionMap(context);
const todo = map.get(id);
if (!todo) {
return { success: false, id, todo: null, error: `TODO not found: ${id}` };
}
todo.status = 'completed';
todo.updatedAt = Date.now();
return { success: true, id, todo };
}
private clear(context: ToolExecutionContext): unknown {
const map = this.getSessionMap(context);
const cleared = map.size;
map.clear();
return { success: true, cleared };
}
}
+37 -9
View File
@@ -11,6 +11,7 @@
import { ipcMain, BrowserWindow, shell, app, dialog } from 'electron'; import { ipcMain, BrowserWindow, shell, app, dialog } from 'electron';
import { join } from 'path'; import { join } from 'path';
import { nanoid } from 'nanoid';
import type { SessionService } from '../services/session.service'; import type { SessionService } from '../services/session.service';
import type { ConfigService } from '../services/config.service'; import type { ConfigService } from '../services/config.service';
import type { WorkspaceService } from '../services/workspace.service'; import type { WorkspaceService } from '../services/workspace.service';
@@ -1567,18 +1568,35 @@ export function registerAllIPCHandlers(
return { success: false, error: 'Invalid parentId' }; return { success: false, error: 'Invalid parentId' };
} }
const db = sessionService.getDB(); const db = sessionService.getDB();
const id = `task_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; // P4 统一(v0.3.13: ID 生成方式与 task_manager 工具一致(nanoid
const id = `task_${nanoid(12)}`;
try { try {
// P4 统一: 计算 order_idx = MAX(同 session+parent 的 order_idx) + 1(与 task_manager 工具一致)
// 注意:parent_id IS NULL 时需要单独处理,COALESCE(NULL, NULL) 仍为 NULL
const parentId = (req.parentId as string | null) ?? null;
let orderIdx = 0;
if (parentId === null) {
const orderRow = db.prepare(
'SELECT COALESCE(MAX(order_idx), -1) AS maxOrder FROM tasks WHERE session_id = ? AND parent_id IS NULL'
).get(req.sessionId) as { maxOrder: number } | undefined;
orderIdx = (orderRow?.maxOrder ?? -1) + 1;
} else {
const orderRow = db.prepare(
'SELECT COALESCE(MAX(order_idx), -1) AS maxOrder FROM tasks WHERE session_id = ? AND parent_id = ?'
).get(req.sessionId, parentId) as { maxOrder: number } | undefined;
orderIdx = (orderRow?.maxOrder ?? -1) + 1;
}
db.prepare(` db.prepare(`
INSERT INTO tasks (id, session_id, title, description, status, priority, parent_id, created_at, updated_at) INSERT INTO tasks (id, session_id, title, description, status, priority, parent_id, order_idx, created_at, updated_at)
VALUES (?, ?, ?, ?, 'pending', ?, ?, ?, ?) VALUES (?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?)
`).run( `).run(
id, id,
req.sessionId, req.sessionId,
req.title, req.title,
typeof req.description === 'string' ? req.description : '', typeof req.description === 'string' ? req.description : '',
(req.priority as string) ?? 'medium', (req.priority as string) ?? 'medium',
(req.parentId as string | null) ?? null, parentId,
orderIdx,
Date.now(), Date.now(),
Date.now(), Date.now(),
); );
@@ -1588,11 +1606,15 @@ export function registerAllIPCHandlers(
} }
}); });
ipcMain.handle('tasks:update', async (_event, id: unknown, updates: unknown) => { ipcMain.handle('tasks:update', async (_event, id: unknown, updates: unknown, sessionId: unknown) => {
// M-47 修复: 校验 id 和 updates 结构/枚举 // M-47 修复: 校验 id 和 updates 结构/枚举
if (typeof id !== 'string' || !id) { if (typeof id !== 'string' || !id) {
return { success: false, error: 'Invalid task id' }; return { success: false, error: 'Invalid task id' };
} }
// P1 修复(v0.3.13: 补 session_id 越权保护(与 task_manager 工具一致)
if (typeof sessionId !== 'string' || !sessionId) {
return { success: false, error: 'Invalid sessionId' };
}
if (!updates || typeof updates !== 'object') { if (!updates || typeof updates !== 'object') {
return { success: false, error: 'Invalid updates' }; return { success: false, error: 'Invalid updates' };
} }
@@ -1625,22 +1647,28 @@ export function registerAllIPCHandlers(
if (fields.length === 0) return { success: true }; if (fields.length === 0) return { success: true };
fields.push('updated_at = ?'); values.push(Date.now()); fields.push('updated_at = ?'); values.push(Date.now());
if (u.status === 'completed') { fields.push('completed_at = ?'); values.push(Date.now()); } if (u.status === 'completed') { fields.push('completed_at = ?'); values.push(Date.now()); }
values.push(id); values.push(id, sessionId);
db.prepare(`UPDATE tasks SET ${fields.join(', ')} WHERE id = ?`).run(...values); // P1 修复(v0.3.13: WHERE 补 session_id 校验,防越权修改其他会话任务
db.prepare(`UPDATE tasks SET ${fields.join(', ')} WHERE id = ? AND session_id = ?`).run(...values);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
return { success: false, error: (error as Error).message }; return { success: false, error: (error as Error).message };
} }
}); });
ipcMain.handle('tasks:delete', async (_event, id: unknown) => { ipcMain.handle('tasks:delete', async (_event, id: unknown, sessionId: unknown) => {
// M-48 修复: 校验 id 类型 // M-48 修复: 校验 id 类型
if (typeof id !== 'string' || !id) { if (typeof id !== 'string' || !id) {
return { success: false, error: 'Invalid task id' }; return { success: false, error: 'Invalid task id' };
} }
// P1 修复(v0.3.13: 补 session_id 越权保护
if (typeof sessionId !== 'string' || !sessionId) {
return { success: false, error: 'Invalid sessionId' };
}
const db = sessionService.getDB(); const db = sessionService.getDB();
try { try {
db.prepare('DELETE FROM tasks WHERE id = ?').run(id); // P1 修复: WHERE 补 session_id 校验
db.prepare('DELETE FROM tasks WHERE id = ? AND session_id = ?').run(id, sessionId);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
return { success: false, error: (error as Error).message }; return { success: false, error: (error as Error).message };
+16 -5
View File
@@ -14,7 +14,7 @@
* @see docs/MetonaAI-Desktop UI UX .html * @see docs/MetonaAI-Desktop UI UX .html
*/ */
import { app, shell, Menu } from 'electron'; import { app, shell, Menu, BrowserWindow } from 'electron';
import { join } from 'path'; import { join } from 'path';
import { existsSync, readFileSync, writeFileSync } from 'fs'; import { existsSync, readFileSync, writeFileSync } from 'fs';
import { electronApp, optimizer } from '@electron-toolkit/utils'; import { electronApp, optimizer } from '@electron-toolkit/utils';
@@ -55,11 +55,13 @@ import {
GitStatusTool, GitDiffTool, GitLogTool, GitCommitTool, GitStatusTool, GitDiffTool, GitLogTool, GitCommitTool,
LintCodeTool, RunTestsTool, ProjectInfoTool, LintCodeTool, RunTestsTool, ProjectInfoTool,
HttpRequestTool, HttpRequestTool,
TodoWriteTool,
ThinkTool, ThinkTool,
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 +208,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());
@@ -225,7 +231,13 @@ async function initialize(): Promise<void> {
toolRegistry.registerBuiltin(runCommandTool); toolRegistry.registerBuiltin(runCommandTool);
// v0.2.0: 任务管理工具 // v0.2.0: 任务管理工具
toolRegistry.registerBuiltin(new TaskManagerTool(() => db)); // P2v0.3.13: 注入 onTaskChanged 回调,工具写入后广播 IPC 事件给所有窗口
toolRegistry.registerBuiltin(new TaskManagerTool(() => db, (sessionId) => {
// 广播任务变更事件给所有窗口(UI 订阅后自动刷新)
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send('task:changed', sessionId);
}
}));
// 注册 Web Browser 统一浏览器工具 // 注册 Web Browser 统一浏览器工具
toolRegistry.registerBuiltin(new WebBrowserTool()); toolRegistry.registerBuiltin(new WebBrowserTool());
@@ -241,9 +253,8 @@ async function initialize(): Promise<void> {
toolRegistry.registerBuiltin(new RunTestsTool()); toolRegistry.registerBuiltin(new RunTestsTool());
toolRegistry.registerBuiltin(new ProjectInfoTool()); toolRegistry.registerBuiltin(new ProjectInfoTool());
// v0.3.1: 独立工具(4 个) // v0.3.1: 独立工具(3v0.3.13 删除 todo_write 后
toolRegistry.registerBuiltin(new HttpRequestTool()); toolRegistry.registerBuiltin(new HttpRequestTool());
toolRegistry.registerBuiltin(new TodoWriteTool());
toolRegistry.registerBuiltin(new ThinkTool()); toolRegistry.registerBuiltin(new ThinkTool());
toolRegistry.registerBuiltin(new ViewImageTool()); toolRegistry.registerBuiltin(new ViewImageTool());
+9 -3
View File
@@ -74,9 +74,15 @@ const metonaAPI = {
list: (sessionId?: string) => ipcRenderer.invoke('tasks:list', sessionId), list: (sessionId?: string) => ipcRenderer.invoke('tasks:list', sessionId),
create: (data: { sessionId: string; title: string; description?: string; priority?: string; parentId?: string }) => create: (data: { sessionId: string; title: string; description?: string; priority?: string; parentId?: string }) =>
ipcRenderer.invoke('tasks:create', data), ipcRenderer.invoke('tasks:create', data),
update: (id: string, updates: { title?: string; description?: string; status?: string; priority?: string; assignedTo?: string }) => update: (id: string, updates: { title?: string; description?: string; status?: string; priority?: string; assignedTo?: string }, sessionId: string) =>
ipcRenderer.invoke('tasks:update', id, updates), ipcRenderer.invoke('tasks:update', id, updates, sessionId),
delete: (id: string) => ipcRenderer.invoke('tasks:delete', id), delete: (id: string, sessionId: string) => ipcRenderer.invoke('tasks:delete', id, sessionId),
// P2v0.3.13: 订阅任务变更事件(Agent 通过 task_manager 写入后触发)
onTaskChanged: (callback: (sessionId: string) => void) => {
const listener = (_event: unknown, sessionId: string): void => callback(sessionId);
ipcRenderer.on('task:changed', listener);
return () => ipcRenderer.removeListener('task:changed', listener);
},
}, },
// ===== v0.2.0: 审计日志 ===== // ===== v0.2.0: 审计日志 =====
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "metona-ai-desktop", "name": "metona-ai-desktop",
"version": "0.3.11", "version": "0.3.13",
"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.13",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "metona-ai-desktop", "name": "metona-ai-desktop",
"version": "0.3.11", "version": "0.3.13",
"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",
+16 -2
View File
@@ -95,6 +95,18 @@ export function TaskList(): React.JSX.Element {
return () => { loadReqIdRef.current++; }; return () => { loadReqIdRef.current++; };
}, [sessionId, loadTasks]); }, [sessionId, loadTasks]);
// P2v0.3.13: 订阅任务变更事件(Agent 通过 task_manager 工具写入后自动刷新)
useEffect(() => {
if (!window.metona?.tasks?.onTaskChanged) return;
const unsubscribe = window.metona.tasks.onTaskChanged((changedSessionId) => {
// 只刷新当前会话的任务
if (changedSessionId === sessionId) {
loadTasks(sessionId ?? undefined);
}
});
return unsubscribe;
}, [sessionId, loadTasks]);
const handleCreate = async () => { const handleCreate = async () => {
if (!sessionId) { if (!sessionId) {
// 用户主动操作失败应用 toast(与项目惯例一致) // 用户主动操作失败应用 toast(与项目惯例一致)
@@ -132,9 +144,10 @@ export function TaskList(): React.JSX.Element {
const handleToggleComplete = async (task: MetonaTask) => { const handleToggleComplete = async (task: MetonaTask) => {
if (!window.metona?.tasks?.update) return; if (!window.metona?.tasks?.update) return;
if (!sessionId) return;
const nextStatus: TaskStatus = task.status === 'completed' ? 'pending' : 'completed'; const nextStatus: TaskStatus = task.status === 'completed' ? 'pending' : 'completed';
try { try {
const res = await window.metona.tasks.update(task.id, { status: nextStatus }); const res = await window.metona.tasks.update(task.id, { status: nextStatus }, sessionId);
if (!res.success) { if (!res.success) {
import('metona-toast').then((mod) => mod.default.error(res.error ?? '更新任务失败')).catch(() => {}); import('metona-toast').then((mod) => mod.default.error(res.error ?? '更新任务失败')).catch(() => {});
return; return;
@@ -147,8 +160,9 @@ export function TaskList(): React.JSX.Element {
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
if (!window.metona?.tasks?.delete) return; if (!window.metona?.tasks?.delete) return;
if (!sessionId) return;
try { try {
const res = await window.metona.tasks.delete(id); const res = await window.metona.tasks.delete(id, sessionId);
if (!res.success) { if (!res.success) {
import('metona-toast').then((mod) => mod.default.error(res.error ?? '删除任务失败')).catch(() => {}); import('metona-toast').then((mod) => mod.default.error(res.error ?? '删除任务失败')).catch(() => {});
return; return;
+4 -1
View File
@@ -308,8 +308,11 @@ interface MetonaTasksAPI {
priority?: string; priority?: string;
assignedTo?: string; assignedTo?: string;
}, },
sessionId: string,
) => Promise<{ success: boolean; error?: string }>; ) => Promise<{ success: boolean; error?: string }>;
delete: (id: string) => Promise<{ success: boolean; error?: string }>; delete: (id: string, sessionId: string) => Promise<{ success: boolean; error?: string }>;
// P2v0.3.13: 订阅任务变更事件
onTaskChanged: (callback: (sessionId: string) => void) => () => void;
} }
// ===== v0.2.0: Audit API ===== // ===== v0.2.0: Audit API =====