sage-mem 2.1.25 → 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.25`** — 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.25`** —— 顶部 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
  }
@@ -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.25",
4
+ "version": "2.2.0",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "icon": "./icon.png",