dsh-instruction-memory 1.4.8 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,55 @@
7
7
  > 1.0.13 起由 CI 经 OIDC Trusted Publishing 发布(首个带 provenance 的版本)。
8
8
  > 这些历史 tag **不应回填**——npm 上的版本号已被占用。
9
9
 
10
+ ## [1.5.0] — 2026-11-04
11
+
12
+ 针对 2026-11-04 全面复核(数据安全 / 并发 / 注入 / SMTP / 设置面板)逐项修复。主线是
13
+ **“合法 JSON 但不是本插件的存储”这一整类输入**:1.4.8 会把 `[]`、`123`、`{"version":"3"}`
14
+ 当作未声明格式的空 store 载入,然后在用户下一次保存时用空库覆盖磁盘。
15
+
16
+ ### 数据安全
17
+
18
+ - **存储形状门**:新增 `storeShapeVerdict()`,区分「不是对象」「对象但不是本插件的存储」
19
+ 「空对象」「合规存储」。前两种不再被采纳,写入被暂停并给出可操作的提示;用户可经历史回滚 /
20
+ 备份整体替换 / 修正文件后重新载入恢复,首次成功写盘后自动解除。
21
+ - **`.bak` 轮转守卫兑现注释承诺**:旧实现只验 `JSON.parse`,畸形主文件会覆盖唯一的好备份;
22
+ 现在只有通过形状门的文件才配称为“上一版备份”。
23
+ - **版本声明收紧**:`{"version":"3"}`、`{"version":"2.0"}` 这类字符串声明不再被当作“未声明版本”
24
+ 而静默降级覆盖,一律拒绝写入。
25
+ - **`adoptParsedStore` 改用同一形状判定**(导入 / 整体替换路径)。
26
+ - **旧位置迁移**:cwd 里的历史文件不是存储时忽略并保留原文件,不再迁移。
27
+
28
+ ### 并发与持久性
29
+
30
+ - **锁回收不再误删**:陈旧锁在 `unlink` 前二次 `stat` 比对 mtime/size,`LOCK_STALE_MS` 10s → 30s。
31
+ - **临时文件唯一化**:`memory.json.tmp-<pid>-<uuid>`,两个进程不会再写同一个临时文件。
32
+ - **落盘栅栏**:tmp 写入 `fsync`,备份复制后 `fsync`,`rename` 后对目录 `fsync`。
33
+ - **重载后强制真写盘**:`readFromDisk` 重置 `lastPersistedKey`,消除“内容巧合相同 → 跳写却报
34
+ saved:true”导致的面板与磁盘分叉;跳写分支同步更新 `savedAt`。
35
+ - 历史清理的设备预算与全局兜底在注释中改为如实描述(跨设备删除是有意保留的例外)。
36
+
37
+ ### 注入与快照
38
+
39
+ - `buildProfilesBlock` 删除死变量;`snapshot()` 的 `injection.profiles` 恢复 `truncated` 标记。
40
+
41
+ ### 反馈邮件(零依赖 SMTP)
42
+
43
+ - EHLO 失败(500/502/504)回落 HELO。
44
+ - 多行响应以最后一行的状态码为准;跨 TCP 块的 UTF-8 用 `StringDecoder` 解码。
45
+ - 服务器未声明 LOGIN/PLAIN 时直接给出机制清单,不再盲目发 `AUTH LOGIN`。
46
+ - `Message-ID` 只接受形如 `local@domain` 的值,其余回退生成;加解密与投递统一走 `mailboxVerdict`。
47
+
48
+ ### 设置面板
49
+
50
+ - **乱序响应不再回退视图**:`absorb()` 拒绝 rev 更旧的快照。
51
+ - **初始加载失败可自愈**:lite 帧在 `view === null` 时也触发重新拉取,并新增「重新载入」按钮。
52
+ - **冲突后不再无限重试**:源码保存的 `baseRev` 在冲突响应后同步为最新 rev;单条记忆在冲突时
53
+ 会先请求确认再覆盖对方修改。
54
+ - **`busy` 改为计数**:首个返回的请求不再提前解锁按钮。
55
+ - **卸载后不再 setState**:组件级 `disposed` 标志。
56
+ - **历史回滚先过体积门**,确认文案带上快照文件名;**切换记忆集前检查用户形象草稿**是否未保存。
57
+ - `normalizeResult` 校验 `data` 是对象且 `entries` 是数组;`resolveSlots()` 的异常不再逃出挂载守卫。
58
+
10
59
  ## [1.4.8] — 2026-11-03
11
60
 
12
61
  针对 2026-11-03 三方复核(架构 / 安全 / 验收)下达的 R1–R11 逐项修复,以及同日第二轮复核
package/lib/client.js CHANGED
@@ -260,6 +260,7 @@ window.__ModuleLoader__.load({
260
260
  'panel.title': '指令记忆',
261
261
  'panel.subtitle': '保存后自动写入系统提示词,对此后所有对话、问答与任务执行生效。',
262
262
  'panel.loading': '正在读取指令记忆…',
263
+ 'panel.retry': '重新载入',
263
264
  'panel.masterSwitch': '总开关',
264
265
  'status.mounted': '已挂载到系统提示词——后续每轮对话自动生效({chars} 字符)',
265
266
  'status.notMountedEmpty': '未挂载:当前没有启用中的指令',
@@ -291,6 +292,7 @@ window.__ModuleLoader__.load({
291
292
  'editor.cancel': '取消',
292
293
  'editor.hint': '保存后对下一轮对话立即生效',
293
294
  'editor.needTitleOrContent': '请至少填写标题或指令内容',
295
+ 'editor.conflictOverwrite': '这条记忆刚被其他窗口或进程修改过。继续保存会用你当前的编辑内容覆盖对方的修改,确定要继续吗?',
294
296
  'editor.confirmDiscard': '当前编辑内容尚未保存,确定放弃这些修改吗?',
295
297
  'confirm.unavailable': '当前环境无法弹出确认对话框,操作已取消,未做任何改动。',
296
298
  'body.expand': '展开全部',
@@ -493,7 +495,7 @@ window.__ModuleLoader__.load({
493
495
  'history.itemMetaLite': '{size} KB(文件较大,未统计条目数)',
494
496
  'history.foreign': '来自其他设备({device})',
495
497
  'history.restore': '回滚到此版本',
496
- 'history.restoreConfirm': '确定把当前记忆整体回滚到 {time} 的快照(rev {rev})?当前内容仍保留在 memory.json.bak 中,且回滚本身会生成一份新快照,可以再回滚回来。',
498
+ 'history.restoreConfirm': '确定把当前记忆整体回滚到 {time} 的快照(rev {rev},文件 {name})?当前内容仍保留在 memory.json.bak 中,且回滚本身会生成一份新快照,可以再回滚回来。',
497
499
  'history.delete': '删除',
498
500
  'history.deleteConfirm': '确定删除这份历史快照?删除后无法恢复。',
499
501
  'history.dir': '快照目录:{dir}',
@@ -545,6 +547,7 @@ window.__ModuleLoader__.load({
545
547
  'panel.title': 'Instruction Memory',
546
548
  'panel.subtitle': 'Saved instructions are written into the system prompt and apply to every conversation, answer, and task that follows.',
547
549
  'panel.loading': 'Loading instruction memory…',
550
+ 'panel.retry': 'Reload',
548
551
  'panel.masterSwitch': 'Master switch',
549
552
  'status.mounted': 'Mounted into the system prompt — effective for every turn that follows ({chars} chars)',
550
553
  'status.notMountedEmpty': 'Not mounted: no enabled instructions',
@@ -576,6 +579,7 @@ window.__ModuleLoader__.load({
576
579
  'editor.cancel': 'Cancel',
577
580
  'editor.hint': 'Takes effect for the next conversation turn once saved',
578
581
  'editor.needTitleOrContent': 'Fill in at least a title or the instruction content',
582
+ 'editor.conflictOverwrite': 'Another window or process just changed this entry. Saving now replaces their edit with yours. Continue?',
579
583
  'editor.confirmDiscard': 'The draft has unsaved changes. Discard them?',
580
584
  'confirm.unavailable': 'This shell cannot show a confirmation dialog, so the action was cancelled and nothing changed.',
581
585
  'body.expand': 'Show all',
@@ -778,7 +782,7 @@ window.__ModuleLoader__.load({
778
782
  'history.itemMetaLite': '{size} KB (file too large to count items)',
779
783
  'history.foreign': 'From another device ({device})',
780
784
  'history.restore': 'Roll back to this',
781
- 'history.restoreConfirm': 'Roll the whole memory back to the snapshot from {time} (rev {rev})? The current content stays in memory.json.bak, and the rollback itself creates a new snapshot, so you can roll forward again.',
785
+ 'history.restoreConfirm': 'Roll the whole memory back to the snapshot from {time} (rev {rev}, file {name})? The current content stays in memory.json.bak, and the rollback itself creates a new snapshot, so you can roll forward again.',
782
786
  'history.delete': 'Delete',
783
787
  'history.deleteConfirm': 'Delete this snapshot? This cannot be undone.',
784
788
  'history.dir': 'Snapshot folder: {dir}',
@@ -1165,10 +1169,16 @@ window.__ModuleLoader__.load({
1165
1169
  */
1166
1170
  function normalizeResult(result) {
1167
1171
  if (result === null || typeof result !== 'object') return null
1168
- const snapshot = result.snapshot !== undefined && result.snapshot !== null ? result.snapshot : result
1172
+ const hasEnvelope = result.snapshot !== undefined && result.snapshot !== null
1173
+ const snapshot = hasEnvelope ? result.snapshot : result
1169
1174
  // data === null passes a typeof check but poisons every consumer that
1170
1175
  // reads data.budgetChars; it is exactly as unusable as a missing data.
1171
- if (snapshot === null || typeof snapshot !== 'object' || snapshot.data === undefined || snapshot.data === null) return null
1176
+ if (snapshot === null || typeof snapshot !== 'object' || Array.isArray(snapshot)) return null
1177
+ const data = snapshot.data
1178
+ if (data === undefined || data === null || typeof data !== 'object' || Array.isArray(data)) return null
1179
+ // Every consumer reads data.entries and sortEntries() calls .slice() on
1180
+ // it; a non-array threw mid-render instead of surfacing a readable error.
1181
+ if (!Array.isArray(data.entries)) return null
1172
1182
  return {
1173
1183
  snapshot,
1174
1184
  ok: result.ok !== false,
@@ -1986,6 +1996,14 @@ window.__ModuleLoader__.load({
1986
1996
  setError(t('err.unrecognized'))
1987
1997
  return
1988
1998
  }
1999
+ // Out-of-order responses: an older request can resolve after a newer one
2000
+ // (an SSE-triggered refresh racing a POST reply) and would restore a
2001
+ // stale snapshot over fresher data. rev only ever increases, so a
2002
+ // snapshot older than the one already rendered is dropped. Equal revs
2003
+ // are still absorbed — they are the same state.
2004
+ const nextRev = revOfFrom(normalized.snapshot)
2005
+ const currentRev = revOf()
2006
+ if (typeof nextRev === 'number' && typeof currentRev === 'number' && nextRev < currentRev) return
1989
2007
  setView(normalized.snapshot)
1990
2008
  viewRef.current = normalized.snapshot
1991
2009
  // Keep the user's in-progress budget edit when a push arrives while
@@ -2007,17 +2025,29 @@ window.__ModuleLoader__.load({
2007
2025
  }
2008
2026
  }
2009
2027
 
2028
+ // A single boolean was cleared by whichever request finished first, which
2029
+ // re-enabled every button while other requests were still in flight.
2030
+ const pendingRef = React.useRef(0)
2031
+ // The panel can be unmounted while a request is in flight; setState after
2032
+ // unmount is a no-op in React 18 but it also loses the error the user
2033
+ // needed to see. Track disposal so late responses are dropped.
2034
+ const disposedRef = React.useRef(false)
2035
+ React.useEffect(() => () => { disposedRef.current = true }, [])
2036
+
2010
2037
  async function send(method, args) {
2038
+ pendingRef.current += 1
2011
2039
  setBusy(true)
2012
2040
  try {
2013
2041
  const result = await callHost(method, args)
2042
+ if (disposedRef.current) return null
2014
2043
  absorb(result)
2015
2044
  return result
2016
2045
  } catch (err) {
2017
- setError(String(err && err.message ? err.message : err))
2046
+ if (!disposedRef.current) setError(String(err && err.message ? err.message : err))
2018
2047
  return null
2019
2048
  } finally {
2020
- setBusy(false)
2049
+ pendingRef.current = Math.max(0, pendingRef.current - 1)
2050
+ if (pendingRef.current === 0 && !disposedRef.current) setBusy(false)
2021
2051
  }
2022
2052
  }
2023
2053
 
@@ -2140,6 +2170,13 @@ window.__ModuleLoader__.load({
2140
2170
  /** Switch the active set. The one click that changes what is injected. */
2141
2171
  function switchSet(id) {
2142
2172
  if (busy || id === '') return
2173
+ // A switch closes every open form. The entry editor has always asked
2174
+ // before discarding; the persona editor did not, so a typed persona was
2175
+ // silently discarded while the entry form next to it would have asked.
2176
+ const openProfile = profileDraftRef.current
2177
+ if (openProfile !== null
2178
+ && profileIsDirty(openProfile, view !== null && Array.isArray(view.data.profiles) ? view.data.profiles : [])
2179
+ && !confirmDiscard()) return
2143
2180
  void send('set-active-set', { id, baseRev: revOf() }).then((result) => {
2144
2181
  // A switch invalidates any list selection: the ids belong to the set
2145
2182
  // that is no longer displayed.
@@ -2226,8 +2263,17 @@ window.__ModuleLoader__.load({
2226
2263
 
2227
2264
  function rollbackTo(item) {
2228
2265
  if (busy) return
2266
+ // Restoring makes the Host read a whole file; a snapshot far larger
2267
+ // than any real store is refused BEFORE the user confirms an operation
2268
+ // that cannot succeed.
2269
+ const sizeVerdict = importSizeVerdict(Number(item.bytes),
2270
+ view !== null && view.limits !== null && typeof view.limits === 'object' ? view.limits.maxBodyChars : undefined)
2271
+ if (!sizeVerdict.ok) {
2272
+ setError(sizeVerdict.message)
2273
+ return
2274
+ }
2229
2275
  const when = new Date(item.epoch).toLocaleString()
2230
- const ask = askConfirm(t('history.restoreConfirm', { time: when, rev: item.rev }))
2276
+ const ask = askConfirm(t('history.restoreConfirm', { time: when, rev: item.rev, name: item.name }))
2231
2277
  if (ask !== CONFIRM_YES) {
2232
2278
  if (ask === CONFIRM_UNAVAILABLE) setError(t('confirm.unavailable'))
2233
2279
  return
@@ -2377,11 +2423,16 @@ window.__ModuleLoader__.load({
2377
2423
  function saveSource() {
2378
2424
  if (busy) return
2379
2425
  void send('write-source', { text: sourceText, baseRev: sourceBaseRev }).then((result) => {
2380
- if (result === null || result.ok !== true) return
2426
+ if (result === null) return
2427
+ // A conflict response carries the FRESH snapshot. Keeping the old
2428
+ // baseRev made every retry conflict again against a rev the Host had
2429
+ // already told us about — an unwinnable loop with no way forward.
2430
+ const freshRev = revOfFrom(result.snapshot)
2431
+ if (typeof freshRev === 'number') setSourceBaseRev(freshRev)
2432
+ if (result.ok !== true) return
2381
2433
  // The draft now matches the committed store, so the rev it is based
2382
2434
  // on must move with it — otherwise the very next save would be
2383
2435
  // refused as a conflict against our own write.
2384
- setSourceBaseRev(revOfFrom(result.snapshot))
2385
2436
  sourceBaselineRef.current = sourceText
2386
2437
  void loadHistory()
2387
2438
  })
@@ -2584,9 +2635,13 @@ window.__ModuleLoader__.load({
2584
2635
  const pushed = JSON.parse(ev.data)
2585
2636
  if (pushed !== null && typeof pushed === 'object' && pushed.lite === true) {
2586
2637
  const current = viewRef.current
2587
- // A null view means the initial POST `state` is still in flight;
2588
- // its response already carries the current rev.
2589
- if (current !== null && current.data !== null && current.data.rev !== pushed.rev) refresh()
2638
+ // A null view used to be ignored here on the assumption that the
2639
+ // initial POST `state` is always in flight. When that request
2640
+ // failed, nothing ever refetched and the panel stayed on its
2641
+ // loading text for the rest of the session. A lite frame proves
2642
+ // the Host is alive, so it is a safe trigger to retry.
2643
+ if (current === null) refresh()
2644
+ else if (current.data !== null && current.data.rev !== pushed.rev) refresh()
2590
2645
  return
2591
2646
  }
2592
2647
  if (sameSnapshot(pushed, viewRef.current)) return
@@ -2600,7 +2655,11 @@ window.__ModuleLoader__.load({
2600
2655
 
2601
2656
  if (view === null) {
2602
2657
  return h('div', { className: 'im-root' },
2603
- h('div', { className: 'im-empty' }, error === '' ? t('panel.loading') : error))
2658
+ h('div', { className: 'im-empty' }, error === '' ? t('panel.loading') : error),
2659
+ error === ''
2660
+ ? null
2661
+ : h('div', { className: 'im-row im-row-end' },
2662
+ h('button', { type: 'button', className: 'im-btn', onClick: () => { setError(''); send('state', null) } }, t('panel.retry'))))
2604
2663
  }
2605
2664
 
2606
2665
  if (draft !== null) {
@@ -2618,13 +2677,26 @@ window.__ModuleLoader__.load({
2618
2677
  setDraft(null); setError('')
2619
2678
  },
2620
2679
  onSave: () => {
2680
+ if (busy) return
2621
2681
  if (draft.title.trim() === '' && draft.content.trim() === '') {
2622
2682
  setError(t('editor.needTitleOrContent'))
2623
2683
  return
2624
2684
  }
2625
- send('save-entry', { entry: draft, baseRev: revOf() }).then((result) => {
2626
- if (result && result.ok === true) { setDraft(null); setError('') }
2627
- })
2685
+ const commit = (baseRev, allowOverwrite) => {
2686
+ void send('save-entry', { entry: draft, baseRev }).then((result) => {
2687
+ if (result === null) return
2688
+ if (result.ok === true) { setDraft(null); setError(''); return }
2689
+ // The Host refuses a write built on a stale rev and answers
2690
+ // with the fresh snapshot (already absorbed into the view).
2691
+ // Retrying on our own would replace the other window's edit
2692
+ // with this draft — a silent overwrite. Ask first, and only
2693
+ // then retry against the rev we just learned.
2694
+ if (result.conflict === true && !allowOverwrite) {
2695
+ if (askConfirm(t('editor.conflictOverwrite'), 'allow') === CONFIRM_YES) commit(revOf(), true)
2696
+ }
2697
+ })
2698
+ }
2699
+ commit(revOf(), false)
2628
2700
  },
2629
2701
  }),
2630
2702
  // The editor is an early return, so it must render its OWN error
@@ -3707,7 +3779,18 @@ window.__ModuleLoader__.load({
3707
3779
  // published before or after this plugin activates.
3708
3780
  runtimeCtx = ctx
3709
3781
 
3710
- const resolveSlots = () => (ctx.slots !== undefined ? ctx.slots : ctx.get('slots'))
3782
+ const resolveSlots = () => {
3783
+ // ctx.get() can throw on a disposed context, and this call sits outside
3784
+ // safeMount's try — an escaping throw here killed the retry timer with
3785
+ // no message anywhere.
3786
+ try {
3787
+ return ctx.slots !== undefined ? ctx.slots : ctx.get('slots')
3788
+ } catch (err) {
3789
+ console.error('[instruction-memory] 读取 slots 服务失败:'
3790
+ + (err !== null && err !== undefined && typeof err.message === 'string' ? err.message : String(err)))
3791
+ return undefined
3792
+ }
3793
+ }
3711
3794
 
3712
3795
  // A retry can fire after the fiber was disposed (plugin disabled, host
3713
3796
  // shutting down); registering into a dead context throws, and an uncaught
package/lib/index.js CHANGED
@@ -25,7 +25,7 @@
25
25
  * user-chosen backup directory (F18).
26
26
  */
27
27
 
28
- import { chmod, copyFile, mkdir, readdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'
28
+ import { chmod, copyFile, mkdir, open, readdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'
29
29
  import { readFileSync, realpathSync, statSync, watch } from 'node:fs'
30
30
  import { randomUUID } from 'node:crypto'
31
31
  import { isIP } from 'node:net'
@@ -102,6 +102,17 @@ const MAX_HISTORY = 20
102
102
  * when it alone exceeds the budget.
103
103
  */
104
104
  const MAX_HISTORY_BYTES = 32 * 1024 * 1024
105
+ /**
106
+ * The largest single snapshot this build will READ back. Every other read path
107
+ * is bounded (the store by MAX_BODY_CHARS, exports by their own ceilings); the
108
+ * restore path historically was not, so a corrupt or hostile file in the
109
+ * history directory could be pulled whole into memory by one click. Snapshots
110
+ * are serialised stores, so this sits above the store ceiling with room to
111
+ * spare while still refusing an absurd file.
112
+ */
113
+ const MAX_HISTORY_FILE_BYTES = 8 * 1024 * 1024
114
+ /** The feedback config is a handful of keys; anything near this is not one. */
115
+ const MAX_FEEDBACK_CONFIG_BYTES = 1024 * 1024
105
116
  /**
106
117
  * F7: the longest single pause. Doubles as the setTimeout guard — 7 days is
107
118
  * far below the 2^31-1 ms (≈24.8 days) timer ceiling, so the delay never
@@ -288,6 +299,14 @@ export function expandDisplayPath(value, home = homedir(), platform = process.pl
288
299
  * .bak — and only the last two may never be treated as "no memory yet".
289
300
  */
290
301
  const EMPTY_STORE_CODE = 'EEMPTYSTORE'
302
+ /**
303
+ * A primary that parses as JSON but is not a store at all. Same role as
304
+ * EMPTY_STORE_CODE, for the state JSON.parse cannot flag: the file is valid
305
+ * JSON, so "it read" says nothing about whether it is a store. Keeping it a
306
+ * distinct code is what lets the .bak rotation refuse to copy it over the last
307
+ * good backup while leaving the file itself untouched for hand repair.
308
+ */
309
+ const NOT_A_STORE_CODE = 'ENOTASTORE'
291
310
 
292
311
  const DEFAULT_BUDGET = 4000
293
312
  const MIN_BUDGET = 800
@@ -371,8 +390,14 @@ export const LIMITS = Object.freeze({
371
390
  maxFeedbackContact: FEEDBACK_LIMITS.maxContact,
372
391
  })
373
392
 
374
- /** How long a crashed holder's `memory.json.lock` may block writes before it is stolen. */
375
- const LOCK_STALE_MS = 10_000
393
+ /**
394
+ * How long a crashed holder's `memory.json.lock` may block writes before it is
395
+ * stolen. This must exceed the worst-case hold time (tmp write + two copies +
396
+ * rename + a large history snapshot), so a LIVE slow holder is not mistaken for
397
+ * debris and swept mid-write: the reclaim path re-checks stat identity, but a
398
+ * wide margin is cheaper than relying on that.
399
+ */
400
+ const LOCK_STALE_MS = 30_000
376
401
  /** How long a write waits for the cross-process lock before proceeding unlocked. */
377
402
  const LOCK_WAIT_MS = 2_000
378
403
  /** Coalescing window for store-directory watcher events before a re-read. */
@@ -775,6 +800,40 @@ const describeValue = (value) => {
775
800
  * `dropped`: the next save persists the corrected values, so the user must be
776
801
  * told their hand edit did not survive verbatim.
777
802
  */
803
+ /**
804
+ * Does this PARSED json carry a store's shape, as opposed to merely being
805
+ * valid JSON?
806
+ *
807
+ * The distinction is load-bearing: a file that parses but is an array, a
808
+ * number, or some other tool's object used to be adopted as "an empty
809
+ * memory" with no warning at all, so the very next save wrote that emptiness
810
+ * back over the user's memory and rotated the result into `.bak`. JSON.parse
811
+ * answering is not the same question as "is this a store", and every place
812
+ * that has to ask the second one now asks it here.
813
+ *
814
+ * @returns 'store' for anything adoptable, 'empty-object' for a truthy but
815
+ * empty store (`{}`, or only option keys — a hand-cleared file), and
816
+ * 'not-object' / 'not-store' for the two states that must never be adopted.
817
+ */
818
+ export function storeShapeVerdict(parsed) {
819
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return 'not-object'
820
+ // A declared, supported version is enough on its own: `{"version":2}` is a
821
+ // store that happens to be empty (and its formatNote says so), not a
822
+ // foreign file. An unsupported one is handled before this test by the
823
+ // version gate in normalizeData.
824
+ if (typeof parsed.version === 'number' && SUPPORTED_VERSIONS.includes(parsed.version)) return 'store'
825
+ if (Array.isArray(parsed.entries) || Array.isArray(parsed.sets)) return 'store'
826
+ // Every key is one the loader itself understands: `{}` and `{"rev":3}` are
827
+ // empty stores, not somebody else's document.
828
+ const OPTION_KEYS = new Set([
829
+ 'version', 'rev', 'enabled', 'disabledUntil', 'budgetChars',
830
+ 'backupDir', 'activeSetId', 'entries', 'sets', 'profiles',
831
+ ])
832
+ const keys = Object.keys(parsed)
833
+ if (keys.length === 0 || keys.every((key) => OPTION_KEYS.has(key))) return 'empty-object'
834
+ return 'not-store'
835
+ }
836
+
778
837
  function normalizeData(raw) {
779
838
  const data = {
780
839
  version: FORMAT_VERSION,
@@ -790,8 +849,18 @@ function normalizeData(raw) {
790
849
  }
791
850
  const loaded = { data, dropped: 0, droppedProfiles: 0, droppedSets: 0, reattributed: 0, optionFixes: [], formatNote: null }
792
851
  if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return loaded
793
- if (typeof raw.version === 'number' && !SUPPORTED_VERSIONS.includes(raw.version)) {
794
- return { data, dropped: 0, droppedProfiles: 0, droppedSets: 0, reattributed: 0, optionFixes: [], rejectedVersion: raw.version }
852
+ // A declared version this build does not support is refused whatever its
853
+ // TYPE. The old test was `typeof raw.version === 'number'`, so a file
854
+ // carrying `"2.0"` or `"3"` read as "no version declared" and the next save
855
+ // rewrote it as the current format — the exact downgrade this gate exists
856
+ // to prevent, merely reached through a string.
857
+ if ('version' in raw && raw.version !== null && raw.version !== undefined) {
858
+ if (!(typeof raw.version === 'number' && SUPPORTED_VERSIONS.includes(raw.version))) {
859
+ return {
860
+ data, dropped: 0, droppedProfiles: 0, droppedSets: 0, reattributed: 0, optionFixes: [],
861
+ rejectedVersion: typeof raw.version === 'number' ? raw.version : String(raw.version),
862
+ }
863
+ }
795
864
  }
796
865
  if (typeof raw.rev === 'number' && Number.isFinite(raw.rev) && raw.rev >= 0) {
797
866
  data.rev = Math.round(raw.rev)
@@ -1443,7 +1512,6 @@ function buildProfilesBlock(profiles, budget) {
1443
1512
  const blocks = []
1444
1513
  const items = []
1445
1514
  let used = PERSONA_HEADER.length + 1
1446
- let omitted = 0
1447
1515
  for (const profile of active) {
1448
1516
  const name = typeof profile.name === 'string' && profile.name !== ''
1449
1517
  ? profile.name
@@ -1475,7 +1543,6 @@ function buildProfilesBlock(profiles, budget) {
1475
1543
  items.push({ id, name, chars: head.length + note.length, truncated: true })
1476
1544
  used += sep + head.length + note.length
1477
1545
  }
1478
- omitted = active.length - blocks.length
1479
1546
  break
1480
1547
  }
1481
1548
  const text = PERSONA_HEADER + '\n' + blocks.join('\n\n') + footerFor(Math.max(0, active.length - blocks.length))
@@ -1748,6 +1815,15 @@ export function apply(ctx) {
1748
1815
  */
1749
1816
  versionRejected: false,
1750
1817
  rejectedVersion: null,
1818
+ /**
1819
+ * Set while the file on disk parses as JSON but is not a store at all (an
1820
+ * array, a number, another tool's object). Adopting it as "an empty
1821
+ * memory" and then saving over it is precisely the silent-loss chain the
1822
+ * rolling backup exists to survive, so ordinary edits are refused in this
1823
+ * state; the repair doors (history restore, restore-from-backup, import,
1824
+ * source mode) stay open and clear it on the first successful write.
1825
+ */
1826
+ shapeRejected: false,
1751
1827
  sectionRegistered: false,
1752
1828
  sectionError: null,
1753
1829
  /** Whether a store file is actually present on disk (diagnostics + the panel). */
@@ -2459,12 +2535,22 @@ export function apply(ctx) {
2459
2535
  if (err.code !== 'EEXIST') throw err
2460
2536
  const info = await stat(lockPath).catch(() => null)
2461
2537
  if (info !== null && Date.now() - info.mtimeMs > LOCK_STALE_MS) {
2462
- // Crashed-holder debris. It may be recreated by a live process
2463
- // between this unlink and the next wx attempt — that case simply
2464
- // loops and re-evaluates freshness instead of forcing its way in.
2465
- // A failed unlink must not become a tight loop, so every attempt
2466
- // still sleeps below.
2467
- await unlink(lockPath).catch(() => {})
2538
+ // Confirm the marker we are about to delete is STILL the one we just
2539
+ // judged stale. Between the stat above and the unlink a live holder
2540
+ // can release it and a new owner can create a fresh marker; deleting
2541
+ // THAT would unlock a live writer, the same TOCTOU the token closed
2542
+ // on release. The mtime+size re-check makes the sweep ownership-safe
2543
+ // without needing to know the dead holder's token.
2544
+ const after = await stat(lockPath).catch(() => null)
2545
+ if (after !== null && after.mtimeMs === info.mtimeMs && after.size === info.size
2546
+ && Date.now() - after.mtimeMs > LOCK_STALE_MS) {
2547
+ // Crashed-holder debris. It may be recreated by a live process
2548
+ // between this unlink and the next wx attempt — that case simply
2549
+ // loops and re-evaluates freshness instead of forcing its way in.
2550
+ // A failed unlink must not become a tight loop, so every attempt
2551
+ // still sleeps below.
2552
+ await unlink(lockPath).catch(() => {})
2553
+ }
2468
2554
  }
2469
2555
  await new Promise((resolve) => setTimeout(resolve, 50))
2470
2556
  }
@@ -2545,14 +2631,22 @@ export function apply(ctx) {
2545
2631
  let lastPersistedKey = null
2546
2632
 
2547
2633
  /**
2548
- * Enforce the per-device retention policy: the count and byte ceilings from
2549
- * planHistoryPrune(). Grouping by the device tag inside the filename is
2550
- * deliberate: two machines sharing one backup folder (the documented F18
2551
- * setup) each keep their OWN budget, so one device can never prune the
2552
- * other's history away. Files that do not match our naming pattern are never
2553
- * touched. Sizes come from stat() rather than a maintained index because the
2554
- * directory is shared with other processes and a stale index would prune the
2555
- * wrong files.
2634
+ * Enforce the retention policy: the count and byte ceilings from
2635
+ * planHistoryPrune(), applied PER DEVICE first. Grouping by the device tag
2636
+ * inside the filename is deliberate: two machines sharing one backup folder
2637
+ * (the documented F18 setup) each keep their OWN budget, so a device that
2638
+ * saves a lot cannot prune a quiet device's history.
2639
+ *
2640
+ * The GLOBAL byte ceiling afterwards is the documented exception to that:
2641
+ * per-device budgets multiply by the number of machines (ten machines = ten
2642
+ * × 32 MB), so the folder as a whole is also bounded, and that pass deletes
2643
+ * the oldest snapshots whichever device they came from. It is disclosed
2644
+ * rather than hidden — the name ordering is chronological across devices
2645
+ * because the stamp is fixed-width UTC.
2646
+ *
2647
+ * Files that do not match our naming pattern are never touched. Sizes come
2648
+ * from stat() rather than a maintained index because the directory is shared
2649
+ * with other processes and a stale index would prune the wrong files.
2556
2650
  */
2557
2651
  const pruneHistory = async (dir) => {
2558
2652
  const names = (await readdir(dir)).filter((name) => HISTORY_FILE_PATTERN.test(name))
@@ -2652,7 +2746,19 @@ export function apply(ctx) {
2652
2746
  const source = await readFile(fileURLToPath(new URL('../' + SCHEMA_FILE_NAME, import.meta.url)), 'utf8')
2653
2747
  const target = join(dirname(storePath), SCHEMA_FILE_NAME)
2654
2748
  const existing = await readFile(target, 'utf8').catch(() => null)
2655
- if (existing !== source) await writeFile(target, source, 'utf8')
2749
+ if (existing !== source) {
2750
+ // Atomic like the store itself: a crash mid-copy must not leave a
2751
+ // truncated schema for the user's editor to validate against, and the
2752
+ // 0600 bit matches every other file this plugin writes.
2753
+ const tmp = target + '.tmp-' + randomUUID()
2754
+ try {
2755
+ await writeFile(tmp, source, { encoding: 'utf8', mode: 0o600 })
2756
+ await rename(tmp, target)
2757
+ } catch (err) {
2758
+ await unlink(tmp).catch(() => {})
2759
+ throw err
2760
+ }
2761
+ }
2656
2762
  } catch {
2657
2763
  // Best effort by design.
2658
2764
  }
@@ -2673,6 +2779,21 @@ export function apply(ctx) {
2673
2779
  }
2674
2780
  }
2675
2781
 
2782
+ /**
2783
+ * Best-effort durability fence. `rename` is atomic against a crash but NOT
2784
+ * durable on its own: without an fsync the directory entry can reach disk
2785
+ * after the data does, so power loss may leave the previous file or a 0-byte
2786
+ * one even though the save reported success. A failure here is deliberately
2787
+ * not a save failure (the bytes are already in the page cache, and Windows
2788
+ * refuses to open directories at all), so it degrades durability only.
2789
+ */
2790
+ const syncPath = async (target) => {
2791
+ try {
2792
+ const fh = await open(target, 'r')
2793
+ try { await fh.sync() } finally { await fh.close() }
2794
+ } catch { /* unsupported here: durability degrades, correctness does not */ }
2795
+ }
2796
+
2676
2797
  const writeToDisk = async () => {
2677
2798
  // Atomic replace (write a sibling temp file, then rename) with a rolling
2678
2799
  // one-version backup: a crash mid-write must never leave a truncated
@@ -2687,7 +2808,13 @@ export function apply(ctx) {
2687
2808
  // user's memory while their real data sat untouched in .bak. Copying keeps
2688
2809
  // the primary in place until the atomic replace succeeds, so a failure at
2689
2810
  // any step leaves the previous store readable at its own path.
2690
- const tmpPath = storePath + '.tmp'
2811
+ // A UNIQUE tmp name per attempt: two processes sharing a $DSH_HOME must
2812
+ // never interleave their writes into one temp file. The fixed
2813
+ // `storePath + '.tmp'` meant the second writer's rename could publish a
2814
+ // half-written mix of both serialisations. The lock narrows the window; a
2815
+ // per-attempt name removes the shared resource, so the window cannot
2816
+ // corrupt anything even when the lock is unavailable.
2817
+ const tmpPath = storePath + '.tmp-' + process.pid + '-' + randomUUID()
2691
2818
  let lock = null
2692
2819
  let previousRev = null
2693
2820
  try {
@@ -2730,6 +2857,10 @@ export function apply(ctx) {
2730
2857
  const contentKey = JSON.stringify({ ...persistableData(), rev: 0 })
2731
2858
  if (state.storeExists && lastPersistedKey !== null && contentKey === lastPersistedKey) {
2732
2859
  logDebug('save skipped: the store content did not change')
2860
+ // The caller's "上次保存" clock reports the last successful SAVE, and
2861
+ // this call is one, even though the bytes were already correct. Leaving
2862
+ // it untouched made the panel look stalled after a no-op save.
2863
+ state.savedAt = Date.now()
2733
2864
  return true
2734
2865
  }
2735
2866
  // The rev is bumped before serialising so the file on disk and the state
@@ -2747,7 +2878,15 @@ export function apply(ctx) {
2747
2878
  // user profile) personal notes, so on a multi-user POSIX host it must not
2748
2879
  // land world-readable the way a bare writeFile (0666 & ~umask = 0644)
2749
2880
  // would. Windows ignores the bit and inherits the directory ACL instead.
2750
- await writeFile(tmpPath, text, { encoding: 'utf8', mode: 0o600 })
2881
+ const tmpHandle = await open(tmpPath, 'w', 0o600)
2882
+ try {
2883
+ await tmpHandle.writeFile(text, 'utf8')
2884
+ // Flush the temp file BEFORE the rename publishes it: otherwise a
2885
+ // power cut can leave the new name pointing at unwritten blocks.
2886
+ await tmpHandle.sync()
2887
+ } finally {
2888
+ await tmpHandle.close()
2889
+ }
2751
2890
  // Both rolling copies stand for the PREVIOUS good version, so both are
2752
2891
  // taken before the atomic replace, and neither may ever be renamed into
2753
2892
  // place (see the long note above).
@@ -2762,8 +2901,22 @@ export function apply(ctx) {
2762
2901
  // discloses it (the .bak may be stale, and the panel says so).
2763
2902
  let hadPrevious = true
2764
2903
  try {
2765
- await readStoreFile(storePath)
2904
+ const previous = await readStoreFile(storePath)
2905
+ // "Reads as a store", not merely "reads as JSON" — this is the test the
2906
+ // comment above promises. A foreign-but-parseable primary (an array, a
2907
+ // scalar, another tool's object) must NOT be copied over the last good
2908
+ // backup: that is exactly what turns a recoverable state into a
2909
+ // permanent one.
2910
+ const previousShape = storeShapeVerdict(previous)
2911
+ if (previousShape === 'not-object' || previousShape === 'not-store') {
2912
+ const shapeError = new Error('memory.json is not a store (' + previousShape + ')')
2913
+ shapeError.code = NOT_A_STORE_CODE
2914
+ throw shapeError
2915
+ }
2766
2916
  await copyFile(storePath, localBackupPath())
2917
+ // The recovery copy exists to survive a crash; an unflushed copy may
2918
+ // roll back with the very crash it is meant to answer.
2919
+ await syncPath(localBackupPath())
2767
2920
  state.backupError = null
2768
2921
  } catch (err) {
2769
2922
  if (err !== null && err !== undefined && err.code === 'ENOENT') {
@@ -2772,6 +2925,12 @@ export function apply(ctx) {
2772
2925
  // would adopt as an "empty store" — the trap this guard exists for.
2773
2926
  hadPrevious = false
2774
2927
  state.backupError = null
2928
+ } else if (err !== null && err !== undefined && err.code === NOT_A_STORE_CODE) {
2929
+ hadPrevious = false
2930
+ const previous = await stat(localBackupPath()).catch(() => null)
2931
+ state.backupError = previous !== null && previous.size > 0
2932
+ ? 'memory.json 的内容不是记忆存储,已保留上一版备份 ' + localBackupPath() + ' 不被覆盖'
2933
+ : 'memory.json 的内容不是记忆存储,且没有可用的上一版备份'
2775
2934
  } else {
2776
2935
  hadPrevious = false
2777
2936
  const previous = await stat(localBackupPath()).catch(() => null)
@@ -2800,6 +2959,9 @@ export function apply(ctx) {
2800
2959
  await unlink(tmpPath).catch(() => {})
2801
2960
  throw err
2802
2961
  }
2962
+ // Make the directory entry itself durable: without this the rename can be
2963
+ // lost on power failure even though the file contents were flushed.
2964
+ await syncPath(dirname(storePath))
2803
2965
  state.storagePath = storePath
2804
2966
  state.storageDisplay = storePath
2805
2967
  state.storageError = null
@@ -2816,6 +2978,9 @@ export function apply(ctx) {
2816
2978
  // committed, and the store is on disk whatever happens next.
2817
2979
  await writeHistorySnapshot(text, contentKey)
2818
2980
  lastPersistedKey = contentKey
2981
+ // The disk holds a store again, so a "primary is not a store" refusal no
2982
+ // longer describes reality — this is the one place that can prove it.
2983
+ state.shapeRejected = false
2819
2984
  await ensureSchemaFile()
2820
2985
  logDebug('store written: ' + storePath + ' bytes=' + text.length
2821
2986
  + ' entries=' + state.data.entries.length + ' rev=' + state.data.rev
@@ -2891,6 +3056,13 @@ export function apply(ctx) {
2891
3056
  state.storeExists = false
2892
3057
  state.versionRejected = false
2893
3058
  state.rejectedVersion = null
3059
+ state.shapeRejected = false
3060
+ // A reload replaces what "the last persisted content" means. Leaving the
3061
+ // old identity in place made a later save that happened to match the
3062
+ // PRE-reload content skip the write while reporting saved:true, so the disk
3063
+ // and the panel silently disagreed. Forcing the next save to write is the
3064
+ // cheap side of that trade.
3065
+ lastPersistedKey = null
2894
3066
 
2895
3067
  /**
2896
3068
  * A store declaring an unsupported format version: adopt nothing, write
@@ -2914,6 +3086,54 @@ export function apply(ctx) {
2914
3086
  state.storeExists = true
2915
3087
  }
2916
3088
 
3089
+ /**
3090
+ * The primary parses as JSON but is not a store (an array, a scalar, or
3091
+ * another tool's object). Adopt NOTHING from it: the in-memory data — a
3092
+ * freshly booted empty store, or whatever this process already had — stays
3093
+ * authoritative, ordinary edits are refused, and both files stay on disk.
3094
+ * The repair doors (history restore, restore-from-backup, import, source
3095
+ * mode) remain open and clear the flag on the first successful write.
3096
+ */
3097
+ const rejectShape = (describe) => {
3098
+ state.shapeRejected = true
3099
+ state.storageError = 'memory.json 的内容不是本插件的记忆存储:' + describe
3100
+ + '。已暂停写入以免覆盖原文件(原文件已保留)。请用「历史回滚」或「用备份整体替换」恢复,或修正该文件后点「重新载入」'
3101
+ state.loadedFrom = 'memory.json (not a store; writes paused)'
3102
+ state.storeExists = true
3103
+ }
3104
+
3105
+ /**
3106
+ * A primary that is not a store still leaves the rolling backup intact, so
3107
+ * the last good version is usually one file away. Promote it into memory
3108
+ * (never back onto disk here) and disclose both facts: the primary is
3109
+ * foreign, and the panel is temporarily showing the backup.
3110
+ */
3111
+ const recoverFromForeignPrimary = async (shape) => {
3112
+ state.storeExists = true
3113
+ const describe = shape === 'not-object'
3114
+ ? '顶层不是对象(是数组或标量)'
3115
+ : '顶层对象里没有 entries / sets / version 等记忆字段'
3116
+ try {
3117
+ const backup = await readStoreFile(storePath + '.bak')
3118
+ if (backup !== null && storeShapeVerdict(backup) === 'store') {
3119
+ const loaded = normalizeData(backup)
3120
+ if (loaded.rejectedVersion !== undefined) {
3121
+ rejectVersion(loaded.rejectedVersion, 'memory.json.bak')
3122
+ return
3123
+ }
3124
+ state.data = loaded.data
3125
+ const warnings = loadWarnings(loaded)
3126
+ state.storageWarning = (warnings !== null ? warnings + ';' : '')
3127
+ + 'memory.json ' + describe + ',不是本插件的记忆存储(原文件已保留);已临时回退到最近备份 memory.json.bak'
3128
+ state.loadedFrom = 'memory.json.bak (primary is not a store)'
3129
+ return
3130
+ }
3131
+ } catch {
3132
+ // No usable backup either; refuse rather than guess.
3133
+ }
3134
+ rejectShape(describe)
3135
+ }
3136
+
2917
3137
  // 1. The canonical location under the harness home.
2918
3138
  try {
2919
3139
  const parsed = await readStoreFile(storePath)
@@ -2923,6 +3143,14 @@ export function apply(ctx) {
2923
3143
  rejectVersion(loaded.rejectedVersion, 'memory.json')
2924
3144
  return false
2925
3145
  }
3146
+ // Parsing is not the same as being a store: an array or a foreign
3147
+ // object used to be adopted as "empty memory" with no warning, and the
3148
+ // next save wrote that emptiness back. Never adopt it.
3149
+ const shape = storeShapeVerdict(parsed)
3150
+ if (shape === 'not-object' || shape === 'not-store') {
3151
+ await recoverFromForeignPrimary(shape)
3152
+ return false
3153
+ }
2926
3154
  state.data = loaded.data
2927
3155
  state.storageWarning = loadWarnings(loaded)
2928
3156
  state.loadedFrom = 'memory.json'
@@ -2964,7 +3192,7 @@ export function apply(ctx) {
2964
3192
  : 'memory.json 解析失败(' + describeErrorSafe(err) + ')'
2965
3193
  try {
2966
3194
  const backup = await readStoreFile(storePath + '.bak')
2967
- if (backup !== null) {
3195
+ if (backup !== null && storeShapeVerdict(backup) === 'store') {
2968
3196
  const loaded = normalizeData(backup)
2969
3197
  if (loaded.rejectedVersion !== undefined) {
2970
3198
  rejectVersion(loaded.rejectedVersion, 'memory.json.bak')
@@ -3001,7 +3229,7 @@ export function apply(ctx) {
3001
3229
  // canonical path so the next boot finds it normally.
3002
3230
  try {
3003
3231
  const backup = await readStoreFile(storePath + '.bak')
3004
- if (backup !== null) {
3232
+ if (backup !== null && storeShapeVerdict(backup) === 'store') {
3005
3233
  const loaded = normalizeData(backup)
3006
3234
  if (loaded.rejectedVersion !== undefined) {
3007
3235
  rejectVersion(loaded.rejectedVersion, 'memory.json.bak')
@@ -3026,22 +3254,29 @@ export function apply(ctx) {
3026
3254
  try {
3027
3255
  const parsed = await readStoreFile(legacyPath)
3028
3256
  if (parsed !== null) {
3029
- const loaded = normalizeData(parsed)
3030
- if (loaded.rejectedVersion !== undefined) {
3031
- // A legacy file in a future format is not ours to migrate or judge.
3032
- state.storageError = '旧位置文件为不支持的格式版本 ' + loaded.rejectedVersion + ',已忽略'
3033
- return false
3034
- }
3035
- state.data = loaded.data
3036
- state.storageWarning = loadWarnings(loaded)
3037
- const migrated = await writeToDisk()
3038
- if (migrated) {
3039
- state.migratedFrom = legacyPath
3040
- // Keep the original as evidence instead of deleting user data.
3041
- await rename(legacyPath, legacyPath + '.migrated').catch(() => {})
3257
+ const legacyShape = storeShapeVerdict(parsed)
3258
+ if (legacyShape === 'not-object' || legacyShape === 'not-store') {
3259
+ // Some other program's JSON beside the old store path is not a store
3260
+ // to migrate, and must never become memory.json.
3261
+ state.storageError = '旧位置文件不是本插件的记忆存储,已忽略'
3262
+ } else {
3263
+ const loaded = normalizeData(parsed)
3264
+ if (loaded.rejectedVersion !== undefined) {
3265
+ // A legacy file in a future format is not ours to migrate or judge.
3266
+ state.storageError = '旧位置文件为不支持的格式版本 ' + loaded.rejectedVersion + ',已忽略'
3267
+ return false
3268
+ }
3269
+ state.data = loaded.data
3270
+ state.storageWarning = loadWarnings(loaded)
3271
+ const migrated = await writeToDisk()
3272
+ if (migrated) {
3273
+ state.migratedFrom = legacyPath
3274
+ // Keep the original as evidence instead of deleting user data.
3275
+ await rename(legacyPath, legacyPath + '.migrated').catch(() => {})
3276
+ }
3277
+ state.loadedFrom = 'legacy cwd file (migrated)'
3278
+ return migrated
3042
3279
  }
3043
- state.loadedFrom = 'legacy cwd file (migrated)'
3044
- return migrated
3045
3280
  }
3046
3281
  } catch (err) {
3047
3282
  // A missing or unreadable legacy file must never block a fresh store.
@@ -3301,9 +3536,16 @@ export function apply(ctx) {
3301
3536
  * Persona section detail: rendered profiles (id/name/chars, in order)
3302
3537
  * and their total size. `entryChars` is the instruction half alone,
3303
3538
  * which is what the budget meter measures; `chars` above stays the
3304
- * whole section (personas + instructions) for diagnostics.
3539
+ * whole section (personas + instructions) for diagnostics. `truncated`
3540
+ * mirrors the entry list: a persona cut by the injection ceiling has to
3541
+ * be DISCLOSED, or the panel shows full text the model never received.
3305
3542
  */
3306
- profiles: plan.profiles.map((item) => ({ id: item.id, name: item.name, chars: item.chars })),
3543
+ profiles: plan.profiles.map((item) => ({
3544
+ id: item.id,
3545
+ name: item.name,
3546
+ chars: item.chars,
3547
+ truncated: item.truncated === true,
3548
+ })),
3307
3549
  profileChars: plan.profileChars,
3308
3550
  entryChars: plan.entryChars,
3309
3551
  /**
@@ -3394,6 +3636,20 @@ export function apply(ctx) {
3394
3636
  snapshot: snapshot(),
3395
3637
  })
3396
3638
 
3639
+ /**
3640
+ * Refusal while memory.json parses as JSON but is not a store at all (an
3641
+ * array, a scalar, another tool's object). Same reason as versionRefusal:
3642
+ * a save would rewrite the file in this build's format over content this
3643
+ * build never understood. The repair doors stay open — that is what
3644
+ * `shapeRejected` is for, and why they deliberately skip this check.
3645
+ */
3646
+ const shapeRefusal = () => ({
3647
+ ok: false,
3648
+ saved: false,
3649
+ message: 'memory.json 的内容不是本插件的记忆存储,修改已暂停以免覆盖原文件。请用「历史回滚」或「用备份整体替换」恢复,或修正该文件后点「重新载入」',
3650
+ snapshot: snapshot(),
3651
+ })
3652
+
3397
3653
  /**
3398
3654
  * Adopt a whole parsed store — the shared core of 源码模式 (F16) and the
3399
3655
  * 整体替换 restore (F14). Both hand the user the SAME safety story as every
@@ -3407,17 +3663,23 @@ export function apply(ctx) {
3407
3663
  * before the path existed — must not silently redirect where backups go.
3408
3664
  */
3409
3665
  const adoptParsedStore = (parsed) => {
3410
- const looksLikeStore = parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
3411
- && ['version', 'rev', 'enabled', 'budgetChars', 'backupDir', 'activeSetId', 'sets', 'entries', 'profiles']
3412
- .some((key) => key in parsed)
3413
- if (!looksLikeStore) {
3666
+ // Version first. `{"version":3}` is a FUTURE store, and the honest answer
3667
+ // is "unsupported version", not "not a store" — an unsupported version is
3668
+ // by definition not a shape this build recognises, so the shape test below
3669
+ // would mislabel it.
3670
+ const loaded = normalizeData(parsed)
3671
+ if (loaded.rejectedVersion !== undefined) return { rejectedVersion: loaded.rejectedVersion }
3672
+ // Then shape, with the loader's own verdict rather than a looser one. A
3673
+ // file carrying a single stray option key (`{"version":2}`,
3674
+ // `{"enabled":false}`) is NOT a store to adopt wholesale: on the
3675
+ // import/restore path that would replace real memory with near-nothing and
3676
+ // report saved:true.
3677
+ if (storeShapeVerdict(parsed) !== 'store') {
3414
3678
  return {
3415
- error: '内容不像本插件的存储文件:顶层需要是对象,且至少包含 entries / profiles / sets / version 之一。'
3679
+ error: '内容不像本插件的存储文件:顶层需要是对象,且必须包含 entries / sets 数组(或受支持的 version)。'
3416
3680
  + '若要清空记忆,请使用界面上的删除操作,而不是保存一个空对象',
3417
3681
  }
3418
3682
  }
3419
- const loaded = normalizeData(parsed)
3420
- if (loaded.rejectedVersion !== undefined) return { rejectedVersion: loaded.rejectedVersion }
3421
3683
  loaded.data.backupDir = backupDirOf()
3422
3684
  return { loaded }
3423
3685
  }
@@ -3489,6 +3751,7 @@ export function apply(ctx) {
3489
3751
 
3490
3752
  const saveEntry = (raw, baseRev) => enqueue(async () => {
3491
3753
  if (state.versionRejected) return versionRefusal()
3754
+ if (state.shapeRejected) return shapeRefusal()
3492
3755
  if (isStaleBaseRev(baseRev)) return conflictResponse()
3493
3756
  const entry = sanitizeEntry(raw)
3494
3757
  if (entry === null) return { ok: false, saved: false, message: '标题与指令内容不能同时为空', snapshot: snapshot() }
@@ -3540,6 +3803,7 @@ export function apply(ctx) {
3540
3803
 
3541
3804
  const deleteEntry = (id, baseRev) => enqueue(async () => {
3542
3805
  if (state.versionRejected) return versionRefusal()
3806
+ if (state.shapeRejected) return shapeRefusal()
3543
3807
  if (isStaleBaseRev(baseRev)) return conflictResponse()
3544
3808
  // Deleting an id that is not there changes nothing, so it must not touch
3545
3809
  // disk, bump rev, or push an echo frame — otherwise two panels deleting
@@ -3562,6 +3826,7 @@ export function apply(ctx) {
3562
3826
 
3563
3827
  const setOptions = (patch, baseRev) => enqueue(async () => {
3564
3828
  if (state.versionRejected) return versionRefusal()
3829
+ if (state.shapeRejected) return shapeRefusal()
3565
3830
  if (isStaleBaseRev(baseRev)) return conflictResponse()
3566
3831
  // The custom backup location (F18) is validated BEFORE anything is queued
3567
3832
  // into the store: an unusable path must be refused with a reason, not
@@ -3711,6 +3976,7 @@ export function apply(ctx) {
3711
3976
  */
3712
3977
  const batchEntries = (op, ids, value, baseRev) => enqueue(async () => {
3713
3978
  if (state.versionRejected) return versionRefusal()
3979
+ if (state.shapeRejected) return shapeRefusal()
3714
3980
  if (isStaleBaseRev(baseRev)) return conflictResponse()
3715
3981
  if (typeof op !== 'string' || !BATCH_OPS.includes(op)) {
3716
3982
  return { ok: false, saved: false, message: '未知的批量操作:' + String(op), snapshot: snapshot() }
@@ -3818,6 +4084,7 @@ export function apply(ctx) {
3818
4084
 
3819
4085
  const saveProfile = (raw, baseRev) => enqueue(async () => {
3820
4086
  if (state.versionRejected) return versionRefusal()
4087
+ if (state.shapeRejected) return shapeRefusal()
3821
4088
  if (isStaleBaseRev(baseRev)) return conflictResponse()
3822
4089
  const profile = sanitizeProfile(raw)
3823
4090
  if (profile === null) return { ok: false, saved: false, message: '形象名称与内容不能同时为空', snapshot: snapshot() }
@@ -3848,6 +4115,7 @@ export function apply(ctx) {
3848
4115
 
3849
4116
  const deleteProfile = (id, baseRev) => enqueue(async () => {
3850
4117
  if (state.versionRejected) return versionRefusal()
4118
+ if (state.shapeRejected) return shapeRefusal()
3851
4119
  if (isStaleBaseRev(baseRev)) return conflictResponse()
3852
4120
  // Mirror deleteEntry: an absent id changes nothing and must not touch disk,
3853
4121
  // and an id belonging to a different set is not this panel's to delete.
@@ -3875,6 +4143,7 @@ export function apply(ctx) {
3875
4143
  */
3876
4144
  const saveSet = (raw, baseRev) => enqueue(async () => {
3877
4145
  if (state.versionRejected) return versionRefusal()
4146
+ if (state.shapeRejected) return shapeRefusal()
3878
4147
  if (isStaleBaseRev(baseRev)) return conflictResponse()
3879
4148
  const wanted = sanitizeSet(raw)
3880
4149
  if (wanted === null) {
@@ -3915,6 +4184,7 @@ export function apply(ctx) {
3915
4184
  */
3916
4185
  const deleteSet = (id, baseRev) => enqueue(async () => {
3917
4186
  if (state.versionRejected) return versionRefusal()
4187
+ if (state.shapeRejected) return shapeRefusal()
3918
4188
  if (isStaleBaseRev(baseRev)) return conflictResponse()
3919
4189
  const index = state.data.sets.findIndex((set) => set.id === id)
3920
4190
  if (index < 0) {
@@ -3943,6 +4213,7 @@ export function apply(ctx) {
3943
4213
  */
3944
4214
  const setActiveSet = (id, baseRev) => enqueue(async () => {
3945
4215
  if (state.versionRejected) return versionRefusal()
4216
+ if (state.shapeRejected) return shapeRefusal()
3946
4217
  if (isStaleBaseRev(baseRev)) return conflictResponse()
3947
4218
  const target = state.data.sets.find((set) => set.id === id)
3948
4219
  if (target === undefined) {
@@ -3964,6 +4235,7 @@ export function apply(ctx) {
3964
4235
  */
3965
4236
  const duplicateSet = (id, name, baseRev) => enqueue(async () => {
3966
4237
  if (state.versionRejected) return versionRefusal()
4238
+ if (state.shapeRejected) return shapeRefusal()
3967
4239
  if (isStaleBaseRev(baseRev)) return conflictResponse()
3968
4240
  const source = state.data.sets.find((set) => set.id === id)
3969
4241
  if (source === undefined) {
@@ -4093,13 +4365,28 @@ export function apply(ctx) {
4093
4365
  */
4094
4366
  const historyRestore = (name, baseRev) => enqueue(async () => {
4095
4367
  if (state.versionRejected) return versionRefusal()
4368
+ // Deliberately NOT gated on shapeRejected: restoring a snapshot is the
4369
+ // repair door for a primary that is not a store.
4096
4370
  if (isStaleBaseRev(baseRev)) return conflictResponse()
4097
4371
  if (typeof name !== 'string' || !HISTORY_FILE_PATTERN.test(name)) {
4098
4372
  return { ok: false, saved: false, message: '历史快照名称无效', snapshot: snapshot() }
4099
4373
  }
4100
4374
  let parsed = null
4375
+ const snapshotPath = join(historyDirPath(), name)
4101
4376
  try {
4102
- parsed = JSON.parse(await readFile(join(historyDirPath(), name), 'utf8'))
4377
+ // Bounded read: stat first, so an absurd file in a shared backup folder
4378
+ // (or a corrupted one) is refused instead of pulled into memory whole.
4379
+ const info = await stat(snapshotPath)
4380
+ if (!info.isFile()) throw new Error('不是普通文件')
4381
+ if (info.size > MAX_HISTORY_FILE_BYTES) {
4382
+ return {
4383
+ ok: false,
4384
+ saved: false,
4385
+ message: '该历史快照过大(' + info.size + ' 字节,上限 ' + MAX_HISTORY_FILE_BYTES + ' 字节),已拒绝读取',
4386
+ snapshot: snapshot(),
4387
+ }
4388
+ }
4389
+ parsed = JSON.parse(await readFile(snapshotPath, 'utf8'))
4103
4390
  } catch (err) {
4104
4391
  return { ok: false, saved: false, message: '无法读取该历史快照:' + describeErrorSafe(err), snapshot: snapshot() }
4105
4392
  }
@@ -4294,6 +4581,8 @@ export function apply(ctx) {
4294
4581
 
4295
4582
  const writeSource = (text, baseRev) => enqueue(async () => {
4296
4583
  if (state.versionRejected) return versionRefusal()
4584
+ // Deliberately NOT gated on shapeRejected: hand-editing the source is how
4585
+ // a user repairs a primary that is not a store.
4297
4586
  if (isStaleBaseRev(baseRev)) return conflictResponse()
4298
4587
  if (typeof text !== 'string') {
4299
4588
  return { ok: false, saved: false, message: '源码必须是 JSON 文本', snapshot: snapshot() }
@@ -4324,6 +4613,8 @@ export function apply(ctx) {
4324
4613
  */
4325
4614
  const restoreData = (payload, baseRev) => enqueue(async () => {
4326
4615
  if (state.versionRejected) return versionRefusal()
4616
+ // Deliberately NOT gated on shapeRejected: wholesale replacement from a
4617
+ // backup is the repair door for a primary that is not a store.
4327
4618
  if (isStaleBaseRev(baseRev)) return conflictResponse()
4328
4619
  if (payload === null || typeof payload !== 'object') {
4329
4620
  return { ok: false, saved: false, message: '恢复内容不是有效的 JSON 对象', snapshot: snapshot() }
@@ -4337,6 +4628,8 @@ export function apply(ctx) {
4337
4628
 
4338
4629
  const importData = (payload, baseRev) => enqueue(async () => {
4339
4630
  if (state.versionRejected) return versionRefusal()
4631
+ // Deliberately NOT gated on shapeRejected: importing a real store is a
4632
+ // repair path for a primary that is not one.
4340
4633
  if (isStaleBaseRev(baseRev)) return conflictResponse()
4341
4634
  if (payload === null || typeof payload !== 'object') {
4342
4635
  return { ok: false, saved: false, message: '导入内容不是有效的 JSON 对象', snapshot: snapshot() }
@@ -4624,6 +4917,14 @@ export function apply(ctx) {
4624
4917
  const readFeedbackConfigFile = async (path) => {
4625
4918
  let text
4626
4919
  try {
4920
+ // Bounded read: this file is operator-authored, but a wrong path (a log,
4921
+ // a database) must not be dragged into memory just because the send
4922
+ // button was pressed.
4923
+ const info = await stat(path)
4924
+ if (!info.isFile()) throw new Error('不是普通文件')
4925
+ if (info.size > MAX_FEEDBACK_CONFIG_BYTES) {
4926
+ throw new Error('文件过大(' + info.size + ' 字节,上限 ' + MAX_FEEDBACK_CONFIG_BYTES + ' 字节)')
4927
+ }
4627
4928
  text = await readFile(path, 'utf8')
4628
4929
  } catch (err) {
4629
4930
  if (err !== null && typeof err === 'object' && err.code === 'ENOENT') return null
package/lib/mailer.js CHANGED
@@ -25,6 +25,7 @@
25
25
  */
26
26
 
27
27
  import { createCipheriv, createDecipheriv, randomBytes, randomUUID, scryptSync } from 'node:crypto'
28
+ import { StringDecoder } from 'node:string_decoder'
28
29
  import { connect as netConnect } from 'node:net'
29
30
  import { connect as tlsConnect } from 'node:tls'
30
31
  import { hostname } from 'node:os'
@@ -253,7 +254,11 @@ function assertSecret(secret) {
253
254
  */
254
255
  export function encryptRecipient(address, secret) {
255
256
  const clean = textOf(address)
256
- if (!EMAIL_RE.test(clean)) throw new Error('收件邮箱格式不正确,无法加密')
257
+ // Same verdict the delivery path uses: encrypting an address that SMTP could
258
+ // never deliver (non-ASCII without SMTPUTF8) only surfaced the problem at
259
+ // send time, when the configuration already looked correct.
260
+ const verdict = mailboxVerdict(clean)
261
+ if (verdict !== '') throw new Error('收件邮箱' + verdict + ',无法加密')
257
262
  const phrase = textOf(secret)
258
263
  assertSecret(phrase)
259
264
  const salt = randomBytes(SALT_BYTES)
@@ -288,7 +293,8 @@ export function decryptRecipient(payload, secret) {
288
293
  } catch {
289
294
  throw new Error('无法解密反馈收件邮箱:密钥不匹配或密文已损坏')
290
295
  }
291
- if (!EMAIL_RE.test(clear)) throw new Error('解密后的反馈收件邮箱格式不正确')
296
+ const verdict = mailboxVerdict(clear)
297
+ if (verdict !== '') throw new Error('解密后的反馈收件邮箱' + verdict)
292
298
  return clear
293
299
  }
294
300
 
@@ -600,7 +606,14 @@ export function buildRawMail(options = {}) {
600
606
  const from = assertHeaderSafe(textOf(options.from), '发件邮箱')
601
607
  const to = assertHeaderSafe(textOf(options.to), '收件邮箱')
602
608
  const now = options.now instanceof Date ? options.now : new Date()
603
- const messageId = textOf(options.messageId) || (randomUUID() + '@' + messageDomain(from))
609
+ // Message-ID is the one header this formatter used to interpolate without
610
+ // validation — and it is exported, so a caller-supplied value could inject
611
+ // CRLF or angle brackets. Only a syntactically id-shaped value survives;
612
+ // anything else falls back to a generated id.
613
+ const candidateId = textOf(options.messageId)
614
+ const messageId = /^[^\s<>@\r\n]+@[^\s<>@\r\n]+$/.test(candidateId)
615
+ ? candidateId
616
+ : randomUUID() + '@' + messageDomain(from)
604
617
  const subject = oneLine(options.subject)
605
618
  const replyTo = oneLine(options.replyTo)
606
619
  const body64 = b64(options.text).replace(/(.{76})/g, '$1\r\n')
@@ -628,6 +641,10 @@ export function buildRawMail(options = {}) {
628
641
  */
629
642
  function createChannel(socket, timeoutMs) {
630
643
  const state = { buffer: '', lines: [], waiters: [], error: null }
644
+ // Decode across chunk boundaries: `chunk.toString('utf8')` corrupts a
645
+ // multi-byte character split between two TCP segments, which turns a server's
646
+ // error text into replacement characters and makes it undiagnosable.
647
+ const decoder = new StringDecoder('utf8')
631
648
 
632
649
  const fail = (err) => {
633
650
  if (state.error === null) state.error = err
@@ -635,7 +652,7 @@ function createChannel(socket, timeoutMs) {
635
652
  for (const waiter of waiters) waiter.reject(state.error)
636
653
  }
637
654
  const onData = (chunk) => {
638
- state.buffer += chunk.toString('utf8')
655
+ state.buffer += decoder.write(chunk)
639
656
  // A peer that keeps sending bytes without CRLF must not be able to grow this
640
657
  // buffer without bound; every other read is line-delimited and small.
641
658
  if (state.buffer.length > MAX_RESPONSE_BYTES) {
@@ -690,7 +707,7 @@ function createChannel(socket, timeoutMs) {
690
707
  const first = await readLine()
691
708
  const match = /^(\d{3})([ -])(.*)$/.exec(first)
692
709
  if (match === null) throw new Error('SMTP 响应无法解析:' + cutText(first, 120))
693
- const code = Number(match[1])
710
+ let code = Number(match[1])
694
711
  const lines = [match[3]]
695
712
  if (match[2] === '-') {
696
713
  for (;;) {
@@ -698,6 +715,11 @@ function createChannel(socket, timeoutMs) {
698
715
  const nextMatch = /^(\d{3})([ -])(.*)$/.exec(next)
699
716
  if (nextMatch === null) throw new Error('SMTP 多行响应无法解析:' + cutText(next, 120))
700
717
  lines.push(nextMatch[3])
718
+ // A continuation line carrying a DIFFERENT status code is how some
719
+ // servers report the real outcome (e.g. `250-...` then `550 ...`).
720
+ // Keeping the first line's code silently turned a rejection into a
721
+ // success; the last line is the verdict.
722
+ code = Number(nextMatch[1])
701
723
  if (nextMatch[2] === ' ') break
702
724
  }
703
725
  }
@@ -732,10 +754,22 @@ async function command(channel, line, codes, what) {
732
754
  return expectCode(channel, codes, what)
733
755
  }
734
756
 
735
- /** EHLO; returns the capability lines the server advertised. */
757
+ /**
758
+ * EHLO; returns the capability lines the server advertised.
759
+ *
760
+ * RFC 5321 §4.1.1.1: a server that does not implement EHLO answers 500, 502 or
761
+ * 504 and expects HELO instead. Without the fallback such a server made the
762
+ * feedback feature unusable with a misleading "EHLO failed (500)" message.
763
+ */
736
764
  async function hello(channel, name) {
737
- const response = await command(channel, 'EHLO ' + name, 250, 'EHLO')
738
- return response.lines.map((line) => line.trim())
765
+ await channel.write('EHLO ' + name + '\r\n')
766
+ const response = await channel.readResponse()
767
+ if (response.code === 250) return response.lines.map((line) => line.trim())
768
+ if ([500, 502, 504].includes(response.code)) {
769
+ const helo = await command(channel, 'HELO ' + name, 250, 'HELO')
770
+ return helo.lines.map((line) => line.trim())
771
+ }
772
+ throw smtpError('EHLO', response)
739
773
  }
740
774
 
741
775
  /**
@@ -759,7 +793,14 @@ async function authenticate(channel, smtp, caps) {
759
793
  + cutText(response.lines.join(' '), 300)
760
794
  + '。请检查用户名 / 密码;Gmail、QQ 邮箱等服务需要「应用专用密码」,不能用登录密码。')
761
795
 
762
- if (canLogin || !canPlain) {
796
+ if (!canLogin && !canPlain) {
797
+ // Sending AUTH LOGIN to a server that never offered it produced a 504 and
798
+ // an error pointing at the password, which sent users to the wrong place.
799
+ throw new Error('SMTP 服务器不支持 LOGIN / PLAIN 认证(服务器声明的机制:'
800
+ + (mechanisms.length > 0 ? mechanisms.join('、') : '未声明')
801
+ + ')。请改用支持 LOGIN 或 PLAIN 的服务器')
802
+ }
803
+ if (canLogin) {
763
804
  await command(channel, 'AUTH LOGIN', 334, 'AUTH LOGIN')
764
805
  await command(channel, b64(smtp.user), 334, 'AUTH LOGIN 用户名')
765
806
  const done = await (async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-instruction-memory",
3
- "version": "1.4.8",
3
+ "version": "1.5.0",
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",