sage-mem 2.1.21 → 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.21`** — 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.21`** —— 顶部 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"];
@@ -3888,11 +4030,13 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
3888
4030
  "error.saveRejected": "保存被拒绝",
3889
4031
  "error.skeletonFailed": "建立索引骨架失败",
3890
4032
  "error.openDirFailed": "打开记忆目录失败",
4033
+ "error.staleHostExists": "同名文件已经存在:{file} —— 宿主还停在升级前的 sage-mem(旧写协议没有 create 语义),已拒绝,没有覆盖它",
3891
4034
  "notice.indexExists": "MEMORY.md 已经存在,没有改动它。",
3892
4035
  "notice.indexCreated": "已建立 MEMORY.md 索引骨架。",
3893
4036
  "notice.updated": "已更新 {file}",
3894
4037
  "notice.saved": "已保存 {file}",
3895
4038
  "notice.rawNew": "已新建 {file}",
4039
+ "notice.staleHostWrite": "已按旧协议写入 {file} —— 宿主仍停着升级前的 sage-mem(DSH 没有完全退出),本次未做版本校验;彻底退出 DSH(含托盘)后重开即可恢复",
3896
4040
  "notice.deleted": "已删除 {file}",
3897
4041
  "notice.archived": "已归档 {file} —— 它现在在「已归档」视图里,随时可以恢复",
3898
4042
  "notice.batchPartial": "已归档 {done} 条,{failed} 条没成功:{files}",
@@ -4369,11 +4513,13 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
4369
4513
  "error.saveRejected": "Save rejected",
4370
4514
  "error.skeletonFailed": "Failed to create the index skeleton",
4371
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",
4372
4517
  "notice.indexExists": "MEMORY.md already exists; it was left unchanged.",
4373
4518
  "notice.indexCreated": "MEMORY.md index skeleton created.",
4374
4519
  "notice.updated": "Updated {file}",
4375
4520
  "notice.saved": "Saved {file}",
4376
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",
4377
4523
  "notice.deleted": "Deleted {file}",
4378
4524
  "notice.archived": "Archived {file} — it is now under the Archived view and can be restored at any time",
4379
4525
  "notice.batchPartial": "Archived {done}, {failed} failed: {files}",
@@ -6129,12 +6275,18 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
6129
6275
  // 服务端负责「全新安装、memory 目录还不存在」时先建父目录;
6130
6276
  // 已有索引 → 明确回 already_exists(逐字节不动)。绝不再「先读一次、读失败就当
6131
6277
  // 可以覆盖」—— 读失败也可能是权限错误,而那正是会顶掉一份真索引的路。
6132
- remote.writeRaw("MEMORY.md", INDEX_SKELETON, { create: true }).then(function (res) {
6133
- 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);
6134
6286
  setBusy(false);
6135
- if (out && out.code === "already_exists") { setNotice(t("notice.indexExists")); return; }
6136
- if (out && out.ok === false) { setError(out.error || t("error.skeletonFailed")); return; }
6137
- 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"));
6138
6290
  load();
6139
6291
  }).catch(function (e) {
6140
6292
  setBusy(false);
@@ -6225,17 +6377,26 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
6225
6377
  }
6226
6378
  // 2.1.2(Q02):编辑已有文件时交回读到的 revision(盘上变了 → 宿主拒绝);
6227
6379
  // 新建走 createIfAbsent —— 撞上同名文件是**明确报已存在**,绝不覆盖入口。
6380
+ const wasEditing = Boolean(editingFile);
6228
6381
  const writeOpts = editingFile
6229
6382
  ? (formRevision ? { expectedRevision: formRevision } : undefined)
6230
6383
  : { create: true };
6231
- remote.writeFile(safeName, content, writeOpts)
6232
- .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) {
6233
6391
  // host 侧对保留名 / 非字符串内容 / 超限一律返回 { ok: false, error },不再抛异常。
6234
6392
  // 冲突看**结构化 code**(stale_revision / already_exists),不解析文案:
6235
6393
  // 编辑框不关、草稿原地保留,由用户看过最新内容后自己决定重新应用 —— 绝不自动重试覆盖。
6394
+ const r = out.result;
6236
6395
  if (r && r.ok === false) { setError(r.error || t("error.writeRejected")); return; }
6237
6396
  closeForm();
6238
- 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 }));
6239
6400
  load();
6240
6401
  })
6241
6402
  .catch(function (e) { setError(String((e && e.message) || e)); })
@@ -6394,12 +6555,16 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
6394
6555
  // 原样交回:不 trim、不补 frontmatter、不做任何「智能」处理。
6395
6556
  // 2.1.2(Q02):带上读到的 revision;归档件在别处被改过 → 宿主回 stale_revision,
6396
6557
  // 编辑框与文本原地保留(不自动重试覆盖)。
6558
+ // 2.1.22(issue #10):陈旧宿主按旧协议降级(没有 create 语义,这里本来也没有)。
6397
6559
  const writeOpts = archRevision ? { expectedRevision: archRevision } : undefined;
6398
- remote.writeArchived(file, archText, writeOpts)
6399
- .then(function (r) {
6560
+ writeWithFallback(remote, "writeArchived", [file, archText], writeOpts)
6561
+ .then(function (out) {
6562
+ const r = out.result;
6400
6563
  if (r && r.ok === false) { setError(r.error || t("error.saveRejected")); return; }
6401
6564
  setArchEdit(null);
6402
- setNotice(t("notice.saved", { file: file }));
6565
+ setNotice(out.degraded
6566
+ ? t("notice.staleHostWrite", { file: file })
6567
+ : t("notice.saved", { file: file }));
6403
6568
  load();
6404
6569
  })
6405
6570
  .catch(function (e) { setError(String((e && e.message) || e)); })
@@ -6752,13 +6917,21 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
6752
6917
  // 原样交回:不 trim、不补 frontmatter、不做任何「智能」处理。
6753
6918
  // 2.1.2(Q02):新建走 createIfAbsent(撞名明确失败、不覆盖);编辑带读到的 revision
6754
6919
  // (盘上变了 → stale_revision,文本原地保留,绝不自动重试覆盖)。
6920
+ // 2.1.22(issue #10):陈旧宿主按旧协议降级;新建语义要先探存在性,绝不覆盖。
6921
+ const wasNew = Boolean(rawIsNew);
6755
6922
  const writeOpts = rawIsNew
6756
6923
  ? { create: true }
6757
6924
  : (rawRevision ? { expectedRevision: rawRevision } : undefined);
6758
- remote.writeRaw(name, rawText, writeOpts)
6759
- .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;
6760
6931
  if (r && r.ok === false) { setError(r.error || t("error.saveRejected")); return; }
6761
- 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 }));
6762
6935
  setRawIsNew(false);
6763
6936
  // 保存成功后把版本换成刚写下的那一版,连续保存不用重读
6764
6937
  if (r && typeof r.revision === "string") setRawRevision(r.revision);
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.21",
4
+ "version": "2.1.22",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "icon": "./icon.png",