dsh-instruction-memory 1.5.4 → 1.5.6

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,73 @@
7
7
  > 1.0.13 起由 CI 经 OIDC Trusted Publishing 发布(首个带 provenance 的版本)。
8
8
  > 这些历史 tag **不应回填**——npm 上的版本号已被占用。
9
9
 
10
+ ## [1.5.6] — 2026-11-13
11
+
12
+ ### 新增
13
+
14
+ - **导出可以真正落到文件(仅在 DSH Go 里生效)**:`doExport()` 现在先探测 `window.DshGoIsland`
15
+ ——那是 DSH Go 的 island 桥。存在时把 JSON 按 6,000 字符一块(不切断代理对)`postMessage` 给 App,
16
+ 由 App 写文件并唤起系统分享面板,面板提示「已交给 DSH Go 保存」。不存在时行为不变:普通 WebView
17
+ 回退到面板内 JSON,浏览器/Electron 仍是 `<a download>`。
18
+ 壳侧需要配套补丁(island 协议新增 `t:'save'` 分块消息),已在仓库外单独产出,见
19
+ `D:\Users\34332\AI\dsh-go-patch\README-PATCH.md`;本仓库不含任何上游代码。
20
+
21
+ ### 文档更正
22
+
23
+ 只改文档的部分,**更正 1.5.5 发布说明里两处对 DSH Go 壳的描述**——都是读源码(`ccch1mneyyy/dsh-go`,2026-11-12)后确认写错的:
24
+
25
+ - **引擎环境不是 `/usr/bin/env -i`**:壳用 `ProcessBuilder`,`environment().clear()` 后逐项填
26
+ `PATH/HOME/TMPDIR/ANDROID_ROOT/ANDROID_DATA/PROOT_TMP_DIR`,再合并运行时清单的 `launchEnv`。
27
+ `HOME` 是 App 的 `filesDir`,不是 `/root`。结论不变(宿主变量不继承、备份开关仍不可达),
28
+ 但机制与取值之前写错了。
29
+ - **文件选择器壳里已经实现**:`island_handle.dart:197` 的 `_files.attach()` →
30
+ `web_file_chooser.dart:26` 的 `setOnShowFileSelector` → 原生 `dsh/web-files` 通道
31
+ (content:// 校验、按文档代际取消)。所以 1.5.5 里"导入依赖文件选择器、壳没实现"的说法不成立;
32
+ 面板那行提示保留为兜底(非 http(s) 页面、`canChoose()` 为假、旧版 App)。
33
+
34
+ 仍未实现、且确实在壳侧的只有三件:**导出用的保存/分享桥**(island 消息协议上限 16 KB,需分块)、
35
+ `onJsPrompt`(webview_flutter 不暴露,插件已绕开,可不做)、**备份开关的环境变量**(壳能加,现未加)。
36
+
37
+ ## [1.5.5] — 2026-11-12
38
+
39
+ DSH Go(Android / WebView)适配。**没有真机验证**(本机 `adb devices` 为空),所以这一版只做
40
+ "插件侧能自救的部分":把所有**静默失败**变成可见的降级路径,其余列给平台侧。存储格式、注入文本
41
+ 与面板在浏览器/桌面上的行为均不变。
42
+
43
+ ### 修复(Android / WebView 静默失败)
44
+
45
+ - **导出在 WebView 上点了没反应**(P1):`<a download>` 只在宿主实现 `DownloadListener` 时才有
46
+ 意义,且 Chromium WebView 拿不到 `blob:` 的字节。现在识别 WebView 后不再点那个必然失败的链接,
47
+ 直接把完整 JSON 渲染在面板里(可全选复制);非 WebView 路径补上「已开始下载」反馈。
48
+ - **导入 / 从备份恢复依赖文件选择器**(P1):WebView 未实现 `onShowFileChooser` 时点击完全无反应。
49
+ 面板在 WebView 上常驻一行提示,指向不依赖任何原生能力的「源码模式」粘贴通道。
50
+ - **批量加标签被误判成"用户取消"**(P1):这段判定当初是为 Electron(`prompt` 抛错)写的;Android
51
+ WebView 是第四种形态——`typeof === 'function'`、不抛错、但返回 `null` 且不弹框。现在 WebView 上
52
+ 把 `null` 判为"问不了",直接走内联输入框。
53
+ - **SSE 断流后静默陈旧**(P2):连续 6 次(≈30 秒)订阅失败后显示「实时更新已断开,请重新载入」,
54
+ 收到下一帧自动清除。此前只有手动「重新载入」能救。同时给事件流 socket 打开 TCP keepalive 与
55
+ `setNoDelay`,让被系统杀掉的 WebView 能被 OS 更快发现(半开连接占满 32 条上限的窗口收窄)。
56
+
57
+ ### 加固
58
+
59
+ - **新增 fail-closed 开关** `DSH_INSTRUCTION_MEMORY_REQUIRE_AUTH_FENCE=1`:缺少宿主 `connection`
60
+ 服务时直接 503,而不是退回"仅回环"。Android 的回环由整机共享,"仅回环"在手机上不构成边界。
61
+ - **备份目录的提示在 Linux / Android 上不再给死指令**:原来一律让用户"设置环境变量后重启宿主",
62
+ 而 DSH Go 用 `/usr/bin/env -i` 启动引擎,那条路根本走不通;现在补一句"该变量设不了,请改用
63
+ 「导出」复制 JSON"。
64
+
65
+ ### 其它
66
+
67
+ - 面板 `-webkit-text-size-adjust:100%`(挡住 Android 系统字号把固定 px 卡片排成乱行)、挂载失败
68
+ 提示避开 `env(safe-area-inset-bottom)`。
69
+ - `docs/demo.html` 进入 npm 发布物(手机上可直接打开预演布局)。
70
+
71
+ ### 测试
72
+
73
+ - `contract-test.mjs`:新增 fail-closed 三连断言(开开关 503 / 文案点名开关 / 关掉同一 handler 恢复
74
+ 200),确保"503 是开关造成的"而不是别的原因。
75
+ - `ui-test.mjs`:新增「6 次流错误后必须出现重新载入提示」,并在同一渲染里验证订阅的是 `?lite=1`。
76
+
10
77
  ## [1.5.4] — 2026-11-12
11
78
 
12
79
  2026-11-12 的三方评审(架构 / 安全 / 验收,桌面端 0.2.2-rc.1 + 网页端同进程)落地的一轮修复。
package/README.md CHANGED
@@ -67,7 +67,7 @@ profile 的解析顺序是 **`$DSH_PROFILE_DIR` → `--profile <名字>` → `$D
67
67
  | `host-range-test.mjs` | 宿主版本范围契约测试:固定矩阵 × 两种 semver 判定模式,并把 `peerDependencies`(**被执行的那个**)、`dsh.engines.dsh`、`dsh.compatibility.dsh` 三处声明锁到同一个生成值 |
68
68
  | `lint.mjs` | 静态门禁,已并入 `npm test`:`node --check` 解析所有随包发布与本仓库运行的 JS 文件,并断言 `lib/` 里不出现 `@deepseek-ai/*` 的包名导入(插件只该通过宿主传进来的上下文对象接触宿主,按包名 import 会把插件钉死在某个安装位置,在 headless / CI / 检出目录里直接解析失败) |
69
69
  | `live-host-smoke.mjs` | 双端冒烟(**1.4.8 起宿主有鉴权门,需 `--token <启动 token>` 换会话 cookie**,否则全 401):对**正在运行**的 web / 桌面宿主各测 `state` p50/p95、SSE 首事件、注入注册与字符数,并比对两端快照(除 `injection.pulls` 外须逐字段相同);`npm run smoke:hosts`,`--require-pulls` 可把「从未经历真实 Agent 回合」判为失败。单宿主拓扑(桌面应用自己服务 `11685`)用 `--base http://127.0.0.1:11685`,parity 会**显式跳过**而不是假装比过;`--web` / `--desktop` 各选一端,同时给直接报错、零探测时打印 `NO HOST PROBED` 而不是 `ALL CHECKS PASSED`。**刻意不进 `npm test`**——CI 没有桌面端。请求走 Node `http` 直连,绕开 `http_proxy`(否则回环请求也会被代理转发,读数虚高) |
70
- | `docs/demo.html` | 离线演示页:桩掉 DSH 外壳、加载真实 Client 半渲染设置页(不随 npm 包发布) |
70
+ | `docs/demo.html` | 离线演示页:桩掉 DSH 外壳、加载真实 Client 半渲染设置页(1.5.5 起随 npm 包发布,手机上也能直接打开预演布局) |
71
71
  | `memory.schema.json` | 手改存储文件用的 JSON Schema(随包发布,并被宿主复制到 `memory.json` 旁边) |
72
72
 
73
73
  ## 生效方式
@@ -457,6 +457,9 @@ DSH 就会得到第二份空记忆,看起来像"记忆消失"。相对的 `$DS
457
457
  把这些设置放在别的盘(比如 Windows 上的 D:)就得显式打开:
458
458
  `DSH_INSTRUCTION_MEMORY_ALLOW_EXTERNAL_BACKUP=1`(重启宿主生效)。导出目录受同一条限制;
459
459
  手改存储文件把 `backupDir` 指到外面也同样被拒——这条路不能变成创建任意目录的通道。
460
+ **在 Linux / Android 上这句话会说全**:DSH Go 用 `/usr/bin/env -i` 启动引擎,用户根本没有
461
+ 设置该变量的途径,因此提示会补上"该变量设不了,请改用「导出」把 JSON 复制出来备份",
462
+ 而不是给一条无法执行的指令(1.5.5 起)。
460
463
  这条规则在**写入时**也执行,不只在校验时:设置页、导出、以及每次保存附带的 `.bak` 与历史
461
464
  快照都走同一个判断(1.5.1 起)。检测到越界的外来 `backupDir` 会被忽略并**在面板上披露**,
462
465
  备份与快照写回默认位置,而不是在外目录里悄悄留下文件。
@@ -787,6 +790,10 @@ nginx / Caddy 等反向代理之后远程访问,需要注意:
787
790
  5. **限制来源**:至少用代理层做 IP 白名单或加 Basic Auth——插件本身不带任何鉴权,它只会
788
791
  转用宿主发布的 `connection` 服务(没有该服务时非回环绑定直接 503)。
789
792
 
793
+ > **手机 / 多租户环境请打开 fail-closed**:设 `DSH_INSTRUCTION_MEMORY_REQUIRE_AUTH_FENCE=1`
794
+ > 后,"没有 `connection` 服务"直接变成 **503**,而不再退回"仅回环"。桌面单用户机器上回环还能
795
+ > 当边界用;Android 上不能——整机共享回环命名空间,任何装了 App 的程序都能连 127.0.0.1。
796
+
790
797
  > **桌面端渲染器 origin 的一个已知边界**(2026-11-12 实测):宿主对 `Origin: dsh-app://app`
791
798
  > 的请求返回 **403**,本插件在没有 `connection` 服务时的自判(`requestOriginVerdict`)同样拒绝
792
799
  > 该 origin(它按 Origin ↔ Host 同源比较)。当前桌面窗口发的是 `http://127.0.0.1:<port>`,
@@ -949,9 +956,14 @@ FAIL LIVE GUARD: the installed host 0.1.8-rc.1 is admitted by both modes -> ..
949
956
 
950
957
  DSH Go(Android 客户端)内置的引擎就是**真实 DSH**,锁定 `@deepseek-ai/dsh` **`0.2.1-rc.4`**
951
958
  (`runtime/locks/dsh-0.2.1-rc.4/package.json`),在 PRoot rootfs 内以
952
- `dsh web --patch /etc/dsh-app/dsh-web.patch.yml --host 127.0.0.1 --port N --no-open` 运行;
953
- 环境经 `/usr/bin/env -i` 清空后只注入 `HOME=/root`、`DSH_HOME=/root/.dsh`——**App 侧设置的
954
- `HOME` 对引擎无效**,别按字面读 `DshProcess.kt`。
959
+ `dsh web --patch /etc/dsh-app/dsh-web.patch.yml --host 127.0.0.1 --port N --no-open` 运行。
960
+
961
+ 引擎进程的环境**不是** `/usr/bin/env -i`(早先这里写错了,2026-11-12 读源码更正):壳用
962
+ `ProcessBuilder(argv)`,先 `environment().clear()`,再逐个填 `PATH` / `HOME`(= **App 的
963
+ `filesDir`**,不是 `/root`)/ `TMPDIR` / `ANDROID_ROOT` / `ANDROID_DATA` / `PROOT_TMP_DIR`,
964
+ 最后合并运行时清单的 `launchEnv` 与 guest 环境(`DshProcess.kt:172-188`)。也就是说:宿主变量
965
+ 一律不继承(这一点与 `env -i` 等效),但**壳想加什么变量都能加**——只是它目前没有加
966
+ `DSH_INSTRUCTION_MEMORY_ALLOW_EXTERNAL_BACKUP`,所以备份目录开关在手机上仍然不可达。
955
967
 
956
968
  - **兼容性**:`0.2.1-rc.4` 正好是上表①档已实测的版本。2026-10-31 又用同一个 rc.4 在隔离的
957
969
  `DSH_HOME` 上复跑一遍 **21/21**(`compat-1016-live-check.mjs`;1.5.1 起该脚本第四个参数给启动
@@ -970,6 +982,40 @@ DSH Go(Android 客户端)内置的引擎就是**真实 DSH**,锁定 `@deep
970
982
  - **双端记忆不共享**:手机与"遥控 PC"各自维护自己的 `memory.json`,没有同步——这是当前形态,
971
983
  不是缺陷。
972
984
 
985
+ #### Android / WebView 适配(1.5.5 起)
986
+
987
+ WebView 不是浏览器:它能把 `window.prompt` 保留成函数却永不弹框,能暴露 `<a download>` 却没有
988
+ 下载管线。这两处以前都是**点了没反应、也不报错**,而它们恰好是手机上数据进出口的一半。现在插件侧
989
+ 这样处理:
990
+
991
+ | 场景 | WebView 上的行为 | 依据 |
992
+ | --- | --- | --- |
993
+ | 导出 | 不再点击一个必然失败的链接,直接把完整 JSON 渲染在面板里(全选即可复制) | `client.js` 的 `isAndroidWebView()` + `exportFallback` 分支 |
994
+ | 导入 / 从备份恢复 | **壳已实现文件选择**(`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 源码确认 |
995
+ | 批量加标签 | 不再调用 `window.prompt`:WebView 返回的 `null` 被判为"问不了",直接走内联输入框 | `batchTagOutcome(..., { nullMeansUnavailable: true })` |
996
+ | 实时更新 | 连续 6 次(≈30 秒)订阅失败后显示「实时更新已断开,请重新载入」;收到下一帧自动清除 | `source.onerror` 计数 |
997
+ | 字号 / 手势条 | `text-size-adjust:100%` 挡住系统字号重排;挂载失败提示避开安全区 | CSS |
998
+
999
+ **插件侧做不到、必须平台提供的**(逐条都已核对 DSH Go 源码):
1000
+
1001
+ 1. **保存/分享桥**:`<a download>` 的 `blob:` 字节 WebView 不会交给 `DownloadListener`,插件无法
1002
+ 自救。壳已有 `DshGoIsland` 通道和 `share_plus`,但没有任何"把这段文本存成文件"的消息类型 →
1003
+ 要新增一条(注意 island 消息协议上限只有 **16 KB**,`IslandMessageLimits.maxBytes`,而一次导出
1004
+ 可达 MB 级,必须分块)。
1005
+ 2. **`onJsPrompt`**:webview_flutter 不暴露 JS 对话框,壳也没包 `WebChromeClient`;插件已改走内联
1006
+ 表单,所以**这是可选项**,不做也不影响功能。
1007
+ 3. **环境变量白名单**:壳会 `environment().clear()` 后逐项填(`DshProcess.kt:172-188`),加一个
1008
+ `DSH_INSTRUCTION_MEMORY_ALLOW_EXTERNAL_BACKUP=1` 就能让备份目录走出 rootfs。
1009
+ 4. **WebView 销毁/后台时关闭 SSE**,否则半开连接会占满 32 条上限。
1010
+
1011
+ **纵深开关**:Android 的回环是全机共享的,没有宿主鉴权门时"仅回环"不构成边界。设置
1012
+ `DSH_INSTRUCTION_MEMORY_REQUIRE_AUTH_FENCE=1` 后,缺少 `connection` 服务的 profile **直接
1013
+ 503**(fail-closed),宁可设置页打不开,也不留一个可被同机任何 App 改写的长期指令入口。
1014
+
1015
+ > **证据等级**:以上为代码级适配,`adb devices` 在本机为空,**没有任何真机验证**。
1016
+ > 真机验收顺序见 `dsh-memory-verification/DSH-VERSION-COMPAT.md`;第一条永远先确认平台门在位
1017
+ > (无 cookie 请求必须返回 `401` 且带 `x-dsh-go-guard: reject`),否则后续结论都不成立。
1018
+
973
1019
  ## 发布(维护者)
974
1020
 
975
1021
  **正规流程是推 tag,由 CI 通过 OIDC(Trusted Publishing)发布**,不需要任何长期令牌:
package/docs/demo.html ADDED
@@ -0,0 +1,671 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>dsh-instruction-memory 设置页演示</title>
7
+ <script src="vendor/react.production.min.js"></script>
8
+ <script src="vendor/react-dom.production.min.js"></script>
9
+ <style>
10
+ body { margin:0; background:#f5f6f8; font-family:system-ui,-apple-system,'Segoe UI','PingFang SC','Microsoft YaHei',sans-serif; }
11
+ #root { max-width:920px; margin:28px auto 0; padding:0 16px; }
12
+ </style>
13
+ </head>
14
+ <body>
15
+ <div id="root"></div>
16
+
17
+ <script>
18
+ // Minimal stand-ins for the DSH web shell: module loader + require.
19
+ window.__ModuleLoader__ = { load: (spec) => { window.__dshSpec = spec } }
20
+ </script>
21
+
22
+ <script>
23
+ // In-memory host that speaks the exact route envelope of lib/index.js, so
24
+ // this page exercises the REAL client module (loaded below) against
25
+ // realistic data — no backend needed.
26
+ var DEMO_STORE = {
27
+ version: 1,
28
+ enabled: true,
29
+ // Deliberately tight so the budget meter and the "本轮不注入" marks are
30
+ // visible immediately; raising it in the input clears them live.
31
+ budgetChars: 800,
32
+ entries: [
33
+ {
34
+ id: 'im-demo-lang',
35
+ title: '回答语言',
36
+ content: '所有回答默认使用简体中文;技术名词首次出现时在括号里附英文原文。',
37
+ mode: 'always', when: '', priority: 2, enabled: true, tags: ['沟通'],
38
+ updatedAt: Date.now() - 86400000 * 3,
39
+ },
40
+ {
41
+ id: 'im-demo-confirm',
42
+ title: '先确认再动手',
43
+ content: '需求不明确或存在多种合理实现时,先列出候选方案向用户确认,再开始修改代码。',
44
+ mode: 'always', when: '', priority: 1, enabled: true, tags: ['沟通', '工作流'],
45
+ updatedAt: Date.now() - 86400000 * 2,
46
+ },
47
+ {
48
+ id: 'im-demo-style',
49
+ title: '代码风格',
50
+ content: '修改代码时遵循该文件既有的缩进、命名密度与注释风格,不顺手重构无关代码;\n变量命名优先使用业务领域词汇而不是数据结构词汇,例如用「订单金额」而不是「data1」;\n新增依赖前先检查 package.json 中是否已有能力等价的依赖,避免重复引入;\n公共函数需要补充输入输出说明注释,但不为改动本身添加解释性注释;\n改动完成后运行项目现有的测试与 lint 检查,若有失败先修复再交付,不留下只改了一半的状态;\n涉及对外接口或数据结构变更时,先在回复中列出受影响的调用方,确认迁移路径后再提交。\n日志打印需带关键上下文(对象标识、入参摘要),不要只输出 "error" 这类无信息内容;\n异步任务必须设置超时与重试上限,不允许无限等待外部资源;\n对外错误信息要区分用户可操作的提示与内部诊断细节,不把堆栈直接暴露给调用方;\n代码评审先核对行为变化与测试覆盖,再讨论风格偏好,意见必须可执行、可验证;\n以上要求同样适用于纯重构类提交,重构前后行为必须完全一致。',
51
+ mode: 'auto', when: '编写或修改代码时', priority: 1, enabled: true, tags: ['代码规范'],
52
+ updatedAt: Date.now() - 86400000,
53
+ },
54
+ {
55
+ id: 'im-demo-reply',
56
+ title: '回复结构',
57
+ content: '较长的回答先给一句结论,再展开细节;方案对比先列差异再给推荐。',
58
+ mode: 'auto', when: '解释性问题与方案对比', priority: 0, enabled: false, tags: ['沟通'],
59
+ updatedAt: Date.now() - 3600000,
60
+ },
61
+ ],
62
+ profiles: [
63
+ {
64
+ id: 'pf-demo-bg',
65
+ name: '职业背景',
66
+ content: '有 8 年后端开发经验,熟悉 Java 与分布式系统;技术问题可以跳过入门概念,直接讲权衡与实现细节。',
67
+ enabled: true,
68
+ updatedAt: Date.now() - 86400000,
69
+ },
70
+ {
71
+ id: 'pf-demo-style',
72
+ name: '沟通偏好',
73
+ content: '喜欢先给结论、再给可执行步骤,少寒暄少铺陈。',
74
+ enabled: true,
75
+ updatedAt: Date.now() - 7200000,
76
+ },
77
+ {
78
+ id: 'pf-demo-goal',
79
+ name: '长期追求(停用示例)',
80
+ content: '希望代码评审意见都具体可执行,不要泛泛而谈。',
81
+ enabled: false,
82
+ updatedAt: Date.now() - 3600000,
83
+ },
84
+ ],
85
+ }
86
+
87
+ // F13 配置集:演示数据里放两套,好让「切换」真的改变注入内容。所有既有条目
88
+ // 归入「默认」,另加一条只属于「工作(示例)」的条目。
89
+ ;(function seedSets() {
90
+ DEMO_STORE.version = 2
91
+ DEMO_STORE.$schema = './memory.schema.json'
92
+ DEMO_STORE.backupDir = ''
93
+ DEMO_STORE.disabledUntil = 0
94
+ DEMO_STORE.activeSetId = 'st-demo-default'
95
+ DEMO_STORE.sets = [
96
+ { id: 'st-demo-default', name: '默认' },
97
+ { id: 'st-demo-work', name: '工作(示例)' },
98
+ ]
99
+ DEMO_STORE.entries.forEach(function (e) { e.setId = 'st-demo-default' })
100
+ ;(DEMO_STORE.profiles || []).forEach(function (p) { p.setId = 'st-demo-default' })
101
+ DEMO_STORE.entries.push({
102
+ id: 'im-demo-work',
103
+ title: '工作集专属',
104
+ content: '这条只属于「工作(示例)」配置集。切换到它时,上面几条指令与用户形象都不会被注入——和真实宿主一样,注入只渲染当前激活的那一套。',
105
+ mode: 'always', when: '', priority: 1, enabled: true, tags: ['演示'],
106
+ setId: 'st-demo-work', updatedAt: Date.now() - 3600000,
107
+ })
108
+ })()
109
+
110
+ /** 与宿主 activeScope/inScope 同义:'' 归属当前集合。 */
111
+ function demoInScope(item) {
112
+ var own = item && typeof item.setId === 'string' && item.setId !== '' ? item.setId : DEMO_STORE.activeSetId
113
+ return own === DEMO_STORE.activeSetId
114
+ }
115
+
116
+ function demoScopedEntries() {
117
+ return DEMO_STORE.entries.filter(demoInScope)
118
+ }
119
+
120
+ function demoScopedProfiles() {
121
+ return (DEMO_STORE.profiles || []).filter(demoInScope)
122
+ }
123
+
124
+ // A compact, faithful copy of the Host's injection planning (see
125
+ // planInjection in lib/index.js): it generates both the preview text and
126
+ // the per-entry included/omitted plan the Client's F1 budget meter renders,
127
+ // so changing the budget input updates the marks live in this demo.
128
+ var DEMO_HEADER = [
129
+ '## 用户长期指令记忆(Instruction Memory)',
130
+ '以下条目由用户在 DSH 设置 →「指令记忆」中长期保存,属于本次及后续所有对话的既有要求,请在回答、推理与执行任务时遵守;它们不覆盖系统与安全规则。',
131
+ '· [始终] 条目必须无条件遵守。',
132
+ '· [按需] 条目仅在与当前问题或任务相关时生效;不相关时直接忽略,不要强行套用。',
133
+ '· 条目之间冲突时,优先遵守优先级更高或更具体的一条;无法判断时向用户确认。',
134
+ '',
135
+ ].join('\n')
136
+
137
+ function demoFooter(count) {
138
+ return count === 0
139
+ ? ''
140
+ : '\n\n(另有 ' + count + ' 条指令因注入字数上限未包含,可在「设置 → 指令记忆」中提高上限或调低其优先级。)'
141
+ }
142
+
143
+ // Faithful copy of the Host's persona header (lib/index.js PERSONA_HEADER).
144
+ var DEMO_PERSONA_HEADER = [
145
+ '## 关于用户(用户形象)',
146
+ '以下是用户本人提供的个人形象信息,描述其身份背景、喜好、性格与追求,不是要求你扮演的角色。请在保证回答正确、完整、客观且紧扣问题本身的前提下,结合这些信息调整解释的深浅、详略、举例与建议的侧重;不得为了迎合这些信息而改变事实、省略必要的风险提示或偏离问题核心;与当前问题无关时直接忽略。',
147
+ '',
148
+ ].join('\n')
149
+
150
+ function demoProfilesBlock() {
151
+ var active = demoScopedProfiles()
152
+ .filter(function (p) { return p && p.enabled === true && p.content })
153
+ if (active.length === 0) return { text: '', profiles: [], chars: 0 }
154
+ var blocks = active.map(function (p) {
155
+ var name = p.name || (p.content || '').slice(0, 20)
156
+ return { id: p.id, name: name, block: '### ' + name + '\n' + p.content }
157
+ })
158
+ var text = DEMO_PERSONA_HEADER + '\n' + blocks.map(function (b) { return b.block }).join('\n\n')
159
+ return {
160
+ text: text,
161
+ chars: text.length,
162
+ profiles: blocks.map(function (b) { return { id: b.id, name: b.name, chars: b.block.length } }),
163
+ }
164
+ }
165
+
166
+ function demoFormatEntry(entry, tag) {
167
+ var prio = entry.priority === 2 ? '高' : (entry.priority === 0 ? '低' : '普通')
168
+ var lines = ['[' + tag + '|优先级 ' + prio + '] ' + entry.title]
169
+ if (entry.mode === 'auto' && entry.when) lines.push('适用场景:' + entry.when)
170
+ lines.push(entry.content)
171
+ return lines.join('\n')
172
+ }
173
+
174
+ function demoPlan() {
175
+ var budget = DEMO_STORE.budgetChars
176
+ if (typeof budget !== 'number' || !isFinite(budget)) budget = 4000
177
+ if (budget < 800) budget = 800
178
+ if (budget > 40000) budget = 40000
179
+ var blank = function () {
180
+ return { text: '', budget: budget, activeCount: 0, included: [], omitted: [], profiles: [], profileChars: 0, entryChars: 0 }
181
+ }
182
+ if (DEMO_STORE.enabled !== true) return blank()
183
+ // F7 临时静音:与真实宿主同义——暂停中的计划与 enabled:false 逐字节相同。
184
+ if (typeof DEMO_STORE.disabledUntil === 'number' && DEMO_STORE.disabledUntil > Date.now()) return blank()
185
+ var persona = demoProfilesBlock()
186
+ var active = demoScopedEntries().filter(function (e) { return e.enabled && e.content !== '' })
187
+
188
+ var blocks = []
189
+ var included = []
190
+ var omittedPlan = []
191
+ var used = DEMO_HEADER.length
192
+ var omitted = 0
193
+
194
+ function consider(entry, tag) {
195
+ var block = demoFormatEntry(entry, tag)
196
+ var reserve = demoFooter(omitted + 1).length
197
+ if (used + block.length + 2 + reserve <= budget) {
198
+ used += block.length + 2
199
+ blocks.push(block)
200
+ included.push({ id: entry.id, chars: block.length, truncated: false })
201
+ return
202
+ }
203
+ if (blocks.length === 0) {
204
+ var room = budget - used - 2 - reserve
205
+ var note = '\n…(本条因注入字数上限被截断)'
206
+ if (room > note.length + 20) {
207
+ var head = block.slice(0, room - note.length)
208
+ var body = head + note
209
+ blocks.push(body)
210
+ used += body.length + 2
211
+ included.push({ id: entry.id, chars: body.length, truncated: true })
212
+ return
213
+ }
214
+ }
215
+ omitted += 1
216
+ omittedPlan.push({ id: entry.id, chars: block.length })
217
+ }
218
+
219
+ active.forEach(function (entry) { if (entry.mode === 'always') consider(entry, '始终') })
220
+ active.forEach(function (entry) { if (entry.mode !== 'always') consider(entry, '按需') })
221
+
222
+ var entryText = blocks.length === 0
223
+ ? ''
224
+ : DEMO_HEADER + blocks.join('\n\n') + demoFooter(omitted)
225
+ var parts = []
226
+ if (persona.text !== '') parts.push(persona.text)
227
+ if (entryText !== '') parts.push(entryText)
228
+ return {
229
+ text: parts.join('\n\n'),
230
+ budget: budget,
231
+ activeCount: active.length,
232
+ included: included,
233
+ omitted: omittedPlan,
234
+ profiles: persona.profiles,
235
+ profileChars: persona.chars,
236
+ entryChars: entryText.length,
237
+ }
238
+ }
239
+
240
+ /** Where a fresh set id comes from (mirrors the Host's st- prefix). */
241
+ function demoSetId() {
242
+ return 'st-demo-' + Math.random().toString(36).slice(2, 8)
243
+ }
244
+
245
+ function demoSnapshot() {
246
+ var plan = demoPlan()
247
+ // The panel edits and lists ONLY the active set (exactly like the real
248
+ // Host's snapshot), so a demo switch really empties the list.
249
+ // sets.total is the WHOLE-STORE count (same rule as the real host), so it
250
+ // is captured from DEMO_STORE BEFORE the scoped arrays replace these
251
+ // fields below — `data` aliases `full`, and counting after the reassign
252
+ // used to report the current-set numbers as the store-wide totals.
253
+ var totalEntries = (DEMO_STORE.entries || []).length
254
+ var totalProfiles = (DEMO_STORE.profiles || []).length
255
+ var full = JSON.parse(JSON.stringify(DEMO_STORE))
256
+ var data = full
257
+ data.entries = demoScopedEntries().map(function (e) { return JSON.parse(JSON.stringify(e)) })
258
+ data.profiles = demoScopedProfiles().map(function (p) { return JSON.parse(JSON.stringify(p)) })
259
+ var belongsTo = function (item, id) {
260
+ var own = item && typeof item.setId === 'string' && item.setId !== '' ? item.setId : DEMO_STORE.activeSetId
261
+ return own === id
262
+ }
263
+ var setList = (DEMO_STORE.sets || []).map(function (s) {
264
+ return {
265
+ id: s.id,
266
+ name: s.name,
267
+ active: s.id === DEMO_STORE.activeSetId,
268
+ entries: (DEMO_STORE.entries || []).filter(function (e) { return belongsTo(e, s.id) }).length,
269
+ profiles: (DEMO_STORE.profiles || []).filter(function (p) { return belongsTo(p, s.id) }).length,
270
+ }
271
+ })
272
+ return {
273
+ data: data,
274
+ sets: {
275
+ activeId: DEMO_STORE.activeSetId,
276
+ scoped: true,
277
+ total: { entries: totalEntries, profiles: totalProfiles },
278
+ list: setList,
279
+ },
280
+ storage: {
281
+ // The store file NEVER moves with the backup location — only the
282
+ // backup copies do (same rule as the real host).
283
+ path: 'C:\\Users\\demo\\.dsh\\instruction-memory\\memory.json',
284
+ error: null,
285
+ warning: null,
286
+ savedAt: Date.now() - 5 * 60000,
287
+ migratedFrom: null,
288
+ onDisk: true,
289
+ backupDir: DEMO_STORE.backupDir || '',
290
+ isDefaultBackupDir: !DEMO_STORE.backupDir,
291
+ backupPath: (DEMO_STORE.backupDir || 'C:\\Users\\demo\\.dsh\\instruction-memory') + '\\memory.json.bak',
292
+ historyDir: (DEMO_STORE.backupDir || 'C:\\Users\\demo\\.dsh\\instruction-memory') + '\\history',
293
+ historyCount: 0,
294
+ historyError: null,
295
+ schemaFile: 'C:\\Users\\demo\\.dsh\\instruction-memory\\memory.schema.json',
296
+ },
297
+ injection: {
298
+ registered: plan.text !== '',
299
+ available: true,
300
+ error: null,
301
+ muted: DEMO_STORE.enabled === true && typeof DEMO_STORE.disabledUntil === 'number' && DEMO_STORE.disabledUntil > Date.now(),
302
+ chars: plan.text.length,
303
+ budget: plan.budget,
304
+ activeCount: plan.activeCount,
305
+ included: plan.included,
306
+ omitted: plan.omitted,
307
+ profiles: plan.profiles,
308
+ profileChars: plan.profileChars,
309
+ entryChars: plan.entryChars,
310
+ pulls: 2,
311
+ },
312
+ // F12:演示桩里路由绑定在回环地址,安全面板不告警。
313
+ security: { hostGuarded: true, boundHost: '127.0.0.1' },
314
+ diagnostics: { loadedFrom: 'demo(内存桩宿主)', rev: data.rev || 0 },
315
+ preview: plan.text,
316
+ limits: {
317
+ minBudget: 800, maxBudget: 40000, maxEntries: 200,
318
+ maxProfiles: 50, maxProfileName: 40, maxProfileContent: 4000,
319
+ maxSets: 20, maxSetName: 30, maxHistory: 20, maxBackupDir: 512,
320
+ maxBodyChars: 3218000,
321
+ },
322
+ }
323
+ }
324
+
325
+ /** A no-op answer: ok, nothing written (mirrors the real envelope). */
326
+ function demoNoop() {
327
+ return {
328
+ ok: true,
329
+ json: async function () {
330
+ return { ok: true, saved: false, message: null, snapshot: demoSnapshot() }
331
+ },
332
+ }
333
+ }
334
+
335
+ /** Uniform route envelope for the demo host (the real one answers the same shape). */
336
+ function demoReply(ok, message) {
337
+ return {
338
+ ok: true,
339
+ json: async function () {
340
+ return {
341
+ ok: ok === true,
342
+ saved: ok === true,
343
+ message: message === undefined ? null : message,
344
+ snapshot: demoSnapshot(),
345
+ }
346
+ },
347
+ }
348
+ }
349
+
350
+ /**
351
+ * Adopt a whole store object (源码模式 / 整体替换). Mirrors the parts of the
352
+ * Host's loader a demo needs to stay believable: sets are kept, every item is
353
+ * stamped onto an existing set, missing ids are minted.
354
+ */
355
+ function demoAdoptStore(parsed) {
356
+ DEMO_STORE.version = 2
357
+ DEMO_STORE.enabled = parsed.enabled !== false
358
+ DEMO_STORE.disabledUntil = typeof parsed.disabledUntil === 'number' && isFinite(parsed.disabledUntil) && parsed.disabledUntil > 0
359
+ ? parsed.disabledUntil
360
+ : 0
361
+ DEMO_STORE.budgetChars = typeof parsed.budgetChars === 'number' ? parsed.budgetChars : 4000
362
+ DEMO_STORE.sets = Array.isArray(parsed.sets) && parsed.sets.length > 0
363
+ ? parsed.sets.map(function (s) { return { id: String(s.id), name: String(s.name).slice(0, 30) } })
364
+ : [{ id: 'st-demo-default', name: '默认' }]
365
+ DEMO_STORE.activeSetId = DEMO_STORE.sets.some(function (s) { return s.id === parsed.activeSetId })
366
+ ? parsed.activeSetId
367
+ : DEMO_STORE.sets[0].id
368
+ DEMO_STORE.entries = (Array.isArray(parsed.entries) ? parsed.entries : []).map(function (e) {
369
+ var entry = JSON.parse(JSON.stringify(e))
370
+ entry.id = entry.id || ('im-demo-' + Math.random().toString(36).slice(2, 8))
371
+ entry.setId = DEMO_STORE.sets.some(function (s) { return s.id === entry.setId })
372
+ ? entry.setId
373
+ : DEMO_STORE.activeSetId
374
+ entry.tags = Array.isArray(entry.tags) ? entry.tags : []
375
+ return entry
376
+ })
377
+ DEMO_STORE.profiles = (Array.isArray(parsed.profiles) ? parsed.profiles : []).map(function (p) {
378
+ var profile = JSON.parse(JSON.stringify(p))
379
+ profile.id = profile.id || ('pf-demo-' + Math.random().toString(36).slice(2, 8))
380
+ profile.setId = DEMO_STORE.sets.some(function (s) { return s.id === profile.setId })
381
+ ? profile.setId
382
+ : DEMO_STORE.activeSetId
383
+ return profile
384
+ })
385
+ }
386
+
387
+ window.fetch = async function (url, options) {
388
+ var payload = JSON.parse(options.body)
389
+ switch (payload.method) {
390
+ case 'save-entry': {
391
+ var entry = Object.assign({}, payload.args.entry)
392
+ if (!entry.id) entry.id = 'im-demo-' + Math.random().toString(36).slice(2, 8)
393
+ entry.updatedAt = Date.now()
394
+ // The interactive path always files into the ACTIVE set (same rule as
395
+ // the real Host — an edit can never move an item between sets), and an
396
+ // id owned by another set is re-minted rather than replaced.
397
+ entry.setId = DEMO_STORE.activeSetId
398
+ var foreignId = DEMO_STORE.entries.some(function (e) {
399
+ return e.id === entry.id && e.setId !== DEMO_STORE.activeSetId
400
+ })
401
+ if (foreignId) entry.id = 'im-demo-' + Math.random().toString(36).slice(2, 8)
402
+ var list = DEMO_STORE.entries
403
+ var index = -1
404
+ for (var i = 0; i < list.length; i += 1) if (list[i].id === entry.id) { index = i; break }
405
+ if (index >= 0) list[index] = entry
406
+ else list.push(entry)
407
+ break
408
+ }
409
+ case 'delete-entry': {
410
+ var doomed = payload.args.id
411
+ var owned = DEMO_STORE.entries.some(function (e) {
412
+ return e.id === doomed && demoInScope(e)
413
+ })
414
+ if (!owned) return demoNoop()
415
+ DEMO_STORE.entries = DEMO_STORE.entries.filter(function (e) { return e.id !== doomed })
416
+ break
417
+ }
418
+ case 'set-options': {
419
+ if (typeof payload.args.enabled === 'boolean') {
420
+ DEMO_STORE.enabled = payload.args.enabled
421
+ DEMO_STORE.disabledUntil = 0
422
+ }
423
+ if (typeof payload.args.disabledUntil === 'number' && isFinite(payload.args.disabledUntil)
424
+ && payload.args.disabledUntil >= 0 && typeof payload.args.enabled !== 'boolean') {
425
+ var wantedMute = Math.round(payload.args.disabledUntil)
426
+ var muteClamped = false
427
+ if (wantedMute <= Date.now()) wantedMute = 0
428
+ else if (wantedMute > Date.now() + 7 * 86400000) { wantedMute = Date.now() + 7 * 86400000; muteClamped = true }
429
+ DEMO_STORE.disabledUntil = wantedMute
430
+ if (wantedMute > 0) {
431
+ // 演示桩也到期自动恢复:真实宿主由插件内的定时器负责,这里用一个页内
432
+ // 定时器模拟同一行为(下一个快照即恢复正常态)。
433
+ setTimeout(function () { DEMO_STORE.disabledUntil = 0 }, wantedMute - Date.now() + 50)
434
+ }
435
+ return demoReply(true, wantedMute === 0
436
+ ? '已恢复注入。'
437
+ : '已暂停注入至 ' + new Date(wantedMute).toLocaleString() + '(到期自动恢复;暂停期间不注入任何指令或用户形象)'
438
+ + (muteClamped ? ';暂停时长上限为 7 天,已按上限记录' : ''))
439
+ }
440
+ if (typeof payload.args.budgetChars === 'number') DEMO_STORE.budgetChars = payload.args.budgetChars
441
+ if (typeof payload.args.backupDir === 'string') {
442
+ DEMO_STORE.backupDir = payload.args.backupDir.trim()
443
+ return demoReply(true, DEMO_STORE.backupDir === ''
444
+ ? '已恢复默认备份位置(版本历史与导出与存储文件同目录)'
445
+ : '备份位置已更新:' + DEMO_STORE.backupDir + '(版本历史与「导出到备份目录」随之改变;存储文件本身不移动)')
446
+ }
447
+ break
448
+ }
449
+ case 'batch': {
450
+ // Demo stand-in for the Host's single-transaction batch route.
451
+ var b = payload.args || {}
452
+ var batchIds = Array.isArray(b.ids) ? b.ids : []
453
+ if (b.op === 'delete') {
454
+ DEMO_STORE.entries = DEMO_STORE.entries.filter(function (e) {
455
+ return batchIds.indexOf(e.id) < 0
456
+ })
457
+ } else {
458
+ DEMO_STORE.entries.forEach(function (e) {
459
+ if (batchIds.indexOf(e.id) < 0) return
460
+ if (b.op === 'set-enabled') {
461
+ e.enabled = b.value
462
+ } else if (b.op === 'set-priority') {
463
+ e.priority = b.value
464
+ } else if (b.op === 'add-tags') {
465
+ var cur = Array.isArray(e.tags) ? e.tags.slice() : []
466
+ ;(b.value || []).forEach(function (tg) { if (cur.indexOf(tg) < 0) cur.push(tg) })
467
+ e.tags = cur.slice(0, 8)
468
+ } else if (b.op === 'remove-tags') {
469
+ e.tags = (e.tags || []).filter(function (tg) { return (b.value || []).indexOf(tg) < 0 })
470
+ }
471
+ })
472
+ }
473
+ break
474
+ }
475
+ case 'save-profile': {
476
+ // Demo stand-in for the Host's save-profile route. Mirror
477
+ // sanitizeProfile: a blank name falls back to the first 20 single-line
478
+ // characters of content, so the card never shows an empty name (the
479
+ // real Host stores this fallback; it is not a client-only label).
480
+ var p = Object.assign({}, payload.args.profile)
481
+ if (!p.id) p.id = 'pf-demo-' + Math.random().toString(36).slice(2, 8)
482
+ if (typeof p.enabled !== 'boolean') p.enabled = true
483
+ if (typeof p.content !== 'string') p.content = ''
484
+ var oneLine = function (s, max) {
485
+ return String(s).split(/\r?\n/).map(function (x) { return x.trim() })
486
+ .filter(Boolean).join(' ').slice(0, max)
487
+ }
488
+ var trimmedName = oneLine(p.name || '', 40)
489
+ p.name = trimmedName !== '' ? trimmedName : oneLine(p.content, 20)
490
+ p.updatedAt = Date.now()
491
+ if (!Array.isArray(DEMO_STORE.profiles)) DEMO_STORE.profiles = []
492
+ var pIndex = -1
493
+ for (var pi = 0; pi < DEMO_STORE.profiles.length; pi += 1) {
494
+ if (DEMO_STORE.profiles[pi].id === p.id) { pIndex = pi; break }
495
+ }
496
+ if (pIndex >= 0) DEMO_STORE.profiles[pIndex] = p
497
+ else DEMO_STORE.profiles.push(p)
498
+ break
499
+ }
500
+ case 'delete-profile':
501
+ DEMO_STORE.profiles = (DEMO_STORE.profiles || [])
502
+ .filter(function (p) { return p.id !== payload.args.id })
503
+ break
504
+ // ---- F13 配置集 ----
505
+ case 'save-set': {
506
+ var wanted = Object.assign({}, payload.args.set)
507
+ var wantedName = typeof wanted.name === 'string'
508
+ ? wanted.name.split(/\r?\n/).join(' ').trim().slice(0, 30)
509
+ : ''
510
+ if (wantedName === '') return demoReply(false, '配置集名称不能为空(单行,最多 30 字符)')
511
+ var sameName = function (s) { return s.name === wantedName && s.id !== wanted.id }
512
+ if (DEMO_STORE.sets.some(sameName)) return demoReply(false, '已存在同名配置集「' + wantedName + '」')
513
+ if (!wanted.id) {
514
+ if (DEMO_STORE.sets.length >= 20) return demoReply(false, '最多创建 20 个配置集')
515
+ DEMO_STORE.sets.push({ id: demoSetId(), name: wantedName })
516
+ return demoReply(true, '已创建配置集「' + wantedName + '」;它是空的,点击「切换」后才会注入它')
517
+ }
518
+ var renameAt = -1
519
+ for (var si = 0; si < DEMO_STORE.sets.length; si += 1) {
520
+ if (DEMO_STORE.sets[si].id === wanted.id) { renameAt = si; break }
521
+ }
522
+ if (renameAt < 0) return demoReply(false, '没有该配置集(可能已被其他窗口删除)')
523
+ DEMO_STORE.sets[renameAt].name = wantedName
524
+ return demoReply(true, '已重命名为「' + wantedName + '」')
525
+ }
526
+ case 'delete-set': {
527
+ var deleteId = payload.args.id
528
+ var targetSet = null
529
+ DEMO_STORE.sets.forEach(function (s) { if (s.id === deleteId) targetSet = s })
530
+ if (targetSet === null) return demoReply(false, '没有该配置集')
531
+ if (DEMO_STORE.sets.length <= 1) return demoReply(false, '至少要保留一个配置集')
532
+ if (deleteId === DEMO_STORE.activeSetId) return demoReply(false, '不能删除当前激活的配置集;请先切换到其他配置集')
533
+ DEMO_STORE.sets = DEMO_STORE.sets.filter(function (s) { return s.id !== deleteId })
534
+ DEMO_STORE.entries = DEMO_STORE.entries.filter(function (e) { return e.setId !== deleteId })
535
+ DEMO_STORE.profiles = (DEMO_STORE.profiles || []).filter(function (p) { return p.setId !== deleteId })
536
+ return demoReply(true, '已删除配置集「' + targetSet.name + '」及其中的条目与形象')
537
+ }
538
+ case 'set-active-set': {
539
+ var activeId = payload.args.id
540
+ var found = DEMO_STORE.sets.some(function (s) { return s.id === activeId })
541
+ if (!found) return demoReply(false, '没有该配置集(可能已被其他窗口删除)')
542
+ if (activeId === DEMO_STORE.activeSetId) return demoNoop()
543
+ DEMO_STORE.activeSetId = activeId
544
+ var activeName = ''
545
+ DEMO_STORE.sets.forEach(function (s) { if (s.id === activeId) activeName = s.name })
546
+ return demoReply(true, '已切换到配置集「' + activeName + '」,注入内容已随之更新')
547
+ }
548
+ case 'duplicate-set': {
549
+ var copyFrom = null
550
+ DEMO_STORE.sets.forEach(function (s) { if (s.id === payload.args.id) copyFrom = s })
551
+ if (copyFrom === null) return demoReply(false, '没有该配置集')
552
+ if (DEMO_STORE.sets.length >= 20) return demoReply(false, '最多 20 个配置集')
553
+ var copyName = (payload.args.name || copyFrom.name + ' 副本').trim().slice(0, 30)
554
+ if (DEMO_STORE.sets.some(function (s) { return s.name === copyName })) return demoReply(false, '已存在同名配置集「' + copyName + '」')
555
+ var copyId = demoSetId()
556
+ var stamp = Date.now()
557
+ var copiedEntries = DEMO_STORE.entries.filter(function (e) { return e.setId === copyFrom.id })
558
+ .map(function (e) {
559
+ var copy = JSON.parse(JSON.stringify(e))
560
+ copy.id = 'im-demo-' + Math.random().toString(36).slice(2, 8)
561
+ copy.setId = copyId
562
+ copy.updatedAt = stamp
563
+ return copy
564
+ })
565
+ DEMO_STORE.sets.push({ id: copyId, name: copyName })
566
+ Array.prototype.push.apply(DEMO_STORE.entries, copiedEntries)
567
+ return demoReply(true, '已复制为「' + copyName + '」(' + copiedEntries.length + ' 条指令);点击「切换」后才会注入它')
568
+ }
569
+ // ---- F14 版本历史(演示页面不落盘,因此没有快照可列) ----
570
+ case 'history-list':
571
+ return {
572
+ ok: true,
573
+ json: async function () {
574
+ return {
575
+ ok: true, saved: false, message: null, snapshot: demoSnapshot(),
576
+ history: { dir: '(演示页面不写历史快照;真实宿主会在存储目录的 history/ 子目录里保留最近 20 份)', items: [] },
577
+ }
578
+ },
579
+ }
580
+ case 'history-restore':
581
+ case 'history-delete':
582
+ return demoReply(false, '演示页面没有历史快照')
583
+ // ---- F16 源码模式 ----
584
+ case 'read-source':
585
+ return {
586
+ ok: true,
587
+ json: async function () {
588
+ return { ok: true, saved: false, message: null, source: JSON.stringify(DEMO_STORE, null, 2) + '\n', snapshot: demoSnapshot() }
589
+ },
590
+ }
591
+ case 'write-source': {
592
+ var text = payload.args.text
593
+ if (typeof text !== 'string' || text.trim() === '') return demoReply(false, '源码不能为空')
594
+ var parsedSource = null
595
+ try {
596
+ parsedSource = JSON.parse(text)
597
+ } catch (err) {
598
+ return demoReply(false, '源码不是有效的 JSON:' + (err && err.message ? err.message : err))
599
+ }
600
+ if (parsedSource === null || typeof parsedSource !== 'object' || Array.isArray(parsedSource)) {
601
+ return demoReply(false, '顶层必须是一个 JSON 对象')
602
+ }
603
+ if (typeof parsedSource.version === 'number' && parsedSource.version !== 2) {
604
+ return demoReply(false, '内容声明为格式版本 ' + parsedSource.version + ',本插件支持的格式版本为 1 / 2')
605
+ }
606
+ demoAdoptStore(parsedSource)
607
+ return demoReply(true, '已按源码保存(演示页面只改内存,不写文件):'
608
+ + DEMO_STORE.entries.length + ' 条指令 / ' + (DEMO_STORE.profiles || []).length + ' 个用户形象 / '
609
+ + DEMO_STORE.sets.length + ' 个配置集')
610
+ }
611
+ // ---- F14 整体替换恢复 ----
612
+ case 'restore-data': {
613
+ var restorePayload = payload.args.payload
614
+ if (restorePayload === null || typeof restorePayload !== 'object') return demoReply(false, '恢复内容不是有效的 JSON 对象')
615
+ var restoreInner = restorePayload.data !== null && typeof restorePayload.data === 'object' ? restorePayload.data : restorePayload
616
+ var looksLikeStore = ['version', 'enabled', 'budgetChars', 'activeSetId', 'sets', 'entries', 'profiles']
617
+ .some(function (key) { return key in restoreInner })
618
+ if (!looksLikeStore) return demoReply(false, '内容不像本插件的存储文件')
619
+ demoAdoptStore(restoreInner)
620
+ return demoReply(true, '已用备份整体替换当前记忆(演示页面只改内存,不写文件)')
621
+ }
622
+ // ---- F18 备份位置 ----
623
+ case 'export-to-dir':
624
+ return demoReply(true, '演示页面不会真的写文件;真实宿主会把导出写入「'
625
+ + (DEMO_STORE.backupDir || '存储目录') + '」,文件名形如 dsh-instruction-memory-20261015-152355Z.json')
626
+ case 'export-data':
627
+ return {
628
+ ok: true,
629
+ json: async function () {
630
+ return {
631
+ ok: true, saved: false, message: null,
632
+ // 导出的是整份存储(所有配置集),不是当前激活的那一套。
633
+ export: { app: 'dsh-instruction-memory', version: 2, exportedAt: new Date().toISOString(), data: JSON.parse(JSON.stringify(DEMO_STORE)) },
634
+ snapshot: demoSnapshot(),
635
+ }
636
+ },
637
+ }
638
+ default:
639
+ break
640
+ }
641
+ return {
642
+ ok: true,
643
+ json: async function () {
644
+ return { ok: true, saved: true, message: null, snapshot: demoSnapshot() }
645
+ },
646
+ }
647
+ }
648
+ </script>
649
+
650
+ <!-- The real client module, unmodified. -->
651
+ <script src="../lib/client.js"></script>
652
+
653
+ <script>
654
+ var plugin = window.__dshSpec.factory(function (id) {
655
+ if (id === 'react') return window.React
656
+ throw new Error('unexpected require: ' + id)
657
+ })
658
+
659
+ var registered = []
660
+ plugin.apply({
661
+ slots: {
662
+ inject: function (key, callback) { callback(); return function () {} },
663
+ register: function (options, render) { registered.push(render); return function () {} },
664
+ },
665
+ effect: function (fn) { fn(); return function () {} },
666
+ })
667
+
668
+ ReactDOM.createRoot(document.getElementById('root')).render(registered[0]())
669
+ </script>
670
+ </body>
671
+ </html>
package/lib/client.js CHANGED
@@ -69,7 +69,10 @@ window.__ModuleLoader__.load({
69
69
 
70
70
  const CSS = `
71
71
  .im-root { display:flex; flex-direction:column; gap:16px; padding:6px 2px 56px; max-width:860px;
72
- color:var(--dsw-alias-label-primary,#1f2329); font-size:13px; line-height:1.6; }
72
+ color:var(--dsw-alias-label-primary,#1f2329); font-size:13px; line-height:1.6;
73
+ /* Android WebView honours the system text zoom unless this is pinned: at
74
+ 150% the fixed-px cards reflow into unreadable ribbons. */
75
+ -webkit-text-size-adjust:100%; text-size-adjust:100%; }
73
76
  .im-h { margin:0; font-size:14px; font-weight:600; }
74
77
  .im-sub { margin:3px 0 0; font-size:12px; color:var(--dsw-alias-label-secondary,#8a9099); }
75
78
  .im-card { display:flex; flex-direction:column; gap:11px; padding:15px; border-radius:12px;
@@ -362,6 +365,12 @@ window.__ModuleLoader__.load({
362
365
  'profiles.truncatedTitle': '这张形象超过注入字数上限,只有开头进入本轮提示',
363
366
  'profiles.omitted': '本轮未包含',
364
367
  'profiles.omittedTitle': '这张形象因注入字数上限未进入本轮提示',
368
+ 'export.saved': '已开始下载;若没有文件出现,请检查浏览器的下载列表',
369
+ 'export.savedToApp': '已交给 DSH Go 保存:在系统分享面板里选「保存到文件」即可',
370
+ 'export.fallbackHint': '当前环境无法触发文件下载,完整 JSON 已放在下面:全选复制后自行保存',
371
+ 'export.fallbackClose': '关闭',
372
+ 'import.webviewHint': 'Android / WebView 上若文件选择器不弹出,请改用「源码模式」粘贴 JSON',
373
+ 'sse.down': '实时更新已断开(连续重试仍失败),数据可能是旧的:请点「重新载入」',
365
374
  'profiles.saving': '保存中…',
366
375
  'action.new': '+ 新增指令',
367
376
  'action.reload': '重新载入',
@@ -654,6 +663,12 @@ window.__ModuleLoader__.load({
654
663
  'profiles.truncatedTitle': 'This persona exceeds the injection limit; only its head reached this turn',
655
664
  'profiles.omitted': 'Not in this turn',
656
665
  'profiles.omittedTitle': 'This persona was dropped by the injection limit and did not reach this turn',
666
+ 'export.saved': 'Download started — if no file appears, check your browser’s download list',
667
+ 'export.savedToApp': 'Handed to DSH Go — pick “Save to Files” in the system share sheet',
668
+ 'export.fallbackHint': 'This environment cannot start a file download; the full JSON is below — select all and copy it',
669
+ 'export.fallbackClose': 'Close',
670
+ 'import.webviewHint': 'On Android / WebView, if the file picker does not open, paste the JSON into “Source mode” instead',
671
+ 'sse.down': 'Live updates stopped (retries kept failing) and the data may be stale — click “Reload”',
657
672
  'profiles.saving': 'Saving…',
658
673
  'action.new': '+ New instruction',
659
674
  'action.reload': 'Reload',
@@ -1037,7 +1052,7 @@ window.__ModuleLoader__.load({
1037
1052
  * @param {string} message
1038
1053
  * @returns {{ kind: 'unavailable' } | { kind: 'cancel' } | { kind: 'answer', text: string }}
1039
1054
  */
1040
- function batchTagOutcome(ask, message) {
1055
+ function batchTagOutcome(ask, message, options) {
1041
1056
  if (typeof ask !== 'function') return { kind: 'unavailable' }
1042
1057
  let answer
1043
1058
  try {
@@ -1045,11 +1060,16 @@ window.__ModuleLoader__.load({
1045
1060
  } catch (err) {
1046
1061
  return { kind: 'unavailable' }
1047
1062
  }
1048
- // Only a real `null` means the user pressed Cancel. `undefined` and any
1049
- // non-string (a broken polyfill, a Promise) mean the runtime cannot
1050
- // deliver an answer, so the caller must show the fallback form instead of
1051
- // swallowing the click.
1052
- if (answer === null) return { kind: 'cancel' }
1063
+ // Android WebView is a FOURTH shape this helper did not know about: it
1064
+ // keeps `window.prompt` as a function and does not throw, but with no
1065
+ // WebChromeClient.onJsPrompt it returns `null` immediately — never
1066
+ // showing a dialog. Treating that as "the user pressed Cancel" turned the
1067
+ // bulk-tag action into a silent no-op, and the inline fallback form below
1068
+ // was unreachable. The caller passes `nullMeansUnavailable` when it knows
1069
+ // the runtime cannot show a dialog.
1070
+ const nullIsUnavailable = options !== null && typeof options === 'object'
1071
+ && options.nullMeansUnavailable === true
1072
+ if (answer === null) return nullIsUnavailable ? { kind: 'unavailable' } : { kind: 'cancel' }
1053
1073
  if (typeof answer !== 'string') return { kind: 'unavailable' }
1054
1074
  return { kind: 'answer', text: answer }
1055
1075
  }
@@ -2005,6 +2025,10 @@ window.__ModuleLoader__.load({
2005
2025
  // pretending the copy happened.
2006
2026
  const [diagOpen, setDiagOpen] = React.useState(false)
2007
2027
  const [diagFallback, setDiagFallback] = React.useState(null)
2028
+ /** Export JSON rendered in-panel when the runtime has no download path. */
2029
+ const [exportFallback, setExportFallback] = React.useState(null)
2030
+ /** True once the event stream has failed long enough to stop pretending. */
2031
+ const [liveDown, setLiveDown] = React.useState(false)
2008
2032
  // Feedback form: closed by default so the settings card stays compact.
2009
2033
  // The draft lives in the panel (not in a child) because the send handler
2010
2034
  // needs the same values the inputs show, and keeping them here survives
@@ -2276,7 +2300,11 @@ window.__ModuleLoader__.load({
2276
2300
  const ask = typeof window.prompt === 'function'
2277
2301
  ? (message, initial) => window.prompt(message, initial)
2278
2302
  : null
2279
- const outcome = batchTagOutcome(ask, t('batch.tagPrompt', { count: ids.length }))
2303
+ const outcome = batchTagOutcome(ask, t('batch.tagPrompt', { count: ids.length }), {
2304
+ // A WebView without onJsPrompt answers `null` instantly and shows no
2305
+ // dialog: that is "cannot ask", not "the user said no".
2306
+ nullMeansUnavailable: isAndroidWebView(),
2307
+ })
2280
2308
  if (outcome.kind === 'cancel') return
2281
2309
  if (outcome.kind === 'answer') {
2282
2310
  if (commitBatchTags(outcome.text)) closeBatchTags()
@@ -2686,6 +2714,63 @@ window.__ModuleLoader__.load({
2686
2714
  }
2687
2715
  }
2688
2716
 
2717
+ /**
2718
+ * Is this an Android WebView (as opposed to a normal browser or Electron)?
2719
+ *
2720
+ * The distinction matters because a WebView can answer `typeof
2721
+ * window.prompt === 'function'` and still never show a dialog, and it can
2722
+ * expose `<a download>` while having no download pipeline behind it. Both
2723
+ * fail silently, so the panel has to detect the environment and take the
2724
+ * in-page route instead of pretending the browser feature exists.
2725
+ */
2726
+ function isAndroidWebView() {
2727
+ try {
2728
+ const ua = typeof navigator !== 'undefined' && typeof navigator.userAgent === 'string'
2729
+ ? navigator.userAgent
2730
+ : ''
2731
+ // `; wv)` is the canonical WebView marker; DSH Go's webview island may
2732
+ // also report a stock Chromium UA, so fall back to "Android without a
2733
+ // full browser brand".
2734
+ return /;\s*wv\)/i.test(ua)
2735
+ || (/Android/i.test(ua) && !/Chrome\/\d+/i.test(ua))
2736
+ } catch {
2737
+ return false
2738
+ }
2739
+ }
2740
+
2741
+ /**
2742
+ * Hand `text` to the DSH Go island bridge, in chunks that fit its 16 KB
2743
+ * message cap (`IslandMessageLimits.maxBytes`). The app reassembles them
2744
+ * and opens the system share sheet — the only export path a WebView has,
2745
+ * since it never hands a `blob:` to a download listener.
2746
+ *
2747
+ * Returns false when the page is not inside DSH Go (or the bridge is
2748
+ * missing), which leaves the browser path untouched.
2749
+ */
2750
+ async function sendToAppBridge(text, name) {
2751
+ try {
2752
+ const bridge = typeof window !== 'undefined' ? window.DshGoIsland : undefined
2753
+ if (bridge === undefined || bridge === null || typeof bridge.postMessage !== 'function') return false
2754
+ const id = 'im-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
2755
+ const CHUNK = 6000
2756
+ let i = 0
2757
+ while (i < text.length) {
2758
+ let end = Math.min(text.length, i + CHUNK)
2759
+ // Never split a surrogate pair across two messages: each one is its
2760
+ // own JSON document and a lone surrogate would not survive it.
2761
+ const code = text.charCodeAt(end - 1)
2762
+ if (end < text.length && code >= 0xd800 && code <= 0xdbff) end += 1
2763
+ bridge.postMessage(JSON.stringify({
2764
+ t: 'save', id, name, data: text.slice(i, end), last: end >= text.length,
2765
+ }))
2766
+ i = end
2767
+ }
2768
+ return true
2769
+ } catch {
2770
+ return false
2771
+ }
2772
+ }
2773
+
2689
2774
  async function doExport() {
2690
2775
  try {
2691
2776
  const result = await callHost('export-data', null)
@@ -2693,15 +2778,37 @@ window.__ModuleLoader__.load({
2693
2778
  setError(t('export.failed'))
2694
2779
  return
2695
2780
  }
2696
- const blob = new Blob([JSON.stringify(result.export, null, 2) + '\n'], { type: 'application/json' })
2781
+ const text = JSON.stringify(result.export, null, 2) + '\n'
2782
+ const fileName = 'dsh-instruction-memory-' + new Date().toISOString().slice(0, 10) + '.json'
2783
+ // Inside DSH Go the app can actually write the file; prefer that over
2784
+ // both the download link and the in-panel copy.
2785
+ if (await sendToAppBridge(text, fileName)) {
2786
+ setNotice(t('export.savedToApp'))
2787
+ return
2788
+ }
2789
+ /**
2790
+ * A WebView can expose `<a download>` and still have nowhere to put
2791
+ * the bytes: Android only saves when the embedding app implements a
2792
+ * DownloadListener, and Chromium's WebView cannot hand it a `blob:`.
2793
+ * The click then produces no file, no error, and no second chance (the
2794
+ * object URL is revoked a second later), so Android takes the in-panel
2795
+ * route unconditionally.
2796
+ */
2797
+ if (isAndroidWebView() || typeof URL === 'undefined' || typeof URL.createObjectURL !== 'function') {
2798
+ setExportFallback(text)
2799
+ setNotice(t('export.fallbackHint'))
2800
+ return
2801
+ }
2802
+ const blob = new Blob([text], { type: 'application/json' })
2697
2803
  const url = URL.createObjectURL(blob)
2698
2804
  const link = document.createElement('a')
2699
2805
  link.href = url
2700
- link.download = 'dsh-instruction-memory-' + new Date().toISOString().slice(0, 10) + '.json'
2806
+ link.download = fileName
2701
2807
  document.body.appendChild(link)
2702
2808
  link.click()
2703
2809
  if (link.parentNode !== null) link.parentNode.removeChild(link)
2704
2810
  setTimeout(() => { URL.revokeObjectURL(url) }, 1000)
2811
+ setNotice(t('export.saved'))
2705
2812
  } catch (err) {
2706
2813
  setError(String(err && err.message ? err.message : err))
2707
2814
  }
@@ -2769,6 +2876,9 @@ window.__ModuleLoader__.load({
2769
2876
  let alive = true
2770
2877
  let refreshing = false
2771
2878
  let missed = false
2879
+ /** Consecutive stream errors; reset by any successful frame. */
2880
+ let failures = 0
2881
+ let down = false
2772
2882
  /**
2773
2883
  * Monotonic frame counter carried by lite frames. `rev` only moves when
2774
2884
  * the store is WRITTEN, so a snapshot that changed without a write (the
@@ -2797,6 +2907,11 @@ window.__ModuleLoader__.load({
2797
2907
  })
2798
2908
  }
2799
2909
  source.onmessage = (ev) => {
2910
+ failures = 0
2911
+ if (down) {
2912
+ down = false
2913
+ setLiveDown(false)
2914
+ }
2800
2915
  try {
2801
2916
  const pushed = JSON.parse(ev.data)
2802
2917
  if (pushed !== null && typeof pushed === 'object' && pushed.lite === true) {
@@ -2820,6 +2935,21 @@ window.__ModuleLoader__.load({
2820
2935
  // A malformed frame is dropped; the next frame, or 重新载入, catches up.
2821
2936
  }
2822
2937
  }
2938
+ /**
2939
+ * Android kills the WebView renderer, expires the session cookie or
2940
+ * freezes the page in Doze; EventSource then retries every 5 s forever
2941
+ * and the panel shows stale data as if it were live. Six consecutive
2942
+ * failures (≈30 s) is past any transient blip, so say so and let the
2943
+ * user reload — the stream keeps retrying, and the next frame that
2944
+ * lands clears the warning.
2945
+ */
2946
+ source.onerror = () => {
2947
+ failures += 1
2948
+ if (!down && failures >= 6) {
2949
+ down = true
2950
+ setLiveDown(true)
2951
+ }
2952
+ }
2823
2953
  return () => { alive = false; source.close() }
2824
2954
  }, [])
2825
2955
 
@@ -3585,6 +3715,21 @@ window.__ModuleLoader__.load({
3585
3715
  }, batchMode ? t('batch.exit') : t('action.batch')),
3586
3716
  h('span', { className: 'im-spacer' }),
3587
3717
  h('span', { className: 'im-meta' }, t('stats.count', { total: entries.length, active: activeCount }))),
3718
+ exportFallback !== null
3719
+ ? h('div', { className: 'im-diag-fallback' },
3720
+ h('div', { className: 'im-meta' }, t('export.fallbackHint')),
3721
+ h('textarea', {
3722
+ className: 'im-textarea',
3723
+ readOnly: true,
3724
+ value: exportFallback,
3725
+ onFocus: (ev) => { if (typeof ev.target.select === 'function') ev.target.select() },
3726
+ }),
3727
+ h('div', { className: 'im-row-wrap' },
3728
+ h('button', { className: 'im-btn', onClick: () => setExportFallback(null) }, t('export.fallbackClose'))))
3729
+ : null,
3730
+ isAndroidWebView()
3731
+ ? h('div', { className: 'im-meta' }, t('import.webviewHint'))
3732
+ : null,
3588
3733
  h('div', { className: 'im-divider' }),
3589
3734
  h('div', { className: 'im-meta' },
3590
3735
  storage.path ? t('storage.file', { path: storage.path }) : t('storage.noFile')),
@@ -3705,6 +3850,7 @@ window.__ModuleLoader__.load({
3705
3850
  }))
3706
3851
  : null)
3707
3852
  : null),
3853
+ liveDown ? h('div', { className: 'im-meta', role: 'status' }, t('sse.down')) : null,
3708
3854
  error !== '' ? h('div', { className: 'im-err', role: 'alert' }, error) : null,
3709
3855
  error === '' && notice !== '' ? h('div', { className: 'im-ok', role: 'status', 'aria-live': 'polite' }, notice) : null,
3710
3856
  error === '' && notice === '' && storage.error ? h('div', { className: 'im-err', role: 'alert' }, storage.error) : null,
@@ -4070,7 +4216,7 @@ window.__ModuleLoader__.load({
4070
4216
  box.id = id
4071
4217
  box.setAttribute('role', 'status')
4072
4218
  box.style.cssText = [
4073
- 'position:fixed', 'right:16px', 'bottom:16px', 'z-index:2147483000',
4219
+ 'position:fixed', 'right:16px', 'bottom:calc(16px + env(safe-area-inset-bottom,0px))', 'z-index:2147483000',
4074
4220
  'max-width:min(380px,calc(100vw - 32px))',
4075
4221
  'background:#3a1d1d', 'color:#ffe8e8', 'border:1px solid #7a3a3a',
4076
4222
  'border-radius:10px', 'padding:12px 14px',
package/lib/index.js CHANGED
@@ -182,6 +182,24 @@ const TRUSTED_HOSTS_ENV = 'DSH_INSTRUCTION_MEMORY_TRUSTED_HOSTS'
182
182
  * volume.
183
183
  */
184
184
  const ALLOW_EXTERNAL_BACKUP_ENV = 'DSH_INSTRUCTION_MEMORY_ALLOW_EXTERNAL_BACKUP'
185
+ /**
186
+ * Opt-in "no fence, no route" switch.
187
+ *
188
+ * Without a `connection` service the plugin falls back to its own loopback /
189
+ * same-origin rule. On a desktop that is a real boundary; on Android it is not,
190
+ * because loopback lives in a namespace every app on the device shares — any
191
+ * app with INTERNET can then rewrite the instructions of every later turn.
192
+ * DSH Go's build-time route guard normally closes that, but the guard is a
193
+ * platform property: a `dsh web` started without `--patch` silently loses it.
194
+ * Setting this to 1 makes the plugin refuse instead of degrade, so the failure
195
+ * mode becomes a settings page that cannot load rather than a writable one.
196
+ */
197
+ const REQUIRE_AUTH_FENCE_ENV = 'DSH_INSTRUCTION_MEMORY_REQUIRE_AUTH_FENCE'
198
+ /** Is the fail-closed switch on? Read per request so a restart is not needed. */
199
+ const requireAuthFence = () => {
200
+ const raw = process.env[REQUIRE_AUTH_FENCE_ENV]
201
+ return typeof raw === 'string' && raw.trim() === '1'
202
+ }
185
203
  /**
186
204
  * How often the store file is stat-ed as a fallback for `fs.watch` (F19).
187
205
  *
@@ -2532,6 +2550,17 @@ export function apply(ctx, deps = {}) {
2532
2550
  const raw = process.env[ALLOW_EXTERNAL_BACKUP_ENV]
2533
2551
  return typeof raw === 'string' && raw.trim() === '1'
2534
2552
  }
2553
+ /**
2554
+ * The "set this env var and restart" footer shared by the three backup paths.
2555
+ *
2556
+ * On Linux — and therefore on every Android build, DSH Go included — the
2557
+ * engine is started through `/usr/bin/env -i`, which leaves no way for the
2558
+ * user to set it. Telling them to export a variable they cannot export is a
2559
+ * dead instruction; point at the route that does work instead.
2560
+ */
2561
+ const externalBackupHint = () => (process.platform === 'linux'
2562
+ ? '要使用其他磁盘请设置 ' + ALLOW_EXTERNAL_BACKUP_ENV + '=1 后重启宿主(DSH Go / Android 用 env -i 启动引擎,该变量设不了,请改用「导出」把 JSON 复制出来备份)'
2563
+ : '要使用其他磁盘请设置 ' + ALLOW_EXTERNAL_BACKUP_ENV + '=1 后重启宿主')
2535
2564
  /** `path`'s real location, or null when it (or a parent) is unavailable. */
2536
2565
  const realPathOrNull = (path) => {
2537
2566
  try {
@@ -2597,7 +2626,7 @@ export function apply(ctx, deps = {}) {
2597
2626
  if (refusedBackupDir !== configured) {
2598
2627
  refusedBackupDir = configured
2599
2628
  state.storageWarning = '备份位置 ' + displayPath(configured) + ' 不在 DSH 主目录内,已忽略(备份与历史快照一律写回默认位置);'
2600
- + '要使用其他磁盘请设置 ' + ALLOW_EXTERNAL_BACKUP_ENV + '=1 后重启宿主'
2629
+ + externalBackupHint()
2601
2630
  logInfo('ignoring backupDir outside the harness home: ' + configured)
2602
2631
  }
2603
2632
  return ''
@@ -4007,8 +4036,7 @@ export function apply(ctx, deps = {}) {
4007
4036
  return {
4008
4037
  ok: false,
4009
4038
  saved: false,
4010
- message: '备份位置必须位于 DSH 主目录内(' + displayPath(dshRootPath()) + ');要使用其他磁盘请设置环境变量 '
4011
- + ALLOW_EXTERNAL_BACKUP_ENV + '=1 后重启宿主',
4039
+ message: '备份位置必须位于 DSH 主目录内(' + displayPath(dshRootPath()) + ');' + externalBackupHint(),
4012
4040
  snapshot: snapshot(),
4013
4041
  }
4014
4042
  }
@@ -4641,8 +4669,7 @@ export function apply(ctx, deps = {}) {
4641
4669
  return {
4642
4670
  ok: false,
4643
4671
  saved: false,
4644
- message: '导出目录必须位于 DSH 主目录内(' + displayPath(dshRootPath()) + ');要写入其他磁盘请设置环境变量 '
4645
- + ALLOW_EXTERNAL_BACKUP_ENV + '=1 后重启宿主',
4672
+ message: '导出目录必须位于 DSH 主目录内(' + displayPath(dshRootPath()) + ');' + externalBackupHint(),
4646
4673
  snapshot: snapshot(),
4647
4674
  }
4648
4675
  }
@@ -5388,6 +5415,16 @@ export function apply(ctx, deps = {}) {
5388
5415
  'cache-control': 'no-store',
5389
5416
  connection: 'keep-alive',
5390
5417
  })
5418
+ // Mobile renderers are killed without a FIN (Android LMK) or lose their
5419
+ // network mid-stream (Wi-Fi → cellular). The heartbeat cannot notice a peer
5420
+ // that never errors — every write succeeds into the socket buffer — so at
5421
+ // least let the OS probe for a dead peer, and never sit on Nagle.
5422
+ try {
5423
+ if (res.socket && typeof res.socket.setKeepAlive === 'function') res.socket.setKeepAlive(true, 30_000)
5424
+ if (res.socket && typeof res.socket.setNoDelay === 'function') res.socket.setNoDelay(true)
5425
+ } catch {
5426
+ // A double without a socket (tests) or a closed one: nothing to tune.
5427
+ }
5391
5428
  // Ask the browser to retry politely if the stream drops (e.g. the host was
5392
5429
  // restarted) instead of hammering the route.
5393
5430
  res.write('retry: 5000\n\n')
@@ -5484,6 +5521,24 @@ export function apply(ctx, deps = {}) {
5484
5521
  }
5485
5522
  return 'gate'
5486
5523
  }
5524
+ /**
5525
+ * Deeper defence for deployments where loopback is not a boundary: on
5526
+ * Android every app shares the loopback namespace, so "reachable only from
5527
+ * this machine" stops meaning "reachable only from this user". With the
5528
+ * switch set, a profile without a fence gets NO route at all — a settings
5529
+ * page that fails to load is a far better outcome than one that can be
5530
+ * rewritten by any installed app.
5531
+ */
5532
+ if (requireAuthFence()) {
5533
+ logInfo('route refused a request: no connection fence and ' + REQUIRE_AUTH_FENCE_ENV + '=1')
5534
+ res.writeHead(503, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
5535
+ res.end(JSON.stringify({
5536
+ ok: false,
5537
+ message: '该 profile 没有宿主的 connection 服务,已按 ' + REQUIRE_AUTH_FENCE_ENV
5538
+ + '=1 拒绝请求(fail-closed);如需在无鉴权宿主上使用,请移除该环境变量。',
5539
+ }))
5540
+ return 'rejected'
5541
+ }
5487
5542
  if (!guardHost) {
5488
5543
  logInfo('route refused a request: non-loopback bind without an authentication fence')
5489
5544
  res.writeHead(503, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-instruction-memory",
3
- "version": "1.5.4",
3
+ "version": "1.5.6",
4
4
  "description": "User-only instruction memory for DSH: long-term instructions maintained in the settings UI and auto-injected into every conversation — the model has no write access. 指令记忆:只由用户在设置页维护的长期指令,自动注入此后每轮对话;模型没有任何写入口。",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,7 +31,8 @@
31
31
  "CHANGELOG.md",
32
32
  "LICENSE",
33
33
  "memory.schema.json",
34
- "docs/screenshot.png"
34
+ "docs/screenshot.png",
35
+ "docs/demo.html"
35
36
  ],
36
37
  "keywords": [
37
38
  "dsh",