sage-mem 2.1.20 → 2.1.22

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.20`** — 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.22`** — 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.20`** —— 顶部 npm 徽章是动态权威。**契约基线:`1.0.0`** —— 第一个稳定版,公开承诺、能力边界与「明确做不到」的清单冻结在
17
+ **当前发布版本:`2.1.22`** —— 顶部 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 是最直接的反馈。
package/lib/client.js CHANGED
@@ -61,6 +61,148 @@ window.__ModuleLoader__.load({
61
61
  return "rgba(" + ((n >> 16) & 255) + "," + ((n >> 8) & 255) + "," + (n & 255) + "," + a.toFixed(3) + ")";
62
62
  }
63
63
 
64
+ // ── 陈旧宿主(2.1.22 / issue #10)────────────────────────────────
65
+ //
66
+ // `@deepseek-ai/dsh-typert-loader` 把每个包的 TYPERT manifest **按包名缓存、
67
+ // 进程内不失效**(它的头注原文:Package verdicts and imported manifests are
68
+ // cached per package name and never expire)。于是「开着 DSH 装上新版 sage-mem」
69
+ // 之后,宿主进程里仍然是升级前的 strict descriptor(2.1.1 的 writeFile 只有
70
+ // name/content 两个 wire 字段),而浏览器这边是从磁盘现取的新客户端分片 ——
71
+ // 第三参 opts 会在宿主**执行之前**被网关挡掉:
72
+ //
73
+ // gateway/arguments-invalid: args fields do not match the descriptor:
74
+ // unexpected "opts"
75
+ //
76
+ // 真正要清掉的是宿主进程里那份旧 manifest(彻底退出 DSH,含托盘常驻进程;
77
+ // 只关窗口不够)。但用户不该被卡在这里:写操作按旧协议降级重试。
78
+
79
+ /**
80
+ * 只认「旧 strict descriptor 不认第三参」这一种结构化失败。
81
+ *
82
+ * ⚠️ 两种形态都要认(2026-11-01 真机抓到):网关的失败既可能被**拒绝**(reject),
83
+ * 也可能作为 remote 的**返回值** `{ok:false, error}` 回来 —— 后者是 api-gateway
84
+ * 的 RemoteResult 语义,sage-mem 面板的其它写路径也一直在读 `r.ok === false`。
85
+ * 只判 rejection 的话,真实环境里这条降级根本不会触发(第一版就踩了)。
86
+ *
87
+ * 判据同时看 code 与文案:同一个 `gateway/arguments-invalid` 也可能是在拒别的
88
+ * 字段,甚至别的调用方传错参数 —— 那些都必须原样抛出,不能拿兼容降级去吞。
89
+ * @returns 出问题的那个 error 对象,或 null。
90
+ */
91
+ function staleHostWriteCause(value) {
92
+ const candidate = value && value.ok === false && value.error ? value.error : value;
93
+ if (!candidate) return null;
94
+ const code = typeof candidate.code === "string" ? candidate.code : "";
95
+ const message = String(candidate.message || candidate);
96
+ if (code !== "gateway/arguments-invalid" && !/gateway\/arguments-invalid/.test(message)) return null;
97
+ return /unexpected\s+["']?opts["']?/i.test(message) ? candidate : null;
98
+ }
99
+
100
+ /** 布尔版判据(面板/测试可读)。 */
101
+ function isStaleHostWriteError(value) {
102
+ return staleHostWriteCause(value) !== null;
103
+ }
104
+
105
+ /**
106
+ * 只认「文件不存在」这一种读失败(宿主 readFile / readRaw 的 ENOENT 文案)。
107
+ *
108
+ * 陈旧宿主降级里的 create 探针必须用它:读失败也可能是权限 / IO 错误,
109
+ * 那种情况下**不能**把目标当成「不存在」去覆盖写(2.1.10 / Q10 的教训)。
110
+ */
111
+ function isNotFoundError(error) {
112
+ return /ENOENT|no such file|not exist/i.test(String((error && error.message) || error));
113
+ }
114
+
115
+ /**
116
+ * 「目标是否已存在」探针(陈旧宿主创建降级用)。
117
+ *
118
+ * ⚠️ 两种返回形态都要认(2026-11-01 真机抓到):宿主方法抛出的错误经 api-gateway
119
+ * 可能变成**返回值** `{ok:false, error}`(RemoteResult 语义),也可能仍是 rejection。
120
+ * 只处理 rejection 会把「文件不存在」误判成「已存在」—— 那会让新建永远报撞名。
121
+ *
122
+ * @returns Promise<boolean>:true=已存在;false=明确的 not-found;
123
+ * 其它读失败(权限 / IO / 网关)抛出来,由调用方决定不降级。
124
+ */
125
+ function probeRemoteExists(remote, method, arg) {
126
+ const call = new Promise(function (resolve) {
127
+ const fn = remote && remote[method];
128
+ if (typeof fn !== "function") throw new Error("sage-mem: host has no " + method);
129
+ resolve(fn.call(remote, arg));
130
+ });
131
+ const classify = function (error) {
132
+ if (isNotFoundError(error)) return false;
133
+ throw error;
134
+ };
135
+ return call.then(function (result) {
136
+ if (result && result.ok === false && result.error) return classify(result.error);
137
+ return true;
138
+ }, classify);
139
+ }
140
+
141
+ /**
142
+ * 写调用 + 陈旧宿主降级。
143
+ *
144
+ * @param remote 宿主 remote 代理
145
+ * @param method 写方法名(writeFile / writeRaw / writeArchived)
146
+ * @param args 位置参数(不含 opts)
147
+ * @param opts 写协议第三参;undefined 表示旧调用方,直接按旧协议写
148
+ * @param probeExists 仅 create 语义用:探测目标是否已存在的函数(Promise<boolean>)。
149
+ * 陈旧宿主没有 create 语义,**没有探针就不降级** —— 宁可这次写失败,也不拿走
150
+ * 条件覆盖写去顶创建。
151
+ * @returns Promise<{result, degraded}>;degraded=true 表示这次按旧协议写入,
152
+ * expectedRevision 的版本校验没有生效,界面必须把这件事告诉用户。
153
+ */
154
+ function writeWithFallback(remote, method, args, opts, probeExists) {
155
+ function send(extraOpts) {
156
+ return new Promise(function (resolve) {
157
+ const fn = remote && remote[method];
158
+ if (typeof fn !== "function") throw new Error("sage-mem: host has no " + method);
159
+ // ⚠️ 第三个**位置参数**必须保留(哪怕值是 undefined):客户端 remote 代理按
160
+ // 客户端描述符校验位置参数个数(三个写方法都声明了 3 个),少传一个会当场报
161
+ // 「client api: memory/writeFile expected 3 argument(s), got 2」—— 降级根本没
162
+ // 走到网关。undefined 在 JSON 里自然缺席,宿主旧接口只看到 name/content,
163
+ // 正好就是旧协议(2026-11-01 真机抓到)。
164
+ resolve(fn.apply(remote, args.concat([extraOpts])));
165
+ });
166
+ }
167
+ function degraded(result) { return { result: result, degraded: true }; }
168
+ /** 降级重试;返回 null 表示「这次不该降级」,由调用方把原始失败原样交出去。 */
169
+ function recover() {
170
+ if (opts && opts.create === true) {
171
+ // 降级不是无条件的:旧宿主没有 create 语义,先探一次存在性。
172
+ if (typeof probeExists !== "function") return null;
173
+ return Promise.resolve().then(probeExists).then(function (exists) {
174
+ if (exists) {
175
+ return degraded({
176
+ ok: false,
177
+ code: "already_exists",
178
+ error: t("error.staleHostExists", { file: String(args[0]) }),
179
+ });
180
+ }
181
+ return send(undefined).then(degraded);
182
+ });
183
+ }
184
+ return send(undefined).then(degraded);
185
+ }
186
+ if (opts === undefined) {
187
+ return send(undefined).then(function (result) { return { result: result, degraded: false }; });
188
+ }
189
+ function onStale(original) {
190
+ const recovered = recover();
191
+ if (recovered === null) throw staleHostWriteCause(original) || original;
192
+ return recovered;
193
+ }
194
+ return send(opts).then(
195
+ function (result) {
196
+ if (!isStaleHostWriteError(result)) return { result: result, degraded: false };
197
+ return onStale(result);
198
+ },
199
+ function (error) {
200
+ if (!isStaleHostWriteError(error)) throw error;
201
+ return onStale(error);
202
+ },
203
+ );
204
+ }
205
+
64
206
  // ── 类型词表(唯一定义) ──
65
207
  // 文件列表的排序 / 筛选 / 徽章、星图的 kind 标签与配色,全部从这里取。
66
208
  const TYPE_ORDER = ["user", "feedback", "project", "reference"];
@@ -2808,6 +2950,20 @@ const StarmapRuntime = (function (require, _react) {
2808
2950
  }, props.label);
2809
2951
  }
2810
2952
 
2953
+ /**
2954
+ * 活动卡片「展开态」的合并(Q16,2.1.21)。
2955
+ *
2956
+ * 为什么是个纯函数:连续点两张卡(或同一帧里点两次)时,事件回调手里那个
2957
+ * `expanded` 可能还是上一帧的快照 —— 拿快照拼新对象会把另一笔改动覆盖掉。
2958
+ * 所以 Section 走**函数式 setState**,把「怎么合并」收敛到这里:
2959
+ * 只翻转目标文件那一条,其余原样保留。测试也直接喂 prev 来验这条语义。
2960
+ */
2961
+ function mergeExpanded(prev, file) {
2962
+ const next = Object.assign({}, prev);
2963
+ next[file] = !(prev && prev[file]);
2964
+ return next;
2965
+ }
2966
+
2811
2967
  function Card(props) {
2812
2968
  const item = props.item;
2813
2969
  const meta = TYPE_META[item.type] || TYPE_META.reference;
@@ -3874,11 +4030,13 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
3874
4030
  "error.saveRejected": "保存被拒绝",
3875
4031
  "error.skeletonFailed": "建立索引骨架失败",
3876
4032
  "error.openDirFailed": "打开记忆目录失败",
4033
+ "error.staleHostExists": "同名文件已经存在:{file} —— 宿主还停在升级前的 sage-mem(旧写协议没有 create 语义),已拒绝,没有覆盖它",
3877
4034
  "notice.indexExists": "MEMORY.md 已经存在,没有改动它。",
3878
4035
  "notice.indexCreated": "已建立 MEMORY.md 索引骨架。",
3879
4036
  "notice.updated": "已更新 {file}",
3880
4037
  "notice.saved": "已保存 {file}",
3881
4038
  "notice.rawNew": "已新建 {file}",
4039
+ "notice.staleHostWrite": "已按旧协议写入 {file} —— 宿主仍停着升级前的 sage-mem(DSH 没有完全退出),本次未做版本校验;彻底退出 DSH(含托盘)后重开即可恢复",
3882
4040
  "notice.deleted": "已删除 {file}",
3883
4041
  "notice.archived": "已归档 {file} —— 它现在在「已归档」视图里,随时可以恢复",
3884
4042
  "notice.batchPartial": "已归档 {done} 条,{failed} 条没成功:{files}",
@@ -4355,11 +4513,13 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
4355
4513
  "error.saveRejected": "Save rejected",
4356
4514
  "error.skeletonFailed": "Failed to create the index skeleton",
4357
4515
  "error.openDirFailed": "Failed to open the memory directory",
4516
+ "error.staleHostExists": "A file with this name already exists: {file} — the host is still on the pre-upgrade sage-mem (the old write protocol has no create semantics), so it was refused and nothing was overwritten",
4358
4517
  "notice.indexExists": "MEMORY.md already exists; it was left unchanged.",
4359
4518
  "notice.indexCreated": "MEMORY.md index skeleton created.",
4360
4519
  "notice.updated": "Updated {file}",
4361
4520
  "notice.saved": "Saved {file}",
4362
4521
  "notice.rawNew": "Created {file}",
4522
+ "notice.staleHostWrite": "Wrote {file} with the legacy protocol — the host still runs the pre-upgrade sage-mem (DSH did not fully exit), so this write skipped the version check; fully quit DSH (including the tray) and reopen to restore",
4363
4523
  "notice.deleted": "Deleted {file}",
4364
4524
  "notice.archived": "Archived {file} — it is now under the Archived view and can be restored at any time",
4365
4525
  "notice.batchPartial": "Archived {done}, {failed} failed: {files}",
@@ -5640,7 +5800,7 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
5640
5800
  // 只负责「按状态/筛选把卡片摆出来 + 四种空态」;状态、筛选语义、动作回调全部由 Section 传。
5641
5801
  // 抽出的判据:它是 Section 里最大的一块纯渲染,而卡片组件(Card / ArchivedCard)本来就在 50 共享层。
5642
5802
  function FilesView(props) {
5643
- const { loading, totalCount, openAdd, createIndexSkeleton, statusFilter, setStatusFilter, items, archiveItems, filtered, setTypeFilter, setTagFilter, setQuery, busy, restoring, archDelete, archEdit, archLoad, archSaving, archText, setArchText, openArchEdit, saveArchEdit, cancelArchEdit, restoreFile, setArchDelete, setArchEdit, deleteArchivedFile, expanded, deleting, archiving, archiveReason, setArchiveReason, setArchiving, setDeleting, confirmArchive, toggleLock, openEdit, confirmDelete } = props;
5803
+ const { loading, totalCount, openAdd, createIndexSkeleton, statusFilter, setStatusFilter, items, archiveItems, filtered, setTypeFilter, setTagFilter, setQuery, busy, restoring, archDelete, archEdit, archLoad, archSaving, archText, setArchText, openArchEdit, saveArchEdit, cancelArchEdit, restoreFile, setArchDelete, setArchEdit, deleteArchivedFile, expanded, onToggleExpanded, deleting, archiving, archiveReason, setArchiveReason, setArchiving, setDeleting, confirmArchive, toggleLock, openEdit, confirmDelete } = props;
5644
5804
  let filesView;
5645
5805
  if (loading && totalCount === 0) {
5646
5806
  filesView = h("div", { className: "smem-skeleton", key: "loading", "data-loading": "1" }, [0, 1, 2].map(function (i) {
@@ -5704,11 +5864,10 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
5704
5864
  onCancelArchive: function () { setArchiving(null); setArchiveReason(""); },
5705
5865
  onConfirmArchive: confirmArchive,
5706
5866
  onToggleLock: toggleLock,
5707
- onToggle: function (file) {
5708
- const nx = {};
5709
- nx[file] = !expanded[file];
5710
- setExpanded(Object.assign({}, expanded, nx));
5711
- },
5867
+ // 展开 / 收起是**业务回调**(Q16):卡片只上报「点了哪张」,
5868
+ // 合并逻辑归 Section。旧版这里引用旧 Section 闭包里的 `setExpanded` ——
5869
+ // 拆件后它不在本函数作用域,点击即 ReferenceError: setExpanded is not defined。
5870
+ onToggle: onToggleExpanded,
5712
5871
  onEdit: openEdit,
5713
5872
  onAskDelete: function (file) { setDeleting(file); setArchiving(null); },
5714
5873
  onCancelDelete: function () { setDeleting(null); },
@@ -5828,6 +5987,12 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
5828
5987
  const query = sQuery[0], setQuery = sQuery[1];
5829
5988
  const sExpanded = react.useState({});
5830
5989
  const expanded = sExpanded[0], setExpanded = sExpanded[1];
5990
+ // Q16:卡片「展开 / 收起」是业务回调(FilesView 只上报点了哪张卡)。
5991
+ // 必须走**函数式** setState:事件里捕获的 expanded 可能是上一帧快照,
5992
+ // 直接 Object.assign({}, expanded, …) 会把连续点击的上一笔改动覆盖掉。
5993
+ function onToggleExpanded(file) {
5994
+ setExpanded(function (prev) { return mergeExpanded(prev, file); });
5995
+ }
5831
5996
  const sDeleting = react.useState(null);
5832
5997
  const deleting = sDeleting[0], setDeleting = sDeleting[1];
5833
5998
  const sFormOpen = react.useState(false);
@@ -6110,12 +6275,18 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
6110
6275
  // 服务端负责「全新安装、memory 目录还不存在」时先建父目录;
6111
6276
  // 已有索引 → 明确回 already_exists(逐字节不动)。绝不再「先读一次、读失败就当
6112
6277
  // 可以覆盖」—— 读失败也可能是权限错误,而那正是会顶掉一份真索引的路。
6113
- remote.writeRaw("MEMORY.md", INDEX_SKELETON, { create: true }).then(function (res) {
6114
- const out = unwrap(res);
6278
+ // 2.1.22(issue #10):陈旧宿主没有 create 语义,降级前先探一次存在性。
6279
+ // 探针只认「明确的 not-found」;读失败(权限 / IO)一律不降级 —— 否则会把一份
6280
+ // 读不到的真索引当成「不存在」顶掉(2.1.10 / Q10 的教训)。
6281
+ const probeIndexExists = function () {
6282
+ return probeRemoteExists(remote, "readRaw", "MEMORY.md");
6283
+ };
6284
+ writeWithFallback(remote, "writeRaw", ["MEMORY.md", INDEX_SKELETON], { create: true }, probeIndexExists).then(function (out) {
6285
+ const res = unwrap(out.result);
6115
6286
  setBusy(false);
6116
- if (out && out.code === "already_exists") { setNotice(t("notice.indexExists")); return; }
6117
- if (out && out.ok === false) { setError(out.error || t("error.skeletonFailed")); return; }
6118
- setNotice(t("notice.indexCreated"));
6287
+ if (res && res.code === "already_exists") { setNotice(t("notice.indexExists")); return; }
6288
+ if (res && res.ok === false) { setError(res.error || t("error.skeletonFailed")); return; }
6289
+ setNotice(out.degraded ? t("notice.staleHostWrite", { file: "MEMORY.md" }) : t("notice.indexCreated"));
6119
6290
  load();
6120
6291
  }).catch(function (e) {
6121
6292
  setBusy(false);
@@ -6206,17 +6377,26 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
6206
6377
  }
6207
6378
  // 2.1.2(Q02):编辑已有文件时交回读到的 revision(盘上变了 → 宿主拒绝);
6208
6379
  // 新建走 createIfAbsent —— 撞上同名文件是**明确报已存在**,绝不覆盖入口。
6380
+ const wasEditing = Boolean(editingFile);
6209
6381
  const writeOpts = editingFile
6210
6382
  ? (formRevision ? { expectedRevision: formRevision } : undefined)
6211
6383
  : { create: true };
6212
- remote.writeFile(safeName, content, writeOpts)
6213
- .then(function (r) {
6384
+ // 2.1.22(issue #10):宿主若还停在升级前的旧 manifest,先按旧协议降级一次;
6385
+ // 新建语义在旧宿主上没有对应参数,降级前必须探存在性(绝不覆盖已有文件)。
6386
+ const probeTarget = wasEditing ? null : function () {
6387
+ return probeRemoteExists(remote, "readFile", safeName);
6388
+ };
6389
+ writeWithFallback(remote, "writeFile", [safeName, content], writeOpts, probeTarget)
6390
+ .then(function (out) {
6214
6391
  // host 侧对保留名 / 非字符串内容 / 超限一律返回 { ok: false, error },不再抛异常。
6215
6392
  // 冲突看**结构化 code**(stale_revision / already_exists),不解析文案:
6216
6393
  // 编辑框不关、草稿原地保留,由用户看过最新内容后自己决定重新应用 —— 绝不自动重试覆盖。
6394
+ const r = out.result;
6217
6395
  if (r && r.ok === false) { setError(r.error || t("error.writeRejected")); return; }
6218
6396
  closeForm();
6219
- setNotice(t(editingFile ? "notice.updated" : "notice.saved", { file: safeName }));
6397
+ setNotice(out.degraded
6398
+ ? t("notice.staleHostWrite", { file: safeName })
6399
+ : t(wasEditing ? "notice.updated" : "notice.saved", { file: safeName }));
6220
6400
  load();
6221
6401
  })
6222
6402
  .catch(function (e) { setError(String((e && e.message) || e)); })
@@ -6375,12 +6555,16 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
6375
6555
  // 原样交回:不 trim、不补 frontmatter、不做任何「智能」处理。
6376
6556
  // 2.1.2(Q02):带上读到的 revision;归档件在别处被改过 → 宿主回 stale_revision,
6377
6557
  // 编辑框与文本原地保留(不自动重试覆盖)。
6558
+ // 2.1.22(issue #10):陈旧宿主按旧协议降级(没有 create 语义,这里本来也没有)。
6378
6559
  const writeOpts = archRevision ? { expectedRevision: archRevision } : undefined;
6379
- remote.writeArchived(file, archText, writeOpts)
6380
- .then(function (r) {
6560
+ writeWithFallback(remote, "writeArchived", [file, archText], writeOpts)
6561
+ .then(function (out) {
6562
+ const r = out.result;
6381
6563
  if (r && r.ok === false) { setError(r.error || t("error.saveRejected")); return; }
6382
6564
  setArchEdit(null);
6383
- setNotice(t("notice.saved", { file: file }));
6565
+ setNotice(out.degraded
6566
+ ? t("notice.staleHostWrite", { file: file })
6567
+ : t("notice.saved", { file: file }));
6384
6568
  load();
6385
6569
  })
6386
6570
  .catch(function (e) { setError(String((e && e.message) || e)); })
@@ -6733,13 +6917,21 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
6733
6917
  // 原样交回:不 trim、不补 frontmatter、不做任何「智能」处理。
6734
6918
  // 2.1.2(Q02):新建走 createIfAbsent(撞名明确失败、不覆盖);编辑带读到的 revision
6735
6919
  // (盘上变了 → stale_revision,文本原地保留,绝不自动重试覆盖)。
6920
+ // 2.1.22(issue #10):陈旧宿主按旧协议降级;新建语义要先探存在性,绝不覆盖。
6921
+ const wasNew = Boolean(rawIsNew);
6736
6922
  const writeOpts = rawIsNew
6737
6923
  ? { create: true }
6738
6924
  : (rawRevision ? { expectedRevision: rawRevision } : undefined);
6739
- remote.writeRaw(name, rawText, writeOpts)
6740
- .then(function (r) {
6925
+ const probeRaw = wasNew ? function () {
6926
+ return probeRemoteExists(remote, "readRaw", name);
6927
+ } : null;
6928
+ writeWithFallback(remote, "writeRaw", [name, rawText], writeOpts, probeRaw)
6929
+ .then(function (out) {
6930
+ const r = out.result;
6741
6931
  if (r && r.ok === false) { setError(r.error || t("error.saveRejected")); return; }
6742
- setNotice(t(rawIsNew ? "notice.rawNew" : "notice.saved", { file: name }));
6932
+ setNotice(out.degraded
6933
+ ? t("notice.staleHostWrite", { file: name })
6934
+ : t(wasNew ? "notice.rawNew" : "notice.saved", { file: name }));
6743
6935
  setRawIsNew(false);
6744
6936
  // 保存成功后把版本换成刚写下的那一版,连续保存不用重读
6745
6937
  if (r && typeof r.revision === "string") setRawRevision(r.revision);
@@ -6840,7 +7032,7 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
6840
7032
  archText: archText, setArchText: setArchText,
6841
7033
  openArchEdit: openArchEdit, saveArchEdit: saveArchEdit, cancelArchEdit: cancelArchEdit,
6842
7034
  restoreFile: restoreFile, setArchDelete: setArchDelete, setArchEdit: setArchEdit, deleteArchivedFile: deleteArchivedFile,
6843
- expanded: expanded, deleting: deleting, archiving: archiving,
7035
+ expanded: expanded, onToggleExpanded: onToggleExpanded, deleting: deleting, archiving: archiving,
6844
7036
  archiveReason: archiveReason, setArchiveReason: setArchiveReason,
6845
7037
  setArchiving: setArchiving, setDeleting: setDeleting,
6846
7038
  confirmArchive: confirmArchive, toggleLock: toggleLock,
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.20",
4
+ "version": "2.1.22",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "icon": "./icon.png",