@max-null/dsh-memory 0.15.0 → 0.16.0
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 +188 -163
- package/dist/backup.d.ts +63 -0
- package/dist/backup.js +128 -0
- package/dist/engine.d.ts +7 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +62 -13
- package/dist/routes.js +3 -3
- package/dist/self.js +2 -2
- package/package.json +2 -1
- package/dist/client/index.d.ts +0 -2
- package/dist/client/index.js +0 -283
package/README.md
CHANGED
|
@@ -1,163 +1,188 @@
|
|
|
1
|
-
# dsh-memory
|
|
2
|
-
|
|
3
|
-
本插件属于 **`@max-null/*` 插件系列**——这一系列共同构成 **[SSID(思灵 · Seek Soul in Darkness)](https://github.com/Max-Null/seek-soul-in-darkness)** 桌面体验。SSID 是整合它们的盒:`dsh-capture` · `dsh-chat-rail` · `dsh-chinese-thinking` · `dsh-draft-polish` · `dsh-guardian` · `dsh-habit` · `dsh-memory` · `dsh-node-appearance` · `dsh-plugin-center` · `dsh-quick-toolbar` · `dsh-skill-mcp-center` · `dsh-ssid-panels` · `dsh-ssid-zh-ui` · `dsh-achievements`。
|
|
4
|
-
|
|
5
|
-
This plugin belongs to the **`@max-null/*` family** — a set of plugins that together form the **[SSID (思灵 · Seek Soul in Darkness)](https://github.com/Max-Null/seek-soul-in-darkness)** desktop experience.
|
|
6
|
-
|
|
7
|
-
一个面向 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 的**跨会话明文记忆插件**。遵循「一切皆插件」——它不修改 DSH 源码,声明 `name`/`inject`/`apply`,由 Loader 从 `cordis.yml` 加载。
|
|
8
|
-
|
|
9
|
-
## 设计原则
|
|
10
|
-
|
|
11
|
-
1. **写入即生效,人是例外干预者**:模型写入的记忆直接生效(`approved`),不再逐条等人放行;人保留随时查看、改写、删除、钉住或回滚的能力——人不在场不等于失控。
|
|
12
|
-
2. **可观测先于精准**:每条记忆是明文,`memory_list` 随时可见、`memory_forget` 随时删除——不存在"静默暗礁"。
|
|
13
|
-
3. **明文是人机共享的审计窗口**:记忆是可读文本,模型可自检其是否过期(有效性锚点会在所绑环境值变化后标记 `stale`),人可随时查看与改写。
|
|
14
|
-
4. **确定性且缓存安全**:BM25 关键词检索是存储的纯函数、无 LLM 调用;固定指引进 system-prompt section,`approved + injected` 记忆进 recall context(global 全量 +
|
|
15
|
-
|
|
16
|
-
## 截图
|
|
17
|
-
|
|
18
|
-
装完后在设置里多出「记忆」一项,可查看与管理跨会话记忆:
|
|
19
|
-
|
|
20
|
-
**入口:** 设置 → 记忆
|
|
21
|
-
|
|
22
|
-
| 设置入口与面板 |
|
|
23
|
-
|---|
|
|
24
|
-
|  |
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
## 用法
|
|
28
|
-
|
|
29
|
-
```bash
|
|
30
|
-
npm install @max-null/dsh-memory
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
在你的 `cordis.yml` 加一条(其余 storage / system-prompt / tools 由宿主已有;记忆的存储后端由插件自己注册):
|
|
34
|
-
|
|
35
|
-
```yaml
|
|
36
|
-
- id: memory
|
|
37
|
-
name: '@max-null/dsh-memory'
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
## 提供的服务与工具
|
|
41
|
-
|
|
42
|
-
- **服务** `ctx.memory`:`remember` / `list` / `search` / `forget` / `
|
|
43
|
-
-
|
|
44
|
-
- **注入**:`tool:memory` 指引 section(工具用法 + 常驻注入判据)+ `memory:self` 机制自述 + `memory:recall` 召回 context(global 的 `approved + injected` + 当前会话工作区的 `approved + injected`,带 `[memory:<id>:<namespace>]` 来源标记;摘要化 + 预算截断)
|
|
45
|
-
- **检索**:BM25(CJK 单字 + 2-gram,content 与 keywords 字段分离加权;中文多字查询精度显著优于单字切分);可选语义融合(见「可选配置」)
|
|
46
|
-
|
|
47
|
-
## 两层存储(global / project)
|
|
48
|
-
|
|
49
|
-
记忆按 `namespace` 分两层物理存储,各落在独立的明文 JSON:
|
|
50
|
-
|
|
51
|
-
| namespace | 默认位置 | 用途 |
|
|
52
|
-
|---|---|---|
|
|
53
|
-
| `global` | `$DSH_HOME/storages/memory.json` | 跨项目的个人偏好 |
|
|
54
|
-
| `project` | `<cwd>/.dsh/storages/memory_project_<hash>.json` | 跟随仓库的项目共识,可 git 分享 |
|
|
55
|
-
|
|
56
|
-
两个根都可用 config 覆盖(`globalRoot` / `projectRoot`)。`memory_list` / `memory_search` 不带 `namespace` 过滤时会同时查两层。旧版双重前缀文件名(`memory_project_memory_project_<hash>.json`)在打开时自动迁移为规范名。
|
|
57
|
-
|
|
58
|
-
## 跨工作区可见性(0.10.0 / 0.11.0)
|
|
59
|
-
|
|
60
|
-
project 记忆按**会话工作区**分文件存放,所以「工作区」就是可见性的边界。0.10.0 起补了三个方向——针对的是同一个盲区:**不知道存在**(检索是有意图的动作,搜不出自己不知道存在的东西)。
|
|
61
|
-
|
|
62
|
-
| 方向 | 机制 | 效果 |
|
|
63
|
-
|---|---|---|
|
|
64
|
-
| 向上 | **祖先链** | 在子目录里开会话能检索到父目录的记忆(反向不行,链是单向向上的),结果带 `..` / `../..` 来源标记 |
|
|
65
|
-
| 向下 | **子项目索引行** | 注入里多一行「本工作区下另有 N 个子项目带记忆:xxx(M 条)——用 memory_search 检索」 |
|
|
66
|
-
| 全局 | **记忆索引行** | 注入里多一行「索引:当前可见 N 条记忆,主题集中在 X(14)、Y(12)…——用 memory_search 检索」 |
|
|
67
|
-
|
|
68
|
-
三条边界:**写入永远只落当前工作区**(「我在这个项目里记的东西」不该被推理到别处);但**检索得到的记录就改得动**——`memory_update` / `memory_forget` 同样沿链定位;**注入路径只读当前工作区**(远处的记忆进检索、不进每轮成本)。
|
|
69
|
-
|
|
70
|
-
**0.11.0 起,「放错了层」有了显式的修正通道**:`memory_move(id, to)` 把一条记忆移到 `global`、`self`(当前工作区)或相对祖先(`..` / `../..`)。它是**例外动作**——默认写入仍然只落当前工作区,只有当你判定「这条其实属于上一层、或上一层的那一支」时才动它。移动**保持 id**、**从源层删除**(不是复制),并报告移动后源层是否仍看得见它:向上移动源层不失明(继承只向上,`global` 人人可见),向下移动则会让源层及其下层从此看不见它。典型用途是**把放高了的记忆降回它真正属于的工作区**——比如只属于某一个生态的规矩躺在 `global` 里、漏进了无关项目。判据与边界见设计文档 §3.2。
|
|
71
|
-
|
|
72
|
-
后两行**不占注入预算**、自消除(没有内容时整行不出现),且**长度常数级**——不随记忆增长。索引行里的主题取自各条的关键词词频,与检索用的是同一套词汇,所以**索引里出现的词就是能搜到的词**。
|
|
73
|
-
|
|
74
|
-
**孤儿文件清点**:`node scripts/scan-orphans.mjs [根目录]` 报出「哪些记忆文件当前代码已经打不开」(历史命名遗留:早期无哈希后缀、双重 `memory_project_` 前缀)。**只读,不做任何处置**——旧文件可能含未过隔离检查的内容,处置需人工拍板。
|
|
75
|
-
|
|
76
|
-
**祖先链诊断与探针**:`node scripts/ancestor-probe.mjs plan [--cwd <会话 cwd>]` 只读地复算该 cwd 的祖先链(哪些层级带记忆文件、引擎会纳入几级),并回答「这个 cwd 上祖先链是否**可观测**」——若以上祖先层都没有记忆文件,祖先链生效与否行为逐字相同,那是个零差异的观测点。要实测检索侧跨链,用 `seed` / `check` / `clean` 在祖先层造一条临时探针再撤销。**前提**:探针只对**没打开过该层**的会话有效——引擎的表打开即缓存,而写路径的新鲜度门(`refreshForWrite`)只比对 global 与当前 cwd,**不含祖先层**,被缓存过的层会用陈旧内存态覆盖掉外部写入。
|
|
77
|
-
|
|
78
|
-
## 多实例共存(0.7.1)
|
|
79
|
-
|
|
80
|
-
两个 DSH 实例(例如 DSH web 与 SSiD 桌面壳)可以同时运行、共用同一个 `DSH_HOME`,**记忆不再互相抹掉**。
|
|
81
|
-
|
|
82
|
-
DSH 存储层的写入是「读—改—写全量覆盖」,且官方两个后端都声明不做跨进程协调(`storage-json`:*writer per process and last-write-wins is correct*;`storage-sqlite`:*cross-process coordination is out of scope*)。插件层的处置是**写前重读**(等价于 update 前先 select):每次写入在串行区里先比对存储文件指纹(mtime + size),发现磁盘被别的实例动过就先重载再写。代价是磁盘没变时的一次 `statSync`。
|
|
83
|
-
|
|
84
|
-
残余窗口只剩两个实例在**同一瞬间**写——此时后写者赢;同时写**不同**记忆已不再互相影响。
|
|
85
|
-
|
|
86
|
-
**未覆盖**:`workspace.json`(工作区登记)由 DSH 自己的 workspace 服务写,插件层够不着,仍会被双实例互相覆盖;根因处置在上游——[discussion #6882](https://github.com/deepseek-ai/deepseek-harness/discussions/6882)。当前防线是轮转快照备份(SSiD 侧 `shell/scripts/backup-storages.mjs` + 计划任务)。
|
|
87
|
-
|
|
88
|
-
##
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
```
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
1
|
+
# dsh-memory
|
|
2
|
+
|
|
3
|
+
本插件属于 **`@max-null/*` 插件系列**——这一系列共同构成 **[SSID(思灵 · Seek Soul in Darkness)](https://github.com/Max-Null/seek-soul-in-darkness)** 桌面体验。SSID 是整合它们的盒:`dsh-capture` · `dsh-chat-rail` · `dsh-chinese-thinking` · `dsh-draft-polish` · `dsh-guardian` · `dsh-habit` · `dsh-memory` · `dsh-node-appearance` · `dsh-plugin-center` · `dsh-quick-toolbar` · `dsh-skill-mcp-center` · `dsh-ssid-panels` · `dsh-ssid-zh-ui` · `dsh-achievements`。
|
|
4
|
+
|
|
5
|
+
This plugin belongs to the **`@max-null/*` family** — a set of plugins that together form the **[SSID (思灵 · Seek Soul in Darkness)](https://github.com/Max-Null/seek-soul-in-darkness)** desktop experience.
|
|
6
|
+
|
|
7
|
+
一个面向 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 的**跨会话明文记忆插件**。遵循「一切皆插件」——它不修改 DSH 源码,声明 `name`/`inject`/`apply`,由 Loader 从 `cordis.yml` 加载。
|
|
8
|
+
|
|
9
|
+
## 设计原则
|
|
10
|
+
|
|
11
|
+
1. **写入即生效,人是例外干预者**:模型写入的记忆直接生效(`approved`),不再逐条等人放行;人保留随时查看、改写、删除、钉住或回滚的能力——人不在场不等于失控。
|
|
12
|
+
2. **可观测先于精准**:每条记忆是明文,`memory_list` 随时可见、`memory_forget` 随时删除——不存在"静默暗礁"。
|
|
13
|
+
3. **明文是人机共享的审计窗口**:记忆是可读文本,模型可自检其是否过期(有效性锚点会在所绑环境值变化后标记 `stale`),人可随时查看与改写。
|
|
14
|
+
4. **确定性且缓存安全**:BM25 关键词检索是存储的纯函数、无 LLM 调用;固定指引进 system-prompt section,`approved + injected` 记忆进 recall context(global 全量 + 当前会话工作区),逐条为单行摘要并按注入预算截断(超预算按最近更新优先取舍,省略数在面板可见)。
|
|
15
|
+
|
|
16
|
+
## 截图
|
|
17
|
+
|
|
18
|
+
装完后在设置里多出「记忆」一项,可查看与管理跨会话记忆:
|
|
19
|
+
|
|
20
|
+
**入口:** 设置 → 记忆
|
|
21
|
+
|
|
22
|
+
| 设置入口与面板 |
|
|
23
|
+
|---|
|
|
24
|
+
|  |
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
## 用法
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm install @max-null/dsh-memory
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
在你的 `cordis.yml` 加一条(其余 storage / system-prompt / tools 由宿主已有;记忆的存储后端由插件自己注册):
|
|
34
|
+
|
|
35
|
+
```yaml
|
|
36
|
+
- id: memory
|
|
37
|
+
name: '@max-null/dsh-memory'
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## 提供的服务与工具
|
|
41
|
+
|
|
42
|
+
- **服务** `ctx.memory`:`remember` / `list` / `search` / `forget` / `update` / `move` / `setStatus` / `setInjected` / `supersede` / `retract` / `sweep` / `demoteStale` / `verifyAnchors` / `reload` 等
|
|
43
|
+
- **工具**(12 个):`memory_save`、`memory_list`、`memory_search`、`memory_update`、`memory_confirm`、`memory_forget`、`memory_move`、`memory_sweep`,加提示词模板四件套 `prompt_search`、`prompt_get`、`prompt_list`、`prompt_add`
|
|
44
|
+
- **注入**:`tool:memory` 指引 section(工具用法 + 常驻注入判据)+ `memory:self` 机制自述 + `memory:recall` 召回 context(global 的 `approved + injected` + 当前会话工作区的 `approved + injected`,带 `[memory:<id>:<namespace>]` 来源标记;摘要化 + 预算截断)
|
|
45
|
+
- **检索**:BM25(CJK 单字 + 2-gram,content 与 keywords 字段分离加权;中文多字查询精度显著优于单字切分);可选语义融合(见「可选配置」)
|
|
46
|
+
|
|
47
|
+
## 两层存储(global / project)
|
|
48
|
+
|
|
49
|
+
记忆按 `namespace` 分两层物理存储,各落在独立的明文 JSON:
|
|
50
|
+
|
|
51
|
+
| namespace | 默认位置 | 用途 |
|
|
52
|
+
|---|---|---|
|
|
53
|
+
| `global` | `$DSH_HOME/storages/memory.json` | 跨项目的个人偏好 |
|
|
54
|
+
| `project` | `<cwd>/.dsh/storages/memory_project_<hash>.json` | 跟随仓库的项目共识,可 git 分享 |
|
|
55
|
+
|
|
56
|
+
两个根都可用 config 覆盖(`globalRoot` / `projectRoot`)。`memory_list` / `memory_search` 不带 `namespace` 过滤时会同时查两层。旧版双重前缀文件名(`memory_project_memory_project_<hash>.json`)在打开时自动迁移为规范名。
|
|
57
|
+
|
|
58
|
+
## 跨工作区可见性(0.10.0 / 0.11.0)
|
|
59
|
+
|
|
60
|
+
project 记忆按**会话工作区**分文件存放,所以「工作区」就是可见性的边界。0.10.0 起补了三个方向——针对的是同一个盲区:**不知道存在**(检索是有意图的动作,搜不出自己不知道存在的东西)。
|
|
61
|
+
|
|
62
|
+
| 方向 | 机制 | 效果 |
|
|
63
|
+
|---|---|---|
|
|
64
|
+
| 向上 | **祖先链** | 在子目录里开会话能检索到父目录的记忆(反向不行,链是单向向上的),结果带 `..` / `../..` 来源标记 |
|
|
65
|
+
| 向下 | **子项目索引行** | 注入里多一行「本工作区下另有 N 个子项目带记忆:xxx(M 条)——用 memory_search 检索」 |
|
|
66
|
+
| 全局 | **记忆索引行** | 注入里多一行「索引:当前可见 N 条记忆,主题集中在 X(14)、Y(12)…——用 memory_search 检索」 |
|
|
67
|
+
|
|
68
|
+
三条边界:**写入永远只落当前工作区**(「我在这个项目里记的东西」不该被推理到别处);但**检索得到的记录就改得动**——`memory_update` / `memory_forget` 同样沿链定位;**注入路径只读当前工作区**(远处的记忆进检索、不进每轮成本)。
|
|
69
|
+
|
|
70
|
+
**0.11.0 起,「放错了层」有了显式的修正通道**:`memory_move(id, to)` 把一条记忆移到 `global`、`self`(当前工作区)或相对祖先(`..` / `../..`)。它是**例外动作**——默认写入仍然只落当前工作区,只有当你判定「这条其实属于上一层、或上一层的那一支」时才动它。移动**保持 id**、**从源层删除**(不是复制),并报告移动后源层是否仍看得见它:向上移动源层不失明(继承只向上,`global` 人人可见),向下移动则会让源层及其下层从此看不见它。典型用途是**把放高了的记忆降回它真正属于的工作区**——比如只属于某一个生态的规矩躺在 `global` 里、漏进了无关项目。判据与边界见设计文档 §3.2。
|
|
71
|
+
|
|
72
|
+
后两行**不占注入预算**、自消除(没有内容时整行不出现),且**长度常数级**——不随记忆增长。索引行里的主题取自各条的关键词词频,与检索用的是同一套词汇,所以**索引里出现的词就是能搜到的词**。
|
|
73
|
+
|
|
74
|
+
**孤儿文件清点**:`node scripts/scan-orphans.mjs [根目录]` 报出「哪些记忆文件当前代码已经打不开」(历史命名遗留:早期无哈希后缀、双重 `memory_project_` 前缀)。**只读,不做任何处置**——旧文件可能含未过隔离检查的内容,处置需人工拍板。
|
|
75
|
+
|
|
76
|
+
**祖先链诊断与探针**:`node scripts/ancestor-probe.mjs plan [--cwd <会话 cwd>]` 只读地复算该 cwd 的祖先链(哪些层级带记忆文件、引擎会纳入几级),并回答「这个 cwd 上祖先链是否**可观测**」——若以上祖先层都没有记忆文件,祖先链生效与否行为逐字相同,那是个零差异的观测点。要实测检索侧跨链,用 `seed` / `check` / `clean` 在祖先层造一条临时探针再撤销。**前提**:探针只对**没打开过该层**的会话有效——引擎的表打开即缓存,而写路径的新鲜度门(`refreshForWrite`)只比对 global 与当前 cwd,**不含祖先层**,被缓存过的层会用陈旧内存态覆盖掉外部写入。
|
|
77
|
+
|
|
78
|
+
## 多实例共存(0.7.1)
|
|
79
|
+
|
|
80
|
+
两个 DSH 实例(例如 DSH web 与 SSiD 桌面壳)可以同时运行、共用同一个 `DSH_HOME`,**记忆不再互相抹掉**。
|
|
81
|
+
|
|
82
|
+
DSH 存储层的写入是「读—改—写全量覆盖」,且官方两个后端都声明不做跨进程协调(`storage-json`:*writer per process and last-write-wins is correct*;`storage-sqlite`:*cross-process coordination is out of scope*)。插件层的处置是**写前重读**(等价于 update 前先 select):每次写入在串行区里先比对存储文件指纹(mtime + size),发现磁盘被别的实例动过就先重载再写。代价是磁盘没变时的一次 `statSync`。
|
|
83
|
+
|
|
84
|
+
残余窗口只剩两个实例在**同一瞬间**写——此时后写者赢;同时写**不同**记忆已不再互相影响。
|
|
85
|
+
|
|
86
|
+
**未覆盖**:`workspace.json`(工作区登记)由 DSH 自己的 workspace 服务写,插件层够不着,仍会被双实例互相覆盖;根因处置在上游——[discussion #6882](https://github.com/deepseek-ai/deepseek-harness/discussions/6882)。当前防线是轮转快照备份(SSiD 侧 `shell/scripts/backup-storages.mjs` + 计划任务)。
|
|
87
|
+
|
|
88
|
+
## 定时备份(2026-10-18)
|
|
89
|
+
|
|
90
|
+
**动机**:2026-10-18 的事故——`.dsh` 下的 storages 被外部整目录删除后,本插件的写入路径(读—改—写全量覆盖)在空磁盘上把内存里的 global 记忆覆盖成一条;而当时 `memory.json` **没有任何一份存在于 `.dsh` 之外**,无从恢复。
|
|
91
|
+
|
|
92
|
+
**机制**:插件启动后每小时(可配)把记忆文件**只读复制**到 `<home>/.dsh-backups/memory/<YYYYMMDD-HHmmss>/`,并写一份 `manifest.json` 记录来源与文件清单。**插件关闭(dispose)时再补一次**——定时器只在运行期间生效,关 DSH 前最后一段变更靠这一次兜住。保留最近 14 份(可配),超期自动清理。
|
|
93
|
+
|
|
94
|
+
**无界面**:这是行为类功能,不新增按钮、面板或设置项——行为效果即上面那段:部署后 `~/.dsh-backups/memory/` 下每小时多一个目录,目录里是当次复制出的 `memory.json`、`memory_project_*.json` 与 `manifest.json`。
|
|
95
|
+
|
|
96
|
+
| 项 | 默认值 |
|
|
97
|
+
|---|---|
|
|
98
|
+
| 备份内容 | `memory.json`(global)+ `memory_project_*.json`(project) |
|
|
99
|
+
| 目标目录 | `~/.dsh-backups/memory/<时间戳>/`(**在 `DSH_HOME` 之外**) |
|
|
100
|
+
| 间隔 | 60 分钟(首次延迟 1 分钟);**插件关闭时另补一次** |
|
|
101
|
+
| 保留 | 最近 14 份 |
|
|
102
|
+
| 失败行为 | 只写日志,不影响记忆读写与启动 |
|
|
103
|
+
|
|
104
|
+
**边界**:
|
|
105
|
+
|
|
106
|
+
- 只备份**当前工作区**的 project 文件(`<cwd>/.dsh/storages/`);其它工作区的 project 记忆不在备份范围。
|
|
107
|
+
- 只读文件、只复制——**绝不复用引擎的写入路径**。写路径会先 `reload()` 再合并,磁盘缺失时它会把内存覆盖掉,正是这次事故的成因;备份不能走同一条路。
|
|
108
|
+
|
|
109
|
+
## 使用流程(写入即生效,人为例外干预)
|
|
110
|
+
|
|
111
|
+
提示词模板库(0.6.0):`prompt_search / prompt_get / prompt_list / prompt_add` 四个工具管理**模板库**——
|
|
112
|
+
md 文件是唯一事实源(`~/.dsh/prompt-library/*.md` 为 global;`<workspace>/.dsh/prompt-library/` 随工作区分享),
|
|
113
|
+
前端(记忆面板「模板」tab / 模型工具)检索同一份索引;模板存在即生效(`source: agent` 角标标识模型新增),
|
|
114
|
+
**永不注入 system prompt**。
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
模型 memory_save → status: approved,立即生效;命中密钥/凭据规则则隔离(不进检索也不进注入)
|
|
118
|
+
给 injected 则同时钉住常驻(0.8.0),不受自动规则影响
|
|
119
|
+
memory_search → 关键词/语义召回(只搜记忆;模板走 prompt_search 通道,不会混进候选池)
|
|
120
|
+
人(面板 / 开关) → 钉住 / 删除 / 放行隔离记录 / 回滚
|
|
121
|
+
memory_forget → 随时删除(删除始终是人的动作)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
**常驻开关只由人(或模型显式传 `injected`)决定。** 两条自动规则都已在 0.12.0 / 0.13.0 停用:命中次数不再自动升常驻(它只喂体检报告的候选提示),30 天未命中的自动撤下也改为**只计量不生效**——撤下来就基本不会自己回去,那种动作不该由一个没有分布依据的阈值决定(阈值来历见 `docs/设计/2026-09-29-借阅-dsh-mneme-后的自查.md` §二)。两者都不删除任何内容——记忆不会无限累积,也不会被系统自行清空。
|
|
125
|
+
|
|
126
|
+
**低频但关键**的记忆(长期约定 / 判据 / 委托)本来就够不着旧阈值,而自动规则停用之后,问题从「够不着」变成了「必须显式说」。这类走显式路径——在 `memory_save` / `memory_update` 里给 `injected` 即钉住,写的是与面板开关同一套语义(`injectedAuto: false`)。未审核与已隔离的记录不接受注入。
|
|
127
|
+
|
|
128
|
+
注入有**字符预算**(默认 1500,config `injectionBudget`):装不下的条目**整条丢弃**(不截内容),按「最近更新优先」取舍——所以 `injected: true` 不等于「每轮真的在场」,它只保证有资格排队。0.9.0 起这种出局不再无声:注入末尾会追加一行 `(另有 N 条常驻因预算未注入:…)`,0.9.1 起列出的是**每条的短摘要**而不是 id——查 id 是什么的那一步最容易省略,省略了就等于没报;**清理干净即自行消失**。面板的注入预览里也能展开看明细。诊断行本身不占预算。
|
|
129
|
+
|
|
130
|
+
记忆还可声明**有效性锚点**(环境变量 / 工具清单 / 插件版本):所绑的值变化后,这条记忆被标记 `stale` 并撤下常驻,检索结果里也带出失效提示——环境变了,旧结论就不再被当成仍然正确。
|
|
131
|
+
|
|
132
|
+
模型写入不需要人点头,但**人始终能看见并推翻**:面板按「隔离区 / 常驻 / 冷数据」分组,被隔离的记录必须由人放行才会重新进入检索。
|
|
133
|
+
|
|
134
|
+
## 为什么明文 + BM25,而不是向量检索
|
|
135
|
+
|
|
136
|
+
向量检索的记忆本体是一串不可读的数字,过期信息会成为**无法观测、无法修复的静默暗礁**;BM25 + 明文让每一次召回都可解释、每一条记忆都可见可删。语义(向量)检索作为**可插拔的可选项**(0.5.2,见「可选配置」)——记忆本体仍是明文,向量仅作为检索辅助字段(`vector`,明文可读),且以"可观测 + 可修复"为门槛。
|
|
137
|
+
|
|
138
|
+
明文还有一层**跟随仓库分享**的好处:`project` 命名空间的记忆落在项目文件夹内(`<cwd>/.dsh/storages/memory_project_<hash>.json`),随 `git` 提交、分享给所有协作者;`global` 命名空间的记忆留在本地 `$DSH_HOME`。团队的共识("本项目统一用 Vue3 `<script setup>`")能沉淀进仓库,而不是散落在每个人的本地。FTS5 的 SQLite 二进制无法这样"跟着仓库走"。
|
|
139
|
+
|
|
140
|
+
## 可选配置
|
|
141
|
+
|
|
142
|
+
在 `cordis.yml` 的 `config` 里传给插件(均可省略):
|
|
143
|
+
|
|
144
|
+
```yaml
|
|
145
|
+
- id: memory
|
|
146
|
+
name: '@max-null/dsh-memory'
|
|
147
|
+
config:
|
|
148
|
+
injectionBudget: 1500 # 常驻注入预算(字符;null = 不限制)
|
|
149
|
+
summaryChars: 80 # 单条注入摘要截断上限(字符)
|
|
150
|
+
semanticTopK: 5 # 语义侧参与融合的 topK(仅配置 embeddings 时生效)
|
|
151
|
+
# 混合语义检索(可插拔;缺省 = 纯 BM25)
|
|
152
|
+
# embeddings: { embed: '...' 提供 embed(texts) 的宿主函数 或 插件名 ... }
|
|
153
|
+
# 定时备份(2026-10-18;缺省启用,目标在 DSH_HOME 之外)
|
|
154
|
+
backup:
|
|
155
|
+
intervalMinutes: 60 # 间隔(分钟)
|
|
156
|
+
keep: 14 # 保留份数
|
|
157
|
+
# targetRoot: 'D:/dsh-backups/memory' # 自定义备份根
|
|
158
|
+
# enabled: false # 关掉
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
`embeddings` 接受 `{ embed(texts): Promise<number[][]>, similarity? }` 对象——由宿主装配层提供嵌入实现(如 DeepSeek 嵌入端点);配置后 `memory_search` 以 BM25 + 语义 RRF 融合,向量增量生成并持久化,嵌入调用失败自动降级为纯 BM25。
|
|
162
|
+
|
|
163
|
+
## SSID 系列
|
|
164
|
+
|
|
165
|
+
本插件是 **[SSID(思灵 · Seek Soul in Darkness)](https://github.com/Max-Null/seek-soul-in-darkness)** 全家桶的一员;也可以单独安装到任意 DSH profile——除设置页的记忆面板外不依赖其它同系列插件。
|
|
166
|
+
|
|
167
|
+
## 开发
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
npm install
|
|
171
|
+
npm run typecheck # tsc 严格类型检查
|
|
172
|
+
npm test # vitest 单测
|
|
173
|
+
npm run build # 产出 dist/
|
|
174
|
+
node scripts/verify-loader.mjs # 用 Loader 端到端验证插件可加载
|
|
175
|
+
node scripts/scan-orphans.mjs [根目录] # 清点「当前代码打不开」的记忆文件(只读)
|
|
176
|
+
node scripts/unzstd-frames.mjs <源> <目标> # 解多帧 zstd(读 DSH 会话日志用)
|
|
177
|
+
node scripts/ancestor-probe.mjs plan [--cwd <cwd>] # 复算祖先链(只读);seed/check/clean 验 ④-A
|
|
178
|
+
node scripts/analyze-cooccurrence.mjs # 关键词共现分析(记忆之间有没有关系网,只读)
|
|
179
|
+
npm run check:memory # 记忆文件存量体检(只读;0 = 全绿 / 1 = 有不合规 / 2 = 用法错)
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## 依赖(peerDependencies,由宿主提供)
|
|
183
|
+
|
|
184
|
+
`@deepseek-ai/cordis`(`^4.0.1`),以及 `@deepseek-ai/dsh-storage`、`@deepseek-ai/dsh-storage-domain`、`@deepseek-ai/dsh-storage-json`、`@deepseek-ai/dsh-system-prompt`、`@deepseek-ai/dsh-tools`、`@deepseek-ai/dsh-typert-protocol`——后面六个声明为 **`>=0.1.1-rc.1 <0.3.0`**。
|
|
185
|
+
|
|
186
|
+
**为什么写显式范围而不是 caret**:caret 的上界是「下一个 minor」——`^0.1.1-rc.1` 展开为 `>=0.1.1-rc.1 <0.2.0-0`,内核一跨 minor 就判定不满足。而 peer 不满足时插件**不会被加载,也不会出现在 `did not activate` 列表里**:它在进入 fiber 图之前就被跳过,界面不报错,只表现为少一批功能。写成显式范围让 0.1 线与 0.2 线都能装载。
|
|
187
|
+
|
|
188
|
+
**验证过的内核**:`0.1.1-rc.2`(原基线)与 `0.2.0-rc.1`(0.12.2 起——typecheck 用其类型定义零错误,并在隔离实例实机加载,三个失败信号全为 0)。下限保持原值不变:它是作者实际验证过的最早版本,比它更早的内核没验证过,不声称支持。
|
package/dist/backup.d.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 记忆定时备份:把 `memory.json`(global)与 `memory_project_*.json`(project)复制到
|
|
3
|
+
* `DSH_HOME` 之外。
|
|
4
|
+
*
|
|
5
|
+
* 存在的理由(2026-10-18 事故):`.dsh` 下的 storages 被外部整目录删除后,dsh-memory 的
|
|
6
|
+
* 写入路径是「reload → 合并 → 写」——空磁盘让 reload 得到空库,写入把内存里的 global
|
|
7
|
+
* 记忆覆盖成一条;而当时 `memory.json` 没有任何一份存在于 `.dsh` 之外,无从恢复。
|
|
8
|
+
*
|
|
9
|
+
* 铁律:这里**只读文件、只复制**,绝不复用引擎的写入路径、不碰内存状态。备份失败只记
|
|
10
|
+
* 日志,绝不影响记忆本身的读写与启动。
|
|
11
|
+
*
|
|
12
|
+
* @module dsh-memory/backup
|
|
13
|
+
*/
|
|
14
|
+
/** 定时备份的配置(`MemoryConfig.backup`)。 */
|
|
15
|
+
export interface MemoryBackupConfig {
|
|
16
|
+
/** 是否启用;缺省启用。 */
|
|
17
|
+
enabled?: boolean;
|
|
18
|
+
/** 备份间隔(分钟);默认 60。 */
|
|
19
|
+
intervalMinutes?: number;
|
|
20
|
+
/** 保留最近多少份;默认 14。 */
|
|
21
|
+
keep?: number;
|
|
22
|
+
/** 备份根目录;默认 `~/.dsh-backups/memory`(必须在 `DSH_HOME` 之外)。 */
|
|
23
|
+
targetRoot?: string;
|
|
24
|
+
}
|
|
25
|
+
/** {@link runMemoryBackup} 的入参。 */
|
|
26
|
+
export interface MemoryBackupOptions {
|
|
27
|
+
/** global 记忆的根(`$DSH_HOME/storages`)。 */
|
|
28
|
+
globalRoot: string;
|
|
29
|
+
/** project 记忆的根(`<cwd>/.dsh/storages`)。 */
|
|
30
|
+
projectRoot: string;
|
|
31
|
+
/** 备份根目录。 */
|
|
32
|
+
targetRoot: string;
|
|
33
|
+
/** 保留份数(≥1)。 */
|
|
34
|
+
keep: number;
|
|
35
|
+
/** 当前时间;测试可注入。 */
|
|
36
|
+
now?: Date;
|
|
37
|
+
/** 日志出口;缺省丢弃。 */
|
|
38
|
+
log?: (text: string) => void;
|
|
39
|
+
}
|
|
40
|
+
/** 一次备份的结果。 */
|
|
41
|
+
export interface MemoryBackupRun {
|
|
42
|
+
/** 本次写入的目录;没有可备份文件时为 `null`。 */
|
|
43
|
+
readonly dir: string | null;
|
|
44
|
+
/** 实际复制的文件名。 */
|
|
45
|
+
readonly files: readonly string[];
|
|
46
|
+
/** 本次清理掉的旧目录名。 */
|
|
47
|
+
readonly pruned: readonly string[];
|
|
48
|
+
/** 失败原因;成功时缺省。 */
|
|
49
|
+
readonly error?: string;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* 把两个根下的记忆文件复制进一份新目录。
|
|
53
|
+
*
|
|
54
|
+
* 目标目录按 `now` 命名;同名目录已存在时追加 `-1`、`-2`……每个文件独立复制,单个失败
|
|
55
|
+
* 只告警、不中断其余文件;`manifest.json` 记录本次来源与文件清单,便于事后核对。
|
|
56
|
+
* 没有可复制文件时返回 `dir: null`,不创建空目录。
|
|
57
|
+
*
|
|
58
|
+
* @param options - 两个来源根、目标根、保留份数与时间注入点。
|
|
59
|
+
* @returns 本次目录、复制清单、清理清单与失败原因。
|
|
60
|
+
*/
|
|
61
|
+
export declare function runMemoryBackup(options: MemoryBackupOptions): MemoryBackupRun;
|
|
62
|
+
/** 备份目录是否像一个已存在的备份树(测试与自检用)。 */
|
|
63
|
+
export declare function isBackupDirectory(path: string): boolean;
|
package/dist/backup.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 记忆定时备份:把 `memory.json`(global)与 `memory_project_*.json`(project)复制到
|
|
3
|
+
* `DSH_HOME` 之外。
|
|
4
|
+
*
|
|
5
|
+
* 存在的理由(2026-10-18 事故):`.dsh` 下的 storages 被外部整目录删除后,dsh-memory 的
|
|
6
|
+
* 写入路径是「reload → 合并 → 写」——空磁盘让 reload 得到空库,写入把内存里的 global
|
|
7
|
+
* 记忆覆盖成一条;而当时 `memory.json` 没有任何一份存在于 `.dsh` 之外,无从恢复。
|
|
8
|
+
*
|
|
9
|
+
* 铁律:这里**只读文件、只复制**,绝不复用引擎的写入路径、不碰内存状态。备份失败只记
|
|
10
|
+
* 日志,绝不影响记忆本身的读写与启动。
|
|
11
|
+
*
|
|
12
|
+
* @module dsh-memory/backup
|
|
13
|
+
*/
|
|
14
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
/** 参与备份的名字:global 的 `memory.json` 与 project 的 `memory_project_*.json`。 */
|
|
17
|
+
const BACKUP_FILE = /^(?:memory\.json|memory_project_[A-Za-z0-9_-]+\.json)$/u;
|
|
18
|
+
/** 备份目录名:`YYYYMMDD-HHmmss`,可排序、可读。 */
|
|
19
|
+
function stamp(now) {
|
|
20
|
+
const pad = (value) => String(value).padStart(2, '0');
|
|
21
|
+
return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`
|
|
22
|
+
+ `-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
|
23
|
+
}
|
|
24
|
+
/** 列出根目录下参与备份的文件名;根不存在或不可读时返回空数组。 */
|
|
25
|
+
function candidateFiles(root, log) {
|
|
26
|
+
if (!existsSync(root))
|
|
27
|
+
return [];
|
|
28
|
+
try {
|
|
29
|
+
return readdirSync(root).filter((name) => BACKUP_FILE.test(name));
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
log(`memory backup: 无法读取 ${root}: ${error instanceof Error ? error.message : String(error)}`);
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* 把两个根下的记忆文件复制进一份新目录。
|
|
38
|
+
*
|
|
39
|
+
* 目标目录按 `now` 命名;同名目录已存在时追加 `-1`、`-2`……每个文件独立复制,单个失败
|
|
40
|
+
* 只告警、不中断其余文件;`manifest.json` 记录本次来源与文件清单,便于事后核对。
|
|
41
|
+
* 没有可复制文件时返回 `dir: null`,不创建空目录。
|
|
42
|
+
*
|
|
43
|
+
* @param options - 两个来源根、目标根、保留份数与时间注入点。
|
|
44
|
+
* @returns 本次目录、复制清单、清理清单与失败原因。
|
|
45
|
+
*/
|
|
46
|
+
export function runMemoryBackup(options) {
|
|
47
|
+
const log = options.log ?? (() => { });
|
|
48
|
+
const now = options.now ?? new Date();
|
|
49
|
+
const globalFiles = candidateFiles(options.globalRoot, log).map(name => ({ name, from: join(options.globalRoot, name) }));
|
|
50
|
+
const projectFiles = candidateFiles(options.projectRoot, log).map(name => ({ name, from: join(options.projectRoot, name) }));
|
|
51
|
+
const sources = [...globalFiles, ...projectFiles];
|
|
52
|
+
if (sources.length === 0) {
|
|
53
|
+
log('memory backup: 没有可备份的记忆文件');
|
|
54
|
+
return { dir: null, files: [], pruned: [] };
|
|
55
|
+
}
|
|
56
|
+
let base = join(options.targetRoot, stamp(now));
|
|
57
|
+
let dir = base;
|
|
58
|
+
for (let suffix = 1; existsSync(dir); suffix += 1)
|
|
59
|
+
dir = `${base}-${String(suffix)}`;
|
|
60
|
+
try {
|
|
61
|
+
mkdirSync(dir, { recursive: true });
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
65
|
+
log(`memory backup: 无法创建 ${dir}: ${message}`);
|
|
66
|
+
return { dir: null, files: [], pruned: [], error: message };
|
|
67
|
+
}
|
|
68
|
+
const files = [];
|
|
69
|
+
for (const source of sources) {
|
|
70
|
+
try {
|
|
71
|
+
copyFileSync(source.from, join(dir, source.name));
|
|
72
|
+
files.push(source.name);
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
log(`memory backup: 复制 ${source.name} 失败: ${error instanceof Error ? error.message : String(error)}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
writeFileSync(join(dir, 'manifest.json'), `${JSON.stringify({
|
|
80
|
+
createdAt: now.toISOString(),
|
|
81
|
+
globalRoot: options.globalRoot,
|
|
82
|
+
projectRoot: options.projectRoot,
|
|
83
|
+
files,
|
|
84
|
+
}, null, 2)}\n`, 'utf8');
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
log(`memory backup: 写 manifest 失败: ${error instanceof Error ? error.message : String(error)}`);
|
|
88
|
+
}
|
|
89
|
+
return { dir, files, pruned: pruneBackups(options.targetRoot, options.keep, log) };
|
|
90
|
+
}
|
|
91
|
+
/** 只保留最近 `keep` 份备份目录(按名字排序即时间序),多余的删除。 */
|
|
92
|
+
function pruneBackups(targetRoot, keep, log) {
|
|
93
|
+
if (!existsSync(targetRoot))
|
|
94
|
+
return [];
|
|
95
|
+
let entries;
|
|
96
|
+
try {
|
|
97
|
+
entries = readdirSync(targetRoot).filter((name) => /^\d{8}-\d{6}(?:-\d+)?$/u.test(name));
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
log(`memory backup: 无法读取 ${targetRoot}: ${error instanceof Error ? error.message : String(error)}`);
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
entries.sort();
|
|
104
|
+
const pruned = [];
|
|
105
|
+
const excess = entries.length - Math.max(1, keep);
|
|
106
|
+
for (let index = 0; index < excess; index += 1) {
|
|
107
|
+
const name = entries[index];
|
|
108
|
+
if (name === undefined)
|
|
109
|
+
break;
|
|
110
|
+
try {
|
|
111
|
+
rmSync(join(targetRoot, name), { recursive: true, force: true });
|
|
112
|
+
pruned.push(name);
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
log(`memory backup: 清理 ${name} 失败: ${error instanceof Error ? error.message : String(error)}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return pruned;
|
|
119
|
+
}
|
|
120
|
+
/** 备份目录是否像一个已存在的备份树(测试与自检用)。 */
|
|
121
|
+
export function isBackupDirectory(path) {
|
|
122
|
+
try {
|
|
123
|
+
return statSync(path).isDirectory();
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
}
|
package/dist/engine.d.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { Service } from '@deepseek-ai/cordis';
|
|
8
8
|
import { type PromptFile } from './prompt-files.ts';
|
|
9
|
+
import type { MemoryBackupConfig } from './backup.ts';
|
|
9
10
|
import type { AnchorProbes, MemoryAnchor } from './anchors.ts';
|
|
10
11
|
import type { MemorySource, SourceStat } from './query-log.ts';
|
|
11
12
|
export type { MemorySource, QueryLogEntry, SourceStat } from './query-log.ts';
|
|
@@ -312,6 +313,12 @@ export interface MemoryConfig {
|
|
|
312
313
|
/** 全局提示词模板根目录(0.6.0);默认 `$DSH_HOME/prompt-library`。
|
|
313
314
|
* project 模板随工作区(`<workspace>/.dsh/prompt-library`,与记忆同构)。 */
|
|
314
315
|
promptGlobalRoot?: string;
|
|
316
|
+
/**
|
|
317
|
+
* 定时备份(2026-10-18 事故后新增):把 `memory.json` 与 `memory_project_*.json`
|
|
318
|
+
* 复制到 `DSH_HOME` 之外的目录,默认每小时一份、保留 14 份。
|
|
319
|
+
* 实现只读文件、只复制,见 `backup.ts` 的文件头。
|
|
320
|
+
*/
|
|
321
|
+
backup?: MemoryBackupConfig;
|
|
315
322
|
/**
|
|
316
323
|
* 查询日志文件路径(2026-09-15 §六.6);默认为全局存储根下的 `query-log.json`
|
|
317
324
|
* ——即与 `memory.json` **同目录**(`$DSH_HOME/storages/query-log.json`),不落子目录。
|
package/dist/index.d.ts
CHANGED
|
@@ -12,4 +12,15 @@ export { MemoryId } from './engine.ts';
|
|
|
12
12
|
export { bm25FieldScores, bm25Scores, cosineSimilarity, rrfFuse, tokenize } from './bm25.ts';
|
|
13
13
|
export declare const name = "dsh-memory";
|
|
14
14
|
export declare const inject: string[];
|
|
15
|
+
/**
|
|
16
|
+
* 本次挂载实际注册的工具名,按注册顺序。
|
|
17
|
+
*
|
|
18
|
+
* 存在的理由:**自述(`memory:self`)与 README 都要列工具清单,而清单会漏**——0.11.0 加了
|
|
19
|
+
* `memory_move` 之后,自述的数字与清单一直停在 11,直到 0.14.0 收尾核对才被翻出来(README
|
|
20
|
+
* 那边更早,只列了 7 个)。`ToolRegistry` 只提供 `register` / `get`,**没有列举 API**,所以
|
|
21
|
+
* 清单没法从注册表反查;改成注册时登记,清单就有了唯一来源。
|
|
22
|
+
*
|
|
23
|
+
* @see test/memory.spec.ts 的自述覆盖用例——它遍历这个数组,不手抄名字。
|
|
24
|
+
*/
|
|
25
|
+
export declare const registeredToolNames: string[];
|
|
15
26
|
export declare function apply(ctx: Context, config?: MemoryConfig): Promise<void>;
|
package/dist/index.js
CHANGED
|
@@ -4,9 +4,12 @@
|
|
|
4
4
|
* domain, system prompt, and tools plugins; it registers `ctx.memory`, four
|
|
5
5
|
* model tools, a stable guidance section, and a recall context.
|
|
6
6
|
*/
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
import { join } from 'node:path';
|
|
7
9
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
8
10
|
import { MemoryEngine } from "./engine.js";
|
|
9
11
|
import { MemoryGateway } from "./remote.js";
|
|
12
|
+
import { runMemoryBackup } from "./backup.js";
|
|
10
13
|
import { ANCHOR_KINDS } from "./anchors.js";
|
|
11
14
|
import { mountMemoryApi } from "./routes.js";
|
|
12
15
|
import { SELF_DESCRIPTION } from "./self.js";
|
|
@@ -15,7 +18,23 @@ export { MemoryEngine } from "./engine.js";
|
|
|
15
18
|
export { MemoryId } from "./engine.js";
|
|
16
19
|
export { bm25FieldScores, bm25Scores, cosineSimilarity, rrfFuse, tokenize } from "./bm25.js";
|
|
17
20
|
export const name = 'dsh-memory';
|
|
18
|
-
export const inject = ['storage', 'systemPrompt', 'tools', 'webServer', '
|
|
21
|
+
export const inject = ['storage', 'systemPrompt', 'tools', 'webServer', 'webStartup'];
|
|
22
|
+
/**
|
|
23
|
+
* 本次挂载实际注册的工具名,按注册顺序。
|
|
24
|
+
*
|
|
25
|
+
* 存在的理由:**自述(`memory:self`)与 README 都要列工具清单,而清单会漏**——0.11.0 加了
|
|
26
|
+
* `memory_move` 之后,自述的数字与清单一直停在 11,直到 0.14.0 收尾核对才被翻出来(README
|
|
27
|
+
* 那边更早,只列了 7 个)。`ToolRegistry` 只提供 `register` / `get`,**没有列举 API**,所以
|
|
28
|
+
* 清单没法从注册表反查;改成注册时登记,清单就有了唯一来源。
|
|
29
|
+
*
|
|
30
|
+
* @see test/memory.spec.ts 的自述覆盖用例——它遍历这个数组,不手抄名字。
|
|
31
|
+
*/
|
|
32
|
+
export const registeredToolNames = [];
|
|
33
|
+
/** 登记一个工具名并原样返回——包在 `defineTool` 的 `name` 字段外面。 */
|
|
34
|
+
function registered(toolName) {
|
|
35
|
+
registeredToolNames.push(toolName);
|
|
36
|
+
return toolName;
|
|
37
|
+
}
|
|
19
38
|
/**
|
|
20
39
|
* 解析时间过滤参数(0.12.0):`7d` / `30d` 这类相对天数,或 `YYYY-MM-DD` 绝对日期。
|
|
21
40
|
*
|
|
@@ -321,6 +340,9 @@ function present(title, kind, rawInput) {
|
|
|
321
340
|
return { card: 'generic', title, kind, ...rawInput === undefined ? {} : { rawInput } };
|
|
322
341
|
}
|
|
323
342
|
export async function apply(ctx, config) {
|
|
343
|
+
// 每次挂载都是一次全新的注册——清空上一轮的登记。它是模块级数组(测试要能 import 它),
|
|
344
|
+
// 不清的话同一进程里挂载两次会累积成双份,而消费方(自述覆盖用例)拿到的数量就对不上了。
|
|
345
|
+
registeredToolNames.length = 0;
|
|
324
346
|
await ctx.plugin(MemoryEngine, config);
|
|
325
347
|
await ctx.plugin(MemoryGateway);
|
|
326
348
|
// 0.3.6:自带面板数据通道(HTTP,独立包无需 Typert 构建产物)
|
|
@@ -388,7 +410,7 @@ export async function apply(ctx, config) {
|
|
|
388
410
|
void memory.ensureProjectOpen(cwd).catch(() => { });
|
|
389
411
|
});
|
|
390
412
|
ctx.tools.register(defineTool({
|
|
391
|
-
name: 'memory_save',
|
|
413
|
+
name: registered('memory_save'),
|
|
392
414
|
description: 'Save one cross-session memory. It takes effect immediately; credential-like content is quarantined instead. Record durable preferences, corrections and reusable conclusions — not transient state, guesses, or facts readable from the repo (record where to look instead). 保存一条跨会话记忆:写入即生效;命中密钥/凭据规则的写入会被隔离。只记长期偏好、纠正与可复用结论——不记临时状态、推测、或能从仓库读到的事实(那类记「去哪查」)。',
|
|
393
415
|
parameters: {
|
|
394
416
|
content: { type: 'string', required: true, description: 'Plaintext memory content.' },
|
|
@@ -425,7 +447,7 @@ export async function apply(ctx, config) {
|
|
|
425
447
|
presentCall: args => present('Save memory', 'other', args.content),
|
|
426
448
|
}));
|
|
427
449
|
ctx.tools.register(defineTool({
|
|
428
|
-
name: 'memory_list',
|
|
450
|
+
name: registered('memory_list'),
|
|
429
451
|
description: 'List every stored memory, optionally filtered by namespace, status, injected switch, or update time. Every memory is plaintext and inspectable. 列出全部记忆,可按 namespace/status/injected/更新时间过滤;均为明文可查。',
|
|
430
452
|
parameters: {
|
|
431
453
|
namespace: { type: 'string', enum: ['global', 'project'], description: 'Restrict to one namespace. 限定单个命名空间.' },
|
|
@@ -453,7 +475,7 @@ export async function apply(ctx, config) {
|
|
|
453
475
|
presentCall: () => present('List memories', 'read'),
|
|
454
476
|
}));
|
|
455
477
|
ctx.tools.register(defineTool({
|
|
456
|
-
name: 'memory_search',
|
|
478
|
+
name: registered('memory_search'),
|
|
457
479
|
description: 'Recall stored memories by keyword. Deterministic literal matching — a miss means no stored term matched the query. 按关键词检索记忆(中文 2-gram + 关键词加权;配置嵌入时含语义融合;含待审核条目,被隔离的除外)。',
|
|
458
480
|
parameters: {
|
|
459
481
|
query: { type: 'string', required: true, description: 'Keyword query. 关键词查询.' },
|
|
@@ -475,7 +497,7 @@ export async function apply(ctx, config) {
|
|
|
475
497
|
presentCall: args => present('Search memory', 'read', args.query),
|
|
476
498
|
}));
|
|
477
499
|
ctx.tools.register(defineTool({
|
|
478
|
-
name: 'prompt_search',
|
|
500
|
+
name: registered('prompt_search'),
|
|
479
501
|
description: 'Search prompt templates by keyword plus dimension/difficulty/tag filters. Returns matching summaries; call prompt_get for the full text. 按关键词+维度/难度/标签检索提示词模板,返回匹配摘要;取全文用 prompt_get。',
|
|
480
502
|
parameters: {
|
|
481
503
|
query: { type: 'string', required: true, description: 'Keyword query (name/tag/body). 关键词查询(名称/标签/正文).' },
|
|
@@ -517,7 +539,7 @@ export async function apply(ctx, config) {
|
|
|
517
539
|
presentCall: args => present('Search prompt library', 'read', args.query),
|
|
518
540
|
}));
|
|
519
541
|
ctx.tools.register(defineTool({
|
|
520
|
-
name: 'prompt_get',
|
|
542
|
+
name: registered('prompt_get'),
|
|
521
543
|
description: 'Fetch a prompt template full text by id or name (name matches the file stem without the seq prefix). 按 id 或名称取模板全文(名称 = 文件名去序号部分)。',
|
|
522
544
|
parameters: {
|
|
523
545
|
nameOrId: { type: 'string', required: true, description: 'Template id or name. 模板 id 或名称.' },
|
|
@@ -546,7 +568,7 @@ export async function apply(ctx, config) {
|
|
|
546
568
|
presentCall: args => present('Get prompt template', 'read', args.nameOrId),
|
|
547
569
|
}));
|
|
548
570
|
ctx.tools.register(defineTool({
|
|
549
|
-
name: 'prompt_list',
|
|
571
|
+
name: registered('prompt_list'),
|
|
550
572
|
description: 'List every prompt template index (no full text), optionally filtered by dimension/difficulty/tag. 列出全部模板索引(不含全文),可按维度/难度/标签过滤。',
|
|
551
573
|
parameters: {
|
|
552
574
|
dimension: { type: 'string', description: '按维度过滤.' },
|
|
@@ -575,7 +597,7 @@ export async function apply(ctx, config) {
|
|
|
575
597
|
presentCall: () => present('List prompt templates', 'read'),
|
|
576
598
|
}));
|
|
577
599
|
ctx.tools.register(defineTool({
|
|
578
|
-
name: 'prompt_add',
|
|
600
|
+
name: registered('prompt_add'),
|
|
579
601
|
description: 'Add one prompt template to the library (writes a metadata-marked md file; source=agent is surfaced in the UI). 新增一条提示词模板(写元数据 md 文件;source=agent 在 UI 角标提示)。',
|
|
580
602
|
parameters: {
|
|
581
603
|
name: { type: 'string', required: true, description: 'Template name (also the file stem). 模板名称(也是文件名).' },
|
|
@@ -605,7 +627,7 @@ export async function apply(ctx, config) {
|
|
|
605
627
|
presentCall: args => present('Add prompt template', 'other', args.name),
|
|
606
628
|
}));
|
|
607
629
|
ctx.tools.register(defineTool({
|
|
608
|
-
name: 'memory_forget',
|
|
630
|
+
name: registered('memory_forget'),
|
|
609
631
|
description: 'Delete one stored memory by id. The human owner may remove any memory.',
|
|
610
632
|
parameters: {
|
|
611
633
|
id: { type: 'string', required: true, description: 'Exact memory id from memory_list or memory_search.' },
|
|
@@ -620,7 +642,7 @@ export async function apply(ctx, config) {
|
|
|
620
642
|
presentCall: args => present('Forget memory', 'other', args.id),
|
|
621
643
|
}));
|
|
622
644
|
ctx.tools.register(defineTool({
|
|
623
|
-
name: 'memory_move',
|
|
645
|
+
name: registered('memory_move'),
|
|
624
646
|
description: 'Move one memory to another layer: `global`, `self` (the current workspace), or a relative ancestor (`..` / `../..`). Use it when a memory sits at the wrong level — a project-specific rule parked in `global` that leaks into unrelated workspaces, or a fact that turned out to hold for sibling projects too. The record keeps its id, and the result reports whether the source layer still sees it. 把一条记忆移到另一层:`global`、`self`(当前工作区)或相对祖先(`..` / `../..`)。用于记忆待错了层——只属于某个项目的规矩躺在 `global` 里、漏进无关工作区,或某条事实其实对兄弟项目也成立。id 保持不变,结果里会报告移动后源层是否仍看得见它。',
|
|
625
647
|
parameters: {
|
|
626
648
|
id: { type: 'string', required: true, description: 'Exact memory id from memory_list or memory_search. 要移动的记忆 id。' },
|
|
@@ -645,7 +667,7 @@ export async function apply(ctx, config) {
|
|
|
645
667
|
presentCall: args => present('Move memory', 'other', `${args.id} → ${args.to}`),
|
|
646
668
|
}));
|
|
647
669
|
ctx.tools.register(defineTool({
|
|
648
|
-
name: 'memory_update',
|
|
670
|
+
name: registered('memory_update'),
|
|
649
671
|
description: 'Update the content, keywords, persistent-injection switch or validity anchor of one stored memory (e.g. correcting stale facts, or re-anchoring a memory whose anchor no longer holds). The record keeps its review status; content that looks like a credential quarantines it instead. 修改一条记忆的内容、关键词、常驻注入开关或有效性锚点(如修正过时信息、把失效的锚点改对)。改动后记忆保持原有审核状态;若新内容命中危险规则则转为隔离。',
|
|
650
672
|
parameters: {
|
|
651
673
|
id: { type: 'string', required: true, description: 'Exact memory id from memory_list. 记忆 id(来自 memory_list).' },
|
|
@@ -696,7 +718,7 @@ export async function apply(ctx, config) {
|
|
|
696
718
|
presentCall: args => present('Update memory', 'other', args.id),
|
|
697
719
|
}));
|
|
698
720
|
ctx.tools.register(defineTool({
|
|
699
|
-
name: 'memory_confirm',
|
|
721
|
+
name: registered('memory_confirm'),
|
|
700
722
|
description: 'Release a quarantined memory: marks it human-reviewed (`approved`) and clears its quarantine. Under the silent mechanism ordinary writes already take effect by themselves, so call this only when the human explicitly asks to release a quarantined memory. 放行一条被隔离的记忆:标记为已审核并解除隔离。静默机制下普通写入已自行生效,因此仅在用户明确要求放行隔离记忆时调用。',
|
|
701
723
|
parameters: {
|
|
702
724
|
id: { type: 'string', required: true, description: 'Exact memory id from memory_list. 记忆 id(来自 memory_list).' },
|
|
@@ -711,7 +733,7 @@ export async function apply(ctx, config) {
|
|
|
711
733
|
presentCall: args => present('Confirm memory', 'other', args.id),
|
|
712
734
|
}));
|
|
713
735
|
ctx.tools.register(defineTool({
|
|
714
|
-
name: 'memory_sweep',
|
|
736
|
+
name: registered('memory_sweep'),
|
|
715
737
|
description: 'Read-only health check: lists stale memories (with the reason), long-unused non-resident ones, candidates that keep being retrieved without being pinned, and how much of the injection budget the resident set takes. Changes nothing. 只读体检:列出失效的记忆(带原因)、久未被检索命中的非常驻记录、被反复检索却未常驻的候选,以及常驻占用了多少注入预算。不修改任何状态——最坏情况是报告不准,而不是误改内容。',
|
|
716
738
|
parameters: {},
|
|
717
739
|
output: {
|
|
@@ -723,4 +745,31 @@ export async function apply(ctx, config) {
|
|
|
723
745
|
},
|
|
724
746
|
presentCall: () => present('Sweep memory', 'other', 'read-only'),
|
|
725
747
|
}));
|
|
748
|
+
// 定时备份(2026-10-18 事故后新增):只读复制,绝不走引擎的写入路径。
|
|
749
|
+
// 首次延迟 1 分钟避开启动高峰;此后按 `backup.intervalMinutes` 重复;`unref` 不挡进程退出。
|
|
750
|
+
const backup = config?.backup;
|
|
751
|
+
if (backup?.enabled !== false) {
|
|
752
|
+
const targetRoot = backup?.targetRoot ?? join(homedir(), '.dsh-backups', 'memory');
|
|
753
|
+
const intervalMs = Math.max(1, backup?.intervalMinutes ?? 60) * 60_000;
|
|
754
|
+
const keep = Math.max(1, backup?.keep ?? 14);
|
|
755
|
+
const roots = {
|
|
756
|
+
globalRoot: config?.globalRoot ?? join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'storages'),
|
|
757
|
+
projectRoot: config?.projectRoot ?? join(process.cwd(), '.dsh', 'storages'),
|
|
758
|
+
};
|
|
759
|
+
ctx.effect(() => {
|
|
760
|
+
const first = setTimeout(() => {
|
|
761
|
+
runMemoryBackup({ ...roots, targetRoot, keep });
|
|
762
|
+
}, 60_000);
|
|
763
|
+
const timer = setInterval(() => {
|
|
764
|
+
runMemoryBackup({ ...roots, targetRoot, keep });
|
|
765
|
+
}, intervalMs);
|
|
766
|
+
timer.unref();
|
|
767
|
+
return () => {
|
|
768
|
+
clearTimeout(first);
|
|
769
|
+
clearInterval(timer);
|
|
770
|
+
// 关闭时补一次:定时器只在运行期间生效,关 DSH 前最后一段变更靠这里兜住。
|
|
771
|
+
runMemoryBackup({ ...roots, targetRoot, keep });
|
|
772
|
+
};
|
|
773
|
+
});
|
|
774
|
+
}
|
|
726
775
|
}
|
package/dist/routes.js
CHANGED
|
@@ -121,9 +121,9 @@ function promptProjection(record) {
|
|
|
121
121
|
}
|
|
122
122
|
/** 挂载 /memory/api 路由(apply 调用)。 */
|
|
123
123
|
export function mountMemoryApi(ctx) {
|
|
124
|
-
// webServer/
|
|
124
|
+
// webServer/webStartup 由 index.ts 的 inject 声明(Context 类型未扩展,断言访问)
|
|
125
125
|
const webServer = ctx.webServer;
|
|
126
|
-
const
|
|
126
|
+
const webStartup = ctx.webStartup;
|
|
127
127
|
const api = {
|
|
128
128
|
'list': (payload) => {
|
|
129
129
|
const memory = ctx.get('memory');
|
|
@@ -265,7 +265,7 @@ export function mountMemoryApi(ctx) {
|
|
|
265
265
|
return memory.refreshPromptIndex(cwdOf((payload ?? {})));
|
|
266
266
|
},
|
|
267
267
|
};
|
|
268
|
-
const fence = (req) => isTrusted(req,
|
|
268
|
+
const fence = (req) => isTrusted(req, webStartup.trustedHosts);
|
|
269
269
|
ctx.effect(() => webServer.register({
|
|
270
270
|
kind: 'prefix',
|
|
271
271
|
path: '/memory/api',
|
package/dist/self.js
CHANGED
|
@@ -23,7 +23,7 @@ export const SELF_VERSION = (() => {
|
|
|
23
23
|
}
|
|
24
24
|
})();
|
|
25
25
|
export const SELF_DESCRIPTION = `[记忆系统自述] 本环境内置跨会话记忆服务(dsh-memory v${SELF_VERSION})。`
|
|
26
|
-
+ '你有
|
|
26
|
+
+ '你有 12 个工具:**记忆工具** memory_save / memory_search / memory_list / memory_update / memory_confirm / memory_forget / memory_move / memory_sweep(`memory_confirm` 仅在用户明确要求放行隔离记录时用;`memory_move` 把放错了层的记忆移到 global、当前工作区或相对祖先;`memory_sweep` 是只读体检——失效清单、久未命中、候选与预算占用,不修改任何状态);**提示词模板工具** prompt_search / prompt_get / prompt_list / prompt_add(模板是 md 文件、**永不注入**,走这条通道,不会混进记忆检索)。'
|
|
27
27
|
+ '记忆分两层:`global` 跨项目(偏好 / 习惯 / 环境知识),`project` 跟随工作区(落在 `<cwd>/.dsh/storages/`,随 git 提交分享)。'
|
|
28
28
|
+ '**何时写入**(不必刻意找机会,遇到下面这些就动手):'
|
|
29
29
|
+ '① 用户表达长期偏好或约定(「以后都…」「记住…」「我们的规矩是…」);'
|
|
@@ -34,7 +34,7 @@ export const SELF_DESCRIPTION = `[记忆系统自述] 本环境内置跨会话
|
|
|
34
34
|
+ '**锚点(可选)**:若这条记忆描述的是「随某个环境值变化的事实」(某工具版本下的行为、某环境变量决定的配置),用 anchor 参数把它绑到那个值上——值变了它会自动失效并标 stale,不会再被当成仍然正确;绑错了用 memory_update 改(当场重新判定,成立即复活),不要了就传 `anchor: null` 清除。当前锚点随记录一起返回。'
|
|
35
35
|
+ '写入即生效(`approved`),人工只在例外时介入;命中密钥/凭据规则的写入会被隔离(不进注入与检索)。'
|
|
36
36
|
+ '**常驻注入**:每轮注入「global + 当前会话工作区」里 approved 且开着常驻开关的记忆单行摘要(形如 `- [memory:id:namespace] 摘要`);全文要靠 memory_search 取。'
|
|
37
|
-
+ '
|
|
37
|
+
+ '常驻开关由人(或模型显式传 `injected`)决定:**自动改变注入状态的规则已全部停用**——命中次数不再自动升常驻(它只喂体检报告的候选提示),30 天未命中的自动撤下也已改为**只计量不生效**(撤下来就基本不会自己回去,那种动作不该由一个没有分布依据的阈值决定)。**给 memory_save / memory_update 传 `injected` 即显式接管**。'
|
|
38
38
|
+ '**钉的判据是「失效可察觉性」**:这条记忆若没被想起来,你会不会根本意识不到自己漏了它?会 → 钉常驻(规则 / 委托 / 判据);不会 → 留检索(事实 / 参考 / 案例)。'
|
|
39
39
|
+ '**注入有预算**(默认 1500 字符,约装 11 条),装不下的整条丢弃、按**最近更新**优先取舍(不是最近被检索——检索是「需要时才用」的行为,拿它决定「谁每轮在场」会让查得多的挤掉钉得牢的)——所以「钉了常驻」只等于「有资格排队」。'
|
|
40
40
|
+ '装不下时注入末尾会出现一行 `(另有 N 条常驻因预算未注入:…短摘要…)`——**那是「有常驻记忆并不在场」的唯一信号**,看到它就该逐条评估撤下。'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@max-null/dsh-memory",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Cross-session plaintext memory plugin for the DeepSeek Harness — deterministic BM25 recall, writes take effect immediately, signal-driven injection, no embeddings",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": {
|
|
@@ -86,6 +86,7 @@
|
|
|
86
86
|
"@deepseek-ai/dsh-tools": "0.2.0-rc.1",
|
|
87
87
|
"@deepseek-ai/dsh-typert-protocol": "0.2.0-rc.1",
|
|
88
88
|
"@types/node": "^26.2.0",
|
|
89
|
+
"esbuild": "^0.25.0",
|
|
89
90
|
"tsx": "^4.0.0",
|
|
90
91
|
"typescript": "^5.5.0",
|
|
91
92
|
"vitest": "^2.1.0"
|
package/dist/client/index.d.ts
DELETED
package/dist/client/index.js
DELETED
|
@@ -1,283 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @max-null/dsh-memory browser half (0.3.3): the memory panel, moved home
|
|
3
|
-
* from dsh-ssid-panels (2026-08-19 用户: 记忆面板应跟随 dsh-memory)。
|
|
4
|
-
* Data goes straight through the Typert `remote.memory` surface
|
|
5
|
-
* (list/search/confirm/forget/setInjected/reload) — no /ssid/api bridge,
|
|
6
|
-
* so any profile with dsh-memory gets the panel. Hosted on better-sidebar
|
|
7
|
-
* as an optional peer (same pattern dsh-ssid-panels used); without it the
|
|
8
|
-
* client half registers nothing.
|
|
9
|
-
*/
|
|
10
|
-
import { createElement, useEffect, useState } from 'react';
|
|
11
|
-
export const inject = ['slots', 'locale', 'remote', 'remote.memory'];
|
|
12
|
-
const STRINGS = {
|
|
13
|
-
zh: {
|
|
14
|
-
tabMemory: '记忆',
|
|
15
|
-
memorySearch: '搜索记忆…',
|
|
16
|
-
empty: '黑暗中未见灵光',
|
|
17
|
-
confirm: '确认',
|
|
18
|
-
forget: '删除',
|
|
19
|
-
groupPending: '待审核',
|
|
20
|
-
groupOnDemand: '已审核 · 按需',
|
|
21
|
-
groupInjected: '常驻注入',
|
|
22
|
-
injectSwitch: '常驻注入',
|
|
23
|
-
approveFirst: '审核通过后可常驻注入',
|
|
24
|
-
allNamespaces: '全部',
|
|
25
|
-
nsGlobal: '全局',
|
|
26
|
-
nsProject: '项目',
|
|
27
|
-
organizeMemory: '整理记忆',
|
|
28
|
-
confirmAll: '全部确认',
|
|
29
|
-
suggested: '待审核',
|
|
30
|
-
approved: '已审核',
|
|
31
|
-
refresh: '刷新',
|
|
32
|
-
},
|
|
33
|
-
en: {
|
|
34
|
-
tabMemory: 'Memory',
|
|
35
|
-
memorySearch: 'Search memory…',
|
|
36
|
-
empty: 'No spark in the dark',
|
|
37
|
-
confirm: 'Confirm',
|
|
38
|
-
forget: 'Forget',
|
|
39
|
-
groupPending: 'Pending review',
|
|
40
|
-
groupOnDemand: 'Approved · on demand',
|
|
41
|
-
groupInjected: 'Always injected',
|
|
42
|
-
injectSwitch: 'Inject every turn',
|
|
43
|
-
approveFirst: 'Approve to enable injection',
|
|
44
|
-
allNamespaces: 'All',
|
|
45
|
-
nsGlobal: 'Global',
|
|
46
|
-
nsProject: 'Project',
|
|
47
|
-
organizeMemory: 'Organize memory',
|
|
48
|
-
confirmAll: 'Approve all',
|
|
49
|
-
suggested: 'Suggested',
|
|
50
|
-
approved: 'Approved',
|
|
51
|
-
refresh: 'Refresh',
|
|
52
|
-
},
|
|
53
|
-
};
|
|
54
|
-
let localeId = 'zh';
|
|
55
|
-
const localeListeners = new Set();
|
|
56
|
-
function adoptLocale(id) {
|
|
57
|
-
const next = id === 'en' ? 'en' : 'zh';
|
|
58
|
-
if (next === localeId)
|
|
59
|
-
return;
|
|
60
|
-
localeId = next;
|
|
61
|
-
localeListeners.forEach(l => l());
|
|
62
|
-
}
|
|
63
|
-
function fmt(tpl, vars = {}) {
|
|
64
|
-
return tpl.replace(/\{(\w+)\}/g, (_, k) => String(vars[k] ?? ''));
|
|
65
|
-
}
|
|
66
|
-
function useT() {
|
|
67
|
-
const [id, setId] = useState(localeId);
|
|
68
|
-
useEffect(() => {
|
|
69
|
-
const l = () => { setId(localeId); };
|
|
70
|
-
localeListeners.add(l);
|
|
71
|
-
return () => { localeListeners.delete(l); };
|
|
72
|
-
}, []);
|
|
73
|
-
return (key, vars) => fmt(STRINGS[id][key] ?? STRINGS.zh[key], vars);
|
|
74
|
-
}
|
|
75
|
-
// ---- inline-styled primitives (no CSS build step) ----
|
|
76
|
-
const ssid = {
|
|
77
|
-
accent: '#4FC3F7',
|
|
78
|
-
wrap: { display: 'flex', flexDirection: 'column', gap: 8, padding: '10px 12px', overflowY: 'auto', height: '100%', boxSizing: 'border-box' },
|
|
79
|
-
card: {
|
|
80
|
-
background: 'var(--dsw-alias-bg-layer-1, #131a26)',
|
|
81
|
-
border: '1px solid var(--dsw-alias-border-l2, #1e2836)',
|
|
82
|
-
borderRadius: 10,
|
|
83
|
-
padding: '10px 12px',
|
|
84
|
-
},
|
|
85
|
-
title: { fontSize: 12, fontWeight: 600, color: 'var(--dsw-alias-label-secondary, #67748a)', marginBottom: 6, display: 'flex', justifyContent: 'space-between', alignItems: 'center' },
|
|
86
|
-
text: { fontSize: 12.5, color: 'var(--dsw-alias-label-primary, #d8e0ea)', lineHeight: 1.55 },
|
|
87
|
-
muted: { fontSize: 11, color: 'var(--dsw-alias-label-secondary, #67748a)' },
|
|
88
|
-
empty: { padding: '28px 12px', textAlign: 'center', fontSize: 12.5, color: 'var(--dsw-alias-label-secondary, #67748a)' },
|
|
89
|
-
btn: {
|
|
90
|
-
padding: '3px 12px', fontSize: 11.5, background: 'none',
|
|
91
|
-
border: '1px solid var(--dsw-alias-border-l2, #1e2836)', borderRadius: 6,
|
|
92
|
-
color: 'var(--dsw-alias-label-primary, #d8e0ea)', cursor: 'pointer',
|
|
93
|
-
},
|
|
94
|
-
};
|
|
95
|
-
// 预填指令(0.3.1+):过时内容用 memory_update 修正;工具面自查法。
|
|
96
|
-
const ORGANIZE_PROMPT = '请整理我的记忆库:用 memory_list 查看全部记忆,合并重复或可归并的条目,精简冗长内容,为每条补充或修正 keywords;对过时、错误或已变化的内容用 memory_update 修正(会重置为待审核),需要删除的用 memory_forget,需要新增的用 memory_save。判断内容是否过时的方法:把记忆里提到的工具名/数量与你当前实际可用的记忆工具对照——你当前可用:memory_save / memory_list / memory_search / memory_confirm / memory_forget / memory_update(共 6 个);若记忆中的工具列表、数量、流程与此不符即为过时,用 memory_update 修正。改动全部落在 suggested 等待审核(不要调用 memory_confirm),完成后用一句话汇报整理结果。';
|
|
97
|
-
function MemoryView(props) {
|
|
98
|
-
const t = useT();
|
|
99
|
-
const [records, setRecords] = useState([]);
|
|
100
|
-
const [query, setQuery] = useState('');
|
|
101
|
-
const [namespace, setNamespace] = useState(null);
|
|
102
|
-
const [refreshing, setRefreshing] = useState(false);
|
|
103
|
-
const [organizing, setOrganizing] = useState(false);
|
|
104
|
-
const reload = async () => {
|
|
105
|
-
try {
|
|
106
|
-
setRecords(await props.remote.list());
|
|
107
|
-
}
|
|
108
|
-
catch {
|
|
109
|
-
setRecords([]);
|
|
110
|
-
}
|
|
111
|
-
};
|
|
112
|
-
// 强制重读存储文件(JsonStorageBackend 无 watch——外部编辑后必须 reload)
|
|
113
|
-
const refreshFromDisk = async () => {
|
|
114
|
-
setRefreshing(true);
|
|
115
|
-
try {
|
|
116
|
-
const value = await props.remote.reload();
|
|
117
|
-
setRecords(value);
|
|
118
|
-
}
|
|
119
|
-
catch {
|
|
120
|
-
await reload();
|
|
121
|
-
}
|
|
122
|
-
finally {
|
|
123
|
-
setRefreshing(false);
|
|
124
|
-
}
|
|
125
|
-
};
|
|
126
|
-
useEffect(() => { if (props.visible)
|
|
127
|
-
void reload(); }, [props.visible]);
|
|
128
|
-
const toggleInjected = async (record) => {
|
|
129
|
-
if (record.status !== 'approved')
|
|
130
|
-
return;
|
|
131
|
-
try {
|
|
132
|
-
await props.remote.setInjected(record.id, !record.injected);
|
|
133
|
-
}
|
|
134
|
-
catch {
|
|
135
|
-
/* 失败保持原状态 */
|
|
136
|
-
}
|
|
137
|
-
await reload();
|
|
138
|
-
};
|
|
139
|
-
const confirmAll = async () => {
|
|
140
|
-
const pending = records.filter(record => record.status === 'suggested');
|
|
141
|
-
if (pending.length === 0)
|
|
142
|
-
return;
|
|
143
|
-
await Promise.all(pending.map(record => props.remote.confirm(record.id).catch(() => null)));
|
|
144
|
-
await reload();
|
|
145
|
-
};
|
|
146
|
-
// 一点即发整理:sessions.create → open → input 就绪(轮询 ≤5s)→ setDraft → submit。
|
|
147
|
-
// 机制实证自 dsh-better-sidebar conversation-draft.ts;sessions/conversation 软获取。
|
|
148
|
-
const organize = async () => {
|
|
149
|
-
if (organizing)
|
|
150
|
-
return;
|
|
151
|
-
setOrganizing(true);
|
|
152
|
-
try {
|
|
153
|
-
const sessions = props.ctx.get?.('sessions');
|
|
154
|
-
const conversation = props.ctx.get?.('conversation');
|
|
155
|
-
if (sessions === undefined || conversation?.input?.for === undefined) {
|
|
156
|
-
throw new Error('sessions/conversation unavailable');
|
|
157
|
-
}
|
|
158
|
-
const sessionId = await sessions.create({});
|
|
159
|
-
sessions.open(sessionId);
|
|
160
|
-
let input;
|
|
161
|
-
for (let i = 0; i < 50; i++) {
|
|
162
|
-
try {
|
|
163
|
-
const actx = sessions.scope(sessionId);
|
|
164
|
-
if (actx !== undefined) {
|
|
165
|
-
input = conversation.input.for(actx);
|
|
166
|
-
if (input !== undefined)
|
|
167
|
-
break;
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
catch {
|
|
171
|
-
/* not ready yet */
|
|
172
|
-
}
|
|
173
|
-
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
174
|
-
}
|
|
175
|
-
if (input === undefined)
|
|
176
|
-
throw new Error('composer input not ready');
|
|
177
|
-
input.setDraft(ORGANIZE_PROMPT);
|
|
178
|
-
input.submit();
|
|
179
|
-
}
|
|
180
|
-
catch (error) {
|
|
181
|
-
console.warn('[dsh-memory] organize memory failed:', error);
|
|
182
|
-
}
|
|
183
|
-
finally {
|
|
184
|
-
setOrganizing(false);
|
|
185
|
-
}
|
|
186
|
-
};
|
|
187
|
-
const q = query.trim().toLowerCase();
|
|
188
|
-
const byNs = namespace === null ? records : records.filter(record => record.namespace === namespace);
|
|
189
|
-
const filtered = byNs.filter(record => q === '' || record.content.toLowerCase().includes(q));
|
|
190
|
-
const groups = [
|
|
191
|
-
{ key: 'pending', label: t('groupPending'), items: filtered.filter(record => record.status === 'suggested') },
|
|
192
|
-
{ key: 'ondemand', label: t('groupOnDemand'), items: filtered.filter(record => record.status === 'approved' && !record.injected) },
|
|
193
|
-
{ key: 'injected', label: t('groupInjected'), items: filtered.filter(record => record.status === 'approved' && record.injected) },
|
|
194
|
-
].filter(group => group.items.length > 0);
|
|
195
|
-
return createElement('div', { style: ssid.wrap }, createElement('div', { style: { display: 'flex', gap: 6 } }, createElement('button', {
|
|
196
|
-
type: 'button',
|
|
197
|
-
title: t('organizeMemory'),
|
|
198
|
-
onClick: () => { void organize(); },
|
|
199
|
-
disabled: organizing,
|
|
200
|
-
style: { ...ssid.btn, color: ssid.accent, borderColor: ssid.accent },
|
|
201
|
-
}, organizing ? '…' : t('organizeMemory')), createElement('input', {
|
|
202
|
-
value: query,
|
|
203
|
-
onChange: (event) => { setQuery(event.target.value); },
|
|
204
|
-
placeholder: t('memorySearch'),
|
|
205
|
-
style: {
|
|
206
|
-
flex: 1, padding: '6px 10px', fontSize: 12.5, boxSizing: 'border-box',
|
|
207
|
-
background: 'var(--dsw-alias-bg-layer-1, #0f141d)',
|
|
208
|
-
border: '1px solid var(--dsw-alias-border-l2, #1e2836)', borderRadius: 8,
|
|
209
|
-
color: 'var(--dsw-alias-label-primary, #d8e0ea)', outline: 'none',
|
|
210
|
-
},
|
|
211
|
-
}), createElement('button', {
|
|
212
|
-
type: 'button',
|
|
213
|
-
title: t('refresh'),
|
|
214
|
-
onClick: () => { void refreshFromDisk(); },
|
|
215
|
-
disabled: refreshing,
|
|
216
|
-
style: ssid.btn,
|
|
217
|
-
}, refreshing ? '…' : '↻')), createElement('div', { style: { display: 'flex', gap: 4 } }, [null, 'global', 'project'].map(ns => createElement('button', {
|
|
218
|
-
key: ns ?? 'all',
|
|
219
|
-
onClick: () => { setNamespace(ns); },
|
|
220
|
-
style: { flex: 1, ...ssid.btn, ...(namespace === ns ? { color: ssid.accent, borderColor: ssid.accent } : {}) },
|
|
221
|
-
}, ns === null ? t('allNamespaces') : ns === 'global' ? t('nsGlobal') : t('nsProject')))), groups.length === 0
|
|
222
|
-
? createElement('div', { style: ssid.empty }, t('empty'))
|
|
223
|
-
: groups.map(group => createElement('div', { key: group.key, style: { display: 'flex', flexDirection: 'column', gap: 6 } }, createElement('div', { style: ssid.title }, createElement('span', null, group.label), createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 6 } }, group.key === 'pending' && group.items.length > 0
|
|
224
|
-
? createElement('button', {
|
|
225
|
-
type: 'button',
|
|
226
|
-
title: t('confirmAll'),
|
|
227
|
-
onClick: () => { void confirmAll(); },
|
|
228
|
-
style: { ...ssid.btn, padding: '1px 8px', fontSize: 10.5 },
|
|
229
|
-
}, t('confirmAll'))
|
|
230
|
-
: null, createElement('span', null, `${group.items.length}`))), group.items.map(record => createElement('div', { key: record.id, style: ssid.card }, createElement('div', { style: ssid.text }, record.content), createElement('div', { style: { ...ssid.muted, marginTop: 6 } }, `${record.namespace} · ${record.status === 'approved' ? t('approved') : t('suggested')}${record.injected ? ` · ${t('groupInjected')}` : ''}`), createElement('div', { style: { display: 'flex', gap: 6, marginTop: 8, alignItems: 'center' } }, createElement('button', {
|
|
231
|
-
type: 'button',
|
|
232
|
-
title: record.status === 'approved' ? t('injectSwitch') : t('approveFirst'),
|
|
233
|
-
disabled: record.status !== 'approved',
|
|
234
|
-
onClick: () => { void toggleInjected(record); },
|
|
235
|
-
style: {
|
|
236
|
-
...ssid.btn,
|
|
237
|
-
...(record.injected ? { color: ssid.accent, borderColor: ssid.accent } : {}),
|
|
238
|
-
opacity: record.status !== 'approved' ? 0.4 : 1,
|
|
239
|
-
cursor: record.status !== 'approved' ? 'not-allowed' : 'pointer',
|
|
240
|
-
},
|
|
241
|
-
}, record.injected ? `✓ ${t('injectSwitch')}` : t('injectSwitch')), record.status === 'suggested'
|
|
242
|
-
? createElement('button', {
|
|
243
|
-
style: ssid.btn,
|
|
244
|
-
onClick: () => { void props.remote.confirm(record.id).then(() => reload()); },
|
|
245
|
-
}, t('confirm'))
|
|
246
|
-
: null, createElement('button', {
|
|
247
|
-
style: ssid.btn,
|
|
248
|
-
onClick: () => { void props.remote.forget(record.id).then(() => reload()); },
|
|
249
|
-
}, t('forget'))))))));
|
|
250
|
-
}
|
|
251
|
-
// ---- client plugin body ----
|
|
252
|
-
export function apply(ctx) {
|
|
253
|
-
const face = ctx;
|
|
254
|
-
const locale = face.get?.('locale');
|
|
255
|
-
const initial = locale?.getLocale?.()?.active;
|
|
256
|
-
if (typeof initial === 'string')
|
|
257
|
-
adoptLocale(initial);
|
|
258
|
-
face.on?.('locale/change', (snap) => { adoptLocale(snap?.active); });
|
|
259
|
-
// better-sidebar 可选软依赖:无则面板不注册(host 侧无影响)。
|
|
260
|
-
const root = ctx;
|
|
261
|
-
if (root.inject === undefined)
|
|
262
|
-
return;
|
|
263
|
-
root.inject(['betterSidebar'], (sidebarCtx) => {
|
|
264
|
-
const service = sidebarCtx.betterSidebar;
|
|
265
|
-
if (service?.registerTab === undefined)
|
|
266
|
-
return;
|
|
267
|
-
const memory = ctx.remote?.memory;
|
|
268
|
-
if (memory === undefined)
|
|
269
|
-
return;
|
|
270
|
-
const tabCtx = ctx;
|
|
271
|
-
service.registerTab({
|
|
272
|
-
id: '@max-null/dsh-memory:memory',
|
|
273
|
-
title: () => STRINGS[localeId].tabMemory,
|
|
274
|
-
order: 60,
|
|
275
|
-
single: true,
|
|
276
|
-
component: ({ visible }) => createElement(MemoryView, {
|
|
277
|
-
visible,
|
|
278
|
-
remote: memory,
|
|
279
|
-
ctx: tabCtx,
|
|
280
|
-
}),
|
|
281
|
-
});
|
|
282
|
-
});
|
|
283
|
-
}
|