@modusensus/dsh-mneme 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/README.md +48 -19
  2. package/docs/CONFIGURATION.md +43 -4
  3. package/docs/DIRECTORIES.md +114 -0
  4. package/docs/DISTILL.md +64 -0
  5. package/docs/DSH-GO.md +97 -0
  6. package/docs/ENTITIES.md +5 -3
  7. package/docs/FORGET.md +77 -0
  8. package/docs/KB.md +110 -0
  9. package/docs/SLEEP.md +28 -3
  10. package/docs/STORAGE.md +23 -0
  11. package/lib/api-standalone.js +4 -1
  12. package/lib/api.js +581 -29
  13. package/lib/attachment-text.js +122 -0
  14. package/lib/choro-command.js +9 -2
  15. package/lib/client.js +3537 -1205
  16. package/lib/config.js +139 -1
  17. package/lib/continuity.js +7 -6
  18. package/lib/diary/auto.js +190 -0
  19. package/lib/diary/compose.js +440 -0
  20. package/lib/diary/dir.js +59 -0
  21. package/lib/diary/export.js +97 -0
  22. package/lib/diary/index.js +26 -0
  23. package/lib/diary/library.js +189 -0
  24. package/lib/diary/qq-inbox.js +142 -0
  25. package/lib/diary/rollover.js +61 -0
  26. package/lib/diary/session-provider.js +122 -0
  27. package/lib/dream/decisions.js +56 -5
  28. package/lib/dream/sleep.js +616 -15
  29. package/lib/dream.js +45 -13
  30. package/lib/dsh-go/host.js +162 -0
  31. package/lib/dsh-go/panel.js +2091 -0
  32. package/lib/dsh-go/text.js +342 -0
  33. package/lib/dsh-go/views/common.js +68 -0
  34. package/lib/dsh-go/views/entity.js +48 -0
  35. package/lib/dsh-go/views/memory.js +198 -0
  36. package/lib/dsh-go/views/root.js +30 -0
  37. package/lib/dsh-go/views/settings.js +398 -0
  38. package/lib/dsh-go/views/status.js +51 -0
  39. package/lib/embedding.js +42 -8
  40. package/lib/entities/extractor.js +36 -7
  41. package/lib/hot-memory.js +8 -9
  42. package/lib/index.js +92 -2
  43. package/lib/inject.js +189 -18
  44. package/lib/kb/index.js +309 -0
  45. package/lib/kb/routes.js +220 -0
  46. package/lib/kb/tools.js +148 -0
  47. package/lib/lang.js +87 -4
  48. package/lib/peak-hours.js +57 -2
  49. package/lib/semantic.js +347 -60
  50. package/lib/service.js +193 -66
  51. package/lib/settings.js +112 -14
  52. package/lib/store.js +347 -21
  53. package/lib/summarize.js +38 -11
  54. package/lib/tools.js +35 -4
  55. package/lib/user-settings.js +128 -32
  56. package/lib/vector-index.js +76 -6
  57. package/lib/wiki/cli-hint.js +90 -0
  58. package/lib/wiki/queries.js +116 -2
  59. package/lib/wiki/t1-jobs.js +232 -0
  60. package/lib/wiki/t1.js +7 -1
  61. package/package.json +25 -1
  62. package/src/api-standalone.js +4 -1
  63. package/src/api.js +581 -29
  64. package/src/attachment-text.js +122 -0
  65. package/src/choro-command.js +9 -2
  66. package/src/config.js +139 -1
  67. package/src/continuity.js +7 -6
  68. package/src/diary/auto.js +190 -0
  69. package/src/diary/compose.js +440 -0
  70. package/src/diary/dir.js +59 -0
  71. package/src/diary/export.js +97 -0
  72. package/src/diary/index.js +26 -0
  73. package/src/diary/library.js +189 -0
  74. package/src/diary/qq-inbox.js +142 -0
  75. package/src/diary/rollover.js +61 -0
  76. package/src/diary/session-provider.js +122 -0
  77. package/src/dream/decisions.js +56 -5
  78. package/src/dream/sleep.js +616 -15
  79. package/src/dream.js +45 -13
  80. package/src/dsh-go/host.js +162 -0
  81. package/src/dsh-go/panel.js +2091 -0
  82. package/src/dsh-go/text.js +342 -0
  83. package/src/dsh-go/views/common.js +68 -0
  84. package/src/dsh-go/views/entity.js +48 -0
  85. package/src/dsh-go/views/memory.js +198 -0
  86. package/src/dsh-go/views/root.js +30 -0
  87. package/src/dsh-go/views/settings.js +398 -0
  88. package/src/dsh-go/views/status.js +51 -0
  89. package/src/embedding.js +42 -8
  90. package/src/entities/extractor.js +36 -7
  91. package/src/hot-memory.js +8 -9
  92. package/src/index.js +92 -2
  93. package/src/inject.js +189 -18
  94. package/src/kb/index.js +309 -0
  95. package/src/kb/routes.js +220 -0
  96. package/src/kb/tools.js +148 -0
  97. package/src/lang.js +87 -4
  98. package/src/peak-hours.js +57 -2
  99. package/src/semantic.js +347 -60
  100. package/src/service.js +193 -66
  101. package/src/settings.js +112 -14
  102. package/src/store.js +347 -21
  103. package/src/summarize.js +38 -11
  104. package/src/tools.js +35 -4
  105. package/src/user-settings.js +128 -32
  106. package/src/vector-index.js +76 -6
  107. package/src/wiki/cli-hint.js +90 -0
  108. package/src/wiki/queries.js +116 -2
  109. package/src/wiki/t1-jobs.js +232 -0
  110. package/src/wiki/t1.js +7 -1
  111. package/test/api-routes-gaps.test.js +63 -0
  112. package/test/api.test.js +375 -4
  113. package/test/attachment-text.test.js +140 -0
  114. package/test/bm25-corpus-cache.test.js +145 -0
  115. package/test/capacity-warning.test.js +262 -0
  116. package/test/client.test.js +779 -154
  117. package/test/conflict-freeze.test.js +45 -1
  118. package/test/continuity.test.js +8 -1
  119. package/test/diary-library.test.js +187 -0
  120. package/test/diary.test.js +576 -0
  121. package/test/dream-maxtokens-fallback.test.js +3 -3
  122. package/test/dream.test.js +62 -1
  123. package/test/dsh-go-host.test.js +134 -0
  124. package/test/dsh-go-panel-actions.test.js +854 -0
  125. package/test/dsh-go-panel.test.js +251 -0
  126. package/test/embedding-ready.test.js +33 -3
  127. package/test/entities.test.js +73 -0
  128. package/test/forget-corrections.test.js +70 -0
  129. package/test/image-memory.test.js +171 -0
  130. package/test/issue-617-step1-query-and-fusion.test.js +361 -0
  131. package/test/kb-api-wiring.test.js +128 -0
  132. package/test/kb-collections.test.js +272 -0
  133. package/test/kb-routes.test.js +379 -0
  134. package/test/mint-source.test.js +26 -0
  135. package/test/peak-hours-observability.test.js +70 -0
  136. package/test/persona-api.test.js +3 -3
  137. package/test/persona-by-session.test.js +568 -0
  138. package/test/persona-settings.test.js +7 -7
  139. package/test/reflection-update-gate.test.js +129 -0
  140. package/test/reindex-backfill.test.js +334 -0
  141. package/test/reindex-scheduler.test.js +369 -0
  142. package/test/scope-candidates.test.js +249 -0
  143. package/test/sleep-action-set.test.js +14 -0
  144. package/test/sleep-heat.test.js +49 -0
  145. package/test/sleep.test.js +598 -0
  146. package/test/standalone-api.test.js +24 -0
  147. package/test/store.test.js +20 -0
  148. package/test/summarize.test.js +26 -0
  149. package/test/user-settings-segments.test.js +528 -0
  150. package/test/vector-index.test.js +120 -6
  151. package/test/wiki-api.test.js +355 -0
  152. package/test/wiki-cli-hint.test.js +118 -0
  153. package/test/wiki-queries.test.js +7 -0
  154. package/test/wiki-t1-jobs.test.js +125 -0
package/lib/store.js CHANGED
@@ -432,7 +432,10 @@ CREATE INDEX IF NOT EXISTS idx_distill_runs_created ON distill_runs(created_at);
432
432
  -- · 本表 = 行级,「这条记忆出生自哪一轮」,覆盖没有窗口的铸造路径。
433
433
  -- 只填 **create** 一种动作:merge/conflict/update/supersede 对既有行的改动,receipt_chain
434
434
  -- 已经按 (run_id, record_id) 记过了——再造一份就是第二套账。create 是那边唯一的洞
435
- -- (buildOutcome 刻意不给 create 派 disposition,receipt_chain 也就不产行)。
435
+ -- (buildRecordReceipts 不给 create/create_merged 产行:新行没有「改动前后的账」可记,
436
+ -- 只有「出生自哪一轮」,那正是本表的事)。#619 起 buildOutcome 会给 create 派
437
+ -- disposition(created / created-refreshed),那是审计面读的结果,与 receipt_chain
438
+ -- 产不产行是两件事;created-refreshed 是原地刷新,没有出生,本表也不记。
436
439
  -- run_type / trigger_source 就地冗余:run_id 在 dream_runs 与 distill_runs 两张表里
437
440
  -- 各有自己的主键域,读侧不该为了显示一个类型去 union 两张审计表。
438
441
  -- ON DELETE CASCADE:记忆行没了,归因没有指代对象;remove() 仍显式清(与
@@ -706,6 +709,59 @@ CREATE TABLE IF NOT EXISTS profile_fact_runs (
706
709
  finished_at TEXT
707
710
  );
708
711
  CREATE INDEX IF NOT EXISTS idx_profile_fact_runs_card ON profile_fact_runs(agent_key, started_at);
712
+
713
+ -- ============================================================
714
+ -- knowledge base(知识库第二层,spec: docs/plans/2026-11-08-kb-qoder-parity.md §3.3)。
715
+ -- 与记忆行物理隔离的硬边界同 choro 那组表:kb 的行永不进 memories /
716
+ -- saveWithDedupe / dream / 质量过滤的任何池子。这里存的是「用户/agent 主动
717
+ -- 收进知识库的资料」——文件、网页、仓库地志页的**副本**与索引,天生属于外来
718
+ -- 原文,让巩固流程够得着它就会把用户刚存的手册自己归档掉。
719
+ -- 幂等键是 (collection_id, rel_key):同一来源重入库按 sha256 判 no-op,
720
+ -- 变了才替换 chunks(替换语义在 replaceKbDoc 里,事务包住删除与插入)。
721
+ -- rel_key 是来源的稳定相对键(本地文件=绝对路径、网页=URL、地志页=repo#slug),
722
+ -- 不是展示名——展示名在 kb_docs.title。
723
+ CREATE TABLE IF NOT EXISTS kb_collections (
724
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
725
+ key TEXT NOT NULL,
726
+ title TEXT NOT NULL,
727
+ description TEXT,
728
+ created_at TEXT NOT NULL,
729
+ updated_at TEXT NOT NULL
730
+ );
731
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_kb_collections_key ON kb_collections(key);
732
+
733
+ -- kind 限定三类(§3.3):file | url | choro_page。sha256 是**正文内容**的哈希,
734
+ -- 不是文件 stat 的指纹——文件 mtime 变了内容没变时重入库必须是 no-op,
735
+ -- 否则每次保存键盘敲一下都全量重切块。
736
+ CREATE TABLE IF NOT EXISTS kb_docs (
737
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
738
+ collection_id INTEGER NOT NULL,
739
+ rel_key TEXT NOT NULL,
740
+ kind TEXT NOT NULL,
741
+ source_path TEXT,
742
+ title TEXT NOT NULL,
743
+ bytes INTEGER NOT NULL DEFAULT 0,
744
+ sha256 TEXT,
745
+ indexed_at TEXT NOT NULL,
746
+ status TEXT NOT NULL DEFAULT 'ok',
747
+ error TEXT
748
+ );
749
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_kb_docs_collection_relkey ON kb_docs(collection_id, rel_key);
750
+
751
+ -- 分块正文。heading 是块所属的二级标题节(无节则空串),ordinal 是块在
752
+ -- 文档内的序号——检索命中要能回指「第几块、哪一节」,面板/工具靠它跳段落。
753
+ -- text 存原文切片(含分块时带的标题行?不含:标题在 heading 里,text 只放正文,
754
+ -- 免得同一条命中把标题算两遍)。
755
+ CREATE TABLE IF NOT EXISTS kb_chunks (
756
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
757
+ doc_id INTEGER NOT NULL,
758
+ ordinal INTEGER NOT NULL,
759
+ heading TEXT NOT NULL DEFAULT '',
760
+ text TEXT NOT NULL,
761
+ created_at TEXT NOT NULL
762
+ );
763
+ CREATE INDEX IF NOT EXISTS idx_kb_chunks_doc_ordinal ON kb_chunks(doc_id, ordinal);
764
+
709
765
  `;
710
766
 
711
767
  // Exported for API-layer type validation (standalone API POST /memories and
@@ -960,6 +1016,10 @@ function toConflictPending(row) {
960
1016
  memory_a: row.memory_a,
961
1017
  memory_b: row.memory_b,
962
1018
  reason: row.reason ?? undefined,
1019
+ // Issue #603 第 2 层:入队时的相似度。存量行与调用方没给的行是 NULL,
1020
+ // 这里如实透出 undefined(条件展开:带 undefined 键会让 in-process schema
1021
+ // 校验判违规,同 toApiList 的处理)。
1022
+ ...(typeof row.similarity === "number" ? { similarity: row.similarity } : {}),
963
1023
  created_at: row.created_at,
964
1024
  resolved_at: row.resolved_at ?? undefined,
965
1025
  resolved_winner: row.resolved_winner ?? undefined
@@ -1493,6 +1553,18 @@ export function createStore(path) {
1493
1553
  addColumn("memory_provenance", "distill_run_id", "ALTER TABLE memory_provenance ADD COLUMN distill_run_id TEXT");
1494
1554
  db.exec("CREATE INDEX IF NOT EXISTS idx_memory_provenance_run ON memory_provenance(distill_run_id);");
1495
1555
 
1556
+ // Issue #603 第 2 层:冲突待审队列此前只能按 created_at 倒序排。一次 sleep 轮次
1557
+ // 里产出的对全部落在同一毫秒(实测 60 条挤在 60ms 内),排序键全同,队列在面板
1558
+ // 上看起来是随机的——而「哪一对最该先看」的唯一客观信号是相似度,此前只存在
1559
+ // reason 文本里(「相似度 0.87」),前端靠正则回收。落成列之后排序键是数据,
1560
+ // 不是解析结果。
1561
+ //
1562
+ // 可空且不带 DEFAULT:存量行没有这个信息,NULL = 「入队时没记」(比填 0 诚实——
1563
+ // 0 会被排到最后,等于替用户断言「这两条毫不相似」)。排序用 NULLS LAST。
1564
+ addColumn("conflict_pending", "similarity", "ALTER TABLE conflict_pending ADD COLUMN similarity REAL");
1565
+ // 索引建在加列之后(同 idx_memory_provenance_run 的理由):老库打开时列才刚加上。
1566
+ db.exec("CREATE INDEX IF NOT EXISTS idx_conflict_pending_similarity ON conflict_pending(similarity DESC, created_at DESC);");
1567
+
1496
1568
  // Audit peer F: a legacy DB may hold a non-integer generation/applied_generation
1497
1569
  // (pre-v0.3.9 the JS gate truncated with Math.trunc and SQLite's CHECK only
1498
1570
  // enforced >= 0). Such a value is ambiguous — it cannot map to a real applied
@@ -1714,13 +1786,12 @@ export function createStore(path) {
1714
1786
  return db.prepare("DELETE FROM distill_parse_failures WHERE session_id = ?").run(sessionId).changes;
1715
1787
  }
1716
1788
 
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
- }
1789
+ // #597:count() 与 countByType() 共用的 WHERE 构造——两张 HTTP 面(宿主内 /list
1790
+ // 与 Bearer /memories)的 total 与 typeCounts 必须同口径,分布的合计才等于总数。
1791
+ // type 刻意不在这里:count() 按调用方带,countByType() 刻意不带(它要的就是
1792
+ // 同一份筛选下、**不带类型**的全库分布——面板分类栏被 type 筛过的页上数不出它)。
1793
+ function pushCountFilters(opts, clauses, params) {
1794
+ 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
1795
  // Same filters as list() so a paged caller's total matches its rows.
1725
1796
  if (minImportance != null) {
1726
1797
  clauses.push("importance >= ?");
@@ -1786,10 +1857,32 @@ export function createStore(path) {
1786
1857
  "(id IN (SELECT record_id FROM receipt_chain WHERE kind IN ('merge', 'update') AND verdict = 'live') OR source = 'dream')"
1787
1858
  );
1788
1859
  }
1860
+ }
1861
+
1862
+ function count(type, opts = {}) {
1863
+ const clauses = [];
1864
+ const params = [];
1865
+ if (type !== undefined) {
1866
+ clauses.push("type = ?");
1867
+ params.push(type);
1868
+ }
1869
+ pushCountFilters(opts, clauses, params);
1789
1870
  const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
1790
1871
  return db.prepare(`SELECT count(*) AS c FROM memories ${where}`).get(...params).c;
1791
1872
  }
1792
1873
 
1874
+ // #597(/list 的 typeCounts):同一份筛选、不带 type,一次 GROUP BY 拿全库分布。
1875
+ // 面板分类栏的数字源:被 type 筛过又分过页的已加载行上数不出真分布。GROUP BY
1876
+ // 只回存在的类,缺类即 0;分布合计恒等于同筛选的 count(undefined)。
1877
+ function countByType(opts = {}) {
1878
+ const clauses = [];
1879
+ const params = [];
1880
+ pushCountFilters(opts, clauses, params);
1881
+ const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
1882
+ const rows = db.prepare(`SELECT type, count(*) AS c FROM memories ${where} GROUP BY type`).all(...params);
1883
+ return Object.fromEntries(rows.map((r) => [r.type, r.c]));
1884
+ }
1885
+
1793
1886
  function getById(id) {
1794
1887
  const row = db.prepare("SELECT * FROM memories WHERE id = ?").get(id);
1795
1888
  return toRow(row);
@@ -2575,6 +2668,30 @@ export function createStore(path) {
2575
2668
  return needsEmbeddingFor(modelHash, 1).length > 0;
2576
2669
  }
2577
2670
 
2671
+ /**
2672
+ * How many rows needsEmbeddingFor() would pick in total.
2673
+ *
2674
+ * A re-index run is bounded by a limit, so "indexed: 0" and "indexed: 3 of
2675
+ * 300" looked identical to the caller. Counting is cheap (no content is
2676
+ * materialised) and is what lets the caller say how much work is left.
2677
+ */
2678
+ function countNeedsEmbeddingFor(modelHash) {
2679
+ if (!modelHash) {
2680
+ return db.prepare(
2681
+ `SELECT count(*) AS c FROM memories
2682
+ WHERE (embedding IS NULL OR embedding = '')
2683
+ AND forgotten = 0 AND archived = 0`
2684
+ ).get().c;
2685
+ }
2686
+ return db.prepare(
2687
+ `SELECT count(*) AS c FROM memories
2688
+ WHERE ((embedding IS NULL OR embedding = '')
2689
+ OR embedding_model IS NULL
2690
+ OR embedding_model != ?)
2691
+ AND forgotten = 0 AND archived = 0`
2692
+ ).get(String(modelHash)).c;
2693
+ }
2694
+
2578
2695
  /** Per-model provenance counts over active rows ('' = unknown origin). */
2579
2696
  function embeddingModelBreakdown() {
2580
2697
  return db.prepare(
@@ -3650,6 +3767,18 @@ export function createStore(path) {
3650
3767
  return { path: typeof path === "string" ? path : null, pageSize, pageCount, freelistCount, fileBytes };
3651
3768
  }
3652
3769
 
3770
+ /**
3771
+ * 容量预警的行数口径(#275 空间维度):活跃 / 归档各一格。体积与空闲页在
3772
+ * storageStats(),这里只补行数——两条 COUNT 的成本,挂在 /health 的按需读上,
3773
+ * 没有周期性消费者。归档增速(净增速率)是 recall-stats 的 archive 块(第五
3774
+ * 指标,按精确 archived_at 算),这里刻意不算速率:同一个数字长两处就会分叉。
3775
+ */
3776
+ function capacityStats() {
3777
+ const active = db.prepare("SELECT COUNT(*) AS n FROM memories WHERE archived = 0").get().n;
3778
+ const archived = db.prepare("SELECT COUNT(*) AS n FROM memories WHERE archived = 1").get().n;
3779
+ return { active, archived };
3780
+ }
3781
+
3653
3782
  /**
3654
3783
  * 整库 VACUUM(#275 的手动步骤):代价 O(库大小) 且需要排他写锁,所以只在显式
3655
3784
  * 入口里跑,绝不挂启动路径、也不开 auto_vacuum。
@@ -3712,7 +3841,10 @@ export function createStore(path) {
3712
3841
  * pending once — a re-detection in a later dream run is a no-op, never a
3713
3842
  * duplicate queue entry. Returns the pending row (freshly inserted, or the
3714
3843
  * existing unresolved row when the pair is already pending), or undefined
3715
- * when the re-detection is suppressed.
3844
+ * when the re-detection is suppressed. The returned row carries `fresh`:
3845
+ * true only when this call actually inserted a row, false when the pair was
3846
+ * already pending (issue #603 — callers used to read "non-null" as "parked",
3847
+ * so a re-detection re-reported the same pair as a new freeze every run).
3716
3848
  *
3717
3849
  * v0.8.1(issue #170 复核项 4):人工已裁决过的对(resolved_at 非空)不再
3718
3850
  * 反复入队——除非任一侧在裁决之后又被改过(updated_at 晚于 resolved_at)。
@@ -3720,12 +3852,12 @@ export function createStore(path) {
3720
3852
  * updated_at 是宽松代理(任何字段更新都会触发重新评审)——宁可多看一眼,
3721
3853
  * 不静音真冲突。
3722
3854
  */
3723
- function saveConflictPending({ run_id, memory_a, memory_b, reason }) {
3855
+ function saveConflictPending({ run_id, memory_a, memory_b, reason, similarity }) {
3724
3856
  const [a, b] = [memory_a, memory_b].sort();
3725
3857
  const existing = db.prepare(
3726
3858
  "SELECT * FROM conflict_pending WHERE memory_a = ? AND memory_b = ? AND resolved_at IS NULL LIMIT 1"
3727
3859
  ).get(a, b);
3728
- if (existing) return toConflictPending(existing);
3860
+ if (existing) return { ...toConflictPending(existing), fresh: false };
3729
3861
  const reviewed = db.prepare(
3730
3862
  "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
3863
  ).get(a, b);
@@ -3737,16 +3869,29 @@ export function createStore(path) {
3737
3869
  }
3738
3870
  const id = randomUUID();
3739
3871
  const now = nowIso();
3872
+ // Issue #603 第 2 层:相似度落列。调用方拿不到(LLM 裁决路径只有理由文本)
3873
+ // 就写 NULL,不猜——面板与排序都按「未知」处理,而不是当成 0。
3874
+ const sim = typeof similarity === "number" && Number.isFinite(similarity)
3875
+ && similarity >= 0 && similarity <= 1 ? similarity : null;
3740
3876
  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));
3877
+ `INSERT INTO conflict_pending (id, run_id, memory_a, memory_b, reason, similarity, created_at)
3878
+ VALUES (?, ?, ?, ?, ?, ?, ?)`
3879
+ ).run(id, run_id ?? null, a, b, reason ?? null, sim, now);
3880
+ return { ...toConflictPending(db.prepare("SELECT * FROM conflict_pending WHERE id = ?").get(id)), fresh: true };
3745
3881
  }
3746
3882
 
3747
3883
  /**
3748
- * List pending conflicts, newest first. Unresolved rows only by default;
3749
- * pass includeResolved to include resolved ones (audit view).
3884
+ * List pending conflicts, most-similar first. Unresolved rows only by
3885
+ * default; pass includeResolved to include resolved ones (audit view).
3886
+ *
3887
+ * Issue #603 第 2 层:排序键从「created_at DESC」改成「相似度 DESC → created_at
3888
+ * DESC」。原键在同一次 sleep 轮次里全同(实测 60 条落在 60ms 内),队列在面板
3889
+ * 上等于随机;相似度是「该先看哪对」的唯一客观信号,现在它是一列而不是 reason
3890
+ * 文本里的正则。
3891
+ *
3892
+ * NULL 排最后而不是当 0:存量行与 LLM 裁决路径没记相似度,把它们混进「低相似
3893
+ * 度」会把「未知」伪装成「已测量」。`(similarity IS NULL)` 前缀让这条不依赖
3894
+ * SQLite 的 NULL 排序约定,两个方向都稳。
3750
3895
  */
3751
3896
  function listConflictPending({ limit = 50, offset = 0, includeResolved = false } = {}) {
3752
3897
  const { limit: lim, offset: off } = sanitizePage(limit, offset, 50);
@@ -3755,7 +3900,9 @@ export function createStore(path) {
3755
3900
  if (!includeResolved) clauses.push("resolved_at IS NULL");
3756
3901
  const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
3757
3902
  const rows = db.prepare(
3758
- `SELECT * FROM conflict_pending ${where} ORDER BY created_at DESC, id LIMIT ? OFFSET ?`
3903
+ `SELECT * FROM conflict_pending ${where}
3904
+ ORDER BY (similarity IS NULL), similarity DESC, created_at DESC, id
3905
+ LIMIT ? OFFSET ?`
3759
3906
  ).all(...params, lim, off);
3760
3907
  return rows.map(toConflictPending);
3761
3908
  }
@@ -4265,6 +4412,26 @@ export function createStore(path) {
4265
4412
  return result.changes > 0;
4266
4413
  }
4267
4414
 
4415
+ /**
4416
+ * 覆盖一条关系行的 metadata(#619)。关系本身仍按 saveRelation 追加,
4417
+ * 这条只给「同一条边被后续观察补充证据」用——情感相位判重命中时要把
4418
+ * evidence 并集与 sessionCount 写回既有边,否则边上的场合数会永远停在
4419
+ * 第一次写入那一刻。只改 metadata,不动 weight/source/created_at:这条边的
4420
+ * 来源与权重反映的是它第一次被建立时的事实。
4421
+ * 幂等(缺行 changes=0 → false)。
4422
+ */
4423
+ function updateRelationMetadata(id, metadata) {
4424
+ const metaStr = metadata === undefined
4425
+ ? null
4426
+ : typeof metadata === "string"
4427
+ ? metadata
4428
+ : JSON.stringify(metadata);
4429
+ const result = db.prepare(
4430
+ "UPDATE entity_relations SET metadata = ? WHERE id = ?"
4431
+ ).run(metaStr, id);
4432
+ return result.changes > 0;
4433
+ }
4434
+
4268
4435
  /**
4269
4436
  * Re-point every attr row whose memory_id is fromMemoryId to toMemoryId
4270
4437
  * (autoDream merge migration, v0.3.0 Phase 4 / 4.3.2). When the keeper
@@ -4501,10 +4668,21 @@ export function createStore(path) {
4501
4668
  /** All entities (optionally name-filtered, newest first). Used by sleep phase 4
4502
4669
  * orphan detection: an entity with zero relations is a candidate for relation
4503
4670
  * completion. */
4504
- function listEntities({ limit = 1000 } = {}) {
4671
+ function listEntities({ limit = 1000, q } = {}) {
4672
+ const needle = typeof q === "string" ? q.trim() : "";
4673
+ if (needle === "") {
4674
+ const rows = db.prepare(
4675
+ "SELECT * FROM entities ORDER BY last_seen DESC, name ASC LIMIT ?"
4676
+ ).all(limit);
4677
+ return rows.map(toEntity);
4678
+ }
4679
+ // 名字过滤放在 SQL 侧:调用方若在 LIMIT 之后过滤,排在 limit 之外的实体
4680
+ // 就永远搜不到(面板按 limit=200 取回再本地过滤,1.6 万实体时命中率约 1%)。
4681
+ // LIKE 的 % / _ 用 ESCAPE 转义,否则用户输入一个 "%" 就会把整库匹配出来。
4682
+ const escaped = needle.replace(/[\\%_]/g, (ch) => `\\${ch}`);
4505
4683
  const rows = db.prepare(
4506
- "SELECT * FROM entities ORDER BY last_seen DESC, name ASC LIMIT ?"
4507
- ).all(limit);
4684
+ "SELECT * FROM entities WHERE name LIKE ? ESCAPE '\\' ORDER BY last_seen DESC, name ASC LIMIT ?"
4685
+ ).all(`%${escaped}%`, limit);
4508
4686
  return rows.map(toEntity);
4509
4687
  }
4510
4688
 
@@ -5128,9 +5306,143 @@ export function createStore(path) {
5128
5306
  ).all().map((r) => r.k);
5129
5307
  }
5130
5308
 
5309
+ /* --------------------- knowledge base(知识库第二层,§3.3) --------------------- */
5310
+ // 与 choro 那组表同一套纪律:本域只读写 kb_* 三张表,绝不进 memories /
5311
+ // saveWithDedupe / dream / 质量过滤的任何池子。时间戳由调用方传(一次 ingest
5312
+ // 的所有行共用一个时刻),store 不自取 now()——两处时钟会把「同一次入库」拆成
5313
+ // 两个版本,sha256 幂等判据也会跟着抖。
5314
+
5315
+ function kbCollectionByKey(key) {
5316
+ return db.prepare("SELECT * FROM kb_collections WHERE key = ?").get(String(key)) ?? undefined;
5317
+ }
5318
+
5319
+ /** 集合列表带计数:面板左栏要的不是空壳 key 列表,docs/chunks 是它的主信息。 */
5320
+ function listKbCollections() {
5321
+ return db.prepare(
5322
+ `SELECT c.*,
5323
+ (SELECT COUNT(*) FROM kb_docs d WHERE d.collection_id = c.id) AS doc_count,
5324
+ (SELECT COUNT(*) FROM kb_chunks k JOIN kb_docs d2 ON k.doc_id = d2.id
5325
+ WHERE d2.collection_id = c.id) AS chunk_count
5326
+ FROM kb_collections c ORDER BY c.key`
5327
+ ).all();
5328
+ }
5329
+
5330
+ // key 是外部键(面板/工具用它寻址),title 是展示名。ON CONFLICT 不动 created_at:
5331
+ // 「这个集合哪天建的」是事实,重命名不该改写它。
5332
+ function upsertKbCollection({ key, title, description = null, at }) {
5333
+ db.prepare(
5334
+ `INSERT INTO kb_collections (key, title, description, created_at, updated_at)
5335
+ VALUES (?, ?, ?, ?, ?)
5336
+ ON CONFLICT(key) DO UPDATE SET
5337
+ title = excluded.title,
5338
+ description = excluded.description,
5339
+ updated_at = excluded.updated_at`
5340
+ ).run(String(key), String(title), description ?? null, at, at);
5341
+ return kbCollectionByKey(key);
5342
+ }
5343
+
5344
+ /** 删集合连带删它的文档与分块(三张表是一个整体,留孤儿行等于悄悄占空间)。 */
5345
+ function deleteKbCollection(key) {
5346
+ const row = kbCollectionByKey(key);
5347
+ if (!row) return false;
5348
+ return runAtomically(() => {
5349
+ db.prepare(
5350
+ "DELETE FROM kb_chunks WHERE doc_id IN (SELECT id FROM kb_docs WHERE collection_id = ?)"
5351
+ ).run(row.id);
5352
+ db.prepare("DELETE FROM kb_docs WHERE collection_id = ?").run(row.id);
5353
+ db.prepare("DELETE FROM kb_collections WHERE id = ?").run(row.id);
5354
+ return true;
5355
+ });
5356
+ }
5357
+
5358
+ function listKbDocs(collectionId) {
5359
+ return db.prepare(
5360
+ "SELECT * FROM kb_docs WHERE collection_id = ? ORDER BY rel_key"
5361
+ ).all(collectionId);
5362
+ }
5363
+
5364
+ function kbDocByRelKey(collectionId, relKey) {
5365
+ return db.prepare(
5366
+ "SELECT * FROM kb_docs WHERE collection_id = ? AND rel_key = ?"
5367
+ ).get(collectionId, String(relKey)) ?? undefined;
5368
+ }
5369
+
5370
+ function kbChunksByDoc(docId) {
5371
+ return db.prepare(
5372
+ "SELECT * FROM kb_chunks WHERE doc_id = ? ORDER BY ordinal"
5373
+ ).all(docId);
5374
+ }
5375
+
5376
+ /**
5377
+ * 入库落库的唯一写口。幂等判据是 **正文 sha256**(不是文件 stat):同 rel_key +
5378
+ * 同 sha256 = 整件事 no-op,回执 skipped 且不动 chunks——否则编辑器保存一次
5379
+ * (mtime 变、内容没变)就要全量重切块,库和索引一起白忙。
5380
+ *
5381
+ * 变了才替换:删旧 chunks 再插新的,事务包住,半截失败整体回滚,库里保持上一版
5382
+ * 完整文档。上一版 status=error 的行不算命中(它没有可分块的正文,重来必须真跑)。
5383
+ */
5384
+ function replaceKbDoc({ collectionId, relKey, kind, sourcePath = null, title, bytes = 0, sha256 = null, at, chunks = [] }) {
5385
+ return runAtomically(() => {
5386
+ const prev = kbDocByRelKey(collectionId, relKey);
5387
+ if (prev && prev.status === "ok" && sha256 && prev.sha256 === sha256) {
5388
+ return { docId: prev.id, chunks: kbChunksByDoc(prev.id).length, skipped: true };
5389
+ }
5390
+ db.prepare("DELETE FROM kb_chunks WHERE doc_id IN (SELECT id FROM kb_docs WHERE collection_id = ? AND rel_key = ?)")
5391
+ .run(collectionId, String(relKey));
5392
+ let docId;
5393
+ if (prev) {
5394
+ db.prepare(
5395
+ `UPDATE kb_docs SET kind = ?, source_path = ?, title = ?, bytes = ?, sha256 = ?,
5396
+ indexed_at = ?, status = 'ok', error = NULL WHERE id = ?`
5397
+ ).run(kind, sourcePath, String(title), bytes, sha256, at, prev.id);
5398
+ docId = prev.id;
5399
+ } else {
5400
+ const info = db.prepare(
5401
+ `INSERT INTO kb_docs (collection_id, rel_key, kind, source_path, title, bytes, sha256, indexed_at, status, error)
5402
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'ok', NULL)`
5403
+ ).run(collectionId, String(relKey), kind, sourcePath, String(title), bytes, sha256, at);
5404
+ docId = Number(info.lastInsertRowid);
5405
+ }
5406
+ const ins = db.prepare(
5407
+ "INSERT INTO kb_chunks (doc_id, ordinal, heading, text, created_at) VALUES (?, ?, ?, ?, ?)"
5408
+ );
5409
+ for (const c of chunks) ins.run(docId, c.ordinal, c.heading ?? "", c.text ?? "", at);
5410
+ return { docId, chunks: chunks.length, skipped: false };
5411
+ });
5412
+ }
5413
+
5414
+ /** 入库失败也要留痕:面板要能显示「这份文档上次为什么没进来」,静默丢来源等于假成功。 */
5415
+ function markKbDocError({ collectionId, relKey, kind, sourcePath = null, title, error, at }) {
5416
+ const prev = kbDocByRelKey(collectionId, relKey);
5417
+ if (prev) {
5418
+ db.prepare(
5419
+ "UPDATE kb_docs SET status = 'error', error = ?, indexed_at = ?, source_path = ?, kind = ?, title = ? WHERE id = ?"
5420
+ ).run(String(error ?? ""), at, sourcePath, kind, String(title ?? prev.title), prev.id);
5421
+ return prev.id;
5422
+ }
5423
+ const info = db.prepare(
5424
+ `INSERT INTO kb_docs (collection_id, rel_key, kind, source_path, title, bytes, sha256, indexed_at, status, error)
5425
+ VALUES (?, ?, ?, ?, ?, 0, NULL, ?, 'error', ?)`
5426
+ ).run(collectionId, String(relKey), kind, sourcePath, String(title ?? relKey), at, String(error ?? ""));
5427
+ return Number(info.lastInsertRowid);
5428
+ }
5429
+
5430
+ /** 检索候选集:跨集合或单集合,一次取全(BM25 需要全语料算 df,不能分页取)。 */
5431
+ function kbChunksForSearch(collectionId = null) {
5432
+ return db.prepare(
5433
+ `SELECT k.id, k.doc_id, k.ordinal, k.heading, k.text,
5434
+ d.rel_key, d.title, d.kind, c.key AS collection_key
5435
+ FROM kb_chunks k
5436
+ JOIN kb_docs d ON k.doc_id = d.id
5437
+ JOIN kb_collections c ON d.collection_id = c.id
5438
+ WHERE d.status = 'ok' AND (? IS NULL OR d.collection_id = ?)`
5439
+ ).all(collectionId, collectionId);
5440
+ }
5441
+
5131
5442
  return {
5132
5443
  db,
5133
5444
  count,
5445
+ countByType,
5134
5446
  getById,
5135
5447
  // 车道 C:外来行身份的两个读口(幂等键 / 整包清点)。
5136
5448
  getByShareExternalId,
@@ -5174,6 +5486,7 @@ export function createStore(path) {
5174
5486
  embeddedCount,
5175
5487
  needsEmbedding,
5176
5488
  needsEmbeddingFor,
5489
+ countNeedsEmbeddingFor,
5177
5490
  hasStaleEmbedding,
5178
5491
  embeddingModelBreakdown,
5179
5492
  embeddingDimensionBreakdown,
@@ -5232,6 +5545,7 @@ export function createStore(path) {
5232
5545
  archivedEmbeddingStats,
5233
5546
  clearArchivedEmbeddings,
5234
5547
  storageStats,
5548
+ capacityStats,
5235
5549
  vacuum,
5236
5550
  saveFailure,
5237
5551
  listFailures,
@@ -5284,6 +5598,7 @@ export function createStore(path) {
5284
5598
  listBoostedRelations,
5285
5599
  getLinkedMemoryIds,
5286
5600
  saveRelation,
5601
+ updateRelationMetadata,
5287
5602
  bumpRelationWeight,
5288
5603
  migrateAttrsToMemory,
5289
5604
  getRelations,
@@ -5303,6 +5618,17 @@ export function createStore(path) {
5303
5618
  getContinuityProposal,
5304
5619
  listContinuityProposals,
5305
5620
  countContinuityProposals,
5621
+ // knowledge base(知识库第二层,§3.3):三张 kb_* 表,与记忆行物理隔离。
5622
+ kbCollectionByKey,
5623
+ listKbCollections,
5624
+ upsertKbCollection,
5625
+ deleteKbCollection,
5626
+ listKbDocs,
5627
+ kbDocByRelKey,
5628
+ kbChunksByDoc,
5629
+ replaceKbDoc,
5630
+ markKbDocError,
5631
+ kbChunksForSearch,
5306
5632
  // chorography(仓库地志):M0 只写不读,消费面 M1 接(见上面注释块)。
5307
5633
  choroRepoByRoot,
5308
5634
  upsertChoroRepo,
package/lib/summarize.js CHANGED
@@ -9,6 +9,8 @@ import { createScopeResolver } from "./scope.js";
9
9
  // #493 补充 §③:断链与巩固侧同一份实现(src/fence.js)。转录里出现闭合标记时
10
10
  // 原先能提前闭合围栏——那是把用户消息升格成指令最短的一条路。
11
11
  import { breakFenceMarkers } from "./fence.js";
12
+ // Issue #611:图片块的文本口径(占位,不是空串)。三个 textOf 副本共用这一份。
13
+ import { contentToText, countImages } from "./attachment-text.js";
12
14
  // #512:每轮蒸馏一个 run id(归因主体)。store 侧也能铸造,但这里必须知道它——
13
15
  // 写记忆时要把它一路带到 saveMemoryProvenance / 落 run 行,拿回来再写就是两次往返。
14
16
  import { randomUUID } from "node:crypto";
@@ -292,13 +294,13 @@ const SUBAGENT_MESSAGE_KINDS = new Set(["agent-message", "subagent-settled"]);
292
294
 
293
295
  // 兼容严格形状 [{type:"text",text}] 与宽松形状 ["字符串", ...](lib-smoke 用例
294
296
  // 直接传字符串数组)。只取公开文本块;reasoning 私有推理块不进蒸馏上下文。
297
+ //
298
+ // Issue #611:图片块曾经在这里一律变空串,于是图-only 消息整条被丢、图+文消息只剩
299
+ // 文字——静默,没有任何计数。现在改由 attachment-text 统一产占位,
300
+ // `[图: 屏幕截图 … 1543x983]`。抽取口径只有一份,三个调用方(本文件 /
301
+ // continuity / hot-memory)共用,避免同一件事写成三份会漂移的实现。
295
302
  function textOf(content) {
296
- if (typeof content === "string") return content;
297
- if (!Array.isArray(content)) return "";
298
- return content
299
- .map((block) => (typeof block === "string" ? block : (block && block.type === "text" && typeof block.text === "string" ? block.text : "")))
300
- .filter((s) => s)
301
- .join("\n");
303
+ return contentToText(content);
302
304
  }
303
305
 
304
306
  /**
@@ -333,6 +335,10 @@ function publicTranscriptParts(event) {
333
335
  parts.push({
334
336
  role: "agent",
335
337
  text,
338
+ // Issue #611:把产文本的那份 content 一起带出去。判定与文本同源,计量才能
339
+ // 跟着同一份判定走(见 collectMessages 里的 imageCount)——另写一套「哪些块
340
+ // 算公开」给计量用,就是第二份实现,迟早与这份漂移。
341
+ blocks: Array.isArray(message?.content) ? message.content : [],
336
342
  // 同一条子会话消息可能同时出现在 user/message 与 agent/inbox/spliced
337
343
  // 里(投递路径重叠),按 id(无 id 时按内容)去重,否则转录会重复。
338
344
  key: typeof message?.id === "string" && message.id
@@ -346,7 +352,7 @@ function publicTranscriptParts(event) {
346
352
  const text = textOf(data?.content);
347
353
  if (!text.trim()) break;
348
354
  if (SUBAGENT_MESSAGE_KINDS.has(kind)) pushAgentMessage(data);
349
- else parts.push({ role: "user", text });
355
+ else parts.push({ role: "user", text, blocks: Array.isArray(data?.content) ? data.content : [] });
350
356
  break;
351
357
  }
352
358
  case "agent/inbox/spliced": {
@@ -358,7 +364,7 @@ function publicTranscriptParts(event) {
358
364
  const blocks = Array.isArray(data?.message?.content) ? data.message.content : [];
359
365
  const text = textOf(blocks);
360
366
  // 私有推理块(reasoning)刻意不采集:蒸馏记忆不得沉淀模型私有思考链。
361
- if (text.trim()) parts.push({ role: "assistant", text });
367
+ if (text.trim()) parts.push({ role: "assistant", text, blocks });
362
368
  break;
363
369
  }
364
370
  default:
@@ -395,6 +401,10 @@ function collectMessages(session, maxChars = 8000, language = "zh", afterSeq, th
395
401
  const events = snapshotSessionEvents(session, afterSeq, throughSeq);
396
402
  const lines = [];
397
403
  const seenSubagentMessages = new Set();
404
+ // Issue #611:这一窗里被折成占位的图片张数。占位本身不丢信息,但「折了几张」
405
+ // 必须是个能读到的数——否则 off 档和旧版静默丢弃在观测上无法区分,下次再有人
406
+ // 问「图到底进来没有」还是得翻源码。计量只数窗口内事件,不受截断影响。
407
+ let imageCount = 0;
398
408
  const trim = (s, n) => (typeof s === "string" && s.length > n ? `${s.slice(0, n)}…` : s);
399
409
  const trimTranscript = (s, n) => {
400
410
  if (typeof s !== "string" || s.length <= n) return s;
@@ -421,9 +431,20 @@ function collectMessages(session, maxChars = 8000, language = "zh", afterSeq, th
421
431
  if (part.role === "agent") {
422
432
  if (part.key && seenSubagentMessages.has(part.key)) continue;
423
433
  if (part.key) seenSubagentMessages.add(part.key);
424
- lines.push(STR.transcriptAgent[language](part.text));
425
- } else if (part.role === "user") {
434
+ }
435
+ // Issue #611 计量:数「真正进了转录的图块」,所以按 part 数、并且在去重之后
436
+ // ——同一条子会话消息会同时出现在 user/message 与 agent/inbox/spliced 里
437
+ // (投递路径重叠),按事件数会把它算两遍。
438
+ //
439
+ // 不按事件数还有个更要紧的理由:被 publicTranscriptParts 挡掉的注入消息
440
+ // (工具结果、插件注入、陌生 source.kind)里的图根本进不了转录,按事件累加
441
+ // 会报「这一窗有 2 张图」而转录里一张都没有。part 是渲染与落盘共用的同一份
442
+ // 判定,计量跟着它走,两处不会再各有一套口径。
443
+ imageCount += countImages(part.blocks);
444
+ if (part.role === "user") {
426
445
  lines.push(STR.transcriptUser[language](part.text));
446
+ } else if (part.role === "agent") {
447
+ lines.push(STR.transcriptAgent[language](part.text));
427
448
  } else {
428
449
  lines.push(STR.transcriptAssistant[language](part.text));
429
450
  }
@@ -485,6 +506,9 @@ function collectMessages(session, maxChars = 8000, language = "zh", afterSeq, th
485
506
  : [],
486
507
  hasEvents: events.length > 0,
487
508
  lastSeq,
509
+ // Issue #611:这一窗折成占位的图片张数。调用方把它写进审计 metadata,
510
+ // 让「图进来了几张」成为可对账的事实而不是推断。
511
+ imageCount,
488
512
  // Issue #493:只数**转录正文**的字符数。minWindowChars 闸门与审计里的
489
513
  // distillChars 量的都是「这一窗有多少可提炼内容」,不是「我们额外包了多少
490
514
  // 提示词」——若把围栏与角色句算进去,一条「短」也会凭空越过任何阈值,
@@ -850,6 +874,7 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate =
850
874
  0
851
875
  );
852
876
  const minWindowChars = config.summarizeMinWindowChars ?? 0;
877
+ const imageCount = collected.imageCount ?? 0;
853
878
  if (minWindowChars > 0 && distillChars < minWindowChars) {
854
879
  commitCursor(session.id, nextSeq);
855
880
  writeAudit({
@@ -857,7 +882,7 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate =
857
882
  model_id: route ? `${route.provider}:${route.model}` : "unknown",
858
883
  status: "skipped",
859
884
  error_message: "window-too-small",
860
- metadata: { distillChars, minWindowChars }
885
+ metadata: { distillChars, minWindowChars, ...(imageCount > 0 ? { imageCount } : {}) }
861
886
  });
862
887
  return;
863
888
  }
@@ -1064,6 +1089,7 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate =
1064
1089
  audit.errorMessage = failureAudit.errorMessage;
1065
1090
  audit.metadata = {
1066
1091
  distillChars,
1092
+ ...(imageCount > 0 ? { imageCount } : {}),
1067
1093
  ...failureAudit.metadata,
1068
1094
  streak,
1069
1095
  ...(maxParseRetries > 0 ? { maxRetries: maxParseRetries } : {}),
@@ -1203,6 +1229,7 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate =
1203
1229
  if (audit) {
1204
1230
  audit.metadata = {
1205
1231
  distillChars,
1232
+ ...(imageCount > 0 ? { imageCount } : {}),
1206
1233
  parsed: parsed.length,
1207
1234
  ...(parsedResult.salvaged ? { json_salvaged: true } : {}),
1208
1235
  ...(capped > 0 ? { capped } : {}),