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:
@@ -65,6 +65,12 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
private currentSessionId: string = '';
|
||||
private currentRequestId: string = '';
|
||||
private workspacePath: string = '';
|
||||
private abortController: AbortController | null = null;
|
||||
private eventSeq = 0;
|
||||
/** 当前 run 的唯一标识(用于前端过滤旧流事件) */
|
||||
private runId: string = '';
|
||||
/** 正在进行的 run Promise(用于 run lock,防止并发 run 污染状态) */
|
||||
private currentRunPromise: Promise<AgentLoopOutput> | null = null;
|
||||
|
||||
constructor(
|
||||
config: Partial<AgentLoopConfig> = {},
|
||||
@@ -127,13 +133,34 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
sessionId: string,
|
||||
history: MetonaMessage[],
|
||||
systemPrompt: MetonaSystemPrompt,
|
||||
): Promise<AgentLoopOutput> {
|
||||
// C-4/H-6: 等待上一次 run 完全结束,防止并发 run 污染状态和旧 DONE 中断新流
|
||||
if (this.currentRunPromise) {
|
||||
await this.currentRunPromise.catch(() => {});
|
||||
}
|
||||
this.currentRunPromise = this.executeRunStream(userMessage, sessionId, history, systemPrompt);
|
||||
try {
|
||||
return await this.currentRunPromise;
|
||||
} finally {
|
||||
this.currentRunPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async executeRunStream(
|
||||
userMessage: MetonaMessage,
|
||||
sessionId: string,
|
||||
history: MetonaMessage[],
|
||||
systemPrompt: MetonaSystemPrompt,
|
||||
): Promise<AgentLoopOutput> {
|
||||
this.startTime = Date.now();
|
||||
this.aborted = false;
|
||||
this.runId = `run_${nanoid(8)}`;
|
||||
this.iterations = [];
|
||||
this.currentIteration = 0;
|
||||
this.currentSessionId = sessionId;
|
||||
this.totalTokens = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
|
||||
this.abortController = new AbortController();
|
||||
this.eventSeq = 0;
|
||||
|
||||
try {
|
||||
await this.transitionTo(AgentLoopState.INIT);
|
||||
@@ -220,7 +247,11 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
return this.finish(TerminationReason.MAX_ITERATIONS);
|
||||
return this.finish(TerminationReason.TIMEOUT);
|
||||
} catch (error) {
|
||||
this.emit('error', { error: (error as Error).message });
|
||||
const errMsg = (error as Error).message;
|
||||
if (this.aborted || errMsg.includes('Aborted')) {
|
||||
return this.finish(TerminationReason.USER_INTERRUPT);
|
||||
}
|
||||
this.emit('error', { error: errMsg });
|
||||
return this.finish(TerminationReason.ERROR, undefined, error as Error);
|
||||
}
|
||||
}
|
||||
@@ -238,9 +269,19 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
/** 中断循环 */
|
||||
abort(): void {
|
||||
this.aborted = true;
|
||||
this.abortController?.abort();
|
||||
this.abortController = null;
|
||||
this.emit('aborted');
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁引擎,清理所有监听器
|
||||
*/
|
||||
destroy(): void {
|
||||
this.abort();
|
||||
this.removeAllListeners();
|
||||
}
|
||||
|
||||
/** 获取当前状态 */
|
||||
getState(): AgentLoopState {
|
||||
return this.currentState;
|
||||
@@ -279,8 +320,18 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
// 过滤掉每轮的 DONE 事件 — 只在全部迭代完成后发送一个最终 DONE
|
||||
if (event.type === MetonaStreamEventType.DONE) continue;
|
||||
|
||||
// 转发流式事件到渲染进程
|
||||
this.emit('streamEvent', event);
|
||||
// 过滤掉 RETRY 类型的 ERROR 事件 — 不转发到前端,避免触发虚假错误 UI
|
||||
// RETRY 事件仅用于 Engine 内部清空缓冲区(见下方 switch 分支)
|
||||
if (event.type === MetonaStreamEventType.ERROR && (event.error?.code as string) === 'RETRY') {
|
||||
// 内部处理:清空已累积的内容和缓冲区(重试会从头开始接收)
|
||||
fullContent = '';
|
||||
reasoningContent = '';
|
||||
toolCallsBuffer.clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
// 转发流式事件到渲染进程(注入 runId 供前端过滤旧流)
|
||||
this.emit('streamEvent', { ...event, runId: this.runId });
|
||||
|
||||
switch (event.type) {
|
||||
case MetonaStreamEventType.TEXT_DELTA:
|
||||
@@ -322,6 +373,7 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
break;
|
||||
|
||||
case MetonaStreamEventType.ERROR:
|
||||
// RETRY 已在循环入口过滤,此处只处理真正的错误
|
||||
if (event.error) {
|
||||
throw new Error(event.error.message);
|
||||
}
|
||||
@@ -329,6 +381,9 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
// === v0.2.0: PARSING 状态 — 解析流式缓冲区中的工具调用 ===
|
||||
await this.transitionTo(AgentLoopState.PARSING);
|
||||
|
||||
// 处理缓冲区中的工具调用(TOOL_CALL_DELTA 拼接)
|
||||
if (toolCallsBuffer.size > 0 && (!step.toolCalls || step.toolCalls.length === 0)) {
|
||||
step.toolCalls = [];
|
||||
@@ -376,29 +431,89 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
const executeAndForward = async (tc: MetonaToolCall): Promise<MetonaToolResult> => {
|
||||
const result = await this.executeToolSafely(tc);
|
||||
|
||||
// 立即转发工具结果到渲染进程(不等其他工具完成)
|
||||
// H-5: abort 后不转发工具结果(避免 DONE 后到达的残留事件污染新流)
|
||||
if (!this.aborted) {
|
||||
// 立即转发工具结果到渲染进程(不等其他工具完成)
|
||||
this.emit('streamEvent', {
|
||||
type: MetonaStreamEventType.TOOL_RESULT,
|
||||
requestId: request.meta.requestId,
|
||||
sessionId,
|
||||
iteration: this.currentIteration,
|
||||
seq: this.nextSeq(),
|
||||
timestamp: Date.now(),
|
||||
toolResult: result,
|
||||
runId: this.runId,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// H-5: abort 时提前退出工具执行等待,不再阻塞至所有工具完成
|
||||
const toolsPromise = Promise.allSettled(
|
||||
step.toolCalls.map((tc) => executeAndForward(tc)),
|
||||
);
|
||||
const controller = this.abortController;
|
||||
const abortPromise = new Promise<null>((resolve) => {
|
||||
if (this.aborted) return resolve(null);
|
||||
if (controller) {
|
||||
controller.signal.addEventListener('abort', () => resolve(null), { once: true });
|
||||
}
|
||||
});
|
||||
const raceResult = await Promise.race([toolsPromise, abortPromise]) as
|
||||
| PromiseSettledResult<MetonaToolResult>[]
|
||||
| null;
|
||||
|
||||
if (raceResult === null) {
|
||||
// 被 abort 中断,标记步骤并退出
|
||||
step.completedAt = Date.now();
|
||||
step.state = AgentLoopState.TERMINATED;
|
||||
return step;
|
||||
}
|
||||
|
||||
const settledResults = raceResult;
|
||||
step.toolResults = settledResults.map((result, idx) => {
|
||||
const tc = step.toolCalls![idx];
|
||||
if (result.status === 'fulfilled') return result.value;
|
||||
// rejected:构造失败 result
|
||||
const errorResult = {
|
||||
toolCallId: tc.id,
|
||||
toolName: tc.name,
|
||||
result: null,
|
||||
success: false,
|
||||
error: `Tool execution failed: ${(result.reason as Error)?.message ?? String(result.reason)}`,
|
||||
durationMs: 0,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
// 转发错误结果到 UI
|
||||
this.emit('streamEvent', {
|
||||
type: MetonaStreamEventType.TOOL_RESULT,
|
||||
requestId: request.meta.requestId,
|
||||
sessionId,
|
||||
iteration: this.currentIteration,
|
||||
seq: 0,
|
||||
seq: this.nextSeq(),
|
||||
timestamp: Date.now(),
|
||||
toolResult: result,
|
||||
toolResult: errorResult,
|
||||
runId: this.runId,
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// Promise.all 保持结果顺序与 toolCalls 一致
|
||||
step.toolResults = await Promise.all(
|
||||
step.toolCalls.map((tc) => executeAndForward(tc)),
|
||||
);
|
||||
return errorResult;
|
||||
});
|
||||
}
|
||||
|
||||
// === OBSERVING ===
|
||||
await this.transitionTo(AgentLoopState.OBSERVING);
|
||||
|
||||
// === v0.2.0: REFLECTING 状态 — 观察工具结果,决定是否继续 ===
|
||||
// 如果有工具调用且需要后续推理,进入 REFLECTING 状态
|
||||
if (this.config.enableReflection && step.toolCalls && step.toolCalls.length > 0) {
|
||||
await this.transitionTo(AgentLoopState.REFLECTING);
|
||||
// 检查工具执行是否有错误,如果有严重错误可以提前终止
|
||||
const hasErrors = step.toolResults?.some((r) => !r.success);
|
||||
if (hasErrors) {
|
||||
log.warn(`[AgentLoop] Iteration ${this.currentIteration} had tool errors`);
|
||||
}
|
||||
}
|
||||
|
||||
// === 上下文压缩(基于 token 使用率触发) ===
|
||||
// 有效上下文窗口:Ollama 使用 contextLength (numCtx),其他 Provider 使用 contextWindow
|
||||
const effectiveContextWindow = this.config.contextLength ?? this.config.contextWindow;
|
||||
@@ -416,6 +531,8 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
compressedTokens: this.estimateMessagesTokens(compressed),
|
||||
});
|
||||
}
|
||||
// 压缩后回到 OBSERVING
|
||||
await this.transitionTo(AgentLoopState.OBSERVING);
|
||||
}
|
||||
|
||||
step.completedAt = Date.now();
|
||||
@@ -455,13 +572,37 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
// 执行工具
|
||||
const toolResult = await this.toolRegistry.execute(toolCall, {
|
||||
sessionId: this.currentSessionId ?? '',
|
||||
workspacePath: this.workspacePath,
|
||||
iteration: this.currentIteration,
|
||||
requestId: this.currentRequestId,
|
||||
});
|
||||
// 执行工具(带超时)
|
||||
const toolTimeout = 120_000; // 单个工具最大 2 分钟
|
||||
let toolResult: MetonaToolResult;
|
||||
try {
|
||||
toolResult = await Promise.race([
|
||||
this.toolRegistry.execute(toolCall, {
|
||||
sessionId: this.currentSessionId ?? '',
|
||||
workspacePath: this.workspacePath,
|
||||
iteration: this.currentIteration,
|
||||
requestId: this.currentRequestId,
|
||||
}),
|
||||
new Promise<MetonaToolResult>((_, reject) =>
|
||||
setTimeout(() => reject(new Error(`Tool '${toolCall.name}' timed out after ${toolTimeout}ms`)), toolTimeout),
|
||||
),
|
||||
]);
|
||||
} catch (err) {
|
||||
toolResult = {
|
||||
toolCallId: toolCall.id,
|
||||
toolName: toolCall.name,
|
||||
result: null,
|
||||
success: false,
|
||||
error: (err as Error).message,
|
||||
durationMs: 0,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
// 仍执行 post-hook
|
||||
for (const hook of this.postToolHooks) {
|
||||
await hook.afterExecute(toolCall, toolResult, this.currentSessionId);
|
||||
}
|
||||
return toolResult;
|
||||
}
|
||||
|
||||
// 后置 Hook 管道
|
||||
for (const hook of this.postToolHooks) {
|
||||
@@ -472,26 +613,94 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* 带重试的流式调用
|
||||
* 带重试的流式调用(v0.2.0: 指数退避)
|
||||
*
|
||||
* 如果 adapter 抛出错误,在 retryCount 次数内重试,每次等待 1 秒。
|
||||
* 如果 adapter 抛出错误,在 retryCount 次数内重试。
|
||||
* v0.2.0: 使用指数退避替代固定 1 秒等待
|
||||
* 等待时间 = baseDelay * 2^attempt(1s, 2s, 4s, 8s...)
|
||||
* 上限 30 秒,加上 ±20% 随机抖动(jitter)避免惊群效应
|
||||
*/
|
||||
private async *chatStreamWithRetry(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
|
||||
let lastError: unknown;
|
||||
const baseDelayMs = 1_000;
|
||||
const maxDelayMs = 30_000;
|
||||
|
||||
for (let attempt = 0; attempt <= this.config.retryCount; attempt++) {
|
||||
try {
|
||||
// 首次尝试直接 yield
|
||||
if (attempt === 0) {
|
||||
yield* this.adapter.chatStream(request);
|
||||
return;
|
||||
}
|
||||
// 重试时:先发送一个 retry 事件,让 UI 清空已接收的 delta
|
||||
yield {
|
||||
type: MetonaStreamEventType.ERROR,
|
||||
requestId: request.meta.requestId,
|
||||
sessionId: request.meta.sessionId,
|
||||
iteration: request.meta.iteration,
|
||||
seq: 0,
|
||||
timestamp: Date.now(),
|
||||
error: {
|
||||
code: 'RETRY' as never,
|
||||
message: `Retrying after error (attempt ${attempt + 1}/${this.config.retryCount + 1})`,
|
||||
retryable: true,
|
||||
},
|
||||
} as MetonaStreamEvent;
|
||||
yield* this.adapter.chatStream(request);
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (this.aborted) throw error;
|
||||
if (attempt < this.config.retryCount) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
// 检查是否为可重试错误
|
||||
if (!this.isRetryableError(error)) throw error;
|
||||
// 指数退避 + 抖动
|
||||
const delay = Math.min(maxDelayMs, baseDelayMs * Math.pow(2, attempt));
|
||||
const jitter = delay * 0.2 * (Math.random() * 2 - 1); // ±20% jitter
|
||||
const waitMs = Math.max(500, delay + jitter);
|
||||
log.warn(`[AgentLoop] Retry ${attempt + 1}/${this.config.retryCount} after ${Math.round(waitMs)}ms: ${(error as Error).message}`);
|
||||
await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(resolve, waitMs);
|
||||
// 支持 abort 中断等待
|
||||
const signal = this.abortController?.signal;
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
clearTimeout(timer);
|
||||
reject(new Error('Aborted'));
|
||||
return;
|
||||
}
|
||||
signal.addEventListener('abort', () => {
|
||||
clearTimeout(timer);
|
||||
reject(new Error('Aborted'));
|
||||
}, { once: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
/** 判断错误是否可重试 */
|
||||
private isRetryableError(error: unknown): boolean {
|
||||
const err = error as { status?: number; code?: string; message?: string };
|
||||
// 429 Too Many Requests — 可重试
|
||||
if (err.status === 429) return true;
|
||||
// 5xx 服务器错误 — 可重试
|
||||
if (err.status && err.status >= 500 && err.status < 600) return true;
|
||||
// 网络超时/连接错误 — 可重试
|
||||
if (err.code === 'ECONNRESET' || err.code === 'ETIMEDOUT' || err.code === 'ENOTFOUND') return true;
|
||||
// SSE 流中断 — 可重试
|
||||
if (err.message?.includes('aborted') || err.message?.includes('socket hang up')) return true;
|
||||
// 其他错误(400/401/403/4xx)不重试
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 生成下一个事件序列号 */
|
||||
private nextSeq(): number {
|
||||
return ++this.eventSeq;
|
||||
}
|
||||
|
||||
private async transitionTo(state: AgentLoopState): Promise<void> {
|
||||
const previous = this.currentState;
|
||||
this.currentState = state;
|
||||
@@ -501,6 +710,7 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
state,
|
||||
sessionId: this.currentSessionId,
|
||||
iteration: this.currentIteration,
|
||||
runId: this.runId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -610,11 +820,22 @@ export class AgentLoopEngine extends EventEmitter {
|
||||
requestId: this.currentRequestId,
|
||||
sessionId: this.currentSessionId,
|
||||
iteration: this.currentIteration,
|
||||
seq: 0,
|
||||
seq: this.nextSeq(),
|
||||
timestamp: Date.now(),
|
||||
runId: this.runId,
|
||||
});
|
||||
|
||||
// H-3: 通过 stateChange 发射 TERMINATED 状态(前端可据此清理 UI)
|
||||
const prevTerminated = this.currentState;
|
||||
this.currentState = AgentLoopState.TERMINATED;
|
||||
this.emit('stateChange', {
|
||||
previous: prevTerminated,
|
||||
current: AgentLoopState.TERMINATED,
|
||||
state: AgentLoopState.TERMINATED,
|
||||
sessionId: this.currentSessionId,
|
||||
iteration: this.currentIteration,
|
||||
runId: this.runId,
|
||||
});
|
||||
|
||||
// 发射 complete 事件(供托盘通知等外部监听器使用)
|
||||
this.emit('complete', {
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* Confirmation Hook — 工具执行前用户确认钩子(v0.2.0 新增)
|
||||
*
|
||||
* 对 requiresPermission=true 或 riskLevel >= HIGH 的工具,
|
||||
* 通过 IPC 请求用户确认后再执行。
|
||||
*
|
||||
* 支持两种自动执行机制:
|
||||
* 1. 持久化自动执行(跨会话):存储在 ConfigService `tools.{toolName}.autoExecute`
|
||||
* 2. 会话内记忆:同一会话内不再重复询问(rememberDecisions)
|
||||
*
|
||||
* @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第五章
|
||||
*/
|
||||
|
||||
import { BrowserWindow } from 'electron';
|
||||
import type { MetonaToolCall, MetonaToolDef } from '../types';
|
||||
import type { PreToolHook, HookResult } from './pre-tool';
|
||||
import type { ConfigService } from '../../services/config.service';
|
||||
|
||||
export interface ConfirmationRequest {
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
riskLevel: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export class ConfirmationHook implements PreToolHook {
|
||||
/** 需要确认的风险等级 */
|
||||
private static REQUIRES_CONFIRMATION = ['high', 'critical'];
|
||||
|
||||
/** 工具定义缓存(由外部设置) */
|
||||
private toolDefs = new Map<string, MetonaToolDef>();
|
||||
|
||||
/** 用户选择记忆(同一会话内不再重复询问) */
|
||||
private rememberedDecisions = new Map<string, boolean>();
|
||||
|
||||
/** 持久化自动执行的工具集合(从 ConfigService 加载,跨会话生效) */
|
||||
private autoExecuteTools = new Set<string>();
|
||||
|
||||
/** 等待确认的 Promise 解析器 */
|
||||
private pendingConfirmations = new Map<string, { resolve: (v: boolean) => void; timer: NodeJS.Timeout; toolName: string }>();
|
||||
|
||||
/** 确认超时时间(默认 120 秒) */
|
||||
private readonly confirmationTimeoutMs = 120_000;
|
||||
|
||||
constructor(
|
||||
private mainWindow: BrowserWindow | null = null,
|
||||
private configService: ConfigService | null = null,
|
||||
) {
|
||||
this.loadAutoExecuteList();
|
||||
}
|
||||
|
||||
/** 设置主窗口(用于发送 IPC 消息) */
|
||||
setMainWindow(window: BrowserWindow): void {
|
||||
this.mainWindow = window;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 ConfigService 加载已设置为自动执行的工具列表
|
||||
* 配置键格式:tools.{toolName}.autoExecute = true
|
||||
*/
|
||||
private loadAutoExecuteList(): void {
|
||||
if (!this.configService) return;
|
||||
try {
|
||||
const all = this.configService.getAll();
|
||||
this.autoExecuteTools.clear();
|
||||
for (const [key, value] of Object.entries(all)) {
|
||||
// 匹配 tools.{toolName}.autoExecute 键
|
||||
const match = /^tools\.(.+)\.autoExecute$/.exec(key);
|
||||
if (match && value === true) {
|
||||
this.autoExecuteTools.add(match[1]);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 配置加载失败,忽略(不阻塞启动)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置/取消工具的持久化自动执行
|
||||
* @param toolName 工具名
|
||||
* @param enabled true=自动执行(不再询问),false=每次询问
|
||||
*/
|
||||
setAutoExecute(toolName: string, enabled: boolean): void {
|
||||
const configKey = `tools.${toolName}.autoExecute`;
|
||||
if (this.configService) {
|
||||
this.configService.set(configKey, enabled);
|
||||
}
|
||||
if (enabled) {
|
||||
this.autoExecuteTools.add(toolName);
|
||||
// 自动执行时同步清除会话内"拒绝"记忆(避免冲突)
|
||||
this.rememberedDecisions.delete(toolName);
|
||||
} else {
|
||||
this.autoExecuteTools.delete(toolName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前自动执行的工具列表
|
||||
*/
|
||||
getAutoExecuteList(): string[] {
|
||||
return Array.from(this.autoExecuteTools);
|
||||
}
|
||||
|
||||
/** 注入工具定义列表(用于查询风险等级) */
|
||||
setToolDefs(defs: MetonaToolDef[]): void {
|
||||
this.toolDefs.clear();
|
||||
for (const def of defs) {
|
||||
this.toolDefs.set(def.name, def);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理用户确认响应(由 IPC handler 调用)
|
||||
* @param remember 会话内记住决定
|
||||
* @param autoExecute 永久自动执行(持久化)
|
||||
*/
|
||||
resolveConfirmation(toolCallId: string, approved: boolean, remember: boolean, autoExecute: boolean = false): void {
|
||||
const pending = this.pendingConfirmations.get(toolCallId);
|
||||
if (pending) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.resolve(approved);
|
||||
// 永久自动执行(仅当用户批准时才设置)
|
||||
if (autoExecute && approved) {
|
||||
this.setAutoExecute(pending.toolName, true);
|
||||
}
|
||||
// 会话内记忆
|
||||
if (remember) {
|
||||
this.rememberedDecisions.set(pending.toolName, approved);
|
||||
}
|
||||
this.pendingConfirmations.delete(toolCallId);
|
||||
}
|
||||
}
|
||||
|
||||
async beforeExecute(toolCall: MetonaToolCall, _sessionId: string): Promise<HookResult> {
|
||||
const def = this.toolDefs.get(toolCall.name);
|
||||
if (!def) {
|
||||
// 未知工具,放行(由 ToolRegistry 处理未知工具错误)
|
||||
return { blocked: false };
|
||||
}
|
||||
|
||||
// 检查是否需要确认
|
||||
const needsConfirmation = def.requiresPermission ||
|
||||
ConfirmationHook.REQUIRES_CONFIRMATION.includes(def.riskLevel);
|
||||
|
||||
if (!needsConfirmation) {
|
||||
return { blocked: false };
|
||||
}
|
||||
|
||||
// 优先检查持久化自动执行(跨会话生效)
|
||||
if (this.autoExecuteTools.has(toolCall.name)) {
|
||||
return { blocked: false };
|
||||
}
|
||||
|
||||
// 检查是否有记住的决策
|
||||
const remembered = this.rememberedDecisions.get(toolCall.name);
|
||||
if (remembered !== undefined) {
|
||||
if (remembered) return { blocked: false };
|
||||
return { blocked: true, reason: `User previously denied tool "${toolCall.name}"` };
|
||||
}
|
||||
|
||||
// 如果没有主窗口,安全起见阻止执行
|
||||
if (!this.mainWindow || this.mainWindow.isDestroyed()) {
|
||||
return { blocked: true, reason: 'Cannot request confirmation: no main window available' };
|
||||
}
|
||||
|
||||
// 发送确认请求到渲染进程
|
||||
const request: ConfirmationRequest = {
|
||||
toolCallId: toolCall.id,
|
||||
toolName: toolCall.name,
|
||||
args: toolCall.args,
|
||||
riskLevel: def.riskLevel,
|
||||
reason: def.requiresPermission
|
||||
? `Tool "${toolCall.name}" requires permission (risk: ${def.riskLevel})`
|
||||
: `Tool "${toolCall.name}" has high risk level: ${def.riskLevel}`,
|
||||
};
|
||||
|
||||
// 等待用户响应(带超时)
|
||||
const approved = await this.waitForConfirmation(request);
|
||||
|
||||
if (!approved) {
|
||||
return { blocked: true, reason: `User denied execution of tool "${toolCall.name}"` };
|
||||
}
|
||||
|
||||
return { blocked: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待用户确认(带超时)
|
||||
*/
|
||||
private waitForConfirmation(request: ConfirmationRequest): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
// 设置超时
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingConfirmations.delete(request.toolCallId);
|
||||
resolve(false); // 超时视为拒绝
|
||||
}, this.confirmationTimeoutMs);
|
||||
|
||||
this.pendingConfirmations.set(request.toolCallId, {
|
||||
resolve,
|
||||
timer,
|
||||
toolName: request.toolName,
|
||||
});
|
||||
|
||||
// 发送确认请求到渲染进程
|
||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||
this.mainWindow.webContents.send('tool:confirmationRequest', request);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理所有等待中的确认(会话结束时调用)
|
||||
*/
|
||||
clearPending(): void {
|
||||
for (const [, pending] of this.pendingConfirmations) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.resolve(false);
|
||||
}
|
||||
this.pendingConfirmations.clear();
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
import type { MetonaToolCall, MetonaToolResult } from '../types';
|
||||
import type { AuditService } from '../../services/audit.service';
|
||||
import type { MemoryManager } from '../memory/manager';
|
||||
import log from 'electron-log';
|
||||
|
||||
export interface PostToolHook {
|
||||
afterExecute(toolCall: MetonaToolCall, result: MetonaToolResult, sessionId: string): Promise<void>;
|
||||
@@ -55,8 +56,7 @@ export class MemoryTriggerHook implements PostToolHook {
|
||||
});
|
||||
} catch (error) {
|
||||
// 记忆存储失败不应影响工具执行结果
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[MemoryTriggerHook] Failed to store episodic memory:', error);
|
||||
log.error('[MemoryTriggerHook] Failed to store episodic memory:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,8 +36,9 @@ export class RateLimitHook implements PreToolHook {
|
||||
|
||||
constructor(private maxCallsPerMinute: number = 20) {}
|
||||
|
||||
async beforeExecute(toolCall: MetonaToolCall, _sessionId: string): Promise<HookResult> {
|
||||
const key = toolCall.name;
|
||||
async beforeExecute(toolCall: MetonaToolCall, sessionId: string): Promise<HookResult> {
|
||||
// 使用 sessionId:toolName 作为 key,实现会话隔离的 per-tool 速率限制
|
||||
const key = `${sessionId}:${toolCall.name}`;
|
||||
const now = Date.now();
|
||||
const entry = this.callCounts.get(key);
|
||||
if (entry && entry.resetTime >= now) {
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* Memory Consolidator — 会话结束记忆固化器
|
||||
*
|
||||
* 每次 Agent 完成一轮对话后,调用 LLM 判断本次对话中哪些内容
|
||||
* 值得持久化到工作空间根目录的 MEMORY.md。
|
||||
*
|
||||
* 工作流程:
|
||||
* 1. 收集本次对话的 user message + assistant final answer + tool calls 摘要
|
||||
* 2. 读取当前 MEMORY.md 内容作为参考(避免重复)
|
||||
* 3. 调用 LLM,要求其返回 JSON 数组 [{section, entry}]
|
||||
* 4. 解析响应,调用 WorkspaceService.appendMemory 追加
|
||||
*
|
||||
* 安全约束:
|
||||
* - 仅追加新条目,不修改已有内容
|
||||
* - section 限定为预定义的 5 个分区
|
||||
* - 单次最多追加 5 条记忆,避免 LLM 过度提取
|
||||
* - LLM 调用失败时静默降级(不影响主流程)
|
||||
*
|
||||
* @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第六章
|
||||
*/
|
||||
|
||||
import { nanoid } from 'nanoid';
|
||||
import log from 'electron-log';
|
||||
import type { IMetonaProviderAdapter } from '../types/metona-adapter';
|
||||
import type { MetonaRequest, MetonaMessage } from '../types';
|
||||
import type { WorkspaceService } from '../../services/workspace.service';
|
||||
import type { IterationStep } from '../agent-loop/types';
|
||||
|
||||
/** 允许写入的 MEMORY.md 分区(与 WorkspaceService.MEMORY_TEMPLATE 对齐) */
|
||||
const ALLOWED_SECTIONS = ['用户偏好', '项目上下文', '重要决策', '待办事项', '已知问题'] as const;
|
||||
type AllowedSection = typeof ALLOWED_SECTIONS[number];
|
||||
|
||||
/** 单次固化最多追加的条目数 */
|
||||
const MAX_ENTRIES_PER_CONSOLIDATION = 5;
|
||||
|
||||
/** 单条记忆最大长度 */
|
||||
const MAX_ENTRY_LENGTH = 500;
|
||||
|
||||
export interface ConsolidationResult {
|
||||
appended: number;
|
||||
entries: Array<{ section: string; entry: string }>;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
export class MemoryConsolidator {
|
||||
constructor(
|
||||
private adapter: IMetonaProviderAdapter,
|
||||
private workspaceService: WorkspaceService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 热切换 Adapter(配置变更时由 main.ts 调用)
|
||||
*/
|
||||
setAdapter(adapter: IMetonaProviderAdapter): void {
|
||||
this.adapter = adapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* 固化本次对话的重要记忆到 MEMORY.md
|
||||
*
|
||||
* @param userMessage 用户原始消息
|
||||
* @param assistantAnswer Agent 最终回答
|
||||
* @param iterations Agent Loop 迭代步骤(用于提取工具调用摘要)
|
||||
* @returns 固化结果
|
||||
*/
|
||||
async consolidate(
|
||||
userMessage: string,
|
||||
assistantAnswer: string,
|
||||
iterations: IterationStep[],
|
||||
): Promise<ConsolidationResult> {
|
||||
try {
|
||||
// 1. 构建对话摘要
|
||||
const conversationDigest = this.buildConversationDigest(userMessage, assistantAnswer, iterations);
|
||||
if (!conversationDigest) {
|
||||
return { appended: 0, entries: [], skipped: 0 };
|
||||
}
|
||||
|
||||
// 2. 读取当前 MEMORY.md 内容(供 LLM 去重)
|
||||
const currentMemory = this.workspaceService.getFiles().memory;
|
||||
const memoryDigest = this.truncateMemoryForPrompt(currentMemory);
|
||||
|
||||
// 3. 调用 LLM 提取需要持久化的记忆
|
||||
const llmResponse = await this.callLLMForExtraction(conversationDigest, memoryDigest);
|
||||
if (!llmResponse) {
|
||||
return { appended: 0, entries: [], skipped: 0 };
|
||||
}
|
||||
|
||||
// 4. 解析 JSON 响应
|
||||
const extracted = this.parseExtractionResponse(llmResponse);
|
||||
if (extracted.length === 0) {
|
||||
log.debug('[MemoryConsolidator] No memories worth persisting');
|
||||
return { appended: 0, entries: [], skipped: 0 };
|
||||
}
|
||||
|
||||
// 5. 过滤 + 截断 + 追加到 MEMORY.md
|
||||
const validEntries: Array<{ section: string; entry: string }> = [];
|
||||
let skipped = 0;
|
||||
|
||||
for (const item of extracted.slice(0, MAX_ENTRIES_PER_CONSOLIDATION)) {
|
||||
if (!this.isValidEntry(item)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const truncatedEntry = item.entry.slice(0, MAX_ENTRY_LENGTH);
|
||||
try {
|
||||
this.workspaceService.appendMemory(item.section, truncatedEntry);
|
||||
validEntries.push({ section: item.section, entry: truncatedEntry });
|
||||
} catch (err) {
|
||||
log.warn(`[MemoryConsolidator] Failed to append to section "${item.section}":`, err);
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
if (validEntries.length > 0) {
|
||||
log.info(`[MemoryConsolidator] Persisted ${validEntries.length} memories to MEMORY.md (skipped: ${skipped})`);
|
||||
}
|
||||
|
||||
return { appended: validEntries.length, entries: validEntries, skipped };
|
||||
} catch (error) {
|
||||
log.error('[MemoryConsolidator] Consolidation failed:', error);
|
||||
return { appended: 0, entries: [], skipped: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建对话摘要(供 LLM 分析)
|
||||
*/
|
||||
private buildConversationDigest(
|
||||
userMessage: string,
|
||||
assistantAnswer: string,
|
||||
iterations: IterationStep[],
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
// 用户消息
|
||||
parts.push(`[USER] ${this.truncate(userMessage, 2000)}`);
|
||||
|
||||
// 工具调用摘要(如果有)
|
||||
const toolSummaries: string[] = [];
|
||||
for (const iter of iterations) {
|
||||
if (!iter.toolCalls?.length) continue;
|
||||
for (let i = 0; i < iter.toolCalls.length; i++) {
|
||||
const tc = iter.toolCalls[i];
|
||||
const result = iter.toolResults?.find((r) => r.toolCallId === tc.id);
|
||||
const status = result?.success ? 'ok' : 'error';
|
||||
const resultPreview = result?.result
|
||||
? this.truncate(JSON.stringify(result.result), 200)
|
||||
: result?.error ?? '';
|
||||
toolSummaries.push(` - ${tc.name}(${this.truncate(JSON.stringify(tc.args), 100)}) [${status}]${resultPreview ? ': ' + resultPreview : ''}`);
|
||||
}
|
||||
}
|
||||
if (toolSummaries.length > 0) {
|
||||
parts.push(`[TOOLS]\n${toolSummaries.join('\n')}`);
|
||||
}
|
||||
|
||||
// Agent 最终回答
|
||||
parts.push(`[ASSISTANT] ${this.truncate(assistantAnswer, 2000)}`);
|
||||
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 截断 MEMORY.md 内容用于 prompt(避免过长)
|
||||
*/
|
||||
private truncateMemoryForPrompt(memory: string): string {
|
||||
if (!memory) return '(empty)';
|
||||
// 截取前 3000 字符,保留分区结构概览
|
||||
if (memory.length <= 3000) return memory;
|
||||
return memory.slice(0, 3000) + '\n... (truncated)';
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用 LLM 提取需要持久化的记忆
|
||||
*/
|
||||
private async callLLMForExtraction(
|
||||
conversationDigest: string,
|
||||
currentMemory: string,
|
||||
): Promise<string | null> {
|
||||
const sectionsList = ALLOWED_SECTIONS.map((s) => `"${s}"`).join(', ');
|
||||
|
||||
const request: MetonaRequest = {
|
||||
meta: {
|
||||
sessionId: 'memory-consolidation',
|
||||
iteration: 0,
|
||||
requestId: `mc_${nanoid(12)}`,
|
||||
timestamp: Date.now(),
|
||||
agentVersion: '1.0.0',
|
||||
},
|
||||
systemPrompt: {
|
||||
roleDefinition: 'You are a memory curator for an AI agent. Your job is to decide what information from the current conversation is worth persisting to the agent\'s long-term memory file (MEMORY.md) for future sessions.',
|
||||
outputConstraints: [
|
||||
'Analyze the conversation below and extract ONLY information that meets ALL of these criteria:',
|
||||
'1. Long-term value: will be useful in future conversations (not transient task state)',
|
||||
'2. Not already present in the current MEMORY.md (avoid duplicates)',
|
||||
'3. Concrete and actionable (not vague observations)',
|
||||
'',
|
||||
'Good candidates: user preferences, project facts, important decisions, pending todos, known issues.',
|
||||
'Bad candidates: temporary tool results, trivial conversation, already-known facts.',
|
||||
'',
|
||||
`Output a JSON array. Each element: {"section": one of ${sectionsList}, "entry": concise description (max 200 chars, in the conversation language)}`,
|
||||
'If nothing is worth persisting, output an empty array: []',
|
||||
'Output ONLY the JSON array, no markdown fences, no explanation.',
|
||||
].join('\n'),
|
||||
safetyGuidelines: 'Do not persist sensitive data (passwords, API keys, tokens). Do not persist user personal information beyond what is necessary for the agent to function.',
|
||||
},
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: `## Current MEMORY.md content:\n\n${currentMemory}\n\n## Current conversation:\n\n${conversationDigest}\n\n## Task:\nExtract information worth persisting. Output JSON array only.`,
|
||||
timestamp: Date.now(),
|
||||
}],
|
||||
params: {
|
||||
maxTokens: 1024,
|
||||
temperature: 0.0,
|
||||
stream: false,
|
||||
thinkingEnabled: false,
|
||||
thinkingEffort: 'low',
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await this.adapter.chat(request);
|
||||
return response.content.trim();
|
||||
} catch (error) {
|
||||
log.warn('[MemoryConsolidator] LLM call failed:', (error as Error).message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 LLM 的提取响应
|
||||
*/
|
||||
private parseExtractionResponse(raw: string): Array<{ section: string; entry: string }> {
|
||||
if (!raw) return [];
|
||||
|
||||
// 尝试直接解析 JSON
|
||||
let cleaned = raw.trim();
|
||||
|
||||
// 移除可能的 markdown 代码围栏
|
||||
if (cleaned.startsWith('```')) {
|
||||
cleaned = cleaned.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '').trim();
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(cleaned);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
|
||||
return parsed
|
||||
.filter((item): item is { section: string; entry: string } =>
|
||||
typeof item === 'object' && item !== null &&
|
||||
typeof item.section === 'string' && typeof item.entry === 'string',
|
||||
)
|
||||
.map((item) => ({
|
||||
section: item.section.trim(),
|
||||
entry: item.entry.trim(),
|
||||
}))
|
||||
.filter((item) => item.section && item.entry);
|
||||
} catch {
|
||||
log.warn('[MemoryConsolidator] Failed to parse LLM response as JSON:', cleaned.slice(0, 200));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验条目是否合法(section 在允许列表内)
|
||||
*/
|
||||
private isValidEntry(item: { section: string; entry: string }): boolean {
|
||||
return (ALLOWED_SECTIONS as readonly string[]).includes(item.section) && item.entry.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 截断字符串
|
||||
*/
|
||||
private truncate(text: string, maxLen: number): string {
|
||||
if (text.length <= maxLen) return text;
|
||||
return text.slice(0, maxLen) + '...';
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,11 @@
|
||||
* 基于 SQLite(better-sqlite3)的三层记忆系统。
|
||||
* 表结构由 DatabaseService 统一创建,此处不再重复。
|
||||
*
|
||||
* v0.2.0 增强:
|
||||
* - TF-IDF 语义检索替代 LIKE 关键词搜索
|
||||
* - 时间衰减策略:老旧记忆权重降低
|
||||
* - IDF 缓存:避免每次搜索重新计算
|
||||
*
|
||||
* @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第六章
|
||||
* @see standard/开发规范.md — 使用 better-sqlite3(禁止自写数据库层)
|
||||
*/
|
||||
@@ -38,17 +43,258 @@ interface MemorySearchOptions {
|
||||
minImportance?: number;
|
||||
}
|
||||
|
||||
/** 分词:将文本拆分为词项(支持中英文) */
|
||||
function tokenize(text: string): string[] {
|
||||
// 转小写,按非字母数字字符分割
|
||||
const lower = text.toLowerCase();
|
||||
// 英文词
|
||||
const words = lower.match(/[a-z][a-z0-9_-]{1,}/g) ?? [];
|
||||
// 中文 bigram(相邻两字组合),覆盖 CJK 统一表意、扩展 A、平假名/片假名、谚文
|
||||
const cjkChars = lower.match(/[\u4e00-\u9fff\u3400-\u4dbf\u3040-\u30ff\uac00-\ud7af]/g) ?? [];
|
||||
const bigrams: string[] = [];
|
||||
for (let i = 0; i < cjkChars.length - 1; i++) {
|
||||
bigrams.push(cjkChars[i] + cjkChars[i + 1]);
|
||||
}
|
||||
// 单字 CJK 补 unigram(避免单字文档无 token)
|
||||
if (cjkChars.length === 1) {
|
||||
bigrams.push(cjkChars[0]);
|
||||
}
|
||||
return [...words, ...bigrams];
|
||||
}
|
||||
|
||||
/** 计算词频(TF) */
|
||||
function computeTF(tokens: string[]): Map<string, number> {
|
||||
const tf = new Map<string, number>();
|
||||
for (const token of tokens) {
|
||||
tf.set(token, (tf.get(token) ?? 0) + 1);
|
||||
}
|
||||
// 归一化
|
||||
const total = tokens.length || 1;
|
||||
for (const [key, val] of tf) {
|
||||
tf.set(key, val / total);
|
||||
}
|
||||
return tf;
|
||||
}
|
||||
|
||||
/** 计算余弦相似度的点积部分 */
|
||||
function dotProduct(tf1: Map<string, number>, tf2: Map<string, number>, idf: Map<string, number>): number {
|
||||
let sum = 0;
|
||||
for (const [term, freq1] of tf1) {
|
||||
const freq2 = tf2.get(term);
|
||||
if (freq2 !== undefined) {
|
||||
const idfVal = idf.get(term) ?? 1;
|
||||
sum += freq1 * freq2 * idfVal * idfVal;
|
||||
}
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
/** 计算向量模长 */
|
||||
function vectorNorm(tf: Map<string, number>, idf: Map<string, number>): number {
|
||||
let sum = 0;
|
||||
for (const [term, freq] of tf) {
|
||||
const idfVal = idf.get(term) ?? 1;
|
||||
sum += (freq * idfVal) ** 2;
|
||||
}
|
||||
return Math.sqrt(sum);
|
||||
}
|
||||
|
||||
/** 时间衰减权重:30 天半衰期(age=30 时 weight=0.5) */
|
||||
function timeDecayWeight(createdAt: number, now: number = Date.now()): number {
|
||||
const ageDays = Math.max(0, (now - createdAt) / (24 * 60 * 60 * 1000));
|
||||
const halfLifeDays = 30;
|
||||
return Math.pow(0.5, ageDays / halfLifeDays);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记忆管理器
|
||||
*/
|
||||
export class MemoryManager {
|
||||
/** IDF 缓存:词项 -> 文档频率 */
|
||||
private idfCache = new Map<string, number>();
|
||||
/** 缓存的记忆总数 */
|
||||
private cachedDocCount = 0;
|
||||
/** 缓存最后更新时间 */
|
||||
private cacheUpdatedAt = 0;
|
||||
/** 缓存有效期(5 分钟) */
|
||||
private readonly CACHE_TTL = 5 * 60 * 1000;
|
||||
|
||||
constructor(private getDB: () => Database.Database) {}
|
||||
|
||||
/**
|
||||
* 初始化(表结构由 DatabaseService 创建)
|
||||
*/
|
||||
initialize(): void {
|
||||
log.info('MemoryManager initialized');
|
||||
log.info('MemoryManager initialized (v0.2.0: TF-IDF enabled)');
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 IDF 缓存
|
||||
*/
|
||||
private updateIdfCache(): void {
|
||||
const now = Date.now();
|
||||
if (now - this.cacheUpdatedAt < this.CACHE_TTL && this.cachedDocCount > 0) {
|
||||
return; // 缓存未过期
|
||||
}
|
||||
|
||||
const db = this.getDB();
|
||||
this.idfCache.clear();
|
||||
|
||||
// 获取所有记忆内容(episodic + semantic + working)
|
||||
const episodicRows = db.prepare('SELECT content, summary FROM episodic_memories').all() as Array<{ content: string; summary: string | null }>;
|
||||
const semanticRows = db.prepare('SELECT value FROM semantic_memories').all() as Array<{ value: string }>;
|
||||
const workingRows = db.prepare('SELECT value FROM working_memories').all() as Array<{ value: string }>;
|
||||
|
||||
const allDocs = [
|
||||
...episodicRows.map((r) => r.content + ' ' + (r.summary ?? '')),
|
||||
...semanticRows.map((r) => r.value),
|
||||
...workingRows.map((r) => r.value),
|
||||
];
|
||||
|
||||
this.cachedDocCount = allDocs.length;
|
||||
const docFreq = new Map<string, number>();
|
||||
|
||||
for (const doc of allDocs) {
|
||||
const tokens = new Set(tokenize(doc));
|
||||
for (const token of tokens) {
|
||||
docFreq.set(token, (docFreq.get(token) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// IDF = log((N+1)/(df+1)) + 1(Sklearn 风格平滑),确保非负
|
||||
for (const [term, df] of docFreq) {
|
||||
this.idfCache.set(term, Math.log((this.cachedDocCount + 1) / (df + 1)) + 1);
|
||||
}
|
||||
|
||||
this.cacheUpdatedAt = now;
|
||||
}
|
||||
|
||||
/**
|
||||
* TF-IDF 相似度搜索
|
||||
*/
|
||||
private tfidfSearch(query: string, options: MemorySearchOptions): SearchResult[] {
|
||||
const db = this.getDB();
|
||||
this.updateIdfCache();
|
||||
|
||||
const queryTokens = tokenize(query);
|
||||
if (queryTokens.length === 0) return [];
|
||||
|
||||
const queryTF = computeTF(queryTokens);
|
||||
const queryNorm = vectorNorm(queryTF, this.idfCache);
|
||||
if (queryNorm === 0) return [];
|
||||
|
||||
const { topK = 5, type, minImportance = 0 } = options;
|
||||
const results: SearchResult[] = [];
|
||||
const now = Date.now();
|
||||
|
||||
// 搜索 episodic 记忆
|
||||
if (!type || type === 'episodic') {
|
||||
const rows = db.prepare(`
|
||||
SELECT * FROM episodic_memories WHERE importance >= ?
|
||||
ORDER BY importance DESC, created_at DESC LIMIT ?
|
||||
`).all(minImportance, topK * 3) as Array<{
|
||||
id: string; session_id: string | null; content: string; summary: string | null;
|
||||
source: string; importance: number; created_at: number; expires_at: number | null;
|
||||
}>;
|
||||
|
||||
for (const row of rows) {
|
||||
const docText = row.content + ' ' + (row.summary ?? '');
|
||||
const docTokens = tokenize(docText);
|
||||
const docTF = computeTF(docTokens);
|
||||
const docNorm = vectorNorm(docTF, this.idfCache);
|
||||
|
||||
if (docNorm === 0) continue;
|
||||
|
||||
const dotProd = dotProduct(queryTF, docTF, this.idfCache);
|
||||
const cosineSim = dotProd / (queryNorm * docNorm);
|
||||
|
||||
// 时间衰减
|
||||
const decayWeight = timeDecayWeight(row.created_at, now);
|
||||
// 最终分数 = 余弦相似度 * 时间衰减 * 重要度权重
|
||||
const finalScore = cosineSim * decayWeight * (0.5 + row.importance * 0.5);
|
||||
|
||||
if (finalScore > 0) {
|
||||
results.push({
|
||||
id: row.id, type: 'episodic', content: row.content,
|
||||
summary: row.summary ?? undefined, source: row.source as MemorySource,
|
||||
importance: row.importance, sessionId: row.session_id ?? undefined,
|
||||
createdAt: row.created_at, expiresAt: row.expires_at ?? undefined,
|
||||
score: finalScore,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索 semantic 记忆
|
||||
if (!type || type === 'semantic') {
|
||||
const rows = db.prepare(`
|
||||
SELECT * FROM semantic_memories WHERE confidence >= ?
|
||||
ORDER BY confidence DESC, access_count DESC LIMIT ?
|
||||
`).all(minImportance, Math.ceil(topK * 1.5)) as Array<{
|
||||
id: string; key: string; value: string; category: string | null;
|
||||
confidence: number; source_session: string | null; created_at: number;
|
||||
}>;
|
||||
|
||||
for (const row of rows) {
|
||||
const docText = row.key + ' ' + row.value;
|
||||
const docTokens = tokenize(docText);
|
||||
const docTF = computeTF(docTokens);
|
||||
const docNorm = vectorNorm(docTF, this.idfCache);
|
||||
|
||||
if (docNorm === 0) continue;
|
||||
|
||||
const dotProd = dotProduct(queryTF, docTF, this.idfCache);
|
||||
const cosineSim = dotProd / (queryNorm * docNorm);
|
||||
|
||||
const decayWeight = timeDecayWeight(row.created_at, now);
|
||||
const finalScore = cosineSim * decayWeight * (0.5 + row.confidence * 0.5);
|
||||
|
||||
if (finalScore > 0) {
|
||||
results.push({
|
||||
id: row.id, type: 'semantic', content: row.value,
|
||||
source: 'imported', importance: row.confidence,
|
||||
sessionId: row.source_session ?? undefined,
|
||||
createdAt: row.created_at, score: finalScore,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索 working 记忆
|
||||
if (!type || type === 'working') {
|
||||
const rows = db.prepare(`
|
||||
SELECT * FROM working_memories ORDER BY updated_at DESC LIMIT ?
|
||||
`).all(topK * 3) as Array<{
|
||||
id: string; session_id: string; task_id: string;
|
||||
key: string; value: string; updated_at: number;
|
||||
}>;
|
||||
|
||||
for (const row of rows) {
|
||||
const docText = row.key + ' ' + row.value;
|
||||
const docTokens = tokenize(docText);
|
||||
const docTF = computeTF(docTokens);
|
||||
const docNorm = vectorNorm(docTF, this.idfCache);
|
||||
|
||||
if (docNorm === 0) continue;
|
||||
|
||||
const dotProd = dotProduct(queryTF, docTF, this.idfCache);
|
||||
const cosineSim = dotProd / (queryNorm * docNorm);
|
||||
|
||||
const decayWeight = timeDecayWeight(row.updated_at, now);
|
||||
const finalScore = cosineSim * decayWeight * 0.5;
|
||||
|
||||
if (finalScore > 0) {
|
||||
results.push({
|
||||
id: row.id, type: 'working', content: row.value,
|
||||
source: 'agent_thought', importance: 0.5,
|
||||
sessionId: row.session_id, createdAt: row.updated_at,
|
||||
score: finalScore,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results.sort((a, b) => b.score - a.score).slice(0, topK);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,26 +327,44 @@ export class MemoryManager {
|
||||
break;
|
||||
}
|
||||
|
||||
// 使 IDF 缓存失效
|
||||
this.cacheUpdatedAt = 0;
|
||||
|
||||
log.debug(`Memory stored: ${id} (${item.type})`);
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检索记忆(关键词搜索)
|
||||
* 检索记忆(v0.2.0: TF-IDF 语义检索 + 时间衰减)
|
||||
*
|
||||
* v0.2.0 变更:
|
||||
* - 使用 TF-IDF 余弦相似度替代 LIKE 关键词搜索
|
||||
* - 支持中英文分词(英文按词,中文按 bigram)
|
||||
* - 时间衰减:30 天半衰期,老旧记忆权重降低
|
||||
* - IDF 缓存:5 分钟有效期,避免重复计算
|
||||
*/
|
||||
search(query: string, options: MemorySearchOptions = {}): SearchResult[] {
|
||||
const db = this.getDB();
|
||||
const { topK = 5, type, minImportance = 0 } = options;
|
||||
if (!query) return [];
|
||||
|
||||
const pattern = `%${query}%`;
|
||||
// v0.2.0: 优先使用 TF-IDF 语义搜索
|
||||
const tfidfResults = this.tfidfSearch(query, options);
|
||||
if (tfidfResults.length > 0) {
|
||||
return tfidfResults;
|
||||
}
|
||||
|
||||
// 回退:如果 TF-IDF 没有结果(如 IDF 缓存为空),使用 LIKE 关键词搜索
|
||||
// 转义 LIKE 通配符,避免用户输入的 % 和 _ 影响匹配
|
||||
const escapedQuery = query.replace(/[%_]/g, '\\$&');
|
||||
const pattern = `%${escapedQuery}%`;
|
||||
const results: SearchResult[] = [];
|
||||
|
||||
// 搜索情节记忆
|
||||
if (!type || type === 'episodic') {
|
||||
const rows = db.prepare(`
|
||||
SELECT * FROM episodic_memories
|
||||
WHERE (content LIKE ? OR summary LIKE ?) AND importance >= ?
|
||||
WHERE (content LIKE ? ESCAPE '\\' OR summary LIKE ? ESCAPE '\\') AND importance >= ?
|
||||
ORDER BY importance DESC, created_at DESC LIMIT ?
|
||||
`).all(pattern, pattern, minImportance, topK) as Array<{
|
||||
id: string; session_id: string | null; content: string; summary: string | null;
|
||||
@@ -112,7 +376,7 @@ export class MemoryManager {
|
||||
summary: row.summary ?? undefined, source: row.source as MemorySource,
|
||||
importance: row.importance, sessionId: row.session_id ?? undefined,
|
||||
createdAt: row.created_at, expiresAt: row.expires_at ?? undefined,
|
||||
score: row.importance,
|
||||
score: row.importance * timeDecayWeight(row.created_at),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -121,7 +385,7 @@ export class MemoryManager {
|
||||
if (!type || type === 'semantic') {
|
||||
const rows = db.prepare(`
|
||||
SELECT * FROM semantic_memories
|
||||
WHERE (key LIKE ? OR value LIKE ?) AND confidence >= ?
|
||||
WHERE (key LIKE ? ESCAPE '\\' OR value LIKE ? ESCAPE '\\') AND confidence >= ?
|
||||
ORDER BY confidence DESC, access_count DESC LIMIT ?
|
||||
`).all(pattern, pattern, minImportance, Math.ceil(topK / 2)) as Array<{
|
||||
id: string; key: string; value: string; category: string | null;
|
||||
@@ -132,7 +396,7 @@ export class MemoryManager {
|
||||
id: row.id, type: 'semantic', content: row.value,
|
||||
source: 'imported', importance: row.confidence,
|
||||
sessionId: row.source_session ?? undefined,
|
||||
createdAt: row.created_at, score: row.confidence,
|
||||
createdAt: row.created_at, score: row.confidence * timeDecayWeight(row.created_at),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -141,7 +405,7 @@ export class MemoryManager {
|
||||
if (type === 'working') {
|
||||
const rows = db.prepare(`
|
||||
SELECT * FROM working_memories
|
||||
WHERE (key LIKE ? OR value LIKE ?)
|
||||
WHERE (key LIKE ? ESCAPE '\\' OR value LIKE ? ESCAPE '\\')
|
||||
ORDER BY updated_at DESC LIMIT ?
|
||||
`).all(pattern, pattern, topK) as Array<{
|
||||
id: string; session_id: string; task_id: string;
|
||||
@@ -152,7 +416,7 @@ export class MemoryManager {
|
||||
id: row.id, type: 'working', content: row.value,
|
||||
source: 'agent_thought', importance: 0.5,
|
||||
sessionId: row.session_id, createdAt: row.updated_at,
|
||||
score: 0.3, // 工作记忆权重较低
|
||||
score: 0.3 * timeDecayWeight(row.updated_at),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,13 +65,33 @@ export class PolicyEngine {
|
||||
};
|
||||
}
|
||||
|
||||
const argsStr = JSON.stringify(args);
|
||||
|
||||
// v0.2.0: allowedPatterns 白名单校验 — 若定义了白名单,参数必须匹配其中之一
|
||||
if (policy.allowedPatterns && policy.allowedPatterns.length > 0) {
|
||||
let matchedAllowed = false;
|
||||
for (const pattern of policy.allowedPatterns) {
|
||||
if (pattern.test(argsStr)) {
|
||||
matchedAllowed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!matchedAllowed) {
|
||||
return {
|
||||
authorized: false,
|
||||
reason: 'Arguments do not match any allowed pattern',
|
||||
level: policy.requiredLevel,
|
||||
requiresConfirmation: policy.requireConfirmation ?? false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (policy.deniedPatterns) {
|
||||
const argsStr = JSON.stringify(args);
|
||||
for (const pattern of policy.deniedPatterns) {
|
||||
if (pattern.test(argsStr)) {
|
||||
return {
|
||||
authorized: false,
|
||||
reason: `Arguments match denied pattern: ${pattern.source}`,
|
||||
reason: 'Command blocked by security policy',
|
||||
level: policy.requiredLevel,
|
||||
requiresConfirmation: false,
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
import { resolve, sep } from 'path';
|
||||
import { existsSync, realpathSync } from 'fs';
|
||||
|
||||
export interface SandboxConfig {
|
||||
allowedPaths?: string[];
|
||||
@@ -49,6 +50,7 @@ export class SandboxManager {
|
||||
* 校验文件路径是否在白名单内
|
||||
*
|
||||
* 安全策略:fail-closed — 如果未配置任何白名单路径,拒绝所有访问。
|
||||
* 同时解析符号链接,防止通过 symlink 逃逸白名单。
|
||||
*/
|
||||
validatePath(requestedPath: string): { allowed: boolean; resolvedPath: string; reason?: string } {
|
||||
const resolved = resolve(requestedPath);
|
||||
@@ -57,6 +59,7 @@ export class SandboxManager {
|
||||
return { allowed: false, resolvedPath: resolved, reason: 'No allowed paths configured (fail-closed)' };
|
||||
}
|
||||
|
||||
// 先做字符串级白名单校验
|
||||
const isAllowed = Array.from(this.allowedPaths).some(
|
||||
(allowed) => resolved === allowed || resolved.startsWith(allowed + sep),
|
||||
);
|
||||
@@ -64,28 +67,79 @@ export class SandboxManager {
|
||||
return { allowed: false, resolvedPath: resolved, reason: 'Path not in allowed list' };
|
||||
}
|
||||
|
||||
// 解析符号链接(如果路径存在)
|
||||
if (existsSync(resolved)) {
|
||||
try {
|
||||
const realPath = realpathSync(resolved);
|
||||
const realAllowed = Array.from(this.allowedPaths).some(
|
||||
(allowed) => realPath === allowed || realPath.startsWith(allowed + sep),
|
||||
);
|
||||
if (!realAllowed) {
|
||||
return { allowed: false, resolvedPath: realPath, reason: 'Symlink escape detected' };
|
||||
}
|
||||
return { allowed: true, resolvedPath: realPath };
|
||||
} catch {
|
||||
// realpath 解析失败(权限问题等),保守拒绝
|
||||
return { allowed: false, resolvedPath: resolved, reason: 'Path resolution failed' };
|
||||
}
|
||||
}
|
||||
|
||||
return { allowed: true, resolvedPath: resolved };
|
||||
}
|
||||
|
||||
/**
|
||||
* 静态代码安全扫描
|
||||
*
|
||||
* 检测危险模块导入、代码执行、路径遍历、危险命令、反向 shell、
|
||||
* fork bomb、PowerShell 编码执行、环境变量窃取、编码绕过等。
|
||||
*/
|
||||
scanCode(code: string): { safe: boolean; reason?: string } {
|
||||
const dangerousPatterns = [
|
||||
/require\s*\(\s*['"]child_process['"]\s*\)/,
|
||||
/eval\s*\(/,
|
||||
/process\.exit/,
|
||||
/import\s+.*from\s+['"]fs['"]/,
|
||||
/\.\.\//,
|
||||
/rm\s+-rf/,
|
||||
/>\s*\/dev\/null/,
|
||||
/curl.*\|\s*bash/,
|
||||
/wget.*\|\s*sh/,
|
||||
// 危险模块导入
|
||||
/require\s*\(\s*['"]child_process['"]\s*\)/i,
|
||||
/import\s+.*from\s+['"]fs['"]/i,
|
||||
/import\s+.*from\s+['"]child_process['"]/i,
|
||||
// 代码执行
|
||||
/\beval\s*\(/i,
|
||||
/process\.exit/i,
|
||||
/Function\s*\(/i,
|
||||
// 路径遍历
|
||||
/\.\.\//i,
|
||||
/\\\.\.\\/i, // Windows ..\
|
||||
// 危险命令
|
||||
/\brm\s+-rf\b/i,
|
||||
/\bkillall\s+-9\b/i,
|
||||
/\bchown\s+-R\s+\//i,
|
||||
// 重定向到系统目录
|
||||
/>\s*\/dev\/null/i,
|
||||
/>\s*\/etc\//i,
|
||||
// 管道执行
|
||||
/\bcurl\b.*\|\s*(bash|sh|zsh)\b/i,
|
||||
/\bwget\b.*\|\s*(sh|bash|zsh)\b/i,
|
||||
// 反向 shell
|
||||
/\/bin\/(bash|sh)\s+-i/i,
|
||||
/\bnc\s+-e\b/i,
|
||||
/\bbash\s+-i\b/i,
|
||||
// Fork bomb
|
||||
/:\(\)\s*\{\s*:\|:\s*&\s*\};:/i,
|
||||
// PowerShell 编码执行
|
||||
/powershell.*-enc(odedCommand)?\s+/i,
|
||||
// 环境变量窃取
|
||||
/env\b.*\b(GITHUB_TOKEN|API_KEY|SECRET|PASSWORD)\b/i,
|
||||
// 编码绕过检测
|
||||
/\bbase64\b.*\|\s*(sh|bash|zsh)\b/i,
|
||||
/\batob\s*\(/i,
|
||||
/\bprintf\s+['"]\\x[0-9a-f]/i,
|
||||
// 命令替换
|
||||
/\$\([^)]*(rm|kill|del|format|mkfs)\b/i,
|
||||
// heredoc 执行
|
||||
/<<\s*(EOF|END)\s*[\s\S]*?\b(rm|kill|del|format|mkfs)\b/i,
|
||||
];
|
||||
|
||||
for (const pattern of dangerousPatterns) {
|
||||
if (pattern.test(code)) {
|
||||
return { safe: false, reason: `Dangerous pattern detected: ${pattern.source}` };
|
||||
// 不暴露 pattern.source,使用通用错误信息
|
||||
return { safe: false, reason: 'Command blocked by security policy' };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,13 @@
|
||||
* 2. 语义级检测(可选)
|
||||
* 3. 指令隔离标记
|
||||
*
|
||||
* v0.2.0 增强:
|
||||
* - 扩展注入检测模式至 30+ 种
|
||||
* - 添加多语言检测(中英文)
|
||||
* - 添加编码注入检测(hex、unicode 转义)
|
||||
* - 添加角色扮演注入检测
|
||||
* - 添加间接注入检测(通过工具返回值注入)
|
||||
*
|
||||
* @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第十章
|
||||
*/
|
||||
|
||||
@@ -22,39 +29,77 @@ export interface InjectionFinding {
|
||||
severity: 'low' | 'medium' | 'high';
|
||||
}
|
||||
|
||||
interface InjectionPattern {
|
||||
pattern: RegExp;
|
||||
severity: 'low' | 'medium' | 'high';
|
||||
}
|
||||
|
||||
export class PromptInjectionDefender {
|
||||
private static INJECTION_PATTERNS = [
|
||||
/ignore\s+(all\s+)?(previous|above|prior)\s+(instructions|commands|directives)/i,
|
||||
/forget\s+(everything|all\s+(of\s+)?(the|your)|above)/i,
|
||||
/you\s+are\s+now/i,
|
||||
/new\s+(instructions|directive|role|persona)/i,
|
||||
/override\s+your\s+/i,
|
||||
/(show|display|print|output|reveal|tell\s+me)\s+(your|the)\s+(system\s+)?(prompt|instruction|directive)/i,
|
||||
/(dump|echo|log|expose)\s+(your|the)\s+(context|memory|history)/i,
|
||||
/-{3,}\s*(system|user|assistant|instruction)/i,
|
||||
/<{3,}(system|instruction|prompt)/i,
|
||||
/\[{3,}(system|instruction|prompt)/i,
|
||||
/base64\s*:?\s*[A-Za-z0-9+/=]{20,}/i,
|
||||
/pretend\s+(you\s+are|to\s+be)/i,
|
||||
/act\s+as\s+(if\s+you\s+were|you're)/i,
|
||||
/DAN\s*[:\[]/i,
|
||||
/JAILBREAK/i,
|
||||
private static INJECTION_PATTERNS: InjectionPattern[] = [
|
||||
// === HIGH 危险:直接越狱/忽略指令 ===
|
||||
{ pattern: /ignore\s+(all\s+)?(previous|above|prior)\s+(instructions|commands|directives)/i, severity: 'high' },
|
||||
{ pattern: /forget\s+(everything|all\s+(of\s+)?(the|your)|above)/i, severity: 'high' },
|
||||
{ pattern: /override\s+your\s+/i, severity: 'high' },
|
||||
{ pattern: /JAILBREAK/i, severity: 'high' },
|
||||
{ pattern: /DAN\s*[:\[]/i, severity: 'high' },
|
||||
{ pattern: /(?:enable|turn\s+on|activate)\s+(developer|debug|root|admin)\s+mode/i, severity: 'high' },
|
||||
{ pattern: /(?:disable|turn\s+off|bypass)\s+(your|the)\s+(safety|security|filter|guard|defense)/i, severity: 'high' },
|
||||
{ pattern: /(?:send|transmit|exfiltrate|upload)\s+(your|the|all)\s+(data|memory|context|secrets)/i, severity: 'high' },
|
||||
{ pattern: /(?:you\s+are|act\s+as|pretend\s+to\s+be)\s+(?:a|an)\s+(?:unrestricted|unfiltered|unlimited|free)/i, severity: 'high' },
|
||||
{ pattern: /(?:in|under)\s+(?:developer|unrestricted|jailbreak|god)\s+mode/i, severity: 'high' },
|
||||
// 中文高危
|
||||
{ pattern: /忽略(?:以上|之前|前面|上面|所有)(?:的)?(?:指令|指示|命令|说明|规则)/i, severity: 'high' },
|
||||
{ pattern: /忘记(?:之前|上面|所有)(?:的)?(?:内容|对话|指令)/i, severity: 'high' },
|
||||
{ pattern: /(?:黑客|越狱|破解)(?:模式|命令|规则)/i, severity: 'high' },
|
||||
{ pattern: /无视(?:以上|之前|前面|所有)(?:的)?(?:指令|指示|命令|规则)/i, severity: 'high' },
|
||||
|
||||
// === MEDIUM 危险:角色扮演/权限提升/编码注入 ===
|
||||
{ pattern: /you\s+are\s+now/i, severity: 'medium' },
|
||||
{ pattern: /new\s+(instructions|directive|role|persona)/i, severity: 'medium' },
|
||||
{ pattern: /(show|display|print|output|reveal|tell\s+me)\s+(your|the)\s+(system\s+)?(prompt|instruction|directive)/i, severity: 'medium' },
|
||||
{ pattern: /(dump|echo|log|expose)\s+(your|the)\s+(context|memory|history)/i, severity: 'medium' },
|
||||
{ pattern: /pretend\s+(you\s+are|to\s+be)/i, severity: 'medium' },
|
||||
{ pattern: /act\s+as\s+(if\s+you\s+were|you're)/i, severity: 'medium' },
|
||||
{ pattern: /(?:grant|give)\s+(me|you|us)\s+(admin|root|sudo|super)/i, severity: 'medium' },
|
||||
{ pattern: /base64\s*:?\s*[A-Za-z0-9+/=]{20,}/i, severity: 'medium' },
|
||||
{ pattern: /eval\s*\(\s*atob\s*\(/i, severity: 'medium' },
|
||||
{ pattern: /\\x[0-9a-f]{2}\\x[0-9a-f]{2}\\x[0-9a-f]{2}/i, severity: 'medium' },
|
||||
{ pattern: /\\u[0-9a-f]{4}\\u[0-9a-f]{4}/i, severity: 'medium' },
|
||||
// 中文中危
|
||||
{ pattern: /你(?:现在|从现在开始)(?:是|扮演|作为一个)/i, severity: 'medium' },
|
||||
{ pattern: /新(?:的)?(?:指令|角色|设定|规则)/i, severity: 'medium' },
|
||||
{ pattern: /(?:显示|打印|输出|告诉我)(?:你的)?(?:系统|内部)(?:提示|指令|设定)/i, severity: 'medium' },
|
||||
{ pattern: /假装(?:你是|自己是)/i, severity: 'medium' },
|
||||
{ pattern: /扮演(?:一个|以下)/i, severity: 'medium' },
|
||||
{ pattern: /(?:恶意|危险|有害)(?:代码|命令|操作)/i, severity: 'medium' },
|
||||
|
||||
// === LOW 危险:分隔符/间接注入标记 ===
|
||||
{ pattern: /-{3,}\s*(system|user|assistant|instruction)/i, severity: 'low' },
|
||||
{ pattern: /<{3,}(system|instruction|prompt)/i, severity: 'low' },
|
||||
{ pattern: /\[{3,}(system|instruction|prompt)/i, severity: 'low' },
|
||||
{ pattern: /\[SYSTEM\]/i, severity: 'low' },
|
||||
{ pattern: /\[INSTRUCTION\]/i, severity: 'low' },
|
||||
{ pattern: /\[ADMIN\]/i, severity: 'low' },
|
||||
{ pattern: /\[OVERRIDE\]/i, severity: 'low' },
|
||||
{ pattern: /<system>/i, severity: 'low' },
|
||||
{ pattern: /<instruction>/i, severity: 'low' },
|
||||
{ pattern: /<admin>/i, severity: 'low' },
|
||||
{ pattern: /<override>/i, severity: 'low' },
|
||||
];
|
||||
|
||||
detect(input: string): InjectionDetectionResult {
|
||||
const findings: InjectionFinding[] = [];
|
||||
let riskScore = 0;
|
||||
|
||||
for (const pattern of PromptInjectionDefender.INJECTION_PATTERNS) {
|
||||
for (const { pattern, severity } of PromptInjectionDefender.INJECTION_PATTERNS) {
|
||||
const matches = input.match(pattern);
|
||||
if (matches) {
|
||||
findings.push({
|
||||
pattern: pattern.source,
|
||||
matched: matches[0],
|
||||
severity: this.classifySeverity(pattern.source),
|
||||
severity,
|
||||
});
|
||||
riskScore += this.classifySeverity(pattern.source) === 'high' ? 3 :
|
||||
this.classifySeverity(pattern.source) === 'medium' ? 2 : 1;
|
||||
riskScore += severity === 'high' ? 5 : severity === 'medium' ? 3 : 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,21 +111,9 @@ export class PromptInjectionDefender {
|
||||
};
|
||||
}
|
||||
|
||||
private classifySeverity(pattern: string): 'low' | 'medium' | 'high' {
|
||||
const highRiskKeywords = ['ignore', 'forget', 'jailbreak', 'DAN', 'override', 'new\\s+instruction'];
|
||||
const mediumRiskKeywords = ['reveal', 'dump', 'pretend', 'act\\s+as'];
|
||||
|
||||
for (const kw of highRiskKeywords) {
|
||||
if (new RegExp(kw, 'i').test(pattern)) return 'high';
|
||||
}
|
||||
for (const kw of mediumRiskKeywords) {
|
||||
if (new RegExp(kw, 'i').test(pattern)) return 'medium';
|
||||
}
|
||||
return 'low';
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理输入内容(移除明显的注入分隔符)
|
||||
* v0.2.0: 增加中文注入标记清理
|
||||
*/
|
||||
sanitize(input: string): string {
|
||||
let cleaned = input;
|
||||
@@ -90,6 +123,15 @@ export class PromptInjectionDefender {
|
||||
cleaned = cleaned.replace(/<{3,}(system|instruction|prompt).*$/gim, '[REMOVED]');
|
||||
cleaned = cleaned.replace(/\[{3,}(system|instruction|prompt).*$/gim, '[REMOVED]');
|
||||
|
||||
// v0.2.0: 移除间接注入标记
|
||||
cleaned = cleaned.replace(/\[(SYSTEM|INSTRUCTION|ADMIN|OVERRIDE)\]/gi, '[REMOVED]');
|
||||
cleaned = cleaned.replace(/<(system|instruction|admin|override)>/gi, '[REMOVED]');
|
||||
|
||||
// v0.2.0: 移除编码注入
|
||||
cleaned = cleaned.replace(/\\x[0-9a-f]{2}/gi, '[REMOVED]');
|
||||
cleaned = cleaned.replace(/\\u[0-9a-f]{4}/gi, '[REMOVED]');
|
||||
cleaned = cleaned.replace(/eval\s*\(\s*atob\s*\([^)]*\)\s*\)/gi, '[REMOVED]');
|
||||
|
||||
return cleaned.trim();
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,6 @@ const MAX_RESULT_CHARS = 50_000;
|
||||
|
||||
export class ToolRegistry {
|
||||
private tools = new Map<string, ToolRegistryEntry>();
|
||||
private disabledTools = new Set<string>();
|
||||
|
||||
/** 注册内置工具 */
|
||||
registerBuiltin(tool: IMetonaTool): void {
|
||||
@@ -51,14 +50,13 @@ export class ToolRegistry {
|
||||
get(name: string): IMetonaTool | undefined {
|
||||
const entry = this.tools.get(name);
|
||||
if (!entry?.enabled) return undefined;
|
||||
if (this.disabledTools.has(name)) return undefined;
|
||||
return entry.tool;
|
||||
}
|
||||
|
||||
/** 列出所有已启用工具的定义 */
|
||||
listTools(): MetonaToolDef[] {
|
||||
return Array.from(this.tools.values())
|
||||
.filter((e) => e.enabled && !this.disabledTools.has(e.tool.definition.name))
|
||||
.filter((e) => e.enabled)
|
||||
.map((e) => e.tool.definition);
|
||||
}
|
||||
|
||||
@@ -66,16 +64,15 @@ export class ToolRegistry {
|
||||
listAllTools(): Array<MetonaToolDef & { enabled: boolean }> {
|
||||
return Array.from(this.tools.values()).map((e) => ({
|
||||
...e.tool.definition,
|
||||
enabled: e.enabled && !this.disabledTools.has(e.tool.definition.name),
|
||||
enabled: e.enabled,
|
||||
}));
|
||||
}
|
||||
|
||||
/** 设置工具启用/禁用状态(供 IPC tools:toggle 调用) */
|
||||
setToolEnabled(name: string, enabled: boolean): void {
|
||||
if (enabled) {
|
||||
this.disabledTools.delete(name);
|
||||
} else {
|
||||
this.disabledTools.add(name);
|
||||
const entry = this.tools.get(name);
|
||||
if (entry) {
|
||||
entry.enabled = enabled;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,6 +148,6 @@ export class ToolRegistry {
|
||||
|
||||
/** 获取工具数量 */
|
||||
get size(): number {
|
||||
return Array.from(this.tools.values()).filter((e) => e.enabled && !this.disabledTools.has(e.tool.definition.name)).length;
|
||||
return Array.from(this.tools.values()).filter((e) => e.enabled).length;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,8 +84,6 @@ export enum MetonaStreamEventType {
|
||||
TOOL_CALL_DELTA = 'tool_call_delta',
|
||||
TOOL_CALL_COMPLETE = 'tool_call_complete',
|
||||
TOOL_RESULT = 'tool_result',
|
||||
THINKING_START = 'thinking_start',
|
||||
THINKING_END = 'thinking_end',
|
||||
ERROR = 'error',
|
||||
DONE = 'done',
|
||||
USAGE = 'usage',
|
||||
@@ -99,6 +97,8 @@ export interface MetonaStreamEvent {
|
||||
/** 序列号 */
|
||||
seq: number;
|
||||
timestamp: number;
|
||||
/** 当前 run 的唯一标识(前端用于过滤旧流事件,abort 后重发场景) */
|
||||
runId?: string;
|
||||
|
||||
/** 根据 type 使用不同字段 */
|
||||
/** TEXT_DELTA / REASONING_DELTA */
|
||||
|
||||
+173
-1
@@ -22,6 +22,8 @@ import type { MemoryManager } from '../harness/memory/manager';
|
||||
import type { MCPManager } from '../services/mcp-manager.service';
|
||||
import type { PromptInjectionDefender } from '../harness/security/prompt-injection-defense';
|
||||
import type { OutputValidator } from '../harness/verification/output-validator';
|
||||
import type { ConfirmationHook } from '../harness/hooks/confirmation-hook';
|
||||
import type { MemoryConsolidator } from '../harness/memory/consolidator';
|
||||
import type { MetonaMessage, MetonaStreamEvent } from '../harness/types';
|
||||
import { MetonaErrorCode, MetonaStreamEventType } from '../harness/types';
|
||||
import type { MetonaError } from '../harness/types';
|
||||
@@ -45,6 +47,8 @@ export function registerAllIPCHandlers(
|
||||
reloadAdapter: () => void,
|
||||
promptInjectionDefender: PromptInjectionDefender,
|
||||
outputValidator: OutputValidator,
|
||||
confirmationHook: ConfirmationHook,
|
||||
memoryConsolidator: MemoryConsolidator,
|
||||
): void {
|
||||
|
||||
// ===== Agent 交互 =====
|
||||
@@ -112,7 +116,7 @@ export function registerAllIPCHandlers(
|
||||
}
|
||||
};
|
||||
|
||||
const onStateChange = (data: { previous?: string; current?: string; sessionId?: string; iteration?: number; state?: string }) => {
|
||||
const onStateChange = (data: { previous?: string; current?: string; sessionId?: string; iteration?: number; state?: string; runId?: string }) => {
|
||||
// AGENT 层:记录状态转换
|
||||
if (data.previous) log.info(`[AGENT] State: ${data.previous} → ${data.current}`);
|
||||
// 转发到渲染进程(携带迭代号)
|
||||
@@ -121,8 +125,19 @@ export function registerAllIPCHandlers(
|
||||
}
|
||||
};
|
||||
|
||||
// M-6: 转发上下文压缩事件为 toast 通知
|
||||
const onCompressed = (data: { iteration?: number; originalTokens?: number; compressedTokens?: number }) => {
|
||||
if (!mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('toast:show', {
|
||||
type: 'info',
|
||||
message: `上下文压缩: ${data.originalTokens ?? '?'} → ${data.compressedTokens ?? '?'} tokens`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
agentLoop.on('streamEvent', onStreamEvent);
|
||||
agentLoop.on('stateChange', onStateChange);
|
||||
agentLoop.on('compressed', onCompressed);
|
||||
|
||||
try {
|
||||
// 提示注入检测(安全模块)
|
||||
@@ -218,6 +233,26 @@ export function registerAllIPCHandlers(
|
||||
// 更新 MEMORY.md 时间戳
|
||||
workspaceService.updateMemoryTimestamp();
|
||||
|
||||
// 会话结束:AI 判断本次对话有哪些重要内容需要持久化到 MEMORY.md
|
||||
// 异步执行,不阻塞主流程返回;失败仅记录日志
|
||||
memoryConsolidator
|
||||
.consolidate(userMessage.content, output.finalAnswer, output.iterations)
|
||||
.then((result) => {
|
||||
if (result.appended > 0) {
|
||||
log.info(`[AGENT] Memory consolidated: ${result.appended} entries appended to MEMORY.md`);
|
||||
// 通知渲染进程记忆已更新
|
||||
if (!mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('toast:show', {
|
||||
type: 'info',
|
||||
message: `AI 已将 ${result.appended} 条重要记忆写入 MEMORY.md`,
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
log.warn('[AGENT] Memory consolidation failed:', err);
|
||||
});
|
||||
|
||||
// TOOL 层:记录会话结束
|
||||
auditService.logSessionEnd({
|
||||
sessionId,
|
||||
@@ -282,6 +317,7 @@ export function registerAllIPCHandlers(
|
||||
} finally {
|
||||
agentLoop.off('streamEvent', onStreamEvent);
|
||||
agentLoop.off('stateChange', onStateChange);
|
||||
agentLoop.off('compressed', onCompressed);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -628,5 +664,141 @@ export function registerAllIPCHandlers(
|
||||
}
|
||||
});
|
||||
|
||||
// ===== v0.2.0: 工具确认响应(ConfirmationDialog → 主进程)=====
|
||||
// 使用 ipcMain.on(渲染进程通过 ipcRenderer.send 发送)
|
||||
ipcMain.on('tool:confirmationResponse', (_event, data: { toolCallId: string; approved: boolean; remember: boolean; autoExecute?: boolean }) => {
|
||||
confirmationHook.resolveConfirmation(data.toolCallId, data.approved, data.remember, data.autoExecute ?? false);
|
||||
log.info(`[CONFIRM] Tool ${data.toolCallId} ${data.approved ? 'approved' : 'denied'}${data.remember ? ' (remembered)' : ''}${data.autoExecute ? ' (autoExecute)' : ''}`);
|
||||
});
|
||||
|
||||
// ===== v0.2.0: 持久化自动执行设置 =====
|
||||
ipcMain.handle('tool:setAutoExecute', async (_event, toolName: string, enabled: boolean) => {
|
||||
try {
|
||||
confirmationHook.setAutoExecute(toolName, enabled);
|
||||
log.info(`[CONFIRM] Tool ${toolName} autoExecute set to ${enabled}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('tool:getAutoExecuteList', async () => {
|
||||
return { success: true, data: confirmationHook.getAutoExecuteList() };
|
||||
});
|
||||
|
||||
// ===== v0.2.0: 审计日志链式哈希验证 =====
|
||||
ipcMain.handle('audit:verifyChain', async () => {
|
||||
try {
|
||||
const result = auditService.verifyChain();
|
||||
log.info(`[AUDIT] Chain verification: ${result.valid ? 'valid' : 'TAMPERED'} (${result.verifiedRecords}/${result.totalRecords})`);
|
||||
return { success: true, ...result };
|
||||
} catch (error) {
|
||||
log.error('[AUDIT] Chain verification failed:', error);
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('audit:query', async (_event, filters?: { sessionId?: string; eventType?: string; limit?: number }) => {
|
||||
try {
|
||||
return { success: true, data: auditService.query(filters as Parameters<typeof auditService.query>[0]) };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// ===== v0.2.0: 任务管理 IPC(TaskList UI)=====
|
||||
ipcMain.handle('tasks:list', async (_event, sessionId?: string) => {
|
||||
const db = sessionService.getDB();
|
||||
let sql = 'SELECT * FROM tasks';
|
||||
const params: unknown[] = [];
|
||||
if (sessionId) {
|
||||
sql += ' WHERE session_id = ?';
|
||||
params.push(sessionId);
|
||||
}
|
||||
sql += ' ORDER BY order_idx ASC, created_at ASC';
|
||||
return { success: true, data: db.prepare(sql).all(...params) };
|
||||
});
|
||||
|
||||
ipcMain.handle('tasks:create', async (_event, data: { sessionId: string; title: string; description?: string; priority?: string; parentId?: string }) => {
|
||||
const db = sessionService.getDB();
|
||||
const id = `task_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
try {
|
||||
db.prepare(`
|
||||
INSERT INTO tasks (id, session_id, title, description, status, priority, parent_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'pending', ?, ?, ?, ?)
|
||||
`).run(id, data.sessionId, data.title, data.description ?? '', data.priority ?? 'medium', data.parentId ?? null, Date.now(), Date.now());
|
||||
return { success: true, id };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('tasks:update', async (_event, id: string, updates: { title?: string; description?: string; status?: string; priority?: string; assignedTo?: string }) => {
|
||||
const db = sessionService.getDB();
|
||||
try {
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
if (updates.title !== undefined) { fields.push('title = ?'); values.push(updates.title); }
|
||||
if (updates.description !== undefined) { fields.push('description = ?'); values.push(updates.description); }
|
||||
if (updates.status !== undefined) { fields.push('status = ?'); values.push(updates.status); }
|
||||
if (updates.priority !== undefined) { fields.push('priority = ?'); values.push(updates.priority); }
|
||||
if (updates.assignedTo !== undefined) { fields.push('assigned_to = ?'); values.push(updates.assignedTo); }
|
||||
if (fields.length === 0) return { success: true };
|
||||
fields.push('updated_at = ?'); values.push(Date.now());
|
||||
if (updates.status === 'completed') { fields.push('completed_at = ?'); values.push(Date.now()); }
|
||||
values.push(id);
|
||||
db.prepare(`UPDATE tasks SET ${fields.join(', ')} WHERE id = ?`).run(...values);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('tasks:delete', async (_event, id: string) => {
|
||||
const db = sessionService.getDB();
|
||||
try {
|
||||
db.prepare('DELETE FROM tasks WHERE id = ?').run(id);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// ===== v0.2.0: 记忆系统增强查询(Memory Viewer UI)=====
|
||||
ipcMain.handle('memory:listAll', async (_event, options?: { type?: string; limit?: number }) => {
|
||||
const db = sessionService.getDB();
|
||||
const limit = options?.limit ?? 100;
|
||||
const type = options?.type;
|
||||
const results: Record<string, unknown[]> = {};
|
||||
try {
|
||||
if (!type || type === 'episodic') {
|
||||
const rows = db.prepare('SELECT * FROM episodic_memories ORDER BY created_at DESC LIMIT ?').all(limit) as Array<Record<string, unknown>>;
|
||||
results.episodic = rows.map((r) => ({ ...r, type: 'episodic', content: r.content ?? '' }));
|
||||
}
|
||||
if (!type || type === 'semantic') {
|
||||
const rows = db.prepare('SELECT * FROM semantic_memories ORDER BY updated_at DESC LIMIT ?').all(limit) as Array<Record<string, unknown>>;
|
||||
results.semantic = rows.map((r) => ({ ...r, type: 'semantic', content: r.value ?? r.key ?? '', importance: r.confidence ?? 0, created_at: r.created_at ?? r.updated_at }));
|
||||
}
|
||||
if (!type || type === 'working') {
|
||||
const rows = db.prepare('SELECT * FROM working_memories ORDER BY updated_at DESC LIMIT ?').all(limit) as Array<Record<string, unknown>>;
|
||||
results.working = rows.map((r) => ({ ...r, type: 'working', content: r.value ?? r.key ?? '', importance: 0.5, created_at: r.updated_at ?? Date.now() }));
|
||||
}
|
||||
return { success: true, data: results };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('memory:delete', async (_event, type: string, id: string) => {
|
||||
const db = sessionService.getDB();
|
||||
try {
|
||||
const table = type === 'episodic' ? 'episodic_memories' : type === 'semantic' ? 'semantic_memories' : 'working_memories';
|
||||
db.prepare(`DELETE FROM ${table} WHERE id = ?`).run(id);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
log.info('[SYS] All IPC handlers registered');
|
||||
}
|
||||
|
||||
+70
-11
@@ -30,6 +30,7 @@ import { WindowManager } from './services/window-manager.service';
|
||||
import { MCPManager } from './services/mcp-manager.service';
|
||||
import { ContextBuilder } from './harness/prompts/context-builder';
|
||||
import { MemoryManager } from './harness/memory/manager';
|
||||
import { MemoryConsolidator } from './harness/memory/consolidator';
|
||||
import { registerAllIPCHandlers } from './ipc/handlers';
|
||||
import { AgentLoopEngine } from './harness/agent-loop';
|
||||
import { ToolRegistry } from './harness/tools/registry';
|
||||
@@ -43,8 +44,14 @@ import {
|
||||
RunCommandTool,
|
||||
WebBrowserTool, cleanupBrowser,
|
||||
DelegateTaskTool,
|
||||
// v0.2.0 新增工具
|
||||
FileEditorTool,
|
||||
CodeSearchTool,
|
||||
TaskManagerTool,
|
||||
DiffViewerTool,
|
||||
} from './harness/tools/built-in';
|
||||
import { AuditLogHook, MemoryTriggerHook, PermissionCheckHook, RateLimitHook } from './harness/hooks';
|
||||
import { ConfirmationHook } from './harness/hooks/confirmation-hook';
|
||||
import { PolicyEngine } from './harness/sandbox/permissions';
|
||||
import { SandboxManager } from './harness/sandbox/sandbox';
|
||||
import { PromptInjectionDefender } from './harness/security/prompt-injection-defense';
|
||||
@@ -150,6 +157,19 @@ async function initialize(): Promise<void> {
|
||||
// ===== 步骤 5: 工作空间文件 + System Prompt =====
|
||||
const contextBuilder = new ContextBuilder();
|
||||
|
||||
// ===== v0.2.0: 安全模块(必须在工具注册之前,便于 RunCommandTool 注入 SandboxManager)=====
|
||||
const policyEngine = new PolicyEngine();
|
||||
const sandboxManager = new SandboxManager({
|
||||
allowedPaths: [workspaceInfo.path],
|
||||
networkPolicy: 'allowlist',
|
||||
});
|
||||
const promptDefender = new PromptInjectionDefender();
|
||||
const outputValidator = new OutputValidator();
|
||||
|
||||
// v0.2.0: ConfirmationHook(提前创建,mainWindow 创建后再注入)
|
||||
// 注入 ConfigService 以支持持久化自动执行设置
|
||||
const confirmationHook = new ConfirmationHook(null, configService);
|
||||
|
||||
// ===== 步骤 6: 注册内置工具 =====
|
||||
const toolRegistry = new ToolRegistry();
|
||||
toolRegistry.registerBuiltin(new ReadFileTool());
|
||||
@@ -157,6 +177,11 @@ async function initialize(): Promise<void> {
|
||||
toolRegistry.registerBuiltin(new ListDirectoryTool());
|
||||
toolRegistry.registerBuiltin(new SearchFilesTool());
|
||||
|
||||
// v0.2.0: 新增文件工具
|
||||
toolRegistry.registerBuiltin(new FileEditorTool());
|
||||
toolRegistry.registerBuiltin(new CodeSearchTool());
|
||||
toolRegistry.registerBuiltin(new DiffViewerTool());
|
||||
|
||||
// WebFetchTool 先于 WebSearchTool 构造,注入为依赖
|
||||
const webFetchTool = new WebFetchTool();
|
||||
toolRegistry.registerBuiltin(webFetchTool);
|
||||
@@ -164,7 +189,14 @@ async function initialize(): Promise<void> {
|
||||
|
||||
toolRegistry.registerBuiltin(new MemoryStoreTool(memoryManager));
|
||||
toolRegistry.registerBuiltin(new MemorySearchTool(memoryManager));
|
||||
toolRegistry.registerBuiltin(new RunCommandTool());
|
||||
|
||||
// v0.2.0: RunCommandTool 注入 SandboxManager
|
||||
const runCommandTool = new RunCommandTool();
|
||||
runCommandTool.setSandboxManager(sandboxManager);
|
||||
toolRegistry.registerBuiltin(runCommandTool);
|
||||
|
||||
// v0.2.0: 任务管理工具
|
||||
toolRegistry.registerBuiltin(new TaskManagerTool(() => db));
|
||||
|
||||
// 注册 Web Browser 统一浏览器工具
|
||||
toolRegistry.registerBuiltin(new WebBrowserTool());
|
||||
@@ -176,19 +208,13 @@ async function initialize(): Promise<void> {
|
||||
log.warn('MCP Manager initialization error:', err);
|
||||
});
|
||||
|
||||
// ===== 安全模块 =====
|
||||
const policyEngine = new PolicyEngine();
|
||||
const sandboxManager = new SandboxManager({
|
||||
allowedPaths: [workspaceInfo.path],
|
||||
networkPolicy: 'allowlist',
|
||||
});
|
||||
const promptDefender = new PromptInjectionDefender();
|
||||
const outputValidator = new OutputValidator();
|
||||
|
||||
// ===== Hooks =====
|
||||
// v0.2.0: ConfirmationHook 注入到 preToolHooks 管道
|
||||
// 注意:setToolDefs 延迟到 DelegateTaskTool 注册后调用,确保包含所有工具的风险等级
|
||||
const preToolHooks = [
|
||||
new PermissionCheckHook(policyEngine),
|
||||
new RateLimitHook(20),
|
||||
confirmationHook,
|
||||
];
|
||||
const postToolHooks = [
|
||||
new AuditLogHook(auditService),
|
||||
@@ -215,6 +241,9 @@ async function initialize(): Promise<void> {
|
||||
agentLoop.setTools(toolRegistry.listTools());
|
||||
agentLoop.setWorkspacePath(workspaceInfo.path);
|
||||
|
||||
// ===== Memory Consolidator(会话结束 AI 提取重要记忆到 MEMORY.md)=====
|
||||
const memoryConsolidator = new MemoryConsolidator(adapter, workspaceService);
|
||||
|
||||
// ===== Task Orchestrator(子任务委派)=====
|
||||
const orchestrator = new TaskOrchestrator(
|
||||
agentLoop, toolRegistry, preToolHooks, postToolHooks,
|
||||
@@ -227,12 +256,16 @@ async function initialize(): Promise<void> {
|
||||
toolRegistry.registerBuiltin(new DelegateTaskTool(orchestrator));
|
||||
// 重新设置工具列表,包含新注册的 delegate_task
|
||||
agentLoop.setTools(toolRegistry.listTools());
|
||||
// v0.2.0: 在所有工具(包括 DelegateTaskTool)注册完成后,刷新 ConfirmationHook 的工具定义缓存
|
||||
confirmationHook.setToolDefs(toolRegistry.listAllTools());
|
||||
|
||||
// ===== 热重载 Adapter 回调(设置变更时触发)=====
|
||||
let lastProvider = configService.get<string>('llm.provider') ?? '';
|
||||
const reloadAdapter = () => {
|
||||
try {
|
||||
const newAdapter = createAdapter();
|
||||
agentLoop.setAdapter(newAdapter);
|
||||
memoryConsolidator.setAdapter(newAdapter);
|
||||
// Provider 切换时同步 contextLength:仅 Ollama 使用 numCtx 作为有效上下文窗口
|
||||
const provider = configService.get<string>('llm.provider') ?? '';
|
||||
if (provider === 'ollama') {
|
||||
@@ -242,8 +275,30 @@ async function initialize(): Promise<void> {
|
||||
agentLoop.updateConfig({ contextLength: undefined });
|
||||
}
|
||||
log.info(`[CONFIG] Adapter reloaded: provider=${provider}`);
|
||||
// 通知渲染进程 Provider 已切换(UI 显示 Toast + 系统消息)
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
if (lastProvider && lastProvider !== provider) {
|
||||
mainWindow.webContents.send('agent:providerSwitched', {
|
||||
from: lastProvider,
|
||||
to: provider,
|
||||
reason: 'config_changed',
|
||||
});
|
||||
}
|
||||
mainWindow.webContents.send('toast:show', {
|
||||
type: 'success',
|
||||
message: `Provider 已切换: ${lastProvider || '未知'} → ${provider}`,
|
||||
});
|
||||
}
|
||||
lastProvider = provider;
|
||||
} catch (err) {
|
||||
log.error(`[CONFIG] Failed to reload adapter: ${(err as Error).message}`);
|
||||
// 通知渲染进程 Provider 切换失败(UI 显示错误 Toast)
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('toast:show', {
|
||||
type: 'error',
|
||||
message: `Provider 切换失败: ${(err as Error).message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -255,6 +310,9 @@ async function initialize(): Promise<void> {
|
||||
title: 'MetonaAI Desktop',
|
||||
});
|
||||
|
||||
// v0.2.0: 为 ConfirmationHook 注入主窗口(用于向渲染进程发送确认请求)
|
||||
confirmationHook.setMainWindow(mainWindow);
|
||||
|
||||
// ===== 系统托盘 =====
|
||||
const resourcesPath = join(__dirname, '../../assets');
|
||||
trayManager = new TrayManager(resourcesPath);
|
||||
@@ -267,7 +325,7 @@ async function initialize(): Promise<void> {
|
||||
agentLoop.on('stateChange', (data: { previous: string; current: string; state?: string }) => {
|
||||
const statusMap: Record<string, 'idle' | 'thinking' | 'executing' | 'error'> = {
|
||||
INIT: 'idle', THINKING: 'thinking', PARSING: 'thinking',
|
||||
EXECUTING: 'executing', OBSERVING: 'executing', REFLECTING: 'thinking',
|
||||
EXECUTING: 'executing', OBSERVING: 'thinking', REFLECTING: 'thinking',
|
||||
COMPRESSING: 'thinking', TERMINATED: 'idle',
|
||||
};
|
||||
const stateValue = (data.current || data.state) ?? '';
|
||||
@@ -289,6 +347,7 @@ async function initialize(): Promise<void> {
|
||||
contextBuilder, agentLoop, toolRegistry, auditService,
|
||||
sessionRecorder, memoryManager, mcpManager, createAdapter,
|
||||
promptDefender, outputValidator,
|
||||
confirmationHook, memoryConsolidator,
|
||||
);
|
||||
|
||||
// TODO: Initialize UpdateService for auto-update functionality
|
||||
|
||||
@@ -64,6 +64,41 @@ const metonaAPI = {
|
||||
// ===== 记忆系统 =====
|
||||
memory: {
|
||||
search: (query: string, options?: unknown) => ipcRenderer.invoke('db:searchMemories', query, options),
|
||||
listAll: (options?: { type?: string; limit?: number }) =>
|
||||
ipcRenderer.invoke('memory:listAll', options),
|
||||
delete: (type: string, id: string) => ipcRenderer.invoke('memory:delete', type, id),
|
||||
},
|
||||
|
||||
// ===== v0.2.0: 任务管理 =====
|
||||
tasks: {
|
||||
list: (sessionId?: string) => ipcRenderer.invoke('tasks:list', sessionId),
|
||||
create: (data: { sessionId: string; title: string; description?: string; priority?: string; parentId?: string }) =>
|
||||
ipcRenderer.invoke('tasks:create', data),
|
||||
update: (id: string, updates: { title?: string; description?: string; status?: string; priority?: string; assignedTo?: string }) =>
|
||||
ipcRenderer.invoke('tasks:update', id, updates),
|
||||
delete: (id: string) => ipcRenderer.invoke('tasks:delete', id),
|
||||
},
|
||||
|
||||
// ===== v0.2.0: 审计日志 =====
|
||||
audit: {
|
||||
verifyChain: () => ipcRenderer.invoke('audit:verifyChain'),
|
||||
query: (filters?: { sessionId?: string; eventType?: string; limit?: number }) =>
|
||||
ipcRenderer.invoke('audit:query', filters),
|
||||
},
|
||||
|
||||
// ===== v0.2.0: 工具确认请求 =====
|
||||
tool: {
|
||||
onConfirmationRequest: (callback: (request: unknown) => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, data: unknown) => callback(data);
|
||||
ipcRenderer.on('tool:confirmationRequest', listener);
|
||||
return () => ipcRenderer.removeListener('tool:confirmationRequest', listener);
|
||||
},
|
||||
sendConfirmationResponse: (response: { toolCallId: string; approved: boolean; remember: boolean; autoExecute?: boolean }) =>
|
||||
ipcRenderer.send('tool:confirmationResponse', response),
|
||||
// v0.2.0: 持久化自动执行设置
|
||||
setAutoExecute: (toolName: string, enabled: boolean) =>
|
||||
ipcRenderer.invoke('tool:setAutoExecute', toolName, enabled),
|
||||
getAutoExecuteList: () => ipcRenderer.invoke('tool:getAutoExecuteList'),
|
||||
},
|
||||
|
||||
// ===== 配置 =====
|
||||
|
||||
@@ -6,12 +6,14 @@
|
||||
* 1. 所有重要操作必须记录
|
||||
* 2. 日志不可篡改(INSERT-ONLY)
|
||||
* 3. 支持按时间/类型/会话查询
|
||||
* 4. v0.2.0: 链式哈希防篡改 — 每条日志的 current_hash = SHA256(prev_hash + content)
|
||||
*
|
||||
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 日志分层
|
||||
* @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第十章
|
||||
*/
|
||||
|
||||
import type Database from 'better-sqlite3';
|
||||
import { createHash } from 'crypto';
|
||||
import log from 'electron-log';
|
||||
|
||||
export type AuditEventType = 'tool_call' | 'permission_check' | 'error' | 'llm_request' | 'llm_response' | 'session_start' | 'session_end' | 'config_change';
|
||||
@@ -33,24 +35,79 @@ export class AuditService {
|
||||
constructor(private getDB: () => Database.Database) {}
|
||||
|
||||
/**
|
||||
* 记录审计条目
|
||||
* 计算链式哈希
|
||||
* current_hash = SHA256(prev_hash + 所有内容字段)
|
||||
* v0.2.0: 纳入 iteration/outcome/durationMs 字段,使用 JSON.stringify 避免分隔符碰撞
|
||||
*/
|
||||
private computeHash(
|
||||
prevHash: string,
|
||||
entry: {
|
||||
sessionId: string; iteration: number | null; eventType: string;
|
||||
actor: string; target: string; details: string | null;
|
||||
outcome: string | null; durationMs: number | null; createdAt: number;
|
||||
},
|
||||
): string {
|
||||
// 使用 JSON.stringify 避免分隔符碰撞
|
||||
const content = JSON.stringify({
|
||||
prevHash,
|
||||
sessionId: entry.sessionId,
|
||||
iteration: entry.iteration,
|
||||
eventType: entry.eventType,
|
||||
actor: entry.actor,
|
||||
target: entry.target,
|
||||
details: entry.details,
|
||||
outcome: entry.outcome,
|
||||
durationMs: entry.durationMs,
|
||||
createdAt: entry.createdAt,
|
||||
});
|
||||
return createHash('sha256').update(content, 'utf-8').digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最后一条日志的 hash(用于链式哈希计算)
|
||||
*/
|
||||
private getLastHash(): string {
|
||||
const db = this.getDB();
|
||||
const row = db.prepare('SELECT current_hash FROM audit_logs ORDER BY id DESC LIMIT 1').get() as { current_hash: string | null } | undefined;
|
||||
return row?.current_hash ?? '0000000000000000000000000000000000000000000000000000000000000000';
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录审计条目(带链式哈希)
|
||||
*/
|
||||
log(entry: AuditEntry): void {
|
||||
try {
|
||||
const db = this.getDB();
|
||||
const now = Date.now();
|
||||
const detailsStr = entry.details ? JSON.stringify(entry.details) : null;
|
||||
const prevHash = this.getLastHash();
|
||||
const currentHash = this.computeHash(prevHash, {
|
||||
sessionId: entry.sessionId,
|
||||
iteration: entry.iteration ?? null,
|
||||
eventType: entry.eventType,
|
||||
actor: entry.actor,
|
||||
target: entry.target,
|
||||
details: detailsStr,
|
||||
outcome: entry.outcome ?? null,
|
||||
durationMs: entry.durationMs ?? null,
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO audit_logs (session_id, iteration, event_type, actor, target, details, outcome, duration_ms, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO audit_logs (session_id, iteration, event_type, actor, target, details, outcome, duration_ms, created_at, prev_hash, current_hash)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
entry.sessionId,
|
||||
entry.iteration ?? null,
|
||||
entry.eventType,
|
||||
entry.actor,
|
||||
entry.target,
|
||||
entry.details ? JSON.stringify(entry.details) : null,
|
||||
detailsStr,
|
||||
entry.outcome ?? null,
|
||||
entry.durationMs ?? null,
|
||||
Date.now(),
|
||||
now,
|
||||
prevHash,
|
||||
currentHash,
|
||||
);
|
||||
} catch (error) {
|
||||
// 审计日志写入失败不应影响主流程
|
||||
@@ -185,6 +242,8 @@ export class AuditService {
|
||||
outcome: string | null;
|
||||
duration_ms: number | null;
|
||||
created_at: number;
|
||||
prev_hash: string | null;
|
||||
current_hash: string | null;
|
||||
}> {
|
||||
const db = this.getDB();
|
||||
let sql = 'SELECT * FROM audit_logs WHERE 1=1';
|
||||
@@ -217,6 +276,57 @@ export class AuditService {
|
||||
outcome: string | null;
|
||||
duration_ms: number | null;
|
||||
created_at: number;
|
||||
prev_hash: string | null;
|
||||
current_hash: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.0: 验证链式哈希完整性
|
||||
*
|
||||
* 遍历所有审计日志,重新计算每条记录的 hash,与存储的 current_hash 对比。
|
||||
* 如果任何一条记录的 hash 不匹配,说明日志已被篡改。
|
||||
*
|
||||
* @returns 验证结果,包括是否通过、首个篡改位置的 ID
|
||||
*/
|
||||
verifyChain(): { valid: boolean; totalRecords: number; verifiedRecords: number; tamperedId: number | null } {
|
||||
const db = this.getDB();
|
||||
const rows = db.prepare('SELECT id, session_id, iteration, event_type, actor, target, details, outcome, duration_ms, created_at, prev_hash, current_hash FROM audit_logs ORDER BY id ASC').all() as Array<{
|
||||
id: number; session_id: string; iteration: number | null; event_type: string; actor: string;
|
||||
target: string; details: string | null; outcome: string | null; duration_ms: number | null;
|
||||
created_at: number; prev_hash: string | null; current_hash: string | null;
|
||||
}>;
|
||||
|
||||
let prevHash = '0000000000000000000000000000000000000000000000000000000000000000';
|
||||
let verified = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
// 跳过旧数据(current_hash 为 NULL,未回填哈希)
|
||||
if (row.current_hash === null) {
|
||||
prevHash = '0'.repeat(64);
|
||||
continue;
|
||||
}
|
||||
|
||||
const expectedHash = this.computeHash(prevHash, {
|
||||
sessionId: row.session_id,
|
||||
iteration: row.iteration,
|
||||
eventType: row.event_type,
|
||||
actor: row.actor,
|
||||
target: row.target,
|
||||
details: row.details,
|
||||
outcome: row.outcome,
|
||||
durationMs: row.duration_ms,
|
||||
createdAt: row.created_at,
|
||||
});
|
||||
|
||||
if (row.current_hash !== expectedHash) {
|
||||
return { valid: false, totalRecords: rows.length, verifiedRecords: verified, tamperedId: row.id };
|
||||
}
|
||||
|
||||
prevHash = row.current_hash;
|
||||
verified++;
|
||||
}
|
||||
|
||||
return { valid: true, totalRecords: rows.length, verifiedRecords: verified, tamperedId: null };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,7 +128,9 @@ export class DatabaseService {
|
||||
details TEXT,
|
||||
outcome TEXT,
|
||||
duration_ms INTEGER,
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
|
||||
prev_hash TEXT,
|
||||
current_hash TEXT
|
||||
);
|
||||
|
||||
-- ===== MCP 服务配置表 =====
|
||||
@@ -183,6 +185,24 @@ export class DatabaseService {
|
||||
UNIQUE(session_id, task_id, key)
|
||||
);
|
||||
|
||||
-- ===== v0.2.0: 任务表 =====
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'in_progress', 'completed', 'blocked', 'cancelled')),
|
||||
priority TEXT NOT NULL DEFAULT 'medium' CHECK(priority IN ('low', 'medium', 'high', 'critical')),
|
||||
parent_id TEXT,
|
||||
assigned_to TEXT,
|
||||
order_idx INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
|
||||
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
|
||||
completed_at INTEGER,
|
||||
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (parent_id) REFERENCES tasks(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- ===== 索引 =====
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_role ON messages(role);
|
||||
@@ -197,6 +217,9 @@ export class DatabaseService {
|
||||
CREATE INDEX IF NOT EXISTS idx_episodic_session ON episodic_memories(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_episodic_importance ON episodic_memories(importance DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_working_session_task ON working_memories(session_id, task_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_session ON tasks(session_id, order_idx);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(session_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_id);
|
||||
`);
|
||||
|
||||
// 审计日志防篡改触发器(INSERT-ONLY)
|
||||
@@ -246,6 +269,28 @@ export class DatabaseService {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// v0.2.0 迁移 3: audit_logs 表添加 prev_hash 列(链式哈希)
|
||||
try {
|
||||
db.exec('ALTER TABLE audit_logs ADD COLUMN prev_hash TEXT');
|
||||
log.info('[DB] Migration: added prev_hash column to audit_logs');
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
if (!msg.includes('duplicate column')) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// v0.2.0 迁移 4: audit_logs 表添加 current_hash 列(链式哈希)
|
||||
try {
|
||||
db.exec('ALTER TABLE audit_logs ADD COLUMN current_hash TEXT');
|
||||
log.info('[DB] Migration: added current_hash column to audit_logs');
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
if (!msg.includes('duplicate column')) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user