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 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
- > 首次启动新版本时会自动迁移到上述位置,并把原文件改名为 `*.migrated` 保留。
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/lib/index.js`),在**安装**与 **profile 启动**时执行 | **真正的门禁**。不满足即拒绝(或要求显式授予精确版本豁免:`dsh plugin allow-version`)。门禁把 peer 的**范围值**与宿主版本(`dsh --version`)比较,peer 的**包名不参与解析**,只用来点名报错 |
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
- 环境经 `/usr/bin/env -i` 清空后只注入 `HOME=/root`、`DSH_HOME=/root/.dsh`——**App 侧设置的
961
- `HOME` 对引擎无效**,别按字面读 `DshProcess.kt`。
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
- | 导入 / 从备份恢复 | 面板常驻一行提示:文件选择器不弹时改用「源码模式」粘贴 JSON(这条通道不依赖任何原生能力) | `import.webviewHint` |
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
- **插件侧做不到、必须平台提供的**:`DownloadListener`(WebView 拿不到 `blob:` 的字节,
995
- 插件无法自救)、`WebChromeClient.onShowFileChooser`、`onJsPrompt`、`env -i` 白名单
996
- (否则备份目录那个开关永远设不上)、以及 WebView 销毁/后台时显式关闭 SSE
997
- (否则半开连接会占满 32 条上限)。
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
- const used = entryUsed + profileUsed
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 = 'dsh-instruction-memory-' + new Date().toISOString().slice(0, 10) + '.json'
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 trimmed = value.split('\r\n').join('\n').split('\r').join('\n').trim()
464
- return cutText(trimmed, max)
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, budget - entryReserve))
1765
- const entryPlan = planEntries(scoped, Math.max(0, budget - persona.chars))
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
- text: parts.join('\n\n'),
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
- const systemPrompt = ctx.systemPrompt
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
- if (systemPrompt === null || typeof systemPrompt.getSectionOrder !== 'function') return SECTION_ORDER
2150
- const lo = systemPrompt.getSectionOrder('FILE_REFERENCE')
2151
- const hi = systemPrompt.getSectionOrder('TOOL_BASH')
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 = systemPrompt.section({
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. Migrate the pre-release location (host process cwd). This is the only
3429
- // reason the legacy name is still known to this plugin.
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
- return false
3445
- }
3446
- state.data = loaded.data
3447
- state.storageWarning = loadWarnings(loaded)
3448
- const migrated = await writeToDisk()
3449
- if (migrated) {
3450
- state.migratedFrom = legacyPath
3451
- // Keep the original as evidence instead of deleting user data.
3452
- await rename(legacyPath, legacyPath + '.migrated').catch(() => {})
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
- state.loadedFrom = 'first run (no store on disk yet)'
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.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 access. 指令记忆:只由用户在设置页维护的长期指令,自动注入此后每轮对话;模型没有任何写入口。",
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
  },