sage-mem 2.7.1 → 2.9.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.7.1`** — 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.9.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.
@@ -165,7 +165,7 @@ The state directory (configuration, ledger, rollback points, conflict queue) def
165
165
 
166
166
  Every image below is **rendered from the UI code in this repository** (the same CSS, the same component tree; the data is demo data for illustration, not a real memory directory).
167
167
 
168
- Since 2.5.0 the panel has **four tabs: Files | Timeline | Automation | Health** — split by what you want to do, not by which data source feeds it: autodream and auto-archive are two cards on one "Automation" screen, conflicts and the audit are two cards on one "Health" screen. Editing opens a right-hand drawer, and every action that touches a file on disk asks in a modal dialog.
168
+ Since 2.5.0 the panel has four tabs: Files | Timeline | Automation | Health; 2.9.0 adds a fifth, **Lab** — split by what you want to do, not by which data source feeds it: autodream and auto-archive are two cards on one "Automation" screen, conflicts and the audit are two cards on one "Health" screen. Editing opens a right-hand drawer, and every action that touches a file on disk asks in a modal dialog.
169
169
 
170
170
  Since 1.9.0 the memory-management surface follows the harness language (**Settings → General → Language**); memory content itself stays in whatever language it was written in, 2.1.0 extended that to the autodream (run / settings / records) and auto-archive cards; the timeline / star-map screen is the only panel still Chinese until the next i18n batch.
171
171
 
@@ -193,6 +193,8 @@ Since 1.9.0 the memory-management surface follows the harness language (**Settin
193
193
 
194
194
  *Health — one screen answers "is anything wrong with this library": the **conflict queue on top** (a finite list that needs your decision: which of two contradictory statements is void) and the **audit below** (dangling index entries, missing index entries, broken links and the rest, reported in red by category). "Points to an archived memory" is only a hint — it does not count as a problem, and there is **no one-click fix**: which one to change is your call. The audit only reports and never modifies. When there is really something to deal with, the badge gets a **「复制修复提示词」(copy fix prompt)** button next to it (2.6.0): it turns what this screen needs into one prompt in your clipboard — **the audit problems and the pending conflicts are written as two separate sections** (fix these / only advise on those), so you can paste it to the agent and let it work. A receipt appears at the bottom afterwards — it closes itself in 10 seconds, closes as soon as you switch tabs, and you can dismiss it by hand.*
195
195
 
196
+ *Lab (2.9.0, sits after Health) — only switches that change **which memories are picked**, all off by default. Today it holds one: **MMR de-duplication**. When one topic takes every injection slot, it subtracts each candidate's similarity to what is already picked before choosing. It only changes **which items are picked** (never the score, never a file, never a pinned baseline memory) and returns candidates as-is when they fit the slots. Why it is off by default: in the offline comparison it never fired (the IDF gate already keeps candidates under 10), and in a stress run with the gate disabled duplication fell only 4.5% while wrong picks rose 24% — it knows "different", not "irrelevant". Read the three notes on that page before turning it on; **if memories seem to go missing, switch it off and restart**.
197
+
196
198
  ![Update notice: a single banner, only when there really is a new version](https://raw.githubusercontent.com/gezi-wen/sage-mem/main/docs/images/update-banner.png)
197
199
 
198
200
  *Update notice — a single banner appears only when there **really is a new version**, naming the new version and the current one; 「看更新内容」(see what's new) opens a new tab, and "×" just means not this time.*
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.7.1`** —— 顶部 npm 徽章是动态权威。**契约基线:`1.0.0`** —— 第一个稳定版,公开承诺、能力边界与「明确做不到」的清单冻结在
17
+ **当前发布版本:`2.9.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 是最直接的反馈。
@@ -165,7 +165,7 @@ SAGE_MEM_DIR=/path/to/your/memory
165
165
 
166
166
  下面每张图都是**从仓库里的界面代码渲染出来的**(同一份 CSS、同一棵组件树;数据是演示用的假数据,不是真实记忆目录)。
167
167
 
168
- 2.5.0 起面板是**四个页签:文件列表 | 时间线 | 自动整理 | 库健康** —— 按「我要干什么」切,不按「数据从哪来」切:自动做梦与自动归档并成一屏的两张卡,冲突与体检也并成一屏的两张卡。编辑走右侧抽屉,凡是会动盘上文件的动作都先弹模态框问一句。
168
+ 2.5.0 起面板是四个页签:文件列表 | 时间线 | 自动整理 | 库健康;2.9.0 加了第五个**实验性** —— 按「我要干什么」切,不按「数据从哪来」切:自动做梦与自动归档并成一屏的两张卡,冲突与体检也并成一屏的两张卡。编辑走右侧抽屉,凡是会动盘上文件的动作都先弹模态框问一句。
169
169
 
170
170
  1.9.0 起,记忆管理这一块跟随宿主语言(**设置 → 通用 → 语言**);记忆内容本身保持它被写下时的语言,2.1.0 起扩展到自动做梦(运行 / 设置 / 记录)与自动归档两张卡;只剩时间线/星图那一屏仍是中文,等下一批 i18n。
171
171
 
@@ -193,6 +193,8 @@ SAGE_MEM_DIR=/path/to/your/memory
193
193
 
194
194
  *库健康 —— 一屏回答「这个库有没有事」:**冲突队列在上**(要你裁决的有限队列:两条说法互斥,选哪一方作废),**体检在下**(索引悬空、漏索引、双链断链等结构问题按类报红)。「指向已归档」只作提示、不计入问题;也**不提供一键修补** —— 改哪条由你决定。体检只报告、不修改。真有事要处理时,徽标后面会多一颗**「复制修复提示词」**(2.6.0):把这一屏要处理的事整理成一段提示词放进剪贴板 —— **体检问题与待裁决冲突分两段写**(前者照清单修、后者只给建议),粘给智能体就能照着办;复制成功后下方弹一条提示,10 秒自收、换页签即收、也能自己关掉。*
195
195
 
196
+ *实验性(2.9.0,排在库健康之后)—— 只给「会改变选出哪些记忆」的开关,默认全关。当前只有一项 **MMR 去重**:注入名额被同一件事的多份记忆占满时,先扣掉候选与已选中那些的相似度再挑。它**只改挑哪几条**(不改打分、不改文件、不碰锁定的 baseline 记忆),候选不够名额时原样返回。为什么默认关:离线对照里它一次都没触发(证据门已把候选压到 10 条以内),而关掉证据门的压力测试中重复度只降 4.5%、误注入却涨了 24% —— 它只知道「不像」,不知道「不相关」。打开前请读那一页的三条声明;**若觉得记忆变少了,关掉它、重启即可恢复**。*
197
+
196
198
  ![更新提示:只在真有新版本时出现一条横幅](https://raw.githubusercontent.com/gezi-wen/sage-mem/main/docs/images/update-banner.png)
197
199
 
198
200
  *更新提示 —— 只在**真有新版本**时出现一条横幅,写明新版本与当前版本;「看更新内容」开新标签页,「×」只是这次不看。*
package/lib/client.js CHANGED
@@ -2679,6 +2679,8 @@ const StarmapRuntime = (function (require, _react) {
2679
2679
  ".smem-autodream-note{font-size:11.5px;color:var(--dsw-alias-label-secondary);line-height:1.5;}",
2680
2680
  // 2.7.1(Q04):上限设置的诊断清单 —— 逐条列宿主的原话,缩进浅一点、行距紧一点。
2681
2681
  ".smem-diag-list{margin:4px 0 4px 16px;padding:0;font-size:11.5px;line-height:1.6;color:var(--dsw-alias-label-secondary);}",
2682
+ // 2.8.0(子槽):第三方向 settings.memory.starmap 注入的视图挂在这里,空的时候不占位置。
2683
+ ".smem-extra-slot{margin-top:14px;padding-top:12px;border-top:0.5px solid var(--dsw-alias-border-l2);}",
2682
2684
  ".smem-autodream-list{display:flex;flex-direction:column;gap:6px;}",
2683
2685
  ".smem-report{border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:6px 9px;font-size:12px;cursor:pointer;background:transparent;color:inherit;text-align:left;font-family:inherit;}",
2684
2686
  ".smem-report:hover{border-color:var(--dsw-alias-border-l2);}",
@@ -4126,7 +4128,32 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
4126
4128
  "tab.star": "时间线",
4127
4129
  "tab.auto": "自动整理",
4128
4130
  "tab.health": "库健康",
4129
-
4131
+ "tab.lab": "实验性",
4132
+ "lab.title": "MMR 去重(实验性)",
4133
+ "lab.switch": "启用 MMR 去重",
4134
+ "lab.switch.onTitle": "现在开启:注入时优先让不同主题的记忆占名额",
4135
+ "lab.switch.offTitle": "现在关闭:注入完全按相关度排(与 2.8.0 一致)",
4136
+ "lab.what1": "注入名额只有几条,而相关度排序会让同一件事的多份记忆把名额占满。MMR 在挑每一条之前,先扣掉它与已挑中那些的相似度,于是「相关但不同」的记忆也能进来。",
4137
+ "lab.what2": "它只改「挑哪几条」:不改相关度打分、不改文件、不改记忆内容;候选不够名额时原样返回。",
4138
+ "lab.strength": "去重强度",
4139
+ "lab.unit": "%",
4140
+ "lab.hint.strength": "50–90 之间比较合适:数值越高越接近原始排序,越低越激进地避开重复。默认 70。",
4141
+ "lab.note.restart": "当前生效 {active}%、待生效 {pending}%;改完需重启 DSH(记忆内容不受影响)。",
4142
+ "lab.status.on": "已开 · {value}%",
4143
+ "lab.status.off": "关闭(默认)",
4144
+ "lab.action.save": "保存",
4145
+ "lab.action.saving": "保存中…",
4146
+ "lab.notice.on": "已开启({value}%),重启 DSH 后生效",
4147
+ "lab.notice.off": "已关闭,重启 DSH 后恢复「完全按相关度排序」",
4148
+ "lab.notice.strengthOn": "已设为 {value}%,重启 DSH 后生效",
4149
+ "lab.err.noSettings": "宿主没有提供设置接口(版本偏旧)",
4150
+ "lab.err.rejected": "保存被拒绝",
4151
+ "lab.err.integer": "去重强度要填 0–100 的整数",
4152
+ "lab.err.range": "去重强度超出 0–100",
4153
+ "lab.declare.title": "实验性功能 —— 请按需开关",
4154
+ "lab.declare.forget": "若出现失忆(该出现的记忆没出现),请先关掉这一页的开关;重启后即恢复原来的行为。",
4155
+ "lab.declare.feedback": "作者希望看到 issue 或 PR 反馈:把你遇到的问句、少了哪条记忆贴上去就够。",
4156
+ "lab.declare.default": "默认关闭;开关只影响「下一次注入挑哪几条」,不碰任何记忆文件。",
4130
4157
  "type.user": "用户",
4131
4158
  "type.feedback": "反馈",
4132
4159
  "type.project": "项目",
@@ -4268,6 +4295,7 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
4268
4295
  "entry.sidebarUnsupported": "当前宿主不支持侧栏入口。",
4269
4296
  "entry.degraded": "当前宿主不支持侧栏入口,已临时按「只显示设置页」显示。",
4270
4297
  "entry.legacyHost": "宿主模块还是旧版本,重启 DSH 后可选「只显示侧栏」。",
4298
+ "entry.sidebarHidesSlot": "选这一档后,第三方通过「记忆星图」子槽注入的视图也会一起藏掉 —— 那一块挂在设置页上。",
4271
4299
  "entry.notice.saved": "界面入口已更新。",
4272
4300
  "entry.err.noSetSettings": "宿主侧还没有 setSettings 接口(插件版本较旧)",
4273
4301
  "entry.err.saveFailed": "保存失败(未知原因)",
@@ -4635,7 +4663,32 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
4635
4663
  "tab.star": "Timeline",
4636
4664
  "tab.auto": "Automation",
4637
4665
  "tab.health": "Health",
4638
-
4666
+ "tab.lab": "Lab",
4667
+ "lab.title": "MMR de-duplication (experimental)",
4668
+ "lab.switch": "Enable MMR de-duplication",
4669
+ "lab.switch.onTitle": "On: injection prefers memories on different topics for the same slots",
4670
+ "lab.switch.offTitle": "Off: injection ranks by relevance alone (same as 2.8.0)",
4671
+ "lab.what1": "Only a few slots are injected, and ranking by relevance alone lets several memories about one thing take them all. Before each pick, MMR subtracts its similarity to what is already chosen, so relevant-but-different memories can get in.",
4672
+ "lab.what2": "It only changes which items are picked: not the relevance score, not files, not memory content; when candidates fit the slots it returns them as-is.",
4673
+ "lab.strength": "Strength",
4674
+ "lab.unit": "%",
4675
+ "lab.hint.strength": "50-90 works well: higher stays closer to the original order, lower avoids duplicates more aggressively. Default 70.",
4676
+ "lab.note.restart": "Active {active}%, pending {pending}%; restart DSH to apply (memory content is untouched).",
4677
+ "lab.status.on": "On · {value}%",
4678
+ "lab.status.off": "Off (default)",
4679
+ "lab.action.save": "Save",
4680
+ "lab.action.saving": "Saving…",
4681
+ "lab.notice.on": "Enabled ({value}%), takes effect after restarting DSH",
4682
+ "lab.notice.off": "Disabled; after restarting DSH, injection ranks by relevance alone again",
4683
+ "lab.notice.strengthOn": "Set to {value}%, takes effect after restarting DSH",
4684
+ "lab.err.noSettings": "This host exposes no settings API (older version)",
4685
+ "lab.err.rejected": "Save was rejected",
4686
+ "lab.err.integer": "Strength must be an integer between 0 and 100",
4687
+ "lab.err.range": "Strength is outside 0-100",
4688
+ "lab.declare.title": "Experimental — turn it on or off as you like",
4689
+ "lab.declare.forget": "If memories go missing, switch this page off first; restarting restores the previous behaviour.",
4690
+ "lab.declare.feedback": "Issues and pull requests are welcome: paste the question you asked and which memory went missing.",
4691
+ "lab.declare.default": "Off by default; the switch only affects which items the next injection picks, never a memory file.",
4639
4692
  "type.user": "User",
4640
4693
  "type.feedback": "Feedback",
4641
4694
  "type.project": "Project",
@@ -4777,6 +4830,7 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
4777
4830
  "entry.sidebarUnsupported": "This host does not support the sidebar entry.",
4778
4831
  "entry.degraded": "The sidebar entry is unavailable on this host, so the Settings entry is shown instead.",
4779
4832
  "entry.legacyHost": "The host module is still the old version; restart DSH to choose Sidebar only.",
4833
+ "entry.sidebarHidesSlot": "With this mode, views that third-party plugins inject into the “memory starmap” sub-slot disappear too — they live on the Settings page.",
4780
4834
  "entry.notice.saved": "Interface entries updated.",
4781
4835
  "entry.err.noSetSettings": "The host has no setSettings API yet (older plugin version)",
4782
4836
  "entry.err.saveFailed": "Save failed (unknown reason)",
@@ -6054,6 +6108,12 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
6054
6108
  ? h("div", { className: "smem-autodream-note smem-ad-warn", key: "deg" },
6055
6109
  t(entryApiModern === false ? "entry.legacyHost" : "entry.degraded"))
6056
6110
  : null,
6111
+ // 2.8.0:子槽接上渲染之后,「只显示侧栏」会连带把别人注入进来的视图一起藏掉
6112
+ // (设置页 section 一注销,挂在它下面的 `settings.memory.starmap` 就没了)。
6113
+ // 这不是 bug,但选这一档的人得先知道 —— 所以只在选中的时候写一句。
6114
+ entryModeValue === "sidebar" && !entryIsDegraded
6115
+ ? h("div", { className: "smem-autodream-note", key: "slotloss" }, t("entry.sidebarHidesSlot"))
6116
+ : null,
6057
6117
  entryErr ? h("div", { className: "smem-autodream-note smem-ad-warn", key: "err" }, entryErr) : null,
6058
6118
  ]),
6059
6119
  // 自动归档那一段(三档策略 + 三个阈值)**已挪到「自动归档」页签**(v0.9.9):
@@ -6524,6 +6584,153 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
6524
6584
  notice ? h("div", { className: "smem-ok", key: "ok" }, "✓ " + notice) : null,
6525
6585
  ]);
6526
6586
  }
6587
+ /**
6588
+ * 「实验性设置」一屏(2.9.0)—— 只放**会改变选材**的开关,不放在注入上限那一栏。
6589
+ *
6590
+ * 为什么单开一屏而不是塞进「自动做梦 → 设置」:
6591
+ * 注入上限改的是**预算**(多给或少给上下文),这一屏改的是**选材**
6592
+ * (哪些记忆被端上来)。后者出事的样子是「该来的那条没来」,也就是失忆 ——
6593
+ * 用户必须能一眼找到这是实验性的、并且知道怎么关掉。
6594
+ *
6595
+ * 目前只有一个开关:**MMR 去重**(注入名额有限时,同族记忆不再互相挤占名额)。
6596
+ * 语义与三道护栏在宿主侧 `lib/memory/mmr.js`;这里只负责读写与说清楚代价。
6597
+ *
6598
+ * 数据全走已有的 `remote.memory.getSettings` / `setSettings`:
6599
+ * - 读回的是 `{ experimental, pendingExperimental, restartRequired, ... }`;
6600
+ * - `setSettings` 的返回是 `{ ok, settings, restartRequired }`,**不要过 unwrap**
6601
+ * (unwrap 只认 `{ok, value}`,会把 settings 吃掉 —— 60-autodream-panel 里有同一条)
6602
+ * - 生效值是**模块加载时**读的 → 改完要重启 DSH,界面必须诚实地说这一句。
6603
+ */
6604
+ function LabSettingsPanel(props) {
6605
+ const memRemote = stableRemote(props.ctx, "remote.memory");
6606
+ const sSet = react.useState(null); const memSet = sSet[0], setMemSet = sSet[1];
6607
+ const sDraft = react.useState(""); const draft = sDraft[0], setDraft = sDraft[1];
6608
+ const sErr = react.useState(null); const err = sErr[0], setErr = sErr[1];
6609
+ const sNotice = react.useState(""); const notice = sNotice[0], setNotice = sNotice[1];
6610
+ const sBusy = react.useState(false); const busy = sBusy[0], setBusy = sBusy[1];
6611
+
6612
+ /** 安全取百分比:非数字一律当 0(= 关闭),与宿主侧的收敛语义一致。 */
6613
+ const pct = function (v) {
6614
+ const n = Number(v);
6615
+ return Number.isFinite(n) && n > 0 ? Math.round(n) : 0;
6616
+ };
6617
+ const active = pct(memSet && memSet.experimental && memSet.experimental.mmr);
6618
+ const pending = memSet && memSet.pendingExperimental
6619
+ ? pct(memSet.pendingExperimental.mmr)
6620
+ : active;
6621
+
6622
+ function load() {
6623
+ if (!memRemote || typeof memRemote.getSettings !== "function") {
6624
+ setErr(t("lab.err.noSettings"));
6625
+ return;
6626
+ }
6627
+ Promise.resolve(memRemote.getSettings()).then(function (res) {
6628
+ const d = unwrap(res) || {};
6629
+ setMemSet(d);
6630
+ const p = d.pendingExperimental ? pct(d.pendingExperimental.mmr) : pct(d.experimental && d.experimental.mmr);
6631
+ setDraft(String(p));
6632
+ }, function (e) { setErr(String((e && e.message) || e)); });
6633
+ }
6634
+
6635
+ react.useEffect(function () { load(); }, []);
6636
+
6637
+ /**
6638
+ * 写一项实验性设置。
6639
+ * @param {number} mmr 0~100
6640
+ * @param {string} okMsg 成功回执
6641
+ */
6642
+ function save(mmr, okMsg) {
6643
+ if (!memRemote || typeof memRemote.setSettings !== "function") { setErr(t("lab.err.noSettings")); return; }
6644
+ setBusy(true); setErr(null); setNotice("");
6645
+ Promise.resolve(memRemote.setSettings({ experimental: { mmr: mmr } })).then(function (out) {
6646
+ setBusy(false);
6647
+ // setSettings 自带 {ok, settings, restartRequired}:直接读,不要 unwrap。
6648
+ if (out && out.ok === false) { setErr(out.error || t("lab.err.rejected")); return; }
6649
+ if (out && out.settings) setMemSet(out.settings); else load();
6650
+ setDraft(String(mmr));
6651
+ setNotice(okMsg);
6652
+ }, function (e) {
6653
+ setBusy(false);
6654
+ setErr(String((e && e.message) || e));
6655
+ });
6656
+ }
6657
+
6658
+ /** 开关:开 → 回到草稿里的值(默认 70);关 → 写 0。 */
6659
+ function toggle() {
6660
+ if (pending > 0) { save(0, t("lab.notice.off")); return; }
6661
+ const n = Number(draft);
6662
+ const target = Number.isFinite(n) && n > 0 ? Math.round(n) : 70;
6663
+ save(target, t("lab.notice.on", { value: target }));
6664
+ }
6665
+
6666
+ /** 保存强度:本地先拦明显笔误(宿主也会拒,但白跑一趟没必要)。 */
6667
+ function saveStrength() {
6668
+ const raw = String(draft == null ? "" : draft).trim();
6669
+ const n = Number(raw);
6670
+ if (raw === "" || !Number.isFinite(n) || !Number.isInteger(n)) { setErr(t("lab.err.integer")); return; }
6671
+ if (n < 0 || n > 100) { setErr(t("lab.err.range")); return; }
6672
+ save(n, n > 0 ? t("lab.notice.strengthOn", { value: n }) : t("lab.notice.off"));
6673
+ }
6674
+
6675
+ const statusText = busy
6676
+ ? t("lab.action.saving")
6677
+ : (pending > 0 ? t("lab.status.on", { value: pending }) : t("lab.status.off"));
6678
+
6679
+ return h("div", { className: "smem-lab smem-module-card" }, [
6680
+ // ── 模块卡头:开关 + 标题 + 状态(照自动归档那张卡的形状)──
6681
+ h("div", { className: "smem-modcard-head", key: "head" }, [
6682
+ h(Switch, {
6683
+ key: "sw",
6684
+ on: pending > 0,
6685
+ label: t("lab.switch"),
6686
+ title: pending > 0 ? t("lab.switch.onTitle") : t("lab.switch.offTitle"),
6687
+ disabled: busy || !memSet,
6688
+ onClick: toggle,
6689
+ }),
6690
+ h("span", { className: "smem-modcard-title", key: "t" }, t("lab.title")),
6691
+ h("span", { className: "smem-modcard-status", key: "s" }, statusText),
6692
+ ]),
6693
+
6694
+ // ── 它做什么:一句话 + 代价(这一屏存在的理由写在最前面)──
6695
+ h("div", { className: "smem-autodream-sec", key: "what" }, [
6696
+ h("div", { className: "smem-autodream-note", key: "p1" }, t("lab.what1")),
6697
+ h("div", { className: "smem-autodream-note", key: "p2" }, t("lab.what2")),
6698
+ ]),
6699
+
6700
+ // ── 强度 ──
6701
+ h("div", { className: "smem-autodream-sec", key: "strength" }, [
6702
+ h("div", { className: "smem-autodream-row", key: "r" }, [
6703
+ h("span", { className: "smem-autodream-key", key: "k" }, t("lab.strength")),
6704
+ h("input", {
6705
+ className: "smem-input smem-num", key: "i", type: "number", min: 0, max: 100, step: 5,
6706
+ value: draft,
6707
+ disabled: busy || !memSet,
6708
+ onChange: function (e) { setDraft(e.target.value); },
6709
+ }),
6710
+ h("span", { className: "smem-muted", key: "u" }, t("lab.unit")),
6711
+ h("button", {
6712
+ type: "button", className: "smem-btn smem-btn-primary", key: "save",
6713
+ disabled: busy || !memSet, onClick: saveStrength,
6714
+ }, busy ? t("lab.action.saving") : t("lab.action.save")),
6715
+ ]),
6716
+ h("div", { className: "smem-autodream-note", key: "hint" }, t("lab.hint.strength")),
6717
+ h("div", { className: "smem-autodream-note", key: "restart" },
6718
+ t("lab.note.restart", { active: active, pending: pending })),
6719
+ ]),
6720
+
6721
+ // ── 实验性声明:用现成的虚线提示框(「提示,不计入问题」那一个)──
6722
+ h("div", { className: "smem-audit-hint", key: "declare" }, [
6723
+ h("div", { key: "t", style: { fontWeight: "600" } }, t("lab.declare.title")),
6724
+ h("div", { key: "a" }, t("lab.declare.forget")),
6725
+ h("div", { key: "b" }, t("lab.declare.feedback")),
6726
+ h("div", { key: "c" }, t("lab.declare.default")),
6727
+ ]),
6728
+
6729
+ // 错误与回执留在最后:不管在哪按的,结果都得看得见。
6730
+ err ? h("div", { className: "smem-err", key: "err" }, "⚠ " + err) : null,
6731
+ notice ? h("div", { className: "smem-ok", key: "ok" }, "✓ " + notice) : null,
6732
+ ]);
6733
+ }
6527
6734
  // ── 文件列表这一屏(1.5.3 从 Section 里抽出;D23 计划的 72 片)──────────
6528
6735
  // 只负责「按状态/筛选把卡片摆出来 + 四种空态」;状态、筛选语义、动作回调全部由 Section 传。
6529
6736
  // 抽出的判据:它是 Section 里最大的一块纯渲染,而卡片组件(Card / ArchivedCard)本来就在 50 共享层。
@@ -6697,6 +6904,30 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
6697
6904
  ]);
6698
6905
  }
6699
6906
 
6907
+ /**
6908
+ * 渲染 `settings.memory.starmap` 子槽(2.8.0)。
6909
+ *
6910
+ * 1.8.0 起这个子槽就**只声明不渲染**(`Section` 从没调过 `renderSlot`):
6911
+ * 第三方按契约往里面注入视图,一个像素都不会出现。接上之后:
6912
+ * - `renderSlot` 是宿主经 props 传进来的(声明了 children 才有),旧外壳可能没有 → 判类型;
6913
+ * - 返回 null / false 表示没有占用者 —— 此时**不占版面**(空 div 也会破坏间距);
6914
+ * - 出任何异常都吞掉:这是个可选面,绝不能让第三方把整个设置页拖垮。
6915
+ * ⚠️ 连带后果(2.7.0 的入口三选一带来的):「只显示侧栏」会把设置页 section 注销,
6916
+ * 于是注入到这里的内容也跟着消失 —— 所以那一档的文案里点明了一句(`entry.sidebarHidesSlot`)。
6917
+ */
6918
+ function renderMemoryExtraSlot(props) {
6919
+ if (!props || typeof props.renderSlot !== "function") return null;
6920
+ try {
6921
+ // owner 传 `{}`:官方对 root scope 的子槽就是这么调的(`renderSlot("shell.overlay", {})`、
6922
+ // `renderSlot("main", {}, …)`)—— 少传一个参数在对 scope 做检查的宿主上可能直接抛。
6923
+ const node = props.renderSlot("settings.memory.starmap", {});
6924
+ if (node === null || node === undefined || node === false) return null;
6925
+ return h("div", { className: "smem-extra-slot", key: "extra-slot" }, node);
6926
+ } catch (e) {
6927
+ return null;
6928
+ }
6929
+ }
6930
+
6700
6931
  function Section(props) {
6701
6932
  // 走 stableRemote:真实宿主里 `ctx.get()` 每次返回新对象(issue #3 的成因)。
6702
6933
  // 这个面板的 effect 依赖是 `[]`、暂时不会爆,但引用稳定是**所有**面板的共同前提。
@@ -7886,6 +8117,9 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
7886
8117
  // 库健康 = 体检(机器扫)+ 冲突(要人裁决)。两者回答的是同一句话,
7887
8118
  // 所以并成一屏;没有 conflict 面的旧宿主只少一张卡,页签照常在。
7888
8119
  h("button", { type: "button", className: "smem-tab" + (tab === "health" ? " smem-tab--on" : ""), key: "a", role: "tab", "aria-selected": tab === "health" ? "true" : "false", onClick: openHealthTab }, t("tab.health")),
8120
+ // 2.9.0:实验性设置单开一页(只放会改变**选材**的开关;见 64-lab-settings.js 开头)。
8121
+ // 排在库健康之后:先看「库有没有事」,再决定「要不要动实验性开关」。
8122
+ h("button", { type: "button", className: "smem-tab" + (tab === "lab" ? " smem-tab--on" : ""), key: "l", role: "tab", "aria-selected": tab === "lab" ? "true" : "false", onClick: function () { setTab("lab"); } }, t("tab.lab")),
7889
8123
  ]),
7890
8124
  // 四个 tab 共用的一条头部带:我在哪 · 目录状态 · 活动/归档计数 · 体检徽标 · 待重启徽标 · 主操作。
7891
8125
  // 层级形状固定:导航=下划线(.smem-tabs)、筛选=chip、动作=按钮,三种不混用。
@@ -8158,7 +8392,12 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
8158
8392
  : null,
8159
8393
  h(AuditPanel, { key: "audit", report: audit, busy: auditBusy, onRun: runAudit, standalone: true }),
8160
8394
  ])
8395
+ : tab === "lab"
8396
+ ? h("div", { className: "smem-stack smem-view", key: "lab" }, [h(LabSettingsPanel, { key: "lab-card", ctx: props.ctx })])
8161
8397
  : h("div", { className: "smem-view", key: "files" }, filesView),
8398
+ // 2.8.0:第三方注入的视图(`settings.memory.starmap` 子槽)。没有占用者时是 null,
8399
+ // 一个 div 都不出 —— 所以默认安装下界面与 2.7.0 逐像素相同。
8400
+ renderMemoryExtraSlot(props),
8162
8401
  // 2.6.0:复制成功的回执铺在视口下方中央(`position:fixed`,不吃点击,10s 自收,
8163
8402
  // 也能自己点 × 关)。渲染在 `.smem-root` 直属层 —— 别塞进带进场动画的
8164
8403
  // `.smem-view`(那个祖先有 transform,`fixed` 会被改写定位)。
@@ -8516,9 +8755,10 @@ h("span", { key: "sep", className: "smem-actions-sep", "aria-hidden": "true" }),
8516
8755
  order: 20,
8517
8756
  label: function () { return t("section.label"); },
8518
8757
  // 声明「记忆星图」子槽(沿用 1.8.0 的契约):sage-starmap 之类的
8519
- // 第三方插件向它注入视图。⚠️ 现状:Section 还没有调用 renderSlot,
8520
- // 所以这个子槽目前只声明、不渲染(2.7.0 未改;渲染它是独立事项)。
8521
- // 保留声明是为了不破坏第三方插件的注入面。
8758
+ // 第三方插件向它注入视图。2.8.0 起 `Section` 真的调 `renderSlot` 了
8759
+ // (之前只声明、不渲染 —— 注进来的东西一个像素都不出现)。
8760
+ // ⚠️ 连带后果:注销这个 section 时子槽跟着没(「只显示侧栏」那一档),
8761
+ // 界面文案里已点明(`entry.sidebarHidesSlot`)。
8522
8762
  children: { "settings.memory.starmap": { kind: "list", scope: "root" } },
8523
8763
  }, function (props) {
8524
8764
  return react.createElement(Section, { ...props, ctx: ctx });
package/lib/index.js CHANGED
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { AutodreamEngine, DEFAULT_CONFIG, auditMemoryDir } from './autodream.js'
10
+ import { MMR_PERCENT, STARTUP_EXPERIMENTAL_DIAGNOSTICS } from './memory/experimental.js'
10
11
  import { LIMIT_SPECS, MAX_BASELINE, MAX_CHARS_PER_FILE, MAX_FILE_BYTES, MAX_RESULTS, MAX_SESSION_BYTES, MEMORY_DIR, RECALL_DISCIPLINE, RESERVED_FILES, SESSIONS_ROOT, STALE_DAYS, STARTUP_LIMIT_DIAGNOSTICS, STATE_ROOT, resolveLimits } from './memory/config.js'
11
12
  import { INDEX_FILE_KEY, SESSION_LOG_KEY, isPlainMdName } from './shared/md-name.js'
12
13
  import { parseAliases, parseBaseline, parseBaselinePriority, parseFrontmatter, parseTags } from './memory/frontmatter.js'
@@ -25,7 +26,7 @@ import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
25
26
  import { mkdir, unlink, writeFile } from 'node:fs/promises'
26
27
  import { join } from 'node:path'
27
28
  import { checkUpdate as checkUpdateImpl } from './update.js'
28
- import { autodreamConfigSnapshot, clampInt, MAX_RAW_BYTES, parseUiPatch, pendingUi, rawName, readReservedFile, readSettingsFileDetailed, RESERVED_FILE, SETTINGS_FILE } from './settings.js'
29
+ import { autodreamConfigSnapshot, clampInt, MAX_RAW_BYTES, parseExperimentalPatch, parseUiPatch, pendingExperimental, pendingUi, rawName, readReservedFile, readSettingsFileDetailed, RESERVED_FILE, SETTINGS_FILE } from './settings.js'
29
30
  import { createRecallInjector, createSessionInjectState, readRecallDebug } from './recall/index.js'
30
31
  import { mountAutodream, mountMemoryTools, mountObserve, mountWriteGate } from './assembly/index.js'
31
32
 
@@ -415,9 +416,14 @@ export class MemoryGateway extends TypertRemoteService {
415
416
  }
416
417
  // Q04:pending 与**启动解析**共用 `resolveLimits` 这一份判据(持久设置 > 环境变量 > 默认)。
417
418
  const pending = resolveLimits(fileRead.data?.limits)
419
+ // 2.9.0:实验性开关走同一套优先级(持久 > 环境变量 > 默认),但**单独一段**、单独诊断 ——
420
+ // 它决定「选哪些记忆」,改错的症状与上限不同,不能混在同一个口径里说。
421
+ const experimental = { mmr: MMR_PERCENT }
422
+ const pendingLab = pendingExperimental(fileRead.data)
418
423
  // 坏设置给明确诊断、不静默回默认:启动期那份 + 这次读文件的问题 + 逐项非法/越界说明,去重后一起给。
419
424
  const limitDiagnostics = [...new Set([
420
425
  ...STARTUP_LIMIT_DIAGNOSTICS,
426
+ ...STARTUP_EXPERIMENTAL_DIAGNOSTICS,
421
427
  ...(fileRead.error ? [fileRead.error] : []),
422
428
  ...pending.diagnostics,
423
429
  ])]
@@ -429,12 +435,17 @@ export class MemoryGateway extends TypertRemoteService {
429
435
  const activeExtra = [...RESERVED_FILES].filter((n) => n !== INDEX_FILE_KEY && n !== SESSION_LOG_KEY)
430
436
  const norm = (list) => JSON.stringify([...list].map((s) => String(s).toLowerCase()).sort())
431
437
  const restartRequired =
432
- JSON.stringify(limits) !== JSON.stringify(pending.limits) || norm(activeExtra) !== norm(reservedExtra)
438
+ JSON.stringify(limits) !== JSON.stringify(pending.limits) ||
439
+ JSON.stringify(experimental) !== JSON.stringify(pendingLab) ||
440
+ norm(activeExtra) !== norm(reservedExtra)
433
441
  // 2.7.0:entry 是 canonical;sidebarEntry 只为 2.6.0 的客户端保留(派生值,不落盘)。
434
442
  const ui = pendingUi(fileRead.data)
435
443
  return {
436
444
  limits,
437
445
  pendingLimits: pending.limits,
446
+ // 2.9.0 实验性:`experimental` = 已生效值(模块加载时读的),`pendingExperimental` = 待生效值。
447
+ experimental,
448
+ pendingExperimental: pendingLab,
438
449
  restartRequired,
439
450
  // Q04 的诊断出口:面板据此说明「你保存的值没生效,原因是 X」;宿主侧先如实给出,别让问题只能靠猜。
440
451
  limitDiagnostics,
@@ -488,10 +499,14 @@ export class MemoryGateway extends TypertRemoteService {
488
499
  const uiCheck = parseUiPatch(p.ui)
489
500
  if (!uiCheck.ok) return { ok: false, error: uiCheck.error }
490
501
  const uiPatch = uiCheck.patch
491
- if (!limitsPatch && nextReserved === null && uiPatch === null) return { ok: false, error: 'sage-mem: empty patch' }
502
+ // 2.9.0:实验性开关与 limits / ui 同一套「先全验、后一起写」。
503
+ const labCheck = parseExperimentalPatch(p.experimental)
504
+ if (!labCheck.ok) return { ok: false, error: labCheck.error }
505
+ const labPatch = labCheck.patch
506
+ if (!limitsPatch && nextReserved === null && uiPatch === null && labPatch === null) return { ok: false, error: 'sage-mem: empty patch' }
492
507
 
493
508
  await mkdir(STATE_ROOT, { recursive: true })
494
- if (limitsPatch || uiPatch !== null) {
509
+ if (limitsPatch || uiPatch !== null || labPatch !== null) {
495
510
  const currentRead = await readSettingsFileDetailed()
496
511
  // 坏文件上不写:没有可合并的旧值,整份重写会把用户手改的内容静默吞掉。
497
512
  // 明确拒绝、让用户先修文件,比「保存成功但别的设置都没了」诚实。
@@ -500,10 +515,12 @@ export class MemoryGateway extends TypertRemoteService {
500
515
  }
501
516
  const current = currentRead.data
502
517
  const mergedLimits = { ...(current?.limits ?? {}), ...nextLimits }
518
+ // 实验性:只覆盖这次给出的键,没给就沿用文件里的有效值(缺失 = 全关)。
519
+ const mergedLab = { ...pendingExperimental(current), ...(labPatch ?? {}) }
503
520
  // ui 只覆盖这次给出的键;没给就沿用文件里的有效值(坏值 / 缺失一律按默认「两个都显示」)。
504
521
  // 2.7.0:落盘只写 canonical 的 entry,旧 sidebarEntry 由 pendingUi 迁移进来、不再写出。
505
522
  const mergedUi = { ...pendingUi(current), ...(uiPatch ?? {}) }
506
- await writeFileAtomic(SETTINGS_FILE, JSON.stringify({ limits: mergedLimits, ui: mergedUi, updatedAt: Date.now() }, null, 2))
523
+ await writeFileAtomic(SETTINGS_FILE, JSON.stringify({ limits: mergedLimits, experimental: mergedLab, ui: mergedUi, updatedAt: Date.now() }, null, 2))
507
524
  }
508
525
  if (nextReserved !== null) {
509
526
  await writeFileAtomic(RESERVED_FILE, `${JSON.stringify(nextReserved, null, 2)}\n`)
@@ -514,7 +531,7 @@ export class MemoryGateway extends TypertRemoteService {
514
531
  return {
515
532
  ok: true,
516
533
  settings,
517
- restartRequired: limitsPatch || nextReserved !== null ? true : settings.restartRequired,
534
+ restartRequired: limitsPatch || nextReserved !== null || labPatch !== null ? true : settings.restartRequired,
518
535
  }
519
536
  }
520
537
 
@@ -164,7 +164,7 @@ export function readSettingsFileSync(stateRoot = STATE_ROOT) {
164
164
  * 启动期一次性解析的结果。**面板与引擎看到的是同一份**(`getSettings` 的 pending
165
165
  * 走同一个 `resolveLimits`,只是输入换了下一次读到的文件内容)。
166
166
  */
167
- const STARTUP_SETTINGS = readSettingsFileSync()
167
+ export const STARTUP_SETTINGS = readSettingsFileSync()
168
168
  const STARTUP_LIMITS = resolveLimits(STARTUP_SETTINGS.data?.limits)
169
169
  export const STARTUP_LIMIT_DIAGNOSTICS = [...STARTUP_SETTINGS.diagnostics, ...STARTUP_LIMITS.diagnostics]
170
170
 
@@ -0,0 +1,71 @@
1
+ /**
2
+ * 实验性开关 —— 只放「会改变**选出哪些记忆**、但还没被长期使用验证」的配置。
3
+ *
4
+ * 为什么与 `LIMIT_SPECS` 分开(而不是塞进 limits):
5
+ * - `limits` 管的是**预算**(条数、字数、会话总量)。改错了,代价是「多给或少给上下文」。
6
+ * - 这里管的是**选材**。改错了,代价是「该来的那条记忆没来」——症状是失忆,
7
+ * 而不是上下文变长。两者该有不同的默认值、不同的诊断,和面板上不同的说法。
8
+ *
9
+ * 取值统一用**整数**(0~100 表示百分比),三处原因:
10
+ * 1. 与 `parseLimitValue` 的整数语义共用一套校验,不必为浮点再造一份;
11
+ * 2. `settings.json` 里手写 `0.7` 与 `"70"` 的差异不再影响解析;
12
+ * 3. 0 天然表示「关闭」,与「没配过」同义 —— 实验性功能必须默认关。
13
+ */
14
+ import { STARTUP_SETTINGS, parseLimitValue } from './config.js'
15
+
16
+ /**
17
+ * 实验性开关的**唯一定义**(值 + 边界)。
18
+ *
19
+ * - `mmr`:注入链的 MMR 去重强度(百分比)。`0` = 关闭;`70` = λ0.7(相关度 70% / 去重 30%)。
20
+ * 取值越接近 100 越接近「纯按相关度」,越接近 0 越接近「只求不重复」。
21
+ * 为什么默认 0:它是**选材**变化,会改变「哪些记忆被端上来」,必须由用户显式打开。
22
+ */
23
+ export const EXPERIMENTAL_SPECS = {
24
+ mmr: { env: 'SAGE_MEM_MMR', dflt: 0, lo: 0, hi: 100 },
25
+ }
26
+
27
+ /**
28
+ * 解析实验性设置:持久设置 > 环境变量 > 内置默认。
29
+ *
30
+ * 与 `resolveLimits` 同构(含诊断口径):非法值回落、越界收敛并各留一条说明。
31
+ * 缺失(`hasOwnProperty` 为假)是正常状态,不产生诊断。
32
+ *
33
+ * @param {object|null|undefined} saved — `settings.json` 的 `experimental` 段
34
+ * @param {object} [env] — 默认 `process.env`(测试可注入)
35
+ * @returns {{experimental: Record<string, number>, diagnostics: string[]}}
36
+ */
37
+ export function resolveExperimental(saved, env = process.env) {
38
+ const experimental = {}
39
+ const diagnostics = []
40
+ const savedObj = saved && typeof saved === 'object' && !Array.isArray(saved) ? saved : null
41
+ for (const [key, spec] of Object.entries(EXPERIMENTAL_SPECS)) {
42
+ const hasSaved = savedObj !== null && Object.prototype.hasOwnProperty.call(savedObj, key)
43
+ if (hasSaved) {
44
+ const value = parseLimitValue(savedObj[key], spec)
45
+ if (value !== undefined) {
46
+ if (String(savedObj[key]).trim() !== String(value)) {
47
+ diagnostics.push(`settings.json 的 experimental.${key}=${JSON.stringify(savedObj[key])} 不是 [${spec.lo}, ${spec.hi}] 内的整数,已收敛为 ${value}`)
48
+ }
49
+ experimental[key] = value
50
+ continue
51
+ }
52
+ diagnostics.push(`settings.json 的 experimental.${key}=${JSON.stringify(savedObj[key])} 不是有效数字,已回落环境变量/默认值`)
53
+ }
54
+ const envValue = parseLimitValue(env?.[spec.env], spec)
55
+ experimental[key] = envValue === undefined ? spec.dflt : envValue
56
+ }
57
+ return { experimental, diagnostics }
58
+ }
59
+
60
+ /** 启动期解析结果(与 `config.js` 共用同一份 `settings.json` 快照,不重复读盘)。 */
61
+ export const STARTUP_EXPERIMENTAL = resolveExperimental(STARTUP_SETTINGS.data?.experimental)
62
+ export const STARTUP_EXPERIMENTAL_DIAGNOSTICS = [...STARTUP_EXPERIMENTAL.diagnostics]
63
+
64
+ /** MMR 强度百分比(0 = 关闭)。 */
65
+ export const MMR_PERCENT = STARTUP_EXPERIMENTAL.experimental.mmr
66
+
67
+ /**
68
+ * 注入链用的 λ。`0` 表示关闭 —— `selectRelevant` 见到 `(lambda > 0)` 才重排。
69
+ * 调用点(`lib/recall/select.js`)以它为默认值,因此「面板/环境变量改了 → 下一次注入生效」。
70
+ */
71
+ export const MMR_LAMBDA = MMR_PERCENT / 100
@@ -0,0 +1,92 @@
1
+ /**
2
+ * MMR(Maximal Marginal Relevance,边际相关度最大化)—— **只是「挑哪几条」的重排**。
3
+ *
4
+ * 它解决的具体病症:注入名额只有 10 条,而检索只按相关度排序,于是同一件事的四份记忆
5
+ * (例如四篇课程笔记:老师 / 平台 / 死规矩 / 作业形态)可能把名额全占掉,别的主题一条都进不来。
6
+ * MMR 每挑一条之前,先扣掉它与**已挑中**条目的相似度,让「不重复但相关」的那条先上。
7
+ *
8
+ * 公式(Carbonell & Goldstein, 1998):
9
+ *
10
+ * MMR(d) = λ · 归一化相关度(d) − (1 − λ) · max{ 相似度(d, s) | s ∈ 已选 }
11
+ *
12
+ * λ = 1 退化为「完全按相关度」;λ = 0 退化为「完全按不重复」。默认 0.7(相关度为主、去重为辅)。
13
+ *
14
+ * ── 三道护栏(本函数的对外契约,改之前先读)──────────────────────────────
15
+ *
16
+ * 1. **λ 收敛到 [0,1],0 表示关闭**:`λ <= 0` 时直接按原顺序取前 k 条,与不启用等价。
17
+ * 非法值(NaN / 负数 / 超过 1)一律收敛,不抛错 —— 这是设置在面板上可调的参数,
18
+ * 一个手滑的输入不该让整条注入链失效。
19
+ * 2. **只有「候选数 > 名额」才启用**:候选本来就不够时原样返回。理由:重排不改变成员,
20
+ * 却会让「按相关度排好」变成「按另一种顺序排」,徒增不可解释性。
21
+ * 3. **只挑不改**:不删除、不改分、不碰文件、不碰写路径。输入数组的每个元素原样返回,
22
+ * 调用方拿到的仍是同一批对象引用;本函数只决定「哪几条、什么顺序」。
23
+ *
24
+ * 另外两条与调用点共担的约束(不在这层实现,但改调用点时别破坏):
25
+ * - **baseline 不参与重排**:`selectBaseline` 走的是另一条线,按显式 `baselinePriority` 排。
26
+ * - **相似度只算词面**:与相关性打分共用同一套 2-gram(见 `ngram.js`),不引入向量/模型。
27
+ */
28
+ import { jaccard } from './ngram.js'
29
+
30
+ /** 默认 λ:相关度 70%、去重 30%。调高更保守(更接近原始排序),调低更激进。 */
31
+ export const DEFAULT_MMR_LAMBDA = 0.7
32
+
33
+ /** 收敛 λ 到 [0,1];非数字按 0(= 关闭)处理,与「配置写坏了就当没开」一致。 */
34
+ export function clampLambda(value) {
35
+ const n = Number(value)
36
+ if (!Number.isFinite(n)) return 0
37
+ if (n <= 0) return 0
38
+ return n >= 1 ? 1 : n
39
+ }
40
+
41
+ /**
42
+ * 按 MMR 从候选里挑出 k 条。
43
+ *
44
+ * @param {Array<{score:number, terms:Set<string>}>} items
45
+ * 候选,**必须已按相关度降序**(调用方负责);`score` 的量纲由调用方决定,
46
+ * `terms` 是用于算相似度的词集(通常 = 摘要面 `description + aliases + 文件名` 的双字集)。
47
+ * @param {{k?:number, lambda?:number}} [opts]
48
+ * `k` 名额(缺省 = 全部)、`lambda` 见文件头(缺省 `DEFAULT_MMR_LAMBDA`)。
49
+ * @returns {Array} 被选中的条目(顺序即 MMR 挑选顺序)。**元素是原数组里的同一批引用**。
50
+ */
51
+ export function mmrSelect(items, opts = {}) {
52
+ const list = Array.isArray(items) ? items : []
53
+ const k = Number.isFinite(opts.k) ? Math.max(0, Math.floor(opts.k)) : list.length
54
+ if (k <= 0 || list.length === 0) return []
55
+ // 护栏 2:候选不超过名额 → 原样返回(连顺序都不动)。
56
+ if (list.length <= k) return list.slice()
57
+
58
+ const lambda = clampLambda(opts.lambda === undefined ? DEFAULT_MMR_LAMBDA : opts.lambda)
59
+ // 护栏 1:λ <= 0 表示关闭,等价于「按原顺序取前 k」。
60
+ if (lambda <= 0) return list.slice(0, k)
61
+
62
+ // 相关度归一到 [0,1]:score 与 Jaccard 是两种量纲,不归一就没有可比性。
63
+ // 全 0 分(理论上不会出现在命中集,但防御一下)时归一化退化为「纯去重」。
64
+ const maxScore = list.reduce((m, it) => Math.max(m, Number(it?.score) || 0), 0)
65
+ const rel = list.map(it => (maxScore > 0 ? (Number(it?.score) || 0) / maxScore : 0))
66
+
67
+ const picked = []
68
+ // 保留原始下标:归一化相关度靠它取;`rest` 只做删除,不重排。
69
+ const rest = list.map((it, idx) => ({ it, idx }))
70
+ while (picked.length < k && rest.length > 0) {
71
+ let bestPos = 0
72
+ let bestValue = -Infinity
73
+ for (let p = 0; p < rest.length; p += 1) {
74
+ const cand = rest[p]
75
+ let maxSim = 0
76
+ for (const chosen of picked) {
77
+ const sim = jaccard(cand.it?.terms, chosen.terms)
78
+ if (sim > maxSim) maxSim = sim
79
+ }
80
+ const value = lambda * (rel[cand.idx] || 0) - (1 - lambda) * maxSim
81
+ // 严格大于:平分时留在前面那条(`rest` 维持原顺序 = 相关度降序),
82
+ // 结果因此是确定的——同样的输入永远给出同样的顺序,测试才能钉住它。
83
+ if (value > bestValue) {
84
+ bestValue = value
85
+ bestPos = p
86
+ }
87
+ }
88
+ picked.push(rest[bestPos].it)
89
+ rest.splice(bestPos, 1)
90
+ }
91
+ return picked
92
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * 词面相似度的最小公共件:双字(2-gram)集合与 Jaccard。
3
+ *
4
+ * 为什么单独一个文件:`retrieval.js` 要用双字做相关性打分,`mmr.js` 要用双字算
5
+ * 「两条记忆像不像」。若让 mmr 反过来 import retrieval,就成了循环(retrieval 也必须
6
+ * import mmr 才能做重排)——把这一层词面工具提到无依赖处,两边都往下引,谁也不用回头。
7
+ *
8
+ * 行为与旧 `retrieval.js:bigrams` 逐字一致;那里仍 re-export 本函数,历史调用点不受影响。
9
+ */
10
+
11
+ /** 提取双字(2-gram)集合,中英文数字通用;标点与空白一律剥掉。 */
12
+ export function bigrams(s) {
13
+ const clean = String(s).toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '')
14
+ const set = new Set()
15
+ for (let i = 0; i < clean.length - 1; i++) set.add(clean.slice(i, i + 2))
16
+ return set
17
+ }
18
+
19
+ /**
20
+ * 两个词集的 Jaccard 相似度(交集 / 并集),取值 0~1。
21
+ *
22
+ * 任一侧为空(超短文本、全是标点)时返回 0:**空集之间不算相似**。这条是刻意的——
23
+ * 若把「没有可比词」当成「相似度为 1」,MMR 会把两条无关的短条目当成重复而丢弃;
24
+ * 若当成「相似度为 0」,它们又会被排在前面。统一按 0 处理,等于「这一对没有信息量」,
25
+ * 排序仍由相关度决定。
26
+ */
27
+ export function jaccard(a, b) {
28
+ if (!(a instanceof Set) || !(b instanceof Set) || a.size === 0 || b.size === 0) return 0
29
+ let inter = 0
30
+ for (const g of a) if (b.has(g)) inter++
31
+ const union = a.size + b.size - inter
32
+ return union === 0 ? 0 : inter / union
33
+ }
@@ -4,6 +4,11 @@
4
4
 
5
5
  import { MAX_BASELINE, MAX_RESULTS } from './config.js'
6
6
  import { buildStats, evidence, idfOf, queryTermsOf, termFreq, tokenize } from './bm25.js'
7
+ import { bigrams } from './ngram.js'
8
+ import { mmrSelect } from './mmr.js'
9
+
10
+ // 双字集合搬到 `ngram.js`(MMR 也要用,放这里会形成循环)。对外 API 不变。
11
+ export { bigrams }
7
12
 
8
13
  /** 提取消息文本,兼容 content 为字符串或 text-block 数组两种形态。 */
9
14
  export function extractText(content) {
@@ -16,14 +21,6 @@ export function extractText(content) {
16
21
  .trim()
17
22
  }
18
23
 
19
- /** 提取双字(2-gram)集合,中英文数字通用。 */
20
- export function bigrams(s) {
21
- const clean = String(s).toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '')
22
- const set = new Set()
23
- for (let i = 0; i < clean.length - 1; i++) set.add(clean.slice(i, i + 2))
24
- return set
25
- }
26
-
27
24
  /** query 的双字在 text 中的覆盖率(0~1),作为相关性得分。 */
28
25
  export function score(query, text) {
29
26
  const q = bigrams(query)
@@ -140,12 +137,29 @@ export function rankSummaries(query, files, opts = {}) {
140
137
  * 名额(`MAX_RESULTS`)仍然守自己的配置 —— 2.1.16 只把「排序」与「名额」拆开,
141
138
  * 没有放开注入上限(那是 Q13 的反向对照)。
142
139
  *
140
+ * 2.9.0 起可选 MMR 去重(`opts.mmr` = λ,0/缺省 = 不启用):**相关度排序照旧,
141
+ * 只把「挑哪几条」交给 `mmrSelect`**。三条护栏的落点在这里:
142
+ * - λ ≤ 0(含缺省)→ 走原来的 `slice(0, k)`,行为与 2.8.0 逐字一致;
143
+ * - 候选数 ≤ 名额 → 同样原样截取,不重排(见 `mmr.js` 护栏 2);
144
+ * - baseline 压根不在这条路上(它走 `selectBaseline`),所以「锁定的记忆」不受影响。
145
+ *
146
+ * 词集用的是**摘要面**(`summaryHay`:description + aliases + 文件名),与相关性打分同一份,
147
+ * 不额外读正文、不引入模型 —— 这一步只花 CPU,不花 I/O。
148
+ *
143
149
  * @param {string} query
144
150
  * @param {Array} files
145
- * @param {{gate?: boolean, minRatio?: number}} [opts]
151
+ * @param {{gate?: boolean, minRatio?: number, maxResults?: number, mmr?: number}} [opts]
146
152
  */
147
153
  export function selectRelevant(query, files, opts = {}) {
148
- return rankSummaries(query, files, opts).slice(0, MAX_RESULTS)
154
+ const k = Number.isFinite(opts.maxResults) ? Math.max(1, Math.floor(opts.maxResults)) : MAX_RESULTS
155
+ const ranked = rankSummaries(query, files, opts)
156
+ const lambda = Number(opts.mmr)
157
+ // 未启用 / 候选不够名额:走老路。这里先判一次,免得为一次注定原样返回的调用去算词集。
158
+ if (!(lambda > 0) || ranked.length <= k) return ranked.slice(0, k)
159
+ // 用「包装对象」承载 score/terms,再把原对象映射回去 —— 不往文件对象上挂字段,
160
+ // 免得调试视图/序列化里多出一个 Set。
161
+ const items = ranked.map(f => ({ score: f.score, terms: bigrams(summaryHay(f)), ref: f }))
162
+ return mmrSelect(items, { k, lambda }).map(it => it.ref)
149
163
  }
150
164
 
151
165
  /**
@@ -8,6 +8,7 @@
8
8
  * 现在 `selectRecall()` 是纯函数,`test/recall-unit-test.mjs` 可以直接喂夹具。
9
9
  */
10
10
  import { MAX_RESULTS } from '../memory/config.js'
11
+ import { MMR_LAMBDA } from '../memory/experimental.js'
11
12
  import { selectBaseline, selectRelevant } from '../memory/retrieval.js'
12
13
  import { detectIntent } from '../memory/intent.js'
13
14
  import { rankChunks } from '../memory/search.js'
@@ -33,11 +34,13 @@ export function dedupeByFile(hits) {
33
34
  /**
34
35
  * 选出这一步要端上去的文件。
35
36
  *
36
- * @param {{text:string, files:Array, st:object, maxResults?:number}} input
37
+ * @param {{text:string, files:Array, st:object, maxResults?:number, mmr?:number}} input
38
+ * `mmr` 是实验性去重强度(λ,0 = 关闭),默认取启动期解析的 `MMR_LAMBDA`;
39
+ * 显式传 0 可以在测试里稳定地关掉它。
37
40
  * @returns {{picked:Array, baselineTaken:number, relevant:Array, intentInfo:object,
38
41
  * bodyRanked:Array, bestChunk:Map, selected:Array, signature:string, deduped:boolean}}
39
42
  */
40
- export function selectRecall({ text, files, st, maxResults = MAX_RESULTS }) {
43
+ export function selectRecall({ text, files, st, maxResults = MAX_RESULTS, mmr = MMR_LAMBDA }) {
41
44
  const picked = []
42
45
  const seen = new Set()
43
46
  const take = (f) => {
@@ -77,7 +80,9 @@ export function selectRecall({ text, files, st, maxResults = MAX_RESULTS }) {
77
80
  // 反过来,旧版把 baseline 文件名也算进签名,会让同一步的检索结果在下一步
78
81
  // 因签名变化而被重复注入。现在 baseline 由会话级标记保证只来一次,
79
82
  // 检索由这份签名保证同一问题不重复追加,两条线互不干扰。
80
- const relevant = text && text.length >= 2 ? selectRelevant(text, files) : []
83
+ // 实验性 MMR(2.9.0):只在 `mmr > 0` 且候选多于名额时改「挑哪几条」,不改相关度。
84
+ // 传 0 时与 2.8.0 的 `selectRelevant(text, files)` 完全等价。
85
+ const relevant = text && text.length >= 2 ? selectRelevant(text, files, { mmr }) : []
81
86
 
82
87
  // ── 意图:先判,因为它要影响下面正文检索的**门槛松紧** ──
83
88
  // 用户是不是在问自己的历史(上次 / 之前 / 更正 / 问依据 / 继续)。
package/lib/settings.js CHANGED
@@ -7,6 +7,7 @@
7
7
  import { readFile } from 'node:fs/promises'
8
8
  import { join } from 'node:path'
9
9
  import { LIMIT_SPECS, STATE_ROOT, resolveLimits } from './memory/config.js'
10
+ import { EXPERIMENTAL_SPECS, resolveExperimental } from './memory/experimental.js'
10
11
  import { MAX_RAW_BYTES } from './shared/limits.js'
11
12
  import { isPlainMdName } from './shared/md-name.js'
12
13
  export { LIMIT_SPECS, MAX_RAW_BYTES }
@@ -127,6 +128,46 @@ export function pendingLimits(fileObj) {
127
128
  return resolveLimits(fileObj?.limits).limits
128
129
  }
129
130
 
131
+ /**
132
+ * 待生效的**实验性**开关(2.9.0)。与 `pendingLimits` 共用同一条纪律:
133
+ * 判据只有 `resolveExperimental` 一份,面板算 pending、启动算生效值都走它。
134
+ *
135
+ * 为什么不并进 limits:默认值与告警口径不同 —— limits 改错是「多给/少给上下文」,
136
+ * 实验性开关改错是「该来的记忆没来」,两者在面板上要说的话也不一样。
137
+ */
138
+ export function pendingExperimental(fileObj) {
139
+ return resolveExperimental(fileObj?.experimental).experimental
140
+ }
141
+
142
+ /**
143
+ * 校验 `setSettings({ experimental })` 的那一段。
144
+ *
145
+ * 与 limits 的严格语义一致:**绝不静默丢不认识的键**,越界直接报错(不夹到边界)——
146
+ * 面板上手输的数字越界是笔误,静默改成别的值比报错更让人困惑。
147
+ *
148
+ * @returns {{ok:true, patch:object|null}|{ok:false, error:string}}
149
+ */
150
+ export function parseExperimentalPatch(experimental) {
151
+ if (experimental === undefined) return { ok: true, patch: null }
152
+ if (!experimental || typeof experimental !== 'object' || Array.isArray(experimental)) {
153
+ return { ok: false, error: 'sage-mem: experimental must be an object' }
154
+ }
155
+ const patch = {}
156
+ for (const [key, value] of Object.entries(experimental)) {
157
+ const spec = EXPERIMENTAL_SPECS[key]
158
+ if (!spec) return { ok: false, error: `sage-mem: unknown experimental setting: ${key}` }
159
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
160
+ return { ok: false, error: `sage-mem: experimental.${key} must be a number, got ${typeof value}` }
161
+ }
162
+ const rounded = Math.round(value)
163
+ if (rounded < spec.lo || rounded > spec.hi) {
164
+ return { ok: false, error: `sage-mem: experimental.${key} must be an integer in [${spec.lo}, ${spec.hi}], got ${value}` }
165
+ }
166
+ patch[key] = rounded
167
+ }
168
+ return { ok: true, patch: Object.keys(patch).length ? patch : null }
169
+ }
170
+
130
171
  /** 读 `<状态根>/reserved.json`(用户声明的保护名单)。**保留原始大小写**,好让 get 读回一致。 */
131
172
  export async function readReservedFile() {
132
173
  try {
@@ -230,8 +230,7 @@ const lockResultSchema = z.object({
230
230
  'locked': z.boolean().readonly(),
231
231
  }).readonly()
232
232
 
233
- // 设置:limits = 当前**已生效**的值(模块加载时读的),pendingLimits = 待生效值
234
- // (settings.json > 环境变量 > 默认)。两个都给,界面才说得清「改了要重启」。
233
+ // 设置:limits = 已生效值,pendingLimits = 待生效值(settings.json > 环境变量 > 默认);两个都给,界面才说得清「改了要重启」。
235
234
  const settingsLimitsSchema = z.object({
236
235
  'maxResults': z.number().readonly(),
237
236
  'maxChars': z.number().readonly(),
@@ -239,8 +238,7 @@ const settingsLimitsSchema = z.object({
239
238
  'maxSessionBytes': z.number().readonly(),
240
239
  'staleDays': z.number().readonly(),
241
240
  }).readonly()
242
- // 1.8.0 / 2.7.0:面板 UI 设置。`entry` 是**客户端行为**(改完立刻生效),不进 restartRequired。
243
- // `sidebarEntry` 是给 2.6.0 客户端的派生值(= entry !== 'settings'),永远随 `entry` 一起返回。
241
+ // 1.8.0 / 2.7.0:面板 UI 设置。`entry` 是客户端行为(改完立刻生效,不进 restartRequired);`sidebarEntry` 是给 2.6.0 客户端的派生值。
244
242
  const settingsUiSchema = z.object({ 'entry': z.enum(['both', 'sidebar', 'settings']).readonly(), 'sidebarEntry': z.boolean().readonly() }).readonly()
245
243
  const settingsSchema = z.object({
246
244
  'limits': settingsLimitsSchema,
@@ -249,6 +247,8 @@ const settingsSchema = z.object({
249
247
  'ui': settingsUiSchema,
250
248
  'reserved': z.array(z.string()).readonly(),
251
249
  'reservedExtra': z.array(z.string()).readonly(),
250
+ 'experimental': z.object({ 'mmr': z.number().readonly() }).readonly(),
251
+ 'pendingExperimental': z.object({ 'mmr': z.number().readonly() }).readonly(),
252
252
  }).readonly()
253
253
  const settingsPatchSchema = z.object({
254
254
  'limits': z.object({
@@ -263,8 +263,8 @@ const settingsPatchSchema = z.object({
263
263
  'sidebarEntry': z.boolean().optional(),
264
264
  }).readonly().optional(),
265
265
  'reserved': z.array(z.string()).optional(),
266
+ 'experimental': z.object({ 'mmr': z.number().optional() }).readonly().optional(),
266
267
  }).readonly()
267
- // 成功回 { ok, settings, restartRequired },失败回 { ok:false, error } —— 两条分支并集。
268
268
  const settingsResultSchema = z.object({
269
269
  'ok': z.boolean().readonly(),
270
270
  'error': z.string().readonly().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.7.1",
4
+ "version": "2.9.0",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "icon": "./icon.png",