@modusensus/dsh-mneme 0.7.6 → 0.7.8
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.
- package/.github/workflows/publish.yml +26 -6
- package/CHANGELOG.md +1 -1
- package/CONTRIBUTING.md +32 -0
- package/README.md +13 -9
- package/SECURITY.md +3 -3
- package/dsh-mneme/CHANGELOG.md +22 -0
- package/dsh-mneme/README.md +8 -4
- package/dsh-mneme/lib/config.js +9 -0
- package/dsh-mneme/lib/dream/sleep.js +121 -0
- package/dsh-mneme/lib/service.js +3 -0
- package/dsh-mneme/lib/store.js +57 -0
- package/dsh-mneme/package-lock.json +2 -2
- package/dsh-mneme/package.json +1 -1
- package/dsh-mneme/src/config.js +9 -0
- package/dsh-mneme/src/dream/sleep.js +121 -0
- package/dsh-mneme/src/inject.js +2 -2
- package/dsh-mneme/src/service.js +3 -0
- package/dsh-mneme/src/store.js +57 -0
- package/dsh-mneme/src/summarize.js +3 -1
- package/dsh-mneme/test/inject.test.js +18 -0
- package/dsh-mneme/test/sleep.test.js +159 -0
- package/dsh-mneme/test/summarize.test.js +15 -0
- package/package.json +1 -1
|
@@ -2,6 +2,11 @@ name: Publish to npm
|
|
|
2
2
|
|
|
3
3
|
# 发布路径:registry.npmjs.org 在本机云服务器被出口 SNI 过滤 + 17897 隧道不稳定,
|
|
4
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 版本。
|
|
5
10
|
|
|
6
11
|
on:
|
|
7
12
|
push:
|
|
@@ -23,15 +28,30 @@ jobs:
|
|
|
23
28
|
node-version: 24
|
|
24
29
|
registry-url: https://registry.npmjs.org/
|
|
25
30
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
31
52
|
env:
|
|
32
53
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
33
54
|
|
|
34
|
-
# 发布后自检:查 registry 上刚发的版本是否可读
|
|
35
55
|
- name: Verify published version
|
|
36
56
|
run: |
|
|
37
57
|
V="$(node -p "require('./dsh-mneme/package.json').version")"
|
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
> **完整版本历史见 [dsh-mneme/CHANGELOG.md](dsh-mneme/CHANGELOG.md)**(正式版本正源,0.3.8 → 0.7.
|
|
3
|
+
> **完整版本历史见 [dsh-mneme/CHANGELOG.md](dsh-mneme/CHANGELOG.md)**(正式版本正源,0.3.8 → 0.7.8)。以下为仓库早期开发记录(0.1.0–0.2.x,2026-08-13~14,已归档)。
|
|
4
4
|
|
|
5
5
|
All notable changes to dsh-mneme are documented here.
|
|
6
6
|
|
package/CONTRIBUTING.md
CHANGED
|
@@ -102,6 +102,22 @@ Common scripts (all run under `dsh-mneme/`):
|
|
|
102
102
|
|
|
103
103
|
---
|
|
104
104
|
|
|
105
|
+
## Scope: Platform Adaptation & Wrapper PRs
|
|
106
|
+
|
|
107
|
+
DSH upstream is still in developer preview — APIs and service interfaces change frequently. Until DSH reaches a stable release (RC or GA), **PRs that adapt dsh-mneme to secondary platforms or wrap it into other hosts are not a priority** and are reviewed with extra caution:
|
|
108
|
+
|
|
109
|
+
- Desktop shells (e.g. a Tauri/Electron wrapper around `dsh web`)
|
|
110
|
+
- Standalone CLI packaging
|
|
111
|
+
- Ports/wrappers of dsh-mneme into other plugin ecosystems
|
|
112
|
+
|
|
113
|
+
These tend to bind against unstable upstream APIs: a single upstream change can break them, and the maintenance burden falls back on this project. The currently supported platform is the **Web Profile** (`dsh web`).
|
|
114
|
+
|
|
115
|
+
Exceptions: if a contributor is willing to **own long-term maintenance** (track upstream changes and fix breakage), open a Discussion first to scope the work — maintainers will evaluate and review the PR.
|
|
116
|
+
|
|
117
|
+
Bug-fix PRs for existing desktop compatibility issues are still welcome.
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
105
121
|
## Release Process (Maintainers)
|
|
106
122
|
|
|
107
123
|
Versioning follows semantic versioning (`MAJOR.MINOR.PATCH`). Full flow:
|
|
@@ -226,6 +242,22 @@ npm run test:coverage # c8 覆盖率
|
|
|
226
242
|
|
|
227
243
|
---
|
|
228
244
|
|
|
245
|
+
## 范围:平台适配与封装 PR
|
|
246
|
+
|
|
247
|
+
DSH 上游仍处于 developer preview 阶段,API 与服务接口变动频繁。在 DSH 稳定(RC 或正式版)之前,以下方向的 PR **不作为优先项**,且会以额外谨慎的态度审查:
|
|
248
|
+
|
|
249
|
+
- 桌面端适配(例如基于 `dsh web` 的 Tauri/Electron 桌面壳)
|
|
250
|
+
- 独立 CLI 封装
|
|
251
|
+
- 把 dsh-mneme 移植/封装到其他插件体系
|
|
252
|
+
|
|
253
|
+
这类 PR 往往绑定不稳定的上游接口——上游一次变动就可能使其失效,而维护责任会落到本项目头上。当前唯一受支持的平台是 **Web Profile**(`dsh web`)。
|
|
254
|
+
|
|
255
|
+
例外:如果贡献者愿意**承担长期维护**(跟进上游变更并修复 break),请先开 Discussion 沟通范围,维护者会评估并 review 该 PR。
|
|
256
|
+
|
|
257
|
+
针对现有 desktop 兼容问题的 **bug 修复 PR 依然欢迎**。
|
|
258
|
+
|
|
259
|
+
---
|
|
260
|
+
|
|
229
261
|
## 发布流程(维护者)
|
|
230
262
|
|
|
231
263
|
版本号遵循语义化版本(`MAJOR.MINOR.PATCH`)。完整流程:
|
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=
|
|
9
|
-
<a href="
|
|
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-812%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-801%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>
|
|
@@ -109,6 +109,8 @@ dsh web
|
|
|
109
109
|
| **v0.7.4** | issue #40 记忆花括号转义 + issue #41 记忆窗口关闭按钮重叠修复 | ✅ |
|
|
110
110
|
| **v0.7.5** | 分层记忆类型 user/fact + Web 总览视图 + stats 端点 | ✅ |
|
|
111
111
|
| **v0.7.6** | issue #48 修复:截断/前缀 id 也能精确操作(统一 resolveMemoryId)+ client.js 改 src 正源 | ✅ |
|
|
112
|
+
| **v0.7.7** | issue #23 图谱回填:sleep 批量实体抽取 phase + node:sqlite 兼容修复 | ✅ |
|
|
113
|
+
| **v0.7.8** | issues #58 #59 修复:DSH 0.1.2-rc.1 兼容——Session.events 改为 snapshotEvents() 垫片,autoSummarize 与 hot-context 注入恢复 | ✅ |
|
|
112
114
|
| **v0.8.0** | 图谱增强:兴趣漂移可视化 + scope 隔离(issue #17)+ 跨 workspace 共享 | 🚧 计划中(9 月末) |
|
|
113
115
|
|
|
114
116
|
## 🧪 本地开发
|
|
@@ -116,7 +118,7 @@ dsh web
|
|
|
116
118
|
```bash
|
|
117
119
|
cd dsh-mneme
|
|
118
120
|
npm install
|
|
119
|
-
npm test #
|
|
121
|
+
npm test # 812 个测试
|
|
120
122
|
npm run stress # 三轴线压测
|
|
121
123
|
npm run sync # src → lib 同步
|
|
122
124
|
```
|
|
@@ -216,6 +218,8 @@ Works out of the box. Enable these as needed:
|
|
|
216
218
|
| **v0.7.4** | Issue #40 prompt-brace escaping + Issue #41 overlay close-button overlap fix | ✅ |
|
|
217
219
|
| **v0.7.5** | Layered memory types (user/fact) + Overview view + stats endpoint | ✅ |
|
|
218
220
|
| **v0.7.6** | Issue #48 fix: truncated/prefix ids resolve for exact ops (unified resolveMemoryId) + client.js now src-authored | ✅ |
|
|
221
|
+
| **v0.7.7** | Issue #23 graph backfill: sleep batch entity extraction phase + node:sqlite compat fix | ✅ |
|
|
222
|
+
| **v0.7.8** | Issues #58 #59 fix: DSH 0.1.2-rc.1 compat — Session.events moved to snapshotEvents() shim; autoSummarize & hot-context injection restored | ✅ |
|
|
219
223
|
| **v0.8.0** | Graph enhancement: interest-drift visualization + scope isolation (issue #17) + cross-workspace sharing | 🚧 Planned (late Sep) |
|
|
220
224
|
|
|
221
225
|
## 🧪 Local Development
|
|
@@ -223,7 +227,7 @@ Works out of the box. Enable these as needed:
|
|
|
223
227
|
```bash
|
|
224
228
|
cd dsh-mneme
|
|
225
229
|
npm install
|
|
226
|
-
npm test #
|
|
230
|
+
npm test # 812 tests
|
|
227
231
|
npm run stress # three-axis stress test
|
|
228
232
|
npm run sync # src → lib sync
|
|
229
233
|
```
|
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** `
|
|
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. **发送邮件**至 `
|
|
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*
|
package/dsh-mneme/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.7.8] - 2026-09-06
|
|
4
|
+
|
|
5
|
+
### 🐛 修复:DSH 0.1.2-rc.1 兼容(issues #58 #59)
|
|
6
|
+
|
|
7
|
+
- **现象**:DSH 0.1.2-rc.1 起官方移除 `Session.events` 属性、改为 `snapshotEvents()` 方法。autoSummarize(会话摘要)与 hot-context(短期上下文)注入都依赖 `session.events` 读取会话事件,升级后取到 undefined 而**静默失效**——会话摘要不再落库、短期上下文块不再渲染。
|
|
8
|
+
- **修复**:`src/summarize.js` 与 `src/inject.js` 两处取事件统一改为兼容垫片 `session.snapshotEvents?.() ?? session.events`——0.1.2-rc.1 及以后走新方法,更早 0.1.x 仍走 `.events`,**新旧 DSH 通吃**。
|
|
9
|
+
- **说明**:兼容垫片,老版本 DSH(0.1.x 早期)行为完全不受影响,无需任何配置改动。
|
|
10
|
+
- 新增 2 个回归用例(会话只提供 `snapshotEvents()` 时 hot-context 渲染与会话摘要均恢复),全套 **812 通过**。
|
|
11
|
+
|
|
12
|
+
## [0.7.7] - 2026-09-05
|
|
13
|
+
|
|
14
|
+
### 🆕 issue #23:sleep 批量实体抽取回填实体图谱
|
|
15
|
+
|
|
16
|
+
- **背景**:写路径抽取(index.js)只有 `entityExtractionEnabled` 开启才触发——每次写入一次 LLM 调用,是刻意的成本取舍——所以默认安装下记忆从不累积实体,这正是 issue #23 报的"实体图谱面板一片空白"(用户明明有很多记忆)。
|
|
17
|
+
- **新增 sleep 批量抽取 phase**:默认关 `sleepEntityExtractionEnabled`,开睡循环时按**最老优先**把没有实体属性的记忆逐条过 `extractEntities`(每轮上限 `sleepEntityExtractionMaxPerRun` 默认 20)。
|
|
18
|
+
- **下沉为有界 SQL 查询**:新增 `store.listForEntityExtraction({limit, offset})`,`WHERE` 只留行级条件(未归档/未遗忘/非 summary/内容非空/`metadata.entity_extracted_at` 为空),`ORDER BY created_at LIMIT ? OFFSET ?` 分页取最老候选;实体是否已存在是跨表检查(`getAttrsByMemory`),由 JS 在每页上过滤。不再 `service.all()` 全表加载。
|
|
19
|
+
- **幂等防重**:抽取前先 stamp `metadata.pending_extracted_at`,成功后替换为 `entity_extracted_at`,失败清除 pending——一条记忆不会被并发/崩溃重复抽取,失败的下轮重试。
|
|
20
|
+
- **metadata 合并不覆盖**:`store.setMemoryMetadata` 改为 merge 语义,打实体时间戳不会抹掉其他路径刚写入的 metadata 字段。
|
|
21
|
+
- **失败不阻断**:单条抽取失败只跳过该条、记入 `failed` 计数,整轮状态由 `deriveStatus` 正确折叠 `failed`——一次 LLM 抖动不会 abort 整个 sleep 周期。
|
|
22
|
+
- **node:sqlite 兼容修复**:初版实现用 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 同构)。
|
|
23
|
+
- 新增 9 个用例(禁用/跳过、stamp 不重复、无实体也 stamp、backfill、失败重试、metadata merge、pending 清除、failed 状态折叠、分页最老优先),全套 **810 通过**。
|
|
24
|
+
|
|
3
25
|
## [0.7.6] - 2026-09-02
|
|
4
26
|
|
|
5
27
|
### 🐛 issue #48:截断的短 id 也能精确操作
|
package/dsh-mneme/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
[](https://www.npmjs.com/package/@modusensus/dsh-mneme)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
[](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
|
|
8
|
-
[](https://github.com/modusensus/dsh-mneme)
|
|
9
9
|
[](https://github.com/modusensus/dsh-mneme/actions)
|
|
10
10
|
[](https://nodejs.org)
|
|
11
11
|
[](https://www.npmjs.com/package/@modusensus/dsh-mneme)
|
|
@@ -241,6 +241,8 @@ v0.3.0 起新增**记忆基因**层:从记忆里抽取**命名实体**、**带
|
|
|
241
241
|
|
|
242
242
|
| 版本 | 亮点 |
|
|
243
243
|
|------|------|
|
|
244
|
+
| **v0.7.8** | DSH 0.1.2-rc.1 兼容(issues #58 #59):官方移除 `Session.events` 属性改为 `snapshotEvents()` 方法,autoSummarize 与 hot-context(短期上下文)注入取不到事件而失效;改用兼容垫片 `session.snapshotEvents?.() ?? session.events`,新旧 DSH 通吃,老版本行为不受影响;新增 2 个回归用例;812 测试全绿 |
|
|
245
|
+
| **v0.7.7** | issue #23 实体图谱回填:sleep 批量实体抽取 phase(`sleepEntityExtractionEnabled` 默认关;最老优先、SQL LIMIT/OFFSET 分页下沉为有界查询不整表扫描;`pending_extracted_at` 幂等防重、成功/失败清除;metadata 合并不覆盖其他路径写入);node:sqlite 兼容修复(pluck→all+map、`forgotten=0` 查询条件);810 测试全绿 |
|
|
244
246
|
| **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 测试全绿 |
|
|
245
247
|
| **v0.7.5** | 分层记忆类型:新增 `user`(用户画像)/`fact`(原子事实)轻量记忆类型(单表 `type` 扩展,不动 schema)+ Web 面板「总览」视图(记忆分层卡片 + 用户画像卡 + 类型分布 + 近 7 天趋势)+ `/api/dsh-mneme/stats` 统计端点;kimi-k2.7-code 复验(days 整数化等);790 测试全绿 |
|
|
246
248
|
| **v0.7.4** | issue #40 修复:记忆内容含 `{{...}}` 模板语法时整轮崩溃(注入边界 run-based 花括号转义 `{{a}}`→`{\{a\}\}`,奇数连续如 `{{{a}}}` 也不残留字面 `{{`;新增 `escapePromptVariables` 配置默认开);issue #41 修复:记忆窗口关闭按钮与宿主窗口控制按钮重叠无法点击(顶栏左对齐,关闭按钮离开右上角宿主控制区);782 测试全绿 |
|
|
@@ -296,6 +298,8 @@ v0.3.0 起新增**记忆基因**层:从记忆里抽取**命名实体**、**带
|
|
|
296
298
|
| **v0.7.3** | ✅ 完成 | issue #38 新功能 | 左下角入口按钮可选开关 `showSidebarTrigger`(默认开,settings-over-config);Web 面板设置一键关闭,与 dsh-cost-meter 等 footer 插件冲突可隐藏按钮、记忆库标签不受影响;776 测试全绿 |
|
|
297
299
|
| **v0.7.4** | ✅ 完成 | issue #40 + #41 修复 | 注入边界 run-based 花括号转义(`{{a}}`→`{\{a\}\}`、奇数连续如 `{{{a}}}` 不残留字面,`escapePromptVariables` 默认开)+ 记忆窗口顶栏左对齐、关闭按钮避开宿主窗口控制按钮区;782 测试全绿 |
|
|
298
300
|
| **v0.7.5** | ✅ 完成 | 分层记忆类型 + 总览视图 | 借鉴 meow-memory 分层概念、贴合单表架构:新增 `user`(用户画像)/`fact`(原子事实)类型,注入/镜像/梦境/质量过滤全链路打通;Web 面板「总览」视图(分层卡片 + 用户画像卡 + 类型分布 + 近 7 天趋势);`/api/dsh-mneme/stats` 端点;kimi-k2.7-code 复验;790 测试全绿 |
|
|
301
|
+
| **v0.7.7** | ✅ 完成 | issue #23 图谱回填 | sleep 批量实体抽取 phase:默认关 `sleepEntityExtractionEnabled`,按最老优先、SQL LIMIT/OFFSET 分页(下沉为有界查询,不再整表扫描)批量抽取未打标记忆的实体(修复 issue #23 实体图谱空白);`pending_extracted_at` 幂等防重、成功/失败清除,metadata 合并不覆盖;node:sqlite 兼容修复(pluck→all+map、`forgotten=0` 查询条件);810 测试全绿 |
|
|
302
|
+
| **v0.7.8** | ✅ 完成 | DSH 0.1.2-rc.1 兼容(issues #58 #59) | 官方移除 `Session.events` 属性、改用 `snapshotEvents()` 方法后 autoSummarize 与 hot-context 注入失效;改为兼容垫片 `session.snapshotEvents?.() ?? session.events`,新旧 DSH 通吃,老版本不受影响;新增 2 个回归用例;812 测试全绿 |
|
|
299
303
|
| **v0.7.6** | ✅ 完成 | issue #48 修复 | 四工具统一 `service.resolveMemoryId`:截断/前缀短 id 也能精确操作(精确命中优先、唯一前缀解析、多命中拒绝并列出候选、`memory_delete` 未命中幂等返回 + `logger.warn` 留痕);Web bundle `client.js` 改 src 正源;801 测试全绿 |
|
|
300
304
|
| **v0.8.0** | 🚧 计划中(9 月末) | 图谱增强 | 兴趣漂移可视化 + scope 隔离(issue #17)+ 跨 workspace 记忆共享 + 更多 heat 信号 |
|
|
301
305
|
|
|
@@ -305,7 +309,7 @@ v0.3.0 起新增**记忆基因**层:从记忆里抽取**命名实体**、**带
|
|
|
305
309
|
|
|
306
310
|
### 前置条件
|
|
307
311
|
|
|
308
|
-
- [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(DSH
|
|
312
|
+
- [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(DSH)— 兼容 DSH 0.1.x,已验证 0.1.2-rc.1(`Session.events` → `snapshotEvents()` 变更已由插件垫片兼容,新旧版本通吃)
|
|
309
313
|
- Node 24+(`node:sqlite`)
|
|
310
314
|
|
|
311
315
|
### 安装步骤
|
|
@@ -462,7 +466,7 @@ src/
|
|
|
462
466
|
├── client.js # Web 面板 bundle(ModuleLoader 自注册;v0.7.6 起 src 正源)
|
|
463
467
|
└── index.js # 插件接线
|
|
464
468
|
lib/ # src 的同步分发产物(npm run sync,prepack 自动执行;无手写例外)
|
|
465
|
-
test/ #
|
|
469
|
+
test/ # 812 个 node:test 测试(含审计与三轴线压测不变量)
|
|
466
470
|
scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压测 · sync-lib.js 同步 · benchmark-recall.js 召回基准
|
|
467
471
|
```
|
|
468
472
|
|
|
@@ -471,7 +475,7 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压
|
|
|
471
475
|
```bash
|
|
472
476
|
cd dsh-mneme
|
|
473
477
|
npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试)
|
|
474
|
-
npm test # 运行
|
|
478
|
+
npm test # 运行 812 个测试
|
|
475
479
|
npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM)
|
|
476
480
|
npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行)
|
|
477
481
|
```
|
package/dsh-mneme/lib/config.js
CHANGED
|
@@ -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);
|
package/dsh-mneme/lib/service.js
CHANGED
|
@@ -1608,6 +1608,7 @@ 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),
|
|
@@ -1862,9 +1863,11 @@ export function createService({ store, mirror, config, onWrite, logger, settings
|
|
|
1862
1863
|
applyMemoryTags: (memoryId, tags) => store.setMemoryTags(memoryId, tags),
|
|
1863
1864
|
saveAttr: (r) => store.saveAttr(r),
|
|
1864
1865
|
createEntity: (r) => store.createEntity(r),
|
|
1866
|
+
updateEntity: (id, patch) => store.updateEntity(id, patch),
|
|
1865
1867
|
findEntityByName: (n) => store.findEntityByName(n),
|
|
1866
1868
|
findEntityById: (id) => store.findEntityById(id),
|
|
1867
1869
|
getAttrsByMemory: (id) => store.getAttrsByMemory(id),
|
|
1870
|
+
setMemoryMetadata: (id, metadata) => store.setMemoryMetadata(id, metadata),
|
|
1868
1871
|
getCurrentAttrs: (id) => store.getCurrentAttrs(id),
|
|
1869
1872
|
migrateAttrsToMemory: (fromId, toId, now) => store.migrateAttrsToMemory(fromId, toId, now)
|
|
1870
1873
|
};
|
package/dsh-mneme/lib/store.js
CHANGED
|
@@ -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
|
|
@@ -834,6 +845,50 @@ export function createStore(path) {
|
|
|
834
845
|
});
|
|
835
846
|
}
|
|
836
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
|
+
|
|
837
892
|
/**
|
|
838
893
|
* Atomic compare-and-set update: applies `patch` only when the row still
|
|
839
894
|
* carries `expectedUpdatedAt` (the version token read by the caller). Returns
|
|
@@ -2234,6 +2289,8 @@ export function createStore(path) {
|
|
|
2234
2289
|
listByIdPrefix,
|
|
2235
2290
|
save,
|
|
2236
2291
|
update,
|
|
2292
|
+
listForEntityExtraction,
|
|
2293
|
+
setMemoryMetadata,
|
|
2237
2294
|
compareAndUpdate,
|
|
2238
2295
|
remove,
|
|
2239
2296
|
setForget,
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@modusensus/dsh-mneme",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.7",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@modusensus/dsh-mneme",
|
|
9
|
-
"version": "0.7.
|
|
9
|
+
"version": "0.7.7",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@huggingface/transformers": "^4.2.0"
|
package/dsh-mneme/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@modusensus/dsh-mneme",
|
|
3
3
|
"description": "Cross-session memory plugin for DeepSeek Harness with autoDream consolidation: SQLite store, Markdown mirrors, 7 model tools, automatic injection, session summarization, user profile/rules, custom slash commands, vector (semantic) search, and a Web GUI panel",
|
|
4
|
-
"version": "0.7.
|
|
4
|
+
"version": "0.7.8",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
package/dsh-mneme/src/config.js
CHANGED
|
@@ -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);
|
package/dsh-mneme/src/inject.js
CHANGED
|
@@ -8,7 +8,7 @@ import { createHotMemory } from "./hot-memory.js";
|
|
|
8
8
|
// falls back to the legacy rule-based pick, never breaking the render.
|
|
9
9
|
function lastUserQuery(ctx) {
|
|
10
10
|
try {
|
|
11
|
-
const events = ctx?.agent?.session?.events;
|
|
11
|
+
const events = ctx?.agent?.session?.snapshotEvents?.() ?? ctx?.agent?.session?.events;
|
|
12
12
|
if (!Array.isArray(events) || events.length === 0) return "";
|
|
13
13
|
for (let i = events.length - 1; i >= 0; i--) {
|
|
14
14
|
const event = events[i];
|
|
@@ -34,7 +34,7 @@ function lastUserQuery(ctx) {
|
|
|
34
34
|
// returns [] on any failure, and the hot block simply does not render.
|
|
35
35
|
function extractRounds(ctx, maxRounds) {
|
|
36
36
|
try {
|
|
37
|
-
const events = ctx?.agent?.session?.events;
|
|
37
|
+
const events = ctx?.agent?.session?.snapshotEvents?.() ?? ctx?.agent?.session?.events;
|
|
38
38
|
if (!Array.isArray(events) || events.length === 0) return [];
|
|
39
39
|
const rounds = [];
|
|
40
40
|
let pendingQuery = null;
|
package/dsh-mneme/src/service.js
CHANGED
|
@@ -1608,6 +1608,7 @@ 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),
|
|
@@ -1862,9 +1863,11 @@ export function createService({ store, mirror, config, onWrite, logger, settings
|
|
|
1862
1863
|
applyMemoryTags: (memoryId, tags) => store.setMemoryTags(memoryId, tags),
|
|
1863
1864
|
saveAttr: (r) => store.saveAttr(r),
|
|
1864
1865
|
createEntity: (r) => store.createEntity(r),
|
|
1866
|
+
updateEntity: (id, patch) => store.updateEntity(id, patch),
|
|
1865
1867
|
findEntityByName: (n) => store.findEntityByName(n),
|
|
1866
1868
|
findEntityById: (id) => store.findEntityById(id),
|
|
1867
1869
|
getAttrsByMemory: (id) => store.getAttrsByMemory(id),
|
|
1870
|
+
setMemoryMetadata: (id, metadata) => store.setMemoryMetadata(id, metadata),
|
|
1868
1871
|
getCurrentAttrs: (id) => store.getCurrentAttrs(id),
|
|
1869
1872
|
migrateAttrsToMemory: (fromId, toId, now) => store.migrateAttrsToMemory(fromId, toId, now)
|
|
1870
1873
|
};
|
package/dsh-mneme/src/store.js
CHANGED
|
@@ -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
|
|
@@ -834,6 +845,50 @@ export function createStore(path) {
|
|
|
834
845
|
});
|
|
835
846
|
}
|
|
836
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
|
+
|
|
837
892
|
/**
|
|
838
893
|
* Atomic compare-and-set update: applies `patch` only when the row still
|
|
839
894
|
* carries `expectedUpdatedAt` (the version token read by the caller). Returns
|
|
@@ -2234,6 +2289,8 @@ export function createStore(path) {
|
|
|
2234
2289
|
listByIdPrefix,
|
|
2235
2290
|
save,
|
|
2236
2291
|
update,
|
|
2292
|
+
listForEntityExtraction,
|
|
2293
|
+
setMemoryMetadata,
|
|
2237
2294
|
compareAndUpdate,
|
|
2238
2295
|
remove,
|
|
2239
2296
|
setForget,
|
|
@@ -78,7 +78,9 @@ function toProtocolChunk(chunk) {
|
|
|
78
78
|
// check below.
|
|
79
79
|
function collectMessages(session) {
|
|
80
80
|
const messages = [];
|
|
81
|
-
|
|
81
|
+
// DSH 0.1.2-rc.1 起 Session 改用 snapshotEvents(),兼容旧版 .events
|
|
82
|
+
const events = session.snapshotEvents?.() ?? session.events ?? [];
|
|
83
|
+
for (const event of events) {
|
|
82
84
|
const kind = event.data?.source?.kind;
|
|
83
85
|
if (event.type !== "user/message") continue;
|
|
84
86
|
if (kind !== undefined && kind !== "user") continue;
|
|
@@ -179,6 +179,24 @@ test("issue #40: hot-context rounds with {{...}} are escaped", () => {
|
|
|
179
179
|
assert.ok(!text.includes("}}"), "no raw }} in hot context");
|
|
180
180
|
});
|
|
181
181
|
|
|
182
|
+
test("session with only snapshotEvents() (DSH 0.1.2-rc.1) still renders hot context", () => {
|
|
183
|
+
const { contexts } = setup();
|
|
184
|
+
const text = contexts[0].text({
|
|
185
|
+
agent: {
|
|
186
|
+
session: {
|
|
187
|
+
id: "s1",
|
|
188
|
+
snapshotEvents: () => [
|
|
189
|
+
{ type: "user/message", data: { source: { kind: "user" }, content: ["用快照接口提问"] } },
|
|
190
|
+
{ type: "assistant/message", data: { source: { kind: "assistant" }, content: ["快照返回的答复"] } }
|
|
191
|
+
]
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
assert.ok(text.includes("[短期上下文]"), "hot context rendered");
|
|
196
|
+
assert.ok(text.includes("用快照接口提问"), "user query picked up from snapshotEvents");
|
|
197
|
+
assert.ok(text.includes("快照返回的答复"), "assistant reply picked up from snapshotEvents");
|
|
198
|
+
});
|
|
199
|
+
|
|
182
200
|
test("issue #40: escapePromptVariables=false passes {{...}} through verbatim", () => {
|
|
183
201
|
const { contexts, service } = setup({ escapePromptVariables: false });
|
|
184
202
|
service.saveWithDedupe({ type: "preference", title: "模板", content: "{{挖空}} 与 {{hl|term}}", importance: 5 });
|
|
@@ -371,3 +371,162 @@ test("sleep: runSleep is abortable via signal between phases", async () => {
|
|
|
371
371
|
assert.equal(result.phases.conflicts, undefined, "aborted before any phase ran");
|
|
372
372
|
store.close();
|
|
373
373
|
});
|
|
374
|
+
|
|
375
|
+
// ------------------------------------------------ entity extraction (issue #23)
|
|
376
|
+
|
|
377
|
+
// Write-path extractor is gated on entityExtractionEnabled (default off), so
|
|
378
|
+
// default installs never accumulate entities → ego-graph panel blank. This
|
|
379
|
+
// sleep phase backfills it; these tests cover gating, idempotence and
|
|
380
|
+
// fail-safe batching.
|
|
381
|
+
const ENTITY_LLM_JSON = JSON.stringify({
|
|
382
|
+
entities: [{ name: "X", type: "concept", attrs: [{ key: "k", value: "v" }] }],
|
|
383
|
+
relations: []
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("sleep: entity extraction skips when disabled", async () => {
|
|
387
|
+
const { service, store } = setup();
|
|
388
|
+
makeMemory(service, "记忆A", "内容A", "history");
|
|
389
|
+
const ctx = mockCtx(() => ENTITY_LLM_JSON, { provider: "p", model: "m" });
|
|
390
|
+
const result = await runSleep(
|
|
391
|
+
ctx, service,
|
|
392
|
+
baseConfig({ sleepEntityExtractionEnabled: false }),
|
|
393
|
+
ctx.logger, null, null
|
|
394
|
+
);
|
|
395
|
+
assert.equal(result.phases["entity-extraction"].status, "skipped", "disabled → skipped");
|
|
396
|
+
store.close();
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test("sleep: extracted memories are stamped and not re-extracted", async () => {
|
|
400
|
+
const { service, store } = setup();
|
|
401
|
+
const mem = makeMemory(service, "记忆B", "内容B", "history");
|
|
402
|
+
const ctx = mockCtx(() => ENTITY_LLM_JSON, { provider: "p", model: "m" });
|
|
403
|
+
const config = baseConfig({ sleepEntityExtractionEnabled: true });
|
|
404
|
+
// First cycle backfills → memory gets entity_extracted_at stamped.
|
|
405
|
+
const first = await runSleep(ctx, service, config, ctx.logger, null, null);
|
|
406
|
+
assert.equal(first.phases["entity-extraction"].status, "ok", "first cycle extracts");
|
|
407
|
+
assert.ok(service.getById(mem.id).metadata?.entity_extracted_at, "stamp written");
|
|
408
|
+
// Second cycle has nothing un-stamped left → skipped.
|
|
409
|
+
const second = await runSleep(ctx, service, config, ctx.logger, null, null);
|
|
410
|
+
assert.equal(second.phases["entity-extraction"].status, "skipped", "nothing left to extract");
|
|
411
|
+
store.close();
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test("sleep: entity-less extracts are stamped so they are not re-queued forever", async () => {
|
|
415
|
+
const { service, store } = setup();
|
|
416
|
+
const mem = makeMemory(service, "无实体记忆", "这里没有任何实体", "history");
|
|
417
|
+
// LLM legitimately returns no entities — must still be stamped, else every
|
|
418
|
+
// sleep cycle re-extracts the same text forever (issue #23 regression).
|
|
419
|
+
const ctx = mockCtx(() => JSON.stringify({ entities: [], relations: [] }), { provider: "p", model: "m" });
|
|
420
|
+
const config = baseConfig({ sleepEntityExtractionEnabled: true });
|
|
421
|
+
const first = await runSleep(ctx, service, config, ctx.logger, null, null);
|
|
422
|
+
assert.equal(first.phases["entity-extraction"].status, "ok", "empty extract counts as handled");
|
|
423
|
+
assert.equal(first.phases["entity-extraction"].extracted, 1);
|
|
424
|
+
assert.ok(service.getById(mem.id).metadata?.entity_extracted_at, "stamp written even with no entities");
|
|
425
|
+
const second = await runSleep(ctx, service, config, ctx.logger, null, null);
|
|
426
|
+
assert.equal(second.phases["entity-extraction"].status, "skipped", "stamped → not re-queued");
|
|
427
|
+
store.close();
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test("sleep: entity extraction backfills entities for un-extracted memories", async () => {
|
|
431
|
+
const { service, store } = setup();
|
|
432
|
+
const mem = makeMemory(service, "记忆C", "内容C", "history");
|
|
433
|
+
const ctx = mockCtx(() => ENTITY_LLM_JSON, { provider: "p", model: "m" });
|
|
434
|
+
const result = await runSleep(
|
|
435
|
+
ctx, service,
|
|
436
|
+
baseConfig({ sleepEntityExtractionEnabled: true }),
|
|
437
|
+
ctx.logger, null, null
|
|
438
|
+
);
|
|
439
|
+
const phase = result.phases["entity-extraction"];
|
|
440
|
+
assert.equal(phase.status, "ok");
|
|
441
|
+
assert.equal(phase.extracted, 1);
|
|
442
|
+
assert.ok(store.listEntities().length > 0, "entity minted");
|
|
443
|
+
assert.ok(service.getAttrsByMemory(mem.id).length > 0, "memory now carries entity attrs");
|
|
444
|
+
store.close();
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test("sleep: a failing extraction does not abort the batch and retries next cycle", async () => {
|
|
448
|
+
const { service, store } = setup();
|
|
449
|
+
const bad = makeMemory(service, "坏记忆", "bad 内容", "history");
|
|
450
|
+
const good = makeMemory(service, "好记忆", "good 内容", "history");
|
|
451
|
+
const ctx = mockCtx(
|
|
452
|
+
(user) => (String(user).includes("bad") ? "这不是合法JSON{{{" : ENTITY_LLM_JSON),
|
|
453
|
+
{ provider: "p", model: "m" }
|
|
454
|
+
);
|
|
455
|
+
const config = baseConfig({ sleepEntityExtractionEnabled: true });
|
|
456
|
+
const first = await runSleep(ctx, service, config, ctx.logger, null, null);
|
|
457
|
+
const phase = first.phases["entity-extraction"];
|
|
458
|
+
assert.equal(phase.extracted, 1, "good memory extracted");
|
|
459
|
+
assert.equal(phase.failed, 1, "bad memory counted as failed");
|
|
460
|
+
assert.equal(phase.status, "ok", "partial success is ok");
|
|
461
|
+
assert.ok(store.listEntities().length > 0, "at least one entity minted");
|
|
462
|
+
// Good memory stamped; bad one not → second cycle retries only the bad one.
|
|
463
|
+
assert.ok(service.getById(good.id).metadata?.entity_extracted_at, "good memory stamped");
|
|
464
|
+
assert.equal(service.getById(bad.id).metadata?.entity_extracted_at, undefined, "failed memory not stamped");
|
|
465
|
+
const second = await runSleep(ctx, service, config, ctx.logger, null, null);
|
|
466
|
+
assert.equal(second.phases["entity-extraction"].extracted, 0, "bad memory still fails");
|
|
467
|
+
assert.equal(second.phases["entity-extraction"].failed, 1, "failed memory retried");
|
|
468
|
+
store.close();
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
test("sleep: metadata merge keeps unrelated fields when stamping (review #4)", async () => {
|
|
472
|
+
const { service, store } = setup();
|
|
473
|
+
const mem = makeMemory(service, "记忆M", "内容M", "history");
|
|
474
|
+
service.setMemoryMetadata(mem.id, { custom_flag: "keep-me" });
|
|
475
|
+
const ctx = mockCtx(() => ENTITY_LLM_JSON, { provider: "p", model: "m" });
|
|
476
|
+
await runSleep(ctx, service, baseConfig({ sleepEntityExtractionEnabled: true }), ctx.logger, null, null);
|
|
477
|
+
const meta = service.getById(mem.id).metadata;
|
|
478
|
+
assert.equal(meta.custom_flag, "keep-me", "pre-existing metadata not clobbered");
|
|
479
|
+
assert.ok(meta.entity_extracted_at, "stamp added alongside");
|
|
480
|
+
store.close();
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
test("sleep: pending_extracted_at is cleared on success and on failure (review #3)", async () => {
|
|
484
|
+
const { service, store } = setup();
|
|
485
|
+
const good = makeMemory(service, "好记忆", "内容G", "history");
|
|
486
|
+
const bad = makeMemory(service, "坏记忆", "bad 内容", "history");
|
|
487
|
+
const ctx = mockCtx(
|
|
488
|
+
(user) => (String(user).includes("bad") ? "不是JSON{{{" : ENTITY_LLM_JSON),
|
|
489
|
+
{ provider: "p", model: "m" }
|
|
490
|
+
);
|
|
491
|
+
const config = baseConfig({ sleepEntityExtractionEnabled: true });
|
|
492
|
+
const first = await runSleep(ctx, service, config, ctx.logger, null, null);
|
|
493
|
+
const phase = first.phases["entity-extraction"];
|
|
494
|
+
assert.equal(phase.extracted, 1);
|
|
495
|
+
assert.equal(phase.failed, 1);
|
|
496
|
+
assert.equal(service.getById(good.id).metadata?.pending_extracted_at, null, "success clears pending");
|
|
497
|
+
assert.equal(service.getById(bad.id).metadata?.pending_extracted_at, null, "failure clears pending");
|
|
498
|
+
store.close();
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
test("sleep: all-failed extraction surfaces failed status through deriveStatus (review #5)", async () => {
|
|
502
|
+
const { service, store } = setup();
|
|
503
|
+
makeMemory(service, "坏记忆1", "bad 一", "history");
|
|
504
|
+
makeMemory(service, "坏记忆2", "bad 二", "history");
|
|
505
|
+
const ctx = mockCtx(() => "不是JSON{{{", { provider: "p", model: "m" });
|
|
506
|
+
const result = await runSleep(ctx, service, baseConfig({ sleepEntityExtractionEnabled: true }), ctx.logger, null, null);
|
|
507
|
+
const phase = result.phases["entity-extraction"];
|
|
508
|
+
assert.equal(phase.status, "failed", "all-failed extraction reports failed");
|
|
509
|
+
assert.equal(phase.failed, 2);
|
|
510
|
+
assert.ok(["failed", "degraded"].includes(result.status), "deriveStatus folds failed into run status");
|
|
511
|
+
store.close();
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
test("store: listForEntityExtraction pages oldest un-stamped candidates (review #2)", async () => {
|
|
515
|
+
const { service, store } = setup();
|
|
516
|
+
const a = makeMemory(service, "甲", "内容甲", "history");
|
|
517
|
+
makeMemory(service, "乙", "内容乙", "history");
|
|
518
|
+
const archived = makeMemory(service, "归档", "内容归档", "history");
|
|
519
|
+
store.setArchived(archived.id, true);
|
|
520
|
+
makeMemory(service, "摘要", "内容摘要", "summary");
|
|
521
|
+
makeMemory(service, "丙", "内容丙", "history"); // second un-stamped candidate so oldest-first has two to compare
|
|
522
|
+
service.setMemoryMetadata(a.id, { entity_extracted_at: new Date().toISOString() });
|
|
523
|
+
const list = store.listForEntityExtraction({ limit: 10, offset: 0 });
|
|
524
|
+
const titles = list.map((m) => m.title);
|
|
525
|
+
assert.ok(!titles.includes("归档"), "archived excluded");
|
|
526
|
+
assert.ok(!titles.includes("摘要"), "summary excluded");
|
|
527
|
+
assert.ok(!titles.includes("甲"), "already-stamped excluded");
|
|
528
|
+
assert.ok(titles.includes("乙"), "un-stamped included");
|
|
529
|
+
assert.ok(titles.includes("丙"), "un-stamped included");
|
|
530
|
+
assert.equal(list[0].created_at <= list[1].created_at, true, "oldest first");
|
|
531
|
+
store.close();
|
|
532
|
+
});
|
|
@@ -85,6 +85,21 @@ test("turn/end event triggers summarization and stores entries", async () => {
|
|
|
85
85
|
assert.ok(all.some((m) => m.type === "preference"));
|
|
86
86
|
});
|
|
87
87
|
|
|
88
|
+
test("session with only snapshotEvents() (DSH 0.1.2-rc.1) still summarizes", async () => {
|
|
89
|
+
const { events, store } = setup();
|
|
90
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
91
|
+
const session = {
|
|
92
|
+
id: "s6",
|
|
93
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
94
|
+
snapshotEvents: () => [userMessage("用快照接口提问"), { seq: 2, type: "turn/end" }]
|
|
95
|
+
};
|
|
96
|
+
await handler(session, { seq: 2, type: "turn/end" });
|
|
97
|
+
assert.equal(store.count(), 2);
|
|
98
|
+
const all = store.all();
|
|
99
|
+
assert.ok(all.some((m) => m.type === "decision"));
|
|
100
|
+
assert.ok(all.some((m) => m.type === "preference"));
|
|
101
|
+
});
|
|
102
|
+
|
|
88
103
|
test("skips summarization for events other than turn/end", async () => {
|
|
89
104
|
const { events, store, calls } = setup();
|
|
90
105
|
const handler = events.find((e) => e.name === "session/event").fn;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@modusensus/dsh-mneme",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.8",
|
|
4
4
|
"description": "Structured memory engine for DeepSeek Harness. Offline semantic search, entity-attribute-timeline, autoDream self-consolidation, and human-editable Markdown storage.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|