diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
index 428a549..3546fce 100644
--- a/.gitea/workflows/ci.yml
+++ b/.gitea/workflows/ci.yml
@@ -1,13 +1,20 @@
# MetonaAI Desktop — Gitea Actions CI
#
# 质量门禁:类型检查 + Lint + 单元测试 + 产物编译验证
-# 触发:push(main/dev 分支)与 Pull Request
+# 触发:push(main/dev/master 分支)与 Pull Request
+#
+# v0.5.0 强化:
+# - typecheck 使用双工程真检查(tsconfig.node.json + tsconfig.web.json),
+# 修复此前 solution-style 根配置导致 `tsc --noEmit` 空操作、类型错误畅通提交的缺陷
+# - electron-test job 从 experimental(continue-on-error)转正为阻塞门禁
+# - 私有 npm 凭据通过 GITEA_NPM_AUTH secret 注入(需在仓库
+# Settings → Actions → Secrets 配置,值为 base64("用户名:密码"))
#
# 说明:
# - 主测试使用系统 Node(npm test)——better-sqlite3 原生模块按系统 Node ABI 编译,
# audit 套件在 ABI 不匹配时自动跳过(skipIf),保证 CI 稳定绿
-# - electron-test job 尝试按 Electron ABI 重建 better-sqlite3 后跑全量测试(含 audit 链式哈希),
-# 该 job 标记为 experimental(continue-on-error),失败不阻塞合并
+# - electron-test job 按 Electron ABI 重建 better-sqlite3 后跑全量测试
+# (含 audit 链式哈希 + 会话摘要分层),作为阻塞门禁
# - build 验证只跑 electron-vite build(产物编译),不跑 electron-builder 打包——
# NSIS 目标需要 wine,Linux runner 上不可用
@@ -22,6 +29,8 @@ on:
env:
# Electron 二进制国内镜像(与 .npmrc 注释保持一致,加速 runner 下载)
ELECTRON_MIRROR: https://npmmirror.com/mirrors/electron/
+ # 私有 npm 仓库认证(@metona-team/metona-toast)— 从仓库 Secrets 注入
+ GITEA_NPM_AUTH: ${{ secrets.GITEA_NPM_AUTH }}
jobs:
quality:
@@ -41,7 +50,8 @@ jobs:
- name: Install dependencies
run: npm ci
- - name: Typecheck
+ # v0.5.0: 双工程真检查(node + web),修复此前 solution-style 空操作缺陷
+ - name: Typecheck (node + web)
run: npm run typecheck
- name: Lint
@@ -51,10 +61,9 @@ jobs:
run: npm test
electron-test:
- name: 全量测试 (Electron ABI, experimental)
+ name: 全量测试 (Electron ABI)
runs-on: debian-latest
timeout-minutes: 25
- continue-on-error: true
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -71,6 +80,7 @@ jobs:
- name: Rebuild better-sqlite3 for Electron ABI
run: npx @electron/rebuild -f -w better-sqlite3
+ # v0.5.0: 从 experimental 转正 — 含 SQLite 审计链哈希与摘要分层的全量用例必须通过
- name: Full tests (Electron Node ABI)
run: npm run test:electron
diff --git a/.npmrc b/.npmrc
index dbf4943..bc4f12c 100644
--- a/.npmrc
+++ b/.npmrc
@@ -1,7 +1,11 @@
registry=https://registry.npmmirror.com
@metona-team:registry=https://git.metona.cn/api/packages/MetonaTeam/npm/
-//git.metona.cn/api/packages/MetonaTeam/npm/:_auth=dGh6eHg6Mjc5MjQ2MDE5MGJkcW4=
-//git.metona.cn/api/packages/MetonaTeam/npm/:always-auth=true
+
+# 私有 npm 仓库认证 — 凭据通过环境变量注入,禁止硬编码到仓库。
+# 值为 base64("用户名:密码"),在终端设置后再执行 npm install:
+# PowerShell: $env:GITEA_NPM_AUTH = "
-
+
@@ -29,7 +29,7 @@
---
- Metona 是一款基于 Electron 的本地优先 AI Agent 桌面应用,内置 ReAct 状态机驱动的智能体循环引擎、26 个内置工具、三层记忆系统、四层纵深安全防线与完整可观测性链路。支持六种 LLM Provider(DeepSeek / Agnes / MiMo / Ollama / OpenAI / Anthropic),兼容 MCP 协议扩展,为开发者提供开箱即用的 AI 编程伙伴。 + Metona 是一款基于 Electron 的本地优先 AI Agent 桌面应用,内置 ReAct 状态机驱动的智能体循环引擎、28 个内置工具、三层记忆系统、四层纵深安全防线与完整可观测性链路。支持六种 LLM Provider(DeepSeek / Agnes / MiMo / Ollama / OpenAI / Anthropic),兼容 MCP 协议扩展(stdio / SSE / Streamable HTTP),为开发者提供开箱即用的 AI 编程伙伴。
--- @@ -63,7 +63,7 @@ReAct 八状态闭环、流式对话 (SSE/NDJSON)、Thinking 推理模式、死循环检测、上下文自动压缩、指数退避重试
文件系统 · 代码搜索 · 网络搜索 · 浏览器自动化 · Git · Shell 命令 · HTTP 请求 · 记忆存储 · 任务管理
支持 stdio / SSE 两种传输方式的 MCP Server,动态发现工具,无需重启应用
+支持 stdio / SSE / Streamable HTTP 三种传输方式的 MCP Server,动态发现工具,无需重启应用
基于「生产级通用 AI Agent 桌面应用构建指南」+「Metona 内部 IR 标准」,定义完整的系统架构、27 个内置工具、4 个用户级磁盘文件、工作空间机制、数据库配置规范及全链路可追踪日志体系。
+基于「生产级通用 AI Agent 桌面应用构建指南」+「Metona 内部 IR 标准」,定义完整的系统架构、28 个内置工具、4 个用户级磁盘文件、工作空间机制、数据库配置规范及全链路可追踪日志体系。
MetonaAI-Desktop 是一个运行在用户本地桌面上的通用 AI Agent 应用。它以工作空间(Workspace)为基本组织单元, 通过 4 个 Markdown 磁盘文件 定义 Agent 的灵魂、行为、记忆和用户画像, - 提供 27 个内置工具 赋予 Agent 操作文件系统、网络、记忆和命令行的能力。 + 提供 28 个内置工具 赋予 Agent 操作文件系统、网络、记忆和命令行的能力。 全链路操作透明可追踪,所有决策过程、工具调用、LLM 推理记录在本地 SQLite 日志中。
@@ -301,7 +304,7 @@~/MetonaWorkspaces/default/)MEMORY.md 带元数据头).metona/agent.db,加载配置、恢复历史会话MEMORY.md(更新时间戳)和 .metona/agent.db 自动更新所有工具使用 Metona IR 的 MetonaToolDef / MetonaToolCall / MetonaToolResult 结构。内置在 Tool Registry 中,Adaper 为 LLM 生成 JSON Schema 格式的描述。
| 7 | memory_store | database | MEDIUM | 否 | 存储一条记忆到 SQLite |
| 8 | memory_search | database | SAFE | 否 | 检索记忆(关键词匹配) |
| 9 | run_command | code_execution | HIGH | 是 | 执行 Shell 命令,沙箱限制 |
| 完整 27 个工具列表详见 README.md「内置工具」章节。新增工具涵盖:file_editor、code_search、diff_viewer、web_browser、http_request、git_status/git_diff/git_log/git_commit、lint_code、run_tests、project_info、delegate_task、task_manager、todo_write、think、view_image。 | |||||
| 完整 28 个工具列表详见 README.md「内置工具」章节。新增工具涵盖:file_editor、code_search、diff_viewer、web_browser、http_request、git_status/git_diff/git_log/git_commit、lint_code、run_tests、project_info、delegate_task、task_manager、think、view_image、delete_file、file_move、file_info。 | |||||
- 4 个 .md 文件位于工作空间根目录,是工作空间的必需文件。
- 其中 SOUL.md、AGENTS.md、USERS.md 完全由用户自定义,MEMORY.md 由 Agent 维护但用户可编辑。
+ .md 文件位于工作空间根目录,是工作空间的必需文件。
+ 其中 SOUL.md 完全由用户自定义,MEMORY.md 由 Agent 维护但用户可编辑。
+ 当前实现为 2 个文件;原设计中的 AGENTS.md / USERS.md 已移除(职责并入 SOUL.md 与内置安全准则),下方相关小节保留为历史设计参考。
📁 实际表结构:v0.3.3 实际共 9 张表,包含:sessions、messages、app_config、audit_logs、mcp_servers、episodic_memories、semantic_memories、working_memories、tasks(新增任务表,用于 Agent 子任务管理)。
📁 实际表结构:当前实际共 10 张表,包含:sessions、messages、app_config、audit_logs、mcp_servers、episodic_memories、semantic_memories、working_memories、tasks(任务表,用于 Agent 子任务管理)、session_summaries(会话滚动摘要表,分层上下文加载)。
-- ====== database/schema.sql ======
-- ============================================
diff --git a/electron.vite.config.ts b/electron.vite.config.ts
index 13638d1..993d42f 100644
--- a/electron.vite.config.ts
+++ b/electron.vite.config.ts
@@ -42,6 +42,34 @@ export default defineConfig({
outDir: 'dist',
rollupOptions: {
input: { main: resolve(__dirname, 'index.html') },
+ output: {
+ // v0.5.0: 代码分割 — 此前全部打进单个 2.6MB chunk,首屏加载随业务代码线性膨胀。
+ // 拆出稳定的第三方 vendor chunk(缓存友好:业务代码变更不再使整个 bundle 失效)
+ manualChunks(id: string): string | undefined {
+ if (!id.includes('node_modules')) return undefined;
+ // React 运行时 + 状态管理
+ if (/[\\/]node_modules[\\/](react|react-dom|scheduler|zustand)[\\/]/.test(id)) {
+ return 'vendor-react';
+ }
+ // MUI 组件体系 + Emotion 样式引擎
+ if (id.includes('@mui') || id.includes('@emotion')) {
+ return 'vendor-mui';
+ }
+ // Markdown 渲染链(react-markdown/remark/rehype/micromark/highlight.js 等)
+ if (
+ /(react-markdown|remark-|rehype-|micromark|mdast-|unified|unist-|hast-|highlight\.js|lowlight|vfile|trough|bail)/.test(
+ id,
+ )
+ ) {
+ return 'vendor-markdown';
+ }
+ // 图标库(lucide 按需引入但仍占可观体积)
+ if (id.includes('lucide-react')) {
+ return 'vendor-icons';
+ }
+ return undefined;
+ },
+ },
},
},
},
diff --git a/electron/harness/hooks/__tests__/confirmation-hook.test.ts b/electron/harness/hooks/__tests__/confirmation-hook.test.ts
index 35b1d9e..27138c5 100644
--- a/electron/harness/hooks/__tests__/confirmation-hook.test.ts
+++ b/electron/harness/hooks/__tests__/confirmation-hook.test.ts
@@ -26,7 +26,7 @@ const SAFE_DEF: MetonaToolDef = {
name: 'read_file',
description: 'Read file (test fixture)',
parameters: { type: 'object', properties: {}, required: [] },
- category: MetonaToolCategory.FILE_SYSTEM,
+ category: MetonaToolCategory.FILESYSTEM,
riskLevel: MetonaRiskLevel.SAFE,
requiresPermission: false,
timeoutMs: 1_000,
@@ -37,7 +37,7 @@ const HIGH_RISK_DEF_2: MetonaToolDef = {
name: 'delete_file',
description: 'Delete file (test fixture)',
parameters: { type: 'object', properties: {}, required: [] },
- category: MetonaToolCategory.FILE_SYSTEM,
+ category: MetonaToolCategory.FILESYSTEM,
riskLevel: MetonaRiskLevel.HIGH,
requiresPermission: true,
timeoutMs: 1_000,
@@ -315,3 +315,110 @@ describe('ConfirmationHook — clearPending', () => {
expect(hook.getPendingConfirmations()).toHaveLength(0);
});
});
+
+describe('ConfirmationHook — 跨会话隔离(v0.5.0)', () => {
+ it('A 会话记住拒绝,B 会话同工具仍正常询问(产生新 pending)', async () => {
+ const hook = new ConfirmationHook(makeMockWindow(), null);
+ hook.setToolDefs([HIGH_RISK_DEF]);
+
+ // A 会话记住拒绝
+ const pA = hook.beforeExecute(makeToolCall(), 'sess-a');
+ hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, false, true, false);
+ expect((await pA).blocked).toBe(true);
+
+ // B 会话同工具 — 不受 A 会话拒绝记忆影响,进入询问流程(产生新 pending)
+ const pB = hook.beforeExecute(makeToolCall(), 'sess-b');
+ const pendingB = hook.getPendingConfirmations();
+ expect(pendingB).toHaveLength(1);
+ hook.resolveConfirmation(pendingB[0].toolCallId, true, false, false);
+ expect((await pB).blocked).toBe(false);
+
+ // A 会话的拒绝记忆仍在(getRememberedDenials 限定 A 会话可见)
+ expect(hook.getRememberedDenials('sess-a')).toHaveLength(1);
+ expect(hook.getRememberedDenials('sess-b')).toHaveLength(0);
+ });
+
+ it('A 会话记住批准,B 会话同工具仍需确认', async () => {
+ const hook = new ConfirmationHook(makeMockWindow(), null);
+ hook.setToolDefs([HIGH_RISK_DEF]);
+
+ // A 会话记住批准
+ const pA = hook.beforeExecute(makeToolCall(), 'sess-a');
+ hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, true, true, false);
+ expect((await pA).blocked).toBe(false);
+
+ // A 会话后续调用直接放行(无新 pending)
+ const again = await hook.beforeExecute(makeToolCall(), 'sess-a');
+ expect(again.blocked).toBe(false);
+ expect(hook.getPendingConfirmations()).toHaveLength(0);
+
+ // B 会话同工具 — 进入询问流程(批准记忆不跨会话共享)
+ const pB = hook.beforeExecute(makeToolCall(), 'sess-b');
+ expect(hook.getPendingConfirmations()).toHaveLength(1);
+ hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, true, false, false);
+ expect((await pB).blocked).toBe(false);
+ });
+
+ it('中断 A 会话(clearPending 按会话)不影响 B 会话等待中的确认', async () => {
+ const hook = new ConfirmationHook(makeMockWindow(), null);
+ hook.setToolDefs([HIGH_RISK_DEF]);
+
+ // 两个会话各有一个等待中的确认
+ const pA = hook.beforeExecute(makeToolCall(), 'sess-a');
+ const pB = hook.beforeExecute(makeToolCall(), 'sess-b');
+ expect(hook.getPendingConfirmations()).toHaveLength(2);
+
+ // 中断 A 会话 — 只拒绝 A 的 pending
+ hook.clearPending('sess-a');
+ expect((await pA).blocked).toBe(true);
+
+ // B 会话的 pending 仍在等待,用户批准后正常放行
+ const remaining = hook.getPendingConfirmations();
+ expect(remaining).toHaveLength(1);
+ expect(remaining[0].toolCallId).toBeDefined();
+ hook.resolveConfirmation(remaining[0].toolCallId, true, false, false);
+ expect((await pB).blocked).toBe(false);
+ });
+
+ it('resetRememberedDenial 指定会话时只重置该会话的拒绝记忆', async () => {
+ const hook = new ConfirmationHook(makeMockWindow(), null);
+ hook.setToolDefs([HIGH_RISK_DEF]);
+
+ // A、B 两个会话都记住拒绝
+ const pA = hook.beforeExecute(makeToolCall(), 'sess-a');
+ hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, false, true, false);
+ await pA;
+ const pB = hook.beforeExecute(makeToolCall(), 'sess-b');
+ hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, false, true, false);
+ await pB;
+
+ // 重置 A 会话的拒绝记忆 — B 会话的记忆保留
+ expect(hook.resetRememberedDenial('run_command', 'sess-a')).toBe(true);
+ expect(hook.getRememberedDenials('sess-a')).toHaveLength(0);
+ expect(hook.getRememberedDenials('sess-b')).toHaveLength(1);
+
+ // 缺省 sessionId — 重置所有会话
+ expect(hook.resetRememberedDenial('run_command')).toBe(true);
+ expect(hook.getRememberedDenials()).toHaveLength(0);
+ });
+
+ it('setAutoExecute 启用时清除所有会话的拒绝记忆(全局设置优先)', async () => {
+ const hook = new ConfirmationHook(makeMockWindow(), null);
+ hook.setToolDefs([HIGH_RISK_DEF]);
+
+ // 两个会话都记住拒绝
+ for (const sid of ['sess-a', 'sess-b']) {
+ const p = hook.beforeExecute(makeToolCall(), sid);
+ hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, false, true, false);
+ await p;
+ }
+ expect(hook.getRememberedDenials()).toHaveLength(1); // 聚合去重后同一工具 1 条
+
+ // 启用自动执行 — 所有会话的拒绝记忆被清除
+ hook.setAutoExecute('run_command', true);
+ expect(hook.getRememberedDenials()).toHaveLength(0);
+ // 自动执行放行(任意会话)
+ expect((await hook.beforeExecute(makeToolCall(), 'sess-a')).blocked).toBe(false);
+ expect((await hook.beforeExecute(makeToolCall(), 'sess-b')).blocked).toBe(false);
+ });
+});
diff --git a/electron/harness/hooks/confirmation-hook.ts b/electron/harness/hooks/confirmation-hook.ts
index 8916bd5..7971cdf 100644
--- a/electron/harness/hooks/confirmation-hook.ts
+++ b/electron/harness/hooks/confirmation-hook.ts
@@ -40,8 +40,14 @@ export class ConfirmationHook implements PreToolHook {
/** 工具定义缓存(由外部设置) */
private toolDefs = new Map();
- /** 用户选择记忆(同一会话内不再重复询问)— v0.4.1: 值扩展为 { approved, at } 以支持拒绝记忆 TTL */
- private rememberedDecisions = new Map();
+ /**
+ * 用户选择记忆(sessionId → toolName → 决策)
+ *
+ * v0.5.0: 从全局 Map 改为按会话隔离的两级 Map — 多会话并发时,
+ * A 会话的"记住拒绝/批准"不再影响 B 会话(与 PolicyEngine 频率限制的会话隔离策略一致)。
+ * 值为 { approved, at } 以支持拒绝记忆 TTL(v0.4.1)。
+ */
+ private rememberedDecisions = new Map>();
/**
* v0.4.1: 会话内拒绝记忆的 TTL(10 分钟)
@@ -63,6 +69,8 @@ export class ConfirmationHook implements PreToolHook {
timer: NodeJS.Timeout;
toolName: string;
expiresAt: number;
+ /** v0.5.0: 发起确认的会话 ID(clearPending 按会话清理的依据) */
+ sessionId: string;
/** v0.3.2 批量审批:缓存完整请求信息,供 getPendingConfirmations() 重建 ConfirmationRequest */
args?: Record;
riskLevel?: string;
@@ -119,8 +127,8 @@ export class ConfirmationHook implements PreToolHook {
}
if (enabled) {
this.autoExecuteTools.add(toolName);
- // 自动执行时同步清除会话内"拒绝"记忆(避免冲突)
- this.rememberedDecisions.delete(toolName);
+ // 自动执行时同步清除所有会话的"拒绝"记忆(避免冲突 — 自动执行是跨会话的全局设置)
+ this.clearDenialMemoryAllSessions(toolName);
} else {
this.autoExecuteTools.delete(toolName);
}
@@ -194,9 +202,9 @@ export class ConfirmationHook implements PreToolHook {
if (autoExecute && approved) {
this.setAutoExecute(pending.toolName, true);
}
- // 会话内记忆(v0.4.1: 拒绝记忆带时间戳,用于 TTL 过期)
+ // 会话内记忆(v0.4.1: 拒绝记忆带时间戳用于 TTL;v0.5.0: 按 pending 所属会话写入)
if (remember) {
- this.rememberedDecisions.set(pending.toolName, { approved, at: Date.now() });
+ this.decisionsFor(pending.sessionId).set(pending.toolName, { approved, at: Date.now() });
}
this.pendingConfirmations.delete(toolCallId);
}
@@ -242,7 +250,7 @@ export class ConfirmationHook implements PreToolHook {
this.setAutoExecute(pending.toolName, true);
}
if (remember) {
- this.rememberedDecisions.set(pending.toolName, { approved, at: Date.now() });
+ this.decisionsFor(pending.sessionId).set(pending.toolName, { approved, at: Date.now() });
}
}
}
@@ -275,44 +283,57 @@ export class ConfirmationHook implements PreToolHook {
}
/**
- * v0.4.1: 获取本会话内记住"拒绝"的工具列表(含剩余有效期,供前端展示恢复入口)
+ * v0.4.1: 获取记住"拒绝"的工具列表(含剩余有效期,供前端展示恢复入口)
+ * v0.5.0: 按 sessionId 过滤 — 只返回指定会话的拒绝记忆;未指定时聚合所有会话(按剩余时间最长去重)
*
* 拒绝记忆有 TTL(默认 10 分钟),到期自动恢复询问;
* 此方法返回未过期的拒绝记忆,前端可提供"重新询问"按钮主动重置。
+ *
+ * @param sessionId 会话 ID(前端应传当前会话;缺省时聚合全部会话)
*/
- getRememberedDenials(): Array<{ toolName: string; expiresInSeconds: number }> {
+ getRememberedDenials(sessionId?: string): Array<{ toolName: string; expiresInSeconds: number }> {
const now = Date.now();
- const result: Array<{ toolName: string; expiresInSeconds: number }> = [];
- for (const [toolName, decision] of this.rememberedDecisions) {
- if (decision.approved) continue;
- const elapsed = now - decision.at;
- if (elapsed >= ConfirmationHook.DENIAL_TTL_MS) {
- // 已过期 — 顺手清理,避免列表返回过期条目
- this.rememberedDecisions.delete(toolName);
- continue;
+ // toolName → 剩余秒数(跨会话聚合时取最长剩余时间)
+ const merged = new Map();
+ for (const [sid, decisions] of this.rememberedDecisions) {
+ if (sessionId && sid !== sessionId) continue;
+ for (const [toolName, decision] of decisions) {
+ if (decision.approved) continue;
+ const elapsed = now - decision.at;
+ if (elapsed >= ConfirmationHook.DENIAL_TTL_MS) {
+ // 已过期 — 顺手清理,避免列表返回过期条目
+ decisions.delete(toolName);
+ continue;
+ }
+ const remaining = Math.ceil((ConfirmationHook.DENIAL_TTL_MS - elapsed) / 1000);
+ merged.set(toolName, Math.max(merged.get(toolName) ?? 0, remaining));
}
- result.push({
- toolName,
- expiresInSeconds: Math.ceil((ConfirmationHook.DENIAL_TTL_MS - elapsed) / 1000),
- });
}
- return result;
+ return Array.from(merged.entries()).map(([toolName, expiresInSeconds]) => ({
+ toolName,
+ expiresInSeconds,
+ }));
}
/**
* v0.4.1: 重置指定工具的会话内拒绝记忆(恢复询问)
+ * v0.5.0: sessionId 指定会话;未指定时重置所有会话中该工具的拒绝记忆
* @returns true 表示重置成功(存在该工具的拒绝记忆);false 表示没有可重置的记忆
*/
- resetRememberedDenial(toolName: string): boolean {
- const decision = this.rememberedDecisions.get(toolName);
- if (decision && !decision.approved) {
- this.rememberedDecisions.delete(toolName);
- return true;
+ resetRememberedDenial(toolName: string, sessionId?: string): boolean {
+ let reset = false;
+ for (const [sid, decisions] of this.rememberedDecisions) {
+ if (sessionId && sid !== sessionId) continue;
+ const decision = decisions.get(toolName);
+ if (decision && !decision.approved) {
+ decisions.delete(toolName);
+ reset = true;
+ }
}
- return false;
+ return reset;
}
- async beforeExecute(toolCall: MetonaToolCall, _sessionId: string): Promise {
+ async beforeExecute(toolCall: MetonaToolCall, sessionId: string): Promise {
const def = this.toolDefs.get(toolCall.name);
if (!def) {
// 未知工具,放行(由 ToolRegistry 处理未知工具错误)
@@ -332,14 +353,15 @@ export class ConfirmationHook implements PreToolHook {
return { blocked: false };
}
- // 检查是否有记住的决策(v0.4.1: 拒绝记忆带 TTL,过期后恢复询问)
- const remembered = this.rememberedDecisions.get(toolCall.name);
- if (remembered !== undefined) {
+ // 检查本会话记住的决策(v0.4.1: 拒绝记忆带 TTL,过期后恢复询问;v0.5.0: 按会话隔离)
+ const sessionDecisions = this.rememberedDecisions.get(sessionId);
+ const remembered = sessionDecisions?.get(toolCall.name);
+ if (sessionDecisions && remembered !== undefined) {
const isExpiredDenial =
!remembered.approved && Date.now() - remembered.at > ConfirmationHook.DENIAL_TTL_MS;
if (isExpiredDenial) {
// 拒绝记忆已过期 — 移除并继续走正常确认流程
- this.rememberedDecisions.delete(toolCall.name);
+ sessionDecisions.delete(toolCall.name);
} else {
if (remembered.approved) return { blocked: false };
return {
@@ -366,7 +388,7 @@ export class ConfirmationHook implements PreToolHook {
};
// 等待用户响应(带超时)
- const approved = await this.waitForConfirmation(request);
+ const approved = await this.waitForConfirmation(request, sessionId);
if (!approved) {
return { blocked: true, reason: `User denied execution of tool "${toolCall.name}"` };
@@ -385,7 +407,7 @@ export class ConfirmationHook implements PreToolHook {
*/
private lastTimeoutToastAt = 0;
- private waitForConfirmation(request: ConfirmationRequest): Promise {
+ private waitForConfirmation(request: ConfirmationRequest, sessionId: string): Promise {
return new Promise((resolve) => {
const expiresAt = Date.now() + this.confirmationTimeoutMs;
@@ -430,6 +452,7 @@ export class ConfirmationHook implements PreToolHook {
timer,
toolName: request.toolName,
expiresAt,
+ sessionId,
// v0.3.2 批量审批:同步缓存完整请求信息,供 getPendingConfirmations() 返回
args: request.args,
riskLevel: request.riskLevel,
@@ -447,13 +470,49 @@ export class ConfirmationHook implements PreToolHook {
}
/**
- * 清理所有等待中的确认(会话结束时调用)
+ * 清理等待中的确认(会话中断时调用)
+ *
+ * v0.5.0: 支持按会话清理 — 多会话并发时,中断 A 会话只拒绝 A 的 pending,
+ * 不再误杀 B 会话等待中的确认请求(原实现全局清空)。
+ *
+ * @param sessionId 指定会话 ID 时只清理该会话的 pending;缺省时清空全部(紧急路径)
*/
- clearPending(): void {
- for (const [, pending] of this.pendingConfirmations) {
+ clearPending(sessionId?: string): void {
+ if (sessionId === undefined) {
+ for (const [, pending] of this.pendingConfirmations) {
+ clearTimeout(pending.timer);
+ pending.resolve(false);
+ }
+ this.pendingConfirmations.clear();
+ return;
+ }
+ for (const [id, pending] of this.pendingConfirmations) {
+ if (pending.sessionId !== sessionId) continue;
clearTimeout(pending.timer);
pending.resolve(false);
+ this.pendingConfirmations.delete(id);
+ }
+ }
+
+ // ===== 私有辅助(v0.5.0: 会话隔离) =====
+
+ /** 获取(或创建)指定会话的决策记忆表 */
+ private decisionsFor(sessionId: string): Map {
+ let decisions = this.rememberedDecisions.get(sessionId);
+ if (!decisions) {
+ decisions = new Map();
+ this.rememberedDecisions.set(sessionId, decisions);
+ }
+ return decisions;
+ }
+
+ /** 清除所有会话中指定工具的拒绝记忆(setAutoExecute 启用时调用) */
+ private clearDenialMemoryAllSessions(toolName: string): void {
+ for (const decisions of this.rememberedDecisions.values()) {
+ const decision = decisions.get(toolName);
+ if (decision && !decision.approved) {
+ decisions.delete(toolName);
+ }
}
- this.pendingConfirmations.clear();
}
}
diff --git a/electron/harness/orchestration/orchestrator.ts b/electron/harness/orchestration/orchestrator.ts
index fe6a35f..44af6b1 100644
--- a/electron/harness/orchestration/orchestrator.ts
+++ b/electron/harness/orchestration/orchestrator.ts
@@ -24,7 +24,12 @@ import { EventEmitter } from 'events';
import { nanoid } from 'nanoid';
import { AgentLoopEngine } from '../agent-loop/engine';
import type { AgentLoopConfig } from '../agent-loop/types';
-import type { MetonaMessage, MetonaSystemPrompt, MetonaToolDef, IMetonaProviderAdapter } from '../types';
+import type {
+ MetonaMessage,
+ MetonaSystemPrompt,
+ MetonaToolDef,
+ IMetonaProviderAdapter,
+} from '../types';
import type { ToolRegistry } from '../tools/registry';
import type { PreToolHook } from '../hooks/pre-tool';
import type { PostToolHook } from '../hooks/post-tool';
@@ -32,12 +37,28 @@ import log from 'electron-log';
export interface SubAgentResult {
taskId: string;
+ /** v0.5.0: 委派方(父会话)ID — 供事件消费者按会话过滤 */
+ parentSessionId: string;
result: string;
success: boolean;
durationMs: number;
iterations: number;
}
+/**
+ * v0.5.0: SubAgent 生命周期事件载荷(统一广播给前端 AgentMonitor 展示)
+ */
+export interface SubAgentEvent {
+ taskId: string;
+ parentSessionId: string;
+ description: string;
+ status: 'delegated' | 'running' | 'completed' | 'error';
+ depth: number;
+ durationMs?: number;
+ iterations?: number;
+ error?: string;
+}
+
/**
* P2-10: 引擎供给接口(由 AgentEngineManager 实现)
* orchestrator 不再持有单个引擎引用,而是按需创建独立实例。
@@ -115,9 +136,12 @@ export class TaskOrchestrator extends EventEmitter {
// ===== 递归深度检查 =====
const currentDepth = this.sessionDepth.get(params.parentSessionId) ?? 0;
if (currentDepth >= MAX_DELEGATION_DEPTH) {
- log.warn(`[Orchestrator] Delegation depth limit reached (${currentDepth}) for session ${params.parentSessionId}`);
+ log.warn(
+ `[Orchestrator] Delegation depth limit reached (${currentDepth}) for session ${params.parentSessionId}`,
+ );
return {
taskId,
+ parentSessionId: params.parentSessionId,
result: `SubAgent delegation depth limit reached (${MAX_DELEGATION_DEPTH}). Cannot delegate further.`,
success: false,
durationMs: 0,
@@ -127,7 +151,12 @@ export class TaskOrchestrator extends EventEmitter {
const depth = currentDepth + 1;
this.sessionDepth.set(params.parentSessionId, depth);
- this.emit('taskDelegated', { taskId, description: params.description, parentSessionId: params.parentSessionId, depth });
+ this.emit('taskDelegated', {
+ taskId,
+ description: params.description,
+ parentSessionId: params.parentSessionId,
+ depth,
+ });
// ===== 创建独立的引擎实例(P2-10: 独立 adapter,隔离 abort 信号) =====
const subEngine = new AgentLoopEngine(
@@ -147,6 +176,10 @@ export class TaskOrchestrator extends EventEmitter {
subEngine.setFallbackAdapter(this.engines.getFallbackAdapter());
subEngine.setWorkspacePath(this.engines.getWorkspacePath());
+ // v0.5.0: 转发 SubEngine 的流式/状态事件(供 TRACE 录制与前端可观测)
+ // 事件以 taskId 为 sessionId,录制到独立 JSONL 文件,不污染父会话的流
+ this.forwardSubEngineEvents(subEngine, taskId);
+
// ===== 工具白名单设置 =====
const allowedTools = this.resolveTools(params.tools);
subEngine.setTools(allowedTools);
@@ -163,11 +196,21 @@ export class TaskOrchestrator extends EventEmitter {
handle.status = 'error';
this.activeSubAgents.delete(taskId);
},
- getStatus: () => ({ taskId, status: handle.status, description: handle.description, depth: handle.depth }),
+ getStatus: () => ({
+ taskId,
+ status: handle.status,
+ description: handle.description,
+ depth: handle.depth,
+ }),
};
this.activeSubAgents.set(taskId, handle);
- this.emit('taskStarted', { taskId, depth });
+ this.emit('taskStarted', {
+ taskId,
+ description: params.description,
+ parentSessionId: params.parentSessionId,
+ depth,
+ });
try {
// 构建用户消息
@@ -184,7 +227,7 @@ export class TaskOrchestrator extends EventEmitter {
const output = await subEngine.runStream(
userMessage,
taskId,
- [], // 子 Agent 无历史
+ [], // 子 Agent 无历史
systemPrompt,
);
@@ -192,6 +235,7 @@ export class TaskOrchestrator extends EventEmitter {
const success = output.terminationReason === 'completed';
const result: SubAgentResult = {
taskId,
+ parentSessionId: params.parentSessionId,
result: output.finalAnswer,
success,
durationMs,
@@ -202,7 +246,9 @@ export class TaskOrchestrator extends EventEmitter {
handle.result = result;
this.emit('taskCompleted', result);
- log.info(`[Orchestrator] SubAgent "${taskId}" (depth=${depth}) ${success ? 'completed' : 'failed'} in ${durationMs}ms, ${output.iterations.length} iterations`);
+ log.info(
+ `[Orchestrator] SubAgent "${taskId}" (depth=${depth}) ${success ? 'completed' : 'failed'} in ${durationMs}ms, ${output.iterations.length} iterations`,
+ );
return result;
} catch (error) {
@@ -210,6 +256,7 @@ export class TaskOrchestrator extends EventEmitter {
const errMsg = (error as Error).message;
const result: SubAgentResult = {
taskId,
+ parentSessionId: params.parentSessionId,
result: errMsg,
success: false,
durationMs,
@@ -218,7 +265,12 @@ export class TaskOrchestrator extends EventEmitter {
handle.status = 'error';
handle.result = result;
- this.emit('taskError', { taskId, error: errMsg });
+ this.emit('taskError', {
+ taskId,
+ parentSessionId: params.parentSessionId,
+ description: params.description,
+ error: errMsg,
+ });
log.error(`[Orchestrator] SubAgent "${taskId}" (depth=${depth}) error: ${errMsg}`);
@@ -326,13 +378,25 @@ export class TaskOrchestrator extends EventEmitter {
const handle = this.activeSubAgents.get(taskId);
if (handle && handle.status === 'running') {
handle.status = success ? 'completed' : 'error';
- handle.result = { taskId, result, success, durationMs: 0, iterations: 0 };
+ handle.result = {
+ taskId,
+ parentSessionId: handle.parentSessionId,
+ result,
+ success,
+ durationMs: 0,
+ iterations: 0,
+ };
this.activeSubAgents.delete(taskId);
this.emit('taskCompleted', handle.result);
}
}
- getActiveAgentsStatus(): Array<{ taskId: string; status: string; description: string; depth: number }> {
+ getActiveAgentsStatus(): Array<{
+ taskId: string;
+ status: string;
+ description: string;
+ depth: number;
+ }> {
return Array.from(this.activeSubAgents.values()).map((a) => a.getStatus());
}
@@ -350,4 +414,24 @@ export class TaskOrchestrator extends EventEmitter {
// 配合 delegate finally 块的 has() 检查:若已被 clear,finally 不再恢复(避免覆盖)。
this.sessionDepth.clear();
}
+
+ /**
+ * v0.5.0: 转发 SubEngine 的事件到 orchestrator 事件总线
+ *
+ * SubEngine 不经过 AgentEngineManager 的转发管道(刻意隔离,避免污染父会话流),
+ * 此处将其流式/状态事件重新包装为 subStreamEvent / subStateChange 广播:
+ * - IPC 层监听后录制到独立 TRACE 文件(sessionId = taskId)
+ * - 生命周期事件(taskDelegated 等)另行广播给前端 AgentMonitor
+ *
+ * @param engine SubAgent 的独立引擎实例
+ * @param taskId 子任务 ID(SubEngine runStream 的 sessionId)
+ */
+ private forwardSubEngineEvents(engine: AgentLoopEngine, taskId: string): void {
+ engine.on('streamEvent', (event: Record) => {
+ this.emit('subStreamEvent', { taskId, event });
+ });
+ engine.on('stateChange', (data: Record) => {
+ this.emit('subStateChange', { taskId, data });
+ });
+ }
}
diff --git a/electron/ipc/__tests__/agent.test.ts b/electron/ipc/__tests__/agent.test.ts
index f884bfa..93a2bf5 100644
--- a/electron/ipc/__tests__/agent.test.ts
+++ b/electron/ipc/__tests__/agent.test.ts
@@ -135,7 +135,9 @@ function makeCtx(overrides: Record = {}) {
buildHistoryMessages: vi.fn(() => []),
maybeSummarize: vi.fn().mockResolvedValue(undefined),
},
- orchestrator: { abortByParent: vi.fn() },
+ // v0.5.0: orchestrator 增加事件监听(SubAgent 可观测性广播/录制),
+ // mock 改用 EventEmitter 提供真实 on/emit 行为
+ orchestrator: Object.assign(new EventEmitter(), { abortByParent: vi.fn() }),
confirmationHook: { clearPending: vi.fn() },
reloadAdapter: vi.fn(() => true),
...overrides,
diff --git a/electron/ipc/agent.ts b/electron/ipc/agent.ts
index 2113e4f..d39adb6 100644
--- a/electron/ipc/agent.ts
+++ b/electron/ipc/agent.ts
@@ -17,6 +17,7 @@ import { broadcast } from './context';
import type { MetonaMessage, MetonaStreamEvent, MetonaError } from '../harness/types';
import { MetonaErrorCode, MetonaStreamEventType } from '../harness/types';
import { estimateMessagesTokens } from '../harness/utils/token-estimator';
+import { DeepSeekAdapter } from '../harness/adapters/deepseek.adapter';
import log from 'electron-log';
/** 单会话的 text_delta 节流状态 */
@@ -511,8 +512,12 @@ export function registerAgentHandlers(ctx: IPCContext): void {
log.debug(`[OutputValidator] Score: ${validation.score}, Valid: ${validation.valid}`);
// v0.4.1: 推送验证结果到前端 — 只推送 warning/error 级(info 级为噪声)
+ // 类型谓词收窄 severity,确保与 MetonaValidationPayload.issues 的类型一致
const visibleIssues = validation.issues
- .filter((i) => i.severity === 'warning' || i.severity === 'error')
+ .filter(
+ (i): i is typeof i & { severity: 'warning' | 'error' } =>
+ i.severity === 'warning' || i.severity === 'error',
+ )
.slice(0, 5);
if (visibleIssues.length > 0) {
broadcast('agent:streamEvent', {
@@ -683,6 +688,27 @@ export function registerAgentHandlers(ctx: IPCContext): void {
},
);
+ // ===== v0.5.0: DeepSeek 余额查询(复用 DeepSeekAdapter.getBalance,原为死代码) =====
+ ipcMain.handle('llm:getBalance', async () => {
+ try {
+ const adapter = agentEngineManager.getAdapter();
+ if (!(adapter instanceof DeepSeekAdapter)) {
+ return {
+ success: false,
+ error: 'Balance query is only supported for the DeepSeek provider',
+ };
+ }
+ const balance = await adapter.getBalance();
+ if (!balance) {
+ return { success: false, error: '余额查询失败(API Key 无效或网络错误)' };
+ }
+ return { success: true, data: balance };
+ } catch (error) {
+ log.warn('[AGENT] getBalance failed:', (error as Error).message);
+ return { success: false, error: (error as Error).message };
+ }
+ });
+
// ===== 中断会话 =====
ipcMain.handle('agent:abortSession', async (_event, sessionId) => {
@@ -693,7 +719,8 @@ export function registerAgentHandlers(ctx: IPCContext): void {
// MT-1 修复: 等待当前 run 完全结束再返回,防止用户立即重发时新消息卡在等待中
await agentEngineManager.waitForAbort(sessionId);
// v0.3.0 修复: 清理所有等待中的工具确认,避免定时器泄漏和超时 toast 在新会话中弹出
- confirmationHook.clearPending();
+ // v0.5.0: 按会话清理 — 只拒绝被中断会话的 pending,不影响其他并发会话等待中的确认
+ confirmationHook.clearPending(sessionId);
// TOOL 层:记录中断
auditService.log({
@@ -707,4 +734,228 @@ export function registerAgentHandlers(ctx: IPCContext): void {
return { success: true };
});
+
+ // ===== v0.5.0: SubAgent 可观测性 =====
+ // 1) 生命周期事件广播给前端(AgentMonitor 的 SubAgent 状态区)
+ // 2) SubEngine 流事件录制到独立 TRACE 文件(sessionId = taskId),不污染父会话的流
+ // 此前 orchestrator 的 6 个事件全项目零消费者,SubAgent 执行过程对 UI 与 TRACE 完全不可见
+
+ /** SubAgent 元信息(description/depth,供完成/失败事件广播时补全载荷) */
+ const subMeta = new Map();
+ /** SubAgent 迭代录制状态(THINKING 驱动新迭代,text 累积供 llm_response) */
+ interface SubTraceState {
+ iteration: number;
+ startedAt: number;
+ text: string;
+ usage: { input: number; output: number; total: number };
+ responded: boolean;
+ }
+ const subTraces = new Map();
+
+ const finishSubTrace = (
+ taskId: string,
+ reason: string,
+ durationMs: number,
+ iterations: number,
+ ): void => {
+ sessionRecorder.stopRecording(taskId, {
+ totalIterations: iterations,
+ totalTokens: 0,
+ durationMs,
+ terminationReason: reason,
+ });
+ subTraces.delete(taskId);
+ subMeta.delete(taskId);
+ };
+
+ orchestrator.on(
+ 'taskDelegated',
+ (d: { taskId: string; description: string; parentSessionId: string; depth: number }) => {
+ broadcast('subagent:event', {
+ taskId: d.taskId,
+ parentSessionId: d.parentSessionId,
+ description: d.description,
+ status: 'delegated',
+ depth: d.depth,
+ });
+ subMeta.set(d.taskId, { description: d.description, depth: d.depth });
+ sessionRecorder.startRecording(d.taskId);
+ },
+ );
+
+ orchestrator.on(
+ 'taskStarted',
+ (d: { taskId: string; description: string; parentSessionId: string; depth: number }) => {
+ broadcast('subagent:event', {
+ taskId: d.taskId,
+ parentSessionId: d.parentSessionId,
+ description: d.description,
+ status: 'running',
+ depth: d.depth,
+ });
+ },
+ );
+
+ orchestrator.on(
+ 'taskCompleted',
+ (r: {
+ taskId: string;
+ parentSessionId: string;
+ success: boolean;
+ durationMs: number;
+ iterations: number;
+ }) => {
+ const meta = subMeta.get(r.taskId);
+ broadcast('subagent:event', {
+ taskId: r.taskId,
+ parentSessionId: r.parentSessionId,
+ description: meta?.description ?? '',
+ status: r.success ? 'completed' : 'error',
+ depth: meta?.depth ?? 1,
+ durationMs: r.durationMs,
+ iterations: r.iterations,
+ });
+ finishSubTrace(r.taskId, r.success ? 'completed' : 'error', r.durationMs, r.iterations);
+ },
+ );
+
+ orchestrator.on(
+ 'taskError',
+ (d: { taskId: string; parentSessionId: string; description: string; error: string }) => {
+ const meta = subMeta.get(d.taskId);
+ broadcast('subagent:event', {
+ taskId: d.taskId,
+ parentSessionId: d.parentSessionId,
+ description: d.description ?? meta?.description ?? '',
+ status: 'error',
+ depth: meta?.depth ?? 1,
+ error: d.error,
+ });
+ finishSubTrace(d.taskId, 'error', 0, 0);
+ },
+ );
+
+ // SubEngine 状态事件 → TRACE 迭代录制(与主管道相同的事件形状,sessionId = taskId)
+ orchestrator.on(
+ 'subStateChange',
+ ({
+ taskId,
+ data,
+ }: {
+ taskId: string;
+ data: { state?: string; current?: string; iteration?: number };
+ }) => {
+ const st = subTraces.get(taskId);
+ const stateValue = data.state ?? data.current ?? '';
+
+ // THINKING 且迭代号变化 → 新迭代开始(关闭上一迭代)
+ if (
+ stateValue === 'THINKING' &&
+ data.iteration != null &&
+ (!st || st.iteration !== data.iteration)
+ ) {
+ if (st && !st.responded) {
+ sessionRecorder.recordLLMResponse({
+ sessionId: taskId,
+ iteration: st.iteration,
+ content: st.text,
+ finishReason: 'stop',
+ tokenUsage: st.usage,
+ });
+ sessionRecorder.recordIterationEnd(taskId, {
+ iteration: st.iteration,
+ durationMs: Date.now() - st.startedAt,
+ });
+ }
+ subTraces.set(taskId, {
+ iteration: data.iteration,
+ startedAt: Date.now(),
+ text: '',
+ usage: { input: 0, output: 0, total: 0 },
+ responded: false,
+ });
+ sessionRecorder.recordIterationStart(taskId, data.iteration);
+ const provider = configService.get('llm.provider') ?? '';
+ const model = configService.get('llm.model') ?? '';
+ sessionRecorder.recordLLMRequest({
+ sessionId: taskId,
+ iteration: data.iteration,
+ provider,
+ model,
+ messageCount: data.iteration + 1,
+ });
+ return;
+ }
+
+ // TERMINATED → 补记最终迭代(正常流程只在下一轮 THINKING 补记,最终轮无后续)
+ if (stateValue === 'TERMINATED' && st) {
+ if (!st.responded) {
+ sessionRecorder.recordLLMResponse({
+ sessionId: taskId,
+ iteration: st.iteration,
+ content: st.text,
+ finishReason: 'stop',
+ tokenUsage: st.usage,
+ });
+ }
+ sessionRecorder.recordIterationEnd(taskId, {
+ iteration: st.iteration,
+ durationMs: Date.now() - st.startedAt,
+ });
+ }
+ },
+ );
+
+ // SubEngine 流事件 → TRACE 内容录制(文本累积 + 工具调用/结果)
+ orchestrator.on(
+ 'subStreamEvent',
+ ({ taskId, event }: { taskId: string; event: MetonaStreamEvent }) => {
+ const st = subTraces.get(taskId);
+ if (!st) return;
+
+ switch (event.type) {
+ case MetonaStreamEventType.TEXT_DELTA:
+ if (event.delta) st.text += event.delta;
+ break;
+ case MetonaStreamEventType.USAGE:
+ if (event.usage) {
+ st.usage = {
+ input: event.usage.inputTokens ?? 0,
+ output: event.usage.outputTokens ?? 0,
+ total: event.usage.totalTokens ?? 0,
+ };
+ }
+ break;
+ case MetonaStreamEventType.TOOL_CALL_COMPLETE:
+ if (event.toolCall) {
+ sessionRecorder.recordToolCall({
+ sessionId: taskId,
+ iteration: event.iteration,
+ toolName: event.toolCall.name,
+ args: event.toolCall.args,
+ });
+ }
+ break;
+ case MetonaStreamEventType.TOOL_RESULT:
+ if (event.toolResult) {
+ const resultPreview =
+ typeof event.toolResult.result === 'string'
+ ? event.toolResult.result
+ : JSON.stringify(event.toolResult.result);
+ sessionRecorder.recordToolResult({
+ sessionId: taskId,
+ iteration: event.iteration,
+ toolName: event.toolResult.toolName,
+ success: event.toolResult.success,
+ durationMs: event.toolResult.durationMs ?? 0,
+ resultPreview,
+ error: event.toolResult.error,
+ });
+ }
+ break;
+ default:
+ break;
+ }
+ },
+ );
}
diff --git a/electron/ipc/app.ts b/electron/ipc/app.ts
index 37955ad..5c32e32 100644
--- a/electron/ipc/app.ts
+++ b/electron/ipc/app.ts
@@ -108,46 +108,56 @@ export function registerAppHandlers(ctx: IPCContext): void {
// ===== SearXNG 连接测试 =====
- ipcMain.handle('searxng:testConnection', async (_event, url: string, authKey: string, authType: string) => {
- try {
- if (!url || !/^https?:\/\//.test(url)) {
- return { success: false, error: 'URL 需以 http:// 或 https:// 开头' };
- }
-
- const startTime = Date.now();
- const headers: Record = {};
-
- if (authKey) {
- if (authType === 'bearer') {
- headers['Authorization'] = `Bearer ${authKey}`;
- } else if (authType === 'basic') {
- headers['Authorization'] = `Basic ${Buffer.from(authKey).toString('base64')}`;
+ ipcMain.handle(
+ 'searxng:testConnection',
+ async (_event, url: string, authKey: string, authType: string) => {
+ try {
+ if (!url || !/^https?:\/\//.test(url)) {
+ return { success: false, error: 'URL 需以 http:// 或 https:// 开头' };
}
+
+ const startTime = Date.now();
+ const headers: Record = {};
+
+ if (authKey) {
+ if (authType === 'bearer') {
+ headers['Authorization'] = `Bearer ${authKey}`;
+ } else if (authType === 'basic') {
+ headers['Authorization'] = `Basic ${Buffer.from(authKey).toString('base64')}`;
+ }
+ }
+
+ const testUrl = `${url.replace(/\/$/, '')}/search?q=test&format=json&pageno=1`;
+ const response = await fetch(testUrl, {
+ headers,
+ signal: AbortSignal.timeout(10_000),
+ });
+
+ const latencyMs = Date.now() - startTime;
+
+ if (response.ok) {
+ return { success: true, statusCode: response.status, latencyMs };
+ }
+ return {
+ success: false,
+ statusCode: response.status,
+ latencyMs,
+ error: `HTTP ${response.status} ${response.statusText}`,
+ };
+ } catch (error) {
+ return { success: false, error: (error as Error).message };
}
-
- const testUrl = `${url.replace(/\/$/, '')}/search?q=test&format=json&pageno=1`;
- const response = await fetch(testUrl, {
- headers,
- signal: AbortSignal.timeout(10_000),
- });
-
- const latencyMs = Date.now() - startTime;
-
- if (response.ok) {
- return { success: true, statusCode: response.status, latencyMs };
- }
- return { success: false, statusCode: response.status, latencyMs, error: `HTTP ${response.status} ${response.statusText}` };
- } catch (error) {
- return { success: false, error: (error as Error).message };
- }
- });
+ },
+ );
// ===== 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})`);
+ 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);
@@ -155,11 +165,35 @@ export function registerAppHandlers(ctx: IPCContext): void {
}
});
+ // v0.5.0: 审计日志导出(JSONL / CSV 两种格式)
+ ipcMain.handle('audit:export', async (_event, format: unknown) => {
+ const fmt = format === 'csv' ? 'csv' : 'jsonl';
+ try {
+ const content = fmt === 'csv' ? auditService.exportCSV() : auditService.exportJSONL();
+ const recordCount = content.trim()
+ ? fmt === 'csv'
+ ? content.trim().split('\n').length - 1
+ : content.trim().split('\n').length
+ : 0;
+ log.info(`[AUDIT] Exported ${recordCount} records as ${fmt.toUpperCase()}`);
+ return { success: true, data: content, format: fmt, recordCount };
+ } catch (error) {
+ log.error('[AUDIT] Export failed:', error);
+ return { success: false, error: (error as Error).message };
+ }
+ });
+
ipcMain.handle('audit:query', async (_event, filters?: unknown) => {
// M-45 修复: 校验 filters 参数类型和范围
const VALID_AUDIT_EVENT_TYPES: readonly AuditEventType[] = [
- 'tool_call', 'permission_check', 'error', 'llm_request',
- 'llm_response', 'session_start', 'session_end', 'config_change',
+ 'tool_call',
+ 'permission_check',
+ 'error',
+ 'llm_request',
+ 'llm_response',
+ 'session_start',
+ 'session_end',
+ 'config_change',
];
const safeFilters: { sessionId?: string; eventType?: AuditEventType; limit?: number } = {};
if (filters && typeof filters === 'object') {
@@ -168,7 +202,10 @@ export function registerAppHandlers(ctx: IPCContext): void {
if (typeof f.eventType === 'string' && f.eventType) {
// 校验 eventType 是否在合法枚举内
if (!(VALID_AUDIT_EVENT_TYPES as readonly string[]).includes(f.eventType)) {
- return { success: false, error: `Invalid eventType (must be one of: ${VALID_AUDIT_EVENT_TYPES.join(', ')})` };
+ return {
+ success: false,
+ error: `Invalid eventType (must be one of: ${VALID_AUDIT_EVENT_TYPES.join(', ')})`,
+ };
}
safeFilters.eventType = f.eventType as AuditEventType;
}
diff --git a/electron/ipc/sessions.ts b/electron/ipc/sessions.ts
index ce5c52d..6bb3855 100644
--- a/electron/ipc/sessions.ts
+++ b/electron/ipc/sessions.ts
@@ -26,7 +26,8 @@ export function registerSessionHandlers(ctx: IPCContext): void {
ipcMain.handle('sessions:rename', async (_event, sessionId: unknown, title: unknown) => {
// M-34 修复: 校验 sessionId 和 title
if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' };
- if (typeof title !== 'string' || !title.trim()) return { success: false, error: 'Invalid title' };
+ if (typeof title !== 'string' || !title.trim())
+ return { success: false, error: 'Invalid title' };
return { success: sessionService.rename(sessionId, title) };
});
@@ -55,7 +56,8 @@ export function registerSessionHandlers(ctx: IPCContext): void {
});
ipcMain.handle('sessions:deleteMessage', async (_event, messageId: unknown) => {
- if (typeof messageId !== 'string' || !messageId) return { success: false, error: 'Invalid messageId' };
+ if (typeof messageId !== 'string' || !messageId)
+ return { success: false, error: 'Invalid messageId' };
return { success: sessionService.deleteMessage(messageId) };
});
@@ -65,13 +67,29 @@ export function registerSessionHandlers(ctx: IPCContext): void {
});
// P2-11: 消息截断(编辑重发 / 重新生成)
- ipcMain.handle('sessions:truncateAfter', async (_event, sessionId: unknown, messageId: unknown, inclusive: unknown) => {
- if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' };
- if (typeof messageId !== 'string' || !messageId) return { success: false, error: 'Invalid messageId' };
- const isInclusive = typeof inclusive === 'boolean' ? inclusive : true;
+ ipcMain.handle(
+ 'sessions:truncateAfter',
+ async (_event, sessionId: unknown, messageId: unknown, inclusive: unknown) => {
+ if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' };
+ if (typeof messageId !== 'string' || !messageId)
+ return { success: false, error: 'Invalid messageId' };
+ const isInclusive = typeof inclusive === 'boolean' ? inclusive : true;
+ try {
+ const truncated = sessionService.truncateMessagesAfter(sessionId, messageId, isInclusive);
+ return { success: true, truncated };
+ } catch (error) {
+ return { success: false, error: (error as Error).message };
+ }
+ },
+ );
+
+ // v0.5.0: 全文搜索会话内容(FTS5,跨会话)
+ ipcMain.handle('sessions:searchContent', async (_event, query: unknown) => {
+ if (typeof query !== 'string' || !query.trim())
+ return { success: false, error: 'Invalid query' };
try {
- const truncated = sessionService.truncateMessagesAfter(sessionId, messageId, isInclusive);
- return { success: true, truncated };
+ const results = sessionService.searchMessages(query);
+ return { success: true, data: results };
} catch (error) {
return { success: false, error: (error as Error).message };
}
diff --git a/electron/ipc/tools.ts b/electron/ipc/tools.ts
index aa020d4..cdec627 100644
--- a/electron/ipc/tools.ts
+++ b/electron/ipc/tools.ts
@@ -128,20 +128,26 @@ export function registerToolHandlers(ctx: IPCContext): void {
});
// ===== v0.4.1: 会话内拒绝记忆管理(拒绝记忆带 TTL,支持手动恢复询问) =====
- ipcMain.handle('tool:getRememberedDenials', async () => {
- return { success: true, data: confirmationHook.getRememberedDenials() };
+ // v0.5.0: sessionId 可选参数 — 拒绝记忆按会话隔离,前端应传当前会话 ID
+ ipcMain.handle('tool:getRememberedDenials', async (_event, sessionId?: unknown) => {
+ const sid = typeof sessionId === 'string' && sessionId ? sessionId : undefined;
+ return { success: true, data: confirmationHook.getRememberedDenials(sid) };
});
- ipcMain.handle('tool:resetRememberedDenial', async (_event, toolName: unknown) => {
- if (typeof toolName !== 'string' || !toolName) {
- return { success: false, error: 'Invalid toolName' };
- }
- const reset = confirmationHook.resetRememberedDenial(toolName);
- if (reset) {
- log.info(`[CONFIRM] Reset remembered denial for tool "${toolName}" — will ask again`);
- }
- return { success: reset };
- });
+ ipcMain.handle(
+ 'tool:resetRememberedDenial',
+ async (_event, toolName: unknown, sessionId?: unknown) => {
+ if (typeof toolName !== 'string' || !toolName) {
+ return { success: false, error: 'Invalid toolName' };
+ }
+ const sid = typeof sessionId === 'string' && sessionId ? sessionId : undefined;
+ const reset = confirmationHook.resetRememberedDenial(toolName, sid);
+ if (reset) {
+ log.info(`[CONFIRM] Reset remembered denial for tool "${toolName}" — will ask again`);
+ }
+ return { success: reset };
+ },
+ );
// ===== v0.2.0: 持久化自动执行设置 =====
ipcMain.handle('tool:setAutoExecute', async (_event, toolName: unknown, enabled: unknown) => {
diff --git a/electron/main.ts b/electron/main.ts
index e7926b3..48c2663 100644
--- a/electron/main.ts
+++ b/electron/main.ts
@@ -15,7 +15,7 @@
*/
import 'dotenv/config';
-import { app, shell, Menu, BrowserWindow } from 'electron';
+import { app, shell, Menu, BrowserWindow, dialog } from 'electron';
import { join } from 'path';
import { existsSync, readFileSync, writeFileSync } from 'fs';
import { electronApp, optimizer } from '@electron-toolkit/utils';
@@ -46,18 +46,29 @@ import { OpenAIAdapter } from './harness/adapters/openai.adapter';
import { AnthropicAdapter } from './harness/adapters/anthropic.adapter';
import type { IMetonaProviderAdapter } from './harness/types/metona-adapter';
import {
- ReadFileTool, WriteFileTool, ListDirectoryTool, SearchFilesTool,
- WebSearchTool, WebFetchTool,
- MemoryStoreTool, MemorySearchTool,
+ ReadFileTool,
+ WriteFileTool,
+ ListDirectoryTool,
+ SearchFilesTool,
+ WebSearchTool,
+ WebFetchTool,
+ MemoryStoreTool,
+ MemorySearchTool,
RunCommandTool,
- WebBrowserTool, cleanupBrowser,
+ WebBrowserTool,
+ cleanupBrowser,
DelegateTaskTool,
FileEditorTool,
CodeSearchTool,
TaskManagerTool,
DiffViewerTool,
- GitStatusTool, GitDiffTool, GitLogTool, GitCommitTool,
- LintCodeTool, RunTestsTool, ProjectInfoTool,
+ GitStatusTool,
+ GitDiffTool,
+ GitLogTool,
+ GitCommitTool,
+ LintCodeTool,
+ RunTestsTool,
+ ProjectInfoTool,
HttpRequestTool,
ThinkTool,
ViewImageTool,
@@ -65,7 +76,13 @@ import {
FileMoveTool,
FileInfoTool,
} from './harness/tools/built-in';
-import { AuditLogHook, MemoryTriggerHook, PermissionCheckHook, RateLimitHook, SecurityScanHook } from './harness/hooks';
+import {
+ AuditLogHook,
+ MemoryTriggerHook,
+ PermissionCheckHook,
+ RateLimitHook,
+ SecurityScanHook,
+} from './harness/hooks';
import { ConfirmationHook } from './harness/hooks/confirmation-hook';
import { PolicyEngine } from './harness/sandbox/permissions';
import { SandboxManager } from './harness/sandbox/sandbox';
@@ -139,7 +156,9 @@ async function initialize(): Promise {
if (savedWorkspacePath !== workspaceInfo.path) {
writeWorkspacePathToFile(workspaceInfo.path);
}
- log.info(`Workspace: ${workspaceInfo.path} (missing: ${workspaceInfo.missingFiles.join(', ') || 'none'})`);
+ log.info(
+ `Workspace: ${workspaceInfo.path} (missing: ${workspaceInfo.missingFiles.join(', ') || 'none'})`,
+ );
// ===== 步骤 3: SQLite =====
databaseService = new DatabaseService(workspaceInfo.path);
@@ -157,7 +176,10 @@ async function initialize(): Promise {
// v0.3.17 迁移: 首次启用全局配置层时,把工作空间 DB 中的全局配置同步到全局 JSON
// 幂等设计:migrateFromWorkspaceDB 仅写入全局层不存在的 key
- const configRows = db.prepare('SELECT key, value FROM app_config').all() as Array<{ key: string; value: string }>;
+ const configRows = db.prepare('SELECT key, value FROM app_config').all() as Array<{
+ key: string;
+ value: string;
+ }>;
const workspaceConfig: Record = {};
for (const row of configRows) {
try {
@@ -168,7 +190,9 @@ async function initialize(): Promise {
}
const migratedCount = globalConfigService.migrateFromWorkspaceDB(workspaceConfig);
if (migratedCount > 0) {
- log.info(`[MIGRATION] Migrated ${migratedCount} global config keys from workspace DB to global layer`);
+ log.info(
+ `[MIGRATION] Migrated ${migratedCount} global config keys from workspace DB to global layer`,
+ );
}
// ===== 日志服务 =====
@@ -185,8 +209,12 @@ async function initialize(): Promise {
const provider = configService.get('llm.provider') ?? '';
const model = configService.get('llm.model') ?? '';
const env = ENV_FALLBACK[provider] ?? {};
- const apiKey = configService.get('llm.apiKey') || (env.apiKey ? process.env[env.apiKey] : '') || '';
- const baseURL = configService.get('llm.baseURL') || (env.baseURL ? process.env[env.baseURL] : '') || '';
+ const apiKey =
+ configService.get('llm.apiKey') || (env.apiKey ? process.env[env.apiKey] : '') || '';
+ const baseURL =
+ configService.get('llm.baseURL') ||
+ (env.baseURL ? process.env[env.baseURL] : '') ||
+ '';
// 配置不完整时返回 null(不抛异常,让应用能启动到 Onboarding)
if (!provider || !baseURL || !model) {
@@ -200,24 +228,35 @@ async function initialize(): Promise {
}
// 读取 Provider 对应的 contextWindow 配置(Ollama 不使用此字段)
- const contextWindow = provider !== 'ollama'
- ? configService.get(`${provider}.contextWindow`) ?? undefined
- : undefined;
+ const contextWindow =
+ provider !== 'ollama'
+ ? (configService.get(`${provider}.contextWindow`) ?? undefined)
+ : undefined;
const adapterConfig = { provider, baseURL, apiKey, defaultModel: model, contextWindow };
switch (provider) {
- case 'agnes': return new AgnesAdapter(adapterConfig);
- case 'mimo': return new MimoAdapter(adapterConfig);
- case 'ollama': return new OllamaAdapter(adapterConfig);
- case 'openai': return new OpenAIAdapter(adapterConfig);
- case 'anthropic': return new AnthropicAdapter(adapterConfig);
- default: return new DeepSeekAdapter(adapterConfig);
+ case 'agnes':
+ return new AgnesAdapter(adapterConfig);
+ case 'mimo':
+ return new MimoAdapter(adapterConfig);
+ case 'ollama':
+ return new OllamaAdapter(adapterConfig);
+ case 'openai':
+ return new OpenAIAdapter(adapterConfig);
+ case 'anthropic':
+ return new AnthropicAdapter(adapterConfig);
+ default:
+ return new DeepSeekAdapter(adapterConfig);
}
};
// 配置未就绪时的 fallback adapter — getContextWindow 返回安全值,send/sendStream 会报错但前端可见
const FALLBACK_ADAPTER = new DeepSeekAdapter({
- provider: '', baseURL: '', apiKey: '', defaultModel: '', contextWindow: 4096,
+ provider: '',
+ baseURL: '',
+ apiKey: '',
+ defaultModel: '',
+ contextWindow: 4096,
});
// ===== 步骤 5: 工作空间文件 + System Prompt =====
@@ -262,11 +301,16 @@ async function initialize(): Promise {
toolRegistry.registerBuiltin(runCommandTool);
// P2(v0.3.13): 注入 onTaskChanged 回调,工具写入后广播 IPC 事件给所有窗口
- toolRegistry.registerBuiltin(new TaskManagerTool(() => db, (sessionId) => {
- for (const win of BrowserWindow.getAllWindows()) {
- win.webContents.send('task:changed', sessionId);
- }
- }));
+ toolRegistry.registerBuiltin(
+ new TaskManagerTool(
+ () => db,
+ (sessionId) => {
+ for (const win of BrowserWindow.getAllWindows()) {
+ win.webContents.send('task:changed', sessionId);
+ }
+ },
+ ),
+ );
toolRegistry.registerBuiltin(new WebBrowserTool());
@@ -308,7 +352,13 @@ async function initialize(): Promise {
contextLength: ollamaNumCtx ?? undefined,
contextWindow: buildAdapter().getContextWindow(),
thinkingEnabled: configService.get('agent.enableThinking') ?? true,
- thinkingEffort: (configService.get('agent.thinkingEffort') as 'low' | 'medium' | 'high' | 'max' | null) ?? 'high',
+ thinkingEffort:
+ (configService.get('agent.thinkingEffort') as
+ | 'low'
+ | 'medium'
+ | 'high'
+ | 'max'
+ | null) ?? 'high',
toolExecutionTimeoutMs: configService.get('agent.toolExecutionTimeoutMs') ?? 120_000,
},
toolRegistry,
@@ -323,21 +373,34 @@ async function initialize(): Promise {
const model = configService.get('llm.fallbackModel');
if (!provider || !model) return null;
const env = ENV_FALLBACK[provider] ?? {};
- const apiKey = configService.get('llm.fallbackApiKey') || (env.apiKey ? process.env[env.apiKey] : '') || '';
- const baseURL = configService.get('llm.fallbackBaseURL') || (env.baseURL ? process.env[env.baseURL] : '') || '';
+ const apiKey =
+ configService.get('llm.fallbackApiKey') ||
+ (env.apiKey ? process.env[env.apiKey] : '') ||
+ '';
+ const baseURL =
+ configService.get('llm.fallbackBaseURL') ||
+ (env.baseURL ? process.env[env.baseURL] : '') ||
+ '';
if (!baseURL) return null;
if (!apiKey && provider !== 'ollama') return null;
- const contextWindow = provider !== 'ollama'
- ? configService.get(`${provider}.contextWindow`) ?? undefined
- : undefined;
+ const contextWindow =
+ provider !== 'ollama'
+ ? (configService.get(`${provider}.contextWindow`) ?? undefined)
+ : undefined;
const cfg = { provider, baseURL, apiKey, defaultModel: model, contextWindow };
switch (provider) {
- case 'agnes': return new AgnesAdapter(cfg);
- case 'mimo': return new MimoAdapter(cfg);
- case 'ollama': return new OllamaAdapter(cfg);
- case 'openai': return new OpenAIAdapter(cfg);
- case 'anthropic': return new AnthropicAdapter(cfg);
- default: return new DeepSeekAdapter(cfg);
+ case 'agnes':
+ return new AgnesAdapter(cfg);
+ case 'mimo':
+ return new MimoAdapter(cfg);
+ case 'ollama':
+ return new OllamaAdapter(cfg);
+ case 'openai':
+ return new OpenAIAdapter(cfg);
+ case 'anthropic':
+ return new AnthropicAdapter(cfg);
+ default:
+ return new DeepSeekAdapter(cfg);
}
};
agentEngineManager.setFallbackAdapter(buildFallbackAdapter());
@@ -345,24 +408,27 @@ async function initialize(): Promise {
// P1-11: MCP 初始化等待所有连接完成后再广播 tools:ready(修复工具未注册即广播的窗口)
// v0.3.18: toolsReadyRef 供 tools:isReady 查询(解决事件竞态)
const toolsReadyRef: ToolsReadyRef = { ready: false, toolCount: 0 };
- mcpManager.initialize().then(() => {
- agentEngineManager.setToolsAll(toolRegistry.listTools());
- log.info('[MCP] Tools registered and synced to all engines');
- toolsReadyRef.ready = true;
- toolsReadyRef.toolCount = toolRegistry.size;
- // 广播工具就绪事件给所有窗口
- for (const win of BrowserWindow.getAllWindows()) {
- win.webContents.send('tools:ready', { toolCount: toolRegistry.size });
- }
- }).catch((err) => {
- log.warn('MCP Manager initialization error:', err);
- // 即使 MCP 失败,内置工具已就绪,仍广播 ready 让前端启用发送
- toolsReadyRef.ready = true;
- toolsReadyRef.toolCount = toolRegistry.size;
- for (const win of BrowserWindow.getAllWindows()) {
- win.webContents.send('tools:ready', { toolCount: toolRegistry.size });
- }
- });
+ mcpManager
+ .initialize()
+ .then(() => {
+ agentEngineManager.setToolsAll(toolRegistry.listTools());
+ log.info('[MCP] Tools registered and synced to all engines');
+ toolsReadyRef.ready = true;
+ toolsReadyRef.toolCount = toolRegistry.size;
+ // 广播工具就绪事件给所有窗口
+ for (const win of BrowserWindow.getAllWindows()) {
+ win.webContents.send('tools:ready', { toolCount: toolRegistry.size });
+ }
+ })
+ .catch((err) => {
+ log.warn('MCP Manager initialization error:', err);
+ // 即使 MCP 失败,内置工具已就绪,仍广播 ready 让前端启用发送
+ toolsReadyRef.ready = true;
+ toolsReadyRef.toolCount = toolRegistry.size;
+ for (const win of BrowserWindow.getAllWindows()) {
+ win.webContents.send('tools:ready', { toolCount: toolRegistry.size });
+ }
+ });
// ===== Memory Consolidator(会话结束 AI 提取重要记忆到 MEMORY.md) =====
const memoryConsolidator = new MemoryConsolidator(buildAdapter(), workspaceService);
@@ -378,10 +444,19 @@ async function initialize(): Promise {
// ===== Task Orchestrator(子任务委派,P2-10: 适配 EngineProvider) =====
const orchestrator = new TaskOrchestrator(
- agentEngineManager, toolRegistry, preToolHooks, postToolHooks,
+ agentEngineManager,
+ toolRegistry,
+ preToolHooks,
+ postToolHooks,
{
thinkingEnabled: configService.get('agent.enableThinking') ?? true,
- thinkingEffort: (configService.get('agent.thinkingEffort') as 'low' | 'medium' | 'high' | 'max' | null) ?? 'high',
+ thinkingEffort:
+ (configService.get('agent.thinkingEffort') as
+ | 'low'
+ | 'medium'
+ | 'high'
+ | 'max'
+ | null) ?? 'high',
contextLength: ollamaNumCtx ?? undefined,
contextWindow: buildAdapter().getContextWindow(),
},
@@ -407,9 +482,10 @@ async function initialize(): Promise {
fallbackModel: configService.get('llm.fallbackModel') ?? '',
fallbackApiKey: configService.get('llm.fallbackApiKey') ?? '',
fallbackBaseURL: configService.get('llm.fallbackBaseURL') ?? '',
- contextWindow: provider === 'ollama'
- ? configService.get('ollama.numCtx')
- : configService.get(`${provider}.contextWindow`),
+ contextWindow:
+ provider === 'ollama'
+ ? configService.get('ollama.numCtx')
+ : configService.get(`${provider}.contextWindow`),
});
};
let lastConfigSig = buildConfigSig();
@@ -425,7 +501,9 @@ async function initialize(): Promise {
// 配置变化 — 校验新配置可构建 adapter
const probe = createAdapter();
if (!probe) {
- log.warn('[CONFIG] Cannot create adapter: LLM config incomplete (provider/apiKey/baseURL/model)');
+ log.warn(
+ '[CONFIG] Cannot create adapter: LLM config incomplete (provider/apiKey/baseURL/model)',
+ );
const wins = BrowserWindow.getAllWindows();
for (const win of wins) {
win.webContents.send('toast:show', {
@@ -528,15 +606,23 @@ async function initialize(): Promise {
windowManager.registerGlobalShortcuts();
// ===== Agent 状态同步到托盘(P2-10: 监听 manager 聚合事件) =====
- agentEngineManager.on('stateChange', (data: { previous?: string; current?: string; state?: string }) => {
- const statusMap: Record = {
- INIT: 'idle', THINKING: 'thinking', PARSING: 'thinking',
- EXECUTING: 'executing', OBSERVING: 'thinking', REFLECTING: 'thinking',
- COMPRESSING: 'thinking', TERMINATED: 'idle',
- };
- const stateValue = (data.current || data.state) ?? '';
- trayManager?.setStatus(statusMap[stateValue] ?? 'idle');
- });
+ agentEngineManager.on(
+ 'stateChange',
+ (data: { previous?: string; current?: string; state?: string }) => {
+ const statusMap: Record = {
+ INIT: 'idle',
+ THINKING: 'thinking',
+ PARSING: 'thinking',
+ EXECUTING: 'executing',
+ OBSERVING: 'thinking',
+ REFLECTING: 'thinking',
+ COMPRESSING: 'thinking',
+ TERMINATED: 'idle',
+ };
+ const stateValue = (data.current || data.state) ?? '';
+ trayManager?.setStatus(statusMap[stateValue] ?? 'idle');
+ },
+ );
// ===== Agent 完成时发送系统通知 =====
agentEngineManager.on('complete', (data: { sessionId: string; durationMs: number }) => {
@@ -560,7 +646,10 @@ async function initialize(): Promise {
try {
const report = await healthChecker.check();
if (!report.healthy) {
- const failed = report.checks.filter((c) => !c.healthy).map((c) => c.name).join(', ');
+ const failed = report.checks
+ .filter((c) => !c.healthy)
+ .map((c) => c.name)
+ .join(', ');
log.warn(`[Health] Unhealthy checks: ${failed}`);
trayManager?.setStatus('error');
} else {
@@ -571,7 +660,7 @@ async function initialize(): Promise {
if (slo.violated) {
log.warn(
`[SLO] Burn rate ${slo.burnRate.toFixed(2)} exceeds budget (errorRate=${(slo.errorRate * 100).toFixed(1)}%, ` +
- `P95=${slo.percentiles['P95'] ?? 0}ms, ${slo.totalRequests} requests)`,
+ `P95=${slo.percentiles['P95'] ?? 0}ms, ${slo.totalRequests} requests)`,
);
}
} catch (err) {
@@ -613,7 +702,10 @@ async function initialize(): Promise {
// v0.3.18 修复: 超时从 5 秒延长到 40 秒,以容纳 consolidate 的 35 秒等待
const shutdownTimeout = setTimeout(() => {
log.warn('[Shutdown] Timeout reached, forcing exit');
- if (databaseService) { databaseService.close(); databaseService = null; }
+ if (databaseService) {
+ databaseService.close();
+ databaseService = null;
+ }
app.exit(0);
}, 40_000);
@@ -636,19 +728,36 @@ async function initialize(): Promise {
}
}
- if (databaseService) { databaseService.close(); databaseService = null; }
+ if (databaseService) {
+ databaseService.close();
+ databaseService = null;
+ }
clearTimeout(shutdownTimeout);
app.exit(0);
})().catch((err) => {
log.error('[Shutdown] Async cleanup failed:', err);
clearTimeout(shutdownTimeout);
- if (databaseService) { try { databaseService.close(); } catch { /* ignore */ } databaseService = null; }
+ if (databaseService) {
+ try {
+ databaseService.close();
+ } catch {
+ /* ignore */
+ }
+ databaseService = null;
+ }
app.exit(1);
});
});
// ===== 应用日志级别配置 =====
- const logLevel = configService.get('logging.level') as 'error' | 'warn' | 'info' | 'debug' | 'verbose' | 'silly' | null;
+ const logLevel = configService.get('logging.level') as
+ | 'error'
+ | 'warn'
+ | 'info'
+ | 'debug'
+ | 'verbose'
+ | 'silly'
+ | null;
if (logLevel) {
log.transports.file.level = logLevel;
log.transports.console.level = logLevel;
@@ -664,10 +773,31 @@ async function initialize(): Promise {
}
}
- log.info(`MetonaAI Desktop initialized [Provider: ${configService.get('llm.provider') ?? 'none'}, Model: ${configService.get('llm.model') ?? 'none'}, Tools: ${toolRegistry.size}]`);
+ log.info(
+ `MetonaAI Desktop initialized [Provider: ${configService.get('llm.provider') ?? 'none'}, Model: ${configService.get('llm.model') ?? 'none'}, Tools: ${toolRegistry.size}]`,
+ );
}
-app.whenReady().then(initialize);
+// v0.5.0: 启动链路异常兜底 — DB 损坏/工作空间不可写等初始化失败时,
+// 原实现会静默挂起(渲染进程白屏且无任何用户可见错误)。
+// 兜底策略:记录日志 + 弹出系统错误对话框 + 退出(exit 1)
+app
+ .whenReady()
+ .then(initialize)
+ .catch((err) => {
+ log.error('[Startup] Initialization failed:', err);
+ try {
+ dialog.showErrorBox(
+ 'MetonaAI Desktop 启动失败',
+ `初始化过程中发生错误,应用即将退出。\n\n${(err as Error)?.message ?? String(err)}\n\n` +
+ '可能原因:工作空间目录不可写、数据库文件损坏。\n' +
+ '可尝试在设置中切换工作空间路径后重新启动。',
+ );
+ } catch {
+ // showErrorBox 失败(极端环境)时仅保留日志
+ }
+ app.exit(1);
+ });
app.on('web-contents-created', (_, contents) => {
// C-8 修复: 全局 web-contents 监听器也校验 URL 协议
diff --git a/electron/preload.ts b/electron/preload.ts
index c4489b1..1285683 100644
--- a/electron/preload.ts
+++ b/electron/preload.ts
@@ -37,6 +37,12 @@ const metonaAPI = {
ipcRenderer.on('agent:providerSwitched', listener);
return () => ipcRenderer.removeListener('agent:providerSwitched', listener);
},
+ /** v0.5.0: 监听 SubAgent 生命周期事件(委派/运行/完成/失败) */
+ onSubAgentEvent: (callback: (event: unknown) => void) => {
+ const listener = (_event: Electron.IpcRendererEvent, data: unknown) => callback(data);
+ ipcRenderer.on('subagent:event', listener);
+ return () => ipcRenderer.removeListener('subagent:event', listener);
+ },
},
// ===== 会话管理 =====
@@ -56,6 +62,21 @@ const metonaAPI = {
/** P2-11: 截断消息(编辑重发/重新生成——删除锚点消息之后的所有消息) */
truncateAfter: (sessionId: string, messageId: string, inclusive?: boolean) =>
ipcRenderer.invoke('sessions:truncateAfter', sessionId, messageId, inclusive),
+ /** v0.5.0: 全文搜索会话内容(FTS5,跨会话,按会话聚合) */
+ searchContent: (query: string) =>
+ ipcRenderer.invoke('sessions:searchContent', query) as Promise<{
+ success: boolean;
+ error?: string;
+ data?: Array<{
+ sessionId: string;
+ sessionTitle: string;
+ matchCount: number;
+ lastMessageId: string;
+ lastRole: string;
+ snippet: string;
+ timestamp: number;
+ }>;
+ }>,
saveTrace: (sessionId: string, data: unknown) =>
ipcRenderer.invoke('sessions:saveTrace', sessionId, data),
getTrace: (sessionId: string) => ipcRenderer.invoke('sessions:getTrace', sessionId),
@@ -114,6 +135,15 @@ const metonaAPI = {
verifyChain: () => ipcRenderer.invoke('audit:verifyChain'),
query: (filters?: { sessionId?: string; eventType?: string; limit?: number }) =>
ipcRenderer.invoke('audit:query', filters),
+ /** v0.5.0: 导出审计日志(jsonl / csv 两种格式) */
+ export: (format: 'jsonl' | 'csv') =>
+ ipcRenderer.invoke('audit:export', format) as Promise<{
+ success: boolean;
+ error?: string;
+ data?: string;
+ format?: string;
+ recordCount?: number;
+ }>,
},
// ===== v0.2.0: 工具确认请求 =====
@@ -150,13 +180,14 @@ const metonaAPI = {
}>;
}>,
// v0.4.1: 会话内拒绝记忆管理(拒绝记忆 10 分钟 TTL,支持手动恢复询问)
- getRememberedDenials: () =>
- ipcRenderer.invoke('tool:getRememberedDenials') as Promise<{
+ // v0.5.0: sessionId 参数 — 拒绝记忆按会话隔离,传当前会话只查看/重置本会话的记忆
+ getRememberedDenials: (sessionId?: string) =>
+ ipcRenderer.invoke('tool:getRememberedDenials', sessionId) as Promise<{
success: boolean;
data: Array<{ toolName: string; expiresInSeconds: number }>;
}>,
- resetRememberedDenial: (toolName: string) =>
- ipcRenderer.invoke('tool:resetRememberedDenial', toolName) as Promise<{
+ resetRememberedDenial: (toolName: string, sessionId?: string) =>
+ ipcRenderer.invoke('tool:resetRememberedDenial', toolName, sessionId) as Promise<{
success: boolean;
error?: string;
}>,
@@ -237,6 +268,22 @@ const metonaAPI = {
ipcRenderer.invoke('searxng:testConnection', url, authKey, authType),
},
+ // ===== LLM 运行时查询 =====
+ llm: {
+ /** v0.5.0: DeepSeek 余额查询(仅 DeepSeek Provider 支持) */
+ getBalance: () =>
+ ipcRenderer.invoke('llm:getBalance') as Promise<{
+ success: boolean;
+ error?: string;
+ data?: {
+ currency: string;
+ totalBalance: string;
+ grantedBalance: string;
+ toppedUpBalance: string;
+ };
+ }>,
+ },
+
// ===== Toast 通知桥接 =====
toast: {
onShow: (callback: (data: { type: string; message: string; options?: unknown }) => void) => {
diff --git a/electron/services/__tests__/session-search.test.ts b/electron/services/__tests__/session-search.test.ts
new file mode 100644
index 0000000..38e897e
--- /dev/null
+++ b/electron/services/__tests__/session-search.test.ts
@@ -0,0 +1,270 @@
+/**
+ * FTS5 会话内容搜索 + 审计日志导出 回归测试(v0.5.0)
+ *
+ * 覆盖:
+ * 1. messages_fts 虚表创建与触发器实时同步(INSERT / DELETE)
+ * 2. searchMessages:短语命中、跨会话聚合、FTS 运算符输入安全、snippet 生成
+ * 3. 审计导出:JSONL 行格式、CSV RFC 4180 转义(逗号/引号/换行)
+ *
+ * 运行要求:better-sqlite3 为 Electron ABI 构建,需 test:electron 模式
+ * (cross-env ELECTRON_RUN_AS_NODE=1 electron ...)执行;系统 Node 下自动跳过。
+ */
+
+import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
+
+vi.mock('electron-log', () => ({
+ default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
+}));
+
+import { mkdtempSync, rmSync } from 'fs';
+import { tmpdir } from 'os';
+import { join } from 'path';
+
+let dbAvailable = true;
+let Database: typeof import('better-sqlite3');
+try {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ Database = require('better-sqlite3');
+ const probe = new Database(':memory:');
+ probe.close();
+} catch {
+ dbAvailable = false;
+}
+
+describe.skipIf(!dbAvailable)('FTS5 会话内容搜索', () => {
+ let db: any;
+ let dir: string;
+ let sessionService: any;
+
+ beforeAll(async () => {
+ const { SessionService } = await import('../session.service');
+ dir = mkdtempSync(join(tmpdir(), 'metona-search-'));
+ db = new Database(':memory:');
+ // 与 DatabaseService.createTables 一致的最小 schema(含 FTS 虚表 + 触发器)
+ db.exec(`
+ CREATE TABLE sessions (
+ id TEXT PRIMARY KEY,
+ title TEXT DEFAULT '新会话',
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL,
+ message_count INTEGER DEFAULT 0,
+ pinned INTEGER DEFAULT 0,
+ archived INTEGER DEFAULT 0,
+ metadata TEXT DEFAULT '{}'
+ );
+ CREATE TABLE messages (
+ id TEXT PRIMARY KEY,
+ session_id TEXT NOT NULL,
+ role TEXT NOT NULL,
+ content TEXT,
+ created_at INTEGER NOT NULL
+ );
+ CREATE VIRTUAL TABLE messages_fts USING fts5(
+ content,
+ content=messages,
+ content_rowid=rowid
+ );
+ CREATE TRIGGER messages_fts_insert AFTER INSERT ON messages BEGIN
+ INSERT INTO messages_fts(rowid, content) VALUES (new.rowid, new.content);
+ END;
+ CREATE TRIGGER messages_fts_delete AFTER DELETE ON messages BEGIN
+ INSERT INTO messages_fts(messages_fts, rowid, content) VALUES ('delete', old.rowid, old.content);
+ END;
+ `);
+ sessionService = new SessionService(() => db);
+ });
+
+ afterAll(() => {
+ try {
+ db?.close();
+ } catch {
+ /* ignore */
+ }
+ try {
+ rmSync(dir, { recursive: true, force: true });
+ } catch {
+ /* ignore */
+ }
+ });
+
+ const insertMessage = (
+ id: string,
+ sessionId: string,
+ role: string,
+ content: string | null,
+ ts: number,
+ ): void => {
+ db.prepare(
+ `INSERT INTO messages (id, session_id, role, content, created_at) VALUES (?, ?, ?, ?, ?)`,
+ ).run(id, sessionId, role, content, ts);
+ };
+
+ it('FTS5 可用且插入消息实时进入索引', () => {
+ // FTS5 编译可用性(better-sqlite3 预编译含 FTS5,此处防御性验证)
+ insertMessage('m1', 's1', 'user', 'Hello FTS5 world', 1000);
+ const rows = db
+ .prepare(`SELECT COUNT(*) AS c FROM messages_fts WHERE messages_fts MATCH 'hello'`)
+ .all() as Array<{ c: number }>;
+ expect(rows[0].c).toBe(1);
+ });
+
+ it('searchMessages 按会话聚合返回(最新匹配 + 匹配计数 + snippet)', () => {
+ db.prepare(
+ `INSERT INTO sessions (id, title, created_at, updated_at) VALUES ('s1', '会话一', 1, 1)`,
+ ).run();
+ db.prepare(
+ `INSERT INTO sessions (id, title, created_at, updated_at) VALUES ('s2', '会话二', 1, 1)`,
+ ).run();
+ insertMessage('m2', 's1', 'assistant', 'uniquekeyword 出现了一次', 2000);
+ insertMessage('m3', 's2', 'user', 'uniquekeyword 在另一个会话', 3000);
+ insertMessage('m4', 's2', 'assistant', 'uniquekeyword 再次出现', 4000);
+
+ const results = sessionService.searchMessages('uniquekeyword');
+ expect(results).toHaveLength(2);
+ // s2 的最新匹配(m4)先返回(按时间倒序聚合)
+ expect(results[0].sessionId).toBe('s2');
+ expect(results[0].matchCount).toBe(2);
+ expect(results[0].lastMessageId).toBe('m4');
+ expect(results[0].sessionTitle).toBe('会话二');
+ expect(results[0].snippet).toContain('uniquekeyword');
+ // s1 单条匹配
+ expect(results[1].sessionId).toBe('s1');
+ expect(results[1].matchCount).toBe(1);
+ });
+
+ it('删除消息后索引同步(搜索不再命中)', () => {
+ db.prepare(`DELETE FROM messages WHERE id = 'm2'`).run();
+ const results = sessionService.searchMessages('uniquekeyword');
+ expect(results).toHaveLength(1);
+ expect(results[0].sessionId).toBe('s2');
+ });
+
+ it('FTS 运算符/引号输入安全(不抛错,按短语匹配)', () => {
+ insertMessage('m5', 's1', 'user', '包含 AND OR NOT 与 "引号" 的消息', 5000);
+ // 含 FTS 保留字与引号的查询不抛异常
+ expect(() => sessionService.searchMessages('AND OR NOT')).not.toThrow();
+ expect(() => sessionService.searchMessages('x"y')).not.toThrow();
+ // 引号内容作为短语可命中
+ const hit = sessionService.searchMessages('引号');
+ expect(hit.some((r: any) => r.lastMessageId === 'm5')).toBe(true);
+ });
+
+ it('空查询与无匹配返回空数组', () => {
+ expect(sessionService.searchMessages('')).toHaveLength(0);
+ expect(sessionService.searchMessages(' ')).toHaveLength(0);
+ expect(sessionService.searchMessages('不存在的关键词xyz')).toHaveLength(0);
+ });
+
+ it('NULL content 消息不进索引(assistant 仅有 tool_calls 场景)', () => {
+ insertMessage('m6', 's1', 'assistant', null, 6000);
+ const rows = db
+ .prepare(`SELECT COUNT(*) AS c FROM messages_fts WHERE messages_fts MATCH 'nulltest'`)
+ .all() as Array<{ c: number }>;
+ expect(rows[0].c).toBe(0);
+ // FTS 行数与 messages 行数一致(NULL 行在 FTS 中占位但不索引内容)
+ const fts = (db.prepare('SELECT COUNT(*) AS c FROM messages_fts').get() as { c: number }).c;
+ const msg = (db.prepare('SELECT COUNT(*) AS c FROM messages').get() as { c: number }).c;
+ expect(fts).toBe(msg);
+ });
+});
+
+describe.skipIf(!dbAvailable)('审计日志导出(v0.5.0)', () => {
+ let db: any;
+ let dir: string;
+ let auditService: any;
+
+ beforeAll(async () => {
+ const { AuditService } = await import('../audit.service');
+ dir = mkdtempSync(join(tmpdir(), 'metona-audit-export-'));
+ db = new Database(':memory:');
+ db.exec(`
+ CREATE TABLE audit_logs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ session_id TEXT,
+ iteration INTEGER,
+ event_type TEXT NOT NULL,
+ actor TEXT NOT NULL DEFAULT 'system',
+ target TEXT NOT NULL,
+ details TEXT,
+ outcome TEXT,
+ duration_ms INTEGER,
+ created_at INTEGER NOT NULL,
+ prev_hash TEXT,
+ current_hash TEXT
+ );
+ `);
+ auditService = new AuditService(() => db);
+ });
+
+ afterAll(() => {
+ try {
+ db?.close();
+ } catch {
+ /* ignore */
+ }
+ try {
+ rmSync(dir, { recursive: true, force: true });
+ } catch {
+ /* ignore */
+ }
+ });
+
+ it('exportJSONL 每行一条 JSON 记录(含链式哈希字段)', () => {
+ auditService.log({
+ sessionId: 's1',
+ eventType: 'tool_call',
+ actor: 'agent',
+ target: 'run_command',
+ details: { command: 'ls' },
+ outcome: 'success',
+ durationMs: 10,
+ });
+ auditService.log({
+ sessionId: 's1',
+ eventType: 'session_end',
+ actor: 'agent',
+ target: 'session',
+ outcome: 'success',
+ });
+
+ const jsonl = auditService.exportJSONL();
+ const lines = jsonl.trim().split('\n');
+ expect(lines).toHaveLength(2);
+ const first = JSON.parse(lines[0]);
+ expect(first.event_type).toBe('tool_call');
+ expect(first.session_id).toBe('s1');
+ expect(first.current_hash).toMatch(/^[0-9a-f]{64}$/);
+ // 第二条 prev_hash 等于第一条 current_hash(链完整保留在导出中)
+ const second = JSON.parse(lines[1]);
+ expect(second.prev_hash).toBe(first.current_hash);
+ });
+
+ it('exportCSV 含表头且 RFC 4180 转义(逗号/引号/换行字段)', () => {
+ auditService.log({
+ sessionId: 's2',
+ eventType: 'error',
+ actor: 'agent',
+ target: 'target,with,commas',
+ details: { msg: '含"双引号"和\n换行' },
+ outcome: 'error',
+ });
+
+ const csv = auditService.exportCSV();
+ const lines = csv.split('\n');
+ // 表头
+ expect(lines[0]).toBe(
+ 'id,session_id,iteration,event_type,actor,target,details,outcome,duration_ms,created_at,prev_hash,current_hash',
+ );
+ // 含特殊字符的 target 字段被双引号包裹
+ expect(csv).toContain('"target,with,commas"');
+ // details 存储为 JSON 字符串(内部双引号为 \" 转义),CSV 再转义后为 \""(反斜杠保留 + 引号翻倍)
+ expect(csv).toContain('\\""双引号\\""');
+ // 记录数与 JSONL 一致
+ const parsed = auditService
+ .exportJSONL()
+ .trim()
+ .split('\n')
+ .map((l: string) => JSON.parse(l));
+ expect(parsed.length).toBeGreaterThanOrEqual(3);
+ });
+});
diff --git a/electron/services/audit.service.ts b/electron/services/audit.service.ts
index da5a25e..3e8e0a9 100644
--- a/electron/services/audit.service.ts
+++ b/electron/services/audit.service.ts
@@ -37,10 +37,34 @@ function stableStringify(obj: unknown): string {
);
}
-export type AuditEventType = 'tool_call' | 'permission_check' | 'error' | 'llm_request' | 'llm_response' | 'session_start' | 'session_end' | 'config_change';
+export type AuditEventType =
+ | 'tool_call'
+ | 'permission_check'
+ | 'error'
+ | 'llm_request'
+ | 'llm_response'
+ | 'session_start'
+ | 'session_end'
+ | 'config_change';
export type AuditActor = 'agent' | 'user' | 'system';
export type AuditOutcome = 'success' | 'denied' | 'error';
+/** v0.5.0: 审计日志行类型(导出/查询共用) */
+interface AuditRecordRow {
+ 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;
+}
+
export interface AuditEntry {
sessionId: string;
iteration?: number;
@@ -64,9 +88,15 @@ export class AuditService {
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;
+ sessionId: string;
+ iteration: number | null;
+ eventType: string;
+ actor: string;
+ target: string;
+ details: string | null;
+ outcome: string | null;
+ durationMs: number | null;
+ createdAt: number;
},
): string {
// #36 修复: 使用 stableStringify 避免分隔符碰撞且保证 key 顺序稳定
@@ -94,8 +124,11 @@ export class AuditService {
private getLastHash(): string {
if (this.cachedLastHash !== null) return this.cachedLastHash;
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;
- this.cachedLastHash = row?.current_hash ?? '0000000000000000000000000000000000000000000000000000000000000000';
+ const row = db.prepare('SELECT current_hash FROM audit_logs ORDER BY id DESC LIMIT 1').get() as
+ | { current_hash: string | null }
+ | undefined;
+ this.cachedLastHash =
+ row?.current_hash ?? '0000000000000000000000000000000000000000000000000000000000000000';
return this.cachedLastHash;
}
@@ -120,10 +153,12 @@ export class AuditService {
createdAt: now,
});
- db.prepare(`
+ db.prepare(
+ `
INSERT INTO audit_logs (session_id, iteration, event_type, actor, target, details, outcome, duration_ms, created_at, prev_hash, current_hash)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- `).run(
+ `,
+ ).run(
entry.sessionId,
entry.iteration ?? null,
entry.eventType,
@@ -257,11 +292,7 @@ export class AuditService {
/**
* 查询审计日志
*/
- query(filters?: {
- sessionId?: string;
- eventType?: AuditEventType;
- limit?: number;
- }): Array<{
+ query(filters?: { sessionId?: string; eventType?: AuditEventType; limit?: number }): Array<{
id: number;
session_id: string;
iteration: number | null;
@@ -311,6 +342,68 @@ export class AuditService {
}>;
}
+ /**
+ * v0.5.0: 导出审计日志为 JSONL 文本(每行一条 JSON 记录)
+ *
+ * @returns JSONL 格式字符串(含全部记录,按 id 升序)
+ */
+ exportJSONL(): string {
+ 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 AuditRecordRow[];
+ return rows.map((r) => JSON.stringify(r)).join('\n') + (rows.length > 0 ? '\n' : '');
+ }
+
+ /**
+ * v0.5.0: 导出审计日志为 CSV 文本
+ *
+ * 字段含逗号/引号/换行时按 RFC 4180 转义(双引号包裹 + 内部双引号翻倍)。
+ *
+ * @returns CSV 格式字符串(含表头,按 id 升序)
+ */
+ exportCSV(): string {
+ 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 AuditRecordRow[];
+
+ const headers = [
+ 'id',
+ 'session_id',
+ 'iteration',
+ 'event_type',
+ 'actor',
+ 'target',
+ 'details',
+ 'outcome',
+ 'duration_ms',
+ 'created_at',
+ 'prev_hash',
+ 'current_hash',
+ ];
+ const escapeCsv = (value: string | number | null): string => {
+ const s = value === null ? '' : String(value);
+ if (/[",\r\n]/.test(s)) {
+ return `"${s.replace(/"/g, '""')}"`;
+ }
+ return s;
+ };
+ const lines = [headers.join(',')];
+ for (const r of rows) {
+ lines.push(
+ headers
+ .map((h) => escapeCsv((r as unknown as Record)[h]))
+ .join(','),
+ );
+ }
+ return lines.join('\n') + '\n';
+ }
+
/**
* v0.2.0: 验证链式哈希完整性
*
@@ -319,12 +412,30 @@ export class AuditService {
*
* @returns 验证结果,包括是否通过、首个篡改位置的 ID
*/
- verifyChain(): { valid: boolean; totalRecords: number; verifiedRecords: number; tamperedId: number | null } {
+ 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;
+ 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';
@@ -369,7 +480,12 @@ export class AuditService {
prevHash = row.current_hash;
continue;
}
- return { valid: false, totalRecords: rows.length, verifiedRecords: verified, tamperedId: row.id };
+ return {
+ valid: false,
+ totalRecords: rows.length,
+ verifiedRecords: verified,
+ tamperedId: row.id,
+ };
}
prevHash = row.current_hash;
diff --git a/electron/services/database.service.ts b/electron/services/database.service.ts
index f887cc4..13bc8c5 100644
--- a/electron/services/database.service.ts
+++ b/electron/services/database.service.ts
@@ -211,10 +211,12 @@ export class DatabaseService {
);
-- ===== MCP 服务配置表 =====
+ -- v0.5.0: 建表 CHECK 直接含 streamable-http(与迁移 6 的重建后 schema 对齐,
+ -- 新库不再依赖迁移 6 立即重建一次表)
CREATE TABLE IF NOT EXISTS mcp_servers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
- transport TEXT NOT NULL CHECK(transport IN ('stdio', 'sse')),
+ transport TEXT NOT NULL CHECK(transport IN ('stdio', 'sse', 'streamable-http')),
command TEXT,
args TEXT,
url TEXT,
@@ -305,6 +307,28 @@ export class DatabaseService {
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);
+
+ -- ===== v0.5.0: 消息全文搜索(FTS5 外内容表模式,content 列索引) =====
+ -- 会话内容搜索通过 sessions:searchContent IPC 使用 MATCH 查询;
+ -- 触发器保持索引与 messages 表实时同步(INSERT/UPDATE/DELETE)
+ CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
+ content,
+ content=messages,
+ content_rowid=rowid
+ );
+
+ CREATE TRIGGER IF NOT EXISTS messages_fts_insert AFTER INSERT ON messages BEGIN
+ INSERT INTO messages_fts(rowid, content) VALUES (new.rowid, new.content);
+ END;
+
+ CREATE TRIGGER IF NOT EXISTS messages_fts_delete AFTER DELETE ON messages BEGIN
+ INSERT INTO messages_fts(messages_fts, rowid, content) VALUES ('delete', old.rowid, old.content);
+ END;
+
+ CREATE TRIGGER IF NOT EXISTS messages_fts_update AFTER UPDATE ON messages BEGIN
+ INSERT INTO messages_fts(messages_fts, rowid, content) VALUES ('delete', old.rowid, old.content);
+ INSERT INTO messages_fts(rowid, content) VALUES (new.rowid, new.content);
+ END;
`);
// 审计日志防篡改触发器(INSERT-ONLY)
@@ -414,6 +438,28 @@ export class DatabaseService {
// 非致命 — 迁移失败时仅无法添加 streamable-http 服务器,stdio/sse 不受影响
}
+ // v0.5.0 迁移 7: 存量库 FTS 索引回填
+ // messages_fts 虚表由 createTables 创建(IF NOT EXISTS),但升级到 v0.5.0 的存量库
+ // 已有消息不会自动进入索引(触发器只覆盖新写入)。行数不一致时执行 rebuild 全量回填。
+ try {
+ const ftsCount = db.prepare('SELECT COUNT(*) AS c FROM messages_fts').get() as {
+ c: number;
+ };
+ const msgCount = db.prepare('SELECT COUNT(*) AS c FROM messages').get() as { c: number };
+ if (ftsCount.c !== msgCount.c) {
+ log.info(
+ `[DB] Migration: rebuilding messages_fts index (fts=${ftsCount.c}, messages=${msgCount.c})`,
+ );
+ db.exec(`INSERT INTO messages_fts(messages_fts) VALUES ('rebuild')`);
+ log.info('[DB] Migration: messages_fts index rebuilt successfully');
+ }
+ } catch (error) {
+ const msg = toErrorMessage(error);
+ log.warn(`[DB] Migration 7 (messages_fts rebuild) skipped: ${msg}`);
+ // 非致命 — 索引回填失败仅影响全文搜索结果完整性,不影响消息读写;
+ // 新写入的消息仍通过触发器正常进入索引
+ }
+
// C-6 修复 迁移 5: 重建 messages 表,将 content 列从 NOT NULL 改为允许 NULL
// @see project_memory.md — Assistant messages with tool_calls must set content to null
// SQLite 不支持 ALTER COLUMN,需要重建表
@@ -457,13 +503,30 @@ export class DatabaseService {
// #42 确认: idx_messages_session 已是 (session_id, created_at) 复合索引,
// 覆盖 getMessages 的 WHERE session_id = ? ORDER BY created_at ASC 查询,
// 工单描述"仅有 session_id 单字段索引"不准确,无需额外添加 idx_messages_session_timestamp
+ // v0.5.0: DROP TABLE messages 连带删除了 FTS 触发器,此处必须重建
db.exec(`
- 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);
- `);
+ 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);
+
+ CREATE TRIGGER IF NOT EXISTS messages_fts_insert AFTER INSERT ON messages BEGIN
+ INSERT INTO messages_fts(rowid, content) VALUES (new.rowid, new.content);
+ END;
+
+ CREATE TRIGGER IF NOT EXISTS messages_fts_delete AFTER DELETE ON messages BEGIN
+ INSERT INTO messages_fts(messages_fts, rowid, content) VALUES ('delete', old.rowid, old.content);
+ END;
+
+ CREATE TRIGGER IF NOT EXISTS messages_fts_update AFTER UPDATE ON messages BEGIN
+ INSERT INTO messages_fts(messages_fts, rowid, content) VALUES ('delete', old.rowid, old.content);
+ INSERT INTO messages_fts(rowid, content) VALUES (new.rowid, new.content);
+ END;
+ `);
});
rebuildMessages();
+ // v0.5.0: 表重建后 rowid 全部重排,FTS 索引指向的旧 rowid 已失效 — 无条件全量回填
+ db.exec(`INSERT INTO messages_fts(messages_fts) VALUES ('rebuild')`);
+
log.info('[DB] Migration: messages table rebuilt successfully (content now allows NULL)');
}
} catch (error) {
diff --git a/electron/services/session.service.ts b/electron/services/session.service.ts
index 097e268..2b70e58 100644
--- a/electron/services/session.service.ts
+++ b/electron/services/session.service.ts
@@ -86,11 +86,15 @@ export class SessionService {
const db = this.getDBFn();
const archived = options?.archived ?? false;
- const rows = db.prepare(`
+ const rows = db
+ .prepare(
+ `
SELECT * FROM sessions
WHERE archived = ?
ORDER BY pinned DESC, updated_at DESC
- `).all(archived ? 1 : 0) as SessionRow[];
+ `,
+ )
+ .all(archived ? 1 : 0) as SessionRow[];
return rows.map((row) => this.toSessionInfo(row));
}
@@ -104,10 +108,12 @@ export class SessionService {
const now = Date.now();
const sessionTitle = title ?? '新会话';
- db.prepare(`
+ db.prepare(
+ `
INSERT INTO sessions (id, title, created_at, updated_at)
VALUES (?, ?, ?, ?)
- `).run(id, sessionTitle, now, now);
+ `,
+ ).run(id, sessionTitle, now, now);
log.info(`Session created: ${id} (${sessionTitle})`);
@@ -128,9 +134,13 @@ export class SessionService {
*/
rename(sessionId: string, title: string): boolean {
const db = this.getDBFn();
- const result = db.prepare(`
+ const result = db
+ .prepare(
+ `
UPDATE sessions SET title = ?, updated_at = ? WHERE id = ?
- `).run(title, Date.now(), sessionId);
+ `,
+ )
+ .run(title, Date.now(), sessionId);
if (result.changes > 0) {
log.info(`Session renamed: ${sessionId} → ${title}`);
@@ -158,9 +168,13 @@ export class SessionService {
*/
pin(sessionId: string, pinned: boolean): boolean {
const db = this.getDBFn();
- const result = db.prepare(`
+ const result = db
+ .prepare(
+ `
UPDATE sessions SET pinned = ?, updated_at = ? WHERE id = ?
- `).run(pinned ? 1 : 0, Date.now(), sessionId);
+ `,
+ )
+ .run(pinned ? 1 : 0, Date.now(), sessionId);
return result.changes > 0;
}
@@ -170,9 +184,13 @@ export class SessionService {
*/
archive(sessionId: string, archived: boolean): boolean {
const db = this.getDBFn();
- const result = db.prepare(`
+ const result = db
+ .prepare(
+ `
UPDATE sessions SET archived = ?, updated_at = ? WHERE id = ?
- `).run(archived ? 1 : 0, Date.now(), sessionId);
+ `,
+ )
+ .run(archived ? 1 : 0, Date.now(), sessionId);
return result.changes > 0;
}
@@ -252,7 +270,9 @@ export class SessionService {
'DELETE FROM session_summaries WHERE session_id = ? AND summarized_until_rowid >= ?',
).run(sessionId, anchor.rid + (inclusive ? 0 : 1));
- log.info(`Session truncated: ${sessionId} (${result.changes} messages removed after ${messageId})`);
+ log.info(
+ `Session truncated: ${sessionId} (${result.changes} messages removed after ${messageId})`,
+ );
}
return result.changes > 0;
}
@@ -279,10 +299,12 @@ export class SessionService {
// message_count = message_count + 1 已是原子 SQL 表达式(避免读-改-写竞态)
// 事务进一步保证消息插入和计数更新要么全部成功,要么全部回滚
const saveMessageTxn = db.transaction(() => {
- db.prepare(`
+ db.prepare(
+ `
INSERT INTO messages (id, session_id, role, content, reasoning_content, tool_calls, tool_result, attachments, iteration, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- `).run(
+ `,
+ ).run(
id,
params.sessionId,
params.role,
@@ -296,11 +318,13 @@ export class SessionService {
);
// 更新会话的 updated_at 和 message_count
- db.prepare(`
+ db.prepare(
+ `
UPDATE sessions
SET updated_at = ?, message_count = message_count + 1
WHERE id = ?
- `).run(now, params.sessionId);
+ `,
+ ).run(now, params.sessionId);
});
saveMessageTxn();
@@ -322,9 +346,11 @@ export class SessionService {
*/
updateTokenUsage(sessionId: string, tokens: number): void {
const db = this.getDBFn();
- db.prepare(`
+ db.prepare(
+ `
UPDATE sessions SET total_tokens = total_tokens + ? WHERE id = ?
- `).run(tokens, sessionId);
+ `,
+ ).run(tokens, sessionId);
}
/**
@@ -342,19 +368,126 @@ export class SessionService {
clearMessages(sessionId: string): boolean {
const db = this.getDBFn();
const result = db.prepare('DELETE FROM messages WHERE session_id = ?').run(sessionId);
- db.prepare('UPDATE sessions SET message_count = 0, updated_at = ? WHERE id = ?').run(Date.now(), sessionId);
+ db.prepare('UPDATE sessions SET message_count = 0, updated_at = ? WHERE id = ?').run(
+ Date.now(),
+ sessionId,
+ );
return result.changes > 0;
}
+ /**
+ * v0.5.0: 全文搜索会话内容(FTS5)
+ *
+ * 跨会话搜索消息内容,按会话聚合返回(每个会话取最新一条匹配 + 匹配总数)。
+ * 查询词按 FTS5 短语语法转义(内部双引号翻倍 + 整体加引号),防止用户输入的
+ * FTS 运算符(AND/OR/NOT/*)导致语法错误或语义偏移。
+ *
+ * @param query 搜索关键词
+ * @param options.maxSessions 返回的会话数上限(默认 20)
+ * @param options.scanLimit 匹配行扫描上限(默认 200,避免超大结果集拖慢聚合)
+ */
+ searchMessages(
+ query: string,
+ options: { maxSessions?: number; scanLimit?: number } = {},
+ ): Array<{
+ sessionId: string;
+ sessionTitle: string;
+ matchCount: number;
+ lastMessageId: string;
+ lastRole: string;
+ snippet: string;
+ timestamp: number;
+ }> {
+ const db = this.getDBFn();
+ const { maxSessions = 20, scanLimit = 200 } = options;
+ const trimmed = query.trim();
+ if (!trimmed) return [];
+
+ // FTS5 短语转义:内部双引号翻倍,整体作为带引号短语匹配
+ const phrase = `"${trimmed.replace(/"/g, '""')}"`;
+
+ interface MatchRow {
+ id: string;
+ session_id: string;
+ role: string;
+ created_at: number;
+ snip: string;
+ }
+ let rows: MatchRow[];
+ try {
+ rows = db
+ .prepare(
+ `
+ SELECT m.id, m.session_id, m.role, m.created_at,
+ snippet(messages_fts, 0, '[', '] ', ' … ', 12) AS snip
+ FROM messages_fts f
+ JOIN messages m ON m.rowid = f.rowid
+ WHERE messages_fts MATCH ?
+ ORDER BY m.created_at DESC
+ LIMIT ?
+ `,
+ )
+ .all(phrase, scanLimit) as MatchRow[];
+ } catch (err) {
+ log.warn('[SessionService] FTS search failed:', (err as Error).message);
+ return [];
+ }
+
+ // 按会话聚合:取每个会话的最新匹配(rows 已按时间倒序,首次出现即最新)
+ const aggregated = new Map<
+ string,
+ {
+ matchCount: number;
+ lastMessageId: string;
+ lastRole: string;
+ snippet: string;
+ timestamp: number;
+ }
+ >();
+ for (const row of rows) {
+ const existing = aggregated.get(row.session_id);
+ if (existing) {
+ existing.matchCount++;
+ } else {
+ aggregated.set(row.session_id, {
+ matchCount: 1,
+ lastMessageId: row.id,
+ lastRole: row.role,
+ snippet: row.snip,
+ timestamp: row.created_at,
+ });
+ }
+ }
+ if (aggregated.size === 0) return [];
+
+ // 补全会话标题(archived 一起搜——搜索不应因归档而缺结果)
+ const sessionIds = Array.from(aggregated.keys());
+ const placeholders = sessionIds.map(() => '?').join(',');
+ const titleRows = db
+ .prepare(`SELECT id, title FROM sessions WHERE id IN (${placeholders})`)
+ .all(...sessionIds) as Array<{ id: string; title: string }>;
+ const titleMap = new Map(titleRows.map((r) => [r.id, r.title]));
+
+ return Array.from(aggregated.entries())
+ .map(([sessionId, info]) => ({
+ sessionId,
+ sessionTitle: titleMap.get(sessionId) ?? '(已删除会话)',
+ ...info,
+ }))
+ .slice(0, maxSessions);
+ }
+
/**
* 保存会话的 trace 步骤和 token 用量(存入 metadata JSON)
*/
saveTraceData(sessionId: string, data: { traceSteps: unknown[]; tokenUsage: unknown }): void {
const db = this.getDBFn();
const metadata = JSON.stringify({ traceSteps: data.traceSteps, tokenUsage: data.tokenUsage });
- db.prepare(`
+ db.prepare(
+ `
UPDATE sessions SET metadata = ?, updated_at = ? WHERE id = ?
- `).run(metadata, Date.now(), sessionId);
+ `,
+ ).run(metadata, Date.now(), sessionId);
}
/**
@@ -362,7 +495,9 @@ export class SessionService {
*/
getTraceData(sessionId: string): { traceSteps: unknown[]; tokenUsage: unknown } | null {
const db = this.getDBFn();
- const row = db.prepare('SELECT metadata FROM sessions WHERE id = ?').get(sessionId) as { metadata: string } | undefined;
+ const row = db.prepare('SELECT metadata FROM sessions WHERE id = ?').get(sessionId) as
+ | { metadata: string }
+ | undefined;
if (!row?.metadata) return null;
try {
const data = JSON.parse(row.metadata);
diff --git a/eslint.config.js b/eslint.config.js
index 58cb0d2..184ef95 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -1,6 +1,8 @@
// ESLint Flat Config(P1-5)
// 项目此前有 lint 脚本但无任何配置文件(npm run lint 直接报错)。
-// 规则取向:未用变量/any 等降为 warn(存量代码渐进清理),错误级规则保持零容忍。
+// 规则取向:部分规则(no-unused-vars / no-explicit-any)配置为 warn 级别以便渐进收紧,
+// 但执行标准是零容忍 —— `npm run lint` 必须返回 0 problems(0 error / 0 warning),
+// 新增代码不允许引入任何 warning(v0.4.0 起确立的工程质量基线)。
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
diff --git a/package.json b/package.json
index 01e5ef7..a482eba 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "metona-ai-desktop",
- "version": "0.4.1",
+ "version": "0.5.0",
"description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用",
"main": "dist-electron/main/main.js",
"author": "Metona Team",
@@ -16,7 +16,7 @@
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write \"src/**/*.{ts,tsx,css}\" \"electron/**/*.ts\"",
- "typecheck": "tsc --noEmit",
+ "typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json",
"typecheck:node": "tsc -p tsconfig.node.json --noEmit",
"test": "vitest run",
"test:watch": "vitest",
diff --git a/src/components/ConfirmationDialog.tsx b/src/components/ConfirmationDialog.tsx
index 2bae594..16187e7 100644
--- a/src/components/ConfirmationDialog.tsx
+++ b/src/components/ConfirmationDialog.tsx
@@ -42,6 +42,7 @@ import {
Divider,
} from '@mui/material';
import { ShieldAlert, ChevronDown, Timer, RefreshCw } from 'lucide-react';
+import { useAgentStore } from '@renderer/stores/agent-store';
interface ConfirmationRequest {
toolCallId: string;
@@ -87,9 +88,11 @@ export function ConfirmationDialog(): React.JSX.Element | null {
>([]);
// v0.4.1: 拉取被拒工具列表(弹框打开/刷新时同步)
+ // v0.5.0: 传当前会话 ID — 拒绝记忆按会话隔离,只展示本会话的记忆
const refreshRememberedDenials = useCallback(async () => {
try {
- const result = await window.metona?.tool?.getRememberedDenials();
+ const sessionId = useAgentStore.getState().currentSessionId ?? undefined;
+ const result = await window.metona?.tool?.getRememberedDenials(sessionId);
setRememberedDenials(result?.success ? (result.data ?? []) : []);
} catch {
setRememberedDenials([]);
@@ -97,9 +100,11 @@ export function ConfirmationDialog(): React.JSX.Element | null {
}, []);
// v0.4.1: 重置某个工具的拒绝记忆(恢复询问)
+ // v0.5.0: 传当前会话 ID,只重置本会话的记忆
const handleResetDenial = useCallback(async (toolName: string) => {
try {
- await window.metona?.tool?.resetRememberedDenial(toolName);
+ const sessionId = useAgentStore.getState().currentSessionId ?? undefined;
+ await window.metona?.tool?.resetRememberedDenial(toolName, sessionId);
setRememberedDenials((prev) => prev.filter((d) => d.toolName !== toolName));
} catch {
// 重置失败保持现状,TTL 到期后仍会自动恢复
diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx
index c811e8d..e5b5fb5 100644
--- a/src/components/ContextMenu.tsx
+++ b/src/components/ContextMenu.tsx
@@ -7,11 +7,31 @@
*/
import {
- Menu, MenuItem, ListItemIcon, ListItemText,
- Dialog, DialogTitle, DialogContent, DialogActions,
- Button, TextField, Typography,
+ Menu,
+ MenuItem,
+ ListItemIcon,
+ ListItemText,
+ Dialog,
+ DialogTitle,
+ DialogContent,
+ DialogActions,
+ Button,
+ TextField,
+ Typography,
} from '@mui/material';
-import { Copy, Quote, Edit, Trash2, RotateCcw, Eye, Code, ExternalLink, Pin, Archive, FileDown } from 'lucide-react';
+import {
+ Copy,
+ Quote,
+ Edit,
+ Trash2,
+ RotateCcw,
+ Eye,
+ Code,
+ ExternalLink,
+ Pin,
+ Archive,
+ FileDown,
+} from 'lucide-react';
import { create } from 'zustand';
import { useAgentStore } from '@renderer/stores/agent-store';
import { useSessionStore } from '@renderer/stores/session-store';
@@ -35,13 +55,33 @@ interface ContextMenuProps {
export function ContextMenu({ x, y, onClose, items }: ContextMenuProps): React.JSX.Element {
return (
-