@modusensus/dsh-mneme 0.8.9 → 0.8.11

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
@@ -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-1405%20passed-success)](https://github.com/slow-stack/mneme)
8
+ [![tests](https://img.shields.io/badge/tests-1437%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)
@@ -432,6 +432,7 @@ dsh web
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
433
  | `scopeEnabled` | `false` | 作用域隔离总开关(v0.8.0,issue #17):memory_save 按会话身份写入 agent / workspace 标注(agentPreset / 工作区路径,registry 反查取不到回退 header.cwd,再取不到 NULL);去重键扩展含作用域三元——跨作用域同标题不再物理合并;检索按当前会话作用域加权(命中 ×1.25、他 scope ×0.5 保留可见)。也走 feature_flags 白名单(面板可启停) |
434
434
  | `strictScope` | `false` | 作用域硬过滤(v0.8.0 引入,v0.8.1 起只认显式声明;依赖 `scopeEnabled`):检索 / 注入 / 列表 / 单取四路过滤,**显式声明**收窄到他者作用域的记忆完全不可见;载体自动标注只降权保留可见——真正的物理隔离请用 sensitivity。会话身份解析不到时 fail-closed 只挡显式行。关闭时全部为软隔离(降权保留可见)。也走 feature_flags 白名单 |
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 白名单 |
435
436
  | `conflictFreezeEnabled` | `false` | 冲突冻结(v0.4.4):dream 发现矛盾对不自动裁决,冻结进冲突队列;状态页「冲突队列」支持并排对比与人工确认(保留 A / 保留 B / 仅标记已处理,v0.8.0) |
436
437
  | `conflictFreezeMaxPending` | `100` | 冲突冻结队列最大挂起数(1-1000):超出后不再入队(防队列无限膨胀) |
437
438
  | `escapePromptVariables` | `true` | 注入文本花括号转义(v0.8.0 恢复,issue #162):注入边界把 `{{...}}` 转义,防止 hot memory / 记忆原文里的 Go template / Vue 语法触发宿主 interpolate 抛错卡死会话(v0.7.4 曾修复、v0.7.11 误删) |
@@ -583,7 +584,7 @@ src/
583
584
  ├── api.js # HTTP 路由(Web 面板数据通道,含 /conflicts 冲突队列)
584
585
  └── index.js # 插件接线
585
586
  lib/ # src 的同步分发产物(npm run sync;发布前由 root prepack 的 check-sync.js 校验一致性;唯一手写例外 lib/client.js——Web 面板 bundle,sync 不覆盖)
586
- test/ # 1405 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验)
587
+ test/ # 1431 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验)
587
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 运行时清单
588
589
  ```
589
590
 
@@ -592,7 +593,7 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压
592
593
  ```bash
593
594
  cd dsh-mneme
594
595
  npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试)
595
- npm test # 运行 1405 个测试
596
+ npm test # 运行 1431 个测试
596
597
  npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM)
597
598
  npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行)
598
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>
@@ -386,7 +386,7 @@ export function createStandaloneApi({ service, store, config = {}, logger, setti
386
386
  }
387
387
  }
388
388
  try {
389
- const { action, memory } = service.saveWithDedupe({
389
+ const result = service.saveWithDedupe({
390
390
  type: body.type,
391
391
  title: body.title,
392
392
  content: body.content,
@@ -398,6 +398,16 @@ export function createStandaloneApi({ service, store, config = {}, logger, setti
398
398
  ...(body.agent_scope !== undefined ? { agent_scope: body.agent_scope, agent_scope_source: "explicit" } : {}),
399
399
  ...(body.workspace_scope !== undefined ? { workspace_scope: body.workspace_scope, workspace_scope_source: "explicit" } : {})
400
400
  });
401
+ const { action } = result;
402
+ // #254 写入准入:这条 HTTP 路径今天不带会话身份,evaluate 在无 sessionKey
403
+ // 时直接早退,所以拦不到;仍然显式分支——将来真接上会话身份时,落到
404
+ // `toApiList([null])` 上会变成 500,把「被拒」伪装成「服务器出错」,而
405
+ // 这两件事对调用方的处置完全不同(改内容 vs 重试)。
406
+ if (action === "denied") {
407
+ sendJson(res, 422, { error: "write-rejected", reason: result.reason });
408
+ return;
409
+ }
410
+ const { memory } = result;
401
411
  // action 随行透出(created/merged):memory_save 工具语义对齐所需,
402
412
  // 附加键对既有消费方(CLI add 等)向后兼容。
403
413
  sendJson(res, action === "created" ? 201 : 200, { ...service.toApiList([memory])[0], action });
package/lib/api.js CHANGED
@@ -135,9 +135,14 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
135
135
  ];
136
136
  // 嵌套键在 kv 里按点号平铺("memoryQualityFilter.enabled"),运行时 cfg 里
137
137
  // 是嵌套对象,effective 从对象子字段取值;其余键照旧从 cfg 顶层取。
138
+ // 这张表必须与 settings.js 白名单里的点号键一一对应:#254 加键时漏了这里,
139
+ // effective 会静默少两个键(configFlagValue 取不到 undefined 就不写进去),
140
+ // 面板上看不出来、PUT 也存不进去——test/api.test.js 的计数锁就是钉这件事的。
138
141
  const NESTED_FLAG_PATHS = {
139
142
  "memoryQualityFilter.enabled": ["memoryQualityFilter", "enabled"],
140
- "llmAudit.enabled": ["llmAudit", "enabled"]
143
+ "llmAudit.enabled": ["llmAudit", "enabled"],
144
+ "writeAdmission.enabled": ["writeAdmission", "enabled"],
145
+ "writeAdmission.enforce": ["writeAdmission", "enforce"]
141
146
  };
142
147
  function configFlagValue(key) {
143
148
  const path = NESTED_FLAG_PATHS[key];
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);
@@ -415,6 +433,8 @@ window.__ModuleLoader__.load({
415
433
  "memory.features.autoSummarize.hint": "对话结束自动提炼记忆条目",
416
434
  "memory.features.hotMemoryEnabled": "热记忆",
417
435
  "memory.features.hotMemoryEnabled.hint": "最近几轮对话原文随注入携带,不写入长期记忆",
436
+ "memory.features.injectTimePrefix": "对话开始注入当前时间",
437
+ "memory.features.injectTimePrefix.hint": "每次新会话的首轮注入一次日期与星期(默认关)",
418
438
  "memory.features.entityExtractionEnabled": "实体抽取",
419
439
  "memory.features.entityExtractionEnabled.hint": "从记忆中提取人物/项目/概念,图谱随之生长",
420
440
  "memory.features.entityExtractionProvider": "实体抽取 Provider",
@@ -466,6 +486,13 @@ window.__ModuleLoader__.load({
466
486
  "memory.features.memoryQualityFilter.enabled.hint": "低价值记忆自动归档,注入按质量降权",
467
487
  "memory.features.llmAudit.enabled": "后台调用审计",
468
488
  "memory.features.llmAudit.enabled.hint": "记录巩固/总结的后台模型调用与 token 消耗",
489
+ // #254:写入准入两层。检测关时整条路径不碰;检测开、拦截关 = 仅告警 +
490
+ // 留审计;两个都开才真拦。文案要写清「只留审计」与「真拦下」的区别,
491
+ // 否则操作者会以为打开检测就已经在拦。
492
+ "memory.features.writeAdmission.enabled": "写入准入判定",
493
+ "memory.features.writeAdmission.enabled.hint": "写入前判空白/噪声,命中留审计(不拦)",
494
+ "memory.features.writeAdmission.enforce": "写入准入拦截",
495
+ "memory.features.writeAdmission.enforce.hint": "真拦下命中的写入;关闭时只留审计不拦",
469
496
  "memory.features.bm25SearchEnabled": "BM25 关键词检索",
470
497
  "memory.features.bm25SearchEnabled.hint": "传统关键词打分检索,与向量召回互补",
471
498
  "memory.features.conflictFreezeEnabled": "冲突冻结",
@@ -839,6 +866,8 @@ window.__ModuleLoader__.load({
839
866
  "memory.features.autoSummarize.hint": "Distill memory entries when a conversation ends",
840
867
  "memory.features.hotMemoryEnabled": "Hot memory",
841
868
  "memory.features.hotMemoryEnabled.hint": "Carry the last few turns verbatim; never written to long-term memory",
869
+ "memory.features.injectTimePrefix": "Inject current time at conversation start",
870
+ "memory.features.injectTimePrefix.hint": "Adds the date and weekday once at each new session's first turn (off by default)",
842
871
  "memory.features.entityExtractionEnabled": "Entity extraction",
843
872
  "memory.features.entityExtractionEnabled.hint": "Extract people / projects / concepts so the graph grows by itself",
844
873
  "memory.features.entityExtractionProvider": "Entity extraction provider",
@@ -890,6 +919,12 @@ window.__ModuleLoader__.load({
890
919
  "memory.features.memoryQualityFilter.enabled.hint": "Archive low-value memories automatically; injection downranks by quality",
891
920
  "memory.features.llmAudit.enabled": "Background call audit",
892
921
  "memory.features.llmAudit.enabled.hint": "Log background model calls and token usage from consolidation / summaries",
922
+ // #254 — two layers: enabled runs the check (audit only), enforce actually
923
+ // blocks. The hints must keep that difference explicit.
924
+ "memory.features.writeAdmission.enabled": "Write admission checks",
925
+ "memory.features.writeAdmission.enabled.hint": "Flag blank / noise writes before storing, with an audit row (never blocks)",
926
+ "memory.features.writeAdmission.enforce": "Write admission enforcement",
927
+ "memory.features.writeAdmission.enforce.hint": "Actually reject flagged writes; off keeps the audit row only",
893
928
  "memory.features.bm25SearchEnabled": "BM25 keyword search",
894
929
  "memory.features.bm25SearchEnabled.hint": "Classic keyword scoring, complementary to vector recall",
895
930
  "memory.features.conflictFreezeEnabled": "Conflict freezing",
@@ -1311,11 +1346,26 @@ window.__ModuleLoader__.load({
1311
1346
  ".mneme-heat--warm{color:var(--dsw-alias-label-secondary)}",
1312
1347
  ".mneme-heat--cold{color:var(--dsw-alias-label-tertiary)}",
1313
1348
  ".mneme-heat--cold .mneme-heatpct{display:none}",
1314
- // 纵向 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}`。
1315
1361
  ".mneme-topentry{display:flex;flex-direction:column;position:relative}",
1316
- ".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}",
1317
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}",
1318
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}",
1319
1369
  // --- 功能开关:一行一开关,Claude 式安静排版 ---
1320
1370
  ".mneme-featgroup{flex:none;font-size:12px;font-weight:600;letter-spacing:.02em;color:var(--dsw-alias-label-tertiary);margin:16px 0 2px}",
1321
1371
  ".mneme-featrow{display:flex;align-items:center;gap:14px;padding:11px 2px;border-bottom:1px solid var(--dsw-alias-border-l1)}",
@@ -2072,7 +2122,7 @@ window.__ModuleLoader__.load({
2072
2122
  // test/inject-parent-gate.test.js 钉住。
2073
2123
  const FEATURE_CHILDREN = { autoInject: ["injectGuidanceEnabled", "continuityRescueEnabled"] };
2074
2124
  const FEATURE_GROUPS = [
2075
- { key: "group.core", items: ["autoInject", "autoSummarize", "hotMemoryEnabled", "memoryQualityFilter.enabled", "llmAudit.enabled"] },
2125
+ { key: "group.core", items: ["autoInject", "autoSummarize", "hotMemoryEnabled", "injectTimePrefix", "memoryQualityFilter.enabled", "llmAudit.enabled", "writeAdmission.enabled", "writeAdmission.enforce"] },
2076
2126
  { key: "group.enhance", items: ["entityExtractionEnabled", "codingRetrospect", "rerankEnabled", "resilientModelDownload", "searchSemanticDedup", "bm25SearchEnabled", "heatEnabled", "documentMemoryEnabled"] },
2077
2127
  { key: "group.dream", items: ["autoDream", "sleepModeEnabled"] },
2078
2128
  // v0.8.0 A4(issue #17):作用域隔离组——标注总开关 + 严格硬过滤。
@@ -3014,6 +3064,10 @@ window.__ModuleLoader__.load({
3014
3064
  // MemoryExplorer 内部状态(选中行、过滤)由各自的跳转回调处理。
3015
3065
  function openLibrary() {
3016
3066
  setOverlayOpen(true);
3067
+ // 打开面板 = 用户正准备处理冻结冲突:顺手把入口红点重取一次,别让他
3068
+ // 对着一个已经过期的计数(裁决走别的路径、或后台 autoDream 刚写过库时,
3069
+ // 计数不该等 60s 兜底轮询才对齐)。
3070
+ conflictBadgeCount.refresh();
3017
3071
  }
3018
3072
 
3019
3073
  // --- Hero fallback overlay state (module-level pub/sub) ---
@@ -3081,7 +3135,7 @@ window.__ModuleLoader__.load({
3081
3135
  h("div", { className: "mneme-overlay", role: "region", "aria-label": t("memory.view.label"), ref: panelRef },
3082
3136
  h("div", { className: "mneme-overlaybar" },
3083
3137
  h("span", { className: "mneme-overlaytitle" },
3084
- renderArchiveIcon({ size: 15 }),
3138
+ renderMnemeMark({ size: 15 }),
3085
3139
  t("memory.view.label")
3086
3140
  ),
3087
3141
  h("button", {
@@ -4907,32 +4961,80 @@ window.__ModuleLoader__.load({
4907
4961
  );
4908
4962
  }
4909
4963
 
4910
- // --- Sidebar entry: 工作区上方的记忆按钮 ---
4964
+ // --- Sidebar entry: 侧边栏里的记忆入口 ---
4911
4965
  // 注册仍在 sidebar.footer.action(list 插槽)——它既是 React 树的挂载
4912
- // 锚点,也是 portal 失效时的原位回退。真实按钮 portal 插到「新会话」行
4913
- // 之后、插件入口组的最前:任务看板/技能中心等生态插件也把入口插在新会话
4914
- // 行后(它们的后插入者在上),若我们固定在 regionArea 之前就会被打成
4915
- // 入口组末尾的孤立位(issue #130),所以观察器发现错位就搬回锚点位。
4916
- // 几何对齐方案:读取宿主「新会话」按钮的实时 className 原样套用,再用
4917
- // 我们的修饰类覆盖配色(次级观感)。这样展开态与收起态(rail)都直接
4918
- // 继承宿主自己的盒模型与间距——宿主改版/缩进动画零位移,无需硬编码。
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
+ // 面板列表不存在(没装任何面板插件)时退回「新会话」行之后。
4919
4978
  // 宿主结构异常时约 2 秒后放弃 portal,footer 回退按钮保持可用。
4920
- // #177:未处理冲突数 badge——数据走 dream-status 的 pendingConflicts(状态卡
4921
- // 同一端点),60s 轮询 + 打开面板即刷新。挂侧边栏入口按钮右上角(portal 与
4922
- // 回退两处都挂),让用户不进面板也知道有冻结要裁决。
4923
- function useConflictBadgeCount(t) {
4924
- 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());
4925
5030
  useEffect(() => {
4926
- let cancelled = false;
4927
- const tick = () => {
4928
- apiFetch("/api/dsh-mneme/dream-status")
4929
- .then((res) => (res.ok ? res.json() : { pendingConflicts: 0 }))
4930
- .then((d) => { if (!cancelled) setPending(Number((d && d.pendingConflicts) ?? 0) || 0); })
4931
- .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);
4932
5037
  };
4933
- tick();
4934
- const timer = setInterval(tick, 60_000);
4935
- return () => { cancelled = true; clearInterval(timer); };
4936
5038
  }, []);
4937
5039
  return pending;
4938
5040
  }
@@ -4942,7 +5044,7 @@ window.__ModuleLoader__.load({
4942
5044
  pending > 99 ? "99+" : String(pending));
4943
5045
  }
4944
5046
  function SidebarFallbackTrigger({ wide, t }) {
4945
- const pending = useConflictBadgeCount(t);
5047
+ const pending = useConflictBadgeCount();
4946
5048
  // #295 评审:.mneme-trigger 自身 overflow:hidden 且无定位上下文,badge
4947
5049
  // 直接放里面会被裁剪——包一层定位容器,badge 挂在容器上。
4948
5050
  return h("div", { className: "mneme-triggerwrap" },
@@ -4956,7 +5058,7 @@ window.__ModuleLoader__.load({
4956
5058
  onClick: openLibrary,
4957
5059
  "data-mneme-overlay-opener": "true"
4958
5060
  },
4959
- renderArchiveIcon({ size: wide ? 16 : 18 }),
5061
+ renderMnemeMark({ size: wide ? 16 : 18 }),
4960
5062
  wide && h("span", { className: "mneme-trigger-label" }, t("memory.panel.open"))
4961
5063
  ),
4962
5064
  h(ConflictBadge, { pending })
@@ -4965,14 +5067,25 @@ window.__ModuleLoader__.load({
4965
5067
 
4966
5068
  function SidebarTopEntry({ wide, t, fallback }) {
4967
5069
  const [host, setHost] = useState(null);
4968
- const [nativeCls, setNativeCls] = useState("");
4969
- const pending = useConflictBadgeCount(t);
5070
+ const [cls, setCls] = useState({ row: "", glyph: "", title: "" });
5071
+ const pending = useConflictBadgeCount();
4970
5072
  useEffect(() => {
4971
5073
  if (!reactDom || typeof document === "undefined") return undefined;
4972
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
+ };
4973
5079
  const findNative = (region) =>
4974
5080
  region.parentElement.querySelector('[class*="newSession"]')
4975
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
+ };
4976
5089
  // 占位锚点:新会话行(现役外壳里 newSession 按钮嵌在 logoRow 内,
4977
5090
  // 旧外壳是根的直接子按钮)。判定与生态插件的 sidebar-entry-core
4978
5091
  // 一致;锚点不可靠时回退到 regionArea 之前的旧位置。
@@ -4991,32 +5104,61 @@ window.__ModuleLoader__.load({
4991
5104
  // insertBefore(x, x) 在 Chromium 里不是 no-op,会触发 mutation 造成
4992
5105
  // 观察器自激风暴、冻死整个 SPA。
4993
5106
  const place = (region) => {
5107
+ if (!created) return;
4994
5108
  const parent = region.parentElement;
4995
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
+ }
4996
5118
  const anchor = findAnchor(region);
4997
5119
  const target = anchor ? anchor.nextSibling : region;
4998
5120
  if (target === created) return;
4999
5121
  if (created.parentElement === parent && created.nextSibling === target) return;
5000
5122
  parent.insertBefore(created, target);
5001
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
+ };
5002
5146
  const attempt = () => {
5003
- const region = document.querySelector('[class*="regionArea"]');
5004
- if (region && region.parentElement) {
5005
- // 「新会话」按钮与 regionArea 同级且紧邻其前,借它的类名获得
5006
- // 与原生侧边栏项完全一致的盒模型(含收起态 rail 几何)。
5007
- // 宿主/皮肤会异步改写按钮类名,用 MutationObserver 持续同步,
5008
- // 否则 portal 按钮会停留在捕获时刻的旧类上(宽度/对齐失配)。
5147
+ const region = findRegion();
5148
+ if (region) {
5149
+ // 宿主切换展开/收起时会重排 DOM(面板列表 → 标题栏固定位),所以每次
5150
+ // 变动都重新落位并重读类名;宿主/皮肤异步改写类名也靠这条同步,否则
5151
+ // portal 按钮会停留在捕获时刻的旧类上(宽度/对齐失配)。
5009
5152
  created = document.createElement("div");
5010
5153
  created.dataset.pluginEntry = "@modusensus/dsh-mneme";
5011
5154
  place(region);
5012
5155
  setHost(created);
5013
- setNativeCls(findNative(region)?.className || "");
5156
+ sync(region);
5014
5157
  mo = new MutationObserver(() => {
5015
- const cur = document.querySelector('[class*="regionArea"]');
5016
- if (!cur || !cur.parentElement) return;
5158
+ const cur = findRegion();
5159
+ if (!cur) return;
5017
5160
  place(cur);
5018
- const cls = findNative(cur)?.className || "";
5019
- setNativeCls((prev) => (prev === cls ? prev : cls));
5161
+ sync(cur);
5020
5162
  });
5021
5163
  mo.observe(region.parentElement, {
5022
5164
  attributes: true,
@@ -5035,13 +5177,19 @@ window.__ModuleLoader__.load({
5035
5177
  if (mo) mo.disconnect();
5036
5178
  if (created && created.parentElement) created.parentElement.removeChild(created);
5037
5179
  };
5038
- }, []);
5180
+ }, [wide]);
5039
5181
  if (!host) return fallback;
5182
+ // 图标与「插件 / 自动化任务」同档(16px);收起态沿用标题栏里原来的 18px。
5183
+ const icon = renderMnemeMark({ size: wide ? 16 : 18 });
5040
5184
  return reactDom.createPortal(
5041
5185
  h("div", { className: "mneme-topentry" },
5042
5186
  h("button", {
5043
5187
  type: "button",
5044
- 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(),
5045
5193
  "aria-label": pending > 0
5046
5194
  ? `${t("memory.sidebar.aria")} · ${t("memory.status.conflicts")} ${pending}`
5047
5195
  : t("memory.sidebar.aria"),
@@ -5049,8 +5197,9 @@ window.__ModuleLoader__.load({
5049
5197
  onClick: openLibrary,
5050
5198
  "data-mneme-overlay-opener": "true"
5051
5199
  },
5052
- renderArchiveIcon({ size: wide ? 15 : 18 }),
5053
- 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")),
5054
5203
  h(ConflictBadge, { pending })
5055
5204
  )
5056
5205
  ), host);
@@ -5111,7 +5260,7 @@ window.__ModuleLoader__.load({
5111
5260
  reg.registerTab({
5112
5261
  id: TAB_ID,
5113
5262
  title: () => t("memory.view.label"),
5114
- icon: (size) => renderArchiveIcon({ size }),
5263
+ icon: (size) => renderMnemeMark({ size }),
5115
5264
  order: 60,
5116
5265
  component: () => h(MemoryExplorer, { t })
5117
5266
  });
package/lib/config.js CHANGED
@@ -71,6 +71,9 @@ export const Config = z.object({
71
71
  // 提示:上限/原长/全文 memory_get 指引(BUDGET_EXCEEDED 原则——绝不静默)。
72
72
  // 默认 300 = 与既有行为一致。
73
73
  injectContentMaxChars: z.natural().min(60).max(4000).default(300),
74
+ // Issue #34(v0.7.2 引入;v0.7.11 误删后随 #333 恢复):对话开始注入一次当前
75
+ // 时间。默认关 = 与既有行为一致;格式与键名不变,存量 feature_flags 恢复即生效。
76
+ injectTimePrefix: z.boolean().default(false),
74
77
  importanceThreshold: z.natural().min(1).max(5).default(3),
75
78
  // Issue #239(第 5 项):注入条数的查询自适应(默认关)。确定性强的话题收缩注入
76
79
  // 条数(减半、下限 1),模糊话题(回指/时间线索,或极短查询)维持
@@ -588,6 +591,29 @@ export const Config = z.object({
588
591
  retentionDays: z.natural().min(1).max(3650).default(90)
589
592
  }).default({}),
590
593
 
594
+ // --- write admission (#254) ------------------------------------------------
595
+ // 写入准入:把「这条该不该进库」前移到 LLM 之前。与 memoryQualityFilter 的分工
596
+ // 是时点不是判据——那个是**写入后**打分归档(扣分可以错,只影响排序与归档),
597
+ // 这个是**写入前**判定(硬拒不能错)。
598
+ //
599
+ // 两个开关都默认关(验收第 1 条「默认关零行为变化」):
600
+ // enabled — 跑第 1 级确定性判据。关时 evaluate 的返回与只计量那一阶段逐字段
601
+ // 一致,写入路径完全不碰。
602
+ // enforce — 命中时真的拒绝。关时判据照跑、审计照留,决策回落 allow(仅告警)。
603
+ // 读审计时「metadata.deny 非空且 metadata.decision=allow」就是这一档的指纹。
604
+ // 分层是为了能把「先观察」和「开始拦」分成两次上线:enforce 打开前先在真实流量
605
+ // 里看误杀面,而不是靠回滚开关当保险。
606
+ //
607
+ // 第 1 级的判据面刻意收窄到两类:密钥/PII(#164 A2 是判据来源,经
608
+ // createWriteAdmission 的 sensitiveScan 注入;本批只定义它怎么被消费)与空白/
609
+ // 纯噪声文本。去重键命中**不**进第 1 级——按维护者 09-22 的判定归 write-update
610
+ // 放行(2606.24535 的失效模式:同步近重复门跑在异步矛盾检测之前,把本该被裁决
611
+ // 的矛盾直接拒掉);G1/G2 的阈值继续只计量,等遥测有分布再单独提。
612
+ writeAdmission: z.object({
613
+ enabled: z.boolean().default(false),
614
+ enforce: z.boolean().default(false)
615
+ }).default({}),
616
+
591
617
  // --- recall evaluation: test-result storage (v0.4.6, 方案 B) --------------
592
618
  // Separate retrieval evaluation snapshots from the production recall audit.
593
619
  // When false (default) evaluateRetrieval still computes precision/recall/mrr
package/lib/index.js CHANGED
@@ -272,10 +272,16 @@ export const apply = (ctx, config) => {
272
272
  documentIndex.remove();
273
273
  }
274
274
 
275
- // 写入准入实例:本批次只做计量(决策恒放行、写审计行),所以不需要新开关——它
276
- // 不改变任何写入行为,也不新增拦截分支;既有的 llmAudit.enabled 关掉时它同样
277
- // 不写(那个开关连审计行的启动期清理一起关掉)。第二阶段把拦截打开时才按仓库
278
- // 惯例引入 opt-in 默认关的配置键,届时只改这一个实例的构造与 service 的调用点。
275
+ // 写入准入实例(#254)。两个开关 writeAdmission.enabled / .enforce 都默认关;
276
+ // 关时它的返回与只计量那一阶段逐字段一致,写入路径完全不碰。既有的
277
+ // llmAudit.enabled 关掉时它同样既不判也不写(那个开关连审计行的启动期清理一起
278
+ // 关掉,在无保留期的表里按写入频次增长是不能接受的)。
279
+ //
280
+ // sensitiveScan 是密钥 / PII 那一类判据的注入点。按 #254 验收第 4 条它是 #164 A2
281
+ // 的判据来源(A2 记在维护者排期里),所以本批不实现它,只把接口形状定在这里——
282
+ // 接上时只改这一行:
283
+ // createWriteAdmission({ ..., sensitiveScan: createSensitiveScan({ config: cfg }) })
284
+ // 缺省 null 时第 1 级只跑空白 / 噪声两类判据,其余一切照旧。
279
285
  const writeAdmission = createWriteAdmission({ store, config: cfg, logger: ctx.logger });
280
286
  const service = createService({ store, mirror, config: cfg, logger: ctx.logger, documentIndex, writeAdmission });
281
287