@modusensus/dsh-mneme 0.10.1 → 0.12.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 (97) hide show
  1. package/README.md +17 -6
  2. package/bin/dsh-mneme-choro.mjs +254 -0
  3. package/docs/CONFIGURATION.md +10 -1
  4. package/lib/api-standalone.js +17 -21
  5. package/lib/api.js +414 -12
  6. package/lib/bootstrap-http.js +105 -0
  7. package/lib/bootstrap.js +5 -1
  8. package/lib/choro-command.js +104 -0
  9. package/lib/client.js +1637 -110
  10. package/lib/config-keys.js +144 -0
  11. package/lib/config.js +60 -1
  12. package/lib/document.js +9 -3
  13. package/lib/dream/clustering.js +6 -1
  14. package/lib/index.js +87 -5
  15. package/lib/inject.js +18 -4
  16. package/lib/lang.js +82 -0
  17. package/lib/profile-facts.js +356 -0
  18. package/lib/quality-filter.js +48 -12
  19. package/lib/service.js +112 -4
  20. package/lib/settings.js +13 -1
  21. package/lib/store.js +978 -27
  22. package/lib/tools.js +244 -1
  23. package/lib/user-settings.js +63 -7
  24. package/lib/wiki/inject.js +164 -0
  25. package/lib/wiki/partition.js +200 -0
  26. package/lib/wiki/plan.js +214 -0
  27. package/lib/wiki/queries.js +360 -0
  28. package/lib/wiki/render.js +187 -0
  29. package/lib/wiki/scan.js +371 -0
  30. package/lib/wiki/t0.js +463 -0
  31. package/lib/wiki/t1.js +496 -0
  32. package/lib/write-admission.js +5 -3
  33. package/package.json +4 -2
  34. package/scripts/sync-test-badge.mjs +1 -1
  35. package/scripts/test-count-sync.mjs +9 -5
  36. package/src/api-standalone.js +17 -21
  37. package/src/api.js +414 -12
  38. package/src/bootstrap-http.js +105 -0
  39. package/src/bootstrap.js +5 -1
  40. package/src/choro-command.js +104 -0
  41. package/src/config-keys.js +144 -0
  42. package/src/config.js +60 -1
  43. package/src/document.js +9 -3
  44. package/src/dream/clustering.js +6 -1
  45. package/src/index.js +87 -5
  46. package/src/inject.js +18 -4
  47. package/src/lang.js +82 -0
  48. package/src/profile-facts.js +356 -0
  49. package/src/quality-filter.js +48 -12
  50. package/src/service.js +112 -4
  51. package/src/settings.js +13 -1
  52. package/src/store.js +978 -27
  53. package/src/tools.js +244 -1
  54. package/src/user-settings.js +63 -7
  55. package/src/wiki/inject.js +164 -0
  56. package/src/wiki/partition.js +200 -0
  57. package/src/wiki/plan.js +214 -0
  58. package/src/wiki/queries.js +360 -0
  59. package/src/wiki/render.js +187 -0
  60. package/src/wiki/scan.js +371 -0
  61. package/src/wiki/t0.js +463 -0
  62. package/src/wiki/t1.js +496 -0
  63. package/src/write-admission.js +5 -3
  64. package/test/api-routes-gaps.test.js +131 -1
  65. package/test/api.test.js +9 -1
  66. package/test/bootstrap.test.js +8 -0
  67. package/test/choro-command.test.js +218 -0
  68. package/test/client.test.js +802 -21
  69. package/test/config-keys-routes.test.js +233 -0
  70. package/test/config.test.js +79 -0
  71. package/test/contrast.test.js +14 -7
  72. package/test/document.test.js +32 -0
  73. package/test/embedding-cache-f32.test.js +276 -0
  74. package/test/inject-pin-explicit.test.js +300 -0
  75. package/test/inject-pin-vector-crowd.test.js +225 -0
  76. package/test/inject.test.js +51 -0
  77. package/test/llm-audit.test.js +29 -1
  78. package/test/persona-settings.test.js +31 -0
  79. package/test/profile-facts.test.js +492 -0
  80. package/test/quality-backfill.test.js +290 -0
  81. package/test/quality-filter.test.js +113 -4
  82. package/test/service.test.js +1 -1
  83. package/test/session-gate.test.js +1 -1
  84. package/test/test-count-sync.test.js +14 -3
  85. package/test/tool-state-ignore.test.js +65 -0
  86. package/test/tools-dto-schema-matrix.test.js +23 -2
  87. package/test/tools.test.js +144 -7
  88. package/test/wiki-api.test.js +273 -0
  89. package/test/wiki-inject.test.js +265 -0
  90. package/test/wiki-partition.test.js +112 -0
  91. package/test/wiki-plan.test.js +80 -0
  92. package/test/wiki-queries.test.js +270 -0
  93. package/test/wiki-scan.test.js +208 -0
  94. package/test/wiki-store.test.js +190 -0
  95. package/test/wiki-t0.test.js +380 -0
  96. package/test/wiki-t1.test.js +235 -0
  97. package/test/wiki-tools.test.js +176 -0
package/lib/store.js CHANGED
@@ -2,6 +2,10 @@ import { DatabaseSync } from "node:sqlite";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { statSync } from "node:fs";
4
4
  import { contentHashOf } from "./content-hash.js";
5
+ // 质量分回填要的判据与扣分表。只取常量与纯函数:依赖方向 store → quality-filter 单向
6
+ // (quality-filter.js 不 import 任何本包模块,是纯函数模块),不构成环,也不会让存储层
7
+ // 反向依赖服务层。判据只许有一份——回填另写一个 0.7 就会与写入路径悄悄分叉。
8
+ import { REPETITION_THRESHOLD, SIGNAL_DEDUCTIONS, repetitionRatio } from "./quality-filter.js";
5
9
  // 政策轴(issue #471)的归一化函数。刻意只取归一化,不取判定:判定(buildPausedIndex /
6
10
  // matchPausedScopes / createScopeStateGate)留在 scope-state.js,由 service 侧消费,
7
11
  // 存储层只管「一格状态怎么落」。依赖方向 store → scope-state → scope 是单向的
@@ -49,6 +53,20 @@ CREATE INDEX IF NOT EXISTS idx_memories_importance ON memories(importance);
49
53
  -- (5k 行实测 all() 231ms → 135ms)。IF NOT EXISTS 幂等,存量库启动即建。
50
54
  CREATE INDEX IF NOT EXISTS idx_memories_updated ON memories(updated_at);
51
55
 
56
+ -- 一次性数据迁移的登记表:一条迁移一行,跑过就记下。看起来多余(本文件其余迁移都
57
+ -- 靠「条件式 SQL 恒等幂等」),但存量回填那类迁移的条件在跑完之后**恒不命中、却仍要
58
+ -- 全表扫一遍才能证明不命中**——质量分回填的探测在本机 12,827 行上是 36.7 ms,而
59
+ -- createStore 稳态才 3.3 ms,等于每次打开都白付十倍的开销。登记表把这一步变成一次
60
+ -- 主键 seek。
61
+ -- 为什么不是 PRAGMA user_version:实测它在 BEGIN 里设值**不随 ROLLBACK 回退**,与回填
62
+ -- 放不进同一个事务(先置标记再回填中途失败 = 永久漏跑);而且它全库只有一个整数,被
63
+ -- 单条迁移占掉之后,日后真需要 schema 版本号就没地方放了。普通表既能跟着事务回退,
64
+ -- 也能一条迁移一行。
65
+ CREATE TABLE IF NOT EXISTS applied_migrations (
66
+ id TEXT PRIMARY KEY,
67
+ applied_at TEXT NOT NULL
68
+ );
69
+
52
70
  -- autoDream audit trail: one row per consolidation run, capturing the exact
53
71
  -- input snapshot digest + the LLM decision list + per-id outcome + a compact
54
72
  -- receipt. This makes every decision replayable so silent consolidation errors
@@ -269,6 +287,29 @@ CREATE TABLE IF NOT EXISTS scope_state (
269
287
  -- 它同时是 setScopeState 那条 UPSERT 的冲突目标,所以不能省。
270
288
  CREATE UNIQUE INDEX IF NOT EXISTS idx_scope_state_key ON scope_state(dimension, scope_value);
271
289
 
290
+ -- pinned_memories: #249 第二批,显式 pin。旧口径把「类型是 constraint/preference」
291
+ -- 当成 pin(隐含假设),于是 1163 条自动变「每轮必进」而预算只有 5——池子本身
292
+ -- 就太大,靠相关性排序永远轮不到「署名」「拒绝谄媚」这类稳定偏好。本表把
293
+ -- 「哪些是用户亲手钉的」变成一条显式事实:注入侧只认这张表,不再认类型。
294
+ --
295
+ -- 为什么是独立表而不是 memories 的布尔列:回退只需一条 DROP,不碰 31 列的主表;
296
+ -- 且「pin 是用户与记忆之间的一条关系」天然是关系表——created_at 还能给出
297
+ -- pin 顺序(按 pin 先后注入,而不是按「最近改过」)。外泄面天然为零:
298
+ -- mirror/share 都不 SELECT 本表,pin 标记不会出现在导出物里。
299
+ --
300
+ -- 幂等靠 CREATE TABLE IF NOT EXISTS(本文件其余建表同款),不登记
301
+ -- applied_migrations——那是给「跑完恒不命中却仍要全表扫一遍」的 data migration
302
+ -- 用的,本表没有回填步骤。存量库升级后本表为空,注入侧据此回落到旧的类型口径
303
+ -- (见 service.injectCandidates 的 type-fallback 分支),升级即等价。
304
+ CREATE TABLE IF NOT EXISTS pinned_memories (
305
+ memory_id TEXT PRIMARY KEY,
306
+ created_at TEXT NOT NULL,
307
+ note TEXT
308
+ );
309
+ -- 按 pin 顺序注入要读 created_at 全序;本表行数以「人手钉的条数」计(个位到几十),
310
+ -- 索引只为把排序的临时 B 树换成一次有序扫,不承担过滤。
311
+ CREATE INDEX IF NOT EXISTS idx_pinned_created ON pinned_memories(created_at);
312
+
272
313
  -- llm_audit_logs: every background LLM call (autoDream consolidation + summary,
273
314
  -- autoSummarize compression) is recorded here — tokens in/out, duration, status
274
315
  -- and the trigger that caused it (Bug8). Failures are captured as status='error'
@@ -516,6 +557,155 @@ CREATE TABLE IF NOT EXISTS continuity_proposals (
516
557
  );
517
558
  CREATE UNIQUE INDEX IF NOT EXISTS idx_continuity_session_kind ON continuity_proposals(session_id, kind);
518
559
  CREATE INDEX IF NOT EXISTS idx_continuity_status ON continuity_proposals(status, updated_at);
560
+
561
+ -- ============================================================
562
+ -- chorography(仓库地志,spec: docs/specs/2026-11-04-chorography-design.md)。
563
+ -- 与记忆行物理隔离的硬边界落在这组表上:wiki 的行永不进 memories /
564
+ -- saveWithDedupe / dream / 质量过滤的任何池子(spec §3)——autoDream 的
565
+ -- 「对核心知识库长期参考价值较低」归档理由一旦够得着 wiki 行,机器生成页就会
566
+ -- 被巩固流程自己归档掉(dream-archive-guardrail 测的正是这个语义)。
567
+ -- M0 只有 T0 确定性骨架(零模型);choro_claims / choro_vectors 此时是空壳,
568
+ -- 分别在 M2(T1 生成轮出 claims)与 M1/M2(embedder 接入)开始灌数据。
569
+ --
570
+ -- 仓库登记(实现计划 P2 补口):spec §5 的 choro_modules.repo_id 指向这里;
571
+ -- 消费面「按工作目录匹配已登记仓库」(spec §7)也要求登记行。root_path 存
572
+ -- realpath——Windows 上大小写不同的两条路径指向同一目录,登记表按 realpath
573
+ -- 收敛成一行。唯一性照本仓惯例走独立索引,不进 CREATE TABLE。
574
+ CREATE TABLE IF NOT EXISTS choro_repos (
575
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
576
+ root_path TEXT NOT NULL,
577
+ name TEXT NOT NULL,
578
+ plan_fingerprint TEXT, -- 上次成功 run 的 plan.yaml 内容 sha256
579
+ git_baseline TEXT, -- 上次成功 run 的 HEAD
580
+ created_at TEXT NOT NULL,
581
+ updated_at TEXT NOT NULL
582
+ );
583
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_choro_repos_root ON choro_repos(root_path);
584
+
585
+ -- 模块划分与依赖边。fingerprint = 模块文件集 + 边集的内容 sha256:幂等语义靠
586
+ -- replaceChoroGraph 的整仓替换,不靠逐行 diff,所以本表没有「上次见过的划分」
587
+ -- 之外的账要记。
588
+ CREATE TABLE IF NOT EXISTS choro_modules (
589
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
590
+ repo_id INTEGER NOT NULL,
591
+ slug TEXT NOT NULL,
592
+ title TEXT NOT NULL,
593
+ parent_id INTEGER, -- 最近祖先模块(目录包含关系),无则 NULL
594
+ file_count INTEGER NOT NULL DEFAULT 0,
595
+ fingerprint TEXT,
596
+ updated_at TEXT NOT NULL
597
+ );
598
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_choro_modules_repo_slug ON choro_modules(repo_id, slug);
599
+
600
+ -- 页面 ↔ baseline ↔ 状态。rel_path 是仓库相对路径(.chorography/modules/<slug>.md):
601
+ -- 真源在仓库磁盘的 MD 里(spec §4),本表只做索引与状态账,删了可以整表重算。
602
+ CREATE TABLE IF NOT EXISTS choro_pages (
603
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
604
+ module_id INTEGER NOT NULL,
605
+ rel_path TEXT NOT NULL,
606
+ status TEXT NOT NULL DEFAULT 'skeleton', -- skeleton | enriched | stale | failed
607
+ git_baseline TEXT,
608
+ template_version TEXT,
609
+ generated_at TEXT,
610
+ human_block_count INTEGER NOT NULL DEFAULT 0
611
+ );
612
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_choro_pages_module ON choro_pages(module_id);
613
+
614
+ -- 溯源知识卡(M2 起写):陈述 + 出处锚点(相对路径#Lx-Ly)。kind 限定五类
615
+ -- (spec §5),不在别处发明第六类。
616
+ CREATE TABLE IF NOT EXISTS choro_claims (
617
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
618
+ page_id INTEGER NOT NULL,
619
+ statement TEXT NOT NULL,
620
+ kind TEXT NOT NULL, -- architecture | convention | tech-stack | setup | unknown
621
+ source_path TEXT,
622
+ start_line INTEGER,
623
+ end_line INTEGER,
624
+ confidence TEXT,
625
+ created_at TEXT NOT NULL
626
+ );
627
+ CREATE INDEX IF NOT EXISTS idx_choro_claims_page ON choro_claims(page_id);
628
+
629
+ -- 向量壳(收尾档 P36/P38 落地):owner_id 指向 choro_pages(v1 只做页级;
630
+ -- claims 级留待真有精排需求)。向量与记忆向量不共表、不共查询——两边换模型
631
+ -- 版本的节奏不同(V4 的答案在此:model/dim 记行内,**不锁死**——dim 失配 =
632
+ -- 惰性全量重嵌,代价只是几十页重嵌而不是作废记忆向量)。
633
+ -- vector 存 JSON 数组(与 memories.embedding 同款 TEXT 形态);source_generated_at
634
+ -- 记嵌入时刻页面的 generated_at——页面富化/重建后该值变化,检索侧据此判 staleness。
635
+ CREATE TABLE IF NOT EXISTS choro_vectors (
636
+ owner_id INTEGER NOT NULL,
637
+ dim INTEGER NOT NULL,
638
+ model TEXT NOT NULL,
639
+ vector TEXT,
640
+ source_generated_at TEXT,
641
+ created_at TEXT NOT NULL
642
+ );
643
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_choro_vectors_owner_model ON choro_vectors(owner_id, model);
644
+
645
+ -- 模块间依赖边(M1 补口,实现计划 P8):spec §7 的 choro_module_map 要画依赖图,
646
+ -- M0 schema 漏了这张。只记跨模块边(from_slug ≠ to_slug),模块内部边不进库——
647
+ -- 模块页正文里已经有完整文件级清单,库只存图需要的那一层。
648
+ CREATE TABLE IF NOT EXISTS choro_edges (
649
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
650
+ repo_id INTEGER NOT NULL,
651
+ from_slug TEXT NOT NULL,
652
+ to_slug TEXT NOT NULL,
653
+ weight INTEGER NOT NULL DEFAULT 1
654
+ );
655
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_choro_edges_triple ON choro_edges(repo_id, from_slug, to_slug);
656
+ CREATE INDEX IF NOT EXISTS idx_choro_edges_to ON choro_edges(repo_id, to_slug);
657
+
658
+ -- profile_facts:「Agent 眼中的你」自动事实(2026-11-06 需求,对标 DS Bot 每 bot
659
+ -- 一份 MEMORY.md 事实清单的设计)。与手填画像(user_settings.profile)**物理分离**
660
+ -- ——那是用户的原文,模型只有读权(profile-extract #497 的「只出草稿不落库」口径
661
+ -- 不变);本表是 agent 侧对用户的独立认知,模型可在 dream 轮里增改、用户可在面板
662
+ -- 上逐条修正。agent_key='' 是 default 卡(解析不到会话身份的会话),非空值是会话
663
+ -- 身份,与 persona byAgent 同一取值域(session.header.agentPreset)。
664
+ -- 输入行严格分区不互串:agent 卡只吃 agent_scope=自己 的记忆行,default 卡只吃
665
+ -- agent_scope IS NULL 的行;外来行(source=imported)整体排除(memory-source.js
666
+ -- 那条「本库不巩固别人导入的原文」延伸到画像事实:导入的包不该改写本库对用户的认知)。
667
+ CREATE TABLE IF NOT EXISTS profile_facts (
668
+ id TEXT PRIMARY KEY,
669
+ agent_key TEXT NOT NULL DEFAULT '',
670
+ content TEXT NOT NULL,
671
+ status TEXT NOT NULL DEFAULT 'active', -- active | archived
672
+ source_memory_ids TEXT NOT NULL DEFAULT '[]', -- JSON:这条事实的出处记忆 id
673
+ created_at TEXT NOT NULL,
674
+ updated_at TEXT NOT NULL
675
+ );
676
+ CREATE INDEX IF NOT EXISTS idx_profile_facts_card ON profile_facts(agent_key, status);
677
+
678
+ -- 逐 op 账本(scope_changes 式):谁(auto 轮 / 面板人工)、何时、把哪条事实从什么
679
+ -- 改成了什么。审计纪律同 dream_runs / llm_audit_logs:只记账,不触发任何 hook。
680
+ CREATE TABLE IF NOT EXISTS profile_fact_journal (
681
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
682
+ at TEXT NOT NULL,
683
+ agent_key TEXT NOT NULL DEFAULT '',
684
+ run_id TEXT, -- auto 轮的回执 id;人工操作为 NULL
685
+ fact_id TEXT,
686
+ op TEXT NOT NULL, -- add | update | archive | restore | delete
687
+ before TEXT,
688
+ after TEXT,
689
+ actor TEXT NOT NULL DEFAULT 'auto' -- auto | manual
690
+ );
691
+ CREATE INDEX IF NOT EXISTS idx_profile_fact_journal_card ON profile_fact_journal(agent_key, id);
692
+
693
+ -- 每轮回执(dream_runs 式):游标(该卡消费到 memories.updated_at 的哪一刻)、
694
+ -- 输入条数、落地 op 数。游标从最近一次 ok 轮读取——failed 轮不推进游标,下次
695
+ -- 重跑同一窗口(与蒸馏游标同款纪律:失败不消费)。
696
+ CREATE TABLE IF NOT EXISTS profile_fact_runs (
697
+ id TEXT PRIMARY KEY,
698
+ agent_key TEXT NOT NULL DEFAULT '',
699
+ trigger_source TEXT NOT NULL, -- auto | manual
700
+ status TEXT NOT NULL, -- ok | noop | failed
701
+ cursor TEXT, -- 本轮消费到的 memories.updated_at
702
+ input_count INTEGER NOT NULL DEFAULT 0,
703
+ ops_applied INTEGER NOT NULL DEFAULT 0,
704
+ detail TEXT,
705
+ started_at TEXT NOT NULL,
706
+ finished_at TEXT
707
+ );
708
+ CREATE INDEX IF NOT EXISTS idx_profile_fact_runs_card ON profile_fact_runs(agent_key, started_at);
519
709
  `;
520
710
 
521
711
  // Exported for API-layer type validation (standalone API POST /memories and
@@ -820,6 +1010,17 @@ function toScopeState(row) {
820
1010
  };
821
1011
  }
822
1012
 
1013
+ // pinned_memories 行 → 读侧对象(#249 第二批)。NULL note 落 undefined,
1014
+ // 与 toScopeState 同口径。
1015
+ function toPinned(row) {
1016
+ if (!row) return undefined;
1017
+ return {
1018
+ memory_id: row.memory_id,
1019
+ created_at: row.created_at,
1020
+ note: row.note ?? undefined
1021
+ };
1022
+ }
1023
+
823
1024
  function toRecallRun(row) {
824
1025
  if (!row) return undefined;
825
1026
  return {
@@ -1028,6 +1229,14 @@ export function createStore(path) {
1028
1229
  addColumn("memories", "content_history", "ALTER TABLE memories ADD COLUMN content_history TEXT");
1029
1230
  addColumn("memories", "quality_score", "ALTER TABLE memories ADD COLUMN quality_score REAL");
1030
1231
 
1232
+ // chorography(M2,实现计划 P17):T1 生成轮的页级失败计数。T0 建的表没有它
1233
+ // (M0 只写 skeleton),存量库经 PRAGMA 检查后补列,幂等。
1234
+ addColumn("choro_pages", "fail_count", "ALTER TABLE choro_pages ADD COLUMN fail_count INTEGER NOT NULL DEFAULT 0");
1235
+ // chorography 收尾档(P36):v0.11.0 的 choro_vectors 是空壳表(spec §5 漏了
1236
+ // 向量本体列),存量库经 PRAGMA 检查补列。
1237
+ addColumn("choro_vectors", "vector", "ALTER TABLE choro_vectors ADD COLUMN vector TEXT");
1238
+ addColumn("choro_vectors", "source_generated_at", "ALTER TABLE choro_vectors ADD COLUMN source_generated_at TEXT");
1239
+
1031
1240
  // v0.8.0 A1(issue #17)scope 隔离存储层:四列全部可空、不带 DEFAULT——存量行
1032
1241
  // 零重写,读侧把 NULL 视为未标注(= 全局可见)。是否落值由 scopeEnabled 门控
1033
1242
  // (service/tools 层),存储层只负责归一化(normalizeScopeText/normalizeOccurredAt)。
@@ -1113,6 +1322,141 @@ export function createStore(path) {
1113
1322
  }
1114
1323
  }
1115
1324
 
1325
+ // 一次性数据迁移 #1:撤回旧重复度度量的误判(#560 的收尾)。
1326
+ //
1327
+ // v0.7.12 起 repetitive 用的是「唯一字符数 ÷ 总长度 < 0.3」,这个比率随长度单调下降,
1328
+ // 于是长中文记忆被系统性判成重复:本机 12,827 条里 800–2000 字的 94.3%、2000 字以上的
1329
+ // 100% 命中。0.11.0 把度量换成 trigram 重复率(> 0.7),那只管住了**新写入**——
1330
+ // evaluateMemoryQuality 全仓只有 service 的写路径一处调用,更新路径不重算 quality_score,
1331
+ // 所以库里那批标签与扣分原封不动。
1332
+ //
1333
+ // 口径是「手术式撤销」,不是「整列重算」:整列重算会顺手重新裁一遍别人的分——实测会
1334
+ // 新命中 90 条 meta 与 98 条 self_referential(判据没变、但评分当时没跑到这些行),
1335
+ // 那是双向改写,越过了「只撤除被修掉的那个误判」的范围。这里只动带 repetitive 的行,
1336
+ // 且必须**用新度量复算仍然不重复**才动;新度量下依旧 > 0.7 的是真重复,标签有效,保持原样。
1337
+ //
1338
+ // 分数据「只升不降」:重建值来自存活标签的扣分表,取 max(存量, 重建) 而不是直接取重建——
1339
+ // 存量分不是纯标签函数(实测有 3 行与任何标签组合都对不上,历史上有信号被改路径抹掉过),
1340
+ // 直接用重建值会把这批行往下压。这次迁移的理由是「它被误扣了 50」,只有加回去才对得上。
1341
+ //
1342
+ // 不碰 archived / archived_at / updated_at / content_hash / embedding,也不 bump
1343
+ // generation:归档位是另一个决定(撤销归档 = 把 300 条重新放进注入池),不夹带在
1344
+ // 「修分数」里;不刷 updated_at 则代表镜像不重排——代价是镜像文件里这些行的 tags 行
1345
+ // 会显示已删掉的 repetitive,而 parseHumanEdits 不解析 tags,陈旧只停留在显示层、不回灌。
1346
+ // 这层陈旧还会自己收敛:镜像每次 syncMirror 都是全量重渲染(store.list limit:null),
1347
+ // 所以启动后第一次业务写就把这批行刷对了——为这几分钟专门接一条「打开库即全量重绘」
1348
+ // 的路径,代价(新导出 syncMirror + createStore 回传标记 + 两个启动点各改一行)大于收益。
1349
+ //
1350
+ // 用 applied_migrations 登记而不是条件式恒等幂等:这批条件在跑完之后**恒不命中**,
1351
+ // 但要全表扫一遍才能证明不命中(本机实测 36.7 ms,而 createStore 稳态只有 3.3 ms)——
1352
+ // 每次打开都白付十倍开销。登记表把它变成一次主键 seek(PRAGMA user_version 做不到:
1353
+ // 实测它在 BEGIN 里设值不随 ROLLBACK 回退,先置标记再回填会永久漏跑)。
1354
+ //
1355
+ // 回填分批提交、登记行最后单独提交,不做「一个大事务包住全部」:逐批提交才不会把
1356
+ // 「候选行数 × 全文长度」攒在内存与一次 WAL 里;而这样拆之所以仍然安全,是因为
1357
+ // **修过的行就不再是候选**(repetitive 已撤),中途崩掉重跑只会从断点接着走,不会
1358
+ // 重复加回分数。登记行只认「全部批次都跑完、且没有一条被 CAS 挡下」,半截进度不落地。
1359
+ {
1360
+ const MIGRATION_ID = "2026-11-quality-repetitive-backfill";
1361
+ const applied = db.prepare("SELECT 1 FROM applied_migrations WHERE id = ?").get(MIGRATION_ID);
1362
+ if (!applied) {
1363
+ // LIKE 只负责候选裁剪,真正的判据是下一段的 tags.includes("repetitive")——把
1364
+ // LIKE 放松成 %repetitive% 也筛不出错误结果(变异自证里它是等价变异)。用带引号
1365
+ // 的形态是为了缩小候选集、少解析几条将来可能出现的坏 JSON,不是为了防误伤。
1366
+ // 按 rowid 键集翻页,每批独立提交:候选行带全文,一次 .all() 会把「行数 × 正文
1367
+ // 长度」全攒在内存里(本机 1,476 条合计 726 KB,大库不是这个量级)。分批之所以
1368
+ // 仍然安全,是因为候选集自己会收缩——修过的行撤掉 repetitive 就不再命中;
1369
+ // 中途崩掉重跑只会从断点接着走,不会把已经加回去的分再加一遍。
1370
+ const BATCH = 200;
1371
+ const selectBatch = db.prepare(
1372
+ `SELECT rowid AS rid, id, content, _full_content, tags, quality_score
1373
+ FROM memories
1374
+ WHERE tags LIKE '%"repetitive"%' AND rowid > ?
1375
+ ORDER BY rowid
1376
+ LIMIT ?`
1377
+ );
1378
+ // CAS:带上读到的旧 tags、旧 quality_score 与两列正文作为条件。多宿主共用一个库是
1379
+ // 支持形态(busy_timeout + WAL),SELECT 到 UPDATE 之间别的进程可能刚改过这一行——
1380
+ // 那就以它为准、跳过这一条,不拿这次读到的旧判断覆盖它。正文两列也要带:判据读的是
1381
+ // 正文,只校验 tags/quality_score 时,正文被换成重复文本而另两列未变仍会 CAS 通过,
1382
+ // 于是把本该保留的 repetitive 撤掉。
1383
+ const stmt = db.prepare(
1384
+ `UPDATE memories SET tags = ?, quality_score = ?
1385
+ WHERE id = ? AND tags = ? AND quality_score IS ?
1386
+ AND content IS ? AND _full_content IS ?
1387
+ AND tags LIKE '%"repetitive"%'`
1388
+ );
1389
+ // CAS 落空的行这一趟没修到,它们仍是候选——那就别登记「已完成」,下次打开整条重跑
1390
+ // (修过的行已不是候选,重跑天然幂等)。否则一条被别处同时改写而保留 repetitive 的行
1391
+ // 会带着旧分与旧标签一直留在库里。
1392
+ let casMisses = 0;
1393
+ let cursor = 0;
1394
+ for (;;) {
1395
+ const rows = selectBatch.all(cursor, BATCH);
1396
+ if (rows.length === 0) break;
1397
+ cursor = rows[rows.length - 1].rid;
1398
+ const updates = [];
1399
+ for (const row of rows) {
1400
+ let tags;
1401
+ try {
1402
+ tags = JSON.parse(row.tags);
1403
+ } catch {
1404
+ continue; // 坏 tags 不动:这条迁移不该顺手重写一个解析不了的值
1405
+ }
1406
+ if (!Array.isArray(tags) || !tags.includes("repetitive")) continue;
1407
+ // 含非字符串元素的行整条放过:下面 Object.hasOwn 对对象元素会先做属性键转换,
1408
+ // 抛 TypeError(JSON.parse('{"toString":1}') 这类值可达),而这里没有任何 catch,
1409
+ // 异常会直接让 createStore 中止、迁移永远登不上记,下次打开原地再撞同一行。
1410
+ if (!tags.every((t) => typeof t === "string")) continue;
1411
+ // 判据落在**原文**上:已降级的行 content 只剩前 120 字摘要(demoteToSummary
1412
+ // 把正文挪进 _full_content,且它不重算 quality_score),拿摘要判重复会把
1413
+ // 「前缀不重复、后段大量重复」的原文放过去——而那恰好是旧度量最容易误判的
1414
+ // 那一类。未降级的行 _full_content 为 NULL,退回 content,与原判据一致。
1415
+ const body = String(row._full_content ?? row.content ?? "");
1416
+ // 新度量下仍然重复 → 标签是对的,保持原样(不重算分数:那会变成整列重算)
1417
+ if (repetitionRatio(body.trim()) > REPETITION_THRESHOLD) continue;
1418
+ const kept = tags.filter((t) => t !== "repetitive");
1419
+ let rebuilt = 100;
1420
+ // 只减自有键:SIGNAL_DEDUCTIONS 是普通对象字面量,查「constructor」命中的
1421
+ // 是原型链上的函数,`?? 0` 挡不住,会把分数算成 NaN 再写进列里。
1422
+ for (const tag of kept) {
1423
+ if (Object.hasOwn(SIGNAL_DEDUCTIONS, tag)) rebuilt -= SIGNAL_DEDUCTIONS[tag];
1424
+ }
1425
+ rebuilt = Math.max(0, Math.min(100, Math.round(rebuilt)));
1426
+ const stored = Number.isFinite(row.quality_score) ? row.quality_score : null;
1427
+ const score = stored === null ? rebuilt : Math.max(stored, rebuilt);
1428
+ // low_quality 是分数的派生态(< 30 必须有、≥ 30 必须没有),分数动了它就跟着动。
1429
+ // 显式同步而不是留给下次写入:不重算的路径不会自己把它修好。
1430
+ const next = kept.filter((t) => t !== "low_quality");
1431
+ if (score < 30) next.push("low_quality");
1432
+ updates.push([
1433
+ JSON.stringify(next),
1434
+ score,
1435
+ row.id,
1436
+ row.tags,
1437
+ row.quality_score,
1438
+ row.content,
1439
+ row._full_content
1440
+ ]);
1441
+ }
1442
+ if (updates.length === 0) continue;
1443
+ const batch = updates;
1444
+ // 一批一个事务:逐条自动提交是每行一次 WAL 提交(与 content_hash 回填同一个教训)。
1445
+ runAtomically(() => {
1446
+ for (const u of batch) {
1447
+ if (stmt.run(u[0], u[1], u[2], u[3], u[4], u[5], u[6]).changes === 0) casMisses += 1;
1448
+ }
1449
+ });
1450
+ }
1451
+ // 登记行单独提交,且只在**所有批次都跑完、且没有一条 CAS 落空**之后:它是「这条迁移
1452
+ // 已完成」的证据,半截进度不能落地,否则重跑会被自己挡住、剩下的行再也修不回来。
1453
+ const mark = db.prepare("INSERT OR IGNORE INTO applied_migrations (id, applied_at) VALUES (?, ?)");
1454
+ // 时间戳这里直接用 new Date():nowIso 的单调守卫是给 memories.updated_at 用的,
1455
+ // 而且它依赖的 lastTs 此刻还没初始化(TDZ),调它会在打开库时直接抛。
1456
+ if (casMisses === 0) runAtomically(() => mark.run(MIGRATION_ID, new Date().toISOString()));
1457
+ }
1458
+ }
1459
+
1116
1460
  // Legacy dream_runs without policy_epoch → backfill with the default epoch.
1117
1461
  addColumn("dream_runs", "policy_epoch", "ALTER TABLE dream_runs ADD COLUMN policy_epoch INTEGER NOT NULL DEFAULT 0");
1118
1462
  addColumn("dream_runs", "run_type", "ALTER TABLE dream_runs ADD COLUMN run_type TEXT NOT NULL DEFAULT 'auto'");
@@ -1170,28 +1514,71 @@ export function createStore(path) {
1170
1514
  // Issue #202:embedding 解析缓存。检索路径此前每次调用都对全部带向量行
1171
1515
  // JSON.parse(993 × 512 维 ≈ 48 ms/次,且 SQL 拖着 10 MB 的 TEXT),余弦本身
1172
1516
  // 只占 3%。向量只经 setEmbedding 写入(单一失效点),把解析结果按 id 缓存,
1173
- // 命中即零解析;上限 FIFO 驱逐防大库内存无界(4000 × 512 维 double ≈ 16 MB)。
1517
+ // 命中即零解析。
1518
+ //
1519
+ // 上限为什么不能是固定常量(2026-11-05 实测):上限 4000、活跃带向量行 6685,
1520
+ // 扫到第 4001 条开始驱逐自己刚填的条目,扫完只剩最后 4000 条,下一轮又从第
1521
+ // 一条 miss——四轮实测命中率恒 0.0%,缓存等于不存在,每次检索都全量重解析
1522
+ // (实测 624 ms)。固定调大只是把复发推到下个月(活跃行近 30 天净增 6156 条,
1523
+ // 上限 20000 也只撑到 2027-01-09),所以上限跟工作集自适应:
1524
+ // · 每次全量扫(searchVector / vectorDimensionState)在循环前把上限抬到
1525
+ // 「本次扫到的行数 ×1.25」,一次扫完不会被自己的驱逐打断;
1526
+ // · 内存预算封顶(默认 256 MB),超预算的行仍按 FIFO 驱逐——大库退化回
1527
+ // 「部分命中」,不会无界吃内存。
1528
+ // 存储用 Float32Array。同一口径(GC 后 heapUsed + arrayBuffers,6730 条
1529
+ // 1024 维全常驻)实测:number[] 56.4MB → Float32Array 29.7MB(heap 1.9 +
1530
+ // arrayBuffers 27.8),省 26.7MB / 47%。注意 heapUsed 不统计 TypedArray 的
1531
+ // 底层 ArrayBuffer,只看 heapUsed 会读成「省 96%」的假象;旧实现若保持上限
1532
+ // 4000 则装不下工作集(常驻 31.9MB 但命中率 0%),两者不可比,所以对照组是
1533
+ // 把旧上限抬到 20000 让工作集全常驻。cosine 全量 25.4ms → 18.0ms,前 50 条
1534
+ // 排序精度差 0.00e+0。对外契约不变:
1535
+ // getParsedEmbedding / getEmbeddings 仍返回 number[],只有内部热路径
1536
+ // (readVector)拿 Float32Array。
1174
1537
  const embeddingCache = new Map();
1175
- const EMBEDDING_CACHE_MAX = 4000;
1538
+ const EMBEDDING_CACHE_FLOOR = 4000;
1539
+ const EMBEDDING_CACHE_BUDGET_BYTES = 256 * 1024 * 1024;
1540
+ let embeddingCacheCap = EMBEDDING_CACHE_FLOOR;
1541
+ let embeddingCacheHits = 0;
1542
+ let embeddingCacheMisses = 0;
1543
+
1544
+ /** Vector-like = number[] or Float32Array (typed). All cosine guards use this
1545
+ * instead of Array.isArray, which is false for typed arrays — with the old
1546
+ * guard every cached vector would silently score 0. */
1547
+ function isVectorLike(v) {
1548
+ return Array.isArray(v) || (ArrayBuffer.isView(v) && !(v instanceof DataView));
1549
+ }
1176
1550
 
1177
1551
  function cacheEmbedding(id, vector) {
1178
- if (embeddingCache.size >= EMBEDDING_CACHE_MAX && !embeddingCache.has(id)) {
1552
+ if (embeddingCache.size >= embeddingCacheCap && !embeddingCache.has(id)) {
1179
1553
  const oldest = embeddingCache.keys().next().value;
1180
1554
  embeddingCache.delete(oldest);
1181
1555
  }
1182
1556
  embeddingCache.set(id, vector);
1183
1557
  }
1184
1558
 
1185
- /** Parsed embedding for one id (cache-first); undefined when none/parse
1186
- * failure. Single read path shared by searchVector / getEmbedding(s). */
1187
- function getParsedEmbedding(id) {
1559
+ /** Raise the cap to fit a sweep of `rows` rows (×1.25 headroom), clamped by
1560
+ * the memory budget. Called before a full scan so the scan itself does not
1561
+ * evict what it just cached. */
1562
+ function noteSweep(rows, avgDim) {
1563
+ if (!rows || !avgDim) return;
1564
+ const perVector = avgDim * Float32Array.BYTES_PER_ELEMENT;
1565
+ const budgetRows = Math.floor(EMBEDDING_CACHE_BUDGET_BYTES / perVector);
1566
+ const want = Math.ceil(rows * 1.25);
1567
+ embeddingCacheCap = Math.max(EMBEDDING_CACHE_FLOOR, Math.min(budgetRows, want));
1568
+ }
1569
+
1570
+ /** Cache-first read returning Float32Array (internal hot path only).
1571
+ * undefined when none/parse failure. */
1572
+ function readVector(id) {
1188
1573
  const cached = embeddingCache.get(id);
1189
- if (cached) return cached;
1574
+ if (cached) { embeddingCacheHits += 1; return cached; }
1575
+ embeddingCacheMisses += 1;
1190
1576
  const row = db.prepare("SELECT embedding FROM memories WHERE id = ?").get(id);
1191
1577
  if (!row?.embedding) return undefined;
1192
1578
  try {
1193
- const v = JSON.parse(row.embedding);
1194
- if (Array.isArray(v) && v.length) {
1579
+ const parsed = JSON.parse(row.embedding);
1580
+ if (Array.isArray(parsed) && parsed.length) {
1581
+ const v = new Float32Array(parsed);
1195
1582
  cacheEmbedding(id, v);
1196
1583
  return v;
1197
1584
  }
@@ -1199,6 +1586,27 @@ export function createStore(path) {
1199
1586
  return undefined;
1200
1587
  }
1201
1588
 
1589
+ /** Parsed embedding for one id (cache-first); undefined when none/parse
1590
+ * failure. Single read path shared by searchVector / getEmbedding(s).
1591
+ * Public contract: returns number[] (callers guard with Array.isArray). */
1592
+ function getParsedEmbedding(id) {
1593
+ const v = readVector(id);
1594
+ return v ? Array.from(v) : undefined;
1595
+ }
1596
+
1597
+ /** Cache instrumentation for the regression lock (hit rate must not collapse
1598
+ * back to 0 when the working set outgrows a fixed cap). */
1599
+ function embeddingCacheStats() {
1600
+ const total = embeddingCacheHits + embeddingCacheMisses;
1601
+ return {
1602
+ size: embeddingCache.size,
1603
+ cap: embeddingCacheCap,
1604
+ hits: embeddingCacheHits,
1605
+ misses: embeddingCacheMisses,
1606
+ hitRate: total ? embeddingCacheHits / total : 0
1607
+ };
1608
+ }
1609
+
1202
1610
  // all()/list() 的显式列清单:从实际 schema 派生(迁移后),只排除 embedding
1203
1611
  // 列——consolidation/sleep/inject 的消费方都不读向量,而它是全表里最重的一列
1204
1612
  // (5k 行库 ≈ 9.4 MB TEXT),每次全表调用都白搬。经 PRAGMA 派生而非硬编码,
@@ -1634,6 +2042,11 @@ export function createStore(path) {
1634
2042
  db.prepare("DELETE FROM memory_provenance WHERE memory_id = ?").run(id);
1635
2043
  // #512:铸造记录同理——它也只是指着记忆行的索引,行没了索引就没有指代对象。
1636
2044
  db.prepare("DELETE FROM memory_mints WHERE memory_id = ?").run(id);
2045
+ // #249 第二批:pin 行同理,理由与前两条完全一致(本表建表时也没抄
2046
+ // REFERENCES ... ON DELETE CASCADE,清理不挂驱动默认)。注意这一条**只
2047
+ // 防攒垃圾**,不修「回落被卡住」——注入侧判据看的是「有几条能用的」
2048
+ // 而不是「表里有几行」,见 service.js 注入段与 pinnedStats.source 三档。
2049
+ db.prepare("DELETE FROM pinned_memories WHERE memory_id = ?").run(id);
1637
2050
  db.prepare("DELETE FROM memories WHERE id = ?").run(id);
1638
2051
  // Mirror sync must reflect the deletion; bump desired generation so a
1639
2052
  // crash between the delete and syncMirror leaves a recoverable debt.
@@ -1701,6 +2114,8 @@ export function createStore(path) {
1701
2114
  const marks = chunk.map(() => "?").join(", ");
1702
2115
  db.prepare(`DELETE FROM memory_provenance WHERE memory_id IN (${marks})`).run(...chunk);
1703
2116
  db.prepare(`DELETE FROM memory_mints WHERE memory_id IN (${marks})`).run(...chunk);
2117
+ // 同 remove():pin 行防攒垃圾(不修卡回落的那个判据问题)。
2118
+ db.prepare(`DELETE FROM pinned_memories WHERE memory_id IN (${marks})`).run(...chunk);
1704
2119
  deleted += db.prepare(`DELETE FROM memories WHERE id IN (${marks})`).run(...chunk).changes;
1705
2120
  }
1706
2121
  incrementGeneration();
@@ -2062,7 +2477,10 @@ export function createStore(path) {
2062
2477
  * name; omitting modelHash leaves the column untouched (callers that
2063
2478
  * predate the column still work, their rows simply read as unknown). */
2064
2479
  function setEmbedding(id, vector, modelHash) {
2065
- const json = Array.isArray(vector) && vector.length ? JSON.stringify(vector) : null;
2480
+ // isVectorLike 而非 Array.isArray:调用方可能递进 Float32Array(本模块缓存的
2481
+ // 就是它)。旧守卫对 typed array 返回 false,会把「设置向量」读成「清空向量」,
2482
+ // 静默把行改成 NULL。
2483
+ const json = isVectorLike(vector) && vector.length ? JSON.stringify(Array.from(vector)) : null;
2066
2484
  if (json) {
2067
2485
  if (modelHash !== undefined) {
2068
2486
  db.prepare("UPDATE memories SET embedding = ?, embedding_model = ? WHERE id = ?")
@@ -2075,15 +2493,10 @@ export function createStore(path) {
2075
2493
  }
2076
2494
  // Issue #202:单一写入口即单一失效点——重写缓存条目(向量在手上不必重解析),
2077
2495
  // 清空则移除,删除过的 id 不会被下一次 searchVector 的 id 集合选中。
2078
- if (json) {
2079
- try {
2080
- const parsed = JSON.parse(json);
2081
- if (Array.isArray(parsed) && parsed.length) cacheEmbedding(id, parsed);
2082
- else embeddingCache.delete(id);
2083
- } catch { embeddingCache.delete(id); }
2084
- } else {
2085
- embeddingCache.delete(id);
2086
- }
2496
+ // 缓存里存 Float32Array(与 readVector 同一表示),不存 JSON.parse 出来的
2497
+ // number[],否则写后第一次读还要再转一次。
2498
+ if (json) cacheEmbedding(id, new Float32Array(vector));
2499
+ else embeddingCache.delete(id);
2087
2500
  }
2088
2501
 
2089
2502
  /** Batch fetch stored embeddings by id (v0.5.0 search-time semantic dedup).
@@ -2244,18 +2657,21 @@ export function createStore(path) {
2244
2657
  */
2245
2658
  function vectorDimensionState(dimension) {
2246
2659
  if (!Number.isInteger(dimension) || dimension <= 0) return { state: "invalid" };
2247
- // 与 searchVector 的 WHERE 谓词逐条相同(顺序不同、语义等价)——同源行集,同步改。
2660
+ // 与 searchVector 的行集逐条相同(顺序不同、语义等价)——同源行集,同步改。
2661
+ // 同样不带 `embedding != ''`:只取 id,读 embedding 列纯属白搬;空串行由
2662
+ // readVector 返回 undefined,落到下面 sawVector 分支(不影响 unreadable 判定)。
2248
2663
  const rows = db.prepare(
2249
2664
  `SELECT id FROM memories
2250
- WHERE embedding IS NOT NULL AND embedding != ''
2665
+ WHERE embedding IS NOT NULL
2251
2666
  AND forgotten = 0 AND archived = 0`
2252
2667
  ).all();
2253
2668
  if (!rows.length) return { state: "none" };
2254
2669
  let sawVector = false;
2670
+ noteSweep(rows.length, dimension);
2255
2671
  for (const row of rows) {
2256
- // 走 getParsedEmbedding 的解析缓存,不自己 JSON.parse——与 searchVector /
2257
- // getEmbedding(s) 同一条读路径,缓存热时零解析。
2258
- const v = getParsedEmbedding(row.id);
2672
+ // 走同一解析缓存,不自己 JSON.parse——与 searchVector / getEmbedding(s)
2673
+ // 同一条读路径,缓存热时零解析。
2674
+ const v = readVector(row.id);
2259
2675
  if (!v || !v.length) continue;
2260
2676
  sawVector = true;
2261
2677
  if (v.length === dimension) return { state: "match" };
@@ -2289,7 +2705,7 @@ export function createStore(path) {
2289
2705
  // --- vector search ------------------------------------------------------
2290
2706
 
2291
2707
  function cosine(a, b) {
2292
- if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length || !a.length) return 0;
2708
+ if (!isVectorLike(a) || !isVectorLike(b) || a.length !== b.length || !a.length) return 0;
2293
2709
  let dot = 0;
2294
2710
  let na = 0;
2295
2711
  let nb = 0;
@@ -2312,13 +2728,22 @@ export function createStore(path) {
2312
2728
  // Issue #202:只取 id 列(~40 KB)而非 SELECT *(~11 MB)——向量从缓存取,
2313
2729
  // 缓存未命中时按 id 点查 embedding 再解析回填。SQL 里保留 embedding 的
2314
2730
  // IS NOT NULL 过滤,保证「有向量的行」集合始终以库为准(删除/清空即时生效)。
2731
+ //
2732
+ // 为什么不带 `embedding != ''`(2026-11-05 实测):`!= ''` 要读 embedding 列
2733
+ // 才能判定,SQLite 就得把 134MB 向量文本整个从磁盘搬一遍——而这里只 SELECT
2734
+ // id,一条文本都不用。真库副本实测:这条 SQL 单独跑 265ms → 96ms;落到
2735
+ // 一轮完整检索上是 138ms → 47ms(省约 90ms,其余是解析/打分开销)。
2736
+ // 空串行(外部直接 UPDATE 造出来的,setEmbedding 写的是 NULL)由 readVector
2737
+ // 兜住:JSON.parse('') 抛错 → 返回 undefined → 跳过,既不计分也不崩。
2315
2738
  const rows = db.prepare(
2316
2739
  `SELECT id FROM memories
2317
- WHERE ${archivedFilter}forgotten = 0 AND embedding IS NOT NULL AND embedding != ''`
2740
+ WHERE ${archivedFilter}forgotten = 0 AND embedding IS NOT NULL`
2318
2741
  ).all();
2319
2742
  const scored = [];
2743
+ // 自适应上限:本次要扫这么多行,先让缓存装得下,否则会边扫边驱逐自己。
2744
+ noteSweep(rows.length, vector.length);
2320
2745
  for (const row of rows) {
2321
- const v = getParsedEmbedding(row.id);
2746
+ const v = readVector(row.id);
2322
2747
  if (!v) continue;
2323
2748
  // distill-tx-embed-gap 顺带修:维度不匹配的行按维度分流,直接跳过。旧路径
2324
2749
  // 喂进 cosine 不等长就 return 0,换了模型/维度的旧向量以 0 分混进结果尾部
@@ -2857,6 +3282,84 @@ export function createStore(path) {
2857
3282
  };
2858
3283
  }
2859
3284
 
3285
+ /**
3286
+ * 沉淀曲线(面板「8 周沉淀」图):按天给出「进来的」与「真沉淀的」两条序列,
3287
+ * 用来一眼看出库到底在巩固还是在膨胀——只看新增会以为一直在长,只看总量
3288
+ * 看不出巩固停摆(2026-10-25~10-31 那段新增照旧、合并归零,就是这张图要
3289
+ * 暴露的形态)。
3290
+ *
3291
+ * added = 当天新建记忆。排除外来导入行(本库不为别人导入的原文记账,
3292
+ * 见 memory-source.js);`source IS NULL OR` 那一支是必填——
3293
+ * SQL 三值逻辑让 `NULL <> 'imported'` 求值为 NULL 而非 true,
3294
+ * 只写后者的版本会让本方行(source 恒为 NULL)整批消失。
3295
+ * merged = 当天 receipt_chain 里 kind∈(merge,update) 且 verdict='live' 的
3296
+ * **去重 record_id 数**,不是行数。同一条记录被多轮巩固重复记账
3297
+ * 是常态(活库实测 2412 行只对应 1796 个记录,见 #559 期间的
3298
+ * 核对),按行数会把「又记了一遍」算成「又沉淀了一条」。口径与
3299
+ * store 里「已巩固」的既有子查询同源(kind IN ('merge','update')
3300
+ * AND verdict='live'),不另立一套。
3301
+ * archived = 当天进入归档面的条数(archived_at 幂等,见其注释:已归档再
3302
+ * 归档不刷新,取消归档置 NULL),所以日聚合 = 当天新归档条数。
3303
+ *
3304
+ * 三列都按 UTC 日期切分,与 getLlmAuditStats 的 by_day 同一套日期口径,
3305
+ * 前端补零/对齐逻辑因此可以复用。
3306
+ *
3307
+ * 只返回有数据的日期(GROUP BY 不补零),与 by_day 同形——补零是展示层的事,
3308
+ * 后端补零会让「未知」和「已知为空」在响应里长得一样。
3309
+ */
3310
+ function getConsolidationStats({ days = 56 } = {}) {
3311
+ const span = Math.max(1, Math.min(365, Math.floor(Number(days)) || 56));
3312
+ const since = new Date(Date.now() - span * 86400000).toISOString();
3313
+ const added = db.prepare(
3314
+ `SELECT date(created_at) AS day, count(*) AS c
3315
+ FROM memories
3316
+ WHERE created_at >= ? AND (source IS NULL OR source <> ?)
3317
+ GROUP BY date(created_at) ORDER BY day ASC`
3318
+ ).all(since, IMPORTED_SOURCE);
3319
+ const merged = db.prepare(
3320
+ `SELECT day, count(*) AS c FROM (
3321
+ SELECT date(created_at) AS day, record_id
3322
+ FROM receipt_chain
3323
+ WHERE created_at >= ? AND kind IN ('merge', 'update') AND verdict = 'live'
3324
+ GROUP BY day, record_id
3325
+ ) GROUP BY day ORDER BY day ASC`
3326
+ ).all(since);
3327
+ const archived = db.prepare(
3328
+ `SELECT date(archived_at) AS day, count(*) AS c
3329
+ FROM memories
3330
+ WHERE archived_at IS NOT NULL AND archived_at >= ? AND (source IS NULL OR source <> ?)
3331
+ GROUP BY date(archived_at) ORDER BY day ASC`
3332
+ ).all(since, IMPORTED_SOURCE);
3333
+ // 三串按天并进一张表:三条 SQL 各扫各的索引,比一次 UNION + 外层聚合好读,
3334
+ // 也让某一段全 0 时不会因为缺行而丢掉这一天(缺的是 0,不是不存在)。
3335
+ const byDay = new Map();
3336
+ const bump = (rows, key) => {
3337
+ for (const r of rows) {
3338
+ const day = String(r.day ?? "");
3339
+ if (!day) continue;
3340
+ const cur = byDay.get(day) ?? { day, added: 0, merged: 0, archived: 0 };
3341
+ cur[key] = Number(r.c) || 0;
3342
+ byDay.set(day, cur);
3343
+ }
3344
+ };
3345
+ bump(added, "added");
3346
+ bump(merged, "merged");
3347
+ bump(archived, "archived");
3348
+ const list = [...byDay.values()].sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0));
3349
+ const sum = list.reduce(
3350
+ (acc, r) => ({ added: acc.added + r.added, merged: acc.merged + r.merged, archived: acc.archived + r.archived }),
3351
+ { added: 0, merged: 0, archived: 0 }
3352
+ );
3353
+ return {
3354
+ days: span,
3355
+ since,
3356
+ by_day: list,
3357
+ total_added: sum.added,
3358
+ total_merged: sum.merged,
3359
+ total_archived: sum.archived
3360
+ };
3361
+ }
3362
+
2860
3363
  /** Delete audit rows older than `before` (ISO string). Returns count removed. */
2861
3364
  function deleteOldLlmAudits(before) {
2862
3365
  return db.prepare("DELETE FROM llm_audit_logs WHERE timestamp < ?").run(before).changes;
@@ -3477,6 +3980,85 @@ export function createStore(path) {
3477
3980
  return rows.map(toScopeState);
3478
3981
  }
3479
3982
 
3983
+ // --- pinned_memories: 显式 pin(#249 第二批) ------------------------------
3984
+ // 四个函数只管「哪条被钉住了」这条事实,不回答「该不该注入」——注入侧的
3985
+ // 越权/暂停作用域/遗忘判定一律留在 service(与 strictScope、scopeStateGate
3986
+ // 同处,见 injectCandidates 的位置纪律)。存储层越薄,越不会被绕过。
3987
+
3988
+ /**
3989
+ * 全部 pin 行,按 pin 先后(created_at ASC)——注入就按这个序前置,**先钉的
3990
+ * 排在最前**。预算不够(钉的条数 > pinnedInjectBudget)时,被 eligible.slice
3991
+ * 取走的是最早钉的那几条,最近才钉的排后面、先被挤掉。
3992
+ *
3993
+ * 这个方向和「稳定偏好」的理由是一致的:pin 表达的是「这条我要长期带着」,
3994
+ * 越早做出这个承诺、越久没被撤回,就越该占住名额;若反过来让新钉的优先,
3995
+ * pin 就退化成「最近关注」清单,和 updated_at DESC 那个错误排序键同病。
3996
+ *
3997
+ * 注意排序键是「什么时候钉的」,不是内容的 updated_at——一条 10 月的老记忆
3998
+ * 今天才钉,它在 pin 序里是新的、在内容上是老的,两个轴不要混。
3999
+ * 次键 rowid:同毫秒连钉两条时 UUID 主键排序是随机的,回执要可复现。
4000
+ */
4001
+ function listPinned() {
4002
+ return db.prepare("SELECT * FROM pinned_memories ORDER BY created_at ASC, rowid ASC").all().map(toPinned);
4003
+ }
4004
+
4005
+ /**
4006
+ * 钉住一条记忆。重复 pin 同一 id 不刷新 created_at(只补 note)——pin 顺序
4007
+ * 是「第一次钉的时间」,重钉不该把老偏好的位置往后推。调用方负责确认目标
4008
+ * 记忆存在;这里不做外键校验(本库其余表同款:关系表不设 FK,删记忆时由
4009
+ * unpinMemory 或下一次读的 forgotten/archived 过滤兜住)。
4010
+ */
4011
+ function pinMemory(memoryId, { note, created_at } = {}) {
4012
+ if (typeof memoryId !== "string" || !memoryId.trim()) return undefined;
4013
+ const id = memoryId.trim();
4014
+ const at = normalizeOccurredAt(created_at) ?? nowIso();
4015
+ db.prepare(
4016
+ `INSERT INTO pinned_memories (memory_id, created_at, note) VALUES (?, ?, ?)
4017
+ ON CONFLICT(memory_id) DO UPDATE SET note = COALESCE(excluded.note, pinned_memories.note)`
4018
+ ).run(id, at, normalizeScopeText(note));
4019
+ return toPinned(db.prepare("SELECT * FROM pinned_memories WHERE memory_id = ?").get(id));
4020
+ }
4021
+
4022
+ /** 松开一条 pin。返回实际删掉的行数(0 = 本来就没钉,不是错误)。 */
4023
+ function unpinMemory(memoryId) {
4024
+ if (typeof memoryId !== "string" || !memoryId.trim()) return 0;
4025
+ return db.prepare("DELETE FROM pinned_memories WHERE memory_id = ?").run(memoryId.trim()).changes;
4026
+ }
4027
+
4028
+ /** pin 行数。注入侧靠它判「表是空的」——空表要回落到旧的类型口径。 */
4029
+ function countPinned() {
4030
+ return db.prepare("SELECT count(*) AS c FROM pinned_memories").get().c;
4031
+ }
4032
+
4033
+ /**
4034
+ * 这条记忆是否被显式钉住(单行点查,读侧)。
4035
+ *
4036
+ * 存在的理由是 #593 的缺口:升格吸收(document.js 的 evidence 归档)只拿得到
4037
+ * 记忆行、拿不到 pin 表,于是它只能按**类型**猜「谁属于保真池」。显式 pin 落地
4038
+ * 后,一条 pattern / decision 被钉住仍然是 pin,按类型猜就把它当普通行收走了。
4039
+ * 谓词必须来自本表,不能从类型推——这正是 #593 把 pin 从类型推断改成显式存储
4040
+ * 的同一件事,别在第二个地方又推回去。
4041
+ */
4042
+ function isPinned(memoryId) {
4043
+ if (typeof memoryId !== "string" || !memoryId.trim()) return false;
4044
+ return db.prepare("SELECT 1 AS hit FROM pinned_memories WHERE memory_id = ?").get(memoryId.trim()) !== undefined;
4045
+ }
4046
+
4047
+ /**
4048
+ * 按 id 点查注入所需的行(绕开 200 行窗口的唯一通道)。
4049
+ *
4050
+ * 显式列清单复用 memoryColumnList:它经 PRAGMA 派生、已排除 embedding
4051
+ * (1024 维浮点文本 ≈ 34KB/条,5 条就白搬 170KB 而注入渲染根本不用向量)。
4052
+ * 返回顺序不保证(IN 的求值序),调用方按自己的次序(pin created_at)重排。
4053
+ */
4054
+ function listByIdsForInject(ids) {
4055
+ const wanted = Array.isArray(ids) ? ids.filter((id) => typeof id === "string" && id) : [];
4056
+ if (wanted.length === 0) return [];
4057
+ const placeholders = wanted.map(() => "?").join(", ");
4058
+ return db.prepare(`SELECT ${memoryColumnList} FROM memories WHERE id IN (${placeholders})`)
4059
+ .all(...wanted).map(toRow);
4060
+ }
4061
+
3480
4062
  function getFailureStats({ since } = {}) {
3481
4063
  const clause = since ? "WHERE created_at >= ?" : "";
3482
4064
  const params = since ? [since] : [];
@@ -3926,6 +4508,16 @@ export function createStore(path) {
3926
4508
  return rows.map(toEntity);
3927
4509
  }
3928
4510
 
4511
+ /**
4512
+ * 实体总行数(全库,不受 limit 影响)。目录路由的 `total` 必须走这里:
4513
+ * 它此前填的是 `entities.length`(本次切片长度),于是库里一万六千个实体
4514
+ * 在面板上永远显示 500 —— 数字看着像"实体太少",其实是被上限截断了。
4515
+ * 与 listEntities 同源同表,只差一个 COUNT。
4516
+ */
4517
+ function countEntities() {
4518
+ return db.prepare("SELECT COUNT(*) AS n FROM entities").get().n;
4519
+ }
4520
+
3929
4521
  // --- 图召回轴(issue #219)-------------------------------------------------
3930
4522
 
3931
4523
  /**
@@ -4215,6 +4807,327 @@ export function createStore(path) {
4215
4807
  });
4216
4808
  }
4217
4809
 
4810
+ /* --------------------- chorography(仓库地志,M0 只写不读) --------------------- */
4811
+ // 消费面(检索 / 注入)在 M1 才接,这里只落写入口。时间戳由调用方传:一次 run
4812
+ // 的所有行共用同一个时刻(生成的页正文里也引用它),由 t0.js 的单一时钟保证,
4813
+ // store 不自取 now()——两处时钟会把「同一 run」拆成两个版本。
4814
+
4815
+ function choroRepoByRoot(rootPath) {
4816
+ return db.prepare("SELECT * FROM choro_repos WHERE root_path = ?").get(rootPath) ?? undefined;
4817
+ }
4818
+
4819
+ // 登记或刷新仓库行。created_at 只在首次插入时落(ON CONFLICT 不碰它);
4820
+ // plan_fingerprint / git_baseline 永远反映上次成功 run——失败 run 不写这两格
4821
+ // (红线 5「失败不推进 state」,库侧与 state.json 同一口径)。
4822
+ function upsertChoroRepo(rootPath, name, planFingerprint, gitBaseline, updatedAt) {
4823
+ db.prepare(
4824
+ `INSERT INTO choro_repos (root_path, name, plan_fingerprint, git_baseline, created_at, updated_at)
4825
+ VALUES (?, ?, ?, ?, ?, ?)
4826
+ ON CONFLICT(root_path) DO UPDATE SET
4827
+ name = excluded.name,
4828
+ plan_fingerprint = excluded.plan_fingerprint,
4829
+ git_baseline = excluded.git_baseline,
4830
+ updated_at = excluded.updated_at`
4831
+ ).run(rootPath, name, planFingerprint, gitBaseline, updatedAt, updatedAt);
4832
+ return choroRepoByRoot(rootPath);
4833
+ }
4834
+
4835
+ // 整仓替换(实现计划 P7):一次成功 run 的模块/页面/claims 全量重写。幂等语义
4836
+ // 在这里成型:重跑同一划分 = 删了重插同样内容,行不翻倍;划分变了也不会留孤儿行
4837
+ // (旧模块的页面与 claims 随模块一起删)。事务包住删除与插入,半截失败整体回滚,
4838
+ // 库里保持上一次成功 run 的完整图。
4839
+ // 父链两段式落(先全插再补 parent_id):父模块的行号在插入前不存在,按 slug 排序
4840
+ // 的单遍插入会遇到「父在后」的顺序问题,两段式与顺序无关。
4841
+ function replaceChoroGraph(repoId, snapshot) {
4842
+ return runAtomically(() => {
4843
+ db.prepare(
4844
+ `DELETE FROM choro_claims WHERE page_id IN (
4845
+ SELECT p.id FROM choro_pages p JOIN choro_modules m ON p.module_id = m.id
4846
+ WHERE m.repo_id = ?)`
4847
+ ).run(repoId);
4848
+ db.prepare(
4849
+ "DELETE FROM choro_pages WHERE module_id IN (SELECT id FROM choro_modules WHERE repo_id = ?)"
4850
+ ).run(repoId);
4851
+ db.prepare("DELETE FROM choro_edges WHERE repo_id = ?").run(repoId);
4852
+ db.prepare("DELETE FROM choro_modules WHERE repo_id = ?").run(repoId);
4853
+
4854
+ const insModule = db.prepare(
4855
+ `INSERT INTO choro_modules (repo_id, slug, title, parent_id, file_count, fingerprint, updated_at)
4856
+ VALUES (?, ?, ?, NULL, ?, ?, ?)`
4857
+ );
4858
+ const ids = new Map();
4859
+ for (const mod of snapshot.modules) {
4860
+ const info = insModule.run(
4861
+ repoId, mod.slug, mod.title, mod.fileCount, mod.fingerprint, snapshot.updatedAt
4862
+ );
4863
+ ids.set(mod.slug, Number(info.lastInsertRowid));
4864
+ }
4865
+ const setParent = db.prepare("UPDATE choro_modules SET parent_id = ? WHERE id = ?");
4866
+ for (const mod of snapshot.modules) {
4867
+ if (mod.parentSlug == null) continue;
4868
+ const parentId = ids.get(mod.parentSlug);
4869
+ if (parentId != null) setParent.run(parentId, ids.get(mod.slug));
4870
+ }
4871
+
4872
+ const insPage = db.prepare(
4873
+ `INSERT INTO choro_pages
4874
+ (module_id, rel_path, status, git_baseline, template_version, generated_at, human_block_count)
4875
+ VALUES (?, ?, ?, ?, ?, ?, ?)`
4876
+ );
4877
+ const insClaim = db.prepare(
4878
+ `INSERT INTO choro_claims
4879
+ (page_id, statement, kind, source_path, start_line, end_line, confidence, created_at)
4880
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
4881
+ );
4882
+ for (const page of snapshot.pages) {
4883
+ const moduleId = ids.get(page.moduleSlug);
4884
+ if (moduleId == null) continue; // 划分与页面同源生成,走到这里说明调用方组装错了
4885
+ const pageInfo = insPage.run(
4886
+ moduleId, page.relPath, page.status, page.gitBaseline,
4887
+ page.templateVersion, page.generatedAt, page.humanBlockCount
4888
+ );
4889
+ for (const claim of page.claims ?? []) {
4890
+ insClaim.run(
4891
+ Number(pageInfo.lastInsertRowid), claim.statement, claim.kind,
4892
+ claim.sourcePath ?? null, claim.startLine ?? null, claim.endLine ?? null,
4893
+ claim.confidence ?? null, snapshot.updatedAt
4894
+ );
4895
+ }
4896
+ }
4897
+ // 跨模块边(P8):from/to 都是模块 slug,只存 from≠to 的;同向多条文件级
4898
+ // 边聚合为一条带 weight 的模块级边。
4899
+ const insEdge = db.prepare(
4900
+ "INSERT INTO choro_edges (repo_id, from_slug, to_slug, weight) VALUES (?, ?, ?, ?)"
4901
+ );
4902
+ let edgeCount = 0;
4903
+ for (const edge of snapshot.edges ?? []) {
4904
+ if (edge.fromSlug === edge.toSlug) continue;
4905
+ insEdge.run(repoId, edge.fromSlug, edge.toSlug, edge.weight ?? 1);
4906
+ edgeCount++;
4907
+ }
4908
+ return { modules: snapshot.modules.length, pages: snapshot.pages.length, edges: edgeCount };
4909
+ });
4910
+ }
4911
+
4912
+ // T1 的页级读写(实现计划 P16–P18):与 replaceChoroGraph 的整仓替换并存——
4913
+ // T1 是逐模块的(一轮只动一页),整仓替换会把其他页的富化状态抹掉。
4914
+ function choroPageBySlug(repoId, slug) {
4915
+ return db.prepare(
4916
+ `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
4918
+ FROM choro_pages p JOIN choro_modules m ON p.module_id = m.id
4919
+ WHERE m.repo_id = ? AND m.slug = ?`
4920
+ ).get(repoId, String(slug));
4921
+ }
4922
+
4923
+ // 单页失败记账(spec 步 12「失败两次即记 failed」):首次失败只计数、状态不动
4924
+ // (骨架仍是当前有效产物),第二次到阈值才把 status 推成 failed。generatedAt
4925
+ // 不动——失败不推进 state(红线 5)的页级口径。
4926
+ function recordChoroPageFailure(pageId, failThreshold = 2) {
4927
+ db.prepare("UPDATE choro_pages SET fail_count = fail_count + 1 WHERE id = ?").run(pageId);
4928
+ const row = db.prepare("SELECT fail_count, status FROM choro_pages WHERE id = ?").get(pageId);
4929
+ if (row.fail_count >= failThreshold) {
4930
+ db.prepare("UPDATE choro_pages SET status = 'failed' WHERE id = ?").run(pageId);
4931
+ row.status = "failed";
4932
+ }
4933
+ return row;
4934
+ }
4935
+
4936
+ // 富化成功:skeleton → enriched,generatedAt 推进、失败计数清零(成功重置的是
4937
+ // 「连续失败」的连续性,不是历史)。
4938
+ function markChoroPageEnriched(pageId, generatedAt) {
4939
+ db.prepare(
4940
+ "UPDATE choro_pages SET status = 'enriched', generated_at = ?, fail_count = 0 WHERE id = ?"
4941
+ ).run(generatedAt, pageId);
4942
+ }
4943
+
4944
+ // 单页 claims 整页替换(P20 的表侧):T1 一轮产出的 claims 删旧插新;evidence/
4945
+ // JSON 由 t1.js 同步写盘(两处同源同写)。
4946
+ function saveChoroPageClaims(pageId, claims) {
4947
+ return runAtomically(() => {
4948
+ db.prepare("DELETE FROM choro_claims WHERE page_id = ?").run(pageId);
4949
+ const ins = db.prepare(
4950
+ `INSERT INTO choro_claims
4951
+ (page_id, statement, kind, source_path, start_line, end_line, confidence, created_at)
4952
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
4953
+ );
4954
+ for (const claim of claims) {
4955
+ ins.run(
4956
+ pageId, claim.statement, claim.kind,
4957
+ claim.sourcePath ?? null, claim.startLine ?? null, claim.endLine ?? null,
4958
+ claim.confidence ?? null, claim.createdAt
4959
+ );
4960
+ }
4961
+ return claims.length;
4962
+ });
4963
+ }
4964
+
4965
+ // --- chorography 向量(收尾档 P36–P38)---------------------------------
4966
+ // 一页一行(saveChoroPageVector 先删后插);dim 失配由检索侧判(查询向量
4967
+ // 的 dim ≠ 行 dim → 该行按 stale 处理,惰性全量重嵌)。
4968
+ function getChoroPageVectors(repoId) {
4969
+ return db.prepare(
4970
+ `SELECT v.owner_id, v.dim, v.model, v.vector, v.source_generated_at,
4971
+ m.slug, m.title, p.generated_at AS page_generated_at
4972
+ FROM choro_vectors v
4973
+ JOIN choro_pages p ON v.owner_id = p.id
4974
+ JOIN choro_modules m ON p.module_id = m.id
4975
+ WHERE m.repo_id = ?`
4976
+ ).all(repoId);
4977
+ }
4978
+
4979
+ function saveChoroPageVector(pageId, { vector, dim, model, sourceGeneratedAt, createdAt }) {
4980
+ return runAtomically(() => {
4981
+ db.prepare("DELETE FROM choro_vectors WHERE owner_id = ?").run(pageId);
4982
+ db.prepare(
4983
+ `INSERT INTO choro_vectors (owner_id, dim, model, vector, source_generated_at, created_at)
4984
+ VALUES (?, ?, ?, ?, ?, ?)`
4985
+ ).run(pageId, dim, model, JSON.stringify(vector), sourceGeneratedAt, createdAt);
4986
+ return { owner_id: pageId, dim };
4987
+ });
4988
+ }
4989
+
4990
+ // --- 「Agent 眼中的你」自动事实(2026-11-06,profile-facts 域)---
4991
+ // 三张表只服务本域,写路径全部走 runAtomically:facts 与记忆主表零交叉,不需要
4992
+ // service.transaction 的 pendingEmbed/pendingEntity 截断语义(那层是给写记忆行用的)。
4993
+
4994
+ function parseFactIds(text) {
4995
+ if (!text) return [];
4996
+ try {
4997
+ const v = JSON.parse(text);
4998
+ return Array.isArray(v) ? v.map((x) => String(x)) : [];
4999
+ } catch { return []; }
5000
+ }
5001
+
5002
+ function listProfileFacts(agentKey, { includeArchived = false } = {}) {
5003
+ const rows = db.prepare(
5004
+ `SELECT * FROM profile_facts WHERE agent_key = ? ${includeArchived ? "" : "AND status = 'active'"} ORDER BY updated_at DESC, id DESC`
5005
+ ).all(agentKey);
5006
+ return rows.map((r) => ({ ...r, source_memory_ids: parseFactIds(r.source_memory_ids) }));
5007
+ }
5008
+
5009
+ /** 全库活跃事实按卡分组(注入 provider 的读口,见 src/profile-facts.js)。 */
5010
+ function listActiveFactsGrouped() {
5011
+ const rows = db.prepare(
5012
+ "SELECT agent_key, content FROM profile_facts WHERE status = 'active' ORDER BY updated_at DESC"
5013
+ ).all();
5014
+ const grouped = { default: [], byAgent: {} };
5015
+ for (const r of rows) {
5016
+ if (r.agent_key === "") grouped.default.push(r.content);
5017
+ else (grouped.byAgent[r.agent_key] ??= []).push(r.content);
5018
+ }
5019
+ return grouped;
5020
+ }
5021
+
5022
+ function getProfileFact(id) {
5023
+ const r = db.prepare("SELECT * FROM profile_facts WHERE id = ?").get(id);
5024
+ return r ? { ...r, source_memory_ids: parseFactIds(r.source_memory_ids) } : undefined;
5025
+ }
5026
+
5027
+ function insertProfileFact({ id, agentKey, content, sourceMemoryIds = [], at }) {
5028
+ db.prepare(
5029
+ "INSERT INTO profile_facts (id, agent_key, content, status, source_memory_ids, created_at, updated_at) VALUES (?, ?, ?, 'active', ?, ?, ?)"
5030
+ ).run(id, agentKey, String(content), JSON.stringify(sourceMemoryIds ?? []), at, at);
5031
+ }
5032
+
5033
+ /** 返回更新后的行;id 不存在返回 undefined,由调用方记入 failures。 */
5034
+ function updateProfileFact(id, { content, status, sourceMemoryIds, at }) {
5035
+ const cur = getProfileFact(id);
5036
+ if (!cur) return undefined;
5037
+ const next = {
5038
+ content: content ?? cur.content,
5039
+ status: status ?? cur.status,
5040
+ source_memory_ids: sourceMemoryIds ?? cur.source_memory_ids,
5041
+ updated_at: at
5042
+ };
5043
+ db.prepare("UPDATE profile_facts SET content = ?, status = ?, source_memory_ids = ?, updated_at = ? WHERE id = ?")
5044
+ .run(next.content, next.status, JSON.stringify(next.source_memory_ids), next.updated_at, id);
5045
+ return { ...cur, ...next };
5046
+ }
5047
+
5048
+ function insertProfileFactJournal({ at, agentKey, runId = null, factId = null, op, before = null, after = null, actor = "auto" }) {
5049
+ db.prepare(
5050
+ "INSERT INTO profile_fact_journal (at, agent_key, run_id, fact_id, op, before, after, actor) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
5051
+ ).run(at, agentKey, runId, factId, op, before, after, actor);
5052
+ }
5053
+
5054
+ function listProfileFactJournal(agentKey, { limit = 20 } = {}) {
5055
+ return db.prepare("SELECT * FROM profile_fact_journal WHERE agent_key = ? ORDER BY id DESC LIMIT ?")
5056
+ .all(agentKey, limit);
5057
+ }
5058
+
5059
+ /** 每卡只留最近 keep 条:账本是审计事实不是检索面,无界增长没有读者。 */
5060
+ function pruneProfileFactJournal(agentKey, keep = 200) {
5061
+ return db.prepare(
5062
+ `DELETE FROM profile_fact_journal WHERE agent_key = ? AND id NOT IN (
5063
+ SELECT id FROM profile_fact_journal WHERE agent_key = ? ORDER BY id DESC LIMIT ?)`
5064
+ ).run(agentKey, agentKey, keep).changes;
5065
+ }
5066
+
5067
+ function insertProfileFactRun({ id, agentKey, triggerSource, status, cursor = null, inputCount = 0, opsApplied = 0, detail = null, startedAt, finishedAt }) {
5068
+ db.prepare(
5069
+ `INSERT INTO profile_fact_runs (id, agent_key, trigger_source, status, cursor, input_count, ops_applied, detail, started_at, finished_at)
5070
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
5071
+ ).run(id, agentKey, triggerSource, status, cursor, inputCount, opsApplied, detail, startedAt, finishedAt);
5072
+ }
5073
+
5074
+ function listProfileFactRuns(agentKey, { limit = 10 } = {}) {
5075
+ return db.prepare("SELECT * FROM profile_fact_runs WHERE agent_key = ? ORDER BY started_at DESC, id DESC LIMIT ?")
5076
+ .all(agentKey, limit);
5077
+ }
5078
+
5079
+ /** 该卡最近一次 ok 轮的游标。failed 轮不消费——下次重跑同一窗口(蒸馏游标同款纪律)。 */
5080
+ function profileFactsCursor(agentKey) {
5081
+ const row = db.prepare(
5082
+ "SELECT cursor FROM profile_fact_runs WHERE agent_key = ? AND status = 'ok' ORDER BY started_at DESC, id DESC LIMIT 1"
5083
+ ).get(agentKey);
5084
+ return row ? row.cursor : null;
5085
+ }
5086
+
5087
+ /**
5088
+ * 增量输入行:活跃(未归档未遗忘)+ 排除外来行(source IS NULL OR 那一支是三值
5089
+ * 逻辑必填,与 list() 的 excludeImported 同一个坑)+ 严格 scope 分区——agent 卡
5090
+ * 只吃 agent_scope=自己 的行,default 卡只吃 agent_scope IS NULL 的行。
5091
+ * updated_at 是 ISO 串,字典序即时间序,`>` 即「游标之后」(单调时间戳纪律)。
5092
+ */
5093
+ function listFactInputRows(agentKey, { since = null, limit = 120 } = {}) {
5094
+ const scopeClause = agentKey ? "agent_scope = ?" : "agent_scope IS NULL";
5095
+ const params = [IMPORTED_SOURCE, ...(agentKey ? [agentKey] : [])];
5096
+ if (since) params.push(since);
5097
+ params.push(limit);
5098
+ return db.prepare(
5099
+ `SELECT id, type, title, content, importance, updated_at FROM memories
5100
+ WHERE forgotten = 0 AND archived = 0 AND (source IS NULL OR source <> ?)
5101
+ AND ${scopeClause}
5102
+ ${since ? "AND updated_at > ?" : ""}
5103
+ ORDER BY updated_at DESC LIMIT ?`
5104
+ ).all(...params);
5105
+ }
5106
+
5107
+ function countFactInputRows(agentKey, { since = null } = {}) {
5108
+ const scopeClause = agentKey ? "agent_scope = ?" : "agent_scope IS NULL";
5109
+ const params = [IMPORTED_SOURCE, ...(agentKey ? [agentKey] : [])];
5110
+ if (since) params.push(since);
5111
+ return db.prepare(
5112
+ `SELECT COUNT(*) AS n FROM memories
5113
+ WHERE forgotten = 0 AND archived = 0 AND (source IS NULL OR source <> ?)
5114
+ AND ${scopeClause}
5115
+ ${since ? "AND updated_at > ?" : ""}`
5116
+ ).get(...params).n;
5117
+ }
5118
+
5119
+ /**
5120
+ * 自动轮要维护的卡:有 scope 行的记忆身份 ∪ 存量卡片。存量卡即使输入行被清空
5121
+ * 也保留在名单里(手动轮还可能对它做人工修正后的再维护);'' 由调用方固定追加。
5122
+ */
5123
+ function listFactCardKeys() {
5124
+ return db.prepare(
5125
+ `SELECT DISTINCT agent_scope AS k FROM memories WHERE agent_scope IS NOT NULL
5126
+ UNION
5127
+ SELECT DISTINCT agent_key FROM profile_facts WHERE agent_key <> ''`
5128
+ ).all().map((r) => r.k);
5129
+ }
5130
+
4218
5131
  return {
4219
5132
  db,
4220
5133
  count,
@@ -4257,6 +5170,7 @@ export function createStore(path) {
4257
5170
  setEmbedding,
4258
5171
  getEmbeddings,
4259
5172
  getParsedEmbedding,
5173
+ embeddingCacheStats,
4260
5174
  embeddedCount,
4261
5175
  needsEmbedding,
4262
5176
  needsEmbeddingFor,
@@ -4294,6 +5208,7 @@ export function createStore(path) {
4294
5208
  listLlmAudits,
4295
5209
  countLlmAudits,
4296
5210
  getLlmAuditStats,
5211
+ getConsolidationStats,
4297
5212
  deleteOldLlmAudits,
4298
5213
  // 蒸馏原始记录与记忆级溯源(#399):窗口凭证的写入/统计/裁剪,链接的读写。
4299
5214
  saveDistillRecords,
@@ -4340,10 +5255,20 @@ export function createStore(path) {
4340
5255
  setScopeState,
4341
5256
  getScopeState,
4342
5257
  listScopeStates,
5258
+ // #249 第二批:显式 pin(独立表,绕开候选池的通道)。注入侧只读这里,
5259
+ // 不再拿 PINNED_MEMORY_TYPES 当 pin 标记(那个常量在 write-admission 里
5260
+ // 另有写入配额豁免的语义,两件事不再叠在同一个名字上)。
5261
+ listPinned,
5262
+ pinMemory,
5263
+ unpinMemory,
5264
+ countPinned,
5265
+ isPinned,
5266
+ listByIdsForInject,
4343
5267
  createEntity,
4344
5268
  findEntityByName,
4345
5269
  findEntityById,
4346
5270
  listEntities,
5271
+ countEntities,
4347
5272
  updateEntity,
4348
5273
  saveAttr,
4349
5274
  invalidateOldAttr,
@@ -4378,6 +5303,32 @@ export function createStore(path) {
4378
5303
  getContinuityProposal,
4379
5304
  listContinuityProposals,
4380
5305
  countContinuityProposals,
5306
+ // chorography(仓库地志):M0 只写不读,消费面 M1 接(见上面注释块)。
5307
+ choroRepoByRoot,
5308
+ upsertChoroRepo,
5309
+ replaceChoroGraph,
5310
+ choroPageBySlug,
5311
+ recordChoroPageFailure,
5312
+ markChoroPageEnriched,
5313
+ saveChoroPageClaims,
5314
+ getChoroPageVectors,
5315
+ saveChoroPageVector,
5316
+ // 「Agent 眼中的你」自动事实(2026-11-06):读口与写口都只给 domain 层
5317
+ // (src/profile-facts.js)和 API 层用,记忆主表路径不经此。
5318
+ listProfileFacts,
5319
+ listActiveFactsGrouped,
5320
+ getProfileFact,
5321
+ insertProfileFact,
5322
+ updateProfileFact,
5323
+ insertProfileFactJournal,
5324
+ listProfileFactJournal,
5325
+ pruneProfileFactJournal,
5326
+ insertProfileFactRun,
5327
+ listProfileFactRuns,
5328
+ profileFactsCursor,
5329
+ listFactInputRows,
5330
+ countFactInputRows,
5331
+ listFactCardKeys,
4381
5332
  close() {
4382
5333
  db.close();
4383
5334
  }