diff --git a/README.md b/README.md index 03508d8..67857d1 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

- Version + Version License Electron React @@ -54,6 +54,22 @@ --- +## 🆕 v0.8.1 更新亮点 + +| 特性 | 说明 | +|:---|:---| +| ⚙️ **上下文/输出上限 全局单一配置** | 硬性契约:删除代码中一切写死的上下文窗口与最大输出上限(含六家模型元信息钳制)——唯一合法来源是设置面板「上下文长度」与「最大输出上限」,跨 Provider/模型原样透传 | +| 🧠 **本地向量混合检索** | 激活 Ollama embeddings:记忆检索升级为 0.6×向量余弦 + 0.4×TF-IDF 混合评分,同义改写可召回;存量记忆惰性回填,嵌入不可用自动回退 TF-IDF | +| 📝 **MEMORY.md 维护闭环** | 固化去重消除全文截断盲区;两阶段维护(AI 建议 → 用户勾选 → 原子改写 + 语义记忆双轨同步);>50KB 体积告警 | +| 📊 **成本/缓存可观测闭环** | Prompt Cache 命中率与估算成本(可选单价配置)进 Token 面板;输入框新增上下文占用指示条(60%/80% 变色) | +| 🔌 **MCP Prompts/Resources 对话可用** | `/mcp:{server}:{prompt}` 斜杠菜单填充输入框;`@mcp:{server}:{uri}` 资源注入附件管线(512KB 上限、二进制拒绝) | +| 🛡 **工具自定义策略** | 每个工具可配置拒绝/允许参数正则、频率上限、强制确认(设置 → 工具管理 → 策略),保存即热生效 | +| ⏩ **批量确认 & 游标分页 & 自启** | 连续 ≥3 同类工具确认自动聚合为单弹框;会话消息游标分页(首屏 200 条向上翻页);开机自启开关 | +| 💾 **记忆生命周期接线** | 会话终态联动清理工作记忆;情节记忆 90 天 TTL 真实写入;语义记忆 access_count 检索回写(LRU 激活) | +| 🧪 **E2E 冒烟** | Playwright + Electron 端到端链路(本地 mock LLM,零外联、数据隔离);附带根治权限加固启动时序、回环代理放行、safeStorage 降级三处环境级缺陷 | + +--- + ## 🆕 v0.8.0 更新亮点 | 特性 | 说明 | @@ -678,12 +694,10 @@ OLLAMA_BASE_URL=http://localhost:11434 | `memory.consolidationMinChars` | `200` | 固化内容门控:回答字符数阈值(或存在成功工具调用) | | `memory.consolidationIntervalMs` | `600000` | 固化频率窗口(同会话两次固化的最小间隔) | | `mcp.autoReconnect` | `true` | MCP 断连自动重连(指数退避 5s/15s/60s,最多 3 次) | -| `deepseek.contextWindow` | `1000000` | DeepSeek 上下文窗口 | -| `agnes.contextWindow` | `1000000` | Agnes 上下文窗口 | -| `mimo.contextWindow` | `1000000` | MiMo 上下文窗口 | -| `openai.contextWindow` | `128000` | OpenAI 上下文窗口 | -| `anthropic.contextWindow` | `200000` | Anthropic 上下文窗口 | -| `ollama.numCtx` | (空) | Ollama num_ctx 参数(空 = 由模型决定) | +| `llm.contextWindow` | `131072` | **上下文长度(全局唯一合法配置,v0.8.1)** —— 驱动引擎压缩预算与占用指示,Ollama 场景同时作为 num_ctx 下发;分 Provider 的 contextWindow 键与 ollama.numCtx 已废除(迁移 12 清理) | +| `llm.maxTokens` | `63488` | **最大输出上限(全局唯一合法配置,v0.8.1)** —— 原样透传请求参数,代码中不存在任何按模型钳制 | +| `llm.priceInput` / `llm.priceOutput` | (空) | 成本估算单价(每百万 tokens,可选;留空隐藏成本行) | +| `memory.embeddingModel` | (空) | 本地向量记忆嵌入模型(Ollama embedding 模型名;留空 = 纯 TF-IDF 检索) | ### SearXNG 元搜索引擎 (可选) @@ -897,6 +911,7 @@ npm run format # Prettier 格式化 npm test # 运行单元测试 (Vitest, 系统 Node — SQLite 依赖用例因 better-sqlite3 ABI 自动跳过) npm run test:electron # 运行全量单元测试 (Electron Node ABI, 全部用例执行, 含 SQLite 审计链哈希 + 引擎工具链集成) npm run test:watch # 测试监听模式 +npm run test:e2e # E2E 冒烟(构建产物 + Playwright + Electron,本地 mock LLM 零外联) # ─── 构建 ───────────────────────────────── npm run build # 构建生产包 (Windows NSIS + 便携版) diff --git a/docs/MetonaAI-Desktop 内部API请求与响应标准.html b/docs/MetonaAI-Desktop 内部API请求与响应标准.html index ef99c06..a20724e 100644 --- a/docs/MetonaAI-Desktop 内部API请求与响应标准.html +++ b/docs/MetonaAI-Desktop 内部API请求与响应标准.html @@ -663,7 +663,7 @@ }; // TOOL_CALL_DELTA toolCall?: MetonaToolCall; // TOOL_CALL_COMPLETE(拼接完成后的完整调用) usage?: MetonaTokenUsage; // USAGE - // v0.8.0: DONE 事件新增 finishReason(归一化 OpenAI 语义:stop/length/ + // v0.8.1: USAGE 事件 cacheHitTokens / cacheMissTokens 全链路透传(引擎 → 渲染层「Prompt 缓存命中率」展示);v0.8.1 硬性契约:max_tokens 等输出上限参数原样透传设置面板「最大输出上限」(llm.maxTokens),六家 Adapter 均不再携带模型元信息钳制或写死默认值;上下文窗口唯一来源为设置面板「上下文长度」(llm.contextWindow)。 v0.8.0: DONE 事件新增 finishReason(归一化 OpenAI 语义:stop/length/ // tool_calls/content_filter;MiMo repetition_truncation→stop)。引擎据此 // 区分自然完成与输出上限截断(空响应守卫 + 降级重试)。 error?: MetonaError; // ERROR diff --git a/docs/v0.8.1-迭代实施清单.md b/docs/v0.8.1-迭代实施清单.md new file mode 100644 index 0000000..1d1d943 --- /dev/null +++ b/docs/v0.8.1-迭代实施清单.md @@ -0,0 +1,239 @@ +# v0.8.1 迭代实施清单 —「记忆深化 · 观测闭环 · 体验收口 · 配置单一化」 + +> 版本基线:v0.8.0(8398600)→ 0.8.1 +> 本文档是 v0.8.1 的**唯一实施与验收依据**。每项含:根因 / 根治方案 / 涉及文件 / 验收标准。 +> 硬性契约(用户指令,贯穿全版本):**代码中不存在任何写死的上下文长度或最大输出上限; +> 唯一合法来源是设置面板 LLM 配置的「上下文长度」(llm.contextWindow)与「最大输出上限」 +> (llm.maxTokens),对一切 Provider / 模型生效,不做模型钳制。** + +--- + +## P0 — 正确性收口 + +### P0-1 上下文窗口 / 最大输出上限 全局单一配置化(硬性契约,取代原"按模型真值修正") +- **根因**:① 六家 adapter 各自持有 MODEL_INFO 的 contextWindow / maxOutputTokens 写死值, + 并按其钳制 max_tokens(vision-exp 128K 与配置 1M 冲突时压缩阈值失真,先 413 再压缩); + ② base-adapter / openai-compatible-base / engine / agent-store / 引导页均有写死兜底 + (128K / 4096 / 1M / 63488 / 2048);③ 分 Provider contextWindow 配置键与 ollama.numCtx + 造成多源语义。 +- **根治方案**: + 1. CONFIG_DEFAULTS 新增 `llm.contextWindow`(种子默认 131072)为唯一上下文窗口配置; + `llm.maxTokens`(63488)为唯一输出上限配置;删除 deepseek/agnes/mimo/openai/ + anthropic.contextWindow 与 ollama.numCtx 五个种子键。 + 2. 迁移 11(SCHEMA_VERSION 3→4):episodic/semantic_memories 增 embedding BLOB 列(P1-1); + 迁移 12:DELETE 存量库中已废除的六个配置键。 + 3. 全部 adapter 的 MODEL_INFO 删除窗口/上限数值字段;deepseek/agnes/mimo/openai 的 + max_tokens / max_completion_tokens / num_predict 原样透传 `params.maxTokens`(未配置 + 不下发);anthropic 删除模型钳制,仅保留 thinking 协议下限 2048(协议不变量, + 非输出上限);ollama 删除 num_ctx 探测缓存与 4096 默认。 + 4. getContextWindow 契约单一化:config(设置面板值)> 0 返回之,否则返回 0 + (引擎 syncContextWindow 仅采纳 >0;压缩预算 effectiveContextWindow<=0 时跳过压缩)。 + 5. engine DEFAULT_CONFIG 删除 contextWindow/maxTokens;AgentLoopConfig 二字段改可选; + SubAgent(orchestrator)不再携带 63488/128_000 兜底。 + 6. main.ts / shared.ts(LLM_CONFIG_KEYS、applyEngineConfigKey): + `llm.contextWindow` 全局键驱动 adapter 重建 + 引擎 contextWindow 热更新, + Ollama 场景同步 contextLength(num_ctx);分 Provider 键进入兼容分支仅 WARN。 + 7. LLMSettings 重写:全局唯一「上下文长度」「最大输出上限」两个输入项; + 删除 supportsOutputLimitConfig 门控、模型元信息钳制提示与 numCtx 分字段; + OnboardingWizard 删除 DEFAULT_CTX 写死表,写 `llm.contextWindow`。 + 8. agent-store 删除 4096/1M 常量;setProvider 读 `llm.contextWindow`;新增 + setContextWindow 热更新(config:changed 联动)。 +- **涉及文件**:database.service / main / ipc/shared / ipc/agent(无)/ engine / agent-loop + types / 六家 adapter / base-adapter / openai-compatible-base / agent-store / useAgentStream / + LLMSettings / OnboardingWizard / model-capabilities / 相关测试 +- **验收**:typecheck 三绿;max-tokens-clamp 契约测试重写为"原样透传 + 未配置不下发 + + anthropic thinking 协议下限";provider-request-shapes 钳制矩阵全部改为透传矩阵; + E2E 冒烟断言请求体 max_tokens === 设置值(2048)。 + +### P0-2 记忆生命周期接线(死账清理) +- **根因**:`clearWorkingMemory` 零调用方(working_memories 随会话永久残留); + `episodic_memories.expires_at` 无写入方(cleanupExpired 空转);semantic + `access_count` 只读不写(LRU 淘汰死语义)。 +- **根治方案**:会话终态(sessions:delete / purge / clearMessages、data:clearSessions、 + SubAgent 终态 finishSubTrace 与 abort 路径)调用 `memoryManager.clearWorkingMemory`; + MemoryTriggerHook 写 episodic 时携带 90 天 TTL;`search()` 命中后回写 access_count。 +- **涉及文件**:memory/manager、hooks/post-tool、ipc/sessions、ipc/agent、ipc/data +- **验收**:memory-manager 测试新增 expiresAt 写入 / access_count 递增用例(Electron ABI)。 + +### P0-3 回放缓冲终态清理 +- **根因**:v0.8.0 引入的 replayBuffers 为 ipc/agent.ts 内部 Map,会话删除/彻底删除 + 无联动清理,已删会话最多 4MB/会话滞留(仅 LRU 50 兜底)。 +- **根治方案**:抽为 `electron/ipc/replay-buffer.ts` 独立模块(append/reset/mark/clear/get); + sessions:delete / purge 调用 clearReplayBuffer。 +- **涉及文件**:ipc/replay-buffer(新)、ipc/agent、ipc/sessions +- **验收**:replay-buffer.test 六用例(有界、truncated、runId、TERMINATED 保留、INIT 重置、 + 终态清除)全绿。 + +### P0-4 i18n 收口第二期 +- **根因**:主进程 toast/系统通知(压缩、死循环、故障转移、固化、sendMessage 错误路径、 + 更新/完成通知)与渲染层 14 处 toast 硬编码中文。 +- **根治方案**:新增 `electron/utils/main-locale.ts`(ui.locale 驱动、mt(key,params) 取词、 + zh/en 双表、启动注入 + applyConfigSideEffects 热切换);ipc/agent.ts、main.ts、ipc/shared.ts + 全部文案出层;渲染层 agent-store / App / useKeyboardShortcuts / UserMessage / useAgentStream + (Provider 切换、输出验证)出层并入 i18n-strings(zh/en)。 +- **涉及文件**:utils/main-locale(新)、ipc/agent、ipc/shared、main、agent-store、App、 + useKeyboardShortcuts、UserMessage、useAgentStream、i18n-strings +- **验收**:`grep` 断言渲染层 toast 调用零硬编码中文;typecheck/lint 绿。 + +## P1 — 能力演进 + +### P1-1 本地向量混合检索(激活 embed 死代码) +- **根因**:OllamaAdapter.embed() 全项目零调用;TF-IDF bigram 对同义改写零召回。 +- **根治方案**:`memory/embedder.ts` 契约 + main.ts 装配(仅 Ollama Provider 且用户配置 + `memory.embeddingModel` 时启用,adapter 闭包动态读取);迁移 11 embedding BLOB 列; + store 写入后异步回填向量(in-flight 去重);search 升级 async:混合评分 = + 0.6×向量余弦 + 0.4×TF-IDF(各自叠加时间衰减与重要度;单路缺失回退单路); + **存量记忆惰性回填**:检索候选中 embedding 为 NULL 的行排队补算(本轮仍走 TF-IDF, + 后续查询命中向量路径,无独立迁移任务、嵌入器不可用零开销);AgentSettings 增 + 「向量嵌入模型」配置(空 = 关闭)。 +- **涉及文件**:memory/embedder(新)、memory/manager、main、ipc/memory、AgentSettings、 + i18n-strings、database.service(迁移 11) +- **验收**:memory 套件 86 用例绿(含 5 个新增:TTL 写入、access_count、同义改写混合命中、 + embedder 抛错/返回 null 回退、异步回填 BLOB)。 + +### P1-2 MEMORY.md 维护闭环 +- **根因**:固化 append-only;固化 prompt 全文截 3000 字符 → 尾部条目对 LLM 不可见, + 去重失效重复写入;无任何回收路径,MEMORY.md 无限膨胀。 +- **根治方案**:① digest 共享化 —— `parseMemoryEntries` / `buildMemoryEntriesDigest` + (纯条目行、8000 字符预算)同时用于固化去重与维护分析;② `memory/maintainer.ts` + 两阶段维护:analyze(LLM 产出 delete/update 建议,条目精确匹配校验防幻觉)→ + apply(用户勾选确认后:重写 MEMORY.md —— workspaceService.rewriteMemory 原子写、 + 同步 semantic_memories、审计留痕);③ IPC `memory:analyzeMaintenance` / + `memory:applyMaintenance`(结构校验 + 审计);④ MemoryViewer「整理记忆」入口 + + 建议勾选弹框;⑤ MEMORY.md > 50KB 固化后 WARN 提示。 +- **涉及文件**:memory/maintainer(新)、memory/consolidator、services/workspace、ipc/memory、 + ipc/context、main、preload、global.d.ts、MemoryViewer、ipc/agent(超限告警)、i18n-strings +- **验收**:maintainer.test 五用例(解析、digest 无盲区、精确匹配防幻觉、update 双轨同步、 + 动作数上限)+ consolidator 套件回归全绿。 + +### P1-3 上下文 / 成本可观测闭环 +- **根因**:adapter 采集的 cacheHitTokens/cacheMissTokens 在引擎 USAGE 映射与前端 + TokenUsage 类型被丢弃;ChatInput 无上下文占用指示(UI/UX 文档预留)。 +- **根治方案**:engine TokenUsage 增缓存字段并随 USAGE/accumulate 透传;agent-store + TokenUsage 扩展 + useAgentStream 累加(Provider 不上报保持 undefined);TokenUsage + 面板新增「Prompt 缓存命中」行与「估算成本」行(单价 llm.priceInput/llm.priceOutput + 为设置面板可选配置,未配置隐藏 —— 成本无任何写死价格);ChatInput 顶部上下文占用 + 指示条(lastInputTokens / llm.contextWindow,60%/80% 变色)。 +- **涉及文件**:agent-loop/types、engine、agent-store、useAgentStream、TokenUsage、ChatInput、 + LLMSettings(单价输入)、i18n-strings +- **验收**:typecheck/web 绿;E2E 冒烟在真实渲染界面运行(contextWindow/maxTokens 种子值 + 驱动)。 + +### P1-4 MCP Prompts / Resources 可用化 +- **根因**:v0.8.0 仅"发现与列表",prompts/resources 在对话中不可用。 +- **根治方案**:mcp-manager 增 getPrompt(prompts/get,单 ContentBlock 文本提取)/ + readResource(resources/read,二进制 blob 显式拒绝);IPC `mcp:getPrompt` / + `mcp:readResource`(512KB 截断);ChatInput:斜杠菜单动态合并 MCP prompts + (`/mcp:{server}:{prompt}` → getPrompt 填充输入框);@ 提及合并 MCP resources + (`@mcp:{server}:{uri}` → readResource 注入附件管线,token 集合扩展 ':'); + server 不支持时菜单自然不出现。 +- **涉及文件**:mcp-manager、ipc/mcp、preload、global.d.ts、ChatInput、i18n-strings、 + mcp-contents.test(新) +- **验收**:mcp-contents.test 五用例(getPrompt 展平 / 未连接抛错 / text 返回 / blob 拒绝 / + 空 contents null)+ ChatInput typecheck。 + +## P2 — 体验补全 + +### P2-1 工具自定义策略(UI/UX 文档预留项落地) +- **根治方案**:permissions.ts 增 `parseToolPolicy`(JSON 配置 → 正则编译,非法正则跳过、 + fail-closed)与 `setPolicyOverride/getPolicyOverride`(覆盖层优先于默认策略,字段级合并 + 保留默认安全项);存储键 `tools.{name}.policy`;main.ts 启动冷加载 + shared.ts + config:set 热加载(IPCContext 增 policyEngine);ToolsSettings 每工具「策略」编辑器 + (拒绝/允许正则、频率上限、强制确认,inline 非法正则提示)。 +- **涉及文件**:sandbox/permissions、ipc/context、ipc/shared、main、ToolsSettings、i18n-strings +- **验收**:typecheck/lint 绿;覆盖层优先级由 resolvePolicy 单一解析点保证。 + +### P2-2 连续同类工具批量确认聚合(UI/UX 文档预留项落地) +- **根治方案**:ConfirmationHook 聚合窗口(800ms)—— 同 (session, tool) 并行请求达 + 3 条即 flush 为单条 `tool:confirmationRequestBatch` 广播(低于阈值逐条广播,原行为); + clearPending 同步丢弃未广播缓冲;preload/global.d.ts 增批量监听;ConfirmationDialog + 消费批量事件(倒计时取最早 expiresAt)。 +- **涉及文件**:confirmation-hook、preload、global.d.ts、ConfirmationDialog、 + confirmation-hook.test(广播测试改 fake timers + 新增 2 用例) +- **验收**:hooks 套件 48 用例绿(≥3 聚合单事件、<2 逐条)。 + +### P2-3 会话消息游标分页加载 +- **根因**:sessions:getMessages 全量加载无上限,超长会话切换 IPC 载荷大。 +- **根治方案**:session.service 增游标语义(无 limit 全量兼容;limit 无游标 = 尾部窗口; + limit+beforeRowid = 游标前 N 条,统一升序);IPC 参数校验;preload/global.d.ts 类型; + agent-store 首屏尾部窗口(200 条)+ loadOlderMessages(防重入 + 完整性判定 + + 竞态保护)+ ChatMessage.rowId;MessageList startReached 触发向上加载。 +- **涉及文件**:session.service、ipc/sessions、preload、global.d.ts、agent-store、 + MessageList、session-pagination.test(新) +- **验收**:pagination.test 四用例(全量兼容 / 尾部窗口 / 游标翻页 / 开头完整性)绿。 + +### P2-4 开机自启 +- **根治方案**:IPC `app:setLoginItem` / `app:getLoginItem`(app.setLoginItemSettings + + 读回真实生效状态,Linux 不可用平台 fail-safe);AppearanceSettings 增开关 + (乐观更新 + 读回校正 + 不支持平台提示);preload / global.d.ts 接线。 +- **涉及文件**:ipc/app、preload、global.d.ts、AppearanceSettings、i18n-strings +- **验收**:typecheck 绿;读回语义保证 UI 与平台真实状态一致。 + +### P2-5 E2E 冒烟(Playwright + Electron) +- **根治方案**:`e2e/mock-llm.ts`(本地 OpenAI 兼容 SSE Provider,随机端口,零外联); + `e2e/metona.spec.ts`(隔离 userData + 种子确定性合法 LLM 配置 → 发消息 → 断言流式 + 回复渲染 + 请求体携带设置 maxTokens);playwright.config;main.ts 增 + `METONA_USER_DATA_DIR` / `METONA_E2E_SEED_CONFIG` 引导钩子(显式 env 才生效); + npm script `test:e2e`(build + playwright)。 + **附带根治三项环境级缺陷**:① 权限白名单在 app ready 前注册抛异常静默失效 + (延迟到 ready 后,防线真正生效);② 代理 dispatcher 不放行回环目标(Loopback + Bypass 组合 dispatcher,本地 Ollama/SearXNG/E2E 全部修复);③ safeStorage 加密 + 回读失败无降级(roundtrip probe,失败会话降级明文存储)。 +- **涉及文件**:e2e/*(新)、playwright.config(新)、main、utils/network-proxy、 + utils/secure-config、package.json +- **验收**:`npx playwright test` 2/2 绿(本机含代理环境实测通过)。 + +## P3 — 收尾 + +- package.json → 0.8.1;README 徽章 / 亮点表 / 配置说明 / 测试命令对齐; +- IR 标准文档补 usage.cacheTokens 透传与"输出上限无模型钳制"语义; +- 全量 typecheck + lint + `npm test`(系统 Node)+ `npm run test:electron`(全量)。 + +--- + +## Review 回归修复(2026-09-08 第二轮) + +完整回归 review(配对审计 + 高风险 diff 逐行复核)发现并修复: + +| # | 类型 | 内容 | +|---|---|---| +| R1 | 真 bug | **MCP Prompt 斜杠命令大小写失配** —— cmd 被整体 toLowerCase,大写 server 名与 MCPManager 原始名 Map key 失配("not connected");改为对 mcpPrompts 清单大小写不敏感匹配反查原始名 | +| R2 | 状态一致性 | **分页状态复位缺失** —— agent-store 的 clearMessages / resetSessionState 未复位 messagesComplete/loadingOlder;已补 | +| F1 | 观察项 O1 | **LLMSettings 清空输入静默回写种子默认值**(63488/131072)+ shared.ts `Number(null)=0` 会把引擎预算清零 —— 语义改为"清空 = 写入 null(未配置)":引擎跳过压缩预算 / 输出上限参数不下发(由服务端默认值决定),helper 文案同步 | +| F2 | 观察项 O2 | **维护弹框空分区提示** —— analyze proposal 增加 sectionEntryCounts,弹框计算"应用后变空的分区"并向用户提示(分区头保留) | +| F3 | 告警位置 | **MEMORY.md >50KB 告警移出固化分支** —— 此前仅固化触发时检查,跳过固化的大文件永不告警;改为每次成功 run 后检查 + 每会话一次去重 | +| F4 | 历史 schema | **working_memories 缺 sessions 外键**(v0.2.0 建表起缺失级联)—— 迁移 13(SCHEMA_VERSION 5):孤儿行清理 + 带 FK(CASCADE) 重建;createTables 新库口径对齐 | +| F5 | 历史残留 | **全局配置层废键未清理** —— GLOBAL_KEY_PREFIXES 移除五个 provider 前缀与 ollama.(其下唯一键已废除);initialize 清除全局 JSON 中的 DEPRECATED_CONFIG_KEYS,与工作空间迁移 12 对齐 | + +**验证**:typecheck 0 错误 / lint 0 问题 / test:electron **2478/2478**(净增 sectionEntryCounts 用例)/ E2E 2/2。 + +--- + +## 验证记录(实施完成后回填) + +> 全局验证(2026-09-07): +> - `npm run typecheck` 0 错误;`npm run lint` 0 问题 +> - `npx playwright test` **2/2 通过**(E2E 冒烟) +> - `npm test`(系统 Node):2167 通过 / 310 按 ABI 设计跳过 +> - `npm run test:electron`(Electron ABI 全量):**2478/2478 通过,0 跳过** +> (基线 2445 → 0.8.1 净增 33 个用例:向量混合检索 / 维护闭环 / 回放缓冲 / +> 分页 / 批量确认聚合 / MCP contents / secure-config probe / sectionEntryCounts) +> - sandbox 符号链接用例曾在本机暴露悬空链接绕过 realpath 的真实缺陷(P0 级), +> 已随 0.8.1 根治并通过 + +| 项 | 验证方式 | 结果 | +|---|---|---| +| P0-1 配置单一化 | max-tokens-clamp 透传契约 12 用例;provider-request-shapes 透传矩阵 19 处重写;anthropic/openai/ollama getContextWindow 0 回退契约;迁移 12 清理键;E2E 断言 max_tokens=2048 透传;附带根治:权限白名单 ready 时序 / 代理回环放行 / safeStorage roundtrip 降级 / 悬空 symlink 白名单逃逸(sandbox lstat) | ✅ | +| P0-2 记忆生命周期 | memory-manager 新增 expiresAt / access_count 用例;sessions/data/agent 终态接线(ipc 测试 stub 同步) | ✅ | +| P0-3 回放缓冲 | replay-buffer.test 六用例 + sessions:delete/purge 联动 | ✅ | +| P0-4 i18n 收口 | main-locale zh/en 双表 + 热切换;渲染层 17 处出层;grep 断言零残留 | ✅ | +| P1-1 向量混合检索 | memory 套件 86 用例(含同义改写命中 / 回退 / 惰性回填 / BLOB 回填) | ✅ | +| P1-2 MEMORY.md 维护 | maintainer.test 五用例 + 固化 digest 共享 + MemoryViewer 弹框 + 审计 | ✅ | +| P1-3 可观测闭环 | engine/agent-store cache 字段透传 + TokenUsage 命中率/成本行 + ChatInput 指示条 | ✅ | +| P1-4 MCP 可用化 | mcp-contents.test 五用例 + ChatInput 斜杠/@mcp 接线 | ✅ | +| P2-1 工具自定义策略 | parseToolPolicy fail-closed 解析 + setPolicyOverride 优先级 + ToolsSettings 编辑器 | ✅ | +| P2-2 批量确认聚合 | confirmation-hook.test +2(≥3 聚合 / <3 逐条),既有广播测试 fake timers 适配 | ✅ | +| P2-3 游标分页 | session-pagination.test 四用例 + store/MessageList startReached 接线 | ✅ | +| P2-4 开机自启 | setLoginItem/getLoginItem + 读回语义 + AppearanceSettings 开关 | ✅ | +| P2-5 E2E 冒烟 | playwright 2/2;附带根治权限加固时序 / 回环代理放行 / safeStorage 降级 | ✅ | +| 收尾 | package.json 0.8.1;README / IR 标准 / 本清单入库;全量三绿 | ✅ | diff --git a/e2e/metona.spec.ts b/e2e/metona.spec.ts new file mode 100644 index 0000000..d2f349d --- /dev/null +++ b/e2e/metona.spec.ts @@ -0,0 +1,97 @@ +/** + * MetonaAI Desktop — E2E 冒烟测试(v0.8.1 P2-5) + * + * 端到端链路(Playwright + Electron): + * 启动应用(隔离 userData)→ 跳过引导(种子配置)→ 新建会话 → 输入消息 → + * 发送 → 引擎调用本地 mock LLM(SSE 流式)→ 断言回复渲染 + mock 收到合法 + * 请求体(携带「最大输出上限」设置值)→ 中断按钮恢复 idle。 + * + * 契约:不触外网(LLM 指向 127.0.0.1 随机端口 mock)、不污染真实用户数据 + * (METONA_USER_DATA_DIR 重定向)、断言的配置值即设置面板合法配置项。 + */ + +import { expect, test, type ElectronApplication, type Page } from '@playwright/test'; +import { _electron as electron } from 'playwright'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { startMockLLM, MOCK_REPLY, type MockLLMHandle } from './mock-llm'; + +let electronApp: ElectronApplication; +let page: Page; +let mock: MockLLMHandle; +let userDataDir: string; +let workspaceDir: string; + +test.beforeAll(async () => { + // 1. 本地 mock Provider(随机端口,仅监听 127.0.0.1) + mock = await startMockLLM(); + + // 2. 隔离的用户数据与工作空间目录 + userDataDir = mkdtempSync(join(tmpdir(), 'metona-e2e-user-')); + workspaceDir = join(userDataDir, 'workspace'); + mkdirSync(workspaceDir, { recursive: true }); + // workspace-config.json 指向隔离工作空间(main.ts 启动时优先读取) + writeFileSync( + join(userDataDir, 'workspace-config.json'), + JSON.stringify({ workspacePath: workspaceDir }), + 'utf-8', + ); + + // 3. 启动 Electron(产物构建由 npm run test:e2e 的 build 步骤保证) + electronApp = await electron.launch({ + args: ['.'], + env: { + ...process.env, + METONA_USER_DATA_DIR: userDataDir, + METONA_E2E_SEED_CONFIG: '1', + METONA_E2E_LLM_URL: mock.url, + // 隔离冒烟环境无更新源(生产环境 updater 由 feedUrl 空值禁用,双保险) + METONA_E2E_DISABLE_UPDATE: '1', + } as Record, + }); + page = await electronApp.firstWindow(); + await page.waitForLoadState('domcontentloaded'); +}); + +test.afterAll(async () => { + await electronApp?.close(); + await mock?.close(); + // 调试期保留 userData(main.log 排查用);稳定后恢复清理 + if (!process.env['METONA_E2E_KEEP_USER_DATA']) { + try { + rmSync(userDataDir, { recursive: true, force: true }); + } catch { + /* Windows 文件句柄释放延迟 — 清理失败不影响断言 */ + } + } +}); + +test('冒烟:发送消息 → mock LLM 流式回复渲染到会话', async () => { + // 等待聊天输入框可用(配置加载 + 工具就绪后启用) + // InputBase 把 data-chat-input 挂在根 div —— 实际可编辑元素是内部 textarea + //(MUI 另渲染一个 readonly 隐藏 textarea 用于测量,需用 .first() 取可交互的) + const input = page.locator('[data-chat-input] textarea').first(); + await expect(input).toBeVisible({ timeout: 30_000 }); + + // 发送一条用户消息 + await input.click(); + await input.fill('你好,请做一个冒烟回复'); + await input.press('Enter'); + + // mock LLM 的流式回复出现在聊天区(引擎 → SSE → 渲染层全链路) + await expect(page.getByText(MOCK_REPLY).first()).toBeVisible({ timeout: 30_000 }); + + // 用户消息持久化回显 + await expect(page.getByText('你好,请做一个冒烟回复').first()).toBeVisible(); +}); + +test('冒烟:引擎请求体携带设置面板「最大输出上限」配置(2048)', async () => { + // beforeAll 后发送的第一条消息已在 mock.requests 中(顺序执行时上一测试已完成) + const chatRequests = mock.requests.filter((r) => 'messages' in r); + expect(chatRequests.length).toBeGreaterThanOrEqual(1); + // llm.maxTokens 原样透传(无任何按模型钳制) + expect(chatRequests[0].max_tokens).toBe(2048); + // 请求发往本地 mock(经 adapter 组装),模型名来自种子配置 + expect(chatRequests[0].model).toBe('deepseek-v4-flash'); +}); diff --git a/e2e/mock-llm.ts b/e2e/mock-llm.ts new file mode 100644 index 0000000..6248bbc --- /dev/null +++ b/e2e/mock-llm.ts @@ -0,0 +1,101 @@ +/** + * Mock LLM Server — E2E 冒烟测试的本地 OpenAI 兼容 Provider(v0.8.1 P2-5) + * + * 实现 DeepSeek 适配器实际消费的最小协议面: + * - POST {baseURL}/chat/completions(stream=true):按 SSE 推送一段固定文本 + + * finish_reason=stop + usage + [DONE]; + * - POST /chat/completions(stream=false):非流式 JSON(压缩摘要等内部调用兜底)。 + * + * 端口随机分配(127.0.0.1),测试结束关闭 —— 不触外网、不落真实会话数据。 + */ + +import { createServer, type Server } from 'http'; +import type { AddressInfo } from 'net'; + +/** 流式回复的固定文本(断言锚点) */ +export const MOCK_REPLY = 'Hello from mock LLM. E2E smoke reply.'; + +export interface MockLLMHandle { + url: string; + close: () => Promise; + /** 收到的 chat/completions 请求体列表(断言用) */ + readonly requests: Array>; +} + +export function startMockLLM(): Promise { + const requests: Array> = []; + const server: Server = createServer((req, res) => { + if (!req.url?.includes('/chat/completions')) { + res.writeHead(404).end(); + return; + } + let body = ''; + req.on('data', (chunk) => { + body += chunk; + }); + req.on('end', () => { + let parsed: Record = {}; + try { + parsed = JSON.parse(body) as Record; + } catch { + /* 忽略解析失败 */ + } + requests.push(parsed); + + const isStream = parsed.stream === true; + if (!isStream) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + id: 'mock-1', + model: parsed.model ?? 'mock', + choices: [ + { + index: 0, + message: { role: 'assistant', content: MOCK_REPLY }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 10, completion_tokens: 8, total_tokens: 18 }, + }), + ); + return; + } + + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }); + const frame = (payload: Record): void => { + res.write(`data: ${JSON.stringify(payload)}\n\n`); + }; + // 首 chunk:正文增量 + frame({ + id: 'mock-1', + model: parsed.model ?? 'mock', + choices: [{ index: 0, delta: { role: 'assistant', content: MOCK_REPLY } }], + }); + // 末帧:finish_reason + usage + frame({ + id: 'mock-1', + model: parsed.model ?? 'mock', + choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], + usage: { prompt_tokens: 10, completion_tokens: 8, total_tokens: 18 }, + }); + res.write('data: [DONE]\n\n'); + res.end(); + }); + }); + + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + const addr = server.address() as AddressInfo; + resolve({ + url: `http://127.0.0.1:${addr.port}`, + close: () => new Promise((r) => server.close(() => r())), + requests, + }); + }); + }); +} diff --git a/electron/harness/adapters/__tests__/anthropic.adapter.test.ts b/electron/harness/adapters/__tests__/anthropic.adapter.test.ts index d5e87b7..7293d0b 100644 --- a/electron/harness/adapters/__tests__/anthropic.adapter.test.ts +++ b/electron/harness/adapters/__tests__/anthropic.adapter.test.ts @@ -950,11 +950,12 @@ describe('AnthropicAdapter — getContextWindow / listModels', () => { expect(adapter.getContextWindow()).toBe(50_000); }); - it('未知模型 → 兜底 200K', () => { - expect(makeAdapter('claude-unknown').getContextWindow()).toBe(200_000); + // v0.8.1: 窗口唯一来源是设置面板 llm.contextWindow,未配置返回 0(无写死兜底) + it('未知模型且未配置 → 返回 0(无写死兜底窗口)', () => { + expect(makeAdapter('claude-unknown').getContextWindow()).toBe(0); }); - it('listModels 返回本地模型元信息(无网络请求)', async () => { + it('listModels 返回本地模型元信息(无网络请求,不含窗口/上限数值)', async () => { const adapter = makeAdapter(); const models = await adapter.listModels(); expect(models.map((m) => m.id)).toEqual([ @@ -962,7 +963,10 @@ describe('AnthropicAdapter — getContextWindow / listModels', () => { 'claude-opus-4-1', 'claude-haiku-4-5', ]); - expect(models[0]).toMatchObject({ contextWindow: 200_000, maxOutputTokens: 64_000 }); + // v0.8.1 硬性契约: 元信息不再承载 contextWindow / maxOutputTokens + expect(models[0]).toMatchObject({ supportsThinking: true }); + expect(models[0].contextWindow).toBeUndefined(); + expect(models[0].maxOutputTokens).toBeUndefined(); expect(mockFetch).not.toHaveBeenCalled(); }); }); diff --git a/electron/harness/adapters/__tests__/base-adapter.test.ts b/electron/harness/adapters/__tests__/base-adapter.test.ts index 354c78d..6755209 100644 --- a/electron/harness/adapters/__tests__/base-adapter.test.ts +++ b/electron/harness/adapters/__tests__/base-adapter.test.ts @@ -130,10 +130,11 @@ describe('BaseAdapter — getContextWindow', () => { expect(adapter.getContextWindow()).toBe(128_000); }); - // v0.7.4 P4-5: 兜底从 1M 降至 128K(未知模型按最保守主流窗口预算,防 413) - it('未配置时返回兜底默认值 128K(子类应覆盖真实窗口)', () => { + // v0.8.1 硬性契约: 上下文窗口唯一来源是设置面板 llm.contextWindow, + // 未配置返回 0(引擎据此跳过压缩预算)—— 任何写死兜底值均已删除 + it('未配置时返回 0(无任何写死兜底窗口,引擎跳过压缩判定)', () => { const adapter = makeAdapter(); - expect(adapter.getContextWindow()).toBe(128_000); + expect(adapter.getContextWindow()).toBe(0); }); }); @@ -443,15 +444,15 @@ describe('BaseAdapter — throwHttpError 错误体解析', () => { // ===== 追加:getContextWindow / listModels / healthCheck ===== -describe('BaseAdapter — getContextWindow 回退链', () => { - it('contextWindow=0 视为未配置(需 >0)', () => { +describe('BaseAdapter — getContextWindow 非法值契约', () => { + it('contextWindow=0 视为未配置(返回 0,引擎跳过压缩判定)', () => { const adapter = makeAdapter({ contextWindow: 0 }); - expect(adapter.getContextWindow()).toBe(128_000); + expect(adapter.getContextWindow()).toBe(0); }); - it('contextWindow 为负数视为未配置', () => { + it('contextWindow 为负数视为未配置(返回 0)', () => { const adapter = makeAdapter({ contextWindow: -1 }); - expect(adapter.getContextWindow()).toBe(128_000); + expect(adapter.getContextWindow()).toBe(0); }); }); diff --git a/electron/harness/adapters/__tests__/deepseek-vision.test.ts b/electron/harness/adapters/__tests__/deepseek-vision.test.ts index d84b5a6..7787a6e 100644 --- a/electron/harness/adapters/__tests__/deepseek-vision.test.ts +++ b/electron/harness/adapters/__tests__/deepseek-vision.test.ts @@ -104,7 +104,7 @@ describe('DeepSeek vision 模型多模态请求格式(v0.5.4)', () => { expect(userMsg.content).toBe('这张图片里有什么?'); }); - it('vision 模型 max_tokens 钳制到 8192(MODEL_INFO 上限)', async () => { + it('vision 模型 max_tokens 原样透传(v0.8.1:8192 钳制已废除)', async () => { mockFetch.mockResolvedValue(okResponse()); const adapter = makeAdapter('deepseek-v4-flash-vision-exp'); @@ -114,7 +114,7 @@ describe('DeepSeek vision 模型多模态请求格式(v0.5.4)', () => { } as MetonaRequest); const body = JSON.parse((mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string); - expect(body.max_tokens).toBe(8_192); + expect(body.max_tokens).toBe(63_488); }); it('vision 模型无图片时不转换(content 保持纯文本)', async () => { @@ -215,7 +215,7 @@ describe('DeepSeek vision 模型多模态请求格式(v0.5.4)', () => { }); }); - it('vision 模型 max_tokens 未配置 → 默认 8192(MODEL_INFO 上限)', async () => { + it('vision 模型 max_tokens 未配置 → 不下发该字段(v0.8.1:无写死兜底)', async () => { mockFetch.mockResolvedValue(okResponse()); const adapter = makeAdapter('deepseek-v4-flash-vision-exp'); @@ -225,7 +225,7 @@ describe('DeepSeek vision 模型多模态请求格式(v0.5.4)', () => { } as MetonaRequest); const body = requestBody(); - expect(body.max_tokens).toBe(8_192); + expect(body.max_tokens).toBeUndefined(); }); it('vision 模型 thinking 参数显式映射(thinkingEnabled 兼容)', async () => { diff --git a/electron/harness/adapters/__tests__/max-tokens-clamp.test.ts b/electron/harness/adapters/__tests__/max-tokens-clamp.test.ts index b17df34..998ee05 100644 --- a/electron/harness/adapters/__tests__/max-tokens-clamp.test.ts +++ b/electron/harness/adapters/__tests__/max-tokens-clamp.test.ts @@ -1,10 +1,10 @@ /** - * Provider maxTokens 上限钳制契约测试(v0.5.3) + * maxTokens 透传契约测试(v0.8.1 硬性契约重写) * - * 背景:引擎默认 maxTokens=63488(engine.ts DEFAULT_CONFIG),超过部分模型 - * 上限时 API 直接 400 —— OpenAI gpt-4o(16384)/gpt-4.1(32768)、Anthropic - * opus/haiku(32000)、MiMo standard(32768) 曾不可用。v0.5.3 各 adapter 按 - * MODEL_INFO.maxOutputTokens 钳制。 + * 背景:v0.5.3 曾引入"按 MODEL_INFO.maxOutputTokens 钳制"逻辑。v0.8.1 按硬性 + * 契约废除 —— 设置面板「最大输出上限」(llm.maxTokens)是唯一合法的输出上限 + * 配置,adapter 对一切 Provider/模型**原样透传** `params.maxTokens`,代码中 + * 不存在任何写死的输出上限或按模型元信息的钳制行为。 * * 测试策略(v0.5.2 教训):mock fetch 记录真实请求体并断言契约 —— * 不 mock adapter 内部方法,验证"发出的 HTTP 请求体"这个最终事实。 @@ -33,7 +33,7 @@ afterEach(() => { mockFetch.mockReset(); }); -/** 引擎默认形态的请求(maxTokens=63488,与 engine DEFAULT_CONFIG 一致) */ +/** 设置面板「最大输出上限」配置生效时的请求形态(llm.maxTokens → params.maxTokens) */ function makeRequest(overrides: Partial = {}): MetonaRequest { return { meta: { @@ -79,8 +79,8 @@ function requestBody(): Record { return JSON.parse(init.body as string) as Record; } -describe('maxTokens 模型上限钳制(引擎默认 63488 场景)', () => { - it('DeepSeek v4 pro(上限 384K):63488 未超限,原样传递', async () => { +describe('maxTokens 原样透传(v0.8.1:设置面板是唯一合法上限配置)', () => { + it('DeepSeek:max_tokens 原样透传(不按模型钳制)', async () => { mockFetch.mockResolvedValue(openAIResponse()); const adapter = new DeepSeekAdapter({ provider: 'deepseek', @@ -92,7 +92,19 @@ describe('maxTokens 模型上限钳制(引擎默认 63488 场景)', () => { expect(requestBody().max_tokens).toBe(63_488); }); - it('Agnes flash(上限 65536):63488 未超限,原样传递', async () => { + it('DeepSeek:超出旧模型元信息上限的值同样原样透传(钳制已废除)', async () => { + mockFetch.mockResolvedValue(openAIResponse()); + const adapter = new DeepSeekAdapter({ + provider: 'deepseek', + baseURL: 'https://api.deepseek.com', + apiKey: 'sk', + defaultModel: 'deepseek-v4-flash-vision-exp', + }); + await adapter.send(makeRequest()); + expect(requestBody().max_tokens).toBe(63_488); + }); + + it('Agnes:max_tokens 原样透传', async () => { mockFetch.mockResolvedValue(openAIResponse()); const adapter = new AgnesAdapter({ provider: 'agnes', @@ -104,7 +116,7 @@ describe('maxTokens 模型上限钳制(引擎默认 63488 场景)', () => { expect(requestBody().max_tokens).toBe(63_488); }); - it('MiMo standard(上限 32768):钳制到 32768(原为 400 错误场景)', async () => { + it('MiMo standard:max_completion_tokens 原样透传(旧 32768 钳制已废除)', async () => { mockFetch.mockResolvedValue(openAIResponse()); const adapter = new MimoAdapter({ provider: 'mimo', @@ -113,10 +125,10 @@ describe('maxTokens 模型上限钳制(引擎默认 63488 场景)', () => { defaultModel: 'mimo-v2.5', }); await adapter.send(makeRequest()); - expect(requestBody().max_completion_tokens).toBe(32_768); + expect(requestBody().max_completion_tokens).toBe(63_488); }); - it('MiMo pro(上限 131072):63488 未超限,原样传递', async () => { + it('MiMo pro:max_completion_tokens 原样透传', async () => { mockFetch.mockResolvedValue(openAIResponse()); const adapter = new MimoAdapter({ provider: 'mimo', @@ -128,7 +140,7 @@ describe('maxTokens 模型上限钳制(引擎默认 63488 场景)', () => { expect(requestBody().max_completion_tokens).toBe(63_488); }); - it('OpenAI gpt-4o(上限 16384):钳制到 16384(原为 400 错误场景)', async () => { + it('OpenAI gpt-4o(非推理模型):max_tokens 原样透传(旧 16384 钳制已废除)', async () => { mockFetch.mockResolvedValue(openAIResponse()); const adapter = new OpenAIAdapter({ provider: 'openai', @@ -137,10 +149,10 @@ describe('maxTokens 模型上限钳制(引擎默认 63488 场景)', () => { defaultModel: 'gpt-4o', }); await adapter.send(makeRequest()); - expect(requestBody().max_tokens).toBe(16_384); + expect(requestBody().max_tokens).toBe(63_488); }); - it('OpenAI o3-mini(上限 100K):63488 未超限,推理模型字段名正确', async () => { + it('OpenAI o3-mini(推理模型):max_completion_tokens 原样透传,字段名路由正确', async () => { mockFetch.mockResolvedValue(openAIResponse()); const adapter = new OpenAIAdapter({ provider: 'openai', @@ -153,7 +165,7 @@ describe('maxTokens 模型上限钳制(引擎默认 63488 场景)', () => { expect(requestBody().max_tokens).toBeUndefined(); }); - it('Anthropic opus(上限 32000):钳制到 32000(原为 400 错误场景)', async () => { + it('Anthropic opus:max_tokens 原样透传(旧 32000 钳制已废除)', async () => { mockFetch.mockResolvedValue(anthropicResponse()); const adapter = new AnthropicAdapter({ provider: 'anthropic', @@ -162,10 +174,10 @@ describe('maxTokens 模型上限钳制(引擎默认 63488 场景)', () => { defaultModel: 'claude-opus-4-1', }); await adapter.send(makeRequest()); - expect(requestBody().max_tokens).toBe(32_000); + expect(requestBody().max_tokens).toBe(63_488); }); - it('Anthropic sonnet(上限 64000):63488 未超限', async () => { + it('Anthropic sonnet:max_tokens 原样透传', async () => { mockFetch.mockResolvedValue(anthropicResponse()); const adapter = new AnthropicAdapter({ provider: 'anthropic', @@ -177,13 +189,12 @@ describe('maxTokens 模型上限钳制(引擎默认 63488 场景)', () => { expect(requestBody().max_tokens).toBe(63_488); }); - it('未配置 maxTokens 时各 adapter 使用安全默认值(不超过模型上限)', async () => { + it('未配置 maxTokens:不下发任何输出上限字段(由 Provider 服务端默认值决定)', async () => { mockFetch.mockResolvedValue(openAIResponse()); const request = makeRequest({ params: { temperature: 0, stream: false } as MetonaRequest['params'], }); - // MiMo standard + thinking 默认 → 兜底 32768(= 上限,安全) const mimo = new MimoAdapter({ provider: 'mimo', baseURL: 'https://api.mimo.com/v1', @@ -191,6 +202,33 @@ describe('maxTokens 模型上限钳制(引擎默认 63488 场景)', () => { defaultModel: 'mimo-v2.5', }); await mimo.send(request); - expect(requestBody().max_completion_tokens).toBe(32_768); + expect(requestBody().max_completion_tokens).toBeUndefined(); + + const deepseek = new DeepSeekAdapter({ + provider: 'deepseek', + baseURL: 'https://api.deepseek.com', + apiKey: 'sk', + defaultModel: 'deepseek-v4-pro', + }); + mockFetch.mockReset(); + mockFetch.mockResolvedValue(openAIResponse()); + await deepseek.send(request); + expect(requestBody().max_tokens).toBeUndefined(); + }); + + it('Anthropic 未配置 maxTokens + thinking 开启:仅满足协议下限 2048(协议不变量,非上限)', async () => { + mockFetch.mockResolvedValue(anthropicResponse()); + const adapter = new AnthropicAdapter({ + provider: 'anthropic', + baseURL: 'https://api.anthropic.com', + apiKey: 'k', + defaultModel: 'claude-opus-4-1', + }); + await adapter.send( + makeRequest({ + params: { temperature: 0, stream: false, thinkingEnabled: true } as MetonaRequest['params'], + }), + ); + expect(requestBody().max_tokens).toBe(2048); }); }); diff --git a/electron/harness/adapters/__tests__/ollama.adapter.test.ts b/electron/harness/adapters/__tests__/ollama.adapter.test.ts index d54c21a..c2c9a54 100644 --- a/electron/harness/adapters/__tests__/ollama.adapter.test.ts +++ b/electron/harness/adapters/__tests__/ollama.adapter.test.ts @@ -452,13 +452,25 @@ describe('OllamaAdapter — 能力探测', () => { expect(caps).toEqual({ supportsTools: true, supportsVision: true, supportsThinking: true }); }); - it('capabilities 为空数组(showModel 缺省 [])→ 三能力全 false(非 null)', async () => { + // v0.8.1: showModel 保留 undefined 语义 —— 响应缺 capabilities 字段 = 未知 → null + //(fail-open),不再把"字段缺失"与"权威空"混同(探测竞态下曾误判不支持思考) + it('capabilities 字段缺失 → 返回 null(未知,fail-open)', async () => { const adapter = makeAdapter(); mockFetch.mockResolvedValue({ ok: true, status: 200, json: async () => ({ parameters: '', template: '' }), } as unknown as Response); + expect(await adapter.probeCapabilities('qwen3')).toBeNull(); + }); + + it('capabilities 为显式空数组(服务端权威无能力)→ 三能力全 false', async () => { + const adapter = makeAdapter(); + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ parameters: '', template: '', capabilities: [] }), + } as unknown as Response); expect(await adapter.probeCapabilities('qwen3')).toEqual({ supportsTools: false, supportsVision: false, @@ -688,28 +700,33 @@ describe('OllamaAdapter — 图片归一化', () => { // ===== getContextWindow ===== -describe('OllamaAdapter — getContextWindow', () => { - it('未探测到时返回默认 4096', () => { +describe('OllamaAdapter — getContextWindow(v0.8.1:唯一来源是设置面板配置)', () => { + it('未配置 contextWindow → 返回 0(无 4096 写死兜底,引擎跳过压缩判定)', () => { const adapter = makeAdapter(); - expect(adapter.getContextWindow()).toBe(4096); + expect(adapter.getContextWindow()).toBe(0); }); - it('探测到 num_ctx 后返回实测值(机会主义缓存收敛)', async () => { - // 需在构造前就位:构造时的 fire-and-forget 探测消费首个 fetch + it('config.contextWindow(llm.contextWindow 注入)→ 返回配置值', () => { + const adapter = makeAdapter('qwen3', { contextWindow: 32_768 }); + expect(adapter.getContextWindow()).toBe(32_768); + }); + + it('/api/show 探测不再缓存窗口数值(num_ctx 由引擎 contextLength 下发)', async () => { mockFetch.mockResolvedValue({ ok: true, status: 200, json: async () => ({ parameters: 'num_ctx 32768', template: '', capabilities: [] }), } as unknown as Response); const adapter = makeAdapter(); - // 等待构造时的探测完成并写入缓存 - await vi.waitFor(() => expect(adapter.getContextWindow()).toBe(32768)); + // 探测完成(含失败路径)后窗口仍为 0 —— 探测只服务于能力门控 + await new Promise((r) => setTimeout(r, 20)); + expect(adapter.getContextWindow()).toBe(0); }); - it('探测失败(网络错误)→ 保持默认 4096', async () => { + it('探测失败(网络错误)→ 仍返回配置值/0,不抛错不阻塞', async () => { mockFetch.mockRejectedValue(new Error('down')); const adapter = makeAdapter(); await new Promise((r) => setTimeout(r, 20)); - expect(adapter.getContextWindow()).toBe(4096); + expect(adapter.getContextWindow()).toBe(0); }); }); diff --git a/electron/harness/adapters/__tests__/openai.adapter.test.ts b/electron/harness/adapters/__tests__/openai.adapter.test.ts index b733500..890d030 100644 --- a/electron/harness/adapters/__tests__/openai.adapter.test.ts +++ b/electron/harness/adapters/__tests__/openai.adapter.test.ts @@ -205,12 +205,12 @@ describe('OpenAIAdapter — 推理模型拒图(ModelCapabilityError)', () => // ===== max_completion_tokens / max_tokens 路由 ===== -describe('OpenAIAdapter — token 参数路由', () => { +describe('OpenAIAdapter — token 参数路由(v0.8.1:原样透传,无模型钳制)', () => { it.each([ ['o3-mini', 63_488, 63_488, 'max_completion_tokens'], - ['o3-mini', 200_000, 100_000, 'max_completion_tokens'], // 上限 100000 - ['gpt-4o', 63_488, 16_384, 'max_tokens'], - ['gpt-4.1', 63_488, 32_768, 'max_tokens'], + ['o3-mini', 200_000, 200_000, 'max_completion_tokens'], // 超过任何旧元信息上限 → 原样 + ['gpt-4o', 63_488, 63_488, 'max_tokens'], + ['gpt-4.1', 63_488, 63_488, 'max_tokens'], ] as const)('%s maxTokens=%d → %s=%d', async (model, requested, expected, field) => { const adapter = makeAdapter(model); mockFetch.mockResolvedValue(okResponse()); @@ -224,18 +224,18 @@ describe('OpenAIAdapter — token 参数路由', () => { expect(body[other]).toBeUndefined(); }); - it('o3-mini 未配置 maxTokens → 默认 32768(thinking 场景安全值)', async () => { + it('o3-mini 未配置 maxTokens → 不下发 max_completion_tokens(无写死兜底)', async () => { const adapter = makeAdapter('o3-mini'); mockFetch.mockResolvedValue(okResponse()); await adapter.send(makeRequest({ params: { temperature: 0, stream: false } })); - expect(lastBody().max_completion_tokens).toBe(32_768); + expect(lastBody().max_completion_tokens).toBeUndefined(); }); - it('非推理模型未配置 maxTokens → 默认模型上限', async () => { + it('非推理模型未配置 maxTokens → 不下发 max_tokens(无写死兜底)', async () => { const adapter = makeAdapter('gpt-4o'); mockFetch.mockResolvedValue(okResponse()); await adapter.send(makeRequest({ params: { temperature: 0, stream: false } })); - expect(lastBody().max_tokens).toBe(16_384); + expect(lastBody().max_tokens).toBeUndefined(); }); }); @@ -277,23 +277,17 @@ describe('OpenAIAdapter — temperature 路由', () => { // ===== getContextWindow 回退链 ===== -describe('OpenAIAdapter — getContextWindow 回退链', () => { - it('gpt-4.1 返回 1M 上下文', () => { - expect(makeAdapter('gpt-4.1').getContextWindow()).toBe(1_000_000); - }); - - it('o3-mini 返回 200K', () => { - expect(makeAdapter('o3-mini').getContextWindow()).toBe(200_000); - }); - - it('未知模型 → 兜底 128K(v0.7.4 P4-5 从 1M 降级)', () => { - expect(makeAdapter('unknown-model-x').getContextWindow()).toBe(128_000); - }); - - it('config.contextWindow 显式配置优先', () => { +describe('OpenAIAdapter — getContextWindow(v0.8.1:唯一来源是设置面板配置)', () => { + it('config.contextWindow 显式配置(llm.contextWindow 注入)返回配置值', () => { const adapter = makeAdapter('gpt-4o', { contextWindow: 64_000 }); expect(adapter.getContextWindow()).toBe(64_000); }); + + it('未配置(任意模型,含已知/未知)→ 返回 0(引擎跳过压缩判定,无写死兜底)', () => { + expect(makeAdapter('gpt-4.1').getContextWindow()).toBe(0); + expect(makeAdapter('o3-mini').getContextWindow()).toBe(0); + expect(makeAdapter('unknown-model-x').getContextWindow()).toBe(0); + }); }); // ===== listModels ===== @@ -303,7 +297,9 @@ describe('OpenAIAdapter — listModels 动态发现与降级', () => { mockFetch.mockResolvedValue(okResponse({ data: [{ id: 'gpt-4o' }, { id: 'custom-model' }] })); const models = await makeAdapter('gpt-4o').listModels(); expect(models).toHaveLength(2); - expect(models[0]).toMatchObject({ id: 'gpt-4o', contextWindow: 128_000 }); + // v0.8.1: 元信息不再承载窗口/上限数值 + expect(models[0]).toMatchObject({ id: 'gpt-4o', name: 'GPT-4o' }); + expect(models[0].contextWindow).toBeUndefined(); expect(models[1]).toEqual({ id: 'custom-model' }); // /models 请求头携带 Bearer const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; diff --git a/electron/harness/adapters/__tests__/provider-request-shapes.test.ts b/electron/harness/adapters/__tests__/provider-request-shapes.test.ts index fa5a969..c0751d5 100644 --- a/electron/harness/adapters/__tests__/provider-request-shapes.test.ts +++ b/electron/harness/adapters/__tests__/provider-request-shapes.test.ts @@ -178,7 +178,7 @@ describe('AnthropicAdapter — 请求体契约', () => { expect(toolResultBlocks[0].tool_use_id).toBe('tc_1'); }); - it('A2: max_tokens 按模型上限钳制(63488 → sonnet 64000 / opus 32000)', async () => { + it('A2: max_tokens 原样透传(v0.8.1:模型钳制已废除,设置面板是唯一上限来源)', async () => { const sonnet = new AnthropicAdapter({ provider: 'anthropic', baseURL: 'http://a.test', @@ -194,9 +194,9 @@ describe('AnthropicAdapter — 请求体契约', () => { const { bodies } = captureFetch(); await sonnet.send(makeRequest()); await opus.send(makeRequest()); - // 引擎默认 63488 低于 sonnet 上限 64000 → 原样保留;opus 上限 32000 → 钳制生效 + // v0.8.1: 设置面板「最大输出上限」对一切模型原样透传,无任何按模型钳制 expect(bodies[0].max_tokens).toBe(63_488); - expect(bodies[1].max_tokens).toBe(32_000); + expect(bodies[1].max_tokens).toBe(63_488); }); it('A3: 小 maxTokens 时 thinking budget 不跌破协议下限 1024(v0.6.4 边界加固)', async () => { @@ -516,8 +516,8 @@ describe('AnthropicAdapter — thinking budget 按 effort 映射矩阵', () => { ['low', 1024], ['medium', 4096], ['high', 16384], - // max=32768 但 sonnet 的 max_tokens 先钳到 64000 → budget 二次钳到 floor(64000/2)=32000 - ['max', 32000], + // v0.8.1: max_tokens 不再按模型钳制(100_000 原样透传)→ budget = min(32768, floor(100000/2)) = 32768 + ['max', 32768], ] as const)('effort=%s → budget 为该档值且 < max_tokens', async (effort, expectBudget) => { const adapter = makeAdapter(); const { bodies } = captureFetch(); @@ -570,13 +570,13 @@ describe('AnthropicAdapter — thinking budget 按 effort 映射矩阵', () => { }); }); -describe('AnthropicAdapter — max_tokens 钳制矩阵', () => { +describe('AnthropicAdapter — max_tokens 透传矩阵(v0.8.1 无钳制)', () => { it.each([ - ['claude-sonnet-4-5', 63_488, 63_488], // 引擎默认低于上限 → 原样 - ['claude-sonnet-4-5', 70_000, 64_000], // 超上限 → 钳到 sonnet 64000 - ['claude-opus-4-1', 63_488, 32_000], // opus 上限 32000 - ['claude-haiku-4-5', 63_488, 32_000], // haiku 上限 32000 - ['claude-sonnet-4-5', 500, 500], // 低于上限 → 原样 + ['claude-sonnet-4-5', 63_488, 63_488], + ['claude-sonnet-4-5', 70_000, 70_000], // 超过任何旧元信息上限 → 原样透传 + ['claude-opus-4-1', 63_488, 63_488], + ['claude-haiku-4-5', 63_488, 63_488], + ['claude-sonnet-4-5', 500, 500], ])('%s maxTokens=%d → max_tokens=%d', async (model, requested, expected) => { const adapter = new AnthropicAdapter({ provider: 'anthropic', @@ -795,7 +795,7 @@ describe('DeepSeekAdapter — thinking 映射矩阵', () => { expect(bodies[0].stop).toEqual(['']); }); - it('max_tokens 按模型钳制(pro 384000 / vision 8192)', async () => { + it('max_tokens 原样透传(v0.8.1:pro/vision 均不钳制)', async () => { const pro = makeAdapter('deepseek-v4-pro'); const vision = makeAdapter('deepseek-v4-flash-vision-exp'); const { bodies } = captureFetch(); @@ -803,8 +803,8 @@ describe('DeepSeekAdapter — thinking 映射矩阵', () => { await vision.send( makeRequest({ params: { maxTokens: 63_488, temperature: 0, stream: false } }), ); - expect(bodies[0].max_tokens).toBe(384_000); - expect(bodies[1].max_tokens).toBe(8_192); + expect(bodies[0].max_tokens).toBe(500_000); + expect(bodies[1].max_tokens).toBe(63_488); }); }); @@ -864,8 +864,8 @@ describe('AgnesAdapter — enable_thinking 对称性矩阵', () => { makeRequest({ params: { maxTokens: 70_000, temperature: 0.9, stream: false } }), ); expect(bodies[0].temperature).toBe(0.9); - // 65536 上限钳制 - expect(bodies[0].max_tokens).toBe(65_536); + // v0.8.1: 原样透传,无 65536 钳制 + expect(bodies[0].max_tokens).toBe(70_000); }); }); @@ -912,21 +912,21 @@ describe('MimoAdapter — thinking 显式开关', () => { expect(bodies[0].top_p).toBeUndefined(); }); - it('max_completion_tokens 钳制矩阵(pro 131072 / standard 32768)', async () => { + it('max_completion_tokens 原样透传(v0.8.1:pro/standard 均不钳制)', async () => { const pro = makeAdapter({ defaultModel: 'mimo-v2.5-pro' }); const std = makeAdapter({ defaultModel: 'mimo-v2.5' }); const { bodies } = captureFetch(); await pro.send(makeRequest({ params: { maxTokens: 200_000, temperature: 0, stream: false } })); await std.send(makeRequest({ params: { maxTokens: 63_488, temperature: 0, stream: false } })); - expect(bodies[0].max_completion_tokens).toBe(131_072); - expect(bodies[1].max_completion_tokens).toBe(32_768); + expect(bodies[0].max_completion_tokens).toBe(200_000); + expect(bodies[1].max_completion_tokens).toBe(63_488); }); - it('thinking 未关闭时未配置 maxTokens → 兜底 32768(思考占配额,防截断)', async () => { + it('thinking 未关闭时未配置 maxTokens → 不下发该字段(v0.8.1:无写死兜底值)', async () => { const pro = makeAdapter({ defaultModel: 'mimo-v2.5-pro' }); const { bodies } = captureFetch(); await pro.send(makeRequest({ params: { temperature: 0, stream: false } })); - expect(bodies[0].max_completion_tokens).toBe(32_768); + expect(bodies[0].max_completion_tokens).toBeUndefined(); }); it('enableWebSearch 且存在客户端 tools → web_search 服务端工具追加(不覆盖客户端工具)', async () => { @@ -1029,27 +1029,36 @@ describe('OpenAIAdapter — 推理模型字段路由(v0.6.4 P3-1)', () => { expect(bodies[0].reasoning_effort).toBeUndefined(); }); - it('gpt-4.1 长上下文 1M → getContextWindow 返回 1M(模型元信息表)', () => { - const adapter = makeAdapter('gpt-4.1'); + it('gpt-4.1 → getContextWindow 返回设置面板配置值(v0.8.1:元信息不再承载窗口)', () => { + const adapter = new OpenAIAdapter({ + provider: 'openai', + baseURL: 'http://o.test', + apiKey: 'k', + defaultModel: 'gpt-4.1', + contextWindow: 1_000_000, + }); expect(adapter.getContextWindow()).toBe(1_000_000); + // 未配置 → 0(引擎跳过压缩判定),无任何写死兜底 + const noCfg = makeAdapter('gpt-4.1'); + expect(noCfg.getContextWindow()).toBe(0); }); - it('o3-mini max_completion_tokens 钳制到 100000', async () => { + it('o3-mini max_completion_tokens 原样透传(v0.8.1:无 100000 钳制)', async () => { const adapter = makeAdapter('o3-mini'); const { bodies } = captureFetch(); await adapter.send( makeRequest({ params: { maxTokens: 200_000, temperature: 0, stream: false } }), ); - expect(bodies[0].max_completion_tokens).toBe(100_000); + expect(bodies[0].max_completion_tokens).toBe(200_000); }); - it('gpt-4o max_tokens 钳制到 16384', async () => { + it('gpt-4o max_tokens 原样透传(v0.8.1:无 16384 钳制)', async () => { const adapter = makeAdapter('gpt-4o'); const { bodies } = captureFetch(); await adapter.send( makeRequest({ params: { maxTokens: 63_488, temperature: 0, stream: false } }), ); - expect(bodies[0].max_tokens).toBe(16_384); + expect(bodies[0].max_tokens).toBe(63_488); }); }); @@ -1255,16 +1264,16 @@ describe('OllamaAdapter — options 缺省与工具定义', () => { // ===== 跨 Provider maxTokens 钳制矩阵 ===== -describe('跨 Provider — maxTokens 钳制矩阵汇总', () => { +describe('跨 Provider — maxTokens 透传矩阵汇总(v0.8.1 无钳制)', () => { it.each([ - ['anthropic', 'claude-opus-4-1', 100_000, 32_000], - ['anthropic', 'claude-sonnet-4-5', 100_000, 64_000], - ['deepseek', 'deepseek-v4-flash-vision-exp', 100_000, 8_192], - ['agnes', 'agnes-2.0-flash', 100_000, 65_536], - ['mimo', 'mimo-v2.5', 100_000, 32_768], - ['openai', 'gpt-4o', 100_000, 16_384], + ['anthropic', 'claude-opus-4-1', 100_000, 100_000], + ['anthropic', 'claude-sonnet-4-5', 100_000, 100_000], + ['deepseek', 'deepseek-v4-flash-vision-exp', 100_000, 100_000], + ['agnes', 'agnes-2.0-flash', 100_000, 100_000], + ['mimo', 'mimo-v2.5', 100_000, 100_000], + ['openai', 'gpt-4o', 100_000, 100_000], ] as const)( - '%s %s maxTokens=100000 → 钳制为 %d', + '%s %s maxTokens=100000 → 原样透传 %d', async (provider, model, requested, expected) => { const adapterMap: Record = { anthropic: new AnthropicAdapter({ diff --git a/electron/harness/adapters/__tests__/thinking-capability-gate.test.ts b/electron/harness/adapters/__tests__/thinking-capability-gate.test.ts index 9dfcf89..8b910e9 100644 --- a/electron/harness/adapters/__tests__/thinking-capability-gate.test.ts +++ b/electron/harness/adapters/__tests__/thinking-capability-gate.test.ts @@ -56,7 +56,7 @@ function asNative( } describe('P0-3 修订: 用户思考意图优先于模型元信息', () => { - it('DeepSeek: vision-exp(元信息 false)+ 用户开启思考 → 照发 enabled + reasoning_effort,max_tokens 仍按模型钳制 8192', async () => { + it('DeepSeek: vision-exp(元信息 false)+ 用户开启思考 → 照发 enabled + reasoning_effort,max_tokens 原样透传(v0.8.1 无钳制)', async () => { const adapter = new DeepSeekAdapter({ provider: 'deepseek', baseURL: 'https://api.deepseek.com', @@ -66,7 +66,7 @@ describe('P0-3 修订: 用户思考意图优先于模型元信息', () => { const body = asNative(adapter)(makeRequest(), false); expect(body.thinking).toEqual({ type: 'enabled' }); expect(body.reasoning_effort).toBe('max'); - expect(body.max_tokens).toBe(8192); + expect(body.max_tokens).toBe(63488); }); it('DeepSeek: 用户关闭思考 → 显式 disabled', async () => { diff --git a/electron/harness/adapters/agnes-ai.adapter.ts b/electron/harness/adapters/agnes-ai.adapter.ts index f2f1d45..f7554c2 100644 --- a/electron/harness/adapters/agnes-ai.adapter.ts +++ b/electron/harness/adapters/agnes-ai.adapter.ts @@ -23,16 +23,15 @@ export class AgnesAdapter extends OpenAICompatibleAdapter { readonly supportsToolCalling = true; readonly supportsThinking = true; - // H-2 修复: Agnes 模型元信息(1M 上下文,65.5K 最大输出) + // H-2 修复: Agnes 模型元信息(v0.8.1: 仅承载展示与能力声明 —— 窗口/输出上限 + // 数值已按硬性契约删除,唯一合法来源是设置面板 llm.contextWindow / llm.maxTokens) private static readonly MODEL_INFO: Record = { 'agnes-2.0-flash': { id: 'agnes-2.0-flash', name: 'Agnes 2.0 Flash', - contextWindow: 1_000_000, - maxOutputTokens: 65_536, supportsToolCalling: true, supportsThinking: true, - description: 'Agnes AI 快速版,1M 上下文,支持多模态图片(URL + Base64)与思考模式', + description: 'Agnes AI 快速版,支持多模态图片(URL + Base64)与思考模式', }, }; @@ -72,16 +71,13 @@ export class AgnesAdapter extends OpenAICompatibleAdapter { ); } - // v0.5.3: max_tokens 按模型上限钳制(agnes-2.0-flash 上限 65536) - const modelInfo = AgnesAdapter.MODEL_INFO[this.config.defaultModel]; - const maxOutput = modelInfo?.maxOutputTokens ?? 65_536; - const maxTokens = Math.min(request.params.maxTokens ?? maxOutput, maxOutput); - + // v0.8.1 硬性契约: max_tokens 原样透传设置面板「最大输出上限」(llm.maxTokens), + // 删除了旧的按模型元信息钳制逻辑 const body: Record = { model: this.config.defaultModel, messages, temperature: request.params.temperature, - max_tokens: maxTokens, + max_tokens: request.params.maxTokens, stream, }; diff --git a/electron/harness/adapters/anthropic.adapter.ts b/electron/harness/adapters/anthropic.adapter.ts index 686ed59..2483ba0 100644 --- a/electron/harness/adapters/anthropic.adapter.ts +++ b/electron/harness/adapters/anthropic.adapter.ts @@ -52,21 +52,19 @@ export class AnthropicAdapter extends BaseAdapter { readonly supportsToolCalling = true; readonly supportsThinking = true; + // v0.8.1: 仅承载展示与能力声明 —— 窗口/输出上限数值已按硬性契约删除, + // 唯一合法来源是设置面板 llm.contextWindow / llm.maxTokens private static readonly MODEL_INFO: Record = { 'claude-sonnet-4-5': { id: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5', - contextWindow: 200_000, - maxOutputTokens: 64_000, supportsToolCalling: true, supportsThinking: true, - description: 'Anthropic 旗舰模型,200K 上下文,支持扩展思考与工具调用', + description: 'Anthropic 旗舰模型,支持扩展思考与工具调用', }, 'claude-opus-4-1': { id: 'claude-opus-4-1', name: 'Claude Opus 4.1', - contextWindow: 200_000, - maxOutputTokens: 32_000, supportsToolCalling: true, supportsThinking: true, description: 'Anthropic 深度推理模型', @@ -74,8 +72,6 @@ export class AnthropicAdapter extends BaseAdapter { 'claude-haiku-4-5': { id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5', - contextWindow: 200_000, - maxOutputTokens: 32_000, supportsToolCalling: true, supportsThinking: true, description: 'Anthropic 低延迟模型', @@ -416,13 +412,9 @@ export class AnthropicAdapter extends BaseAdapter { return this.supportedModels.map((id) => AnthropicAdapter.MODEL_INFO[id] ?? { id }); } - override getContextWindow(): number { - if (typeof this.config.contextWindow === 'number' && this.config.contextWindow > 0) { - return this.config.contextWindow; - } - const modelInfo = AnthropicAdapter.MODEL_INFO[this.config.defaultModel]; - return modelInfo?.contextWindow ?? 200_000; - } + // getContextWindow 使用基类实现 —— v0.8.1 硬性契约:唯一来源是 + // 设置面板「上下文长度」(llm.contextWindow → AdapterConfig.contextWindow), + // 未配置返回 0,引擎据此跳过压缩预算计算。 // ========== 私有方法 ========== @@ -531,22 +523,21 @@ export class AnthropicAdapter extends BaseAdapter { }); } - // v0.5.3: max_tokens 按模型上限钳制(sonnet 64000 / opus 32000 / haiku 32000)— - // 引擎默认 63488 超过 opus/haiku 上限时 API 直接 400;thinking budget 已在此值内二分 - const anthropicMaxOutput = - AnthropicAdapter.MODEL_INFO[this.config.defaultModel]?.maxOutputTokens ?? 64_000; - - // v0.6.4 边界加固: thinking 开启时保证 max_tokens ≥ 2048 —— 协议要求 - // budget_tokens >= 1024 且 < max_tokens。原实现当用户配置极小 maxTokens - // (如 1500)时 Math.floor(1500/2)=750 < 1024 直接 API 400。 - const requestedMaxTokens = request.params.maxTokens ?? 8192; + // v0.8.1 硬性契约: max_tokens 原样透传设置面板「最大输出上限」(llm.maxTokens), + // 删除了旧的按模型元信息钳制逻辑(MODEL_INFO.maxOutputTokens 已删除)。 + // 唯一保留的协议不变量:thinking 开启时 max_tokens ≥ 2048 —— Anthropic 协议 + // 要求 budget_tokens >= 1024 且 < max_tokens,用户配置低于该下限时 API 必然 + // 400,此为协议正确性下限而非输出上限(不修改用户配置的持久化值,仅在 + // 本次请求体上满足协议约束)。 + const requestedMaxTokens = request.params.maxTokens; const maxTokensForRequest = thinkingRequested - ? Math.max(2048, Math.min(requestedMaxTokens, anthropicMaxOutput)) - : Math.min(requestedMaxTokens, anthropicMaxOutput); + ? Math.max(2048, requestedMaxTokens ?? 2048) + : requestedMaxTokens; const body: Record = { model: this.config.defaultModel, - max_tokens: maxTokensForRequest, + // v0.8.1: 未配置「最大输出上限」时不下发 max_tokens(服务端默认值生效) + ...(maxTokensForRequest != null ? { max_tokens: maxTokensForRequest } : {}), messages: merged, stream, }; @@ -581,7 +572,8 @@ export class AnthropicAdapter extends BaseAdapter { max: 32768, }; const effortBudget = budgetMap[request.params.thinkingEffort ?? 'high'] ?? 16384; - const budget = Math.min(effortBudget, Math.floor(maxTokensForRequest / 2)); + // thinking 路径 maxTokensForRequest 恒为数字(Math.max(2048, …) 兜底) + const budget = Math.min(effortBudget, Math.floor((maxTokensForRequest ?? 2048) / 2)); body.thinking = { type: 'enabled', budget_tokens: budget }; } else { body.temperature = request.params.temperature; diff --git a/electron/harness/adapters/base-adapter.ts b/electron/harness/adapters/base-adapter.ts index bbe238f..c321034 100644 --- a/electron/harness/adapters/base-adapter.ts +++ b/electron/harness/adapters/base-adapter.ts @@ -69,19 +69,15 @@ export abstract class BaseAdapter implements IMetonaProviderAdapter { /** * H-2 修复: 获取上下文窗口大小(规范要求) * - * 默认实现从 config 读取 contextWindow,子类可覆盖以支持动态查询。 - * Engine 用此值估算上下文使用率,决定是否触发压缩。 - * - * @returns 上下文窗口大小(token 数) + * v0.8.1 硬性契约:上下文窗口的唯一合法来源是设置面板 LLM 配置的「上下文长度」 + * (llm.contextWindow,经 main.ts 注入 AdapterConfig.contextWindow)。 + * 本基类与所有子类禁止携带任何写死的默认窗口值 —— 未配置时返回 0, + * 引擎据此跳过压缩预算计算(syncContextWindow 仅在 >0 时采纳)。 */ getContextWindow(): number { - // 优先使用 AdapterConfig.contextWindow(如果存在) const ctx = (this.config as AdapterConfig & { contextWindow?: number }).contextWindow; if (typeof ctx === 'number' && ctx > 0) return ctx; - // v0.7.4 P4-5: 兜底从 1M 降至 128K —— 旧默认值 1M 在 config 与模型元信息均缺失时 - // (如 DeepSeek 未知模型),压缩阈值按 1M 算,实际 64K/128K 模型会先 413 再压缩。 - // 128K 是当前最保守的主流窗口,未知模型按最小值预算更安全。 - return 128_000; + return 0; } /** diff --git a/electron/harness/adapters/deepseek.adapter.ts b/electron/harness/adapters/deepseek.adapter.ts index 133e46f..2b7c2b2 100644 --- a/electron/harness/adapters/deepseek.adapter.ts +++ b/electron/harness/adapters/deepseek.adapter.ts @@ -28,32 +28,27 @@ export class DeepSeekAdapter extends OpenAICompatibleAdapter { readonly supportsToolCalling = true; readonly supportsThinking = true; - // H-2 修复: DeepSeek 模型元信息(1M 上下文,384K 最大输出) + // H-2 修复: DeepSeek 模型元信息(v0.8.1: 仅承载展示与能力声明 —— 窗口/输出上限 + // 数值已按硬性契约删除,唯一合法来源是设置面板 llm.contextWindow / llm.maxTokens) private static readonly MODEL_INFO: Record = { 'deepseek-v4-pro': { id: 'deepseek-v4-pro', name: 'DeepSeek V4 Pro', - contextWindow: 1_000_000, - maxOutputTokens: 384_000, supportsToolCalling: true, supportsThinking: true, - description: 'DeepSeek 旗舰模型,1M 上下文,支持深度推理与工具调用', + description: 'DeepSeek 旗舰模型,支持深度推理与工具调用', }, 'deepseek-v4-flash': { id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash', - contextWindow: 1_000_000, - maxOutputTokens: 384_000, supportsToolCalling: true, supportsThinking: true, - description: 'DeepSeek 快速版,1M 上下文,低延迟推理', + description: 'DeepSeek 快速版,低延迟推理', }, // v0.5.4: DeepSeek 多模态实验模型(OpenAI image_url content parts 格式) 'deepseek-v4-flash-vision-exp': { id: 'deepseek-v4-flash-vision-exp', name: 'DeepSeek V4 Flash Vision (Exp)', - contextWindow: 128_000, - maxOutputTokens: 8_192, supportsToolCalling: true, supportsThinking: false, description: 'DeepSeek 多模态实验模型,支持图片输入(image_url content parts)', @@ -173,16 +168,13 @@ export class DeepSeekAdapter extends OpenAICompatibleAdapter { const messages = buildOpenAICompatibleMessages(request, this.isVisionModel()); const tools = buildOpenAICompatibleTools(request.tools); - // v0.5.3: max_tokens 按模型上限钳制 — 引擎默认 63488 超过部分模型上限时 API 直接 400 - const modelInfo = DeepSeekAdapter.MODEL_INFO[this.config.defaultModel]; - const maxOutput = modelInfo?.maxOutputTokens ?? 384_000; - const maxTokens = Math.min(request.params.maxTokens ?? maxOutput, maxOutput); - + // v0.8.1 硬性契约: max_tokens 原样透传设置面板「最大输出上限」(llm.maxTokens), + // 删除了旧的按模型元信息钳制逻辑 —— 代码中不存在任何写死的输出上限。 const body: Record = { model: this.config.defaultModel, messages, temperature: request.params.temperature, - max_tokens: maxTokens, + max_tokens: request.params.maxTokens, stream, }; @@ -228,11 +220,11 @@ export class DeepSeekAdapter extends OpenAICompatibleAdapter { `[DeepSeek] model "${this.config.defaultModel}" metadata says thinking unsupported — sending thinking params per user config (degraded retry handles budget exhaustion)`, ); } - // v0.8.0 P0-3: 思考会占用输出预算 —— 钳制后预算过小时显式告警 + // v0.8.0 P0-3: 思考会占用输出预算 —— 用户配置的输出预算过小时显式告警 //(思考 token 计入 max_tokens,预算过小会出现"思考耗尽正文为零"截断) - if (maxTokens < 8192) { + if (typeof request.params.maxTokens === 'number' && request.params.maxTokens < 8192) { log.warn( - `[DeepSeek] thinking enabled with small output budget (${maxTokens} tokens after model clamp) — reasoning may consume the entire budget and truncate the answer`, + `[DeepSeek] thinking enabled with small output budget (${request.params.maxTokens} tokens per user config) — reasoning may consume the entire budget and truncate the answer`, ); } } diff --git a/electron/harness/adapters/mimo.adapter.ts b/electron/harness/adapters/mimo.adapter.ts index 453940b..728bd04 100644 --- a/electron/harness/adapters/mimo.adapter.ts +++ b/electron/harness/adapters/mimo.adapter.ts @@ -24,13 +24,12 @@ export class MimoAdapter extends OpenAICompatibleAdapter { readonly supportsThinking = true; // MiMo 模型元信息 - // mimo-v2.5-pro: 1M 上下文 / 131072 max_tokens;mimo-v2.5: 1M 上下文 / 32768 max_tokens + // v0.8.1: 仅承载展示与能力声明 —— 窗口/输出上限数值已按硬性契约删除, + // 唯一合法来源是设置面板 llm.contextWindow / llm.maxTokens private static readonly MODEL_INFO: Record = { 'mimo-v2.5-pro': { id: 'mimo-v2.5-pro', name: 'MiMo V2.5 Pro', - contextWindow: 1_000_000, - maxOutputTokens: 131_072, supportsToolCalling: true, supportsThinking: true, description: '小米 MiMo 旗舰模型,支持深度思考与工具调用', @@ -38,8 +37,6 @@ export class MimoAdapter extends OpenAICompatibleAdapter { 'mimo-v2.5': { id: 'mimo-v2.5', name: 'MiMo V2.5', - contextWindow: 1_000_000, - maxOutputTokens: 32_768, supportsToolCalling: true, supportsThinking: true, description: '小米 MiMo 标准模型,低延迟推理', @@ -82,17 +79,13 @@ export class MimoAdapter extends OpenAICompatibleAdapter { const tools = buildOpenAICompatibleTools(request.tools); // MiMo 使用 max_completion_tokens(非 max_tokens) - // #41 修复: thinking 模式下未配置时兜底 32768(thinking 占用 token 配额,API 默认值过小会截断输出) - // v0.5.3: 按模型上限钳制(pro 131072 / standard 32768)— - // 引擎默认 63488 超过 standard 上限时 API 直接 400 - const mimoMaxOutput = - MimoAdapter.MODEL_INFO[this.config.defaultModel]?.maxOutputTokens ?? 131_072; - const mimoDefault = request.params.thinkingEnabled !== false ? 32_768 : mimoMaxOutput; - + // v0.8.1 硬性契约: 原样透传设置面板「最大输出上限」(llm.maxTokens),删除了 + // 旧的"#41 thinking 兜底 32768"与"按模型上限钳制"逻辑 —— 代码中不存在任何 + // 写死的输出上限;未配置时该字段不下发,由服务端默认值决定。 const body: Record = { model: this.config.defaultModel, messages, - max_completion_tokens: Math.min(request.params.maxTokens ?? mimoDefault, mimoMaxOutput), + max_completion_tokens: request.params.maxTokens, stream, }; @@ -145,11 +138,11 @@ export class MimoAdapter extends OpenAICompatibleAdapter { `[MiMo] model "${this.config.defaultModel}" metadata says thinking unsupported — sending thinking params per user config (degraded retry handles budget exhaustion)`, ); } - // v0.8.0 P0-3: 思考占用输出预算 —— 钳制后预算过小时显式告警 - const effectiveMax = body.max_completion_tokens as number; + // v0.8.0 P0-3: 思考占用输出预算 —— 用户配置的输出预算过小时显式告警 + const effectiveMax = body.max_completion_tokens as number | undefined; if (typeof effectiveMax === 'number' && effectiveMax < 8192) { log.warn( - `[MiMo] thinking enabled with small output budget (${effectiveMax} tokens after model clamp) — reasoning may consume the entire budget and truncate the answer`, + `[MiMo] thinking enabled with small output budget (${effectiveMax} tokens per user config) — reasoning may consume the entire budget and truncate the answer`, ); } } diff --git a/electron/harness/adapters/ollama.adapter.ts b/electron/harness/adapters/ollama.adapter.ts index dc2419a..deada71 100644 --- a/electron/harness/adapters/ollama.adapter.ts +++ b/electron/harness/adapters/ollama.adapter.ts @@ -37,18 +37,48 @@ export class OllamaAdapter extends BaseAdapter { readonly supportsToolCalling = true; readonly supportsThinking = true; - // H-2 修复: Ollama 本地模型默认上下文窗口(可由 options.num_ctx 覆盖) - private static readonly DEFAULT_CONTEXT_WINDOW = 4096; - private baseURL: string; constructor(config: ConstructorParameters[0]) { super(config); this.baseURL = config.baseURL || 'http://localhost:11434'; - // v0.6.4 P4-1: 每个适配器实例(= 每会话独立引擎)启动时做一次 /api/show 探测, - // 把 num_ctx 实测值填充进 getContextWindow 缓存。fire-and-forget:失败静默, - // 不阻塞/不影响首个请求;此后压缩预算基于实测窗口而非保守默认 4096。 - this.refreshContextWindow(); + // v0.8.1: 上下文窗口不再从 /api/show 探测或写死默认值获取 —— 唯一合法来源是 + // 设置面板「上下文长度」(llm.contextWindow → AdapterConfig.contextWindow, + // 引擎侧经 contextLength=num_ctx 下发)。构造时仅探测能力(thinking/tools/vision, + // 属协议正确性门控),不再缓存窗口数值。 + this.probeCapabilitiesOnce(); + } + + /** /api/show 能力探测只发一次(构造函数发起),失败不重试(fail-open) */ + private probeAttempted = false; + private probeInProgress = false; + /** + * /api/show capabilities 探测缓存 —— 模型是否支持思考(协议正确性门控, + * 非窗口/输出上限语义)。null = 未探测/探测失败(fail-open 放行,与 + * listModels 能力回退策略一致);false = 服务端明确不支持 → 不发 think 参数。 + */ + private cachedThinkingSupport: boolean | null = null; + + /** + * v0.8.1: 构造时 fire-and-forget 探测一次默认模型能力(仅 thinking 门控消费)。 + * 旧实现同时缓存 num_ctx 窗口数值 —— 已按"窗口唯一来源是设置面板"契约删除。 + */ + private probeCapabilitiesOnce(): void { + if (this.probeAttempted) return; + this.probeAttempted = true; + this.probeInProgress = true; + void this.showModel(this.config.defaultModel) + .then((info) => { + if (Array.isArray(info?.capabilities)) { + this.cachedThinkingSupport = info!.capabilities.map(String).includes('thinking'); + } + }) + .catch(() => { + /* 模型探测失败不阻塞对话(fail-open) */ + }) + .finally(() => { + this.probeInProgress = false; + }); } // ===== POST /api/chat ===== @@ -356,14 +386,13 @@ export class OllamaAdapter extends BaseAdapter { // 该模型回退保守 true(不可用时行为与旧实现一致,fail-open 保可用性) // v0.7.3 P1-4: supportsVision 随探测结果透出(undefined = 未知 → 前端保守放行), // 供上传入口拒绝不支持图片的本地语言模型 + // v0.8.1: 不再填充 contextWindow —— 窗口唯一来源是设置面板「上下文长度」 const enriched = await Promise.all( data.models.map(async (m) => { const caps = await this.probeCapabilities(m.name); return { id: m.name, name: m.name, - // Ollama 模型上下文窗口由 options.num_ctx 决定,此处给保守值 - contextWindow: OllamaAdapter.DEFAULT_CONTEXT_WINDOW, supportsToolCalling: caps ? caps.supportsTools : true, supportsThinking: caps ? caps.supportsThinking : true, supportsVision: caps ? caps.supportsVision : undefined, @@ -386,59 +415,15 @@ export class OllamaAdapter extends BaseAdapter { /** * H-2 修复: 获取上下文窗口大小(规范要求) * - * Ollama 上下文窗口由 options.num_ctx 决定(默认 4096), - * Engine 应通过 MetonaRequest.params.contextLength 显式设置。 - * 此处返回默认值,供 Engine 在未指定时参考。 + * v0.8.1 硬性契约: 唯一来源是设置面板「上下文长度」(llm.contextWindow), + * 未配置返回 0 —— 删除了旧的 4096 写死默认值与 /api/show num_ctx 探测缓存。 + * Ollama 的 num_ctx 由引擎经 params.contextLength(同源配置)下发给服务端。 */ override getContextWindow(): number { - return this.cachedContextWindow ?? OllamaAdapter.DEFAULT_CONTEXT_WINDOW; - } - - /** - * v0.6.4 P4-1: 从 /api/show 的 parameters 区解析 num_ctx 真值。 - * - * 契约约束:IMetonaProviderAdapter.getContextWindow 是同步接口(引擎压缩判定 - * 依赖同步取值),无法在内部 await。因此采用"机会主义缓存"策略: - * send/sendStream 启动时 fire-and-forget 刷新缓存;首次请求前返回默认 4096, - * 之后永远返回实测值。压缩预算的准确性随使用逐渐收敛到真值。 - */ - private cachedContextWindow: number | null = null; - private refreshingContextWindow = false; - /** v0.8.0 P0-3: /api/show 探测只发一次(构造函数发起),失败不重试(fail-open) */ - private probeAttempted = false; - /** - * v0.8.0 P0-3: /api/show capabilities 探测缓存 —— 模型是否支持思考。 - * null = 未探测/探测失败(fail-open 放行,与 listModels 能力回退策略一致); - * false = 服务端明确不支持 → toNativeRequest 不发 think 参数。 - */ - private cachedThinkingSupport: boolean | null = null; - - private refreshContextWindow(): void { - if (this.refreshingContextWindow || this.probeAttempted) return; - this.probeAttempted = true; - this.refreshingContextWindow = true; - void this.showModel(this.config.defaultModel) - .then((info) => { - if (!info?.parameters) return; - const match = /^num_ctx\s+(\d+)\s*$/m.exec(info.parameters); - if (match) { - const value = Number(match[1]); - if (Number.isFinite(value) && value > 0) { - this.cachedContextWindow = value; - log.info(`[Ollama] Context window (num_ctx) detected: ${value}`); - } - } - // v0.8.0 P0-3: 同一次探测顺带缓存思考能力(供 toNativeRequest 同步门控) - if (Array.isArray(info.capabilities)) { - this.cachedThinkingSupport = info.capabilities.map(String).includes('thinking'); - } - }) - .catch(() => { - /* 模型探测失败不阻塞对话 */ - }) - .finally(() => { - this.refreshingContextWindow = false; - }); + if (typeof this.config.contextWindow === 'number' && this.config.contextWindow > 0) { + return this.config.contextWindow; + } + return 0; } /** @@ -466,7 +451,7 @@ export class OllamaAdapter extends BaseAdapter { async showModel( model: string, - ): Promise<{ parameters: string; template: string; capabilities: string[] } | null> { + ): Promise<{ parameters: string; template: string; capabilities?: string[] } | null> { try { const response = await fetch(`${this.baseURL}/api/show`, { method: 'POST', @@ -483,7 +468,10 @@ export class OllamaAdapter extends BaseAdapter { return { parameters: data.parameters ?? '', template: data.template ?? '', - capabilities: data.capabilities ?? [], + // v0.8.1: 保留 undefined 语义 —— 响应未携带 capabilities 字段 = 未知 + //(fail-open),显式数组(含空数组 = 服务端权威"无任何能力")才参与门控。 + // 旧实现 `?? []` 把"字段缺失"与"权威空"混同,探测竞态下会误判不支持思考。 + capabilities: data.capabilities, }; } catch { return null; @@ -707,7 +695,7 @@ export class OllamaAdapter extends BaseAdapter { // think 会导致每次请求 400 "does not support thinking",而非静默忽略), // 故此处门控属协议正确性而非用户意图覆盖;探测为服务端实时真值(非静态 // 元信息)。未探测/探测失败(null)fail-open 放行,与 listModels 能力回退 - // 策略一致。探测在适配器实例创建时 fire-and-forget 发起(refreshContextWindow)。 + // 策略一致。探测在适配器实例创建时 fire-and-forget 发起(probeCapabilitiesOnce)。 if (request.params.thinkingEnabled) { const modelThinkingSupported = this.cachedThinkingSupport !== false; if (modelThinkingSupported) { diff --git a/electron/harness/adapters/openai.adapter.ts b/electron/harness/adapters/openai.adapter.ts index 738bec5..a9826bd 100644 --- a/electron/harness/adapters/openai.adapter.ts +++ b/electron/harness/adapters/openai.adapter.ts @@ -24,39 +24,33 @@ export class OpenAIAdapter extends OpenAICompatibleAdapter { readonly supportsToolCalling = true; readonly supportsThinking = true; + // v0.8.1: 仅承载展示与能力声明 —— 窗口/输出上限数值已按硬性契约删除, + // 唯一合法来源是设置面板 llm.contextWindow / llm.maxTokens private static readonly MODEL_INFO: Record = { 'gpt-4o': { id: 'gpt-4o', name: 'GPT-4o', - contextWindow: 128_000, - maxOutputTokens: 16_384, supportsToolCalling: true, supportsThinking: false, - description: 'OpenAI 旗舰多模态模型,128K 上下文', + description: 'OpenAI 旗舰多模态模型', }, 'gpt-4o-mini': { id: 'gpt-4o-mini', name: 'GPT-4o mini', - contextWindow: 128_000, - maxOutputTokens: 16_384, supportsToolCalling: true, supportsThinking: false, - description: 'OpenAI 高性价比模型,128K 上下文', + description: 'OpenAI 高性价比模型', }, 'gpt-4.1': { id: 'gpt-4.1', name: 'GPT-4.1', - contextWindow: 1_000_000, - maxOutputTokens: 32_768, supportsToolCalling: true, supportsThinking: false, - description: 'OpenAI 长上下文模型,1M 上下文', + description: 'OpenAI 长上下文模型', }, 'o3-mini': { id: 'o3-mini', name: 'o3-mini', - contextWindow: 200_000, - maxOutputTokens: 100_000, supportsToolCalling: true, supportsThinking: true, description: 'OpenAI 推理模型,支持 reasoning_effort', @@ -81,11 +75,6 @@ export class OpenAIAdapter extends OpenAICompatibleAdapter { return 'OpenAI'; } - // v0.6.4: OpenAI 家族兜底窗口为 128K(其余 OpenAI 兼容 Provider 为 1M) - protected override defaultContextWindowFallback(): number { - return 128_000; - } - // ===== GET /v1/models ===== override async listModels(): Promise { @@ -137,18 +126,13 @@ export class OpenAIAdapter extends OpenAICompatibleAdapter { }; // Token 上限参数:o 系列/gpt-5 使用 max_completion_tokens - // v0.5.3: 按模型上限钳制(gpt-4o 16384 / gpt-4.1 32768 / o3-mini 100000)— - // 引擎默认 63488 超过 gpt-4o/gpt-4.1 上限时 API 直接 400 - const oaMaxOutput = OpenAIAdapter.MODEL_INFO[model]?.maxOutputTokens ?? 128_000; - const oaMaxTokens = Math.min( - request.params.maxTokens ?? (isReasoningModel ? 32_768 : oaMaxOutput), - oaMaxOutput, - ); - if (oaMaxTokens) { + // v0.8.1 硬性契约: 原样透传设置面板「最大输出上限」(llm.maxTokens),删除了 + // 旧的按模型元信息钳制逻辑与未配置时的 32_768 兜底 —— 未配置时不下发该字段。 + if (request.params.maxTokens != null) { if (isReasoningModel) { - body.max_completion_tokens = oaMaxTokens; + body.max_completion_tokens = request.params.maxTokens; } else { - body.max_tokens = oaMaxTokens; + body.max_tokens = request.params.maxTokens; } } @@ -169,10 +153,10 @@ export class OpenAIAdapter extends OpenAICompatibleAdapter { max: 'high', }; body.reasoning_effort = effortMap[request.params.thinkingEffort ?? 'high'] ?? 'high'; - // v0.8.0 P0-3: 推理 token 计入 max_completion_tokens —— 钳制后预算过小时告警 - if (oaMaxTokens < 8192) { + // v0.8.0 P0-3: 推理 token 计入 max_completion_tokens —— 用户配置的预算过小时告警 + if (typeof request.params.maxTokens === 'number' && request.params.maxTokens < 8192) { log.warn( - `[OpenAI] reasoning enabled with small output budget (${oaMaxTokens} tokens after model clamp) — reasoning may consume the entire budget and truncate the answer`, + `[OpenAI] reasoning enabled with small output budget (${request.params.maxTokens} tokens per user config) — reasoning may consume the entire budget and truncate the answer`, ); } } else if (!isReasoningModel) { diff --git a/electron/harness/adapters/shared/openai-compatible-base.ts b/electron/harness/adapters/shared/openai-compatible-base.ts index 3a6710b..98aa9fe 100644 --- a/electron/harness/adapters/shared/openai-compatible-base.ts +++ b/electron/harness/adapters/shared/openai-compatible-base.ts @@ -41,16 +41,9 @@ export abstract class OpenAICompatibleAdapter extends BaseAdapter { /** 非流式 send 的默认超时。DeepSeek/MiMo/OpenAI=120s;Agnes 历史 300s,保留其值。 */ protected abstract sendTimeoutMs(): number; - /** 模型元信息表(子类持有;用于 getContextWindow 回退链与钳制) */ + /** 模型元信息表(子类持有;仅承载展示与能力声明,不含任何窗口/输出上限数值) */ protected abstract modelInfoTable(): Record; - /** getContextWindow 的最终兜底窗口(未配置且模型未知时使用) */ - // v0.7.4 P4-5: 1M → 128K(与 base-adapter 兜底对齐)——未知模型按最保守主流窗口预算, - // 防止压缩阈值按 1M 计算导致实际小窗口模型先 413 再压缩 - protected defaultContextWindowFallback(): number { - return 128_000; - } - // ===== 认证头 ===== protected buildHeaders(): Record { @@ -161,15 +154,16 @@ export abstract class OpenAICompatibleAdapter extends BaseAdapter { } /** - * 上下文窗口回退链(v0.6.3 一致化后的统一实现): - * config.contextWindow(用户显式配置)→ 模型元信息 → Provider 兜底。 + * 上下文窗口(v0.8.1 硬性契约单一化): + * 唯一合法来源是设置面板「上下文长度」(llm.contextWindow → AdapterConfig.contextWindow)。 + * 删除了旧的 config → 模型元信息 → 兜底 三级回退链 —— 模型元信息不再承载窗口数值, + * 未配置时返回 0(引擎据此跳过压缩预算,行为与"用户未声明窗口"语义一致)。 */ override getContextWindow(): number { if (typeof this.config.contextWindow === 'number' && this.config.contextWindow > 0) { return this.config.contextWindow; } - const modelInfo = this.modelInfoTable()[this.config.defaultModel]; - return modelInfo?.contextWindow ?? this.defaultContextWindowFallback(); + return 0; } } diff --git a/electron/harness/agent-loop/engine.ts b/electron/harness/agent-loop/engine.ts index 6d4b45d..687f1f9 100644 --- a/electron/harness/agent-loop/engine.ts +++ b/electron/harness/agent-loop/engine.ts @@ -84,10 +84,11 @@ const DEFAULT_CONFIG: AgentLoopConfig = { totalTimeoutMs: 600_000, enableReflection: false, compressionThreshold: 0.8, - contextWindow: 128_000, + // v0.8.1: contextWindow / maxTokens 不再携带任何写死默认值 —— 唯一合法来源是 + // 设置面板 LLM 配置(llm.contextWindow / llm.maxTokens),由 main.ts baseConfig + // 与 applyEngineConfigKey 注入。未配置时压缩判定跳过、输出上限参数不下发。 retryCount: 3, temperature: 0.0, - maxTokens: 63488, thinkingEnabled: true, thinkingEffort: 'high', toolExecutionTimeoutMs: 120_000, @@ -185,10 +186,9 @@ export class AgentLoopEngine extends EventEmitter { /** * #3 修复: 从 adapter 同步 contextWindow 到 Engine 配置 * - * Engine 的 DEFAULT_CONFIG.contextWindow 硬编码为 128_000,但各 Provider 实际支持的 - * 上下文窗口差异巨大(DeepSeek 1M / Agnes 1M / MiMo 1M / OpenAI 128K~1M / - * Anthropic 200K / Ollama 4096 起,v0.7.4 修正注释——此前误写 64K)。 - * 不同步会导致压缩阈值(compressionThreshold * contextWindow)计算错误。 + * v0.8.1 契约: 窗口唯一来源是设置面板「上下文长度」—— 引擎 config 与 adapter + * config 同源注入;本同步仅当 adapter 侧返回 >0(用户已配置)时采纳, + * 返回 0(未配置)不覆盖,压缩判定按未配置语义跳过。 */ private syncContextWindow(): void { const adapterCtx = this.adapter.getContextWindow?.(); @@ -628,6 +628,10 @@ export class AgentLoopEngine extends EventEmitter { promptTokens: event.usage.inputTokens ?? 0, completionTokens: event.usage.outputTokens ?? 0, totalTokens: event.usage.totalTokens ?? 0, + // v0.8.1 P1-3: 透传 Prompt Cache 命中字段(前端命中率展示的数据源; + // 此前引擎在此处丢弃 adapter 已采集的 cache 字段,观测链路断头) + cacheHitTokens: event.usage.cacheHitTokens, + cacheMissTokens: event.usage.cacheMissTokens, }; // v0.3.18 修复: 记录最近一次 LLM 调用的真实输入 token,用于校正压缩判断 // 估算值可能偏低(尤其中文场景),导致不压缩但 API 413 @@ -788,10 +792,10 @@ export class AgentLoopEngine extends EventEmitter { } // === 上下文压缩(基于 token 使用率触发) === - // 有效上下文窗口:Ollama 使用 contextLength (numCtx),其他 Provider 使用 contextWindow - // v0.3.18 修复: 加默认值 128_000 保护,避免 config 都为 undefined 时 compressionThreshold 变 NaN 导致压缩永不触发 - const effectiveContextWindow = - this.config.contextLength ?? this.config.contextWindow ?? 128_000; + // 有效上下文窗口:Ollama 使用 contextLength (num_ctx),其他 Provider 使用 contextWindow。 + // v0.8.1: 唯一来源是设置面板「上下文长度」(llm.contextWindow)—— 不再有任何写死 + // 兜底值;未配置(<=0)时跳过压缩判定(无法计算阈值,且用户未声明窗口即不预算)。 + const effectiveContextWindow = this.config.contextLength ?? this.config.contextWindow ?? 0; const estimatedTokens = this.estimateMessagesTokens(request.messages); // v0.3.18 修复: 取 max(估算值, 真实值) 作为实际占用,避免估算偏低导致不压缩但 API 413 // 估算值用于 LLM 尚未返回 usage 时的早期判断(首轮或重试场景) @@ -801,7 +805,11 @@ export class AgentLoopEngine extends EventEmitter { // v0.3.18 修复: 触发条件从"消息数 > 10"改为"消息数 >= 4" // 新压缩策略按 token 预算动态截断,不再依赖固定 10 条。 // 至少 4 条消息(2 轮 user+assistant)才有压缩意义,否则保留区已是最小。 - if (actualTokens > compressionThreshold && request.messages.length >= 4) { + if ( + effectiveContextWindow > 0 && + actualTokens > compressionThreshold && + request.messages.length >= 4 + ) { await this.transitionTo(AgentLoopState.COMPRESSING); const compressed = await this.compressMessages(request.messages); if (compressed) { @@ -1462,9 +1470,10 @@ export class AgentLoopEngine extends EventEmitter { */ private async compressMessages(messages: MetonaMessage[]): Promise { // v0.3.18 修复: 动态计算保留预算,避免固定 10 条在超长消息场景仍超限 - // 加默认值 128_000 保护,避免 config 都为 undefined 时 keepBudget 变 NaN - const effectiveContextWindow = - this.config.contextLength ?? this.config.contextWindow ?? 128_000; + // v0.8.1: 窗口唯一来源是设置面板「上下文长度」,无写死兜底 —— 未配置时无法 + // 计算保留预算,直接放弃压缩(调用方保持原数组,行为安全) + const effectiveContextWindow = this.config.contextLength ?? this.config.contextWindow ?? 0; + if (!(effectiveContextWindow > 0)) return null; const keepBudget = Math.floor(effectiveContextWindow * 0.5); // 保留区占上下文窗口 50% const minKeepCount = 2; // 至少保留最后 2 条(user + assistant),保证有可推理上下文 @@ -1623,6 +1632,16 @@ export class AgentLoopEngine extends EventEmitter { this.totalTokens.promptTokens += usage.promptTokens; this.totalTokens.completionTokens += usage.completionTokens; this.totalTokens.totalTokens += usage.totalTokens; + // v0.8.1 P1-3: 累计缓存命中/未命中(未上报的 Provider 保持 undefined, + // 不把 undefined 污染为数字 0 —— 前端以 undefined 判定"该 Provider 不上报") + if (usage.cacheHitTokens != null) { + this.totalTokens.cacheHitTokens = + (this.totalTokens.cacheHitTokens ?? 0) + usage.cacheHitTokens; + } + if (usage.cacheMissTokens != null) { + this.totalTokens.cacheMissTokens = + (this.totalTokens.cacheMissTokens ?? 0) + usage.cacheMissTokens; + } } private finish(reason: TerminationReason, answer?: string, error?: Error): AgentLoopOutput { diff --git a/electron/harness/agent-loop/types.ts b/electron/harness/agent-loop/types.ts index 3a8758a..098215b 100644 --- a/electron/harness/agent-loop/types.ts +++ b/electron/harness/agent-loop/types.ts @@ -61,6 +61,13 @@ export interface TokenUsage { promptTokens: number; completionTokens: number; totalTokens: number; + /** + * v0.8.1 P1-3: Prompt Cache 命中/未命中 token 数(Provider 上报时透传)。 + * 供前端 TokenUsage 面板计算缓存命中率 —— 采集与展示全链路闭环, + * 未上报的 Provider 为 undefined(UI 隐藏该行)。 + */ + cacheHitTokens?: number; + cacheMissTokens?: number; } // ===== Agent Loop 配置 ===== @@ -70,16 +77,26 @@ export interface AgentLoopConfig { totalTimeoutMs: number; enableReflection: boolean; compressionThreshold: number; - contextWindow: number; + /** + * 上下文窗口大小(token 数)—— 唯一合法来源是设置面板 LLM 配置的「上下文长度」 + * (llm.contextWindow,经 main.ts baseConfig / applyEngineConfigKey 注入)。 + * 引擎与适配器禁止携带任何写死的默认值;未配置(undefined/0)时压缩判定跳过。 + */ + contextWindow?: number; retryCount: number; temperature: number; - /** 最大生成 token 数(默认 63488) */ - maxTokens: number; + /** + * 最大生成 token 数 —— 唯一合法来源是设置面板 LLM 配置的「最大输出上限」 + * (llm.maxTokens,经 main.ts baseConfig / applyEngineConfigKey 注入)。 + * 引擎与适配器禁止携带任何写死的默认值,也不得按模型元信息钳制; + * 未配置时该参数不下发,由 Provider 服务端默认值决定。 + */ + maxTokens?: number; /** 是否启用思考模式(默认 true) */ thinkingEnabled: boolean; /** 思考强度(默认 'high') */ thinkingEffort: 'low' | 'medium' | 'high' | 'max'; - /** Ollama 上下文窗口大小(num_ctx),其他 Provider 忽略 */ + /** Ollama num_ctx(与「上下文长度」同源:llm.contextWindow,仅 Ollama Provider 下发) */ contextLength?: number; /** 工具执行兜底超时(ms,默认 120000),实际取 max(此值, tool.timeoutMs) */ toolExecutionTimeoutMs?: number; diff --git a/electron/harness/hooks/__tests__/confirmation-hook.test.ts b/electron/harness/hooks/__tests__/confirmation-hook.test.ts index 25d388c..2429a89 100644 --- a/electron/harness/hooks/__tests__/confirmation-hook.test.ts +++ b/electron/harness/hooks/__tests__/confirmation-hook.test.ts @@ -88,6 +88,7 @@ describe('ConfirmationHook — 多窗口广播(v0.7.2 P2-7)', () => { } it('确认请求广播到所有存活窗口(而非仅 mainWindow)', async () => { + vi.useFakeTimers(); const a = makeTrackedWindow(); const b = makeTrackedWindow(); const destroyed = makeTrackedWindow(true); @@ -96,6 +97,8 @@ describe('ConfirmationHook — 多窗口广播(v0.7.2 P2-7)', () => { const hook = new ConfirmationHook(null, null); hook.setToolDefs([HIGH_RISK_DEF]); const p = hook.beforeExecute(makeToolCall(), 'sess'); + // v0.8.1 P2-2: 聚合窗口(800ms)结束才广播 —— 推进 fake timers + await vi.advanceTimersByTimeAsync(1000); // 所有存活窗口均收到确认请求(携带 expiresAt 倒计时契约) expect(a.send).toHaveBeenCalledWith( @@ -111,20 +114,25 @@ describe('ConfirmationHook — 多窗口广播(v0.7.2 P2-7)', () => { hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, true, false, false); expect((await p).blocked).toBe(false); + vi.useRealTimers(); }); it('getAllWindows 为空时回退到注入的 mainWindow(向后兼容)', async () => { + vi.useFakeTimers(); const mainWin = makeMockWindow(); getAllWindowsMock.mockReturnValue([]); const hook = new ConfirmationHook(mainWin, null); hook.setToolDefs([HIGH_RISK_DEF]); const p = hook.beforeExecute(makeToolCall(), 'sess'); + // v0.8.1 P2-2: 推进聚合窗口后断言广播 + await vi.advanceTimersByTimeAsync(1000); expect((mainWin.webContents as unknown as { send: Mock }).send).toHaveBeenCalledWith( 'tool:confirmationRequest', expect.objectContaining({ toolName: 'run_command' }), ); hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, true, false, false); expect((await p).blocked).toBe(false); + vi.useRealTimers(); }); it('全部窗口不可达时 fail-closed 阻断(no main window available)', async () => { @@ -524,3 +532,92 @@ describe('ConfirmationHook — 跨会话隔离(v0.5.0)', () => { expect((await hook.beforeExecute(makeToolCall(), 'sess-b')).blocked).toBe(false); }); }); + +// ===== v0.8.1 P2-2: 连续同类工具批量确认聚合 ===== + +describe('ConfirmationHook — 同类工具批量确认聚合(v0.8.1 P2-2)', () => { + /** 单次 prompt(beforeExecute 阻塞等待确认,不消费 promise) */ + function startPrompt(hook: ConfirmationHook, id: string): Promise { + return hook.beforeExecute( + { id, name: 'run_command', args: {}, iteration: 1, timestamp: Date.now() }, + 'sess', + ); + } + + /** 本 describe 专用的 send 追踪窗口(makeTrackedWindow 定义在上一 describe 作用域) */ + function makeWindow(): { win: BrowserWindow; send: Mock } { + const send = vi.fn(); + const win = { + isDestroyed: () => false, + webContents: { send }, + } as unknown as BrowserWindow; + return { win, send }; + } + + function makeRunCommandDef(): MetonaToolDef { + return { + ...HIGH_RISK_DEF, + name: 'run_command', + requiresPermission: true, + }; + } + + it('3 个同类并行请求 → 聚合为单条 batch 事件(无逐条事件)', async () => { + vi.useFakeTimers(); + const w = makeWindow(); + getAllWindowsMock.mockReturnValue([w.win]); + const hook = new ConfirmationHook(null, null); + hook.setToolDefs([makeRunCommandDef()]); + + const prompts = [ + startPrompt(hook, 'tc_1'), + startPrompt(hook, 'tc_2'), + startPrompt(hook, 'tc_3'), + ]; + // 达到阈值立即 flush + await vi.advanceTimersByTimeAsync(0); + + const batchCalls = (w.send as Mock).mock.calls.filter( + (c) => c[0] === 'tool:confirmationRequestBatch', + ); + expect(batchCalls).toHaveLength(1); + expect( + (batchCalls[0][1] as unknown[]).map((r) => (r as { toolCallId: string }).toolCallId), + ).toEqual(['tc_1', 'tc_2', 'tc_3']); + // 不应再发送逐条事件 + const individual = (w.send as Mock).mock.calls.filter( + (c) => c[0] === 'tool:confirmationRequest', + ); + expect(individual).toHaveLength(0); + + for (const id of ['tc_1', 'tc_2', 'tc_3']) { + hook.resolveConfirmation(id, true, false, false); + } + await Promise.all(prompts); + vi.useRealTimers(); + }); + + it('2 个同类请求(低于阈值)→ 窗口结束逐条广播(原行为)', async () => { + vi.useFakeTimers(); + const w = makeWindow(); + getAllWindowsMock.mockReturnValue([w.win]); + const hook = new ConfirmationHook(null, null); + hook.setToolDefs([makeRunCommandDef()]); + + const prompts = [startPrompt(hook, 'tc_a'), startPrompt(hook, 'tc_b')]; + await vi.advanceTimersByTimeAsync(1000); + + expect( + (w.send as Mock).mock.calls.filter((c) => c[0] === 'tool:confirmationRequestBatch'), + ).toHaveLength(0); + expect( + (w.send as Mock).mock.calls.filter((c) => c[0] === 'tool:confirmationRequest'), + ).toHaveLength(2); + + for (const id of ['tc_a', 'tc_b']) { + hook.resolveConfirmation(id, true, false, false); + } + await Promise.all(prompts); + vi.useRealTimers(); + }); +}); diff --git a/electron/harness/hooks/confirmation-hook.ts b/electron/harness/hooks/confirmation-hook.ts index a0595cf..e1f5e31 100644 --- a/electron/harness/hooks/confirmation-hook.ts +++ b/electron/harness/hooks/confirmation-hook.ts @@ -467,6 +467,39 @@ export class ConfirmationHook implements PreToolHook { */ private lastTimeoutToastAt = 0; + // ===== v0.8.1 P2-2: 连续同类工具批量确认聚合 ===== + /** + * 同 (sessionId, toolName) 的请求在 AGGREGATION_WINDOW_MS 内聚合:窗口结束时 + * 若积压 >= AGGREGATION_THRESHOLD 条则只广播一条 `tool:confirmationRequestBatch` + * (前端一次性拉取/渲染全部 pending),否则逐条广播(原行为)。 + * 目的:批量重构等场景(并行工具连续触发 3+ 同类确认)不再弹出 N 个连续弹框。 + */ + private static readonly AGGREGATION_WINDOW_MS = 800; + private static readonly AGGREGATION_THRESHOLD = 3; + private aggregationBuffers = new Map< + string, + { requests: ConfirmationRequest[]; timer: NodeJS.Timeout | null } + >(); + + private flushAggregationBuffer(key: string): void { + const buf = this.aggregationBuffers.get(key); + if (!buf) return; + this.aggregationBuffers.delete(key); + if (buf.timer) { + clearTimeout(buf.timer); + buf.timer = null; + } + if (buf.requests.length >= ConfirmationHook.AGGREGATION_THRESHOLD) { + // 批量事件:携带完整请求列表(expiresAt 已含) + this.broadcastToAllWindows('tool:confirmationRequestBatch', buf.requests); + } else { + // 逐条广播(原行为) + for (const req of buf.requests) { + this.broadcastToAllWindows('tool:confirmationRequest', req); + } + } + } + private waitForConfirmation(request: ConfirmationRequest, sessionId: string): Promise { return new Promise((resolve) => { const expiresAt = Date.now() + this.confirmationTimeoutMs; @@ -518,10 +551,24 @@ export class ConfirmationHook implements PreToolHook { // 发送确认请求到渲染进程(携带过期时间戳,供前端倒计时) // v0.7.2 P2-7: 广播到所有窗口 —— 多窗口场景下任意窗口发起的会话 // 触发的确认请求都可达(ConfirmationDialog 按会话过滤展示) - this.broadcastToAllWindows('tool:confirmationRequest', { - ...request, - expiresAt, - }); + // v0.8.1 P2-2: 聚合窗口 —— 同会话同类工具的并行请求进入缓冲; + // 窗口结束达到阈值时合并为单条批量事件,否则逐条广播(原行为) + const aggKey = `${sessionId}:${request.toolName}`; + let buf = this.aggregationBuffers.get(aggKey); + if (!buf) { + buf = { requests: [], timer: null }; + this.aggregationBuffers.set(aggKey, buf); + buf.timer = setTimeout( + () => this.flushAggregationBuffer(aggKey), + ConfirmationHook.AGGREGATION_WINDOW_MS, + ); + } + buf.requests.push({ ...request, expiresAt }); + + // 窗口内积压已达阈值 → 立即 flush(不等满窗口) + if (buf.requests.length >= ConfirmationHook.AGGREGATION_THRESHOLD) { + this.flushAggregationBuffer(aggKey); + } }); } @@ -540,6 +587,9 @@ export class ConfirmationHook implements PreToolHook { pending.resolve(false); } this.pendingConfirmations.clear(); + // v0.8.1 P2-2: 清空聚合缓冲(resolve(false) 已由逐条 timer 覆盖……缓冲内的 + // 请求本身尚未注册 pending,需丢弃防止稍后广播已失效请求) + this.aggregationBuffers.clear(); return; } for (const [id, pending] of this.pendingConfirmations) { @@ -548,6 +598,20 @@ export class ConfirmationHook implements PreToolHook { pending.resolve(false); this.pendingConfirmations.delete(id); } + // v0.8.1 P2-2: 丢弃该会话尚未广播的聚合缓冲 + for (const [key, buf] of this.aggregationBuffers) { + if (!key.startsWith(`${sessionId}:`)) continue; + if (buf.timer) clearTimeout(buf.timer); + for (const req of buf.requests) { + const pending = this.pendingConfirmations.get(req.toolCallId); + if (pending) { + clearTimeout(pending.timer); + pending.resolve(false); + this.pendingConfirmations.delete(req.toolCallId); + } + } + this.aggregationBuffers.delete(key); + } } /** diff --git a/electron/harness/hooks/post-tool.ts b/electron/harness/hooks/post-tool.ts index 0cf5ce4..78bb6ee 100644 --- a/electron/harness/hooks/post-tool.ts +++ b/electron/harness/hooks/post-tool.ts @@ -29,7 +29,11 @@ export interface PostToolHook { export class AuditLogHook implements PostToolHook { constructor(private auditService: AuditService) {} - async afterExecute(toolCall: MetonaToolCall, result: MetonaToolResult, sessionId: string): Promise { + async afterExecute( + toolCall: MetonaToolCall, + result: MetonaToolResult, + sessionId: string, + ): Promise { // #17 修复: AuditHook 应为 "fire and forget",hook 失败不应影响工具执行链 // 虽然 AuditService.log() 内部已 try-catch,但 hook 层再加一层防御, // 确保任何意外异常(如 getDB 抛错、JSON.stringify 失败)都不会冒泡到 ToolRegistry @@ -59,11 +63,24 @@ export class MemoryTriggerHook implements PostToolHook { /** 单次工具结果存储上限(字符),防止过大内容淹没记忆系统 */ private readonly MAX_MEMORY_CONTENT = 500; + /** + * v0.8.1 P0-2: 工具结果类情节记忆的 TTL(90 天)。 + * 此前 episodic_memories.expires_at 全链路无写入方 —— cleanupExpired(健康检查 + * 周期调用)空转,情节记忆只增不减。现为此类低价值记忆写入过期时间,90 天后 + * 由周期清理回收;用户偏好等高价值记忆(consolidator 写入 semantic 表)不受影响。 + */ + private readonly EPISODIC_TTL_MS = 90 * 24 * 60 * 60 * 1000; + constructor(private memoryManager: MemoryManager) {} - async afterExecute(toolCall: MetonaToolCall, result: MetonaToolResult, sessionId: string): Promise { + async afterExecute( + toolCall: MetonaToolCall, + result: MetonaToolResult, + sessionId: string, + ): Promise { if (this.memorableTools.includes(toolCall.name) && result.success) { - const content = typeof result.result === 'string' ? result.result : JSON.stringify(result.result); + const content = + typeof result.result === 'string' ? result.result : JSON.stringify(result.result); try { this.memoryManager.store({ type: 'episodic', @@ -71,6 +88,7 @@ export class MemoryTriggerHook implements PostToolHook { source: 'tool_result', sessionId, importance: 0.6, + expiresAt: Date.now() + this.EPISODIC_TTL_MS, }); } catch (error) { // 记忆存储失败不应影响工具执行结果 diff --git a/electron/harness/memory/__tests__/maintainer.test.ts b/electron/harness/memory/__tests__/maintainer.test.ts new file mode 100644 index 0000000..fcc0c19 --- /dev/null +++ b/electron/harness/memory/__tests__/maintainer.test.ts @@ -0,0 +1,182 @@ +/** + * MemoryMaintainer 测试(v0.8.1 P1-2 MEMORY.md 维护闭环) + * + * 锁定契约: + * 1. parseMemoryEntries / buildMemoryEntriesDigest —— 分区条目摘要(纯条目行, + * 消除 Consolidator 旧全文截断的去重盲区) + * 2. apply 的精确匹配防线 —— LLM 建议的 entry 必须原样存在,防幻觉改写无关内容 + * 3. delete/update 动作重写 MEMORY.md + 同步 semantic_memories 双轨一致 + */ + +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('electron-log', () => ({ + default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +import { MemoryMaintainer, parseMemoryEntries, buildMemoryEntriesDigest } from '../maintainer'; +import type { MemoryMaintenanceAction } from '../maintainer'; + +const SAMPLE = `# MEMORY.md — AI 持久记忆 +> 最后更新: 2026-09-07 + +## 用户偏好 +- [沟通风格] 用户喜欢简洁的回答 +- [工具偏好] 项目使用 pnpm + +## 项目上下文 +- [Metona] 技术栈: Electron + React + +## 待办事项 +- [done] 旧待办已完成 +`; + +describe('parseMemoryEntries / buildMemoryEntriesDigest(v0.8.1 P1-2)', () => { + it('解析分区与条目(跳过元数据头)', () => { + const sections = parseMemoryEntries(SAMPLE); + expect(sections).toHaveLength(3); + expect(sections[0].section).toBe('用户偏好'); + expect(sections[0].entries).toEqual([ + '[沟通风格] 用户喜欢简洁的回答', + '[工具偏好] 项目使用 pnpm', + ]); + }); + + it('digest 为纯条目行形态且条目全文可见(无 3000 字符截断盲区)', () => { + const digest = buildMemoryEntriesDigest(parseMemoryEntries(SAMPLE)); + expect(digest).toContain('## 用户偏好'); + expect(digest).toContain('- [沟通风格] 用户喜欢简洁的回答'); + // 旧全文形态的头部元数据不进入 digest + expect(digest).not.toContain('最后更新'); + // 超过旧 3000 字符预算的记忆尾部条目同样完整进入 digest + const manyEntries = Array.from({ length: 200 }, (_, i) => `- 条目 ${i} ${'x'.repeat(20)}`); + const bigMemory = `## 项目上下文\n${manyEntries.join('\n')}`; + const bigDigest = buildMemoryEntriesDigest(parseMemoryEntries(bigMemory)); + expect(bigDigest).toContain('条目 199'); + }); +}); + +describe('MemoryMaintainer.apply — 精确匹配与双轨同步', () => { + function makeMaintainer(memory: string): { + maintainer: MemoryMaintainer; + getMemory: () => string; + db: { prepare(sql: string): { run(...args: unknown[]): { changes: number } } }; + } { + let current = memory; + const semanticRows: Array<{ content: string }> = [{ content: '[沟通风格] 用户喜欢简洁的回答' }]; + const db = { + prepare: (sql: string) => ({ + run: (...args: unknown[]) => { + if (sql.startsWith('DELETE')) { + const before = semanticRows.length; + const target = semanticRows.find((r) => r.content === args[0]); + if (target) semanticRows.splice(semanticRows.indexOf(target), 1); + return { changes: before - semanticRows.length }; + } + if (sql.startsWith('UPDATE')) { + const row = semanticRows.find((r) => r.content === args[2]); + if (row) { + row.content = args[0] as string; + return { changes: 1 }; + } + return { changes: 0 }; + } + return { changes: 0 }; + }, + }), + }; + const maintainer = new MemoryMaintainer( + () => { + throw new Error('not used in apply'); + }, + { + getFiles: () => ({ soul: '', memory: current }), + rewriteMemory: (content: string) => { + current = content; + }, + } as never, + () => db as never, + ); + return { maintainer, getMemory: () => current, db }; + } + + it('delete 精确命中 → 行被移除;未命中条目被跳过(防幻觉改写)', () => { + const { maintainer, getMemory } = makeMaintainer(SAMPLE); + const actions: MemoryMaintenanceAction[] = [ + { action: 'delete', section: '待办事项', entry: '[done] 旧待办已完成' }, + // 幻觉条目:文件中不存在 → 必须跳过 + { action: 'delete', section: '用户偏好', entry: '不存在的条目' }, + ]; + const result = maintainer.apply(actions); + expect(result.applied).toBe(1); + expect(result.skipped).toBe(1); + const after = getMemory(); + expect(after).not.toContain('[done] 旧待办已完成'); + expect(after).toContain('用户喜欢简洁的回答'); + expect(after).toContain('## 待办事项'); // 分区头保留(空分区仍保留结构) + }); + + it('update(合并)→ 替换条目并同步 semantic_memories', () => { + const { maintainer, getMemory, db } = makeMaintainer(SAMPLE); + const actions: MemoryMaintenanceAction[] = [ + { + action: 'update', + section: '用户偏好', + entry: '[沟通风格] 用户喜欢简洁的回答', + newEntry: '[沟通风格] 用户喜欢简洁的回答,不需要过度解释', + }, + ]; + const result = maintainer.apply(actions); + expect(result.applied).toBe(1); + expect(getMemory()).toContain('不需要过度解释'); + const row = db + .prepare('SELECT * FROM semantic_memories WHERE content = ?') + .run('[沟通风格] 用户喜欢简洁的回答,不需要过度解释'); + expect(row).toBeDefined(); + }); + + it('动作数上限 30(防 LLM 过度建议)', () => { + const { maintainer } = makeMaintainer(SAMPLE); + const actions: MemoryMaintenanceAction[] = Array.from({ length: 40 }, () => ({ + action: 'delete' as const, + section: '待办事项', + entry: '不存在的条目', + })); + const result = maintainer.apply(actions); + expect(result.skipped).toBe(40); + expect(result.applied).toBe(0); + }); +}); + +describe('MemoryMaintainer.analyze — sectionEntryCounts(v0.8.1 review O2)', () => { + function makeAnalyzer( + memory: string, + llmReply: string, + ): { + maintainer: MemoryMaintainer; + } { + const adapter = { + send: vi.fn().mockResolvedValue({ content: llmReply }), + } as never; + return { + maintainer: new MemoryMaintainer( + () => adapter, + { + getFiles: () => ({ soul: '', memory }), + rewriteMemory: () => {}, + } as never, + (() => ({})) as never, + ), + }; + } + + it('proposal 携带各分区条目数(空分区提示的数据源)', async () => { + const { maintainer } = makeAnalyzer( + SAMPLE, + '[{"action":"delete","section":"待办事项","entry":"[done] 旧待办已完成","reason":"已完成"}]', + ); + const proposal = await maintainer.analyze(); + expect(proposal.sectionEntryCounts['待办事项']).toBe(1); + expect(proposal.sectionEntryCounts['用户偏好']).toBe(2); + }); +}); diff --git a/electron/harness/memory/__tests__/memory-manager.test.ts b/electron/harness/memory/__tests__/memory-manager.test.ts index d5e297c..2bffd84 100644 --- a/electron/harness/memory/__tests__/memory-manager.test.ts +++ b/electron/harness/memory/__tests__/memory-manager.test.ts @@ -43,7 +43,8 @@ function createMemorySchema(db: any): void { importance REAL DEFAULT 0.5, created_at INTEGER NOT NULL DEFAULT 0, expires_at INTEGER, - tf_cache TEXT + tf_cache TEXT, + embedding BLOB ); CREATE TABLE semantic_memories ( id TEXT PRIMARY KEY, @@ -55,7 +56,8 @@ function createMemorySchema(db: any): void { created_at INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL DEFAULT 0, access_count INTEGER DEFAULT 0, - tf_cache TEXT + tf_cache TEXT, + embedding BLOB ); CREATE TABLE working_memories ( id TEXT PRIMARY KEY, @@ -349,10 +351,10 @@ describe.skipIf(!dbAvailable)('MemoryManager — store 三层记忆', () => { ).toThrow(/Unknown memory type/); }); - it('store 使 IDF 缓存失效(cacheUpdatedAt 重置)', () => { + it('store 使 IDF 缓存失效(cacheUpdatedAt 重置)', async () => { // v0.7.4 强化断言: 若 IDF 缓存未失效/检索不扫描新行,store 后 search 返回空即失败。 mgr.store({ type: 'episodic', content: 'hello world', source: 'user_input', importance: 0.7 }); - mgr.search('hello'); // 建立 IDF 缓存 + await mgr.search('hello'); // 建立 IDF 缓存 // 再 store 一条 → 缓存应失效,新内容可被检索 mgr.store({ type: 'episodic', @@ -360,10 +362,10 @@ describe.skipIf(!dbAvailable)('MemoryManager — store 三层记忆', () => { source: 'user_input', importance: 0.7, }); - const results = mgr.search('another'); + const results = await mgr.search('another'); expect(results.some((r) => r.content === 'another content')).toBe(true); // 双向验证:缓存重建后旧内容仍可检索(不因重建丢失) - const oldResults = mgr.search('hello'); + const oldResults = await mgr.search('hello'); expect(oldResults.some((r) => r.content === 'hello world')).toBe(true); }); @@ -401,7 +403,7 @@ describe.skipIf(!dbAvailable)('MemoryManager — TF-IDF 检索与时间衰减', } }); - it('相同关键词:得分高者排前(内容重复度越高得分越高)', () => { + it('相同关键词:得分高者排前(内容重复度越高得分越高)', async () => { mgr.store({ type: 'episodic', content: 'memory hello world test', @@ -416,7 +418,7 @@ describe.skipIf(!dbAvailable)('MemoryManager — TF-IDF 检索与时间衰减', importance: 0.7, }); - const results = mgr.search('hello world'); + const results = await mgr.search('hello world'); expect(results.length).toBeGreaterThan(0); expect(results.every((r) => r.score > 0)).toBe(true); // 两条命中的按分数降序 @@ -424,7 +426,7 @@ describe.skipIf(!dbAvailable)('MemoryManager — TF-IDF 检索与时间衰减', expect([...scores].sort((a, b) => b - a)).toEqual(scores); }); - it('时间衰减:同内容越新得分越高(30 天半衰期)', () => { + it('时间衰减:同内容越新得分越高(30 天半衰期)', async () => { mgr.store({ type: 'episodic', content: '关键 bug 修复方案', @@ -455,7 +457,7 @@ describe.skipIf(!dbAvailable)('MemoryManager — TF-IDF 检索与时间衰减', newId, ); - const results = mgr.search('关键 bug'); + const results = await mgr.search('关键 bug'); expect(results.length).toBeGreaterThan(0); const newResult = results.find((r) => r.id === newId); const oldResult = results.find((r) => r.id === oldId); @@ -464,7 +466,7 @@ describe.skipIf(!dbAvailable)('MemoryManager — TF-IDF 检索与时间衰减', expect(newResult!.score).toBeGreaterThan(oldResult!.score); }); - it('半衰期数学:30 天衰减系数恰为 0.5(score 相对无衰减×0.5)', () => { + it('半衰期数学:30 天衰减系数恰为 0.5(score 相对无衰减×0.5)', async () => { // 新鲜记录(0 天) const freshId = mgr.store({ type: 'episodic', @@ -484,7 +486,7 @@ describe.skipIf(!dbAvailable)('MemoryManager — TF-IDF 检索与时间衰减', agedId, ); - const results = mgr.search('衰减数学验证内容'); + const results = await mgr.search('衰减数学验证内容'); const fresh = results.find((r) => r.id === freshId)!; const aged = results.find((r) => r.id === agedId)!; // score = cosine * decay * importanceFactor;两记录余弦与 importance 相同 @@ -492,7 +494,7 @@ describe.skipIf(!dbAvailable)('MemoryManager — TF-IDF 检索与时间衰减', expect(aged.score / fresh.score).toBeCloseTo(0.5, 1); }); - it('importance 权重:0.5 + importance*0.5 缩放(importance=1 得分为 0 的 2 倍)', () => { + it('importance 权重:0.5 + importance*0.5 缩放(importance=1 得分为 0 的 2 倍)', async () => { const lowId = mgr.store({ type: 'episodic', content: '重要性权重验证', @@ -506,24 +508,24 @@ describe.skipIf(!dbAvailable)('MemoryManager — TF-IDF 检索与时间衰减', importance: 1, }); // 同一时间创建,重要性不同 → factor = 0.5+0*0.5 vs 0.5+1*0.5 - const results = mgr.search('重要性权重验证'); + const results = await mgr.search('重要性权重验证'); const low = results.find((r) => r.id === lowId)!; const high = results.find((r) => r.id === highId)!; expect(high.score / low.score).toBeCloseTo(2.0, 1); }); - it('semantic 记忆可被检索(key+value 参与分词)', () => { + it('semantic 记忆可被检索(key+value 参与分词)', async () => { mgr.store({ type: 'semantic', content: '用户偏好深色主题', source: 'imported', importance: 0.5, }); - const results = mgr.search('偏好'); + const results = await mgr.search('偏好'); expect(results.some((r) => r.type === 'semantic')).toBe(true); }); - it('working 记忆可被检索(key+value 参与分词,importance 固定 0.5)', () => { + it('working 记忆可被检索(key+value 参与分词,importance 固定 0.5)', async () => { mgr.store({ type: 'working', content: '当前任务文件', @@ -531,11 +533,11 @@ describe.skipIf(!dbAvailable)('MemoryManager — TF-IDF 检索与时间衰减', importance: 0.5, source: 'agent_thought', }); - const results = mgr.search('当前任务'); + const results = await mgr.search('当前任务'); expect(results.some((r) => r.type === 'working')).toBe(true); }); - it('type 过滤:仅返回指定类型', () => { + it('type 过滤:仅返回指定类型', async () => { mgr.store({ type: 'episodic', content: 'typefilter 内容', @@ -549,13 +551,13 @@ describe.skipIf(!dbAvailable)('MemoryManager — TF-IDF 检索与时间衰减', importance: 0.5, }); - const episodic = mgr.search('typefilter', { type: 'episodic' }); + const episodic = await mgr.search('typefilter', { type: 'episodic' }); expect(episodic.every((r) => r.type === 'episodic')).toBe(true); - const semantic = mgr.search('typefilter', { type: 'semantic' }); + const semantic = await mgr.search('typefilter', { type: 'semantic' }); expect(semantic.every((r) => r.type === 'semantic')).toBe(true); }); - it('topK 限制返回条数', () => { + it('topK 限制返回条数', async () => { for (let i = 0; i < 8; i++) { mgr.store({ type: 'episodic', @@ -564,22 +566,22 @@ describe.skipIf(!dbAvailable)('MemoryManager — TF-IDF 检索与时间衰减', importance: 0.7, }); } - const results = mgr.search('topk 内容'); + const results = await mgr.search('topk 内容'); expect(results.length).toBeLessThanOrEqual(5); // 默认 topK=5 - const results2 = mgr.search('topk 内容', { topK: 2 }); + const results2 = await mgr.search('topk 内容', { topK: 2 }); expect(results2.length).toBeLessThanOrEqual(2); }); - it('minImportance 过滤低重要性记忆', () => { + it('minImportance 过滤低重要性记忆', async () => { mgr.store({ type: 'episodic', content: '低重要内容', source: 'user_input', importance: 0.1 }); mgr.store({ type: 'episodic', content: '高重要内容', source: 'user_input', importance: 0.9 }); - const results = mgr.search('重要', { minImportance: 0.5 }); + const results = await mgr.search('重要', { minImportance: 0.5 }); expect(results.every((r) => r.importance >= 0.5)).toBe(true); }); - it('score 字段为 finalScore = cosine * decay * (0.5+importance*0.5)(>0 才返回)', () => { + it('score 字段为 finalScore = cosine * decay * (0.5+importance*0.5)(>0 才返回)', async () => { mgr.store({ type: 'episodic', content: 'score 数学', source: 'user_input', importance: 0.7 }); - const results = mgr.search('score 数学'); + const results = await mgr.search('score 数学'); expect(results.length).toBeGreaterThan(0); for (const r of results) { expect(r.score).toBeGreaterThan(0); @@ -604,19 +606,19 @@ describe.skipIf(!dbAvailable)('MemoryManager — search 回退与边界', () => } }); - it('空查询与纯空白查询返回空数组', () => { - expect(mgr.search('')).toEqual([]); - expect(mgr.search(' ')).toEqual([]); - expect(mgr.search('', { topK: 3 })).toEqual([]); + it('空查询与纯空白查询返回空数组', async () => { + expect(await mgr.search('')).toEqual([]); + expect(await mgr.search(' ')).toEqual([]); + expect(await mgr.search('', { topK: 3 })).toEqual([]); }); - it('无匹配关键词返回空数组(不抛错)', () => { + it('无匹配关键词返回空数组(不抛错)', async () => { mgr.store({ type: 'episodic', content: '存在的关键词', source: 'user_input', importance: 0.7 }); // "完全无关联" 的 bigram 与文档无重叠 → TF-IDF 0 命中;LIKE 也无子串 → [] - expect(mgr.search('完全无关联')).toEqual([]); + expect(await mgr.search('完全无关联')).toEqual([]); }); - it('英文无命中时回退 LIKE 子串搜索', () => { + it('英文无命中时回退 LIKE 子串搜索', async () => { mgr.store({ type: 'episodic', content: 'hello world network', @@ -625,11 +627,11 @@ describe.skipIf(!dbAvailable)('MemoryManager — search 回退与边界', () => }); // query "lo wo" 分词为 ['lo','wo'],与文档 token 无重叠 → TF-IDF 0 命中 // 但 "%lo wo%" 是 "hello world" 的连续子串 → LIKE 回退命中 - const results = mgr.search('lo wo'); + const results = await mgr.search('lo wo'); expect(results.some((r) => r.content.includes('hello world'))).toBe(true); }); - it('LIKE 回退时 LIKE 通配符 % 与 _ 被转义(不当作通配符)', () => { + it('LIKE 回退时 LIKE 通配符 % 与 _ 被转义(不当作通配符)', async () => { mgr.store({ type: 'episodic', content: '使用 50% 折扣 与 under_score', @@ -643,15 +645,15 @@ describe.skipIf(!dbAvailable)('MemoryManager — search 回退与边界', () => importance: 0.7, }); // 查询 "%":分词为空 → 强制走 LIKE;若 % 未转义会匹配所有记录 - const pct = mgr.search('%'); + const pct = await mgr.search('%'); expect(pct.some((r) => r.content.includes('50%'))).toBe(true); expect(pct.some((r) => r.content === '完全无关的内容')).toBe(false); // 查询 "_":若未转义会匹配任意单字符 → 误命中无关记录 - const underscore = mgr.search('_'); + const underscore = await mgr.search('_'); expect(underscore.some((r) => r.content === '完全无关的内容')).toBe(false); }); - it('LIKE 回退时反斜杠被转义(Windows 路径不报错)', () => { + it('LIKE 回退时反斜杠被转义(Windows 路径不报错)', async () => { mgr.store({ type: 'episodic', content: '路径 C:\\Users\\test', @@ -659,10 +661,10 @@ describe.skipIf(!dbAvailable)('MemoryManager — search 回退与边界', () => importance: 0.7, }); // 反斜杠单独作为查询 → tokenize 为空 → LIKE 路径;不转义会导致 SQLite 报错 - expect(() => mgr.search('\\')).not.toThrow(); + await expect(mgr.search('\\')).resolves.toBeInstanceOf(Array); }); - it('search 的 topK 同时作用于回退路径', () => { + it('search 的 topK 同时作用于回退路径', async () => { for (let i = 0; i < 6; i++) { mgr.store({ type: 'episodic', @@ -671,11 +673,11 @@ describe.skipIf(!dbAvailable)('MemoryManager — search 回退与边界', () => importance: 0.7, }); } - const results = mgr.search('backup', { topK: 3 }); + const results = await mgr.search('backup', { topK: 3 }); expect(results.length).toBeLessThanOrEqual(3); }); - it('search 无结果时回退 LIKE 的 score = importance * timeDecay', () => { + it('search 无结果时回退 LIKE 的 score = importance * timeDecay', async () => { mgr.store({ type: 'episodic', content: 'fallbackscore 内容', @@ -683,16 +685,16 @@ describe.skipIf(!dbAvailable)('MemoryManager — search 回退与边界', () => importance: 0.8, }); // "allbackscor" 分词不在文档 token 中 → TF-IDF 0 命中;LIKE %allbackscor% 命中 - const results = mgr.search('allbackscor'); + const results = await mgr.search('allbackscor'); expect(results.length).toBeGreaterThan(0); // 新记录 timeDecay≈1 → score≈importance=0.8 expect(results[0].score).toBeCloseTo(0.8, 1); }); - it('LIKE 回退:episodic 按 importance 降序返回', () => { + it('LIKE 回退:episodic 按 importance 降序返回', async () => { mgr.store({ type: 'episodic', content: '排序验证', source: 'user_input', importance: 0.2 }); mgr.store({ type: 'episodic', content: '排序验证', source: 'user_input', importance: 0.9 }); - const results = mgr.search('排序验证'); + const results = await mgr.search('排序验证'); expect(results[0].importance).toBe(0.9); }); }); @@ -844,3 +846,141 @@ describe.skipIf(!dbAvailable)('MemoryManager — cleanupExpired', () => { expect(db.prepare('SELECT COUNT(*) AS c FROM episodic_memories').get().c).toBe(1); }); }); + +// ===== v0.8.1: P0-2 生命周期 + P1-1 向量混合检索 ===== + +describe.skipIf(!dbAvailable)('MemoryManager — v0.8.1 生命周期与混合检索', () => { + let db: any; + let mgr: MemoryManager; + + beforeAll(() => { + if (!dbAvailable) return; + db = new Database(':memory:'); + createMemorySchema(db); + mgr = new MemoryManager(() => db); + mgr.initialize(); + }); + afterAll(() => { + if (db) db.close(); + }); + afterEach(() => { + db.exec('DELETE FROM episodic_memories'); + db.exec('DELETE FROM semantic_memories'); + db.exec('DELETE FROM working_memories'); + mgr.setEmbedder(null); + }); + + it('P0-2: store 接受 expiresAt 并写入 episodic_memories.expires_at', () => { + const ttl = Date.now() + 1000; + mgr.store({ + type: 'episodic', + content: 'TTL 验证内容', + source: 'tool_result', + importance: 0.6, + expiresAt: ttl, + }); + const row = db + .prepare('SELECT expires_at FROM episodic_memories WHERE content = ?') + .get('TTL 验证内容') as { + expires_at: number | null; + }; + expect(row.expires_at).toBe(ttl); + }); + + it('P0-2: semantic 检索命中后 access_count 递增', async () => { + mgr.store({ + type: 'semantic', + content: '用户偏好简洁回答', + summary: 'pref-brief', + source: 'agent_thought', + importance: 0.9, + }); + const before = ( + db.prepare('SELECT access_count FROM semantic_memories WHERE key = ?').get('pref-brief') as { + access_count: number; + } + ).access_count; + await mgr.search('偏好简洁'); + const after = ( + db.prepare('SELECT access_count FROM semantic_memories WHERE key = ?').get('pref-brief') as { + access_count: number; + } + ).access_count; + expect(after).toBeGreaterThan(before); + }); + + it('P1-1: 注入 embedder 后混合检索命中同义改写(TF-IDF 单路召回不到的查询)', async () => { + // 文档:"回复要短" — 查询"我喜欢简洁回答"(同义改写,无字面重叠) + mgr.store({ + type: 'semantic', + content: '回复要短', + summary: 'style-rule', + source: 'agent_thought', + importance: 0.9, + }); + // 词表不重叠 → TF-IDF 嵌入向量正交 → 纯 TF-IDF 0 分 + const tfidfOnly = await mgr.search('我喜欢简洁回答'); + expect(tfidfOnly).toHaveLength(0); + + // 注入固定向量的 embedder:同义改写在向量空间中余弦 > 0 + const VECTORS: Record = { + '回复要短 style-rule': [1, 0.9, 0], + 我喜欢简洁回答: [0.95, 1, 0.1], + 无关内容xyz: [0, 0.1, 1], + }; + mgr.setEmbedder({ + embed: async (text) => { + for (const [k, v] of Object.entries(VECTORS)) { + if (text.includes(k)) return v; + } + return [0, 0, 1]; + }, + }); + // 首次检索触发存量记忆的惰性向量回填(本轮仍走 TF-IDF → 0 命中) + await mgr.search('我喜欢简洁回答'); + await new Promise((r) => setTimeout(r, 10)); + // 回填完成后,向量路径生效 → 同义改写命中 + const hybrid = await mgr.search('我喜欢简洁回答'); + expect(hybrid.length).toBeGreaterThan(0); + expect(hybrid[0].content).toBe('回复要短'); + expect(hybrid[0].score).toBeGreaterThan(0); + }); + + it('P1-1: embedder 抛错/返回 null → 回退纯 TF-IDF(行为兼容)', async () => { + mgr.store({ + type: 'semantic', + content: 'TF-IDF 兜底验证', + summary: 'fallback-vec', + source: 'agent_thought', + importance: 0.9, + }); + mgr.setEmbedder({ + embed: async () => { + throw new Error('embed down'); + }, + }); + const results = await mgr.search('TF-IDF 兜底验证'); + expect(results.length).toBeGreaterThan(0); + + mgr.setEmbedder({ embed: async () => null }); + const results2 = await mgr.search('TF-IDF 兜底验证'); + expect(results2.length).toBeGreaterThan(0); + }); + + it('P1-1: store 写入异步回填 embedding BLOB', async () => { + mgr.setEmbedder({ embed: async () => [0.5, 0.5, 0.5] }); + mgr.store({ + type: 'semantic', + content: '向量化回填验证', + summary: 'vec-backfill', + source: 'agent_thought', + importance: 0.8, + }); + await new Promise((r) => setTimeout(r, 10)); + const row = db + .prepare('SELECT embedding FROM semantic_memories WHERE key = ?') + .get('vec-backfill') as { embedding: Buffer | null }; + expect(row.embedding).not.toBeNull(); + expect(row.embedding!.length % 4).toBe(0); + }); +}); diff --git a/electron/harness/memory/consolidator.ts b/electron/harness/memory/consolidator.ts index efaa4a1..4a787ee 100644 --- a/electron/harness/memory/consolidator.ts +++ b/electron/harness/memory/consolidator.ts @@ -26,6 +26,8 @@ import type { MetonaRequest } from '../types'; import type { WorkspaceService } from '../../services/workspace.service'; import type { IterationStep } from '../agent-loop/types'; import type { MemoryManager } from './manager'; +// v0.8.1 P1-2: 分区条目摘要(与 Maintainer 共用,消除全文截断去重盲区) +import { parseMemoryEntries, buildMemoryEntriesDigest } from './maintainer'; /** 允许写入的 MEMORY.md 分区(与 WorkspaceService.MEMORY_TEMPLATE 对齐) */ const ALLOWED_SECTIONS = ['用户偏好', '项目上下文', '重要决策', '待办事项', '已知问题'] as const; @@ -91,10 +93,7 @@ export class MemoryConsolidator { }, timeoutMs); }); try { - await Promise.race([ - this.runningPromise.catch(() => {}), - timer, - ]); + await Promise.race([this.runningPromise.catch(() => {}), timer]); return !timedOut; } finally { if (timerHandle) clearTimeout(timerHandle); @@ -142,14 +141,21 @@ export class MemoryConsolidator { ): Promise { try { // 1. 构建对话摘要 - const conversationDigest = this.buildConversationDigest(userMessage, assistantAnswer, iterations); + const conversationDigest = this.buildConversationDigest( + userMessage, + assistantAnswer, + iterations, + ); if (!conversationDigest) { return { appended: 0, entries: [], skipped: 0 }; } - // 2. 读取当前 MEMORY.md 内容(供 LLM 去重) + // 2. 读取当前 MEMORY.md 条目摘要(供 LLM 去重) + // v0.8.1 P1-2 根治: 旧实现全文截 3000 字符,尾部条目对 LLM 不可见 → 去重 + // 失效、重复写入。现用纯条目摘要(8000 字符预算),完整覆盖全部条目。 const currentMemory = this.workspaceService.getFiles().memory; - const memoryDigest = this.truncateMemoryForPrompt(currentMemory); + const memoryDigest = + buildMemoryEntriesDigest(parseMemoryEntries(currentMemory ?? '')) || '(empty)'; // 3. 调用 LLM 提取需要持久化的记忆 const llmResponse = await this.callLLMForExtraction(conversationDigest, memoryDigest); @@ -205,7 +211,9 @@ export class MemoryConsolidator { } if (validEntries.length > 0) { - log.info(`[MemoryConsolidator] Persisted ${validEntries.length} memories to MEMORY.md (skipped: ${skipped})`); + log.info( + `[MemoryConsolidator] Persisted ${validEntries.length} memories to MEMORY.md (skipped: ${skipped})`, + ); } return { appended: validEntries.length, entries: validEntries, skipped }; @@ -238,8 +246,10 @@ export class MemoryConsolidator { const status = result?.success ? 'ok' : 'error'; const resultPreview = result?.result ? this.truncate(JSON.stringify(result.result), 200) - : result?.error ?? ''; - toolSummaries.push(` - ${tc.name}(${this.truncate(JSON.stringify(tc.args), 100)}) [${status}]${resultPreview ? ': ' + resultPreview : ''}`); + : (result?.error ?? ''); + toolSummaries.push( + ` - ${tc.name}(${this.truncate(JSON.stringify(tc.args), 100)}) [${status}]${resultPreview ? ': ' + resultPreview : ''}`, + ); } } if (toolSummaries.length > 0) { @@ -252,16 +262,6 @@ export class MemoryConsolidator { return parts.join('\n\n'); } - /** - * 截断 MEMORY.md 内容用于 prompt(避免过长) - */ - private truncateMemoryForPrompt(memory: string): string { - if (!memory) return '(empty)'; - // 截取前 3000 字符,保留分区结构概览 - if (memory.length <= 3000) return memory; - return memory.slice(0, 3000) + '\n... (truncated)'; - } - /** * 调用 LLM 提取需要持久化的记忆 */ @@ -280,7 +280,8 @@ export class MemoryConsolidator { agentVersion: '1.0.0', }, systemPrompt: { - roleDefinition: 'You are a memory curator for an AI agent. Your job is to decide what information from the current conversation is worth persisting to the agent\'s long-term memory file (MEMORY.md) for future sessions.', + roleDefinition: + "You are a memory curator for an AI agent. Your job is to decide what information from the current conversation is worth persisting to the agent's long-term memory file (MEMORY.md) for future sessions.", outputConstraints: [ 'Analyze the conversation below and extract ONLY information that meets ALL of these criteria:', '1. Long-term value: will be useful in future conversations (not transient task state)', @@ -294,13 +295,16 @@ export class MemoryConsolidator { 'If nothing is worth persisting, output an empty array: []', 'Output ONLY the JSON array, no markdown fences, no explanation.', ].join('\n'), - safetyGuidelines: 'Do not persist sensitive data (passwords, API keys, tokens). Do not persist user personal information beyond what is necessary for the agent to function.', + safetyGuidelines: + 'Do not persist sensitive data (passwords, API keys, tokens). Do not persist user personal information beyond what is necessary for the agent to function.', }, - messages: [{ - role: 'user', - content: `## Current MEMORY.md content:\n\n${currentMemory}\n\n## Current conversation:\n\n${conversationDigest}\n\n## Task:\nExtract information worth persisting. Output JSON array only.`, - timestamp: Date.now(), - }], + messages: [ + { + role: 'user', + content: `## Current MEMORY.md content:\n\n${currentMemory}\n\n## Current conversation:\n\n${conversationDigest}\n\n## Task:\nExtract information worth persisting. Output JSON array only.`, + timestamp: Date.now(), + }, + ], params: { maxTokens: 1024, temperature: 0.0, @@ -341,7 +345,10 @@ export class MemoryConsolidator { // 移除可能的 markdown 代码围栏 if (cleaned.startsWith('```')) { - cleaned = cleaned.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '').trim(); + cleaned = cleaned + .replace(/^```(?:json)?\s*/i, '') + .replace(/\s*```$/, '') + .trim(); } try { @@ -349,9 +356,12 @@ export class MemoryConsolidator { if (!Array.isArray(parsed)) return []; return parsed - .filter((item): item is { section: string; entry: string } => - typeof item === 'object' && item !== null && - typeof item.section === 'string' && typeof item.entry === 'string', + .filter( + (item): item is { section: string; entry: string } => + typeof item === 'object' && + item !== null && + typeof item.section === 'string' && + typeof item.entry === 'string', ) .map((item) => ({ section: item.section.trim(), diff --git a/electron/harness/memory/embedder.ts b/electron/harness/memory/embedder.ts new file mode 100644 index 0000000..21b3b68 --- /dev/null +++ b/electron/harness/memory/embedder.ts @@ -0,0 +1,19 @@ +/** + * Memory Embedder — 本地向量记忆嵌入接口(v0.8.1 P1-1) + * + * 职责边界:本模块只定义记忆系统消费的嵌入契约,不绑定任何 Provider 实现。 + * main.ts 按用户配置装配:仅在 Provider 为 Ollama(本地推理,零成本、数据不出设备) + * 且设置面板配置了 `memory.embeddingModel` 时注入真实实现;否则保持 null, + * MemoryManager 自动回退纯 TF-IDF 检索(行为与历史版本完全兼容)。 + * + * 根治背景:OllamaAdapter.embed() 自实现以来全项目零调用 —— 本地向量检索能力 + * 一直躺在代码里,TF-IDF bigram 对同义改写("我喜欢简洁回答" vs "回复要短") + * 零召回。本契约激活该能力,检索升级为 混合评分(向量余弦 × TF-IDF)。 + */ + +/** 嵌入失败/不可用的统一返回:null = 本次无法向量化(调用方回退 TF-IDF 路径) */ +export type MemoryEmbedFn = (text: string) => Promise; + +export interface MemoryEmbedder { + embed: MemoryEmbedFn; +} diff --git a/electron/harness/memory/maintainer.ts b/electron/harness/memory/maintainer.ts new file mode 100644 index 0000000..7a976ce --- /dev/null +++ b/electron/harness/memory/maintainer.ts @@ -0,0 +1,355 @@ +/** + * Memory Maintainer — MEMORY.md 维护闭环(v0.8.1 P1-2) + * + * 根治背景:MemoryConsolidator 纯 append-only —— ① 去重盲区:固化 prompt 只带 + * 全文前 3000 字符,超出部分对 LLM 不可见,重复写入无法避免;② 只增不减: + * 过期/被推翻的条目无任何回收路径,MEMORY.md 随使用无限膨胀(>50KB 后固化 + * prompt 与 system 注入双双劣化)。 + * + * 本模块实现两阶段维护闭环(分析与应用分离,应用前必须经用户确认): + * 1. analyze():LLM 读取"分区条目摘要"(纯条目行,无全文截断盲区)→ 产出 + * 结构化建议 {deletes[], updates[]}(去重 / 合并 / 清理过期); + * 2. apply():按用户勾选的动作改写 MEMORY.md(WorkspaceService.rewriteMemory, + * 唯一合法写入口)并同步删除/更新 semantic_memories 对应行(双轨一致)。 + * + * 安全边界:仅追加白名单分区、单次动作数上限、条目精确匹配(防 LLM 幻觉改写 + * 无关内容)、全部动作写入 audit_logs。 + */ + +import { nanoid } from 'nanoid'; +import log from 'electron-log'; +import type Database from 'better-sqlite3'; +import type { IMetonaProviderAdapter } from '../types/metona-adapter'; +import type { MetonaRequest } from '../types'; +import type { WorkspaceService } from '../../services/workspace.service'; + +/** 允许写入的 MEMORY.md 分区(与 WorkspaceService.MEMORY_TEMPLATE / Consolidator 对齐) */ +const ALLOWED_SECTIONS = ['用户偏好', '项目上下文', '重要决策', '待办事项', '已知问题'] as const; + +/** 动作数上限(防 LLM 过度建议) */ +const MAX_ACTIONS = 30; +/** 单条目在 prompt 中的截断长度 */ +const ENTRY_PROMPT_CHARS = 160; +/** 条目摘要总预算(字符)—— 纯条目行远小于全文,同预算下覆盖完整文件 */ +const DIGEST_BUDGET_CHARS = 8000; + +/** 一条维护动作(用户确认的输入/输出单元) */ +export interface MemoryMaintenanceAction { + /** 动作类型:delete = 删除整行;merge = 用 newEntry 替换该行(合并多条时产生多条 update 指向同一 newEntry) */ + action: 'delete' | 'update'; + section: string; + /** MEMORY.md 中该条目的当前完整文本(不含 "- " 前缀;精确匹配锚点) */ + entry: string; + /** action=update 时的替换文本(合并后的新条目) */ + newEntry?: string; + /** LLM 给出的理由(UI 展示) */ + reason?: string; +} + +export interface MemoryMaintenanceProposal { + actions: MemoryMaintenanceAction[]; + /** 当前文件条目总数(UI 展示上下文) */ + totalEntries: number; + /** + * v0.8.1 review (O2): 各分区当前条目数 —— 供维护弹框计算"应用后变空的分区" + * 并向用户提示(空分区保留分区头,条目区将显示为空)。 + */ + sectionEntryCounts: Record; +} + +/** 解析后的分区结构(模块级类型 —— parseEntries/apply 共用) */ +interface ParsedSection { + section: string; + entries: string[]; +} + +/** 解析 MEMORY.md 的分区与条目(模块级工具 —— Maintainer 与 Consolidator 共用) */ +export function parseMemoryEntries(memory: string): ParsedSection[] { + const sections: ParsedSection[] = []; + let current: ParsedSection | null = null; + let inHead = true; + for (const line of memory.split('\n')) { + if (inHead) { + if (line.startsWith('## ')) inHead = false; + else continue; + } + const m = line.match(/^## (.+)$/); + if (m) { + current = { section: m[1].trim(), entries: [] }; + sections.push(current); + continue; + } + const em = line.match(/^- (.+)$/); + if (em && current) { + current.entries.push(em[1].trim()); + } + } + return sections; +} + +/** + * 构建"分区条目摘要"(纯条目行,消除全文截断盲区)。 + * Consolidator 固化去重与 Maintainer 分析共用:同预算(8000 字符)下纯条目 + * 形态可覆盖完整文件,而旧的全文截断(3000 字符)会让 LLM 看不到尾部条目、 + * 去重失效 → 重复写入。 + */ +export function buildMemoryEntriesDigest(sections: ParsedSection[]): string { + const parts: string[] = []; + let used = 0; + for (const s of sections) { + if (s.entries.length === 0) continue; + const lines: string[] = [`## ${s.section}`]; + for (const e of s.entries) { + const clipped = e.length > ENTRY_PROMPT_CHARS ? `${e.slice(0, ENTRY_PROMPT_CHARS)}...` : e; + lines.push(`- ${clipped}`); + } + const block = lines.join('\n'); + if (used + block.length > DIGEST_BUDGET_CHARS) break; + parts.push(block); + used += block.length; + } + return parts.join('\n\n'); +} + +export class MemoryMaintainer { + constructor( + private getAdapter: () => IMetonaProviderAdapter, + private workspaceService: WorkspaceService, + private getDB: () => Database.Database, + ) {} + + /** 分析当前 MEMORY.md,产出维护建议(不改任何文件/DB) */ + async analyze(): Promise { + const memory = this.workspaceService.getFiles().memory ?? ''; + const sections = this.parseEntries(memory); + const totalEntries = sections.reduce((n, s) => n + s.entries.length, 0); + + const sectionEntryCounts: Record = {}; + for (const s of sections) { + sectionEntryCounts[s.section] = s.entries.length; + } + + if (totalEntries === 0) { + return { actions: [], totalEntries: 0, sectionEntryCounts }; + } + + const digest = this.buildDigest(sections); + const raw = await this.callLLM(digest); + const actions = this.parseActions(raw, sections); + return { actions, totalEntries, sectionEntryCounts }; + } + + /** 应用用户确认的动作(只处理精确命中当前文件内容的动作,防幻觉改写) */ + apply(actions: MemoryMaintenanceAction[]): { applied: number; skipped: number } { + const memory = this.workspaceService.getFiles().memory ?? ''; + const sections = this.parseEntries(memory); + + // 精确匹配校验:entry 必须原样存在于对应分区(LLM 响应与文件状态之间的一致性锚点) + const valid: MemoryMaintenanceAction[] = []; + for (const a of actions.slice(0, MAX_ACTIONS)) { + const section = sections.find((s) => s.section === a.section); + const exists = section?.entries.includes(a.entry) ?? false; + if (!exists) continue; + if (a.action === 'update' && (!a.newEntry || !a.newEntry.trim())) continue; + valid.push(a); + } + + if (valid.length === 0) return { applied: 0, skipped: actions.length }; + + // 应用到内存结构:delete 直接删;update 替换文本 + for (const a of valid) { + const section = sections.find((s) => s.section === a.section); + if (!section) continue; + if (a.action === 'delete') { + section.entries = section.entries.filter((e) => e !== a.entry); + } else { + section.entries = section.entries.map((e) => (e === a.entry ? a.newEntry!.trim() : e)); + } + } + + // 序列化回 Markdown(保留原文件头;分区结构重建) + const head = this.extractHead(memory); + const body = sections + .map((s) => `## ${s.section}\n${s.entries.map((e) => `- ${e}`).join('\n')}`) + .filter((s) => !s.endsWith('## ') && s.split('\n').length > 1) + .join('\n\n'); + this.workspaceService.rewriteMemory(`${head}${body}\n`); + + // 双轨一致:同步 semantic_memories(content 以 entry 写入 —— Consolidator 同口径) + const db = this.getDB(); + const delStmt = db.prepare('DELETE FROM semantic_memories WHERE content = ?'); + const updStmt = db.prepare( + 'UPDATE semantic_memories SET content = ?, summary = ? WHERE content = ?', + ); + let dbOps = 0; + for (const a of valid) { + try { + if (a.action === 'delete') { + dbOps += delStmt.run(a.entry).changes; + } else { + dbOps += updStmt.run( + a.newEntry!.trim(), + `[${a.section}] ${a.newEntry!.trim().slice(0, 60)}`, + a.entry, + ).changes; + } + } catch (err) { + // DB 同步失败不影响 MEMORY.md 已写入结果(与 Consolidator 同语义) + log.warn('[MemoryMaintainer] semantic_memories sync failed:', (err as Error).message); + } + } + + log.info( + `[MemoryMaintainer] applied ${valid.length} action(s) (db rows touched: ${dbOps}, skipped: ${actions.length - valid.length})`, + ); + return { applied: valid.length, skipped: actions.length - valid.length }; + } + + // ===== 私有方法 ===== + + /** 解析 MEMORY.md 为 {section, entries[]} 结构(跳过元数据头;复用模块级工具) */ + private parseEntries(memory: string): ParsedSection[] { + return parseMemoryEntries(memory); + } + + /** 提取文件头(H1 + > 元数据区),供重建时保留 */ + private extractHead(memory: string): string { + const lines = memory.split('\n'); + let headEnd = 0; + for (let i = 0; i < lines.length; i++) { + if (lines[i].startsWith('## ')) { + headEnd = i; + break; + } + } + const headLines = lines.slice(0, headEnd).join('\n').trimEnd(); + return headLines.length > 0 ? `${headLines}\n\n` : ''; + } + + /** 构建"分区条目摘要"(纯条目行,消除全文截断盲区) */ + private buildDigest(sections: ParsedSection[]): string { + const parts: string[] = []; + let used = 0; + for (const s of sections) { + if (s.entries.length === 0) continue; + const lines: string[] = [`## ${s.section}`]; + for (const e of s.entries) { + const clipped = e.length > ENTRY_PROMPT_CHARS ? `${e.slice(0, ENTRY_PROMPT_CHARS)}...` : e; + lines.push(`- ${clipped}`); + } + const block = lines.join('\n'); + if (used + block.length > DIGEST_BUDGET_CHARS) break; + parts.push(block); + used += block.length; + } + return parts.join('\n\n'); + } + + /** LLM 分析(结构化 JSON 输出,30s 超时与 Consolidator 同口径) */ + private async callLLM(digest: string): Promise { + const sectionsList = ALLOWED_SECTIONS.map((s) => `"${s}"`).join(', '); + const request: MetonaRequest = { + meta: { + sessionId: 'memory-maintenance', + iteration: 0, + requestId: `mm_${nanoid(12)}`, + timestamp: Date.now(), + agentVersion: '1.0.0', + }, + systemPrompt: { + roleDefinition: + "You are a memory curator maintaining the agent's long-term memory file (MEMORY.md).", + outputConstraints: [ + 'Analyze the memory entries below and propose maintenance actions:', + '- "delete": remove stale, superseded, duplicated, or completed entries', + '- "update": merge two or more duplicate/similar entries into ONE consolidated entry', + 'Keep valuable, still-valid information — do NOT delete aggressively.', + `Every action must reference an existing entry EXACTLY as written (section must be one of ${sectionsList}).`, + '', + 'Output ONLY a JSON array, no markdown fences:', + '[{"action":"delete","section":"...","entry":"...","reason":"..."},', + ' {"action":"update","section":"...","entry":"old entry","newEntry":"merged entry","reason":"..."}]', + 'If nothing needs maintenance, output []', + ].join('\n'), + safetyGuidelines: 'Never propose deleting user preference facts without a clear reason.', + }, + messages: [ + { + role: 'user', + content: `## Current MEMORY.md entries:\n\n${digest}\n\n## Task:\nPropose maintenance actions. Output JSON array only.`, + timestamp: Date.now(), + }, + ], + params: { + maxTokens: 2048, + temperature: 0.0, + stream: false, + thinkingEnabled: false, + thinkingEffort: 'low', + }, + }; + + try { + let timer: ReturnType | undefined; + try { + const timeoutPromise = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('maintenance analysis timeout')), 30_000); + }); + const response = await Promise.race([this.getAdapter().send(request), timeoutPromise]); + return response.content.trim(); + } finally { + if (timer) clearTimeout(timer); + } + } catch (error) { + log.warn('[MemoryMaintainer] LLM call failed:', (error as Error).message); + return null; + } + } + + /** 解析 LLM 建议(丢弃非法 section / 空条目 / 超限动作) */ + private parseActions(raw: string | null, sections: ParsedSection[]): MemoryMaintenanceAction[] { + if (!raw) return []; + let cleaned = raw.trim(); + if (cleaned.startsWith('```')) { + cleaned = cleaned + .replace(/^```(?:json)?\s*/i, '') + .replace(/\s*```$/, '') + .trim(); + } + let parsed: unknown; + try { + parsed = JSON.parse(cleaned); + } catch { + log.warn('[MemoryMaintainer] failed to parse LLM response as JSON:', cleaned.slice(0, 200)); + return []; + } + if (!Array.isArray(parsed)) return []; + + const validSections = new Set(ALLOWED_SECTIONS); + // 仅允许引用当前文件中真实存在的条目(先过滤一轮,双保险在 apply 中再做精确校验) + const existing = new Set(); + for (const s of sections) { + for (const e of s.entries) existing.add(e); + } + + const out: MemoryMaintenanceAction[] = []; + for (const item of parsed.slice(0, MAX_ACTIONS)) { + if (!item || typeof item !== 'object') continue; + const a = item as Record; + const action = a.action; + const section = typeof a.section === 'string' ? a.section.trim() : ''; + const entry = typeof a.entry === 'string' ? a.entry.trim() : ''; + if (action !== 'delete' && action !== 'update') continue; + if (!validSections.has(section) || !entry || !existing.has(entry)) continue; + if (action === 'update' && (typeof a.newEntry !== 'string' || !a.newEntry.trim())) continue; + out.push({ + action, + section, + entry, + newEntry: action === 'update' ? (a.newEntry as string).trim() : undefined, + reason: typeof a.reason === 'string' ? a.reason.slice(0, 200) : undefined, + }); + } + return out; + } +} diff --git a/electron/harness/memory/manager.ts b/electron/harness/memory/manager.ts index 32c5f09..e1c23bd 100644 --- a/electron/harness/memory/manager.ts +++ b/electron/harness/memory/manager.ts @@ -17,6 +17,7 @@ import { nanoid } from 'nanoid'; import { createHash } from 'crypto'; import type Database from 'better-sqlite3'; import log from 'electron-log'; +import type { MemoryEmbedder } from './embedder'; export type MemoryType = 'episodic' | 'semantic' | 'working'; export type MemorySource = 'user_input' | 'tool_result' | 'agent_thought' | 'imported'; @@ -94,7 +95,11 @@ function computeTF(tokens: string[]): Map { } /** 计算余弦相似度的点积部分 */ -function dotProduct(tf1: Map, tf2: Map, idf: Map): number { +function dotProduct( + tf1: Map, + tf2: Map, + idf: Map, +): number { let sum = 0; for (const [term, freq1] of tf1) { const freq2 = tf2.get(term); @@ -123,6 +128,37 @@ function timeDecayWeight(createdAt: number, now: number = Date.now()): number { return Math.pow(0.5, ageDays / halfLifeDays); } +// ===== 向量工具(v0.8.1 P1-1 本地向量混合检索) ===== + +/** Float32Array → SQLite BLOB(little-endian 原生布局,Node/SQLite 同机读写安全) */ +function float32ToBlob(vec: number[]): Buffer { + const f32 = Float32Array.from(vec); + return Buffer.from(f32.buffer, f32.byteOffset, f32.byteLength); +} + +/** SQLite BLOB → number[](维度/字节损坏时返回 null,调用方回退 TF-IDF 路径) */ +function blobToFloat32(blob: unknown): number[] | null { + if (!Buffer.isBuffer(blob)) return null; + if (blob.length === 0 || blob.length % 4 !== 0) return null; + const f32 = new Float32Array(blob.buffer, blob.byteOffset, blob.length / 4); + return Array.from(f32); +} + +/** 余弦相似度(零向量/维度不匹配返回 0) */ +function cosineSimilarity(a: number[], b: number[]): number { + if (a.length === 0 || a.length !== b.length) return 0; + let dot = 0; + let na = 0; + let nb = 0; + for (let i = 0; i < a.length; i++) { + dot += a[i] * b[i]; + na += a[i] * a[i]; + nb += b[i] * b[i]; + } + if (na === 0 || nb === 0) return 0; + return dot / (Math.sqrt(na) * Math.sqrt(nb)); +} + /** * 记忆管理器 */ @@ -136,8 +172,20 @@ export class MemoryManager { /** 缓存有效期(5 分钟) */ private readonly CACHE_TTL = 5 * 60 * 1000; + /** + * v0.8.1 P1-1: 本地向量嵌入器(可选注入)。 + * main.ts 仅在 Provider=Ollama 且用户配置了 memory.embeddingModel 时注入; + * null = 向量检索禁用,search/store 全部走纯 TF-IDF 路径(历史行为)。 + */ + private embedder: MemoryEmbedder | null = null; + constructor(private getDB: () => Database.Database) {} + /** 注入向量嵌入器(传 null 关闭向量路径;热切换由 main.ts 在 adapter 重载时联动) */ + setEmbedder(embedder: MemoryEmbedder | null): void { + this.embedder = embedder; + } + /** * 初始化(表结构由 DatabaseService 创建) */ @@ -165,9 +213,15 @@ export class MemoryManager { const newIdfCache = new Map(); // 获取所有记忆内容(episodic + semantic + working) - const episodicRows = db.prepare('SELECT content, summary FROM episodic_memories').all() as Array<{ content: string; summary: string | null }>; - const semanticRows = db.prepare('SELECT value FROM semantic_memories').all() as Array<{ value: string }>; - const workingRows = db.prepare('SELECT value FROM working_memories').all() as Array<{ value: string }>; + const episodicRows = db + .prepare('SELECT content, summary FROM episodic_memories') + .all() as Array<{ content: string; summary: string | null }>; + const semanticRows = db.prepare('SELECT value FROM semantic_memories').all() as Array<{ + value: string; + }>; + const workingRows = db.prepare('SELECT value FROM working_memories').all() as Array<{ + value: string; + }>; const allDocs = [ ...episodicRows.map((r) => r.content + ' ' + (r.summary ?? '')), @@ -244,6 +298,10 @@ export class MemoryManager { source: MemorySource; sessionId?: string; expiresAt?: number; + /** v0.8.1 P1-1: 文档向量(embedding 列缺失/损坏时为 null → 单路 TF-IDF 评分) */ + docVec?: number[] | null; + /** v0.8.1 P1-1: 查询向量(null → 单路 TF-IDF 评分) */ + queryVec?: number[] | null; }, queryTF: Map, queryNorm: number, @@ -253,31 +311,59 @@ export class MemoryManager { const docTF = computeTF(params.docTokens); const docNorm = vectorNorm(docTF, this.idfCache); - if (docNorm === 0) return; - - const dotProd = dotProduct(queryTF, docTF, this.idfCache); - const cosineSim = dotProd / (queryNorm * docNorm); + if (docNorm === 0 && !(params.docVec && params.queryVec)) return; // 时间衰减 const decayWeight = timeDecayWeight(params.createdAt, now); - // 最终分数 = 余弦相似度 * 时间衰减 * 重要度权重 - const finalScore = cosineSim * decayWeight * (0.5 + params.importance * 0.5); + const importanceWeight = 0.5 + params.importance * 0.5; + + // TF-IDF 路(docNorm=0 时得 0 分,交由向量路兜底) + const tfidfScore = + docNorm > 0 + ? (dotProduct(queryTF, docTF, this.idfCache) / (queryNorm * docNorm)) * + decayWeight * + importanceWeight + : 0; + // 向量路(双侧齐备时计算余弦) + const vectorScore = + params.docVec && params.queryVec + ? cosineSimilarity(params.queryVec, params.docVec) * decayWeight * importanceWeight + : 0; + + // v0.8.1 P1-1 混合评分:双侧齐备 0.6 向量 + 0.4 TF-IDF;否则取可用单路 + let finalScore: number; + if (params.docVec && params.queryVec) { + finalScore = 0.6 * vectorScore + 0.4 * tfidfScore; + } else { + finalScore = tfidfScore > 0 ? tfidfScore : vectorScore; + } if (finalScore > 0) { results.push({ - id: params.id, type: params.type, content: params.content, - summary: params.summary, source: params.source, - importance: params.importance, sessionId: params.sessionId, - createdAt: params.createdAt, expiresAt: params.expiresAt, + id: params.id, + type: params.type, + content: params.content, + summary: params.summary, + source: params.source, + importance: params.importance, + sessionId: params.sessionId, + createdAt: params.createdAt, + expiresAt: params.expiresAt, score: finalScore, }); } } /** - * TF-IDF 相似度搜索 + * TF-IDF 相似度搜索(v0.8.1 P1-1: 可选向量混合评分) + * + * @param queryVec 查询向量(embedder 未注入/失败时为 null → 纯 TF-IDF) */ - private tfidfSearch(query: string, options: MemorySearchOptions): SearchResult[] { + private tfidfSearch( + query: string, + options: MemorySearchOptions, + queryVec: number[] | null, + ): SearchResult[] { const db = this.getDB(); this.updateIdfCache(); @@ -296,71 +382,142 @@ export class MemoryManager { // 搜索 episodic 记忆 if (!type || type === 'episodic') { - const rows = db.prepare(` + const rows = db + .prepare( + ` SELECT * FROM episodic_memories WHERE importance >= ? ORDER BY importance DESC, created_at DESC LIMIT ? - `).all(minImportance, topK * 3) as Array<{ - id: string; session_id: string | null; content: string; summary: string | null; - source: string; importance: number; created_at: number; expires_at: number | null; + `, + ) + .all(minImportance, topK * 3) as Array<{ + id: string; + session_id: string | null; + content: string; + summary: string | null; + source: string; + importance: number; + created_at: number; + expires_at: number | null; tf_cache: string | null; }>; for (const row of rows) { - this.scoreAndPushMemory({ - docTokens: this.cachedTokens(row.tf_cache, row.content + ' ' + (row.summary ?? '')), - createdAt: row.created_at, - importance: row.importance, - id: row.id, type: 'episodic', content: row.content, - summary: row.summary ?? undefined, - source: row.source as MemorySource, - sessionId: row.session_id ?? undefined, - expiresAt: row.expires_at ?? undefined, - }, queryTF, queryNorm, now, results); + this.scoreAndPushMemory( + { + docTokens: this.cachedTokens(row.tf_cache, row.content + ' ' + (row.summary ?? '')), + createdAt: row.created_at, + importance: row.importance, + id: row.id, + type: 'episodic', + content: row.content, + summary: row.summary ?? undefined, + source: row.source as MemorySource, + sessionId: row.session_id ?? undefined, + expiresAt: row.expires_at ?? undefined, + docVec: blobToFloat32((row as { embedding?: unknown }).embedding), + queryVec, + }, + queryTF, + queryNorm, + now, + results, + ); } + this.backfillMissingEmbeddings( + 'episodic', + rows.map((r) => ({ + id: r.id, + embedding: (r as { embedding?: unknown }).embedding, + text: r.content + ' ' + (r.summary ?? ''), + })), + ); } // 搜索 semantic 记忆 if (!type || type === 'semantic') { - const rows = db.prepare(` + const rows = db + .prepare( + ` SELECT * FROM semantic_memories WHERE confidence >= ? ORDER BY confidence DESC, access_count DESC LIMIT ? - `).all(minImportance, Math.ceil(topK * 1.5)) as Array<{ - id: string; key: string; value: string; category: string | null; - confidence: number; source_session: string | null; created_at: number; + `, + ) + .all(minImportance, Math.ceil(topK * 1.5)) as Array<{ + id: string; + key: string; + value: string; + category: string | null; + confidence: number; + source_session: string | null; + created_at: number; tf_cache: string | null; }>; for (const row of rows) { - this.scoreAndPushMemory({ - docTokens: this.cachedTokens(row.tf_cache, row.key + ' ' + row.value), - createdAt: row.created_at, - importance: row.confidence, - id: row.id, type: 'semantic', content: row.value, - source: 'imported', - sessionId: row.source_session ?? undefined, - }, queryTF, queryNorm, now, results); + this.scoreAndPushMemory( + { + docTokens: this.cachedTokens(row.tf_cache, row.key + ' ' + row.value), + createdAt: row.created_at, + importance: row.confidence, + id: row.id, + type: 'semantic', + content: row.value, + source: 'imported', + sessionId: row.source_session ?? undefined, + docVec: blobToFloat32((row as { embedding?: unknown }).embedding), + queryVec, + }, + queryTF, + queryNorm, + now, + results, + ); } + this.backfillMissingEmbeddings( + 'semantic', + rows.map((r) => ({ + id: r.id, + embedding: (r as { embedding?: unknown }).embedding, + text: r.key + ' ' + r.value, + })), + ); } // 搜索 working 记忆 if (!type || type === 'working') { - const rows = db.prepare(` + const rows = db + .prepare( + ` SELECT * FROM working_memories ORDER BY updated_at DESC LIMIT ? - `).all(topK * 3) as Array<{ - id: string; session_id: string; task_id: string; - key: string; value: string; updated_at: number; + `, + ) + .all(topK * 3) as Array<{ + id: string; + session_id: string; + task_id: string; + key: string; + value: string; + updated_at: number; tf_cache: string | null; }>; for (const row of rows) { - this.scoreAndPushMemory({ - docTokens: this.cachedTokens(row.tf_cache, row.key + ' ' + row.value), - createdAt: row.updated_at, - importance: 0.5, - id: row.id, type: 'working', content: row.value, - source: 'agent_thought', - sessionId: row.session_id, - }, queryTF, queryNorm, now, results); + this.scoreAndPushMemory( + { + docTokens: this.cachedTokens(row.tf_cache, row.key + ' ' + row.value), + createdAt: row.updated_at, + importance: 0.5, + id: row.id, + type: 'working', + content: row.value, + source: 'agent_thought', + sessionId: row.session_id, + }, + queryTF, + queryNorm, + now, + results, + ); } } @@ -383,11 +540,22 @@ export class MemoryManager { switch (item.type) { case 'episodic': - db.prepare(` - INSERT INTO episodic_memories (id, session_id, content, summary, source, importance, created_at, tf_cache) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `).run( - id, item.sessionId ?? null, item.content, item.summary ?? null, item.source, importance, now, + db.prepare( + ` + INSERT INTO episodic_memories (id, session_id, content, summary, source, importance, created_at, expires_at, tf_cache) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ).run( + id, + item.sessionId ?? null, + item.content, + item.summary ?? null, + item.source, + importance, + now, + // v0.8.1 P0-2: expires_at 真实写入方 —— 调用方(MemoryTriggerHook 等)可携带 + // TTL;此前该列全链路无写入方,cleanupExpired 空转,情节记忆只增不减 + item.expiresAt ?? null, // P2-12: 写入时预计算分词缓存,加速后续检索 JSON.stringify(tokenize(item.content + ' ' + (item.summary ?? ''))), ); @@ -397,22 +565,38 @@ export class MemoryManager { // #32 修复: 当 summary 未提供时,使用 content hash 作为 key 实现基于内容的去重 // v0.3.0 用 id 作为 key 时,因 id 每次新生成,INSERT OR REPLACE 永远不触发 REPLACE, // 导致重复 store 同一内容会创建多条记忆。改为 contentHash 后,相同内容自动 REPLACE。 - db.prepare(` + db.prepare( + ` INSERT OR REPLACE INTO semantic_memories (id, key, value, category, confidence, source_session, created_at, updated_at, access_count, tf_cache) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?) - `).run( - id, item.summary ?? this.contentHash(item.content), item.content, 'general', importance, item.sessionId ?? null, now, now, + `, + ).run( + id, + item.summary ?? this.contentHash(item.content), + item.content, + 'general', + importance, + item.sessionId ?? null, + now, + now, JSON.stringify(tokenize((item.summary ?? '') + ' ' + item.content)), ); break; case 'working': // v0.3.0 修复:使用 summary 作为 key(若提供),避免硬编码 'default' 导致覆盖 // #32 修复: 当 summary 未提供时,使用 content hash 作为 key 实现基于内容的去重 - db.prepare(` + db.prepare( + ` INSERT OR REPLACE INTO working_memories (id, session_id, task_id, key, value, updated_at, tf_cache) VALUES (?, ?, ?, ?, ?, ?, ?) - `).run( - id, item.sessionId ?? 'default', 'default', item.summary ?? this.contentHash(item.content), item.content, now, + `, + ).run( + id, + item.sessionId ?? 'default', + 'default', + item.summary ?? this.contentHash(item.content), + item.content, + now, JSON.stringify(tokenize((item.summary ?? '') + ' ' + item.content)), ); break; @@ -424,28 +608,117 @@ export class MemoryManager { // 使 IDF 缓存失效 this.cacheUpdatedAt = 0; + // v0.8.1 P1-1: 异步向量化回填(fire-and-forget)—— 嵌入不可用时静默跳过, + // 该记忆保留 NULL embedding,检索时自动回退 TF-IDF 路径 + this.enrichEmbedding(item.type, id, (item.summary ?? '') + ' ' + item.content); + log.debug(`Memory stored: ${id} (${item.type})`); return id; } /** - * 检索记忆(v0.2.0: TF-IDF 语义检索 + 时间衰减) - * - * v0.2.0 变更: - * - 使用 TF-IDF 余弦相似度替代 LIKE 关键词搜索 - * - 支持中英文分词(英文按词,中文按 bigram) - * - 时间衰减:30 天半衰期,老旧记忆权重降低 - * - IDF 缓存:5 分钟有效期,避免重复计算 + * v0.8.1 P1-1: 异步生成并回填 embedding BLOB。 + * 失败静默(降级 TF-IDF),不阻塞写入方(工具执行/记忆固化均不等待)。 */ - search(query: string, options: MemorySearchOptions = {}): SearchResult[] { + private embeddingBackfillInFlight = new Set(); + + private enrichEmbedding(type: MemoryType, id: string, text: string): void { + const embedder = this.embedder; + if (!embedder) return; + const table = + type === 'episodic' ? 'episodic_memories' : type === 'semantic' ? 'semantic_memories' : null; + if (!table) return; // working 记忆会话级生命周期短,不参与向量检索 + const key = `${table}:${id}`; + if (this.embeddingBackfillInFlight.has(key)) return; + this.embeddingBackfillInFlight.add(key); + void embedder + .embed(text.slice(0, 8000)) + .then((vec) => { + if (!vec || vec.length === 0) return; + this.getDB() + .prepare(`UPDATE ${table} SET embedding = ? WHERE id = ?`) + .run(float32ToBlob(vec), id); + }) + .catch((err) => { + log.debug(`MemoryManager: embedding enrichment skipped: ${(err as Error).message}`); + }) + .finally(() => { + this.embeddingBackfillInFlight.delete(key); + }); + } + + /** + * v0.8.1 P1-1: 存量记忆向量惰性回填 —— 嵌入功能开启前写入的记忆(embedding + * IS NULL)在参与检索时排队补算:本轮查询仍走 TF-IDF,后续查询即可命中向量 + * 路径。无阻塞、无独立迁移任务,收敛速度随检索频次自然提升;嵌入器不可用 + * 时零开销(直接返回)。 + */ + private backfillMissingEmbeddings( + type: MemoryType, + rows: Array<{ id: string; embedding?: unknown; text: string }>, + ): void { + if (!this.embedder) return; + for (const row of rows) { + if (row.embedding != null) continue; + this.enrichEmbedding(type, row.id, row.text); + } + } + + /** + * v0.8.1 P0-2: 检索命中后回写 semantic_memories.access_count(LRU 淘汰语义激活)。 + * 此前该列只在 ORDER BY 中被读取、从无更新方,LRU 淘汰是死语义。 + */ + private bumpAccessCounts(results: SearchResult[]): void { + const semanticIds = results.filter((r) => r.type === 'semantic').map((r) => r.id); + if (semanticIds.length === 0) return; + try { + const placeholders = semanticIds.map(() => '?').join(', '); + this.getDB() + .prepare( + `UPDATE semantic_memories SET access_count = access_count + 1 WHERE id IN (${placeholders})`, + ) + .run(...semanticIds); + } catch (err) { + // 计数回写失败不影响检索结果 + log.debug('MemoryManager: access_count bump failed:', (err as Error).message); + } + } + + /** + * 检索记忆(v0.2.0: TF-IDF 语义检索 + 时间衰减;v0.8.1 P1-1: 本地向量混合检索) + * + * v0.8.1 变更: + * - 方法改为 async(查询向量需经 MemoryEmbedder 异步生成;Ollama 本地嵌入)。 + * - 混合评分:查询向量与文档向量齐备时 score = 0.6×向量余弦 + 0.4×TF-IDF + * (两者各自叠加时间衰减与重要度权重);任一缺失时回退单路评分 —— + * 未注入 embedder(或嵌入失败)时行为与历史版本完全一致。 + * - 检索命中的 semantic 记忆回写 access_count(LRU 语义激活,P0-2)。 + */ + async search(query: string, options: MemorySearchOptions = {}): Promise { const db = this.getDB(); const { topK = 5, type, minImportance = 0 } = options; // v0.3.0 修复:拦截空 query 和纯空格 query if (!query || !query.trim()) return []; - // v0.2.0: 优先使用 TF-IDF 语义搜索 - const tfidfResults = this.tfidfSearch(query, options); + // v0.8.1: 查询向量生成一次(嵌入器缺失/失败 → null,全量回退 TF-IDF) + let queryVec: number[] | null = null; + if (this.embedder) { + try { + queryVec = await this.embedder.embed(query.slice(0, 8000)); + if (queryVec && queryVec.length === 0) queryVec = null; + } catch (err) { + log.debug( + 'MemoryManager: query embedding failed, falling back to TF-IDF:', + (err as Error).message, + ); + queryVec = null; + } + } + + // v0.2.0: 优先使用语义搜索(TF-IDF ± 向量混合) + const tfidfResults = this.tfidfSearch(query, options, queryVec); if (tfidfResults.length > 0) { + this.bumpAccessCounts(tfidfResults); return tfidfResults; } @@ -458,20 +731,35 @@ export class MemoryManager { // 搜索情节记忆 if (!type || type === 'episodic') { - const rows = db.prepare(` + const rows = db + .prepare( + ` SELECT * FROM episodic_memories WHERE (content LIKE ? ESCAPE '\\' OR summary LIKE ? ESCAPE '\\') AND importance >= ? ORDER BY importance DESC, created_at DESC LIMIT ? - `).all(pattern, pattern, minImportance, topK) as Array<{ - id: string; session_id: string | null; content: string; summary: string | null; - source: string; importance: number; created_at: number; expires_at: number | null; + `, + ) + .all(pattern, pattern, minImportance, topK) as Array<{ + id: string; + session_id: string | null; + content: string; + summary: string | null; + source: string; + importance: number; + created_at: number; + expires_at: number | null; }>; for (const row of rows) { results.push({ - id: row.id, type: 'episodic', content: row.content, - summary: row.summary ?? undefined, source: row.source as MemorySource, - importance: row.importance, sessionId: row.session_id ?? undefined, - createdAt: row.created_at, expiresAt: row.expires_at ?? undefined, + id: row.id, + type: 'episodic', + content: row.content, + summary: row.summary ?? undefined, + source: row.source as MemorySource, + importance: row.importance, + sessionId: row.session_id ?? undefined, + createdAt: row.created_at, + expiresAt: row.expires_at ?? undefined, score: row.importance * timeDecayWeight(row.created_at), }); } @@ -479,20 +767,33 @@ export class MemoryManager { // 搜索语义记忆 if (!type || type === 'semantic') { - const rows = db.prepare(` + const rows = db + .prepare( + ` SELECT * FROM semantic_memories WHERE (key LIKE ? ESCAPE '\\' OR value LIKE ? ESCAPE '\\') AND confidence >= ? ORDER BY confidence DESC, access_count DESC LIMIT ? - `).all(pattern, pattern, minImportance, Math.ceil(topK / 2)) as Array<{ - id: string; key: string; value: string; category: string | null; - confidence: number; source_session: string | null; created_at: number; + `, + ) + .all(pattern, pattern, minImportance, Math.ceil(topK / 2)) as Array<{ + id: string; + key: string; + value: string; + category: string | null; + confidence: number; + source_session: string | null; + created_at: number; }>; for (const row of rows) { results.push({ - id: row.id, type: 'semantic', content: row.value, - source: 'imported', importance: row.confidence, + id: row.id, + type: 'semantic', + content: row.value, + source: 'imported', + importance: row.confidence, sessionId: row.source_session ?? undefined, - createdAt: row.created_at, score: row.confidence * timeDecayWeight(row.created_at), + createdAt: row.created_at, + score: row.confidence * timeDecayWeight(row.created_at), }); } } @@ -500,25 +801,39 @@ export class MemoryManager { // 搜索工作记忆 // v0.3.0 修复:LIKE 回退路径也需添加 !type 分支(与 tfidfSearch 保持一致) if (!type || type === 'working') { - const rows = db.prepare(` + const rows = db + .prepare( + ` SELECT * FROM working_memories WHERE (key LIKE ? ESCAPE '\\' OR value LIKE ? ESCAPE '\\') ORDER BY updated_at DESC LIMIT ? - `).all(pattern, pattern, topK) as Array<{ - id: string; session_id: string; task_id: string; - key: string; value: string; updated_at: number; + `, + ) + .all(pattern, pattern, topK) as Array<{ + id: string; + session_id: string; + task_id: string; + key: string; + value: string; + updated_at: number; }>; for (const row of rows) { results.push({ - id: row.id, type: 'working', content: row.value, - source: 'agent_thought', importance: 0.5, - sessionId: row.session_id, createdAt: row.updated_at, + id: row.id, + type: 'working', + content: row.value, + source: 'agent_thought', + importance: 0.5, + sessionId: row.session_id, + createdAt: row.updated_at, score: 0.3 * timeDecayWeight(row.updated_at), }); } } - return results.sort((a, b) => b.score - a.score).slice(0, topK); + const finalResults = results.sort((a, b) => b.score - a.score).slice(0, topK); + this.bumpAccessCounts(finalResults); + return finalResults; } /** @@ -526,9 +841,13 @@ export class MemoryManager { */ getWorkingMemory(sessionId: string, taskId: string = 'default'): Map { const db = this.getDB(); - const rows = db.prepare(` + const rows = db + .prepare( + ` SELECT key, value FROM working_memories WHERE session_id = ? AND task_id = ? - `).all(sessionId, taskId) as Array<{ key: string; value: string }>; + `, + ) + .all(sessionId, taskId) as Array<{ key: string; value: string }>; return new Map(rows.map((r) => [r.key, r.value])); } @@ -537,10 +856,12 @@ export class MemoryManager { */ setWorkingMemory(sessionId: string, taskId: string, key: string, value: string): void { const db = this.getDB(); - db.prepare(` + db.prepare( + ` INSERT OR REPLACE INTO working_memories (id, session_id, task_id, key, value, updated_at) VALUES (?, ?, ?, ?, ?, ?) - `).run(`wm_${nanoid(8)}`, sessionId, taskId, key, value, Date.now()); + `, + ).run(`wm_${nanoid(8)}`, sessionId, taskId, key, value, Date.now()); } /** @@ -549,7 +870,10 @@ export class MemoryManager { clearWorkingMemory(sessionId: string, taskId?: string): void { const db = this.getDB(); if (taskId) { - db.prepare('DELETE FROM working_memories WHERE session_id = ? AND task_id = ?').run(sessionId, taskId); + db.prepare('DELETE FROM working_memories WHERE session_id = ? AND task_id = ?').run( + sessionId, + taskId, + ); } else { db.prepare('DELETE FROM working_memories WHERE session_id = ?').run(sessionId); } @@ -560,7 +884,9 @@ export class MemoryManager { */ cleanupExpired(): number { const db = this.getDB(); - const result = db.prepare('DELETE FROM episodic_memories WHERE expires_at IS NOT NULL AND expires_at < ?').run(Date.now()); + const result = db + .prepare('DELETE FROM episodic_memories WHERE expires_at IS NOT NULL AND expires_at < ?') + .run(Date.now()); return result.changes; } diff --git a/electron/harness/orchestration/orchestrator.ts b/electron/harness/orchestration/orchestrator.ts index ed050dc..3f46312 100644 --- a/electron/harness/orchestration/orchestrator.ts +++ b/electron/harness/orchestration/orchestrator.ts @@ -172,14 +172,14 @@ export class TaskOrchestrator extends EventEmitter { thinkingEnabled: this.defaultConfig?.thinkingEnabled ?? true, thinkingEffort: this.defaultConfig?.thinkingEffort ?? 'medium', contextLength: this.defaultConfig?.contextLength, - contextWindow: this.defaultConfig?.contextWindow ?? 128_000, + contextWindow: this.defaultConfig?.contextWindow, // v0.7.3 P3-1: SubAgent 与主引擎同源消费 enableReflection(REFLECTING 状态开关) enableReflection: this.defaultConfig?.enableReflection ?? false, // v0.7.4 P3-2 修正: SubAgent 继承主引擎的 temperature/maxTokens —— - // 旧实现不读这两个键,新 SubAgent 恒用引擎 DEFAULT_CONFIG(0.0/63488), - // 导致"热生效"对子任务不完整 + // 旧实现不读这两个键导致"热生效"对子任务不完整。 + // v0.8.1 硬性契约: 不携带任何写死兜底值 —— 与主引擎同源继承设置面板配置 temperature: this.defaultConfig?.temperature ?? 0.0, - maxTokens: this.defaultConfig?.maxTokens ?? 63488, + maxTokens: this.defaultConfig?.maxTokens, }, this.engines.createAdapter(), this.toolRegistry, diff --git a/electron/harness/sandbox/permissions.ts b/electron/harness/sandbox/permissions.ts index 9ae9c4a..2a08ade 100644 --- a/electron/harness/sandbox/permissions.ts +++ b/electron/harness/sandbox/permissions.ts @@ -172,9 +172,101 @@ export const DEFAULT_POLICIES: PermissionPolicy[] = [ { toolName: 'file_info', requiredLevel: PermissionLevel.READ }, ]; +/** + * v0.8.1 P2-1: 用户自定义策略解析(设置面板 ToolsSettings 存储) + * + * 存储契约:配置键 `tools.{toolName}.policy`(JSON 字符串),字段: + * - deniedPatterns / allowedPatterns: string[](正则源;加载时编译,非法正则跳过) + * - maxFrequency: number(次/分钟) + * - requireConfirmation: boolean + * 解析失败整体返回 null(回退默认策略),单条非法正则仅跳过该条 —— 配置错误 + * 不放大执行面(fail-closed),也不让一条坏配置瘫痪整个策略引擎。 + */ +export interface ParsedToolPolicy { + deniedPatterns?: RegExp[]; + allowedPatterns?: RegExp[]; + maxFrequency?: number; + requireConfirmation?: boolean; +} + +export function parseToolPolicy(raw: unknown): ParsedToolPolicy | null { + let obj: unknown = raw; + if (typeof raw === 'string') { + try { + obj = JSON.parse(raw); + } catch { + return null; + } + } + if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return null; + const o = obj as Record; + const compileList = (value: unknown): RegExp[] | undefined => { + if (value === undefined) return undefined; + if (!Array.isArray(value)) return undefined; + const compiled: RegExp[] = []; + for (const item of value.slice(0, 50)) { + if (typeof item !== 'string' || item.length === 0 || item.length > 500) continue; + try { + compiled.push(new RegExp(item)); + } catch { + /* 非法正则跳过 */ + } + } + return compiled; + }; + const out: ParsedToolPolicy = {}; + const denied = compileList(o.deniedPatterns); + if (denied) out.deniedPatterns = denied; + const allowed = compileList(o.allowedPatterns); + if (allowed) out.allowedPatterns = allowed; + if (typeof o.maxFrequency === 'number' && Number.isFinite(o.maxFrequency) && o.maxFrequency > 0) { + out.maxFrequency = Math.floor(o.maxFrequency); + } + if (typeof o.requireConfirmation === 'boolean') { + out.requireConfirmation = o.requireConfirmation; + } + return Object.keys(out).length > 0 ? out : null; +} + export class PolicyEngine { private policies: Map = new Map(); + /** + * v0.8.1 P2-1: 用户自定义策略覆盖层(settings → 热加载)。 + * resolvePolicy 的最高优先级 —— 覆盖层与默认策略按字段合并 + * (未指定的安全字段如 deniedPatterns 保留默认值,与构造函数合并语义一致)。 + */ + private policyOverrides: Map = new Map(); + + /** 设置/清除某工具的用户策略覆盖(null = 清除,回退默认策略) */ + setPolicyOverride(toolName: string, override: ParsedToolPolicy | null): void { + if (!override) { + this.policyOverrides.delete(toolName); + return; + } + const base = this.policies.get(toolName); + this.policyOverrides.set(toolName, { + ...(base ?? { + toolName, + requiredLevel: PermissionLevel.WRITE, + }), + toolName, + ...override, + }); + } + + /** 获取某工具当前的覆盖策略(UI 回显用;无覆盖返回 null) */ + getPolicyOverride(toolName: string): ParsedToolPolicy | null { + const o = this.policyOverrides.get(toolName); + if (!o) return null; + return { + deniedPatterns: o.deniedPatterns?.map((r) => r.source), + allowedPatterns: o.allowedPatterns?.map((r) => r.source), + maxFrequency: o.maxFrequency, + requireConfirmation: o.requireConfirmation, + } as unknown as ParsedToolPolicy; + } + /** * v0.4.1: 工具调用频率追踪 — 频率 key -> 调用时间戳列表 * key 格式: `${sessionId}:${toolName}`(会话隔离) @@ -217,6 +309,9 @@ export class PolicyEngine { * 供 checkAuthorization 与 requiresConfirmation 共用匹配逻辑,消除双份漂移。 */ private resolvePolicy(toolName: string): PermissionPolicy | undefined { + // v0.8.1 P2-1: 用户覆盖层最高优先级(settings 面板 → setPolicyOverride) + const override = this.policyOverrides.get(toolName); + if (override) return override; const exact = this.policies.get(toolName); if (exact) return exact; // C-7 修复: 支持通配符策略匹配(如 mcp_* 匹配所有 MCP 工具) diff --git a/electron/harness/sandbox/sandbox.ts b/electron/harness/sandbox/sandbox.ts index b8f4cbd..548ac09 100644 --- a/electron/harness/sandbox/sandbox.ts +++ b/electron/harness/sandbox/sandbox.ts @@ -7,7 +7,7 @@ */ import { resolve, sep } from 'path'; -import { existsSync, realpathSync } from 'fs'; +import { lstatSync, realpathSync } from 'fs'; // v0.6.4 死代码清理:networkPolicy / resourceLimits 配置壳已删除。 // 原字段被赋值后无任何方法消费(SandboxManager 没有进程沙箱执行器), @@ -43,7 +43,11 @@ export class SandboxManager { const resolved = resolve(requestedPath); if (this.allowedPaths.size === 0) { - return { allowed: false, resolvedPath: resolved, reason: 'No allowed paths configured (fail-closed)' }; + return { + allowed: false, + resolvedPath: resolved, + reason: 'No allowed paths configured (fail-closed)', + }; } // 先做字符串级白名单校验 @@ -54,9 +58,40 @@ export class SandboxManager { return { allowed: false, resolvedPath: resolved, reason: 'Path not in allowed list' }; } - // 解析符号链接(如果路径存在) - if (existsSync(resolved)) { + // 解析符号链接(v0.8.1 根治:lstat 判定 —— 旧实现用 existsSync 前置判定, + // 而 existsSync 跟随链接目标:悬空 symlink(目标不存在)会跳过 realpath + // 校验整体放行,构成白名单逃逸 —— 写操作可在白名单外创建目标文件)。 + // 现契约:lstat 判定条目存在性(不跟随目标);符号链接一律 realpath 解析, + // 悬空链接(realpath ENOENT)fail-closed 拒绝;普通条目维持既有 realpath 复核。 + let st: ReturnType | null = null; + try { + st = lstatSync(resolved); + } catch { + st = null; // 条目不存在 → 允许(新建文件场景,行为不变) + } + if (st) { try { + if (st.isSymbolicLink()) { + // 悬空 symlink:realpathSync 抛 ENOENT → 显式拒绝(非偶然 catch) + let realPath: string; + try { + realPath = realpathSync(resolved); + } catch { + return { + allowed: false, + resolvedPath: resolved, + reason: 'Dangling symlink target outside workspace', + }; + } + const realAllowed = Array.from(this.allowedPaths).some( + (allowed) => realPath === allowed || realPath.startsWith(allowed + sep), + ); + if (!realAllowed) { + return { allowed: false, resolvedPath: realPath, reason: 'Symlink escape detected' }; + } + return { allowed: true, resolvedPath: realPath }; + } + // 普通条目:realpath 复核父级链接逃逸(既有行为) const realPath = realpathSync(resolved); const realAllowed = Array.from(this.allowedPaths).some( (allowed) => realPath === allowed || realPath.startsWith(allowed + sep), diff --git a/electron/harness/tools/built-in/memory.ts b/electron/harness/tools/built-in/memory.ts index 9bb4257..c442cff 100644 --- a/electron/harness/tools/built-in/memory.ts +++ b/electron/harness/tools/built-in/memory.ts @@ -16,14 +16,24 @@ import type { MemoryManager } from '../../memory/manager'; export class MemoryStoreTool implements IMetonaTool { readonly definition: MetonaToolDef = { name: 'memory_store', - description: 'Store a piece of information in persistent memory. Useful for remembering important facts, decisions, or user preferences across sessions.', + description: + 'Store a piece of information in persistent memory. Useful for remembering important facts, decisions, or user preferences across sessions.', parameters: { type: 'object', properties: { content: { type: 'string', description: 'The memory content to store' }, - type: { type: 'string', description: 'Memory type: "episodic" (events), "semantic" (knowledge), or "working" (task state)', enum: ['episodic', 'semantic', 'working'] }, + type: { + type: 'string', + description: + 'Memory type: "episodic" (events), "semantic" (knowledge), or "working" (task state)', + enum: ['episodic', 'semantic', 'working'], + }, importance: { type: 'number', description: 'Importance score 0-1 (default 0.5)' }, - source: { type: 'string', description: 'Source of the memory', enum: ['user_input', 'tool_result', 'agent_thought', 'imported'] }, + source: { + type: 'string', + description: 'Source of the memory', + enum: ['user_input', 'tool_result', 'agent_thought', 'imported'], + }, }, required: ['content', 'type'], }, @@ -39,7 +49,9 @@ export class MemoryStoreTool implements IMetonaTool { const content = args.content as string; const type = args.type as 'episodic' | 'semantic' | 'working'; const importance = (args.importance as number) ?? 0.5; - const source = (args.source as 'user_input' | 'tool_result' | 'agent_thought' | 'imported') ?? 'agent_thought'; + const source = + (args.source as 'user_input' | 'tool_result' | 'agent_thought' | 'imported') ?? + 'agent_thought'; // v0.3.0 修复: store() 是同步方法,移除多余的 await 避免误导维护者 const id = this.memoryManager.store({ @@ -59,14 +71,23 @@ export class MemoryStoreTool implements IMetonaTool { export class MemorySearchTool implements IMetonaTool { readonly definition: MetonaToolDef = { name: 'memory_search', - description: 'Search persistent memory for relevant information. Returns memories sorted by relevance.', + description: + 'Search persistent memory for relevant information. Returns memories sorted by relevance.', parameters: { type: 'object', properties: { query: { type: 'string', description: 'Search query or keywords' }, - type: { type: 'string', description: 'Filter by memory type', enum: ['episodic', 'semantic', 'working'] }, + type: { + type: 'string', + description: 'Filter by memory type', + enum: ['episodic', 'semantic', 'working'], + }, topK: { type: 'number', description: 'Number of results (default 5)' }, - threshold: { type: 'number', description: 'Minimum importance score 0-1 (default 0.7). Filters memories by importance, not search relevance.' }, + threshold: { + type: 'number', + description: + 'Minimum importance score 0-1 (default 0.7). Filters memories by importance, not search relevance.', + }, }, required: ['query'], }, @@ -84,8 +105,8 @@ export class MemorySearchTool implements IMetonaTool { const topK = (args.topK as number) ?? 5; const threshold = (args.threshold as number) ?? 0.7; - // v0.3.0 修复: search() 是同步方法,移除多余的 await 避免误导维护者 - const results = this.memoryManager.search(query, { + // v0.8.1: search() 升级为 async(向量混合检索),查询向量异步生成 + const results = await this.memoryManager.search(query, { topK, type, minImportance: threshold, diff --git a/electron/ipc/__tests__/agent.test.ts b/electron/ipc/__tests__/agent.test.ts index b6a73a3..93ffa22 100644 --- a/electron/ipc/__tests__/agent.test.ts +++ b/electron/ipc/__tests__/agent.test.ts @@ -127,7 +127,7 @@ function makeCtx(overrides: Record = {}) { logSessionEnd: vi.fn(), log: vi.fn(), }, - memoryManager: { search: vi.fn(() => []) }, + memoryManager: { search: vi.fn(() => []), clearWorkingMemory: vi.fn() }, promptInjectionDefender: { detect: vi.fn(() => ({ isInjection: false, diff --git a/electron/ipc/__tests__/replay-buffer.test.ts b/electron/ipc/__tests__/replay-buffer.test.ts new file mode 100644 index 0000000..732dab9 --- /dev/null +++ b/electron/ipc/__tests__/replay-buffer.test.ts @@ -0,0 +1,80 @@ +/** + * Replay Buffer 单元测试(v0.8.1 P0-3 模块化收口) + * + * 锁定有界缓冲契约:条数/字节双上限溢出丢最旧、truncated 标记、runId 记录、 + * TERMINATED 保留 + 新内容兜底重置、终态清除(会话删除联动)。 + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { + appendReplay, + resetReplayBuffer, + markReplayTerminated, + clearReplayBuffer, + getReplayBufferData, +} from '../replay-buffer'; + +describe('replay-buffer — 有界回放缓冲(v0.8.1 P0-3)', () => { + beforeEach(() => { + clearReplayBuffer('s1'); + }); + + it('append + get:按序返回事件与 runId', () => { + appendReplay('s1', { channel: 'stateChange', payload: { runId: 'run_a' }, ts: 1 }); + appendReplay('s1', { channel: 'streamEvent', payload: { type: 'text_delta' }, ts: 2 }); + const data = getReplayBufferData('s1'); + expect(data.events).toHaveLength(2); + expect(data.events[0].channel).toBe('stateChange'); + expect(data.runId).toBe('run_a'); + expect(data.truncated).toBe(false); + }); + + it('条数上限:超过 2000 条丢弃最旧并置 truncated', () => { + for (let i = 0; i < 2005; i++) { + appendReplay('s1', { channel: 'streamEvent', payload: { i }, ts: i }); + } + const data = getReplayBufferData('s1'); + expect(data.events.length).toBeLessThanOrEqual(2000); + expect(data.truncated).toBe(true); + // 最旧的已被丢弃 + expect((data.events[0].payload as { i: number }).i).toBeGreaterThan(0); + }); + + it('字节上限:单条 1MB 的超大 payload 被丢弃并置 truncated', () => { + const big = 'x'.repeat(1024 * 1024); + for (let i = 0; i < 6; i++) { + appendReplay('s1', { channel: 'streamEvent', payload: { big }, ts: i }); + } + const data = getReplayBufferData('s1'); + expect(data.truncated).toBe(true); + // 缓冲字节被控制在 4MB 上限附近(至少留最后一条) + expect(data.events.length).toBeGreaterThanOrEqual(1); + expect(data.events.length).toBeLessThan(6); + }); + + it('TERMINATED 保留缓冲;新内容到达兜底重置', () => { + appendReplay('s1', { channel: 'stateChange', payload: { state: 'TERMINATED' }, ts: 1 }); + markReplayTerminated('s1'); + // 完成后仍可回放最终内容 + expect(getReplayBufferData('s1').events).toHaveLength(1); + // 下一次 run 的新内容 → 缓冲重置 + appendReplay('s1', { channel: 'streamEvent', payload: { type: 'text_delta' }, ts: 2 }); + const data = getReplayBufferData('s1'); + expect(data.events).toHaveLength(1); + expect(data.truncated).toBe(false); + }); + + it('INIT 重置:清除上一 run 的缓冲', () => { + appendReplay('s1', { channel: 'streamEvent', payload: { a: 1 }, ts: 1 }); + resetReplayBuffer('s1'); + expect(getReplayBufferData('s1').events).toHaveLength(0); + }); + + it('clearReplayBuffer:终态清除(会话删除联动)', () => { + appendReplay('s1', { channel: 'streamEvent', payload: { a: 1 }, ts: 1 }); + clearReplayBuffer('s1'); + const data = getReplayBufferData('s1'); + expect(data.events).toHaveLength(0); + expect(data.runId).toBeNull(); + }); +}); diff --git a/electron/ipc/__tests__/sessions-tools-config.test.ts b/electron/ipc/__tests__/sessions-tools-config.test.ts index 52b4298..0863696 100644 --- a/electron/ipc/__tests__/sessions-tools-config.test.ts +++ b/electron/ipc/__tests__/sessions-tools-config.test.ts @@ -115,7 +115,8 @@ describe('sessions 域 — 参数校验矩阵', () => { expect(await getHandler('sessions:getMessages')(null, 42)).toEqual([]); await getHandler('sessions:getMessages')(null, 's1'); - expect(svc.getMessages).toHaveBeenCalledWith('s1'); + // v0.8.1 P2-3: 未传选项时以 undefined 透传(全量语义向后兼容) + expect(svc.getMessages).toHaveBeenCalledWith('s1', undefined); }); it('saveTrace 严格校验:traceSteps 必须为数组且 tokenUsage 必填', async () => { @@ -149,6 +150,8 @@ describe('sessions:clearMessages — v0.7.2 A1 语义修正', () => { function makeCtx(): IPCContext { return { sessionService: { clearMessages: vi.fn() }, + // v0.8.1 P0-2: clearMessages 联动清理工作记忆 + memoryManager: { clearWorkingMemory: vi.fn() }, } as unknown as IPCContext; } @@ -442,6 +445,8 @@ describe('sessions 域 — 补充校验矩阵', () => { disposeEngine: vi.fn(), }, confirmationHook: { forgetSession: vi.fn() }, + // v0.8.1 P0-2: delete/purge/clearMessages 联动清理工作记忆 + memoryManager: { clearWorkingMemory: vi.fn() }, }; return { ctx: raw as unknown as IPCContext, raw }; } diff --git a/electron/ipc/agent.ts b/electron/ipc/agent.ts index dffb7ef..17efb99 100644 --- a/electron/ipc/agent.ts +++ b/electron/ipc/agent.ts @@ -29,6 +29,8 @@ import { buildUserContextPrefix, withUserContextPrefix } from '../harness/prompt // v0.7.3 P1-5: 记忆固化触发决策(纯函数) import { shouldConsolidate } from '../harness/memory/consolidation-policy'; import log from 'electron-log'; +// v0.8.1 P0-4: 主进程 toast 文案双语(ui.locale 驱动) +import { mt } from '../utils/main-locale'; /** 构建 "时区名 (UTC±N)" 标签(注入用户上下文前置块;失败回退 UTC) */ function buildTimezoneLabel(): string { @@ -67,34 +69,14 @@ interface IterationTrace { finishReason?: string; } -/** - * v0.8.0 P1-1a: 单会话的流式回放缓冲 —— 渲染层切走会话期间,事件管道仍照常 - * 投递(按会话隔离被渲染层准入拒绝),此缓冲按序保存 streamEvent + stateChange - * 双通道事件;用户切回会话时经 agent:getReplayState 拉取并灌入渲染层事件总线, - * 实现"后台会话运行内容不丢失"。 - * - * 有界设计:单会话上限 MAX_REPLAY_EVENTS 条 / MAX_REPLAY_BYTES 字节,溢出丢弃 - * 最旧并置 truncated 标记;TERMINATED 后保留(供"完成后切回"看到最终内容), - * 下一次 run 启动(INIT stateChange)时清空重建。 - */ -interface ReplayEntry { - channel: 'streamEvent' | 'stateChange'; - payload: unknown; - ts: number; -} - -interface ReplayBuffer { - events: ReplayEntry[]; - bytes: number; - truncated: boolean; - /** 最近一次观察到的 runId(供渲染层回放后对齐 run 守卫) */ - runId?: string; - terminated: boolean; -} - -const MAX_REPLAY_EVENTS = 2000; -const MAX_REPLAY_BYTES = 4 * 1024 * 1024; -const MAX_REPLAY_SESSIONS = 50; +// v0.8.1 P0-3: 回放缓冲抽为独立模块(replay-buffer.ts)—— 会话删除/彻底删除 +// 的终态路径可显式清除缓冲,杜绝已删会话最多 4MB/会话的内存滞留 +import { + appendReplay, + resetReplayBuffer, + markReplayTerminated, + getReplayBufferData, +} from './replay-buffer'; export function registerAgentHandlers(ctx: IPCContext): void { const { @@ -118,54 +100,9 @@ export function registerAgentHandlers(ctx: IPCContext): void { // ===== 常驻事件管道:text_delta 按会话节流(F8) ===== const throttleStates = new Map(); const iterationTraces = new Map(); - // v0.8.0 P1-1a: 每会话流式回放缓冲(后台会话内容恢复) - const replayBuffers = new Map(); - /** 追加一条事件到会话回放缓冲(有界:条数/字节双上限,溢出丢最旧) */ - const appendReplay = (sessionId: string, entry: ReplayEntry): void => { - let buf = replayBuffers.get(sessionId); - if (!buf) { - // 会话数上限保护:超出后淘汰最早的缓冲(Map 迭代序 = 插入序) - if (replayBuffers.size >= MAX_REPLAY_SESSIONS) { - const oldest = replayBuffers.keys().next().value as string | undefined; - if (oldest !== undefined) replayBuffers.delete(oldest); - } - buf = { events: [], bytes: 0, truncated: false, terminated: false }; - replayBuffers.set(sessionId, buf); - } - // run 启动(INIT)后旧 run 缓冲作废 —— 由 stateChange 监听器在 INIT 时清空, - // 此处仅在 terminated 缓冲上遇到新内容时兜底重置(正常路径 INIT 先行) - if (buf.terminated) { - replayBuffers.delete(sessionId); - buf = { events: [], bytes: 0, truncated: false, terminated: false }; - replayBuffers.set(sessionId, buf); - } - let size = 0; - try { - size = JSON.stringify(entry.payload).length; - } catch { - size = 256; // 序列化失败按保守值计入 - } - buf.events.push(entry); - buf.bytes += size; - while ( - buf.events.length > MAX_REPLAY_EVENTS || - (buf.bytes > MAX_REPLAY_BYTES && buf.events.length > 1) - ) { - const dropped = buf.events.shift(); - buf.truncated = true; - if (dropped) { - try { - buf.bytes -= JSON.stringify(dropped.payload).length; - } catch { - buf.bytes -= 256; - } - } - } - // 记录 runId(首个携带者) - const payload = entry.payload as { runId?: string } | null; - if (!buf.runId && payload?.runId) buf.runId = payload.runId; - }; + // v0.8.1 review: MEMORY.md 体积告警去重(每会话一次) + const memoryOversizeWarned = new Set(); // v0.7.3 P1-5: 会话级记忆固化时间戳(consolidation-policy 频率门控的状态持有方) // v0.7.4 P3-5: LRU 化 —— 旧实现每会话一条永不清除,长期运行后无界增长。 @@ -340,12 +277,11 @@ export function registerAgentHandlers(ctx: IPCContext): void { if (sessionId) { const stateRaw = data.state ?? data.current ?? ''; if (stateRaw === 'INIT') { - replayBuffers.delete(sessionId); + resetReplayBuffer(sessionId); } else { appendReplay(sessionId, { channel: 'stateChange', payload: data, ts: Date.now() }); if (stateRaw === 'TERMINATED') { - const buf = replayBuffers.get(sessionId); - if (buf) buf.terminated = true; + markReplayTerminated(sessionId); } } } @@ -434,7 +370,11 @@ export function registerAgentHandlers(ctx: IPCContext): void { // toast 通知用户压缩已发生 broadcast('toast:show', { type: 'info', - message: `上下文压缩: ${data.originalTokens ?? '?'} → ${data.compressedTokens ?? '?'} tokens(节省 ${savedTokens})`, + message: mt('agent.toast.compressed', { + original: data.originalTokens ?? '?', + compressed: data.compressedTokens ?? '?', + saved: savedTokens, + }), }); // 通过 streamEvent 转发,前端 useAgentStream 监听 'compressed' 类型后更新 store broadcast('agent:streamEvent', { @@ -454,7 +394,7 @@ export function registerAgentHandlers(ctx: IPCContext): void { log.warn(`[AGENT] Dead loop detected at iteration ${data.iteration ?? '?'}`); broadcast('toast:show', { type: 'warning', - message: `检测到死循环(第 ${data.iteration ?? '?'} 轮):连续3轮重复相同工具调用,已自动终止`, + message: mt('agent.toast.deadLoop', { iteration: data.iteration ?? '?' }), }); }); @@ -470,7 +410,7 @@ export function registerAgentHandlers(ctx: IPCContext): void { }); broadcast('toast:show', { type: 'warning', - message: `Provider 故障转移: ${data.from ?? '?'} → ${data.to ?? '?'}(主 Provider 请求失败)`, + message: mt('agent.toast.providerSwitched', { from: data.from ?? '?', to: data.to ?? '?' }), }); }, ); @@ -498,7 +438,7 @@ export function registerAgentHandlers(ctx: IPCContext): void { if (!sessionId || typeof sessionId !== 'string') { log.warn('[AGENT] sendMessage rejected: invalid sessionId'); - sendErrorEvent('无效的会话 ID', sessionId ?? ''); + sendErrorEvent(mt('agent.error.invalidSessionId'), sessionId ?? ''); return { success: false, error: 'Invalid sessionId' }; } if ( @@ -507,7 +447,7 @@ export function registerAgentHandlers(ctx: IPCContext): void { typeof userMessage.content !== 'string' ) { log.warn('[AGENT] sendMessage rejected: invalid userMessage'); - sendErrorEvent('无效的消息格式', sessionId); + sendErrorEvent(mt('agent.error.invalidMessage'), sessionId); return { success: false, error: 'Invalid message format' }; } log.info('[AGENT] sendMessage:', sessionId, (userMessage.content ?? '').slice(0, 80)); @@ -517,7 +457,7 @@ export function registerAgentHandlers(ctx: IPCContext): void { // 排队 30s 后强制 abort 旧 run,用户看到"上一次操作未完成")。此处直接拒绝并 // 广播明确错误事件,前端 isStreaming 正常收尾。 if (agentEngineManager.isRunning(sessionId)) { - const busyMsg = '该会话正在执行任务,请等待完成或先中断后再发送'; + const busyMsg = mt('agent.error.sessionBusy'); log.warn(`[AGENT] sendMessage rejected: session ${sessionId} is already running`); sendErrorEvent(busyMsg, sessionId); return { success: false, error: busyMsg }; @@ -529,7 +469,7 @@ export function registerAgentHandlers(ctx: IPCContext): void { // 此处提前校验并走统一的 sendErrorEvent + stopRecording 收尾路径。 const sessionExists = sessionService.getSession(sessionId) != null; if (!sessionExists) { - const missingMsg = '会话不存在或已被删除,请刷新后重试'; + const missingMsg = mt('agent.error.sessionMissing'); log.warn(`[AGENT] sendMessage rejected: session ${sessionId} not found`); sendErrorEvent(missingMsg, sessionId); await sessionRecorder.stopRecording(sessionId, { @@ -543,8 +483,7 @@ export function registerAgentHandlers(ctx: IPCContext): void { // 发送消息前确保 Adapter 使用最新配置(失败则中止,防止用旧 Provider 的 adapter 发送) if (!ctx.reloadAdapter()) { - const errorMsg = - 'Adapter 加载失败,请检查 LLM 配置(Provider、API Key、Base URL、Model 是否完整)'; + const errorMsg = mt('agent.error.adapterLoadFailed'); log.error('[AGENT]', errorMsg); sendErrorEvent(errorMsg, sessionId); await sessionRecorder.stopRecording(sessionId, { @@ -593,8 +532,7 @@ export function registerAgentHandlers(ctx: IPCContext): void { if (contextBuilder.isUsingFallbackRole()) { broadcast('toast:show', { type: 'info', - message: - '未找到 SOUL.md 或内容为空,已使用默认 Metona 身份。可在工作空间根目录创建 SOUL.md 自定义 Agent 人格', + message: mt('agent.soul.fallbackToast'), }); } @@ -603,7 +541,8 @@ export function registerAgentHandlers(ctx: IPCContext): void { // 每条消息都改变 system 字节 → 跨 run 缓存全 miss。现随首条 user 消息注入 // (LLM 语义等价),system prompt 保持跨 run 字节级稳定。 try { - const memories = memoryManager.search(userMessage.content, { + // v0.8.1: search 升级 async(向量混合检索) + const memories = await memoryManager.search(userMessage.content, { topK: 5, minImportance: 0.3, }); @@ -850,7 +789,7 @@ export function registerAgentHandlers(ctx: IPCContext): void { ); broadcast('toast:show', { type: 'info', - message: `AI 已将 ${result.appended} 条重要记忆写入 MEMORY.md`, + message: mt('agent.toast.consolidated', { count: result.appended }), }); } }) @@ -861,6 +800,18 @@ export function registerAgentHandlers(ctx: IPCContext): void { log.debug(`[AGENT] Memory consolidation skipped (${decision.reason})`); } + // v0.8.1 review 修复: MEMORY.md 体积告警移出固化分支 —— 每次成功 run 后 + // 检查(此前仅固化触发时检查,跳过固化的大文件永不告警);每会话只告警 + // 一次,避免连续消息刷屏。 + const memorySize = (workspaceService.getFiles().memory ?? '').length; + if (memorySize > 51_200 && !memoryOversizeWarned.has(sessionId)) { + memoryOversizeWarned.add(sessionId); + broadcast('toast:show', { + type: 'warning', + message: mt('agent.toast.memoryOversize'), + }); + } + // v0.7.3 P4-1: 首个完成的 run 之后生成精炼会话标题(每会话幂等,失败静默) if (output.terminationReason === 'completed') { titleGenerator @@ -955,7 +906,7 @@ export function registerAgentHandlers(ctx: IPCContext): void { } const balance = await adapter.getBalance(); if (!balance) { - return { success: false, error: '余额查询失败(API Key 无效或网络错误)' }; + return { success: false, error: mt('llm.balance.queryFailed') }; } return { success: true, data: balance }; } catch (error) { @@ -975,18 +926,18 @@ export function registerAgentHandlers(ctx: IPCContext): void { const model = configService.get('llm.model') ?? ''; const apiKey = configService.get('llm.apiKey') || ''; if (!provider || !model) { - return { success: false, error: 'LLM 未配置(Provider/Model 为空),无法获取模型列表' }; + return { success: false, error: mt('llm.listModels.notConfigured') }; } if (!apiKey && provider !== 'ollama') { - return { success: false, error: 'API Key 未配置,无法获取模型列表' }; + return { success: false, error: mt('llm.listModels.noApiKey') }; } // 列表基于当前已保存配置 —— 先幂等重载 adapter(配置签名未变时为 no-op) if (!ctx.reloadAdapter()) { - return { success: false, error: 'LLM 配置校验失败,请先在设置中修正配置' }; + return { success: false, error: mt('llm.listModels.configInvalid') }; } const adapter = agentEngineManager.getAdapter(); if (!adapter.listModels) { - return { success: false, error: '当前 Provider 不支持模型列表查询' }; + return { success: false, error: mt('llm.listModels.unsupported') }; } const models = await adapter.listModels(); return { success: true, data: models }; @@ -1014,10 +965,10 @@ export function registerAgentHandlers(ctx: IPCContext): void { } const adapter = agentEngineManager.getAdapter(); if (!(adapter instanceof OllamaAdapter)) { - return { success: false, error: '仅 Ollama Provider 支持模型下载' }; + return { success: false, error: mt('llm.pull.ollamaOnly') }; } if (ollamaPullController) { - return { success: false, error: '已有模型下载任务进行中,请先取消' }; + return { success: false, error: mt('llm.pull.inProgress') }; } const controller = new AbortController(); ollamaPullController = controller; @@ -1045,7 +996,7 @@ export function registerAgentHandlers(ctx: IPCContext): void { ipcMain.handle('llm:ollamaPullCancel', async () => { if (!ollamaPullController) { - return { success: false, error: '没有进行中的下载任务' }; + return { success: false, error: mt('llm.pull.none') }; } ollamaPullController.abort(); return { success: true }; @@ -1071,6 +1022,8 @@ export function registerAgentHandlers(ctx: IPCContext): void { // 旧实现只覆盖 taskCompleted/taskError 路径,被 abort 的 SubAgent 残留 Map 条目 for (const taskId of abortedTaskIds) { confirmationHook.forgetSession(taskId); + // v0.8.1 P0-2: 被中止 SubAgent 的工作记忆一并清理 + memoryManager.clearWorkingMemory(taskId); subTraces.delete(taskId); subMeta.delete(taskId); // 中止的 SubAgent 录制文件也收尾(TRACE 完整性:标记为中断终止) @@ -1104,14 +1057,14 @@ export function registerAgentHandlers(ctx: IPCContext): void { if (typeof sessionId !== 'string' || !sessionId) { return { success: false, error: 'Invalid sessionId' }; } - const buf = replayBuffers.get(sessionId); + const data = getReplayBufferData(sessionId); return { success: true, data: { isRunning: agentEngineManager.isRunning(sessionId), - runId: buf?.runId ?? null, - truncated: buf?.truncated ?? false, - events: buf?.events ?? [], + runId: data.runId, + truncated: data.truncated, + events: data.events, }, }; }); @@ -1151,6 +1104,8 @@ export function registerAgentHandlers(ctx: IPCContext): void { subMeta.delete(taskId); // v0.7.3 P2-3: SubAgent 终态 —— 决策记忆随任务终结清理(防长期运行泄漏) confirmationHook.forgetSession(taskId); + // v0.8.1 P0-2: SubAgent 终态联动清理工作记忆(taskId 作为 sessionId 写入) + memoryManager.clearWorkingMemory(taskId); }; orchestrator.on( diff --git a/electron/ipc/app.ts b/electron/ipc/app.ts index 109c9e6..d2b0f0f 100644 --- a/electron/ipc/app.ts +++ b/electron/ipc/app.ts @@ -122,6 +122,32 @@ export function registerAppHandlers(ctx: IPCContext): void { return { canceled: false, path: result.filePaths[0] }; }); + // ===== v0.8.1 P2-4: 开机自启(app.setLoginItemSettings,跨平台尽力语义)===== + ipcMain.handle('app:setLoginItem', async (_event, enabled: unknown) => { + if (typeof enabled !== 'boolean') { + return { success: false, error: 'Invalid enabled flag' }; + } + try { + app.setLoginItemSettings({ openAtLogin: enabled }); + // 设置后回读真实状态(Linux 等 setLoginItemSettings 不可用平台保持 false) + const actual = app.getLoginItemSettings().openAtLogin; + if (enabled && !actual) { + log.warn('[APP] setLoginItemSettings not effective on this platform (Linux?)'); + } + return { success: true, data: { openAtLogin: actual } }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } + }); + + ipcMain.handle('app:getLoginItem', async () => { + try { + return { success: true, data: { openAtLogin: app.getLoginItemSettings().openAtLogin } }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } + }); + // 重启应用(工作空间切换后调用) ipcMain.handle('app:restart', async () => { log.info('[APP] Restart requested'); diff --git a/electron/ipc/context.ts b/electron/ipc/context.ts index 128be1c..42be22a 100644 --- a/electron/ipc/context.ts +++ b/electron/ipc/context.ts @@ -13,6 +13,7 @@ import type { WorkspaceService } from '../services/workspace.service'; import type { ContextBuilder } from '../harness/prompts/context-builder'; import type { AgentEngineManager } from '../services/agent-engine-manager.service'; import type { ToolRegistry } from '../harness/tools/registry'; +import type { PolicyEngine } from '../harness/sandbox/permissions'; import type { AuditService } from '../services/audit.service'; import type { SessionRecorder } from '../services/session-recorder.service'; import type { MemoryManager } from '../harness/memory/manager'; @@ -21,6 +22,7 @@ import type { PromptInjectionDefender } from '../harness/security/prompt-injecti import type { OutputValidator } from '../harness/verification/output-validator'; import type { ConfirmationHook } from '../harness/hooks/confirmation-hook'; import type { MemoryConsolidator } from '../harness/memory/consolidator'; +import type { MemoryMaintainer } from '../harness/memory/maintainer'; import type { TaskOrchestrator } from '../harness/orchestration/orchestrator'; import type { SessionSummaryService } from '../services/session-summary.service'; import type { TitleGenerator } from '../services/title-generator.service'; @@ -49,6 +51,8 @@ export interface IPCContext { contextBuilder: ContextBuilder; agentEngineManager: AgentEngineManager; toolRegistry: ToolRegistry; + /** v0.8.1 P2-1: 策略引擎(tools.{name}.policy 用户自定义策略热加载) */ + policyEngine: PolicyEngine; auditService: AuditService; sessionRecorder: SessionRecorder; memoryManager: MemoryManager; @@ -58,6 +62,8 @@ export interface IPCContext { outputValidator: OutputValidator; confirmationHook: ConfirmationHook; memoryConsolidator: MemoryConsolidator; + /** v0.8.1 P1-2: MEMORY.md 维护闭环(memory:analyzeMaintenance / memory:applyMaintenance) */ + memoryMaintainer: MemoryMaintainer; orchestrator: TaskOrchestrator; sessionSummaryService: SessionSummaryService; /** v0.7.3 P4-1: 会话标题生成器 */ diff --git a/electron/ipc/data.ts b/electron/ipc/data.ts index 51b822d..0b11890 100644 --- a/electron/ipc/data.ts +++ b/electron/ipc/data.ts @@ -107,6 +107,9 @@ export function registerDataHandlers(ctx: IPCContext): void { const sessCount = db.prepare('SELECT COUNT(*) as c FROM sessions').get() as { c: number }; db.exec('DELETE FROM messages'); db.exec('DELETE FROM sessions'); + // v0.8.1 P0-2: working_memories 无 sessions 外键(历史 schema),此处显式清理 + // 防止全量清空后工作记忆成为无主孤数据 + db.exec('DELETE FROM working_memories'); db.exec('COMMIT'); log.info( `[DATA] All sessions cleared: ${sessCount.c} sessions, ${msgCount.c} messages deleted`, diff --git a/electron/ipc/mcp.ts b/electron/ipc/mcp.ts index efda5d4..8445004 100644 --- a/electron/ipc/mcp.ts +++ b/electron/ipc/mcp.ts @@ -152,4 +152,60 @@ export function registerMCPHandlers(ctx: IPCContext): void { } return { success: true, data: mcpManager.getServerContents(name) }; }); + + // ===== v0.8.1 P1-4: Prompt 渲染(prompts/get,ChatInput 斜杠菜单消费) ===== + ipcMain.handle( + 'mcp:getPrompt', + async (_event, serverName: unknown, promptName: unknown, args: unknown) => { + if (typeof serverName !== 'string' || !serverName.trim()) { + return { success: false, error: 'Invalid server name' }; + } + if (typeof promptName !== 'string' || !promptName.trim()) { + return { success: false, error: 'Invalid prompt name' }; + } + // args 可选,必须是 string→string 扁平对象(MCP prompts/get arguments 契约) + let promptArgs: Record | undefined; + if (args !== undefined && args !== null) { + if (!args || typeof args !== 'object' || Array.isArray(args)) { + return { success: false, error: 'Invalid arguments' }; + } + promptArgs = {}; + for (const [k, v] of Object.entries(args as Record)) { + if (typeof v !== 'string') { + return { success: false, error: `Argument "${k.slice(0, 40)}" must be a string` }; + } + promptArgs[k] = v; + } + } + try { + const result = await mcpManager.getPrompt(serverName, promptName, promptArgs); + return { success: true, data: result }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + }, + ); + + // ===== v0.8.1 P1-4: Resource 读取(resources/read,@mcp 提及消费;512KB 上限) ===== + const MAX_RESOURCE_BYTES = 512 * 1024; + ipcMain.handle('mcp:readResource', async (_event, serverName: unknown, uri: unknown) => { + if (typeof serverName !== 'string' || !serverName.trim()) { + return { success: false, error: 'Invalid server name' }; + } + if (typeof uri !== 'string' || !uri.trim()) { + return { success: false, error: 'Invalid resource uri' }; + } + try { + const result = await mcpManager.readResource(serverName, uri); + if (result && result.text.length > MAX_RESOURCE_BYTES) { + return { + success: true, + data: { text: result.text.slice(0, MAX_RESOURCE_BYTES), truncated: true }, + }; + } + return { success: true, data: { text: result?.text ?? '', truncated: false } }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + }); } diff --git a/electron/ipc/memory.ts b/electron/ipc/memory.ts index 63aaadb..79cb8e2 100644 --- a/electron/ipc/memory.ts +++ b/electron/ipc/memory.ts @@ -5,11 +5,72 @@ import { ipcMain } from 'electron'; import type { IPCContext } from './context'; import type { MemoryType } from '../harness/memory/manager'; +import type { MemoryMaintenanceAction } from '../harness/memory/maintainer'; +import log from 'electron-log'; const VALID_MEMORY_TYPES: readonly MemoryType[] = ['episodic', 'semantic', 'working']; export function registerMemoryHandlers(ctx: IPCContext): void { - const { memoryManager, sessionService } = ctx; + const { memoryManager, sessionService, memoryMaintainer, auditService } = ctx; + + // ===== v0.8.1 P1-2: MEMORY.md 维护闭环(分析/应用两阶段) ===== + + // 阶段一:LLM 分析当前 MEMORY.md → 结构化维护建议(不改任何文件/DB) + ipcMain.handle('memory:analyzeMaintenance', async () => { + try { + const proposal = await memoryMaintainer.analyze(); + return { success: true, data: proposal }; + } catch (error) { + log.warn('[IPC] memory:analyzeMaintenance failed:', (error as Error).message); + return { success: false, error: (error as Error).message }; + } + }); + + // 阶段二:应用用户确认(勾选)后的动作 —— 精确匹配校验 + 重写 MEMORY.md + + // 同步 semantic_memories + 审计留痕 + ipcMain.handle('memory:applyMaintenance', async (_event, actions: unknown) => { + if (!Array.isArray(actions)) { + return { success: false, error: 'Invalid actions: must be an array' }; + } + // 结构校验:只透传合法字段,其余拒绝 + const valid: MemoryMaintenanceAction[] = []; + for (const item of actions.slice(0, 30)) { + if (!item || typeof item !== 'object') continue; + const a = item as Record; + if (a.action !== 'delete' && a.action !== 'update') continue; + if (typeof a.section !== 'string' || typeof a.entry !== 'string') continue; + if (a.action === 'update' && typeof a.newEntry !== 'string') continue; + valid.push({ + action: a.action, + section: a.section, + entry: a.entry, + newEntry: typeof a.newEntry === 'string' ? a.newEntry : undefined, + reason: typeof a.reason === 'string' ? a.reason : undefined, + }); + } + try { + const result = memoryMaintainer.apply(valid); + auditService.log({ + sessionId: '', + eventType: 'tool_call', + actor: 'user', + target: 'memory_maintenance', + details: { requested: valid.length, applied: result.applied, skipped: result.skipped }, + outcome: 'success', + }); + return { success: true, data: result }; + } catch (error) { + auditService.log({ + sessionId: '', + eventType: 'error', + actor: 'user', + target: 'memory_maintenance', + details: { error: (error as Error).message }, + outcome: 'error', + }); + return { success: false, error: (error as Error).message }; + } + }); ipcMain.handle('db:searchMemories', async (_event, query: unknown, options: unknown) => { // M-37 修复: query 和 options 校验 @@ -17,7 +78,12 @@ export function registerMemoryHandlers(ctx: IPCContext): void { return []; } // 构造合法的搜索选项(仅保留已知字段,强制类型安全) - const searchOptions: { topK?: number; sessionId?: string; type?: MemoryType; minImportance?: number } = {}; + const searchOptions: { + topK?: number; + sessionId?: string; + type?: MemoryType; + minImportance?: number; + } = {}; if (options && typeof options === 'object') { const opts = options as Record; // topK 限制范围 1-100(防止过大查询) @@ -26,12 +92,15 @@ export function registerMemoryHandlers(ctx: IPCContext): void { if (Number.isFinite(topK) && topK >= 1 && topK <= 100) { searchOptions.topK = topK; } else { - searchOptions.topK = 10; // 默认值 + searchOptions.topK = 10; // 默认值 } } if (typeof opts.sessionId === 'string') searchOptions.sessionId = opts.sessionId; // type 必须是合法的 MemoryType 枚举值 - if (typeof opts.type === 'string' && (VALID_MEMORY_TYPES as readonly string[]).includes(opts.type)) { + if ( + typeof opts.type === 'string' && + (VALID_MEMORY_TYPES as readonly string[]).includes(opts.type) + ) { searchOptions.type = opts.type as MemoryType; } if (typeof opts.minImportance === 'number' && Number.isFinite(opts.minImportance)) { @@ -52,7 +121,10 @@ export function registerMemoryHandlers(ctx: IPCContext): void { const opts = options as Record; if (opts.type !== undefined) { if (typeof opts.type !== 'string' || !VALID_MEMORY_TYPES_LIST.includes(opts.type)) { - return { success: false, error: `Invalid type (must be one of: ${VALID_MEMORY_TYPES_LIST.join(', ')})` }; + return { + success: false, + error: `Invalid type (must be one of: ${VALID_MEMORY_TYPES_LIST.join(', ')})`, + }; } type = opts.type; } @@ -69,16 +141,34 @@ export function registerMemoryHandlers(ctx: IPCContext): void { const results: Record = {}; try { if (!type || type === 'episodic') { - const rows = db.prepare('SELECT * FROM episodic_memories ORDER BY created_at DESC LIMIT ?').all(limit) as Array>; + const rows = db + .prepare('SELECT * FROM episodic_memories ORDER BY created_at DESC LIMIT ?') + .all(limit) as Array>; results.episodic = rows.map((r) => ({ ...r, type: 'episodic', content: r.content ?? '' })); } if (!type || type === 'semantic') { - const rows = db.prepare('SELECT * FROM semantic_memories ORDER BY updated_at DESC LIMIT ?').all(limit) as Array>; - results.semantic = rows.map((r) => ({ ...r, type: 'semantic', content: r.value ?? r.key ?? '', importance: r.confidence ?? 0, created_at: r.created_at ?? r.updated_at })); + const rows = db + .prepare('SELECT * FROM semantic_memories ORDER BY updated_at DESC LIMIT ?') + .all(limit) as Array>; + results.semantic = rows.map((r) => ({ + ...r, + type: 'semantic', + content: r.value ?? r.key ?? '', + importance: r.confidence ?? 0, + created_at: r.created_at ?? r.updated_at, + })); } if (!type || type === 'working') { - const rows = db.prepare('SELECT * FROM working_memories ORDER BY updated_at DESC LIMIT ?').all(limit) as Array>; - results.working = rows.map((r) => ({ ...r, type: 'working', content: r.value ?? r.key ?? '', importance: 0.5, created_at: r.updated_at ?? Date.now() })); + const rows = db + .prepare('SELECT * FROM working_memories ORDER BY updated_at DESC LIMIT ?') + .all(limit) as Array>; + results.working = rows.map((r) => ({ + ...r, + type: 'working', + content: r.value ?? r.key ?? '', + importance: 0.5, + created_at: r.updated_at ?? Date.now(), + })); } return { success: true, data: results }; } catch (error) { @@ -89,14 +179,22 @@ export function registerMemoryHandlers(ctx: IPCContext): void { ipcMain.handle('memory:delete', async (_event, type: unknown, id: unknown) => { // M-50 修复: 校验 type 枚举(防止三元表达式默认映射到 working_memories)和 id 类型 if (typeof type !== 'string' || !VALID_MEMORY_TYPES_LIST.includes(type)) { - return { success: false, error: `Invalid type (must be one of: ${VALID_MEMORY_TYPES_LIST.join(', ')})` }; + return { + success: false, + error: `Invalid type (must be one of: ${VALID_MEMORY_TYPES_LIST.join(', ')})`, + }; } if (typeof id !== 'string' || !id) { return { success: false, error: 'Invalid memory id' }; } const db = sessionService.getDB(); try { - const table = type === 'episodic' ? 'episodic_memories' : type === 'semantic' ? 'semantic_memories' : 'working_memories'; + const table = + type === 'episodic' + ? 'episodic_memories' + : type === 'semantic' + ? 'semantic_memories' + : 'working_memories'; db.prepare(`DELETE FROM ${table} WHERE id = ?`).run(id); return { success: true }; } catch (error) { diff --git a/electron/ipc/replay-buffer.ts b/electron/ipc/replay-buffer.ts new file mode 100644 index 0000000..bcc0716 --- /dev/null +++ b/electron/ipc/replay-buffer.ts @@ -0,0 +1,112 @@ +/** + * Replay Buffer — 每会话流式回放缓冲(v0.8.0 P1-1a 引入;v0.8.1 P0-3 模块化收口) + * + * 渲染层切走会话期间,事件管道仍照常投递(按会话隔离被渲染层准入拒绝), + * 此缓冲按序保存 streamEvent + stateChange 双通道事件;用户切回会话时经 + * agent:getReplayState 拉取并灌入渲染层事件总线,实现"后台会话运行内容不丢失"。 + * + * v0.8.1 P0-3 根治:缓冲原为 ipc/agent.ts 内部 Map —— 会话删除/彻底删除时无 + * 联动清理,仅靠 50 会话 LRU 兜底,已删会话的缓冲(最多 4MB/会话)滞留内存。 + * 现抽为独立模块,sessions:delete / sessions:purge 终态路径显式清除。 + * + * 有界设计:单会话上限 MAX_REPLAY_EVENTS 条 / MAX_REPLAY_BYTES 字节,溢出丢弃 + * 最旧并置 truncated 标记;TERMINATED 后保留(供"完成后切回"看到最终内容), + * 下一次 run 启动(INIT stateChange)时清空重建。 + */ + +const MAX_REPLAY_EVENTS = 2000; +const MAX_REPLAY_BYTES = 4 * 1024 * 1024; +const MAX_REPLAY_SESSIONS = 50; + +export interface ReplayEntry { + channel: 'streamEvent' | 'stateChange'; + payload: unknown; + ts: number; +} + +export interface ReplayBuffer { + events: ReplayEntry[]; + bytes: number; + truncated: boolean; + /** 最近一次观察到的 runId(供渲染层回放后对齐 run 守卫) */ + runId?: string; + terminated: boolean; +} + +const replayBuffers = new Map(); + +/** 追加一条事件到会话回放缓冲(有界:条数/字节双上限,溢出丢最旧) */ +export function appendReplay(sessionId: string, entry: ReplayEntry): void { + let buf = replayBuffers.get(sessionId); + if (!buf) { + // 会话数上限保护:超出后淘汰最早的缓冲(Map 迭代序 = 插入序) + if (replayBuffers.size >= MAX_REPLAY_SESSIONS) { + const oldest = replayBuffers.keys().next().value as string | undefined; + if (oldest !== undefined) replayBuffers.delete(oldest); + } + buf = { events: [], bytes: 0, truncated: false, terminated: false }; + replayBuffers.set(sessionId, buf); + } + // run 启动(INIT)后旧 run 缓冲作废 —— 由 stateChange 监听器在 INIT 时清空, + // 此处仅在 terminated 缓冲上遇到新内容时兜底重置(正常路径 INIT 先行) + if (buf.terminated) { + replayBuffers.delete(sessionId); + buf = { events: [], bytes: 0, truncated: false, terminated: false }; + replayBuffers.set(sessionId, buf); + } + let size = 0; + try { + size = JSON.stringify(entry.payload).length; + } catch { + size = 256; // 序列化失败按保守值计入 + } + buf.events.push(entry); + buf.bytes += size; + while ( + buf.events.length > MAX_REPLAY_EVENTS || + (buf.bytes > MAX_REPLAY_BYTES && buf.events.length > 1) + ) { + const dropped = buf.events.shift(); + buf.truncated = true; + if (dropped) { + try { + buf.bytes -= JSON.stringify(dropped.payload).length; + } catch { + buf.bytes -= 256; + } + } + } + // 记录 runId(首个携带者) + const payload = entry.payload as { runId?: string } | null; + if (!buf.runId && payload?.runId) buf.runId = payload.runId; +} + +/** run 启动(INIT)时清空上一 run 的缓冲 */ +export function resetReplayBuffer(sessionId: string): void { + replayBuffers.delete(sessionId); +} + +/** TERMINATED 时置终态标记(保留缓冲供"完成后切回"回放最终内容) */ +export function markReplayTerminated(sessionId: string): void { + const buf = replayBuffers.get(sessionId); + if (buf) buf.terminated = true; +} + +/** v0.8.1 P0-3: 会话终态(删除/彻底删除)时清除缓冲,杜绝内存滞留 */ +export function clearReplayBuffer(sessionId: string): void { + replayBuffers.delete(sessionId); +} + +/** 拉取会话回放状态(agent:getReplayState 消费) */ +export function getReplayBufferData(sessionId: string): { + runId: string | null; + truncated: boolean; + events: ReplayEntry[]; +} { + const buf = replayBuffers.get(sessionId); + return { + runId: buf?.runId ?? null, + truncated: buf?.truncated ?? false, + events: buf?.events ?? [], + }; +} diff --git a/electron/ipc/sessions.ts b/electron/ipc/sessions.ts index 5508cee..15ed2d1 100644 --- a/electron/ipc/sessions.ts +++ b/electron/ipc/sessions.ts @@ -9,6 +9,8 @@ import { ipcMain } from 'electron'; import { join, resolve as resolvePath, sep } from 'path'; import { existsSync, readdirSync, readFileSync, statSync } from 'fs'; import type { IPCContext } from './context'; +// v0.8.1 P0-3: 会话终态清除流式回放缓冲 +import { clearReplayBuffer } from './replay-buffer'; /** M-34 修复: 统一 sessionId 校验辅助函数 */ const isValidSessionId = (id: unknown): id is string => @@ -54,6 +56,11 @@ export function registerSessionHandlers(ctx: IPCContext): void { // (防 rememberedDecisions 随会话数累积泄漏) if (result.success) { confirmationHook.forgetSession(sessionId); + // v0.8.1 P0-2: 会话终态联动清理工作记忆(此前 clearWorkingMemory 零调用方, + // working_memories 随会话数永久残留) + ctx.memoryManager.clearWorkingMemory(sessionId); + // v0.8.1 P0-3: 清除该会话的流式回放缓冲(最多 4MB/会话) + clearReplayBuffer(sessionId); // v0.7.4 P3-5: 显式销毁引擎与 adapter 实例(内存收口) // —— 旧实现只靠 LRU 上限 30 淘汰,会话删除后引擎仍驻留 agentEngineManager.disposeEngine(sessionId); @@ -78,14 +85,35 @@ export function registerSessionHandlers(ctx: IPCContext): void { if (result.success) { confirmationHook.forgetSession(sessionId); agentEngineManager.disposeEngine(sessionId); + // v0.8.1 P0-2: 终态联动清理工作记忆 + ctx.memoryManager.clearWorkingMemory(sessionId); + // v0.8.1 P0-3: 清除该会话的流式回放缓冲 + clearReplayBuffer(sessionId); } return result; }); - ipcMain.handle('sessions:getMessages', async (_event, sessionId: unknown) => { + ipcMain.handle('sessions:getMessages', async (_event, sessionId: unknown, options?: unknown) => { // M-34 修复: 校验 sessionId if (!isValidSessionId(sessionId)) return []; - return sessionService.getMessages(sessionId); + // v0.8.1 P2-3: 游标分页选项(limit 1-1000 / beforeRowid 正整数),非法值忽略 + let safeOptions: { limit?: number; beforeRowid?: number } | undefined; + if (options && typeof options === 'object') { + const o = options as Record; + safeOptions = {}; + if (typeof o.limit === 'number' && Number.isFinite(o.limit) && o.limit >= 1) { + safeOptions.limit = Math.min(1000, Math.floor(o.limit)); + } + if ( + typeof o.beforeRowid === 'number' && + Number.isFinite(o.beforeRowid) && + o.beforeRowid > 0 + ) { + safeOptions.beforeRowid = Math.floor(o.beforeRowid); + } + if (Object.keys(safeOptions).length === 0) safeOptions = undefined; + } + return sessionService.getMessages(sessionId, safeOptions); }); // v0.7.4 P3-10: 更新单条用户消息内容(编辑"仅保存"落库)。 @@ -129,6 +157,8 @@ export function registerSessionHandlers(ctx: IPCContext): void { if (!isValidSessionId(sessionId)) return { success: false, error: 'Invalid sessionId' }; try { sessionService.clearMessages(sessionId); + // v0.8.1 P0-2: 清空会话 = 会话内容重置 —— 工作记忆一并清理 + ctx.memoryManager.clearWorkingMemory(sessionId); return { success: true }; } catch (error) { return { success: false, error: (error as Error).message }; diff --git a/electron/ipc/shared.ts b/electron/ipc/shared.ts index a2c5936..3cad1f4 100644 --- a/electron/ipc/shared.ts +++ b/electron/ipc/shared.ts @@ -10,11 +10,15 @@ import log from 'electron-log'; import type { IPCContext } from './context'; import { broadcast } from './context'; import { isSensitiveConfigKey } from '../utils/secure-config'; +// v0.8.1 P2-1: 工具自定义策略解析 +import { parseToolPolicy } from '../harness/sandbox/permissions'; // v0.8.0 P1-5: 配置 URL 深校验(域名真实 DNS 解析,拦"解析到云元数据 IP"绕过) import { assertSafeConfigTargetDeep, DeepCheckSoftFailure, } from '../harness/tools/built-in/ssrf-guard'; +// v0.8.1 P0-4: 主进程文案双语 +import { setMainLocale, mt } from '../utils/main-locale'; /** * v0.7.4 P2-9-C: URL 类配置键 —— 写入时须过 assertSafeConfigTarget 高危目标校验。 @@ -65,16 +69,12 @@ export const LLM_CONFIG_KEYS = [ 'llm.model', 'llm.apiKey', 'llm.baseURL', + // v0.8.1: 全局单一「上下文长度」—— 重建 adapter(携带新窗口)+ 引擎配置同步 + 'llm.contextWindow', 'llm.fallbackProvider', 'llm.fallbackModel', 'llm.fallbackApiKey', 'llm.fallbackBaseURL', - 'ollama.numCtx', - 'deepseek.contextWindow', - 'agnes.contextWindow', - 'mimo.contextWindow', - 'openai.contextWindow', - 'anthropic.contextWindow', ]; /** 敏感配置值脱敏(审计日志用:长值保留后 4 位,短值完全掩码) */ @@ -129,7 +129,7 @@ export function clearApiKeyOnProviderChange( * 应用单条配置的引擎/编排器副作用(Engine/Orchestrator/ConfirmationHook 同步) */ export function applyEngineConfigKey(ctx: IPCContext, key: string, value: unknown): void { - const { agentEngineManager, orchestrator, confirmationHook } = ctx; + const { agentEngineManager, orchestrator, confirmationHook, configService } = ctx; switch (key) { case 'agent.maxIterations': agentEngineManager.updateConfigAll({ maxIterations: value as number }); @@ -162,8 +162,15 @@ export function applyEngineConfigKey(ctx: IPCContext, key: string, value: unknow orchestrator.updateDefaultConfig({ temperature: Number(value) }); break; case 'llm.maxTokens': - agentEngineManager.updateConfigAll({ maxTokens: Number(value) }); - orchestrator.updateDefaultConfig({ maxTokens: Number(value) }); + // v0.8.1 review 修复: null/空串 = 用户清空「最大输出上限」→ 未配置语义 + //(undefined 下发,由 Provider 服务端默认值决定);此前 Number(null)=0 + // 会把引擎预算清零。 + agentEngineManager.updateConfigAll({ + maxTokens: value == null || value === '' ? undefined : Number(value) || undefined, + }); + orchestrator.updateDefaultConfig({ + maxTokens: value == null || value === '' ? undefined : Number(value) || undefined, + }); break; case 'agent.toolExecutionTimeoutMs': agentEngineManager.updateConfigAll({ toolExecutionTimeoutMs: value as number }); @@ -172,17 +179,34 @@ export function applyEngineConfigKey(ctx: IPCContext, key: string, value: unknow confirmationHook.setConfirmationTimeout(value as number); break; case 'ollama.numCtx': + // v0.8.1: ollama.numCtx 已废除 —— 与「上下文长度」合并为 llm.contextWindow。 + // 保留此分支仅为兼容存量库中的遗留键写入(迁移 12 已清理),行为同 llm.contextWindow。 agentEngineManager.updateConfigAll({ contextLength: (value as number) || undefined }); orchestrator.updateDefaultConfig({ contextLength: (value as number) || undefined }); break; + // v0.8.1: 全局单一「上下文长度」—— 替代旧的分 Provider contextWindow 键与 + // ollama.numCtx。Ollama Provider 下同时作为 num_ctx(contextLength)下发。 + case 'llm.contextWindow': { + const ctx = (value as number) || undefined; + const isOllama = (configService.get('llm.provider') ?? '') === 'ollama'; + agentEngineManager.updateConfigAll({ + contextWindow: ctx, + contextLength: isOllama ? ctx : undefined, + }); + orchestrator.updateDefaultConfig({ + contextWindow: ctx, + contextLength: isOllama ? ctx : undefined, + }); + break; + } case 'deepseek.contextWindow': case 'agnes.contextWindow': case 'mimo.contextWindow': case 'openai.contextWindow': case 'anthropic.contextWindow': - // reloadAdapter 已重建 adapter 并同步 contextWindow,此处确保 Engine 配置同步(兜底) - agentEngineManager.updateConfigAll({ contextWindow: (value as number) || undefined }); - orchestrator.updateDefaultConfig({ contextWindow: (value as number) || undefined }); + // v0.8.1: 分 Provider 键已废除 —— 兼容分支收敛为空操作(迁移 12 已清理 + // 存量库;若前端旧版本仍写入,静默忽略以防双源语义复活)。 + log.warn(`[CONFIG] Deprecated provider contextWindow key ignored: ${key}`); break; default: break; @@ -208,13 +232,27 @@ export async function applyConfigSideEffects( if (entries.some((e) => LLM_CONFIG_KEYS.includes(e.key))) { if (!ctx.reloadAdapter()) { log.warn('[CONFIG] Adapter reload failed after config save'); - return 'LLM 配置不完整,请检查 Provider、API Key、Base URL 和 Model 是否都已填写'; + return mt('config.error.configIncomplete'); } } // 2. Engine/Orchestrator/ConfirmationHook 配置同步 for (const { key, value } of entries) { applyEngineConfigKey(ctx, key, value); + // v0.8.1 P2-1: 工具自定义策略热加载(tools.{name}.policy,JSON 字符串) + const m = /^tools\.(.+)\.policy$/.exec(key); + if (m) { + const toolName = m[1]; + const known = ctx.toolRegistry.listAllTools().some((t) => t.name === toolName); + if (known) { + const parsed = + typeof value === 'string' && value.trim() !== '' ? parseToolPolicy(value) : null; + ctx.policyEngine.setPolicyOverride(toolName, parsed); + if (typeof value === 'string' && value.trim() !== '' && !parsed) { + log.warn(`[Policy] Ignored invalid policy config for tool: ${toolName}`); + } + } + } } // 3. 日志级别即时应用 @@ -255,7 +293,7 @@ export async function applyConfigSideEffects( } catch (err) { log.error('[CONFIG] Failed to save workspace path:', err); // v0.3.10: 写入失败必须告知用户,否则下次启动仍使用旧路径 - return `工作空间路径保存失败:${(err as Error).message}`; + return mt('config.error.workspaceSaveFailed', { message: (err as Error).message }); } } @@ -266,6 +304,12 @@ export async function applyConfigSideEffects( await applySessionProxy(typeof proxyValue === 'string' ? proxyValue : null); } + // v0.8.1 P0-4: 界面语言变更 → 主进程 toast/通知语言热切换(无需重启) + const localeEntry = entries.find((e) => e.key === 'ui.locale'); + if (localeEntry && typeof localeEntry.value === 'string') { + setMainLocale(localeEntry.value); + } + // v0.7.3 P4-2: MCP 自动重连开关变更 → 即时联动(关闭时取消全部已排程重连) const autoReconnectEntry = entries.find((e) => e.key === 'mcp.autoReconnect'); if (autoReconnectEntry) { diff --git a/electron/main.ts b/electron/main.ts index c4ce5b7..4875824 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -36,6 +36,7 @@ import { TitleGenerator } from './services/title-generator.service'; import { ContextBuilder } from './harness/prompts/context-builder'; import { MemoryManager } from './harness/memory/manager'; import { MemoryConsolidator } from './harness/memory/consolidator'; +import { MemoryMaintainer } from './harness/memory/maintainer'; import { registerAllIPCHandlers } from './ipc'; import type { ToolsReadyRef } from './ipc'; import { ToolRegistry } from './harness/tools/registry'; @@ -85,12 +86,14 @@ import { SecurityScanHook, } from './harness/hooks'; import { ConfirmationHook } from './harness/hooks/confirmation-hook'; -import { PolicyEngine } from './harness/sandbox/permissions'; +import { PolicyEngine, parseToolPolicy } from './harness/sandbox/permissions'; import { SandboxManager } from './harness/sandbox/sandbox'; import { PromptInjectionDefender } from './harness/security/prompt-injection-defense'; import { OutputValidator } from './harness/verification/output-validator'; import { TaskOrchestrator } from './harness/orchestration/orchestrator'; import { HealthChecker, SLOMonitor } from './utils/slo'; +// v0.8.1 P0-4: 主进程 toast/通知文案双语(ui.locale 驱动) +import { setMainLocale, mt } from './utils/main-locale'; // v0.6.4 P4-5: session 级网络代理应用工具(default + agent-browser 分区) // ===== 步骤 1: 初始化日志系统(SYS 层)===== @@ -117,6 +120,16 @@ app.on('child-process-gone', (_event, details) => { ); }); +// ===== E2E 测试引导(v0.8.1 P2-5)===== +// METONA_USER_DATA_DIR:重定向 userData(workspace-config.json / 全局配置层), +// 使 Playwright 冒烟测试与真实用户数据完全隔离; +// METONA_E2E_SEED_CONFIG:跳过引导向导并写入一组确定性的合法 LLM 配置 +//(Provider/Model/输出上限/上下文长度 —— 全部为设置面板合法配置项), +// 指向 e2e/mock-llm.ts 启动的本地 OpenAI 兼容服务。仅在显式设置时生效。 +if (process.env['METONA_USER_DATA_DIR']) { + app.setPath('userData', process.env['METONA_USER_DATA_DIR']); +} + // ===== 工作空间路径独立存储(解决 DB 在 workspace 内的鸡生蛋问题)===== const WORKSPACE_CONFIG_FILE = join(app.getPath('userData'), 'workspace-config.json'); @@ -204,6 +217,21 @@ async function initialize(): Promise { // 注入全局配置层:读取时回退到全局 JSON,写入时双写 configService.setGlobalConfig(globalConfigService); + // v0.8.1 P0-4: 主进程语言随 ui.locale 注入(变更经 applyConfigSideEffects 热切换) + setMainLocale(configService.get('ui.locale')); + + // v0.8.1 P2-5: E2E 引导种子(见文件顶部说明;仅 METONA_E2E_SEED_CONFIG 时生效) + if (process.env['METONA_E2E_SEED_CONFIG'] === '1') { + configService.set('onboarding.completed', true); + configService.set('llm.provider', 'deepseek'); + configService.set('llm.model', 'deepseek-v4-flash'); + configService.set('llm.baseURL', process.env['METONA_E2E_LLM_URL'] ?? 'http://127.0.0.1:0'); + configService.set('llm.apiKey', 'e2e-test-key'); + configService.set('llm.maxTokens', 2048); + configService.set('llm.contextWindow', 32768); + log.info('[E2E] Seeded deterministic LLM config for smoke test'); + } + // v0.3.17 迁移: 首次启用全局配置层时,把工作空间 DB 中的全局配置同步到全局 JSON // 幂等设计:migrateFromWorkspaceDB 仅写入全局层不存在的 key const configRows = db.prepare('SELECT key, value FROM app_config').all() as Array<{ @@ -265,11 +293,10 @@ async function initialize(): Promise { return null; } - // 读取 Provider 对应的 contextWindow 配置(Ollama 不使用此字段) - const contextWindow = - provider !== 'ollama' - ? (configService.get(`${provider}.contextWindow`) ?? undefined) - : undefined; + // v0.8.1 硬性契约: 上下文窗口唯一合法来源是设置面板「上下文长度」 + // (llm.contextWindow,全局单一配置,适用于一切 Provider/模型)。 + // 删除了旧的 deepseek/agnes/mimo/openai/anthropic.contextWindow 分 Provider 键。 + const contextWindow = configService.get('llm.contextWindow') ?? undefined; const adapterConfig = { provider, baseURL, apiKey, defaultModel: model, contextWindow }; switch (provider) { @@ -288,13 +315,14 @@ async function initialize(): Promise { } }; - // 配置未就绪时的 fallback adapter — getContextWindow 返回安全值,send/sendStream 会报错但前端可见 + // 配置未就绪时的 fallback adapter —— send/sendStream 会报错但前端可见。 + // v0.8.1: 不再携带写死的 contextWindow(4096)—— 未配置时 getContextWindow + // 返回 0,压缩判定跳过;该 adapter 仅在 LLM 配置缺失时兜底存在。 const FALLBACK_ADAPTER = new DeepSeekAdapter({ provider: '', baseURL: '', apiKey: '', defaultModel: '', - contextWindow: 4096, }); // ===== 步骤 5: 工作空间文件 + System Prompt ===== @@ -429,15 +457,23 @@ async function initialize(): Promise { } // ===== P2-10: Agent Engine Manager(每会话独立引擎,替代全局单引擎) ===== + // v0.8.1: contextWindow / maxTokens 唯一来源是设置面板 llm.contextWindow / llm.maxTokens; + // Ollama 的 num_ctx(contextLength)与「上下文长度」同源 —— 同一设置同时驱动 + // 压缩预算与 num_ctx 下发,不再存在独立的 ollama.numCtx 配置键。 const buildAdapter = (): IMetonaProviderAdapter => createAdapter() ?? FALLBACK_ADAPTER; - const ollamaNumCtx = configService.get('ollama.numCtx'); + const getContextWindowSetting = (): number | undefined => + configService.get('llm.contextWindow') ?? undefined; + const getOllamaContextLength = (): number | undefined => + (configService.get('llm.provider') ?? '') === 'ollama' + ? getContextWindowSetting() + : undefined; const agentEngineManager = new AgentEngineManager({ buildAdapter, baseConfig: { maxIterations: configService.get('agent.maxIterations') ?? 20, totalTimeoutMs: configService.get('agent.totalTimeoutMs') ?? 600_000, - contextLength: ollamaNumCtx ?? undefined, - contextWindow: buildAdapter().getContextWindow(), + contextLength: getOllamaContextLength(), + contextWindow: getContextWindowSetting(), thinkingEnabled: configService.get('agent.enableThinking') ?? true, thinkingEffort: (configService.get('agent.thinkingEffort') as @@ -452,7 +488,9 @@ async function initialize(): Promise { enableReflection: configService.get('agent.enableReflection') === true, // F-8 接通: llm.temperature / llm.maxTokens 此前为死配置(引擎硬编码 0.0/63488) temperature: configService.get('llm.temperature') ?? 0.0, - maxTokens: configService.get('llm.maxTokens') ?? 63488, + // v0.8.1 硬性契约: 直接读设置面板值(种子默认由 CONFIG_DEFAULTS 保证), + // 不再在代码侧携带 63488 写死兜底 + maxTokens: configService.get('llm.maxTokens') ?? undefined, }, toolRegistry, preToolHooks, @@ -483,10 +521,8 @@ async function initialize(): Promise { ); return null; } - const contextWindow = - provider !== 'ollama' - ? (configService.get(`${provider}.contextWindow`) ?? undefined) - : undefined; + // v0.8.1: 上下文窗口全局单一配置(llm.contextWindow),不再按 Provider 读取 + const contextWindow = configService.get('llm.contextWindow') ?? undefined; const cfg = { provider, baseURL, apiKey, defaultModel: model, contextWindow }; switch (provider) { case 'agnes': @@ -505,6 +541,27 @@ async function initialize(): Promise { }; agentEngineManager.setFallbackAdapter(buildFallbackAdapter()); + // ===== v0.8.1 P1-1: 本地向量记忆嵌入器装配 ===== + // 仅在 Provider=Ollama(本地推理、数据不出设备、零 token 成本)且用户在设置面板 + // 配置了 memory.embeddingModel(如 nomic-embed-text)时启用;否则 embedder 返回 + // null,MemoryManager 全量回退纯 TF-IDF 检索(历史行为兼容)。 + // adapter 经闭包动态读取 —— 故障转移/热重载后无需重新装配。 + memoryManager.setEmbedder({ + embed: async (text) => { + const adapter = agentEngineManager.getAdapter(); + if (!(adapter instanceof OllamaAdapter)) return null; + const model = configService.get('memory.embeddingModel') ?? ''; + if (!model.trim()) return null; + try { + const r = await adapter.embed({ model: model.trim(), input: text }); + return r.embeddings?.[0] ?? null; + } catch (err) { + log.warn('[Memory] Embedding failed (falling back to TF-IDF):', (err as Error).message); + return null; + } + }, + }); + // ===== P1-11: MCP 初始化等待所有连接完成后再广播 tools:ready(修复工具未注册即广播的窗口) ===== // v0.3.18: toolsReadyRef 供 tools:isReady 查询(解决事件竞态) // v0.5.3: MCP 工具集合运行中变更(添加/断开/启停 server)→ 同步所有已存在引擎。 @@ -544,6 +601,13 @@ async function initialize(): Promise { // v0.3.18: 注入 MemoryManager,实现 DB 记忆与 MEMORY.md 双轨交叉 memoryConsolidator.setMemoryManager(memoryManager); + // ===== v0.8.1 P1-2: MEMORY.md 维护闭环(分析/应用两阶段,应用前需用户确认) ===== + const memoryMaintainer = new MemoryMaintainer( + () => agentEngineManager.getAdapter(), + workspaceService, + () => db, + ); + // ===== P2-11: 会话摘要分层上下文服务 ===== const sessionSummaryService = new SessionSummaryService( () => db, @@ -566,8 +630,8 @@ async function initialize(): Promise { | 'high' | 'max' | null) ?? 'high', - contextLength: ollamaNumCtx ?? undefined, - contextWindow: buildAdapter().getContextWindow(), + contextLength: getOllamaContextLength(), + contextWindow: getContextWindowSetting(), // v0.7.3 P3-1: SubAgent 与主引擎同源消费 enableReflection enableReflection: configService.get('agent.enableReflection') === true, }, @@ -577,6 +641,17 @@ async function initialize(): Promise { agentEngineManager.setToolsAll(toolRegistry.listTools()); // 在所有工具(包括 DelegateTaskTool)注册完成后,刷新 ConfirmationHook 的工具定义缓存 confirmationHook.setToolDefs(toolRegistry.listAllTools()); + + // ===== v0.8.1 P2-1: 加载用户自定义工具策略(tools.{name}.policy)===== + // 设置面板保存经 shared.ts 热加载;此处覆盖应用启动的冷加载。 + for (const toolDef of toolRegistry.listAllTools()) { + const raw = configService.get(`tools.${toolDef.name}.policy`); + if (raw != null && raw !== '') { + const parsed = parseToolPolicy(raw); + policyEngine.setPolicyOverride(toolDef.name, parsed); + if (!parsed) log.warn(`[Policy] Ignored invalid policy config for tool: ${toolDef.name}`); + } + } log.info(`Registered ${toolRegistry.size} built-in tools`); // ===== 热重载 Adapter 回调(设置变更时触发) ===== @@ -593,10 +668,8 @@ 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`), + // v0.8.1: 全局单一「上下文长度」配置进入签名(替代旧的分 Provider 键) + contextWindow: configService.get('llm.contextWindow') ?? null, }); }; let lastConfigSig = buildConfigSig(); @@ -619,7 +692,7 @@ async function initialize(): Promise { for (const win of wins) { win.webContents.send('toast:show', { type: 'warning', - message: 'LLM 配置不完整,请在设置中补全 Provider、API Key、Base URL 和 Model', + message: mt('config.toast.configIncomplete'), }); } return false; @@ -630,20 +703,17 @@ async function initialize(): Promise { agentEngineManager.setFallbackAdapter(buildFallbackAdapter()); memoryConsolidator.setAdapter(agentEngineManager.getAdapter()); - // Provider 切换时同步 contextLength 和 contextWindow + // v0.8.1: 同步「上下文长度」到所有引擎(Ollama 同时作为 num_ctx/contextLength) const provider = configService.get('llm.provider') ?? ''; - if (provider === 'ollama') { - const numCtx = configService.get('ollama.numCtx'); - agentEngineManager.updateConfigAll({ - contextLength: numCtx ?? undefined, - contextWindow: agentEngineManager.getAdapter().getContextWindow(), - }); - } else { - agentEngineManager.updateConfigAll({ - contextLength: undefined, - contextWindow: agentEngineManager.getAdapter().getContextWindow(), - }); - } + const ctxSetting = getContextWindowSetting(); + agentEngineManager.updateConfigAll({ + contextLength: provider === 'ollama' ? ctxSetting : undefined, + contextWindow: ctxSetting, + }); + orchestrator.updateDefaultConfig({ + contextLength: provider === 'ollama' ? ctxSetting : undefined, + contextWindow: ctxSetting, + }); log.info(`[CONFIG] Adapter reloaded: provider=${provider}`); // 仅在 Provider 真正变化时通知渲染进程 if (lastProvider && lastProvider !== provider) { @@ -656,7 +726,7 @@ async function initialize(): Promise { }); win.webContents.send('toast:show', { type: 'success', - message: `Provider 已切换: ${lastProvider} → ${provider}`, + message: mt('config.toast.providerSwitched', { from: lastProvider, to: provider }), }); } } @@ -668,7 +738,7 @@ async function initialize(): Promise { for (const win of BrowserWindow.getAllWindows()) { win.webContents.send('toast:show', { type: 'error', - message: `Provider 切换失败: ${(err as Error).message}`, + message: mt('config.toast.providerSwitchFailed', { message: (err as Error).message }), }); } return false; @@ -749,6 +819,7 @@ async function initialize(): Promise { contextBuilder, agentEngineManager, toolRegistry, + policyEngine, auditService, sessionRecorder, memoryManager, @@ -758,6 +829,7 @@ async function initialize(): Promise { outputValidator, confirmationHook, memoryConsolidator, + memoryMaintainer, orchestrator, sessionSummaryService, titleGenerator, @@ -789,8 +861,8 @@ async function initialize(): Promise { } if (event.status === 'available') { trayManager?.sendNotification( - 'MetonaAI 更新可用', - `新版本 ${event.latestVersion} 已发布,可在 设置 → 日志与数据 中下载安装`, + mt('notify.updateAvailable.title'), + mt('notify.updateAvailable.body', { version: event.latestVersion }), ); } }); @@ -830,8 +902,8 @@ async function initialize(): Promise { return; } trayManager?.sendNotification( - 'MetonaAI — 任务完成', - `Agent 已完成任务 (${(data.durationMs / 1000).toFixed(1)}s)`, + mt('notify.taskCompleted.title'), + mt('notify.taskCompleted.body', { seconds: (data.durationMs / 1000).toFixed(1) }), () => windowManager?.focusWindow(), ); }, @@ -998,13 +1070,25 @@ if (!gotSingleInstanceLock) { // 两条防线均为 deny-by-default 白名单制,任何一条失败都不放大攻击面。 { // 防线一:权限请求白名单(deny-by-default) + // v0.8.1 根治: session.defaultSession 必须在 app ready 后才能访问 —— 此前在 + // 模块加载期(ready 前)直接调用,每次启动都抛 "Session can only be received + // when app is ready",权限白名单静默失效(FATAL 日志为证)。现延迟到 ready 后 + // 注册,防线真正生效。 const ALLOWED_PERMISSIONS = new Set(['clipboard-sanitized-write', 'fullscreen']); - session.defaultSession.setPermissionRequestHandler((_wc, permission, callback) => { - callback(ALLOWED_PERMISSIONS.has(permission)); - }); - session.defaultSession.setPermissionCheckHandler((_wc, permission) => - ALLOWED_PERMISSIONS.has(permission), - ); + void app + .whenReady() + .then(() => { + session.defaultSession.setPermissionRequestHandler((_wc, permission, callback) => { + callback(ALLOWED_PERMISSIONS.has(permission)); + }); + session.defaultSession.setPermissionCheckHandler((_wc, permission) => + ALLOWED_PERMISSIONS.has(permission), + ); + log.info('[Security] Permission whitelist registered (app ready)'); + }) + .catch((err) => { + log.error('[Security] Failed to register permission whitelist:', err); + }); // 防线二:生产环境 CSP 注入(仅 mainFrame,不触碰 dev server 的 HMR)。 // MUI/emotion 需要 style-src 'unsafe-inline'(运行时注入