recallnest 3.0.0 → 4.0.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 (246) hide show
  1. package/CHANGELOG.md +304 -0
  2. package/README.md +251 -258
  3. package/README_CN.md +226 -247
  4. package/assets/ui/app.js +6 -1
  5. package/bin/recallnest.mjs +20 -37
  6. package/docs/api-reference.md +8 -0
  7. package/docs/architecture.md +18 -13
  8. package/eval/cases-canary.json +107 -9
  9. package/integrations/examples/claude-agent-sdk/memory-agent.ts +2 -0
  10. package/integrations/examples/langchain/memory-chain.py +4 -0
  11. package/integrations/examples/openai-agents-sdk/memory-agent.py +4 -0
  12. package/package.json +12 -3
  13. package/src/activity-counter.ts +1 -0
  14. package/src/adaptive-retrieval.ts +23 -0
  15. package/src/api-server.ts +13 -11
  16. package/src/asset-sync.ts +26 -2
  17. package/src/audit-log.ts +2 -0
  18. package/src/auto-gc.ts +4 -3
  19. package/src/belief-history.ts +137 -31
  20. package/src/capture-engine.ts +459 -71
  21. package/src/cascade-forget.ts +7 -3
  22. package/src/checkpoint-mirror.ts +417 -0
  23. package/src/cli.ts +574 -54
  24. package/src/conflict-engine.ts +43 -15
  25. package/src/consolidation-engine.ts +24 -3
  26. package/src/context-collapse-renderer.ts +8 -1
  27. package/src/context-composer-stable.ts +5 -1
  28. package/src/context-composer.ts +11 -2
  29. package/src/distill-lock.ts +246 -74
  30. package/src/dream-pipeline.ts +3 -1
  31. package/src/embedder.ts +24 -0
  32. package/src/env-config.ts +136 -0
  33. package/src/flock.ts +97 -0
  34. package/src/forget-engine.ts +342 -58
  35. package/src/gateway-server.ts +4 -0
  36. package/src/ingest-plan.ts +56 -0
  37. package/src/ingest.ts +294 -121
  38. package/src/kg-contradiction.ts +753 -0
  39. package/src/kg-extractor.ts +17 -0
  40. package/src/kg-store.ts +1 -1
  41. package/src/known-item-eval.ts +455 -0
  42. package/src/llm-client.ts +13 -26
  43. package/src/llm-consolidation.ts +2 -2
  44. package/src/mcp-tool-deps.ts +6 -0
  45. package/src/mcp-tools-advanced.ts +80 -45
  46. package/src/mcp-tools-core.ts +23 -17
  47. package/src/mcp-tools-governance.ts +13 -7
  48. package/src/memory-assets.ts +144 -8
  49. package/src/memory-boundaries.ts +19 -0
  50. package/src/memory-evolution.ts +9 -0
  51. package/src/memory-lint.ts +1 -1
  52. package/src/memory-output.ts +311 -14
  53. package/src/memory-reconcile.ts +1437 -0
  54. package/src/memory-schema.ts +11 -0
  55. package/src/memory-store-port.ts +1 -0
  56. package/src/minis-archive.ts +151 -0
  57. package/src/noise-filter.ts +15 -2
  58. package/src/pii-detector.ts +31 -1
  59. package/src/pivot-apply.ts +55 -5
  60. package/src/pivot-evidence.ts +128 -0
  61. package/src/ppr-traversal.ts +25 -1
  62. package/src/preference-matcher.ts +40 -28
  63. package/src/prospective-memory.ts +7 -4
  64. package/src/retriever.ts +297 -57
  65. package/src/retroactive-boost.ts +3 -2
  66. package/src/runtime-config.ts +15 -1
  67. package/src/scope-policy.ts +1 -0
  68. package/src/session-distiller.ts +6 -2
  69. package/src/session-engine.ts +11 -4
  70. package/src/session-output.ts +20 -1
  71. package/src/session-schema.ts +5 -2
  72. package/src/session-store.ts +59 -12
  73. package/src/skill-promotion.ts +67 -1
  74. package/src/store-receipts.ts +109 -0
  75. package/src/store-write-budget.ts +37 -0
  76. package/src/store.ts +298 -57
  77. package/src/text-fingerprint.ts +25 -0
  78. package/src/trigger-store.ts +443 -0
  79. package/src/ui-server.ts +22 -21
  80. package/src/version-manager.ts +19 -5
  81. package/src/write-verifier.ts +16 -11
  82. package/src/__tests__/access-tracker-novelty.test.ts +0 -324
  83. package/src/__tests__/activity-counter.test.ts +0 -125
  84. package/src/__tests__/adaptive-fidelity.test.ts +0 -102
  85. package/src/__tests__/admission-control.test.ts +0 -206
  86. package/src/__tests__/age-label.test.ts +0 -33
  87. package/src/__tests__/anchor-generator.test.ts +0 -67
  88. package/src/__tests__/api-server-security.test.ts +0 -102
  89. package/src/__tests__/auto-consolidation.test.ts +0 -168
  90. package/src/__tests__/auto-recall.test.ts +0 -131
  91. package/src/__tests__/babel-integration.test.ts +0 -144
  92. package/src/__tests__/batch-dedup.test.ts +0 -65
  93. package/src/__tests__/belief-history.test.ts +0 -220
  94. package/src/__tests__/canary-eval.test.ts +0 -114
  95. package/src/__tests__/capture-engine.test.ts +0 -1328
  96. package/src/__tests__/capture-heuristic.test.ts +0 -171
  97. package/src/__tests__/cascade-forget.test.ts +0 -210
  98. package/src/__tests__/category-min-scores.test.ts +0 -116
  99. package/src/__tests__/cc8-cc1-essential-context.test.ts +0 -298
  100. package/src/__tests__/circuit-breaker.test.ts +0 -99
  101. package/src/__tests__/cluster-consolidation.test.ts +0 -1236
  102. package/src/__tests__/confidence-metadata.test.ts +0 -125
  103. package/src/__tests__/confidence-tracker.test.ts +0 -261
  104. package/src/__tests__/conflict-advisor.test.ts +0 -206
  105. package/src/__tests__/conflict-escalation.test.ts +0 -149
  106. package/src/__tests__/conflict-lifecycle.test.ts +0 -83
  107. package/src/__tests__/conflict-output.test.ts +0 -85
  108. package/src/__tests__/conflict-store.test.ts +0 -418
  109. package/src/__tests__/connector-v1.test.ts +0 -181
  110. package/src/__tests__/consolidation-engine.test.ts +0 -520
  111. package/src/__tests__/constructive-retrieval.test.ts +0 -509
  112. package/src/__tests__/context-collapse-renderer.test.ts +0 -214
  113. package/src/__tests__/context-composer-pins.test.ts +0 -64
  114. package/src/__tests__/context-composer-task-selection.test.ts +0 -24
  115. package/src/__tests__/context-reconstructor.test.ts +0 -65
  116. package/src/__tests__/context-renderer.test.ts +0 -91
  117. package/src/__tests__/continuity-eval.test.ts +0 -275
  118. package/src/__tests__/continuity-schema.test.ts +0 -97
  119. package/src/__tests__/contracts.test.ts +0 -262
  120. package/src/__tests__/conversation-importer.test.ts +0 -692
  121. package/src/__tests__/core-recall-eval.test.ts +0 -235
  122. package/src/__tests__/core-summary.test.ts +0 -109
  123. package/src/__tests__/data-checkup.test.ts +0 -304
  124. package/src/__tests__/decay-exemption.test.ts +0 -129
  125. package/src/__tests__/dedup-actions.test.ts +0 -58
  126. package/src/__tests__/dedup-guard.test.ts +0 -39
  127. package/src/__tests__/deterministic-id.test.ts +0 -39
  128. package/src/__tests__/distill-lock.test.ts +0 -305
  129. package/src/__tests__/distill-session-argshape.test.ts +0 -79
  130. package/src/__tests__/doctor.test.ts +0 -319
  131. package/src/__tests__/dream-pipeline.test.ts +0 -820
  132. package/src/__tests__/dream-scope-isolation.test.ts +0 -386
  133. package/src/__tests__/dream-write-batching.test.ts +0 -211
  134. package/src/__tests__/drill-down.test.ts +0 -108
  135. package/src/__tests__/embedder.test.ts +0 -122
  136. package/src/__tests__/emotion-decay.test.ts +0 -46
  137. package/src/__tests__/emotion-detector.test.ts +0 -56
  138. package/src/__tests__/emotion-phase1-integration.test.ts +0 -153
  139. package/src/__tests__/emotion-retrieval.test.ts +0 -56
  140. package/src/__tests__/entity-resolver.test.ts +0 -58
  141. package/src/__tests__/env-config-invariant.test.ts +0 -80
  142. package/src/__tests__/env-config.test.ts +0 -211
  143. package/src/__tests__/error-signature-boost.test.ts +0 -87
  144. package/src/__tests__/error-signature.test.ts +0 -95
  145. package/src/__tests__/error-taxonomy.test.ts +0 -129
  146. package/src/__tests__/ethics-layer.test.ts +0 -476
  147. package/src/__tests__/event-segmenter.test.ts +0 -153
  148. package/src/__tests__/feature-flags-integration.test.ts +0 -54
  149. package/src/__tests__/frequency-tracker.test.ts +0 -113
  150. package/src/__tests__/freshness.test.ts +0 -186
  151. package/src/__tests__/graph-export.test.ts +0 -489
  152. package/src/__tests__/hotness-blend.test.ts +0 -283
  153. package/src/__tests__/ingest-chunking.test.ts +0 -44
  154. package/src/__tests__/ingest-scope-prefix.test.ts +0 -69
  155. package/src/__tests__/ingest.test.ts +0 -360
  156. package/src/__tests__/interference-detector.test.ts +0 -203
  157. package/src/__tests__/kg-extractor.test.ts +0 -192
  158. package/src/__tests__/kg-store.test.ts +0 -300
  159. package/src/__tests__/llm-client-pivot.test.ts +0 -628
  160. package/src/__tests__/llm-consolidation.test.ts +0 -226
  161. package/src/__tests__/maintenance-locks.test.ts +0 -197
  162. package/src/__tests__/mcp-efficiency.test.ts +0 -288
  163. package/src/__tests__/mcp-related-scopes.test.ts +0 -151
  164. package/src/__tests__/memory-boundaries.test.ts +0 -282
  165. package/src/__tests__/memory-evolution-b2-b3-e1.test.ts +0 -362
  166. package/src/__tests__/memory-evolution.test.ts +0 -271
  167. package/src/__tests__/memory-health-rebalance.test.ts +0 -144
  168. package/src/__tests__/memory-lint.test.ts +0 -421
  169. package/src/__tests__/memory-output.test.ts +0 -224
  170. package/src/__tests__/memory-promotion.test.ts +0 -315
  171. package/src/__tests__/memory-utility.test.ts +0 -256
  172. package/src/__tests__/migrate-dedup-ids.test.ts +0 -65
  173. package/src/__tests__/multi-vector.test.ts +0 -242
  174. package/src/__tests__/narrative-architecture.test.ts +0 -456
  175. package/src/__tests__/noise-filter.test.ts +0 -205
  176. package/src/__tests__/noise-prototype-bank.test.ts +0 -280
  177. package/src/__tests__/obsidian-connector.test.ts +0 -363
  178. package/src/__tests__/omitted-reporting.test.ts +0 -98
  179. package/src/__tests__/openai-contract.test.ts +0 -513
  180. package/src/__tests__/pending-review.test.ts +0 -77
  181. package/src/__tests__/pii-redact.test.ts +0 -333
  182. package/src/__tests__/pivot-apply.test.ts +0 -373
  183. package/src/__tests__/pivot-distill-supervisor.test.ts +0 -457
  184. package/src/__tests__/pivot-distill.test.ts +0 -1178
  185. package/src/__tests__/post-retrieval-filter.test.ts +0 -178
  186. package/src/__tests__/ppr-traversal.test.ts +0 -256
  187. package/src/__tests__/prediction-engine.test.ts +0 -349
  188. package/src/__tests__/predictive-prospective-memory.test.ts +0 -416
  189. package/src/__tests__/preference-guard-smoke.test.ts +0 -211
  190. package/src/__tests__/preference-matcher.test.ts +0 -282
  191. package/src/__tests__/preference-slots.test.ts +0 -205
  192. package/src/__tests__/promotion-distinct-sources.test.ts +0 -162
  193. package/src/__tests__/prospective-memory.test.ts +0 -283
  194. package/src/__tests__/pull-from-macbook-sync-only.test.ts +0 -679
  195. package/src/__tests__/query-entity-detector.test.ts +0 -171
  196. package/src/__tests__/query-expander-alias.test.ts +0 -146
  197. package/src/__tests__/query-expander.test.ts +0 -34
  198. package/src/__tests__/recall-gate.test.ts +0 -266
  199. package/src/__tests__/recall-governor.test.ts +0 -227
  200. package/src/__tests__/recall-mode.test.ts +0 -64
  201. package/src/__tests__/resolve-db-path.test.ts +0 -130
  202. package/src/__tests__/resolve-tier.test.ts +0 -64
  203. package/src/__tests__/result-synthesizer.test.ts +0 -198
  204. package/src/__tests__/resume-context.test.ts +0 -4898
  205. package/src/__tests__/retrieval-eval.test.ts +0 -163
  206. package/src/__tests__/retrieval-trace.test.ts +0 -213
  207. package/src/__tests__/retrieve-audit-revision.test.ts +0 -255
  208. package/src/__tests__/retriever-boundary-weight.test.ts +0 -136
  209. package/src/__tests__/retriever-lazy-init-dispatch.test.ts +0 -123
  210. package/src/__tests__/retroactive-boost.test.ts +0 -223
  211. package/src/__tests__/salience-scoring.test.ts +0 -106
  212. package/src/__tests__/scope-inventory.test.ts +0 -200
  213. package/src/__tests__/scope-policy.test.ts +0 -69
  214. package/src/__tests__/scope-suggester.test.ts +0 -80
  215. package/src/__tests__/session-checkpoint.test.ts +0 -291
  216. package/src/__tests__/session-distiller.test.ts +0 -640
  217. package/src/__tests__/session-gc.test.ts +0 -215
  218. package/src/__tests__/skill-engine.test.ts +0 -552
  219. package/src/__tests__/skill-promotion.test.ts +0 -654
  220. package/src/__tests__/skill-verifier.test.ts +0 -122
  221. package/src/__tests__/source-heartbeat.test.ts +0 -254
  222. package/src/__tests__/stable-context-length-contract.test.ts +0 -62
  223. package/src/__tests__/store-patch-metadata.test.ts +0 -235
  224. package/src/__tests__/store-read-consistency.test.ts +0 -122
  225. package/src/__tests__/store-schema-migration.test.ts +0 -87
  226. package/src/__tests__/store-scope-audit.test.ts +0 -81
  227. package/src/__tests__/store-upsert.test.ts +0 -201
  228. package/src/__tests__/supersede-chain.test.ts +0 -154
  229. package/src/__tests__/synthesis-contract.test.ts +0 -252
  230. package/src/__tests__/synthesis-promotion.test.ts +0 -369
  231. package/src/__tests__/temporal-parser.test.ts +0 -241
  232. package/src/__tests__/temporal-validity.test.ts +0 -160
  233. package/src/__tests__/term-registry-terminal-roster.test.ts +0 -127
  234. package/src/__tests__/term-registry.test.ts +0 -121
  235. package/src/__tests__/tool-error-surfacing.test.ts +0 -98
  236. package/src/__tests__/tool-output-compressor.test.ts +0 -160
  237. package/src/__tests__/topic-tag.test.ts +0 -113
  238. package/src/__tests__/trusted-memory.test.ts +0 -867
  239. package/src/__tests__/ui-server-security.test.ts +0 -93
  240. package/src/__tests__/usage-tracker.test.ts +0 -287
  241. package/src/__tests__/utility-weight.test.ts +0 -57
  242. package/src/__tests__/verbatim-self-recall.test.ts +0 -279
  243. package/src/__tests__/version-manager.test.ts +0 -248
  244. package/src/__tests__/workflow-observation-skill-outcome.test.ts +0 -352
  245. package/src/__tests__/workflow-observation.test.ts +0 -433
  246. package/src/__tests__/write-verifier.test.ts +0 -122
package/src/ingest.ts CHANGED
@@ -13,6 +13,7 @@ import { join, basename, resolve, dirname } from "node:path";
13
13
  import { homedir } from "node:os";
14
14
  import { detectLang, tokenizeFts } from "./language-hook.js";
15
15
  import { redactSecrets } from "./pii-detector.js";
16
+ import { normalizeDedupText } from "./text-fingerprint.js";
16
17
  import type { MemoryStore, MemoryEntry } from "./store.js";
17
18
  import type { Embedder } from "./embedder.js";
18
19
  import { chunkDocument, type ChunkerConfig } from "./chunker.js";
@@ -35,6 +36,8 @@ import {
35
36
  import { compressToolOutput } from "./tool-output-compressor.js";
36
37
  import { tagNarrativeIfEnabled } from "./narrative-tagger.js";
37
38
 
39
+ export { normalizeDedupText };
40
+
38
41
  // ============================================================================
39
42
  // Types
40
43
  // ============================================================================
@@ -53,6 +56,8 @@ export interface IngestResult {
53
56
  chunksDeduped: number;
54
57
  dedupReasonCounts: DedupReasonCounts;
55
58
  errors: string[];
59
+ /** 子 agent 独立会话文件被整文件跳过的数量(目前只有 Codex 分支会产生;见 isCodexSubagentSessionFile)。 */
60
+ subagentFilesSkipped?: number;
56
61
  }
57
62
 
58
63
  interface ConversationTurn {
@@ -60,6 +65,29 @@ interface ConversationTurn {
60
65
  text: string;
61
66
  timestamp: string;
62
67
  sessionId: string;
68
+ /**
69
+ * 本条记忆所在 session 里用户亲手贴的图片总数——注意是整个 session 的,
70
+ * 不是本轮的张数。工具产的图(截图 / 读图,实测占全部图片九成)不计。
71
+ *
72
+ * 粒度取 session 而不是轮次,是有意的(2026-08-27 定):图所在的那一轮
73
+ * 常常因为正文太短根本没进库,精确坐标挂不上去;而同 session 的别的轮次
74
+ * 进了库。打成 session 级的标,搜到那些轮次时才能顺藤摸到图。
75
+ *
76
+ * 代价是精度低:一个 session 里几十条记忆会带同一个标,图未必跟搜到的
77
+ * 这一条有关。这是粒度换覆盖,明知而为——判断值不值得看,回 Deja 按
78
+ * session 读原文,图本体不进库。
79
+ */
80
+ sessionImages?: number;
81
+ /**
82
+ * 本 session 里 AI 自己产的图片总数:工具截图、读进来的图片文件、模型生成的图。
83
+ * 实测它是用户贴图的三倍多(5812 vs 1629)。
84
+ *
85
+ * 跟 sessionImages 分开计,因为两者服务的检索意图不是一回事——人找「我发过的
86
+ * 那张截图」要的是前者;agent 回溯「我当时生成的示意图长什么样」「那个页面当时
87
+ * 渲染成什么样」要的是后者。混成一个数,两种意图都答不好;只留前者,则 agent
88
+ * 自己回溯时看不见任何线索。
89
+ */
90
+ sessionToolImages?: number;
63
91
  }
64
92
 
65
93
  // ============================================================================
@@ -88,19 +116,21 @@ export type DedupReason = "hard" | "exact" | "llm-skip" | "llm-merge" | "unique"
88
116
 
89
117
  export type DedupReasonCounts = Record<DedupReason, number>;
90
118
 
91
- /** Secondary action on an existing memory during dedup (e.g., delete outdated entries). */
92
- export interface DedupAction {
93
- id: string;
94
- action: "delete";
95
- reason: string;
96
- }
97
-
119
+ /**
120
+ * 去重结论只回答「这条进来的切片存不存」,不带任何指向已有记忆的动作。
121
+ *
122
+ * 2026-10-05 之前这里还有一个 `secondaryDeletes`:模型可以顺手标记「过时」的已有记忆,
123
+ * 导入调用点原样 `store.delete`——候选不分 scope,不写审计、不记日志、错误吞掉。
124
+ * 2026-09-22T16:38:03Z 记忆文件导入就这样删掉了当天手写进人工维护那一层的一条偏好,
125
+ * 11 秒后同一轮导入写入了内容相近的文档切片;到 10-05 核对时,显式存过、不在主表、又没有
126
+ * forget 记录的手写记忆有 17 条。导入只新增和跳过,删除一律走 forget_memory(有隐私级别
127
+ * 检查与审计)。经过与数字见 CHANGELOG「Ingest dedup can no longer delete existing memories」;
128
+ * 回归用例 `ingest-dedup-no-deletes.test.ts`。
129
+ */
98
130
  export interface DedupCheckResult {
99
131
  action: "store" | "skip";
100
132
  reason: DedupReason;
101
133
  existingText?: string;
102
- /** Secondary actions on other existing memories (only populated with LLM dedup). */
103
- secondaryDeletes?: DedupAction[];
104
134
  }
105
135
 
106
136
  function createDedupReasonCounts(): DedupReasonCounts {
@@ -120,26 +150,6 @@ function recordDedupDecision(result: IngestResult, decision: DedupCheckResult):
120
150
  }
121
151
  }
122
152
 
123
- /**
124
- * Execute secondary delete actions from a dedup decision.
125
- * Errors are isolated per-action to avoid cascading failures.
126
- */
127
- async function executeSecondaryDeletes(
128
- store: MemoryStore,
129
- deletes: DedupAction[],
130
- ): Promise<number> {
131
- let executed = 0;
132
- for (const del of deletes) {
133
- try {
134
- await store.delete(del.id);
135
- executed++;
136
- } catch {
137
- // Per-action error isolation: log and continue
138
- }
139
- }
140
- return executed;
141
- }
142
-
143
153
  export function getDedupSkippedCount(result: IngestResult): number {
144
154
  return result.dedupReasonCounts.hard
145
155
  + result.dedupReasonCounts.exact
@@ -158,22 +168,6 @@ export function formatDedupReasonSummary(result: IngestResult): string {
158
168
  return `hard:${counts.hard}, exact:${counts.exact}, llm-skip:${counts["llm-skip"]}, llm-merge:${counts["llm-merge"]}`;
159
169
  }
160
170
 
161
- /**
162
- * Two-stage dedup: vector pre-filter + optional LLM semantic decision.
163
- *
164
- * Returns: "store" | "skip"
165
- *
166
- * Note: when the LLM says MERGE, we currently keep the new chunk instead of
167
- * dropping it. We do not have a structured ingest-time merge path yet, and
168
- * swallowing "same topic + new information" transcript chunks is worse for
169
- * recall fidelity than storing an incremental near-duplicate.
170
- */
171
- function normalizeDedupText(value: string): string {
172
- return value
173
- .replace(/^\[(用户|助手)\]\s*/gm, "")
174
- .replace(/\s+/g, " ")
175
- .trim();
176
- }
177
171
 
178
172
  /**
179
173
  * Detect content that carries temporal events, file operations, or explicit
@@ -283,6 +277,16 @@ function shouldForceCreateToolChoicePreference(
283
277
  : { shouldForceCreate: false };
284
278
  }
285
279
 
280
+ /**
281
+ * Two-stage dedup: vector pre-filter + optional LLM semantic decision.
282
+ *
283
+ * Returns: "store" | "skip"
284
+ *
285
+ * Note: when the LLM says MERGE, we currently keep the new chunk instead of
286
+ * dropping it. We do not have a structured ingest-time merge path yet, and
287
+ * swallowing "same topic + new information" transcript chunks is worse for
288
+ * recall fidelity than storing an incremental near-duplicate.
289
+ */
286
290
  export async function dedupCheck(
287
291
  store: MemoryStore,
288
292
  vector: number[],
@@ -392,8 +396,6 @@ export async function dedupCheck(
392
396
  const hasMulti = typeof (llm as any).dedupDecisionMulti === "function";
393
397
 
394
398
  let primaryAction: "CREATE" | "MERGE" | "SKIP" = "CREATE";
395
- let primaryReason = "";
396
- const secondaryDeletes: DedupAction[] = [];
397
399
 
398
400
  if (hasMulti) {
399
401
  const candidates = results
@@ -401,48 +403,20 @@ export async function dedupCheck(
401
403
  .slice(0, DEDUP_CANDIDATE_LIMIT)
402
404
  .map(r => ({ id: r.entry.id, text: r.entry.text }));
403
405
 
406
+ // 只取「存 / 并 / 跳」这一个结论。候选来自全库(不分 scope),模型对它们没有处置权,
407
+ // 见 DedupCheckResult 上方的说明。
404
408
  const decision = await llm.dedupDecisionMulti(text, candidates);
405
409
  primaryAction = decision.action;
406
- primaryReason = decision.reason;
407
-
408
- // Build secondary deletes from validated actions (resolve IDs at parse time)
409
- if (decision.actions) {
410
- for (const act of decision.actions) {
411
- const target = candidates[act.match_index - 1];
412
- if (target) {
413
- secondaryDeletes.push({
414
- id: target.id,
415
- action: "delete",
416
- reason: typeof act.reason === "string" ? act.reason : "",
417
- });
418
- }
419
- }
420
- }
421
410
  } else {
422
411
  const decision = await llm.dedupDecision(text, existingText);
423
412
  primaryAction = decision.action;
424
- primaryReason = decision.reason;
425
413
  }
426
414
 
427
415
  if (primaryAction === "SKIP") {
428
- return {
429
- action: "skip",
430
- reason: "llm-skip",
431
- existingText,
432
- ...(secondaryDeletes.length > 0 ? { secondaryDeletes } : {}),
433
- };
416
+ return { action: "skip", reason: "llm-skip", existingText };
434
417
  }
435
418
  if (primaryAction === "MERGE") {
436
- return {
437
- action: "store",
438
- reason: "llm-merge",
439
- existingText,
440
- ...(secondaryDeletes.length > 0 ? { secondaryDeletes } : {}),
441
- };
442
- }
443
- // CREATE — still execute secondary deletes if any
444
- if (secondaryDeletes.length > 0) {
445
- return { action: "store", reason: "unique", secondaryDeletes };
419
+ return { action: "store", reason: "llm-merge", existingText };
446
420
  }
447
421
  } catch {
448
422
  // LLM failed, fall through to store
@@ -493,7 +467,7 @@ function fallbackExtraction(text: string): SmartExtraction {
493
467
  * Uses LLM 6-category extraction when available, falls back to heuristic.
494
468
  * Returns: category + L0 + L1 + importance for each text.
495
469
  */
496
- async function smartExtractBatch(
470
+ export async function smartExtractBatch(
497
471
  texts: string[],
498
472
  llm?: LLMClient | null,
499
473
  ): Promise<SmartExtraction[]> {
@@ -522,7 +496,7 @@ function isCoreSummaryEnabled(): boolean {
522
496
  * Only runs when RECALLNEST_CORE_SUMMARY=true and LLM is available.
523
497
  * Returns null array when disabled (no overhead).
524
498
  */
525
- async function generateCoreSummaries(
499
+ export async function generateCoreSummaries(
526
500
  texts: string[],
527
501
  llm?: LLMClient | null,
528
502
  ): Promise<(string | null)[]> {
@@ -649,13 +623,20 @@ export async function drainPendingQueue(
649
623
  const entryText = extractions[j].l1 || extractions[j].l0;
650
624
  const language = detectLang(entryText);
651
625
  const fts_text = tokenizeFts(entryText, language);
652
- await store.store({
626
+ // 2026-08-24 修:回填路径此前直接 store.store(),绕过 buildIngestedEntry,
627
+ // 导致条目缺 boundary/layer/tier(transcript 碎片不降权)。改走同一构造器;
628
+ // 入队时未保留原文件路径,file 用 "pending-queue" 诚实标记回填来源。
629
+ const entry = buildIngestedEntry({
630
+ source: batch[j].scope.split(":")[0],
631
+ scope: batch[j].scope,
653
632
  text: entryText,
654
633
  vector: vectors[j],
655
- category: extractions[j].category as any,
656
- scope: batch[j].scope,
657
- importance: extractions[j].importance,
658
- metadata: JSON.stringify({ source: batch[j].scope.split(":")[0], l0_abstract: extractions[j].l0, l1_overview: extractions[j].l1, l2_content: extractions[j].l1 || extractions[j].l0 }),
634
+ extraction: extractions[j],
635
+ file: "pending-queue",
636
+ });
637
+ await store.store({
638
+ ...entry,
639
+ category: entry.category as any,
659
640
  language,
660
641
  fts_text,
661
642
  });
@@ -685,7 +666,7 @@ function initialTier(extraction: Pick<SmartExtraction, "category" | "importance"
685
666
  return "peripheral";
686
667
  }
687
668
 
688
- function buildIngestedEntry(params: {
669
+ export function buildIngestedEntry(params: {
689
670
  source: string;
690
671
  scope: string;
691
672
  text: string;
@@ -696,6 +677,10 @@ function buildIngestedEntry(params: {
696
677
  heading?: string;
697
678
  /** Tier 3.1: optional core summary (≤200 chars) for token-efficient context output */
698
679
  coreSummary?: string | null;
680
+ /** 本条记忆所在 session 里用户亲手贴的图片总数;无图时不传 */
681
+ sessionImages?: number;
682
+ /** 本条记忆所在 session 里 AI 自己产的图片总数(截图 / 读图 / 生图);无则不传 */
683
+ sessionToolImages?: number;
699
684
  }): {
700
685
  text: string;
701
686
  vector: number[];
@@ -739,6 +724,8 @@ function buildIngestedEntry(params: {
739
724
  l1_overview: params.extraction.l1,
740
725
  l2_content: params.text,
741
726
  ...(params.coreSummary ? { core_summary: params.coreSummary } : {}),
727
+ ...(params.sessionImages ? { sessionImages: params.sessionImages } : {}),
728
+ ...(params.sessionToolImages ? { sessionToolImages: params.sessionToolImages } : {}),
742
729
  tier,
743
730
  boundary: resolution.boundary,
744
731
  ...(narrative ? { narrative } : {}),
@@ -786,16 +773,77 @@ const MARKDOWN_CHUNK_CONFIG: ChunkerConfig = {
786
773
  // CC Transcript Parser
787
774
  // ============================================================================
788
775
 
789
- function parseCCTranscript(filePath: string): ConversationTurn[] {
776
+ /**
777
+ * 数一条 transcript 记录里所有的图片信号,**不预设它们是什么用途、在什么位置**。
778
+ *
779
+ * 这是有意的宽口径:AI 产图的形态一直在长(工具截图、读进来的图片文件、模型生图、
780
+ * 写公众号时让它配的插图……),任何"列举有哪几类"的写法都注定漏掉下一类。所以这里
781
+ * 只回答一个问题——「这是不是一张图」,然后由调用方用**补集**去定义 AI 产图:
782
+ * 整条记录里的图 减去 用户在消息里亲手贴的,剩下的全算,不问出身。
783
+ *
784
+ * 判据是「携带图片的块」的三种通用形态,跨 Anthropic / OpenAI / Kimi 都成立:
785
+ * - type 里含 image(image / input_image / image_url / image_generation_* …)
786
+ * - 有 image_url / imageUrl 字段
787
+ * - 有 media_type / mimeType 且以 image/ 开头
788
+ *
789
+ * ⚠️ 这是**信号计数不是精确张数**:一次生图可能同时留下 call 与 end 两条记录,
790
+ * 会被数成 2。这是刻意选的方向——宁可多算,不可漏报,因为它服务的问题是
791
+ * 「这附近有没有图」而不是「精确几张」。
792
+ */
793
+ function countImageSignals(node: unknown, depth = 0): number {
794
+ if (depth > 10 || node === null || typeof node !== "object") return 0;
795
+ if (Array.isArray(node)) {
796
+ let sum = 0;
797
+ for (const item of node) sum += countImageSignals(item, depth + 1);
798
+ return sum;
799
+ }
800
+
801
+ const obj = node as Record<string, unknown>;
802
+ const type = obj.type;
803
+ const mediaType = obj.media_type ?? obj.mimeType;
804
+ const isImageBlock =
805
+ (typeof type === "string" && type.includes("image")) ||
806
+ typeof obj.image_url === "string" ||
807
+ (obj.imageUrl !== null && typeof obj.imageUrl === "object") ||
808
+ (typeof mediaType === "string" && mediaType.startsWith("image/"));
809
+
810
+ // 命中即计一次并停止下钻:一个图片块内部不会再嵌另一张独立的图,继续递归
811
+ // 只会把 {type:"image", source:{media_type:"image/png"}} 这种数成两张。
812
+ if (isImageBlock) return 1;
813
+
814
+ let count = 0;
815
+ for (const value of Object.values(obj)) {
816
+ if (value !== null && typeof value === "object") {
817
+ count += countImageSignals(value, depth + 1);
818
+ }
819
+ }
820
+ return count;
821
+ }
822
+
823
+ export function parseCCTranscript(filePath: string): ConversationTurn[] {
790
824
  const turns: ConversationTurn[] = [];
791
825
  const content = readFileSync(filePath, "utf-8");
792
826
  const lines = content.split("\n").filter((l) => l.trim());
793
827
 
794
828
  let sessionId = "";
829
+ // 整个 session 里的图片计数,扫完全文后统一盖到每个 turn 上。两类分开数:
830
+ // 用户亲手贴的 vs AI 自己产的(工具截图 / 读图 / 生图)。
831
+ // 数得到是因为本函数本来就逐行读完整个文件,顺带累加不额外花钱。
832
+ let sessionImages = 0;
833
+ let sessionToolImages = 0;
795
834
 
796
835
  for (const line of lines) {
797
836
  try {
798
837
  const obj = JSON.parse(line);
838
+ // 子 agent 侧链(isSidechain:true)的 user 行是主 agent 写给子 agent 的任务信封,不是人说的话。
839
+ // 今天 CC 把侧链分文件存在 <session>/subagents/agent-*.jsonl,而本函数的调用方只读项目目录顶层
840
+ // 的 .jsonl,所以这一行当前不会命中;留着是防 CC 哪天把侧链内联回主文件(2026-09-08 借鉴 ECC
841
+ // observe.sh 的「按结构字段认谁在说、不认 role」)。
842
+ if (obj.isSidechain === true) continue;
843
+ // 这一行里所有的图片信号。用户亲手贴的那部分在下面单独精确认,
844
+ // 其余的一律按补集归入 AI 产图——不枚举它是截图、读图、生图还是配图。
845
+ const lineImageSignals = countImageSignals(obj);
846
+ let lineUserImages = 0;
799
847
 
800
848
  if (!sessionId && obj.sessionId) {
801
849
  sessionId = obj.sessionId;
@@ -804,20 +852,39 @@ function parseCCTranscript(filePath: string): ConversationTurn[] {
804
852
  if (obj.type === "user" && obj.message) {
805
853
  const msg = obj.message;
806
854
  let text = "";
855
+ let turnImages = 0;
807
856
  if (typeof msg.content === "string") {
808
857
  text = msg.content;
809
858
  } else if (Array.isArray(msg.content)) {
810
- // Extract text blocks, skip tool_use/tool_result/images
859
+ // 文本块照旧提取,tool_use / tool_result 仍然整块丢弃(架构决定,见 memory-boundaries)。
860
+ // 图片本体也不进库,只数一下:这里只认顶层的 image block,
861
+ // tool_result 里内嵌的不算——那是工具截图/读图,实测占全部图片九成。
811
862
  text = msg.content
812
863
  .filter((c: any) => c.type === "text")
813
864
  .map((c: any) => c.text || "")
814
865
  .join("\n");
866
+ turnImages = msg.content.filter((c: any) => c?.type === "image").length;
815
867
  }
816
-
817
- if (text.trim().length > 10) {
868
+ sessionImages += turnImages;
869
+ lineUserImages = turnImages;
870
+
871
+ // 只贴图不配字的那一轮,此前会被长度闸整条丢掉——实测占含图轮次的 12.5%,
872
+ // 且丢掉的常是「操作步骤在这里」+ 截图这类正文全在图里的高价值轮次
873
+ // (最极端的样本是 0 字配 7 张图)。给它一个占位正文,让它能跟下一条
874
+ // 助手回复合成 chunk——助手的回复通常描述了图里是什么,那就是定位这些图的文字。
875
+ //
876
+ // 有配文时正文一个字都不改:文字本身已是检索入口,往里塞标记只会稀释 embedding。
877
+ const trimmed = text.trim();
878
+ const body = trimmed.length > 0
879
+ ? trimmed
880
+ : turnImages > 0
881
+ ? `[图片×${turnImages}]`
882
+ : "";
883
+
884
+ if (body.length > 10 || turnImages > 0) {
818
885
  turns.push({
819
886
  role: "user",
820
- text: text.trim(),
887
+ text: body,
821
888
  timestamp: obj.timestamp || "",
822
889
  sessionId,
823
890
  });
@@ -846,11 +913,26 @@ function parseCCTranscript(filePath: string): ConversationTurn[] {
846
913
  });
847
914
  }
848
915
  }
916
+
917
+ // 补集:这一行里除用户亲手贴的之外的图,全算 AI 产的,不问出身。
918
+ // 用补集而不是列举,是因为 AI 产图的形态会一直长(生图、截图、读图、
919
+ // 写文章时的配图……),任何枚举都注定漏掉下一类。
920
+ sessionToolImages += Math.max(0, lineImageSignals - lineUserImages);
849
921
  } catch {
850
922
  // Skip malformed lines
851
923
  }
852
924
  }
853
925
 
926
+ // 整份 transcript 扫完才知道总数,统一盖到每个 turn 上——这条标记回答的是
927
+ // 「这条记忆所在的 session 里有没有图」,不是「这一轮有没有图」。图所在的那轮
928
+ // 常因正文太短进不了库,只有盖到同 session 的其他轮次上,才可能被搜到。
929
+ if (sessionImages > 0 || sessionToolImages > 0) {
930
+ for (const turn of turns) {
931
+ if (sessionImages > 0) turn.sessionImages = sessionImages;
932
+ if (sessionToolImages > 0) turn.sessionToolImages = sessionToolImages;
933
+ }
934
+ }
935
+
854
936
  return turns;
855
937
  }
856
938
 
@@ -872,12 +954,26 @@ export function groupTurnsIntoChunks(turns: ConversationTurn[]): Array<{
872
954
  text: string;
873
955
  timestamp: string;
874
956
  sessionId: string;
957
+ sessionImages?: number;
958
+ sessionToolImages?: number;
875
959
  }> {
876
960
  const filtered = filterNoiseTurns(turns);
877
- const chunks: Array<{ text: string; timestamp: string; sessionId: string }> = [];
961
+ const chunks: Array<{
962
+ text: string;
963
+ timestamp: string;
964
+ sessionId: string;
965
+ sessionImages?: number;
966
+ sessionToolImages?: number;
967
+ }> = [];
878
968
 
879
969
  for (let i = 0; i < filtered.length; i++) {
880
970
  const turn = filtered[i];
971
+ // 同一 session 切出来的每个 chunk 都带同一个标——它说的是「这个 session 有图」,
972
+ // 跟这一片本身有没有图无关,这正是 session 粒度要的效果。
973
+ const imgMark = {
974
+ ...(turn.sessionImages ? { sessionImages: turn.sessionImages } : {}),
975
+ ...(turn.sessionToolImages ? { sessionToolImages: turn.sessionToolImages } : {}),
976
+ };
881
977
 
882
978
  // If this is a user turn followed by an assistant turn, merge them
883
979
  if (turn.role === "user" && i + 1 < filtered.length && filtered[i + 1].role === "assistant") {
@@ -896,10 +992,10 @@ export function groupTurnsIntoChunks(turns: ConversationTurn[]): Array<{
896
992
  if (seg.text.length > CONVERSATION_CHUNK_CONFIG.maxChunkSize) {
897
993
  const chunkResult = chunkDocument(seg.text, CONVERSATION_CHUNK_CONFIG);
898
994
  for (const chunk of chunkResult.chunks) {
899
- chunks.push({ text: chunk, timestamp: turn.timestamp, sessionId: turn.sessionId });
995
+ chunks.push({ text: chunk, timestamp: turn.timestamp, sessionId: turn.sessionId, ...imgMark });
900
996
  }
901
997
  } else {
902
- chunks.push({ text: seg.text, timestamp: turn.timestamp, sessionId: turn.sessionId });
998
+ chunks.push({ text: seg.text, timestamp: turn.timestamp, sessionId: turn.sessionId, ...imgMark });
903
999
  }
904
1000
  }
905
1001
  } else {
@@ -907,6 +1003,7 @@ export function groupTurnsIntoChunks(turns: ConversationTurn[]): Array<{
907
1003
  text: merged,
908
1004
  timestamp: turn.timestamp,
909
1005
  sessionId: turn.sessionId,
1006
+ ...imgMark,
910
1007
  });
911
1008
  }
912
1009
 
@@ -925,10 +1022,10 @@ export function groupTurnsIntoChunks(turns: ConversationTurn[]): Array<{
925
1022
  if (seg.text.length > CONVERSATION_CHUNK_CONFIG.maxChunkSize) {
926
1023
  const chunkResult = chunkDocument(seg.text, CONVERSATION_CHUNK_CONFIG);
927
1024
  for (const chunk of chunkResult.chunks) {
928
- chunks.push({ text: chunk, timestamp: turn.timestamp, sessionId: turn.sessionId });
1025
+ chunks.push({ text: chunk, timestamp: turn.timestamp, sessionId: turn.sessionId, ...imgMark });
929
1026
  }
930
1027
  } else {
931
- chunks.push({ text: seg.text, timestamp: turn.timestamp, sessionId: turn.sessionId });
1028
+ chunks.push({ text: seg.text, timestamp: turn.timestamp, sessionId: turn.sessionId, ...imgMark });
932
1029
  }
933
1030
  }
934
1031
  } else {
@@ -936,6 +1033,7 @@ export function groupTurnsIntoChunks(turns: ConversationTurn[]): Array<{
936
1033
  text,
937
1034
  timestamp: turn.timestamp,
938
1035
  sessionId: turn.sessionId,
1036
+ ...imgMark,
939
1037
  });
940
1038
  }
941
1039
  }
@@ -959,18 +1057,48 @@ export function groupTurnsIntoChunks(turns: ConversationTurn[]): Array<{
959
1057
  // Codex Session Parser
960
1058
  // ============================================================================
961
1059
 
962
- function parseCodexSession(filePath: string): ConversationTurn[] {
1060
+ /**
1061
+ * Codex multi_agent 派出的子 agent 会话是独立的 rollout 文件,首行 session_meta 带 parent_thread_id
1062
+ * (另有 agent_nickname / agent_role / multi_agent_version)。文件里 role=user 的 input_text 是主 agent
1063
+ * 派的任务,不是人说的话——2026-09-08 实测本机 5,597 个 Codex 会话文件里 2,555 个(45.6%)是子 agent 文件,
1064
+ * 其 user turn 占全部 user turn 的 46.7%(近 7 天 63.7%)。ingest 前整文件跳过,与 Kimi 分支只读
1065
+ * agents/main/wire.jsonl 同一个判据:按结构字段认「谁在说」,不认 role。只读首行,不整读文件。
1066
+ */
1067
+ export function isCodexSubagentSessionFile(filePath: string): boolean {
1068
+ try {
1069
+ const content = readFileSync(filePath, "utf-8");
1070
+ const nl = content.indexOf("\n");
1071
+ const first = (nl === -1 ? content : content.slice(0, nl)).trim();
1072
+ if (!first) return false;
1073
+ const obj = JSON.parse(first);
1074
+ if (obj?.type !== "session_meta") return false;
1075
+ const parent = obj.payload?.parent_thread_id;
1076
+ return typeof parent === "string" && parent.length > 0;
1077
+ } catch {
1078
+ return false;
1079
+ }
1080
+ }
1081
+
1082
+ export function parseCodexSession(filePath: string): ConversationTurn[] {
963
1083
  const turns: ConversationTurn[] = [];
964
1084
  const content = readFileSync(filePath, "utf-8");
965
1085
  const lines = content.split("\n").filter((l) => l.trim());
966
1086
 
967
1087
  let sessionId = "";
1088
+ // 同 parseCCTranscript:两类图片分开计数。
1089
+ // Codex 的图片字段是 input_image(OpenAI 格式),不是 Anthropic 的 image;
1090
+ // 用户贴的在 payload.content 里可以精确认;其余一律按补集归 AI 产图。
1091
+ let sessionImages = 0;
1092
+ let sessionToolImages = 0;
968
1093
 
969
1094
  for (const line of lines) {
970
1095
  try {
971
1096
  const obj = JSON.parse(line);
972
1097
  const payload = obj.payload;
973
1098
  const timestamp = obj.timestamp || "";
1099
+ // 同 parseCCTranscript:整行图片信号,减去用户贴的,剩下全归 AI 产
1100
+ const lineImageSignals = countImageSignals(obj);
1101
+ let lineUserImages = 0;
974
1102
 
975
1103
  if (obj.type === "session_meta" && payload?.id) {
976
1104
  sessionId = payload.id;
@@ -982,6 +1110,13 @@ function parseCodexSession(filePath: string): ConversationTurn[] {
982
1110
  const content = payload.content;
983
1111
 
984
1112
  if (Array.isArray(content)) {
1113
+ // 只精确认用户消息里的 input_image;Codex 自己生的图 / 读的图挂在
1114
+ // payload.output 等别处,由下面的补集统一接住,这里不去枚举它们。
1115
+ if (role === "user") {
1116
+ const own = content.filter((c: any) => c?.type === "input_image").length;
1117
+ sessionImages += own;
1118
+ lineUserImages += own;
1119
+ }
985
1120
  for (const c of content) {
986
1121
  if (c.type === "input_text" && c.text && c.text.length > 10) {
987
1122
  // Skip system/developer prompts (usually very long instructions)
@@ -1023,11 +1158,21 @@ function parseCodexSession(filePath: string): ConversationTurn[] {
1023
1158
  }
1024
1159
  }
1025
1160
  }
1161
+
1162
+ // 补集:除用户亲手贴的之外,这一行里的图全算 AI 产的,不问出身。
1163
+ sessionToolImages += Math.max(0, lineImageSignals - lineUserImages);
1026
1164
  } catch {
1027
1165
  // Skip malformed lines
1028
1166
  }
1029
1167
  }
1030
1168
 
1169
+ if (sessionImages > 0 || sessionToolImages > 0) {
1170
+ for (const turn of turns) {
1171
+ if (sessionImages > 0) turn.sessionImages = sessionImages;
1172
+ if (sessionToolImages > 0) turn.sessionToolImages = sessionToolImages;
1173
+ }
1174
+ }
1175
+
1031
1176
  return turns;
1032
1177
  }
1033
1178
 
@@ -1099,6 +1244,14 @@ export async function ingestCodexSessions(
1099
1244
  continue;
1100
1245
  }
1101
1246
 
1247
+ // 子 agent 独立会话整文件跳过(判据与理由见 isCodexSubagentSessionFile)。标记 processed 是为了
1248
+ // 下一轮非 --recent 的增量不再重读它;子 agent 的结论本就该由主 agent 落盘,原文由 Deja 全量索引兜底。
1249
+ if (isCodexSubagentSessionFile(filePath)) {
1250
+ result.subagentFilesSkipped = (result.subagentFilesSkipped ?? 0) + 1;
1251
+ markProcessed(filePath, stat.size, 0, stat.mtimeMs);
1252
+ continue;
1253
+ }
1254
+
1102
1255
  const turns = parseCodexSession(filePath);
1103
1256
  if (turns.length === 0) {
1104
1257
  result.chunksSkipped++;
@@ -1133,9 +1286,6 @@ export async function ingestCodexSessions(
1133
1286
  if (!options.noDedup) {
1134
1287
  const decision = await dedupCheck(store, vector, batch[j], options.llm);
1135
1288
  recordDedupDecision(result, decision);
1136
- if (decision.secondaryDeletes?.length) {
1137
- await executeSecondaryDeletes(store, decision.secondaryDeletes);
1138
- }
1139
1289
  if (decision.action === "skip") continue;
1140
1290
  }
1141
1291
  dedupedTexts.push(batch[j]);
@@ -1164,6 +1314,8 @@ export async function ingestCodexSessions(
1164
1314
  sessionId: chunk.sessionId,
1165
1315
  file: basename(filePath),
1166
1316
  coreSummary: coreSummaries[j],
1317
+ sessionImages: chunk.sessionImages,
1318
+ sessionToolImages: chunk.sessionToolImages,
1167
1319
  }));
1168
1320
  }
1169
1321
  }
@@ -1205,7 +1357,7 @@ export async function ingestCodexSessions(
1205
1357
  // context.append_loop_event content.part → 助手文本(part.type==="text";think 跳过)
1206
1358
  // 其余类型(llm.tools_snapshot / mcp.tools_discovered / config.update / llm.request /
1207
1359
  // usage.record / permission.* 等,约占 86% 体积)是运行事件,一律跳过。
1208
- function parseKimiSession(filePath: string): ConversationTurn[] {
1360
+ export function parseKimiSession(filePath: string): ConversationTurn[] {
1209
1361
  const turns: ConversationTurn[] = [];
1210
1362
  const content = readFileSync(filePath, "utf-8");
1211
1363
  const lines = content.split("\n").filter((l) => l.trim());
@@ -1215,6 +1367,11 @@ function parseKimiSession(filePath: string): ConversationTurn[] {
1215
1367
  const pathParts = filePath.split("/");
1216
1368
  const rawId = pathParts.length >= 4 ? pathParts[pathParts.length - 4] : basename(filePath, ".jsonl");
1217
1369
  const sessionId = rawId.replace(/^session_/, "");
1370
+ // 同 parseCCTranscript:两类图片分开计数。
1371
+ // Kimi 的图片字段是 image_url;用户贴的在 turn.prompt / context.append_message,
1372
+ // AI 产的在 context.append_loop_event 的 tool.result 里。
1373
+ let sessionImages = 0;
1374
+ let sessionToolImages = 0;
1218
1375
 
1219
1376
  const pushTurn = (role: "user" | "assistant", text: string, timestamp: string) => {
1220
1377
  const trimmed = text.trim();
@@ -1231,6 +1388,23 @@ function parseKimiSession(filePath: string): ConversationTurn[] {
1231
1388
  const obj = JSON.parse(line);
1232
1389
  const timestamp = typeof obj.time === "number" ? new Date(obj.time).toISOString() : "";
1233
1390
 
1391
+ // 图片统计在这里一次算完,不放进下面的分支——那些分支各自 continue,
1392
+ // 放进去会被跳过。用户贴的精确认,其余按补集归 AI 产,不枚举出身。
1393
+ {
1394
+ const lineImageSignals = countImageSignals(obj);
1395
+ let lineUserImages = 0;
1396
+ if (obj.type === "turn.prompt" && obj.origin?.kind === "user" && Array.isArray(obj.input)) {
1397
+ lineUserImages = obj.input.filter((c: any) => c?.type === "image_url").length;
1398
+ } else if (obj.type === "context.append_message") {
1399
+ const m = obj.message;
1400
+ if (m?.role === "user" && m?.origin?.kind === "user" && Array.isArray(m.content)) {
1401
+ lineUserImages = m.content.filter((c: any) => c?.type === "image_url").length;
1402
+ }
1403
+ }
1404
+ sessionImages += lineUserImages;
1405
+ sessionToolImages += Math.max(0, lineImageSignals - lineUserImages);
1406
+ }
1407
+
1234
1408
  if (obj.type === "turn.prompt") {
1235
1409
  if (obj.origin?.kind !== "user") continue;
1236
1410
  const parts = Array.isArray(obj.input) ? obj.input : [];
@@ -1271,6 +1445,13 @@ function parseKimiSession(filePath: string): ConversationTurn[] {
1271
1445
  }
1272
1446
  }
1273
1447
 
1448
+ if (sessionImages > 0 || sessionToolImages > 0) {
1449
+ for (const turn of turns) {
1450
+ if (sessionImages > 0) turn.sessionImages = sessionImages;
1451
+ if (sessionToolImages > 0) turn.sessionToolImages = sessionToolImages;
1452
+ }
1453
+ }
1454
+
1274
1455
  return turns;
1275
1456
  }
1276
1457
 
@@ -1403,9 +1584,6 @@ export async function ingestKimiSessions(
1403
1584
  if (!options.noDedup) {
1404
1585
  const decision = await dedupCheck(store, vector, batch[j], options.llm);
1405
1586
  recordDedupDecision(result, decision);
1406
- if (decision.secondaryDeletes?.length) {
1407
- await executeSecondaryDeletes(store, decision.secondaryDeletes);
1408
- }
1409
1587
  if (decision.action === "skip") continue;
1410
1588
  }
1411
1589
  dedupedTexts.push(batch[j]);
@@ -1434,6 +1612,8 @@ export async function ingestKimiSessions(
1434
1612
  sessionId: chunk.sessionId,
1435
1613
  file: basename(filePath),
1436
1614
  coreSummary: coreSummaries[j],
1615
+ sessionImages: chunk.sessionImages,
1616
+ sessionToolImages: chunk.sessionToolImages,
1437
1617
  }));
1438
1618
  }
1439
1619
  }
@@ -1606,9 +1786,6 @@ export async function ingestGeminiSessions(
1606
1786
  if (!options.noDedup) {
1607
1787
  const decision = await dedupCheck(store, vector, batch[j], options.llm);
1608
1788
  recordDedupDecision(result, decision);
1609
- if (decision.secondaryDeletes?.length) {
1610
- await executeSecondaryDeletes(store, decision.secondaryDeletes);
1611
- }
1612
1789
  if (decision.action === "skip") continue;
1613
1790
  }
1614
1791
  dedupedTexts.push(batch[j]);
@@ -1676,7 +1853,7 @@ export async function ingestGeminiSessions(
1676
1853
  * Split a markdown file by headings (## or #) into chunks.
1677
1854
  * Each chunk includes the heading + content under it.
1678
1855
  */
1679
- function parseMarkdown(filePath: string): Array<{ text: string; heading: string }> {
1856
+ export function parseMarkdown(filePath: string): Array<{ text: string; heading: string }> {
1680
1857
  const content = readFileSync(filePath, "utf-8");
1681
1858
  const chunks: Array<{ text: string; heading: string }> = [];
1682
1859
 
@@ -1758,6 +1935,12 @@ export async function ingestCCTranscripts(
1758
1935
  };
1759
1936
 
1760
1937
  const dir = expandHome(sourcePath);
1938
+ // session-digest.py 摘要调用留下的会话记录不 ingest(2026-08-24 审计:这类
1939
+ // transcript 是原始会话 8000 字符采样的副本,ingest 后 scope/metadata 指向
1940
+ // 摘要任务自身 sid——内容是真的、坐标是假的,已产生 1,950 条 evidence 污染)。
1941
+ if (dir.includes("session-digest")) {
1942
+ return result;
1943
+ }
1761
1944
  if (!existsSync(dir)) {
1762
1945
  result.errors.push(`Directory not found: ${dir}`);
1763
1946
  return result;
@@ -1836,9 +2019,6 @@ export async function ingestCCTranscripts(
1836
2019
  if (!options.noDedup) {
1837
2020
  const decision = await dedupCheck(store, vector, batch[j], options.llm);
1838
2021
  recordDedupDecision(result, decision);
1839
- if (decision.secondaryDeletes?.length) {
1840
- await executeSecondaryDeletes(store, decision.secondaryDeletes);
1841
- }
1842
2022
  if (decision.action === "skip") continue;
1843
2023
  }
1844
2024
 
@@ -1879,6 +2059,8 @@ export async function ingestCCTranscripts(
1879
2059
  sessionId: chunk.sessionId,
1880
2060
  file,
1881
2061
  coreSummary: coreSummaries[j],
2062
+ sessionImages: chunk.sessionImages,
2063
+ sessionToolImages: chunk.sessionToolImages,
1882
2064
  }));
1883
2065
  }
1884
2066
 
@@ -1988,9 +2170,6 @@ export async function ingestMarkdownFiles(
1988
2170
  if (!options.noDedup) {
1989
2171
  const decision = await dedupCheck(store, vector, batch[j], options.llm);
1990
2172
  recordDedupDecision(result, decision);
1991
- if (decision.secondaryDeletes?.length) {
1992
- await executeSecondaryDeletes(store, decision.secondaryDeletes);
1993
- }
1994
2173
  if (decision.action === "skip") continue;
1995
2174
  }
1996
2175
  dedupedTexts.push(batch[j]);
@@ -2122,9 +2301,6 @@ export async function ingestGenericText(
2122
2301
  if (!options.noDedup) {
2123
2302
  const decision = await dedupCheck(store, vector, batch[j], options.llm);
2124
2303
  recordDedupDecision(result, decision);
2125
- if (decision.secondaryDeletes?.length) {
2126
- await executeSecondaryDeletes(store, decision.secondaryDeletes);
2127
- }
2128
2304
  if (decision.action === "skip") continue;
2129
2305
  }
2130
2306
  dedupedTexts.push(batch[j]);
@@ -2241,9 +2417,6 @@ export async function ingestConnectorFile(
2241
2417
  if (!options.noDedup) {
2242
2418
  const decision = await dedupCheck(store, vector, texts[j], options.llm);
2243
2419
  recordDedupDecision(result, decision);
2244
- if (decision.secondaryDeletes?.length) {
2245
- await executeSecondaryDeletes(store, decision.secondaryDeletes);
2246
- }
2247
2420
  if (decision.action === "skip") continue;
2248
2421
  }
2249
2422
  dedupedTexts.push(texts[j]);