流式渲染修复: - 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
259 lines
8.8 KiB
TypeScript
259 lines
8.8 KiB
TypeScript
/**
|
||
* 文件差异对比工具(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 };
|
||
}
|
||
}
|
||
}
|