# dsh-memory-graph 改进与修复清单 > 基于 2026-08 对全部源码(约 3500 行)、测试与真实数据库(`~/.dsh/memory-graph.sqlite`)的审查。 > 所有标注「已复现」的条目均通过内存库实验验证过行为。 优先级说明: - **P0** 正确性 / 健壮性 —— 长期运行会踩坑 - **P1** 图数据质量 —— 直接影响"知识图谱准不准" - **P2** 性能 / 可扩展性 - **P3** 前端与体验 ## 2026-08-19 实施结论(v0.7.0) - 明确仪表盘为当前 profile 数据库的跨会话全局图谱,补充节点、关系、引用次数、长期记忆和轮次摘要的中文说明;`summaryLanguage` 默认跟随用户主要语言,旧英文记忆保持不变。 - 修复语义不可用且仅语义权重时的 `0/0`:配置层要求至少一个本地权重大于零,存储层同时保证分数有限。 - 新记忆的 embedding 改为去重后台队列;查询侧使用 3.5 秒独立超时、5 分钟缓存和 1 分钟故障冷却,避免 20 秒写入超时进入 Agent 热路径。 - schema v6 记录归档是否裁剪及裁剪前消息数;归档与摘要使用独立预算,并增加 30 天 TTL、每 session 200 轮上限和启动时失败归档有界重试。 - supersede/revert 清理失活 embedding,revert 后自动重建;语义候选按稳定顺序轮转扫描,避免老/低重要度记录永久失去候选资格。 - 意图信号改为有界单调 boost,避免高分候选同时被截成 1.0。新增回归覆盖权重降级、热路径、缓存/冷却、轮转扫描、embedding 生命周期、归档保留、裁剪状态和启动重试。 ## 2026-08-19 实施结论(v0.6.0) 本轮对照 OpenViking 的 DSH 示例完成以下改进: - 增加 DSH 输入框旁的单轮策略选择,`default/on/off` 在 `turn/start` 锁定并在 `turn/end` 清理;仪表盘直接显示全局 recall、summary、archive、tool result、semantic 与 outbox 状态。 - 仪表盘增加带确认的记忆删除;写接口使用同源检查、JSON POST 和进程随机 token,读取仍保持 `no-store` 与 ETag。 - schema v5 增加 `turn_transcripts` 与 `memory_embeddings`;archive 支持原文展开、失败状态和无需重放会话的重新抽取。 - 增加可选 OpenAI-compatible embedding 适配器、Float32 归一化本地向量、余弦相似度混排与存量 reindex。默认关闭,无 key/请求失败时继续纯本地召回。 - 增加 CJK-aware token 预算、偏好/时间意图、词法重叠 boost、规范化内容去重和分层 `memory_expand`。 - 增加可重试 SQLite 写 outbox,启动时有序重放;不可重试或损坏项进入 dead-letter。 - 工具结果捕获默认关闭;启用后只进入有界本地 archive 和摘要输入,不保存工具调用参数。 本轮未照搬 OpenViking server:不引入常驻服务、远端 session/peer、`viking://` 资源树或查询扩展 LLM。OpenViking 在跨用户隔离、资源摄取、服务端分层内容与真实服务 E2E 上仍更完整;本项目继续保持个人本地 SQLite + 显式知识图谱 + DSH Web 的定位。 ## 2026-08-19 实施结论(v0.5.0) 本轮采用以下改进: - #3:增加跨会话全局并发上限、有限次数重试和指数退避;保留 `summarizeEveryTurns: 1`,因为当前实现并不会把被跳过的多轮合并总结,直接改为 3~5 会造成确定性记忆缺失。 - #4:JSONL header 记录源 schema 版本;导入根据当前表结构选择列,兼容缺失但有数据库默认值的旧列,并对缺失必需列和未知列给出明确错误。 - #5:CI 继续覆盖 Node.js 22.19 与 24。 - #6/#7:不扩大强制身份归一化,避免 `C++`、人名变音符等误并;新增保守的 `memory_entity_find_duplicates`。补充 alias 冲突不再触发隐式合并,而是保留原归属并显式报告。 - #8:数据库继续按记忆保存关系来源,以保证 `memory_forget` 正确;遍历和图谱输出按 `(source, predicate, target)` 聚合,并返回 `mentions` 与最大 confidence。 - #9:增加 `predicateAliases` 配置,同时保留基础 snake_case 归一化。 - #11:schema v4 为 alias 增加独立 `preferred` 标志,人工重命名不再伪造 mentions。 - #12:增加“合并后再 supersede/revert”的回归测试;旧名字通过保留 alias 解析到现有实体,不产生重复节点。 - #14:准确 alias 命中改走索引快速路径,只在没有准确命中时执行有界子串回退。 - #15:正常关闭数据库前执行轻量 `PRAGMA optimize`;未自动执行可能长时间阻塞的 FTS optimize/VACUUM。 - #16:增加节点 limit、可见端点和确定性边界测试。 - #17:快照接口增加进程实例级 ETag;未变化返回 304,前端不更新 state,页面隐藏时暂停轮询。 - #18(部分):缩小视图时隐藏边标签,聚合边显示引用次数;kind 图例/筛选仍保留在后续 UI 工作中。 - 安全建议:导入路径解析真实目录,并拒绝指向 `backupDirectory` 外的文件符号链接。 本轮明确不采用: - #1:`better-sqlite3` 同样是同步 API,不能消除事件循环阻塞,反而增加原生 ABI 和跨平台安装成本。当前继续限定为个人本地规模,并保留 Node 双版本 CI;真正解决热路径阻塞需要异步驱动或 worker 存储边界,不能靠替换同步驱动完成。 - #2:长期持有 `flock`/排他锁会引入崩溃后的陈旧锁处理和正常多读进程冲突。当前采用 SQLite WAL、5 秒 busy timeout、事务回滚与“单可写进程”文档约束;多写进程需要独立的存储服务设计。 - #10:不强制封闭 kind 枚举,避免丢失插件使用者的领域类型。后续应先提供可配置 kind alias,再考虑 UI 图例。 - #7 的合并撤销:隐式合并已被移除,现有合并工具是明确操作;可靠撤销需要保存 source 实体、别名、记忆链接和关系的完整可恢复快照,后续应与合并审计表一起设计,不能只恢复一个已删除实体行。 - #13:merge/rename/delete 本身不会产生未引用实体;在这些路径无条件全表 GC 只会增加写事务成本。现有 remember/forget/revert 在引用变化后执行 GC,孤儿查询保留为诊断工具。 - #19 与作用域、embedding、时间线、批量删除:需要新的数据/API 设计,留待独立版本,避免扩大本轮正确性修复的回归面。 --- ## 一、问题清单 ### P0 正确性 / 健壮性 | # | 问题 | 位置 | 影响 | 建议修复 | 工作量 | |---|------|------|------|----------|--------| | 1 | **`node:sqlite` 是实验性 API,且 `DatabaseSync` 同步阻塞事件循环**。工具执行与 `agent/pre-step` 钩子都在 Agent 热路径上,库增大后每次召回都会卡住整个进程 | `src/store.ts` 全部;`src/index.ts` `memory_recall` / `agent/pre-step` | 数据量大时(数万条记忆)召回/写入阻塞 Agent 的 LLM 流;启动时有实验性警告 | 评估迁移 `better-sqlite3`(API 高度兼容,消除警告,支持同步用法不变);或至少把重查询移出热路径(如 pre-step 内改用节流/缓存) | 中 | | 2 | **无跨进程锁/多实例保护**。两个 DSH 进程共用同一库文件时,WAL 下写冲突仅靠 5s `busy_timeout`,可能直接抛错 | `src/store.ts` `configure()` (`busy_timeout = 5000`) | 罕见但崩溃式失败,无锁文件或"库已被占用"的明确错误 | 启动时用 `flock` 或 SQLite `BEGIN EXCLUSIVE` 探测并给出友好错误;或文档明确"单进程使用" | 低 | | 3 | **摘要器无重试、无跨会话并发上限**。`summarizeEveryTurns: 1` 下每轮对话都触发一次 LLM 调用;多会话并行时并发无上限;模型输出非法 JSON 时该轮永不重试,只留一条 warn | `src/summarizer.ts` `enqueue()` / `extract()`;`cordis.patch.yml` `summarizeEveryTurns` | 成本不可控;偶发失败导致记忆永久丢失且无感知 | 默认 `summarizeEveryTurns` 调到 3~5;加全局并发上限(信号量,如 2);失败轮次进入待重试队列(带退避)或用 `hasSummary` 幂等重试 | 中 | | 4 | **备份格式单一版本、导入列名硬编码**。`BACKUP_FORMAT_VERSION = 1` 一个常量;import 按当前表结构硬编码列名,未来迁移加列后旧备份会以 `null` 填 NOT NULL 列而失败 | `src/store.ts` `exportJsonl()` / `importJsonl()` | 升级后旧备份不可恢复 | 导入时按"列名存在则取值,缺失则填默认/报错",并随 schema 版本提升备份格式版本;补"旧格式→新格式"兼容测试 | 低 | | 5 | **`node:sqlite` 行为依赖 Node 版本**。`engines` 声明 `^22.19 || >=24`,实验性 API 在不同版本行为可能有差异 | `package.json` | 潜在隐性坏库 | 若保留 `node:sqlite`,CI 中增加多 Node 版本矩阵测试;或迁移 `better-sqlite3` 后消除该问题 | 低 | ### P1 图数据质量(知识图谱准确性核心) | # | 问题 | 位置 | 影响 | 建议修复 | 工作量 | |---|------|------|------|----------|--------| | 6 | **实体归一化过弱 → 近拼写重复节点(已复现)**。`normalizeEntity` 只做 NFKC + 空格折叠 + 小写;`Open AI` / `OpenAI` / `open-ai` / `open ai` 各成节点 | `src/store.ts` `normalizeEntity()` L74-76 | 同义拼写长期积累重复节点 | 增强归一化:去标点/连字符/下划线统一为空格、剥变音符、折叠重复空格;再叠加"相似度提示"而非强制合并,避免误并 | 低 | | 7 | **共享别名触发"静默"破坏性合并(已复现)**。`ensureEntity` 命中任一拼写即 `mergeEntityIds`,源实体行被直接删除;`entityConflicts` 不报告"发生了合并",且无撤销途径 | `src/store.ts` `ensureEntity()` L583-597、`mergeEntityIds()` L1097-1141 | 模型一旦写出常见缩写别名(如 `AI`),两个不同概念永久粘合,不可恢复 | ① 合并写审计表 `entity_merges(ts, target_id, source_id, reason)`;② 工具与自动合并都返回 `merged` 事件;③ 提供 `memory_entity_merge --undo`(保留 source 实体行软删除一周兜底) | 中 | | 8 | **同一逻辑边跨记忆重复计数(已复现)**。`relations.UNIQUE(source_id, predicate, target_id, source_memory_id)` 允许同 `(S,P,T)` 多行;`graph()` 按行输出 → 前端画两条重叠边、计数虚高 | `src/store.ts` createBaseSchema 的 relations 定义 L258;`walk()` / `graph()` | 图上边数虚高、视觉重叠;统计失真 | ① 表层面收紧:同 `(S,P,T)` 若已存在则复用并累计 confidence/mentions;② 至少在图输出端按 `(S,P,T)` 去重聚合 | 低 | | 9 | **谓词归一化映射表只有 5 个硬编码别名**。`developed_by` / `created_by` / `maintained_by` 等同义谓词各自成类型,图谱谓词空间失控 | `src/store.ts` `PREDICATE_ALIASES` L78-84 | 谓词膨胀,图语义不一致 | 将该表改为配置项(`predicateAliases`),或引入"谓词词表 + 建议合并"机制;文档给出维护方式 | 低 | | 10 | **kind 是自由文本 + 默认 `concept`**。仅出现在 relation 端点的实体无 kind 时写入 `'concept'`;`user`/`person`/`topic` 等语义相同拼写不同的 kind 各占一色 | `src/store.ts` L595(`?? 'concept'`)、`entity_kind_votes`;前端 `color(kind)` | 图谱上堆灰节点;同类节点颜色不一致 | ① kind 收敛到建议枚举(配置列表),超出归入 `other`;② 前端提供 kind→颜色/图例映射表;③ 关系端点实体默认 kind 改为 `argument` 或复用配置默认值 | 低 | | 11 | **`renameEntity` 用 `max(mentions)+1` 永久压制后续投票**。改名后旧拼写再多次出现也无法赢回显示名;merge 后 votes 强制叠加,维护操作幂等性差 | `src/store.ts` `renameEntity()` L906-911 | 显示名维护结果不可预期 | 改名不设 `max+1`,改为单独 `preferred` 标志位或权重字段;合并时 votes 按比例折算而非直接相加 | 低 | | 12 | **`graph_json` 陈旧 → `revert` 可能重建已合并实体**。记忆行快照存的是写入时名字;实体改名/合并后 json 不更新;`revert` 从旧 json 重新 attach 时可能按陈旧名重新创建实体 | `src/store.ts` L976-978(`revert()` 解析 graph_json) | 回滚产生幽灵实体 | ① attach 前对 graph_json 中的名字做别名解析,解析不到则跳过;② 提供 `graph_json` 定期重建工具(按当前实体 ID 重写) | 低 | | 13 | **孤儿回收只在部分路径触发**。`collectOrphanEntities` 仅在 `remember/forget/revert` 内调用;`mergeEntities` / `renameEntity` / `deleteEntity` 后不主动清理 | `src/store.ts`(`collectOrphanEntities()` 调用点) | `memory_entity_orphans` 短暂报出残留;"自动 GC 保持列表为空"的文档承诺不成立 | 在 merge/rename/delete 后统一调用;或改为按需惰性清理 | 低 | ### P2 性能 / 可扩展性 | # | 问题 | 位置 | 影响 | 建议修复 | 工作量 | |---|------|------|------|----------|--------| | 14 | **别名搜索用 `instr()` 子串扫描,无索引**。`graph()` 与 `recall()` 的种子查询对 alias 表全扫 | `src/store.ts` L698-704、L780-783 | 别名表变大后每次查询退化 O(N) | 限制扫描行数 + 单列扫描覆盖;或为 alias 建补充 FTS 索引 | 中 | | 15 | **无定期维护任务**(FTS optimize / VACUUM / 统计刷新)。长期增删改后 FTS 索引碎片化、文件膨胀 | `src/store.ts` 无相关逻辑 | 召回变慢、库文件膨胀 | 启动或空闲时执行 `INSERT INTO memory_fts(memory_fts) VALUES('optimize')` 与 `PRAGMA optimize`,可配置开关 | 低 | | 16 | **游标/边界行为**:`graph()` 在 limit 边界处可能漏边或多边(relations 按 `limit*4` 收集后按可见节点过滤),大图下结果不稳定 | `src/store.ts` `walk()` L1206-1210 | 大图下节点/边比例失真 | 明确"以节点数为准,边只保留两端可见"并加测试固化边界语义 | 低 | ### P3 前端与体验 | # | 问题 | 位置 | 影响 | 建议修复 | 工作量 | |---|------|------|------|----------|--------| | 17 | **侧边栏仪表盘 5s 无条件轮询**,无变化不跳过、无暂停;后台标签页也持续请求 | `src/client/index.tsx` `MemoryDashboard` L252-256 | 无谓资源消耗 | 客户端 ETag/长度对比,变化才 setState;`document.hidden` 时暂停轮询 | 低 | | 18 | **边标签在稠密图上互相重叠**;无 kind 图例/筛选;无"跳转到来源会话"入口(数据里已有 `sourceSession/sourceTurn` 但未展示) | `src/client/index.tsx` `GraphCard` | 大图难以阅读 | 标签开启随缩放显示;节点旁注高度降为 kind 缩写;补充 kind 筛选与图例 | 低 | | 19 | **选中节点详情无分页/加载更多**,`related` 仅取图谱内带过来的 memories | `src/client/index.tsx` L228-230 | 超过 limit 的记忆看不到 | 增加"加载全部该实体记忆"按钮(调 `memory_graph` 或新增详情端点) | 低 | --- ## 二、功能建议(按收益排序) | 功能 | 说明 | 优先级建议 | 工作量 | |------|------|-----------|--------| | **重复实体发现工具** `memory_entity_find_duplicates` | 编辑距离/别名重叠/Jaccard 打分,预览相似对 + 一键 `merge` | 高(直接缓解 #6) | 中 | | **合并审计与回滚** | `entity_merges` 审计表 + `memory_entity_history` / `--undo`(承接 #7) | 高 | 中 | | **统计工具 + 仪表盘统计** `memory_stats` | 实体/边/记忆计数、密度、top 实体、孤儿、合并史 | 中 | 低 | | **作用域/多 profile 隔离** | 按 assistant profile 命名空间隔离记忆,recall/图按 scope 过滤 | 中 | 中 | | **语义召回(embeddings,可选模块)** | 向量列 + embedding,补同义表述召回;配置开关,默认关闭 | 中 | 高 | | **时间线视图 + 关系时间属性** | relations 加 `valid_from/valid_to`,图上按时间过滤;前端 timeline 面板 | 中 | 中 | | **保留策略** | 低重要性 + 超龄记忆自动降级/归档(先备份再删),阈值可配 | 中 | 低 | | **合并式导入** | `memory_backup import --merge` 允许非空库增量恢复(承接 #4 的格式演进) | 低 | 中 | | **批量操作** | `memory_forget_all` / 按 kind/tag 批量操作 | 低 | 低 | --- ## 三、测试与文档建议 1. **补充针对本次发现的单测**: - `normalizeEntity` 边界(`open-ai` / `open_ai` / 变音符 / 全角空格); - 共享别名合并返回"合并事件"而非只有投票冲突; - 同 `(S,P,T)` 多记忆 → `graph()` 输出单条边; - 旧备份格式导入(构造低版本 JSONL); - `revert` 在实体已被改名/合并后的行为。 2. **README 数据口径修正**:"自动 GC 保持 orphan 列表为空"仅对 remember/forget/revert 路径成立;建议改为"孤儿通常在写入路径被清理,可通过 `memory_entity_orphans` 手动检查"。 3. **安全**:`backupPath` 正则挡住了 `..`、`/`,但 `backupDirectory` 若为符号链接目录可逃逸;建议解析 `realpath` 后校验前缀,并补测试。 --- ## 四、建议实施路线图 **第一阶段(低风险,直接改善图谱质量)** —— 编号 #6、#8、#13、#17 > 实体归一化增强 + 图输出边去重 + GC 路径统一 + 前端轮询优化。每项配单测,一周内可完成。 **第二阶段(可观测性与防误操作)** —— 编号 #7、#9、#10、#11、#12 > 合并审计/回滚、谓词同义词与 kind 词表配置化、改名/投票机制修正、`graph_json` 失效防御。 **第三阶段(健壮性与扩展)** —— 编号 #1/#5、#3、#4、#14、#15 > 存储后端评估(`better-sqlite3`)、摘要并发/重试、备份格式版本化、索引与维护任务。 **第四阶段(功能新能力)** —— 功能清单中的高/中优先级项 > 重复实体发现工具、统计工具、作用域隔离、语义召回(可选)、时间线。 --- ## 附录:已复现实验记录 在内存库(`:memory:`)上的验证输出: ``` 场景1 近拼写实体(Open AI / OpenAI / open-ai / open ai) → 3 个独立节点 场景2 共享别名 AI 的 Apple 与 Artificial Intelligence → 静默合并为 1 节点, entityConflicts 仅报告 name/kind 投票冲突,无合并事件 场景3 两个记忆声明同一条 User -uses-> Python → graph() edges 输出 2 条 ``` (复现脚本基于 `lib/store.js` 编写,可在本地重复执行验证修复是否生效。)