sage-mem 2.6.0 → 2.7.0

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.en.md CHANGED
@@ -12,7 +12,7 @@
12
12
 
13
13
  **Cross-session memory for DeepSeek Harness — plain markdown files, no database, no worker.**
14
14
 
15
- **Current release: `2.6.0`** — the npm badge above is the dynamic source of truth. **Contract baseline: `1.0.0`** — the first stable release, whose public commitments, the capability boundaries and the list of things it explicitly cannot do are frozen in the
15
+ **Current release: `2.7.0`** — the npm badge above is the dynamic source of truth. **Contract baseline: `1.0.0`** — the first stable release, whose public commitments, the capability boundaries and the list of things it explicitly cannot do are frozen in the
16
16
  [frozen contract](https://github.com/gezi-wen/sage-mem/blob/main/docs/1.0.0-contract.md).
17
17
 
18
18
  If it is useful to you, a Star is the most direct feedback.
@@ -81,10 +81,10 @@ Most memory schemes take the "SQLite + a long-running worker" route: once memori
81
81
 
82
82
  The UI also carries four more capabilities (all of them optional): the memory management page, type and tag filtering, the timeline (an entries list by default, with an optional star-map view), and audit & archiving.
83
83
 
84
- **Since 1.8.0, memory management is also reachable from the sidebar**: a 「记忆管理」(memory management) icon appears in the sidebar and switches the central panel to this page (with a "back to conversation" control at the top).
85
- It can be turned off in the settings page under 「记忆管理 → 自动做梦 → 设置」(on by default; applies immediately, no restart);
86
- **since 1.8.2 the same toggle also lives on the plugin's detail page** (「插件 → sage-mem」card) and applies immediately too;
87
- **turning it off only removes the sidebar icon — the 「设置 → 记忆管理」 entry always stays**.
84
+ **Since 2.7.0 the interface entry is a three-way choice**: show both (default), sidebar only, or settings only — **at least one entry always stays on**.
85
+ The sidebar entry (1.8.0) switches the central panel to this page with one click (with a "back to conversation" control at the top); the settings entry is 「设置 → 记忆管理」.
86
+ The switch lives in the 「界面入口」 disclosure on the plugin's detail page (「插件 → sage-mem」), and the same options are in the panel under 「自动整理 → 设置」; changes apply immediately, no restart.
87
+ When the host does not support the sidebar slot, "sidebar only" is disabled and the plugin falls back to the settings entry; old settings files migrate automatically (sidebar on → both, off → settings only).
88
88
  Since 1.8.2 the plugin also ships its own icon (a golden stack of memory cards) instead of the generic square.
89
89
 
90
90
  The memory management page's file list also has an **Open memory folder** button: it opens the
package/README.md CHANGED
@@ -14,7 +14,7 @@
14
14
 
15
15
  > File-based cross-session memory for DeepSeek Harness — every memory is a plain markdown file you can open, edit and delete. No database, no worker, no port.
16
16
 
17
- **当前发布版本:`2.6.0`** —— 顶部 npm 徽章是动态权威。**契约基线:`1.0.0`** —— 第一个稳定版,公开承诺、能力边界与「明确做不到」的清单冻结在
17
+ **当前发布版本:`2.7.0`** —— 顶部 npm 徽章是动态权威。**契约基线:`1.0.0`** —— 第一个稳定版,公开承诺、能力边界与「明确做不到」的清单冻结在
18
18
  [契约冻结](https://github.com/gezi-wen/sage-mem/blob/main/docs/1.0.0-contract.md)。
19
19
 
20
20
  如果它对你有用,给个 Star 是最直接的反馈。
@@ -83,10 +83,10 @@ sage-mem 补上这一层,做法是:**把该记住的事写成一张张卡片
83
83
 
84
84
  界面上还有四块能力(都可以不用):记忆管理页、类型与标签筛选、时间线(默认是条目列表,可切星空视角)、体检与归档。
85
85
 
86
- **1.8.0 起,记忆管理还能从侧栏直接进**:侧栏多一个「记忆管理」图标,点它把中央主区切到本页(页顶有「返回会话」)。
87
- 设置页「记忆管理 → 自动做梦 → 设置」里可以关掉这个入口(默认开,立即生效、不用重启);
88
- **1.8.2 起,插件详情页也有同一个开关**(「插件 → sage-mem」那张卡),改完立即生效;
89
- **关掉只影响侧栏图标,「设置 → 记忆管理」这个入口永远保留**。
86
+ **从 2.7.0 起,记忆管理的界面入口是三选一**:两个都显示(默认)/ 只显示侧栏 / 只显示设置页 —— **至少保留一个**。
87
+ 侧栏入口(1.8.0)点一下就把中央主区切到本页(页顶有「返回会话」);设置页入口是「设置 → 记忆管理」。
88
+ 开关在「插件 → sage-mem」的「界面入口」折叠行里,面板「自动整理 → 设置」里也有同一组选项;改完立即生效、不用重启。
89
+ 宿主不支持侧栏时「只显示侧栏」自动置灰并回落到设置页;旧设置文件里的「侧栏开 / 关」会按 true → 两个都显示、false → 只显示设置页迁移。
90
90
  从 1.8.2 开始,sage-mem 在插件列表里有了自己的图标(金色记忆卡片),不再是默认的通用方图标。
91
91
 
92
92
  记忆管理页的「文件列表」头部还有一个「打开记忆文件夹」按钮:用系统文件管理器打开当前记忆目录
package/lib/client.js CHANGED
@@ -2686,14 +2686,20 @@ const StarmapRuntime = (function (require, _react) {
2686
2686
  ".smem-page-top{display:flex;align-items:center;}",
2687
2687
  ".smem-page-back{display:inline-flex;align-items:center;gap:5px;border:0;background:transparent;color:var(--dsw-alias-label-secondary);font-family:inherit;font-size:12.5px;line-height:1.4;padding:3px 6px 3px 2px;border-radius:6px;cursor:pointer;}",
2688
2688
  ".smem-page-back:hover{color:var(--dsw-alias-label-primary,#1f2329);background:var(--dsw-alias-bg-layer-2);}",
2689
- // ── 1.8.2:插件详情页的「侧栏入口」卡片 ──
2689
+ // ── 1.8.2 / 2.7.0:插件详情页的「界面入口」折叠行 ──
2690
2690
  // 融入宿主插件页:标题 13px/600、说明 12px 次要色,圆角与分隔线只用宿主 token。
2691
- // 不抄设置页的大标题 —— 这一块是插件页里的一张紧凑卡片,不是另一个 settings section。
2691
+ // 2.7.0 起默认收起:收起时只露标题 + 当前状态,展开才是三选一。
2692
2692
  ".smem-plugin-card{display:flex;flex-direction:column;gap:8px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;padding:12px 14px;background:var(--dsw-alias-bg-layer-1,#fafbfc);box-sizing:border-box;}",
2693
- ".smem-plugin-card-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap;}",
2693
+ ".smem-entry-summary{display:flex;align-items:center;gap:8px;width:100%;border:0;background:transparent;padding:0;margin:0;font-family:inherit;text-align:left;cursor:pointer;color:inherit;}",
2694
+ ".smem-entry-summary:hover .smem-plugin-card-title{color:var(--dsw-alias-brand-primary,#4d76e6);}",
2695
+ ".smem-entry-summary:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#4d76e6);outline-offset:3px;border-radius:6px;}",
2696
+ ".smem-entry-chevron{flex:none;margin-left:auto;color:var(--dsw-alias-label-tertiary,#8b93a1);transition:transform 180ms cubic-bezier(.2,0,0,1);}",
2697
+ ".smem-entry-body{display:flex;flex-direction:column;gap:8px;padding-top:2px;}",
2698
+ ".smem-entry-opts{display:flex;flex-direction:column;gap:6px;}",
2699
+ ".smem-entry-opt{font-size:12.5px;line-height:1.5;padding:1px 0;}",
2700
+ ".smem-entry-opt input:disabled{cursor:not-allowed;opacity:.55;}",
2694
2701
  ".smem-plugin-card-title{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary,#1f2430);line-height:1.4;}",
2695
2702
  ".smem-plugin-card-status{font-size:12px;color:var(--dsw-alias-label-secondary,#6b7280);line-height:1.4;}",
2696
- ".smem-plugin-card-check{font-size:12.5px;}",
2697
2703
  ".smem-plugin-card-note{font-size:12px;line-height:1.55;color:var(--dsw-alias-label-secondary,#6b7280);}",
2698
2704
  ".smem-plugin-card-err{font-size:12px;line-height:1.5;color:var(--dsw-alias-state-error-primary,#ef4444);overflow-wrap:anywhere;}",
2699
2705
  // ── 底部一行仓库链接(引流,不抢视线)──
@@ -4188,13 +4194,18 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
4188
4194
  "star.dismiss": "不再显示(下次打开 DSH 还会出现)",
4189
4195
  "star.aria": "在 GitHub 上给 gezi-wen/sage-mem 点一颗星",
4190
4196
 
4191
- "pluginCard.saveFailed": "保存失败(未知原因)",
4192
- "pluginCard.noSetSettings": "宿主侧还没有 setSettings 接口(插件版本较旧)",
4193
- "pluginCard.title": "侧栏入口",
4194
- "pluginCard.on": "已开启",
4195
- "pluginCard.off": "已关闭",
4196
- "pluginCard.label": "在侧栏显示「记忆管理」入口",
4197
- "pluginCard.note": "默认开启;改动立即生效,不需要重启。关闭后「设置 → 记忆管理」入口仍然保留。",
4197
+ "entry.title": "界面入口",
4198
+ "entry.subtitle": "控制「记忆管理」在界面中的入口。至少保留一个。",
4199
+ "entry.opt.both": "两个都显示",
4200
+ "entry.opt.sidebar": "只显示侧栏",
4201
+ "entry.opt.settings": "只显示设置页",
4202
+ "entry.note": "改动立即生效,不需要重启。",
4203
+ "entry.sidebarUnsupported": "当前宿主不支持侧栏入口。",
4204
+ "entry.degraded": "当前宿主不支持侧栏入口,已临时按「只显示设置页」显示。",
4205
+ "entry.legacyHost": "宿主模块还是旧版本,重启 DSH 后可选「只显示侧栏」。",
4206
+ "entry.notice.saved": "界面入口已更新。",
4207
+ "entry.err.noSetSettings": "宿主侧还没有 setSettings 接口(插件版本较旧)",
4208
+ "entry.err.saveFailed": "保存失败(未知原因)",
4198
4209
 
4199
4210
  "error.remote": "remote 调用失败",
4200
4211
  "error.listFiles": "listFiles RPC 失败: {message}",
@@ -4389,11 +4400,6 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
4389
4400
  "ad.last.retried": " · 零正文重试 {count} 次后补齐",
4390
4401
  "ad.last.continuations": " · 续写 {count} 轮",
4391
4402
  "ad.last.report": " · 报告 {name}",
4392
- "ad.sidebar.title": "侧栏入口",
4393
- "ad.sidebar.on": "已开启",
4394
- "ad.sidebar.off": "已关闭",
4395
- "ad.sidebar.note": "在 DSH 侧栏显示「记忆管理」图标,点它把中央主区切到本页。默认开启;关闭后「设置 → 记忆管理」这个入口仍然保留。改动立即生效,不需要重启。",
4396
- "ad.sidebar.label": "在侧栏显示「记忆管理」入口",
4397
4403
  "ad.limits.title": "记忆注入参数",
4398
4404
  "ad.limits.pending": "有改动待生效",
4399
4405
  "ad.limits.restartWarn": "⚠ 这些值在 DSH 启动时就固化了 —— 要重启 DSH 才生效。下面每行都写清「当前 → 重启后」。",
@@ -4493,8 +4499,6 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
4493
4499
  "ad.err.notSaved": "没保存 —— {detail}",
4494
4500
  "ad.err.saveRejected": "保存被拒绝",
4495
4501
  "ad.notice.limitsSaved": "已保存 —— 需重启 DSH 才生效",
4496
- "ad.notice.sidebarOn": "侧栏入口已开启(立即生效)",
4497
- "ad.notice.sidebarOff": "侧栏入口已关闭(设置页入口仍在)",
4498
4502
  "ad.err.reservedRejected": "名单被拒绝",
4499
4503
  "ad.notice.reservedSaved": "保留名名单已保存 —— 需重启 DSH 才生效",
4500
4504
  "ad.err.reservedNeedName": "先写一个文件名,例如 project_notes.md",
@@ -4697,13 +4701,18 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
4697
4701
  "star.dismiss": "Hide until next launch",
4698
4702
  "star.aria": "Star gezi-wen/sage-mem on GitHub",
4699
4703
 
4700
- "pluginCard.saveFailed": "Save failed (unknown reason)",
4701
- "pluginCard.noSetSettings": "The host has no setSettings API yet (older plugin version)",
4702
- "pluginCard.title": "Sidebar entry",
4703
- "pluginCard.on": "On",
4704
- "pluginCard.off": "Off",
4705
- "pluginCard.label": "Show the “Memory management” entry in the sidebar",
4706
- "pluginCard.note": "On by default; changes take effect immediately, no restart needed. Turning it off keeps the Settings → Memory management entry.",
4704
+ "entry.title": "Interface entries",
4705
+ "entry.subtitle": "Choose where Memory management appears. At least one entry stays on.",
4706
+ "entry.opt.both": "Show both",
4707
+ "entry.opt.sidebar": "Sidebar only",
4708
+ "entry.opt.settings": "Settings only",
4709
+ "entry.note": "Changes take effect immediately; no restart needed.",
4710
+ "entry.sidebarUnsupported": "This host does not support the sidebar entry.",
4711
+ "entry.degraded": "The sidebar entry is unavailable on this host, so the Settings entry is shown instead.",
4712
+ "entry.legacyHost": "The host module is still the old version; restart DSH to choose Sidebar only.",
4713
+ "entry.notice.saved": "Interface entries updated.",
4714
+ "entry.err.noSetSettings": "The host has no setSettings API yet (older plugin version)",
4715
+ "entry.err.saveFailed": "Save failed (unknown reason)",
4707
4716
 
4708
4717
  "error.remote": "remote call failed",
4709
4718
  "error.listFiles": "listFiles RPC failed: {message}",
@@ -4896,11 +4905,6 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
4896
4905
  "ad.last.retried": " · completed after {count} zero-body retries",
4897
4906
  "ad.last.continuations": " · {count} continuation rounds",
4898
4907
  "ad.last.report": " · report {name}",
4899
- "ad.sidebar.title": "Sidebar entry",
4900
- "ad.sidebar.on": "On",
4901
- "ad.sidebar.off": "Off",
4902
- "ad.sidebar.note": "Show the “Memory management” icon in the DSH sidebar; clicking it switches the main area to this page. On by default; turning it off keeps the Settings → Memory management entry. Changes take effect immediately, no restart needed.",
4903
- "ad.sidebar.label": "Show the “Memory management” entry in the sidebar",
4904
4908
  "ad.limits.title": "Injection limits",
4905
4909
  "ad.limits.pending": "changes pending",
4906
4910
  "ad.limits.restartWarn": "⚠ These values are fixed at DSH startup — restart DSH for them to apply. Each row below shows “current → after restart”.",
@@ -5000,8 +5004,6 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
5000
5004
  "ad.err.notSaved": "Not saved — {detail}",
5001
5005
  "ad.err.saveRejected": "Save rejected",
5002
5006
  "ad.notice.limitsSaved": "Saved — restart DSH for it to take effect",
5003
- "ad.notice.sidebarOn": "Sidebar entry enabled (takes effect immediately)",
5004
- "ad.notice.sidebarOff": "Sidebar entry disabled (the Settings entry remains)",
5005
5007
  "ad.err.reservedRejected": "Reserved-name list rejected",
5006
5008
  "ad.notice.reservedSaved": "Reserved-name list saved — restart DSH for it to take effect",
5007
5009
  "ad.err.reservedNeedName": "Enter a file name first, e.g. project_notes.md",
@@ -5494,9 +5496,9 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
5494
5496
  const sLimitsDraft = react.useState(null); const limitsDraft = sLimitsDraft[0], setLimitsDraft = sLimitsDraft[1];
5495
5497
  const sSaveBusy = react.useState(false); const saveBusy = sSaveBusy[0], setSaveBusy = sSaveBusy[1];
5496
5498
  const sReservedNew = react.useState(""); const reservedNew = sReservedNew[0], setReservedNew = sReservedNew[1];
5497
- // ── 1.8.0 侧栏入口开关(客户端行为:改完立刻生效,不需要重启)──
5498
- const sSidebarBusy = react.useState(false); const sidebarBusy = sSidebarBusy[0], setSidebarBusy = sSidebarBusy[1];
5499
- const sSidebarErr = react.useState(""); const sidebarErr = sSidebarErr[0], setSidebarErr = sSidebarErr[1];
5499
+ // ── 2.7.0 界面入口三选一(客户端行为:改完立刻生效,不需要重启)──
5500
+ const sEntryBusy = react.useState(false); const entryBusy = sEntryBusy[0], setEntryBusy = sEntryBusy[1];
5501
+ const sEntryErr = react.useState(""); const entryErr = sEntryErr[0], setEntryErr = sEntryErr[1];
5500
5502
 
5501
5503
  // 派生量放在 effect 之前:下面几个 effect 的依赖数组要在渲染期就能取到值。
5502
5504
  const c = cfg || {};
@@ -5511,8 +5513,11 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
5511
5513
  const fixedReserved = ((memSet && memSet.reserved) || []).filter(function (n) {
5512
5514
  return extraLower.indexOf(String(n).toLowerCase()) < 0;
5513
5515
  });
5514
- // 默认开:宿主没回 ui 字段(旧版本)或值不是布尔,都按「开」显示。
5515
- const sidebarEntryOn = !(memSet && memSet.ui && memSet.ui.sidebarEntry === false);
5516
+ // 入口模式:新字段优先,旧 sidebarEntry 兜底;坏值 / 旧宿主一律按「两个都显示」。
5517
+ // 存了 sidebar 但当前宿主不支持侧栏时,界面按 settings 显示并给一条降级提示。
5518
+ const entryModeValue = entryModeFromSettings(memSet);
5519
+ const entryShown = entryModeValue === "sidebar" && !sidebarEntryAvailable() ? "settings" : entryModeValue;
5520
+ const entryIsDegraded = entryModeValue === "sidebar" && !sidebarEntryAvailable();
5516
5521
 
5517
5522
  react.useEffect(function () {
5518
5523
  if (!remote) { setErr(t("ad.err.remoteUnavailable")); return undefined; }
@@ -5644,28 +5649,29 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
5644
5649
  }
5645
5650
 
5646
5651
  /**
5647
- * 侧栏入口开关(1.8.0)。
5652
+ * 界面入口三选一(2.7.0)。
5648
5653
  *
5649
- * 顺序是「先写设置、成功后再动槽位」:设置没落盘就不该让界面先变一个样。
5650
- * 失败只报错、不改任何东西 —— checkbox 的勾选状态由 memSet 派生,于是自动回到原值。
5651
- * 成功后调 `setSidebarEntry`:**立即**增删侧栏槽位,不刷新、不重启。
5654
+ * 顺序仍是「先写设置、成功后再动槽位」:设置没落盘就不该让界面先变一个样。
5655
+ * 失败只报错、不改任何东西 —— 选项由 memSet 派生,于是自动回到原值。
5656
+ * 成功后 saveEntryMode 内部会调 applyEntryMode:**立即**增删槽位,不刷新、不重启。
5652
5657
  */
5653
- function saveSidebarEntry(next) {
5658
+ function saveEntry(next) {
5654
5659
  if (!memRemote || typeof memRemote.setSettings !== "function") {
5655
- setSidebarErr(t("ad.err.noSetSettings"));
5660
+ setEntryErr(t("entry.err.noSetSettings"));
5656
5661
  return;
5657
5662
  }
5658
- if (sidebarBusy) return;
5659
- setSidebarBusy(true); setSidebarErr(""); setNotice("");
5660
- Promise.resolve(memRemote.setSettings({ ui: { sidebarEntry: !!next } })).then(function (out) {
5661
- setSidebarBusy(false);
5662
- // 与 saveLimits 同款:setSettings 是 {ok, settings, restartRequired} 形状,不要过 unwrap。
5663
- if (out && out.ok === false) { setSidebarErr(out.error || t("ad.err.saveRejected")); return; }
5664
- if (out && out.settings) setMemSet(out.settings);
5663
+ if (entryBusy) return;
5664
+ setEntryBusy(true); setEntryErr(""); setNotice("");
5665
+ saveEntryMode(memRemote, next).then(function (out) {
5666
+ setEntryBusy(false);
5667
+ if (!out || out.ok === false) {
5668
+ setEntryErr((out && out.error) || t("ad.err.saveRejected"));
5669
+ return;
5670
+ }
5671
+ if (out.settings) setMemSet(out.settings);
5665
5672
  else loadMemSettings();
5666
- setSidebarEntry(!!next);
5667
- setNotice(!!next ? t("ad.notice.sidebarOn") : t("ad.notice.sidebarOff"));
5668
- }, function (e) { setSidebarBusy(false); setSidebarErr(detail(e)); });
5673
+ setNotice(t("entry.notice.saved"));
5674
+ }, function (e) { setEntryBusy(false); setEntryErr(detail(e)); });
5669
5675
  }
5670
5676
 
5671
5677
  /**
@@ -5958,24 +5964,24 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
5958
5964
  ]),
5959
5965
  ]) : null,
5960
5966
  segOf === "settings" ? h("div", { key: "seg-set" }, [
5961
- // 1.8.0:侧栏入口开关。默认开;关掉只影响侧栏那个图标,
5962
- // 「设置 → 记忆管理」这个入口永远在(它注册在 80-apply,和这个开关无关)。
5963
- h("div", { className: "smem-autodream-sec", key: "sidebar-entry" }, [
5967
+ // 2.7.0:界面入口三选一。至少保留一个(三选一从类型上就排除了「都不显示」)。
5968
+ // 这里与插件详情页共用同一个 EntryChooser 与 applyEntryMode。
5969
+ h("div", { className: "smem-autodream-sec", key: "entry" }, [
5964
5970
  h("div", { className: "smem-autodream-row", key: "h" }, [
5965
- h("span", { key: "t", style: { fontWeight: "600", fontSize: "12.5px" } }, t("ad.sidebar.title")),
5966
- h("span", { className: "smem-status", key: "s" }, sidebarEntryOn ? t("ad.sidebar.on") : t("ad.sidebar.off")),
5967
- ]),
5968
- h("div", { className: "smem-autodream-note", key: "n" }, t("ad.sidebar.note")),
5969
- h("label", { className: "smem-check", key: "row" }, [
5970
- h("input", {
5971
- type: "checkbox", key: "c",
5972
- checked: sidebarEntryOn,
5973
- disabled: sidebarBusy,
5974
- onChange: function (e) { saveSidebarEntry(!!(e.target && e.target.checked)); },
5975
- }),
5976
- h("span", { key: "l" }, t("ad.sidebar.label")),
5971
+ h("span", { key: "t", style: { fontWeight: "600", fontSize: "12.5px" } }, t("entry.title")),
5972
+ h("span", { className: "smem-status", key: "s" }, entryStatusLabel(entryShown)),
5977
5973
  ]),
5978
- sidebarErr ? h("div", { className: "smem-autodream-note smem-ad-warn", key: "err" }, sidebarErr) : null,
5974
+ h("div", { className: "smem-autodream-note", key: "n" }, t("entry.subtitle")),
5975
+ h(EntryChooser, {
5976
+ key: "opts", mode: entryShown, busy: entryBusy,
5977
+ sidebarOk: sidebarEntryAvailable(), modernApi: entryApiModern,
5978
+ onPick: saveEntry,
5979
+ }),
5980
+ (entryIsDegraded || entryApiModern === false)
5981
+ ? h("div", { className: "smem-autodream-note smem-ad-warn", key: "deg" },
5982
+ t(entryApiModern === false ? "entry.legacyHost" : "entry.degraded"))
5983
+ : null,
5984
+ entryErr ? h("div", { className: "smem-autodream-note smem-ad-warn", key: "err" }, entryErr) : null,
5979
5985
  ]),
5980
5986
  // 自动归档那一段(三档策略 + 三个阈值)**已挪到「自动归档」页签**(v0.9.9):
5981
5987
  // 归档是确定性规则、不调模型,和这一页的「让模型整理」不是一件事;
@@ -8128,7 +8134,7 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
8128
8134
 
8129
8135
  /** 卡片里的错误文案:remote 可能 reject 一个字符串 / 对象 / Error,尽量展开成人话。 */
8130
8136
  function pluginCardErr(e) {
8131
- if (!e) return t("pluginCard.saveFailed");
8137
+ if (!e) return t("entry.err.saveFailed");
8132
8138
  if (e instanceof Error) return e.message || String(e);
8133
8139
  try {
8134
8140
  const s = JSON.stringify(e);
@@ -8154,20 +8160,16 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
8154
8160
  const ctx = props && props.ctx;
8155
8161
  // 必须走 stableRemote:真实宿主里 ctx.get() 每次返回新对象,进依赖数组会无限重跑。
8156
8162
  const remote = stableRemote(ctx, "remote.memory");
8157
- const sOn = react.useState(true); const on = sOn[0], setOn = sOn[1];
8163
+ const sOpen = react.useState(false); const open = sOpen[0], setOpen = sOpen[1];
8164
+ const sMode = react.useState("both"); const mode = sMode[0], setMode = sMode[1];
8158
8165
  const sBusy = react.useState(false); const busy = sBusy[0], setBusy = sBusy[1];
8159
8166
  const sErr = react.useState(""); const err = sErr[0], setErr = sErr[1];
8160
8167
 
8161
8168
  react.useEffect(function () {
8162
- if (!remote || typeof remote.getSettings !== "function") return undefined;
8163
8169
  let alive = true;
8164
- Promise.resolve(remote.getSettings()).then(function (out) {
8165
- if (!alive) return;
8166
- const loaded = unwrap(out) || {};
8167
- // 旧宿主没有 ui 字段 / 值不是布尔 → 保持默认「开」,不弹错、不阻塞界面。
8168
- if (loaded.ui && typeof loaded.ui.sidebarEntry === "boolean") setOn(loaded.ui.sidebarEntry);
8169
- }, function () {
8170
- // 读设置失败也按「开」显示:这里只是插件页的一张卡片,不该因为它报红。
8170
+ readEntrySettings(remote).then(function (loaded) {
8171
+ if (!alive || !loaded) return;
8172
+ setMode(entryDisplayMode());
8171
8173
  });
8172
8174
  return function () { alive = false; };
8173
8175
  }, [remote]);
@@ -8177,25 +8179,15 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
8177
8179
 
8178
8180
  function toggle(next) {
8179
8181
  if (busy) return;
8180
- if (!remote || typeof remote.setSettings !== "function") {
8181
- setErr(t("pluginCard.noSetSettings"));
8182
- return;
8183
- }
8184
8182
  setBusy(true); setErr("");
8185
- Promise.resolve(remote.setSettings({ ui: { sidebarEntry: !!next } })).then(function (out) {
8183
+ // saveEntryMode 负责「先写设置、成功后再动槽位」;失败时界面由服务端返回值派生。
8184
+ saveEntryMode(remote, next).then(function (out) {
8186
8185
  setBusy(false);
8187
- // setSettings 是 { ok, settings, restartRequired } 形状,不要过 unwrap。
8188
- if (out && out.ok === false) {
8189
- setErr(out.error || t("error.saveRejected"));
8186
+ if (!out || out.ok === false) {
8187
+ setErr((out && out.error) || t("entry.err.saveFailed"));
8190
8188
  return;
8191
8189
  }
8192
- const settings = out && out.settings ? out.settings : null;
8193
- const applied = settings && settings.ui && typeof settings.ui.sidebarEntry === "boolean"
8194
- ? settings.ui.sidebarEntry
8195
- : !!next;
8196
- setOn(applied);
8197
- // 写盘成功之后才动槽位:设置没落盘时界面不该先变一个样。
8198
- setSidebarEntry(!!next);
8190
+ setMode(entryDisplayMode());
8199
8191
  }, function (e) {
8200
8192
  setBusy(false);
8201
8193
  setErr(pluginCardErr(e));
@@ -8203,22 +8195,36 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
8203
8195
  }
8204
8196
 
8205
8197
  return h("div", { className: "smem-plugin-card" }, [
8206
- h("div", { className: "smem-plugin-card-head", key: "h" }, [
8207
- h("span", { className: "smem-plugin-card-title", key: "t" }, t("pluginCard.title")),
8208
- h("span", { className: "smem-plugin-card-status", key: "s" }, on ? t("pluginCard.on") : t("pluginCard.off")),
8198
+ h("button", {
8199
+ type: "button", className: "smem-entry-summary", key: "sum",
8200
+ "aria-expanded": open ? "true" : "false",
8201
+ onClick: function () { setOpen(!open); },
8202
+ }, [
8203
+ h("span", { className: "smem-plugin-card-title", key: "t" }, t("entry.title")),
8204
+ h("span", { className: "smem-plugin-card-status", key: "s" }, entryStatusLabel(mode)),
8205
+ h("svg", {
8206
+ key: "i", className: "smem-entry-chevron", width: 14, height: 14,
8207
+ viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true",
8208
+ style: { transform: open ? "rotate(180deg)" : "rotate(0deg)" },
8209
+ }, h("path", {
8210
+ d: "M4 6.5 8 10.5 12 6.5", stroke: "currentColor", strokeWidth: "1.6",
8211
+ strokeLinecap: "round", strokeLinejoin: "round",
8212
+ })),
8209
8213
  ]),
8210
- h("label", { className: "smem-check smem-plugin-card-check", key: "row" }, [
8211
- h("input", {
8212
- type: "checkbox", key: "c",
8213
- checked: on,
8214
- disabled: busy,
8215
- onChange: function (e) { toggle(!!(e.target && e.target.checked)); },
8214
+ open ? h("div", { className: "smem-entry-body", key: "body" }, [
8215
+ h("div", { className: "smem-plugin-card-note", key: "sub" }, t("entry.subtitle")),
8216
+ h(EntryChooser, {
8217
+ key: "opts", mode: mode, busy: busy,
8218
+ sidebarOk: sidebarEntryAvailable(), modernApi: entryApiModern,
8219
+ onPick: toggle,
8216
8220
  }),
8217
- h("span", { key: "l" }, t("pluginCard.label")),
8218
- ]),
8219
- h("div", { className: "smem-plugin-card-note", key: "n" },
8220
- t("pluginCard.note")),
8221
- err ? h("div", { className: "smem-plugin-card-err", key: "e" }, err) : null,
8221
+ h("div", { className: "smem-plugin-card-note", key: "note" }, t("entry.note")),
8222
+ (entryDegraded() || entryApiModern === false)
8223
+ ? h("div", { className: "smem-plugin-card-note smem-ad-warn", key: "deg" },
8224
+ t(entryApiModern === false ? "entry.legacyHost" : "entry.degraded"))
8225
+ : null,
8226
+ err ? h("div", { className: "smem-plugin-card-err", key: "e" }, err) : null,
8227
+ ]) : null,
8222
8228
  ]);
8223
8229
  }
8224
8230
 
@@ -8257,28 +8263,44 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
8257
8263
  ]);
8258
8264
  }
8259
8265
 
8260
- /** 注册两个槽位(幂等:已经注册就什么都不做)。 */
8266
+ /**
8267
+ * 注册两个槽位(幂等:已经注册就什么都不做)。
8268
+ *
8269
+ * 2.7.0:返回布尔值 —— 侧栏是可选入口,旧宿主没有 sidebar.panellist 时
8270
+ * inject 会抛错。这里必须自己接住并**反序清理**已经注册成功的槽位,
8271
+ * 否则会留下「侧栏条目录入了、main 却没注册」的半状态。
8272
+ */
8261
8273
  function registerSidebarEntry() {
8262
- if (sidebarDispose !== null || !sidebarCtx) return true;
8274
+ if (sidebarDispose !== null) return true;
8275
+ if (!sidebarCtx) return false;
8263
8276
  const ctx = sidebarCtx;
8264
8277
  const handles = [];
8265
- // 侧栏 list 槽:只给图标与 label,按钮由宿主自己画。
8266
- handles.push(ctx.slots.inject("sidebar.panellist", function () {
8267
- return ctx.slots.register({
8268
- name: "sidebar.panellist",
8269
- id: SIDEBAR_ENTRY_ID,
8270
- order: 20,
8271
- label: () => t("sidebar.label"),
8272
- }, SidebarEntryIcon);
8273
- }));
8274
- // 中央主区 keyed 槽:key 与侧栏 id 相同;inject 面把「返回会话」交给页面组件。
8275
- handles.push(ctx.slots.inject("main", function () {
8276
- return ctx.slots.register({
8277
- name: "main",
8278
- key: SIDEBAR_ENTRY_ID,
8279
- inject: function () { return { backToConversation: backToConversation }; },
8280
- }, MemoryPanelPage);
8281
- }));
8278
+ try {
8279
+ // 侧栏 list 槽:只给图标与 label,按钮由宿主自己画。
8280
+ handles.push(ctx.slots.inject("sidebar.panellist", function () {
8281
+ return ctx.slots.register({
8282
+ name: "sidebar.panellist",
8283
+ id: SIDEBAR_ENTRY_ID,
8284
+ order: 20,
8285
+ label: () => t("sidebar.label"),
8286
+ }, SidebarEntryIcon);
8287
+ }));
8288
+ // 中央主区 keyed 槽:key 与侧栏 id 相同;inject 面把「返回会话」交给页面组件。
8289
+ handles.push(ctx.slots.inject("main", function () {
8290
+ return ctx.slots.register({
8291
+ name: "main",
8292
+ key: SIDEBAR_ENTRY_ID,
8293
+ inject: function () { return { backToConversation: backToConversation }; },
8294
+ }, MemoryPanelPage);
8295
+ }));
8296
+ } catch (e) {
8297
+ for (let i = handles.length - 1; i >= 0; i--) {
8298
+ const dispose = handles[i];
8299
+ if (typeof dispose !== "function") continue;
8300
+ try { dispose(); } catch (inner) { /* 清理失败不掩盖原始错误 */ }
8301
+ }
8302
+ return false;
8303
+ }
8282
8304
  sidebarDispose = handles;
8283
8305
  return true;
8284
8306
  }
@@ -8302,6 +8324,255 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
8302
8324
  }
8303
8325
  return true;
8304
8326
  }
8327
+ // ── 界面入口三选一(2.7.0)──────────────────────────────────────────
8328
+ // 两个入口不是重复:设置页那个是所有宿主版本都有的保底,侧栏那个是可选、
8329
+ // 用户还能在插件详情页关掉。所以「只留一个」必须是**用户选择**,而不是
8330
+ // 默认删掉一个;三选一从类型上就保证不会出现「一个入口都没有」。
8331
+ //
8332
+ // 这一分片负责三件事:
8333
+ // 1. 把服务端的 ui.entry / 旧 ui.sidebarEntry 归一成一个模式;
8334
+ // 2. 按「宿主实际能力」解析出该注册哪一侧(侧栏槽 / 设置页 section);
8335
+ // 3. 提供两个控制面共用的 EntryChooser(面板 60 与插件页 76 都渲染它)。
8336
+ //
8337
+ // 旧接口(stale host:客户端已更新、宿主模块还是 2.6.0)没有 ui.entry,
8338
+ // 这时只能表达「侧栏开 / 关」,所以「只显示侧栏」置灰,保存退回 sidebarEntry。
8339
+
8340
+ /** 三个合法模式;顺序就是界面里的顺序。 */
8341
+ const ENTRY_MODES = ["both", "sidebar", "settings"];
8342
+
8343
+ /** 当前存储值(canonical);apply 时从 getSettings 读入。 */
8344
+ let entryMode = "both";
8345
+ /** 宿主是否认识 ui.entry;false = stale host,只能写 sidebarEntry。 */
8346
+ let entryApiModern = true;
8347
+ /** 侧栏槽能力:null = 还没探测,true / false = 探测结果。 */
8348
+ let sidebarSupported = null;
8349
+ /** 设置页 section 能力:null = 还没注册过,true / false = 结果。 */
8350
+ let settingsSupported = null;
8351
+ /** settings.section 的注销句柄(null = 当前没注册)。 */
8352
+ let settingsSectionDispose = null;
8353
+
8354
+ /** 合法模式原样返回,其余(含旧布尔)交给调用方回落。 */
8355
+ function normalizeEntryMode(value) {
8356
+ return ENTRY_MODES.indexOf(value) >= 0 ? value : null;
8357
+ }
8358
+
8359
+ /** 从 getSettings 返回值里取模式:新字段优先,旧字段按 true→both / false→settings。 */
8360
+ function entryModeFromSettings(settings) {
8361
+ const ui = settings && settings.ui;
8362
+ const next = normalizeEntryMode(ui && ui.entry);
8363
+ if (next) return next;
8364
+ if (ui && typeof ui.sidebarEntry === "boolean") return ui.sidebarEntry ? "both" : "settings";
8365
+ return "both";
8366
+ }
8367
+
8368
+ /** 宿主模块是不是 2.7.0:有 ui.entry 才算认识三选一。 */
8369
+ function hasModernEntryApi(settings) {
8370
+ return !!(settings && settings.ui && normalizeEntryMode(settings.ui.entry));
8371
+ }
8372
+
8373
+ /** 选项文案(三处共用:折叠行状态、面板状态、radio label)。 */
8374
+ function entryStatusLabel(mode) {
8375
+ return t(mode === "sidebar" ? "entry.opt.sidebar" : mode === "settings" ? "entry.opt.settings" : "entry.opt.both");
8376
+ }
8377
+
8378
+ /**
8379
+ * 某宿主上「实际会显示哪一个」。
8380
+ *
8381
+ * - 存 sidebar 但侧栏不可用 → 回落 settings(绝不两个都不显示);
8382
+ * - 存 settings 但设置页不可用 → 回落 sidebar;
8383
+ * - 两个都不可用(极旧宿主)→ 交给插件页卡片兜底,这里不抛错。
8384
+ */
8385
+ function resolveEntryVisibility(mode, support) {
8386
+ const sidebarOk = support.sidebar !== false;
8387
+ const settingsOk = support.settings !== false;
8388
+ if (mode === "sidebar") {
8389
+ if (sidebarOk) return { sidebar: true, settings: false };
8390
+ return { sidebar: false, settings: settingsOk };
8391
+ }
8392
+ if (mode === "settings") {
8393
+ if (settingsOk) return { sidebar: false, settings: true };
8394
+ return { sidebar: sidebarOk, settings: false };
8395
+ }
8396
+ return { sidebar: sidebarOk, settings: settingsOk };
8397
+ }
8398
+
8399
+ /** 界面上展示的模式:降级时把 sidebar 显示成 settings,避免控件与实际不符。 */
8400
+ function entryDisplayMode() {
8401
+ if (entryMode === "sidebar" && (sidebarSupported === false || entryApiModern === false)) return "settings";
8402
+ return entryMode;
8403
+ }
8404
+
8405
+ /** 降级提示是否要出现(存了 sidebar、当前宿主却显示设置页)。 */
8406
+ function entryDegraded() {
8407
+ return entryMode === "sidebar" && (sidebarSupported === false || entryApiModern === false);
8408
+ }
8409
+
8410
+ /** 「只显示侧栏」这一项能不能选:宿主支持侧栏,且认识 ui.entry。 */
8411
+ function sidebarEntryAvailable() {
8412
+ return sidebarSupported !== false && entryApiModern !== false;
8413
+ }
8414
+
8415
+ /** 探测侧栏槽能力:真实注册一次,失败时 registerSidebarEntry 自己清干净半注册。 */
8416
+ function detectSidebarSupport() {
8417
+ if (sidebarSupported !== null) return sidebarSupported;
8418
+ sidebarSupported = !!registerSidebarEntry();
8419
+ return sidebarSupported;
8420
+ }
8421
+
8422
+ /** 注册设置页 section(幂等)。失败返回 false —— 旧外壳 / 没有设置面的宿主。 */
8423
+ function registerSettingsSection() {
8424
+ if (settingsSectionDispose !== null) return true;
8425
+ if (!sidebarCtx) return false;
8426
+ const ctx = sidebarCtx;
8427
+ try {
8428
+ settingsSectionDispose = ctx.slots.inject("settings.section", function () {
8429
+ return ctx.slots.register({
8430
+ name: "settings.section",
8431
+ id: "sage-mem",
8432
+ order: 20,
8433
+ label: function () { return t("section.label"); },
8434
+ // 声明「记忆星图」子槽(沿用 1.8.0 的契约):sage-starmap 之类的
8435
+ // 第三方插件向它注入视图。⚠️ 现状:Section 还没有调用 renderSlot,
8436
+ // 所以这个子槽目前只声明、不渲染(2.7.0 未改;渲染它是独立事项)。
8437
+ // 保留声明是为了不破坏第三方插件的注入面。
8438
+ children: { "settings.memory.starmap": { kind: "list", scope: "root" } },
8439
+ }, function (props) {
8440
+ return react.createElement(Section, { ...props, ctx: ctx });
8441
+ });
8442
+ });
8443
+ return true;
8444
+ } catch (e) {
8445
+ settingsSectionDispose = null;
8446
+ return false;
8447
+ }
8448
+ }
8449
+
8450
+ /** 注销设置页 section(反序不需要:只有一个 inject 句柄)。 */
8451
+ function disposeSettingsSection() {
8452
+ const stop = settingsSectionDispose;
8453
+ settingsSectionDispose = null;
8454
+ if (typeof stop !== "function") return;
8455
+ try { stop(); } catch (e) { /* 注销失败不能把设置卡死在「已注销」状态 */ }
8456
+ }
8457
+
8458
+ /**
8459
+ * 立即应用一个入口模式(改完槽位就变,不需要刷新)。
8460
+ *
8461
+ * 顺序:探测侧栏 → 解析可见性 → 按需注册 / 注销两侧。
8462
+ * 设置页注册失败而侧栏又被要求隐藏时,强制把侧栏放回来 —— 这是最后一道门。
8463
+ */
8464
+ function applyEntryMode(mode) {
8465
+ entryMode = normalizeEntryMode(mode) || "both";
8466
+ const sidebarOk = detectSidebarSupport();
8467
+ const visible = resolveEntryVisibility(entryMode, { sidebar: sidebarOk, settings: settingsSupported });
8468
+
8469
+ if (visible.sidebar) {
8470
+ if (!registerSidebarEntry()) {
8471
+ sidebarSupported = false;
8472
+ visible.sidebar = false;
8473
+ }
8474
+ } else {
8475
+ setSidebarEntry(false);
8476
+ }
8477
+
8478
+ if (visible.settings) {
8479
+ settingsSupported = registerSettingsSection();
8480
+ if (!settingsSupported && !visible.sidebar) {
8481
+ if (registerSidebarEntry()) {
8482
+ sidebarSupported = true;
8483
+ visible.sidebar = true;
8484
+ }
8485
+ }
8486
+ } else {
8487
+ disposeSettingsSection();
8488
+ }
8489
+
8490
+ return {
8491
+ mode: entryMode,
8492
+ sidebar: visible.sidebar,
8493
+ settings: visible.settings,
8494
+ sidebarOk: sidebarSupported,
8495
+ settingsOk: settingsSupported,
8496
+ };
8497
+ }
8498
+
8499
+ /**
8500
+ * 保存一个入口模式:先写设置、成功后再动槽位。
8501
+ *
8502
+ * stale host 上没有 ui.entry,退回旧写法 sidebarEntry(true = 两个都显示,
8503
+ * false = 只显示设置页);若宿主真的拒绝了 entry,也自动重试一次旧写法。
8504
+ */
8505
+ function saveEntryMode(remote, mode) {
8506
+ const next = normalizeEntryMode(mode) || "both";
8507
+ const writeModern = function () {
8508
+ return Promise.resolve(remote.setSettings({ ui: { entry: next } }));
8509
+ };
8510
+ const writeLegacy = function () {
8511
+ return Promise.resolve(remote.setSettings({ ui: { sidebarEntry: next !== "settings" } }));
8512
+ };
8513
+ const finish = function (out, retried) {
8514
+ if (out && out.ok === false) {
8515
+ // stale host 的报错形如 "unknown ui setting: entry":自动退回旧写法再试一次。
8516
+ if (!retried && entryApiModern !== false && /unknown ui setting/i.test(String(out.error || ""))) {
8517
+ entryApiModern = false;
8518
+ return writeLegacy().then(function (legacyOut) { return finish(legacyOut, true); });
8519
+ }
8520
+ return { ok: false, error: out.error || t("ad.err.saveRejected") };
8521
+ }
8522
+ const settings = out && out.settings ? out.settings : null;
8523
+ // 服务端回传的形状就是事实:带 ui.entry 才是新接口。
8524
+ if (settings) entryApiModern = hasModernEntryApi(settings);
8525
+ const applied = settings ? entryModeFromSettings(settings) : next;
8526
+ applyEntryMode(applied);
8527
+ return { ok: true, settings: settings, mode: applied };
8528
+ };
8529
+ return (entryApiModern === false ? writeLegacy() : writeModern()).then(function (out) {
8530
+ return finish(out, entryApiModern === false);
8531
+ });
8532
+ }
8533
+
8534
+ /** 读一次设置并同步全局模式;失败时返回 null(界面按默认渲染,不阻塞)。 */
8535
+ function readEntrySettings(remote) {
8536
+ if (!remote || typeof remote.getSettings !== "function") return Promise.resolve(null);
8537
+ return Promise.resolve(remote.getSettings()).then(function (out) {
8538
+ const settings = unwrap(out) || {};
8539
+ entryApiModern = hasModernEntryApi(settings);
8540
+ const mode = entryModeFromSettings(settings);
8541
+ applyEntryMode(mode);
8542
+ return { settings: settings, mode: mode };
8543
+ }, function () { return null; });
8544
+ }
8545
+
8546
+ /**
8547
+ * 三选一控件(受控)。
8548
+ *
8549
+ * 不自己读设置、不自己保存:两个控制面各自处理 busy / error,
8550
+ * 这样插件页卡片与面板里的交互文案可以不同,而选项本身只有一份。
8551
+ */
8552
+ function EntryChooser(props) {
8553
+ const mode = props && normalizeEntryMode(props.mode) ? props.mode : entryDisplayMode();
8554
+ const busy = !!(props && props.busy);
8555
+ const sidebarOk = !(props && props.sidebarOk === false);
8556
+ const modernApi = !(props && props.modernApi === false);
8557
+ const onPick = props && typeof props.onPick === "function" ? props.onPick : function () {};
8558
+ const labels = { both: "entry.opt.both", sidebar: "entry.opt.sidebar", settings: "entry.opt.settings" };
8559
+ return h("div", { className: "smem-entry-opts" }, ENTRY_MODES.map(function (value) {
8560
+ const blocked = value === "sidebar" && (!sidebarOk || !modernApi);
8561
+ const disabled = busy || blocked;
8562
+ const blockedWhy = !modernApi ? t("entry.legacyHost") : t("entry.sidebarUnsupported");
8563
+ return h("label", {
8564
+ className: "smem-check smem-entry-opt", key: value,
8565
+ title: blocked ? blockedWhy : undefined,
8566
+ }, [
8567
+ h("input", {
8568
+ type: "radio", key: "r", name: "smem-entry-mode",
8569
+ value: value, checked: mode === value, disabled: disabled,
8570
+ onChange: function () { if (!disabled) onPick(value); },
8571
+ }),
8572
+ h("span", { key: "l" }, t(labels[value])),
8573
+ ]);
8574
+ }));
8575
+ }
8305
8576
  // ⚠️ 不把 `layout` 写进 inject:它是**可选**依赖(官方 inspect:optional access =
8306
8577
  // `ctx.get("layout")` + 判空;hardDependency 才是 inject)。写进 inject 会让旧外壳
8307
8578
  // 缺这个服务时整个 client 插件不 apply —— 连带丢掉设置页。返回会话优先用宿主给的
@@ -8324,38 +8595,26 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
8324
8595
  document.head.appendChild(tag);
8325
8596
  }
8326
8597
  setupI18n(ctx);
8327
- ctx.slots.inject("settings.section", () =>
8328
- ctx.slots.register(
8329
- {
8330
- name: "settings.section",
8331
- id: "sage-mem",
8332
- order: 20,
8333
- label: () => t("section.label"),
8334
- // 声明「记忆星图」子槽:sage-starmap 向它注入星空视图,
8335
- // 记忆管理页用 renderSlot 渲染。声明 children 后 shell 才会
8336
- // 向本 section 组件传入 renderSlot。
8337
- children: { "settings.memory.starmap": { kind: "list", scope: "root" } },
8338
- },
8339
- (props) => react.createElement(Section, { ...props, ctx }),
8340
- ),
8341
- );
8342
- // ── 1.8.0 侧栏入口:先记住 ctx,再读一次设置决定注不注册 ──────────────
8343
- // 设置页 section **永远**保留(关闭侧栏入口只是少一个入口,不是少一个设置页)。
8344
- // 读失败 / 旧宿主没有 ui 字段 / 值坏掉 → 一律按「开」处理,绝不阻塞界面。
8598
+
8599
+ // ── 2.7.0 界面入口三选一 ────────────────────────────────────────────
8600
+ // 先读一次设置,决定注册哪一侧;读失败 / 旧宿主没有 ui.entry / 值坏掉
8601
+ // → 一律按「两个都显示」处理,绝不阻塞界面。
8602
+ // 设置页 section 不再无条件注册:只显示侧栏时它会被注销(78-entry-visibility)。
8345
8603
  sidebarCtx = ctx;
8346
- let sidebarEnabled = true;
8604
+ let initialMode = "both";
8347
8605
  try {
8348
8606
  const mem = typeof ctx.get === "function" ? ctx.get("remote.memory") : null;
8349
8607
  if (mem && typeof mem.getSettings === "function") {
8350
8608
  const loaded = unwrap(await mem.getSettings()) || {};
8351
- if (loaded.ui && loaded.ui.sidebarEntry === false) sidebarEnabled = false;
8609
+ entryApiModern = hasModernEntryApi(loaded);
8610
+ initialMode = entryModeFromSettings(loaded);
8352
8611
  }
8353
8612
  } catch (e) {
8354
- sidebarEnabled = true;
8613
+ initialMode = "both";
8355
8614
  }
8356
- setSidebarEntry(sidebarEnabled);
8615
+ applyEntryMode(initialMode);
8357
8616
 
8358
- // ── 1.8.2 插件详情页的「侧栏入口」卡片 ──────────────────────────────
8617
+ // ── 1.8.2 / 2.7.0 插件详情页的「界面入口」折叠行 ────────────────────
8359
8618
  // `plugins.bundle.config` 是 keyed 槽,key = 本 bundle 的 **package name**(sage-mem)。
8360
8619
  // 宿主在插件页的 description 与「包含的组件」行之间渲染它;ownerProps 形如
8361
8620
  // `{ view: "summary" | "page" }`,卡片里自己再判一次(summary 返回 null)。
package/lib/index.js CHANGED
@@ -430,14 +430,16 @@ export class MemoryGateway extends TypertRemoteService {
430
430
  const norm = (list) => JSON.stringify([...list].map((s) => String(s).toLowerCase()).sort())
431
431
  const restartRequired =
432
432
  JSON.stringify(limits) !== JSON.stringify(pending.limits) || norm(activeExtra) !== norm(reservedExtra)
433
+ // 2.7.0:entry 是 canonical;sidebarEntry 只为 2.6.0 的客户端保留(派生值,不落盘)。
434
+ const ui = pendingUi(fileRead.data)
433
435
  return {
434
436
  limits,
435
437
  pendingLimits: pending.limits,
436
438
  restartRequired,
437
439
  // Q04 的诊断出口:面板据此说明「你保存的值没生效,原因是 X」;宿主侧先如实给出,别让问题只能靠猜。
438
440
  limitDiagnostics,
439
- // 1.8.0:客户端行为(侧栏入口开关)。**不参与** restartRequired —— 改完立刻生效。
440
- ui: pendingUi(fileRead.data),
441
+ // 1.8.0 / 2.7.0:客户端行为(界面入口三选一)。**不参与** restartRequired —— 改完立刻生效。
442
+ ui: { ...ui, sidebarEntry: ui.entry !== 'settings' },
441
443
  // 生效的保护名单(含两个内置名)+ 用户声明的那部分原文(面板编辑的是后者)
442
444
  reserved: [...new Set([INDEX_FILE_KEY, SESSION_LOG_KEY, ...reservedExtra.map((s) => s.toLowerCase())])],
443
445
  reservedExtra,
@@ -498,7 +500,8 @@ export class MemoryGateway extends TypertRemoteService {
498
500
  }
499
501
  const current = currentRead.data
500
502
  const mergedLimits = { ...(current?.limits ?? {}), ...nextLimits }
501
- // ui 只覆盖这次给出的键;没给就沿用文件里的有效值(坏值 / 缺失一律按默认「开」)
503
+ // ui 只覆盖这次给出的键;没给就沿用文件里的有效值(坏值 / 缺失一律按默认「两个都显示」)。
504
+ // 2.7.0:落盘只写 canonical 的 entry,旧 sidebarEntry 由 pendingUi 迁移进来、不再写出。
502
505
  const mergedUi = { ...pendingUi(current), ...(uiPatch ?? {}) }
503
506
  await writeFileAtomic(SETTINGS_FILE, JSON.stringify({ limits: mergedLimits, ui: mergedUi, updatedAt: Date.now() }, null, 2))
504
507
  }
package/lib/settings.js CHANGED
@@ -21,22 +21,34 @@ export const RESERVED_FILE = join(STATE_ROOT, 'reserved.json')
21
21
  export const clampInt = (v, spec) => (Number.isFinite(v) ? Math.max(spec.lo, Math.min(spec.hi, Math.round(v))) : spec.dflt)
22
22
 
23
23
  /**
24
- * 面板 UI 设置(1.8.0)。
24
+ * 面板 UI 设置(1.8.0;2.7.0 改成三选一)。
25
25
  *
26
26
  * ⚠️ 与 `limits` **不是一类**:上限在模块加载时就固化进常量,改完必须重启;
27
- * `ui.sidebarEntry` 是**客户端行为** —— 界面读到值就决定注不注册侧栏槽位,
27
+ * `ui.entry` 是**客户端行为** —— 界面读到值就决定注不注册侧栏 / 设置页槽位,
28
28
  * 改完立刻生效。所以它不进 `restartRequired` 的计算。
29
+ *
30
+ * 2.7.0:`entry` 取代 1.8.0 的布尔 `sidebarEntry`。三个值从类型上就保证
31
+ * 「至少保留一个入口」,不需要额外的互斥校验;旧字段只读不写。
29
32
  */
30
- export const DEFAULT_UI = Object.freeze({ sidebarEntry: true })
33
+ export const ENTRY_MODES = Object.freeze(['both', 'sidebar', 'settings'])
34
+ export const DEFAULT_UI = Object.freeze({ entry: 'both' })
31
35
 
32
- /** 读出 `ui` 的**有效值**:文件里没有 / 坏对象 / 不是布尔 → 一律回落默认(开)。 */
36
+ /**
37
+ * 读出 `ui` 的**有效值**:新字段优先,旧 `sidebarEntry` 按
38
+ * `true → both` / `false → settings` 迁移;缺失或坏值回落默认(两个都显示)。
39
+ */
33
40
  export function pendingUi(fileObj) {
34
41
  const saved = fileObj?.ui && typeof fileObj.ui === 'object' && !Array.isArray(fileObj.ui) ? fileObj.ui : {}
35
- return { sidebarEntry: typeof saved.sidebarEntry === 'boolean' ? saved.sidebarEntry : DEFAULT_UI.sidebarEntry }
42
+ if (ENTRY_MODES.includes(saved.entry)) return { entry: saved.entry }
43
+ if (typeof saved.sidebarEntry === 'boolean') return { entry: saved.sidebarEntry ? 'both' : 'settings' }
44
+ return { entry: DEFAULT_UI.entry }
36
45
  }
37
46
 
38
47
  /**
39
- * 校验 `setSettings({ ui })` 的那一段:**只认 `sidebarEntry` 一个键**,值必须是布尔。
48
+ * 校验 `setSettings({ ui })` 的那一段:认 `entry`(2.7.0)与旧 `sidebarEntry`(1.8.0)。
49
+ *
50
+ * 两个键同时给时要求语义一致(`sidebarEntry === (entry !== 'settings')`),否则报冲突 ——
51
+ * 旧客户端写 true 的语义是「侧栏显示、设置页也保留」,和 `entry:'sidebar'` 不是一回事。
40
52
  *
41
53
  * 返回 `{ ok:true, patch }`(patch 为 null = 调用方没给 ui,或给的是空对象);`{ ok:false, error }`。
42
54
  * 与 limits 同一套严格语义:绝不静默丢掉不认识的键。
@@ -45,10 +57,30 @@ export function parseUiPatch(ui) {
45
57
  if (ui === undefined) return { ok: true, patch: null }
46
58
  if (!ui || typeof ui !== 'object' || Array.isArray(ui)) return { ok: false, error: 'sage-mem: ui must be an object' }
47
59
  const patch = {}
60
+ let hasLegacy = false
61
+ let legacy = null
48
62
  for (const [key, value] of Object.entries(ui)) {
49
- if (key !== 'sidebarEntry') return { ok: false, error: `sage-mem: unknown ui setting: ${key}` }
50
- if (typeof value !== 'boolean') return { ok: false, error: `sage-mem: ui.sidebarEntry must be a boolean, got ${typeof value}` }
51
- patch.sidebarEntry = value
63
+ if (key === 'entry') {
64
+ if (typeof value !== 'string' || !ENTRY_MODES.includes(value)) {
65
+ return { ok: false, error: `sage-mem: ui.entry must be one of ${ENTRY_MODES.join(' | ')}, got ${JSON.stringify(value)}` }
66
+ }
67
+ patch.entry = value
68
+ continue
69
+ }
70
+ if (key === 'sidebarEntry') {
71
+ if (typeof value !== 'boolean') return { ok: false, error: `sage-mem: ui.sidebarEntry must be a boolean, got ${typeof value}` }
72
+ hasLegacy = true
73
+ legacy = value
74
+ continue
75
+ }
76
+ return { ok: false, error: `sage-mem: unknown ui setting: ${key}` }
77
+ }
78
+ if (hasLegacy) {
79
+ const derived = legacy ? 'both' : 'settings'
80
+ if (patch.entry !== undefined && patch.entry !== derived) {
81
+ return { ok: false, error: `sage-mem: ui.entry=${patch.entry} conflicts with ui.sidebarEntry=${legacy}` }
82
+ }
83
+ patch.entry = derived
52
84
  }
53
85
  return { ok: true, patch: Object.keys(patch).length ? patch : null }
54
86
  }
@@ -239,10 +239,9 @@ const settingsLimitsSchema = z.object({
239
239
  'maxSessionBytes': z.number().readonly(),
240
240
  'staleDays': z.number().readonly(),
241
241
  }).readonly()
242
- // 1.8.0:面板 UI 设置。`sidebarEntry` 是**客户端行为**(改完立刻生效),不进 restartRequired。
243
- const settingsUiSchema = z.object({
244
- 'sidebarEntry': z.boolean().readonly(),
245
- }).readonly()
242
+ // 1.8.0 / 2.7.0:面板 UI 设置。`entry` 是**客户端行为**(改完立刻生效),不进 restartRequired。
243
+ // `sidebarEntry` 是给 2.6.0 客户端的派生值(= entry !== 'settings'),永远随 `entry` 一起返回。
244
+ const settingsUiSchema = z.object({ 'entry': z.enum(['both', 'sidebar', 'settings']).readonly(), 'sidebarEntry': z.boolean().readonly() }).readonly()
246
245
  const settingsSchema = z.object({
247
246
  'limits': settingsLimitsSchema,
248
247
  'pendingLimits': settingsLimitsSchema,
@@ -260,6 +259,7 @@ const settingsPatchSchema = z.object({
260
259
  'staleDays': z.number().optional(),
261
260
  }).readonly().optional(),
262
261
  'ui': z.object({
262
+ 'entry': z.enum(['both', 'sidebar', 'settings']).optional(),
263
263
  'sidebarEntry': z.boolean().optional(),
264
264
  }).readonly().optional(),
265
265
  'reserved': z.array(z.string()).optional(),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sage-mem",
3
3
  "description": "Cross-session memory for DeepSeek Harness — plain markdown files, no database, no worker.",
4
- "version": "2.6.0",
4
+ "version": "2.7.0",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "icon": "./icon.png",