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 +112 -2
- package/README.md +5 -2
- package/lib/client.js +75 -7
- package/lib/index.js +564 -30
- package/memory.schema.json +7 -1
- package/package.json +7 -2
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
|
-
|
|
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
|
-
>
|
|
232
|
+
> **1.5.7 起不再自动迁移**:启动时只**检出**该文件并在设置页提示(「导入旧位置数据 / 忽略」两项),
|
|
233
|
+
> 由用户确认后才写入正式位置,并把原文件改名为 `*.migrated` 保留。原因是这条旧路径**既没有鉴权门、
|
|
234
|
+
> 也没有人在回路里**:`process.cwd()` 不属于插件,任何同权限进程在那里放一份 store 形状的 JSON,
|
|
235
|
+
> 下一轮就会被注入成"用户的长期指令"。检出状态在 `storage.legacyPending` 里,未确认前不读入、不注入。
|
|
233
236
|
|
|
234
237
|
**当前存储格式为版本 2**(1.1.0 及更早写的是版本 1,见下文「多情境配置集」)。
|
|
235
238
|
两者都能载入:版本 1 的文件会**平滑迁移**——其中的条目与用户形象全部归入一个名为
|
|
@@ -820,7 +823,7 @@ DSH Go 给官方 Web 服务器包了一层**构建期补丁** `runtime/patches/r
|
|
|
820
823
|
|
|
821
824
|
| 位置 | 谁读它 | 作用 |
|
|
822
825
|
| --- | --- | --- |
|
|
823
|
-
| **`peerDependencies`**(`@deepseek-ai/dsh-system-prompt`) | **宿主**:`evaluatePluginCompatibility()`(`dsh-app-boot
|
|
826
|
+
| **`peerDependencies`**(`@deepseek-ai/dsh-system-prompt`) | **宿主**:`evaluatePluginCompatibility()`(`dsh-app-boot`),在**安装**与 **profile 启动**时执行 | **门禁之一**,不是唯一。1.5.7 起两处实测结论必须同时写在这里:① 门禁读的是**宿主自身版本**(`getDshRuntimeVersion()` → `dsh-app-boot` 的 package.json),并用 `{ includePrerelease: true }` 求值,所以预发布行不会被误判;② 在 `0.2.2-rc.3` 的宿主实现里**存在 `engines.dsh` 时 `peerDependencies` 的区间被忽略**(`dsh-app-boot/src/plugin-compatibility.ts:52`)——也就是说这段 peer 区间可能完全不参与判定,只用来在报错时点名。本仓库仍让三处**逐字节同值**,就是为了不依赖"到底哪一处生效"。这条已从"真正的门禁"更正:**能证明它生效的只有实测,不能靠读声明推断** |
|
|
824
827
|
| `dsh.engines.dsh` | 插件市场(第三方,服务端读取) | 市场卡片上的兼容性判定与展示 |
|
|
825
828
|
| `dsh.compatibility.dsh` | 未找到读取方(官方文档与 DSH 树内均无) | 仅作人类可读的重复声明 |
|
|
826
829
|
|
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': '
|
|
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
|
-
|
|
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
|
|
464
|
-
return cutText(
|
|
494
|
+
const normalised = stripInvisible(value.split('\r\n').join('\n').split('\r').join('\n'))
|
|
495
|
+
return cutText(normalised.trim(), max)
|
|
465
496
|
}
|
|
466
497
|
|
|
467
498
|
/**
|
|
@@ -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('
|
|
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,
|
|
1765
|
-
const entryPlan = planEntries(scoped, Math.max(0,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2150
|
-
|
|
2151
|
-
const
|
|
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 =
|
|
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.
|
|
3429
|
-
//
|
|
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
|
-
|
|
3445
|
-
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
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
|
-
|
|
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
|
package/memory.schema.json
CHANGED
|
@@ -115,7 +115,13 @@
|
|
|
115
115
|
"description": "是否启用该条目。",
|
|
116
116
|
"default": true
|
|
117
117
|
},
|
|
118
|
-
"
|
|
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.
|
|
4
|
-
"description": "User-only instruction memory for DSH: long-term instructions maintained in the settings UI and auto-injected into every conversation — the model has no write
|
|
3
|
+
"version": "1.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
|
},
|