@modusensus/dsh-mneme 0.8.10 → 0.8.12

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.
Files changed (45) hide show
  1. package/README.md +5 -5
  2. package/assets/icon.svg +3 -0
  3. package/lib/client.js +190 -58
  4. package/lib/config.js +47 -1
  5. package/lib/dream/decisions.js +16 -0
  6. package/lib/dream/sleep.js +3 -0
  7. package/lib/dream.js +57 -9
  8. package/lib/entities/extractor.js +4 -1
  9. package/lib/graph/anchoring.js +180 -0
  10. package/lib/index.js +5 -0
  11. package/lib/inject.js +15 -3
  12. package/lib/lang.js +10 -0
  13. package/lib/service.js +164 -23
  14. package/lib/settings.js +24 -2
  15. package/lib/store.js +155 -11
  16. package/lib/summarize.js +104 -7
  17. package/locale/en.json +6 -0
  18. package/locale/zh.json +6 -0
  19. package/package.json +12 -7
  20. package/scripts/sync-test-badge.mjs +9 -7
  21. package/scripts/test-count-sync.mjs +44 -0
  22. package/src/config.js +47 -1
  23. package/src/dream/decisions.js +16 -0
  24. package/src/dream/sleep.js +3 -0
  25. package/src/dream.js +57 -9
  26. package/src/entities/extractor.js +4 -1
  27. package/src/graph/anchoring.js +180 -0
  28. package/src/index.js +5 -0
  29. package/src/inject.js +15 -3
  30. package/src/lang.js +10 -0
  31. package/src/service.js +164 -23
  32. package/src/settings.js +24 -2
  33. package/src/store.js +155 -11
  34. package/src/summarize.js +104 -7
  35. package/test/api.test.js +7 -2
  36. package/test/baseline-persist.test.js +248 -0
  37. package/test/client.test.js +245 -45
  38. package/test/dream.test.js +75 -0
  39. package/test/graph-anchoring.test.js +185 -0
  40. package/test/graph-inject-budget.test.js +104 -0
  41. package/test/graph-passive-confirm.test.js +92 -0
  42. package/test/graph-weight-evolution.test.js +105 -0
  43. package/test/scope-inject-soft.test.js +106 -0
  44. package/test/summarize.test.js +177 -0
  45. package/test/test-count-sync.test.js +60 -0
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![npm version](https://img.shields.io/npm/v/@modusensus/dsh-mneme?color=blue&label=npm)](https://www.npmjs.com/package/@modusensus/dsh-mneme)
6
6
  [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE)
7
7
  [![Awesome](https://awesome-dsh-plugin.com/badge.svg)](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
8
- [![tests](https://img.shields.io/badge/tests-1431%20passed-success)](https://github.com/slow-stack/mneme)
8
+ [![tests](https://img.shields.io/badge/tests-1497%20passed-success)](https://github.com/slow-stack/mneme)
9
9
  [![CI](https://img.shields.io/github/actions/workflow/status/slow-stack/mneme/ci.yml)](https://github.com/slow-stack/mneme/actions)
10
10
  [![node](https://img.shields.io/badge/node-22%2B-blue)](https://nodejs.org)
11
11
  [![npm downloads](https://img.shields.io/npm/d18m/@modusensus/dsh-mneme.svg?color=blue&label=downloads)](https://www.npmjs.com/package/@modusensus/dsh-mneme)
@@ -253,7 +253,7 @@ v0.3.0 起新增**记忆基因**层:从记忆里抽取**命名实体**、**带
253
253
 
254
254
  ### 前置条件
255
255
 
256
- - [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(DSH)— 兼容 DSH 0.1.x,已验证 0.1.2-rc.1(`Session.events` → `snapshotEvents()` 变更已由插件垫片兼容,新旧版本通吃;v0.7.9 确保该垫片真正进入发布产物 lib/)
256
+ - [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(DSH)— 兼容 DSH 0.1.x 与 0.2.x(三段式 peer 范围见 `package.json`);开发与 CI 已对齐 0.2 线,`0.2.0-rc.1` / `0.2.0-rc.2` 实测全绿。旧版兼容垫片(`Session.events` → `snapshotEvents()`,v0.7.9 起确保进入发布产物 lib/)保留
257
257
  - Node 24+(`node:sqlite`)
258
258
 
259
259
  ### 安装步骤
@@ -430,7 +430,7 @@ dsh web
430
430
  | `searchSemanticDedupThreshold` | `0.95` | 语义去重相似度阈值(v0.5.0,默认 0.95,范围 0.5-1.0):`searchSemanticDedup=true` 时生效,调整可防小模型误折叠 |
431
431
  | `memoryQualityFilter` | `{enabled:true, archiveThreshold:30, degradeThreshold:60, minContentLength:10, exemptImportance:4}` | 记忆质量过滤(v0.4.6,默认开):写库前启发式打分 0-100,元记忆词汇/自指/过短/重复/近似重复扣分;≥60 正常存储,30-60 降权(注入排序按 importance×quality/100),<30 归档标记 `low_quality`(显式搜索仍可召回,永不自动注入;`exemptImportance` 豁免:importance ≥ 该值只降权不归档,#135) |
432
432
  | `llmAudit` | `{enabled:true, retentionDays:90}` | LLM 消耗审计(v0.4.6,默认开):每次后台 LLM 调用(`autoDream` / `autoSummarize` / `sleep` / `entityExtract`,对应 `operation_type` 为 `dream_consolidate` / `dream_summarize` / `dream_narrative`、`summarize_compress`、`sleep_conflict` / `sleep_pattern`、`entity_extract`)写 `llm_audit_logs`(tokens/duration/status/source);失败记 error 不阻塞;只读 API `/api/dsh-mneme/semantic/llm-audit` + `/llm-audit/stats` |
433
- | `scopeEnabled` | `false` | 作用域隔离总开关(v0.8.0,issue #17):memory_save 按会话身份写入 agent / workspace 标注(agentPreset / 工作区路径,registry 反查取不到回退 header.cwd,再取不到 NULL);去重键扩展含作用域三元——跨作用域同标题不再物理合并;检索按当前会话作用域加权(命中 ×1.25、他 scope ×0.5 保留可见)。也走 feature_flags 白名单(面板可启停) |
433
+ | `scopeEnabled` | `false` | 作用域隔离总开关(v0.8.0,issue #17):memory_save 按会话身份写入 agent / workspace 标注(agentPreset / 工作区路径,registry 反查取不到回退 header.cwd,再取不到 NULL);去重键扩展含作用域三元——跨作用域同标题不再物理合并;检索与注入排序按当前会话作用域加权(命中 ×1.25、他 scope ×0.5 保留可见,issue #339 补齐注入通道)。也走 feature_flags 白名单(面板可启停) |
434
434
  | `strictScope` | `false` | 作用域硬过滤(v0.8.0 引入,v0.8.1 起只认显式声明;依赖 `scopeEnabled`):检索 / 注入 / 列表 / 单取四路过滤,**显式声明**收窄到他者作用域的记忆完全不可见;载体自动标注只降权保留可见——真正的物理隔离请用 sensitivity。会话身份解析不到时 fail-closed 只挡显式行。关闭时全部为软隔离(降权保留可见)。也走 feature_flags 白名单 |
435
435
  | `writeAdmission` | `{enabled:false, enforce:false}` | 写入准入(issue #254,默认关):把「这条该不该进库」前移到 LLM 之前。第 1 级是零 LLM 的确定性判据——空白 / 纯噪声 + 密钥 / PII(判据来源是 #164 A2,经 `sensitiveScan` 注入,本批只定义接口)。`enabled` 跑判据并落审计行(`llm_audit_logs` 的 `metadata.deny`),`enforce` 才真的拒(`store.save` 之前返回,`memory_save` 返回 `action:"denied"` + `reason`);只开 `enabled` = 仅告警、写入不拦。去重键命中不进第 1 级(归 write-update 放行),G1/G2 阈值只计量。也走 feature_flags 白名单 |
436
436
  | `conflictFreezeEnabled` | `false` | 冲突冻结(v0.4.4):dream 发现矛盾对不自动裁决,冻结进冲突队列;状态页「冲突队列」支持并排对比与人工确认(保留 A / 保留 B / 仅标记已处理,v0.8.0) |
@@ -584,7 +584,7 @@ src/
584
584
  ├── api.js # HTTP 路由(Web 面板数据通道,含 /conflicts 冲突队列)
585
585
  └── index.js # 插件接线
586
586
  lib/ # src 的同步分发产物(npm run sync;发布前由 root prepack 的 check-sync.js 校验一致性;唯一手写例外 lib/client.js——Web 面板 bundle,sync 不覆盖)
587
- test/ # 1431 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验)
587
+ test/ # 1497 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验)
588
588
  scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压测 · sync-lib.js 同步 · check-sync.js 发布闸门 · benchmark-recall.js / benchmark-embed.js / benchmark-rerank.js 基准 · sync-test-badge.mjs 测试徽章 · build-runtime-manifest.mjs 运行时清单
589
589
  ```
590
590
 
@@ -593,7 +593,7 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压
593
593
  ```bash
594
594
  cd dsh-mneme
595
595
  npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试)
596
- npm test # 运行 1431 个测试
596
+ npm test # 运行 1497 个测试
597
597
  npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM)
598
598
  npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行)
599
599
  ```
@@ -0,0 +1,3 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 16 16" fill="none" stroke="#4176e6" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
2
+ <path d="M4.43 12.31C2.19 10.17 2.27 6.81 4.18 4.84C6.10 2.88 9.06 2.99 10.74 4.68C12.43 6.37 12.28 8.92 10.82 10.33C9.35 11.74 7.22 11.55 6.08 10.32C4.95 9.08 5.18 7.35 6.18 6.50C6.57 6.17 7.03 5.99 7.48 5.97"/>
3
+ </svg>
package/lib/client.js CHANGED
@@ -68,19 +68,37 @@ window.__ModuleLoader__.load({
68
68
  if (ops.filter((o) => o.kind !== "same").length > 200) return null;
69
69
  return ops;
70
70
  }
71
- // #287 归档图标跨代兼容:primitives 在 0.1.7 把图标命名从「像素后缀」改成
72
- // 「字重后缀」(IconArchiveOutline20 → …OutlineRegular / …OutlineMedium),
73
- // 旧名不留别名,而 peerDependencies 同时覆盖 0.1.6 与 0.1.7 两代宿主——
74
- // 只认某一代的名字,另一代就会取到 undefined,h(undefined) 即 React #130
75
- // (整个 slot entry 崩掉)。故按「旧名 → 新名」顺序取第一个存在的;
76
- // 两代都缺时降级为不渲染图标,宁可无图标也不把 slot 打崩。
77
- const IconArchive = primitives.IconArchiveOutline20
78
- ?? primitives.IconArchiveOutlineRegular
79
- ?? primitives.IconArchiveOutlineMedium
80
- ?? null;
81
-
82
- /** 渲染归档图标;宿主未提供任一候选名时返回 null(无图标,不影响其余内容)。 */
83
- const renderArchiveIcon = (props) => (IconArchive ? h(IconArchive, props) : null);
71
+ // --- mneme mark:插件自己的标识图标 -------------------------------------
72
+ // 不再借宿主的 IconArchiveOutline*,两个原因:
73
+ // · 语义撞车——「归档盒」和记忆库里的归档动作是同一个符号,用户反馈看不出
74
+ // 这是记忆插件(issue 走维护者自开通道);
75
+ // · 宿主耦合——宿主图标名会换代(#287:Outline20 → OutlineRegular/Medium),
76
+ // 只认某一代就取到 undefined,h(undefined) 即 React #130 把整个 slot entry
77
+ // 崩掉。自绘后这一整类问题消失,也不再需要「探测多代名字 + 缺名降级」。
78
+ // 形状:单线螺旋(记忆痕迹 / 召回回路)。规格逐条对齐宿主图标,才不像外来户:
79
+ // 16 栅格、fill:none、stroke:currentColor、描边 1(宿主 Regular 档)、圆头圆角。
80
+ // 路径是 6 段三次贝塞尔拟合同一螺旋(拟合版与逐点采样版逐像素一致,已核验),
81
+ // 所以源码里只有 200 字符,不是一百多个采样点。
82
+ // 改形状只需换 MNEME_MARK_D,并同步 assets/icon.svg(插件列表那份,同一个 d)。
83
+ const MNEME_MARK_D = "M4.43 12.31C2.19 10.17 2.27 6.81 4.18 4.84C6.10 2.88 9.06 2.99 10.74 4.68C12.43 6.37 12.28 8.92 10.82 10.33C9.35 11.74 7.22 11.55 6.08 10.32C4.95 9.08 5.18 7.35 6.18 6.50C6.57 6.17 7.03 5.99 7.48 5.97";
84
+
85
+ /** mneme mark 的内联 SVG;currentColor + 描边 1,随主题与字号走。 */
86
+ const MnemeMark = ({ size = 16, className }) => h("svg", {
87
+ width: size,
88
+ height: size,
89
+ className,
90
+ viewBox: "0 0 16 16",
91
+ fill: "none",
92
+ xmlns: "http://www.w3.org/2000/svg",
93
+ "aria-hidden": "true",
94
+ stroke: "currentColor",
95
+ strokeWidth: 1,
96
+ strokeLinecap: "round",
97
+ strokeLinejoin: "round"
98
+ }, h("path", { d: MNEME_MARK_D }));
99
+
100
+ /** 渲染 mneme mark:四个入口(浮层标题栏 / footer 回退 / 侧边栏 portal / better-sidebar tab)共用同一颗。 */
101
+ const renderMnemeMark = (props) => h(MnemeMark, props);
84
102
 
85
103
  // Portal target for the hero fallback surface. The host whitelists
86
104
  // react-dom for its own bundles (dsh-client-ui-trajectory requires it);
@@ -1328,11 +1346,26 @@ window.__ModuleLoader__.load({
1328
1346
  ".mneme-heat--warm{color:var(--dsw-alias-label-secondary)}",
1329
1347
  ".mneme-heat--cold{color:var(--dsw-alias-label-tertiary)}",
1330
1348
  ".mneme-heat--cold .mneme-heatpct{display:none}",
1331
- // 纵向 flex 容器按原生按钮的宽度与外边距分配空间,保留展开和收起态的对齐。
1349
+ // 侧边栏入口两种形态的样式(挂载与搬移逻辑见 SidebarTopEntry 的头注释)。
1350
+ // 展开态兜底:逐条镜像宿主 panelRow(36px 行高 / 透明底 / 2px 外边距 /
1351
+ // 7px 8px 内边距)——宿主面板行类名在或不在,观感都是「插件 / 自动化任务」
1352
+ // 那一档,不会再退回 v0.8.10 那种高亮大按钮。
1353
+ // 收起态:按钮带宿主 newSession 类名,标题栏固定几何(position:fixed、
1354
+ // 顶部居中、28px 圆钮、次级文字色、hover 填充)整套继承宿主规则,我们只用
1355
+ // 一条更具体的规则把 left 从宿主的 48px 推到 84px——即「新会话」右侧,
1356
+ // 不再像 v0.8.10 那样压在它身上(用户实测确认的遮挡 bug)。
1357
+ // 84 = 48(宿主给「新会话」的 left)+ 28(钮宽)+ 8(间距);120 = 84 + 28 + 8,
1358
+ // 让「应用 / 编辑」菜单顺延到我们右侧。宿主侧出处:SidebarRoot.module.css 的
1359
+ // `.collapsed .newSession{left:48px}` 与
1360
+ // `html[data-windows-titlebar]:has([data-sidebar-collapsed=true]){--dsh-windows-menu-start:84px}`。
1332
1361
  ".mneme-topentry{display:flex;flex-direction:column;position:relative}",
1333
- ".mneme-topentry-native{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary)}",
1362
+ ".mneme-topentry-native{box-sizing:border-box;min-height:36px;border:none;border-radius:var(--dsw-radius-md);background:0 0;color:var(--dsw-alias-label-primary);font:inherit;text-align:left;cursor:pointer;align-items:center;gap:8px;margin:0 2px;padding:7px 8px;line-height:22px;display:flex}",
1334
1363
  ".mneme-topentry-native:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}",
1364
+ ".mneme-topentry-native:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:-2px}",
1335
1365
  ".mneme-topentry-native .mneme-topentry-label{white-space:nowrap}",
1366
+ "html[data-windows-titlebar] .mneme-topentry .mneme-topentry-rail{left:84px}",
1367
+ // 菜单让位只在「收起 + 入口确实挂在标题栏里」时生效;展开态完全不碰宿主变量。
1368
+ "html[data-windows-titlebar]:has([data-sidebar-collapsed=true]):has([data-plugin-entry=\"@modusensus/dsh-mneme\"]){--dsh-windows-menu-start:120px}",
1336
1369
  // --- 功能开关:一行一开关,Claude 式安静排版 ---
1337
1370
  ".mneme-featgroup{flex:none;font-size:12px;font-weight:600;letter-spacing:.02em;color:var(--dsw-alias-label-tertiary);margin:16px 0 2px}",
1338
1371
  ".mneme-featrow{display:flex;align-items:center;gap:14px;padding:11px 2px;border-bottom:1px solid var(--dsw-alias-border-l1)}",
@@ -3031,6 +3064,10 @@ window.__ModuleLoader__.load({
3031
3064
  // MemoryExplorer 内部状态(选中行、过滤)由各自的跳转回调处理。
3032
3065
  function openLibrary() {
3033
3066
  setOverlayOpen(true);
3067
+ // 打开面板 = 用户正准备处理冻结冲突:顺手把入口红点重取一次,别让他
3068
+ // 对着一个已经过期的计数(裁决走别的路径、或后台 autoDream 刚写过库时,
3069
+ // 计数不该等 60s 兜底轮询才对齐)。
3070
+ conflictBadgeCount.refresh();
3034
3071
  }
3035
3072
 
3036
3073
  // --- Hero fallback overlay state (module-level pub/sub) ---
@@ -3098,7 +3135,7 @@ window.__ModuleLoader__.load({
3098
3135
  h("div", { className: "mneme-overlay", role: "region", "aria-label": t("memory.view.label"), ref: panelRef },
3099
3136
  h("div", { className: "mneme-overlaybar" },
3100
3137
  h("span", { className: "mneme-overlaytitle" },
3101
- renderArchiveIcon({ size: 15 }),
3138
+ renderMnemeMark({ size: 15 }),
3102
3139
  t("memory.view.label")
3103
3140
  ),
3104
3141
  h("button", {
@@ -4924,32 +4961,80 @@ window.__ModuleLoader__.load({
4924
4961
  );
4925
4962
  }
4926
4963
 
4927
- // --- Sidebar entry: 工作区上方的记忆按钮 ---
4964
+ // --- Sidebar entry: 侧边栏里的记忆入口 ---
4928
4965
  // 注册仍在 sidebar.footer.action(list 插槽)——它既是 React 树的挂载
4929
- // 锚点,也是 portal 失效时的原位回退。真实按钮 portal 插到「新会话」行
4930
- // 之后、插件入口组的最前:任务看板/技能中心等生态插件也把入口插在新会话
4931
- // 行后(它们的后插入者在上),若我们固定在 regionArea 之前就会被打成
4932
- // 入口组末尾的孤立位(issue #130),所以观察器发现错位就搬回锚点位。
4933
- // 几何对齐方案:读取宿主「新会话」按钮的实时 className 原样套用,再用
4934
- // 我们的修饰类覆盖配色(次级观感)。这样展开态与收起态(rail)都直接
4935
- // 继承宿主自己的盒模型与间距——宿主改版/缩进动画零位移,无需硬编码。
4966
+ // 锚点,也是 portal 失效时的原位回退;常驻 sheet 也从这个插槽挂载。
4967
+ // 入口形状跟着宿主 v0.2 的侧边栏走,分两态:
4968
+ // · 展开态:宿主把「插件 / 自动化任务」这类全局面板渲染成 nav.panelList
4969
+ // 里的 panelRow(36px 行高、透明底、图标 + 标题)。我们把按钮 portal 进
4970
+ // 同一组并原样套用宿主 panelRow 的实时类名,于是它是该组的第三行——低调,
4971
+ // 而不是 v0.8.10 那样长得像第二个高亮「新会话」按钮。
4972
+ // · 收起态(Windows 标题栏):panelList / regionArea / footArea 被宿主
4973
+ // display:none,只有开关与「新会话」被改成固定在标题栏上。我们把按钮搬回
4974
+ // 标题栏、并排在「新会话」右侧(84px / 120px 的算法见上面 CSS 注释),
4975
+ // 三颗图标各就各位——v0.8.10 把入口留在宿主的 48px 上把它盖住了。
4976
+ // 承载类名一律从活元素读取(宿主/皮肤会异步改写哈希类名),不硬编码;
4977
+ // 面板列表不存在(没装任何面板插件)时退回「新会话」行之后。
4936
4978
  // 宿主结构异常时约 2 秒后放弃 portal,footer 回退按钮保持可用。
4937
- // #177:未处理冲突数 badge——数据走 dream-status 的 pendingConflicts(状态卡
4938
- // 同一端点),60s 轮询 + 打开面板即刷新。挂侧边栏入口按钮右上角(portal 与
4939
- // 回退两处都挂),让用户不进面板也知道有冻结要裁决。
4940
- function useConflictBadgeCount(t) {
4941
- const [pending, setPending] = useState(0);
4979
+ // #177:未处理冲突数 badge——数据走 dream-status 的 pendingConflicts(与状态卡
4980
+ // 同一端点)。挂侧边栏入口按钮右上角(portal 与回退两处都挂),让用户不进面板
4981
+ // 也知道有冻结要裁决。
4982
+ // 刷新时机(用户反馈「裁决完红点消失得很慢」后重做):
4983
+ // · 裁决成功时冲突队列广播 mneme:conflicts-changed → 立刻重取,不必等下一轮;
4984
+ // 此前这条广播只有状态卡订阅,红点漏订,才慢到要等 60s 兜底轮询;
4985
+ // · openLibrary 打开面板时重取(见上);
4986
+ // · 60s 兜底轮询——后台 autoDream 自己产生/消解冲突时没人广播,靠它浮现。
4987
+ // 计数是模块级单例:入口按钮的 portal 与 footer 回退是两个组件,原先各起一个
4988
+ // 60s 轮询(两份请求、两个互不同步的计数)。现在共享一个 store:一次取回广播给
4989
+ // 全部订阅者,in-flight 请求也去重,最后一个订阅者离开才停表。
4990
+ const conflictBadgeCount = (() => {
4991
+ const listeners = new Set();
4992
+ let value = 0;
4993
+ let timer = null;
4994
+ let inflight = null;
4995
+ const publish = (next) => {
4996
+ if (next === value) return;
4997
+ value = next;
4998
+ listeners.forEach((fn) => {
4999
+ try { fn(value); } catch { /* 单个订阅者出错不拖垮其余 */ }
5000
+ });
5001
+ };
5002
+ const refresh = () => {
5003
+ if (inflight) return inflight;
5004
+ inflight = apiFetch("/api/dsh-mneme/dream-status")
5005
+ .then((res) => (res.ok ? res.json() : { pendingConflicts: 0 }))
5006
+ .then((d) => { publish(Number((d && d.pendingConflicts) ?? 0) || 0); })
5007
+ .catch(() => { publish(0); })
5008
+ .finally(() => { inflight = null; });
5009
+ return inflight;
5010
+ };
5011
+ return {
5012
+ get: () => value,
5013
+ refresh,
5014
+ subscribe: (fn) => {
5015
+ listeners.add(fn);
5016
+ fn(value);
5017
+ if (listeners.size === 1) {
5018
+ refresh();
5019
+ timer = setInterval(refresh, 60_000);
5020
+ }
5021
+ return () => {
5022
+ listeners.delete(fn);
5023
+ if (listeners.size === 0 && timer !== null) { clearInterval(timer); timer = null; }
5024
+ };
5025
+ }
5026
+ };
5027
+ })();
5028
+ function useConflictBadgeCount() {
5029
+ const [pending, setPending] = useState(() => conflictBadgeCount.get());
4942
5030
  useEffect(() => {
4943
- let cancelled = false;
4944
- const tick = () => {
4945
- apiFetch("/api/dsh-mneme/dream-status")
4946
- .then((res) => (res.ok ? res.json() : { pendingConflicts: 0 }))
4947
- .then((d) => { if (!cancelled) setPending(Number((d && d.pendingConflicts) ?? 0) || 0); })
4948
- .catch(() => { if (!cancelled) setPending(0); });
5031
+ const unsubscribe = conflictBadgeCount.subscribe(setPending);
5032
+ const onChanged = () => { conflictBadgeCount.refresh(); };
5033
+ window.addEventListener("mneme:conflicts-changed", onChanged);
5034
+ return () => {
5035
+ unsubscribe();
5036
+ window.removeEventListener("mneme:conflicts-changed", onChanged);
4949
5037
  };
4950
- tick();
4951
- const timer = setInterval(tick, 60_000);
4952
- return () => { cancelled = true; clearInterval(timer); };
4953
5038
  }, []);
4954
5039
  return pending;
4955
5040
  }
@@ -4959,7 +5044,7 @@ window.__ModuleLoader__.load({
4959
5044
  pending > 99 ? "99+" : String(pending));
4960
5045
  }
4961
5046
  function SidebarFallbackTrigger({ wide, t }) {
4962
- const pending = useConflictBadgeCount(t);
5047
+ const pending = useConflictBadgeCount();
4963
5048
  // #295 评审:.mneme-trigger 自身 overflow:hidden 且无定位上下文,badge
4964
5049
  // 直接放里面会被裁剪——包一层定位容器,badge 挂在容器上。
4965
5050
  return h("div", { className: "mneme-triggerwrap" },
@@ -4973,7 +5058,7 @@ window.__ModuleLoader__.load({
4973
5058
  onClick: openLibrary,
4974
5059
  "data-mneme-overlay-opener": "true"
4975
5060
  },
4976
- renderArchiveIcon({ size: wide ? 16 : 18 }),
5061
+ renderMnemeMark({ size: wide ? 16 : 18 }),
4977
5062
  wide && h("span", { className: "mneme-trigger-label" }, t("memory.panel.open"))
4978
5063
  ),
4979
5064
  h(ConflictBadge, { pending })
@@ -4982,14 +5067,25 @@ window.__ModuleLoader__.load({
4982
5067
 
4983
5068
  function SidebarTopEntry({ wide, t, fallback }) {
4984
5069
  const [host, setHost] = useState(null);
4985
- const [nativeCls, setNativeCls] = useState("");
4986
- const pending = useConflictBadgeCount(t);
5070
+ const [cls, setCls] = useState({ row: "", glyph: "", title: "" });
5071
+ const pending = useConflictBadgeCount();
4987
5072
  useEffect(() => {
4988
5073
  if (!reactDom || typeof document === "undefined") return undefined;
4989
5074
  let tries = 0, timer = null, created = null, mo = null;
5075
+ const findRegion = () => {
5076
+ const region = document.querySelector('[class*="regionArea"]');
5077
+ return region && region.parentElement ? region : null;
5078
+ };
4990
5079
  const findNative = (region) =>
4991
5080
  region.parentElement.querySelector('[class*="newSession"]')
4992
5081
  || region.previousElementSibling;
5082
+ const findPanelList = (region) =>
5083
+ region.parentElement.querySelector('[class*="panelList"]');
5084
+ // 宿主自己的面板行:排除我们自己 portal 进去的那颗按钮(它也带 panelRow)。
5085
+ const findPanelRow = (region) => {
5086
+ const list = findPanelList(region);
5087
+ return list ? list.querySelector('[class*="panelRow"]:not(.mneme-topentry-native)') : null;
5088
+ };
4993
5089
  // 占位锚点:新会话行(现役外壳里 newSession 按钮嵌在 logoRow 内,
4994
5090
  // 旧外壳是根的直接子按钮)。判定与生态插件的 sidebar-entry-core
4995
5091
  // 一致;锚点不可靠时回退到 regionArea 之前的旧位置。
@@ -5008,32 +5104,61 @@ window.__ModuleLoader__.load({
5008
5104
  // insertBefore(x, x) 在 Chromium 里不是 no-op,会触发 mutation 造成
5009
5105
  // 观察器自激风暴、冻死整个 SPA。
5010
5106
  const place = (region) => {
5107
+ if (!created) return;
5011
5108
  const parent = region.parentElement;
5012
5109
  if (!parent) return;
5110
+ // 展开态落进宿主的面板列表(紧跟「插件 / 自动化任务」);宿主没有面板
5111
+ // 列表时退回「新会话」行之后,由 .mneme-topentry-native 兜住低调行样式。
5112
+ const list = wide ? findPanelList(region) : null;
5113
+ if (list) {
5114
+ if (created.parentElement === list && created === list.lastElementChild) return;
5115
+ list.appendChild(created);
5116
+ return;
5117
+ }
5013
5118
  const anchor = findAnchor(region);
5014
5119
  const target = anchor ? anchor.nextSibling : region;
5015
5120
  if (target === created) return;
5016
5121
  if (created.parentElement === parent && created.nextSibling === target) return;
5017
5122
  parent.insertBefore(created, target);
5018
5123
  };
5124
+ // 类名一律从活元素读。展开态借宿主面板行的盒模型,并剔掉激活态修饰类
5125
+ // (否则我们的行会跟着宿主当前选中的面板一起常亮);收起态借「新会话」
5126
+ // 按钮的类名拿整套标题栏固定几何。两处都取不到时留空,由我们的类兜底。
5127
+ const readCls = (region) => {
5128
+ if (!wide) return { row: findNative(region)?.className || "", glyph: "", title: "" };
5129
+ const row = findPanelRow(region);
5130
+ const btn = row || findNative(region);
5131
+ const glyph = row && row.querySelector('[class*="panelGlyph"]');
5132
+ const title = row && row.querySelector('[class*="panelTitle"]');
5133
+ return {
5134
+ row: (btn?.className || "")
5135
+ .split(/\s+/)
5136
+ .filter((name) => name && !name.includes("panelActive"))
5137
+ .join(" "),
5138
+ glyph: (glyph && glyph.className) || "",
5139
+ title: (title && title.className) || ""
5140
+ };
5141
+ };
5142
+ const sync = (region) => {
5143
+ const next = readCls(region);
5144
+ setCls((prev) => (prev.row === next.row && prev.glyph === next.glyph && prev.title === next.title ? prev : next));
5145
+ };
5019
5146
  const attempt = () => {
5020
- const region = document.querySelector('[class*="regionArea"]');
5021
- if (region && region.parentElement) {
5022
- // 「新会话」按钮与 regionArea 同级且紧邻其前,借它的类名获得
5023
- // 与原生侧边栏项完全一致的盒模型(含收起态 rail 几何)。
5024
- // 宿主/皮肤会异步改写按钮类名,用 MutationObserver 持续同步,
5025
- // 否则 portal 按钮会停留在捕获时刻的旧类上(宽度/对齐失配)。
5147
+ const region = findRegion();
5148
+ if (region) {
5149
+ // 宿主切换展开/收起时会重排 DOM(面板列表 → 标题栏固定位),所以每次
5150
+ // 变动都重新落位并重读类名;宿主/皮肤异步改写类名也靠这条同步,否则
5151
+ // portal 按钮会停留在捕获时刻的旧类上(宽度/对齐失配)。
5026
5152
  created = document.createElement("div");
5027
5153
  created.dataset.pluginEntry = "@modusensus/dsh-mneme";
5028
5154
  place(region);
5029
5155
  setHost(created);
5030
- setNativeCls(findNative(region)?.className || "");
5156
+ sync(region);
5031
5157
  mo = new MutationObserver(() => {
5032
- const cur = document.querySelector('[class*="regionArea"]');
5033
- if (!cur || !cur.parentElement) return;
5158
+ const cur = findRegion();
5159
+ if (!cur) return;
5034
5160
  place(cur);
5035
- const cls = findNative(cur)?.className || "";
5036
- setNativeCls((prev) => (prev === cls ? prev : cls));
5161
+ sync(cur);
5037
5162
  });
5038
5163
  mo.observe(region.parentElement, {
5039
5164
  attributes: true,
@@ -5052,13 +5177,19 @@ window.__ModuleLoader__.load({
5052
5177
  if (mo) mo.disconnect();
5053
5178
  if (created && created.parentElement) created.parentElement.removeChild(created);
5054
5179
  };
5055
- }, []);
5180
+ }, [wide]);
5056
5181
  if (!host) return fallback;
5182
+ // 图标与「插件 / 自动化任务」同档(16px);收起态沿用标题栏里原来的 18px。
5183
+ const icon = renderMnemeMark({ size: wide ? 16 : 18 });
5057
5184
  return reactDom.createPortal(
5058
5185
  h("div", { className: "mneme-topentry" },
5059
5186
  h("button", {
5060
5187
  type: "button",
5061
- className: `${nativeCls} mneme-topentry-native`.trim(),
5188
+ // 展开态:宿主面板行类名 + 我们的同款兜底;收起态:宿主「新会话」类名
5189
+ // + 只覆盖 left 的 rail 规则(整套标题栏几何继承宿主)。
5190
+ className: wide
5191
+ ? `${cls.row} mneme-topentry-native`.trim()
5192
+ : `${cls.row} mneme-topentry-rail`.trim(),
5062
5193
  "aria-label": pending > 0
5063
5194
  ? `${t("memory.sidebar.aria")} · ${t("memory.status.conflicts")} ${pending}`
5064
5195
  : t("memory.sidebar.aria"),
@@ -5066,8 +5197,9 @@ window.__ModuleLoader__.load({
5066
5197
  onClick: openLibrary,
5067
5198
  "data-mneme-overlay-opener": "true"
5068
5199
  },
5069
- renderArchiveIcon({ size: wide ? 15 : 18 }),
5070
- wide && h("span", { className: "mneme-topentry-label" }, t("memory.panel.open")),
5200
+ // 图标套上宿主面板行的槽位类名,视觉与同组入口完全一致;取不到时裸渲染。
5201
+ cls.glyph ? h("span", { className: cls.glyph, "aria-hidden": "true" }, icon) : icon,
5202
+ wide && h("span", { className: `${cls.title} mneme-topentry-label`.trim() }, t("memory.panel.open")),
5071
5203
  h(ConflictBadge, { pending })
5072
5204
  )
5073
5205
  ), host);
@@ -5128,7 +5260,7 @@ window.__ModuleLoader__.load({
5128
5260
  reg.registerTab({
5129
5261
  id: TAB_ID,
5130
5262
  title: () => t("memory.view.label"),
5131
- icon: (size) => renderArchiveIcon({ size }),
5263
+ icon: (size) => renderMnemeMark({ size }),
5132
5264
  order: 60,
5133
5265
  component: () => h(MemoryExplorer, { t })
5134
5266
  });
package/lib/config.js CHANGED
@@ -281,6 +281,14 @@ export const Config = z.object({
281
281
  // 显式开启后放宽跨类型合并检查(类型边界由用户自行承担);配合
282
282
  // dreamSkipInvalid 理解:关闭 skipInvalid 时跨类型 merge 直接整单拒绝。
283
283
  allowCrossTypeMerge: z.boolean().default(false),
284
+ // issue #339 / E8 考卷:merge 护栏(opt-in,默认关 = 现状)。开启后合并对象
285
+ // 命中长保留类型(preference/pattern/rejected_solution/constraint/pitfall,
286
+ // 与 archive 护栏同一张表)的 merge 决策整条跳过——E8 实测巩固损耗里
287
+ // 10/26 条被丢约束已归位 guarded 类型仍被 merge 吃掉,而 archive 护栏只挡
288
+ // archive 不挡 merge,「更精炼的摘要」恰是约束失真的主通道。skipInvalid
289
+ // (默认开)时被跳条目进 dream_runs.skipped、run 记 degraded;关闭
290
+ // skipInvalid 时整单拒绝。与 allowCrossTypeMerge 同用时长 guard 先判。
291
+ dreamMergeGuard: z.boolean().default(false),
284
292
  // Rule version for dream adjudication: when this bumps, older dream_runs
285
293
  // degrade to historical evidence (their receipts no longer drive live
286
294
  // decisions). Default 0 = no versioning in use yet.
@@ -463,6 +471,35 @@ export const Config = z.object({
463
471
  // 检索融合池(与 BM25 同级的确认/回填信号)。默认关=检索行为与 #219 前
464
472
  // 逐字节一致;走 feature_flags 面板可启停,lightMode 强制关闭。
465
473
  entityRecallEnabled: z.boolean().default(false),
474
+ // 边权重演化(issue #24 块2):touch 门控是否把被寻回路径的边权重抬升。
475
+ // 默认关=边初值纯由建边来源决定(store.RELATION_SOURCE_DEFAULTS),演化
476
+ // 不生效;开=被激活的边经 bumpRelationWeight 上涨(只加不减、封顶 1.0)。
477
+ graphWeightEnabled: z.boolean().default(false),
478
+ // 单次有效触达的抬升幅度。
479
+ graphWeightDelta: z.number().min(0).max(1).default(0.1),
480
+ // 被动确认(issue #24 块4):heat 触达(记忆被召回/注入=被用)即对该记忆
481
+ // 挂联的关系边做被动确认——bumpRelationWeight 抬一格。默认关=零行为变化;
482
+ // 开=正常触达演化边权,「关系是用出来的」闭环落地(异常路径复核留后续块)。
483
+ graphPassiveConfirm: z.boolean().default(false),
484
+
485
+ // --- 图谱锚定层(issue #24 · 块1 Activation Anchoring)-------------------
486
+ // entityRecallEnabled 之上再开一层:以查询命中的实体为种子,沿实体关系表
487
+ // 级联扩散(默认深度 2),把「邻居实体挂联的记忆」也带进融合池。种子集
488
+ // 上限 graphSeedCap、级联深度 graphCascadeDepth 一并下发到 anchorSeeds /
489
+ // cascadeDepths(见 src/graph/anchoring.js)。默认关=行为与 #219 单跳轴
490
+ // 逐字节一致;走 feature_flags 面板可启停,lightMode 强制关闭。
491
+ graphAnchoringEnabled: z.boolean().default(false),
492
+ graphSeedCap: z.natural().min(1).max(30).default(12),
493
+ graphCascadeDepth: z.natural().min(1).max(3).default(2),
494
+
495
+ // 关联提示防幻觉(issue #24 块3):图召回的候选(查询命中实体的挂联记忆)
496
+ // 进注入时默认只参与排序、不改变注入块构成(graphInjectHint=false 保守档);
497
+ // 开启后注入文本里以固定句式「[检索线索] …」标出,引导语说明这是链路信息
498
+ // 非事实断言,防 LLM 单次消费把桥接召回当有依据的事实回头扩散。
499
+ graphInjectHint: z.boolean().default(false),
500
+ // 图召回候选在注入块里的独立预算(条数上限)。与 pin/document 预算同构:
501
+ // 不占 maxInjectedItems 名额、只约束「桥接线索」这类低信任信息的常驻占比。
502
+ graphInjectBudget: z.natural().min(0).max(5).default(1),
466
503
 
467
504
  // --- sleep mode: idle-triggered deep maintenance (v0.4.0) ---------------
468
505
  // Opt-in, off by default. Unlike autoDream (threshold-triggered, lightweight)
@@ -685,7 +722,8 @@ export const Config = z.object({
685
722
  // agent_scope, workspace_scope, sensitivity)。检索侧加权/过滤在 A2/A3 落地。
686
723
  // 也走 feature_flags(FEATURE_FLAG_BOOLEANS 白名单),面板可启停=线上回滚开关。
687
724
  scopeEnabled: z.boolean().default(false),
688
- // strictScope(A3):硬过滤模式。关闭=A2 软隔离(他 scope 降权保留可见);
725
+ // strictScope(A3):硬过滤模式。关闭=A2 软隔离(他 scope 降权保留可见,
726
+ // 检索与注入排序同权重,issue #339 补齐注入通道);
689
727
  // 开启后检索/注入/list/get 按 issue #17 四象限可见性公式硬过滤——带他 scope
690
728
  // 的记忆完全不可见,未标注(NULL)恒可见;当前会话某维度解析不到时该维度
691
729
  // 带标注的记忆一律不可见(fail-closed:身份不明只见全局)。依赖 scopeEnabled
@@ -712,6 +750,14 @@ const LIGHT_MODE_OFF = [
712
750
  "bm25SearchEnabled",
713
751
  // 轻量模式不开图召回轴(#219,依赖实体抽取产出;抽取本身已被关掉)。
714
752
  "entityRecallEnabled",
753
+ // 轻量模式不开图谱锚定层级联(#24 块1,依赖实体抽取 + 关系表,抽取已关则无边可扩)。
754
+ "graphAnchoringEnabled",
755
+ // 轻量模式不开边权重演化(#24 块2,依赖实体抽取产出;抽取关则无 v08 边可抬升)。
756
+ "graphWeightEnabled",
757
+ // 轻量模式不开关联提示标注(#24 块3,常驻注入文本多一段提示成本)。
758
+ "graphInjectHint",
759
+ // 轻量模式不开被动确认(#24 块4,依赖实体抽取产出;抽取关则无 v08 边可抬升)。
760
+ "graphPassiveConfirm",
715
761
  // 轻量模式不开叙述条(额外 LLM 调用;#164 对齐,opt-in)。
716
762
  "dreamNarrativeEnabled",
717
763
  // 轻量模式不开 document 指针行(#230,opt-in:注册/注入/检索增强全随闸)。
@@ -210,6 +210,22 @@ export function validateDecisions(decisions, snapshot, options = {}) {
210
210
  if (mergeTypes.size > 1 && options.allowCrossTypeMerge !== true) {
211
211
  local.push(`${at}: merge ids span multiple types (${[...mergeTypes].join(", ")})`);
212
212
  }
213
+ // issue #339 / E8 考卷:merge 护栏(opt-in,dreamMergeGuard)。巩固损耗
214
+ // 实测里 10/26 条被丢约束已归位 guarded 类型仍被 merge 吃掉——archive
215
+ // 护栏(上方)只挡 archive 不挡 merge,而 merge 的「更精炼摘要」恰是
216
+ // 约束失真的主通道。开启后被合并对象命中 ARCHIVE_GUARDED_TYPES 的
217
+ // merge 决策整条跳过(与 archive 护栏同款通道:skipInvalid 时 skipped
218
+ // 明细、严格模式整单拒绝)。
219
+ if (local.length === 0 && options.mergeGuard === true) {
220
+ const guardedTypes = new Set();
221
+ for (const id of d.ids) {
222
+ const mem = snapshot.get(id);
223
+ if (mem && ARCHIVE_GUARDED_TYPES.has(mem.type)) guardedTypes.add(mem.type);
224
+ }
225
+ if (guardedTypes.size > 0) {
226
+ local.push(`${at}: merge of long-retention type(s) ${[...guardedTypes].join(", ")} is blocked by dreamMergeGuard`);
227
+ }
228
+ }
213
229
  }
214
230
  }
215
231
  if (local.length > 0) {
@@ -373,6 +373,9 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
373
373
  minAgeHours: config.reflectionUpdateMinAgeHours,
374
374
  skipInvalid: config.dreamSkipInvalid !== false,
375
375
  allowCrossTypeMerge: config.allowCrossTypeMerge === true,
376
+ // issue #339 / E8:guarded 类型 merge 护栏(opt-in,dreamMergeGuard),
377
+ // 与 dream 主链路同一判据。
378
+ mergeGuard: config.dreamMergeGuard === true,
376
379
  // Issue #126 review(Copilot):默认档必须真的只放行旧动作集——只换 prompt 挡不住
377
380
  // 模型自发输出 supersede / differentiate,那样"opt-in 零行为变化"就不成立。
378
381
  allowedActions: actionSet === "full" ? null : LEGACY_ACTIONS