@modusensus/dsh-mneme 0.12.0 → 0.13.0

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 (154) hide show
  1. package/README.md +48 -19
  2. package/docs/CONFIGURATION.md +43 -4
  3. package/docs/DIRECTORIES.md +114 -0
  4. package/docs/DISTILL.md +64 -0
  5. package/docs/DSH-GO.md +97 -0
  6. package/docs/ENTITIES.md +5 -3
  7. package/docs/FORGET.md +77 -0
  8. package/docs/KB.md +110 -0
  9. package/docs/SLEEP.md +28 -3
  10. package/docs/STORAGE.md +23 -0
  11. package/lib/api-standalone.js +4 -1
  12. package/lib/api.js +581 -29
  13. package/lib/attachment-text.js +122 -0
  14. package/lib/choro-command.js +9 -2
  15. package/lib/client.js +3537 -1205
  16. package/lib/config.js +139 -1
  17. package/lib/continuity.js +7 -6
  18. package/lib/diary/auto.js +190 -0
  19. package/lib/diary/compose.js +440 -0
  20. package/lib/diary/dir.js +59 -0
  21. package/lib/diary/export.js +97 -0
  22. package/lib/diary/index.js +26 -0
  23. package/lib/diary/library.js +189 -0
  24. package/lib/diary/qq-inbox.js +142 -0
  25. package/lib/diary/rollover.js +61 -0
  26. package/lib/diary/session-provider.js +122 -0
  27. package/lib/dream/decisions.js +56 -5
  28. package/lib/dream/sleep.js +616 -15
  29. package/lib/dream.js +45 -13
  30. package/lib/dsh-go/host.js +162 -0
  31. package/lib/dsh-go/panel.js +2091 -0
  32. package/lib/dsh-go/text.js +342 -0
  33. package/lib/dsh-go/views/common.js +68 -0
  34. package/lib/dsh-go/views/entity.js +48 -0
  35. package/lib/dsh-go/views/memory.js +198 -0
  36. package/lib/dsh-go/views/root.js +30 -0
  37. package/lib/dsh-go/views/settings.js +398 -0
  38. package/lib/dsh-go/views/status.js +51 -0
  39. package/lib/embedding.js +42 -8
  40. package/lib/entities/extractor.js +36 -7
  41. package/lib/hot-memory.js +8 -9
  42. package/lib/index.js +92 -2
  43. package/lib/inject.js +189 -18
  44. package/lib/kb/index.js +309 -0
  45. package/lib/kb/routes.js +220 -0
  46. package/lib/kb/tools.js +148 -0
  47. package/lib/lang.js +87 -4
  48. package/lib/peak-hours.js +57 -2
  49. package/lib/semantic.js +347 -60
  50. package/lib/service.js +193 -66
  51. package/lib/settings.js +112 -14
  52. package/lib/store.js +347 -21
  53. package/lib/summarize.js +38 -11
  54. package/lib/tools.js +35 -4
  55. package/lib/user-settings.js +128 -32
  56. package/lib/vector-index.js +76 -6
  57. package/lib/wiki/cli-hint.js +90 -0
  58. package/lib/wiki/queries.js +116 -2
  59. package/lib/wiki/t1-jobs.js +232 -0
  60. package/lib/wiki/t1.js +7 -1
  61. package/package.json +25 -1
  62. package/src/api-standalone.js +4 -1
  63. package/src/api.js +581 -29
  64. package/src/attachment-text.js +122 -0
  65. package/src/choro-command.js +9 -2
  66. package/src/config.js +139 -1
  67. package/src/continuity.js +7 -6
  68. package/src/diary/auto.js +190 -0
  69. package/src/diary/compose.js +440 -0
  70. package/src/diary/dir.js +59 -0
  71. package/src/diary/export.js +97 -0
  72. package/src/diary/index.js +26 -0
  73. package/src/diary/library.js +189 -0
  74. package/src/diary/qq-inbox.js +142 -0
  75. package/src/diary/rollover.js +61 -0
  76. package/src/diary/session-provider.js +122 -0
  77. package/src/dream/decisions.js +56 -5
  78. package/src/dream/sleep.js +616 -15
  79. package/src/dream.js +45 -13
  80. package/src/dsh-go/host.js +162 -0
  81. package/src/dsh-go/panel.js +2091 -0
  82. package/src/dsh-go/text.js +342 -0
  83. package/src/dsh-go/views/common.js +68 -0
  84. package/src/dsh-go/views/entity.js +48 -0
  85. package/src/dsh-go/views/memory.js +198 -0
  86. package/src/dsh-go/views/root.js +30 -0
  87. package/src/dsh-go/views/settings.js +398 -0
  88. package/src/dsh-go/views/status.js +51 -0
  89. package/src/embedding.js +42 -8
  90. package/src/entities/extractor.js +36 -7
  91. package/src/hot-memory.js +8 -9
  92. package/src/index.js +92 -2
  93. package/src/inject.js +189 -18
  94. package/src/kb/index.js +309 -0
  95. package/src/kb/routes.js +220 -0
  96. package/src/kb/tools.js +148 -0
  97. package/src/lang.js +87 -4
  98. package/src/peak-hours.js +57 -2
  99. package/src/semantic.js +347 -60
  100. package/src/service.js +193 -66
  101. package/src/settings.js +112 -14
  102. package/src/store.js +347 -21
  103. package/src/summarize.js +38 -11
  104. package/src/tools.js +35 -4
  105. package/src/user-settings.js +128 -32
  106. package/src/vector-index.js +76 -6
  107. package/src/wiki/cli-hint.js +90 -0
  108. package/src/wiki/queries.js +116 -2
  109. package/src/wiki/t1-jobs.js +232 -0
  110. package/src/wiki/t1.js +7 -1
  111. package/test/api-routes-gaps.test.js +63 -0
  112. package/test/api.test.js +375 -4
  113. package/test/attachment-text.test.js +140 -0
  114. package/test/bm25-corpus-cache.test.js +145 -0
  115. package/test/capacity-warning.test.js +262 -0
  116. package/test/client.test.js +779 -154
  117. package/test/conflict-freeze.test.js +45 -1
  118. package/test/continuity.test.js +8 -1
  119. package/test/diary-library.test.js +187 -0
  120. package/test/diary.test.js +576 -0
  121. package/test/dream-maxtokens-fallback.test.js +3 -3
  122. package/test/dream.test.js +62 -1
  123. package/test/dsh-go-host.test.js +134 -0
  124. package/test/dsh-go-panel-actions.test.js +854 -0
  125. package/test/dsh-go-panel.test.js +251 -0
  126. package/test/embedding-ready.test.js +33 -3
  127. package/test/entities.test.js +73 -0
  128. package/test/forget-corrections.test.js +70 -0
  129. package/test/image-memory.test.js +171 -0
  130. package/test/issue-617-step1-query-and-fusion.test.js +361 -0
  131. package/test/kb-api-wiring.test.js +128 -0
  132. package/test/kb-collections.test.js +272 -0
  133. package/test/kb-routes.test.js +379 -0
  134. package/test/mint-source.test.js +26 -0
  135. package/test/peak-hours-observability.test.js +70 -0
  136. package/test/persona-api.test.js +3 -3
  137. package/test/persona-by-session.test.js +568 -0
  138. package/test/persona-settings.test.js +7 -7
  139. package/test/reflection-update-gate.test.js +129 -0
  140. package/test/reindex-backfill.test.js +334 -0
  141. package/test/reindex-scheduler.test.js +369 -0
  142. package/test/scope-candidates.test.js +249 -0
  143. package/test/sleep-action-set.test.js +14 -0
  144. package/test/sleep-heat.test.js +49 -0
  145. package/test/sleep.test.js +598 -0
  146. package/test/standalone-api.test.js +24 -0
  147. package/test/store.test.js +20 -0
  148. package/test/summarize.test.js +26 -0
  149. package/test/user-settings-segments.test.js +528 -0
  150. package/test/vector-index.test.js +120 -6
  151. package/test/wiki-api.test.js +355 -0
  152. package/test/wiki-cli-hint.test.js +118 -0
  153. package/test/wiki-queries.test.js +7 -0
  154. package/test/wiki-t1-jobs.test.js +125 -0
package/lib/service.js CHANGED
@@ -7,6 +7,7 @@ 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";
@@ -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 +
@@ -922,18 +968,13 @@ 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);
936
- }
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 排序就是它们各自算出来的排序。
937
978
 
938
979
  return { merged, signals };
939
980
  }
@@ -1778,62 +1819,60 @@ export function createService({ store, mirror, config, onWrite, logger, document
1778
1819
  (effImportance(a) * qualityWeight(a) * heatOf(a) * scopeMultOf(a));
1779
1820
  });
1780
1821
  let candidates = items;
1822
+ // #617 第 2 条(前半):向量路与 BM25 路此前是**互斥**的——`if (!semanticItems.length)`
1823
+ // 包住 BM25,于是「有向量」等于「词法命中整批消失」。可两条路测的不是同一件事:
1824
+ // 向量管「意思像」,BM25 管「词面撞上」,专有名词、issue 编号、函数名这类查询
1825
+ // 恰恰是向量最容易糊、词法最准的地方。现在两路都跑,用 RRF(k=60)融合排名:
1826
+ // 某一路的第 1 名恒得 1/(60+1),两路都靠前的行自然走高,不再由「谁先跑」决定。
1827
+ // 另外先按 fetchLimit 多取再过滤——旧代码先按 poolSize 截断再筛门控,筛掉的
1828
+ // 名额永远补不回来(缩水),池子越脏越明显。
1829
+ let fusedRrf = null; // id -> RRF 原始分;selectiveInject 复用它做二次重排
1781
1830
  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 = [];
1831
+ const fetchLimit = Math.max(poolSize * 4, 40);
1832
+ const passesGate = (m) => Boolean(m) && !m.archived && injectTypes.has(m.type) && !m.forgotten &&
1833
+ (m.source !== "narrative" || allowNarrative) &&
1834
+ codingGate(m) &&
1835
+ (m.type === "summary" || m.type === "preference" || m.importance >= threshold);
1836
+ const vectorHits = [];
1788
1837
  if (Array.isArray(queryVector) && queryVector.length && vectorIndex) {
1789
1838
  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
- }
1839
+ for (const m of vectorIndex.search(queryVector, { limit: fetchLimit, threshold: 0 })) {
1840
+ if (passesGate(m)) vectorHits.push(m);
1798
1841
  }
1799
1842
  } catch { /* vector unavailable: fall through to the recall cache */ }
1800
1843
  }
1801
- if (!semanticItems.length && lastSemanticRecall?.query === q && lastSemanticRecall.items?.length) {
1802
- // CodeRabbit on #404:这条回填分支此前独漏重要度门槛——向量路(上方)与
1803
- // BM25 路(下方 :1655 注释「门控与向量路径一致(含 importance 阈值)」)
1844
+ if (!vectorHits.length && lastSemanticRecall?.query === q && lastSemanticRecall.items?.length) {
1845
+ // CodeRabbit on #404:这条回填分支此前独漏重要度门槛——向量路与 BM25 路
1804
1846
  // 都带着它。#369 收编 history 后缺口会被放大:库里低于闸门的行(实测
1805
1847
  // 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
- }
1848
+ for (const m of lastSemanticRecall.items) if (passesGate(m)) vectorHits.push(m);
1811
1849
  }
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) {
1850
+ // Issue #198:BM25 词法路。首轮(无向量、无缓存召回)这是唯一的同步相关性
1851
+ // 信号;有向量时它补上向量糊掉的那一类命中。#617 之后它不再被向量挤掉。
1852
+ const bm25Hits = [];
1853
+ for (const hit of bm25Recall(q, fetchLimit)) if (passesGate(hit)) bm25Hits.push(hit);
1854
+ if (vectorHits.length || bm25Hits.length) {
1855
+ const RRF_K = 60;
1856
+ const rrf = new Map();
1857
+ const rowById = new Map();
1858
+ const addList = (list) => {
1859
+ list.forEach((m, idx) => {
1860
+ rowById.set(m.id, m);
1861
+ rrf.set(m.id, (rrf.get(m.id) ?? 0) + 1 / (RRF_K + idx + 1));
1862
+ });
1863
+ };
1864
+ addList(vectorHits);
1865
+ addList(bm25Hits);
1866
+ fusedRrf = rrf;
1867
+ // 软加权(issue #339 / CodeRabbit on #350)作用在融合分上:foreign 行不再
1868
+ // 靠一路的原始 score 绕过降权,也不需要像旧代码那样单独给 BM25 排一次序。
1869
+ const ranked = [...rrf.entries()]
1870
+ .map(([id, score]) => {
1871
+ const m = rowById.get(id);
1872
+ return { m, score: score * scopeMultOf(m) };
1873
+ })
1874
+ .sort((a, b) => b.score - a.score)
1875
+ .map((row) => row.m);
1837
1876
  const seen = new Set();
1838
1877
  const merged = [];
1839
1878
  const push = (m) => {
@@ -1841,7 +1880,7 @@ export function createService({ store, mirror, config, onWrite, logger, document
1841
1880
  seen.add(m.id);
1842
1881
  merged.push(m);
1843
1882
  };
1844
- for (const m of semanticItems) {
1883
+ for (const m of ranked) {
1845
1884
  push(m);
1846
1885
  if (merged.length >= poolSize) break;
1847
1886
  }
@@ -1927,16 +1966,29 @@ export function createService({ store, mirror, config, onWrite, logger, document
1927
1966
  // vector, so the injected slots go to memories on the current topic
1928
1967
  // rather than to the rule-based order. Rows the index did not return
1929
1968
  // keep their relative order after the scored ones.
1969
+ //
1970
+ // #617 第 2 条(后半):这段此前是**纯余弦**全量 sort,于是上游用 RRF 顶上来的
1971
+ // BM25 命中(词面精确、余弦却低)会被重新压到 maxItems 之后,再被 general 槽
1972
+ // 的 `general.length >= maxItems` 丢掉——融合白做。现在余弦只占一半权重,另一半
1973
+ // 给 RRF 分(同一轮融合算出来的那份):纯余弦命中仍是主体,但词法精确命中
1974
+ // 不会因为「意思不够像」被挤出去。
1975
+ // RRF 归一化:单路第 1 名原始分 1/(60+1),乘 (60+1)/2 得 0.5,双路都在第 1 名
1976
+ // 得 1.0 —— 与余弦同量纲,且天然落在 [0,1]。
1930
1977
  if (config?.selectiveInjectEnabled !== false && Array.isArray(queryVector) && queryVector.length && vectorIndex) {
1931
1978
  try {
1932
1979
  const hits = vectorIndex.search(queryVector, { limit: 200, threshold: 0 });
1933
1980
  const sim = new Map(hits.map((m) => [m.id, m.score ?? 0]));
1934
1981
  if (sim.size) {
1935
- // 软加权作用在真实 sim 上;未命中的规则候选保持 -1 沉底(缺失项
1936
- // 乘乘数会让 foreign 缺失项反而排到自己的缺失项之上,故不乘)。
1982
+ const RRF_NORM = (60 + 1) / 2;
1983
+ // 软加权乘在融合分上(不是乘在某一路的原始分上);两路都没有的规则
1984
+ // 候选保持 -1 沉底(缺失项乘乘数会让 foreign 缺失项反而排到自己的缺失
1985
+ // 项之上,故不乘)。
1937
1986
  const scopedSim = (m) => {
1938
1987
  const s = sim.get(m.id);
1939
- return s === undefined ? -1 : s * scopeMultOf(m);
1988
+ const rrf = fusedRrf?.get(m.id);
1989
+ if (s === undefined && rrf === undefined) return -1;
1990
+ const fused = 0.5 * (s ?? 0) + 0.5 * (rrf ?? 0) * RRF_NORM;
1991
+ return fused * scopeMultOf(m);
1940
1992
  };
1941
1993
  candidates = [...candidates].sort((a, b) => scopedSim(b) - scopedSim(a));
1942
1994
  }
@@ -2260,6 +2312,10 @@ export function createService({ store, mirror, config, onWrite, logger, document
2260
2312
  id: r.id,
2261
2313
  run_id: r.run_id ?? null,
2262
2314
  reason: r.reason ?? null,
2315
+ // Issue #603 第 2 层:入队时记下的相似度(0–1)。存量行与 LLM 裁决路径
2316
+ // 没记 → null。面板据此排序与画条,不再从 reason 文本里正则回收;
2317
+ // null 与 0 必须区分开(「没测过」不是「不相似」)。
2318
+ similarity: typeof r.similarity === "number" ? r.similarity : null,
2263
2319
  created_at: r.created_at,
2264
2320
  memory_a: side(r.memory_a),
2265
2321
  memory_b: side(r.memory_b)
@@ -2612,6 +2668,71 @@ export function createService({ store, mirror, config, onWrite, logger, document
2612
2668
  }
2613
2669
  }
2614
2670
 
2671
+ // getCapacityHealth:容量预警(#275 空间维度)——库体积 / 归档区规模是否过线。
2672
+ // 与 getMirrorHealth 同款 fail-safe:读失败显式 unknown,绝不伪装成 ok,也绝不
2673
+ // 向外抛(/health 还有镜像那半张答复,容量不能把整张健康面拖塌)。
2674
+ // 阈值读 config.capacityWarning;config 缺席(直连调用方 / 测试传裸对象)时退回
2675
+ // config.js 的同一份常量,不另立一套数字。答复里只有数字,**绝不带 storageStats
2676
+ // 的 path**——那是本机文件系统布局,/health 是半张对外面。
2677
+ function getCapacityHealth() {
2678
+ const unknown = () => ({
2679
+ status: "unknown",
2680
+ file_bytes: null,
2681
+ freelist_bytes: null,
2682
+ active_rows: null,
2683
+ archived_rows: null,
2684
+ archived_ratio: null,
2685
+ checks: []
2686
+ });
2687
+ try {
2688
+ const cfg = { ...CAPACITY_WARNING_DEFAULTS, ...(config?.capacityWarning ?? {}) };
2689
+ const stats = store.storageStats();
2690
+ const counts = store.capacityStats();
2691
+ const total = counts.active + counts.archived;
2692
+ const ratio = total > 0 ? counts.archived / total : null;
2693
+ const checks = [];
2694
+ // 每条检查:阈值 > 0 才启用(0 = 显式关掉这一条),过线判 >= ——「到阈值」
2695
+ // 即报警(#275 口径)。没法评的检查直接缺席而不是伪装 ok:file_bytes 为
2696
+ // null(:memory: 库)时体积检查进不了列表,total 为 0 时占比检查同理。
2697
+ if (cfg.dbSizeWarnMb > 0 && stats.fileBytes !== null) {
2698
+ checks.push({
2699
+ key: "db_size",
2700
+ status: stats.fileBytes >= cfg.dbSizeWarnMb * 1048576 ? "warn" : "ok",
2701
+ value_bytes: stats.fileBytes,
2702
+ threshold_bytes: cfg.dbSizeWarnMb * 1048576
2703
+ });
2704
+ }
2705
+ if (cfg.archivedRowsWarn > 0) {
2706
+ checks.push({
2707
+ key: "archived_rows",
2708
+ status: counts.archived >= cfg.archivedRowsWarn ? "warn" : "ok",
2709
+ value: counts.archived,
2710
+ threshold: cfg.archivedRowsWarn
2711
+ });
2712
+ }
2713
+ if (cfg.archivedRatioWarn > 0 && ratio !== null) {
2714
+ checks.push({
2715
+ key: "archived_ratio",
2716
+ status: ratio >= cfg.archivedRatioWarn ? "warn" : "ok",
2717
+ value: Math.round(ratio * 1000) / 1000,
2718
+ threshold: cfg.archivedRatioWarn
2719
+ });
2720
+ }
2721
+ return {
2722
+ status: checks.some((c) => c.status === "warn") ? "warn" : "ok",
2723
+ file_bytes: stats.fileBytes,
2724
+ freelist_bytes: stats.freelistCount * stats.pageSize,
2725
+ active_rows: counts.active,
2726
+ archived_rows: counts.archived,
2727
+ archived_ratio: ratio === null ? null : Math.round(ratio * 1000) / 1000,
2728
+ checks
2729
+ };
2730
+ } catch (error) {
2731
+ logger?.warn?.("getCapacityHealth failed:", error);
2732
+ return unknown();
2733
+ }
2734
+ }
2735
+
2615
2736
  // 返回字面量与内部裁决器(resolveConflictPending → applyDecisions 的 conflict
2616
2737
  // 处置)共用的写路径:mirror 同步、写通知、嵌入调度全部同源,不能各写一份。
2617
2738
  function updateMemory(id, p, ctx = {}) {
@@ -2765,6 +2886,7 @@ export function createService({ store, mirror, config, onWrite, logger, document
2765
2886
  organize,
2766
2887
  recoverMirror,
2767
2888
  getMirrorHealth,
2889
+ getCapacityHealth,
2768
2890
  getMirrorState: () => store.getMirrorState(),
2769
2891
  injectCandidates,
2770
2892
  mergeHumanEdits,
@@ -2897,6 +3019,7 @@ export function createService({ store, mirror, config, onWrite, logger, document
2897
3019
  // 向量出身的两个分组读数:状态端点 /semantic 用它们区分「有向量」和
2898
3020
  // 「可检索」(byModel 按行级 embedding_model,byDimension 按向量维数)。
2899
3021
  // passthrough 形状与 embeddedCount 同款,api 层不直接摸 store。
3022
+ countNeedsEmbeddingFor: (modelHash) => store.countNeedsEmbeddingFor(modelHash),
2900
3023
  embeddingModelBreakdown: () => store.embeddingModelBreakdown(),
2901
3024
  embeddingDimensionBreakdown: () => store.embeddingDimensionBreakdown(),
2902
3025
  // #383:维度探针,供 /context 判「库里有没有一条活跃向量与查询同维」。注意
@@ -2916,6 +3039,8 @@ export function createService({ store, mirror, config, onWrite, logger, document
2916
3039
  list: (o) => store.list(o),
2917
3040
  all: () => store.all(),
2918
3041
  count: (type, opts) => store.count(type, opts),
3042
+ // #597:/list 的 typeCounts——同一份筛选、不带 type 的全库分布(面板分类栏数字源)。
3043
+ countByType: (opts) => store.countByType(opts),
2919
3044
  getById: (id) => store.getById(id),
2920
3045
  remove: (id) => {
2921
3046
  store.remove(id);
@@ -3035,6 +3160,8 @@ export function createService({ store, mirror, config, onWrite, logger, document
3035
3160
  // migrates entity_attrs on merge. Bookkeeping writes like the audit
3036
3161
  // passthroughs above — never write-hook-triggering memory mutations.
3037
3162
  saveRelation: (r) => store.saveRelation(r),
3163
+ // #619:同一条边被后续观察补充证据时回写 metadata(情感相位判重命中)。
3164
+ updateRelationMetadata: (id, metadata) => store.updateRelationMetadata(id, metadata),
3038
3165
  listEntities: (o) => store.listEntities(o),
3039
3166
  countEntities: () => store.countEntities(),
3040
3167
  getRelations: (id) => store.getRelations(id),
package/lib/settings.js CHANGED
@@ -34,7 +34,9 @@ function parseList(raw) {
34
34
  // persona 对象的字段白名单:写入时只挑这些键,未知字段丢弃(设计稿①:字段从简,
35
35
  // beta 收到真实反馈再加;provenance 是 v1.5 协作式人格的预留位,v1 不写管道)。
36
36
  // 读侧损坏即回空对象——同一容错口径照 parseList(坏值不抛错、面板显示为空可重写)。
37
- const PERSONA_FIELDS = ["name", "personality", "replyStyle", "enabled", "provenance"];
37
+ // scenario / mesExample(#606):对标 Character Card V2 的同名字段,是「人格卡能装
38
+ // 下完整角色」所需的最小补充。两者都可选、都不写时渲染零输出(老用户逐字节不变)。
39
+ const PERSONA_FIELDS = ["name", "personality", "replyStyle", "scenario", "mesExample", "enabled", "provenance"];
38
40
 
39
41
  function parsePersona(raw) {
40
42
  try {
@@ -54,14 +56,20 @@ function parsePersona(raw) {
54
56
  // 兼容层:v1 存的是单对象(有 PERSONA_FIELDS 键、无 default/byAgent 键),读侧
55
57
  // 判形状——单对象原样落 default 位,旧数据零迁移。byAgent 的键是 agent 身份
56
58
  // id(注入时按会话身份寻址,取不到落 default)。default 卡不可删(兜底位)。
59
+ //
60
+ // 绑会话(v1.3,#606):加第三维 bySession: { <sessionId>: <卡> }。为什么需要它——
61
+ // byAgent 的键来自 session.header.agentPreset,而 DS Bot 这类宿主把它写成常量
62
+ // (所有 bot 会话都是 "standard",实测 43/50),按 bot 名字建卡永远不命中。会话 id
63
+ // 才是那个「一个 bot 一张卡」真正能分开的键。bySession 同样是纯加法:没有这个键
64
+ // 的老数据读成 {},注入文本逐字节不变。
57
65
  function parsePersonaStore(raw) {
58
66
  try {
59
67
  const value = JSON.parse(raw);
60
68
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
61
- return { default: {}, byAgent: {} };
69
+ return { default: {}, byAgent: {}, bySession: {} };
62
70
  }
63
- // 新形:含 default 或 byAgent 键 → 按多卡解析
64
- if ("default" in value || "byAgent" in value) {
71
+ // 新形:含 default / byAgent / bySession 任一键 → 按多卡解析
72
+ if ("default" in value || "byAgent" in value || "bySession" in value) {
65
73
  const byAgent = {};
66
74
  if (value.byAgent && typeof value.byAgent === "object" && !Array.isArray(value.byAgent)) {
67
75
  for (const [agentId, card] of Object.entries(value.byAgent)) {
@@ -69,19 +77,27 @@ function parsePersonaStore(raw) {
69
77
  if (Object.keys(parsed).length) byAgent[String(agentId)] = parsed;
70
78
  }
71
79
  }
72
- return { default: parsePersona(JSON.stringify(value.default ?? {})), byAgent };
80
+ const bySession = {};
81
+ if (value.bySession && typeof value.bySession === "object" && !Array.isArray(value.bySession)) {
82
+ for (const [sessionId, card] of Object.entries(value.bySession)) {
83
+ const parsed = parsePersona(JSON.stringify(card ?? {}));
84
+ if (Object.keys(parsed).length) bySession[String(sessionId)] = parsed;
85
+ }
86
+ }
87
+ return { default: parsePersona(JSON.stringify(value.default ?? {})), byAgent, bySession };
73
88
  }
74
89
  // 旧形:v1 单对象 → 整体作 default(字段白名单在 parsePersona 内裁)
75
- return { default: parsePersona(raw), byAgent: {} };
90
+ return { default: parsePersona(raw), byAgent: {}, bySession: {} };
76
91
  } catch {
77
- return { default: {}, byAgent: {} };
92
+ return { default: {}, byAgent: {}, bySession: {} };
78
93
  }
79
94
  }
80
95
 
81
96
  function serializePersonaStore(store) {
82
97
  const out = {
83
98
  default: parsePersona(JSON.stringify(store?.default ?? {})),
84
- byAgent: {}
99
+ byAgent: {},
100
+ bySession: {}
85
101
  };
86
102
  if (store?.byAgent && typeof store.byAgent === "object") {
87
103
  for (const [agentId, card] of Object.entries(store.byAgent)) {
@@ -89,6 +105,12 @@ function serializePersonaStore(store) {
89
105
  if (Object.keys(parsed).length) out.byAgent[String(agentId)] = parsed;
90
106
  }
91
107
  }
108
+ if (store?.bySession && typeof store.bySession === "object") {
109
+ for (const [sessionId, card] of Object.entries(store.bySession)) {
110
+ const parsed = parsePersona(JSON.stringify(card ?? {}));
111
+ if (Object.keys(parsed).length) out.bySession[String(sessionId)] = parsed;
112
+ }
113
+ }
92
114
  return JSON.stringify(out);
93
115
  }
94
116
 
@@ -137,15 +159,27 @@ const FEATURE_FLAG_BOOLEANS = [
137
159
  // Issue #230:document 型记忆——agent 产长文档的指针行(注册校验/摘要+
138
160
  // doc_path 落库/C2 去重/supersede 记账;全文归 agent;默认关,lightMode 强制关)。
139
161
  "documentMemoryEnabled",
162
+ // 日记本:跨天惰性判定 + 启动补写,正文落 <memoryDir>/diaries/<项目键>/。
163
+ // 默认关,进 LIGHT_MODE_OFF(要调模型,与低资源档互斥)。与 documentMemoryEnabled
164
+ // 成对但不相依:关掉文档记忆只影响检索,正文照样写。
165
+ "diaryEnabled",
140
166
  "codingRetrospect",
141
167
  "autoDream",
142
168
  // Issue #292:autoDream 连续失败退避(默认关)。基数是 dreamMinIntervalMinutes,
143
169
  // 有效间隔 = 基数 × 2^连续失败数(成功清零),封顶 30 分钟;面板可启停。
144
170
  "autoDreamFailureBackoff",
171
+ // Issue #557:dream 审计行输入快照瘦身(默认关)。开启后 auto 轮只落骨架
172
+ // (不含 content),organize 轮的重放载荷不受影响;面板可启停。
173
+ "dreamInputSummaryEnabled",
145
174
  "sleepModeEnabled",
146
175
  // Issue #529:sleep 连续失败退避(默认关)。基数是 sleepMinIntervalHours,
147
176
  // 有效间隔 = 基数 × 2^连续失败数(成功清零),封顶 max(基数, 24h);面板可启停。
148
177
  "sleepFailureBackoff",
178
+ // Issue #619:情感归因相位(默认关)。把「某人某场合的情绪」写成 occasion-bound
179
+ // 的 affective_context 关系,只有跨够 sleepAffectiveTraitMinSessions 个独立会话
180
+ // 才允许升成稳定特质。要调模型且依赖实体抽取,所以必须显式打开、可随时关掉;
181
+ // 它是 sleepModeEnabled 的子相:父开关关着时整个 sleep 不跑。
182
+ "sleepAffectiveEnabled",
149
183
  "heatEnabled",
150
184
  // issue #218 / E5:注入侧 heat 独立开关(默认关,老用户 injectHeatEnabled=true 回滚)。
151
185
  "injectHeatEnabled",
@@ -159,6 +193,11 @@ const FEATURE_FLAG_BOOLEANS = [
159
193
  "reflectionFailureTracking",
160
194
  "bm25SearchEnabled",
161
195
  "conflictFreezeEnabled",
196
+ // Issue #603 第 4 层:自动裁决档(默认关)。相似度极高的同 scope 重复对直接
197
+ // 保留新条、归档旧条,不进人工队列;模糊地带照旧留给人。会归档记忆,所以必须
198
+ // 显式打开,面板可启停。阈值见 FEATURE_FLAG_NUMBER_RANGES 的
199
+ // conflictAutoResolveMinSimilarity。
200
+ "conflictAutoResolveEnabled",
162
201
  "trustEpistemicWeighting",
163
202
  // Plan #2: attach per-source {keyword, vector, bm25, final} signals to each
164
203
  // search result for transparency/debugging. Default off, purely decorative.
@@ -255,7 +294,14 @@ const FEATURE_FLAG_BOOLEANS = [
255
294
  // #539:逐会话无痕默认关闭;子代理开关受总开关约束。手动 session id 名单留在配置,
256
295
  // 不进入只支持标量的 feature_flags 白名单。
257
296
  "sessionGateEnabled",
258
- "sessionGateSubagents"
297
+ "sessionGateSubagents",
298
+ // 2026-11-08 kb-qoder parity(契约 §3.1):chorography 面板闭环的两个授权闸 +
299
+ // kb 子系统总闸。三条都与 config.js schema 成对出现,计数锁 test/api.test.js 钉住。
300
+ // 不进 LIGHT_MODE_OFF —— 它们是「谁按下了按钮」而不是「每轮付多少」,理由写在
301
+ // config.js 那三个键的注释里。kbEnabled 本车道只登记、不接线(B 线做子系统)。
302
+ "chorographyAutoRefresh",
303
+ "chorographyT1FromPanel",
304
+ "kbEnabled"
259
305
  ];
260
306
  // 整数开关的闭区间,与 config.js 里 z.natural().min().max() 对齐。
261
307
  const FEATURE_FLAG_INT_RANGES = {
@@ -280,6 +326,9 @@ const FEATURE_FLAG_INT_RANGES = {
280
326
  summarizePeakMaxDeferMinutes: [0, 1440],
281
327
  // Issue #239 第 4 项镜像到巩固:同一口径(分钟,0 = 不设上限)。
282
328
  dreamPeakMaxDeferMinutes: [0, 1440],
329
+ // v0.13 日记定时写:每隔 N 分钟唤醒一次跨天判定(0 = 关,只在 turn/end 与
330
+ // 启动时判)。上限一天——判定单位是天,更长的间隔没有意义。
331
+ diaryWriteEveryMinutes: [0, 1440],
283
332
  // Issue #125:hybrid 候选量上限(0 = 复用 dreamMaxSnapshotSize)。
284
333
  dreamCandidateMax: [0, 5000],
285
334
  // #135 成本审计派生:单轮 merge 决策上限(merge 是唯一回吐正文的动作,默认 40
@@ -295,6 +344,11 @@ const FEATURE_FLAG_INT_RANGES = {
295
344
  pinnedInjectBudget: [0, 5],
296
345
  // Issue #257:sleep 冲突/模式阶段的 LLM 输出预算(原硬编码 2048,实测不足)。
297
346
  sleepMaxTokens: [256, 131072],
347
+ // Issue #619:情感归因的两个闸——升特质所需的最小独立会话数(下界 2:N=1 就是
348
+ // 单次场合,正是必须禁止升特质的那个情形),以及单轮可写的 affective_context
349
+ // 关系条数上限(0 = 关闭关系通道)。闭区间与 config.js 的 z.natural() 对齐。
350
+ sleepAffectiveTraitMinSessions: [2, 10],
351
+ sleepAffectiveMaxRelationsPerRun: [0, 50],
298
352
  // Issue #529:sleep 冲突阶段的输入侧预算(此前完全没有——全部候选对一次进一个
299
353
  // prompt,实测 163,054 token 撞服务端 163,840 上限)。两个键都是 0 = 不设上限,
300
354
  // 与 config.js 的 z.natural().min(0).max() 成对。
@@ -309,7 +363,12 @@ const FEATURE_FLAG_INT_RANGES = {
309
363
  graphInjectBudget: [0, 5],
310
364
  // Issue #399:蒸馏原文记录的保留天数(与 config.js 的 z.natural().min(1).max(3650)
311
365
  // 对齐;同 llmAudit.retentionDays,是 int 而非开关)。
312
- "distillRecords.retentionDays": [1, 3650]
366
+ "distillRecords.retentionDays": [1, 3650],
367
+ // #275 容量预警阈值(嵌套点号键,必须同时进 api.js 的 NESTED_FLAG_PATHS)。
368
+ // 0 = 显式关掉那一条检查(阈值判级的启用条件写在 service.getCapacityHealth)。
369
+ // 上限与 config.js 的 z.natural().min(0).max() 成对。
370
+ "capacityWarning.dbSizeWarnMb": [0, 1048576],
371
+ "capacityWarning.archivedRowsWarn": [0, 10000000]
313
372
  };
314
373
  // 浮点开关的闭区间(与 config.js 的 z.number().min().max() 对齐)。与整数开关
315
374
  // 分开:面板的整数控件要求 Number.isInteger,而余弦相似度阈值必须允许小数。
@@ -319,7 +378,14 @@ const FEATURE_FLAG_NUMBER_RANGES = {
319
378
  // Issue #125:hybrid 判"高相似"的阈值。
320
379
  dreamCandidateMinSim: [0.5, 0.99],
321
380
  // Issue #24 块2:单次有效触达的边权抬升幅度(与 config.js 的闭区间对齐)。
322
- graphWeightDelta: [0, 1]
381
+ graphWeightDelta: [0, 1],
382
+ // Issue #603 第 4 层:自动裁决的相似度门槛。下界 0.85 与冲突检测阈值同源——
383
+ // 低于它就不是「重复」而是「相似」,不该自动归档任何一条。
384
+ conflictAutoResolveMinSimilarity: [0.85, 1],
385
+ // #275 容量预警的归档占比阈值(0 = 关):健康形态本身就落在 0.8~0.9,默认
386
+ // 0.95 起才算「归档区吞掉活跃区」——0.9 会对着健康库天天报警(依据见 config.js
387
+ // capacityWarning 的注释)。浮点键,同 conflictAutoResolveMinSimilarity 的缘由。
388
+ "capacityWarning.archivedRatioWarn": [0, 1]
323
389
  };
324
390
  // 自由字符串开关(与 config.js 的 z.string() 同名同型):trim 后 ≤200 字符,
325
391
  // 空串合法(= 跟随主对话模型/默认路径,面板显示 placeholder)。
@@ -534,10 +600,10 @@ export function createSettings(db) {
534
600
  return parsePersonaStore(getSetting("persona") ?? "{}");
535
601
  },
536
602
  setPersona(input) {
537
- // 双形兼容:单对象形 = 只更新 default、byAgent 保留(旧面板/旧调用方合并语义);
538
- // 多卡形 = 全量替换。坏体(非对象)在此不落库不抛错,保值由调用方守卫。
603
+ // 双形兼容:单对象形 = 只更新 default、byAgent/bySession 保留(旧面板/旧调用方
604
+ // 合并语义);多卡形 = 全量替换。坏体(非对象)在此不落库不抛错,保值由调用方守卫。
539
605
  if (typeof input !== "object" || input === null || Array.isArray(input)) return;
540
- if ("default" in input || "byAgent" in input) {
606
+ if ("default" in input || "byAgent" in input || "bySession" in input) {
541
607
  setSetting("persona", serializePersonaStore(input));
542
608
  return;
543
609
  }
@@ -573,6 +639,38 @@ export function createSettings(db) {
573
639
  return true;
574
640
  },
575
641
 
642
+ /**
643
+ * 绑会话卡(#606):按 sessionId 存/删/列。形状与 setPersonaFor 一族逐一对应,
644
+ * 只是换了一个维度——为什么需要第二维见 parsePersonaStore 的注释。
645
+ * sessionId 为空或 "default" 拒绝(与 byAgent 同一条纪律:兜底位不可被顶替)。
646
+ */
647
+ setPersonaForSession(sessionId, card) {
648
+ const id = typeof sessionId === "string" && sessionId.trim() ? sessionId.trim() : "";
649
+ if (!id || id === "default") return false;
650
+ const store = parsePersonaStore(getSetting("persona") ?? "{}");
651
+ const clean = parsePersona(JSON.stringify(
652
+ typeof card === "object" && card !== null && !Array.isArray(card) ? card : {}
653
+ ));
654
+ if (Object.keys(clean).length) store.bySession[id] = clean;
655
+ else delete store.bySession[id];
656
+ setSetting("persona", serializePersonaStore(store));
657
+ return true;
658
+ },
659
+ /** bySession 键列表(面板「按会话建的卡」用,default 不在其中)。 */
660
+ listPersonaSessions() {
661
+ return Object.keys(parsePersonaStore(getSetting("persona") ?? "{}").bySession);
662
+ },
663
+ /** 删一张 bySession 卡;不存在返回 false。 */
664
+ removePersonaForSession(sessionId) {
665
+ const id = typeof sessionId === "string" && sessionId.trim() ? sessionId.trim() : "";
666
+ if (!id || id === "default") return false;
667
+ const store = parsePersonaStore(getSetting("persona") ?? "{}");
668
+ if (!(id in store.bySession)) return false;
669
+ delete store.bySession[id];
670
+ setSetting("persona", serializePersonaStore(store));
671
+ return true;
672
+ },
673
+
576
674
  /** All custom commands, sorted by name. */
577
675
  listCommands() {
578
676
  const rows = db.prepare("SELECT * FROM custom_commands ORDER BY name ASC").all();