feat: 升级至 v0.2.1 — 流式渲染修复、安全增强、工具自动执行

流式渲染修复:
- runId 机制防止 abort 后旧流事件污染新 run
- run lock 防止并发 run 污染引擎状态
- abort race 提前退出工具执行等待
- TERMINATED 状态通过 stateChange 发射
- tool_call_delta 流式参数拼接 + pending 占位替换
- 首轮卡片创建路径统一,traceStep 按 ID 精确匹配
- compressed 事件转发为 toast 通知

安全增强:
- ConfirmationHook 支持持久化自动执行(跨会话)
- 设置面板新增自动执行工具管理 UI
- SandboxManager 双重安全校验 fail-closed
- 审计日志链式哈希防篡改
- PromptInjectionDefender 中文注入标记清理
- scanCode 28 模式 + base64/$() 检测
- validatePath realpathSync 防符号链接逃逸
- code-search 使用 execFile 防命令注入

新增工具:
- file_editor、code_search、task_manager、diff_viewer

其他:
- Agent Loop 加 PARSING/REFLECTING 状态 + 指数退避重试
- MemoryManager TF-IDF 语义检索
- run_command Windows 中文编码修复(chcp 65001)
- 版本号 0.2.0 → 0.2.1
This commit is contained in:
thzxx
2026-07-12 12:54:52 +08:00
parent 469f53b623
commit 3c5aea8fb7
41 changed files with 4972 additions and 423 deletions
+246 -25
View File
@@ -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^attempt1s, 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', {
+222
View File
@@ -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();
}
}
+2 -2
View File
@@ -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);
}
}
}
+3 -2
View File
@@ -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) {
+277
View File
@@ -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) + '...';
}
}
+273 -9
View File
@@ -4,6 +4,11 @@
* 基于 SQLitebetter-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)) + 1Sklearn 风格平滑),确保非负
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),
});
}
}
+22 -2
View File
@@ -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,
};
+64 -10
View File
@@ -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;
}
}
+75 -4
View File
@@ -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;
}
/** 最长公共子序列(LCSdiff 算法 */
function computeDiff(oldLines: string[], newLines: string[]): DiffLine[] {
// 构建 LCS 表(限制内存:大文件截断到 5000 行)
const maxLines = 5000;
const oldSliced = oldLines.slice(0, maxLines);
const newSliced = newLines.slice(0, maxLines);
const sm = oldSliced.length;
const sn = newSliced.length;
// D4.1: 使用 Uint32Array 一维数组替代 number[][],节省内存
const lcs = new Uint32Array((sm + 1) * (sn + 1));
const idx = (i: number, j: number): number => i * (sn + 1) + j;
for (let i = 1; i <= sm; i++) {
for (let j = 1; j <= sn; j++) {
if (oldSliced[i - 1] === newSliced[j - 1]) {
lcs[idx(i, j)] = lcs[idx(i - 1, j - 1)] + 1;
} else {
lcs[idx(i, j)] = Math.max(lcs[idx(i - 1, j)], lcs[idx(i, j - 1)]);
}
}
}
// 回溯生成 diff
const result: DiffLine[] = [];
let i = sm, j = sn;
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && oldSliced[i - 1] === newSliced[j - 1]) {
result.unshift({ type: 'context', oldLineNo: i, newLineNo: j, content: oldSliced[i - 1] });
i--; j--;
} else if (j > 0 && (i === 0 || lcs[idx(i, j - 1)] >= lcs[idx(i - 1, j)])) {
result.unshift({ type: 'added', oldLineNo: null, newLineNo: j, content: newSliced[j - 1] });
j--;
} else {
result.unshift({ type: 'removed', oldLineNo: i, newLineNo: null, content: oldSliced[i - 1] });
i--;
}
}
return result;
}
/** 生成 unified diff 格式字符串 */
function formatUnifiedDiff(diffLines: DiffLine[], oldLabel: string, newLabel: string, contextLines: number = 3): string {
const lines: string[] = [];
lines.push(`--- ${oldLabel}`);
lines.push(`+++ ${newLabel}`);
let oldLine = 1;
let newLine = 1;
let hunkLines: string[] = [];
let hunkStartOld = 1;
let hunkStartNew = 1;
let inHunk = false;
let contextSinceChange = 0;
const flushHunk = () => {
if (hunkLines.length > 0) {
// 移除尾部多余的 context 行
const trimmed: string[] = [...hunkLines];
while (trimmed.length > 0 && trimmed[trimmed.length - 1].startsWith(' ') && contextSinceChange > 0) {
trimmed.pop();
contextSinceChange--;
}
if (trimmed.length > 0) {
const oldCount = trimmed.filter((l) => l.startsWith('-') || l.startsWith(' ')).length;
const newCount = trimmed.filter((l) => l.startsWith('+') || l.startsWith(' ')).length;
lines.push(`@@ -${hunkStartOld},${oldCount} +${hunkStartNew},${newCount} @@`);
lines.push(...trimmed);
}
}
hunkLines = [];
inHunk = false;
contextSinceChange = 0;
};
for (const dl of diffLines) {
if (dl.type === 'context') {
if (inHunk) {
hunkLines.push(` ${dl.content}`);
contextSinceChange++;
// 超过 contextLines 行连续 context,结束当前 hunk
if (contextSinceChange > contextLines) {
flushHunk();
}
}
oldLine++;
newLine++;
} else if (dl.type === 'added') {
if (!inHunk) {
inHunk = true;
hunkStartOld = oldLine;
hunkStartNew = newLine;
}
hunkLines.push(`+${dl.content}`);
contextSinceChange = 0;
newLine++;
} else if (dl.type === 'removed') {
if (!inHunk) {
inHunk = true;
hunkStartOld = oldLine;
hunkStartNew = newLine;
}
hunkLines.push(`-${dl.content}`);
contextSinceChange = 0;
oldLine++;
}
}
flushHunk();
return lines.join('\n');
}
export class DiffViewerTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'diff_viewer',
description: 'Compare two files or two text snippets and show differences. Generates unified diff format output. Useful for reviewing changes before applying or comparing configurations.',
parameters: {
type: 'object',
properties: {
mode: {
type: 'string',
description: 'Comparison mode',
enum: ['files', 'text'],
},
file_a: { type: 'string', description: 'First file path (for files mode)' },
file_b: { type: 'string', description: 'Second file path (for files mode)' },
text_a: { type: 'string', description: 'First text content (for text mode)' },
text_b: { type: 'string', description: 'Second text content (for text mode)' },
context_lines: { type: 'number', description: 'Context lines around changes (default 3, max 10)' },
},
required: ['mode'],
},
category: MetonaToolCategory.FILESYSTEM,
riskLevel: MetonaRiskLevel.SAFE,
requiresPermission: false,
timeoutMs: 15_000,
};
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
try {
const mode = args.mode as 'files' | 'text';
const contextLines = Math.min(10, Math.max(0, (args.context_lines as number) ?? 3));
// D4.5: 校验 mode
if (mode !== 'files' && mode !== 'text') {
return { success: false, error: `Invalid mode: ${mode}. Must be 'files' or 'text'` };
}
let contentA: string;
let contentB: string;
let labelA: string;
let labelB: string;
if (mode === 'files') {
const fileA = args.file_a as string;
const fileB = args.file_b as string;
if (!fileA || !fileB) {
return { success: false, error: 'file_a and file_b are required for files mode' };
}
// 安全校验
const pathA = resolve(context.workspacePath, fileA);
const pathB = resolve(context.workspacePath, fileB);
if (!isPathWithinWorkspace(fileA, context.workspacePath) || !isPathWithinWorkspace(fileB, context.workspacePath)) {
return { success: false, error: 'Path traversal detected' };
}
if (isProtectedWorkspaceFile(fileA, context.workspacePath) || isProtectedWorkspaceFile(fileB, context.workspacePath)) {
return { success: false, error: 'Access denied: MEMORY.md is protected' };
}
try {
contentA = await readFile(pathA, 'utf-8');
contentB = await readFile(pathB, 'utf-8');
} catch (err) {
return { success: false, error: `Failed to read files: ${(err as Error).message}` };
}
labelA = fileA;
labelB = fileB;
} else {
contentA = (args.text_a as string) ?? '';
contentB = (args.text_b as string) ?? '';
labelA = 'text_a';
labelB = 'text_b';
}
const linesA = contentA.split('\n');
const linesB = contentB.split('\n');
const diff = computeDiff(linesA, linesB);
const unifiedDiff = formatUnifiedDiff(diff, labelA, labelB, contextLines);
const addedCount = diff.filter((d) => d.type === 'added').length;
const removedCount = diff.filter((d) => d.type === 'removed').length;
const contextCount = diff.filter((d) => d.type === 'context').length;
// D4.3: similarity 计算使用截断后的长度作为分母
const maxLines = 5000;
const oldSlicedLen = Math.min(linesA.length, maxLines);
const newSlicedLen = Math.min(linesB.length, maxLines);
const oldTruncated = linesA.length > maxLines;
const newTruncated = linesB.length > maxLines;
const truncated = oldTruncated || newTruncated;
// 生成摘要
const summary = {
files_compared: mode === 'files' ? 2 : 0,
lines_added: addedCount,
lines_removed: removedCount,
lines_unchanged: contextCount,
total_changes: addedCount + removedCount,
similarity: (oldSlicedLen + newSlicedLen) > 0
? Math.round((contextCount * 2 / (oldSlicedLen + newSlicedLen)) * 100) / 100
: 1,
truncated,
};
// D4.6: unifiedDiff 大小限制
const MAX_DIFF_CHARS = 50_000;
const truncatedDiff = unifiedDiff.length > MAX_DIFF_CHARS
? unifiedDiff.slice(0, MAX_DIFF_CHARS) + '\n... (diff truncated)'
: unifiedDiff;
return {
success: true,
summary,
diff: truncatedDiff,
diff_lines: diff.slice(0, 500),
};
} catch (err) {
return { success: false, error: (err as Error).message };
}
}
}
@@ -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 };
}
}
}
+12 -3
View File
@@ -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;
}
}
+24
View File
@@ -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),
};
}
}
+6 -9
View File
@@ -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;
}
}
+2 -2
View File
@@ -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
View File
@@ -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: 任务管理 IPCTaskList 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
View File
@@ -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
+35
View File
@@ -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'),
},
// ===== 配置 =====
+115 -5
View File
@@ -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 };
}
}
+46 -1
View File
@@ -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;
}
}
}
/**