@modusensus/dsh-mneme 0.7.5 → 0.7.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,58 @@
1
+ name: Publish to npm
2
+
3
+ # 发布路径:registry.npmjs.org 在本机云服务器被出口 SNI 过滤 + 17897 隧道不稳定,
4
+ # 改由 GitHub runner 发布(不依赖本机出网)。打 v* tag 自动触发,或 workflow_dispatch 手动。
5
+ #
6
+ # ⚠️ 版本号机制(血的教训):
7
+ # root package.json 在 git 里永远停在 0.7.2,真实版本号在 dsh-mneme/package.json。
8
+ # npm 在跑 prepublishOnly 之前就已加载 manifest,脚本里改 root 版本不生效,
9
+ # 会把 root 的旧版本(0.7.2)发出去。必须在 npm publish 之前显式同步 root 版本。
10
+
11
+ on:
12
+ push:
13
+ tags:
14
+ - 'v*'
15
+ workflow_dispatch:
16
+
17
+ permissions:
18
+ contents: read
19
+
20
+ jobs:
21
+ publish:
22
+ runs-on: ubuntu-latest
23
+ steps:
24
+ - uses: actions/checkout@v4
25
+
26
+ - uses: actions/setup-node@v4
27
+ with:
28
+ node-version: 24
29
+ registry-url: https://registry.npmjs.org/
30
+
31
+ - name: Registry current state (diagnostic)
32
+ run: |
33
+ npm view @modusensus/dsh-mneme versions --json || true
34
+ npm view @modusensus/dsh-mneme time --json || true
35
+
36
+ # 关键:先同步 root 版本 → 再 publish(prepublishOnly 改版本不可靠)
37
+ - name: Sync root version from dsh-mneme
38
+ run: |
39
+ V="$(node -p "require('./dsh-mneme/package.json').version")"
40
+ echo "Syncing root package.json to v${V}"
41
+ node -e "const fs=require('fs');const v=process.argv[1];const p=JSON.parse(fs.readFileSync('./package.json','utf8'));p.version=v;fs.writeFileSync('./package.json',JSON.stringify(p,null,2)+'\n')" "$V"
42
+ node -p "console.log('root now:', require('./package.json').version)"
43
+
44
+ - name: Publish @modusensus/dsh-mneme (skip if exists)
45
+ run: |
46
+ V="$(node -p "require('./dsh-mneme/package.json').version")"
47
+ if npm view "@modusensus/dsh-mneme@${V}" version >/dev/null 2>&1; then
48
+ echo "v${V} already published on registry — skipping"
49
+ else
50
+ npm publish
51
+ fi
52
+ env:
53
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
54
+
55
+ - name: Verify published version
56
+ run: |
57
+ V="$(node -p "require('./dsh-mneme/package.json').version")"
58
+ echo "Published: $(npm view "@modusensus/dsh-mneme@${V}" version)"
package/CHANGELOG.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # Changelog
2
2
 
3
+ > **完整版本历史见 [dsh-mneme/CHANGELOG.md](dsh-mneme/CHANGELOG.md)**(正式版本正源,0.3.8 → 0.7.7)。以下为仓库早期开发记录(0.1.0–0.2.x,2026-08-13~14,已归档)。
4
+
3
5
  All notable changes to dsh-mneme are documented here.
4
6
 
5
7
  ## [Unreleased]
package/README.md CHANGED
@@ -5,14 +5,14 @@
5
5
  <h1 align="center">dsh-mneme</h1>
6
6
 
7
7
  <p align="center">
8
- <a href="https://www.npmjs.com/package/@modusensus/dsh-mneme"><img src="https://img.shields.io/npm/v/@modusensus/dsh-mneme?color=blue&label=npm" alt="npm version"></a>
9
- <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="license"></a>
8
+ <a href="https://www.npmjs.com/package/@modusensus/dsh-mneme"><img src="https://img.shields.io/npm/v/@modusensus/dsh-mneme?style=flat-square&color=3E63DD&label=npm" alt="npm version"></a>
9
+ <a href="https://www.npmjs.com/package/@modusensus/dsh-mneme"><img src="https://img.shields.io/npm/dm/@modusensus/dsh-mneme?style=flat-square&color=3E63DD&label=downloads" alt="npm downloads"></a>
10
+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-3E63DD?style=flat-square" alt="license"></a>
11
+ <a href="https://github.com/modusensus/dsh-mneme/actions"><img src="https://img.shields.io/github/actions/workflow/status/modusensus/dsh-mneme/test.yml?style=flat-square&label=CI" alt="CI"></a>
12
+ <a href="https://nodejs.org"><img src="https://img.shields.io/badge/node-24%2B-3E63DD?style=flat-square&logo=nodedotjs&logoColor=white" alt="node"></a>
13
+ <a href="https://github.com/modusensus/dsh-mneme"><img src="https://img.shields.io/badge/tests-810%20passed-3E63DD?style=flat-square" alt="tests"></a>
14
+ <a href="https://codecov.io/gh/modusensus/dsh-mneme"><img src="https://img.shields.io/codecov/c/github/modusensus/dsh-mneme/main?style=flat-square" alt="coverage"></a>
10
15
  <a href="https://github.com/awesome-dsh-plugin/awesome-dsh-plugin"><img src="https://awesome-dsh-plugin.com/badge.svg" alt="Awesome"></a>
11
- <a href="https://github.com/modusensus/dsh-mneme/actions"><img src="https://img.shields.io/github/actions/workflow/status/modusensus/dsh-mneme/test.yml" alt="CI"></a>
12
- <a href="https://nodejs.org"><img src="https://img.shields.io/badge/node-24%2B-blue" alt="node"></a>
13
- <a href="https://github.com/modusensus/dsh-mneme"><img src="https://img.shields.io/badge/tests-782%20passed-success" alt="tests"></a>
14
- <a href="https://www.npmjs.com/package/@modusensus/dsh-mneme"><img src="https://img.shields.io/npm/dm/@modusensus/dsh-mneme?color=blue&label=downloads" alt="npm downloads"></a>
15
- <a href="https://codecov.io/gh/modusensus/dsh-mneme"><img src="https://img.shields.io/codecov/c/github/modusensus/dsh-mneme/main" alt="coverage"></a>
16
16
  </p>
17
17
 
18
18
  <p align="center"><strong><a href="#中文">中文</a> | <a href="#english">English</a></strong></p>
@@ -87,7 +87,7 @@ dsh web
87
87
  | 语义架构 | [dsh-mneme/docs/SEMANTIC.md](dsh-mneme/docs/SEMANTIC.md) |
88
88
  | 本地模型部署指南 | [dsh-mneme/docs/LOCAL_MODEL.md](dsh-mneme/docs/LOCAL_MODEL.md) |
89
89
  | v0.1 迁移说明 | [dsh-mneme/docs/MIGRATION.md](dsh-mneme/docs/MIGRATION.md) |
90
- | 版本历史 | [CHANGELOG.md](CHANGELOG.md) |
90
+ | 版本历史 | [dsh-mneme/CHANGELOG.md](dsh-mneme/CHANGELOG.md) |
91
91
  | 安全策略 | [SECURITY.md](SECURITY.md) |
92
92
 
93
93
  ## 🗺️ 路线图
@@ -107,14 +107,17 @@ dsh web
107
107
  | **v0.7.2** | issue #35 删除按钮内联确认修复 + issue #34 对话开始注入当前时间(opt-in) | ✅ |
108
108
  | **v0.7.3** | issue #38 左下角入口按钮可选开关 `showSidebarTrigger` | ✅ |
109
109
  | **v0.7.4** | issue #40 记忆花括号转义 + issue #41 记忆窗口关闭按钮重叠修复 | ✅ |
110
- | **v0.8.0** | 图谱增强:兴趣漂移 + workspace 共享 | 🚧 计划中(9 月末) |
110
+ | **v0.7.5** | 分层记忆类型 user/fact + Web 总览视图 + stats 端点 | |
111
+ | **v0.7.6** | issue #48 修复:截断/前缀 id 也能精确操作(统一 resolveMemoryId)+ client.js 改 src 正源 | ✅ |
112
+ | **v0.7.7** | issue #23 图谱回填:sleep 批量实体抽取 phase + node:sqlite 兼容修复 | ✅ |
113
+ | **v0.8.0** | 图谱增强:兴趣漂移可视化 + scope 隔离(issue #17)+ 跨 workspace 共享 | 🚧 计划中(9 月末) |
111
114
 
112
115
  ## 🧪 本地开发
113
116
 
114
117
  ```bash
115
118
  cd dsh-mneme
116
119
  npm install
117
- npm test # 782 个测试
120
+ npm test # 810 个测试
118
121
  npm run stress # 三轴线压测
119
122
  npm run sync # src → lib 同步
120
123
  ```
@@ -192,7 +195,7 @@ Works out of the box. Enable these as needed:
192
195
  | Semantic architecture | [dsh-mneme/docs/SEMANTIC.md](dsh-mneme/docs/SEMANTIC.md) |
193
196
  | Local model guide | [dsh-mneme/docs/LOCAL_MODEL.md](dsh-mneme/docs/LOCAL_MODEL.md) |
194
197
  | v0.1 migration | [dsh-mneme/docs/MIGRATION.md](dsh-mneme/docs/MIGRATION.md) |
195
- | Changelog | [CHANGELOG.md](CHANGELOG.md) |
198
+ | Changelog | [dsh-mneme/CHANGELOG.md](dsh-mneme/CHANGELOG.md) |
196
199
  | Security | [SECURITY.md](SECURITY.md) |
197
200
 
198
201
  ## 🗺️ Roadmap
@@ -212,14 +215,16 @@ Works out of the box. Enable these as needed:
212
215
  | **v0.7.2** | Issue #35 delete button inline-confirm fix + Issue #34 inject current time at conversation start (opt-in) | ✅ |
213
216
  | **v0.7.3** | Issue #38 optional sidebar trigger `showSidebarTrigger` | ✅ |
214
217
  | **v0.7.4** | Issue #40 prompt-brace escaping + Issue #41 overlay close-button overlap fix | ✅ |
215
- | **v0.8.0** | Graph enhancement: interest drift + cross-workspace sharing | 🚧 Planned (late Sep) |
218
+ | **v0.7.5** | Layered memory types (user/fact) + Overview view + stats endpoint | |
219
+ | **v0.7.6** | Issue #48 fix: truncated/prefix ids resolve for exact ops (unified resolveMemoryId) + client.js now src-authored | ✅ |
220
+ | **v0.8.0** | Graph enhancement: interest-drift visualization + scope isolation (issue #17) + cross-workspace sharing | 🚧 Planned (late Sep) |
216
221
 
217
222
  ## 🧪 Local Development
218
223
 
219
224
  ```bash
220
225
  cd dsh-mneme
221
226
  npm install
222
- npm test # 782 tests
227
+ npm test # 810 tests
223
228
  npm run stress # three-axis stress test
224
229
  npm run sync # src → lib sync
225
230
  ```
package/SECURITY.md CHANGED
@@ -56,7 +56,7 @@ The following security features are implemented and maintained in the project:
56
56
 
57
57
  ### Please DO
58
58
 
59
- 1. **Email** `guanqishi26@gmail.com` (or open a **private security advisory** via [GitHub Security Advisories](https://github.com/modusensus/dsh-mneme/security/advisories))
59
+ 1. **Email** `work@modusensus.space` (or open a **private security advisory** via [GitHub Security Advisories](https://github.com/modusensus/dsh-mneme/security/advisories))
60
60
  2. Include:
61
61
  - A clear description of the vulnerability
62
62
  - Steps to reproduce (minimal test case preferred)
@@ -392,7 +392,7 @@ This project is licensed under the **MIT License**. See [LICENSE](https://github
392
392
 
393
393
  ### 请这样做
394
394
 
395
- 1. **发送邮件**至 `guanqishi26@gmail.com`(或通过 [GitHub 私有安全公告](https://github.com/modusensus/dsh-mneme/security/advisories) 提交)
395
+ 1. **发送邮件**至 `work@modusensus.space`(或通过 [GitHub 私有安全公告](https://github.com/modusensus/dsh-mneme/security/advisories) 提交)
396
396
  2. 邮件内容请包含:
397
397
  - 漏洞的清晰描述
398
398
  - 复现步骤(优先提供最小测试用例)
@@ -671,4 +671,4 @@ dsh-mneme:
671
671
  ---
672
672
 
673
673
  *Last updated: 2026-08-28*
674
- *Policy version: 2.2*
674
+ *Policy version: 2.2*
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.7.7] - 2026-09-05
4
+
5
+ ### 🆕 issue #23:sleep 批量实体抽取回填实体图谱
6
+
7
+ - **背景**:写路径抽取(index.js)只有 `entityExtractionEnabled` 开启才触发——每次写入一次 LLM 调用,是刻意的成本取舍——所以默认安装下记忆从不累积实体,这正是 issue #23 报的"实体图谱面板一片空白"(用户明明有很多记忆)。
8
+ - **新增 sleep 批量抽取 phase**:默认关 `sleepEntityExtractionEnabled`,开睡循环时按**最老优先**把没有实体属性的记忆逐条过 `extractEntities`(每轮上限 `sleepEntityExtractionMaxPerRun` 默认 20)。
9
+ - **下沉为有界 SQL 查询**(kimi 复验意见 #2):新增 `store.listForEntityExtraction({limit, offset})`,`WHERE` 只留行级条件(未归档/未遗忘/非 summary/内容非空/`metadata.entity_extracted_at` 为空),`ORDER BY created_at LIMIT ? OFFSET ?` 分页取最老候选;实体是否已存在是跨表检查(`getAttrsByMemory`),由 JS 在每页上过滤。不再 `service.all()` 全表加载。
10
+ - **幂等防重**(kimi 意见 #3):抽取前先 stamp `metadata.pending_extracted_at`,成功后替换为 `entity_extracted_at`,失败清除 pending——一条记忆不会被并发/崩溃重复抽取,失败的下轮重试。
11
+ - **metadata 合并不覆盖**(kimi 意见 #4):`store.setMemoryMetadata` 改为 merge 语义,打实体时间戳不会抹掉其他路径刚写入的 metadata 字段。
12
+ - **失败不阻断**(kimi 意见 #5):单条抽取失败只跳过该条、记入 `failed` 计数,整轮状态由 `deriveStatus` 正确折叠 `failed`——一次 LLM 抖动不会 abort 整个 sleep 周期。
13
+ - **node:sqlite 兼容修复**(复验时抓到的实现 bug):wxbot 初版用 better-sqlite3 的 `.pluck()`(node:sqlite 不存在),改为 `.all().map(row => getById(row.id))`;`listForEntityExtraction` 的 `forgotten IS NULL` 条件与 `save` 硬编码写入的 `forgotten=0` 永不匹配导致查询恒空,修正为 `(forgotten = 0 OR forgotten IS NULL)`(与 archived 同构)。
14
+ - 新增 9 个用例(禁用/跳过、stamp 不重复、无实体也 stamp、backfill、失败重试、metadata merge #4、pending 清除 #3、failed 状态折叠 #5、分页最老优先 #2),全套 **810 通过**。
15
+
16
+ ## [0.7.6] - 2026-09-02
17
+
18
+ ### 🐛 issue #48:截断的短 id 也能精确操作
19
+
20
+ - **现象**:`memory_update` / `memory_delete` / `memory_forget` / `memory_archive` 此前对 id 做**严格精确匹配**(`WHERE id = ?`,无任何前缀/模糊逻辑)。若宿主上下文压缩或手抄把 36 位 UUID 截断,`memory_delete` 会静默返回 `{deleted:false}`(无 warn、无日志),其余三个工具抛错——对用户都表现为"操作不进也不出"。
21
+ - **新增 `service.resolveMemoryId`**:精确命中优先,否则把传入值当 id 前缀解析(主键前缀走索引,无性能问题);命中多条 → 拒绝操作并列出候选完整 id,**绝不瞎猜**。
22
+ - **四工具统一接入**:`memory_update` / `memory_forget` / `memory_archive` 未命中抛错并提示"请传完整 id(`memory_list` / `memory_search` 输出均为完整 id)";`memory_delete` 保持幂等返回 `{deleted:false}`,未命中补 `logger.warn` 留痕,可被服务日志定位。
23
+ - 完整 id 直连不受影响(精确命中优先,不会因"恰好是另一条的前缀"被误判歧义)。
24
+ - 复验加固:纯通配符 id(`%`/`_`)剥空后按无匹配处理,杜绝退化成 `LIKE '%'` 全表误删单条记忆;id 首尾空白自动 trim。
25
+ - 补 11 个用例(前缀删除/更新/归档/遗忘、歧义拒绝、未命中兜底、精确优先、通配符兜底、空白 trim、warnMiss 日志),全套 **801 通过**。
26
+
27
+ ### 🧹 工程:client.js 改为 src 正源
28
+
29
+ - Web 面板 bundle `client.js` 历史上是 lib/ 下**手写、无 src 副本**的唯一例外(重蹈过 v0.6.7 源码漏提交);现补 `src/client.js` 为唯一正源,`npm run sync` 统一同步出 `lib/client.js`,guard 测试改为断言两文件字节一致,杜绝 drift。
30
+
3
31
  ## [0.7.5] - 2026-09-02
4
32
 
5
33
  ### 🆕 新功能:分层记忆类型 user/fact
@@ -5,7 +5,7 @@
5
5
  [![npm version](https://img.shields.io/npm/v/@modusensus/dsh-mneme?color=blue&label=npm)](https://www.npmjs.com/package/@modusensus/dsh-mneme)
6
6
  [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE)
7
7
  [![Awesome](https://awesome-dsh-plugin.com/badge.svg)](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
8
- [![tests](https://img.shields.io/badge/tests-782%20passed-success)](https://github.com/modusensus/dsh-mneme)
8
+ [![tests](https://img.shields.io/badge/tests-810%20passed-success)](https://github.com/modusensus/dsh-mneme)
9
9
  [![CI](https://img.shields.io/github/actions/workflow/status/modusensus/dsh-mneme/test.yml)](https://github.com/modusensus/dsh-mneme/actions)
10
10
  [![node](https://img.shields.io/badge/node-24%2B-blue)](https://nodejs.org)
11
11
  [![npm downloads](https://img.shields.io/npm/dm/@modusensus/dsh-mneme?color=blue&label=downloads)](https://www.npmjs.com/package/@modusensus/dsh-mneme)
@@ -241,6 +241,8 @@ v0.3.0 起新增**记忆基因**层:从记忆里抽取**命名实体**、**带
241
241
 
242
242
  | 版本 | 亮点 |
243
243
  |------|------|
244
+ | **v0.7.7** | issue #23 实体图谱回填:sleep 批量实体抽取 phase(`sleepEntityExtractionEnabled` 默认关;最老优先、SQL LIMIT/OFFSET 分页下沉为有界查询不整表扫描;`pending_extracted_at` 幂等防重、成功/失败清除;metadata 合并不覆盖其他路径写入);kimi-k2.7-code 复验 + node:sqlite 兼容修复(pluck→all+map、`forgotten=0` 查询条件);810 测试全绿 |
245
+ | **v0.7.6** | issue #48 修复:`memory_update`/`memory_delete`/`memory_forget`/`memory_archive` 支持截断/前缀短 id(新增 `service.resolveMemoryId`:精确命中优先 + 唯一前缀解析 + 多命中拒绝列出候选 + `memory_delete` 未命中幂等补 `logger.warn`;纯通配符/空白兜底);Web bundle `client.js` 改 src 正源;801 测试全绿 |
244
246
  | **v0.7.5** | 分层记忆类型:新增 `user`(用户画像)/`fact`(原子事实)轻量记忆类型(单表 `type` 扩展,不动 schema)+ Web 面板「总览」视图(记忆分层卡片 + 用户画像卡 + 类型分布 + 近 7 天趋势)+ `/api/dsh-mneme/stats` 统计端点;kimi-k2.7-code 复验(days 整数化等);790 测试全绿 |
245
247
  | **v0.7.4** | issue #40 修复:记忆内容含 `{{...}}` 模板语法时整轮崩溃(注入边界 run-based 花括号转义 `{{a}}`→`{\{a\}\}`,奇数连续如 `{{{a}}}` 也不残留字面 `{{`;新增 `escapePromptVariables` 配置默认开);issue #41 修复:记忆窗口关闭按钮与宿主窗口控制按钮重叠无法点击(顶栏左对齐,关闭按钮离开右上角宿主控制区);782 测试全绿 |
246
248
  | **v0.7.3** | issue #38 新功能:左下角入口按钮可选开关 `showSidebarTrigger`(默认开)——与 dsh-cost-meter 等抢占 footer slot 的插件冲突时可在 Web 面板「设置」一键关闭,仅隐藏按钮、记忆库标签不受影响;776 测试全绿 |
@@ -295,7 +297,9 @@ v0.3.0 起新增**记忆基因**层:从记忆里抽取**命名实体**、**带
295
297
  | **v0.7.3** | ✅ 完成 | issue #38 新功能 | 左下角入口按钮可选开关 `showSidebarTrigger`(默认开,settings-over-config);Web 面板设置一键关闭,与 dsh-cost-meter 等 footer 插件冲突可隐藏按钮、记忆库标签不受影响;776 测试全绿 |
296
298
  | **v0.7.4** | ✅ 完成 | issue #40 + #41 修复 | 注入边界 run-based 花括号转义(`{{a}}`→`{\{a\}\}`、奇数连续如 `{{{a}}}` 不残留字面,`escapePromptVariables` 默认开)+ 记忆窗口顶栏左对齐、关闭按钮避开宿主窗口控制按钮区;782 测试全绿 |
297
299
  | **v0.7.5** | ✅ 完成 | 分层记忆类型 + 总览视图 | 借鉴 meow-memory 分层概念、贴合单表架构:新增 `user`(用户画像)/`fact`(原子事实)类型,注入/镜像/梦境/质量过滤全链路打通;Web 面板「总览」视图(分层卡片 + 用户画像卡 + 类型分布 + 近 7 天趋势);`/api/dsh-mneme/stats` 端点;kimi-k2.7-code 复验;790 测试全绿 |
298
- | **v0.8.0** | 🚧 计划中(9 月末) | 图谱增强 | 兴趣漂移可视化 + workspace 记忆共享 + 更多 heat 信号 |
300
+ | **v0.7.7** | 完成 | issue #23 图谱回填 | sleep 批量实体抽取 phase:默认关 `sleepEntityExtractionEnabled`,按最老优先、SQL LIMIT/OFFSET 分页(下沉为有界查询,不再整表扫描)批量抽取未打标记忆的实体(修复 issue #23 实体图谱空白);`pending_extracted_at` 幂等防重、成功/失败清除,metadata 合并不覆盖;kimi-k2.7-code 复验 + node:sqlite 兼容修复(pluck→all+map、`forgotten=0` 查询条件);810 测试全绿 |
301
+ | **v0.7.6** | ✅ 完成 | issue #48 修复 | 四工具统一 `service.resolveMemoryId`:截断/前缀短 id 也能精确操作(精确命中优先、唯一前缀解析、多命中拒绝并列出候选、`memory_delete` 未命中幂等返回 + `logger.warn` 留痕);Web bundle `client.js` 改 src 正源;801 测试全绿 |
302
+ | **v0.8.0** | 🚧 计划中(9 月末) | 图谱增强 | 兴趣漂移可视化 + scope 隔离(issue #17)+ 跨 workspace 记忆共享 + 更多 heat 信号 |
299
303
 
300
304
  > 新能力一律做成**可开关的功能**(配置启用/关闭),默认保守开启、不破坏现有行为。`failure_memories` 表与 autoDream 决策引擎已为后续反思性成长铺好路。
301
305
 
@@ -457,11 +461,10 @@ src/
457
461
  ├── hot-memory.js # 会话级短期热记忆(v0.5.0:滚动轮次 + token 预算)
458
462
  ├── embedding.js # OpenAI 兼容 embeddings 客户端 + 向量检索
459
463
  ├── api.js # HTTP 路由(Web 面板数据通道)
464
+ ├── client.js # Web 面板 bundle(ModuleLoader 自注册;v0.7.6 起 src 正源)
460
465
  └── index.js # 插件接线
461
- lib/
462
- ├── client.js # Web 面板(手写 ModuleLoader bundle)
463
- └── *.js # src 的同步分发产物
464
- test/ # 757 个 node:test 测试(含审计与三轴线压测不变量)
466
+ lib/ # src 的同步分发产物(npm run sync,prepack 自动执行;无手写例外)
467
+ test/ # 810 node:test 测试(含审计与三轴线压测不变量)
465
468
  scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压测 · sync-lib.js 同步 · benchmark-recall.js 召回基准
466
469
  ```
467
470
 
@@ -470,14 +473,14 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压
470
473
  ```bash
471
474
  cd dsh-mneme
472
475
  npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试)
473
- npm test # 运行 782 个测试
476
+ npm test # 运行 810 个测试
474
477
  npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM)
475
478
  npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行)
476
479
  ```
477
480
 
478
481
  > 压测(`npm run stress`)三条轴线:**长会话检索**(Recall@k、陈旧残留率)、**冲突裁决**(可重放仲裁集:审计快照 hash + receipt + 幂等回放)、**多 Agent 并发**(丢更新、重复合并、事务/崩溃恢复)。每次 autoDream 运行都会写入审计表 `dream_runs`(输入快照 digest + 决策清单 + 逐 id 去向 + receipt),让高通过率下也能定位静默错误。
479
482
 
480
- > `lib/` 是 `src/` 的同步分发产物(`npm run sync`),其中 `lib/client.js` 为手写 Web 面板源码,不受同步影响。
483
+ > `lib/` 是 `src/` 的同步分发产物(`npm run sync`):v0.7.6 起全部由 src 复制而来,**不再有任何手写 lib 文件**——Web 面板 bundle 也以 `src/client.js` 为唯一正源。
481
484
 
482
485
  ## 📄 设计文档
483
486
 
@@ -274,6 +274,15 @@ export const Config = z.object({
274
274
  z.const("high"),
275
275
  z.const("none")
276
276
  ]).default("none"),
277
+ // Batch entity extraction during sleep (issue #23). The write-path extractor
278
+ // only fires when entityExtractionEnabled is on (an LLM call per write);
279
+ // this additive phase backfills entities/attrs/relations for memories that
280
+ // never went through it, so stores that leave the write-path extractor off
281
+ // still accumulate an ego graph as long as sleep runs. On by default (it is
282
+ // a no-op until a sleep cycle fires), capped per run to bound token spend.
283
+ sleepEntityExtractionEnabled: z.boolean().default(true),
284
+ // Max memories entity-extracted per sleep run (oldest un-extracted first).
285
+ sleepEntityExtractionMaxPerRun: z.natural().min(1).max(100).default(20),
277
286
 
278
287
  // --- epistemic trust: memory source credibility (v0.4.5) -----------------
279
288
  // Distinguish memories by source: observation (measured / witnessed),
@@ -19,6 +19,7 @@ import { validateDecisions, applyDecisions } from "./decisions.js";
19
19
  import { findPotentialConflicts } from "./clustering.js";
20
20
  import { buildReceipt } from "../dream.js";
21
21
  import { computeHeat } from "../heat.js";
22
+ import { extractEntities } from "../entities/extractor.js";
22
23
 
23
24
  const SUMMARY_MAX = 120;
24
25
  // Conflict similarity threshold per strictness level (v0.4.0):
@@ -396,6 +397,123 @@ function phaseRelations(service, config, logger, runId, signal = null) {
396
397
  };
397
398
  }
398
399
 
400
+ /**
401
+ * Phase 4.5 — batch entity extraction. The write-path extractor (index.js)
402
+ * only fires when entityExtractionEnabled is on — one LLM call per write, a
403
+ * deliberate cost decision — so default installs never accumulate entities,
404
+ * which is exactly why the ego-graph panel (issue #23) renders blank for users
405
+ * with plenty of memories. This phase backfills the entity graph in bulk:
406
+ * memories that carry no entity attrs yet are passed through extractEntities
407
+ * one at a time (oldest first, capped per run). Write-path stays untouched —
408
+ * this is an additive channel for sleep users. Fail-safe per memory: one bad
409
+ * extract never aborts the phase or the cycle.
410
+ */
411
+ async function phaseEntityExtraction(ctx, service, config, logger, runId, signal = null) {
412
+ if (config.sleepEntityExtractionEnabled !== true) {
413
+ return { status: "skipped", reason: "disabled" };
414
+ }
415
+ const route = resolveSleepRoute(ctx, config, logger);
416
+ if (!route) return { status: "skipped", reason: "no llm route" };
417
+ const maxPerRun = config.sleepEntityExtractionMaxPerRun ?? 20;
418
+
419
+ // Oldest un-extracted first. The store returns bounded pages via SQL (no
420
+ // O(N) full-table scan); JS pages through offsets and only pays the
421
+ // getAttrsByMemory cross-table check on each page, stopping once it has
422
+ // maxPerRun un-extracted memories or the store is exhausted. Every memory
423
+ // this phase touches gets stamped entity_extracted_at (successful or
424
+ // entity-less extracts alike) so a text with no extractable entities is not
425
+ // re-queued forever; failures are NOT stamped, so a transient LLM hiccup
426
+ // retries next cycle.
427
+ const candidates = [];
428
+ for (let offset = 0; candidates.length < maxPerRun; offset += 100) {
429
+ const page = service.listForEntityExtraction({ limit: 100, offset });
430
+ if (page.length === 0) break;
431
+ candidates.push(...page.filter((m) => (service.getAttrsByMemory?.(m.id) ?? []).length === 0));
432
+ }
433
+ candidates.length = Math.min(candidates.length, maxPerRun);
434
+ if (candidates.length === 0) return { status: "skipped", reason: "no memories to extract" };
435
+ if (signal?.aborted) return { status: "aborted", reason: "user activity" };
436
+
437
+ // extractEntities expects callLLM(messages, options) → text; adapt sleep's
438
+ // streamText + route resolution (same precedence as the other phases).
439
+ const callLLM = async (messages) => {
440
+ const r = resolveSleepRoute(ctx, config, logger);
441
+ if (!r) return undefined;
442
+ return streamText(ctx, {
443
+ ...r,
444
+ purpose: "sleep-entity-extract",
445
+ maxTokens: 4096,
446
+ ...(config.sleepReasoningEffort && config.sleepReasoningEffort !== "none"
447
+ ? { reasoningEffort: config.sleepReasoningEffort }
448
+ : {}),
449
+ messages
450
+ });
451
+ };
452
+
453
+ let extracted = 0;
454
+ let failed = 0;
455
+ let aborted = false;
456
+ const extractedIds = [];
457
+ for (const m of candidates) {
458
+ if (signal?.aborted) {
459
+ aborted = true;
460
+ break;
461
+ }
462
+ const now = new Date().toISOString();
463
+ try {
464
+ // Stamp a pending marker BEFORE the LLM call so a crash mid-extract (the
465
+ // stamp and the entity writes are not one transaction) cannot re-queue
466
+ // this memory while we are already working it. Cleared on outcome.
467
+ service.setMemoryMetadata?.(m.id, { pending_extracted_at: now });
468
+ } catch (error) {
469
+ logger?.warn?.(`dsh-mneme sleep: failed to stamp pending_extracted_at for ${m.id}: ${String(error)}`);
470
+ failed++;
471
+ continue;
472
+ }
473
+ try {
474
+ const result = await extractEntities(m, { store: service, config, callLLM, logger });
475
+ if (result?.ok) {
476
+ extracted++;
477
+ extractedIds.push(m.id);
478
+ // Stamp done regardless of whether the LLM found entities — an empty
479
+ // extract is still a definitive answer for this memory.
480
+ try {
481
+ service.setMemoryMetadata?.(m.id, { entity_extracted_at: now, pending_extracted_at: null });
482
+ } catch (error) {
483
+ logger?.warn?.(`dsh-mneme sleep: failed to stamp entity_extracted_at for ${m.id}: ${String(error)}`);
484
+ }
485
+ } else {
486
+ failed++;
487
+ logger?.warn?.(`dsh-mneme sleep: entity extraction failed for ${m.id}: ${result?.error ?? "unknown"}`);
488
+ try {
489
+ service.setMemoryMetadata?.(m.id, { pending_extracted_at: null });
490
+ } catch (error) {
491
+ logger?.warn?.(`dsh-mneme sleep: failed to clear pending_extracted_at for ${m.id}: ${String(error)}`);
492
+ }
493
+ }
494
+ } catch (error) {
495
+ failed++;
496
+ logger?.warn?.(`dsh-mneme sleep: entity extraction threw for ${m.id}: ${String(error)}`);
497
+ try {
498
+ service.setMemoryMetadata?.(m.id, { pending_extracted_at: null });
499
+ } catch (err) {
500
+ logger?.warn?.(`dsh-mneme sleep: failed to clear pending_extracted_at for ${m.id}: ${String(err)}`);
501
+ }
502
+ }
503
+ }
504
+ return {
505
+ // Any success counts as ok (failures are surfaced via detail.failed + warn,
506
+ // matching phaseConflicts); all-failed reports failed so deriveStatus can
507
+ // reflect a broken route without aborting the other phases.
508
+ status: aborted ? "aborted" : extracted > 0 ? "ok" : failed > 0 ? "failed" : "noop",
509
+ scanned: candidates.length,
510
+ extracted,
511
+ failed,
512
+ aborted,
513
+ extractedIds
514
+ };
515
+ }
516
+
399
517
  // ---------------------------------------------------------------- run
400
518
 
401
519
  function deriveStatus(phases) {
@@ -429,6 +547,9 @@ export async function runSleep(ctx, service, config, logger, semantic = null, si
429
547
  await attempt("conflicts", () => phaseConflicts(ctx, service, config, logger, runId, semantic, signal));
430
548
  await attempt("demotion", () => phaseDemotion(service, config, logger, runId, signal));
431
549
  await attempt("patterns", () => phasePatterns(ctx, service, config, logger, runId, signal));
550
+ // entity-extraction runs before relation completion so freshly minted
551
+ // entities get their orphan relations completed in the same cycle.
552
+ await attempt("entity-extraction", () => phaseEntityExtraction(ctx, service, config, logger, runId, signal));
432
553
  await attempt("relations", () => phaseRelations(service, config, logger, runId, signal));
433
554
 
434
555
  const status = deriveStatus(phases);
@@ -1608,9 +1608,48 @@ export function createService({ store, mirror, config, onWrite, logger, settings
1608
1608
  embeddedCount: () => store.embeddedCount(),
1609
1609
  list: (o) => store.list(o),
1610
1610
  all: () => store.all(),
1611
+ listForEntityExtraction: (opts) => store.listForEntityExtraction(opts),
1611
1612
  count: (type, opts) => store.count(type, opts),
1612
1613
  stats: (opts) => store.stats(opts),
1613
1614
  getById: (id) => store.getById(id),
1615
+ // issue #48: resolve a possibly-truncated id to its canonical full id.
1616
+ // Exact hit wins; otherwise the input is treated as a prefix of the id
1617
+ // PRIMARY KEY. Never guesses on ambiguity — returns the candidates and the
1618
+ // caller must pass a full id. Outcome is {ok:true,id} or {ok:false,reason,
1619
+ // message} with reason ∈ invalid | not-found | ambiguous. warnMiss logs the
1620
+ // silent-miss case the delete tool previously swallowed (no return channel
1621
+ // for it, so observability has to live here in the service layer).
1622
+ resolveMemoryId: (input, { warnMiss = false } = {}) => {
1623
+ const bad = (reason, message) => ({ ok: false, reason, message });
1624
+ if (typeof input !== "string") return bad("invalid", "memory id is required");
1625
+ // 手抄/上下文压缩来的 id 可能带首尾空白,统一 trim 后再做精确与前缀解析。
1626
+ const id = input.trim();
1627
+ if (!id) return bad("invalid", "memory id is required");
1628
+ const exact = store.getById(id);
1629
+ if (exact) return { ok: true, id: exact.id };
1630
+ const matches = store.listByIdPrefix(id);
1631
+ if (matches.length === 0) {
1632
+ if (warnMiss) {
1633
+ logger?.warn?.(
1634
+ `[dsh-mneme] memory id "${id}" matched nothing (exact or prefix) — ` +
1635
+ "no entry was deleted; ids are full-length, pass one from memory_list/memory_search output or delete by query=…"
1636
+ );
1637
+ }
1638
+ return bad(
1639
+ "not-found",
1640
+ `memory not found: ${id} (checked exact id and prefix; use a full id from memory_list/memory_search output)`
1641
+ );
1642
+ }
1643
+ if (matches.length > 1) {
1644
+ const sample = matches.slice(0, 5).map((m) => m.id);
1645
+ const tail = matches.length > sample.length ? ` …(+${matches.length - sample.length})` : "";
1646
+ return bad(
1647
+ "ambiguous",
1648
+ `memory id prefix "${id}" matches ${matches.length} entries (${sample.join(", ")}${tail}); refusing to guess — pass a full id`
1649
+ );
1650
+ }
1651
+ return { ok: true, id: matches[0].id };
1652
+ },
1614
1653
  remove: (id) => {
1615
1654
  store.remove(id);
1616
1655
  afterSync("write");
@@ -1824,9 +1863,11 @@ export function createService({ store, mirror, config, onWrite, logger, settings
1824
1863
  applyMemoryTags: (memoryId, tags) => store.setMemoryTags(memoryId, tags),
1825
1864
  saveAttr: (r) => store.saveAttr(r),
1826
1865
  createEntity: (r) => store.createEntity(r),
1866
+ updateEntity: (id, patch) => store.updateEntity(id, patch),
1827
1867
  findEntityByName: (n) => store.findEntityByName(n),
1828
1868
  findEntityById: (id) => store.findEntityById(id),
1829
1869
  getAttrsByMemory: (id) => store.getAttrsByMemory(id),
1870
+ setMemoryMetadata: (id, metadata) => store.setMemoryMetadata(id, metadata),
1830
1871
  getCurrentAttrs: (id) => store.getCurrentAttrs(id),
1831
1872
  migrateAttrsToMemory: (fromId, toId, now) => store.migrateAttrsToMemory(fromId, toId, now)
1832
1873
  };
@@ -20,6 +20,7 @@ CREATE TABLE IF NOT EXISTS memories (
20
20
  epistemic_status TEXT NOT NULL DEFAULT 'subjective',
21
21
  last_accessed_at TEXT,
22
22
  _full_content TEXT,
23
+ metadata TEXT, -- JSON: free-form extras (e.g. sleep entity_extracted_at)
23
24
  created_at TEXT NOT NULL,
24
25
  updated_at TEXT NOT NULL
25
26
  );
@@ -314,6 +315,14 @@ function parseTags(raw) {
314
315
 
315
316
  function toRow(row) {
316
317
  if (!row) return undefined;
318
+ let metadata;
319
+ if (row.metadata != null) {
320
+ try {
321
+ metadata = JSON.parse(row.metadata);
322
+ } catch {
323
+ metadata = row.metadata;
324
+ }
325
+ }
317
326
  return {
318
327
  id: row.id,
319
328
  type: row.type,
@@ -329,6 +338,7 @@ function toRow(row) {
329
338
  content_history: parseJsonArray(row.content_history),
330
339
  quality_score: row.quality_score !== null && row.quality_score !== undefined ? Number(row.quality_score) : undefined,
331
340
  epistemic_status: row.epistemic_status ?? "subjective",
341
+ metadata,
332
342
  created_at: row.created_at,
333
343
  updated_at: row.updated_at,
334
344
  last_accessed_at: row.last_accessed_at ?? undefined,
@@ -589,6 +599,7 @@ export function createStore(path) {
589
599
  addColumn("memories", "content_history", "ALTER TABLE memories ADD COLUMN content_history TEXT");
590
600
  addColumn("memories", "quality_score", "ALTER TABLE memories ADD COLUMN quality_score REAL");
591
601
  addColumn("memories", "session_id", "ALTER TABLE memories ADD COLUMN session_id TEXT");
602
+ addColumn("memories", "metadata", "ALTER TABLE memories ADD COLUMN metadata TEXT");
592
603
 
593
604
  // Composite index for session-lifecycle queries (dispose/restore/listBySession).
594
605
  // Created post-migration, NOT in SCHEMA: on legacy DBs both columns arrive via
@@ -697,6 +708,27 @@ export function createStore(path) {
697
708
  return toRow(row);
698
709
  }
699
710
 
711
+ /**
712
+ * issue #48: resolve a truncated id — as can leak through an agent's context
713
+ * window when list/search output is shortened — by matching it as a prefix of
714
+ * the id PRIMARY KEY. Exact lookups keep using getById; this only serves
715
+ * resolving a *candidate* id. Returns up to 51 rows so the caller can tell
716
+ * "unique" from "ambiguous" without a second query. LIKE wildcards are
717
+ * stripped from the input (a valid id fragment is hex/UUID text, never % or
718
+ * _). Prefix over a PK stays an index scan, so this is cheap even at scale.
719
+ */
720
+ function listByIdPrefix(idPrefix) {
721
+ if (typeof idPrefix !== "string" || !idPrefix.trim()) return [];
722
+ // LIKE 通配符不参与 id 匹配,一律剥掉。若剥完为空(如 id="%"),
723
+ // 不能让 SQL 退化成 `LIKE '%'` 全表命中——那会让单条记忆被误删,
724
+ // 一律视为无匹配返回。
725
+ const safe = idPrefix.replace(/[\\%_]/g, "");
726
+ if (!safe) return [];
727
+ const rows = db.prepare("SELECT * FROM memories WHERE id LIKE ? LIMIT 51")
728
+ .all(`${safe}%`);
729
+ return rows.map(toRow);
730
+ }
731
+
700
732
  /**
701
733
  * Case-insensitive exact title lookup (v0.6.1 wiki-link). COLLATE NOCASE
702
734
  * folds ASCII case (CJK titles are inherently case-free, so they match
@@ -813,6 +845,50 @@ export function createStore(path) {
813
845
  });
814
846
  }
815
847
 
848
+ /**
849
+ * Bounded scan for sleep's entity-extraction phase: oldest memories without
850
+ * an entity_extracted_at stamp (row-level filters only — attr presence is a
851
+ * cross-table check, so the caller filters the returned page via
852
+ * getAttrsByMemory). Caller pages with {limit, offset}; a full-table scan +
853
+ * JS filter would be O(N) per sleep cycle.
854
+ */
855
+ function listForEntityExtraction({ limit = 50, offset = 0 } = {}) {
856
+ // node:sqlite has no StatementSync#pluck; all() returns row objects.
857
+ return db
858
+ .prepare(`
859
+ SELECT id FROM memories
860
+ WHERE (archived = 0 OR archived IS NULL)
861
+ AND session_disposed_at IS NULL
862
+ AND (forgotten = 0 OR forgotten IS NULL)
863
+ AND type != 'summary'
864
+ AND content IS NOT NULL AND content != ''
865
+ AND (metadata IS NULL OR json_extract(metadata, '$.entity_extracted_at') IS NULL)
866
+ ORDER BY created_at ASC
867
+ LIMIT ? OFFSET ?
868
+ `)
869
+ .all(limit, offset)
870
+ .map((row) => getById(row.id));
871
+ }
872
+
873
+ /**
874
+ * Lightweight metadata-only update (used by sleep's batch entity extraction
875
+ * to stamp `entity_extracted_at` without disturbing title/content/embedding
876
+ * or the normal update semantics). Does not bump updated_at — metadata
877
+ * stamps are bookkeeping, not content, so they must not fake freshness.
878
+ * Incoming fields are MERGED into the existing metadata object so a stamp
879
+ * never clobbers metadata another path just wrote.
880
+ */
881
+ function setMemoryMetadata(id, metadata) {
882
+ const existing = getById(id);
883
+ if (!existing) throw new Error(`memory not found: ${id}`);
884
+ const patch = typeof metadata === "string"
885
+ ? JSON.parse(metadata)
886
+ : (metadata ?? {});
887
+ const merged = { ...(existing.metadata ?? {}), ...patch };
888
+ db.prepare("UPDATE memories SET metadata = ? WHERE id = ?").run(JSON.stringify(merged), id);
889
+ return getById(id);
890
+ }
891
+
816
892
  /**
817
893
  * Atomic compare-and-set update: applies `patch` only when the row still
818
894
  * carries `expectedUpdatedAt` (the version token read by the caller). Returns
@@ -2210,8 +2286,11 @@ export function createStore(path) {
2210
2286
  count,
2211
2287
  stats,
2212
2288
  getById,
2289
+ listByIdPrefix,
2213
2290
  save,
2214
2291
  update,
2292
+ listForEntityExtraction,
2293
+ setMemoryMetadata,
2215
2294
  compareAndUpdate,
2216
2295
  remove,
2217
2296
  setForget,