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 +1 -1
- package/README.zh.md +1 -1
- package/lib/client.js +16 -6
- package/lib/index.js +25 -15
- package/lib/memory/archive.js +117 -39
- package/lib/memory/scan.js +23 -8
- 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/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 { 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 {
|
|
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
|
* 先落归档副本、再删原件:中途崩了最坏是两份都在(人工可收拾),不会一份都没有。
|
|
@@ -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.
|
|
351
|
-
*
|
|
352
|
-
*
|
|
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
|
-
* ⚠️
|
|
356
|
-
*
|
|
357
|
-
*
|
|
358
|
-
*
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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 =
|
|
399
|
-
const
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
)
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
|
419
|
-
|
|
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(
|
|
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',
|
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/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