dsh-memsys 0.2.1 → 0.2.3
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/README.md +92 -234
- package/client.js +150 -8
- package/index.mjs +50 -4
- package/package.json +1 -1
- package/src/runtime/inject-round.mjs +1 -1
- package/src/runtime/service.mjs +178 -0
- package/src/runtime/summary.mjs +108 -3
- package/test/p6-service.test.mjs +52 -0
- package/test/p6-ui-summary.test.mjs +67 -10
package/README.md
CHANGED
|
@@ -1,281 +1,139 @@
|
|
|
1
1
|
# dsh-memsys
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
DeepSeek Harness 的长期记忆插件。事实只追加,候选可审,判断与裁决分开;记忆按预算注入上下文,支持项目、惦记、梦境与检索。
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
node scripts/migrate.mjs --data-dir ./data # 建库(三库:main / control / dream)
|
|
7
|
-
npm test # 324 项测试
|
|
8
|
-
node scripts/live-check.mjs --case tools # 真机:插件能否装进宿主、工具能否注册
|
|
9
|
-
```
|
|
10
|
-
|
|
11
|
-
已实测(隔离 profile,带 key):插件加载 → 10 个 `memsys_*` 工具注册进宿主 → 模型调用 `memsys_search` 返回真实命中 → 模型调用 `memsys_record` 写入候选与记忆,`origin.author_kind=model`、会话与调用 ID 与宿主记录一致。
|
|
12
|
-
|
|
13
|
-
## 安装
|
|
5
|
+
零第三方运行时依赖,只使用 Node 内置的 `node:sqlite`。
|
|
14
6
|
|
|
15
|
-
|
|
7
|
+
## 特性
|
|
16
8
|
|
|
17
|
-
|
|
9
|
+
- **可审写入**:模型只能提交候选;记忆由候选提升产生,不能直接落事实。
|
|
10
|
+
- **三库分离**:主库(事实/候选/记忆/证据/关系)、控制库(操作/尝试/编译/快照/容量)、梦库。
|
|
11
|
+
- **可靠提交**:预扣、写后读回、最多一次即时补写、连续失败冻结、跨重启恢复。
|
|
12
|
+
- **按预算注入**:支持 full / compressed / grouped / protected_overflow 四种交付级别,快照分页可续。
|
|
13
|
+
- **检索与项目**:按关键词、场景、主题、时间检索;项目概览与版本;待确认惦记的审核分区。
|
|
14
|
+
- **模块开关**:context / read / write / dream / projects / due / feed 七项独立开关。
|
|
15
|
+
- **可选 Web UI**:左侧栏「记忆」面板、设置页模块开关;无 Web 服务时自动降级。
|
|
18
16
|
|
|
19
|
-
|
|
20
|
-
dsh --profile memsys headless --from-default-profile --dump-config # 初始化隔离 profile
|
|
21
|
-
dsh plugin --profile memsys add dsh-memsys # 从 npm 装
|
|
22
|
-
```
|
|
17
|
+
## 环境要求
|
|
23
18
|
|
|
24
|
-
|
|
19
|
+
- Node.js >= 22.13
|
|
20
|
+
- DeepSeek Harness 0.2.x
|
|
21
|
+
- 无第三方运行时依赖
|
|
25
22
|
|
|
26
|
-
|
|
27
|
-
git clone https://github.com/tok-ryan/dsh-memsys.git dsh-memsys
|
|
28
|
-
cd dsh-memsys
|
|
29
|
-
npm test # 先跑一遍,确认本地环境能过(Node >= 22.13)
|
|
30
|
-
```
|
|
23
|
+
## 安装
|
|
31
24
|
|
|
32
25
|
```bash
|
|
33
|
-
dsh plugin --profile
|
|
26
|
+
dsh plugin --profile <profile> add dsh-memsys
|
|
34
27
|
```
|
|
35
28
|
|
|
36
|
-
|
|
29
|
+
建议使用独立 profile,不要直接装进日常使用的 profile。
|
|
37
30
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
装完自检:
|
|
31
|
+
从源码安装时,先把仓库复制到临时目录再安装,不要在 profile 里使用 junction 或符号链接。
|
|
41
32
|
|
|
42
33
|
```bash
|
|
43
|
-
dsh plugin --profile
|
|
44
|
-
node scripts/live-check.mjs --case tools --profile memsys # 真机:插件加载 + 工具注册
|
|
34
|
+
dsh plugin --profile <profile> add "file:///C:/path/to/dsh-memsys"
|
|
45
35
|
```
|
|
46
36
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
## 模块与分层
|
|
50
|
-
|
|
51
|
-
| 目录 | 职责 |
|
|
52
|
-
|---|---|
|
|
53
|
-
| `src/storage/` | 三库连接、迁移(SQL)、只追加触发器、repo |
|
|
54
|
-
| `src/control/` | 控制账:reserve / attempt / 精确读回 / 三败冻结 / 恢复 / 容量账 |
|
|
55
|
-
| `src/write/` | 观察 → 候选 → 提升六步、裁决与回执、项目、惦记、自述 |
|
|
56
|
-
| `src/context/` | 选择、排序、预算、编译(full/compressed/grouped)、快照、交付回执 |
|
|
57
|
-
| `src/read/` | 取回:get / search / review / self / project / dream / activity |
|
|
58
|
-
| `src/tools/` | 工具 schema 与 handler;`src/runtime/` 负责宿主注册与身份 |
|
|
59
|
-
| `src/dream/` | 梦库:命题、确认链、浮现、整理 |
|
|
60
|
-
| `src/import/` | 旧内容导入、门牌回填、回退核验 |
|
|
61
|
-
| `src/feed/` | 三闸筛选(相关 → 细节 → 时间) |
|
|
62
|
-
| `src/gates/` | 验收边界,可失败 |
|
|
63
|
-
|
|
64
|
-
**设计原则**:事实只追加;当前状态是可重建投影;模型只能提交候选,不能直接落事实;一切写后读回按固化预期比对,不是"行还在就算成功"。
|
|
65
|
-
|
|
66
|
-
## 界面(0.2.0 起)
|
|
67
|
-
|
|
68
|
-
装上之后 Web GUI 里会多三样东西,都来自同一个浏览器半 `client.js`:
|
|
69
|
-
|
|
70
|
-
- **左侧栏「🐳 记忆」**:点开是一块面板——记忆条数 / 候选条数 / 待确认惦记数,加最近 5 条记忆正文(超长截断)
|
|
71
|
-
- **设置 →「记忆系统」**:`auto_inject` 开关 + 当前数据目录;开关改完**立即生效**(重挂/撤下 system prompt 里的记忆段),刷新后仍在
|
|
72
|
-
- 面板与设置页共用数据口 `/dsh-memsys/api/*`(loopback + 同源校验;跨源 403)
|
|
73
|
-
|
|
74
|
-
**设置优先级**:`<data_dir>/ui-config.json`(存在且合法)> profile 的 `config` > 默认全开。
|
|
75
|
-
`/api/summary` 会返回 `modules` / `modules_source`,告诉你每个开关的当前值和来源(`ui-config` / `profile` / `default`)。
|
|
76
|
-
|
|
77
|
-
**注意生效时机**:自动注入、写入工具**改完立即生效**;读取工具 / 做梦 / 项目 / 待确认惦记 / 信息流是**下次启动生效**(工具在启动时一次性注册,宿主没有注销接口)。
|
|
78
|
-
|
|
79
|
-
**headless 不受影响**:没有 `webServer` 时那段注册不激活,浏览器半也不加载。
|
|
80
|
-
|
|
81
|
-
## 状态(0.2.1)
|
|
82
|
-
|
|
83
|
-
- **读**:真机 `dispatch=ok`,`direct_hit_count: 2` 返回真实命中
|
|
84
|
-
- **写**:真机 `record=ok`,库内落 candidate + memory,`origin.author_kind=model`、`call_id` 与宿主记录一致
|
|
85
|
-
- **注入**:`compileRound` 已接进运行时,section 文本是**当轮编译出来的记忆目录**(`text` 为函数,每步 `assemble()` 重取);无 tokenizer/H 时退回 tools-only,不假装投递(`delivery=unknown`)
|
|
86
|
-
- **界面**:左侧栏「记忆」入口 + 面板(三数字 + 最近 5 条)+ 设置页七行模块开关
|
|
87
|
-
- **测试**:356 项全绿
|
|
88
|
-
|
|
89
|
-
**仍不达标**:`L6` 只核到内容 hash 与关联,**没有逐项重放 relations 内容**;真实旧库尚未导入;生产 cutover 未跑;`feed` 生产 provider 未接。见文末"已知边界"。
|
|
90
|
-
|
|
91
|
-
MIT License。
|
|
92
|
-
|
|
93
|
-
---
|
|
94
|
-
|
|
95
|
-
_以下为原始施工期文档(安装、默认行为、边界),保留以便对照:_
|
|
96
|
-
|
|
97
|
-
# dsh-memsys · 第一批(存储 / 写入 / 可靠提交 / 注入 / 取回)
|
|
98
|
-
|
|
99
|
-
本包是 DSH 的长期记忆器官第一批实现。行为规范只有一份:`whale-unify/memsys-design-v0.38.md`;本文件只讲**怎么装、怎么跑、默认行为是什么、哪些边界还没闭合**。
|
|
37
|
+
安装后自检:
|
|
100
38
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
- 三库底座:主库(事实/候选/记忆/证据/事件)、控制账(操作、尝试、编译、快照、容量)、梦库(仅 schema 占位)。
|
|
104
|
-
- 写入三段:观察(输入原话先落库并读回)→ 候选(capture / 工具候选)→ 提升六步(memories / timeline / pointers / 证据 / 候选状态 / promote 事件,同一事务)。
|
|
105
|
-
- 可靠提交:控制账预扣尝试、写后读回三态(found / missing / unknown)、最多一次即时补写、失败三次冻结、跨重启保留、操作者可解冻。
|
|
106
|
-
- 注入器官:按已验证钩子 `ctx.inject(['systemPrompt'], …)` 注册一个段(`memsys:context`,order=500),全集 S、排序、字符/字节/token 分账、full / compressed / grouped / protected_overflow 四档、分组三件套、快照分页、投递回执与曝光。
|
|
107
|
-
- 取回工具:`memsys_get / memsys_search / memsys_review_get / memsys_context_get` 的离线处理器,`memsys_record` 写入处理器。
|
|
108
|
-
|
|
109
|
-
**不包含**:做梦、self note 工具、judgments、projects、旧数据迁移、feed、主人专有动作入口。
|
|
110
|
-
|
|
111
|
-
## 二、环境要求
|
|
112
|
-
|
|
113
|
-
- Node >= 22.13(本机实测 v24.19.0)。
|
|
114
|
-
- 只用内置 `node:sqlite`,**零第三方依赖**,不编译原生模块。
|
|
115
|
-
- 不需要 API key、不需要网络;`live-check` 的模型回合由人另外执行。
|
|
116
|
-
|
|
117
|
-
## 三、迁移与自检
|
|
118
|
-
|
|
119
|
-
```powershell
|
|
120
|
-
# 迁移并打印固定验收行
|
|
121
|
-
node whale-unify/memsys/scripts/migrate.mjs --data-dir whale-unify/_build/test-data/m1
|
|
122
|
-
# 期望:MIGRATIONS_OK main=3 control=2 dream=1
|
|
123
|
-
|
|
124
|
-
# 三库完整性
|
|
125
|
-
node whale-unify/memsys/scripts/check-db.mjs --data-dir whale-unify/_build/test-data/m1
|
|
126
|
-
# 期望:每库 integrity_check=ok、foreign_key_check=0;migrations 3/2/1
|
|
127
|
-
```
|
|
128
|
-
|
|
129
|
-
- 生产默认目录是 `~/.dsh/memsys/`(由受控启动配置决定,模型不能选库或目录)。
|
|
130
|
-
- 验收/测试必须显式传 `--data-dir`,不要写生产库。
|
|
131
|
-
- 迁移按库各自事务执行;已应用脚本的哈希不一致会拒绝启动;库版本高于当前代码只读报错。
|
|
132
|
-
|
|
133
|
-
## 四、测试与门禁
|
|
134
|
-
|
|
135
|
-
```powershell
|
|
136
|
-
npm --prefix whale-unify/memsys test # 全部离线套件,当前 118 条
|
|
137
|
-
node whale-unify/memsys/scripts/build.mjs # 语法/入口/bundle 检查 → BUILD_OK
|
|
138
|
-
node whale-unify/memsys/scripts/check-bom.mjs # UTF-8 与 BOM 检查 → UTF8_OK BOM=0
|
|
139
|
-
```
|
|
140
|
-
|
|
141
|
-
单套件示例:
|
|
142
|
-
|
|
143
|
-
```powershell
|
|
144
|
-
node --test whale-unify/memsys/test/m1-storage.test.mjs
|
|
145
|
-
node --test whale-unify/memsys/test/m2-write.test.mjs
|
|
146
|
-
node --test whale-unify/memsys/test/m3-reliable.test.mjs
|
|
147
|
-
node --test whale-unify/memsys/test/m4-context.test.mjs
|
|
148
|
-
node --test whale-unify/memsys/test/m5-tools.test.mjs whale-unify/memsys/test/e2e.test.mjs
|
|
39
|
+
```bash
|
|
40
|
+
dsh --profile <profile> --dump-config
|
|
149
41
|
```
|
|
150
42
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
> **禁止把工作区目录以 junction / 符号链接 / 硬链接的方式链进 profile。** 重建 profile 时递归删除会跟随链接,把源码一起删掉(本项目真出过一次事故)。要装就复制。
|
|
43
|
+
配置中应出现 `dsh-memsys`。
|
|
154
44
|
|
|
155
|
-
|
|
156
|
-
# 1) 复制到 ASCII 临时目录(不要直接 add 工作区路径)
|
|
157
|
-
$stage = Join-Path $env:TEMP "dsh-memsys-pkg"
|
|
158
|
-
Remove-Item $stage -Recurse -Force -ErrorAction SilentlyContinue
|
|
159
|
-
New-Item -ItemType Directory $stage | Out-Null
|
|
160
|
-
Copy-Item whale-unify/memsys\package.json, whale-unify/memsys\index.mjs, whale-unify/memsys\cordis.patch.yml $stage
|
|
161
|
-
Copy-Item whale-unify/memsys\src $stage\src -Recurse
|
|
45
|
+
## 配置
|
|
162
46
|
|
|
163
|
-
|
|
164
|
-
dsh --profile memsys-plan1-probe --from-default-profile headless --dump-config
|
|
165
|
-
dsh plugin --profile memsys-plan1-probe add $stage
|
|
47
|
+
在 profile 的 patch 中配置:
|
|
166
48
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
49
|
+
```yaml
|
|
50
|
+
- id: memsys
|
|
51
|
+
config:
|
|
52
|
+
data_dir: C:/path/to/memsys-data
|
|
53
|
+
modules:
|
|
54
|
+
context: true
|
|
55
|
+
read: true
|
|
56
|
+
write: true
|
|
57
|
+
dream: true
|
|
58
|
+
projects: true
|
|
59
|
+
due: true
|
|
60
|
+
feed: true
|
|
173
61
|
```
|
|
174
62
|
|
|
175
|
-
|
|
63
|
+
- `data_dir`:三库与运行数据目录;未配置时插件不创建数据库。
|
|
64
|
+
- `modules`:缺省为开启。安装后的默认行为是自动注入关闭(`context: false`),其余六项开启;可在设置页或 `ui-config.json` 中覆盖。
|
|
65
|
+
- 兼容旧配置:只有 `auto_inject: true` 时等价于 `modules.context: true`。
|
|
176
66
|
|
|
177
|
-
|
|
67
|
+
配置优先级:
|
|
178
68
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
```
|
|
69
|
+
1. `<data_dir>/ui-config.json`
|
|
70
|
+
2. profile 的 `config`
|
|
71
|
+
3. 默认值
|
|
183
72
|
|
|
184
|
-
|
|
73
|
+
## 工具
|
|
185
74
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
## 七、默认行为:auto_inject = false(tools-only 档)
|
|
189
|
-
|
|
190
|
-
| 配置 | 行为 |
|
|
75
|
+
| 工具 | 用途 |
|
|
191
76
|
|---|---|
|
|
192
|
-
| `
|
|
193
|
-
| `
|
|
194
|
-
| `
|
|
195
|
-
|
|
196
|
-
|
|
77
|
+
| `memsys_get` | 按 id 读取记忆、证据、时间线,支持历史视图 |
|
|
78
|
+
| `memsys_search` | 关键词 / 场景 / 主题 / 时间检索 |
|
|
79
|
+
| `memsys_review_get` | 候选、待审、惦记历史、已确认梦境 |
|
|
80
|
+
| `memsys_context_get` | 展开冻结上下文快照中的一个分组 |
|
|
81
|
+
| `memsys_project_get` | 项目概览与版本历史 |
|
|
82
|
+
| `memsys_self_get` | 自述、判断、推测 |
|
|
83
|
+
| `memsys_record` | 写入候选、判断、自述、项目更新、惦记操作提案 |
|
|
84
|
+
| `memsys_dream_record` | 记录梦境与命题 |
|
|
85
|
+
| `memsys_organize` | 精确合并或取代提案 |
|
|
86
|
+
| `memsys_feed` | 信息流筛选(需要对应 provider) |
|
|
197
87
|
|
|
198
|
-
|
|
88
|
+
关闭模块时:
|
|
199
89
|
|
|
200
|
-
`
|
|
90
|
+
- `read: false`:不注册读取类工具。
|
|
91
|
+
- `dream: false`:不注册梦境工具,梦不浮现。
|
|
92
|
+
- `projects: false`:不注册项目读取工具。
|
|
93
|
+
- `feed: false`:不注册信息流工具。
|
|
94
|
+
- `write: false`:写工具保留注册,但调用返回 `module_disabled`。
|
|
201
95
|
|
|
202
|
-
|
|
203
|
-
- `section`:`{ name: 'memsys:context', order: 500, text, payload_hash }`。
|
|
204
|
-
- `hard` / `soft`:分别记条数、UTF-8 字节、token;**字符 / 字节 / token 三列分开,不互相顶替**。
|
|
205
|
-
- `groups[]`:每组给 `group_id`、中文组名、`total_count`、`deferred_count`,以及可执行参数
|
|
206
|
-
`memsys_context_get(snapshot_revision=…, group_id=…, cursor=null, max_chars=8000)`。
|
|
207
|
-
正文里不会出现"另有 N 条"这种不可执行的计数。
|
|
208
|
-
- `delivery`:默认 `unknown`;只有收到满足"空闲集合相等 + payload hash 匹配"的回执才会 `confirmed`,部分回执是 `incomplete`。
|
|
209
|
-
- 快照:`snapshot_revision` 指向控制账里的持久快照,分组页游标绑定"快照 + 组";快照过期返回 `stale_cursor` 并给新报告入口,不会把新版数据拼进旧页。
|
|
96
|
+
## Web UI
|
|
210
97
|
|
|
211
|
-
|
|
98
|
+
安装后,Web GUI 会增加:
|
|
212
99
|
|
|
213
|
-
|
|
100
|
+
- **左侧栏「记忆」**:记忆条数、候选条数、待确认惦记数,以及最近 5 条记忆。
|
|
101
|
+
- **设置 →「记忆系统」**:模块开关与当前数据目录。
|
|
102
|
+
- **数据接口**:`/dsh-memsys/api/summary` 与 `/dsh-memsys/api/config`,仅接受 loopback 与同源请求。
|
|
214
103
|
|
|
215
|
-
|
|
216
|
-
import { createReadHandler } from './src/tools/read.mjs';
|
|
217
|
-
const tools = createReadHandler({ main, control, clock });
|
|
218
|
-
tools.get({ ids: ['mem_…'], view: 'current', include_evidence: true, max_chars: 8000 });
|
|
219
|
-
tools.search({ keywords: '葱', scene: 'life', topic_key: 'tk:…', time_range: { start, end } });
|
|
220
|
-
tools.review({ kind: 'candidate' }); // 或 kind: 'pending' / 'due-history'
|
|
221
|
-
tools.context({ snapshot_revision, group_id, cursor: null, max_chars: 8000 });
|
|
222
|
-
tools.touch({ roundId, memoryId, kind: 'lookup' }); // 只对真实读取记 lookup
|
|
223
|
-
```
|
|
224
|
-
|
|
225
|
-
- `memsys_get`:1–10 个唯一 id;`missing_ids` 与读取错误分开;超长正文按码点分片,`next_cursor` 续页,拼回后与原文逐字一致。
|
|
226
|
-
- `memsys_search`:关键词/场景/主题/时间范围过滤;空命中是 `status=ok`,不是错误;`graph` 参数返回 `unsupported_feature=graph`(第一批没有骨架)。
|
|
227
|
-
- `memsys_review_get`:候选/权限提案/due-history,给期限、默认出路、可用操作;这里的文本是审核材料,不是事实正文。
|
|
228
|
-
- `memsys_context_get`:只读持久快照分组页,不扩大快照场景。
|
|
229
|
-
- `lookup` 与 `used` 分开:只有真实读取加 `uses`,同轮同条只加一次;`used` / 主人提及只刷新 `last_touched_at`。
|
|
230
|
-
|
|
231
|
-
**宿主工具注册 API 尚未验证**:`registerTools(...)` 在没有传入真实 `register` 函数时抛 `tool_registration_unverified`,能力状态保持 `unknown`。不要在这之前声称工具已在线可用。
|
|
232
|
-
|
|
233
|
-
## 十、数据、备份与恢复
|
|
104
|
+
没有 `webServer` 服务时,host 半不激活 UI 路由,浏览器半也不会加载;headless profile 不受影响。
|
|
234
105
|
|
|
235
|
-
|
|
236
|
-
- 只追加:observations / evidence / relations / origins / operation_attempts / control_events 由触发器拒改拒删;candidates 只许 4 列、memories 只许 3 列事件投影。
|
|
237
|
-
- 备份:`node:sqlite` 没有 `db.backup()`,统一用 `VACUUM INTO` 做一致性快照(包含已提交 WAL 内容),不要直接复制 db/wal/shm。
|
|
238
|
-
- 检查点:`node scripts/checkpoint.mjs --label M5 --data-dir <dir>` 同时写源码副本(`_build/backup/<批次>/`)与三库备份(`<data-dir>/backup/<批次>/`)。
|
|
239
|
-
- 写后读回:业务提交后必须精确读回;`found / missing / unknown` 三态分明,`unknown` 不许当 `missing`。
|
|
240
|
-
- 冻结:同一对象连续三次失败 → `frozen=true`,第四次业务写为 0;重启不归零;冻结后探测到数据存在只记 `reconciliation`,不自动解冻。解冻只能由本地操作者指定 `object_key`、原因与新 generation。
|
|
241
|
-
- 梦库第一批只有 `schema_migrations` 和占位标记,没有任何梦内容表。
|
|
106
|
+
## 数据与备份
|
|
242
107
|
|
|
243
|
-
|
|
108
|
+
- `memsys.db`:业务事实与证据。
|
|
109
|
+
- `control.db`:操作账、编译快照、容量与恢复。
|
|
110
|
+
- `dreams.db`:梦境库。
|
|
111
|
+
- 备份使用 SQLite 一致性备份;恢复校验会比较三库版本与关键表内容,不一致时失败。
|
|
244
112
|
|
|
245
|
-
|
|
246
|
-
|---|---|---|
|
|
247
|
-
| 工具注册 | `node scripts/live-check.mjs --case tools` | `LIVE_CHECK_OK registration=registered registered=10 schemas=35 get=true` |
|
|
248
|
-
| 工具调度 | `node scripts/live-check.mjs --case dispatch`(带 key) | `LIVE_CHECK_OK dispatch=ok`;`tool/result` 里 `direct_hit_count: 2`(命中「葱是绿的」「葱可以吃」),`evidence_mean: 0.14` |
|
|
249
|
-
| 注入段 | `node scripts/live-check.mjs --case inject` | `LIVE_CHECK_OK section=memsys:context order=500 auto_inject=false delivery=unknown` |
|
|
113
|
+
## 开发
|
|
250
114
|
|
|
251
|
-
|
|
252
|
-
|
|
115
|
+
```bash
|
|
116
|
+
npm test
|
|
117
|
+
npm run build
|
|
118
|
+
```
|
|
253
119
|
|
|
254
|
-
|
|
120
|
+
测试覆盖存储、写入、可靠提交、上下文编译、检索、项目、惦记、梦、导入、回退与模块闸门。
|
|
255
121
|
|
|
256
|
-
|
|
122
|
+
## 设计边界
|
|
257
123
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
5. **空分组回执边界**(C1):只有组头、无实际 memory 时的确认语义未测死
|
|
263
|
-
6. **证据原文无分片续页**(C4):`observation_text` 只截断
|
|
264
|
-
7. **回退不重放关系内容**(B3):只核行存在与关联,不核 relations 内容/hash
|
|
265
|
-
8. **合并只保 `due_at`**(B1):候选层没有 `due_state`/`done`
|
|
266
|
-
9. **门牌回填靠事件自证**(B2):不重读源文件;图只核基本字段
|
|
267
|
-
10. **写入类工具仍不可用**:`memsys_record`/`dream_record`/`organize` 返回 `runtime_identity_unavailable`
|
|
124
|
+
- 模型不能直接写记忆,只能提交候选;确认与裁决属于主人侧动作。
|
|
125
|
+
- 没有宿主回执时,交付状态保持 `unknown`,不假装已投递。
|
|
126
|
+
- 证据必须能回到原始观察;来源不可读或字节范围变化时,门牌回填拒绝写入。
|
|
127
|
+
- 自动注入是显式开关;关闭时只保留普通工具读取。
|
|
268
128
|
|
|
269
|
-
|
|
129
|
+
## 已知限制
|
|
270
130
|
|
|
131
|
+
- `L6` 回退核验只到内容 hash 与关联,**未逐项重放 relations 内容**。
|
|
132
|
+
- 真实旧库尚未导入;生产 cutover 未跑。
|
|
133
|
+
- `feed` 的生产 provider 未接。
|
|
134
|
+
- 界面只在开发者本机目测过一次,未做多分辨率与暗色之外的主题验证。
|
|
135
|
+
- 自动注入默认关闭(`context: false`),需要手动开启。
|
|
271
136
|
|
|
272
|
-
##
|
|
137
|
+
## License
|
|
273
138
|
|
|
274
|
-
|
|
275
|
-
node --version # >= 22.13
|
|
276
|
-
node whale-unify/memsys/scripts/build.mjs # BUILD_OK
|
|
277
|
-
node whale-unify/memsys/scripts/check-bom.mjs # UTF8_OK BOM=0
|
|
278
|
-
npm --prefix whale-unify/memsys test # 118/118
|
|
279
|
-
node whale-unify/memsys/scripts/migrate.mjs --data-dir whale-unify/_build/test-data/m5
|
|
280
|
-
node whale-unify/memsys/scripts/check-db.mjs --data-dir whale-unify/_build/test-data/m5
|
|
281
|
-
```
|
|
139
|
+
MIT
|
package/client.js
CHANGED
|
@@ -6,6 +6,7 @@ window.__ModuleLoader__.load({
|
|
|
6
6
|
const NS = 'dsh-memsys';
|
|
7
7
|
const API = '/dsh-memsys/api/summary';
|
|
8
8
|
const CONFIG = '/dsh-memsys/api/config';
|
|
9
|
+
const SEARCH = '/dsh-memsys/api/search';
|
|
9
10
|
|
|
10
11
|
function useSummary() {
|
|
11
12
|
const [state, setState] = React.useState(null);
|
|
@@ -27,19 +28,155 @@ window.__ModuleLoader__.load({
|
|
|
27
28
|
return [state, setState];
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
// Pure CSS/DOM "memory balls on a shelf". No 3D library, no build step,
|
|
32
|
+
// no external resource: gradient + inset shadow + one float keyframe.
|
|
33
|
+
const BALL_CSS = [
|
|
34
|
+
'@keyframes msFloat{0%,100%{transform:translateY(0)}50%{transform:translateY(-3px)}}',
|
|
35
|
+
'.ms-panel{padding:16px;line-height:1.7}',
|
|
36
|
+
'.ms-counts{margin:0 0 12px;opacity:.85}',
|
|
37
|
+
'.ms-shelf{display:flex;gap:16px;align-items:flex-end;padding:12px 16px 8px;margin:0 0 10px;border-radius:12px;',
|
|
38
|
+
'background:linear-gradient(180deg,rgba(127,127,127,.10),rgba(127,127,127,.03));',
|
|
39
|
+
'box-shadow:inset 0 -1px 0 rgba(127,127,127,.28)}',
|
|
40
|
+
'.ms-ball{width:44px;height:44px;border-radius:50%;border:0;padding:0;cursor:pointer;position:relative;',
|
|
41
|
+
'background:radial-gradient(circle at 32% 27%,rgba(255,255,255,.95),rgba(255,255,255,.28) 16%,var(--ms-c) 60%,rgba(0,0,0,.45) 100%);',
|
|
42
|
+
'box-shadow:inset -6px -8px 14px rgba(0,0,0,.30),inset 6px 6px 12px rgba(255,255,255,.22),0 4px 10px rgba(0,0,0,.18);',
|
|
43
|
+
'animation:msFloat 5s ease-in-out infinite;transition:transform .15s ease,box-shadow .15s ease}',
|
|
44
|
+
'.ms-ball:hover,.ms-ball:focus-visible{transform:scale(1.06);',
|
|
45
|
+
'box-shadow:inset -6px -8px 14px rgba(0,0,0,.30),inset 6px 6px 12px rgba(255,255,255,.22),0 0 18px 4px var(--ms-glow);outline:none}',
|
|
46
|
+
'.ms-ball[aria-pressed="true"]{transform:scale(1.06);box-shadow:0 0 18px 4px var(--ms-glow)}',
|
|
47
|
+
'.ms-empty{width:44px;height:44px;border-radius:50%;border:1px dashed rgba(127,127,127,.35);background:rgba(127,127,127,.05)}',
|
|
48
|
+
'.ms-card{margin-top:10px;padding:12px 14px;border-radius:10px;background:rgba(127,127,127,.08);font-size:.95em}',
|
|
49
|
+
'.ms-hint{opacity:.75;margin:6px 0 0}',
|
|
50
|
+
'.ms-search{width:100%;box-sizing:border-box;padding:7px 10px;margin:0 0 12px;border-radius:8px;',
|
|
51
|
+
'border:1px solid rgba(127,127,127,.35);background:rgba(127,127,127,.06);color:inherit;font:inherit}',
|
|
52
|
+
'.ms-row{display:block;width:100%;text-align:left;padding:8px 10px;margin:0 0 6px;border-radius:8px;cursor:pointer;',
|
|
53
|
+
'border:1px solid transparent;background:rgba(127,127,127,.06);color:inherit;font:inherit}',
|
|
54
|
+
'.ms-row:hover,.ms-row:focus-visible{border-color:rgba(63,167,255,.55);outline:none}',
|
|
55
|
+
'.ms-kind{opacity:.65;margin-right:6px;font-size:.9em}',
|
|
56
|
+
'.ms-muted{opacity:.65}',
|
|
57
|
+
'@media (prefers-reduced-motion:reduce){.ms-ball{animation:none}}',
|
|
58
|
+
].join('');
|
|
59
|
+
const BALL_COLORS = ['#3fa7ff', '#5b8cff', '#7a6bff', '#8f5bd8', '#8450a8'];
|
|
60
|
+
const BALL_GLOWS = [
|
|
61
|
+
'rgba(63,167,255,.45)',
|
|
62
|
+
'rgba(91,140,255,.45)',
|
|
63
|
+
'rgba(122,107,255,.45)',
|
|
64
|
+
'rgba(143,91,216,.45)',
|
|
65
|
+
'rgba(132,80,168,.45)',
|
|
66
|
+
];
|
|
67
|
+
const EMPTY_SLOTS = 5;
|
|
68
|
+
const CARD_TEXT_LIMIT = 120;
|
|
69
|
+
|
|
70
|
+
function SearchResults({ query, onOpen, openId }) {
|
|
71
|
+
const [result, setResult] = React.useState(null);
|
|
72
|
+
const [failed, setFailed] = React.useState(false);
|
|
73
|
+
React.useEffect(() => {
|
|
74
|
+
if (!query) return undefined;
|
|
75
|
+
let alive = true;
|
|
76
|
+
let timer = null;
|
|
77
|
+
const run = () => {
|
|
78
|
+
fetch(SEARCH + '?q=' + encodeURIComponent(query))
|
|
79
|
+
.then((response) => (response.ok ? response.json() : Promise.reject(new Error(String(response.status)))))
|
|
80
|
+
.then((data) => {
|
|
81
|
+
if (!alive) return;
|
|
82
|
+
setFailed(false);
|
|
83
|
+
setResult(Array.isArray(data?.items) ? data.items : []);
|
|
84
|
+
})
|
|
85
|
+
.catch(() => {
|
|
86
|
+
if (alive) setFailed(true);
|
|
87
|
+
});
|
|
88
|
+
};
|
|
89
|
+
timer = setTimeout(run, 250);
|
|
90
|
+
return () => {
|
|
91
|
+
alive = false;
|
|
92
|
+
clearTimeout(timer);
|
|
93
|
+
};
|
|
94
|
+
}, [query]);
|
|
95
|
+
if (failed) return h('p', { className: 'ms-muted' }, '搜索不可用');
|
|
96
|
+
if (result === null) return h('p', { className: 'ms-muted' }, '搜索中…');
|
|
97
|
+
if (result.length === 0) return h('p', { className: 'ms-muted' }, '没找到匹配的记忆');
|
|
98
|
+
return h('div', { role: 'list', 'aria-label': '搜索结果' },
|
|
99
|
+
result.map((item) => {
|
|
100
|
+
const open = openId === item.id;
|
|
101
|
+
const body = Array.from(String(item.text ?? '')).slice(0, CARD_TEXT_LIMIT).join('');
|
|
102
|
+
return h('button', {
|
|
103
|
+
key: item.id,
|
|
104
|
+
type: 'button',
|
|
105
|
+
role: 'listitem',
|
|
106
|
+
className: 'ms-row',
|
|
107
|
+
'aria-expanded': open,
|
|
108
|
+
onClick: () => onOpen(open ? null : item.id),
|
|
109
|
+
},
|
|
110
|
+
h('span', { className: 'ms-kind' }, '[' + (item.kind ?? 'memory') + ']'),
|
|
111
|
+
h('span', null, open ? body : Array.from(body).slice(0, 40).join('') + (body.length > 40 ? '…' : '')),
|
|
112
|
+
);
|
|
113
|
+
}));
|
|
114
|
+
}
|
|
115
|
+
|
|
30
116
|
function MemoryPanel() {
|
|
31
117
|
const [state] = useSummary();
|
|
32
|
-
|
|
33
|
-
|
|
118
|
+
const [selectedId, setSelectedId] = React.useState(null);
|
|
119
|
+
const [query, setQuery] = React.useState('');
|
|
120
|
+
const [openResultId, setOpenResultId] = React.useState(null);
|
|
121
|
+
if (!state) return h('p', { className: 'ms-panel' }, '读取记忆…');
|
|
122
|
+
if (state.error) return h('p', { className: 'ms-panel' }, '暂时读不到记忆数据。');
|
|
123
|
+
const recent = state.recent ?? [];
|
|
124
|
+
const selected = recent.find((memory) => memory.id === selectedId) ?? null;
|
|
125
|
+
// Pad with ghost slots so an almost-empty shelf keeps its shape.
|
|
126
|
+
const shelf = [];
|
|
127
|
+
for (let index = 0; index < Math.max(recent.length, EMPTY_SLOTS); index += 1) shelf.push(recent[index] ?? null);
|
|
34
128
|
return h(
|
|
35
129
|
'div',
|
|
36
|
-
{
|
|
130
|
+
{ className: 'ms-panel' },
|
|
131
|
+
h('style', { dangerouslySetInnerHTML: { __html: BALL_CSS } }),
|
|
37
132
|
h('h2', { style: { margin: '0 0 8px' } }, '记忆'),
|
|
38
|
-
h('p',
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
:
|
|
133
|
+
h('p', { className: 'ms-counts' },
|
|
134
|
+
`记忆 ${state.memories} 条 · 候选 ${state.candidates} 条 · 待确认惦记 ${state.pending_due} 条`),
|
|
135
|
+
h('input', {
|
|
136
|
+
className: 'ms-search',
|
|
137
|
+
type: 'search',
|
|
138
|
+
value: query,
|
|
139
|
+
placeholder: '搜记忆…',
|
|
140
|
+
'aria-label': '搜记忆',
|
|
141
|
+
onChange: (event) => setQuery(event.target.value),
|
|
142
|
+
onKeyDown: (event) => {
|
|
143
|
+
// Enter searches immediately; typing waits for the 250ms debounce.
|
|
144
|
+
if (event.key === 'Enter') setQuery(event.currentTarget.value);
|
|
145
|
+
},
|
|
146
|
+
}),
|
|
147
|
+
query
|
|
148
|
+
? h(SearchResults, { query, openId: openResultId, onOpen: setOpenResultId })
|
|
149
|
+
: h('div', { className: 'ms-shelf', role: 'list', 'aria-label': '最近的记忆' },
|
|
150
|
+
shelf.map((memory, index) => memory === null
|
|
151
|
+
? h('span', { key: `empty-${index}`, className: 'ms-empty', 'aria-hidden': true })
|
|
152
|
+
: h('button', {
|
|
153
|
+
key: memory.id,
|
|
154
|
+
type: 'button',
|
|
155
|
+
role: 'listitem',
|
|
156
|
+
className: 'ms-ball',
|
|
157
|
+
'aria-pressed': selectedId === memory.id,
|
|
158
|
+
'aria-label': memory.text,
|
|
159
|
+
title: memory.text,
|
|
160
|
+
style: {
|
|
161
|
+
'--ms-c': BALL_COLORS[Math.min(index, BALL_COLORS.length - 1)],
|
|
162
|
+
'--ms-glow': BALL_GLOWS[Math.min(index, BALL_GLOWS.length - 1)],
|
|
163
|
+
animationDelay: `${index * 0.35}s`,
|
|
164
|
+
width: `${44 - Math.min(index, 3) * 2}px`,
|
|
165
|
+
height: `${44 - Math.min(index, 3) * 2}px`,
|
|
166
|
+
},
|
|
167
|
+
onClick: () => setSelectedId(selectedId === memory.id ? null : memory.id),
|
|
168
|
+
})),
|
|
169
|
+
),
|
|
170
|
+
query
|
|
171
|
+
? null
|
|
172
|
+
: h('p', { className: 'ms-hint' },
|
|
173
|
+
recent.length === 0
|
|
174
|
+
? '还没有记忆。去跟模型说一句「记住:…」吧'
|
|
175
|
+
: '点亮一颗球,看看它装的是什么。'),
|
|
176
|
+
!query && selected
|
|
177
|
+
? h('div', { className: 'ms-card', key: selected.id },
|
|
178
|
+
Array.from(selected.text).slice(0, CARD_TEXT_LIMIT).join(''))
|
|
179
|
+
: null,
|
|
43
180
|
);
|
|
44
181
|
}
|
|
45
182
|
|
|
@@ -115,6 +252,11 @@ window.__ModuleLoader__.load({
|
|
|
115
252
|
h('p', { style: { opacity: 0.75, margin: '10px 0 0' } }, `数据目录:${state?.data_dir ?? '未配置'}`),
|
|
116
253
|
h('p', { style: { opacity: 0.75, margin: '4px 0 0' } },
|
|
117
254
|
`当前生效值来源:${SOURCE_LABEL[state?.modules_source] ?? state?.modules_source ?? '未知'}`),
|
|
255
|
+
// A switch that says "on" while the injector runs on tools-only text is a lie.
|
|
256
|
+
state?.inject && state.inject.level === 'unavailable'
|
|
257
|
+
? h('p', { style: { margin: '4px 0 0', color: '#c46a00' } },
|
|
258
|
+
`自动注入没跑起来(${state.inject.reason ?? 'unknown'})`)
|
|
259
|
+
: null,
|
|
118
260
|
note ? h('p', { style: { opacity: 0.75, margin: '4px 0 0' } }, note) : null,
|
|
119
261
|
);
|
|
120
262
|
}
|
package/index.mjs
CHANGED
|
@@ -12,10 +12,20 @@ import { createFeedHandler } from './src/tools/feed.mjs';
|
|
|
12
12
|
import { createSelfHandler } from './src/tools/self.mjs';
|
|
13
13
|
import { createRecordHandler } from './src/tools/record.mjs';
|
|
14
14
|
import { runtimeFromExec } from './src/runtime/identity.mjs';
|
|
15
|
-
import { createSummaryHandler } from './src/runtime/summary.mjs';
|
|
15
|
+
import { createSearchHandler, createSummaryHandler } from './src/runtime/summary.mjs';
|
|
16
16
|
import { mergeModules, resolveModulesDetail, writeUiConfig } from './src/runtime/ui-config.mjs';
|
|
17
17
|
import { createRoundInjector } from './src/runtime/inject-round.mjs';
|
|
18
|
+
import { createMemSysService, provideMemSysService } from './src/runtime/service.mjs';
|
|
18
19
|
import { compileRound } from './src/context/compile.mjs';
|
|
20
|
+
import { conservativeTokens } from './src/context/budget.mjs';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Conservative host budget for the round injector: `conservativeTokens` counts
|
|
24
|
+
* ceil(codepoints / 2), so 900 tokens ≈ 1800 characters of section text — enough
|
|
25
|
+
* for a small directory, small enough that injection never crowds out the task.
|
|
26
|
+
* A host that knows its real budget overrides this with `config.H`.
|
|
27
|
+
*/
|
|
28
|
+
const DEFAULT_HOST_BUDGET_TOKENS = 900;
|
|
19
29
|
|
|
20
30
|
export const name = 'dsh-memsys';
|
|
21
31
|
/** tools is a real host service; static inject is the verified registration path. */
|
|
@@ -52,6 +62,9 @@ function createUiState(config = {}, logger = console) {
|
|
|
52
62
|
Object.assign(modules, written.modules);
|
|
53
63
|
source = 'ui-config';
|
|
54
64
|
},
|
|
65
|
+
// Overridden by `apply` once the injector exists; the settings page reads it
|
|
66
|
+
// through /api/summary so a dead injector is visible instead of silent.
|
|
67
|
+
injectStatus: () => ({ level: 'unavailable', reason: 'not_started' }),
|
|
55
68
|
sync: () => {},
|
|
56
69
|
};
|
|
57
70
|
}
|
|
@@ -71,10 +84,11 @@ function createHostHandlers(config = {}, ctx = null, ui = null) {
|
|
|
71
84
|
apply() {},
|
|
72
85
|
sync: () => {},
|
|
73
86
|
};
|
|
87
|
+
const uiSearch = createSearchHandler({ main: () => db().main, clock: systemClock });
|
|
74
88
|
const uiSummary = createSummaryHandler({
|
|
75
89
|
main: () => db().main,
|
|
76
90
|
dataDir,
|
|
77
|
-
getConfig: () => ({ modules: uiState.modules, source: uiState.source }),
|
|
91
|
+
getConfig: () => ({ modules: uiState.modules, source: uiState.source, inject: uiState.injectStatus() }),
|
|
78
92
|
onConfig: (patch) => {
|
|
79
93
|
if (!dataDir) {
|
|
80
94
|
const err = new Error('data_dir_missing');
|
|
@@ -136,6 +150,9 @@ function createHostHandlers(config = {}, ctx = null, ui = null) {
|
|
|
136
150
|
memsys_self_get: (args) => createSelfHandler({ main: db().main, clock: systemClock }).get(args),
|
|
137
151
|
memsys_feed: (args) => createFeedHandler({ clock: systemClock }).feed(args),
|
|
138
152
|
memsys_ui_summary: (req, res) => uiSummary(req, res),
|
|
153
|
+
memsys_ui_search: (req, res) => uiSearch(req, res),
|
|
154
|
+
// Cross-plugin surface: `ctx.provide('memsys', …)` exposes this instance.
|
|
155
|
+
memsys_service: () => createMemSysService({ main: db().main, control: db().control, clock: systemClock }),
|
|
139
156
|
memsys_record: (args, exec) => {
|
|
140
157
|
const identity = runtimeFromExec({ exec, ctx, tool: 'record', args });
|
|
141
158
|
if (!identity.ok) return { status: 'error', code: identity.code, ...identity };
|
|
@@ -167,6 +184,17 @@ export async function apply(ctx, config = {}) {
|
|
|
167
184
|
const handlers = createHostHandlers(config, ctx, ui);
|
|
168
185
|
const registration = await registerHostTools({ ctx, handlers, modules: ui.modules });
|
|
169
186
|
|
|
187
|
+
// Optional cross-plugin service: other plugins reach memsys through
|
|
188
|
+
// `ctx.get('memsys')`. Registration needs `ctx.provide`; a profile (or a fake
|
|
189
|
+
// harness in tests) without it simply keeps the service unprovided.
|
|
190
|
+
if (typeof ctx?.provide === 'function' && typeof handlers.memsys_service === 'function') {
|
|
191
|
+
try {
|
|
192
|
+
provideMemSysService(ctx, handlers.memsys_service());
|
|
193
|
+
} catch {
|
|
194
|
+
// no data dir yet, or the host refused the registration: tools still work
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
170
198
|
// The round injector owns the session/turn subscription; the section only
|
|
171
199
|
// reads its current text. Verified empirically: a section's `text` function is
|
|
172
200
|
// re-evaluated on every `systemPrompt.assemble()` (once per model step), so no
|
|
@@ -184,8 +212,11 @@ export async function apply(ctx, config = {}) {
|
|
|
184
212
|
dataDir: ui.dataDir,
|
|
185
213
|
modules: ui.modules,
|
|
186
214
|
compileRound,
|
|
187
|
-
|
|
188
|
-
|
|
215
|
+
// A profile patch cannot carry a function or a host number, so both
|
|
216
|
+
// default anyway: without them compileRound degrades to tools-only and
|
|
217
|
+
// the section becomes an empty shell that never injects a memory.
|
|
218
|
+
tokenizer: config.tokenizer ?? conservativeTokens,
|
|
219
|
+
H: config.H ?? DEFAULT_HOST_BUDGET_TOKENS,
|
|
189
220
|
mode: config.mode ?? 'work',
|
|
190
221
|
});
|
|
191
222
|
try {
|
|
@@ -195,6 +226,12 @@ export async function apply(ctx, config = {}) {
|
|
|
195
226
|
}
|
|
196
227
|
}
|
|
197
228
|
};
|
|
229
|
+
ui.injectStatus = () => {
|
|
230
|
+
if (ui.modules.context === false) return { level: 'disabled', reason: 'context_off' };
|
|
231
|
+
if (!injector) return { level: 'unavailable', reason: ui.dataDir ? 'injector_failed' : 'data_dir_missing' };
|
|
232
|
+
const level = injector.current?.().delivery_level ?? 'unavailable';
|
|
233
|
+
return { level, reason: level === 'unavailable' ? 'compile_unavailable' : null };
|
|
234
|
+
};
|
|
198
235
|
syncInjector();
|
|
199
236
|
const toolProbe = process.env.MEMSYS_TOOL_PROBE ?? null;
|
|
200
237
|
const writeToolProbe = (deferred) => {
|
|
@@ -356,6 +393,15 @@ export async function apply(ctx, config = {}) {
|
|
|
356
393
|
}),
|
|
357
394
|
'memsys: ui config route',
|
|
358
395
|
);
|
|
396
|
+
web.effect(
|
|
397
|
+
() =>
|
|
398
|
+
web.webServer.register({
|
|
399
|
+
kind: 'exact',
|
|
400
|
+
path: '/dsh-memsys/api/search',
|
|
401
|
+
handler: (req, res) => handlers.memsys_ui_search(req, res),
|
|
402
|
+
}),
|
|
403
|
+
'memsys: ui search route',
|
|
404
|
+
);
|
|
359
405
|
});
|
|
360
406
|
} catch {
|
|
361
407
|
// profiles without webServer stay headless
|
package/package.json
CHANGED
|
@@ -65,7 +65,7 @@ export function createRoundInjector({
|
|
|
65
65
|
}
|
|
66
66
|
const sessionId = sessionIdOf(turn) ?? state.sessionId;
|
|
67
67
|
counter += 1;
|
|
68
|
-
const roundId = turn?.id ?? turn?.roundId ?? `turn-${counter}`;
|
|
68
|
+
const roundId = turn?.id ?? turn?.roundId ?? `turn-${Date.now()}-${counter}`;
|
|
69
69
|
let result;
|
|
70
70
|
try {
|
|
71
71
|
result = compileRound({
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { objectId, payloadHash } from '../identity.mjs';
|
|
2
|
+
import { withImmediate } from '../storage/open.mjs';
|
|
3
|
+
import {
|
|
4
|
+
insertCandidate,
|
|
5
|
+
insertEvidence,
|
|
6
|
+
insertObservation,
|
|
7
|
+
getObservation,
|
|
8
|
+
} from '../storage/repositories.mjs';
|
|
9
|
+
import { searchMemories } from '../read/search.mjs';
|
|
10
|
+
import { compileRound } from '../context/compile.mjs';
|
|
11
|
+
import { conservativeTokens } from '../context/budget.mjs';
|
|
12
|
+
import { recordSelfNotes } from '../write/self-note.mjs';
|
|
13
|
+
import { WriteError } from '../write/authority.mjs';
|
|
14
|
+
|
|
15
|
+
const SERVICE_ROLE = 'model';
|
|
16
|
+
|
|
17
|
+
function assertCaller(caller = {}) {
|
|
18
|
+
if (caller.role === 'owner' || caller.role === 'master' || caller.isOwner === true) {
|
|
19
|
+
throw new WriteError('validation_error', { field: 'role', reason: 'service_caller_not_owner' });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function candidateIdFor(source, externalId) {
|
|
24
|
+
return objectId('cand', `svc:${source}:${externalId}`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createMemSysService({ main, control, clock }) {
|
|
28
|
+
const deps = { main, control, clock };
|
|
29
|
+
return {
|
|
30
|
+
version: 1,
|
|
31
|
+
|
|
32
|
+
ingest(input = {}, caller = {}) {
|
|
33
|
+
assertCaller(caller);
|
|
34
|
+
const source = String(input.source ?? '').trim();
|
|
35
|
+
const externalId = String(input.externalId ?? '').trim();
|
|
36
|
+
const text = String(input.text ?? '');
|
|
37
|
+
if (!source || !externalId || !text) {
|
|
38
|
+
throw new WriteError('validation_error', { field: 'source/externalId/text' });
|
|
39
|
+
}
|
|
40
|
+
const obsKey = `svc:${source}:${externalId}`;
|
|
41
|
+
const observationId = objectId('obs', obsKey);
|
|
42
|
+
const candidateId = candidateIdFor(source, externalId);
|
|
43
|
+
const existing = getObservation(main, observationId);
|
|
44
|
+
if (existing) {
|
|
45
|
+
const candidate = main.prepare('SELECT id FROM candidates WHERE observation_id = ?').get(observationId);
|
|
46
|
+
return { status: 'duplicate', candidateId: candidate?.id ?? null };
|
|
47
|
+
}
|
|
48
|
+
const at = input.at ?? clock.at().at;
|
|
49
|
+
const origin = `source:${source}:${externalId}`;
|
|
50
|
+
const sourceUnit = `bytes:0:${Buffer.byteLength(text, 'utf8')}`;
|
|
51
|
+
withImmediate(main, () => {
|
|
52
|
+
insertObservation(main, {
|
|
53
|
+
id: observationId,
|
|
54
|
+
idempotency_key: obsKey,
|
|
55
|
+
at,
|
|
56
|
+
payload_hash: payloadHash({ text, source, externalId }),
|
|
57
|
+
text,
|
|
58
|
+
source: 'file',
|
|
59
|
+
author_kind: 'file',
|
|
60
|
+
runtime_refs: JSON.stringify({ source, externalId, url: input.url ?? null, author: input.author ?? null }),
|
|
61
|
+
origin: JSON.stringify([origin]),
|
|
62
|
+
lossy: 0,
|
|
63
|
+
state_snapshot: '{}',
|
|
64
|
+
initial_strength: 0.5,
|
|
65
|
+
recorded_at: at,
|
|
66
|
+
});
|
|
67
|
+
insertCandidate(main, {
|
|
68
|
+
id: candidateId,
|
|
69
|
+
idempotency_key: obsKey,
|
|
70
|
+
at,
|
|
71
|
+
payload_hash: payloadHash({ text, why: 'service ingest', type: input.kind ?? 'fact', scene: input.scene ?? 'work', sourceUnit }),
|
|
72
|
+
observation_id: observationId,
|
|
73
|
+
source_unit: sourceUnit,
|
|
74
|
+
source_path: 'tool',
|
|
75
|
+
text,
|
|
76
|
+
detail: null,
|
|
77
|
+
why: 'service ingest',
|
|
78
|
+
type: input.kind ?? 'fact',
|
|
79
|
+
scene: input.scene ?? 'work',
|
|
80
|
+
scope: input.scope ?? 'work-only',
|
|
81
|
+
tags: JSON.stringify(input.tags ?? []),
|
|
82
|
+
topic_key: null,
|
|
83
|
+
topic_components: null,
|
|
84
|
+
registry_revision: null,
|
|
85
|
+
structured_value: null,
|
|
86
|
+
semantic_status: 'proposed',
|
|
87
|
+
record_kind: 'assertion',
|
|
88
|
+
origin: JSON.stringify([origin]),
|
|
89
|
+
confidence: 0.5,
|
|
90
|
+
lossy: 0,
|
|
91
|
+
start_at: input.at ?? null,
|
|
92
|
+
end_at: null,
|
|
93
|
+
prec_start: 'unknown',
|
|
94
|
+
prec_end: 'unknown',
|
|
95
|
+
time_state: 'point',
|
|
96
|
+
due_at: null,
|
|
97
|
+
weight: 5,
|
|
98
|
+
pointer_status: 'unknown',
|
|
99
|
+
pointer_location: input.url ?? null,
|
|
100
|
+
pointer_search_hint: null,
|
|
101
|
+
judgment_id: null,
|
|
102
|
+
key_status: 'unresolved',
|
|
103
|
+
binding_status: 'proposed',
|
|
104
|
+
binding_basis: JSON.stringify({ ruleRevision: 'topic-v1', source: 'service' }),
|
|
105
|
+
origin_mode: 'inherited',
|
|
106
|
+
creator_session_id: caller.sessionId ?? 'service',
|
|
107
|
+
owner_touched: 0,
|
|
108
|
+
reason: null,
|
|
109
|
+
resolved_from: null,
|
|
110
|
+
state_snapshot: '{}',
|
|
111
|
+
initial_strength: 0.5,
|
|
112
|
+
status: 'pending',
|
|
113
|
+
promoted_at: null,
|
|
114
|
+
rejected_reason: null,
|
|
115
|
+
review_policy: 'candidate-v1',
|
|
116
|
+
policy_revision: '1',
|
|
117
|
+
deadline_at: null,
|
|
118
|
+
created_input_seq: 1,
|
|
119
|
+
recorded_at: at,
|
|
120
|
+
});
|
|
121
|
+
insertEvidence(main, {
|
|
122
|
+
id: objectId('ev', `svc:${candidateId}`),
|
|
123
|
+
memory_id: null,
|
|
124
|
+
candidate_id: candidateId,
|
|
125
|
+
observation_id: observationId,
|
|
126
|
+
source_memory_id: null,
|
|
127
|
+
role: 'basis',
|
|
128
|
+
source_span: sourceUnit,
|
|
129
|
+
at,
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
return { status: 'accepted', candidateId, observationId };
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
search(query = {}) {
|
|
136
|
+
return searchMemories({
|
|
137
|
+
main, clock,
|
|
138
|
+
keywords: query.q ?? null,
|
|
139
|
+
scene: query.scene ?? null,
|
|
140
|
+
maxChars: query.limit ? Math.max(1000, Math.min(16000, Number(query.limit) * 200)) : 8000,
|
|
141
|
+
});
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
directory(query = {}) {
|
|
145
|
+
const budget = Number.isFinite(query.budget) ? query.budget : 4000;
|
|
146
|
+
const result = compileRound({
|
|
147
|
+
main, control, clock,
|
|
148
|
+
sessionId: 'service-directory',
|
|
149
|
+
roundId: 'service-directory',
|
|
150
|
+
mode: query.scene === 'life' ? 'life' : 'work',
|
|
151
|
+
tokenizer: conservativeTokens,
|
|
152
|
+
H: budget,
|
|
153
|
+
});
|
|
154
|
+
return result?.section?.text ?? '';
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
recordSelf(input = {}, caller = {}) {
|
|
158
|
+
assertCaller(caller);
|
|
159
|
+
const text = String(input.text ?? '');
|
|
160
|
+
if (!text) throw new WriteError('validation_error', { field: 'text' });
|
|
161
|
+
const runtime = {
|
|
162
|
+
role: SERVICE_ROLE,
|
|
163
|
+
identity: caller.agentId ?? caller.sessionId ?? 'service',
|
|
164
|
+
agentId: caller.agentId ?? null,
|
|
165
|
+
sessionId: caller.sessionId ?? 'service',
|
|
166
|
+
callId: caller.callId ?? 'service',
|
|
167
|
+
};
|
|
168
|
+
return recordSelfNotes(runtime, {
|
|
169
|
+
items: [{ text, tags: input.tags ?? [], kind: 'self_note' }],
|
|
170
|
+
}, deps);
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function provideMemSysService(ctx, service) {
|
|
176
|
+
if (typeof ctx?.provide !== 'function') throw new WriteError('service_unavailable', { reason: 'provide_missing' });
|
|
177
|
+
return ctx.provide('memsys', service, (value) => Boolean(value && value.version === 1));
|
|
178
|
+
}
|
package/src/runtime/summary.mjs
CHANGED
|
@@ -12,17 +12,28 @@ import {
|
|
|
12
12
|
resolveModulesDetail,
|
|
13
13
|
writeUiConfig,
|
|
14
14
|
} from './ui-config.mjs';
|
|
15
|
+
import { searchMemories } from '../read/search.mjs';
|
|
15
16
|
|
|
16
17
|
const RECENT_LIMIT = 5;
|
|
17
|
-
const
|
|
18
|
+
const SEARCH_LIMIT_DEFAULT = 20;
|
|
19
|
+
const SEARCH_LIMIT_MAX = 50;
|
|
20
|
+
const QUERY_MAX_CHARS = 200;
|
|
21
|
+
// 主人要求:卡片展示 120 字(按码点截断,预算保护仍在 host 侧)
|
|
22
|
+
const TEXT_LIMIT = 120;
|
|
18
23
|
const BODY_LIMIT = 4096;
|
|
19
24
|
|
|
20
25
|
const truncate = (text) => Array.from(String(text ?? '')).slice(0, TEXT_LIMIT).join('');
|
|
21
26
|
|
|
22
|
-
/** Three counts, the five latest memories,
|
|
27
|
+
/** Three counts, the five latest memories, the module map, and injector health. */
|
|
23
28
|
export function buildSummary(
|
|
24
29
|
main,
|
|
25
|
-
{
|
|
30
|
+
{
|
|
31
|
+
dataDir = null,
|
|
32
|
+
modules = DEFAULT_MODULES,
|
|
33
|
+
modulesSource = 'default',
|
|
34
|
+
autoInject = undefined,
|
|
35
|
+
inject = { level: 'unavailable', reason: 'not_started' },
|
|
36
|
+
} = {},
|
|
26
37
|
) {
|
|
27
38
|
const db = typeof main === 'function' ? main() : main;
|
|
28
39
|
const one = (sql, ...params) => Number(db.prepare(sql).get(...params)?.n ?? 0);
|
|
@@ -42,6 +53,98 @@ export function buildSummary(
|
|
|
42
53
|
auto_inject_source: modulesSource,
|
|
43
54
|
modules: effective,
|
|
44
55
|
modules_source: modulesSource,
|
|
56
|
+
// Without this the "on" switch can be a lie: the injector may be running on
|
|
57
|
+
// tools-only text because no tokenizer/H reached it.
|
|
58
|
+
inject: { level: inject?.level ?? 'unavailable', reason: inject?.reason ?? null },
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Search over the existing retrieval layer — no second query implementation.
|
|
64
|
+
* The search layer returns ids and text; kind/created_at come from one lookup by
|
|
65
|
+
* id, and per-item score is not exposed by it (null rather than invented).
|
|
66
|
+
*/
|
|
67
|
+
export function buildSearch(main, { q, limit = SEARCH_LIMIT_DEFAULT, clock } = {}) {
|
|
68
|
+
const db = typeof main === 'function' ? main() : main;
|
|
69
|
+
const result = searchMemories({ main: db, clock, keywords: q, maxChars: 16000 });
|
|
70
|
+
if (result?.status !== 'ok') return { status: 'error', code: result?.code ?? 'search_failed' };
|
|
71
|
+
const hits = result.hits ?? [];
|
|
72
|
+
const ids = hits.map((hit) => hit.id);
|
|
73
|
+
const meta = new Map();
|
|
74
|
+
if (ids.length > 0) {
|
|
75
|
+
const placeholders = ids.map(() => '?').join(',');
|
|
76
|
+
for (const row of db.prepare(`SELECT id, type, recorded_at FROM memories WHERE id IN (${placeholders})`).all(...ids)) {
|
|
77
|
+
meta.set(row.id, row);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const items = hits.slice(0, limit).map((hit) => ({
|
|
81
|
+
id: hit.id,
|
|
82
|
+
text: truncate(hit.text),
|
|
83
|
+
kind: meta.get(hit.id)?.type ?? null,
|
|
84
|
+
created_at: meta.get(hit.id)?.recorded_at ?? null,
|
|
85
|
+
score: null,
|
|
86
|
+
}));
|
|
87
|
+
return { status: 'ok', q, items, total: Number(result.direct_hit_count ?? items.length) };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function readQuery(url) {
|
|
91
|
+
const raw = String(url ?? '');
|
|
92
|
+
const index = raw.indexOf('?');
|
|
93
|
+
if (index < 0) return new URLSearchParams('');
|
|
94
|
+
try {
|
|
95
|
+
return new URLSearchParams(raw.slice(index + 1));
|
|
96
|
+
} catch {
|
|
97
|
+
return new URLSearchParams('');
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* GET /dsh-memsys/api/search?q=…&limit=…
|
|
103
|
+
* Same loopback + same-origin gate as the summary route; text is truncated to
|
|
104
|
+
* the same 120 codepoints the panel shows, and always keeps its id.
|
|
105
|
+
*/
|
|
106
|
+
export function createSearchHandler({ main, clock, logger = console } = {}) {
|
|
107
|
+
const send = (res, status, payload) => {
|
|
108
|
+
res.statusCode = status;
|
|
109
|
+
res.setHeader('content-type', 'application/json');
|
|
110
|
+
res.setHeader('cache-control', 'no-store');
|
|
111
|
+
res.end(JSON.stringify(payload));
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
return (req, res) => {
|
|
115
|
+
if (!isLoopback(req) || !sameOrigin(req)) {
|
|
116
|
+
send(res, 403, { error: 'forbidden' });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (req.method && req.method !== 'GET') {
|
|
120
|
+
send(res, 405, { error: 'method_not_allowed' });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const params = readQuery(req.url);
|
|
124
|
+
const q = String(params.get('q') ?? '').trim();
|
|
125
|
+
if (!q) {
|
|
126
|
+
send(res, 400, { error: 'empty_query' });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (Array.from(q).length > QUERY_MAX_CHARS) {
|
|
130
|
+
send(res, 400, { error: 'query_too_long' });
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const rawLimit = Number(params.get('limit'));
|
|
134
|
+
const limit = Number.isFinite(rawLimit) && rawLimit > 0
|
|
135
|
+
? Math.min(SEARCH_LIMIT_MAX, Math.floor(rawLimit))
|
|
136
|
+
: SEARCH_LIMIT_DEFAULT;
|
|
137
|
+
try {
|
|
138
|
+
const result = buildSearch(main, { q, limit, clock });
|
|
139
|
+
if (result.status !== 'ok') {
|
|
140
|
+
send(res, 503, { error: 'search_unavailable', message: String(result.code) });
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
send(res, 200, { q: result.q, items: result.items, total: result.total });
|
|
144
|
+
} catch (err) {
|
|
145
|
+
logger?.warn?.(`[dsh-memsys] search failed: ${err?.message ?? err}`);
|
|
146
|
+
send(res, 503, { error: 'search_unavailable', message: String(err?.code ?? err?.message ?? err) });
|
|
147
|
+
}
|
|
45
148
|
};
|
|
46
149
|
}
|
|
47
150
|
|
|
@@ -87,6 +190,7 @@ export function createSummaryHandler({
|
|
|
87
190
|
modules,
|
|
88
191
|
source: current.source ?? 'default',
|
|
89
192
|
auto_inject: modules?.context !== false,
|
|
193
|
+
inject: current.inject ?? { level: 'unavailable', reason: 'not_started' },
|
|
90
194
|
};
|
|
91
195
|
};
|
|
92
196
|
|
|
@@ -165,6 +269,7 @@ export function createSummaryHandler({
|
|
|
165
269
|
modules: state.modules,
|
|
166
270
|
modulesSource: state.source,
|
|
167
271
|
autoInject: state.auto_inject,
|
|
272
|
+
inject: state.inject,
|
|
168
273
|
}),
|
|
169
274
|
);
|
|
170
275
|
} catch (err) {
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
|
|
4
|
+
import { createMemSysService } from '../src/runtime/service.mjs';
|
|
5
|
+
import { ingestInput } from '../src/write/observe.mjs';
|
|
6
|
+
import { promoteCandidate, recordCandidates } from '../src/write/candidate.mjs';
|
|
7
|
+
import { createTestContext, mutableClock } from './helpers/fixtures.mjs';
|
|
8
|
+
import { fakeRuntime } from './helpers/runtime.mjs';
|
|
9
|
+
|
|
10
|
+
let seq = 0;
|
|
11
|
+
function seedMemory(ctx, text) {
|
|
12
|
+
seq += 1;
|
|
13
|
+
const runtime = fakeRuntime({ role: 'daily', identity: 'svc', callId: 'svc-' + seq, messageSeq: 32000 + seq });
|
|
14
|
+
const observation = ingestInput(runtime, { text: 'svc-' + seq, messageSeq: 32000 + seq }, { main: ctx.main, control: ctx.control, clock: ctx.clock });
|
|
15
|
+
const created = recordCandidates(runtime, {
|
|
16
|
+
items: [{ observationId: observation.observation.id, text, why: 'w', type: 'fact', scene: 'work', scope: 'work-only', evidenceRefs: [{ observationId: observation.observation.id, sourceSpan: 'bytes:0:3' }] }],
|
|
17
|
+
}, { main: ctx.main, control: ctx.control, clock: ctx.clock });
|
|
18
|
+
promoteCandidate(runtime, created.items[0].id, null, { main: ctx.main, control: ctx.control, clock: ctx.clock });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
test('P6/service: same externalId does not add rows; product is a candidate', (t) => {
|
|
22
|
+
const ctx = createTestContext({ clock: mutableClock().clock });
|
|
23
|
+
t.after(() => ctx.cleanup());
|
|
24
|
+
const service = createMemSysService({ main: ctx.main, control: ctx.control, clock: ctx.clock });
|
|
25
|
+
const first = service.ingest({ source: 'rss', externalId: 'a-1', kind: 'fact', text: '外部文章正文' }, { sessionId: 's-svc', callId: 'c-svc' });
|
|
26
|
+
const second = service.ingest({ source: 'rss', externalId: 'a-1', kind: 'fact', text: '外部文章正文' }, { sessionId: 's-svc', callId: 'c-svc' });
|
|
27
|
+
assert.equal(first.status, 'accepted');
|
|
28
|
+
assert.equal(second.status, 'duplicate');
|
|
29
|
+
assert.equal(ctx.main.prepare('SELECT COUNT(*) AS n FROM candidates').get().n, 1);
|
|
30
|
+
assert.equal(ctx.main.prepare('SELECT COUNT(*) AS n FROM memories').get().n, 0);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('P6/service: owner identity is rejected', (t) => {
|
|
34
|
+
const ctx = createTestContext({ clock: mutableClock().clock });
|
|
35
|
+
t.after(() => ctx.cleanup());
|
|
36
|
+
const service = createMemSysService({ main: ctx.main, control: ctx.control, clock: ctx.clock });
|
|
37
|
+
assert.throws(
|
|
38
|
+
() => service.ingest({ source: 'rss', externalId: 'a-2', text: 'x' }, { role: 'owner' }),
|
|
39
|
+
(err) => err.code === 'validation_error',
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('P6/service: directory returns a string containing a real memory id', (t) => {
|
|
44
|
+
const ctx = createTestContext({ clock: mutableClock().clock });
|
|
45
|
+
t.after(() => ctx.cleanup());
|
|
46
|
+
seedMemory(ctx, '服务目录记忆');
|
|
47
|
+
const memory = ctx.main.prepare('SELECT id FROM memories LIMIT 1').get();
|
|
48
|
+
const service = createMemSysService({ main: ctx.main, control: ctx.control, clock: ctx.clock });
|
|
49
|
+
const text = service.directory({ scene: 'work', budget: 2000 });
|
|
50
|
+
assert.equal(typeof text, 'string');
|
|
51
|
+
assert.ok(text.includes(memory.id), text.slice(0, 200));
|
|
52
|
+
});
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
resolveModulesDetail,
|
|
13
13
|
writeUiConfig,
|
|
14
14
|
} from '../src/runtime/ui-config.mjs';
|
|
15
|
-
import { buildSummary, createSummaryHandler } from '../src/runtime/summary.mjs';
|
|
15
|
+
import { buildSummary, createSearchHandler, createSummaryHandler } from '../src/runtime/summary.mjs';
|
|
16
16
|
import { registerHostTools } from '../src/runtime/tools.mjs';
|
|
17
17
|
import { conservativeTokens } from '../src/context/budget.mjs';
|
|
18
18
|
import { insertRow } from '../src/storage/repositories.mjs';
|
|
@@ -44,12 +44,12 @@ function fakeRes() {
|
|
|
44
44
|
};
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
function fakeReq({ method = 'GET', headers = {}, body = null, remoteAddress = '127.0.0.1' } = {}) {
|
|
47
|
+
function fakeReq({ method = 'GET', headers = {}, body = null, remoteAddress = '127.0.0.1', url = '/dsh-memsys/api/summary' } = {}) {
|
|
48
48
|
const listeners = new Map();
|
|
49
49
|
const req = {
|
|
50
50
|
method,
|
|
51
51
|
headers,
|
|
52
|
-
url
|
|
52
|
+
url,
|
|
53
53
|
socket: { remoteAddress },
|
|
54
54
|
on(event, handler) {
|
|
55
55
|
listeners.set(event, handler);
|
|
@@ -103,6 +103,7 @@ test('P6/UI: modules default to all true and the summary exposes seven keys', (t
|
|
|
103
103
|
'auto_inject_source',
|
|
104
104
|
'candidates',
|
|
105
105
|
'data_dir',
|
|
106
|
+
'inject',
|
|
106
107
|
'memories',
|
|
107
108
|
'modules',
|
|
108
109
|
'modules_source',
|
|
@@ -283,7 +284,8 @@ test('P6/UI: counts only open candidates, pending-due memories, and truncates re
|
|
|
283
284
|
assert.equal(summary.candidates, 2, 'promoted candidates are history, not "候选"');
|
|
284
285
|
assert.equal(summary.pending_due, 1);
|
|
285
286
|
assert.equal(summary.recent.length, 2);
|
|
286
|
-
|
|
287
|
+
// 主人要求 120 码点(原来是 80);这里改的是需求,不是放宽判据。
|
|
288
|
+
assert.equal(Array.from(summary.recent.find((row) => row.id === dueMemory.id).text).length, 120);
|
|
287
289
|
});
|
|
288
290
|
|
|
289
291
|
test('P6/UI: routes reject off-loopback and cross-origin callers', () => {
|
|
@@ -399,6 +401,7 @@ test('P6/UI: with webServer the two exact routes serve loopback and reject cross
|
|
|
399
401
|
[
|
|
400
402
|
['exact', '/dsh-memsys/api/summary'],
|
|
401
403
|
['exact', '/dsh-memsys/api/config'],
|
|
404
|
+
['exact', '/dsh-memsys/api/search'],
|
|
402
405
|
],
|
|
403
406
|
);
|
|
404
407
|
|
|
@@ -539,12 +542,9 @@ test('P6/UI: the registered section text carries a real memory id, and nothing m
|
|
|
539
542
|
};
|
|
540
543
|
|
|
541
544
|
const on = capture();
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
H: 20000,
|
|
546
|
-
mode: 'work',
|
|
547
|
-
});
|
|
545
|
+
// No explicit tokenizer/H: the plugin must default them, otherwise the
|
|
546
|
+
// injector only ever emits tools-only text and `context:true` is a dead shell.
|
|
547
|
+
const disposeOn = await apply(on.host, { data_dir: ctx.dir, mode: 'work' });
|
|
548
548
|
try {
|
|
549
549
|
assert.equal(on.sections.length, 1, 'context:true mounts exactly one section');
|
|
550
550
|
const registered = on.sections[0];
|
|
@@ -552,6 +552,12 @@ test('P6/UI: the registered section text carries a real memory id, and nothing m
|
|
|
552
552
|
const text = registered.text();
|
|
553
553
|
assert.ok(text.includes(memory.id), 'the injected text carries the real memory id');
|
|
554
554
|
assert.ok(text.length > 40, 'and it is not the empty/tools-only placeholder');
|
|
555
|
+
|
|
556
|
+
const summaryRes = fakeRes();
|
|
557
|
+
on.routes[0].handler(fakeReq({ headers: { host: '127.0.0.1:50478' } }), summaryRes);
|
|
558
|
+
const injectState = summaryRes.json().inject;
|
|
559
|
+
assert.notEqual(injectState.level, 'unavailable', 'the default tokenizer/H keep the injector alive');
|
|
560
|
+
assert.equal(injectState.reason, null);
|
|
555
561
|
} finally {
|
|
556
562
|
disposeOn();
|
|
557
563
|
}
|
|
@@ -564,3 +570,54 @@ test('P6/UI: the registered section text carries a real memory id, and nothing m
|
|
|
564
570
|
disposeOff();
|
|
565
571
|
}
|
|
566
572
|
});
|
|
573
|
+
|
|
574
|
+
test('P6/UI: search route returns id-bearing items from the existing retrieval layer', (t) => {
|
|
575
|
+
const ctx = createTestContext({ clock: mutableClock().clock });
|
|
576
|
+
t.after(() => ctx.cleanup());
|
|
577
|
+
|
|
578
|
+
const observation = makeObservation(ctx, { text: '香菜不要放,闻到就想吐。' });
|
|
579
|
+
insertRow(ctx.main, 'observations', observation);
|
|
580
|
+
const candidate = makeCandidate(ctx, observation, { businessKey: 'ui-search-1', text: '不吃香菜' });
|
|
581
|
+
insertRow(ctx.main, 'candidates', candidate);
|
|
582
|
+
const memory = makeMemory(ctx, candidate, { businessKey: 'ui-search-mem' });
|
|
583
|
+
insertRow(ctx.main, 'memories', memory);
|
|
584
|
+
|
|
585
|
+
const handler = createSearchHandler({ main: ctx.main, clock: ctx.clock });
|
|
586
|
+
const res = fakeRes();
|
|
587
|
+
handler(fakeReq({ headers: { host: '127.0.0.1:50478' }, url: '/dsh-memsys/api/search?q=%E9%A6%99%E8%8F%9C&limit=5' }), res);
|
|
588
|
+
assert.equal(res.statusCode, 200);
|
|
589
|
+
const body = res.json();
|
|
590
|
+
assert.equal(body.q, '香菜');
|
|
591
|
+
assert.ok(Array.isArray(body.items) && body.items.length >= 1);
|
|
592
|
+
const first = body.items[0];
|
|
593
|
+
assert.equal(first.id, memory.id);
|
|
594
|
+
assert.equal(typeof first.text, 'string');
|
|
595
|
+
assert.equal(first.kind, 'episode');
|
|
596
|
+
assert.ok(Array.from(first.text).length <= 120);
|
|
597
|
+
assert.equal(typeof body.total, 'number');
|
|
598
|
+
|
|
599
|
+
const empty = fakeRes();
|
|
600
|
+
handler(fakeReq({ url: '/dsh-memsys/api/search?q=' }), empty);
|
|
601
|
+
assert.equal(empty.statusCode, 400);
|
|
602
|
+
assert.equal(empty.json().error, 'empty_query');
|
|
603
|
+
|
|
604
|
+
const cross = fakeRes();
|
|
605
|
+
handler(
|
|
606
|
+
fakeReq({ headers: { origin: 'https://evil.example', host: '127.0.0.1:50478' }, url: '/dsh-memsys/api/search?q=x' }),
|
|
607
|
+
cross,
|
|
608
|
+
);
|
|
609
|
+
assert.equal(cross.statusCode, 403);
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
test('P6/UI: the browser half renders a debounced search box and never builds HTML', () => {
|
|
613
|
+
const code = fs.readFileSync(path.join(ROOT, 'client.js'), 'utf8');
|
|
614
|
+
assert.ok(code.includes("placeholder: '搜记忆…'"), 'search box with the agreed placeholder');
|
|
615
|
+
assert.ok(code.includes("const SEARCH = '/dsh-memsys/api/search'"));
|
|
616
|
+
assert.ok(code.includes('encodeURIComponent(query)'));
|
|
617
|
+
assert.ok(/setTimeout\(run, 250\)/.test(code), 'input is debounced by 250ms');
|
|
618
|
+
assert.ok(code.includes("'没找到匹配的记忆'"));
|
|
619
|
+
assert.ok(code.includes("'搜索不可用'"));
|
|
620
|
+
// The only innerHTML-ish call is the <style> block; result rows are React text nodes.
|
|
621
|
+
assert.equal((code.match(/dangerouslySetInnerHTML/g) ?? []).length, 1);
|
|
622
|
+
assert.equal((code.match(/innerHTML/g) ?? []).length, 0, 'no raw innerHTML: rows are text nodes');
|
|
623
|
+
});
|