sage-mem 0.9.6 → 0.9.8

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,17 @@ 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/audit.png)
57
57
 
58
- *体检 —— 索引悬空、漏索引、断链这类硬问题按类报红;「指向已归档」只作提示、不计入问题,也不提供一键修补按钮(改哪条由你决定)。*
58
+ *体检(排在「自动做梦」之后的第 4 个页签)—— 索引悬空、漏索引、断链这类硬问题按类报红;「指向已归档」只作提示、不计入问题,也不提供一键修补按钮(改哪条由你决定)。*
59
+
60
+ ![更新提示:只在真有新版本时出现一条横幅](https://raw.githubusercontent.com/gezi-wen/sage-mem/main/docs/images/update-banner.png)
61
+
62
+ *更新提示 —— 只在**真有新版本**时出现一条横幅,写明新版本与当前版本;「看更新内容」开新标签页,「×」只是这次不看。查不到 / 被限流 / 网络不通一律静默,不会因为查版本失败而冒红条。*
59
63
 
60
64
  ![归档候选:每条写清「为什么建议归档」,确认后才执行](https://raw.githubusercontent.com/gezi-wen/sage-mem/main/docs/images/archive-candidates.png)
61
65
 
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,13 @@ 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);}",
1979
2010
  // 体检 / 候选是「翻清单」的面板:定高 + 内部滚动,列表不被顶出首屏。
1980
2011
  // 原样编辑器(.smem-ar-editor)不设上限 —— 那是专心改一份文件的界面,给它完整高度。
1981
2012
  ".smem-tool-panel--cap{max-height:320px;overflow-y:auto;}",
@@ -2074,6 +2105,13 @@ const StarmapRuntime = (function (require, _react) {
2074
2105
  ".smem-ad-warn{color:var(--dsw-alias-state-warning-primary,#d97706);}",
2075
2106
  ".smem-chip--fixed{opacity:.72;cursor:default;}",
2076
2107
  ".smem-chip-x{border:0;background:transparent;color:inherit;cursor:pointer;font-size:11px;font-family:inherit;padding:0 0 0 5px;}",
2108
+ // ── 更新提示(有新版本才出现的一条窄横幅)──
2109
+ ".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);}",
2110
+ ".smem-update-link{color:var(--dsw-alias-brand-primary,#4d76e6);text-decoration:underline;}",
2111
+ ".smem-update-x{margin-left:auto;border:0;background:transparent;color:inherit;cursor:pointer;font-size:14px;line-height:1;padding:0 2px;}",
2112
+ // ── 底部一行仓库链接(引流,不抢视线)──
2113
+ ".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;}",
2114
+ ".smem-star-link{color:var(--dsw-alias-brand-primary,#4d76e6);text-decoration:underline;}",
2077
2115
  ].join("\n");
2078
2116
 
2079
2117
  const TYPE_META = {
@@ -2408,8 +2446,33 @@ const StarmapRuntime = (function (require, _react) {
2408
2446
  ]
2409
2447
  : [
2410
2448
  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); } }, "归档"),
2449
+ /**
2450
+ * 锁定排在「编辑」与「归档」之间(用户 2026-10-11 指定):
2451
+ * 它是「别归档这条」的开关,紧挨着归档才说得通。
2452
+ *
2453
+ * 锁上的记忆**自动与手动都不许归档** —— 候选计算会跳过它,
2454
+ * 手动点归档会被宿主拒绝。图标用 🔒 / 🔓 之外还留着文字,
2455
+ * 因为「锁」在这里是**拒绝归档**,不是「只读」。
2456
+ */
2457
+ h("button", {
2458
+ type: "button",
2459
+ className: "smem-btn" + (item.locked ? " smem-btn-locked" : ""),
2460
+ key: "lock",
2461
+ title: item.locked
2462
+ ? "已锁定:自动归档不会选它,手动归档也会被拒绝。点一下解锁"
2463
+ : "锁定这条记忆:自动与手动归档都不再动它(重要的人设 / 偏好建议锁上)",
2464
+ disabled: props.busy,
2465
+ onClick: function () { props.onToggleLock(item.file, !item.locked); },
2466
+ }, item.locked ? "🔒 已锁定" : "🔓 锁定"),
2467
+ // 归档排在「锁定」和「删除」之间:比编辑轻、比删除温和,且随时可逆。
2468
+ h("button", {
2469
+ type: "button", className: "smem-btn", key: "arch",
2470
+ title: item.locked
2471
+ ? "这条已锁定,归档会被拒绝(先解锁)"
2472
+ : "收进档案馆:不再参与检索与星图,随时可以恢复",
2473
+ disabled: props.busy || item.locked === true,
2474
+ onClick: function () { props.onAskArchive(item.file); },
2475
+ }, "归档"),
2413
2476
  h("button", { type: "button", className: "smem-btn smem-btn-danger-ghost", key: "del", onClick: function () { props.onAskDelete(item.file); } }, "删除"),
2414
2477
  ])),
2415
2478
  ]);
@@ -2493,8 +2556,8 @@ const StarmapRuntime = (function (require, _react) {
2493
2556
  };
2494
2557
  const autoArchiveOf = (v) => (v === "off" || v === "auto" ? v : "report");
2495
2558
 
2496
- /** 头部带上的「我在哪」:三个 tab 的中文名(与 .smem-tabs 上的字面一致)。 */
2497
- const TAB_LABEL = { files: "文件列表", star: "记忆星图", autodream: "自动做梦" };
2559
+ /** 头部带上的「我在哪」:四个 tab 的中文名(与 .smem-tabs 上的字面一致)。 */
2560
+ const TAB_LABEL = { files: "文件列表", star: "记忆星图", autodream: "自动做梦", audit: "体检" };
2498
2561
 
2499
2562
  /**
2500
2563
  * 工具面板头部的「收起」按钮:抽屉里开的工具要能一键收回去 ——
@@ -2513,8 +2576,33 @@ const StarmapRuntime = (function (require, _react) {
2513
2576
  * 硬问题与提示**分开渲染**:`archivedLinks` 是提示(归档不是删除,目标还在档案馆里),
2514
2577
  * 跟问题一样染红会让人去「修」一条本来正确的链接 —— 那正是审计里那条分界线。
2515
2578
  */
2579
+ /**
2580
+ * 面板底部的一行仓库链接。
2581
+ *
2582
+ * 参照手机访问插件那一行的做法:放在不抢视线的地方、语气轻、点开是新标签页。
2583
+ * 它**是引流不是功能** —— 所以不占一屏、不做成按钮、失败也没有任何副作用;
2584
+ * 四个 tab 下都在(谁也没必要为看它切页)。
2585
+ */
2586
+ function StarLine() {
2587
+ return h("div", { className: "smem-star" }, [
2588
+ h("span", { key: "ask" }, "⭐ 顺手留颗 Star,作者能高兴一整天"),
2589
+ h("a", {
2590
+ key: "cta",
2591
+ href: "https://github.com/gezi-wen/sage-mem",
2592
+ target: "_blank",
2593
+ rel: "noreferrer",
2594
+ className: "smem-star-link",
2595
+ }, "行,给你一颗 Star"),
2596
+ ]);
2597
+ }
2598
+
2516
2599
  function AuditPanel(props) {
2517
2600
  const r = props.report || {};
2601
+ /**
2602
+ * 独立成 tab 时不套抽屉那层窄框(`--cap` 有 320px 限高)、也不给「收起」按钮 ——
2603
+ * 它不是「暂时摊开的一层」,它就是这一屏。
2604
+ */
2605
+ const standalone = props.standalone === true;
2518
2606
  const rows = (list, render) => (list || []).map(function (x, i) {
2519
2607
  return h("div", { className: "smem-audit-row", key: String(x.file || "?") + "-" + i }, render(x));
2520
2608
  });
@@ -2525,7 +2613,7 @@ const StarmapRuntime = (function (require, _react) {
2525
2613
  ])
2526
2614
  : null);
2527
2615
  const hints = r.archivedLinks || [];
2528
- return h("div", { className: "smem-tool-panel smem-tool-panel--cap", key: "audit" }, [
2616
+ return h("div", { className: "smem-tool-panel" + (standalone ? " smem-tool-panel--full" : " smem-tool-panel--cap"), key: "audit" }, [
2529
2617
  h("div", { className: "smem-tool-head", key: "head" }, [
2530
2618
  h("span", { className: "smem-tool-title", key: "t" }, "体检"),
2531
2619
  h("span", { className: "smem-muted", key: "s" },
@@ -2535,7 +2623,7 @@ const StarmapRuntime = (function (require, _react) {
2535
2623
  h("span", { className: "smem-tool-badge" + (r.problems ? " smem-tool-badge--bad" : ""), key: "p" },
2536
2624
  r.problems ? ("问题 " + r.problems) : "没有问题"),
2537
2625
  h("button", { type: "button", className: "smem-btn", key: "again", disabled: props.busy, onClick: props.onRun }, props.busy ? "体检中…" : "重新体检"),
2538
- toolClose(props),
2626
+ standalone ? null : toolClose(props),
2539
2627
  ]),
2540
2628
  r.problems
2541
2629
  ? h("div", { className: "smem-audit-secs", key: "secs" }, [
@@ -3504,6 +3592,9 @@ const StarmapRuntime = (function (require, _react) {
3504
3592
  const audit = sAudit[0], setAudit = sAudit[1];
3505
3593
  const sAuditBusy = react.useState(false);
3506
3594
  const auditBusy = sAuditBusy[0], setAuditBusy = sAuditBusy[1];
3595
+ // 更新提示:只在**确实有新版本**时才被赋值(查不到/限流/网络错都不赋值)
3596
+ const sUpdate = react.useState(null);
3597
+ const updateInfo = sUpdate[0], setUpdateInfo = sUpdate[1];
3507
3598
  const sCand = react.useState(null);
3508
3599
  const cand = sCand[0], setCand = sCand[1];
3509
3600
  const sCandBusy = react.useState(false);
@@ -3558,6 +3649,19 @@ const StarmapRuntime = (function (require, _react) {
3558
3649
  // 头部带的两个徽标要有数据才立得住:体检(只读扫描)与待重启项。
3559
3650
  runAudit();
3560
3651
  loadSettings();
3652
+ /**
3653
+ * 更新检查:**静默**跑,失败一律不打扰 ——
3654
+ * 网络不通、被 GitHub 限流、仓库还没建 release,都不该在面板上冒一条红。
3655
+ * 只有 `updateAvailable` 为真才把结果放进 state,界面才有一条横幅。
3656
+ */
3657
+ if (remote && typeof remote.checkUpdate === "function") {
3658
+ remote.checkUpdate()
3659
+ .then(function (r) {
3660
+ const d = unwrap(r);
3661
+ if (d && d.updateAvailable) setUpdateInfo(d);
3662
+ })
3663
+ .catch(function () { /* 查版本失败不是用户的事 */ });
3664
+ }
3561
3665
  }, []);
3562
3666
 
3563
3667
  /**
@@ -3699,9 +3803,33 @@ const StarmapRuntime = (function (require, _react) {
3699
3803
  .then(function () { setBusy(false); });
3700
3804
  }
3701
3805
 
3806
+ /**
3807
+ * 上锁 / 解锁一条记忆。
3808
+ *
3809
+ * 锁定 = **拒绝归档**(自动归档的候选里不出现、手动点归档也被宿主拒绝),
3810
+ * **不是「只读」** —— 文案必须按这个说,否则用户会以为锁上就不能编辑了。
3811
+ */
3812
+ function toggleLock(file, locked) {
3813
+ if (typeof remote.setLocked !== "function") {
3814
+ setError("宿主侧还没有 setLocked 接口(插件版本较旧)");
3815
+ return;
3816
+ }
3817
+ setBusy(true); setError(null); setNotice("");
3818
+ remote.setLocked(file, locked)
3819
+ .then(function (r) {
3820
+ const d = unwrap(r);
3821
+ if (d && d.ok === false) { setError(d.error || "锁定操作被拒绝"); return; }
3822
+ setNotice(locked
3823
+ ? ("已锁定 " + file + " —— 自动归档不会再选它,手动归档也会被拒绝")
3824
+ : ("已解锁 " + file));
3825
+ load();
3826
+ })
3827
+ .catch(function (e) { setError(String((e && e.message) || e)); })
3828
+ .then(function () { setBusy(false); });
3829
+ }
3830
+
3702
3831
  /** 从档案馆捞回来:文件移回记忆目录,归档留痕被抹掉。 */
3703
- function restoreFile(file) {
3704
- if (typeof remote.restore !== "function") {
3832
+ function restoreFile(file) { if (typeof remote.restore !== "function") {
3705
3833
  setError("宿主侧还没有 restore 接口(插件版本较旧)");
3706
3834
  return;
3707
3835
  }
@@ -3710,7 +3838,8 @@ const StarmapRuntime = (function (require, _react) {
3710
3838
  remote.restore(file)
3711
3839
  .then(function (r) {
3712
3840
  if (r && r.ok === false) { setError(r.error || "恢复被拒绝"); return; }
3713
- setNotice("已恢复 " + file + " —— 它回到了活动记忆里");
3841
+ // 恢复会顺带把这条补进索引:说清楚,否则用户不知道「漏索引」是怎么好的
3842
+ setNotice("已恢复 " + file + " —— 它回到了活动记忆里" + (r && r.indexed ? ",并补进了索引" : ""));
3714
3843
  load();
3715
3844
  })
3716
3845
  .catch(function (e) { setError(String((e && e.message) || e)); })
@@ -3810,16 +3939,27 @@ const StarmapRuntime = (function (require, _react) {
3810
3939
  const next = tool === name ? null : name;
3811
3940
  setTool(next);
3812
3941
  setError(null);
3813
- if (next === "audit" && !audit) runAudit();
3814
3942
  if (next === "candidates" && !cand) loadCandidates();
3815
3943
  if (next === "raw" && !settings) loadSettings();
3816
3944
  }
3817
3945
 
3818
- /** 打开某个工具(头部带的徽标点进来时用:不切换、直接开)。 */
3946
+ /**
3947
+ * 切到「体检」这一屏。
3948
+ *
3949
+ * 体检从工具抽屉里独立出来了(它本来就是「看」不是「做」),排在「自动做梦」之后。
3950
+ * 数据没拉过才拉:初始加载已经跑过一次体检(头部带那个徽标要有数据才立得住),
3951
+ * 切过来通常直接就看得见内容。
3952
+ */
3953
+ function openAuditTab() {
3954
+ setTab("audit");
3955
+ setError(null);
3956
+ if (!audit) runAudit();
3957
+ }
3958
+
3959
+ /** 打开某个工具(抽屉里点进来时用)。 */
3819
3960
  function openTool(name) {
3820
3961
  setTool(name);
3821
3962
  setError(null);
3822
- if (name === "audit" && !audit) runAudit();
3823
3963
  if (name === "candidates" && !cand) loadCandidates();
3824
3964
  if (name === "raw" && !settings) loadSettings();
3825
3965
  }
@@ -4013,6 +4153,7 @@ const StarmapRuntime = (function (require, _react) {
4013
4153
  onAskArchive: function (file) { setArchiving(file); setArchiveReason(""); setDeleting(null); },
4014
4154
  onCancelArchive: function () { setArchiving(null); setArchiveReason(""); },
4015
4155
  onConfirmArchive: confirmArchive,
4156
+ onToggleLock: toggleLock,
4016
4157
  onToggle: function (file) {
4017
4158
  const nx = {};
4018
4159
  nx[file] = !expanded[file];
@@ -4039,8 +4180,11 @@ const StarmapRuntime = (function (require, _react) {
4039
4180
  h("button", { type: "button", className: "smem-tab" + (tab === "files" ? " smem-tab--on" : ""), key: "f", onClick: function () { setTab("files"); } }, "文件列表"),
4040
4181
  h("button", { type: "button", className: "smem-tab" + (tab === "star" ? " smem-tab--on" : ""), key: "s", onClick: function () { setTab("star"); } }, "记忆星图"),
4041
4182
  h("button", { type: "button", className: "smem-tab" + (tab === "autodream" ? " smem-tab--on" : ""), key: "d", onClick: function () { setTab("autodream"); } }, "自动做梦"),
4183
+ // 体检排在「自动做梦」之后、独立成一屏:它只报告不修改,和「文件列表」里的
4184
+ // 增删改是两件事,塞在工具抽屉里会让人以为它是个动作按钮。
4185
+ h("button", { type: "button", className: "smem-tab" + (tab === "audit" ? " smem-tab--on" : ""), key: "a", onClick: openAuditTab }, "体检"),
4042
4186
  ]),
4043
- // 三个 tab 共用的一条头部带:我在哪 · 目录状态 · 活动/归档计数 · 体检徽标 · 待重启徽标 · 主操作。
4187
+ // 四个 tab 共用的一条头部带:我在哪 · 目录状态 · 活动/归档计数 · 体检徽标 · 待重启徽标 · 主操作。
4044
4188
  // 层级形状固定:导航=下划线(.smem-tabs)、筛选=chip、动作=按钮,三种不混用。
4045
4189
  h("div", { className: "smem-head", key: "head" }, [
4046
4190
  h("span", { className: "smem-title", key: "t" }, "记忆管理"),
@@ -4048,13 +4192,12 @@ const StarmapRuntime = (function (require, _react) {
4048
4192
  h("span", { className: "smem-dot" + (dirOk ? "" : " smem-dot--bad"), key: "dot", title: dirOk ? "memory 目录可读" : "memory 目录不可读" }),
4049
4193
  h("span", { className: "smem-muted", key: "st" }, dirOk ? "目录可读" : "目录不可读"),
4050
4194
  h("span", { className: "smem-badge", key: "cnt" }, headerCount),
4051
- // 体检徽标:**抽屉关着也一直看得见**。整个「工具收进抽屉」的方案就靠它成立 ——
4052
- // 没有这个徽标,三个能力就退回「藏起来」了(上一轮刚把它们做成用户自己的入口)。
4195
+ // 体检徽标:**抽屉关着也一直看得见**,点它就切到「体检」那一屏。
4053
4196
  h("button", {
4054
4197
  type: "button", key: "aud",
4055
4198
  className: "smem-tool-badge" + (audit && audit.problems ? " smem-tool-badge--bad" : ""),
4056
- title: audit ? (audit.problems ? "打开体检看是哪几条" : "体检没有发现问题") : "跑一次体检(只读)",
4057
- onClick: function () { openTool("audit"); },
4199
+ title: audit ? (audit.problems ? "切到体检看是哪几条" : "体检没有发现问题") : "跑一次体检(只读)",
4200
+ onClick: openAuditTab,
4058
4201
  }, audit ? (audit.problems ? ("体检 " + audit.problems + " 个问题") : "体检 通过") : "体检 · 未跑"),
4059
4202
  restartPending
4060
4203
  ? h("button", {
@@ -4069,6 +4212,22 @@ const StarmapRuntime = (function (require, _react) {
4069
4212
  h("button", { type: "button", className: "smem-btn smem-btn-primary", key: "add", onClick: openAdd }, "+ 添加记忆"),
4070
4213
  ] : null),
4071
4214
  ]),
4215
+ // 更新提示:四个 tab 共用一条,只在真有新版时出现;「×」只是这次不看
4216
+ updateInfo
4217
+ ? h("div", { className: "smem-update", key: "update" }, [
4218
+ h("span", { key: "t" }, "发现新版本 v" + String(updateInfo.latest) + "(当前 v" + String(updateInfo.current) + ")"),
4219
+ h("a", {
4220
+ key: "l", className: "smem-update-link",
4221
+ href: updateInfo.releasesUrl || "https://github.com/gezi-wen/sage-mem/releases",
4222
+ target: "_blank", rel: "noreferrer",
4223
+ }, "看更新内容"),
4224
+ h("button", {
4225
+ type: "button", key: "x", className: "smem-update-x",
4226
+ title: "这次不看了(下次进面板还会提示)",
4227
+ onClick: function () { setUpdateInfo(null); },
4228
+ }, "×"),
4229
+ ])
4230
+ : null,
4072
4231
  // 工具栏只剩「搜索 + 工具 ▾」:刷新与添加已经收进头部带的主操作位。
4073
4232
  tab === "files" ? h("div", { className: "smem-toolbar", key: "bar" }, [
4074
4233
  h("input", { className: "smem-search", key: "q", value: query, placeholder: "搜索文件名 / 描述 / 类型 / 标签…", onChange: function (e) { setQuery(e.target.value); } }),
@@ -4078,15 +4237,11 @@ const StarmapRuntime = (function (require, _react) {
4078
4237
  type: "button", key: "btn",
4079
4238
  className: "smem-btn" + (drawer ? " smem-btn-primary" : ""),
4080
4239
  "aria-expanded": drawer ? "true" : "false",
4081
- title: "体检 / 归档候选 / 保留名编辑",
4240
+ title: "归档候选 / 保留名编辑",
4082
4241
  onClick: function () { setDrawer(!drawer); },
4083
4242
  }, "工具 ▾"),
4084
4243
  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 + " 个问题") : "通过") : "未跑")]),
4244
+ // 体检独立成 tab 之后,抽屉里只剩这两件「要动手」的事。
4090
4245
  h("button", {
4091
4246
  type: "button", className: "smem-drawer-item", key: "c",
4092
4247
  "data-on": tool === "candidates" ? "1" : "0",
@@ -4100,8 +4255,8 @@ const StarmapRuntime = (function (require, _react) {
4100
4255
  ]) : null,
4101
4256
  ]),
4102
4257
  ]) : null,
4103
- tab === "files" && tool === "audit"
4104
- ? h(AuditPanel, { report: audit, busy: auditBusy, onRun: runAudit, onClose: function () { setTool(null); } })
4258
+ tab === "audit"
4259
+ ? h(AuditPanel, { report: audit, busy: auditBusy, onRun: runAudit, standalone: true })
4105
4260
  : null,
4106
4261
  tab === "files" && tool === "candidates"
4107
4262
  ? h(CandidatePanel, {
@@ -4232,7 +4387,13 @@ const StarmapRuntime = (function (require, _react) {
4232
4387
  : tab === "star"
4233
4388
  ? h("div", { className: "smem-starmap", key: "starmap" },
4234
4389
  h(StarmapRuntime.StarMap, { ctx: props.ctx }))
4390
+ : tab === "audit"
4391
+ // 体检面板在上面已经渲染过了 —— 这里必须显式给 null,
4392
+ // 否则会落进下面那个 `filesView` 兜底分支,变成「体检 + 一整套文件列表」同屏。
4393
+ ? null
4235
4394
  : filesView,
4395
+ // 底部一行仓库链接(四个 tab 都在,不抢视线)
4396
+ h(StarLine, { key: "star-line" }),
4236
4397
  ]);
4237
4398
  }
4238
4399
 
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.8",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {