dsh-prime-memory 0.26.5 → 0.26.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.en.md +62 -1
  3. package/README.md +52 -1
  4. package/dist/config.d.ts +9 -0
  5. package/dist/config.js +8 -0
  6. package/dist/contract.d.ts +2 -0
  7. package/dist/embedding-child.cjs +192 -0
  8. package/dist/embedding-worker.cjs +2 -0
  9. package/dist/graph/apply.d.ts +9 -0
  10. package/dist/graph/apply.js +12 -2
  11. package/dist/graph/search.d.ts +11 -2
  12. package/dist/graph/search.js +38 -10
  13. package/dist/hooks/recall.js +28 -1
  14. package/dist/index.js +8 -1
  15. package/dist/llm.d.ts +7 -2
  16. package/dist/llm.js +14 -22
  17. package/dist/pipeline/graph.d.ts +17 -1
  18. package/dist/pipeline/graph.js +45 -2
  19. package/dist/pipeline/l1.js +87 -26
  20. package/dist/pipeline/md-sync.js +4 -2
  21. package/dist/pipeline/runner.d.ts +26 -0
  22. package/dist/pipeline/runner.js +74 -7
  23. package/dist/pipeline/slot-migrate.d.ts +3 -2
  24. package/dist/pipeline/slot-migrate.js +3 -2
  25. package/dist/projection/recall.d.ts +10 -3
  26. package/dist/projection/recall.js +14 -0
  27. package/dist/settings.js +3 -0
  28. package/dist/stats.d.ts +1 -1
  29. package/dist/stats.js +17 -11
  30. package/dist/store/embedding-source.d.ts +4 -2
  31. package/dist/store/embedding-source.js +5 -2
  32. package/dist/store/graph-store.d.ts +71 -1
  33. package/dist/store/graph-store.js +221 -8
  34. package/dist/store/l1.d.ts +5 -0
  35. package/dist/store/l1.js +18 -2
  36. package/dist/store/local-embedding.d.ts +9 -5
  37. package/dist/store/local-embedding.js +137 -17
  38. package/dist/store/memory-md-disable.d.ts +3 -2
  39. package/dist/store/memory-md.d.ts +3 -3
  40. package/dist/store/memory-md.js +6 -4
  41. package/dist/store/runtime-installer.d.ts +13 -1
  42. package/dist/store/runtime-installer.js +40 -2
  43. package/dist/store/sqlite.d.ts +24 -12
  44. package/dist/store/sqlite.js +97 -31
  45. package/dist/store/supersede.d.ts +10 -2
  46. package/dist/store/supersede.js +10 -2
  47. package/dist/store/validity.d.ts +150 -0
  48. package/dist/store/validity.js +209 -0
  49. package/dist/tools/index.d.ts +15 -1
  50. package/dist/tools/index.js +393 -11
  51. package/dist/util/json-repair.d.ts +48 -0
  52. package/dist/util/json-repair.js +285 -0
  53. package/dist/validity-backfill.d.ts +78 -0
  54. package/dist/validity-backfill.js +160 -0
  55. package/dsh.plugin.json +2 -2
  56. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # 更新日志(Changelog)
2
2
 
3
+ ## [0.26.6] — 2026-10-22
4
+
5
+ ### 新增
6
+
7
+ - **图谱确定性写面(计划 graph-write-plane)**:写面从"add/delete/import"补齐"改、重投、迁"三语义。
8
+ - `memory_update`(高权限):修订一条既有记忆——就地产新版本(version+1,新 id),旧版经退场汇流口
9
+ `superseded` 软删(可恢复),随后对 [旧,新] 强制重投让图谱边跟随;extKey 不随血缘走(幂等索引由旧行占位)。
10
+ - `memory_reproject`(需 `graph.enabled`):抹掉"已按当前版本投影"凭证、按高优先级重新入队——绕开
11
+ `projector_version` 永久跳过;幂等(在途任务不堆重)。手加/导入的记忆长期不出现在图谱里的纠正通道。
12
+ - `memory_migrate_relation`(高权限):源导式关系迁移——硬校验来源全部"现存未退场"(判据单源
13
+ `classifyValidity`)→ 显式边提案经 `applyGraphProjection` 纯函数校验落库(同 (起点,关系) 换目标自动闭旧边
14
+ 出新边)→ 新增承载新关系的 L1 源记录留可回溯根据(metadata `proposal_source='tool'` + 决策凭证)。
15
+ 全程不提供、也不会提供绕过投影直写 `graph_*` 表的入口(零无源事实不变量)。
16
+ - **非蒸馏写入自动入投影队**:`memory_add`/`memory_import` 与蒸馏产出同优先级(`GRAPH_PRIORITY_NEW`)进队——
17
+ 闭合"只有蒸馏才投影"的旁路缺口(手加记录 projected=None 的真根因)。图谱向量路缺陷同步修复:
18
+ 嵌入启用时投影收尾为落库的 active 节点补写 `graph_node_vec`(旧版本该调用生产零落点、全库 0 行)。
19
+ - **记忆时效三档与工具反查(计划 expiry-tiering,ADR-0022)**:退场方式/有效性定性/持续性/现实有效期
20
+ 四轴正交,六态(current/historical/expired/expired_suspect/superseded/retired)判据单源 `classifyValidity`(查询期谓词,
21
+ 不落库翻态);`dsh_validity` metadata 标注落写侧汇流口(证据链 activity_*→evidence、工具直填→llm_inferred、
22
+ 兜底→unknown)。门2 灰度闸 `validity.tieringEnabled`(默认关):开时被动注入剔 `expired`、`expired_suspect`
23
+ 保留带"疑似失效"标注,召回卡显示 `validity`/`validUntil`。门3:`memory_search` 新增 `include_expired` /
24
+ `as_of`(有效期回放,不复活退场)/ `follow_supersede`(顺/逆取代链)三参数,全缺省时输出与改前逐字一致。
25
+ 存量标注由零 LLM 的 `validity-backfill` 低频补齐(启动 3 分钟首轮、6h 续跑;改过的记录顺手重投图谱保鲜)。
26
+
27
+ ### 修复
28
+
29
+ - **门1 解耦:合法历史 valid_to 不再被静默遮蔽**(bugfix,不受灰度门控):退场判据全仓改为只认取代标记
30
+ (`retiredL1Sql`/`isRetired`/md 投射层同口径一对);`retire()` 不再闭合 valid_to,它回归纯现实有效期。
31
+ 升版后首启自动做一次 FTS 全量回灌收敛误遮蔽历史(`gate1.retire-decoupled.v1` 戳幂等,日志报回灌/跳过数;
32
+ 向量面由增量重嵌自动补齐或设置页手动重建加速);面板"已退场"区/删除线/计数随之只认真退场行。
33
+ - **退场→图谱退役传播补腿(R9)**:软删/取代后按"来源是否全部退场态"重算清扫——全退场的 active/disputed
34
+ 节点·边标 archived 墓碑(保留行 expand 可见),来源全部复活(restore)则拉回 active;多源支撑的节点不误杀。
35
+ - **md 同步退场收敛**:`.disable` 回灌的"已退场"判据换 marker 单源,修复同批翻转波及的"第二轮重复计数、
36
+ 永不收敛 same"。
37
+
3
38
  ## 0.26.2 (2026-10-18)
4
39
 
5
40
  - hosts: 追加 0.2.1-rc.2 声明面枚举(34/34 @deepseek-ai/* 宿主 peer 逐包 npm 实测 rc.2 在版;本插件不触 ctx.web provider 与 conversation.input.model.label 槽,代码零改动,纯元数据发版;农场 rc.2 格挂账待命)
@@ -22,6 +57,10 @@
22
57
  - **撤下浮动球 UI(`client/src/longtask-fab.ts`;beta.1 起曾随包发布)**:悬浮建议面板的归属本在 context-compression 侧(「建议启动压缩」是其领域),pm 属误载——2026-10-02 用户裁定撤下。撤下不违座位契约(settings.section 恰一个 + 输入栏 pill,body 单例不占座位),但全应用级 overlay 会与其他插件的悬浮 UI 抢占屏幕角落;长任务开关与手动压缩入口待长任务定版时落记忆设置分节。
23
58
  - **`session-mode-set` 的 `mode` 转为可选**:与 `longTask` 二选一必传——纯切长任务开关不再被迫发送档位(旧客户端恒传 mode,行为不变)。响应 `mode` 改回当前生效值。
24
59
 
60
+ ### 修复
61
+
62
+ - **嵌入推理子进程隔离,根治 libvips DLL 撞名(`fix #11`)**:旧路径把 transformers 推理放在 worker_threads 子线程,与宿主 Electron 同进程——插件 runtime 的 sharp/libvips 与宿主 sharp/libvips 同名不同路径时在 OS LoadLibrary 层冲突(切大模型 bge-small→bge-m3 时崩溃)。现在默认改走 `child_process.fork()` 独立子进程(`resources/embedding-child.cjs`,主线程侧 `RealChildChannel` 协议代理),DLL 表物理隔离;配置经 env `DSH_EMBED_CFG` 传递,向量经 number[][] 序列化回传(512d×32 条 ≈64KB,<5ms,实测 batch32 含序列化 91ms、单条中位数 5ms),零拷贝 transfer 降级为可忽略开销。旧 `RealWorkerChannel` 与 `embedding-worker.cjs` 保留一版作回滚逃生门(`opts.useWorker=true`,下版删);`RuntimeInstaller.isReady()` 兼验 sharp 平台原生件就位(--ignore-scripts 半装状态检出即触发重装)。真机 worker↔child 32 对向量 cosine=1.0(IEEE754 float32 往返无损)。用户无感知,非 breaking。
63
+
25
64
  ## [0.26.1] — 2026-10-15
26
65
 
27
66
  ### 新增
package/README.en.md CHANGED
@@ -474,7 +474,9 @@ adds 12 agent-facing memory tools (high-privilege writes `memory_add` / `memory_
474
474
  `memory_import`, ruminate controls `memory_ruminate`, the memory graph
475
475
  `memory_search_graph` / `memory_expand_graph_node`, decision-receipt backtracking
476
476
  `memory_receipts`, and conflict adjudication `memory_resolve_conflict`), the `skills/memport`
477
- cross-tool memory transfer, and storefront screenshot declarations.
477
+ cross-tool memory transfer, and storefront screenshot declarations; the deterministic
478
+ write-plane adds three more (`memory_update` / `memory_reproject` / `memory_migrate_relation`,
479
+ see the "Deterministic graph write plane" section).
478
480
 
479
481
  Two of those tools exist for **traceability**:
480
482
 
@@ -518,6 +520,65 @@ The restore entry point accepts **directory names only, never paths**: otherwise
518
520
 
519
521
  > **One semantic you need to know**: `cleanup-retired` only removes **already-retired** records, and the snapshot is taken **before** deletion — so every record a cleanup snapshot can bring back carries a retirement marker. By default `snapshot-restore` only writes the row back to the main table (and reports it honestly in `stillRetired`); **"back in the main table" ≠ "back in recall"**. For a genuine one-call rollback add `unretire: true`, or call `records-restore` on that batch of ids afterwards.
520
522
 
523
+ ## Memory expiry tiers and supersede-chain lookup
524
+
525
+ A memory's "temporality" splits into **four non-substitutable axes** ([ADR-0022](./docs/adr/0022-expiry-tiering-and-validity.md)):
526
+ retirement mode (supersede marker), validity state (derived at query time), objective persistence, and the
527
+ real-world validity window (`valid_to`). The old model crammed all four into one `valid_to` column — the cost
528
+ was that perfectly legal historical memories ("a trip that ended in 2020") were **silently shadowed from the
529
+ retrieval surface** although they never retired.
530
+
531
+ | Tier | Meaning | Indexed | Auto-injected (default) | Explicit tool query |
532
+ | --- | --- | --- | --- | --- |
533
+ | `current` | open-ended, not past its window | ✅ | ✅ | ✅ |
534
+ | `historical` | point/closed-span/timeless, not retired | ✅ | ✅ | ✅ |
535
+ | `expired` | window closed **with trustworthy evidence** | ✅ | ❌ (gated, gate 2) | ✅ `include_expired` |
536
+ | `expired_suspect` | same but the window source is untrustworthy (flag, never hide) | ✅ | ✅ annotated | ✅ |
537
+ | `superseded` / `retired` | soft-deleted (restorable) | ❌ | ❌ | via supersede chain only |
538
+
539
+ Three gates:
540
+
541
+ - **Gate 1 (bugfix, ungated)**: retirement is judged by the marker alone, everywhere; the first boot after
542
+ upgrade runs one full FTS back-fill that returns wrongly-shadowed history to the retrieval surface
543
+ (log reports back-filled/skipped counts; vectors catch up incrementally or via the manual re-index button).
544
+ - **Gate 2 (grey flag `validity.tieringEnabled`, off by default)**: when on, passive injection drops
545
+ `expired` and keeps `expired_suspect` with a "suspected expired" annotation; recall bubbles show
546
+ "expired since X". A same-named runtime toggle (OR-ed with the deploy config) lives on the settings overview.
547
+ - **Gate 3 (`memory_search` parameters, all off by default = byte-identical to before)**: `include_expired`
548
+ admits trustworthy-expired rows with `state`/`valid_from`/`valid_to`/`valid_to_source`; `as_of` replays the
549
+ validity axis at a past moment (never reviving retired rows); `follow_supersede` walks the supersede chain
550
+ to answer "what did this entry replace".
551
+
552
+ Legacy rows get their `dsh_validity` annotation from a low-frequency local back-fill (zero LLM; touched
553
+ records are re-projected into the graph to keep it fresh). The classifier is a single source: the six tiers
554
+ are query-time predicates, never wall-clock-driven stored state.
555
+
556
+ ## Deterministic graph write plane and the correction channel
557
+
558
+ The graph is a **derived projection** of L1: nodes/edges are always recomputed from current
559
+ facts, so the single source of truth for every write is L1 itself —
560
+ **correction = edit the source + reproject, never hand-written SQL**. This plugin does not
561
+ offer, and will never offer, an entry that bypasses projection to write `graph_*` tables
562
+ directly ("zero source-less facts" invariant: every edge traces back to live L1 records via
563
+ `sourceRecordIds`).
564
+
565
+ | Action | Tool | Preconditions | Notes |
566
+ | --- | --- | --- | --- |
567
+ | Revise one memory | `memory_update` | high-privilege mode | Read old → create new (`version+1`, new id) → old row retires via the shared soft-delete confluence with a `superseded` marker (restorable) → new version enters retrieval; then both [old, new] ids are force-reprojected so graph edges follow (edges supported only by the old source get tombstoned). Not a physical delete; the JSONL fact source is append-only and never rewritten |
568
+ | Force reprojection | `memory_reproject` | `graph.enabled` | Erases the "already projected at current version" receipts and re-enqueues at top priority, bypassing the permanent `projector_version` skip. Idempotent: in-flight jobs never pile up. **Manually added/imported memories that never showed up in the graph: this is the back-fill projection channel** (non-distilled writes now auto-enqueue; the tool remains the correction path for legacy rows and post-edit freshness) |
569
+ | Migrate one relation | `memory_migrate_relation` | high-privilege mode | Source-derived semantics: hard-validate that every `source_record_ids` is live and un-retired → the new relation lands through the deterministic projection branch (same (from, relation) with a new target closes the old edge) → a new L1 carrier record is written as the traceable basis (tagged `proposal_source=tool` plus a decision receipt). Old edges are not flipped back by later rejections: the reprojection batch pulls only the carrier, not sources whose text still describes the old relation |
570
+ | Non-distilled writes auto-enqueue | `memory_add` / `memory_import` | `graph.enabled` | After a successful write, records join the projection queue at the same priority as distillation output (`GRAPH_PRIORITY_NEW`) — the "only distillation projects" bypass is closed |
571
+
572
+ Two boundaries worth knowing:
573
+
574
+ - **Frequent updates accumulate version rows**: retired old rows stay in the main table (the
575
+ price of restorability); volume is governed by the existing `cleanup-retired` (dry-run +
576
+ snapshot gate).
577
+ - **The graph vector path activates with the embedding source**: `graph_node_vec` is
578
+ *structurally absent* when embeddings are off (not an empty table); once embeddings are on,
579
+ projection wrap-up writes vectors for freshly landed active nodes (legacy nodes catch up
580
+ via reprojection/rebuild).
581
+
521
582
  ## Roadmap
522
583
 
523
584
  Features under planning — feedback and priorities welcome in the
package/README.md CHANGED
@@ -461,6 +461,56 @@ fsync),断电等极端崩溃最多丢最后一小段尾部,检索库可用
461
461
 
462
462
  > **一处必须知道的语义**:`cleanup-retired` 只清理**已退场**记录,而快照拍在删除**之前**——所以清理快照能找回的每一条都带着退场标记。默认情况下 `snapshot-restore` 只把行写回主表(并在 `stillRetired` 里如实报出),**"回到主表" ≠ "回到召回"**;要一步完成真正的回滚,加 `unretire: true`,或随后对该批 id 调 `records-restore`。
463
463
 
464
+ ## 记忆时效三档与失效链反查
465
+
466
+ 一条记忆的"时间性"拆成**四根互不替代的轴**(ADR-0022):退场方式(取代标记)、有效性定性
467
+ (查询期派生六态)、客观持续性(persistence)、现实有效期(valid_to)。旧版把这四件事塞进
468
+ 一根 `valid_to` 列,代价是"2020 年结束的旅行"这类**合法历史记录被检索面静默遮蔽**——
469
+ 它没退场,只是成了历史。
470
+
471
+ | 档 | 含义 | 进索引 | 被动注入(默认) | 工具显式查 |
472
+ | --- | --- | --- | --- | --- |
473
+ | `current` | 仍在持续且未到有效期 | ✅ | ✅ | ✅ |
474
+ | `historical` | 时点/已结束区间/无时间性,未退场 | ✅ | ✅ | ✅ |
475
+ | `expired` | 有效期已闭合**且来源可信** | ✅ | ❌ 需开门2 | ✅ `include_expired` |
476
+ | `expired_suspect` | 同上但有效期来源不可信(宁判疑不静默隐) | ✅ | ✅ 带标注 | ✅ |
477
+ | `superseded` / `retired` | 取代/人工退场(软删,可恢复) | ❌ 撤出 | ❌ | 仅取代链反查 |
478
+
479
+ 三扇门:
480
+
481
+ - **门1(bugfix,不受灰度门控)**:退场判据全仓改为只认取代标记;升版后首启自动做一次
482
+ FTS 全量回灌,把误遮蔽的历史记忆放回检索面(日志报回灌/跳过数;向量面由增量重嵌补齐,
483
+ 或设置页手动「语义检索重建」加速)。
484
+ - **门2(灰度闸 `validity.tieringEnabled`,默认关)**:开时被动注入剔除 `expired`,
485
+ `expired_suspect` 保留并在注入行带"疑似失效"标注;召回卡泡泡同步显示"已于 X 失效"。
486
+ 面板概览有同名运行时开关(与部署配置取或),验证通过后再评估翻默认。
487
+ - **门3(`memory_search` 反查参数,默认全关 = 与改前逐字一致)**:`include_expired` 放行
488
+ 可信过期项并附 `state`/`valid_from`/`valid_to`/`valid_to_source`;`as_of` 按有效期回放
489
+ 历史时刻(不复活退场记忆);`follow_supersede` 顺取代链反查"本条取代了谁"。
490
+
491
+ 存量记录的 `dsh_validity` 标注由低频本地回填补齐(零 LLM;改过的记录顺手重投图谱保鲜)。
492
+ 判据单源 `classifyValidity`:六态是查询期谓词,不会随墙钟落库翻态。
493
+
494
+ ## 图谱确定性写面与纠正通道
495
+
496
+ 图谱是 L1 的**派生投影**:节点/边永远由"当前事实重算"产出,所以写面的唯一真源是 L1,
497
+ **纠正 = 改源 + 重投,不是手写 SQL**。本插件不提供、也永远不会提供绕过投影直写
498
+ `graph_*` 表的入口("零无源事实"不变量:每条边都能沿 `sourceRecordIds` 点查回现存 L1 记录)。
499
+
500
+ | 动作 | 工具 | 前提 | 说明 |
501
+ | --- | --- | --- | --- |
502
+ | 修订一条记忆 | `memory_update` | 高权限模式 | 读旧 → 造新(`version+1`,新 id)→ 旧行经退场汇流口 `superseded` 软删(可恢复)→ 新版进检索面;随后对 [旧,新] 强制重投,图谱边跟随(旧源支撑的边自动标墓碑)。不是物理删,也不覆写 JSONL 事实源 |
503
+ | 强制重投 | `memory_reproject` | 配置 `graph.enabled` | 抹掉"已按当前版本投影"凭证、按高优先级重新入队——绕开 `projector_version` 的永久跳过。幂等:在途任务不堆重。**手加/导入的记忆长期不出现在图谱里,用它补投影**(非蒸馏写入现已自动入队,此工具是给存量与改后保鲜的纠正通道) |
504
+ | 迁移一条关系 | `memory_migrate_relation` | 高权限模式 | 源导语义:先硬校验 `source_record_ids` 全部"现存未退场" → 新关系经确定性投影支路落库(同 (起点,关系) 换目标自动闭旧边出新边)→ 新增一条承载新关系的 L1 源记录留可回溯根据(带 `proposal_source=tool` 审计标记与决策凭证)。旧边不会因后续重投被翻回:重投批只拉载体记录,不拉描述旧关系的旧源 |
505
+ | 非蒸馏写入自动入队 | `memory_add` / `memory_import` | 配置 `graph.enabled` | 落库成功后与蒸馏产出同优先级(`GRAPH_PRIORITY_NEW`)进投影队——"只有蒸馏才投影"的旁路缺口已闭合 |
506
+
507
+ 两条必须知道的边界:
508
+
509
+ - **频繁 update 会累积版本行**:旧行以退场态留在主表(可恢复的设计代价),规模由既有
510
+ `cleanup-retired`(干跑 + 快照门禁)控制;
511
+ - **图谱向量路随嵌入源激活**:`graph_node_vec` 在嵌入关闭时**结构性不存在**(不是空表);
512
+ 启用嵌入后,投影收尾会为落库的 active 节点补写向量(旧版本存量节点经重投/重建逐步补齐)。
513
+
464
514
  ## 路线图
465
515
 
466
516
  以下为规划中的功能,欢迎在 [Issues](https://github.com/drscrewdriver/dsh-prime-memory/issues) 反馈需求与优先级:
@@ -477,7 +527,8 @@ fsync),断电等极端崩溃最多丢最后一小段尾部,检索库可用
477
527
  相对上游,本仓库新增了面向 Agent 的 12 个记忆工具(含高权限写入 `memory_add` / `memory_delete` /
478
528
  `memory_import`、反刍控制 `memory_ruminate` 系列、记忆图谱 `memory_search_graph` /
479
529
  `memory_expand_graph_node`、决策凭证回溯 `memory_receipts`、矛盾裁决 `memory_resolve_conflict`)、
480
- `skills/memport` 跨工具记忆搬运,以及商店截图声明。
530
+ `skills/memport` 跨工具记忆搬运,以及商店截图声明;确定性写面计划又补上三件
531
+ (`memory_update` / `memory_reproject` / `memory_migrate_relation`,见"图谱确定性写面"一节)。
481
532
 
482
533
  其中两个工具服务于**可追溯性**:
483
534
 
package/dist/config.d.ts CHANGED
@@ -81,6 +81,15 @@ export interface MemoryConfig {
81
81
  * 同时为真才执行;图谱是 L1 的可重建投影,关闭不影响记忆主链路。 */
82
82
  enabled: boolean;
83
83
  };
84
+ /**
85
+ * 时效分级(expiry-tiering 门2):`tieringEnabled` 灰度闸,**默认关**。
86
+ * 关时被动注入与改前逐字一致(零漂移);开时注入候选按 classifyValidity 现算态
87
+ * 剔除 `expired`(仍在索引可检索,只是不自动浮现),`expired_suspect` 保留并带标注。
88
+ * 与部署 cfg.validity.tieringEnabled 取或的还有面板运行时开关(live.validityTiering)。
89
+ * 门1 解耦本身不受本闸控(它是 bugfix:误遮蔽历史应尽快恢复)。 */
90
+ validity: {
91
+ tieringEnabled: boolean;
92
+ };
84
93
  /** §C 矛盾冻结:去重判定"两边都像是对的、机器判不了"时不自动裁决,
85
94
  * 把冲突对停放到待人工裁决区(conflict_pending)。**默认关**——
86
95
  * 冻结消耗人的注意力,不可默认全开。 */
package/dist/config.js CHANGED
@@ -87,6 +87,9 @@ export function liveSettingsSchema() {
87
87
  redistillEnabled: Schema.boolean().default(false),
88
88
  mdSyncEnabled: Schema.boolean().default(false),
89
89
  memportEnabled: Schema.boolean().default(false),
90
+ /** 时效分级门2 面板开关:与部署 cfg.validity.tieringEnabled 取或(任一为真即启用
91
+ * 注入侧 expired 过滤)。默认关;先面板验证再翻部署默认(照 keywordEnabled 先例)。 */
92
+ validityTiering: Schema.boolean().default(false),
90
93
  /**
91
94
  * 存量回填(keyword / redistill)LLM 批调用的**运行时并行度**覆盖:
92
95
  * `0` = 跟随部署 `cfg.backfill.concurrency`;1–8 = 直接覆盖。
@@ -156,6 +159,11 @@ export const memorySchema = Schema.object({
156
159
  graph: Schema.object({
157
160
  enabled: Schema.boolean().default(false),
158
161
  }),
162
+ // 时效分级门2 灰度闸:默认关(关=注入行为与改前逐字一致)。验后翻默认为 true 的
163
+ // 发版决策记在计划 findings(task_8.6)。
164
+ validity: Schema.object({
165
+ tieringEnabled: Schema.boolean().default(false),
166
+ }),
159
167
  // §C 矛盾冻结:默认关。开启后去重决策词表多出 conflict 动作,冲突对停放待人工裁决。
160
168
  conflictFreeze: Schema.object({
161
169
  enabled: Schema.boolean().default(false),
@@ -116,6 +116,8 @@ export interface MemoryLiveSettings {
116
116
  mdSyncEnabled: boolean;
117
117
  /** 外部记忆来源 Connect 启用开关(概览区可拨):true = 放行 memport-* 端点。与部署 cfg.memport.enabled 取或。 */
118
118
  memportEnabled: boolean;
119
+ /** 时效分级门2 启用开关(概览区可拨):true = 被动注入剔除 expired。与部署 cfg.validity.tieringEnabled 取或;关=注入与改前逐字一致。 */
120
+ validityTiering: boolean;
119
121
  /** md 投射/后缀收敛的单次动作护栏运行时覆盖:0 = 跟随部署 `cfg.memExport.maxRecords`(默认 2000)。
120
122
  * 存在的理由:一次点完多少条属于“回填历史时才改一下”的旋钮,不该要求改 YAML + 重启。 */
121
123
  mdExportMaxRecords: number;
@@ -0,0 +1,192 @@
1
+ /**
2
+ * 本地嵌入子进程(fix #11):transformers.js 的模型加载与推理在 fork 出的独立
3
+ * 子进程执行,主线程只投递文本、收回向量。取代 worker_threads 的原因:
4
+ * 插件 runtime 的 sharp/libvips 与宿主 Electron 的 sharp/libvips 在同一进程内
5
+ * 加载会撞 libvips-42.dll 名字(OS LoadLibrary 层冲突,同名不同路径行为未定义)
6
+ * ——worker_thread 与主线程同进程,撞名前提仍在;子进程自带独立 DLL 表,物理隔离。
7
+ *
8
+ * 推理队列语义与 embedding-worker.cjs 完全一致(单线程串行、priority 插队、
9
+ * 条间 setImmediate 让路),只有 IPC 传输层不同:
10
+ *
11
+ * 协议(与 src/store/local-embedding.ts 的 RealChildChannel 配对;自增 id):
12
+ * - 启动配置:env DSH_EMBED_CFG(JSON 字符串:{runtimeDir,modelDir,pooling,dtype,
13
+ * maxInputChars}——fork 的 env 只收标量,嵌套对象经序列化传递);
14
+ * - 入向:{id,type:'ping'} | {id,type:'warmup'} | {id,type:'embed',texts,priority}
15
+ * - 出向:{id,ok:true,type:'pong'|'ready'|'embedded',vectors?}(vectors 为
16
+ * number[][],fork IPC 默认 JSON 序列化不支持 TypedArray,主线程侧重建
17
+ * Float32Array;512d×32 条 ≈64KB,序列化 <5ms,瓶颈在推理不在传输)
18
+ * | {id,ok:false,stage:'load'|'infer',error} | {type:'fatal',error}
19
+ *
20
+ * 信任边界(沿 AGENTS.md 审查记录):runtime/ 目录在数据目录信任边界内,
21
+ * 本进程按其 package.json createRequire 加载 transformers——对数据目录有写
22
+ * 权限的进程理论上可植入恶意模块;模型文件逐文件 sha256 锁定,运行时代码
23
+ * 无同级防护,完整修复需安装指纹设计,暂以文档化假设为准。
24
+ */
25
+ 'use strict';
26
+
27
+ const path = require('node:path');
28
+ const { createRequire } = require('node:module');
29
+
30
+ // 必须由 fork 启动(需要 IPC channel);裸 node 执行时静默退出,不报 SyntaxError、不永挂。
31
+ if (typeof process.send !== 'function') {
32
+ console.error('[embedding-child] 未经 fork 启动,无 IPC 通道,退出');
33
+ process.exit(0);
34
+ }
35
+
36
+ /** 错误消息归一化(Error 取 message,其余 String 化)。 */
37
+ function errMsg(err) {
38
+ return err && err.message ? err.message : String(err);
39
+ }
40
+
41
+ const cfg = JSON.parse(process.env.DSH_EMBED_CFG || '{}');
42
+ const RUNTIME_DIR = typeof cfg.runtimeDir === 'string' ? cfg.runtimeDir : '';
43
+ const MODEL_DIR = typeof cfg.modelDir === 'string' ? cfg.modelDir : '';
44
+ const POOLING = cfg.pooling === 'cls' ? 'cls' : 'mean';
45
+ const DTYPE = typeof cfg.dtype === 'string' ? cfg.dtype : 'q8';
46
+ const MAX_INPUT_CHARS = cfg.maxInputChars > 0 ? cfg.maxInputChars : 5000;
47
+
48
+ let extractor = null; // pipeline('feature-extraction', …)
49
+ let loadState = 'idle'; // idle | loading | ready | failed
50
+ let loadPromise = null;
51
+ let loadError = null;
52
+
53
+ // ── 模型懒加载(首请求触发;失败后经 warmup 可重试,embed 请求失败快返回) ──
54
+ function ensureLoaded() {
55
+ if (loadState === 'ready') return Promise.resolve();
56
+ if (loadState === 'loading' && loadPromise) return loadPromise;
57
+ loadState = 'loading';
58
+ loadError = null;
59
+ loadPromise = (async () => {
60
+ try {
61
+ if (!RUNTIME_DIR || !MODEL_DIR) throw new Error('DSH_EMBED_CFG 缺少 runtimeDir/modelDir');
62
+ const req = createRequire(path.join(RUNTIME_DIR, 'package.json'));
63
+ const mod = req('@huggingface/transformers');
64
+ if (mod.env) {
65
+ mod.env.allowRemoteModels = false;
66
+ if (mod.env.allowLocalModels !== undefined) mod.env.allowLocalModels = true;
67
+ }
68
+ extractor = await mod.pipeline('feature-extraction', MODEL_DIR, { dtype: DTYPE });
69
+ loadState = 'ready';
70
+ } catch (err) {
71
+ loadState = 'failed';
72
+ loadError = errMsg(err);
73
+ loadPromise = null; // 失败后下一次 warmup 重新走加载
74
+ throw err;
75
+ }
76
+ })();
77
+ return loadPromise;
78
+ }
79
+
80
+ // ── 推理队列(逐条 job;priority 插队;条间让路) ──
81
+ const queue = []; // [{ request, index, text }]
82
+ let pumping = false;
83
+
84
+ function postReply(msg) {
85
+ // 通道已断(父进程退出/正在终止)时 send 抛错——静默即可,父进程侧有 exit 兜底
86
+ try {
87
+ process.send(msg);
88
+ } catch {
89
+ /* IPC 已关闭,回复无处可去 */
90
+ }
91
+ }
92
+
93
+ /** 请求全部条目完成 → 回执(Float32Array → number[] 序列化,IPC JSON 通道不支持 TypedArray)。 */
94
+ function settle(request) {
95
+ if (request.failed || request.remaining !== 0) return;
96
+ postReply({
97
+ id: request.id,
98
+ ok: true,
99
+ type: 'embedded',
100
+ vectors: request.vectors.map((v) => Array.from(v)),
101
+ });
102
+ }
103
+
104
+ /** 首个失败即回执错误,并移除该请求尚未开跑的条目(正在跑的至多 1 条,算完即弃)。 */
105
+ function failRequest(request, error, stage) {
106
+ if (request.failed) return;
107
+ request.failed = true;
108
+ for (let i = queue.length - 1; i >= 0; i--) {
109
+ if (queue[i].request === request) queue.splice(i, 1);
110
+ }
111
+ postReply({ id: request.id, ok: false, stage: stage, error: errMsg(error) });
112
+ }
113
+
114
+ async function pump() {
115
+ if (pumping) return;
116
+ pumping = true;
117
+ try {
118
+ while (queue.length > 0) {
119
+ const job = queue.shift();
120
+ if (job.request.failed) continue;
121
+ try {
122
+ const result = await extractor([job.text.slice(0, MAX_INPUT_CHARS)], { pooling: POOLING, normalize: true });
123
+ const data = result[0] && result[0].data;
124
+ job.request.vectors[job.index] = data instanceof Float32Array ? data : new Float32Array(data);
125
+ job.request.remaining -= 1;
126
+ settle(job.request);
127
+ } catch (err) {
128
+ failRequest(job.request, err, 'infer');
129
+ }
130
+ // 条间让路:交还本进程事件循环,新消息(含 priority 插队)可在批间被处理
131
+ await new Promise((resolve) => setImmediate(resolve));
132
+ }
133
+ } finally {
134
+ pumping = false;
135
+ }
136
+ }
137
+
138
+ function handleEmbed(msg) {
139
+ const texts = Array.isArray(msg.texts) ? msg.texts : [];
140
+ if (texts.length === 0) {
141
+ postReply({ id: msg.id, ok: true, type: 'embedded', vectors: [] });
142
+ return;
143
+ }
144
+ const request = { id: msg.id, vectors: new Array(texts.length), remaining: texts.length, failed: false };
145
+ // 单条请求(召回 query)插队到队首;批次追加到队尾(批内条目间仍会被后来的插队)
146
+ const priority = msg.priority === true && texts.length === 1;
147
+ for (let i = 0; i < texts.length; i++) {
148
+ const job = { request: request, index: i, text: String(texts[i] == null ? '' : texts[i]) };
149
+ if (priority) queue.unshift(job);
150
+ else queue.push(job);
151
+ }
152
+ void pump();
153
+ }
154
+
155
+ process.on('message', (msg) => {
156
+ if (!msg || typeof msg.id !== 'number' || typeof msg.type !== 'string') return;
157
+ if (msg.type === 'ping') {
158
+ postReply({ id: msg.id, ok: true, type: 'pong' });
159
+ return;
160
+ }
161
+ if (msg.type === 'warmup') {
162
+ ensureLoaded().then(
163
+ () => postReply({ id: msg.id, ok: true, type: 'ready' }),
164
+ () => postReply({ id: msg.id, ok: false, stage: 'load', error: loadError ?? '模型加载失败' }),
165
+ );
166
+ return;
167
+ }
168
+ if (msg.type === 'embed') {
169
+ void (async () => {
170
+ try {
171
+ await ensureLoaded();
172
+ } catch {
173
+ postReply({ id: msg.id, ok: false, stage: 'load', error: loadError ?? '模型加载失败' });
174
+ return;
175
+ }
176
+ handleEmbed(msg);
177
+ })();
178
+ return;
179
+ }
180
+ });
181
+
182
+ // 父进程disconnect(通道被主动关闭,如宿主优雅退出/RealChildChannel 前置断开):
183
+ // 模型与 onnxruntime 都只服务于这条通道,留着就是孤儿常驻——直接退出。
184
+ // taskkill /T /F 兜底覆盖"父进程被硬杀、无 disconnect"的路径。
185
+ process.on('disconnect', () => process.exit(0));
186
+
187
+ // 未捕获异常:尽力通知父进程后强制退出(父进程的 exit 处理会拒绝全部 pending
188
+ // 并转入 failed 态)。不保持半死状态——半死的子进程只会让调用方挂到超时。
189
+ process.on('uncaughtException', (err) => {
190
+ postReply({ type: 'fatal', error: errMsg(err) });
191
+ setTimeout(() => process.exit(1), 50);
192
+ });
@@ -1,3 +1,5 @@
1
+ // DEPRECATED: use embedding-child.cjs (subprocess isolation, fix #11)
2
+ // 保留一版兼容(opts.useWorker 逃生门),下版删除。不再主动修改。
1
3
  /**
2
4
  * 本地嵌入 worker:transformers.js 的模型加载与推理在本线程执行,主线程只
3
5
  * 投递文本、收回向量。onnxruntime-node 的 run/loadModel 是 setImmediate 回调里
@@ -31,7 +31,16 @@ export declare function normalizeEntityName(name: string): string;
31
31
  * validTo → validFrom(时间增强列,新写入路径)→ metadata.activity_end_time →
32
32
  * metadata.activity_start_time(列迁移前的存量数据)→ timestamps 最新 → createdAt。
33
33
  * 全部无证据才用 fallback(now),绝不猜测。
34
+ *
35
+ * task_2.6(expiry-tiering):兜底 now 分支回传 `'fallback_now'` 证据等级——供图谱侧
36
+ * 同口径读时派生使用(落到 now 的 validFrom **不是**时间证据,与 activity_* 链的
37
+ * 'evidence' 必须可区分;否则假过期与真过期同形)。
34
38
  */
39
+ export declare function anchorTimeFromRecordsDetailed(records: readonly MemoryRecord[], fallbackIso: string): {
40
+ iso: string;
41
+ source: 'evidence' | 'fallback_now';
42
+ };
43
+ /** {@link anchorTimeFromRecordsDetailed} 的旧签名封装(调用方只要时刻时的零改动通道)。 */
35
44
  export declare function anchorTimeFromRecords(records: readonly MemoryRecord[], fallbackIso: string): string;
36
45
  /** 应用一次投影提案(硬校验 + 消歧 + supersede + 状态重建)。 */
37
46
  export declare function applyGraphProjection(options: ApplyGraphProjectionOptions): ApplyGraphProjectionOutcome;
@@ -66,8 +66,12 @@ function recordFamily(r) {
66
66
  * validTo → validFrom(时间增强列,新写入路径)→ metadata.activity_end_time →
67
67
  * metadata.activity_start_time(列迁移前的存量数据)→ timestamps 最新 → createdAt。
68
68
  * 全部无证据才用 fallback(now),绝不猜测。
69
+ *
70
+ * task_2.6(expiry-tiering):兜底 now 分支回传 `'fallback_now'` 证据等级——供图谱侧
71
+ * 同口径读时派生使用(落到 now 的 validFrom **不是**时间证据,与 activity_* 链的
72
+ * 'evidence' 必须可区分;否则假过期与真过期同形)。
69
73
  */
70
- export function anchorTimeFromRecords(records, fallbackIso) {
74
+ export function anchorTimeFromRecordsDetailed(records, fallbackIso) {
71
75
  let latest = Number.NaN;
72
76
  const consider = (raw) => {
73
77
  const t = typeof raw === 'number' ? raw : typeof raw === 'string' ? Date.parse(raw) : Number.NaN;
@@ -85,7 +89,13 @@ export function anchorTimeFromRecords(records, fallbackIso) {
85
89
  consider(t);
86
90
  consider(r.createdAt);
87
91
  }
88
- return Number.isNaN(latest) ? fallbackIso : new Date(latest).toISOString();
92
+ return Number.isNaN(latest)
93
+ ? { iso: fallbackIso, source: 'fallback_now' }
94
+ : { iso: new Date(latest).toISOString(), source: 'evidence' };
95
+ }
96
+ /** {@link anchorTimeFromRecordsDetailed} 的旧签名封装(调用方只要时刻时的零改动通道)。 */
97
+ export function anchorTimeFromRecords(records, fallbackIso) {
98
+ return anchorTimeFromRecordsDetailed(records, fallbackIso).iso;
89
99
  }
90
100
  /** 来源集并集封顶(保最新:新引用追加在尾部,溢出丢头部最老引用)。 */
91
101
  function unionSourcesCapped(old, add) {
@@ -9,6 +9,15 @@ export declare const GRAPH_FIELD_WEIGHTS: {
9
9
  readonly relations: 3;
10
10
  readonly type: 2;
11
11
  };
12
+ /**
13
+ * as-of 回放可选参数(expiry-tiering Phase 5:图谱读时派生,不加冻结列)。
14
+ * 默认(缺席)= 当前态检索,行为与改前逐字一致。
15
+ */
16
+ export interface GraphAsOfOptions {
17
+ /** ISO 8601 时刻:把"当时成立"的 superseded fact/边也纳入检索与回链;
18
+ * archived 仍排除(那是删除传播的墓碑,来源已不在,回放也不该复活)。 */
19
+ asOf?: string;
20
+ }
12
21
  /**
13
22
  * 图谱命中 → L1 记录 id 序列(§D 第 3 路 RRF 的输入;纯函数,无 I/O)。
14
23
  *
@@ -35,7 +44,7 @@ export declare const GRAPH_FIELD_WEIGHTS: {
35
44
  * 见文件头演进阶梯)。族过滤不在本函数:邻居节点可跨族,兜底在 graphLane 尾部的
36
45
  * 记录级 family/scope 双闸(宁可漏不可串落在记录上)。
37
46
  */
38
- export declare function expandHitRecordIds(hits: readonly GraphNodeSearchResult[], nodes: readonly GraphNode[], edges: readonly GraphEdge[]): string[];
47
+ export declare function expandHitRecordIds(hits: readonly GraphNodeSearchResult[], nodes: readonly GraphNode[], edges: readonly GraphEdge[], opts?: GraphAsOfOptions): string[];
39
48
  /**
40
49
  * 直连段回链(无 hop)——保留 task_9 的原语义形状(既有测试继续钉住);
41
50
  * 生产链路一律走 expandHitRecordIds(向量种子并集 + 1-hop)。
@@ -45,4 +54,4 @@ export declare function graphHitRecordIds(hits: readonly GraphNodeSearchResult[]
45
54
  * 图谱节点检索:返回按 score 降序的命中(最多 limit 条;limit 钳制 1~20)。
46
55
  * 候选节点 = active | disputed(superseded/archived 不进检索)。
47
56
  */
48
- export declare function searchGraphNodes(nodes: readonly GraphNode[], edges: readonly GraphEdge[], query: string, limit: number): GraphNodeSearchResult[];
57
+ export declare function searchGraphNodes(nodes: readonly GraphNode[], edges: readonly GraphEdge[], query: string, limit: number, opts?: GraphAsOfOptions): GraphNodeSearchResult[];
@@ -60,6 +60,22 @@ const FIELD_ORDER = ['name', 'aliases', 'tags', 'currentState', 'facts', 'relati
60
60
  function factValueText(value) {
61
61
  return Array.isArray(value) ? value.join(' ') : value;
62
62
  }
63
+ /** ISO 字符串区间覆盖判定(字典序=时间序;空串视作未闭合)。 */
64
+ function coversInterval(from, to, asOf) {
65
+ if (from && from > asOf)
66
+ return false;
67
+ if (to && asOf >= to)
68
+ return false;
69
+ return true;
70
+ }
71
+ /** fact 在回放时刻是否成立(active 恒成立;superseded 看区间)。archived/disputed 不含。 */
72
+ function factValidAtAsOf(f, asOf) {
73
+ if (f.status === 'active')
74
+ return true;
75
+ if (f.status === 'superseded')
76
+ return coversInterval(f.validFrom, f.validTo, asOf);
77
+ return false;
78
+ }
63
79
  /** 节点新度序(updatedAt 新→旧,同值按 id 升序——与检索结果排序同式)。 */
64
80
  function byNodeRecency(a, b) {
65
81
  return a.updatedAt < b.updatedAt ? 1 : a.updatedAt > b.updatedAt ? -1 : a.id < b.id ? -1 : 1;
@@ -90,7 +106,8 @@ function byNodeRecency(a, b) {
90
106
  * 见文件头演进阶梯)。族过滤不在本函数:邻居节点可跨族,兜底在 graphLane 尾部的
91
107
  * 记录级 family/scope 双闸(宁可漏不可串落在记录上)。
92
108
  */
93
- export function expandHitRecordIds(hits, nodes, edges) {
109
+ export function expandHitRecordIds(hits, nodes, edges, opts) {
110
+ const asOf = typeof opts?.asOf === 'string' && opts.asOf.length > 0 ? opts.asOf : undefined;
94
111
  const seen = new Set();
95
112
  const out = [];
96
113
  const take = (id) => {
@@ -99,12 +116,13 @@ export function expandHitRecordIds(hits, nodes, edges) {
99
116
  seen.add(id);
100
117
  out.push(id);
101
118
  };
102
- // 直连段(节点身份来源在前,active facts 在后)
119
+ // 直连段(节点身份来源在前,有效 facts 在后;task_5.2:asOf 下额外纳入当时成立的 superseded fact)
103
120
  for (const h of hits) {
104
121
  for (const id of h.node.sourceRecordIds)
105
122
  take(id);
106
123
  for (const f of h.node.facts) {
107
- if (f.status !== 'active')
124
+ const ok = asOf === undefined ? f.status === 'active' : factValidAtAsOf(f, asOf);
125
+ if (!ok)
108
126
  continue;
109
127
  for (const id of f.sourceRecordIds ?? [])
110
128
  take(id);
@@ -112,13 +130,17 @@ export function expandHitRecordIds(hits, nodes, edges) {
112
130
  }
113
131
  if (edges.length === 0)
114
132
  return out;
115
- // hop 段:active 边的双向邻居 ∉ 命中集,邻居去重,状态需 active|disputed
133
+ // hop 段:可用边的双向邻居 ∉ 命中集,邻居去重,状态需 active|disputed
134
+ // (task_5.2:asOf 下"当时成立"的 superseded 边也可回链,邻居同理含当时成立的 superseded 节点)
116
135
  const hitNodeIds = new Set(hits.map((h) => h.node.id));
117
136
  const nodeById = new Map(nodes.map((n) => [n.id, n]));
137
+ const edgeUsable = (e) => e.status === 'active' || (asOf !== undefined && e.status === 'superseded' && coversInterval(e.validFrom, e.validTo, asOf));
138
+ const neighborUsable = (n) => n.status === 'active' || n.status === 'disputed'
139
+ || (asOf !== undefined && n.status === 'superseded' && coversInterval(n.createdAt, n.updatedAt, asOf));
118
140
  const seenNeighbor = new Set();
119
141
  const neighbors = [];
120
142
  for (const e of edges) {
121
- if (e.status !== 'active')
143
+ if (!edgeUsable(e))
122
144
  continue;
123
145
  const dirs = [
124
146
  [e.fromNodeId, e.toNodeId],
@@ -128,7 +150,7 @@ export function expandHitRecordIds(hits, nodes, edges) {
128
150
  if (!hitNodeIds.has(self) || hitNodeIds.has(other) || seenNeighbor.has(other))
129
151
  continue;
130
152
  const n = nodeById.get(other);
131
- if (!n || (n.status !== 'active' && n.status !== 'disputed'))
153
+ if (!n || !neighborUsable(n))
132
154
  continue;
133
155
  seenNeighbor.add(other);
134
156
  neighbors.push(n);
@@ -161,15 +183,18 @@ export function graphHitRecordIds(hits) {
161
183
  * 图谱节点检索:返回按 score 降序的命中(最多 limit 条;limit 钳制 1~20)。
162
184
  * 候选节点 = active | disputed(superseded/archived 不进检索)。
163
185
  */
164
- export function searchGraphNodes(nodes, edges, query, limit) {
186
+ export function searchGraphNodes(nodes, edges, query, limit, opts) {
187
+ const asOf = typeof opts?.asOf === 'string' && opts.asOf.length > 0 ? opts.asOf : undefined;
165
188
  const tokens = [...new Set(tokenize(query))];
166
189
  const cappedLimit = Math.min(Math.max(Math.floor(limit) || 5, 1), GRAPH_SEARCH_LIMIT_MAX);
167
190
  if (tokens.length === 0)
168
191
  return [];
169
- // nodeId → active 关系词 token 集(先建邻接表,防逐节点全量扫边)
192
+ // nodeId → 可用关系词 token 集(先建邻接表,防逐节点全量扫边;
193
+ // task_5.1:asOf 下纳入"当时成立"的 superseded 边)
170
194
  const relationTokensByNode = new Map();
171
195
  for (const edge of edges) {
172
- if (edge.status !== 'active')
196
+ if (edge.status !== 'active'
197
+ && !(asOf !== undefined && edge.status === 'superseded' && coversInterval(edge.validFrom, edge.validTo, asOf)))
173
198
  continue;
174
199
  let set = relationTokensByNode.get(edge.fromNodeId);
175
200
  if (!set) {
@@ -188,6 +213,9 @@ export function searchGraphNodes(nodes, edges, query, limit) {
188
213
  }
189
214
  const results = [];
190
215
  for (const node of nodes) {
216
+ // 候选状态闸保持 active|disputed:task_5.1 的放宽面在 **fact/边**("纳入 superseded 且
217
+ // 区间覆盖 T 的 fact/edge")——GraphNode 没有 valid 区间列,节点主体复活无判据;
218
+ // 历史实体经"当时成立边"的邻居回链(task_5.2)重新可达,不需要翻状态。
191
219
  if (node.status !== 'active' && node.status !== 'disputed')
192
220
  continue;
193
221
  const fieldTokens = {
@@ -196,7 +224,7 @@ export function searchGraphNodes(nodes, edges, query, limit) {
196
224
  tags: new Set((node.tags ?? []).flatMap((t) => tokenize(t))),
197
225
  currentState: new Set(tokenize(node.currentState)),
198
226
  facts: new Set(node.facts
199
- .filter((f) => f.status === 'active')
227
+ .filter((f) => (asOf === undefined ? f.status === 'active' : factValidAtAsOf(f, asOf)))
200
228
  .flatMap((f) => tokenize(`${f.key} ${factValueText(f.value)}`))),
201
229
  relations: relationTokensByNode.get(node.id) ?? new Set(),
202
230
  type: new Set(tokenize(node.type)),