dsh-instruction-memory 1.0.15 → 1.0.17
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 +83 -0
- package/README.md +19 -6
- package/lib/client.js +126 -2
- package/lib/index.js +276 -43
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,89 @@
|
|
|
7
7
|
> 1.0.13 起由 CI 经 OIDC Trusted Publishing 发布(首个带 provenance 的版本)。
|
|
8
8
|
> 这些历史 tag **不应回填**——npm 上的版本号已被占用。
|
|
9
9
|
|
|
10
|
+
## [1.0.17] — 2026-10-12
|
|
11
|
+
|
|
12
|
+
### 新增
|
|
13
|
+
|
|
14
|
+
- **注入预算可视化(F1)**:设置页在「注入字数上限」下方显示用量条与「实际注入 x / y 字符
|
|
15
|
+
(约 z token,估算)」(token 按 0.6 系数估算,与既有说明同一口径,仅作参考);被上限略过的
|
|
16
|
+
**生效中**条目在列表中逐条标注橙色「本轮不注入」(悬停说明原因),极端情况下首条被迫截断时
|
|
17
|
+
标注红色「已截断」,用量条同时变为警示色并给出条数提示。调高上限或调整优先级保存后标记即时变化。
|
|
18
|
+
- Host 新增导出纯函数 `planInjection(data)`:在**不改变注入文本一个字节**的前提下,返回注入文本、
|
|
19
|
+
生效上限(clamp 后的 budget)、候选条目数,以及每条 `included`(`{id, chars, truncated}`)
|
|
20
|
+
/ `omitted`(`{id, chars}`)的取舍明细与考虑顺序(先「始终」后「按需」、同档优先级/更新时间降序);
|
|
21
|
+
`buildBlock()` 改为它的薄封装。
|
|
22
|
+
- 快照 `injection` 新增 `budget` / `activeCount` / `included` / `omitted` 字段(SSE 帧随之自动携带)。
|
|
23
|
+
存储格式仍为 v1、**无新增落盘字段**;旧客户端收到新字段自然忽略,新客户端对没有这些字段的旧宿主
|
|
24
|
+
快照回退到 `data.budgetChars`、不显示条目标记,两侧均不报错。
|
|
25
|
+
|
|
26
|
+
### 测试
|
|
27
|
+
|
|
28
|
+
- `smoke-test.mjs` 新增 20+ 条 `planInjection` 断言:空/总开关关闭/非法输入、budget 越界夹取、
|
|
29
|
+
与 `buildBlock()` 逐字节一致、停用与空条目不参与、「始终」先于「按需」、紧预算下 included/omitted
|
|
30
|
+
恰好分区全部候选、单条超长首条截断标记、计划本身确定性。
|
|
31
|
+
- `contract-test.mjs` 新增独立验证块:经真实路由在 800 字符上限下断言快照预算、`chars === preview.length`
|
|
32
|
+
且不超上限、取舍明细恰好覆盖全部生效条目且不含停用条目、预览脚注数量与 omitted 一致、提高上限后
|
|
33
|
+
全部纳入;并把真实/旧版/垃圾三类快照喂给客户端 reducer 验证接缝。
|
|
34
|
+
- `verify.mjs` 新增客户端 `__budgetMeter` reducer 断言(计划字段优先、旧快照回退、非法项忽略、
|
|
35
|
+
垃圾输入零值不抛错、percent 封顶 100、0.6 token 估算)及 6 个新 i18n 键的双语存在性;
|
|
36
|
+
键集与占位符一致性仍由既有用例锁定。
|
|
37
|
+
- `docs/demo.html` 的演示宿主改为实时计算注入计划(与 `planInjection` 同一算法),演示数据
|
|
38
|
+
默认 800 字符上限以直接展示用量条与「本轮不注入」标记;旧的手工静态预览文本移除。
|
|
39
|
+
|
|
40
|
+
## [1.0.16] — 2026-10-05
|
|
41
|
+
|
|
42
|
+
一次定向缺陷修复 + 宿主范围扩展。每项修复都有对应回归断言(`npm test` 五套件全绿:
|
|
43
|
+
lint、host-range、smoke、contract、verify)。
|
|
44
|
+
|
|
45
|
+
### 安全
|
|
46
|
+
|
|
47
|
+
- **修复 IPv6 回环被错误 403 的缺陷**:经 `http://[::1]:端口` 打开设置页时,保存 /
|
|
48
|
+
删除 / 导入 / SSE 实时刷新此前全部不可用。两个成因:① `hostnameOf('::1')`
|
|
49
|
+
把裸 IPv6 字面量按最后一个冒号截断成 `:`,永远匹配不上回环名单(名单里的
|
|
50
|
+
`::1` 是死条目);② WHATWG URL 解析器给出的 `Origin` 主机名**保留方括号**
|
|
51
|
+
(`[::1]`),而 `Host` 解析去掉方括号(`::1`),同源比较永不相等。现新增
|
|
52
|
+
IP 规范化比较(8 段零填充展开),同时覆盖 IPv4-mapped 地址的两种拼写
|
|
53
|
+
(`::ffff:127.0.0.1` 与 URL 规范化后的 `::ffff:7f00:1` 视为同一地址);
|
|
54
|
+
DNS 重绑定等真正的跨站请求在 IPv6 上同样被拒。默认 `--host 127.0.0.1` 不受
|
|
55
|
+
此问题影响。
|
|
56
|
+
- 绑定侧的回环判定同步走规范化比较:宿主绑定到 `::ffff:127.0.0.1` 这类等价
|
|
57
|
+
地址时,Host 防护不再被错误关闭。
|
|
58
|
+
|
|
59
|
+
### 缺陷修复
|
|
60
|
+
|
|
61
|
+
- **跨进程写锁补上持有者令牌(TOCTOU)**:锁文件 `memory.json.lock` 现在写入
|
|
62
|
+
本次写入的随机令牌,释放时仅当文件仍属自己才删除。此前一个超时 2 秒、"无锁
|
|
63
|
+
照写"的慢持有者,其 `finally` 可能把新持有者刚接管的锁删掉,静默放跑第三个
|
|
64
|
+
写者。过期死锁(超 10 秒)的接管策略不变。
|
|
65
|
+
- **SSE 首帧等待 boot 完成**:在宿主首次磁盘读取尚未结束时打开的设置页,不再
|
|
66
|
+
先收到一帧空存储、再收到真实快照(一次"没有任何指令"的闪烁)。
|
|
67
|
+
- **SSE 写出异常的连接现在完整清理**:广播时 `res.write` 抛错(对端已消失)会
|
|
68
|
+
注销并结束该响应,而不是只从广播集合删除、留下半注册的流;其他健康流不受
|
|
69
|
+
影响。
|
|
70
|
+
- **编辑注入字数上限时不再被实时推送打断**:输入框聚焦期间,其他窗口/进程的
|
|
71
|
+
SSE 快照不再覆盖正在输入的数字;失焦后恢复与已提交值同步。
|
|
72
|
+
- **删除不存在的 id 不再产生空写盘**:不再 bump `rev`、不写文件、不推送回声帧
|
|
73
|
+
(删除一个已被另一窗口删掉的条目时不再制造无意义的写入)。
|
|
74
|
+
|
|
75
|
+
### 样式
|
|
76
|
+
|
|
77
|
+
- 修复错误状态红点的 CSS 变量名笔误:`--dsh-alias-state-error-primary` →
|
|
78
|
+
`--dsw-alias-state-error-primary`(此前该点不跟随宿主主题,恒取兜底色);
|
|
79
|
+
verify 套件新增全文件变量前缀扫描,杜绝同类笔误再发布。
|
|
80
|
+
|
|
81
|
+
### 兼容性
|
|
82
|
+
|
|
83
|
+
- **声明范围扩展到 `0.2.1-alpha.1`(2026-10-03 发布)**:新增
|
|
84
|
+
`>=0.2.1-alpha.0 <0.3.0-0` 枚举段。该版本此前在**宿主门禁**(includePrerelease)
|
|
85
|
+
与市场判定下可通过、但在 npm/pnpm 的默认 peer 解析下会报 ERESOLVE;现两种
|
|
86
|
+
模式一致放行。三处声明(peerDependencies / dsh.engines / dsh.compatibility)
|
|
87
|
+
仍由 host-range 测试钉死逐字节一致。对 npm 上全部 30 个已发布版本穷举:
|
|
88
|
+
范围内 15 个、两模式零分歧;契约矩阵对最近 10 个版本 12/12 通过。
|
|
89
|
+
**0.2.1-alpha.1 仅有源码契约层证据,未做真机启动实测。**
|
|
90
|
+
- README 兼容性统计按当前 registry 更新(28/13 → 30/15),cordis 已发布版本
|
|
91
|
+
数 6 → 7(新增 `4.0.5-alpha.1`,已核验含 `ctx.inject`/`ctx.effect`)。
|
|
92
|
+
|
|
10
93
|
## [1.0.15] — 2026-10-03
|
|
11
94
|
|
|
12
95
|
本轮为一次系统性缺陷清查后的批量修复,每一项都有对应的回归测试(`npm test` 五套件全绿:
|
package/README.md
CHANGED
|
@@ -54,6 +54,11 @@ dsh plugin --profile web remove dsh-instruction-memory
|
|
|
54
54
|
上限单位是**字符**而非 token——按 DeepSeek 官方分词比例约 1 个中文字符 ≈ 0.6 token、
|
|
55
55
|
1 个英文字符 ≈ 0.3 token(不同模型分词有差异,实际用量以模型返回为准),上限 40000
|
|
56
56
|
字符约合 24000 token。
|
|
57
|
+
- **预算用量可视化**:设置页在上限输入框下方显示用量条——「实际注入 x / y 字符(约 z token,
|
|
58
|
+
估算)」(token 数按 0.6 系数估算,仅作参考);被上限略过的**生效中**条目会在列表里逐条
|
|
59
|
+
标注橙色「本轮不注入」(悬停可见原因),极端情况下首条被迫截断时标注红色「已截断」。
|
|
60
|
+
调高上限或调整优先级后标记实时变化。这些数据只是对注入取舍的**展示**,不改变注入文本本身;
|
|
61
|
+
存储格式不变,旧宿主/旧客户端对新字段一律忽略,互不影响。
|
|
57
62
|
|
|
58
63
|
条目全部停用或总开关关闭时,该提示词段落**整个注销**,系统提示词与未安装本插件时逐字节一致。
|
|
59
64
|
|
|
@@ -259,24 +264,28 @@ GUI 与路由本就同主机不同端口),`none`(用户直接导航)放
|
|
|
259
264
|
同一串范围(三处逐字节一致,由测试锁定):
|
|
260
265
|
|
|
261
266
|
```
|
|
262
|
-
>=0.1.3-alpha.2 <0.1.4 || >=0.1.4-0 <0.1.5-0 || >=0.1.5-alpha.1 <0.1.6-0 || >=0.1.6-alpha.0 <0.1.7-0 || >=0.1.7-alpha.0 <0.2.0-0 || >=0.2.0-0
|
|
267
|
+
>=0.1.3-alpha.2 <0.1.4 || >=0.1.4-0 <0.1.5-0 || >=0.1.5-alpha.1 <0.1.6-0 || >=0.1.6-alpha.0 <0.1.7-0 || >=0.1.7-alpha.0 <0.2.0-0 || >=0.2.1-alpha.0 <0.3.0-0 || >=0.2.0-0
|
|
263
268
|
```
|
|
264
269
|
|
|
265
270
|
**验证范围与证据等级**——三者不可混为一谈,下面每一行都标了它到底证明了什么:
|
|
266
271
|
|
|
267
272
|
| 等级 | 证明什么 | 覆盖 |
|
|
268
273
|
| --- | --- | --- |
|
|
269
|
-
| ① **真实启动** | 在该宿主版本上真的把 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.2.0-rc.1`** ⁽*⁾ |
|
|
270
|
-
| ② **契约核验** | 抓该版本**真实发布的源码**,逐个核验本插件实际用到的每个符号:`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` | 声明范围接住的**全部
|
|
274
|
+
| ① **真实启动** | 在该宿主版本上真的把 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.2.0-rc.1`** ⁽*⁾ · **`0.2.0-rc.2`** ⁽**⁾ |
|
|
275
|
+
| ② **契约核验** | 抓该版本**真实发布的源码**,逐个核验本插件实际用到的每个符号:`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` | 声明范围接住的**全部 15 个已发布版本**(`0.1.3-alpha.2` → `0.2.1-alpha.1`) |
|
|
271
276
|
| ③ **真实执行** | 把插件**真的挂载**进该版本的 `@deepseek-ai/cordis`,跑装载 / 路由注册 / 卸载共 9 项断言 | cordis `4.0.2` 与 `4.0.4`(各 9/9) |
|
|
272
277
|
|
|
273
278
|
⁽*⁾ **`0.2.0-rc.1` 的①档有一条要说明**:插件装载、注入进入组装后的提示、HTTP 路由可应答三项**均已实测**(路由 `HTTP 200`;`injection.registered=true`、`chars=437`、**`pulls=38`**——即 0.2.0-rc.1 的 agent loop 真的求值了 38 次函数型 `text`)。第四项"客户端模块被下发"**没有直接抓取**:首页需要鉴权(`/` 返回 `401`),我无法取到 `window.__DSH_BOOT__`。这一项的支撑是间接但强的两条:① `dsh-client-modules` 在合成阶段对 `dsh.client` 声明做硬校验,不合格会抛 `ClientPackageCompositionError` 直接导致启动失败——宿主正常启动即说明声明通过了 0.2.0-rc.1 的校验;② 本插件用到的三个客户端契约包(`-slots` / `-renderer` / `-settings`)相对升级前真正在跑的 `0.1.7-rc.2` **逐字节未变**。**未做人工视觉确认**(没有打开设置页看)。
|
|
274
279
|
|
|
275
|
-
|
|
280
|
+
⁽**⁾ **`0.2.0-rc.2` 的①档**:桌面版 `0.2.0-rc.2`(私有发行版 `dsh-desktop-runtime`,见仓库 `dsh-memory-verification/DSH-VERSION-COMPAT.md` §12)上完成过路由 200 + `registered=true` + 注入段落真实出现在会话提示中的活体实测;网页版宿主 `0.2.0-rc.2` 上对本插件路由的 `state` 探测同样返回 200。同样**未做设置页人工视觉确认**。
|
|
276
281
|
|
|
277
|
-
|
|
282
|
+
此外 `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`,并会**读取本机实际安装的宿主版本**做实时断言。
|
|
278
283
|
|
|
279
|
-
|
|
284
|
+
**关于 1.0.10 新增的 `ctx.inject`。** 这是新版唯一的硬依赖增量:`inject` 去掉了 `webServer`,设置页路由改为非阻塞的 `ctx.inject(['webServer'], …)` 子 fiber(原因见上)。为此逐个核验了 **cordis 全部 7 个已发布版本**(`4.0.1-rc.1` → `4.0.5-alpha.1`)——**每一个都具备 `ctx.inject`**(其中 4.0.2 / 4.0.4 另做了真实挂载执行,4.0.5-alpha.1 为源码符号核验),因此声明范围内不存在因它而失效的宿主版本。
|
|
285
|
+
|
|
286
|
+
**关于声明范围是否"超范围承诺"。** 已对全部 **30 个**已发布宿主版本穷举核验(2026-10-05,npm 官方 registry):范围内 **15 个**版本的 API 面**全部齐备,超范围承诺 0 项**,且默认与 `includePrerelease` 两种语义在这 15 个版本上零分歧。范围外的 15 个版本(`0.0.1-rc.1` → `0.1.2-rc.1`)API 面**同样齐备**,但它们**从未真实启动验证过**,所以下限维持不动、不向外放宽——不把没验证过的东西写进声明。
|
|
287
|
+
|
|
288
|
+
**关于 `0.2.1-alpha.1`(2026-10-03 发布,1.0.16 起覆盖)。** 它是前述"未来 minor 预发布版本在默认语义下接不住"的**第一个真实发生案例**:1.0.15 的范围在宿主门禁与市场判定(均用 `includePrerelease`)下放行它,但在 npm/pnpm 默认 peer 解析下拒绝(ERESOLVE);其耦合面(9 个契约点)经最近 10 版本矩阵核验全部齐备,cordis 为 `4.0.5-alpha.1`。1.0.16 新增 `>=0.2.1-alpha.0 <0.3.0-0` 枚举段后两种语义一致放行。**该版本仅有契约层证据,未做真机启动实测。**
|
|
280
289
|
|
|
281
290
|
**`0.2.0-rc.1` 的核验口径**(2026-09-29 增补):基准取 npm 上**真实发布的 `0.1.7-rc.2`**(升级前本机真正在跑的版本),逐文件 SHA256 与 `0.2.0-rc.1` 比对。本插件的 6 个耦合点里 **6 个逐字节未变**(`dsh-system-prompt` 4/4、`dsh-client-ui-slots` 4/4、`-renderer` 12/12、`-settings` 11/11、`dsh-host-webserver` 3/3、`dsh-client-modules` 10/10);变动的只有 `dsh-agent-loop`(注入通路 `preStep` / `assemble` 逐行等同)与 `dsh-app-boot`(兼容门禁函数逐字节等同)。方法带**阴性对照**:同法比对 `0.1.6-alpha.2` 得到的哈希**不同**,证明这套比对确实能检出变化,而不是恒真。
|
|
282
291
|
|
|
@@ -295,6 +304,10 @@ major.minor.patch 且自身带预发布标签的比较符"。这条规则是**
|
|
|
295
304
|
⚠️ **一个必须知道的上限(注意它是"模式相关"的,别当成普适结论):未声明的未来 minor 的预发布版本,在 npm 的默认语义下无法被任何范围覆盖。**
|
|
296
305
|
`0.1.8-rc.1` 这类版本 tuple 与所有比较符都不同,在 **npm 默认语义**下**永远匹配不到**——与
|
|
297
306
|
`^0.1.0` 无法匹配 `0.1.8-rc.1` 是同一个限制。正式版(`0.1.8`、`0.2.0`、`1.0.0` 等)不受影响。
|
|
307
|
+
这个限制不再只是假设:**`0.2.1-alpha.1`(2026-10-03 发布)就是第一个真实撞上它的版本**,
|
|
308
|
+
1.0.16 已通过新增 `0.2.1` 枚举段闭合;下一个未声明 tuple(如 `0.2.2-rc.1`、`0.3.0-rc.1`)
|
|
309
|
+
仍会重演同一现象,需要发一版加一行——host-range 的实时守卫会在本机升级到该宿主时
|
|
310
|
+
直接让测试失败并给出可直接套用的修复行。
|
|
298
311
|
|
|
299
312
|
**但在 `includePrerelease: true` 下这条上限不成立。** 实测同一串范围:`0.1.8-rc.1` 在默认语义下 `false`、
|
|
300
313
|
在 `includePrerelease: true` 下 **`true`**(已用 DSH 自带的 semver 7.8.5 验证)。而这个区别是**有后果**的,
|
package/lib/client.js
CHANGED
|
@@ -58,6 +58,12 @@ window.__ModuleLoader__.load({
|
|
|
58
58
|
.im-tag-always { color:var(--dsw-alias-brand-primary,#3b6cff); border-color:currentColor; }
|
|
59
59
|
.im-tag-auto { color:var(--dsw-alias-state-success-primary,#18a058); border-color:currentColor; }
|
|
60
60
|
.im-tag-high { color:var(--dsw-alias-state-warn-primary,#d97706); border-color:currentColor; }
|
|
61
|
+
.im-tag-off { color:var(--dsw-alias-state-warn-primary,#d97706); border-color:currentColor; }
|
|
62
|
+
.im-tag-cut { color:var(--dsw-alias-state-error-primary,#d94838); border-color:currentColor; }
|
|
63
|
+
.im-meter { height:6px; border-radius:999px; overflow:hidden;
|
|
64
|
+
background:var(--dsw-alias-bg-layer-2,rgba(0,0,0,.08)); }
|
|
65
|
+
.im-meter-fill { height:100%; border-radius:999px; background:var(--dsw-alias-brand-primary,#3b6cff); }
|
|
66
|
+
.im-meter-fill-warn { background:var(--dsw-alias-state-warn-primary,#d97706); }
|
|
61
67
|
.im-empty { padding:26px; border-radius:10px; text-align:center; font-size:12px;
|
|
62
68
|
border:1px dashed var(--dsw-alias-border-l2,rgba(0,0,0,.18)); color:var(--dsw-alias-label-secondary,#8a9099); }
|
|
63
69
|
.im-err { font-size:12px; color:var(--dsw-alias-state-error-primary,#d94838); }
|
|
@@ -75,10 +81,11 @@ window.__ModuleLoader__.load({
|
|
|
75
81
|
.im-dot { width:7px; height:7px; border-radius:50%; flex:none; background:var(--dsw-alias-label-secondary,#8a9099); }
|
|
76
82
|
.im-dot-on { background:var(--dsw-alias-state-success-primary,#18a058); }
|
|
77
83
|
.im-dot-off { background:var(--dsw-alias-state-warn-primary,#d97706); }
|
|
78
|
-
.im-dot-err { background:var(--
|
|
84
|
+
.im-dot-err { background:var(--dsw-alias-state-error-primary,#d94838); }
|
|
79
85
|
.im-body-open { max-height:none; }
|
|
80
86
|
.im-more { margin-top:4px; font-size:11.5px; color:var(--dsw-alias-brand-primary,#3b6cff); cursor:pointer; user-select:none; }
|
|
81
87
|
.im-search-meta { margin-top:5px; }
|
|
88
|
+
.im-meter-line { margin-top:5px; }
|
|
82
89
|
.im-undo { display:flex; align-items:center; gap:10px; padding:9px 13px; border-radius:8px; font-size:12px;
|
|
83
90
|
border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.14)); background:var(--dsw-alias-bg-layer-2,rgba(0,0,0,.04)); }
|
|
84
91
|
.im-undo button { border:1px solid var(--dsw-alias-brand-primary,#3b6cff); color:var(--dsw-alias-brand-primary,#3b6cff);
|
|
@@ -140,6 +147,12 @@ window.__ModuleLoader__.load({
|
|
|
140
147
|
'budget.unitHint': '单位是字符,不是 token',
|
|
141
148
|
'budget.rangeError': '上限需在 {min} ~ {max} 之间',
|
|
142
149
|
'budget.note': '单位为字符、非 token:按 DeepSeek 分词约 1 个中文字符 ≈ 0.6 token(英文 ≈ 0.3),{max} 字符上限约合 {tokens} token,实际以模型为准。',
|
|
150
|
+
'budget.used': '实际注入 {used} / {budget} 字符(约 {tokens} token,估算)',
|
|
151
|
+
'budget.omitted': '有 {count} 条生效指令因超出上限本轮未注入,可调高上限或调整优先级',
|
|
152
|
+
'tag.omitted': '本轮不注入',
|
|
153
|
+
'tag.truncated': '已截断',
|
|
154
|
+
'tag.omittedTitle': '因超出注入字数上限,本条当前不会进入系统提示词',
|
|
155
|
+
'tag.truncatedTitle': '本条过长,仅开头部分进入系统提示词',
|
|
143
156
|
'rules.label': '生效规则',
|
|
144
157
|
'rules.note': '「始终」无条件生效;「按需」由模型按相关性自动判断。超出上限时按优先级保留,其余暂不注入。',
|
|
145
158
|
'search.placeholder': '搜索指令:标题 / 内容 / 适用场景(共 {count} 条)',
|
|
@@ -217,6 +230,12 @@ window.__ModuleLoader__.load({
|
|
|
217
230
|
'budget.unitHint': 'The unit is characters, not tokens',
|
|
218
231
|
'budget.rangeError': 'The limit must be between {min} and {max}',
|
|
219
232
|
'budget.note': 'Characters, not tokens: with DeepSeek tokenization roughly 1 Chinese character ≈ 0.6 token (English ≈ 0.3), the {max}-character ceiling is about {tokens} tokens; actual usage depends on the model.',
|
|
233
|
+
'budget.used': '{used} of {budget} characters actually injected (≈{tokens} tokens, estimate)',
|
|
234
|
+
'budget.omitted': '{count} active entries are not injected this turn because the limit is exceeded; raise the limit or adjust priorities',
|
|
235
|
+
'tag.omitted': 'Not injected',
|
|
236
|
+
'tag.truncated': 'Truncated',
|
|
237
|
+
'tag.omittedTitle': 'This entry currently stays out of the system prompt because the injection size limit is exceeded',
|
|
238
|
+
'tag.truncatedTitle': 'This entry is too long; only its beginning reaches the system prompt',
|
|
220
239
|
'rules.label': 'How instructions apply',
|
|
221
240
|
'rules.note': '“Always” applies unconditionally; “On demand” lets the model judge by relevance. When the limit is exceeded, higher-priority entries are kept and the rest are skipped.',
|
|
222
241
|
'search.placeholder': 'Search instructions: title / content / scenario ({count} in total)',
|
|
@@ -485,6 +504,53 @@ window.__ModuleLoader__.load({
|
|
|
485
504
|
}
|
|
486
505
|
}
|
|
487
506
|
|
|
507
|
+
/**
|
|
508
|
+
* What the budget input should show after a snapshot arrives.
|
|
509
|
+
*
|
|
510
|
+
* A push from another window used to reset budgetText unconditionally
|
|
511
|
+
* inside absorb(), so editing the injection-size field while any other
|
|
512
|
+
* panel saved wiped the half-typed number. While the input is focused the
|
|
513
|
+
* user's draft wins; the committed value re-syncs on blur.
|
|
514
|
+
*/
|
|
515
|
+
function nextBudgetText(currentText, incoming, focused) {
|
|
516
|
+
return focused ? currentText : String(incoming)
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Reduce a snapshot's injection plan to the values the header meter and
|
|
521
|
+
* the entry badges render. Pinned as a pure export so the fallback math is
|
|
522
|
+
* testable without React.
|
|
523
|
+
*
|
|
524
|
+
* Tolerates an older Host whose snapshot predates the plan fields: the
|
|
525
|
+
* ceiling then falls back to data.budgetChars and there are simply no
|
|
526
|
+
* per-entry marks (included/omitted). Garbage input degrades to zeros
|
|
527
|
+
* instead of throwing mid-render.
|
|
528
|
+
*/
|
|
529
|
+
function budgetMeter(snapshot) {
|
|
530
|
+
const inj = snapshot !== null && typeof snapshot === 'object'
|
|
531
|
+
&& snapshot.injection !== null && typeof snapshot.injection === 'object'
|
|
532
|
+
? snapshot.injection : {}
|
|
533
|
+
const data = snapshot !== null && typeof snapshot === 'object'
|
|
534
|
+
&& snapshot.data !== null && typeof snapshot.data === 'object'
|
|
535
|
+
? snapshot.data : {}
|
|
536
|
+
const budget = typeof inj.budget === 'number' && inj.budget > 0
|
|
537
|
+
? inj.budget
|
|
538
|
+
: (typeof data.budgetChars === 'number' && data.budgetChars > 0 ? data.budgetChars : 0)
|
|
539
|
+
const used = typeof inj.chars === 'number' && inj.chars >= 0 ? inj.chars : 0
|
|
540
|
+
const countFlagged = (list, wantTruncated) =>
|
|
541
|
+
Array.isArray(list)
|
|
542
|
+
? list.filter((item) => item !== null && typeof item === 'object'
|
|
543
|
+
&& typeof item.id === 'string' && item.id !== ''
|
|
544
|
+
&& (wantTruncated ? item.truncated === true : true)).length
|
|
545
|
+
: 0
|
|
546
|
+
const omitted = countFlagged(inj.omitted, false)
|
|
547
|
+
const truncated = countFlagged(inj.included, true)
|
|
548
|
+
const percent = budget > 0 ? Math.min(100, Math.round((used / budget) * 100)) : 0
|
|
549
|
+
// The same rough ratio the budget note quotes; displayed as an ESTIMATE.
|
|
550
|
+
const tokens = Math.round(used * 0.6)
|
|
551
|
+
return { budget, used, omitted, truncated, percent, tokens }
|
|
552
|
+
}
|
|
553
|
+
|
|
488
554
|
async function callHost(method, args) {
|
|
489
555
|
// A hung host used to pin the panel on 保存中… forever; fail visibly
|
|
490
556
|
// after a generous timeout instead.
|
|
@@ -613,10 +679,18 @@ window.__ModuleLoader__.load({
|
|
|
613
679
|
const [busy, setBusy] = React.useState(false)
|
|
614
680
|
const [draft, setDraft] = React.useState(null)
|
|
615
681
|
const [budgetText, setBudgetText] = React.useState('')
|
|
682
|
+
// Latest live value of the draft, because the SSE onmessage closure below
|
|
683
|
+
// is created once and must not read a stale first-render budgetText.
|
|
684
|
+
const budgetTextRef = React.useRef('')
|
|
685
|
+
budgetTextRef.current = budgetText
|
|
616
686
|
const [showPreview, setShowPreview] = React.useState(false)
|
|
617
687
|
const [query, setQuery] = React.useState('')
|
|
618
688
|
const [undo, setUndo] = React.useState(null)
|
|
619
689
|
const importInputRef = React.useRef(null)
|
|
690
|
+
// Whether the budget input is being edited right now. A live SSE push
|
|
691
|
+
// from another window must not replace the number under the cursor;
|
|
692
|
+
// the ref (not state) is read inside absorb so no re-render is needed.
|
|
693
|
+
const budgetFocusedRef = React.useRef(false)
|
|
620
694
|
// The latest snapshot this panel has absorbed. The SSE effect runs once
|
|
621
695
|
// (empty deps), so it cannot read the `view` state of a later render —
|
|
622
696
|
// the ref is how its frames are compared against what is on screen.
|
|
@@ -668,7 +742,11 @@ window.__ModuleLoader__.load({
|
|
|
668
742
|
}
|
|
669
743
|
setView(normalized.snapshot)
|
|
670
744
|
viewRef.current = normalized.snapshot
|
|
671
|
-
|
|
745
|
+
// Keep the user's in-progress budget edit when a push arrives while
|
|
746
|
+
// the input is focused; re-sync only once editing is not active. The
|
|
747
|
+
// ref is the live draft — absorb may run from the SSE closure created
|
|
748
|
+
// on the first render.
|
|
749
|
+
setBudgetText(nextBudgetText(budgetTextRef.current, normalized.snapshot.data.budgetChars, budgetFocusedRef.current))
|
|
672
750
|
if (!normalized.ok && normalized.message) {
|
|
673
751
|
setError(normalized.message)
|
|
674
752
|
setNotice('')
|
|
@@ -813,6 +891,23 @@ window.__ModuleLoader__.load({
|
|
|
813
891
|
const injection = view.injection || { registered: false, chars: 0, error: null }
|
|
814
892
|
const entries = data.entries.slice().sort((a, b) => (b.priority - a.priority) || (b.updatedAt - a.updatedAt))
|
|
815
893
|
const activeCount = entries.filter((e) => e.enabled && e.content !== '').length
|
|
894
|
+
// Budget usage + per-entry marks straight from the Host's injection plan.
|
|
895
|
+
// Older hosts simply publish none of these fields: the meter falls back
|
|
896
|
+
// to data.budgetChars and the maps stay empty.
|
|
897
|
+
const meter = budgetMeter(view)
|
|
898
|
+
const omittedById = new Map()
|
|
899
|
+
const truncatedById = new Map()
|
|
900
|
+
{
|
|
901
|
+
const planItems = injection && typeof injection === 'object' ? injection : {}
|
|
902
|
+
for (const item of Array.isArray(planItems.omitted) ? planItems.omitted : []) {
|
|
903
|
+
if (item !== null && typeof item === 'object' && typeof item.id === 'string') omittedById.set(item.id, item)
|
|
904
|
+
}
|
|
905
|
+
for (const item of Array.isArray(planItems.included) ? planItems.included : []) {
|
|
906
|
+
if (item !== null && typeof item === 'object' && item.truncated === true && typeof item.id === 'string') {
|
|
907
|
+
truncatedById.set(item.id, item)
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
}
|
|
816
911
|
const keyword = query.trim().toLowerCase()
|
|
817
912
|
const visible = keyword === ''
|
|
818
913
|
? entries
|
|
@@ -858,8 +953,11 @@ window.__ModuleLoader__.load({
|
|
|
858
953
|
value: budgetText,
|
|
859
954
|
disabled: busy,
|
|
860
955
|
title: t('budget.unitHint'),
|
|
956
|
+
onFocus: () => { budgetFocusedRef.current = true },
|
|
861
957
|
onChange: (ev) => setBudgetText(ev.target.value),
|
|
862
958
|
onBlur: () => {
|
|
959
|
+
// Editing is over: later pushes may sync the field again.
|
|
960
|
+
budgetFocusedRef.current = false
|
|
863
961
|
const parsed = Number(budgetText)
|
|
864
962
|
if (!Number.isFinite(parsed) || parsed < limits.minBudget || parsed > limits.maxBudget) {
|
|
865
963
|
setBudgetText(String(data.budgetChars))
|
|
@@ -875,6 +973,19 @@ window.__ModuleLoader__.load({
|
|
|
875
973
|
h('label', { className: 'im-label' }, t('rules.label')),
|
|
876
974
|
h('div', { className: 'im-meta' },
|
|
877
975
|
t('rules.note')))),
|
|
976
|
+
(data.enabled === true && activeCount > 0)
|
|
977
|
+
? h('div', { key: 'meter' },
|
|
978
|
+
h('div', { className: 'im-meter' },
|
|
979
|
+
h('div', {
|
|
980
|
+
className: 'im-meter-fill' + (meter.omitted > 0 ? ' im-meter-fill-warn' : ''),
|
|
981
|
+
style: { width: meter.percent + '%' },
|
|
982
|
+
})),
|
|
983
|
+
h('div', { className: 'im-meta im-meter-line' },
|
|
984
|
+
t('budget.used', { used: meter.used, budget: meter.budget, tokens: meter.tokens })),
|
|
985
|
+
meter.omitted > 0
|
|
986
|
+
? h('div', { className: 'im-warn im-meter-line' }, t('budget.omitted', { count: meter.omitted }))
|
|
987
|
+
: null)
|
|
988
|
+
: null,
|
|
878
989
|
entries.length > 0 ? h('div', { key: 'search' },
|
|
879
990
|
h('input', {
|
|
880
991
|
className: 'im-input',
|
|
@@ -960,6 +1071,17 @@ window.__ModuleLoader__.load({
|
|
|
960
1071
|
]
|
|
961
1072
|
if (entry.priority === 2) tags.push(h('span', { className: 'im-tag im-tag-high', key: 'p' }, t('priority.high')))
|
|
962
1073
|
else if (entry.priority === 0) tags.push(h('span', { className: 'im-tag', key: 'p' }, t('priority.low')))
|
|
1074
|
+
// Budget marks from the Host's injection plan. Only active entries can
|
|
1075
|
+
// appear in the plans, so a disabled entry never gets a stale mark.
|
|
1076
|
+
if (omittedById.has(entry.id)) {
|
|
1077
|
+
tags.push(h('span', {
|
|
1078
|
+
className: 'im-tag im-tag-off', key: 'off', title: t('tag.omittedTitle'),
|
|
1079
|
+
}, t('tag.omitted')))
|
|
1080
|
+
} else if (truncatedById.has(entry.id)) {
|
|
1081
|
+
tags.push(h('span', {
|
|
1082
|
+
className: 'im-tag im-tag-cut', key: 'cut', title: t('tag.truncatedTitle'),
|
|
1083
|
+
}, t('tag.truncated')))
|
|
1084
|
+
}
|
|
963
1085
|
return h('div', {
|
|
964
1086
|
className: 'im-item' + (entry.enabled ? '' : ' im-item-off'),
|
|
965
1087
|
key: entry.id,
|
|
@@ -1108,6 +1230,8 @@ window.__ModuleLoader__.load({
|
|
|
1108
1230
|
exports.__sameSnapshot = sameSnapshot
|
|
1109
1231
|
exports.__confirmDiscard = confirmDiscard
|
|
1110
1232
|
exports.__importSizeVerdict = importSizeVerdict
|
|
1233
|
+
exports.__nextBudgetText = nextBudgetText
|
|
1234
|
+
exports.__budgetMeter = budgetMeter
|
|
1111
1235
|
exports.__strings = STRINGS
|
|
1112
1236
|
exports.__ns = NS
|
|
1113
1237
|
exports.__dictionaryT = dictionaryT
|
package/lib/index.js
CHANGED
|
@@ -10,8 +10,10 @@
|
|
|
10
10
|
* plugin's fiber, so unmounting the row removes all of them.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { copyFile, mkdir,
|
|
13
|
+
import { copyFile, mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'
|
|
14
14
|
import { watch } from 'node:fs'
|
|
15
|
+
import { randomUUID } from 'node:crypto'
|
|
16
|
+
import { isIP } from 'node:net'
|
|
15
17
|
import { homedir } from 'node:os'
|
|
16
18
|
import { basename, dirname, join } from 'node:path'
|
|
17
19
|
|
|
@@ -326,6 +328,13 @@ const LOOPBACK_HOSTNAMES = new Set(['127.0.0.1', 'localhost', '::1', '::ffff:127
|
|
|
326
328
|
*
|
|
327
329
|
* `127.0.0.1:8080` -> `127.0.0.1`; `[::1]:8080` -> `::1`; `localhost` -> `localhost`.
|
|
328
330
|
* Exported so the parsing itself can be pinned by tests.
|
|
331
|
+
*
|
|
332
|
+
* HTTP requires IPv6 literals in Host to wear brackets, so the bracketed
|
|
333
|
+
* form is the one real traffic uses; the bare form still has to parse instead
|
|
334
|
+
* of being chopped at the last colon (the old code turned a bare `::1` into
|
|
335
|
+
* `:`, which never matched the loopback set). A bare value carrying MORE than
|
|
336
|
+
* one colon is an IPv6 literal with no port — with at most one colon it is
|
|
337
|
+
* host:port.
|
|
329
338
|
*/
|
|
330
339
|
export function hostnameOf(value) {
|
|
331
340
|
if (typeof value !== 'string') return ''
|
|
@@ -333,12 +342,100 @@ export function hostnameOf(value) {
|
|
|
333
342
|
if (trimmed === '') return ''
|
|
334
343
|
if (trimmed.startsWith('[')) {
|
|
335
344
|
const end = trimmed.indexOf(']')
|
|
336
|
-
return end === -1 ? trimmed : trimmed.slice(1, end)
|
|
345
|
+
return end === -1 ? trimmed.slice(1) : trimmed.slice(1, end)
|
|
337
346
|
}
|
|
347
|
+
// Unbracketed IPv6 literal: the colons are part of the address, not a port.
|
|
348
|
+
if (trimmed.indexOf(':') !== trimmed.lastIndexOf(':')) return trimmed
|
|
338
349
|
const colon = trimmed.lastIndexOf(':')
|
|
339
350
|
return colon === -1 ? trimmed : trimmed.slice(0, colon)
|
|
340
351
|
}
|
|
341
352
|
|
|
353
|
+
/**
|
|
354
|
+
* Normalise a hostname the same way on BOTH sides of the Origin comparison.
|
|
355
|
+
*
|
|
356
|
+
* `new URL('http://[::1]:8080').hostname` keeps the brackets (`[::1]`), while
|
|
357
|
+
* `hostnameOf('[::1]:8080')` strips them (`::1`) — comparing the two raw forms
|
|
358
|
+
* rejected every same-origin request that reached the GUI over IPv6 loopback.
|
|
359
|
+
* Strip one surrounding pair of brackets here so both sides spell an IPv6
|
|
360
|
+
* literal identically.
|
|
361
|
+
*/
|
|
362
|
+
const normalizeHostname = (value) => (
|
|
363
|
+
typeof value === 'string' && value.startsWith('[') && value.endsWith(']') && value.length >= 2
|
|
364
|
+
? value.slice(1, -1)
|
|
365
|
+
: value
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Expand an IPv6 literal to eight lower-cased zero-padded groups, translating a
|
|
370
|
+
* trailing embedded IPv4 suffix to two hex groups. Returns null for anything
|
|
371
|
+
* that is not a valid literal.
|
|
372
|
+
*
|
|
373
|
+
* Needed because the two sides of the Origin check spell the SAME address
|
|
374
|
+
* differently: a browser sends Host `[::ffff:127.0.0.1]` while the URL parser
|
|
375
|
+
* canonicalises Origin to `[::ffff:7f00:1]` — text comparison says these are
|
|
376
|
+
* different; address comparison must say they are the same.
|
|
377
|
+
*/
|
|
378
|
+
export function canonicalIp(value) {
|
|
379
|
+
if (typeof value !== 'string') return null
|
|
380
|
+
let addr = normalizeHostname(value.trim().toLowerCase())
|
|
381
|
+
const percent = addr.indexOf('%')
|
|
382
|
+
if (percent !== -1) addr = addr.slice(0, percent)
|
|
383
|
+
if (isIP(addr) === 4) {
|
|
384
|
+
return 'v4:' + addr.split('.').map((part) => Number(part)).join('.')
|
|
385
|
+
}
|
|
386
|
+
if (isIP(addr) !== 6) return null
|
|
387
|
+
|
|
388
|
+
// Translate a trailing dotted-quad (e.g. ::ffff:127.0.0.1) into two groups.
|
|
389
|
+
const v4 = /(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(addr)
|
|
390
|
+
if (v4 !== null) {
|
|
391
|
+
const octets = v4[1].split('.').map((part) => Number(part))
|
|
392
|
+
if (octets.some((octet) => octet > 255)) return null
|
|
393
|
+
const prefix = addr.slice(0, v4.index)
|
|
394
|
+
const hi = (octets[0] << 8 | octets[1]).toString(16)
|
|
395
|
+
const lo = (octets[2] << 8 | octets[3]).toString(16)
|
|
396
|
+
// Keep the trailing ':' of the prefix as the separator before the two
|
|
397
|
+
// translated groups; it may also be the second ':' of a '::' compression,
|
|
398
|
+
// which must survive (so do not strip a leading colon afterwards).
|
|
399
|
+
addr = (prefix.endsWith(':') ? prefix : prefix + ':') + hi + ':' + lo
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const halves = addr.split('::')
|
|
403
|
+
if (halves.length > 2) return null
|
|
404
|
+
if (halves.length === 1) {
|
|
405
|
+
const groups = halves[0].split(':')
|
|
406
|
+
if (groups.length !== 8 || groups.some((group) => !/^[0-9a-f]{1,4}$/i.test(group))) return null
|
|
407
|
+
return groups.map((group) => parseInt(group, 16).toString(16).padStart(4, '0')).join(':')
|
|
408
|
+
}
|
|
409
|
+
const [leftRaw, rightRaw] = halves
|
|
410
|
+
const left = leftRaw === '' ? [] : leftRaw.split(':')
|
|
411
|
+
const right = rightRaw === '' ? [] : rightRaw.split(':')
|
|
412
|
+
const missing = 8 - left.length - right.length
|
|
413
|
+
if (missing < 0) return null
|
|
414
|
+
const groups = left.concat(Array(missing).fill('0'), right)
|
|
415
|
+
if (groups.length !== 8 || groups.some((group) => !/^[0-9a-f]{1,4}$/i.test(group))) return null
|
|
416
|
+
return groups.map((group) => parseInt(group, 16).toString(16).padStart(4, '0')).join(':')
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Whether two hostnames are the same endpoint: exact text or same IP literal. */
|
|
420
|
+
const sameHostname = (a, b) => {
|
|
421
|
+
if (a === b) return true
|
|
422
|
+
const ca = canonicalIp(a)
|
|
423
|
+
const cb = canonicalIp(b)
|
|
424
|
+
return ca !== null && cb !== null && ca === cb
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Loopback by exact name or by equivalent IP spelling (mapped/hex forms). */
|
|
428
|
+
const isLoopbackName = (name) => {
|
|
429
|
+
if (LOOPBACK_HOSTNAMES.has(name)) return true
|
|
430
|
+
const canonical = canonicalIp(name)
|
|
431
|
+
if (canonical === null) return false
|
|
432
|
+
for (const known of LOOPBACK_HOSTNAMES) {
|
|
433
|
+
const knownCanonical = canonicalIp(known)
|
|
434
|
+
if (knownCanonical !== null && knownCanonical === canonical) return true
|
|
435
|
+
}
|
|
436
|
+
return false
|
|
437
|
+
}
|
|
438
|
+
|
|
342
439
|
/**
|
|
343
440
|
* Why the settings-page route checks `Host`, `Origin` and `Sec-Fetch-Site` at
|
|
344
441
|
* all, when it already refuses any content-type that is not
|
|
@@ -380,7 +477,7 @@ export function requestOriginVerdict(headers, guarded) {
|
|
|
380
477
|
const requestHost = read('host').trim().toLowerCase()
|
|
381
478
|
const requestHostname = hostnameOf(requestHost)
|
|
382
479
|
|
|
383
|
-
if (guarded && !
|
|
480
|
+
if (guarded && !isLoopbackName(requestHostname)) {
|
|
384
481
|
return { ok: false, reason: 'Host 头不是回环地址(可能是 DNS 重绑定攻击):' + (requestHost === '' ? '(缺失)' : requestHost) }
|
|
385
482
|
}
|
|
386
483
|
|
|
@@ -393,14 +490,19 @@ export function requestOriginVerdict(headers, guarded) {
|
|
|
393
490
|
if (origin !== '') {
|
|
394
491
|
let originHostname = ''
|
|
395
492
|
try {
|
|
396
|
-
|
|
493
|
+
// URL.hostname keeps the brackets on an IPv6 literal; normalizeHostname
|
|
494
|
+
// takes them off so it can meet the bracket-free Host parsing on equal
|
|
495
|
+
// terms.
|
|
496
|
+
originHostname = normalizeHostname(new URL(origin).hostname.toLowerCase())
|
|
397
497
|
} catch {
|
|
398
498
|
originHostname = ''
|
|
399
499
|
}
|
|
400
500
|
// Compare hostnames, not host:port: a proxy may rewrite the port, and the
|
|
401
501
|
// hostname is the part rebinding actually attacks. `Origin: null`
|
|
402
502
|
// (sandboxed iframe, file://) parses to no hostname and is refused.
|
|
403
|
-
|
|
503
|
+
// Canonical IP comparison covers the two spellings of one address:
|
|
504
|
+
// ::1 vs [::1], and ::ffff:127.0.0.1 vs ::ffff:7f00:1.
|
|
505
|
+
if (originHostname === '' || !sameHostname(originHostname, requestHostname)) {
|
|
404
506
|
return { ok: false, reason: 'Origin 与 Host 不同源:' + origin }
|
|
405
507
|
}
|
|
406
508
|
}
|
|
@@ -416,19 +518,41 @@ function formatEntry(entry, tag) {
|
|
|
416
518
|
}
|
|
417
519
|
|
|
418
520
|
/**
|
|
419
|
-
*
|
|
420
|
-
*
|
|
421
|
-
*
|
|
521
|
+
* Build the injection plan: the exact text assembled into the system prompt
|
|
522
|
+
* PLUS the per-entry decisions behind it, so the settings page can show how
|
|
523
|
+
* much of the character budget is in use and flag the enabled entries the
|
|
524
|
+
* current budget leaves out.
|
|
525
|
+
*
|
|
526
|
+
* Deliberately deterministic: identical memory produces a byte-identical
|
|
527
|
+
* plan on every step, so the prompt prefix stays cacheable across a session.
|
|
528
|
+
*
|
|
529
|
+
* Returned shape:
|
|
530
|
+
* {
|
|
531
|
+
* text: string — exactly what buildBlock() returns
|
|
532
|
+
* budget: number — the clamped budgetChars in effect
|
|
533
|
+
* activeCount: number — enabled, non-blank candidates considered
|
|
534
|
+
* included: [{ id, chars, truncated }] — entries that reach the prompt,
|
|
535
|
+
* in consideration order (始终 first, then 按需; within
|
|
536
|
+
* each, priority desc then updatedAt desc); chars is the
|
|
537
|
+
* rendered length of that entry's block (its cut length
|
|
538
|
+
* when truncated)
|
|
539
|
+
* omitted: [{ id, chars }] — active entries the budget could not fit;
|
|
540
|
+
* chars is the full rendered block length they would need
|
|
541
|
+
* }
|
|
542
|
+
*
|
|
543
|
+
* Disabled or blank entries are candidates for neither list: they are not
|
|
544
|
+
* competing for the budget at all. When nothing renders, `text` is '' and
|
|
545
|
+
* `included` is [] even though `budget` still reports the effective ceiling.
|
|
422
546
|
*/
|
|
423
|
-
export function
|
|
424
|
-
|
|
547
|
+
export function planInjection(data) {
|
|
548
|
+
const budget = clampBudget(data !== null && typeof data === 'object' ? data.budgetChars : undefined)
|
|
549
|
+
const blank = () => ({ text: '', budget, activeCount: 0, included: [], omitted: [] })
|
|
550
|
+
if (data === null || typeof data !== 'object' || data.enabled !== true) return blank()
|
|
425
551
|
const active = (Array.isArray(data.entries) ? data.entries : [])
|
|
426
552
|
.filter((entry) => entry.enabled === true && entry.content !== '')
|
|
427
|
-
if (active.length === 0) return
|
|
553
|
+
if (active.length === 0) return blank()
|
|
428
554
|
active.sort((a, b) => (b.priority - a.priority) || (b.updatedAt - a.updatedAt))
|
|
429
555
|
|
|
430
|
-
const budget = clampBudget(data.budgetChars)
|
|
431
|
-
|
|
432
556
|
// Richest preamble that still leaves room for one real entry plus the
|
|
433
557
|
// worst-case omission footer. A tight budget degrades the preamble instead of
|
|
434
558
|
// silently injecting nothing.
|
|
@@ -463,6 +587,8 @@ export function buildBlock(data) {
|
|
|
463
587
|
}
|
|
464
588
|
|
|
465
589
|
const blocks = []
|
|
590
|
+
const included = []
|
|
591
|
+
const omittedPlan = []
|
|
466
592
|
let used = header.length
|
|
467
593
|
let omitted = 0
|
|
468
594
|
|
|
@@ -474,6 +600,7 @@ export function buildBlock(data) {
|
|
|
474
600
|
if (used + block.length + 2 + reserve <= budget) {
|
|
475
601
|
used += block.length + 2
|
|
476
602
|
blocks.push(block)
|
|
603
|
+
included.push({ id: entry.id, chars: block.length, truncated: false })
|
|
477
604
|
return
|
|
478
605
|
}
|
|
479
606
|
// The first entry always gets in, truncated if the budget demands it: a
|
|
@@ -491,17 +618,36 @@ export function buildBlock(data) {
|
|
|
491
618
|
const body = head + note
|
|
492
619
|
blocks.push(body)
|
|
493
620
|
used += body.length + 2
|
|
621
|
+
included.push({ id: entry.id, chars: body.length, truncated: true })
|
|
494
622
|
return
|
|
495
623
|
}
|
|
496
624
|
}
|
|
497
625
|
omitted += 1
|
|
626
|
+
omittedPlan.push({ id: entry.id, chars: block.length })
|
|
498
627
|
}
|
|
499
628
|
|
|
500
629
|
for (const entry of active) if (entry.mode === 'always') consider(entry, '始终')
|
|
501
630
|
for (const entry of active) if (entry.mode !== 'always') consider(entry, '按需')
|
|
502
631
|
|
|
503
|
-
if (blocks.length === 0)
|
|
504
|
-
|
|
632
|
+
if (blocks.length === 0) {
|
|
633
|
+
return { text: '', budget, activeCount: active.length, included: [], omitted: omittedPlan }
|
|
634
|
+
}
|
|
635
|
+
return {
|
|
636
|
+
text: header + blocks.join('\n\n') + footerFor(omitted),
|
|
637
|
+
budget,
|
|
638
|
+
activeCount: active.length,
|
|
639
|
+
included,
|
|
640
|
+
omitted: omittedPlan,
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Render the injected block text. A thin wrapper over planInjection: the
|
|
646
|
+
* decision process lives in one place, and callers that only need the text
|
|
647
|
+
* (the prompt section) keep this byte-stable entry point.
|
|
648
|
+
*/
|
|
649
|
+
export function buildBlock(data) {
|
|
650
|
+
return planInjection(data).text
|
|
505
651
|
}
|
|
506
652
|
|
|
507
653
|
/* ------------------------------------------------------------------ *
|
|
@@ -623,6 +769,22 @@ export function apply(ctx) {
|
|
|
623
769
|
}
|
|
624
770
|
}
|
|
625
771
|
|
|
772
|
+
/**
|
|
773
|
+
* The full injection plan (text + per-entry budget decisions) behind the
|
|
774
|
+
* registered section. The snapshot publishes it so the settings page can
|
|
775
|
+
* show budget usage and mark the enabled entries that currently do not fit.
|
|
776
|
+
* A render failure degrades to an empty plan at the effective ceiling
|
|
777
|
+
* rather than throwing into the route/SSE path.
|
|
778
|
+
*/
|
|
779
|
+
const sectionPlan = () => {
|
|
780
|
+
try {
|
|
781
|
+
return planInjection(state.data)
|
|
782
|
+
} catch (err) {
|
|
783
|
+
console.error('[instruction-memory] render failed:', err)
|
|
784
|
+
return { text: '', budget: clampBudget(state.data.budgetChars), activeCount: 0, included: [], omitted: [] }
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
626
788
|
/**
|
|
627
789
|
* The thunk handed to the harness.
|
|
628
790
|
*
|
|
@@ -692,23 +854,25 @@ export function apply(ctx) {
|
|
|
692
854
|
/* ---------------- external-change watching (SSE) ---------------- */
|
|
693
855
|
|
|
694
856
|
/**
|
|
695
|
-
* Live subscribers to the settings page's event stream. Each entry
|
|
696
|
-
* `send(payload)` writer
|
|
857
|
+
* Live subscribers to the settings page's event stream. Each entry maps a
|
|
858
|
+
* stream's `send(payload)` writer to its own teardown, so a write that fails
|
|
859
|
+
* (the socket went away) can dispose THAT stream instead of just dropping
|
|
860
|
+
* it from the broadcast set while its response stays open.
|
|
697
861
|
*/
|
|
698
|
-
const
|
|
699
|
-
/** Per-connection teardown, so unmounting the plugin can end every stream. */
|
|
700
|
-
const sseCleanups = new Set()
|
|
862
|
+
const sseStreams = new Map()
|
|
701
863
|
let storeWatcher = null
|
|
702
864
|
let watchDebounce = null
|
|
703
865
|
|
|
704
866
|
const pushSnapshot = () => {
|
|
705
|
-
if (
|
|
867
|
+
if (sseStreams.size === 0) return
|
|
706
868
|
const payload = 'data: ' + JSON.stringify(snapshot()) + '\n\n'
|
|
707
|
-
for (const send of Array.from(
|
|
869
|
+
for (const [send, cleanup] of Array.from(sseStreams)) {
|
|
708
870
|
try {
|
|
709
871
|
send(payload)
|
|
710
872
|
} catch {
|
|
711
|
-
|
|
873
|
+
// The response is dead: run its full teardown (end + unsubscribe),
|
|
874
|
+
// not just a set delete that leaves the stream half-registered.
|
|
875
|
+
cleanup()
|
|
712
876
|
}
|
|
713
877
|
}
|
|
714
878
|
}
|
|
@@ -788,15 +952,14 @@ export function apply(ctx) {
|
|
|
788
952
|
}
|
|
789
953
|
|
|
790
954
|
const disposeStreams = () => {
|
|
791
|
-
for (const cleanup of Array.from(
|
|
955
|
+
for (const cleanup of Array.from(sseStreams.values())) {
|
|
792
956
|
try {
|
|
793
957
|
cleanup()
|
|
794
958
|
} catch {
|
|
795
959
|
// The response is already gone.
|
|
796
960
|
}
|
|
797
961
|
}
|
|
798
|
-
|
|
799
|
-
sseClients.clear()
|
|
962
|
+
sseStreams.clear()
|
|
800
963
|
}
|
|
801
964
|
|
|
802
965
|
/* ---------------- storage ---------------- */
|
|
@@ -807,7 +970,8 @@ export function apply(ctx) {
|
|
|
807
970
|
|
|
808
971
|
/**
|
|
809
972
|
* Cross-process write lock: a `memory.json.lock` marker created exclusively
|
|
810
|
-
* (`wx`),
|
|
973
|
+
* (`wx`) holding a random owner token, kept for the duration of one atomic
|
|
974
|
+
* replace, and removed afterwards ONLY BY ITS OWNER.
|
|
811
975
|
*
|
|
812
976
|
* The in-process diskQueue serialises this plugin's own writes, but two DSH
|
|
813
977
|
* processes sharing a `$DSH_HOME` (e.g. `web` and `tui` profiles running at
|
|
@@ -821,19 +985,32 @@ export function apply(ctx) {
|
|
|
821
985
|
* write proceeds anyway (disclosed in the debug log). A lock older than
|
|
822
986
|
* LOCK_STALE_MS is treated as crashed-holder debris and stolen, so a killed
|
|
823
987
|
* process cannot wedge every later save behind a stale marker.
|
|
988
|
+
*
|
|
989
|
+
* The token matters: stealing used to be a plain unlink, and the slow
|
|
990
|
+
* holder's `finally` then unlinked the lock the NEW owner had just created —
|
|
991
|
+
* a classic TOCTOU that silently unlocked the third writer. The marker
|
|
992
|
+
* carries this attempt's UUID, and release deletes it only when the file
|
|
993
|
+
* still carries that UUID, so a finished holder can never remove a lock it
|
|
994
|
+
* no longer owns.
|
|
995
|
+
*
|
|
996
|
+
* @returns `{ path, token }` while held, or `null` when the wait elapsed
|
|
997
|
+
* (the save proceeds unlocked by design).
|
|
824
998
|
*/
|
|
825
999
|
const acquireStoreLock = async () => {
|
|
826
1000
|
const lockPath = storePath + '.lock'
|
|
1001
|
+
const token = randomUUID()
|
|
827
1002
|
const deadline = Date.now() + LOCK_WAIT_MS
|
|
828
1003
|
for (;;) {
|
|
829
1004
|
try {
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
return lockPath
|
|
1005
|
+
await writeFile(lockPath, token, { flag: 'wx' })
|
|
1006
|
+
return { path: lockPath, token }
|
|
833
1007
|
} catch (err) {
|
|
834
1008
|
if (err.code !== 'EEXIST') throw err
|
|
835
1009
|
const info = await stat(lockPath).catch(() => null)
|
|
836
1010
|
if (info !== null && Date.now() - info.mtimeMs > LOCK_STALE_MS) {
|
|
1011
|
+
// Crashed-holder debris. It may be recreated by a live process
|
|
1012
|
+
// between this unlink and the next wx attempt — that case simply
|
|
1013
|
+
// loops and re-evaluates freshness instead of forcing its way in.
|
|
837
1014
|
await unlink(lockPath).catch(() => {})
|
|
838
1015
|
continue
|
|
839
1016
|
}
|
|
@@ -843,6 +1020,21 @@ export function apply(ctx) {
|
|
|
843
1020
|
}
|
|
844
1021
|
}
|
|
845
1022
|
|
|
1023
|
+
/**
|
|
1024
|
+
* Release a lock only when THIS attempt still owns it. Reading the marker
|
|
1025
|
+
* back and comparing the token closes the window in which a finished slow
|
|
1026
|
+
* holder deleted a lock the next owner had already taken over.
|
|
1027
|
+
*/
|
|
1028
|
+
const releaseStoreLock = async (lock) => {
|
|
1029
|
+
if (lock === null) return
|
|
1030
|
+
try {
|
|
1031
|
+
const owner = await readFile(lock.path, 'utf8').catch(() => null)
|
|
1032
|
+
if (owner === lock.token) await unlink(lock.path).catch(() => {})
|
|
1033
|
+
} catch {
|
|
1034
|
+
// Cleanup must never throw into the save's finally.
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
846
1038
|
const writeToDisk = async () => {
|
|
847
1039
|
// Atomic replace (write a sibling temp file, then rename) with a rolling
|
|
848
1040
|
// one-version backup: a crash mid-write must never leave a truncated
|
|
@@ -859,12 +1051,12 @@ export function apply(ctx) {
|
|
|
859
1051
|
// any step leaves the previous store readable at its own path.
|
|
860
1052
|
const tmpPath = storePath + '.tmp'
|
|
861
1053
|
const bakPath = storePath + '.bak'
|
|
862
|
-
let
|
|
1054
|
+
let lock = null
|
|
863
1055
|
let previousRev = null
|
|
864
1056
|
try {
|
|
865
1057
|
await mkdir(dirname(storePath), { recursive: true })
|
|
866
|
-
|
|
867
|
-
if (
|
|
1058
|
+
lock = await acquireStoreLock()
|
|
1059
|
+
if (lock === null) {
|
|
868
1060
|
logDebug('store lock still held by another process; writing anyway (last write wins)')
|
|
869
1061
|
}
|
|
870
1062
|
// The rev is bumped before serialising so the file on disk and the state
|
|
@@ -912,7 +1104,7 @@ export function apply(ctx) {
|
|
|
912
1104
|
state.storageError = '保存失败:' + describeError(err)
|
|
913
1105
|
return false
|
|
914
1106
|
} finally {
|
|
915
|
-
|
|
1107
|
+
await releaseStoreLock(lock)
|
|
916
1108
|
}
|
|
917
1109
|
}
|
|
918
1110
|
|
|
@@ -1104,11 +1296,19 @@ export function apply(ctx) {
|
|
|
1104
1296
|
// Load the store, then register the section if anything must be injected.
|
|
1105
1297
|
// Every entry point awaits this, so a request that arrives before the disk
|
|
1106
1298
|
// read settles can never overwrite the file with an empty in-memory state.
|
|
1299
|
+
//
|
|
1300
|
+
// `bootSettled` flips only after the read lands. The SSE connect frame is
|
|
1301
|
+
// gated on it: a stream opened in the gap between apply() and the read
|
|
1302
|
+
// finishing must wait, or its first frame would describe the EMPTY boot
|
|
1303
|
+
// state and the panel would flash "no instructions" before the real
|
|
1304
|
+
// snapshot arrived (POST /state already awaited boot; the stream did not).
|
|
1305
|
+
let bootSettled = false
|
|
1107
1306
|
const boot = readFromDisk()
|
|
1108
1307
|
.catch((err) => {
|
|
1109
1308
|
state.storageError = '读取失败:' + describeError(err)
|
|
1110
1309
|
})
|
|
1111
1310
|
.then(() => {
|
|
1311
|
+
bootSettled = true
|
|
1112
1312
|
syncSection()
|
|
1113
1313
|
// The one always-on line: it answers "is memory mounted, where does it
|
|
1114
1314
|
// live, and how much is being injected?" without any further digging.
|
|
@@ -1147,7 +1347,8 @@ export function apply(ctx) {
|
|
|
1147
1347
|
/* ---------------- snapshots ---------------- */
|
|
1148
1348
|
|
|
1149
1349
|
const snapshot = () => {
|
|
1150
|
-
const
|
|
1350
|
+
const plan = sectionPlan()
|
|
1351
|
+
const preview = plan.text
|
|
1151
1352
|
return {
|
|
1152
1353
|
data: {
|
|
1153
1354
|
version: 1,
|
|
@@ -1170,6 +1371,16 @@ export function apply(ctx) {
|
|
|
1170
1371
|
available: true,
|
|
1171
1372
|
error: state.sectionError,
|
|
1172
1373
|
chars: preview.length,
|
|
1374
|
+
/**
|
|
1375
|
+
* The budget decision behind `preview`, for the settings page's
|
|
1376
|
+
* usage meter and per-entry marks. `budget` is the effective ceiling;
|
|
1377
|
+
* `included`/`omitted` carry one item per active entry in
|
|
1378
|
+
* consideration order; disabled or blank entries appear in neither.
|
|
1379
|
+
*/
|
|
1380
|
+
budget: plan.budget,
|
|
1381
|
+
activeCount: plan.activeCount,
|
|
1382
|
+
included: plan.included.map((item) => ({ id: item.id, chars: item.chars, truncated: item.truncated === true })),
|
|
1383
|
+
omitted: plan.omitted.map((item) => ({ id: item.id, chars: item.chars })),
|
|
1173
1384
|
/**
|
|
1174
1385
|
* How many times the harness has pulled this section into a prompt since
|
|
1175
1386
|
* boot. 0 means "prepared, never assembled" — the distinction the old
|
|
@@ -1301,6 +1512,13 @@ export function apply(ctx) {
|
|
|
1301
1512
|
const deleteEntry = (id, baseRev) => enqueue(async () => {
|
|
1302
1513
|
if (state.versionRejected) return versionRefusal()
|
|
1303
1514
|
if (isStaleBaseRev(baseRev)) return conflictResponse()
|
|
1515
|
+
// Deleting an id that is not there changes nothing, so it must not touch
|
|
1516
|
+
// disk, bump rev, or push an echo frame — otherwise two panels deleting
|
|
1517
|
+
// the same entry one after another would manufacture a pointless write
|
|
1518
|
+
// (and an external process watching the file would wake for nothing).
|
|
1519
|
+
if (!state.data.entries.some((entry) => entry.id === id)) {
|
|
1520
|
+
return { ok: true, saved: false, message: null, snapshot: snapshot() }
|
|
1521
|
+
}
|
|
1304
1522
|
const saved = await commit(() => {
|
|
1305
1523
|
state.data.entries = state.data.entries.filter((entry) => entry.id !== id)
|
|
1306
1524
|
})
|
|
@@ -1487,23 +1705,38 @@ export function apply(ctx) {
|
|
|
1487
1705
|
// restarted) instead of hammering the route.
|
|
1488
1706
|
res.write('retry: 5000\n\n')
|
|
1489
1707
|
const send = (payload) => { res.write(payload) }
|
|
1490
|
-
|
|
1491
|
-
// A snapshot on connect, so a late subscriber synchronises immediately
|
|
1492
|
-
// instead of waiting for the next change.
|
|
1493
|
-
send('data: ' + JSON.stringify(snapshot()) + '\n\n')
|
|
1708
|
+
let closed = false
|
|
1494
1709
|
const cleanup = () => {
|
|
1495
|
-
|
|
1496
|
-
|
|
1710
|
+
closed = true
|
|
1711
|
+
sseStreams.delete(send)
|
|
1497
1712
|
try {
|
|
1498
1713
|
res.end()
|
|
1499
1714
|
} catch {
|
|
1500
1715
|
// The response is already gone.
|
|
1501
1716
|
}
|
|
1502
1717
|
}
|
|
1503
|
-
|
|
1718
|
+
sseStreams.set(send, cleanup)
|
|
1504
1719
|
req.on('close', cleanup)
|
|
1505
1720
|
req.on('error', cleanup)
|
|
1506
1721
|
res.on('error', cleanup)
|
|
1722
|
+
// A snapshot on connect, so a late subscriber synchronises immediately
|
|
1723
|
+
// instead of waiting for the next change. It must wait for boot: a stream
|
|
1724
|
+
// opened while the initial disk read is still in flight would otherwise be
|
|
1725
|
+
// handed the empty boot state as its first frame, flashing "no
|
|
1726
|
+
// instructions" before the loaded store arrives.
|
|
1727
|
+
const sendConnectFrame = () => {
|
|
1728
|
+
if (closed) return
|
|
1729
|
+
try {
|
|
1730
|
+
send('data: ' + JSON.stringify(snapshot()) + '\n\n')
|
|
1731
|
+
} catch {
|
|
1732
|
+
cleanup()
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
if (bootSettled) {
|
|
1736
|
+
sendConnectFrame()
|
|
1737
|
+
} else {
|
|
1738
|
+
boot.then(sendConnectFrame, sendConnectFrame)
|
|
1739
|
+
}
|
|
1507
1740
|
}
|
|
1508
1741
|
|
|
1509
1742
|
const handler = (req, res) => {
|
|
@@ -1613,7 +1846,7 @@ export function apply(ctx) {
|
|
|
1613
1846
|
// disabled for all non-loopback binds and the trade-off is documented in
|
|
1614
1847
|
// the README.
|
|
1615
1848
|
const boundHost = typeof webServer.host === 'string' ? webServer.host.trim().toLowerCase() : ''
|
|
1616
|
-
guardHost = boundHost === '' ||
|
|
1849
|
+
guardHost = boundHost === '' || isLoopbackName(boundHost)
|
|
1617
1850
|
try {
|
|
1618
1851
|
routeDispose = webServer.register({ kind: 'prefix', path: API_PREFIX, handler })
|
|
1619
1852
|
logDebug('settings route registered at ' + API_PREFIX + ' (boundHost=' + boundHost + ', hostGuard=' + guardHost + ')')
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-instruction-memory",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.17",
|
|
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",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"node": ">=20"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
|
-
"@deepseek-ai/dsh-system-prompt": ">=0.1.3-alpha.2 <0.1.4 || >=0.1.4-0 <0.1.5-0 || >=0.1.5-alpha.1 <0.1.6-0 || >=0.1.6-alpha.0 <0.1.7-0 || >=0.1.7-alpha.0 <0.2.0-0 || >=0.2.0-0"
|
|
52
|
+
"@deepseek-ai/dsh-system-prompt": ">=0.1.3-alpha.2 <0.1.4 || >=0.1.4-0 <0.1.5-0 || >=0.1.5-alpha.1 <0.1.6-0 || >=0.1.6-alpha.0 <0.1.7-0 || >=0.1.7-alpha.0 <0.2.0-0 || >=0.2.1-alpha.0 <0.3.0-0 || >=0.2.0-0"
|
|
53
53
|
},
|
|
54
54
|
"publishConfig": {
|
|
55
55
|
"access": "public"
|
|
@@ -72,10 +72,10 @@
|
|
|
72
72
|
"platform": "web"
|
|
73
73
|
},
|
|
74
74
|
"engines": {
|
|
75
|
-
"dsh": ">=0.1.3-alpha.2 <0.1.4 || >=0.1.4-0 <0.1.5-0 || >=0.1.5-alpha.1 <0.1.6-0 || >=0.1.6-alpha.0 <0.1.7-0 || >=0.1.7-alpha.0 <0.2.0-0 || >=0.2.0-0"
|
|
75
|
+
"dsh": ">=0.1.3-alpha.2 <0.1.4 || >=0.1.4-0 <0.1.5-0 || >=0.1.5-alpha.1 <0.1.6-0 || >=0.1.6-alpha.0 <0.1.7-0 || >=0.1.7-alpha.0 <0.2.0-0 || >=0.2.1-alpha.0 <0.3.0-0 || >=0.2.0-0"
|
|
76
76
|
},
|
|
77
77
|
"compatibility": {
|
|
78
|
-
"dsh": ">=0.1.3-alpha.2 <0.1.4 || >=0.1.4-0 <0.1.5-0 || >=0.1.5-alpha.1 <0.1.6-0 || >=0.1.6-alpha.0 <0.1.7-0 || >=0.1.7-alpha.0 <0.2.0-0 || >=0.2.0-0"
|
|
78
|
+
"dsh": ">=0.1.3-alpha.2 <0.1.4 || >=0.1.4-0 <0.1.5-0 || >=0.1.5-alpha.1 <0.1.6-0 || >=0.1.6-alpha.0 <0.1.7-0 || >=0.1.7-alpha.0 <0.2.0-0 || >=0.2.1-alpha.0 <0.3.0-0 || >=0.2.0-0"
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
81
|
}
|