sage-mem 2.1.4 → 2.1.6
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 +1 -1
- package/README.zh.md +1 -1
- package/lib/autodream/audit.js +5 -2
- package/lib/client.js +16 -6
- package/lib/index.js +25 -15
- package/lib/memory/archive.js +197 -73
- package/lib/memory/scan.js +23 -8
- package/lib/memory/util.js +73 -1
- package/lib/typert.host.js +14 -14
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
**Cross-session memory for DeepSeek Harness — plain markdown files, no database, no worker.**
|
|
14
14
|
|
|
15
|
-
**Current release: `2.1.
|
|
15
|
+
**Current release: `2.1.6`** — the npm badge above is the dynamic source of truth. **Contract baseline: `1.0.0`** — the first stable release, whose public commitments, the capability boundaries and the list of things it explicitly cannot do are frozen in the
|
|
16
16
|
[frozen contract](https://github.com/gezi-wen/sage-mem/blob/main/docs/1.0.0-contract.md).
|
|
17
17
|
|
|
18
18
|
If it is useful to you, a Star is the most direct feedback.
|
package/README.zh.md
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
> File-based cross-session memory for DeepSeek Harness — every memory is a plain markdown file you can open, edit and delete. No database, no worker, no port.
|
|
16
16
|
|
|
17
|
-
**当前发布版本:`2.1.
|
|
17
|
+
**当前发布版本:`2.1.6`** —— 顶部 npm 徽章是动态权威。**契约基线:`1.0.0`** —— 第一个稳定版,公开承诺、能力边界与「明确做不到」的清单冻结在
|
|
18
18
|
[契约冻结](https://github.com/gezi-wen/sage-mem/blob/main/docs/1.0.0-contract.md)。
|
|
19
19
|
|
|
20
20
|
如果它对你有用,给个 Star 是最直接的反馈。
|
package/lib/autodream/audit.js
CHANGED
|
@@ -33,15 +33,18 @@ import { warnLog } from '../memory/util.js'
|
|
|
33
33
|
const INDEX_FILE = 'MEMORY.md'
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
|
-
* 归档 / 恢复中途崩溃留下的**私有名残留**:`a.md.archiving-<hex>`。
|
|
36
|
+
* 归档 / 恢复中途崩溃留下的**私有名残留**:`a.md.archiving-<hex>` / `a.md.restoring-<hex>`。
|
|
37
37
|
*
|
|
38
38
|
* 为什么单列一类(2026-10-12 复审复核点出):`memory/archive.js` 把源 `rename` 到这个
|
|
39
39
|
* 私有名之后、复核完成之前若进程死掉,这份内容就留在了记忆目录里。它**不是** `.md`,
|
|
40
40
|
* 所以扫描 / 注入 / 星图 / 快照 / 列表都不认它(这本来是对的,免得半成品被当成一条记忆)——
|
|
41
41
|
* 但连体检也不认,就等于「可能装着用户唯一一份内容的东西」在界面上完全隐形,
|
|
42
42
|
* 而它躺在用户会同步、会备份的记忆根目录里。审计负责把它报出来(纯只读,只报不删)。
|
|
43
|
+
*
|
|
44
|
+
* 2026-10-31 外部审查 Q06:恢复也改用同一套「接管到私有名」的事务原语,所以残留后缀
|
|
45
|
+
* 多了一个 `.restoring-` —— 两种都必须报,只加新不留旧(归档残留照样报)。
|
|
43
46
|
*/
|
|
44
|
-
const STRAY_RE = /\.archiving-[0-9a-f]+$/i
|
|
47
|
+
const STRAY_RE = /\.(?:archiving|restoring)-[0-9a-f]+$/i
|
|
45
48
|
|
|
46
49
|
/**
|
|
47
50
|
* 审计**整个跳过**的文件(不读、不当被审对象)。
|
package/lib/client.js
CHANGED
|
@@ -1555,6 +1555,9 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
1555
1555
|
const archivedFileSchema = parseSchema({
|
|
1556
1556
|
"file": isStr, "type": isStr, "description": isStr, "size": isNum,
|
|
1557
1557
|
"archivedAt": isStr, "archivedReason": isStr,
|
|
1558
|
+
// 2.1.6(外部审查 Q07):内容身份(归档代次 + 正文 revision)。它不是装饰 ——
|
|
1559
|
+
// 「彻底删除」必须把它原样交回宿主,否则只能按名字删,而名字不是身份。
|
|
1560
|
+
"revision": isStr,
|
|
1558
1561
|
});
|
|
1559
1562
|
const archivedListSchema = {
|
|
1560
1563
|
parse(v) {
|
|
@@ -1567,9 +1570,11 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
1567
1570
|
// 归档文件的读 / 写(B1b):形状与活跃记忆那套的 readSchema / writeSchema 对称。
|
|
1568
1571
|
const archivedReadSchema = withOptional(parseSchema({ "name": isStr, "content": isStr }), ["revision"]);
|
|
1569
1572
|
const archivedWriteSchema = withOptional(parseSchema({ "ok": isBool, "file": isStr }), ["code", "revision"]);
|
|
1570
|
-
//
|
|
1571
|
-
//
|
|
1573
|
+
// 「彻底删除」的入参:`archivedAt`(展示时间)+ `revision`(内容身份,见 archive.js 的
|
|
1574
|
+
// archivedRevisionOf)。两个都不是装饰 —— 宿主拿它们核对「要删的是不是用户确认过的那一版」;
|
|
1575
|
+
// 只有前者的旧确认在 2.1.6 起会被宿主拒绝(分钟精度的时间不是内容身份)。
|
|
1572
1576
|
const archiveStampSchema = strSchema;
|
|
1577
|
+
const archiveRevisionSchema = { parse(v) { isStr(v); if (v.length === 0) throw new Error("sage-mem: expected revision"); return v; } };
|
|
1573
1578
|
|
|
1574
1579
|
// ── 2.1.2(Q02)写协议:读快照 revision / 写回执的 additive 字段 / 写选项 ──
|
|
1575
1580
|
/**
|
|
@@ -1844,8 +1849,9 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
1844
1849
|
sourceLocation: { "file": "lib/index.js", "line": 1, "column": 1 },
|
|
1845
1850
|
},
|
|
1846
1851
|
{
|
|
1847
|
-
//
|
|
1852
|
+
// 彻底删除一条归档件(不可恢复)。第二、第三个参数是身份判据:宿主拿它们核对
|
|
1848
1853
|
// 「要删的是不是用户确认过的那一版」,传错(比如列表过期)会被拒绝。
|
|
1854
|
+
// `archivedAt` 只是展示时间;`revision` 才是内容身份(2.1.6 起,外部审查 Q07)。
|
|
1849
1855
|
id: "sage-mem#memory/deleteArchived",
|
|
1850
1856
|
service: "memory",
|
|
1851
1857
|
namespace: "memory",
|
|
@@ -1854,6 +1860,7 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
1854
1860
|
parameters: [
|
|
1855
1861
|
{ name: "name", wire: "name", source: "json", codec: { mode: "strict", typeSymbol: "sage-mem/types#ArchivedFileName", schema: strSchema, create: () => strSchema } },
|
|
1856
1862
|
{ name: "archivedAt", wire: "archivedAt", source: "json", codec: { mode: "strict", typeSymbol: "sage-mem/types#ArchiveStamp", schema: archiveStampSchema, create: () => archiveStampSchema } },
|
|
1863
|
+
{ name: "revision", wire: "revision", source: "json", codec: { mode: "strict", typeSymbol: "sage-mem/types#ArchiveRevision", schema: archiveRevisionSchema, create: () => archiveRevisionSchema } },
|
|
1857
1864
|
],
|
|
1858
1865
|
result: { mode: "strict", typeSymbol: "sage-mem/types#ArchiveResult", schema: archiveSchema, create: () => archiveSchema },
|
|
1859
1866
|
sourceLocation: { "file": "lib/index.js", "line": 1, "column": 1 },
|
|
@@ -6247,8 +6254,10 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
|
|
|
6247
6254
|
/**
|
|
6248
6255
|
* **彻底删除**一条归档件(不可恢复)。
|
|
6249
6256
|
*
|
|
6250
|
-
*
|
|
6251
|
-
*
|
|
6257
|
+
* 第二、第三个参数是身份判据:`archivedAt` 是展示时间,`revision` 是**内容身份**
|
|
6258
|
+
* (归档代次 + 正文 revision,2.1.6 起)。宿主拿它们核对「要删的是不是我看过的那一版」——
|
|
6259
|
+
* 只传时间的话,正文被改成同时间戳的另一版、或同一分钟内重新归档,旧确认都会删错。
|
|
6260
|
+
* 二次确认只是交互,服务端的版本核对才是判据。
|
|
6252
6261
|
*/
|
|
6253
6262
|
function deleteArchivedFile(file) {
|
|
6254
6263
|
if (typeof remote.deleteArchived !== "function") {
|
|
@@ -6257,8 +6266,9 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
|
|
|
6257
6266
|
}
|
|
6258
6267
|
const item = archiveItems.filter(function (x) { return x.file === file; })[0];
|
|
6259
6268
|
const stamp = (item && item.archivedAt) || "";
|
|
6269
|
+
const revision = (item && item.revision) || "";
|
|
6260
6270
|
setBusy(true); setError(null); setNotice("");
|
|
6261
|
-
remote.deleteArchived(file, stamp)
|
|
6271
|
+
remote.deleteArchived(file, stamp, revision)
|
|
6262
6272
|
.then(function (r) {
|
|
6263
6273
|
if (r && r.ok === false) { setError(r.error || t("error.deleteRejected")); return; }
|
|
6264
6274
|
setArchDelete(null);
|
package/lib/index.js
CHANGED
|
@@ -24,7 +24,7 @@ import { MEMORY_ARCHIVE_DIR, addArchiveMeta, extractTitle, inspectDir, inspectMe
|
|
|
24
24
|
import { listConflicts, patchConflict, readConflict, removeConflict, summarizeConflict } from './memory/conflicts.js'
|
|
25
25
|
import { invalidateMemory, undoMemoryInvalidation } from './memory/arbitrate.js'
|
|
26
26
|
import { readSnapshot, writeSnapshot } from './memory/write-protocol.js'
|
|
27
|
-
import { archiveMemory, deleteArchivedMemory, restoreMemory } from './memory/archive.js'
|
|
27
|
+
import { archiveMemory, archivedRevisionOf, deleteArchivedMemory, restoreMemory } from './memory/archive.js'
|
|
28
28
|
import { flushAccess, pickArchiveCandidates, readAccessLedger } from './memory/access.js'
|
|
29
29
|
import { warnLog, writeFileAtomic } from './memory/util.js'
|
|
30
30
|
import { readLockSet, setLocked as setLockedImpl } from './memory/lock.js'
|
|
@@ -266,8 +266,12 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
266
266
|
* 列出归档区的全部记忆(面板的「已归档」区)。
|
|
267
267
|
*
|
|
268
268
|
* 与 `listFiles()` 分开、形状也不同:`listFiles` 只列活动记忆、多一个字段都会破坏
|
|
269
|
-
* 界面与断言依赖的形状;归档条目额外带 `archivedAt` / `archivedReason`。
|
|
270
|
-
* 旧版 autodream
|
|
269
|
+
* 界面与断言依赖的形状;归档条目额外带 `archivedAt` / `archivedReason` / `revision`。
|
|
270
|
+
* 旧版 autodream 工具直接移文件、不写留痕,所以前两个字段可能是空串。
|
|
271
|
+
*
|
|
272
|
+
* `revision` 是**内容身份**(2026-10-31 外部审查 Q07):归档代次 + 正文 revision。
|
|
273
|
+
* 删除确认必须把它交回来 —— `archivedAt` 只是展示时间,同一分钟内不可区分,
|
|
274
|
+
* 拿它当版本会让「A 改成同时间戳的 B」和「同分钟重新归档」的旧确认删掉新内容。
|
|
271
275
|
*/
|
|
272
276
|
async listArchived() {
|
|
273
277
|
const files = await scanArchived(this.ctx)
|
|
@@ -282,6 +286,7 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
282
286
|
tags: meta.tags,
|
|
283
287
|
archivedAt: archived.archivedAt,
|
|
284
288
|
archivedReason: archived.archivedReason,
|
|
289
|
+
revision: archivedRevisionOf(f.content),
|
|
285
290
|
}
|
|
286
291
|
})
|
|
287
292
|
return { count: out.length, files: out }
|
|
@@ -354,27 +359,32 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
354
359
|
}
|
|
355
360
|
const given = readArchivedMeta(content)
|
|
356
361
|
let toWrite = content
|
|
357
|
-
|
|
362
|
+
/**
|
|
363
|
+
* 留痕按**盘上那份**补齐:正文被面板改过之后,`archived_at` / `archived_reason` /
|
|
364
|
+
* `archived_rev` 一个都不许丢(2026-10-31 外部审查 Q07 起 `archived_rev` 也是留痕,
|
|
365
|
+
* 丢了它删除确认的内容身份就少了一半)。任何一个缺了才重写整块 frontmatter。
|
|
366
|
+
*/
|
|
367
|
+
if (!given.archivedAt || !given.archivedReason || !given.archivedRev) {
|
|
358
368
|
const onDisk = readArchivedMeta(await readVerified(full, gate, 'utf8'))
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
369
|
+
const at = given.archivedAt || onDisk.archivedAt
|
|
370
|
+
const reason = given.archivedReason || onDisk.archivedReason
|
|
371
|
+
const rev = given.archivedRev || onDisk.archivedRev
|
|
372
|
+
if (at || reason || rev) toWrite = addArchiveMeta(content, reason, at, rev)
|
|
362
373
|
}
|
|
363
374
|
// 2.1.2(Q02):落盘也走写协议 —— 带 opts 时先比盘上版本,对不上就拒绝(正文逐字节不变)。
|
|
364
375
|
return await writeSnapshot(full, toWrite, gate, opts, { ctx: this.ctx, name: safe, kind: 'writeArchived' })
|
|
365
376
|
}
|
|
366
377
|
|
|
367
378
|
/**
|
|
368
|
-
*
|
|
369
|
-
*
|
|
370
|
-
* 与 `deleteFile` 的区别要在代码里就分开:那个删的是活动记忆,这个删的是档案馆里的退役件;
|
|
371
|
-
* 归档「只移不删」的承诺不变 —— 走进档案馆之后,删不删由人单独决定。
|
|
379
|
+
* **彻底删除**一条已归档记忆(不可恢复)。与 `deleteFile` 的区别在代码里就分开:那个删活动记忆,
|
|
380
|
+
* 这个删档案馆里的退役件;归档「只移不删」的承诺不变。
|
|
372
381
|
*
|
|
373
|
-
*
|
|
374
|
-
*
|
|
382
|
+
* 两个身份判据(见 `memory/archive.js` 头注):`archivedAt` 是展示时间(老归档件可空);
|
|
383
|
+
* `revision` 是**内容身份**(归档代次 + 正文 revision,`listArchived` 给的那份)。2026-10-31
|
|
384
|
+
* 外部审查 Q07:没有它就必须拒绝 —— `archived_at` 是分钟精度的时间,拿它当版本会让旧确认删新内容。
|
|
375
385
|
*/
|
|
376
|
-
async deleteArchived(name, archivedAt) {
|
|
377
|
-
const res = await deleteArchivedMemory(MEMORY_DIR, name, archivedAt)
|
|
386
|
+
async deleteArchived(name, archivedAt, revision) {
|
|
387
|
+
const res = await deleteArchivedMemory(MEMORY_DIR, name, archivedAt, revision)
|
|
378
388
|
if (!res.ok) return { ok: false, error: res.error }
|
|
379
389
|
return { ok: true, file: res.file }
|
|
380
390
|
}
|
package/lib/memory/archive.js
CHANGED
|
@@ -17,10 +17,11 @@
|
|
|
17
17
|
|
|
18
18
|
import { ARCHIVE_DIR_NAME, addArchiveMeta, inspectDir, inspectMemoryEntry, localStamp, readArchivedMeta, stripArchiveMeta } from './scan.js'
|
|
19
19
|
import { isReserved, nameKey, safeName } from './naming.js'
|
|
20
|
-
import {
|
|
20
|
+
import { revisionOf } from './chunks.js'
|
|
21
|
+
import { retireTaken, returnSource, takeOverSource, writeFileAtomic, writeFileExclusive } from './util.js'
|
|
21
22
|
import { isLocked } from './lock.js'
|
|
22
23
|
import { ensureIndexEntry, dropIndexEntry } from './index-file.js'
|
|
23
|
-
import {
|
|
24
|
+
import { mkdir, readFile, rm, stat } from 'node:fs/promises'
|
|
24
25
|
import { basename, join } from 'node:path'
|
|
25
26
|
import { randomUUID } from 'node:crypto'
|
|
26
27
|
|
|
@@ -91,7 +92,14 @@ export async function archiveMemory(memoryDir, name, reason, at = localStamp())
|
|
|
91
92
|
const content = sourceBytes.toString('utf8')
|
|
92
93
|
// 空理由给默认值:手动 remote 允许省略(工具那侧自己会先挡掉空理由,那是它自己的语义)。
|
|
93
94
|
const why = String(reason ?? '').trim() || '手动归档'
|
|
94
|
-
|
|
95
|
+
/**
|
|
96
|
+
* **归档代次**(2026-10-31 外部审查 Q07):每次归档新生成的一次性随机标记,写进
|
|
97
|
+
* frontmatter 的 `archived_rev`。它解决的是「`archived_at` 是展示时间、不是身份」:
|
|
98
|
+
* 同一分钟内「恢复 → 再归档」会复用同一个时间戳,拿旧确认去删就会删掉新归档的那一版。
|
|
99
|
+
* 内容身份另有一半 —— 见本文件下方的 `archivedRevisionOf`。
|
|
100
|
+
*/
|
|
101
|
+
const rev = randomUUID().replace(/-/g, '').slice(0, 16)
|
|
102
|
+
const stamped = addArchiveMeta(content, why, at, rev)
|
|
95
103
|
await mkdir(archiveDirOf(memoryDir), { recursive: true })
|
|
96
104
|
/**
|
|
97
105
|
* 先落归档副本、再删原件:中途崩了最坏是两份都在(人工可收拾),不会一份都没有。
|
|
@@ -107,7 +115,9 @@ export async function archiveMemory(memoryDir, name, reason, at = localStamp())
|
|
|
107
115
|
throw err
|
|
108
116
|
}
|
|
109
117
|
/**
|
|
110
|
-
* ⚠️
|
|
118
|
+
* ⚠️ 收走原件这一步交给 **`takeOverSource`**(`rename` 到私有名 → 逐字节复核 → 还回 /
|
|
119
|
+
* 退役),不是 `unlink`(复审 R06 场景①;2026-10-31 外部审查 Q06 起归档与恢复共用
|
|
120
|
+
* 同一份事务移动原语,见 `memory/util.js`)。
|
|
111
121
|
*
|
|
112
122
|
* 从上面那次 `inspectMemoryEntry(src)` 到现在,中间隔着读文件 + 写归档副本两次 IO;
|
|
113
123
|
* 这期间若有人(面板保存、另一个进程、模型工具)把 `a.md` 写成了新版本,
|
|
@@ -125,18 +135,14 @@ export async function archiveMemory(memoryDir, name, reason, at = localStamp())
|
|
|
125
135
|
* 于是**新版本被当成旧版本收走**,顶层与归档件里都不再有它。`ino` 在拿不到 inode 的卷上
|
|
126
136
|
* 还会退化成 `null === null` 恒真,把判据进一步削弱成「体积 + 时间」。
|
|
127
137
|
* 内容比对是精确判据,而且成本可忽略(归档本来就要把这份内容读进内存)。
|
|
128
|
-
* · 与读到的字节完全相同 →
|
|
129
|
-
* · 不同(或读不回来)→
|
|
138
|
+
* · 与读到的字节完全相同 → 就是当初那一份,归档完成,退役私有名;
|
|
139
|
+
* · 不同(或读不回来)→ 是别人的新版本,原语用 `link()` 还回原名,放弃本次归档。
|
|
130
140
|
*
|
|
131
141
|
* 崩在中途的代价说清楚:私有名文件会留在记忆目录里(不是 `.md`,扫描与注入都不认它),
|
|
132
|
-
* 而归档副本已经写好 ——
|
|
142
|
+
* 而归档副本已经写好 —— 不会出现「一份都没有」;体检会把 `.archiving-` 残留报出来。
|
|
133
143
|
*/
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
() => null,
|
|
137
|
-
(err) => err,
|
|
138
|
-
)
|
|
139
|
-
if (moveErr) {
|
|
144
|
+
const takenRes = await takeOverSource(src, sourceBytes, 'archiving')
|
|
145
|
+
if (!takenRes.ok) {
|
|
140
146
|
/**
|
|
141
147
|
* ⚠️ `rename` 失败**不等于**「源不见了」(复审复核实测,真机 Windows):
|
|
142
148
|
* 另一进程只读占用(编辑器 / 杀软 / 同步工具)时 `rename` 报 **EBUSY** 而不是 ENOENT,
|
|
@@ -144,45 +150,37 @@ export async function archiveMemory(memoryDir, name, reason, at = localStamp())
|
|
|
144
150
|
* 「错误:没有这个文件」—— 诊断是错的,人会去别处找。按真实错误码分流。
|
|
145
151
|
*/
|
|
146
152
|
await rm(dst, { force: true }).catch(() => {})
|
|
147
|
-
if (
|
|
153
|
+
if (takenRes.code === 'not-found') {
|
|
148
154
|
return { ok: false, code: 'not-found', error: `sage-mem: file not found: ${safe}(归档途中源文件消失了)` }
|
|
149
155
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
156
|
+
if (takenRes.code === 'move-failed') {
|
|
157
|
+
const moveErr = takenRes.cause
|
|
158
|
+
return {
|
|
159
|
+
ok: false,
|
|
160
|
+
code: 'move-failed',
|
|
161
|
+
error: `sage-mem: 归档 ${safe} 时移不动它(${moveErr?.code ?? moveErr}:${moveErr?.message ?? moveErr})。文件仍在原处、一个字节没动,本次归档未完成 —— 可稍后重试(若是被其它程序占用,关掉它再来)。`,
|
|
162
|
+
}
|
|
154
163
|
}
|
|
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) {
|
|
164
|
+
if (takenRes.keptAt) {
|
|
166
165
|
/**
|
|
167
166
|
* ⚠️ 归还失败时**绝不能删 `taken`**:它是这份新版本**唯一**的落身处
|
|
168
167
|
* (原名此刻已被别人占着,link 才报的 EEXIST)。删掉它就是真的丢数据。
|
|
169
|
-
*
|
|
170
|
-
* 后缀,不是 `.md`,扫描 / 注入 /
|
|
168
|
+
* 原语把它原样留下,这里把路径如实写进错误里让人去捞 —— 名字带 `.archiving-`
|
|
169
|
+
* 后缀,不是 `.md`,扫描 / 注入 / 星图都不会把它当成一条记忆,体检会报出来。
|
|
171
170
|
*/
|
|
172
171
|
return {
|
|
173
172
|
ok: false,
|
|
174
173
|
code: 'changed',
|
|
175
|
-
error: `sage-mem: ${safe} 在归档过程中被改写,已放弃本次归档;归还时原名已被占用,**内容仍保留在 ${basename(
|
|
174
|
+
error: `sage-mem: ${safe} 在归档过程中被改写,已放弃本次归档;归还时原名已被占用,**内容仍保留在 ${basename(takenRes.keptAt)}**,请手工改回 ${safe}`,
|
|
176
175
|
}
|
|
177
176
|
}
|
|
178
|
-
await rm(taken, { force: true }).catch(() => {})
|
|
179
177
|
return {
|
|
180
178
|
ok: false,
|
|
181
179
|
code: 'changed',
|
|
182
180
|
error: `sage-mem: ${safe} 在归档过程中被改写,已放弃本次归档(新版本保留在原处,请重试)`,
|
|
183
181
|
}
|
|
184
182
|
}
|
|
185
|
-
await
|
|
183
|
+
await retireTaken(takenRes.taken)
|
|
186
184
|
/**
|
|
187
185
|
* **从索引里摘掉**(与恢复时补索引对称):索引是「活动记忆的清单」,
|
|
188
186
|
* 记忆归档了就该从清单里退出 —— 不摘的话审计会报「索引悬空」。
|
|
@@ -245,7 +243,54 @@ export async function restoreMemory(memoryDir, name) {
|
|
|
245
243
|
if (dstGate.code !== 'missing') {
|
|
246
244
|
return { ok: false, code: dstGate.code, error: `sage-mem: refusing to restore over ${safe}: ${dstGate.error}` }
|
|
247
245
|
}
|
|
248
|
-
const
|
|
246
|
+
const sourceBytes = await readFile(src)
|
|
247
|
+
const content = sourceBytes.toString('utf8')
|
|
248
|
+
/**
|
|
249
|
+
* **事务接管源 + 目标排他落位**(2026-10-31 外部审查 Q06)。
|
|
250
|
+
*
|
|
251
|
+
* 旧写法是「先把 A 排他落位到顶层,再 `unlink(归档源)`」:落位确实保护了目标
|
|
252
|
+
* (复审 R06 场景②),但最后那步按名删除**没有绑定读取时的源版本** —— 落位与 unlink
|
|
253
|
+
* 之间另一路把归档源改写成 B,`unlink(名字)` 删的就是 B,新版本从两个位置一起消失,
|
|
254
|
+
* 而函数报 `ok:true`。
|
|
255
|
+
*
|
|
256
|
+
* 现在顺序反过来,用与归档同一份原语(`memory/util.js` 的 `takeOverSource`):
|
|
257
|
+
* ① 先把源原子 `rename` 成私有名,并逐字节核对它仍是当初读到的那一份;
|
|
258
|
+
* ② 内容对不上 → 原语把 B 还回原名,本次放弃(一个字节都不丢);
|
|
259
|
+
* ③ 排他落位目标(`writeFileExclusive`:目标冒出来就 EEXIST,绝不覆盖);
|
|
260
|
+
* ④ 落位失败 → 把接管的那份**原样还回**归档区;还不动时留在私有名上并点名;
|
|
261
|
+
* ⑤ 落位成功后才退役**私有副本** —— 不碰「当前叫 archive/a.md 的那个文件」,
|
|
262
|
+
* 所以落位之后被写进来的 B 会老老实实留在归档区。
|
|
263
|
+
*
|
|
264
|
+
* 崩在中途的代价:`.restoring-` 私有副本会留在归档区(不是 `.md`,扫描 / 注入都不认),
|
|
265
|
+
* 体检会把它报出来 —— 至少有一份可定位、可恢复的原文。
|
|
266
|
+
*/
|
|
267
|
+
const takenRes = await takeOverSource(src, sourceBytes, 'restoring')
|
|
268
|
+
if (!takenRes.ok) {
|
|
269
|
+
if (takenRes.code === 'not-found') {
|
|
270
|
+
return { ok: false, code: 'not-archived', error: `sage-mem: not archived: ${safe}(恢复途中源文件消失了)` }
|
|
271
|
+
}
|
|
272
|
+
if (takenRes.code === 'move-failed') {
|
|
273
|
+
const moveErr = takenRes.cause
|
|
274
|
+
return {
|
|
275
|
+
ok: false,
|
|
276
|
+
code: 'move-failed',
|
|
277
|
+
error: `sage-mem: 恢复 ${safe} 时移不动它(${moveErr?.code ?? moveErr}:${moveErr?.message ?? moveErr})。文件仍在 archive/ 里、一个字节没动 —— 可稍后重试(若是被其它程序占用,关掉它再来)。`,
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (takenRes.keptAt) {
|
|
281
|
+
// 核对时发现是别人的新版本,而原名已被再次占住 —— 新版本留在私有名上,点名给人捞。
|
|
282
|
+
return {
|
|
283
|
+
ok: false,
|
|
284
|
+
code: 'changed',
|
|
285
|
+
error: `sage-mem: ${safe} 在恢复过程中被改写,已放弃本次恢复;归还时原名已被占用,**内容(新版本)仍保留在 archive/${basename(takenRes.keptAt)}**,请手工改回 ${safe} 再试。`,
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return {
|
|
289
|
+
ok: false,
|
|
290
|
+
code: 'changed',
|
|
291
|
+
error: `sage-mem: ${safe} 在恢复过程中被改写,已放弃本次恢复(新版本保留在归档区原处,请重试)`,
|
|
292
|
+
}
|
|
293
|
+
}
|
|
249
294
|
/**
|
|
250
295
|
* **排他落位**(复审 R06 场景②):上面那句 `dstGate` 检查通过 ≠ 目标此刻仍不存在 ——
|
|
251
296
|
* 检查与写入之间,用户完全可能在顶层新建一条同名记忆(实测用 IPC 固定交错即可复现)。
|
|
@@ -256,12 +301,21 @@ export async function restoreMemory(memoryDir, name) {
|
|
|
256
301
|
try {
|
|
257
302
|
await writeFileExclusive(dst, stripArchiveMeta(content))
|
|
258
303
|
} catch (err) {
|
|
304
|
+
// 落位没成:接管的归档件必须先还回去 —— 它此刻是这个名字下唯一的一份内容。
|
|
305
|
+
const back = await returnSource(takenRes.taken, src)
|
|
306
|
+
if (!back.ok) {
|
|
307
|
+
return {
|
|
308
|
+
ok: false,
|
|
309
|
+
code: 'restore-failed',
|
|
310
|
+
error: `sage-mem: 恢复 ${safe} 落位失败(${err?.code ?? err}),且归还归档区原名时被占用 —— **内容仍保留在 archive/${basename(back.keptAt)}**,请手工改回 ${safe} 再试。`,
|
|
311
|
+
}
|
|
312
|
+
}
|
|
259
313
|
if (err?.code === 'EEXIST') {
|
|
260
314
|
return { ok: false, code: 'target-exists', error: `sage-mem: already exists, refusing to overwrite: ${safe}` }
|
|
261
315
|
}
|
|
262
316
|
throw err
|
|
263
317
|
}
|
|
264
|
-
await
|
|
318
|
+
await retireTaken(takenRes.taken)
|
|
265
319
|
/**
|
|
266
320
|
* **注册回索引**:移回文件不等于这条记忆又存在了 —— 索引里没有它,
|
|
267
321
|
* 面板与检索都看不见,体检会报「漏索引」(真实事故见 lib/memory/index-file.js 的头注)。
|
|
@@ -291,6 +345,31 @@ export async function restoreMemory(memoryDir, name) {
|
|
|
291
345
|
}
|
|
292
346
|
}
|
|
293
347
|
|
|
348
|
+
/**
|
|
349
|
+
* **归档内容的身份**:一次性归档代次 + 当前正文的内容 revision。
|
|
350
|
+
*
|
|
351
|
+
* 为什么必须换成它(2026-10-31 外部审查 Q07):`archived_at` 只是**展示时间**
|
|
352
|
+
* (`YYYY-MM-DD HH:mm`,同一分钟内不可区分),既不是内容身份、也不可复用 ——
|
|
353
|
+
* 归档 A 之后把正文改成 B 而 `archived_at` 不变,旧确认照样把 B 删掉;
|
|
354
|
+
* 同一分钟内「恢复 → 再归档」也会复用同一个时间戳。
|
|
355
|
+
*
|
|
356
|
+
* 身份由两半组成,缺一不可:
|
|
357
|
+
* · `archived_rev`(每次归档新生成的随机标记)→ 同分钟重新归档也绝不复用;
|
|
358
|
+
* · `revisionOf(去掉留痕的正文)`(sha256 前 12 位)→ 正文被改过(哪怕代次没变)立刻失配。
|
|
359
|
+
* 两半都在**盘上的字节**上算,调用方只能交回「它看见的那一份」的身份 ——
|
|
360
|
+
* 服务端拿它核对,界面上的二次确认只是交互,不是判据。
|
|
361
|
+
*
|
|
362
|
+
* 老数据没有 `archived_rev` 这一行时前半为空串;正文 revision 仍然把它钉在具体内容上。
|
|
363
|
+
*
|
|
364
|
+
* @param {string} content — 归档文件的全文(含 frontmatter)
|
|
365
|
+
* @returns {string} `<代次>@<内容 revision>`,如 `3f9ac1de4b7a2015@sha256:0123456789ab`
|
|
366
|
+
*/
|
|
367
|
+
export function archivedRevisionOf(content) {
|
|
368
|
+
const src = String(content ?? '')
|
|
369
|
+
const meta = readArchivedMeta(src)
|
|
370
|
+
return `${meta.archivedRev || ''}@${revisionOf(stripArchiveMeta(src))}`
|
|
371
|
+
}
|
|
372
|
+
|
|
294
373
|
/**
|
|
295
374
|
* 从归档区**彻底删除**一条记忆(不可恢复)。
|
|
296
375
|
*
|
|
@@ -301,22 +380,28 @@ export async function restoreMemory(memoryDir, name) {
|
|
|
301
380
|
* 三道判据,缺一不可:
|
|
302
381
|
* 1. **归档区的目录闸门**:它若被换成指向外部的联接,删掉的是别人的文件;
|
|
303
382
|
* 2. **条目闸门**:链接 / 硬链接指向的可能是记忆目录之外的内容,删它等于替别人删文件;
|
|
304
|
-
* 3.
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
383
|
+
* 3. **内容身份(revision = 归档代次 + 正文 revision)**:名字不是身份,`archived_at`
|
|
384
|
+
* 也不是(2026-10-31 外部审查 Q07)。用户从列表看到确认框、到请求到达之间,
|
|
385
|
+
* 这条完全可能被恢复回顶层、又被同一分钟内的另一版同名记忆归档进来;
|
|
386
|
+
* 归档正文也可能被改成了 B 而 `archived_at` 一个字没变。所以调用方必须交回
|
|
387
|
+
* 它当时看见的 `archived_at` **和** `revision`,服务端对着**接管到手的实物**
|
|
388
|
+
* 重新算一遍内容身份,对不上就拒绝。界面上的二次确认只是交互,不是判据。
|
|
308
389
|
*
|
|
309
|
-
* ⚠️
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
390
|
+
* ⚠️ 删除本身是「**先接管、再核对、最后删私有名**」:
|
|
391
|
+
* 旧写法读完留痕比对通过后才 `rename(full, taken)`,两次调用之间另一路把归档件换成
|
|
392
|
+
* 另一版时,rename 移走的正是那一版、随后 rm 删掉,还返回 ok:true。
|
|
393
|
+
* 现在用与归档/恢复同一份原语 `takeOverSource`:先把盘上那份原子接管成 `.deleting-`
|
|
394
|
+
* 私有名(顺带核对它仍是刚读到的字节),再对**接管到的实物**算身份比对 ——
|
|
395
|
+
* 比对与删除之间不再有名字窗口。核对不通过时 `returnSource` 把原文还回 archive/,
|
|
396
|
+
* 还不动就留在私有名上并点名(内容绝不丢)。
|
|
313
397
|
*
|
|
314
398
|
* @param {string} memoryDir — 记忆根目录(调用方给,不许拿模块常量)
|
|
315
399
|
* @param {string} name — 归档区里的文件名
|
|
316
|
-
* @param {string} expectedArchivedAt — 调用方看见的 `archived_at
|
|
400
|
+
* @param {string} expectedArchivedAt — 调用方看见的 `archived_at`(老数据可能为空串)
|
|
401
|
+
* @param {string} expectedRevision — 调用方看见的 `revision`(`archivedRevisionOf` 的值)
|
|
317
402
|
* @returns {Promise<{ok:true,file:string}|{ok:false,code:string,error:string}>}
|
|
318
403
|
*/
|
|
319
|
-
export async function deleteArchivedMemory(memoryDir, name, expectedArchivedAt = '') {
|
|
404
|
+
export async function deleteArchivedMemory(memoryDir, name, expectedArchivedAt = '', expectedRevision = '') {
|
|
320
405
|
const gate = checkName(name, 'delete')
|
|
321
406
|
if (!gate.ok) return gate
|
|
322
407
|
const safe = gate.safe
|
|
@@ -337,52 +422,91 @@ export async function deleteArchivedMemory(memoryDir, name, expectedArchivedAt =
|
|
|
337
422
|
error: `sage-mem: not archived: ${safe}(${srcGate.error})`,
|
|
338
423
|
}
|
|
339
424
|
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
425
|
+
/**
|
|
426
|
+
* **先接管、再核对**:判据只能来自**接管到手的实物**,不能信调用方一路传下来的展示值,
|
|
427
|
+
* 也不能在「比对 → rename」之间留窗口(审查脚本正是在那段窗口里把文件换成 B 的)。
|
|
428
|
+
*/
|
|
429
|
+
const sourceBytes = await readFile(full)
|
|
430
|
+
const takenRes = await takeOverSource(full, sourceBytes, 'deleting')
|
|
431
|
+
if (!takenRes.ok) {
|
|
432
|
+
if (takenRes.code === 'not-found') {
|
|
433
|
+
return { ok: false, code: 'not-archived', error: `sage-mem: not archived: ${safe}(删除途中它从归档区消失了)` }
|
|
434
|
+
}
|
|
435
|
+
if (takenRes.code === 'move-failed') {
|
|
436
|
+
const moveErr = takenRes.cause
|
|
437
|
+
return {
|
|
438
|
+
ok: false,
|
|
439
|
+
code: 'move-failed',
|
|
440
|
+
error: `sage-mem: 删除 ${safe} 时移不动它(${moveErr?.code ?? moveErr}:${moveErr?.message ?? moveErr})。文件仍在 archive/ 里、一个字节没动 —— 可稍后重试(若是被其它程序占用,关掉它再来)。`,
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
// 接管时发现内容已经不是刚读到的那份:原语会把它还回原名(还不动则留私有名)。
|
|
444
|
+
if (takenRes.keptAt) {
|
|
445
|
+
return {
|
|
446
|
+
ok: false,
|
|
447
|
+
code: 'changed',
|
|
448
|
+
error: `sage-mem: ${safe} 在删除过程中被换成了另一版,已放弃本次删除;归还原名时被占用 —— **内容仍保留在 archive/${basename(takenRes.keptAt)}**,请手工改回 ${safe}。`,
|
|
449
|
+
}
|
|
450
|
+
}
|
|
344
451
|
return {
|
|
345
452
|
ok: false,
|
|
346
453
|
code: 'changed',
|
|
347
|
-
error:
|
|
348
|
-
`sage-mem: ${safe} 在确认之后被换成了另一版(界面上是「${expected || '时间未知'}」,` +
|
|
349
|
-
`盘上是「${onDisk.archivedAt || '时间未知'}」),本次一个字节都没删 —— 请刷新列表再看一眼。`,
|
|
454
|
+
error: `sage-mem: ${safe} 在删除过程中被换成了另一版,本次一个字节都没删 —— 请刷新列表再看一眼。`,
|
|
350
455
|
}
|
|
351
456
|
}
|
|
352
|
-
const taken =
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
)
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
457
|
+
const taken = takenRes.taken
|
|
458
|
+
const takenContent = await readFile(taken, 'utf8')
|
|
459
|
+
const onDisk = readArchivedMeta(takenContent)
|
|
460
|
+
const currentRev = archivedRevisionOf(takenContent)
|
|
461
|
+
const expectedAt = String(expectedArchivedAt ?? '')
|
|
462
|
+
const expectedRev = String(expectedRevision ?? '')
|
|
463
|
+
|
|
464
|
+
/** 拒绝并把接管的原文**还回 archive/**;还不动时把路径带进错误里(内容绝不丢)。 */
|
|
465
|
+
const rejectAndReturn = async (code, head) => {
|
|
466
|
+
const back = await returnSource(taken, full)
|
|
467
|
+
if (!back.ok) {
|
|
468
|
+
return {
|
|
469
|
+
ok: false,
|
|
470
|
+
code,
|
|
471
|
+
error: `${head};归还原名时被占用 —— **内容仍保留在 archive/${basename(back.keptAt)}**,请手工改回 ${safe}。`,
|
|
472
|
+
}
|
|
365
473
|
}
|
|
474
|
+
return { ok: false, code, error: head }
|
|
366
475
|
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* 没带内容身份的老界面 / 老调用:**不许删**。放行它就等于退回「按名字删」——
|
|
479
|
+
* 用户确认过的到底是哪一版,服务端无从核对。
|
|
480
|
+
*/
|
|
481
|
+
if (expectedRev === '') {
|
|
482
|
+
return rejectAndReturn(
|
|
483
|
+
'stale-confirm',
|
|
484
|
+
`sage-mem: ${safe} 的删除确认没有带内容代次(revision),宿主无法核对「删的是不是你看过的那一版」,本次一个字节都没删 —— 请刷新列表后重试(旧版面板需要升级)`,
|
|
485
|
+
)
|
|
486
|
+
}
|
|
487
|
+
if (expectedAt !== onDisk.archivedAt || expectedRev !== currentRev) {
|
|
488
|
+
return rejectAndReturn(
|
|
489
|
+
'changed',
|
|
490
|
+
`sage-mem: ${safe} 在确认之后被换成了另一版(界面上是「${expectedAt || '时间未知'}」/「${expectedRev}」,` +
|
|
491
|
+
`盘上是「${onDisk.archivedAt || '时间未知'}」/「${currentRev}」),本次一个字节都没删 —— 请刷新列表再看一眼`,
|
|
492
|
+
)
|
|
493
|
+
}
|
|
494
|
+
/** 到这里才删:删的是**核对过内容身份的私有副本**(名字底下的东西早就被接管走了)。 */
|
|
367
495
|
const delErr = await rm(taken, { force: true }).then(
|
|
368
496
|
() => null,
|
|
369
497
|
(err) => err,
|
|
370
498
|
)
|
|
371
499
|
if (delErr) {
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
() => false,
|
|
375
|
-
)
|
|
376
|
-
if (!restored) {
|
|
500
|
+
const back = await returnSource(taken, full)
|
|
501
|
+
if (!back.ok) {
|
|
377
502
|
return {
|
|
378
503
|
ok: false,
|
|
379
504
|
code: 'delete-failed',
|
|
380
505
|
error:
|
|
381
506
|
`sage-mem: 删除 ${safe} 失败(${delErr?.code ?? delErr}),且归还原名时被占用 —— ` +
|
|
382
|
-
`**内容仍保留在 archive/${basename(
|
|
507
|
+
`**内容仍保留在 archive/${basename(back.keptAt)}**,请手工改回 ${safe} 再试。`,
|
|
383
508
|
}
|
|
384
509
|
}
|
|
385
|
-
await rm(taken, { force: true }).catch(() => {})
|
|
386
510
|
return {
|
|
387
511
|
ok: false,
|
|
388
512
|
code: 'delete-failed',
|
package/lib/memory/scan.js
CHANGED
|
@@ -65,18 +65,25 @@ function unquoteYaml(raw) {
|
|
|
65
65
|
return s
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
/**
|
|
68
|
+
/**
|
|
69
|
+
* 读 frontmatter 里的归档留痕。没有 frontmatter / 没有这些字段都回空串。
|
|
70
|
+
*
|
|
71
|
+
* `archivedRev` 是 2026-10-31 外部审查 Q07 补的**归档代次**:一次性随机标记,
|
|
72
|
+
* 每次归档新生成。`archived_at` 只是展示时间(`YYYY-MM-DD HH:mm`,同一分钟内不可区分),
|
|
73
|
+
* 既不是内容身份也不可复用 —— 删除确认必须带代次 + 内容 revision,见
|
|
74
|
+
* `memory/archive.js` 的 `archivedRevisionOf`。老数据没有这一行时回空串。
|
|
75
|
+
*/
|
|
69
76
|
export function readArchivedMeta(content) {
|
|
70
77
|
const m = String(content ?? '').match(FRONTMATTER_RE)
|
|
71
|
-
if (!m) return { archivedAt: '', archivedReason: '' }
|
|
78
|
+
if (!m) return { archivedAt: '', archivedReason: '', archivedRev: '' }
|
|
72
79
|
const grab = (key) => {
|
|
73
80
|
const hit = m[1].match(new RegExp(`^${key}:[ \\t]*(.*?)[ \\t\\r]*$`, 'm'))
|
|
74
81
|
return hit ? unquoteYaml(hit[1]) : ''
|
|
75
82
|
}
|
|
76
|
-
return { archivedAt: grab('archived_at'), archivedReason: grab('archived_reason') }
|
|
83
|
+
return { archivedAt: grab('archived_at'), archivedReason: grab('archived_reason'), archivedRev: grab('archived_rev') }
|
|
77
84
|
}
|
|
78
85
|
|
|
79
|
-
/**
|
|
86
|
+
/** 去掉归档留痕(恢复时用)。没有 frontmatter 就原样返回。 */
|
|
80
87
|
export function stripArchiveMeta(content) {
|
|
81
88
|
const src = String(content ?? '')
|
|
82
89
|
const m = src.match(FRONTMATTER_RE)
|
|
@@ -84,22 +91,30 @@ export function stripArchiveMeta(content) {
|
|
|
84
91
|
const eol = eolOf(src)
|
|
85
92
|
const body = m[1]
|
|
86
93
|
.split(/\r?\n/)
|
|
87
|
-
.filter((line) => !/^[ \t]*archived_(at|reason)[ \t]*:/.test(line))
|
|
94
|
+
.filter((line) => !/^[ \t]*archived_(at|reason|rev)[ \t]*:/.test(line))
|
|
88
95
|
const block = ['---', ...body, '---'].join(eol)
|
|
89
96
|
return block + src.slice(m.index + m[0].length)
|
|
90
97
|
}
|
|
91
98
|
|
|
92
99
|
/**
|
|
93
|
-
* 写入归档留痕:`archived_at` + `archived_reason
|
|
100
|
+
* 写入归档留痕:`archived_at` + `archived_reason`(+ 可选 `archived_rev`)插在
|
|
101
|
+
* frontmatter 末尾。
|
|
94
102
|
*
|
|
95
103
|
* 已经是归档态的文件再归档没有意义(上层会先拒绝),所以这里只做 upsert:
|
|
96
|
-
*
|
|
104
|
+
* 先剥掉旧的行再插,免得同一份文件里出现两个 `archived_at`。
|
|
97
105
|
* 没有 frontmatter 的文件补一个最小块 —— 归档信息必须有地方放。
|
|
106
|
+
*
|
|
107
|
+
* @param {string} content — 正文
|
|
108
|
+
* @param {string} reason — 归档缘由
|
|
109
|
+
* @param {string} at — 展示时间 `YYYY-MM-DD HH:mm`
|
|
110
|
+
* @param {string} [rev] — 归档代次(一次性;省略时不写这一行,老调用与老数据保持原样)
|
|
98
111
|
*/
|
|
99
|
-
export function addArchiveMeta(content, reason, at) {
|
|
112
|
+
export function addArchiveMeta(content, reason, at, rev = '') {
|
|
100
113
|
const src = String(content ?? '')
|
|
101
114
|
const eol = eolOf(src)
|
|
102
115
|
const lines = [`archived_at: ${yamlScalar(at)}`, `archived_reason: ${yamlScalar(reason)}`]
|
|
116
|
+
const gen = String(rev ?? '')
|
|
117
|
+
if (gen) lines.push(`archived_rev: ${yamlScalar(gen)}`)
|
|
103
118
|
const stripped = stripArchiveMeta(src)
|
|
104
119
|
const m = stripped.match(FRONTMATTER_RE)
|
|
105
120
|
if (!m) return ['---', ...lines, '---', '', stripped].join(eol)
|
package/lib/memory/util.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* sage-mem 记忆层 —— 通用小工具:统一日志出口与原子写。
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { randomUUID } from 'node:crypto'
|
|
6
|
+
import { link, open, readFile, rename, rm, unlink, writeFile } from 'node:fs/promises'
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* 统一告警出口。所有非 ENOENT 的失败都必须落到日志,绝不静默
|
|
@@ -124,6 +125,77 @@ export async function writeFileExclusive(target, content) {
|
|
|
124
125
|
return { exclusive }
|
|
125
126
|
}
|
|
126
127
|
|
|
128
|
+
/**
|
|
129
|
+
* **事务式接管源文件**:把源 `rename` 到只有本次调用知道的私有名,再逐字节核对
|
|
130
|
+
* 它是不是调用方读到的那些字节。
|
|
131
|
+
*
|
|
132
|
+
* 为什么需要它(2026-10-31 外部审查 Q06 / 复审 R06):`unlink(名字)` / `rm(名字)`
|
|
133
|
+
* 只认名字 —— 从「读到内容」到「动手删」之间隔着若干次 IO,别人完全可能在这段时间里
|
|
134
|
+
* 把那个名字换成新版本,按名删除就会把**从未被收走过的新版本**从唯一的位置抹掉。
|
|
135
|
+
* `rename` 是原子的:先把源移到私有名上,再对**移走的那份实物**核对内容,
|
|
136
|
+
* 「名字」与「身份」就解耦了(与 `autodream/gate.js`「抢占过期锁 + 复核 + 还回」同一套手法)。
|
|
137
|
+
*
|
|
138
|
+
* ⚠️ 判据必须是**内容**,不能是 `dev:ino + size + mtime`(复审复核实测:原地截断改写 +
|
|
139
|
+
* 体积刚好相同 + 回写 mtime 会让三元组全部命中,新版本被当成旧版本收走)。内容比对
|
|
140
|
+
* 成本可忽略 —— 归档 / 恢复本来就要把这份内容读进内存。
|
|
141
|
+
*
|
|
142
|
+
* 失败时**绝不直接丢掉**这份内容:
|
|
143
|
+
* - 内容对不上 → 用 `link()` 还回原名;原名已被别人占住时,把它留在私有名上并带回路径;
|
|
144
|
+
* - 移不动 → 源还在原处、一个字节没动(按真实错误码分流,别谎称「源不存在」)。
|
|
145
|
+
*
|
|
146
|
+
* @param {string} from — 源路径
|
|
147
|
+
* @param {Buffer} expected — 调用方读到的字节
|
|
148
|
+
* @param {string} tag — 私有名后缀(`archiving` / `restoring`);体检按它认崩溃残留
|
|
149
|
+
* @returns {Promise<
|
|
150
|
+
* {ok:true, taken:string} |
|
|
151
|
+
* {ok:false, code:'not-found'|'move-failed'|'changed', cause?:Error, keptAt?:string}
|
|
152
|
+
* >}
|
|
153
|
+
*/
|
|
154
|
+
export async function takeOverSource(from, expected, tag) {
|
|
155
|
+
const taken = `${from}.${tag}-${randomUUID().replace(/-/g, '')}`
|
|
156
|
+
const moveErr = await rename(from, taken).then(
|
|
157
|
+
() => null,
|
|
158
|
+
(err) => err,
|
|
159
|
+
)
|
|
160
|
+
if (moveErr) {
|
|
161
|
+
return { ok: false, code: moveErr?.code === 'ENOENT' ? 'not-found' : 'move-failed', cause: moveErr }
|
|
162
|
+
}
|
|
163
|
+
const current = await readFile(taken).catch(() => null)
|
|
164
|
+
if (current !== null && Buffer.compare(current, expected) === 0) return { ok: true, taken }
|
|
165
|
+
// 移走的是**别人**的新版本:还回去,一个字节都不动。
|
|
166
|
+
const back = await returnSource(taken, from)
|
|
167
|
+
return { ok: false, code: 'changed', ...(back.ok ? {} : { keptAt: back.keptAt }) }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* 把 `takeOverSource` 接管的那份**原样**还回原名。
|
|
172
|
+
*
|
|
173
|
+
* 还成功时删掉私有副本;原名已被别人占住(`link` 报 EEXIST)或其它原因还不动时,
|
|
174
|
+
* **绝不删它** —— 它是这份内容唯一的落身处,留在私有名上让人去捞。
|
|
175
|
+
*
|
|
176
|
+
* @returns {Promise<{ok:true}|{ok:false,keptAt:string}>}
|
|
177
|
+
*/
|
|
178
|
+
export async function returnSource(taken, to) {
|
|
179
|
+
const linked = await link(taken, to).then(
|
|
180
|
+
() => true,
|
|
181
|
+
() => false,
|
|
182
|
+
)
|
|
183
|
+
if (!linked) return { ok: false, keptAt: taken }
|
|
184
|
+
await rm(taken, { force: true }).catch(() => {})
|
|
185
|
+
return { ok: true }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* 退役一份已经被**成功接管并落位**的私有副本。
|
|
190
|
+
*
|
|
191
|
+
* 调用者必须已经确认落位目标拿到了同一份内容:这里删的是私有名,不是任何可能
|
|
192
|
+
* 已经被别人改过的公开名字 —— 「目标落位后归档源又被写成 B」时,B 住在公开名上,
|
|
193
|
+
* 这里一个字节都碰不到它。
|
|
194
|
+
*/
|
|
195
|
+
export async function retireTaken(taken) {
|
|
196
|
+
await rm(taken, { force: true }).catch(() => {})
|
|
197
|
+
}
|
|
198
|
+
|
|
127
199
|
/**
|
|
128
200
|
* 造一个**进程内串行队列**:同一个队列上的任务一个接一个跑,绝不并发。
|
|
129
201
|
*
|
package/lib/typert.host.js
CHANGED
|
@@ -20,8 +20,7 @@ const fileNameSchema = z.string().min(1).max(255).regex(new RegExp(MD_NAME_PATTE
|
|
|
20
20
|
// 到半兆量级基本是写错了目标(贴了日志/代码进来)。与 lib/index.js 的 MAX_FILE_BYTES 一致。
|
|
21
21
|
const fileContentSchema = z.string().max(MAX_FILE_BYTES)
|
|
22
22
|
|
|
23
|
-
// 2.1.2(Q02):写协议第三参(expectedRevision / create
|
|
24
|
-
// 不声明它的话 strictObject 会把新面板传的 revision 整个拒掉(不是弱化,是完全不通)。
|
|
23
|
+
// 2.1.2(Q02):写协议第三参(expectedRevision / create),都 optional;不声明的话 strictObject 会把新面板传的 revision 整个拒掉。
|
|
25
24
|
const writeOptsSchema = z.object({ 'expectedRevision': z.string().max(128).readonly().optional(), 'create': z.boolean().readonly().optional() }).readonly()
|
|
26
25
|
|
|
27
26
|
// autodream 报告引用:比 fileNameSchema 宽——旧版报告列表里的条目带 `dream/` 前缀
|
|
@@ -97,6 +96,8 @@ const archivedFileSchema = z.object({
|
|
|
97
96
|
'tags': z.array(z.string()).readonly(),
|
|
98
97
|
'archivedAt': z.string().readonly(),
|
|
99
98
|
'archivedReason': z.string().readonly(),
|
|
99
|
+
// 2.1.6(外部审查 Q07):**内容身份**(归档代次 + 正文 revision);strict 会把没声明的字段剥掉。
|
|
100
|
+
'revision': z.string().readonly(),
|
|
100
101
|
}).readonly()
|
|
101
102
|
const archivedListSchema = z.object({
|
|
102
103
|
'count': z.number().readonly(),
|
|
@@ -106,21 +107,19 @@ const archivedListSchema = z.object({
|
|
|
106
107
|
// 归档缘由:用户输入的一句话。真正的 YAML 转义在 host 侧 yamlScalar(值里的「: 」「 #」),
|
|
107
108
|
// 这里只兜长度 —— 缘由不该长到把 frontmatter 变成正文。
|
|
108
109
|
const archiveReasonSchema = z.string().max(1000).optional()
|
|
109
|
-
// 归档代次(`archived_at`,`YYYY-MM-DD HH:mm
|
|
110
|
-
// —— 用户确认过的是「这一版归档件」,名字不是身份。老归档件没有留痕时传空串,所以不设 min。
|
|
110
|
+
// 归档代次(`archived_at`,`YYYY-MM-DD HH:mm`):展示时间,老归档件没有留痕时传空串。
|
|
111
111
|
const archiveStampSchema = z.string().max(64)
|
|
112
|
+
// 归档内容身份(`归档代次@内容 revision`,见 archive.js 的 archivedRevisionOf):2.1.6(Q07)起
|
|
113
|
+
// 「彻底删除」必须带它 —— 没有它就只能按名字删,而名字不是身份;格式是不透明字符串。
|
|
114
|
+
const archiveRevisionSchema = z.string().min(1).max(128)
|
|
112
115
|
// starmap.listStars 的可选开关:不看归档星时行为与从前完全一致。
|
|
113
116
|
const includeArchivedSchema = z.boolean().optional()
|
|
114
117
|
|
|
115
|
-
// 归档 / 恢复的结果:成功 `{ ok
|
|
116
|
-
//
|
|
117
|
-
// 所以不能复用 writeResultSchema:那边 `file` 是必填(writeFile 连失败都带着归一后的文件名),
|
|
118
|
-
// strict 校验会把失败分支判成坏结果。
|
|
118
|
+
// 归档 / 恢复的结果:成功 `{ ok, file, unindexed|indexed, indexError? }`,失败 `{ ok:false, error }`
|
|
119
|
+
// —— 失败分支**没有 file**,不能复用 writeResultSchema(那边 file 必填,strict 会把失败判成坏结果)。
|
|
119
120
|
//
|
|
120
|
-
// ⚠️ `unindexed` / `indexed` / `indexError` 必须在这里声明(2026-10-12 复审 R11
|
|
121
|
-
// strict codec
|
|
122
|
-
// 永远不可见 —— 与 R01 漏掉四个配置字段是同一类错误,换了个字段名而已。
|
|
123
|
-
// `archivedReason` 同理:面板要显示「它为什么被收起来」。
|
|
121
|
+
// ⚠️ `unindexed` / `indexed` / `indexError` / `archivedReason` 必须在这里声明(2026-10-12 复审 R11):
|
|
122
|
+
// strict codec 会把没声明的字段整个剥掉,「索引没摘掉 / 没补上」在界面路径上就永远不可见。
|
|
124
123
|
const archiveResultSchema = z.object({
|
|
125
124
|
'ok': z.boolean().readonly(),
|
|
126
125
|
'file': z.string().readonly().optional(),
|
|
@@ -736,8 +735,8 @@ export const TYPERT = {
|
|
|
736
735
|
sourceLocation: { "file": "lib/index.js", "line": 1, "column": 1 },
|
|
737
736
|
},
|
|
738
737
|
{
|
|
739
|
-
//
|
|
740
|
-
//
|
|
738
|
+
// **彻底删除**一条归档件(不可恢复)。`archivedAt` 是展示时间,`revision` 才是内容身份
|
|
739
|
+
// (归档代次 + 正文 revision)—— 期间可能被恢复+同分钟重新归档、或改成同时间戳另一版(Q07)。
|
|
741
740
|
id: 'sage-mem#memory/deleteArchived',
|
|
742
741
|
service: 'memory',
|
|
743
742
|
namespace: 'memory',
|
|
@@ -746,6 +745,7 @@ export const TYPERT = {
|
|
|
746
745
|
parameters: [
|
|
747
746
|
{ name: 'name', wire: 'name', source: 'json', codec: { mode: 'strict', typeSymbol: 'sage-mem/types#ArchivedFileName', schema: fileNameSchema, create: () => fileNameSchema } },
|
|
748
747
|
{ name: 'archivedAt', wire: 'archivedAt', source: 'json', codec: { mode: 'strict', typeSymbol: 'sage-mem/types#ArchiveStamp', schema: archiveStampSchema, create: () => archiveStampSchema } },
|
|
748
|
+
{ name: 'revision', wire: 'revision', source: 'json', codec: { mode: 'strict', typeSymbol: 'sage-mem/types#ArchiveRevision', schema: archiveRevisionSchema, create: () => archiveRevisionSchema } },
|
|
749
749
|
],
|
|
750
750
|
// 成功 `{ ok: true, file }`、失败 `{ ok: false, error }` —— 与归档 / 恢复同形,复用 ArchiveResult。
|
|
751
751
|
result: { mode: 'strict', typeSymbol: 'sage-mem/types#ArchiveResult', schema: archiveResultSchema, create: () => archiveResultSchema },
|
package/package.json
CHANGED