recallnest 2.6.1 → 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 (207) hide show
  1. package/CHANGELOG.md +292 -0
  2. package/README.md +280 -195
  3. package/README_CN.md +266 -199
  4. package/config.json.example +8 -1
  5. package/docs/api-reference.md +1 -1
  6. package/integrations/README.md +1 -1
  7. package/integrations/agy/README.md +73 -0
  8. package/integrations/agy/setup.sh +75 -0
  9. package/integrations/setup-all.sh +1 -1
  10. package/package.json +7 -5
  11. package/src/access-tracker.ts +10 -1
  12. package/src/activity-counter.ts +69 -11
  13. package/src/age-label.ts +29 -0
  14. package/src/audit-log.ts +35 -0
  15. package/src/auto-gc.ts +97 -30
  16. package/src/capture-engine.ts +26 -4
  17. package/src/cli.ts +224 -10
  18. package/src/consolidation-engine.ts +317 -123
  19. package/src/context-composer-stable.ts +5 -1
  20. package/src/data-checkup.ts +17 -3
  21. package/src/dream-pipeline.ts +90 -12
  22. package/src/embedder.ts +44 -5
  23. package/src/env-config.ts +71 -0
  24. package/src/forget-engine.ts +8 -2
  25. package/src/gateway-server.ts +272 -0
  26. package/src/ingest.ts +248 -22
  27. package/src/kg-extractor.ts +17 -0
  28. package/src/llm-client.ts +86 -25
  29. package/src/mcp-server.ts +2 -1
  30. package/src/mcp-tools-advanced.ts +7 -2
  31. package/src/mcp-tools-core.ts +11 -1
  32. package/src/mcp-tools-governance.ts +45 -3
  33. package/src/memory-evolution.ts +16 -0
  34. package/src/memory-output.ts +47 -8
  35. package/src/memory-promotion.ts +280 -0
  36. package/src/memory-store-port.ts +32 -3
  37. package/src/memory-utility.ts +314 -0
  38. package/src/pivot-apply.ts +52 -1
  39. package/src/pivot-evidence.ts +128 -0
  40. package/src/ppr-traversal.ts +25 -1
  41. package/src/recall-ledger.ts +76 -0
  42. package/src/retriever.ts +82 -9
  43. package/src/runtime-config.ts +9 -0
  44. package/src/scope-policy.ts +22 -1
  45. package/src/session-engine.ts +11 -4
  46. package/src/session-output.ts +13 -4
  47. package/src/session-store.ts +2 -3
  48. package/src/skill-promotion.ts +218 -1
  49. package/src/store.ts +231 -71
  50. package/src/synthesis-contract.ts +286 -0
  51. package/src/version-manager.ts +83 -0
  52. package/src/workflow-observation-schema.ts +26 -0
  53. package/integrations/gemini-cli/README.md +0 -57
  54. package/integrations/gemini-cli/setup.sh +0 -64
  55. package/src/__tests__/access-tracker-novelty.test.ts +0 -304
  56. package/src/__tests__/activity-counter.test.ts +0 -125
  57. package/src/__tests__/adaptive-fidelity.test.ts +0 -102
  58. package/src/__tests__/admission-control.test.ts +0 -206
  59. package/src/__tests__/anchor-generator.test.ts +0 -67
  60. package/src/__tests__/api-server-security.test.ts +0 -102
  61. package/src/__tests__/auto-consolidation.test.ts +0 -168
  62. package/src/__tests__/auto-recall.test.ts +0 -131
  63. package/src/__tests__/babel-integration.test.ts +0 -144
  64. package/src/__tests__/batch-dedup.test.ts +0 -65
  65. package/src/__tests__/belief-history.test.ts +0 -220
  66. package/src/__tests__/canary-eval.test.ts +0 -114
  67. package/src/__tests__/capture-engine.test.ts +0 -1328
  68. package/src/__tests__/capture-heuristic.test.ts +0 -171
  69. package/src/__tests__/cascade-forget.test.ts +0 -210
  70. package/src/__tests__/category-min-scores.test.ts +0 -116
  71. package/src/__tests__/cc8-cc1-essential-context.test.ts +0 -298
  72. package/src/__tests__/circuit-breaker.test.ts +0 -99
  73. package/src/__tests__/cluster-consolidation.test.ts +0 -945
  74. package/src/__tests__/confidence-metadata.test.ts +0 -125
  75. package/src/__tests__/confidence-tracker.test.ts +0 -261
  76. package/src/__tests__/conflict-advisor.test.ts +0 -206
  77. package/src/__tests__/conflict-escalation.test.ts +0 -149
  78. package/src/__tests__/conflict-lifecycle.test.ts +0 -83
  79. package/src/__tests__/conflict-output.test.ts +0 -85
  80. package/src/__tests__/conflict-store.test.ts +0 -418
  81. package/src/__tests__/connector-v1.test.ts +0 -181
  82. package/src/__tests__/consolidation-engine.test.ts +0 -430
  83. package/src/__tests__/constructive-retrieval.test.ts +0 -509
  84. package/src/__tests__/context-collapse-renderer.test.ts +0 -214
  85. package/src/__tests__/context-composer-pins.test.ts +0 -64
  86. package/src/__tests__/context-composer-task-selection.test.ts +0 -24
  87. package/src/__tests__/context-reconstructor.test.ts +0 -65
  88. package/src/__tests__/context-renderer.test.ts +0 -91
  89. package/src/__tests__/continuity-eval.test.ts +0 -275
  90. package/src/__tests__/continuity-schema.test.ts +0 -97
  91. package/src/__tests__/contracts.test.ts +0 -262
  92. package/src/__tests__/conversation-importer.test.ts +0 -692
  93. package/src/__tests__/core-recall-eval.test.ts +0 -235
  94. package/src/__tests__/core-summary.test.ts +0 -109
  95. package/src/__tests__/data-checkup.test.ts +0 -276
  96. package/src/__tests__/decay-exemption.test.ts +0 -129
  97. package/src/__tests__/dedup-actions.test.ts +0 -58
  98. package/src/__tests__/dedup-guard.test.ts +0 -39
  99. package/src/__tests__/deterministic-id.test.ts +0 -39
  100. package/src/__tests__/distill-lock.test.ts +0 -305
  101. package/src/__tests__/distill-session-argshape.test.ts +0 -79
  102. package/src/__tests__/doctor.test.ts +0 -319
  103. package/src/__tests__/dream-pipeline.test.ts +0 -780
  104. package/src/__tests__/drill-down.test.ts +0 -108
  105. package/src/__tests__/embedder.test.ts +0 -122
  106. package/src/__tests__/emotion-decay.test.ts +0 -46
  107. package/src/__tests__/emotion-detector.test.ts +0 -56
  108. package/src/__tests__/emotion-phase1-integration.test.ts +0 -153
  109. package/src/__tests__/emotion-retrieval.test.ts +0 -56
  110. package/src/__tests__/entity-resolver.test.ts +0 -58
  111. package/src/__tests__/env-config.test.ts +0 -151
  112. package/src/__tests__/error-signature-boost.test.ts +0 -87
  113. package/src/__tests__/error-signature.test.ts +0 -95
  114. package/src/__tests__/error-taxonomy.test.ts +0 -129
  115. package/src/__tests__/ethics-layer.test.ts +0 -476
  116. package/src/__tests__/event-segmenter.test.ts +0 -153
  117. package/src/__tests__/feature-flags-integration.test.ts +0 -54
  118. package/src/__tests__/frequency-tracker.test.ts +0 -113
  119. package/src/__tests__/freshness.test.ts +0 -186
  120. package/src/__tests__/graph-export.test.ts +0 -489
  121. package/src/__tests__/hotness-blend.test.ts +0 -283
  122. package/src/__tests__/ingest-chunking.test.ts +0 -44
  123. package/src/__tests__/ingest.test.ts +0 -360
  124. package/src/__tests__/interference-detector.test.ts +0 -203
  125. package/src/__tests__/kg-extractor.test.ts +0 -192
  126. package/src/__tests__/kg-store.test.ts +0 -300
  127. package/src/__tests__/llm-client-pivot.test.ts +0 -628
  128. package/src/__tests__/llm-consolidation.test.ts +0 -226
  129. package/src/__tests__/maintenance-locks.test.ts +0 -189
  130. package/src/__tests__/mcp-efficiency.test.ts +0 -288
  131. package/src/__tests__/mcp-related-scopes.test.ts +0 -151
  132. package/src/__tests__/memory-boundaries.test.ts +0 -282
  133. package/src/__tests__/memory-evolution-b2-b3-e1.test.ts +0 -345
  134. package/src/__tests__/memory-evolution.test.ts +0 -271
  135. package/src/__tests__/memory-health-rebalance.test.ts +0 -144
  136. package/src/__tests__/memory-lint.test.ts +0 -421
  137. package/src/__tests__/memory-output.test.ts +0 -215
  138. package/src/__tests__/memory-promotion.test.ts +0 -315
  139. package/src/__tests__/migrate-dedup-ids.test.ts +0 -65
  140. package/src/__tests__/multi-vector.test.ts +0 -242
  141. package/src/__tests__/narrative-architecture.test.ts +0 -456
  142. package/src/__tests__/noise-filter.test.ts +0 -205
  143. package/src/__tests__/noise-prototype-bank.test.ts +0 -280
  144. package/src/__tests__/obsidian-connector.test.ts +0 -363
  145. package/src/__tests__/omitted-reporting.test.ts +0 -98
  146. package/src/__tests__/pending-review.test.ts +0 -77
  147. package/src/__tests__/pii-redact.test.ts +0 -333
  148. package/src/__tests__/pivot-apply.test.ts +0 -373
  149. package/src/__tests__/pivot-distill-supervisor.test.ts +0 -457
  150. package/src/__tests__/pivot-distill.test.ts +0 -1178
  151. package/src/__tests__/post-retrieval-filter.test.ts +0 -178
  152. package/src/__tests__/ppr-traversal.test.ts +0 -256
  153. package/src/__tests__/prediction-engine.test.ts +0 -349
  154. package/src/__tests__/predictive-prospective-memory.test.ts +0 -416
  155. package/src/__tests__/preference-guard-smoke.test.ts +0 -211
  156. package/src/__tests__/preference-matcher.test.ts +0 -282
  157. package/src/__tests__/preference-slots.test.ts +0 -205
  158. package/src/__tests__/prospective-memory.test.ts +0 -283
  159. package/src/__tests__/pull-from-macbook-sync-only.test.ts +0 -679
  160. package/src/__tests__/query-entity-detector.test.ts +0 -171
  161. package/src/__tests__/query-expander-alias.test.ts +0 -146
  162. package/src/__tests__/query-expander.test.ts +0 -34
  163. package/src/__tests__/recall-gate.test.ts +0 -266
  164. package/src/__tests__/recall-governor.test.ts +0 -227
  165. package/src/__tests__/recall-mode.test.ts +0 -64
  166. package/src/__tests__/resolve-db-path.test.ts +0 -130
  167. package/src/__tests__/resolve-tier.test.ts +0 -64
  168. package/src/__tests__/result-synthesizer.test.ts +0 -198
  169. package/src/__tests__/resume-context.test.ts +0 -4898
  170. package/src/__tests__/retrieval-eval.test.ts +0 -163
  171. package/src/__tests__/retrieval-trace.test.ts +0 -213
  172. package/src/__tests__/retriever-boundary-weight.test.ts +0 -136
  173. package/src/__tests__/retriever-lazy-init-dispatch.test.ts +0 -123
  174. package/src/__tests__/retroactive-boost.test.ts +0 -223
  175. package/src/__tests__/salience-scoring.test.ts +0 -106
  176. package/src/__tests__/scope-inventory.test.ts +0 -200
  177. package/src/__tests__/scope-policy.test.ts +0 -69
  178. package/src/__tests__/scope-suggester.test.ts +0 -80
  179. package/src/__tests__/session-checkpoint.test.ts +0 -291
  180. package/src/__tests__/session-distiller.test.ts +0 -640
  181. package/src/__tests__/session-gc.test.ts +0 -215
  182. package/src/__tests__/skill-engine.test.ts +0 -552
  183. package/src/__tests__/skill-promotion.test.ts +0 -654
  184. package/src/__tests__/skill-verifier.test.ts +0 -122
  185. package/src/__tests__/source-heartbeat.test.ts +0 -250
  186. package/src/__tests__/stable-context-length-contract.test.ts +0 -62
  187. package/src/__tests__/store-patch-metadata.test.ts +0 -205
  188. package/src/__tests__/store-read-consistency.test.ts +0 -122
  189. package/src/__tests__/store-schema-migration.test.ts +0 -87
  190. package/src/__tests__/store-scope-audit.test.ts +0 -81
  191. package/src/__tests__/store-upsert.test.ts +0 -201
  192. package/src/__tests__/supersede-chain.test.ts +0 -154
  193. package/src/__tests__/temporal-parser.test.ts +0 -241
  194. package/src/__tests__/temporal-validity.test.ts +0 -160
  195. package/src/__tests__/term-registry-terminal-roster.test.ts +0 -127
  196. package/src/__tests__/term-registry.test.ts +0 -121
  197. package/src/__tests__/tool-error-surfacing.test.ts +0 -98
  198. package/src/__tests__/tool-output-compressor.test.ts +0 -160
  199. package/src/__tests__/topic-tag.test.ts +0 -113
  200. package/src/__tests__/trusted-memory.test.ts +0 -839
  201. package/src/__tests__/ui-server-security.test.ts +0 -93
  202. package/src/__tests__/usage-tracker.test.ts +0 -287
  203. package/src/__tests__/version-manager.test.ts +0 -213
  204. package/src/__tests__/workflow-observation-skill-outcome.test.ts +0 -352
  205. package/src/__tests__/workflow-observation.test.ts +0 -433
  206. package/src/__tests__/write-verifier.test.ts +0 -122
  207. /package/integrations/{gemini-cli/gemini-md-snippet.md → agy/agy-md-snippet.md} +0 -0
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
  }
@@ -1731,10 +1931,24 @@ export async function ingestCCTranscripts(
1731
1931
  store: MemoryStore,
1732
1932
  embedder: Embedder,
1733
1933
  sourcePath: string,
1734
- options: { limit?: number; verbose?: boolean; noDedup?: boolean; llm?: LLMClient | null; recentHours?: number } = {},
1934
+ options: {
1935
+ limit?: number;
1936
+ verbose?: boolean;
1937
+ noDedup?: boolean;
1938
+ llm?: LLMClient | null;
1939
+ recentHours?: number;
1940
+ /**
1941
+ * scope 前缀与来源标签。默认 "cc" —— 这个默认值是承重的:
1942
+ * memory-boundaries.ts 的 TRANSCRIPT_SCOPE_PREFIXES 靠前缀把会话碎片降权到
1943
+ * evidence 层,前缀写错等于让某个端的 shell 输出被当成"用户说过的话"。
1944
+ * 新增来源时必须同时在那份清单里登记,别只在这里传个新字符串。
1945
+ */
1946
+ scopePrefix?: string;
1947
+ } = {},
1735
1948
  ): Promise<IngestResult> {
1949
+ const scopePrefix = options.scopePrefix ?? "cc";
1736
1950
  const result: IngestResult = {
1737
- source: "cc",
1951
+ source: scopePrefix,
1738
1952
  filesProcessed: 0,
1739
1953
  chunksIngested: 0,
1740
1954
  chunksSkipped: 0,
@@ -1744,6 +1958,12 @@ export async function ingestCCTranscripts(
1744
1958
  };
1745
1959
 
1746
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
+ }
1747
1967
  if (!existsSync(dir)) {
1748
1968
  result.errors.push(`Directory not found: ${dir}`);
1749
1969
  return result;
@@ -1838,7 +2058,7 @@ export async function ingestCCTranscripts(
1838
2058
  // Without LLM: queue raw chunks for later extraction instead of storing garbage
1839
2059
  if (!options.llm) {
1840
2060
  queueForLaterExtraction(
1841
- dedupedChunks.map(c => ({ text: c.text, scope: `cc:${c.sessionId.slice(0, 8)}` }))
2061
+ dedupedChunks.map(c => ({ text: c.text, scope: `${scopePrefix}:${c.sessionId.slice(0, 8)}` }))
1842
2062
  );
1843
2063
  result.chunksSkipped += dedupedTexts.length;
1844
2064
  continue;
@@ -1853,14 +2073,20 @@ export async function ingestCCTranscripts(
1853
2073
  const chunk = dedupedChunks[j];
1854
2074
  const ext = extractions[j];
1855
2075
  toStore.push(buildIngestedEntry({
1856
- source: "cc",
1857
- scope: `cc:${chunk.sessionId.slice(0, 8)}`,
2076
+ // 必须跟 scope 前缀一致:memory-boundaries 有两道闸,isTranscriptScope 看 scope、
2077
+ // isTranscriptSource 看这个字段。写死 "cc" 时降权仍然生效(cc 也在清单里),
2078
+ // 所以错了不会报错、只会让「按来源过滤」静默失真——2026-08-20 接 minis 源时漏改这处,
2079
+ // 库里出现 10 条 scope=minis: 而 source=cc 的记录。
2080
+ source: scopePrefix,
2081
+ scope: `${scopePrefix}:${chunk.sessionId.slice(0, 8)}`,
1858
2082
  text: dedupedTexts[j],
1859
2083
  vector: dedupedVectors[j],
1860
2084
  extraction: ext,
1861
2085
  sessionId: chunk.sessionId,
1862
2086
  file,
1863
2087
  coreSummary: coreSummaries[j],
2088
+ sessionImages: chunk.sessionImages,
2089
+ sessionToolImages: chunk.sessionToolImages,
1864
2090
  }));
1865
2091
  }
1866
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.
package/src/llm-client.ts CHANGED
@@ -16,6 +16,19 @@
16
16
  import OpenAI from "openai";
17
17
  import { logInfo } from "./stderr-log.js";
18
18
  import { isEmotionScoringEnabled } from "./memory-schema.js";
19
+ import { synthesisModel } from "./env-config.js";
20
+ import {
21
+ CLUSTER_INSIGHT_PROMPT,
22
+ CLUSTER_PATTERN_PROMPT,
23
+ allocateClusterBudget,
24
+ numberClusterTexts,
25
+ parseSynthesisJson,
26
+ validateSynthesis,
27
+ type SynthesisVerdict,
28
+ } from "./synthesis-contract.js";
29
+
30
+ /** dream 合成单次调用的超时。离线批处理,宽松换稳定,见 `runSynthesis` 处的注释。 */
31
+ const SYNTHESIS_TIMEOUT_MS = 60_000;
19
32
 
20
33
  // ============================================================================
21
34
  // LME-9: Circuit Breaker — LLM 降级策略
@@ -691,31 +704,64 @@ export class LLMClient {
691
704
  }
692
705
  }
693
706
 
707
+ // --------------------------------------------------------------------------
708
+ // dream 合成(契约见 synthesis-contract.ts)
709
+ //
710
+ // 这两个方法一起取代了原来的「`generateL0` 当 insight 用 + `extractPattern` 找性格
711
+ // 模式」。三点结构性差别,不是提示词微调:
712
+ // 1. 有弃权路径 —— 炼不出结论时返回 `abstained`,不再硬凑一句进库;
713
+ // 2. 强制 evidence 编号 —— 说不出出处的结论不写库;
714
+ // 3. 输入按成员等额分配预算 —— 不再让簇的前 2,000 字吃光整个上下文。
715
+ // 三者都在 `SynthesisVerdict` 里有形状,调用方据此分开计数,不再退化成一个 null。
716
+ // --------------------------------------------------------------------------
717
+
694
718
  /**
695
- * HP-5: Extract cross-memory pattern from a cluster of related memories.
696
- * Asks LLM whether the cluster reveals an implicit recurring theme,
697
- * preference, or behavior not stated in any single memory.
698
- *
699
- * @returns Pattern text if found, null if no pattern or LLM failure.
719
+ * 从一簇相关记忆里提炼一条**可复用结论**(规则/因果/决策/踩坑/判断转折)。
720
+ * 提炼不出来返回 `abstained` —— 这条路径是新的,老 `generateL0` 只会在 LLM 失败时
721
+ * 不产出,「这簇不值得留」从来没有表达方式。
700
722
  */
701
- async extractPattern(clusterTexts: string[]): Promise<string | null> {
702
- if (clusterTexts.length < 3) return null;
703
-
704
- const numbered = clusterTexts.map((t, i) => `[${i + 1}] ${t}`).join("\n");
705
- const result = await this.chatJson<{ hasPattern: boolean; pattern?: string }>(
706
- "你是记忆模式发现助手。分析以下一组相关记忆,判断它们是否暗示了某个跨条目的隐含模式——" +
707
- "例如反复出现的偏好、行为倾向、价值观或循环主题。\n\n" +
708
- "规则:\n" +
709
- "- 只发现真正跨条目的模式,而非单条记忆已经明确表达的内容\n" +
710
- "- 模式必须有至少 2 条记忆作为证据支撑\n" +
711
- "- 如果没有发现有意义的模式,返回 hasPattern: false\n" +
712
- "- 模式描述不超过 150 字,简洁且可操作\n" +
713
- '- 输出 JSON:{"hasPattern": true, "pattern": "描述..."} 或 {"hasPattern": false}',
714
- `记忆条目(共${clusterTexts.length}条):\n${numbered}`,
715
- );
723
+ async synthesizeClusterInsight(clusterTexts: string[]): Promise<SynthesisVerdict> {
724
+ return this.runSynthesis(clusterTexts, CLUSTER_INSIGHT_PROMPT, "conclusion", 1);
725
+ }
726
+
727
+ /**
728
+ * 从一簇相关记忆里提炼**跨条目才成立**的结论。
729
+ * evidence ≥2 是定义性要求:只有一条支撑的东西按定义不是跨条目模式。
730
+ */
731
+ async synthesizeClusterPattern(clusterTexts: string[]): Promise<SynthesisVerdict> {
732
+ if (clusterTexts.length < 3) return { status: "abstained" };
733
+ return this.runSynthesis(clusterTexts, CLUSTER_PATTERN_PROMPT, "pattern", 2);
734
+ }
735
+
736
+ private async runSynthesis(
737
+ clusterTexts: string[],
738
+ system: string,
739
+ textField: string,
740
+ minEvidence: number,
741
+ ): Promise<SynthesisVerdict> {
742
+ if (clusterTexts.length === 0) return { status: "abstained" };
743
+
744
+ const budgeted = allocateClusterBudget(clusterTexts);
745
+ const user = `记忆条目(共 ${clusterTexts.length} 条):\n${numberClusterTexts(budgeted)}`;
746
+ // max_tokens 放宽到 400:默认的 500 是给整段回复的,而这里只要一个小 JSON;
747
+ // 给足余量是为了避免 evidence 数组被截断——截断的 JSON 会整条判 unparsable,
748
+ // 一条本可用的结论就这么丢了。
749
+ // max_tokens 沿用默认 500,不另设:2026-08-23 实测 60 次合成调用 finish_reason
750
+ // 全为 stop、completion_tokens 最大仅 132,输出侧根本够不到上限。
751
+ const raw = await this.chatRaw(system, user, {
752
+ model: synthesisModel(),
753
+ // 默认 15s 是给 2,000 字输入定的。等额预算后单次输入可达 12,000 字,更强的
754
+ // 合成模型也更慢;沿用 15s 会把「模型正在正常工作」误判成失败,而 dream 是
755
+ // 离线批处理,等一会儿不影响任何人。
756
+ timeoutMs: SYNTHESIS_TIMEOUT_MS,
757
+ });
716
758
 
717
- if (!result || !result.hasPattern || !result.pattern) return null;
718
- return result.pattern;
759
+ return validateSynthesis({
760
+ parsed: parseSynthesisJson(raw),
761
+ textField,
762
+ memberCount: clusterTexts.length,
763
+ minEvidence,
764
+ });
719
765
  }
720
766
 
721
767
  /**
@@ -843,24 +889,39 @@ export class LLMClient {
843
889
  // --------------------------------------------------------------------------
844
890
 
845
891
  private async chat(system: string, user: string): Promise<string | null> {
892
+ return this.chatRaw(system, user);
893
+ }
894
+
895
+ /**
896
+ * `chat()` 的可调版:允许按调用点覆盖模型与 token 上限,其余行为(熔断、超时、
897
+ * 温度、异常语义)完全一致。
898
+ *
899
+ * 加这一层而不是给 `chat()` 加参数,是为了让「默认调用」的形状一个字不变 ——
900
+ * 现有十来个调用点不该因为合成这一处的需要而被动改签名。
901
+ */
902
+ private async chatRaw(
903
+ system: string,
904
+ user: string,
905
+ opts?: { model?: string; maxTokens?: number; timeoutMs?: number },
906
+ ): Promise<string | null> {
846
907
  // LME-9: Circuit breaker — short-circuit when LLM is down
847
908
  if (!this.breaker.canAttempt()) {
848
909
  return null;
849
910
  }
850
911
 
851
912
  const controller = new AbortController();
852
- const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
913
+ const timeout = setTimeout(() => controller.abort(), opts?.timeoutMs ?? this.timeoutMs);
853
914
 
854
915
  try {
855
916
  const response = await this.client.chat.completions.create(
856
917
  {
857
- model: this.model,
918
+ model: opts?.model || this.model,
858
919
  messages: [
859
920
  { role: "system", content: system },
860
921
  { role: "user", content: user },
861
922
  ],
862
923
  temperature: this.temperature,
863
- max_tokens: 500,
924
+ max_tokens: opts?.maxTokens ?? 500,
864
925
  },
865
926
  { signal: controller.signal },
866
927
  );