@modusensus/dsh-mneme 0.12.0 → 0.14.0-beta.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.
Files changed (182) hide show
  1. package/README.md +51 -20
  2. package/bin/dsh-mneme-choro.mjs +9 -1
  3. package/docs/CONFIGURATION.md +47 -6
  4. package/docs/DIRECTORIES.md +114 -0
  5. package/docs/DISTILL.md +64 -0
  6. package/docs/DSH-GO.md +97 -0
  7. package/docs/ENTITIES.md +5 -3
  8. package/docs/FORGET.md +77 -0
  9. package/docs/KB.md +110 -0
  10. package/docs/SLEEP.md +28 -3
  11. package/docs/STORAGE.md +23 -0
  12. package/lib/api-standalone.js +4 -1
  13. package/lib/api.js +788 -30
  14. package/lib/attachment-text.js +122 -0
  15. package/lib/choro-command.js +9 -2
  16. package/lib/client.js +4627 -1409
  17. package/lib/config.js +166 -1
  18. package/lib/continuity.js +7 -6
  19. package/lib/diary/auto.js +190 -0
  20. package/lib/diary/compose.js +440 -0
  21. package/lib/diary/dir.js +59 -0
  22. package/lib/diary/export.js +97 -0
  23. package/lib/diary/index.js +26 -0
  24. package/lib/diary/library.js +189 -0
  25. package/lib/diary/qq-inbox.js +142 -0
  26. package/lib/diary/rollover.js +61 -0
  27. package/lib/diary/session-provider.js +122 -0
  28. package/lib/dream/decisions.js +95 -6
  29. package/lib/dream/sleep.js +760 -19
  30. package/lib/dream/triage.js +139 -0
  31. package/lib/dream.js +168 -21
  32. package/lib/dsh-go/host.js +162 -0
  33. package/lib/dsh-go/panel.js +2091 -0
  34. package/lib/dsh-go/text.js +342 -0
  35. package/lib/dsh-go/views/common.js +68 -0
  36. package/lib/dsh-go/views/entity.js +48 -0
  37. package/lib/dsh-go/views/memory.js +198 -0
  38. package/lib/dsh-go/views/root.js +30 -0
  39. package/lib/dsh-go/views/settings.js +398 -0
  40. package/lib/dsh-go/views/status.js +51 -0
  41. package/lib/embedding.js +42 -8
  42. package/lib/entities/extractor.js +36 -7
  43. package/lib/hot-memory.js +22 -11
  44. package/lib/index.js +123 -3
  45. package/lib/inject.js +203 -20
  46. package/lib/kb/index.js +309 -0
  47. package/lib/kb/routes.js +220 -0
  48. package/lib/kb/tools.js +148 -0
  49. package/lib/lang.js +98 -4
  50. package/lib/peak-hours.js +57 -2
  51. package/lib/semantic.js +347 -60
  52. package/lib/service.js +268 -69
  53. package/lib/settings.js +128 -15
  54. package/lib/store.js +585 -26
  55. package/lib/summarize.js +38 -11
  56. package/lib/tools.js +35 -4
  57. package/lib/user-settings.js +128 -32
  58. package/lib/vector-index.js +76 -6
  59. package/lib/wiki/claims.js +267 -0
  60. package/lib/wiki/cli-hint.js +90 -0
  61. package/lib/wiki/queries.js +282 -2
  62. package/lib/wiki/render.js +74 -0
  63. package/lib/wiki/t0.js +43 -6
  64. package/lib/wiki/t1-jobs.js +232 -0
  65. package/lib/wiki/t1.js +145 -32
  66. package/package.json +39 -1
  67. package/scripts/release-docs.mjs +7 -1
  68. package/scripts/release-notes.mjs +4 -1
  69. package/src/api-standalone.js +4 -1
  70. package/src/api.js +788 -30
  71. package/src/attachment-text.js +122 -0
  72. package/src/choro-command.js +9 -2
  73. package/src/config.js +166 -1
  74. package/src/continuity.js +7 -6
  75. package/src/diary/auto.js +190 -0
  76. package/src/diary/compose.js +440 -0
  77. package/src/diary/dir.js +59 -0
  78. package/src/diary/export.js +97 -0
  79. package/src/diary/index.js +26 -0
  80. package/src/diary/library.js +189 -0
  81. package/src/diary/qq-inbox.js +142 -0
  82. package/src/diary/rollover.js +61 -0
  83. package/src/diary/session-provider.js +122 -0
  84. package/src/dream/decisions.js +95 -6
  85. package/src/dream/sleep.js +760 -19
  86. package/src/dream/triage.js +139 -0
  87. package/src/dream.js +168 -21
  88. package/src/dsh-go/host.js +162 -0
  89. package/src/dsh-go/panel.js +2091 -0
  90. package/src/dsh-go/text.js +342 -0
  91. package/src/dsh-go/views/common.js +68 -0
  92. package/src/dsh-go/views/entity.js +48 -0
  93. package/src/dsh-go/views/memory.js +198 -0
  94. package/src/dsh-go/views/root.js +30 -0
  95. package/src/dsh-go/views/settings.js +398 -0
  96. package/src/dsh-go/views/status.js +51 -0
  97. package/src/embedding.js +42 -8
  98. package/src/entities/extractor.js +36 -7
  99. package/src/hot-memory.js +22 -11
  100. package/src/index.js +123 -3
  101. package/src/inject.js +203 -20
  102. package/src/kb/index.js +309 -0
  103. package/src/kb/routes.js +220 -0
  104. package/src/kb/tools.js +148 -0
  105. package/src/lang.js +98 -4
  106. package/src/peak-hours.js +57 -2
  107. package/src/semantic.js +347 -60
  108. package/src/service.js +268 -69
  109. package/src/settings.js +128 -15
  110. package/src/store.js +585 -26
  111. package/src/summarize.js +38 -11
  112. package/src/tools.js +35 -4
  113. package/src/user-settings.js +128 -32
  114. package/src/vector-index.js +76 -6
  115. package/src/wiki/claims.js +267 -0
  116. package/src/wiki/cli-hint.js +90 -0
  117. package/src/wiki/queries.js +282 -2
  118. package/src/wiki/render.js +74 -0
  119. package/src/wiki/t0.js +43 -6
  120. package/src/wiki/t1-jobs.js +232 -0
  121. package/src/wiki/t1.js +145 -32
  122. package/test/api-routes-gaps.test.js +63 -0
  123. package/test/api.test.js +385 -4
  124. package/test/attachment-text.test.js +140 -0
  125. package/test/bm25-corpus-cache.test.js +145 -0
  126. package/test/capacity-warning.test.js +262 -0
  127. package/test/client.test.js +1051 -185
  128. package/test/conflict-freeze.test.js +45 -1
  129. package/test/conflict-ledger-enforce.test.js +452 -0
  130. package/test/conflict-ledger.test.js +242 -0
  131. package/test/continuity.test.js +8 -1
  132. package/test/contrast.test.js +124 -12
  133. package/test/diary-library.test.js +187 -0
  134. package/test/diary.test.js +576 -0
  135. package/test/dream-baseline-clamp.test.js +174 -0
  136. package/test/dream-maxtokens-fallback.test.js +3 -3
  137. package/test/dream.test.js +62 -1
  138. package/test/dsh-go-host.test.js +134 -0
  139. package/test/dsh-go-panel-actions.test.js +854 -0
  140. package/test/dsh-go-panel.test.js +251 -0
  141. package/test/embedding-ready.test.js +33 -3
  142. package/test/entities.test.js +73 -0
  143. package/test/forget-corrections.test.js +70 -0
  144. package/test/graph-api.test.js +124 -0
  145. package/test/hot-memory.test.js +48 -0
  146. package/test/image-memory.test.js +171 -0
  147. package/test/inject-parent-gate.test.js +6 -3
  148. package/test/inject.test.js +59 -0
  149. package/test/issue-617-step1-query-and-fusion.test.js +361 -0
  150. package/test/kb-api-wiring.test.js +128 -0
  151. package/test/kb-collections.test.js +272 -0
  152. package/test/kb-routes.test.js +379 -0
  153. package/test/mint-source.test.js +26 -0
  154. package/test/panel-chart-scale.test.js +90 -0
  155. package/test/panel-tokens.test.js +245 -0
  156. package/test/peak-hours-observability.test.js +70 -0
  157. package/test/persona-api.test.js +3 -3
  158. package/test/persona-by-session.test.js +568 -0
  159. package/test/persona-settings.test.js +7 -7
  160. package/test/reflection-update-gate.test.js +129 -0
  161. package/test/reindex-backfill.test.js +334 -0
  162. package/test/reindex-scheduler.test.js +369 -0
  163. package/test/release-docs.test.js +20 -0
  164. package/test/release-notes.test.js +25 -0
  165. package/test/scope-candidates.test.js +249 -0
  166. package/test/security-doc.test.js +10 -0
  167. package/test/sleep-action-set.test.js +14 -0
  168. package/test/sleep-heat.test.js +49 -0
  169. package/test/sleep.test.js +635 -0
  170. package/test/standalone-api.test.js +24 -0
  171. package/test/store.test.js +20 -0
  172. package/test/summarize.test.js +26 -0
  173. package/test/user-settings-segments.test.js +528 -0
  174. package/test/vector-index.test.js +120 -6
  175. package/test/wiki-api.test.js +463 -1
  176. package/test/wiki-claims.test.js +194 -0
  177. package/test/wiki-cli-hint.test.js +118 -0
  178. package/test/wiki-provenance.test.js +202 -0
  179. package/test/wiki-queries.test.js +128 -0
  180. package/test/wiki-t0.test.js +77 -1
  181. package/test/wiki-t1-jobs.test.js +125 -0
  182. package/test/wiki-t1.test.js +226 -1
package/lib/service.js CHANGED
@@ -7,10 +7,11 @@ import { STR, langOf } from "./lang.js";
7
7
  import { computeHeat } from "./heat.js";
8
8
  import { recallStats } from "./recall-stats.js";
9
9
  import { evaluateMemoryQuality } from "./quality-filter.js";
10
+ import { CAPACITY_WARNING_DEFAULTS } from "./config.js";
10
11
  // 外来行(source='imported',导入路径落的来源)的身份判定只许有一个定义处——本文件的
11
12
  // 两处汇流口与巩固/降级那些读侧行池都调这同一个谓词,避免各处自己写字符串比较然后各自漂移。
12
13
  import { isImportedRow } from "./memory-source.js";
13
- import { applyDecisions } from "./dream/decisions.js";
14
+ import { applyDecisions, EPISTEMIC_PRIORITY } from "./dream/decisions.js";
14
15
  import { createDocumentRegistrar } from "./document.js";
15
16
  import { createOrganizer } from "./organize.js";
16
17
  import { createShareImport } from "./share-import.js";
@@ -532,6 +533,25 @@ export function createService({ store, mirror, config, onWrite, logger, document
532
533
  return dot / (Math.sqrt(na) * Math.sqrt(nb));
533
534
  }
534
535
 
536
+ // BM25 语料索引缓存(#617 审计 O2)。此前每次渲染都 store.list 500 行 + 重新
537
+ // tokenize 全语料,实测 4.318ms/次、每个 step 一次;其中 store.list 占 4.318ms
538
+ // 里的绝大部分,而语料在两次写入之间根本没变。
539
+ //
540
+ // 失效键用 mirror_state.generation,不另建计数器:改正文/可见性的写入(insert /
541
+ // update / remove / setArchived / setForgotten / setContent …)都已经 bump 它,
542
+ // 而 last_accessed_at 与 embedding 这两类写入**刻意不 bump**(store.js 里写明
543
+ // reads must not mark the mirror dirty)——这两类恰好也不改 title+content 语料,
544
+ // 正是我们要的失效粒度,不用额外判断。
545
+ //
546
+ // 水位每次从库里现读,不在进程内记本地计数:多宿主共用一个 memoryDir 是支持形态
547
+ // (busy_timeout + WAL),另一个进程写完 bump 的是库里的 generation,本地计数
548
+ // 永远看不见,只有现读才收得到。
549
+ //
550
+ // 只缓存索引、不缓存查询结果:search() 返回的是 {...row, score} 副本,索引里
551
+ // 留着的原始行不会被调用方改到;缓存结果则会跨 step 串味(同一 query 在不同
552
+ // 池子状态下该给不同答案)。
553
+ let bm25IndexCache = null; // { generation, index } | null
554
+
535
555
  /**
536
556
  * BM25 third recall path (v0.5.0 1.1). Scores the query tokens against the
537
557
  * live non-archived rows and returns the top `limit` hits with scores
@@ -541,14 +561,40 @@ export function createService({ store, mirror, config, onWrite, logger, document
541
561
  function bm25Recall(q, limit) {
542
562
  if (config?.bm25SearchEnabled === false) return [];
543
563
  try {
544
- const docs = store.list({ limit: 500, includeForgotten: false }).filter((m) => !m.archived);
545
- if (!docs.length) return [];
546
- return createBM25Index(docs).search(q, { limit });
564
+ const index = bm25CorpusIndex();
565
+ if (!index) return [];
566
+ return index.search(q, { limit });
547
567
  } catch {
548
568
  return [];
549
569
  }
550
570
  }
551
571
 
572
+ /**
573
+ * 取(必要时重建)BM25 语料索引。水位读不到时退化成每次重建而不是报错:这条
574
+ * 通路是 recall booster,读不到 mirror_state 只该慢一点,不该让词法召回整批消失。
575
+ */
576
+ function bm25CorpusIndex() {
577
+ let generation = null;
578
+ try {
579
+ generation = store.getMirrorState().generation;
580
+ } catch { /* 水位不可读:本次退回重建,且不写缓存 */ }
581
+ if (bm25IndexCache && generation !== null && bm25IndexCache.generation === generation) {
582
+ return bm25IndexCache.index;
583
+ }
584
+ const docs = store.list({ limit: 500, includeForgotten: false }).filter((m) => !m.archived);
585
+ if (!docs.length) {
586
+ bm25IndexCache = null;
587
+ return null;
588
+ }
589
+ const index = createBM25Index(docs);
590
+ // 水位读不到时**不碰缓存**,保留上一份「已知有效 generation」的条目:读失败
591
+ // 若只是偶发(库锁、迁移窗口)而期间没有写入,恢复后水位与条目一致,直接命中;
592
+ // 期间真有写入,水位已经变了,下一轮自然重建。反过来把 null 当键存进去则是纯
593
+ // 噪声——那样的条目永远命不中,还把有效的那份挤掉了。
594
+ if (generation !== null) bm25IndexCache = { generation, index };
595
+ return index;
596
+ }
597
+
552
598
  /**
553
599
  * Graph fourth recall path (issue #219 → #24 块1 锚定层). When the query text
554
600
  * mentions a known entity name, memories linked to that entity (attrs +
@@ -789,7 +835,7 @@ export function createService({ store, mirror, config, onWrite, logger, document
789
835
  * Returns { merged, signals }, where signals is Map<id, {keyword, vector,
790
836
  * bm25}> so searchMemories can decorate rows when signalTransparency is on.
791
837
  */
792
- function fuseRecall({ keyword, vector, bm25, entity, lim, mode, wv, wk, wb, we }) {
838
+ function fuseRecall({ keyword, vector, bm25, entity, lim, mode, wv, wk, wb, we, supersededIds = null }) {
793
839
  const recipe = config?.recallFusion ?? "blend";
794
840
 
795
841
  // Per-source scores are recorded for every recipe so signalTransparency
@@ -922,17 +968,28 @@ export function createService({ store, mirror, config, onWrite, logger, document
922
968
  }
923
969
  }
924
970
 
925
- // Mode contract, auto (aligns rrf/minmax with blend): keyword leads, the
926
- // recipe fills the remaining slots. blend.auto already front-loads keyword;
927
- // rrf/minmax rank across sources, so re-apply the same "keyword first"
928
- // ordering here to preserve the pre-fusion auto contract — the keyword
929
- // hit list keeps its power, and only slots it couldn't fill go to the
930
- // recipe's ranking.
931
- if (recipe !== "blend" && mode === "auto" && keyword.length) {
932
- const head = keyword.slice(0, lim);
933
- const seen = new Set(head.map((m) => m.id));
934
- const tail = merged.filter((m) => !seen.has(m.id)).slice(0, Math.max(0, lim - head.length));
935
- merged = head.concat(tail);
971
+ // #617 第 3 条:这里原本还有一段「recipe !== "blend" 且 mode === "auto" 时用
972
+ // keyword.slice(0,lim) 覆盖 merged」的收尾,注释自称是「保留 blend 的 auto
973
+ // 契约」。但 rrf/minmax 的全部意义就是跨源排序——把它们算出来的序整个换成
974
+ // 「关键词命中先上」,等于配置了 rrf 却在 auto 下拿到 keyword 的顺序,配方
975
+ // 白选。auto 的「关键词领位」语义只属于 blend(它在 :906-922 里自己实现了,
976
+ // 且带 seen 去重与容量控制),不该由收尾段替别的配方代劳。删掉后 rrf/minmax
977
+ // 的 auto 排序就是它们各自算出来的排序。
978
+
979
+ // #641(T3 取代链的检索侧,beta 第三阶段):已被取代的行整体降权后重排——
980
+ // 它仍是「曾经为真」的可追溯历史,显式搜索照样能找到,但不该与取代它的
981
+ // 那行竞争注入名次。乘 0.5 的语义是「同卷必排继任者之后」,不是隐藏;
982
+ // 取代链只会在 enforce 档下落笔,链没落过时本段与从前逐字节一致。
983
+ // keyword 短路在前面已 return——那是人的显式检索动作,保留完整可见性与
984
+ // 原始排序。已知边界:各分支已按 lim 截断,这里只重排不再回填——极端
985
+ // 情况下一个刚挤进末位的被取代行会占住名额,但它被压到继任者之后,按
986
+ // 名次消费的注入实际效果等价;把降权挪进各分支截断前是显式重构,等
987
+ // 召回基准跑过一轮再决定值不值。行形状随来源不一(向量源可能是瘦行),
988
+ // 所以按 id 集合判定而不是读行上的 superseded_by。
989
+ if (supersededIds?.size) {
990
+ merged = merged
991
+ .map((m) => (supersededIds.has(m.id) ? { ...m, score: (m.score ?? 0) * 0.5 } : m))
992
+ .sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
936
993
  }
937
994
 
938
995
  return { merged, signals };
@@ -1049,7 +1106,9 @@ export function createService({ store, mirror, config, onWrite, logger, document
1049
1106
  const wv = config?.hybridSearchVectorWeight ?? DEFAULT_HYBRID_WEIGHTS.vector;
1050
1107
  const wk = config?.hybridSearchKeywordWeight ?? DEFAULT_HYBRID_WEIGHTS.keyword;
1051
1108
 
1052
- const { merged: fusedMerged, signals } = fuseRecall({ keyword, vector, bm25, entity, lim, mode, wv, wk, wb, we: wb });
1109
+ // T3 降权的判定集:一次部分索引查询拿全部已取代行 id(通常为空或个位数)。
1110
+ const supersededIds = new Set(store.supersededIds());
1111
+ const { merged: fusedMerged, signals } = fuseRecall({ keyword, vector, bm25, entity, lim, mode, wv, wk, wb, we: wb, supersededIds });
1053
1112
  let merged = fusedMerged;
1054
1113
  // v0.8.0 A3(issue #17):strictScope 硬过滤——他 scope 的候选直接出局
1055
1114
  // (区别于 A2 的降权保留可见);未标注行与命中行保留。strict 与 A2 加权
@@ -1778,62 +1837,60 @@ export function createService({ store, mirror, config, onWrite, logger, document
1778
1837
  (effImportance(a) * qualityWeight(a) * heatOf(a) * scopeMultOf(a));
1779
1838
  });
1780
1839
  let candidates = items;
1840
+ // #617 第 2 条(前半):向量路与 BM25 路此前是**互斥**的——`if (!semanticItems.length)`
1841
+ // 包住 BM25,于是「有向量」等于「词法命中整批消失」。可两条路测的不是同一件事:
1842
+ // 向量管「意思像」,BM25 管「词面撞上」,专有名词、issue 编号、函数名这类查询
1843
+ // 恰恰是向量最容易糊、词法最准的地方。现在两路都跑,用 RRF(k=60)融合排名:
1844
+ // 某一路的第 1 名恒得 1/(60+1),两路都靠前的行自然走高,不再由「谁先跑」决定。
1845
+ // 另外先按 fetchLimit 多取再过滤——旧代码先按 poolSize 截断再筛门控,筛掉的
1846
+ // 名额永远补不回来(缩水),池子越脏越明显。
1847
+ let fusedRrf = null; // id -> RRF 原始分;selectiveInject 复用它做二次重排
1781
1848
  if (config.hybridInject !== false && q) {
1782
- // Bug4: semantic-first recall. Vector hits (queryVector, cached by the
1783
- // injector's async prefetch) lead when present; otherwise the last
1784
- // searchMemories recall for the exact same query is reused. Rule-based
1785
- // items fill + dedupe the remaining slots. Empty query / no vector /
1786
- // no cached recall → pure legacy rule-based selection.
1787
- const semanticItems = [];
1849
+ const fetchLimit = Math.max(poolSize * 4, 40);
1850
+ const passesGate = (m) => Boolean(m) && !m.archived && injectTypes.has(m.type) && !m.forgotten &&
1851
+ (m.source !== "narrative" || allowNarrative) &&
1852
+ codingGate(m) &&
1853
+ (m.type === "summary" || m.type === "preference" || m.importance >= threshold);
1854
+ const vectorHits = [];
1788
1855
  if (Array.isArray(queryVector) && queryVector.length && vectorIndex) {
1789
1856
  try {
1790
- const hits = vectorIndex.search(queryVector, { limit: poolSize, threshold: 0 });
1791
- for (const m of hits) {
1792
- if (m && !m.archived && injectTypes.has(m.type) && !m.forgotten &&
1793
- (m.source !== "narrative" || allowNarrative) &&
1794
- codingGate(m) &&
1795
- (m.type === "summary" || m.type === "preference" || m.importance >= threshold)) {
1796
- semanticItems.push(m);
1797
- }
1857
+ for (const m of vectorIndex.search(queryVector, { limit: fetchLimit, threshold: 0 })) {
1858
+ if (passesGate(m)) vectorHits.push(m);
1798
1859
  }
1799
1860
  } catch { /* vector unavailable: fall through to the recall cache */ }
1800
1861
  }
1801
- if (!semanticItems.length && lastSemanticRecall?.query === q && lastSemanticRecall.items?.length) {
1802
- // CodeRabbit on #404:这条回填分支此前独漏重要度门槛——向量路(上方)与
1803
- // BM25 路(下方 :1655 注释「门控与向量路径一致(含 importance 阈值)」)
1862
+ if (!vectorHits.length && lastSemanticRecall?.query === q && lastSemanticRecall.items?.length) {
1863
+ // CodeRabbit on #404:这条回填分支此前独漏重要度门槛——向量路与 BM25 路
1804
1864
  // 都带着它。#369 收编 history 后缺口会被放大:库里低于闸门的行(实测
1805
1865
  // history 378 条中有 27 条 i<=2)走一次检索进缓存就能绕开门槛占注入位。
1806
- for (const m of lastSemanticRecall.items) {
1807
- if (m && !m.archived && injectTypes.has(m.type) && !m.forgotten &&
1808
- (m.source !== "narrative" || allowNarrative) && codingGate(m) &&
1809
- (m.type === "summary" || m.type === "preference" || m.importance >= threshold)) semanticItems.push(m);
1810
- }
1866
+ for (const m of lastSemanticRecall.items) if (passesGate(m)) vectorHits.push(m);
1811
1867
  }
1812
- // Issue #198:首轮(无向量、无缓存召回)的同步兜底——BM25 词法召回领位。
1813
- // 注入渲染是同步的(宿主 systemPrompt 的 contexts 不支持异步 text),
1814
- // 查询向量只能异步 prefetch 给下一轮,首轮必 miss;此时纯静态排序会让
1815
- // 老的高重要性 summary/preference 占满槽位,当前话题的 decision/project
1816
- // 进不来。查询文本本身就在手上,BM25 是纯进程内同步计算,词法相关性
1817
- // 足以把当前话题顶到前排;门控与向量路径一致(含 importance 阈值)。
1818
- // 语义向量命中时本分支不参与,行为不变。
1819
- if (!semanticItems.length) {
1820
- for (const hit of bm25Recall(q, poolSize)) {
1821
- if (hit && !hit.archived && injectTypes.has(hit.type) && !hit.forgotten &&
1822
- (hit.source !== "narrative" || allowNarrative) &&
1823
- codingGate(hit) &&
1824
- (hit.type === "summary" || hit.type === "preference" || hit.importance >= threshold)) {
1825
- semanticItems.push(hit);
1826
- }
1827
- }
1828
- // issue #339(CodeRabbit review on #350):首轮 BM25 兜底领跑合并池,
1829
- // 而 selectiveInject 相似度重排要等查询向量就绪才执行——软加权必须
1830
- // 在这里就位,否则 foreign 命中绕过降权直接占注入位。
1831
- if (softScopeActive) {
1832
- semanticItems.sort((a, b) =>
1833
- (b.score ?? 0) * scopeMultOf(b) - (a.score ?? 0) * scopeMultOf(a));
1834
- }
1835
- }
1836
- if (semanticItems.length) {
1868
+ // Issue #198:BM25 词法路。首轮(无向量、无缓存召回)这是唯一的同步相关性
1869
+ // 信号;有向量时它补上向量糊掉的那一类命中。#617 之后它不再被向量挤掉。
1870
+ const bm25Hits = [];
1871
+ for (const hit of bm25Recall(q, fetchLimit)) if (passesGate(hit)) bm25Hits.push(hit);
1872
+ if (vectorHits.length || bm25Hits.length) {
1873
+ const RRF_K = 60;
1874
+ const rrf = new Map();
1875
+ const rowById = new Map();
1876
+ const addList = (list) => {
1877
+ list.forEach((m, idx) => {
1878
+ rowById.set(m.id, m);
1879
+ rrf.set(m.id, (rrf.get(m.id) ?? 0) + 1 / (RRF_K + idx + 1));
1880
+ });
1881
+ };
1882
+ addList(vectorHits);
1883
+ addList(bm25Hits);
1884
+ fusedRrf = rrf;
1885
+ // 软加权(issue #339 / CodeRabbit on #350)作用在融合分上:foreign 行不再
1886
+ // 靠一路的原始 score 绕过降权,也不需要像旧代码那样单独给 BM25 排一次序。
1887
+ const ranked = [...rrf.entries()]
1888
+ .map(([id, score]) => {
1889
+ const m = rowById.get(id);
1890
+ return { m, score: score * scopeMultOf(m) };
1891
+ })
1892
+ .sort((a, b) => b.score - a.score)
1893
+ .map((row) => row.m);
1837
1894
  const seen = new Set();
1838
1895
  const merged = [];
1839
1896
  const push = (m) => {
@@ -1841,7 +1898,7 @@ export function createService({ store, mirror, config, onWrite, logger, document
1841
1898
  seen.add(m.id);
1842
1899
  merged.push(m);
1843
1900
  };
1844
- for (const m of semanticItems) {
1901
+ for (const m of ranked) {
1845
1902
  push(m);
1846
1903
  if (merged.length >= poolSize) break;
1847
1904
  }
@@ -1927,16 +1984,29 @@ export function createService({ store, mirror, config, onWrite, logger, document
1927
1984
  // vector, so the injected slots go to memories on the current topic
1928
1985
  // rather than to the rule-based order. Rows the index did not return
1929
1986
  // keep their relative order after the scored ones.
1987
+ //
1988
+ // #617 第 2 条(后半):这段此前是**纯余弦**全量 sort,于是上游用 RRF 顶上来的
1989
+ // BM25 命中(词面精确、余弦却低)会被重新压到 maxItems 之后,再被 general 槽
1990
+ // 的 `general.length >= maxItems` 丢掉——融合白做。现在余弦只占一半权重,另一半
1991
+ // 给 RRF 分(同一轮融合算出来的那份):纯余弦命中仍是主体,但词法精确命中
1992
+ // 不会因为「意思不够像」被挤出去。
1993
+ // RRF 归一化:单路第 1 名原始分 1/(60+1),乘 (60+1)/2 得 0.5,双路都在第 1 名
1994
+ // 得 1.0 —— 与余弦同量纲,且天然落在 [0,1]。
1930
1995
  if (config?.selectiveInjectEnabled !== false && Array.isArray(queryVector) && queryVector.length && vectorIndex) {
1931
1996
  try {
1932
1997
  const hits = vectorIndex.search(queryVector, { limit: 200, threshold: 0 });
1933
1998
  const sim = new Map(hits.map((m) => [m.id, m.score ?? 0]));
1934
1999
  if (sim.size) {
1935
- // 软加权作用在真实 sim 上;未命中的规则候选保持 -1 沉底(缺失项
1936
- // 乘乘数会让 foreign 缺失项反而排到自己的缺失项之上,故不乘)。
2000
+ const RRF_NORM = (60 + 1) / 2;
2001
+ // 软加权乘在融合分上(不是乘在某一路的原始分上);两路都没有的规则
2002
+ // 候选保持 -1 沉底(缺失项乘乘数会让 foreign 缺失项反而排到自己的缺失
2003
+ // 项之上,故不乘)。
1937
2004
  const scopedSim = (m) => {
1938
2005
  const s = sim.get(m.id);
1939
- return s === undefined ? -1 : s * scopeMultOf(m);
2006
+ const rrf = fusedRrf?.get(m.id);
2007
+ if (s === undefined && rrf === undefined) return -1;
2008
+ const fused = 0.5 * (s ?? 0) + 0.5 * (rrf ?? 0) * RRF_NORM;
2009
+ return fused * scopeMultOf(m);
1940
2010
  };
1941
2011
  candidates = [...candidates].sort((a, b) => scopedSim(b) - scopedSim(a));
1942
2012
  }
@@ -2045,7 +2115,49 @@ export function createService({ store, mirror, config, onWrite, logger, document
2045
2115
  }
2046
2116
  // #249 第一批:pin 前置到块内相关性排序之前(验收项),且不占 maxItems 名额。
2047
2117
  // pinned 为空时 selected 就是 general 本身——关闭态与改动前逐字节一致。
2048
- const selected = pinned.length > 0 ? [...pinned, ...general] : general;
2118
+ let selected = pinned.length > 0 ? [...pinned, ...general] : general;
2119
+ // Issue #641(T3/T4 注入侧,beta 第三阶段):取代链行不进注入——继任者携带
2120
+ // 现状,旧值注入只会浪费名额、冒着被采用的风险(supersedeChain 只由 enforce
2121
+ // 落笔,shadow/关闭态下集合恒空,本过滤与从前逐字节一致)。矛盾对(tier=
2122
+ // conflict 且仍 open)同批时按「可信度 → 新者」排位并互相标注:#537 的教训
2123
+ // 是标注必须把区分信息带进注入文本,否则重演「记账了但没用上」;标注再显式
2124
+ // 门控在 enforce 档——shadow 只记账零处置。
2125
+ const supersededIdSet = new Set(store.supersededIds());
2126
+ if (supersededIdSet.size) {
2127
+ selected = selected.filter((m) => !supersededIdSet.has(m.id));
2128
+ }
2129
+ if (config.conflictLedgerMode === "enforce" && selected.length > 1) {
2130
+ const language = langOf(config);
2131
+ const byId = new Map(selected.map((m) => [m.id, m]));
2132
+ for (const p of store.openConflictPairs()) {
2133
+ const ma = byId.get(p.memory_a);
2134
+ const mb = byId.get(p.memory_b);
2135
+ if (!ma || !mb) continue;
2136
+ // 确定性排位:可信度(observation > inferred > subjective,与
2137
+ // applyConflict 的换位同序)优先,平手退回服务器时钟新者。多对并存时
2138
+ // 逐一交换以相对次序为准——矛盾对是稀有事件,交换足够且可预测。
2139
+ const rank = (m) => EPISTEMIC_PRIORITY[m.epistemic_status] ?? 0;
2140
+ let preferred = ma;
2141
+ let other = mb;
2142
+ const ra = rank(ma);
2143
+ const rb = rank(mb);
2144
+ if (ra !== rb) {
2145
+ if (rb > ra) { preferred = mb; other = ma; }
2146
+ } else if ((mb.created_at ?? "") > (ma.created_at ?? "")) {
2147
+ preferred = mb;
2148
+ other = ma;
2149
+ }
2150
+ const detected = String(p.detected ?? "").slice(0, 10);
2151
+ preferred.conflictNote = STR.conflictPairPreferred[language](String(other.title ?? "").slice(0, 24), detected);
2152
+ other.conflictNote = STR.conflictPairSecondary[language](String(preferred.title ?? "").slice(0, 24), detected);
2153
+ const pi = selected.indexOf(preferred);
2154
+ const oi = selected.indexOf(other);
2155
+ if (pi > oi) {
2156
+ selected.splice(pi, 1);
2157
+ selected.splice(oi, 0, preferred);
2158
+ }
2159
+ }
2160
+ }
2049
2161
  if (pinnedStats) {
2050
2162
  // 「未展示」只数**真的没进块**的 pin 类条目:被 pin 预算挤下来的条目会回到
2051
2163
  // 候选池,仍可能被 general 槽选中——那就是展示了。按 eligible - pinned 直接
@@ -2260,6 +2372,10 @@ export function createService({ store, mirror, config, onWrite, logger, document
2260
2372
  id: r.id,
2261
2373
  run_id: r.run_id ?? null,
2262
2374
  reason: r.reason ?? null,
2375
+ // Issue #603 第 2 层:入队时记下的相似度(0–1)。存量行与 LLM 裁决路径
2376
+ // 没记 → null。面板据此排序与画条,不再从 reason 文本里正则回收;
2377
+ // null 与 0 必须区分开(「没测过」不是「不相似」)。
2378
+ similarity: typeof r.similarity === "number" ? r.similarity : null,
2263
2379
  created_at: r.created_at,
2264
2380
  memory_a: side(r.memory_a),
2265
2381
  memory_b: side(r.memory_b)
@@ -2612,6 +2728,71 @@ export function createService({ store, mirror, config, onWrite, logger, document
2612
2728
  }
2613
2729
  }
2614
2730
 
2731
+ // getCapacityHealth:容量预警(#275 空间维度)——库体积 / 归档区规模是否过线。
2732
+ // 与 getMirrorHealth 同款 fail-safe:读失败显式 unknown,绝不伪装成 ok,也绝不
2733
+ // 向外抛(/health 还有镜像那半张答复,容量不能把整张健康面拖塌)。
2734
+ // 阈值读 config.capacityWarning;config 缺席(直连调用方 / 测试传裸对象)时退回
2735
+ // config.js 的同一份常量,不另立一套数字。答复里只有数字,**绝不带 storageStats
2736
+ // 的 path**——那是本机文件系统布局,/health 是半张对外面。
2737
+ function getCapacityHealth() {
2738
+ const unknown = () => ({
2739
+ status: "unknown",
2740
+ file_bytes: null,
2741
+ freelist_bytes: null,
2742
+ active_rows: null,
2743
+ archived_rows: null,
2744
+ archived_ratio: null,
2745
+ checks: []
2746
+ });
2747
+ try {
2748
+ const cfg = { ...CAPACITY_WARNING_DEFAULTS, ...(config?.capacityWarning ?? {}) };
2749
+ const stats = store.storageStats();
2750
+ const counts = store.capacityStats();
2751
+ const total = counts.active + counts.archived;
2752
+ const ratio = total > 0 ? counts.archived / total : null;
2753
+ const checks = [];
2754
+ // 每条检查:阈值 > 0 才启用(0 = 显式关掉这一条),过线判 >= ——「到阈值」
2755
+ // 即报警(#275 口径)。没法评的检查直接缺席而不是伪装 ok:file_bytes 为
2756
+ // null(:memory: 库)时体积检查进不了列表,total 为 0 时占比检查同理。
2757
+ if (cfg.dbSizeWarnMb > 0 && stats.fileBytes !== null) {
2758
+ checks.push({
2759
+ key: "db_size",
2760
+ status: stats.fileBytes >= cfg.dbSizeWarnMb * 1048576 ? "warn" : "ok",
2761
+ value_bytes: stats.fileBytes,
2762
+ threshold_bytes: cfg.dbSizeWarnMb * 1048576
2763
+ });
2764
+ }
2765
+ if (cfg.archivedRowsWarn > 0) {
2766
+ checks.push({
2767
+ key: "archived_rows",
2768
+ status: counts.archived >= cfg.archivedRowsWarn ? "warn" : "ok",
2769
+ value: counts.archived,
2770
+ threshold: cfg.archivedRowsWarn
2771
+ });
2772
+ }
2773
+ if (cfg.archivedRatioWarn > 0 && ratio !== null) {
2774
+ checks.push({
2775
+ key: "archived_ratio",
2776
+ status: ratio >= cfg.archivedRatioWarn ? "warn" : "ok",
2777
+ value: Math.round(ratio * 1000) / 1000,
2778
+ threshold: cfg.archivedRatioWarn
2779
+ });
2780
+ }
2781
+ return {
2782
+ status: checks.some((c) => c.status === "warn") ? "warn" : "ok",
2783
+ file_bytes: stats.fileBytes,
2784
+ freelist_bytes: stats.freelistCount * stats.pageSize,
2785
+ active_rows: counts.active,
2786
+ archived_rows: counts.archived,
2787
+ archived_ratio: ratio === null ? null : Math.round(ratio * 1000) / 1000,
2788
+ checks
2789
+ };
2790
+ } catch (error) {
2791
+ logger?.warn?.("getCapacityHealth failed:", error);
2792
+ return unknown();
2793
+ }
2794
+ }
2795
+
2615
2796
  // 返回字面量与内部裁决器(resolveConflictPending → applyDecisions 的 conflict
2616
2797
  // 处置)共用的写路径:mirror 同步、写通知、嵌入调度全部同源,不能各写一份。
2617
2798
  function updateMemory(id, p, ctx = {}) {
@@ -2765,6 +2946,7 @@ export function createService({ store, mirror, config, onWrite, logger, document
2765
2946
  organize,
2766
2947
  recoverMirror,
2767
2948
  getMirrorHealth,
2949
+ getCapacityHealth,
2768
2950
  getMirrorState: () => store.getMirrorState(),
2769
2951
  injectCandidates,
2770
2952
  mergeHumanEdits,
@@ -2897,6 +3079,7 @@ export function createService({ store, mirror, config, onWrite, logger, document
2897
3079
  // 向量出身的两个分组读数:状态端点 /semantic 用它们区分「有向量」和
2898
3080
  // 「可检索」(byModel 按行级 embedding_model,byDimension 按向量维数)。
2899
3081
  // passthrough 形状与 embeddedCount 同款,api 层不直接摸 store。
3082
+ countNeedsEmbeddingFor: (modelHash) => store.countNeedsEmbeddingFor(modelHash),
2900
3083
  embeddingModelBreakdown: () => store.embeddingModelBreakdown(),
2901
3084
  embeddingDimensionBreakdown: () => store.embeddingDimensionBreakdown(),
2902
3085
  // #383:维度探针,供 /context 判「库里有没有一条活跃向量与查询同维」。注意
@@ -2916,6 +3099,8 @@ export function createService({ store, mirror, config, onWrite, logger, document
2916
3099
  list: (o) => store.list(o),
2917
3100
  all: () => store.all(),
2918
3101
  count: (type, opts) => store.count(type, opts),
3102
+ // #597:/list 的 typeCounts——同一份筛选、不带 type 的全库分布(面板分类栏数字源)。
3103
+ countByType: (opts) => store.countByType(opts),
2919
3104
  getById: (id) => store.getById(id),
2920
3105
  remove: (id) => {
2921
3106
  store.remove(id);
@@ -3013,6 +3198,17 @@ export function createService({ store, mirror, config, onWrite, logger, document
3013
3198
  saveConflictReviewed: (r) => store.saveConflictReviewed(r),
3014
3199
  loadConflictReviewed: () => store.loadConflictReviewed(),
3015
3200
  listConflictReviewed: (opts) => store.listConflictReviewed(opts),
3201
+ // #641:冲突分诊账本。与上面 conflict_reviewed 同一条 bookkeeping 纪律——纯
3202
+ // 测量记录,写它绝不触发 write hooks,否则 shadow 档的每轮记账都会把睡眠
3203
+ // 调度器叫醒。
3204
+ saveConflictLedgerEntry: (r) => store.saveConflictLedgerEntry(r),
3205
+ setConflictLedgerStatus: (a, b, s) => store.setConflictLedgerStatus(a, b, s),
3206
+ listConflictLedger: (opts) => store.listConflictLedger(opts),
3207
+ countConflictLedger: (opts) => store.countConflictLedger(opts),
3208
+ // #641 T3 取代链:与上面 ledger 同一条 bookkeeping 纪律——纯元数据标注绝不
3209
+ // 触发 write hooks(正文零变化,镜像无 diff;bump updated_at 会污染
3210
+ // 「这一对刚判过」的宽松代理判定)。
3211
+ supersedeChain: (loserId, winnerId) => store.supersedeChain(loserId, winnerId),
3016
3212
  // Recall evaluation trail (方案 B): audit-bookkeeping semantics like the
3017
3213
  // dream/recall passthroughs above — a recall_evals write is a snapshot, not
3018
3214
  // a memory mutation, so it never triggers write hooks.
@@ -3035,9 +3231,12 @@ export function createService({ store, mirror, config, onWrite, logger, document
3035
3231
  // migrates entity_attrs on merge. Bookkeeping writes like the audit
3036
3232
  // passthroughs above — never write-hook-triggering memory mutations.
3037
3233
  saveRelation: (r) => store.saveRelation(r),
3234
+ // #619:同一条边被后续观察补充证据时回写 metadata(情感相位判重命中)。
3235
+ updateRelationMetadata: (id, metadata) => store.updateRelationMetadata(id, metadata),
3038
3236
  listEntities: (o) => store.listEntities(o),
3039
3237
  countEntities: () => store.countEntities(),
3040
3238
  getRelations: (id) => store.getRelations(id),
3239
+ relationsByType: (type, limit) => store.relationsByType(type, limit),
3041
3240
  // #24 块4 被动确认的复核出口:列出权重被 bump 抬过的边(gain = 现权 −
3042
3241
  // 该 source 的初值)。纯读,不动权重——「看哪些边被用出来过」是人的动作。
3043
3242
  listBoostedRelations: (o) => store.listBoostedRelations(o),