@modusensus/dsh-mneme 0.10.0 → 0.10.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/src/api.js CHANGED
@@ -1438,7 +1438,57 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
1438
1438
  to: e.to_entity,
1439
1439
  relation_type: e.relation_type,
1440
1440
  memory_id: e.memory_id ?? null,
1441
- created_at: e.created_at
1441
+ created_at: e.created_at,
1442
+ // issue #24 块4:被动确认的复核要靠这两个字段——weight 是「用出来
1443
+ // 的分」,source 决定它的起点(manual 1.0 / tag 0.3 / llm 0.4)。
1444
+ // 少了它们,「这条边被抬过」在面板上根本看不出来。取不到时给
1445
+ // 初值形状而不是 undefined,前端不必到处判空。
1446
+ weight: e.weight ?? 1.0,
1447
+ source: e.source ?? "manual"
1448
+ }))
1449
+ });
1450
+ } catch {
1451
+ sendJson(res, 500, { error: "internal" });
1452
+ }
1453
+ }
1454
+ });
1455
+
1456
+ // --- graph boosted relations: the review side of passive confirmation ---
1457
+ // issue #24 块4(被动确认)的复核出口。正常触达即 bump 边权重是安静的设计,
1458
+ // 但「抬过哪些边」必须能看见,否则「例外管理」只剩自动确认没有例外。
1459
+ // 判据是 gain = 现权 − 该 source 初值 > 0(见 store.listBoostedRelations),
1460
+ // 不另设记账表:抬升是唯一会改变检索形状的动作,它自己就是证据。
1461
+ // 只读、挂在 /semantic/graph/ 前缀下(SECURITY.md 的开放读清单已含该通配)。
1462
+ register({
1463
+ kind: "exact",
1464
+ path: "/api/dsh-mneme/semantic/graph/boosted-relations",
1465
+ policy: "open",
1466
+ handler(req, res) {
1467
+ try {
1468
+ const url = new URL(req.url, "http://localhost");
1469
+ const limit = Math.max(1, Math.min(200, Number(url.searchParams.get("limit") ?? 100) || 100));
1470
+ const rows = service.listBoostedRelations?.({ limit }) ?? [];
1471
+ sendJson(res, 200, {
1472
+ count: rows.length,
1473
+ // 提示「这张表为什么可能为空」:被确认抬升的边只在图真的被用过之后
1474
+ // 才出现,两个相关闸默认都关。面板据此说明而不是让人以为坏了。
1475
+ // 必须两个闸同时开才算「会继续产生新提升」——touchRecalled 里
1476
+ // passive = graphPassiveConfirm && graphWeightEnabled,只看前者会让面板
1477
+ // 说「会自动提升」而实际上一条都不会抬(评审指出)。
1478
+ passiveConfirmEnabled: config?.graphPassiveConfirm === true && config?.graphWeightEnabled === true,
1479
+ items: rows.map((r) => ({
1480
+ id: r.id,
1481
+ from: r.from_entity,
1482
+ to: r.to_entity,
1483
+ from_name: r.from_name ?? null,
1484
+ to_name: r.to_name ?? null,
1485
+ relation_type: r.relation_type,
1486
+ memory_id: r.memory_id ?? null,
1487
+ created_at: r.created_at,
1488
+ weight: r.weight,
1489
+ base_weight: r.base_weight,
1490
+ gain: r.gain,
1491
+ source: r.source
1442
1492
  }))
1443
1493
  });
1444
1494
  } catch {
@@ -46,7 +46,12 @@ export function minMax(scores) {
46
46
  /**
47
47
  * 把一条检测通路的结果归一化成 0..1 的打分表。
48
48
  * path 是离散通路名(exact/tag/...)→ 查 DISCRETE_LEVELS;是连续通路
49
- * (continuous=true)→ min-max。返回 { byId: Map<id, score>, order: [id...] }。
49
+ * (continuous=true)→ min-max。返回 { path, byId: Map<id, score>, order }。
50
+ *
51
+ * issue #24 块1 补充:返回值带上 `path`。anchorSeeds 用它记「这个种子被哪几路
52
+ * 激活」(`seeds[i].paths`),复核时据此判断一条边是被精确名、Tag 桥接还是向量
53
+ * 拉进来的。此前只有 { byId, order },`p.path ?? "?"` 把每一路都记成 "?",
54
+ * 通路身份在合并那一步就丢了。连续通路调用方传 `path: "vector"`(cos 相似度)。
50
55
  */
51
56
  export function normalizePath(hits, { path = "exact", continuous = false } = {}) {
52
57
  const ids = Array.isArray(hits) ? hits : [];
@@ -62,7 +67,7 @@ export function normalizePath(hits, { path = "exact", continuous = false } = {})
62
67
  const level = DISCRETE_LEVELS[path] ?? DISCRETE_LEVELS.substring;
63
68
  for (const id of ids) byId.set(id, level);
64
69
  }
65
- return { byId, order: [...byId.keys()] };
70
+ return { path, byId, order: [...byId.keys()] };
66
71
  }
67
72
 
68
73
  /**
package/src/service.js CHANGED
@@ -560,16 +560,36 @@ export function createService({ store, mirror, config, onWrite, logger, document
560
560
  * 用出来的」延伸成检索信号:查询命中的不只有直接挂联,还有跳跃可达的活跃子图。
561
561
  * 邻居权重 = HOP_QUOTA[hop] × linked tier(attr 1.0 / relation 0.9),仍低于
562
562
  * 直达种子的满权,绝不喧宾夺主。
563
+ *
564
+ * #24 块1 补充(三路起点接线):前半段只喂了 exact 一路,Tag 桥接与向量两路
565
+ * 能力在 anchoring.js 里却从未被调用。现补齐——
566
+ * · exact:查询里出现实体名(布尔 1.0);
567
+ * · tag :查询词命中某条记忆的 tag,该记忆挂联的实体即种子(计数级别 0.4);
568
+ * · vector:查询向量近邻的记忆挂联的实体(cos 走 min-max,连续通路)。
569
+ * 三路在累加前各按自己的尺度归一化(离散级别 vs min-max),混加不会让高频
570
+ * Tag 淹没强向量命中。两路都是弱信号入口:任一路为空就少一路,全空则
571
+ * anchorSeeds 返回空、整个图轴让位(与加这两路之前的行为一致)。
572
+ * 三者共用 graphAnchoringEnabled 一个闸:默认关时仍是逐字节的 #219 单跳轴。
563
573
  */
564
- function entityRecall(q, limit) {
574
+ function entityRecall(q, limit, { queryVector = null } = {}) {
565
575
  if (config?.entityRecallEnabled !== true) return [];
566
576
  try {
567
577
  const entities = store.findEntitiesMentionedIn(q);
568
- if (!entities.length) return [];
578
+ // Tag 桥接与向量两路只服务锚定层:关锚定时不查(少两次库读,也守住
579
+ // 「关档行为逐字节不变」的承诺——见下方 if 分支)。
580
+ const anchoring = config?.graphAnchoringEnabled === true;
581
+ const tagEntities = anchoring && typeof store.findEntitiesByTags === "function"
582
+ ? (() => { try { return store.findEntitiesByTags(q); } catch { return []; } })()
583
+ : [];
584
+ // 早退只在**三路全无来源**时才成立。向量路的存在感恰恰在查询里没有实体名
585
+ // 的时候最大(「数据库迁移」说不出任何实体),若仍以「名字没命中」早退,
586
+ // 第二、三路就永远等不到自己被调用——接上了也等于没接。
587
+ const canVector = anchoring && Array.isArray(queryVector) && queryVector.length > 0 && !!vectorIndex;
588
+ if (!entities.length && !tagEntities.length && !canVector) return [];
569
589
  // 关闭锚定级联=逐字节复用 #219 单跳轴:种子裁剪/重排/截断全是 #24 新增的
570
590
  // 行为,默认关时一条都不许漏进旧路径(graphSeedCap 也只在开启后生效——
571
591
  // 无论 cap 调多小,关档时命中实体仍全量挂联)。
572
- if (config?.graphAnchoringEnabled !== true) {
592
+ if (!anchoring) {
573
593
  const linked = store.getLinkedMemoryIds(entities.map((e) => e.id));
574
594
  if (!linked.size) return [];
575
595
  const hits = [];
@@ -581,9 +601,43 @@ export function createService({ store, mirror, config, onWrite, logger, document
581
601
  }
582
602
  return hits;
583
603
  }
584
- // 锚定种子(精确名布尔通路——命名实体逻辑上是离散匹配,不参与 min-max)。
585
- const exact = normalizePath(entities.map((e) => e.id), { path: "exact" });
586
- const seeds = anchorSeeds({ paths: [exact], cap: config?.graphSeedCap ?? 12 });
604
+ // 三路激活信号,各自先归一化再累加(尺度不同,混加会让高频通路淹没强相关)。
605
+ const paths = [];
606
+ // 第一路:精确名(布尔通路——命名实体逻辑上是离散匹配,不参与 min-max)。
607
+ if (entities.length) {
608
+ paths.push(normalizePath(entities.map((e) => e.id), { path: "exact" }));
609
+ }
610
+ // 第二路:Tag 桥接(tag 共现,离散级别 0.4——比精确名弱、比子串强)。
611
+ if (tagEntities.length) {
612
+ paths.push(normalizePath(tagEntities.map((e) => e.id), { path: "tag" }));
613
+ }
614
+ // 第三路:向量(连续通路,cos 相似度走 min-max)。记忆 → 挂联实体反查,
615
+ // 同一实体被多条近邻记忆挂联时取最高分(一条更近的记忆就够证明相关)。
616
+ if (Array.isArray(queryVector) && queryVector.length && vectorIndex) {
617
+ try {
618
+ const near = vectorIndex.search(queryVector, { limit: limit * 2, threshold: 0 });
619
+ const bestOf = new Map();
620
+ for (const m of near) {
621
+ const score = m?.score ?? 0;
622
+ if (!(score > 0)) continue;
623
+ for (const eid of store.entityIdsForMemories([m.id])) {
624
+ if (!(bestOf.get(eid) >= score)) bestOf.set(eid, score);
625
+ }
626
+ }
627
+ if (bestOf.size) {
628
+ paths.push(normalizePath(
629
+ [...bestOf.entries()].map(([id, score]) => ({ id, score })),
630
+ { path: "vector", continuous: true }
631
+ ));
632
+ }
633
+ } catch { /* 向量路失败只是少一路,不拖垮整条图轴 */ }
634
+ }
635
+ if (!paths.length) return [];
636
+ // 激活分为 0 的种子不算种子:min-max 会把**连续通路里最弱的那条**压成 0,
637
+ // 可下游给种子挂联记忆的是固定满权 tier(1.0/0.9),不看种子自己的分数。
638
+ // 留着 0 分种子,等于让「这一路里最不像的那条」以最强的方式把记忆拉进池。
639
+ // 零分项在 anchorSeeds 里排最后,所以先取 cap 再滤掉不会浪费名额。
640
+ const seeds = anchorSeeds({ paths, cap: config?.graphSeedCap ?? 12 }).filter((s) => s.score > 0);
587
641
  if (!seeds.length) return [];
588
642
  // 0-hop 直达:挂联记忆按 tier 定分(attr 1.0 / relation 0.9)——与 #219
589
643
  // 单跳轴同分。MAX 语义:同一条记忆被多实体挂联时保留最高分。
@@ -939,6 +993,9 @@ export function createService({ store, mirror, config, onWrite, logger, document
939
993
  const keyword = rawKeyword.map((m) => ({ ...m, score: scoreKeyword(m, q), source: "keyword" }));
940
994
  const wantVector = mode === "vector" || mode === "hybrid" || (mode === "auto" && !!embedder);
941
995
  let vector = [];
996
+ // #24 块1:查询向量在这里算出来,图轴的向量起点要用同一份——再嵌一次既
997
+ // 多一次模型调用,也可能拿到不同的向量(本地模型带随机性时)。
998
+ let vectorQuery = null;
942
999
  if (wantVector && embedder) {
943
1000
  try {
944
1001
  // Legacy embedders expose embed(query); local ones expose embedSingle.
@@ -947,6 +1004,7 @@ export function createService({ store, mirror, config, onWrite, logger, document
947
1004
  : embedder.embed.bind(embedder);
948
1005
  const qv = await embedSingle(q);
949
1006
  if (qv?.length) {
1007
+ vectorQuery = qv;
950
1008
  // Adaptive threshold (v0.5.0 1.2): the fetch runs at the loosest
951
1009
  // branch floor so the head-gap rule can still re-admit the tail;
952
1010
  // the final cutoff is computed against the fetched score
@@ -980,7 +1038,7 @@ export function createService({ store, mirror, config, onWrite, logger, document
980
1038
  // mentioned entities pull their linked memories into the pool. Keyword
981
1039
  // mode stays text-only per the mode contract, so the axis is skipped
982
1040
  // there even when enabled.
983
- const entity = mode === "keyword" ? [] : entityRecall(q, lim);
1041
+ const entity = mode === "keyword" ? [] : entityRecall(q, lim, { queryVector: vectorQuery });
984
1042
  // Entity axis shares BM25's backfill weight (we = wb) until #217 recall
985
1043
  // data justifies a distinct entity weight.
986
1044
 
@@ -1819,7 +1877,7 @@ export function createService({ store, mirror, config, onWrite, logger, document
1819
1877
  // 为空时兜底检索,避免注入路径双倍候选成本。
1820
1878
  if (config?.entityRecallEnabled === true && q) {
1821
1879
  try {
1822
- const graphHits = entityRecall(q, maxItems * 2);
1880
+ const graphHits = entityRecall(q, maxItems * 2, { queryVector });
1823
1881
  if (graphHits.length) {
1824
1882
  const graphIds = new Set(graphHits.map((m) => m.id));
1825
1883
  candidates = [
@@ -2872,6 +2930,9 @@ export function createService({ store, mirror, config, onWrite, logger, document
2872
2930
  saveRelation: (r) => store.saveRelation(r),
2873
2931
  listEntities: (o) => store.listEntities(o),
2874
2932
  getRelations: (id) => store.getRelations(id),
2933
+ // #24 块4 被动确认的复核出口:列出权重被 bump 抬过的边(gain = 现权 −
2934
+ // 该 source 的初值)。纯读,不动权重——「看哪些边被用出来过」是人的动作。
2935
+ listBoostedRelations: (o) => store.listBoostedRelations(o),
2875
2936
  // v0.7.0 实体热投影:实体热 = 关联记忆 heat 聚合(取 max)。无关联记忆
2876
2937
  // 或 heatEnabled=false 时返回 null;前端据此决定图谱节点大小/明暗。
2877
2938
  entityHeat: (entityId) => {
package/src/store.js CHANGED
@@ -1647,6 +1647,11 @@ export function createStore(path) {
1647
1647
  // 解析成本可以忽略、又永远够不到那格上限,块数只随包条数线性增长。
1648
1648
  const DELETE_ID_CHUNK = 500;
1649
1649
 
1650
+ // Tag 锚点的 IN 列表分块。比 DELETE_ID_CHUNK 多一层顾虑:那条语句只有一处 IN,
1651
+ // 这条有**三处**(attrs / relations.from / relations.to)共用同一个记忆 id 集合,
1652
+ // 所以单块占用的参数是 3×chunk。取 500 后最坏 1500 个参数,离 32766 仍有二十倍余量。
1653
+ const TAG_ID_CHUNK = 500;
1654
+
1650
1655
  /**
1651
1656
  * 整包退出(车道 C)。删空一个不存在的包是幂等的 0,不是错误——对方点第二次「收起
1652
1657
  * 这个包」与「收起一个从来没导入过的包」都该是这个答案;早退也不 bump generation:
@@ -3712,6 +3717,152 @@ export function createStore(path) {
3712
3717
  return { migrated, invalidated };
3713
3718
  }
3714
3719
 
3720
+ /**
3721
+ * Tag 桥接(issue #24 · 块1 三路起点里的第二路):查询词命中某些记忆的 tag
3722
+ * 时,那些记忆挂联的实体本身就是「这个查询正在谈的东西」——即使查询里没有
3723
+ * 出现它们的名字。Tag 是 LLM 打的标签,比精确名松,但比向量具体,正好填在
3724
+ * 二者之间。
3725
+ *
3726
+ * 匹配口径与 findEntitiesMentionedIn 对齐(JS 侧 toLowerCase,不受 SQLite
3727
+ * 只折 ASCII 的限制):把查询按空白与常见中英标点切词,任一词与某个 tag
3728
+ * 全等即算命中(不做子串——tag 通常是短词,子串匹配会让「ai」命中「mail」)。
3729
+ * 只认未归档未遗忘的行:一条已归档记忆的 tag 不该再拉出种子。
3730
+ * 返回 {id, name},按命中记忆数降序、名称升序(稳定)。
3731
+ */
3732
+ function findEntitiesByTags(text, { limit = 5 } = {}) {
3733
+ const words = String(text ?? "")
3734
+ .toLowerCase()
3735
+ .split(/[\s,,、;;::!!??.。()()[\]【】"'“”‘’/|]+/u)
3736
+ .map((w) => w.trim())
3737
+ .filter((w) => w.length >= 2);
3738
+ if (!words.length) return [];
3739
+ const rows = db.prepare(
3740
+ `SELECT id, tags FROM memories WHERE archived = 0 AND forgotten = 0`
3741
+ ).all();
3742
+ const memHits = new Set();
3743
+ for (const row of rows) {
3744
+ const tags = parseTags(row.tags).map((t) => String(t).toLowerCase());
3745
+ if (!tags.length) continue;
3746
+ if (words.some((w) => tags.includes(w))) memHits.add(row.id);
3747
+ }
3748
+ if (!memHits.size) return [];
3749
+ // 计数必须在 SQL 里做:entityIdsForMemories 返回的是 DISTINCT entity_id,
3750
+ // 拿它反推「命中几条记忆」永远是 1,排序就静默失效了(评审抓到的正是这个)。
3751
+ //
3752
+ // 两个来源都要算:只读 entity_attrs 会漏掉「记忆只通过关系挂到实体上」的情况
3753
+ // ——phaseRelations 只写 entity_relations.memory_id、不建 attr 行,于是仅由关系
3754
+ // 关联的记忆对关系两端一个种子都生不出来。这与 getLinkedMemoryIds 的口径一致
3755
+ // (那边 attr 与 relation 两端同样并列)。UNION 去重的是 (实体, 记忆) 对,
3756
+ // 所以同一条记忆的多条关联记录不会把 via 抬高。
3757
+ //
3758
+ // 分块理由同 DELETE_ID_CHUNK:一条语句一行一个 `?`,撞上 SQLITE_MAX_VARIABLE_NUMBER
3759
+ // (32766)会整笔抛错,而调用方 try/catch 兜底 → 静默丢掉整条 Tag 锚点。
3760
+ // 这里每块绑 3×TAG_ID_CHUNK 个参数(三处 IN 列表),500 仍有二十倍余量。
3761
+ const rowsByEntity = new Map();
3762
+ const memoryIds = [...memHits];
3763
+ for (let offset = 0; offset < memoryIds.length; offset += TAG_ID_CHUNK) {
3764
+ const batch = memoryIds.slice(offset, offset + TAG_ID_CHUNK);
3765
+ const marks = batch.map(() => "?").join(",");
3766
+ const batchRows = db.prepare(
3767
+ `SELECT hits.id AS id, e.name AS name, COUNT(DISTINCT hits.memory_id) AS via
3768
+ FROM (
3769
+ SELECT entity_id AS id, memory_id FROM entity_attrs
3770
+ WHERE memory_id IN (${marks}) AND entity_id IS NOT NULL
3771
+ UNION
3772
+ SELECT from_entity AS id, memory_id FROM entity_relations
3773
+ WHERE memory_id IN (${marks}) AND from_entity IS NOT NULL
3774
+ UNION
3775
+ SELECT to_entity AS id, memory_id FROM entity_relations
3776
+ WHERE memory_id IN (${marks}) AND to_entity IS NOT NULL
3777
+ ) hits JOIN entities e ON e.id = hits.id
3778
+ GROUP BY hits.id`
3779
+ ).all(...batch, ...batch, ...batch);
3780
+ for (const row of batchRows) {
3781
+ const prev = rowsByEntity.get(row.id);
3782
+ // 各块的记忆集合互不相交,所以 via 直接相加就是全集的 DISTINCT 计数。
3783
+ rowsByEntity.set(row.id, { id: row.id, name: row.name, via: (prev?.via ?? 0) + Number(row.via) });
3784
+ }
3785
+ }
3786
+ const rows2 = [...rowsByEntity.values()];
3787
+ if (!rows2.length) return [];
3788
+ return rows2
3789
+ .map((r) => ({ id: r.id, name: r.name, via: r.via }))
3790
+ .sort((a, b) => b.via - a.via || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
3791
+ .slice(0, limit)
3792
+ .map(({ id, name }) => ({ id, name }));
3793
+ }
3794
+
3795
+ /**
3796
+ * 按记忆批量反查实体 id(Tag 桥接用)。与 entitiesForMemory 的区别是「批量 +
3797
+ * 带 id」:那边是面板详情页的单条展示(只要 name/type),这边要的是能直接
3798
+ * 喂给 normalizePath 的 id 列表。同一 (记忆, 实体) 只出一次。
3799
+ *
3800
+ * 与 findEntitiesByTags 同口径:attr 行与关系两端并列。只读 entity_attrs 时,
3801
+ * 一条只通过关系挂到实体上的记忆在向量路里也生不出种子(phaseRelations 不建 attr)。
3802
+ */
3803
+ function entityIdsForMemories(memoryIds) {
3804
+ const ids = Array.isArray(memoryIds) ? memoryIds.filter(Boolean) : [];
3805
+ if (!ids.length) return [];
3806
+ const placeholders = ids.map(() => "?").join(",");
3807
+ const rows = db.prepare(
3808
+ `SELECT entity_id FROM entity_attrs
3809
+ WHERE memory_id IN (${placeholders}) AND entity_id IS NOT NULL
3810
+ UNION
3811
+ SELECT from_entity AS entity_id FROM entity_relations
3812
+ WHERE memory_id IN (${placeholders}) AND from_entity IS NOT NULL
3813
+ UNION
3814
+ SELECT to_entity AS entity_id FROM entity_relations
3815
+ WHERE memory_id IN (${placeholders}) AND to_entity IS NOT NULL`
3816
+ ).all(...ids, ...ids, ...ids);
3817
+ return rows.map((r) => r.entity_id);
3818
+ }
3819
+
3820
+ /**
3821
+ * 被动确认的复核出口(issue #24 · 块4):列出「权重已经被抬过」的边。
3822
+ *
3823
+ * 设计口径:被动确认本身是安静的——正常触达就 bump,不打扰人。要复核的恰恰
3824
+ * 是「抬过的那些」,因为抬升是唯一会改变检索形状的动作。判据不看有没有被
3825
+ * bump 过(那要额外记账),看权重是否高于该 source 的初值:RELATION_SOURCE_
3826
+ * DEFAULTS 是建边时的起点,高出去的部分只能来自 bumpRelationWeight。
3827
+ *
3828
+ * 显式传过 weight 的边(manual 自带权重)会被算成「抬过」,这在语义上没错
3829
+ * ——它确实不是初值——但它是人工意图,所以把 source 一起返回,面板自己决定
3830
+ * 怎么标。整数比较用 gain > 0 而不是 >= 0,防浮点累加的 1e-17 噪声。
3831
+ * 返回按 gain 降序、created_at 降序,附两侧实体名(面板不必再查一遍)。
3832
+ */
3833
+ function listBoostedRelations({ limit = 200, minGain = 0 } = {}) {
3834
+ const { limit: lim } = sanitizePage(limit, 1, 200);
3835
+ const rows = db.prepare(
3836
+ `SELECT r.*, ef.name AS from_name, et.name AS to_name
3837
+ FROM entity_relations r
3838
+ LEFT JOIN entities ef ON ef.id = r.from_entity
3839
+ LEFT JOIN entities et ON et.id = r.to_entity
3840
+ ORDER BY r.weight DESC, r.created_at DESC`
3841
+ ).all();
3842
+ const out = [];
3843
+ for (const row of rows) {
3844
+ const src = row.source ?? "manual";
3845
+ const base = Object.hasOwn(RELATION_SOURCE_DEFAULTS, src)
3846
+ ? RELATION_SOURCE_DEFAULTS[src]
3847
+ : 1.0;
3848
+ const gain = (row.weight ?? 1.0) - base;
3849
+ if (!(gain > minGain)) continue;
3850
+ out.push({
3851
+ ...toRelation(row),
3852
+ from_name: row.from_name ?? null,
3853
+ to_name: row.to_name ?? null,
3854
+ base_weight: base,
3855
+ gain
3856
+ });
3857
+ }
3858
+ // 排序必须在算完 gain 之后做,不能交给 SQL 的 weight DESC:不同 source 的
3859
+ // base 不同,weight 大不等于 gain 大(llm 边 0.95 / gain 0.55 会压过
3860
+ // tag 边 0.90 / gain 0.60)。早先在循环里按 lim 提前 break 时,被截掉的
3861
+ // 恰恰是 gain 更大的边——面板按 limit=100 取数,截断处正是最该看见的那几条。
3862
+ out.sort((a, b) => b.gain - a.gain || String(b.created_at).localeCompare(String(a.created_at)));
3863
+ return out.slice(0, lim);
3864
+ }
3865
+
3715
3866
  /** Relations where the entity appears on either side (from or to). */
3716
3867
  function getRelations(entityId) {
3717
3868
  return db.prepare(
@@ -4202,6 +4353,10 @@ export function createStore(path) {
4202
4353
  entitiesForMemory,
4203
4354
  findMemoriesByAttr,
4204
4355
  findEntitiesMentionedIn,
4356
+ // #24 块1 第二路(Tag 桥接)与块4 复核出口。
4357
+ findEntitiesByTags,
4358
+ entityIdsForMemories,
4359
+ listBoostedRelations,
4205
4360
  getLinkedMemoryIds,
4206
4361
  saveRelation,
4207
4362
  bumpRelationWeight,
@@ -2611,3 +2611,168 @@ test("memory drawer surfaces distillation provenance, both layers (issue #512)",
2611
2611
  assert.ok(clientSource.includes("/api/dsh-mneme/memory/provenance?id=") && clientSource.includes("setProvenance(null)"),
2612
2612
  "a failed read must clear the block instead of pretending there is no attribution");
2613
2613
  });
2614
+
2615
+ // Issue #24 块4:被动确认让「正常触达」自动抬高边的权重——这是唯一会悄悄改变
2616
+ // 检索形状的写动作,所以它必须有一个能查的地方,否则「自动确认」与「没人看过的
2617
+ // 自动确认」在界面上没有区别。这里锁四件事:
2618
+ // ① 复核区读的是那条专用端点,而不是把 ego 的边自己过滤一遍(抬高量 gain 只有
2619
+ // 后端知道——它要和来源基线比对,前端手里只有当前 weight);
2620
+ // ② 边侧卡必须同时显示 weight 和 source:只看 weight 会把「tag 边被抬到 0.5」
2621
+ // 误读成「比 manual 边弱」,而 0.3→0.5 恰恰是被用出来的证据;
2622
+ // ③ 闸关着时要说「闸关着」,而不是显示「还没有边被抬过」——后者让人以为系统
2623
+ // 在工作但没数据,前者才说明是开关没开;
2624
+ // ③c 空表文案也不能断言「从未抬过权」:端点只列「权重高于其来源基线」的边,
2625
+ // 一条人工边以 0.5 建立、被抬到 0.6 仍然低于基线 1.0,它确实被抬过却不在表里。
2626
+ // 文案只能描述「当前筛选结果为空」(评审第四轮指出)。
2627
+ // ④ 收起时不发请求(默认收起是刻意的:这是审计视角,不是日常视角)。
2628
+ test("entity graph surfaces the passive-confirmation review surface (issue #24)", () => {
2629
+ for (const key of [
2630
+ "memory.graph.boosted.title",
2631
+ "memory.graph.boosted.hint",
2632
+ "memory.graph.boosted.empty",
2633
+ "memory.graph.boosted.loadFailed",
2634
+ "memory.graph.boosted.gateOff",
2635
+ "memory.graph.boosted.show",
2636
+ "memory.graph.boosted.hide",
2637
+ "memory.graph.boosted.gain",
2638
+ "memory.graph.weight",
2639
+ "memory.graph.source",
2640
+ "memory.graph.src.manual",
2641
+ "memory.graph.src.confirmed",
2642
+ "memory.graph.src.tag",
2643
+ "memory.graph.src.llm"
2644
+ ]) {
2645
+ const defined = clientSource.split(`"${key}":`).length - 1;
2646
+ assert.equal(defined, 2, `i18n key ${key} must be defined exactly once in zh and once in en (got ${defined})`);
2647
+ }
2648
+
2649
+ // ① 专用端点
2650
+ assert.ok(clientSource.includes("/api/dsh-mneme/semantic/graph/boosted-relations?limit="),
2651
+ "the review surface must read the boosted-relations endpoint");
2652
+
2653
+ // ② 边侧卡:weight 与 source 一起出现,且 source 走字典而不是直接上屏原词
2654
+ assert.ok(clientSource.includes("memory.graph.weight\").replace(\"{w}\""),
2655
+ "the edge side card must render the weight");
2656
+ assert.ok(clientSource.includes("memory.graph.src.${selected.edge.source}"),
2657
+ "the edge side card must localize the source, not print the raw enum");
2658
+
2659
+ // ③ 闸关与空表是两句不同的话。
2660
+ // 断言必须落在「闸关分支之后的那一小段」而不是全文件 contains:gateOff 这个键
2661
+ // 在字典定义里本来就有一次,全文件搜一遍,把渲染处换成 empty 也照样命中——
2662
+ // 本条用例的首版变异自证就是这么翻车的。
2663
+ const gateIdx = clientSource.indexOf("boosted.passiveConfirmEnabled !== true");
2664
+ assert.ok(gateIdx > 0, "the off-gate branch must be driven by the server's passiveConfirmEnabled, not guessed");
2665
+ const gateBranch = clientSource.slice(gateIdx, gateIdx + 700);
2666
+ assert.ok(gateBranch.includes("memory.graph.boosted.gateOff"),
2667
+ "a closed gate must say so instead of looking like an empty table");
2668
+ // 闸关时既有的提升仍然要列出来(评审指出:关掉闸只表示今后不再自动抬,
2669
+ // 隐藏整张表会让人永远没法复核已经发生过的提升)。
2670
+ assert.ok(/off\s*&&\s*h\([^)]*gateOff/.test(gateBranch),
2671
+ "the off-gate notice must be rendered alongside the list, not as a replacement for it");
2672
+ assert.ok(gateBranch.includes("items.length === 0"),
2673
+ "the empty-table copy must be decided by item count, independently of the gate");
2674
+ // 空表提示本身也不能被闸门状态挡住:闸关且一条都没有时,光说「下面列的是此前
2675
+ // 已经抬过的边」却不给任何行,读起来像这一屏坏了(评审第二轮指出)。
2676
+ assert.ok(!/items\.length === 0\s*\?\s*\(!\s*off/.test(gateBranch),
2677
+ "the empty-table copy must render even when the gate is closed");
2678
+ assert.ok(!/off\s*\?[^:]*gateOff[^:]*:\s*\(boosted\.items/.test(gateBranch),
2679
+ "the gate must not short-circuit the list branch");
2680
+
2681
+ // ③c 空表文案只描述「当前筛选为空」,不能断言「从未有边被抬过」:端点按
2682
+ // gain > 0 筛,而 gain 是相对来源基线算的——manual 边 0.5→0.6 涨了,却仍低于
2683
+ // 基线 1.0,不在表里。说「还没抬过」就是事实错误。
2684
+ for (const lang of ["zh", "en"]) {
2685
+ const re = new RegExp(`"memory\\.graph\\.boosted\\.empty": "([^"]*)"`, "g");
2686
+ const all = [...clientSource.matchAll(re)].map((m) => m[1]);
2687
+ assert.equal(all.length, 2, `empty copy must exist in both languages (${lang})`);
2688
+ for (const copy of all) {
2689
+ assert.ok(!/还没|从未|nothing raised|no edge has been raised/i.test(copy),
2690
+ `empty copy must not claim nothing was ever raised: ${copy}`);
2691
+ assert.ok(/筛选|filter|筛选下|只列|only edges/i.test(copy),
2692
+ `empty copy must describe the current filter instead: ${copy}`);
2693
+ }
2694
+ }
2695
+
2696
+ // ③b 加载失败不能被说成「闸关」或「没有数据」——那会把接口故障误导成配置问题
2697
+ const failIdx = clientSource.indexOf("boosted.error === true");
2698
+ assert.ok(failIdx > 0, "the panel must distinguish a failed fetch from a closed gate");
2699
+ assert.ok(clientSource.slice(failIdx, failIdx + 160).includes("memory.graph.boosted.loadFailed"),
2700
+ "a failed fetch must render its own copy");
2701
+
2702
+ // ④ 收起时不取数
2703
+ assert.ok(clientSource.includes("if (boostedOpen) loadBoosted();"),
2704
+ "the review surface must not fetch while collapsed");
2705
+ });
2706
+
2707
+ // issue #553:`MemoryDrawer` 用了 `provenance` / `setProvenance`,而那句 useState 却落在
2708
+ // 同文件的上一个组件 `EntityPanel` 里——于是打开任何一条记忆详情都会在渲染期抛
2709
+ // `ReferenceError: provenance is not defined`,整个 renderer 被带走(DSH 直接重启)。
2710
+ //
2711
+ // 这个形状不是笔误级别的,它躲得过三样东西:
2712
+ // · 眼睛——两个组件隔了三千多行,同名的 useState 看起来完全正常;
2713
+ // · 语法检查——`provenance` 是合法标识符,只是不在作用域里;
2714
+ // · 现有用例——它们读的是源码字符串,从不执行组件。
2715
+ // 所以这里不锁某一个名字,而是把**每个组件里「调了 setter 却没有声明」**这件事整体扫一遍,
2716
+ // 才对得起「别再有人把 state 放进隔壁组件」这个意图。
2717
+ test("every client component declares the setters it calls (issue #553)", () => {
2718
+ // 浏览器/宿主全局,不是 React state,不该被要求声明
2719
+ const GLOBALS = new Set([
2720
+ "setTimeout", "setInterval", "setImmediate", "setAnimationFrame",
2721
+ "setState" // 子组件把 setter 当参数传下去时,形参名会命中,由 params 分支放行
2722
+ ]);
2723
+
2724
+ const lines = clientSource.split("\n");
2725
+ // 组件 = 缩进 4 空格的 function 声明(本文件所有 UI 组件都是这个形状)。
2726
+ // 排除自身名字就叫 `setXxx` 的——那是普通的 setter 函数定义,不是组件
2727
+ // (`openLibrary` / `setOverlayOpen` 就是一对模块级函数,后者调用自身是递归)。
2728
+ const comps = [];
2729
+ for (let i = 0; i < lines.length; i++) {
2730
+ const m = lines[i].match(/^ function ([A-Za-z_$][\w$]*)\s*\(([^)]*)\)\s*\{/);
2731
+ if (!m || /^set[A-Z]/.test(m[1])) continue;
2732
+ let end = lines.length;
2733
+ for (let j = i + 1; j < lines.length; j++) {
2734
+ if (/^ function /.test(lines[j]) || /^\s{0,3}(const|let|export|class) /.test(lines[j])) {
2735
+ end = j;
2736
+ break;
2737
+ }
2738
+ }
2739
+ comps.push({ name: m[1], params: m[2], start: i + 1, text: lines.slice(i, end).join("\n") });
2740
+ }
2741
+ assert.ok(comps.length > 50, `expected the component scan to find many components, got ${comps.length}`);
2742
+
2743
+ const offenders = [];
2744
+ // 模块作用域里已经存在同名绑定时不算越界(`openLibrary` 调 `setOverlayOpen` 就是这类:
2745
+ // 后者是模块级的普通函数,不是 React setter,而且函数声明会提升,先调后声明也成立)。
2746
+ // 整个 bundle 包在 `__ModuleLoader__.load({...})` 里,所以「模块顶层」= 缩进正好 4 空格。
2747
+ const moduleScope = new Set();
2748
+ for (const l of lines) {
2749
+ if (!/^ \S/.test(l)) continue;
2750
+ const f = l.match(/^ function\s+([A-Za-z_$][\w$]*)/);
2751
+ if (f) moduleScope.add(f[1]);
2752
+ const c = l.match(/^ (?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/);
2753
+ if (c) moduleScope.add(c[1]);
2754
+ }
2755
+
2756
+ for (const c of comps) {
2757
+ const declared = new Set();
2758
+ // const [x, setX] = ... / const setX = ...
2759
+ for (const m of c.text.matchAll(/const\s*\[[^\]]*?,\s*(set[A-Za-z_$][\w$]*)\s*\]\s*=/g)) declared.add(m[1]);
2760
+ for (const m of c.text.matchAll(/const\s+(set[A-Za-z_$][\w$]*)\s*=/g)) declared.add(m[1]);
2761
+ // 形参:组件自身的、以及体内嵌套函数/箭头函数的形参——
2762
+ // setter 经常这样往下传(`routeSelects(providerKey, modelKey, testState, setTestState, …)`)。
2763
+ for (const m of c.params.matchAll(/(set[A-Za-z_$][\w$]*)/g)) declared.add(m[1]);
2764
+ for (const m of c.text.matchAll(/\(([^()]*)\)\s*(?:=>|\{)/g)) {
2765
+ for (const p of m[1].matchAll(/set[A-Za-z_$][\w$]*/g)) declared.add(p[0]);
2766
+ }
2767
+ // 调用点:setXxx( ——排除 obj.setXxx(
2768
+ for (const m of c.text.matchAll(/(?<![.\w$])(set[A-Z][A-Za-z0-9_$]*)\s*\(/g)) {
2769
+ const name = m[1];
2770
+ if (GLOBALS.has(name) || declared.has(name) || moduleScope.has(name)) continue;
2771
+ offenders.push(`${c.name} (line ${c.start}) calls ${name} without declaring it`);
2772
+ }
2773
+ }
2774
+
2775
+ assert.deepEqual(offenders, [],
2776
+ "a setter must be declared in the component that calls it — a state hook placed in the "
2777
+ + "neighbouring component compiles fine but throws ReferenceError at render time (#553)");
2778
+ });