sage-mem 2.1.24 → 2.2.0
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/tools.js +2 -2
- package/lib/client.js +40 -10
- package/lib/memory/archive.js +24 -24
- package/lib/memory/lock.js +2 -2
- package/lib/memory/reader.js +1 -1
- package/lib/memory/scan.js +5 -5
- package/lib/memory/util.js +2 -2
- package/lib/memory/watch.js +1 -1
- package/lib/shared/codes.js +71 -0
- 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.
|
|
15
|
+
**Current release: `2.2.0`** — 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.
|
|
17
|
+
**当前发布版本:`2.2.0`** —— 顶部 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/tools.js
CHANGED
|
@@ -764,7 +764,7 @@ export function createToolRunner(opts) {
|
|
|
764
764
|
if (guard.refuse) {
|
|
765
765
|
// **不知道就不写。** 以前这条路会退化成「用当前版本当守卫」,等于替调用方担保一份它没读过的
|
|
766
766
|
// 内容 —— 那比「什么都不做」危险得多。转成一条待人工处理的冲突,让人来判。
|
|
767
|
-
await recordStaleConflict(fullPath, content, { code: '
|
|
767
|
+
await recordStaleConflict(fullPath, content, { code: 'unverified_overwrite', message: guard.why }, 'external_change')
|
|
768
768
|
throw Object.assign(new Error(guard.why), { code: 'SAGE_MEM_UNVERIFIED_OVERWRITE' })
|
|
769
769
|
}
|
|
770
770
|
try {
|
|
@@ -959,7 +959,7 @@ export function createToolRunner(opts) {
|
|
|
959
959
|
const before = await fileFingerprint(full)
|
|
960
960
|
const res = await archiveMemory(memoryDir, basename(full), reason)
|
|
961
961
|
if (!res.ok) {
|
|
962
|
-
if (res.code === '
|
|
962
|
+
if (res.code === 'not_found') return `错误:没有这个文件:${basename(full)}`
|
|
963
963
|
if (res.code === 'exists') return `错误:archive/ 里已有同名文件(${basename(full)}),请先人工处理,避免覆盖。`
|
|
964
964
|
return `错误:${String(res.error).replace(/^sage-mem: /, '')}`
|
|
965
965
|
}
|
package/lib/client.js
CHANGED
|
@@ -31,6 +31,10 @@ window.__ModuleLoader__.load({
|
|
|
31
31
|
const isNum = (v) => { if (typeof v !== "number") throw new Error("expected number"); };
|
|
32
32
|
function parseSchema(shape) {
|
|
33
33
|
return {
|
|
34
|
+
// 字段清单**暴露出来**:`test/client-exec-test.mjs` 第 3.1 组拿它跟宿主 zod 的
|
|
35
|
+
// shape 逐条对表。没有这一项,「strict」只意味着「不查多余键」——
|
|
36
|
+
// 宿主加了字段、客户端没声明这种漂移它挡不住(2026-11-03 实测已漂三处)。
|
|
37
|
+
fields: Object.keys(shape),
|
|
34
38
|
parse(value) {
|
|
35
39
|
if (value === null || typeof value !== "object") throw new Error("sage-mem: invalid payload");
|
|
36
40
|
for (const [key, check] of Object.entries(shape)) {
|
|
@@ -1650,13 +1654,21 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
1650
1654
|
|
|
1651
1655
|
// ── 手写 strict codec(浏览器无 zod,typert 只要求 parse 方法)──
|
|
1652
1656
|
// 基元(parseSchema / isStr / isBool / isNum / strSchema)在 05-shared.js。
|
|
1653
|
-
const
|
|
1654
|
-
|
|
1657
|
+
const isStrArr = (v) => { if (!Array.isArray(v)) throw new Error("expected string array"); for (const x of v) isStr(x); };
|
|
1658
|
+
// 字段必须与宿主 `lib/typert.host.js` 的 fileSchema **同宽**(strict 会剥掉未声明字段):
|
|
1659
|
+
// 少了 tags / locked,列表页的「按标签筛」与「锁定」按钮就永远拿到 undefined。
|
|
1660
|
+
const fileSchema = parseSchema({ "file": isStr, "type": isStr, "description": isStr, "size": isNum, "tags": isStrArr, "locked": isBool });
|
|
1661
|
+
const listSchema = { fields: fileSchema.fields, parse(v) { if (!Array.isArray(v)) throw new Error("expected array"); v.forEach((f) => fileSchema.parse(f)); return v; } };
|
|
1655
1662
|
const readSchema = withOptional(parseSchema({ "name": isStr, "content": isStr }), ["revision"]);
|
|
1656
|
-
const writeSchema =
|
|
1657
|
-
const deleteSchema = parseSchema({ "ok": isBool });
|
|
1658
|
-
|
|
1659
|
-
|
|
1663
|
+
const writeSchema = optionalFields(parseSchema({ "ok": isBool, "file": isStr }), { "error": isStr, "code": isStr, "revision": isStr });
|
|
1664
|
+
const deleteSchema = optionalFields(parseSchema({ "ok": isBool }), { "error": isStr });
|
|
1665
|
+
// `archived` 每颗星都有(活动星是 false);archivedAt / archivedReason 只对归档星出现,
|
|
1666
|
+
// 且旧版 autodream 归档的文件没有它们 —— 所以是 optional(与宿主 manifest 一致)。
|
|
1667
|
+
const starmapStarSchema = withOptional(
|
|
1668
|
+
parseSchema({ "file": isStr, "kind": isStr, "title": isStr, "desc": isStr, "bytes": isNum, "mtimeMs": isNum, "archived": isBool }),
|
|
1669
|
+
["archivedAt", "archivedReason"],
|
|
1670
|
+
);
|
|
1671
|
+
const starmapListSchema = { fields: ["count", "stars"], elementFields: starmapStarSchema.fields, parse(v) { if (!v || typeof v !== "object") throw new Error("expected object"); if (!Array.isArray(v.stars)) throw new Error("expected stars array"); v.stars.forEach((f) => starmapStarSchema.parse(f)); return v; } };
|
|
1660
1672
|
const starmapReadSchema = parseSchema({ "name": isStr, "content": isStr });
|
|
1661
1673
|
|
|
1662
1674
|
// ── 归档区(v0.9.0 档案馆)──
|
|
@@ -1664,15 +1676,24 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
1664
1676
|
// 结果 codec 与宿主 manifest 逐字段对齐(ArchiveResult 的 file / error 都可选,
|
|
1665
1677
|
// 成功回 file、失败回 error,所以这里只硬校 ok)。
|
|
1666
1678
|
const boolSchema = { parse(v) { isBool(v); return v; } };
|
|
1667
|
-
|
|
1679
|
+
// 归档结果(宿主的 archiveResultSchema):**除 ok 外全部 optional**。失败分支没有 file,
|
|
1680
|
+
// 把它当必填会把「失败」判成坏结果 —— 宿主的 typert.host.js 有同一条注释。
|
|
1681
|
+
// `unindexed` / `indexed` 是 boolean,所以不能套 withOptional(那个只做 isStr)。
|
|
1682
|
+
const archiveResultFields = { "file": isStr, "error": isStr, "archivedReason": isStr, "unindexed": isBool, "indexed": isBool, "indexError": isStr };
|
|
1683
|
+
const archiveResultSchema = optionalFields(parseSchema({ "ok": isBool }), archiveResultFields);
|
|
1684
|
+
// archive / restore / deleteArchived / writeArchived 四个 remote 共用这一个形状。
|
|
1685
|
+
const archiveSchema = archiveResultSchema;
|
|
1668
1686
|
const archivedFileSchema = parseSchema({
|
|
1669
1687
|
"file": isStr, "type": isStr, "description": isStr, "size": isNum,
|
|
1688
|
+
"tags": isStrArr,
|
|
1670
1689
|
"archivedAt": isStr, "archivedReason": isStr,
|
|
1671
1690
|
// 2.1.6(外部审查 Q07):内容身份(归档代次 + 正文 revision)。它不是装饰 ——
|
|
1672
1691
|
// 「彻底删除」必须把它原样交回宿主,否则只能按名字删,而名字不是身份。
|
|
1673
1692
|
"revision": isStr,
|
|
1674
1693
|
});
|
|
1675
1694
|
const archivedListSchema = {
|
|
1695
|
+
fields: ["count", "files"],
|
|
1696
|
+
elementFields: archivedFileSchema.fields,
|
|
1676
1697
|
parse(v) {
|
|
1677
1698
|
if (!v || typeof v !== "object") throw new Error("sage-mem: expected object");
|
|
1678
1699
|
if (!Array.isArray(v.files)) throw new Error("sage-mem: expected files array");
|
|
@@ -1682,7 +1703,9 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
1682
1703
|
};
|
|
1683
1704
|
// 归档文件的读 / 写(B1b):形状与活跃记忆那套的 readSchema / writeSchema 对称。
|
|
1684
1705
|
const archivedReadSchema = withOptional(parseSchema({ "name": isStr, "content": isStr }), ["revision"]);
|
|
1685
|
-
|
|
1706
|
+
// 宿主 `archivedWriteResultSchema` 与 `writeResultSchema` **同形、只是类型名不同**
|
|
1707
|
+
// (typert.host.js 的注释明说这一点)——所以直接复用 writeSchema 的字段面。
|
|
1708
|
+
const archivedWriteSchema = writeSchema;
|
|
1686
1709
|
// 「彻底删除」的入参:`archivedAt`(展示时间)+ `revision`(内容身份,见 archive.js 的
|
|
1687
1710
|
// archivedRevisionOf)。两个都不是装饰 —— 宿主拿它们核对「要删的是不是用户确认过的那一版」;
|
|
1688
1711
|
// 只有前者的旧确认在 2.1.6 起会被宿主拒绝(分钟精度的时间不是内容身份)。
|
|
@@ -1695,15 +1718,22 @@ const StarmapRuntime = (function (require, _react) {
|
|
|
1695
1718
|
* 混合版本环境里「旧宿主 + 新面板」必须还能用,所以这里不能当必填字段拒掉;
|
|
1696
1719
|
* 但字段给了就必须是字符串 —— 否则面板拿到一个数字或对象会静默走错分支。
|
|
1697
1720
|
*/
|
|
1698
|
-
function
|
|
1721
|
+
function optionalFields(base, spec) {
|
|
1699
1722
|
return {
|
|
1723
|
+
// 可选字段并入字段集:对表时宿主 zod 的 optional 字段也在它的 shape 里,
|
|
1724
|
+
// 两边必须一样宽,否则「客户端没声明 optional 字段」会被误报成漂移。
|
|
1725
|
+
fields: (base.fields || []).concat(Object.keys(spec)),
|
|
1700
1726
|
parse(v) {
|
|
1701
1727
|
base.parse(v);
|
|
1702
|
-
for (
|
|
1728
|
+
for (const [k, check] of Object.entries(spec)) if (k in v) check(v[k]);
|
|
1703
1729
|
return v;
|
|
1704
1730
|
},
|
|
1705
1731
|
};
|
|
1706
1732
|
}
|
|
1733
|
+
/** 可选字段全是字符串时的简写(读 revision / 写 code / revision)。 */
|
|
1734
|
+
function withOptional(base, keys) {
|
|
1735
|
+
return optionalFields(base, Object.fromEntries(keys.map((k) => [k, isStr])));
|
|
1736
|
+
}
|
|
1707
1737
|
/**
|
|
1708
1738
|
* 写协议的第三参(`{ expectedRevision?, create? }`)——**两个字段都可选**。
|
|
1709
1739
|
* 不传 = 旧调用方,行为与从前一致(宿主会打一条 warn);传了就必须是正确类型。
|
package/lib/memory/archive.js
CHANGED
|
@@ -34,7 +34,7 @@ function checkName(name, verb) {
|
|
|
34
34
|
return { ok: false, code: 'reserved', error: `sage-mem: reserved file, refusing to ${verb}: ${nameKey(name)}` }
|
|
35
35
|
}
|
|
36
36
|
const safe = safeName(name)
|
|
37
|
-
if (!safe) return { ok: false, code: '
|
|
37
|
+
if (!safe) return { ok: false, code: 'invalid_name', error: `sage-mem: invalid file name: ${String(name)}` }
|
|
38
38
|
return { ok: true, safe }
|
|
39
39
|
}
|
|
40
40
|
|
|
@@ -63,7 +63,7 @@ export async function archiveMemory(memoryDir, name, reason, at = localStamp())
|
|
|
63
63
|
if (lock.status === 'unknown') {
|
|
64
64
|
return {
|
|
65
65
|
ok: false,
|
|
66
|
-
code: '
|
|
66
|
+
code: 'lock_unknown',
|
|
67
67
|
error: `sage-mem: 锁表不可读(${lock.reason}),无法确认这条记忆是否受保护,拒绝归档:${safe}`,
|
|
68
68
|
}
|
|
69
69
|
}
|
|
@@ -83,7 +83,7 @@ export async function archiveMemory(memoryDir, name, reason, at = localStamp())
|
|
|
83
83
|
if (!srcGate.ok) {
|
|
84
84
|
return {
|
|
85
85
|
ok: false,
|
|
86
|
-
code: srcGate.code === 'missing' ? '
|
|
86
|
+
code: srcGate.code === 'missing' ? 'not_found' : srcGate.code,
|
|
87
87
|
error: `sage-mem: file not found: ${safe}(${srcGate.error})`,
|
|
88
88
|
}
|
|
89
89
|
}
|
|
@@ -162,14 +162,14 @@ export async function archiveMemory(memoryDir, name, reason, at = localStamp())
|
|
|
162
162
|
* 「错误:没有这个文件」—— 诊断是错的,人会去别处找。按真实错误码分流。
|
|
163
163
|
*/
|
|
164
164
|
await rm(dst, { force: true }).catch(() => {})
|
|
165
|
-
if (takenRes.code === '
|
|
166
|
-
return { ok: false, code: '
|
|
165
|
+
if (takenRes.code === 'not_found') {
|
|
166
|
+
return { ok: false, code: 'not_found', error: `sage-mem: file not found: ${safe}(归档途中源文件消失了)` }
|
|
167
167
|
}
|
|
168
|
-
if (takenRes.code === '
|
|
168
|
+
if (takenRes.code === 'move_failed') {
|
|
169
169
|
const moveErr = takenRes.cause
|
|
170
170
|
return {
|
|
171
171
|
ok: false,
|
|
172
|
-
code: '
|
|
172
|
+
code: 'move_failed',
|
|
173
173
|
error: `sage-mem: 归档 ${safe} 时移不动它(${moveErr?.code ?? moveErr}:${moveErr?.message ?? moveErr})。文件仍在原处、一个字节没动,本次归档未完成 —— 可稍后重试(若是被其它程序占用,关掉它再来)。`,
|
|
174
174
|
}
|
|
175
175
|
}
|
|
@@ -241,16 +241,16 @@ export async function restoreMemory(memoryDir, name) {
|
|
|
241
241
|
if (!dirGate.ok) {
|
|
242
242
|
return {
|
|
243
243
|
ok: false,
|
|
244
|
-
code: dirGate.code === 'missing' ? '
|
|
244
|
+
code: dirGate.code === 'missing' ? 'not_archived' : dirGate.code,
|
|
245
245
|
error: `sage-mem: not archived: ${safe}(${dirGate.error})`,
|
|
246
246
|
}
|
|
247
247
|
}
|
|
248
248
|
const srcGate = await inspectMemoryEntry(src, { limitBytes: null })
|
|
249
|
-
if (!srcGate.ok) return { ok: false, code: '
|
|
249
|
+
if (!srcGate.ok) return { ok: false, code: 'not_archived', error: `sage-mem: not archived: ${safe}(${srcGate.error})` }
|
|
250
250
|
// 顶层已有同名(且不是链接)时拒绝:根目录那份是正在用的记忆,归档这份只是退役版。
|
|
251
251
|
const dstGate = await inspectMemoryEntry(dst, { limitBytes: null })
|
|
252
252
|
if (dstGate.ok) {
|
|
253
|
-
return { ok: false, code: '
|
|
253
|
+
return { ok: false, code: 'target_exists', error: `sage-mem: already exists, refusing to overwrite: ${safe}` }
|
|
254
254
|
}
|
|
255
255
|
if (dstGate.code !== 'missing') {
|
|
256
256
|
return { ok: false, code: dstGate.code, error: `sage-mem: refusing to restore over ${safe}: ${dstGate.error}` }
|
|
@@ -278,14 +278,14 @@ export async function restoreMemory(memoryDir, name) {
|
|
|
278
278
|
*/
|
|
279
279
|
const takenRes = await takeOverSource(src, sourceBytes, 'restoring')
|
|
280
280
|
if (!takenRes.ok) {
|
|
281
|
-
if (takenRes.code === '
|
|
282
|
-
return { ok: false, code: '
|
|
281
|
+
if (takenRes.code === 'not_found') {
|
|
282
|
+
return { ok: false, code: 'not_archived', error: `sage-mem: not archived: ${safe}(恢复途中源文件消失了)` }
|
|
283
283
|
}
|
|
284
|
-
if (takenRes.code === '
|
|
284
|
+
if (takenRes.code === 'move_failed') {
|
|
285
285
|
const moveErr = takenRes.cause
|
|
286
286
|
return {
|
|
287
287
|
ok: false,
|
|
288
|
-
code: '
|
|
288
|
+
code: 'move_failed',
|
|
289
289
|
error: `sage-mem: 恢复 ${safe} 时移不动它(${moveErr?.code ?? moveErr}:${moveErr?.message ?? moveErr})。文件仍在 archive/ 里、一个字节没动 —— 可稍后重试(若是被其它程序占用,关掉它再来)。`,
|
|
290
290
|
}
|
|
291
291
|
}
|
|
@@ -318,12 +318,12 @@ export async function restoreMemory(memoryDir, name) {
|
|
|
318
318
|
if (!back.ok) {
|
|
319
319
|
return {
|
|
320
320
|
ok: false,
|
|
321
|
-
code: '
|
|
321
|
+
code: 'restore_failed',
|
|
322
322
|
error: `sage-mem: 恢复 ${safe} 落位失败(${err?.code ?? err}),且归还归档区原名时被占用 —— **内容仍保留在 archive/${basename(back.keptAt)}**,请手工改回 ${safe} 再试。`,
|
|
323
323
|
}
|
|
324
324
|
}
|
|
325
325
|
if (err?.code === 'EEXIST') {
|
|
326
|
-
return { ok: false, code: '
|
|
326
|
+
return { ok: false, code: 'target_exists', error: `sage-mem: already exists, refusing to overwrite: ${safe}` }
|
|
327
327
|
}
|
|
328
328
|
throw err
|
|
329
329
|
}
|
|
@@ -421,7 +421,7 @@ export async function deleteArchivedMemory(memoryDir, name, expectedArchivedAt =
|
|
|
421
421
|
if (!dirGate.ok) {
|
|
422
422
|
return {
|
|
423
423
|
ok: false,
|
|
424
|
-
code: dirGate.code === 'missing' ? '
|
|
424
|
+
code: dirGate.code === 'missing' ? 'not_archived' : dirGate.code,
|
|
425
425
|
error: `sage-mem: not archived: ${safe}(${dirGate.error})`,
|
|
426
426
|
}
|
|
427
427
|
}
|
|
@@ -430,7 +430,7 @@ export async function deleteArchivedMemory(memoryDir, name, expectedArchivedAt =
|
|
|
430
430
|
if (!srcGate.ok) {
|
|
431
431
|
return {
|
|
432
432
|
ok: false,
|
|
433
|
-
code: srcGate.code === 'missing' ? '
|
|
433
|
+
code: srcGate.code === 'missing' ? 'not_archived' : srcGate.code,
|
|
434
434
|
error: `sage-mem: not archived: ${safe}(${srcGate.error})`,
|
|
435
435
|
}
|
|
436
436
|
}
|
|
@@ -441,14 +441,14 @@ export async function deleteArchivedMemory(memoryDir, name, expectedArchivedAt =
|
|
|
441
441
|
const sourceBytes = await readFile(full)
|
|
442
442
|
const takenRes = await takeOverSource(full, sourceBytes, 'deleting')
|
|
443
443
|
if (!takenRes.ok) {
|
|
444
|
-
if (takenRes.code === '
|
|
445
|
-
return { ok: false, code: '
|
|
444
|
+
if (takenRes.code === 'not_found') {
|
|
445
|
+
return { ok: false, code: 'not_archived', error: `sage-mem: not archived: ${safe}(删除途中它从归档区消失了)` }
|
|
446
446
|
}
|
|
447
|
-
if (takenRes.code === '
|
|
447
|
+
if (takenRes.code === 'move_failed') {
|
|
448
448
|
const moveErr = takenRes.cause
|
|
449
449
|
return {
|
|
450
450
|
ok: false,
|
|
451
|
-
code: '
|
|
451
|
+
code: 'move_failed',
|
|
452
452
|
error: `sage-mem: 删除 ${safe} 时移不动它(${moveErr?.code ?? moveErr}:${moveErr?.message ?? moveErr})。文件仍在 archive/ 里、一个字节没动 —— 可稍后重试(若是被其它程序占用,关掉它再来)。`,
|
|
453
453
|
}
|
|
454
454
|
}
|
|
@@ -513,7 +513,7 @@ export async function deleteArchivedMemory(memoryDir, name, expectedArchivedAt =
|
|
|
513
513
|
if (!back.ok) {
|
|
514
514
|
return {
|
|
515
515
|
ok: false,
|
|
516
|
-
code: '
|
|
516
|
+
code: 'delete_failed',
|
|
517
517
|
error:
|
|
518
518
|
`sage-mem: 删除 ${safe} 失败(${delErr?.code ?? delErr}),且归还原名时被占用 —— ` +
|
|
519
519
|
`**内容仍保留在 archive/${basename(back.keptAt)}**,请手工改回 ${safe} 再试。`,
|
|
@@ -521,7 +521,7 @@ export async function deleteArchivedMemory(memoryDir, name, expectedArchivedAt =
|
|
|
521
521
|
}
|
|
522
522
|
return {
|
|
523
523
|
ok: false,
|
|
524
|
-
code: '
|
|
524
|
+
code: 'delete_failed',
|
|
525
525
|
error: `sage-mem: 删除 ${safe} 失败(${delErr?.code ?? delErr}:${delErr?.message ?? delErr}),文件已还回 archive/${safe},没有丢。`,
|
|
526
526
|
}
|
|
527
527
|
}
|
package/lib/memory/lock.js
CHANGED
|
@@ -125,7 +125,7 @@ export async function isLocked(memoryDir, name) {
|
|
|
125
125
|
* RMW 全程在**按锁表路径分键的进程内队列**里:读最新表 → 改一项 → 原子写回。
|
|
126
126
|
* 并发的不同文件因此逐条合并,不再互相覆盖。
|
|
127
127
|
*
|
|
128
|
-
* 坏表上**拒绝写入**(`{ok:false, code:'
|
|
128
|
+
* 坏表上**拒绝写入**(`{ok:false, code:'lock_table_unknown'}`),而不是把表重置成空表 ——
|
|
129
129
|
* 后者会静默抹掉用户的全部保护状态,比「这次没锁上」严重得多。
|
|
130
130
|
*/
|
|
131
131
|
export async function setLocked(memoryDir, name, locked) {
|
|
@@ -136,7 +136,7 @@ export async function setLocked(memoryDir, name, locked) {
|
|
|
136
136
|
if (read.status === 'unknown') {
|
|
137
137
|
return {
|
|
138
138
|
ok: false,
|
|
139
|
-
code: '
|
|
139
|
+
code: 'lock_table_unknown',
|
|
140
140
|
error: `sage-mem: 锁表不可读(${read.reason}),本次没有写入,也不会把它重置成空表:${name}`,
|
|
141
141
|
}
|
|
142
142
|
}
|
package/lib/memory/reader.js
CHANGED
|
@@ -226,7 +226,7 @@ export async function readSlice(req = {}) {
|
|
|
226
226
|
if (!gate.ok) {
|
|
227
227
|
if (gate.code === 'missing') return { ok: false, code: 'not_found', error: `没有这份记忆:${norm.name}` }
|
|
228
228
|
// stat-failed 不是「形状不对」,是读不到属性(权限 / 盘掉线)→ 归 io_error 更好排查
|
|
229
|
-
if (gate.code === '
|
|
229
|
+
if (gate.code === 'stat_failed') return { ok: false, code: 'io_error', error: `读不到文件属性:${gate.error}` }
|
|
230
230
|
return { ok: false, code: 'denied', error: `拒绝读取 ${norm.name}:${gate.error}` }
|
|
231
231
|
}
|
|
232
232
|
|
package/lib/memory/scan.js
CHANGED
|
@@ -158,17 +158,17 @@ export async function inspectMemoryEntry(full, opts = {}) {
|
|
|
158
158
|
} catch (err) {
|
|
159
159
|
return {
|
|
160
160
|
ok: false,
|
|
161
|
-
code: err?.code === 'ENOENT' ? 'missing' : '
|
|
161
|
+
code: err?.code === 'ENOENT' ? 'missing' : 'stat_failed',
|
|
162
162
|
error: `读不到文件属性(${err?.code ?? err})`,
|
|
163
163
|
}
|
|
164
164
|
}
|
|
165
165
|
if (info.isSymbolicLink()) return { ok: false, code: 'symlink', error: '是指向别处的符号链接 / 目录联接' }
|
|
166
|
-
if (!info.isFile()) return { ok: false, code: '
|
|
166
|
+
if (!info.isFile()) return { ok: false, code: 'not_file', error: '不是普通文件' }
|
|
167
167
|
// null = 明确「不判体积」;其余一律按数值上限处理(0 / NaN / 字符串都会被当成超限)。
|
|
168
168
|
if (limitBytes !== null && (!Number.isFinite(limitBytes) || info.size > limitBytes)) {
|
|
169
169
|
return {
|
|
170
170
|
ok: false,
|
|
171
|
-
code: '
|
|
171
|
+
code: 'too_big',
|
|
172
172
|
error: Number.isFinite(limitBytes)
|
|
173
173
|
? `体积 ${info.size} 字节,超过单文件上限 ${limitBytes} 字节`
|
|
174
174
|
: `体积 ${info.size} 字节,上限参数不合法(${String(limitBytes)})`,
|
|
@@ -287,12 +287,12 @@ export async function inspectDir(dir) {
|
|
|
287
287
|
} catch (err) {
|
|
288
288
|
return {
|
|
289
289
|
ok: false,
|
|
290
|
-
code: err?.code === 'ENOENT' ? 'missing' : '
|
|
290
|
+
code: err?.code === 'ENOENT' ? 'missing' : 'stat_failed',
|
|
291
291
|
error: `读不到目录属性(${err?.code ?? err})`,
|
|
292
292
|
}
|
|
293
293
|
}
|
|
294
294
|
if (info.isSymbolicLink()) return { ok: false, code: 'symlink', error: '是指向别处的符号链接 / 目录联接' }
|
|
295
|
-
if (!info.isDirectory()) return { ok: false, code: '
|
|
295
|
+
if (!info.isDirectory()) return { ok: false, code: 'not_dir', error: '不是目录' }
|
|
296
296
|
return { ok: true }
|
|
297
297
|
}
|
|
298
298
|
|
package/lib/memory/util.js
CHANGED
|
@@ -249,7 +249,7 @@ async function copyMoveExclusive(from, to) {
|
|
|
249
249
|
* @param {string} tag — 私有名后缀(`archiving` / `restoring`);体检按它认崩溃残留
|
|
250
250
|
* @returns {Promise<
|
|
251
251
|
* {ok:true, taken:string} |
|
|
252
|
-
* {ok:false, code:'
|
|
252
|
+
* {ok:false, code:'not_found'|'move_failed'|'changed', cause?:Error, keptAt?:string}
|
|
253
253
|
* >}
|
|
254
254
|
*/
|
|
255
255
|
export async function takeOverSource(from, expected, tag) {
|
|
@@ -259,7 +259,7 @@ export async function takeOverSource(from, expected, tag) {
|
|
|
259
259
|
(err) => err,
|
|
260
260
|
)
|
|
261
261
|
if (moveErr) {
|
|
262
|
-
return { ok: false, code: moveErr?.code === 'ENOENT' ? '
|
|
262
|
+
return { ok: false, code: moveErr?.code === 'ENOENT' ? 'not_found' : 'move_failed', cause: moveErr }
|
|
263
263
|
}
|
|
264
264
|
const current = await readFile(taken).catch(() => null)
|
|
265
265
|
if (current !== null && Buffer.compare(current, expected) === 0) return { ok: true, taken }
|
package/lib/memory/watch.js
CHANGED
|
@@ -107,7 +107,7 @@ export function createDirWatcher(dir, onChange) {
|
|
|
107
107
|
floodCount++
|
|
108
108
|
if (floodCount > WATCH_FLOOD_MAX_EVENTS) {
|
|
109
109
|
// 句柄已坏(多半是目录被删 / 被同名重建):报错这条路会顺手 close 掉,空转立刻停。
|
|
110
|
-
onError({ code: '
|
|
110
|
+
onError({ code: 'flood', message: `${WATCH_FLOOD_WINDOW_MS}ms 内 ${floodCount} 个变更事件,句柄已不可信` })
|
|
111
111
|
return
|
|
112
112
|
}
|
|
113
113
|
if (!burstOpen) {
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sage-mem 业务错误码的**唯一清单**(2026-11-03 归一,2.2.0)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么要有它:`code` 曾经同时存在三套命名法 ——
|
|
5
|
+
* - snake_case:`io_error` / `not_found` / `stale_version`
|
|
6
|
+
* - kebab-case:`not-found` / `move-failed` / `lock-table-unknown`
|
|
7
|
+
* - UPPER:`UNVERIFIED_OVERWRITE` / `FLOOD`
|
|
8
|
+
* 更糟的是**同一概念两个值并存**:`not_found`(reader)与 `not-found`(archive)、
|
|
9
|
+
* `too_big` 与 `too-big`。消费方只能两边都匹配,漏一边就是静默的错误分支
|
|
10
|
+
* (`client/50-shared-ui.js` 的 code → i18n 映射表就踩过)。
|
|
11
|
+
*
|
|
12
|
+
* 现在:**业务码一律 snake_case,且必须在本文件登记**。
|
|
13
|
+
* `test/code-registry-test.mjs` 盯着两件事:
|
|
14
|
+
* 1. `lib/**` 里出现的 `code:` 字面量必须在 `CODES` 里;
|
|
15
|
+
* 2. 清单里不许出现 kebab-case。
|
|
16
|
+
*
|
|
17
|
+
* ⚠️ **不在本清单的三类码,不要往这里加:**
|
|
18
|
+
* - **Node errno**:`ENOENT` / `EEXIST` / `EIO` / `EACCES` …——不是我们的命名空间,
|
|
19
|
+
* 它们来自 `node:fs`,我们只是判读。
|
|
20
|
+
* - **宿主的码**:`FS_SANDBOX_DENIED` / `FS_STALE_VERSION`——我们在解读 DSH 的文件沙箱
|
|
21
|
+
* 与版本闸,改名等于假装自己拥有它。
|
|
22
|
+
* - **`Error.code`**:`SAGE_MEM_UNVERIFIED_OVERWRITE` / `LOCK_TABLE_UNKNOWN`——
|
|
23
|
+
* 挂在**异常对象**上而非返回值里,保留 UPPER 前缀是为了与 errno 区分(`error.code`
|
|
24
|
+
* 和 `result.code` 是两个命名空间,别合并)。
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** 全部业务码。值的唯一合法拼写就是键名。 */
|
|
28
|
+
export const CODES = Object.freeze({
|
|
29
|
+
// ── 读 / 扫描 ──
|
|
30
|
+
missing: 'missing',
|
|
31
|
+
not_found: 'not_found',
|
|
32
|
+
denied: 'denied',
|
|
33
|
+
symlink: 'symlink',
|
|
34
|
+
stat_failed: 'stat_failed',
|
|
35
|
+
not_file: 'not_file',
|
|
36
|
+
not_dir: 'not_dir',
|
|
37
|
+
too_big: 'too_big',
|
|
38
|
+
malformed: 'malformed',
|
|
39
|
+
// ── 写 ──
|
|
40
|
+
invalid: 'invalid',
|
|
41
|
+
invalid_transition: 'invalid_transition',
|
|
42
|
+
io_error: 'io_error',
|
|
43
|
+
changed: 'changed',
|
|
44
|
+
stale_version: 'stale_version',
|
|
45
|
+
revision_changed: 'revision_changed',
|
|
46
|
+
unsupported: 'unsupported',
|
|
47
|
+
exists: 'exists',
|
|
48
|
+
// ── 归档 ──
|
|
49
|
+
not_archived: 'not_archived',
|
|
50
|
+
move_failed: 'move_failed',
|
|
51
|
+
target_exists: 'target_exists',
|
|
52
|
+
delete_failed: 'delete_failed',
|
|
53
|
+
restore_failed: 'restore_failed',
|
|
54
|
+
invalid_name: 'invalid_name',
|
|
55
|
+
reserved: 'reserved',
|
|
56
|
+
locked: 'locked',
|
|
57
|
+
lock_unknown: 'lock_unknown',
|
|
58
|
+
lock_table_unknown: 'lock_table_unknown',
|
|
59
|
+
unverified_overwrite: 'unverified_overwrite',
|
|
60
|
+
// ── 其它 ──
|
|
61
|
+
memory_unavailable: 'memory_unavailable',
|
|
62
|
+
flood: 'flood',
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
/** 值集合(判据用)。 */
|
|
66
|
+
export const CODE_VALUES = new Set(Object.keys(CODES))
|
|
67
|
+
|
|
68
|
+
/** 这个 code 是不是登记过的业务码。 */
|
|
69
|
+
export function isKnownCode(value) {
|
|
70
|
+
return typeof value === 'string' && CODE_VALUES.has(value)
|
|
71
|
+
}
|
package/package.json
CHANGED