@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/store.js CHANGED
@@ -249,6 +249,36 @@ CREATE TABLE IF NOT EXISTS conflict_reviewed (
249
249
  );
250
250
  CREATE INDEX IF NOT EXISTS idx_conflict_reviewed_judged ON conflict_reviewed(judged_at);
251
251
 
252
+ -- conflict_ledger: 冲突分诊账本(issue #641 beta 第一阶段,shadow 档)。conflict
253
+ -- 相位的每一对候选在送进 freeze/LLM 之前先过确定性分诊(dup / cross-scope-dup /
254
+ -- evolution / conflict,判据唯一实现见 src/dream/triage.js),分档结果落在这里。
255
+ -- shadow 档只记账不改任何行为——它存在的目的就是让「分诊分布是否复现真实库的
256
+ -- 46/20/5/27」这件事在真实运行里可测,切 enforce 前先有一周的真实分布可对。
257
+ --
258
+ -- 为什么不复用 conflict_reviewed:那张表的语义是「这一对被机器**裁决**过」,键的
259
+ -- 消费方(alreadyJudged 预筛)拿它挡重送;本表语义是「这一对被分诊**观察**过」,
260
+ -- 挡重送反而错误——同一对每轮都要重新观察(两侧内容变了 tier 可能变),它不是
261
+ -- 处置记录,是测量记录。也不复用 conflict_pending(人工队列,见上表注释)。
262
+ --
263
+ -- 主键归一化后的对,与 conflict_reviewed 同一口径;ON CONFLICT 只刷新 tier/
264
+ -- signals/last_*,first_seen_at/first_run_id 保留首次检出时刻——同召率与「挂账
265
+ -- 时长」这类惰性消解指标都以此为准。status 是 enforce 阶段消解落账的方向
266
+ -- (open → superseded / differentiated / zombied),shadow 期恒为 open。
267
+ CREATE TABLE IF NOT EXISTS conflict_ledger (
268
+ memory_a TEXT NOT NULL,
269
+ memory_b TEXT NOT NULL,
270
+ tier TEXT NOT NULL, -- dup | cross-scope-dup | evolution | conflict
271
+ similarity REAL,
272
+ signals TEXT, -- JSON: 分诊依据(crossScope/annotated/gapDays/dupMin/similarity)
273
+ status TEXT NOT NULL DEFAULT 'open',
274
+ first_run_id TEXT,
275
+ last_run_id TEXT,
276
+ first_seen_at TEXT NOT NULL,
277
+ last_seen_at TEXT NOT NULL,
278
+ PRIMARY KEY (memory_a, memory_b)
279
+ );
280
+ CREATE INDEX IF NOT EXISTS idx_conflict_ledger_status ON conflict_ledger(status);
281
+
252
282
  -- scope_changes: v0.8.1 底座(issue #170)scope 归属的人工修正审计。显式声明
253
283
  -- (memory_save/memory_update 的 scope 参数、面板编辑)每次改变某条记忆的
254
284
  -- agent/workspace 归属都落一行:改动前后值 + 来源 + actor(tool=模型侧 /
@@ -432,7 +462,10 @@ CREATE INDEX IF NOT EXISTS idx_distill_runs_created ON distill_runs(created_at);
432
462
  -- · 本表 = 行级,「这条记忆出生自哪一轮」,覆盖没有窗口的铸造路径。
433
463
  -- 只填 **create** 一种动作:merge/conflict/update/supersede 对既有行的改动,receipt_chain
434
464
  -- 已经按 (run_id, record_id) 记过了——再造一份就是第二套账。create 是那边唯一的洞
435
- -- (buildOutcome 刻意不给 create 派 disposition,receipt_chain 也就不产行)。
465
+ -- (buildRecordReceipts 不给 create/create_merged 产行:新行没有「改动前后的账」可记,
466
+ -- 只有「出生自哪一轮」,那正是本表的事)。#619 起 buildOutcome 会给 create 派
467
+ -- disposition(created / created-refreshed),那是审计面读的结果,与 receipt_chain
468
+ -- 产不产行是两件事;created-refreshed 是原地刷新,没有出生,本表也不记。
436
469
  -- run_type / trigger_source 就地冗余:run_id 在 dream_runs 与 distill_runs 两张表里
437
470
  -- 各有自己的主键域,读侧不该为了显示一个类型去 union 两张审计表。
438
471
  -- ON DELETE CASCADE:记忆行没了,归因没有指代对象;remove() 仍显式清(与
@@ -607,7 +640,12 @@ CREATE TABLE IF NOT EXISTS choro_pages (
607
640
  git_baseline TEXT,
608
641
  template_version TEXT,
609
642
  generated_at TEXT,
610
- human_block_count INTEGER NOT NULL DEFAULT 0
643
+ human_block_count INTEGER NOT NULL DEFAULT 0,
644
+ -- 语义段的「对着哪个提交验证的」(openwiki/deepwiki-rs 式的锚点溯源):只有 T1 写,
645
+ -- T0 重建整页时随 T1 正文一起归 NULL。全仓 git_baseline 是 T0 的时点,拿它当 claims
646
+ -- 的比对起点会把「T1 之前就发生、claim 早已算进去」的改动误报成 touched。
647
+ verified_baseline TEXT,
648
+ verified_at TEXT
611
649
  );
612
650
  CREATE UNIQUE INDEX IF NOT EXISTS idx_choro_pages_module ON choro_pages(module_id);
613
651
 
@@ -706,6 +744,59 @@ CREATE TABLE IF NOT EXISTS profile_fact_runs (
706
744
  finished_at TEXT
707
745
  );
708
746
  CREATE INDEX IF NOT EXISTS idx_profile_fact_runs_card ON profile_fact_runs(agent_key, started_at);
747
+
748
+ -- ============================================================
749
+ -- knowledge base(知识库第二层,spec: docs/plans/2026-11-08-kb-qoder-parity.md §3.3)。
750
+ -- 与记忆行物理隔离的硬边界同 choro 那组表:kb 的行永不进 memories /
751
+ -- saveWithDedupe / dream / 质量过滤的任何池子。这里存的是「用户/agent 主动
752
+ -- 收进知识库的资料」——文件、网页、仓库地志页的**副本**与索引,天生属于外来
753
+ -- 原文,让巩固流程够得着它就会把用户刚存的手册自己归档掉。
754
+ -- 幂等键是 (collection_id, rel_key):同一来源重入库按 sha256 判 no-op,
755
+ -- 变了才替换 chunks(替换语义在 replaceKbDoc 里,事务包住删除与插入)。
756
+ -- rel_key 是来源的稳定相对键(本地文件=绝对路径、网页=URL、地志页=repo#slug),
757
+ -- 不是展示名——展示名在 kb_docs.title。
758
+ CREATE TABLE IF NOT EXISTS kb_collections (
759
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
760
+ key TEXT NOT NULL,
761
+ title TEXT NOT NULL,
762
+ description TEXT,
763
+ created_at TEXT NOT NULL,
764
+ updated_at TEXT NOT NULL
765
+ );
766
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_kb_collections_key ON kb_collections(key);
767
+
768
+ -- kind 限定三类(§3.3):file | url | choro_page。sha256 是**正文内容**的哈希,
769
+ -- 不是文件 stat 的指纹——文件 mtime 变了内容没变时重入库必须是 no-op,
770
+ -- 否则每次保存键盘敲一下都全量重切块。
771
+ CREATE TABLE IF NOT EXISTS kb_docs (
772
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
773
+ collection_id INTEGER NOT NULL,
774
+ rel_key TEXT NOT NULL,
775
+ kind TEXT NOT NULL,
776
+ source_path TEXT,
777
+ title TEXT NOT NULL,
778
+ bytes INTEGER NOT NULL DEFAULT 0,
779
+ sha256 TEXT,
780
+ indexed_at TEXT NOT NULL,
781
+ status TEXT NOT NULL DEFAULT 'ok',
782
+ error TEXT
783
+ );
784
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_kb_docs_collection_relkey ON kb_docs(collection_id, rel_key);
785
+
786
+ -- 分块正文。heading 是块所属的二级标题节(无节则空串),ordinal 是块在
787
+ -- 文档内的序号——检索命中要能回指「第几块、哪一节」,面板/工具靠它跳段落。
788
+ -- text 存原文切片(含分块时带的标题行?不含:标题在 heading 里,text 只放正文,
789
+ -- 免得同一条命中把标题算两遍)。
790
+ CREATE TABLE IF NOT EXISTS kb_chunks (
791
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
792
+ doc_id INTEGER NOT NULL,
793
+ ordinal INTEGER NOT NULL,
794
+ heading TEXT NOT NULL DEFAULT '',
795
+ text TEXT NOT NULL,
796
+ created_at TEXT NOT NULL
797
+ );
798
+ CREATE INDEX IF NOT EXISTS idx_kb_chunks_doc_ordinal ON kb_chunks(doc_id, ordinal);
799
+
709
800
  `;
710
801
 
711
802
  // Exported for API-layer type validation (standalone API POST /memories and
@@ -864,6 +955,10 @@ function toRow(row) {
864
955
  // isImportedRow)——这两格只回答「来自哪个包 / 幂等键是什么」,不回答身份。
865
956
  share_pack_id: row.share_pack_id ?? undefined,
866
957
  share_external_id: row.share_external_id ?? undefined,
958
+ // Issue #641 取代链:NULL 读作 undefined(未被取代),与 archived_at 同款
959
+ // 「时刻不可知就不编值」。
960
+ superseded_by: row.superseded_by ?? undefined,
961
+ superseded_at: row.superseded_at ?? undefined,
867
962
  content_history: parseJsonArray(row.content_history),
868
963
  evidence: parseJsonArray(row.evidence),
869
964
  doc_path: row.doc_path ?? undefined,
@@ -960,6 +1055,10 @@ function toConflictPending(row) {
960
1055
  memory_a: row.memory_a,
961
1056
  memory_b: row.memory_b,
962
1057
  reason: row.reason ?? undefined,
1058
+ // Issue #603 第 2 层:入队时的相似度。存量行与调用方没给的行是 NULL,
1059
+ // 这里如实透出 undefined(条件展开:带 undefined 键会让 in-process schema
1060
+ // 校验判违规,同 toApiList 的处理)。
1061
+ ...(typeof row.similarity === "number" ? { similarity: row.similarity } : {}),
963
1062
  created_at: row.created_at,
964
1063
  resolved_at: row.resolved_at ?? undefined,
965
1064
  resolved_winner: row.resolved_winner ?? undefined
@@ -1232,6 +1331,10 @@ export function createStore(path) {
1232
1331
  // chorography(M2,实现计划 P17):T1 生成轮的页级失败计数。T0 建的表没有它
1233
1332
  // (M0 只写 skeleton),存量库经 PRAGMA 检查后补列,幂等。
1234
1333
  addColumn("choro_pages", "fail_count", "ALTER TABLE choro_pages ADD COLUMN fail_count INTEGER NOT NULL DEFAULT 0");
1334
+ // chorography ①(语义段 provenance):页级验证基线 + 验证时刻。可空不带 DEFAULT——
1335
+ // 存量行的 NULL 就是事实(那批页富化时还没记这个),不要伪造一个提交号。
1336
+ addColumn("choro_pages", "verified_baseline", "ALTER TABLE choro_pages ADD COLUMN verified_baseline TEXT");
1337
+ addColumn("choro_pages", "verified_at", "ALTER TABLE choro_pages ADD COLUMN verified_at TEXT");
1235
1338
  // chorography 收尾档(P36):v0.11.0 的 choro_vectors 是空壳表(spec §5 漏了
1236
1339
  // 向量本体列),存量库经 PRAGMA 检查补列。
1237
1340
  addColumn("choro_vectors", "vector", "ALTER TABLE choro_vectors ADD COLUMN vector TEXT");
@@ -1274,6 +1377,15 @@ export function createStore(path) {
1274
1377
  // 唯一性(第二条本地行就撞响),而存量行零重写、读侧 undefined = 「不是外来行」。
1275
1378
  addColumn("memories", "share_pack_id", "ALTER TABLE memories ADD COLUMN share_pack_id TEXT");
1276
1379
  addColumn("memories", "share_external_id", "ALTER TABLE memories ADD COLUMN share_external_id TEXT");
1380
+ // Issue #641(beta 第三阶段,取代链):T3(时序演进)的处置不归档——旧行写
1381
+ // superseded_by 指向取代它的新行,检索侧降权、注入不再选中,但行本身保持
1382
+ // 活跃、随时可人工恢复。存量行两列皆 NULL:历史裁决走的是「归档」路径,
1383
+ // 反推不出该指给谁(receipt_chain 有 winner/loser,但那是另一套语义),宁可
1384
+ // 不填也不猜。superseded_at 是裁决时刻(服务器单调时钟),与内容时间无关。
1385
+ addColumn("memories", "superseded_by", "ALTER TABLE memories ADD COLUMN superseded_by TEXT");
1386
+ addColumn("memories", "superseded_at", "ALTER TABLE memories ADD COLUMN superseded_at TEXT");
1387
+ // 部分索引:降权查询只关心「已被取代」的行;未取代行(绝大多数)不进索引。
1388
+ db.exec("CREATE INDEX IF NOT EXISTS idx_memories_superseded ON memories(superseded_by) WHERE superseded_by IS NOT NULL;");
1277
1389
  // 索引建在加列之后而非写进 SCHEMA(本文件 content_hash 那段:存量库打开时 CREATE TABLE
1278
1390
  // 是 no-op,列还不存在就建索引会直接 no such column)。
1279
1391
  // 部分索引(WHERE ... IS NOT NULL)是刻意的,但它承重的地方要说准:SQLite 的 UNIQUE
@@ -1493,6 +1605,18 @@ export function createStore(path) {
1493
1605
  addColumn("memory_provenance", "distill_run_id", "ALTER TABLE memory_provenance ADD COLUMN distill_run_id TEXT");
1494
1606
  db.exec("CREATE INDEX IF NOT EXISTS idx_memory_provenance_run ON memory_provenance(distill_run_id);");
1495
1607
 
1608
+ // Issue #603 第 2 层:冲突待审队列此前只能按 created_at 倒序排。一次 sleep 轮次
1609
+ // 里产出的对全部落在同一毫秒(实测 60 条挤在 60ms 内),排序键全同,队列在面板
1610
+ // 上看起来是随机的——而「哪一对最该先看」的唯一客观信号是相似度,此前只存在
1611
+ // reason 文本里(「相似度 0.87」),前端靠正则回收。落成列之后排序键是数据,
1612
+ // 不是解析结果。
1613
+ //
1614
+ // 可空且不带 DEFAULT:存量行没有这个信息,NULL = 「入队时没记」(比填 0 诚实——
1615
+ // 0 会被排到最后,等于替用户断言「这两条毫不相似」)。排序用 NULLS LAST。
1616
+ addColumn("conflict_pending", "similarity", "ALTER TABLE conflict_pending ADD COLUMN similarity REAL");
1617
+ // 索引建在加列之后(同 idx_memory_provenance_run 的理由):老库打开时列才刚加上。
1618
+ db.exec("CREATE INDEX IF NOT EXISTS idx_conflict_pending_similarity ON conflict_pending(similarity DESC, created_at DESC);");
1619
+
1496
1620
  // Audit peer F: a legacy DB may hold a non-integer generation/applied_generation
1497
1621
  // (pre-v0.3.9 the JS gate truncated with Math.trunc and SQLite's CHECK only
1498
1622
  // enforced >= 0). Such a value is ambiguous — it cannot map to a real applied
@@ -1714,13 +1838,12 @@ export function createStore(path) {
1714
1838
  return db.prepare("DELETE FROM distill_parse_failures WHERE session_id = ?").run(sessionId).changes;
1715
1839
  }
1716
1840
 
1717
- function count(type, { minImportance = null, source = null, includeForgotten = false, includeArchived = false, onlyArchived = false, depositedOnly = false, updatedFrom = null, updatedTo = null, occurredFrom = null, occurredTo = null, visibility = null } = {}) {
1718
- const clauses = [];
1719
- const params = [];
1720
- if (type !== undefined) {
1721
- clauses.push("type = ?");
1722
- params.push(type);
1723
- }
1841
+ // #597:count() 与 countByType() 共用的 WHERE 构造——两张 HTTP 面(宿主内 /list
1842
+ // 与 Bearer /memories)的 total 与 typeCounts 必须同口径,分布的合计才等于总数。
1843
+ // type 刻意不在这里:count() 按调用方带,countByType() 刻意不带(它要的就是
1844
+ // 同一份筛选下、**不带类型**的全库分布——面板分类栏被 type 筛过的页上数不出它)。
1845
+ function pushCountFilters(opts, clauses, params) {
1846
+ const { minImportance = null, source = null, includeForgotten = false, includeArchived = false, onlyArchived = false, depositedOnly = false, updatedFrom = null, updatedTo = null, occurredFrom = null, occurredTo = null, visibility = null } = opts ?? {};
1724
1847
  // Same filters as list() so a paged caller's total matches its rows.
1725
1848
  if (minImportance != null) {
1726
1849
  clauses.push("importance >= ?");
@@ -1786,10 +1909,32 @@ export function createStore(path) {
1786
1909
  "(id IN (SELECT record_id FROM receipt_chain WHERE kind IN ('merge', 'update') AND verdict = 'live') OR source = 'dream')"
1787
1910
  );
1788
1911
  }
1912
+ }
1913
+
1914
+ function count(type, opts = {}) {
1915
+ const clauses = [];
1916
+ const params = [];
1917
+ if (type !== undefined) {
1918
+ clauses.push("type = ?");
1919
+ params.push(type);
1920
+ }
1921
+ pushCountFilters(opts, clauses, params);
1789
1922
  const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
1790
1923
  return db.prepare(`SELECT count(*) AS c FROM memories ${where}`).get(...params).c;
1791
1924
  }
1792
1925
 
1926
+ // #597(/list 的 typeCounts):同一份筛选、不带 type,一次 GROUP BY 拿全库分布。
1927
+ // 面板分类栏的数字源:被 type 筛过又分过页的已加载行上数不出真分布。GROUP BY
1928
+ // 只回存在的类,缺类即 0;分布合计恒等于同筛选的 count(undefined)。
1929
+ function countByType(opts = {}) {
1930
+ const clauses = [];
1931
+ const params = [];
1932
+ pushCountFilters(opts, clauses, params);
1933
+ const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
1934
+ const rows = db.prepare(`SELECT type, count(*) AS c FROM memories ${where} GROUP BY type`).all(...params);
1935
+ return Object.fromEntries(rows.map((r) => [r.type, r.c]));
1936
+ }
1937
+
1793
1938
  function getById(id) {
1794
1939
  const row = db.prepare("SELECT * FROM memories WHERE id = ?").get(id);
1795
1940
  return toRow(row);
@@ -2575,6 +2720,30 @@ export function createStore(path) {
2575
2720
  return needsEmbeddingFor(modelHash, 1).length > 0;
2576
2721
  }
2577
2722
 
2723
+ /**
2724
+ * How many rows needsEmbeddingFor() would pick in total.
2725
+ *
2726
+ * A re-index run is bounded by a limit, so "indexed: 0" and "indexed: 3 of
2727
+ * 300" looked identical to the caller. Counting is cheap (no content is
2728
+ * materialised) and is what lets the caller say how much work is left.
2729
+ */
2730
+ function countNeedsEmbeddingFor(modelHash) {
2731
+ if (!modelHash) {
2732
+ return db.prepare(
2733
+ `SELECT count(*) AS c FROM memories
2734
+ WHERE (embedding IS NULL OR embedding = '')
2735
+ AND forgotten = 0 AND archived = 0`
2736
+ ).get().c;
2737
+ }
2738
+ return db.prepare(
2739
+ `SELECT count(*) AS c FROM memories
2740
+ WHERE ((embedding IS NULL OR embedding = '')
2741
+ OR embedding_model IS NULL
2742
+ OR embedding_model != ?)
2743
+ AND forgotten = 0 AND archived = 0`
2744
+ ).get(String(modelHash)).c;
2745
+ }
2746
+
2578
2747
  /** Per-model provenance counts over active rows ('' = unknown origin). */
2579
2748
  function embeddingModelBreakdown() {
2580
2749
  return db.prepare(
@@ -3650,6 +3819,18 @@ export function createStore(path) {
3650
3819
  return { path: typeof path === "string" ? path : null, pageSize, pageCount, freelistCount, fileBytes };
3651
3820
  }
3652
3821
 
3822
+ /**
3823
+ * 容量预警的行数口径(#275 空间维度):活跃 / 归档各一格。体积与空闲页在
3824
+ * storageStats(),这里只补行数——两条 COUNT 的成本,挂在 /health 的按需读上,
3825
+ * 没有周期性消费者。归档增速(净增速率)是 recall-stats 的 archive 块(第五
3826
+ * 指标,按精确 archived_at 算),这里刻意不算速率:同一个数字长两处就会分叉。
3827
+ */
3828
+ function capacityStats() {
3829
+ const active = db.prepare("SELECT COUNT(*) AS n FROM memories WHERE archived = 0").get().n;
3830
+ const archived = db.prepare("SELECT COUNT(*) AS n FROM memories WHERE archived = 1").get().n;
3831
+ return { active, archived };
3832
+ }
3833
+
3653
3834
  /**
3654
3835
  * 整库 VACUUM(#275 的手动步骤):代价 O(库大小) 且需要排他写锁,所以只在显式
3655
3836
  * 入口里跑,绝不挂启动路径、也不开 auto_vacuum。
@@ -3712,7 +3893,10 @@ export function createStore(path) {
3712
3893
  * pending once — a re-detection in a later dream run is a no-op, never a
3713
3894
  * duplicate queue entry. Returns the pending row (freshly inserted, or the
3714
3895
  * existing unresolved row when the pair is already pending), or undefined
3715
- * when the re-detection is suppressed.
3896
+ * when the re-detection is suppressed. The returned row carries `fresh`:
3897
+ * true only when this call actually inserted a row, false when the pair was
3898
+ * already pending (issue #603 — callers used to read "non-null" as "parked",
3899
+ * so a re-detection re-reported the same pair as a new freeze every run).
3716
3900
  *
3717
3901
  * v0.8.1(issue #170 复核项 4):人工已裁决过的对(resolved_at 非空)不再
3718
3902
  * 反复入队——除非任一侧在裁决之后又被改过(updated_at 晚于 resolved_at)。
@@ -3720,12 +3904,12 @@ export function createStore(path) {
3720
3904
  * updated_at 是宽松代理(任何字段更新都会触发重新评审)——宁可多看一眼,
3721
3905
  * 不静音真冲突。
3722
3906
  */
3723
- function saveConflictPending({ run_id, memory_a, memory_b, reason }) {
3907
+ function saveConflictPending({ run_id, memory_a, memory_b, reason, similarity }) {
3724
3908
  const [a, b] = [memory_a, memory_b].sort();
3725
3909
  const existing = db.prepare(
3726
3910
  "SELECT * FROM conflict_pending WHERE memory_a = ? AND memory_b = ? AND resolved_at IS NULL LIMIT 1"
3727
3911
  ).get(a, b);
3728
- if (existing) return toConflictPending(existing);
3912
+ if (existing) return { ...toConflictPending(existing), fresh: false };
3729
3913
  const reviewed = db.prepare(
3730
3914
  "SELECT * FROM conflict_pending WHERE memory_a = ? AND memory_b = ? AND resolved_at IS NOT NULL ORDER BY resolved_at DESC, rowid DESC LIMIT 1"
3731
3915
  ).get(a, b);
@@ -3737,16 +3921,29 @@ export function createStore(path) {
3737
3921
  }
3738
3922
  const id = randomUUID();
3739
3923
  const now = nowIso();
3924
+ // Issue #603 第 2 层:相似度落列。调用方拿不到(LLM 裁决路径只有理由文本)
3925
+ // 就写 NULL,不猜——面板与排序都按「未知」处理,而不是当成 0。
3926
+ const sim = typeof similarity === "number" && Number.isFinite(similarity)
3927
+ && similarity >= 0 && similarity <= 1 ? similarity : null;
3740
3928
  db.prepare(
3741
- `INSERT INTO conflict_pending (id, run_id, memory_a, memory_b, reason, created_at)
3742
- VALUES (?, ?, ?, ?, ?, ?)`
3743
- ).run(id, run_id ?? null, a, b, reason ?? null, now);
3744
- return toConflictPending(db.prepare("SELECT * FROM conflict_pending WHERE id = ?").get(id));
3929
+ `INSERT INTO conflict_pending (id, run_id, memory_a, memory_b, reason, similarity, created_at)
3930
+ VALUES (?, ?, ?, ?, ?, ?, ?)`
3931
+ ).run(id, run_id ?? null, a, b, reason ?? null, sim, now);
3932
+ return { ...toConflictPending(db.prepare("SELECT * FROM conflict_pending WHERE id = ?").get(id)), fresh: true };
3745
3933
  }
3746
3934
 
3747
3935
  /**
3748
- * List pending conflicts, newest first. Unresolved rows only by default;
3749
- * pass includeResolved to include resolved ones (audit view).
3936
+ * List pending conflicts, most-similar first. Unresolved rows only by
3937
+ * default; pass includeResolved to include resolved ones (audit view).
3938
+ *
3939
+ * Issue #603 第 2 层:排序键从「created_at DESC」改成「相似度 DESC → created_at
3940
+ * DESC」。原键在同一次 sleep 轮次里全同(实测 60 条落在 60ms 内),队列在面板
3941
+ * 上等于随机;相似度是「该先看哪对」的唯一客观信号,现在它是一列而不是 reason
3942
+ * 文本里的正则。
3943
+ *
3944
+ * NULL 排最后而不是当 0:存量行与 LLM 裁决路径没记相似度,把它们混进「低相似
3945
+ * 度」会把「未知」伪装成「已测量」。`(similarity IS NULL)` 前缀让这条不依赖
3946
+ * SQLite 的 NULL 排序约定,两个方向都稳。
3750
3947
  */
3751
3948
  function listConflictPending({ limit = 50, offset = 0, includeResolved = false } = {}) {
3752
3949
  const { limit: lim, offset: off } = sanitizePage(limit, offset, 50);
@@ -3755,7 +3952,9 @@ export function createStore(path) {
3755
3952
  if (!includeResolved) clauses.push("resolved_at IS NULL");
3756
3953
  const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
3757
3954
  const rows = db.prepare(
3758
- `SELECT * FROM conflict_pending ${where} ORDER BY created_at DESC, id LIMIT ? OFFSET ?`
3955
+ `SELECT * FROM conflict_pending ${where}
3956
+ ORDER BY (similarity IS NULL), similarity DESC, created_at DESC, id
3957
+ LIMIT ? OFFSET ?`
3759
3958
  ).all(...params, lim, off);
3760
3959
  return rows.map(toConflictPending);
3761
3960
  }
@@ -3882,6 +4081,137 @@ export function createStore(path) {
3882
4081
  ).all(Math.max(1, Math.min(5000, limit | 0)));
3883
4082
  }
3884
4083
 
4084
+ // --- conflict triage ledger(issue #641,shadow 档)-----------------------
4085
+
4086
+ // 四态里 superseded(已被取代)与 open 有写入点;differentiated(人审判为不同事、
4087
+ // 不必再问)与 zombied(两条都已退场、无从消解)是预留位,当前没有任何代码路径
4088
+ // 会写它们——留着是为了状态机与面板筛选口径完整,别当成「已实现但没数据」。
4089
+ const LEDGER_STATUSES = new Set(["open", "superseded", "differentiated", "zombied"]);
4090
+ const LEDGER_TIERS = new Set(["dup", "cross-scope-dup", "evolution", "conflict"]);
4091
+
4092
+ /**
4093
+ * Record one triage observation. Upsert on the normalized pair: tier/
4094
+ * signals/last_* refresh (the pair's classification may legitimately change
4095
+ * after an edit), while first_seen_at/first_run_id stay put — 挂账时长与
4096
+ * 消解路径统计都锚在首次检出,刷新它们等于重写历史。
4097
+ */
4098
+ function saveConflictLedgerEntry({ run_id, memory_a, memory_b, tier, similarity, signals }) {
4099
+ const [a, b] = [memory_a, memory_b].sort();
4100
+ if (!LEDGER_TIERS.has(tier)) {
4101
+ throw new Error(`conflict_ledger: unknown tier ${String(tier)}`);
4102
+ }
4103
+ const now = nowIso();
4104
+ db.prepare(
4105
+ `INSERT INTO conflict_ledger (memory_a, memory_b, tier, similarity, signals, status, first_run_id, last_run_id, first_seen_at, last_seen_at)
4106
+ VALUES (?, ?, ?, ?, ?, 'open', ?, ?, ?, ?)
4107
+ ON CONFLICT(memory_a, memory_b) DO UPDATE SET
4108
+ tier = excluded.tier,
4109
+ similarity = excluded.similarity,
4110
+ signals = excluded.signals,
4111
+ last_run_id = excluded.last_run_id,
4112
+ last_seen_at = excluded.last_seen_at`
4113
+ ).run(
4114
+ a, b, tier,
4115
+ typeof similarity === "number" ? similarity : null,
4116
+ // 与 differentiated_pairs.distinctions 同一条约定:没有就落 NULL,不落 "[]"。
4117
+ signals == null ? null : JSON.stringify(signals),
4118
+ run_id ?? null, run_id ?? null, now, now
4119
+ );
4120
+ }
4121
+
4122
+ /** Status transition for the lazy-resolution stages (enforce-phase wiring). */
4123
+ function setConflictLedgerStatus(memory_a, memory_b, status) {
4124
+ const [a, b] = [memory_a, memory_b].sort();
4125
+ if (!LEDGER_STATUSES.has(status)) {
4126
+ throw new Error(`conflict_ledger: unknown status ${String(status)}`);
4127
+ }
4128
+ const res = db.prepare(
4129
+ "UPDATE conflict_ledger SET status = ? WHERE memory_a = ? AND memory_b = ?"
4130
+ ).run(status, a, b);
4131
+ return res.changes > 0;
4132
+ }
4133
+
4134
+ /**
4135
+ * 取代链落笔(issue #641,T3 时序演进的处置):旧行 superseded_by → 新行。
4136
+ * 只动这两列,刻意不走 updateMemory 的完整写路径——syncMirror/notifyWrite/
4137
+ * updated_at 都不该被一个纯元数据标注触发(正文一个字节没变,镜像无 diff
4138
+ * 可同步;bump updated_at 反而会误导「已审过」的宽松代理判定)。已归档/
4139
+ * 已遗忘的行不落链(它们已经退场,再标注是给幽灵记账);重复写同一指向
4140
+ * 是无害的幂等 no-op,换指向以最后一次为准。
4141
+ *
4142
+ * 幂等守卫(#650 评审):上面那句「无害的 no-op」只对指向成立——superseded_at
4143
+ * 每次都会刷成新的 nowIso(),返回值又按 changes > 0 判,于是同一对每轮都报
4144
+ * 「刚处理过」:t3Chained 虚增,取代时刻被改写成最近一次巩固的时间(挂账时长
4145
+ * 一类指标随之失真)。先读当前指向,已指向同一 winner 就直接返回 false 且不
4146
+ * 写库,只有首次落链或换指向才真正落笔。
4147
+ */
4148
+ function supersedeChain(loserId, winnerId) {
4149
+ const current = db.prepare("SELECT superseded_by FROM memories WHERE id = ?").get(loserId);
4150
+ // 行不存在 / 已指向同一继任者:no-op,不刷时间戳、不计入变更数。
4151
+ if (!current || current.superseded_by === winnerId) return false;
4152
+ const res = db.prepare(
4153
+ "UPDATE memories SET superseded_by = ?, superseded_at = ? WHERE id = ? AND archived = 0 AND forgotten = 0"
4154
+ ).run(winnerId, nowIso(), loserId);
4155
+ return res.changes > 0;
4156
+ }
4157
+
4158
+ /**
4159
+ * 已被取代行的 id 清单(issue #641,T3 检索侧降权用)。走部分索引,未落链
4160
+ * 的库上是空查询。返回数组而非 Set——集合语义由调用方决定。
4161
+ *
4162
+ * 只认「继任者仍活着」的旧行(#650 评审):链是一次性写下的指向,而继任者
4163
+ * 之后仍可能被归档/遗忘/删除。若只判 superseded_by IS NOT NULL,那种情况下
4164
+ * 旧行会被当成「已有新版本」从注入里摘掉,可它自己明明是活的、且它指向的
4165
+ * 那条已经退场——数据在库里却永远不再出现,是一种沉默的不可逆失效。JOIN 到
4166
+ * 继任者上要求 archived=0 且 forgotten=0,继任者退场即自动恢复旧行可见性。
4167
+ */
4168
+ function supersededIds() {
4169
+ return db.prepare(
4170
+ `SELECT m.id AS id
4171
+ FROM memories m
4172
+ JOIN memories w ON w.id = m.superseded_by
4173
+ WHERE m.superseded_by IS NOT NULL
4174
+ AND w.archived = 0
4175
+ AND w.forgotten = 0`
4176
+ ).all().map((r) => r.id);
4177
+ }
4178
+
4179
+ /**
4180
+ * 仍未消解的真矛盾对(issue #641,T4 注入侧感知用)。只取 tier=conflict 且
4181
+ * status=open 的行——superseded/differentiated 已消解,zombied 已被 heat 兜底;
4182
+ * shadow 档记下的观察对也在其中(注入侧标注以 enforce 档为门,见 injectCandidates)。
4183
+ * 按最近观察排序,上限 2000:账本对的量级是个位数到几十,上限只为防御性兜底。
4184
+ */
4185
+ function openConflictPairs(limit = 2000) {
4186
+ return db.prepare(
4187
+ "SELECT memory_a, memory_b, first_seen_at AS detected FROM conflict_ledger WHERE tier = 'conflict' AND status = 'open' ORDER BY last_seen_at DESC LIMIT ?"
4188
+ ).all(Math.max(1, Math.min(2000, limit | 0)));
4189
+ }
4190
+
4191
+ /** Triage rows for the panel/analysis views, newest detection first. */
4192
+ function listConflictLedger({ limit = 200, offset = 0, tier, status } = {}) {
4193
+ const lim = Math.max(1, Math.min(5000, limit | 0));
4194
+ const off = Math.max(0, offset | 0);
4195
+ const where = [];
4196
+ const args = [];
4197
+ if (tier != null) { where.push("tier = ?"); args.push(String(tier)); }
4198
+ if (status != null) { where.push("status = ?"); args.push(String(status)); }
4199
+ const clause = where.length ? ` WHERE ${where.join(" AND ")}` : "";
4200
+ args.push(lim, off);
4201
+ return db.prepare(
4202
+ `SELECT * FROM conflict_ledger${clause} ORDER BY last_seen_at DESC, rowid DESC LIMIT ? OFFSET ?`
4203
+ ).all(...args);
4204
+ }
4205
+
4206
+ function countConflictLedger({ tier, status } = {}) {
4207
+ const where = [];
4208
+ const args = [];
4209
+ if (tier != null) { where.push("tier = ?"); args.push(String(tier)); }
4210
+ if (status != null) { where.push("status = ?"); args.push(String(status)); }
4211
+ const clause = where.length ? ` WHERE ${where.join(" AND ")}` : "";
4212
+ return db.prepare(`SELECT COUNT(*) AS n FROM conflict_ledger${clause}`).get(...args).n;
4213
+ }
4214
+
3885
4215
  /**
3886
4216
  * v0.8.1 底座(issue #170):scope 归属人工修正的审计行。record 描述「一次
3887
4217
  * 决策后的完整状态」(prev_* 为改前、next_* 为改后,NULL=global/未标注),
@@ -4265,6 +4595,26 @@ export function createStore(path) {
4265
4595
  return result.changes > 0;
4266
4596
  }
4267
4597
 
4598
+ /**
4599
+ * 覆盖一条关系行的 metadata(#619)。关系本身仍按 saveRelation 追加,
4600
+ * 这条只给「同一条边被后续观察补充证据」用——情感相位判重命中时要把
4601
+ * evidence 并集与 sessionCount 写回既有边,否则边上的场合数会永远停在
4602
+ * 第一次写入那一刻。只改 metadata,不动 weight/source/created_at:这条边的
4603
+ * 来源与权重反映的是它第一次被建立时的事实。
4604
+ * 幂等(缺行 changes=0 → false)。
4605
+ */
4606
+ function updateRelationMetadata(id, metadata) {
4607
+ const metaStr = metadata === undefined
4608
+ ? null
4609
+ : typeof metadata === "string"
4610
+ ? metadata
4611
+ : JSON.stringify(metadata);
4612
+ const result = db.prepare(
4613
+ "UPDATE entity_relations SET metadata = ? WHERE id = ?"
4614
+ ).run(metaStr, id);
4615
+ return result.changes > 0;
4616
+ }
4617
+
4268
4618
  /**
4269
4619
  * Re-point every attr row whose memory_id is fromMemoryId to toMemoryId
4270
4620
  * (autoDream merge migration, v0.3.0 Phase 4 / 4.3.2). When the keeper
@@ -4498,13 +4848,58 @@ export function createStore(path) {
4498
4848
  ).all(memoryId).map(toRelation);
4499
4849
  }
4500
4850
 
4851
+ /**
4852
+ * 按关系类型取全部边(#619 可见化 C)。情感边散在 23k 个实体之间,`ego` 端点
4853
+ * 是「从某个实体往外看」,天然不适合回答「这库里一共记了哪些情绪」——那要的
4854
+ * 是反向的、全局的一眼。所以单开一条按类型全表取,一次 SQL,不做 N 次往返。
4855
+ *
4856
+ * 只按类型过滤,不在 SQL 里碰 metadata:情绪/归属/场合数都由调用方从 metadata
4857
+ * 里读,SQL 里拼 JSON 提取会把「字段名」焊进查询,下一版改字段名就得改 SQL。
4858
+ * 按 created_at DESC 排,最近记的更靠前;limit 兜底防止面板把整表拖进内存。
4859
+ */
4860
+ function relationsByType(relationType, limit = 500) {
4861
+ if (!relationType) return [];
4862
+ const lim = Number.isFinite(limit) ? Math.max(0, Math.trunc(limit)) : 500;
4863
+ if (lim === 0) return [];
4864
+ // 端点名字一并取回:面板要显示「谁对谁」,让调用方拿 id 再逐条查实体
4865
+ // 就是 N 次往返,而这个列表本来就是给「一眼看全」用的。
4866
+ const rows = db.prepare(
4867
+ `SELECT r.*, f.name AS from_name, f.type AS from_type,
4868
+ t.name AS to_name, t.type AS to_type
4869
+ FROM entity_relations r
4870
+ LEFT JOIN entities f ON f.id = r.from_entity
4871
+ LEFT JOIN entities t ON t.id = r.to_entity
4872
+ WHERE r.relation_type = ?
4873
+ ORDER BY r.created_at DESC
4874
+ LIMIT ?`
4875
+ ).all(relationType, lim);
4876
+ return rows.map((row) => ({
4877
+ ...toRelation(row),
4878
+ from_name: row.from_name ?? null,
4879
+ from_type: row.from_type ?? null,
4880
+ to_name: row.to_name ?? null,
4881
+ to_type: row.to_type ?? null
4882
+ }));
4883
+ }
4884
+
4501
4885
  /** All entities (optionally name-filtered, newest first). Used by sleep phase 4
4502
4886
  * orphan detection: an entity with zero relations is a candidate for relation
4503
4887
  * completion. */
4504
- function listEntities({ limit = 1000 } = {}) {
4888
+ function listEntities({ limit = 1000, q } = {}) {
4889
+ const needle = typeof q === "string" ? q.trim() : "";
4890
+ if (needle === "") {
4891
+ const rows = db.prepare(
4892
+ "SELECT * FROM entities ORDER BY last_seen DESC, name ASC LIMIT ?"
4893
+ ).all(limit);
4894
+ return rows.map(toEntity);
4895
+ }
4896
+ // 名字过滤放在 SQL 侧:调用方若在 LIMIT 之后过滤,排在 limit 之外的实体
4897
+ // 就永远搜不到(面板按 limit=200 取回再本地过滤,1.6 万实体时命中率约 1%)。
4898
+ // LIKE 的 % / _ 用 ESCAPE 转义,否则用户输入一个 "%" 就会把整库匹配出来。
4899
+ const escaped = needle.replace(/[\\%_]/g, (ch) => `\\${ch}`);
4505
4900
  const rows = db.prepare(
4506
- "SELECT * FROM entities ORDER BY last_seen DESC, name ASC LIMIT ?"
4507
- ).all(limit);
4901
+ "SELECT * FROM entities WHERE name LIKE ? ESCAPE '\\' ORDER BY last_seen DESC, name ASC LIMIT ?"
4902
+ ).all(`%${escaped}%`, limit);
4508
4903
  return rows.map(toEntity);
4509
4904
  }
4510
4905
 
@@ -4914,7 +5309,8 @@ export function createStore(path) {
4914
5309
  function choroPageBySlug(repoId, slug) {
4915
5310
  return db.prepare(
4916
5311
  `SELECT p.id, p.module_id, p.rel_path, p.status, p.git_baseline, p.template_version,
4917
- p.generated_at, p.human_block_count, p.fail_count, m.slug
5312
+ p.generated_at, p.human_block_count, p.fail_count,
5313
+ p.verified_baseline, p.verified_at, m.slug
4918
5314
  FROM choro_pages p JOIN choro_modules m ON p.module_id = m.id
4919
5315
  WHERE m.repo_id = ? AND m.slug = ?`
4920
5316
  ).get(repoId, String(slug));
@@ -4935,10 +5331,17 @@ export function createStore(path) {
4935
5331
 
4936
5332
  // 富化成功:skeleton → enriched,generatedAt 推进、失败计数清零(成功重置的是
4937
5333
  // 「连续失败」的连续性,不是历史)。
4938
- function markChoroPageEnriched(pageId, generatedAt) {
5334
+ //
5335
+ // verifiedBaseline 是 T1 那一刻的 HEAD:claims 的行号取自那个提交的代码,之后判失效
5336
+ // 只能从它起算(传 null 表示拿不到 HEAD——那时 diff 判不了,面板要说「不知道」,
5337
+ // 而不是退回全仓 git_baseline 假装知道)。
5338
+ function markChoroPageEnriched(pageId, generatedAt, verifiedBaseline = null) {
4939
5339
  db.prepare(
4940
- "UPDATE choro_pages SET status = 'enriched', generated_at = ?, fail_count = 0 WHERE id = ?"
4941
- ).run(generatedAt, pageId);
5340
+ `UPDATE choro_pages
5341
+ SET status = 'enriched', generated_at = ?, fail_count = 0,
5342
+ verified_baseline = ?, verified_at = ?
5343
+ WHERE id = ?`
5344
+ ).run(generatedAt, verifiedBaseline, generatedAt, pageId);
4942
5345
  }
4943
5346
 
4944
5347
  // 单页 claims 整页替换(P20 的表侧):T1 一轮产出的 claims 删旧插新;evidence/
@@ -5128,9 +5531,143 @@ export function createStore(path) {
5128
5531
  ).all().map((r) => r.k);
5129
5532
  }
5130
5533
 
5534
+ /* --------------------- knowledge base(知识库第二层,§3.3) --------------------- */
5535
+ // 与 choro 那组表同一套纪律:本域只读写 kb_* 三张表,绝不进 memories /
5536
+ // saveWithDedupe / dream / 质量过滤的任何池子。时间戳由调用方传(一次 ingest
5537
+ // 的所有行共用一个时刻),store 不自取 now()——两处时钟会把「同一次入库」拆成
5538
+ // 两个版本,sha256 幂等判据也会跟着抖。
5539
+
5540
+ function kbCollectionByKey(key) {
5541
+ return db.prepare("SELECT * FROM kb_collections WHERE key = ?").get(String(key)) ?? undefined;
5542
+ }
5543
+
5544
+ /** 集合列表带计数:面板左栏要的不是空壳 key 列表,docs/chunks 是它的主信息。 */
5545
+ function listKbCollections() {
5546
+ return db.prepare(
5547
+ `SELECT c.*,
5548
+ (SELECT COUNT(*) FROM kb_docs d WHERE d.collection_id = c.id) AS doc_count,
5549
+ (SELECT COUNT(*) FROM kb_chunks k JOIN kb_docs d2 ON k.doc_id = d2.id
5550
+ WHERE d2.collection_id = c.id) AS chunk_count
5551
+ FROM kb_collections c ORDER BY c.key`
5552
+ ).all();
5553
+ }
5554
+
5555
+ // key 是外部键(面板/工具用它寻址),title 是展示名。ON CONFLICT 不动 created_at:
5556
+ // 「这个集合哪天建的」是事实,重命名不该改写它。
5557
+ function upsertKbCollection({ key, title, description = null, at }) {
5558
+ db.prepare(
5559
+ `INSERT INTO kb_collections (key, title, description, created_at, updated_at)
5560
+ VALUES (?, ?, ?, ?, ?)
5561
+ ON CONFLICT(key) DO UPDATE SET
5562
+ title = excluded.title,
5563
+ description = excluded.description,
5564
+ updated_at = excluded.updated_at`
5565
+ ).run(String(key), String(title), description ?? null, at, at);
5566
+ return kbCollectionByKey(key);
5567
+ }
5568
+
5569
+ /** 删集合连带删它的文档与分块(三张表是一个整体,留孤儿行等于悄悄占空间)。 */
5570
+ function deleteKbCollection(key) {
5571
+ const row = kbCollectionByKey(key);
5572
+ if (!row) return false;
5573
+ return runAtomically(() => {
5574
+ db.prepare(
5575
+ "DELETE FROM kb_chunks WHERE doc_id IN (SELECT id FROM kb_docs WHERE collection_id = ?)"
5576
+ ).run(row.id);
5577
+ db.prepare("DELETE FROM kb_docs WHERE collection_id = ?").run(row.id);
5578
+ db.prepare("DELETE FROM kb_collections WHERE id = ?").run(row.id);
5579
+ return true;
5580
+ });
5581
+ }
5582
+
5583
+ function listKbDocs(collectionId) {
5584
+ return db.prepare(
5585
+ "SELECT * FROM kb_docs WHERE collection_id = ? ORDER BY rel_key"
5586
+ ).all(collectionId);
5587
+ }
5588
+
5589
+ function kbDocByRelKey(collectionId, relKey) {
5590
+ return db.prepare(
5591
+ "SELECT * FROM kb_docs WHERE collection_id = ? AND rel_key = ?"
5592
+ ).get(collectionId, String(relKey)) ?? undefined;
5593
+ }
5594
+
5595
+ function kbChunksByDoc(docId) {
5596
+ return db.prepare(
5597
+ "SELECT * FROM kb_chunks WHERE doc_id = ? ORDER BY ordinal"
5598
+ ).all(docId);
5599
+ }
5600
+
5601
+ /**
5602
+ * 入库落库的唯一写口。幂等判据是 **正文 sha256**(不是文件 stat):同 rel_key +
5603
+ * 同 sha256 = 整件事 no-op,回执 skipped 且不动 chunks——否则编辑器保存一次
5604
+ * (mtime 变、内容没变)就要全量重切块,库和索引一起白忙。
5605
+ *
5606
+ * 变了才替换:删旧 chunks 再插新的,事务包住,半截失败整体回滚,库里保持上一版
5607
+ * 完整文档。上一版 status=error 的行不算命中(它没有可分块的正文,重来必须真跑)。
5608
+ */
5609
+ function replaceKbDoc({ collectionId, relKey, kind, sourcePath = null, title, bytes = 0, sha256 = null, at, chunks = [] }) {
5610
+ return runAtomically(() => {
5611
+ const prev = kbDocByRelKey(collectionId, relKey);
5612
+ if (prev && prev.status === "ok" && sha256 && prev.sha256 === sha256) {
5613
+ return { docId: prev.id, chunks: kbChunksByDoc(prev.id).length, skipped: true };
5614
+ }
5615
+ db.prepare("DELETE FROM kb_chunks WHERE doc_id IN (SELECT id FROM kb_docs WHERE collection_id = ? AND rel_key = ?)")
5616
+ .run(collectionId, String(relKey));
5617
+ let docId;
5618
+ if (prev) {
5619
+ db.prepare(
5620
+ `UPDATE kb_docs SET kind = ?, source_path = ?, title = ?, bytes = ?, sha256 = ?,
5621
+ indexed_at = ?, status = 'ok', error = NULL WHERE id = ?`
5622
+ ).run(kind, sourcePath, String(title), bytes, sha256, at, prev.id);
5623
+ docId = prev.id;
5624
+ } else {
5625
+ const info = db.prepare(
5626
+ `INSERT INTO kb_docs (collection_id, rel_key, kind, source_path, title, bytes, sha256, indexed_at, status, error)
5627
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'ok', NULL)`
5628
+ ).run(collectionId, String(relKey), kind, sourcePath, String(title), bytes, sha256, at);
5629
+ docId = Number(info.lastInsertRowid);
5630
+ }
5631
+ const ins = db.prepare(
5632
+ "INSERT INTO kb_chunks (doc_id, ordinal, heading, text, created_at) VALUES (?, ?, ?, ?, ?)"
5633
+ );
5634
+ for (const c of chunks) ins.run(docId, c.ordinal, c.heading ?? "", c.text ?? "", at);
5635
+ return { docId, chunks: chunks.length, skipped: false };
5636
+ });
5637
+ }
5638
+
5639
+ /** 入库失败也要留痕:面板要能显示「这份文档上次为什么没进来」,静默丢来源等于假成功。 */
5640
+ function markKbDocError({ collectionId, relKey, kind, sourcePath = null, title, error, at }) {
5641
+ const prev = kbDocByRelKey(collectionId, relKey);
5642
+ if (prev) {
5643
+ db.prepare(
5644
+ "UPDATE kb_docs SET status = 'error', error = ?, indexed_at = ?, source_path = ?, kind = ?, title = ? WHERE id = ?"
5645
+ ).run(String(error ?? ""), at, sourcePath, kind, String(title ?? prev.title), prev.id);
5646
+ return prev.id;
5647
+ }
5648
+ const info = db.prepare(
5649
+ `INSERT INTO kb_docs (collection_id, rel_key, kind, source_path, title, bytes, sha256, indexed_at, status, error)
5650
+ VALUES (?, ?, ?, ?, ?, 0, NULL, ?, 'error', ?)`
5651
+ ).run(collectionId, String(relKey), kind, sourcePath, String(title ?? relKey), at, String(error ?? ""));
5652
+ return Number(info.lastInsertRowid);
5653
+ }
5654
+
5655
+ /** 检索候选集:跨集合或单集合,一次取全(BM25 需要全语料算 df,不能分页取)。 */
5656
+ function kbChunksForSearch(collectionId = null) {
5657
+ return db.prepare(
5658
+ `SELECT k.id, k.doc_id, k.ordinal, k.heading, k.text,
5659
+ d.rel_key, d.title, d.kind, c.key AS collection_key
5660
+ FROM kb_chunks k
5661
+ JOIN kb_docs d ON k.doc_id = d.id
5662
+ JOIN kb_collections c ON d.collection_id = c.id
5663
+ WHERE d.status = 'ok' AND (? IS NULL OR d.collection_id = ?)`
5664
+ ).all(collectionId, collectionId);
5665
+ }
5666
+
5131
5667
  return {
5132
5668
  db,
5133
5669
  count,
5670
+ countByType,
5134
5671
  getById,
5135
5672
  // 车道 C:外来行身份的两个读口(幂等键 / 整包清点)。
5136
5673
  getByShareExternalId,
@@ -5174,6 +5711,7 @@ export function createStore(path) {
5174
5711
  embeddedCount,
5175
5712
  needsEmbedding,
5176
5713
  needsEmbeddingFor,
5714
+ countNeedsEmbeddingFor,
5177
5715
  hasStaleEmbedding,
5178
5716
  embeddingModelBreakdown,
5179
5717
  embeddingDimensionBreakdown,
@@ -5232,6 +5770,7 @@ export function createStore(path) {
5232
5770
  archivedEmbeddingStats,
5233
5771
  clearArchivedEmbeddings,
5234
5772
  storageStats,
5773
+ capacityStats,
5235
5774
  vacuum,
5236
5775
  saveFailure,
5237
5776
  listFailures,
@@ -5248,6 +5787,13 @@ export function createStore(path) {
5248
5787
  saveConflictReviewed,
5249
5788
  loadConflictReviewed,
5250
5789
  listConflictReviewed,
5790
+ saveConflictLedgerEntry,
5791
+ setConflictLedgerStatus,
5792
+ supersedeChain,
5793
+ supersededIds,
5794
+ openConflictPairs,
5795
+ listConflictLedger,
5796
+ countConflictLedger,
5251
5797
  saveScopeChange,
5252
5798
  listScopeChanges,
5253
5799
  // 政策轴(issue #471):按下/松开一个作用域。纯 bookkeeping,不调
@@ -5284,9 +5830,11 @@ export function createStore(path) {
5284
5830
  listBoostedRelations,
5285
5831
  getLinkedMemoryIds,
5286
5832
  saveRelation,
5833
+ updateRelationMetadata,
5287
5834
  bumpRelationWeight,
5288
5835
  migrateAttrsToMemory,
5289
5836
  getRelations,
5837
+ relationsByType,
5290
5838
  getEntityNeighbors,
5291
5839
  getRelationsByMemory,
5292
5840
  setMirrorState,
@@ -5303,6 +5851,17 @@ export function createStore(path) {
5303
5851
  getContinuityProposal,
5304
5852
  listContinuityProposals,
5305
5853
  countContinuityProposals,
5854
+ // knowledge base(知识库第二层,§3.3):三张 kb_* 表,与记忆行物理隔离。
5855
+ kbCollectionByKey,
5856
+ listKbCollections,
5857
+ upsertKbCollection,
5858
+ deleteKbCollection,
5859
+ listKbDocs,
5860
+ kbDocByRelKey,
5861
+ kbChunksByDoc,
5862
+ replaceKbDoc,
5863
+ markKbDocError,
5864
+ kbChunksForSearch,
5306
5865
  // chorography(仓库地志):M0 只写不读,消费面 M1 接(见上面注释块)。
5307
5866
  choroRepoByRoot,
5308
5867
  upsertChoroRepo,