feat: 升级至 v0.2.1 — 流式渲染修复、安全增强、工具自动执行
流式渲染修复: - runId 机制防止 abort 后旧流事件污染新 run - run lock 防止并发 run 污染引擎状态 - abort race 提前退出工具执行等待 - TERMINATED 状态通过 stateChange 发射 - tool_call_delta 流式参数拼接 + pending 占位替换 - 首轮卡片创建路径统一,traceStep 按 ID 精确匹配 - compressed 事件转发为 toast 通知 安全增强: - ConfirmationHook 支持持久化自动执行(跨会话) - 设置面板新增自动执行工具管理 UI - SandboxManager 双重安全校验 fail-closed - 审计日志链式哈希防篡改 - PromptInjectionDefender 中文注入标记清理 - scanCode 28 模式 + base64/$() 检测 - validatePath realpathSync 防符号链接逃逸 - code-search 使用 execFile 防命令注入 新增工具: - file_editor、code_search、task_manager、diff_viewer 其他: - Agent Loop 加 PARSING/REFLECTING 状态 + 指数退避重试 - MemoryManager TF-IDF 语义检索 - run_command Windows 中文编码修复(chcp 65001) - 版本号 0.2.0 → 0.2.1
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* 代码搜索工具(1 个)
|
||||
*
|
||||
* code_search — 基于 ripgrep 的高速代码搜索
|
||||
*
|
||||
* 相比 search_files 的纯 JS 实现,code_search 调用 ripgrep 子进程,
|
||||
* 性能提升 10-100 倍,支持正则、文件类型过滤、上下文行展示。
|
||||
* 适合大型代码库的精准搜索。
|
||||
*
|
||||
* @see standard/开发规范.md — 优先使用第三方成熟库
|
||||
*/
|
||||
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { resolve } from 'path';
|
||||
import log from 'electron-log';
|
||||
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
|
||||
import type { MetonaToolDef } from '../../../harness/types';
|
||||
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
|
||||
import { isPathWithinWorkspace } from './file-guard';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export class CodeSearchTool implements IMetonaTool {
|
||||
/** ripgrep 可用性缓存(实例级,便于测试重置) */
|
||||
private rgAvailable: boolean | null = null;
|
||||
|
||||
/** 检测系统是否安装了 ripgrep */
|
||||
private async checkRipgrep(): Promise<boolean> {
|
||||
if (this.rgAvailable !== null) return this.rgAvailable;
|
||||
try {
|
||||
await execFileAsync('rg', ['--version'], { timeout: 3_000 });
|
||||
this.rgAvailable = true;
|
||||
} catch {
|
||||
this.rgAvailable = false;
|
||||
}
|
||||
return this.rgAvailable;
|
||||
}
|
||||
|
||||
readonly definition: MetonaToolDef = {
|
||||
name: 'code_search',
|
||||
description: 'Search code using ripgrep. Supports regex patterns, file type filtering, and context lines. Much faster than search_files for large codebases. Falls back to JS implementation if ripgrep is not installed.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
pattern: { type: 'string', description: 'Regex pattern to search for' },
|
||||
path: { type: 'string', description: 'Search directory (default: workspace root)' },
|
||||
file_glob: { type: 'string', description: 'File name glob filter (e.g., "*.ts", "*.py")' },
|
||||
case_sensitive: { type: 'boolean', description: 'Case sensitive search (default false)' },
|
||||
context_before: { type: 'number', description: 'Lines of context before match (default 0, max 5)' },
|
||||
context_after: { type: 'number', description: 'Lines of context after match (default 0, max 5)' },
|
||||
max_results: { type: 'number', description: 'Maximum results (default 50, max 200)' },
|
||||
},
|
||||
required: ['pattern'],
|
||||
},
|
||||
category: MetonaToolCategory.SEARCH,
|
||||
riskLevel: MetonaRiskLevel.SAFE,
|
||||
requiresPermission: false,
|
||||
timeoutMs: 30_000,
|
||||
};
|
||||
|
||||
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
||||
const pattern = args.pattern as string;
|
||||
if (typeof pattern !== 'string' || !pattern) {
|
||||
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
|
||||
? this.resolveSearchPath(args.path as string, context.workspacePath)
|
||||
: context.workspacePath;
|
||||
const fileGlob = args.file_glob as string | undefined;
|
||||
const caseSensitive = (args.case_sensitive as boolean) ?? false;
|
||||
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 maxResults = Math.min(200, Math.max(1, (args.max_results as number) ?? 50));
|
||||
|
||||
const opts = { fileGlob, caseSensitive, contextBefore, contextAfter, maxResults };
|
||||
|
||||
// 优先使用 ripgrep,回退到 JS 实现
|
||||
const hasRg = await this.checkRipgrep();
|
||||
if (hasRg) {
|
||||
return this.searchWithRipgrep(pattern, searchPath, opts, context);
|
||||
}
|
||||
return this.searchWithJs(pattern, searchPath, opts, context);
|
||||
}
|
||||
|
||||
/** 使用 ripgrep 子进程搜索 */
|
||||
private async searchWithRipgrep(
|
||||
pattern: string,
|
||||
searchPath: string,
|
||||
opts: { fileGlob?: string; caseSensitive: boolean; contextBefore: number; contextAfter: number; maxResults: number },
|
||||
context: ToolExecutionContext,
|
||||
): Promise<unknown> {
|
||||
const rgArgs: string[] = ['--json'];
|
||||
|
||||
if (!opts.caseSensitive) rgArgs.push('-i');
|
||||
if (opts.contextBefore > 0) rgArgs.push('-B', String(opts.contextBefore));
|
||||
if (opts.contextAfter > 0) rgArgs.push('-A', String(opts.contextAfter));
|
||||
rgArgs.push('-g', '!MEMORY.md');
|
||||
if (opts.fileGlob) rgArgs.push('-g', opts.fileGlob);
|
||||
|
||||
rgArgs.push(pattern, searchPath);
|
||||
|
||||
try {
|
||||
const { stdout } = await execFileAsync('rg', rgArgs, {
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
timeout: 25_000,
|
||||
});
|
||||
|
||||
const results = this.parseRipgrepJsonOutput(stdout);
|
||||
return { results: results.slice(0, opts.maxResults), count: results.length, engine: 'ripgrep' };
|
||||
} catch (error) {
|
||||
const err = error as { code?: number; signal?: string; stdout?: string; stderr?: string; killed?: boolean; message?: string };
|
||||
// rg 退出码 1 = 无匹配,不是错误
|
||||
if (err.code === 1) {
|
||||
return { results: [], count: 0, engine: 'ripgrep' };
|
||||
}
|
||||
// 超时被 kill
|
||||
if (err.killed || err.signal === 'SIGTERM') {
|
||||
return { results: [], count: 0, error: 'ripgrep search timed out', engine: 'ripgrep' };
|
||||
}
|
||||
// 其他错误回退到 JS
|
||||
log.warn('[CodeSearch] ripgrep failed, falling back to JS:', err.stderr || err.message);
|
||||
return this.searchWithJs(pattern, searchPath, opts, context);
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析 ripgrep --json 输出 */
|
||||
private parseRipgrepJsonOutput(output: string): Array<{
|
||||
path: string;
|
||||
line: number;
|
||||
column: number;
|
||||
match: string;
|
||||
before?: string[];
|
||||
after?: string[];
|
||||
}> {
|
||||
const results: Array<{ path: string; line: number; column: number; match: string; before?: string[]; after?: string[] }> = [];
|
||||
const lines = output.split('\n').filter((l) => l.trim());
|
||||
|
||||
let currentMatch: { path: string; line: number; column: number; match: string; before?: string[]; after?: string[] } | null = null;
|
||||
let beforeBuffer: string[] = [];
|
||||
let afterBuffer: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
let entry: Record<string, unknown>;
|
||||
try {
|
||||
entry = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const type = entry.type as string;
|
||||
const data = entry.data as Record<string, unknown>;
|
||||
|
||||
if (type === 'context') {
|
||||
const text = (data.lines as { text?: string } | undefined)?.text ?? '';
|
||||
|
||||
if (currentMatch) {
|
||||
// 当前有 match,这是 after context
|
||||
afterBuffer.push(text);
|
||||
} else {
|
||||
// 当前无 match,这是 before context
|
||||
beforeBuffer.push(text);
|
||||
}
|
||||
} else if (type === 'match') {
|
||||
// 新 match:先保存上一个 match 的 after context
|
||||
if (currentMatch) {
|
||||
if (afterBuffer.length > 0) currentMatch.after = [...afterBuffer];
|
||||
results.push(currentMatch);
|
||||
afterBuffer = [];
|
||||
}
|
||||
|
||||
const text = (data.lines as { text?: string } | undefined)?.text ?? '';
|
||||
const submatches = (data.submatches as Array<{ match: { text?: string }; start?: number }> | undefined) ?? [];
|
||||
const matchText = submatches[0]?.match?.text ?? text;
|
||||
const column = (submatches[0]?.start ?? 0) + 1;
|
||||
|
||||
currentMatch = {
|
||||
path: (data.path as { text?: string } | undefined)?.text ?? '',
|
||||
line: data.line_number as number,
|
||||
column,
|
||||
match: matchText,
|
||||
before: beforeBuffer.length > 0 ? [...beforeBuffer] : undefined,
|
||||
};
|
||||
beforeBuffer = [];
|
||||
afterBuffer = [];
|
||||
}
|
||||
}
|
||||
|
||||
// 保存最后一个 match
|
||||
if (currentMatch) {
|
||||
if (afterBuffer.length > 0) currentMatch.after = [...afterBuffer];
|
||||
results.push(currentMatch);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/** JS 回退实现 */
|
||||
private async searchWithJs(
|
||||
pattern: string,
|
||||
searchPath: string,
|
||||
opts: { fileGlob?: string; caseSensitive: boolean; contextBefore: number; contextAfter: number; maxResults: number },
|
||||
context: ToolExecutionContext,
|
||||
): Promise<unknown> {
|
||||
// 动态导入以避免循环依赖
|
||||
const { SearchFilesTool } = await import('./filesystem');
|
||||
const searchTool = new SearchFilesTool();
|
||||
return searchTool.execute({
|
||||
pattern,
|
||||
target: 'content',
|
||||
path: searchPath,
|
||||
file_glob: opts.fileGlob,
|
||||
limit: opts.maxResults,
|
||||
}, 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;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,11 @@
|
||||
*
|
||||
* run_command — 在沙箱环境中执行 Shell 命令
|
||||
*
|
||||
* 安全增强(v0.2.0):
|
||||
* 1. 接入 SandboxManager.scanCode 进行静态代码安全扫描
|
||||
* 2. 通过 SandboxManager.validatePath 校验工作目录
|
||||
* 3. 命令注入模式检测扩展
|
||||
*
|
||||
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 9 个基础工具
|
||||
* @see standard/开发规范.md — 使用 shell-quote 解析命令(禁止简单字符串匹配)
|
||||
*/
|
||||
@@ -10,10 +15,12 @@
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { resolve } from 'path';
|
||||
import log from 'electron-log';
|
||||
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
|
||||
import type { MetonaToolDef } from '../../../harness/types';
|
||||
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
|
||||
import { commandTouchesProtectedFile, isPathWithinWorkspace } from './file-guard';
|
||||
import type { SandboxManager } from '../../sandbox/sandbox';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
@@ -22,7 +29,7 @@ const execAsync = promisify(exec);
|
||||
export class RunCommandTool implements IMetonaTool {
|
||||
readonly definition: MetonaToolDef = {
|
||||
name: 'run_command',
|
||||
description: 'Execute a shell command in a sandboxed environment. Commands run in the workspace directory. High-risk commands require user confirmation.',
|
||||
description: 'Execute a shell command in a sandboxed environment. Commands run in the workspace directory. High-risk commands require user confirmation. Passes through SandboxManager static code scan and path validation.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -38,6 +45,14 @@ export class RunCommandTool implements IMetonaTool {
|
||||
timeoutMs: 120_000,
|
||||
};
|
||||
|
||||
/** v0.2.0: 可选注入 SandboxManager 进行双重安全校验 */
|
||||
private sandboxManager: SandboxManager | null = null;
|
||||
|
||||
/** 注入 SandboxManager(由 main.ts 在注册时调用) */
|
||||
setSandboxManager(manager: SandboxManager): void {
|
||||
this.sandboxManager = manager;
|
||||
}
|
||||
|
||||
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
||||
const command = args.command as string;
|
||||
const workdir = (args.workdir as string) ?? context.workspacePath;
|
||||
@@ -49,18 +64,56 @@ export class RunCommandTool implements IMetonaTool {
|
||||
return { success: false, error: `Working directory must be within workspace: ${workdir}`, command };
|
||||
}
|
||||
|
||||
// 命令安全校验
|
||||
// v0.2.0: SandboxManager 双重安全校验 — fail-closed 设计
|
||||
// 若 SandboxManager 未注入(配置错误),拒绝执行高风险命令
|
||||
if (!this.sandboxManager) {
|
||||
log.error('[RunCommandTool] SandboxManager not injected — refusing to execute (fail-closed)');
|
||||
return {
|
||||
success: false,
|
||||
error: 'Security sandbox unavailable — command execution disabled',
|
||||
command,
|
||||
};
|
||||
}
|
||||
|
||||
// SandboxManager 静态代码扫描
|
||||
const scanResult = this.sandboxManager.scanCode(command);
|
||||
if (!scanResult.safe) {
|
||||
return { success: false, error: 'Command blocked by security policy', command };
|
||||
}
|
||||
// SandboxManager 路径校验
|
||||
const pathResult = this.sandboxManager.validatePath(resolvedWorkdir);
|
||||
if (!pathResult.allowed) {
|
||||
return { success: false, error: 'Command blocked by security policy', command };
|
||||
}
|
||||
|
||||
// 命令安全校验(内置硬阻止列表)
|
||||
const validation = this.validateCommand(command);
|
||||
if (!validation.allowed) {
|
||||
return { success: false, error: validation.reason, command };
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await execAsync(command, {
|
||||
// Windows 中文编码修复:通过 chcp 65001 切换控制台到 UTF-8 代码页
|
||||
// 避免 GBK/CP936 输出被 Node.js 按 UTF-8 解码导致中文乱码
|
||||
const isWindows = process.platform === 'win32';
|
||||
const finalCommand = isWindows
|
||||
? `chcp 65001 >nul 2>&1 && ${command}`
|
||||
: command;
|
||||
|
||||
const { stdout, stderr } = await execAsync(finalCommand, {
|
||||
cwd: resolvedWorkdir,
|
||||
timeout,
|
||||
maxBuffer: 1024 * 1024, // 1MB
|
||||
env: { ...process.env, NODE_ENV: 'production' },
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: 'production',
|
||||
...(isWindows ? {
|
||||
// 强制常见程序使用 UTF-8 输出
|
||||
PYTHONIOENCODING: 'utf-8',
|
||||
LANG: 'zh_CN.UTF-8',
|
||||
LC_ALL: 'zh_CN.UTF-8',
|
||||
} : {}),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -97,23 +150,41 @@ export class RunCommandTool implements IMetonaTool {
|
||||
}
|
||||
|
||||
// 硬阻止列表(绝对禁止执行)
|
||||
// v0.2.0: 扩展危险命令检测模式
|
||||
const hardBlocks = [
|
||||
// 文件系统破坏
|
||||
{ pattern: /\brm\b.*\//, reason: 'rm with absolute path is forbidden' },
|
||||
{ pattern: /\brm\s+-rf?\s+\/(?:[^|;&\s]*\s)*?(?:bin|boot|dev|etc|lib|proc|root|sbin|sys|usr|var)\b/i, reason: 'rm on system directories is forbidden' },
|
||||
{ pattern: /\b(sudo|su|doas)\b/, reason: 'Privilege escalation commands are forbidden' },
|
||||
// 系统控制
|
||||
{ pattern: /\b(shutdown|reboot|halt|poweroff)\b/, reason: 'System shutdown commands are forbidden' },
|
||||
{ pattern: /\b(killall|pkill)\s+-9\b/, reason: 'Force kill all processes is forbidden' },
|
||||
// 远程代码执行
|
||||
{ pattern: /curl.*\|\s*(ba)?sh/, reason: 'Remote code execution via pipe is forbidden' },
|
||||
{ pattern: /wget.*\|\s*(ba)?sh/, reason: 'Remote code execution via pipe is forbidden' },
|
||||
{ pattern: /\bcurl\s+.*\s*-o\s+\/etc\//i, reason: 'Writing to system directories via curl is forbidden' },
|
||||
// 设备文件
|
||||
{ pattern: /\bdd\b.*of=\/dev\//, reason: 'Writing to device files is forbidden' },
|
||||
// 磁盘格式化
|
||||
{ pattern: /\b(mkfs|fdisk)\b/, reason: 'Disk formatting commands are forbidden' },
|
||||
// 权限滥用
|
||||
{ pattern: /\bchmod\s+777\b/, reason: 'chmod 777 is forbidden' },
|
||||
{ pattern: /\bchown\s+-R\s+\S+\s+\/(?:\s|$)/i, reason: 'Recursive chown on root is forbidden' },
|
||||
// 环境变量窃取
|
||||
{ pattern: /\b(env|export|printenv)\s*\|.*\b(curl|wget|nc|ncat)\b/i, reason: 'Exfiltrating environment variables is forbidden' },
|
||||
// 反向 shell
|
||||
{ pattern: /\b(bash|sh|zsh)\s+-i\s+>\s*&\s*\/dev\/tcp\//i, reason: 'Reverse shell via /dev/tcp is forbidden' },
|
||||
{ pattern: /\bnc\s+.*\s+-e\s+(bash|sh)/i, reason: 'Reverse shell via netcat is forbidden' },
|
||||
// Windows 危险命令
|
||||
{ pattern: /\b(format|diskpart)\b/i, reason: 'Disk formatting commands are forbidden' },
|
||||
{ pattern: /\bshutdown\s*\//i, reason: 'System shutdown commands are forbidden' },
|
||||
{ pattern: /\breg\s+(add|delete|import|restore)/i, reason: 'Registry modification commands are forbidden' },
|
||||
{ pattern: /\b(taskkill|kill)\s*\//i, reason: 'Process termination with system flags is forbidden' },
|
||||
{ pattern: /\bpowershell\s+-enc\s+/i, reason: 'PowerShell encoded command execution is forbidden' },
|
||||
// 后台进程与管道炸弹
|
||||
{ pattern: /&\s*\(/, reason: 'Background subshell execution is forbidden' },
|
||||
{ pattern: /\|\s*&/, reason: 'Pipe to background process is forbidden' },
|
||||
{ pattern: /:\(\)\s*\{\s*:\|:&\s*\}\s*;:/, reason: 'Fork bomb is forbidden' },
|
||||
];
|
||||
|
||||
for (const block of hardBlocks) {
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* 文件差异对比工具(1 个)
|
||||
*
|
||||
* diff_viewer — 对比两个文件或两段文本的差异
|
||||
*
|
||||
* 生成 unified diff 格式输出,支持行级差异检测。
|
||||
* 用于 Agent 在编辑文件前后对比变化,或对比两个配置文件。
|
||||
*/
|
||||
|
||||
import { readFile } from 'fs/promises';
|
||||
import { resolve } from 'path';
|
||||
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
|
||||
import type { MetonaToolDef } from '../../../harness/types';
|
||||
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
|
||||
import { isProtectedWorkspaceFile, isPathWithinWorkspace } from './file-guard';
|
||||
|
||||
interface DiffLine {
|
||||
type: 'context' | 'added' | 'removed';
|
||||
oldLineNo: number | null;
|
||||
newLineNo: number | null;
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** 最长公共子序列(LCS)diff 算法 */
|
||||
function computeDiff(oldLines: string[], newLines: string[]): DiffLine[] {
|
||||
// 构建 LCS 表(限制内存:大文件截断到 5000 行)
|
||||
const maxLines = 5000;
|
||||
const oldSliced = oldLines.slice(0, maxLines);
|
||||
const newSliced = newLines.slice(0, maxLines);
|
||||
const sm = oldSliced.length;
|
||||
const sn = newSliced.length;
|
||||
|
||||
// D4.1: 使用 Uint32Array 一维数组替代 number[][],节省内存
|
||||
const lcs = new Uint32Array((sm + 1) * (sn + 1));
|
||||
const idx = (i: number, j: number): number => i * (sn + 1) + j;
|
||||
|
||||
for (let i = 1; i <= sm; i++) {
|
||||
for (let j = 1; j <= sn; j++) {
|
||||
if (oldSliced[i - 1] === newSliced[j - 1]) {
|
||||
lcs[idx(i, j)] = lcs[idx(i - 1, j - 1)] + 1;
|
||||
} else {
|
||||
lcs[idx(i, j)] = Math.max(lcs[idx(i - 1, j)], lcs[idx(i, j - 1)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 回溯生成 diff
|
||||
const result: DiffLine[] = [];
|
||||
let i = sm, j = sn;
|
||||
|
||||
while (i > 0 || j > 0) {
|
||||
if (i > 0 && j > 0 && oldSliced[i - 1] === newSliced[j - 1]) {
|
||||
result.unshift({ type: 'context', oldLineNo: i, newLineNo: j, content: oldSliced[i - 1] });
|
||||
i--; j--;
|
||||
} else if (j > 0 && (i === 0 || lcs[idx(i, j - 1)] >= lcs[idx(i - 1, j)])) {
|
||||
result.unshift({ type: 'added', oldLineNo: null, newLineNo: j, content: newSliced[j - 1] });
|
||||
j--;
|
||||
} else {
|
||||
result.unshift({ type: 'removed', oldLineNo: i, newLineNo: null, content: oldSliced[i - 1] });
|
||||
i--;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 生成 unified diff 格式字符串 */
|
||||
function formatUnifiedDiff(diffLines: DiffLine[], oldLabel: string, newLabel: string, contextLines: number = 3): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`--- ${oldLabel}`);
|
||||
lines.push(`+++ ${newLabel}`);
|
||||
|
||||
let oldLine = 1;
|
||||
let newLine = 1;
|
||||
let hunkLines: string[] = [];
|
||||
let hunkStartOld = 1;
|
||||
let hunkStartNew = 1;
|
||||
let inHunk = false;
|
||||
let contextSinceChange = 0;
|
||||
|
||||
const flushHunk = () => {
|
||||
if (hunkLines.length > 0) {
|
||||
// 移除尾部多余的 context 行
|
||||
const trimmed: string[] = [...hunkLines];
|
||||
while (trimmed.length > 0 && trimmed[trimmed.length - 1].startsWith(' ') && contextSinceChange > 0) {
|
||||
trimmed.pop();
|
||||
contextSinceChange--;
|
||||
}
|
||||
if (trimmed.length > 0) {
|
||||
const oldCount = trimmed.filter((l) => l.startsWith('-') || l.startsWith(' ')).length;
|
||||
const newCount = trimmed.filter((l) => l.startsWith('+') || l.startsWith(' ')).length;
|
||||
lines.push(`@@ -${hunkStartOld},${oldCount} +${hunkStartNew},${newCount} @@`);
|
||||
lines.push(...trimmed);
|
||||
}
|
||||
}
|
||||
hunkLines = [];
|
||||
inHunk = false;
|
||||
contextSinceChange = 0;
|
||||
};
|
||||
|
||||
for (const dl of diffLines) {
|
||||
if (dl.type === 'context') {
|
||||
if (inHunk) {
|
||||
hunkLines.push(` ${dl.content}`);
|
||||
contextSinceChange++;
|
||||
// 超过 contextLines 行连续 context,结束当前 hunk
|
||||
if (contextSinceChange > contextLines) {
|
||||
flushHunk();
|
||||
}
|
||||
}
|
||||
oldLine++;
|
||||
newLine++;
|
||||
} else if (dl.type === 'added') {
|
||||
if (!inHunk) {
|
||||
inHunk = true;
|
||||
hunkStartOld = oldLine;
|
||||
hunkStartNew = newLine;
|
||||
}
|
||||
hunkLines.push(`+${dl.content}`);
|
||||
contextSinceChange = 0;
|
||||
newLine++;
|
||||
} else if (dl.type === 'removed') {
|
||||
if (!inHunk) {
|
||||
inHunk = true;
|
||||
hunkStartOld = oldLine;
|
||||
hunkStartNew = newLine;
|
||||
}
|
||||
hunkLines.push(`-${dl.content}`);
|
||||
contextSinceChange = 0;
|
||||
oldLine++;
|
||||
}
|
||||
}
|
||||
flushHunk();
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export class DiffViewerTool implements IMetonaTool {
|
||||
readonly definition: MetonaToolDef = {
|
||||
name: 'diff_viewer',
|
||||
description: 'Compare two files or two text snippets and show differences. Generates unified diff format output. Useful for reviewing changes before applying or comparing configurations.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
mode: {
|
||||
type: 'string',
|
||||
description: 'Comparison mode',
|
||||
enum: ['files', 'text'],
|
||||
},
|
||||
file_a: { type: 'string', description: 'First file path (for files mode)' },
|
||||
file_b: { type: 'string', description: 'Second file path (for files mode)' },
|
||||
text_a: { type: 'string', description: 'First text content (for text mode)' },
|
||||
text_b: { type: 'string', description: 'Second text content (for text mode)' },
|
||||
context_lines: { type: 'number', description: 'Context lines around changes (default 3, max 10)' },
|
||||
},
|
||||
required: ['mode'],
|
||||
},
|
||||
category: MetonaToolCategory.FILESYSTEM,
|
||||
riskLevel: MetonaRiskLevel.SAFE,
|
||||
requiresPermission: false,
|
||||
timeoutMs: 15_000,
|
||||
};
|
||||
|
||||
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
||||
try {
|
||||
const mode = args.mode as 'files' | 'text';
|
||||
const contextLines = Math.min(10, Math.max(0, (args.context_lines as number) ?? 3));
|
||||
|
||||
// D4.5: 校验 mode
|
||||
if (mode !== 'files' && mode !== 'text') {
|
||||
return { success: false, error: `Invalid mode: ${mode}. Must be 'files' or 'text'` };
|
||||
}
|
||||
|
||||
let contentA: string;
|
||||
let contentB: string;
|
||||
let labelA: string;
|
||||
let labelB: string;
|
||||
|
||||
if (mode === 'files') {
|
||||
const fileA = args.file_a as string;
|
||||
const fileB = args.file_b as string;
|
||||
if (!fileA || !fileB) {
|
||||
return { success: false, error: 'file_a and file_b are required for files mode' };
|
||||
}
|
||||
|
||||
// 安全校验
|
||||
const pathA = resolve(context.workspacePath, fileA);
|
||||
const pathB = resolve(context.workspacePath, fileB);
|
||||
if (!isPathWithinWorkspace(fileA, context.workspacePath) || !isPathWithinWorkspace(fileB, context.workspacePath)) {
|
||||
return { success: false, error: 'Path traversal detected' };
|
||||
}
|
||||
if (isProtectedWorkspaceFile(fileA, context.workspacePath) || isProtectedWorkspaceFile(fileB, context.workspacePath)) {
|
||||
return { success: false, error: 'Access denied: MEMORY.md is protected' };
|
||||
}
|
||||
|
||||
try {
|
||||
contentA = await readFile(pathA, 'utf-8');
|
||||
contentB = await readFile(pathB, 'utf-8');
|
||||
} catch (err) {
|
||||
return { success: false, error: `Failed to read files: ${(err as Error).message}` };
|
||||
}
|
||||
labelA = fileA;
|
||||
labelB = fileB;
|
||||
} else {
|
||||
contentA = (args.text_a as string) ?? '';
|
||||
contentB = (args.text_b as string) ?? '';
|
||||
labelA = 'text_a';
|
||||
labelB = 'text_b';
|
||||
}
|
||||
|
||||
const linesA = contentA.split('\n');
|
||||
const linesB = contentB.split('\n');
|
||||
|
||||
const diff = computeDiff(linesA, linesB);
|
||||
const unifiedDiff = formatUnifiedDiff(diff, labelA, labelB, contextLines);
|
||||
|
||||
const addedCount = diff.filter((d) => d.type === 'added').length;
|
||||
const removedCount = diff.filter((d) => d.type === 'removed').length;
|
||||
const contextCount = diff.filter((d) => d.type === 'context').length;
|
||||
|
||||
// D4.3: similarity 计算使用截断后的长度作为分母
|
||||
const maxLines = 5000;
|
||||
const oldSlicedLen = Math.min(linesA.length, maxLines);
|
||||
const newSlicedLen = Math.min(linesB.length, maxLines);
|
||||
const oldTruncated = linesA.length > maxLines;
|
||||
const newTruncated = linesB.length > maxLines;
|
||||
const truncated = oldTruncated || newTruncated;
|
||||
|
||||
// 生成摘要
|
||||
const summary = {
|
||||
files_compared: mode === 'files' ? 2 : 0,
|
||||
lines_added: addedCount,
|
||||
lines_removed: removedCount,
|
||||
lines_unchanged: contextCount,
|
||||
total_changes: addedCount + removedCount,
|
||||
similarity: (oldSlicedLen + newSlicedLen) > 0
|
||||
? Math.round((contextCount * 2 / (oldSlicedLen + newSlicedLen)) * 100) / 100
|
||||
: 1,
|
||||
truncated,
|
||||
};
|
||||
|
||||
// D4.6: unifiedDiff 大小限制
|
||||
const MAX_DIFF_CHARS = 50_000;
|
||||
const truncatedDiff = unifiedDiff.length > MAX_DIFF_CHARS
|
||||
? unifiedDiff.slice(0, MAX_DIFF_CHARS) + '\n... (diff truncated)'
|
||||
: unifiedDiff;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
summary,
|
||||
diff: truncatedDiff,
|
||||
diff_lines: diff.slice(0, 500),
|
||||
};
|
||||
} catch (err) {
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* 文件编辑工具(1 个)
|
||||
*
|
||||
* file_editor — 精准文件编辑,支持行替换/插入/删除/正则替换
|
||||
*
|
||||
* 相比 write_file 的整文件覆盖,file_editor 支持精准定位修改,
|
||||
* 避免大文件全量重写,减少 token 消耗和出错风险。
|
||||
*
|
||||
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 9 个基础工具
|
||||
*/
|
||||
|
||||
import { readFile, writeFile, rename, mkdir } from 'fs/promises';
|
||||
import { dirname } from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
|
||||
import type { MetonaToolDef } from '../../../harness/types';
|
||||
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
|
||||
import { isProtectedWorkspaceFile, isPathWithinWorkspace } from './file-guard';
|
||||
import { resolve } from 'path';
|
||||
|
||||
/** 安全校验:路径遍历防护 + 受保护文件拦截 */
|
||||
function safeResolvePath(filePath: string, workspacePath: string): string {
|
||||
const resolved = resolve(workspacePath, filePath);
|
||||
if (!isPathWithinWorkspace(filePath, workspacePath)) {
|
||||
throw new Error(`Path traversal detected: ${filePath}`);
|
||||
}
|
||||
if (isProtectedWorkspaceFile(filePath, workspacePath)) {
|
||||
throw new Error('Access denied: MEMORY.md is managed by the memory system and cannot be accessed via file tools');
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export class FileEditorTool implements IMetonaTool {
|
||||
readonly definition: MetonaToolDef = {
|
||||
name: 'file_editor',
|
||||
description: 'Edit a file with precision. Supports operations: replace (replace lines), insert (insert at line), delete (delete lines), regex (regex replace in range). More efficient than write_file for targeted edits.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file_path: { type: 'string', description: 'Path to the file to edit' },
|
||||
operation: {
|
||||
type: 'string',
|
||||
description: 'Edit operation',
|
||||
enum: ['replace', 'insert', 'delete', 'regex'],
|
||||
},
|
||||
start_line: { type: 'number', description: 'Start line number (1-indexed). Used by replace/insert/delete/regex' },
|
||||
end_line: { type: 'number', description: 'End line number (inclusive). Used by replace/delete' },
|
||||
content: { type: 'string', description: 'New content for replace/insert operations' },
|
||||
pattern: { type: 'string', description: 'Regex pattern for regex operation' },
|
||||
replacement: { type: 'string', description: 'Replacement string for regex operation' },
|
||||
flags: { type: 'string', description: 'Regex flags (default "g")' },
|
||||
},
|
||||
required: ['file_path', 'operation'],
|
||||
},
|
||||
category: MetonaToolCategory.FILESYSTEM,
|
||||
riskLevel: MetonaRiskLevel.MEDIUM,
|
||||
requiresPermission: true,
|
||||
timeoutMs: 15_000,
|
||||
};
|
||||
|
||||
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
||||
try {
|
||||
// F1.5: file_path 空值校验
|
||||
if (!args.file_path || typeof args.file_path !== 'string') {
|
||||
return { success: false, error: 'file_path is required' };
|
||||
}
|
||||
const filePath = safeResolvePath(args.file_path as string, context.workspacePath);
|
||||
const operation = args.operation as 'replace' | 'insert' | 'delete' | 'regex';
|
||||
|
||||
// 文件必须存在(不支持创建新文件,请用 write_file)
|
||||
if (!existsSync(filePath)) {
|
||||
return { success: false, error: `File not found: ${args.file_path}. Use write_file to create new files.` };
|
||||
}
|
||||
|
||||
const originalContent = await readFile(filePath, 'utf-8');
|
||||
const lines = originalContent.split('\n');
|
||||
|
||||
let newLines: string[];
|
||||
let affectedRange: { start: number; end: number };
|
||||
|
||||
switch (operation) {
|
||||
case 'replace': {
|
||||
const startLine = Math.max(1, (args.start_line as number) ?? 1);
|
||||
const endLine = Math.min(lines.length, (args.end_line as number) ?? startLine);
|
||||
if (endLine < startLine) {
|
||||
return { success: false, error: `end_line (${endLine}) must be >= start_line (${startLine})` };
|
||||
}
|
||||
const content = (args.content as string) ?? '';
|
||||
const contentLines = content.split('\n');
|
||||
newLines = [
|
||||
...lines.slice(0, startLine - 1),
|
||||
...contentLines,
|
||||
...lines.slice(endLine),
|
||||
];
|
||||
affectedRange = { start: startLine, end: endLine };
|
||||
break;
|
||||
}
|
||||
|
||||
case 'insert': {
|
||||
let startLine = Math.max(1, (args.start_line as number) ?? 1);
|
||||
startLine = Math.min(startLine, lines.length + 1); // 允许追加到末尾
|
||||
const content = (args.content as string) ?? '';
|
||||
const contentLines = content.split('\n');
|
||||
newLines = [
|
||||
...lines.slice(0, startLine - 1),
|
||||
...contentLines,
|
||||
...lines.slice(startLine - 1),
|
||||
];
|
||||
affectedRange = { start: startLine, end: startLine + contentLines.length - 1 };
|
||||
break;
|
||||
}
|
||||
|
||||
case 'delete': {
|
||||
const startLine = Math.max(1, (args.start_line as number) ?? 1);
|
||||
const endLine = Math.min(lines.length, (args.end_line as number) ?? startLine);
|
||||
if (endLine < startLine) {
|
||||
return { success: false, error: `end_line (${endLine}) must be >= start_line (${startLine})` };
|
||||
}
|
||||
newLines = [
|
||||
...lines.slice(0, startLine - 1),
|
||||
...lines.slice(endLine),
|
||||
];
|
||||
affectedRange = { start: startLine, end: endLine };
|
||||
break;
|
||||
}
|
||||
|
||||
case 'regex': {
|
||||
const pattern = args.pattern as string;
|
||||
const replacement = (args.replacement as string) ?? '';
|
||||
const flags = (args.flags as string) ?? 'g';
|
||||
if (!pattern) {
|
||||
return { success: false, error: 'Regex operation requires "pattern" parameter' };
|
||||
}
|
||||
// F1.3: regex pattern 长度限制
|
||||
if (pattern.length > 500) {
|
||||
return { success: false, error: 'Regex pattern too long (max 500 chars)' };
|
||||
}
|
||||
const startLine = Math.max(1, (args.start_line as number) ?? 1);
|
||||
const endLine = Math.min(lines.length, (args.end_line as number) ?? lines.length);
|
||||
if (endLine < startLine) {
|
||||
return { success: false, error: `end_line (${endLine}) must be >= start_line (${startLine})` };
|
||||
}
|
||||
|
||||
let regex: RegExp;
|
||||
try {
|
||||
regex = new RegExp(pattern, flags);
|
||||
} catch (err) {
|
||||
return { success: false, error: `Invalid regex: ${(err as Error).message}` };
|
||||
}
|
||||
|
||||
// F1.4: 用带 g 标志的正则统计匹配数
|
||||
let replaceCount = 0;
|
||||
const targetLines = lines.slice(startLine - 1, endLine);
|
||||
const countRegex = new RegExp(pattern, flags.includes('g') ? flags : flags + 'g');
|
||||
const replacedLines = targetLines.map((line) => {
|
||||
const matches = line.match(countRegex);
|
||||
if (matches) replaceCount += matches.length;
|
||||
return line.replace(regex, replacement);
|
||||
});
|
||||
|
||||
newLines = [
|
||||
...lines.slice(0, startLine - 1),
|
||||
...replacedLines,
|
||||
...lines.slice(endLine),
|
||||
];
|
||||
affectedRange = { start: startLine, end: endLine };
|
||||
|
||||
// 如果没有替换,返回提示
|
||||
if (replaceCount === 0) {
|
||||
return {
|
||||
success: true,
|
||||
operation,
|
||||
file_path: args.file_path,
|
||||
message: 'No matches found for regex pattern',
|
||||
replacements: 0,
|
||||
affected_range: affectedRange,
|
||||
};
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
return { success: false, error: `Unknown operation: ${operation}` };
|
||||
}
|
||||
|
||||
// 自动创建父目录 (F1.8: 异步 mkdir)
|
||||
const parentDir = dirname(filePath);
|
||||
if (!existsSync(parentDir)) {
|
||||
await mkdir(parentDir, { recursive: true });
|
||||
}
|
||||
|
||||
const newContent = newLines.join('\n');
|
||||
// F1.7: 原子写入 - 临时文件 + rename
|
||||
const tmpPath = `${filePath}.tmp_${Date.now()}`;
|
||||
await writeFile(tmpPath, newContent, 'utf-8');
|
||||
await rename(tmpPath, filePath);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
operation,
|
||||
file_path: args.file_path,
|
||||
affected_range: affectedRange,
|
||||
lines_before: lines.length,
|
||||
lines_after: newLines.length,
|
||||
bytes_written: Buffer.byteLength(newContent, 'utf-8'),
|
||||
};
|
||||
} catch (err) {
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,15 +60,24 @@ export function isPathWithinWorkspace(
|
||||
* 检查命令字符串是否尝试访问工作空间根目录的受保护文件
|
||||
*
|
||||
* 用于 run_command 工具的命令校验。
|
||||
* 采用大小写不敏感匹配,覆盖 cat/type/Get-Content 等常见读取命令。
|
||||
* 仅匹配直接引用的 MEMORY.md(前面是命令起始/空白/引号/分号/管道),
|
||||
* 不拦截子目录路径中的同名文件(如 subdir/MEMORY.md 或 subdir\MEMORY.md)。
|
||||
*
|
||||
* 注意:run_command 的工作目录固定为 workspacePath,因此裸引用 MEMORY.md
|
||||
* 等价于工作空间根目录的 MEMORY.md。
|
||||
*
|
||||
* @param command Shell 命令字符串
|
||||
* @returns true 如果命令包含受保护文件名
|
||||
* @returns true 如果命令直接引用了受保护文件名
|
||||
*/
|
||||
export function commandTouchesProtectedFile(command: string): boolean {
|
||||
const lowerCmd = command.toLowerCase();
|
||||
for (const protectedName of PROTECTED_FILES) {
|
||||
if (lowerCmd.includes(protectedName.toLowerCase())) {
|
||||
const lowerName = protectedName.toLowerCase();
|
||||
// 前面是起始/空白/引号/分号/管道/&/>;后面是结束/空白/引号/分号/管道/&/</>
|
||||
// 这样 subdir/MEMORY.md 和 subdir\MEMORY.md 不会被匹配(前面是 / 或 \)
|
||||
const escaped = lowerName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const regex = new RegExp(`(?:^|[\\s"'|;&>])${escaped}(?:$|[\\s"'|;&<])`, 'i');
|
||||
if (regex.test(lowerCmd)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,36 @@
|
||||
/**
|
||||
* 内置工具导出
|
||||
*
|
||||
* v0.2.0: 从 11 个工具扩展到 15 个工具
|
||||
* 新增:file_editor, code_search, task_manager, diff_viewer
|
||||
*
|
||||
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 9 个基础工具
|
||||
*/
|
||||
|
||||
// 文件系统工具(4 个)
|
||||
export { ReadFileTool, WriteFileTool, ListDirectoryTool, SearchFilesTool } from './filesystem';
|
||||
// v0.2.0: 精准文件编辑工具
|
||||
export { FileEditorTool } from './file-editor';
|
||||
// v0.2.0: ripgrep 代码搜索工具
|
||||
export { CodeSearchTool } from './code-search';
|
||||
// v0.2.0: 文件差异对比工具
|
||||
export { DiffViewerTool } from './diff-viewer';
|
||||
|
||||
// 网络工具(2 个)
|
||||
export { WebSearchTool, WebFetchTool } from './network';
|
||||
|
||||
// 记忆工具(2 个)
|
||||
export { MemoryStoreTool, MemorySearchTool } from './memory';
|
||||
|
||||
// 命令工具(1 个)
|
||||
export { RunCommandTool } from './command';
|
||||
|
||||
// 浏览器工具(1 个)
|
||||
export { WebBrowserTool, cleanupBrowser, getBrowserManager } from './browser';
|
||||
|
||||
// 委派工具(1 个)
|
||||
export { DelegateTaskTool } from './delegate-task';
|
||||
|
||||
// v0.2.0: 任务管理工具(1 个)
|
||||
export { TaskManagerTool } from './task-manager';
|
||||
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* 任务管理工具(1 个)
|
||||
*
|
||||
* task_manager — 创建/更新/列出/完成任务
|
||||
*
|
||||
* 支持 Agent 在执行复杂任务时将任务分解为子任务并跟踪状态。
|
||||
* 任务持久化到 SQLite tasks 表。
|
||||
*
|
||||
* @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html
|
||||
*/
|
||||
|
||||
import { nanoid } from 'nanoid';
|
||||
import type Database from 'better-sqlite3';
|
||||
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
|
||||
import type { MetonaToolDef } from '../../../harness/types';
|
||||
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
|
||||
|
||||
export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'blocked' | 'cancelled';
|
||||
export type TaskPriority = 'low' | 'medium' | 'high' | 'critical';
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
status: TaskStatus;
|
||||
priority: TaskPriority;
|
||||
parentId: string | null;
|
||||
assignedTo: string | null;
|
||||
order: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
completedAt: number | null;
|
||||
}
|
||||
|
||||
/** 从数据库行映射到 Task 对象 */
|
||||
function mapRow(row: Record<string, unknown>): Task {
|
||||
return {
|
||||
id: row.id as string,
|
||||
sessionId: row.session_id as string,
|
||||
title: row.title as string,
|
||||
description: row.description as string,
|
||||
status: row.status as TaskStatus,
|
||||
priority: row.priority as TaskPriority,
|
||||
parentId: (row.parent_id as string) ?? null,
|
||||
assignedTo: (row.assigned_to as string) ?? null,
|
||||
order: row.order_idx as number,
|
||||
createdAt: row.created_at as number,
|
||||
updatedAt: row.updated_at as number,
|
||||
completedAt: (row.completed_at as number) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// T3.5: 合法枚举值
|
||||
const VALID_STATUSES: TaskStatus[] = ['pending', 'in_progress', 'completed', 'blocked', 'cancelled'];
|
||||
const VALID_PRIORITIES: TaskPriority[] = ['low', 'medium', 'high', 'critical'];
|
||||
|
||||
export class TaskManagerTool implements IMetonaTool {
|
||||
readonly definition: MetonaToolDef = {
|
||||
name: 'task_manager',
|
||||
description: 'Manage tasks for complex multi-step work. Supports operations: create, update, list, complete, delete. Tasks are persisted per session and can have parent-child relationships.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
operation: {
|
||||
type: 'string',
|
||||
description: 'Task operation',
|
||||
enum: ['create', 'update', 'list', 'complete', 'delete', 'get'],
|
||||
},
|
||||
title: { type: 'string', description: 'Task title (for create)' },
|
||||
description: { type: 'string', description: 'Task description (for create/update)' },
|
||||
task_id: { type: 'string', description: 'Task ID (for update/complete/delete/get)' },
|
||||
status: { type: 'string', description: 'New status (for update)', enum: ['pending', 'in_progress', 'completed', 'blocked', 'cancelled'] },
|
||||
priority: { type: 'string', description: 'Priority (for create/update)', enum: ['low', 'medium', 'high', 'critical'] },
|
||||
parent_id: { type: 'string', description: 'Parent task ID (for create, establishes subtask relationship)' },
|
||||
},
|
||||
required: ['operation'],
|
||||
},
|
||||
category: MetonaToolCategory.DATABASE,
|
||||
riskLevel: MetonaRiskLevel.LOW,
|
||||
requiresPermission: false,
|
||||
timeoutMs: 10_000,
|
||||
};
|
||||
|
||||
constructor(private getDB: () => Database.Database) {}
|
||||
|
||||
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
||||
try {
|
||||
const operation = args.operation as string;
|
||||
|
||||
switch (operation) {
|
||||
case 'create':
|
||||
return this.createTask(args, context);
|
||||
case 'update':
|
||||
return this.updateTask(args, context);
|
||||
case 'list':
|
||||
return this.listTasks(args, context);
|
||||
case 'complete':
|
||||
return this.completeTask(args, context);
|
||||
case 'delete':
|
||||
return this.deleteTask(args, context);
|
||||
case 'get':
|
||||
return this.getTask(args, context);
|
||||
default:
|
||||
return { success: false, error: `Unknown operation: ${operation}` };
|
||||
}
|
||||
} catch (err) {
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
private createTask(args: Record<string, unknown>, context: ToolExecutionContext): unknown {
|
||||
const db = this.getDB();
|
||||
const id = `task_${nanoid(12)}`;
|
||||
const now = Date.now();
|
||||
const sessionId = context.sessionId;
|
||||
const title = args.title as string;
|
||||
const description = (args.description as string) ?? '';
|
||||
const priority = (args.priority as TaskPriority) ?? 'medium';
|
||||
const parentId = (args.parent_id as string) ?? null;
|
||||
|
||||
if (!title) {
|
||||
return { success: false, error: 'title is required for create operation' };
|
||||
}
|
||||
|
||||
// T3.5: 校验 priority 枚举值
|
||||
if (!VALID_PRIORITIES.includes(priority)) {
|
||||
return { success: false, error: `Invalid priority: ${priority}. Must be one of: ${VALID_PRIORITIES.join(', ')}` };
|
||||
}
|
||||
|
||||
// 获取同 session 同 parent 下的最大 order
|
||||
const maxOrderRow = db.prepare(
|
||||
'SELECT MAX(order_idx) as max_order FROM tasks WHERE session_id = ? AND parent_id IS ?',
|
||||
).get(sessionId, parentId) as { max_order: number | null } | undefined;
|
||||
const order = (maxOrderRow?.max_order ?? -1) + 1;
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO tasks (id, session_id, title, description, status, priority, parent_id, assigned_to, order_idx, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, sessionId, title, description, 'pending', priority, parentId, null, order, now, now);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
task: {
|
||||
id, sessionId, title, description,
|
||||
status: 'pending', priority, parentId,
|
||||
assignedTo: null, order,
|
||||
createdAt: now, updatedAt: now, completedAt: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private updateTask(args: Record<string, unknown>, context: ToolExecutionContext): unknown {
|
||||
const db = this.getDB();
|
||||
const taskId = args.task_id as string;
|
||||
if (!taskId) {
|
||||
return { success: false, error: 'task_id is required for update operation' };
|
||||
}
|
||||
|
||||
// T3.1: 校验任务属于当前会话
|
||||
const existing = db.prepare('SELECT * FROM tasks WHERE id = ? AND session_id = ?').get(taskId, context.sessionId);
|
||||
if (!existing) {
|
||||
return { success: false, error: `Task not found: ${taskId}` };
|
||||
}
|
||||
|
||||
// T3.5: 校验枚举值
|
||||
if (args.status !== undefined && !VALID_STATUSES.includes(args.status as TaskStatus)) {
|
||||
return { success: false, error: `Invalid status: ${args.status}. Must be one of: ${VALID_STATUSES.join(', ')}` };
|
||||
}
|
||||
if (args.priority !== undefined && !VALID_PRIORITIES.includes(args.priority as TaskPriority)) {
|
||||
return { success: false, error: `Invalid priority: ${args.priority}. Must be one of: ${VALID_PRIORITIES.join(', ')}` };
|
||||
}
|
||||
|
||||
const updates: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
|
||||
if (args.title !== undefined) {
|
||||
updates.push('title = ?');
|
||||
values.push(args.title);
|
||||
}
|
||||
if (args.description !== undefined) {
|
||||
updates.push('description = ?');
|
||||
values.push(args.description);
|
||||
}
|
||||
if (args.status !== undefined) {
|
||||
updates.push('status = ?');
|
||||
values.push(args.status);
|
||||
if (args.status === 'completed') {
|
||||
updates.push('completed_at = ?');
|
||||
values.push(Date.now());
|
||||
}
|
||||
}
|
||||
if (args.priority !== undefined) {
|
||||
updates.push('priority = ?');
|
||||
values.push(args.priority);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return { success: false, error: 'No fields to update' };
|
||||
}
|
||||
|
||||
updates.push('updated_at = ?');
|
||||
values.push(Date.now());
|
||||
values.push(taskId);
|
||||
values.push(context.sessionId);
|
||||
|
||||
// T3.1: UPDATE 语句加 AND session_id = ? 防止越权
|
||||
db.prepare(`UPDATE tasks SET ${updates.join(', ')} WHERE id = ? AND session_id = ?`).run(...values);
|
||||
|
||||
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) };
|
||||
}
|
||||
|
||||
private listTasks(args: Record<string, unknown>, context: ToolExecutionContext): unknown {
|
||||
const db = this.getDB();
|
||||
const sessionId = context.sessionId;
|
||||
const status = args.status as string | undefined;
|
||||
|
||||
let sql = 'SELECT * FROM tasks WHERE session_id = ?';
|
||||
const values: unknown[] = [sessionId];
|
||||
|
||||
if (status) {
|
||||
sql += ' AND status = ?';
|
||||
values.push(status);
|
||||
}
|
||||
|
||||
sql += ' ORDER BY order_idx ASC, created_at ASC';
|
||||
|
||||
const rows = db.prepare(sql).all(...values) as Array<Record<string, unknown>>;
|
||||
const tasks = rows.map(mapRow);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
tasks,
|
||||
count: tasks.length,
|
||||
by_status: {
|
||||
pending: tasks.filter((t) => t.status === 'pending').length,
|
||||
in_progress: tasks.filter((t) => t.status === 'in_progress').length,
|
||||
completed: tasks.filter((t) => t.status === 'completed').length,
|
||||
blocked: tasks.filter((t) => t.status === 'blocked').length,
|
||||
cancelled: tasks.filter((t) => t.status === 'cancelled').length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private completeTask(args: Record<string, unknown>, context: ToolExecutionContext): unknown {
|
||||
const db = this.getDB();
|
||||
const taskId = args.task_id as string;
|
||||
if (!taskId) {
|
||||
return { success: false, error: 'task_id is required for complete operation' };
|
||||
}
|
||||
|
||||
// T3.1: 校验任务属于当前会话
|
||||
const existing = db.prepare('SELECT id FROM tasks WHERE id = ? AND session_id = ?').get(taskId, context.sessionId);
|
||||
if (!existing) {
|
||||
return { success: false, error: `Task not found: ${taskId}` };
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
// T3.1: UPDATE 语句加 AND session_id = ? 防止越权
|
||||
const result = db.prepare(
|
||||
'UPDATE tasks SET status = ?, completed_at = ?, updated_at = ? WHERE id = ? AND session_id = ?',
|
||||
).run('completed', now, now, taskId, context.sessionId);
|
||||
|
||||
if (result.changes === 0) {
|
||||
return { success: false, error: `Task not found: ${taskId}` };
|
||||
}
|
||||
|
||||
return { success: true, task_id: taskId, completed_at: now };
|
||||
}
|
||||
|
||||
private deleteTask(args: Record<string, unknown>, context: ToolExecutionContext): unknown {
|
||||
const db = this.getDB();
|
||||
const taskId = args.task_id as string;
|
||||
if (!taskId) {
|
||||
return { success: false, error: 'task_id is required for delete operation' };
|
||||
}
|
||||
|
||||
// T3.1: 校验任务属于当前会话
|
||||
const existing = db.prepare('SELECT id FROM tasks WHERE id = ? AND session_id = ?').get(taskId, context.sessionId);
|
||||
if (!existing) {
|
||||
return { success: false, error: `Task not found: ${taskId}` };
|
||||
}
|
||||
|
||||
// T3.2/T3.3: 递归 CTE 查出所有后代任务 ID
|
||||
const descendantIds = db.prepare(`
|
||||
WITH RECURSIVE descendants AS (
|
||||
SELECT id FROM tasks WHERE id = ?
|
||||
UNION ALL
|
||||
SELECT t.id FROM tasks t JOIN descendants d ON t.parent_id = d.id
|
||||
)
|
||||
SELECT id FROM descendants
|
||||
`).all(taskId) as Array<{ id: string }>;
|
||||
|
||||
const allIds = descendantIds.map((r) => r.id);
|
||||
|
||||
// 事务包裹批量删除
|
||||
const deleteMany = db.transaction(() => {
|
||||
const placeholders = allIds.map(() => '?').join(',');
|
||||
db.prepare(`DELETE FROM tasks WHERE id IN (${placeholders})`).run(...allIds);
|
||||
});
|
||||
deleteMany();
|
||||
|
||||
return { success: true, task_id: taskId, deleted: allIds.length };
|
||||
}
|
||||
|
||||
private getTask(args: Record<string, unknown>, context: ToolExecutionContext): unknown {
|
||||
const db = this.getDB();
|
||||
const taskId = args.task_id as string;
|
||||
if (!taskId) {
|
||||
return { success: false, error: 'task_id is required for get operation' };
|
||||
}
|
||||
|
||||
// T3.1: 校验任务属于当前会话
|
||||
const row = db.prepare('SELECT * FROM tasks WHERE id = ? AND session_id = ?').get(taskId, context.sessionId) as Record<string, unknown> | undefined;
|
||||
if (!row) {
|
||||
return { success: false, error: `Task not found: ${taskId}` };
|
||||
}
|
||||
|
||||
// 获取子任务(同样限制在当前会话内)
|
||||
const subtasks = db.prepare('SELECT * FROM tasks WHERE parent_id = ? AND session_id = ? ORDER BY order_idx ASC').all(taskId, context.sessionId) as Array<Record<string, unknown>>;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
task: mapRow(row),
|
||||
subtasks: subtasks.map(mapRow),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user