sage-mem 2.1.0 → 2.1.2

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.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
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
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.
@@ -493,6 +493,8 @@ Your memories are the files in that directory, openable at any time for confirma
493
493
 
494
494
  sage-mem is a pure DSH plugin and declares its host requirement through **`engines.dsh`** in `package.json`: `>=0.1.2-rc.1` — **a one-sided open interval with no upper bound**, so no future DSH version will be blocked by this declaration. `engines.node` requires `>= 18`.
495
495
 
496
+ **Verified on DSH Go (the Android client).** After installing from the in-app plugin market, the settings section and sidebar panel render normally and the `sidebar.panellist` island opens; the memory directory keeps sage-mem's default `~/.sage-mem/memory`, no extra adaptation needed. 2.1.1 fixes the invisible primary-button label on the phone host (foreground token fallback).
497
+
496
498
  Version-by-version compatibility record:
497
499
 
498
500
  | DSH version | Status |
@@ -509,8 +511,10 @@ Version-by-version compatibility record:
509
511
  | 0.2.0-rc.2 | compatible |
510
512
  | 0.2.1-rc.1 | compatible |
511
513
  | 0.2.1-rc.3 | compatible |
514
+ | 0.2.1-rc.4 | compatible |
515
+ | 0.2.1-rc.5 | compatible |
512
516
 
513
- **How to read `compatible`.** It means this plugin's codec and manifest contract has been checked against that host generation (code-level comparison plus the unit/contract suite), **not** that an end-to-end probe was run on that exact host build. The table is a declaration; how each claim was checked is summarized here. Real-host probes so far: `headless` (2026-10-15), `web` (2026-10-16), and `web` on **0.2.1-rc.3** for the 2.0.0 release (2026-10-27: client activation, conflict panel arbitration, injection suppression, rollback to 1.9.2 — 23/23 checks); the `desktop` profile has **not** been probed yet (see [release-checklist.md](https://github.com/gezi-wen/sage-mem/blob/main/docs/release-checklist.md) §4b). CI runs on Ubuntu against **Node 18.x / 20.x / 24.x** with the `0.2.0-rc.1` peer generation; macOS and Windows are not CI-covered, and there is no desktop-host probe yet.
517
+ **How to read `compatible`.** It means this plugin's codec and manifest contract has been checked against that host generation (code-level comparison plus the unit/contract suite), **not** that an end-to-end probe was run on that exact host build. The table is a declaration; how each claim was checked is summarized here. Real-host probes so far: `headless` (2026-10-15), `web` (2026-10-16), `web` on **0.2.1-rc.3** for the 2.0.0 release (2026-10-27: client activation, conflict panel arbitration, injection suppression, rollback to 1.9.2 — 23/23 checks), and the **DSH Go mobile engine on 0.2.1-rc.4** (2026-10-31: installed from the in-app plugin market, sidebar panel and settings section rendered by the app, `sidebar.panellist` island opened); `0.2.1-rc.5` was checked after upgrading the greycloud host the same day (composed config, boot, panel walk-through). The `desktop` profile has **not** been probed yet (see [release-checklist.md](https://github.com/gezi-wen/sage-mem/blob/main/docs/release-checklist.md) §4b). CI runs on Ubuntu against **Node 18.x / 20.x / 24.x** with the `0.2.0-rc.1` peer generation; macOS and Windows are not CI-covered, and there is no desktop-host probe yet.
514
518
 
515
519
  Two points worth spelling out:
516
520
 
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.0`** —— 顶部 npm 徽章是动态权威。**契约基线:`1.0.0`** —— 第一个稳定版,公开承诺、能力边界与「明确做不到」的清单冻结在
17
+ **当前发布版本:`2.1.2`** —— 顶部 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 是最直接的反馈。
@@ -495,6 +495,8 @@ sage-mem 只做三件事,全都在你本机:
495
495
 
496
496
  sage-mem 是纯 DSH 插件,用 `package.json` 的 **`engines.dsh`** 声明宿主要求:`>=0.1.2-rc.1` —— **单边开区间、不设上界**,所以任何未来的 DSH 版本都不会被这道声明挡住。`engines.node` 要求 `>= 18`。
497
497
 
498
+ **已在 DSH Go(Android 客户端)上验证可用**:从 App 内插件市场安装后,设置分区与侧栏面板正常、`sidebar.panellist` 岛屿可打开;记忆目录用 `SAGE_MEM_DIR` 默认的 `~/.sage-mem/memory` 即可,不需要额外适配。2.1.1 修掉了手机宿主上主按钮文字不可见的问题(前景令牌兜底)。
499
+
498
500
  逐版本兼容记录:
499
501
 
500
502
  | DSH 版本 | 状态 |
@@ -511,8 +513,10 @@ sage-mem 是纯 DSH 插件,用 `package.json` 的 **`engines.dsh`** 声明宿
511
513
  | 0.2.0-rc.2 | compatible |
512
514
  | 0.2.1-rc.1 | compatible |
513
515
  | 0.2.1-rc.3 | compatible |
516
+ | 0.2.1-rc.4 | compatible |
517
+ | 0.2.1-rc.5 | compatible |
514
518
 
515
- **`compatible` 怎么读**:它表示本插件的 codec / manifest 契约在该宿主代上核对过(代码层比对 + 单元/契约测试),**不表示**在那个确切宿主构建上跑过端到端探针。这张表是声明,核对方式总结在下面。真实宿主探针目前跑过 `headless`(2026-10-15)、`web`(2026-10-16),以及 **0.2.1-rc.3 上的 `web`**(2026-10-27,2.0.0 发布前:客户端激活、冲突面板裁决、注入抑制、回退到 1.9.2,23/23 项通过);`desktop` profile **还没跑过**(见 [release-checklist.md](https://github.com/gezi-wen/sage-mem/blob/main/docs/release-checklist.md) §4b)。CI 在 Ubuntu 上覆盖 **Node 18.x / 20.x / 24.x**,peer 钉 `0.2.0-rc.1` 一代;macOS 与 Windows 没有 CI 覆盖,desktop 宿主探针也还没有。
519
+ **`compatible` 怎么读**:它表示本插件的 codec / manifest 契约在该宿主代上核对过(代码层比对 + 单元/契约测试),**不表示**在那个确切宿主构建上跑过端到端探针。这张表是声明,核对方式总结在下面。真实宿主探针目前跑过 `headless`(2026-10-15)、`web`(2026-10-16)、**0.2.1-rc.3 上的 `web`**(2026-10-27,2.0.0 发布前:客户端激活、冲突面板裁决、注入抑制、回退到 1.9.2,23/23 项通过),以及 **0.2.1-rc.4 上的 DSH Go 手机引擎**(2026-10-31:App 插件市场安装、侧栏面板与设置分区原生呈现、`sidebar.panellist` 岛屿打开);`0.2.1-rc.5` 于同日升级灰云月殿后按 dump/启动/面板走查核对;`desktop` profile **还没跑过**(见 [release-checklist.md](https://github.com/gezi-wen/sage-mem/blob/main/docs/release-checklist.md) §4b)。CI 在 Ubuntu 上覆盖 **Node 18.x / 20.x / 24.x**,peer 钉 `0.2.0-rc.1` 一代;macOS 与 Windows 没有 CI 覆盖,desktop 宿主探针也还没有。
516
520
 
517
521
  两点值得说明:
518
522
 
package/lib/autodream.js CHANGED
@@ -6,3 +6,8 @@
6
6
  */
7
7
 
8
8
  export { AutodreamEngine } from './autodream/engine.js'
9
+ // 组合根的本地 import 有 ≤20 条的条数护栏(`test/structure-test.mjs`「依赖面不许发散」)。
10
+ // 体检与默认配置本来就是 autodream 这一域的一部分,从这层转出(本文件的存在意义就是
11
+ // 让外部只认一个入口),组合根因此不必再多两条 import —— 不是给组合根找捷径。
12
+ export { auditMemoryDir } from './autodream/audit.js'
13
+ export { DEFAULT_CONFIG } from './autodream/config.js'
package/lib/client.js CHANGED
@@ -1539,8 +1539,8 @@ const StarmapRuntime = (function (require, _react) {
1539
1539
  // 基元(parseSchema / isStr / isBool / isNum / strSchema)在 05-shared.js。
1540
1540
  const fileSchema = parseSchema({ "file": isStr, "type": isStr, "description": isStr, "size": isNum });
1541
1541
  const listSchema = { parse(v) { if (!Array.isArray(v)) throw new Error("expected array"); v.forEach((f) => fileSchema.parse(f)); return v; } };
1542
- const readSchema = parseSchema({ "name": isStr, "content": isStr });
1543
- const writeSchema = parseSchema({ "ok": isBool, "file": isStr });
1542
+ const readSchema = withOptional(parseSchema({ "name": isStr, "content": isStr }), ["revision"]);
1543
+ const writeSchema = withOptional(parseSchema({ "ok": isBool, "file": isStr }), ["code", "revision"]);
1544
1544
  const deleteSchema = parseSchema({ "ok": isBool });
1545
1545
  const starmapStarSchema = parseSchema({ "file": isStr, "kind": isStr, "title": isStr, "desc": isStr, "bytes": isNum, "mtimeMs": isNum });
1546
1546
  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; } };
@@ -1565,12 +1565,40 @@ const StarmapRuntime = (function (require, _react) {
1565
1565
  },
1566
1566
  };
1567
1567
  // 归档文件的读 / 写(B1b):形状与活跃记忆那套的 readSchema / writeSchema 对称。
1568
- const archivedReadSchema = parseSchema({ "name": isStr, "content": isStr });
1569
- const archivedWriteSchema = parseSchema({ "ok": isBool, "file": isStr });
1568
+ const archivedReadSchema = withOptional(parseSchema({ "name": isStr, "content": isStr }), ["revision"]);
1569
+ const archivedWriteSchema = withOptional(parseSchema({ "ok": isBool, "file": isStr }), ["code", "revision"]);
1570
1570
  // 「彻底删除」的入参:归档代次(`archivedAt`)。它不是装饰 —— 宿主拿它核对
1571
1571
  // 「要删的是不是用户确认过的那一版」。老归档件没有留痕时传空串,所以只校类型。
1572
1572
  const archiveStampSchema = strSchema;
1573
1573
 
1574
+ // ── 2.1.2(Q02)写协议:读快照 revision / 写回执的 additive 字段 / 写选项 ──
1575
+ /**
1576
+ * **可选**字段校型:新宿主一定给(读 revision、写 code / revision),旧宿主没有。
1577
+ * 混合版本环境里「旧宿主 + 新面板」必须还能用,所以这里不能当必填字段拒掉;
1578
+ * 但字段给了就必须是字符串 —— 否则面板拿到一个数字或对象会静默走错分支。
1579
+ */
1580
+ function withOptional(base, keys) {
1581
+ return {
1582
+ parse(v) {
1583
+ base.parse(v);
1584
+ for (let i = 0; i < keys.length; i++) if (keys[i] in v) isStr(v[keys[i]]);
1585
+ return v;
1586
+ },
1587
+ };
1588
+ }
1589
+ /**
1590
+ * 写协议的第三参(`{ expectedRevision?, create? }`)——**两个字段都可选**。
1591
+ * 不传 = 旧调用方,行为与从前一致(宿主会打一条 warn);传了就必须是正确类型。
1592
+ */
1593
+ const writeOptsSchema = {
1594
+ parse(v) {
1595
+ if (v === null || typeof v !== "object" || Array.isArray(v)) throw new Error("sage-mem: expected object");
1596
+ if ("expectedRevision" in v) isStr(v.expectedRevision);
1597
+ if ("create" in v) isBool(v.create);
1598
+ return v;
1599
+ },
1600
+ };
1601
+
1574
1602
  // ── autodream 的 codec ──
1575
1603
  // 刻意写得宽:这几个方法返回的是配置 / 状态对象,字段会随版本增删。
1576
1604
  // 用 parseSchema 逐字段硬写一遍的代价是 —— host 那边加一个字段,client 的旧
@@ -1736,6 +1764,8 @@ const StarmapRuntime = (function (require, _react) {
1736
1764
  parameters: [
1737
1765
  { name: "name", wire: "name", source: "json", codec: { mode: "strict", typeSymbol: "sage-mem/types#FileName", schema: strSchema, create: () => strSchema } },
1738
1766
  { name: "content", wire: "content", source: "json", codec: { mode: "strict", typeSymbol: "sage-mem/types#FileContent", schema: strSchema, create: () => strSchema } },
1767
+ // 2.1.2(Q02):写协议第三参(expectedRevision / create),可选 —— 与宿主 manifest 同一条。
1768
+ { name: "opts", wire: "opts", source: "json", acceptsUndefined: true, codec: { mode: "strict", typeSymbol: "sage-mem/types#WriteOpts", schema: writeOptsSchema, create: () => writeOptsSchema } },
1739
1769
  ],
1740
1770
  result: { mode: "strict", typeSymbol: "sage-mem/types#WriteResult", schema: writeSchema, create: () => writeSchema },
1741
1771
  sourceLocation: { "file": "lib/index.js", "line": 1, "column": 1 },
@@ -1808,6 +1838,7 @@ const StarmapRuntime = (function (require, _react) {
1808
1838
  parameters: [
1809
1839
  { name: "name", wire: "name", source: "json", codec: { mode: "strict", typeSymbol: "sage-mem/types#ArchivedFileName", schema: strSchema, create: () => strSchema } },
1810
1840
  { name: "content", wire: "content", source: "json", codec: { mode: "strict", typeSymbol: "sage-mem/types#ArchivedFileContent", schema: strSchema, create: () => strSchema } },
1841
+ { name: "opts", wire: "opts", source: "json", acceptsUndefined: true, codec: { mode: "strict", typeSymbol: "sage-mem/types#WriteOpts", schema: writeOptsSchema, create: () => writeOptsSchema } },
1811
1842
  ],
1812
1843
  result: { mode: "strict", typeSymbol: "sage-mem/types#ArchivedWriteResult", schema: archivedWriteSchema, create: () => archivedWriteSchema },
1813
1844
  sourceLocation: { "file": "lib/index.js", "line": 1, "column": 1 },
@@ -1932,6 +1963,7 @@ const StarmapRuntime = (function (require, _react) {
1932
1963
  parameters: [
1933
1964
  { name: "name", wire: "name", source: "json", codec: { mode: "strict", typeSymbol: "sage-mem/types#ReservedFileName", schema: strSchema, create: () => strSchema } },
1934
1965
  { name: "content", wire: "content", source: "json", codec: { mode: "strict", typeSymbol: "sage-mem/types#RawFileContent", schema: strSchema, create: () => strSchema } },
1966
+ { name: "opts", wire: "opts", source: "json", acceptsUndefined: true, codec: { mode: "strict", typeSymbol: "sage-mem/types#WriteOpts", schema: writeOptsSchema, create: () => writeOptsSchema } },
1935
1967
  ],
1936
1968
  result: { mode: "strict", typeSymbol: "sage-mem/types#RawWriteResult", schema: objCodec, create: () => objCodec },
1937
1969
  sourceLocation: { "file": "lib/index.js", "line": 1, "column": 1 },
@@ -2224,8 +2256,8 @@ const StarmapRuntime = (function (require, _react) {
2224
2256
  ".smem-btn{border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);border-radius:8px;padding:4px 12px;font-size:12px;cursor:pointer;font-family:inherit;white-space:nowrap;}",
2225
2257
  ".smem-btn:hover{border-color:var(--dsw-alias-brand-primary,#4d76e6);color:var(--dsw-alias-brand-primary,#4d76e6);}",
2226
2258
  ".smem-btn:disabled{opacity:.5;cursor:default;}",
2227
- ".smem-btn-primary{background:var(--dsw-alias-brand-primary,#4d76e6);border-color:var(--dsw-alias-brand-primary,#4d76e6);color:var(--dsw-alias-bg-base,#fff);}",
2228
- ".smem-btn-primary:hover{color:var(--dsw-alias-bg-base,#fff);opacity:.88;}",
2259
+ ".smem-btn-primary{background:var(--dsw-alias-brand-primary,#4d76e6);border-color:var(--dsw-alias-brand-primary,#4d76e6);color:var(--dsw-alias-label-primary-foreground,var(--dsw-alias-bg-base,#10131a));}",
2260
+ ".smem-btn-primary:hover{color:var(--dsw-alias-label-primary-foreground,var(--dsw-alias-bg-base,#10131a));opacity:.88;}",
2229
2261
  ".smem-btn-danger-ghost{color:var(--dsw-alias-state-error-primary,#ef4444);border-color:var(--dsw-alias-border-l2);}",
2230
2262
  ".smem-btn-danger{background:var(--dsw-alias-state-error-primary,#dc2626);border-color:var(--dsw-alias-state-error-primary,#dc2626);color:#fff;}",
2231
2263
  ".smem-btn-danger:hover{color:#fff;opacity:.9;}",
@@ -5769,6 +5801,10 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
5769
5801
  // 打开时展示给用户的类型(判断他是否真的改过类型)
5770
5802
  const sFormBaseType = react.useState("");
5771
5803
  const formBaseType = sFormBaseType[0], setFormBaseType = sFormBaseType[1];
5804
+ // 2.1.2(Q02):打开编辑时读到的**版本**(readFile 的 revision)。保存时原样带回;
5805
+ // 宿主发现盘上已变就回 stale_revision,编辑框与草稿原地保留,绝不自动重试覆盖。
5806
+ const sFormRevision = react.useState(null);
5807
+ const formRevision = sFormRevision[0], setFormRevision = sFormRevision[1];
5772
5808
  const sBusy = react.useState(false);
5773
5809
  const busy = sBusy[0], setBusy = sBusy[1];
5774
5810
  const sOk = react.useState(false);
@@ -5797,6 +5833,9 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
5797
5833
  const archEdit = sArchEdit[0], setArchEdit = sArchEdit[1];
5798
5834
  const sArchText = react.useState("");
5799
5835
  const archText = sArchText[0], setArchText = sArchText[1];
5836
+ // 2.1.2(Q02):这份归档件读到的版本(保存时带回;冲突时草稿保留)
5837
+ const sArchRevision = react.useState(null);
5838
+ const archRevision = sArchRevision[0], setArchRevision = sArchRevision[1];
5800
5839
  const sArchLoad = react.useState(false);
5801
5840
  const archLoad = sArchLoad[0], setArchLoad = sArchLoad[1];
5802
5841
  const sArchSaving = react.useState(false);
@@ -5837,6 +5876,9 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
5837
5876
  const rawText = sRawText[0], setRawText = sRawText[1];
5838
5877
  const sRawIsNew = react.useState(false);
5839
5878
  const rawIsNew = sRawIsNew[0], setRawIsNew = sRawIsNew[1];
5879
+ // 2.1.2(Q02):这份保留名文件读到的版本;null + rawIsNew = 走 createIfAbsent 新建
5880
+ const sRawRevision = react.useState(null);
5881
+ const rawRevision = sRawRevision[0], setRawRevision = sRawRevision[1];
5840
5882
  const sRawBusy = react.useState(false);
5841
5883
  const rawBusy = sRawBusy[0], setRawBusy = sRawBusy[1];
5842
5884
  const sRawLoading = react.useState(false);
@@ -5971,6 +6013,7 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
5971
6013
  function closeForm() {
5972
6014
  setFormOpen(false); setEditingFile(null); setFormName(""); setFormDesc(""); setFormBody(""); setFormType("feedback");
5973
6015
  setFormFm(null); setFormBaseType(""); setFormTags("");
6016
+ setFormRevision(null);
5974
6017
  }
5975
6018
 
5976
6019
  // 空记忆库时「建立索引骨架」写进去的引导文件。它只是给人看的起点:
@@ -6056,6 +6099,8 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
6056
6099
  setFormTags(detectTags(parts.fm).join(", "));
6057
6100
  // 关键:原 frontmatter 原文留着,保存时「就地合并」而不是重建
6058
6101
  setFormFm(parts.hasFm ? parts.fm : null);
6102
+ // 2.1.2(Q02):把这一版的 revision 一起记住(保存时带回;旧宿主没有就是 null)
6103
+ setFormRevision(typeof data.revision === "string" ? data.revision : null);
6059
6104
  setFormOpen(true);
6060
6105
  })
6061
6106
  .catch(function (e) { setError(String((e && e.message) || e)); })
@@ -6072,6 +6117,7 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
6072
6117
  setFormBody("");
6073
6118
  setFormFm(null);
6074
6119
  setFormTags("");
6120
+ setFormRevision(null);
6075
6121
  setFormOpen(true);
6076
6122
  }
6077
6123
 
@@ -6098,9 +6144,16 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
6098
6144
  tags: tags,
6099
6145
  });
6100
6146
  }
6101
- remote.writeFile(safeName, content)
6147
+ // 2.1.2(Q02):编辑已有文件时交回读到的 revision(盘上变了 → 宿主拒绝);
6148
+ // 新建走 createIfAbsent —— 撞上同名文件是**明确报已存在**,绝不覆盖入口。
6149
+ const writeOpts = editingFile
6150
+ ? (formRevision ? { expectedRevision: formRevision } : undefined)
6151
+ : { create: true };
6152
+ remote.writeFile(safeName, content, writeOpts)
6102
6153
  .then(function (r) {
6103
6154
  // host 侧对保留名 / 非字符串内容 / 超限一律返回 { ok: false, error },不再抛异常。
6155
+ // 冲突看**结构化 code**(stale_revision / already_exists),不解析文案:
6156
+ // 编辑框不关、草稿原地保留,由用户看过最新内容后自己决定重新应用 —— 绝不自动重试覆盖。
6104
6157
  if (r && r.ok === false) { setError(r.error || t("error.writeRejected")); return; }
6105
6158
  closeForm();
6106
6159
  setNotice(t(editingFile ? "notice.updated" : "notice.saved", { file: safeName }));
@@ -6233,11 +6286,14 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
6233
6286
  setArchDelete(null);
6234
6287
  setArchEdit(file);
6235
6288
  setArchText("");
6289
+ setArchRevision(null);
6236
6290
  setArchLoad(true);
6237
6291
  remote.readArchived(file)
6238
6292
  .then(function (r) {
6239
6293
  const d = unwrap(r);
6240
6294
  setArchText(d && typeof d.content === "string" ? d.content : "");
6295
+ // 2.1.2(Q02):记住这一版的 revision(保存时带回)
6296
+ setArchRevision(d && typeof d.revision === "string" ? d.revision : null);
6241
6297
  setArchLoad(false);
6242
6298
  })
6243
6299
  .catch(function (e) {
@@ -6254,7 +6310,10 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
6254
6310
  }
6255
6311
  setArchSaving(true); setError(null); setNotice("");
6256
6312
  // 原样交回:不 trim、不补 frontmatter、不做任何「智能」处理。
6257
- remote.writeArchived(file, archText)
6313
+ // 2.1.2(Q02):带上读到的 revision;归档件在别处被改过 → 宿主回 stale_revision,
6314
+ // 编辑框与文本原地保留(不自动重试覆盖)。
6315
+ const writeOpts = archRevision ? { expectedRevision: archRevision } : undefined;
6316
+ remote.writeArchived(file, archText, writeOpts)
6258
6317
  .then(function (r) {
6259
6318
  if (r && r.ok === false) { setError(r.error || t("error.saveRejected")); return; }
6260
6319
  setArchEdit(null);
@@ -6265,7 +6324,7 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
6265
6324
  .then(function () { setArchSaving(false); });
6266
6325
  }
6267
6326
 
6268
- function cancelArchEdit() { setArchEdit(null); setArchText(""); }
6327
+ function cancelArchEdit() { setArchEdit(null); setArchText(""); setArchRevision(null); }
6269
6328
 
6270
6329
  // ── 工具面板的三个数据通道 ──────────────────────────────────────
6271
6330
 
@@ -6578,11 +6637,14 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
6578
6637
  setRawName(name);
6579
6638
  setRawText("");
6580
6639
  setRawIsNew(false);
6640
+ setRawRevision(null);
6581
6641
  setRawLoading(true);
6582
6642
  remote.readRaw(name)
6583
6643
  .then(function (r) {
6584
6644
  const d = unwrap(r);
6585
6645
  setRawText(d && typeof d.content === "string" ? d.content : "");
6646
+ // 2.1.2(Q02):记住这一版的 revision(保存时带回)
6647
+ setRawRevision(d && typeof d.revision === "string" ? d.revision : null);
6586
6648
  setRawLoading(false);
6587
6649
  })
6588
6650
  .catch(function (e) {
@@ -6606,11 +6668,18 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
6606
6668
  if (typeof remote.writeRaw !== "function") { setError(t("error.noWriteRaw")); return; }
6607
6669
  setRawBusy(true); setError(null); setNotice("");
6608
6670
  // 原样交回:不 trim、不补 frontmatter、不做任何「智能」处理。
6609
- remote.writeRaw(name, rawText)
6671
+ // 2.1.2(Q02):新建走 createIfAbsent(撞名明确失败、不覆盖);编辑带读到的 revision
6672
+ // (盘上变了 → stale_revision,文本原地保留,绝不自动重试覆盖)。
6673
+ const writeOpts = rawIsNew
6674
+ ? { create: true }
6675
+ : (rawRevision ? { expectedRevision: rawRevision } : undefined);
6676
+ remote.writeRaw(name, rawText, writeOpts)
6610
6677
  .then(function (r) {
6611
6678
  if (r && r.ok === false) { setError(r.error || t("error.saveRejected")); return; }
6612
6679
  setNotice(t(rawIsNew ? "notice.rawNew" : "notice.saved", { file: name }));
6613
6680
  setRawIsNew(false);
6681
+ // 保存成功后把版本换成刚写下的那一版,连续保存不用重读
6682
+ if (r && typeof r.revision === "string") setRawRevision(r.revision);
6614
6683
  })
6615
6684
  .catch(function (e) { setError(String((e && e.message) || e)); })
6616
6685
  .then(function () { setRawBusy(false); });
package/lib/index.js CHANGED
@@ -14,9 +14,7 @@
14
14
  * 旧 worker 版见 git 分支 `sqlite-worker`。
15
15
  */
16
16
 
17
- import { AutodreamEngine } from './autodream.js'
18
- import { auditMemoryDir } from './autodream/audit.js'
19
- import { DEFAULT_CONFIG } from './autodream/config.js'
17
+ import { AutodreamEngine, DEFAULT_CONFIG, auditMemoryDir } from './autodream.js'
20
18
  import { LIMIT_SPECS, MAX_BASELINE, MAX_CHARS_PER_FILE, MAX_FILE_BYTES, MAX_RESULTS, MAX_SESSION_BYTES, MEMORY_DIR, RECALL_DISCIPLINE, RESERVED_FILES, SESSIONS_ROOT, STALE_DAYS, STATE_ROOT } from './memory/config.js'
21
19
  import { INDEX_FILE_KEY, SESSION_LOG_KEY, isPlainMdName } from './shared/md-name.js'
22
20
  import { parseAliases, parseBaseline, parseBaselinePriority, parseFrontmatter, parseTags } from './memory/frontmatter.js'
@@ -25,6 +23,7 @@ import { isReserved, nameKey, safeName } from './memory/naming.js'
25
23
  import { MEMORY_ARCHIVE_DIR, addArchiveMeta, extractTitle, inspectDir, inspectMemoryEntry, readArchivedMeta, readVerified, scanArchived, scanMemoryFiles, scanStars, shadowsReserved } from './memory/scan.js'
26
24
  import { listConflicts, patchConflict, readConflict, removeConflict, summarizeConflict } from './memory/conflicts.js'
27
25
  import { invalidateMemory, undoMemoryInvalidation } from './memory/arbitrate.js'
26
+ import { readSnapshot, writeSnapshot } from './memory/write-protocol.js'
28
27
  import { archiveMemory, deleteArchivedMemory, restoreMemory } from './memory/archive.js'
29
28
  import { flushAccess, pickArchiveCandidates, readAccessLedger } from './memory/access.js'
30
29
  import { warnLog, writeFileAtomic } from './memory/util.js'
@@ -131,6 +130,9 @@ export class MemoryGateway extends TypertRemoteService {
131
130
  *
132
131
  * 过闸门再读:名字在记忆目录里**不等于**内容住在记忆目录里 —— 符号链接 / 目录联接 /
133
132
  * 硬链接都能让一次「读自己的记忆」变成读别人的文件,而读到的内容会被注入模型上下文。
133
+ *
134
+ * 2.1.2(Q02):返回值多一个 `revision`(正文字节的 sha256)。保存时把它带回来,
135
+ * 盘上版本变了就拒绝覆盖(见 `memory/write-protocol.js`)。
134
136
  */
135
137
  async readFile(name) {
136
138
  const safe = safeName(name)
@@ -143,8 +145,7 @@ export class MemoryGateway extends TypertRemoteService {
143
145
  }
144
146
  const gate = await inspectMemoryEntry(full)
145
147
  if (!gate.ok) throw new Error(`sage-mem: refusing to read ${safe}: ${gate.error}`)
146
- const content = await readVerified(full, gate, 'utf8')
147
- return { name: safe, content }
148
+ return await readSnapshot(full, gate, safe)
148
149
  }
149
150
 
150
151
  /**
@@ -152,8 +153,16 @@ export class MemoryGateway extends TypertRemoteService {
152
153
  *
153
154
  * 三道闸:保留名不可写、内容必须是字符串、体积有上限;落盘走原子写。
154
155
  * 拒绝一律返回 { ok: false, error }(不是抛异常)——设置页的调用方按 ok 判定。
156
+ *
157
+ * 2.1.2(Q02):可选第三参 `opts`
158
+ * · `{ expectedRevision }`:读快照时拿到的 revision;盘上对不上 → `{ok:false, code:'stale_revision'}`,
159
+ * 当前内容逐字节不变(面板保留草稿,提示重新应用);
160
+ * · `{ create: true }`:新建语义(createIfAbsent)。撞名 → `{ok:false, code:'already_exists'}`,
161
+ * **绝不**覆盖已有正文;
162
+ * · 不传 `opts`:旧调用方照旧覆盖,但会落一条 warn(additive 契约里只保留兼容,不保留静默)。
163
+ * 成功时回 `revision`(落盘后的新版本),调用方可以直接拿它继续保存。
155
164
  */
156
- async writeFile(name, content) {
165
+ async writeFile(name, content, opts) {
157
166
  // 保留名判定放在最前面、且以 nameKey 归一:'memory.md' / 'MEMORY.MD' /
158
167
  // 'Session-Log.md' 在 NTFS 上都是同一个文件,必须一律走 { ok: false } 这条路
159
168
  // (设置页按 ok 判定,见 client.js 的 submit())。
@@ -189,8 +198,7 @@ export class MemoryGateway extends TypertRemoteService {
189
198
  if (!existing.ok && existing.code !== 'missing') {
190
199
  return { ok: false, file: safe, error: `sage-mem: refusing to write ${safe}: ${existing.error}` }
191
200
  }
192
- await writeFileAtomic(target, content)
193
- return { ok: true, file: safe }
201
+ return await writeSnapshot(target, content, existing, opts, { ctx: this.ctx, name: safe, kind: 'writeFile' })
194
202
  }
195
203
 
196
204
  /** 删除单个记忆文件:保留名一律拒绝(unlink 不可逆)。判定同 writeFile。 */
@@ -301,8 +309,8 @@ export class MemoryGateway extends TypertRemoteService {
301
309
  if (gate.code === 'missing') throw new Error(`sage-mem: archived file not found: ${safe}`)
302
310
  throw new Error(`sage-mem: refusing to read archived ${safe}: ${gate.error}`)
303
311
  }
304
- const content = await readVerified(full, gate, 'utf8')
305
- return { name: safe, content }
312
+ // 2.1.2(Q02):归档件与活动记忆同一套读快照协议,回 {name, content, revision}。
313
+ return await readSnapshot(full, gate, safe)
306
314
  }
307
315
 
308
316
  /**
@@ -312,8 +320,11 @@ export class MemoryGateway extends TypertRemoteService {
312
320
  * 额外一条:**盘上那份的归档留痕(`archived_at` / `archived_reason`)不许因为改正文而丢**。
313
321
  * 调用方若交回一份连留痕都没有的内容(「重建 frontmatter」式编辑器就是这样),
314
322
  * 这里按盘上原来的值补回去 —— 归档凭据不该依赖调用方是否体贴。
323
+ *
324
+ * 2.1.2(Q02):与 `writeFile` 同一套写协议 —— 可选 `opts.expectedRevision`,
325
+ * 归档件在别处被改过就回 `{ok:false, code:'stale_revision'}`,正文逐字节不变。
315
326
  */
316
- async writeArchived(name, content) {
327
+ async writeArchived(name, content, opts) {
317
328
  if (isReserved(name)) {
318
329
  return { ok: false, file: nameKey(name), error: `sage-mem: reserved file, edit it with the file tools: ${nameKey(name)}` }
319
330
  }
@@ -349,8 +360,8 @@ export class MemoryGateway extends TypertRemoteService {
349
360
  toWrite = addArchiveMeta(content, onDisk.archivedReason, onDisk.archivedAt)
350
361
  }
351
362
  }
352
- await writeFileAtomic(full, toWrite)
353
- return { ok: true, file: safe }
363
+ // 2.1.2(Q02):落盘也走写协议 —— 带 opts 时先比盘上版本,对不上就拒绝(正文逐字节不变)。
364
+ return await writeSnapshot(full, toWrite, gate, opts, { ctx: this.ctx, name: safe, kind: 'writeArchived' })
354
365
  }
355
366
 
356
367
  /**
@@ -549,8 +560,8 @@ export class MemoryGateway extends TypertRemoteService {
549
560
  if (!gate.ok) throw new Error(`sage-mem: refusing to read ${safe}: ${gate.error}`)
550
561
  // 读不到就抛(与 readFile 一致):静默返回空串会让面板显示一份「空的索引」,
551
562
  // 用户一存就把真索引顶掉。
552
- const content = await readVerified(full, gate, 'utf8')
553
- return { name: safe, content }
563
+ // 2.1.2(Q02):保留名与普通记忆同一套读快照协议 —— 回 {name, content, revision}。
564
+ return await readSnapshot(full, gate, safe)
554
565
  }
555
566
 
556
567
  /**
@@ -558,8 +569,12 @@ export class MemoryGateway extends TypertRemoteService {
558
569
  *
559
570
  * 这就是 2026-09-25 那次 P0 数据丢失事故(表单重建 frontmatter 把 baseline /
560
571
  * node_type / originSessionId 全抹掉)的解药:索引与流水只许原样进出。
572
+ *
573
+ * 2.1.2(Q02):与 `writeFile` 同一套写协议 —— `opts.expectedRevision` 对不上则
574
+ * `{ok:false, code:'stale_revision'}`(索引逐字节不变);`opts.create` 用于「建立索引骨架」
575
+ * 这类 createIfAbsent 语义(撞名明确报 `already_exists`,绝不覆盖)。
561
576
  */
562
- async writeRaw(name, content) {
577
+ async writeRaw(name, content, opts) {
563
578
  if (!isReserved(name)) {
564
579
  return { ok: false, error: `sage-mem: ${String(name)} is not a reserved file; normal memories use the memory form or writeFile` }
565
580
  }
@@ -584,8 +599,7 @@ export class MemoryGateway extends TypertRemoteService {
584
599
  if (!existing.ok && existing.code !== 'missing') {
585
600
  return { ok: false, file: safe, error: `sage-mem: refusing to write ${safe}: ${existing.error}` }
586
601
  }
587
- await writeFileAtomic(target, content)
588
- return { ok: true, file: safe }
602
+ return await writeSnapshot(target, content, existing, opts, { ctx: this.ctx, name: safe, kind: 'writeRaw' })
589
603
  }
590
604
  }
591
605
 
@@ -0,0 +1,111 @@
1
+ /**
2
+ * sage-mem 记忆层 —— 写协议:**读快照带 revision、整份覆盖带守卫**(2.1.2 / 外部审查 Q02)。
3
+ *
4
+ * 问题:原来的 `readFile` 只交正文、不交「这份正文是哪一版」;`writeFile` / `writeArchived` /
5
+ * `writeRaw` 只管原子替换(不暴露半截内容),**不保证没有别的更新** —— 面板读 A、别处存 B、
6
+ * 面板再把基于 A 的 C 存回去,B 就没了,而两边都报成功。
7
+ *
8
+ * 契约(additive,旧调用方不受影响):
9
+ * · 读:结果多一个 `revision`(正文**字节**的 sha256);
10
+ * · 写:可选 `opts.expectedRevision`;对不上 → `{ok:false, code:'stale_revision'}`,
11
+ * 当前内容一个字节不动;调用方拿新 revision 重读、比较、重新应用。
12
+ * · 写:可选 `opts.create`(createIfAbsent)—— 新建撞名一律 `{ok:false, code:'already_exists'}`,
13
+ * **绝不**走覆盖入口;
14
+ * · 不传 `opts` 的旧调用方照旧覆盖,但会落一条 warn(静默无守卫覆盖不再允许)。
15
+ *
16
+ * 为什么单列一个模块而不是长在 `lib/index.js`:组合根有 ≤1000 行的结构守卫
17
+ * (`test/structure-test.mjs`),而「读版本 → 比版本 → 写」这段顺序是**协议**不是组合根的接线。
18
+ *
19
+ * ⚠️ 边界:这是**内容 hash 守卫**,不是跨进程锁。校验与落盘之间仍有一个极窄窗口
20
+ * (外部编辑器恰在此刻改文件仍会被覆盖);进程内并发由 `createWriteQueue` 那一层负责,
21
+ * 面板这条路上「读快照 → 用户思考 → 保存」的窗口正是 revision 覆盖的那一段。
22
+ */
23
+
24
+ import { createHash } from 'node:crypto'
25
+ import { readVerified } from './scan.js'
26
+ import { warnLog, writeFileAtomic, writeFileExclusive } from './util.js'
27
+
28
+ /** 冲突码:内容在「读快照」之后被改过。固定字符串,界面与测试都只认它。 */
29
+ export const ERR_STALE_REVISION = 'stale_revision'
30
+ /** 冲突码:`create`(createIfAbsent)撞上已有文件 —— 新建绝不覆盖已有内容。 */
31
+ export const ERR_ALREADY_EXISTS = 'already_exists'
32
+
33
+ /** 内容 revision:字节的 sha256。读与写必须用同一个算法,调用方原样回传。 */
34
+ export function revisionOf(bytes) {
35
+ return createHash('sha256').update(bytes).digest('hex')
36
+ }
37
+
38
+ /**
39
+ * 读一份**可信快照**:content 与 revision 在同一次读取里拿到。
40
+ *
41
+ * 调用方不能只拿一半 —— 「正文来自这次读、版本来自另一次读」正是 Q02 的成因。
42
+ *
43
+ * @returns {Promise<{name:string, content:string, revision:string}>}
44
+ */
45
+ export async function readSnapshot(full, gate, name) {
46
+ const bytes = await readVerified(full, gate, false)
47
+ return { name, content: bytes.toString('utf8'), revision: revisionOf(bytes) }
48
+ }
49
+
50
+ /** 旧调用方的兼容提示(一次写入一行,不做去重 —— 谁漏传一眼就能在日志里翻到)。 */
51
+ const LEGACY_WARN = '没有带 expectedRevision,按旧协议直接覆盖(面板必须带;旧调用方仅兼容,建议尽快带上)'
52
+
53
+ /**
54
+ * 整份覆盖写:`create` / `expectedRevision` / 旧调用方三种语义,一个出口。
55
+ *
56
+ * @param {string} target — 完整路径(名字闸门由调用方先过)
57
+ * @param {string} content — 要写的正文
58
+ * @param {object} gate — 调用方刚取到的 `inspectMemoryEntry` 结果(ok 或 code:'missing')
59
+ * @param {object} [opts] — `{ expectedRevision?, create? }`
60
+ * @param {{ctx:object, name:string, kind:string}} meta — 日志与回执用
61
+ * @returns {Promise<{ok:true, file:string, revision:string, created?:boolean}
62
+ * | {ok:false, code:string, file?:string, error:string}>}
63
+ */
64
+ export async function writeSnapshot(target, content, gate, opts, meta) {
65
+ const o = opts && typeof opts === 'object' ? opts : {}
66
+ const name = meta?.name ?? ''
67
+ const rejected = (code, error) => ({ ok: false, code, file: name, error })
68
+
69
+ // ① createIfAbsent:新建只能「建出来」或「明确报已存在」,没有第三条路。
70
+ if (o.create === true) {
71
+ if (gate?.ok) return rejected(ERR_ALREADY_EXISTS, `sage-mem: ${name} 已经存在,新建不会覆盖它(要改正文请打开它)`)
72
+ try {
73
+ await writeFileExclusive(target, content)
74
+ } catch (err) {
75
+ if (err?.code === 'EEXIST') return rejected(ERR_ALREADY_EXISTS, `sage-mem: ${name} 刚刚被别的写入建出来了,新建没有覆盖它`)
76
+ return rejected('io_error', `sage-mem: 写入 ${name} 失败:${String(err?.message ?? err)}`)
77
+ }
78
+ return { ok: true, file: name, revision: revisionOf(Buffer.from(content, 'utf8')), created: true }
79
+ }
80
+
81
+ // ② 带 expectedRevision:盘上版本与调用方读到的不一致 → 拒绝,内容一个字节不动。
82
+ if (o.expectedRevision !== undefined && o.expectedRevision !== null) {
83
+ if (!gate?.ok) {
84
+ // 读快照时还在、现在没了:这同样是「版本已变」,不能悄悄当成新建写回去。
85
+ return rejected(ERR_STALE_REVISION, `sage-mem: ${name} 已经不在盘上了(读取之后被删除或改名),没有写入`)
86
+ }
87
+ let current
88
+ try {
89
+ current = revisionOf(await readVerified(target, gate, false))
90
+ } catch (err) {
91
+ return rejected(ERR_STALE_REVISION, `sage-mem: 读不到 ${name} 的当前版本(${err?.code ?? err}),没有写入`)
92
+ }
93
+ if (current !== o.expectedRevision) {
94
+ // 错误正文是给用户看的:草稿还在、下一步该做什么,都要说清楚(界面直接显示它)。
95
+ return rejected(
96
+ ERR_STALE_REVISION,
97
+ `sage-mem: ${name} 在你保存之前已经被别的写入改过(当前版本 ${current.slice(0, 12)}…)。你的草稿还在,请先查看最新内容再决定如何重新应用 —— 没有自动覆盖`,
98
+ )
99
+ }
100
+ } else {
101
+ // ③ 旧调用方:additive 契约允许继续工作,但必须留痕(否则「静默覆盖」永远查不出来)。
102
+ warnLog(meta?.ctx, `${meta?.kind ?? 'write'}(${name}) ${LEGACY_WARN}`)
103
+ }
104
+
105
+ try {
106
+ await writeFileAtomic(target, content)
107
+ } catch (err) {
108
+ return rejected('io_error', `sage-mem: 写入 ${name} 失败:${String(err?.message ?? err)}`)
109
+ }
110
+ return { ok: true, file: name, revision: revisionOf(Buffer.from(content, 'utf8')) }
111
+ }
@@ -20,6 +20,10 @@ const fileNameSchema = z.string().min(1).max(255).regex(new RegExp(MD_NAME_PATTE
20
20
  // 到半兆量级基本是写错了目标(贴了日志/代码进来)。与 lib/index.js 的 MAX_FILE_BYTES 一致。
21
21
  const fileContentSchema = z.string().max(MAX_FILE_BYTES)
22
22
 
23
+ // 2.1.2(Q02):写协议第三参(expectedRevision / create)—— 字段与参数都 optional,旧调用方照旧过;
24
+ // 不声明它的话 strictObject 会把新面板传的 revision 整个拒掉(不是弱化,是完全不通)。
25
+ const writeOptsSchema = z.object({ 'expectedRevision': z.string().max(128).readonly().optional(), 'create': z.boolean().readonly().optional() }).readonly()
26
+
23
27
  // autodream 报告引用:比 fileNameSchema 宽——旧版报告列表里的条目带 `dream/` 前缀
24
28
  // (新版 `autodream/` 不加前缀只给裸文件名)。这里放行斜杠,真正的目录逃逸防护
25
29
  // 在 host 侧 AutodreamEngine.readReport 里(先剥已知前缀、再对 basename 走 safeName)。
@@ -65,6 +69,8 @@ const listResultSchema = z.array(fileSchema)
65
69
  const readResultSchema = z.object({
66
70
  'name': z.string().readonly(),
67
71
  'content': z.string().readonly(),
72
+ // 2.1.2(Q02):读快照的版本令牌(optional —— additive 契约只许加,不许把已有字段改成必填)。
73
+ 'revision': z.string().readonly().optional(),
68
74
  }).readonly()
69
75
  // 写/删失败不再抛异常,走 { ok: false, error } 分支:error 必须声明,否则 strict
70
76
  // 校验会把它当未知字段剥掉,设置页只能看到一个没有原因的 ok:false。
@@ -72,6 +78,8 @@ const writeResultSchema = z.object({
72
78
  'ok': z.boolean().readonly(),
73
79
  'file': z.string().readonly(),
74
80
  'error': z.string().readonly().optional(),
81
+ // 2.1.2(Q02):成功回新 revision;冲突回固定错误码(stale_revision / already_exists)。都是 optional。
82
+ 'revision': z.string().readonly().optional(), 'code': z.string().readonly().optional(),
75
83
  }).readonly()
76
84
  const deleteResultSchema = z.object({
77
85
  'ok': z.boolean().readonly(),
@@ -129,6 +137,8 @@ const archiveResultSchema = z.object({
129
137
  const archivedReadResultSchema = z.object({
130
138
  'name': z.string().readonly(),
131
139
  'content': z.string().readonly(),
140
+ // 2.1.2(Q02):与活跃记忆同一套读快照协议(optional,同 readResultSchema 的理由)。
141
+ 'revision': z.string().readonly().optional(),
132
142
  }).readonly()
133
143
  // 写归档的结果与 writeResultSchema 同形,但**另立一个类型名**:strict 层要能区分
134
144
  // 「写活跃记忆」与「写归档记忆」两条路径,将来两者形状分叉时也不必回头改协议。
@@ -136,6 +146,7 @@ const archivedWriteResultSchema = z.object({
136
146
  'ok': z.boolean().readonly(),
137
147
  'file': z.string().readonly(),
138
148
  'error': z.string().readonly().optional(),
149
+ 'revision': z.string().readonly().optional(), 'code': z.string().readonly().optional(),
139
150
  }).readonly()
140
151
 
141
152
  // ── 面板 API(v0.9.0 D1):体检 / 归档候选 / 设置 / 保留名原样读写 ──────────────
@@ -269,11 +280,14 @@ const rawReadResultSchema = z.object({
269
280
  'content': z.string().readonly().optional(),
270
281
  'ok': z.boolean().readonly().optional(),
271
282
  'error': z.string().readonly().optional(),
283
+ // 2.1.2(Q02):保留名读快照的版本令牌(optional,同 readResultSchema 的理由)。
284
+ 'revision': z.string().readonly().optional(),
272
285
  }).readonly()
273
286
  const rawWriteResultSchema = z.object({
274
287
  'ok': z.boolean().readonly(),
275
288
  'file': z.string().readonly().optional(),
276
289
  'error': z.string().readonly().optional(),
290
+ 'revision': z.string().readonly().optional(), 'code': z.string().readonly().optional(),
277
291
  }).readonly()
278
292
  // 保留名的体积上限比单条记忆大得多(session-log.md 天然 > 512 KB)—— 4 MB,独立于
279
293
  // fileContentSchema,别把两者混用。
@@ -639,6 +653,7 @@ export const TYPERT = {
639
653
  parameters: [
640
654
  { name: 'name', wire: 'name', source: 'json', codec: { mode: 'strict', typeSymbol: 'sage-mem/types#FileName', schema: fileNameSchema, create: () => fileNameSchema } },
641
655
  { name: 'content', wire: 'content', source: 'json', codec: { mode: 'strict', typeSymbol: 'sage-mem/types#FileContent', schema: fileContentSchema, create: () => fileContentSchema } },
656
+ { name: 'opts', wire: 'opts', source: 'json', acceptsUndefined: true, codec: { mode: 'strict', typeSymbol: 'sage-mem/types#WriteOpts', schema: writeOptsSchema, create: () => writeOptsSchema } },
642
657
  ],
643
658
  result: { mode: 'strict', typeSymbol: 'sage-mem/types#WriteResult', schema: writeResultSchema, create: () => writeResultSchema },
644
659
  sourceLocation: { "file": "lib/index.js", "line": 1, "column": 1 },
@@ -715,6 +730,7 @@ export const TYPERT = {
715
730
  // 正文与「写活跃记忆」共用同一个 512 KB 上限 schema;类型名沿用 #ArchivedFileContent
716
731
  // 是为了与「读归档」那一侧对称(活跃记忆那套也是 FileContent 一符两用)。
717
732
  { name: 'content', wire: 'content', source: 'json', codec: { mode: 'strict', typeSymbol: 'sage-mem/types#ArchivedFileContent', schema: fileContentSchema, create: () => fileContentSchema } },
733
+ { name: 'opts', wire: 'opts', source: 'json', acceptsUndefined: true, codec: { mode: 'strict', typeSymbol: 'sage-mem/types#WriteOpts', schema: writeOptsSchema, create: () => writeOptsSchema } },
718
734
  ],
719
735
  result: { mode: 'strict', typeSymbol: 'sage-mem/types#ArchivedWriteResult', schema: archivedWriteResultSchema, create: () => archivedWriteResultSchema },
720
736
  sourceLocation: { "file": "lib/index.js", "line": 1, "column": 1 },
@@ -835,6 +851,7 @@ export const TYPERT = {
835
851
  { name: 'name', wire: 'name', source: 'json', codec: { mode: 'strict', typeSymbol: 'sage-mem/types#ReservedFileName', schema: fileNameSchema, create: () => fileNameSchema } },
836
852
  // 4 MB:与 fileContentSchema(512 KB)**不是同一个上限**,见 rawFileContentSchema 的注释。
837
853
  { name: 'content', wire: 'content', source: 'json', codec: { mode: 'strict', typeSymbol: 'sage-mem/types#RawFileContent', schema: rawFileContentSchema, create: () => rawFileContentSchema } },
854
+ { name: 'opts', wire: 'opts', source: 'json', acceptsUndefined: true, codec: { mode: 'strict', typeSymbol: 'sage-mem/types#WriteOpts', schema: writeOptsSchema, create: () => writeOptsSchema } },
838
855
  ],
839
856
  result: { mode: 'strict', typeSymbol: 'sage-mem/types#RawWriteResult', schema: rawWriteResultSchema, create: () => rawWriteResultSchema },
840
857
  sourceLocation: { "file": "lib/index.js", "line": 1, "column": 1 },
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.0",
4
+ "version": "2.1.2",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "icon": "./icon.png",
@@ -51,7 +51,9 @@
51
51
  "0.2.0-rc.1": "compatible",
52
52
  "0.2.0-rc.2": "compatible",
53
53
  "0.2.1-rc.1": "compatible",
54
- "0.2.1-rc.3": "compatible"
54
+ "0.2.1-rc.3": "compatible",
55
+ "0.2.1-rc.4": "compatible",
56
+ "0.2.1-rc.5": "compatible"
55
57
  }
56
58
  }
57
59
  },