sage-mem 1.7.2 → 1.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,7 +12,7 @@
12
12
 
13
13
  **Cross-session memory for DeepSeek Harness — plain markdown files, no database, no worker.**
14
14
 
15
- **Current release: `1.7.2`** — 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: `1.8.2`** — 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.
@@ -80,6 +80,12 @@ Most memory schemes take the "SQLite + a long-running worker" route: once memori
80
80
 
81
81
  The UI also carries four more capabilities (all of them optional): the memory management page, type and tag filtering, the memory star map, and audit & archiving.
82
82
 
83
+ **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).
84
+ It can be turned off in the settings page under 「记忆管理 → 自动做梦 → 设置」(on by default; applies immediately, no restart);
85
+ **since 1.8.2 the same toggle also lives on the plugin's detail page** (「插件 → sage-mem」card) and applies immediately too;
86
+ **turning it off only removes the sidebar icon — the 「设置 → 记忆管理」 entry always stays**.
87
+ Since 1.8.2 the plugin also ships its own icon (a golden stack of memory cards) instead of the generic square.
88
+
83
89
  The memory management page's file list also has an **Open memory folder** button: it opens the
84
90
  memory directory in the system file manager (Explorer / Finder / the Linux file manager) through a
85
91
  detached host-side process and returns without waiting for the window.
package/README.zh.md CHANGED
@@ -14,7 +14,7 @@
14
14
 
15
15
  > File-based cross-session memory for DeepSeek Harness — every memory is a plain markdown file you can open, edit and delete. No database, no worker, no port.
16
16
 
17
- **当前发布版本:`1.7.2`** —— 顶部 npm 徽章是动态权威。**契约基线:`1.0.0`** —— 第一个稳定版,公开承诺、能力边界与「明确做不到」的清单冻结在
17
+ **当前发布版本:`1.8.2`** —— 顶部 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 是最直接的反馈。
@@ -82,6 +82,12 @@ sage-mem 补上这一层,做法是:**把该记住的事写成一张张卡片
82
82
 
83
83
  界面上还有四块能力(都可以不用):记忆管理页、类型与标签筛选、记忆星图、体检与归档。
84
84
 
85
+ **1.8.0 起,记忆管理还能从侧栏直接进**:侧栏多一个「记忆管理」图标,点它把中央主区切到本页(页顶有「返回会话」)。
86
+ 设置页「记忆管理 → 自动做梦 → 设置」里可以关掉这个入口(默认开,立即生效、不用重启);
87
+ **1.8.2 起,插件详情页也有同一个开关**(「插件 → sage-mem」那张卡),改完立即生效;
88
+ **关掉只影响侧栏图标,「设置 → 记忆管理」这个入口永远保留**。
89
+ 从 1.8.2 开始,sage-mem 在插件列表里有了自己的图标(金色记忆卡片),不再是默认的通用方图标。
90
+
85
91
  记忆管理页的「文件列表」头部还有一个「打开记忆文件夹」按钮:用系统文件管理器打开当前记忆目录
86
92
  (Windows Explorer / macOS Finder / Linux 文件管理器)。host 侧起一个 detached 进程就返回,不等窗口。
87
93
 
package/icon.png ADDED
Binary file
package/lib/client.js CHANGED
@@ -1772,6 +1772,9 @@ const StarmapRuntime = (function (require, _react) {
1772
1772
  sourceLocation: { "file": "lib/index.js", "line": 1, "column": 1 },
1773
1773
  },
1774
1774
  {
1775
+ // 1.8.0:结果里多了 `ui.sidebarEntry`(侧栏入口开关)。客户端这份 codec 是
1776
+ // **顶层严格、内部宽松**的 objCodec —— host 加字段不需要改这里(与 audit /
1777
+ // autodream 那批同一套理由);host 侧真正的形状约束在 typert.host.js 的 zod schema。
1775
1778
  id: "sage-mem#memory/getSettings",
1776
1779
  service: "memory",
1777
1780
  namespace: "memory",
@@ -1782,6 +1785,8 @@ const StarmapRuntime = (function (require, _react) {
1782
1785
  sourceLocation: { "file": "lib/index.js", "line": 1, "column": 1 },
1783
1786
  },
1784
1787
  {
1788
+ // 1.8.0:patch 可带 `{ ui: { sidebarEntry: boolean } }`。objCodec 只保证顶层是对象,
1789
+ // 字段级严格校验在 host 的 setSettings(非布尔 / 未知键都回 {ok:false})。
1785
1790
  id: "sage-mem#memory/setSettings",
1786
1791
  service: "memory",
1787
1792
  namespace: "memory",
@@ -2219,6 +2224,23 @@ const StarmapRuntime = (function (require, _react) {
2219
2224
  ".smem-tabs-sep{width:1px;align-self:stretch;margin:6px 2px;background:var(--dsw-alias-border-l2);}",
2220
2225
  // 窄窗口(≤420px):头部带与筛选条各让一行,卡片动作允许换行、分隔符隐藏。
2221
2226
  "@media (max-width:420px){.smem-root{gap:10px;}.smem-head{gap:6px;}.smem-head-actions{flex-wrap:wrap;}.smem-card-title{min-width:0;word-break:break-word;}.smem-actions{flex-wrap:wrap;}.smem-actions-sep{display:none;}.smem-num{width:56px;}.smem-search{min-width:100px;}}",
2227
+ // ── 1.8.0:侧栏入口(设置开关 + 中央主区整页)──
2228
+ ".smem-check{display:flex;align-items:center;gap:7px;font-size:12.5px;cursor:pointer;user-select:none;}",
2229
+ ".smem-check input{margin:0;cursor:pointer;accent-color:var(--dsw-alias-brand-primary,#4d76e6);}",
2230
+ ".smem-page{display:flex;flex-direction:column;gap:10px;padding:14px 16px 18px;box-sizing:border-box;min-height:0;overflow:auto;}",
2231
+ ".smem-page-top{display:flex;align-items:center;}",
2232
+ ".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;}",
2233
+ ".smem-page-back:hover{color:var(--dsw-alias-label-primary,#1f2329);background:var(--dsw-alias-bg-layer-2);}",
2234
+ // ── 1.8.2:插件详情页的「侧栏入口」卡片 ──
2235
+ // 融入宿主插件页:标题 13px/600、说明 12px 次要色,圆角与分隔线只用宿主 token。
2236
+ // 不抄设置页的大标题 —— 这一块是插件页里的一张紧凑卡片,不是另一个 settings section。
2237
+ ".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;}",
2238
+ ".smem-plugin-card-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap;}",
2239
+ ".smem-plugin-card-title{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary,#1f2430);line-height:1.4;}",
2240
+ ".smem-plugin-card-status{font-size:12px;color:var(--dsw-alias-label-secondary,#6b7280);line-height:1.4;}",
2241
+ ".smem-plugin-card-check{font-size:12.5px;}",
2242
+ ".smem-plugin-card-note{font-size:12px;line-height:1.55;color:var(--dsw-alias-label-secondary,#6b7280);}",
2243
+ ".smem-plugin-card-err{font-size:12px;line-height:1.5;color:var(--dsw-alias-state-error-primary,#ef4444);overflow-wrap:anywhere;}",
2222
2244
  // ── 底部一行仓库链接(引流,不抢视线)──
2223
2245
  ".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;}",
2224
2246
  ".smem-star-link{color:var(--dsw-alias-brand-primary,#4d76e6);text-decoration:underline;}",
@@ -3086,6 +3108,9 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
3086
3108
  const sLimitsDraft = react.useState(null); const limitsDraft = sLimitsDraft[0], setLimitsDraft = sLimitsDraft[1];
3087
3109
  const sSaveBusy = react.useState(false); const saveBusy = sSaveBusy[0], setSaveBusy = sSaveBusy[1];
3088
3110
  const sReservedNew = react.useState(""); const reservedNew = sReservedNew[0], setReservedNew = sReservedNew[1];
3111
+ // ── 1.8.0 侧栏入口开关(客户端行为:改完立刻生效,不需要重启)──
3112
+ const sSidebarBusy = react.useState(false); const sidebarBusy = sSidebarBusy[0], setSidebarBusy = sSidebarBusy[1];
3113
+ const sSidebarErr = react.useState(""); const sidebarErr = sSidebarErr[0], setSidebarErr = sSidebarErr[1];
3089
3114
 
3090
3115
  // 派生量放在 effect 之前:下面几个 effect 的依赖数组要在渲染期就能取到值。
3091
3116
  const c = cfg || {};
@@ -3100,6 +3125,8 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
3100
3125
  const fixedReserved = ((memSet && memSet.reserved) || []).filter(function (n) {
3101
3126
  return extraLower.indexOf(String(n).toLowerCase()) < 0;
3102
3127
  });
3128
+ // 默认开:宿主没回 ui 字段(旧版本)或值不是布尔,都按「开」显示。
3129
+ const sidebarEntryOn = !(memSet && memSet.ui && memSet.ui.sidebarEntry === false);
3103
3130
 
3104
3131
  react.useEffect(function () {
3105
3132
  if (!remote) { setErr("remote.autodream 不可用 —— 插件的 宿主侧可能没加载成功,看 DSH 启动日志"); return undefined; }
@@ -3210,6 +3237,31 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
3210
3237
  }, function (e) { setSaveBusy(false); setErr(detail(e)); });
3211
3238
  }
3212
3239
 
3240
+ /**
3241
+ * 侧栏入口开关(1.8.0)。
3242
+ *
3243
+ * 顺序是「先写设置、成功后再动槽位」:设置没落盘就不该让界面先变一个样。
3244
+ * 失败只报错、不改任何东西 —— checkbox 的勾选状态由 memSet 派生,于是自动回到原值。
3245
+ * 成功后调 `setSidebarEntry`:**立即**增删侧栏槽位,不刷新、不重启。
3246
+ */
3247
+ function saveSidebarEntry(next) {
3248
+ if (!memRemote || typeof memRemote.setSettings !== "function") {
3249
+ setSidebarErr("宿主侧还没有 setSettings 接口(插件版本较旧)");
3250
+ return;
3251
+ }
3252
+ if (sidebarBusy) return;
3253
+ setSidebarBusy(true); setSidebarErr(""); setNotice("");
3254
+ Promise.resolve(memRemote.setSettings({ ui: { sidebarEntry: !!next } })).then(function (out) {
3255
+ setSidebarBusy(false);
3256
+ // 与 saveLimits 同款:setSettings 是 {ok, settings, restartRequired} 形状,不要过 unwrap。
3257
+ if (out && out.ok === false) { setSidebarErr(out.error || "保存被拒绝"); return; }
3258
+ if (out && out.settings) setMemSet(out.settings);
3259
+ else loadMemSettings();
3260
+ setSidebarEntry(!!next);
3261
+ setNotice(!!next ? "侧栏入口已开启(立即生效)" : "侧栏入口已关闭(设置页入口仍在)");
3262
+ }, function (e) { setSidebarBusy(false); setSidebarErr(detail(e)); });
3263
+ }
3264
+
3213
3265
  /**
3214
3266
  * 保存保留名名单(用户声明的那部分)。
3215
3267
  *
@@ -3481,6 +3533,26 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
3481
3533
  ]),
3482
3534
  ]) : null,
3483
3535
  segOf === "settings" ? h("div", { key: "seg-set" }, [
3536
+ // 1.8.0:侧栏入口开关。默认开;关掉只影响侧栏那个图标,
3537
+ // 「设置 → 记忆管理」这个入口永远在(它注册在 80-apply,和这个开关无关)。
3538
+ h("div", { className: "smem-autodream-sec", key: "sidebar-entry" }, [
3539
+ h("div", { className: "smem-autodream-row", key: "h" }, [
3540
+ h("span", { key: "t", style: { fontWeight: "600", fontSize: "12.5px" } }, "侧栏入口"),
3541
+ h("span", { className: "smem-status", key: "s" }, sidebarEntryOn ? "已开启" : "已关闭"),
3542
+ ]),
3543
+ h("div", { className: "smem-autodream-note", key: "n" },
3544
+ "在 DSH 侧栏显示「记忆管理」图标,点它把中央主区切到本页。默认开启;关闭后「设置 → 记忆管理」这个入口仍然保留。改动立即生效,不需要重启。"),
3545
+ h("label", { className: "smem-check", key: "row" }, [
3546
+ h("input", {
3547
+ type: "checkbox", key: "c",
3548
+ checked: sidebarEntryOn,
3549
+ disabled: sidebarBusy,
3550
+ onChange: function (e) { saveSidebarEntry(!!(e.target && e.target.checked)); },
3551
+ }),
3552
+ h("span", { key: "l" }, "在侧栏显示「记忆管理」入口"),
3553
+ ]),
3554
+ sidebarErr ? h("div", { className: "smem-autodream-note smem-ad-warn", key: "err" }, sidebarErr) : null,
3555
+ ]),
3484
3556
  // 自动归档那一段(三档策略 + 三个阈值)**已挪到「自动归档」页签**(v0.9.9):
3485
3557
  // 归档是确定性规则、不调模型,和这一页的「让模型整理」不是一件事;
3486
3558
  // 同一件事只在一处设定,这里不再重复摆一份。
@@ -5057,11 +5129,237 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
5057
5129
  ]);
5058
5130
  }
5059
5131
 
5132
+ // ── 侧栏入口(1.8.0)─────────────────────────────────────────────
5133
+ // 宿主侧栏的官方契约是两个槽位:
5134
+ // `sidebar.panellist`(list 槽):只画一个 size 见方的图标,行标题由宿主从 label 投影;
5135
+ // `main`(keyed 槽):key **必须**与侧栏条目 id 相同,选中缺失 key 宿主会直接抛错。
5136
+ // 开关(settings.ui.sidebarEntry)是**客户端行为**:这里持有两个 inject 的清理句柄,
5137
+ // 关 → 调清理器(宿主会连带注销 register 进去的条目),开 → 重新 inject/register。
5138
+ const SIDEBAR_ENTRY_ID = "sage-mem";
5139
+ /** 插件 ctx(80-apply 在 apply 时挂进来);setSidebarEntry 之后要拿它重注册。 */
5140
+ let sidebarCtx = null;
5141
+ /** 该槽位的清理句柄数组(null = 当前没注册)。 */
5142
+ let sidebarDispose = null;
5143
+
5144
+ /**
5145
+ * 侧栏图标:内联 SVG、currentColor、细线 —— 不引外部图片/字体,跟随宿主明暗主题。
5146
+ *
5147
+ * 1.8.2 起字形与仓库根的 `icon.png` 同源:**叠层记忆卡片**(后面一张只描边、低透明,
5148
+ * 前面一张压在左下),不再是「文档三行」。单色而不是品牌金:侧栏图标必须跟随
5149
+ * 宿主的 currentColor,深色 / 浅色主题下才都可读。
5150
+ */
5151
+ function SidebarEntryIcon(props) {
5152
+ const edge = typeof props.size === "number" && props.size > 0 ? props.size : 16;
5153
+ return h("svg", {
5154
+ width: edge, height: edge, viewBox: "0 0 16 16", fill: "none",
5155
+ "aria-hidden": "true", focusable: "false",
5156
+ }, [
5157
+ // 后面那张卡:错开、只描边、低透明 —— 表达「一叠记忆」而不是两张孤立的卡。
5158
+ h("rect", {
5159
+ key: "back", x: "4.4", y: "2.2", width: "9.4", height: "9", rx: "1.8",
5160
+ stroke: "currentColor", strokeWidth: "1.2", opacity: "0.45",
5161
+ }),
5162
+ // 前面那张卡:压在左下,实心描边。
5163
+ h("rect", {
5164
+ key: "front", x: "2.2", y: "4.8", width: "9.4", height: "9", rx: "1.8",
5165
+ stroke: "currentColor", strokeWidth: "1.2",
5166
+ }),
5167
+ h("path", {
5168
+ key: "l1", d: "M4.7 8.2h4.4",
5169
+ stroke: "currentColor", strokeWidth: "1.2", strokeLinecap: "round",
5170
+ }),
5171
+ h("path", {
5172
+ key: "l2", d: "M4.7 10.6h3.1",
5173
+ stroke: "currentColor", strokeWidth: "1.2", strokeLinecap: "round",
5174
+ }),
5175
+ ]);
5176
+ }
5177
+
5178
+ /** 卡片里的错误文案:remote 可能 reject 一个字符串 / 对象 / Error,尽量展开成人话。 */
5179
+ function pluginCardErr(e) {
5180
+ if (!e) return "保存失败(未知原因)";
5181
+ if (e instanceof Error) return e.message || String(e);
5182
+ try {
5183
+ const s = JSON.stringify(e);
5184
+ return s && s !== "{}" ? s.slice(0, 200) : String(e);
5185
+ } catch (_) {
5186
+ return String(e);
5187
+ }
5188
+ }
5189
+
5190
+ /**
5191
+ * 插件详情页的「侧栏入口」卡片(1.8.2)。
5192
+ *
5193
+ * 槽位 `plugins.bundle.config` 是 keyed 槽(key = 包名),宿主在 sage-mem 的插件页里、
5194
+ * description 与「包含的组件」行之间渲染它,ownerProps 为 `{ view: "summary" | "page" }`:
5195
+ * - summary:标题下的一块摘要位 —— 这里返回 null,不在摘要位重复渲染;
5196
+ * - page:详情页本体(本卡片自己写设置,所以宿主给的是 page)。
5197
+ *
5198
+ * 数据与设置页同一个来源(remote.memory 的 getSettings / setSettings),切换成功后调
5199
+ * 模块级 `setSidebarEntry`:**立即**增删侧栏图标,不需要刷新 / 重启。
5200
+ * 勾选态一律由服务端回传的 settings 派生 —— 失败时什么都不改,于是自动回到原值。
5201
+ */
5202
+ function SidebarEntryConfigCard(props) {
5203
+ const ctx = props && props.ctx;
5204
+ // 必须走 stableRemote:真实宿主里 ctx.get() 每次返回新对象,进依赖数组会无限重跑。
5205
+ const remote = stableRemote(ctx, "remote.memory");
5206
+ const sOn = react.useState(true); const on = sOn[0], setOn = sOn[1];
5207
+ const sBusy = react.useState(false); const busy = sBusy[0], setBusy = sBusy[1];
5208
+ const sErr = react.useState(""); const err = sErr[0], setErr = sErr[1];
5209
+
5210
+ react.useEffect(function () {
5211
+ if (!remote || typeof remote.getSettings !== "function") return undefined;
5212
+ let alive = true;
5213
+ Promise.resolve(remote.getSettings()).then(function (out) {
5214
+ if (!alive) return;
5215
+ const loaded = unwrap(out) || {};
5216
+ // 旧宿主没有 ui 字段 / 值不是布尔 → 保持默认「开」,不弹错、不阻塞界面。
5217
+ if (loaded.ui && typeof loaded.ui.sidebarEntry === "boolean") setOn(loaded.ui.sidebarEntry);
5218
+ }, function () {
5219
+ // 读设置失败也按「开」显示:这里只是插件页的一张卡片,不该因为它报红。
5220
+ });
5221
+ return function () { alive = false; };
5222
+ }, [remote]);
5223
+
5224
+ // summary 位不渲染(hooks 必须在上方无条件调用完再返回,规则与顺序都不变)。
5225
+ if (props && props.view === "summary") return null;
5226
+
5227
+ function toggle(next) {
5228
+ if (busy) return;
5229
+ if (!remote || typeof remote.setSettings !== "function") {
5230
+ setErr("宿主侧还没有 setSettings 接口(插件版本较旧)");
5231
+ return;
5232
+ }
5233
+ setBusy(true); setErr("");
5234
+ Promise.resolve(remote.setSettings({ ui: { sidebarEntry: !!next } })).then(function (out) {
5235
+ setBusy(false);
5236
+ // setSettings 是 { ok, settings, restartRequired } 形状,不要过 unwrap。
5237
+ if (out && out.ok === false) {
5238
+ setErr(out.error || "保存被拒绝");
5239
+ return;
5240
+ }
5241
+ const settings = out && out.settings ? out.settings : null;
5242
+ const applied = settings && settings.ui && typeof settings.ui.sidebarEntry === "boolean"
5243
+ ? settings.ui.sidebarEntry
5244
+ : !!next;
5245
+ setOn(applied);
5246
+ // 写盘成功之后才动槽位:设置没落盘时界面不该先变一个样。
5247
+ setSidebarEntry(!!next);
5248
+ }, function (e) {
5249
+ setBusy(false);
5250
+ setErr(pluginCardErr(e));
5251
+ });
5252
+ }
5253
+
5254
+ return h("div", { className: "smem-plugin-card" }, [
5255
+ h("div", { className: "smem-plugin-card-head", key: "h" }, [
5256
+ h("span", { className: "smem-plugin-card-title", key: "t" }, "侧栏入口"),
5257
+ h("span", { className: "smem-plugin-card-status", key: "s" }, on ? "已开启" : "已关闭"),
5258
+ ]),
5259
+ h("label", { className: "smem-check smem-plugin-card-check", key: "row" }, [
5260
+ h("input", {
5261
+ type: "checkbox", key: "c",
5262
+ checked: on,
5263
+ disabled: busy,
5264
+ onChange: function (e) { toggle(!!(e.target && e.target.checked)); },
5265
+ }),
5266
+ h("span", { key: "l" }, "在侧栏显示「记忆管理」入口"),
5267
+ ]),
5268
+ h("div", { className: "smem-plugin-card-note", key: "n" },
5269
+ "默认开启;改动立即生效,不需要重启。关闭后「设置 → 记忆管理」入口仍然保留。"),
5270
+ err ? h("div", { className: "smem-plugin-card-err", key: "e" }, err) : null,
5271
+ ]);
5272
+ }
5273
+
5274
+ /**
5275
+ * 返回会话:宿主 layout 服务的 `selectPanel(null)` 就是把中央主区还给 Conversation。
5276
+ * 取不到 layout(旧外壳 / 测试环境)时是**空操作**,不影响页面其它部分。
5277
+ */
5278
+ function backToConversation() {
5279
+ try {
5280
+ const layout = sidebarCtx && typeof sidebarCtx.get === "function" ? sidebarCtx.get("layout") : null;
5281
+ if (layout && typeof layout.selectPanel === "function") layout.selectPanel(null);
5282
+ } catch (e) {
5283
+ // 返回按钮不能因为宿主差异把整页搞崩
5284
+ }
5285
+ }
5286
+
5287
+ /** 中央主区的整页:页顶「返回会话」+ 复用设置页那份 Section。 */
5288
+ function MemoryPanelPage(props) {
5289
+ const onBack = typeof props.backToConversation === "function" ? props.backToConversation : backToConversation;
5290
+ return h("div", { className: "smem-page" }, [
5291
+ h("div", { className: "smem-page-top", key: "top" }, [
5292
+ h("button", {
5293
+ type: "button", className: "smem-page-back", key: "back",
5294
+ title: "返回会话", onClick: onBack,
5295
+ }, [
5296
+ h("svg", {
5297
+ key: "i", width: 14, height: 14, viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true",
5298
+ }, h("path", {
5299
+ d: "M10 3.5 5.5 8l4.5 4.5", stroke: "currentColor", strokeWidth: "1.6",
5300
+ strokeLinecap: "round", strokeLinejoin: "round",
5301
+ })),
5302
+ h("span", { key: "l" }, "返回会话"),
5303
+ ]),
5304
+ ]),
5305
+ h(Section, { key: "sec", ...props, ctx: sidebarCtx }),
5306
+ ]);
5307
+ }
5308
+
5309
+ /** 注册两个槽位(幂等:已经注册就什么都不做)。 */
5310
+ function registerSidebarEntry() {
5311
+ if (sidebarDispose !== null || !sidebarCtx) return true;
5312
+ const ctx = sidebarCtx;
5313
+ const handles = [];
5314
+ // 侧栏 list 槽:只给图标与 label,按钮由宿主自己画。
5315
+ handles.push(ctx.slots.inject("sidebar.panellist", function () {
5316
+ return ctx.slots.register({
5317
+ name: "sidebar.panellist",
5318
+ id: SIDEBAR_ENTRY_ID,
5319
+ order: 20,
5320
+ label: "记忆管理",
5321
+ }, SidebarEntryIcon);
5322
+ }));
5323
+ // 中央主区 keyed 槽:key 与侧栏 id 相同;inject 面把「返回会话」交给页面组件。
5324
+ handles.push(ctx.slots.inject("main", function () {
5325
+ return ctx.slots.register({
5326
+ name: "main",
5327
+ key: SIDEBAR_ENTRY_ID,
5328
+ inject: function () { return { backToConversation: backToConversation }; },
5329
+ }, MemoryPanelPage);
5330
+ }));
5331
+ sidebarDispose = handles;
5332
+ return true;
5333
+ }
5334
+
5335
+ /**
5336
+ * 开 / 关侧栏入口(立即生效,不改设置文件 —— 写盘由设置页负责)。
5337
+ *
5338
+ * 关闭顺序是**反序**:先摘 main、再摘 sidebar,避免宿主在「侧栏条目还在、main 已没了」
5339
+ * 的中间态里去解析一个不存在的 key(官方契约里那会抛错)。
5340
+ */
5341
+ function setSidebarEntry(enabled) {
5342
+ if (enabled) return registerSidebarEntry();
5343
+ const handles = sidebarDispose;
5344
+ sidebarDispose = null;
5345
+ if (Array.isArray(handles)) {
5346
+ for (let i = handles.length - 1; i >= 0; i--) {
5347
+ const dispose = handles[i];
5348
+ if (typeof dispose !== "function") continue;
5349
+ try { dispose(); } catch (e) { /* 注销失败也不该把开关卡死在「已注册」状态 */ }
5350
+ }
5351
+ }
5352
+ return true;
5353
+ }
5354
+ // ⚠️ 不把 `layout` 写进 inject:它是**可选**依赖(官方 inspect:optional access =
5355
+ // `ctx.get("layout")` + 判空;hardDependency 才是 inject)。写进 inject 会让旧外壳
5356
+ // 缺这个服务时整个 client 插件不 apply —— 连带丢掉设置页。返回会话优先用宿主给的
5357
+ // `props.backToConversation`,layout 只是兜底。
5060
5358
  const inject = ["slots", "remote"];
5061
5359
 
5062
5360
  /**
5063
5361
  * Client 插件:$mount strict remote contribution(第三方 remote 不在
5064
- * 宿主的固定 remote 清单里,必须自己挂),再注册设置页签。
5362
+ * 宿主的固定 remote 清单里,必须自己挂),再注册设置页签 / 侧栏入口。
5065
5363
  */
5066
5364
  async function apply(ctx) {
5067
5365
  await ctx.remote.$mount(TYPERT_REMOTE);
@@ -5089,6 +5387,44 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
5089
5387
  (props) => react.createElement(Section, { ...props, ctx }),
5090
5388
  ),
5091
5389
  );
5390
+ // ── 1.8.0 侧栏入口:先记住 ctx,再读一次设置决定注不注册 ──────────────
5391
+ // 设置页 section **永远**保留(关闭侧栏入口只是少一个入口,不是少一个设置页)。
5392
+ // 读失败 / 旧宿主没有 ui 字段 / 值坏掉 → 一律按「开」处理,绝不阻塞界面。
5393
+ sidebarCtx = ctx;
5394
+ let sidebarEnabled = true;
5395
+ try {
5396
+ const mem = typeof ctx.get === "function" ? ctx.get("remote.memory") : null;
5397
+ if (mem && typeof mem.getSettings === "function") {
5398
+ const loaded = unwrap(await mem.getSettings()) || {};
5399
+ if (loaded.ui && loaded.ui.sidebarEntry === false) sidebarEnabled = false;
5400
+ }
5401
+ } catch (e) {
5402
+ sidebarEnabled = true;
5403
+ }
5404
+ setSidebarEntry(sidebarEnabled);
5405
+
5406
+ // ── 1.8.2 插件详情页的「侧栏入口」卡片 ──────────────────────────────
5407
+ // `plugins.bundle.config` 是 keyed 槽,key = 本 bundle 的 **package name**(sage-mem)。
5408
+ // 宿主在插件页的 description 与「包含的组件」行之间渲染它;ownerProps 形如
5409
+ // `{ view: "summary" | "page" }`,卡片里自己再判一次(summary 返回 null)。
5410
+ //
5411
+ // ⚠️ 这个槽 0.1.7+ 才有。旧宿主上 inject 会**抛错**,所以必须整段 try/catch ——
5412
+ // 否则一个可选的插件页卡片会把设置页与侧栏入口一起拖垮(整个 client 插件不 apply)。
5413
+ // 放在 apply 最后:即使这里因宿主差异出问题,前面的界面已经挂上去了。
5414
+ try {
5415
+ if (ctx.slots && typeof ctx.slots.inject === "function") {
5416
+ ctx.slots.inject("plugins.bundle.config", function () {
5417
+ return ctx.slots.register({
5418
+ name: "plugins.bundle.config",
5419
+ key: "sage-mem",
5420
+ }, function (props) {
5421
+ return react.createElement(SidebarEntryConfigCard, { ...(props || {}), ctx });
5422
+ });
5423
+ });
5424
+ }
5425
+ } catch (e) {
5426
+ // 旧宿主没有这个槽 / 不接受注册:静默跳过,绝不影响其余界面。
5427
+ }
5092
5428
  }
5093
5429
 
5094
5430
  exports.apply = apply;
package/lib/index.js CHANGED
@@ -32,7 +32,7 @@ import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
32
32
  import { mkdir, unlink, writeFile } from 'node:fs/promises'
33
33
  import { join } from 'node:path'
34
34
  import { checkUpdate as checkUpdateImpl } from './update.js'
35
- import { autodreamConfigSnapshot, clampInt, MAX_RAW_BYTES, pendingLimits, rawName, readReservedFile, readSettingsFile, RESERVED_FILE, SETTINGS_FILE } from './settings.js'
35
+ import { autodreamConfigSnapshot, clampInt, MAX_RAW_BYTES, parseUiPatch, pendingLimits, pendingUi, rawName, readReservedFile, readSettingsFile, RESERVED_FILE, SETTINGS_FILE } from './settings.js'
36
36
  import { createRecallInjector, createSessionInjectState, readRecallDebug } from './recall/index.js'
37
37
  import { mountAutodream, mountMemoryTools, mountObserve, mountWriteGate } from './assembly/index.js'
38
38
 
@@ -451,6 +451,8 @@ export class MemoryGateway extends TypertRemoteService {
451
451
  limits,
452
452
  pendingLimits: pending,
453
453
  restartRequired,
454
+ // 1.8.0:客户端行为(侧栏入口开关)。**不参与** restartRequired —— 改完立刻生效。
455
+ ui: pendingUi(file),
454
456
  // 生效的保护名单(含两个内置名)+ 用户声明的那部分原文(面板编辑的是后者)
455
457
  reserved: [...new Set([INDEX_FILE_KEY, SESSION_LOG_KEY, ...reservedExtra.map((s) => s.toLowerCase())])],
456
458
  reservedExtra,
@@ -495,19 +497,31 @@ export class MemoryGateway extends TypertRemoteService {
495
497
  }
496
498
  nextReserved = cleaned
497
499
  }
498
- if (!limitsPatch && nextReserved === null) return { ok: false, error: 'sage-mem: empty patch' }
500
+ // 1.8.0:ui 与 limits / reserved 同一套「先全验、后一起写」—— 任何一个非法,整份 patch 不落盘。
501
+ const uiCheck = parseUiPatch(p.ui)
502
+ if (!uiCheck.ok) return { ok: false, error: uiCheck.error }
503
+ const uiPatch = uiCheck.patch
504
+ if (!limitsPatch && nextReserved === null && uiPatch === null) return { ok: false, error: 'sage-mem: empty patch' }
499
505
 
500
506
  await mkdir(STATE_ROOT, { recursive: true })
501
- if (limitsPatch) {
507
+ if (limitsPatch || uiPatch !== null) {
502
508
  const current = await readSettingsFile()
503
- const merged = { ...(current?.limits ?? {}), ...nextLimits }
504
- await writeFileAtomic(SETTINGS_FILE, JSON.stringify({ limits: merged, updatedAt: Date.now() }, null, 2))
509
+ const mergedLimits = { ...(current?.limits ?? {}), ...nextLimits }
510
+ // ui 只覆盖这次给出的键;没给就沿用文件里的有效值(坏值 / 缺失一律按默认「开」)
511
+ const mergedUi = { ...pendingUi(current), ...(uiPatch ?? {}) }
512
+ await writeFileAtomic(SETTINGS_FILE, JSON.stringify({ limits: mergedLimits, ui: mergedUi, updatedAt: Date.now() }, null, 2))
505
513
  }
506
514
  if (nextReserved !== null) {
507
515
  await writeFileAtomic(RESERVED_FILE, `${JSON.stringify(nextReserved, null, 2)}\n`)
508
516
  }
509
- // 写盘即提示重启:这些值在模块加载时就固化了,不重启它一定还没生效。
510
- return { ok: true, settings: await this.getSettings(), restartRequired: true }
517
+ // 上限 / 保护名单在模块加载时就固化了,写盘即提示重启;ui 是**客户端行为**,改完立刻生效,
518
+ // 所以只有前两者会强制 true,纯 ui 补丁照实回传(通常就是 false)。
519
+ const settings = await this.getSettings()
520
+ return {
521
+ ok: true,
522
+ settings,
523
+ restartRequired: limitsPatch || nextReserved !== null ? true : settings.restartRequired,
524
+ }
511
525
  }
512
526
 
513
527
  /**
package/lib/settings.js CHANGED
@@ -20,6 +20,39 @@ export const RESERVED_FILE = join(STATE_ROOT, 'reserved.json')
20
20
  /** 整数收敛(与 config.js 的 envInt 同一套语义):非有限数回落默认,越界夹到边界。 */
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
+ /**
24
+ * 面板 UI 设置(1.8.0)。
25
+ *
26
+ * ⚠️ 与 `limits` **不是一类**:上限在模块加载时就固化进常量,改完必须重启;
27
+ * `ui.sidebarEntry` 是**客户端行为** —— 界面读到值就决定注不注册侧栏槽位,
28
+ * 改完立刻生效。所以它不进 `restartRequired` 的计算。
29
+ */
30
+ export const DEFAULT_UI = Object.freeze({ sidebarEntry: true })
31
+
32
+ /** 读出 `ui` 的**有效值**:文件里没有 / 坏对象 / 不是布尔 → 一律回落默认(开)。 */
33
+ export function pendingUi(fileObj) {
34
+ 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 }
36
+ }
37
+
38
+ /**
39
+ * 校验 `setSettings({ ui })` 的那一段:**只认 `sidebarEntry` 一个键**,值必须是布尔。
40
+ *
41
+ * 返回 `{ ok:true, patch }`(patch 为 null = 调用方没给 ui,或给的是空对象);`{ ok:false, error }`。
42
+ * 与 limits 同一套严格语义:绝不静默丢掉不认识的键。
43
+ */
44
+ export function parseUiPatch(ui) {
45
+ if (ui === undefined) return { ok: true, patch: null }
46
+ if (!ui || typeof ui !== 'object' || Array.isArray(ui)) return { ok: false, error: 'sage-mem: ui must be an object' }
47
+ const patch = {}
48
+ 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
52
+ }
53
+ return { ok: true, patch: Object.keys(patch).length ? patch : null }
54
+ }
55
+
23
56
  /** 读 `<状态根>/settings.json`;缺失、坏 JSON、不是对象都当「没设置」。 */
24
57
  export async function readSettingsFile() {
25
58
  try {
@@ -222,10 +222,15 @@ const settingsLimitsSchema = z.object({
222
222
  'maxSessionBytes': z.number().readonly(),
223
223
  'staleDays': z.number().readonly(),
224
224
  }).readonly()
225
+ // 1.8.0:面板 UI 设置。`sidebarEntry` 是**客户端行为**(改完立刻生效),不进 restartRequired。
226
+ const settingsUiSchema = z.object({
227
+ 'sidebarEntry': z.boolean().readonly(),
228
+ }).readonly()
225
229
  const settingsSchema = z.object({
226
230
  'limits': settingsLimitsSchema,
227
231
  'pendingLimits': settingsLimitsSchema,
228
232
  'restartRequired': z.boolean().readonly(),
233
+ 'ui': settingsUiSchema,
229
234
  'reserved': z.array(z.string()).readonly(),
230
235
  'reservedExtra': z.array(z.string()).readonly(),
231
236
  }).readonly()
@@ -237,6 +242,9 @@ const settingsPatchSchema = z.object({
237
242
  'maxSessionBytes': z.number().optional(),
238
243
  'staleDays': z.number().optional(),
239
244
  }).readonly().optional(),
245
+ 'ui': z.object({
246
+ 'sidebarEntry': z.boolean().optional(),
247
+ }).readonly().optional(),
240
248
  'reserved': z.array(z.string()).optional(),
241
249
  }).readonly()
242
250
  // 成功回 { ok, settings, restartRequired },失败回 { ok:false, error } —— 两条分支并集。
package/package.json CHANGED
@@ -1,9 +1,10 @@
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": "1.7.2",
4
+ "version": "1.8.2",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
+ "icon": "./icon.png",
7
8
  "exports": {
8
9
  ".": "./lib/index.js",
9
10
  "./client": "./lib/client.js",
@@ -13,7 +14,8 @@
13
14
  "files": [
14
15
  "lib/",
15
16
  "cordis.patch.yml",
16
- "NOTICE"
17
+ "NOTICE",
18
+ "icon.png"
17
19
  ],
18
20
  "scripts": {
19
21
  "test": "node test/run.mjs",