dsh-instruction-memory 1.0.9 → 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 +87 -7
- package/lib/client.js +13 -1
- package/lib/index.js +459 -120
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -64,13 +64,74 @@ 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
|
|
|
@@ -101,8 +162,25 @@ major.minor.patch 且自身带预发布标签的比较符"。这条规则是**
|
|
|
101
162
|
⚠️ **一个必须知道的上限**:**未声明的未来 minor 的预发布版本,无法被任何范围覆盖。**
|
|
102
163
|
`0.1.8-rc.1` 这类版本 tuple 与所有比较符都不同,在 npm 语义下**永远匹配不到**——与
|
|
103
164
|
`^0.1.0` 无法匹配 `0.1.8-rc.1` 是同一个限制。正式版(`0.1.8`、`0.2.0`、`1.0.0` 等)不受影响。
|
|
104
|
-
|
|
105
|
-
|
|
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
|
+
测试里,而不是失败在用户的安装上。
|
|
106
184
|
|
|
107
185
|
**历史修复说明**(同一个 bug 出现过两次,值得记下来):
|
|
108
186
|
|
|
@@ -110,6 +188,8 @@ DSH 发布新的预发布 minor 时需追加一段,例如 `|| >=0.1.8-alpha.0
|
|
|
110
188
|
被判为**不兼容**(预发布门禁要求 tuple 相同:`0.1.5 ≠ 0.1.6`,`>=` 的大小比较根本走不到)。
|
|
111
189
|
- 1.0.8 改为逐段枚举,但只枚举到 `0.1.6`。DSH 随后发布 `0.1.7-rc.2`,**同一个假阴性再次出现**:
|
|
112
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 的**成因**(手改长串)从此被消掉。
|
|
113
193
|
|
|
114
194
|
两次都因为插件市场传入 `includePrerelease: true` 而侥幸放行,但任何标准 semver 判定
|
|
115
195
|
(`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,6 +429,10 @@ 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,
|
|
@@ -304,6 +447,56 @@ export function apply(ctx) {
|
|
|
304
447
|
return String(err)
|
|
305
448
|
}
|
|
306
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
|
+
|
|
307
500
|
const sectionText = () => {
|
|
308
501
|
try {
|
|
309
502
|
return buildBlock(state.data)
|
|
@@ -325,6 +518,7 @@ export function apply(ctx) {
|
|
|
325
518
|
sectionDispose = systemPrompt.section({ name: SECTION_NAME, order: SECTION_ORDER, text: sectionText })
|
|
326
519
|
state.sectionRegistered = true
|
|
327
520
|
state.sectionError = null
|
|
521
|
+
logDebug('prompt section registered: order=' + SECTION_ORDER + ' chars=' + sectionText().length)
|
|
328
522
|
} catch (err) {
|
|
329
523
|
sectionDispose = null
|
|
330
524
|
state.sectionRegistered = false
|
|
@@ -339,6 +533,7 @@ export function apply(ctx) {
|
|
|
339
533
|
sectionDispose = null
|
|
340
534
|
try {
|
|
341
535
|
dispose()
|
|
536
|
+
logDebug('prompt section disposed (nothing to inject)')
|
|
342
537
|
} catch (err) {
|
|
343
538
|
console.error('[instruction-memory] section dispose failed:', err)
|
|
344
539
|
}
|
|
@@ -371,16 +566,33 @@ export function apply(ctx) {
|
|
|
371
566
|
// one-version backup: a crash mid-write must never leave a truncated
|
|
372
567
|
// memory.json, and an edit the user regrets must still be recoverable
|
|
373
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.
|
|
374
578
|
const tmpPath = storePath + '.tmp'
|
|
375
579
|
const bakPath = storePath + '.bak'
|
|
376
580
|
try {
|
|
377
581
|
await mkdir(dirname(storePath), { recursive: true })
|
|
378
|
-
|
|
582
|
+
const text = serialize()
|
|
583
|
+
await writeFile(tmpPath, text, 'utf8')
|
|
379
584
|
try {
|
|
380
|
-
await
|
|
585
|
+
await copyFile(storePath, bakPath)
|
|
586
|
+
state.backupError = null
|
|
381
587
|
} catch (err) {
|
|
382
|
-
//
|
|
383
|
-
|
|
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)
|
|
384
596
|
}
|
|
385
597
|
try {
|
|
386
598
|
await rename(tmpPath, storePath)
|
|
@@ -392,6 +604,9 @@ export function apply(ctx) {
|
|
|
392
604
|
state.storageDisplay = storePath
|
|
393
605
|
state.storageError = null
|
|
394
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'))
|
|
395
610
|
return true
|
|
396
611
|
} catch (err) {
|
|
397
612
|
state.storageError = '保存失败:' + describeError(err)
|
|
@@ -408,6 +623,7 @@ export function apply(ctx) {
|
|
|
408
623
|
state.storagePath = storePath
|
|
409
624
|
state.storageDisplay = storePath
|
|
410
625
|
state.storageWarning = null
|
|
626
|
+
state.loadedFrom = null
|
|
411
627
|
|
|
412
628
|
// 1. The canonical location under the harness home.
|
|
413
629
|
try {
|
|
@@ -416,6 +632,7 @@ export function apply(ctx) {
|
|
|
416
632
|
const loaded = normalizeData(parsed)
|
|
417
633
|
state.data = loaded.data
|
|
418
634
|
state.storageWarning = droppedEntriesMessage(loaded.dropped)
|
|
635
|
+
state.loadedFrom = 'memory.json'
|
|
419
636
|
}
|
|
420
637
|
state.storageError = null
|
|
421
638
|
return
|
|
@@ -435,6 +652,7 @@ export function apply(ctx) {
|
|
|
435
652
|
state.storageWarning = (droppedEntriesMessage(loaded.dropped) || '')
|
|
436
653
|
+ (loaded.dropped > 0 ? ';' : '')
|
|
437
654
|
+ 'memory.json 解析失败(' + describeError(err) + '),已临时回退到最近备份 memory.json.bak;损坏文件已保留,下次保存时会覆盖它'
|
|
655
|
+
state.loadedFrom = 'memory.json.bak (main file corrupt)'
|
|
438
656
|
return
|
|
439
657
|
}
|
|
440
658
|
} catch {
|
|
@@ -449,6 +667,31 @@ export function apply(ctx) {
|
|
|
449
667
|
}
|
|
450
668
|
}
|
|
451
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
|
+
|
|
452
695
|
// 2. Migrate the pre-release location (host process cwd). This is the only
|
|
453
696
|
// reason the legacy name is still known to this plugin.
|
|
454
697
|
const legacyPath = join(process.cwd(), LEGACY_FILE_NAME)
|
|
@@ -463,6 +706,7 @@ export function apply(ctx) {
|
|
|
463
706
|
// Keep the original as evidence instead of deleting user data.
|
|
464
707
|
await rename(legacyPath, legacyPath + '.migrated').catch(() => {})
|
|
465
708
|
}
|
|
709
|
+
state.loadedFrom = 'legacy cwd file (migrated)'
|
|
466
710
|
return
|
|
467
711
|
}
|
|
468
712
|
} catch (err) {
|
|
@@ -473,6 +717,7 @@ export function apply(ctx) {
|
|
|
473
717
|
// 3. First run: materialise an empty store so the path is real and the
|
|
474
718
|
// settings page can show it.
|
|
475
719
|
await writeToDisk()
|
|
720
|
+
if (state.loadedFrom === null) state.loadedFrom = 'first run (empty store created)'
|
|
476
721
|
}
|
|
477
722
|
|
|
478
723
|
/* ---------------- boot ---------------- */
|
|
@@ -486,6 +731,15 @@ export function apply(ctx) {
|
|
|
486
731
|
})
|
|
487
732
|
.then(() => {
|
|
488
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 : ''))
|
|
489
743
|
})
|
|
490
744
|
|
|
491
745
|
/**
|
|
@@ -517,7 +771,7 @@ export function apply(ctx) {
|
|
|
517
771
|
storage: {
|
|
518
772
|
path: state.storageDisplay !== null ? state.storageDisplay : state.storagePath,
|
|
519
773
|
error: state.storageError,
|
|
520
|
-
warning: state.storageWarning,
|
|
774
|
+
warning: state.storageWarning !== null ? state.storageWarning : state.backupError,
|
|
521
775
|
savedAt: state.savedAt,
|
|
522
776
|
migratedFrom: state.migratedFrom,
|
|
523
777
|
},
|
|
@@ -541,46 +795,78 @@ export function apply(ctx) {
|
|
|
541
795
|
|
|
542
796
|
/* ---------------- commands (shared by route and callers) ---------------- */
|
|
543
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
|
+
|
|
544
831
|
const saveEntry = (raw) => enqueue(async () => {
|
|
545
832
|
const entry = sanitizeEntry(raw)
|
|
546
833
|
if (entry === null) return { ok: false, saved: false, message: '标题与指令内容不能同时为空', snapshot: snapshot() }
|
|
547
834
|
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)
|
|
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() }
|
|
556
840
|
}
|
|
557
|
-
|
|
558
|
-
const saved = await
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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)
|
|
564
852
|
})
|
|
565
853
|
|
|
566
854
|
const deleteEntry = (id) => enqueue(async () => {
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
if (saved) state.storageWarning = null
|
|
855
|
+
const saved = await commit(() => {
|
|
856
|
+
state.data.entries = state.data.entries.filter((entry) => entry.id !== id)
|
|
857
|
+
})
|
|
571
858
|
return settle(null, saved)
|
|
572
859
|
})
|
|
573
860
|
|
|
574
861
|
const setOptions = (patch) => enqueue(async () => {
|
|
575
|
-
|
|
576
|
-
if (typeof patch
|
|
577
|
-
|
|
578
|
-
|
|
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
|
+
}
|
|
579
868
|
}
|
|
580
|
-
}
|
|
581
|
-
syncSection()
|
|
582
|
-
const saved = await writeToDisk()
|
|
583
|
-
if (saved) state.storageWarning = null
|
|
869
|
+
})
|
|
584
870
|
return settle(null, saved)
|
|
585
871
|
})
|
|
586
872
|
|
|
@@ -617,10 +903,15 @@ export function apply(ctx) {
|
|
|
617
903
|
// Merge, never replace: importing must not be able to wipe what is here.
|
|
618
904
|
// Duplicates (same id, or same title+content) and invalid items are
|
|
619
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.
|
|
620
909
|
const existing = state.data.entries
|
|
621
910
|
const seenIds = new Set(existing.map((entry) => entry.id))
|
|
622
911
|
const seenBodies = new Set(existing.map((entry) => entry.title + '\u0000' + entry.content))
|
|
623
|
-
|
|
912
|
+
const accepted = []
|
|
913
|
+
const cutFields = new Set()
|
|
914
|
+
let truncatedCount = 0
|
|
624
915
|
let skipped = 0
|
|
625
916
|
for (const candidate of rawEntries) {
|
|
626
917
|
const entry = sanitizeEntry(candidate)
|
|
@@ -628,23 +919,36 @@ export function apply(ctx) {
|
|
|
628
919
|
skipped += 1
|
|
629
920
|
continue
|
|
630
921
|
}
|
|
631
|
-
if (existing.length >= MAX_ENTRIES) {
|
|
922
|
+
if (existing.length + accepted.length >= MAX_ENTRIES) {
|
|
632
923
|
skipped += 1
|
|
633
924
|
continue
|
|
634
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
|
+
}
|
|
635
935
|
entry.updatedAt = Date.now()
|
|
636
|
-
|
|
936
|
+
accepted.push(entry)
|
|
637
937
|
seenIds.add(entry.id)
|
|
638
938
|
seenBodies.add(entry.title + '\u0000' + entry.content)
|
|
639
|
-
imported += 1
|
|
640
939
|
}
|
|
641
|
-
if (
|
|
642
|
-
|
|
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() }
|
|
643
944
|
}
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
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)
|
|
648
952
|
})
|
|
649
953
|
|
|
650
954
|
const dispatch = async (method, args) => {
|
|
@@ -673,84 +977,119 @@ export function apply(ctx) {
|
|
|
673
977
|
|
|
674
978
|
/* ---------------- same-origin JSON route for the Client half ---------------- */
|
|
675
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
|
+
|
|
676
986
|
let routeDispose = null
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
)
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
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 = ''
|
|
695
1033
|
}
|
|
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 = ''
|
|
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
|
|
714
1041
|
}
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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
|
|
734
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'
|
|
735
1074
|
try {
|
|
736
1075
|
routeDispose = webServer.register({ kind: 'prefix', path: API_PREFIX, handler })
|
|
1076
|
+
logDebug('settings route registered at ' + API_PREFIX + ' (boundHost=' + boundHost + ', hostGuard=' + guardHost + ')')
|
|
737
1077
|
} catch (err) {
|
|
738
1078
|
console.error('[instruction-memory] route registration failed:', err)
|
|
739
1079
|
routeDispose = null
|
|
740
1080
|
}
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
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
|
+
}
|
|
753
1092
|
}
|
|
754
|
-
}
|
|
1093
|
+
})
|
|
755
1094
|
})
|
|
756
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",
|