sage-mem 2.1.2 → 2.1.3

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.2`** — 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.1.3`** — 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.2`** —— 顶部 npm 徽章是动态权威。**契约基线:`1.0.0`** —— 第一个稳定版,公开承诺、能力边界与「明确做不到」的清单冻结在
17
+ **当前发布版本:`2.1.3`** —— 顶部 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 是最直接的反馈。
@@ -489,24 +489,65 @@ export function createToolRunner(opts) {
489
489
  const readVersions = new Map()
490
490
 
491
491
  /**
492
- * 记下一次读取对应的版本。取不到可信版本就**什么都不记**(宁可让写入走「拒绝」,
493
- * 也不要拿一个来路不明的版本假装有守卫)。
492
+ * 把「这份内容对应磁盘上的哪个版本」记下来(写回时当守卫)。
494
493
  *
495
- * 一致性:宿主 fs 可用时用 `stat → 读 → stat`,**两次版本相同**才认这份内容是那个版本的快照。
496
- * 中间被人改过就不记 —— 分开读拿不到原子快照,那就诚实地说「不知道」。
494
+ * 取不到可信版本(`version` 为 null)就**什么都不记** —— 宁可让写入走「拒绝」,
495
+ * 也不要拿一个来路不明的版本假装有守卫。
496
+ *
497
+ * ⚠️ 这里只登记、**自己不 stat**。版本由 `readEntryWithVersion` 在**实际读取的前后**
498
+ * 各取一次得到;以前是在 `readFile` **之后**才 stat 两次,读 A、盘上已是 B 时两次 stat
499
+ * 都是 B,于是 A 的正文被绑到 B 的版本上(外部审查 2026-10-31,Q01)。
497
500
  */
498
- async function rememberReadVersion(fullPath, content, truncated) {
501
+ function rememberReadVersion(fullPath, version, truncated) {
502
+ if (!version) return
503
+ const where = memoryPathOf(fullPath, memoryDir)
504
+ if (where.ok) readVersions.set(where.canon, { version, truncated: truncated === true })
505
+ }
506
+
507
+ /**
508
+ * 「读内容」与「取版本」共用的**唯一入口**(外部审查 2026-10-31,Q01)。
509
+ *
510
+ * 为什么必须是入口、不是两段调用:写回守卫要回答的是「**这份正文**对应哪一个版本」。
511
+ * 分开写时,调用方手里是 `readFile` 的返回值和两次 `stat` 的结果,把三者拼起来那一步最容易错
512
+ * —— 真实代码曾是 `readFile → stat → stat`:读的是 A,两次 stat 都读到 B,
513
+ * 于是 A 的正文被绑到 B 的版本,宿主的 CAS 即使完全正确也救不了这个错配。
514
+ *
515
+ * 顺序固定 **stat → read → stat**,两次版本**相同**才认这份内容就是那个版本的快照;
516
+ * 任何一次取不到(没有宿主 fs 服务 / stat 抛错 / 版本字段缺失)都按「版本未知」处理,
517
+ * 不拿当前版本补票 —— 补票等于替调用方担保一份它没读过的内容。
518
+ *
519
+ * 宿主 `fs` 契约里目前**没有**「原子 read-with-version」这一原语(`readText` 只回正文),
520
+ * 所以退到两次 stat 夹住实际读取。将来宿主提供这个原语,应当优先在 open 句柄上一次性拿到。
521
+ *
522
+ * @returns {Promise<{ok:false, gate:object}|{ok:true, gate:object, content:string, version:string|null}>}
523
+ * `gate` 是 `inspectMemoryEntry` 的结果(2.1.4 起内容读还用它做身份核对)
524
+ */
525
+ async function readEntryWithVersion(full) {
526
+ const gate = await inspectMemoryEntry(full)
527
+ if (!gate.ok) return { ok: false, gate }
528
+ const fs = hostFs()
529
+ const hostStat = async () => {
530
+ if (!fs || typeof fs.stat !== 'function') return null
531
+ try {
532
+ const info = await fs.stat(await fs.resolve(full))
533
+ return info?.version ?? null
534
+ } catch {
535
+ return null // 取不到就是未知:不要拿「现在」的版本顶替
536
+ }
537
+ }
538
+ const before = await hostStat()
539
+ let content
499
540
  try {
500
- const fs = hostFs()
501
- if (!fs || typeof fs.stat !== 'function') return
502
- const target = await fs.resolve(fullPath)
503
- const before = await fs.stat(target)
504
- if (!before?.version) return
505
- const after = await fs.stat(target)
506
- if (!after?.version || after.version !== before.version) return
507
- const where = memoryPathOf(fullPath, memoryDir)
508
- if (where.ok) readVersions.set(where.canon, { version: before.version, truncated: truncated === true })
509
- } catch { /* 记不下就不记:调用方会按「未知版本」处理 */ }
541
+ content = await readFile(full, 'utf8')
542
+ } catch (err) {
543
+ // 扫到读之间被删是正常状态;其余读失败照旧抛给上层(外层会转成一次工具失败)。
544
+ if (err?.code === 'ENOENT') return { ok: false, gate: { ok: false, code: 'missing' } }
545
+ throw err
546
+ }
547
+ const after = await hostStat()
548
+ // 两次版本相同才认「内容 = 版本」;只要中间可能被动过就当作未知。
549
+ const version = before && after && before === after ? before : null
550
+ return { ok: true, gate, content, version }
510
551
  }
511
552
 
512
553
  /**
@@ -800,22 +841,21 @@ export function createToolRunner(opts) {
800
841
  * 过闸门再读:**名字在记忆目录里不等于内容住在记忆目录里**。
801
842
  * 注入与面板两条腿都挡住了,这条腿(模型工具)也必须挡 ——
802
843
  * 否则外部文件的内容照样会被读进来、送进模型上下文。
844
+ *
845
+ * 内容与版本一起取(`readEntryWithVersion`):守卫要的是「这份正文对应哪个版本」,
846
+ * 分开取会把 A 的正文绑到 B 的版本上(Q01)。
803
847
  */
804
- const gate = await inspectMemoryEntry(full)
805
- if (!gate.ok) {
806
- return gate.code === 'missing'
848
+ const seen = await readEntryWithVersion(full)
849
+ if (!seen.ok) {
850
+ return seen.gate.code === 'missing'
807
851
  ? `错误:没有这个文件:${basename(full)}`
808
- : `错误:拒绝读取 ${basename(full)}:${gate.error}`
852
+ : `错误:拒绝读取 ${basename(full)}:${seen.gate.error}`
809
853
  }
810
- const content = await readFile(full, 'utf8').catch((err) => {
811
- if (err?.code === 'ENOENT') return null
812
- throw err
813
- })
814
- if (content === null) return `错误:没有这个文件:${basename(full)}`
815
- // 记下「这份内容对应磁盘上的哪个版本」——写回时用它当守卫(外部复审 R01)。
854
+ const content = seen.content
855
+ // 记下「这份内容对应磁盘上的哪个版本」——写回时用它当守卫(外部复审 R01 / Q01)。
816
856
  // 截断的读也要标出来:截断内容不足以作为整份覆盖的依据。
817
857
  const truncated = content.length > READ_LIMIT
818
- await rememberReadVersion(full, content, truncated)
858
+ rememberReadVersion(full, seen.version, truncated)
819
859
  if (truncated) {
820
860
  return `${content.slice(0, READ_LIMIT)}\n\n…(已截断,全文 ${content.length} 字符。注意:截断的读不能作为整份覆盖的依据)`
821
861
  }
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.2",
4
+ "version": "2.1.3",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "icon": "./icon.png",