recallnest 3.0.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/README.md +222 -254
  3. package/README_CN.md +206 -243
  4. package/package.json +3 -2
  5. package/src/capture-engine.ts +26 -4
  6. package/src/context-composer-stable.ts +5 -1
  7. package/src/embedder.ts +24 -0
  8. package/src/env-config.ts +18 -0
  9. package/src/ingest.ts +225 -17
  10. package/src/kg-extractor.ts +17 -0
  11. package/src/memory-output.ts +35 -0
  12. package/src/pivot-apply.ts +52 -1
  13. package/src/pivot-evidence.ts +128 -0
  14. package/src/ppr-traversal.ts +25 -1
  15. package/src/runtime-config.ts +9 -0
  16. package/src/session-engine.ts +11 -4
  17. package/src/session-output.ts +9 -1
  18. package/src/session-store.ts +2 -3
  19. package/src/skill-promotion.ts +67 -1
  20. package/src/__tests__/access-tracker-novelty.test.ts +0 -324
  21. package/src/__tests__/activity-counter.test.ts +0 -125
  22. package/src/__tests__/adaptive-fidelity.test.ts +0 -102
  23. package/src/__tests__/admission-control.test.ts +0 -206
  24. package/src/__tests__/age-label.test.ts +0 -33
  25. package/src/__tests__/anchor-generator.test.ts +0 -67
  26. package/src/__tests__/api-server-security.test.ts +0 -102
  27. package/src/__tests__/auto-consolidation.test.ts +0 -168
  28. package/src/__tests__/auto-recall.test.ts +0 -131
  29. package/src/__tests__/babel-integration.test.ts +0 -144
  30. package/src/__tests__/batch-dedup.test.ts +0 -65
  31. package/src/__tests__/belief-history.test.ts +0 -220
  32. package/src/__tests__/canary-eval.test.ts +0 -114
  33. package/src/__tests__/capture-engine.test.ts +0 -1328
  34. package/src/__tests__/capture-heuristic.test.ts +0 -171
  35. package/src/__tests__/cascade-forget.test.ts +0 -210
  36. package/src/__tests__/category-min-scores.test.ts +0 -116
  37. package/src/__tests__/cc8-cc1-essential-context.test.ts +0 -298
  38. package/src/__tests__/circuit-breaker.test.ts +0 -99
  39. package/src/__tests__/cluster-consolidation.test.ts +0 -1236
  40. package/src/__tests__/confidence-metadata.test.ts +0 -125
  41. package/src/__tests__/confidence-tracker.test.ts +0 -261
  42. package/src/__tests__/conflict-advisor.test.ts +0 -206
  43. package/src/__tests__/conflict-escalation.test.ts +0 -149
  44. package/src/__tests__/conflict-lifecycle.test.ts +0 -83
  45. package/src/__tests__/conflict-output.test.ts +0 -85
  46. package/src/__tests__/conflict-store.test.ts +0 -418
  47. package/src/__tests__/connector-v1.test.ts +0 -181
  48. package/src/__tests__/consolidation-engine.test.ts +0 -520
  49. package/src/__tests__/constructive-retrieval.test.ts +0 -509
  50. package/src/__tests__/context-collapse-renderer.test.ts +0 -214
  51. package/src/__tests__/context-composer-pins.test.ts +0 -64
  52. package/src/__tests__/context-composer-task-selection.test.ts +0 -24
  53. package/src/__tests__/context-reconstructor.test.ts +0 -65
  54. package/src/__tests__/context-renderer.test.ts +0 -91
  55. package/src/__tests__/continuity-eval.test.ts +0 -275
  56. package/src/__tests__/continuity-schema.test.ts +0 -97
  57. package/src/__tests__/contracts.test.ts +0 -262
  58. package/src/__tests__/conversation-importer.test.ts +0 -692
  59. package/src/__tests__/core-recall-eval.test.ts +0 -235
  60. package/src/__tests__/core-summary.test.ts +0 -109
  61. package/src/__tests__/data-checkup.test.ts +0 -304
  62. package/src/__tests__/decay-exemption.test.ts +0 -129
  63. package/src/__tests__/dedup-actions.test.ts +0 -58
  64. package/src/__tests__/dedup-guard.test.ts +0 -39
  65. package/src/__tests__/deterministic-id.test.ts +0 -39
  66. package/src/__tests__/distill-lock.test.ts +0 -305
  67. package/src/__tests__/distill-session-argshape.test.ts +0 -79
  68. package/src/__tests__/doctor.test.ts +0 -319
  69. package/src/__tests__/dream-pipeline.test.ts +0 -820
  70. package/src/__tests__/dream-scope-isolation.test.ts +0 -386
  71. package/src/__tests__/dream-write-batching.test.ts +0 -211
  72. package/src/__tests__/drill-down.test.ts +0 -108
  73. package/src/__tests__/embedder.test.ts +0 -122
  74. package/src/__tests__/emotion-decay.test.ts +0 -46
  75. package/src/__tests__/emotion-detector.test.ts +0 -56
  76. package/src/__tests__/emotion-phase1-integration.test.ts +0 -153
  77. package/src/__tests__/emotion-retrieval.test.ts +0 -56
  78. package/src/__tests__/entity-resolver.test.ts +0 -58
  79. package/src/__tests__/env-config-invariant.test.ts +0 -80
  80. package/src/__tests__/env-config.test.ts +0 -211
  81. package/src/__tests__/error-signature-boost.test.ts +0 -87
  82. package/src/__tests__/error-signature.test.ts +0 -95
  83. package/src/__tests__/error-taxonomy.test.ts +0 -129
  84. package/src/__tests__/ethics-layer.test.ts +0 -476
  85. package/src/__tests__/event-segmenter.test.ts +0 -153
  86. package/src/__tests__/feature-flags-integration.test.ts +0 -54
  87. package/src/__tests__/frequency-tracker.test.ts +0 -113
  88. package/src/__tests__/freshness.test.ts +0 -186
  89. package/src/__tests__/graph-export.test.ts +0 -489
  90. package/src/__tests__/hotness-blend.test.ts +0 -283
  91. package/src/__tests__/ingest-chunking.test.ts +0 -44
  92. package/src/__tests__/ingest-scope-prefix.test.ts +0 -69
  93. package/src/__tests__/ingest.test.ts +0 -360
  94. package/src/__tests__/interference-detector.test.ts +0 -203
  95. package/src/__tests__/kg-extractor.test.ts +0 -192
  96. package/src/__tests__/kg-store.test.ts +0 -300
  97. package/src/__tests__/llm-client-pivot.test.ts +0 -628
  98. package/src/__tests__/llm-consolidation.test.ts +0 -226
  99. package/src/__tests__/maintenance-locks.test.ts +0 -197
  100. package/src/__tests__/mcp-efficiency.test.ts +0 -288
  101. package/src/__tests__/mcp-related-scopes.test.ts +0 -151
  102. package/src/__tests__/memory-boundaries.test.ts +0 -282
  103. package/src/__tests__/memory-evolution-b2-b3-e1.test.ts +0 -362
  104. package/src/__tests__/memory-evolution.test.ts +0 -271
  105. package/src/__tests__/memory-health-rebalance.test.ts +0 -144
  106. package/src/__tests__/memory-lint.test.ts +0 -421
  107. package/src/__tests__/memory-output.test.ts +0 -224
  108. package/src/__tests__/memory-promotion.test.ts +0 -315
  109. package/src/__tests__/memory-utility.test.ts +0 -256
  110. package/src/__tests__/migrate-dedup-ids.test.ts +0 -65
  111. package/src/__tests__/multi-vector.test.ts +0 -242
  112. package/src/__tests__/narrative-architecture.test.ts +0 -456
  113. package/src/__tests__/noise-filter.test.ts +0 -205
  114. package/src/__tests__/noise-prototype-bank.test.ts +0 -280
  115. package/src/__tests__/obsidian-connector.test.ts +0 -363
  116. package/src/__tests__/omitted-reporting.test.ts +0 -98
  117. package/src/__tests__/openai-contract.test.ts +0 -513
  118. package/src/__tests__/pending-review.test.ts +0 -77
  119. package/src/__tests__/pii-redact.test.ts +0 -333
  120. package/src/__tests__/pivot-apply.test.ts +0 -373
  121. package/src/__tests__/pivot-distill-supervisor.test.ts +0 -457
  122. package/src/__tests__/pivot-distill.test.ts +0 -1178
  123. package/src/__tests__/post-retrieval-filter.test.ts +0 -178
  124. package/src/__tests__/ppr-traversal.test.ts +0 -256
  125. package/src/__tests__/prediction-engine.test.ts +0 -349
  126. package/src/__tests__/predictive-prospective-memory.test.ts +0 -416
  127. package/src/__tests__/preference-guard-smoke.test.ts +0 -211
  128. package/src/__tests__/preference-matcher.test.ts +0 -282
  129. package/src/__tests__/preference-slots.test.ts +0 -205
  130. package/src/__tests__/promotion-distinct-sources.test.ts +0 -162
  131. package/src/__tests__/prospective-memory.test.ts +0 -283
  132. package/src/__tests__/pull-from-macbook-sync-only.test.ts +0 -679
  133. package/src/__tests__/query-entity-detector.test.ts +0 -171
  134. package/src/__tests__/query-expander-alias.test.ts +0 -146
  135. package/src/__tests__/query-expander.test.ts +0 -34
  136. package/src/__tests__/recall-gate.test.ts +0 -266
  137. package/src/__tests__/recall-governor.test.ts +0 -227
  138. package/src/__tests__/recall-mode.test.ts +0 -64
  139. package/src/__tests__/resolve-db-path.test.ts +0 -130
  140. package/src/__tests__/resolve-tier.test.ts +0 -64
  141. package/src/__tests__/result-synthesizer.test.ts +0 -198
  142. package/src/__tests__/resume-context.test.ts +0 -4898
  143. package/src/__tests__/retrieval-eval.test.ts +0 -163
  144. package/src/__tests__/retrieval-trace.test.ts +0 -213
  145. package/src/__tests__/retrieve-audit-revision.test.ts +0 -255
  146. package/src/__tests__/retriever-boundary-weight.test.ts +0 -136
  147. package/src/__tests__/retriever-lazy-init-dispatch.test.ts +0 -123
  148. package/src/__tests__/retroactive-boost.test.ts +0 -223
  149. package/src/__tests__/salience-scoring.test.ts +0 -106
  150. package/src/__tests__/scope-inventory.test.ts +0 -200
  151. package/src/__tests__/scope-policy.test.ts +0 -69
  152. package/src/__tests__/scope-suggester.test.ts +0 -80
  153. package/src/__tests__/session-checkpoint.test.ts +0 -291
  154. package/src/__tests__/session-distiller.test.ts +0 -640
  155. package/src/__tests__/session-gc.test.ts +0 -215
  156. package/src/__tests__/skill-engine.test.ts +0 -552
  157. package/src/__tests__/skill-promotion.test.ts +0 -654
  158. package/src/__tests__/skill-verifier.test.ts +0 -122
  159. package/src/__tests__/source-heartbeat.test.ts +0 -254
  160. package/src/__tests__/stable-context-length-contract.test.ts +0 -62
  161. package/src/__tests__/store-patch-metadata.test.ts +0 -235
  162. package/src/__tests__/store-read-consistency.test.ts +0 -122
  163. package/src/__tests__/store-schema-migration.test.ts +0 -87
  164. package/src/__tests__/store-scope-audit.test.ts +0 -81
  165. package/src/__tests__/store-upsert.test.ts +0 -201
  166. package/src/__tests__/supersede-chain.test.ts +0 -154
  167. package/src/__tests__/synthesis-contract.test.ts +0 -252
  168. package/src/__tests__/synthesis-promotion.test.ts +0 -369
  169. package/src/__tests__/temporal-parser.test.ts +0 -241
  170. package/src/__tests__/temporal-validity.test.ts +0 -160
  171. package/src/__tests__/term-registry-terminal-roster.test.ts +0 -127
  172. package/src/__tests__/term-registry.test.ts +0 -121
  173. package/src/__tests__/tool-error-surfacing.test.ts +0 -98
  174. package/src/__tests__/tool-output-compressor.test.ts +0 -160
  175. package/src/__tests__/topic-tag.test.ts +0 -113
  176. package/src/__tests__/trusted-memory.test.ts +0 -867
  177. package/src/__tests__/ui-server-security.test.ts +0 -93
  178. package/src/__tests__/usage-tracker.test.ts +0 -287
  179. package/src/__tests__/utility-weight.test.ts +0 -57
  180. package/src/__tests__/verbatim-self-recall.test.ts +0 -279
  181. package/src/__tests__/version-manager.test.ts +0 -248
  182. package/src/__tests__/workflow-observation-skill-outcome.test.ts +0 -352
  183. package/src/__tests__/workflow-observation.test.ts +0 -433
  184. package/src/__tests__/write-verifier.test.ts +0 -122
package/src/env-config.ts CHANGED
@@ -146,6 +146,24 @@ export const synthesisModel = (): string | undefined => {
146
146
  return trimmed ? trimmed : undefined;
147
147
  };
148
148
 
149
+ /**
150
+ * Per-request timeout (ms) for the embedding client, overriding `embedding.timeoutMs`
151
+ * in config.json.
152
+ *
153
+ * Exists so the timeout can be set per deployment (the resident MCP server, a one-off
154
+ * CLI ingest) without editing the shared config file — the same reason
155
+ * `RECALLNEST_LAYER_ADMISSION` lives here.
156
+ *
157
+ * Unset / empty / non-finite / <= 0 → undefined, i.e. fall through to config.json and
158
+ * ultimately to the SDK default. **Not setting it anywhere keeps the old behavior.**
159
+ */
160
+ export const embeddingTimeoutMs = (): number | undefined => {
161
+ const raw = process.env.RECALLNEST_EMBEDDING_TIMEOUT_MS;
162
+ if (raw === undefined || raw.trim() === "") return undefined;
163
+ const n = Number(raw);
164
+ return Number.isFinite(n) && n > 0 ? n : undefined;
165
+ };
166
+
149
167
  // --- Raw env values (caller validates / clamps / falls back to config) ---
150
168
 
151
169
  export const recallModeRaw = (): string | undefined => process.env.RECALLNEST_RECALL_MODE;
package/src/ingest.ts CHANGED
@@ -60,6 +60,29 @@ interface ConversationTurn {
60
60
  text: string;
61
61
  timestamp: string;
62
62
  sessionId: string;
63
+ /**
64
+ * 本条记忆所在 session 里用户亲手贴的图片总数——注意是整个 session 的,
65
+ * 不是本轮的张数。工具产的图(截图 / 读图,实测占全部图片九成)不计。
66
+ *
67
+ * 粒度取 session 而不是轮次,是有意的(2026-08-27 定):图所在的那一轮
68
+ * 常常因为正文太短根本没进库,精确坐标挂不上去;而同 session 的别的轮次
69
+ * 进了库。打成 session 级的标,搜到那些轮次时才能顺藤摸到图。
70
+ *
71
+ * 代价是精度低:一个 session 里几十条记忆会带同一个标,图未必跟搜到的
72
+ * 这一条有关。这是粒度换覆盖,明知而为——判断值不值得看,回 Deja 按
73
+ * session 读原文,图本体不进库。
74
+ */
75
+ sessionImages?: number;
76
+ /**
77
+ * 本 session 里 AI 自己产的图片总数:工具截图、读进来的图片文件、模型生成的图。
78
+ * 实测它是用户贴图的三倍多(5812 vs 1629)。
79
+ *
80
+ * 跟 sessionImages 分开计,因为两者服务的检索意图不是一回事——人找「我发过的
81
+ * 那张截图」要的是前者;agent 回溯「我当时生成的示意图长什么样」「那个页面当时
82
+ * 渲染成什么样」要的是后者。混成一个数,两种意图都答不好;只留前者,则 agent
83
+ * 自己回溯时看不见任何线索。
84
+ */
85
+ sessionToolImages?: number;
63
86
  }
64
87
 
65
88
  // ============================================================================
@@ -649,13 +672,20 @@ export async function drainPendingQueue(
649
672
  const entryText = extractions[j].l1 || extractions[j].l0;
650
673
  const language = detectLang(entryText);
651
674
  const fts_text = tokenizeFts(entryText, language);
652
- await store.store({
675
+ // 2026-08-24 修:回填路径此前直接 store.store(),绕过 buildIngestedEntry,
676
+ // 导致条目缺 boundary/layer/tier(transcript 碎片不降权)。改走同一构造器;
677
+ // 入队时未保留原文件路径,file 用 "pending-queue" 诚实标记回填来源。
678
+ const entry = buildIngestedEntry({
679
+ source: batch[j].scope.split(":")[0],
680
+ scope: batch[j].scope,
653
681
  text: entryText,
654
682
  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 }),
683
+ extraction: extractions[j],
684
+ file: "pending-queue",
685
+ });
686
+ await store.store({
687
+ ...entry,
688
+ category: entry.category as any,
659
689
  language,
660
690
  fts_text,
661
691
  });
@@ -696,6 +726,10 @@ function buildIngestedEntry(params: {
696
726
  heading?: string;
697
727
  /** Tier 3.1: optional core summary (≤200 chars) for token-efficient context output */
698
728
  coreSummary?: string | null;
729
+ /** 本条记忆所在 session 里用户亲手贴的图片总数;无图时不传 */
730
+ sessionImages?: number;
731
+ /** 本条记忆所在 session 里 AI 自己产的图片总数(截图 / 读图 / 生图);无则不传 */
732
+ sessionToolImages?: number;
699
733
  }): {
700
734
  text: string;
701
735
  vector: number[];
@@ -739,6 +773,8 @@ function buildIngestedEntry(params: {
739
773
  l1_overview: params.extraction.l1,
740
774
  l2_content: params.text,
741
775
  ...(params.coreSummary ? { core_summary: params.coreSummary } : {}),
776
+ ...(params.sessionImages ? { sessionImages: params.sessionImages } : {}),
777
+ ...(params.sessionToolImages ? { sessionToolImages: params.sessionToolImages } : {}),
742
778
  tier,
743
779
  boundary: resolution.boundary,
744
780
  ...(narrative ? { narrative } : {}),
@@ -786,16 +822,72 @@ const MARKDOWN_CHUNK_CONFIG: ChunkerConfig = {
786
822
  // CC Transcript Parser
787
823
  // ============================================================================
788
824
 
789
- function parseCCTranscript(filePath: string): ConversationTurn[] {
825
+ /**
826
+ * 数一条 transcript 记录里所有的图片信号,**不预设它们是什么用途、在什么位置**。
827
+ *
828
+ * 这是有意的宽口径:AI 产图的形态一直在长(工具截图、读进来的图片文件、模型生图、
829
+ * 写公众号时让它配的插图……),任何"列举有哪几类"的写法都注定漏掉下一类。所以这里
830
+ * 只回答一个问题——「这是不是一张图」,然后由调用方用**补集**去定义 AI 产图:
831
+ * 整条记录里的图 减去 用户在消息里亲手贴的,剩下的全算,不问出身。
832
+ *
833
+ * 判据是「携带图片的块」的三种通用形态,跨 Anthropic / OpenAI / Kimi 都成立:
834
+ * - type 里含 image(image / input_image / image_url / image_generation_* …)
835
+ * - 有 image_url / imageUrl 字段
836
+ * - 有 media_type / mimeType 且以 image/ 开头
837
+ *
838
+ * ⚠️ 这是**信号计数不是精确张数**:一次生图可能同时留下 call 与 end 两条记录,
839
+ * 会被数成 2。这是刻意选的方向——宁可多算,不可漏报,因为它服务的问题是
840
+ * 「这附近有没有图」而不是「精确几张」。
841
+ */
842
+ function countImageSignals(node: unknown, depth = 0): number {
843
+ if (depth > 10 || node === null || typeof node !== "object") return 0;
844
+ if (Array.isArray(node)) {
845
+ let sum = 0;
846
+ for (const item of node) sum += countImageSignals(item, depth + 1);
847
+ return sum;
848
+ }
849
+
850
+ const obj = node as Record<string, unknown>;
851
+ const type = obj.type;
852
+ const mediaType = obj.media_type ?? obj.mimeType;
853
+ const isImageBlock =
854
+ (typeof type === "string" && type.includes("image")) ||
855
+ typeof obj.image_url === "string" ||
856
+ (obj.imageUrl !== null && typeof obj.imageUrl === "object") ||
857
+ (typeof mediaType === "string" && mediaType.startsWith("image/"));
858
+
859
+ // 命中即计一次并停止下钻:一个图片块内部不会再嵌另一张独立的图,继续递归
860
+ // 只会把 {type:"image", source:{media_type:"image/png"}} 这种数成两张。
861
+ if (isImageBlock) return 1;
862
+
863
+ let count = 0;
864
+ for (const value of Object.values(obj)) {
865
+ if (value !== null && typeof value === "object") {
866
+ count += countImageSignals(value, depth + 1);
867
+ }
868
+ }
869
+ return count;
870
+ }
871
+
872
+ export function parseCCTranscript(filePath: string): ConversationTurn[] {
790
873
  const turns: ConversationTurn[] = [];
791
874
  const content = readFileSync(filePath, "utf-8");
792
875
  const lines = content.split("\n").filter((l) => l.trim());
793
876
 
794
877
  let sessionId = "";
878
+ // 整个 session 里的图片计数,扫完全文后统一盖到每个 turn 上。两类分开数:
879
+ // 用户亲手贴的 vs AI 自己产的(工具截图 / 读图 / 生图)。
880
+ // 数得到是因为本函数本来就逐行读完整个文件,顺带累加不额外花钱。
881
+ let sessionImages = 0;
882
+ let sessionToolImages = 0;
795
883
 
796
884
  for (const line of lines) {
797
885
  try {
798
886
  const obj = JSON.parse(line);
887
+ // 这一行里所有的图片信号。用户亲手贴的那部分在下面单独精确认,
888
+ // 其余的一律按补集归入 AI 产图——不枚举它是截图、读图、生图还是配图。
889
+ const lineImageSignals = countImageSignals(obj);
890
+ let lineUserImages = 0;
799
891
 
800
892
  if (!sessionId && obj.sessionId) {
801
893
  sessionId = obj.sessionId;
@@ -804,20 +896,39 @@ function parseCCTranscript(filePath: string): ConversationTurn[] {
804
896
  if (obj.type === "user" && obj.message) {
805
897
  const msg = obj.message;
806
898
  let text = "";
899
+ let turnImages = 0;
807
900
  if (typeof msg.content === "string") {
808
901
  text = msg.content;
809
902
  } else if (Array.isArray(msg.content)) {
810
- // Extract text blocks, skip tool_use/tool_result/images
903
+ // 文本块照旧提取,tool_use / tool_result 仍然整块丢弃(架构决定,见 memory-boundaries)。
904
+ // 图片本体也不进库,只数一下:这里只认顶层的 image block,
905
+ // tool_result 里内嵌的不算——那是工具截图/读图,实测占全部图片九成。
811
906
  text = msg.content
812
907
  .filter((c: any) => c.type === "text")
813
908
  .map((c: any) => c.text || "")
814
909
  .join("\n");
910
+ turnImages = msg.content.filter((c: any) => c?.type === "image").length;
815
911
  }
816
-
817
- if (text.trim().length > 10) {
912
+ sessionImages += turnImages;
913
+ lineUserImages = turnImages;
914
+
915
+ // 只贴图不配字的那一轮,此前会被长度闸整条丢掉——实测占含图轮次的 12.5%,
916
+ // 且丢掉的常是「操作步骤在这里」+ 截图这类正文全在图里的高价值轮次
917
+ // (最极端的样本是 0 字配 7 张图)。给它一个占位正文,让它能跟下一条
918
+ // 助手回复合成 chunk——助手的回复通常描述了图里是什么,那就是定位这些图的文字。
919
+ //
920
+ // 有配文时正文一个字都不改:文字本身已是检索入口,往里塞标记只会稀释 embedding。
921
+ const trimmed = text.trim();
922
+ const body = trimmed.length > 0
923
+ ? trimmed
924
+ : turnImages > 0
925
+ ? `[图片×${turnImages}]`
926
+ : "";
927
+
928
+ if (body.length > 10 || turnImages > 0) {
818
929
  turns.push({
819
930
  role: "user",
820
- text: text.trim(),
931
+ text: body,
821
932
  timestamp: obj.timestamp || "",
822
933
  sessionId,
823
934
  });
@@ -846,11 +957,26 @@ function parseCCTranscript(filePath: string): ConversationTurn[] {
846
957
  });
847
958
  }
848
959
  }
960
+
961
+ // 补集:这一行里除用户亲手贴的之外的图,全算 AI 产的,不问出身。
962
+ // 用补集而不是列举,是因为 AI 产图的形态会一直长(生图、截图、读图、
963
+ // 写文章时的配图……),任何枚举都注定漏掉下一类。
964
+ sessionToolImages += Math.max(0, lineImageSignals - lineUserImages);
849
965
  } catch {
850
966
  // Skip malformed lines
851
967
  }
852
968
  }
853
969
 
970
+ // 整份 transcript 扫完才知道总数,统一盖到每个 turn 上——这条标记回答的是
971
+ // 「这条记忆所在的 session 里有没有图」,不是「这一轮有没有图」。图所在的那轮
972
+ // 常因正文太短进不了库,只有盖到同 session 的其他轮次上,才可能被搜到。
973
+ if (sessionImages > 0 || sessionToolImages > 0) {
974
+ for (const turn of turns) {
975
+ if (sessionImages > 0) turn.sessionImages = sessionImages;
976
+ if (sessionToolImages > 0) turn.sessionToolImages = sessionToolImages;
977
+ }
978
+ }
979
+
854
980
  return turns;
855
981
  }
856
982
 
@@ -872,12 +998,26 @@ export function groupTurnsIntoChunks(turns: ConversationTurn[]): Array<{
872
998
  text: string;
873
999
  timestamp: string;
874
1000
  sessionId: string;
1001
+ sessionImages?: number;
1002
+ sessionToolImages?: number;
875
1003
  }> {
876
1004
  const filtered = filterNoiseTurns(turns);
877
- const chunks: Array<{ text: string; timestamp: string; sessionId: string }> = [];
1005
+ const chunks: Array<{
1006
+ text: string;
1007
+ timestamp: string;
1008
+ sessionId: string;
1009
+ sessionImages?: number;
1010
+ sessionToolImages?: number;
1011
+ }> = [];
878
1012
 
879
1013
  for (let i = 0; i < filtered.length; i++) {
880
1014
  const turn = filtered[i];
1015
+ // 同一 session 切出来的每个 chunk 都带同一个标——它说的是「这个 session 有图」,
1016
+ // 跟这一片本身有没有图无关,这正是 session 粒度要的效果。
1017
+ const imgMark = {
1018
+ ...(turn.sessionImages ? { sessionImages: turn.sessionImages } : {}),
1019
+ ...(turn.sessionToolImages ? { sessionToolImages: turn.sessionToolImages } : {}),
1020
+ };
881
1021
 
882
1022
  // If this is a user turn followed by an assistant turn, merge them
883
1023
  if (turn.role === "user" && i + 1 < filtered.length && filtered[i + 1].role === "assistant") {
@@ -896,10 +1036,10 @@ export function groupTurnsIntoChunks(turns: ConversationTurn[]): Array<{
896
1036
  if (seg.text.length > CONVERSATION_CHUNK_CONFIG.maxChunkSize) {
897
1037
  const chunkResult = chunkDocument(seg.text, CONVERSATION_CHUNK_CONFIG);
898
1038
  for (const chunk of chunkResult.chunks) {
899
- chunks.push({ text: chunk, timestamp: turn.timestamp, sessionId: turn.sessionId });
1039
+ chunks.push({ text: chunk, timestamp: turn.timestamp, sessionId: turn.sessionId, ...imgMark });
900
1040
  }
901
1041
  } else {
902
- chunks.push({ text: seg.text, timestamp: turn.timestamp, sessionId: turn.sessionId });
1042
+ chunks.push({ text: seg.text, timestamp: turn.timestamp, sessionId: turn.sessionId, ...imgMark });
903
1043
  }
904
1044
  }
905
1045
  } else {
@@ -907,6 +1047,7 @@ export function groupTurnsIntoChunks(turns: ConversationTurn[]): Array<{
907
1047
  text: merged,
908
1048
  timestamp: turn.timestamp,
909
1049
  sessionId: turn.sessionId,
1050
+ ...imgMark,
910
1051
  });
911
1052
  }
912
1053
 
@@ -925,10 +1066,10 @@ export function groupTurnsIntoChunks(turns: ConversationTurn[]): Array<{
925
1066
  if (seg.text.length > CONVERSATION_CHUNK_CONFIG.maxChunkSize) {
926
1067
  const chunkResult = chunkDocument(seg.text, CONVERSATION_CHUNK_CONFIG);
927
1068
  for (const chunk of chunkResult.chunks) {
928
- chunks.push({ text: chunk, timestamp: turn.timestamp, sessionId: turn.sessionId });
1069
+ chunks.push({ text: chunk, timestamp: turn.timestamp, sessionId: turn.sessionId, ...imgMark });
929
1070
  }
930
1071
  } else {
931
- chunks.push({ text: seg.text, timestamp: turn.timestamp, sessionId: turn.sessionId });
1072
+ chunks.push({ text: seg.text, timestamp: turn.timestamp, sessionId: turn.sessionId, ...imgMark });
932
1073
  }
933
1074
  }
934
1075
  } else {
@@ -936,6 +1077,7 @@ export function groupTurnsIntoChunks(turns: ConversationTurn[]): Array<{
936
1077
  text,
937
1078
  timestamp: turn.timestamp,
938
1079
  sessionId: turn.sessionId,
1080
+ ...imgMark,
939
1081
  });
940
1082
  }
941
1083
  }
@@ -959,18 +1101,26 @@ export function groupTurnsIntoChunks(turns: ConversationTurn[]): Array<{
959
1101
  // Codex Session Parser
960
1102
  // ============================================================================
961
1103
 
962
- function parseCodexSession(filePath: string): ConversationTurn[] {
1104
+ export function parseCodexSession(filePath: string): ConversationTurn[] {
963
1105
  const turns: ConversationTurn[] = [];
964
1106
  const content = readFileSync(filePath, "utf-8");
965
1107
  const lines = content.split("\n").filter((l) => l.trim());
966
1108
 
967
1109
  let sessionId = "";
1110
+ // 同 parseCCTranscript:两类图片分开计数。
1111
+ // Codex 的图片字段是 input_image(OpenAI 格式),不是 Anthropic 的 image;
1112
+ // 用户贴的在 payload.content 里可以精确认;其余一律按补集归 AI 产图。
1113
+ let sessionImages = 0;
1114
+ let sessionToolImages = 0;
968
1115
 
969
1116
  for (const line of lines) {
970
1117
  try {
971
1118
  const obj = JSON.parse(line);
972
1119
  const payload = obj.payload;
973
1120
  const timestamp = obj.timestamp || "";
1121
+ // 同 parseCCTranscript:整行图片信号,减去用户贴的,剩下全归 AI 产
1122
+ const lineImageSignals = countImageSignals(obj);
1123
+ let lineUserImages = 0;
974
1124
 
975
1125
  if (obj.type === "session_meta" && payload?.id) {
976
1126
  sessionId = payload.id;
@@ -982,6 +1132,13 @@ function parseCodexSession(filePath: string): ConversationTurn[] {
982
1132
  const content = payload.content;
983
1133
 
984
1134
  if (Array.isArray(content)) {
1135
+ // 只精确认用户消息里的 input_image;Codex 自己生的图 / 读的图挂在
1136
+ // payload.output 等别处,由下面的补集统一接住,这里不去枚举它们。
1137
+ if (role === "user") {
1138
+ const own = content.filter((c: any) => c?.type === "input_image").length;
1139
+ sessionImages += own;
1140
+ lineUserImages += own;
1141
+ }
985
1142
  for (const c of content) {
986
1143
  if (c.type === "input_text" && c.text && c.text.length > 10) {
987
1144
  // Skip system/developer prompts (usually very long instructions)
@@ -1023,11 +1180,21 @@ function parseCodexSession(filePath: string): ConversationTurn[] {
1023
1180
  }
1024
1181
  }
1025
1182
  }
1183
+
1184
+ // 补集:除用户亲手贴的之外,这一行里的图全算 AI 产的,不问出身。
1185
+ sessionToolImages += Math.max(0, lineImageSignals - lineUserImages);
1026
1186
  } catch {
1027
1187
  // Skip malformed lines
1028
1188
  }
1029
1189
  }
1030
1190
 
1191
+ if (sessionImages > 0 || sessionToolImages > 0) {
1192
+ for (const turn of turns) {
1193
+ if (sessionImages > 0) turn.sessionImages = sessionImages;
1194
+ if (sessionToolImages > 0) turn.sessionToolImages = sessionToolImages;
1195
+ }
1196
+ }
1197
+
1031
1198
  return turns;
1032
1199
  }
1033
1200
 
@@ -1164,6 +1331,8 @@ export async function ingestCodexSessions(
1164
1331
  sessionId: chunk.sessionId,
1165
1332
  file: basename(filePath),
1166
1333
  coreSummary: coreSummaries[j],
1334
+ sessionImages: chunk.sessionImages,
1335
+ sessionToolImages: chunk.sessionToolImages,
1167
1336
  }));
1168
1337
  }
1169
1338
  }
@@ -1205,7 +1374,7 @@ export async function ingestCodexSessions(
1205
1374
  // context.append_loop_event content.part → 助手文本(part.type==="text";think 跳过)
1206
1375
  // 其余类型(llm.tools_snapshot / mcp.tools_discovered / config.update / llm.request /
1207
1376
  // usage.record / permission.* 等,约占 86% 体积)是运行事件,一律跳过。
1208
- function parseKimiSession(filePath: string): ConversationTurn[] {
1377
+ export function parseKimiSession(filePath: string): ConversationTurn[] {
1209
1378
  const turns: ConversationTurn[] = [];
1210
1379
  const content = readFileSync(filePath, "utf-8");
1211
1380
  const lines = content.split("\n").filter((l) => l.trim());
@@ -1215,6 +1384,11 @@ function parseKimiSession(filePath: string): ConversationTurn[] {
1215
1384
  const pathParts = filePath.split("/");
1216
1385
  const rawId = pathParts.length >= 4 ? pathParts[pathParts.length - 4] : basename(filePath, ".jsonl");
1217
1386
  const sessionId = rawId.replace(/^session_/, "");
1387
+ // 同 parseCCTranscript:两类图片分开计数。
1388
+ // Kimi 的图片字段是 image_url;用户贴的在 turn.prompt / context.append_message,
1389
+ // AI 产的在 context.append_loop_event 的 tool.result 里。
1390
+ let sessionImages = 0;
1391
+ let sessionToolImages = 0;
1218
1392
 
1219
1393
  const pushTurn = (role: "user" | "assistant", text: string, timestamp: string) => {
1220
1394
  const trimmed = text.trim();
@@ -1231,6 +1405,23 @@ function parseKimiSession(filePath: string): ConversationTurn[] {
1231
1405
  const obj = JSON.parse(line);
1232
1406
  const timestamp = typeof obj.time === "number" ? new Date(obj.time).toISOString() : "";
1233
1407
 
1408
+ // 图片统计在这里一次算完,不放进下面的分支——那些分支各自 continue,
1409
+ // 放进去会被跳过。用户贴的精确认,其余按补集归 AI 产,不枚举出身。
1410
+ {
1411
+ const lineImageSignals = countImageSignals(obj);
1412
+ let lineUserImages = 0;
1413
+ if (obj.type === "turn.prompt" && obj.origin?.kind === "user" && Array.isArray(obj.input)) {
1414
+ lineUserImages = obj.input.filter((c: any) => c?.type === "image_url").length;
1415
+ } else if (obj.type === "context.append_message") {
1416
+ const m = obj.message;
1417
+ if (m?.role === "user" && m?.origin?.kind === "user" && Array.isArray(m.content)) {
1418
+ lineUserImages = m.content.filter((c: any) => c?.type === "image_url").length;
1419
+ }
1420
+ }
1421
+ sessionImages += lineUserImages;
1422
+ sessionToolImages += Math.max(0, lineImageSignals - lineUserImages);
1423
+ }
1424
+
1234
1425
  if (obj.type === "turn.prompt") {
1235
1426
  if (obj.origin?.kind !== "user") continue;
1236
1427
  const parts = Array.isArray(obj.input) ? obj.input : [];
@@ -1271,6 +1462,13 @@ function parseKimiSession(filePath: string): ConversationTurn[] {
1271
1462
  }
1272
1463
  }
1273
1464
 
1465
+ if (sessionImages > 0 || sessionToolImages > 0) {
1466
+ for (const turn of turns) {
1467
+ if (sessionImages > 0) turn.sessionImages = sessionImages;
1468
+ if (sessionToolImages > 0) turn.sessionToolImages = sessionToolImages;
1469
+ }
1470
+ }
1471
+
1274
1472
  return turns;
1275
1473
  }
1276
1474
 
@@ -1434,6 +1632,8 @@ export async function ingestKimiSessions(
1434
1632
  sessionId: chunk.sessionId,
1435
1633
  file: basename(filePath),
1436
1634
  coreSummary: coreSummaries[j],
1635
+ sessionImages: chunk.sessionImages,
1636
+ sessionToolImages: chunk.sessionToolImages,
1437
1637
  }));
1438
1638
  }
1439
1639
  }
@@ -1758,6 +1958,12 @@ export async function ingestCCTranscripts(
1758
1958
  };
1759
1959
 
1760
1960
  const dir = expandHome(sourcePath);
1961
+ // session-digest.py 摘要调用留下的会话记录不 ingest(2026-08-24 审计:这类
1962
+ // transcript 是原始会话 8000 字符采样的副本,ingest 后 scope/metadata 指向
1963
+ // 摘要任务自身 sid——内容是真的、坐标是假的,已产生 1,950 条 evidence 污染)。
1964
+ if (dir.includes("session-digest")) {
1965
+ return result;
1966
+ }
1761
1967
  if (!existsSync(dir)) {
1762
1968
  result.errors.push(`Directory not found: ${dir}`);
1763
1969
  return result;
@@ -1879,6 +2085,8 @@ export async function ingestCCTranscripts(
1879
2085
  sessionId: chunk.sessionId,
1880
2086
  file,
1881
2087
  coreSummary: coreSummaries[j],
2088
+ sessionImages: chunk.sessionImages,
2089
+ sessionToolImages: chunk.sessionToolImages,
1882
2090
  }));
1883
2091
  }
1884
2092
 
@@ -123,6 +123,23 @@ export class KGExtractor {
123
123
  this.kgStore = config.kgStore;
124
124
  }
125
125
 
126
+ /**
127
+ * Drop this memory's contribution to the graph.
128
+ *
129
+ * Triple ids are `sha256(scope + subject + predicate + object)` — derived from
130
+ * content, not from the source memory. So when a memory's text is replaced, the
131
+ * new text yields brand-new ids and the edges from the OLD text stay behind
132
+ * forever, competing with the new ones during retrieval. Callers that overwrite
133
+ * a memory's content must purge first, then re-extract.
134
+ *
135
+ * Delegates to KGStore.deleteBySource, which is reference-counted: an edge that
136
+ * other memories still attest to only loses this one source (mention_count -1);
137
+ * the row itself is deleted only when the last piece of evidence goes away.
138
+ */
139
+ async purgeForMemory(memoryId: string): Promise<void> {
140
+ await this.kgStore.deleteBySource(memoryId);
141
+ }
142
+
126
143
  /**
127
144
  * Extract triples from text and persist to KG store.
128
145
  * Returns the number of triples stored.
@@ -84,6 +84,37 @@ function getTierLabel(result: RetrievalResult): string {
84
84
  return String(meta.tier || "peripheral");
85
85
  }
86
86
 
87
+ /**
88
+ * 所在 session 里有用户贴图时,多给一行提示。
89
+ *
90
+ * 图本体不在库里,正文里通常也没有对图的描述——这一行是唯一能让读到这条
91
+ * 记忆的人意识到「这附近还有图没看」的信号。判断值不值得看,回 Deja 按
92
+ * session 读原文。
93
+ *
94
+ * ⚠️ 这是 **session 级**标记,不是「本条记忆有图」:同一个 session 切出来
95
+ * 的每条记忆都带同一个数,图未必跟眼前这条有关。粒度换覆盖,明知而为——
96
+ * 图所在的那一轮常因正文太短根本没进库,只有盖到同 session 的其他轮次上
97
+ * 才可能被搜到。
98
+ *
99
+ * 无图时返回 null,调用方据此不输出这一行——不给无图记忆添噪音。
100
+ */
101
+ function formatSessionImages(result: RetrievalResult): string | null {
102
+ const meta = parseMetadata(result.entry);
103
+ const own = typeof meta.sessionImages === "number" ? meta.sessionImages : 0;
104
+ const tool = typeof meta.sessionToolImages === "number" ? meta.sessionToolImages : 0;
105
+ if (own <= 0 && tool <= 0) return null;
106
+
107
+ // 两类分开报,因为它们答的是不同的问题:找「我发过的那张截图」看前者,
108
+ // 回溯「我当时生成的图 / 那个页面当时的样子」看后者。
109
+ // 措辞用英文,与同一块里的 prov / fresh 行保持一致(这个输出面是英文的)。
110
+ const parts: string[] = [];
111
+ if (own > 0) parts.push(`${own} user-pasted`);
112
+ if (tool > 0) parts.push(`${tool} agent-made`);
113
+ const sess = typeof meta.sessionId === "string" ? meta.sessionId.slice(0, 8) : "?";
114
+
115
+ return `${parts.join(", ")} in this session · read sess=${sess}`;
116
+ }
117
+
87
118
  function getProvenanceSummary(result: RetrievalResult): string {
88
119
  const provenance = extractMemoryProvenance({
89
120
  scope: result.entry.scope,
@@ -385,6 +416,8 @@ export function formatFullResults(
385
416
  const row = buildSearchRow(i, context.query, results[i]);
386
417
  lines.push(`${row[0]} ${row[1]} ${row[2]} ${row[3]} ${row[4]} ${row[5]} ${row[6]} ${row[7]} ${row[8]} ${row[9]} | ${row[10]}`);
387
418
  lines.push(` prov : ${getProvenanceSummary(results[i])}`);
419
+ const imgs = formatSessionImages(results[i]);
420
+ if (imgs) lines.push(` imgs : ${imgs}`);
388
421
  // Full mode: append metadata details
389
422
  const meta = parseMetadata(results[i].entry);
390
423
  const evolution = typeof meta.evolutionStatus === "string" ? meta.evolutionStatus : "-";
@@ -435,6 +468,8 @@ export function formatSearchResults(
435
468
  const row = buildSearchRow(i, context.query, results[i]);
436
469
  lines.push(`${row[0]} ${row[1]} ${row[2]} ${row[3]} ${row[4]} ${row[5]} ${row[6]} ${row[7]} ${row[8]} ${row[9]} | ${row[10]}`);
437
470
  lines.push(` prov : ${getProvenanceSummary(results[i])}`);
471
+ const imgs = formatSessionImages(results[i]);
472
+ if (imgs) lines.push(` imgs : ${imgs}`);
438
473
  // Freshness: only shown for memories that declared dependsOn (opt-in, cheap check).
439
474
  const fresh = evaluateEntryFreshness(results[i].entry.metadata, freshnessCache);
440
475
  if (fresh) lines.push(` fresh: ${fresh}`);
@@ -36,9 +36,17 @@ import {
36
36
  import { assignDefaultConfidence } from "./confidence-tracker.js";
37
37
  import { detectLang, tokenizeFts } from "./language-hook.js";
38
38
  import { scanForPII, redactSecrets } from "./pii-detector.js";
39
+ import {
40
+ EvidenceCoordinateSchema,
41
+ EvidenceWindowSchema,
42
+ PIVOT_EVIDENCE_CONTRACT_VERSION,
43
+ PivotEvidenceProvenanceSchema,
44
+ evidenceWindowIssues,
45
+ } from "./pivot-evidence.js";
39
46
 
40
47
  export const PIVOT_APPLY_SOURCE = "session_distill";
41
48
  export const PIVOT_APPLY_CAPTURE = "pivot_apply_v1";
49
+ export const PIVOT_APPLY_EVIDENCE_CAPTURE = "pivot_apply_v2";
42
50
  export const PIVOT_SCOPE = "memory:pivot";
43
51
 
44
52
  const SHA256_HEX = /^[0-9a-f]{64}$/;
@@ -77,12 +85,46 @@ export const PivotCandidateInputSchema = z
77
85
  candidateHash: z.string().regex(SHA256_HEX, "candidateHash must be 64-char sha256 hex"),
78
86
  batchId: z.string().min(1).max(64),
79
87
  importance: z.number().min(0).max(1),
88
+ evidenceContractVersion: z.literal(PIVOT_EVIDENCE_CONTRACT_VERSION).optional(),
89
+ sourceFingerprint: z.string().regex(SHA256_HEX).optional(),
90
+ anchorCoordinate: EvidenceCoordinateSchema.optional(),
91
+ evidenceWindows: z.array(EvidenceWindowSchema).max(16).optional(),
80
92
  })
81
93
  .refine((c) => KIND_CATEGORY[c.kind] === c.proposedCategory, {
82
94
  message: "proposedCategory does not match kind (corpus-verified mapping violated)",
95
+ })
96
+ .superRefine((candidate, context) => {
97
+ const fields = [
98
+ candidate.evidenceContractVersion,
99
+ candidate.sourceFingerprint,
100
+ candidate.anchorCoordinate,
101
+ candidate.evidenceWindows,
102
+ ];
103
+ if (fields.every((field) => field === undefined)) return;
104
+ const provenance = PivotEvidenceProvenanceSchema.safeParse({
105
+ evidenceContractVersion: candidate.evidenceContractVersion,
106
+ sourceFingerprint: candidate.sourceFingerprint,
107
+ anchorCoordinate: candidate.anchorCoordinate,
108
+ evidenceWindows: candidate.evidenceWindows,
109
+ });
110
+ if (!provenance.success) {
111
+ context.addIssue({
112
+ code: z.ZodIssueCode.custom,
113
+ message: "evidence provenance must be complete and match contract version 1",
114
+ });
115
+ return;
116
+ }
117
+ for (const issue of evidenceWindowIssues(provenance.data, candidate.evidence.length, candidate.sessionId)) {
118
+ context.addIssue({ code: z.ZodIssueCode.custom, message: issue });
119
+ }
83
120
  });
84
121
  export type PivotCandidateInput = z.infer<typeof PivotCandidateInputSchema>;
85
122
 
123
+ export const PivotEvidenceCandidateInputSchema = PivotCandidateInputSchema.refine(
124
+ (candidate) => candidate.evidenceContractVersion === PIVOT_EVIDENCE_CONTRACT_VERSION,
125
+ { message: "evidence-coordinate apply requires contract version 1 provenance" },
126
+ );
127
+
86
128
  export type PivotApplyDisposition =
87
129
  | "stored"
88
130
  | "updated"
@@ -195,10 +237,18 @@ export async function persistPivotCandidate(
195
237
  const language = detectLang(text);
196
238
  const fts_text = tokenizeFts(text, language);
197
239
 
240
+ const evidenceProvenance = input.evidenceContractVersion === PIVOT_EVIDENCE_CONTRACT_VERSION
241
+ ? PivotEvidenceProvenanceSchema.parse({
242
+ evidenceContractVersion: input.evidenceContractVersion,
243
+ sourceFingerprint: input.sourceFingerprint,
244
+ anchorCoordinate: input.anchorCoordinate,
245
+ evidenceWindows: input.evidenceWindows,
246
+ })
247
+ : null;
198
248
  let metadata = buildStructuredMetadata({
199
249
  source: PIVOT_APPLY_SOURCE,
200
250
  tags: [...input.tags, "pivot-apply", `batch:${input.batchId}`],
201
- capture: PIVOT_APPLY_CAPTURE,
251
+ capture: evidenceProvenance ? PIVOT_APPLY_EVIDENCE_CAPTURE : PIVOT_APPLY_CAPTURE,
202
252
  category: input.proposedCategory,
203
253
  canonicalKey: input.canonicalKey,
204
254
  extra: {
@@ -210,6 +260,7 @@ export async function persistPivotCandidate(
210
260
  reportId: input.reportId,
211
261
  candidateHash: input.candidateHash,
212
262
  batchId: input.batchId,
263
+ ...(evidenceProvenance ?? {}),
213
264
  },
214
265
  },
215
266
  });