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,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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user