@max-null/dsh-memory 0.15.1 → 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 CHANGED
@@ -1,161 +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
- | ![设置 → 记忆](docs/shots/memory-settings-1.png) |
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
- ## 使用流程(写入即生效,人为例外干预)
89
-
90
- 提示词模板库(0.6.0):`prompt_search / prompt_get / prompt_list / prompt_add` 四个工具管理**模板库**——
91
- md 文件是唯一事实源(`~/.dsh/prompt-library/*.md` 为 global;`<workspace>/.dsh/prompt-library/` 随工作区分享),
92
- 前端(记忆面板「模板」tab / 模型工具)检索同一份索引;模板存在即生效(`source: agent` 角标标识模型新增),
93
- **永不注入 system prompt**。
94
-
95
- ```
96
- 模型 memory_save → status: approved,立即生效;命中密钥/凭据规则则隔离(不进检索也不进注入)
97
- 给 injected 则同时钉住常驻(0.8.0),不受自动规则影响
98
- memory_search → 关键词/语义召回(只搜记忆;模板走 prompt_search 通道,不会混进候选池)
99
- 人(面板 / 开关) → 钉住 / 删除 / 放行隔离记录 / 回滚
100
- memory_forget → 随时删除(删除始终是人的动作)
101
- ```
102
-
103
- **常驻开关只由人(或模型显式传 `injected`)决定。** 两条自动规则都已在 0.12.0 / 0.13.0 停用:命中次数不再自动升常驻(它只喂体检报告的候选提示),30 天未命中的自动撤下也改为**只计量不生效**——撤下来就基本不会自己回去,那种动作不该由一个没有分布依据的阈值决定(阈值来历见 `docs/设计/2026-09-29-借阅-dsh-mneme-后的自查.md` §二)。两者都不删除任何内容——记忆不会无限累积,也不会被系统自行清空。
104
-
105
- **低频但关键**的记忆(长期约定 / 判据 / 委托)本来就够不着旧阈值,而自动规则停用之后,问题从「够不着」变成了「必须显式说」。这类走显式路径——在 `memory_save` / `memory_update` 里给 `injected` 即钉住,写的是与面板开关同一套语义(`injectedAuto: false`)。未审核与已隔离的记录不接受注入。
106
-
107
- 注入有**字符预算**(默认 1500,config `injectionBudget`):装不下的条目**整条丢弃**(不截内容),按「最近更新优先」取舍——所以 `injected: true` 不等于「每轮真的在场」,它只保证有资格排队。0.9.0 起这种出局不再无声:注入末尾会追加一行 `(另有 N 条常驻因预算未注入:…)`,0.9.1 起列出的是**每条的短摘要**而不是 id——查 id 是什么的那一步最容易省略,省略了就等于没报;**清理干净即自行消失**。面板的注入预览里也能展开看明细。诊断行本身不占预算。
108
-
109
- 记忆还可声明**有效性锚点**(环境变量 / 工具清单 / 插件版本):所绑的值变化后,这条记忆被标记 `stale` 并撤下常驻,检索结果里也带出失效提示——环境变了,旧结论就不再被当成仍然正确。
110
-
111
- 模型写入不需要人点头,但**人始终能看见并推翻**:面板按「隔离区 / 常驻 / 冷数据」分组,被隔离的记录必须由人放行才会重新进入检索。
112
-
113
- ## 为什么明文 + BM25,而不是向量检索
114
-
115
- 向量检索的记忆本体是一串不可读的数字,过期信息会成为**无法观测、无法修复的静默暗礁**;BM25 + 明文让每一次召回都可解释、每一条记忆都可见可删。语义(向量)检索作为**可插拔的可选项**(0.5.2,见「可选配置」)——记忆本体仍是明文,向量仅作为检索辅助字段(`vector`,明文可读),且以"可观测 + 可修复"为门槛。
116
-
117
- 明文还有一层**跟随仓库分享**的好处:`project` 命名空间的记忆落在项目文件夹内(`<cwd>/.dsh/storages/memory_project_<hash>.json`),随 `git` 提交、分享给所有协作者;`global` 命名空间的记忆留在本地 `$DSH_HOME`。团队的共识("本项目统一用 Vue3 `<script setup>`")能沉淀进仓库,而不是散落在每个人的本地。FTS5 的 SQLite 二进制无法这样"跟着仓库走"。
118
-
119
- ## 可选配置
120
-
121
- 在 `cordis.yml` 的 `config` 里传给插件(均可省略):
122
-
123
- ```yaml
124
- - id: memory
125
- name: '@max-null/dsh-memory'
126
- config:
127
- injectionBudget: 1500 # 常驻注入预算(字符;null = 不限制)
128
- summaryChars: 80 # 单条注入摘要截断上限(字符)
129
- semanticTopK: 5 # 语义侧参与融合的 topK(仅配置 embeddings 时生效)
130
- # 混合语义检索(可插拔;缺省 = 纯 BM25)
131
- # embeddings: { embed: '...' 提供 embed(texts) 的宿主函数 或 插件名 ... }
132
- ```
133
-
134
- `embeddings` 接受 `{ embed(texts): Promise<number[][]>, similarity? }` 对象——由宿主装配层提供嵌入实现(如 DeepSeek 嵌入端点);配置后 `memory_search` 以 BM25 + 语义 RRF 融合,向量增量生成并持久化,嵌入调用失败自动降级为纯 BM25。
135
-
136
- ## SSID 系列
137
-
138
- 本插件是 **[SSID(思灵 · Seek Soul in Darkness)](https://github.com/Max-Null/seek-soul-in-darkness)** 全家桶的一员;也可以单独安装到任意 DSH profile——除设置页的记忆面板外不依赖其它同系列插件。
139
-
140
- ## 开发
141
-
142
- ```bash
143
- npm install
144
- npm run typecheck # tsc 严格类型检查
145
- npm test # vitest 单测
146
- npm run build # 产出 dist/
147
- node scripts/verify-loader.mjs # 用 Loader 端到端验证插件可加载
148
- node scripts/scan-orphans.mjs [根目录] # 清点「当前代码打不开」的记忆文件(只读)
149
- node scripts/unzstd-frames.mjs <源> <目标> # 解多帧 zstd(读 DSH 会话日志用)
150
- node scripts/ancestor-probe.mjs plan [--cwd <cwd>] # 复算祖先链(只读);seed/check/clean 验 ④-A
151
- node scripts/analyze-cooccurrence.mjs # 关键词共现分析(记忆之间有没有关系网,只读)
152
- npm run check:memory # 记忆文件存量体检(只读;0 = 全绿 / 1 = 有不合规 / 2 = 用法错)
153
- ```
154
-
155
- ## 依赖(peerDependencies,由宿主提供)
156
-
157
- `@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`**。
158
-
159
- **为什么写显式范围而不是 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 线都能装载。
160
-
161
- **验证过的内核**:`0.1.1-rc.2`(原基线)与 `0.2.0-rc.1`(0.12.2 起——typecheck 用其类型定义零错误,并在隔离实例实机加载,三个失败信号全为 0)。下限保持原值不变:它是作者实际验证过的最早版本,比它更早的内核没验证过,不声称支持。
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
+ | ![设置 → 记忆](docs/shots/memory-settings-1.png) |
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)。下限保持原值不变:它是作者实际验证过的最早版本,比它更早的内核没验证过,不声称支持。
@@ -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.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,7 @@ 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', 'webRuntime'];
21
+ export const inject = ['storage', 'systemPrompt', 'tools', 'webServer', 'webStartup'];
19
22
  /**
20
23
  * 本次挂载实际注册的工具名,按注册顺序。
21
24
  *
@@ -742,4 +745,31 @@ export async function apply(ctx, config) {
742
745
  },
743
746
  presentCall: () => present('Sweep memory', 'other', 'read-only'),
744
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
+ }
745
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/webRuntime 由 index.ts 的 inject 声明(Context 类型未扩展,断言访问)
124
+ // webServer/webStartup 由 index.ts 的 inject 声明(Context 类型未扩展,断言访问)
125
125
  const webServer = ctx.webServer;
126
- const webRuntime = ctx.webRuntime;
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, webRuntime.trustedHosts);
268
+ const fence = (req) => isTrusted(req, webStartup.trustedHosts);
269
269
  ctx.effect(() => webServer.register({
270
270
  kind: 'prefix',
271
271
  path: '/memory/api',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@max-null/dsh-memory",
3
- "version": "0.15.1",
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"
@@ -1,2 +0,0 @@
1
- export declare const inject: string[];
2
- export declare function apply(ctx: unknown): void;
@@ -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
- }