sage-mem 0.9.12 → 0.9.13
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/lib/autodream/engine.js +10 -3
- package/lib/autodream/gate.js +107 -13
- package/lib/autodream/rollback.js +117 -20
- package/lib/autodream/snapshot.js +51 -5
- package/lib/autodream-audit.js +34 -2
- package/lib/autodream-tools.js +22 -1
- package/lib/client.js +3 -0
- package/lib/index.js +21 -2
- package/lib/memory/access.js +22 -1
- package/lib/memory/archive.js +158 -12
- package/lib/memory/index-file.js +49 -3
- package/lib/memory/util.js +104 -1
- package/lib/typert.host.js +38 -4
- package/package.json +8 -8
package/lib/autodream/engine.js
CHANGED
|
@@ -119,8 +119,9 @@ export class AutodreamEngine {
|
|
|
119
119
|
async releaseLock() { return gateMod.releaseLock.call(this) }
|
|
120
120
|
|
|
121
121
|
// ── 回滚点(快照):实现在 lib/autodream/snapshot.js ──
|
|
122
|
-
|
|
123
|
-
async
|
|
122
|
+
// `opts.protect` = 本轮轮转不许淘汰的快照目录名(回滚要用它自己的源,别被自己删掉)。
|
|
123
|
+
async snapshot(id, kind = 'run', opts) { return snapshotMod.snapshot.call(this, id, kind, opts) }
|
|
124
|
+
async pruneSnapshots(protect) { return snapshotMod.pruneSnapshots.call(this, protect) }
|
|
124
125
|
async resolveSnapshotDir(id) { return snapshotMod.resolveSnapshotDir.call(this, id) }
|
|
125
126
|
async verifySnapshot(dir, opts) { return snapshotMod.verifySnapshot.call(this, dir, opts) }
|
|
126
127
|
async listSnapshots() { return snapshotMod.listSnapshots.call(this) }
|
|
@@ -459,10 +460,16 @@ export class AutodreamEngine {
|
|
|
459
460
|
for (const c of candidates) {
|
|
460
461
|
const reason = `自动归档:${c.type} 类,超过 ${c.limit} 天未被注入`
|
|
461
462
|
const res = await archiveMemory(this.memoryDir, c.file, reason)
|
|
463
|
+
// 索引没摘掉要跟着进报告(复审 R11 的配套):报告是自动路径上唯一被人看到的地方。
|
|
464
|
+
if (res.ok && res.indexError) {
|
|
465
|
+
this.log?.('warn', `自动归档:${c.file} 的索引没摘掉 —— ${res.indexError}`)
|
|
466
|
+
}
|
|
462
467
|
archived.push({
|
|
463
468
|
file: c.file,
|
|
464
469
|
ok: res.ok === true,
|
|
465
|
-
message: res.ok
|
|
470
|
+
message: res.ok
|
|
471
|
+
? `已归档 ${res.file}(原因:${res.archivedReason})${res.indexError ? `;⚠ 索引没摘掉:${res.indexError}` : ''}`
|
|
472
|
+
: res.error,
|
|
466
473
|
})
|
|
467
474
|
}
|
|
468
475
|
return { mode: 'auto', requested, candidates, archived, degraded: '' }
|
package/lib/autodream/gate.js
CHANGED
|
@@ -83,11 +83,20 @@ export async function acquireLock() {
|
|
|
83
83
|
*/
|
|
84
84
|
const before = await readJson(this.lockPath)
|
|
85
85
|
const grabbed = `${this.lockPath}.stale-${randomUUID().replace(/-/g, '')}`
|
|
86
|
-
const
|
|
87
|
-
() =>
|
|
88
|
-
() =>
|
|
86
|
+
const moveErr = await rename(this.lockPath, grabbed).then(
|
|
87
|
+
() => null,
|
|
88
|
+
(err) => err,
|
|
89
89
|
)
|
|
90
|
-
if (
|
|
90
|
+
if (moveErr) {
|
|
91
|
+
/**
|
|
92
|
+
* ⚠️ 失败要分流,否则「占用/权限类失败」会让这条路径**递归到自己爆栈**
|
|
93
|
+
* (旧写法 `if (!moved) return this.acquireLock()`:锁没被移走,重来再走同一条分支,
|
|
94
|
+
* 一路递归下去)。只有 ENOENT(别人抢先移走了、锁位确实空着)重来才有意义。
|
|
95
|
+
*/
|
|
96
|
+
if (moveErr?.code === 'ENOENT') return this.acquireLock() // 别人抢先移走了,直接重来
|
|
97
|
+
this.log?.('warn', `抢占过期锁失败(rename ${moveErr?.code ?? moveErr}):${this.lockPath} 没能移走;${moveErr?.message ?? moveErr}`)
|
|
98
|
+
return false
|
|
99
|
+
}
|
|
91
100
|
const movedInfo = await stat(grabbed).catch(() => null)
|
|
92
101
|
const after = await readJson(grabbed)
|
|
93
102
|
const stillStale = movedInfo !== null && Date.now() - movedInfo.mtimeMs > LOCK_STALE_MS
|
|
@@ -98,11 +107,20 @@ export async function acquireLock() {
|
|
|
98
107
|
() => true,
|
|
99
108
|
() => false,
|
|
100
109
|
)
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
110
|
+
if (restored) {
|
|
111
|
+
// 名字已经回到原位,这个私有名可以收掉了。
|
|
112
|
+
await rm(grabbed, { force: true }).catch((err) => {
|
|
113
|
+
this.log?.('warn', `锁抢占复核不通过且遗留文件清不掉:${grabbed}(${err?.message ?? err})`)
|
|
114
|
+
})
|
|
115
|
+
} else {
|
|
116
|
+
/**
|
|
117
|
+
* ⚠️ 还回失败(锁位置已被第三方占用)时**不能删 `grabbed`**。
|
|
118
|
+
*
|
|
119
|
+
* 它是我们误移走的那份锁的唯一落身处,删掉等于「既没还回去、又把人家的凭据抹了」
|
|
120
|
+
* —— 与 R05 同一个根因。留着它:名字带 `.stale-` 后缀,不会被任何逻辑当成锁路径,
|
|
121
|
+
* 内容还在磁盘上,排错时看得见。
|
|
122
|
+
*/
|
|
123
|
+
this.log?.('warn', `锁抢占复核不通过,且锁位置已被占用 —— 未覆盖它;被移走的那份留在 ${grabbed}`)
|
|
106
124
|
}
|
|
107
125
|
return false
|
|
108
126
|
}
|
|
@@ -122,6 +140,28 @@ export async function acquireLock() {
|
|
|
122
140
|
* 会把**别人中途抢到的新锁**删掉 —— 于是两个整理进程同时往里写,而两边都以为自己独占。
|
|
123
141
|
* 判据落在锁文件里的 `token` 上:不是自己的,一个字都不动。
|
|
124
142
|
*
|
|
143
|
+
* ⚠️ 光有 token 判据还不够(2026-10-12 复审 R05):「读锁 → 比 token → rm」是三次独立
|
|
144
|
+
* 系统调用,**比较通过之后、rm 之前**别人完全可能抢走过期锁并写入新 token(实测用
|
|
145
|
+
* 两个真实进程 + IPC 暂停点即可稳定复现:新持有者拿到了锁,随即被旧持有者删掉)。
|
|
146
|
+
* 判据正确的写法必须让「删」这个动作本身带上身份前提 —— 这里复用 `acquireLock` 里
|
|
147
|
+
* 抢占过期锁的同一套手法:
|
|
148
|
+
*
|
|
149
|
+
* ① `rename(lockPath, 私有名)` —— **原子地**把锁文件移走。同一把锁只可能被一个进程移走;
|
|
150
|
+
* ② 读移走的那份,token 是**自己的** → `rm` 私有名,释放完成;
|
|
151
|
+
* ③ token **不是自己的**(说明我们移走的是别人刚建的新锁)→ 用 `link()` 还回去。
|
|
152
|
+
* `link` 在锁位置已被占用时报 EEXIST,**绝不覆盖**;还回去了就什么都不少。
|
|
153
|
+
*
|
|
154
|
+
* 顺序是**三层**:预检(廉价,挡住绝大多数)→ `rename` 抢占(原子)→ 复核 + 还回(兜换主)。
|
|
155
|
+
* 三层缺一不可:只有预检就是原来的 R05;只有 rename + 复核则会留下「锁位空着的窗口」——
|
|
156
|
+
* 第三方在那个窗口里 `open(wx)` 能直接拿到锁(三实例实测),所以预检必须先做。
|
|
157
|
+
*
|
|
158
|
+
* 两条已知残留,说清楚不藏:
|
|
159
|
+
* · 预检通过之后、`rename` 之前换主 → 移走别人的锁 → 复核发现 → `link` 还回(不会删错东西);
|
|
160
|
+
* 若此刻锁位已被第三方占用,还回失败,那份锁会**留在 `.released-*`**(内容不丢,可人工捞,
|
|
161
|
+
* 不会被任何路径当成锁)。
|
|
162
|
+
* · 进程在本函数中途被 kill → 锁位可能空着,后来者免过期直接拿到锁。这是「崩溃」的代价,
|
|
163
|
+
* 与「两把锁同时有效」不同:那时没有任何进程会以为自己还持着已被移走的那把。
|
|
164
|
+
*
|
|
125
165
|
* ⚠️ 边界说清楚:`token` 明文躺在锁文件里,**它不是秘密**,防的是「协作的两个实例各跑各的」
|
|
126
166
|
* 这类误删,不防「能读这个目录的对手」—— 那种对手本来就能直接删锁文件。
|
|
127
167
|
*/
|
|
@@ -129,13 +169,67 @@ export async function releaseLock() {
|
|
|
129
169
|
const token = this.lockToken
|
|
130
170
|
// 没持有过(acquireLock 从没成功过)→ 什么都不做。旧实现会在这里删掉别人的锁。
|
|
131
171
|
if (!token) return
|
|
132
|
-
|
|
133
|
-
|
|
172
|
+
/**
|
|
173
|
+
* ① **廉价预检**:锁位上现在这把还是我的吗?不是就**一个动作都不做**。
|
|
174
|
+
*
|
|
175
|
+
* 这一步不能省(复审复核实测的三实例反例):只靠「rename 移走 + 复核 + 还回」的话,
|
|
176
|
+
* 从 `rename` 成功到 `link` 还回之间,**锁位置是空的** —— 第三方在这个窗口里
|
|
177
|
+
* `open(wx)` 能直接拿到锁,而原来那把(B 的)还躺在 `.released-*` 里;
|
|
178
|
+
* 终态是「B 以为独占、C 也拿着锁」,正是 R05 要防的两个整理进程同时写。
|
|
179
|
+
* 预检把绝大多数情况挡在 `rename` 之前(旧实现本来就有这一步,不该丢),
|
|
180
|
+
* `rename + 复核 + 还回` 只用来兜「预检通过之后才换主」那一小段。
|
|
181
|
+
*/
|
|
182
|
+
const head = await readJson(this.lockPath)
|
|
183
|
+
if (!head || head.token !== token) {
|
|
184
|
+
this.lockToken = ''
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
const grabbed = `${this.lockPath}.released-${randomUUID().replace(/-/g, '')}`
|
|
188
|
+
const moveErr = await rename(this.lockPath, grabbed).then(
|
|
189
|
+
() => null,
|
|
190
|
+
(err) => err,
|
|
191
|
+
)
|
|
192
|
+
if (moveErr) {
|
|
193
|
+
/**
|
|
194
|
+
* ⚠️ 这里**必须分流**(复审复核实测,真机 Windows):`rename` 失败的原因不只有
|
|
195
|
+
* 「锁已经不在」。锁文件被占用(AV / 同步盘 / 只读共享)时报 EBUSY / EPERM / EACCES,
|
|
196
|
+
* 那时锁**还留在盘上**,而旧写法把它和 ENOENT 混成一个分支、清掉 token 就返回 ——
|
|
197
|
+
* 一行日志都没有,engine 以为自己释放了,autodream 却被自己的判据挡满 LOCK_STALE_MS。
|
|
198
|
+
* `util.js` 头注那条「非 ENOENT 的失败必须落到日志」正在这条路上。
|
|
199
|
+
*/
|
|
200
|
+
if (moveErr?.code === 'ENOENT') {
|
|
201
|
+
// 锁确实不在了(另一实例抢先释放 / 被人工清掉):没有可删的东西,也不算失败。
|
|
202
|
+
this.lockToken = ''
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
this.log?.('warn', `释放锁失败(rename ${moveErr?.code ?? moveErr}):${this.lockPath} 没能移走,锁可能仍在盘上;${moveErr?.message ?? moveErr}`)
|
|
206
|
+
// 保留 lockToken:锁大概还在,下次再试;也让调用方看得见这次没释放成功。
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
const held = await readJson(grabbed)
|
|
210
|
+
if (held?.token !== token) {
|
|
211
|
+
// 预检之后、移走之前被换主了:移走的是**别人**的锁,还回去。`link` 不覆盖已存在的锁位置。
|
|
212
|
+
const restored = await link(grabbed, this.lockPath).then(
|
|
213
|
+
() => true,
|
|
214
|
+
() => false,
|
|
215
|
+
)
|
|
216
|
+
if (restored) {
|
|
217
|
+
await rm(grabbed, { force: true }).catch((err) => {
|
|
218
|
+
this.log?.('warn', `释放锁:归还成功后残留文件清不掉:${grabbed}(${err?.message ?? err})`)
|
|
219
|
+
})
|
|
220
|
+
} else {
|
|
221
|
+
/**
|
|
222
|
+
* ⚠️ 归还失败时**不能删 `grabbed`**:那是被我们误移走的那份锁的唯一落身处。
|
|
223
|
+
* 删掉它就成了「既没还回去、又把人家的凭据抹了」—— 与 R05 同一个根因。
|
|
224
|
+
* 名字带 `.released-` 后缀,不会在任何路径上被当成锁;内容留着,排错时看得见。
|
|
225
|
+
*/
|
|
226
|
+
this.log?.('warn', `释放锁:移走的不是自己的锁,且锁位置已被占用 —— 未覆盖它;被移走的那份留在 ${grabbed}`)
|
|
227
|
+
}
|
|
134
228
|
this.lockToken = ''
|
|
135
229
|
return
|
|
136
230
|
}
|
|
137
|
-
await rm(
|
|
138
|
-
this.log?.('warn',
|
|
231
|
+
await rm(grabbed, { force: true }).catch((err) => {
|
|
232
|
+
this.log?.('warn', `释放锁:删自己的私有名失败(${err?.message ?? err});残留 ${grabbed}`)
|
|
139
233
|
})
|
|
140
234
|
this.lockToken = ''
|
|
141
235
|
}
|
|
@@ -11,9 +11,10 @@ import { readJson, safeName, snapshotEntries, stampCompact, stampHuman } from '.
|
|
|
11
11
|
import { restoreMemory } from '../memory/archive.js'
|
|
12
12
|
import { nameKey } from '../memory/naming.js'
|
|
13
13
|
import { stripArchiveMeta } from '../memory/scan.js'
|
|
14
|
+
import { writeFileAtomic } from '../memory/util.js'
|
|
14
15
|
import { randomUUID } from 'node:crypto'
|
|
15
|
-
import {
|
|
16
|
-
import { dirname, join } from 'node:path'
|
|
16
|
+
import { chmod, link, lstat, mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'
|
|
17
|
+
import { basename, dirname, join } from 'node:path'
|
|
17
18
|
|
|
18
19
|
/** 读回滚留痕(面板也许要显示「最近谁退回过」)。 */
|
|
19
20
|
export async function listRollbacks(limit = 20) {
|
|
@@ -107,7 +108,17 @@ export async function rollback(opts = {}) {
|
|
|
107
108
|
// 带随机后缀:秒级时间戳会撞(同一秒内连点两次回滚是真实用法),
|
|
108
109
|
// 而撞车的后果是**覆盖掉上一次的保护快照**——那等于把「回滚也能退」这条承诺吃掉。
|
|
109
110
|
const protectionId = `pre-rollback-${stamp}-${randomUUID().replace(/-/g, '').slice(0, 4)}`
|
|
110
|
-
|
|
111
|
+
/**
|
|
112
|
+
* ⚠️ 把选中的回滚源列进**轮转豁免名单**(2026-10-12 复审 R03)。
|
|
113
|
+
*
|
|
114
|
+
* 保护快照走的是 `snapshot()`,而它末尾会轮转旧快照。被选中的源若正好是最旧的
|
|
115
|
+
* 那份保护快照,就会在**恢复开始之前**被这趟回滚自己删掉:之后枚举源得到空集合,
|
|
116
|
+
* 回滚以 `ok:true / restored:0` 收场 —— 报成功、什么都没恢复,而人以为退回去了。
|
|
117
|
+
* 「正在被使用的回滚点」不该参与淘汰。豁免只对当前快照根里的那份有意义(旧版根不轮转)。
|
|
118
|
+
*/
|
|
119
|
+
const sourceDirName = basename(snapDir)
|
|
120
|
+
const protect = dirname(snapDir) === this.snapshotRoot ? [sourceDirName] : []
|
|
121
|
+
const protection = await this.snapshot(protectionId, 'pre-rollback', { protect })
|
|
111
122
|
|
|
112
123
|
/**
|
|
113
124
|
* ⚠️ 保护快照不完整就**中止**,一个字都不许写。
|
|
@@ -139,7 +150,65 @@ export async function rollback(opts = {}) {
|
|
|
139
150
|
|
|
140
151
|
// ② 恢复
|
|
141
152
|
this.phase = '恢复文件'
|
|
153
|
+
/**
|
|
154
|
+
* ②-a 开工前**再确认一次源还在**。
|
|
155
|
+
*
|
|
156
|
+
* 上面那句 `protect` 是根治,这里再加一道兜底:从校验(`verifySnapshot`)到现在,
|
|
157
|
+
* 中间夹着一次完整的保护快照(建目录、逐文件复制、轮转删除),期间外部完全可能
|
|
158
|
+
* 把快照根清掉或轮转掉。源的消失意味着「恢复」退化成「把顶层文件按『快照里没有』
|
|
159
|
+
* 处理」—— 那是**比不回滚更糟**的结局。宁可中止,一个字不写。
|
|
160
|
+
*/
|
|
161
|
+
if (!(await stat(snapDir).catch(() => null))?.isDirectory()) {
|
|
162
|
+
this.log('warn', `回滚中止:回滚点 ${snapshotId} 在恢复动作开始前消失了`)
|
|
163
|
+
this.phase = ''
|
|
164
|
+
return {
|
|
165
|
+
ok: false,
|
|
166
|
+
error: `回滚点 ${snapshotId} 在恢复动作开始前消失了(可能被轮转或人工清理)。这次回滚已中止,记忆目录一个字没动。`,
|
|
167
|
+
}
|
|
168
|
+
}
|
|
142
169
|
const snapNames = await snapshotEntries(snapDir)
|
|
170
|
+
/**
|
|
171
|
+
* 名录为空而清单说有文件 → 源被掏空了(轮转删目录内容、杀软隔离、外部清理)。
|
|
172
|
+
* 照旧往下走的话,整目录回滚会把顶层**每一条记忆**都判成「快照里没有」→ 全部移进
|
|
173
|
+
* `archive/`,然后报 `ok:true / restored:0`。这是必须挡死的方向。
|
|
174
|
+
*/
|
|
175
|
+
const expectedSnapFiles = Array.isArray(manifest?.files) ? manifest.files.length : 0
|
|
176
|
+
if (!snapNames.length && expectedSnapFiles > 0) {
|
|
177
|
+
this.log('warn', `回滚中止:回滚点 ${snapshotId} 的清单记着 ${expectedSnapFiles} 个文件,但目录里一个都没有`)
|
|
178
|
+
this.phase = ''
|
|
179
|
+
return {
|
|
180
|
+
ok: false,
|
|
181
|
+
error: `回滚点 ${snapshotId} 的内容不见了(清单记着 ${expectedSnapFiles} 个文件,目录里一个都没有)。为免把现有记忆全部误判成「多余的」而移进 archive/,这次回滚已中止,记忆目录一个字没动。`,
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* **写回一份快照内容**。
|
|
186
|
+
*
|
|
187
|
+
* ⚠️ 用 `writeFileAtomic`(写临时文件 + rename 替换**目录项**),**不用 `copyFile`**
|
|
188
|
+
* (2026-10-12 复审 R02)。`copyFile` 是打开目标、往它**已有的 inode** 里灌内容,
|
|
189
|
+
* 于是:
|
|
190
|
+
* - 目标有硬链接(`cp -al` / `rsync --link-dest` 做的快照备份)→ 记忆目录**之外**那份
|
|
191
|
+
* 备份被一起改掉,实测复现;
|
|
192
|
+
* - 目标是符号链接 / 目录联接 → 内容穿过链接,写到目录外的真实文件上,实测复现。
|
|
193
|
+
* rename 之后硬链接关系自然解除(备份保持原样),符号链接被**替换成本身**而不是穿透。
|
|
194
|
+
* 这正是「把记忆目录恢复成快照那一版」该有的语义:动的是这个名字,不是它指向的那个 inode。
|
|
195
|
+
*/
|
|
196
|
+
const writeBack = async (name, snapName) => {
|
|
197
|
+
const target = join(this.memoryDir, name)
|
|
198
|
+
/**
|
|
199
|
+
* 记下目标原来的权限位,写完再贴回去。
|
|
200
|
+
*
|
|
201
|
+
* 为什么(复审复核点出的 R02 副作用):`writeFileAtomic` 是「新建临时文件 + rename」,
|
|
202
|
+
* 新文件的 mode 走 umask;而 `copyFile`(旧实现)会沿用源文件的 mode。于是一个被用户
|
|
203
|
+
* 设成 0600 的记忆文件,回滚一趟就变成 0644 —— 隐私被悄悄放宽。
|
|
204
|
+
* 用 `lstat`:目标是符号链接时不该跟随(`stat` 会拿到链接指向的外部文件权限)。
|
|
205
|
+
* 只有**普通文件**才贴回原权限;链接 / 新建文件用默认值。Windows 上 chmod 是 no-op。
|
|
206
|
+
*/
|
|
207
|
+
const before = await lstat(target).catch(() => null)
|
|
208
|
+
const keepMode = before && before.isFile() && typeof before.mode === 'number' ? before.mode : null
|
|
209
|
+
await writeFileAtomic(target, await readFile(join(snapDir, snapName)))
|
|
210
|
+
if (keepMode !== null) await chmod(target, keepMode).catch(() => {})
|
|
211
|
+
}
|
|
143
212
|
/**
|
|
144
213
|
* 快照名录按**归一化名字**索引(`nameKey`:小写归一)。
|
|
145
214
|
*
|
|
@@ -160,6 +229,28 @@ export async function rollback(opts = {}) {
|
|
|
160
229
|
const archiveDir = join(this.memoryDir, 'archive')
|
|
161
230
|
await mkdir(archiveDir, { recursive: true })
|
|
162
231
|
|
|
232
|
+
/**
|
|
233
|
+
* **排他移动**:先 `link` 到目标(目标已存在 → `EEXIST`,绝不覆盖),成功后再删源。
|
|
234
|
+
*
|
|
235
|
+
* 为什么不能 `stat → rename`(复审复核实测构造出了覆盖):`rename` 在目标已存在时
|
|
236
|
+
* **静默覆盖**,而 `stat` 与 `rename` 之间正是别人(面板 / 模型工具的 `archiveMemory`,
|
|
237
|
+
* 它们不取 autodream 这把锁)可能刚把同名归档写进去的窗口 —— 与 R06② 同一类:
|
|
238
|
+
* 第二次系统调用没有存在性前提。而本函数的注释一直承诺「绝不覆盖」,代码却做不到。
|
|
239
|
+
*
|
|
240
|
+
* 平台不支持硬链接 / 跨卷时退回 `rename`(与 `memory/util.js` 那边的降级口径一致),
|
|
241
|
+
* 那是已知降级;NTFS / APFS / ext4 上走的是排他路径。
|
|
242
|
+
*/
|
|
243
|
+
const moveExclusive = async (from, to) => {
|
|
244
|
+
try {
|
|
245
|
+
await link(from, to)
|
|
246
|
+
} catch (err) {
|
|
247
|
+
if (err?.code === 'EEXIST') throw err
|
|
248
|
+
await rename(from, to)
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
await rm(from, { force: true })
|
|
252
|
+
}
|
|
253
|
+
|
|
163
254
|
/**
|
|
164
255
|
* 移进 archive/(不删)。同名时**换一个退役名**落进去,绝不覆盖,也绝不放弃。
|
|
165
256
|
*
|
|
@@ -167,30 +258,36 @@ export async function rollback(opts = {}) {
|
|
|
167
258
|
* 归档区已经被本趟占着,回滚要让它从顶层退场时撞名 → 直接放弃 → 那条运行前根本
|
|
168
259
|
* 不存在的记忆继续活在顶层。目标状态是「顶层没有它」,撞名只是「原件名被占了」
|
|
169
260
|
* ——换个名字照样达到目标,且仍然一个文件都没删。
|
|
261
|
+
*
|
|
262
|
+
* 现在连「撞名判定」都不需要了:直接按「原名 → 退役名 → 再一个退役名」依次**排他落位**,
|
|
263
|
+
* `EEXIST` 就换下一个。判定与动作合成一步,窗口自然消失。
|
|
170
264
|
*/
|
|
171
265
|
const park = async (name, why) => {
|
|
172
|
-
const
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
266
|
+
const from = join(this.memoryDir, name)
|
|
267
|
+
const retiredName = () => `${name.replace(/\.md$/i, '')}.retired-${stamp}-${randomUUID().replace(/-/g, '').slice(0, 6)}.md`
|
|
268
|
+
for (const candidate of [name, retiredName(), retiredName()]) {
|
|
269
|
+
try {
|
|
270
|
+
await moveExclusive(from, join(archiveDir, candidate))
|
|
271
|
+
} catch (err) {
|
|
272
|
+
if (err?.code === 'EEXIST') continue
|
|
273
|
+
skipped.push(`${name}(本想${why},archive/ 里写不进去:${err?.message ?? err})`)
|
|
274
|
+
return false
|
|
275
|
+
}
|
|
276
|
+
parked.push(
|
|
277
|
+
candidate === name
|
|
278
|
+
? `${name}(${why})`
|
|
279
|
+
: `${name} → ${candidate}(${why};archive/ 已有同名,改用退役名,未删除)`,
|
|
280
|
+
)
|
|
177
281
|
return true
|
|
178
282
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
await rename(join(this.memoryDir, name), join(archiveDir, retired))
|
|
182
|
-
} catch (err) {
|
|
183
|
-
skipped.push(`${name}(本想${why},archive/ 已有同名、退役名也写不进去:${err?.message ?? err})`)
|
|
184
|
-
return false
|
|
185
|
-
}
|
|
186
|
-
parked.push(`${name} → ${retired}(${why};archive/ 已有同名,改用退役名,未删除)`)
|
|
187
|
-
return true
|
|
283
|
+
skipped.push(`${name}(本想${why},archive/ 里原名与两个退役名都被占了)`)
|
|
284
|
+
return false
|
|
188
285
|
}
|
|
189
286
|
|
|
190
287
|
if (scope === 'all') {
|
|
191
288
|
for (const name of snapNames) {
|
|
192
289
|
try {
|
|
193
|
-
await
|
|
290
|
+
await writeBack(name, name)
|
|
194
291
|
restored.push(name)
|
|
195
292
|
} catch (err) {
|
|
196
293
|
skipped.push(`${name}(写回失败:${err?.message ?? err})`)
|
|
@@ -243,7 +340,7 @@ export async function rollback(opts = {}) {
|
|
|
243
340
|
// 运行前存在:目标状态是「顶层是运行前那一版」。
|
|
244
341
|
if (atTop) {
|
|
245
342
|
try {
|
|
246
|
-
await
|
|
343
|
+
await writeBack(name, snapName)
|
|
247
344
|
restored.push(name)
|
|
248
345
|
} catch (err) {
|
|
249
346
|
skipped.push(`${name}(写回失败:${err?.message ?? err})`)
|
|
@@ -272,7 +369,7 @@ export async function rollback(opts = {}) {
|
|
|
272
369
|
else skipped.push(`${name}(恢复失败:${res.error})`)
|
|
273
370
|
} else if (snapContent) {
|
|
274
371
|
try {
|
|
275
|
-
await
|
|
372
|
+
await writeBack(name, snapName)
|
|
276
373
|
restored.push(name)
|
|
277
374
|
// ⚠️ 记进 notes 而**不是** skipped:文件确实恢复了(restored 里算一个),
|
|
278
375
|
// 只是没走「从 archive/ 移回」那条常规路径。计进 skipped 会让面板说
|
|
@@ -22,9 +22,11 @@ import { join, basename } from 'node:path'
|
|
|
22
22
|
*
|
|
23
23
|
* @param {string} id — 快照 id(apply 运行就是 runId;保护快照用 `pre-rollback-<时间戳>`)
|
|
24
24
|
* @param {'run'|'pre-rollback'} kind
|
|
25
|
+
* @param {{protect?:string[]}} [opts] — `protect` = 本轮轮转**不许淘汰**的快照目录名
|
|
26
|
+
* (回滚建保护快照时必须把它自己的源列进去,见 `pruneSnapshots` 的 R03 说明)
|
|
25
27
|
* @returns {Promise<{id:string, dir:string, count:number, expected:number, failed:string[], files:Array<{name:string,bytes:number,sha256:string}>}>}
|
|
26
28
|
*/
|
|
27
|
-
export async function snapshot(id, kind = 'run') {
|
|
29
|
+
export async function snapshot(id, kind = 'run', opts = {}) {
|
|
28
30
|
const dir = join(this.snapshotRoot, id)
|
|
29
31
|
await mkdir(this.snapshotRoot, { recursive: true })
|
|
30
32
|
// **故意不用 recursive**:快照 id 撞车必须是硬错误,不能静默覆盖一份已有的回滚点。
|
|
@@ -80,7 +82,7 @@ export async function snapshot(id, kind = 'run') {
|
|
|
80
82
|
failed,
|
|
81
83
|
}
|
|
82
84
|
await writeFile(join(dir, 'MANIFEST.json'), JSON.stringify(manifest, null, 2), 'utf8')
|
|
83
|
-
await this.pruneSnapshots()
|
|
85
|
+
await this.pruneSnapshots(opts?.protect ?? [])
|
|
84
86
|
return { id, dir, count: recorded.length, expected: files.length, failed, files: recorded }
|
|
85
87
|
}
|
|
86
88
|
|
|
@@ -90,13 +92,24 @@ export async function snapshot(id, kind = 'run') {
|
|
|
90
92
|
* **两类分开数**:普通运行的快照按 `maxSnapshotKeep`;「回滚前保护快照」
|
|
91
93
|
* 另给一个额度(`PRE_ROLLBACK_KEEP`)—— 保护快照的唯一用途就是「刚回滚完发现滚错了」,
|
|
92
94
|
* 被普通运行的快照轮转挤掉等于没有。旧版目录(`<stateRoot>/snapshots/`)只读,不参与淘汰。
|
|
95
|
+
*
|
|
96
|
+
* ⚠️ `protect` 是**豁免名单**(2026-10-12 复审 R03):回滚会先建一份保护快照,而
|
|
97
|
+
* `snapshot()` 末尾就调这里轮转 —— 若被选中的回滚源恰好是最旧的那份保护快照,
|
|
98
|
+
* 它会在**恢复动作开始之前**被自己这趟回滚删掉:随后源目录枚举为空,
|
|
99
|
+
* 回滚以 `ok:true / restored:0` 收场,而人以为自己退回去了。
|
|
100
|
+
* 「正在被使用的回滚点」必须排在淘汰之外。
|
|
101
|
+
*
|
|
102
|
+
* @param {string[]} [protect] — 本轮不许淘汰的快照目录名
|
|
93
103
|
*/
|
|
94
|
-
export async function pruneSnapshots() {
|
|
104
|
+
export async function pruneSnapshots(protect = []) {
|
|
95
105
|
const keep = Math.max(1, this.state.config.maxSnapshotKeep || DEFAULT_CONFIG.maxSnapshotKeep)
|
|
106
|
+
const keepNames = new Set((Array.isArray(protect) ? protect : []).filter((n) => typeof n === 'string' && n))
|
|
96
107
|
const entries = await readdir(this.snapshotRoot, { withFileTypes: true }).catch(() => [])
|
|
97
108
|
const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name).sort()
|
|
98
|
-
|
|
99
|
-
const
|
|
109
|
+
// 被豁免的那份先从轮转名单里摘出去:它不占额度,也不参与「谁最旧」的排序。
|
|
110
|
+
const eligible = dirs.filter((n) => !keepNames.has(n))
|
|
111
|
+
const preRollback = eligible.filter((n) => n.startsWith('pre-rollback-'))
|
|
112
|
+
const runs = eligible.filter((n) => !n.startsWith('pre-rollback-'))
|
|
100
113
|
for (const name of runs.slice(0, Math.max(0, runs.length - keep))) {
|
|
101
114
|
await rm(join(this.snapshotRoot, name), { recursive: true, force: true }).catch(() => {})
|
|
102
115
|
}
|
|
@@ -196,6 +209,39 @@ export async function verifySnapshot(dir, opts = {}) {
|
|
|
196
209
|
if (legacy) return unhashed('这是一份旧版回滚点(MANIFEST 里没有逐文件哈希),内容无法校验')
|
|
197
210
|
return broken('MANIFEST.json 里没有逐文件哈希清单(files 不是数组)—— 无法校验内容')
|
|
198
211
|
}
|
|
212
|
+
/**
|
|
213
|
+
* ⚠️ **清单自己声明过「这次没存全」时,校验必须直接拒绝**(2026-10-12 复审 R04)。
|
|
214
|
+
*
|
|
215
|
+
* `snapshot()` 把读失败的文件记进 `manifest.failed`,而这里原先只遍历 `files`(成功
|
|
216
|
+
* 存下来的那些)—— 一份 `failed:["a.md"]` 的快照于是能拿满分:`ok:true / checked:1`,
|
|
217
|
+
* 四个异常数组全空,回滚照走。
|
|
218
|
+
*
|
|
219
|
+
* 后果不是「少恢复一个文件」,而是**判据被换掉了**:整目录回滚会把 a.md 当成
|
|
220
|
+
* 「快照里本来就没有」→ 移进 `archive/`。一份已知残缺的快照被当成「当时那一版」,
|
|
221
|
+
* 而它恰恰不能代表那一版 —— 与「回滚点内容被外部改过就拒绝」是同一条原则,
|
|
222
|
+
* 只是这一条写在自己人留下的元数据里。
|
|
223
|
+
*
|
|
224
|
+
* 只认 `manifest.failed`:旧版快照没有这个字段(undefined → 空数组,不受影响)。
|
|
225
|
+
*/
|
|
226
|
+
const manifestFailed = Array.isArray(manifest?.failed)
|
|
227
|
+
? manifest.failed.filter((n) => typeof n === 'string' && n)
|
|
228
|
+
: []
|
|
229
|
+
if (manifestFailed.length) {
|
|
230
|
+
return {
|
|
231
|
+
ok: false,
|
|
232
|
+
schema: 'hashed',
|
|
233
|
+
checked: 0,
|
|
234
|
+
missing: [],
|
|
235
|
+
tampered: [],
|
|
236
|
+
extra: [],
|
|
237
|
+
invalid: [],
|
|
238
|
+
manifestFailed,
|
|
239
|
+
reason:
|
|
240
|
+
`这份回滚点当初就没存全(${manifestFailed.length} 个文件捕获失败:` +
|
|
241
|
+
`${manifestFailed.slice(0, 3).join('、')}${manifestFailed.length > 3 ? '…' : ''})` +
|
|
242
|
+
'—— 它不能代表「当时那一版」',
|
|
243
|
+
}
|
|
244
|
+
}
|
|
199
245
|
|
|
200
246
|
const missing = []
|
|
201
247
|
const tampered = []
|
package/lib/autodream-audit.js
CHANGED
|
@@ -23,12 +23,23 @@
|
|
|
23
23
|
|
|
24
24
|
import { readdir, readFile } from 'node:fs/promises'
|
|
25
25
|
import { join } from 'node:path'
|
|
26
|
-
import { inspectMemoryEntry } from './memory/scan.js'
|
|
26
|
+
import { ARCHIVE_DIR_NAME, inspectMemoryEntry } from './memory/scan.js'
|
|
27
27
|
import { warnLog } from './memory/util.js'
|
|
28
28
|
|
|
29
29
|
/** 索引文件名。它本身不是一条记忆,是索引。 */
|
|
30
30
|
const INDEX_FILE = 'MEMORY.md'
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* 归档 / 恢复中途崩溃留下的**私有名残留**:`a.md.archiving-<hex>`。
|
|
34
|
+
*
|
|
35
|
+
* 为什么单列一类(2026-10-12 复审复核点出):`memory/archive.js` 把源 `rename` 到这个
|
|
36
|
+
* 私有名之后、复核完成之前若进程死掉,这份内容就留在了记忆目录里。它**不是** `.md`,
|
|
37
|
+
* 所以扫描 / 注入 / 星图 / 快照 / 列表都不认它(这本来是对的,免得半成品被当成一条记忆)——
|
|
38
|
+
* 但连体检也不认,就等于「可能装着用户唯一一份内容的东西」在界面上完全隐形,
|
|
39
|
+
* 而它躺在用户会同步、会备份的记忆根目录里。审计负责把它报出来(纯只读,只报不删)。
|
|
40
|
+
*/
|
|
41
|
+
const STRAY_RE = /\.archiving-[0-9a-f]+$/i
|
|
42
|
+
|
|
32
43
|
/**
|
|
33
44
|
* 审计**整个跳过**的文件(不读、不当被审对象)。
|
|
34
45
|
*
|
|
@@ -278,6 +289,18 @@ export async function auditMemoryDir(dir) {
|
|
|
278
289
|
}
|
|
279
290
|
}
|
|
280
291
|
|
|
292
|
+
/**
|
|
293
|
+
* 归档 / 恢复崩溃留下的私有名残留(只报不删)。顶层与归档区都扫:
|
|
294
|
+
* 归档动作的源在顶层,而人工移动 / 历史版本可能让残留在归档区里也出现。
|
|
295
|
+
*/
|
|
296
|
+
const strays = []
|
|
297
|
+
for (const sub of ['', ARCHIVE_DIR_NAME]) {
|
|
298
|
+
const list = await readdir(sub ? join(dir, sub) : dir, { withFileTypes: true }).catch(() => [])
|
|
299
|
+
for (const e of list) {
|
|
300
|
+
if (e.isFile() && STRAY_RE.test(e.name)) strays.push(sub ? `${sub}/${e.name}` : e.name)
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
281
304
|
const problems =
|
|
282
305
|
dangling.length +
|
|
283
306
|
unlisted.length +
|
|
@@ -286,7 +309,8 @@ export async function auditMemoryDir(dir) {
|
|
|
286
309
|
brokenLinks.length +
|
|
287
310
|
hashHazards.length +
|
|
288
311
|
noFrontmatter.length +
|
|
289
|
-
unreadable.length
|
|
312
|
+
unreadable.length +
|
|
313
|
+
strays.length
|
|
290
314
|
|
|
291
315
|
return {
|
|
292
316
|
dir,
|
|
@@ -304,6 +328,7 @@ export async function auditMemoryDir(dir) {
|
|
|
304
328
|
hashHazards,
|
|
305
329
|
noFrontmatter: noFrontmatter.map((file) => ({ file })),
|
|
306
330
|
unreadable: unreadable.map((file) => ({ file })),
|
|
331
|
+
strays: strays.map((file) => ({ file })),
|
|
307
332
|
problems,
|
|
308
333
|
}
|
|
309
334
|
}
|
|
@@ -336,5 +361,12 @@ export function formatAudit(report, limit = 12) {
|
|
|
336
361
|
// 读不动的单列,且排最后:它不是「这条记忆写错了」,是「我没读到它」——
|
|
337
362
|
// 别让模型以为去改改 frontmatter 就能好。
|
|
338
363
|
section('读不动(权限/占用/编码,不参与上面各项检查)', report.unreadable ?? [], (r) => r.file)
|
|
364
|
+
// 归档残留:内容是**用户的**,只是文件名的形状不对。要提醒人去看,而不是让模型当成一条
|
|
365
|
+
// 记忆去整理 —— 所以写明处置方式(改名回 `.md` 或删掉),并且它不计入「记忆写错了」那类。
|
|
366
|
+
section(
|
|
367
|
+
'归档中途留下的残留(内容可能还在,请人工确认后改回 .md 或删除)',
|
|
368
|
+
report.strays ?? [],
|
|
369
|
+
(r) => r.file,
|
|
370
|
+
)
|
|
339
371
|
return lines.join('\n')
|
|
340
372
|
}
|
package/lib/autodream-tools.js
CHANGED
|
@@ -29,6 +29,7 @@ import { join, basename } from 'node:path'
|
|
|
29
29
|
import { createHash } from 'node:crypto'
|
|
30
30
|
import { auditMemoryDir, formatAudit, parseFrontmatterLite } from './autodream-audit.js'
|
|
31
31
|
import { archiveMemory } from './memory/archive.js'
|
|
32
|
+
import { MAX_FILE_BYTES } from './memory/config.js'
|
|
32
33
|
import { writeFileAtomic, warnLog } from './memory/util.js'
|
|
33
34
|
import { inspectMemoryEntry, reservedIdentitySet } from './memory/scan.js'
|
|
34
35
|
|
|
@@ -556,7 +557,18 @@ export function createToolRunner(opts) {
|
|
|
556
557
|
if (!/^---\r?\n/.test(a.content)) {
|
|
557
558
|
return '错误:内容必须以 `---` 开头的 YAML frontmatter 起始。参考 list_memory 里已有文件的写法。'
|
|
558
559
|
}
|
|
559
|
-
|
|
560
|
+
/**
|
|
561
|
+
* ⚠️ 体积闸门必须按**字节**算,与读取端(`inspectMemoryEntry` 的 `info.size`)同一把尺子。
|
|
562
|
+
*
|
|
563
|
+
* 原先按 `content.length`(UTF-16 码元数)判:18 万个汉字 = 18 万「字符」、
|
|
564
|
+
* 却是 54 万字节 —— 工具高高兴兴回「已写入」,紧接着这条记忆就被读端的
|
|
565
|
+
* 512 KiB 闸门判成 `too-big` 拒绝。**自己写出自己读不了的文件**,
|
|
566
|
+
* 而模型收到的是一句成功。多字节文本(中文正是常态)下两把尺子差了 3 倍。
|
|
567
|
+
*/
|
|
568
|
+
const bytes = Buffer.byteLength(a.content, 'utf8')
|
|
569
|
+
if (bytes > MAX_FILE_BYTES) {
|
|
570
|
+
return `错误:内容过大(${bytes} 字节,上限 ${MAX_FILE_BYTES} 字节 / ${a.content.length} 字符)。请拆成多条记忆,或只保留要点。`
|
|
571
|
+
}
|
|
560
572
|
const reason = typeof a.reason === 'string' ? a.reason.trim() : ''
|
|
561
573
|
const selfReported = reason.length >= MIN_REASON_CHARS
|
|
562
574
|
const before = await fileFingerprint(full)
|
|
@@ -606,6 +618,15 @@ export function createToolRunner(opts) {
|
|
|
606
618
|
touched.push(`${basename(full)} → archive/`)
|
|
607
619
|
listCache = null
|
|
608
620
|
auditCache = null
|
|
621
|
+
/**
|
|
622
|
+
* 归档成功、索引却没摘掉时**必须说出来**(复审 R11 的配套):索引会留下一条
|
|
623
|
+
* 指向「已不在顶层」的悬空条目,下次体检报问题,而模型只看到一句「已归档」时
|
|
624
|
+
* 完全无从修正。这类「说了要做就得做到」的出口,缺一个都等于没修。
|
|
625
|
+
*/
|
|
626
|
+
if (res.indexError) {
|
|
627
|
+
warnings.push(`${basename(full)} 的索引没摘掉:${res.indexError}`)
|
|
628
|
+
return `已归档 ${basename(full)}(原因:${reason})。⚠ 但索引里的那一行没能摘掉(${res.indexError})—— 这条记忆已不在顶层,索引会显示为悬空条目,需要人工处理。`
|
|
629
|
+
}
|
|
609
630
|
return `已归档 ${basename(full)}(原因:${reason})。`
|
|
610
631
|
}
|
|
611
632
|
default:
|
package/lib/client.js
CHANGED
|
@@ -2671,6 +2671,9 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
2671
2671
|
section("未加引号的「空格+#」", r.hashHazards, (x) => x.file + " · " + x.line),
|
|
2672
2672
|
section("没有 frontmatter", r.noFrontmatter, (x) => x.file),
|
|
2673
2673
|
section("读不出来", r.unreadable, (x) => x.file),
|
|
2674
|
+
// 归档中途崩溃留下的私有名残留:它不是一条记忆(扫描/注入都不认),
|
|
2675
|
+
// 但里面可能是用户唯一一份内容 —— 所以要在体检里露头,而不是隐形。
|
|
2676
|
+
section("归档残留(内容可能还在,请确认后改回 .md 或删除)", r.strays, (x) => x.file),
|
|
2674
2677
|
])
|
|
2675
2678
|
: h("div", { className: "smem-empty smem-empty--ok", key: "ok" },
|
|
2676
2679
|
"没有发现问题 —— 索引、双链、frontmatter 与行尾都对得上。"),
|
package/lib/index.js
CHANGED
|
@@ -504,7 +504,19 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
504
504
|
*/
|
|
505
505
|
async archive(name, reason) {
|
|
506
506
|
const res = await archiveMemory(MEMORY_DIR, name, reason)
|
|
507
|
-
|
|
507
|
+
if (!res.ok) return { ok: false, error: res.error }
|
|
508
|
+
/**
|
|
509
|
+
* ⚠️ `unindexed` / `indexError` 必须**原样透出去**(2026-10-12 复审 R11 的配套):
|
|
510
|
+
* 归档本身成功、索引却没摘掉时,这正是用户该被告知的那件事 —— 底下那句
|
|
511
|
+
* 「索引没摘」在面板上要看得见,不能只活在 host 的返回值里被 strict 剥掉。
|
|
512
|
+
*/
|
|
513
|
+
return {
|
|
514
|
+
ok: true,
|
|
515
|
+
file: res.file,
|
|
516
|
+
archivedReason: res.archivedReason,
|
|
517
|
+
unindexed: res.unindexed,
|
|
518
|
+
...(res.indexError ? { indexError: res.indexError } : {}),
|
|
519
|
+
}
|
|
508
520
|
}
|
|
509
521
|
|
|
510
522
|
/**
|
|
@@ -513,7 +525,14 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
513
525
|
*/
|
|
514
526
|
async restore(name) {
|
|
515
527
|
const res = await restoreMemory(MEMORY_DIR, name)
|
|
516
|
-
|
|
528
|
+
if (!res.ok) return { ok: false, error: res.error }
|
|
529
|
+
// 与 `archive` 对称:文件回来了但索引没补上时,如实告诉界面(见那边的说明)。
|
|
530
|
+
return {
|
|
531
|
+
ok: true,
|
|
532
|
+
file: res.file,
|
|
533
|
+
indexed: res.indexed,
|
|
534
|
+
...(res.indexError ? { indexError: res.indexError } : {}),
|
|
535
|
+
}
|
|
517
536
|
}
|
|
518
537
|
|
|
519
538
|
/**
|
package/lib/memory/access.js
CHANGED
|
@@ -16,7 +16,7 @@ import { MEMORY_DIR, STATE_ROOT } from './config.js'
|
|
|
16
16
|
import { ARCHIVE_DIR_NAME } from './scan.js'
|
|
17
17
|
import { isMemoryEntry } from './naming.js'
|
|
18
18
|
import { parseFrontmatter } from './frontmatter.js'
|
|
19
|
-
import { writeFileAtomic } from './util.js'
|
|
19
|
+
import { createWriteQueue, writeFileAtomic } from './util.js'
|
|
20
20
|
import { mkdir, readFile, readdir } from 'node:fs/promises'
|
|
21
21
|
import { dirname, join } from 'node:path'
|
|
22
22
|
import { nameKey } from './naming.js'
|
|
@@ -33,6 +33,19 @@ const DEBUG = process.env.SAGE_MEM_DEBUG === '1'
|
|
|
33
33
|
/** 「永不自动归档」的类型:feedback 是用户给的做事方式,退役它等于把人的交代扔掉。 */
|
|
34
34
|
export const NEVER_ARCHIVE_TYPES = new Set(['feedback'])
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* 台账写的**进程内串行队列**(与索引 `MEMORY.md`、锁表共用 `util.js` 里那一份实现)。
|
|
38
|
+
*
|
|
39
|
+
* 为什么必须有(2026-10-12 复审复核点出,与 R07 同源):`flushAccess` 是
|
|
40
|
+
* 「读整份台账 → 合并增量 → 整体覆盖」。两个 flush 撞上时两边都读到旧盘、各自覆盖,
|
|
41
|
+
* 后写的那个把先写的**时间戳**冲掉 —— 而 `pending` 在写盘前就清空了,那批时间戳
|
|
42
|
+
* 无处可回。后果不是丢一条计时,而是 `idleDays` 变小甚至回落到 `updated/mtime`,
|
|
43
|
+
* 让**自动归档阈值算错**(本来不该进候选的记忆提前进候选)。
|
|
44
|
+
*
|
|
45
|
+
* 撞车是真实存在的:30 秒 debounce 定时器到点、与「autodream 运行前强制 flush」同时发生。
|
|
46
|
+
*/
|
|
47
|
+
const enqueueFlush = createWriteQueue()
|
|
48
|
+
|
|
36
49
|
/** 内存累计(尚未落盘):文件名 → 时间戳,同名取最新。 */
|
|
37
50
|
let pending = new Map()
|
|
38
51
|
let flushTimer = null
|
|
@@ -129,6 +142,14 @@ async function currentMemoryNames(memoryDir) {
|
|
|
129
142
|
* @param {string} [memoryDir] — 记忆根目录(引擎必须传自己的,不许拿模块常量:它可能是别的目录)
|
|
130
143
|
*/
|
|
131
144
|
export async function flushAccess(memoryDir = MEMORY_DIR) {
|
|
145
|
+
if (pending.size === 0) return { ok: true, written: 0 }
|
|
146
|
+
// 真正的读-改-写排进串行队列:两个 flush 不许同时拿着旧盘各自覆盖。
|
|
147
|
+
return enqueueFlush(() => flushAccessLocked(memoryDir))
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** 队列内的实现(**不许**再调用 `flushAccess`,那会自己等自己)。 */
|
|
151
|
+
async function flushAccessLocked(memoryDir) {
|
|
152
|
+
// 排队期间可能已经被前一个任务刷掉了。
|
|
132
153
|
if (pending.size === 0) return { ok: true, written: 0 }
|
|
133
154
|
const batch = pending
|
|
134
155
|
pending = new Map()
|
package/lib/memory/archive.js
CHANGED
|
@@ -17,11 +17,12 @@
|
|
|
17
17
|
|
|
18
18
|
import { ARCHIVE_DIR_NAME, addArchiveMeta, inspectDir, inspectMemoryEntry, localStamp, stripArchiveMeta } from './scan.js'
|
|
19
19
|
import { isReserved, nameKey, safeName } from './naming.js'
|
|
20
|
-
import { writeFileAtomic } from './util.js'
|
|
20
|
+
import { writeFileAtomic, writeFileExclusive } from './util.js'
|
|
21
21
|
import { isLocked } from './lock.js'
|
|
22
22
|
import { ensureIndexEntry, dropIndexEntry } from './index-file.js'
|
|
23
|
-
import { mkdir, readFile, stat, unlink } from 'node:fs/promises'
|
|
24
|
-
import { join } from 'node:path'
|
|
23
|
+
import { link, mkdir, readFile, rename, rm, stat, unlink } from 'node:fs/promises'
|
|
24
|
+
import { basename, join } from 'node:path'
|
|
25
|
+
import { randomUUID } from 'node:crypto'
|
|
25
26
|
|
|
26
27
|
/** 归档区绝对路径(按调用方给的记忆目录算)。 */
|
|
27
28
|
export const archiveDirOf = (memoryDir) => join(memoryDir, ARCHIVE_DIR_NAME)
|
|
@@ -82,22 +83,135 @@ export async function archiveMemory(memoryDir, name, reason, at = localStamp())
|
|
|
82
83
|
// 归档区已有同名:不覆盖 —— 那份旧归档可能是用户想留的另一版。
|
|
83
84
|
return { ok: false, code: 'exists', error: `sage-mem: already archived: ${safe}` }
|
|
84
85
|
}
|
|
85
|
-
|
|
86
|
+
/**
|
|
87
|
+
* 读**字节**而不是先转字符串:后面复核「移走的是不是我读的那一份」要逐字节比,
|
|
88
|
+
* 转成字符串再比会在非法 UTF-8 / 不同换行归一上失真。
|
|
89
|
+
*/
|
|
90
|
+
const sourceBytes = await readFile(src)
|
|
91
|
+
const content = sourceBytes.toString('utf8')
|
|
86
92
|
// 空理由给默认值:手动 remote 允许省略(工具那侧自己会先挡掉空理由,那是它自己的语义)。
|
|
87
93
|
const why = String(reason ?? '').trim() || '手动归档'
|
|
88
94
|
const stamped = addArchiveMeta(content, why, at)
|
|
89
95
|
await mkdir(archiveDirOf(memoryDir), { recursive: true })
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
96
|
+
/**
|
|
97
|
+
* 先落归档副本、再删原件:中途崩了最坏是两份都在(人工可收拾),不会一份都没有。
|
|
98
|
+
*
|
|
99
|
+
* **排他落位**(复审 R06):上面那句 `stat(dst)` 与这次写入之间是两条独立系统调用,
|
|
100
|
+
* 别人在这中间把同名归档建出来时,`writeFileAtomic` 的 rename 会直接把它盖掉。
|
|
101
|
+
* `writeFileExclusive` 把「目标不存在」变成写入本身的原子前提。
|
|
102
|
+
*/
|
|
103
|
+
try {
|
|
104
|
+
await writeFileExclusive(dst, stamped)
|
|
105
|
+
} catch (err) {
|
|
106
|
+
if (err?.code === 'EEXIST') return { ok: false, code: 'exists', error: `sage-mem: already archived: ${safe}` }
|
|
107
|
+
throw err
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* ⚠️ 收走原件这一步用 **`rename` 到私有名 + 复核内容**,不是 `unlink`(复审 R06 场景①)。
|
|
111
|
+
*
|
|
112
|
+
* 从上面那次 `inspectMemoryEntry(src)` 到现在,中间隔着读文件 + 写归档副本两次 IO;
|
|
113
|
+
* 这期间若有人(面板保存、另一个进程、模型工具)把 `a.md` 写成了新版本,
|
|
114
|
+
* 直接删就会把**从未被归档过的新版本**删掉 —— 归档件里躺着的是旧内容,而函数返回成功。
|
|
115
|
+
*
|
|
116
|
+
* 「删除前再 stat 一次」**修不了**这个问题:文件系统没有「比较再删除」这种原子动作,
|
|
117
|
+
* `unlink(路径)` 只认名字,而名字底下的 inode 在两次系统调用之间随时可能被换掉。
|
|
118
|
+
* 可用的手法是**把身份和名字解耦**:`rename` 是原子的,先把源移到一个只有本函数知道的
|
|
119
|
+
* 私有名上,再对**移走的那份实物**核对 —— 与 `autodream/gate.js`「抢占过期锁 + 复核 + 还回」
|
|
120
|
+
* 是同一套手法。
|
|
121
|
+
*
|
|
122
|
+
* ⚠️ 复核判据必须是**内容**,不能是 `dev:ino + size + mtime`(复审复核实测:改用后者
|
|
123
|
+
* 12 次里仍有 2 次被穿过)。原地截断改写 + 体积刚好相同 + 把 mtime 回写成原值
|
|
124
|
+
* (`cp -p` / `rsync -t` 这类还原工具,或 mtime 粒度粗的文件系统)会让三元组全部命中,
|
|
125
|
+
* 于是**新版本被当成旧版本收走**,顶层与归档件里都不再有它。`ino` 在拿不到 inode 的卷上
|
|
126
|
+
* 还会退化成 `null === null` 恒真,把判据进一步削弱成「体积 + 时间」。
|
|
127
|
+
* 内容比对是精确判据,而且成本可忽略(归档本来就要把这份内容读进内存)。
|
|
128
|
+
* · 与读到的字节完全相同 → 就是当初那一份,归档完成,删掉私有名;
|
|
129
|
+
* · 不同(或读不回来)→ 是别人的新版本,用 `link()` 还回原名,放弃本次归档。
|
|
130
|
+
*
|
|
131
|
+
* 崩在中途的代价说清楚:私有名文件会留在记忆目录里(不是 `.md`,扫描与注入都不认它),
|
|
132
|
+
* 而归档副本已经写好 —— 不会出现「一份都没有」。
|
|
133
|
+
*/
|
|
134
|
+
const taken = `${src}.archiving-${randomUUID().replace(/-/g, '')}`
|
|
135
|
+
const moveErr = await rename(src, taken).then(
|
|
136
|
+
() => null,
|
|
137
|
+
(err) => err,
|
|
138
|
+
)
|
|
139
|
+
if (moveErr) {
|
|
140
|
+
/**
|
|
141
|
+
* ⚠️ `rename` 失败**不等于**「源不见了」(复审复核实测,真机 Windows):
|
|
142
|
+
* 另一进程只读占用(编辑器 / 杀软 / 同步工具)时 `rename` 报 **EBUSY** 而不是 ENOENT,
|
|
143
|
+
* 而文件明明还在、内容也读得出来。原先一律报 `not-found`,工具层再翻成
|
|
144
|
+
* 「错误:没有这个文件」—— 诊断是错的,人会去别处找。按真实错误码分流。
|
|
145
|
+
*/
|
|
146
|
+
await rm(dst, { force: true }).catch(() => {})
|
|
147
|
+
if (moveErr?.code === 'ENOENT') {
|
|
148
|
+
return { ok: false, code: 'not-found', error: `sage-mem: file not found: ${safe}(归档途中源文件消失了)` }
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
ok: false,
|
|
152
|
+
code: 'move-failed',
|
|
153
|
+
error: `sage-mem: 归档 ${safe} 时移不动它(${moveErr?.code ?? moveErr}:${moveErr?.message ?? moveErr})。文件仍在原处、一个字节没动,本次归档未完成 —— 可稍后重试(若是被其它程序占用,关掉它再来)。`,
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const currentBytes = await readFile(taken).catch(() => null)
|
|
157
|
+
const sameOne = currentBytes !== null && Buffer.compare(currentBytes, sourceBytes) === 0
|
|
158
|
+
if (!sameOne) {
|
|
159
|
+
// 移走的是**别人**的新版本:还回去,一个字节都不动。
|
|
160
|
+
const restored = await link(taken, src).then(
|
|
161
|
+
() => true,
|
|
162
|
+
() => false,
|
|
163
|
+
)
|
|
164
|
+
await rm(dst, { force: true }).catch(() => {})
|
|
165
|
+
if (!restored) {
|
|
166
|
+
/**
|
|
167
|
+
* ⚠️ 归还失败时**绝不能删 `taken`**:它是这份新版本**唯一**的落身处
|
|
168
|
+
* (原名此刻已被别人占着,link 才报的 EEXIST)。删掉它就是真的丢数据。
|
|
169
|
+
* 这里把它原样留下,并把路径如实写进错误里让人去捞 —— 名字带 `.archiving-`
|
|
170
|
+
* 后缀,不是 `.md`,扫描 / 注入 / 星图都不会把它当成一条记忆。
|
|
171
|
+
*/
|
|
172
|
+
return {
|
|
173
|
+
ok: false,
|
|
174
|
+
code: 'changed',
|
|
175
|
+
error: `sage-mem: ${safe} 在归档过程中被改写,已放弃本次归档;归还时原名已被占用,**内容仍保留在 ${basename(taken)}**,请手工改回 ${safe}`,
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
await rm(taken, { force: true }).catch(() => {})
|
|
179
|
+
return {
|
|
180
|
+
ok: false,
|
|
181
|
+
code: 'changed',
|
|
182
|
+
error: `sage-mem: ${safe} 在归档过程中被改写,已放弃本次归档(新版本保留在原处,请重试)`,
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
await rm(taken, { force: true }).catch(() => {})
|
|
93
186
|
/**
|
|
94
187
|
* **从索引里摘掉**(与恢复时补索引对称):索引是「活动记忆的清单」,
|
|
95
188
|
* 记忆归档了就该从清单里退出 —— 不摘的话审计会报「索引悬空」。
|
|
96
189
|
* 2026-10-11 在真实库里撞到过:归档两条,体检立刻从 0 问题变 2 问题。
|
|
97
190
|
* 摘失败**不算归档失败**:文件已经移走了,那才是主要结果。
|
|
191
|
+
*
|
|
192
|
+
* 但**失败要说出去**(复审 R11 的配套):读不动索引时旧写法会把「读失败」折成空串、
|
|
193
|
+
* 顺手把用户的索引整体重写;现在改成不写,同时把理由带在返回值里。
|
|
194
|
+
* **抛异常那条路也要接住**(复审复核点出):`dropIndexEntry` 写索引时抛出的
|
|
195
|
+
* EIO / 占用 / 权限原先会被 `.catch(() => null)` 吞成 null,于是「索引里那行还在、
|
|
196
|
+
* 却报成功」无人知晓。读失败与写失败在这里对称上报。
|
|
98
197
|
*/
|
|
99
|
-
|
|
100
|
-
|
|
198
|
+
let unindexedRemoved = false
|
|
199
|
+
let indexError = ''
|
|
200
|
+
try {
|
|
201
|
+
const unindexed = await dropIndexEntry(memoryDir, safe)
|
|
202
|
+
unindexedRemoved = !!(unindexed && unindexed.removed)
|
|
203
|
+
if (unindexed && unindexed.ok === false) indexError = unindexed.error ?? '索引没摘掉'
|
|
204
|
+
} catch (err) {
|
|
205
|
+
indexError = `摘索引失败(${err?.code ?? err}):${err?.message ?? err}`
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
ok: true,
|
|
209
|
+
file: safe,
|
|
210
|
+
archivedReason: why,
|
|
211
|
+
// `unindexed` 的原语义不变:**确实摘掉了一行**(不是「索引没出错」)。
|
|
212
|
+
unindexed: unindexedRemoved,
|
|
213
|
+
...(indexError ? { indexError } : {}),
|
|
214
|
+
}
|
|
101
215
|
}
|
|
102
216
|
|
|
103
217
|
/**
|
|
@@ -132,7 +246,21 @@ export async function restoreMemory(memoryDir, name) {
|
|
|
132
246
|
return { ok: false, code: dstGate.code, error: `sage-mem: refusing to restore over ${safe}: ${dstGate.error}` }
|
|
133
247
|
}
|
|
134
248
|
const content = await readFile(src, 'utf8')
|
|
135
|
-
|
|
249
|
+
/**
|
|
250
|
+
* **排他落位**(复审 R06 场景②):上面那句 `dstGate` 检查通过 ≠ 目标此刻仍不存在 ——
|
|
251
|
+
* 检查与写入之间,用户完全可能在顶层新建一条同名记忆(实测用 IPC 固定交错即可复现)。
|
|
252
|
+
* 旧写法 `writeFileAtomic` 的 rename 会**直接盖掉**它,而函数报成功、归档源也删了,
|
|
253
|
+
* 于是「新写的那版」在两个位置都不存在。`writeFileExclusive` 把「目标必须不存在」
|
|
254
|
+
* 变成写入本身的原子前提:目标冒出来就 EEXIST,一个字节都不动。
|
|
255
|
+
*/
|
|
256
|
+
try {
|
|
257
|
+
await writeFileExclusive(dst, stripArchiveMeta(content))
|
|
258
|
+
} catch (err) {
|
|
259
|
+
if (err?.code === 'EEXIST') {
|
|
260
|
+
return { ok: false, code: 'target-exists', error: `sage-mem: already exists, refusing to overwrite: ${safe}` }
|
|
261
|
+
}
|
|
262
|
+
throw err
|
|
263
|
+
}
|
|
136
264
|
await unlink(src)
|
|
137
265
|
/**
|
|
138
266
|
* **注册回索引**:移回文件不等于这条记忆又存在了 —— 索引里没有它,
|
|
@@ -140,7 +268,25 @@ export async function restoreMemory(memoryDir, name) {
|
|
|
140
268
|
*
|
|
141
269
|
* 幂等(索引里已有指向它的一行就什么都不做);索引缺失或写不进去**不算恢复失败** ——
|
|
142
270
|
* 文件已经回到顶层了,那才是主要结果,索引只是补账。
|
|
271
|
+
*
|
|
272
|
+
* 索引**读不动**时(复审 R11)旧写法会当成「索引是空的」然后整体重写 —— 用户手写的
|
|
273
|
+
* 标题、段落、其它条目一起没了。现在这种情况一个字都不写,只把理由带回去;
|
|
274
|
+
* 写索引时抛出的异常同样接住(与归档那侧对称)。
|
|
143
275
|
*/
|
|
144
|
-
|
|
145
|
-
|
|
276
|
+
let indexedAdded = false
|
|
277
|
+
let indexError = ''
|
|
278
|
+
try {
|
|
279
|
+
const indexed = await ensureIndexEntry(memoryDir, safe)
|
|
280
|
+
indexedAdded = !!(indexed && indexed.added)
|
|
281
|
+
if (indexed && indexed.ok === false) indexError = indexed.error ?? '索引没补上'
|
|
282
|
+
} catch (err) {
|
|
283
|
+
indexError = `补索引失败(${err?.code ?? err}):${err?.message ?? err}`
|
|
284
|
+
}
|
|
285
|
+
return {
|
|
286
|
+
ok: true,
|
|
287
|
+
file: safe,
|
|
288
|
+
// `indexed` 的原语义不变:**确实补上了一行**。
|
|
289
|
+
indexed: indexedAdded,
|
|
290
|
+
...(indexError ? { indexError } : {}),
|
|
291
|
+
}
|
|
146
292
|
}
|
package/lib/memory/index-file.js
CHANGED
|
@@ -12,12 +12,43 @@ import { readFile, readdir } from 'node:fs/promises'
|
|
|
12
12
|
import { basename, join } from 'node:path'
|
|
13
13
|
import { parseFrontmatter } from './frontmatter.js'
|
|
14
14
|
import { nameKey } from './naming.js'
|
|
15
|
-
import { writeFileAtomic } from './util.js'
|
|
15
|
+
import { createWriteQueue, writeFileAtomic } from './util.js'
|
|
16
16
|
|
|
17
17
|
const INDEX_KEY = 'memory.md'
|
|
18
18
|
/** 条目里的描述上限:索引是清单,不是内容堆。 */
|
|
19
19
|
const DESC_MAX = 120
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* 索引写的**进程内串行队列**(实现在 `util.js`,与访问台账共用同一份实现)。
|
|
23
|
+
*
|
|
24
|
+
* 为什么要它(2026-10-12 复审 R07):两个归档同时跑时,两边都先读整份索引、
|
|
25
|
+
* 各自算「摘掉我这条之后剩下的行」、再各自整体覆盖 —— 后写的那个把先写的成果盖回去,
|
|
26
|
+
* 于是索引里留下一条指向「已不在顶层」的悬空条目,而两次调用都报 `ok:true`。
|
|
27
|
+
* 实测两个 `archiveMemory` 并发(`Promise.all`)就能稳定看到。
|
|
28
|
+
*
|
|
29
|
+
* 串行只包**读-改-写**那一段,不包调用方(归档/恢复)的整个流程,避免把文件搬移也串起来。
|
|
30
|
+
* 边界(进程内、不许嵌套)写在 `createWriteQueue` 的注释里。
|
|
31
|
+
*/
|
|
32
|
+
const withIndexWrite = createWriteQueue()
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 读索引正文。**读失败绝不等于「索引是空的」**(2026-10-12 复审 R11)。
|
|
36
|
+
*
|
|
37
|
+
* 旧写法 `.catch(() => '')` 把 EIO / EACCES / 被占用一律折叠成空字符串,而调用方
|
|
38
|
+
* 拿到空串后会走「索引里没有这一行 → 整体重写」这条路 —— 用户手写的标题、段落、
|
|
39
|
+
* 其它条目**一起被覆盖掉**,而函数报的是成功。一次瞬时读失败就吃掉用户的手工维护成果。
|
|
40
|
+
*
|
|
41
|
+
* 判据收紧成:读得到就返回正文;读不到就**明确上报失败、一个字节都不写**。
|
|
42
|
+
* 「磁盘上确实没有索引」不走这条 —— 那由 `findIndexFile` 判(名字找不到就直接返回)。
|
|
43
|
+
*/
|
|
44
|
+
async function readIndexText(indexPath) {
|
|
45
|
+
try {
|
|
46
|
+
return { ok: true, text: await readFile(indexPath, 'utf8') }
|
|
47
|
+
} catch (err) {
|
|
48
|
+
return { ok: false, error: `读不了索引文件(${err?.code ?? err}):${err?.message ?? err}` }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
21
52
|
/**
|
|
22
53
|
* 索引文件的**磁盘真名**(大小写不敏感地找)。
|
|
23
54
|
* 不假设盘上是 `MEMORY.md` —— 系统盘是 Windows、用户可能就写成了小写。
|
|
@@ -42,11 +73,18 @@ function hasEntry(indexText, name) {
|
|
|
42
73
|
* @returns {Promise<{ok:boolean, added:boolean, file?:string, line?:string, error?:string}>}
|
|
43
74
|
*/
|
|
44
75
|
export async function ensureIndexEntry(memoryDir, name) {
|
|
76
|
+
return withIndexWrite(() => ensureIndexEntryLocked(memoryDir, name))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function ensureIndexEntryLocked(memoryDir, name) {
|
|
45
80
|
const indexName = await findIndexFile(memoryDir)
|
|
46
81
|
// 没有索引是**正常状态**(索引是可选件):不崩、不建、不当失败。
|
|
47
82
|
if (!indexName) return { ok: true, added: false, error: 'no-index' }
|
|
48
83
|
const indexPath = join(memoryDir, indexName)
|
|
49
|
-
|
|
84
|
+
// 读失败 → 不写(R11)。宁可这次不补索引,也不能拿一份「读出来的空索引」覆盖用户的正文。
|
|
85
|
+
const read = await readIndexText(indexPath)
|
|
86
|
+
if (!read.ok) return { ok: false, added: false, error: read.error }
|
|
87
|
+
const indexText = read.text
|
|
50
88
|
if (hasEntry(indexText, name)) return { ok: true, added: false }
|
|
51
89
|
|
|
52
90
|
const content = await readFile(join(memoryDir, name), 'utf8').catch(() => '')
|
|
@@ -75,11 +113,19 @@ export async function ensureIndexEntry(memoryDir, name) {
|
|
|
75
113
|
* @returns {Promise<{ok:boolean, removed:boolean, error?:string}>}
|
|
76
114
|
*/
|
|
77
115
|
export async function dropIndexEntry(memoryDir, name) {
|
|
116
|
+
return withIndexWrite(() => dropIndexEntryLocked(memoryDir, name))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function dropIndexEntryLocked(memoryDir, name) {
|
|
78
120
|
const indexName = await findIndexFile(memoryDir)
|
|
79
121
|
// 没有索引是正常状态:归档照常进行,只是没有清单要维护。
|
|
80
122
|
if (!indexName) return { ok: true, removed: false, error: 'no-index' }
|
|
81
123
|
const indexPath = join(memoryDir, indexName)
|
|
82
|
-
|
|
124
|
+
// 读失败 → 不写(R11):摘不掉一行只是"索引暂时不准",而拿空串当基线整体重写
|
|
125
|
+
// 会把用户手写的整份索引抹平 —— 两者不是一个量级。
|
|
126
|
+
const read = await readIndexText(indexPath)
|
|
127
|
+
if (!read.ok) return { ok: false, removed: false, error: read.error }
|
|
128
|
+
const indexText = read.text
|
|
83
129
|
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
84
130
|
const lineRe = new RegExp(`\\]\\(\\s*${escaped}(?:#[^)]*)?\\s*\\)`, 'i')
|
|
85
131
|
const lines = indexText.split('\n')
|
package/lib/memory/util.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* sage-mem 记忆层 —— 通用小工具:统一日志出口与原子写。
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { rename, unlink, writeFile } from 'node:fs/promises'
|
|
5
|
+
import { link, open, rename, unlink, writeFile } from 'node:fs/promises'
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* 统一告警出口。所有非 ENOENT 的失败都必须落到日志,绝不静默
|
|
@@ -65,3 +65,106 @@ export async function writeFileAtomic(target, content) {
|
|
|
65
65
|
await unlink(tmp).catch(() => {})
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
|
+
|
|
69
|
+
/** 硬链接不被支持的失败码(部分网络盘 / exFAT / 某些 FUSE)。 */
|
|
70
|
+
const LINK_UNSUPPORTED_CODES = new Set(['EPERM', 'EACCES', 'ENOTSUP', 'EOPNOTSUPP', 'EINVAL', 'EXDEV'])
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* **排他**落位:目标已存在时绝不覆盖,报 `EEXIST` 让调用方自己决定。
|
|
74
|
+
*
|
|
75
|
+
* 为什么要它(2026-10-12 复审 R06):归档 / 恢复都是「先检查目标在不在 → 再写」,
|
|
76
|
+
* 两步之间别人完全可能把目标建出来 —— `writeFileAtomic` 的 rename 会**直接盖掉**它。
|
|
77
|
+
* 恢复那条路上,被盖掉的正是用户刚落笔的活动记忆。
|
|
78
|
+
*
|
|
79
|
+
* 实现用 `writeFile(tmp)` + `link(tmp, target)`:`link` 在目标存在时原子地报 `EEXIST`,
|
|
80
|
+
* 不覆盖、没有中间态 —— 与 `autodream/gate.js` 里「还回锁」用的是同一套手法。
|
|
81
|
+
*
|
|
82
|
+
* ⚠️ 平台不支持硬链接时退回 **`open(target, 'wx')` 排他创建**(`exclusive:false`):
|
|
83
|
+
* 少数网络盘 / exFAT 上 link 会 EPERM/ENOTSUP,但 `open` 的 `wx` 排除标志在所有平台都可用,
|
|
84
|
+
* 所以「目标已存在就绝不覆盖」这条**在降级路径上依然成立**。
|
|
85
|
+
* 代价只剩一条:内容不是「一次性原子写入」(崩在中途会留下半截**新**文件),
|
|
86
|
+
* 而它绝不会破坏已存在的那份数据 —— 与「把用户的活记忆盖掉」不是一个量级。
|
|
87
|
+
*
|
|
88
|
+
* (复审复核点出过一版更差的降级:退回 `rename` 覆盖写。那等于把 R06② 原样放回来,
|
|
89
|
+
* 而且静默 —— 谁都没发现自己在网盘上失去了这层保护。别退回去。)
|
|
90
|
+
*
|
|
91
|
+
* 返回值 `exclusive` 是**诊断用**的:调用方不据此改变成功 / 失败的判定(降级不是错误),
|
|
92
|
+
* 但断言与排错需要知道本次究竟有没有拿到排他性 —— `test/race-guards-test.mjs` 就会检查
|
|
93
|
+
* 本机走的是哪条路,并单独验降级路径仍然排他。
|
|
94
|
+
*
|
|
95
|
+
* @returns {Promise<{exclusive:boolean}>} `exclusive:false` = 走的是 `open('wx')` 降级路径
|
|
96
|
+
*/
|
|
97
|
+
export async function writeFileExclusive(target, content) {
|
|
98
|
+
tmpSeq += 1
|
|
99
|
+
const uniq = `${process.pid}-${Date.now().toString(36)}-${tmpSeq.toString(36)}-${Math.random().toString(36).slice(2, 8)}`
|
|
100
|
+
const tmp = `${target}.${uniq}.tmp`
|
|
101
|
+
let exclusive = true
|
|
102
|
+
try {
|
|
103
|
+
await writeFile(tmp, content, 'utf8')
|
|
104
|
+
try {
|
|
105
|
+
await link(tmp, target)
|
|
106
|
+
} catch (err) {
|
|
107
|
+
// 目标已存在:这是**预期**的拒绝,原样抛给调用方(它负责翻译成业务错误码)。
|
|
108
|
+
if (err?.code === 'EEXIST') throw err
|
|
109
|
+
if (!LINK_UNSUPPORTED_CODES.has(err?.code)) throw err
|
|
110
|
+
// 降级:`wx` 同样「已存在就 EEXIST」,只是内容分多次写。
|
|
111
|
+
const fh = await open(target, 'wx')
|
|
112
|
+
try {
|
|
113
|
+
await fh.writeFile(content, 'utf8')
|
|
114
|
+
} finally {
|
|
115
|
+
await fh.close().catch(() => {})
|
|
116
|
+
}
|
|
117
|
+
exclusive = false
|
|
118
|
+
}
|
|
119
|
+
} finally {
|
|
120
|
+
// link 成功后 target 与 tmp 是同一 inode,删 tmp 不影响 target;
|
|
121
|
+
// 降级路径与失败路径下 tmp 都还是多余的那份,清掉。
|
|
122
|
+
await unlink(tmp).catch(() => {})
|
|
123
|
+
}
|
|
124
|
+
return { exclusive }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 造一个**进程内串行队列**:同一个队列上的任务一个接一个跑,绝不并发。
|
|
129
|
+
*
|
|
130
|
+
* 给「读整份文件 → 改 → 整体覆盖」这类操作兜底。两个这样的操作并发时,后写的那个
|
|
131
|
+
* 会把先写的成果盖回去,而两边都报成功 —— 已经踩过两次:
|
|
132
|
+
* · 索引 `MEMORY.md`(复审 R07:两条并发归档各摘一行,最后总留一条悬空)
|
|
133
|
+
* · 访问台账 `access.json`(复审复核点出:debounce 定时器与运行前强刷撞上时丢时间戳)
|
|
134
|
+
*
|
|
135
|
+
* ⚠️ 边界:**进程内**串行,不是跨进程锁。真实并发(界面 remote、模型工具、归档策略、
|
|
136
|
+
* 定时器)都在同一个插件进程里,所以覆盖得住;同时开两个 DSH 实例写同一个记忆目录时
|
|
137
|
+
* 仍可能丢一次 —— 那要另加文件锁,复杂度与误伤面都不划算,这里明确不做。
|
|
138
|
+
*
|
|
139
|
+
* ⚠️ **队内函数不许再调同一个队列上的函数**(会自己等自己,永久挂住)。
|
|
140
|
+
*
|
|
141
|
+
* @returns {(fn:()=>Promise<any>)=>Promise<any>}
|
|
142
|
+
*/
|
|
143
|
+
export function createWriteQueue() {
|
|
144
|
+
let tail = Promise.resolve()
|
|
145
|
+
return function enqueue(fn) {
|
|
146
|
+
// 显式包一层:前一次失败的 reason 不该被当成参数传进 fn(fn 不接参数)。
|
|
147
|
+
const run = tail.then(
|
|
148
|
+
() => fn(),
|
|
149
|
+
() => fn(),
|
|
150
|
+
)
|
|
151
|
+
// 队尾吞掉结果与异常:一次失败不该毒化后面所有排队的任务。
|
|
152
|
+
tail = run.then(
|
|
153
|
+
() => {},
|
|
154
|
+
() => {},
|
|
155
|
+
)
|
|
156
|
+
return run
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* ⚠️ 回滚写回请用 **`writeFileAtomic`**,不要用 `copyFile(src, dst)`。
|
|
162
|
+
*
|
|
163
|
+
* 差别只有一个,但那一个是关键:`writeFileAtomic` 是「写新文件 + rename 替换**目录项**」,
|
|
164
|
+
* 而 `copyFile` 是**打开目标、往它已有的 inode 里灌内容**。后者的后果
|
|
165
|
+
* (2026-10-12 复审 R02,两类链接都实测写穿):
|
|
166
|
+
* - 目标有硬链接(`cp -al` / `rsync --link-dest` 做的快照备份)→ 记忆目录外的备份**跟着一起变**
|
|
167
|
+
* - 目标是符号链接 → 内容穿过链接落到目录外的真实文件上
|
|
168
|
+
* rename 之后:硬链接关系自然解除(旧 inode 的那份备份保持原样),符号链接被**替换成本身**
|
|
169
|
+
* 而不是被穿透 —— 这正是「把记忆目录恢复成快照那一版」该有的语义。
|
|
170
|
+
*/
|
package/lib/typert.host.js
CHANGED
|
@@ -8,7 +8,12 @@ import { z } from 'zod'
|
|
|
8
8
|
|
|
9
9
|
// 文件名:与 host 侧 safeName 同一套底线 —— 非空、无路径分隔符与 Windows 非法字符、
|
|
10
10
|
// 以 .md 结尾、长度有界。(真正的目录逃逸防护仍在 host 侧 safeName。)
|
|
11
|
-
|
|
11
|
+
//
|
|
12
|
+
// ⚠️ 结尾的 `i` **不能去掉**:`safeName`(`memory/naming.js`)判扩展名用的是 `/\.md$/i`,
|
|
13
|
+
// 而文件名闸门原先**没有** `i`,两层判据宽度不一致 —— 宿主侧认可的 `a.MD`
|
|
14
|
+
// 会被传输入参 schema 挡在门外(读不了自己列表里列出来的文件)。大小写不敏感是
|
|
15
|
+
// NTFS / APFS 的事实,判据必须与文件系统同宽(与 `nameKey` 归一同一原则)。
|
|
16
|
+
const fileNameSchema = z.string().min(1).max(255).regex(/^[^\\/:*?"<>|\u0000-\u001f]+\.md$/i)
|
|
12
17
|
// 正文:512 KB 上限。理由是「记忆要塞进 system prompt」——单条正常量级在几十 KB,
|
|
13
18
|
// 到半兆量级基本是写错了目标(贴了日志/代码进来)。与 lib/index.js 的 MAX_FILE_BYTES 一致。
|
|
14
19
|
const fileContentSchema = z.string().max(512 * 1024)
|
|
@@ -94,13 +99,23 @@ const archiveReasonSchema = z.string().max(1000).optional()
|
|
|
94
99
|
// starmap.listStars 的可选开关:不看归档星时行为与从前完全一致。
|
|
95
100
|
const includeArchivedSchema = z.boolean().optional()
|
|
96
101
|
|
|
97
|
-
// 归档 / 恢复的结果:成功 `{ ok: true, file
|
|
98
|
-
//
|
|
99
|
-
// 是必填(writeFile 连失败都带着归一后的文件名),
|
|
102
|
+
// 归档 / 恢复的结果:成功 `{ ok: true, file, unindexed|indexed, indexError? }`,
|
|
103
|
+
// 失败 `{ ok: false, error }` —— 失败分支**没有 file**(名字本身可能就是非法的)。
|
|
104
|
+
// 所以不能复用 writeResultSchema:那边 `file` 是必填(writeFile 连失败都带着归一后的文件名),
|
|
105
|
+
// strict 校验会把失败分支判成坏结果。
|
|
106
|
+
//
|
|
107
|
+
// ⚠️ `unindexed` / `indexed` / `indexError` 必须在这里声明(2026-10-12 复审 R11 的配套):
|
|
108
|
+
// strict codec 会把没声明的字段**整个剥掉**,于是「索引没摘掉 / 没补上」这件事在界面路径上
|
|
109
|
+
// 永远不可见 —— 与 R01 漏掉四个配置字段是同一类错误,换了个字段名而已。
|
|
110
|
+
// `archivedReason` 同理:面板要显示「它为什么被收起来」。
|
|
100
111
|
const archiveResultSchema = z.object({
|
|
101
112
|
'ok': z.boolean().readonly(),
|
|
102
113
|
'file': z.string().readonly().optional(),
|
|
103
114
|
'error': z.string().readonly().optional(),
|
|
115
|
+
'archivedReason': z.string().readonly().optional(),
|
|
116
|
+
'unindexed': z.boolean().readonly().optional(),
|
|
117
|
+
'indexed': z.boolean().readonly().optional(),
|
|
118
|
+
'indexError': z.string().readonly().optional(),
|
|
104
119
|
}).readonly()
|
|
105
120
|
|
|
106
121
|
// 归档文件的读 / 写(v0.9.0 B1b):归档不是删除,正文要能看能改。三个类型名与活跃记忆
|
|
@@ -155,6 +170,9 @@ const auditReportSchema = z.object({
|
|
|
155
170
|
'hashHazards': z.array(auditHazardSchema).readonly(),
|
|
156
171
|
'noFrontmatter': z.array(auditItemSchema).readonly(),
|
|
157
172
|
'unreadable': z.array(auditItemSchema).readonly(),
|
|
173
|
+
// 归档/恢复中途崩溃留下的私有名残留(`*.md.archiving-<hex>`)。它可能装着用户唯一
|
|
174
|
+
// 一份内容,所以体检必须报出来 —— strict 不声明它就等于界面上永远看不见。
|
|
175
|
+
'strays': z.array(auditItemSchema).readonly(),
|
|
158
176
|
'problems': z.number().readonly(),
|
|
159
177
|
}).readonly()
|
|
160
178
|
|
|
@@ -251,6 +269,22 @@ const autodreamConfigSchema = z.object({
|
|
|
251
269
|
'maxSteps': z.number().readonly(),
|
|
252
270
|
'maxSnapshotKeep': z.number().readonly(),
|
|
253
271
|
'rollbackScope': z.string().readonly(),
|
|
272
|
+
/**
|
|
273
|
+
* ⚠️ 这一组必须留在这里(2026-10-12 补)。
|
|
274
|
+
*
|
|
275
|
+
* 入参 schema(`autodreamPatchSchema`)补了这四个字段只解决了「写得进去」;
|
|
276
|
+
* **响应**走的还是本 schema,strict 会把未声明的字段剥掉 —— 于是界面
|
|
277
|
+
* 「保存 → 回读 → 比对」这套自检永远拿到 `undefined`:`autoArchive` 被当成默认的
|
|
278
|
+
* `report`,三个阈值被当成「仍是旧值」,改对了也报「保存没生效」。
|
|
279
|
+
*
|
|
280
|
+
* 契约是**双向**的:能写进去的字段,返回里也必须看得见。
|
|
281
|
+
* `test/race-guards-test.mjs` 第 1 组拿 `engine.getConfig().config` 的**全部键**
|
|
282
|
+
* 对这份 schema 做覆盖断言 —— 以后再加配置项、漏了这里,会立刻红。
|
|
283
|
+
*/
|
|
284
|
+
'autoArchive': z.string().readonly(),
|
|
285
|
+
'archiveAfterDaysProject': z.number().readonly(),
|
|
286
|
+
'archiveAfterDaysReference': z.number().readonly(),
|
|
287
|
+
'archiveAfterDaysUser': z.number().readonly(),
|
|
254
288
|
}).readonly()
|
|
255
289
|
|
|
256
290
|
const autodreamPathsSchema = z.object({
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sage-mem",
|
|
3
3
|
"description": "File-based memory plugin for DeepSeek Harness (DSH) — cross-session memory in plain markdown files, with a memory star-map view, type/tag filtering, retrieval aliases, and an optional autodream pass that consolidates memories on demand or on a schedule — with per-run rollback points, a per-file change declaration (what changed and why), and a separately selectable model for the consolidation pass",
|
|
4
|
-
"version": "0.9.
|
|
4
|
+
"version": "0.9.13",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|
|
@@ -15,6 +15,11 @@
|
|
|
15
15
|
"cordis.patch.yml",
|
|
16
16
|
"NOTICE"
|
|
17
17
|
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"test": "node test/run.mjs",
|
|
20
|
+
"build:client": "node scripts/build-client.mjs",
|
|
21
|
+
"images": "node scripts/make-readme-images.mjs"
|
|
22
|
+
},
|
|
18
23
|
"license": "Apache-2.0",
|
|
19
24
|
"engines": {
|
|
20
25
|
"node": ">=18",
|
|
@@ -85,10 +90,5 @@
|
|
|
85
90
|
"ai-agent",
|
|
86
91
|
"agent",
|
|
87
92
|
"claude-code"
|
|
88
|
-
]
|
|
89
|
-
|
|
90
|
-
"test": "node test/run.mjs",
|
|
91
|
-
"build:client": "node scripts/build-client.mjs",
|
|
92
|
-
"images": "node scripts/make-readme-images.mjs"
|
|
93
|
-
}
|
|
94
|
-
}
|
|
93
|
+
]
|
|
94
|
+
}
|