dsh-instruction-memory 1.5.6 → 1.6.0

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,115 @@
7
7
  > 1.0.13 起由 CI 经 OIDC Trusted Publishing 发布(首个带 provenance 的版本)。
8
8
  > 这些历史 tag **不应回填**——npm 上的版本号已被占用。
9
9
 
10
+ ## [1.6.0] — 2026-11-14
11
+
12
+ 让「按需(auto)」条目第一次真正有机会被执行。此前的 auto 只在会话开头注入一次、措辞是「参考」,
13
+ 模型读完第一轮就忘了;而「每次给最终答案前」这类时机,从来没有任何东西提醒过它。
14
+
15
+ ### 新增
16
+
17
+ - **触发词提升(`triggers`)**:条目可配置最多 8 个触发词(每项 ≤24 字)。每个真人回合
18
+ (`user/message`,`source.kind === 'user'`)用户话匹配一次,命中即把该条目在本回合提升为
19
+ 「本轮强制」,并重新注册 prompt section,使**当轮**注入立刻带上新标记。未配触发词的条目行为不变。
20
+ - **设置页触发词输入**:`mode=auto` 时编辑器多一个触发词输入(回车添加,复用现有标签组件),
21
+ 中英双语文案齐备;`memory.schema.json` 同步新增 `triggers` 定义。
22
+
23
+ ### 变更(行为)
24
+
25
+ - **auto 的注入语义从「按需」改为「条件必做」**:条目前缀 `[按需|优先级 X]` 改为
26
+ `[条件必做|…]`;场景行 `适用场景:…` 改为 `出现以下情形时必须执行,不得跳过:…`。
27
+ 起因是注入块头部那句「[按需] 条目仅在与当前问题或任务相关时生效;不相关时直接忽略」——
28
+ 它等于插件自己给了模型一个合法跳过的理由。
29
+ - 伪造前缀中和正则同步扩到 `条件必做`(保留 `按需` 以兼容历史内容)。
30
+
31
+ ### 修复
32
+
33
+ - `ctx.on` 在没有事件总线的假宿主(测试与裁剪环境)上会抛 `TypeError`,改为能力检测后再订阅。
34
+
35
+ ### 兼容性
36
+
37
+ - 存储格式不变(`version: 2`);新字段缺省为空数组。旧版本读到含 `triggers` 的文件会忽略该字段。
38
+
39
+ ## [1.5.7] — 2026-11-14
40
+
41
+ 针对**最新版桌面端(`dsh-desktop-runtime 0.2.2-rc.3`,Electron)与网页端**全量审查后的修复轮。
42
+ 审查结论先说清楚:**功能性缺陷只有 1 条**(测试守卫读错宿主),其余是注入链路的加固、一处
43
+ 无鉴权无确认的自动写路径、以及若干"声明/文档与实现不符"。审查同时**撤回了一条看起来该改、
44
+ 实测不该改**的建议(见末节)。
45
+
46
+ ### 修复
47
+
48
+ - **回归守卫读错宿主,对着错误的版本虚假通过(B1)**:`host-range-test.mjs` 的 LIVE GUARD
49
+ 只在 `profiles/*/node_modules`、`/usr/lib…`、`/usr/local/lib…` 和 TRAE 字面路径里找宿主。
50
+ 桌面端把 dsh 打包进 `app.asar`,这些路径**永远不命中**;本机因此命中一条与本机无关的软链
51
+ (TRAE 捆绑的 `0.2.2-alpha.2`),并在桌面端实际运行 `0.2.2-rc.3` 时打印
52
+ `PASS LIVE GUARD: the installed host 0.2.2-alpha.2 ...`。这正是该守卫注释里声称要防的
53
+ false negative。现在优先读取 `resources/runtime/primary-runtime/runtime.json` 的
54
+ `desktopVersion`(可选 `DSH_DESKTOP_RUNTIME_ROOT` 覆盖),桌面证据存在时以它为准,
55
+ `profiles/node_modules` 的软链树降级为"没有桌面安装时"的证据;标签里**打印版本来源路径**,
56
+ 免得下次读错树又是静默通过。
57
+ - **`systemPrompt` 一次性捕获,服务重发布后注入静默死亡(A1)**:`apply()` 里
58
+ `const systemPrompt = ctx.systemPrompt` 只取一次。宿主 dispose 并重新 publish 该服务后
59
+ (宿主重启、fiber 重挂、profile 重载),旧实例仍然接受 `section()` 并返回 disposer、
60
+ 不抛错,新实例一个 section 都收不到 —— 于是此后整轮对话都不再有指令记忆,且没有任何报错。
61
+ 改为可变引用 + 非阻塞 `ctx.inject(['systemPrompt'], …)`(与 `webServer` 注册同构):
62
+ 服务换实例时先 dispose 旧注册再在新实例上重建;服务下线时把
63
+ `注入服务已重新发布/下线` 写进 `injection.error`,而不是假装无事发生。
64
+
65
+ ### 安全
66
+
67
+ - **旧位置文件不再自动采纳(A4)**:`process.cwd()` 下的 `.dsh-instruction-memory.json`
68
+ 过去会被**直接写进 memory.json 并在下一轮注入**——没有鉴权门、没有确认,是全插件唯一
69
+ 这样的写路径。`cwd` 不属于插件:任何同权限进程在那里放一份 store 形状的 JSON,就能变成
70
+ "用户的长期指令"。现在只**检出**(`storage.legacyPending`,设置页显示条数与路径),
71
+ 由用户点「导入旧位置数据」才采纳(`adopt-legacy`,走与其他写入相同的门禁,且在接纳前
72
+ 重新读取并复核该路径**仍是**本插件的 store);「忽略」为 `dismiss-legacy`,原文件不动。
73
+ - **注入文本剥离不可见字符(A3)**:`cleanText` 过去只归一 `\r`。现在剥离 C0 控制符
74
+ (保留 `\n`/`\t`)、DEL、`U+200B–U+200F`、`U+202A–U+202E`、`U+2066–U+2069`、`U+FEFF`,
75
+ 并把 `U+2028/2029` 归一成换行。理由是**设置页所见必须等于模型所读**:bidi override 能让
76
+ 一行显示成另一个词(Trojan-Source 类),零宽字符能拆词躲过关键词与去重检查。
77
+ - **注入块加不可伪造定界(A2)**:整段现在由 `<user-instruction-memory>` /
78
+ `</user-instruction-memory>` 包裹,且 `content` 中同形的标签、本插件自己的段头
79
+ (`## 用户长期指令记忆`、`## 关于用户(用户形象)`)与条目前缀(`[始终|优先级`/`[按需|优先级`)
80
+ 会被换成全角近似字符,无法再冒充系统段头或伪造优先级。普通 Markdown 标题
81
+ (如 `## 实验步骤`)不受影响。
82
+ - **无鉴权门时,写方法要求浏览器来源标记(A9/A12)**:`requestOriginVerdict` 的每条分支都以
83
+ "有 Origin"为前提,因此**本机脚本/curl 不带 Origin 就能在无 `connection` 服务的 profile 上
84
+ 直接改记忆**。回环在"同机任意程序都以该用户身份运行"的机器上不再是边界。现在:
85
+ ① 无鉴权门且主机名白名单被放宽(`hostGuarded === false` + `trustedHostCount > 0`)时,
86
+ 写方法一律 **503**;② 无鉴权门时,写方法必须带 `Origin` 或 `Sec-Fetch-Site: same-origin`
87
+ (浏览器必发其一,设置页不受影响),否则 **403**。只读方法(`state`、`history-list`、
88
+ `read-source`、`export-data`、`export-to-dir`、`reload`、`send-feedback`)不受限制。
89
+ `DSH_INSTRUCTION_MEMORY_REQUIRE_AUTH_FENCE=1` 仍可让整条路由 fail closed。
90
+ - **崩溃残留的临时副本会被清理(A13)**:原子写用
91
+ `memory.json.tmp-<pid>-<uuid>`,在 `open` 与 `rename` 之间崩溃会留下**一份完整的记忆明文**,
92
+ 且永不清理。载入时按插件自己的命名模式清扫;记录里的 pid 仍存活则跳过(那是另一个进程
93
+ 正在写,不是垃圾)。
94
+
95
+ ### 变更
96
+
97
+ - 新字段 `injection.envelopeChars`:`chars` 里有多少是定界包裹。面板的用量条改为
98
+ `entryChars + profileChars + envelopeChars`(旧宿主不发该字段,按 0 计),这样条上的数字
99
+ 与宿主真正执行的预算一致。
100
+ - 新字段 `storage.legacyPending`(`{ path, entries, profiles }` 或 `null`)与
101
+ `security.routeRegistered`(设置页路由是否真的挂上了 —— 没有 `webServer` 的 profile 里过去
102
+ 只有一行 `console.error`,面板无法自述其缺席)。
103
+ - 新方法 `adopt-legacy` / `dismiss-legacy`。
104
+ - `peerDependenciesMeta` 把 `@deepseek-ai/dsh-system-prompt` 标为 **optional**:插件代码
105
+ 全文只 import `node:` 内置模块,服务靠 `inject = ['systemPrompt']` 与鸭子类型取得,
106
+ 把 peer 标成强制只会在 `strict-peer-dependencies` 下误伤安装。
107
+ - `description` 措辞更正:从"模型没有任何写入口"改为"**插件不注册任何面向模型的工具**,
108
+ 模型没有插件内的写入口"——原话与本仓库 README 自述(同权限进程可直接改文件)自相矛盾。
109
+
110
+ ### 未改动(审查建议过,复核后撤回)
111
+
112
+ - **冗余区间段不能删(A14 撤回)**:`>=0.2.1-alpha.0`、`>=0.2.2-alpha.0` 在
113
+ `includePrerelease: true`(宿主门)下确实被 `>=0.2.0-0 <0.3.0-0` 吸收,但在**默认 semver
114
+ 语义**(pnpm / npm 的 peer 解析)下,预发布版本要求**同一 tuple** 的比较器存在,删掉这两段
115
+ 会让 `0.2.1-alpha.x` / `0.2.2-alpha.x` 被判不兼容。宿主门用 includePrerelease、包管理器用
116
+ 默认语义,两套并存,所以每一段都是必需的。README 的兼容性小节据此更正了
117
+ `peerDependencies` 被称作"真正的门禁"的说法。
118
+
10
119
  ## [1.5.6] — 2026-11-13
11
120
 
12
121
  ### 新增
@@ -15,8 +124,9 @@
15
124
  ——那是 DSH Go 的 island 桥。存在时把 JSON 按 6,000 字符一块(不切断代理对)`postMessage` 给 App,
16
125
  由 App 写文件并唤起系统分享面板,面板提示「已交给 DSH Go 保存」。不存在时行为不变:普通 WebView
17
126
  回退到面板内 JSON,浏览器/Electron 仍是 `<a download>`。
18
- 壳侧需要配套补丁(island 协议新增 `t:'save'` 分块消息),已在仓库外单独产出,见
19
- `D:\Users\34332\AI\dsh-go-patch\README-PATCH.md`;本仓库不含任何上游代码。
127
+ 壳侧需要配套补丁(island 协议新增 `t:'save'` 分块消息),已在仓库外单独产出
128
+ (`dsh-go-patch` 的 `README-PATCH.md`,放在本机独立目录,未随本仓库或 npm 包分发);
129
+ 本仓库不含任何上游代码。
20
130
 
21
131
  ### 文档更正
22
132
 
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
 
package/lib/client.js CHANGED
@@ -283,13 +283,14 @@ window.__ModuleLoader__.load({
283
283
  'editor.contentPlaceholder': '例如:所有回答使用简体中文;不确定需求时先提问再动手。',
284
284
  'editor.mode': '生效方式',
285
285
  'editor.modeAlways': '始终生效(无条件遵守)',
286
- 'editor.modeAuto': '按需生效(按相关性自动判断)',
286
+ 'editor.modeAuto': '条件必做(适用情形出现时必须执行)',
287
287
  'editor.priority': '优先级',
288
288
  'priority.high': '高',
289
289
  'priority.normal': '普通',
290
290
  'priority.low': '低',
291
291
  'editor.when': '适用场景(可选,帮助模型判断何时生效)',
292
292
  'editor.whenPlaceholder': '例如:编写或修改代码时',
293
+ 'editor.triggers': '触发词(本回合用户说中即提升为「本轮强制」)',
293
294
  'editor.save': '保存',
294
295
  'editor.saving': '保存中…',
295
296
  'editor.cancel': '取消',
@@ -311,7 +312,7 @@ window.__ModuleLoader__.load({
311
312
  'tag.omittedTitle': '因超出注入字数上限,本条当前不会进入系统提示词',
312
313
  'tag.truncatedTitle': '本条过长,仅开头部分进入系统提示词',
313
314
  'rules.label': '生效规则',
314
- 'rules.note': '「始终」无条件生效;「按需」由模型按相关性自动判断。超出上限时按优先级保留,其余暂不注入。',
315
+ 'rules.note': '「始终」无条件生效;「条件必做」在适用情形出现时必须执行。超出上限时按优先级保留,其余暂不注入。',
315
316
  'search.placeholder': '搜索指令:标题 / 内容 / 适用场景(共 {count} 条)',
316
317
  'search.matchCount': '匹配 {shown} / {total} 条',
317
318
  'tags.label': '标签(可选,仅用于在本页分组与筛选,不会注入提示词)',
@@ -325,7 +326,7 @@ window.__ModuleLoader__.load({
325
326
  'filter.mode': '生效方式',
326
327
  'filter.status': '状态',
327
328
  'filter.modeAlways': '始终',
328
- 'filter.modeAuto': '按需',
329
+ 'filter.modeAuto': '条件必做',
329
330
  'filter.statusEnabled': '已启用',
330
331
  'filter.statusDisabled': '已停用',
331
332
  'sort.label': '排序',
@@ -384,19 +385,22 @@ window.__ModuleLoader__.load({
384
385
  'storage.savedAt': '最近保存:{time}',
385
386
  'storage.notSaved': '尚未保存到磁盘',
386
387
  'storage.migrated': '已从旧位置迁移:{path}(原文件保留为 *.migrated)',
388
+ 'storage.legacyPending': '检测到旧位置(启动目录)有一份记忆:{path}({entries} 条指令、{profiles} 个形象)。在你确认之前,它不会被导入,也不会进入注入。',
389
+ 'storage.legacyAdopt': '导入旧位置数据',
390
+ 'storage.legacyDismiss': '忽略',
387
391
  'preview.label': '当前注入到每轮系统提示词的内容',
388
392
  'preview.empty': '(当前没有生效中的指令)',
389
393
  'list.empty': '还没有指令记忆。点击「+ 新增指令」添加第一条,例如“所有回答使用简体中文”。',
390
394
  'list.noMatch': '没有匹配“{query}”的指令。',
391
395
  'list.filteredEmpty': '没有符合当前筛选条件的指令。',
392
396
  'tag.always': '始终',
393
- 'tag.auto': '按需',
397
+ 'tag.auto': '条件必做',
394
398
  'entry.enabled': '启用',
395
399
  'entry.edit': '编辑',
396
400
  'entry.delete': '删除',
397
401
  'entry.untitled': '未命名指令',
398
402
  'entry.confirmDelete': '确定删除「{title}」?删除后无法恢复。',
399
- 'entry.when': '适用场景:{when}',
403
+ 'entry.when': '适用情形:{when}',
400
404
  'entry.updatedAt': '更新于 {time}',
401
405
  'undo.deleted': '已删除「{title}」',
402
406
  'undo.restore': '撤销删除',
@@ -588,6 +592,7 @@ window.__ModuleLoader__.load({
588
592
  'priority.low': 'Low',
589
593
  'editor.when': 'Applicable scenarios (optional; helps the model decide when it applies)',
590
594
  'editor.whenPlaceholder': 'e.g. When writing or modifying code',
595
+ 'editor.triggers': 'Trigger words (promote to 本轮强制 when the user says one)',
591
596
  'editor.save': 'Save',
592
597
  'editor.saving': 'Saving…',
593
598
  'editor.cancel': 'Cancel',
@@ -682,6 +687,9 @@ window.__ModuleLoader__.load({
682
687
  'storage.savedAt': 'Last saved: {time}',
683
688
  'storage.notSaved': 'Not yet saved to disk',
684
689
  'storage.migrated': 'Migrated from the old location: {path} (the original is kept as *.migrated)',
690
+ '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.',
691
+ 'storage.legacyAdopt': 'Import the old data',
692
+ 'storage.legacyDismiss': 'Ignore',
685
693
  'preview.label': 'What the system prompt receives on every turn',
686
694
  'preview.empty': '(no active instructions right now)',
687
695
  'list.empty': 'No instructions yet. Click “+ New instruction” to add your first one, e.g. “Always answer in Simplified Chinese”.',
@@ -1081,6 +1089,7 @@ window.__ModuleLoader__.load({
1081
1089
  content: entry.content,
1082
1090
  mode: entry.mode,
1083
1091
  when: entry.when,
1092
+ triggers: Array.isArray(entry.triggers) ? entry.triggers.slice(0, 8) : [],
1084
1093
  priority: entry.priority,
1085
1094
  enabled: entry.enabled,
1086
1095
  tags: Array.isArray(entry.tags) ? entry.tags.slice() : [],
@@ -1113,7 +1122,7 @@ window.__ModuleLoader__.load({
1113
1122
  }
1114
1123
 
1115
1124
  function blankDraft() {
1116
- return { id: '', title: '', content: '', mode: 'always', when: '', priority: 1, enabled: true, tags: [], updatedAt: 0 }
1125
+ return { id: '', title: '', content: '', mode: 'always', when: '', priority: 1, enabled: true, tags: [], triggers: [], updatedAt: 0 }
1117
1126
  }
1118
1127
 
1119
1128
  /**
@@ -1310,7 +1319,21 @@ window.__ModuleLoader__.load({
1310
1319
  const profileUsed = typeof inj.entryChars === 'number' && inj.entryChars >= 0
1311
1320
  && typeof inj.profileChars === 'number' && inj.profileChars >= 0
1312
1321
  ? inj.profileChars : 0
1313
- const used = entryUsed + profileUsed
1322
+ // Measure the budgeted halves, never the whole section: on a host that
1323
+ // publishes persona profiles, `chars` is the whole section while
1324
+ // `entryChars`/`profileChars` are the two halves the Host caps against
1325
+ // `budget`. Falling back to `chars` keeps older hosts (which publish
1326
+ // neither half) working.
1327
+ //
1328
+ // A2's envelope is a third, fixed part of the section: it is reserved
1329
+ // against the budget on the Host side, so the meter has to count it or it
1330
+ // reads lower than the number the Host actually enforces. Older hosts do
1331
+ // not publish it, and add zero.
1332
+ const envelopeUsed = typeof inj.envelopeChars === 'number' && inj.envelopeChars >= 0
1333
+ ? inj.envelopeChars : 0
1334
+ const used = typeof inj.entryChars === 'number' && inj.entryChars >= 0
1335
+ ? entryUsed + profileUsed + envelopeUsed
1336
+ : (typeof inj.chars === 'number' && inj.chars >= 0 ? inj.chars : 0)
1314
1337
  const countFlagged = (list, wantTruncated) =>
1315
1338
  Array.isArray(list)
1316
1339
  ? list.filter((item) => item !== null && typeof item === 'object'
@@ -1790,6 +1813,17 @@ window.__ModuleLoader__.load({
1790
1813
  onChange: (ev) => set({ when: ev.target.value }),
1791
1814
  })))
1792
1815
  }
1816
+ if (draft.mode === 'auto') {
1817
+ children.push(h('div', { key: 'tr' },
1818
+ h('label', { className: 'im-label', htmlFor: 'im-entry-triggers' }, t('editor.triggers')),
1819
+ h(TagInput, {
1820
+ inputId: 'im-entry-triggers',
1821
+ tags: Array.isArray(draft.triggers) ? draft.triggers : [],
1822
+ limits: props.limits,
1823
+ disabled: props.busy,
1824
+ onChange: (next) => set({ triggers: next }),
1825
+ })))
1826
+ }
1793
1827
  children.push(h('div', { key: 'tags' },
1794
1828
  h('label', { className: 'im-label', htmlFor: 'im-entry-tags' }, t('tags.label')),
1795
1829
  h(TagInput, {
@@ -2635,6 +2669,23 @@ window.__ModuleLoader__.load({
2635
2669
  void send('export-to-dir', null)
2636
2670
  }
2637
2671
 
2672
+ // ---- A4 legacy location: detected at boot, adopted only on request ----
2673
+ //
2674
+ // The Host no longer writes a store it finds at the startup directory into
2675
+ // memory.json on its own. That path had no fence and no user in the loop,
2676
+ // so anything able to drop a JSON file there could become the user's
2677
+ // long-lived instructions. These two actions are the only way in.
2678
+
2679
+ function adoptLegacy() {
2680
+ if (busy) return
2681
+ void send('adopt-legacy', { baseRev: revOf() })
2682
+ }
2683
+
2684
+ function dismissLegacy() {
2685
+ if (busy) return
2686
+ void send('dismiss-legacy', null)
2687
+ }
2688
+
2638
2689
  // ---- F14 whole-store restore (the "replace" door import does not open) ----
2639
2690
 
2640
2691
  async function handleRestoreFile(file) {
@@ -3739,6 +3790,23 @@ window.__ModuleLoader__.load({
3739
3790
  ? h('div', { className: 'im-ok' }, t('storage.migrated', { path: storage.migratedFrom }))
3740
3791
  : null,
3741
3792
  storage.warning ? h('div', { className: 'im-warn' }, storage.warning) : null,
3793
+ // A4: an explicit decision, never an automatic import.
3794
+ storage.legacyPending
3795
+ ? h('div', { className: 'im-warn' },
3796
+ h('div', null, t('storage.legacyPending', {
3797
+ path: storage.legacyPending.path,
3798
+ entries: storage.legacyPending.entries,
3799
+ profiles: storage.legacyPending.profiles,
3800
+ })),
3801
+ h('div', { className: 'im-row-wrap' },
3802
+ h('button', {
3803
+ className: 'im-btn im-btn-primary',
3804
+ disabled: busy,
3805
+ onClick: adoptLegacy,
3806
+ }, t('storage.legacyAdopt')),
3807
+ h('button', { className: 'im-btn', disabled: busy, onClick: dismissLegacy },
3808
+ t('storage.legacyDismiss'))))
3809
+ : null,
3742
3810
  // ---- F18 备份位置 ----
3743
3811
  h('div', { key: 'backup', className: 'im-backup' },
3744
3812
  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
  /**
@@ -522,6 +553,31 @@ export function sanitizeTags(raw) {
522
553
  return out
523
554
  }
524
555
 
556
+ /** Trigger words per entry, matched against the user's own words every turn. */
557
+ const MAX_TRIGGERS = 8
558
+ const MAX_TRIGGER_LENGTH = 24
559
+
560
+ /**
561
+ * Normalise one entry's `triggers`: the phrases that promote the entry to
562
+ * 「本轮强制」 when they appear in the user's message this turn. Same rules as
563
+ * tags — single line, whitespace collapsed, de-duplicated, capped — because
564
+ * they are matched case-insensitively against prose the user actually typed.
565
+ */
566
+ export function sanitizeTriggers(raw) {
567
+ if (!Array.isArray(raw)) return []
568
+ const out = []
569
+ const seen = new Set()
570
+ for (const item of raw) {
571
+ if (typeof item !== 'string') continue
572
+ const trigger = singleLine(item, MAX_TRIGGER_LENGTH).replace(/\s+/g, ' ').trim()
573
+ if (trigger === '' || seen.has(trigger)) continue
574
+ seen.add(trigger)
575
+ if (out.length >= MAX_TRIGGERS) break
576
+ out.push(trigger)
577
+ }
578
+ return out
579
+ }
580
+
525
581
  export function sanitizeEntry(raw) {
526
582
  if (raw === null || typeof raw !== 'object') return null
527
583
  const title = singleLine(raw.title, MAX_TITLE)
@@ -536,6 +592,7 @@ export function sanitizeEntry(raw) {
536
592
  priority: raw.priority === 2 || raw.priority === 0 ? raw.priority : 1,
537
593
  enabled: raw.enabled !== false,
538
594
  tags: sanitizeTags(raw.tags),
595
+ triggers: sanitizeTriggers(raw.triggers),
539
596
  /**
540
597
  * Which instruction set (F13) this entry belongs to. Preserved here — NOT
541
598
  * re-derived — because both the loader and the importer need to honour a
@@ -1452,10 +1509,61 @@ function requestPortOf(hostHeader) {
1452
1509
  return port === '80' || port === '443' ? '' : port
1453
1510
  }
1454
1511
 
1512
+ /**
1513
+ * Defuse text that would read as OUR OWN structure instead of as the user's
1514
+ * prose (A2).
1515
+ *
1516
+ * The injected block distinguishes "this is the harness speaking" from "this is
1517
+ * what the user typed" only by its own headings and entry prefixes. Anyone who
1518
+ * can write the store (the settings page, a hand edit, an import, or the model
1519
+ * itself through the same-permission file path the README documents) can paste
1520
+ * `## 用户长期指令记忆(Instruction Memory)` or `[始终|优先级 高]` into
1521
+ * `content`, and the model then sees two headings of which one is forged: a
1522
+ * fabricated priority, or a fabricated "system" section that the store's own
1523
+ * renderer never produced.
1524
+ *
1525
+ * Only the markers that NAME this plugin's blocks are neutralised, and only at
1526
+ * the start of a line, so ordinary Markdown (`## 实验步骤`) and ordinary
1527
+ * bracketed prose survive untouched. The marker character is swapped for a
1528
+ * full-width look-alike: the text stays readable to the user, but it can no
1529
+ * longer equal the token the model was told to trust.
1530
+ */
1531
+ const FORGED_HEADING = /^([ \t]*)(#{1,6})([ \t]*)(用户长期指令记忆|关于用户(用户形象)|关于用户|用户形象)/gm
1532
+ const FORGED_ENTRY_PREFIX = /^([ \t]*)\[(始终|条件必做|按需)(|优先级)/gm
1533
+
1534
+ function neutralizeForgedStructure(value) {
1535
+ if (typeof value !== 'string' || value === '') return value
1536
+ return value
1537
+ // A hand-written copy of the envelope would close the section early and put
1538
+ // everything after it outside the block the model was told to trust, which
1539
+ // is the whole point of having an envelope. Full-width brackets keep the
1540
+ // text readable while making the token unusable.
1541
+ .replace(/<\s*(\/?)\s*user-instruction-memory\s*>/gi,
1542
+ (match, slash) => '〔' + (slash === '/' ? '/' : '') + 'user-instruction-memory〕')
1543
+ .replace(FORGED_HEADING, '$1#$3$4')
1544
+ .replace(FORGED_ENTRY_PREFIX, '$1[$2$3')
1545
+ }
1546
+
1547
+ /**
1548
+ * The delimiters around the whole injected section (A2).
1549
+ *
1550
+ * A heading alone cannot tell the model where this block ends: the section had
1551
+ * no closing token, so a content line shaped like the start of something else
1552
+ * read as if it were still inside the user's memory. The envelope gives the
1553
+ * section an unambiguous start and end that ordinary prose cannot produce by
1554
+ * accident, and `neutralizeForgedStructure` keeps a hand-written copy of it from
1555
+ * being reproduced inside `content`.
1556
+ *
1557
+ * The cost is reserved against the character budget in planInjection, so the
1558
+ * `text.length <= budget` invariant the panel shows the user still holds.
1559
+ */
1560
+ const MEMORY_ENVELOPE_OPEN = '<user-instruction-memory>\n'
1561
+ const MEMORY_ENVELOPE_CLOSE = '\n</user-instruction-memory>'
1562
+
1455
1563
  function formatEntry(entry, tag) {
1456
1564
  const lines = ['[' + tag + '|优先级 ' + priorityLabel(entry.priority) + '] ' + entry.title]
1457
- if (entry.mode === 'auto' && entry.when !== '') lines.push('适用场景:' + entry.when)
1458
- lines.push(entry.content)
1565
+ if (entry.mode === 'auto' && entry.when !== '') lines.push('出现以下情形时必须执行,不得跳过:' + entry.when)
1566
+ lines.push(neutralizeForgedStructure(entry.content))
1459
1567
  return lines.join('\n')
1460
1568
  }
1461
1569
 
@@ -1539,7 +1647,7 @@ function buildProfilesBlock(profiles, budget) {
1539
1647
  ? profile.name
1540
1648
  : singleLine(profile.content, 20)
1541
1649
  const id = typeof profile.id === 'string' ? profile.id : ''
1542
- const block = '### ' + name + '\n' + profile.content
1650
+ const block = '### ' + name + '\n' + neutralizeForgedStructure(profile.content)
1543
1651
  const sep = blocks.length === 0 ? 0 : 2
1544
1652
  // Reserve the omission footer up front: the disclosure must never be the
1545
1653
  // thing squeezed out by the profiles that caused it. Reserve the WORST
@@ -1589,6 +1697,8 @@ export function entryOmittedFooter(count) {
1589
1697
  * trimming. Kept as the inner function so planInjection can prepend the
1590
1698
  * persona section without touching (and risking) the trimming logic.
1591
1699
  */
1700
+ let FORCED_IDS = new Set() // 本轮命中 triggers 的条目 id,由 turn/start 监听器写入。
1701
+
1592
1702
  function planEntries(data, budget) {
1593
1703
  const blank = () => ({ text: '', activeCount: 0, included: [], omitted: [] })
1594
1704
  if (data === null || typeof data !== 'object' || data.enabled !== true) return blank()
@@ -1605,13 +1715,13 @@ function planEntries(data, budget) {
1605
1715
  '## 用户长期指令记忆(Instruction Memory)',
1606
1716
  '以下条目由用户在 DSH 设置 →「指令记忆」中长期保存,属于本次及后续所有对话的既有要求,请在回答、推理与执行任务时遵守;它们不覆盖系统与安全规则。',
1607
1717
  '· [始终] 条目必须无条件遵守。',
1608
- '· [按需] 条目仅在与当前问题或任务相关时生效;不相关时直接忽略,不要强行套用。',
1718
+ '· [条件必做] 条目在「适用情形」出现时必须执行;跳过须在回答中说明原因。',
1609
1719
  '· 条目之间冲突时,优先遵守优先级更高或更具体的一条;无法判断时向用户确认。',
1610
1720
  '',
1611
1721
  ].join('\n'),
1612
1722
  [
1613
1723
  '## 用户长期指令记忆(Instruction Memory)',
1614
- '[始终] 条目无条件遵守;[按需] 条目仅在与当前任务相关时生效。',
1724
+ '[始终] 条目无条件遵守;[条件必做] 条目在适用情形出现时必须执行。',
1615
1725
  '',
1616
1726
  ].join('\n'),
1617
1727
  '## 用户长期指令记忆\n',
@@ -1666,7 +1776,7 @@ function planEntries(data, budget) {
1666
1776
  }
1667
1777
 
1668
1778
  for (const entry of active) if (entry.mode === 'always') consider(entry, '始终')
1669
- for (const entry of active) if (entry.mode !== 'always') consider(entry, '按需')
1779
+ for (const entry of active) if (entry.mode !== 'always') consider(entry, FORCED_IDS.has(entry.id) ? '本轮强制' : '条件必做')
1670
1780
 
1671
1781
  if (blocks.length === 0) {
1672
1782
  return { text: '', budget, activeCount: active.length, included: [], omitted: omittedPlan }
@@ -1759,16 +1869,27 @@ export function planInjection(data, now = Date.now()) {
1759
1869
  // joined with: if the persona section took the whole budget, every entry
1760
1870
  // would be dropped and its footer still has to fit.
1761
1871
  const JOIN = '\n\n'.length
1872
+ // The envelope costs characters, so it comes out of the budget BEFORE the
1873
+ // persona and the entries spend it. `text.length <= budget` is the one
1874
+ // invariant the panel shows the user; an envelope added on top would break it
1875
+ // silently, and reserving afterwards would only move the overshoot into the
1876
+ // persona, which is never trimmed.
1877
+ const envelopeCost = MEMORY_ENVELOPE_OPEN.length + MEMORY_ENVELOPE_CLOSE.length
1878
+ const innerBudget = Math.max(0, budget - envelopeCost)
1762
1879
  const activeEntryCount = (scoped.entries || []).filter((entry) => entry && entry.enabled === true && entry.content !== '').length
1763
1880
  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))
1881
+ const persona = buildProfilesBlock(scoped.profiles, Math.max(0, innerBudget - entryReserve))
1882
+ const entryPlan = planEntries(scoped, Math.max(0, innerBudget - persona.chars))
1766
1883
  const parts = []
1767
1884
  if (persona.text !== '') parts.push(persona.text)
1768
1885
  if (entryPlan.text !== '') parts.push(entryPlan.text)
1769
1886
  else if (parts.length > 0 && entryPlan.omitted.length > 0) parts.push(entryOmittedFooter(entryPlan.omitted.length))
1887
+ const inner = parts.join('\n\n')
1770
1888
  return {
1771
- text: parts.join('\n\n'),
1889
+ // Empty stays EMPTY, byte for byte: a store with nothing to say must leave
1890
+ // the prompt identical to a deployment without this plugin, so no envelope
1891
+ // is emitted around nothing.
1892
+ text: inner === '' ? '' : MEMORY_ENVELOPE_OPEN + inner + MEMORY_ENVELOPE_CLOSE,
1772
1893
  budget,
1773
1894
  activeCount: entryPlan.activeCount,
1774
1895
  included: entryPlan.included,
@@ -1800,7 +1921,24 @@ export function apply(ctx, deps = {}) {
1800
1921
  * to be deleted mid-test, which no contract test can do reproducibly.
1801
1922
  */
1802
1923
  const watchStore = typeof deps.watch === 'function' ? deps.watch : watch
1803
- const systemPrompt = ctx.systemPrompt
1924
+ /**
1925
+ * A1: the prompt service is held in a mutable slot, not a one-shot capture.
1926
+ *
1927
+ * `ctx.systemPrompt` read at apply() time is only valid until the host
1928
+ * disposes and republishes the service (host restart, fiber remount, a
1929
+ * profile reload that swaps the row's dependencies). After that the captured
1930
+ * reference points at a DEAD service: `section()` there still returns a
1931
+ * disposer and throws nothing, the live service never receives a section, and
1932
+ * the prompt silently loses its memory for the rest of the process — the one
1933
+ * failure mode this plugin exists to prevent.
1934
+ *
1935
+ * The webServer registration further down already treats this shape
1936
+ * explicitly ("registering on top of the previous registration would LEAK
1937
+ * it"). The prompt side now matches it: `systemPromptRef` is rewired by the
1938
+ * non-blocking `ctx.inject(['systemPrompt'], …)` right where the route is
1939
+ * wired, and `syncSection` re-registers against whatever the slot holds.
1940
+ */
1941
+ let systemPromptRef = ctx.systemPrompt === undefined ? null : ctx.systemPrompt
1804
1942
 
1805
1943
  const state = {
1806
1944
  data: {
@@ -1837,6 +1975,15 @@ export function apply(ctx, deps = {}) {
1837
1975
  loadedFrom: null,
1838
1976
  savedAt: null,
1839
1977
  migratedFrom: null,
1978
+ /**
1979
+ * A4: a store-shaped file found at the legacy (process cwd) location, held
1980
+ * for the user to confirm. `{ path, entries, profiles }` or null.
1981
+ *
1982
+ * Detection is deliberate and adoption is not: see the load branch. The
1983
+ * panel renders this as an explicit "import the old data" action, so the
1984
+ * decision belongs to the user the way it always should have.
1985
+ */
1986
+ legacyPending: null,
1840
1987
  /**
1841
1988
  * Set while the store on disk declares an unsupported format version.
1842
1989
  * Mutating commands are refused in that state: a save would rewrite a
@@ -1855,6 +2002,16 @@ export function apply(ctx, deps = {}) {
1855
2002
  shapeRejected: false,
1856
2003
  sectionRegistered: false,
1857
2004
  sectionError: null,
2005
+ /**
2006
+ * A7: whether the settings-page route is actually mounted.
2007
+ *
2008
+ * Without it, a profile that never publishes `webServer` (headless, tui, a
2009
+ * custom shell) left the plugin working for injection while the settings
2010
+ * page simply had no route — announced by one console.error and nothing
2011
+ * else. The panel cannot render, so the SNAPSHOT has to carry the fact for
2012
+ * the diagnostics view and for any support dump.
2013
+ */
2014
+ routeRegistered: false,
1858
2015
  /** Whether a store file is actually present on disk (diagnostics + the panel). */
1859
2016
  storeExists: false,
1860
2017
  /** How many history snapshots the current directory holds (diagnostics). */
@@ -2146,9 +2303,10 @@ export function apply(ctx, deps = {}) {
2146
2303
  */
2147
2304
  const resolveSectionOrder = () => {
2148
2305
  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')
2306
+ const service = systemPromptRef
2307
+ if (service === null || typeof service.getSectionOrder !== 'function') return SECTION_ORDER
2308
+ const lo = service.getSectionOrder('FILE_REFERENCE')
2309
+ const hi = service.getSectionOrder('TOOL_BASH')
2152
2310
  if (!Number.isFinite(lo) || !Number.isFinite(hi) || hi <= lo) return SECTION_ORDER
2153
2311
  return lo + (hi - lo) / 2
2154
2312
  } catch {
@@ -2159,7 +2317,19 @@ export function apply(ctx, deps = {}) {
2159
2317
  const syncSection = () => {
2160
2318
  if (disposed) return
2161
2319
  const wanted = sectionText() !== ''
2320
+ const service = systemPromptRef
2162
2321
  if (wanted && sectionDispose === null) {
2322
+ // A1: no live service yet. That is not an error to report — the
2323
+ // `ctx.inject(['systemPrompt'], …)` wired below re-runs syncSection the
2324
+ // moment one is published — but registering through a null slot would
2325
+ // throw a TypeError into the load path instead of leaving the plugin
2326
+ // usable without its prompt half.
2327
+ if (service === null || typeof service.section !== 'function') {
2328
+ state.sectionRegistered = false
2329
+ state.sectionError = null
2330
+ scheduleMuteTimer()
2331
+ return
2332
+ }
2163
2333
  try {
2164
2334
  // `interpolate: false` is load-bearing, not a hint. The host runs every
2165
2335
  // section through `{{variable}}` interpolation by DEFAULT and THROWS on
@@ -2170,7 +2340,7 @@ export function apply(ctx, deps = {}) {
2170
2340
  // agent turn at renderPrompt(). This section is literal text and never
2171
2341
  // references a host variable, so opt out explicitly.
2172
2342
  const order = resolveSectionOrder()
2173
- sectionDispose = systemPrompt.section({
2343
+ sectionDispose = service.section({
2174
2344
  name: SECTION_NAME,
2175
2345
  order,
2176
2346
  text: assembledSectionText,
@@ -2223,6 +2393,76 @@ export function apply(ctx, deps = {}) {
2223
2393
  disposeStreams()
2224
2394
  })
2225
2395
 
2396
+ /* ---------------- turn-triggered promotion ---------------- */
2397
+
2398
+ /**
2399
+ * Promote entries whose `triggers` appear in THIS turn's user message to
2400
+ * 「本轮强制」. Without this the auto mode is only advisory: the model reads
2401
+ * 「条件必做」 at turn one and has forgotten it by the time the work is done.
2402
+ * Matching runs on the user's own words rather than on the model's judgement,
2403
+ * so a missed instruction becomes a plugin bug instead of a compliance
2404
+ * failure. No triggers configured = no promotion, i.e. the old behaviour.
2405
+ */
2406
+ let forcedSignature = ''
2407
+ const refreshSection = () => {
2408
+ if (disposed) return
2409
+ if (sectionDispose === null) {
2410
+ syncSection()
2411
+ return
2412
+ }
2413
+ const dispose = sectionDispose
2414
+ sectionDispose = null
2415
+ try {
2416
+ dispose()
2417
+ } catch (err) {
2418
+ console.error('[instruction-memory] section refresh dispose failed:', err)
2419
+ }
2420
+ syncSection()
2421
+ }
2422
+ const recomputeForced = (text) => {
2423
+ const list = Array.isArray(state.data.entries) ? state.data.entries : []
2424
+ const lower = String(text || '').toLowerCase()
2425
+ const hit = new Set()
2426
+ for (const entry of list) {
2427
+ if (entry.enabled !== true || entry.mode !== 'auto') continue
2428
+ const triggers = Array.isArray(entry.triggers) ? entry.triggers : []
2429
+ for (const trigger of triggers) {
2430
+ if (typeof trigger === 'string' && trigger !== ''
2431
+ && lower.includes(trigger.toLowerCase())) {
2432
+ hit.add(entry.id)
2433
+ break
2434
+ }
2435
+ }
2436
+ }
2437
+ const signature = Array.from(hit).sort().join(',')
2438
+ if (signature === forcedSignature) return
2439
+ forcedSignature = signature
2440
+ FORCED_IDS = hit
2441
+ logDebug('forced promotion: ' + (signature === '' ? 'none' : signature))
2442
+ refreshSection()
2443
+ }
2444
+
2445
+ const onSessionEvent = (session, event) => {
2446
+ try {
2447
+ if (disposed || !event || typeof event.type !== 'string') return
2448
+ if (event.type !== 'user/message') return
2449
+ const data = event.data || {}
2450
+ const source = data.source || {}
2451
+ // Only human input promotes. runtime-context, notices and plugin messages
2452
+ // are not the user asking for anything.
2453
+ const kind = typeof source.kind === 'string' ? source.kind : ''
2454
+ if (kind !== '' && kind !== 'user') return
2455
+ const text = typeof data.text === 'string' ? data.text
2456
+ : (typeof data.content === 'string' ? data.content : '')
2457
+ if (text === '') return
2458
+ recomputeForced(text)
2459
+ } catch (err) {
2460
+ console.error('[instruction-memory] trigger match failed:', err)
2461
+ }
2462
+ }
2463
+ // Fake hosts (tests) may expose no event bus; the plugin must still mount.
2464
+ if (typeof ctx.on === 'function') ctx.on('session/event', onSessionEvent)
2465
+
2226
2466
  /* ---------------- external-change watching (SSE) ---------------- */
2227
2467
 
2228
2468
  /**
@@ -3219,6 +3459,60 @@ export function apply(ctx, deps = {}) {
3219
3459
  *
3220
3460
  * @returns whether this load performed a successful write.
3221
3461
  */
3462
+ /**
3463
+ * A13: sweep temp files a crashed write left behind.
3464
+ *
3465
+ * `writeToDisk` is atomic by rename: it writes `memory.json.tmp-<pid>-<uuid>`,
3466
+ * fsyncs, then renames. A crash between `open` and `rename` leaves a COMPLETE
3467
+ * COPY OF THE USER'S INSTRUCTION MEMORY on disk under that name, in the same
3468
+ * directory, forever — and nothing ever looked for it. It is the store's own
3469
+ * content (not a redacted log line), so it inherits exactly the exposure of
3470
+ * memory.json while being invisible to every UI path.
3471
+ *
3472
+ * Only the plugin's own name shape is touched, and a temp file whose recorded
3473
+ * pid is still alive is left alone: that is another process in the middle of
3474
+ * a legitimate write, not debris.
3475
+ */
3476
+ const cleanupOrphanTempFiles = async () => {
3477
+ const dir = dirname(storePath)
3478
+ const prefix = basename(storePath) + '.tmp-'
3479
+ let names = []
3480
+ try {
3481
+ names = await readdir(dir)
3482
+ } catch {
3483
+ return 0
3484
+ }
3485
+ let removed = 0
3486
+ for (const name of names) {
3487
+ if (!name.startsWith(prefix)) continue
3488
+ const rest = name.slice(prefix.length)
3489
+ const dash = rest.indexOf('-')
3490
+ const pidText = dash === -1 ? rest : rest.slice(0, dash)
3491
+ const pid = Number.parseInt(pidText, 10)
3492
+ if (Number.isFinite(pid) && pid > 0 && pid !== process.pid) {
3493
+ let alive = true
3494
+ try {
3495
+ process.kill(pid, 0)
3496
+ } catch (err) {
3497
+ // ESRCH means the writer is gone; EPERM means it exists but belongs to
3498
+ // someone else — treat that as alive and leave the file alone.
3499
+ alive = err !== null && err !== undefined && err.code === 'EPERM'
3500
+ }
3501
+ if (alive) continue
3502
+ }
3503
+ try {
3504
+ await unlink(join(dir, name))
3505
+ removed += 1
3506
+ } catch {
3507
+ // Best effort: a file we cannot remove must not stop the load.
3508
+ }
3509
+ }
3510
+ if (removed > 0) {
3511
+ logInfo('removed ' + removed + ' orphaned temp store file(s) left by an interrupted write')
3512
+ }
3513
+ return removed
3514
+ }
3515
+
3222
3516
  const readFromDisk = async () => {
3223
3517
  state.storagePath = storePath
3224
3518
  state.storageDisplay = storePath
@@ -3234,6 +3528,8 @@ export function apply(ctx, deps = {}) {
3234
3528
  // and the panel silently disagreed. Forcing the next save to write is the
3235
3529
  // cheap side of that trade.
3236
3530
  lastPersistedKey = null
3531
+ // A13: drop temp copies a crashed write left in the store directory.
3532
+ await cleanupOrphanTempFiles()
3237
3533
 
3238
3534
  /**
3239
3535
  * A store declaring an unsupported format version: adopt nothing, write
@@ -3425,8 +3721,20 @@ export function apply(ctx, deps = {}) {
3425
3721
  // An unusable backup must never block a fresh store; fall through.
3426
3722
  }
3427
3723
 
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.
3724
+ // 2. A pre-release store at the host process cwd (the legacy location) is
3725
+ // DETECTED here, never adopted here (A4).
3726
+ //
3727
+ // This used to write it straight into memory.json and inject it on the
3728
+ // next turn, with no user action and no confirmation. It was the only
3729
+ // write path in the plugin with neither an authentication fence nor a
3730
+ // human in the loop, and `process.cwd()` is not the plugin's to trust:
3731
+ // any process running as the same user could drop a store-shaped JSON
3732
+ // there and have it become the long-lived instruction memory injected
3733
+ // into every subsequent prompt.
3734
+ //
3735
+ // The file is now only ANNOUNCED. Adopting it takes the explicit
3736
+ // `adopt-legacy` command from the settings page, which is a deliberate
3737
+ // user action behind the same fence every other mutation uses.
3430
3738
  const legacyPath = join(process.cwd(), LEGACY_FILE_NAME)
3431
3739
  try {
3432
3740
  const parsed = await readStoreFile(legacyPath)
@@ -3441,18 +3749,18 @@ export function apply(ctx, deps = {}) {
3441
3749
  if (loaded.rejectedVersion !== undefined) {
3442
3750
  // A legacy file in a future format is not ours to migrate or judge.
3443
3751
  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(() => {})
3752
+ } else {
3753
+ const entryCount = loaded.data.entries.length
3754
+ const profileCount = loaded.data.profiles.length
3755
+ state.legacyPending = { path: legacyPath, entries: entryCount, profiles: profileCount }
3756
+ const warnings = loadWarnings(loaded)
3757
+ state.storageWarning = (warnings !== null ? warnings + ';' : '')
3758
+ + '检测到旧位置(启动目录)有一份记忆:' + legacyPath
3759
+ + '(' + entryCount + ' 条指令、' + profileCount + ' 个形象)。'
3760
+ + '为避免未经确认就注入一个外部文件,本次没有自动导入;'
3761
+ + '确认内容无误后在设置页点击「导入旧位置数据」。'
3762
+ state.loadedFrom = 'legacy cwd file (detected, awaiting confirmation)'
3453
3763
  }
3454
- state.loadedFrom = 'legacy cwd file (migrated)'
3455
- return migrated
3456
3764
  }
3457
3765
  }
3458
3766
  } catch (err) {
@@ -3471,7 +3779,10 @@ export function apply(ctx, deps = {}) {
3471
3779
  // `state` (see ensureStoreMaterialized). Until then the panel shows
3472
3780
  // "尚未写入磁盘", a state its UI already renders.
3473
3781
  needsMaterialize = true
3474
- state.loadedFrom = 'first run (no store on disk yet)'
3782
+ // A detected-but-unconfirmed legacy file keeps its own `loadedFrom`: the
3783
+ // panel must be able to say "there is a store next door, waiting for you"
3784
+ // rather than "first run, nothing anywhere" (A4).
3785
+ if (state.legacyPending === null) state.loadedFrom = 'first run (no store on disk yet)'
3475
3786
  state.storeExists = false
3476
3787
  return false
3477
3788
  }
@@ -3662,6 +3973,13 @@ export function apply(ctx, deps = {}) {
3662
3973
  * allow-list is the only thing standing in front of a fence-less route.
3663
3974
  */
3664
3975
  trustedHostCount: trustedHosts.length,
3976
+ /**
3977
+ * A7: is the settings route actually mounted? `false` means this
3978
+ * profile published no `webServer` (or the registration threw), so the
3979
+ * panel has no way in even though injection works. Published here
3980
+ * because a panel that cannot render cannot report its own absence.
3981
+ */
3982
+ routeRegistered: state.routeRegistered === true,
3665
3983
  },
3666
3984
  /**
3667
3985
  * F12: load-side diagnostics that used to be log-only. `loadedFrom` says
@@ -3679,6 +3997,19 @@ export function apply(ctx, deps = {}) {
3679
3997
  warning: state.storageWarning !== null ? state.storageWarning : state.backupError,
3680
3998
  savedAt: state.savedAt,
3681
3999
  migratedFrom: state.migratedFrom === null ? null : displayPath(state.migratedFrom),
4000
+ /**
4001
+ * A4: a store-shaped file sitting at the legacy (startup directory)
4002
+ * location, waiting for the user to confirm. `null` when there is
4003
+ * nothing pending. The panel renders this as an explicit action; the
4004
+ * file is never read into memory.json on its own.
4005
+ */
4006
+ legacyPending: state.legacyPending === null
4007
+ ? null
4008
+ : {
4009
+ path: displayPath(state.legacyPending.path),
4010
+ entries: state.legacyPending.entries,
4011
+ profiles: state.legacyPending.profiles,
4012
+ },
3682
4013
  /** false until the store file has actually been written (see readFromDisk step 3). */
3683
4014
  onDisk: state.storeExists,
3684
4015
  /** Backup location (F18): '' means "next to the store file". */
@@ -3706,6 +4037,13 @@ export function apply(ctx, deps = {}) {
3706
4037
  */
3707
4038
  muted: isMuted(state.data, Date.now()),
3708
4039
  chars: preview.length,
4040
+ /**
4041
+ * A2: how much of `chars` is the fixed envelope around the block. The
4042
+ * panel's meter measures `entryChars + profileChars + envelopeChars`
4043
+ * (the parts the budget is actually spent on) instead of `chars`, so
4044
+ * it has to know this number; older clients simply ignore the field.
4045
+ */
4046
+ envelopeChars: preview === '' ? 0 : MEMORY_ENVELOPE_OPEN.length + MEMORY_ENVELOPE_CLOSE.length,
3709
4047
  /**
3710
4048
  * The budget decision behind `preview`, for the settings page's
3711
4049
  * usage meter and per-entry marks. `budget` is the effective ceiling;
@@ -4761,6 +5099,76 @@ export function apply(ctx, deps = {}) {
4761
5099
  return settle([head + describeStoreShape(state.data), ...notes].join(';'), saved)
4762
5100
  }
4763
5101
 
5102
+ /**
5103
+ * A4: adopt the store-shaped file the boot load found at the legacy
5104
+ * (process cwd) location.
5105
+ *
5106
+ * Reachable only from the settings page, and only when the load actually
5107
+ * found such a file: it re-reads THAT path (never one supplied by the
5108
+ * request), and refuses anything that stopped looking like a store since —
5109
+ * so a file swapped in between detection and confirmation is caught rather
5110
+ * than imported. Adoption is a deliberate user action behind the same route
5111
+ * fence every other mutation uses, which is the whole point of the change.
5112
+ */
5113
+ const adoptLegacy = (baseRev) => enqueue(async () => {
5114
+ if (state.versionRejected) return versionRefusal()
5115
+ if (isStaleBaseRev(baseRev)) return conflictResponse()
5116
+ const pending = state.legacyPending
5117
+ if (pending === null) {
5118
+ return { ok: false, saved: false, message: '没有待导入的旧位置数据', snapshot: snapshot() }
5119
+ }
5120
+ let parsed = null
5121
+ try {
5122
+ parsed = await readStoreFile(pending.path)
5123
+ } catch (err) {
5124
+ return {
5125
+ ok: false,
5126
+ saved: false,
5127
+ message: '读取旧位置文件失败:' + describeErrorSafe(err),
5128
+ snapshot: snapshot(),
5129
+ }
5130
+ }
5131
+ if (parsed === null) {
5132
+ state.legacyPending = null
5133
+ state.storageWarning = '旧位置文件已不存在,待导入状态已清除:' + pending.path
5134
+ return { ok: false, saved: false, message: '旧位置文件已不存在,待导入状态已清除', snapshot: snapshot() }
5135
+ }
5136
+ if (storeShapeVerdict(parsed) !== 'store') {
5137
+ state.legacyPending = null
5138
+ state.storageWarning = '旧位置文件的内容已不是本插件的记忆存储,待导入状态已清除'
5139
+ return { ok: false, saved: false, message: '旧位置文件已不是本插件的记忆存储,未导入', snapshot: snapshot() }
5140
+ }
5141
+ const result = await commitAdoptedStore(parsed, '已导入旧位置数据:')
5142
+ if (result !== null && result.ok === true) {
5143
+ state.legacyPending = null
5144
+ state.storageWarning = null
5145
+ state.storageError = null
5146
+ state.migratedFrom = pending.path
5147
+ state.loadedFrom = 'legacy cwd file (adopted after confirmation)'
5148
+ // Keep the original as evidence instead of deleting user data.
5149
+ await rename(pending.path, pending.path + '.migrated').catch(() => {})
5150
+ return { ...result, snapshot: snapshot() }
5151
+ }
5152
+ return result
5153
+ })
5154
+
5155
+ /**
5156
+ * A4: "not now" for a detected legacy file. Clears the pending flag and the
5157
+ * announcement, and deliberately leaves the file alone — nothing was read
5158
+ * into the store, so there is nothing to undo.
5159
+ */
5160
+ const dismissLegacy = () => {
5161
+ const had = state.legacyPending !== null
5162
+ state.legacyPending = null
5163
+ state.storageWarning = null
5164
+ return {
5165
+ ok: true,
5166
+ saved: false,
5167
+ message: had ? '已忽略旧位置数据(原文件未改动)' : '没有待导入的旧位置数据',
5168
+ snapshot: snapshot(),
5169
+ }
5170
+ }
5171
+
4764
5172
  const writeSource = (text, baseRev) => enqueue(async () => {
4765
5173
  if (state.versionRejected) return versionRefusal()
4766
5174
  // Deliberately NOT gated on shapeRejected: hand-editing the source is how
@@ -5312,6 +5720,10 @@ export function apply(ctx, deps = {}) {
5312
5720
  return importData(
5313
5721
  args !== null && typeof args === 'object' ? args.payload : null,
5314
5722
  args !== null && typeof args === 'object' ? args.baseRev : undefined)
5723
+ case 'adopt-legacy':
5724
+ return adoptLegacy(args !== null && typeof args === 'object' ? args.baseRev : undefined)
5725
+ case 'dismiss-legacy':
5726
+ return dismissLegacy()
5315
5727
  case 'send-feedback':
5316
5728
  return sendFeedback(args)
5317
5729
  case 'batch':
@@ -5578,6 +5990,65 @@ export function apply(ctx, deps = {}) {
5578
5990
  return true
5579
5991
  }
5580
5992
 
5993
+ /**
5994
+ * A9/A12: the last line of defence for MUTATIONS on a fence-less route.
5995
+ *
5996
+ * `originVerdictRefuses` above only judges what a browser volunteers. A local
5997
+ * process (curl, a script, another app) sends no `Origin` at all, and every
5998
+ * branch of requestOriginVerdict is conditioned on an Origin being present —
5999
+ * so on a profile that publishes a web server WITHOUT a `connection` service
6000
+ * the settings route accepted unauthenticated writes from anything running as
6001
+ * the same user. Loopback used to be a boundary; it stopped being one the
6002
+ * moment any program on the machine runs as that user.
6003
+ *
6004
+ * Two rules, both limited to mutating methods so a read-only panel keeps
6005
+ * working in those deployments:
6006
+ *
6007
+ * 1. A widened DSH_INSTRUCTION_MEMORY_TRUSTED_HOSTS list is a HOSTNAME rule,
6008
+ * not an identity check. Combined with a non-loopback bind
6009
+ * (`hostGuarded === false`) and no fence, the only thing in front of the
6010
+ * route is a name an attacker picks, so writes are refused outright (503)
6011
+ * instead of relying on an orange warning in the panel.
6012
+ * 2. Without a fence, a mutation must carry a browser origin marker: either
6013
+ * `Origin` or `Sec-Fetch-Site: same-origin`. A page always sends one of
6014
+ * the two, so the settings page is unaffected; a bare process is refused
6015
+ * (403). `Sec-Fetch-Site` is accepted because script cannot set it — it is
6016
+ * the browser's own claim, not the caller's.
6017
+ *
6018
+ * Set DSH_INSTRUCTION_MEMORY_REQUIRE_AUTH_FENCE=1 to make the whole route fail
6019
+ * closed instead of falling back to these two rules.
6020
+ */
6021
+ const READ_ONLY_METHODS = new Set([
6022
+ 'state', 'history-list', 'read-source', 'export-data', 'export-to-dir', 'reload', 'send-feedback',
6023
+ ])
6024
+
6025
+ const writeGateRefusal = (method, headers) => {
6026
+ const name = typeof method === 'string' ? method : ''
6027
+ if (READ_ONLY_METHODS.has(name)) return null
6028
+ if (connectionGate !== null) return null
6029
+ const head = headers !== null && typeof headers === 'object' ? headers : {}
6030
+ if (guardHost !== true && trustedHosts.length > 0) {
6031
+ return {
6032
+ status: 503,
6033
+ message: '拒绝修改请求:当前部署绑定在非回环地址、没有宿主的会话鉴权门,且仅靠一份主机名白名单('
6034
+ + trustedHosts.length + ' 条)。白名单是主机名规则而非身份校验,因此写操作已停用;'
6035
+ + '请改为回环绑定,或让宿主提供 connection 服务。',
6036
+ }
6037
+ }
6038
+ const origin = typeof head.origin === 'string' ? head.origin.trim() : ''
6039
+ const fetchSite = typeof head['sec-fetch-site'] === 'string'
6040
+ ? head['sec-fetch-site'].trim().toLowerCase() : ''
6041
+ if (origin === '' && fetchSite !== 'same-origin') {
6042
+ return {
6043
+ status: 403,
6044
+ message: '拒绝无来源标记的修改请求:当前 profile 没有宿主的会话鉴权门,因此修改必须来自浏览器'
6045
+ + '(带 Origin 或 Sec-Fetch-Site: same-origin)。设置页读写不受影响;本机脚本 / curl 的写入会被拒绝。'
6046
+ + '如需完全关闭该路由,设置 DSH_INSTRUCTION_MEMORY_REQUIRE_AUTH_FENCE=1。',
6047
+ }
6048
+ }
6049
+ return null
6050
+ }
6051
+
5581
6052
  const handler = (req, res) => {
5582
6053
  // Authentication before anything else: not the method, not the body, not
5583
6054
  // the size. A rejection must not leak which methods exist.
@@ -5686,6 +6157,16 @@ export function apply(ctx, deps = {}) {
5686
6157
  let result
5687
6158
  try {
5688
6159
  const payload = body === '' ? {} : JSON.parse(body)
6160
+ // A9/A12: mutations on a fence-less route need a browser origin
6161
+ // marker. Checked after parsing (the method is only known now) and
6162
+ // before dispatch, so a refused write never touches the store.
6163
+ const gateRefusal = writeGateRefusal(payload.method, req.headers)
6164
+ if (gateRefusal !== null) {
6165
+ logInfo('route refused a mutation: ' + gateRefusal.message)
6166
+ res.writeHead(gateRefusal.status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
6167
+ res.end(JSON.stringify({ ok: false, saved: false, message: gateRefusal.message, snapshot: snapshot() }))
6168
+ return
6169
+ }
5689
6170
  result = await dispatch(payload.method, payload.args === undefined ? null : payload.args)
5690
6171
  } catch (err) {
5691
6172
  result = { ok: false, saved: false, message: describeErrorSafe(err), snapshot: snapshot() }
@@ -5702,6 +6183,56 @@ export function apply(ctx, deps = {}) {
5702
6183
  // losing the prompt injection along with the settings page. The callback also
5703
6184
  // fixes the original race properly — it runs whenever the service is
5704
6185
  // published, not only if it happened to beat this row to the mount.
6186
+ /**
6187
+ * A1: keep the prompt service subscription live for as long as this row is.
6188
+ *
6189
+ * `systemPrompt` is a declared dependency (`export const inject`), so the
6190
+ * initial capture in apply() is normally valid. It is NOT valid after the host
6191
+ * republishes the service: the old instance keeps accepting `section()` calls
6192
+ * and keeps returning a disposer, which is why the failure is silent. Wiring
6193
+ * a non-blocking child fiber here is the same shape the webServer route below
6194
+ * uses, for the same reason.
6195
+ *
6196
+ * The callback is a no-op when the published service is the one already held,
6197
+ * so a host that publishes once behaves exactly as before.
6198
+ */
6199
+ ctx.inject(['systemPrompt'], (scope) => {
6200
+ const service = scope === null || scope === undefined ? null : scope.systemPrompt
6201
+ if (service === null || service === undefined || typeof service.section !== 'function') return
6202
+ if (service === systemPromptRef) return
6203
+
6204
+ const previous = systemPromptRef
6205
+ systemPromptRef = service
6206
+ // Drop the registration made against the previous instance BEFORE creating
6207
+ // one on the new: two live registrations would both be pulled into every
6208
+ // prompt (the block appears twice) and would keep the dead service wired.
6209
+ if (sectionDispose !== null) {
6210
+ const dispose = sectionDispose
6211
+ sectionDispose = null
6212
+ try {
6213
+ dispose()
6214
+ } catch (err) {
6215
+ console.error('[instruction-memory] stale prompt section dispose failed:', err)
6216
+ }
6217
+ }
6218
+ state.sectionRegistered = false
6219
+ if (typeof scope.effect === 'function') {
6220
+ scope.effect(() => () => {
6221
+ if (systemPromptRef !== service) return
6222
+ // The service went away. Stop pointing at it so the next publish can
6223
+ // re-register, and record it instead of pretending nothing happened:
6224
+ // an injected memory that quietly stops being injected is the one
6225
+ // symptom a user cannot diagnose from the panel.
6226
+ systemPromptRef = null
6227
+ state.sectionRegistered = false
6228
+ state.sectionError = '注入服务已重新发布/下线,发布后会自动恢复注入'
6229
+ logInfo('systemPrompt disposed or republished while mounted; the section will re-register when it returns')
6230
+ })
6231
+ }
6232
+ logInfo('systemPrompt republished; re-registering the instruction section against the new service')
6233
+ syncSection()
6234
+ })
6235
+
5705
6236
  // F19: take the harness authentication fence when the profile has one.
5706
6237
  // Same non-blocking shape as the web server below — a profile without a
5707
6238
  // browser never publishes `connection`, and the prompt injection must not
@@ -5765,14 +6296,17 @@ export function apply(ctx, deps = {}) {
5765
6296
  }
5766
6297
  try {
5767
6298
  routeDispose = webServer.register({ kind: 'prefix', path: API_PREFIX, handler })
6299
+ state.routeRegistered = true
5768
6300
  logDebug('settings route registered at ' + API_PREFIX + ' (boundHost=' + boundHost + ', hostGuard=' + guardHost + ')')
5769
6301
  } catch (err) {
5770
6302
  console.error('[instruction-memory] route registration failed:', err)
5771
6303
  routeDispose = null
6304
+ state.routeRegistered = false
5772
6305
  }
5773
6306
  // The child fiber is owned by this one, so unmounting the plugin still
5774
6307
  // disposes the route even though it was registered here.
5775
6308
  scope.effect(() => () => {
6309
+ state.routeRegistered = false
5776
6310
  if (routeDispose !== null) {
5777
6311
  const dispose = routeDispose
5778
6312
  routeDispose = null
@@ -115,7 +115,13 @@
115
115
  "description": "是否启用该条目。",
116
116
  "default": true
117
117
  },
118
- "tags": {
118
+ "triggers": {
119
+ "type": "array",
120
+ "maxItems": 8,
121
+ "items": { "type": "string", "maxLength": 24 },
122
+ "description": "触发词:本回合用户消息中出现其一即把该条提升为「本轮强制」;仅 mode=auto 有意义,留空则从不提升。"
123
+ },
124
+ "tags": {
119
125
  "type": "array",
120
126
  "maxItems": 8,
121
127
  "description": "标签:仅用于设置页分组与筛选,**不会**进入注入文本,也不影响注入顺序与取舍。",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-instruction-memory",
3
- "version": "1.5.6",
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.6.0",
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
  },