sage-mem 0.9.2 → 0.9.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/lib/autodream/engine.js +2 -0
- package/lib/autodream/rollback.js +133 -61
- package/lib/autodream/settings.js +6 -9
- package/lib/autodream/snapshot.js +132 -1
- package/lib/autodream/util.js +4 -1
- package/lib/autodream-audit.js +19 -1
- package/lib/autodream-tools.js +35 -8
- package/lib/index.js +81 -11
- package/lib/memory/archive.js +37 -6
- package/lib/memory/naming.js +5 -0
- package/lib/memory/scan.js +178 -11
- package/lib/memory/util.js +33 -3
- package/package.json +8 -8
package/README.md
CHANGED
|
@@ -156,7 +156,7 @@ SAGE_MEM_DIR=/path/to/your/memory
|
|
|
156
156
|
- **`baseline: true` 的记忆每场会话第一步无条件注入**,最多 8 条——给「你是谁」「待办清单」这类每次开场都该在场的记忆用。多条 baseline 之间的先后由 `baselinePriority` 决定(数字越大越前,缺省 0),**不按文件名排**
|
|
157
157
|
- 记忆目录读不到、权限错、frontmatter 格式坏,这些都**降级为不注入并写日志**,不会静默失效
|
|
158
158
|
|
|
159
|
-
单文件上限 512 KB
|
|
159
|
+
单文件上限 512 KB,**读写两侧都校验**(读的时候先看体积,超了就不读进来);标签最多 20 个、每个 40 字符(再多就不是标签,是一句话被塞进 `tags` 了)。
|
|
160
160
|
|
|
161
161
|
## 自动做梦(autodream)
|
|
162
162
|
|
|
@@ -261,7 +261,7 @@ sage-mem 只做三件事,全都在你本机:
|
|
|
261
261
|
|
|
262
262
|
| 它做什么 | 具体范围 |
|
|
263
263
|
|---|---|
|
|
264
|
-
| **读记忆** | 只读 `SAGE_MEM_DIR`(默认 `~/.sage-mem/memory`)下的顶层 `*.md`,单文件上限 512 KB |
|
|
264
|
+
| **读记忆** | 只读 `SAGE_MEM_DIR`(默认 `~/.sage-mem/memory`)下的顶层 `*.md`,单文件上限 512 KB(读写两侧都查) |
|
|
265
265
|
| **写记忆** | 经 agent 的文件工具或设置页写同一个目录;`MEMORY.md`、`session-log.md` 等保留名受保护,设置页碰不到 |
|
|
266
266
|
| **注入** | 把选中的记忆作为上下文交给模型(最多 10 条相关 + 8 条 `baseline`,单场会话累计上限 60 KB) |
|
|
267
267
|
|
package/lib/autodream/engine.js
CHANGED
|
@@ -120,6 +120,7 @@ export class AutodreamEngine {
|
|
|
120
120
|
async snapshot(id, kind = 'run') { return snapshotMod.snapshot.call(this, id, kind) }
|
|
121
121
|
async pruneSnapshots() { return snapshotMod.pruneSnapshots.call(this) }
|
|
122
122
|
async resolveSnapshotDir(id) { return snapshotMod.resolveSnapshotDir.call(this, id) }
|
|
123
|
+
async verifySnapshot(dir, opts) { return snapshotMod.verifySnapshot.call(this, dir, opts) }
|
|
123
124
|
async listSnapshots() { return snapshotMod.listSnapshots.call(this) }
|
|
124
125
|
|
|
125
126
|
// ── LLM 循环:实现在 lib/autodream/llm-loop.js ──
|
|
@@ -237,6 +238,7 @@ export class AutodreamEngine {
|
|
|
237
238
|
sessionsRoot: this.sessionsRoot,
|
|
238
239
|
apply: !!cfg.apply,
|
|
239
240
|
withSessions,
|
|
241
|
+
ctx: this.ctx,
|
|
240
242
|
})
|
|
241
243
|
const toolSchemas = autodreamToolSchemas({ apply: !!cfg.apply, withSessions })
|
|
242
244
|
|
|
@@ -9,9 +9,11 @@
|
|
|
9
9
|
import { REPORT_DIR } from './config.js'
|
|
10
10
|
import { readJson, safeName, snapshotEntries, stampCompact, stampHuman } from './util.js'
|
|
11
11
|
import { restoreMemory } from '../memory/archive.js'
|
|
12
|
+
import { nameKey } from '../memory/naming.js'
|
|
13
|
+
import { stripArchiveMeta } from '../memory/scan.js'
|
|
12
14
|
import { randomUUID } from 'node:crypto'
|
|
13
|
-
import { copyFile, mkdir, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'
|
|
14
|
-
import { join } from 'node:path'
|
|
15
|
+
import { copyFile, mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'
|
|
16
|
+
import { dirname, join } from 'node:path'
|
|
15
17
|
|
|
16
18
|
/** 读回滚留痕(面板也许要显示「最近谁退回过」)。 */
|
|
17
19
|
export async function listRollbacks(limit = 20) {
|
|
@@ -80,6 +82,26 @@ export async function rollback(opts = {}) {
|
|
|
80
82
|
if (!runManifest) return { ok: false, error: `找不到回滚点 ${snapshotId} 对应的运行记录,只能选「整目录恢复」` }
|
|
81
83
|
}
|
|
82
84
|
|
|
85
|
+
/**
|
|
86
|
+
* ①-a 校验回滚点内容。
|
|
87
|
+
*
|
|
88
|
+
* 顺序在保护快照**之前**:判据不成立时连快照都不该建 —— 拒绝要干净,不留任何副作用。
|
|
89
|
+
* 校验失败一律拒绝整趟,不做「只恢复没坏的那几个」的部分回滚:部分回滚会把记忆目录
|
|
90
|
+
* 变成「半新半旧」的第三种状态,比不回滚更难收拾,而记忆目录没有版本控制兜底。
|
|
91
|
+
*/
|
|
92
|
+
const verdict = await this.verifySnapshot(snapDir, { legacy: dirname(snapDir) === this.legacySnapshotRoot })
|
|
93
|
+
if (!verdict.ok) {
|
|
94
|
+
this.log('warn', `回滚中止:${verdict.reason}`)
|
|
95
|
+
this.phase = ''
|
|
96
|
+
return {
|
|
97
|
+
ok: false,
|
|
98
|
+
error:
|
|
99
|
+
`${verdict.reason}。这份回滚点已不能代表「当时那一版」,拿它覆盖记忆目录风险太大,` +
|
|
100
|
+
'这次回滚已中止,记忆目录一个字没动。请换一份回滚点。',
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (verdict.schema === 'unhashed') this.log('warn', `回滚:${verdict.reason}`)
|
|
104
|
+
|
|
83
105
|
// ① 保护快照
|
|
84
106
|
this.phase = '回滚前保护快照'
|
|
85
107
|
// 带随机后缀:秒级时间戳会撞(同一秒内连点两次回滚是真实用法),
|
|
@@ -118,27 +140,50 @@ export async function rollback(opts = {}) {
|
|
|
118
140
|
// ② 恢复
|
|
119
141
|
this.phase = '恢复文件'
|
|
120
142
|
const snapNames = await snapshotEntries(snapDir)
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
143
|
+
/**
|
|
144
|
+
* 快照名录按**归一化名字**索引(`nameKey`:小写归一)。
|
|
145
|
+
*
|
|
146
|
+
* Windows / macOS 的文件名大小写不敏感,`a.md` 与 `A.md` 是同一个文件。旧写法用
|
|
147
|
+
* `new Set(snapNames)` 做大小写敏感的 `has()`:**快照之后只改了大小写的文件**,在整目录
|
|
148
|
+
* 回滚里会被判成「快照里没有」→ 先被写回、紧接着被 park 进 `archive/` → 活动记忆静默
|
|
149
|
+
* 消失,而回滚报 `ok:true`(实测复现过)。判据必须与文件系统同宽。
|
|
150
|
+
*/
|
|
151
|
+
const snapByKey = new Map(snapNames.map((n) => [nameKey(n), n]))
|
|
152
|
+
/** 名字在当前文件系统上算不算「快照里已有」;返回快照里那一份的**真实文件名**。 */
|
|
153
|
+
const snapNameOf = (name) => snapByKey.get(nameKey(name)) ?? null
|
|
125
154
|
|
|
126
155
|
const restored = []
|
|
127
156
|
const parked = []
|
|
128
157
|
const skipped = []
|
|
158
|
+
/** 说明(不是「跳过」也不是「移走」):结果达到了,只是过程和常规路径不同,要写进报告。 */
|
|
159
|
+
const notes = []
|
|
129
160
|
const archiveDir = join(this.memoryDir, 'archive')
|
|
130
161
|
await mkdir(archiveDir, { recursive: true })
|
|
131
162
|
|
|
132
|
-
/**
|
|
163
|
+
/**
|
|
164
|
+
* 移进 archive/(不删)。同名时**换一个退役名**落进去,绝不覆盖,也绝不放弃。
|
|
165
|
+
*
|
|
166
|
+
* 旧写法撞名就 `skipped` 走人,代价是:同一趟 `create → archive → create` 的文件,
|
|
167
|
+
* 归档区已经被本趟占着,回滚要让它从顶层退场时撞名 → 直接放弃 → 那条运行前根本
|
|
168
|
+
* 不存在的记忆继续活在顶层。目标状态是「顶层没有它」,撞名只是「原件名被占了」
|
|
169
|
+
* ——换个名字照样达到目标,且仍然一个文件都没删。
|
|
170
|
+
*/
|
|
133
171
|
const park = async (name, why) => {
|
|
134
172
|
const target = join(archiveDir, name)
|
|
135
173
|
const clash = await stat(target).catch(() => null)
|
|
136
|
-
if (clash) {
|
|
137
|
-
|
|
174
|
+
if (!clash) {
|
|
175
|
+
await rename(join(this.memoryDir, name), target)
|
|
176
|
+
parked.push(`${name}(${why})`)
|
|
177
|
+
return true
|
|
178
|
+
}
|
|
179
|
+
const retired = `${name.replace(/\.md$/i, '')}.retired-${stamp}-${randomUUID().replace(/-/g, '').slice(0, 6)}.md`
|
|
180
|
+
try {
|
|
181
|
+
await rename(join(this.memoryDir, name), join(archiveDir, retired))
|
|
182
|
+
} catch (err) {
|
|
183
|
+
skipped.push(`${name}(本想${why},archive/ 已有同名、退役名也写不进去:${err?.message ?? err})`)
|
|
138
184
|
return false
|
|
139
185
|
}
|
|
140
|
-
|
|
141
|
-
parked.push(`${name}(${why})`)
|
|
186
|
+
parked.push(`${name} → ${retired}(${why};archive/ 已有同名,改用退役名,未删除)`)
|
|
142
187
|
return true
|
|
143
188
|
}
|
|
144
189
|
|
|
@@ -152,69 +197,93 @@ export async function rollback(opts = {}) {
|
|
|
152
197
|
}
|
|
153
198
|
}
|
|
154
199
|
// 快照之后新建的文件:整目录回滚要让它退场,但不能删——移进 archive/。
|
|
200
|
+
// `current` 只在这条分支用得上,扫一遍目录就够了(文件级回滚按 ledger 逐个 stat)。
|
|
201
|
+
const current = (await readdir(this.memoryDir, { withFileTypes: true }).catch(() => []))
|
|
202
|
+
.filter((e) => e.isFile() && e.name.toLowerCase().endsWith('.md'))
|
|
203
|
+
.map((e) => e.name)
|
|
155
204
|
for (const name of current) {
|
|
156
|
-
if (!
|
|
205
|
+
if (!snapNameOf(name)) await park(name, '快照里没有,整目录回滚需退场')
|
|
157
206
|
}
|
|
158
207
|
} else {
|
|
159
|
-
for (const c of runManifest.changes ?? []) {
|
|
160
|
-
const name = c.file
|
|
161
|
-
if (c.op === 'update') {
|
|
162
|
-
if (snapSet.has(name)) {
|
|
163
|
-
try {
|
|
164
|
-
await copyFile(join(snapDir, name), join(this.memoryDir, name))
|
|
165
|
-
restored.push(name)
|
|
166
|
-
} catch (err) {
|
|
167
|
-
skipped.push(`${name}(写回失败:${err?.message ?? err})`)
|
|
168
|
-
}
|
|
169
|
-
} else {
|
|
170
|
-
skipped.push(`${name}(回滚点里没有这一版,可能已被别的整理改过)`)
|
|
171
|
-
}
|
|
172
|
-
} else if (c.op === 'create') {
|
|
173
|
-
if (current.includes(name)) await park(name, '本次整理新建,回滚即退场')
|
|
174
|
-
} else if (c.op === 'archive') {
|
|
175
|
-
// 归档 = 从顶层移进 archive/;回滚就是把它移回来。
|
|
176
|
-
const src = join(archiveDir, name)
|
|
177
|
-
const exists = await stat(src).catch(() => null)
|
|
178
|
-
const atTop = await stat(join(this.memoryDir, name)).catch(() => null)
|
|
179
|
-
if (!exists) skipped.push(`${name}(archive/ 里找不到,可能已人工处理过)`)
|
|
180
|
-
else if (atTop) skipped.push(`${name}(顶层已有同名文件,不覆盖)`)
|
|
181
|
-
else {
|
|
182
|
-
await rename(src, join(this.memoryDir, name))
|
|
183
|
-
restored.push(name)
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
|
|
188
208
|
/**
|
|
189
|
-
*
|
|
209
|
+
* 文件级回滚**按文件聚合**,不按 `changes` 的条目顺序逐条执行。
|
|
190
210
|
*
|
|
191
|
-
* `
|
|
192
|
-
* `archive
|
|
193
|
-
*
|
|
194
|
-
* 用户以为退回来了,文件其实还躺在 `archive/` 里。
|
|
211
|
+
* 同一趟里模型完全可能先 `write_memory` 新建、再 `archive_memory` 归档同一个文件。
|
|
212
|
+
* 逐条执行时 create 那条把它 park 进 `archive/`、archive 那条又把它 `rename` 回顶层 ——
|
|
213
|
+
* 回滚反而**激活**了一条运行前根本不存在的记忆,而面板照报「已回滚」。
|
|
195
214
|
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
215
|
+
* 判据只有一个:**这个文件在运行前快照里存在吗**。
|
|
216
|
+
* 存在 → 目标是「顶层有一份运行前的版本」:内容从快照取;若已被归档,从 `archive/`
|
|
217
|
+
* 洗掉留痕搬回来(走 `memory/archive.js` 的共享实现,与手动恢复同一份)
|
|
218
|
+
* 不存在 → 目标是「顶层没有它」:在顶层就 park 进 `archive/`;已经在 `archive/` 里
|
|
219
|
+
* 就留在那儿 —— 「不删」不等于「重新激活」
|
|
199
220
|
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
221
|
+
* 两本账(模型的 `changes` + 确定性策略的 `archive.archived`)在这里**合流成一个文件清单**:
|
|
222
|
+
* 策略归档按设计不写进 `changes`,只遍历 `changes` 会整段漏掉;而同一个文件在两本账里
|
|
223
|
+
* 各出现一次时,逐条执行会互相打架。按文件聚合把这两个问题一起消掉。
|
|
202
224
|
*/
|
|
225
|
+
const ledger = new Set()
|
|
226
|
+
for (const c of runManifest.changes ?? []) {
|
|
227
|
+
if (typeof c?.file === 'string' && c.file) ledger.add(c.file)
|
|
228
|
+
}
|
|
203
229
|
for (const a of runManifest.archive?.archived ?? []) {
|
|
204
|
-
if (
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
230
|
+
if (a?.ok && a.file) ledger.add(a.file)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
for (const name of ledger) {
|
|
234
|
+
const atTop = await stat(join(this.memoryDir, name)).catch(() => null)
|
|
235
|
+
/** 快照里那一份的真实文件名(大小写可能不同),null = 运行前不存在。 */
|
|
236
|
+
const snapName = snapNameOf(name)
|
|
237
|
+
if (!snapName) {
|
|
238
|
+
// 运行前不存在:目标状态是「顶层没有它」。
|
|
239
|
+
if (atTop) await park(name, '本次整理新建,回滚即退场')
|
|
240
|
+
// 已被这趟归档进 archive/ 的:留在档案馆,不搬回来。
|
|
241
|
+
continue
|
|
242
|
+
}
|
|
243
|
+
// 运行前存在:目标状态是「顶层是运行前那一版」。
|
|
244
|
+
if (atTop) {
|
|
245
|
+
try {
|
|
246
|
+
await copyFile(join(snapDir, snapName), join(this.memoryDir, name))
|
|
247
|
+
restored.push(name)
|
|
248
|
+
} catch (err) {
|
|
249
|
+
skipped.push(`${name}(写回失败:${err?.message ?? err})`)
|
|
250
|
+
}
|
|
209
251
|
continue
|
|
210
252
|
}
|
|
211
|
-
if (await stat(join(
|
|
212
|
-
skipped.push(`${name}
|
|
253
|
+
if (!(await stat(join(archiveDir, name)).catch(() => null))) {
|
|
254
|
+
skipped.push(`${name}(顶层与 archive/ 里都找不到,可能已人工处理过)`)
|
|
213
255
|
continue
|
|
214
256
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
257
|
+
/**
|
|
258
|
+
* archive/ 里那份**必须是运行前那一版**,才谈得上「移回来」。
|
|
259
|
+
*
|
|
260
|
+
* 一个真实的反例:顶层已不在、而 `archive/` 里躺着一份**更早的另一版**时,
|
|
261
|
+
* 旧写法直接 `restoreMemory()` 把它搬回顶层并报 `restored: 1` —— 出来的既不是
|
|
262
|
+
* 运行前那一版,档案馆那份还被吃掉了。`snapshot.js` 那句「回滚后是不是真回到了
|
|
263
|
+
* 那一版」得在这里落地:
|
|
264
|
+
* - 洗掉留痕后与快照**逐字节相同** → 就是它,走 `restoreMemory`(顺手洗留痕)
|
|
265
|
+
* - 不同 → 它是别的东西:从**快照**复制到顶层,`archive/` 那份原样留着(不删不动)
|
|
266
|
+
*/
|
|
267
|
+
const snapContent = await readFile(join(snapDir, snapName)).catch(() => null)
|
|
268
|
+
const archContent = await readFile(join(archiveDir, name), 'utf8').catch(() => null)
|
|
269
|
+
if (snapContent && archContent !== null && stripArchiveMeta(archContent) === snapContent.toString('utf8')) {
|
|
270
|
+
const res = await restoreMemory(this.memoryDir, name)
|
|
271
|
+
if (res.ok) restored.push(name)
|
|
272
|
+
else skipped.push(`${name}(恢复失败:${res.error})`)
|
|
273
|
+
} else if (snapContent) {
|
|
274
|
+
try {
|
|
275
|
+
await copyFile(join(snapDir, snapName), join(this.memoryDir, name))
|
|
276
|
+
restored.push(name)
|
|
277
|
+
// ⚠️ 记进 notes 而**不是** skipped:文件确实恢复了(restored 里算一个),
|
|
278
|
+
// 只是没走「从 archive/ 移回」那条常规路径。计进 skipped 会让面板说
|
|
279
|
+
// 「跳过 1 个」,读者以为它没回来。
|
|
280
|
+
notes.push(`${name}:archive/ 里那份不是运行前那一版,已改用快照版本写回;archive/ 那份原样保留`)
|
|
281
|
+
} catch (err) {
|
|
282
|
+
skipped.push(`${name}(写回失败:${err?.message ?? err})`)
|
|
283
|
+
}
|
|
284
|
+
} else {
|
|
285
|
+
skipped.push(`${name}(快照里没有这一版,无法恢复)`)
|
|
286
|
+
}
|
|
218
287
|
}
|
|
219
288
|
}
|
|
220
289
|
|
|
@@ -240,6 +309,9 @@ export async function rollback(opts = {}) {
|
|
|
240
309
|
if (skipped.length) {
|
|
241
310
|
lines.push('## 跳过', '', ...skipped.map((f) => `- ${f}`), '')
|
|
242
311
|
}
|
|
312
|
+
if (notes.length) {
|
|
313
|
+
lines.push('## 说明', '', ...notes.map((n) => `- ${n}`), '')
|
|
314
|
+
}
|
|
243
315
|
const reportDir = join(this.memoryDir, REPORT_DIR)
|
|
244
316
|
await mkdir(reportDir, { recursive: true })
|
|
245
317
|
await writeFile(join(reportDir, reportName), lines.join('\n'), 'utf8')
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
|
|
9
9
|
import { CONFIG_VERSION, DEFAULT_CONFIG, LEGACY_STATE_FILE } from './config.js'
|
|
10
10
|
import { clampNum, readJson } from './util.js'
|
|
11
|
-
import {
|
|
11
|
+
import { writeFileAtomic } from '../memory/util.js'
|
|
12
|
+
import { mkdir } from 'node:fs/promises'
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* 读配置 + 状态(同一个文件)。缺失或损坏都回落到默认值,绝不让它拖死插件加载。
|
|
@@ -50,14 +51,10 @@ export async function load() {
|
|
|
50
51
|
/** 落盘。写入是原子的(临时文件 + rename),半截文件不会留在盘上。 */
|
|
51
52
|
export async function persist() {
|
|
52
53
|
await mkdir(this.stateRoot, { recursive: true })
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
} finally {
|
|
58
|
-
// rename 成功后 tmp 已不存在(ENOENT),失败时清掉不留垃圾。
|
|
59
|
-
await rm(tmp, { force: true }).catch(() => {})
|
|
60
|
-
}
|
|
54
|
+
// 这里曾经是**第二份** tmp+rename 实现,而且临时名同样固定成 `${configPath}.${pid}.tmp`:
|
|
55
|
+
// 只修 `memory/util.js` 那份的话,同一个缺陷会原样留在这里(实测 20 次并发配置落盘
|
|
56
|
+
// 18 次 ENOENT)。两份实现合一,以后只有一处要维护。
|
|
57
|
+
await writeFileAtomic(this.configPath, JSON.stringify(this.state, null, 2))
|
|
61
58
|
}
|
|
62
59
|
|
|
63
60
|
/** 给 UI 的完整配置(含有效模型路线与门控快照)。 */
|
|
@@ -10,7 +10,7 @@ import { DEFAULT_CONFIG, PRE_ROLLBACK_KEEP } from './config.js'
|
|
|
10
10
|
import { readJson, safeName, snapshotEntries, stampHuman } from './util.js'
|
|
11
11
|
import { createHash } from 'node:crypto'
|
|
12
12
|
import { mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
|
|
13
|
-
import { join } from 'node:path'
|
|
13
|
+
import { join, basename } from 'node:path'
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
16
|
* 把 memory 目录顶层的 markdown 整体快照一份,作为**回滚点**。
|
|
@@ -117,6 +117,137 @@ export async function resolveSnapshotDir(id) {
|
|
|
117
117
|
return null
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
/**
|
|
121
|
+
* 校验一份回滚点:内容有没有被外部动过。
|
|
122
|
+
*
|
|
123
|
+
* 回滚是**把快照复制回记忆目录**——记忆目录没有版本控制也没有回收站,所以「拿一份假的
|
|
124
|
+
* 回滚点去覆盖现在」等于一次性销毁当前全部记忆。而快照落盘后一直躺在磁盘上:备份工具
|
|
125
|
+
* 还原错版本、手工编辑、杀软改写、半截写入,都会让 MANIFEST 里的哈希与实际内容对不上。
|
|
126
|
+
* 校验失败必须**拒绝整趟回滚**,一个字都不许写。
|
|
127
|
+
*
|
|
128
|
+
* 三个方向都要查,缺一个就漏一种攻击面:
|
|
129
|
+
* - `missing` 清单里记了、盘上却没有 → 整目录回滚会把它当成「快照里没有」→ 停进 archive/
|
|
130
|
+
* - `tampered` 内容与 sha256 不符 → 会把被改过的内容当「原始版本」写回去
|
|
131
|
+
* - `extra` 盘上有、清单没记 → 整目录回滚会把它复制进记忆目录(凭空多出一条记忆)
|
|
132
|
+
*
|
|
133
|
+
* ⚠️ 已知边界:MANIFEST 与内容同源,**改了内容又顺手改哈希**骗得过这里(没有签名)。
|
|
134
|
+
* 它防的是「无意的损坏与还原错版本」,不是有意的伪造。
|
|
135
|
+
*
|
|
136
|
+
* @returns {Promise<{ok:boolean, schema:'hashed'|'unhashed', checked:number,
|
|
137
|
+
* missing:string[], tampered:string[], extra:string[], invalid:string[], reason:string}>}
|
|
138
|
+
*/
|
|
139
|
+
export async function verifySnapshot(dir, opts = {}) {
|
|
140
|
+
const legacy = opts?.legacy === true
|
|
141
|
+
/** 无哈希可比(旧版回滚点):放行,但要说清「这次没法校验」。 */
|
|
142
|
+
const unhashed = (reason) => ({
|
|
143
|
+
ok: true,
|
|
144
|
+
schema: 'unhashed',
|
|
145
|
+
checked: 0,
|
|
146
|
+
missing: [],
|
|
147
|
+
tampered: [],
|
|
148
|
+
extra: [],
|
|
149
|
+
invalid: [],
|
|
150
|
+
reason,
|
|
151
|
+
})
|
|
152
|
+
/** 清单本身坏了:**拒绝**。坏掉的清单与「旧版格式」在数据上不可区分,只能从严。 */
|
|
153
|
+
const broken = (reason) => ({
|
|
154
|
+
ok: false,
|
|
155
|
+
schema: 'broken',
|
|
156
|
+
checked: 0,
|
|
157
|
+
missing: [],
|
|
158
|
+
tampered: [],
|
|
159
|
+
extra: [],
|
|
160
|
+
invalid: [],
|
|
161
|
+
reason,
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* ⚠️ 这里**不能**用 `readJson` 那种「读不出来就回 null」的写法。
|
|
166
|
+
*
|
|
167
|
+
* 一条真实的绕过:删掉 `MANIFEST.json`(或把它弄成半截 JSON)→ 旧写法一律当成
|
|
168
|
+
* 「旧版快照没有哈希」放行 → 被篡改的快照内容照样写进记忆目录,**整套校验等于没有**。
|
|
169
|
+
* 一个文件的删除就能拆掉安全带的修法不叫修法。
|
|
170
|
+
*
|
|
171
|
+
* 判据因此收紧成:新版目录(`snapshots/`)里的回滚点**必须**有一份能解析、且带逐文件
|
|
172
|
+
* 哈希清单的 `MANIFEST.json`;只有旧版目录(`snapshots-legacy/` 一类)才允许没有哈希 ——
|
|
173
|
+
* 那是历史遗留,废掉它等于把老回滚点全部作废。
|
|
174
|
+
*/
|
|
175
|
+
let rawManifest
|
|
176
|
+
try {
|
|
177
|
+
rawManifest = await readFile(join(dir, 'MANIFEST.json'), 'utf8')
|
|
178
|
+
} catch (err) {
|
|
179
|
+
if (err?.code === 'ENOENT' && legacy) {
|
|
180
|
+
return unhashed('这是一份旧版回滚点(目录里没有 MANIFEST.json),内容无法校验')
|
|
181
|
+
}
|
|
182
|
+
return broken(
|
|
183
|
+
err?.code === 'ENOENT'
|
|
184
|
+
? '这份回滚点里没有 MANIFEST.json(新版回滚点必然带它)—— 无法校验内容'
|
|
185
|
+
: `读不了 MANIFEST.json(${err?.message ?? err})—— 无法校验内容`,
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
let manifest
|
|
189
|
+
try {
|
|
190
|
+
manifest = JSON.parse(rawManifest)
|
|
191
|
+
} catch (err) {
|
|
192
|
+
return broken(`MANIFEST.json 解析失败(${err?.message ?? err})—— 无法校验内容`)
|
|
193
|
+
}
|
|
194
|
+
const recorded = Array.isArray(manifest?.files) ? manifest.files : null
|
|
195
|
+
if (!recorded) {
|
|
196
|
+
if (legacy) return unhashed('这是一份旧版回滚点(MANIFEST 里没有逐文件哈希),内容无法校验')
|
|
197
|
+
return broken('MANIFEST.json 里没有逐文件哈希清单(files 不是数组)—— 无法校验内容')
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const missing = []
|
|
201
|
+
const tampered = []
|
|
202
|
+
const invalid = []
|
|
203
|
+
let checked = 0
|
|
204
|
+
for (const f of recorded) {
|
|
205
|
+
const name = typeof f?.name === 'string' ? f.name : ''
|
|
206
|
+
/**
|
|
207
|
+
* 名字闸门只管一件事:**不许带路径成分**(`..\x.md`、`sub/x.md`、`C:\…` 一律判坏),
|
|
208
|
+
* 否则 `join(dir, name)` 会指到快照目录外面去。
|
|
209
|
+
*
|
|
210
|
+
* 这里**不**复用 autodream 的 `safeName` —— 那套比记忆层(`memory/naming.js`)严,
|
|
211
|
+
* 而 `snapshot()` 是照着 readdir 的原名记清单的:一份含 `笔记.md` / `a..b.md` 的
|
|
212
|
+
* 快照会被判成「有非法文件名」而**整份滚不回去**(实测)。
|
|
213
|
+
* 合法字符集由记忆层定义,这里只做边界防护。
|
|
214
|
+
*/
|
|
215
|
+
if (!name || name !== basename(name) || name === '.' || name === '..') {
|
|
216
|
+
invalid.push(name || '(空名字)')
|
|
217
|
+
continue
|
|
218
|
+
}
|
|
219
|
+
let buf
|
|
220
|
+
try {
|
|
221
|
+
buf = await readFile(join(dir, name))
|
|
222
|
+
} catch {
|
|
223
|
+
missing.push(name)
|
|
224
|
+
continue
|
|
225
|
+
}
|
|
226
|
+
const sha = createHash('sha256').update(buf).digest('hex')
|
|
227
|
+
if (!f.sha256 || sha !== f.sha256) tampered.push(name)
|
|
228
|
+
else checked += 1
|
|
229
|
+
}
|
|
230
|
+
const known = new Set(recorded.map((f) => f?.name))
|
|
231
|
+
const extra = (await snapshotEntries(dir)).filter((n) => !known.has(n))
|
|
232
|
+
|
|
233
|
+
const ok = !missing.length && !tampered.length && !extra.length && !invalid.length
|
|
234
|
+
const parts = []
|
|
235
|
+
if (missing.length) parts.push(`缺 ${missing.length} 个文件(${missing.slice(0, 3).join('、')})`)
|
|
236
|
+
if (tampered.length) parts.push(`${tampered.length} 个文件内容与哈希不符(${tampered.slice(0, 3).join('、')})`)
|
|
237
|
+
if (extra.length) parts.push(`多出 ${extra.length} 个未记录的文件(${extra.slice(0, 3).join('、')})`)
|
|
238
|
+
if (invalid.length) parts.push(`清单里有 ${invalid.length} 个非法文件名`)
|
|
239
|
+
return {
|
|
240
|
+
ok,
|
|
241
|
+
schema: 'hashed',
|
|
242
|
+
checked,
|
|
243
|
+
missing,
|
|
244
|
+
tampered,
|
|
245
|
+
extra,
|
|
246
|
+
invalid,
|
|
247
|
+
reason: ok ? '' : `快照内容校验不通过:${parts.join(';')}`,
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
120
251
|
/**
|
|
121
252
|
* 列出所有回滚点(新版 + 旧版目录都列)。
|
|
122
253
|
*
|
package/lib/autodream/util.js
CHANGED
|
@@ -36,7 +36,10 @@ export function newRunId(ms) {
|
|
|
36
36
|
/** 文件名/目录名的白名单校验(快照 id、运行 id、报告名都走这里,别让路径穿越进来)。 */
|
|
37
37
|
export function safeName(raw) {
|
|
38
38
|
const s = String(raw ?? '').trim()
|
|
39
|
-
|
|
39
|
+
// `.` 单独放行过:它通过 `[\w.-]+` 且不含 `..`,于是 `snapshotId: '.'` 会被解析成
|
|
40
|
+
// **快照根目录本身**(`resolveSnapshotDir` 一试就中)—— 一份「拿整个根当回滚点」的请求
|
|
41
|
+
// 虽然后面会被 MANIFEST 校验拦住,但它根本不该走到那一步。
|
|
42
|
+
if (s === '.' || !s || s.includes('/') || s.includes('\\') || s.includes('..')) return null
|
|
40
43
|
if (!/^[\w.-]+$/.test(s)) return null
|
|
41
44
|
return s
|
|
42
45
|
}
|
package/lib/autodream-audit.js
CHANGED
|
@@ -23,6 +23,8 @@
|
|
|
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'
|
|
27
|
+
import { warnLog } from './memory/util.js'
|
|
26
28
|
|
|
27
29
|
/** 索引文件名。它本身不是一条记忆,是索引。 */
|
|
28
30
|
const INDEX_FILE = 'MEMORY.md'
|
|
@@ -98,7 +100,23 @@ async function readDirSnapshot(dir) {
|
|
|
98
100
|
const all = entries
|
|
99
101
|
.filter((e) => e.isFile() && e.name.toLowerCase().endsWith('.md'))
|
|
100
102
|
.map((e) => e.name)
|
|
101
|
-
const files =
|
|
103
|
+
const files = []
|
|
104
|
+
for (const f of all) {
|
|
105
|
+
if (AUDIT_SKIP_FILES.has(f.toLowerCase())) continue
|
|
106
|
+
/**
|
|
107
|
+
* 与「列表 / 注入 / 模型工具」同一条闸门。
|
|
108
|
+
*
|
|
109
|
+
* 审计原先自己扫目录、不过闸门,于是同一个目录会给出互相矛盾的两个数:
|
|
110
|
+
* 列表说 1 条(链接 / 超大的被跳过),体检说 3 条 —— 用户根本不知道该信哪个。
|
|
111
|
+
* 被跳过的条目在这里同样不计入 fileCount,原因落到日志。
|
|
112
|
+
*/
|
|
113
|
+
const gate = await inspectMemoryEntry(join(dir, f))
|
|
114
|
+
if (!gate.ok) {
|
|
115
|
+
warnLog(null, `audit: skip ${f}: ${gate.error}`)
|
|
116
|
+
continue
|
|
117
|
+
}
|
|
118
|
+
files.push(f)
|
|
119
|
+
}
|
|
102
120
|
let indexText = ''
|
|
103
121
|
try {
|
|
104
122
|
indexText = await readFile(join(dir, INDEX_FILE), 'utf8')
|
package/lib/autodream-tools.js
CHANGED
|
@@ -24,11 +24,13 @@
|
|
|
24
24
|
* `write_memory` 的 frontmatter 规范都沿用 sage-mem 的既有约定,不另造一套。
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
-
import { readFile, readdir,
|
|
27
|
+
import { readFile, readdir, stat } from 'node:fs/promises'
|
|
28
28
|
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 { writeFileAtomic, warnLog } from './memory/util.js'
|
|
33
|
+
import { inspectMemoryEntry, reservedIdentitySet } from './memory/scan.js'
|
|
32
34
|
|
|
33
35
|
/** 单次 read_memory 返回的正文上限(字符)。超过就截断并明确告诉模型。 */
|
|
34
36
|
const READ_LIMIT = 20000
|
|
@@ -102,7 +104,7 @@ function ageDays(ms) {
|
|
|
102
104
|
* @param {string} memoryDir
|
|
103
105
|
* @returns {Promise<Array<object>>}
|
|
104
106
|
*/
|
|
105
|
-
async function scanEntries(memoryDir) {
|
|
107
|
+
async function scanEntries(memoryDir, ctx) {
|
|
106
108
|
let entries
|
|
107
109
|
try {
|
|
108
110
|
entries = await readdir(memoryDir, { withFileTypes: true })
|
|
@@ -110,14 +112,25 @@ async function scanEntries(memoryDir) {
|
|
|
110
112
|
if (err?.code === 'ENOENT') return []
|
|
111
113
|
throw err
|
|
112
114
|
}
|
|
115
|
+
const reservedIds = await reservedIdentitySet(memoryDir)
|
|
113
116
|
const names = entries
|
|
114
117
|
.filter((e) => e.isFile() && e.name.toLowerCase().endsWith('.md') && !NON_ENTRY_FILES.has(e.name.toLowerCase()))
|
|
115
118
|
.map((e) => e.name)
|
|
116
119
|
const out = []
|
|
117
120
|
for (const name of names) {
|
|
118
121
|
const full = join(memoryDir, name)
|
|
122
|
+
// 与注入 / 面板同一条闸门:模型看到的清单不该包含「住在别处」的文件。
|
|
123
|
+
const gate = await inspectMemoryEntry(full)
|
|
124
|
+
if (!gate.ok) {
|
|
125
|
+
warnLog(ctx, `autodream: skip ${name}: ${gate.error}`)
|
|
126
|
+
continue
|
|
127
|
+
}
|
|
128
|
+
if (gate.id && reservedIds.has(gate.id)) {
|
|
129
|
+
warnLog(ctx, `autodream: skip ${name}: 这是某个保留名的另一个名字`)
|
|
130
|
+
continue
|
|
131
|
+
}
|
|
119
132
|
try {
|
|
120
|
-
const
|
|
133
|
+
const content = await readFile(full, 'utf8')
|
|
121
134
|
const meta = parseFrontmatterLite(content)
|
|
122
135
|
const body = content.replace(/^---\r?\n[\s\S]*?\r?\n---/, '')
|
|
123
136
|
const h1 = body.match(/^#\s+(.+)$/m)
|
|
@@ -125,8 +138,8 @@ async function scanEntries(memoryDir) {
|
|
|
125
138
|
file: name,
|
|
126
139
|
// 索引不是一条记忆:标出来,否则模型会看到「MEMORY.md · reference」而困惑。
|
|
127
140
|
type: name.toLowerCase() === INDEX_FILE ? '索引' : meta.type || 'reference',
|
|
128
|
-
bytes:
|
|
129
|
-
mtimeMs:
|
|
141
|
+
bytes: gate.size,
|
|
142
|
+
mtimeMs: gate.mtimeMs,
|
|
130
143
|
title: (h1 ? h1[1] : meta.name || '').trim(),
|
|
131
144
|
description: meta.description || '',
|
|
132
145
|
})
|
|
@@ -326,7 +339,7 @@ const MIN_REASON_CHARS = 4
|
|
|
326
339
|
* @returns {{run:(name:string, args:object)=>Promise<string>, touchedFiles:()=>string[], changes:()=>object[], warnings:()=>string[]}}
|
|
327
340
|
*/
|
|
328
341
|
export function createToolRunner(opts) {
|
|
329
|
-
const { memoryDir, sessionsRoot, apply, withSessions } = opts
|
|
342
|
+
const { memoryDir, sessionsRoot, apply, withSessions, ctx } = opts
|
|
330
343
|
let listCache = null
|
|
331
344
|
let auditCache = null
|
|
332
345
|
const touched = []
|
|
@@ -395,7 +408,7 @@ export function createToolRunner(opts) {
|
|
|
395
408
|
try {
|
|
396
409
|
switch (name) {
|
|
397
410
|
case 'list_memory': {
|
|
398
|
-
if (!listCache) listCache = await scanEntries(memoryDir)
|
|
411
|
+
if (!listCache) listCache = await scanEntries(memoryDir, ctx)
|
|
399
412
|
const lines = listCache.map(
|
|
400
413
|
(e) =>
|
|
401
414
|
`- ${e.file} · ${e.type} · ${Math.round(e.bytes / 1024)}KB · ${ageDays(e.mtimeMs)}天前 · ${localStamp(e.mtimeMs)}\n ${(e.title || '(无标题)').slice(0, 60)}${e.description ? ' — ' + e.description.slice(0, LIST_DESC_CHARS) : ''}`,
|
|
@@ -407,6 +420,17 @@ export function createToolRunner(opts) {
|
|
|
407
420
|
if (!validated) return `错误:文件名不合法(只能是 memory 目录顶层的 .md 文件名):${String(a.file)}`
|
|
408
421
|
// 统一落到磁盘上的真实名字:NTFS 大小写不敏感,`A.md` 与 `a.md` 是同一个文件。
|
|
409
422
|
const full = join(memoryDir, await resolveRealName(memoryDir, basename(validated)))
|
|
423
|
+
/**
|
|
424
|
+
* 过闸门再读:**名字在记忆目录里不等于内容住在记忆目录里**。
|
|
425
|
+
* 注入与面板两条腿都挡住了,这条腿(模型工具)也必须挡 ——
|
|
426
|
+
* 否则外部文件的内容照样会被读进来、送进模型上下文。
|
|
427
|
+
*/
|
|
428
|
+
const gate = await inspectMemoryEntry(full)
|
|
429
|
+
if (!gate.ok) {
|
|
430
|
+
return gate.code === 'missing'
|
|
431
|
+
? `错误:没有这个文件:${basename(full)}`
|
|
432
|
+
: `错误:拒绝读取 ${basename(full)}:${gate.error}`
|
|
433
|
+
}
|
|
410
434
|
const content = await readFile(full, 'utf8').catch((err) => {
|
|
411
435
|
if (err?.code === 'ENOENT') return null
|
|
412
436
|
throw err
|
|
@@ -441,7 +465,10 @@ export function createToolRunner(opts) {
|
|
|
441
465
|
const reason = typeof a.reason === 'string' ? a.reason.trim() : ''
|
|
442
466
|
const selfReported = reason.length >= MIN_REASON_CHARS
|
|
443
467
|
const before = await fileFingerprint(full)
|
|
444
|
-
|
|
468
|
+
// 原子写:这是**唯一**会把模型生成的内容写进记忆目录的路径。
|
|
469
|
+
// 直写若崩在中途,留下的是半截 frontmatter —— 文件还在、列表还列得出,
|
|
470
|
+
// 但解析不出类型与标题,检索与星图同时哑掉。
|
|
471
|
+
await writeFileAtomic(full, a.content)
|
|
445
472
|
const after = await fileFingerprint(full)
|
|
446
473
|
if (!selfReported) {
|
|
447
474
|
// **不拒绝。** 一次改写缺了缘由,拒掉的代价是这条修正彻底丢失——而它可能
|
package/lib/index.js
CHANGED
|
@@ -22,12 +22,12 @@ import { MAX_BASELINE, MAX_CHARS_PER_FILE, MAX_FILE_BYTES, MAX_RESULTS, MAX_SESS
|
|
|
22
22
|
import { parseAliases, parseBaseline, parseBaselinePriority, parseFrontmatter, parseTags } from './memory/frontmatter.js'
|
|
23
23
|
import { isReserved, nameKey, safeName } from './memory/naming.js'
|
|
24
24
|
import { ageDays, bigrams, extractText, score, selectBaseline, selectRelevant } from './memory/retrieval.js'
|
|
25
|
-
import { MEMORY_ARCHIVE_DIR, addArchiveMeta, extractTitle, readArchivedMeta, scanArchived, scanMemoryFiles, scanStars } from './memory/scan.js'
|
|
25
|
+
import { MEMORY_ARCHIVE_DIR, addArchiveMeta, extractTitle, inspectDir, inspectMemoryEntry, readArchivedMeta, scanArchived, scanMemoryFiles, scanStars, shadowsReserved } from './memory/scan.js'
|
|
26
26
|
import { archiveMemory, restoreMemory } from './memory/archive.js'
|
|
27
27
|
import { flushAccess, pickArchiveCandidates, readAccessLedger, recordAccess } from './memory/access.js'
|
|
28
28
|
import { warnLog, writeFileAtomic } from './memory/util.js'
|
|
29
29
|
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
|
|
30
|
-
import { mkdir, readFile,
|
|
30
|
+
import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises'
|
|
31
31
|
import { basename, join } from 'node:path'
|
|
32
32
|
|
|
33
33
|
/**
|
|
@@ -121,6 +121,9 @@ async function readReservedFile() {
|
|
|
121
121
|
function rawName(raw) {
|
|
122
122
|
const s = typeof raw === 'string' ? raw : ''
|
|
123
123
|
if (!s || basename(s) !== s) return null
|
|
124
|
+
// `:` 是 NTFS 交替数据流的分隔符:`MEMORY.md:evil.md` 能过扩展名判据,落盘却是写到
|
|
125
|
+
// 索引的隐藏流上(`readdir` 看不见)。与 `safeName` 同一条理由,这里也要拒。
|
|
126
|
+
if (s.includes(':')) return null
|
|
124
127
|
return /\.md$/i.test(s) ? s : null
|
|
125
128
|
}
|
|
126
129
|
|
|
@@ -389,11 +392,24 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
389
392
|
})
|
|
390
393
|
}
|
|
391
394
|
|
|
392
|
-
/**
|
|
395
|
+
/**
|
|
396
|
+
* 读单个记忆文件全文。
|
|
397
|
+
*
|
|
398
|
+
* 过闸门再读:名字在记忆目录里**不等于**内容住在记忆目录里 —— 符号链接 / 目录联接 /
|
|
399
|
+
* 硬链接都能让一次「读自己的记忆」变成读别人的文件,而读到的内容会被注入模型上下文。
|
|
400
|
+
*/
|
|
393
401
|
async readFile(name) {
|
|
394
402
|
const safe = safeName(name)
|
|
395
403
|
if (!safe) throw new Error('sage-mem: invalid file name')
|
|
396
|
-
const
|
|
404
|
+
const full = join(MEMORY_DIR, safe)
|
|
405
|
+
// 名字不是保留名 ≠ 文件不是保留名:8.3 短名(`MEMORY~1.MD`)文本上与索引毫无关系,
|
|
406
|
+
// 却是同一个文件。别把索引当普通记忆交出去。
|
|
407
|
+
if (await shadowsReserved(full)) {
|
|
408
|
+
throw new Error(`sage-mem: refusing to read ${safe}: 这是某个保留名(索引 / 流水)的另一个名字`)
|
|
409
|
+
}
|
|
410
|
+
const gate = await inspectMemoryEntry(full)
|
|
411
|
+
if (!gate.ok) throw new Error(`sage-mem: refusing to read ${safe}: ${gate.error}`)
|
|
412
|
+
const content = await readFile(full, 'utf8')
|
|
397
413
|
return { name: safe, content }
|
|
398
414
|
}
|
|
399
415
|
|
|
@@ -412,6 +428,12 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
412
428
|
}
|
|
413
429
|
const safe = safeName(name)
|
|
414
430
|
if (!safe) throw new Error('sage-mem: invalid file name')
|
|
431
|
+
// 名字文本不是保留名 **不等于** 文件不是保留名:NTFS 的 8.3 短名(`MEMORY~1.MD`)
|
|
432
|
+
// 在文本上跟索引毫无关系,inode 却是同一个 —— 实测它能改写索引。
|
|
433
|
+
// 保留名保护必须落在**文件身份**上,否则「保留名不可写」这句承诺是假的。
|
|
434
|
+
if (await shadowsReserved(join(MEMORY_DIR, safe))) {
|
|
435
|
+
return { ok: false, file: safe, error: `sage-mem: reserved file (它的另一个名字是 ${safe}),请用文件工具改` }
|
|
436
|
+
}
|
|
415
437
|
// 不加这一层,传个对象进来会被 String() 写成 "[object Object]" 还回 { ok: true },
|
|
416
438
|
// 原文件就此被一行垃圾顶掉,且没有任何错误信号。
|
|
417
439
|
if (typeof content !== 'string') {
|
|
@@ -421,7 +443,19 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
421
443
|
if (bytes > MAX_FILE_BYTES) {
|
|
422
444
|
return { ok: false, file: safe, error: `sage-mem: content too large (${bytes} bytes > ${MAX_FILE_BYTES})` }
|
|
423
445
|
}
|
|
424
|
-
|
|
446
|
+
/**
|
|
447
|
+
* 目标已存在时也要过闸门:链接 / 硬链接一律拒绝。
|
|
448
|
+
*
|
|
449
|
+
* 原子写本身不会写穿链接(rename 换掉的是链接本身),但那样「改这条记忆」就变成了
|
|
450
|
+
* 「把这个名字指向别处」—— 用户以为改的是记忆,实际磁盘上多了一个指向外部的名字。
|
|
451
|
+
* `limitBytes = 0` 表示不在这里判体积(上面刚按字符数查过)。
|
|
452
|
+
*/
|
|
453
|
+
const target = join(MEMORY_DIR, safe)
|
|
454
|
+
const existing = await inspectMemoryEntry(target, { limitBytes: null })
|
|
455
|
+
if (!existing.ok && existing.code !== 'missing') {
|
|
456
|
+
return { ok: false, file: safe, error: `sage-mem: refusing to write ${safe}: ${existing.error}` }
|
|
457
|
+
}
|
|
458
|
+
await writeFileAtomic(target, content)
|
|
425
459
|
return { ok: true, file: safe }
|
|
426
460
|
}
|
|
427
461
|
|
|
@@ -432,6 +466,10 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
432
466
|
}
|
|
433
467
|
const safe = safeName(name)
|
|
434
468
|
if (!safe) throw new Error('sage-mem: invalid file name')
|
|
469
|
+
// 同 writeFile:8.3 短名能绕过名字文本判据,删除保护也必须看文件身份。
|
|
470
|
+
if (await shadowsReserved(join(MEMORY_DIR, safe))) {
|
|
471
|
+
return { ok: false, error: `sage-mem: reserved file (它的另一个名字是 ${safe}),拒绝删除` }
|
|
472
|
+
}
|
|
435
473
|
await unlink(join(MEMORY_DIR, safe))
|
|
436
474
|
return { ok: true }
|
|
437
475
|
}
|
|
@@ -497,11 +535,19 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
497
535
|
async readArchived(name) {
|
|
498
536
|
const safe = safeName(name)
|
|
499
537
|
if (!safe) throw new Error('sage-mem: invalid file name')
|
|
538
|
+
// 归档区本身先过目录闸门:它若是指向别处的联接,读归档就等于读外部的文件。
|
|
539
|
+
const dirGate = await inspectDir(MEMORY_ARCHIVE_DIR)
|
|
540
|
+
if (!dirGate.ok) throw new Error(`sage-mem: archive dir unavailable: ${dirGate.error}`)
|
|
500
541
|
const full = join(MEMORY_ARCHIVE_DIR, safe)
|
|
501
|
-
const
|
|
542
|
+
const gate = await inspectMemoryEntry(full)
|
|
502
543
|
// 找不到就抛:与 readFile 一致。静默返回空串会让面板显示一个「空的记忆」,
|
|
503
544
|
// 而用户改完一存 —— 就把一条真实内容顶掉了。
|
|
504
|
-
if (!
|
|
545
|
+
if (!gate.ok) {
|
|
546
|
+
// ⚠️「形状不对」与「不存在」是两回事:把联接 / 超限都报成「文件不存在」,
|
|
547
|
+
// 会让用户去找一个明明躺在盘上的文件。
|
|
548
|
+
if (gate.code === 'missing') throw new Error(`sage-mem: archived file not found: ${safe}`)
|
|
549
|
+
throw new Error(`sage-mem: refusing to read archived ${safe}: ${gate.error}`)
|
|
550
|
+
}
|
|
505
551
|
const content = await readFile(full, 'utf8')
|
|
506
552
|
return { name: safe, content }
|
|
507
553
|
}
|
|
@@ -520,10 +566,21 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
520
566
|
}
|
|
521
567
|
const safe = safeName(name)
|
|
522
568
|
if (!safe) throw new Error('sage-mem: invalid file name')
|
|
569
|
+
const dirGate = await inspectDir(MEMORY_ARCHIVE_DIR)
|
|
570
|
+
if (!dirGate.ok) return { ok: false, file: safe, error: `sage-mem: archive dir unavailable: ${dirGate.error}` }
|
|
523
571
|
const full = join(MEMORY_ARCHIVE_DIR, safe)
|
|
524
|
-
const
|
|
572
|
+
const gate = await inspectMemoryEntry(full, { limitBytes: null })
|
|
525
573
|
// 只改归档区里**已经存在**的文件:这条 remote 不是「把任意文件写进归档区」的后门。
|
|
526
|
-
if (!
|
|
574
|
+
if (!gate.ok) {
|
|
575
|
+
return {
|
|
576
|
+
ok: false,
|
|
577
|
+
file: safe,
|
|
578
|
+
error:
|
|
579
|
+
gate.code === 'missing'
|
|
580
|
+
? `sage-mem: archived file not found: ${safe}`
|
|
581
|
+
: `sage-mem: refusing to write archived ${safe}: ${gate.error}`,
|
|
582
|
+
}
|
|
583
|
+
}
|
|
527
584
|
if (typeof content !== 'string') {
|
|
528
585
|
return { ok: false, file: safe, error: `sage-mem: content must be a string, got ${typeof content}` }
|
|
529
586
|
}
|
|
@@ -669,9 +726,15 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
669
726
|
}
|
|
670
727
|
const safe = rawName(name)
|
|
671
728
|
if (!safe) return { ok: false, error: `sage-mem: invalid file name: ${String(name)}` }
|
|
729
|
+
// 保留名同样过闸门:链接 / 硬链接一样能把「读索引 / 读流水」变成读外部文件。
|
|
730
|
+
// ⚠️ 体积**不**在这里判(`limitBytes = 0`):流水天然比单条记忆大得多,
|
|
731
|
+
// 它有自己的上限(`MAX_RAW_BYTES`,见上面的注释)。
|
|
732
|
+
const full = join(MEMORY_DIR, safe)
|
|
733
|
+
const gate = await inspectMemoryEntry(full, { limitBytes: null })
|
|
734
|
+
if (!gate.ok) throw new Error(`sage-mem: refusing to read ${safe}: ${gate.error}`)
|
|
672
735
|
// 读不到就抛(与 readFile 一致):静默返回空串会让面板显示一份「空的索引」,
|
|
673
736
|
// 用户一存就把真索引顶掉。
|
|
674
|
-
const content = await readFile(
|
|
737
|
+
const content = await readFile(full, 'utf8')
|
|
675
738
|
return { name: safe, content }
|
|
676
739
|
}
|
|
677
740
|
|
|
@@ -699,7 +762,14 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
699
762
|
error: `sage-mem: content too large (${bytes} bytes > ${MAX_RAW_BYTES / (1024 * 1024)} MB raw limit)`,
|
|
700
763
|
}
|
|
701
764
|
}
|
|
702
|
-
|
|
765
|
+
const target = join(MEMORY_DIR, safe)
|
|
766
|
+
// 与 `writeFile` 同一条理由:目标已存在时若是链接 / 硬链接,一律拒绝 ——
|
|
767
|
+
// 「改这份索引」不该变成「把这个名字指向别处」。体积由上面那道 `MAX_RAW_BYTES` 管。
|
|
768
|
+
const existing = await inspectMemoryEntry(target, { limitBytes: null })
|
|
769
|
+
if (!existing.ok && existing.code !== 'missing') {
|
|
770
|
+
return { ok: false, file: safe, error: `sage-mem: refusing to write ${safe}: ${existing.error}` }
|
|
771
|
+
}
|
|
772
|
+
await writeFileAtomic(target, content)
|
|
703
773
|
return { ok: true, file: safe }
|
|
704
774
|
}
|
|
705
775
|
}
|
package/lib/memory/archive.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* 就是临时目录),拿模块级常量会写错地方 —— 那是会碰到真实记忆的危险错误。
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { ARCHIVE_DIR_NAME, addArchiveMeta, localStamp, stripArchiveMeta } from './scan.js'
|
|
18
|
+
import { ARCHIVE_DIR_NAME, addArchiveMeta, inspectDir, inspectMemoryEntry, localStamp, stripArchiveMeta } from './scan.js'
|
|
19
19
|
import { isReserved, nameKey, safeName } from './naming.js'
|
|
20
20
|
import { writeFileAtomic } from './util.js'
|
|
21
21
|
import { mkdir, readFile, stat, unlink } from 'node:fs/promises'
|
|
@@ -49,9 +49,26 @@ export async function archiveMemory(memoryDir, name, reason, at = localStamp())
|
|
|
49
49
|
const safe = gate.safe
|
|
50
50
|
const src = join(memoryDir, safe)
|
|
51
51
|
const dst = join(archiveDirOf(memoryDir), safe)
|
|
52
|
-
|
|
52
|
+
/**
|
|
53
|
+
* 两道闸门,缺一不可:
|
|
54
|
+
* - 源文件:链接 / 硬链接指向的可能是记忆目录**之外**的文件,归档它 = 把它从外部搬走
|
|
55
|
+
* - 归档区:它若被换成指向外部的目录联接,归档 = 把记忆搬出记忆目录
|
|
56
|
+
* (体积不在这里判:归档是搬移,不是写入。)
|
|
57
|
+
*/
|
|
58
|
+
const srcGate = await inspectMemoryEntry(src, { limitBytes: null })
|
|
53
59
|
// 根目录里没有 → 文件名写错,或者它已经是归档态(归档区那份不该再归档一次)。
|
|
54
|
-
if (!
|
|
60
|
+
if (!srcGate.ok) {
|
|
61
|
+
return {
|
|
62
|
+
ok: false,
|
|
63
|
+
code: srcGate.code === 'missing' ? 'not-found' : srcGate.code,
|
|
64
|
+
error: `sage-mem: file not found: ${safe}(${srcGate.error})`,
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const dirGate = await inspectDir(archiveDirOf(memoryDir))
|
|
68
|
+
// 归档区还不存在是正常状态(下面 mkdir 会建)。
|
|
69
|
+
if (!dirGate.ok && dirGate.code !== 'missing') {
|
|
70
|
+
return { ok: false, code: dirGate.code, error: `sage-mem: archive dir unavailable: ${dirGate.error}` }
|
|
71
|
+
}
|
|
55
72
|
if (await stat(dst).catch(() => null)) {
|
|
56
73
|
// 归档区已有同名:不覆盖 —— 那份旧归档可能是用户想留的另一版。
|
|
57
74
|
return { ok: false, code: 'exists', error: `sage-mem: already archived: ${safe}` }
|
|
@@ -79,11 +96,25 @@ export async function restoreMemory(memoryDir, name) {
|
|
|
79
96
|
const safe = gate.safe
|
|
80
97
|
const dst = join(memoryDir, safe)
|
|
81
98
|
const src = join(archiveDirOf(memoryDir), safe)
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if (
|
|
99
|
+
// 归档区过目录闸门:联接指向外部时,「恢复」会把外部的文件搬进记忆目录。
|
|
100
|
+
const dirGate = await inspectDir(archiveDirOf(memoryDir))
|
|
101
|
+
if (!dirGate.ok) {
|
|
102
|
+
return {
|
|
103
|
+
ok: false,
|
|
104
|
+
code: dirGate.code === 'missing' ? 'not-archived' : dirGate.code,
|
|
105
|
+
error: `sage-mem: not archived: ${safe}(${dirGate.error})`,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const srcGate = await inspectMemoryEntry(src, { limitBytes: null })
|
|
109
|
+
if (!srcGate.ok) return { ok: false, code: 'not-archived', error: `sage-mem: not archived: ${safe}(${srcGate.error})` }
|
|
110
|
+
// 顶层已有同名(且不是链接)时拒绝:根目录那份是正在用的记忆,归档这份只是退役版。
|
|
111
|
+
const dstGate = await inspectMemoryEntry(dst, { limitBytes: null })
|
|
112
|
+
if (dstGate.ok) {
|
|
85
113
|
return { ok: false, code: 'target-exists', error: `sage-mem: already exists, refusing to overwrite: ${safe}` }
|
|
86
114
|
}
|
|
115
|
+
if (dstGate.code !== 'missing') {
|
|
116
|
+
return { ok: false, code: dstGate.code, error: `sage-mem: refusing to restore over ${safe}: ${dstGate.error}` }
|
|
117
|
+
}
|
|
87
118
|
const content = await readFile(src, 'utf8')
|
|
88
119
|
await writeFileAtomic(dst, stripArchiveMeta(content))
|
|
89
120
|
await unlink(src)
|
package/lib/memory/naming.js
CHANGED
|
@@ -48,6 +48,11 @@ export function safeName(raw) {
|
|
|
48
48
|
// basename 与原文不一致=含目录成分。旧版是「悄悄取 basename 继续」,
|
|
49
49
|
// 与 starmap 那套(要求 safe === raw)行为不一致;现在统一为拒绝。
|
|
50
50
|
if (basename(raw) !== raw) return null
|
|
51
|
+
// `:` 在 NTFS 上是**交替数据流**的分隔符:`MEMORY.md:evil.md` 以 `.md` 结尾、能过下面
|
|
52
|
+
// 所有判据,落盘时却会写到索引的隐藏数据流上(`readdir` 看不见,只写流不报错)。
|
|
53
|
+
// 实测更糟:`writeFile('MEMORY.md:evil.md')` 会在 `rename` 处抛**未捕获**的 EINVAL ——
|
|
54
|
+
// 违背「拒绝一律返回 {ok:false}、不抛」这条契约。正常文件名不允许 `:`,直接拒。
|
|
55
|
+
if (raw.includes(':')) return null
|
|
51
56
|
if (!/\.md$/i.test(raw)) return null
|
|
52
57
|
if (isNonEntry(raw)) return null
|
|
53
58
|
return raw
|
package/lib/memory/scan.js
CHANGED
|
@@ -10,11 +10,11 @@
|
|
|
10
10
|
* 文件可能被手工搬来搬去,随文件走的元数据才不会与文件脱节。
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { MEMORY_DIR } from './config.js'
|
|
13
|
+
import { MAX_FILE_BYTES, MEMORY_DIR, RESERVED_FILES } from './config.js'
|
|
14
14
|
import { parseBaseline, parseFrontmatter } from './frontmatter.js'
|
|
15
15
|
import { isMemoryEntry } from './naming.js'
|
|
16
16
|
import { warnLog } from './util.js'
|
|
17
|
-
import { readFile, readdir
|
|
17
|
+
import { lstat, readFile, readdir } from 'node:fs/promises'
|
|
18
18
|
import { join } from 'node:path'
|
|
19
19
|
|
|
20
20
|
/** 归档区目录名。与 autodream 工具里的 ARCHIVE_DIR 同名同义(同一个目录)。 */
|
|
@@ -112,18 +112,160 @@ export function addArchiveMeta(content, reason, at) {
|
|
|
112
112
|
return stripped.slice(0, m.index) + block.join(eol) + stripped.slice(m.index + m[0].length)
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
/**
|
|
116
|
+
* 记忆文件形状闸门:只认「普通文件、体积在给定上限内」的 `.md`。
|
|
117
|
+
*
|
|
118
|
+
* 为什么要闸门:记忆目录里的一条 `x.md` **不一定真的住在记忆目录里** ——
|
|
119
|
+
* **符号链接 / 目录联接**把名字留在记忆目录、把内容指到任意路径:读它就是读别人的文件,
|
|
120
|
+
* 写它就是改别人的文件,而这些内容会被注入模型上下文。
|
|
121
|
+
* 用 `lstat` 而不是 `stat`:`stat` 会跟随链接,永远看不出这一层。
|
|
122
|
+
*
|
|
123
|
+
* ⚠️ 本机实测:Windows 上 `symlink()`(文件或目录)要特权、默认 EPERM,但
|
|
124
|
+
* `symlink(dir, 'junction')` **能建** —— 所以这不是理论风险。
|
|
125
|
+
*
|
|
126
|
+
* **硬链接(`nlink > 1`)不拦,只提示**(返回值里的 `links`,由调用方负责出声)。
|
|
127
|
+
* 取舍理由:硬链接**不提供**「指向任意路径」的能力(不能跨卷,只能由已在同卷上有权限的人建),
|
|
128
|
+
* 它带来的是「内容可能被记忆目录之外改到」—— 这是完整性提示,不是越界读取。
|
|
129
|
+
* 而拦它的代价是**真实误伤**:`cp -al` / `rsync --link-dest` 这类快照备份会让源文件的
|
|
130
|
+
* `nlink` 变成 2,于是整条记忆从注入与面板里静默消失(实测复现过)。
|
|
131
|
+
* 宁可提示、不可丢记忆 —— 与「大小写判定宁可多留」同一条原则。
|
|
132
|
+
*
|
|
133
|
+
* 体积上限也在这里:它原先只写在**写入**路径上,读路径谁来都全量读进内存,
|
|
134
|
+
* 等于上限只在「自己人写」的前提下成立(一条被手工粘进来的日志就能把整轮注入吃光)。
|
|
135
|
+
*
|
|
136
|
+
* @param {string} full — 绝对路径
|
|
137
|
+
* @param {{limitBytes?: number|null}} [opts] — `limitBytes: null` = **不判体积**(归档区用);
|
|
138
|
+
* 不传则用 `MAX_FILE_BYTES`。⚠️ 显式 `null` 与「随便传个 0 / NaN」是两回事:
|
|
139
|
+
* 非有限数一律按「超过上限」处理,不会静默把闸门关掉。
|
|
140
|
+
* @returns {Promise<{ok:true,size:number,mtimeMs:number,links:number}|{ok:false,code:string,error:string}>}
|
|
141
|
+
*/
|
|
142
|
+
export async function inspectMemoryEntry(full, opts = {}) {
|
|
143
|
+
const limitBytes = opts?.limitBytes === undefined ? MAX_FILE_BYTES : opts.limitBytes
|
|
144
|
+
let info
|
|
145
|
+
try {
|
|
146
|
+
info = await lstat(full)
|
|
147
|
+
} catch (err) {
|
|
148
|
+
return {
|
|
149
|
+
ok: false,
|
|
150
|
+
code: err?.code === 'ENOENT' ? 'missing' : 'stat-failed',
|
|
151
|
+
error: `读不到文件属性(${err?.code ?? err})`,
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (info.isSymbolicLink()) return { ok: false, code: 'symlink', error: '是指向别处的符号链接 / 目录联接' }
|
|
155
|
+
if (!info.isFile()) return { ok: false, code: 'not-file', error: '不是普通文件' }
|
|
156
|
+
// null = 明确「不判体积」;其余一律按数值上限处理(0 / NaN / 字符串都会被当成超限)。
|
|
157
|
+
if (limitBytes !== null && (!Number.isFinite(limitBytes) || info.size > limitBytes)) {
|
|
158
|
+
return {
|
|
159
|
+
ok: false,
|
|
160
|
+
code: 'too-big',
|
|
161
|
+
error: Number.isFinite(limitBytes)
|
|
162
|
+
? `体积 ${info.size} 字节,超过单文件上限 ${limitBytes} 字节`
|
|
163
|
+
: `体积 ${info.size} 字节,上限参数不合法(${String(limitBytes)})`,
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return { ok: true, size: info.size, mtimeMs: info.mtimeMs, links: info.nlink, id: identityOf(info) }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** 文件身份键(`dev:ino`)。拿不到 inode 的平台返回 null(那就退回名字文本判据)。 */
|
|
170
|
+
export function identityOf(info) {
|
|
171
|
+
return info && info.ino ? `${info.dev}:${info.ino}` : null
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 保留名在磁盘上的**文件身份**集合(`dev:ino`)。
|
|
176
|
+
*
|
|
177
|
+
* 为什么需要它:保留名保护原先只比**名字文本**(`nameKey` 归一 + 小写)。但 NTFS 上一个
|
|
178
|
+
* 文件可以有第二个 8.3 短名 —— `MEMORY~1.MD` 与 `MEMORY.md` 文本上毫无关系,判据却必须
|
|
179
|
+
* 认出它们是**同一个文件**:实测在能建短名的卷上,`writeFile('MEMORY~1.MD')` 能改写索引、
|
|
180
|
+
* `deleteFile('MEMORY~2.MD')` 能把它删掉,而 `isReserved()` 一路放行。
|
|
181
|
+
*
|
|
182
|
+
* 返回集合在每次调用时现取(保留名就那两三个,一次目录写入的开销可忽略)。
|
|
183
|
+
*/
|
|
184
|
+
export async function reservedIdentitySet(dir = MEMORY_DIR) {
|
|
185
|
+
const ids = new Set()
|
|
186
|
+
for (const name of RESERVED_FILES) {
|
|
187
|
+
const info = await lstat(join(dir, name)).catch(() => null)
|
|
188
|
+
const id = identityOf(info)
|
|
189
|
+
if (id) ids.add(id)
|
|
190
|
+
}
|
|
191
|
+
return ids
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** 这个名字在磁盘上是不是某个保留名的另一个名字(8.3 短名 / 硬链接)。 */
|
|
195
|
+
export async function shadowsReserved(full, dir = MEMORY_DIR) {
|
|
196
|
+
const ids = await reservedIdentitySet(dir)
|
|
197
|
+
if (!ids.size) return false
|
|
198
|
+
const info = await lstat(full).catch(() => null)
|
|
199
|
+
const id = identityOf(info)
|
|
200
|
+
return id !== null && ids.has(id)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* 子目录闸门:`archive/` 这类**插件自己管理的目录**必须是真目录,不能是指向别处的联接。
|
|
205
|
+
*
|
|
206
|
+
* ⚠️ 只对 `archive/` 这类子目录用,**不**对记忆目录本身用:把 `SAGE_MEM_DIR` 指向一个
|
|
207
|
+
* 链接目录(例如网盘同步目录)是正当用法,拦掉它属于误伤。而 `archive/` 是归档动作的
|
|
208
|
+
* 落地处 —— 它若是联接,归档就等于把记忆搬出记忆目录,恢复则可能把外部文件搬进来。
|
|
209
|
+
*
|
|
210
|
+
* @returns {Promise<{ok:true}|{ok:false,code:string,error:string}>}
|
|
211
|
+
*/
|
|
212
|
+
export async function inspectDir(dir) {
|
|
213
|
+
let info
|
|
214
|
+
try {
|
|
215
|
+
info = await lstat(dir)
|
|
216
|
+
} catch (err) {
|
|
217
|
+
return {
|
|
218
|
+
ok: false,
|
|
219
|
+
code: err?.code === 'ENOENT' ? 'missing' : 'stat-failed',
|
|
220
|
+
error: `读不到目录属性(${err?.code ?? err})`,
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (info.isSymbolicLink()) return { ok: false, code: 'symlink', error: '是指向别处的符号链接 / 目录联接' }
|
|
224
|
+
if (!info.isDirectory()) return { ok: false, code: 'not-dir', error: '不是目录' }
|
|
225
|
+
return { ok: true }
|
|
226
|
+
}
|
|
227
|
+
|
|
115
228
|
/** 扫 memory 目录,读回全部记忆文件(跳过索引与子目录),并解析 frontmatter 摘要。 */
|
|
116
229
|
export async function scanMemoryFiles(ctx, sessionId) {
|
|
117
230
|
try {
|
|
118
231
|
const entries = await readdir(MEMORY_DIR, { withFileTypes: true })
|
|
232
|
+
/** 保留名的文件身份:8.3 短名与硬链接都得靠它才认得出(名字文本上它们毫无关系)。 */
|
|
233
|
+
const reservedIds = await reservedIdentitySet()
|
|
234
|
+
/**
|
|
235
|
+
* 链接条目在 `isFile()` 这一步就被滤掉了(`readdir` 的 Dirent 报的是链接本身),
|
|
236
|
+
* 也就是说**闸门根本没机会看到它们** —— 不在这里出声,用户只会看到「这条记忆不见了」。
|
|
237
|
+
*/
|
|
238
|
+
for (const e of entries) {
|
|
239
|
+
if (e.isSymbolicLink() && /\.md$/i.test(e.name)) {
|
|
240
|
+
warnLog(ctx, `skip ${e.name}: 是指向别处的符号链接 / 目录联接`, sessionId)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
119
243
|
const names = entries
|
|
120
244
|
.filter(e => e.isFile() && isMemoryEntry(e.name))
|
|
121
245
|
.map(e => e.name)
|
|
122
246
|
const loaded = await Promise.all(names.map(async (name) => {
|
|
247
|
+
const full = join(MEMORY_DIR, name)
|
|
248
|
+
// 先过闸门(lstat + 体积),再读:链接与超大文件都在**全量读之前**被挡下,
|
|
249
|
+
// 而不是读进来之后才发现不该读。mtime 由闸门一并给出,比原来少一跳 stat。
|
|
250
|
+
const gate = await inspectMemoryEntry(full)
|
|
251
|
+
/**
|
|
252
|
+
* 8.3 短名 / 硬链接都可能是「保留名的另一个名字」:`MEMORY~1.MD` 与 `MEMORY.md`
|
|
253
|
+
* 文本上毫无关系,inode 却是同一个。不按身份拦,索引就会以普通记忆的身份被读进上下文。
|
|
254
|
+
*/
|
|
255
|
+
if (gate.ok && gate.id && reservedIds.has(gate.id)) {
|
|
256
|
+
warnLog(ctx, `skip ${name}: 这是某个保留名(索引 / 流水)的另一个名字,不当记忆读`, sessionId)
|
|
257
|
+
return null
|
|
258
|
+
}
|
|
259
|
+
// 硬链接**不拦**(见 inspectMemoryEntry 的取舍),但要让用户知道内容可能被外部改到。
|
|
260
|
+
if (gate.ok && gate.links > 1) {
|
|
261
|
+
warnLog(ctx, `note ${name}: 有 ${gate.links} 个硬链接名,内容可能被记忆目录之外改到`, sessionId)
|
|
262
|
+
}
|
|
263
|
+
if (!gate.ok) {
|
|
264
|
+
warnLog(ctx, `skip ${name}: ${gate.error}`, sessionId)
|
|
265
|
+
return null
|
|
266
|
+
}
|
|
123
267
|
try {
|
|
124
|
-
const
|
|
125
|
-
// stat 与 readFile 并行 —— 多要一个 mtime 不该变成串行两跳。
|
|
126
|
-
const [content, info] = await Promise.all([readFile(full, 'utf8'), stat(full)])
|
|
268
|
+
const content = await readFile(full, 'utf8')
|
|
127
269
|
const meta = parseFrontmatter(content)
|
|
128
270
|
return {
|
|
129
271
|
file: name,
|
|
@@ -134,7 +276,7 @@ export async function scanMemoryFiles(ctx, sessionId) {
|
|
|
134
276
|
baselinePriority: meta.baselinePriority || 0,
|
|
135
277
|
// 新鲜度的两个来源:frontmatter 的 updated(权威)与文件 mtime(退路)。
|
|
136
278
|
updated: meta.updated || '',
|
|
137
|
-
mtimeMs:
|
|
279
|
+
mtimeMs: gate.mtimeMs,
|
|
138
280
|
}
|
|
139
281
|
} catch (err) {
|
|
140
282
|
// 只有 ENOENT 是正常状态(扫到读之间被删)。其余——权限错、编码坏、盘掉线——
|
|
@@ -159,7 +301,7 @@ export function extractTitle(content) {
|
|
|
159
301
|
}
|
|
160
302
|
|
|
161
303
|
/** 扫一个目录,读回星图要的轻量元数据(正文 + 体积 + 修改时间)。 */
|
|
162
|
-
async function readStarDir(dir, ctx) {
|
|
304
|
+
async function readStarDir(dir, ctx, opts = {}) {
|
|
163
305
|
let entries
|
|
164
306
|
try {
|
|
165
307
|
entries = await readdir(dir, { withFileTypes: true })
|
|
@@ -170,11 +312,22 @@ async function readStarDir(dir, ctx) {
|
|
|
170
312
|
const names = entries
|
|
171
313
|
.filter(e => e.isFile() && isMemoryEntry(e.name))
|
|
172
314
|
.map(e => e.name)
|
|
315
|
+
const reservedIds = await reservedIdentitySet()
|
|
173
316
|
const loaded = await Promise.all(names.map(async (name) => {
|
|
317
|
+
const full = join(dir, name)
|
|
318
|
+
const gate = await inspectMemoryEntry(full, opts)
|
|
319
|
+
if (!gate.ok) {
|
|
320
|
+
warnLog(ctx, `skip ${full}: ${gate.error}`)
|
|
321
|
+
return null
|
|
322
|
+
}
|
|
323
|
+
// 星图同样不显示「保留名的另一个名字」(8.3 短名 / 硬链接)。
|
|
324
|
+
if (gate.id && reservedIds.has(gate.id)) {
|
|
325
|
+
warnLog(ctx, `skip ${full}: 这是某个保留名(索引 / 流水)的另一个名字`)
|
|
326
|
+
return null
|
|
327
|
+
}
|
|
174
328
|
try {
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
return { name, content, bytes: info.size, mtimeMs: info.mtimeMs }
|
|
329
|
+
const content = await readFile(full, 'utf8')
|
|
330
|
+
return { name, content, bytes: gate.size, mtimeMs: gate.mtimeMs }
|
|
178
331
|
} catch (err) {
|
|
179
332
|
if (err?.code !== 'ENOENT') warnLog(ctx, `cannot read ${full}: ${err?.message ?? err}`)
|
|
180
333
|
return null
|
|
@@ -188,7 +341,21 @@ async function readStarDir(dir, ctx) {
|
|
|
188
341
|
* 归档区不存在是正常状态(没人归档过)→ 空数组,不是错误。
|
|
189
342
|
*/
|
|
190
343
|
export async function scanArchived(ctx) {
|
|
191
|
-
|
|
344
|
+
// 归档区是**插件自己建的子目录**,必须过目录闸门:它若被换成指向外部的联接,
|
|
345
|
+
// 「归档 = 搬进 archive/」就变成「把记忆搬出记忆目录」。
|
|
346
|
+
const gate = await inspectDir(MEMORY_ARCHIVE_DIR)
|
|
347
|
+
if (!gate.ok) {
|
|
348
|
+
// ENOENT 是正常状态(还没人归档过)→ 空数组;其余一律出声,绝不静默当成「没有归档」。
|
|
349
|
+
if (gate.code !== 'missing') warnLog(ctx, `归档区不可用,跳过:${gate.error}`)
|
|
350
|
+
return []
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* ⚠️ 归档区**不判体积**(`limitBytes: null`):归档是「把这条记忆收起来」,
|
|
354
|
+
* 不是「新写一条记忆」—— 拿活动记忆的上限去卡归档区,会造出「能归档、列不出、
|
|
355
|
+
* 读不回」的半死状态(实测:600 KB 的记忆归档成功,却从馆里消失、又还能被覆盖)。
|
|
356
|
+
* 形状闸门(symlink)照旧生效。
|
|
357
|
+
*/
|
|
358
|
+
const rows = await readStarDir(MEMORY_ARCHIVE_DIR, ctx, { limitBytes: null })
|
|
192
359
|
return rows.map(f => ({ ...f, archived: true }))
|
|
193
360
|
}
|
|
194
361
|
|
package/lib/memory/util.js
CHANGED
|
@@ -17,21 +17,51 @@ export function warnLog(ctx, msg, sessionId) {
|
|
|
17
17
|
else console.warn(line)
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* 进程内的临时文件序号:`writeFileAtomic` 的临时名必须**每次调用都不同**。
|
|
22
|
+
*
|
|
23
|
+
* 旧写法是固定的 `${target}.${process.pid}.tmp` —— 同一进程里两个调用同时写同一个目标
|
|
24
|
+
* (访问台账 flush 与面板写就真实地会抢),会共用同一个临时文件:先写的 rename 成功、
|
|
25
|
+
* 后写的 rename 拿到 ENOENT,而失败那方的 `finally` 还会把**别人正在用的**临时文件删掉。
|
|
26
|
+
* 实测 20 次并发只有 4 次成功 —— 另外 16 次全栽在共享的那个临时文件上。
|
|
27
|
+
*
|
|
28
|
+
* 序号 + 随机后缀 + pid 一起用:单进程内不会撞,多进程也不会撞。
|
|
29
|
+
*/
|
|
30
|
+
let tmpSeq = 0
|
|
31
|
+
|
|
32
|
+
/** rename 的瞬时失败码:Windows 上目标正被另一个 rename 覆盖时会短暂 EPERM/EBUSY。 */
|
|
33
|
+
const RENAME_RETRY_CODES = new Set(['EPERM', 'EACCES', 'EBUSY', 'EEXIST'])
|
|
34
|
+
|
|
35
|
+
/** 带退避重试的 rename —— 并发写同一目标时,瞬时占用不该被当成失败。 */
|
|
36
|
+
async function renameWithRetry(from, to, attempts = 8) {
|
|
37
|
+
for (let i = 1; ; i++) {
|
|
38
|
+
try {
|
|
39
|
+
return await rename(from, to)
|
|
40
|
+
} catch (err) {
|
|
41
|
+
if (i >= attempts || !RENAME_RETRY_CODES.has(err?.code)) throw err
|
|
42
|
+
await new Promise((r) => setTimeout(r, 4 * i))
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
20
47
|
/**
|
|
21
48
|
* 原子写:先写同目录临时文件,再 rename 覆盖目标。
|
|
22
49
|
*
|
|
23
50
|
* 直写的风险是崩在中途(断电、进程被杀、磁盘满)留下半截文件 —— frontmatter
|
|
24
51
|
* 缺半边,这条记忆从此解析不出来、检索不到,而文件看上去还在。
|
|
25
52
|
* 同卷 rename 是原子的:读方要么看到旧全文、要么看到新全文,不存在中间态。
|
|
26
|
-
*
|
|
53
|
+
* 任何一步失败都不碰目标文件,**自己那份**临时文件收尾清掉。
|
|
27
54
|
*/
|
|
28
55
|
export async function writeFileAtomic(target, content) {
|
|
29
|
-
|
|
56
|
+
tmpSeq += 1
|
|
57
|
+
const uniq = `${process.pid}-${Date.now().toString(36)}-${tmpSeq.toString(36)}-${Math.random().toString(36).slice(2, 8)}`
|
|
58
|
+
const tmp = `${target}.${uniq}.tmp`
|
|
30
59
|
try {
|
|
31
60
|
await writeFile(tmp, content, 'utf8')
|
|
32
|
-
await
|
|
61
|
+
await renameWithRetry(tmp, target)
|
|
33
62
|
} finally {
|
|
34
63
|
// rename 成功后 tmp 已不存在(ENOENT),失败时把它清掉不留垃圾。
|
|
64
|
+
// 临时名每次唯一,所以这里删的只可能是自己那份。
|
|
35
65
|
await unlink(tmp).catch(() => {})
|
|
36
66
|
}
|
|
37
67
|
}
|
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.4",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|
|
@@ -15,11 +15,6 @@
|
|
|
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
|
-
},
|
|
23
18
|
"license": "Apache-2.0",
|
|
24
19
|
"engines": {
|
|
25
20
|
"node": ">=18",
|
|
@@ -90,5 +85,10 @@
|
|
|
90
85
|
"ai-agent",
|
|
91
86
|
"agent",
|
|
92
87
|
"claude-code"
|
|
93
|
-
]
|
|
94
|
-
|
|
88
|
+
],
|
|
89
|
+
"scripts": {
|
|
90
|
+
"test": "node test/run.mjs",
|
|
91
|
+
"build:client": "node scripts/build-client.mjs",
|
|
92
|
+
"images": "node scripts/make-readme-images.mjs"
|
|
93
|
+
}
|
|
94
|
+
}
|