dsh-instruction-memory 1.0.8 → 1.0.10
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/README.md +102 -17
- package/lib/client.js +13 -1
- package/lib/index.js +481 -121
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -64,20 +64,81 @@ DSH 就会得到第二份空记忆,看起来像"记忆消失"。
|
|
|
64
64
|
> 首次启动新版本时会自动迁移到上述位置,并把原文件改名为 `*.migrated` 保留。
|
|
65
65
|
|
|
66
66
|
该文件可手工编辑,改完点设置页的「重新载入」即可读回。每次保存先写临时文件再原子替换,
|
|
67
|
-
进程崩溃不会留下损坏的半截 `memory.json
|
|
68
|
-
若 `memory.json`
|
|
69
|
-
|
|
70
|
-
|
|
67
|
+
进程崩溃不会留下损坏的半截 `memory.json`;保存前把上一版**复制**为 `memory.json.bak`,
|
|
68
|
+
若 `memory.json` 解析失败会自动临时回退到该备份并明确提示。
|
|
69
|
+
|
|
70
|
+
> **备份为什么是"复制"而不是"改名"**:曾经的做法是先把 `memory.json` 改名成 `.bak`、
|
|
71
|
+
> 再改名临时文件顶上。这两步之间 `memory.json` 是不存在的——只要第二步失败(文件被占用、
|
|
72
|
+
> 磁盘满、杀软扫描),主路径就一直空着,下次启动找不到文件就会**建一份空存储**,用户的
|
|
73
|
+
> 记忆看起来凭空消失,而真实数据其实好好躺在 `.bak` 里。改为复制后主文件在原子替换成功
|
|
74
|
+
> 前始终待在原位;同时启动时若发现 `memory.json` 缺失而 `.bak` 可用,会**从备份恢复并写回**
|
|
75
|
+
> 主路径,而不是从零开始。
|
|
76
|
+
|
|
77
|
+
若手工编辑后的文件有条目为空或超过 200 条上限,设置页会明确提示哪些条目未能载入
|
|
78
|
+
(它们将在下次保存时从文件中移除),不会静默丢弃。导入时若某条超出长度上限
|
|
79
|
+
(标题 120 / 内容 6000 / 适用场景 200 字符),会明确提示**有几条、哪个字段**被截断,
|
|
80
|
+
而不是悄悄改短。
|
|
71
81
|
|
|
72
82
|
设置页支持**导出 / 导入**:导出为自带说明的 JSON 文件;导入采用**合并语义**——按 id 与
|
|
73
|
-
|
|
83
|
+
「标题+内容」去重,只新增、绝不覆盖或清空现有条目。请求体上限由条目上限推导
|
|
84
|
+
(`MAX_ENTRIES × (MAX_CONTENT + MAX_TITLE + MAX_WHEN + 200) × 2 + 100000`),保证**本插件
|
|
85
|
+
导出的文件一定能被自己导回**——早先写死的 1,000,000 小于满配导出(200 × 6000 = 1.2 M 字符),
|
|
86
|
+
导致满配记忆导出后无法导入。
|
|
87
|
+
|
|
88
|
+
## 无 Web 服务器时依然生效
|
|
89
|
+
|
|
90
|
+
`systemPrompt` 是本插件唯一的硬依赖。**Web 服务器不是前置条件**:设置页那部分路由通过
|
|
91
|
+
非阻塞的 `ctx.inject(['webServer'], …)` 子 fiber 注册,服务出现时自动挂上;没有 Web 服务器
|
|
92
|
+
的 profile(`headless`、`tui`)里,系统提示词注入照常工作,只是设置页不可用。
|
|
93
|
+
|
|
94
|
+
> 这里曾声明 `inject = ['systemPrompt', 'webServer']`。声明式依赖是**前置条件**,于是没有
|
|
95
|
+
> Web 服务器的 profile 里整行插件永远停在 pending、`apply()` 根本不执行——用户同时失去设置页
|
|
96
|
+
> **和**记忆注入,而记忆本身根本不需要 Web 服务器。非阻塞注册同时把最初那个竞态修得更彻底:
|
|
97
|
+
> 回调在服务发布时就会执行,而不只是在它"抢在插件挂载之前"发布时才生效。
|
|
98
|
+
|
|
99
|
+
## 诊断日志
|
|
100
|
+
|
|
101
|
+
插件此前**只有失败才有输出**:每次载入、保存、注入成功都不留任何记录,只有失败分支走
|
|
102
|
+
`console.error`。于是"记忆到底有没有在干活"完全无法从日志回答。现在它挂载时写一行
|
|
103
|
+
`info`,过程细节写 `debug`(走宿主的 `ctx.logger`):
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
[instruction-memory] mounted: store=<DSH_HOME>\instruction-memory\memory.json from=memory.json entries=2 enabled=true injected=437 chars
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
挂载行含**数据来源**(`memory.json` / `memory.json.bak (main file missing)` / `legacy cwd file (migrated)`
|
|
110
|
+
/ `first run (empty store created)`)、条目数、总开关状态、实际注入字符数,以及读取错误。
|
|
111
|
+
|
|
112
|
+
**这是纯日志,绝不影响注入内容**:同一份存储在"有 logger"和"没有 logger"下渲染出的文本
|
|
113
|
+
**逐字节相同**(`verify.mjs` 有断言锁定这一点)。`debug` 级别由宿主日志级别过滤;
|
|
114
|
+
没有 logger 服务的环境下需要 `DSH_INSTRUCTION_MEMORY_DEBUG=1` 才会输出到标准错误。
|
|
115
|
+
|
|
116
|
+
## 请求来源防护(Host / Origin)
|
|
117
|
+
|
|
118
|
+
设置页路由之所以要求 `content-type: application/json`,是因为 HTML 表单无法伪造 JSON body,
|
|
119
|
+
而跨源 `fetch` 带 JSON 会先在 preflight 失败。**这两条防线都建立在"浏览器认为这是跨源"之上,
|
|
120
|
+
DNS 重绑定恰好推翻了它**:`evil.com` 解析到 `127.0.0.1` 之后,页面与服务器在浏览器眼里是
|
|
121
|
+
**同源**——不发 preflight、JSON 也合法,于是任何网页都能改写用户那份"注入到此后每一轮
|
|
122
|
+
对话"的长期指令。此时唯一无法伪造的就是浏览器发出的 `Host` 头。
|
|
123
|
+
|
|
124
|
+
因此当服务器绑定在回环地址(默认)时,路由**要求 `Host` 是回环**,另外校验 `Origin`(若存在)
|
|
125
|
+
与 `Host` 同源。命中即 **403**,并记一行日志:
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
[instruction-memory] route rejected a request: Host 头不是回环地址(可能是 DNS 重绑定攻击):evil.com:8080
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
- 绑定 `--host 0.0.0.0`(主动对所有网卡开放)时 Host 校验**自动关闭**——那种情况下无法预知合法
|
|
132
|
+
Host,强行限制只会打断所有正常客户端;`Origin` 校验仍然生效。
|
|
133
|
+
- 无 `Host` 的 HTTP/1.1 请求由 Node 的 HTTP 解析器先以 400 拒掉,根本到不了路由。
|
|
134
|
+
- 端口不参与 `Origin` 比对(反向代理可能改写端口),主机名才是重绑定攻击的对象。
|
|
74
135
|
|
|
75
136
|
## 兼容性
|
|
76
137
|
|
|
77
138
|
`dsh.compatibility.dsh` 与 `dsh.engines.dsh` 声明为同一串:
|
|
78
139
|
|
|
79
140
|
```
|
|
80
|
-
>=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.2.0-0 || >=0.2.0-0
|
|
141
|
+
>=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.0-0
|
|
81
142
|
```
|
|
82
143
|
|
|
83
144
|
**实测范围**:以下 5 个宿主版本已在 **Windows / Node 22** 上逐个真实启动验证(每个版本都确认了
|
|
@@ -85,7 +146,8 @@ DSH 就会得到第二份空记忆,看起来像"记忆消失"。
|
|
|
85
146
|
|
|
86
147
|
`0.1.3-alpha.2` · `0.1.5-alpha.1` · `0.1.5-alpha.2` · `0.1.5-rc.1` · `0.1.5-rc.2`
|
|
87
148
|
|
|
88
|
-
此外 `0.1.6-alpha.2` 完成了完整的装载与兼容性判定核验(`host-range-test.mjs`
|
|
149
|
+
此外 `0.1.6-alpha.2` 与 `0.1.7-rc.2` 完成了完整的装载与兼容性判定核验(`host-range-test.mjs`
|
|
150
|
+
的固定矩阵覆盖它们,且该脚本会**读取本机实际安装的宿主版本**做实时断言)。
|
|
89
151
|
|
|
90
152
|
**为什么写成分段形式**:node-semver 规定"候选版本带预发布标签时,区间里必须存在同一个
|
|
91
153
|
major.minor.patch 且自身带预发布标签的比较符"。这条规则是**集合级**的——一个 `||` 分支最多
|
|
@@ -98,16 +160,39 @@ major.minor.patch 且自身带预发布标签的比较符"。这条规则是**
|
|
|
98
160
|
在把 `0.1.3` 当 token 时会放行 `0.1.3-alpha.0/1`。
|
|
99
161
|
|
|
100
162
|
⚠️ **一个必须知道的上限**:**未声明的未来 minor 的预发布版本,无法被任何范围覆盖。**
|
|
101
|
-
`0.1.
|
|
102
|
-
`^0.1.0` 无法匹配 `0.1.
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
163
|
+
`0.1.8-rc.1` 这类版本 tuple 与所有比较符都不同,在 npm 语义下**永远匹配不到**——与
|
|
164
|
+
`^0.1.0` 无法匹配 `0.1.8-rc.1` 是同一个限制。正式版(`0.1.8`、`0.2.0`、`1.0.0` 等)不受影响。
|
|
165
|
+
|
|
166
|
+
**这个限制无法用代码消除,但它的后果已经被消掉了。** 范围现在是**生成出来的**:
|
|
167
|
+
`host-range-test.mjs` 顶部的 `COVERED_LINES` 是唯一事实来源,package.json 里那串长字符串必须
|
|
168
|
+
逐字节等于由它生成的结果(有专门的漂移断言),所以手改长串导致"枚举漏了一段"这类错误不可能
|
|
169
|
+
再发生——DSH 发新 minor 时只需在列表末尾加一行。
|
|
170
|
+
|
|
171
|
+
同时**实时守卫**(直接读本机实际安装的宿主版本)在发现未覆盖的宿主时,会打印**精确到行**的
|
|
172
|
+
修复配方,并且在打印前**先验证该配方真的能让那个版本被放行**:
|
|
173
|
+
|
|
174
|
+
```
|
|
175
|
+
FAIL LIVE GUARD: the installed host 0.1.8-rc.1 is admitted by both modes -> ...
|
|
176
|
+
Fix: append { floor: '0.1.8-alpha.0', below: '0.2.0-0' } to COVERED_LINES
|
|
177
|
+
and set the preceding line's below to '0.1.8-0'
|
|
178
|
+
[verified: that edit admits 0.1.8-rc.1]
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
这份配方本身也有测试:`host-range-test.mjs` 会拿 `0.1.8-rc.1` 走一遍"确认当前范围拒收 →
|
|
182
|
+
套用配方 → 确认放行 → 确认下方版本仍被拒收 → 确认所有已覆盖版本仍被放行"。配方失效会先失败在
|
|
183
|
+
测试里,而不是失败在用户的安装上。
|
|
184
|
+
|
|
185
|
+
**历史修复说明**(同一个 bug 出现过两次,值得记下来):
|
|
186
|
+
|
|
187
|
+
- 1.0.7 及以前,范围末段是 `>=0.1.5-alpha.1`,使宿主 `0.1.6-alpha.2` 在 npm **默认**语义下
|
|
188
|
+
被判为**不兼容**(预发布门禁要求 tuple 相同:`0.1.5 ≠ 0.1.6`,`>=` 的大小比较根本走不到)。
|
|
189
|
+
- 1.0.8 改为逐段枚举,但只枚举到 `0.1.6`。DSH 随后发布 `0.1.7-rc.2`,**同一个假阴性再次出现**:
|
|
190
|
+
范围里最后一个预发布 tuple 是 `0.1.6`,而 `0.1.7 ≠ 0.1.6`。1.0.9 补上 `0.1.7` 段。
|
|
191
|
+
- 1.0.10 把枚举抽成 `COVERED_LINES` 单一事实来源 + 漂移/可达/递增断言,并让实时守卫给出
|
|
192
|
+
**自验证**的修复配方——这个 bug 的**成因**(手改长串)从此被消掉。
|
|
193
|
+
|
|
194
|
+
两次都因为插件市场传入 `includePrerelease: true` 而侥幸放行,但任何标准 semver 判定
|
|
195
|
+
(`npm` / `pnpm` 的依赖解析)都会拒绝。现在 `host-range-test.mjs` 同时具备固定矩阵与实时守卫。
|
|
111
196
|
|
|
112
197
|
**未验证范围**:macOS / Linux、`headless` 与 `tui` profile、以及上表之外的其他 DSH 版本均未实测。
|
|
113
198
|
|
package/lib/client.js
CHANGED
|
@@ -122,7 +122,19 @@ window.__ModuleLoader__.load({
|
|
|
122
122
|
body: JSON.stringify({ method, args: args === undefined ? null : args }),
|
|
123
123
|
signal: controller !== null ? controller.signal : undefined,
|
|
124
124
|
})
|
|
125
|
-
if (!response.ok)
|
|
125
|
+
if (!response.ok) {
|
|
126
|
+
// 413/415 carry a readable reason in the JSON body. Reporting only the
|
|
127
|
+
// status code left the user staring at "宿主返回 HTTP 413" with no
|
|
128
|
+
// hint that the import file was simply too large.
|
|
129
|
+
let detail = ''
|
|
130
|
+
try {
|
|
131
|
+
const body = await response.json()
|
|
132
|
+
if (body !== null && typeof body === 'object' && typeof body.message === 'string') detail = body.message
|
|
133
|
+
} catch (err) {
|
|
134
|
+
// A non-JSON error body leaves the status code as the only clue.
|
|
135
|
+
}
|
|
136
|
+
throw new Error('宿主返回 HTTP ' + response.status + (detail === '' ? '' : ':' + detail))
|
|
137
|
+
}
|
|
126
138
|
return await response.json()
|
|
127
139
|
} catch (err) {
|
|
128
140
|
if (controller !== null && controller.signal.aborted) throw new Error('请求宿主超时(15 秒无响应)')
|
package/lib/index.js
CHANGED
|
@@ -10,36 +10,64 @@
|
|
|
10
10
|
* plugin's fiber, so unmounting the row removes all of them.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
|
|
13
|
+
import { copyFile, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
|
|
14
14
|
import { homedir } from 'node:os'
|
|
15
15
|
import { dirname, join } from 'node:path'
|
|
16
16
|
|
|
17
17
|
export const name = 'instruction-memory'
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
|
-
*
|
|
20
|
+
* `systemPrompt` is the one hard dependency: without it there is nothing to
|
|
21
|
+
* inject into. `webServer` deliberately is NOT declared here any more.
|
|
21
22
|
*
|
|
22
|
-
*
|
|
23
|
-
* `ctx.get('webServer')` at apply time
|
|
24
|
-
* lost for the life of the process.
|
|
25
|
-
*
|
|
23
|
+
* It used to be, to fix a real race — this row mounts early, before the web
|
|
24
|
+
* server has published, so a one-shot `ctx.get('webServer')` at apply time
|
|
25
|
+
* resolved `undefined` and the route was lost for the life of the process. But
|
|
26
|
+
* declaring it made the web server a *precondition for the whole plugin*: in a
|
|
27
|
+
* profile that has no web server at all (headless, tui) the row stays pending
|
|
28
|
+
* forever, `apply()` never runs, and the user silently loses BOTH the settings
|
|
29
|
+
* page and the prompt injection — even though `apply()` has always been able to
|
|
30
|
+
* serve memory without any web server. Memory must not be gated on the UI.
|
|
31
|
+
*
|
|
32
|
+
* The route is now registered from a non-blocking `ctx.inject(['webServer'], …)`
|
|
33
|
+
* child fiber, which solves the original race properly: the callback runs
|
|
34
|
+
* whenever the service is published, not only if it beat this row to it.
|
|
26
35
|
*
|
|
27
36
|
* The harness `fs` service is deliberately NOT used. Its default base is the
|
|
28
37
|
* host process cwd, which is exactly what `@deepseek-ai/dsh-home-paths` warns
|
|
29
38
|
* against: a user relaunching DSH from another directory would silently get a
|
|
30
39
|
* second, empty store. Plugin-owned data belongs under the harness home.
|
|
31
40
|
*/
|
|
32
|
-
export const inject = ['systemPrompt'
|
|
41
|
+
export const inject = ['systemPrompt']
|
|
33
42
|
|
|
34
43
|
const STORE_DIR_NAME = 'instruction-memory'
|
|
35
44
|
const STORE_FILE_NAME = 'memory.json'
|
|
36
45
|
/** Pre-release location, kept only so existing installs can migrate off it. */
|
|
37
46
|
const LEGACY_FILE_NAME = '.dsh-instruction-memory.json'
|
|
38
47
|
const SECTION_NAME = 'instruction-memory'
|
|
39
|
-
|
|
48
|
+
/**
|
|
49
|
+
* Placement of the injected instruction block among the system prompt's
|
|
50
|
+
* ordered sections. Sections are concatenated ascending; equal orders fall
|
|
51
|
+
* back to code-unit name order, which is not something a plugin can reason
|
|
52
|
+
* about — so this value must not collide with a reserved slot.
|
|
53
|
+
*
|
|
54
|
+
* `@deepseek-ai/dsh-system-prompt` owns a central `SECTION_ORDERS` table
|
|
55
|
+
* (0.1.7-rc.2): HARNESS_IDENTITY -1000, DEPLOYMENT_PERSONA_PREFIX 0,
|
|
56
|
+
* PLAN_POLICY 500, TEAM_POLICY 600, PTC_ONLY 800, FILE_REFERENCE **900**,
|
|
57
|
+
* TOOL_BASH 1000, … STRUCTURED_OUTPUT 9900, HARNESS_SOURCE 10000,
|
|
58
|
+
* WEB_SURFACE 10100, DEPLOYMENT_PERSONA_SUFFIX 10200.
|
|
59
|
+
*
|
|
60
|
+
* This was 900 through 1.0.8 — an exact tie with FILE_REFERENCE. It never
|
|
61
|
+
* threw (a tie only degrades to dictionary order), and it happened to sort
|
|
62
|
+
* the way we wanted: `instruction-memory` > `file-reference` on code units
|
|
63
|
+
* ('i' 105 > 'f' 102), so the block landed just after the file references.
|
|
64
|
+
* That is an accident of the name, not a decision, and renaming the section
|
|
65
|
+
* would silently move it. 950 states the intent outright: in the gap between
|
|
66
|
+
* FILE_REFERENCE (900) and TOOL_BASH (1000) — after the file-reference block,
|
|
67
|
+
* before the tool descriptions, clear of every reserved value.
|
|
68
|
+
*/
|
|
69
|
+
const SECTION_ORDER = 950
|
|
40
70
|
const API_PREFIX = '/instruction-memory/api'
|
|
41
|
-
/** Request body ceiling for the settings-page route; anything larger is rejected. */
|
|
42
|
-
const MAX_BODY_CHARS = 1_000_000
|
|
43
71
|
|
|
44
72
|
/**
|
|
45
73
|
* Resolve this plugin's data file under the harness home.
|
|
@@ -62,6 +90,21 @@ const MAX_TITLE = 120
|
|
|
62
90
|
const MAX_CONTENT = 6000
|
|
63
91
|
const MAX_WHEN = 200
|
|
64
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Request body ceiling for the settings-page route; anything larger is refused
|
|
95
|
+
* with a clean 413.
|
|
96
|
+
*
|
|
97
|
+
* Derived from the entry limits instead of hard-coded, because the two must not
|
|
98
|
+
* drift apart. A fixed 1,000,000 sat BELOW the largest legal payload — 200
|
|
99
|
+
* entries carrying 6,000 characters each is already 1.2 M characters of body —
|
|
100
|
+
* so a maximal store could be exported by this plugin and then never imported
|
|
101
|
+
* back into it: the route rejected the plugin's own export with 413. The factor
|
|
102
|
+
* of two covers JSON string escaping (a `"` or a newline costs two characters);
|
|
103
|
+
* the flat slack covers the `{method,args:{payload:{app,…,data:{entries:[…]}}}}`
|
|
104
|
+
* wrapper.
|
|
105
|
+
*/
|
|
106
|
+
export const MAX_BODY_CHARS = MAX_ENTRIES * (MAX_CONTENT + MAX_TITLE + MAX_WHEN + 200) * 2 + 100_000
|
|
107
|
+
|
|
65
108
|
/* ------------------------------------------------------------------ *
|
|
66
109
|
* Pure helpers (no ctx, fully unit-testable)
|
|
67
110
|
* ------------------------------------------------------------------ */
|
|
@@ -122,6 +165,28 @@ export function sanitizeEntry(raw) {
|
|
|
122
165
|
}
|
|
123
166
|
}
|
|
124
167
|
|
|
168
|
+
/**
|
|
169
|
+
* Which fields of a raw entry had to be cut to fit the stored limits.
|
|
170
|
+
*
|
|
171
|
+
* `sanitizeEntry` silently enforces MAX_TITLE / MAX_CONTENT / MAX_WHEN, which
|
|
172
|
+
* is fine for the editor (its inputs carry the same maxLength) but wrong for
|
|
173
|
+
* import and hand-edited payloads: a 7,000-character instruction came back
|
|
174
|
+
* stored as 6,000 with no indication that anything was lost, contradicting the
|
|
175
|
+
* plugin's stated rule of never discarding user text without saying so. Callers
|
|
176
|
+
* use this to disclose the cut; the sanitizer itself stays pure and unchanged.
|
|
177
|
+
*
|
|
178
|
+
* @returns the names of the fields that were shortened, in field order.
|
|
179
|
+
*/
|
|
180
|
+
export function truncatedFields(raw) {
|
|
181
|
+
if (raw === null || typeof raw !== 'object') return []
|
|
182
|
+
const cut = []
|
|
183
|
+
const unlimited = Number.MAX_SAFE_INTEGER
|
|
184
|
+
if (typeof raw.title === 'string' && singleLine(raw.title, unlimited) !== singleLine(raw.title, MAX_TITLE)) cut.push('标题')
|
|
185
|
+
if (typeof raw.content === 'string' && cleanText(raw.content, unlimited) !== cleanText(raw.content, MAX_CONTENT)) cut.push('内容')
|
|
186
|
+
if (typeof raw.when === 'string' && singleLine(raw.when, unlimited) !== singleLine(raw.when, MAX_WHEN)) cut.push('适用场景')
|
|
187
|
+
return cut
|
|
188
|
+
}
|
|
189
|
+
|
|
125
190
|
/**
|
|
126
191
|
* Load a parsed memory file. Returns the sanitized data plus how many array
|
|
127
192
|
* items were discarded (blank entries, or valid ones past the cap). The count
|
|
@@ -154,11 +219,107 @@ function normalizeData(raw) {
|
|
|
154
219
|
return { data, dropped }
|
|
155
220
|
}
|
|
156
221
|
|
|
222
|
+
/**
|
|
223
|
+
* A structural copy of the store, shallow per entry.
|
|
224
|
+
*
|
|
225
|
+
* The mutating commands edit `state.data` in place (push, filter, field
|
|
226
|
+
* assignment), so holding a plain reference is not enough to undo one — the
|
|
227
|
+
* "previous" value would already have changed with it. Entries are flat
|
|
228
|
+
* objects, so one spread per entry is a complete copy, and at the 200-entry cap
|
|
229
|
+
* that is a trivial cost on a path that is already doing a disk write.
|
|
230
|
+
*/
|
|
231
|
+
function cloneData(data) {
|
|
232
|
+
return {
|
|
233
|
+
version: 1,
|
|
234
|
+
enabled: data.enabled === true,
|
|
235
|
+
budgetChars: data.budgetChars,
|
|
236
|
+
entries: (Array.isArray(data.entries) ? data.entries : []).map((entry) => ({ ...entry })),
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
157
240
|
const droppedEntriesMessage = (count) =>
|
|
158
241
|
count > 0
|
|
159
242
|
? '存储文件中有 ' + count + ' 条条目未能载入(内容为空或超过 ' + MAX_ENTRIES + ' 条上限);下次保存时它们将从文件中移除'
|
|
160
243
|
: null
|
|
161
244
|
|
|
245
|
+
/**
|
|
246
|
+
* Hostnames a loopback-bound server may legitimately be reached by.
|
|
247
|
+
*
|
|
248
|
+
* `localhost` is included for people who open the GUI that way; the IPv4-mapped
|
|
249
|
+
* form covers a dual-stack listener reporting the client as `::ffff:127.0.0.1`.
|
|
250
|
+
*/
|
|
251
|
+
const LOOPBACK_HOSTNAMES = new Set(['127.0.0.1', 'localhost', '::1', '::ffff:127.0.0.1'])
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The hostname part of a `Host` header, lower-cased and without its port.
|
|
255
|
+
*
|
|
256
|
+
* `127.0.0.1:8080` -> `127.0.0.1`; `[::1]:8080` -> `::1`; `localhost` -> `localhost`.
|
|
257
|
+
* Exported so the parsing itself can be pinned by tests.
|
|
258
|
+
*/
|
|
259
|
+
export function hostnameOf(value) {
|
|
260
|
+
if (typeof value !== 'string') return ''
|
|
261
|
+
const trimmed = value.trim().toLowerCase()
|
|
262
|
+
if (trimmed === '') return ''
|
|
263
|
+
if (trimmed.startsWith('[')) {
|
|
264
|
+
const end = trimmed.indexOf(']')
|
|
265
|
+
return end === -1 ? trimmed : trimmed.slice(1, end)
|
|
266
|
+
}
|
|
267
|
+
const colon = trimmed.lastIndexOf(':')
|
|
268
|
+
return colon === -1 ? trimmed : trimmed.slice(0, colon)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Why the settings-page route checks `Host` and `Origin` at all, when it already
|
|
273
|
+
* refuses any content-type that is not `application/json`.
|
|
274
|
+
*
|
|
275
|
+
* The content-type rule stops a cross-site form post, and no CORS headers are
|
|
276
|
+
* sent so a cross-origin `fetch` with a JSON body dies in preflight. Both
|
|
277
|
+
* defences assume the browser sees the request as *cross-origin*. DNS
|
|
278
|
+
* rebinding removes that assumption: a page served from `evil.com` whose DNS
|
|
279
|
+
* is re-pointed at 127.0.0.1 is, to the browser, same-origin with the server it
|
|
280
|
+
* then talks to — so no preflight happens, the JSON content-type is allowed,
|
|
281
|
+
* and the plugin would happily rewrite the user's long-lived instructions,
|
|
282
|
+
* which are injected into every later prompt. The one thing the attacker cannot
|
|
283
|
+
* change is the `Host` header the browser sends: it still carries `evil.com`.
|
|
284
|
+
*
|
|
285
|
+
* So when the server is bound to loopback (the default) the route requires a
|
|
286
|
+
* loopback `Host`. An operator who binds `0.0.0.0` has deliberately published
|
|
287
|
+
* the whole server on every interface, where no Host value can be known in
|
|
288
|
+
* advance — the check is disabled there rather than breaking every legitimate
|
|
289
|
+
* client, and that trade-off is documented in the README.
|
|
290
|
+
*/
|
|
291
|
+
export function requestOriginVerdict(headers, guarded) {
|
|
292
|
+
const read = (name) => {
|
|
293
|
+
if (headers === null || typeof headers !== 'object') return ''
|
|
294
|
+
const value = headers[name]
|
|
295
|
+
return typeof value === 'string' ? value : ''
|
|
296
|
+
}
|
|
297
|
+
const requestHost = read('host').trim().toLowerCase()
|
|
298
|
+
const requestHostname = hostnameOf(requestHost)
|
|
299
|
+
|
|
300
|
+
if (guarded && !LOOPBACK_HOSTNAMES.has(requestHostname)) {
|
|
301
|
+
return { ok: false, reason: 'Host 头不是回环地址(可能是 DNS 重绑定攻击):' + (requestHost === '' ? '(缺失)' : requestHost) }
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const origin = read('origin').trim()
|
|
305
|
+
if (origin !== '') {
|
|
306
|
+
let originHostname = ''
|
|
307
|
+
try {
|
|
308
|
+
originHostname = new URL(origin).hostname.toLowerCase()
|
|
309
|
+
} catch {
|
|
310
|
+
originHostname = ''
|
|
311
|
+
}
|
|
312
|
+
// Compare hostnames, not host:port: a proxy may rewrite the port, and the
|
|
313
|
+
// hostname is the part rebinding actually attacks. `Origin: null`
|
|
314
|
+
// (sandboxed iframe, file://) parses to no hostname and is refused.
|
|
315
|
+
if (originHostname === '' || originHostname !== requestHostname) {
|
|
316
|
+
return { ok: false, reason: 'Origin 与 Host 不同源:' + origin }
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return { ok: true, reason: null }
|
|
321
|
+
}
|
|
322
|
+
|
|
162
323
|
function formatEntry(entry, tag) {
|
|
163
324
|
const lines = ['[' + tag + '|优先级 ' + priorityLabel(entry.priority) + '] ' + entry.title]
|
|
164
325
|
if (entry.mode === 'auto' && entry.when !== '') lines.push('适用场景:' + entry.when)
|
|
@@ -261,7 +422,6 @@ export function buildBlock(data) {
|
|
|
261
422
|
|
|
262
423
|
export function apply(ctx) {
|
|
263
424
|
const systemPrompt = ctx.systemPrompt
|
|
264
|
-
const webServer = ctx.get('webServer')
|
|
265
425
|
|
|
266
426
|
const state = {
|
|
267
427
|
data: { version: 1, enabled: true, budgetChars: DEFAULT_BUDGET, entries: [] },
|
|
@@ -269,6 +429,10 @@ export function apply(ctx) {
|
|
|
269
429
|
storageDisplay: null,
|
|
270
430
|
storageError: null,
|
|
271
431
|
storageWarning: null,
|
|
432
|
+
/** A failed rolling-backup refresh, shown only when no load warning wins. */
|
|
433
|
+
backupError: null,
|
|
434
|
+
/** Which branch of the load actually produced the current data (diagnostics). */
|
|
435
|
+
loadedFrom: null,
|
|
272
436
|
savedAt: null,
|
|
273
437
|
migratedFrom: null,
|
|
274
438
|
sectionRegistered: false,
|
|
@@ -283,6 +447,56 @@ export function apply(ctx) {
|
|
|
283
447
|
return String(err)
|
|
284
448
|
}
|
|
285
449
|
|
|
450
|
+
/* ---------------- diagnostics ---------------- */
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* The plugin used to be completely silent on success: every load, save and
|
|
454
|
+
* injection produced no record at all, and only failures reached
|
|
455
|
+
* `console.error`. "Is memory actually doing anything?" was therefore
|
|
456
|
+
* unanswerable from logs — verifying it meant substituting route snapshots
|
|
457
|
+
* and transcript forensics for real evidence.
|
|
458
|
+
*
|
|
459
|
+
* This adds an opt-in diagnostic channel on the harness logger. It is
|
|
460
|
+
* log-only: it never touches what gets injected, so the rendered block stays
|
|
461
|
+
* byte-identical with it on or off.
|
|
462
|
+
*
|
|
463
|
+
* info — one line per mount (where the store is, how much is injected)
|
|
464
|
+
* debug — per-operation detail, including the ones the route guards reject
|
|
465
|
+
*
|
|
466
|
+
* With no logger service (the offline harnesses) debug needs
|
|
467
|
+
* DSH_INSTRUCTION_MEMORY_DEBUG=1; info always falls back to console.error so
|
|
468
|
+
* a mount failure is never swallowed.
|
|
469
|
+
*/
|
|
470
|
+
const logger = (() => {
|
|
471
|
+
try {
|
|
472
|
+
const candidate = typeof ctx.logger === 'function' ? ctx.logger('instruction-memory') : ctx.logger
|
|
473
|
+
if (candidate !== null && candidate !== undefined && typeof candidate.info === 'function') return candidate
|
|
474
|
+
} catch {
|
|
475
|
+
// A context without a logger service must never break the plugin.
|
|
476
|
+
}
|
|
477
|
+
return null
|
|
478
|
+
})()
|
|
479
|
+
|
|
480
|
+
const debugToConsole = (() => {
|
|
481
|
+
const raw = typeof process.env.DSH_INSTRUCTION_MEMORY_DEBUG === 'string'
|
|
482
|
+
? process.env.DSH_INSTRUCTION_MEMORY_DEBUG.trim().toLowerCase()
|
|
483
|
+
: ''
|
|
484
|
+
return raw === '1' || raw === 'true' || raw === 'yes'
|
|
485
|
+
})()
|
|
486
|
+
|
|
487
|
+
const logInfo = (message) => {
|
|
488
|
+
if (logger !== null) logger.info(message)
|
|
489
|
+
else console.error('[instruction-memory] ' + message)
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const logDebug = (message) => {
|
|
493
|
+
if (logger !== null) {
|
|
494
|
+
if (typeof logger.debug === 'function') logger.debug(message)
|
|
495
|
+
return
|
|
496
|
+
}
|
|
497
|
+
if (debugToConsole) console.error('[instruction-memory] ' + message)
|
|
498
|
+
}
|
|
499
|
+
|
|
286
500
|
const sectionText = () => {
|
|
287
501
|
try {
|
|
288
502
|
return buildBlock(state.data)
|
|
@@ -304,6 +518,7 @@ export function apply(ctx) {
|
|
|
304
518
|
sectionDispose = systemPrompt.section({ name: SECTION_NAME, order: SECTION_ORDER, text: sectionText })
|
|
305
519
|
state.sectionRegistered = true
|
|
306
520
|
state.sectionError = null
|
|
521
|
+
logDebug('prompt section registered: order=' + SECTION_ORDER + ' chars=' + sectionText().length)
|
|
307
522
|
} catch (err) {
|
|
308
523
|
sectionDispose = null
|
|
309
524
|
state.sectionRegistered = false
|
|
@@ -318,6 +533,7 @@ export function apply(ctx) {
|
|
|
318
533
|
sectionDispose = null
|
|
319
534
|
try {
|
|
320
535
|
dispose()
|
|
536
|
+
logDebug('prompt section disposed (nothing to inject)')
|
|
321
537
|
} catch (err) {
|
|
322
538
|
console.error('[instruction-memory] section dispose failed:', err)
|
|
323
539
|
}
|
|
@@ -350,16 +566,33 @@ export function apply(ctx) {
|
|
|
350
566
|
// one-version backup: a crash mid-write must never leave a truncated
|
|
351
567
|
// memory.json, and an edit the user regrets must still be recoverable
|
|
352
568
|
// from memory.json.bak. The .bak holds the previous good version.
|
|
569
|
+
//
|
|
570
|
+
// The backup is a COPY, not a rename. Renaming the live file out of the way
|
|
571
|
+
// first opened a window in which memory.json did not exist: if the rename
|
|
572
|
+
// that followed failed (a lock, a full disk, an indexer holding the file)
|
|
573
|
+
// the primary path stayed missing, and the next boot — finding no
|
|
574
|
+
// memory.json — materialised an EMPTY store and silently discarded the
|
|
575
|
+
// user's memory while their real data sat untouched in .bak. Copying keeps
|
|
576
|
+
// the primary in place until the atomic replace succeeds, so a failure at
|
|
577
|
+
// any step leaves the previous store readable at its own path.
|
|
353
578
|
const tmpPath = storePath + '.tmp'
|
|
354
579
|
const bakPath = storePath + '.bak'
|
|
355
580
|
try {
|
|
356
581
|
await mkdir(dirname(storePath), { recursive: true })
|
|
357
|
-
|
|
582
|
+
const text = serialize()
|
|
583
|
+
await writeFile(tmpPath, text, 'utf8')
|
|
358
584
|
try {
|
|
359
|
-
await
|
|
585
|
+
await copyFile(storePath, bakPath)
|
|
586
|
+
state.backupError = null
|
|
360
587
|
} catch (err) {
|
|
361
|
-
//
|
|
362
|
-
|
|
588
|
+
// ENOENT is simply the very first save: there is no previous version
|
|
589
|
+
// to roll. Any other failure means the promised rolling backup did not
|
|
590
|
+
// happen — non-fatal for this save, because the primary is still intact
|
|
591
|
+
// and the replace below is the real gate, but it must be disclosed
|
|
592
|
+
// rather than swallowed.
|
|
593
|
+
state.backupError = err.code === 'ENOENT'
|
|
594
|
+
? null
|
|
595
|
+
: '上一版未能保留为 memory.json.bak:' + describeError(err)
|
|
363
596
|
}
|
|
364
597
|
try {
|
|
365
598
|
await rename(tmpPath, storePath)
|
|
@@ -371,6 +604,9 @@ export function apply(ctx) {
|
|
|
371
604
|
state.storageDisplay = storePath
|
|
372
605
|
state.storageError = null
|
|
373
606
|
state.savedAt = Date.now()
|
|
607
|
+
logDebug('store written: ' + storePath + ' bytes=' + text.length
|
|
608
|
+
+ ' entries=' + state.data.entries.length
|
|
609
|
+
+ (state.backupError === null ? ' backup=refreshed' : ' backup=FAILED'))
|
|
374
610
|
return true
|
|
375
611
|
} catch (err) {
|
|
376
612
|
state.storageError = '保存失败:' + describeError(err)
|
|
@@ -387,6 +623,7 @@ export function apply(ctx) {
|
|
|
387
623
|
state.storagePath = storePath
|
|
388
624
|
state.storageDisplay = storePath
|
|
389
625
|
state.storageWarning = null
|
|
626
|
+
state.loadedFrom = null
|
|
390
627
|
|
|
391
628
|
// 1. The canonical location under the harness home.
|
|
392
629
|
try {
|
|
@@ -395,6 +632,7 @@ export function apply(ctx) {
|
|
|
395
632
|
const loaded = normalizeData(parsed)
|
|
396
633
|
state.data = loaded.data
|
|
397
634
|
state.storageWarning = droppedEntriesMessage(loaded.dropped)
|
|
635
|
+
state.loadedFrom = 'memory.json'
|
|
398
636
|
}
|
|
399
637
|
state.storageError = null
|
|
400
638
|
return
|
|
@@ -414,6 +652,7 @@ export function apply(ctx) {
|
|
|
414
652
|
state.storageWarning = (droppedEntriesMessage(loaded.dropped) || '')
|
|
415
653
|
+ (loaded.dropped > 0 ? ';' : '')
|
|
416
654
|
+ 'memory.json 解析失败(' + describeError(err) + '),已临时回退到最近备份 memory.json.bak;损坏文件已保留,下次保存时会覆盖它'
|
|
655
|
+
state.loadedFrom = 'memory.json.bak (main file corrupt)'
|
|
417
656
|
return
|
|
418
657
|
}
|
|
419
658
|
} catch {
|
|
@@ -428,6 +667,31 @@ export function apply(ctx) {
|
|
|
428
667
|
}
|
|
429
668
|
}
|
|
430
669
|
|
|
670
|
+
// 1b. The primary file is gone but a rolling backup survives. That is the
|
|
671
|
+
// state an interrupted save leaves behind (the backup step used to
|
|
672
|
+
// rename the primary out of the way before the replace), and it is also
|
|
673
|
+
// what a stray delete looks like. Falling through to "first run" here
|
|
674
|
+
// would materialise an EMPTY store and silently present the user with
|
|
675
|
+
// no memory at all while their previous version sat right there in
|
|
676
|
+
// .bak — so recover it, and promote the recovered data back to the
|
|
677
|
+
// canonical path so the next boot finds it normally.
|
|
678
|
+
try {
|
|
679
|
+
const backup = await readStoreFile(storePath + '.bak')
|
|
680
|
+
if (backup !== null) {
|
|
681
|
+
const loaded = normalizeData(backup)
|
|
682
|
+
state.data = loaded.data
|
|
683
|
+
const promoted = await writeToDisk()
|
|
684
|
+
state.storageWarning = (droppedEntriesMessage(loaded.dropped) || '')
|
|
685
|
+
+ (loaded.dropped > 0 ? ';' : '')
|
|
686
|
+
+ 'memory.json 不存在,已从最近备份 memory.json.bak 恢复'
|
|
687
|
+
+ (promoted ? ',并已写回 memory.json' : ';写回 memory.json 失败,下次保存时会重试')
|
|
688
|
+
state.loadedFrom = 'memory.json.bak (main file missing)'
|
|
689
|
+
return
|
|
690
|
+
}
|
|
691
|
+
} catch {
|
|
692
|
+
// An unusable backup must never block a fresh store; fall through.
|
|
693
|
+
}
|
|
694
|
+
|
|
431
695
|
// 2. Migrate the pre-release location (host process cwd). This is the only
|
|
432
696
|
// reason the legacy name is still known to this plugin.
|
|
433
697
|
const legacyPath = join(process.cwd(), LEGACY_FILE_NAME)
|
|
@@ -442,6 +706,7 @@ export function apply(ctx) {
|
|
|
442
706
|
// Keep the original as evidence instead of deleting user data.
|
|
443
707
|
await rename(legacyPath, legacyPath + '.migrated').catch(() => {})
|
|
444
708
|
}
|
|
709
|
+
state.loadedFrom = 'legacy cwd file (migrated)'
|
|
445
710
|
return
|
|
446
711
|
}
|
|
447
712
|
} catch (err) {
|
|
@@ -452,6 +717,7 @@ export function apply(ctx) {
|
|
|
452
717
|
// 3. First run: materialise an empty store so the path is real and the
|
|
453
718
|
// settings page can show it.
|
|
454
719
|
await writeToDisk()
|
|
720
|
+
if (state.loadedFrom === null) state.loadedFrom = 'first run (empty store created)'
|
|
455
721
|
}
|
|
456
722
|
|
|
457
723
|
/* ---------------- boot ---------------- */
|
|
@@ -465,6 +731,15 @@ export function apply(ctx) {
|
|
|
465
731
|
})
|
|
466
732
|
.then(() => {
|
|
467
733
|
syncSection()
|
|
734
|
+
// The one always-on line: it answers "is memory mounted, where does it
|
|
735
|
+
// live, and how much is being injected?" without any further digging.
|
|
736
|
+
const chars = sectionText().length
|
|
737
|
+
logInfo('mounted: store=' + storePath
|
|
738
|
+
+ ' from=' + String(state.loadedFrom)
|
|
739
|
+
+ ' entries=' + state.data.entries.length
|
|
740
|
+
+ ' enabled=' + (state.data.enabled === true)
|
|
741
|
+
+ ' injected=' + (chars > 0 ? chars + ' chars' : 'nothing')
|
|
742
|
+
+ (state.storageError !== null ? ' error=' + state.storageError : ''))
|
|
468
743
|
})
|
|
469
744
|
|
|
470
745
|
/**
|
|
@@ -496,7 +771,7 @@ export function apply(ctx) {
|
|
|
496
771
|
storage: {
|
|
497
772
|
path: state.storageDisplay !== null ? state.storageDisplay : state.storagePath,
|
|
498
773
|
error: state.storageError,
|
|
499
|
-
warning: state.storageWarning,
|
|
774
|
+
warning: state.storageWarning !== null ? state.storageWarning : state.backupError,
|
|
500
775
|
savedAt: state.savedAt,
|
|
501
776
|
migratedFrom: state.migratedFrom,
|
|
502
777
|
},
|
|
@@ -520,46 +795,78 @@ export function apply(ctx) {
|
|
|
520
795
|
|
|
521
796
|
/* ---------------- commands (shared by route and callers) ---------------- */
|
|
522
797
|
|
|
798
|
+
/**
|
|
799
|
+
* Apply an in-memory mutation, persist it, and UNDO it when the write fails.
|
|
800
|
+
*
|
|
801
|
+
* Every mutating command used to edit `state.data`, re-register the prompt
|
|
802
|
+
* section, and only then find out that the disk write had failed. The panel
|
|
803
|
+
* said 保存失败 and memory.json was correctly left alone — but the injected
|
|
804
|
+
* system prompt kept carrying the unsaved edit for the rest of the process
|
|
805
|
+
* lifetime, so the model went on obeying an instruction the user had just
|
|
806
|
+
* been told was not saved. The successful write is the commit point: fail it
|
|
807
|
+
* and the in-memory state, the prompt section and the panel roll back
|
|
808
|
+
* together.
|
|
809
|
+
*
|
|
810
|
+
* @returns whether the store was persisted.
|
|
811
|
+
*/
|
|
812
|
+
const commit = async (mutate) => {
|
|
813
|
+
const previousData = cloneData(state.data)
|
|
814
|
+
const previousWarning = state.storageWarning
|
|
815
|
+
mutate()
|
|
816
|
+
syncSection()
|
|
817
|
+
if (await writeToDisk()) {
|
|
818
|
+
// A successful save has rewritten the file to match memory, so a
|
|
819
|
+
// dropped-entries warning from load time ("they will be removed on the
|
|
820
|
+
// next save") has played out and must not keep showing.
|
|
821
|
+
state.storageWarning = null
|
|
822
|
+
return true
|
|
823
|
+
}
|
|
824
|
+
logDebug('commit rolled back: the disk write failed, in-memory state restored')
|
|
825
|
+
state.data = previousData
|
|
826
|
+
state.storageWarning = previousWarning
|
|
827
|
+
syncSection()
|
|
828
|
+
return false
|
|
829
|
+
}
|
|
830
|
+
|
|
523
831
|
const saveEntry = (raw) => enqueue(async () => {
|
|
524
832
|
const entry = sanitizeEntry(raw)
|
|
525
833
|
if (entry === null) return { ok: false, saved: false, message: '标题与指令内容不能同时为空', snapshot: snapshot() }
|
|
526
834
|
entry.updatedAt = Date.now()
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
return { ok: false, saved: false, message: '最多保存 ' + MAX_ENTRIES + ' 条指令', snapshot: snapshot() }
|
|
533
|
-
}
|
|
534
|
-
list.push(entry)
|
|
835
|
+
// Resolved before the mutation so a refusal can return a response instead
|
|
836
|
+
// of being swallowed as an early exit inside it.
|
|
837
|
+
const replacesExisting = state.data.entries.some((candidate) => candidate.id === entry.id)
|
|
838
|
+
if (!replacesExisting && state.data.entries.length >= MAX_ENTRIES) {
|
|
839
|
+
return { ok: false, saved: false, message: '最多保存 ' + MAX_ENTRIES + ' 条指令', snapshot: snapshot() }
|
|
535
840
|
}
|
|
536
|
-
|
|
537
|
-
const saved = await
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
841
|
+
const cut = truncatedFields(raw)
|
|
842
|
+
const saved = await commit(() => {
|
|
843
|
+
const list = state.data.entries
|
|
844
|
+
const index = list.findIndex((candidate) => candidate.id === entry.id)
|
|
845
|
+
if (index >= 0) list[index] = entry
|
|
846
|
+
else list.push(entry)
|
|
847
|
+
})
|
|
848
|
+
const note = cut.length > 0
|
|
849
|
+
? '已保存;' + cut.join('、') + '超过长度上限(标题 ' + MAX_TITLE + ' / 内容 ' + MAX_CONTENT + ' / 适用场景 ' + MAX_WHEN + ' 字符),超出部分已被截断'
|
|
850
|
+
: null
|
|
851
|
+
return settle(note, saved)
|
|
543
852
|
})
|
|
544
853
|
|
|
545
854
|
const deleteEntry = (id) => enqueue(async () => {
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
if (saved) state.storageWarning = null
|
|
855
|
+
const saved = await commit(() => {
|
|
856
|
+
state.data.entries = state.data.entries.filter((entry) => entry.id !== id)
|
|
857
|
+
})
|
|
550
858
|
return settle(null, saved)
|
|
551
859
|
})
|
|
552
860
|
|
|
553
861
|
const setOptions = (patch) => enqueue(async () => {
|
|
554
|
-
|
|
555
|
-
if (typeof patch
|
|
556
|
-
|
|
557
|
-
|
|
862
|
+
const saved = await commit(() => {
|
|
863
|
+
if (patch !== null && typeof patch === 'object') {
|
|
864
|
+
if (typeof patch.enabled === 'boolean') state.data.enabled = patch.enabled
|
|
865
|
+
if (typeof patch.budgetChars === 'number' && Number.isFinite(patch.budgetChars)) {
|
|
866
|
+
state.data.budgetChars = clampBudget(patch.budgetChars)
|
|
867
|
+
}
|
|
558
868
|
}
|
|
559
|
-
}
|
|
560
|
-
syncSection()
|
|
561
|
-
const saved = await writeToDisk()
|
|
562
|
-
if (saved) state.storageWarning = null
|
|
869
|
+
})
|
|
563
870
|
return settle(null, saved)
|
|
564
871
|
})
|
|
565
872
|
|
|
@@ -596,10 +903,15 @@ export function apply(ctx) {
|
|
|
596
903
|
// Merge, never replace: importing must not be able to wipe what is here.
|
|
597
904
|
// Duplicates (same id, or same title+content) and invalid items are
|
|
598
905
|
// skipped and counted, not silently dropped.
|
|
906
|
+
//
|
|
907
|
+
// The whole plan is computed BEFORE the store is touched, so the merge and
|
|
908
|
+
// its counters cannot disagree with what actually gets appended.
|
|
599
909
|
const existing = state.data.entries
|
|
600
910
|
const seenIds = new Set(existing.map((entry) => entry.id))
|
|
601
911
|
const seenBodies = new Set(existing.map((entry) => entry.title + '\u0000' + entry.content))
|
|
602
|
-
|
|
912
|
+
const accepted = []
|
|
913
|
+
const cutFields = new Set()
|
|
914
|
+
let truncatedCount = 0
|
|
603
915
|
let skipped = 0
|
|
604
916
|
for (const candidate of rawEntries) {
|
|
605
917
|
const entry = sanitizeEntry(candidate)
|
|
@@ -607,23 +919,36 @@ export function apply(ctx) {
|
|
|
607
919
|
skipped += 1
|
|
608
920
|
continue
|
|
609
921
|
}
|
|
610
|
-
if (existing.length >= MAX_ENTRIES) {
|
|
922
|
+
if (existing.length + accepted.length >= MAX_ENTRIES) {
|
|
611
923
|
skipped += 1
|
|
612
924
|
continue
|
|
613
925
|
}
|
|
926
|
+
const cut = truncatedFields(candidate)
|
|
927
|
+
if (cut.length > 0) {
|
|
928
|
+
// sanitizeEntry enforces the limits silently, which is fine for the
|
|
929
|
+
// editor (its inputs carry the same maxLength) but wrong here: an
|
|
930
|
+
// imported 7,000-character instruction would come back as 6,000 with
|
|
931
|
+
// nothing said about the loss.
|
|
932
|
+
truncatedCount += 1
|
|
933
|
+
for (const field of cut) cutFields.add(field)
|
|
934
|
+
}
|
|
614
935
|
entry.updatedAt = Date.now()
|
|
615
|
-
|
|
936
|
+
accepted.push(entry)
|
|
616
937
|
seenIds.add(entry.id)
|
|
617
938
|
seenBodies.add(entry.title + '\u0000' + entry.content)
|
|
618
|
-
imported += 1
|
|
619
939
|
}
|
|
620
|
-
if (
|
|
621
|
-
|
|
940
|
+
if (accepted.length === 0) {
|
|
941
|
+
// Nothing was written to disk, so claiming saved:true would be a lie the
|
|
942
|
+
// panel cannot distinguish from a real save.
|
|
943
|
+
return { ok: true, saved: false, message: '没有新增条目:' + skipped + ' 条已存在或无效', snapshot: snapshot() }
|
|
622
944
|
}
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
945
|
+
const saved = await commit(() => {
|
|
946
|
+
state.data.entries.push(...accepted)
|
|
947
|
+
})
|
|
948
|
+
const truncated = truncatedCount > 0
|
|
949
|
+
? ';其中 ' + truncatedCount + ' 条超过长度上限,' + [...cutFields].join('、') + '已被截断'
|
|
950
|
+
: ''
|
|
951
|
+
return settle('导入完成:新增 ' + accepted.length + ' 条,跳过 ' + skipped + ' 条(已存在或无效)' + truncated, saved)
|
|
627
952
|
})
|
|
628
953
|
|
|
629
954
|
const dispatch = async (method, args) => {
|
|
@@ -652,84 +977,119 @@ export function apply(ctx) {
|
|
|
652
977
|
|
|
653
978
|
/* ---------------- same-origin JSON route for the Client half ---------------- */
|
|
654
979
|
|
|
980
|
+
// Which Host values this route accepts. Set when the route is registered,
|
|
981
|
+
// from the address the web server is actually bound to (see the long note on
|
|
982
|
+
// requestOriginVerdict). Until then the route does not exist at all, so the
|
|
983
|
+
// default only matters if something registers it out of band.
|
|
984
|
+
let guardHost = true
|
|
985
|
+
|
|
655
986
|
let routeDispose = null
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
)
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
987
|
+
const handler = (req, res) => {
|
|
988
|
+
if (req.method !== 'POST') {
|
|
989
|
+
res.writeHead(405, { 'content-type': 'application/json; charset=utf-8' })
|
|
990
|
+
res.end(JSON.stringify({ ok: false, message: 'only POST is supported' }))
|
|
991
|
+
return
|
|
992
|
+
}
|
|
993
|
+
// Origin/Host guard, before anything else is read. See
|
|
994
|
+
// requestOriginVerdict() for why the content-type rule below is not enough
|
|
995
|
+
// on its own.
|
|
996
|
+
const verdict = requestOriginVerdict(req.headers, guardHost)
|
|
997
|
+
if (!verdict.ok) {
|
|
998
|
+
logInfo('route rejected a request: ' + verdict.reason)
|
|
999
|
+
res.writeHead(403, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
1000
|
+
res.end(JSON.stringify({ ok: false, message: '请求来源被拒绝:' + verdict.reason }))
|
|
1001
|
+
return
|
|
1002
|
+
}
|
|
1003
|
+
// The settings page always posts application/json. Enforcing it turns
|
|
1004
|
+
// the anti-CSRF coincidence (a plain HTML form cannot fabricate a JSON
|
|
1005
|
+
// body) into a hard rule: a cross-site or malformed post now gets a
|
|
1006
|
+
// readable 415 instead of a confusing parse failure.
|
|
1007
|
+
const contentType = String(
|
|
1008
|
+
(req.headers !== null && typeof req.headers === 'object') ? (req.headers['content-type'] || '') : '',
|
|
1009
|
+
)
|
|
1010
|
+
if (contentType.indexOf('application/json') === -1) {
|
|
1011
|
+
res.writeHead(415, { 'content-type': 'application/json; charset=utf-8' })
|
|
1012
|
+
res.end(JSON.stringify({ ok: false, message: 'content-type 必须是 application/json' }))
|
|
1013
|
+
return
|
|
1014
|
+
}
|
|
1015
|
+
// A call aborted mid-flight (settings page closed, tab navigated away)
|
|
1016
|
+
// makes these streams emit 'error'. A server stream with no error
|
|
1017
|
+
// listener turns that into an uncaught exception — i.e. it would take
|
|
1018
|
+
// the whole host process down over one dead HTTP request.
|
|
1019
|
+
req.on('error', () => {})
|
|
1020
|
+
res.on('error', () => {})
|
|
1021
|
+
let body = ''
|
|
1022
|
+
// Over the ceiling we stop accumulating but keep draining, so the
|
|
1023
|
+
// response can go out as a clean 413 instead of a destroyed socket the
|
|
1024
|
+
// Client can only report as a generic network failure.
|
|
1025
|
+
let overflow = false
|
|
1026
|
+
req.setEncoding('utf8')
|
|
1027
|
+
req.on('data', (chunk) => {
|
|
1028
|
+
if (overflow) return
|
|
1029
|
+
body += chunk
|
|
1030
|
+
if (body.length > MAX_BODY_CHARS) {
|
|
1031
|
+
overflow = true
|
|
1032
|
+
body = ''
|
|
674
1033
|
}
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
// Over the ceiling we stop accumulating but keep draining, so the
|
|
683
|
-
// response can go out as a clean 413 instead of a destroyed socket the
|
|
684
|
-
// Client can only report as a generic network failure.
|
|
685
|
-
let overflow = false
|
|
686
|
-
req.setEncoding('utf8')
|
|
687
|
-
req.on('data', (chunk) => {
|
|
688
|
-
if (overflow) return
|
|
689
|
-
body += chunk
|
|
690
|
-
if (body.length > MAX_BODY_CHARS) {
|
|
691
|
-
overflow = true
|
|
692
|
-
body = ''
|
|
1034
|
+
})
|
|
1035
|
+
req.on('end', () => {
|
|
1036
|
+
void (async () => {
|
|
1037
|
+
if (overflow) {
|
|
1038
|
+
res.writeHead(413, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
1039
|
+
res.end(JSON.stringify({ ok: false, message: '请求体超过 ' + MAX_BODY_CHARS + ' 字符上限' }))
|
|
1040
|
+
return
|
|
693
1041
|
}
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
1042
|
+
let result
|
|
1043
|
+
try {
|
|
1044
|
+
const payload = body === '' ? {} : JSON.parse(body)
|
|
1045
|
+
result = await dispatch(payload.method, payload.args === undefined ? null : payload.args)
|
|
1046
|
+
} catch (err) {
|
|
1047
|
+
result = { ok: false, saved: false, message: describeError(err), snapshot: snapshot() }
|
|
1048
|
+
}
|
|
1049
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
1050
|
+
res.end(JSON.stringify(result))
|
|
1051
|
+
})()
|
|
1052
|
+
})
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// Register the settings-page route from a NON-BLOCKING child fiber. A web
|
|
1056
|
+
// server must not be a precondition for this plugin: in a profile without one
|
|
1057
|
+
// (headless, tui) a blocking dependency would keep the whole row pending,
|
|
1058
|
+
// losing the prompt injection along with the settings page. The callback also
|
|
1059
|
+
// fixes the original race properly — it runs whenever the service is
|
|
1060
|
+
// published, not only if it happened to beat this row to the mount.
|
|
1061
|
+
ctx.inject(['webServer'], (scope) => {
|
|
1062
|
+
const webServer = scope.webServer
|
|
1063
|
+
if (webServer === undefined || typeof webServer.register !== 'function') {
|
|
1064
|
+
console.error('[instruction-memory] webServer published without a register(); the settings page cannot reach the host')
|
|
1065
|
+
return
|
|
713
1066
|
}
|
|
1067
|
+
// The Host allowlist only makes sense when the operator bound the server to
|
|
1068
|
+
// loopback. `0.0.0.0` means they deliberately published it on every
|
|
1069
|
+
// interface, where no Host value can be known in advance — enforcing a
|
|
1070
|
+
// loopback allowlist there would break every legitimate client, so the
|
|
1071
|
+
// check is disabled and the trade-off is documented in the README.
|
|
1072
|
+
const boundHost = typeof webServer.host === 'string' ? webServer.host.trim().toLowerCase() : '127.0.0.1'
|
|
1073
|
+
guardHost = boundHost !== '0.0.0.0'
|
|
714
1074
|
try {
|
|
715
1075
|
routeDispose = webServer.register({ kind: 'prefix', path: API_PREFIX, handler })
|
|
1076
|
+
logDebug('settings route registered at ' + API_PREFIX + ' (boundHost=' + boundHost + ', hostGuard=' + guardHost + ')')
|
|
716
1077
|
} catch (err) {
|
|
717
1078
|
console.error('[instruction-memory] route registration failed:', err)
|
|
718
1079
|
routeDispose = null
|
|
719
1080
|
}
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
console.error('[instruction-memory] route dispose failed:', err)
|
|
1081
|
+
// The child fiber is owned by this one, so unmounting the plugin still
|
|
1082
|
+
// disposes the route even though it was registered here.
|
|
1083
|
+
scope.effect(() => () => {
|
|
1084
|
+
if (routeDispose !== null) {
|
|
1085
|
+
const dispose = routeDispose
|
|
1086
|
+
routeDispose = null
|
|
1087
|
+
try {
|
|
1088
|
+
dispose()
|
|
1089
|
+
} catch (err) {
|
|
1090
|
+
console.error('[instruction-memory] route dispose failed:', err)
|
|
1091
|
+
}
|
|
732
1092
|
}
|
|
733
|
-
}
|
|
1093
|
+
})
|
|
734
1094
|
})
|
|
735
1095
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-instruction-memory",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.10",
|
|
4
4
|
"description": "User-only instruction memory for DSH: long-term instructions maintained in the settings UI and auto-injected into every conversation — the model has no write access. 指令记忆:只由用户在设置页维护的长期指令,自动注入此后每轮对话;模型没有任何写入口。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -61,16 +61,16 @@
|
|
|
61
61
|
},
|
|
62
62
|
"client": {
|
|
63
63
|
"inject": [
|
|
64
|
-
"@deepseek-ai/dsh-client-
|
|
65
|
-
"@deepseek-ai/dsh-client-ui-
|
|
64
|
+
"@deepseek-ai/dsh-client-ui-renderer",
|
|
65
|
+
"@deepseek-ai/dsh-client-ui-settings"
|
|
66
66
|
],
|
|
67
67
|
"platform": "web"
|
|
68
68
|
},
|
|
69
69
|
"engines": {
|
|
70
|
-
"dsh": ">=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.2.0-0 || >=0.2.0-0"
|
|
70
|
+
"dsh": ">=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.0-0"
|
|
71
71
|
},
|
|
72
72
|
"compatibility": {
|
|
73
|
-
"dsh": ">=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.2.0-0 || >=0.2.0-0"
|
|
73
|
+
"dsh": ">=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.0-0"
|
|
74
74
|
}
|
|
75
75
|
}
|
|
76
76
|
}
|