sage-mem 0.9.6 → 0.9.9

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
@@ -49,13 +49,21 @@ sage-mem 换一条路:**每条记忆就是一个 Markdown 文件**。用编辑
49
49
 
50
50
  *文件列表 —— 头部带常显「体检 N 个问题」与「待重启生效」,不用点开就知道该不该处理;灰底条目是已归档,随时可以恢复。*
51
51
 
52
- ![工具抽屉:体检 / 归档候选 / 保留名编辑,关着时零占位](https://raw.githubusercontent.com/gezi-wen/sage-mem/main/docs/images/tools-drawer.png)
52
+ ![工具抽屉:归档候选 / 保留名编辑,关着时零占位](https://raw.githubusercontent.com/gezi-wen/sage-mem/main/docs/images/tools-drawer.png)
53
53
 
54
- *工具抽屉 —— 关着时只占一个按钮,展开后三项各带状态:体检 4 个问题 / 归档候选 2 条 / 保留名 3 个。*
54
+ *工具抽屉 —— 关着时只占一个按钮,展开后两项各带状态:归档候选 2 条 / 保留名 3 个。(体检不在这里:它自己有一页。)*
55
55
 
56
- ![体检:硬问题按类报红分节,「指向已归档」只作提示](https://raw.githubusercontent.com/gezi-wen/sage-mem/main/docs/images/audit.png)
56
+ ![锁定:按钮在「编辑」与「归档」之间,锁上后归档被禁用](https://raw.githubusercontent.com/gezi-wen/sage-mem/main/docs/images/lock-card.png)
57
57
 
58
- *体检 —— 索引悬空、漏索引、断链这类硬问题按类报红;「指向已归档」只作提示、不计入问题,也不提供一键修补按钮(改哪条由你决定)。*
58
+ *锁定 —— 重要记忆(人设、偏好这类)点一下就锁上:**自动归档的候选里不再出现它,手动点归档也会被宿主拒绝**。锁存在状态目录里,不写进记忆自己的 frontmatter —— 那会被自动整理重写掉,锁一丢它又会回到候选里。*
59
+
60
+ ![体检:独立一页,硬问题按类报红分节,「指向已归档」只作提示](https://raw.githubusercontent.com/gezi-wen/sage-mem/main/docs/images/audit.png)
61
+
62
+ *体检(独立成一个页签)—— 索引悬空、漏索引、断链这类硬问题按类报红;「指向已归档」只作提示、不计入问题,也不提供一键修补按钮(改哪条由你决定)。从档案馆恢复一条记忆时,插件会顺手把它补回索引 —— 以前只搬文件、不补条目,于是顶层有文件、`MEMORY.md` 里没有,体检一直报「漏索引」。*
63
+
64
+ ![更新提示:只在真有新版本时出现一条横幅](https://raw.githubusercontent.com/gezi-wen/sage-mem/main/docs/images/update-banner.png)
65
+
66
+ *更新提示 —— 只在**真有新版本**时出现一条横幅,写明新版本与当前版本;「看更新内容」开新标签页,「×」只是这次不看。查不到 / 被限流 / 网络不通一律静默,不会因为查版本失败而冒红条。*
59
67
 
60
68
  ![归档候选:每条写清「为什么建议归档」,确认后才执行](https://raw.githubusercontent.com/gezi-wen/sage-mem/main/docs/images/archive-candidates.png)
61
69
 
@@ -69,6 +77,10 @@ sage-mem 换一条路:**每条记忆就是一个 Markdown 文件**。用编辑
69
77
 
70
78
  *自动做梦 · 设置 —— 运行 / 设置 / 记录 二级分组;五个注入参数两列排开,并写清「当前生效」与「重启后」,不会让人误以为改完立刻生效。*
71
79
 
80
+ ![自动归档:独立一页,先说清它做什么,三档策略与三个闲置阈值都在这儿](https://raw.githubusercontent.com/gezi-wen/sage-mem/main/docs/images/archive-settings.png)
81
+
82
+ *自动归档(独立成一个页签,排在「自动做梦」与「体检」之间)—— 它和自动做梦不是一件事:归档是**确定性规则**(按访问台账的闲置天数算,不调模型),混在模型那套设置里既难找、也容易让人以为归档会花钱。这一页决定「多久算闲置」,以及要不要真的动手;阈值**保存即生效**,不用重启。*
83
+
72
84
  ## 快速开始
73
85
 
74
86
  需要 DSH `0.1.2-rc.1` 或更高(见[兼容性](#dsh-兼容性)),Node.js `>= 18`。
package/lib/client.js CHANGED
@@ -1684,6 +1684,30 @@ const StarmapRuntime = (function (require, _react) {
1684
1684
  result: { mode: "strict", typeSymbol: "sage-mem/types#AuditReport", schema: objCodec, create: () => objCodec },
1685
1685
  sourceLocation: { "file": "lib/index.js", "line": 1, "column": 1 },
1686
1686
  },
1687
+ {
1688
+ id: "sage-mem#memory/checkUpdate",
1689
+ service: "memory",
1690
+ namespace: "memory",
1691
+ method: "checkUpdate",
1692
+ invocation: { kind: "direct" },
1693
+ parameters: [],
1694
+ result: { mode: "strict", typeSymbol: "sage-mem/types#UpdateCheck", schema: objCodec, create: () => objCodec },
1695
+ sourceLocation: { "file": "lib/index.js", "line": 1, "column": 1 },
1696
+ },
1697
+ {
1698
+ // 上锁 / 解锁一条记忆:锁上的记忆拒绝归档(自动与手动都不行)。
1699
+ id: "sage-mem#memory/setLocked",
1700
+ service: "memory",
1701
+ namespace: "memory",
1702
+ method: "setLocked",
1703
+ invocation: { kind: "direct" },
1704
+ parameters: [
1705
+ { name: "name", wire: "name", source: "json", codec: { mode: "strict", typeSymbol: "sage-mem/types#FileName", schema: strSchema, create: () => strSchema } },
1706
+ { name: "locked", wire: "locked", source: "json", codec: { mode: "strict", typeSymbol: "sage-mem/types#LockFlag", schema: boolSchema, create: () => boolSchema } },
1707
+ ],
1708
+ result: { mode: "strict", typeSymbol: "sage-mem/types#LockResult", schema: objCodec, create: () => objCodec },
1709
+ sourceLocation: { "file": "lib/index.js", "line": 1, "column": 1 },
1710
+ },
1687
1711
  {
1688
1712
  id: "sage-mem#memory/archiveCandidates",
1689
1713
  service: "memory",
@@ -1976,6 +2000,15 @@ const StarmapRuntime = (function (require, _react) {
1976
2000
  ".smem-head-job{font-size:12.5px;font-weight:600;}",
1977
2001
  ".smem-head-actions{margin-left:auto;display:flex;align-items:center;gap:6px;}",
1978
2002
  ".smem-tool-panel{border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-bg-layer-1);padding:10px 12px;display:flex;flex-direction:column;gap:8px;}",
2003
+ // 独立成屏的工具面板(现在的「体检」):显式占满**可用宽度**。
2004
+ // 整个面板的宽度上限在 `.smem-root`(760px)上,这里只要确保它在任何父容器里
2005
+ // 都吃满那个上限 —— 抽屉里的小面板没有这个问题(它外面还套着一层),
2006
+ // 而独立成屏后如果父容器不是 column flex,`align-items:stretch` 就不会发生。
2007
+ ".smem-tool-panel--full{width:100%;align-self:stretch;box-sizing:border-box;}",
2008
+ // 已锁定的记忆:卡片上那个按钮要一眼看出「这条现在拒绝归档」,用警示色
2009
+ ".smem-btn-locked{border-color:var(--dsw-alias-state-warning-primary,#d97706);color:var(--dsw-alias-state-warning-primary,#d97706);}",
2010
+ // 「自动归档」页签里的天数输入框:窄一点,别让三个数字各占一整行
2011
+ ".smem-num{width:88px;}",
1979
2012
  // 体检 / 候选是「翻清单」的面板:定高 + 内部滚动,列表不被顶出首屏。
1980
2013
  // 原样编辑器(.smem-ar-editor)不设上限 —— 那是专心改一份文件的界面,给它完整高度。
1981
2014
  ".smem-tool-panel--cap{max-height:320px;overflow-y:auto;}",
@@ -2074,6 +2107,13 @@ const StarmapRuntime = (function (require, _react) {
2074
2107
  ".smem-ad-warn{color:var(--dsw-alias-state-warning-primary,#d97706);}",
2075
2108
  ".smem-chip--fixed{opacity:.72;cursor:default;}",
2076
2109
  ".smem-chip-x{border:0;background:transparent;color:inherit;cursor:pointer;font-size:11px;font-family:inherit;padding:0 0 0 5px;}",
2110
+ // ── 更新提示(有新版本才出现的一条窄横幅)──
2111
+ ".smem-update{display:flex;align-items:center;gap:10px;flex-wrap:wrap;border:1px solid var(--dsw-alias-brand-primary,#4d76e6);border-radius:8px;padding:6px 10px;font-size:12px;background:var(--dsw-alias-bg-layer-1);}",
2112
+ ".smem-update-link{color:var(--dsw-alias-brand-primary,#4d76e6);text-decoration:underline;}",
2113
+ ".smem-update-x{margin-left:auto;border:0;background:transparent;color:inherit;cursor:pointer;font-size:14px;line-height:1;padding:0 2px;}",
2114
+ // ── 底部一行仓库链接(引流,不抢视线)──
2115
+ ".smem-star{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap;font-size:12px;color:var(--dsw-alias-label-tertiary,#8b93a1);padding:2px 2px 0;}",
2116
+ ".smem-star-link{color:var(--dsw-alias-brand-primary,#4d76e6);text-decoration:underline;}",
2077
2117
  ].join("\n");
2078
2118
 
2079
2119
  const TYPE_META = {
@@ -2408,8 +2448,33 @@ const StarmapRuntime = (function (require, _react) {
2408
2448
  ]
2409
2449
  : [
2410
2450
  h("button", { type: "button", className: "smem-btn", key: "edit", onClick: function () { props.onEdit(item.file); } }, "编辑"),
2411
- // 归档排在「编辑」和「删除」之间:比编辑轻、比删除温和,且随时可逆。
2412
- h("button", { type: "button", className: "smem-btn", key: "arch", title: "收进档案馆:不再参与检索与星图,随时可以恢复", onClick: function () { props.onAskArchive(item.file); } }, "归档"),
2451
+ /**
2452
+ * 锁定排在「编辑」与「归档」之间(用户 2026-10-11 指定):
2453
+ * 它是「别归档这条」的开关,紧挨着归档才说得通。
2454
+ *
2455
+ * 锁上的记忆**自动与手动都不许归档** —— 候选计算会跳过它,
2456
+ * 手动点归档会被宿主拒绝。图标用 🔒 / 🔓 之外还留着文字,
2457
+ * 因为「锁」在这里是**拒绝归档**,不是「只读」。
2458
+ */
2459
+ h("button", {
2460
+ type: "button",
2461
+ className: "smem-btn" + (item.locked ? " smem-btn-locked" : ""),
2462
+ key: "lock",
2463
+ title: item.locked
2464
+ ? "已锁定:自动归档不会选它,手动归档也会被拒绝。点一下解锁"
2465
+ : "锁定这条记忆:自动与手动归档都不再动它(重要的人设 / 偏好建议锁上)",
2466
+ disabled: props.busy,
2467
+ onClick: function () { props.onToggleLock(item.file, !item.locked); },
2468
+ }, item.locked ? "🔒 已锁定" : "🔓 锁定"),
2469
+ // 归档排在「锁定」和「删除」之间:比编辑轻、比删除温和,且随时可逆。
2470
+ h("button", {
2471
+ type: "button", className: "smem-btn", key: "arch",
2472
+ title: item.locked
2473
+ ? "这条已锁定,归档会被拒绝(先解锁)"
2474
+ : "收进档案馆:不再参与检索与星图,随时可以恢复",
2475
+ disabled: props.busy || item.locked === true,
2476
+ onClick: function () { props.onAskArchive(item.file); },
2477
+ }, "归档"),
2413
2478
  h("button", { type: "button", className: "smem-btn smem-btn-danger-ghost", key: "del", onClick: function () { props.onAskDelete(item.file); } }, "删除"),
2414
2479
  ])),
2415
2480
  ]);
@@ -2493,8 +2558,8 @@ const StarmapRuntime = (function (require, _react) {
2493
2558
  };
2494
2559
  const autoArchiveOf = (v) => (v === "off" || v === "auto" ? v : "report");
2495
2560
 
2496
- /** 头部带上的「我在哪」:三个 tab 的中文名(与 .smem-tabs 上的字面一致)。 */
2497
- const TAB_LABEL = { files: "文件列表", star: "记忆星图", autodream: "自动做梦" };
2561
+ /** 头部带上的「我在哪」:五个 tab 的中文名(与 .smem-tabs 上的字面一致)。 */
2562
+ const TAB_LABEL = { files: "文件列表", star: "记忆星图", autodream: "自动做梦", archive: "自动归档", audit: "体检" };
2498
2563
 
2499
2564
  /**
2500
2565
  * 工具面板头部的「收起」按钮:抽屉里开的工具要能一键收回去 ——
@@ -2513,8 +2578,33 @@ const StarmapRuntime = (function (require, _react) {
2513
2578
  * 硬问题与提示**分开渲染**:`archivedLinks` 是提示(归档不是删除,目标还在档案馆里),
2514
2579
  * 跟问题一样染红会让人去「修」一条本来正确的链接 —— 那正是审计里那条分界线。
2515
2580
  */
2581
+ /**
2582
+ * 面板底部的一行仓库链接。
2583
+ *
2584
+ * 参照手机访问插件那一行的做法:放在不抢视线的地方、语气轻、点开是新标签页。
2585
+ * 它**是引流不是功能** —— 所以不占一屏、不做成按钮、失败也没有任何副作用;
2586
+ * 四个 tab 下都在(谁也没必要为看它切页)。
2587
+ */
2588
+ function StarLine() {
2589
+ return h("div", { className: "smem-star" }, [
2590
+ h("span", { key: "ask" }, "⭐ 顺手留颗 Star,作者能高兴一整天"),
2591
+ h("a", {
2592
+ key: "cta",
2593
+ href: "https://github.com/gezi-wen/sage-mem",
2594
+ target: "_blank",
2595
+ rel: "noreferrer",
2596
+ className: "smem-star-link",
2597
+ }, "行,给你一颗 Star"),
2598
+ ]);
2599
+ }
2600
+
2516
2601
  function AuditPanel(props) {
2517
2602
  const r = props.report || {};
2603
+ /**
2604
+ * 独立成 tab 时不套抽屉那层窄框(`--cap` 有 320px 限高)、也不给「收起」按钮 ——
2605
+ * 它不是「暂时摊开的一层」,它就是这一屏。
2606
+ */
2607
+ const standalone = props.standalone === true;
2518
2608
  const rows = (list, render) => (list || []).map(function (x, i) {
2519
2609
  return h("div", { className: "smem-audit-row", key: String(x.file || "?") + "-" + i }, render(x));
2520
2610
  });
@@ -2525,7 +2615,7 @@ const StarmapRuntime = (function (require, _react) {
2525
2615
  ])
2526
2616
  : null);
2527
2617
  const hints = r.archivedLinks || [];
2528
- return h("div", { className: "smem-tool-panel smem-tool-panel--cap", key: "audit" }, [
2618
+ return h("div", { className: "smem-tool-panel" + (standalone ? " smem-tool-panel--full" : " smem-tool-panel--cap"), key: "audit" }, [
2529
2619
  h("div", { className: "smem-tool-head", key: "head" }, [
2530
2620
  h("span", { className: "smem-tool-title", key: "t" }, "体检"),
2531
2621
  h("span", { className: "smem-muted", key: "s" },
@@ -2535,7 +2625,7 @@ const StarmapRuntime = (function (require, _react) {
2535
2625
  h("span", { className: "smem-tool-badge" + (r.problems ? " smem-tool-badge--bad" : ""), key: "p" },
2536
2626
  r.problems ? ("问题 " + r.problems) : "没有问题"),
2537
2627
  h("button", { type: "button", className: "smem-btn", key: "again", disabled: props.busy, onClick: props.onRun }, props.busy ? "体检中…" : "重新体检"),
2538
- toolClose(props),
2628
+ standalone ? null : toolClose(props),
2539
2629
  ]),
2540
2630
  r.problems
2541
2631
  ? h("div", { className: "smem-audit-secs", key: "secs" }, [
@@ -2748,7 +2838,6 @@ const StarmapRuntime = (function (require, _react) {
2748
2838
  const sMemSet = react.useState(null); const memSet = sMemSet[0], setMemSet = sMemSet[1];
2749
2839
  const sLimitsDraft = react.useState(null); const limitsDraft = sLimitsDraft[0], setLimitsDraft = sLimitsDraft[1];
2750
2840
  const sSaveBusy = react.useState(false); const saveBusy = sSaveBusy[0], setSaveBusy = sSaveBusy[1];
2751
- const sAutoSaving = react.useState(false); const autoSaving = sAutoSaving[0], setAutoSaving = sAutoSaving[1];
2752
2841
  const sReservedNew = react.useState(""); const reservedNew = sReservedNew[0], setReservedNew = sReservedNew[1];
2753
2842
 
2754
2843
  // 派生量放在 effect 之前:下面几个 effect 的依赖数组要在渲染期就能取到值。
@@ -2848,35 +2937,6 @@ const StarmapRuntime = (function (require, _react) {
2848
2937
  }, function (e) { setErr(detail(e)); });
2849
2938
  }
2850
2939
 
2851
- /**
2852
- * 自动归档三档:保存后**回读校验**。
2853
- *
2854
- * `setConfig` 对非法值沿用的是「忽略、保留原值」的语义(没有 `{ok:false}` 通道),
2855
- * 所以不能只看它返回的 config —— 必须再 `getConfig()` 一次,确认那个值真的落在
2856
- * 配置里。没变就报错:**不能让用户点了保存以为成了、实际什么都没发生**。
2857
- */
2858
- function saveAutoArchive(v) {
2859
- setErr(null); setNotice(""); setAutoSaving(true);
2860
- callRemote("setConfig", [{ autoArchive: v }], function (d) {
2861
- if (d && d.config && d.config.autoArchive === v) {
2862
- setCfg(d.config);
2863
- setAutoSaving(false);
2864
- setNotice("自动归档已设为「" + AUTO_ARCHIVE_LABEL[v] + "」");
2865
- return;
2866
- }
2867
- // 返回的 config 里没看到新值 → 回读一次再定论
2868
- callRemote("getConfig", [], function (d2) {
2869
- const next = d2 && d2.config ? d2.config : null;
2870
- setAutoSaving(false);
2871
- if (next) setCfg(next);
2872
- const got = next ? autoArchiveOf(next.autoArchive) : null;
2873
- if (got === v) { setNotice("自动归档已设为「" + AUTO_ARCHIVE_LABEL[v] + "」"); return; }
2874
- setErr("保存没生效:配置里仍是「" + (got ? AUTO_ARCHIVE_LABEL[got] : "读不出来") +
2875
- "」。setConfig 对非法值会忽略并保留原值 —— 请重试,或看 DSH 启动日志。");
2876
- }, function (m) { setAutoSaving(false); setErr("保存后回读失败:" + m); });
2877
- }, function (m) { setAutoSaving(false); setErr(m); });
2878
- }
2879
-
2880
2940
  /** 保存 5 项上限。越界/非整数**本地先拦**(host 也是直接拒绝),别让它白跑一趟。 */
2881
2941
  function saveLimits() {
2882
2942
  if (!memRemote || typeof memRemote.setSettings !== "function") { setErr("宿主侧还没有 setSettings 接口(插件版本较旧)"); return; }
@@ -3174,28 +3234,9 @@ const StarmapRuntime = (function (require, _react) {
3174
3234
  ]),
3175
3235
  ]) : null,
3176
3236
  segOf === "settings" ? h("div", { className: "smem-seg", key: "seg-set" }, [
3177
- h("div", { className: "smem-autodream-sec", key: "autoarchive" }, [
3178
- h("div", { className: "smem-autodream-row", key: "r" }, [
3179
- h("span", { className: "smem-autodream-key", key: "k" }, "自动归档"),
3180
- h(Seg, {
3181
- key: "v", value: autoArchiveOf(c.autoArchive),
3182
- options: [
3183
- { value: "off", label: AUTO_ARCHIVE_LABEL.off },
3184
- { value: "report", label: AUTO_ARCHIVE_LABEL.report },
3185
- { value: "auto", label: AUTO_ARCHIVE_LABEL.auto },
3186
- ],
3187
- // 点一档就存一档,但**存完要回读**:setConfig 对非法值是静默忽略,
3188
- // 只信返回值不够(见 saveAutoArchive 的注释)。
3189
- onChange: function (v) { if (v !== autoArchiveOf(c.autoArchive)) saveAutoArchive(v); },
3190
- }),
3191
- autoSaving ? h("span", { className: "smem-status", key: "s" }, "保存中…") : null,
3192
- ]),
3193
- h("div", { className: "smem-autodream-note", key: "cost" }, AUTO_ARCHIVE_COST[autoArchiveOf(c.autoArchive)]),
3194
- autoArchiveOf(c.autoArchive) === "auto" && !c.apply
3195
- ? h("div", { className: "smem-autodream-note smem-ad-warn", key: "deg" },
3196
- "注意:现在「改动方式」是只出报告 —— 这一档会自动降级成只出报告,不会动文件。")
3197
- : null,
3198
- ]),
3237
+ // 自动归档那一段(三档策略 + 三个阈值)**已挪到「自动归档」页签**(v0.9.9):
3238
+ // 归档是确定性规则、不调模型,和这一页的「让模型整理」不是一件事;
3239
+ // 同一件事只在一处设定,这里不再重复摆一份。
3199
3240
  h("div", { className: "smem-autodream-sec", key: "limits" }, [
3200
3241
  h("div", { className: "smem-autodream-row", key: "h" }, [
3201
3242
  h("span", { key: "t", style: { fontWeight: "600", fontSize: "12.5px" } }, "记忆注入参数"),
@@ -3411,6 +3452,187 @@ const StarmapRuntime = (function (require, _react) {
3411
3452
  ]);
3412
3453
  }
3413
3454
 
3455
+ /**
3456
+ * 「自动归档」面板 —— 独立成一屏(v0.9.9)。
3457
+ *
3458
+ * 为什么独立出来(用户 2026-10-11):这一块原来塞在「自动做梦 → 设置」里,
3459
+ * 他自己的原话是「扔这里连我都找不到」。而且它确实是**另一件事**:
3460
+ * 自动做梦 = 让模型读记忆、整理、改写(要调模型、有 token 成本)
3461
+ * 自动归档 = 按「多久没被注入过」把记忆移进 archive/(纯确定性规则、不调模型)
3462
+ * 混在模型那套设置里,既难找、也容易让人以为归档会花钱。
3463
+ *
3464
+ * 这一页管两件事:
3465
+ * 1. **三档策略**:关闭 / 只出报告 / 满足条件自动跑
3466
+ * 2. **三个阈值**(项目 / 参考 / 用户):多少天没被注入就算闲置
3467
+ *
3468
+ * 数据全走 `remote.autodream` 的 `getConfig` / `setConfig` —— 阈值本来就在配置里
3469
+ * (`lib/autodream/config.js` 的 archiveAfterDays*),只是以前**界面够不着**。
3470
+ * 所以这一版没有新增任何 remote。
3471
+ *
3472
+ * 两处诚实标注:阈值**存完立即生效**(不经过模块加载期固化,与注入参数不同);
3473
+ * 保存后**一定回读**(`setConfig` 对非法值是「忽略并保留原值」,只信返回值会骗人)。
3474
+ */
3475
+ function ArchiveSettingsPanel(props) {
3476
+ const remote = props.ctx && props.ctx.get ? props.ctx.get("remote.autodream") : null;
3477
+ const sCfg = react.useState(null); const cfg = sCfg[0], setCfg = sCfg[1];
3478
+ const sDraft = react.useState(null); const draft = sDraft[0], setDraft = sDraft[1];
3479
+ const sErr = react.useState(null); const err = sErr[0], setErr = sErr[1];
3480
+ const sNotice = react.useState(""); const notice = sNotice[0], setNotice = sNotice[1];
3481
+ const sBusy = react.useState(false); const busy = sBusy[0], setBusy = sBusy[1];
3482
+
3483
+ const TH_KEYS = ["archiveAfterDaysProject", "archiveAfterDaysReference", "archiveAfterDaysUser"];
3484
+ const TH_LABEL = { archiveAfterDaysProject: "项目", archiveAfterDaysReference: "参考", archiveAfterDaysUser: "用户" };
3485
+ const TH_HINT = {
3486
+ archiveAfterDaysProject: "project_* 连续这么多天没被注入 → 进候选",
3487
+ archiveAfterDaysReference: "reference_* 同上(资料一般比项目留得久)",
3488
+ archiveAfterDaysUser: "user_* 同上(人设这类动它要格外谨慎)",
3489
+ };
3490
+
3491
+ /** 阈值 → 输入框草稿。存成字符串:number input 清空时立刻变 0 会很难用。 */
3492
+ const draftOf = function (c) {
3493
+ const src = c || {};
3494
+ const out = {};
3495
+ for (const k of TH_KEYS) out[k] = String(src[k] == null ? "" : src[k]);
3496
+ return out;
3497
+ };
3498
+
3499
+ react.useEffect(function () {
3500
+ if (!remote || typeof remote.getConfig !== "function") {
3501
+ setErr("宿主侧没有 autodream 配置接口(插件版本较旧)");
3502
+ return;
3503
+ }
3504
+ remote.getConfig()
3505
+ .then(function (r) {
3506
+ const d = unwrap(r);
3507
+ const c = d && d.config ? d.config : null;
3508
+ if (!c) { setErr("读不出自动做梦配置"); return; }
3509
+ setCfg(c);
3510
+ setDraft(draftOf(c));
3511
+ })
3512
+ .catch(function (e) { setErr("读配置失败:" + ((e && e.message) || e)); });
3513
+ }, []);
3514
+
3515
+ const c = cfg || {};
3516
+ const mode = autoArchiveOf(c.autoArchive);
3517
+
3518
+ /**
3519
+ * 存完回读。
3520
+ * @param {Object} patch 要写的配置
3521
+ * @param {string} okMsg 成功提示
3522
+ * @param {Function} failOf (got) => string|null —— 返回 null 表示这次改动真的落进去了
3523
+ */
3524
+ function save(patch, okMsg, failOf) {
3525
+ setBusy(true); setErr(null); setNotice("");
3526
+ remote.setConfig(patch)
3527
+ .then(function () { return remote.getConfig(); })
3528
+ .then(function (r) {
3529
+ const d = unwrap(r);
3530
+ const got = d && d.config ? d.config : null;
3531
+ if (got) { setCfg(got); setDraft(draftOf(got)); }
3532
+ const bad = failOf(got);
3533
+ if (!bad) { setNotice(okMsg); return; }
3534
+ setErr("保存没生效:" + bad + "。setConfig 对非法值会忽略并保留原值 —— 请重试,或看 DSH 启动日志。");
3535
+ })
3536
+ .catch(function (e) { setErr("保存失败:" + ((e && e.message) || e)); })
3537
+ .then(function () { setBusy(false); });
3538
+ }
3539
+
3540
+ function saveMode(v) {
3541
+ save({ autoArchive: v }, "自动归档已设为「" + AUTO_ARCHIVE_LABEL[v] + "」", function (got) {
3542
+ const g = got ? autoArchiveOf(got.autoArchive) : null;
3543
+ return g === v ? null : ("配置里仍是「" + (g ? AUTO_ARCHIVE_LABEL[g] : "读不出来") + "」");
3544
+ });
3545
+ }
3546
+
3547
+ /** 越界/非整数**本地先拦**(host 也会拒),别让它白跑一趟。 */
3548
+ function saveThresholds() {
3549
+ const d = draft || {};
3550
+ const patch = {};
3551
+ for (const k of TH_KEYS) {
3552
+ const n = Number(d[k]);
3553
+ if (!Number.isFinite(n) || n < 1 || n > 3650) {
3554
+ setErr(TH_LABEL[k] + "的阈值要是 1~3650 之间的天数(现在是「" + d[k] + "」)");
3555
+ return;
3556
+ }
3557
+ patch[k] = Math.round(n);
3558
+ }
3559
+ save(patch, "归档条件已保存", function (got) {
3560
+ const bad = TH_KEYS.filter(function (k) { return !got || Number(got[k]) !== patch[k]; });
3561
+ if (!bad.length) return null;
3562
+ return bad.map(function (k) { return TH_LABEL[k] + " 仍是 " + (got ? got[k] : "读不出来"); }).join("、");
3563
+ });
3564
+ }
3565
+
3566
+ return h("div", { className: "smem-seg" }, [
3567
+ // ── ① 它做什么(这一屏存在的理由写在最前面)──
3568
+ h("div", { className: "smem-autodream-sec", key: "what" }, [
3569
+ h("div", { className: "smem-autodream-row", key: "h" }, [
3570
+ h("span", { key: "t", style: { fontWeight: "600", fontSize: "12.5px" } }, "自动归档做什么"),
3571
+ ]),
3572
+ h("div", { className: "smem-autodream-note", key: "p1" },
3573
+ "闲置太久没被注入过的记忆,会被移进 archive/ —— 不删除,随时能恢复。这一页决定「多久算闲置」,以及要不要真的动手。"),
3574
+ h("div", { className: "smem-autodream-note", key: "p2" },
3575
+ "它不调模型:候选完全按访问台账的天数算,同一份输入每次算出来都一样。"),
3576
+ h("div", { className: "smem-autodream-note", key: "p3" },
3577
+ "想现在看看会被收走哪些:文件列表 →「工具 ▾」→ 归档候选。"),
3578
+ ]),
3579
+ // ── ② 三档策略 ──
3580
+ h("div", { className: "smem-autodream-sec", key: "mode" }, [
3581
+ h("div", { className: "smem-autodream-row", key: "r" }, [
3582
+ h("span", { className: "smem-autodream-key", key: "k" }, "自动归档"),
3583
+ h(Seg, {
3584
+ key: "v", value: mode,
3585
+ options: [
3586
+ { value: "off", label: AUTO_ARCHIVE_LABEL.off },
3587
+ { value: "report", label: AUTO_ARCHIVE_LABEL.report },
3588
+ { value: "auto", label: AUTO_ARCHIVE_LABEL.auto },
3589
+ ],
3590
+ onChange: function (v) { if (v !== mode) saveMode(v); },
3591
+ }),
3592
+ busy ? h("span", { className: "smem-status", key: "s" }, "保存中…") : null,
3593
+ ]),
3594
+ h("div", { className: "smem-autodream-note", key: "cost" }, AUTO_ARCHIVE_COST[mode]),
3595
+ mode === "auto" && !c.apply
3596
+ ? h("div", { className: "smem-autodream-note smem-ad-warn", key: "deg" },
3597
+ "注意:「自动做梦 → 改动方式」现在是只出报告 —— 这一档会自动降级成只出报告,不会动文件。")
3598
+ : null,
3599
+ ]),
3600
+ // ── ③ 三个阈值 ──
3601
+ h("div", { className: "smem-autodream-sec", key: "th" }, [
3602
+ h("div", { className: "smem-autodream-row", key: "h" }, [
3603
+ h("span", { key: "t", style: { fontWeight: "600", fontSize: "12.5px" } }, "闲置多少天算可以归档"),
3604
+ ]),
3605
+ draft
3606
+ ? h("div", { key: "rows" }, TH_KEYS.map(function (k) {
3607
+ return h("div", { className: "smem-autodream-row", key: k }, [
3608
+ h("span", { className: "smem-autodream-key", key: "k" }, TH_LABEL[k]),
3609
+ h("input", {
3610
+ className: "smem-input smem-num", key: "i", type: "number", min: 1, max: 3650,
3611
+ value: draft[k],
3612
+ onChange: function (e) {
3613
+ const nx = Object.assign({}, draft);
3614
+ nx[k] = e.target.value;
3615
+ setDraft(nx);
3616
+ },
3617
+ }),
3618
+ h("span", { className: "smem-muted", key: "u" }, TH_HINT[k]),
3619
+ ]);
3620
+ }))
3621
+ : h("div", { className: "smem-muted", key: "loading" }, "读配置中…"),
3622
+ h("div", { className: "smem-autodream-row", key: "btns" }, [
3623
+ h("button", {
3624
+ type: "button", className: "smem-btn smem-btn-primary", key: "save",
3625
+ disabled: busy || !draft, onClick: saveThresholds,
3626
+ }, busy ? "保存中…" : "保存归档条件"),
3627
+ h("span", { className: "smem-muted", key: "n" }, "保存即生效,不用重启 DSH"),
3628
+ ]),
3629
+ h("div", { className: "smem-autodream-note", key: "note" },
3630
+ "天数按访问台账算(这条记忆上次被注入给模型是什么时候)。feedback_* 永不入选;锁定的记忆也不会被选中。"),
3631
+ ]),
3632
+ err ? h("div", { className: "smem-err", key: "err" }, "⚠ " + err) : null,
3633
+ notice ? h("div", { className: "smem-ok", key: "ok" }, "✓ " + notice) : null,
3634
+ ]);
3635
+ }
3414
3636
  function Section(props) {
3415
3637
  const remote = props.ctx.get("remote.memory");
3416
3638
  const sItems = react.useState([]);
@@ -3504,6 +3726,9 @@ const StarmapRuntime = (function (require, _react) {
3504
3726
  const audit = sAudit[0], setAudit = sAudit[1];
3505
3727
  const sAuditBusy = react.useState(false);
3506
3728
  const auditBusy = sAuditBusy[0], setAuditBusy = sAuditBusy[1];
3729
+ // 更新提示:只在**确实有新版本**时才被赋值(查不到/限流/网络错都不赋值)
3730
+ const sUpdate = react.useState(null);
3731
+ const updateInfo = sUpdate[0], setUpdateInfo = sUpdate[1];
3507
3732
  const sCand = react.useState(null);
3508
3733
  const cand = sCand[0], setCand = sCand[1];
3509
3734
  const sCandBusy = react.useState(false);
@@ -3558,6 +3783,19 @@ const StarmapRuntime = (function (require, _react) {
3558
3783
  // 头部带的两个徽标要有数据才立得住:体检(只读扫描)与待重启项。
3559
3784
  runAudit();
3560
3785
  loadSettings();
3786
+ /**
3787
+ * 更新检查:**静默**跑,失败一律不打扰 ——
3788
+ * 网络不通、被 GitHub 限流、仓库还没建 release,都不该在面板上冒一条红。
3789
+ * 只有 `updateAvailable` 为真才把结果放进 state,界面才有一条横幅。
3790
+ */
3791
+ if (remote && typeof remote.checkUpdate === "function") {
3792
+ remote.checkUpdate()
3793
+ .then(function (r) {
3794
+ const d = unwrap(r);
3795
+ if (d && d.updateAvailable) setUpdateInfo(d);
3796
+ })
3797
+ .catch(function () { /* 查版本失败不是用户的事 */ });
3798
+ }
3561
3799
  }, []);
3562
3800
 
3563
3801
  /**
@@ -3699,9 +3937,33 @@ const StarmapRuntime = (function (require, _react) {
3699
3937
  .then(function () { setBusy(false); });
3700
3938
  }
3701
3939
 
3940
+ /**
3941
+ * 上锁 / 解锁一条记忆。
3942
+ *
3943
+ * 锁定 = **拒绝归档**(自动归档的候选里不出现、手动点归档也被宿主拒绝),
3944
+ * **不是「只读」** —— 文案必须按这个说,否则用户会以为锁上就不能编辑了。
3945
+ */
3946
+ function toggleLock(file, locked) {
3947
+ if (typeof remote.setLocked !== "function") {
3948
+ setError("宿主侧还没有 setLocked 接口(插件版本较旧)");
3949
+ return;
3950
+ }
3951
+ setBusy(true); setError(null); setNotice("");
3952
+ remote.setLocked(file, locked)
3953
+ .then(function (r) {
3954
+ const d = unwrap(r);
3955
+ if (d && d.ok === false) { setError(d.error || "锁定操作被拒绝"); return; }
3956
+ setNotice(locked
3957
+ ? ("已锁定 " + file + " —— 自动归档不会再选它,手动归档也会被拒绝")
3958
+ : ("已解锁 " + file));
3959
+ load();
3960
+ })
3961
+ .catch(function (e) { setError(String((e && e.message) || e)); })
3962
+ .then(function () { setBusy(false); });
3963
+ }
3964
+
3702
3965
  /** 从档案馆捞回来:文件移回记忆目录,归档留痕被抹掉。 */
3703
- function restoreFile(file) {
3704
- if (typeof remote.restore !== "function") {
3966
+ function restoreFile(file) { if (typeof remote.restore !== "function") {
3705
3967
  setError("宿主侧还没有 restore 接口(插件版本较旧)");
3706
3968
  return;
3707
3969
  }
@@ -3710,7 +3972,8 @@ const StarmapRuntime = (function (require, _react) {
3710
3972
  remote.restore(file)
3711
3973
  .then(function (r) {
3712
3974
  if (r && r.ok === false) { setError(r.error || "恢复被拒绝"); return; }
3713
- setNotice("已恢复 " + file + " —— 它回到了活动记忆里");
3975
+ // 恢复会顺带把这条补进索引:说清楚,否则用户不知道「漏索引」是怎么好的
3976
+ setNotice("已恢复 " + file + " —— 它回到了活动记忆里" + (r && r.indexed ? ",并补进了索引" : ""));
3714
3977
  load();
3715
3978
  })
3716
3979
  .catch(function (e) { setError(String((e && e.message) || e)); })
@@ -3810,16 +4073,27 @@ const StarmapRuntime = (function (require, _react) {
3810
4073
  const next = tool === name ? null : name;
3811
4074
  setTool(next);
3812
4075
  setError(null);
3813
- if (next === "audit" && !audit) runAudit();
3814
4076
  if (next === "candidates" && !cand) loadCandidates();
3815
4077
  if (next === "raw" && !settings) loadSettings();
3816
4078
  }
3817
4079
 
3818
- /** 打开某个工具(头部带的徽标点进来时用:不切换、直接开)。 */
4080
+ /**
4081
+ * 切到「体检」这一屏。
4082
+ *
4083
+ * 体检从工具抽屉里独立出来了(它本来就是「看」不是「做」),排在「自动做梦」之后。
4084
+ * 数据没拉过才拉:初始加载已经跑过一次体检(头部带那个徽标要有数据才立得住),
4085
+ * 切过来通常直接就看得见内容。
4086
+ */
4087
+ function openAuditTab() {
4088
+ setTab("audit");
4089
+ setError(null);
4090
+ if (!audit) runAudit();
4091
+ }
4092
+
4093
+ /** 打开某个工具(抽屉里点进来时用)。 */
3819
4094
  function openTool(name) {
3820
4095
  setTool(name);
3821
4096
  setError(null);
3822
- if (name === "audit" && !audit) runAudit();
3823
4097
  if (name === "candidates" && !cand) loadCandidates();
3824
4098
  if (name === "raw" && !settings) loadSettings();
3825
4099
  }
@@ -4013,6 +4287,7 @@ const StarmapRuntime = (function (require, _react) {
4013
4287
  onAskArchive: function (file) { setArchiving(file); setArchiveReason(""); setDeleting(null); },
4014
4288
  onCancelArchive: function () { setArchiving(null); setArchiveReason(""); },
4015
4289
  onConfirmArchive: confirmArchive,
4290
+ onToggleLock: toggleLock,
4016
4291
  onToggle: function (file) {
4017
4292
  const nx = {};
4018
4293
  nx[file] = !expanded[file];
@@ -4039,8 +4314,15 @@ const StarmapRuntime = (function (require, _react) {
4039
4314
  h("button", { type: "button", className: "smem-tab" + (tab === "files" ? " smem-tab--on" : ""), key: "f", onClick: function () { setTab("files"); } }, "文件列表"),
4040
4315
  h("button", { type: "button", className: "smem-tab" + (tab === "star" ? " smem-tab--on" : ""), key: "s", onClick: function () { setTab("star"); } }, "记忆星图"),
4041
4316
  h("button", { type: "button", className: "smem-tab" + (tab === "autodream" ? " smem-tab--on" : ""), key: "d", onClick: function () { setTab("autodream"); } }, "自动做梦"),
4317
+ // 「自动归档」排在自动做梦之后:归档是**确定性规则**(按闲置天数算,不调模型),
4318
+ // 和「让模型整理记忆」是两件事。塞在自动做梦的设置里用户找不到
4319
+ // (2026-10-11 用户原话:「扔这里连我都找不到」)。
4320
+ h("button", { type: "button", className: "smem-tab" + (tab === "archive" ? " smem-tab--on" : ""), key: "ar", onClick: function () { setTab("archive"); } }, "自动归档"),
4321
+ // 体检再排在「自动归档」之后、独立成一屏:它只报告不修改,和「文件列表」里的
4322
+ // 增删改是两件事,塞在工具抽屉里会让人以为它是个动作按钮。
4323
+ h("button", { type: "button", className: "smem-tab" + (tab === "audit" ? " smem-tab--on" : ""), key: "a", onClick: openAuditTab }, "体检"),
4042
4324
  ]),
4043
- // 三个 tab 共用的一条头部带:我在哪 · 目录状态 · 活动/归档计数 · 体检徽标 · 待重启徽标 · 主操作。
4325
+ // 四个 tab 共用的一条头部带:我在哪 · 目录状态 · 活动/归档计数 · 体检徽标 · 待重启徽标 · 主操作。
4044
4326
  // 层级形状固定:导航=下划线(.smem-tabs)、筛选=chip、动作=按钮,三种不混用。
4045
4327
  h("div", { className: "smem-head", key: "head" }, [
4046
4328
  h("span", { className: "smem-title", key: "t" }, "记忆管理"),
@@ -4048,13 +4330,12 @@ const StarmapRuntime = (function (require, _react) {
4048
4330
  h("span", { className: "smem-dot" + (dirOk ? "" : " smem-dot--bad"), key: "dot", title: dirOk ? "memory 目录可读" : "memory 目录不可读" }),
4049
4331
  h("span", { className: "smem-muted", key: "st" }, dirOk ? "目录可读" : "目录不可读"),
4050
4332
  h("span", { className: "smem-badge", key: "cnt" }, headerCount),
4051
- // 体检徽标:**抽屉关着也一直看得见**。整个「工具收进抽屉」的方案就靠它成立 ——
4052
- // 没有这个徽标,三个能力就退回「藏起来」了(上一轮刚把它们做成用户自己的入口)。
4333
+ // 体检徽标:**抽屉关着也一直看得见**,点它就切到「体检」那一屏。
4053
4334
  h("button", {
4054
4335
  type: "button", key: "aud",
4055
4336
  className: "smem-tool-badge" + (audit && audit.problems ? " smem-tool-badge--bad" : ""),
4056
- title: audit ? (audit.problems ? "打开体检看是哪几条" : "体检没有发现问题") : "跑一次体检(只读)",
4057
- onClick: function () { openTool("audit"); },
4337
+ title: audit ? (audit.problems ? "切到体检看是哪几条" : "体检没有发现问题") : "跑一次体检(只读)",
4338
+ onClick: openAuditTab,
4058
4339
  }, audit ? (audit.problems ? ("体检 " + audit.problems + " 个问题") : "体检 通过") : "体检 · 未跑"),
4059
4340
  restartPending
4060
4341
  ? h("button", {
@@ -4069,6 +4350,22 @@ const StarmapRuntime = (function (require, _react) {
4069
4350
  h("button", { type: "button", className: "smem-btn smem-btn-primary", key: "add", onClick: openAdd }, "+ 添加记忆"),
4070
4351
  ] : null),
4071
4352
  ]),
4353
+ // 更新提示:四个 tab 共用一条,只在真有新版时出现;「×」只是这次不看
4354
+ updateInfo
4355
+ ? h("div", { className: "smem-update", key: "update" }, [
4356
+ h("span", { key: "t" }, "发现新版本 v" + String(updateInfo.latest) + "(当前 v" + String(updateInfo.current) + ")"),
4357
+ h("a", {
4358
+ key: "l", className: "smem-update-link",
4359
+ href: updateInfo.releasesUrl || "https://github.com/gezi-wen/sage-mem/releases",
4360
+ target: "_blank", rel: "noreferrer",
4361
+ }, "看更新内容"),
4362
+ h("button", {
4363
+ type: "button", key: "x", className: "smem-update-x",
4364
+ title: "这次不看了(下次进面板还会提示)",
4365
+ onClick: function () { setUpdateInfo(null); },
4366
+ }, "×"),
4367
+ ])
4368
+ : null,
4072
4369
  // 工具栏只剩「搜索 + 工具 ▾」:刷新与添加已经收进头部带的主操作位。
4073
4370
  tab === "files" ? h("div", { className: "smem-toolbar", key: "bar" }, [
4074
4371
  h("input", { className: "smem-search", key: "q", value: query, placeholder: "搜索文件名 / 描述 / 类型 / 标签…", onChange: function (e) { setQuery(e.target.value); } }),
@@ -4078,15 +4375,11 @@ const StarmapRuntime = (function (require, _react) {
4078
4375
  type: "button", key: "btn",
4079
4376
  className: "smem-btn" + (drawer ? " smem-btn-primary" : ""),
4080
4377
  "aria-expanded": drawer ? "true" : "false",
4081
- title: "体检 / 归档候选 / 保留名编辑",
4378
+ title: "归档候选 / 保留名编辑",
4082
4379
  onClick: function () { setDrawer(!drawer); },
4083
4380
  }, "工具 ▾"),
4084
4381
  drawer ? h("div", { className: "smem-drawer-pop", key: "pop" }, [
4085
- h("button", {
4086
- type: "button", className: "smem-drawer-item", key: "a",
4087
- "data-on": tool === "audit" ? "1" : "0",
4088
- onClick: function () { toggleTool("audit"); setDrawer(false); },
4089
- }, ["体检", h("span", { className: "smem-status" + (audit && audit.problems ? " smem-status--bad" : ""), key: "s" }, audit ? (audit.problems ? (audit.problems + " 个问题") : "通过") : "未跑")]),
4382
+ // 体检独立成 tab 之后,抽屉里只剩这两件「要动手」的事。
4090
4383
  h("button", {
4091
4384
  type: "button", className: "smem-drawer-item", key: "c",
4092
4385
  "data-on": tool === "candidates" ? "1" : "0",
@@ -4100,8 +4393,8 @@ const StarmapRuntime = (function (require, _react) {
4100
4393
  ]) : null,
4101
4394
  ]),
4102
4395
  ]) : null,
4103
- tab === "files" && tool === "audit"
4104
- ? h(AuditPanel, { report: audit, busy: auditBusy, onRun: runAudit, onClose: function () { setTool(null); } })
4396
+ tab === "audit"
4397
+ ? h(AuditPanel, { report: audit, busy: auditBusy, onRun: runAudit, standalone: true })
4105
4398
  : null,
4106
4399
  tab === "files" && tool === "candidates"
4107
4400
  ? h(CandidatePanel, {
@@ -4232,7 +4525,15 @@ const StarmapRuntime = (function (require, _react) {
4232
4525
  : tab === "star"
4233
4526
  ? h("div", { className: "smem-starmap", key: "starmap" },
4234
4527
  h(StarmapRuntime.StarMap, { ctx: props.ctx }))
4528
+ : tab === "archive"
4529
+ ? h(ArchiveSettingsPanel, { key: "archive", ctx: props.ctx })
4530
+ : tab === "audit"
4531
+ // 体检面板在上面已经渲染过了 —— 这里必须显式给 null,
4532
+ // 否则会落进下面那个 `filesView` 兜底分支,变成「体检 + 一整套文件列表」同屏。
4533
+ ? null
4235
4534
  : filesView,
4535
+ // 底部一行仓库链接(四个 tab 都在,不抢视线)
4536
+ h(StarLine, { key: "star-line" }),
4236
4537
  ]);
4237
4538
  }
4238
4539
 
package/lib/index.js CHANGED
@@ -29,6 +29,8 @@ import { warnLog, writeFileAtomic } from './memory/util.js'
29
29
  import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
30
30
  import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises'
31
31
  import { basename, join } from 'node:path'
32
+ import { checkUpdate as checkUpdateImpl } from './update.js'
33
+ import { readLockSet, setLocked as setLockedImpl } from './memory/lock.js'
32
34
 
33
35
  /**
34
36
  * `SAGE_MEM_DEBUG=1` 才出声的注入链调试出口。
@@ -377,9 +379,20 @@ export class MemoryGateway extends TypertRemoteService {
377
379
  })
378
380
  }
379
381
 
380
- /** 列出 memory 目录全部记忆文件(文件名 + 类型 + 描述 + 大小 + 自定义标签)。 */
382
+ /**
383
+ * 查有没有新版本(面板进入时静默调一次)。
384
+ *
385
+ * 实现全在 `lib/update.js`:**当前版本运行时读自己的 `package.json`**(不内联常量 ——
386
+ * 内联会在「构建早于 bump」时停留在旧版本,插件于是永远对自己报「有新版本」);
387
+ * 查不到一律降级为「不提示」,绝不在界面上冒红条。
388
+ */
389
+ async checkUpdate() {
390
+ return await checkUpdateImpl()
391
+ }
392
+
393
+ /** 列出 memory 目录全部记忆文件(文件名 + 类型 + 描述 + 大小 + 自定义标签 + 是否锁定)。 */
381
394
  async listFiles() {
382
- const files = await scanMemoryFiles(this.ctx, '')
395
+ const [files, locked] = await Promise.all([scanMemoryFiles(this.ctx, ''), readLockSet(MEMORY_DIR)])
383
396
  return files.map(f => {
384
397
  const meta = parseFrontmatter(f.content)
385
398
  return {
@@ -388,6 +401,8 @@ export class MemoryGateway extends TypertRemoteService {
388
401
  description: meta.description || '',
389
402
  size: f.content.length,
390
403
  tags: meta.tags,
404
+ // 锁定状态跟着每条一起给:卡片上的「锁定 / 已锁定」按钮要据此渲染
405
+ locked: locked.has(nameKey(f.file)),
391
406
  }
392
407
  })
393
408
  }
@@ -616,17 +631,40 @@ export class MemoryGateway extends TypertRemoteService {
616
631
  * 复用 `memory/access.js` 的台账与候选计算,与 autodream 引擎跑的是同一份逻辑。
617
632
  * 先强制 flush 台账:面板看到的候选不能比引擎少(内存里还压着的那批注入必须算进去)。
618
633
  */
634
+ /**
635
+ * 上锁 / 解锁一条记忆。锁上的记忆**拒绝归档**(自动与手动都不行)。
636
+ *
637
+ * 落点是**状态目录**(`.sage-mem/locked.json`),不写进记忆自己的 frontmatter ——
638
+ * 理由见 `lib/memory/lock.js` 的头注:frontmatter 会被「自动做梦」重写,锁写在里面
639
+ * 可能被整理过程擦掉,而锁一丢那条记忆就重新进了自动归档候选(正是这功能要防的事)。
640
+ */
641
+ async setLocked(name, locked) {
642
+ return await setLockedImpl(MEMORY_DIR, name, locked === true)
643
+ }
644
+
619
645
  async archiveCandidates() {
620
646
  await flushAccess(MEMORY_DIR)
621
- const [ledger, files] = await Promise.all([readAccessLedger(), scanMemoryFiles(this.ctx, '')])
647
+ const [ledger, files, locked] = await Promise.all([
648
+ readAccessLedger(),
649
+ scanMemoryFiles(this.ctx, ''),
650
+ readLockSet(MEMORY_DIR),
651
+ ])
622
652
  const cfg = await autodreamConfigSnapshot()
623
653
  const thresholds = {
624
654
  project: cfg.archiveAfterDaysProject,
625
655
  reference: cfg.archiveAfterDaysReference,
626
656
  user: cfg.archiveAfterDaysUser,
627
657
  }
628
- const candidates = pickArchiveCandidates(files, ledger, { thresholds })
629
- return { count: candidates.length, candidates, autoArchive: cfg.autoArchive, thresholds }
658
+ // 锁表交给候选计算:锁上的记忆一个都不选(用户对**单条**记忆说的话优先于阈值)。
659
+ const candidates = pickArchiveCandidates(files, ledger, { thresholds, locked })
660
+ return {
661
+ count: candidates.length,
662
+ candidates,
663
+ autoArchive: cfg.autoArchive,
664
+ thresholds,
665
+ // 面板要显示「哪些锁着」,所以把锁表也带回去(已按 nameKey 归一)
666
+ locked: [...locked],
667
+ }
630
668
  }
631
669
 
632
670
  /**
@@ -784,6 +822,8 @@ markRemote(MemoryGateway, 'listArchived', 'listArchived')
784
822
  markRemote(MemoryGateway, 'readArchived', 'readArchived')
785
823
  markRemote(MemoryGateway, 'writeArchived', 'writeArchived')
786
824
  markRemote(MemoryGateway, 'audit', 'audit')
825
+ markRemote(MemoryGateway, 'checkUpdate', 'checkUpdate')
826
+ markRemote(MemoryGateway, 'setLocked', 'setLocked')
787
827
  markRemote(MemoryGateway, 'archiveCandidates', 'archiveCandidates')
788
828
  markRemote(MemoryGateway, 'getSettings', 'getSettings')
789
829
  markRemote(MemoryGateway, 'setSettings', 'setSettings')
@@ -19,6 +19,7 @@ import { parseFrontmatter } from './frontmatter.js'
19
19
  import { writeFileAtomic } from './util.js'
20
20
  import { mkdir, readFile, readdir } from 'node:fs/promises'
21
21
  import { dirname, join } from 'node:path'
22
+ import { nameKey } from './naming.js'
22
23
 
23
24
  /** 台账文件:状态根下,与记忆文件分开。 */
24
25
  export const ACCESS_LEDGER_FILE = join(STATE_ROOT, 'access.json')
@@ -192,14 +193,18 @@ export function idleDays(f, ledger = {}, now = Date.now()) {
192
193
  *
193
194
  * @param {Array} files — `scanMemoryFiles` 的结果(**只有根目录**,所以归档区天然不在内)
194
195
  * @param {Object} ledger — 访问台账
195
- * @param {{thresholds?: Object}} opts — 按类型的阈值(天);缺阈值=该类型永不候选
196
+ * @param {{thresholds?: Object, locked?: Set<string>}} opts — 按类型的阈值(天;缺阈值=该类型永不候选)
197
+ * 与**锁表**(`lib/memory/lock.js` 的 `readLockSet`,键已按 `nameKey` 归一)
196
198
  * @returns {Array<{file:string,type:string,days:number,limit:number,reason:string}>} 按闲置天数降序
197
199
  */
198
200
  export function pickArchiveCandidates(files, ledger = {}, opts = {}, now = Date.now()) {
199
201
  const thresholds = opts.thresholds ?? {}
202
+ const locked = opts.locked ?? null
200
203
  const out = []
201
204
  for (const f of files ?? []) {
202
205
  if (!f?.file || f.archived === true) continue
206
+ // 锁定的记忆一个都不选:这是用户对**单条**记忆说的「别动它」,优先于阈值。
207
+ if (locked && locked.has(nameKey(f.file))) continue
203
208
  const type = fileType(f)
204
209
  if (NEVER_ARCHIVE_TYPES.has(type)) continue
205
210
  // baseline 是「每场会话必读」的那几条:它们本来就不靠检索命中,闲置天数没有意义。
@@ -18,6 +18,8 @@
18
18
  import { ARCHIVE_DIR_NAME, addArchiveMeta, inspectDir, inspectMemoryEntry, localStamp, stripArchiveMeta } from './scan.js'
19
19
  import { isReserved, nameKey, safeName } from './naming.js'
20
20
  import { writeFileAtomic } from './util.js'
21
+ import { isLocked } from './lock.js'
22
+ import { ensureIndexEntry } from './index-file.js'
21
23
  import { mkdir, readFile, stat, unlink } from 'node:fs/promises'
22
24
  import { join } from 'node:path'
23
25
 
@@ -47,6 +49,13 @@ export async function archiveMemory(memoryDir, name, reason, at = localStamp())
47
49
  const gate = checkName(name, 'archive')
48
50
  if (!gate.ok) return gate
49
51
  const safe = gate.safe
52
+ /**
53
+ * 「锁定」优先于一切:锁上的记忆**自动与手动都不许归档**。
54
+ * 放在最前面判 —— 名字合法之后立刻拒绝,不读目录、不算理由。
55
+ */
56
+ if (await isLocked(memoryDir, safe)) {
57
+ return { ok: false, code: 'locked', error: `sage-mem: locked, refusing to archive: ${safe}(先在界面里解锁)` }
58
+ }
50
59
  const src = join(memoryDir, safe)
51
60
  const dst = join(archiveDirOf(memoryDir), safe)
52
61
  /**
@@ -118,5 +127,13 @@ export async function restoreMemory(memoryDir, name) {
118
127
  const content = await readFile(src, 'utf8')
119
128
  await writeFileAtomic(dst, stripArchiveMeta(content))
120
129
  await unlink(src)
121
- return { ok: true, file: safe }
130
+ /**
131
+ * **注册回索引**:移回文件不等于这条记忆又存在了 —— 索引里没有它,
132
+ * 面板与检索都看不见,体检会报「漏索引」(真实事故见 lib/memory/index-file.js 的头注)。
133
+ *
134
+ * 幂等(索引里已有指向它的一行就什么都不做);索引缺失或写不进去**不算恢复失败** ——
135
+ * 文件已经回到顶层了,那才是主要结果,索引只是补账。
136
+ */
137
+ const indexed = await ensureIndexEntry(memoryDir, safe).catch(() => null)
138
+ return { ok: true, file: safe, indexed: !!(indexed && indexed.added) }
122
139
  }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * 索引(`MEMORY.md`)的**写入侧** —— 只有一件事:确保某条记忆在索引里有一行。
3
+ *
4
+ * 为什么要单独一个文件:索引是**用户手写**的东西(「改动记忆时同步改这里」这条就写在
5
+ * 索引自己的说明段里)。所以这里只做**追加**,绝不重排、绝不删用户已有的行、绝不重建
6
+ * 整个文件 —— 索引被整份覆盖就等于用户的维护成果没了。
7
+ *
8
+ * 起因(2026-10-11 在一次真实使用中确认):恢复归档记忆时只把文件搬回顶层、
9
+ * 不补索引,于是顶层有文件、索引里没有 → 体检报「漏索引」。
10
+ */
11
+ import { readFile, readdir } from 'node:fs/promises'
12
+ import { basename, join } from 'node:path'
13
+ import { parseFrontmatter } from './frontmatter.js'
14
+ import { nameKey } from './naming.js'
15
+ import { writeFileAtomic } from './util.js'
16
+
17
+ const INDEX_KEY = 'memory.md'
18
+ /** 条目里的描述上限:索引是清单,不是内容堆。 */
19
+ const DESC_MAX = 120
20
+
21
+ /**
22
+ * 索引文件的**磁盘真名**(大小写不敏感地找)。
23
+ * 不假设盘上是 `MEMORY.md` —— 系统盘是 Windows、用户可能就写成了小写。
24
+ */
25
+ export async function findIndexFile(memoryDir) {
26
+ const names = await readdir(memoryDir).catch(() => [])
27
+ return names.find((n) => nameKey(n) === INDEX_KEY) ?? null
28
+ }
29
+
30
+ /** 索引里有没有指向该文件的条目(`](file.md)` 或 `](file.md#L12)`,大小写不敏感)。 */
31
+ function hasEntry(indexText, name) {
32
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
33
+ return new RegExp(`\\]\\(\\s*${escaped}(?:#[^)]*)?\\s*\\)`, 'i').test(indexText)
34
+ }
35
+
36
+ /**
37
+ * 确保索引里有指向 `name` 的一行;已经有就**什么都不做**(幂等)。
38
+ *
39
+ * 条目形状与索引自己声明的约定一致:`- [标题](文件.md) — 一句话`。
40
+ * 标题取 frontmatter 的 `name`(没有就用文件名),描述取 `description`。
41
+ *
42
+ * @returns {Promise<{ok:boolean, added:boolean, file?:string, line?:string, error?:string}>}
43
+ */
44
+ export async function ensureIndexEntry(memoryDir, name) {
45
+ const indexName = await findIndexFile(memoryDir)
46
+ // 没有索引是**正常状态**(索引是可选件):不崩、不建、不当失败。
47
+ if (!indexName) return { ok: true, added: false, error: 'no-index' }
48
+ const indexPath = join(memoryDir, indexName)
49
+ const indexText = await readFile(indexPath, 'utf8').catch(() => '')
50
+ if (hasEntry(indexText, name)) return { ok: true, added: false }
51
+
52
+ const content = await readFile(join(memoryDir, name), 'utf8').catch(() => '')
53
+ const fm = parseFrontmatter(content)
54
+ const title = fm.name || basename(name, '.md')
55
+ const desc = (fm.description || '').trim().slice(0, DESC_MAX)
56
+ const line = `- [${title}](${name})${desc ? ' — ' + desc : ''}`
57
+ // 追加到末尾。文件末尾没换行就先补一个,别把新条目粘到上一行的尾巴上。
58
+ const head = indexText === '' || indexText.endsWith('\n') ? indexText : indexText + '\n'
59
+ await writeFileAtomic(indexPath, head + line + '\n')
60
+ return { ok: true, added: true, file: name, line }
61
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * 「锁定」—— 让一条记忆拒绝归档。
3
+ *
4
+ * 为什么用**状态文件**而不是写进记忆自己的 frontmatter:
5
+ * 1. frontmatter 是用户的正文,而「自动做梦」会重写它 —— 锁写在里面可能被整理过程擦掉,
6
+ * 而锁一旦丢失,那条记忆就会重新进入自动归档候选(**正是这个功能要防的事**)
7
+ * 2. 状态根 `.sage-mem/` 已经是访问台账、快照、回滚点的家,锁放同一层语义一致
8
+ * 代价要说清楚:锁不在记忆文件里,**跟着文件单独拷贝时不会跟走**。别把它当文件属性。
9
+ *
10
+ * 锁表按 `nameKey()` 归一存放:NTFS 不区分大小写,`User_Persona.MD` 与 `user_persona.md`
11
+ * 是同一个文件 —— 锁必须认得出这一点(与保留名保护同一套判据)。
12
+ */
13
+ import { mkdir, readFile } from 'node:fs/promises'
14
+ import { dirname, join } from 'node:path'
15
+ import { nameKey } from './naming.js'
16
+ import { writeFileAtomic } from './util.js'
17
+
18
+ /** 锁表文件:状态根下,与记忆文件分开。 */
19
+ export function lockFileOf(memoryDir) {
20
+ return join(dirname(memoryDir), '.sage-mem', 'locked.json')
21
+ }
22
+
23
+ /**
24
+ * 读锁表。读不到 / JSON 坏 / 不是对象 / 值不是 `true` → 一律当空表。
25
+ * **绝不抛**:锁表坏了最坏是「锁没生效」,不该升级成「读不了记忆」。
26
+ */
27
+ export async function readLockSet(memoryDir) {
28
+ try {
29
+ const parsed = JSON.parse(await readFile(lockFileOf(memoryDir), 'utf8'))
30
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return new Set()
31
+ const out = new Set()
32
+ for (const [k, v] of Object.entries(parsed)) {
33
+ if (v === true) out.add(nameKey(k))
34
+ }
35
+ return out
36
+ } catch {
37
+ return new Set()
38
+ }
39
+ }
40
+
41
+ /** 这条记忆锁上了吗。 */
42
+ export async function isLocked(memoryDir, name) {
43
+ return (await readLockSet(memoryDir)).has(nameKey(name))
44
+ }
45
+
46
+ /**
47
+ * 上锁 / 解锁。
48
+ *
49
+ * 读-改-写:两张并发的锁操作理论上会互相覆盖(丢一次锁)。这里**不引入锁**:
50
+ * 代价是「同时锁两条记忆时可能少一条」,而这远轻于给一个状态文件加一套锁 ——
51
+ * 重按一次就好,用户看得见结果。
52
+ */
53
+ export async function setLocked(memoryDir, name, locked) {
54
+ const key = nameKey(name)
55
+ const file = lockFileOf(memoryDir)
56
+ let table = {}
57
+ try {
58
+ const parsed = JSON.parse(await readFile(file, 'utf8'))
59
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) table = parsed
60
+ } catch {
61
+ table = {}
62
+ }
63
+ if (locked) table[key] = true
64
+ else delete table[key]
65
+ await mkdir(dirname(file), { recursive: true })
66
+ await writeFileAtomic(file, JSON.stringify(table, null, 2) + '\n')
67
+ return { ok: true, file: name, locked: !!locked }
68
+ }
@@ -49,6 +49,9 @@ const fileSchema = z.object({
49
49
  // strict 模式下 host 多返回的字段会被剥掉,所以「列表页要能按标签筛」这件事
50
50
  // 必须在这里显式声明,否则前端永远拿到 undefined。
51
51
  'tags': z.array(z.string()).readonly(),
52
+ // 锁定状态(拒绝归档)跟着每条一起给 —— strict 会把没声明的字段剥掉,
53
+ // 不在这里声明,卡片上的「锁定 / 已锁定」按钮就永远拿到 undefined。
54
+ 'locked': z.boolean().readonly(),
52
55
  }).readonly()
53
56
 
54
57
  const listResultSchema = z.array(fileSchema)
@@ -116,6 +119,16 @@ const archivedWriteResultSchema = z.object({
116
119
  }).readonly()
117
120
 
118
121
  // ── 面板 API(v0.9.0 D1):体检 / 归档候选 / 设置 / 保留名原样读写 ──────────────
122
+ // 更新检查:只回答「有没有更新的正式版」。失败一律降级为「不提示」(error / rateLimited
123
+ // 只是给日志与调试看的,界面不该因为查版本失败而冒红条)。
124
+ const updateCheckSchema = z.object({
125
+ 'current': z.string().readonly(),
126
+ 'latest': z.string().readonly().nullable(),
127
+ 'updateAvailable': z.boolean().readonly(),
128
+ 'rateLimited': z.boolean().readonly(),
129
+ 'error': z.string().readonly(),
130
+ 'releasesUrl': z.string().readonly(),
131
+ }).readonly()
119
132
  // 审计报告:字段面就是 `auditMemoryDir()` 的返回,逐项在这里钉住 —— strict 会把没声明的
120
133
  // 字段剥掉,面板看到的清单必须与模型看到的一致。
121
134
  const auditItemSchema = z.object({ 'file': z.string().readonly() }).readonly()
@@ -161,6 +174,14 @@ const archiveCandidatesResultSchema = z.object({
161
174
  'reference': z.number().readonly(),
162
175
  'user': z.number().readonly(),
163
176
  }).readonly(),
177
+ // 锁定的记忆(已按 nameKey 归一):面板要显示「哪些锁着」
178
+ 'locked': z.array(z.string().readonly()).readonly(),
179
+ }).readonly()
180
+ // 上锁 / 解锁的结果
181
+ const lockResultSchema = z.object({
182
+ 'ok': z.boolean().readonly(),
183
+ 'file': z.string().readonly(),
184
+ 'locked': z.boolean().readonly(),
164
185
  }).readonly()
165
186
 
166
187
  // 设置:limits = 当前**已生效**的值(模块加载时读的),pendingLimits = 待生效值
@@ -528,6 +549,31 @@ export const TYPERT = {
528
549
  result: { mode: 'strict', typeSymbol: 'sage-mem/types#AuditReport', schema: auditReportSchema, create: () => auditReportSchema },
529
550
  sourceLocation: { "file": "lib/index.js", "line": 1, "column": 1 },
530
551
  },
552
+ {
553
+ // 面板进入时静默查一次;只有「有新版本」才在界面上提醒。
554
+ id: 'sage-mem#memory/checkUpdate',
555
+ service: 'memory',
556
+ namespace: 'memory',
557
+ method: 'checkUpdate',
558
+ invocation: { kind: 'direct' },
559
+ parameters: [],
560
+ result: { mode: 'strict', typeSymbol: 'sage-mem/types#UpdateCheck', schema: updateCheckSchema, create: () => updateCheckSchema },
561
+ sourceLocation: { "file": "lib/index.js", "line": 1, "column": 1 },
562
+ },
563
+ {
564
+ // 上锁 / 解锁一条记忆:锁上的记忆拒绝归档(自动与手动都不行)。
565
+ id: 'sage-mem#memory/setLocked',
566
+ service: 'memory',
567
+ namespace: 'memory',
568
+ method: 'setLocked',
569
+ invocation: { kind: 'direct' },
570
+ parameters: [
571
+ { name: 'name', wire: 'name', source: 'json', codec: { mode: 'strict', typeSymbol: 'sage-mem/types#FileName', schema: fileNameSchema, create: () => fileNameSchema } },
572
+ { name: 'locked', wire: 'locked', source: 'json', codec: { mode: 'strict', typeSymbol: 'sage-mem/types#LockFlag', schema: z.boolean().readonly(), create: () => z.boolean().readonly() } },
573
+ ],
574
+ result: { mode: 'strict', typeSymbol: 'sage-mem/types#LockResult', schema: lockResultSchema, create: () => lockResultSchema },
575
+ sourceLocation: { "file": "lib/index.js", "line": 1, "column": 1 },
576
+ },
531
577
  {
532
578
  id: 'sage-mem#memory/archiveCandidates',
533
579
  service: 'memory',
package/lib/update.js ADDED
@@ -0,0 +1,103 @@
1
+ /**
2
+ * 版本检查(宿主侧)。
3
+ *
4
+ * 为什么放在宿主侧查、而不是界面里 fetch:
5
+ * - 跨域与限流都按用户的浏览器出口 IP 算(未认证 60 次/小时),宿主侧可以带
6
+ * `GITHUB_TOKEN` 把额度提到 5000 次/小时
7
+ * - 界面拿不到进程环境里的凭据,宿主侧拿得到
8
+ *
9
+ * ⚠️ **当前版本一律运行时读自己的 `package.json`**,绝不内联常量:
10
+ * 客户端 bundle 是构建期产物,如果版本号在构建时被写死,那么「先 build 再 bump」
11
+ * 的发布流程会让插件永远拿旧常量去比最新 release —— 于是它**对自己报「有新版本」**,
12
+ * 而其实已经是最新的。运行时读文件没有这个时间差。
13
+ */
14
+ import { readFile } from 'node:fs/promises'
15
+
16
+ /** 仓库与接口(单一事实源:界面上的「看更新内容」也指向它)。 */
17
+ export const REPO_SLUG = 'gezi-wen/sage-mem'
18
+ export const REPO_URL = `https://github.com/${REPO_SLUG}`
19
+ export const RELEASES_URL = `${REPO_URL}/releases`
20
+ export const RELEASES_LATEST_API = `https://api.github.com/repos/${REPO_SLUG}/releases/latest`
21
+
22
+ /** 当前安装的版本;读不到就回空串(宁可不提示,也不误报)。 */
23
+ export async function currentVersion() {
24
+ try {
25
+ const raw = await readFile(new URL('../package.json', import.meta.url), 'utf8')
26
+ const pkg = JSON.parse(raw)
27
+ return typeof pkg?.version === 'string' ? pkg.version : ''
28
+ } catch {
29
+ return ''
30
+ }
31
+ }
32
+
33
+ /**
34
+ * 简易版本比较:数字段逐位比,忽略 `v` 前缀与预发布后缀(`-rc.1` 这种不参与)。
35
+ *
36
+ * **故意不引 semver 依赖**:这里只回答「有没有更新的正式版」,
37
+ * 预发布前缀的比较规则(`1.0.0-rc.1 < 1.0.0`)不在这个问题的范围内。
38
+ * @returns {number} a > b 返回正数,a < b 返回负数,相等返回 0
39
+ */
40
+ export function compareVersions(a, b) {
41
+ const parts = (v) =>
42
+ String(v ?? '')
43
+ .replace(/^v/i, '')
44
+ .split('-')[0]
45
+ .split('.')
46
+ .map((n) => parseInt(n, 10) || 0)
47
+ const pa = parts(a)
48
+ const pb = parts(b)
49
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
50
+ const diff = (pa[i] ?? 0) - (pb[i] ?? 0)
51
+ if (diff !== 0) return diff
52
+ }
53
+ return 0
54
+ }
55
+
56
+ /**
57
+ * 查有没有新版本。
58
+ *
59
+ * 失败一律**降级为「不提示」**,并把原因放在 `error` / `rateLimited` 里 ——
60
+ * 网络不可达、被限流、仓库还没建 release,都不该在面板上冒一个红条打断人。
61
+ *
62
+ * @param {{fetchImpl?: Function, timeoutMs?: number}} [opts] — `fetchImpl` 只为测试注入
63
+ * @returns {Promise<{current:string, latest:string|null, updateAvailable:boolean, rateLimited:boolean, error:string}>}
64
+ */
65
+ export async function checkUpdate(opts = {}) {
66
+ const fetchImpl = opts.fetchImpl ?? globalThis.fetch
67
+ const current = await currentVersion()
68
+ const base = { current, latest: null, updateAvailable: false, rateLimited: false, error: '', releasesUrl: RELEASES_URL }
69
+ if (typeof fetchImpl !== 'function') return { ...base, error: '当前运行时不支持 fetch' }
70
+
71
+ // 手动 AbortController + finally 清理:AbortSignal.timeout 的隐式定时器在 Windows 上
72
+ // 退出时可能触发 libuv 断言(UV_HANDLE_CLOSING)。
73
+ const controller = new AbortController()
74
+ const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 10000)
75
+ try {
76
+ const token = String(process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? '').trim()
77
+ const res = await fetchImpl(RELEASES_LATEST_API, {
78
+ headers: {
79
+ Accept: 'application/vnd.github+json',
80
+ 'User-Agent': 'sage-mem',
81
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
82
+ },
83
+ redirect: 'follow',
84
+ signal: controller.signal,
85
+ })
86
+ if (res.status === 403 || res.status === 429) {
87
+ await res.body?.cancel?.().catch?.(() => {})
88
+ return { ...base, rateLimited: true, error: 'GitHub API 限流' }
89
+ }
90
+ if (!res.ok) {
91
+ await res.body?.cancel?.().catch?.(() => {})
92
+ return { ...base, error: `GitHub API ${res.status}` }
93
+ }
94
+ const data = await res.json().catch(() => undefined)
95
+ const tag = typeof data?.tag_name === 'string' ? data.tag_name.replace(/^v/i, '') : ''
96
+ if (!tag) return { ...base, error: 'release 里没有 tag_name' }
97
+ return { ...base, latest: tag, updateAvailable: current !== '' && compareVersions(tag, current) > 0 }
98
+ } catch (err) {
99
+ return { ...base, error: err?.message ?? String(err) }
100
+ } finally {
101
+ clearTimeout(timer)
102
+ }
103
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sage-mem",
3
3
  "description": "File-based memory plugin for DeepSeek Harness (DSH) — cross-session memory in plain markdown files, with a memory star-map view, type/tag filtering, retrieval aliases, and an optional autodream pass that consolidates memories on demand or on a schedule — with per-run rollback points, a per-file change declaration (what changed and why), and a separately selectable model for the consolidation pass",
4
- "version": "0.9.6",
4
+ "version": "0.9.9",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {