@modusensus/dsh-mneme 0.8.0 → 0.8.1

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
@@ -21,7 +21,7 @@
21
21
 
22
22
  | ① 写入 | ② 存储 | ③ 进化 |
23
23
  |--------|--------|--------|
24
- | 对话中模型主动记录(`memory_save`,可带敏感度与事件时间);会话结束自动提炼(`autoSummarize`) | SQLite 主库 + 人类可编辑 Markdown 镜像;实体 / 属性 / 时间轴三层结构化;记忆带 agent / workspace / 敏感度 / 发生时间标注 | 新会话自动注入相关记忆(按当前作用域加权,strictScope 开启后硬隔离);autoDream 后台去重 / 合并 / 归档,冲突冻结待人工裁决 |
24
+ | 对话中模型主动记录(`memory_save`,可带敏感度与事件时间);会话结束自动提炼(`autoSummarize`) | SQLite 主库 + 人类可编辑 Markdown 镜像;实体 / 属性 / 时间轴三层结构化;记忆带 agent / workspace / 敏感度 / 发生时间标注 | 新会话自动注入相关记忆(按当前作用域加权,strictScope 开启后显式收窄的归属硬隔离);autoDream 后台去重 / 合并 / 归档,冲突冻结待人工裁决 |
25
25
 
26
26
  ```bash
27
27
  # 30 秒上手
@@ -228,6 +228,7 @@ v0.3.0 起新增**记忆基因**层:从记忆里抽取**命名实体**、**带
228
228
 
229
229
  | 版本 | 亮点 |
230
230
  |------|------|
231
+ | **v0.8.1** | 归属显式声明与人工纠偏 + 插件版本自检:记忆归属三段式可控——per-dim 来源列 + save/update scope 参数 + 面板可编辑(#170 第 1 步,#171);sleep 跨 scope 相似对停车进冲突队列人工裁决(第 2 步,#172);`strictScope` 硬过滤只认显式声明、自动标注降为纯软加权(第 3 步,#173,4.3 已确认);`GET /api/dsh-mneme/version-check` 只读路由 + 设置页偏差横幅(仅 outdated 渲染,registry 精确 host 白名单 + TTL 缓存 + 全失败静默,pnpm 钉子警示 + 市场收录延迟说明,#174 后续);1026 测试全绿 |
231
232
  | **v0.8.0** | 作用域隔离(issue #17 批次 A)+ 冲突集中处理 + 斜杠命令提交 Agent + 注入转义回归修复:记忆按 agent 与工作区双维标注(agent_scope / workspace_scope / sensitivity / occurred_at 四列可空),检索按当前会话作用域加权(命中 ×1.25、他 scope ×0.5 保留可见),opt-in `strictScope` 硬过滤贯通检索/注入/列表/单取(fail-closed);`scopeEnabled` / `strictScope` 默认关、面板「作用域隔离」组可启停;去重键扩展含作用域三元——跨作用域同标题不再物理合并;`occurred_at` 事件发生时间 + `occurred_from/to` 过滤贯通搜索与列表;冲突冻结(conflictFreezeEnabled)新增人工出口——状态页冲突队列并排对比 + 保留 A/B/仅标记(#166);斜杠命令经 agent.followup 真正提交模型(#152);恢复 v0.7.4 的注入花括号转义 + hot memory 跳过 reasoning——`{{.Server.Version}}` 类文本不再卡死会话(#165);988 测试全绿 |
232
233
  | **v0.7.32** | 冲突动作集扩展 + 运行时自管化 + 记忆/提示语言 + 候选集 hybrid 档 + 一批修复:`sleepActionSet` 新增 `full` 档(supersede/differentiate/update/merge/conflict/keep 六分支,默认 `conflict` 零行为变化)——supersede 赢家正文干净、输家归档附「已被取代」注记,differentiate 双留+差异注记;sleep 校验接入 `dreamSkipInvalid` 宽容策略(默认 true,一票否决变宽容,严格可关);运行时三档取件(收编/本地 .tgz/registry)+ transformers 转可选 peer(#131);`memory.language`(zh/en)提示/注入/镜像语言可选(#124);summarize 节流 + 产出上限 + 同会话去重(#127);inject 正文读取修复(#129)、#135 五连修(前缀解析/effort 最低档/importance 豁免/ready 语义/覆盖度降级告警)、boot 回填指纹短路修复(#128)、degraded 轮跳过明细落库(#104);916 测试全绿、总覆盖 92.7%(runtime 96%、embedding 100%) |
233
234
  | **v0.7.30** | 状态页向量卡修复(issue #118):改读开放的 `/semantic`——不再吃 `/vector-config` 的 401「加载失败」,ollama/local 模式不再恒显「未启用」;新增「初始化中(embedder 不可达,正在重试)」与「已索引 N / M 条」回填进度显示;legacy OpenAI 兼容 embedder「Object · 0D」显示修复(embedder 显式 `name: "OpenAI"` + 维度回退 `index.dimension`);「已索引 N / M」分子分母同口径(`embeddedCount` 剔除归档/遗忘,与 `count()` 默认过滤对齐);embedder init 失败有界重试(共 5 次,不再一次性永久降级,unload 清理定时器)+ `OllamaEmbedder.ready` 生命周期位 + `/semantic` 新增 `ready` 字段;745 测试全绿 |
@@ -335,6 +336,7 @@ v0.3.0 起新增**记忆基因**层:从记忆里抽取**命名实体**、**带
335
336
  | **v0.7.31** | ✅ 完成 | peerDependencies 宿主版本声明修复 | 原 `^0.1.0-rc.6` 按 node-semver 预发布元组规则不匹配 `0.1.5-rc.1` 等中间预发布版本(当前 dsh 用户安装 ERESOLVE),也违反 awesome-dsh-plugin 的 peer-range 预发布分支规范;改显式三段式预发布分支(覆盖 0.1.0-rc.6 至 0.1.5-rc.1 全部已发布 0.1.x 含预发布 + 0.2 预发布留显式分支 + 0.3+ 待验证后放开);745 测试全绿 |
336
337
  | **v0.7.32** | ✅ 完成 | sleep 冲突动作集扩展 + 运行时自管化 + 候选集 hybrid 档 + 一批修复 | `sleepActionSet` 新增 `full` 档(supersede/differentiate/update/merge/conflict/keep 六分支,默认 `conflict` 零行为变化)+ sleep 校验接入 `dreamSkipInvalid` 宽容策略 + 被跳决策带 phase 落库 `dream_runs.skipped`;运行时三档取件 + transformers 转可选 peer(#131);`memory.language` 提示/注入/镜像语言可选(#124);候选集向量驱动 hybrid 档(#125,PR #147);summarize 节流 + 产出上限 + 同会话去重(#127);inject 正文读取修复(#129)+ #135 五连修 + boot 回填指纹短路修复(#128)+ degraded 轮跳过明细落库(#104);runtime 覆盖统计归一 + 补齐(总覆盖 92.7%);916 测试全绿 |
337
338
  | **v0.8.0** | ✅ 完成 | 作用域隔离 + 冲突队列 | 按 agent 与工作区双维隔离记忆(标注 / 检索加权 / opt-in strictScope 四路硬过滤,issue #17 批次 A);冻结冲突新增人工确认队列(状态页并排对比,#166);斜杠命令真正提交 Agent(#152);注入转义回归恢复 + hot memory 跳过 reasoning(#162)。图谱增强(兴趣漂移可视化)、跨 workspace 记忆共享仍在规划 |
339
+ | **v0.8.1** | ✅ 完成 | 归属显式声明与人工纠偏 + 插件版本自检 | 归属三段式可控:per-dim 来源列 + save/update scope 参数 + 面板可编辑(#170 第 1 步,#171);sleep 跨 scope 相似对停车人工裁决接冲突队列(第 2 步,#172);`strictScope` 硬过滤只认显式声明、自动标注降纯软加权(第 3 步,#173)。插件自报新版本:/version-check 只读路由 + 设置页 outdated 横幅(registry 精确白名单 + TTL 缓存 + 全失败静默,pnpm 钉子警示,#174 后续,#176);1026 测试全绿 |
338
340
 
339
341
  > 新能力一律做成**可开关的功能**(配置启用/关闭),默认保守开启、不破坏现有行为。`failure_memories` 表与 autoDream 决策引擎已为后续反思性成长铺好路。
340
342
 
@@ -461,7 +463,7 @@ dsh web
461
463
  | `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) |
462
464
  | `llmAudit` | `{enabled:true, retentionDays:90}` | LLM 消耗审计(v0.4.6,默认开):每次后台 LLM 调用(autoDream/autoSummarize)写 `llm_audit_logs`(tokens/duration/status/source);失败记 error 不阻塞;只读 API `/api/dsh-mneme/semantic/llm-audit` + `/llm-audit/stats` |
463
465
  | `scopeEnabled` | `false` | 作用域隔离总开关(v0.8.0,issue #17):memory_save 按会话身份写入 agent / workspace 标注(agentPreset / 工作区路径,registry 反查取不到回退 header.cwd,再取不到 NULL);去重键扩展含作用域三元——跨作用域同标题不再物理合并;检索按当前会话作用域加权(命中 ×1.25、他 scope ×0.5 保留可见)。也走 feature_flags 白名单(面板可启停) |
464
- | `strictScope` | `false` | 作用域硬过滤(v0.8.0,依赖 `scopeEnabled`):检索 / 注入 / 列表 / 单取四路过滤,带他者作用域的记忆完全不可见;会话身份解析不到时 fail-closed 只见未标注行。关闭时为软隔离(降权保留可见)。也走 feature_flags 白名单 |
466
+ | `strictScope` | `false` | 作用域硬过滤(v0.8.0 引入,v0.8.1 起只认显式声明;依赖 `scopeEnabled`):检索 / 注入 / 列表 / 单取四路过滤,**显式声明**收窄到他者作用域的记忆完全不可见;载体自动标注只降权保留可见——真正的物理隔离请用 sensitivity。会话身份解析不到时 fail-closed 只挡显式行。关闭时全部为软隔离(降权保留可见)。也走 feature_flags 白名单 |
465
467
  | `conflictFreezeEnabled` | `false` | 冲突冻结(v0.4.4):dream 发现矛盾对不自动裁决,冻结进冲突队列;状态页「冲突队列」支持并排对比与人工确认(保留 A / 保留 B / 仅标记已处理,v0.8.0) |
466
468
  | `escapePromptVariables` | `true` | 注入文本花括号转义(v0.8.0 恢复,issue #162):注入边界把 `{{...}}` 转义,防止 hot memory / 记忆原文里的 Go template / Vue 语法触发宿主 interpolate 抛错卡死会话(v0.7.4 曾修复、v0.7.11 误删) |
467
469
 
package/lib/api.js CHANGED
@@ -1,5 +1,4 @@
1
1
  import { URL } from "node:url";
2
- import { readFileSync } from "node:fs";
3
2
  import { timingSafeEqual, randomBytes } from "node:crypto";
4
3
  import { FEATURE_FLAG_SPEC } from "./settings.js";
5
4
  import { TYPE_FILE, renderMirrorText, parseHumanEdits } from "./mirror.js";
@@ -8,6 +7,7 @@ import { computeHeat } from "./heat.js";
8
7
  import { describeStreamFailure, resolveRoute } from "./dream.js";
9
8
  import { describeLocalRuntime, publicRuntimeStatus } from "./runtime/loader.js";
10
9
  import { hostModulesDir, provisionRuntime } from "./runtime/provision.js";
10
+ import { classify, fetchLatestVersion, PACKAGE_VERSION } from "./version-check.js";
11
11
 
12
12
  // headers:少数端点(/export 附件下载)需要追加 Content-Disposition 等响应头。
13
13
  function sendJson(res, status, payload, headers = {}) {
@@ -24,16 +24,8 @@ function sendAttachment(res, status, contentType, filename, body) {
24
24
  res.end(body);
25
25
  }
26
26
 
27
- // 导出 JSON 的 version 字段:读插件根的 package.json(src/ 与 lib/ 都在根下
28
- // 一层,相对 import.meta.url 解析一致)。读取失败(打包/受限环境)降级为
29
- // "unknown",导出本身仍然可用。
30
- const PACKAGE_VERSION = (() => {
31
- try {
32
- return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version ?? "unknown";
33
- } catch {
34
- return "unknown";
35
- }
36
- })();
27
+ // 版本号统一从 version-check.js 取(同一份读 package.json 逻辑,读取失败
28
+ // 同样降级 "unknown")——/info 与 /version-check 两个路由共用,不再各写一份。
37
29
 
38
30
  // Defaults for the standalone external API — keep in step with the schema
39
31
  // defaults in config.js (externalApiPort / externalApiHost).
@@ -175,6 +167,29 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
175
167
  }
176
168
  });
177
169
 
170
+ // 版本自检(#174 后续):运行版本 vs npm registry latest。只读,宿主围栏
171
+ // 统一鉴权(与 dream-status 同款,面板 apiFetch 直接可用)。registry 查询
172
+ // 全失败时 latest=null、status=unknown——前端对 unknown 完全静默,版本提示
173
+ // 是锦上添花,绝不因它新增故障面。
174
+ register({
175
+ kind: "exact",
176
+ path: "/api/dsh-mneme/version-check",
177
+ handler(req, res) {
178
+ if (req.method !== "GET") {
179
+ sendJson(res, 404, { error: "not-found" });
180
+ return;
181
+ }
182
+ fetchLatestVersion()
183
+ .then((latest) => sendJson(res, 200, {
184
+ version: PACKAGE_VERSION,
185
+ latest,
186
+ status: classify(PACKAGE_VERSION, latest),
187
+ checkedAt: new Date().toISOString()
188
+ }))
189
+ .catch(() => sendJson(res, 200, { version: PACKAGE_VERSION, latest: null, status: "unknown" }));
190
+ }
191
+ });
192
+
178
193
  register({
179
194
  kind: "exact",
180
195
  path: "/api/dsh-mneme/list",
@@ -480,6 +495,19 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
480
495
  }
481
496
  patch.tags = body.tags;
482
497
  }
498
+ // v0.8.1 底座(issue #170):显式 scope 修正。null=放宽到全局,
499
+ // 字符串=收窄/改标;归一化("global"/"*"/空白→NULL + explicit 章 +
500
+ // 审计行)在 service.updateMemory 内做,路由只做形状校验。
501
+ if (body.agent_scope !== undefined && body.agent_scope !== null && typeof body.agent_scope !== "string") {
502
+ sendJson(res, 400, { error: "invalid-agent-scope" });
503
+ return;
504
+ }
505
+ if (body.workspace_scope !== undefined && body.workspace_scope !== null && typeof body.workspace_scope !== "string") {
506
+ sendJson(res, 400, { error: "invalid-workspace-scope" });
507
+ return;
508
+ }
509
+ if (body.agent_scope !== undefined) patch.agent_scope = body.agent_scope;
510
+ if (body.workspace_scope !== undefined) patch.workspace_scope = body.workspace_scope;
483
511
  if (body.archived !== undefined && typeof body.archived !== "boolean") {
484
512
  sendJson(res, 400, { error: "invalid-archived" });
485
513
  return;
@@ -501,7 +529,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
501
529
  ...history
502
530
  ].slice(0, 20);
503
531
  }
504
- if (Object.keys(patch).length) service.update(id, patch);
532
+ if (Object.keys(patch).length) service.update(id, patch, { actor: "panel" });
505
533
  if (body.archived !== undefined) service.setArchived(id, body.archived);
506
534
  sendJson(res, 200, { memory: service.getById(id) });
507
535
  });
package/lib/client.js CHANGED
@@ -270,6 +270,8 @@ window.__ModuleLoader__.load({
270
270
  "memory.settings.feedback.email": "邮件反馈(work@modusensus.space)",
271
271
  "memory.settings.feedback.browse": "浏览仓库已知问题",
272
272
  "memory.settings.feedback.hint": "反馈前先搜搜是否已有相同问题,能省一份重复的 issue~",
273
+ "memory.settings.version.outdated": "🆕 有新版本 {v}(当前运行 {c})",
274
+ "memory.settings.version.outdatedHint": "若安装时指定过版本号,常规升级不会跨大/小版本(pnpm 钉子)——请重新安装或在升级命令加 --latest;npm 已发布而市场暂未收录属正常(约 1 天延迟)。",
273
275
  "memory.settings.mode.title": "运行模式",
274
276
  "memory.settings.mode.desc": "轻量模式只保留核心的记忆读写与自动注入(关闭 autoDream 巩固、实体抽取、语义搜索等高级功能),适合只想「记住偏好」的轻量使用;标准模式开启全部功能。",
275
277
  "memory.settings.mode.light": "轻量",
@@ -351,7 +353,7 @@ window.__ModuleLoader__.load({
351
353
  "memory.features.scopeEnabled": "作用域标注",
352
354
  "memory.features.scopeEnabled.hint": "按会话身份(agent/工作区)给新记忆打归属标注;重启 DSH 后生效",
353
355
  "memory.features.strictScope": "严格隔离",
354
- "memory.features.strictScope.hint": "检索与注入硬过滤:带他者作用域的记忆完全不可见(关闭时仅降权保留可见);重启 DSH 后生效",
356
+ "memory.features.strictScope.hint": "检索与注入硬过滤,只对显式声明的归属生效:显式收窄到他者作用域的记忆不可见;载体自动标注只降权保留可见(真正的物理隔离请用 sensitivity)。重启 DSH 后生效",
355
357
  "memory.features.trustEpistemicWeighting": "可信度加权",
356
358
  "memory.features.trustEpistemicWeighting.hint": "按来源可信度调整召回排序(实验性)",
357
359
  "memory.features.reflectionFailureTracking": "反思失败追踪",
@@ -407,6 +409,12 @@ window.__ModuleLoader__.load({
407
409
  "memory.explorer.scopeBadge": "作用域",
408
410
  "memory.explorer.detail.agentScope": "Agent 作用域",
409
411
  "memory.explorer.detail.workspaceScope": "工作区作用域",
412
+ "memory.explorer.detail.scopeGlobal": "全局(所有会话可见)",
413
+ "memory.explorer.detail.scopeSourceAuto": "自动",
414
+ "memory.explorer.detail.scopeSourceExplicit": "显式",
415
+ "memory.explorer.detail.scopeEditHint": "留空 = 全局;也可填写具体标签收窄归属",
416
+ "memory.explorer.detail.scopeWidenConfirm": "确认放宽可见性?",
417
+ "memory.explorer.detail.scopeWidenHint": "放宽后该记忆将对所有会话可见",
410
418
  "memory.explorer.detail.sensitivity": "敏感度",
411
419
  "memory.explorer.detail.occurred": "发生时间",
412
420
  "memory.explorer.detail.edit": "编辑",
@@ -611,6 +619,8 @@ window.__ModuleLoader__.load({
611
619
  "memory.settings.feedback.email": "Email feedback (work@modusensus.space)",
612
620
  "memory.settings.feedback.browse": "Browse known issues",
613
621
  "memory.settings.feedback.hint": "Search for an existing issue first — it saves a duplicate.",
622
+ "memory.settings.version.outdated": "🆕 New version {v} available (running {c})",
623
+ "memory.settings.version.outdatedHint": "If the plugin was installed with a pinned version, regular upgrades never cross minor/major lines — reinstall or pass --latest. A fresh npm release may take about a day to appear in the market.",
614
624
  "memory.settings.mode.title": "Runtime mode",
615
625
  "memory.settings.mode.desc": "Light mode keeps only the core memory read/write and auto-injection (autoDream consolidation, entity extraction and semantic search are off) — for light use where you just want preferences remembered. Standard mode enables everything.",
616
626
  "memory.settings.mode.light": "Light",
@@ -692,7 +702,7 @@ window.__ModuleLoader__.load({
692
702
  "memory.features.scopeEnabled": "Scope tagging",
693
703
  "memory.features.scopeEnabled.hint": "Stamp new memories with the writing session's identity (agent/workspace); takes effect after restarting DSH",
694
704
  "memory.features.strictScope": "Strict isolation",
695
- "memory.features.strictScope.hint": "Hard-filter search and injection: memories scoped to other agents/workspaces become invisible (off = demoted but visible); takes effect after restarting DSH",
705
+ "memory.features.strictScope.hint": "Hard-filter search and injection for EXPLICITLY declared scopes only: memories explicitly narrowed to other agents/workspaces become invisible; auto carrier labels are demoted but stay visible (use sensitivity for true physical isolation). Takes effect after restarting DSH",
696
706
  "memory.features.trustEpistemicWeighting": "Credibility weighting",
697
707
  "memory.features.trustEpistemicWeighting.hint": "Adjust recall ranking by source credibility (experimental)",
698
708
  "memory.features.reflectionFailureTracking": "Reflection failure tracking",
@@ -748,6 +758,12 @@ window.__ModuleLoader__.load({
748
758
  "memory.explorer.scopeBadge": "Scoped",
749
759
  "memory.explorer.detail.agentScope": "Agent scope",
750
760
  "memory.explorer.detail.workspaceScope": "Workspace scope",
761
+ "memory.explorer.detail.scopeGlobal": "Global (visible to all sessions)",
762
+ "memory.explorer.detail.scopeSourceAuto": "auto",
763
+ "memory.explorer.detail.scopeSourceExplicit": "explicit",
764
+ "memory.explorer.detail.scopeEditHint": "Empty = global; or enter a specific label to narrow the scope",
765
+ "memory.explorer.detail.scopeWidenConfirm": "Widen visibility?",
766
+ "memory.explorer.detail.scopeWidenHint": "Widening makes this memory visible to all sessions",
751
767
  "memory.explorer.detail.sensitivity": "Sensitivity",
752
768
  "memory.explorer.detail.occurred": "Occurred",
753
769
  "memory.explorer.detail.edit": "Edit",
@@ -2068,6 +2084,9 @@ window.__ModuleLoader__.load({
2068
2084
  const [apiTokenSaved, setApiTokenSaved] = react.useState(false);
2069
2085
  // 反馈入口的插件版本(GET /info)。失败保持 "unknown",链接仍可用。
2070
2086
  const [pkgVersion, setPkgVersion] = react.useState("unknown");
2087
+ // 版本自检(#174 后续):运行版本 vs registry latest。仅 outdated 渲染
2088
+ // 横幅,其余状态(up-to-date / ahead / unknown / 查询失败)零渲染零打扰。
2089
+ const [updateInfo, setUpdateInfo] = react.useState(null);
2071
2090
  // 运行模式 — light vs standard; null = still loading. The card keeps
2072
2091
  // its own busy/saved/error state so it never blocks the others.
2073
2092
  const [mode, setMode] = react.useState(null);
@@ -2113,6 +2132,21 @@ window.__ModuleLoader__.load({
2113
2132
  return () => { cancelled = true; };
2114
2133
  }, []);
2115
2134
 
2135
+ // 版本自检:只读 /version-check(宿主围栏鉴权)。非 outdated 一律归
2136
+ // null——横幅不出现即是「无需处理」;接口失败也静默,不新增故障面。
2137
+ react.useEffect(() => {
2138
+ let cancelled = false;
2139
+ apiFetch("/api/dsh-mneme/version-check")
2140
+ .then((res) => (res.ok ? res.json() : null))
2141
+ .then((j) => {
2142
+ if (!cancelled && j && j.status === "outdated" && typeof j.latest === "string" && typeof j.version === "string") {
2143
+ setUpdateInfo({ latest: j.latest, current: j.version });
2144
+ }
2145
+ })
2146
+ .catch(() => {});
2147
+ return () => { cancelled = true; };
2148
+ }, []);
2149
+
2116
2150
  // 反馈链接预填:环境信息(插件版本 + 平台)+ 问题描述骨架。邮件主题带
2117
2151
  // 版本/平台方便归档,正文同款模板;纯链接零后端成本。
2118
2152
  const feedbackEnv = `**插件版本**: ${pkgVersion}\n**平台**: ${platformLabel()}\n`;
@@ -2323,6 +2357,17 @@ window.__ModuleLoader__.load({
2323
2357
  }
2324
2358
 
2325
2359
  return h("div", null,
2360
+ // 版本自检横幅 — 仅 outdated 时渲染(up-to-date/ahead/unknown/失败
2361
+ // 全部零渲染)。钉子警示:安装时指定过版本号的 profile 会被 pnpm
2362
+ // 挡住常规升级(#174 报障者的实际成因);市场收录新发布约有 1 天延迟。
2363
+ updateInfo && h("section", { className: "mneme-set-sec" },
2364
+ h("div", { className: "mneme-set-title" },
2365
+ t("memory.settings.version.outdated")
2366
+ .replace("{v}", updateInfo.latest)
2367
+ .replace("{c}", updateInfo.current)
2368
+ ),
2369
+ h("div", { className: "mneme-featsubhint" }, t("memory.settings.version.outdatedHint"))
2370
+ ),
2326
2371
  // 用户画像 — who the agent is talking to
2327
2372
  h("section", { className: "mneme-set-sec" },
2328
2373
  h("div", { className: "mneme-set-title" }, t("memory.settings.profile")),
@@ -3191,11 +3236,30 @@ window.__ModuleLoader__.load({
3191
3236
  // 右侧滑出:全文、来源、质量分、关联实体与手动操作(编辑/归档/删除)。
3192
3237
  // 编辑态本地暂存草稿,保存经 POST /update 落库;后端成功后重渲染镜像,
3193
3238
  // 前端只做本地视图同步。两步删除确认在此完成(红钮武装 → 实心红提交)。
3239
+ // v0.8.1 底座(issue #170):scope 行的来源徽注(显式/自动)。存量行无来源
3240
+ // 时不加后缀——与 A4 的「未标注零视觉变化」同款克制。
3241
+ const scopeSourceSuffix = (t, source) =>
3242
+ source === "explicit" ? ` · ${t("memory.explorer.detail.scopeSourceExplicit")}`
3243
+ : source === "auto" ? ` · ${t("memory.explorer.detail.scopeSourceAuto")}`
3244
+ : "";
3245
+ const scopeProvenanceTitle = (t, source, decidedAt) =>
3246
+ [
3247
+ source === "explicit" ? t("memory.explorer.detail.scopeSourceExplicit")
3248
+ : source === "auto" ? t("memory.explorer.detail.scopeSourceAuto") : null,
3249
+ decidedAt ?? null
3250
+ ].filter(Boolean).join(" · ");
3251
+
3194
3252
  function MemoryDrawer({ t, memory, conflict, deleting, deleteError, onClose, onDelete, onSaved }) {
3195
3253
  const [editing, setEditing] = useState(false);
3196
3254
  const [title, setTitle] = useState(memory.title || "");
3197
3255
  const [content, setContent] = useState(memory.content || "");
3198
3256
  const [importance, setImportance] = useState(memory.importance || 3);
3257
+ // v0.8.1 底座(issue #170):scope 归属的人工修正入口。空串=放宽到全局
3258
+ // (发送 null),非空=收窄到该标签;只有真的改动才会随 patch 发送并盖
3259
+ // explicit 章——普通编辑不得污染归属来源。
3260
+ const [agentScope, setAgentScope] = useState(memory.agent_scope || "");
3261
+ const [workspaceScope, setWorkspaceScope] = useState(memory.workspace_scope || "");
3262
+ const [confirmWiden, setConfirmWiden] = useState(false);
3199
3263
  const [confirmDelete, setConfirmDelete] = useState(false);
3200
3264
  const [busy, setBusy] = useState(false);
3201
3265
  const [savedTick, setSavedTick] = useState(false);
@@ -3234,7 +3298,25 @@ window.__ModuleLoader__.load({
3234
3298
  const save = () => {
3235
3299
  const t2 = title.trim();
3236
3300
  if (!t2) return;
3237
- postUpdate({ title: t2, content, importance }, {});
3301
+ const patch = { title: t2, content, importance };
3302
+ const curAgent = memory.agent_scope ?? "";
3303
+ const curWorkspace = memory.workspace_scope ?? "";
3304
+ const nextAgent = agentScope.trim();
3305
+ const nextWorkspace = workspaceScope.trim();
3306
+ const agentChanged = nextAgent !== curAgent;
3307
+ const workspaceChanged = nextWorkspace !== curWorkspace;
3308
+ if (agentChanged) patch.agent_scope = nextAgent || null;
3309
+ if (workspaceChanged) patch.workspace_scope = nextWorkspace || null;
3310
+ // 放宽可见性(标注→全局)必须显式确认(issue #170 的安全阀):
3311
+ // 第一次点保存只弹确认,再次点「确认放宽」才真正落库。
3312
+ const widening = (curAgent && agentChanged && !nextAgent)
3313
+ || (curWorkspace && workspaceChanged && !nextWorkspace);
3314
+ if (widening && !confirmWiden) {
3315
+ setConfirmWiden(true);
3316
+ return;
3317
+ }
3318
+ setConfirmWiden(false);
3319
+ postUpdate(patch, {});
3238
3320
  };
3239
3321
 
3240
3322
  return h("aside", { className: "mneme-drawer", "aria-label": t("memory.explorer.detail") },
@@ -3278,12 +3360,40 @@ window.__ModuleLoader__.load({
3278
3360
  memory.occurred_at && h(react.Fragment, null,
3279
3361
  h("span", { className: "mneme-dmetakey" }, t("memory.explorer.detail.occurred")),
3280
3362
  h("span", { className: "mneme-dmetaval", title: memory.occurred_at }, formatDateShort(memory.occurred_at))),
3281
- memory.agent_scope && h(react.Fragment, null,
3363
+ // v0.8.1 底座:scope 行双态——查看态展示归属 + 来源徽注(显式声明的
3364
+ // 「全局」也渲染,不再因 NULL 而隐身);编辑态为文本框(留空=全局)。
3365
+ (editing || memory.agent_scope || memory.agent_scope_source === "explicit") && h(react.Fragment, null,
3282
3366
  h("span", { className: "mneme-dmetakey" }, t("memory.explorer.detail.agentScope")),
3283
- h("span", { className: "mneme-dmetaval", title: memory.agent_scope }, memory.agent_scope)),
3284
- memory.workspace_scope && h(react.Fragment, null,
3367
+ editing
3368
+ ? h("input", {
3369
+ type: "text",
3370
+ className: "mneme-select",
3371
+ style: { height: 26, fontSize: 12, justifySelf: "start", width: "100%" },
3372
+ value: agentScope,
3373
+ placeholder: t("memory.explorer.detail.scopeGlobal"),
3374
+ title: t("memory.explorer.detail.scopeEditHint"),
3375
+ onChange: (e) => setAgentScope(e.target.value)
3376
+ })
3377
+ : h("span", {
3378
+ className: "mneme-dmetaval",
3379
+ title: scopeProvenanceTitle(t, memory.agent_scope_source, memory.scope_decided_at) || memory.agent_scope
3380
+ }, (memory.agent_scope ?? t("memory.explorer.detail.scopeGlobal")) + scopeSourceSuffix(t, memory.agent_scope_source))),
3381
+ (editing || memory.workspace_scope || memory.workspace_scope_source === "explicit") && h(react.Fragment, null,
3285
3382
  h("span", { className: "mneme-dmetakey" }, t("memory.explorer.detail.workspaceScope")),
3286
- h("span", { className: "mneme-dmetaval", title: memory.workspace_scope }, memory.workspace_scope)),
3383
+ editing
3384
+ ? h("input", {
3385
+ type: "text",
3386
+ className: "mneme-select",
3387
+ style: { height: 26, fontSize: 12, justifySelf: "start", width: "100%" },
3388
+ value: workspaceScope,
3389
+ placeholder: t("memory.explorer.detail.scopeGlobal"),
3390
+ title: t("memory.explorer.detail.scopeEditHint"),
3391
+ onChange: (e) => setWorkspaceScope(e.target.value)
3392
+ })
3393
+ : h("span", {
3394
+ className: "mneme-dmetaval",
3395
+ title: scopeProvenanceTitle(t, memory.workspace_scope_source, memory.scope_decided_at) || memory.workspace_scope
3396
+ }, (memory.workspace_scope ?? t("memory.explorer.detail.scopeGlobal")) + scopeSourceSuffix(t, memory.workspace_scope_source))),
3287
3397
  memory.sensitivity && h(react.Fragment, null,
3288
3398
  h("span", { className: "mneme-dmetakey" }, t("memory.explorer.detail.sensitivity")),
3289
3399
  h("span", { className: "mneme-dmetaval", title: memory.sensitivity }, memory.sensitivity)),
@@ -3316,7 +3426,16 @@ window.__ModuleLoader__.load({
3316
3426
  h("div", { className: "mneme-dactions" },
3317
3427
  editing
3318
3428
  ? h(react.Fragment, null,
3319
- h("button", { type: "button", className: "mneme-btn", disabled: busy, onClick: save }, t("memory.explorer.detail.save")),
3429
+ confirmWiden
3430
+ ? h(react.Fragment, null,
3431
+ h("span", { style: { fontSize: 12, color: "var(--dsw-alias-state-warn,#b8860b)" } },
3432
+ t("memory.explorer.detail.scopeWidenHint")),
3433
+ h("button", { type: "button", className: "mneme-btn mneme-btndangerconfirm", disabled: busy, onClick: save },
3434
+ t("memory.explorer.detail.scopeWidenConfirm")),
3435
+ h("button", { type: "button", className: "mneme-btn", disabled: busy, onClick: () => setConfirmWiden(false) },
3436
+ t("memory.explorer.cancel"))
3437
+ )
3438
+ : h("button", { type: "button", className: "mneme-btn", disabled: busy, onClick: save }, t("memory.explorer.detail.save")),
3320
3439
  h("button", {
3321
3440
  type: "button",
3322
3441
  className: "mneme-btn",
@@ -3326,6 +3445,9 @@ window.__ModuleLoader__.load({
3326
3445
  setTitle(memory.title || "");
3327
3446
  setContent(memory.content || "");
3328
3447
  setImportance(memory.importance || 3);
3448
+ setAgentScope(memory.agent_scope || "");
3449
+ setWorkspaceScope(memory.workspace_scope || "");
3450
+ setConfirmWiden(false);
3329
3451
  }
3330
3452
  }, t("memory.explorer.detail.cancel")),
3331
3453
  savedTick && h("span", { className: "mneme-saved" }, t("memory.explorer.detail.saved")),
@@ -4,6 +4,8 @@ import { STR, langOf } from "../lang.js";
4
4
  // 1. conflict resolution — high-similarity same-type pairs are either parked
5
5
  // for review (freeze mode) or adjudicated by the LLM (winner kept / loser
6
6
  // archived), reusing the dream conflict machinery. Strictness-graded.
7
+ // v0.8.1 (issue #170): cross-scope pairs are ALWAYS parked for human
8
+ // ownership review with a dedicated reason — never auto-adjudicated.
7
9
  // 2. archival demotion — memories unreferenced past sleepArchiveDays shrink
8
10
  // to a one-line summary with the full body moved to _full_content; past
9
11
  // sleepCompressDays they are archived outright.
@@ -18,6 +20,7 @@ import { STR, langOf } from "../lang.js";
18
20
  import { randomUUID, createHash } from "node:crypto";
19
21
  import { validateDecisions, applyDecisions, ACTIONS } from "./decisions.js";
20
22
  import { findPotentialConflicts, cosineSimilarity } from "./clustering.js";
23
+ import { scopeKeyOf } from "../scope.js";
21
24
  import { buildReceipt, describeStreamFailure, resolveDreamEffort, withEffortFallback, maintainIndexAfterDream } from "../dream.js";
22
25
  import { computeHeat } from "../heat.js";
23
26
 
@@ -169,29 +172,57 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
169
172
  selected.push(p);
170
173
  }
171
174
 
172
- // Freeze mode: park pairs for manual review, no LLM required.
175
+ // v0.8.1(issue #170 第 2 步):跨 scope 相似对分流。同一内容落在两个归属下
176
+ // (任一维标注键不等即算,NULL=未标注=全局也参与比较——「全局副本 + 专属副本
177
+ // 并存」正是收窄/提升裁决的典型候选)不能自动裁决:归档败者可能销毁该内容在
178
+ // 某个归属下的唯一副本,LLM 也无从替用户决定归属。一律停车到冲突待确认队列
179
+ // (专属 reason),同 scope 对维持既有路径(freeze 停车 / LLM 裁决)。
180
+ const isCrossScopePair = (p) =>
181
+ scopeKeyOf(p.a.agent_scope) !== scopeKeyOf(p.b.agent_scope)
182
+ || scopeKeyOf(p.a.workspace_scope) !== scopeKeyOf(p.b.workspace_scope);
183
+ const crossPairs = selected.filter(isCrossScopePair);
184
+ const samePairs = selected.filter((p) => !isCrossScopePair(p));
185
+ const parkPair = (p, reason) => {
186
+ try {
187
+ // 返回 undefined = 该对已被人工裁决且两侧内容未变(复核项 4)——不算停车。
188
+ return service.saveConflictPending({ run_id: runId, memory_a: p.a.id, memory_b: p.b.id, reason }) != null;
189
+ } catch (error) {
190
+ logger?.warn?.(`dsh-mneme sleep: failed to park conflict ${p.a.id}/${p.b.id}: ${String(error)}`);
191
+ return false;
192
+ }
193
+ };
194
+ const scopeReason = (p) => STR.scopeCandidateReason[language](p.similarity.toFixed(2));
195
+ const simReason = (p) => STR.similarityReason[language](p.similarity.toFixed(2));
196
+
197
+ // Freeze mode: park pairs for manual review, no LLM required. Cross-scope
198
+ // pairs carry the dedicated scope-candidate reason so the queue reads them
199
+ // as ownership decisions rather than plain duplicates.
173
200
  if (config.conflictFreezeEnabled === true) {
174
201
  let frozen = 0;
175
202
  for (const p of selected) {
176
- try {
177
- service.saveConflictPending({ run_id: runId, memory_a: p.a.id, memory_b: p.b.id, reason: STR.similarityReason[language](p.similarity.toFixed(2)) });
178
- frozen++;
179
- } catch (error) {
180
- logger?.warn?.(`dsh-mneme sleep: failed to freeze conflict ${p.a.id}/${p.b.id}: ${String(error)}`);
181
- }
203
+ if (parkPair(p, isCrossScopePair(p) ? scopeReason(p) : simReason(p))) frozen++;
182
204
  }
183
- return { status: frozen > 0 ? "ok" : "noop", frozen, pairs: selected.length };
205
+ return { status: frozen > 0 ? "ok" : "noop", frozen, scopeCandidates: crossPairs.length, pairs: selected.length };
184
206
  }
185
207
 
186
- // LLM adjudication.
208
+ // 非 freeze:跨 scope 对先停车(绝不进 LLM 裁决),同 scope 对照常走 LLM。
209
+ let scopeParked = 0;
210
+ for (const p of crossPairs) {
211
+ if (parkPair(p, scopeReason(p))) scopeParked++;
212
+ }
213
+ if (samePairs.length === 0) {
214
+ return { status: scopeParked > 0 ? "ok" : "noop", frozen: 0, scopeCandidates: scopeParked, pairs: selected.length };
215
+ }
216
+
217
+ // LLM adjudication(仅同 scope 对)。
187
218
  const route = resolveSleepRoute(ctx, config, logger);
188
- if (!route) return { status: "skipped", reason: "no llm route" };
219
+ if (!route) return { status: "skipped", reason: "no llm route", scopeCandidates: scopeParked, pairs: selected.length };
189
220
  const snapshot = new Map();
190
- for (const p of selected) {
221
+ for (const p of samePairs) {
191
222
  snapshot.set(p.a.id, p.a);
192
223
  snapshot.set(p.b.id, p.b);
193
224
  }
194
- const listText = selected.map((p) =>
225
+ const listText = samePairs.map((p) =>
195
226
  STR.candidateConflicts[language](p)
196
227
  ).join("\n\n");
197
228
  const sleepEffort = await resolveDreamEffort(ctx, route, config.sleepReasoningEffort, logger);
package/lib/dream.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { validateDecisions, applyDecisions } from "./dream/decisions.js";
2
2
  import { clusterMemories, findPotentialConflicts, cosineSimilarity } from "./dream/clustering.js";
3
+ import { scopeKeyOf } from "./scope.js";
3
4
  import { createHash, randomUUID } from "node:crypto";
4
5
  import { STR, langOf } from "./lang.js";
5
6
  export { validateDecisions, applyDecisions, withEffortFallback, describeStreamFailure, resolveDreamEffort, resolveRoute };
@@ -989,7 +990,21 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
989
990
  // bounds the review queue; overflow is skipped with a warning.
990
991
  let frozenCount = 0;
991
992
  const frozenIds = [];
992
- const applyList = freezeEnabled ? decisions.filter((d) => d.action !== "conflict") : decisions;
993
+ // v0.8.1(issue #170 复核项 3):跨 scope 的 conflict 决策两种模式都一律
994
+ // 停车人工裁决、绝不自动归档——否则普通 dream 的 LLM 能归档别的 scope 的
995
+ // 唯一副本,sleep 路径(scope-candidate-queue)的保护在这里缺位。同 scope
996
+ // 的 conflict 决策照旧:freeze 停车 / 非 freeze 自动裁决。
997
+ const isCrossScopeDecision = (d) => {
998
+ const a = snapshot.get(d.winner);
999
+ const b = snapshot.get(d.loser);
1000
+ return !!a && !!b && (
1001
+ scopeKeyOf(a.agent_scope) !== scopeKeyOf(b.agent_scope)
1002
+ || scopeKeyOf(a.workspace_scope) !== scopeKeyOf(b.workspace_scope)
1003
+ );
1004
+ };
1005
+ const applyList = freezeEnabled
1006
+ ? decisions.filter((d) => d.action !== "conflict")
1007
+ : decisions.filter((d) => d.action !== "conflict" || !isCrossScopeDecision(d));
993
1008
  if (freezeEnabled) {
994
1009
  const conflictsToFreeze = decisions.filter((d) => d.action === "conflict");
995
1010
  if (conflictsToFreeze.length > 0) {
@@ -1003,7 +1018,10 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
1003
1018
  }
1004
1019
  for (const d of toFreeze) {
1005
1020
  try {
1006
- service.saveConflictPending({ run_id: runId, memory_a: d.winner, memory_b: d.loser, reason: d.reason });
1021
+ // 返回 undefined = 该对已被人工裁决且两侧内容未变(issue #170 复核
1022
+ // 项 4:内容变了才重新入队)——不算停车、不进 frozenIds。
1023
+ const parked = service.saveConflictPending({ run_id: runId, memory_a: d.winner, memory_b: d.loser, reason: d.reason });
1024
+ if (parked == null) continue;
1007
1025
  frozenCount++;
1008
1026
  frozenIds.push(d.winner, d.loser);
1009
1027
  } catch (error) {
@@ -1014,6 +1032,29 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
1014
1032
  logger?.warn?.(`dsh-mneme dream: conflict freeze lookup failed: ${String(error)}`);
1015
1033
  }
1016
1034
  }
1035
+ } else {
1036
+ // 非 freeze:跨 scope conflict 停车(绝不进 applyDecisions)。带专属
1037
+ // reason + 模型给出的裁决理由,队列里读起来是「归属裁决」而非重复项。
1038
+ const crossScope = decisions.filter((d) => d.action === "conflict" && isCrossScopeDecision(d));
1039
+ for (const d of crossScope) {
1040
+ try {
1041
+ const parked = service.saveConflictPending({
1042
+ run_id: runId,
1043
+ memory_a: d.winner,
1044
+ memory_b: d.loser,
1045
+ reason: STR.scopeConflictParkReason[language](d.reason)
1046
+ });
1047
+ if (parked != null) {
1048
+ frozenCount++;
1049
+ frozenIds.push(d.winner, d.loser);
1050
+ }
1051
+ } catch (error) {
1052
+ logger?.warn?.(`dsh-mneme dream: failed to park cross-scope conflict ${d.winner}/${d.loser}: ${String(error)}`);
1053
+ }
1054
+ }
1055
+ if (crossScope.length > 0) {
1056
+ logger?.info?.(`[dsh-mneme] dream: parked ${crossScope.length} cross-scope conflict(s) for human ownership review`);
1057
+ }
1017
1058
  }
1018
1059
 
1019
1060
  // CAS-guarded, per-decision-transactional apply against the run snapshot:
package/lib/lang.js CHANGED
@@ -103,6 +103,17 @@ export const STR = {
103
103
 
104
104
  // --- dream/sleep.js:冲突候选列表 --------------------------------------------
105
105
  similarityReason: { zh: (s) => `相似度 ${s}`, en: (s) => `similarity ${s}` },
106
+ // v0.8.1(issue #170 第 2 步):跨 scope 相似对停车到冲突队列的专属 reason。
107
+ scopeCandidateReason: {
108
+ zh: (s) => `跨作用域相似 ${s}(疑似同一内容落在两个归属下,请裁决归属/去重)`,
109
+ en: (s) => `cross-scope similar ${s} (same content under two scopes — review ownership)`
110
+ },
111
+ // v0.8.1(issue #170 复核项 3):普通 dream 的 LLM 输出跨 scope conflict 时
112
+ // 停车用(相似度未知,带模型给的裁决理由)。
113
+ scopeConflictParkReason: {
114
+ zh: (r) => `跨作用域冲突(归属不同,不自动裁决)${r ? `:${r}` : ""}`,
115
+ en: (r) => `cross-scope conflict (different ownership — not auto-adjudicated)${r ? `: ${r}` : ""}`
116
+ },
106
117
  candidateConflicts: {
107
118
  zh: (p) => `候选冲突:\nid=${p.a.id} | type=${p.a.type} | title=${p.a.title}\n${p.a.content}\n---\nid=${p.b.id} | type=${p.b.type} | title=${p.b.title}\n${p.b.content}\n(相似度 ${p.similarity.toFixed(2)})`,
108
119
  en: (p) => `Candidate conflicts:\nid=${p.a.id} | type=${p.a.type} | title=${p.a.title}\n${p.a.content}\n---\nid=${p.b.id} | type=${p.b.type} | title=${p.b.title}\n${p.b.content}\n(similarity ${p.similarity.toFixed(2)})`
package/lib/scope.js CHANGED
@@ -8,8 +8,11 @@
8
8
  // 硬约束:解析失败绝不阻塞写入——任何一步取不到就落 NULL(读侧 NULL = 未标注
9
9
  // = 全局可见),任何异常都被吞掉(registry 故障 warn 一次后降级)。
10
10
  //
11
- // scopeEnabled 关闭时 resolveWriteScope 返回 null,调用方一个字段都不该标注,
12
- // 写入与去重行为和 A1 之前逐字节一致。
11
+ // v0.8.1(issue #170 复核项 2):scopeEnabled 的闸门从解析器挪到「写标注」处
12
+ // (memory_save 的 auto 分支)。解析器恒返回会话身份(真取不到才是 null)——
13
+ // 读取侧(strictVisibility / memory_get / inject)与显式声明都依赖它:flag 关
14
+ // 只关「自动标注」,不能连读取身份一起关,否则 strictScope 硬墙被静默拆掉
15
+ // (fail-open)。
13
16
 
14
17
  /**
15
18
  * @param {object} deps
@@ -31,8 +34,6 @@ export function createScopeResolver({ ctx, config, logger } = {}) {
31
34
  };
32
35
 
33
36
  return function resolveWriteScope(exec) {
34
- if (config?.scopeEnabled !== true) return null;
35
-
36
37
  let session = null;
37
38
  try {
38
39
  session = exec?.agent?.session ?? null;
@@ -81,3 +82,37 @@ export function createScopeResolver({ ctx, config, logger } = {}) {
81
82
  return { agent_scope: agentScope, workspace_scope: workspaceScope };
82
83
  };
83
84
  }
85
+
86
+ /**
87
+ * v0.8.1 底座(issue #170):显式 scope 参数(memory_save / memory_update /
88
+ * 面板编辑)的归一化。约定:
89
+ * - 省略(undefined,由调用方判断)= 该维不声明,回落载体自动标注;
90
+ * - "global"(大小写不敏感)/ "*" / 空白 = 显式声明全局 → 存储 NULL
91
+ * (行上的 agent_scope_source='explicit' 承担「显式全局」与「从未标注」
92
+ * 的区分,NULL 本身继续表示全局可见);
93
+ * - 其余非空 trim 字符串 = 收窄到该标签。
94
+ * 脏输入(非字符串)落 NULL(等同声明全局)——与 resolveWriteScope 的「解析
95
+ * 绝不阻塞写入」同一哲学:显式声明是用户意图,形状不对时宁可放宽不报错。
96
+ *
97
+ * @param {unknown} raw 工具/HTTP 参数原值
98
+ * @returns {string|null} 归一化后的标签值(null=显式全局)
99
+ */
100
+ export function normalizeExplicitScope(raw) {
101
+ if (typeof raw !== "string") return null;
102
+ const s = raw.trim();
103
+ if (!s || s === "*" || s.toLowerCase() === "global") return null;
104
+ return s;
105
+ }
106
+
107
+ /**
108
+ * scope 标注的比较键(去重键/检索加权/跨 scope 配对共用口径):非空 trim 字符串
109
+ * 原样返回,其余(NULL/undefined/空串/非字符串)统一为 null(「未标注」)。
110
+ * v0.8.1(issue #170 第 2 步)从 service.js 上提为共享定义——sleep 的跨 scope
111
+ * 候选配对必须与去重键用同一把钥匙,NULL 与已标注才不会错配。
112
+ *
113
+ * @param {unknown} v scope 标注原值
114
+ * @returns {string|null} 比较键(null=未标注)
115
+ */
116
+ export function scopeKeyOf(v) {
117
+ return typeof v === "string" && v.trim() ? v.trim() : null;
118
+ }