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 CHANGED
@@ -64,13 +64,74 @@ DSH 就会得到第二份空记忆,看起来像"记忆消失"。
64
64
  > 首次启动新版本时会自动迁移到上述位置,并把原文件改名为 `*.migrated` 保留。
65
65
 
66
66
  该文件可手工编辑,改完点设置页的「重新载入」即可读回。每次保存先写临时文件再原子替换,
67
- 进程崩溃不会留下损坏的半截 `memory.json`;保存时上一版会保留为 `memory.json.bak`,
68
- 若 `memory.json` 解析失败会自动临时回退到该备份并明确提示。若手工编辑后的文件有条目为空
69
- 或超过 200 条上限,设置页会明确提示哪些条目未能载入(它们将在下次保存时从文件中移除),
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
- DSH 发布新的预发布 minor 时需追加一段,例如 `|| >=0.1.8-alpha.0 <0.2.0-0`;
105
- `host-range-test.mjs` 的**实时守卫**会把这类缺口暴露出来(它直接读本机安装的宿主版本)。
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) throw new Error('宿主返回 HTTP ' + response.status)
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
- * Hard dependencies, declared rather than probed.
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
- * This row mounts early — before the web server has published — so a one-shot
23
- * `ctx.get('webServer')` at apply time resolves `undefined` and the route is
24
- * lost for the life of the process. Declaring it here makes Cordis hold the row
25
- * until the service exists.
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', 'webServer']
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
- await writeFile(tmpPath, serialize(), 'utf8')
582
+ const text = serialize()
583
+ await writeFile(tmpPath, text, 'utf8')
379
584
  try {
380
- await rename(storePath, bakPath)
585
+ await copyFile(storePath, bakPath)
586
+ state.backupError = null
381
587
  } catch (err) {
382
- // No previous version on the very first save; anything else is real.
383
- if (err.code !== 'ENOENT') throw err
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
- const list = state.data.entries
549
- const index = list.findIndex((candidate) => candidate.id === entry.id)
550
- if (index >= 0) list[index] = entry
551
- else {
552
- if (list.length >= MAX_ENTRIES) {
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
- syncSection()
558
- const saved = await writeToDisk()
559
- // A successful save has rewritten the file to match memory, so a
560
- // dropped-entries warning from load time ("they will be removed on the
561
- // next save") has played out and must not keep showing.
562
- if (saved) state.storageWarning = null
563
- return settle(null, saved)
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
- state.data.entries = state.data.entries.filter((entry) => entry.id !== id)
568
- syncSection()
569
- const saved = await writeToDisk()
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
- if (patch !== null && typeof patch === 'object') {
576
- if (typeof patch.enabled === 'boolean') state.data.enabled = patch.enabled
577
- if (typeof patch.budgetChars === 'number' && Number.isFinite(patch.budgetChars)) {
578
- state.data.budgetChars = clampBudget(patch.budgetChars)
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
- let imported = 0
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
- existing.push(entry)
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 (imported === 0) {
642
- return { ok: true, saved: true, message: '没有新增条目:' + skipped + ' 条已存在或无效', snapshot: snapshot() }
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
- syncSection()
645
- const saved = await writeToDisk()
646
- if (saved) state.storageWarning = null
647
- return settle('导入完成:新增 ' + imported + ' 条,跳过 ' + skipped + ' 条(已存在或无效)', saved)
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
- if (webServer !== undefined && typeof webServer.register === 'function') {
678
- const handler = (req, res) => {
679
- if (req.method !== 'POST') {
680
- res.writeHead(405, { 'content-type': 'application/json; charset=utf-8' })
681
- res.end(JSON.stringify({ ok: false, message: 'only POST is supported' }))
682
- return
683
- }
684
- // The settings page always posts application/json. Enforcing it turns
685
- // the anti-CSRF coincidence (a plain HTML form cannot fabricate a JSON
686
- // body) into a hard rule: a cross-site or malformed post now gets a
687
- // readable 415 instead of a confusing parse failure.
688
- const contentType = String(
689
- (req.headers !== null && typeof req.headers === 'object') ? (req.headers['content-type'] || '') : '',
690
- )
691
- if (contentType.indexOf('application/json') === -1) {
692
- res.writeHead(415, { 'content-type': 'application/json; charset=utf-8' })
693
- res.end(JSON.stringify({ ok: false, message: 'content-type 必须是 application/json' }))
694
- return
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
- // A call aborted mid-flight (settings page closed, tab navigated away)
697
- // makes these streams emit 'error'. A server stream with no error
698
- // listener turns that into an uncaught exception — i.e. it would take
699
- // the whole host process down over one dead HTTP request.
700
- req.on('error', () => {})
701
- res.on('error', () => {})
702
- let body = ''
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
- req.on('end', () => {
717
- void (async () => {
718
- if (overflow) {
719
- res.writeHead(413, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
720
- res.end(JSON.stringify({ ok: false, message: '请求体超过 ' + MAX_BODY_CHARS + ' 字符上限' }))
721
- return
722
- }
723
- let result
724
- try {
725
- const payload = body === '' ? {} : JSON.parse(body)
726
- result = await dispatch(payload.method, payload.args === undefined ? null : payload.args)
727
- } catch (err) {
728
- result = { ok: false, saved: false, message: describeError(err), snapshot: snapshot() }
729
- }
730
- res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
731
- res.end(JSON.stringify(result))
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
- } else {
742
- console.error('[instruction-memory] webServer unavailable: settings page will not be able to reach the host')
743
- }
744
-
745
- ctx.effect(() => () => {
746
- if (routeDispose !== null) {
747
- const dispose = routeDispose
748
- routeDispose = null
749
- try {
750
- dispose()
751
- } catch (err) {
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.9",
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",