dsh-instruction-memory 1.5.2 → 1.5.4
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/CHANGELOG.md +71 -0
- package/README.md +116 -13
- package/lib/client.js +56 -4
- package/lib/index.js +59 -33
- package/memory.schema.json +2 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,77 @@
|
|
|
7
7
|
> 1.0.13 起由 CI 经 OIDC Trusted Publishing 发布(首个带 provenance 的版本)。
|
|
8
8
|
> 这些历史 tag **不应回填**——npm 上的版本号已被占用。
|
|
9
9
|
|
|
10
|
+
## [1.5.4] — 2026-11-12
|
|
11
|
+
|
|
12
|
+
2026-11-12 的三方评审(架构 / 安全 / 验收,桌面端 0.2.2-rc.1 + 网页端同进程)落地的一轮修复。
|
|
13
|
+
没有新增功能,存储格式、注入文本与面板行为在正常路径上不变;改的是**一处承诺与实现相反**、
|
|
14
|
+
**一处静默丢事件**、**一处验收脚本假绿**,以及三处缺失的回归护栏。
|
|
15
|
+
|
|
16
|
+
### 修复
|
|
17
|
+
|
|
18
|
+
- **面板与 JSON Schema 仍在说「形象独立于指令字数上限、完整注入、不做裁剪」**(P1,已复现):
|
|
19
|
+
这是 1.2.0 修「形象绕过注入预算」时漏改的文案——`lib/index.js` 从那时起就把形象段夹在
|
|
20
|
+
`MAX_PROFILE_INJECT_CHARS`(4000)与共享 budget 之内,而 `lib/client.js`(中/英两套字典)和
|
|
21
|
+
`memory.schema.json`(两处 description,会随 store 复制到 `~/.dsh/instruction-memory/`)还写着
|
|
22
|
+
相反的话。实测 2 张 3900 字形象 + 4000 预算:只注入 4000 字符、第二张整段丢弃,面板却逐字宣称
|
|
23
|
+
"完整注入"。现已改为「与指令共用同一注入字数上限;超出时按序截断,段末会说明」。
|
|
24
|
+
- **形象被截断/被丢弃时,面板上没有任何标记**(P2):宿主早在 `injection.profiles[]` 里发布了
|
|
25
|
+
`truncated`,其注释写着"必须披露",而 Client 对该字段零引用——条目有「已截断」红标、形象卡却
|
|
26
|
+
什么都不显示,用户只能从「查看实际注入内容」里自己发现。现在形象行会渲染「已截断」/「本轮未包含」,
|
|
27
|
+
数据全部来自既有快照字段,旧宿主缺该字段时不渲染标记。
|
|
28
|
+
- **`?lite=1` 的帧只带 `rev`,两条不写盘的变更推不出去**(P1):`rev` 是磁盘修订号,"暂停注入到期"
|
|
29
|
+
(状态变化、按规定不落盘)与"手改 `memory.json` 但保留 rev"都改变宿主看到的内容却不改 rev,
|
|
30
|
+
于是推出去的帧与上一帧逐字节相同,面板静默停在旧状态(仍显示静音、用量条仍是 0)。帧里现在带
|
|
31
|
+
单调递增的进程内计数器 `seq`,rev 未变而 seq 前进时面板同样回拉一次;`seq` 不落盘,旧客户端
|
|
32
|
+
忽略它、旧宿主不发它,两个方向都兼容。
|
|
33
|
+
- **`live-host-smoke.mjs` 同时给 `--web` 与 `--desktop` 会一个宿主都不探测,却打印
|
|
34
|
+
`ALL CHECKS PASSED`**(P1,已复现):两个 flag 各自"排除对方",同时给就互相抵消,`PORTS` 为空,
|
|
35
|
+
但汇报阶段只看 problems 是否为空。现在冲突直接报错退出;`PORTS` 为空时打印 `NO HOST PROBED`
|
|
36
|
+
并退出 2,绝不再打印成功。另加 `--base <origin>`(单宿主同时服务两端的真实拓扑,parity 显式
|
|
37
|
+
跳过)、`--both`;401/403 不再被说成"无法连接"。
|
|
38
|
+
|
|
39
|
+
### 测试
|
|
40
|
+
|
|
41
|
+
- `contract-test.mjs` 新增 5 条断言:lite 帧带 `seq`;**rev 不变而内容变化时必须产生可判定的帧**;
|
|
42
|
+
`res.write()` 恒返回 `false` 且不抛错的"慢但健康"订阅者**不得**被当成死连接(1.5.3 的回归护栏);
|
|
43
|
+
宿主 watcher 报 `error` 后 5 秒轮询仍能发现外部改动(1.5.2 的回归护栏,经 `apply(ctx, { watch })`
|
|
44
|
+
注入假 watcher)。
|
|
45
|
+
- `verify.mjs`:`host: no longer depends on the harness fs service` 这条断言名与断言体不符(只检查函数
|
|
46
|
+
存在),改为真正断言"存储解析在 `<DSH_HOME>/instruction-memory` 下"。
|
|
47
|
+
- 上述两条护栏都做过变异验证:把对应修复回滚后,新断言必须转红。
|
|
48
|
+
|
|
49
|
+
### 文档
|
|
50
|
+
|
|
51
|
+
- README「外部改动自动刷新(SSE)」补 `seq` 与"不写盘的变更";「用户形象」补面板截断标记;
|
|
52
|
+
`live-host-smoke.mjs` 一行补 `--base` / `--both` 与单宿主拓扑的说明。
|
|
53
|
+
|
|
54
|
+
## [1.5.3] — 2026-11-09
|
|
55
|
+
|
|
56
|
+
1.5.2 的发布后修正,两处都是当天发现:一处**判断依据用错**,一处**依赖了初始化顺序**。没有新功能,
|
|
57
|
+
存储格式、注入文本与面板行为不变。
|
|
58
|
+
|
|
59
|
+
### 修复
|
|
60
|
+
|
|
61
|
+
- **背压判断把健康但读得慢的订阅者当成死连接(1.5.2 引入)**:1.5.2 让 `send()` 返回 `res.write()`
|
|
62
|
+
的结果,并在返回 `false` 时立刻 `cleanup()`。但 `write()` 的 `false` 只表示**这一次写入超过了
|
|
63
|
+
socket 缓冲的高水位**——一个健康、但此刻读得慢的客户端同样会拿到 `false`。于是一个大帧(极限存储
|
|
64
|
+
约 3.4 MB)就可能把正常订阅者踢下线。现在返回值不再用作存活判据:心跳只负责让"写失败"这条路走通
|
|
65
|
+
——对端真的消失时,写入会报错并触发该流早已注册的 `close`/`error` 清理;`pushSnapshot` 恢复
|
|
66
|
+
1.5.1 的语义。
|
|
67
|
+
- **`diskRevFrom()` 的声明顺序依赖**:1.5.2 把它定义在 `writeToDisk()` 之后。`apply()` 是同步函数,
|
|
68
|
+
通常情况下没有问题;但"缺主文件时从 `.bak` 恢复"这条路径会在 boot 序列里调用 `writeToDisk()`,
|
|
69
|
+
那是一次**可能先于声明执行**的调用,而 `const` 会落在暂时性死区并抛 `ReferenceError`(保存因此
|
|
70
|
+
失败、`storageError` 报"保存失败")。现在两个 helper 提到 `writeToDisk()` 之前,用顺序消除这个
|
|
71
|
+
假设,而不是依赖"今天恰好先 await"。
|
|
72
|
+
|
|
73
|
+
### 验收
|
|
74
|
+
|
|
75
|
+
- 全套 7 个套件与 `node check-metadata.mjs` 全绿;`contract-test.mjs` 连续独立运行 5 次均 ALL PASS。
|
|
76
|
+
- 1.5.2 的 CI 在 `windows-latest / Node 24` 上失败了 2 条 SSE 断言:
|
|
77
|
+
`sse: a writer that throws on broadcast is fully disposed (response ended)` 与
|
|
78
|
+
`sse: disposing the dead writer does not interrupt a healthy stream`。本版按上面的修正重新验证这两个
|
|
79
|
+
断言所在的路径(死连接清理 + 健康流不受牵连)。
|
|
80
|
+
|
|
10
81
|
## [1.5.2] — 2026-11-09
|
|
11
82
|
|
|
12
83
|
针对 2026-11-09 的代码审查(Host 侧与 Client 侧并行只读审查,逐条复核后落地)。本版**没有新增功能、
|
package/README.md
CHANGED
|
@@ -66,7 +66,7 @@ profile 的解析顺序是 **`$DSH_PROFILE_DIR` → `--profile <名字>` → `$D
|
|
|
66
66
|
| `smoke-test.mjs` | 注入渲染逻辑的单元测试(含「只渲染激活配置集」的隔离断言与 cwd 红线) |
|
|
67
67
|
| `host-range-test.mjs` | 宿主版本范围契约测试:固定矩阵 × 两种 semver 判定模式,并把 `peerDependencies`(**被执行的那个**)、`dsh.engines.dsh`、`dsh.compatibility.dsh` 三处声明锁到同一个生成值 |
|
|
68
68
|
| `lint.mjs` | 静态门禁,已并入 `npm test`:`node --check` 解析所有随包发布与本仓库运行的 JS 文件,并断言 `lib/` 里不出现 `@deepseek-ai/*` 的包名导入(插件只该通过宿主传进来的上下文对象接触宿主,按包名 import 会把插件钉死在某个安装位置,在 headless / CI / 检出目录里直接解析失败) |
|
|
69
|
-
| `live-host-smoke.mjs` | 双端冒烟(**1.4.8 起宿主有鉴权门,需 `--token <启动 token>` 换会话 cookie**,否则全 401):对**正在运行**的 web / 桌面宿主各测 `state` p50/p95、SSE 首事件、注入注册与字符数,并比对两端快照(除 `injection.pulls` 外须逐字段相同);`npm run smoke:hosts`,`--require-pulls` 可把「从未经历真实 Agent
|
|
69
|
+
| `live-host-smoke.mjs` | 双端冒烟(**1.4.8 起宿主有鉴权门,需 `--token <启动 token>` 换会话 cookie**,否则全 401):对**正在运行**的 web / 桌面宿主各测 `state` p50/p95、SSE 首事件、注入注册与字符数,并比对两端快照(除 `injection.pulls` 外须逐字段相同);`npm run smoke:hosts`,`--require-pulls` 可把「从未经历真实 Agent 回合」判为失败。单宿主拓扑(桌面应用自己服务 `11685`)用 `--base http://127.0.0.1:11685`,parity 会**显式跳过**而不是假装比过;`--web` / `--desktop` 各选一端,同时给直接报错、零探测时打印 `NO HOST PROBED` 而不是 `ALL CHECKS PASSED`。**刻意不进 `npm test`**——CI 没有桌面端。请求走 Node `http` 直连,绕开 `http_proxy`(否则回环请求也会被代理转发,读数虚高) |
|
|
70
70
|
| `docs/demo.html` | 离线演示页:桩掉 DSH 外壳、加载真实 Client 半渲染设置页(不随 npm 包发布) |
|
|
71
71
|
| `memory.schema.json` | 手改存储文件用的 JSON Schema(随包发布,并被宿主复制到 `memory.json` 旁边) |
|
|
72
72
|
|
|
@@ -95,15 +95,9 @@ profile 的解析顺序是 **`$DSH_PROFILE_DIR` → `--profile <名字>` → `$D
|
|
|
95
95
|
「加标签」在浏览器里用原生输入框;**桌面版(Electron)不提供 `window.prompt`**(调用即抛错),
|
|
96
96
|
此时自动改用面板内的行内输入框(回车提交、Esc 取消),功能不受影响。
|
|
97
97
|
- **用户形象**:除了"要求 AI 怎么做"的指令,你还可以记录**关于你本人**的信息(身份背景、喜好、性格、
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
前提下据此调整解释深浅、详略、举例与建议方向,**不得为了迎合而改变事实、省略风险提示或偏离问题本身**,
|
|
102
|
-
无关时直接忽略。形象不是让 AI 扮演的角色。形象段与指令**共用同一个注入字数上限**:
|
|
103
|
-
形象优先占用,指令只拿剩下的余量(早期版本让形象完全绕过预算,十几个填满的形象能在指令预算还没被
|
|
104
|
-
考虑之前就塞进约 5 万字符)。形象自身另有 4000 字符的硬上限,超出部分按顺序截断并说明。
|
|
105
|
-
页面上的用量条**同时计入**形象与指令,所以它显示的比例就是宿主真正用来裁剪的那个数字。
|
|
106
|
-
总开关关闭时形象与指令一起停用。
|
|
98
|
+
长期追求等),每张形象卡片单独勾选「使用该形象」。形象段注入在指令块**之前**,与指令共用同一个注入
|
|
99
|
+
字数上限(形象优先占用、指令拿余量),并且它**不是**让 AI 扮演的角色。字段、注入原文、预算细节与
|
|
100
|
+
和配置集的区分见[用户形象(关于你本人的画像)](#用户形象关于你本人的画像)。
|
|
107
101
|
|
|
108
102
|
总开关关闭时,该提示词段落(指令与用户形象)**整个注销**,系统提示词与未安装本插件时逐字节一致;
|
|
109
103
|
总开关开启但没有任何生效指令、也没有启用形象时,段落同样不出现。
|
|
@@ -119,6 +113,101 @@ profile 的解析顺序是 **`$DSH_PROFILE_DIR` → `--profile <名字>` → `$D
|
|
|
119
113
|
(`injection.muted`)里。到期判定与注入渲染都是**可注入时钟**的纯函数(`isMuted`、
|
|
120
114
|
`planInjection(data, now)`),相同输入永远得到相同字节。
|
|
121
115
|
|
|
116
|
+
## 用户形象(关于你本人的画像)
|
|
117
|
+
|
|
118
|
+
指令回答的是"要 AI 怎么做",**用户形象**回答的是"你是谁":身份与职业背景、专业与经历、喜好与禁忌、
|
|
119
|
+
性格与沟通偏好、长期目标等。它在设置页的「指令记忆 → 用户形象」分区维护——卡片式,可新增、编辑、
|
|
120
|
+
删除,每张卡片右侧一个「使用该形象」开关;一张都没有时,该分区只显示空状态提示,注入行为与未安装
|
|
121
|
+
本插件完全一致。
|
|
122
|
+
|
|
123
|
+
### 字段与上限
|
|
124
|
+
|
|
125
|
+
| 项 | 上限 | 说明 |
|
|
126
|
+
| --- | --- | --- |
|
|
127
|
+
| 形象名称 | 40 字符 | 单行,作列表标题;留空时自动取内容前 20 字,仍能在列表里分辨 |
|
|
128
|
+
| 形象内容 | 4000 字符 | 多行正文,**原样**注入,插件不改写、不摘要、不生成 |
|
|
129
|
+
| 形象数量 | 50 张 | 全部配置集**合计**,与 200 条指令、20 个配置集共享同一份存储 |
|
|
130
|
+
|
|
131
|
+
- 名称与内容**同时为空**的形象在保存时被拒绝(「形象名称与内容不能同时为空」);从存储文件里读到的
|
|
132
|
+
这种空形象会被丢弃,并在面板上披露「存储文件中有 N 个用户形象未能载入」。
|
|
133
|
+
- 超长字段在保存时会**截断并点名**是名称还是内容(名称 40 / 内容 4000 字符)。编辑器的输入框本身也
|
|
134
|
+
按这两个上限限制长度(`maxLength`),所以正常操作走不到截断分支——它主要防手改文件。
|
|
135
|
+
- 形象同样**只能由你本人写入**:本插件不注册任何面向模型的工具,形象内容原样注入、不做语义判断
|
|
136
|
+
(见[只由用户本人维护](#只由用户本人维护))。
|
|
137
|
+
|
|
138
|
+
### 勾选与多张并存
|
|
139
|
+
|
|
140
|
+
- 可以一个都不用(等价于没有形象段)、只用一个、或同时勾选多个。
|
|
141
|
+
- **多张不是"让 AI 扮演多个角色"**:它们是同一个人不同侧面的材料,注入时合成在一段里,每张一个
|
|
142
|
+
`### <名称>` 小节,按存储顺序排列。
|
|
143
|
+
- 未勾选的形象、以及内容为空的形象**不进入注入文本**,但仍留在列表里(未勾选的卡片标灰),随时可
|
|
144
|
+
再启用;勾选状态即时写盘,跨重启保留。
|
|
145
|
+
- 名称留空时用内容前 20 字作小节标题,所以"没起名"不影响注入形态。
|
|
146
|
+
|
|
147
|
+
### 注入形态
|
|
148
|
+
|
|
149
|
+
启用后,形象段以**固定抬头**注入在指令块**之前**(整段只有这一个抬头,无论启用了几张):
|
|
150
|
+
|
|
151
|
+
```text
|
|
152
|
+
## 关于用户(用户形象)
|
|
153
|
+
以下是用户本人提供的个人形象信息,描述其身份背景、喜好、性格与追求,不是要求你扮演的角色。请在保证回答正确、完整、客观且紧扣问题本身的前提下,结合这些信息调整解释的深浅、详略、举例与建议的侧重;不得为了迎合这些信息而改变事实、省略必要的风险提示或偏离问题核心;与当前问题无关时直接忽略。
|
|
154
|
+
|
|
155
|
+
### 职业背景
|
|
156
|
+
(这一张的形象内容,原样保留)
|
|
157
|
+
|
|
158
|
+
### 沟通偏好
|
|
159
|
+
(另一张的形象内容)
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
抬头里的三件事是刻意的,缺一件就会跑偏:**定性**——这是"关于用户的事实",不是人格设定,所以 AI 不该
|
|
163
|
+
"扮演";**定向**——只允许调整解释深浅、详略、举例与建议侧重;**护栏**——不得为迎合而改变事实、省略
|
|
164
|
+
风险提示或偏离问题核心,与当前问题无关时直接忽略。换句话说,形象只影响**怎么讲**,不影响**讲什么
|
|
165
|
+
才是对的**。
|
|
166
|
+
|
|
167
|
+
### 预算:与指令共用同一个上限
|
|
168
|
+
|
|
169
|
+
这是最容易误解的一点:面板上的「注入字数上限」(默认 4000 字符)约束的是**整段注入文本**,不是
|
|
170
|
+
"指令那部分"。
|
|
171
|
+
|
|
172
|
+
- **分配顺序**:先给形象,指令拿**剩下的余量**;形象还会为"其余指令被略过"的脚注预留空间。因此
|
|
173
|
+
`形象 + 指令 + 脚注 ≤ 上限` 恒成立。(早期版本让形象完全绕过预算,十几个填满的形象能在指令预算
|
|
174
|
+
还没被考虑之前就塞进约 5 万字符。)
|
|
175
|
+
- **形象段自身另有 4000 字符硬上限**:即使把注入上限调到 40000,形象段最多也只占前 4000,其余才轮到
|
|
176
|
+
指令。
|
|
177
|
+
- **放不下时按顺序取舍**:某张形象装不进剩余空间,就保留截断的开头并追加
|
|
178
|
+
「…(此形象因注入字数上限被截断)」,它之后的形象整张略过,段末追加「(另有 N 个形象因注入字数
|
|
179
|
+
上限未包含,可在「设置 → 指令记忆」中精简其内容。)」。
|
|
180
|
+
- **形象把预算吃光时,指令不会被静默丢掉**:段末仍会输出「(另有 N 条指令因注入字数上限未包含…)」,
|
|
181
|
+
避免出现"用户的指令悄悄消失、只剩形象"这种最难发现的状态。
|
|
182
|
+
- **面板会把这件事标出来**:被截断的形象带「已截断」,整张没进本轮的形象带「本轮未包含」(悬停看原因)。
|
|
183
|
+
两个标记直接读宿主的注入计划(`injection.profiles[]`),与条目上的「本轮不注入 / 已截断」是同一套
|
|
184
|
+
数据——否则面板显示的是全文,而模型只收到截断的头。
|
|
185
|
+
- 面板上的用量条**同时计入**形象与指令(`entryChars + profileChars`),它显示的比例就是宿主真正
|
|
186
|
+
用来裁剪的那个数字。
|
|
187
|
+
|
|
188
|
+
### 与配置集不是一回事
|
|
189
|
+
|
|
190
|
+
配置集(`sets`)是"记忆的分区",用户形象是"关于你本人的材料"——两者容易混,区别在于:形象通过
|
|
191
|
+
`setId` **归属**某个配置集,切换配置集时形象与指令一起换;删除配置集会连同其中的形象一起删(确认框
|
|
192
|
+
写明条数与形象数);「复制」配置集会以全新 id 把形象复制过去。存储字段上,形象叫 `profiles`
|
|
193
|
+
(1.1.0 引入时先占用了这个名字),配置集叫 `sets`,互不相干。详见下文「多情境配置集(配置集 / sets)」。
|
|
194
|
+
|
|
195
|
+
### 停用、暂停与空存储
|
|
196
|
+
|
|
197
|
+
- **总开关关闭**或**暂停注入期间**(F7):形象与指令一起停用,系统提示词与未安装本插件时**逐字节
|
|
198
|
+
一致**。
|
|
199
|
+
- **只有形象、没有任何生效指令**时仍然注入——纯画像存储也生效,不会因为"没有指令"就整段消失。
|
|
200
|
+
- **没有任何启用形象**时,形象段完全**不出现**,不会留下一个空标题。
|
|
201
|
+
- 存储里 `profiles` 字段缺失按空数组处理,旧文件照常载入;文件里指向不存在配置集的形象会被归入
|
|
202
|
+
当前配置集并披露(与指令同一套容错规则)。
|
|
203
|
+
|
|
204
|
+
### 手改文件
|
|
205
|
+
|
|
206
|
+
形象在 `memory.json` 的 `profiles` 数组里,每项形如
|
|
207
|
+
`{ "id": "…", "name": "职业背景", "content": "…", "enabled": true, "setId": "…", "updatedAt": 1730000000000 }`;
|
|
208
|
+
`enabled` 就是那张卡片的「使用该形象」开关(缺失按 `true` 处理)。手改时用 `memory.schema.json` 校验,
|
|
209
|
+
条目定义见[数据格式](#数据格式)。
|
|
210
|
+
|
|
122
211
|
## 存储
|
|
123
212
|
|
|
124
213
|
记忆保存在 **DSH 数据根目录**下(设置页显示折叠后的路径:Windows 为 `%USERPROFILE%`,macOS/Linux 为 `~`;真实绝对路径只用于读写):
|
|
@@ -193,10 +282,16 @@ DSH 就会得到第二份空记忆,看起来像"记忆消失"。相对的 `$DS
|
|
|
193
282
|
被外部进程或手工改写(宿主以 `fs.watch` 监听**存储目录**——原子替换在 Windows 上会
|
|
194
283
|
使文件级监听失效——去抖 250ms 后经与写盘相同的队列重读,内容确有变化才重载并推送;
|
|
195
284
|
自己的写入盘后磁盘与内存一致,天然被同一比较抑制,不会回声)。
|
|
196
|
-
- **推送的是变更,不是全量**:设置页以 `?lite=1` 订阅,宿主只推 `{ rev, lite
|
|
197
|
-
(约
|
|
285
|
+
- **推送的是变更,不是全量**:设置页以 `?lite=1` 订阅,宿主只推 `{ rev, lite, seq }`
|
|
286
|
+
(约 50 字节),面板发现 rev 或 seq 比自己渲染的更新时才回拉一次 `state`。满配存储的一份快照
|
|
198
287
|
约 1.24 M 字符(中文约 3.4 MB),逐次全量推送会把这些字节乘以打开的窗口数。不带该参数
|
|
199
288
|
的旧客户端仍收到完整快照,两代客户端与两代宿主可任意组合。
|
|
289
|
+
- **不写盘的变更也要能通知**:`rev` 是**磁盘**修订号,只有真正写入才前进;而「暂停注入到期」
|
|
290
|
+
(状态变化,按规定不落盘)和「手改 `memory.json` 但保留 rev」都会改变宿主看到的内容却不改
|
|
291
|
+
rev。早期帧只带 `rev`,这两条路径推出去的帧与上一帧逐字节相同,面板于是静默停在旧状态——
|
|
292
|
+
暂停到期后仍显示静音、用量条仍是 0、清单还是旧内容。现在帧里另有单调递增的进程内计数器
|
|
293
|
+
`seq`:rev 未变而 seq 前进时面板同样回拉。`seq` 不落盘;旧客户端只比 rev(忽略它),
|
|
294
|
+
旧宿主不发它(客户端退回只比 rev),两个方向都保持兼容。
|
|
200
295
|
- **回声抑制**:发起修改的窗口也会收到自己那次写入的推送,但帧里的 rev 与它已吸收的响应
|
|
201
296
|
快照相同,客户端按 rev 比对直接丢弃(旧客户端的完整帧则按内容逐字节比对)——否则刚出现
|
|
202
297
|
的「已保存」提示会被空消息的推送帧瞬间冲掉。
|
|
@@ -692,6 +787,12 @@ nginx / Caddy 等反向代理之后远程访问,需要注意:
|
|
|
692
787
|
5. **限制来源**:至少用代理层做 IP 白名单或加 Basic Auth——插件本身不带任何鉴权,它只会
|
|
693
788
|
转用宿主发布的 `connection` 服务(没有该服务时非回环绑定直接 503)。
|
|
694
789
|
|
|
790
|
+
> **桌面端渲染器 origin 的一个已知边界**(2026-11-12 实测):宿主对 `Origin: dsh-app://app`
|
|
791
|
+
> 的请求返回 **403**,本插件在没有 `connection` 服务时的自判(`requestOriginVerdict`)同样拒绝
|
|
792
|
+
> 该 origin(它按 Origin ↔ Host 同源比较)。当前桌面窗口发的是 `http://127.0.0.1:<port>`,
|
|
793
|
+
> 所以不受影响;但如果将来的渲染器改用 `dsh-app://` 作为 document origin,设置页会整片 403,
|
|
794
|
+
> 届时需要在两侧各放行该 scheme——这不是配置问题,是代码问题。
|
|
795
|
+
|
|
695
796
|
### 在 DSH Go(Android)上:鉴权来自平台补丁,不是本插件
|
|
696
797
|
|
|
697
798
|
DSH Go 给官方 Web 服务器包了一层**构建期补丁** `runtime/patches/route-guard.diff`(钉在
|
|
@@ -728,7 +829,7 @@ DSH Go 给官方 Web 服务器包了一层**构建期补丁** `runtime/patches/r
|
|
|
728
829
|
|
|
729
830
|
| 等级 | 证明什么 | 覆盖 |
|
|
730
831
|
| --- | --- | --- |
|
|
731
|
-
| ① **真实启动** | 在该宿主版本上真的把 DSH 跑起来:插件装载、注入真的进入**组装后的提示**、插件 HTTP 路由可应答、客户端设置页模块被下发(Windows / Node 22) | `0.1.3-alpha.2` · `0.1.5-alpha.1` · `0.1.5-alpha.2` · `0.1.5-rc.1` · `0.1.5-rc.2` · `0.1.5-rc.3` · **`0.2.0-rc.1`** ⁽*⁾ · **`0.2.0-rc.2`** ⁽**⁾ · `0.2.1-alpha.2` ⁽***⁾ · **`0.2.1-alpha.3`** ⁽***⁾ · **`0.2.1-alpha.4`** ⁽***⁾ · **`0.2.1-rc.2`** · **`0.2.1-rc.3`** ⁽⁴⁾ · **`0.2.1-rc.4`** ⁽⁵⁾ · **`0.2.2-alpha.2`** ⁽⁵⁾ |
|
|
832
|
+
| ① **真实启动** | 在该宿主版本上真的把 DSH 跑起来:插件装载、注入真的进入**组装后的提示**、插件 HTTP 路由可应答、客户端设置页模块被下发(Windows / Node 22) | `0.1.3-alpha.2` · `0.1.5-alpha.1` · `0.1.5-alpha.2` · `0.1.5-rc.1` · `0.1.5-rc.2` · `0.1.5-rc.3` · **`0.2.0-rc.1`** ⁽*⁾ · **`0.2.0-rc.2`** ⁽**⁾ · `0.2.1-alpha.2` ⁽***⁾ · **`0.2.1-alpha.3`** ⁽***⁾ · **`0.2.1-alpha.4`** ⁽***⁾ · **`0.2.1-rc.2`** · **`0.2.1-rc.3`** ⁽⁴⁾ · **`0.2.1-rc.4`** ⁽⁵⁾ · **`0.2.2-alpha.2`** ⁽⁵⁾ · **`0.2.2-rc.1`** ⁽⁶⁾ |
|
|
732
833
|
| ② **契约核验** | 抓该版本**真实发布的源码**,逐个核验本插件实际用到的每个符号:`systemPrompt.section()`、`assemble()` 是否求值**函数型** `text`、`FILE_REFERENCE=900` / `TOOL_BASH=1000` 仍在(决定 order 950 是否空档)、重名注册是否抛错、`webServer.register()` / `get host()`、`ctx.inject()` / `ctx.effect()`、`dsh.client.inject` 两包在该版本是否存在且声明了 `dsh.client` | 截至 2026-10-16 审计时,声明范围接住的 **18 个已发布版本**(`0.1.3-alpha.2` → `0.2.1-alpha.4`);后续新增版本按发布补充 |
|
|
733
834
|
| ③ **真实执行** | 把插件**真的挂载**进该版本的 `@deepseek-ai/cordis`,跑装载 / 路由注册 / 卸载共 9 项断言 | cordis `4.0.2` 与 `4.0.4`(各 9/9) |
|
|
734
835
|
|
|
@@ -742,6 +843,8 @@ DSH Go 给官方 Web 服务器包了一层**构建期补丁** `runtime/patches/r
|
|
|
742
843
|
|
|
743
844
|
⁽⁵⁾ **`0.2.1-rc.4` / `0.2.2-alpha.2` 的①档**(2026-10-29,插件 1.4.4 → 1.4.5):隔离 `DSH_HOME` + 真实 `dsh web` 宿主,两个版本各跑 21 项 HTTP 断言;两端均 **21/21 PASS**(1.4.4 首次跑出的 2 项 FAIL 是验证脚本未展开折叠路径造成的假阴性,1.4.5 修正脚本后复验通过)。客户端 bundle 实际下发:rc.4 `2,211,701` 字节、alpha.2 `2,370,024` 字节,正文均含 Client 半源码与「指令记忆」文案。**首次做了设置页人工视觉确认**——两个宿主上「指令记忆」栏目均完整渲染。桌面端 `0.2.1-rc.3` 活体 `pulls=653`、p50 0.95 ms、`problems=[]`。仍**未跑真实 Agent 回合**(隔离宿主无模型凭证,`pulls=0`)。
|
|
744
845
|
|
|
846
|
+
⁽⁶⁾ **`0.2.2-rc.1` 的①档**(2026-11-12,插件 1.5.3 → 1.5.4,桌面版 `dsh-desktop-runtime 0.2.2-rc.1` / Electron 44.7.0 / cordis 4.1.0 / Node 24.21.0):1) **注入真的进入了组装后的提示**——该会话的系统提示里含本插件的「用户长期指令记忆」块,条目与磁盘 store 逐条一致(不是"注册了但没被求值");2) 路由在 `127.0.0.1:11685` 上应答,且宿主鉴权门在该版本上真实生效:匿名 GET / POST / SSE 全部 **401**,伪造 `Host` 与 `Sec-Fetch-Site: cross-site` 全部 **403**(这三条同时反证宿主 `connection.requestRejection()` 返回数字状态码,与插件判定一致);3) 设置页在真实窗口里渲染出磁盘数据(「共 0 个」与 `profiles: []` 一致)。**未做**的两项:桌面窗口 DevTools 里抓 `Origin` 实际值(宿主对 `Origin: dsh-app://app` 实测返回 403,见「请求来源防护」——若将来渲染器真改用该 scheme,这一行需要重验);客户端 bundle 下发字节数未重抓(⁽⁴⁾⁽⁵⁾ 的方法可复用)。
|
|
847
|
+
|
|
745
848
|
此外 `host-range-test.mjs` 的固定矩阵覆盖 `0.1.6-alpha.2`、`0.1.7-rc.2`、`0.2.0-rc.1`、`0.2.0-rc.2`、`0.2.1-alpha.1`~`0.2.1-alpha.4`、`0.2.1-rc.2`~`0.2.1-rc.4` 与 `0.2.2-alpha.2`(回归守卫把最新验证过的宿主逐名钉住),并会**读取本机实际安装的宿主版本**做实时断言。
|
|
746
849
|
|
|
747
850
|
**关于 1.0.10 新增的 `ctx.inject`。** 这是新版唯一的硬依赖增量:`inject` 去掉了 `webServer`,设置页路由改为非阻塞的 `ctx.inject(['webServer'], …)` 子 fiber(原因见上)。为此逐个核验了 **cordis 全部 8 个已发布版本**(`4.0.1-rc.1` → `4.0.5-alpha.2`)——**每一个都具备 `ctx.inject`**(其中 4.0.2 / 4.0.4 另做了真实挂载执行,4.0.5-alpha.1 与 4.0.5-alpha.2 为源码符号核验),因此声明范围内不存在因它而失效的宿主版本。
|
package/lib/client.js
CHANGED
|
@@ -357,7 +357,11 @@ window.__ModuleLoader__.load({
|
|
|
357
357
|
'profiles.confirmDelete': '确定删除用户形象「{name}」?删除后无法恢复。',
|
|
358
358
|
'profiles.empty': '还没有用户形象。点击「+ 新增形象」添加关于你本人的信息;不勾选任何形象时,AI 完全按原本方式回答。',
|
|
359
359
|
'profiles.count': '共 {total} 个,使用中 {active} 个',
|
|
360
|
-
'profiles.chars': '已启用形象合计 {count}
|
|
360
|
+
'profiles.chars': '已启用形象合计 {count} 字符,与指令共用同一注入字数上限(超出时按序截断,段末会说明)',
|
|
361
|
+
'profiles.truncated': '已截断',
|
|
362
|
+
'profiles.truncatedTitle': '这张形象超过注入字数上限,只有开头进入本轮提示',
|
|
363
|
+
'profiles.omitted': '本轮未包含',
|
|
364
|
+
'profiles.omittedTitle': '这张形象因注入字数上限未进入本轮提示',
|
|
361
365
|
'profiles.saving': '保存中…',
|
|
362
366
|
'action.new': '+ 新增指令',
|
|
363
367
|
'action.reload': '重新载入',
|
|
@@ -645,7 +649,11 @@ window.__ModuleLoader__.load({
|
|
|
645
649
|
'profiles.confirmDelete': 'Delete the persona “{name}”? This cannot be undone.',
|
|
646
650
|
'profiles.empty': 'No persona yet. Click “+ New persona” to add facts about yourself; with no persona ticked, the AI answers exactly as it normally would.',
|
|
647
651
|
'profiles.count': '{total} in total, {active} in use',
|
|
648
|
-
'profiles.chars': 'Active personas total {count} characters
|
|
652
|
+
'profiles.chars': 'Active personas total {count} characters, counted against the same injection limit as instructions (anything over is truncated in order and disclosed at the end)',
|
|
653
|
+
'profiles.truncated': 'Truncated',
|
|
654
|
+
'profiles.truncatedTitle': 'This persona exceeds the injection limit; only its head reached this turn',
|
|
655
|
+
'profiles.omitted': 'Not in this turn',
|
|
656
|
+
'profiles.omittedTitle': 'This persona was dropped by the injection limit and did not reach this turn',
|
|
649
657
|
'profiles.saving': 'Saving…',
|
|
650
658
|
'action.new': '+ New instruction',
|
|
651
659
|
'action.reload': 'Reload',
|
|
@@ -2761,6 +2769,15 @@ window.__ModuleLoader__.load({
|
|
|
2761
2769
|
let alive = true
|
|
2762
2770
|
let refreshing = false
|
|
2763
2771
|
let missed = false
|
|
2772
|
+
/**
|
|
2773
|
+
* Monotonic frame counter carried by lite frames. `rev` only moves when
|
|
2774
|
+
* the store is WRITTEN, so a snapshot that changed without a write (the
|
|
2775
|
+
* F7 pause expiring, an external hand-edit that keeps its rev) used to
|
|
2776
|
+
* produce a frame identical to the last one and the panel stayed stale
|
|
2777
|
+
* for the rest of the session. `null` means the Host predates the field
|
|
2778
|
+
* and the rev comparison alone drives the refetch.
|
|
2779
|
+
*/
|
|
2780
|
+
let lastSeq = null
|
|
2764
2781
|
const refresh = () => {
|
|
2765
2782
|
// One refetch at a time. A frame that arrives mid-flight means the
|
|
2766
2783
|
// store moved again, and is re-checked after this response lands —
|
|
@@ -2784,6 +2801,9 @@ window.__ModuleLoader__.load({
|
|
|
2784
2801
|
const pushed = JSON.parse(ev.data)
|
|
2785
2802
|
if (pushed !== null && typeof pushed === 'object' && pushed.lite === true) {
|
|
2786
2803
|
const current = viewRef.current
|
|
2804
|
+
const seq = typeof pushed.seq === 'number' ? pushed.seq : null
|
|
2805
|
+
const moved = seq !== null && lastSeq !== null && seq !== lastSeq
|
|
2806
|
+
if (seq !== null) lastSeq = seq
|
|
2787
2807
|
// A null view used to be ignored here on the assumption that the
|
|
2788
2808
|
// initial POST `state` is always in flight. When that request
|
|
2789
2809
|
// failed, nothing ever refetched and the panel stayed on its
|
|
@@ -2791,6 +2811,7 @@ window.__ModuleLoader__.load({
|
|
|
2791
2811
|
// the Host is alive, so it is a safe trigger to retry.
|
|
2792
2812
|
if (current === null) refresh()
|
|
2793
2813
|
else if (current.data !== null && current.data.rev !== pushed.rev) refresh()
|
|
2814
|
+
else if (moved) refresh()
|
|
2794
2815
|
return
|
|
2795
2816
|
}
|
|
2796
2817
|
if (sameSnapshot(pushed, viewRef.current)) return
|
|
@@ -2937,6 +2958,15 @@ window.__ModuleLoader__.load({
|
|
|
2937
2958
|
const simById = new Map(sim.results.map((hit) => [hit.id, hit]))
|
|
2938
2959
|
const omittedById = new Map()
|
|
2939
2960
|
const truncatedById = new Map()
|
|
2961
|
+
/**
|
|
2962
|
+
* Persona budget marks, from the same plan. `injection.profiles` lists
|
|
2963
|
+
* every persona that actually reached the prompt, with `truncated` when
|
|
2964
|
+
* the ceiling cut it; an enabled persona MISSING from that list was
|
|
2965
|
+
* dropped whole. Both were published by the Host and never read here, so
|
|
2966
|
+
* the panel kept showing full text the model never received.
|
|
2967
|
+
*/
|
|
2968
|
+
const renderedProfileById = new Map()
|
|
2969
|
+
const truncatedProfileById = new Map()
|
|
2940
2970
|
{
|
|
2941
2971
|
const planItems = injection && typeof injection === 'object' ? injection : {}
|
|
2942
2972
|
for (const item of Array.isArray(planItems.omitted) ? planItems.omitted : []) {
|
|
@@ -2947,6 +2977,12 @@ window.__ModuleLoader__.load({
|
|
|
2947
2977
|
truncatedById.set(item.id, item)
|
|
2948
2978
|
}
|
|
2949
2979
|
}
|
|
2980
|
+
for (const item of Array.isArray(planItems.profiles) ? planItems.profiles : []) {
|
|
2981
|
+
if (item !== null && typeof item === 'object' && typeof item.id === 'string') {
|
|
2982
|
+
renderedProfileById.set(item.id, item)
|
|
2983
|
+
if (item.truncated === true) truncatedProfileById.set(item.id, item)
|
|
2984
|
+
}
|
|
2985
|
+
}
|
|
2950
2986
|
}
|
|
2951
2987
|
const keyword = query.trim().toLowerCase()
|
|
2952
2988
|
const filtersActive = tagFilter !== '' || modeFilter !== '' || statusFilter !== ''
|
|
@@ -3279,13 +3315,28 @@ window.__ModuleLoader__.load({
|
|
|
3279
3315
|
: null,
|
|
3280
3316
|
profiles.length === 0 && profileDraft === null
|
|
3281
3317
|
? h('div', { className: 'im-empty' }, t('profiles.empty'))
|
|
3282
|
-
: profiles.map((profile) =>
|
|
3318
|
+
: profiles.map((profile) => {
|
|
3319
|
+
// Only an ENABLED persona with content can be in the plan, so a
|
|
3320
|
+
// disabled card never wears a stale mark. A Host that publishes no
|
|
3321
|
+
// `injection.profiles` leaves both maps empty and no mark renders.
|
|
3322
|
+
const personaMark = profile.enabled !== true || profile.content === ''
|
|
3323
|
+
? null
|
|
3324
|
+
: (truncatedProfileById.has(profile.id)
|
|
3325
|
+
? 'truncated'
|
|
3326
|
+
: (renderedProfileById.has(profile.id) ? null : 'omitted'))
|
|
3327
|
+
return h('div', {
|
|
3283
3328
|
className: 'im-profile-item' + (profile.enabled === true ? '' : ' im-profile-item-off'),
|
|
3284
3329
|
key: profile.id,
|
|
3285
3330
|
},
|
|
3286
3331
|
h('div', { className: 'im-item-head' },
|
|
3287
3332
|
h('span', { className: 'im-profile-name' },
|
|
3288
3333
|
profile.name !== '' ? profile.name : t('profiles.untitled')),
|
|
3334
|
+
personaMark === null
|
|
3335
|
+
? null
|
|
3336
|
+
: h('span', {
|
|
3337
|
+
className: 'im-tag ' + (personaMark === 'omitted' ? 'im-tag-off' : 'im-tag-cut'),
|
|
3338
|
+
title: personaMark === 'omitted' ? t('profiles.omittedTitle') : t('profiles.truncatedTitle'),
|
|
3339
|
+
}, personaMark === 'omitted' ? t('profiles.omitted') : t('profiles.truncated')),
|
|
3289
3340
|
h('span', { className: 'im-spacer' }),
|
|
3290
3341
|
h('label', { className: 'im-switch' },
|
|
3291
3342
|
h('input', {
|
|
@@ -3308,7 +3359,8 @@ window.__ModuleLoader__.load({
|
|
|
3308
3359
|
h(EntryBody, { text: profile.content, key: 'body' }),
|
|
3309
3360
|
profile.updatedAt > 0
|
|
3310
3361
|
? h('div', { className: 'im-meta' }, t('entry.updatedAt', { time: new Date(profile.updatedAt).toLocaleString() }))
|
|
3311
|
-
: null)
|
|
3362
|
+
: null)
|
|
3363
|
+
}),
|
|
3312
3364
|
activeProfiles > 0
|
|
3313
3365
|
? h('div', { className: 'im-meta im-meter-line' }, t('profiles.chars', { count: profileChars }))
|
|
3314
3366
|
: null),
|
package/lib/index.js
CHANGED
|
@@ -1774,7 +1774,14 @@ export function buildBlock(data, now = Date.now()) {
|
|
|
1774
1774
|
* Plugin
|
|
1775
1775
|
* ------------------------------------------------------------------ */
|
|
1776
1776
|
|
|
1777
|
-
export function apply(ctx) {
|
|
1777
|
+
export function apply(ctx, deps = {}) {
|
|
1778
|
+
/**
|
|
1779
|
+
* Test seam for the OS watcher. Production passes nothing and this resolves
|
|
1780
|
+
* to `node:fs`'s `watch`; the regression that matters — the watcher dying
|
|
1781
|
+
* and the 5s poller having to take over — otherwise needs a real directory
|
|
1782
|
+
* to be deleted mid-test, which no contract test can do reproducibly.
|
|
1783
|
+
*/
|
|
1784
|
+
const watchStore = typeof deps.watch === 'function' ? deps.watch : watch
|
|
1778
1785
|
const systemPrompt = ctx.systemPrompt
|
|
1779
1786
|
|
|
1780
1787
|
const state = {
|
|
@@ -2209,6 +2216,18 @@ export function apply(ctx) {
|
|
|
2209
2216
|
* pushSnapshot).
|
|
2210
2217
|
*/
|
|
2211
2218
|
const sseStreams = new Map()
|
|
2219
|
+
/**
|
|
2220
|
+
* Monotonic counter of pushed frames. It exists because `rev` is a DISK
|
|
2221
|
+
* revision: it only moves when the store is written. Two legitimate pushes
|
|
2222
|
+
* change what the prompt sees without writing anything — the F7 pause
|
|
2223
|
+
* expiring (see the mute timer) and an external reload that keeps the file's
|
|
2224
|
+
* rev — and a lite subscriber compares rev to decide whether to refetch. Both
|
|
2225
|
+
* therefore produced a frame identical to the one already on screen, so the
|
|
2226
|
+
* panel kept showing the muted/old state while the host had already moved on.
|
|
2227
|
+
* `seq` is process-local and never persisted; a full-frame subscriber never
|
|
2228
|
+
* reads it.
|
|
2229
|
+
*/
|
|
2230
|
+
let stateSeq = 0
|
|
2212
2231
|
/**
|
|
2213
2232
|
* Shared heartbeat over every live event stream, started with the first
|
|
2214
2233
|
* subscriber and stopped with the last. Two jobs:
|
|
@@ -2238,7 +2257,13 @@ export function apply(ctx) {
|
|
|
2238
2257
|
}
|
|
2239
2258
|
for (const [send, entry] of Array.from(sseStreams)) {
|
|
2240
2259
|
try {
|
|
2241
|
-
|
|
2260
|
+
// The RETURN VALUE is deliberately not read as a liveness signal:
|
|
2261
|
+
// res.write() answers false when THIS write exceeded the socket's
|
|
2262
|
+
// high-water mark, which a healthy but momentarily slow reader
|
|
2263
|
+
// produces too — killing on it tore down live streams. A dead peer
|
|
2264
|
+
// is detected the reliable way instead: the write fails, which fires
|
|
2265
|
+
// the close/error handler this stream has already registered.
|
|
2266
|
+
send(SSE_PING_FRAME)
|
|
2242
2267
|
} catch {
|
|
2243
2268
|
entry.cleanup()
|
|
2244
2269
|
}
|
|
@@ -2258,22 +2283,22 @@ export function apply(ctx) {
|
|
|
2258
2283
|
|
|
2259
2284
|
const pushSnapshot = () => {
|
|
2260
2285
|
if (disposed || sseStreams.size === 0) return
|
|
2286
|
+
// Every broadcast bumps the counter, whether or not the disk rev moved.
|
|
2287
|
+
stateSeq += 1
|
|
2261
2288
|
// A lite stream only needs to know that the store moved: it fetches the
|
|
2262
2289
|
// snapshot through POST `state` when the rev it sees differs from the one
|
|
2263
2290
|
// on screen. The full frame is built LAZILY, so a page whose subscribers
|
|
2264
2291
|
// are all lite (the current client) never pays for serialising the whole
|
|
2265
2292
|
// store — up to ~3.4 MB at the 200 × 6,000-character cap — on every save.
|
|
2266
|
-
const litePayload = 'data: ' + JSON.stringify({ rev: state.data.rev || 0, lite: true }) + '\n\n'
|
|
2293
|
+
const litePayload = 'data: ' + JSON.stringify({ rev: state.data.rev || 0, lite: true, seq: stateSeq }) + '\n\n'
|
|
2267
2294
|
let fullPayload = null
|
|
2268
2295
|
for (const [send, entry] of Array.from(sseStreams)) {
|
|
2269
2296
|
try {
|
|
2270
2297
|
if (entry.lite === true) {
|
|
2271
|
-
|
|
2272
|
-
// stopped reading. Drop it now rather than queue frames in memory.
|
|
2273
|
-
if (send(litePayload) === false) entry.cleanup()
|
|
2298
|
+
send(litePayload)
|
|
2274
2299
|
} else {
|
|
2275
2300
|
if (fullPayload === null) fullPayload = 'data: ' + JSON.stringify(snapshot()) + '\n\n'
|
|
2276
|
-
|
|
2301
|
+
send(fullPayload)
|
|
2277
2302
|
}
|
|
2278
2303
|
} catch {
|
|
2279
2304
|
// The response is dead: run its full teardown (end + unsubscribe),
|
|
@@ -2397,7 +2422,7 @@ export function apply(ctx) {
|
|
|
2397
2422
|
const startWatching = () => {
|
|
2398
2423
|
if (disposed || storeWatcher !== null) return
|
|
2399
2424
|
try {
|
|
2400
|
-
storeWatcher =
|
|
2425
|
+
storeWatcher = watchStore(dirname(storePath), { persistent: false }, (event, filename) => {
|
|
2401
2426
|
// Only events naming memory.json matter; the .tmp/.bak/.lock churn of
|
|
2402
2427
|
// our own writes is filtered out here (the content compare in
|
|
2403
2428
|
// onStoreFileEvent catches what slips through).
|
|
@@ -2449,6 +2474,28 @@ export function apply(ctx) {
|
|
|
2449
2474
|
|
|
2450
2475
|
const storePath = resolveStorePath()
|
|
2451
2476
|
|
|
2477
|
+
/**
|
|
2478
|
+
* The rev a PARSED store carries, or 0 when it carries none usable.
|
|
2479
|
+
*
|
|
2480
|
+
* Defined BEFORE writeToDisk on purpose: the missing-primary recovery path
|
|
2481
|
+
* calls writeToDisk from the boot sequence, and a `const` declared later in
|
|
2482
|
+
* apply() would still be in its temporal dead zone if that write ever ran
|
|
2483
|
+
* before apply() reached the declaration. Ordering it here removes the
|
|
2484
|
+
* question instead of relying on the await that happens to come first today.
|
|
2485
|
+
*/
|
|
2486
|
+
const diskRevFrom = (parsed) => {
|
|
2487
|
+
const rev = parsed !== null && parsed !== undefined && typeof parsed === 'object' ? parsed.rev : null
|
|
2488
|
+
return typeof rev === 'number' && Number.isFinite(rev) && rev > 0 ? Math.round(rev) : 0
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
const readDiskRev = async () => {
|
|
2492
|
+
try {
|
|
2493
|
+
return diskRevFrom(await readStoreFile(storePath))
|
|
2494
|
+
} catch {
|
|
2495
|
+
return 0
|
|
2496
|
+
}
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2452
2499
|
/**
|
|
2453
2500
|
* The exact object written to memory.json — and the one an export carries.
|
|
2454
2501
|
*
|
|
@@ -3132,25 +3179,6 @@ export function apply(ctx) {
|
|
|
3132
3179
|
* then silently misses the second save; and (b) deciding whether a save made
|
|
3133
3180
|
* while another process holds the lock would clobber a newer store.
|
|
3134
3181
|
*/
|
|
3135
|
-
/**
|
|
3136
|
-
* The rev a PARSED store carries, or 0 when it carries none usable. Split out
|
|
3137
|
-
* so a caller that already read the file — writeToDisk does, once, inside the
|
|
3138
|
-
* lock — can derive the same number without a second read. Called at request
|
|
3139
|
-
* time, long after apply() finished defining it.
|
|
3140
|
-
*/
|
|
3141
|
-
const diskRevFrom = (parsed) => {
|
|
3142
|
-
const rev = parsed !== null && parsed !== undefined && typeof parsed === 'object' ? parsed.rev : null
|
|
3143
|
-
return typeof rev === 'number' && Number.isFinite(rev) && rev > 0 ? Math.round(rev) : 0
|
|
3144
|
-
}
|
|
3145
|
-
|
|
3146
|
-
const readDiskRev = async () => {
|
|
3147
|
-
try {
|
|
3148
|
-
return diskRevFrom(await readStoreFile(storePath))
|
|
3149
|
-
} catch {
|
|
3150
|
-
return 0
|
|
3151
|
-
}
|
|
3152
|
-
}
|
|
3153
|
-
|
|
3154
3182
|
/**
|
|
3155
3183
|
* Load the store from disk. Most branches only read; two of them also write —
|
|
3156
3184
|
* the missing-primary recovery promotes the backup to memory.json, and the
|
|
@@ -5306,7 +5334,7 @@ export function apply(ctx) {
|
|
|
5306
5334
|
* `?lite=1` is additive on purpose: a client that does not ask (every build
|
|
5307
5335
|
* before this one) keeps receiving the bare snapshot exactly as documented,
|
|
5308
5336
|
* so an old client against a new Host is unchanged. A lite subscriber gets
|
|
5309
|
-
* `{ rev, lite
|
|
5337
|
+
* `{ rev, lite, seq }` and fetches the snapshot itself when either moved
|
|
5310
5338
|
* — which is the difference between pushing ~3.4 MB per save (maximal store)
|
|
5311
5339
|
* and pushing ~40 bytes.
|
|
5312
5340
|
*
|
|
@@ -5335,7 +5363,7 @@ export function apply(ctx) {
|
|
|
5335
5363
|
*
|
|
5336
5364
|
* Frames carry the bare snapshot — the same shape `state` answers with —
|
|
5337
5365
|
* which the Client's normalizer already accepts. A subscriber that appends
|
|
5338
|
-
* `?lite=1` gets `{ rev, lite
|
|
5366
|
+
* `?lite=1` gets `{ rev, lite, seq }` frames instead (see pushSnapshot).
|
|
5339
5367
|
*/
|
|
5340
5368
|
const handleEventStream = (req, res) => {
|
|
5341
5369
|
// Same fence as the POST path: an EventSource carries the login cookie, so
|
|
@@ -5364,9 +5392,7 @@ export function apply(ctx) {
|
|
|
5364
5392
|
// restarted) instead of hammering the route.
|
|
5365
5393
|
res.write('retry: 5000\n\n')
|
|
5366
5394
|
const lite = isLiteStreamRequest(req)
|
|
5367
|
-
|
|
5368
|
-
// heartbeat act on: false means the peer stopped reading this stream.
|
|
5369
|
-
const send = (payload) => res.write(payload)
|
|
5395
|
+
const send = (payload) => { res.write(payload) }
|
|
5370
5396
|
let closed = false
|
|
5371
5397
|
const cleanup = () => {
|
|
5372
5398
|
closed = true
|
|
@@ -5394,7 +5420,7 @@ export function apply(ctx) {
|
|
|
5394
5420
|
if (closed) return
|
|
5395
5421
|
try {
|
|
5396
5422
|
send(lite
|
|
5397
|
-
? 'data: ' + JSON.stringify({ rev: state.data.rev || 0, lite: true }) + '\n\n'
|
|
5423
|
+
? 'data: ' + JSON.stringify({ rev: state.data.rev || 0, lite: true, seq: stateSeq }) + '\n\n'
|
|
5398
5424
|
: 'data: ' + JSON.stringify(snapshot()) + '\n\n')
|
|
5399
5425
|
} catch {
|
|
5400
5426
|
cleanup()
|
package/memory.schema.json
CHANGED
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"type": "integer",
|
|
38
38
|
"minimum": 800,
|
|
39
39
|
"maximum": 40000,
|
|
40
|
-
"description": "指令注入字数上限(字符,非 token
|
|
40
|
+
"description": "指令注入字数上限(字符,非 token)。超出部分按优先级取舍,未注入的条目会在设置页标记。用户形象同样计入此上限:形象先占用、指令拿余量。",
|
|
41
41
|
"default": 4000
|
|
42
42
|
},
|
|
43
43
|
"backupDir": {
|
|
@@ -135,7 +135,7 @@
|
|
|
135
135
|
"profiles": {
|
|
136
136
|
"type": "array",
|
|
137
137
|
"maxItems": 50,
|
|
138
|
-
"description": "
|
|
138
|
+
"description": "用户形象:关于**用户本人**的身份背景、喜好、性格与追求。它们与指令共用同一个注入字数上限(形象先占用、指令拿余量),形象段自身另有 4000 字符上限,超出时按序截断并在段末说明;带有一段「仅供调整表达侧重、不得为迎合而改变事实」的护栏说明;形象不是让模型扮演的角色。",
|
|
139
139
|
"items": {
|
|
140
140
|
"type": "object",
|
|
141
141
|
"properties": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-instruction-memory",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.4",
|
|
4
4
|
"description": "User-only instruction memory for DSH: long-term instructions maintained in the settings UI and auto-injected into every conversation — the model has no write access. 指令记忆:只由用户在设置页维护的长期指令,自动注入此后每轮对话;模型没有任何写入口。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|