From 8b377daa025bd73830605606481e3bd5934eeb63 Mon Sep 17 00:00:00 2001 From: error2913 <2913949387@qq.com> Date: Tue, 1 Sep 2026 02:43:02 +0800 Subject: [PATCH 1/7] =?UTF-8?q?fix(memory):=20=E7=9F=A5=E8=AF=86=E5=BA=93?= =?UTF-8?q?=E6=A3=80=E7=B4=A2=E5=BC=8F=E6=B3=A8=E5=85=A5=20+=20=E5=BF=83?= =?UTF-8?q?=E6=99=BA=E6=A8=A1=E5=9E=8B/=E8=A7=82=E5=AF=9F=E6=B3=A8?= =?UTF-8?q?=E5=85=A5=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复 .ai memo mm del 删除失效:del 经别名归一化为 delete 后未命中删除分支返回帮助文本; 子命令 case 统一按 aliasToCmd 归一化目标值,del/delete 均可删除,并支持按问题名/关键词删除 - 知识库改为按当前对话检索式注入(最多 5 个分块),未命中只注入前 50 条索引兜底, 不再全量注入正文/索引;formatIndex 支持条数上限,knowledge_list/.ai kb list 上限 200 条 - 心智模型/观察注入 scope 匹配由 all_strict 放宽为任一命中,群聊不再因贡献者不在最近消息被整条剔除; memory_mm_create/.ai memo mm add 的 scope_tag 非法或与目标类型不符时回退默认标签 - 观察记忆只保留一条注入路径(启用长期记忆时由长期记忆段裁剪注入),观察段同口径裁剪, 巩固生成观察后刷新观察段缓存,消除最长 60 秒滞后 - 新增 memory_mm_list/memory_mm_view 工具,memory_mm_delete 支持按 question 删除 - 修正工具描述过时引用(search_memory→memory_recall、kb_list/kb_search→knowledge_list/knowledge_search) - .ai memo status 显示心智模型将注入条数 - 补充单测:mm del 别名、KB 索引/检索式注入、MM 段注入、观察裁剪 - 同步 changelog/README/docs --- README.md | 6 +- ...66\346\236\204\350\256\276\350\256\241.md" | 2 +- ...41\345\235\227\350\257\246\350\247\243.md" | 6 +- ...45\345\205\267\347\263\273\347\273\237.md" | 2 +- ...44\344\270\216\351\205\215\347\275\256.md" | 4 +- scripts/unit-test-entry.ts | 139 +++++++++++++++++- src/changelog.ts | 11 ++ src/cmd/sub_cmd/kb.ts | 4 +- src/cmd/sub_cmd/memory.ts | 40 +++-- src/config/configs/knowledge_base.ts | 2 +- src/memory/knowledge.ts | 1 + src/memory/knowledge_base.ts | 38 +++-- src/memory/manager.ts | 18 ++- src/memory/v2/prompt.ts | 6 +- src/prompt/builder.ts | 4 +- src/tool/tools/memory/tool_knowledge.ts | 6 +- src/tool/tools/memory/tool_memory.ts | 95 ++++++++++-- 17 files changed, 325 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index 35012b09..a74a29dd 100644 --- a/README.md +++ b/README.md @@ -280,7 +280,7 @@ max_tokens = 2048 | 设置项 | 说明 | |:---:|:---| | 启用知识库记忆 | 开启后把知识库内容注入 system prompt,供对话参考 | -| 知识库注入阈值(字符) | 知识库总内容不超过该值时全量注入;超过时只注入条目索引,模型用 kb_read 工具读取详情(默认 5000) | +| 知识库注入阈值(字符) | 已弃用:知识库改为按当前对话检索式注入(最多 5 个分块),未命中只注入前 50 条条目索引;模型用 knowledge_read 工具读取详情(保留该配置仅为兼容旧存档) | | 知识库 | Markdown 模板,每条一份完整文档(# 条目标题、##/### 小节,超长自动分块);只读,内容由管理员维护,AI 通过 kb 工具/指令检索。语法定义见 [CommonMark 规范](https://commonmark.org/help/) | ### 回复 @@ -436,8 +436,8 @@ max_tokens = 2048 | 分类 | 工具函数 | |:---:|:---| -| 记忆 | `add_memory`、`update_memory`、`del_memory`、`search_memory`、`clear_memory`、`reflect_memory`、`consolidate_memory` | -| 知识库 | `kb_search`、`kb_read`、`kb_list`(只读检索,内容由配置维护) | +| 记忆 | `memory_add`、`memory_update`、`memory_delete`、`memory_recall`、`memory_clear`、`memory_reflect`、`memory_consolidate`、`memory_mm_list`、`memory_mm_view`、`memory_mm_create`、`memory_mm_refresh`、`memory_mm_delete` | +| 知识库 | `knowledge_search`、`knowledge_read`、`knowledge_list`(只读检索,内容由配置维护) | | OB11 API | `call_ob11_api`(通过 action 调用消息、查询、管理、文件和合并转发 API) | | 特殊ID | `resolve_special_id`(还原上下文短 ID/句柄为原始字段,用于对接协议 API) | | 定时 | `set_timer`、`show_timer_list`、`cancel_timer` | diff --git "a/docs/02-\346\236\266\346\236\204\350\256\276\350\256\241.md" "b/docs/02-\346\236\266\346\236\204\350\256\276\350\256\241.md" index f662881f..9729c1b7 100644 --- "a/docs/02-\346\236\266\346\236\204\350\256\276\350\256\241.md" +++ "b/docs/02-\346\236\266\346\236\204\350\256\276\350\256\241.md" @@ -122,7 +122,7 @@ SYSTEM_MESSAGE_TEMPLATE(用户可编辑的骨架) ├── 图片/音频资源(本地图片、本地语音) ├── memoryPrompt:长期记忆段(按开关 MEMORY) ├── summaryPrompt:观察记忆段(按开关 SUMMARY) -├── knowledgePrompt:知识库段(按开关 KNOWLEDGE;总内容 ≤ 注入阈值时全量注入,否则只注入条目索引,模型用 kb_read 读取详情) +├── knowledgePrompt:知识库段(按开关 KNOWLEDGE;按当前对话检索式注入命中分块,未命中只注入限条数索引兜底,模型用 knowledge_read 读取详情) └── toolPrompt:工具函数段(仅提示词工程模式) 最后追加"可用技能"摘要段(两种模式都可见) ``` diff --git "a/docs/03-\346\240\270\345\277\203\346\250\241\345\235\227\350\257\246\350\247\243.md" "b/docs/03-\346\240\270\345\277\203\346\250\241\345\235\227\350\257\246\350\247\243.md" index 06b71624..1fdd8aa4 100644 --- "a/docs/03-\346\240\270\345\277\203\346\250\241\345\235\227\350\257\246\350\247\243.md" +++ "b/docs/03-\346\240\270\345\277\203\346\250\241\345\235\227\350\257\246\350\247\243.md" @@ -55,7 +55,7 @@ | `mcp.ts` | MCP | 是否启用MCP、MCP服务器配置、MCP会话空闲回收分钟、MCP每服务器最大会话数 | | `skills.ts` | 技能 | 技能配置(仅标准 SKILL.md) | | `memory.ts` | 记忆 | 长期/观察记忆开关、观察间隔轮数/参与轮数、巩固间隔、记忆LLM抽取/重排/观察合成/推理、新近度权重/半衰期、长期记忆条数上限(默认100,0=不限制)、巩固后自动刷新心智模型、刷新最小间隔 | -| `knowledge_base.ts` | 知识库 | 知识库开关、注入阈值(字符)、知识库(Markdown 模板) | +| `knowledge_base.ts` | 知识库 | 知识库开关、知识库注入阈值(字符,已弃用,不再参与注入)、知识库(Markdown 模板) | | `reply.ts` | 回复 | 引用(含戳戳跳过)、最大字数、去空白、禁止复读与相似度、过滤正则与模板 | | `message.ts` | 对话 | 角色扮演设定(每条第一行为名称,以 `.` 开头隐藏)、示例对话、轮数、token 上限、system 插入间隔、压缩阈值 | | `resource.ts` | 资源 | 本地图片路径、本地语音路径(`语音名=路径`,供 system prompt 列出资源) | @@ -124,10 +124,10 @@ - `memory.ts`:`MemoryService` 提供 `memoryMap` 增删改查、`search()`(方法:importance/similarity/score/early/late/recent)、去重合并、按分数淘汰;内含旧格式迁移逻辑(兼容早期 `src/AI/memory.ts` 存档)。 - `session_memory.ts`:`SessionMemoryService` 提供会话级观察记忆:`summarize()` 把游标之后 `SUMMARY_SIZE` 轮对话交给 `summarize_agent`,解析 JSON 摘要与事实列表(facts,含 op/type/importance),写入 `summaries` 并落库;旧存档「短期记忆」自动迁移为观察记忆。 - `knowledge_chunk.ts`:`splitMarkdownIntoChunks()` 按 `#`/`##`/`###` 标题与段落把 Markdown 文档切块(单块约 800 字符、块间保留重叠),生成稳定 ID(`kb_xxxxxxxx`)。 -- `knowledge_base.ts`:`KnowledgeBaseService` 单例,配置驱动(Markdown 模板,每条一份完整文档),解析时不出向量(懒向量);`search()` 先关键词过滤,嵌入可用时仅对命中候选(≤20 块)做惰性向量重排,失败自动降级关键词;`buildKnowledgePrompt()` 总内容 ≤ 注入阈值(默认 5000)时全量注入,否则只注入条目索引。 +- `knowledge_base.ts`:`KnowledgeBaseService` 单例,配置驱动(Markdown 模板,每条一份完整文档),解析时不出向量(懒向量);`search()` 先关键词过滤,嵌入可用时仅对命中候选(≤20 块)做惰性向量重排,失败自动降级关键词;`buildKnowledgePrompt(query)` 按当前对话检索式注入(最多 5 块),未命中时只注入前 50 条条目索引兜底,不再全量注入正文/索引;`formatIndex(limit)` 支持索引条数上限,`knowledge_list` 工具与 `.ai kb list` 上限 200 条。 - `knowledge.ts`:兼容导出层,`knowledgeService = knowledgeBase`(旧 `KnowledgeService` 已移除)。 - `manager.ts`:`MemoryManager` 统一入口:`buildLongTermPrompt` / `buildSummaryPrompt` / `buildKnowledgePrompt` / `addMemory` / `search` / `summarize`。 -- `v2/`(4.17.0 起):Hindsight-like 纯 TS 记忆引擎。`engine.ts` 实现 Retain(抽取原子事实/观察记忆,可选 LLM 结构化抽取 occurred_at/entities)、Recall(RRF 混合检索 + Hindsight 式新近度加权(按 lastAccessedAt 指数衰减,权重/半衰期可配)+ 可选 LLM 重排)、Consolidation(LLM 合成观察记忆、合并重复、清理 90 天过期观察)、MentalModel(心智模型:question/answer/scopeTags/lastRefreshedAt/history/trigger,支持 refresh/delete、persona 懒迁移)、遗忘机制(长期记忆条数上限,默认 100,0=不限制;retain/consolidate 后超限时按「已被观察覆盖 → 普通按衰减分 → observation 沉淀 → 保护项(importance≥0.9 或 pinned)」优先级物理删除多余记忆,上限为硬约束);`repository.ts` 统一存取长期/观察记忆与心智模型,`bank_resolver.ts` 按会话解析记忆库,`migrate.ts` 负责旧记忆自动迁移,`llm.ts` 提供可选的 LLM 抽取/重排/合成/推理回调,`prompt.ts` 负责注入裁剪(scopeTags 严格过滤 + 90 天过期观察剔除 + 心智模型/观察各取最新 5/20 条)。配套记忆工具为 `memory_*` 系列(增删改查/巩固/推理/心智模型),`.ai memo` 子命令重构为 status/add/list/update/delete/clear/obs/consolidate/reflect/mm,范围参数改用 `--u`/`--g`。 +- `v2/`(4.17.0 起):Hindsight-like 纯 TS 记忆引擎。`engine.ts` 实现 Retain(抽取原子事实/观察记忆,可选 LLM 结构化抽取 occurred_at/entities)、Recall(RRF 混合检索 + Hindsight 式新近度加权(按 lastAccessedAt 指数衰减,权重/半衰期可配)+ 可选 LLM 重排)、Consolidation(LLM 合成观察记忆、合并重复、清理 90 天过期观察)、MentalModel(心智模型:question/answer/scopeTags/lastRefreshedAt/history/trigger,支持 refresh/delete、persona 懒迁移)、遗忘机制(长期记忆条数上限,默认 100,0=不限制;retain/consolidate 后超限时按「已被观察覆盖 → 普通按衰减分 → observation 沉淀 → 保护项(importance≥0.9 或 pinned)」优先级物理删除多余记忆,上限为硬约束);`repository.ts` 统一存取长期/观察记忆与心智模型,`bank_resolver.ts` 按会话解析记忆库,`migrate.ts` 负责旧记忆自动迁移,`llm.ts` 提供可选的 LLM 抽取/重排/合成/推理回调,`prompt.ts` 负责注入裁剪(scopeTags 任一命中过滤 + 90 天过期观察剔除 + 心智模型/观察各取最新 5/20 条,观察记忆只在长期记忆段裁剪注入,避免重复)。配套记忆工具为 `memory_*` 系列(增删改查/巩固/推理/心智模型,含 `memory_mm_list/view` 查看心智模型、`memory_mm_delete` 可按 model_id 或 question 删除),`.ai memo` 子命令重构为 status/add/list/update/delete/clear/obs/consolidate/reflect/mm,范围参数改用 `--u`/`--g`。 ## 智能体(src/agent/) diff --git "a/docs/04-\345\267\245\345\205\267\347\263\273\347\273\237.md" "b/docs/04-\345\267\245\345\205\267\347\263\273\347\273\237.md" index 194b17f5..9406f409 100644 --- "a/docs/04-\345\267\245\345\205\267\347\263\273\347\273\237.md" +++ "b/docs/04-\345\267\245\345\205\267\347\263\273\347\273\237.md" @@ -72,7 +72,7 @@ | 分类 | 工具 | | --- | --- | -| 记忆 | `memory_add`、`memory_update`、`memory_delete`、`memory_recall`、`memory_clear`、`memory_reflect`、`memory_consolidate`、`memory_mm_create`、`memory_mm_refresh`、`memory_mm_delete` | +| 记忆 | `memory_add`、`memory_update`、`memory_delete`、`memory_recall`、`memory_clear`、`memory_reflect`、`memory_consolidate`、`memory_mm_list`、`memory_mm_view`、`memory_mm_create`、`memory_mm_refresh`、`memory_mm_delete`(可按 model_id 或 question 删除) | | 知识库 | `knowledge_search`、`knowledge_read`、`knowledge_list`(只读检索,内容由「知识库」配置维护) | | OB11 API | `call_ob11_api`(通过 action 调用消息、查询、管理、文件和合并转发 API) | | 特殊ID | `resolve_special_id`(还原上下文短 ID/句柄为原始字段,用于对接协议 API) | diff --git "a/docs/05-\345\221\275\344\273\244\344\270\216\351\205\215\347\275\256.md" "b/docs/05-\345\221\275\344\273\244\344\270\216\351\205\215\347\275\256.md" index 27616db5..19578def 100644 --- "a/docs/05-\345\221\275\344\273\244\344\270\216\351\205\215\347\275\256.md" +++ "b/docs/05-\345\221\275\344\273\244\344\270\216\351\205\215\347\275\256.md" @@ -52,7 +52,7 @@ .ai memo mm view # 心智模型详情 .ai memo mm add <问题> [答案] # 添加心智模型(不填答案时自动推理;--tag=xx 自定义范围标签) .ai memo mm refresh [ID] # 刷新心智模型(基于当前记忆重新推理) -.ai memo mm del # 删除心智模型 +.ai memo mm del # 删除心智模型(ID 或问题名/关键词,关键词唯一命中时直接删除) .ai kb list # 列出知识库条目索引 .ai kb search <关键词> # 按关键词搜索知识库 .ai kb read # 按 ID 读取知识库条目 @@ -120,7 +120,7 @@ | MCP | 是否启用MCP、MCP服务器配置、MCP会话空闲回收分钟、MCP每服务器最大会话数 | 「是否启用MCP」默认关闭,开启后才会解析并连接 MCP 服务器;仅支持标准 mcpServers JSON(默认后端直接提供 mcp-files-exec(read_file、download_file、run_shell)、md-html-render/render_markdown/render_html,格式见 modelcontextprotocol.io/specification/latest) | | 技能 | 技能配置 | 仅支持标准 SKILL.md(格式见 agentskills.io/specification) | | 记忆 | 启用长期/观察记忆、每隔多少轮对话生成一次观察、每次观察纳入最近的对话轮数、每隔多少次观察整合一次记忆、用LLM抽取记忆/重排召回结果/合成观察记忆/推理记忆(默认开)、记忆召回新近度权重(默认0.4)/半衰期(默认60天)、长期记忆条数上限(默认100)、巩固后自动刷新心智模型(默认开)、心智模型刷新最小间隔(默认30分钟) | 观察记忆增量观察(只观察上次之后的新消息);巩固间隔 0 为关闭;长期记忆条数上限 0 为不限制,超限自动物理删除最不重要的记忆(先删已被观察覆盖的细节,后删重要/置顶保护项),释放存储空间;新近度加权只影响召回排序 | -| 知识库 | 启用知识库记忆、知识库注入阈值(字符)、知识库(Markdown 模板,每条一份完整文档) | 知识库只读,AI 通过 kb 工具/指令检索;修改后需重载 JS 生效;Markdown 语法见 commonmark.org/help/ | +| 知识库 | 启用知识库记忆、知识库注入阈值(字符,已弃用,不再参与注入)、知识库(Markdown 模板,每条一份完整文档) | 知识库只读,AI 通过 knowledge_search/knowledge_read/knowledge_list 工具或 .ai kb 指令检索;注入为按当前对话检索式(最多 5 块),未命中只注入前 50 条索引;修改后需重载 JS 生效;Markdown 语法见 commonmark.org/help/ | | 回复 | 回复引用、回复最大字数、分段发送延时/基础延时/含图额外延时、禁止回复复读 | 回复过滤规则已内置硬编码 | | 后端 | 流式输出、图片转base64、联网搜索、用量图表、论坛地址/API Token/签名密钥、核心桥WS地址/核心桥Token | 可自建,见 [08-相关后端项目](08-相关后端项目.md);md/html 渲染(MCP)在「MCP → MCP服务器配置」;核心桥用于 run_core_command 直连(默认 ws://127.0.0.1:46880/plugin) | | 资源 | 本地图片路径、本地语音路径(支持 `语音名=路径`) | 供 system prompt 列出可发送资源 | diff --git a/scripts/unit-test-entry.ts b/scripts/unit-test-entry.ts index ea08ad30..87ad1f12 100644 --- a/scripts/unit-test-entry.ts +++ b/scripts/unit-test-entry.ts @@ -22,6 +22,7 @@ import { registerCmdModel } from "../src/cmd/sub_cmd/model"; import { registerCmdMemory } from "../src/cmd/sub_cmd/memory"; import { getPlatform, isGroupId, makeGroupId, makeUserId, normalizeGroupId, normalizeTargetId, normalizeUserId, platformOf } from "../src/utils/target_id"; import { MemoryManager, parseOccurredAt } from "../src/memory/manager"; +import { KnowledgeBaseService } from "../src/memory/knowledge_base"; import SessionMemoryService, { parseLooseJson } from "../src/memory/session_memory"; import { MemoryEngine } from "../src/memory/v2/engine"; import { migrateLegacyMemory } from "../src/memory/v2/migrate"; @@ -628,6 +629,98 @@ export const tests: Record void | Promise> = { assert.ok(prompt.includes('用户喜欢简洁的回复'), 'Prompt 应包含心智模型答案'); }, + /** 知识库索引渲染:limit 截断 + 总数提示;不传 limit 保持全量(向后兼容) */ + testKbFormatIndexLimit(): void { + const svc = new KnowledgeBaseService(); + const chunks = Array.from({ length: 120 }, (_, i) => ({ id: `kb_${i + 1}`, title: `条目${i + 1}`, heading: '', content: `内容${i + 1}` })); + const index = svc.formatIndex(chunks as any, 50); + const itemLines = index.split('\n').filter(l => /^\d+\. \[/.test(l)); + assert.equal(itemLines[0], '1. [kb_1] 条目1', '索引首行应为 id + 标题'); + assert.ok(index.includes('共 120 条'), '超限应提示总数'); + assert.ok(itemLines.length <= 50, `索引行数应不超过 limit,实际 ${itemLines.length}`); + const full = svc.formatIndex(chunks as any); + assert.equal(full.split('\n').length, 120, '不传 limit 应全量输出'); + }, + + /** 知识库注入:有 query 时检索式注入命中分块(限 topK);无 query/未命中时只注入限条数索引兜底(不注入正文) */ + async testKbBuildKnowledgePromptRetrieval(): Promise { + const svc = new KnowledgeBaseService(); + const chunks = Array.from({ length: 80 }, (_, i) => ({ id: `kb_${i + 1}`, title: `条目${i + 1}`, heading: '', content: `关于咖啡的说明 ${i + 1}` })); + (svc as any).chunks = chunks; + (svc as any).loadedSignature = 'test'; // 跳过 ensureLoaded 覆盖手动构造的 chunks + const hit = await svc.buildKnowledgePrompt('咖啡'); + assert.ok(hit.startsWith('## 知识库'), '应包含知识库标题'); + assert.ok(hit.includes('关于咖啡的说明'), '检索命中应注入正文分块'); + assert.ok(hit.includes('[kb_'), '命中分块应带 ID'); + const hitBodyLines = hit.split('\n').filter(l => /^\d+\. \[kb_/.test(l)).length; + assert.ok(hitBodyLines <= 5, `检索式注入应限制在 topK,实际 ${hitBodyLines}`); + const miss = await svc.buildKnowledgePrompt('完全不存在的词xyz'); + assert.ok(miss.includes('知识库内容较多'), '未命中应退化为索引提示'); + assert.ok(!miss.includes('关于咖啡的说明'), '索引兜底不应注入正文'); + assert.ok(miss.includes('共 80 条'), '索引兜底应提示总数'); + }, + + /** buildLongTermPrompt:bank 有心智模型时长期记忆段应注入 ## 心智模型 与答案 */ + async testBuildLongTermPromptInjectsMentalModel(): Promise { + const origLongTerm = TC.boolConfigs['启用长期记忆']; + TC.boolConfigs['启用长期记忆'] = true; + resetConfigCache(); + resetMemoryEngineForTest(new InMemoryMemoryStorage()); + try { + const engine = getMemoryEngine(); + const session = new Session(); + session.sessionId = 'QQ:1'; + session.sessionType = 'user'; + session.memory.persona = '无'; + await engine.createMentalModel('user_QQ:1', '这个用户的偏好是什么?', '喜欢简洁回复', ['user:QQ:1']); + const ctx = { isPrivate: true, player: { userId: 'QQ:1', name: '测试员' }, group: null } as any; + const uis = [{ isPrivate: true, id: 'QQ:1', name: '测试员' }] as any; + const prompt = await MemoryManager.buildLongTermPrompt(ctx, session, '你好', uis, null); + assert.ok(prompt.includes('## 心智模型'), '长期记忆段应包含心智模型段'); + assert.ok(prompt.includes('喜欢简洁回复'), '心智模型答案应被注入'); + } finally { + if (origLongTerm === undefined) delete TC.boolConfigs['启用长期记忆']; else TC.boolConfigs['启用长期记忆'] = origLongTerm; + resetConfigCache(); + } + }, + + /** buildObservationPrompt:观察超过上限时裁剪到 MAX_OBSERVATIONS(不再全量注入) */ + testBuildObservationPromptTrimmed(): void { + const origSummary = TC.boolConfigs['启用观察记忆']; + TC.boolConfigs['启用观察记忆'] = true; + resetConfigCache(); + resetMemoryEngineForTest(new InMemoryMemoryStorage()); + try { + const engine = getMemoryEngine(); + const session = new Session(); + session.sessionId = 'QQ:1'; + session.sessionType = 'user'; + const nowSec = Math.floor(Date.now() / 1000); + const repo = engine.repository; + for (let i = 0; i < 25; i++) { + repo.addObservation('user_QQ:1', { + id: `obs_${i}`, + bankId: 'user_QQ:1', + text: `观察${i}`, + scopeTags: [], + evidence: [], + proofCount: 1, + createdAt: nowSec - 1000 + i, + updatedAt: nowSec - 1000 + i, + lastVerifiedAt: nowSec - 10, + history: [], + } as any); + } + const prompt = MemoryManager.buildObservationPrompt(session); + assert.ok(prompt.includes('## 观察记忆'), '观察段应包含标题'); + const lines = prompt.split('\n').filter(l => /^\d+\. /.test(l)); + assert.equal(lines.length, MAX_OBSERVATIONS, `观察应裁剪到上限 ${MAX_OBSERVATIONS}`); + } finally { + if (origSummary === undefined) delete TC.boolConfigs['启用观察记忆']; else TC.boolConfigs['启用观察记忆'] = origSummary; + resetConfigCache(); + } + }, + /** Hindsight-like 新引擎:旧记忆迁移到新 Bank */ async testV2MigrateLegacyMemory(): Promise { const engine = new MemoryEngine({ storage: new InMemoryMemoryStorage() }); @@ -773,14 +866,14 @@ export const tests: Record void | Promise> = { const mkMM = (id: string, question: string, answer: string, scopeTags: string[], updatedAt: number) => makeTestMentalModel(id, question, answer, scopeTags, updatedAt); - // scopeTags 过滤:空=全局放行;任一标签不命中即剔除(all_strict) + // scopeTags 过滤:空=全局放行;任一标签命中即注入;完全不命中才剔除(放宽 all_strict) const sel = selectInjectionCandidates( [mkMM('mm_other', '他人模型', 'C', ['user:QQ:2'], nowSec - 30), mkMM('mm_match', '命中模型', 'B', ['user:QQ:1'], nowSec - 20), mkMM('mm_global', '全局模型', 'A', [], nowSec - 10)], [mkObs('o_other', '他人观察', ['user:QQ:2'], nowSec - 30), mkObs('o_multi', '多标签观察', ['user:QQ:1', 'vis:public'], nowSec - 40), mkObs('o_match', '命中观察', ['user:QQ:1'], nowSec - 20), mkObs('o_global', '全局观察', [], nowSec - 10)], ['user:QQ:1'], now ); - assert.deepEqual(sel.observations.map(o => o.id).sort(), ['o_global', 'o_match'], '空 scopeTags 应全局放行,不匹配/缺标签应剔除'); + assert.deepEqual(sel.observations.map(o => o.id).sort(), ['o_global', 'o_match', 'o_multi'], '任一 scopeTag 命中即注入,空 scopeTags 全局放行,完全不匹配剔除'); assert.deepEqual(sel.mentalModels.map(m => m.id).sort(), ['mm_global', 'mm_match']); // stale 剔除:lastVerifiedAt 距今超过 90 天剔除;缺省 lastVerifiedAt 回退 updatedAt @@ -2839,6 +2932,46 @@ export const tests: Record void | Promise> = { } }, + /** .ai memo:mm del 别名归一化(del→delete)后真正删除;手输 delete 同样生效 */ + async testCmdMemoMmDeleteAlias(): Promise { + const origMemory = SubCmd.map['memory']; + resetMemoryEngineForTest(new InMemoryMemoryStorage()); + try { + registerCmdMemory(); + const groupSession = makeMemoSession('QQ-Group:1064487252'); + const mkGroupScc = (cmdArgs: any) => makeMemoScc({ + session: groupSession, + ctx: { isPrivate: false, group: { groupId: 'QQ-Group:1064487252', groupName: '测试群' } }, + gid: 'QQ-Group:1064487252', + sid: 'QQ-Group:1064487252', + cmdArgs, + }); + const bank = 'group_QQ-Group:1064487252'; + + // add 一条,取回模型 ID + let replied = await runMemo(mkGroupScc(makeMemoArgs(['memo', 'mm', 'add', '群规则是什么?', '不许刷屏']))); + assert.ok(replied.includes('心智模型已添加<'), `add 应返回模型 ID: ${replied}`); + const id = (replied.match(/<([^>]+)>/) || [])[1]; + assert.ok(id, `应能解析出模型 ID: ${replied}`); + assert.equal(getMemoryEngine().listMentalModels(bank).length, 1, 'add 后 bank 应有 1 条'); + + // mm del :del 经 ALIAS_MAP 归一化为 delete,应命中删除分支而非帮助文本 + replied = await runMemo(mkGroupScc(makeMemoArgs(['memo', 'mm', 'del', id]))); + assert.ok(replied.includes('心智模型已删除'), `mm del 应真正删除: ${replied}`); + assert.equal(getMemoryEngine().listMentalModels(bank).length, 0, 'mm del 后 bank 应为空'); + + // 再 add 一条,手输 delete (全名)也应删除 + replied = await runMemo(mkGroupScc(makeMemoArgs(['memo', 'mm', 'add', '偏好?', '简洁']))); + const id2 = (replied.match(/<([^>]+)>/) || [])[1]; + assert.ok(id2, `第二次 add 应返回模型 ID: ${replied}`); + replied = await runMemo(mkGroupScc(makeMemoArgs(['memo', 'mm', 'delete', id2]))); + assert.ok(replied.includes('心智模型已删除'), `mm delete 也应删除: ${replied}`); + assert.equal(getMemoryEngine().listMentalModels(bank).length, 0, 'mm delete 后 bank 应为空'); + } finally { + if (origMemory) SubCmd.map['memory'] = origMemory; else delete SubCmd.map['memory']; + } + }, + /** .ai memo:旧 p|g|st 子命令废弃提示 + 新语法 add --u 正常写入且不落心智模型 */ async testCmdMemoNoStAndOldSyntax(): Promise { const origMemory = SubCmd.map['memory']; @@ -3457,4 +3590,4 @@ export const tests: Record void | Promise> = { assert.equal(bank2.units.length, bank.units.length, '重载后单元数应与内存一致(淘汰已落盘)'); }, -}; \ No newline at end of file +}; diff --git a/src/changelog.ts b/src/changelog.ts index c47a9463..86bb2067 100644 --- a/src/changelog.ts +++ b/src/changelog.ts @@ -9,6 +9,17 @@ export const changelog: { [version: string]: string } = { ## 变更 - 记忆 v2 存储拆分为 5 个分片 key(memv2::meta/units/links/docs/obs):按变更分片重写,避免整库大 JSON 反复全量序列化;旧版单 key(memv2::bank)首次读取时一次性自动迁移并清空旧 key - 记忆写入收敛:retain / consolidate 全程内存累计变更、末尾统一落盘一次(原逐条整库序列化);consolidate 触发条数上限淘汰时,淘汰结果即时落盘,不再停留在内存(重载后不会复活) + +## 修复 +- 修复 .ai memo mm del 删除失效:del 经别名归一化为 delete 后未命中删除分支,实际返回帮助文本;现按归一化目标匹配,del / delete 均可删除,并支持按问题名或关键词删除(唯一命中直接删,多条列出 ID 由用户指定) +- 知识库注入改为检索式:按当前对话内容只注入命中的知识库分块(最多 5 个),未命中时只注入前 50 条条目索引兜底,不再全量注入正文/索引;knowledge_list 工具与 .ai kb list 索引上限 200 条,超出提示用 knowledge_search 检索 +- 修复心智模型/观察记忆 scope 严格匹配导致的注入丢失:改为任一标签命中即注入,群聊中不再因某贡献者不在最近消息而整条剔除;memory_mm_create / .ai memo mm add 的 scope_tag 非法或与目标类型不符时回退默认标签,避免静默失效 +- 观察记忆注入收敛:启用长期记忆时观察只由长期记忆段裁剪注入(上限 20 + 90 天过期剔除),不再与观察段重复渲染;观察段同样按上限裁剪;巩固生成观察后刷新观察段缓存,消除最长 60 秒滞后 +- 记忆/知识库工具描述修正:memory_update 引用的 search_memory 改为 memory_recall;knowledge_read 引用的 kb_list/kb_search 改为 knowledge_list/knowledge_search + +## 新功能 +- 新增 memory_mm_list / memory_mm_view 工具:模型可通过 function calling 列出/查看心智模型;memory_mm_delete 支持按问题名删除(model_id 与 question 二选一) +- .ai memo status 显示心智模型将注入条数(按当前会话范围估算) `, "4.19.2": `## 配置变更 - 复杂配置(不适合热加载)改为「重载 JS 才生效」:纯文本/多模态/嵌入模型、评分触发配置、MCP 服务器配置、技能配置、知识库、角色扮演设定、触发/忽略正则、本地图片/语音/文件/视频路径、音乐服务配置等,现在启动时解析一次并常驻内存,修改后需在海豹面板「插件管理 → 重载 JS」(或重启海豹)才生效,避免配置与运行时解析结果不一致 - 简单配置(开关/数值/单行字符串/纯字符串数组等)仍按原样 60 秒内自动生效;MCP 服务器列表与总开关、技能、知识库等修改后不再热加载,需重载 JS diff --git a/src/cmd/sub_cmd/kb.ts b/src/cmd/sub_cmd/kb.ts index c178941b..cb5c8fcb 100644 --- a/src/cmd/sub_cmd/kb.ts +++ b/src/cmd/sub_cmd/kb.ts @@ -1,5 +1,5 @@ // .ai kb:知识库只读查询(list/search/read) -import { knowledgeService } from "../../memory/knowledge"; +import { KB_LIST_LIMIT, knowledgeService } from "../../memory/knowledge"; import { aliasToCmd } from "../../utils/utils"; import { U } from "../privilege"; import { SubCmd, SubCmdContext } from "../root_cmd"; @@ -24,7 +24,7 @@ export function registerCmdKB() { switch (val2) { case 'list': { await knowledgeService.init(); - const index = knowledgeService.formatIndex(); + const index = knowledgeService.formatIndex(undefined, KB_LIST_LIMIT); seal.replyToSender(ctx, msg, index ? index : '知识库为空'); return ret; } diff --git a/src/cmd/sub_cmd/memory.ts b/src/cmd/sub_cmd/memory.ts index f7360e63..c5064202 100644 --- a/src/cmd/sub_cmd/memory.ts +++ b/src/cmd/sub_cmd/memory.ts @@ -4,7 +4,7 @@ import { PRIVILEGE_LEVEL_MAP } from "../../config/static_config"; import { MemoryManager } from "../../memory/manager"; import { bumpMemoryRevision } from "../../memory/revision"; import { resolveBankId } from "../../memory/v2/bank_resolver"; -import { getMemoryEngine, OBSERVATION_STALE_DAYS } from "../../memory/v2/index"; +import { getMemoryEngine, OBSERVATION_STALE_DAYS, selectInjectionCandidates } from "../../memory/v2/index"; import { Session } from "../../session/session"; import { getSession } from "../../session/session_service"; import { fmtDate, stripInternalTags } from "../../utils/string"; @@ -156,7 +156,7 @@ const MEMO_MM_HELP = `心智模型操作: 【.ai memo mm view 】查看心智模型详情 【.ai memo mm add <问题> [答案]】添加心智模型(不填答案时自动推理;--tag=xx 自定义范围标签) 【.ai memo mm refresh [ID]】刷新心智模型(基于当前记忆重新推理) - 【.ai memo mm del 】删除心智模型 + 【.ai memo mm del 】删除心智模型(ID 或问题名/关键词,关键词唯一命中时直接删除) ${MEMO_SCOPE_NOTE}`; export function registerCmdMemory() { const cmd = new SubCmd('memory'); @@ -221,12 +221,16 @@ export function registerCmdMemory() { const summarySuffix = targetSession.memory.summaryOverride === undefined ? '' : '(会话级)'; const statusModels = getMemoryEngine().listMentalModels(bank.bankId); const modelOverview = statusModels.length === 0 ? '(空)' : statusModels.map(m => `${m.question}${m.version > 1 ? `(v${m.version})` : ''}`).join(';'); + // 注入可见性:按当前目标会话的 user/group 标签宽松估算「将注入」条数(运行时还会叠加最近发言人标签) + const injectableModels = selectInjectionCandidates(statusModels, [], [ + target.kind === 'group' ? `group:${targetSession.sessionId}` : `user:${targetSession.sessionId}` + ]).mentalModels.length; seal.replyToSender(ctx, msg, `${target.explicit ? prefix : `${prefix} ${targetSession.id}`} 长期记忆开启状态: ${isMemory ? '是' : '否'} 长期记忆条数: ${targetSession.memory.memoryIds.length}${capText} 观察记忆开启状态: ${summaryEffective ? '是' : '否'}${summarySuffix} 观察记忆条数: ${getMemoryEngine().repository.listObservations(bank.bankId).length} - 心智模型条数: ${statusModels.length} + 心智模型条数: ${statusModels.length}(将注入 ${injectableModels} 条) 心智模型概览: ${modelOverview}`); return ret; } @@ -510,7 +514,11 @@ export function registerCmdMemory() { } const answer = cmdArgs.getRestArgsFrom(5); const tagKw = cmdArgs.getKwarg('tag'); - const scopeTags = tagKw && tagKw.value ? [tagKw.value] : [target.kind === 'group' ? `group:${targetSession.sessionId}` : `user:${targetSession.sessionId}`]; + // --tag 校验/归一化:必须是 user:/group: 前缀,且个人目标只接受 user:; + // 非法或与目标类型不符时回退默认标签,避免心智模型因标签不匹配而注入失效 + const rawTag = tagKw && tagKw.value ? String(tagKw.value).trim() : ''; + const tagOk = rawTag && (rawTag.startsWith('user:') || rawTag.startsWith('group:')) && (target.kind === 'group' || rawTag.startsWith('user:')); + const scopeTags = tagOk ? [rawTag] : [target.kind === 'group' ? `group:${targetSession.sessionId}` : `user:${targetSession.sessionId}`]; if (!answer) { const result = await mmEngine.reflect(bank.bankId, question); const m = await mmEngine.createMentalModel(bank.bankId, question, result.text, scopeTags); @@ -541,20 +549,34 @@ export function registerCmdMemory() { seal.replyToSender(ctx, msg, `${prefix}已刷新 ${updated} 条,跳过 ${total - updated} 条`); return ret; } - case 'del': { + case 'delete': { const id = cmdArgs.getArgN(4); if (!id) { - seal.replyToSender(ctx, msg, '参数缺失,【.ai memo mm del 】删除心智模型'); + seal.replyToSender(ctx, msg, '参数缺失,【.ai memo mm del 】删除心智模型'); return ret; } - const ok = mmEngine.deleteMentalModel(bank.bankId, id); + // 优先按 ID 精确删除,其次按问题名精确匹配,再按问题关键词(唯一命中时删除,多条列出让用户用 ID 指定) + const models = mmEngine.listMentalModels(bank.bankId); + const byId = models.find(x => x.id === id); + const byQuestion = byId ? null : models.find(x => x.question === id); + const byKeyword = byId || byQuestion ? null : models.filter(x => x.question.includes(id)); + const target = byId || byQuestion || (byKeyword && byKeyword.length === 1 ? byKeyword[0] : null); + if (!target) { + if (byKeyword && byKeyword.length > 1) { + seal.replyToSender(ctx, msg, `关键词「${id}」匹配到 ${byKeyword.length} 条心智模型,请用 ID 指定:\n${byKeyword.map(m => `${m.id} ${m.question}`).join('\n')}`); + return ret; + } + seal.replyToSender(ctx, msg, `未找到心智模型<${id}>`); + return ret; + } + const ok = mmEngine.deleteMentalModel(bank.bankId, target.id); if (!ok) { seal.replyToSender(ctx, msg, `未找到心智模型<${id}>`); return ret; } bumpMemoryRevision(); targetSession.save(); - seal.replyToSender(ctx, msg, `${prefix}心智模型已删除<${id}>`); + seal.replyToSender(ctx, msg, `${prefix}心智模型已删除<${target.id}>:${target.question}`); return ret; } default: { @@ -576,4 +598,4 @@ export function registerCmdMemory() { } } } -} \ No newline at end of file +} diff --git a/src/config/configs/knowledge_base.ts b/src/config/configs/knowledge_base.ts index 70d71572..8fe96988 100644 --- a/src/config/configs/knowledge_base.ts +++ b/src/config/configs/knowledge_base.ts @@ -5,7 +5,7 @@ import { ext } from "../config"; export default class KnowledgeBaseConfig { static register() { seal.ext.registerBoolConfig(ext, "启用知识库记忆", false, "开启后把知识库内容注入 system prompt,供对话参考", "知识库"); - seal.ext.registerIntConfig(ext, "知识库注入阈值(字符)", 5000, "知识库总内容不超过该值时全量注入 system prompt;超过时只注入条目索引,需要详情时模型用 kb_read 工具读取。设为 0 时始终只注入索引", "知识库"); + seal.ext.registerIntConfig(ext, "知识库注入阈值(字符)", 5000, "已弃用:知识库改为按当前对话内容检索式注入(最多 5 个分块),未命中时只注入前 50 条条目索引;需要详情时模型用 knowledge_read 工具读取。该配置不再参与注入", "知识库"); seal.ext.registerTemplateConfig(ext, "知识库", [ `# AI 骰娘4 使用指南 diff --git a/src/memory/knowledge.ts b/src/memory/knowledge.ts index b96b5599..fdec9713 100644 --- a/src/memory/knowledge.ts +++ b/src/memory/knowledge.ts @@ -4,3 +4,4 @@ import { knowledgeBase, KnowledgeBaseService } from "./knowledge_base"; export { knowledgeBase as knowledgeService }; export default knowledgeBase; export { KnowledgeBaseService }; +export { KB_INDEX_LIMIT, KB_INJECT_TOP_K, KB_LIST_LIMIT } from "./knowledge_base"; diff --git a/src/memory/knowledge_base.ts b/src/memory/knowledge_base.ts index 424f0210..baf5767d 100644 --- a/src/memory/knowledge_base.ts +++ b/src/memory/knowledge_base.ts @@ -9,6 +9,12 @@ import { hashString, KnowledgeChunk, splitMarkdownIntoChunks } from "./knowledge // 向量重排候选上限:仅对关键词命中的前 N 块做惰性嵌入,控制单次检索的 API 开销 const VECTOR_RERANK_CANDIDATE_LIMIT = 20; +/** 知识库索引渲染上限:注入兜底/列表展示时控制单次输出条数,超出提示用 knowledge_search 检索 */ +export const KB_INDEX_LIMIT = 50; +/** 主动查询(.ai kb list / knowledge_list 工具)的索引上限,避免超大知识库一次输出过长 */ +export const KB_LIST_LIMIT = 200; +/** 检索式注入上限:按当前对话内容命中时最多注入的分块数 */ +export const KB_INJECT_TOP_K = 5; export class KnowledgeBaseService { private chunks: KnowledgeChunk[] = []; @@ -203,29 +209,35 @@ export class KnowledgeBaseService { return `[${chunk.id}] ${chunk.title}${heading}\n${chunk.content}`; } - /** 索引渲染:只给 id 与标题,供列表/超阈值注入使用 */ - formatIndex(chunks: KnowledgeChunk[] = this.chunks): string { + /** 索引渲染:只给 id 与标题,供列表/超阈值注入使用;limit>0 时只取前 limit 条并提示总数 */ + formatIndex(chunks: KnowledgeChunk[] = this.chunks, limit = 0): string { if (chunks.length === 0) return ''; - return chunks.map((c, i) => { + const list = limit > 0 ? chunks.slice(0, limit) : chunks; + const lines = list.map((c, i) => { const heading = c.heading ? ` - ${c.heading}` : ''; return `${i + 1}. [${c.id}] ${c.title}${heading}`; }).join('\n'); + if (limit > 0 && chunks.length > limit) { + return `${lines}\n…共 ${chunks.length} 条,检索请用 knowledge_search / knowledge_read`; + } + return lines; } /** - * 构造 system prompt 的知识库段。 - * 总内容不超过「知识库注入阈值(字符)」时全量注入;超过则只注入标题索引, - * 模型需要详情时通过 kb_read 工具按 ID 读取。 + * 构造 system prompt 的知识库段(检索式注入,不再全量注入正文): + * - 传入 query 时按当前对话内容检索,只注入命中的 topK 分块; + * - 无 query 或未命中时只注入限条数索引兜底,模型需要详情时通过 knowledge_read 按 ID 读取。 */ - buildKnowledgePrompt(): string { + async buildKnowledgePrompt(query = ''): Promise { if (this.chunks.length === 0) return ''; - const { KNOWLEDGE_INJECT_THRESHOLD } = Config.knowledgeBase; - const total = this.chunks.reduce((sum, c) => sum + c.content.length, 0); - if (total <= KNOWLEDGE_INJECT_THRESHOLD) { - const body = this.chunks.map((c, i) => `${i + 1}. ${this.formatChunk(c)}`).join('\n\n'); - return `## 知识库\n${body}`; + const trimmed = String(query || '').trim(); + if (trimmed) { + const hits = await this.search(trimmed, KB_INJECT_TOP_K); + if (hits.length > 0) { + return `## 知识库\n${hits.map((c, i) => `${i + 1}. ${this.formatChunk(c)}`).join('\n\n')}`; + } } - return `## 知识库\n知识库内容较多(共 ${this.chunks.length} 个分块),以下为条目索引,需要详情时使用 kb_read 工具按 ID 读取:\n${this.formatIndex()}`; + return `## 知识库\n知识库内容较多(共 ${this.chunks.length} 个分块),以下为条目索引,需要详情时使用 knowledge_read 工具按 ID 读取:\n${this.formatIndex(this.chunks, KB_INDEX_LIMIT)}`; } } diff --git a/src/memory/manager.ts b/src/memory/manager.ts index 77262225..9a5d813d 100644 --- a/src/memory/manager.ts +++ b/src/memory/manager.ts @@ -1,6 +1,7 @@ // 记忆管理器:统一长期/观察/知识库的读取入口,底层只使用 Hindsight-like 新引擎。 import Agent from "../agent/agent"; import Config from "../config/config"; +import Logger from "../logger"; import { SUMMARY_PROMPT_TEMPLATE } from "../prompt/templates"; import Image from "../resource/image"; import Group from "../session/group"; @@ -11,7 +12,7 @@ import { buildContent } from "../utils/message"; import { stripInternalTags } from "../utils/string"; import { knowledgeService } from "./knowledge"; -import { bumpMemoryRevision } from "./revision"; +import { bumpMemoryRevision, bumpSummaryRevision } from "./revision"; import { parseLooseJson } from "./session_memory"; import { resolveBankId } from "./v2/bank_resolver"; import { getMemoryEngine, MENTAL_MODEL_PERSONA_QUESTION } from "./v2/index"; @@ -72,6 +73,7 @@ export class MemoryManager { const allMentalModels = engine.listMentalModels(bank.bankId); // E1/E2:scopeTags 过滤 + stale 剔除 + 条数上限(注入裁剪,控制 token) const { mentalModels, observations } = selectInjectionCandidates(allMentalModels, allObservations, tags); + Logger.debug(`[记忆注入] bank=${bank.bankId} tags=[${tags.join(',')}] 心智模型 ${allMentalModels.length}→${mentalModels.length} 观察 ${allObservations.length}→${observations.length}`); const sessionName = ctx.isPrivate ? (ctx.player?.name || '') : (ctx.group?.groupName || ''); return buildMemoryPrompt({ isPrivate: ctx.isPrivate, @@ -90,15 +92,19 @@ export class MemoryManager { const bank = bankForSession(session); const observations = engine.repository.listObservations(bank.bankId); if (observations.length === 0) return ''; - return '## 观察记忆\n' + observations.map((o, i) => `${i + 1}. ${o.text}`).join('\n'); + // 与长期记忆段的观察注入同口径:scope 过滤 + stale 剔除 + 条数上限(按会话自身标签宽松匹配) + const sessionTag = session.sessionType === 'group' ? `group:${session.sessionId}` : `user:${session.sessionId}`; + const { observations: injectable } = selectInjectionCandidates([], observations, [sessionTag]); + if (injectable.length === 0) return ''; + return '## 观察记忆\n' + injectable.map((o, i) => `${i + 1}. ${o.text}`).join('\n'); } - /** 知识库段:按开关构建(配置驱动,全局单例加载) */ - static async buildKnowledgePrompt(_session: Session, _text: string): Promise { + /** 知识库段:按开关构建,按当前对话文本检索式注入(配置驱动,全局单例加载) */ + static async buildKnowledgePrompt(_session: Session, text: string): Promise { const { KNOWLEDGE } = Config.knowledgeBase; if (!KNOWLEDGE) return ''; await knowledgeService.init(); - return knowledgeService.buildKnowledgePrompt(); + return knowledgeService.buildKnowledgePrompt(text); } /** 写入记忆:统一入口,返回新引擎结果 */ @@ -158,6 +164,7 @@ export class MemoryManager { const engine = getMemoryEngine(); const bank = bankForSession(session); const result = await engine.consolidate(bank.bankId); + bumpSummaryRevision(); // R2:巩固后自动刷新心智模型(引擎层防重入 + 最小间隔限流) if (Config.memory.MEMORY_REFRESH_AFTER_CONSOLIDATE) { await engine.refreshMentalModels(bank.bankId); @@ -260,6 +267,7 @@ export class MemoryManager { const since = engine.getConsolidateSince(bank.bankId) + 1; if (since >= consolidateInterval) { await engine.consolidate(bank.bankId); + bumpSummaryRevision(); engine.setConsolidateSince(bank.bankId, 0); // R2:巩固后自动刷新心智模型(引擎层防重入 + 最小间隔限流) if (Config.memory.MEMORY_REFRESH_AFTER_CONSOLIDATE) { diff --git a/src/memory/v2/prompt.ts b/src/memory/v2/prompt.ts index 4619acf5..7b394310 100644 --- a/src/memory/v2/prompt.ts +++ b/src/memory/v2/prompt.ts @@ -15,7 +15,8 @@ export interface InjectionCandidates { /** * 注入候选筛选(E1/E2): - * - scopeTags 过滤:默认 all_strict,所有 scopeTags 必须命中当前会话 tags(空 scopeTags 视为全局,放行) + * - scopeTags 过滤:任一命中即注入(空 scopeTags 视为全局放行), + * 避免 all_strict 下群聊中「未在最近消息出现的贡献者」导致整条被剔除 * - stale 剔除:观察记忆按 lastVerifiedAt 过期窗口过滤 * - 条数上限:心智模型 MAX_MENTAL_MODELS、观察 MAX_OBSERVATIONS,按更新时间倒序取最新 */ @@ -28,7 +29,8 @@ export function selectInjectionCandidates( const tagSet = new Set(sessionTags); const scopeMatch = (scope: string[] | undefined): boolean => { const tags = scope || []; - return tags.every(t => tagSet.has(t)); + if (tags.length === 0) return true; + return tags.some(t => tagSet.has(t)); }; const staleCutoff = Math.floor(now / 1000) - OBSERVATION_STALE_DAYS * 86400; const fresh = (observations || []) diff --git a/src/prompt/builder.ts b/src/prompt/builder.ts index 7bc16733..e939e0f8 100644 --- a/src/prompt/builder.ts +++ b/src/prompt/builder.ts @@ -147,7 +147,9 @@ export async function buildSystemPromptContent( const memoryTask = Config.memory.MEMORY ? getCachedString(memoryKey, LONG_TERM_MEMORY_TTL, () => MemoryManager.buildLongTermPrompt(ctx, session, text, uis, gi || null)) : Promise.resolve(''); - const summaryTask = Config.memory.SUMMARY + // 观察记忆只保留一条注入路径:MEMORY 开启时已由 buildLongTermPrompt 裁剪注入, + // 此处仅在 MEMORY 关闭且 SUMMARY 开启时兜底渲染,避免同一批观察重复注入 + const summaryTask = (!Config.memory.MEMORY && Config.memory.SUMMARY) ? getCachedString(summaryKey, SUMMARY_TTL, () => MemoryManager.buildObservationPrompt(session)) : Promise.resolve(''); const knowledgeTask = Config.knowledgeBase.KNOWLEDGE diff --git a/src/tool/tools/memory/tool_knowledge.ts b/src/tool/tools/memory/tool_knowledge.ts index d9cd50c2..6c92bf0c 100644 --- a/src/tool/tools/memory/tool_knowledge.ts +++ b/src/tool/tools/memory/tool_knowledge.ts @@ -1,5 +1,5 @@ // 知识库工具:只读检索(search/read/list),内容由配置维护,AI 不能增删 -import { knowledgeService } from "../../../memory/knowledge"; +import { KB_LIST_LIMIT, knowledgeService } from "../../../memory/knowledge"; import Tool from "../../tool"; // 单次检索返回条数上限,防止模型请求超大 topK 造成上下文/API 浪费 @@ -39,7 +39,7 @@ export function registerKnowledgeTools() { type: 'function', function: { name: 'knowledge_read', - description: '按 ID 读取知识库中某个条目的完整内容,ID 来自 kb_list 或 kb_search 的结果', + description: '按 ID 读取知识库中某个条目的完整内容,ID 来自 knowledge_list 或 knowledge_search 的结果', parameters: { type: 'object', properties: { @@ -72,7 +72,7 @@ export function registerKnowledgeTools() { }); toolList.solve = async () => { await knowledgeService.init(); - const index = knowledgeService.formatIndex(); + const index = knowledgeService.formatIndex(undefined, KB_LIST_LIMIT); return index ? index : '知识库为空'; }; } diff --git a/src/tool/tools/memory/tool_memory.ts b/src/tool/tools/memory/tool_memory.ts index b34b61ec..d2f34578 100644 --- a/src/tool/tools/memory/tool_memory.ts +++ b/src/tool/tools/memory/tool_memory.ts @@ -1,7 +1,7 @@ // 记忆工具:添加/更新/删除/搜索/清除记忆 import Config from "../../../config/config"; import { MemoryManager } from "../../../memory/manager"; -import { bumpMemoryRevision } from "../../../memory/revision"; +import { bumpMemoryRevision, bumpSummaryRevision } from "../../../memory/revision"; import { resolveTargetSession } from "../../../memory/session_target"; import { resolveBankId } from "../../../memory/v2/bank_resolver"; import { getMemoryEngine } from "../../../memory/v2/index"; @@ -414,7 +414,7 @@ export function registerMemory() { }, id: { type: 'string', - description: '要更新的记忆ID(来自记忆列表或 search_memory 结果)' + description: '要更新的记忆ID(来自 memory_recall 搜索结果)' }, text: { type: 'string', @@ -514,6 +514,7 @@ export function registerMemory() { if (!target) return `目标ID格式无效<${target_id}>`; const bank = resolveBankId(target.sessionId, target.sessionType === 'group' ? 'group' : 'user', target.agentName); const result = await getMemoryEngine().consolidate(bank.bankId); + bumpSummaryRevision(); // R2:巩固后自动刷新心智模型(引擎层防重入 + 最小间隔限流) let refreshText = ''; if (Config.memory.MEMORY_REFRESH_AFTER_CONSOLIDATE) { @@ -525,6 +526,63 @@ export function registerMemory() { }; + const toolMmList = new Tool({ + type: 'function', + function: { + name: 'memory_mm_list', + description: '列出指定个人或群聊的心智模型(ID + 问题 + 版本),用于定位要查看/刷新/删除的模型;返回分页列表与总数', + parameters: { + type: 'object', + properties: { + memory_type: { type: 'string', description: '记忆归属,个人或群聊', enum: ['private', 'group'] }, + target_id: { type: 'string', description: '目标用户ID或群ID' }, + page: { type: 'number', description: '可选:页码,默认 1,每页 10 条' } + }, + required: ['memory_type', 'target_id'] + } + } + }); + toolMmList.solve = async (_ctx, _, session, args) => { + const { memory_type, target_id, page } = args; + const target = resolveTargetSession(session, memory_type, target_id); + if (!target) return `目标ID格式无效<${target_id}>`; + const bank = resolveBankId(target.sessionId, target.sessionType === 'group' ? 'group' : 'user', target.agentName); + const models = getMemoryEngine().listMentalModels(bank.bankId); + if (models.length === 0) return '暂无心智模型'; + const perPage = 10; + const totalPages = Math.max(1, Math.ceil(models.length / perPage)); + const p = typeof page === 'number' && page > 0 ? Math.min(Math.floor(page), totalPages) : 1; + const items = models.slice((p - 1) * perPage, p * perPage); + const lines = items.map(m => `${m.id} ${m.question} (v${m.version} · 更新于${new Date((m.updatedAt || 0) * 1000).toISOString().slice(0, 10)})`); + return `心智模型 ${models.length} 条\n${lines.join('\n')}\n当前页码: ${p}/${totalPages}`; + }; + + const toolMmView = new Tool({ + type: 'function', + function: { + name: 'memory_mm_view', + description: '查看指定个人或群聊的一条心智模型详情(问题/答案/范围/版本/更新时间)', + parameters: { + type: 'object', + properties: { + memory_type: { type: 'string', description: '记忆归属,个人或群聊', enum: ['private', 'group'] }, + target_id: { type: 'string', description: '目标用户ID或群ID' }, + model_id: { type: 'string', description: '心智模型 ID(来自 memory_mm_list)' } + }, + required: ['memory_type', 'target_id', 'model_id'] + } + } + }); + toolMmView.solve = async (_ctx, _, session, args) => { + const { memory_type, target_id, model_id } = args; + const target = resolveTargetSession(session, memory_type, target_id); + if (!target) return `目标ID格式无效<${target_id}>`; + const bank = resolveBankId(target.sessionId, target.sessionType === 'group' ? 'group' : 'user', target.agentName); + const m = getMemoryEngine().listMentalModels(bank.bankId).find(x => x.id === String(model_id)); + if (!m) return `未找到心智模型<${model_id}>`; + return `【心智模型】${m.question}\n${m.answer}\nID: ${m.id} · v${m.version}\n范围: ${m.scopeTags.join(', ') || '(全局)'}\n创建: ${new Date((m.createdAt || 0) * 1000).toISOString()}\n更新: ${new Date((m.updatedAt || 0) * 1000).toISOString()}`; + }; + const toolMmCreate = new Tool({ type: 'function', function: { @@ -550,7 +608,11 @@ export function registerMemory() { const bank = resolveBankId(target.sessionId, target.sessionType === 'group' ? 'group' : 'user', target.agentName); getMemoryEngine().ensureBank(bank.bankId, bank.kind, bank.agentName); const defaultTag = target.sessionType === 'group' ? `group:${target.sessionId}` : `user:${target.sessionId}`; - const scopeTags = typeof scope_tag === 'string' && scope_tag.trim() ? [scope_tag.trim()] : [defaultTag]; + // scope_tag 校验/归一化:必须是 user:/group: 前缀,且私聊目标只接受 user:(群标签无意义); + // 非法或与目标类型不符时回退默认标签,避免心智模型因标签不匹配而注入失效 + const rawTag = typeof scope_tag === 'string' ? scope_tag.trim() : ''; + const tagOk = rawTag && (rawTag.startsWith('user:') || rawTag.startsWith('group:')) && (target.sessionType === 'group' || rawTag.startsWith('user:')); + const scopeTags = tagOk ? [rawTag] : [defaultTag]; if (typeof answer === 'string' && answer.trim()) { const m = await getMemoryEngine().createMentalModel(bank.bankId, String(question || ''), stripInternalTags(answer.trim()), scopeTags); bumpMemoryRevision(); @@ -601,28 +663,41 @@ export function registerMemory() { type: 'function', function: { name: 'memory_mm_delete', - description: '删除指定个人或群聊的一条心智模型', + description: '删除指定个人或群聊的一条心智模型:传 model_id(来自 memory_mm_list)或 question(问题名,精确匹配),二者至少提供一个', parameters: { type: 'object', properties: { memory_type: { type: 'string', description: '记忆归属,个人或群聊', enum: ['private', 'group'] }, target_id: { type: 'string', description: '目标用户ID或群ID' }, - model_id: { type: 'string', description: '要删除的心智模型 ID' } + model_id: { type: 'string', description: '可选:要删除的心智模型 ID(来自 memory_mm_list)' }, + question: { type: 'string', description: '可选:要删除的心智模型问题名(精确匹配,与 model_id 二选一)' } }, - required: ['memory_type', 'target_id', 'model_id'] + required: ['memory_type', 'target_id'] } } }); toolMmDelete.solve = async (_ctx, _, session, args) => { - const { memory_type, target_id, model_id } = args; + const { memory_type, target_id, model_id, question } = args; const target = resolveTargetSession(session, memory_type, target_id); if (!target) return `目标ID格式无效<${target_id}>`; const bank = resolveBankId(target.sessionId, target.sessionType === 'group' ? 'group' : 'user', target.agentName); - const ok = getMemoryEngine().deleteMentalModel(bank.bankId, String(model_id)); - if (!ok) return `未找到心智模型<${model_id}>`; + const engine = getMemoryEngine(); + const models = engine.listMentalModels(bank.bankId); + const mid = model_id !== undefined && model_id !== null && String(model_id).trim() !== '' ? String(model_id).trim() : ''; + const q = typeof question === 'string' ? question.trim() : ''; + const targetModel = mid + ? models.find(m => m.id === mid) + : q ? models.find(m => m.question === q) : undefined; + if (!targetModel) { + if (mid) return `未找到心智模型<${mid}>`; + if (q) return `未找到问题为「${q}」的心智模型`; + return '缺少 model_id 或 question'; + } + const ok = engine.deleteMentalModel(bank.bankId, targetModel.id); + if (!ok) return `未找到心智模型<${targetModel.id}>`; bumpMemoryRevision(); SessionService.save(target); - return `心智模型已删除<${model_id}>`; + return `心智模型已删除<${targetModel.id}>:${targetModel.question}`; }; const toolReflect = new Tool({ From c5b0337bf641dfaebf3057a6e74647a4fdf004cd Mon Sep 17 00:00:00 2001 From: error2913 <2913949387@qq.com> Date: Tue, 1 Sep 2026 02:53:48 +0800 Subject: [PATCH 2/7] =?UTF-8?q?fix(memory):=20=E7=9F=A5=E8=AF=86=E5=BA=93?= =?UTF-8?q?=E6=B3=A8=E5=85=A5=E8=B6=85=E9=A2=84=E7=AE=97=E6=97=B6=E4=BC=98?= =?UTF-8?q?=E5=85=88=E5=B1=95=E7=A4=BA=E6=9D=A1=E7=9B=AE=E7=B4=A2=E5=BC=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 命中正文超过 KB_INJECT_MAX_CHARS(1500) 字符预算时不再注入正文, 改为预算优先用于条目索引(尽可能多列,上限 KB_INDEX_LIMIT=100 条), 详情由模型用 knowledge_read 按 ID 读取 - 索引兜底按字符预算动态截断,末尾提示剩余条数用 knowledge_search/knowledge_read - 新增单测:命中正文超预算降级为索引 - 同步 changelog/README/docs --- README.md | 2 +- ...66\346\236\204\350\256\276\350\256\241.md" | 2 +- ...41\345\235\227\350\257\246\350\247\243.md" | 2 +- ...44\344\270\216\351\205\215\347\275\256.md" | 2 +- src/changelog.ts | 2 +- src/memory/knowledge_base.ts | 37 ++++++++++++++++--- 6 files changed, 37 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index a74a29dd..d688f49a 100644 --- a/README.md +++ b/README.md @@ -280,7 +280,7 @@ max_tokens = 2048 | 设置项 | 说明 | |:---:|:---| | 启用知识库记忆 | 开启后把知识库内容注入 system prompt,供对话参考 | -| 知识库注入阈值(字符) | 已弃用:知识库改为按当前对话检索式注入(最多 5 个分块),未命中只注入前 50 条条目索引;模型用 knowledge_read 工具读取详情(保留该配置仅为兼容旧存档) | +| 知识库注入阈值(字符) | 已弃用:知识库改为按当前对话检索式注入,命中正文在 1500 字符预算内才注入(最多 5 个分块),否则预算优先用于条目索引(尽可能多列,最多 100 条);模型用 knowledge_read 工具读取详情(保留该配置仅为兼容旧存档) | | 知识库 | Markdown 模板,每条一份完整文档(# 条目标题、##/### 小节,超长自动分块);只读,内容由管理员维护,AI 通过 kb 工具/指令检索。语法定义见 [CommonMark 规范](https://commonmark.org/help/) | ### 回复 diff --git "a/docs/02-\346\236\266\346\236\204\350\256\276\350\256\241.md" "b/docs/02-\346\236\266\346\236\204\350\256\276\350\256\241.md" index 9729c1b7..a6bfc2fa 100644 --- "a/docs/02-\346\236\266\346\236\204\350\256\276\350\256\241.md" +++ "b/docs/02-\346\236\266\346\236\204\350\256\276\350\256\241.md" @@ -122,7 +122,7 @@ SYSTEM_MESSAGE_TEMPLATE(用户可编辑的骨架) ├── 图片/音频资源(本地图片、本地语音) ├── memoryPrompt:长期记忆段(按开关 MEMORY) ├── summaryPrompt:观察记忆段(按开关 SUMMARY) -├── knowledgePrompt:知识库段(按开关 KNOWLEDGE;按当前对话检索式注入命中分块,未命中只注入限条数索引兜底,模型用 knowledge_read 读取详情) +├── knowledgePrompt:知识库段(按开关 KNOWLEDGE;按当前对话检索式注入,命中正文在 1500 字符预算内才注入(≤5 块),否则预算优先用于条目索引(尽可能多列,≤100 条),模型用 knowledge_read 读取详情) └── toolPrompt:工具函数段(仅提示词工程模式) 最后追加"可用技能"摘要段(两种模式都可见) ``` diff --git "a/docs/03-\346\240\270\345\277\203\346\250\241\345\235\227\350\257\246\350\247\243.md" "b/docs/03-\346\240\270\345\277\203\346\250\241\345\235\227\350\257\246\350\247\243.md" index 1fdd8aa4..1ca77839 100644 --- "a/docs/03-\346\240\270\345\277\203\346\250\241\345\235\227\350\257\246\350\247\243.md" +++ "b/docs/03-\346\240\270\345\277\203\346\250\241\345\235\227\350\257\246\350\247\243.md" @@ -124,7 +124,7 @@ - `memory.ts`:`MemoryService` 提供 `memoryMap` 增删改查、`search()`(方法:importance/similarity/score/early/late/recent)、去重合并、按分数淘汰;内含旧格式迁移逻辑(兼容早期 `src/AI/memory.ts` 存档)。 - `session_memory.ts`:`SessionMemoryService` 提供会话级观察记忆:`summarize()` 把游标之后 `SUMMARY_SIZE` 轮对话交给 `summarize_agent`,解析 JSON 摘要与事实列表(facts,含 op/type/importance),写入 `summaries` 并落库;旧存档「短期记忆」自动迁移为观察记忆。 - `knowledge_chunk.ts`:`splitMarkdownIntoChunks()` 按 `#`/`##`/`###` 标题与段落把 Markdown 文档切块(单块约 800 字符、块间保留重叠),生成稳定 ID(`kb_xxxxxxxx`)。 -- `knowledge_base.ts`:`KnowledgeBaseService` 单例,配置驱动(Markdown 模板,每条一份完整文档),解析时不出向量(懒向量);`search()` 先关键词过滤,嵌入可用时仅对命中候选(≤20 块)做惰性向量重排,失败自动降级关键词;`buildKnowledgePrompt(query)` 按当前对话检索式注入(最多 5 块),未命中时只注入前 50 条条目索引兜底,不再全量注入正文/索引;`formatIndex(limit)` 支持索引条数上限,`knowledge_list` 工具与 `.ai kb list` 上限 200 条。 +- `knowledge_base.ts`:`KnowledgeBaseService` 单例,配置驱动(Markdown 模板,每条一份完整文档),解析时不出向量(懒向量);`search()` 先关键词过滤,嵌入可用时仅对命中候选(≤20 块)做惰性向量重排,失败自动降级关键词;`buildKnowledgePrompt(query)` 按当前对话检索式注入:命中正文在 1500 字符预算内才注入(≤5 块),正文超预算/未命中/无对话时预算优先用于条目索引(尽可能多列,≤100 条),不再全量注入正文/索引;`formatIndex(limit)` 支持索引条数上限,`knowledge_list` 工具与 `.ai kb list` 上限 200 条。 - `knowledge.ts`:兼容导出层,`knowledgeService = knowledgeBase`(旧 `KnowledgeService` 已移除)。 - `manager.ts`:`MemoryManager` 统一入口:`buildLongTermPrompt` / `buildSummaryPrompt` / `buildKnowledgePrompt` / `addMemory` / `search` / `summarize`。 - `v2/`(4.17.0 起):Hindsight-like 纯 TS 记忆引擎。`engine.ts` 实现 Retain(抽取原子事实/观察记忆,可选 LLM 结构化抽取 occurred_at/entities)、Recall(RRF 混合检索 + Hindsight 式新近度加权(按 lastAccessedAt 指数衰减,权重/半衰期可配)+ 可选 LLM 重排)、Consolidation(LLM 合成观察记忆、合并重复、清理 90 天过期观察)、MentalModel(心智模型:question/answer/scopeTags/lastRefreshedAt/history/trigger,支持 refresh/delete、persona 懒迁移)、遗忘机制(长期记忆条数上限,默认 100,0=不限制;retain/consolidate 后超限时按「已被观察覆盖 → 普通按衰减分 → observation 沉淀 → 保护项(importance≥0.9 或 pinned)」优先级物理删除多余记忆,上限为硬约束);`repository.ts` 统一存取长期/观察记忆与心智模型,`bank_resolver.ts` 按会话解析记忆库,`migrate.ts` 负责旧记忆自动迁移,`llm.ts` 提供可选的 LLM 抽取/重排/合成/推理回调,`prompt.ts` 负责注入裁剪(scopeTags 任一命中过滤 + 90 天过期观察剔除 + 心智模型/观察各取最新 5/20 条,观察记忆只在长期记忆段裁剪注入,避免重复)。配套记忆工具为 `memory_*` 系列(增删改查/巩固/推理/心智模型,含 `memory_mm_list/view` 查看心智模型、`memory_mm_delete` 可按 model_id 或 question 删除),`.ai memo` 子命令重构为 status/add/list/update/delete/clear/obs/consolidate/reflect/mm,范围参数改用 `--u`/`--g`。 diff --git "a/docs/05-\345\221\275\344\273\244\344\270\216\351\205\215\347\275\256.md" "b/docs/05-\345\221\275\344\273\244\344\270\216\351\205\215\347\275\256.md" index 19578def..93e989cd 100644 --- "a/docs/05-\345\221\275\344\273\244\344\270\216\351\205\215\347\275\256.md" +++ "b/docs/05-\345\221\275\344\273\244\344\270\216\351\205\215\347\275\256.md" @@ -120,7 +120,7 @@ | MCP | 是否启用MCP、MCP服务器配置、MCP会话空闲回收分钟、MCP每服务器最大会话数 | 「是否启用MCP」默认关闭,开启后才会解析并连接 MCP 服务器;仅支持标准 mcpServers JSON(默认后端直接提供 mcp-files-exec(read_file、download_file、run_shell)、md-html-render/render_markdown/render_html,格式见 modelcontextprotocol.io/specification/latest) | | 技能 | 技能配置 | 仅支持标准 SKILL.md(格式见 agentskills.io/specification) | | 记忆 | 启用长期/观察记忆、每隔多少轮对话生成一次观察、每次观察纳入最近的对话轮数、每隔多少次观察整合一次记忆、用LLM抽取记忆/重排召回结果/合成观察记忆/推理记忆(默认开)、记忆召回新近度权重(默认0.4)/半衰期(默认60天)、长期记忆条数上限(默认100)、巩固后自动刷新心智模型(默认开)、心智模型刷新最小间隔(默认30分钟) | 观察记忆增量观察(只观察上次之后的新消息);巩固间隔 0 为关闭;长期记忆条数上限 0 为不限制,超限自动物理删除最不重要的记忆(先删已被观察覆盖的细节,后删重要/置顶保护项),释放存储空间;新近度加权只影响召回排序 | -| 知识库 | 启用知识库记忆、知识库注入阈值(字符,已弃用,不再参与注入)、知识库(Markdown 模板,每条一份完整文档) | 知识库只读,AI 通过 knowledge_search/knowledge_read/knowledge_list 工具或 .ai kb 指令检索;注入为按当前对话检索式(最多 5 块),未命中只注入前 50 条索引;修改后需重载 JS 生效;Markdown 语法见 commonmark.org/help/ | +| 知识库 | 启用知识库记忆、知识库注入阈值(字符,已弃用,不再参与注入)、知识库(Markdown 模板,每条一份完整文档) | 知识库只读,AI 通过 knowledge_search/knowledge_read/knowledge_list 工具或 .ai kb 指令检索;注入为按当前对话检索式,命中正文在 1500 字符预算内才注入(≤5 块),否则预算优先用于条目索引(尽可能多列,≤100 条);修改后需重载 JS 生效;Markdown 语法见 commonmark.org/help/ | | 回复 | 回复引用、回复最大字数、分段发送延时/基础延时/含图额外延时、禁止回复复读 | 回复过滤规则已内置硬编码 | | 后端 | 流式输出、图片转base64、联网搜索、用量图表、论坛地址/API Token/签名密钥、核心桥WS地址/核心桥Token | 可自建,见 [08-相关后端项目](08-相关后端项目.md);md/html 渲染(MCP)在「MCP → MCP服务器配置」;核心桥用于 run_core_command 直连(默认 ws://127.0.0.1:46880/plugin) | | 资源 | 本地图片路径、本地语音路径(支持 `语音名=路径`) | 供 system prompt 列出可发送资源 | diff --git a/src/changelog.ts b/src/changelog.ts index 86bb2067..4ef8e648 100644 --- a/src/changelog.ts +++ b/src/changelog.ts @@ -12,7 +12,7 @@ export const changelog: { [version: string]: string } = { ## 修复 - 修复 .ai memo mm del 删除失效:del 经别名归一化为 delete 后未命中删除分支,实际返回帮助文本;现按归一化目标匹配,del / delete 均可删除,并支持按问题名或关键词删除(唯一命中直接删,多条列出 ID 由用户指定) -- 知识库注入改为检索式:按当前对话内容只注入命中的知识库分块(最多 5 个),未命中时只注入前 50 条条目索引兜底,不再全量注入正文/索引;knowledge_list 工具与 .ai kb list 索引上限 200 条,超出提示用 knowledge_search 检索 +- 知识库注入改为检索式:按当前对话内容检索,命中正文在 1500 字符预算内才注入(最多 5 块),正文超预算/未命中/无对话时预算优先用于条目索引(尽可能多列,最多 100 条),不再全量注入正文/索引;knowledge_list 工具与 .ai kb list 索引上限 200 条,超出提示用 knowledge_search 检索 - 修复心智模型/观察记忆 scope 严格匹配导致的注入丢失:改为任一标签命中即注入,群聊中不再因某贡献者不在最近消息而整条剔除;memory_mm_create / .ai memo mm add 的 scope_tag 非法或与目标类型不符时回退默认标签,避免静默失效 - 观察记忆注入收敛:启用长期记忆时观察只由长期记忆段裁剪注入(上限 20 + 90 天过期剔除),不再与观察段重复渲染;观察段同样按上限裁剪;巩固生成观察后刷新观察段缓存,消除最长 60 秒滞后 - 记忆/知识库工具描述修正:memory_update 引用的 search_memory 改为 memory_recall;knowledge_read 引用的 kb_list/kb_search 改为 knowledge_list/knowledge_search diff --git a/src/memory/knowledge_base.ts b/src/memory/knowledge_base.ts index baf5767d..b4d3f117 100644 --- a/src/memory/knowledge_base.ts +++ b/src/memory/knowledge_base.ts @@ -10,11 +10,13 @@ import { hashString, KnowledgeChunk, splitMarkdownIntoChunks } from "./knowledge // 向量重排候选上限:仅对关键词命中的前 N 块做惰性嵌入,控制单次检索的 API 开销 const VECTOR_RERANK_CANDIDATE_LIMIT = 20; /** 知识库索引渲染上限:注入兜底/列表展示时控制单次输出条数,超出提示用 knowledge_search 检索 */ -export const KB_INDEX_LIMIT = 50; +export const KB_INDEX_LIMIT = 100; /** 主动查询(.ai kb list / knowledge_list 工具)的索引上限,避免超大知识库一次输出过长 */ export const KB_LIST_LIMIT = 200; /** 检索式注入上限:按当前对话内容命中时最多注入的分块数 */ export const KB_INJECT_TOP_K = 5; +/** 知识库注入段字符预算:正文/索引合计不超过该值,避免挤占 system prompt */ +export const KB_INJECT_MAX_CHARS = 1500; export class KnowledgeBaseService { private chunks: KnowledgeChunk[] = []; @@ -225,8 +227,9 @@ export class KnowledgeBaseService { /** * 构造 system prompt 的知识库段(检索式注入,不再全量注入正文): - * - 传入 query 时按当前对话内容检索,只注入命中的 topK 分块; - * - 无 query 或未命中时只注入限条数索引兜底,模型需要详情时通过 knowledge_read 按 ID 读取。 + * - 传入 query 时按当前对话内容检索,命中的 topK 分块正文在字符预算内才注入; + * - 命中正文超预算 / 未命中 / 无 query 时,把预算优先花在条目索引上(尽可能多列), + * 模型需要详情时通过 knowledge_read 按 ID 读取。 */ async buildKnowledgePrompt(query = ''): Promise { if (this.chunks.length === 0) return ''; @@ -234,10 +237,34 @@ export class KnowledgeBaseService { if (trimmed) { const hits = await this.search(trimmed, KB_INJECT_TOP_K); if (hits.length > 0) { - return `## 知识库\n${hits.map((c, i) => `${i + 1}. ${this.formatChunk(c)}`).join('\n\n')}`; + const body = hits.map((c, i) => `${i + 1}. ${this.formatChunk(c)}`).join('\n\n'); + if (body.length <= KB_INJECT_MAX_CHARS) { + return `## 知识库\n${body}`; + } + // 命中正文超预算:降级为索引,避免正文挤占上下文 } } - return `## 知识库\n知识库内容较多(共 ${this.chunks.length} 个分块),以下为条目索引,需要详情时使用 knowledge_read 工具按 ID 读取:\n${this.formatIndex(this.chunks, KB_INDEX_LIMIT)}`; + return this.buildIndexFallback(); + } + + /** 注入索引兜底:预算内尽可能多列条目(标题 + 小节),正文按需用 knowledge_read 读取 */ + private buildIndexFallback(): string { + const head = `## 知识库\n知识库内容较多(共 ${this.chunks.length} 个分块),以下为条目索引,需要详情时使用 knowledge_read 工具按 ID 读取:\n`; + const lines: string[] = []; + let total = 0; + for (let i = 0; i < this.chunks.length && lines.length < KB_INDEX_LIMIT; i++) { + const c = this.chunks[i]; + const heading = c.heading ? ` - ${c.heading}` : ''; + const line = `${lines.length + 1}. [${c.id}] ${c.title}${heading}`; + if (total + line.length + 1 > KB_INJECT_MAX_CHARS) break; + lines.push(line); + total += line.length + 1; + } + const index = lines.join('\n'); + const remaining = this.chunks.length > lines.length + ? `\n…共 ${this.chunks.length} 条,检索请用 knowledge_search / knowledge_read` + : ''; + return head + index + remaining; } } From e7c3d1f916918fb977fd1a34b040cb7cc139cc58 Mon Sep 17 00:00:00 2001 From: error2913 <2913949387@qq.com> Date: Tue, 1 Sep 2026 02:58:57 +0800 Subject: [PATCH 3/7] =?UTF-8?q?refactor(memory):=20=E7=9F=A5=E8=AF=86?= =?UTF-8?q?=E5=BA=93=E6=B3=A8=E5=85=A5=E5=8F=AA=E5=B1=95=E7=A4=BA=E6=9D=A1?= =?UTF-8?q?=E7=9B=AE=E7=B4=A2=E5=BC=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - system prompt 知识库段只列条目(ID + 标题),不再注入正文; 在 KB_INJECT_MAX_CHARS(1500) 字符预算内尽可能多列(上限 KB_INDEX_LIMIT=100 条), 详情由模型用 knowledge_read 按 ID 读取 - 移除 KB_INJECT_TOP_K(不再做检索式正文注入) - 更新单测为纯索引注入断言;同步 changelog/README/docs --- README.md | 2 +- ...66\346\236\204\350\256\276\350\256\241.md" | 2 +- ...41\345\235\227\350\257\246\350\247\243.md" | 2 +- ...44\344\270\216\351\205\215\347\275\256.md" | 2 +- scripts/unit-test-entry.ts | 24 +++++++++---------- src/changelog.ts | 2 +- src/memory/knowledge.ts | 2 +- src/memory/knowledge_base.ts | 22 ++++------------- 8 files changed, 21 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index d688f49a..26f24242 100644 --- a/README.md +++ b/README.md @@ -280,7 +280,7 @@ max_tokens = 2048 | 设置项 | 说明 | |:---:|:---| | 启用知识库记忆 | 开启后把知识库内容注入 system prompt,供对话参考 | -| 知识库注入阈值(字符) | 已弃用:知识库改为按当前对话检索式注入,命中正文在 1500 字符预算内才注入(最多 5 个分块),否则预算优先用于条目索引(尽可能多列,最多 100 条);模型用 knowledge_read 工具读取详情(保留该配置仅为兼容旧存档) | +| 知识库注入阈值(字符) | 已弃用:知识库注入只列条目索引(ID + 标题,1500 字符预算内尽可能多列,最多 100 条),不注入正文;模型用 knowledge_read 工具按 ID 读取详情(保留该配置仅为兼容旧存档) | | 知识库 | Markdown 模板,每条一份完整文档(# 条目标题、##/### 小节,超长自动分块);只读,内容由管理员维护,AI 通过 kb 工具/指令检索。语法定义见 [CommonMark 规范](https://commonmark.org/help/) | ### 回复 diff --git "a/docs/02-\346\236\266\346\236\204\350\256\276\350\256\241.md" "b/docs/02-\346\236\266\346\236\204\350\256\276\350\256\241.md" index a6bfc2fa..e680dec7 100644 --- "a/docs/02-\346\236\266\346\236\204\350\256\276\350\256\241.md" +++ "b/docs/02-\346\236\266\346\236\204\350\256\276\350\256\241.md" @@ -122,7 +122,7 @@ SYSTEM_MESSAGE_TEMPLATE(用户可编辑的骨架) ├── 图片/音频资源(本地图片、本地语音) ├── memoryPrompt:长期记忆段(按开关 MEMORY) ├── summaryPrompt:观察记忆段(按开关 SUMMARY) -├── knowledgePrompt:知识库段(按开关 KNOWLEDGE;按当前对话检索式注入,命中正文在 1500 字符预算内才注入(≤5 块),否则预算优先用于条目索引(尽可能多列,≤100 条),模型用 knowledge_read 读取详情) +├── knowledgePrompt:知识库段(按开关 KNOWLEDGE;只列条目索引:ID + 标题,1500 字符预算内尽可能多列(≤100 条),不注入正文,模型用 knowledge_read 读取详情) └── toolPrompt:工具函数段(仅提示词工程模式) 最后追加"可用技能"摘要段(两种模式都可见) ``` diff --git "a/docs/03-\346\240\270\345\277\203\346\250\241\345\235\227\350\257\246\350\247\243.md" "b/docs/03-\346\240\270\345\277\203\346\250\241\345\235\227\350\257\246\350\247\243.md" index 1ca77839..5bac9756 100644 --- "a/docs/03-\346\240\270\345\277\203\346\250\241\345\235\227\350\257\246\350\247\243.md" +++ "b/docs/03-\346\240\270\345\277\203\346\250\241\345\235\227\350\257\246\350\247\243.md" @@ -124,7 +124,7 @@ - `memory.ts`:`MemoryService` 提供 `memoryMap` 增删改查、`search()`(方法:importance/similarity/score/early/late/recent)、去重合并、按分数淘汰;内含旧格式迁移逻辑(兼容早期 `src/AI/memory.ts` 存档)。 - `session_memory.ts`:`SessionMemoryService` 提供会话级观察记忆:`summarize()` 把游标之后 `SUMMARY_SIZE` 轮对话交给 `summarize_agent`,解析 JSON 摘要与事实列表(facts,含 op/type/importance),写入 `summaries` 并落库;旧存档「短期记忆」自动迁移为观察记忆。 - `knowledge_chunk.ts`:`splitMarkdownIntoChunks()` 按 `#`/`##`/`###` 标题与段落把 Markdown 文档切块(单块约 800 字符、块间保留重叠),生成稳定 ID(`kb_xxxxxxxx`)。 -- `knowledge_base.ts`:`KnowledgeBaseService` 单例,配置驱动(Markdown 模板,每条一份完整文档),解析时不出向量(懒向量);`search()` 先关键词过滤,嵌入可用时仅对命中候选(≤20 块)做惰性向量重排,失败自动降级关键词;`buildKnowledgePrompt(query)` 按当前对话检索式注入:命中正文在 1500 字符预算内才注入(≤5 块),正文超预算/未命中/无对话时预算优先用于条目索引(尽可能多列,≤100 条),不再全量注入正文/索引;`formatIndex(limit)` 支持索引条数上限,`knowledge_list` 工具与 `.ai kb list` 上限 200 条。 +- `knowledge_base.ts`:`KnowledgeBaseService` 单例,配置驱动(Markdown 模板,每条一份完整文档),解析时不出向量(懒向量);`search()` 先关键词过滤,嵌入可用时仅对命中候选(≤20 块)做惰性向量重排,失败自动降级关键词;`buildKnowledgePrompt()` 只展示条目索引:ID + 标题,1500 字符预算内尽可能多列(≤100 条),不注入正文,详情由模型用 `knowledge_read` 按 ID 读取;`formatIndex(limit)` 支持索引条数上限,`knowledge_list` 工具与 `.ai kb list` 上限 200 条。 - `knowledge.ts`:兼容导出层,`knowledgeService = knowledgeBase`(旧 `KnowledgeService` 已移除)。 - `manager.ts`:`MemoryManager` 统一入口:`buildLongTermPrompt` / `buildSummaryPrompt` / `buildKnowledgePrompt` / `addMemory` / `search` / `summarize`。 - `v2/`(4.17.0 起):Hindsight-like 纯 TS 记忆引擎。`engine.ts` 实现 Retain(抽取原子事实/观察记忆,可选 LLM 结构化抽取 occurred_at/entities)、Recall(RRF 混合检索 + Hindsight 式新近度加权(按 lastAccessedAt 指数衰减,权重/半衰期可配)+ 可选 LLM 重排)、Consolidation(LLM 合成观察记忆、合并重复、清理 90 天过期观察)、MentalModel(心智模型:question/answer/scopeTags/lastRefreshedAt/history/trigger,支持 refresh/delete、persona 懒迁移)、遗忘机制(长期记忆条数上限,默认 100,0=不限制;retain/consolidate 后超限时按「已被观察覆盖 → 普通按衰减分 → observation 沉淀 → 保护项(importance≥0.9 或 pinned)」优先级物理删除多余记忆,上限为硬约束);`repository.ts` 统一存取长期/观察记忆与心智模型,`bank_resolver.ts` 按会话解析记忆库,`migrate.ts` 负责旧记忆自动迁移,`llm.ts` 提供可选的 LLM 抽取/重排/合成/推理回调,`prompt.ts` 负责注入裁剪(scopeTags 任一命中过滤 + 90 天过期观察剔除 + 心智模型/观察各取最新 5/20 条,观察记忆只在长期记忆段裁剪注入,避免重复)。配套记忆工具为 `memory_*` 系列(增删改查/巩固/推理/心智模型,含 `memory_mm_list/view` 查看心智模型、`memory_mm_delete` 可按 model_id 或 question 删除),`.ai memo` 子命令重构为 status/add/list/update/delete/clear/obs/consolidate/reflect/mm,范围参数改用 `--u`/`--g`。 diff --git "a/docs/05-\345\221\275\344\273\244\344\270\216\351\205\215\347\275\256.md" "b/docs/05-\345\221\275\344\273\244\344\270\216\351\205\215\347\275\256.md" index 93e989cd..71293c5d 100644 --- "a/docs/05-\345\221\275\344\273\244\344\270\216\351\205\215\347\275\256.md" +++ "b/docs/05-\345\221\275\344\273\244\344\270\216\351\205\215\347\275\256.md" @@ -120,7 +120,7 @@ | MCP | 是否启用MCP、MCP服务器配置、MCP会话空闲回收分钟、MCP每服务器最大会话数 | 「是否启用MCP」默认关闭,开启后才会解析并连接 MCP 服务器;仅支持标准 mcpServers JSON(默认后端直接提供 mcp-files-exec(read_file、download_file、run_shell)、md-html-render/render_markdown/render_html,格式见 modelcontextprotocol.io/specification/latest) | | 技能 | 技能配置 | 仅支持标准 SKILL.md(格式见 agentskills.io/specification) | | 记忆 | 启用长期/观察记忆、每隔多少轮对话生成一次观察、每次观察纳入最近的对话轮数、每隔多少次观察整合一次记忆、用LLM抽取记忆/重排召回结果/合成观察记忆/推理记忆(默认开)、记忆召回新近度权重(默认0.4)/半衰期(默认60天)、长期记忆条数上限(默认100)、巩固后自动刷新心智模型(默认开)、心智模型刷新最小间隔(默认30分钟) | 观察记忆增量观察(只观察上次之后的新消息);巩固间隔 0 为关闭;长期记忆条数上限 0 为不限制,超限自动物理删除最不重要的记忆(先删已被观察覆盖的细节,后删重要/置顶保护项),释放存储空间;新近度加权只影响召回排序 | -| 知识库 | 启用知识库记忆、知识库注入阈值(字符,已弃用,不再参与注入)、知识库(Markdown 模板,每条一份完整文档) | 知识库只读,AI 通过 knowledge_search/knowledge_read/knowledge_list 工具或 .ai kb 指令检索;注入为按当前对话检索式,命中正文在 1500 字符预算内才注入(≤5 块),否则预算优先用于条目索引(尽可能多列,≤100 条);修改后需重载 JS 生效;Markdown 语法见 commonmark.org/help/ | +| 知识库 | 启用知识库记忆、知识库注入阈值(字符,已弃用,不再参与注入)、知识库(Markdown 模板,每条一份完整文档) | 知识库只读,AI 通过 knowledge_search/knowledge_read/knowledge_list 工具或 .ai kb 指令检索;注入只列条目索引(ID + 标题,1500 字符预算内尽可能多列,≤100 条),正文用 knowledge_read 按 ID 读取;修改后需重载 JS 生效;Markdown 语法见 commonmark.org/help/ | | 回复 | 回复引用、回复最大字数、分段发送延时/基础延时/含图额外延时、禁止回复复读 | 回复过滤规则已内置硬编码 | | 后端 | 流式输出、图片转base64、联网搜索、用量图表、论坛地址/API Token/签名密钥、核心桥WS地址/核心桥Token | 可自建,见 [08-相关后端项目](08-相关后端项目.md);md/html 渲染(MCP)在「MCP → MCP服务器配置」;核心桥用于 run_core_command 直连(默认 ws://127.0.0.1:46880/plugin) | | 资源 | 本地图片路径、本地语音路径(支持 `语音名=路径`) | 供 system prompt 列出可发送资源 | diff --git a/scripts/unit-test-entry.ts b/scripts/unit-test-entry.ts index 87ad1f12..362957b2 100644 --- a/scripts/unit-test-entry.ts +++ b/scripts/unit-test-entry.ts @@ -642,22 +642,20 @@ export const tests: Record void | Promise> = { assert.equal(full.split('\n').length, 120, '不传 limit 应全量输出'); }, - /** 知识库注入:有 query 时检索式注入命中分块(限 topK);无 query/未命中时只注入限条数索引兜底(不注入正文) */ - async testKbBuildKnowledgePromptRetrieval(): Promise { + /** 知识库注入:只展示条目索引(预算内尽可能多列),不注入正文,且条目总字数不超预算 */ + async testKbBuildKnowledgePromptIndexOnly(): Promise { const svc = new KnowledgeBaseService(); - const chunks = Array.from({ length: 80 }, (_, i) => ({ id: `kb_${i + 1}`, title: `条目${i + 1}`, heading: '', content: `关于咖啡的说明 ${i + 1}` })); + const longBody = '很长的正文内容'.repeat(60); // 单块约 420 字符 + const chunks = Array.from({ length: 30 }, (_, i) => ({ id: `kb_${i + 1}`, title: `条目${i + 1}`, heading: '', content: `${longBody} ${i + 1}` })); (svc as any).chunks = chunks; (svc as any).loadedSignature = 'test'; // 跳过 ensureLoaded 覆盖手动构造的 chunks - const hit = await svc.buildKnowledgePrompt('咖啡'); - assert.ok(hit.startsWith('## 知识库'), '应包含知识库标题'); - assert.ok(hit.includes('关于咖啡的说明'), '检索命中应注入正文分块'); - assert.ok(hit.includes('[kb_'), '命中分块应带 ID'); - const hitBodyLines = hit.split('\n').filter(l => /^\d+\. \[kb_/.test(l)).length; - assert.ok(hitBodyLines <= 5, `检索式注入应限制在 topK,实际 ${hitBodyLines}`); - const miss = await svc.buildKnowledgePrompt('完全不存在的词xyz'); - assert.ok(miss.includes('知识库内容较多'), '未命中应退化为索引提示'); - assert.ok(!miss.includes('关于咖啡的说明'), '索引兜底不应注入正文'); - assert.ok(miss.includes('共 80 条'), '索引兜底应提示总数'); + const prompt = await svc.buildKnowledgePrompt('很长的正文内容'); + assert.ok(prompt.startsWith('## 知识库'), '应包含知识库标题'); + assert.ok(prompt.includes('知识库内容较多'), '应展示条目索引'); + assert.ok(!prompt.includes(longBody), '只展示条目,不应注入正文'); + const indexLines = prompt.split('\n').filter(l => /^\d+\. \[kb_/.test(l)); + assert.ok(indexLines.length >= 1 && indexLines.length <= 30, `预算内应尽可能多列条目,实际 ${indexLines.length}`); + assert.ok(prompt.length <= 2000, `索引段总字数应控制在预算附近,实际 ${prompt.length}`); }, /** buildLongTermPrompt:bank 有心智模型时长期记忆段应注入 ## 心智模型 与答案 */ diff --git a/src/changelog.ts b/src/changelog.ts index 4ef8e648..9f3c3518 100644 --- a/src/changelog.ts +++ b/src/changelog.ts @@ -12,7 +12,7 @@ export const changelog: { [version: string]: string } = { ## 修复 - 修复 .ai memo mm del 删除失效:del 经别名归一化为 delete 后未命中删除分支,实际返回帮助文本;现按归一化目标匹配,del / delete 均可删除,并支持按问题名或关键词删除(唯一命中直接删,多条列出 ID 由用户指定) -- 知识库注入改为检索式:按当前对话内容检索,命中正文在 1500 字符预算内才注入(最多 5 块),正文超预算/未命中/无对话时预算优先用于条目索引(尽可能多列,最多 100 条),不再全量注入正文/索引;knowledge_list 工具与 .ai kb list 索引上限 200 条,超出提示用 knowledge_search 检索 +- 知识库注入只展示条目索引:system prompt 中的知识库段只列条目(ID + 标题,1500 字符预算内尽可能多列,最多 100 条),不再注入正文,详情由模型用 knowledge_read 按 ID 读取;knowledge_list 工具与 .ai kb list 索引上限 200 条,超出提示用 knowledge_search 检索 - 修复心智模型/观察记忆 scope 严格匹配导致的注入丢失:改为任一标签命中即注入,群聊中不再因某贡献者不在最近消息而整条剔除;memory_mm_create / .ai memo mm add 的 scope_tag 非法或与目标类型不符时回退默认标签,避免静默失效 - 观察记忆注入收敛:启用长期记忆时观察只由长期记忆段裁剪注入(上限 20 + 90 天过期剔除),不再与观察段重复渲染;观察段同样按上限裁剪;巩固生成观察后刷新观察段缓存,消除最长 60 秒滞后 - 记忆/知识库工具描述修正:memory_update 引用的 search_memory 改为 memory_recall;knowledge_read 引用的 kb_list/kb_search 改为 knowledge_list/knowledge_search diff --git a/src/memory/knowledge.ts b/src/memory/knowledge.ts index fdec9713..a0160cf7 100644 --- a/src/memory/knowledge.ts +++ b/src/memory/knowledge.ts @@ -4,4 +4,4 @@ import { knowledgeBase, KnowledgeBaseService } from "./knowledge_base"; export { knowledgeBase as knowledgeService }; export default knowledgeBase; export { KnowledgeBaseService }; -export { KB_INDEX_LIMIT, KB_INJECT_TOP_K, KB_LIST_LIMIT } from "./knowledge_base"; +export { KB_INDEX_LIMIT, KB_LIST_LIMIT } from "./knowledge_base"; diff --git a/src/memory/knowledge_base.ts b/src/memory/knowledge_base.ts index b4d3f117..9909714a 100644 --- a/src/memory/knowledge_base.ts +++ b/src/memory/knowledge_base.ts @@ -13,8 +13,6 @@ const VECTOR_RERANK_CANDIDATE_LIMIT = 20; export const KB_INDEX_LIMIT = 100; /** 主动查询(.ai kb list / knowledge_list 工具)的索引上限,避免超大知识库一次输出过长 */ export const KB_LIST_LIMIT = 200; -/** 检索式注入上限:按当前对话内容命中时最多注入的分块数 */ -export const KB_INJECT_TOP_K = 5; /** 知识库注入段字符预算:正文/索引合计不超过该值,避免挤占 system prompt */ export const KB_INJECT_MAX_CHARS = 1500; @@ -226,24 +224,12 @@ export class KnowledgeBaseService { } /** - * 构造 system prompt 的知识库段(检索式注入,不再全量注入正文): - * - 传入 query 时按当前对话内容检索,命中的 topK 分块正文在字符预算内才注入; - * - 命中正文超预算 / 未命中 / 无 query 时,把预算优先花在条目索引上(尽可能多列), - * 模型需要详情时通过 knowledge_read 按 ID 读取。 + * 构造 system prompt 的知识库段(纯索引注入,不注入正文): + * 在 KB_INJECT_MAX_CHARS 字符预算内尽可能多列条目索引(上限 KB_INDEX_LIMIT), + * 模型需要正文时通过 knowledge_read 按 ID 读取。 */ - async buildKnowledgePrompt(query = ''): Promise { + async buildKnowledgePrompt(_query = ''): Promise { if (this.chunks.length === 0) return ''; - const trimmed = String(query || '').trim(); - if (trimmed) { - const hits = await this.search(trimmed, KB_INJECT_TOP_K); - if (hits.length > 0) { - const body = hits.map((c, i) => `${i + 1}. ${this.formatChunk(c)}`).join('\n\n'); - if (body.length <= KB_INJECT_MAX_CHARS) { - return `## 知识库\n${body}`; - } - // 命中正文超预算:降级为索引,避免正文挤占上下文 - } - } return this.buildIndexFallback(); } From 1264914651fb2a8e1eb014f81d8dfe069dfcd5a3 Mon Sep 17 00:00:00 2001 From: error2913 <2913949387@qq.com> Date: Tue, 1 Sep 2026 03:05:15 +0800 Subject: [PATCH 4/7] =?UTF-8?q?feat(skills):=20=E6=8A=80=E8=83=BD=E6=91=98?= =?UTF-8?q?=E8=A6=81=E9=A2=84=E7=AE=97=E6=B3=A8=E5=85=A5=20+=20skill=5Flis?= =?UTF-8?q?t=20=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - system prompt「可用技能」段只列技能摘要(名称 + 描述), 按 SKILL_INJECT_MAX_CHARS(1500) 字符预算截断、预算内尽可能多列, 超出提示用 skill_list 查看完整技能名列表 - 新增 skill_list 工具:列出全部技能名称与描述(上限 SKILL_LIST_LIMIT=200), 供模型发现「可用技能」段未列出的技能,再按需 use_skill 获取正文 - use_skill 描述同步提及 skill_list - 新增单测 testSkillSummariesBudgeted;同步 changelog/README/docs/04 --- README.md | 4 +- ...45\345\205\267\347\263\273\347\273\237.md" | 6 +-- scripts/unit-test-entry.ts | 16 ++++++ src/changelog.ts | 1 + src/prompt/builder.ts | 8 +-- src/tool/skills.ts | 49 +++++++++++++++++-- 6 files changed, 72 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 26f24242..40fea377 100644 --- a/README.md +++ b/README.md @@ -256,6 +256,8 @@ max_tokens = 2048 |:---:|:---| | 技能配置 | 仅支持标准 SKILL.md 格式(frontmatter 的 name/description 自动解析,正文为技能内容),可直接粘贴其他 agent 的技能文件;默认包含核心命令、内置扩展命令及别名的 `run_ext_command` / `run_core_command` 调用帮助。格式定义见 [agentskills.io 规范](https://agentskills.io/specification) | +> system prompt 的「可用技能」段只列技能摘要(名称 + 描述,1500 字符预算内尽可能多列),技能过多时超出部分用 `skill_list` 查看完整列表、用 `use_skill` 按需获取正文。 + ### 记忆 | 设置项 | 说明 | @@ -454,7 +456,7 @@ max_tokens = 2048 | 音乐资源 | `search_music`(返回 music 消息段,不直接发送) | | 黑名单 | `suggest_block`(AI 建议拉黑,带冷却;默认需骰主确认)、`unblock_user`、`get_block_list` | | 论坛 | `forum_get_posts`、`forum_get_post_detail`、`forum_search`、`forum_create_post`、`forum_manage_comment`、`forum_get_activity`、`forum_manage_post` | -| MCP / 技能 | 远端工具名(MCP 工具,同名冲突时跳过)、`use_skill`(技能) | +| MCP / 技能 | 远端工具名(MCP 工具,同名冲突时跳过)、`use_skill`、`skill_list`(技能) | > 指令类技能(今日人品、COC 模组抽取/搜索、属性展示、属性检定、san 检定等)通过 `use_skill` 按需获取内容,内部统一使用 `run_ext_command` / `run_core_command` 调用海豹指令,对应指令需加入「可调用指令白名单」。 diff --git "a/docs/04-\345\267\245\345\205\267\347\263\273\347\273\237.md" "b/docs/04-\345\267\245\345\205\267\347\263\273\347\273\237.md" index 9406f409..36ea75ff 100644 --- "a/docs/04-\345\267\245\345\205\267\347\263\273\347\273\237.md" +++ "b/docs/04-\345\267\245\345\205\267\347\263\273\347\273\237.md" @@ -100,7 +100,7 @@ 外部注册: - MCP 工具:默认直接按远端 `tools/list` 返回的工具名注册;与本地/已有工具同名时自动跳过。 -- 技能工具:`use_skill`(见技能节)。 +- 技能工具:`use_skill`、`skill_list`(见技能节)。 依赖说明:AI 只调用 `call_ob11_api`。有 ob11 网络连接依赖时 action 原样透传;没有依赖时原生可完成的发送和上下文查询继续可用,其余 action 返回 `OB11_DEPENDENCY_REQUIRED`。 @@ -126,6 +126,6 @@ ## 技能(Skills,src/tool/skills.ts) - 配置项 `技能配置`(技能 分组)只支持标准 SKILL.md 格式:直接粘贴 Claude/Codex/Cursor 的技能文件,frontmatter 的 `name`/`description` 自动解析为技能名与摘要,正文为技能内容 -- 注册 `use_skill` 工具;`getSkillSummaries()` 生成的技能摘要会追加到 system prompt 的"可用技能"段(两种工具模式都可见)。 -- 技能内容支持 `{{skill:名称}}` 引用其他技能(最大解析深度 2),单次返回上限 4000 字符(超出截断并注明)。 +- 注册 `use_skill` 与 `skill_list` 工具;`getSkillSummariesBudgeted()` 生成的技能摘要追加到 system prompt 的"可用技能"段(两种工具模式都可见),摘要按 1500 字符预算截断、预算内尽可能多列,超出提示用 `skill_list` 查看完整技能名列表;`use_skill` 按名称获取正文。 +- 技能内容支持 `{{skill:名称}}` 引用其他技能(最大解析深度 2),单次返回上限 12000 字符(超出截断并注明)。 - 默认技能包含当前 SealDice 核心命令、内置扩展命令及全部别名的调用帮助:扩展指令使用 `run_ext_command`,核心命令(含 `core|team`)使用 `run_core_command`;默认白名单与 SealDice 源码中的命令表同步。 diff --git a/scripts/unit-test-entry.ts b/scripts/unit-test-entry.ts index 362957b2..d5469b0d 100644 --- a/scripts/unit-test-entry.ts +++ b/scripts/unit-test-entry.ts @@ -42,6 +42,7 @@ import { JudgeManager } from "../src/judge/judge_manager"; import { TimerInfo, TimerManager } from "../src/timer"; import Image from "../src/resource/image"; import Tool, { toolMap } from "../src/tool/tool"; +import { getSkillSummariesBudgeted, SKILL_INJECT_MAX_CHARS } from "../src/tool/skills"; import { registerDispatchTools } from "../src/tool/tools/core/tool_dispatch"; import { createStopEvent, fireStopEvent, resetStopEvent, revive, StopError, transformMsgId, transformMsgIdBack, withTimeout } from "../src/utils/utils"; import { registerResolveSpecialId } from "../src/tool/tools/ob11/tool_resolve_id"; @@ -658,6 +659,21 @@ export const tests: Record void | Promise> = { assert.ok(prompt.length <= 2000, `索引段总字数应控制在预算附近,实际 ${prompt.length}`); }, + /** 技能摘要注入:预算内尽可能多列,超预算截断并标记 truncated(详情用 use_skill / skill_list 按需获取) */ + testSkillSummariesBudgeted(): void { + const all = getSkillSummariesBudgeted(1000000); + assert.ok(all.total > 0, '默认技能应非空'); + assert.equal(all.truncated, false, '大预算不应截断'); + assert.equal(all.summaries.length, all.total, '大预算应列出全部技能'); + const tiny = getSkillSummariesBudgeted(1); + assert.ok(tiny.total > 0, '默认技能应非空'); + assert.equal(tiny.truncated, true, '极小预算应截断'); + assert.ok(tiny.summaries.length < tiny.total, '极小预算只应列出少量技能'); + const budgeted = getSkillSummariesBudgeted(SKILL_INJECT_MAX_CHARS); + const rendered = budgeted.summaries.join('\n'); + assert.ok(rendered.length <= SKILL_INJECT_MAX_CHARS, `摘要总长应不超预算,实际 ${rendered.length}`); + }, + /** buildLongTermPrompt:bank 有心智模型时长期记忆段应注入 ## 心智模型 与答案 */ async testBuildLongTermPromptInjectsMentalModel(): Promise { const origLongTerm = TC.boolConfigs['启用长期记忆']; diff --git a/src/changelog.ts b/src/changelog.ts index 9f3c3518..3b12765e 100644 --- a/src/changelog.ts +++ b/src/changelog.ts @@ -20,6 +20,7 @@ export const changelog: { [version: string]: string } = { ## 新功能 - 新增 memory_mm_list / memory_mm_view 工具:模型可通过 function calling 列出/查看心智模型;memory_mm_delete 支持按问题名删除(model_id 与 question 二选一) - .ai memo status 显示心智模型将注入条数(按当前会话范围估算) +- 新增 skill_list 工具:列出全部技能名称与描述,供模型发现「可用技能」段未列出的技能;可用技能段摘要按 1500 字符预算截断(预算内尽可能多列),超出提示用 skill_list 查看完整列表 `, "4.19.2": `## 配置变更 - 复杂配置(不适合热加载)改为「重载 JS 才生效」:纯文本/多模态/嵌入模型、评分触发配置、MCP 服务器配置、技能配置、知识库、角色扮演设定、触发/忽略正则、本地图片/语音/文件/视频路径、音乐服务配置等,现在启动时解析一次并常驻内存,修改后需在海豹面板「插件管理 → 重载 JS」(或重启海豹)才生效,避免配置与运行时解析结果不一致 - 简单配置(开关/数值/单行字符串/纯字符串数组等)仍按原样 60 秒内自动生效;MCP 服务器列表与总开关、技能、知识库等修改后不再热加载,需重载 JS diff --git a/src/prompt/builder.ts b/src/prompt/builder.ts index e939e0f8..a07efed8 100644 --- a/src/prompt/builder.ts +++ b/src/prompt/builder.ts @@ -9,7 +9,7 @@ import Model from "../model/model"; import { Session } from "../session/session"; import { GroupInfo, UserInfo } from "../session/types"; import User from "../session/user"; -import { getSkillsSignature, getSkillSummaries } from "../tool/skills"; +import { getSkillsSignature, getSkillSummariesBudgeted } from "../tool/skills"; import Tool from "../tool/tool"; import { fmtDate, stripInternalTags } from "../utils/string"; @@ -97,7 +97,7 @@ export async function buildSystemPromptContent( skillConfigSignature ]); const frame = await getCachedString(staticKey, STATIC_FRAME_TTL, () => { - const skillSummaries = getSkillSummaries(); + const skillResult = getSkillSummariesBudgeted(); const toolPrompt = STATUS && PROMPT_ENGINEERING ? Tool.getToolsInfoPrompt(session) : ''; let content = SYSTEM_MESSAGE_TEMPLATE({ @@ -111,8 +111,8 @@ export async function buildSystemPromptContent( toolPrompt }); - const skillBlock = STATUS && skillSummaries.length > 0 - ? `\n\n## 可用技能\n- ${skillSummaries.join('\n- ')}\n需要时请使用 use_skill 工具获取对应技能内容。` + const skillBlock = STATUS && skillResult.summaries.length > 0 + ? `\n\n## 可用技能\n- ${skillResult.summaries.join('\n- ')}${skillResult.truncated ? `\n…共 ${skillResult.total} 个技能,其余技能名可用 skill_list 查看` : ''}\n需要时请使用 use_skill 工具获取对应技能内容。` : ''; content = content.replace('**DYNAMIC_SECTIONS**', `${skillBlock}\n\n**DYNAMIC_SECTIONS**`); return content; diff --git a/src/tool/skills.ts b/src/tool/skills.ts index ef0b0cdc..93991205 100644 --- a/src/tool/skills.ts +++ b/src/tool/skills.ts @@ -14,6 +14,10 @@ interface Skill { const MAX_SKILL_CONTENT_LENGTH = 12000; // 单次返回的技能内容上限 const MAX_REF_DEPTH = 2; // 技能间引用的最大解析深度 +/** 技能摘要注入字符预算:system prompt「可用技能」段不超该值,超出提示用 skill_list 查看完整列表 */ +export const SKILL_INJECT_MAX_CHARS = 1500; +/** skill_list 工具返回技能条目的上限,避免技能过多时一次输出过长 */ +export const SKILL_LIST_LIMIT = 200; /** 解析标准 SKILL.md frontmatter(--- 开头,name/description 键值对),兼容 Claude/Codex/Cursor 等 agent 的技能文件 */ function parseSkillFrontmatter(raw: string): { name?: string, description?: string } { @@ -83,20 +87,57 @@ export function getSkillNames(): string[] { return getSkills().map(s => s.name); } -/** 返回技能摘要(名称 + 描述),用于注入 system prompt 的能力段 */ -export function getSkillSummaries(): string[] { - return getSkills().map(s => s.description ? `${s.name}:${s.description}` : s.name); +export interface SkillSummaryResult { + summaries: string[]; + total: number; + truncated: boolean; +} + +/** + * 返回技能摘要(名称 + 描述),用于注入 system prompt 的能力段。 + * 预算内尽可能多列,超出 SKILL_INJECT_MAX_CHARS 截断并标记 truncated, + * 模型可用 skill_list 查看完整技能名列表、用 use_skill 按需获取内容。 + */ +export function getSkillSummariesBudgeted(maxChars = SKILL_INJECT_MAX_CHARS): SkillSummaryResult { + const skills = getSkills(); + const summaries: string[] = []; + let total = 0; + for (const s of skills) { + const line = s.description ? `${s.name}:${s.description}` : s.name; + if (total + line.length + 2 > maxChars) break; + summaries.push(line); + total += line.length + 2; + } + return { summaries, total: skills.length, truncated: summaries.length < skills.length }; } /** * 读取“技能配置”,注册 use_skill 工具 */ export function registerSkills() { + const toolList = new Tool({ + type: "function", + function: { + name: "skill_list", + description: "列出全部技能的名称与描述(用于发现 system prompt「可用技能」段未列出的技能),随后可用 use_skill 按名称获取技能内容", + parameters: { + type: "object", + properties: {} + } + } + }); + toolList.solve = async () => { + const skills = getSkills(); + if (skills.length === 0) return '暂无技能'; + const items = skills.slice(0, SKILL_LIST_LIMIT).map(s => s.description ? `${s.name}:${s.description}` : s.name); + return `技能列表(共 ${skills.length} 个)\n- ${items.join('\n- ')}${skills.length > SKILL_LIST_LIMIT ? `\n…共 ${skills.length} 个技能,超出 ${SKILL_LIST_LIMIT} 条展示上限` : ''}`; + }; + const tool = new Tool({ type: "function", function: { name: "use_skill", - description: "使用指定技能完成当前任务,技能名称以 system prompt 中「可用技能」列表为准", + description: "使用指定技能完成当前任务,技能名称以 system prompt 中「可用技能」列表或 skill_list 返回的列表为准", parameters: { type: "object", properties: { From 0a2d1fe01bb4e1742d39e33880566829701b8bfa Mon Sep 17 00:00:00 2001 From: error2913 <2913949387@qq.com> Date: Tue, 1 Sep 2026 03:20:56 +0800 Subject: [PATCH 5/7] =?UTF-8?q?feat(memory):=20=E7=BE=A4=E8=81=8A=E9=95=BF?= =?UTF-8?q?=E6=9C=9F=E8=AE=B0=E5=BF=86=E6=B3=A8=E5=85=A5=E5=B9=B6=E5=85=A5?= =?UTF-8?q?=E6=9C=80=E8=BF=91=E5=8F=91=E8=A8=80=E8=80=85=E7=9A=84=E4=B8=AA?= =?UTF-8?q?=E4=BA=BA=E8=AE=B0=E5=BF=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - buildLongTermPrompt 在群聊时除群记忆库外,并入最近发言者(uis,即最近 2~3 条消息发言人) 的个人记忆库:长期记忆(每用户最多 5 条,合并后按分数降序 + 总字符预算 4096 截断) 与个人心智模型(每用户最多 2 条,追加在群心智模型之后) - 个人记忆按用户标签召回,私有记忆仅创建者本人可见(canSeeMemoryUnit),不泄露其他成员隐私 - 群心智模型(≤5)与群观察(≤20)保留原注入逻辑 - 新增单测 testBuildLongTermPromptMergesUserMemoryInGroup - 同步 changelog / docs/03 --- ...41\345\235\227\350\257\246\350\247\243.md" | 2 +- scripts/unit-test-entry.ts | 33 ++++++++++++++ src/changelog.ts | 1 + src/memory/manager.ts | 45 +++++++++++++++++-- 4 files changed, 76 insertions(+), 5 deletions(-) diff --git "a/docs/03-\346\240\270\345\277\203\346\250\241\345\235\227\350\257\246\350\247\243.md" "b/docs/03-\346\240\270\345\277\203\346\250\241\345\235\227\350\257\246\350\247\243.md" index 5bac9756..7d944939 100644 --- "a/docs/03-\346\240\270\345\277\203\346\250\241\345\235\227\350\257\246\350\247\243.md" +++ "b/docs/03-\346\240\270\345\277\203\346\250\241\345\235\227\350\257\246\350\247\243.md" @@ -127,7 +127,7 @@ - `knowledge_base.ts`:`KnowledgeBaseService` 单例,配置驱动(Markdown 模板,每条一份完整文档),解析时不出向量(懒向量);`search()` 先关键词过滤,嵌入可用时仅对命中候选(≤20 块)做惰性向量重排,失败自动降级关键词;`buildKnowledgePrompt()` 只展示条目索引:ID + 标题,1500 字符预算内尽可能多列(≤100 条),不注入正文,详情由模型用 `knowledge_read` 按 ID 读取;`formatIndex(limit)` 支持索引条数上限,`knowledge_list` 工具与 `.ai kb list` 上限 200 条。 - `knowledge.ts`:兼容导出层,`knowledgeService = knowledgeBase`(旧 `KnowledgeService` 已移除)。 - `manager.ts`:`MemoryManager` 统一入口:`buildLongTermPrompt` / `buildSummaryPrompt` / `buildKnowledgePrompt` / `addMemory` / `search` / `summarize`。 -- `v2/`(4.17.0 起):Hindsight-like 纯 TS 记忆引擎。`engine.ts` 实现 Retain(抽取原子事实/观察记忆,可选 LLM 结构化抽取 occurred_at/entities)、Recall(RRF 混合检索 + Hindsight 式新近度加权(按 lastAccessedAt 指数衰减,权重/半衰期可配)+ 可选 LLM 重排)、Consolidation(LLM 合成观察记忆、合并重复、清理 90 天过期观察)、MentalModel(心智模型:question/answer/scopeTags/lastRefreshedAt/history/trigger,支持 refresh/delete、persona 懒迁移)、遗忘机制(长期记忆条数上限,默认 100,0=不限制;retain/consolidate 后超限时按「已被观察覆盖 → 普通按衰减分 → observation 沉淀 → 保护项(importance≥0.9 或 pinned)」优先级物理删除多余记忆,上限为硬约束);`repository.ts` 统一存取长期/观察记忆与心智模型,`bank_resolver.ts` 按会话解析记忆库,`migrate.ts` 负责旧记忆自动迁移,`llm.ts` 提供可选的 LLM 抽取/重排/合成/推理回调,`prompt.ts` 负责注入裁剪(scopeTags 任一命中过滤 + 90 天过期观察剔除 + 心智模型/观察各取最新 5/20 条,观察记忆只在长期记忆段裁剪注入,避免重复)。配套记忆工具为 `memory_*` 系列(增删改查/巩固/推理/心智模型,含 `memory_mm_list/view` 查看心智模型、`memory_mm_delete` 可按 model_id 或 question 删除),`.ai memo` 子命令重构为 status/add/list/update/delete/clear/obs/consolidate/reflect/mm,范围参数改用 `--u`/`--g`。 +- `v2/`(4.17.0 起):Hindsight-like 纯 TS 记忆引擎。`engine.ts` 实现 Retain(抽取原子事实/观察记忆,可选 LLM 结构化抽取 occurred_at/entities)、Recall(RRF 混合检索 + Hindsight 式新近度加权(按 lastAccessedAt 指数衰减,权重/半衰期可配)+ 可选 LLM 重排)、Consolidation(LLM 合成观察记忆、合并重复、清理 90 天过期观察)、MentalModel(心智模型:question/answer/scopeTags/lastRefreshedAt/history/trigger,支持 refresh/delete、persona 懒迁移)、遗忘机制(长期记忆条数上限,默认 100,0=不限制;retain/consolidate 后超限时按「已被观察覆盖 → 普通按衰减分 → observation 沉淀 → 保护项(importance≥0.9 或 pinned)」优先级物理删除多余记忆,上限为硬约束);`repository.ts` 统一存取长期/观察记忆与心智模型,`bank_resolver.ts` 按会话解析记忆库(私聊 `user_` / 群聊 `group_`),`migrate.ts` 负责旧记忆自动迁移,`llm.ts` 提供可选的 LLM 抽取/重排/合成/推理回调,`prompt.ts` 负责注入裁剪(scopeTags 任一命中过滤 + 90 天过期观察剔除 + 心智模型/观察各取最新 5/20 条,观察记忆只在长期记忆段裁剪注入,避免重复)。`manager.ts buildLongTermPrompt` 在群聊时除群记忆库外,还会并入最近发言者(最近 2~3 条消息发言人)的个人记忆库:长期记忆(每用户最多 5 条,合并后按分数降序 + 总字符预算截断)与个人心智模型(每用户最多 2 条),私有记忆仅创建者本人可见。配套记忆工具为 `memory_*` 系列(增删改查/巩固/推理/心智模型,含 `memory_mm_list/view` 查看心智模型、`memory_mm_delete` 可按 model_id 或 question 删除),`.ai memo` 子命令重构为 status/add/list/update/delete/clear/obs/consolidate/reflect/mm,范围参数改用 `--u`/`--g`。 ## 智能体(src/agent/) diff --git a/scripts/unit-test-entry.ts b/scripts/unit-test-entry.ts index d5469b0d..206ce2d2 100644 --- a/scripts/unit-test-entry.ts +++ b/scripts/unit-test-entry.ts @@ -698,6 +698,39 @@ export const tests: Record void | Promise> = { } }, + /** 群聊长期记忆段:并入最近发言者(uis)的个人记忆库——长期记忆 + 个人心智模型,且群记忆/群心智模型保留 */ + async testBuildLongTermPromptMergesUserMemoryInGroup(): Promise { + const origLongTerm = TC.boolConfigs['启用长期记忆']; + TC.boolConfigs['启用长期记忆'] = true; + resetConfigCache(); + resetMemoryEngineForTest(new InMemoryMemoryStorage()); + try { + const engine = getMemoryEngine(); + const session = new Session(); + session.sessionId = 'QQ-Group:1'; + session.sessionType = 'group'; + session.memory.persona = '无'; + // 群记忆 + 群心智模型 + await engine.addMemory('group_QQ-Group:1', { content: '群规则:不许刷屏', tags: ['group:QQ-Group:1'] }); + await engine.createMentalModel('group_QQ-Group:1', '群规是什么?', '不许刷屏', ['group:QQ-Group:1']); + // 最近发言者的个人记忆 + 个人心智模型(QQ:2) + await engine.addMemory('user_QQ:2', { content: '小明喜欢喝咖啡', tags: ['user:QQ:2'] }); + await engine.createMentalModel('user_QQ:2', '小明的偏好?', '喜欢咖啡', ['user:QQ:2']); + const ctx = { isPrivate: false, player: { userId: 'QQ:2', name: '小明' }, group: { groupId: 'QQ-Group:1', groupName: '测试群' } } as any; + const uis = [{ isPrivate: true, id: 'QQ:2', name: '小明' }] as any; + const prompt = await MemoryManager.buildLongTermPrompt(ctx, session, '群规 咖啡', uis, { isPrivate: false, id: 'QQ-Group:1', name: '测试群' } as any); + assert.ok(prompt.includes('## 心智模型'), '应包含心智模型段'); + assert.ok(prompt.includes('群规是什么?') && prompt.includes('不许刷屏'), '群心智模型应注入'); + assert.ok(prompt.includes('小明的偏好?') && prompt.includes('喜欢咖啡'), '个人心智模型应注入'); + assert.ok(prompt.includes('## 长期记忆'), '应包含长期记忆段'); + assert.ok(prompt.includes('群规则:不许刷屏'), '群记忆应注入'); + assert.ok(prompt.includes('小明喜欢喝咖啡'), '最近发言者的个人记忆应注入'); + } finally { + if (origLongTerm === undefined) delete TC.boolConfigs['启用长期记忆']; else TC.boolConfigs['启用长期记忆'] = origLongTerm; + resetConfigCache(); + } + }, + /** buildObservationPrompt:观察超过上限时裁剪到 MAX_OBSERVATIONS(不再全量注入) */ testBuildObservationPromptTrimmed(): void { const origSummary = TC.boolConfigs['启用观察记忆']; diff --git a/src/changelog.ts b/src/changelog.ts index 3b12765e..88c52b06 100644 --- a/src/changelog.ts +++ b/src/changelog.ts @@ -16,6 +16,7 @@ export const changelog: { [version: string]: string } = { - 修复心智模型/观察记忆 scope 严格匹配导致的注入丢失:改为任一标签命中即注入,群聊中不再因某贡献者不在最近消息而整条剔除;memory_mm_create / .ai memo mm add 的 scope_tag 非法或与目标类型不符时回退默认标签,避免静默失效 - 观察记忆注入收敛:启用长期记忆时观察只由长期记忆段裁剪注入(上限 20 + 90 天过期剔除),不再与观察段重复渲染;观察段同样按上限裁剪;巩固生成观察后刷新观察段缓存,消除最长 60 秒滞后 - 记忆/知识库工具描述修正:memory_update 引用的 search_memory 改为 memory_recall;knowledge_read 引用的 kb_list/kb_search 改为 knowledge_list/knowledge_search +- 群聊长期记忆注入并入最近发言者的个人记忆:每个最近发言者(最近 2~3 条消息的发言人)的个人记忆库中的长期记忆与心智模型随群聊注入;个人记忆按分数降序 + 总字符预算截断(每用户最多 5 条/2 个心智模型),私有记忆仅创建者本人可见,不泄露其他成员隐私 ## 新功能 - 新增 memory_mm_list / memory_mm_view 工具:模型可通过 function calling 列出/查看心智模型;memory_mm_delete 支持按问题名删除(model_id 与 question 二选一) diff --git a/src/memory/manager.ts b/src/memory/manager.ts index 9a5d813d..4c4a5623 100644 --- a/src/memory/manager.ts +++ b/src/memory/manager.ts @@ -30,6 +30,15 @@ function tagsForSession(uis: UserInfo[], gi: GroupInfo | null): string[] { return tags; } +/** 群聊注入时,每个最近发言者个人记忆库的召回 token 预算 */ +const MERGE_USER_RECALL_MAX_TOKENS = 1024; +/** 群聊注入时,每个最近发言者最多并入的个人记忆条数 */ +const PER_USER_RECALLS = 5; +/** 群聊注入时,每个最近发言者最多并入的个人心智模型条数 */ +const PER_USER_MENTAL_MODELS = 2; +/** 群聊注入时,个人记忆合并后的总字符预算(按分数降序截断,避免多库膨胀) */ +const MERGE_USER_RECALL_MAX_CHARS = 4096; + export class MemoryManager { /** * 旧 persona 懒迁移:把仍存于 session.memory.persona 的旧设定写入心智模型 @@ -70,10 +79,38 @@ export class MemoryManager { budget: 'mid', })).filter(r => canSeeMemoryUnit(r.unit, callerSessionId)); const allObservations = engine.repository.listObservations(bank.bankId); - const allMentalModels = engine.listMentalModels(bank.bankId); - // E1/E2:scopeTags 过滤 + stale 剔除 + 条数上限(注入裁剪,控制 token) - const { mentalModels, observations } = selectInjectionCandidates(allMentalModels, allObservations, tags); - Logger.debug(`[记忆注入] bank=${bank.bankId} tags=[${tags.join(',')}] 心智模型 ${allMentalModels.length}→${mentalModels.length} 观察 ${allObservations.length}→${observations.length}`); + // 群心智模型(≤MAX_MENTAL_MODELS)与群观察(≤MAX_OBSERVATIONS):scopeTags 过滤 + stale 剔除 + 条数上限 + const mentalModels = selectInjectionCandidates(engine.listMentalModels(bank.bankId), allObservations, tags).mentalModels; + const observations = selectInjectionCandidates([], allObservations, tags).observations; + + // 群聊:并入最近发言者(uis)的个人记忆库——长期记忆(召回)+ 个人心智模型。 + // 个人记忆按用户标签召回、按分数降序 + 总字符预算截断;私有记忆仅创建者本人可见(canSeeMemoryUnit)。 + if (session.sessionType === 'group' && uis.length > 0) { + const mergedRecalls: typeof recalls = []; + for (const u of uis) { + const userBankId = resolveBankId(u.id, 'user', session.agentName).bankId; + mergedRecalls.push(...(await engine.recall(userBankId, text, { + tags: [`user:${u.id}`], + maxTokens: MERGE_USER_RECALL_MAX_TOKENS, + preferObservations: true, + budget: 'low', + })) + .filter(r => canSeeMemoryUnit(r.unit, callerSessionId)) + .slice(0, PER_USER_RECALLS)); + // 个人心智模型:scope 过滤后每用户最多 PER_USER_MENTAL_MODELS 条,追加在群心智模型之后 + mentalModels.push(...selectInjectionCandidates(engine.listMentalModels(userBankId), [], tags) + .mentalModels + .slice(0, PER_USER_MENTAL_MODELS)); + } + mergedRecalls.sort((a, b) => b.score - a.score); + let total = 0; + for (const r of mergedRecalls) { + if (total + r.unit.text.length > MERGE_USER_RECALL_MAX_CHARS) break; + recalls.push(r); + total += r.unit.text.length; + } + } + Logger.debug(`[记忆注入] bank=${bank.bankId} tags=[${tags.join(',')}] 心智模型 ${mentalModels.length} 观察 ${observations.length} 长期记忆 ${recalls.length}(群聊并入了 ${uis.length} 个发言者的个人记忆)`); const sessionName = ctx.isPrivate ? (ctx.player?.name || '') : (ctx.group?.groupName || ''); return buildMemoryPrompt({ isPrivate: ctx.isPrivate, From b13bc7d93d35476d29110dfdaa1471de2a978340 Mon Sep 17 00:00:00 2001 From: error2913 <2913949387@qq.com> Date: Tue, 1 Sep 2026 03:29:02 +0800 Subject: [PATCH 6/7] =?UTF-8?q?refactor(memory):=20=E7=BE=A4=E8=81=8A/?= =?UTF-8?q?=E4=B8=AA=E4=BA=BA=E8=AE=B0=E5=BF=86=E5=88=86=E7=BB=84=E6=B8=B2?= =?UTF-8?q?=E6=9F=93=EF=BC=8C=E9=81=BF=E5=85=8D=E5=BF=83=E6=99=BA=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E5=BD=92=E5=B1=9E=E6=B7=B7=E6=B7=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 buildGroupedMemoryPrompt:按「群聊 / 每个最近发言者」分别渲染记忆段, 每组独立列出长期记忆 + 心智模型(+ 群观察),不再把多个归属的心智模型/记忆混在一起 - buildLongTermPrompt 改用分组渲染:私聊单组「个人记忆(名称)」; 群聊为「群聊记忆(群名)」+ 每个发言者「个人记忆(名称)」 - 个人记忆每用户最多 5 条、单用户 2048 字符预算;个人心智模型每用户最多 2 条 - 更新单测断言适配分组格式 + 归属顺序;同步 changelog / docs/03 --- ...41\345\235\227\350\257\246\350\247\243.md" | 2 +- scripts/unit-test-entry.ts | 20 +++--- src/changelog.ts | 2 +- src/memory/manager.ts | 72 ++++++++++--------- src/memory/v2/prompt.ts | 39 ++++++++++ 5 files changed, 92 insertions(+), 43 deletions(-) diff --git "a/docs/03-\346\240\270\345\277\203\346\250\241\345\235\227\350\257\246\350\247\243.md" "b/docs/03-\346\240\270\345\277\203\346\250\241\345\235\227\350\257\246\350\247\243.md" index 7d944939..110b88ce 100644 --- "a/docs/03-\346\240\270\345\277\203\346\250\241\345\235\227\350\257\246\350\247\243.md" +++ "b/docs/03-\346\240\270\345\277\203\346\250\241\345\235\227\350\257\246\350\247\243.md" @@ -127,7 +127,7 @@ - `knowledge_base.ts`:`KnowledgeBaseService` 单例,配置驱动(Markdown 模板,每条一份完整文档),解析时不出向量(懒向量);`search()` 先关键词过滤,嵌入可用时仅对命中候选(≤20 块)做惰性向量重排,失败自动降级关键词;`buildKnowledgePrompt()` 只展示条目索引:ID + 标题,1500 字符预算内尽可能多列(≤100 条),不注入正文,详情由模型用 `knowledge_read` 按 ID 读取;`formatIndex(limit)` 支持索引条数上限,`knowledge_list` 工具与 `.ai kb list` 上限 200 条。 - `knowledge.ts`:兼容导出层,`knowledgeService = knowledgeBase`(旧 `KnowledgeService` 已移除)。 - `manager.ts`:`MemoryManager` 统一入口:`buildLongTermPrompt` / `buildSummaryPrompt` / `buildKnowledgePrompt` / `addMemory` / `search` / `summarize`。 -- `v2/`(4.17.0 起):Hindsight-like 纯 TS 记忆引擎。`engine.ts` 实现 Retain(抽取原子事实/观察记忆,可选 LLM 结构化抽取 occurred_at/entities)、Recall(RRF 混合检索 + Hindsight 式新近度加权(按 lastAccessedAt 指数衰减,权重/半衰期可配)+ 可选 LLM 重排)、Consolidation(LLM 合成观察记忆、合并重复、清理 90 天过期观察)、MentalModel(心智模型:question/answer/scopeTags/lastRefreshedAt/history/trigger,支持 refresh/delete、persona 懒迁移)、遗忘机制(长期记忆条数上限,默认 100,0=不限制;retain/consolidate 后超限时按「已被观察覆盖 → 普通按衰减分 → observation 沉淀 → 保护项(importance≥0.9 或 pinned)」优先级物理删除多余记忆,上限为硬约束);`repository.ts` 统一存取长期/观察记忆与心智模型,`bank_resolver.ts` 按会话解析记忆库(私聊 `user_` / 群聊 `group_`),`migrate.ts` 负责旧记忆自动迁移,`llm.ts` 提供可选的 LLM 抽取/重排/合成/推理回调,`prompt.ts` 负责注入裁剪(scopeTags 任一命中过滤 + 90 天过期观察剔除 + 心智模型/观察各取最新 5/20 条,观察记忆只在长期记忆段裁剪注入,避免重复)。`manager.ts buildLongTermPrompt` 在群聊时除群记忆库外,还会并入最近发言者(最近 2~3 条消息发言人)的个人记忆库:长期记忆(每用户最多 5 条,合并后按分数降序 + 总字符预算截断)与个人心智模型(每用户最多 2 条),私有记忆仅创建者本人可见。配套记忆工具为 `memory_*` 系列(增删改查/巩固/推理/心智模型,含 `memory_mm_list/view` 查看心智模型、`memory_mm_delete` 可按 model_id 或 question 删除),`.ai memo` 子命令重构为 status/add/list/update/delete/clear/obs/consolidate/reflect/mm,范围参数改用 `--u`/`--g`。 +- `v2/`(4.17.0 起):Hindsight-like 纯 TS 记忆引擎。`engine.ts` 实现 Retain(抽取原子事实/观察记忆,可选 LLM 结构化抽取 occurred_at/entities)、Recall(RRF 混合检索 + Hindsight 式新近度加权(按 lastAccessedAt 指数衰减,权重/半衰期可配)+ 可选 LLM 重排)、Consolidation(LLM 合成观察记忆、合并重复、清理 90 天过期观察)、MentalModel(心智模型:question/answer/scopeTags/lastRefreshedAt/history/trigger,支持 refresh/delete、persona 懒迁移)、遗忘机制(长期记忆条数上限,默认 100,0=不限制;retain/consolidate 后超限时按「已被观察覆盖 → 普通按衰减分 → observation 沉淀 → 保护项(importance≥0.9 或 pinned)」优先级物理删除多余记忆,上限为硬约束);`repository.ts` 统一存取长期/观察记忆与心智模型,`bank_resolver.ts` 按会话解析记忆库(私聊 `user_` / 群聊 `group_`),`migrate.ts` 负责旧记忆自动迁移,`llm.ts` 提供可选的 LLM 抽取/重排/合成/推理回调,`prompt.ts` 负责注入裁剪(scopeTags 任一命中过滤 + 90 天过期观察剔除 + 心智模型/观察各取最新 5/20 条,观察记忆只在长期记忆段裁剪注入,避免重复)与分组渲染(`buildGroupedMemoryPrompt`:按「群聊 / 每个发言者」分别渲染长期记忆 + 心智模型)。`manager.ts buildLongTermPrompt` 在群聊时除群记忆库外,还会并入最近发言者(最近 2~3 条消息发言人)的个人记忆库并分组渲染:群心智模型 ≤5、群观察 ≤20、个人记忆每用户最多 5 条(单用户 2048 字符预算)、个人心智模型每用户最多 2 条,私有记忆仅创建者本人可见。配套记忆工具为 `memory_*` 系列(增删改查/巩固/推理/心智模型,含 `memory_mm_list/view` 查看心智模型、`memory_mm_delete` 可按 model_id 或 question 删除),`.ai memo` 子命令重构为 status/add/list/update/delete/clear/obs/consolidate/reflect/mm,范围参数改用 `--u`/`--g`。 ## 智能体(src/agent/) diff --git a/scripts/unit-test-entry.ts b/scripts/unit-test-entry.ts index 206ce2d2..f995ea82 100644 --- a/scripts/unit-test-entry.ts +++ b/scripts/unit-test-entry.ts @@ -690,8 +690,8 @@ export const tests: Record void | Promise> = { const ctx = { isPrivate: true, player: { userId: 'QQ:1', name: '测试员' }, group: null } as any; const uis = [{ isPrivate: true, id: 'QQ:1', name: '测试员' }] as any; const prompt = await MemoryManager.buildLongTermPrompt(ctx, session, '你好', uis, null); - assert.ok(prompt.includes('## 心智模型'), '长期记忆段应包含心智模型段'); - assert.ok(prompt.includes('喜欢简洁回复'), '心智模型答案应被注入'); + assert.ok(prompt.includes('## 个人记忆(测试员)'), '私聊应渲染个人记忆分组'); + assert.ok(prompt.includes('心智模型:') && prompt.includes('喜欢简洁回复'), '分组内应注入心智模型'); } finally { if (origLongTerm === undefined) delete TC.boolConfigs['启用长期记忆']; else TC.boolConfigs['启用长期记忆'] = origLongTerm; resetConfigCache(); @@ -719,12 +719,16 @@ export const tests: Record void | Promise> = { const ctx = { isPrivate: false, player: { userId: 'QQ:2', name: '小明' }, group: { groupId: 'QQ-Group:1', groupName: '测试群' } } as any; const uis = [{ isPrivate: true, id: 'QQ:2', name: '小明' }] as any; const prompt = await MemoryManager.buildLongTermPrompt(ctx, session, '群规 咖啡', uis, { isPrivate: false, id: 'QQ-Group:1', name: '测试群' } as any); - assert.ok(prompt.includes('## 心智模型'), '应包含心智模型段'); - assert.ok(prompt.includes('群规是什么?') && prompt.includes('不许刷屏'), '群心智模型应注入'); - assert.ok(prompt.includes('小明的偏好?') && prompt.includes('喜欢咖啡'), '个人心智模型应注入'); - assert.ok(prompt.includes('## 长期记忆'), '应包含长期记忆段'); - assert.ok(prompt.includes('群规则:不许刷屏'), '群记忆应注入'); - assert.ok(prompt.includes('小明喜欢喝咖啡'), '最近发言者的个人记忆应注入'); + assert.ok(prompt.includes('## 群聊记忆(测试群)'), '应渲染群聊分组'); + assert.ok(prompt.includes('## 个人记忆(小明)'), '应渲染最近发言者的个人分组'); + assert.ok(prompt.includes('群规是什么?') && prompt.includes('不许刷屏'), '群心智模型应注入群聊分组'); + assert.ok(prompt.includes('小明的偏好?') && prompt.includes('喜欢咖啡'), '个人心智模型应注入个人分组'); + assert.ok(prompt.includes('群规则:不许刷屏'), '群记忆应注入群聊分组'); + assert.ok(prompt.includes('小明喜欢喝咖啡'), '最近发言者的个人记忆应注入个人分组'); + // 归属隔离:个人心智模型/记忆不应出现在群聊分组之前,避免混在一起 + const groupIdx = prompt.indexOf('## 群聊记忆'); + const personalIdx = prompt.indexOf('## 个人记忆'); + assert.ok(groupIdx >= 0 && personalIdx > groupIdx, '群聊分组应排在个人分组之前'); } finally { if (origLongTerm === undefined) delete TC.boolConfigs['启用长期记忆']; else TC.boolConfigs['启用长期记忆'] = origLongTerm; resetConfigCache(); diff --git a/src/changelog.ts b/src/changelog.ts index 88c52b06..9e6c957c 100644 --- a/src/changelog.ts +++ b/src/changelog.ts @@ -16,7 +16,7 @@ export const changelog: { [version: string]: string } = { - 修复心智模型/观察记忆 scope 严格匹配导致的注入丢失:改为任一标签命中即注入,群聊中不再因某贡献者不在最近消息而整条剔除;memory_mm_create / .ai memo mm add 的 scope_tag 非法或与目标类型不符时回退默认标签,避免静默失效 - 观察记忆注入收敛:启用长期记忆时观察只由长期记忆段裁剪注入(上限 20 + 90 天过期剔除),不再与观察段重复渲染;观察段同样按上限裁剪;巩固生成观察后刷新观察段缓存,消除最长 60 秒滞后 - 记忆/知识库工具描述修正:memory_update 引用的 search_memory 改为 memory_recall;knowledge_read 引用的 kb_list/kb_search 改为 knowledge_list/knowledge_search -- 群聊长期记忆注入并入最近发言者的个人记忆:每个最近发言者(最近 2~3 条消息的发言人)的个人记忆库中的长期记忆与心智模型随群聊注入;个人记忆按分数降序 + 总字符预算截断(每用户最多 5 条/2 个心智模型),私有记忆仅创建者本人可见,不泄露其他成员隐私 +- 群聊长期记忆按「群聊 / 每个最近发言者」分组注入:每组独立渲染长期记忆与心智模型,避免多个归属混在一起造成混淆;群心智模型 ≤5、群观察 ≤20,个人记忆每用户最多 5 条(单用户 2048 字符预算)、个人心智模型每用户最多 2 条;私有记忆仅创建者本人可见,不泄露其他成员隐私 ## 新功能 - 新增 memory_mm_list / memory_mm_view 工具:模型可通过 function calling 列出/查看心智模型;memory_mm_delete 支持按问题名删除(model_id 与 question 二选一) diff --git a/src/memory/manager.ts b/src/memory/manager.ts index 4c4a5623..3d70caeb 100644 --- a/src/memory/manager.ts +++ b/src/memory/manager.ts @@ -16,7 +16,7 @@ import { bumpMemoryRevision, bumpSummaryRevision } from "./revision"; import { parseLooseJson } from "./session_memory"; import { resolveBankId } from "./v2/bank_resolver"; import { getMemoryEngine, MENTAL_MODEL_PERSONA_QUESTION } from "./v2/index"; -import { buildMemoryPrompt, selectInjectionCandidates } from "./v2/prompt"; +import { buildGroupedMemoryPrompt, MemoryPromptSection, selectInjectionCandidates } from "./v2/prompt"; import type { MemoryUnit, RecallOptions, RetainResult } from "./v2/types"; function bankForSession(session: Session) { @@ -36,8 +36,8 @@ const MERGE_USER_RECALL_MAX_TOKENS = 1024; const PER_USER_RECALLS = 5; /** 群聊注入时,每个最近发言者最多并入的个人心智模型条数 */ const PER_USER_MENTAL_MODELS = 2; -/** 群聊注入时,个人记忆合并后的总字符预算(按分数降序截断,避免多库膨胀) */ -const MERGE_USER_RECALL_MAX_CHARS = 4096; +/** 群聊注入时,单个最近发言者个人记忆组的字符预算(按分数降序截断,避免多库膨胀) */ +const PER_USER_RECALL_MAX_CHARS = 2048; export class MemoryManager { /** @@ -72,7 +72,7 @@ export class MemoryManager { engine.ensureBank(bank.bankId, bank.kind, bank.agentName); const tags = tagsForSession(uis, gi || null); const callerSessionId = ctx.player?.userId || session.sessionId; - const recalls = (await engine.recall(bank.bankId, text, { + const groupRecalls = (await engine.recall(bank.bankId, text, { tags, maxTokens: 2048, preferObservations: true, @@ -80,45 +80,51 @@ export class MemoryManager { })).filter(r => canSeeMemoryUnit(r.unit, callerSessionId)); const allObservations = engine.repository.listObservations(bank.bankId); // 群心智模型(≤MAX_MENTAL_MODELS)与群观察(≤MAX_OBSERVATIONS):scopeTags 过滤 + stale 剔除 + 条数上限 - const mentalModels = selectInjectionCandidates(engine.listMentalModels(bank.bankId), allObservations, tags).mentalModels; - const observations = selectInjectionCandidates([], allObservations, tags).observations; + const groupMMs = selectInjectionCandidates(engine.listMentalModels(bank.bankId), allObservations, tags).mentalModels; + const groupObs = selectInjectionCandidates([], allObservations, tags).observations; - // 群聊:并入最近发言者(uis)的个人记忆库——长期记忆(召回)+ 个人心智模型。 - // 个人记忆按用户标签召回、按分数降序 + 总字符预算截断;私有记忆仅创建者本人可见(canSeeMemoryUnit)。 - if (session.sessionType === 'group' && uis.length > 0) { - const mergedRecalls: typeof recalls = []; + // 分组渲染:群聊按「群聊 / 每个最近发言者」分别渲染长期记忆 + 心智模型,避免归属混淆; + // 个人记忆按用户标签召回、单用户条数/字符预算截断,私有记忆仅创建者本人可见(canSeeMemoryUnit)。 + const sections: MemoryPromptSection[] = []; + const isGroup = session.sessionType === 'group'; + if (isGroup) { + sections.push({ + title: `群聊记忆(${gi?.name || session.sessionId})`, + mentalModels: groupMMs, + observations: groupObs, + recalls: groupRecalls, + }); for (const u of uis) { const userBankId = resolveBankId(u.id, 'user', session.agentName).bankId; - mergedRecalls.push(...(await engine.recall(userBankId, text, { + const ranked = (await engine.recall(userBankId, text, { tags: [`user:${u.id}`], maxTokens: MERGE_USER_RECALL_MAX_TOKENS, preferObservations: true, budget: 'low', - })) - .filter(r => canSeeMemoryUnit(r.unit, callerSessionId)) - .slice(0, PER_USER_RECALLS)); - // 个人心智模型:scope 过滤后每用户最多 PER_USER_MENTAL_MODELS 条,追加在群心智模型之后 - mentalModels.push(...selectInjectionCandidates(engine.listMentalModels(userBankId), [], tags) + })).filter(r => canSeeMemoryUnit(r.unit, callerSessionId)); + const userRecalls: typeof groupRecalls = []; + let total = 0; + for (const r of ranked) { + if (userRecalls.length >= PER_USER_RECALLS) break; + if (total + r.unit.text.length > PER_USER_RECALL_MAX_CHARS) break; + userRecalls.push(r); + total += r.unit.text.length; + } + const userMMs = selectInjectionCandidates(engine.listMentalModels(userBankId), [], tags) .mentalModels - .slice(0, PER_USER_MENTAL_MODELS)); - } - mergedRecalls.sort((a, b) => b.score - a.score); - let total = 0; - for (const r of mergedRecalls) { - if (total + r.unit.text.length > MERGE_USER_RECALL_MAX_CHARS) break; - recalls.push(r); - total += r.unit.text.length; + .slice(0, PER_USER_MENTAL_MODELS); + sections.push({ title: `个人记忆(${u.name || u.id})`, mentalModels: userMMs, recalls: userRecalls }); } + } else { + sections.push({ + title: `个人记忆(${ctx.player?.name || session.sessionId})`, + mentalModels: groupMMs, + observations: groupObs, + recalls: groupRecalls, + }); } - Logger.debug(`[记忆注入] bank=${bank.bankId} tags=[${tags.join(',')}] 心智模型 ${mentalModels.length} 观察 ${observations.length} 长期记忆 ${recalls.length}(群聊并入了 ${uis.length} 个发言者的个人记忆)`); - const sessionName = ctx.isPrivate ? (ctx.player?.name || '') : (ctx.group?.groupName || ''); - return buildMemoryPrompt({ - isPrivate: ctx.isPrivate, - sessionName, - mentalModels, - observations, - recalls, - }); + Logger.debug(`[记忆注入] bank=${bank.bankId} tags=[${tags.join(',')}] 分组 ${sections.length} 段(群聊 ${isGroup ? uis.length + 1 : 1} 段)`); + return buildGroupedMemoryPrompt(sections); } /** 观察记忆段:由 Observation 替代旧 summaries */ diff --git a/src/memory/v2/prompt.ts b/src/memory/v2/prompt.ts index 7b394310..42131348 100644 --- a/src/memory/v2/prompt.ts +++ b/src/memory/v2/prompt.ts @@ -53,6 +53,15 @@ export interface MemoryPromptOptions { maxLengthPerItem?: number; } +/** 分组记忆段:一组对应一个归属(群聊 / 某个发言者),组内独立渲染心智模型 + 观察记忆 + 长期记忆 */ +export interface MemoryPromptSection { + title: string; + mentalModels?: MentalModel[]; + observations?: Observation[]; + recalls?: RecallResult[]; + maxLengthPerItem?: number; +} + export function buildMemoryPrompt(options: MemoryPromptOptions): string { const parts: string[] = []; const max = options.maxLengthPerItem ?? 1000; @@ -78,6 +87,36 @@ export function buildMemoryPrompt(options: MemoryPromptOptions): string { return parts.join('\n\n'); } +/** + * 分组渲染记忆段:按「群聊 / 每个发言者」分别渲染长期记忆 + 心智模型, + * 避免多个归属的心智模型/记忆混在同一段里造成归属混淆。 + */ +export function buildGroupedMemoryPrompt(sections: MemoryPromptSection[]): string { + const parts: string[] = []; + for (const sec of sections) { + const hasMM = !!sec.mentalModels?.length; + const hasObs = !!sec.observations?.length; + const hasRecall = !!sec.recalls?.length; + if (!hasMM && !hasObs && !hasRecall) continue; + const max = sec.maxLengthPerItem ?? 1000; + const lines: string[] = [`## ${sec.title}`]; + if (hasMM) { + lines.push('心智模型:'); + lines.push(sec.mentalModels!.map(m => `${m.question}\n${truncate(m.answer, max)}`).join('\n')); + } + if (hasObs) { + lines.push('观察记忆:'); + lines.push(sec.observations!.map((o, i) => `${i + 1}. ${truncate(o.text, max)}`).join('\n')); + } + if (hasRecall) { + lines.push('长期记忆:'); + lines.push(sec.recalls!.map((r, i) => `${i + 1}. [${r.unit.id}] ${truncate(r.unit.text, max)}`).join('\n')); + } + parts.push(lines.join('\n')); + } + return parts.join('\n\n'); +} + function truncate(text: string, max: number): string { const s = String(text || ''); return s.length > max ? s.slice(0, max) + '…' : s; From 7d030643052820efb0bf46f205017826bc0c793e Mon Sep 17 00:00:00 2001 From: error2913 <2913949387@qq.com> Date: Tue, 1 Sep 2026 03:37:52 +0800 Subject: [PATCH 7/7] =?UTF-8?q?docs(changelog):=20=E6=95=B4=E7=90=86=204.1?= =?UTF-8?q?9.3=20=E5=8F=91=E5=B8=83=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 合并重复的 ## 修复 节,面向用户重写本次记忆/知识库/技能改动条目 - 变更:知识库只展示条目索引、技能摘要预算注入、群聊/个人记忆分组注入 - 修复:.ai memo mm del 删除失效、心智模型/观察注入丢失、观察重复注入与滞后、工具描述修正 - 新功能:memory_mm_list/view、skill_list、status 注入条数 --- src/changelog.ts | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/changelog.ts b/src/changelog.ts index 9e6c957c..3a1129d8 100644 --- a/src/changelog.ts +++ b/src/changelog.ts @@ -5,23 +5,22 @@ export const changelog: { [version: string]: string } = { - 定时器变更改为即时落盘(写穿):此前定时器在到点执行、间隔更新等变更后先改内存、再定时批量落盘,重载 JS / 杀进程会丢失最近变更;现在每次变更(新增/移除/间隔更新/到点执行/超时忽略)都同步写入存储 - 修复重载 JS 后定时器队列恒空:恢复存档时的过滤条件与存档字段不一致(用 sessionId/sessionType 匹配 sid/isPrivate),导致所有定时器被过滤、重载后队列清空;现按实际存档字段恢复,旧格式条目自动跳过 - 挂起消息队列改为轻量分片持久化(session_:pending):此前挂起队列随会话整存整取,含不可序列化的运行时 ctx/msg 对象;现只落盘重建所需字段(内容/用户/消息ID/类型/原因/epId/isPrivate),重载 JS 后按 epId/isPrivate 重建上下文续跑,队列清空时同步清档避免脏数据复活 +- 修复 .ai memo mm del 删除失效:del 经别名归一化为 delete 后未命中删除分支,实际返回帮助文本;现按归一化目标匹配,del / delete 均可删除,并支持按问题名或关键词删除(唯一命中直接删,多条列出 ID 由用户指定) +- 修复心智模型/观察记忆注入丢失:范围标签由「全部命中」放宽为「任一命中」,群聊中不再因某发言者不在最近消息而整条被剔除;memory_mm_create / .ai memo mm add 的自定义范围标签非法或与目标类型不符时自动回退默认标签 +- 修复观察记忆重复注入与滞后:启用长期记忆时观察只由长期记忆段注入(上限 20 条 + 90 天过期剔除),不再重复渲染;巩固生成观察后立即刷新观察记忆缓存,消除最长 60 秒滞后 +- 记忆/知识库工具描述修正:memory_update 引用的 search_memory 改为 memory_recall;knowledge_read 引用的 kb_list/kb_search 改为 knowledge_list/knowledge_search ## 变更 - 记忆 v2 存储拆分为 5 个分片 key(memv2::meta/units/links/docs/obs):按变更分片重写,避免整库大 JSON 反复全量序列化;旧版单 key(memv2::bank)首次读取时一次性自动迁移并清空旧 key - 记忆写入收敛:retain / consolidate 全程内存累计变更、末尾统一落盘一次(原逐条整库序列化);consolidate 触发条数上限淘汰时,淘汰结果即时落盘,不再停留在内存(重载后不会复活) - -## 修复 -- 修复 .ai memo mm del 删除失效:del 经别名归一化为 delete 后未命中删除分支,实际返回帮助文本;现按归一化目标匹配,del / delete 均可删除,并支持按问题名或关键词删除(唯一命中直接删,多条列出 ID 由用户指定) -- 知识库注入只展示条目索引:system prompt 中的知识库段只列条目(ID + 标题,1500 字符预算内尽可能多列,最多 100 条),不再注入正文,详情由模型用 knowledge_read 按 ID 读取;knowledge_list 工具与 .ai kb list 索引上限 200 条,超出提示用 knowledge_search 检索 -- 修复心智模型/观察记忆 scope 严格匹配导致的注入丢失:改为任一标签命中即注入,群聊中不再因某贡献者不在最近消息而整条剔除;memory_mm_create / .ai memo mm add 的 scope_tag 非法或与目标类型不符时回退默认标签,避免静默失效 -- 观察记忆注入收敛:启用长期记忆时观察只由长期记忆段裁剪注入(上限 20 + 90 天过期剔除),不再与观察段重复渲染;观察段同样按上限裁剪;巩固生成观察后刷新观察段缓存,消除最长 60 秒滞后 -- 记忆/知识库工具描述修正:memory_update 引用的 search_memory 改为 memory_recall;knowledge_read 引用的 kb_list/kb_search 改为 knowledge_list/knowledge_search -- 群聊长期记忆按「群聊 / 每个最近发言者」分组注入:每组独立渲染长期记忆与心智模型,避免多个归属混在一起造成混淆;群心智模型 ≤5、群观察 ≤20,个人记忆每用户最多 5 条(单用户 2048 字符预算)、个人心智模型每用户最多 2 条;私有记忆仅创建者本人可见,不泄露其他成员隐私 +- 知识库注入只展示条目索引:system prompt 不再注入知识库正文,只列条目(ID + 标题,1500 字符预算内尽可能多列,最多 100 条),详情由模型用 knowledge_read 按 ID 读取;knowledge_list 工具与 .ai kb list 索引上限 200 条 +- 可用技能段只展示技能摘要:1500 字符预算内尽可能多列,超出部分用 skill_list 查看完整列表、use_skill 按需获取内容 +- 群聊记忆按「群聊 / 每个最近发言者」分组注入:每组独立渲染长期记忆与心智模型,避免多个归属混在一起造成混淆;群心智模型 ≤5、群观察 ≤20,个人记忆每用户最多 5 条(单用户 2048 字符预算)、个人心智模型每用户最多 2 条;私有记忆仅创建者本人可见 ## 新功能 -- 新增 memory_mm_list / memory_mm_view 工具:模型可通过 function calling 列出/查看心智模型;memory_mm_delete 支持按问题名删除(model_id 与 question 二选一) -- .ai memo status 显示心智模型将注入条数(按当前会话范围估算) -- 新增 skill_list 工具:列出全部技能名称与描述,供模型发现「可用技能」段未列出的技能;可用技能段摘要按 1500 字符预算截断(预算内尽可能多列),超出提示用 skill_list 查看完整列表 +- 新增 memory_mm_list / memory_mm_view 工具:模型可列出/查看心智模型;memory_mm_delete 支持按问题名删除 +- 新增 skill_list 工具:列出全部技能名称与描述 +- .ai memo status 显示心智模型将注入条数 `, "4.19.2": `## 配置变更 - 复杂配置(不适合热加载)改为「重载 JS 才生效」:纯文本/多模态/嵌入模型、评分触发配置、MCP 服务器配置、技能配置、知识库、角色扮演设定、触发/忽略正则、本地图片/语音/文件/视频路径、音乐服务配置等,现在启动时解析一次并常驻内存,修改后需在海豹面板「插件管理 → 重载 JS」(或重启海豹)才生效,避免配置与运行时解析结果不一致 - 简单配置(开关/数值/单行字符串/纯字符串数组等)仍按原样 60 秒内自动生效;MCP 服务器列表与总开关、技能、知识库等修改后不再热加载,需重载 JS