sage-mem 2.1.25 → 2.2.1

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.1`** — 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.1`** —— 顶部 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 是最直接的反馈。
@@ -35,6 +35,7 @@
35
35
 
36
36
  import { auditMemoryDir } from './audit.js'
37
37
  import { autodreamToolSchemas, createToolRunner } from './tools.js'
38
+ import { warnLog } from '../memory/util.js'
38
39
  import { CONFIG_VERSION, DEFAULT_CONFIG, DEFAULT_TOKEN_BUDGET, FAILURE_BACKOFF_MS, FEATURE_DIR, LEGACY_SNAPSHOT_DIR, LEGACY_STATE_FILE, SCHEDULE_INTERVAL_MS, STARTUP_CHECK_DELAY_MS, STATE_DIR } from './config.js'
39
40
  import { countSessionsSince, newRunId, stampHuman } from './util.js'
40
41
  import { flushAccess, ledgerFileFor, pickArchiveCandidates, readAccessLedger } from '../memory/access.js'
@@ -131,11 +132,10 @@ export class AutodreamEngine {
131
132
  }
132
133
 
133
134
  log(level, msg) {
134
- const line = `sage-mem autodream: ${msg}`
135
- const logger = this.ctx?.logger
136
- if (level === 'error' && logger?.error) logger.error(line)
137
- else if (logger?.warn) logger.warn(line)
138
- else console.warn(line)
135
+ // 2.2.1:统一走 warnLog —— 前缀 `sage-mem: `,子系统名进正文。原来这里手搓了第二套
136
+ // logger(`sage-mem autodream: ` 前缀 + 自己的 fallback),与 util.js「所有失败都要落到
137
+ // 日志」那条规则重复,而且日志里因此出现两种前缀。
138
+ warnLog(this.ctx, `autodream${level === 'error' ? ' [error]' : ''}: ${msg}`)
139
139
  }
140
140
 
141
141
  // ────────────────────────────── 配置与状态 ──────────────────────────────
@@ -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
@@ -113,6 +113,11 @@ window.__ModuleLoader__.load({
113
113
  * 那种情况下**不能**把目标当成「不存在」去覆盖写(2.1.10 / Q10 的教训)。
114
114
  */
115
115
  function isNotFoundError(error) {
116
+ // 2.2.1:**优先看结构化码** —— 宿主读方法现在抛带 `.code` 的 Error
117
+ // (not_found / invalid_name / reserved / io_error…)。文案正则只留兜底:
118
+ // 换一句措辞就会让降级链走错边,那是 2026-11-01 真机踩过的形状。
119
+ const code = error && error.code;
120
+ if (code === "not_found" || code === "missing" || code === "ENOENT") return true;
116
121
  return /ENOENT|no such file|not exist/i.test(String((error && error.message) || error));
117
122
  }
118
123
 
@@ -137,7 +142,12 @@ window.__ModuleLoader__.load({
137
142
  throw error;
138
143
  };
139
144
  return call.then(function (result) {
140
- if (result && result.ok === false && result.error) return classify(result.error);
145
+ if (result && result.ok === false) {
146
+ // 2.2.1:结构化码在**信封外层**(RemoteResult 的 `{ok:false, code, error}`)——
147
+ // 只看 error 里的文案会漏掉这一半形态。
148
+ if (result.code === "not_found" || result.code === "missing" || result.code === "ENOENT") return false;
149
+ if (result.error) return classify(result.error);
150
+ }
141
151
  return true;
142
152
  }, classify);
143
153
  }
package/lib/index.js CHANGED
@@ -15,7 +15,7 @@ import { isReserved, nameKey, safeName } from './memory/naming.js'
15
15
  import { MEMORY_ARCHIVE_DIR, addArchiveMeta, extractTitle, inspectDir, inspectMemoryEntry, readArchivedMeta, readVerified, scanArchived, scanMemoryFiles, scanStars, shadowsReserved } from './memory/scan.js'
16
16
  import { listConflicts, patchConflict, readConflict, removeConflict, summarizeConflict } from './memory/conflicts.js'
17
17
  import { invalidateMemory, undoMemoryInvalidation } from './memory/arbitrate.js'
18
- import { readSnapshot, writeSnapshot } from './memory/write-protocol.js'
18
+ import { prepareWrite, readSnapshot, writeSnapshot } from './memory/write-protocol.js'
19
19
  import { archiveMemory, archivedRevisionOf, deleteArchivedMemory, restoreMemory } from './memory/archive.js'
20
20
  import { flushAccess, pickArchiveCandidates, readAccessLedger } from './memory/access.js'
21
21
  import { serializeByKey, warnLog, writeFileAtomic } from './memory/util.js'
@@ -29,6 +29,16 @@ import { autodreamConfigSnapshot, clampInt, MAX_RAW_BYTES, parseUiPatch, pending
29
29
  import { createRecallInjector, createSessionInjectState, readRecallDebug } from './recall/index.js'
30
30
  import { mountAutodream, mountMemoryTools, mountObserve, mountWriteGate } from './assembly/index.js'
31
31
 
32
+ /**
33
+ * 读方法的拒绝:**抛**一个带 `.code` 的 Error(写方法返回 `{ok:false, code}` 回执,见 prepareWrite)。
34
+ *
35
+ * 为什么读要抛:调用方拿不到内容就没法继续,异常语义比「回一个空结果」更难被静默忽略。
36
+ * 为什么必须带 code:客户端要靠结构化码判断「是不是真的不存在」——它以前只能正则匹配
37
+ * ENOENT 文案,宿主改一个措辞就会让陈旧宿主降级链走错边。
38
+ */
39
+ function readReject(code, message) {
40
+ return Object.assign(new Error(message), { code })
41
+ }
32
42
  /**
33
43
  * 手动 @Remote marker 注册(Node 24 不解析装饰器语法,手写 decorator context)。
34
44
  */
@@ -123,62 +133,37 @@ export class MemoryGateway extends TypertRemoteService {
123
133
  */
124
134
  async readFile(name) {
125
135
  const safe = safeName(name)
126
- if (!safe) throw new Error('sage-mem: invalid file name')
136
+ if (!safe) throw readReject('invalid_name', 'sage-mem: invalid file name')
127
137
  const full = join(MEMORY_DIR, safe)
128
138
  // 名字不是保留名 ≠ 文件不是保留名:8.3 短名(`MEMORY~1.MD`)文本上与索引毫无关系,
129
139
  // 却是同一个文件。别把索引当普通记忆交出去。
130
140
  if (await shadowsReserved(full)) {
131
- throw new Error(`sage-mem: refusing to read ${safe}: 这是某个保留名(索引 / 流水)的另一个名字`)
141
+ throw readReject('reserved', `sage-mem: refusing to read ${safe}: 这是某个保留名(索引 / 流水)的另一个名字`)
132
142
  }
133
143
  const gate = await inspectMemoryEntry(full)
134
- if (!gate.ok) throw new Error(`sage-mem: refusing to read ${safe}: ${gate.error}`)
144
+ if (!gate.ok) throw readReject(gate.code || 'io_error', `sage-mem: refusing to read ${safe}: ${gate.error}`)
135
145
  return await readSnapshot(full, gate, safe)
136
146
  }
137
147
 
138
148
  /**
139
149
  * 写(新增或覆盖)单个记忆文件。三道闸:保留名不可写、内容必须是字符串、体积有上限;落盘走原子写。
140
- * 拒绝一律返回 { ok: false, error }(不是抛异常)——设置页的调用方按 ok 判定。
150
+ * **错误形态**:拒绝一律返回 `{ ok: false, code, error }`,**不抛异常**——设置页的调用方按 ok 判定。
151
+ * (2.2.1 起「名字不合法」也走回执:原来它是同方法里唯一的 throw,让这句话变成假话。)
141
152
  * 2.1.2(Q02)新增可选第三参 `opts`:`{ expectedRevision }` 对不上 → `stale_revision`(当前内容逐字节
142
153
  * 不变,面板保留草稿);`{ create: true }` 撞名 → `already_exists`(绝不覆盖);不传 opts 走旧行为但落
143
154
  * 一条 warn(additive 契约只保留兼容,不保留静默)。成功时回 `revision`(落盘后的新版本)。
144
155
  */
145
156
  async writeFile(name, content, opts) {
146
- // 保留名判定放在最前面、且以 nameKey 归一:'memory.md' / 'MEMORY.MD' /
147
- // 'Session-Log.md' 在 NTFS 上都是同一个文件,必须一律走 { ok: false } 这条路
148
- // (设置页按 ok 判定,见 client.js 的 submit())。
149
- if (isReserved(name)) {
150
- return { ok: false, file: nameKey(name), error: `sage-mem: reserved file, edit it with the file tools: ${nameKey(name)}` }
151
- }
152
- const safe = safeName(name)
153
- if (!safe) throw new Error('sage-mem: invalid file name')
154
- // 名字文本不是保留名 **不等于** 文件不是保留名:NTFS 的 8.3 短名(`MEMORY~1.MD`)
155
- // 在文本上跟索引毫无关系,inode 却是同一个 —— 实测它能改写索引。
156
- // 保留名保护必须落在**文件身份**上,否则「保留名不可写」这句承诺是假的。
157
- if (await shadowsReserved(join(MEMORY_DIR, safe))) {
158
- return { ok: false, file: safe, error: `sage-mem: reserved file (它的另一个名字是 ${safe}),请用文件工具改` }
159
- }
160
- // 不加这一层,传个对象进来会被 String() 写成 "[object Object]" 还回 { ok: true },
161
- // 原文件就此被一行垃圾顶掉,且没有任何错误信号。
162
- if (typeof content !== 'string') {
163
- return { ok: false, file: safe, error: `sage-mem: content must be a string, got ${typeof content}` }
164
- }
165
- const bytes = Buffer.byteLength(content, 'utf8')
166
- if (bytes > MAX_FILE_BYTES) {
167
- return { ok: false, file: safe, error: `sage-mem: content too large (${bytes} bytes > ${MAX_FILE_BYTES})` }
168
- }
169
- /**
170
- * 目标已存在时也要过闸门:链接 / 硬链接一律拒绝。
171
- *
172
- * 原子写本身不会写穿链接(rename 换掉的是链接本身),但那样「改这条记忆」就变成了
173
- * 「把这个名字指向别处」—— 用户以为改的是记忆,实际磁盘上多了一个指向外部的名字。
174
- * `limitBytes = 0` 表示不在这里判体积(上面刚按字符数查过)。
175
- */
176
- const target = join(MEMORY_DIR, safe)
177
- const existing = await inspectMemoryEntry(target, { limitBytes: null })
178
- if (!existing.ok && existing.code !== 'missing') {
179
- return { ok: false, file: safe, error: `sage-mem: refusing to write ${safe}: ${existing.error}` }
180
- }
181
- return await writeSnapshot(target, content, existing, opts, { ctx: this.ctx, name: safe, kind: 'writeFile' })
157
+ // 三道闸(保留名 / 名字合法 / 影子文件)在 prepareWrite 里,顺序与另外两个写入口一致。
158
+ const pre = await prepareWrite({
159
+ name, content, limitBytes: MAX_FILE_BYTES,
160
+ isReserved, nameKey, safeName,
161
+ shadowCheck: (full) => shadowsReserved(full),
162
+ dir: MEMORY_DIR,
163
+ inspectEntry: inspectMemoryEntry,
164
+ })
165
+ if (!pre.ok) return pre
166
+ return await writeSnapshot(pre.target, content, pre.gate, opts, { ctx: this.ctx, name: pre.safe, kind: "writeFile" })
182
167
  }
183
168
 
184
169
  /** 删除单个记忆文件:保留名一律拒绝(unlink 不可逆)。判定同 writeFile。 */
@@ -187,7 +172,7 @@ export class MemoryGateway extends TypertRemoteService {
187
172
  return { ok: false, error: `sage-mem: reserved file, refusing to delete: ${nameKey(name)}` }
188
173
  }
189
174
  const safe = safeName(name)
190
- if (!safe) throw new Error('sage-mem: invalid file name')
175
+ if (!safe) return { ok: false, code: 'invalid_name', error: 'sage-mem: invalid file name' }
191
176
  // 同 writeFile:8.3 短名能绕过名字文本判据,删除保护也必须看文件身份。
192
177
  if (await shadowsReserved(join(MEMORY_DIR, safe))) {
193
178
  return { ok: false, error: `sage-mem: reserved file (它的另一个名字是 ${safe}),拒绝删除` }
@@ -275,10 +260,10 @@ export class MemoryGateway extends TypertRemoteService {
275
260
  */
276
261
  async readArchived(name) {
277
262
  const safe = safeName(name)
278
- if (!safe) throw new Error('sage-mem: invalid file name')
263
+ if (!safe) throw readReject('invalid_name', 'sage-mem: invalid file name')
279
264
  // 归档区本身先过目录闸门:它若是指向别处的联接,读归档就等于读外部的文件。
280
265
  const dirGate = await inspectDir(MEMORY_ARCHIVE_DIR)
281
- if (!dirGate.ok) throw new Error(`sage-mem: archive dir unavailable: ${dirGate.error}`)
266
+ if (!dirGate.ok) throw readReject('io_error', `sage-mem: archive dir unavailable: ${dirGate.error}`)
282
267
  const full = join(MEMORY_ARCHIVE_DIR, safe)
283
268
  const gate = await inspectMemoryEntry(full)
284
269
  // 找不到就抛:与 readFile 一致。静默返回空串会让面板显示一个「空的记忆」,
@@ -286,8 +271,8 @@ export class MemoryGateway extends TypertRemoteService {
286
271
  if (!gate.ok) {
287
272
  // ⚠️「形状不对」与「不存在」是两回事:把联接 / 超限都报成「文件不存在」,
288
273
  // 会让用户去找一个明明躺在盘上的文件。
289
- if (gate.code === 'missing') throw new Error(`sage-mem: archived file not found: ${safe}`)
290
- throw new Error(`sage-mem: refusing to read archived ${safe}: ${gate.error}`)
274
+ if (gate.code === 'missing') throw readReject('not_found', `sage-mem: archived file not found: ${safe}`)
275
+ throw readReject(gate.code || 'io_error', `sage-mem: refusing to read archived ${safe}: ${gate.error}`)
291
276
  }
292
277
  // 2.1.2(Q02):归档件与活动记忆同一套读快照协议,回 {name, content, revision}。
293
278
  return await readSnapshot(full, gate, safe)
@@ -296,7 +281,8 @@ export class MemoryGateway extends TypertRemoteService {
296
281
  /**
297
282
  * 写一份**已归档**记忆的正文(覆盖整份文件,形状与 `writeFile` 一致)。
298
283
  *
299
- * 三道闸照抄 writeFile:名字先过 safeName、内容必须是字符串、体积上限,最后原子写。
284
+ * 闸门与 `writeFile` 共用 `prepareWrite`(同一顺序):名字先过 safeName、内容必须是字符串、
285
+ * 体积上限、磁盘探针。**错误形态**:返回 `{ ok: false, code, error }`,**不抛异常**。
300
286
  * 额外一条:**盘上那份的归档留痕(`archived_at` / `archived_reason`)不许因为改正文而丢**。
301
287
  * 调用方若交回一份连留痕都没有的内容(「重建 frontmatter」式编辑器就是这样),
302
288
  * 这里按盘上原来的值补回去 —— 归档凭据不该依赖调用方是否体贴。
@@ -305,33 +291,15 @@ export class MemoryGateway extends TypertRemoteService {
305
291
  * 归档件在别处被改过就回 `{ok:false, code:'stale_revision'}`,正文逐字节不变。
306
292
  */
307
293
  async writeArchived(name, content, opts) {
308
- if (isReserved(name)) {
309
- return { ok: false, file: nameKey(name), error: `sage-mem: reserved file, edit it with the file tools: ${nameKey(name)}` }
310
- }
311
- const safe = safeName(name)
312
- if (!safe) throw new Error('sage-mem: invalid file name')
313
- const dirGate = await inspectDir(MEMORY_ARCHIVE_DIR)
314
- if (!dirGate.ok) return { ok: false, file: safe, error: `sage-mem: archive dir unavailable: ${dirGate.error}` }
315
- const full = join(MEMORY_ARCHIVE_DIR, safe)
316
- const gate = await inspectMemoryEntry(full, { limitBytes: null })
317
- // 只改归档区里**已经存在**的文件:这条 remote 不是「把任意文件写进归档区」的后门。
318
- if (!gate.ok) {
319
- return {
320
- ok: false,
321
- file: safe,
322
- error:
323
- gate.code === 'missing'
324
- ? `sage-mem: archived file not found: ${safe}`
325
- : `sage-mem: refusing to write archived ${safe}: ${gate.error}`,
326
- }
327
- }
328
- if (typeof content !== 'string') {
329
- return { ok: false, file: safe, error: `sage-mem: content must be a string, got ${typeof content}` }
330
- }
331
- const bytes = Buffer.byteLength(content, 'utf8')
332
- if (bytes > MAX_FILE_BYTES) {
333
- return { ok: false, file: safe, error: `sage-mem: content too large (${bytes} bytes > ${MAX_FILE_BYTES})` }
334
- }
294
+ const pre = await prepareWrite({
295
+ name, content, limitBytes: MAX_FILE_BYTES,
296
+ isReserved, nameKey, safeName,
297
+ dirCheck: () => inspectDir(MEMORY_ARCHIVE_DIR),
298
+ dir: MEMORY_ARCHIVE_DIR,
299
+ inspectEntry: inspectMemoryEntry,
300
+ allowMissing: false,
301
+ })
302
+ if (!pre.ok) return pre
335
303
  const given = readArchivedMeta(content)
336
304
  let toWrite = content
337
305
  /**
@@ -340,14 +308,14 @@ export class MemoryGateway extends TypertRemoteService {
340
308
  * 丢了它删除确认的内容身份就少了一半)。任何一个缺了才重写整块 frontmatter。
341
309
  */
342
310
  if (!given.archivedAt || !given.archivedReason || !given.archivedRev) {
343
- const onDisk = readArchivedMeta(await readVerified(full, gate, 'utf8'))
311
+ const onDisk = readArchivedMeta(await readVerified(pre.target, pre.gate, "utf8"))
344
312
  const at = given.archivedAt || onDisk.archivedAt
345
313
  const reason = given.archivedReason || onDisk.archivedReason
346
314
  const rev = given.archivedRev || onDisk.archivedRev
347
315
  if (at || reason || rev) toWrite = addArchiveMeta(content, reason, at, rev)
348
316
  }
349
317
  // 2.1.2(Q02):落盘也走写协议 —— 带 opts 时先比盘上版本,对不上就拒绝(正文逐字节不变)。
350
- return await writeSnapshot(full, toWrite, gate, opts, { ctx: this.ctx, name: safe, kind: 'writeArchived' })
318
+ return await writeSnapshot(pre.target, toWrite, pre.gate, opts, { ctx: this.ctx, name: pre.safe, kind: "writeArchived" })
351
319
  }
352
320
 
353
321
  /**
@@ -563,7 +531,7 @@ export class MemoryGateway extends TypertRemoteService {
563
531
  // 它有自己的上限(`MAX_RAW_BYTES`,见上面的注释)。
564
532
  const full = join(MEMORY_DIR, safe)
565
533
  const gate = await inspectMemoryEntry(full, { limitBytes: null })
566
- if (!gate.ok) throw new Error(`sage-mem: refusing to read ${safe}: ${gate.error}`)
534
+ if (!gate.ok) throw readReject(gate.code || 'io_error', `sage-mem: refusing to read ${safe}: ${gate.error}`)
567
535
  // 读不到就抛(与 readFile 一致):静默返回空串会让面板显示一份「空的索引」,
568
536
  // 用户一存就把真索引顶掉。
569
537
  // 2.1.2(Q02):保留名与普通记忆同一套读快照协议 —— 回 {name, content, revision}。
@@ -576,36 +544,24 @@ export class MemoryGateway extends TypertRemoteService {
576
544
  * 这就是 2026-09-25 那次 P0 数据丢失事故(表单重建 frontmatter 把 baseline /
577
545
  * node_type / originSessionId 全抹掉)的解药:索引与流水只许原样进出。
578
546
  *
547
+ * **错误形态**:返回 `{ ok: false, code, error }`,**不抛异常**(名字判据用 `rawName`,因为服务对象就是保留名)。
548
+ *
579
549
  * 2.1.2(Q02):与 `writeFile` 同一套写协议 —— `opts.expectedRevision` 对不上则
580
550
  * `{ok:false, code:'stale_revision'}`(索引逐字节不变);`opts.create` 用于「建立索引骨架」
581
551
  * 这类 createIfAbsent 语义(撞名明确报 `already_exists`,绝不覆盖)。
582
552
  */
583
553
  async writeRaw(name, content, opts) {
584
- if (!isReserved(name)) {
585
- return { ok: false, error: `sage-mem: ${String(name)} is not a reserved file; normal memories use the memory form or writeFile` }
586
- }
587
- const safe = rawName(name)
588
- if (!safe) return { ok: false, error: `sage-mem: invalid file name: ${String(name)}` }
589
- if (typeof content !== 'string') {
590
- return { ok: false, file: safe, error: `sage-mem: content must be a string, got ${typeof content}` }
591
- }
592
- const bytes = Buffer.byteLength(content, 'utf8')
593
- // ⚠️ 与 writeFile 的 512 KB 上限**不是同一个数**:流水天然更大(见 MAX_RAW_BYTES 的注释)。
594
- if (bytes > MAX_RAW_BYTES) {
595
- return {
596
- ok: false,
597
- file: safe,
598
- error: `sage-mem: content too large (${bytes} bytes > ${MAX_RAW_BYTES / (1024 * 1024)} MB raw limit)`,
599
- }
600
- }
601
- const target = join(MEMORY_DIR, safe)
602
- // 与 `writeFile` 同一条理由:目标已存在时若是链接 / 硬链接,一律拒绝 ——
603
- // 「改这份索引」不该变成「把这个名字指向别处」。体积由上面那道 `MAX_RAW_BYTES` 管。
604
- const existing = await inspectMemoryEntry(target, { limitBytes: null })
605
- if (!existing.ok && existing.code !== 'missing') {
606
- return { ok: false, file: safe, error: `sage-mem: refusing to write ${safe}: ${existing.error}` }
607
- }
608
- return await writeSnapshot(target, content, existing, opts, { ctx: this.ctx, name: safe, kind: 'writeRaw' })
554
+ const pre = await prepareWrite({
555
+ name, content, limitBytes: MAX_RAW_BYTES, limitLabel: `${MAX_RAW_BYTES / (1024 * 1024)} MB raw limit`,
556
+ // ⚠️ writeRaw 服务的就是**保留名**,所以名字判据是 `rawName`(它接受索引 / 流水),
557
+ // 不是 `safeName`(那个会把保留名判成非法)。
558
+ isReserved, nameKey, safeName: rawName,
559
+ requireReserved: true,
560
+ dir: MEMORY_DIR,
561
+ inspectEntry: inspectMemoryEntry,
562
+ })
563
+ if (!pre.ok) return pre
564
+ return await writeSnapshot(pre.target, content, pre.gate, opts, { ctx: this.ctx, name: pre.safe, kind: "writeRaw" })
609
565
  }
610
566
  }
611
567
 
@@ -672,15 +628,15 @@ export class StarmapGateway extends TypertRemoteService {
672
628
  // 与 MemoryGateway.readFile 收敛到同一个 safeName:它现在同时排除
673
629
  // MEMORY.md 与 session-log.md,且大小写不敏感。
674
630
  const safe = safeName(name)
675
- if (!safe) throw new Error('sage-starmap: invalid file name')
631
+ if (!safe) throw readReject('invalid_name', 'sage-starmap: invalid file name')
676
632
  const full = join(MEMORY_DIR, safe)
677
633
  // 2026-10-23 审查 R19:面板读原先只过 safeName,与注入链 / 工具读不是同一套闸门。
678
634
  // 面板读不进模型上下文,但「同一个名字在不同入口有不同含义」本身就是缺陷面 —— 收口。
679
635
  if (await shadowsReserved(full)) {
680
- throw new Error(`sage-starmap: refusing to read ${safe}: 这是某个保留名(索引 / 流水)的另一个名字`)
636
+ throw readReject('reserved', `sage-starmap: refusing to read ${safe}: 这是某个保留名(索引 / 流水)的另一个名字`)
681
637
  }
682
638
  const gate = await inspectMemoryEntry(full)
683
- if (!gate.ok) throw new Error(`sage-starmap: refusing to read ${safe}: ${gate.error}`)
639
+ if (!gate.ok) throw readReject(gate.code || 'io_error', `sage-starmap: refusing to read ${safe}: ${gate.error}`)
684
640
  const content = await readVerified(full, gate, 'utf8')
685
641
  return { name: safe, content }
686
642
  }
@@ -76,8 +76,13 @@ export function injectedAt(value) {
76
76
  /** 落盘节流:内存先累计,最多每 30 秒写一次盘。 */
77
77
  export const ACCESS_FLUSH_INTERVAL_MS = 30 * 1000
78
78
 
79
- /** 开了才出声:台账写不进去是**静默降级**,没有这个开关就查不出「为什么阈值算不准」。 */
80
- const DEBUG = process.env.SAGE_MEM_DEBUG === '1'
79
+ /**
80
+ * 开了才出声:台账写不进去是**静默降级**,没有这个开关就查不出「为什么阈值算不准」。
81
+ *
82
+ * 2.2.1:**每次现读**,不做模块级常量 —— 常量在「运行中打开开关」和「测试用 query 重载模块」
83
+ * 两种场景都会停在旧值(`recall/debug.js` 的头注记过同一个坑,那里也是现读)。
84
+ */
85
+ const debugEnabled = () => process.env.SAGE_MEM_DEBUG === '1'
81
86
 
82
87
  /** 「永不自动归档」的类型:feedback 是用户给的做事方式,退役它等于把人的交代扔掉。 */
83
88
  export const NEVER_ARCHIVE_TYPES = new Set(['feedback'])
@@ -292,7 +297,9 @@ async function flushAccessLocked(memoryDir) {
292
297
  })
293
298
  }
294
299
  // 静默降级:注入是插件最核心的事,台账写不进去不许把它拖下水。开了 DEBUG 才出声。
295
- if (DEBUG) console.warn(`[sage-mem][debug] access ledger flush failed: ${err?.message ?? err}`)
300
+ // 2.2.1:前缀归一(原来是 `[sage-mem][debug] `)。本函数签名里没有 ctx,
301
+ // 所以用 console.warn —— 与 warnLog 的 fallback 形态一致。
302
+ if (debugEnabled()) console.warn(`sage-mem: access ledger flush failed: ${err?.message ?? err}`)
296
303
  return { ok: false, error: err?.message ?? String(err) }
297
304
  }
298
305
  }
@@ -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) {
@@ -23,7 +23,7 @@
23
23
 
24
24
  import { createHash } from 'node:crypto'
25
25
  import { mkdir } from 'node:fs/promises'
26
- import { dirname } from 'node:path'
26
+ import { dirname, join } from 'node:path'
27
27
  import { readVerified } from './scan.js'
28
28
  import { warnLog, writeFileAtomic, writeFileExclusive } from './util.js'
29
29
 
@@ -52,6 +52,109 @@ export async function readSnapshot(full, gate, name) {
52
52
  /** 旧调用方的兼容提示(一次写入一行,不做去重 —— 谁漏传一眼就能在日志里翻到)。 */
53
53
  const LEGACY_WARN = '没有带 expectedRevision,按旧协议直接覆盖(面板必须带;旧调用方仅兼容,建议尽快带上)'
54
54
 
55
+ /**
56
+ * 写入前置闸门:**一套顺序**,三处写路径(`writeFile` / `writeArchived` / `writeRaw`)共用。
57
+ *
58
+ * 为什么要有它:这三处曾经各写一遍,闸门顺序还不一样 —— 内容类型检查分别排在
59
+ * 第 3 / 第 6 / 第 3 位,`writeRaw` 用的上限常量也不同。于同一个坏输入从三个入口进去
60
+ * 会报三个不同的「第一个错」,而这里正是历史事故最集中的地方(D4、2026-09-25 P0、R01)。
61
+ *
62
+ * 现在的顺序**固定为**:
63
+ * ① 保留名(`requireReserved` 时反向:只许保留名)
64
+ * ② 名字合法(唯一判据 `shared/md-name.js` 的 `safeName`)
65
+ * ③ 目录闸(归档区专属,可选)
66
+ * ④ 影子文件 / 8.3 短名(内存区专属,可选)
67
+ * ⑤ 内容必须是字符串
68
+ * ⑥ 体积上限
69
+ * ⑦ 磁盘探针(链接 / 硬链接拒绝;`allowMissing` 决定「不存在」算不算合法)
70
+ *
71
+ * 失败一律返回 `{ok:false, code, file, error}` —— **不抛异常**(调用方按 ok 判定)。
72
+ *
73
+ * @param {object} spec
74
+ * @param {string} spec.name 调用方给的名字(未归一)
75
+ * @param {unknown} spec.content 待写正文
76
+ * @param {number} spec.limitBytes 体积上限
77
+ * @param {string} [spec.limitLabel] 错误文案里的上限描述(默认字节数)
78
+ * @param {(n:string)=>boolean} spec.isReserved
79
+ * @param {(n:string)=>string} spec.nameKey
80
+ * @param {(n:string)=>string|null} spec.safeName
81
+ * @param {boolean} [spec.requireReserved=false]
82
+ * @param {((full:string)=>Promise<boolean>)} [spec.shadowCheck]
83
+ * @param {(()=>Promise<{ok:boolean,error?:string}>)} [spec.dirCheck]
84
+ * @param {string} spec.dir 目标目录
85
+ * @param {(full:string, opts:object)=>Promise<object>} spec.inspectEntry
86
+ * @param {boolean} [spec.allowMissing=true] 磁盘探针是否允许「不存在」
87
+ * @returns {Promise<{ok:true, safe:string, target:string, gate:object}
88
+ * | {ok:false, code:string, file?:string, error:string}>}
89
+ */
90
+ export async function prepareWrite(spec) {
91
+ const {
92
+ name, content, limitBytes, limitLabel,
93
+ isReserved, nameKey, safeName,
94
+ requireReserved = false,
95
+ shadowCheck = null,
96
+ dirCheck = null,
97
+ dir,
98
+ inspectEntry,
99
+ allowMissing = true,
100
+ } = spec
101
+
102
+ // ① 保留名。writeRaw 是反向的:它**只**服务索引 / 流水。
103
+ const reserved = typeof isReserved === 'function' ? isReserved(name) : false
104
+ if (requireReserved ? !reserved : reserved) {
105
+ return requireReserved
106
+ ? { ok: false, code: 'reserved', error: `sage-mem: ${String(name)} is not a reserved file; normal memories use the memory form or writeFile` }
107
+ : { ok: false, code: 'reserved', file: nameKey(name), error: `sage-mem: reserved file, edit it with the file tools: ${nameKey(name)}` }
108
+ }
109
+
110
+ // ② 名字合法。这里**返回回执而不是抛** —— 同一方法里「保留名回执、非法名抛异常」曾经
111
+ // 让 writeFile 的 JSDoc(「拒绝一律返回 { ok: false }」)变成假话。
112
+ const safe = safeName(name)
113
+ if (!safe) return { ok: false, code: 'invalid_name', file: '', error: `sage-mem: invalid file name: ${String(name)}` }
114
+
115
+ // ③ 目录闸(归档区:目录读不了就别谈写)
116
+ if (dirCheck) {
117
+ const d = await dirCheck()
118
+ if (!d.ok) return { ok: false, code: 'io_error', file: safe, error: `sage-mem: archive dir unavailable: ${d.error}` }
119
+ }
120
+
121
+ const target = join(dir, safe)
122
+
123
+ // ④ 影子文件:名字文本不是保留名 ≠ 文件不是保留名(NTFS 8.3 短名能改写索引)
124
+ if (shadowCheck && (await shadowCheck(target))) {
125
+ return { ok: false, code: 'reserved', file: safe, error: `sage-mem: reserved file (它的另一个名字是 ${safe}),请用文件工具改` }
126
+ }
127
+
128
+ // ⑤ 内容类型。不加这一层,传个对象进来会被 String() 写成 "[object Object]" 还回 ok:true。
129
+ if (typeof content !== 'string') {
130
+ return { ok: false, code: 'invalid', file: safe, error: `sage-mem: content must be a string, got ${typeof content}` }
131
+ }
132
+
133
+ // ⑥ 体积
134
+ const bytes = Buffer.byteLength(content, 'utf8')
135
+ if (bytes > limitBytes) {
136
+ return { ok: false, code: 'too_big', file: safe, error: `sage-mem: content too large (${bytes} bytes > ${limitLabel || limitBytes})` }
137
+ }
138
+
139
+ // ⑦ 磁盘探针:目标已存在时若是链接 / 硬链接,一律拒绝 ——
140
+ // 「改这条记忆」不该变成「把这个名字指向别处」。体积上面刚查过,这里不判。
141
+ const gate = await inspectEntry(target, { limitBytes: null })
142
+ if (!gate.ok && !(allowMissing && gate.code === 'missing')) {
143
+ return {
144
+ ok: false,
145
+ code: gate.code || 'io_error',
146
+ file: safe,
147
+ error: allowMissing
148
+ ? `sage-mem: refusing to write ${safe}: ${gate.error}`
149
+ : gate.code === 'missing'
150
+ ? `sage-mem: archived file not found: ${safe}`
151
+ : `sage-mem: refusing to write archived ${safe}: ${gate.error}`,
152
+ }
153
+ }
154
+
155
+ return { ok: true, safe, target, gate }
156
+ }
157
+
55
158
  /**
56
159
  * 整份覆盖写:`create` / `expectedRevision` / 旧调用方三种语义,一个出口。
57
160
  *
@@ -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.1",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "icon": "./icon.png",