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:
thzxx
2026-07-12 12:54:52 +08:00
parent 469f53b623
commit 3c5aea8fb7
41 changed files with 4972 additions and 423 deletions
@@ -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;
}
/** 最长公共子序列(LCSdiff 算法 */
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 };
}
}
}