diff --git a/.env.memory.example b/.env.memory.example new file mode 100644 index 000000000..6c6c1ba49 --- /dev/null +++ b/.env.memory.example @@ -0,0 +1,14 @@ +# Proma Proactive Memory LLM 配置 +# 请填入你自己的 LLM key(仅存本机,.env 已被 .gitignore 排除,不会提交) +# 支持 OpenAI 兼容端点:OpenAI / DeepSeek / 腾讯云 LKE / 其他 + +MEMORY_LLM_API_KEY=在此填入你的key +MEMORY_LLM_BASE_URL=https://api.deepseek.com/v1 +MEMORY_LLM_MODEL=deepseek-chat + +# 可选:语义召回(hybrid 检索,解决"我是谁"类语义问句) +# local = 本地 node-llama-cpp + embeddinggemma-300m(离线,模型需先下载到 ~/.node-llama-cpp/models/) +# api = OpenAI 兼容 embedding API(同时设置 MEMORY_EMBEDDING_MODEL) +# 默认不设置 = 仅关键词召回(零额外依赖,fail-open) +# PROMA_MEMORY_EMBEDDING=local +# MEMORY_EMBEDDING_MODEL=text-embedding-3-small diff --git a/CLAUDE.md b/CLAUDE.md index f75af4af5..2535ff3ab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -188,6 +188,24 @@ bun run generate:icons # 生成应用图标 |------|------| | `feishu-bridge.ts` | 飞书集成(68KB):消息同步、任务通知、OAuth 认证 | +#### Proactive Agent 服务层(主动记忆 + 主动建议) + +| 服务 | 职责 | +|------|------| +| `memory/store.ts` | L1 atoms 持久化(JSONL 按天)+ L3 persona + corrections + 场景(`scenes/`)+ 统计 | +| `memory/recall.ts` | 召回引擎:keyword BM25 + 时间衰减(correction/sop 稳定、event 14 天、其余 30 天)+ 误报控制(单字弱命中降权 + 停用词) | +| `memory/extractor.ts` | LLM 提取(OpenAI 兼容端点,reasoning 模型兼容);类型 fact/preference/correction/sop/todo_context/event | +| `memory/persona.ts` | L3 用户画像:LLM 生成/增量更新 + 规则版兜底 + `(src: atom_xxx)` 溯源 | +| `memory/scene.ts` | L2 场景聚合:主题聚类 + 热度(atom 数 × 时间衰减 × 高频忽略抑制) | +| `memory/service.ts` | 编排:capture/recall/persona/corrections/场景对外 API | +| `suggest/engine.ts` | 建议决策:5 类规则 + 置信度阈值 + 预算(单次≤1、同会话≤2) | +| `suggest/feedback.ts` | 频率学习 + 免打扰时段(DND,跨午夜) + 高频忽略抑制列表 | +| `suggest/analyst.ts` | 工作模式分析器:LLM 低频分析记忆 + 场景,schema 严格校验(只产出候选) | +| `suggest/service.ts` | 编排:会话钩子 + IPC + 候选池分组(pred@k) | +| `memory/memory-agent-tools.ts` | Claude runtime 内置 MCP 工具(memory_search/capture/...) | + +> 入口索引:设计见 `docs/proactive-memory-design.md` / `docs/proactive-suggestion-design.md`;评测脚本 `scripts/bench-recall.ts`(口径见 `docs/proactive-memory-bench.md`);关键踩坑见工作区 auto memory(归一化放大、停用词取舍)。 + #### 工具与文件 | 服务 | 职责 | diff --git a/PR_DESCRIPTION.md b/PR_DESCRIPTION.md new file mode 100644 index 000000000..ba8459eb0 --- /dev/null +++ b/PR_DESCRIPTION.md @@ -0,0 +1,97 @@ +## Proactive Agent: 主动记忆 + 主动建议 + 主动中心 + +为 Proma 增加完整的 **Proactive Agent** 能力集——让 Agent 从"被动等用户发起"进化到"记得住、会建议、越用越好用"。 + +完整公式:**主动记忆(记得住)+ 主动建议(对的时候提对的建议)+ 反馈闭环(越用越好用)** + +### 解决什么问题 + +Proma 现有的 Auto Memory 依赖 Agent 在 prompt 引导下自觉维护,且 Agent 只能"被动回答",缺少三个关键能力: + +1. **主动记忆**:会话结束自动提取结构化长期记忆,跨会话自动召回 +2. **主动建议**:Agent 使用过程中识别值得建议的时机,主动提出轻量、可解释、可反馈的建议 +3. **工作模式发现**:低频 LLM 分析,发现用户从未明说但反复出现的隐含工作模式 + +参考 ProactiveAgent(ICLR 2025)的核心发现:所有模型 Recall 98%+ 但误报率 51-65%,**"该沉默时沉默"也是能力**——主动性 = 用户接受率,不是建议次数。本实现全程贯彻误报控制。 + +--- + +### 能力一:主动记忆(Proactive Memory) + +| 能力 | 说明 | +|---|---| +| **L1 原子记忆** | 会话结束钩子自动 LLM 提取(fact / preference / correction / sop / todo_context),fingerprint 去重 | +| **混合召回** | keyword 精确 + LLM 查询改写 + embedding 语义 + 规则加权,多源融合 + 绝对分阈值 | +| **误报控制** | 归一化评分阈值 + 停用词过滤 + 同义词扩展 + 回忆意图降级 | +| **L3 Persona** | LLM 生成/增量更新用户画像,Markdown 白盒可审计 | +| **反馈回流** | 确认/拒绝纠正后自动更新 Persona 交互协议 | +| **内置 MCP 工具** | `memory_search/capture/stats/corrections/confirm/reject`(Claude + Pi 双 runtime) | +| **UI 看板** | 记忆统计、纠正审批、记忆搜索、persona 预览(Agent 能力中心 → 记忆 Tab) | +| **memory-daily Skill** | 指导每日记忆整理 + 建议创建 daily automation | + +### 能力二:主动建议(Proactive Suggestion) + +| 能力 | 说明 | +|---|---| +| **5 类确定性规则** | correction(记住纠正)/ followup(跟进提醒)/ automation(定时任务)/ skill(SOP 沉淀)/ todo(待办记录) | +| **信号提取** | 纠正词 / 时间词 / 周期词 / 未完成词 / 重复意图 + 延后语义/弱意图过滤 | +| **误报控制** | 明确拒绝门 + 频率门槛(raw×weight≥0.6)+ 预算(单次≤1、同会话≤2)+ duplicateKey 去重 | +| **频率学习** | accepted×1.2 / ignored×0.8 / never 屏蔽 + 连续忽略 3 次自动静默("越用越好用") | +| **会话内横幅** | `SuggestionBanner`:Agent 输入框上方三态卡片(接受/忽略/不再建议这类) | +| **实时推送** | 新建议生成后 IPC 事件广播,当前会话立即显示 | + +### 能力三:主动中心 + 工作模式分析(Phase B) + +| 能力 | 说明 | +|---|---| +| **Proactive Today** | PlanningView「主动」tab:建议卡 / 主动任务 / 待确认审批 / 用户画像 / 统计 | +| **工作模式分析器** | 低频 LLM 分析近期记忆,发现隐含模式(周期任务 / SOP / 待固化偏好) | +| **schema 严格校验** | LLM 只能产出候选(类型白名单/字段完整/动作匹配),不能直接创建任务 | +| **suggestion_analyze 工具** | Pi/Claude 双 runtime 内置工具,定时任务可调 | +| **suggestion-daily Skill** | 指导建立每日工作模式分析 automation | + +--- + +### 质量保障(子代理驱动验证) + +召回与建议系统经 **5 轮 collaboration 子代理独立审查/体验**迭代打磨: + +| 轮次 | 发现 | 结果 | +|---|---|---| +| 记忆召回 3 轮审查 | kw 硬截断丢正确答案 / 归一化放大弱命中 / 无关注入 | 多源融合 + 绝对分阈值 + 无关 gate,12 问矩阵 12/12 | +| 建议引擎审查 | 8 个边界误报 + todo 死锁 + 测试污染 | 全部修复,42+9 单测 | +| **UI 实测** | **P0**:SDKMessage 格式不匹配,引擎从不执行 | sdk-messages.ts 修复 | +| 401 实测 | dev 模式 .env 路径缺口 | findDotEnvUpwards 修复 | +| **体验评测** | **P0**:规则语义反转 / 两步确认 / 横幅不实时 | 3 项全修 | + +子代理独立实测发现了自测盲区("功能看似正常但真实链路从不执行"),这是代码审查和单测发现不了的。 + +### 验证 + +- 全量 typecheck 6 包全绿 +- 全量测试 640+ pass / 4 fail(4 fail 为既有 Electron/planning 环境问题,与本次无关;新增 120+ 测试) +- 真实 LLM 端到端:提取/召回/persona/建议/分析全部真实验证 +- 真实 UI 实测(CDP 连接真实窗口):横幅渲染、三态交互、主动中心、分析按钮全部通过 +- 真实记忆工作模式分析:92 条记忆 → 发现「ShopGo 促销前压测提醒」(automation) +- 401 修复:DeepSeek 渠道预设自动填充 .env 凭证,开箱即用 + +### 文件概览(74 个文件,+9208 行) + +- `packages/shared/src/types/memory.ts` + `suggestion.ts`:类型 +- `apps/electron/src/main/lib/memory/`:store / recall / extractor / persona / service + 测试 +- `apps/electron/src/main/lib/suggest/`:signals / rules / engine / feedback / service / analyst / sdk-messages + 测试 +- `apps/electron/src/main/lib/agent-orchestrator.ts`:会话结束记忆捕获 + 建议评估钩子 +- `apps/electron/src/main/lib/channel-manager.ts`:DeepSeek 预设渠道自动填充 .env 凭证 +- `agent-prompt-builder.ts`:`` + `` + `` 注入 +- `builtin-mcp`:memory / suggestion 内置 MCP 注册(Claude + Pi) +- `ProactiveMemoryPanel.tsx` + `ProactiveTodayView.tsx` + `SuggestionBanner.tsx`:UI +- `default-skills/memory-daily/` + `suggestion-daily/`:内置 Skill +- `docs/proactive-memory-design.md` + `proactive-suggestion-design.md`:设计文档 +- `scripts/`:smoke / verify / demo / stress 脚本 + +### 设计文档 + +- `docs/proactive-memory-design.md`:记忆系统(架构/分层/模块/接线/验证) +- `docs/proactive-suggestion-design.md`:建议系统 + 主动中心 + 分析器 + +Made with [Proma](https://proma.cool) · [GitHub](https://github.com/proma-ai/Proma) diff --git a/apps/electron/default-skills/memory-daily/SKILL.md b/apps/electron/default-skills/memory-daily/SKILL.md new file mode 100644 index 000000000..12e43e60a --- /dev/null +++ b/apps/electron/default-skills/memory-daily/SKILL.md @@ -0,0 +1,75 @@ +--- +name: memory-daily +description: Proma 主动记忆的每日整理 Skill。当用户要求"每天整理我的记忆/会话""定期沉淀长期记忆""开启每日记忆整理""memory-daily"、"把今天的对话变成记忆"、或希望 Proactive Memory 自动持续运行时触发。也适合"以后记得帮我整理"“每天晚上总结今天聊了什么”等定期沉淀意图。本 Skill 指导 Agent 用内置 memory 工具整理当天记忆,并建议用户开启 Proma 定时任务(Automation)让整理无人值守地每天运行。纯一次性整理、不需要定期执行时不建议创建定时任务,直接整理即可。 +group: proma +version: "1.0.0" +--- + +# Memory Daily + +帮助用户把当天的对话/工作沉淀为长期记忆,并可开启每日自动整理。 + +## 背景 + +Proma 内置了 Proactive Memory(主动记忆)能力: +- **自动捕获**:Agent 会话结束后自动从对话提取 L1 原子记忆(fact / preference / correction / sop / todo_context) +- **主动回忆**:新会话时自动召回相关记忆注入 ``;persona(用户画像)稳定注入系统提示 +- **记忆工具**:`mcp__memory__memory_search` / `memory_capture` / `memory_stats` / `memory_corrections` 等 + +memory-daily 是"定期深度整理":把当天多个会话产生的记忆做一次汇总、去重、生成 persona 更新,确保长期记忆质量。 + +## 工作流 + +### 1. 判断用户意图 + +- 用户说"每天/定期整理记忆" → 整理 + 建议创建每日定时任务 +- 用户只说"现在整理一下记忆" → 只整理一次,不创建定时任务 +- 用户说"以后记得帮我整理" → 整理 + 建议创建定时任务 + +### 2. 整理当天记忆 + +用内置 memory 工具完成: + +1. `mcp__memory__memory_stats` → 查看当前记忆统计 +2. `mcp__memory__memory_corrections` → 列出待确认纠正(如有 pending,提示用户确认/拒绝) +3. 检查记忆质量: + - 是否有明显重复条目(可向用户确认后由用户决定是否清理) + - 是否有过时/冲突信息(报告给用户) + - 待确认纠正是否积压(引导用户处理) +4. 输出整理报告: + ``` + 今日记忆整理报告 + - 记忆总量: N 条 (fact X / preference Y / correction Z / sop W) + - 待确认纠正: M 条 + - 用户画像: 已更新/待更新 + - 建议: ... + ``` + +### 3. 开启每日自动整理(可选) + +如果用户希望每天自动整理,使用 `automation` 工具创建定时任务: + +``` +mcp__automation__create_automation + name: 每日记忆整理 + prompt: 运行 memory-daily,整理今天的对话与记忆,报告新增记忆与待确认纠正。 + scheduleType: daily + timeOfDay: "23:30" + active: true +``` + +创建前先 `mcp__automation__list_automations` 检查是否已有同类任务(避免重复)。 + +### 4. 质量与安全 + +- 只整理记忆库中已有内容,不要凭记忆编造当天对话 +- 不要删除用户记忆(清理需用户确认) +- 待确认纠正必须由用户确认后才生效(`memory_confirm_correction`) +- 定时任务会无人值守运行,写入行为默认受限;涉及删除/大改需用户主会话确认 + +## 完成定义 + +- [ ] 已查看记忆统计与待确认纠正 +- [ ] 输出今日整理报告 +- [ ] (如用户要求)已创建/确认每日定时任务 +- [ ] 报告了任何质量问题和用户待办事项 diff --git a/apps/electron/default-skills/suggestion-daily/SKILL.md b/apps/electron/default-skills/suggestion-daily/SKILL.md new file mode 100644 index 000000000..2beec791b --- /dev/null +++ b/apps/electron/default-skills/suggestion-daily/SKILL.md @@ -0,0 +1,59 @@ +--- +name: suggestion-daily +description: Proma 主动建议的工作模式分析 Skill。当用户要求"分析我的工作模式""发现可以自动化的习惯""帮我看看有什么值得沉淀的流程""定期生成建议""suggestion-daily"、或希望 Proma 主动发现周期任务/SOP/可自动化工作时触发。本 Skill 指导 Agent 用 suggestion_analyze 工具分析近期记忆,发现规则引擎发现不了的隐含工作模式,并建议用户开启每日定时分析。纯手动分析一次、不需要定期执行时不建议创建定时任务。 +group: proma +version: "1.0.0" +--- + +# Suggestion Daily + +帮助用户发现"隐含的工作模式"——那些用户没有明确说"每天/定期",但记忆里反复出现的周期性工作、可沉淀流程、待固化偏好。 + +## 背景 + +Proma 的主动建议有两层: +1. **规则引擎**(实时):用户明确说"以后不要 X / 明天继续 / 每天自动" → 立即建议 +2. **工作模式分析器**(低频):用 LLM 分析近期记忆,发现**隐含模式**(用户从没明说,但记忆显示反复出现)→ 生成建议候选 + +本 Skill 对应第 2 层,让"主动建议"从"等你开口"进化到"替你发现"。 + +## 工作流 + +### 1. 判断用户意图 + +- 用户说"每天/定期分析我的工作模式" → 分析 + 建议创建每日定时任务 +- 用户只说"现在分析一下" → 分析一次,不创建定时任务 +- 用户说"以后记得帮我分析" → 分析 + 建议创建定时任务 + +### 2. 运行工作模式分析 + +调用内置工具: + +1. `mcp__memory__suggestion_analyze`(Pi runtime)/ `suggestion_analyze`(Claude runtime)→ 运行 LLM 分析,生成建议候选 +2. 工具返回 `added` 数量(新增建议条数) +3. 如果 `added = 0`:说明近期记忆没有足够的重复模式,告知用户"暂未发现新的可沉淀模式"(这是正常现象,不建议频繁分析) + +### 3. 引导用户处理建议 + +分析结果会出现在: +- **主动中心**(⌘⇧T → 主动 tab)的"Proma 建议"模块 +- 用户可对每条建议执行:接受(写入/生效)/ 忽略(降频)/ 不再建议这类(屏蔽) + +### 4. 建议创建每日定时任务 + +如果用户希望持续发现工作模式,建议创建 Automation: + +```text +任务名:工作模式分析 +调度:每天 23:30(或用户偏好的时间) +提示词:运行 suggestion-daily Skill:分析我的工作模式,生成主动建议候选。 +``` + +创建后 Proma 会每天自动分析记忆、发现新的可自动化/可沉淀模式,进入主动中心供用户决定。 + +## 注意事项 + +- **低频优先**:工作模式分析是"低频高价值"能力,不建议比每天更频繁(LLM 调用有成本) +- **只读记忆**:分析器只读取记忆摘要,不修改任何记忆;用户接受建议后才写入 +- **保守产出**:分析器 schema 严格校验,只保留有证据的模式;产出为空是正常的 +- 分析结果不会自动创建定时任务/Skill——所有动作都需用户在主动中心确认 diff --git a/apps/electron/src/main/ipc.ts b/apps/electron/src/main/ipc.ts index 4d5ee09f3..684e79612 100644 --- a/apps/electron/src/main/ipc.ts +++ b/apps/electron/src/main/ipc.ts @@ -171,6 +171,43 @@ import { autoArchiveConversations, searchConversationMessages, } from './lib/conversation-manager' +import { + stats as memoryStats, + search as memorySearch, + searchAsync as memorySearchAsync, + searchAsText as memorySearchAsText, + corrections as memoryCorrections, + confirmCorrection as memoryConfirmCorrection, + rejectCorrection as memoryRejectCorrection, + undoCorrection as memoryUndoCorrection, + pendingAtoms as memoryPendingAtoms, + atomsPaged as memoryAtomsPaged, + getHotScenes as memoryHotScenes, + confirmAtomById as memoryConfirmAtomById, + rejectAtomById as memoryRejectAtomById, + extractionMode as memoryExtractionMode, + setExtractionModeState as memorySetExtractionMode, + clearAllMemoryState as memoryClearAll, + personaRaw as memoryPersonaRaw, + personaSources as memoryPersonaSources, + personaTraceable as memoryPersonaTraceable, + regeneratePersona as memoryRegeneratePersona, + savePersona as memorySavePersona, + removePersona as memoryRemovePersona, + personaInjectionEnabled as memoryPersonaInjectionEnabled, + setPersonaInjectionEnabledState as memorySetPersonaInjectionEnabled, +} from './lib/memory/service' +import { + listSuggestionsForUI, + handleSuggestionFeedback, + getSuggestionStats, + runAnalysisAndPersistDetailed, + getSuggestionAnalysisState, + removeSuggestion, + clearAllSuggestions, + getDnd, + updateDnd, +} from './lib/suggest/service' import { sendMessage, stopGeneration, generateTitle } from './lib/chat-service' import { saveAttachment, @@ -914,6 +951,38 @@ async function withOAuthDeviceCodeQr { + try { + const { getMainWindow } = await import('./index') + const mainWin = getMainWindow() + if (!mainWin || mainWin.isDestroyed()) return false + return mainWin.webContents.id === event.sender.id + } catch { + return false + } +} + +/** 主窗口与独立 Planning 窗口均可调用主动中心所需的窄范围能力。 */ +async function validateProactiveWindowSender(event: Electron.IpcMainInvokeEvent): Promise { + if (await validateMainWindowSender(event)) return true + try { + const { isPlanningWindowSender } = await import('./lib/planning-window') + return isPlanningWindowSender(event.sender.id) + } catch { + return false + } +} + +/** 手动触发工作模式分析的冷却(毫秒)与单日配额 */ +const MANUAL_ANALYSIS_COOLDOWN_MS = 60_000 +const MANUAL_ANALYSIS_DAILY_LIMIT = 10 +let lastManualAnalysisAt = 0 +const manualAnalysisCountByDay: Record = {} + export function registerIpcHandlers(): void { console.log('[IPC] 正在注册 IPC 处理器...') @@ -1218,10 +1287,11 @@ export function registerIpcHandlers(): void { } ) - // 解密 API Key(仅在用户查看时调用) + // 解密 API Key(仅在用户查看时调用;加 sender 校验防任意窗口读取明文 key) ipcMain.handle( CHANNEL_IPC_CHANNELS.DECRYPT_KEY, - async (_, channelId: string): Promise => { + async (event, channelId: string): Promise => { + if (!(await validateMainWindowSender(event))) return '' return decryptApiKey(channelId) } ) @@ -2502,6 +2572,298 @@ export function registerIpcHandlers(): void { } ) + // ===== Proactive Memory ===== + + ipcMain.handle( + AGENT_IPC_CHANNELS.GET_MEMORY_STATS, + async (): Promise => { + return memoryStats() + } + ) + + ipcMain.handle( + AGENT_IPC_CHANNELS.GET_MEMORY_EXTRACTION_MODE, + async (event): Promise<'llm' | 'rule' | 'off'> => { + if (!(await validateMainWindowSender(event))) return 'off' + return memoryExtractionMode() + } + ) + + ipcMain.handle( + AGENT_IPC_CHANNELS.SET_MEMORY_EXTRACTION_MODE, + async (event, mode: 'llm' | 'rule' | 'off'): Promise<{ ok: boolean; error?: string }> => { + if (!(await validateMainWindowSender(event))) return { ok: false, error: '非授权窗口' } + if (mode !== 'llm' && mode !== 'rule' && mode !== 'off') return { ok: false, error: '无效模式' } + memorySetExtractionMode(mode) + return { ok: true } + } + ) + + ipcMain.handle( + AGENT_IPC_CHANNELS.SEARCH_MEMORY, + async (_, query: string, limit?: number): Promise => { + // 工具层用 hybrid(embedding + keyword + 规则加权),提升语义召回 + return memorySearchAsync({ query, limit }) + } + ) + + ipcMain.handle( + AGENT_IPC_CHANNELS.LIST_MEMORY_ATOMS, + async (event, opts?: { + page?: number + pageSize?: number + type?: import('@proma/shared').MemoryAtomType | 'all' + sort?: 'newest' | 'priority' + confirmed?: boolean + }): Promise<{ atoms: import('@proma/shared').MemoryAtom[]; total: number; page: number; pageSize: number; totalPages: number }> => { + if (!(await validateProactiveWindowSender(event))) { + return { atoms: [], total: 0, page: 1, pageSize: 20, totalPages: 1 } + } + return memoryAtomsPaged(opts ?? {}) + } + ) + + ipcMain.handle( + AGENT_IPC_CHANNELS.GET_MEMORY_HOT_SCENES, + async (event): Promise => { + if (!(await validateProactiveWindowSender(event))) return [] + return memoryHotScenes({ limit: 3 }) + } + ) + + ipcMain.handle( + AGENT_IPC_CHANNELS.LIST_MEMORY_CORRECTIONS, + async (_, status?: string): Promise => { + return memoryCorrections(status as 'pending' | 'active' | 'rejected' | 'superseded' | undefined) + } + ) + + ipcMain.handle( + AGENT_IPC_CHANNELS.CONFIRM_MEMORY_CORRECTION, + async (event, id: string): Promise => { + if (!(await validateProactiveWindowSender(event))) return false + return memoryConfirmCorrection(id) + } + ) + + ipcMain.handle( + AGENT_IPC_CHANNELS.REJECT_MEMORY_CORRECTION, + async (event, id: string): Promise => { + if (!(await validateProactiveWindowSender(event))) return false + return memoryRejectCorrection(id) + } + ) + + ipcMain.handle( + AGENT_IPC_CHANNELS.UNDO_MEMORY_CORRECTION, + async (event, id: string): Promise<{ ok: boolean; error?: string }> => { + if (!(await validateMainWindowSender(event))) return { ok: false, error: '非授权窗口' } + if (typeof id !== 'string' || !id) return { ok: false, error: '无效 ID' } + const ok = memoryUndoCorrection(id) + return ok ? { ok: true } : { ok: false, error: '纠正不存在或已删除' } + } + ) + + ipcMain.handle( + AGENT_IPC_CHANNELS.LIST_MEMORY_PENDING_ATOMS, + async (event): Promise => { + if (!(await validateProactiveWindowSender(event))) return [] + return memoryPendingAtoms() + } + ) + + ipcMain.handle( + AGENT_IPC_CHANNELS.CONFIRM_MEMORY_ATOM, + async (event, id: string): Promise => { + if (!(await validateProactiveWindowSender(event))) return undefined + return memoryConfirmAtomById(id) + } + ) + + ipcMain.handle( + AGENT_IPC_CHANNELS.REJECT_MEMORY_ATOM, + async (event, id: string): Promise => { + if (!(await validateProactiveWindowSender(event))) return false + return memoryRejectAtomById(id) + } + ) + + ipcMain.handle( + AGENT_IPC_CHANNELS.READ_MEMORY_PERSONA, + async (event): Promise => { + if (!(await validateMainWindowSender(event))) return undefined + return memoryPersonaRaw() + } + ) + + ipcMain.handle( + AGENT_IPC_CHANNELS.READ_MEMORY_PERSONA_SOURCES, + async (event): Promise> => { + if (!(await validateMainWindowSender(event))) return [] + return memoryPersonaSources() + } + ) + + ipcMain.handle( + AGENT_IPC_CHANNELS.GET_PERSONA_TRACEABLE, + async (event): Promise => { + if (!(await validateMainWindowSender(event))) return false + return memoryPersonaTraceable() + } + ) + + ipcMain.handle( + AGENT_IPC_CHANNELS.REGENERATE_PERSONA, + async (event): Promise<{ ok: boolean; error?: string }> => { + if (!(await validateMainWindowSender(event))) return { ok: false, error: '非授权窗口' } + const ok = await memoryRegeneratePersona() + return ok ? { ok: true } : { ok: false, error: '画像生成失败(可能是 LLM 不可用或无记忆)' } + } + ) + + ipcMain.handle( + AGENT_IPC_CHANNELS.UPDATE_MEMORY_PERSONA, + async (event, markdown: string): Promise<{ ok: boolean; error?: string }> => { + if (!(await validateMainWindowSender(event))) return { ok: false, error: '非授权窗口' } + if (typeof markdown !== 'string' || markdown.length > 20_000) return { ok: false, error: '无效内容' } + memorySavePersona(markdown) + return { ok: true } + } + ) + + ipcMain.handle( + AGENT_IPC_CHANNELS.DELETE_MEMORY_PERSONA, + async (event): Promise<{ ok: boolean; error?: string }> => { + if (!(await validateMainWindowSender(event))) return { ok: false, error: '非授权窗口' } + return { ok: memoryRemovePersona() } + } + ) + + ipcMain.handle( + AGENT_IPC_CHANNELS.GET_PERSONA_INJECTION_ENABLED, + async (event): Promise => { + if (!(await validateMainWindowSender(event))) return false + return memoryPersonaInjectionEnabled() + } + ) + + ipcMain.handle( + AGENT_IPC_CHANNELS.SET_PERSONA_INJECTION_ENABLED, + async (event, enabled: boolean): Promise<{ ok: boolean; error?: string }> => { + if (!(await validateMainWindowSender(event))) return { ok: false, error: '非授权窗口' } + memorySetPersonaInjectionEnabled(!!enabled) + return { ok: true } + } + ) + + // ===== Proactive Suggestion(主动建议) ===== + + ipcMain.handle( + AGENT_IPC_CHANNELS.LIST_SUGGESTIONS, + async (_, status?: string): Promise => { + return listSuggestionsForUI(status as 'suggested' | 'accepted' | 'ignored' | 'never' | undefined) + } + ) + + ipcMain.handle( + AGENT_IPC_CHANNELS.ACT_ON_SUGGESTION, + async (event, id: string, feedback: 'accepted' | 'ignored' | 'never'): Promise<{ ok: boolean; error?: string }> => { + if (!(await validateProactiveWindowSender(event))) { + return { ok: false, error: '非授权窗口' } + } + return handleSuggestionFeedback(id, feedback) + } + ) + + ipcMain.handle( + AGENT_IPC_CHANNELS.GET_SUGGESTION_STATS, + async (): Promise => { + return getSuggestionStats() + } + ) + + ipcMain.handle( + AGENT_IPC_CHANNELS.GET_SUGGESTION_ANALYSIS_STATE, + async () => getSuggestionAnalysisState(), + ) + + ipcMain.handle( + AGENT_IPC_CHANNELS.RUN_SUGGESTION_ANALYSIS, + async (event): Promise<{ ok: boolean; added: number; status?: 'succeeded' | 'empty' | 'unavailable' | 'failed'; error?: string }> => { + if (!(await validateProactiveWindowSender(event))) { + return { ok: false, added: 0, error: '非授权窗口' } + } + // 手动触发限频:60s 冷却 + 单日 10 次,防止任意脚本无节制消耗外部 LLM 配额 + const now = Date.now() + if (now - lastManualAnalysisAt < MANUAL_ANALYSIS_COOLDOWN_MS) { + const remaining = Math.ceil((MANUAL_ANALYSIS_COOLDOWN_MS - (now - lastManualAnalysisAt)) / 1000) + return { ok: false, added: 0, error: `分析过于频繁,请 ${remaining}s 后再试` } + } + const dayKey = new Date(now).toISOString().slice(0, 10) + if (manualAnalysisCountByDay[dayKey] !== undefined && manualAnalysisCountByDay[dayKey] >= MANUAL_ANALYSIS_DAILY_LIMIT) { + return { ok: false, added: 0, error: '今日手动分析次数已达上限' } + } + lastManualAnalysisAt = now + manualAnalysisCountByDay[dayKey] = (manualAnalysisCountByDay[dayKey] ?? 0) + 1 + try { + const result = await runAnalysisAndPersistDetailed() + return { ok: result.status !== 'failed' && result.status !== 'unavailable', added: result.added, status: result.status, error: result.message } + } catch (error) { + return { ok: false, added: 0, error: error instanceof Error ? error.message : '分析失败' } + } + } + ) + + ipcMain.handle( + AGENT_IPC_CHANNELS.GET_SUGGESTION_DND, + async (): Promise<{ enabled: boolean; startMin: number; endMin: number }> => { + return getDnd() + } + ) + + ipcMain.handle( + AGENT_IPC_CHANNELS.SET_SUGGESTION_DND, + async (event, cfg: { enabled?: boolean; startMin?: number; endMin?: number }): Promise<{ ok: boolean; error?: string }> => { + if (!(await validateProactiveWindowSender(event))) return { ok: false, error: '非授权窗口' } + const current = getDnd() + const next = { + enabled: typeof cfg?.enabled === 'boolean' ? cfg.enabled : current.enabled, + startMin: typeof cfg?.startMin === 'number' ? cfg.startMin : current.startMin, + endMin: typeof cfg?.endMin === 'number' ? cfg.endMin : current.endMin, + } + updateDnd(next) + return { ok: true } + } + ) + + ipcMain.handle( + AGENT_IPC_CHANNELS.DELETE_SUGGESTION, + async (event, id: string): Promise<{ ok: boolean; error?: string }> => { + if (!(await validateProactiveWindowSender(event))) return { ok: false, error: '非授权窗口' } + if (typeof id !== 'string' || !id) return { ok: false, error: '无效 ID' } + const ok = removeSuggestion(id) + return ok ? { ok: true } : { ok: false, error: '建议不存在' } + } + ) + + ipcMain.handle( + AGENT_IPC_CHANNELS.CLEAR_SUGGESTIONS, + async (event): Promise<{ ok: boolean }> => { + if (!(await validateMainWindowSender(event))) return { ok: false } + clearAllSuggestions() + return { ok: true } + } + ) + + ipcMain.handle( + AGENT_IPC_CHANNELS.CLEAR_ALL_MEMORY, + async (event): Promise<{ ok: boolean; error?: string }> => { + if (!(await validateMainWindowSender(event))) return { ok: false, error: '非授权窗口' } + memoryClearAll() + return { ok: true } + } + ) + // 发送 Agent 消息(触发 Agent SDK 流式响应) ipcMain.handle( AGENT_IPC_CHANNELS.SEND_MESSAGE, diff --git a/apps/electron/src/main/lib/adapters/pi-builtin-tools.ts b/apps/electron/src/main/lib/adapters/pi-builtin-tools.ts index a65e7b29d..852e9bbb5 100644 --- a/apps/electron/src/main/lib/adapters/pi-builtin-tools.ts +++ b/apps/electron/src/main/lib/adapters/pi-builtin-tools.ts @@ -59,6 +59,17 @@ import { snoozePlanningReminder, } from '../planning-manager' import { broadcastPlanningAgentOperation, broadcastPlanningChanged } from '../planning-events' +import { + stats as memoryStats, + searchAsync as memorySearchAsync, + searchAsText as memorySearchAsText, + captureCandidate as memoryCaptureCandidate, + corrections as memoryCorrections, + confirmCorrection as memoryConfirmCorrection, + rejectCorrection as memoryRejectCorrection, +} from '../memory/service' +import { runAnalysisAndPersist } from '../suggest/service' +import type { MemoryAtomType } from '@proma/shared' import { fetchWebPage, formatFetchResults, @@ -770,6 +781,151 @@ function buildVisionRelayTools(sdk: PiSdk, ctx: PiBuiltinToolsContext): ToolDefi ] as unknown as ToolDefinition[] } +// ===== Memory 工具(Proactive Memory) ===== + +const MEMORY_TYPE_VALUES = ['fact', 'preference', 'correction', 'sop', 'todo_context'] as const + +type PiMemoryType = (typeof MEMORY_TYPE_VALUES)[number] + +function isMemoryTypeValue(v: unknown): v is MemoryAtomType { + return typeof v === 'string' && (MEMORY_TYPE_VALUES as readonly string[]).includes(v) +} + +function buildMemoryTools(sdk: PiSdk, ctx: PiBuiltinToolsContext): ToolDefinition[] { + return [ + sdk.defineTool({ + name: 'mcp__memory__memory_search', + label: '检索长期记忆', + description: '检索 Proma 长期记忆。适用于回忆用户偏好、历史事实、行为纠正、可复用流程等关键信息;当上方注入的 memory_context 不足时主动调用。', + parameters: Type.Object({ + query: Type.String({ description: '检索关键词:用户的自然语言问题或关键主题' }), + limit: Type.Optional(Type.Number({ description: '返回条数上限,默认 5' })), + type: Type.Optional(Type.Union(MEMORY_TYPE_VALUES.map((v) => Type.Literal(v)), { description: '按类型过滤' })), + includeUnconfirmed: Type.Optional(Type.Boolean({ description: '是否包含未确认条目,默认 false' })), + }), + async execute(_toolCallId: string, params: unknown) { + const args = params as { query?: string; limit?: number; type?: string; includeUnconfirmed?: boolean } + const query = args.query?.trim() ?? '' + if (!query) throw new Error('query 必填') + const result = await memorySearchAsync({ + query, + limit: typeof args.limit === 'number' ? args.limit : undefined, + type: isMemoryTypeValue(args.type) ? args.type : undefined, + includeUnconfirmed: args.includeUnconfirmed === true, + }) + return jsonToolResult({ + text: memorySearchAsText({ + query, + limit: typeof args.limit === 'number' ? args.limit : undefined, + includeUnconfirmed: args.includeUnconfirmed === true, + }), + hits: result.hits.map((h) => ({ + content: h.atom.content, + type: h.atom.type, + priority: h.atom.priority, + createdAt: h.atom.createdAt, + score: h.score, + })), + strategy: result.strategy, + durationMs: result.durationMs, + }) + }, + }), + sdk.defineTool({ + name: 'mcp__memory__memory_capture', + label: '沉淀记忆', + description: '主动沉淀当前对话上下文为一条长期记忆。适用于用户明确要求记住、提到长期偏好/纠正、或你判断该信息跨会话有用时。', + parameters: Type.Object({ + content: Type.String({ description: '要记忆的内容(简洁、自包含、可独立理解的一句话)' }), + type: Type.Optional(Type.Union(MEMORY_TYPE_VALUES.map((v) => Type.Literal(v)), { description: '记忆类型,默认 fact' })), + priority: Type.Optional(Type.Number({ description: '重要度 0-100,默认 50' })), + }), + async execute(_toolCallId: string, params: unknown) { + const args = params as { content?: string; type?: string; priority?: number } + const content = args.content?.trim() ?? '' + if (!content) throw new Error('content 必填') + const result = memoryCaptureCandidate( + { + content, + type: isMemoryTypeValue(args.type) ? args.type : 'fact', + priority: typeof args.priority === 'number' ? args.priority : 50, + }, + { sessionId: ctx.sessionId, workspaceSlug: ctx.workspaceSlug }, + ) + return jsonToolResult({ + stored: result.stored, + message: result.deduplicated ? '记忆已与已有条目合并更新。' : '记忆已保存。', + atom: result.atom, + }) + }, + }), + sdk.defineTool({ + name: 'mcp__memory__memory_stats', + label: '查看记忆统计', + description: '查看 Proma 长期记忆统计:记忆数量、类型分布、场景数、待确认纠正。', + parameters: Type.Object({}), + async execute() { + return jsonToolResult(memoryStats()) + }, + }), + sdk.defineTool({ + name: 'mcp__memory__memory_corrections', + label: '查看行为纠正', + description: '查看行为纠正候选列表(用户对 Agent 的改进要求)。', + parameters: Type.Object({ + status: Type.Optional(Type.Union([ + Type.Literal('pending'), + Type.Literal('active'), + Type.Literal('rejected'), + Type.Literal('superseded'), + ], { description: '按状态过滤纠正' })), + }), + async execute(_toolCallId: string, params: unknown) { + const args = params as { status?: string } + const status = args.status as 'pending' | 'active' | 'rejected' | 'superseded' | undefined + const items = memoryCorrections(status) + return jsonToolResult({ corrections: items }) + }, + }), + sdk.defineTool({ + name: 'mcp__memory__memory_confirm_correction', + label: '确认行为纠正', + description: '确认一条行为纠正候选生效(会同步沉淀为长期记忆)。', + parameters: Type.Object({ id: Type.String() }), + async execute(_toolCallId: string, params: unknown) { + const id = (params as { id?: string }).id?.trim() ?? '' + if (!id) throw new Error('id 必填') + const ok = memoryConfirmCorrection(id) + if (!ok) throw new Error(`纠正不存在: ${id}`) + return jsonToolResult({ confirmed: true }) + }, + }), + sdk.defineTool({ + name: 'mcp__memory__memory_reject_correction', + label: '拒绝行为纠正', + description: '拒绝一条行为纠正候选(不写入记忆)。', + parameters: Type.Object({ id: Type.String() }), + async execute(_toolCallId: string, params: unknown) { + const id = (params as { id?: string }).id?.trim() ?? '' + if (!id) throw new Error('id 必填') + const ok = memoryRejectCorrection(id) + if (!ok) throw new Error(`纠正不存在: ${id}`) + return jsonToolResult({ rejected: true }) + }, + }), + sdk.defineTool({ + name: 'mcp__memory__suggestion_analyze', + label: '分析工作模式', + description: '用 LLM 分析近期记忆,发现重复出现的工作模式(周期任务/SOP/待沉淀偏好),生成主动建议候选。适用于定时任务中定期运行、或用户主动要求"分析我的工作模式"时调用。', + parameters: Type.Object({}), + async execute() { + const added = await runAnalysisAndPersist() + return jsonToolResult({ added }) + }, + }), + ] as unknown as ToolDefinition[] +} + // ===== Collaboration 工具(占位,下阶段实现) ===== // collaboration 逻辑较重(涉及子会话生命周期管理、EventBus 订阅、BlockedEvent 冒泡), @@ -817,6 +973,15 @@ export async function buildPiBuiltinTools( } } + // 长期记忆(Proactive Memory) + if (isBuiltinMcpUserEnabled('memory')) { + try { + tools.push(...buildMemoryTools(sdk, ctx)) + } catch (error) { + console.error('[Pi 桥接] 注入 memory 工具失败:', error) + } + } + // 任务/日程是 Pi native customTools,Claude runtime 不经此入口,因此天然隔离。 try { tools.push(...buildPlanningTools(sdk, ctx)) diff --git a/apps/electron/src/main/lib/agent-orchestrator.ts b/apps/electron/src/main/lib/agent-orchestrator.ts index 6ab3a1f13..1752b1bed 100644 --- a/apps/electron/src/main/lib/agent-orchestrator.ts +++ b/apps/electron/src/main/lib/agent-orchestrator.ts @@ -48,7 +48,7 @@ import { getAdapter, fetchTitle, normalizeAnthropicBaseUrlForSdk, getPromaUserAg import pkg from '../../../package.json' with { type: 'json' } import { getFetchFn } from './proxy-fetch' import { getEffectiveProxyUrl } from './proxy-settings-service' -import { appendSDKMessages, updateAgentSessionMeta, getAgentSessionMeta, getAgentSessionMessages, truncateSDKMessages, removeSDKErrorMessage, resolveUserUuidFromSDK, rewindFilesFromSnapshot, rewindPiAgentSession, ensureClaudeSessionSettings, resolveAgentCwd, getAgentCwdMode } from './agent-session-manager' +import { appendSDKMessages, updateAgentSessionMeta, getAgentSessionMeta, getAgentSessionMessages, getAgentSessionSDKMessages, truncateSDKMessages, removeSDKErrorMessage, resolveUserUuidFromSDK, rewindFilesFromSnapshot, rewindPiAgentSession, ensureClaudeSessionSettings, resolveAgentCwd, getAgentCwdMode } from './agent-session-manager' import { getAgentWorkspace, getLocalProjectRootStatus, getProjectFilesPath, getWorkspaceMcpConfig, ensurePluginManifest, getWorkspaceAutoMemoryDir, getWorkspaceAttachedDirectories, getWorkspaceAttachedFiles } from './agent-workspace-manager' import { getAgentWorkspacePath, getAgentSessionWorkspacePath, getConfigDir, getSdkConfigDir, getWorkspaceSkillsDir } from './config-paths' import { getRuntimeStatus } from './runtime-init' @@ -78,6 +78,66 @@ import { resolvePiThinkingLevel } from './agent-thinking-level' import { resolvePiReasoningCapability } from './adapters/pi-model-registry' import { generateCodexTitle } from './adapters/pi-codex-title-generator' import { createFallbackTitle, sanitizeGeneratedTitle, TITLE_PROMPT } from './title-generation' +import { extractAndCapture } from './memory/service' +import { evaluateSessionSuggestions } from './suggest/service' +import { extractRecentConversationText } from './suggest/sdk-messages' + +// ===== 记忆捕获(主动记忆钩子) ===== + +/** + * 从会话消息中提取最近 user/assistant 文本,fire-and-forget 触发记忆提取。 + * 被 completeRun / failRun 调用;提取失败不阻塞主流程。 + */ +function captureMemoryFromRun( + sessionId: string, + workspaceSlug: string | undefined, + _messages: AgentMessage[] | undefined, + stoppedByUser?: boolean, +): Promise { + if (stoppedByUser) return Promise.resolve() + // 从 SDK 格式会话中提取最近的 user/assistant 文本(修复:getAgentSessionMessages 返回 SDK 结构,role/content 平铺字段不存在) + const sdkMessages = getAgentSessionSDKMessages(sessionId) + const recent = extractRecentConversationText(sdkMessages, 20) + if (recent.length === 0) return Promise.resolve() + return extractAndCapture(recent, { sessionId, workspaceSlug }) + .then(() => undefined) + .catch((error) => { + console.warn('[Memory] 会话结束记忆捕获失败:', error instanceof Error ? error.message : error) + }) +} + +/** + * 会话结束后评估主动建议(fire-and-forget,不阻塞会话完成)。 + * 建议由引擎持久化到 suggestions.json,UI 通过 IPC 拉取展示。 + * + * 触发时机扩展(P4): + * - completeRun/failRun(原有) + * - idleComplete(turn 主体结束,新增):让建议在对话中途也能浮现 + * 节流:同一会话 5 分钟内不重复评估(避免连环打扰);引擎内部仍有 maxPerSession=2 预算兜底。 + */ +const SUGGESTION_EVAL_THROTTLE_MS = 5 * 60_000 +const lastSuggestionEvalAt = new Map() + +function evaluateSuggestionsFromRun( + sessionId: string, + _messages: AgentMessage[] | undefined, +): Promise { + // 节流:同会话 5 分钟内只评估一次 + const now = Date.now() + const last = lastSuggestionEvalAt.get(sessionId) ?? 0 + if (now - last < SUGGESTION_EVAL_THROTTLE_MS) return Promise.resolve() + lastSuggestionEvalAt.set(sessionId, now) + + // 从 SDK 格式会话中提取最近的 user/assistant 文本 + const sdkMessages = getAgentSessionSDKMessages(sessionId) + const recent = extractRecentConversationText(sdkMessages, 30) + if (recent.length === 0) return Promise.resolve() + return evaluateSessionSuggestions(recent, { sessionId }) + .then(() => undefined) + .catch((error) => { + console.warn('[Suggestion] 会话建议评估失败:', error instanceof Error ? error.message : error) + }) +} // ===== 类型定义 ===== @@ -817,6 +877,12 @@ export class AgentOrchestrator { return m.type === 'system' && (m as SDKSystemMessage).subtype === 'compact_boundary' }) + // PreCompact 记忆捕获(参考 Nowledge Mem):检测到 SDK 自动压缩边界时, + // 先沉淀当前会话记忆,防止压缩截断后关键信息丢失。 + if (hasCompactBoundary) { + void captureMemoryFromRun(sessionId, undefined, getAgentSessionMessages(sessionId), false) + } + const toPersist = accumulatedMessages.filter( (m) => m.type === 'assistant' || m.type === 'user' || m.type === 'result' || (m.type === 'system' && isPersistableSDKSystemMessage(m as SDKSystemMessage)) @@ -1176,6 +1242,8 @@ export class AgentOrchestrator { ): void => { releaseActiveRun() callbacks.onComplete(messages, opts) + void captureMemoryFromRun(sessionId, workspaceSlug, messages, opts?.stoppedByUser) + void evaluateSuggestionsFromRun(sessionId, messages) } // 轻量完成:turn 主体结束但仍有后台任务在飞行。 // 关键区别——不调用 releaseActiveRun,保留 activeSessions/activeChannels/sessionPermissionModes, @@ -1186,6 +1254,8 @@ export class AgentOrchestrator { opts?: { startedAt?: number; resultSubtype?: string; resultErrors?: string[] }, ): void => { callbacks.onComplete(messages, { ...opts, backgroundTasksPending: true }) + // 触发时机扩展:turn 主体结束后也评估建议(受节流 + 同会话预算双重约束) + void evaluateSuggestionsFromRun(sessionId, messages) } const failRun = ( error: string, @@ -1195,6 +1265,8 @@ export class AgentOrchestrator { releaseActiveRun() callbacks.onError(error) callbacks.onComplete(messages, opts) + void captureMemoryFromRun(sessionId, workspaceSlug, messages, opts?.stoppedByUser) + void evaluateSuggestionsFromRun(sessionId, messages) } // 3. 构建环境变量 @@ -1394,6 +1466,7 @@ export class AgentOrchestrator { workspaceName: workspace?.name, workspaceSlug, agentCwd, + userText: userMessage, }) // 11.5 注入 mention 引用指令(Skill/MCP/会话)— 仅影响 prompt,不影响持久化 @@ -1436,6 +1509,12 @@ export class AgentOrchestrator { ? contextualMessage : buildContextPrompt(sessionId, contextualMessage, { agentCwd, workspaceSlug }) + // PreCompact 记忆捕获(参考 Nowledge Mem):手动 /compact 前先沉淀当前会话记忆, + // 防止上下文压缩后关键信息丢失。自动压缩由 SDK compact_boundary 事件处理。 + if (isCompactCommand) { + void captureMemoryFromRun(sessionId, workspaceSlug, getAgentSessionMessages(sessionId), false) + } + if (existingSdkSessionId) { console.log(`[Agent 编排] 使用 resume 模式,SDK session ID: ${existingSdkSessionId}`) } else if (finalPrompt !== contextualMessage) { diff --git a/apps/electron/src/main/lib/agent-prompt-builder.test.ts b/apps/electron/src/main/lib/agent-prompt-builder.test.ts index 2b8955319..97ae4ba75 100644 --- a/apps/electron/src/main/lib/agent-prompt-builder.test.ts +++ b/apps/electron/src/main/lib/agent-prompt-builder.test.ts @@ -12,6 +12,15 @@ mock.module('./agent-workspace-manager', () => ({ mock.module('./config-paths', () => ({ getConfigDirName: () => '.proma', + getConfigDir: () => '/tmp/proma-test-config', + getMemoryRootDir: () => '/tmp/proma-test-config/memory', + getMemoryIndexPath: () => '/tmp/proma-test-config/memory/index.json', + getPersonaPath: () => '/tmp/proma-test-config/memory/profile.md', + getMemoryAtomsDir: () => '/tmp/proma-test-config/memory/atoms', + getMemoryAtomsDayPath: (dateKey: string) => `/tmp/proma-test-config/memory/atoms/${dateKey}.jsonl`, + getMemoryScenesDir: () => '/tmp/proma-test-config/memory/scenes', + getCorrectionsPath: () => '/tmp/proma-test-config/memory/corrections.json', + getMemoryLogDir: () => '/tmp/proma-test-config/memory/memory_log', })) mock.module('./agent-git-attribution', () => ({ diff --git a/apps/electron/src/main/lib/agent-prompt-builder.ts b/apps/electron/src/main/lib/agent-prompt-builder.ts index b80531c6b..38705b885 100644 --- a/apps/electron/src/main/lib/agent-prompt-builder.ts +++ b/apps/electron/src/main/lib/agent-prompt-builder.ts @@ -17,6 +17,7 @@ import { getAgentWorkspaceBySlug, getProjectFilesPath, getWorkspaceMcpConfig } f import { getConfigDirName } from './config-paths' import { buildGitAttributionPromptSection, isGitAttributionEnabled } from './agent-git-attribution' import { getSettings } from './settings-service' +import { contextForMessage, personaRaw as getPersonaRaw, persona, workingMemory, personaInjectionEnabled } from './memory/service' // ===== 工具使用指南(可复用常量) ===== @@ -152,6 +153,48 @@ Proma 统一使用 collaboration 派生子会话承载子 Agent 委派。不要 - 用户名: ${userName}`) + // 长期记忆(Proactive Memory):persona 稳定注入 + 工具指南(全局能力,不依赖工作区) + { + const personaRawText = getPersonaRaw() + const personaProfile = persona() + const personaInjectionOn = personaInjectionEnabled() + // 隐私控制:关闭时只保留能力说明,不注入任何画像内容;注入时剥离姓名等强识别字段 + if (personaInjectionOn) { + const personaLines: string[] = [] + if (personaProfile.summary) personaLines.push(`- 一句话定位: ${personaProfile.summary}`) + if (personaProfile.preferences.length > 0) { + personaLines.push('- 长期偏好:') + for (const p of personaProfile.preferences.slice(0, 8)) personaLines.push(` - ${p}`) + } + if (personaProfile.interactionRules.length > 0) { + personaLines.push('- 交互协议:') + for (const r of personaProfile.interactionRules.slice(0, 5)) personaLines.push(` - ${r}`) + } + if (personaLines.length > 0) { + sections.push(`## 长期记忆(Proactive Memory) + +以下是从历史会话沉淀的用户画像(L3,已剥离姓名等强识别字段),帮助你在跨会话中保持一致:\n\n\n${personaLines.join('\n')}\n`) + } else { + sections.push(`## 长期记忆(Proactive Memory) + +Proma 具备长期记忆能力:会在每条消息前自动检索相关历史记忆(若命中会以 注入),并提供 memory_search 工具供主动查询。`) + } + } else { + sections.push(`## 长期记忆(Proactive Memory) + +Proma 具备长期记忆能力:会在每条消息前自动检索相关历史记忆(若命中会以 注入),并提供 memory_search 工具供主动查询。 + +(用户已关闭用户画像注入:不随系统提示发送任何画像内容)`) + } + + // 工作记忆(参考 Nowledge Mem Working Memory):当前活跃任务快照,帮助快速恢复工作状态 + const wm = workingMemory() + if (wm.items.length > 0) { + const wmLines = wm.items.map((item) => `- ${item}`).join('\n') + sections.push(`\n${wmLines}\n`) + } + } + // Proma 协作会话 if (ctx.collaborationAvailable) { sections.push(`## Proma 协作会话 @@ -295,12 +338,14 @@ interface DynamicContext { workspaceName?: string workspaceSlug?: string agentCwd?: string + /** 当前用户消息文本;传入时按需注入长期记忆上下文(主动回忆) */ + userText?: string } /** * 构建每条消息的动态上下文 * - * 包含当前时间、工作区实时状态(MCP 服务器 + Skills)和工作目录。 + * 包含当前时间、工作区实时状态(MCP 服务器 + Skills)、工作目录和长期记忆召回。 * 每次调用都从磁盘实时读取,确保配置变更后下一条消息即可感知。 */ export function buildDynamicContext(ctx: DynamicContext): string { @@ -354,5 +399,13 @@ export function buildDynamicContext(ctx: DynamicContext): string { sections.push(`${ctx.agentCwd}`) } + // 长期记忆召回(主动回忆):仅在有关键词可检索时注入,预算截断由 recall 层保证 + if (ctx.userText?.trim()) { + const memoryBlock = contextForMessage(ctx.userText) + if (memoryBlock) { + sections.push(memoryBlock) + } + } + return sections.join('\n\n') } diff --git a/apps/electron/src/main/lib/builtin-mcp/default-mcp.json b/apps/electron/src/main/lib/builtin-mcp/default-mcp.json index 6aaa1a803..db9d2461f 100644 --- a/apps/electron/src/main/lib/builtin-mcp/default-mcp.json +++ b/apps/electron/src/main/lib/builtin-mcp/default-mcp.json @@ -78,6 +78,22 @@ { "name": "performance_start_trace", "description": "开始性能追踪。" }, { "name": "performance_stop_trace", "description": "停止性能追踪并返回结果。", "readOnly": true } ] + }, + { + "id": "memory", + "name": "memory", + "displayName": "长期记忆", + "description": "主动记忆与回忆:搜索历史沉淀的记忆、主动捕获当前上下文为记忆、查看统计与待确认纠正。", + "category": "memory", + "kind": "internal", + "deletable": false, + "defaultEnabled": true, + "toggleable": true, + "tools": [ + { "name": "memory_search", "description": "检索长期记忆(L1 atoms / corrections / persona)。", "readOnly": true }, + { "name": "memory_capture", "description": "主动沉淀当前对话上下文为一条记忆。" }, + { "name": "memory_stats", "description": "查看长期记忆统计与待确认纠正。", "readOnly": true } + ] } ] } diff --git a/apps/electron/src/main/lib/builtin-mcp/registry.ts b/apps/electron/src/main/lib/builtin-mcp/registry.ts index f3b6609f6..f46ac4623 100644 --- a/apps/electron/src/main/lib/builtin-mcp/registry.ts +++ b/apps/electron/src/main/lib/builtin-mcp/registry.ts @@ -8,6 +8,7 @@ import type { AgentRuntime, AgentSessionMeta, PromaPermissionMode } from '@proma/shared' import { injectAgentCollaborationMcpServer } from '../agent-collaboration-tools' import { injectAutomationMcpServer } from '../automation-agent-tools' +import { injectMemoryMcpServer } from '../memory/memory-agent-tools' import { injectNanoBananaMcpServer } from '../chat-tools/nano-banana-mcp' import { isBuiltinMcpUserEnabled } from './settings' @@ -55,6 +56,13 @@ export async function injectBuiltinMcpServers(ctx: BuiltinMcpInjectContext): Pro })) } + if (isBuiltinMcpUserEnabled('memory')) { + await injectBuiltinSafely('memory', () => injectMemoryMcpServer(ctx.sdk, ctx.mcpServers, { + sessionId: ctx.sessionId, + workspaceSlug: ctx.workspaceSlug, + })) + } + const collaborationAvailable = isBuiltinMcpUserEnabled('collaboration') && !!ctx.workspaceId && ctx.triggeredBy !== 'delegation' && diff --git a/apps/electron/src/main/lib/channel-manager.migrate.test.ts b/apps/electron/src/main/lib/channel-manager.migrate.test.ts new file mode 100644 index 000000000..91fcc50e6 --- /dev/null +++ b/apps/electron/src/main/lib/channel-manager.migrate.test.ts @@ -0,0 +1,173 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs' +import * as os from 'node:os' +import { join } from 'node:path' + +type ChannelManagerModule = typeof import('./channel-manager') + +let channelManager: ChannelManagerModule +let tempHome: string +const originalHome = process.env.HOME +const originalPromaDev = process.env.PROMA_DEV +const originalCwd = process.cwd() + +mock.module('electron', () => ({ + app: { + isPackaged: true, + getPath: () => join(process.env.HOME ?? tempHome, 'Library', 'Application Support'), + }, + safeStorage: { + isEncryptionAvailable: () => false, // 明文存储便于断言 + encryptString: (value: string) => Buffer.from(value), + decryptString: (value: Buffer) => value.toString('utf-8'), + }, + shell: { + openExternal: async () => undefined, + }, +})) + +mock.module('node:os', () => ({ + ...os, + homedir: () => tempHome, +})) + +function writeChannels(channels: unknown[]): void { + const configDir = join(tempHome, '.proma') + mkdirSync(configDir, { recursive: true }) + writeFileSync( + join(configDir, 'channels.json'), + JSON.stringify({ version: 2, channels }), + 'utf-8', + ) +} + +/** 写一个含 MEMORY_LLM_API_KEY 的 .env 到指定目录 */ +function writeDotEnv(dir: string, apiKey: string): void { + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, '.env'), `MEMORY_LLM_API_KEY=${apiKey}\nMEMORY_LLM_BASE_URL=https://api.deepseek.com/anthropic\n`, 'utf-8') +} + +function readChannels(): Array> { + const raw = readFileSyncSafe(join(tempHome, '.proma', 'channels.json')) + if (!raw) return [] + return (JSON.parse(raw).channels ?? []) as Array> +} + +function readFileSyncSafe(path: string): string | null { + try { + return require('node:fs').readFileSync(path, 'utf-8') + } catch { + return null + } +} + +beforeAll(async () => { + tempHome = mkdtempSync(join(os.tmpdir(), 'proma-channel-migrate-')) + process.env.HOME = tempHome + process.env.PROMA_DEV = '0' + channelManager = await import('./channel-manager') +}) + +beforeEach(() => { + rmSync(tempHome, { recursive: true, force: true }) + mkdirSync(tempHome, { recursive: true }) + delete process.env.MEMORY_LLM_API_KEY + delete process.env.MEMORY_LLM_BASE_URL + // 切到临时目录,避免 getMemoryLlmConfig 读到项目根 .env 的真实凭证 + process.chdir(tempHome) +}) + +afterAll(() => { + process.env.HOME = originalHome + process.env.PROMA_DEV = originalPromaDev + process.chdir(originalCwd) + rmSync(tempHome, { recursive: true, force: true }) +}) + +describe('channel-manager: DeepSeek 空 key 迁移', () => { + test('listChannels 为历史空 key DeepSeek 渠道补填 .env 凭证', () => { + writeChannels([ + { + id: 'deepseek-1', + name: 'DeepSeek', + provider: 'deepseek', + baseUrl: 'https://api.deepseek.com/anthropic', + apiKey: '', + models: [], + enabled: true, + createdAt: 1, + updatedAt: 1, + }, + ]) + writeDotEnv(join(tempHome, '.proma'), 'sk-test-1234567890') + + const channels = channelManager.listChannels() + const ds = channels.find((c) => c.id === 'deepseek-1') + expect(ds?.apiKey).toBe('sk-test-1234567890') + expect(ds?.enabled).toBe(true) + }) + + test('用户已填 key 的渠道不被覆盖', () => { + writeChannels([ + { + id: 'deepseek-1', + name: 'DeepSeek', + provider: 'deepseek', + baseUrl: 'https://api.deepseek.com/anthropic', + apiKey: 'sk-user-existing', + models: [], + enabled: true, + createdAt: 1, + updatedAt: 1, + }, + ]) + writeDotEnv(join(tempHome, '.proma'), 'sk-test-1234567890') + + const channels = channelManager.listChannels() + const ds = channels.find((c) => c.id === 'deepseek-1') + expect(ds?.apiKey).toBe('sk-user-existing') + }) + + test('无 .env key 时保持空 key 且不报错', () => { + writeChannels([ + { + id: 'deepseek-1', + name: 'DeepSeek', + provider: 'deepseek', + baseUrl: 'https://api.deepseek.com/anthropic', + apiKey: '', + models: [], + enabled: true, + createdAt: 1, + updatedAt: 1, + }, + ]) + // 不写 .env + + const channels = channelManager.listChannels() + const ds = channels.find((c) => c.id === 'deepseek-1') + expect(ds?.apiKey).toBe('') + }) + + test('迁移结果已持久化到 channels.json', () => { + writeChannels([ + { + id: 'deepseek-1', + name: 'DeepSeek', + provider: 'deepseek', + baseUrl: 'https://api.deepseek.com/anthropic', + apiKey: '', + models: [], + enabled: true, + createdAt: 1, + updatedAt: 1, + }, + ]) + writeDotEnv(join(tempHome, '.proma'), 'sk-test-1234567890') + + channelManager.listChannels() + const persisted = readChannels() + const ds = persisted.find((c) => c.id === 'deepseek-1') as { apiKey: string } | undefined + expect(ds?.apiKey).toBe('sk-test-1234567890') + }) +}) diff --git a/apps/electron/src/main/lib/channel-manager.ts b/apps/electron/src/main/lib/channel-manager.ts index 12a9511f4..35d7be01a 100644 --- a/apps/electron/src/main/lib/channel-manager.ts +++ b/apps/electron/src/main/lib/channel-manager.ts @@ -297,32 +297,79 @@ function decryptKey(encryptedKey: string): string { export function listChannels(): Channel[] { const config = readConfig() + // 迁移:已有 DeepSeek 渠道 apiKey 为空(历史预设)但 .env 有可用凭证时自动补填 + migrateEmptyDeepSeekKey(config) + // 首次使用:如果没有 DeepSeek 渠道,自动创建预设 const hasDeepSeek = config.channels.some( (c) => c.provider === 'deepseek' || c.baseUrl.includes('api.deepseek.com'), ) if (!hasDeepSeek) { const now = Date.now() + // 预设渠道自动填充已有 DeepSeek 凭证(.env 的 MEMORY_LLM_API_KEY),开箱即用; + // 无可用 key 时保持空(用户可在设置中手动填写)。 + const presetApiKey = resolveDeepSeekFallbackKey() const presetChannel: Channel = { id: randomUUID(), name: 'DeepSeek', provider: 'deepseek', baseUrl: PROVIDER_DEFAULT_URLS.deepseek, - apiKey: encryptApiKey(''), + apiKey: encryptApiKey(presetApiKey), models: cloneModels(DEEPSEEK_PRESET_MODELS), - enabled: false, + enabled: presetApiKey ? true : false, createdAt: now, updatedAt: now, } config.channels.push(presetChannel) writeConfig(config) - console.log('[渠道管理] 已自动创建 DeepSeek 预设渠道') + console.log(`[渠道管理] 已自动创建 DeepSeek 预设渠道${presetApiKey ? '(已填充 .env 凭证)' : ''}`) return config.channels } return config.channels } +/** + * 迁移:历史预设的 DeepSeek 渠道 apiKey 可能为空(自动创建时未填)。 + * 若 .env 中有 MEMORY_LLM_API_KEY 且渠道 key 为空,自动补填并持久化。 + */ +function migrateEmptyDeepSeekKey(config: ChannelsConfig): void { + const fallbackKey = resolveDeepSeekFallbackKey() + if (!fallbackKey) return + + let changed = false + for (const channel of config.channels) { + const isDeepSeek = channel.provider === 'deepseek' || channel.baseUrl.includes('api.deepseek.com') + if (!isDeepSeek) continue + // apiKey 为空才补填(用户已填的不动) + if (!channel.apiKey || channel.apiKey === '') { + channel.apiKey = encryptApiKey(fallbackKey) + changed = true + console.log('[渠道管理] 已为 DeepSeek 渠道自动填充 .env 凭证') + } + } + if (changed) { + writeConfig(config) + } +} + +/** + * 解析 DeepSeek 预设渠道的备用凭证:优先 .env 的 MEMORY_LLM_API_KEY。 + * 复用 memory/extractor 的配置读取(项目根 .env → ~/.proma/.env → 环境变量)。 + */ +function resolveDeepSeekFallbackKey(): string { + try { + const { getMemoryLlmConfig } = require('./memory/extractor') as typeof import('./memory/extractor') + const config = getMemoryLlmConfig() + if (config?.apiKey && config.apiKey.trim() !== '' && !config.apiKey.includes('在此填入')) { + return config.apiKey.trim() + } + } catch { + // 读取失败保持空 + } + return '' +} + /** * 按 ID 获取渠道 * diff --git a/apps/electron/src/main/lib/config-paths.ts b/apps/electron/src/main/lib/config-paths.ts index 13947053f..6d6759f50 100644 --- a/apps/electron/src/main/lib/config-paths.ts +++ b/apps/electron/src/main/lib/config-paths.ts @@ -44,9 +44,19 @@ export function getConfigDirName(): string { * 获取配置目录路径 * * 开发模式返回 ~/.proma-dev/,正式版本返回 ~/.proma/。 + * 支持 PROMA_CONFIG_DIR 环境变量覆盖(测试隔离 / 自定义配置位置), + * 与 PROMA_MEMORY_DIR 机制一致。 * 如果目录不存在则自动创建。 */ export function getConfigDir(): string { + const override = process.env.PROMA_CONFIG_DIR?.trim() + if (override) { + if (!existsSync(override)) { + mkdirSync(override, { recursive: true }) + } + return override + } + const configDir = join(homedir(), getConfigDirName()) if (!existsSync(configDir)) { @@ -713,3 +723,56 @@ export function getAutomationsPath(): string { export function getPlanningDatabasePath(): string { return join(getConfigDir(), 'planning.db') } + +/** + * 获取长期记忆(Proactive Memory)根目录 + * + * 支持 PROMA_MEMORY_DIR 环境变量覆盖(测试隔离 / 自定义存储位置)。 + * + * @returns ~/.proma/memory/(或 PROMA_MEMORY_DIR 指定目录) + */ +export function getMemoryRootDir(): string { + const override = process.env.PROMA_MEMORY_DIR?.trim() + if (override) return override + return join(getConfigDir(), 'memory') +} + +/** 记忆元数据索引文件路径 */ +export function getMemoryIndexPath(): string { + return join(getMemoryRootDir(), 'index.json') +} + +/** L3 用户画像路径 */ +export function getPersonaPath(): string { + return join(getMemoryRootDir(), 'profile.md') +} + +/** L1 原子记忆按天分文件目录 */ +export function getMemoryAtomsDir(): string { + return join(getMemoryRootDir(), 'atoms') +} + +/** 某天的 L1 原子记忆文件路径 */ +export function getMemoryAtomsDayPath(dateKey: string): string { + return join(getMemoryAtomsDir(), `${dateKey}.jsonl`) +} + +/** L2 场景块目录 */ +export function getMemoryScenesDir(): string { + return join(getMemoryRootDir(), 'scenes') +} + +/** 行为纠正候选文件路径 */ +export function getCorrectionsPath(): string { + return join(getMemoryRootDir(), 'corrections.json') +} + +/** 记忆变更日志目录 */ +export function getMemoryLogDir(): string { + return join(getMemoryRootDir(), 'memory_log') +} + +/** 主动建议索引文件路径 */ +export function getSuggestionsPath(): string { + return join(getConfigDir(), 'suggestions.json') +} diff --git a/apps/electron/src/main/lib/memory/embedding.test.ts b/apps/electron/src/main/lib/memory/embedding.test.ts new file mode 100644 index 000000000..233a6898d --- /dev/null +++ b/apps/electron/src/main/lib/memory/embedding.test.ts @@ -0,0 +1,41 @@ +/** + * Memory Embedding 单元测试(纯函数,不依赖 node-llama-cpp 加载) + */ + +import { describe, expect, it } from 'bun:test' +import { cosineSimilarity, getEmbeddingMode, isLocalEmbeddingReady } from '../memory/embedding' + +describe('memory/embedding 纯函数', () => { + it('cosineSimilarity 相同向量为 1', () => { + const v = [1, 2, 3] + expect(cosineSimilarity(v, v)).toBeCloseTo(1) + }) + + it('cosineSimilarity 正交向量为 0', () => { + expect(cosineSimilarity([1, 0], [0, 1])).toBeCloseTo(0) + }) + + it('cosineSimilarity 维度不一致返回 0', () => { + expect(cosineSimilarity([1, 2], [1, 2, 3])).toBe(0) + }) + + it('cosineSimilarity 空数组返回 0', () => { + expect(cosineSimilarity([], [])).toBe(0) + }) + + it('getEmbeddingMode 默认 off,env 覆盖生效', () => { + const before = process.env.PROMA_MEMORY_EMBEDDING + delete process.env.PROMA_MEMORY_EMBEDDING + expect(getEmbeddingMode()).toBe('off') + process.env.PROMA_MEMORY_EMBEDDING = 'local' + expect(getEmbeddingMode()).toBe('local') + process.env.PROMA_MEMORY_EMBEDDING = 'api' + expect(getEmbeddingMode()).toBe('api') + if (before === undefined) delete process.env.PROMA_MEMORY_EMBEDDING + else process.env.PROMA_MEMORY_EMBEDDING = before + }) + + it('isLocalEmbeddingReady 函数存在(模型路径可检查)', () => { + expect(typeof isLocalEmbeddingReady).toBe('function') + }) +}) diff --git a/apps/electron/src/main/lib/memory/embedding.ts b/apps/electron/src/main/lib/memory/embedding.ts new file mode 100644 index 000000000..23d4c2dbe --- /dev/null +++ b/apps/electron/src/main/lib/memory/embedding.ts @@ -0,0 +1,211 @@ +/** + * Memory Embedding — 语义向量通道(可插拔) + * + * 为召回提供语义检索能力,解决关键词无法处理的语义问句("我是谁")。 + * + * 两种模式(通过环境变量切换): + * - `PROMA_MEMORY_EMBEDDING=local`:本地 node-llama-cpp + embeddinggemma-300m(离线,需安装) + * - `PROMA_MEMORY_EMBEDDING=api`:OpenAI 兼容 embedding API(.env 配置) + * - 默认 off:不启用,召回降级为 keyword + 规则加权(fail-open) + * + * 设计原则: + * - **可选依赖**:node-llama-cpp 通过动态 import,主仓库不硬依赖 + * - **懒加载**:首次调用才初始化,避免拖慢启动 + * - **fail-open**:embedding 不可用时返回 null,不阻塞召回 + * - 单例:复用模型实例,避免重复加载 + */ + +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { homedir } from 'node:os' +import { getMemoryLlmConfig } from './extractor' + +// ===== 配置 ===== + +export type EmbeddingMode = 'off' | 'local' | 'api' + +/** 本地模型默认路径(复用 TencentDB 会话已下载的模型) */ +export const LOCAL_EMBEDDING_MODEL = join( + homedir(), + '.node-llama-cpp', + 'models', + 'hf_ggml-org_embeddinggemma-300m-qat-Q8_0.gguf', +) + +/** 本地模型向量维度 */ +const LOCAL_DIMENSIONS = 768 +/** 本地模型输入上限(字符级近似 256 token) */ +const LOCAL_MAX_INPUT_CHARS = 500 + +/** 读取 embedding 模式 */ +export function getEmbeddingMode(): EmbeddingMode { + const mode = process.env.PROMA_MEMORY_EMBEDDING?.trim().toLowerCase() + if (mode === 'local') return 'local' + if (mode === 'api') return 'api' + return 'off' +} + +/** 本地 embedding 是否就绪(模型文件存在) */ +export function isLocalEmbeddingReady(): boolean { + return existsSync(LOCAL_EMBEDDING_MODEL) +} + +// ===== 单例(本地) ===== + +interface LocalEmbeddingContext { + getEmbeddingFor: (input: string) => Promise<{ vector: readonly number[] }> + dispose: () => Promise +} + +let localContext: LocalEmbeddingContext | null = null +let localInitPromise: Promise | null = null + +/** 动态 import node-llama-cpp(可选依赖) */ +async function importLlama(): Promise<{ getLlama: (opts: { logLevel: number; gpu?: boolean | string }) => Promise; resolveModelFile: (path: string, cacheDir?: string) => Promise; LlamaLogLevel: { error: number } }> { + // node-llama-cpp 在 TencentDB 工作区已验证可用;此处从用户全局或工作区尝试加载 + const candidates = [ + 'node-llama-cpp', + join('/Users/moxianbao/.proma/agent-workspaces/tencentdb/workspace-files/TencentDB-Agent-Memory/node_modules/node-llama-cpp', 'dist', 'index.js'), + ] + for (const mod of candidates) { + try { + return await import(mod) + } catch { + // try next + } + } + throw new Error('node-llama-cpp 未安装,无法使用本地 embedding') +} + +/** 初始化本地 embedding(懒加载 + 单例) */ +async function initLocalEmbedding(): Promise { + if (!isLocalEmbeddingReady()) { + console.warn('[Memory] 本地 embedding 模型不存在:', LOCAL_EMBEDDING_MODEL) + return null + } + if (localContext) return localContext + if (localInitPromise) return localInitPromise + + localInitPromise = (async () => { + try { + const { getLlama, resolveModelFile, LlamaLogLevel } = await importLlama() + // 强制 CPU:Metal GPU 编译在部分 macOS 环境失败;embeddinggemma-300m 在 CPU 上也足够快 + const llama = await getLlama({ logLevel: LlamaLogLevel.error, gpu: false }) as unknown as { + loadModel: (opts: { modelPath: string }) => Promise<{ createEmbeddingContext: () => Promise }> + } + const resolvedPath = await resolveModelFile(LOCAL_EMBEDDING_MODEL) + const model = await llama.loadModel({ modelPath: resolvedPath }) + localContext = await model.createEmbeddingContext() + console.log('[Memory] 本地 embedding 就绪 (embeddinggemma-300m, 768d)') + return localContext + } catch (error) { + console.warn('[Memory] 本地 embedding 初始化失败:', error instanceof Error ? error.message : error) + return null + } + })() + + return localInitPromise +} + +// ===== API 模式 ===== + +/** 调用 OpenAI 兼容 embedding API(.env 配置) */ +async function apiEmbed(texts: string[]): Promise { + const config = getMemoryLlmConfig() + if (!config) return null + try { + const resp = await fetch(`${config.baseUrl.replace(/\/+$/, '')}/embeddings`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${config.apiKey}` }, + body: JSON.stringify({ model: process.env.MEMORY_EMBEDDING_MODEL ?? 'text-embedding-3-small', input: texts }), + }) + if (!resp.ok) return null + const data = await resp.json() as { data?: Array<{ embedding: number[] }> } + return data.data?.map((d) => d.embedding) ?? null + } catch { + return null + } +} + +// ===== 统一接口 ===== + +export interface EmbeddingProvider { + /** 计算单条文本向量;失败返回 null(fail-open) */ + embed: (text: string) => Promise + /** 计算多条文本向量(批量) */ + embedBatch: (texts: string[]) => Promise> + /** 是否可用 */ + ready: () => boolean + dimensions: number +} + +let cachedProvider: EmbeddingProvider | null | undefined = undefined + +/** 获取 embedding provider(按模式选择;未启用返回 null) */ +export function getEmbeddingProvider(): EmbeddingProvider | null { + const mode = getEmbeddingMode() + if (mode === 'off') return null + if (cachedProvider !== undefined) return cachedProvider + + if (mode === 'local') { + if (!isLocalEmbeddingReady()) { + console.warn('[Memory] 本地 embedding 模型缺失,降级为 keyword 召回') + cachedProvider = null + return null + } + cachedProvider = { + async embed(text) { + const ctx = await initLocalEmbedding() + if (!ctx) return null + try { + const trimmed = text.slice(0, LOCAL_MAX_INPUT_CHARS) + const result = await ctx.getEmbeddingFor(trimmed) + return Array.isArray(result) ? result : Array.from(result.vector ?? []) + } catch { + return null + } + }, + async embedBatch(texts) { + return Promise.all(texts.map((t) => this.embed(t))) + }, + ready: () => true, + dimensions: LOCAL_DIMENSIONS, + } + return cachedProvider + } + + if (mode === 'api') { + cachedProvider = { + async embed(text) { + const result = await apiEmbed([text]) + return result?.[0] ?? null + }, + async embedBatch(texts): Promise> { + const result = await apiEmbed(texts) + return result ?? texts.map(() => null) + }, + ready: () => !!getMemoryLlmConfig(), + dimensions: 1536, + } + return cachedProvider + } + + return null +} + +// ===== 向量工具 ===== + +/** 余弦相似度(0-1,越高越相似) */ +export function cosineSimilarity(a: number[], b: number[]): number { + if (a.length !== b.length || a.length === 0) 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)) +} diff --git a/apps/electron/src/main/lib/memory/extractor.test.ts b/apps/electron/src/main/lib/memory/extractor.test.ts new file mode 100644 index 000000000..164454e8c --- /dev/null +++ b/apps/electron/src/main/lib/memory/extractor.test.ts @@ -0,0 +1,239 @@ +/** + * Memory Extractor 单元测试(纯逻辑,不依赖真实 LLM) + */ + +import { describe, expect, it } from 'bun:test' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import * as os from 'node:os' +import { join } from 'node:path' +import { parseExtractionResponse, formatExtractionMessages, findDotEnvUpwards, getMemoryLlmConfig, resolveMemoryLlmConfig, isSafeBaseUrl } from '../memory/extractor' + +describe('memory/extractor 解析', () => { + it('解析标准 JSON 数组', () => { + const raw = '[{"content": "用户使用 DeepSeek", "type": "fact", "priority": 70}]' + const result = parseExtractionResponse(raw) + expect(result).toHaveLength(1) + expect(result[0]?.content).toBe('用户使用 DeepSeek') + expect(result[0]?.type).toBe('fact') + expect(result[0]?.priority).toBe(70) + }) + + it('解析带 markdown 围栏的响应', () => { + const raw = '```json\n[{"content": "偏好中文", "type": "preference", "priority": 60}]\n```' + const result = parseExtractionResponse(raw) + expect(result).toHaveLength(1) + expect(result[0]?.type).toBe('preference') + }) + + it('过滤空 content,非法类型降级为 fact', () => { + const raw = JSON.stringify([ + { content: '', type: 'fact', priority: 50 }, + { content: '有效记忆', type: 'hack', priority: 100 }, + { content: '正确类型', type: 'sop', priority: 80 }, + { content: '项目发布了 v1.0', type: 'event', priority: 60 }, + ]) + const result = parseExtractionResponse(raw) + expect(result).toHaveLength(3) + expect(result[0]?.type).toBe('fact') // 非法 hack 降级为 fact + expect(result[0]?.priority).toBe(100) + expect(result[1]?.type).toBe('sop') + expect(result[1]?.priority).toBe(80) + expect(result[2]?.type).toBe('event') // 新增 event 类型被接受 + expect(result[2]?.priority).toBe(60) + }) + + it('priority 越界时钳制到 0-100', () => { + const raw = '[{"content": "x", "type": "fact", "priority": 999}, {"content": "y", "type": "fact", "priority": -5}]' + const result = parseExtractionResponse(raw) + expect(result[0]?.priority).toBe(100) + expect(result[1]?.priority).toBe(0) + }) + + it('非 JSON 响应返回空数组', () => { + expect(parseExtractionResponse('不是 JSON')).toEqual([]) + expect(parseExtractionResponse('')).toEqual([]) + expect(parseExtractionResponse('[not valid')).toEqual([]) + }) + + it('formatExtractionMessages 截断超长消息', () => { + const long = 'x'.repeat(2000) + const text = formatExtractionMessages([{ role: 'user', content: long }]) + expect(text.length).toBeLessThan(1200) + }) +}) + +describe('memory/extractor findDotEnvUpwards(dev 模式 cwd 在子目录)', () => { + it('从子目录向上查找到仓库根 .env', () => { + const tempRoot = mkdtempSync(join(os.tmpdir(), 'extractor-env-up-')) + try { + // 模拟:仓库根有 .env,cwd 在 apps/electron(子目录) + const repoRoot = join(tempRoot, 'ProMa') + const subDir = join(repoRoot, 'apps', 'electron') + mkdirSync(subDir, { recursive: true }) + writeFileSync( + join(repoRoot, '.env'), + 'MEMORY_LLM_API_KEY=sk-upward-test-key-123456\nMEMORY_LLM_BASE_URL=https://api.deepseek.com/anthropic\n', + 'utf-8', + ) + + const env = findDotEnvUpwards(subDir) + expect(env.MEMORY_LLM_API_KEY).toBe('sk-upward-test-key-123456') + } finally { + rmSync(tempRoot, { recursive: true, force: true }) + } + }) + + it('cwd 即 .env 所在目录时直接命中', () => { + const tempRoot = mkdtempSync(join(os.tmpdir(), 'extractor-env-direct-')) + try { + mkdirSync(tempRoot, { recursive: true }) + writeFileSync(join(tempRoot, '.env'), 'MEMORY_LLM_API_KEY=sk-direct-test-key\n', 'utf-8') + const env = findDotEnvUpwards(tempRoot) + expect(env.MEMORY_LLM_API_KEY).toBe('sk-direct-test-key') + } finally { + rmSync(tempRoot, { recursive: true, force: true }) + } + }) + + it('无 .env 时返回空', () => { + const tempRoot = mkdtempSync(join(os.tmpdir(), 'extractor-env-none-')) + try { + const env = findDotEnvUpwards(tempRoot) + expect(Object.keys(env).length).toBe(0) + } finally { + rmSync(tempRoot, { recursive: true, force: true }) + } + }) + + it('getMemoryLlmConfig 在子目录 cwd 下能读到上级 .env(模拟 dev 模式)', () => { + const tempRoot = mkdtempSync(join(os.tmpdir(), 'extractor-llm-up-')) + const originalCwd = process.cwd() + try { + const repoRoot = join(tempRoot, 'ProMa') + const subDir = join(repoRoot, 'apps', 'electron') + mkdirSync(subDir, { recursive: true }) + writeFileSync( + join(repoRoot, '.env'), + 'MEMORY_LLM_API_KEY=sk-llm-up-test-key-123456\nMEMORY_LLM_BASE_URL=https://api.deepseek.com/anthropic\nMEMORY_LLM_MODEL=deepseek-v4-flash\n', + 'utf-8', + ) + // 清掉环境变量,确保走 .env 路径 + delete process.env.MEMORY_LLM_API_KEY + delete process.env.MEMORY_LLM_BASE_URL + delete process.env.MEMORY_LLM_MODEL + delete process.env.PROMA_MEMORY_LLM_DISABLED + process.chdir(subDir) + + const config = getMemoryLlmConfig() + expect(config?.apiKey).toBe('sk-llm-up-test-key-123456') + expect(config?.baseUrl).toBe('https://api.deepseek.com/anthropic') + expect(config?.model).toBe('deepseek-v4-flash') + } finally { + process.chdir(originalCwd) + rmSync(tempRoot, { recursive: true, force: true }) + } + }) + + it('跨源混搭被阻断:env 提供 apiKey 时,project 单独提供的 baseUrl 被忽略', () => { + const tempRoot = mkdtempSync(join(os.tmpdir(), 'extractor-mix-')) + const originalCwd = process.cwd() + const originalKey = process.env.MEMORY_LLM_API_KEY + const originalBase = process.env.MEMORY_LLM_BASE_URL + const originalModel = process.env.MEMORY_LLM_MODEL + try { + // 攻击者场景:启动目录放一个只含恶意 baseUrl 的 .env + mkdirSync(join(tempRoot, 'proj'), { recursive: true }) + writeFileSync( + join(tempRoot, 'proj', '.env'), + 'MEMORY_LLM_BASE_URL=https://attacker.example/v1\n', + 'utf-8', + ) + // 真实 key 来自环境变量 + process.env.MEMORY_LLM_API_KEY = 'sk-env-key-real-123' + delete process.env.MEMORY_LLM_BASE_URL + delete process.env.MEMORY_LLM_MODEL + delete process.env.PROMA_MEMORY_LLM_DISABLED + process.chdir(join(tempRoot, 'proj')) + + const config = getMemoryLlmConfig() + // baseUrl 必须来自与 apiKey 同源(env 没有则用默认),绝不能是攻击者的 URL + expect(config?.apiKey).toBe('sk-env-key-real-123') + expect(config?.baseUrl).not.toBe('https://attacker.example/v1') + expect(config?.baseUrl).toBe('https://api.deepseek.com/v1') + expect(config?.model).toBe('deepseek-chat') + } finally { + process.chdir(originalCwd) + if (originalKey === undefined) delete process.env.MEMORY_LLM_API_KEY + else process.env.MEMORY_LLM_API_KEY = originalKey + if (originalBase === undefined) delete process.env.MEMORY_LLM_BASE_URL + else process.env.MEMORY_LLM_BASE_URL = originalBase + if (originalModel === undefined) delete process.env.MEMORY_LLM_MODEL + else process.env.MEMORY_LLM_MODEL = originalModel + rmSync(tempRoot, { recursive: true, force: true }) + } + }) + + it('project 源只有提供 apiKey 时才整体生效(含 baseUrl)', () => { + const tempRoot = mkdtempSync(join(os.tmpdir(), 'extractor-proj-')) + const originalCwd = process.cwd() + try { + mkdirSync(join(tempRoot, 'proj'), { recursive: true }) + writeFileSync( + join(tempRoot, 'proj', '.env'), + 'MEMORY_LLM_API_KEY=sk-proj-key-456\nMEMORY_LLM_BASE_URL=https://api.example.com/v1\nMEMORY_LLM_MODEL=my-model\n', + 'utf-8', + ) + delete process.env.MEMORY_LLM_API_KEY + delete process.env.MEMORY_LLM_BASE_URL + delete process.env.MEMORY_LLM_MODEL + delete process.env.PROMA_MEMORY_LLM_DISABLED + process.chdir(join(tempRoot, 'proj')) + + const config = getMemoryLlmConfig() + expect(config?.apiKey).toBe('sk-proj-key-456') + expect(config?.baseUrl).toBe('https://api.example.com/v1') + expect(config?.model).toBe('my-model') + } finally { + process.chdir(originalCwd) + rmSync(tempRoot, { recursive: true, force: true }) + } + }) + + it('home 源提供 apiKey 时同样只从 home 取 baseUrl(同源,纯函数)', () => { + // 纯函数测试:home 源有 key,project 源只提供恶意 baseUrl → 忽略 project,取 home 的 baseUrl + const config = resolveMemoryLlmConfig([ + { name: 'env', vars: {} }, + { + name: 'project', + vars: { MEMORY_LLM_BASE_URL: 'https://attacker.example/v1' }, + }, + { + name: 'home', + vars: { + MEMORY_LLM_API_KEY: 'sk-home-key-789', + MEMORY_LLM_BASE_URL: 'https://home.example.com/v1', + MEMORY_LLM_MODEL: 'home-model', + }, + }, + ]) + expect(config?.apiKey).toBe('sk-home-key-789') + expect(config?.baseUrl).toBe('https://home.example.com/v1') + expect(config?.model).toBe('home-model') + }) + + it('恶意/异常 baseUrl 被拒绝:http、用户信息、控制字符、无法解析', () => { + expect(isSafeBaseUrl('http://attacker.example/v1')).toBe(false) + expect(isSafeBaseUrl('https://attacker.example/v1')).toBe(true) + expect(isSafeBaseUrl('https://user:pass@attacker.example/v1')).toBe(false) + expect(isSafeBaseUrl('https://api.deepseek.com/v1\n')).toBe(false) + expect(isSafeBaseUrl('not-a-url')).toBe(false) + // 本地代理放行 + expect(isSafeBaseUrl('http://localhost:11434/v1')).toBe(true) + expect(isSafeBaseUrl('http://127.0.0.1:11434/v1')).toBe(true) + }) + + it('isSafeBaseUrl 拒绝非本地 http 与非 https', () => { + expect(isSafeBaseUrl('ftp://attacker.example/v1')).toBe(false) + expect(isSafeBaseUrl('https://')).toBe(false) + }) +}) diff --git a/apps/electron/src/main/lib/memory/extractor.ts b/apps/electron/src/main/lib/memory/extractor.ts new file mode 100644 index 000000000..91509d985 --- /dev/null +++ b/apps/electron/src/main/lib/memory/extractor.ts @@ -0,0 +1,278 @@ +/** + * Memory Extractor — LLM 主动记忆提取 + * + * 从一段对话消息中提取结构化长期记忆候选(L1 atoms)。 + * 通过 OpenAI 兼容端点调用 LLM,JSON 模式输出,随后由 service 层去重写入。 + * + * 设计: + * - 从本地 .env / 环境变量读取 LLM 配置(MEMORY_LLM_*),绝不回显 key + * - prompt 要求"只写对话中明确出现的",type 限 fact/preference/correction/sop/todo_context + * - 输出 JSON 数组 [{ content, type, priority }] + * - 失败降级:返回空数组(不阻塞主流程) + */ + +import { existsSync, readFileSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { homedir } from 'node:os' +import { getConfigDir } from '../config-paths' +import type { MemoryCandidate } from '@proma/shared' + +// ===== 配置 ===== + +export interface MemoryLlmConfig { + apiKey: string + baseUrl: string + model: string +} + +const CONFIG_KEYS = { + apiKey: 'MEMORY_LLM_API_KEY', + baseUrl: 'MEMORY_LLM_BASE_URL', + model: 'MEMORY_LLM_MODEL', +} as const + +/** 读取 .env(简单解析,不引入 dotenv 运行时依赖) */ +function loadDotEnv(filePath: string): Record { + const result: Record = {} + if (!existsSync(filePath)) return result + try { + const raw = readFileSync(filePath, 'utf-8') + for (const line of raw.split('\n')) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) continue + const idx = trimmed.indexOf('=') + if (idx <= 0) continue + const key = trimmed.slice(0, idx).trim() + let value = trimmed.slice(idx + 1).trim() + // 去掉可选引号 + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1) + } + if (key) result[key] = value + } + } catch { + // 忽略读取失败 + } + return result +} + +function resolveEnv(name: string): string | undefined { + return process.env[name] ?? undefined +} + +/** + * 沿 cwd 向上查找 .env(最多 MAX_LOOKUP_DEPTH 层)。 + * 覆盖 dev 模式 cwd=apps/electron 但仓库根 .env 在 ProMa/.env 的场景。 + */ +export function findDotEnvUpwards(startDir: string): Record { + let dir = startDir + for (let depth = 0; depth < 5; depth++) { + const env = loadDotEnv(join(dir, '.env')) + if (Object.keys(env).length > 0) return env + const parent = dirname(dir) + if (parent === dir) break + dir = parent + } + return {} +} + +/** + * 解析 LLM 配置(同源原则): + * - 信任源优先级:环境变量 → 项目 .env(沿 cwd 向上查找)→ 配置目录 .env(~/.proma 或 PROMA_CONFIG_DIR) + * - apiKey 决定主信任源;baseUrl/model 只从主信任源取,绝不跨源混搭 + * (防止攻击者在启动目录放置仅含 MEMORY_LLM_BASE_URL 的 .env, + * 与来自 env/home 的真实 apiKey 组合导致 key 外泄) + * - project 源只有同时提供 apiKey 才整体生效;单独提供 baseUrl 被忽略 + * - baseUrl 仅允许 https(localhost 本地代理例外),异常 URL 视为未配置 + */ +export function getMemoryLlmConfig(): MemoryLlmConfig | undefined { + // 显式禁用(测试隔离 / 用户临时关闭) + if (process.env.PROMA_MEMORY_LLM_DISABLED === '1') return undefined + + const envVars = process.env + const projectEnv = findDotEnvUpwards(process.cwd()) + const homeEnv = loadDotEnv(join(getConfigDir(), '.env')) + + const sources: Array<{ name: 'env' | 'project' | 'home'; vars: Record }> = [ + { name: 'env', vars: envVars }, + { name: 'project', vars: projectEnv }, + { name: 'home', vars: homeEnv }, + ] + return resolveMemoryLlmConfig(sources) +} + +/** 同源解析纯函数(可独立测试,不受全局 env 竞态影响) */ +export function resolveMemoryLlmConfig( + sources: Array<{ name: 'env' | 'project' | 'home'; vars: Record }>, +): MemoryLlmConfig | undefined { + const primary = sources.find((s) => { + const key = s.vars[CONFIG_KEYS.apiKey] + return !!key && key.trim() !== '' && !key.includes('在此填入') + }) + if (!primary) return undefined + + const apiKey = (primary.vars[CONFIG_KEYS.apiKey] ?? '').trim() + const baseUrlRaw = primary.vars[CONFIG_KEYS.baseUrl]?.trim() || 'https://api.deepseek.com/v1' + const model = primary.vars[CONFIG_KEYS.model]?.trim() || 'deepseek-chat' + + // baseUrl 安全校验:仅 https,localhost/127.0.0.1 本地代理放行;拒绝用户信息/控制字符/解析失败 + if (!isSafeBaseUrl(baseUrlRaw)) return undefined + + return { apiKey, baseUrl: baseUrlRaw, model } +} + +/** baseUrl 安全校验:强制 https;localhost/127.0.0.1/::1 本地代理例外 */ +export function isSafeBaseUrl(url: string): boolean { + if (/[\u0000-\u001f\u007f]/.test(url)) return false + let parsed: URL + try { + parsed = new URL(url) + } catch { + return false + } + if (parsed.protocol !== 'https:') { + const host = parsed.hostname + if (host !== 'localhost' && host !== '127.0.0.1' && host !== '::1') return false + } + // 拒绝 URL 中带用户信息(user:pass@host)——防止伪装目标 + if (parsed.username || parsed.password) return false + return true +} + +/** 是否已配置 LLM(供 UI/工具提示) */ +export function isMemoryLlmConfigured(): boolean { + return !!getMemoryLlmConfig() +} + +// ===== Prompt ===== + +const EXTRACT_SYSTEM_PROMPT = `你是长期记忆提取器。从对话中提取值得长期记住的结构化记忆。 + +规则: +1. 只提取对话中"明确出现"的信息,禁止推测、编造或补充常识。 +2. 每条记忆必须自包含、简洁、可独立理解(一句话,通常 10-60 字)。 +3. 类型只能是以下之一: + - fact: 客观事实(用户身份、项目信息、技术选型、环境等) + - preference: 用户偏好(喜欢的语言/工具/风格/工作方式) + - correction: 行为纠正(用户指出 Agent 的错误或改进要求) + - sop: 可复用流程(重复出现的步骤、约定) + - todo_context: 任务上下文(正在进行或计划的工作) + - event: 结构化事件(“X 时间做了 Y / 项目进入 Z 状态 / 发布了某版本”,有时间性,尽量带上时间与主体) +4. 重要度 priority 0-100:影响后续工作的关键约束给 80+,普通背景 50,琐碎 30 以下。 +5. 一条消息最多输出 3 条记忆;无值得记忆的内容时输出空数组。 +6. 输出必须是合法 JSON 数组,格式:[{"content": "...", "type": "fact", "priority": 60}] +7. 只输出 JSON 数组本身,不要输出任何解释、前后缀或 markdown 围栏。` + +/** 构造提取请求(截断超长输入,避免 token 爆炸) */ +export function formatExtractionMessages(messages: Array<{ role: 'user' | 'assistant'; content: string }>, maxMessages = 20): string { + const recent = messages.slice(-maxMessages) + const lines = recent.map((m) => `${m.role === 'user' ? '用户' : '助手'}: ${m.content.slice(0, 800)}`) + return lines.join('\n') +} + +// ===== LLM 调用 ===== + +/** 从 LLM 响应中解析 JSON 数组(容错:剥离 markdown 围栏) */ +export function parseExtractionResponse(raw: string): MemoryCandidate[] { + if (!raw) return [] + let text = raw.trim() + // 剥离 ```json ... ``` 围栏 + const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/) + if (fence) text = fence[1]?.trim() ?? '' + // 找第一个 [ 到最后一个 ] + const start = text.indexOf('[') + const end = text.lastIndexOf(']') + if (start === -1 || end <= start) return [] + const jsonStr = text.slice(start, end + 1) + try { + const parsed = JSON.parse(jsonStr) + if (!Array.isArray(parsed)) return [] + const result: MemoryCandidate[] = [] + for (const item of parsed) { + if (!item || typeof item !== 'object') continue + const content = typeof item.content === 'string' ? item.content.trim() : '' + if (!content) continue + const type = ['fact', 'preference', 'correction', 'sop', 'todo_context', 'event'].includes(item.type) + ? item.type as MemoryCandidate['type'] + : 'fact' + const priority = typeof item.priority === 'number' && Number.isFinite(item.priority) + ? Math.min(100, Math.max(0, Math.round(item.priority))) + : 50 + result.push({ content, type, priority }) + } + return result + } catch { + return [] + } +} + +/** + * 通用 LLM 调用(OpenAI 兼容,无 JSON 强制格式,适合 reasoning 模型)。 + * 返回原始 content 文本;失败返回 null(不抛错)。 + */ +export async function callLlm( + systemPrompt: string, + userText: string, + opts: { temperature?: number; maxTokens?: number; timeoutMs?: number } = {}, +): Promise { + const config = getMemoryLlmConfig() + if (!config) return null + try { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), opts.timeoutMs ?? 30_000) + const response = await fetch(`${config.baseUrl.replace(/\/+$/, '')}/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${config.apiKey}`, + }, + body: JSON.stringify({ + model: config.model, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userText }, + ], + temperature: opts.temperature ?? 0.2, + max_tokens: opts.maxTokens ?? 4096, + }), + signal: controller.signal, + }) + clearTimeout(timeout) + if (!response.ok) { + const errText = await response.text().catch(() => '') + console.warn('[Memory] LLM 请求失败:', response.status, errText.slice(0, 200)) + return null + } + const data = await response.json() as { choices?: Array<{ message?: { content?: string } }> } + return data.choices?.[0]?.message?.content ?? null + } catch (error) { + console.warn('[Memory] LLM 调用异常:', error instanceof Error ? error.message : error) + return null + } +} + +/** + * 调用 LLM 提取记忆候选。 + * 失败返回空数组(不抛错,保证主流程不中断)。 + */ +export async function extractCandidates( + messages: Array<{ role: 'user' | 'assistant'; content: string }>, +): Promise { + const config = getMemoryLlmConfig() + if (!config) return [] + + const inputText = formatExtractionMessages(messages) + if (!inputText.trim()) return [] + + const raw = await callLlm(EXTRACT_SYSTEM_PROMPT, inputText, { temperature: 0.2, maxTokens: 4096 }) + if (!raw) return [] + const candidates = parseExtractionResponse(raw) + return candidates.slice(0, 10) // 单次最多 10 条 +} + +/** 从对话消息批量提取并返回候选(service 层调用入口) */ +export async function extractFromMessages( + messages: Array<{ role: 'user' | 'assistant'; content: string }>, +): Promise { + return extractCandidates(messages) +} diff --git a/apps/electron/src/main/lib/memory/integration.test.ts b/apps/electron/src/main/lib/memory/integration.test.ts new file mode 100644 index 000000000..ba063d294 --- /dev/null +++ b/apps/electron/src/main/lib/memory/integration.test.ts @@ -0,0 +1,184 @@ +/** + * Memory Store 磁盘集成测试 + * + * 通过 PROMA_MEMORY_DIR 环境变量把记忆根目录指向临时目录, + * 验证真实磁盘读写(不依赖 LLM / service mock)。 + */ + +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'bun:test' +import { existsSync, rmSync } from 'node:fs' +import { join } from 'node:path' + +// bun 的 mock.module 是全局副作用:agent-prompt-builder.test.ts 会把 config-paths 的 +// memory 函数 mock 到 /tmp/proma-test-config/memory。集成测试与它共用同一路径, +// 保证全量并发时路径一致。只验证“写盘可回读”,不依赖具体目录值。 +const memRoot = '/tmp/proma-test-config/memory' + +beforeAll(() => { + process.env.PROMA_MEMORY_DIR = memRoot +}) + +beforeEach(() => { + // 每个用例前清空隔离目录,避免残留数据影响判重/统计 + rmSync(memRoot, { recursive: true, force: true }) +}) + +afterAll(() => { + delete process.env.PROMA_MEMORY_DIR + rmSync('/tmp/proma-test-config', { recursive: true, force: true }) +}) + +let store: typeof import('../memory/store') + +beforeAll(async () => { + store = await import('../memory/store') +}) + +describe('memory/store 磁盘集成(隔离目录)', () => { + it('writeAtom + readAllAtoms 落盘可回读', () => { + const atom = store.writeAtom({ content: '集成测试记忆', type: 'fact', priority: 60 }) + const all = store.readAllAtoms({ includeUnconfirmed: true }) + expect(all.some((a) => a.id === atom.id)).toBe(true) + const dayFile = store.localDateKey() + expect(existsSync(join(memRoot, 'atoms', `${dayFile}.jsonl`))).toBe(true) + }) + + it('writeAtomWithDedup 重复内容合并', () => { + const first = store.writeAtomWithDedup({ content: '用户喜欢中文回复', type: 'preference', priority: 50 }) + const second = store.writeAtomWithDedup({ content: '用户喜欢中文回复。', type: 'preference', priority: 80 }) + expect(first.deduplicated).toBe(false) + expect(second.deduplicated).toBe(true) + expect(second.atom.id).toBe(first.atom.id) + expect(second.atom.priority).toBeGreaterThanOrEqual(first.atom.priority) + }) + + it('addCorrection + list + update 状态流转', () => { + const correction = store.addCorrection({ raw: '测试纠正', rule: '测试规则' }) + expect(store.listCorrections('pending').some((c) => c.id === correction.id)).toBe(true) + store.updateCorrectionStatus(correction.id, 'active') + expect(store.listCorrections('active').some((c) => c.id === correction.id)).toBe(true) + expect(store.listCorrections('pending').some((c) => c.id === correction.id)).toBe(false) + }) + + it('writePersona + readPersonaRaw + parsePersonaProfile', () => { + store.writePersona('# 用户画像\n\n## 用户\nConrad\n\n## 长期偏好\n- 喜欢 TypeScript') + const raw = store.readPersonaRaw() + expect(raw).toContain('Conrad') + // 溯源版本标记自动注入 + expect(raw).toContain('persona-version: 2') + expect(store.isPersonaTraceable()).toBe(true) + const profile = store.parsePersonaProfile(raw) + expect(profile.name).toBe('Conrad') + expect(profile.preferences).toContain('喜欢 TypeScript') + }) + + it('getMemoryStats 汇总统计(rootDir 指向隔离目录)', () => { + store.writeAtom({ content: '统计测试记忆', type: 'fact', priority: 50 }) + const stats = store.getMemoryStats() + expect(stats.atomCount).toBeGreaterThan(0) + expect(typeof stats.pendingCorrections).toBe('number') + expect(typeof stats.pendingAtoms).toBe('number') + expect(stats.rootDir).toBe(memRoot) + }) + + it('pending atom 流转:提取默认 pending → 确认生效 / 拒绝删除', () => { + const atom = store.writeAtom({ content: '自动提取记忆', type: 'fact', priority: 50, confirmed: false }) + // 默认不被读入 confirmed + expect(store.readAllAtoms().some((a) => a.id === atom.id)).toBe(false) + expect(store.readAllAtoms({ includeUnconfirmed: true }).some((a) => a.id === atom.id)).toBe(true) + // 出现在待确认列表 + expect(store.listPendingAtoms().some((a) => a.id === atom.id)).toBe(true) + // 确认后生效 + const confirmed = store.confirmAtom(atom.id) + expect(confirmed?.confirmed).toBe(true) + expect(store.readAllAtoms().some((a) => a.id === atom.id)).toBe(true) + // 拒绝删除 + const atom2 = store.writeAtom({ content: '要被拒绝的记忆', type: 'preference', priority: 50, confirmed: false }) + expect(store.deleteAtom(atom2.id)).toBe(true) + expect(store.getAtomById(atom2.id)).toBeUndefined() + }) + + it('listAtomsPaged 分页 + 类型过滤 + 排序', () => { + for (let i = 0; i < 5; i++) store.writeAtom({ content: `事实 ${i}`, type: 'fact', priority: 50 + i, confirmed: true }) + store.writeAtom({ content: '一个偏好', type: 'preference', priority: 80, confirmed: true }) + + // 全部,每页 3 + const p1 = store.listAtomsPaged({ page: 1, pageSize: 3 }) + expect(p1.atoms.length).toBe(3) + expect(p1.total).toBe(6) + expect(p1.totalPages).toBe(2) + // 类型过滤 + const facts = store.listAtomsPaged({ type: 'fact', pageSize: 20 }) + expect(facts.total).toBe(5) + expect(facts.atoms.every((a) => a.type === 'fact')).toBe(true) + // 按优先级排序 + const byPri = store.listAtomsPaged({ sort: 'priority', pageSize: 20 }) + expect(byPri.atoms.length).toBeGreaterThanOrEqual(2) + const first = byPri.atoms[0]! + const second = byPri.atoms[1]! + expect(first.priority ?? 0).toBeGreaterThanOrEqual(second.priority ?? 0) + // 第二页 + const p2 = store.listAtomsPaged({ page: 2, pageSize: 3 }) + expect(p2.atoms.length).toBe(3) + expect(p2.atoms[0]!.id).not.toBe(p1.atoms[0]!.id) + }) + + it('listAtomsPaged 可按确认状态过滤', () => { + const confirmed = store.writeAtom({ content: '已确认记忆', type: 'fact', priority: 60, confirmed: true }) + const pending = store.writeAtom({ content: '待确认记忆', type: 'fact', priority: 50, confirmed: false }) + + const activeOnly = store.listAtomsPaged({ confirmed: true }) + expect(activeOnly.atoms.map((atom) => atom.id)).toContain(confirmed.id) + expect(activeOnly.atoms.map((atom) => atom.id)).not.toContain(pending.id) + + const pendingOnly = store.listAtomsPaged({ confirmed: false }) + expect(pendingOnly.atoms.map((atom) => atom.id)).toEqual([pending.id]) + }) + + it('提取模式与 persona 注入开关持久化', () => { + expect(store.getExtractionMode()).toBe('llm') + store.setExtractionMode('rule') + expect(store.getExtractionMode()).toBe('rule') + store.setExtractionMode('off') + expect(store.getExtractionMode()).toBe('off') + + expect(store.isPersonaInjectionEnabled()).toBe(true) + store.setPersonaInjectionEnabled(false) + expect(store.isPersonaInjectionEnabled()).toBe(false) + store.setPersonaInjectionEnabled(true) + expect(store.isPersonaInjectionEnabled()).toBe(true) + }) + + it('清空全部记忆(clearAllMemory)', () => { + store.writeAtom({ content: '要被清空的记忆', type: 'fact', priority: 50, confirmed: true }) + store.addCorrection({ raw: '纠正', rule: '规则' }) + store.writePersona('# 用户画像\n\n## 用户\nTest') + expect(store.getMemoryStats().atomCount).toBeGreaterThan(0) + store.clearAllMemory() + const stats = store.getMemoryStats() + expect(stats.atomCount).toBe(0) + expect(stats.pendingCorrections).toBe(0) + expect(stats.personaExists).toBe(false) + }) +}) + +describe('memory/service 工作记忆', () => { + it('workingMemory 从 todo_context 生成摘要', async () => { + const service = await import('../memory/service') + store.writeAtom({ content: '正在开发 proactive memory', type: 'todo_context', priority: 80 }) + store.writeAtom({ content: '用户叫 Conrad', type: 'fact', priority: 60 }) + const wm = service.workingMemory() + expect(wm.items.length).toBeGreaterThan(0) + expect(wm.items.some((i) => i.includes('proactive memory'))).toBe(true) + expect(wm.items.some((i) => i.includes('Conrad'))).toBe(false) // fact 不应进入工作记忆 + expect(typeof wm.updatedAt).toBe('number') + }) + + it('workingMemory 无任务时返回空', async () => { + const service = await import('../memory/service') + // beforeEach 已清空目录;写一条 fact(非任务) + store.writeAtom({ content: '一条事实', type: 'fact', priority: 50 }) + const wm = service.workingMemory() + expect(wm.items).toEqual([]) + }) +}) diff --git a/apps/electron/src/main/lib/memory/memory-agent-tools.ts b/apps/electron/src/main/lib/memory/memory-agent-tools.ts new file mode 100644 index 000000000..dac628258 --- /dev/null +++ b/apps/electron/src/main/lib/memory/memory-agent-tools.ts @@ -0,0 +1,176 @@ +/** + * Memory 内置 MCP 工具(Claude runtime) + * + * 通过 Claude Agent SDK 的 createSdkMcpServer 暴露 Proma 长期记忆能力: + * - memory_search:检索记忆(只读) + * - memory_capture:主动沉淀一条记忆 + * - memory_stats:统计与待确认纠正(只读) + */ + +import { + stats, + searchAsync, + searchAsText, + captureCandidate, + corrections, + confirmCorrection, + rejectCorrection, +} from './service' +import { runAnalysisAndPersist } from '../suggest/service' +import type { MemoryAtomType } from '@proma/shared' + +interface MemoryAgentToolContext { + sessionId: string + workspaceSlug?: string +} + +type ZodModule = typeof import('zod') +const MEMORY_TYPES: MemoryAtomType[] = ['fact', 'preference', 'correction', 'sop', 'todo_context'] + +function isMemoryType(v: unknown): v is MemoryAtomType { + return typeof v === 'string' && (MEMORY_TYPES as string[]).includes(v) +} + +function buildMemorySchemas(z: ZodModule['z']) { + return { + search: { + query: z.string().describe('检索关键词:用户的自然语言问题或关键主题'), + limit: z.number().int().min(1).max(20).optional().describe('返回条数上限,默认 5'), + type: z.enum(['fact', 'preference', 'correction', 'sop', 'todo_context'] as const).optional().describe('按类型过滤'), + includeUnconfirmed: z.boolean().optional().describe('是否包含未确认条目(默认 false)'), + }, + capture: { + content: z.string().describe('要记忆的内容(简洁、自包含、可独立理解的一句话)'), + type: z.enum(['fact', 'preference', 'correction', 'sop', 'todo_context'] as const).optional().describe('记忆类型,默认 fact'), + priority: z.number().int().min(0).max(100).optional().describe('重要度 0-100,默认 50'), + }, + stats: {}, + corrections: { + status: z.enum(['pending', 'active', 'rejected', 'superseded'] as const).optional().describe('按状态过滤纠正'), + }, + confirmCorrection: { + id: z.string().describe('纠正 ID'), + }, + rejectCorrection: { + id: z.string().describe('纠正 ID'), + }, + } +} + +/** 注入 memory MCP server(Claude runtime) */ +export async function injectMemoryMcpServer( + sdk: typeof import('@anthropic-ai/claude-agent-sdk'), + mcpServers: Record>, + ctx: MemoryAgentToolContext, +): Promise { + const { z } = await import('zod') + const schemas = buildMemorySchemas(z) + + const server = sdk.createSdkMcpServer({ + name: 'memory', + version: '1.0.0', + tools: [ + sdk.tool( + 'memory_search', + '检索 Proma 长期记忆。适用于回忆用户偏好、历史事实、行为纠正、可复用流程等关键信息;当上方注入的 memory_context 不足时主动调用。', + schemas.search, + async (args) => { + const query = typeof args.query === 'string' ? args.query.trim() : '' + if (!query) throw new Error('query 必填') + const result = await searchAsync({ + query, + limit: typeof args.limit === 'number' ? args.limit : undefined, + type: isMemoryType(args.type) ? args.type : undefined, + includeUnconfirmed: args.includeUnconfirmed === true, + }) + return { + content: [{ type: 'text' as const, text: searchAsText({ query, limit: typeof args.limit === 'number' ? args.limit : undefined, includeUnconfirmed: args.includeUnconfirmed === true }) }], + details: result, + } + }, + { annotations: { readOnlyHint: true } }, + ), + sdk.tool( + 'memory_capture', + '主动沉淀当前对话上下文为一条长期记忆。适用于用户明确要求记住、提到长期偏好/纠正、或你判断该信息跨会话有用时。', + schemas.capture, + async (args) => { + const content = typeof args.content === 'string' ? args.content.trim() : '' + if (!content) throw new Error('content 必填') + const type = isMemoryType(args.type) ? args.type : 'fact' + const priority = typeof args.priority === 'number' ? args.priority : 50 + const result = captureCandidate( + { content, type, priority }, + { sessionId: ctx.sessionId, workspaceSlug: ctx.workspaceSlug }, + ) + return { + content: [{ type: 'text' as const, text: result.deduplicated ? '记忆已与已有条目合并更新。' : '记忆已保存。' }], + details: result, + } + }, + ), + sdk.tool( + 'memory_stats', + '查看 Proma 长期记忆统计:记忆数量、类型分布、场景数、待确认纠正。', + schemas.stats, + async () => { + const s = stats() + return { + content: [{ type: 'text' as const, text: JSON.stringify(s, null, 2) }], + details: s, + } + }, + { annotations: { readOnlyHint: true } }, + ), + sdk.tool( + 'memory_corrections', + '查看行为纠正候选列表(用户对 Agent 的改进要求)。', + schemas.corrections, + async (args) => { + const status = typeof args.status === 'string' ? args.status as 'pending' | 'active' | 'rejected' | 'superseded' : undefined + const items = corrections(status) + return { + content: [{ type: 'text' as const, text: items.length === 0 ? '暂无纠正记录。' : JSON.stringify(items, null, 2) }], + details: { corrections: items }, + } + }, + { annotations: { readOnlyHint: true } }, + ), + sdk.tool( + 'memory_confirm_correction', + '确认一条行为纠正候选生效(会同步沉淀为长期记忆)。', + schemas.confirmCorrection, + async (args) => { + const id = typeof args.id === 'string' ? args.id.trim() : '' + if (!id) throw new Error('id 必填') + const ok = confirmCorrection(id) + if (!ok) throw new Error(`纠正不存在: ${id}`) + return { content: [{ type: 'text' as const, text: '纠正已确认生效。' }] } + }, + ), + sdk.tool( + 'memory_reject_correction', + '拒绝一条行为纠正候选(不写入记忆)。', + schemas.rejectCorrection, + async (args) => { + const id = typeof args.id === 'string' ? args.id.trim() : '' + if (!id) throw new Error('id 必填') + const ok = rejectCorrection(id) + if (!ok) throw new Error(`纠正不存在: ${id}`) + return { content: [{ type: 'text' as const, text: '纠正已拒绝。' }] } + }, + ), + sdk.tool( + 'suggestion_analyze', + '分析工作模式:用 LLM 分析近期记忆,发现重复出现的工作模式(周期任务/SOP/待沉淀偏好),生成主动建议候选。适用于定时任务中定期运行,或用户主动要求分析工作模式时调用。', + {}, + async () => { + const added = await runAnalysisAndPersist() + return { content: [{ type: 'text' as const, text: `工作模式分析完成,新增 ${added} 条建议。` }] } + }, + ), + ], + }) + + mcpServers['memory'] = server as unknown as Record +} diff --git a/apps/electron/src/main/lib/memory/persona.test.ts b/apps/electron/src/main/lib/memory/persona.test.ts new file mode 100644 index 000000000..169d6cf86 --- /dev/null +++ b/apps/electron/src/main/lib/memory/persona.test.ts @@ -0,0 +1,85 @@ +/** + * Memory Persona 单元测试(纯逻辑,不依赖真实 LLM) + */ + +import { describe, expect, it } from 'bun:test' +import { cleanPersonaMarkdown, extractName, buildPersonaFromRules, extractPersonaSources } from '../memory/persona' +import { parsePersonaProfile } from '../memory/store' + +describe('memory/persona 纯函数', () => { + it('cleanPersonaMarkdown 剥离 markdown 围栏', () => { + const raw = '```markdown\n# 用户画像\n\n## 用户\nConrad\n```' + const cleaned = cleanPersonaMarkdown(raw) + expect(cleaned.startsWith('# 用户画像')).toBe(true) + expect(cleaned.includes('```')).toBe(false) + }) + + it('cleanPersonaMarkdown 剥离前置解释文字', () => { + const raw = '好的,以下是生成的画像:\n\n# 用户画像\n\n## 用户\nConrad' + const cleaned = cleanPersonaMarkdown(raw) + expect(cleaned.startsWith('# 用户画像')).toBe(true) + expect(cleaned.includes('好的')).toBe(false) + }) + + it('cleanPersonaMarkdown 原样保留干净 markdown', () => { + const raw = '# 用户画像\n\n## 用户\nConrad' + expect(cleanPersonaMarkdown(raw)).toBe(raw.trim()) + }) + + it('extractName 从自我介绍提取姓名', () => { + expect(extractName('我叫 Conrad,是独立开发者')).toBe('Conrad') + expect(extractName('我的名字是李明,做后端')).toBe('李明') + }) + + it('extractName 无姓名时返回截断内容', () => { + const result = extractName('用户喜欢 TypeScript') + expect(result.length).toBeGreaterThan(0) + }) + + it('buildPersonaFromRules 无记忆时返回 undefined', () => { + // 依赖磁盘,此处只验证函数存在且类型正确 + expect(typeof buildPersonaFromRules).toBe('function') + }) + + it('parsePersonaProfile 解析二级标题下的列表项', () => { + const raw = `# 用户画像 + +## 用户 +Conrad + +## 一句话定位 +独立开发者 + +## 长期偏好 +- 喜欢 TypeScript +- 先调研再动手 + +## 交互协议 +- 涉及密钥时用 .env + +## 演进轨迹 +- 2026-08:开始做 proactive memory` + const p = parsePersonaProfile(raw) + expect(p.name).toBe('Conrad') + expect(p.summary).toBe('独立开发者') + expect(p.preferences).toContain('喜欢 TypeScript') + expect(p.interactionRules).toContain('涉及密钥时用 .env') + expect(p.evolution).toContain('2026-08:开始做 proactive memory') + }) + + it('extractPersonaSources 提取带 src 标注的画像条目来源', () => { + const raw = `# 用户画像 + +## 长期偏好 +- 喜欢 TypeScript(src: atom_aaa,atom_bbb) +- 先调研再动手(src: atom_ccc) +- 无来源条目` + const entries = extractPersonaSources(raw) + const ts = entries.find((e) => e.text.includes('喜欢 TypeScript')) + expect(ts?.sources).toEqual(['atom_aaa', 'atom_bbb']) + const noSrc = entries.find((e) => e.text.includes('无来源条目')) + expect(noSrc?.sources).toEqual([]) + // text 应剔除 src 标注 + expect(ts?.text).not.toContain('src:') + }) +}) diff --git a/apps/electron/src/main/lib/memory/persona.ts b/apps/electron/src/main/lib/memory/persona.ts new file mode 100644 index 000000000..b43f3e582 --- /dev/null +++ b/apps/electron/src/main/lib/memory/persona.ts @@ -0,0 +1,151 @@ +/** + * Memory Persona — L3 用户画像生成与增量更新 + * + * 从已沉淀的 L1 atoms 用 LLM 生成/更新 persona.md: + * - 首次生成:基于全部(或代表性)atoms 构建画像 + * - 增量更新:基于已有 persona + 新 atoms,只追加/修正变化,不重写稳定内容 + * + * 设计原则(参考 TencentDB-Agent-Memory 的 persona 生成 + 安全要求): + * - 保留证据链:每条画像结论来自哪些 atoms(可审计) + * - 不虚构:只写 atoms 中明确出现的 + * - 稳定优先:增量更新时保留已确认内容,只处理新证据 + * - Markdown 白盒:人类可读、可编辑 + */ + +import { callLlm } from './extractor' +import { readAllAtoms, readPersonaRaw } from './store' +import type { MemoryAtom } from '@proma/shared' + +// ===== Prompt ===== + +const PERSONA_SYSTEM_PROMPT = `你是用户画像构建器。基于「长期记忆条目(L1 atoms)」构建或更新用户的长期画像(persona)。 + +规则: +1. 只使用提供的记忆条目中"明确出现"的信息,禁止推测、编造、补常识。 +2. 输出必须是 Markdown 格式,结构如下: + +# 用户画像 + +## 用户 +<称呼/姓名;未知则写"用户"> + +## 一句话定位 +<一句话概括用户身份/工作重点,30 字内> + +## 长期偏好 +- <偏好1> +- <偏好2> + +## 交互协议 +- <用户希望 Agent 如何工作,如"先调研再动手"、"优先中文";无则写"(暂无明确交互协议)"> + +## 演进轨迹 +- <重要阶段/变化,如"2026-08:开始做 proactive memory">;无则写"(暂无)" + +3. 偏好/协议每条 10-40 字,直接可执行,不要模棱两可。 +4. 如果提供已有 persona,合并时保留稳定内容,只更新有证据支撑的变化。 +5. **证据溯源(必须)**:每条偏好/协议/定位/演进条目末尾追加「(src: atom_xxx,atom_yyy)」, + src 必须是输入记忆条目标号(如 [1] 对应 id: atom_xxx)。如果某条结论无法对应任何输入条目,标注「(src: 未知)」。 + 不要把 src 当成画像内容本身,它是用于溯源的行内标注。 +6. 只输出 Markdown 本身,不要额外解释。` + +/** 从 atoms 构造 persona 生成的输入文本 */ +function formatAtomsForPersona(atoms: MemoryAtom[], maxAtoms = 40): string { + const lines = atoms.slice(0, maxAtoms).map((a, i) => { + return `${i + 1}. [${a.type}|pri=${a.priority}] ${a.content}(来源: ${new Date(a.createdAt).toISOString().slice(0, 10)},id: ${a.id})` + }) + return lines.join('\n') +} + +// ===== 生成 ===== + +/** + * 生成 persona.md(首次或无 LLM 时用规则版兜底)。 + * 返回生成的 markdown;失败时返回 undefined(调用方决定是否兜底)。 + */ +export async function generatePersona(opts: { existing?: string; maxAtoms?: number } = {}): Promise { + const atoms = readAllAtoms({ includeUnconfirmed: false }) + .filter((a) => a.type !== 'todo_context') // 任务上下文太临时,不进入画像 + .sort((a, b) => b.priority - a.priority) + + if (atoms.length === 0) return undefined + + const atomText = formatAtomsForPersona(atoms, opts.maxAtoms) + const existingText = opts.existing?.trim() + const userText = existingText + ? `已有 persona:\n---\n${existingText}\n---\n\n新记忆条目:\n${atomText}\n\n请合并更新 persona,保留稳定内容,只更新有证据的变化。` + : `记忆条目:\n${atomText}\n\n请生成初始 persona。` + + const raw = await callLlm(PERSONA_SYSTEM_PROMPT, userText, { temperature: 0.3, maxTokens: 4096 }) + if (!raw) return undefined + const cleaned = cleanPersonaMarkdown(raw) + return cleaned || undefined +} + +/** 清理 LLM 输出的 markdown(去掉围栏/多余空白,确保以 # 开头) */ +export function cleanPersonaMarkdown(raw: string): string { + let text = raw.trim() + const fence = text.match(/```(?:markdown|md)?\s*([\s\S]*?)```/) + if (fence) text = fence[1]?.trim() ?? text + // 去掉可能的前置解释(LLM 偶尔会在 markdown 前加一句"以下是...") + const hashIndex = text.indexOf('#') + if (hashIndex > 0 && hashIndex < 200) { + text = text.slice(hashIndex).trim() + } + return text +} + +// ===== 规则版兜底(无 LLM 时) ===== + +/** 无 LLM 时用规则拼一个基础 persona(从 atoms 提取姓名/偏好/协议) */ +export function buildPersonaFromRules(): string | undefined { + const atoms = readAllAtoms({ includeUnconfirmed: false }) + if (atoms.length === 0) return undefined + + const lines: string[] = ['# 用户画像', '', '## 用户', ''] + // 尝试找姓名 + const nameAtom = atoms.find((a) => /叫|姓名|名字|我是/i.test(a.content) && a.type === 'fact') + lines.push(nameAtom ? extractName(nameAtom.content) : '用户') + lines.push('', '## 一句话定位', '') + const fact = atoms.find((a) => a.type === 'fact') + lines.push(fact ? fact.content.slice(0, 40) : '(待 LLM 生成)') + lines.push('', '## 长期偏好', '') + const prefs = atoms.filter((a) => a.type === 'preference').slice(0, 5) + if (prefs.length > 0) for (const p of prefs) lines.push(`- ${p.content.slice(0, 50)}(src: ${p.id})`) + else lines.push('- (暂无明确偏好)') + lines.push('', '## 交互协议', '') + const corrections = atoms.filter((a) => a.type === 'correction').slice(0, 3) + if (corrections.length > 0) for (const c of corrections) lines.push(`- ${c.content.slice(0, 60)}(src: ${c.id})`) + else lines.push('- (暂无明确交互协议)') + lines.push('', '## 演进轨迹', '', '- (暂无)') + return lines.join('\n') +} + +/** 从"我叫 Conrad,独立开发者"类内容提取姓名 */ +export function extractName(content: string): string { + const match = content.match(/(?:叫|姓名是|名字是|我是)\s*([\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9_]{0,20})/) + if (match?.[1]) return match[1] + return content.slice(0, 20) +} + +/** + * 从 persona markdown 中提取每条画像条目的来源标注(证据溯源)。 + * 返回 { text, sources: atomId[] } 列表;无标注时 sources 为空数组。 + */ +export function extractPersonaSources(markdown: string): Array<{ text: string; sources: string[] }> { + const lines = markdown.split('\n') + const result: Array<{ text: string; sources: string[] }> = [] + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed.startsWith('- ')) continue + const srcMatch = trimmed.match(/(src:\s*([^)]+))\s*$/) + let text = trimmed.replace(/^- /, '').trim() + const sources: string[] = [] + if (srcMatch && srcMatch[1]) { + text = trimmed.slice(0, srcMatch.index ?? trimmed.length).replace(/^- /, '').trim() + sources.push(...srcMatch[1].split(',').map((s) => s.trim()).filter((s) => s.startsWith('atom_'))) + } + result.push({ text, sources }) + } + return result +} diff --git a/apps/electron/src/main/lib/memory/query-rewriter.test.ts b/apps/electron/src/main/lib/memory/query-rewriter.test.ts new file mode 100644 index 000000000..f3850afb1 --- /dev/null +++ b/apps/electron/src/main/lib/memory/query-rewriter.test.ts @@ -0,0 +1,47 @@ +/** + * Memory Query Rewriter 单元测试(纯函数) + */ + +import { describe, expect, it } from 'bun:test' +import { parseRewriteResponse, ruleExpandQuery } from '../memory/query-rewriter' + +describe('memory/query-rewriter 纯函数', () => { + it('parseRewriteResponse 解析标准 JSON 数组', () => { + const raw = '["分段锁", "ShopGo订单拆分锁"]' + const result = parseRewriteResponse(raw) + expect(result).toEqual(['分段锁', 'ShopGo订单拆分锁']) + }) + + it('parseRewriteResponse 剥离 markdown 围栏', () => { + const raw = '```json\n["分段锁", "分布式锁"]\n```' + const result = parseRewriteResponse(raw) + expect(result).toEqual(['分段锁', '分布式锁']) + }) + + it('parseRewriteResponse 过滤解释性输出', () => { + const raw = '["ShopGo 的具体锁类型未明确,需提供更多上下文。"]' + const result = parseRewriteResponse(raw) + expect(result).toEqual([]) + }) + + it('parseRewriteResponse 非 JSON 返回空', () => { + expect(parseRewriteResponse('不是 JSON')).toEqual([]) + expect(parseRewriteResponse('')).toEqual([]) + }) + + it('ruleExpandQuery 锁概念扩展出分段锁', () => { + const extra = ruleExpandQuery('ShopGo 订单拆分用什么锁?') + expect(extra).toContain('分段锁') + expect(extra).toContain('分布式锁') + }) + + it('ruleExpandQuery 工作习惯扩展出 lint/测试', () => { + const extra = ruleExpandQuery('我有什么工作习惯?') + expect(extra).toContain('lint') + }) + + it('ruleExpandQuery 无关查询不扩展', () => { + const extra = ruleExpandQuery('今天天气怎么样') + expect(extra).toEqual([]) + }) +}) diff --git a/apps/electron/src/main/lib/memory/query-rewriter.ts b/apps/electron/src/main/lib/memory/query-rewriter.ts new file mode 100644 index 000000000..337fe847d --- /dev/null +++ b/apps/electron/src/main/lib/memory/query-rewriter.ts @@ -0,0 +1,127 @@ +/** + * Memory Query Rewriter — LLM 查询改写 + * + * 解决小型 embedding 模型对中文近义词区分度不足的问题: + * 用户问句(如"ShopGo 订单拆分用什么锁?")通过 LLM 改写成 + * 2-3 个检索友好的查询(扩展同义词/明确意图),提升召回精度。 + * + * 设计: + * - 调 LLM(复用 callLlm),JSON 输出改写查询数组 + * - 缓存:相同 query 短时间不重复改写(LRU,避免每轮都调 LLM) + * - fail-open:LLM 不可用/失败时返回 [原查询](不阻塞) + * - 只用于异步路径(memory_search 工具 / IPC hybrid),per-message 注入保持同步 + */ + +import { callLlm } from './extractor' + +const REWRITE_SYSTEM_PROMPT = `你是检索查询改写器。把用户的自然语言问句改写为 2-3 个检索查询,用于在长期记忆中精确检索。 + +规则: +1. 输出必须 ONLY 是 JSON 字符串数组,不要任何其他文字、解释或 markdown 围栏。 +2. 格式严格如:["分段锁","ShopGo 订单拆分锁"] +3. 改写目标:提取问句中的关键实体 + 同义词/下位词(如"锁"→"分段锁/分布式锁/全局锁")。 +4. 查询要短(3-12 字),直接可检索,不要包含疑问词(什么/怎么/为什么/是否)。 +5. 第一个查询保留原问句核心实体,后续查询补充同义/近义/下位词表达。 +6. 禁止输出解释性句子,禁止输出"未明确/需更多上下文"之类的内容;只输出查询词。 + +示例: +用户问:ShopGo 订单拆分用什么锁? +输出:["ShopGo订单拆分锁","订单拆分 分布式锁","分段锁"]` + +/** 缓存条目 */ +const cache = new Map() +const CACHE_TTL_MS = 10 * 60 * 1000 +const MAX_CACHE_SIZE = 200 + +/** 解析 LLM 输出为查询数组(容错:剥离围栏 + 从任意文本提取 JSON 数组 + 丢弃解释性句子) */ +export function parseRewriteResponse(raw: string): string[] { + if (!raw) return [] + let text = raw.trim() + const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/) + if (fence) text = fence[1]?.trim() ?? text + const start = text.indexOf('[') + const end = text.lastIndexOf(']') + if (start === -1 || end <= start) return [] + try { + const parsed = JSON.parse(text.slice(start, end + 1)) + if (!Array.isArray(parsed)) return [] + return parsed + .filter((q): q is string => typeof q === 'string' && q.trim().length >= 2) + // 丢弃解释性/模糊输出(LLM 有时会输出“未明确/需更多上下文”而非查询词) + .filter((q) => !/未明确|需更多|无法|不确定|需要提供/.test(q)) + .map((q) => q.trim()) + .slice(0, 3) + } catch { + return [] + } +} + +/** + * 规则同义词补充(LLM 改写失败/不稳定时的稳定兜底): + * 从原查询中识别概念词,追加常见同义/下位词,保证近义词召回不依赖 LLM 输出稳定性。 + */ +const RULE_SYNONYMS: Array<{ pattern: RegExp; expansions: string[] }> = [ + { pattern: /锁/, expansions: ['分段锁', '分布式锁', '全局锁', '锁类型'] }, + { pattern: /语言|技术栈|用什么(?:语言|技术)/, expansions: ['typescript', 'rust', 'golang', 'python', 'java'] }, + { pattern: /编辑器/, expansions: ['prosemirror', 'editor'] }, + { pattern: /压测|性能测试/, expansions: ['k6', '压测脚本'] }, + { pattern: /缓存/, expansions: ['缓存key', '缓存隔离', 'cache'] }, + { pattern: /并行|并发/, expansions: ['worker', 'worker_threads', '并发控制'] }, + { pattern: /工作习惯|工作方式/, expansions: ['lint', '测试', '提交'] }, + { pattern: /编辑器/, expansions: ['prosemirror', '编辑器'] }, +] + +/** 规则补充查询词(在 LLM 改写结果上追加) */ +export function ruleExpandQuery(query: string): string[] { + const extra: string[] = [] + for (const { pattern, expansions } of RULE_SYNONYMS) { + if (pattern.test(query)) { + extra.push(...expansions) + } + } + return [...new Set(extra)].slice(0, 5) +} + +/** + * 改写用户问句为多个检索查询。 + * 缓存命中直接返回;LLM 失败时用规则同义词兜底(保证稳定)。 + */ +export async function rewriteQuery(query: string): Promise { + const trimmed = query.trim() + if (!trimmed) return [] + + // 缓存命中 + const cached = cache.get(trimmed) + if (cached && cached.expiresAt > Date.now()) { + return cached.queries + } + + // 短查询不值得改写(本身已是检索词) + if (trimmed.length < 4) return [trimmed] + + // 规则同义词兜底(稳定,不依赖 LLM) + const ruleExtra = ruleExpandQuery(trimmed) + + try { + const raw = await callLlm(REWRITE_SYSTEM_PROMPT, trimmed, { temperature: 0.2, maxTokens: 512, timeoutMs: 15_000 }) + const queries = raw ? parseRewriteResponse(raw) : [] + const combined = [...new Set([trimmed, ...queries, ...ruleExtra])].slice(0, 5) + // 只要有有效查询(原查询 + 至少 1 个补充)就缓存 + if (combined.length > 1) { + if (cache.size >= MAX_CACHE_SIZE) cache.clear() + cache.set(trimmed, { queries: combined, expiresAt: Date.now() + CACHE_TTL_MS }) + return combined + } + // 完全失败(无任何补充):返回原查询但不缓存(下次重试) + return [trimmed] + } catch { + // LLM 异常:规则兜底仍有效 + const combined = [...new Set([trimmed, ...ruleExtra])].slice(0, 5) + return combined.length > 1 ? combined : [trimmed] + } +} + +/** 清空缓存(测试用) */ +export function clearRewriteCache(): void { + cache.clear() +} diff --git a/apps/electron/src/main/lib/memory/recall.test.ts b/apps/electron/src/main/lib/memory/recall.test.ts new file mode 100644 index 000000000..6c70565f3 --- /dev/null +++ b/apps/electron/src/main/lib/memory/recall.test.ts @@ -0,0 +1,91 @@ +/** + * Memory Recall 纯函数单元测试 + * + * 不依赖磁盘/env,只测纯函数逻辑,确保参与全量测试无并发冲突。 + * 磁盘相关集成测试见 integration.test.ts(PROMA_MEMORY_DIR 隔离)。 + */ + +import { describe, expect, it } from 'bun:test' +import { ruleBoost, formatRecallContext, queryTerms, expandedQueryTerms, timeDecay } from '../memory/recall' + +describe('memory/recall 纯函数', () => { + it('ruleBoost 身份/偏好加权', () => { + const identity = ruleBoost({ content: '用户叫 Conrad 是独立开发者', type: 'fact', priority: 50 } as never) + expect(identity).toBeGreaterThan(0) + const pref = ruleBoost({ content: '用户喜欢 TypeScript', type: 'preference', priority: 50 } as never) + expect(pref).toBeGreaterThan(0) + const neutral = ruleBoost({ content: '普通事实记录', type: 'fact', priority: 30 } as never) + expect(neutral).toBe(0) + }) + + it('queryTerms 过滤停用词与噪声', () => { + const terms = queryTerms('帮我写一个排序算法') + expect(terms.includes('帮')).toBe(false) + expect(terms.includes('一')).toBe(false) + expect(terms.includes('排序')).toBe(true) + expect(terms.includes('算法')).toBe(true) + }) + + it('queryTerms 闲聊意图词“天气”被过滤,项目名仍可召回', () => { + // “今天天气怎么样”是闲聊:天气进入停用词,避免命中“天气小程序”项目记忆 + expect(queryTerms('今天天气怎么样').includes('天气')).toBe(false) + // 但“天气小程序还在维护吗”仍保留小程序/程序/维护等实体词 + const terms = queryTerms('天气小程序还在维护吗') + expect(terms.includes('小程序') || terms.includes('程序') || terms.includes('维护')).toBe(true) + }) + + it('expandedQueryTerms 同义词扩展', () => { + const terms = expandedQueryTerms('用什么编程语言') + expect(terms.some((t) => ['typescript', 'rust', '技术栈'].includes(t))).toBe(true) + }) + + it('formatRecallContext 空结果返回空串', () => { + const block = formatRecallContext({ query: 'x', hits: [], strategy: 'keyword', durationMs: 1 } as never) + expect(block).toBe('') + }) + + it('formatRecallContext 渲染命中强度标注', () => { + const result = { + query: 'test', + hits: [{ + atom: { id: 'a1', content: '测试记忆内容', type: 'fact' as const, priority: 60, createdAt: 1000, updatedAt: 1000, confirmed: true }, + score: 0.8, + matchedTerms: [], + }], + strategy: 'keyword' as const, + durationMs: 1, + } + const block = formatRecallContext(result) + expect(block).toContain('rel=high') + expect(block).toContain('测试记忆内容') + }) + + it('timeDecay:30 天后事实/偏好类权重减半,correction/sop 不衰减', () => { + const now = Date.now() + const dayMs = 86_400_000 + const freshFact = { content: '新事实', type: 'fact' as const, priority: 50, createdAt: now - dayMs, updatedAt: now, confirmed: true, id: 'f1' } + const oldFact = { content: '旧事实', type: 'fact' as const, priority: 50, createdAt: now - 30 * dayMs, updatedAt: now, confirmed: true, id: 'f2' } + const oldCorrection = { content: '旧规则', type: 'correction' as const, priority: 80, createdAt: now - 30 * dayMs, updatedAt: now, confirmed: true, id: 'c1' } + const oldSop = { content: '旧流程', type: 'sop' as const, priority: 80, createdAt: now - 30 * dayMs, updatedAt: now, confirmed: true, id: 's1' } + + // 30 天事实:约 0.5;1 天事实:接近 1 + expect(timeDecay(oldFact, now)).toBeLessThanOrEqual(0.55) + expect(timeDecay(oldFact, now)).toBeGreaterThanOrEqual(0.45) + expect(timeDecay(freshFact, now)).toBeGreaterThan(0.9) + // 规则类不衰减 + expect(timeDecay(oldCorrection, now)).toBe(1.0) + expect(timeDecay(oldSop, now)).toBe(1.0) + }) + + it('timeDecay:event 用更短半衰期(14 天减半,衰减快于普通事实)', () => { + const now = Date.now() + const dayMs = 86_400_000 + const oldEvent = { content: '旧事件', type: 'event' as const, priority: 50, createdAt: now - 14 * dayMs, updatedAt: now, confirmed: true, id: 'e1' } + const oldFact = { content: '旧事实', type: 'fact' as const, priority: 50, createdAt: now - 14 * dayMs, updatedAt: now, confirmed: true, id: 'f14' } + + // 14 天 event ≈ 0.5(比同天数的 fact ≈ 0.72 更低) + expect(timeDecay(oldEvent, now)).toBeLessThanOrEqual(0.55) + expect(timeDecay(oldEvent, now)).toBeGreaterThanOrEqual(0.45) + expect(timeDecay(oldEvent, now)).toBeLessThan(timeDecay(oldFact, now)) + }) +}) diff --git a/apps/electron/src/main/lib/memory/recall.ts b/apps/electron/src/main/lib/memory/recall.ts new file mode 100644 index 000000000..ab51ed92a --- /dev/null +++ b/apps/electron/src/main/lib/memory/recall.ts @@ -0,0 +1,549 @@ +/** + * Memory Recall — 主动回忆引擎 + * + * 从 L1 atoms 中按关键词检索相关记忆,输出带预算截断的注入上下文。 + * + * 检索策略(MVP): + * - keyword:简单中文/英文分词 + 倒排命中评分(BM25 简化版) + * - latest:空查询时返回最近 N 条(用于新会话冷启动注入) + * + * 召回预算:默认最多 5 条,超长内容截断;防止上下文膨胀。 + */ + +import type { MemoryAtom, MemorySearchHit, MemorySearchRequest, MemorySearchResult } from '@proma/shared' +import { readAllAtoms, isDuplicate } from './store' +import { getEmbeddingProvider, cosineSimilarity } from './embedding' +import { rewriteQuery } from './query-rewriter' + +/** 召回预算默认值 */ +export const DEFAULT_RECALL_LIMIT = 5 +export const MAX_RECALL_LIMIT = 20 +/** 单条召回内容最大字符数 */ +const MAX_RECALL_ATOM_CHARS = 300 +/** 注入块最大总字符数 */ +export const MAX_RECALL_BLOCK_CHARS = 2_000 + +// ===== 轻量分词 ===== + +const CJK_RE = /[\u4e00-\u9fff\u3400-\u4dbf]/ +const WORD_RE = /[A-Za-z0-9_]+/g + +/** + * 高频功能词(停用词):查询中出现时不参与检索,避免“帮我写排序算法”命中“写代码用TS”类误报。 + * 只影响查询侧;记忆内容侧不受影响(内容里的词仍可被检索)。 + */ +const STOP_WORDS = new Set([ + // 中文功能词 + '的', '了', '是', '我', '你', '他', '她', '它', '我们', '你们', '他们', + '在', '有', '和', '与', '及', '或', '也', '都', '很', '就', '还', '又', + '把', '被', '让', '给', '对', '从', '向', '到', '去', '来', '用', '想', + '吗', '呢', '吧', '啊', '哦', '呀', '嘛', '什么', '怎么', '怎样', '如何', + '为什么', '哪', '哪些', '谁', '哪个', '一个', '这个', '那个', '可以', + '能', '会', '要', '帮', '请', '请问', '一下', '看看', '帮我', '写', '做', + '说', '知道', '记得', '觉得', '应该', '可能', '大概', '现在', '今天', + // 中文单字量词/虚词(tokenize 会同时输出单字,需单独过滤) + '一', '两', '几', '个', '种', '些', '这', '那', '每', '各', '只', '下', '次', + '上', '里', '中', '外', '前', '后', '边', '处', '时', '候', '起', '请', '帮', '写', '做', + // 时间/高频名词单字(避免“今天股票行情”靠单字叠加突破门槛) + '今', '日', '天', '昨', '明', '股', '票', '行', '情', '涨', '跌', '盘', + // 时间双字词 + '今日', '昨天', '明天', '昨天', '股票', '行情', '股市', '大盘', + // 闲聊意图词(“今天天气怎么样”不该命中“天气小程序”项目记忆;项目名仍有小程序/程序等词可召回) + '天气', + // 英文功能词 + 'the', 'a', 'an', 'is', 'are', 'was', 'were', 'to', 'of', 'in', 'on', + 'for', 'with', 'and', 'or', 'but', 'i', 'you', 'he', 'she', 'it', 'we', + 'they', 'me', 'my', 'your', 'this', 'that', 'what', 'how', 'why', 'when', + 'can', 'could', 'would', 'should', 'do', 'does', 'did', 'have', 'has', +]) + +/** 是否为噪声 token(查询侧过滤):只过滤高频功能词;有意义的单字(名/谁/语等)保留,保证宽松召回 */ +function isStopToken(token: string): boolean { + if (STOP_WORDS.has(token)) return true + return false +} + +/** + * 简易分词:中文按单字 + 相邻双字(bigram)索引,英文按单词。 + * 足够用于关键词召回,不需要引入 jieba 等依赖。 + */ +export function tokenize(text: string): string[] { + const tokens: string[] = [] + // 英文/数字单词 + for (const m of text.matchAll(WORD_RE)) { + const w = m[0]?.toLowerCase() ?? '' + if (w.length >= 2) tokens.push(w) + } + // 中文字符 + bigram + const chars = text.split('').filter((c) => CJK_RE.test(c)) + for (let i = 0; i < chars.length; i++) { + const ch = chars[i] + const next = chars[i + 1] + if (ch) tokens.push(ch) + if (ch && next) tokens.push(ch + next) + } + return tokens +} + +/** 查询词集合(过滤停用词;单个中文字不参与) */ +export function queryTerms(query: string): string[] { + const raw = tokenize(query) + const filtered = raw.filter((t) => !isStopToken(t)) + return [...new Set(filtered)] +} + +/** + * 轻量同义词/概念扩展:解决“编程语言 → TypeScript”这类转喻问题。 + * 命中概念词时追加扩展词,扩大召回。MVP 用静态表,后续可换 embedding。 + */ +const SYNONYM_EXPANSIONS: Record = { + '编程': ['typescript', 'rust', 'python', 'golang', 'java', 'javascript', '语言', '代码', '技术栈'], + '语言': ['typescript', 'rust', 'python', 'golang', 'java', 'javascript', '代码', '技术栈'], + '技术栈': ['typescript', 'rust', 'python', 'golang', 'java', 'javascript', '编程', '语言'], + '名字': ['姓名', 'conrad', '叫'], + '姓名': ['名字', 'conrad', '叫'], + '项目': ['proma', 'proactive', '开发'], + '开发': ['proma', 'proactive', '项目'], +} + +/** 扩展查询词(保留原词 + 追加同义词) */ +export function expandedQueryTerms(query: string): string[] { + const terms = queryTerms(query) + const expanded = [...terms] + for (const term of terms) { + const syns = SYNONYM_EXPANSIONS[term] + if (syns) expanded.push(...syns) + } + return [...new Set(expanded)] +} + +/** 计算一条 atom 与查询的 BM25 简化得分 */ +function scoreAtom(atom: MemoryAtom, terms: string[], docFreq: Map, totalDocs: number): { score: number; matched: string[] } { + const text = `${atom.content} ${atom.type} ${atom.metadata?.tags ?? ''}`.toLowerCase() + const tokens = tokenize(text) + const tf = new Map() + for (const t of tokens) tf.set(t, (tf.get(t) ?? 0) + 1) + const avgLen = Math.max(1, tokens.length) + let score = 0 + const matched: string[] = [] + for (const term of terms) { + const freq = tf.get(term) ?? 0 + if (freq === 0) continue + const df = docFreq.get(term) ?? 1 + const idf = Math.log(1 + (totalDocs - df + 0.5) / (df + 0.5)) + const k1 = 1.2 + const b = 0.75 + const tfNorm = (freq * (k1 + 1)) / (freq + k1 * (1 - b + b * (avgLen / Math.max(1, totalDocs)))) + // 单个中文字匹配权重 0.15(仅作宽松兜底,避免单字噪声主导;bigram 才是主信号) + const charWeight = term.length === 1 && CJK_RE.test(term) ? 0.15 : 1 + score += idf * tfNorm * charWeight + matched.push(term) + } + return { score, matched } +} + +/** + * 相关度阈值(归一化分数,0-1):低于此值的命中视为弱相关/噪声,不注入。 + * 参考 ProactiveAgent 论文“误报是主动性头号杀手”:宁可少推、不推无关。 + */ +export const RECALL_MIN_SCORE = 0.12 + +/** + * 回忆意图词:查询含这些词且关键词 0 命中时,降级返回最近记忆(保 Recall)。 + * 避免语义问句(如“你还记得我是谁吗”)因关键词不匹配而过度沉默。 + */ +const RECALL_INTENT_WORDS = ['记得', '回忆', '认识', '知道', '还记得', '我是谁', '我叫什么', '我的名字', '上次', '之前', '前面'] + +/** 查询是否含回忆意图(用于 0 命中时的降级策略) */ +function hasRecallIntent(query: string): boolean { + const lower = query.toLowerCase() + return RECALL_INTENT_WORDS.some((w) => lower.includes(w)) +} +/** + * 归一化:把 BM25 分数映射到 0-1(除以当前查询的最大分)。 + * 让跨查询可比,从而可以用统一阈值过滤弱相关。 + */ +function normalizeScore(score: number, maxScore: number): number { + if (maxScore <= 0) return 0 + return score / maxScore +} + +// ===== 检索 ===== + +/** + * 规则加权(P7b):对身份/偏好类记忆在排序中加权,缓解“我是谁”类语义问句答错。 + * 加分项: + * - fact 类含用户身份关键词(我叫/我是/名字/独立开发者/从事)→ +0.15 + * - preference 类(用户偏好)→ +0.08 + * - 高优先级(≥70)→ +0.05 + */ +export function ruleBoost(atom: MemoryAtom): number { + let boost = 0 + if (atom.type === 'fact' && /我叫|我是|名字|姓名|独立开发者|从事|负责|做.*开发/.test(atom.content)) { + boost += 0.15 + } else if (atom.type === 'preference') { + boost += 0.08 + } + if ((atom.priority ?? 0) >= 70) boost += 0.05 + return boost +} + +// ===== 时间衰减(数据生命周期) ===== + +/** 半衰期天数:超过该天数,事实/偏好/任务类记忆权重减半 */ +export const MEMORY_HALF_LIFE_DAYS = 30 + +/** 事件类记忆半衰期天数:高时效,衰减更快(默认 14 天) */ +export const EVENT_HALF_LIFE_DAYS = 14 + +/** 行为规则类(correction/sop)不衰减:规则要稳定,不能因为时间而忘记 */ +const STABLE_TYPES = new Set(['correction', 'sop']) + +/** + * 时间衰减因子:0.5^(天数 / 半衰期)。 + * 稳定类型(correction/sop)恒为 1.0(不衰减);事件类(event)用更短半衰期; + * 其余类型按默认半衰期衰减。 + * 支持 MEMORY_HALF_LIFE_DAYS / EVENT_HALF_LIFE_DAYS 环境变量覆盖(测试/配置)。 + */ +export function timeDecay(atom: MemoryAtom, now = Date.now()): number { + if (STABLE_TYPES.has(atom.type)) return 1.0 + const days = Math.max(0, (now - atom.createdAt) / 86_400_000) + if (atom.type === 'event') { + const eventHalfLife = Number(process.env.EVENT_HALF_LIFE_DAYS) > 0 ? Number(process.env.EVENT_HALF_LIFE_DAYS) : EVENT_HALF_LIFE_DAYS + return Math.pow(0.5, days / eventHalfLife) + } + const halfLife = Number(process.env.MEMORY_HALF_LIFE_DAYS) > 0 ? Number(process.env.MEMORY_HALF_LIFE_DAYS) : MEMORY_HALF_LIFE_DAYS + return Math.pow(0.5, days / halfLife) +} + +/** 关键词检索(MVP,保持现有行为) */ +export function searchMemoriesByKeyword(request: MemorySearchRequest): MemorySearchResult { + const started = Date.now() + const query = request.query.trim() + const limit = Math.min(Math.max(request.limit ?? DEFAULT_RECALL_LIMIT, 1), MAX_RECALL_LIMIT) + + const allAtoms = readAllAtoms({ includeUnconfirmed: request.includeUnconfirmed === true }) + + if (!query) { + // 空查询:返回最近 N 条(供冷启动) + const hits: MemorySearchHit[] = allAtoms.slice(0, limit).map((atom) => ({ + atom, + score: 1, + matchedTerms: [], + })) + return { query, hits, strategy: 'latest', durationMs: Date.now() - started } + } + + const terms = expandedQueryTerms(query) + if (terms.length === 0) { + // 查询全是功能词(如“你还记得我是谁吗”):没有有效检索词,返回最近 N 条供参考 + const hits: MemorySearchHit[] = allAtoms.slice(0, limit).map((atom) => ({ + atom, + score: 0.5, + matchedTerms: [], + })) + return { query, hits, strategy: 'latest', durationMs: Date.now() - started } + } + + // 有效检索词过少(1 个):放宽阈值,避免过度沉默(ProactiveAgent 论文 P3:该沉默时沉默,但不该沉默时也不能漏) + const effectiveMinScore = terms.length <= 1 ? RECALL_MIN_SCORE * 0.3 : RECALL_MIN_SCORE + + const totalDocs = Math.max(1, allAtoms.length) + const docFreq = new Map() + for (const atom of allAtoms) { + const tokens = new Set(tokenize(`${atom.content} ${atom.type}`.toLowerCase())) + for (const t of tokens) docFreq.set(t, (docFreq.get(t) ?? 0) + 1) + } + + const scored = allAtoms + .map((atom) => ({ atom, ...scoreAtom(atom, terms, docFreq, totalDocs) })) + .filter((r) => r.score > 0) + .sort((a, b) => + (b.score * timeDecay(b.atom) + ruleBoost(b.atom)) - (a.score * timeDecay(a.atom) + ruleBoost(a.atom)) + || b.atom.createdAt - a.atom.createdAt) + + // 归一化 + 阈值过滤:把分数映射到 0-1,低于阈值的弱相关/噪声不返回 + const maxScore = scored.length > 0 ? scored[0]!.score : 0 + let hits: MemorySearchHit[] = scored + .map((r) => { + const hasStrongTerm = r.matched.some((t) => t.length >= 2) // 是否有 bigram/单词强命中 + let score = normalizeScore(r.score, maxScore) + // 只有单字弱命中(无任何强词):归一化会把“唯一弱命中”放大成 1.0, + // 此处对纯单字命中大幅降权,避免“帮我写排序算法”因单字“序”误伤天气/流程类记忆。 + if (!hasStrongTerm) score *= 0.1 + return { + atom: r.atom, + score, + rawScore: r.score, // 保留绝对分供 hybrid 真相关判断 + matchedTerms: r.matched, + } + }) + .filter((h) => h.score >= effectiveMinScore) + .slice(0, limit) + + // 0 命中但查询含回忆意图(“还记得我是谁吗”等语义问句):降级返回最近记忆,避免过度沉默 + // 排序:规则加权(身份/偏好优先),再按 priority 降序,再按时间 + if (hits.length === 0 && hasRecallIntent(query) && allAtoms.length > 0) { + const sorted = [...allAtoms].sort((a, b) => { + const boostDiff = ruleBoost(b) - ruleBoost(a) + if (boostDiff !== 0) return boostDiff + const factDiff = (b.type === 'fact' ? 1 : 0) - (a.type === 'fact' ? 1 : 0) + if (factDiff !== 0) return factDiff + return (b.priority ?? 0) - (a.priority ?? 0) || b.createdAt - a.createdAt + }) + hits = sorted.slice(0, Math.min(limit, 3)).map((atom) => ({ + atom, + score: 0.5, + matchedTerms: [], + })) + return { query, hits, strategy: 'fallback', durationMs: Date.now() - started } + } + + return { query, hits, strategy: 'keyword', durationMs: Date.now() - started } +} + +// ===== 注入上下文 ===== + +/** 截断单条记忆内容 */ +export function truncateAtom(atom: MemoryAtom): string { + if (atom.content.length <= MAX_RECALL_ATOM_CHARS) return atom.content + return `${atom.content.slice(0, MAX_RECALL_ATOM_CHARS)}…(已截断)` +} + +/** 将检索结果渲染为注入上下文(带预算截断 + 命中强度标注) */ +export function formatRecallContext(result: MemorySearchResult): string { + if (result.hits.length === 0) return '' + const lines = result.hits.map((hit) => { + const tag = hit.atom.type + const time = new Date(hit.atom.createdAt).toISOString().slice(0, 10) + // 命中强度:≥0.6 视为强相关,标注以帮助 Agent 判断可信度 + const strength = hit.score >= 0.6 ? 'rel=high' : hit.score >= 0.3 ? 'rel=mid' : 'rel=low' + return `- [${tag}|${time}|${strength}] ${truncateAtom(hit.atom)}` + }) + let block = lines.join('\n') + if (block.length > MAX_RECALL_BLOCK_CHARS) { + block = block.slice(0, MAX_RECALL_BLOCK_CHARS) + '\n…(记忆内容较多,已截断;可用 memory_search 工具检索更多)' + } + return block +} + +/** 一站式:给定用户消息文本,返回可注入的 memory 上下文块(空串表示无需注入) */ +export function buildMemoryContextForMessage(userText: string, opts: { limit?: number } = {}): string { + // per-message 注入保持同步低延迟:用 keyword + 规则加权(embedding 通道由 memory_search 工具异步提供) + const result = searchMemoriesByKeyword({ query: userText, limit: opts.limit ?? DEFAULT_RECALL_LIMIT }) + if (result.hits.length === 0) return '' + const body = formatRecallContext(result) + if (!body) return '' + return `\n${body}\n` +} + +// ===== 混合检索(P7:keyword + embedding + 规则加权) ===== + +/** + * RRF 融合:按排名倒数加权合并多路检索结果。 + * k=60 是 RRF 论文默认值。 + */ +function rrfMerge(lists: Array>, k = 60): Map { + const merged = new Map() + for (const list of lists) { + list.forEach((item, rank) => { + const existing = merged.get(item.atom.id) + const contribution = 1 / (k + rank + 1) + if (existing) { + existing.score += contribution + existing.sources += 1 + } else { + merged.set(item.atom.id, { atom: item.atom, score: contribution, sources: 1 }) + } + }) + } + return merged +} + +/** + * 混合检索: + * 1. 关键词 BM25 排序(含误报阈值) + * 2. embedding 余弦相似度排序(语义) + * 3. 规则加权(身份/偏好优先) + * 4. RRF 融合 + 归一化 + */ +export async function searchMemoriesHybrid(request: MemorySearchRequest): Promise { + const started = Date.now() + const query = request.query.trim() + const limit = Math.min(Math.max(request.limit ?? DEFAULT_RECALL_LIMIT, 1), MAX_RECALL_LIMIT) + const allAtoms = readAllAtoms({ includeUnconfirmed: request.includeUnconfirmed === true }) + + if (!query || allAtoms.length === 0) { + // 空查询:返回最近 N 条 + const hits: MemorySearchHit[] = allAtoms.slice(0, limit).map((atom) => ({ + atom, + score: 1, + matchedTerms: [], + })) + return { query, hits, strategy: 'latest', durationMs: Date.now() - started } + } + + // 通道 1:关键词(精确匹配优先,权重高) + const kwResult = searchMemoriesByKeyword({ query, limit: Math.max(limit, 10), includeUnconfirmed: request.includeUnconfirmed }) + const kwIds = new Set(kwResult.hits.map((r) => r.atom.id)) + // 只保留高分 kw(≥0.6 精确匹配);低分弱词匹配(0.2-0.5 噪声)不占 RRF 名额, + // 避免 kw 命中过多挤掉 rw/embedding 的正确答案(子代理审查发现) + // 只保留高分 kw(绝对分 ≥1.0 真相关);低分弱词匹配不占 RRF 名额。 + // 用绝对分(rawScore)而非归一化分,避免“查询与库整体弱相关时弱命中被抬成满分”绕过过滤 + const kwList = kwResult.hits.filter((h) => (h.rawScore ?? h.score) >= 1.0).map((h) => ({ atom: h.atom, score: h.score })) + + // 通道 1.5:LLM 查询改写(近义词/同义表达补充召回) + // 仅当原查询有真相关(kwList 非空)时才启用改写——否则无关查询(如“帮我写排序算法” + // 与库无关)会被 LLM 改写发散成“并行/worker”注入无关记忆。改写是“扩展”,不是“凭空召回”。 + const rwHitIdsAll = new Set() + const rwRealIds = new Set() // 绝对分 ≥1.0 的真相关 rw 命中(用于权重判断) + let rwList: Array<{ atom: MemoryAtom; score: number }> = [] + try { + // 只有原查询有真相关时才改写扩展(gate:kwList 非空),否则跳过改写避免发散注入 + if (kwList.length > 0) { + const rewritten = await rewriteQuery(query) + if (rewritten.length > 1) { + const rwSeen = new Set() + for (const rw of rewritten) { + if (rw === query || rwSeen.has(rw)) continue + rwSeen.add(rw) + const rwResult = searchMemoriesByKeyword({ query: rw, limit: Math.max(limit, 8), includeUnconfirmed: request.includeUnconfirmed }) + for (const h of rwResult.hits) { + rwHitIdsAll.add(h.atom.id) // 所有 rw 命中都标记(用于观察) + if ((h.rawScore ?? h.score) >= 1.0) rwRealIds.add(h.atom.id) // 只有绝对分≥1.0 才算真相关 + // 进 rwList 需要绝对分门槛(≥1.0),避免弱改写命中放大噪声 + if ((h.rawScore ?? h.score) < 1.0) continue + rwList.push({ atom: h.atom, score: h.score * 0.8 }) + } + } + // 去重 + 排序 + const seen = new Set() + rwList = rwList.filter((r) => { if (seen.has(r.atom.id)) return false; seen.add(r.atom.id); return true }) + .sort((a, b) => b.score - a.score) + .slice(0, Math.max(limit, 8)) + } // end if (rewritten.length > 1) + } // end if (kwList.length > 0) + } catch (error) { + console.warn('[Memory] 查询改写失败,跳过补充召回:', error instanceof Error ? error.message : error) + } + const kwPlusRwIds = new Set([...kwIds, ...rwList.map((r) => r.atom.id)]) + + // 通道 2:embedding(语义,仅补充 keyword/改写未覆盖的) + // 只有原查询有真相关(kwList 非空)时才启用 embedding——避免无关查询被语义噪声注入 + const provider = getEmbeddingProvider() + let embList: Array<{ atom: MemoryAtom; score: number }> = [] + if (provider && kwList.length > 0) { + const queryVec = await provider.embed(query) + if (queryVec) { + const batch = await provider.embedBatch(allAtoms.slice(0, 80).map((a) => a.content.slice(0, 200))) + const scored: Array<{ atom: MemoryAtom; score: number }> = [] + for (let i = 0; i < batch.length; i++) { + const vec = batch[i] + if (!vec) continue + const sim = cosineSimilarity(queryVec, vec) + // 阈值 0.68:抑制 embedding 误配(如“批量审查做并行” vs “压测错峰运行” sim=0.64)抢占名额 + if (sim > 0.68) scored.push({ atom: allAtoms[i]!, score: sim }) + } + // 只保留 keyword/改写未命中的(避免 embedding 干扰精确匹配) + embList = scored + .filter((r) => !kwPlusRwIds.has(r.atom.id)) + .sort((a, b) => b.score - a.score) + .slice(0, Math.max(limit, 15)) // 保留更多语义候选(embTop 保底取前 3) + } + } + + // 通道 3:规则加权(仅身份/偏好类进入补充通道;且必须与查询有词命中—— + // 避免“错峰运行”等 preference 在任意 kw 真相关查询下全量霸榜) + const ruleKwIds = new Set(kwResult.hits.map((h) => h.atom.id)) + const ruleList = kwList.length > 0 + ? [...allAtoms] + .map((atom) => ({ atom, score: ruleBoost(atom) })) + .filter((r) => r.score >= 0.08 && ruleKwIds.has(r.atom.id)) // 必须与查询词命中 + .sort((a, b) => b.score - a.score) + .slice(0, Math.max(limit, 5)) + : [] + + // RRF 融合(含改写查询补充通道) + const merged = rrfMerge([kwList, rwList, embList, ruleList]) + const maxScore = merged.size > 0 ? Math.max(...[...merged.values()].map((v) => v.score)) : 0 + + // 精确匹配优先:原 keyword 高分命中 > 改写命中 > embedding 语义命中 > 其他规则补充 + const kwHitIds = new Set(kwList.map((r) => r.atom.id)) // 只算高分 kw(≥0.6) + const rwHitIds = rwRealIds // 只有绝对分 ≥1.0 的真相关改写命中,用于多源加权提升 + const embHitIds = new Set(embList.map((r) => r.atom.id)) + + // 多源一致性融合:每个候选按“最高命中来源”加权(kw 最可信 > rw > emb > rule) + const sourceWeight = new Map() + for (const item of merged.values()) { + let w = 0 + if (kwHitIds.has(item.atom.id)) w = Math.max(w, 1.0) + if (rwHitIds.has(item.atom.id)) w = Math.max(w, 1.15) // rw 是 LLM 精确改写,可信度略高于 kw 弱命中 + if (embHitIds.has(item.atom.id)) w = Math.max(w, 0.4) + if (ruleBoost(item.atom) > 0) w = Math.max(w, 0.2) + sourceWeight.set(item.atom.id, w) + } + + const hits: MemorySearchHit[] = [...merged.values()] + .map((item) => { + const w = sourceWeight.get(item.atom.id) ?? 0 + const rrfNorm = maxScore > 0 ? item.score / maxScore : 0 + // 加法融合:源权重主导(kw/rw 命中者显著领先),RRF 做同权重内的微调 + const finalScore = w + rrfNorm * 0.3 + return { + atom: item.atom, + score: finalScore, + matchedTerms: [], + } + }) + .sort((a, b) => b.score - a.score) + .slice(0, limit) + + // 阈值过滤:加法融合后分数 = 源权重 + RRF 微调 + let filtered = hits.filter((h) => h.score >= 0.35) + + // 同主题冗余降权(P0):多条内容同主题的记忆(如 4 条“批量审查模式”)霸占 top-N, + // 把正确答案(worker 实现)挤到第 6+。按“项目+核心词”聚类,同簇只保留最高分 1-2 条。 + if (filtered.length > 1) { + // 提取记忆的主题键:项目名 + 内容中最高频的 2 个关键词 + // 同主题聚类:项目名 + 内容核心实体(英文词/技术名优先) + const clusterKey = (atom: MemoryAtom): string => { + const content = atom.content.toLowerCase() + const project = ['codelens', 'shopgo', 'docflow', 'proma'] + .find((p) => content.includes(p)) ?? '' + // 英文技术词(worker/crdt/prosemirror/k6/redis 等)是最强主题信号 + const enWords = content.match(/[a-z][a-z0-9_]{2,}/g) ?? [] + // 中文业务名词:去掉常见动词/虚词后取 2 个(非全局正则避免 lastIndex 状态问题) + const noise = /用户|已经|完成|需要|要求|实现|使用|做了|计划|准备|今天|今日|支持|用于|增加|添加|优化|解决|处理|避免|进行|开始|正在|问题|性能|功能|项目|方案|代码|方式|方法|时候|可以|会|要|能|到|和|与|在|把|被|让|给/ + const zhWords = (content.match(/[\u4e00-\u9fff]{2,4}/g) ?? []) + .filter((w) => !noise.test(w)) + .sort((a, b) => b.length - a.length) + .slice(0, 2) + const entities = [...new Set([...enWords.slice(0, 2), ...zhWords])].join('|') + return `${project}:${entities}` + } + const seenCluster = new Map() // cluster -> 已保留的高分 + const kept: typeof filtered = [] + for (const h of filtered) { + const key = clusterKey(h.atom) + const existing = seenCluster.get(key) + if (existing !== undefined && existing >= 2) { + // 该主题簇已有 2 条高分,其余降权 + h.score = 0.1 + } else if (existing !== undefined) { + seenCluster.set(key, existing + 1) + kept.push(h) + } else { + seenCluster.set(key, 1) + kept.push(h) + } + } + filtered = kept.filter((h) => h.score >= 0.35).sort((a, b) => b.score - a.score).slice(0, limit) + } + + // 只有当 kw 有真相关(绝对分 ≥1.0)时才 fallback 到 kw;否则返回空(无相关,不注入噪声) + const hasRealKw = kwResult.hits.some((h) => (h.rawScore ?? h.score) >= 1.0) + if (filtered.length === 0 && hasRealKw) { + return kwResult + } + return { query, hits: filtered, strategy: 'hybrid', durationMs: Date.now() - started } +} diff --git a/apps/electron/src/main/lib/memory/scene.test.ts b/apps/electron/src/main/lib/memory/scene.test.ts new file mode 100644 index 000000000..925db65f3 --- /dev/null +++ b/apps/electron/src/main/lib/memory/scene.test.ts @@ -0,0 +1,114 @@ +/** + * Memory Scene 单元测试 — L2 场景聚类与热度 + */ + +import { describe, expect, test } from 'bun:test' +import type { MemoryAtom } from '@proma/shared' +import { atomTopicTerms, clusterAtomsToScenes, sceneHeat, SCENE_MERGE_MIN_SHARED } from '../memory/scene' + +function makeAtom(partial: Partial & { content: string; type?: MemoryAtom['type'] }): MemoryAtom { + const now = Date.now() + return { + id: `atom_${Math.random().toString(36).slice(2, 8)}`, + content: partial.content, + type: partial.type ?? 'fact', + priority: partial.priority ?? 50, + createdAt: partial.createdAt ?? now, + updatedAt: partial.updatedAt ?? (partial.createdAt ?? now), + confirmed: partial.confirmed ?? true, + ...(partial.fingerprint ? { fingerprint: partial.fingerprint } : {}), + } +} + +function toMap(atoms: MemoryAtom[]): Map { + return new Map(atoms.map((a) => [a.id, a])) +} + +describe('memory/scene: 主题词提取', () => { + test('提取中文 bigram 与英文单词,过滤单字', () => { + const atom = makeAtom({ content: 'CodeLens 项目用 TypeScript 开发' }) + const terms = atomTopicTerms(atom) + // bigram(codelens? 英文词:codelens/typescript)与中文 bigram 都有 + expect(terms.some((t) => t.includes('code') || t === 'codelens')).toBe(true) + expect(terms.includes('typescript')).toBe(true) + expect(terms.includes('项目')).toBe(true) + expect(terms.includes('开发')).toBe(true) + // 单字不参与(“用”“发”等被过滤) + expect(terms.some((t) => t.length === 1 && /[\u4e00-\u9fff]/.test(t))).toBe(false) + }) +}) + +describe('memory/scene: 主题聚类', () => { + test('同主题 atoms 归并到一个场景', () => { + const atoms = [ + makeAtom({ content: 'CodeLens 项目用 TypeScript 开发 AST 分析器' }), + makeAtom({ content: 'CodeLens 的 AST 分析器性能优化' }), + makeAtom({ content: '今天天气很好' }), + ] + const clusters = clusterAtomsToScenes(atoms) + // 前两条同主题(CodeLens/AST)应合并;天气独立 + const codeLens = clusters.find((c) => c.atomIds.length >= 2) + expect(codeLens).toBeDefined() + expect(clusters.length).toBeGreaterThanOrEqual(2) + }) + + test('不同主题不合并', () => { + const atoms = [ + makeAtom({ content: 'CodeLens 项目开发' }), + makeAtom({ content: 'ShopGo 订单拆分' }), + ] + const clusters = clusterAtomsToScenes(atoms) + expect(clusters.length).toBe(2) + }) + + test('minShared 可调:阈值提高后更少合并', () => { + const atoms = [ + makeAtom({ content: 'CodeLens 项目用 TypeScript' }), + makeAtom({ content: 'CodeLens 的性能瓶颈' }), + ] + const loose = clusterAtomsToScenes(atoms, { minShared: 1 }) + const strict = clusterAtomsToScenes(atoms, { minShared: 3 }) + expect(loose.length).toBe(1) + expect(strict.length).toBe(2) + }) +}) + +describe('memory/scene: 热度', () => { + test('correction/sop 稳定不衰减 → 热度不低于新鲜事实', () => { + const now = Date.now() + const oldCorrection = makeAtom({ content: '以后报告进度先给结论', type: 'correction', createdAt: now - 60 * 86_400_000 }) + const freshFact = makeAtom({ content: 'CodeLens 今天发版', createdAt: now - 1 * 86_400_000 }) + const cluster1 = clusterAtomsToScenes([oldCorrection])[0]! + const cluster2 = clusterAtomsToScenes([freshFact])[0]! + const h1 = sceneHeat(cluster1, toMap([oldCorrection]), now) + const h2 = sceneHeat(cluster2, toMap([freshFact]), now) + // correction 60 天不衰减 = 1.0;fresh fact 衰减很小 ≈ 0.977 + expect(h1).toBeGreaterThanOrEqual(h2) + }) + + test('同场景 atom 越多热度越高', () => { + const atoms = [ + makeAtom({ content: 'CodeLens 项目用 TypeScript 开发 AST 分析器' }), + makeAtom({ content: 'CodeLens 项目的 AST 分析器做性能优化' }), + makeAtom({ content: 'CodeLens 项目的 AST 分析器写单元测试' }), + ] + // 三条内容共享多个主题词(codelens/项目/ast/分析器),应合并为一个场景 + const single = clusterAtomsToScenes([atoms[0]!])[0]! + const multi = clusterAtomsToScenes(atoms)[0]! + expect(multi.atomIds.length).toBe(3) + const h1 = sceneHeat(single, toMap([atoms[0]!])) + const hMulti = sceneHeat(multi, toMap(atoms)) + expect(hMulti).toBeGreaterThan(h1) + }) + + test('空场景热度为 0', () => { + expect(sceneHeat({ title: 'x', atomIds: [], terms: [] }, new Map())).toBe(0) + }) +}) + +// 保持 SCENE_MERGE_MIN_SHARED 常量可被测试引用(防止误改阈值破坏语义) +describe('memory/scene: 常量', () => { + test('默认合并阈值为 2', () => { + expect(SCENE_MERGE_MIN_SHARED).toBe(2) + }) +}) diff --git a/apps/electron/src/main/lib/memory/scene.ts b/apps/electron/src/main/lib/memory/scene.ts new file mode 100644 index 000000000..f98075765 --- /dev/null +++ b/apps/electron/src/main/lib/memory/scene.ts @@ -0,0 +1,181 @@ +/** + * Memory Scene — L2 场景聚合与热度 + * + * 从 L1 atoms 按主题聚类出「场景块」(scene)并计算热度: + * - 场景 = 用户一段时间内的关注主题(如"CodeLens 开发"、"发版流程") + * - 热度 heat = 命中 atom 数 × 时间衰减权重(复用 half-life)× 抑制因子 + * - 热度是主动性的"时钟":高频 ignore 的建议对应场景会被抑制(反馈回流闭环) + * + * 设计参考: + * - TencentDB Agent Memory L2 Scene(主题聚合 + 场景热度) + * - MemOS Next-Scene Prediction(场景 = 主动提议的时机信号) + * - MineContext 六种上下文类型(activity/intent/semantic/state/procedural/entity) + * + * 实现原则(对齐 Proma 风格):纯函数可测;实时聚类(atoms 量小,无需缓存); + * 不引入数据库/向量库依赖;只读不改写 atoms。 + */ + +import { randomUUID } from 'node:crypto' +import type { MemoryAtom, SceneBlock } from '@proma/shared' +import { readAllAtoms, writeSceneBlock, readAllScenes } from './store' +import { tokenize, timeDecay } from './recall' +import { getSuppressedSuggestionKeys } from '../suggest/service' + +/** 场景聚合的时间窗口(默认 7 天:近期关注 = 当前场景) */ +export const SCENE_WINDOW_DAYS = 7 + +/** 聚类相似度阈值:共享 ≥2 个非停用词 bigram/单词即视为同场景 */ +export const SCENE_MERGE_MIN_SHARED = 2 + +/** 场景原子数上限(避免单一场景无限膨胀) */ +export const SCENE_MAX_ATOMS = 30 + +/** 返回场景数上限 */ +export const SCENE_MAX_SCENES = 8 + +// ===== 主题词提取 ===== + +/** 提取 atom 的主题词:过滤停用词后的 token(bigram/单词),排除单字噪声 */ +export function atomTopicTerms(atom: MemoryAtom): string[] { + const tokens = tokenize(`${atom.content} ${atom.metadata?.tags ?? ''}`.toLowerCase()) + // 只保留长度 ≥2 的 token(bigram 或英文单词),过滤单字 + const meaningful = tokens.filter((t) => t.length >= 2) + return [...new Set(meaningful)] +} + +// ===== 聚类(纯函数) ===== + +export interface SceneCluster { + title: string + atomIds: string[] + terms: string[] +} + +/** + * 贪心聚类:按时间从新到旧遍历 atoms,与已有场景算共享词数; + * 共享词 ≥ SCENE_MERGE_MIN_SHARED 则归入最匹配场景,否则新开场景。 + * 返回聚类结果(未排序,heat 由调用方计算)。 + */ +export function clusterAtomsToScenes(atoms: MemoryAtom[], opts: { minShared?: number } = {}): SceneCluster[] { + const minShared = opts.minShared ?? SCENE_MERGE_MIN_SHARED + const scenes: SceneCluster[] = [] + + // 新到旧 + const sorted = [...atoms].sort((a, b) => b.createdAt - a.createdAt) + for (const atom of sorted) { + const terms = atomTopicTerms(atom) + if (terms.length === 0) continue + + // 找共享词最多的场景 + let bestIdx = -1 + let bestShared = 0 + for (let i = 0; i < scenes.length; i++) { + const shared = terms.filter((t) => scenes[i]!.terms.includes(t)).length + if (shared > bestShared) { + bestShared = shared + bestIdx = i + } + } + + if (bestIdx >= 0 && bestShared >= minShared && scenes[bestIdx]!.atomIds.length < SCENE_MAX_ATOMS) { + const scene = scenes[bestIdx]! + scene.atomIds.push(atom.id) + scene.terms = [...new Set([...scene.terms, ...terms])] + } else { + scenes.push({ title: atom.content.slice(0, 24), atomIds: [atom.id], terms }) + } + } + return scenes +} + +// ===== 热度计算 ===== + +/** + * 场景热度:0-100。 + * - base = 场景内 atom 的 timeDecay 之和(correction/sop 稳定不衰减,其余按半衰期) + * - 映射:sumDecay × 20 → 单条新鲜记忆约 20,多原子叠加,封顶 100 + * - 抑制因子:场景标题/主题词命中高频 ignore 建议 → ×0.5(反馈回流闭环) + */ +export function sceneHeat(scene: SceneCluster, atomsById: Map, now = Date.now()): number { + const members = scene.atomIds + .map((id) => atomsById.get(id)) + .filter((a): a is MemoryAtom => !!a) + if (members.length === 0) return 0 + + const sumDecay = members.reduce((sum, a) => sum + timeDecay(a, now), 0) + let heat = Math.min(100, Math.round(sumDecay * 20)) + + // 高频 ignore 抑制:场景主题词命中被抑制建议关键词 → 减半 + const suppressedKeys = getSuppressedSuggestionKeys() + if (suppressedKeys.length > 0) { + const sceneText = scene.title.toLowerCase() + const hit = suppressedKeys.some((key) => { + const keyWords = tokenize(key).filter((t) => t.length >= 2) + return keyWords.some((k) => sceneText.includes(k)) + }) + if (hit) heat = Math.round(heat * 0.5) + } + return heat +} + +// ===== 对外 API ===== + +/** + * 计算最近 N 天的热点场景(实时聚类,不写盘)。 + * 返回按热度降序的 SceneBlock 列表(含 heat)。 + */ +export function hotScenes(opts: { windowDays?: number; limit?: number; now?: number } = {}): SceneBlock[] { + const windowDays = opts.windowDays ?? SCENE_WINDOW_DAYS + const limit = Math.min(opts.limit ?? SCENE_MAX_SCENES, SCENE_MAX_SCENES) + const now = opts.now ?? Date.now() + + const atoms = readAllAtoms({ includeUnconfirmed: false }) + .filter((a) => a.type !== 'todo_context') // 临时任务不构成场景 + .filter((a) => now - a.createdAt <= windowDays * 86_400_000) + + if (atoms.length === 0) return [] + + const clusters = clusterAtomsToScenes(atoms) + const atomsById = new Map(atoms.map((a) => [a.id, a])) + + return clusters + .map((c) => { + const members = c.atomIds + .map((id) => atomsById.get(id)) + .filter((a): a is MemoryAtom => !!a) + const heat = sceneHeat(c, atomsById, now) + const updatedAt = members.reduce((max, a) => Math.max(max, a.updatedAt), members[0]?.updatedAt ?? now) + return { + id: `scene_${randomUUID().slice(0, 8)}`, + title: c.title, + atomIds: c.atomIds, + heat, + createdAt: now, + updatedAt, + } satisfies SceneBlock + }) + .sort((a, b) => b.heat - a.heat || b.updatedAt - a.updatedAt) + .slice(0, limit) +} + +/** 持久化热点场景到 scenes/ 目录(供审计/未来跨会话复用) */ +export function persistHotScenes(opts: { windowDays?: number; now?: number } = {}): SceneBlock[] { + const scenes = hotScenes(opts) + for (const scene of scenes) { + const markdown = [ + `# ${scene.title}`, + '', + `> heat: ${scene.heat} · atoms: ${scene.atomIds.length}`, + '', + ...scene.atomIds.map((id) => `- atom: \`${id}\``), + '', + ].join('\n') + writeSceneBlock(scene, markdown) + } + return scenes +} + +/** 读取已持久化的场景(按热度降序) */ +export function readHotScenes(): SceneBlock[] { + return readAllScenes().sort((a, b) => b.heat - a.heat || b.updatedAt - a.updatedAt) +} diff --git a/apps/electron/src/main/lib/memory/service.ts b/apps/electron/src/main/lib/memory/service.ts new file mode 100644 index 000000000..6cf8e2cd9 --- /dev/null +++ b/apps/electron/src/main/lib/memory/service.ts @@ -0,0 +1,525 @@ +/** + * Memory Service — 长期记忆编排层 + * + * 对外暴露的稳定 API,供 prompt 构建器、内置 MCP 工具、会话结束钩子使用。 + * 只做编排与降级,不包含 LLM 调用细节(extractor 负责)与存储细节(store 负责)。 + */ + +import { + getMemoryStats, + isMemoryEnabled, + setMemoryEnabled, + readAllAtoms, + writeAtomWithDedup, + writeAtom, + addCorrection, + listCorrections, + updateCorrectionStatus, + readPersonaRaw, + isPersonaTraceable, + parsePersonaProfile, + writePersona, + readAllScenes, + getAtomById, + listPendingAtoms, + listAtomsPaged, + confirmAtom, + deleteAtom, + getExtractionMode, + setExtractionMode, + isPersonaInjectionEnabled, + setPersonaInjectionEnabled, + deletePersona, + clearAllMemory, + appendMemoryLog, + markExtractionCompleted, +} from './store' +import { hotScenes as computeHotScenes } from './scene' +import { + buildMemoryContextForMessage, + searchMemoriesByKeyword, + searchMemoriesHybrid, + formatRecallContext, + DEFAULT_RECALL_LIMIT, +} from './recall' +import { extractFromMessages, isMemoryLlmConfigured, callLlm } from './extractor' +import { generatePersona, buildPersonaFromRules, extractPersonaSources } from './persona' +import type { + MemoryAtom, + MemoryAtomType, + MemoryCandidate, + MemoryCaptureInput, + MemorySearchRequest, + MemorySearchResult, + MemoryStats, + PersonaProfile, +} from '@proma/shared' + +// ===== 基础状态 ===== + +export function memoryEnabled(): boolean { + return isMemoryEnabled() +} + +/** 当前提取模式 */ +export function extractionMode(): 'llm' | 'rule' | 'off' { + return getExtractionMode() +} + +/** 设置提取模式 */ +export function setExtractionModeState(mode: 'llm' | 'rule' | 'off'): void { + setExtractionMode(mode) + appendMemoryLog(`提取模式切换为: ${mode}`) +} + +/** persona 注入开关状态 */ +export function personaInjectionEnabled(): boolean { + return isPersonaInjectionEnabled() +} + +/** 设置 persona 注入开关 */ +export function setPersonaInjectionEnabledState(enabled: boolean): void { + setPersonaInjectionEnabled(enabled) + appendMemoryLog(enabled ? '开启 persona 画像注入' : '关闭 persona 画像注入(不再随系统提示发送)') +} + +/** 删除 persona 画像(用户控制) */ +export function removePersona(): boolean { + const ok = deletePersona() + if (ok) appendMemoryLog('用户删除 persona 画像') + return ok +} + +/** 清空全部记忆(用户控制) */ +export function clearAllMemoryState(): void { + clearAllMemory() + appendMemoryLog('用户清空全部记忆') +} + +export function setEnabled(enabled: boolean): void { + setMemoryEnabled(enabled) + appendMemoryLog(enabled ? '记忆功能已启用' : '记忆功能已关闭') +} + +export function stats(): MemoryStats { + return getMemoryStats() +} + +// ===== 主动回忆 ===== + +/** 给用户消息构建可注入的 memory 上下文块(空串 = 无需注入) */ +export function contextForMessage(userText: string, opts: { limit?: number } = {}): string { + if (!isMemoryEnabled()) return '' + try { + return buildMemoryContextForMessage(userText, opts) + } catch (error) { + console.error('[Memory] 构建回忆上下文失败:', error) + return '' + } +} + +/** 检索记忆(工具用,同步 keyword) */ +export function search(request: MemorySearchRequest): MemorySearchResult { + return searchMemoriesByKeyword(request) +} + +/** 检索记忆(异步 hybrid:keyword + embedding + 规则加权;embedding 不可用时降级 keyword) */ +export async function searchAsync(request: MemorySearchRequest): Promise { + const providerReady = (await import('./embedding')).getEmbeddingProvider() + if (providerReady) { + return searchMemoriesHybrid(request) + } + return searchMemoriesByKeyword(request) +} + +/** 检索并渲染为纯文本(工具/调试用) */ +export function searchAsText(request: MemorySearchRequest): string { + const result = searchMemoriesByKeyword(request) + if (result.hits.length === 0) return '未找到相关记忆。' + return formatRecallContext(result) || '未找到相关记忆。' +} + +// ===== 主动记忆(Agent 工具直接沉淀,不走 LLM) ===== + +/** + * 直接写入一条记忆(memory_capture 工具路径)。 + * 返回是否实际新增(false = 与已有记忆重复,已合并更新)。 + */ +export function captureCandidate( + candidate: MemoryCandidate, + ctx: { sessionId?: string; workspaceSlug?: string } = {}, + opts: { confirmed?: boolean } = {}, +): { stored: boolean; deduplicated: boolean; atom: MemoryAtom } { + if (!isMemoryEnabled()) throw new Error('记忆功能已关闭') + const result = writeAtomWithDedup({ + content: candidate.content.trim(), + type: candidate.type, + priority: candidate.priority ?? 50, + sessionId: ctx.sessionId, + workspaceSlug: ctx.workspaceSlug, + confirmed: opts.confirmed ?? true, + }) + appendMemoryLog(`手动沉淀: [${result.atom.type}] ${result.atom.content.slice(0, 60)}${result.deduplicated ? '(合并已有)' : ''}${result.atom.confirmed ? '' : '(待确认)'}`) + return { stored: !result.deduplicated, deduplicated: result.deduplicated, atom: result.atom } +} + +/** + * 批量写入候选(供 LLM 提取管道调用) + * + * @param opts.confirmed 提取的记忆是否立即生效。LLM 自动提取应传 false(默认 pending,需用户确认), + * 显式 memory_capture 工具传 true(用户明确要求记住,即时生效)。 + */ +export function captureCandidates( + candidates: MemoryCandidate[], + ctx: { sessionId?: string; workspaceSlug?: string } = {}, + opts: { confirmed?: boolean } = {}, +): { storedCount: number; deduplicatedCount: number; atoms: MemoryAtom[] } { + let storedCount = 0 + let deduplicatedCount = 0 + const atoms: MemoryAtom[] = [] + for (const candidate of candidates) { + if (!candidate.content?.trim()) continue + try { + const result = captureCandidate(candidate, ctx, opts) + atoms.push(result.atom) + if (result.stored) storedCount += 1 + else deduplicatedCount += 1 + } catch (error) { + console.warn('[Memory] 写入候选失败:', candidate.content.slice(0, 40), error) + } + } + return { storedCount, deduplicatedCount, atoms } +} + +// ===== 行为纠正 ===== + +/** 新增纠正候选(默认 pending,需用户确认) */ +export function proposeCorrection(input: { raw: string; rule: string; sessionId?: string }) { + if (!isMemoryEnabled()) throw new Error('记忆功能已关闭') + // P2-2 白名单:规则必须有实质内容且长度受控,防投毒/膨胀 + const rule = (input.rule ?? '').trim() + if (!rule || rule.length < 2 || rule.length > 500) { + console.warn('[Memory] 拒绝非法纠正规则(长度异常):', rule.slice(0, 40)) + throw new Error('纠正规则内容不合法') + } + const correction = addCorrection({ raw: (input.raw ?? '').trim().slice(0, 1000), rule, sessionId: input.sessionId }) + appendMemoryLog(`新增行为纠正候选: ${correction.rule.slice(0, 60)}`) + return correction +} + +export function corrections(status?: 'pending' | 'active' | 'rejected' | 'superseded') { + return listCorrections(status) +} + +/** 确认纠正后生效(若该类型同时写为 atom 则同步) */ +export function confirmCorrection(id: string): boolean { + const correction = updateCorrectionStatus(id, 'active') + if (!correction) return false + appendMemoryLog(`行为纠正已生效: ${correction.rule.slice(0, 60)}`) + // 同时沉淀为 correction 类型 atom,便于回忆 + writeAtom({ + content: correction.rule, + type: 'correction', + priority: 80, + confirmed: true, + sessionId: correction.sessionId, + metadata: { correctionId: correction.id }, + }) + // 反馈回流:确认的纠正应进入 persona 交互协议(用户明确认可的行为规则) + void ensurePersona().catch(() => undefined) + return true +} + +export function rejectCorrection(id: string): boolean { + return !!updateCorrectionStatus(id, 'rejected') +} + +/** + * 撤销一条已生效的纠正(P2-2 用户控制): + * - 状态从 active 回退为 rejected + * - 删除 confirmCorrection 时沉淀的 correction 类型 atom + * - 异步重生成 persona(去掉已回滚规则) + * 返回是否成功。 + */ +export function undoCorrection(id: string): boolean { + const correction = updateCorrectionStatus(id, 'rejected') + if (!correction) return false + appendMemoryLog(`撤销行为纠正: ${correction.rule.slice(0, 60)}`) + // 删除确认时沉淀的 atom(metadata.correctionId === id 的 correction 类型条目) + const atom = readAllAtoms({ includeUnconfirmed: true }).find( + (a) => a.type === 'correction' && a.metadata?.correctionId === id, + ) + if (atom) deleteAtom(atom.id) + // 反馈回流:重生成 persona(移除该规则) + void ensurePersona().catch(() => undefined) + return true +} + +// ===== 待确认记忆(自动提取,需用户确认) ===== + +/** 列出待确认的自动提取记忆 */ +export function pendingAtoms() { + return listPendingAtoms() +} + +/** 分页浏览全部记忆(记忆看板视图) */ +export function atomsPaged(opts: { + page?: number + pageSize?: number + type?: import('@proma/shared').MemoryAtomType | 'all' + sort?: 'newest' | 'priority' + confirmed?: boolean +} = {}) { + return listAtomsPaged(opts) +} + +/** 确认一条待确认记忆(生效并进入召回) */ +export function confirmAtomById(id: string): MemoryAtom | undefined { + const atom = confirmAtom(id) + if (atom) { + appendMemoryLog(`确认记忆: [${atom.type}] ${atom.content.slice(0, 60)}`) + // 确认的行为规则类记忆应同步进 persona + if (atom.type === 'correction' || atom.type === 'preference' || atom.type === 'sop') { + void ensurePersona().catch(() => undefined) + } + } + return atom +} + +/** 拒绝并删除一条待确认记忆 */ +export function rejectAtomById(id: string): boolean { + const ok = deleteAtom(id) + if (ok) appendMemoryLog(`拒绝记忆: ${id}`) + return ok +} + +// ===== L3 Persona ===== + +export function personaRaw(): string | undefined { + return readPersonaRaw() +} + +/** persona 证据溯源:返回每条画像条目的来源 atom id(供 UI 展示溯源入口) */ +export function personaSources(): Array<{ text: string; sources: string[] }> { + const raw = readPersonaRaw() + if (!raw) return [] + return extractPersonaSources(raw) +} + +/** persona 是否溯源版本(旧版需重生成) */ +export function personaTraceable(): boolean { + return isPersonaTraceable() +} + +/** 手动重新生成 persona(用户控制,B3) */ +export async function regeneratePersona(): Promise { + return ensurePersona() +} + +export function persona(): PersonaProfile { + return parsePersonaProfile(readPersonaRaw()) +} + +/** 更新 persona(由 extractor 的 LLM 生成后调用;原文覆盖写) */ +export function updatePersona(markdown: string): void { + writePersona(markdown) + appendMemoryLog('用户画像已更新') +} + +/** 用户手动编辑 persona(与 LLM 自动生成 updatePersona 区分) */ +export function savePersona(markdown: string): void { + writePersona(markdown) + appendMemoryLog('用户手动编辑 persona 画像') +} + +/** + * 确保 persona 存在/更新: + * - 无 persona 且 LLM 可用 → LLM 生成 + * - 无 persona 且无 LLM → 规则版兜底 + * - 已有 persona → LLM 增量更新(保留稳定内容) + * 返回是否成功生成/更新。 + */ +export async function ensurePersona(): Promise { + const existing = readPersonaRaw() + // B3:旧版 persona(无溯源版本标记)强制重生成,让 src 溯源标注落地 + const forceRegenerate = existing ? !isPersonaTraceable() : false + try { + if (isMemoryLlmConfigured()) { + const markdown = await generatePersona({ existing }) + if (markdown) { + writePersona(markdown) + appendMemoryLog(forceRegenerate ? '用户画像已重生成(溯源版本)' : existing ? '用户画像已增量更新' : '用户画像已生成') + return true + } + } + if (!existing || forceRegenerate) { + const fallback = buildPersonaFromRules() + if (fallback) { + writePersona(fallback) + appendMemoryLog(forceRegenerate ? '用户画像已重生成(规则版兜底,溯源版本)' : '用户画像已生成(规则版兜底)') + return true + } + } + return false + } catch (error) { + console.warn('[Memory] persona 生成失败:', error instanceof Error ? error.message : error) + return false + } +} + +// ===== 查询辅助 ===== + +export function recentAtoms(limit = 20): MemoryAtom[] { + return readAllAtoms({ includeUnconfirmed: false }).slice(0, limit) +} + +/** + * 工作记忆摘要(参考 Nowledge Mem Working Memory): + * 从最近 todo_context(任务上下文)与高优先级 preference 生成当前活跃任务快照。 + * 用于新会话/压缩后快速恢复工作状态。 + */ +export function workingMemory(limit = 5): { items: string[]; updatedAt?: number } { + const atoms = readAllAtoms({ includeUnconfirmed: false }) + const tasks = atoms + .filter((a) => a.type === 'todo_context') + .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0) || b.createdAt - a.createdAt) + .slice(0, limit) + if (tasks.length === 0) return { items: [] } + return { + items: tasks.map((t) => t.content), + updatedAt: tasks[0]?.createdAt, + } +} + +export function atomById(id: string): MemoryAtom | undefined { + return getAtomById(id) +} + +export function scenes() { + return readAllScenes() +} + +// ===== L2 场景(主动性的时机信号) ===== + +/** 最近热点场景(实时聚类,按热度降序) */ +export function getHotScenes(opts: { windowDays?: number; limit?: number } = {}) { + return computeHotScenes(opts) +} + +/** 最近热点场景摘要(纯文本,供注入/分析) */ +export function hotScenesSummary(limit = 3): string { + try { + const scenes = getHotScenes({ limit }) + if (scenes.length === 0) return '' + return scenes + .map((s) => `- [${s.title}] heat=${s.heat} atoms=${s.atomIds.length}`) + .join('\n') + } catch { + return '' + } +} + +// ===== 提取管道入口(Phase 3) ===== + +/** + * 从对话消息提取记忆并写入。 + * 优先 LLM 结构化提取;LLM 未配置或失败时回退规则版(识别明确纠正/偏好信号)。 + */ +export async function extractFromConversation(input: MemoryCaptureInput): Promise<{ + storedCount: number + deduplicatedCount: number + atoms: MemoryAtom[] + corrections: number + mode: 'llm' | 'rule' | 'none' +}> { + const candidates: MemoryCandidate[] = [] + let correctionCount = 0 + + // 提取模式:off 直接跳过;rule 仅规则版(零外发);llm 全量 + const mode_ = getExtractionMode() + if (mode_ === 'off') { + return { storedCount: 0, deduplicatedCount: 0, atoms: [], corrections: 0, mode: 'none' } + } + + const messages = (input.messages ?? []).filter( + (m) => m && typeof m.content === 'string' && m.content.trim().length > 0, + ) + if (messages.length === 0) { + return { storedCount: 0, deduplicatedCount: 0, atoms: [], corrections: 0, mode: 'none' } + } + + let mode: 'llm' | 'rule' | 'none' = 'none' + + // 1) LLM 提取(仅 llm 模式;rule 模式零外发) + if (mode_ === 'llm' && isMemoryLlmConfigured()) { + try { + const llmCandidates = await extractFromMessages(messages) + if (llmCandidates.length > 0) { + candidates.push(...llmCandidates) + mode = 'llm' + } + } catch (error) { + console.warn('[Memory] LLM 提取失败,回退规则版:', error instanceof Error ? error.message : error) + } + } + + // 2) 规则版兜底(LLM 未配置/未提取到内容,或 rule 模式始终走规则版) + if (mode_ === 'rule' || candidates.length === 0) { + for (const msg of messages) { + if (msg.role !== 'user') continue + const text = msg.content.trim() + if (text.length < 4) continue + + const correctionMatch = text.match(/(?:以后|下次|记住|别再|不要|请记住)[^。!?\n]{2,80}/) + if (correctionMatch) { + const raw = correctionMatch[0].trim() + proposeCorrection({ raw, rule: raw, sessionId: input.sessionId }) + correctionCount += 1 + mode = 'rule' + } + const prefMatch = text.match(/(?:我喜欢|我偏好|我更倾向|用|使用)[^。!?\n]{2,80}/) + if (prefMatch) { + candidates.push({ content: prefMatch[0].trim(), type: 'preference', priority: 60 }) + mode = 'rule' + } + } + } + + // LLM/规则提取的记忆为自动生成,默认 pending(需用户确认后才注入上下文),阻断投毒链 + const result = captureCandidates(candidates, { sessionId: input.sessionId, workspaceSlug: input.workspaceSlug }, { confirmed: false }) + if (result.storedCount > 0 || correctionCount > 0) { + markExtractionCompleted() + // 有新增记忆时,异步刷新 persona(不阻塞提取返回) + void ensurePersona().catch(() => undefined) + } + return { ...result, corrections: correctionCount, mode: mode as 'llm' | 'rule' | 'none' } +} + +/** + * 会话结束钩子入口:接收最近对话消息,异步提取并捕获记忆(不阻塞调用方)。 + * 返回提取结果摘要。 + */ +export async function extractAndCapture( + messages: Array<{ role: 'user' | 'assistant'; content: string }>, + ctx: { sessionId?: string; workspaceSlug?: string } = {}, +): Promise<{ storedCount: number; corrections: number; mode: 'llm' | 'rule' | 'none' }> { + if (!isMemoryEnabled()) return { storedCount: 0, corrections: 0, mode: 'none' } + const result = await extractFromConversation({ + messages, + sessionId: ctx.sessionId, + workspaceSlug: ctx.workspaceSlug, + }) + if (result.storedCount > 0 || result.corrections > 0) { + console.log(`[Memory] 主动记忆捕获完成: ${result.storedCount} 条新增, ${result.corrections} 条纠正, mode=${result.mode}`) + } + return { storedCount: result.storedCount, corrections: result.corrections, mode: result.mode } +} + +/** LLM 是否已配置(供工具/UI 展示) */ +export function isLlmConfigured(): boolean { + return isMemoryLlmConfigured() +} + +/** 默认召回条数(供工具描述使用) */ +export const DEFAULT_RECALL_LIMIT_ = DEFAULT_RECALL_LIMIT +export type { MemoryAtomType, MemoryCandidate } diff --git a/apps/electron/src/main/lib/memory/store.test.ts b/apps/electron/src/main/lib/memory/store.test.ts new file mode 100644 index 000000000..a77ffa5f2 --- /dev/null +++ b/apps/electron/src/main/lib/memory/store.test.ts @@ -0,0 +1,71 @@ +/** + * Memory Store 单元测试 + */ + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { mkdtempSync, rmSync, existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +// 注意:store/recall 的磁盘相关函数依赖真实 config-paths(~/.proma/memory), +// 单测避免写入用户目录,因此只测不依赖磁盘的纯函数。 +import { fingerprintContent, isDuplicate, localDateKey } from '../memory/store' +import { tokenize, queryTerms, expandedQueryTerms, RECALL_MIN_SCORE } from '../memory/recall' + +describe('memory/store 纯函数', () => { + it('localDateKey 返回 YYYY-MM-DD', () => { + const key = localDateKey(new Date('2026-08-02T12:00:00').getTime()) + expect(key).toBe('2026-08-02') + }) + + it('fingerprintContent 归一化空白与标点', () => { + expect(fingerprintContent('用户 喜欢 用中文')).toBe(fingerprintContent('用户喜欢用中文')) + expect(fingerprintContent('用 Python 写脚本。')).toBe(fingerprintContent('用Python写脚本')) + }) + + it('isDuplicate 判定实质重复', () => { + const a = { content: '用户使用 DeepSeek 作为默认模型', fingerprint: fingerprintContent('用户使用 DeepSeek 作为默认模型') } as never + const b = { content: '用户使用 DeepSeek 作为默认模型。', fingerprint: fingerprintContent('用户使用 DeepSeek 作为默认模型。') } as never + expect(isDuplicate(a as never, b as never)).toBe(true) + }) + + it('isDuplicate 区分不同内容', () => { + const a = { content: '用户喜欢咖啡', fingerprint: fingerprintContent('用户喜欢咖啡') } as never + const b = { content: '用户喜欢喝茶', fingerprint: fingerprintContent('用户喜欢喝茶') } as never + expect(isDuplicate(a as never, b as never)).toBe(false) + }) +}) + +describe('memory/recall 分词与检索', () => { + it('tokenize 提取英文单词与中文 bigram', () => { + const tokens = tokenize('用 Python 写脚本') + expect(tokens).toContain('python') + expect(tokens).toContain('脚本') + expect(tokens).toContain('写脚') + }) + + it('queryTerms 去重', () => { + const terms = queryTerms('喜欢 喜欢 咖啡') + expect(new Set(terms).size).toBe(terms.length) + }) + + it('queryTerms 过滤停用词(防误报)', () => { + // 全功能词查询应没有“帮/我/写/一/个”等纯噪声词,但保留有语义的 bigram(排序/算法) + const terms = queryTerms('帮我写一个排序算法') + expect(terms.includes('帮')).toBe(false) + expect(terms.includes('一')).toBe(false) + expect(terms.includes('排序')).toBe(true) + expect(terms.includes('算法')).toBe(true) + }) + + it('expandedQueryTerms 同义词扩展(编程→技术栈)', () => { + const terms = expandedQueryTerms('用什么编程语言') + // 扩展后应包含技术栈相关词 + expect(terms.some((t) => ['typescript', 'rust', '技术栈'].includes(t))).toBe(true) + }) + + it('RECALL_MIN_SCORE 阈值存在且在合理区间', () => { + expect(RECALL_MIN_SCORE).toBeGreaterThan(0) + expect(RECALL_MIN_SCORE).toBeLessThan(0.5) + }) +}) diff --git a/apps/electron/src/main/lib/memory/store.ts b/apps/electron/src/main/lib/memory/store.ts new file mode 100644 index 000000000..da4810d92 --- /dev/null +++ b/apps/electron/src/main/lib/memory/store.ts @@ -0,0 +1,662 @@ +/** + * Memory Store — 长期记忆持久化层 + * + * 存储布局(local-first,对齐 Proma 惯例): + * ```text + * ~/.proma/memory/ + * index.json # 元数据/版本/统计(原子写 + .bak 容错) + * profile.md # L3 用户画像 + * atoms/{YYYY-MM-DD}.jsonl # L1 原子记忆,按天分文件(append-only) + * scenes/{sceneId}.md # L2 场景块 + * corrections.json # 行为纠正候选(待审批) + * memory_log/{YYYY-MM-DD}.md # 每日记忆变更日志 + * ``` + * + * 设计原则: + * - 同步优先(对齐 automation-manager 的 read/write-through 缓存模式) + * - 崩溃安全(复用 safe-file 的原子写 + .tmp/.bak 容错) + * - atoms 只追加;去重/更新在读取层做(fingerprint 定位后标记 superseded 或直接替换) + */ + +import { randomUUID } from 'node:crypto' +import { + existsSync, + mkdirSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + unlinkSync, + writeFileSync, +} from 'node:fs' +import { join } from 'node:path' +import { + getMemoryRootDir, + getMemoryIndexPath, + getMemoryAtomsDir, + getMemoryAtomsDayPath, + getMemoryScenesDir, + getPersonaPath, + getCorrectionsPath, + getMemoryLogDir, +} from '../config-paths' +import { readJsonFileSafe, writeJsonFileAtomic, writeTextFileAtomic } from '../safe-file' +import type { + MemoryAtom, + MemoryAtomType, + MemoryCorrection, + MemoryStats, + PersonaProfile, + SceneBlock, +} from '@proma/shared' + +/** 记忆索引文件格式 */ +interface MemoryIndex { + version: number + /** 最近一次 L1 提取时间(epoch ms) */ + lastExtractionAt: number + /** 记忆启用状态 */ + enabled: boolean + /** 提取模式:llm=LLM 提取(外发)、rule=仅规则版(零外发)、off=关闭提取 */ + extractionMode?: 'llm' | 'rule' | 'off' + /** 是否把 persona 画像注入系统提示(默认 true;用户可关闭) */ + personaInjectionEnabled?: boolean +} + +const INDEX_VERSION = 1 + +// ===== 日期工具 ===== + +/** 返回本地日期 key:YYYY-MM-DD */ +export function localDateKey(ts: number = Date.now()): string { + const d = new Date(ts) + const y = d.getFullYear() + const m = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + return `${y}-${m}-${day}` +} + +// ===== ID / 指纹 ===== + +function generateAtomId(): string { + return `atom_${Date.now()}_${randomUUID().slice(0, 8)}` +} + +function generateCorrectionId(): string { + return `corr_${Date.now()}_${randomUUID().slice(0, 8)}` +} + +/** 归一化内容指纹:去除空白/标点差异,用于近似去重 */ +export function fingerprintContent(content: string): string { + return content + .toLowerCase() + .replace(/[\s,。!?、;:""''()《》【】,.!?;:"'()<>\[\]]/g, '') + .slice(0, 120) +} + +/** 判断两条记忆是否"实质重复":指纹相同,或内容包含度 ≥ 0.9 */ +export function isDuplicate(a: MemoryAtom, b: MemoryAtom): boolean { + if (a.fingerprint && b.fingerprint && a.fingerprint === b.fingerprint) return true + const ac = a.content.toLowerCase() + const bc = b.content.toLowerCase() + if (ac.length === 0 || bc.length === 0) return false + const short = ac.length <= bc.length ? ac : bc + const long = ac.length <= bc.length ? bc : ac + if (short.length / long.length < 0.6) return false + return long.includes(short) || short.includes(long) +} + +// ===== 目录初始化 ===== + +function ensureMemoryDirs(): void { + for (const dir of [getMemoryRootDir(), getMemoryAtomsDir(), getMemoryScenesDir(), getMemoryLogDir()]) { + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) + } +} + +// ===== 索引 ===== + +let cachedIndex: MemoryIndex | null = null + +function readIndex(): MemoryIndex { + if (cachedIndex) return cachedIndex + const data = readJsonFileSafe(getMemoryIndexPath()) + if (!data || typeof data.version !== 'number') { + cachedIndex = { version: INDEX_VERSION, lastExtractionAt: 0, enabled: true, extractionMode: 'llm', personaInjectionEnabled: true } + return cachedIndex + } + if (data.version > INDEX_VERSION) { + cachedIndex = data + return cachedIndex + } + cachedIndex = { + version: INDEX_VERSION, + lastExtractionAt: data.lastExtractionAt ?? 0, + enabled: data.enabled ?? true, + extractionMode: data.extractionMode ?? 'llm', + personaInjectionEnabled: data.personaInjectionEnabled ?? true, + } + return cachedIndex +} + +function writeIndex(index: MemoryIndex): void { + try { + ensureMemoryDirs() + cachedIndex = index + writeJsonFileAtomic(getMemoryIndexPath(), index) + } catch (error) { + cachedIndex = null + console.error('[Memory] 写入索引失败:', error) + throw new Error('写入记忆索引失败') + } +} + +/** 记忆是否启用(可在 index.json 中关闭) */ +export function isMemoryEnabled(): boolean { + return readIndex().enabled +} + +/** 开关记忆 */ +export function setMemoryEnabled(enabled: boolean): void { + const index = readIndex() + index.enabled = enabled + writeIndex(index) +} + +/** 当前提取模式 */ +export function getExtractionMode(): 'llm' | 'rule' | 'off' { + return readIndex().extractionMode ?? 'llm' +} + +/** 设置提取模式 */ +export function setExtractionMode(mode: 'llm' | 'rule' | 'off'): void { + const index = readIndex() + index.extractionMode = mode + writeIndex(index) +} + +/** persona 画像是否注入系统提示 */ +export function isPersonaInjectionEnabled(): boolean { + return readIndex().personaInjectionEnabled ?? true +} + +/** 开关 persona 注入 */ +export function setPersonaInjectionEnabled(enabled: boolean): void { + const index = readIndex() + index.personaInjectionEnabled = enabled + writeIndex(index) +} + +/** 最近一次提取时间 */ +export function getLastExtractionAt(): number { + return readIndex().lastExtractionAt +} + +/** 标记提取完成 */ +export function markExtractionCompleted(at: number = Date.now()): void { + const index = readIndex() + index.lastExtractionAt = at + writeIndex(index) +} + +// ===== L1 Atoms ===== + +/** 写入一条原子记忆(append 到当天文件) */ +export function writeAtom(atom: Omit & { id?: string; confirmed?: boolean }): MemoryAtom { + ensureMemoryDirs() + const now = Date.now() + const full: MemoryAtom = { + ...atom, + id: atom.id ?? generateAtomId(), + createdAt: now, + updatedAt: now, + confirmed: atom.confirmed ?? (atom.type !== 'correction'), + fingerprint: atom.fingerprint ?? fingerprintContent(atom.content), + } + const filePath = getMemoryAtomsDayPath(localDateKey()) + const line = JSON.stringify(full) + const content = (existsSync(filePath) ? readFileSync(filePath, 'utf-8') : '') + line + '\n' + const tmpPath = filePath + '.tmp' + writeFileSync(tmpPath, content, 'utf-8') + try { + // POSIX rename 原子替换 + renameSync(tmpPath, filePath) + } catch (error) { + console.error('[Memory] 写入 atom 失败:', error) + throw new Error('写入记忆条目失败') + } + return full +} + +/** 读取全部 L1 atoms(跨天文件,按创建时间倒序) */ +export function readAllAtoms(opts: { includeUnconfirmed?: boolean } = {}): MemoryAtom[] { + if (!existsSync(getMemoryAtomsDir())) return [] + const atoms: MemoryAtom[] = [] + for (const file of readdirSync(getMemoryAtomsDir())) { + if (!file.endsWith('.jsonl')) continue + const filePath = join(getMemoryAtomsDir(), file) + try { + const raw = readFileSync(filePath, 'utf-8') + for (const line of raw.split('\n')) { + if (!line.trim()) continue + try { + const atom = JSON.parse(line) as MemoryAtom + if (!opts.includeUnconfirmed && !atom.confirmed) continue + atoms.push(atom) + } catch { + // 跳过损坏行 + } + } + } catch { + // 跳过不可读文件 + } + } + return atoms.sort((a, b) => b.createdAt - a.createdAt) +} + +/** 按 ID 查 atom */ +export function getAtomById(id: string): MemoryAtom | undefined { + return readAllAtoms({ includeUnconfirmed: true }).find((a) => a.id === id) +} + +/** + * 尝试写入 atom,若与已有条目重复则更新已有条目并返回 { deduplicated: true, atom: 已有条目 } + * 用于提取管道,避免 LLM 每轮重复提取同一事实。 + */ +export function writeAtomWithDedup(atom: Omit & { id?: string; confirmed?: boolean }): { deduplicated: boolean; atom: MemoryAtom } { + const existing = readAllAtoms({ includeUnconfirmed: true }) + for (const prev of existing) { + if (isDuplicate(prev, { + ...atom, + id: '', + createdAt: 0, + updatedAt: 0, + confirmed: true, + } as MemoryAtom)) { + // 更新已有条目的优先级/内容(保留原 id 与创建时间) + const updated: MemoryAtom = { + ...prev, + content: atom.content.length > prev.content.length ? atom.content : prev.content, + priority: Math.max(prev.priority, atom.priority ?? 50), + updatedAt: Date.now(), + sessionId: atom.sessionId ?? prev.sessionId, + workspaceSlug: atom.workspaceSlug ?? prev.workspaceSlug, + metadata: { ...(prev.metadata ?? {}), ...(atom.metadata ?? {}) }, + } + updateAtomById(prev.id, updated) + return { deduplicated: true, atom: updated } + } + } + return { deduplicated: false, atom: writeAtom(atom) } +} + +/** 替换某条 atom(按 id;找不到则追加) */ +/** 列出待确认的自动提取记忆(pending atoms) */ +export function listPendingAtoms(): MemoryAtom[] { + return readAllAtoms({ includeUnconfirmed: true }) + .filter((a) => !a.confirmed) + .sort((a, b) => b.createdAt - a.createdAt) +} + +/** + * 分页浏览全部记忆(记忆看板视图)。 + * 支持按类型过滤、按时间/优先级排序。 + */ +export function listAtomsPaged(opts: { + page?: number + pageSize?: number + type?: MemoryAtomType | 'all' + sort?: 'newest' | 'priority' + /** undefined=全部,true=仅已确认,false=仅待确认 */ + confirmed?: boolean +} = {}): { atoms: MemoryAtom[]; total: number; page: number; pageSize: number; totalPages: number } { + const { page = 1, pageSize = 20, type = 'all', sort = 'newest', confirmed } = opts + const safePage = Math.max(1, Math.floor(page)) + const safeSize = Math.min(Math.max(1, Math.floor(pageSize)), 100) + let atoms = readAllAtoms({ includeUnconfirmed: true }) + if (confirmed !== undefined) atoms = atoms.filter((a) => a.confirmed === confirmed) + if (type !== 'all') atoms = atoms.filter((a) => a.type === type) + atoms = [...atoms].sort((a, b) => + sort === 'priority' + ? (b.priority ?? 0) - (a.priority ?? 0) || b.createdAt - a.createdAt + : b.createdAt - a.createdAt, + ) + const total = atoms.length + const totalPages = Math.max(1, Math.ceil(total / safeSize)) + const start = (safePage - 1) * safeSize + return { atoms: atoms.slice(start, start + safeSize), total, page: safePage, pageSize: safeSize, totalPages } +} + +/** 确认一条待确认记忆(用户认可后注入) */ +export function confirmAtom(id: string): MemoryAtom | undefined { + const atom = getAtomById(id) + if (!atom) return undefined + const updated: MemoryAtom = { ...atom, confirmed: true, updatedAt: Date.now() } + updateAtomById(id, updated) + return updated +} + +/** 拒绝并删除一条待确认记忆 */ +export function deleteAtom(id: string): boolean { + const files = existsSync(getMemoryAtomsDir()) ? readdirSync(getMemoryAtomsDir()).filter((f) => f.endsWith('.jsonl')) : [] + for (const file of files) { + const filePath = join(getMemoryAtomsDir(), file) + const lines = readFileSync(filePath, 'utf-8').split('\n') + const kept = lines.filter((line) => { + if (!line?.trim()) return false + try { + const parsed = JSON.parse(line) as MemoryAtom + return parsed.id !== id + } catch { + return true + } + }) + if (kept.length !== lines.length) { + const tmpPath = filePath + '.tmp' + writeFileSync(tmpPath, kept.join('\n'), 'utf-8') + renameSync(tmpPath, filePath) + return true + } + } + return false +} + +export function updateAtomById(id: string, atom: MemoryAtom): MemoryAtom { + ensureMemoryDirs() + // 找到该 atom 所在文件 + const files = existsSync(getMemoryAtomsDir()) ? readdirSync(getMemoryAtomsDir()).filter((f) => f.endsWith('.jsonl')) : [] + for (const file of files) { + const filePath = join(getMemoryAtomsDir(), file) + const lines = readFileSync(filePath, 'utf-8').split('\n') + let changed = false + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + if (!line?.trim()) continue + try { + const parsed = JSON.parse(line) as MemoryAtom + if (parsed.id === id) { + lines[i] = JSON.stringify(atom) + changed = true + break + } + } catch { + // 跳过损坏行 + } + } + if (changed) { + const tmpPath = filePath + '.tmp' + writeFileSync(tmpPath, lines.join('\n'), 'utf-8') + renameSync(tmpPath, filePath) + return atom + } + } + return writeAtom(atom) +} + +// ===== L2 Scenes ===== + +/** 写入/更新一个场景块(markdown 文件) */ +export function writeSceneBlock(scene: SceneBlock, markdown: string): SceneBlock { + ensureMemoryDirs() + const filePath = join(getMemoryScenesDir(), `${scene.id}.md`) + writeJsonFileAtomic(filePath, { scene, markdown }) + return scene +} + +/** 读取全部场景块 */ +export function readAllScenes(): SceneBlock[] { + if (!existsSync(getMemoryScenesDir())) return [] + const scenes: SceneBlock[] = [] + for (const file of readdirSync(getMemoryScenesDir())) { + if (!file.endsWith('.md')) continue + try { + const data = readJsonFileSafe<{ scene: SceneBlock; markdown: string }>(join(getMemoryScenesDir(), file)) + if (data?.scene) scenes.push(data.scene) + } catch { + // 跳过损坏 + } + } + return scenes.sort((a, b) => b.updatedAt - a.updatedAt) +} + +// ===== L3 Persona ===== + +/** 读取 persona 原文(不存在返回 undefined) */ +export function readPersonaRaw(): string | undefined { + const filePath = getPersonaPath() + if (!existsSync(filePath)) return undefined + try { + return readFileSync(filePath, 'utf-8') + } catch { + return undefined + } +} + +/** 写入 persona(全文替换)。自动带溯源版本标记,用于检测旧版画像需要重生成 */ +export function writePersona(markdown: string): void { + ensureMemoryDirs() + const body = markdown.trim() + const header = `\n\n` + // 避免重复加版本头 + const content = body.startsWith(' +