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 +49 -0
- package/lib/client.js +100 -17
- package/lib/index.js +353 -52
- package/lib/mailer.js +50 -9
- package/package.json +1 -1
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
|
|
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' ||
|
|
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
|
-
|
|
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
|
|
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
|
|
2588
|
-
//
|
|
2589
|
-
|
|
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
|
-
|
|
2626
|
-
|
|
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 = () =>
|
|
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
|
-
/**
|
|
375
|
-
|
|
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
|
-
|
|
794
|
-
|
|
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
|
-
//
|
|
2463
|
-
//
|
|
2464
|
-
//
|
|
2465
|
-
//
|
|
2466
|
-
//
|
|
2467
|
-
|
|
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
|
|
2549
|
-
* planHistoryPrune(). Grouping by the device tag
|
|
2550
|
-
* deliberate: two machines sharing one backup folder
|
|
2551
|
-
* setup) each keep their OWN budget, so
|
|
2552
|
-
*
|
|
2553
|
-
*
|
|
2554
|
-
*
|
|
2555
|
-
*
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
|
3030
|
-
if (
|
|
3031
|
-
//
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
}
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
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) => ({
|
|
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
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
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: '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 +=
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
738
|
-
|
|
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
|
|
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.
|
|
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",
|