dsh-instruction-memory 1.5.5 → 1.5.7
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 +108 -0
- package/README.md +27 -12
- package/lib/client.js +98 -2
- package/lib/index.js +462 -26
- package/package.json +7 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,114 @@
|
|
|
7
7
|
> 1.0.13 起由 CI 经 OIDC Trusted Publishing 发布(首个带 provenance 的版本)。
|
|
8
8
|
> 这些历史 tag **不应回填**——npm 上的版本号已被占用。
|
|
9
9
|
|
|
10
|
+
## [1.5.7] — 2026-11-14
|
|
11
|
+
|
|
12
|
+
针对**最新版桌面端(`dsh-desktop-runtime 0.2.2-rc.3`,Electron)与网页端**全量审查后的修复轮。
|
|
13
|
+
审查结论先说清楚:**功能性缺陷只有 1 条**(测试守卫读错宿主),其余是注入链路的加固、一处
|
|
14
|
+
无鉴权无确认的自动写路径、以及若干"声明/文档与实现不符"。审查同时**撤回了一条看起来该改、
|
|
15
|
+
实测不该改**的建议(见末节)。
|
|
16
|
+
|
|
17
|
+
### 修复
|
|
18
|
+
|
|
19
|
+
- **回归守卫读错宿主,对着错误的版本虚假通过(B1)**:`host-range-test.mjs` 的 LIVE GUARD
|
|
20
|
+
只在 `profiles/*/node_modules`、`/usr/lib…`、`/usr/local/lib…` 和 TRAE 字面路径里找宿主。
|
|
21
|
+
桌面端把 dsh 打包进 `app.asar`,这些路径**永远不命中**;本机因此命中一条与本机无关的软链
|
|
22
|
+
(TRAE 捆绑的 `0.2.2-alpha.2`),并在桌面端实际运行 `0.2.2-rc.3` 时打印
|
|
23
|
+
`PASS LIVE GUARD: the installed host 0.2.2-alpha.2 ...`。这正是该守卫注释里声称要防的
|
|
24
|
+
false negative。现在优先读取 `resources/runtime/primary-runtime/runtime.json` 的
|
|
25
|
+
`desktopVersion`(可选 `DSH_DESKTOP_RUNTIME_ROOT` 覆盖),桌面证据存在时以它为准,
|
|
26
|
+
`profiles/node_modules` 的软链树降级为"没有桌面安装时"的证据;标签里**打印版本来源路径**,
|
|
27
|
+
免得下次读错树又是静默通过。
|
|
28
|
+
- **`systemPrompt` 一次性捕获,服务重发布后注入静默死亡(A1)**:`apply()` 里
|
|
29
|
+
`const systemPrompt = ctx.systemPrompt` 只取一次。宿主 dispose 并重新 publish 该服务后
|
|
30
|
+
(宿主重启、fiber 重挂、profile 重载),旧实例仍然接受 `section()` 并返回 disposer、
|
|
31
|
+
不抛错,新实例一个 section 都收不到 —— 于是此后整轮对话都不再有指令记忆,且没有任何报错。
|
|
32
|
+
改为可变引用 + 非阻塞 `ctx.inject(['systemPrompt'], …)`(与 `webServer` 注册同构):
|
|
33
|
+
服务换实例时先 dispose 旧注册再在新实例上重建;服务下线时把
|
|
34
|
+
`注入服务已重新发布/下线` 写进 `injection.error`,而不是假装无事发生。
|
|
35
|
+
|
|
36
|
+
### 安全
|
|
37
|
+
|
|
38
|
+
- **旧位置文件不再自动采纳(A4)**:`process.cwd()` 下的 `.dsh-instruction-memory.json`
|
|
39
|
+
过去会被**直接写进 memory.json 并在下一轮注入**——没有鉴权门、没有确认,是全插件唯一
|
|
40
|
+
这样的写路径。`cwd` 不属于插件:任何同权限进程在那里放一份 store 形状的 JSON,就能变成
|
|
41
|
+
"用户的长期指令"。现在只**检出**(`storage.legacyPending`,设置页显示条数与路径),
|
|
42
|
+
由用户点「导入旧位置数据」才采纳(`adopt-legacy`,走与其他写入相同的门禁,且在接纳前
|
|
43
|
+
重新读取并复核该路径**仍是**本插件的 store);「忽略」为 `dismiss-legacy`,原文件不动。
|
|
44
|
+
- **注入文本剥离不可见字符(A3)**:`cleanText` 过去只归一 `\r`。现在剥离 C0 控制符
|
|
45
|
+
(保留 `\n`/`\t`)、DEL、`U+200B–U+200F`、`U+202A–U+202E`、`U+2066–U+2069`、`U+FEFF`,
|
|
46
|
+
并把 `U+2028/2029` 归一成换行。理由是**设置页所见必须等于模型所读**:bidi override 能让
|
|
47
|
+
一行显示成另一个词(Trojan-Source 类),零宽字符能拆词躲过关键词与去重检查。
|
|
48
|
+
- **注入块加不可伪造定界(A2)**:整段现在由 `<user-instruction-memory>` /
|
|
49
|
+
`</user-instruction-memory>` 包裹,且 `content` 中同形的标签、本插件自己的段头
|
|
50
|
+
(`## 用户长期指令记忆`、`## 关于用户(用户形象)`)与条目前缀(`[始终|优先级`/`[按需|优先级`)
|
|
51
|
+
会被换成全角近似字符,无法再冒充系统段头或伪造优先级。普通 Markdown 标题
|
|
52
|
+
(如 `## 实验步骤`)不受影响。
|
|
53
|
+
- **无鉴权门时,写方法要求浏览器来源标记(A9/A12)**:`requestOriginVerdict` 的每条分支都以
|
|
54
|
+
"有 Origin"为前提,因此**本机脚本/curl 不带 Origin 就能在无 `connection` 服务的 profile 上
|
|
55
|
+
直接改记忆**。回环在"同机任意程序都以该用户身份运行"的机器上不再是边界。现在:
|
|
56
|
+
① 无鉴权门且主机名白名单被放宽(`hostGuarded === false` + `trustedHostCount > 0`)时,
|
|
57
|
+
写方法一律 **503**;② 无鉴权门时,写方法必须带 `Origin` 或 `Sec-Fetch-Site: same-origin`
|
|
58
|
+
(浏览器必发其一,设置页不受影响),否则 **403**。只读方法(`state`、`history-list`、
|
|
59
|
+
`read-source`、`export-data`、`export-to-dir`、`reload`、`send-feedback`)不受限制。
|
|
60
|
+
`DSH_INSTRUCTION_MEMORY_REQUIRE_AUTH_FENCE=1` 仍可让整条路由 fail closed。
|
|
61
|
+
- **崩溃残留的临时副本会被清理(A13)**:原子写用
|
|
62
|
+
`memory.json.tmp-<pid>-<uuid>`,在 `open` 与 `rename` 之间崩溃会留下**一份完整的记忆明文**,
|
|
63
|
+
且永不清理。载入时按插件自己的命名模式清扫;记录里的 pid 仍存活则跳过(那是另一个进程
|
|
64
|
+
正在写,不是垃圾)。
|
|
65
|
+
|
|
66
|
+
### 变更
|
|
67
|
+
|
|
68
|
+
- 新字段 `injection.envelopeChars`:`chars` 里有多少是定界包裹。面板的用量条改为
|
|
69
|
+
`entryChars + profileChars + envelopeChars`(旧宿主不发该字段,按 0 计),这样条上的数字
|
|
70
|
+
与宿主真正执行的预算一致。
|
|
71
|
+
- 新字段 `storage.legacyPending`(`{ path, entries, profiles }` 或 `null`)与
|
|
72
|
+
`security.routeRegistered`(设置页路由是否真的挂上了 —— 没有 `webServer` 的 profile 里过去
|
|
73
|
+
只有一行 `console.error`,面板无法自述其缺席)。
|
|
74
|
+
- 新方法 `adopt-legacy` / `dismiss-legacy`。
|
|
75
|
+
- `peerDependenciesMeta` 把 `@deepseek-ai/dsh-system-prompt` 标为 **optional**:插件代码
|
|
76
|
+
全文只 import `node:` 内置模块,服务靠 `inject = ['systemPrompt']` 与鸭子类型取得,
|
|
77
|
+
把 peer 标成强制只会在 `strict-peer-dependencies` 下误伤安装。
|
|
78
|
+
- `description` 措辞更正:从"模型没有任何写入口"改为"**插件不注册任何面向模型的工具**,
|
|
79
|
+
模型没有插件内的写入口"——原话与本仓库 README 自述(同权限进程可直接改文件)自相矛盾。
|
|
80
|
+
|
|
81
|
+
### 未改动(审查建议过,复核后撤回)
|
|
82
|
+
|
|
83
|
+
- **冗余区间段不能删(A14 撤回)**:`>=0.2.1-alpha.0`、`>=0.2.2-alpha.0` 在
|
|
84
|
+
`includePrerelease: true`(宿主门)下确实被 `>=0.2.0-0 <0.3.0-0` 吸收,但在**默认 semver
|
|
85
|
+
语义**(pnpm / npm 的 peer 解析)下,预发布版本要求**同一 tuple** 的比较器存在,删掉这两段
|
|
86
|
+
会让 `0.2.1-alpha.x` / `0.2.2-alpha.x` 被判不兼容。宿主门用 includePrerelease、包管理器用
|
|
87
|
+
默认语义,两套并存,所以每一段都是必需的。README 的兼容性小节据此更正了
|
|
88
|
+
`peerDependencies` 被称作"真正的门禁"的说法。
|
|
89
|
+
|
|
90
|
+
## [1.5.6] — 2026-11-13
|
|
91
|
+
|
|
92
|
+
### 新增
|
|
93
|
+
|
|
94
|
+
- **导出可以真正落到文件(仅在 DSH Go 里生效)**:`doExport()` 现在先探测 `window.DshGoIsland`
|
|
95
|
+
——那是 DSH Go 的 island 桥。存在时把 JSON 按 6,000 字符一块(不切断代理对)`postMessage` 给 App,
|
|
96
|
+
由 App 写文件并唤起系统分享面板,面板提示「已交给 DSH Go 保存」。不存在时行为不变:普通 WebView
|
|
97
|
+
回退到面板内 JSON,浏览器/Electron 仍是 `<a download>`。
|
|
98
|
+
壳侧需要配套补丁(island 协议新增 `t:'save'` 分块消息),已在仓库外单独产出
|
|
99
|
+
(`dsh-go-patch` 的 `README-PATCH.md`,放在本机独立目录,未随本仓库或 npm 包分发);
|
|
100
|
+
本仓库不含任何上游代码。
|
|
101
|
+
|
|
102
|
+
### 文档更正
|
|
103
|
+
|
|
104
|
+
只改文档的部分,**更正 1.5.5 发布说明里两处对 DSH Go 壳的描述**——都是读源码(`ccch1mneyyy/dsh-go`,2026-11-12)后确认写错的:
|
|
105
|
+
|
|
106
|
+
- **引擎环境不是 `/usr/bin/env -i`**:壳用 `ProcessBuilder`,`environment().clear()` 后逐项填
|
|
107
|
+
`PATH/HOME/TMPDIR/ANDROID_ROOT/ANDROID_DATA/PROOT_TMP_DIR`,再合并运行时清单的 `launchEnv`。
|
|
108
|
+
`HOME` 是 App 的 `filesDir`,不是 `/root`。结论不变(宿主变量不继承、备份开关仍不可达),
|
|
109
|
+
但机制与取值之前写错了。
|
|
110
|
+
- **文件选择器壳里已经实现**:`island_handle.dart:197` 的 `_files.attach()` →
|
|
111
|
+
`web_file_chooser.dart:26` 的 `setOnShowFileSelector` → 原生 `dsh/web-files` 通道
|
|
112
|
+
(content:// 校验、按文档代际取消)。所以 1.5.5 里"导入依赖文件选择器、壳没实现"的说法不成立;
|
|
113
|
+
面板那行提示保留为兜底(非 http(s) 页面、`canChoose()` 为假、旧版 App)。
|
|
114
|
+
|
|
115
|
+
仍未实现、且确实在壳侧的只有三件:**导出用的保存/分享桥**(island 消息协议上限 16 KB,需分块)、
|
|
116
|
+
`onJsPrompt`(webview_flutter 不暴露,插件已绕开,可不做)、**备份开关的环境变量**(壳能加,现未加)。
|
|
117
|
+
|
|
10
118
|
## [1.5.5] — 2026-11-12
|
|
11
119
|
|
|
12
120
|
DSH Go(Android / WebView)适配。**没有真机验证**(本机 `adb devices` 为空),所以这一版只做
|
package/README.md
CHANGED
|
@@ -229,7 +229,10 @@ DSH 就会得到第二份空记忆,看起来像"记忆消失"。相对的 `$DS
|
|
|
229
229
|
没有理由读得到。
|
|
230
230
|
|
|
231
231
|
> 1.0.0 之前的构建曾把数据写在宿主进程 cwd 下(`.dsh-instruction-memory.json`)。
|
|
232
|
-
>
|
|
232
|
+
> **1.5.7 起不再自动迁移**:启动时只**检出**该文件并在设置页提示(「导入旧位置数据 / 忽略」两项),
|
|
233
|
+
> 由用户确认后才写入正式位置,并把原文件改名为 `*.migrated` 保留。原因是这条旧路径**既没有鉴权门、
|
|
234
|
+
> 也没有人在回路里**:`process.cwd()` 不属于插件,任何同权限进程在那里放一份 store 形状的 JSON,
|
|
235
|
+
> 下一轮就会被注入成"用户的长期指令"。检出状态在 `storage.legacyPending` 里,未确认前不读入、不注入。
|
|
233
236
|
|
|
234
237
|
**当前存储格式为版本 2**(1.1.0 及更早写的是版本 1,见下文「多情境配置集」)。
|
|
235
238
|
两者都能载入:版本 1 的文件会**平滑迁移**——其中的条目与用户形象全部归入一个名为
|
|
@@ -820,7 +823,7 @@ DSH Go 给官方 Web 服务器包了一层**构建期补丁** `runtime/patches/r
|
|
|
820
823
|
|
|
821
824
|
| 位置 | 谁读它 | 作用 |
|
|
822
825
|
| --- | --- | --- |
|
|
823
|
-
| **`peerDependencies`**(`@deepseek-ai/dsh-system-prompt`) | **宿主**:`evaluatePluginCompatibility()`(`dsh-app-boot
|
|
826
|
+
| **`peerDependencies`**(`@deepseek-ai/dsh-system-prompt`) | **宿主**:`evaluatePluginCompatibility()`(`dsh-app-boot`),在**安装**与 **profile 启动**时执行 | **门禁之一**,不是唯一。1.5.7 起两处实测结论必须同时写在这里:① 门禁读的是**宿主自身版本**(`getDshRuntimeVersion()` → `dsh-app-boot` 的 package.json),并用 `{ includePrerelease: true }` 求值,所以预发布行不会被误判;② 在 `0.2.2-rc.3` 的宿主实现里**存在 `engines.dsh` 时 `peerDependencies` 的区间被忽略**(`dsh-app-boot/src/plugin-compatibility.ts:52`)——也就是说这段 peer 区间可能完全不参与判定,只用来在报错时点名。本仓库仍让三处**逐字节同值**,就是为了不依赖"到底哪一处生效"。这条已从"真正的门禁"更正:**能证明它生效的只有实测,不能靠读声明推断** |
|
|
824
827
|
| `dsh.engines.dsh` | 插件市场(第三方,服务端读取) | 市场卡片上的兼容性判定与展示 |
|
|
825
828
|
| `dsh.compatibility.dsh` | 未找到读取方(官方文档与 DSH 树内均无) | 仅作人类可读的重复声明 |
|
|
826
829
|
|
|
@@ -956,9 +959,14 @@ FAIL LIVE GUARD: the installed host 0.1.8-rc.1 is admitted by both modes -> ..
|
|
|
956
959
|
|
|
957
960
|
DSH Go(Android 客户端)内置的引擎就是**真实 DSH**,锁定 `@deepseek-ai/dsh` **`0.2.1-rc.4`**
|
|
958
961
|
(`runtime/locks/dsh-0.2.1-rc.4/package.json`),在 PRoot rootfs 内以
|
|
959
|
-
`dsh web --patch /etc/dsh-app/dsh-web.patch.yml --host 127.0.0.1 --port N --no-open`
|
|
960
|
-
|
|
961
|
-
|
|
962
|
+
`dsh web --patch /etc/dsh-app/dsh-web.patch.yml --host 127.0.0.1 --port N --no-open` 运行。
|
|
963
|
+
|
|
964
|
+
引擎进程的环境**不是** `/usr/bin/env -i`(早先这里写错了,2026-11-12 读源码更正):壳用
|
|
965
|
+
`ProcessBuilder(argv)`,先 `environment().clear()`,再逐个填 `PATH` / `HOME`(= **App 的
|
|
966
|
+
`filesDir`**,不是 `/root`)/ `TMPDIR` / `ANDROID_ROOT` / `ANDROID_DATA` / `PROOT_TMP_DIR`,
|
|
967
|
+
最后合并运行时清单的 `launchEnv` 与 guest 环境(`DshProcess.kt:172-188`)。也就是说:宿主变量
|
|
968
|
+
一律不继承(这一点与 `env -i` 等效),但**壳想加什么变量都能加**——只是它目前没有加
|
|
969
|
+
`DSH_INSTRUCTION_MEMORY_ALLOW_EXTERNAL_BACKUP`,所以备份目录开关在手机上仍然不可达。
|
|
962
970
|
|
|
963
971
|
- **兼容性**:`0.2.1-rc.4` 正好是上表①档已实测的版本。2026-10-31 又用同一个 rc.4 在隔离的
|
|
964
972
|
`DSH_HOME` 上复跑一遍 **21/21**(`compat-1016-live-check.mjs`;1.5.1 起该脚本第四个参数给启动
|
|
@@ -980,21 +988,28 @@ DSH Go(Android 客户端)内置的引擎就是**真实 DSH**,锁定 `@deep
|
|
|
980
988
|
#### Android / WebView 适配(1.5.5 起)
|
|
981
989
|
|
|
982
990
|
WebView 不是浏览器:它能把 `window.prompt` 保留成函数却永不弹框,能暴露 `<a download>` 却没有
|
|
983
|
-
|
|
984
|
-
|
|
991
|
+
下载管线。这两处以前都是**点了没反应、也不报错**,而它们恰好是手机上数据进出口的一半。现在插件侧
|
|
992
|
+
这样处理:
|
|
985
993
|
|
|
986
994
|
| 场景 | WebView 上的行为 | 依据 |
|
|
987
995
|
| --- | --- | --- |
|
|
988
996
|
| 导出 | 不再点击一个必然失败的链接,直接把完整 JSON 渲染在面板里(全选即可复制) | `client.js` 的 `isAndroidWebView()` + `exportFallback` 分支 |
|
|
989
|
-
| 导入 / 从备份恢复 |
|
|
997
|
+
| 导入 / 从备份恢复 | **壳已实现文件选择**(`island_handle.dart:197` 的 `_files.attach()` → `web_file_chooser.dart:26` 的 `setOnShowFileSelector` → 原生 `dsh/web-files` 通道,content:// 校验 + 按文档代际取消);面板那行提示只是兜底:页面不是 http(s)、`canChoose()` 为假、或旧版 App 时,请改用「源码模式」粘贴 JSON | 2026-11-12 读 DSH Go 源码确认 |
|
|
990
998
|
| 批量加标签 | 不再调用 `window.prompt`:WebView 返回的 `null` 被判为"问不了",直接走内联输入框 | `batchTagOutcome(..., { nullMeansUnavailable: true })` |
|
|
991
999
|
| 实时更新 | 连续 6 次(≈30 秒)订阅失败后显示「实时更新已断开,请重新载入」;收到下一帧自动清除 | `source.onerror` 计数 |
|
|
992
1000
|
| 字号 / 手势条 | `text-size-adjust:100%` 挡住系统字号重排;挂载失败提示避开安全区 | CSS |
|
|
993
1001
|
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
1002
|
+
**插件侧做不到、必须平台提供的**(逐条都已核对 DSH Go 源码):
|
|
1003
|
+
|
|
1004
|
+
1. **保存/分享桥**:`<a download>` 的 `blob:` 字节 WebView 不会交给 `DownloadListener`,插件无法
|
|
1005
|
+
自救。壳已有 `DshGoIsland` 通道和 `share_plus`,但没有任何"把这段文本存成文件"的消息类型 →
|
|
1006
|
+
要新增一条(注意 island 消息协议上限只有 **16 KB**,`IslandMessageLimits.maxBytes`,而一次导出
|
|
1007
|
+
可达 MB 级,必须分块)。
|
|
1008
|
+
2. **`onJsPrompt`**:webview_flutter 不暴露 JS 对话框,壳也没包 `WebChromeClient`;插件已改走内联
|
|
1009
|
+
表单,所以**这是可选项**,不做也不影响功能。
|
|
1010
|
+
3. **环境变量白名单**:壳会 `environment().clear()` 后逐项填(`DshProcess.kt:172-188`),加一个
|
|
1011
|
+
`DSH_INSTRUCTION_MEMORY_ALLOW_EXTERNAL_BACKUP=1` 就能让备份目录走出 rootfs。
|
|
1012
|
+
4. **WebView 销毁/后台时关闭 SSE**,否则半开连接会占满 32 条上限。
|
|
998
1013
|
|
|
999
1014
|
**纵深开关**:Android 的回环是全机共享的,没有宿主鉴权门时"仅回环"不构成边界。设置
|
|
1000
1015
|
`DSH_INSTRUCTION_MEMORY_REQUIRE_AUTH_FENCE=1` 后,缺少 `connection` 服务的 profile **直接
|
package/lib/client.js
CHANGED
|
@@ -366,6 +366,7 @@ window.__ModuleLoader__.load({
|
|
|
366
366
|
'profiles.omitted': '本轮未包含',
|
|
367
367
|
'profiles.omittedTitle': '这张形象因注入字数上限未进入本轮提示',
|
|
368
368
|
'export.saved': '已开始下载;若没有文件出现,请检查浏览器的下载列表',
|
|
369
|
+
'export.savedToApp': '已交给 DSH Go 保存:在系统分享面板里选「保存到文件」即可',
|
|
369
370
|
'export.fallbackHint': '当前环境无法触发文件下载,完整 JSON 已放在下面:全选复制后自行保存',
|
|
370
371
|
'export.fallbackClose': '关闭',
|
|
371
372
|
'import.webviewHint': 'Android / WebView 上若文件选择器不弹出,请改用「源码模式」粘贴 JSON',
|
|
@@ -383,6 +384,9 @@ window.__ModuleLoader__.load({
|
|
|
383
384
|
'storage.savedAt': '最近保存:{time}',
|
|
384
385
|
'storage.notSaved': '尚未保存到磁盘',
|
|
385
386
|
'storage.migrated': '已从旧位置迁移:{path}(原文件保留为 *.migrated)',
|
|
387
|
+
'storage.legacyPending': '检测到旧位置(启动目录)有一份记忆:{path}({entries} 条指令、{profiles} 个形象)。在你确认之前,它不会被导入,也不会进入注入。',
|
|
388
|
+
'storage.legacyAdopt': '导入旧位置数据',
|
|
389
|
+
'storage.legacyDismiss': '忽略',
|
|
386
390
|
'preview.label': '当前注入到每轮系统提示词的内容',
|
|
387
391
|
'preview.empty': '(当前没有生效中的指令)',
|
|
388
392
|
'list.empty': '还没有指令记忆。点击「+ 新增指令」添加第一条,例如“所有回答使用简体中文”。',
|
|
@@ -663,6 +667,7 @@ window.__ModuleLoader__.load({
|
|
|
663
667
|
'profiles.omitted': 'Not in this turn',
|
|
664
668
|
'profiles.omittedTitle': 'This persona was dropped by the injection limit and did not reach this turn',
|
|
665
669
|
'export.saved': 'Download started — if no file appears, check your browser’s download list',
|
|
670
|
+
'export.savedToApp': 'Handed to DSH Go — pick “Save to Files” in the system share sheet',
|
|
666
671
|
'export.fallbackHint': 'This environment cannot start a file download; the full JSON is below — select all and copy it',
|
|
667
672
|
'export.fallbackClose': 'Close',
|
|
668
673
|
'import.webviewHint': 'On Android / WebView, if the file picker does not open, paste the JSON into “Source mode” instead',
|
|
@@ -680,6 +685,9 @@ window.__ModuleLoader__.load({
|
|
|
680
685
|
'storage.savedAt': 'Last saved: {time}',
|
|
681
686
|
'storage.notSaved': 'Not yet saved to disk',
|
|
682
687
|
'storage.migrated': 'Migrated from the old location: {path} (the original is kept as *.migrated)',
|
|
688
|
+
'storage.legacyPending': 'Found a memory store at the old location (startup directory): {path} ({entries} entries, {profiles} profiles). Nothing is imported or injected until you confirm.',
|
|
689
|
+
'storage.legacyAdopt': 'Import the old data',
|
|
690
|
+
'storage.legacyDismiss': 'Ignore',
|
|
683
691
|
'preview.label': 'What the system prompt receives on every turn',
|
|
684
692
|
'preview.empty': '(no active instructions right now)',
|
|
685
693
|
'list.empty': 'No instructions yet. Click “+ New instruction” to add your first one, e.g. “Always answer in Simplified Chinese”.',
|
|
@@ -1308,7 +1316,21 @@ window.__ModuleLoader__.load({
|
|
|
1308
1316
|
const profileUsed = typeof inj.entryChars === 'number' && inj.entryChars >= 0
|
|
1309
1317
|
&& typeof inj.profileChars === 'number' && inj.profileChars >= 0
|
|
1310
1318
|
? inj.profileChars : 0
|
|
1311
|
-
|
|
1319
|
+
// Measure the budgeted halves, never the whole section: on a host that
|
|
1320
|
+
// publishes persona profiles, `chars` is the whole section while
|
|
1321
|
+
// `entryChars`/`profileChars` are the two halves the Host caps against
|
|
1322
|
+
// `budget`. Falling back to `chars` keeps older hosts (which publish
|
|
1323
|
+
// neither half) working.
|
|
1324
|
+
//
|
|
1325
|
+
// A2's envelope is a third, fixed part of the section: it is reserved
|
|
1326
|
+
// against the budget on the Host side, so the meter has to count it or it
|
|
1327
|
+
// reads lower than the number the Host actually enforces. Older hosts do
|
|
1328
|
+
// not publish it, and add zero.
|
|
1329
|
+
const envelopeUsed = typeof inj.envelopeChars === 'number' && inj.envelopeChars >= 0
|
|
1330
|
+
? inj.envelopeChars : 0
|
|
1331
|
+
const used = typeof inj.entryChars === 'number' && inj.entryChars >= 0
|
|
1332
|
+
? entryUsed + profileUsed + envelopeUsed
|
|
1333
|
+
: (typeof inj.chars === 'number' && inj.chars >= 0 ? inj.chars : 0)
|
|
1312
1334
|
const countFlagged = (list, wantTruncated) =>
|
|
1313
1335
|
Array.isArray(list)
|
|
1314
1336
|
? list.filter((item) => item !== null && typeof item === 'object'
|
|
@@ -2633,6 +2655,23 @@ window.__ModuleLoader__.load({
|
|
|
2633
2655
|
void send('export-to-dir', null)
|
|
2634
2656
|
}
|
|
2635
2657
|
|
|
2658
|
+
// ---- A4 legacy location: detected at boot, adopted only on request ----
|
|
2659
|
+
//
|
|
2660
|
+
// The Host no longer writes a store it finds at the startup directory into
|
|
2661
|
+
// memory.json on its own. That path had no fence and no user in the loop,
|
|
2662
|
+
// so anything able to drop a JSON file there could become the user's
|
|
2663
|
+
// long-lived instructions. These two actions are the only way in.
|
|
2664
|
+
|
|
2665
|
+
function adoptLegacy() {
|
|
2666
|
+
if (busy) return
|
|
2667
|
+
void send('adopt-legacy', { baseRev: revOf() })
|
|
2668
|
+
}
|
|
2669
|
+
|
|
2670
|
+
function dismissLegacy() {
|
|
2671
|
+
if (busy) return
|
|
2672
|
+
void send('dismiss-legacy', null)
|
|
2673
|
+
}
|
|
2674
|
+
|
|
2636
2675
|
// ---- F14 whole-store restore (the "replace" door import does not open) ----
|
|
2637
2676
|
|
|
2638
2677
|
async function handleRestoreFile(file) {
|
|
@@ -2736,6 +2775,39 @@ window.__ModuleLoader__.load({
|
|
|
2736
2775
|
}
|
|
2737
2776
|
}
|
|
2738
2777
|
|
|
2778
|
+
/**
|
|
2779
|
+
* Hand `text` to the DSH Go island bridge, in chunks that fit its 16 KB
|
|
2780
|
+
* message cap (`IslandMessageLimits.maxBytes`). The app reassembles them
|
|
2781
|
+
* and opens the system share sheet — the only export path a WebView has,
|
|
2782
|
+
* since it never hands a `blob:` to a download listener.
|
|
2783
|
+
*
|
|
2784
|
+
* Returns false when the page is not inside DSH Go (or the bridge is
|
|
2785
|
+
* missing), which leaves the browser path untouched.
|
|
2786
|
+
*/
|
|
2787
|
+
async function sendToAppBridge(text, name) {
|
|
2788
|
+
try {
|
|
2789
|
+
const bridge = typeof window !== 'undefined' ? window.DshGoIsland : undefined
|
|
2790
|
+
if (bridge === undefined || bridge === null || typeof bridge.postMessage !== 'function') return false
|
|
2791
|
+
const id = 'im-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
|
|
2792
|
+
const CHUNK = 6000
|
|
2793
|
+
let i = 0
|
|
2794
|
+
while (i < text.length) {
|
|
2795
|
+
let end = Math.min(text.length, i + CHUNK)
|
|
2796
|
+
// Never split a surrogate pair across two messages: each one is its
|
|
2797
|
+
// own JSON document and a lone surrogate would not survive it.
|
|
2798
|
+
const code = text.charCodeAt(end - 1)
|
|
2799
|
+
if (end < text.length && code >= 0xd800 && code <= 0xdbff) end += 1
|
|
2800
|
+
bridge.postMessage(JSON.stringify({
|
|
2801
|
+
t: 'save', id, name, data: text.slice(i, end), last: end >= text.length,
|
|
2802
|
+
}))
|
|
2803
|
+
i = end
|
|
2804
|
+
}
|
|
2805
|
+
return true
|
|
2806
|
+
} catch {
|
|
2807
|
+
return false
|
|
2808
|
+
}
|
|
2809
|
+
}
|
|
2810
|
+
|
|
2739
2811
|
async function doExport() {
|
|
2740
2812
|
try {
|
|
2741
2813
|
const result = await callHost('export-data', null)
|
|
@@ -2744,6 +2816,13 @@ window.__ModuleLoader__.load({
|
|
|
2744
2816
|
return
|
|
2745
2817
|
}
|
|
2746
2818
|
const text = JSON.stringify(result.export, null, 2) + '\n'
|
|
2819
|
+
const fileName = 'dsh-instruction-memory-' + new Date().toISOString().slice(0, 10) + '.json'
|
|
2820
|
+
// Inside DSH Go the app can actually write the file; prefer that over
|
|
2821
|
+
// both the download link and the in-panel copy.
|
|
2822
|
+
if (await sendToAppBridge(text, fileName)) {
|
|
2823
|
+
setNotice(t('export.savedToApp'))
|
|
2824
|
+
return
|
|
2825
|
+
}
|
|
2747
2826
|
/**
|
|
2748
2827
|
* A WebView can expose `<a download>` and still have nowhere to put
|
|
2749
2828
|
* the bytes: Android only saves when the embedding app implements a
|
|
@@ -2761,7 +2840,7 @@ window.__ModuleLoader__.load({
|
|
|
2761
2840
|
const url = URL.createObjectURL(blob)
|
|
2762
2841
|
const link = document.createElement('a')
|
|
2763
2842
|
link.href = url
|
|
2764
|
-
link.download =
|
|
2843
|
+
link.download = fileName
|
|
2765
2844
|
document.body.appendChild(link)
|
|
2766
2845
|
link.click()
|
|
2767
2846
|
if (link.parentNode !== null) link.parentNode.removeChild(link)
|
|
@@ -3697,6 +3776,23 @@ window.__ModuleLoader__.load({
|
|
|
3697
3776
|
? h('div', { className: 'im-ok' }, t('storage.migrated', { path: storage.migratedFrom }))
|
|
3698
3777
|
: null,
|
|
3699
3778
|
storage.warning ? h('div', { className: 'im-warn' }, storage.warning) : null,
|
|
3779
|
+
// A4: an explicit decision, never an automatic import.
|
|
3780
|
+
storage.legacyPending
|
|
3781
|
+
? h('div', { className: 'im-warn' },
|
|
3782
|
+
h('div', null, t('storage.legacyPending', {
|
|
3783
|
+
path: storage.legacyPending.path,
|
|
3784
|
+
entries: storage.legacyPending.entries,
|
|
3785
|
+
profiles: storage.legacyPending.profiles,
|
|
3786
|
+
})),
|
|
3787
|
+
h('div', { className: 'im-row-wrap' },
|
|
3788
|
+
h('button', {
|
|
3789
|
+
className: 'im-btn im-btn-primary',
|
|
3790
|
+
disabled: busy,
|
|
3791
|
+
onClick: adoptLegacy,
|
|
3792
|
+
}, t('storage.legacyAdopt')),
|
|
3793
|
+
h('button', { className: 'im-btn', disabled: busy, onClick: dismissLegacy },
|
|
3794
|
+
t('storage.legacyDismiss'))))
|
|
3795
|
+
: null,
|
|
3700
3796
|
// ---- F18 备份位置 ----
|
|
3701
3797
|
h('div', { key: 'backup', className: 'im-backup' },
|
|
3702
3798
|
h('div', { className: 'im-section-title' }, t('backup.section')),
|
package/lib/index.js
CHANGED
|
@@ -458,10 +458,41 @@ export function cutText(value, max) {
|
|
|
458
458
|
return head
|
|
459
459
|
}
|
|
460
460
|
|
|
461
|
+
/**
|
|
462
|
+
* Characters that render as nothing (or as a different ORDER than the bytes
|
|
463
|
+
* say) and therefore must never survive into the injected prompt.
|
|
464
|
+
*
|
|
465
|
+
* The store content is user prose that ends up inside the system prompt, so an
|
|
466
|
+
* invisible character is not a cosmetic wart — it is a place where what the
|
|
467
|
+
* settings page shows and what the model reads diverge:
|
|
468
|
+
*
|
|
469
|
+
* - U+202A…U+202E / U+2066…U+2069 (bidi embeddings, overrides, isolates) can
|
|
470
|
+
* reverse the visual order of a run, so `evil` can be displayed as `live`
|
|
471
|
+
* while the model still reads `evil` — the classic Trojan-Source trick, and
|
|
472
|
+
* it defeats exactly the review the panel exists for.
|
|
473
|
+
* - U+200B…U+200F / U+FEFF are zero-width: a word can be split so a keyword
|
|
474
|
+
* filter, a duplicate check or the user's own eye misses it.
|
|
475
|
+
* - C0 controls (minus tab and newline) and DEL carry nothing but can break
|
|
476
|
+
* downstream tooling that assumes prompt text is printable.
|
|
477
|
+
*
|
|
478
|
+
* CR/LF are normalised first, and U+2028/U+2029 become real newlines rather
|
|
479
|
+
* than vanishing, so legitimate multi-line prose is preserved byte-for-byte in
|
|
480
|
+
* meaning while nothing invisible is.
|
|
481
|
+
*/
|
|
482
|
+
const INVISIBLE_CHARS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F\u200B-\u200F\u202A-\u202E\u2066-\u2069\uFEFF]/g
|
|
483
|
+
|
|
484
|
+
function stripInvisible(value) {
|
|
485
|
+
if (typeof value !== 'string') return ''
|
|
486
|
+
return value
|
|
487
|
+
.split('\u2028').join('\n')
|
|
488
|
+
.split('\u2029').join('\n')
|
|
489
|
+
.replace(INVISIBLE_CHARS, '')
|
|
490
|
+
}
|
|
491
|
+
|
|
461
492
|
function cleanText(value, max) {
|
|
462
493
|
if (typeof value !== 'string') return ''
|
|
463
|
-
const
|
|
464
|
-
return cutText(
|
|
494
|
+
const normalised = stripInvisible(value.split('\r\n').join('\n').split('\r').join('\n'))
|
|
495
|
+
return cutText(normalised.trim(), max)
|
|
465
496
|
}
|
|
466
497
|
|
|
467
498
|
/**
|
|
@@ -1452,10 +1483,61 @@ function requestPortOf(hostHeader) {
|
|
|
1452
1483
|
return port === '80' || port === '443' ? '' : port
|
|
1453
1484
|
}
|
|
1454
1485
|
|
|
1486
|
+
/**
|
|
1487
|
+
* Defuse text that would read as OUR OWN structure instead of as the user's
|
|
1488
|
+
* prose (A2).
|
|
1489
|
+
*
|
|
1490
|
+
* The injected block distinguishes "this is the harness speaking" from "this is
|
|
1491
|
+
* what the user typed" only by its own headings and entry prefixes. Anyone who
|
|
1492
|
+
* can write the store (the settings page, a hand edit, an import, or the model
|
|
1493
|
+
* itself through the same-permission file path the README documents) can paste
|
|
1494
|
+
* `## 用户长期指令记忆(Instruction Memory)` or `[始终|优先级 高]` into
|
|
1495
|
+
* `content`, and the model then sees two headings of which one is forged: a
|
|
1496
|
+
* fabricated priority, or a fabricated "system" section that the store's own
|
|
1497
|
+
* renderer never produced.
|
|
1498
|
+
*
|
|
1499
|
+
* Only the markers that NAME this plugin's blocks are neutralised, and only at
|
|
1500
|
+
* the start of a line, so ordinary Markdown (`## 实验步骤`) and ordinary
|
|
1501
|
+
* bracketed prose survive untouched. The marker character is swapped for a
|
|
1502
|
+
* full-width look-alike: the text stays readable to the user, but it can no
|
|
1503
|
+
* longer equal the token the model was told to trust.
|
|
1504
|
+
*/
|
|
1505
|
+
const FORGED_HEADING = /^([ \t]*)(#{1,6})([ \t]*)(用户长期指令记忆|关于用户(用户形象)|关于用户|用户形象)/gm
|
|
1506
|
+
const FORGED_ENTRY_PREFIX = /^([ \t]*)\[(始终|按需)(|优先级)/gm
|
|
1507
|
+
|
|
1508
|
+
function neutralizeForgedStructure(value) {
|
|
1509
|
+
if (typeof value !== 'string' || value === '') return value
|
|
1510
|
+
return value
|
|
1511
|
+
// A hand-written copy of the envelope would close the section early and put
|
|
1512
|
+
// everything after it outside the block the model was told to trust, which
|
|
1513
|
+
// is the whole point of having an envelope. Full-width brackets keep the
|
|
1514
|
+
// text readable while making the token unusable.
|
|
1515
|
+
.replace(/<\s*(\/?)\s*user-instruction-memory\s*>/gi,
|
|
1516
|
+
(match, slash) => '〔' + (slash === '/' ? '/' : '') + 'user-instruction-memory〕')
|
|
1517
|
+
.replace(FORGED_HEADING, '$1#$3$4')
|
|
1518
|
+
.replace(FORGED_ENTRY_PREFIX, '$1[$2$3')
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
/**
|
|
1522
|
+
* The delimiters around the whole injected section (A2).
|
|
1523
|
+
*
|
|
1524
|
+
* A heading alone cannot tell the model where this block ends: the section had
|
|
1525
|
+
* no closing token, so a content line shaped like the start of something else
|
|
1526
|
+
* read as if it were still inside the user's memory. The envelope gives the
|
|
1527
|
+
* section an unambiguous start and end that ordinary prose cannot produce by
|
|
1528
|
+
* accident, and `neutralizeForgedStructure` keeps a hand-written copy of it from
|
|
1529
|
+
* being reproduced inside `content`.
|
|
1530
|
+
*
|
|
1531
|
+
* The cost is reserved against the character budget in planInjection, so the
|
|
1532
|
+
* `text.length <= budget` invariant the panel shows the user still holds.
|
|
1533
|
+
*/
|
|
1534
|
+
const MEMORY_ENVELOPE_OPEN = '<user-instruction-memory>\n'
|
|
1535
|
+
const MEMORY_ENVELOPE_CLOSE = '\n</user-instruction-memory>'
|
|
1536
|
+
|
|
1455
1537
|
function formatEntry(entry, tag) {
|
|
1456
1538
|
const lines = ['[' + tag + '|优先级 ' + priorityLabel(entry.priority) + '] ' + entry.title]
|
|
1457
1539
|
if (entry.mode === 'auto' && entry.when !== '') lines.push('适用场景:' + entry.when)
|
|
1458
|
-
lines.push(entry.content)
|
|
1540
|
+
lines.push(neutralizeForgedStructure(entry.content))
|
|
1459
1541
|
return lines.join('\n')
|
|
1460
1542
|
}
|
|
1461
1543
|
|
|
@@ -1539,7 +1621,7 @@ function buildProfilesBlock(profiles, budget) {
|
|
|
1539
1621
|
? profile.name
|
|
1540
1622
|
: singleLine(profile.content, 20)
|
|
1541
1623
|
const id = typeof profile.id === 'string' ? profile.id : ''
|
|
1542
|
-
const block = '### ' + name + '\n' + profile.content
|
|
1624
|
+
const block = '### ' + name + '\n' + neutralizeForgedStructure(profile.content)
|
|
1543
1625
|
const sep = blocks.length === 0 ? 0 : 2
|
|
1544
1626
|
// Reserve the omission footer up front: the disclosure must never be the
|
|
1545
1627
|
// thing squeezed out by the profiles that caused it. Reserve the WORST
|
|
@@ -1759,16 +1841,27 @@ export function planInjection(data, now = Date.now()) {
|
|
|
1759
1841
|
// joined with: if the persona section took the whole budget, every entry
|
|
1760
1842
|
// would be dropped and its footer still has to fit.
|
|
1761
1843
|
const JOIN = '\n\n'.length
|
|
1844
|
+
// The envelope costs characters, so it comes out of the budget BEFORE the
|
|
1845
|
+
// persona and the entries spend it. `text.length <= budget` is the one
|
|
1846
|
+
// invariant the panel shows the user; an envelope added on top would break it
|
|
1847
|
+
// silently, and reserving afterwards would only move the overshoot into the
|
|
1848
|
+
// persona, which is never trimmed.
|
|
1849
|
+
const envelopeCost = MEMORY_ENVELOPE_OPEN.length + MEMORY_ENVELOPE_CLOSE.length
|
|
1850
|
+
const innerBudget = Math.max(0, budget - envelopeCost)
|
|
1762
1851
|
const activeEntryCount = (scoped.entries || []).filter((entry) => entry && entry.enabled === true && entry.content !== '').length
|
|
1763
1852
|
const entryReserve = activeEntryCount > 0 ? entryOmittedFooter(activeEntryCount).length + JOIN : 0
|
|
1764
|
-
const persona = buildProfilesBlock(scoped.profiles, Math.max(0,
|
|
1765
|
-
const entryPlan = planEntries(scoped, Math.max(0,
|
|
1853
|
+
const persona = buildProfilesBlock(scoped.profiles, Math.max(0, innerBudget - entryReserve))
|
|
1854
|
+
const entryPlan = planEntries(scoped, Math.max(0, innerBudget - persona.chars))
|
|
1766
1855
|
const parts = []
|
|
1767
1856
|
if (persona.text !== '') parts.push(persona.text)
|
|
1768
1857
|
if (entryPlan.text !== '') parts.push(entryPlan.text)
|
|
1769
1858
|
else if (parts.length > 0 && entryPlan.omitted.length > 0) parts.push(entryOmittedFooter(entryPlan.omitted.length))
|
|
1859
|
+
const inner = parts.join('\n\n')
|
|
1770
1860
|
return {
|
|
1771
|
-
|
|
1861
|
+
// Empty stays EMPTY, byte for byte: a store with nothing to say must leave
|
|
1862
|
+
// the prompt identical to a deployment without this plugin, so no envelope
|
|
1863
|
+
// is emitted around nothing.
|
|
1864
|
+
text: inner === '' ? '' : MEMORY_ENVELOPE_OPEN + inner + MEMORY_ENVELOPE_CLOSE,
|
|
1772
1865
|
budget,
|
|
1773
1866
|
activeCount: entryPlan.activeCount,
|
|
1774
1867
|
included: entryPlan.included,
|
|
@@ -1800,7 +1893,24 @@ export function apply(ctx, deps = {}) {
|
|
|
1800
1893
|
* to be deleted mid-test, which no contract test can do reproducibly.
|
|
1801
1894
|
*/
|
|
1802
1895
|
const watchStore = typeof deps.watch === 'function' ? deps.watch : watch
|
|
1803
|
-
|
|
1896
|
+
/**
|
|
1897
|
+
* A1: the prompt service is held in a mutable slot, not a one-shot capture.
|
|
1898
|
+
*
|
|
1899
|
+
* `ctx.systemPrompt` read at apply() time is only valid until the host
|
|
1900
|
+
* disposes and republishes the service (host restart, fiber remount, a
|
|
1901
|
+
* profile reload that swaps the row's dependencies). After that the captured
|
|
1902
|
+
* reference points at a DEAD service: `section()` there still returns a
|
|
1903
|
+
* disposer and throws nothing, the live service never receives a section, and
|
|
1904
|
+
* the prompt silently loses its memory for the rest of the process — the one
|
|
1905
|
+
* failure mode this plugin exists to prevent.
|
|
1906
|
+
*
|
|
1907
|
+
* The webServer registration further down already treats this shape
|
|
1908
|
+
* explicitly ("registering on top of the previous registration would LEAK
|
|
1909
|
+
* it"). The prompt side now matches it: `systemPromptRef` is rewired by the
|
|
1910
|
+
* non-blocking `ctx.inject(['systemPrompt'], …)` right where the route is
|
|
1911
|
+
* wired, and `syncSection` re-registers against whatever the slot holds.
|
|
1912
|
+
*/
|
|
1913
|
+
let systemPromptRef = ctx.systemPrompt === undefined ? null : ctx.systemPrompt
|
|
1804
1914
|
|
|
1805
1915
|
const state = {
|
|
1806
1916
|
data: {
|
|
@@ -1837,6 +1947,15 @@ export function apply(ctx, deps = {}) {
|
|
|
1837
1947
|
loadedFrom: null,
|
|
1838
1948
|
savedAt: null,
|
|
1839
1949
|
migratedFrom: null,
|
|
1950
|
+
/**
|
|
1951
|
+
* A4: a store-shaped file found at the legacy (process cwd) location, held
|
|
1952
|
+
* for the user to confirm. `{ path, entries, profiles }` or null.
|
|
1953
|
+
*
|
|
1954
|
+
* Detection is deliberate and adoption is not: see the load branch. The
|
|
1955
|
+
* panel renders this as an explicit "import the old data" action, so the
|
|
1956
|
+
* decision belongs to the user the way it always should have.
|
|
1957
|
+
*/
|
|
1958
|
+
legacyPending: null,
|
|
1840
1959
|
/**
|
|
1841
1960
|
* Set while the store on disk declares an unsupported format version.
|
|
1842
1961
|
* Mutating commands are refused in that state: a save would rewrite a
|
|
@@ -1855,6 +1974,16 @@ export function apply(ctx, deps = {}) {
|
|
|
1855
1974
|
shapeRejected: false,
|
|
1856
1975
|
sectionRegistered: false,
|
|
1857
1976
|
sectionError: null,
|
|
1977
|
+
/**
|
|
1978
|
+
* A7: whether the settings-page route is actually mounted.
|
|
1979
|
+
*
|
|
1980
|
+
* Without it, a profile that never publishes `webServer` (headless, tui, a
|
|
1981
|
+
* custom shell) left the plugin working for injection while the settings
|
|
1982
|
+
* page simply had no route — announced by one console.error and nothing
|
|
1983
|
+
* else. The panel cannot render, so the SNAPSHOT has to carry the fact for
|
|
1984
|
+
* the diagnostics view and for any support dump.
|
|
1985
|
+
*/
|
|
1986
|
+
routeRegistered: false,
|
|
1858
1987
|
/** Whether a store file is actually present on disk (diagnostics + the panel). */
|
|
1859
1988
|
storeExists: false,
|
|
1860
1989
|
/** How many history snapshots the current directory holds (diagnostics). */
|
|
@@ -2146,9 +2275,10 @@ export function apply(ctx, deps = {}) {
|
|
|
2146
2275
|
*/
|
|
2147
2276
|
const resolveSectionOrder = () => {
|
|
2148
2277
|
try {
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
const
|
|
2278
|
+
const service = systemPromptRef
|
|
2279
|
+
if (service === null || typeof service.getSectionOrder !== 'function') return SECTION_ORDER
|
|
2280
|
+
const lo = service.getSectionOrder('FILE_REFERENCE')
|
|
2281
|
+
const hi = service.getSectionOrder('TOOL_BASH')
|
|
2152
2282
|
if (!Number.isFinite(lo) || !Number.isFinite(hi) || hi <= lo) return SECTION_ORDER
|
|
2153
2283
|
return lo + (hi - lo) / 2
|
|
2154
2284
|
} catch {
|
|
@@ -2159,7 +2289,19 @@ export function apply(ctx, deps = {}) {
|
|
|
2159
2289
|
const syncSection = () => {
|
|
2160
2290
|
if (disposed) return
|
|
2161
2291
|
const wanted = sectionText() !== ''
|
|
2292
|
+
const service = systemPromptRef
|
|
2162
2293
|
if (wanted && sectionDispose === null) {
|
|
2294
|
+
// A1: no live service yet. That is not an error to report — the
|
|
2295
|
+
// `ctx.inject(['systemPrompt'], …)` wired below re-runs syncSection the
|
|
2296
|
+
// moment one is published — but registering through a null slot would
|
|
2297
|
+
// throw a TypeError into the load path instead of leaving the plugin
|
|
2298
|
+
// usable without its prompt half.
|
|
2299
|
+
if (service === null || typeof service.section !== 'function') {
|
|
2300
|
+
state.sectionRegistered = false
|
|
2301
|
+
state.sectionError = null
|
|
2302
|
+
scheduleMuteTimer()
|
|
2303
|
+
return
|
|
2304
|
+
}
|
|
2163
2305
|
try {
|
|
2164
2306
|
// `interpolate: false` is load-bearing, not a hint. The host runs every
|
|
2165
2307
|
// section through `{{variable}}` interpolation by DEFAULT and THROWS on
|
|
@@ -2170,7 +2312,7 @@ export function apply(ctx, deps = {}) {
|
|
|
2170
2312
|
// agent turn at renderPrompt(). This section is literal text and never
|
|
2171
2313
|
// references a host variable, so opt out explicitly.
|
|
2172
2314
|
const order = resolveSectionOrder()
|
|
2173
|
-
sectionDispose =
|
|
2315
|
+
sectionDispose = service.section({
|
|
2174
2316
|
name: SECTION_NAME,
|
|
2175
2317
|
order,
|
|
2176
2318
|
text: assembledSectionText,
|
|
@@ -3219,6 +3361,60 @@ export function apply(ctx, deps = {}) {
|
|
|
3219
3361
|
*
|
|
3220
3362
|
* @returns whether this load performed a successful write.
|
|
3221
3363
|
*/
|
|
3364
|
+
/**
|
|
3365
|
+
* A13: sweep temp files a crashed write left behind.
|
|
3366
|
+
*
|
|
3367
|
+
* `writeToDisk` is atomic by rename: it writes `memory.json.tmp-<pid>-<uuid>`,
|
|
3368
|
+
* fsyncs, then renames. A crash between `open` and `rename` leaves a COMPLETE
|
|
3369
|
+
* COPY OF THE USER'S INSTRUCTION MEMORY on disk under that name, in the same
|
|
3370
|
+
* directory, forever — and nothing ever looked for it. It is the store's own
|
|
3371
|
+
* content (not a redacted log line), so it inherits exactly the exposure of
|
|
3372
|
+
* memory.json while being invisible to every UI path.
|
|
3373
|
+
*
|
|
3374
|
+
* Only the plugin's own name shape is touched, and a temp file whose recorded
|
|
3375
|
+
* pid is still alive is left alone: that is another process in the middle of
|
|
3376
|
+
* a legitimate write, not debris.
|
|
3377
|
+
*/
|
|
3378
|
+
const cleanupOrphanTempFiles = async () => {
|
|
3379
|
+
const dir = dirname(storePath)
|
|
3380
|
+
const prefix = basename(storePath) + '.tmp-'
|
|
3381
|
+
let names = []
|
|
3382
|
+
try {
|
|
3383
|
+
names = await readdir(dir)
|
|
3384
|
+
} catch {
|
|
3385
|
+
return 0
|
|
3386
|
+
}
|
|
3387
|
+
let removed = 0
|
|
3388
|
+
for (const name of names) {
|
|
3389
|
+
if (!name.startsWith(prefix)) continue
|
|
3390
|
+
const rest = name.slice(prefix.length)
|
|
3391
|
+
const dash = rest.indexOf('-')
|
|
3392
|
+
const pidText = dash === -1 ? rest : rest.slice(0, dash)
|
|
3393
|
+
const pid = Number.parseInt(pidText, 10)
|
|
3394
|
+
if (Number.isFinite(pid) && pid > 0 && pid !== process.pid) {
|
|
3395
|
+
let alive = true
|
|
3396
|
+
try {
|
|
3397
|
+
process.kill(pid, 0)
|
|
3398
|
+
} catch (err) {
|
|
3399
|
+
// ESRCH means the writer is gone; EPERM means it exists but belongs to
|
|
3400
|
+
// someone else — treat that as alive and leave the file alone.
|
|
3401
|
+
alive = err !== null && err !== undefined && err.code === 'EPERM'
|
|
3402
|
+
}
|
|
3403
|
+
if (alive) continue
|
|
3404
|
+
}
|
|
3405
|
+
try {
|
|
3406
|
+
await unlink(join(dir, name))
|
|
3407
|
+
removed += 1
|
|
3408
|
+
} catch {
|
|
3409
|
+
// Best effort: a file we cannot remove must not stop the load.
|
|
3410
|
+
}
|
|
3411
|
+
}
|
|
3412
|
+
if (removed > 0) {
|
|
3413
|
+
logInfo('removed ' + removed + ' orphaned temp store file(s) left by an interrupted write')
|
|
3414
|
+
}
|
|
3415
|
+
return removed
|
|
3416
|
+
}
|
|
3417
|
+
|
|
3222
3418
|
const readFromDisk = async () => {
|
|
3223
3419
|
state.storagePath = storePath
|
|
3224
3420
|
state.storageDisplay = storePath
|
|
@@ -3234,6 +3430,8 @@ export function apply(ctx, deps = {}) {
|
|
|
3234
3430
|
// and the panel silently disagreed. Forcing the next save to write is the
|
|
3235
3431
|
// cheap side of that trade.
|
|
3236
3432
|
lastPersistedKey = null
|
|
3433
|
+
// A13: drop temp copies a crashed write left in the store directory.
|
|
3434
|
+
await cleanupOrphanTempFiles()
|
|
3237
3435
|
|
|
3238
3436
|
/**
|
|
3239
3437
|
* A store declaring an unsupported format version: adopt nothing, write
|
|
@@ -3425,8 +3623,20 @@ export function apply(ctx, deps = {}) {
|
|
|
3425
3623
|
// An unusable backup must never block a fresh store; fall through.
|
|
3426
3624
|
}
|
|
3427
3625
|
|
|
3428
|
-
// 2.
|
|
3429
|
-
//
|
|
3626
|
+
// 2. A pre-release store at the host process cwd (the legacy location) is
|
|
3627
|
+
// DETECTED here, never adopted here (A4).
|
|
3628
|
+
//
|
|
3629
|
+
// This used to write it straight into memory.json and inject it on the
|
|
3630
|
+
// next turn, with no user action and no confirmation. It was the only
|
|
3631
|
+
// write path in the plugin with neither an authentication fence nor a
|
|
3632
|
+
// human in the loop, and `process.cwd()` is not the plugin's to trust:
|
|
3633
|
+
// any process running as the same user could drop a store-shaped JSON
|
|
3634
|
+
// there and have it become the long-lived instruction memory injected
|
|
3635
|
+
// into every subsequent prompt.
|
|
3636
|
+
//
|
|
3637
|
+
// The file is now only ANNOUNCED. Adopting it takes the explicit
|
|
3638
|
+
// `adopt-legacy` command from the settings page, which is a deliberate
|
|
3639
|
+
// user action behind the same fence every other mutation uses.
|
|
3430
3640
|
const legacyPath = join(process.cwd(), LEGACY_FILE_NAME)
|
|
3431
3641
|
try {
|
|
3432
3642
|
const parsed = await readStoreFile(legacyPath)
|
|
@@ -3441,18 +3651,18 @@ export function apply(ctx, deps = {}) {
|
|
|
3441
3651
|
if (loaded.rejectedVersion !== undefined) {
|
|
3442
3652
|
// A legacy file in a future format is not ours to migrate or judge.
|
|
3443
3653
|
state.storageError = '旧位置文件为不支持的格式版本 ' + loaded.rejectedVersion + ',已忽略'
|
|
3444
|
-
|
|
3445
|
-
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3654
|
+
} else {
|
|
3655
|
+
const entryCount = loaded.data.entries.length
|
|
3656
|
+
const profileCount = loaded.data.profiles.length
|
|
3657
|
+
state.legacyPending = { path: legacyPath, entries: entryCount, profiles: profileCount }
|
|
3658
|
+
const warnings = loadWarnings(loaded)
|
|
3659
|
+
state.storageWarning = (warnings !== null ? warnings + ';' : '')
|
|
3660
|
+
+ '检测到旧位置(启动目录)有一份记忆:' + legacyPath
|
|
3661
|
+
+ '(' + entryCount + ' 条指令、' + profileCount + ' 个形象)。'
|
|
3662
|
+
+ '为避免未经确认就注入一个外部文件,本次没有自动导入;'
|
|
3663
|
+
+ '确认内容无误后在设置页点击「导入旧位置数据」。'
|
|
3664
|
+
state.loadedFrom = 'legacy cwd file (detected, awaiting confirmation)'
|
|
3453
3665
|
}
|
|
3454
|
-
state.loadedFrom = 'legacy cwd file (migrated)'
|
|
3455
|
-
return migrated
|
|
3456
3666
|
}
|
|
3457
3667
|
}
|
|
3458
3668
|
} catch (err) {
|
|
@@ -3471,7 +3681,10 @@ export function apply(ctx, deps = {}) {
|
|
|
3471
3681
|
// `state` (see ensureStoreMaterialized). Until then the panel shows
|
|
3472
3682
|
// "尚未写入磁盘", a state its UI already renders.
|
|
3473
3683
|
needsMaterialize = true
|
|
3474
|
-
|
|
3684
|
+
// A detected-but-unconfirmed legacy file keeps its own `loadedFrom`: the
|
|
3685
|
+
// panel must be able to say "there is a store next door, waiting for you"
|
|
3686
|
+
// rather than "first run, nothing anywhere" (A4).
|
|
3687
|
+
if (state.legacyPending === null) state.loadedFrom = 'first run (no store on disk yet)'
|
|
3475
3688
|
state.storeExists = false
|
|
3476
3689
|
return false
|
|
3477
3690
|
}
|
|
@@ -3662,6 +3875,13 @@ export function apply(ctx, deps = {}) {
|
|
|
3662
3875
|
* allow-list is the only thing standing in front of a fence-less route.
|
|
3663
3876
|
*/
|
|
3664
3877
|
trustedHostCount: trustedHosts.length,
|
|
3878
|
+
/**
|
|
3879
|
+
* A7: is the settings route actually mounted? `false` means this
|
|
3880
|
+
* profile published no `webServer` (or the registration threw), so the
|
|
3881
|
+
* panel has no way in even though injection works. Published here
|
|
3882
|
+
* because a panel that cannot render cannot report its own absence.
|
|
3883
|
+
*/
|
|
3884
|
+
routeRegistered: state.routeRegistered === true,
|
|
3665
3885
|
},
|
|
3666
3886
|
/**
|
|
3667
3887
|
* F12: load-side diagnostics that used to be log-only. `loadedFrom` says
|
|
@@ -3679,6 +3899,19 @@ export function apply(ctx, deps = {}) {
|
|
|
3679
3899
|
warning: state.storageWarning !== null ? state.storageWarning : state.backupError,
|
|
3680
3900
|
savedAt: state.savedAt,
|
|
3681
3901
|
migratedFrom: state.migratedFrom === null ? null : displayPath(state.migratedFrom),
|
|
3902
|
+
/**
|
|
3903
|
+
* A4: a store-shaped file sitting at the legacy (startup directory)
|
|
3904
|
+
* location, waiting for the user to confirm. `null` when there is
|
|
3905
|
+
* nothing pending. The panel renders this as an explicit action; the
|
|
3906
|
+
* file is never read into memory.json on its own.
|
|
3907
|
+
*/
|
|
3908
|
+
legacyPending: state.legacyPending === null
|
|
3909
|
+
? null
|
|
3910
|
+
: {
|
|
3911
|
+
path: displayPath(state.legacyPending.path),
|
|
3912
|
+
entries: state.legacyPending.entries,
|
|
3913
|
+
profiles: state.legacyPending.profiles,
|
|
3914
|
+
},
|
|
3682
3915
|
/** false until the store file has actually been written (see readFromDisk step 3). */
|
|
3683
3916
|
onDisk: state.storeExists,
|
|
3684
3917
|
/** Backup location (F18): '' means "next to the store file". */
|
|
@@ -3706,6 +3939,13 @@ export function apply(ctx, deps = {}) {
|
|
|
3706
3939
|
*/
|
|
3707
3940
|
muted: isMuted(state.data, Date.now()),
|
|
3708
3941
|
chars: preview.length,
|
|
3942
|
+
/**
|
|
3943
|
+
* A2: how much of `chars` is the fixed envelope around the block. The
|
|
3944
|
+
* panel's meter measures `entryChars + profileChars + envelopeChars`
|
|
3945
|
+
* (the parts the budget is actually spent on) instead of `chars`, so
|
|
3946
|
+
* it has to know this number; older clients simply ignore the field.
|
|
3947
|
+
*/
|
|
3948
|
+
envelopeChars: preview === '' ? 0 : MEMORY_ENVELOPE_OPEN.length + MEMORY_ENVELOPE_CLOSE.length,
|
|
3709
3949
|
/**
|
|
3710
3950
|
* The budget decision behind `preview`, for the settings page's
|
|
3711
3951
|
* usage meter and per-entry marks. `budget` is the effective ceiling;
|
|
@@ -4761,6 +5001,76 @@ export function apply(ctx, deps = {}) {
|
|
|
4761
5001
|
return settle([head + describeStoreShape(state.data), ...notes].join(';'), saved)
|
|
4762
5002
|
}
|
|
4763
5003
|
|
|
5004
|
+
/**
|
|
5005
|
+
* A4: adopt the store-shaped file the boot load found at the legacy
|
|
5006
|
+
* (process cwd) location.
|
|
5007
|
+
*
|
|
5008
|
+
* Reachable only from the settings page, and only when the load actually
|
|
5009
|
+
* found such a file: it re-reads THAT path (never one supplied by the
|
|
5010
|
+
* request), and refuses anything that stopped looking like a store since —
|
|
5011
|
+
* so a file swapped in between detection and confirmation is caught rather
|
|
5012
|
+
* than imported. Adoption is a deliberate user action behind the same route
|
|
5013
|
+
* fence every other mutation uses, which is the whole point of the change.
|
|
5014
|
+
*/
|
|
5015
|
+
const adoptLegacy = (baseRev) => enqueue(async () => {
|
|
5016
|
+
if (state.versionRejected) return versionRefusal()
|
|
5017
|
+
if (isStaleBaseRev(baseRev)) return conflictResponse()
|
|
5018
|
+
const pending = state.legacyPending
|
|
5019
|
+
if (pending === null) {
|
|
5020
|
+
return { ok: false, saved: false, message: '没有待导入的旧位置数据', snapshot: snapshot() }
|
|
5021
|
+
}
|
|
5022
|
+
let parsed = null
|
|
5023
|
+
try {
|
|
5024
|
+
parsed = await readStoreFile(pending.path)
|
|
5025
|
+
} catch (err) {
|
|
5026
|
+
return {
|
|
5027
|
+
ok: false,
|
|
5028
|
+
saved: false,
|
|
5029
|
+
message: '读取旧位置文件失败:' + describeErrorSafe(err),
|
|
5030
|
+
snapshot: snapshot(),
|
|
5031
|
+
}
|
|
5032
|
+
}
|
|
5033
|
+
if (parsed === null) {
|
|
5034
|
+
state.legacyPending = null
|
|
5035
|
+
state.storageWarning = '旧位置文件已不存在,待导入状态已清除:' + pending.path
|
|
5036
|
+
return { ok: false, saved: false, message: '旧位置文件已不存在,待导入状态已清除', snapshot: snapshot() }
|
|
5037
|
+
}
|
|
5038
|
+
if (storeShapeVerdict(parsed) !== 'store') {
|
|
5039
|
+
state.legacyPending = null
|
|
5040
|
+
state.storageWarning = '旧位置文件的内容已不是本插件的记忆存储,待导入状态已清除'
|
|
5041
|
+
return { ok: false, saved: false, message: '旧位置文件已不是本插件的记忆存储,未导入', snapshot: snapshot() }
|
|
5042
|
+
}
|
|
5043
|
+
const result = await commitAdoptedStore(parsed, '已导入旧位置数据:')
|
|
5044
|
+
if (result !== null && result.ok === true) {
|
|
5045
|
+
state.legacyPending = null
|
|
5046
|
+
state.storageWarning = null
|
|
5047
|
+
state.storageError = null
|
|
5048
|
+
state.migratedFrom = pending.path
|
|
5049
|
+
state.loadedFrom = 'legacy cwd file (adopted after confirmation)'
|
|
5050
|
+
// Keep the original as evidence instead of deleting user data.
|
|
5051
|
+
await rename(pending.path, pending.path + '.migrated').catch(() => {})
|
|
5052
|
+
return { ...result, snapshot: snapshot() }
|
|
5053
|
+
}
|
|
5054
|
+
return result
|
|
5055
|
+
})
|
|
5056
|
+
|
|
5057
|
+
/**
|
|
5058
|
+
* A4: "not now" for a detected legacy file. Clears the pending flag and the
|
|
5059
|
+
* announcement, and deliberately leaves the file alone — nothing was read
|
|
5060
|
+
* into the store, so there is nothing to undo.
|
|
5061
|
+
*/
|
|
5062
|
+
const dismissLegacy = () => {
|
|
5063
|
+
const had = state.legacyPending !== null
|
|
5064
|
+
state.legacyPending = null
|
|
5065
|
+
state.storageWarning = null
|
|
5066
|
+
return {
|
|
5067
|
+
ok: true,
|
|
5068
|
+
saved: false,
|
|
5069
|
+
message: had ? '已忽略旧位置数据(原文件未改动)' : '没有待导入的旧位置数据',
|
|
5070
|
+
snapshot: snapshot(),
|
|
5071
|
+
}
|
|
5072
|
+
}
|
|
5073
|
+
|
|
4764
5074
|
const writeSource = (text, baseRev) => enqueue(async () => {
|
|
4765
5075
|
if (state.versionRejected) return versionRefusal()
|
|
4766
5076
|
// Deliberately NOT gated on shapeRejected: hand-editing the source is how
|
|
@@ -5312,6 +5622,10 @@ export function apply(ctx, deps = {}) {
|
|
|
5312
5622
|
return importData(
|
|
5313
5623
|
args !== null && typeof args === 'object' ? args.payload : null,
|
|
5314
5624
|
args !== null && typeof args === 'object' ? args.baseRev : undefined)
|
|
5625
|
+
case 'adopt-legacy':
|
|
5626
|
+
return adoptLegacy(args !== null && typeof args === 'object' ? args.baseRev : undefined)
|
|
5627
|
+
case 'dismiss-legacy':
|
|
5628
|
+
return dismissLegacy()
|
|
5315
5629
|
case 'send-feedback':
|
|
5316
5630
|
return sendFeedback(args)
|
|
5317
5631
|
case 'batch':
|
|
@@ -5578,6 +5892,65 @@ export function apply(ctx, deps = {}) {
|
|
|
5578
5892
|
return true
|
|
5579
5893
|
}
|
|
5580
5894
|
|
|
5895
|
+
/**
|
|
5896
|
+
* A9/A12: the last line of defence for MUTATIONS on a fence-less route.
|
|
5897
|
+
*
|
|
5898
|
+
* `originVerdictRefuses` above only judges what a browser volunteers. A local
|
|
5899
|
+
* process (curl, a script, another app) sends no `Origin` at all, and every
|
|
5900
|
+
* branch of requestOriginVerdict is conditioned on an Origin being present —
|
|
5901
|
+
* so on a profile that publishes a web server WITHOUT a `connection` service
|
|
5902
|
+
* the settings route accepted unauthenticated writes from anything running as
|
|
5903
|
+
* the same user. Loopback used to be a boundary; it stopped being one the
|
|
5904
|
+
* moment any program on the machine runs as that user.
|
|
5905
|
+
*
|
|
5906
|
+
* Two rules, both limited to mutating methods so a read-only panel keeps
|
|
5907
|
+
* working in those deployments:
|
|
5908
|
+
*
|
|
5909
|
+
* 1. A widened DSH_INSTRUCTION_MEMORY_TRUSTED_HOSTS list is a HOSTNAME rule,
|
|
5910
|
+
* not an identity check. Combined with a non-loopback bind
|
|
5911
|
+
* (`hostGuarded === false`) and no fence, the only thing in front of the
|
|
5912
|
+
* route is a name an attacker picks, so writes are refused outright (503)
|
|
5913
|
+
* instead of relying on an orange warning in the panel.
|
|
5914
|
+
* 2. Without a fence, a mutation must carry a browser origin marker: either
|
|
5915
|
+
* `Origin` or `Sec-Fetch-Site: same-origin`. A page always sends one of
|
|
5916
|
+
* the two, so the settings page is unaffected; a bare process is refused
|
|
5917
|
+
* (403). `Sec-Fetch-Site` is accepted because script cannot set it — it is
|
|
5918
|
+
* the browser's own claim, not the caller's.
|
|
5919
|
+
*
|
|
5920
|
+
* Set DSH_INSTRUCTION_MEMORY_REQUIRE_AUTH_FENCE=1 to make the whole route fail
|
|
5921
|
+
* closed instead of falling back to these two rules.
|
|
5922
|
+
*/
|
|
5923
|
+
const READ_ONLY_METHODS = new Set([
|
|
5924
|
+
'state', 'history-list', 'read-source', 'export-data', 'export-to-dir', 'reload', 'send-feedback',
|
|
5925
|
+
])
|
|
5926
|
+
|
|
5927
|
+
const writeGateRefusal = (method, headers) => {
|
|
5928
|
+
const name = typeof method === 'string' ? method : ''
|
|
5929
|
+
if (READ_ONLY_METHODS.has(name)) return null
|
|
5930
|
+
if (connectionGate !== null) return null
|
|
5931
|
+
const head = headers !== null && typeof headers === 'object' ? headers : {}
|
|
5932
|
+
if (guardHost !== true && trustedHosts.length > 0) {
|
|
5933
|
+
return {
|
|
5934
|
+
status: 503,
|
|
5935
|
+
message: '拒绝修改请求:当前部署绑定在非回环地址、没有宿主的会话鉴权门,且仅靠一份主机名白名单('
|
|
5936
|
+
+ trustedHosts.length + ' 条)。白名单是主机名规则而非身份校验,因此写操作已停用;'
|
|
5937
|
+
+ '请改为回环绑定,或让宿主提供 connection 服务。',
|
|
5938
|
+
}
|
|
5939
|
+
}
|
|
5940
|
+
const origin = typeof head.origin === 'string' ? head.origin.trim() : ''
|
|
5941
|
+
const fetchSite = typeof head['sec-fetch-site'] === 'string'
|
|
5942
|
+
? head['sec-fetch-site'].trim().toLowerCase() : ''
|
|
5943
|
+
if (origin === '' && fetchSite !== 'same-origin') {
|
|
5944
|
+
return {
|
|
5945
|
+
status: 403,
|
|
5946
|
+
message: '拒绝无来源标记的修改请求:当前 profile 没有宿主的会话鉴权门,因此修改必须来自浏览器'
|
|
5947
|
+
+ '(带 Origin 或 Sec-Fetch-Site: same-origin)。设置页读写不受影响;本机脚本 / curl 的写入会被拒绝。'
|
|
5948
|
+
+ '如需完全关闭该路由,设置 DSH_INSTRUCTION_MEMORY_REQUIRE_AUTH_FENCE=1。',
|
|
5949
|
+
}
|
|
5950
|
+
}
|
|
5951
|
+
return null
|
|
5952
|
+
}
|
|
5953
|
+
|
|
5581
5954
|
const handler = (req, res) => {
|
|
5582
5955
|
// Authentication before anything else: not the method, not the body, not
|
|
5583
5956
|
// the size. A rejection must not leak which methods exist.
|
|
@@ -5686,6 +6059,16 @@ export function apply(ctx, deps = {}) {
|
|
|
5686
6059
|
let result
|
|
5687
6060
|
try {
|
|
5688
6061
|
const payload = body === '' ? {} : JSON.parse(body)
|
|
6062
|
+
// A9/A12: mutations on a fence-less route need a browser origin
|
|
6063
|
+
// marker. Checked after parsing (the method is only known now) and
|
|
6064
|
+
// before dispatch, so a refused write never touches the store.
|
|
6065
|
+
const gateRefusal = writeGateRefusal(payload.method, req.headers)
|
|
6066
|
+
if (gateRefusal !== null) {
|
|
6067
|
+
logInfo('route refused a mutation: ' + gateRefusal.message)
|
|
6068
|
+
res.writeHead(gateRefusal.status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
6069
|
+
res.end(JSON.stringify({ ok: false, saved: false, message: gateRefusal.message, snapshot: snapshot() }))
|
|
6070
|
+
return
|
|
6071
|
+
}
|
|
5689
6072
|
result = await dispatch(payload.method, payload.args === undefined ? null : payload.args)
|
|
5690
6073
|
} catch (err) {
|
|
5691
6074
|
result = { ok: false, saved: false, message: describeErrorSafe(err), snapshot: snapshot() }
|
|
@@ -5702,6 +6085,56 @@ export function apply(ctx, deps = {}) {
|
|
|
5702
6085
|
// losing the prompt injection along with the settings page. The callback also
|
|
5703
6086
|
// fixes the original race properly — it runs whenever the service is
|
|
5704
6087
|
// published, not only if it happened to beat this row to the mount.
|
|
6088
|
+
/**
|
|
6089
|
+
* A1: keep the prompt service subscription live for as long as this row is.
|
|
6090
|
+
*
|
|
6091
|
+
* `systemPrompt` is a declared dependency (`export const inject`), so the
|
|
6092
|
+
* initial capture in apply() is normally valid. It is NOT valid after the host
|
|
6093
|
+
* republishes the service: the old instance keeps accepting `section()` calls
|
|
6094
|
+
* and keeps returning a disposer, which is why the failure is silent. Wiring
|
|
6095
|
+
* a non-blocking child fiber here is the same shape the webServer route below
|
|
6096
|
+
* uses, for the same reason.
|
|
6097
|
+
*
|
|
6098
|
+
* The callback is a no-op when the published service is the one already held,
|
|
6099
|
+
* so a host that publishes once behaves exactly as before.
|
|
6100
|
+
*/
|
|
6101
|
+
ctx.inject(['systemPrompt'], (scope) => {
|
|
6102
|
+
const service = scope === null || scope === undefined ? null : scope.systemPrompt
|
|
6103
|
+
if (service === null || service === undefined || typeof service.section !== 'function') return
|
|
6104
|
+
if (service === systemPromptRef) return
|
|
6105
|
+
|
|
6106
|
+
const previous = systemPromptRef
|
|
6107
|
+
systemPromptRef = service
|
|
6108
|
+
// Drop the registration made against the previous instance BEFORE creating
|
|
6109
|
+
// one on the new: two live registrations would both be pulled into every
|
|
6110
|
+
// prompt (the block appears twice) and would keep the dead service wired.
|
|
6111
|
+
if (sectionDispose !== null) {
|
|
6112
|
+
const dispose = sectionDispose
|
|
6113
|
+
sectionDispose = null
|
|
6114
|
+
try {
|
|
6115
|
+
dispose()
|
|
6116
|
+
} catch (err) {
|
|
6117
|
+
console.error('[instruction-memory] stale prompt section dispose failed:', err)
|
|
6118
|
+
}
|
|
6119
|
+
}
|
|
6120
|
+
state.sectionRegistered = false
|
|
6121
|
+
if (typeof scope.effect === 'function') {
|
|
6122
|
+
scope.effect(() => () => {
|
|
6123
|
+
if (systemPromptRef !== service) return
|
|
6124
|
+
// The service went away. Stop pointing at it so the next publish can
|
|
6125
|
+
// re-register, and record it instead of pretending nothing happened:
|
|
6126
|
+
// an injected memory that quietly stops being injected is the one
|
|
6127
|
+
// symptom a user cannot diagnose from the panel.
|
|
6128
|
+
systemPromptRef = null
|
|
6129
|
+
state.sectionRegistered = false
|
|
6130
|
+
state.sectionError = '注入服务已重新发布/下线,发布后会自动恢复注入'
|
|
6131
|
+
logInfo('systemPrompt disposed or republished while mounted; the section will re-register when it returns')
|
|
6132
|
+
})
|
|
6133
|
+
}
|
|
6134
|
+
logInfo('systemPrompt republished; re-registering the instruction section against the new service')
|
|
6135
|
+
syncSection()
|
|
6136
|
+
})
|
|
6137
|
+
|
|
5705
6138
|
// F19: take the harness authentication fence when the profile has one.
|
|
5706
6139
|
// Same non-blocking shape as the web server below — a profile without a
|
|
5707
6140
|
// browser never publishes `connection`, and the prompt injection must not
|
|
@@ -5765,14 +6198,17 @@ export function apply(ctx, deps = {}) {
|
|
|
5765
6198
|
}
|
|
5766
6199
|
try {
|
|
5767
6200
|
routeDispose = webServer.register({ kind: 'prefix', path: API_PREFIX, handler })
|
|
6201
|
+
state.routeRegistered = true
|
|
5768
6202
|
logDebug('settings route registered at ' + API_PREFIX + ' (boundHost=' + boundHost + ', hostGuard=' + guardHost + ')')
|
|
5769
6203
|
} catch (err) {
|
|
5770
6204
|
console.error('[instruction-memory] route registration failed:', err)
|
|
5771
6205
|
routeDispose = null
|
|
6206
|
+
state.routeRegistered = false
|
|
5772
6207
|
}
|
|
5773
6208
|
// The child fiber is owned by this one, so unmounting the plugin still
|
|
5774
6209
|
// disposes the route even though it was registered here.
|
|
5775
6210
|
scope.effect(() => () => {
|
|
6211
|
+
state.routeRegistered = false
|
|
5776
6212
|
if (routeDispose !== null) {
|
|
5777
6213
|
const dispose = routeDispose
|
|
5778
6214
|
routeDispose = null
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-instruction-memory",
|
|
3
|
-
"version": "1.5.
|
|
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
|
|
3
|
+
"version": "1.5.7",
|
|
4
|
+
"description": "User-only instruction memory for DSH: long-term instructions maintained in the settings UI and auto-injected into every conversation — the plugin registers no model-facing tool, so the model has no in-plugin write path (a same-permission process can still edit the file directly). 指令记忆:只由用户在设置页维护的长期指令,自动注入此后每轮对话;插件不注册任何面向模型的工具,模型没有插件内的写入口。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"repository": {
|
|
@@ -54,6 +54,11 @@
|
|
|
54
54
|
"peerDependencies": {
|
|
55
55
|
"@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.2-alpha.0 <0.3.0-0 || >=0.2.0-0 <0.3.0-0"
|
|
56
56
|
},
|
|
57
|
+
"peerDependenciesMeta": {
|
|
58
|
+
"@deepseek-ai/dsh-system-prompt": {
|
|
59
|
+
"optional": true
|
|
60
|
+
}
|
|
61
|
+
},
|
|
57
62
|
"publishConfig": {
|
|
58
63
|
"access": "public"
|
|
59
64
|
},
|