sage-mem 2.1.5 → 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 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.5`** — 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
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.5`** —— 顶部 npm 徽章是动态权威。**契约基线:`1.0.0`** —— 第一个稳定版,公开承诺、能力边界与「明确做不到」的清单冻结在
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/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
- // 「彻底删除」的入参:归档代次(`archivedAt`)。它不是装饰 —— 宿主拿它核对
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
- * 第二个参数是归档代次(列表上看到的 `archivedAt`):宿主拿它核对「要删的是不是
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
- if (!given.archivedAt && !given.archivedReason) {
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
- if (onDisk.archivedAt || onDisk.archivedReason) {
360
- toWrite = addArchiveMeta(content, onDisk.archivedReason, onDisk.archivedAt)
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
- * `archivedAt` 是**身份判据**(见 `memory/archive.js` 的头注):调用方交回它当时看见的
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
  }
@@ -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 { revisionOf } from './chunks.js'
20
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 { link, mkdir, readFile, rename, rm, stat } from 'node:fs/promises'
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
- const stamped = addArchiveMeta(content, why, at)
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
  * 先落归档副本、再删原件:中途崩了最坏是两份都在(人工可收拾),不会一份都没有。
@@ -337,6 +345,31 @@ export async function restoreMemory(memoryDir, name) {
337
345
  }
338
346
  }
339
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
+
340
373
  /**
341
374
  * 从归档区**彻底删除**一条记忆(不可恢复)。
342
375
  *
@@ -347,22 +380,28 @@ export async function restoreMemory(memoryDir, name) {
347
380
  * 三道判据,缺一不可:
348
381
  * 1. **归档区的目录闸门**:它若被换成指向外部的联接,删掉的是别人的文件;
349
382
  * 2. **条目闸门**:链接 / 硬链接指向的可能是记忆目录之外的内容,删它等于替别人删文件;
350
- * 3. **代次(`archivedAt`)**:名字不是身份。用户从列表看到确认框、到请求到达之间,
351
- * 这条完全可能被恢复回顶层、又被另一版同名记忆归档进来 —— 按名删掉的就不再是他
352
- * 确认过的那一份。所以调用方必须交回它当时看见的 `archived_at`,对不上就拒绝。
353
- * 老数据没有留痕时两边都是空串,仍然一致(那是「同一份、只是没有时间戳」)。
383
+ * 3. **内容身份(revision = 归档代次 + 正文 revision)**:名字不是身份,`archived_at`
384
+ * 也不是(2026-10-31 外部审查 Q07)。用户从列表看到确认框、到请求到达之间,
385
+ * 这条完全可能被恢复回顶层、又被同一分钟内的另一版同名记忆归档进来;
386
+ * 归档正文也可能被改成了 B 而 `archived_at` 一个字没变。所以调用方必须交回
387
+ * 它当时看见的 `archived_at` **和** `revision`,服务端对着**接管到手的实物**
388
+ * 重新算一遍内容身份,对不上就拒绝。界面上的二次确认只是交互,不是判据。
354
389
  *
355
- * ⚠️ 删除本身用 **`rename` 到私有名 + `rm` 私有名**:`inspect` 与 `unlink` 之间隔着
356
- * 读文件,直接 `unlink(full)` 认的是**名字**而不是我们核对过的那份实物。`rename` 是原子的,
357
- * 之后删的一定是刚移走的那一个 inode。删失败时把名字 `link()` 还回去 —— 绝不让它
358
- * 以 `.deleting-` 的私有名留在盘上(扫描不认那个名字,等于文件凭空消失)。
390
+ * ⚠️ 删除本身是「**先接管、再核对、最后删私有名**」:
391
+ * 旧写法读完留痕比对通过后才 `rename(full, taken)`,两次调用之间另一路把归档件换成
392
+ * 另一版时,rename 移走的正是那一版、随后 rm 删掉,还返回 ok:true。
393
+ * 现在用与归档/恢复同一份原语 `takeOverSource`:先把盘上那份原子接管成 `.deleting-`
394
+ * 私有名(顺带核对它仍是刚读到的字节),再对**接管到的实物**算身份比对 ——
395
+ * 比对与删除之间不再有名字窗口。核对不通过时 `returnSource` 把原文还回 archive/,
396
+ * 还不动就留在私有名上并点名(内容绝不丢)。
359
397
  *
360
398
  * @param {string} memoryDir — 记忆根目录(调用方给,不许拿模块常量)
361
399
  * @param {string} name — 归档区里的文件名
362
- * @param {string} expectedArchivedAt — 调用方看见的 `archived_at`(没有留痕时传空串)
400
+ * @param {string} expectedArchivedAt — 调用方看见的 `archived_at`(老数据可能为空串)
401
+ * @param {string} expectedRevision — 调用方看见的 `revision`(`archivedRevisionOf` 的值)
363
402
  * @returns {Promise<{ok:true,file:string}|{ok:false,code:string,error:string}>}
364
403
  */
365
- export async function deleteArchivedMemory(memoryDir, name, expectedArchivedAt = '') {
404
+ export async function deleteArchivedMemory(memoryDir, name, expectedArchivedAt = '', expectedRevision = '') {
366
405
  const gate = checkName(name, 'delete')
367
406
  if (!gate.ok) return gate
368
407
  const safe = gate.safe
@@ -383,52 +422,91 @@ export async function deleteArchivedMemory(memoryDir, name, expectedArchivedAt =
383
422
  error: `sage-mem: not archived: ${safe}(${srcGate.error})`,
384
423
  }
385
424
  }
386
- // 读全文只为取留痕:代次判据必须来自**盘上的那份**,不能信调用方一路传下来的展示值。
387
- const onDisk = readArchivedMeta(await readFile(full, 'utf8'))
388
- const expected = String(expectedArchivedAt ?? '')
389
- if (expected !== onDisk.archivedAt) {
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
+ }
390
451
  return {
391
452
  ok: false,
392
453
  code: 'changed',
393
- error:
394
- `sage-mem: ${safe} 在确认之后被换成了另一版(界面上是「${expected || '时间未知'}」,` +
395
- `盘上是「${onDisk.archivedAt || '时间未知'}」),本次一个字节都没删 —— 请刷新列表再看一眼。`,
454
+ error: `sage-mem: ${safe} 在删除过程中被换成了另一版,本次一个字节都没删 —— 请刷新列表再看一眼。`,
396
455
  }
397
456
  }
398
- const taken = join(archiveDirOf(memoryDir), `.${safe}.deleting-${randomUUID().replace(/-/g, '')}`)
399
- const moveErr = await rename(full, taken).then(
400
- () => null,
401
- (err) => err,
402
- )
403
- if (moveErr) {
404
- if (moveErr?.code === 'ENOENT') {
405
- return { ok: false, code: 'not-archived', error: `sage-mem: not archived: ${safe}(删除途中它从归档区消失了)` }
406
- }
407
- return {
408
- ok: false,
409
- code: 'move-failed',
410
- error: `sage-mem: 删除 ${safe} 时移不动它(${moveErr?.code ?? moveErr}:${moveErr?.message ?? moveErr})。文件仍在 archive/ 里、一个字节没动 —— 可稍后重试(若是被其它程序占用,关掉它再来)。`,
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
+ }
411
473
  }
474
+ return { ok: false, code, error: head }
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
+ )
412
493
  }
494
+ /** 到这里才删:删的是**核对过内容身份的私有副本**(名字底下的东西早就被接管走了)。 */
413
495
  const delErr = await rm(taken, { force: true }).then(
414
496
  () => null,
415
497
  (err) => err,
416
498
  )
417
499
  if (delErr) {
418
- const restored = await link(taken, full).then(
419
- () => true,
420
- () => false,
421
- )
422
- if (!restored) {
500
+ const back = await returnSource(taken, full)
501
+ if (!back.ok) {
423
502
  return {
424
503
  ok: false,
425
504
  code: 'delete-failed',
426
505
  error:
427
506
  `sage-mem: 删除 ${safe} 失败(${delErr?.code ?? delErr}),且归还原名时被占用 —— ` +
428
- `**内容仍保留在 archive/${basename(taken)}**,请手工改回 ${safe} 再试。`,
507
+ `**内容仍保留在 archive/${basename(back.keptAt)}**,请手工改回 ${safe} 再试。`,
429
508
  }
430
509
  }
431
- await rm(taken, { force: true }).catch(() => {})
432
510
  return {
433
511
  ok: false,
434
512
  code: 'delete-failed',
@@ -65,18 +65,25 @@ function unquoteYaml(raw) {
65
65
  return s
66
66
  }
67
67
 
68
- /** 读 frontmatter 里的归档留痕。没有 frontmatter / 没有这两个字段都回空串。 */
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
- /** 去掉归档留痕两行(恢复时用)。没有 frontmatter 就原样返回。 */
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` 插在 frontmatter 末尾。
100
+ * 写入归档留痕:`archived_at` + `archived_reason`(+ 可选 `archived_rev`)插在
101
+ * frontmatter 末尾。
94
102
  *
95
103
  * 已经是归档态的文件再归档没有意义(上层会先拒绝),所以这里只做 upsert:
96
- * 先剥掉旧的两行再插,免得同一份文件里出现两个 `archived_at`。
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)
@@ -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)—— 字段与参数都 optional,旧调用方照旧过;
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: true, file, unindexed|indexed, indexError? }`,
116
- // 失败 `{ ok: false, error }` —— 失败分支**没有 file**(名字本身可能就是非法的)。
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sage-mem",
3
3
  "description": "Cross-session memory for DeepSeek Harness — plain markdown files, no database, no worker.",
4
- "version": "2.1.5",
4
+ "version": "2.1.6",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "icon": "./icon.png",