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