dsh-instruction-memory 1.0.9 → 1.0.11
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 +119 -7
- package/lib/client.js +13 -1
- package/lib/index.js +548 -125
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -64,13 +64,106 @@ 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
|
+
### 首次运行不写文件(惰性创建)
|
|
89
|
+
|
|
90
|
+
全新安装、尚无任何记忆时,插件**不会**在宿主启动那一刻创建空的 `memory.json`。
|
|
91
|
+
|
|
92
|
+
> 这里曾主动物化一份空存储,只为让设置页有个真实路径可显示。但那意味着插件在**进程启动、
|
|
93
|
+
> 零会话、零任务**的状态下执行了一次磁盘写入——这正是行为审计中唯一能被正当称为
|
|
94
|
+
> "没活干还在动"的副作用。现在文件由**第一个真正需要它的动作**创建:一次保存、一次导入,
|
|
95
|
+
> 或设置页读取 `state`(面板打开时就会读)。
|
|
96
|
+
|
|
97
|
+
在这之前:`storage.path` 仍会显示路径,但 `storage.onDisk` 为 `false`,设置页显示
|
|
98
|
+
「尚未写入磁盘」(该文案本就存在),`state.injection.registered === false`,`pulls === 0`。
|
|
99
|
+
挂载日志相应为 `from=first run (no store on disk yet) onDisk=no`。
|
|
100
|
+
|
|
101
|
+
**不受影响**:从 `.bak` 恢复、以及从旧位置迁移——这两条分支只有在**磁盘上已有真实数据**时才会走,
|
|
102
|
+
它们仍然会立即写回。
|
|
103
|
+
|
|
104
|
+
## 无 Web 服务器时依然生效
|
|
105
|
+
|
|
106
|
+
`systemPrompt` 是本插件唯一的硬依赖。**Web 服务器不是前置条件**:设置页那部分路由通过
|
|
107
|
+
非阻塞的 `ctx.inject(['webServer'], …)` 子 fiber 注册,服务出现时自动挂上;没有 Web 服务器
|
|
108
|
+
的 profile(`headless`、`tui`)里,系统提示词注入照常工作,只是设置页不可用。
|
|
109
|
+
|
|
110
|
+
> 这里曾声明 `inject = ['systemPrompt', 'webServer']`。声明式依赖是**前置条件**,于是没有
|
|
111
|
+
> Web 服务器的 profile 里整行插件永远停在 pending、`apply()` 根本不执行——用户同时失去设置页
|
|
112
|
+
> **和**记忆注入,而记忆本身根本不需要 Web 服务器。非阻塞注册同时把最初那个竞态修得更彻底:
|
|
113
|
+
> 回调在服务发布时就会执行,而不只是在它"抢在插件挂载之前"发布时才生效。
|
|
114
|
+
|
|
115
|
+
## 诊断日志
|
|
116
|
+
|
|
117
|
+
插件此前**只有失败才有输出**:每次载入、保存、注入成功都不留任何记录,只有失败分支走
|
|
118
|
+
`console.error`。于是"记忆到底有没有在干活"完全无法从日志回答。现在它挂载时写一行
|
|
119
|
+
`info`,过程细节写 `debug`(走宿主的 `ctx.logger`):
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
[instruction-memory] mounted: store=<DSH_HOME>\instruction-memory\memory.json from=memory.json onDisk=yes entries=2 enabled=true section=437 chars (pending assembly) pulls=0
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
挂载行含**数据来源**(`memory.json` / `memory.json.bak (main file missing)` / `memory.json.bak (main file corrupt)`
|
|
126
|
+
/ `legacy cwd file (migrated)` / `first run (no store on disk yet)`)、**是否已在磁盘上**(`onDisk`)、
|
|
127
|
+
条目数、总开关状态、段落就绪时的字符数,以及 `pulls`。
|
|
128
|
+
|
|
129
|
+
### 为什么是 `section=… (pending assembly)` 而不是 `injected=…`
|
|
130
|
+
|
|
131
|
+
**「段落已就绪」≠「已经注入」。** 注入发生在宿主装配提示词时;插件在挂载那一刻**什么都没注入过**,
|
|
132
|
+
打印 `injected=437 chars` 会让人误以为记忆已经进入了某个提示词。这是本插件此前一处会误导排查的措辞。
|
|
133
|
+
|
|
134
|
+
真正的"到达了提示词"的信号是 **`pulls`**:宿主每次对这段做装配,插件注册的那个 thunk 就被求值一次,
|
|
135
|
+
计数器加一。因此
|
|
136
|
+
|
|
137
|
+
- `pulls=0` = 已准备、**从未装配**(进程刚起来、或会话空闲);
|
|
138
|
+
- `pulls=N` = 已被拉进提示词 N 次。
|
|
139
|
+
|
|
140
|
+
插件看不到会话或轮次,**不会**报告 `sessions=` 之类的数字——那会是编造。它只报告自己能真正观测到的东西。
|
|
141
|
+
`pulls` 同时出现在路由的 `state` 快照里(`injection.pulls`)。每次拉取另有一条 `debug` 行:
|
|
142
|
+
`section pulled into a prompt (pull #N)`。
|
|
143
|
+
|
|
144
|
+
**这是纯日志,绝不影响注入内容**:同一份存储在"有 logger"和"没有 logger"下渲染出的文本
|
|
145
|
+
**逐字节相同**(`verify.mjs` 有断言锁定这一点)。`debug` 级别由宿主日志级别过滤;
|
|
146
|
+
没有 logger 服务的环境下需要 `DSH_INSTRUCTION_MEMORY_DEBUG=1` 才会输出到标准错误。
|
|
147
|
+
|
|
148
|
+
## 请求来源防护(Host / Origin)
|
|
149
|
+
|
|
150
|
+
设置页路由之所以要求 `content-type: application/json`,是因为 HTML 表单无法伪造 JSON body,
|
|
151
|
+
而跨源 `fetch` 带 JSON 会先在 preflight 失败。**这两条防线都建立在"浏览器认为这是跨源"之上,
|
|
152
|
+
DNS 重绑定恰好推翻了它**:`evil.com` 解析到 `127.0.0.1` 之后,页面与服务器在浏览器眼里是
|
|
153
|
+
**同源**——不发 preflight、JSON 也合法,于是任何网页都能改写用户那份"注入到此后每一轮
|
|
154
|
+
对话"的长期指令。此时唯一无法伪造的就是浏览器发出的 `Host` 头。
|
|
155
|
+
|
|
156
|
+
因此当服务器绑定在回环地址(默认)时,路由**要求 `Host` 是回环**,另外校验 `Origin`(若存在)
|
|
157
|
+
与 `Host` 同源。命中即 **403**,并记一行日志:
|
|
158
|
+
|
|
159
|
+
```
|
|
160
|
+
[instruction-memory] route rejected a request: Host 头不是回环地址(可能是 DNS 重绑定攻击):evil.com:8080
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
- 绑定 `--host 0.0.0.0`(主动对所有网卡开放)时 Host 校验**自动关闭**——那种情况下无法预知合法
|
|
164
|
+
Host,强行限制只会打断所有正常客户端;`Origin` 校验仍然生效。
|
|
165
|
+
- 无 `Host` 的 HTTP/1.1 请求由 Node 的 HTTP 解析器先以 400 拒掉,根本到不了路由。
|
|
166
|
+
- 端口不参与 `Origin` 比对(反向代理可能改写端口),主机名才是重绑定攻击的对象。
|
|
74
167
|
|
|
75
168
|
## 兼容性
|
|
76
169
|
|
|
@@ -101,8 +194,25 @@ major.minor.patch 且自身带预发布标签的比较符"。这条规则是**
|
|
|
101
194
|
⚠️ **一个必须知道的上限**:**未声明的未来 minor 的预发布版本,无法被任何范围覆盖。**
|
|
102
195
|
`0.1.8-rc.1` 这类版本 tuple 与所有比较符都不同,在 npm 语义下**永远匹配不到**——与
|
|
103
196
|
`^0.1.0` 无法匹配 `0.1.8-rc.1` 是同一个限制。正式版(`0.1.8`、`0.2.0`、`1.0.0` 等)不受影响。
|
|
104
|
-
|
|
105
|
-
|
|
197
|
+
|
|
198
|
+
**这个限制无法用代码消除,但它的后果已经被消掉了。** 范围现在是**生成出来的**:
|
|
199
|
+
`host-range-test.mjs` 顶部的 `COVERED_LINES` 是唯一事实来源,package.json 里那串长字符串必须
|
|
200
|
+
逐字节等于由它生成的结果(有专门的漂移断言),所以手改长串导致"枚举漏了一段"这类错误不可能
|
|
201
|
+
再发生——DSH 发新 minor 时只需在列表末尾加一行。
|
|
202
|
+
|
|
203
|
+
同时**实时守卫**(直接读本机实际安装的宿主版本)在发现未覆盖的宿主时,会打印**精确到行**的
|
|
204
|
+
修复配方,并且在打印前**先验证该配方真的能让那个版本被放行**:
|
|
205
|
+
|
|
206
|
+
```
|
|
207
|
+
FAIL LIVE GUARD: the installed host 0.1.8-rc.1 is admitted by both modes -> ...
|
|
208
|
+
Fix: append { floor: '0.1.8-alpha.0', below: '0.2.0-0' } to COVERED_LINES
|
|
209
|
+
and set the preceding line's below to '0.1.8-0'
|
|
210
|
+
[verified: that edit admits 0.1.8-rc.1]
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
这份配方本身也有测试:`host-range-test.mjs` 会拿 `0.1.8-rc.1` 走一遍"确认当前范围拒收 →
|
|
214
|
+
套用配方 → 确认放行 → 确认下方版本仍被拒收 → 确认所有已覆盖版本仍被放行"。配方失效会先失败在
|
|
215
|
+
测试里,而不是失败在用户的安装上。
|
|
106
216
|
|
|
107
217
|
**历史修复说明**(同一个 bug 出现过两次,值得记下来):
|
|
108
218
|
|
|
@@ -110,6 +220,8 @@ DSH 发布新的预发布 minor 时需追加一段,例如 `|| >=0.1.8-alpha.0
|
|
|
110
220
|
被判为**不兼容**(预发布门禁要求 tuple 相同:`0.1.5 ≠ 0.1.6`,`>=` 的大小比较根本走不到)。
|
|
111
221
|
- 1.0.8 改为逐段枚举,但只枚举到 `0.1.6`。DSH 随后发布 `0.1.7-rc.2`,**同一个假阴性再次出现**:
|
|
112
222
|
范围里最后一个预发布 tuple 是 `0.1.6`,而 `0.1.7 ≠ 0.1.6`。1.0.9 补上 `0.1.7` 段。
|
|
223
|
+
- 1.0.10 把枚举抽成 `COVERED_LINES` 单一事实来源 + 漂移/可达/递增断言,并让实时守卫给出
|
|
224
|
+
**自验证**的修复配方——这个 bug 的**成因**(手改长串)从此被消掉。
|
|
113
225
|
|
|
114
226
|
两次都因为插件市场传入 `includePrerelease: true` 而侥幸放行,但任何标准 semver 判定
|
|
115
227
|
(`npm` / `pnpm` 的依赖解析)都会拒绝。现在 `host-range-test.mjs` 同时具备固定矩阵与实时守卫。
|
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,26 +10,35 @@
|
|
|
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'
|
|
@@ -59,8 +68,6 @@ const SECTION_NAME = 'instruction-memory'
|
|
|
59
68
|
*/
|
|
60
69
|
const SECTION_ORDER = 950
|
|
61
70
|
const API_PREFIX = '/instruction-memory/api'
|
|
62
|
-
/** Request body ceiling for the settings-page route; anything larger is rejected. */
|
|
63
|
-
const MAX_BODY_CHARS = 1_000_000
|
|
64
71
|
|
|
65
72
|
/**
|
|
66
73
|
* Resolve this plugin's data file under the harness home.
|
|
@@ -83,6 +90,21 @@ const MAX_TITLE = 120
|
|
|
83
90
|
const MAX_CONTENT = 6000
|
|
84
91
|
const MAX_WHEN = 200
|
|
85
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
|
+
|
|
86
108
|
/* ------------------------------------------------------------------ *
|
|
87
109
|
* Pure helpers (no ctx, fully unit-testable)
|
|
88
110
|
* ------------------------------------------------------------------ */
|
|
@@ -143,6 +165,28 @@ export function sanitizeEntry(raw) {
|
|
|
143
165
|
}
|
|
144
166
|
}
|
|
145
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
|
+
|
|
146
190
|
/**
|
|
147
191
|
* Load a parsed memory file. Returns the sanitized data plus how many array
|
|
148
192
|
* items were discarded (blank entries, or valid ones past the cap). The count
|
|
@@ -175,11 +219,107 @@ function normalizeData(raw) {
|
|
|
175
219
|
return { data, dropped }
|
|
176
220
|
}
|
|
177
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
|
+
|
|
178
240
|
const droppedEntriesMessage = (count) =>
|
|
179
241
|
count > 0
|
|
180
242
|
? '存储文件中有 ' + count + ' 条条目未能载入(内容为空或超过 ' + MAX_ENTRIES + ' 条上限);下次保存时它们将从文件中移除'
|
|
181
243
|
: null
|
|
182
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
|
+
|
|
183
323
|
function formatEntry(entry, tag) {
|
|
184
324
|
const lines = ['[' + tag + '|优先级 ' + priorityLabel(entry.priority) + '] ' + entry.title]
|
|
185
325
|
if (entry.mode === 'auto' && entry.when !== '') lines.push('适用场景:' + entry.when)
|
|
@@ -282,7 +422,6 @@ export function buildBlock(data) {
|
|
|
282
422
|
|
|
283
423
|
export function apply(ctx) {
|
|
284
424
|
const systemPrompt = ctx.systemPrompt
|
|
285
|
-
const webServer = ctx.get('webServer')
|
|
286
425
|
|
|
287
426
|
const state = {
|
|
288
427
|
data: { version: 1, enabled: true, budgetChars: DEFAULT_BUDGET, entries: [] },
|
|
@@ -290,13 +429,39 @@ export function apply(ctx) {
|
|
|
290
429
|
storageDisplay: null,
|
|
291
430
|
storageError: null,
|
|
292
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,
|
|
293
436
|
savedAt: null,
|
|
294
437
|
migratedFrom: null,
|
|
295
438
|
sectionRegistered: false,
|
|
296
439
|
sectionError: null,
|
|
440
|
+
/** Whether a store file is actually present on disk (diagnostics + the panel). */
|
|
441
|
+
storeExists: false,
|
|
297
442
|
}
|
|
298
443
|
let sectionDispose = null
|
|
299
444
|
|
|
445
|
+
/**
|
|
446
|
+
* A first run deliberately writes NOTHING (see readFromDisk step 3). This flag
|
|
447
|
+
* remembers that the store has not been materialised yet, so the file is
|
|
448
|
+
* created by the first action that genuinely needs it — a save, or the
|
|
449
|
+
* settings page asking for `state` — instead of by the bare act of the host
|
|
450
|
+
* booting with no session and no task.
|
|
451
|
+
*/
|
|
452
|
+
let needsMaterialize = false
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* How many times the host has pulled this section into a prompt, since boot.
|
|
456
|
+
*
|
|
457
|
+
* The plugin cannot see sessions or turns; it only knows that the harness
|
|
458
|
+
* evaluated the thunk it registered. That is still the honest answer to "did
|
|
459
|
+
* this actually reach a prompt?", and it is the thing the previous log line
|
|
460
|
+
* could not distinguish: a registered section with pulls=0 has been prepared
|
|
461
|
+
* but never injected.
|
|
462
|
+
*/
|
|
463
|
+
let sectionPulls = 0
|
|
464
|
+
|
|
300
465
|
const describeError = (err) => {
|
|
301
466
|
if (err === null || err === undefined) return '未知错误'
|
|
302
467
|
if (typeof err === 'string') return err
|
|
@@ -304,6 +469,56 @@ export function apply(ctx) {
|
|
|
304
469
|
return String(err)
|
|
305
470
|
}
|
|
306
471
|
|
|
472
|
+
/* ---------------- diagnostics ---------------- */
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* The plugin used to be completely silent on success: every load, save and
|
|
476
|
+
* injection produced no record at all, and only failures reached
|
|
477
|
+
* `console.error`. "Is memory actually doing anything?" was therefore
|
|
478
|
+
* unanswerable from logs — verifying it meant substituting route snapshots
|
|
479
|
+
* and transcript forensics for real evidence.
|
|
480
|
+
*
|
|
481
|
+
* This adds an opt-in diagnostic channel on the harness logger. It is
|
|
482
|
+
* log-only: it never touches what gets injected, so the rendered block stays
|
|
483
|
+
* byte-identical with it on or off.
|
|
484
|
+
*
|
|
485
|
+
* info — one line per mount (where the store is, how much is injected)
|
|
486
|
+
* debug — per-operation detail, including the ones the route guards reject
|
|
487
|
+
*
|
|
488
|
+
* With no logger service (the offline harnesses) debug needs
|
|
489
|
+
* DSH_INSTRUCTION_MEMORY_DEBUG=1; info always falls back to console.error so
|
|
490
|
+
* a mount failure is never swallowed.
|
|
491
|
+
*/
|
|
492
|
+
const logger = (() => {
|
|
493
|
+
try {
|
|
494
|
+
const candidate = typeof ctx.logger === 'function' ? ctx.logger('instruction-memory') : ctx.logger
|
|
495
|
+
if (candidate !== null && candidate !== undefined && typeof candidate.info === 'function') return candidate
|
|
496
|
+
} catch {
|
|
497
|
+
// A context without a logger service must never break the plugin.
|
|
498
|
+
}
|
|
499
|
+
return null
|
|
500
|
+
})()
|
|
501
|
+
|
|
502
|
+
const debugToConsole = (() => {
|
|
503
|
+
const raw = typeof process.env.DSH_INSTRUCTION_MEMORY_DEBUG === 'string'
|
|
504
|
+
? process.env.DSH_INSTRUCTION_MEMORY_DEBUG.trim().toLowerCase()
|
|
505
|
+
: ''
|
|
506
|
+
return raw === '1' || raw === 'true' || raw === 'yes'
|
|
507
|
+
})()
|
|
508
|
+
|
|
509
|
+
const logInfo = (message) => {
|
|
510
|
+
if (logger !== null) logger.info(message)
|
|
511
|
+
else console.error('[instruction-memory] ' + message)
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const logDebug = (message) => {
|
|
515
|
+
if (logger !== null) {
|
|
516
|
+
if (typeof logger.debug === 'function') logger.debug(message)
|
|
517
|
+
return
|
|
518
|
+
}
|
|
519
|
+
if (debugToConsole) console.error('[instruction-memory] ' + message)
|
|
520
|
+
}
|
|
521
|
+
|
|
307
522
|
const sectionText = () => {
|
|
308
523
|
try {
|
|
309
524
|
return buildBlock(state.data)
|
|
@@ -313,6 +528,21 @@ export function apply(ctx) {
|
|
|
313
528
|
}
|
|
314
529
|
}
|
|
315
530
|
|
|
531
|
+
/**
|
|
532
|
+
* The thunk handed to the harness.
|
|
533
|
+
*
|
|
534
|
+
* It is deliberately NOT `sectionText` itself: wrapping it lets the plugin
|
|
535
|
+
* count the pulls the host performs (`assembled`), which is the only
|
|
536
|
+
* first-hand evidence of "this reached a prompt" available on this side of the
|
|
537
|
+
* API. The returned text is untouched — the render stays byte-identical
|
|
538
|
+
* (verify.mjs pins that with and without a logger attached).
|
|
539
|
+
*/
|
|
540
|
+
const assembledSectionText = () => {
|
|
541
|
+
sectionPulls += 1
|
|
542
|
+
logDebug('section pulled into a prompt (pull #' + sectionPulls + ')')
|
|
543
|
+
return sectionText()
|
|
544
|
+
}
|
|
545
|
+
|
|
316
546
|
/**
|
|
317
547
|
* Keep the registered section exactly in step with the stored data: present
|
|
318
548
|
* when something must be injected, absent otherwise. An empty memory must
|
|
@@ -322,9 +552,10 @@ export function apply(ctx) {
|
|
|
322
552
|
const wanted = sectionText() !== ''
|
|
323
553
|
if (wanted && sectionDispose === null) {
|
|
324
554
|
try {
|
|
325
|
-
sectionDispose = systemPrompt.section({ name: SECTION_NAME, order: SECTION_ORDER, text:
|
|
555
|
+
sectionDispose = systemPrompt.section({ name: SECTION_NAME, order: SECTION_ORDER, text: assembledSectionText })
|
|
326
556
|
state.sectionRegistered = true
|
|
327
557
|
state.sectionError = null
|
|
558
|
+
logDebug('prompt section registered: order=' + SECTION_ORDER + ' chars=' + sectionText().length)
|
|
328
559
|
} catch (err) {
|
|
329
560
|
sectionDispose = null
|
|
330
561
|
state.sectionRegistered = false
|
|
@@ -339,6 +570,7 @@ export function apply(ctx) {
|
|
|
339
570
|
sectionDispose = null
|
|
340
571
|
try {
|
|
341
572
|
dispose()
|
|
573
|
+
logDebug('prompt section disposed (nothing to inject)')
|
|
342
574
|
} catch (err) {
|
|
343
575
|
console.error('[instruction-memory] section dispose failed:', err)
|
|
344
576
|
}
|
|
@@ -371,16 +603,33 @@ export function apply(ctx) {
|
|
|
371
603
|
// one-version backup: a crash mid-write must never leave a truncated
|
|
372
604
|
// memory.json, and an edit the user regrets must still be recoverable
|
|
373
605
|
// from memory.json.bak. The .bak holds the previous good version.
|
|
606
|
+
//
|
|
607
|
+
// The backup is a COPY, not a rename. Renaming the live file out of the way
|
|
608
|
+
// first opened a window in which memory.json did not exist: if the rename
|
|
609
|
+
// that followed failed (a lock, a full disk, an indexer holding the file)
|
|
610
|
+
// the primary path stayed missing, and the next boot — finding no
|
|
611
|
+
// memory.json — materialised an EMPTY store and silently discarded the
|
|
612
|
+
// user's memory while their real data sat untouched in .bak. Copying keeps
|
|
613
|
+
// the primary in place until the atomic replace succeeds, so a failure at
|
|
614
|
+
// any step leaves the previous store readable at its own path.
|
|
374
615
|
const tmpPath = storePath + '.tmp'
|
|
375
616
|
const bakPath = storePath + '.bak'
|
|
376
617
|
try {
|
|
377
618
|
await mkdir(dirname(storePath), { recursive: true })
|
|
378
|
-
|
|
619
|
+
const text = serialize()
|
|
620
|
+
await writeFile(tmpPath, text, 'utf8')
|
|
379
621
|
try {
|
|
380
|
-
await
|
|
622
|
+
await copyFile(storePath, bakPath)
|
|
623
|
+
state.backupError = null
|
|
381
624
|
} catch (err) {
|
|
382
|
-
//
|
|
383
|
-
|
|
625
|
+
// ENOENT is simply the very first save: there is no previous version
|
|
626
|
+
// to roll. Any other failure means the promised rolling backup did not
|
|
627
|
+
// happen — non-fatal for this save, because the primary is still intact
|
|
628
|
+
// and the replace below is the real gate, but it must be disclosed
|
|
629
|
+
// rather than swallowed.
|
|
630
|
+
state.backupError = err.code === 'ENOENT'
|
|
631
|
+
? null
|
|
632
|
+
: '上一版未能保留为 memory.json.bak:' + describeError(err)
|
|
384
633
|
}
|
|
385
634
|
try {
|
|
386
635
|
await rename(tmpPath, storePath)
|
|
@@ -391,7 +640,14 @@ export function apply(ctx) {
|
|
|
391
640
|
state.storagePath = storePath
|
|
392
641
|
state.storageDisplay = storePath
|
|
393
642
|
state.storageError = null
|
|
643
|
+
state.storeExists = true
|
|
644
|
+
// A successful write is the one thing that makes the file real, so it is
|
|
645
|
+
// also what clears the "not materialised yet" flag set by a first run.
|
|
646
|
+
needsMaterialize = false
|
|
394
647
|
state.savedAt = Date.now()
|
|
648
|
+
logDebug('store written: ' + storePath + ' bytes=' + text.length
|
|
649
|
+
+ ' entries=' + state.data.entries.length
|
|
650
|
+
+ (state.backupError === null ? ' backup=refreshed' : ' backup=FAILED'))
|
|
395
651
|
return true
|
|
396
652
|
} catch (err) {
|
|
397
653
|
state.storageError = '保存失败:' + describeError(err)
|
|
@@ -408,6 +664,8 @@ export function apply(ctx) {
|
|
|
408
664
|
state.storagePath = storePath
|
|
409
665
|
state.storageDisplay = storePath
|
|
410
666
|
state.storageWarning = null
|
|
667
|
+
state.loadedFrom = null
|
|
668
|
+
state.storeExists = false
|
|
411
669
|
|
|
412
670
|
// 1. The canonical location under the harness home.
|
|
413
671
|
try {
|
|
@@ -416,6 +674,8 @@ export function apply(ctx) {
|
|
|
416
674
|
const loaded = normalizeData(parsed)
|
|
417
675
|
state.data = loaded.data
|
|
418
676
|
state.storageWarning = droppedEntriesMessage(loaded.dropped)
|
|
677
|
+
state.loadedFrom = 'memory.json'
|
|
678
|
+
state.storeExists = true
|
|
419
679
|
}
|
|
420
680
|
state.storageError = null
|
|
421
681
|
return
|
|
@@ -435,6 +695,7 @@ export function apply(ctx) {
|
|
|
435
695
|
state.storageWarning = (droppedEntriesMessage(loaded.dropped) || '')
|
|
436
696
|
+ (loaded.dropped > 0 ? ';' : '')
|
|
437
697
|
+ 'memory.json 解析失败(' + describeError(err) + '),已临时回退到最近备份 memory.json.bak;损坏文件已保留,下次保存时会覆盖它'
|
|
698
|
+
state.loadedFrom = 'memory.json.bak (main file corrupt)'
|
|
438
699
|
return
|
|
439
700
|
}
|
|
440
701
|
} catch {
|
|
@@ -449,6 +710,31 @@ export function apply(ctx) {
|
|
|
449
710
|
}
|
|
450
711
|
}
|
|
451
712
|
|
|
713
|
+
// 1b. The primary file is gone but a rolling backup survives. That is the
|
|
714
|
+
// state an interrupted save leaves behind (the backup step used to
|
|
715
|
+
// rename the primary out of the way before the replace), and it is also
|
|
716
|
+
// what a stray delete looks like. Falling through to "first run" here
|
|
717
|
+
// would materialise an EMPTY store and silently present the user with
|
|
718
|
+
// no memory at all while their previous version sat right there in
|
|
719
|
+
// .bak — so recover it, and promote the recovered data back to the
|
|
720
|
+
// canonical path so the next boot finds it normally.
|
|
721
|
+
try {
|
|
722
|
+
const backup = await readStoreFile(storePath + '.bak')
|
|
723
|
+
if (backup !== null) {
|
|
724
|
+
const loaded = normalizeData(backup)
|
|
725
|
+
state.data = loaded.data
|
|
726
|
+
const promoted = await writeToDisk()
|
|
727
|
+
state.storageWarning = (droppedEntriesMessage(loaded.dropped) || '')
|
|
728
|
+
+ (loaded.dropped > 0 ? ';' : '')
|
|
729
|
+
+ 'memory.json 不存在,已从最近备份 memory.json.bak 恢复'
|
|
730
|
+
+ (promoted ? ',并已写回 memory.json' : ';写回 memory.json 失败,下次保存时会重试')
|
|
731
|
+
state.loadedFrom = 'memory.json.bak (main file missing)'
|
|
732
|
+
return
|
|
733
|
+
}
|
|
734
|
+
} catch {
|
|
735
|
+
// An unusable backup must never block a fresh store; fall through.
|
|
736
|
+
}
|
|
737
|
+
|
|
452
738
|
// 2. Migrate the pre-release location (host process cwd). This is the only
|
|
453
739
|
// reason the legacy name is still known to this plugin.
|
|
454
740
|
const legacyPath = join(process.cwd(), LEGACY_FILE_NAME)
|
|
@@ -463,6 +749,7 @@ export function apply(ctx) {
|
|
|
463
749
|
// Keep the original as evidence instead of deleting user data.
|
|
464
750
|
await rename(legacyPath, legacyPath + '.migrated').catch(() => {})
|
|
465
751
|
}
|
|
752
|
+
state.loadedFrom = 'legacy cwd file (migrated)'
|
|
466
753
|
return
|
|
467
754
|
}
|
|
468
755
|
} catch (err) {
|
|
@@ -470,9 +757,31 @@ export function apply(ctx) {
|
|
|
470
757
|
if (err instanceof SyntaxError) state.storageError = '旧位置文件解析失败,已忽略:' + describeError(err)
|
|
471
758
|
}
|
|
472
759
|
|
|
473
|
-
// 3. First run:
|
|
474
|
-
//
|
|
475
|
-
|
|
760
|
+
// 3. First run: there is no store anywhere. Deliberately write NOTHING.
|
|
761
|
+
//
|
|
762
|
+
// This used to materialise an empty memory.json so the settings page had
|
|
763
|
+
// a real path to show. That made the plugin perform a disk write at
|
|
764
|
+
// process boot — i.e. with no session, no turn and no task — which is the
|
|
765
|
+
// one side effect that a behaviour audit can legitimately call "running
|
|
766
|
+
// with nothing to do". The file is now created by the first action that
|
|
767
|
+
// genuinely needs it: a save, an import, or the settings page reading
|
|
768
|
+
// `state` (see ensureStoreMaterialized). Until then the panel shows
|
|
769
|
+
// "尚未写入磁盘", a state its UI already renders.
|
|
770
|
+
needsMaterialize = true
|
|
771
|
+
state.loadedFrom = 'first run (no store on disk yet)'
|
|
772
|
+
state.storeExists = false
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* Write the empty store, once, the first time something actually needs the
|
|
777
|
+
* file to exist. A failed attempt leaves the flag set so the next caller
|
|
778
|
+
* retries instead of silently pretending the file is there.
|
|
779
|
+
*
|
|
780
|
+
* @returns whether a write was performed and succeeded.
|
|
781
|
+
*/
|
|
782
|
+
const ensureStoreMaterialized = async () => {
|
|
783
|
+
if (!needsMaterialize) return false
|
|
784
|
+
return writeToDisk()
|
|
476
785
|
}
|
|
477
786
|
|
|
478
787
|
/* ---------------- boot ---------------- */
|
|
@@ -486,6 +795,20 @@ export function apply(ctx) {
|
|
|
486
795
|
})
|
|
487
796
|
.then(() => {
|
|
488
797
|
syncSection()
|
|
798
|
+
// The one always-on line: it answers "is memory mounted, where does it
|
|
799
|
+
// live, and how much is being injected?" without any further digging.
|
|
800
|
+
// 措辞很重要:`section=` 说的是"这个段落已就绪、若被装配会产出多少字符",
|
|
801
|
+
// 而不是"已经注入"。在零会话时打印 `injected=` 会让人误以为记忆已经进入
|
|
802
|
+
// 了某个提示词。真正的注入证据是 pulls:宿主每拉取一次加一(初始必为 0)。
|
|
803
|
+
const chars = sectionText().length
|
|
804
|
+
logInfo('mounted: store=' + storePath
|
|
805
|
+
+ ' from=' + String(state.loadedFrom)
|
|
806
|
+
+ ' onDisk=' + (state.storeExists ? 'yes' : 'no')
|
|
807
|
+
+ ' entries=' + state.data.entries.length
|
|
808
|
+
+ ' enabled=' + (state.data.enabled === true)
|
|
809
|
+
+ ' section=' + (chars > 0 ? chars + ' chars (pending assembly)' : 'nothing to inject')
|
|
810
|
+
+ ' pulls=' + sectionPulls
|
|
811
|
+
+ (state.storageError !== null ? ' error=' + state.storageError : ''))
|
|
489
812
|
})
|
|
490
813
|
|
|
491
814
|
/**
|
|
@@ -517,15 +840,23 @@ export function apply(ctx) {
|
|
|
517
840
|
storage: {
|
|
518
841
|
path: state.storageDisplay !== null ? state.storageDisplay : state.storagePath,
|
|
519
842
|
error: state.storageError,
|
|
520
|
-
warning: state.storageWarning,
|
|
843
|
+
warning: state.storageWarning !== null ? state.storageWarning : state.backupError,
|
|
521
844
|
savedAt: state.savedAt,
|
|
522
845
|
migratedFrom: state.migratedFrom,
|
|
846
|
+
/** false until the store file has actually been written (see readFromDisk step 3). */
|
|
847
|
+
onDisk: state.storeExists,
|
|
523
848
|
},
|
|
524
849
|
injection: {
|
|
525
850
|
registered: state.sectionRegistered,
|
|
526
851
|
available: true,
|
|
527
852
|
error: state.sectionError,
|
|
528
853
|
chars: preview.length,
|
|
854
|
+
/**
|
|
855
|
+
* How many times the harness has pulled this section into a prompt since
|
|
856
|
+
* boot. 0 means "prepared, never assembled" — the distinction the old
|
|
857
|
+
* `injected=` log line could not make.
|
|
858
|
+
*/
|
|
859
|
+
pulls: sectionPulls,
|
|
529
860
|
},
|
|
530
861
|
preview,
|
|
531
862
|
limits: { minBudget: MIN_BUDGET, maxBudget: MAX_BUDGET, maxEntries: MAX_ENTRIES },
|
|
@@ -541,46 +872,78 @@ export function apply(ctx) {
|
|
|
541
872
|
|
|
542
873
|
/* ---------------- commands (shared by route and callers) ---------------- */
|
|
543
874
|
|
|
875
|
+
/**
|
|
876
|
+
* Apply an in-memory mutation, persist it, and UNDO it when the write fails.
|
|
877
|
+
*
|
|
878
|
+
* Every mutating command used to edit `state.data`, re-register the prompt
|
|
879
|
+
* section, and only then find out that the disk write had failed. The panel
|
|
880
|
+
* said 保存失败 and memory.json was correctly left alone — but the injected
|
|
881
|
+
* system prompt kept carrying the unsaved edit for the rest of the process
|
|
882
|
+
* lifetime, so the model went on obeying an instruction the user had just
|
|
883
|
+
* been told was not saved. The successful write is the commit point: fail it
|
|
884
|
+
* and the in-memory state, the prompt section and the panel roll back
|
|
885
|
+
* together.
|
|
886
|
+
*
|
|
887
|
+
* @returns whether the store was persisted.
|
|
888
|
+
*/
|
|
889
|
+
const commit = async (mutate) => {
|
|
890
|
+
const previousData = cloneData(state.data)
|
|
891
|
+
const previousWarning = state.storageWarning
|
|
892
|
+
mutate()
|
|
893
|
+
syncSection()
|
|
894
|
+
if (await writeToDisk()) {
|
|
895
|
+
// A successful save has rewritten the file to match memory, so a
|
|
896
|
+
// dropped-entries warning from load time ("they will be removed on the
|
|
897
|
+
// next save") has played out and must not keep showing.
|
|
898
|
+
state.storageWarning = null
|
|
899
|
+
return true
|
|
900
|
+
}
|
|
901
|
+
logDebug('commit rolled back: the disk write failed, in-memory state restored')
|
|
902
|
+
state.data = previousData
|
|
903
|
+
state.storageWarning = previousWarning
|
|
904
|
+
syncSection()
|
|
905
|
+
return false
|
|
906
|
+
}
|
|
907
|
+
|
|
544
908
|
const saveEntry = (raw) => enqueue(async () => {
|
|
545
909
|
const entry = sanitizeEntry(raw)
|
|
546
910
|
if (entry === null) return { ok: false, saved: false, message: '标题与指令内容不能同时为空', snapshot: snapshot() }
|
|
547
911
|
entry.updatedAt = Date.now()
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
return { ok: false, saved: false, message: '最多保存 ' + MAX_ENTRIES + ' 条指令', snapshot: snapshot() }
|
|
554
|
-
}
|
|
555
|
-
list.push(entry)
|
|
912
|
+
// Resolved before the mutation so a refusal can return a response instead
|
|
913
|
+
// of being swallowed as an early exit inside it.
|
|
914
|
+
const replacesExisting = state.data.entries.some((candidate) => candidate.id === entry.id)
|
|
915
|
+
if (!replacesExisting && state.data.entries.length >= MAX_ENTRIES) {
|
|
916
|
+
return { ok: false, saved: false, message: '最多保存 ' + MAX_ENTRIES + ' 条指令', snapshot: snapshot() }
|
|
556
917
|
}
|
|
557
|
-
|
|
558
|
-
const saved = await
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
918
|
+
const cut = truncatedFields(raw)
|
|
919
|
+
const saved = await commit(() => {
|
|
920
|
+
const list = state.data.entries
|
|
921
|
+
const index = list.findIndex((candidate) => candidate.id === entry.id)
|
|
922
|
+
if (index >= 0) list[index] = entry
|
|
923
|
+
else list.push(entry)
|
|
924
|
+
})
|
|
925
|
+
const note = cut.length > 0
|
|
926
|
+
? '已保存;' + cut.join('、') + '超过长度上限(标题 ' + MAX_TITLE + ' / 内容 ' + MAX_CONTENT + ' / 适用场景 ' + MAX_WHEN + ' 字符),超出部分已被截断'
|
|
927
|
+
: null
|
|
928
|
+
return settle(note, saved)
|
|
564
929
|
})
|
|
565
930
|
|
|
566
931
|
const deleteEntry = (id) => enqueue(async () => {
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
if (saved) state.storageWarning = null
|
|
932
|
+
const saved = await commit(() => {
|
|
933
|
+
state.data.entries = state.data.entries.filter((entry) => entry.id !== id)
|
|
934
|
+
})
|
|
571
935
|
return settle(null, saved)
|
|
572
936
|
})
|
|
573
937
|
|
|
574
938
|
const setOptions = (patch) => enqueue(async () => {
|
|
575
|
-
|
|
576
|
-
if (typeof patch
|
|
577
|
-
|
|
578
|
-
|
|
939
|
+
const saved = await commit(() => {
|
|
940
|
+
if (patch !== null && typeof patch === 'object') {
|
|
941
|
+
if (typeof patch.enabled === 'boolean') state.data.enabled = patch.enabled
|
|
942
|
+
if (typeof patch.budgetChars === 'number' && Number.isFinite(patch.budgetChars)) {
|
|
943
|
+
state.data.budgetChars = clampBudget(patch.budgetChars)
|
|
944
|
+
}
|
|
579
945
|
}
|
|
580
|
-
}
|
|
581
|
-
syncSection()
|
|
582
|
-
const saved = await writeToDisk()
|
|
583
|
-
if (saved) state.storageWarning = null
|
|
946
|
+
})
|
|
584
947
|
return settle(null, saved)
|
|
585
948
|
})
|
|
586
949
|
|
|
@@ -617,10 +980,15 @@ export function apply(ctx) {
|
|
|
617
980
|
// Merge, never replace: importing must not be able to wipe what is here.
|
|
618
981
|
// Duplicates (same id, or same title+content) and invalid items are
|
|
619
982
|
// skipped and counted, not silently dropped.
|
|
983
|
+
//
|
|
984
|
+
// The whole plan is computed BEFORE the store is touched, so the merge and
|
|
985
|
+
// its counters cannot disagree with what actually gets appended.
|
|
620
986
|
const existing = state.data.entries
|
|
621
987
|
const seenIds = new Set(existing.map((entry) => entry.id))
|
|
622
988
|
const seenBodies = new Set(existing.map((entry) => entry.title + '\u0000' + entry.content))
|
|
623
|
-
|
|
989
|
+
const accepted = []
|
|
990
|
+
const cutFields = new Set()
|
|
991
|
+
let truncatedCount = 0
|
|
624
992
|
let skipped = 0
|
|
625
993
|
for (const candidate of rawEntries) {
|
|
626
994
|
const entry = sanitizeEntry(candidate)
|
|
@@ -628,32 +996,52 @@ export function apply(ctx) {
|
|
|
628
996
|
skipped += 1
|
|
629
997
|
continue
|
|
630
998
|
}
|
|
631
|
-
if (existing.length >= MAX_ENTRIES) {
|
|
999
|
+
if (existing.length + accepted.length >= MAX_ENTRIES) {
|
|
632
1000
|
skipped += 1
|
|
633
1001
|
continue
|
|
634
1002
|
}
|
|
1003
|
+
const cut = truncatedFields(candidate)
|
|
1004
|
+
if (cut.length > 0) {
|
|
1005
|
+
// sanitizeEntry enforces the limits silently, which is fine for the
|
|
1006
|
+
// editor (its inputs carry the same maxLength) but wrong here: an
|
|
1007
|
+
// imported 7,000-character instruction would come back as 6,000 with
|
|
1008
|
+
// nothing said about the loss.
|
|
1009
|
+
truncatedCount += 1
|
|
1010
|
+
for (const field of cut) cutFields.add(field)
|
|
1011
|
+
}
|
|
635
1012
|
entry.updatedAt = Date.now()
|
|
636
|
-
|
|
1013
|
+
accepted.push(entry)
|
|
637
1014
|
seenIds.add(entry.id)
|
|
638
1015
|
seenBodies.add(entry.title + '\u0000' + entry.content)
|
|
639
|
-
imported += 1
|
|
640
1016
|
}
|
|
641
|
-
if (
|
|
642
|
-
|
|
1017
|
+
if (accepted.length === 0) {
|
|
1018
|
+
// Nothing was written to disk, so claiming saved:true would be a lie the
|
|
1019
|
+
// panel cannot distinguish from a real save.
|
|
1020
|
+
return { ok: true, saved: false, message: '没有新增条目:' + skipped + ' 条已存在或无效', snapshot: snapshot() }
|
|
643
1021
|
}
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
1022
|
+
const saved = await commit(() => {
|
|
1023
|
+
state.data.entries.push(...accepted)
|
|
1024
|
+
})
|
|
1025
|
+
const truncated = truncatedCount > 0
|
|
1026
|
+
? ';其中 ' + truncatedCount + ' 条超过长度上限,' + [...cutFields].join('、') + '已被截断'
|
|
1027
|
+
: ''
|
|
1028
|
+
return settle('导入完成:新增 ' + accepted.length + ' 条,跳过 ' + skipped + ' 条(已存在或无效)' + truncated, saved)
|
|
648
1029
|
})
|
|
649
1030
|
|
|
650
1031
|
const dispatch = async (method, args) => {
|
|
651
1032
|
await boot
|
|
652
1033
|
switch (method) {
|
|
653
|
-
case 'state':
|
|
1034
|
+
case 'state': {
|
|
1035
|
+
// The settings page is the first thing that needs a real file on disk —
|
|
1036
|
+
// it displays the absolute path, and the user may then hand-edit it.
|
|
1037
|
+
// A first run writes nothing at boot (readFromDisk step 3), so the
|
|
1038
|
+
// materialisation happens here, on a deliberate user action, instead of
|
|
1039
|
+
// as a side effect of the host merely starting up.
|
|
1040
|
+
await ensureStoreMaterialized()
|
|
654
1041
|
// Same envelope as every other method. One response shape means the
|
|
655
1042
|
// Client can never again be handed a payload it silently ignores.
|
|
656
1043
|
return { ok: true, saved: true, message: null, snapshot: snapshot() }
|
|
1044
|
+
}
|
|
657
1045
|
case 'save-entry':
|
|
658
1046
|
return saveEntry(args !== null && typeof args === 'object' ? args.entry : null)
|
|
659
1047
|
case 'delete-entry':
|
|
@@ -673,84 +1061,119 @@ export function apply(ctx) {
|
|
|
673
1061
|
|
|
674
1062
|
/* ---------------- same-origin JSON route for the Client half ---------------- */
|
|
675
1063
|
|
|
1064
|
+
// Which Host values this route accepts. Set when the route is registered,
|
|
1065
|
+
// from the address the web server is actually bound to (see the long note on
|
|
1066
|
+
// requestOriginVerdict). Until then the route does not exist at all, so the
|
|
1067
|
+
// default only matters if something registers it out of band.
|
|
1068
|
+
let guardHost = true
|
|
1069
|
+
|
|
676
1070
|
let routeDispose = null
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
)
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
1071
|
+
const handler = (req, res) => {
|
|
1072
|
+
if (req.method !== 'POST') {
|
|
1073
|
+
res.writeHead(405, { 'content-type': 'application/json; charset=utf-8' })
|
|
1074
|
+
res.end(JSON.stringify({ ok: false, message: 'only POST is supported' }))
|
|
1075
|
+
return
|
|
1076
|
+
}
|
|
1077
|
+
// Origin/Host guard, before anything else is read. See
|
|
1078
|
+
// requestOriginVerdict() for why the content-type rule below is not enough
|
|
1079
|
+
// on its own.
|
|
1080
|
+
const verdict = requestOriginVerdict(req.headers, guardHost)
|
|
1081
|
+
if (!verdict.ok) {
|
|
1082
|
+
logInfo('route rejected a request: ' + verdict.reason)
|
|
1083
|
+
res.writeHead(403, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
1084
|
+
res.end(JSON.stringify({ ok: false, message: '请求来源被拒绝:' + verdict.reason }))
|
|
1085
|
+
return
|
|
1086
|
+
}
|
|
1087
|
+
// The settings page always posts application/json. Enforcing it turns
|
|
1088
|
+
// the anti-CSRF coincidence (a plain HTML form cannot fabricate a JSON
|
|
1089
|
+
// body) into a hard rule: a cross-site or malformed post now gets a
|
|
1090
|
+
// readable 415 instead of a confusing parse failure.
|
|
1091
|
+
const contentType = String(
|
|
1092
|
+
(req.headers !== null && typeof req.headers === 'object') ? (req.headers['content-type'] || '') : '',
|
|
1093
|
+
)
|
|
1094
|
+
if (contentType.indexOf('application/json') === -1) {
|
|
1095
|
+
res.writeHead(415, { 'content-type': 'application/json; charset=utf-8' })
|
|
1096
|
+
res.end(JSON.stringify({ ok: false, message: 'content-type 必须是 application/json' }))
|
|
1097
|
+
return
|
|
1098
|
+
}
|
|
1099
|
+
// A call aborted mid-flight (settings page closed, tab navigated away)
|
|
1100
|
+
// makes these streams emit 'error'. A server stream with no error
|
|
1101
|
+
// listener turns that into an uncaught exception — i.e. it would take
|
|
1102
|
+
// the whole host process down over one dead HTTP request.
|
|
1103
|
+
req.on('error', () => {})
|
|
1104
|
+
res.on('error', () => {})
|
|
1105
|
+
let body = ''
|
|
1106
|
+
// Over the ceiling we stop accumulating but keep draining, so the
|
|
1107
|
+
// response can go out as a clean 413 instead of a destroyed socket the
|
|
1108
|
+
// Client can only report as a generic network failure.
|
|
1109
|
+
let overflow = false
|
|
1110
|
+
req.setEncoding('utf8')
|
|
1111
|
+
req.on('data', (chunk) => {
|
|
1112
|
+
if (overflow) return
|
|
1113
|
+
body += chunk
|
|
1114
|
+
if (body.length > MAX_BODY_CHARS) {
|
|
1115
|
+
overflow = true
|
|
1116
|
+
body = ''
|
|
695
1117
|
}
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
// Over the ceiling we stop accumulating but keep draining, so the
|
|
704
|
-
// response can go out as a clean 413 instead of a destroyed socket the
|
|
705
|
-
// Client can only report as a generic network failure.
|
|
706
|
-
let overflow = false
|
|
707
|
-
req.setEncoding('utf8')
|
|
708
|
-
req.on('data', (chunk) => {
|
|
709
|
-
if (overflow) return
|
|
710
|
-
body += chunk
|
|
711
|
-
if (body.length > MAX_BODY_CHARS) {
|
|
712
|
-
overflow = true
|
|
713
|
-
body = ''
|
|
1118
|
+
})
|
|
1119
|
+
req.on('end', () => {
|
|
1120
|
+
void (async () => {
|
|
1121
|
+
if (overflow) {
|
|
1122
|
+
res.writeHead(413, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
1123
|
+
res.end(JSON.stringify({ ok: false, message: '请求体超过 ' + MAX_BODY_CHARS + ' 字符上限' }))
|
|
1124
|
+
return
|
|
714
1125
|
}
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
1126
|
+
let result
|
|
1127
|
+
try {
|
|
1128
|
+
const payload = body === '' ? {} : JSON.parse(body)
|
|
1129
|
+
result = await dispatch(payload.method, payload.args === undefined ? null : payload.args)
|
|
1130
|
+
} catch (err) {
|
|
1131
|
+
result = { ok: false, saved: false, message: describeError(err), snapshot: snapshot() }
|
|
1132
|
+
}
|
|
1133
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
1134
|
+
res.end(JSON.stringify(result))
|
|
1135
|
+
})()
|
|
1136
|
+
})
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
// Register the settings-page route from a NON-BLOCKING child fiber. A web
|
|
1140
|
+
// server must not be a precondition for this plugin: in a profile without one
|
|
1141
|
+
// (headless, tui) a blocking dependency would keep the whole row pending,
|
|
1142
|
+
// losing the prompt injection along with the settings page. The callback also
|
|
1143
|
+
// fixes the original race properly — it runs whenever the service is
|
|
1144
|
+
// published, not only if it happened to beat this row to the mount.
|
|
1145
|
+
ctx.inject(['webServer'], (scope) => {
|
|
1146
|
+
const webServer = scope.webServer
|
|
1147
|
+
if (webServer === undefined || typeof webServer.register !== 'function') {
|
|
1148
|
+
console.error('[instruction-memory] webServer published without a register(); the settings page cannot reach the host')
|
|
1149
|
+
return
|
|
734
1150
|
}
|
|
1151
|
+
// The Host allowlist only makes sense when the operator bound the server to
|
|
1152
|
+
// loopback. `0.0.0.0` means they deliberately published it on every
|
|
1153
|
+
// interface, where no Host value can be known in advance — enforcing a
|
|
1154
|
+
// loopback allowlist there would break every legitimate client, so the
|
|
1155
|
+
// check is disabled and the trade-off is documented in the README.
|
|
1156
|
+
const boundHost = typeof webServer.host === 'string' ? webServer.host.trim().toLowerCase() : '127.0.0.1'
|
|
1157
|
+
guardHost = boundHost !== '0.0.0.0'
|
|
735
1158
|
try {
|
|
736
1159
|
routeDispose = webServer.register({ kind: 'prefix', path: API_PREFIX, handler })
|
|
1160
|
+
logDebug('settings route registered at ' + API_PREFIX + ' (boundHost=' + boundHost + ', hostGuard=' + guardHost + ')')
|
|
737
1161
|
} catch (err) {
|
|
738
1162
|
console.error('[instruction-memory] route registration failed:', err)
|
|
739
1163
|
routeDispose = null
|
|
740
1164
|
}
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
console.error('[instruction-memory] route dispose failed:', err)
|
|
1165
|
+
// The child fiber is owned by this one, so unmounting the plugin still
|
|
1166
|
+
// disposes the route even though it was registered here.
|
|
1167
|
+
scope.effect(() => () => {
|
|
1168
|
+
if (routeDispose !== null) {
|
|
1169
|
+
const dispose = routeDispose
|
|
1170
|
+
routeDispose = null
|
|
1171
|
+
try {
|
|
1172
|
+
dispose()
|
|
1173
|
+
} catch (err) {
|
|
1174
|
+
console.error('[instruction-memory] route dispose failed:', err)
|
|
1175
|
+
}
|
|
753
1176
|
}
|
|
754
|
-
}
|
|
1177
|
+
})
|
|
755
1178
|
})
|
|
756
1179
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-instruction-memory",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.11",
|
|
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",
|