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 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.24`** — 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.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.1.24`** —— 顶部 npm 徽章是动态权威。**契约基线:`1.0.0`** —— 第一个稳定版,公开承诺、能力边界与「明确做不到」的清单冻结在
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 是最直接的反馈。
@@ -764,7 +764,7 @@ export function createToolRunner(opts) {
764
764
  if (guard.refuse) {
765
765
  // **不知道就不写。** 以前这条路会退化成「用当前版本当守卫」,等于替调用方担保一份它没读过的
766
766
  // 内容 —— 那比「什么都不做」危险得多。转成一条待人工处理的冲突,让人来判。
767
- await recordStaleConflict(fullPath, content, { code: 'UNVERIFIED_OVERWRITE', message: guard.why }, 'external_change')
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 === 'not-found') return `错误:没有这个文件:${basename(full)}`
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 fileSchema = parseSchema({ "file": isStr, "type": isStr, "description": isStr, "size": isNum });
1654
- const listSchema = { parse(v) { if (!Array.isArray(v)) throw new Error("expected array"); v.forEach((f) => fileSchema.parse(f)); return v; } };
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 = withOptional(parseSchema({ "ok": isBool, "file": isStr }), ["code", "revision"]);
1657
- const deleteSchema = parseSchema({ "ok": isBool });
1658
- const starmapStarSchema = parseSchema({ "file": isStr, "kind": isStr, "title": isStr, "desc": isStr, "bytes": isNum, "mtimeMs": isNum });
1659
- const starmapListSchema = { 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; } };
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
- const archiveSchema = parseSchema({ "ok": isBool });
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
- const archivedWriteSchema = withOptional(parseSchema({ "ok": isBool, "file": isStr }), ["code", "revision"]);
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 withOptional(base, keys) {
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 (let i = 0; i < keys.length; i++) if (keys[i] in v) isStr(v[keys[i]]);
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);传了就必须是正确类型。
@@ -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: 'invalid-name', error: `sage-mem: invalid file name: ${String(name)}` }
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: 'lock-unknown',
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' ? 'not-found' : srcGate.code,
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 === 'not-found') {
166
- return { ok: false, code: 'not-found', error: `sage-mem: file not found: ${safe}(归档途中源文件消失了)` }
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 === 'move-failed') {
168
+ if (takenRes.code === 'move_failed') {
169
169
  const moveErr = takenRes.cause
170
170
  return {
171
171
  ok: false,
172
- code: 'move-failed',
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' ? 'not-archived' : dirGate.code,
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: 'not-archived', error: `sage-mem: not archived: ${safe}(${srcGate.error})` }
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: 'target-exists', error: `sage-mem: already exists, refusing to overwrite: ${safe}` }
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 === 'not-found') {
282
- return { ok: false, code: 'not-archived', error: `sage-mem: not archived: ${safe}(恢复途中源文件消失了)` }
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 === 'move-failed') {
284
+ if (takenRes.code === 'move_failed') {
285
285
  const moveErr = takenRes.cause
286
286
  return {
287
287
  ok: false,
288
- code: 'move-failed',
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: 'restore-failed',
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: 'target-exists', error: `sage-mem: already exists, refusing to overwrite: ${safe}` }
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' ? 'not-archived' : dirGate.code,
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' ? 'not-archived' : srcGate.code,
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 === 'not-found') {
445
- return { ok: false, code: 'not-archived', error: `sage-mem: not archived: ${safe}(删除途中它从归档区消失了)` }
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 === 'move-failed') {
447
+ if (takenRes.code === 'move_failed') {
448
448
  const moveErr = takenRes.cause
449
449
  return {
450
450
  ok: false,
451
- code: 'move-failed',
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: 'delete-failed',
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: 'delete-failed',
524
+ code: 'delete_failed',
525
525
  error: `sage-mem: 删除 ${safe} 失败(${delErr?.code ?? delErr}:${delErr?.message ?? delErr}),文件已还回 archive/${safe},没有丢。`,
526
526
  }
527
527
  }
@@ -125,7 +125,7 @@ export async function isLocked(memoryDir, name) {
125
125
  * RMW 全程在**按锁表路径分键的进程内队列**里:读最新表 → 改一项 → 原子写回。
126
126
  * 并发的不同文件因此逐条合并,不再互相覆盖。
127
127
  *
128
- * 坏表上**拒绝写入**(`{ok:false, code:'lock-table-unknown'}`),而不是把表重置成空表 ——
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: 'lock-table-unknown',
139
+ code: 'lock_table_unknown',
140
140
  error: `sage-mem: 锁表不可读(${read.reason}),本次没有写入,也不会把它重置成空表:${name}`,
141
141
  }
142
142
  }
@@ -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 === 'stat-failed') return { ok: false, code: 'io_error', error: `读不到文件属性:${gate.error}` }
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
 
@@ -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' : 'stat-failed',
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: 'not-file', error: '不是普通文件' }
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: 'too-big',
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' : 'stat-failed',
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: 'not-dir', error: '不是目录' }
295
+ if (!info.isDirectory()) return { ok: false, code: 'not_dir', error: '不是目录' }
296
296
  return { ok: true }
297
297
  }
298
298
 
@@ -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:'not-found'|'move-failed'|'changed', cause?:Error, keptAt?:string}
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' ? 'not-found' : 'move-failed', cause: moveErr }
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 }
@@ -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: 'FLOOD', message: `${WATCH_FLOOD_WINDOW_MS}ms 内 ${floodCount} 个变更事件,句柄已不可信` })
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
@@ -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.24",
4
+ "version": "2.2.0",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "icon": "./icon.png",