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/retriever.ts CHANGED
@@ -13,6 +13,7 @@ import { expandQueryWithAliases } from "./aliases.js";
13
13
  import { expandQuery } from "./query-expander.js";
14
14
  import { detectLang, tokenizeFts } from "./language-hook.js";
15
15
  import { type AccessTracker, computeHotnessScore, parseAccessMetadata } from "./access-tracker.js";
16
+ import { readUtility } from "./memory-utility.js";
16
17
  import { weibullDecay, resolveTier, isDecayExempt, adjustHalfLifeForEmotion, computeArousalBoost } from "./decay-engine.js";
17
18
  import { logInfo, logWarn } from "./stderr-log.js";
18
19
  import { extractBoundaryMetadata, isDurableMemoryScope, isTranscriptScope } from "./memory-boundaries.js";
@@ -39,7 +40,7 @@ import { isKGModeEnabled } from "./kg-extractor.js";
39
40
  import { detectEntities } from "./query-entity-detector.js";
40
41
  import { buildGraph, pprTraverse } from "./ppr-traversal.js";
41
42
  import { logInfo } from "./stderr-log.js";
42
- import type { AuditLogger } from "./audit-log.js";
43
+ import { AUDIT_RETRIEVED_CAP, type AuditLogger } from "./audit-log.js";
43
44
  import { parseEmotion, isEmotionScoringEnabled } from "./memory-schema.js";
44
45
  import { parseNarrative, isNarrativeModeEnabled } from "./narrative-schema.js";
45
46
  import type { EmotionMetadata } from "./memory-schema.js";
@@ -111,6 +112,19 @@ export interface RetrievalConfig {
111
112
  * 0 = disabled (default), 0.15 = recommended.
112
113
  */
113
114
  hotnessWeight: number;
115
+ /**
116
+ * Utility weight — 价值回溯(memory-utility.ts)算出的效用值对最终分的影响强度。
117
+ * Formula: score *= 1 + utilityWeight * utility,utility ∈ [-0.8, 0.8]。
118
+ * 与 hotness/frequency 的区别在信号来源:那两个数「被读过多少次」,
119
+ * 这个数「被用到的那些任务成没成」——前者是热度,后者是因果贡献。
120
+ * 没有 utility 的条目分数原样不动(bonus-only 冷启动,不惩罚没数据的)。
121
+ *
122
+ * 0 = disabled(默认)。**默认关是有意的影子期**:目前生产库里带 join key 的
123
+ * observation 从 0 开始积累,样本不足时开启只会放大噪声。
124
+ * 升级条件写在 open-loops「MemOS 价值回溯」条目里,不在这行注释里——
125
+ * 注释不会有人定期回来读,那个条目每窗口都会被推到眼前。
126
+ */
127
+ utilityWeight: number;
114
128
  /**
115
129
  * Per-category score thresholds. When a result's category matches a key,
116
130
  * that threshold is used instead of hardMinScore. Categories not listed
@@ -234,6 +248,15 @@ export const DEFAULT_RETRIEVAL_CONFIG: RetrievalConfig = {
234
248
  bm25Weight: 0.3,
235
249
  minScore: 0.3,
236
250
  rerank: "cross-encoder",
251
+ // ⚠️ 2026-08-23 查实:**这里超过 20 的部分从来没生效过**。两条候选腿最终都落到
252
+ // store.ts 的 `clampInt(limit, 1, 20)`(vectorSearch:588 / bm25Search:643),
253
+ // 路径是 retriever.ts:1109 算出 ceil(candidatePoolSize × multiplier) →
254
+ // runVectorSearch/runBM25Search(:1341/:1373) → store.*Search(limit) → 被压成 20。
255
+ // 那个 clamp 从初始提交 c10607a(2026-03-03)就在,而 07-16 调参(9374a4f)只改了本文件、
256
+ // 没碰 store.ts —— 所以这个 30 从落地当天就是空转。retrieval-profiles.ts 的 24/26/28 同理。
257
+ // **不要顺手去改那个 clamp**:动它等于扩候选池,而扩池方案已于 2026-08-22 跑 shadow 后被
258
+ // 实测否决(RecallNest memory:pivot `rejected-recall-widen-candidate-pool`,主犯认定订正为
259
+ // applyLayerAdmission 79.4% 而非 hard_min 13.5%)。要推翻它得先拿出那条否决记录要求的新证据。
237
260
  candidatePoolSize: 30,
238
261
  sourceDiversity: 1,
239
262
  recencyHalfLifeDays: 14,
@@ -254,6 +277,7 @@ export const DEFAULT_RETRIEVAL_CONFIG: RetrievalConfig = {
254
277
  hardMinScore: 0.35,
255
278
  timeDecayHalfLifeDays: 60,
256
279
  hotnessWeight: 0,
280
+ utilityWeight: 0,
257
281
  };
258
282
 
259
283
  /**
@@ -894,12 +918,28 @@ export class MemoryRetriever {
894
918
 
895
919
  // F-1: Audit log — record retrieve operation (non-blocking, silent on failure)
896
920
  try {
897
- this.auditLogger?.log({
898
- operation: "retrieve",
899
- scope: context.scopeFilter?.[0],
900
- actor: context.source || "manual",
901
- details: `query="${context.query.slice(0, 80)}" hits=${results.length}`,
902
- });
921
+ if (this.auditLogger) {
922
+ const retrieved = results.slice(0, AUDIT_RETRIEVED_CAP).map((r) => {
923
+ const evo = parseEvolution(r.entry.metadata, r.entry.timestamp);
924
+ const boundary = extractBoundaryMetadata(r.entry.metadata);
925
+ return {
926
+ id: r.entry.id.slice(0, 8),
927
+ rev: evo.version,
928
+ st: evo.status,
929
+ ...(boundary?.layer ? { ly: boundary.layer } : {}),
930
+ ...(boundary?.authority ? { au: boundary.authority } : {}),
931
+ };
932
+ });
933
+ this.auditLogger.log({
934
+ operation: "retrieve",
935
+ scope: context.scopeFilter?.[0],
936
+ actor: context.source || "manual",
937
+ details: `query="${context.query.slice(0, 80)}" hits=${results.length}`,
938
+ ...(retrieved.length > 0 ? { retrieved } : {}),
939
+ // Only when the list is short of the truth — a truncated list must look truncated.
940
+ ...(results.length > retrieved.length ? { retrievedTotal: results.length } : {}),
941
+ });
942
+ }
903
943
  } catch {
904
944
  // Audit must never block retrieval
905
945
  }
@@ -1212,8 +1252,14 @@ export class MemoryRetriever {
1212
1252
  const weighted = this.applyImportanceWeight(boosted);
1213
1253
  trace?.endStage(weighted.length, weighted.map(r => r.score));
1214
1254
 
1215
- trace?.startStage("confidence_weight", weighted.length);
1216
- const confidenceWeighted = this.applyConfidence(weighted);
1255
+ // 价值回溯:紧跟 importance 之后,因为两者回答的是同一类问题(这条本身有多值得),
1256
+ // 只是 importance 说的是「存的时候认为多重要」,utility 说的是「用起来结果怎么样」。
1257
+ trace?.startStage("utility_weight", weighted.length);
1258
+ const utilityWeighted = this.applyUtilityWeight(weighted);
1259
+ trace?.endStage(utilityWeighted.length, utilityWeighted.map(r => r.score));
1260
+
1261
+ trace?.startStage("confidence_weight", utilityWeighted.length);
1262
+ const confidenceWeighted = this.applyConfidence(utilityWeighted);
1217
1263
  trace?.endStage(confidenceWeighted.length, confidenceWeighted.map(r => r.score));
1218
1264
 
1219
1265
  let afterEmotionVector = confidenceWeighted;
@@ -2163,6 +2209,33 @@ export class MemoryRetriever {
2163
2209
  *
2164
2210
  * No-op when hotnessWeight is 0 or AccessTracker is absent.
2165
2211
  */
2212
+ /**
2213
+ * 价值回溯加权:被用在成功任务里的记忆上浮,被用在失败任务里的下沉。
2214
+ *
2215
+ * 这是 utility 唯一的消费点——它**只喂排序**,不进 tier、不进 decay 豁免、
2216
+ * 更不写回 importance(那一列是阈值语义,≥0.95 会触发永久免衰减,
2217
+ * 详见 memory-utility.ts 抬头的三条理由)。
2218
+ *
2219
+ * No-op when utilityWeight is 0(默认)或条目没有 utility。
2220
+ */
2221
+ private applyUtilityWeight(results: RetrievalResult[]): RetrievalResult[] {
2222
+ const raw = this.config.utilityWeight;
2223
+ const weight = Math.min(1, Math.max(0, Number.isFinite(raw) ? raw : 0));
2224
+ if (weight <= 0) return results;
2225
+
2226
+ const weighted = results.map((r) => {
2227
+ const utility = readUtility(r.entry.metadata);
2228
+ if (utility === null) return r; // 没数据 ≠ 中性差评,原样放行
2229
+ const factor = 1 + weight * utility;
2230
+ return {
2231
+ ...r,
2232
+ score: clamp01(r.score * factor, 0),
2233
+ };
2234
+ });
2235
+
2236
+ return weighted.sort((a, b) => b.score - a.score);
2237
+ }
2238
+
2166
2239
  private applyHotnessBlend(results: RetrievalResult[]): RetrievalResult[] {
2167
2240
  const raw = this.config.hotnessWeight;
2168
2241
  const alpha = Math.min(1, Math.max(0, Number.isFinite(raw) ? raw : 0));
@@ -27,6 +27,11 @@ export interface LocalMemoryConfig {
27
27
  dimensions?: number;
28
28
  taskQuery?: string;
29
29
  taskPassage?: string;
30
+ /**
31
+ * Per-request timeout (ms) for the embedding client. Omit to keep the SDK default
32
+ * (600s in openai v7); `RECALLNEST_EMBEDDING_TIMEOUT_MS` overrides this.
33
+ */
34
+ timeoutMs?: number;
30
35
  };
31
36
  llm?: {
32
37
  apiKey: string;
@@ -156,6 +161,10 @@ export function createComponents(config: LocalMemoryConfig, profileName?: string
156
161
  dimensions: config.embedding.dimensions,
157
162
  taskQuery: config.embedding.taskQuery,
158
163
  taskPassage: config.embedding.taskPassage,
164
+ // Env wins over config.json so a single deployment can bound its embed calls
165
+ // without editing the shared file. Both unset → no `timeout` reaches the SDK,
166
+ // which is the behavior every release through v3.0.0 had.
167
+ timeoutMs: envConfig.embeddingTimeoutMs() ?? config.embedding.timeoutMs,
159
168
  };
160
169
 
161
170
  const embedder = createEmbedder(embeddingConfig);
@@ -38,8 +38,29 @@ export function resolveSessionScope(sessionId?: string): string | undefined {
38
38
  return normalized ? `session:${normalized}` : undefined;
39
39
  }
40
40
 
41
- export function matchesScopeFilter(rowScope: string, scopeFilter?: string[]): boolean {
41
+ /**
42
+ * scope 过滤的匹配模式(2026-08-16 加,互审 C1/K3 双方独立命中)。
43
+ *
44
+ * - `family`(默认,历史行为):**由 scope 字符串里有没有冒号隐式决定** —— 含冒号走精确,
45
+ * 不含冒号走前缀。`search_memory` 等靠它做 `project` → `project:*` 的家族匹配。
46
+ * - `exact`:调用方明说"我给的就是一个具体 scope 名",一律精确匹配。
47
+ *
48
+ * 为什么要显式模式:隐式规则在调用方**拿真实 scope 名当参数**时会失效——真实 scope 名
49
+ * 里恰好没有冒号(`memory` / `cc` / `global`),于是被当成 family selector。
50
+ * 实测后果:weekly `dream --scope memory` 借前缀把 `memory:pivot`(手写提炼层)
51
+ * 一起卷进同一轮聚类,14 条 insight 的来源横跨两个 scope(2026-08-16 查库坐实)。
52
+ * 修法不是改默认语义(会伤 search_memory),是让 dream 这类"喂具体 scope 名"的
53
+ * 入口显式声明 exact。
54
+ */
55
+ export type ScopeMatchMode = "exact" | "family";
56
+
57
+ export function matchesScopeFilter(
58
+ rowScope: string,
59
+ scopeFilter?: string[],
60
+ mode: ScopeMatchMode = "family",
61
+ ): boolean {
42
62
  if (!scopeFilter || scopeFilter.length === 0) return true;
63
+ if (mode === "exact") return scopeFilter.some((scope) => rowScope === scope);
43
64
  return scopeFilter.some((scope) => scope.includes(":") ? rowScope === scope : rowScope.startsWith(scope));
44
65
  }
45
66
 
@@ -8,6 +8,14 @@ import {
8
8
  } from "./session-schema.js";
9
9
  import { buildIdempotentRecordId } from "./idempotency.js";
10
10
 
11
+ /** 当 summary 里每一句都被 repo-state 规则滤掉时的兜底文案(单一来源,勿再字面重复)。 */
12
+ export const FALLBACK_SUMMARY = "Checkpoint captured current task state without repo-state details.";
13
+
14
+ /** summary 是否已退化成兜底文案——即「本来写了东西,但整段被清洗空了」。 */
15
+ export function isFallbackSummary(summary: string | null | undefined): boolean {
16
+ return (summary ?? "").trim() === FALLBACK_SUMMARY;
17
+ }
18
+
11
19
  const REPO_STATE_PATTERN = /\bgit status\b|未提交|staged|uncommitted|已修改文件|modified files?|untracked|新增文件|dirty repo|dirty worktree/iu;
12
20
  const SENTENCE_SPLIT_PATTERN = /(?:\r?\n)+|(?<=[。!?!?;;])\s+/u;
13
21
  const CHECKPOINT_SANITIZABLE_FIELDS = ["summary", "task", "decisions", "openLoops", "nextActions"] as const;
@@ -51,7 +59,7 @@ function sanitizeCheckpointSummary(summary: string): SanitizedStringResult {
51
59
  .map(normalizeCheckpointText)
52
60
  .filter(Boolean);
53
61
  const kept = parts.filter((part) => !hasRepoStateText(part));
54
- const value = kept[0] ? kept.join(" ") : "Checkpoint captured current task state without repo-state details.";
62
+ const value = kept[0] ? kept.join(" ") : FALLBACK_SUMMARY;
55
63
  return {
56
64
  value,
57
65
  changed: kept.length !== parts.length,
@@ -146,13 +154,12 @@ export function buildSessionCheckpointResult(rawInput: unknown): SessionCheckpoi
146
154
  resolvedScope: resolveCheckpointScope(sanitizedInput),
147
155
  });
148
156
 
149
- const FALLBACK_SUMMARY = "Checkpoint captured current task state without repo-state details.";
150
- const isFallbackSummary = record.summary === FALLBACK_SUMMARY;
157
+ const summaryIsFallback = isFallbackSummary(record.summary);
151
158
  const hasStructuredContent =
152
159
  record.decisions.length > 0 ||
153
160
  record.openLoops.length > 0 ||
154
161
  record.nextActions.length > 0;
155
- const quality: CheckpointQuality = isFallbackSummary && !hasStructuredContent ? "minimal" : "rich";
162
+ const quality: CheckpointQuality = summaryIsFallback && !hasStructuredContent ? "minimal" : "rich";
156
163
 
157
164
  return {
158
165
  record,
@@ -1,5 +1,7 @@
1
1
  import type { ResumeContextResponse, SessionCheckpointRecord } from "./session-schema.js";
2
2
  import { cleanText } from "./context-composer-text.js";
3
+ import { formatIsoAgeLabel } from "./age-label.js";
4
+ import { isFallbackSummary } from "./session-engine.js";
3
5
 
4
6
  function listBlock(label: string, items: string[]): string[] {
5
7
  if (items.length === 0) return [];
@@ -11,7 +13,14 @@ function listBlock(label: string, items: string[]): string[] {
11
13
 
12
14
  export function formatCheckpointRecallSummary(record: SessionCheckpointRecord): string {
13
15
  const parts: string[] = [];
14
- const baseSummary = record.summary.trim();
16
+ const rawSummary = record.summary.trim();
17
+ // summary 整段被 repo-state 清洗规则滤空时会退化成兜底文案,headline 就只剩一句无信息的
18
+ // 套话。此时改用第一条实质内容顶上,并保留标记,让读者能分辨「本来就没写」和「被洗掉了」。
19
+ const substitute = isFallbackSummary(rawSummary)
20
+ ? [...record.decisions, ...record.nextActions, ...record.openLoops]
21
+ .find((item) => item.trim().length > 0) ?? ""
22
+ : "";
23
+ const baseSummary = substitute ? `[summary sanitized] ${substitute}` : rawSummary;
15
24
  if (baseSummary) {
16
25
  parts.push(baseSummary);
17
26
  }
@@ -32,7 +41,7 @@ export function formatCheckpointSaved(record: SessionCheckpointRecord): string {
32
41
  `Checkpoint ${record.checkpointId.slice(0, 8)}`,
33
42
  `Session: ${record.sessionId}`,
34
43
  `Scope: ${record.resolvedScope}`,
35
- `Updated: ${record.updatedAt}`,
44
+ `Updated: ${record.updatedAt} (${formatIsoAgeLabel(record.updatedAt)})`,
36
45
  `Summary: ${record.summary}`,
37
46
  ...listBlock("Decisions", record.decisions),
38
47
  ...listBlock("Open loops", record.openLoops),
@@ -48,7 +57,7 @@ export function formatCheckpointSummary(record: SessionCheckpointRecord | null):
48
57
  `Latest checkpoint`,
49
58
  `Session: ${record.sessionId}`,
50
59
  `Scope: ${record.resolvedScope}`,
51
- `Updated: ${record.updatedAt}`,
60
+ `Updated: ${record.updatedAt} (${formatIsoAgeLabel(record.updatedAt)})`,
52
61
  `Summary: ${record.summary}`,
53
62
  ];
54
63
  if (record.nextActions.length > 0) {
@@ -123,7 +132,7 @@ export function formatResumeContext(response: ResumeContextResponse): string {
123
132
  if (response.latestCheckpoint.resolvedScope) {
124
133
  lines.push(`Scope: ${response.latestCheckpoint.resolvedScope}`);
125
134
  }
126
- lines.push(`Updated: ${response.latestCheckpoint.updatedAt}`);
135
+ lines.push(`Updated: ${response.latestCheckpoint.updatedAt} (${formatIsoAgeLabel(response.latestCheckpoint.updatedAt)})`);
127
136
  lines.push(`Summary: ${response.latestCheckpoint.summary}`);
128
137
  }
129
138
 
@@ -4,12 +4,11 @@ import { basename, join, resolve } from "node:path";
4
4
  import { metaDir } from "./compat.js";
5
5
  import type { SessionCheckpointRecord } from "./session-schema.js";
6
6
  import { SessionCheckpointRecordSchema } from "./session-schema.js";
7
- import { normalizeCheckpointScope, type CheckpointQuality } from "./session-engine.js";
7
+ import { isFallbackSummary, normalizeCheckpointScope, type CheckpointQuality } from "./session-engine.js";
8
8
 
9
- const FALLBACK_SUMMARY = "Checkpoint captured current task state without repo-state details.";
10
9
 
11
10
  export function classifyCheckpointQuality(record: SessionCheckpointRecord): CheckpointQuality {
12
- const isFallback = record.summary === FALLBACK_SUMMARY;
11
+ const isFallback = isFallbackSummary(record.summary);
13
12
  const hasContent = record.decisions.length > 0 || record.openLoops.length > 0 || record.nextActions.length > 0;
14
13
  return isFallback && !hasContent ? "minimal" : "rich";
15
14
  }
@@ -30,6 +30,39 @@ export interface PromotionCandidate {
30
30
  verification?: VerifyResult;
31
31
  /** Read-signal evidence aggregated from source entries(Artel 读驱动升华:被真实读过的候选提权)。 */
32
32
  readEvidence?: { totalAccess: number; distinctReaders: number };
33
+ /** 这个候选覆盖了几个不同来源(episode)。1 = 同一次经历的重复,不是跨任务规律。 */
34
+ distinctSources?: number;
35
+ }
36
+
37
+ /**
38
+ * 被跨来源判据拒掉的簇 —— 「同一个坑踩了 N 次」。
39
+ *
40
+ * 这不是待晋升的规律,是反模式证据:`judgeDistinctSources` 判 `rejected` 时原本只
41
+ * `rejectedSingleSource++` 然后 continue,信号变成一个数字后消失(同文件下方注释
42
+ * 早就写着"走 failure-burst 反模式那条路",而那条路一直不存在)。
43
+ *
44
+ * 字段借自 MemTensor/memmy-agent 的 `Memory/src/service/evolution/negative-experience-pipeline.ts`
45
+ * (其 `NegativeExperienceSource` 枚举里字面就有 `tool_failure_burst`)。
46
+ * **有意只借它列的证据字段**:memmy 那份 schema 还有 `preference`(该怎么做)与
47
+ * `verification`(怎么确认没再犯),但那两格要的是判断不是统计 —— 扫描器编出来的
48
+ * 「该怎么做」比没有更糟。它们列在 `needsJudgement` 里等人或 LLM 填。
49
+ */
50
+ export interface AntiPatternCandidate {
51
+ /** 目前只有一条来路;留成联合类型是为了将来接别的信号(如工具连续失败)时不改调用方。 */
52
+ source: "single_source_burst";
53
+ /** 撞上它的场景 —— 取簇种子的首行,和 candidates 的 suggestedName 同款提取。 */
54
+ trigger: string;
55
+ /** 反模式描述 —— 簇内多条的共性摘要。 */
56
+ antiPattern: string;
57
+ /** 同一个坑重复了几次。 */
58
+ occurrences: number;
59
+ /** 实际覆盖几个不同来源(判 rejected 说明它 < minDistinctSources)。 */
60
+ distinctSources: number;
61
+ /** 0-1。同一来源里重复越多越强,但不因此就变成"规律"。 */
62
+ evidenceStrength: number;
63
+ sourceEntries: Array<{ id: string; text: string }>;
64
+ /** 扫描器有意不填的字段,等判断补上。写死成数组是为了让调用方能机械检查。 */
65
+ needsJudgement: ReadonlyArray<"preference" | "verification">;
33
66
  }
34
67
 
35
68
  export interface PromotionScanResult {
@@ -40,6 +73,16 @@ export interface PromotionScanResult {
40
73
  truncatedCases: number;
41
74
  /** 向量回填失败(库中无向量)而被跳过的条目数。 */
42
75
  vectorlessSkipped: number;
76
+ /** No-silent-caps 披露:条数够、但来源不够(同一个坑重复)而被拒的簇数。 */
77
+ rejectedSingleSource: number;
78
+ /** No-silent-caps 披露:拿不到来源指针、跨来源判据弃权放行的簇数。 */
79
+ abstainedUnknownSource: number;
80
+ /**
81
+ * P4 反模式出口:被判 rejected 的簇的**内容**,不只是计数。
82
+ * 注意与 abstainedUnknownSource 的区别 —— 弃权的簇不进这里:
83
+ * 不知道来源就不能指控它是「同一个坑踩三次」。
84
+ */
85
+ antiPatterns?: AntiPatternCandidate[];
43
86
  }
44
87
 
45
88
  export interface PromotionConfig {
@@ -58,6 +101,19 @@ export interface PromotionConfig {
58
101
  /** pattern_to_skill 通道最多探测的 structured patterns 数(每个一次 vectorSearch,
59
102
  * default: 1000,按 recency 优先)。 */
60
103
  maxPatternProbes: number;
104
+ /**
105
+ * 一个簇要来自几个**不同来源**才算规律(default: 2)。
106
+ *
107
+ * 借鉴 MemOS 的 L1→L2 判据:它数的是「几个不同 episode」,而这里原本数的是
108
+ * 「几条相似 case」——差的是维度不是数字。**同一个坑踩三次**在旧口径下是 3 条
109
+ * 相似 case、会被识别成一条规律;在 MemOS 那儿是 1 个 episode 重复,够不上 L2。
110
+ * 而反复撞同一个坑本该走 failure-burst 生成「Avoid」反模式,跟「这类子任务该怎么做」
111
+ * 是相反的两种东西,现在它们混在同一条通道里。
112
+ *
113
+ * 审计原文的要害:「两个完全不同的任务遇到同类子问题,才诱导出一条可迁移 L2」——
114
+ * 最小值是 2,但要害在「跨任务」不在「2」。设 1 = 关闭该判据(旧行为,对照用)。
115
+ */
116
+ minDistinctSources: number;
61
117
  }
62
118
 
63
119
  const DEFAULT_CONFIG: PromotionConfig = {
@@ -68,6 +124,7 @@ const DEFAULT_CONFIG: PromotionConfig = {
68
124
  maxBucketSize: 2_000,
69
125
  pageSize: 1_000,
70
126
  maxPatternProbes: 1_000,
127
+ minDistinctSources: 2,
71
128
  };
72
129
 
73
130
  // ---------------------------------------------------------------------------
@@ -268,6 +325,98 @@ export function greedyCluster(
268
325
  return clusters;
269
326
  }
270
327
 
328
+ // ---------------------------------------------------------------------------
329
+ // Source identity (MemOS-style episode counting)
330
+ // ---------------------------------------------------------------------------
331
+
332
+ /**
333
+ * 一条记录来自「哪一次」——跨来源去重的 key。
334
+ *
335
+ * 三级取值,越靠前越可靠:
336
+ * 1. `metadata.evidenceMemories` —— dream 合成产物指向的簇内真实源(synthesis-contract
337
+ * 2026-08-23 起写入,已验证互不重复且无悬空)。同一批源出来的两条派生物
338
+ * 签名相同 → 算一个来源,不是两个。
339
+ * 2. `tags` 里的 `src:<sessionId 前 8 位>` —— shared-behaviors §5.3 要求 pivot 写入必带。
340
+ * 2026-08-23 实测:`memory:pivot` 池 1699 条覆盖率 99.8%,其中 cases 260 条 100% 覆盖。
341
+ * ⚠️ 但全库只有 2.3%、`category=cases` 全库仅 1.2% —— **这个判据只在 pivot 池成立**,
342
+ * 别拿去当全库口径。
343
+ * 3. 兜底 `day:<scope>:<YYYY-MM-DD>` —— 没有来源指针时退到「同一天同一 scope 算一次」。
344
+ * 粗,但比把同一次经历数成 N 次强。
345
+ */
346
+ export function extractSourceKey(entry: Pick<MemoryEntry, "metadata" | "scope" | "timestamp">): string {
347
+ let meta: Record<string, unknown> = {};
348
+ try {
349
+ const parsed: unknown = JSON.parse(entry.metadata || "{}");
350
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
351
+ meta = parsed as Record<string, unknown>;
352
+ }
353
+ } catch { /* fall through to day bucket */ }
354
+
355
+ const evidence = meta.evidenceMemories;
356
+ if (Array.isArray(evidence) && evidence.length > 0) {
357
+ const ids = evidence.filter((v): v is string => typeof v === "string").sort();
358
+ if (ids.length > 0) return `ev:${ids.join("|")}`;
359
+ }
360
+
361
+ if (Array.isArray(meta.tags)) {
362
+ const src = meta.tags.find((t): t is string => typeof t === "string" && t.startsWith("src:"));
363
+ if (src) return src;
364
+ }
365
+
366
+ const day = Number.isFinite(entry.timestamp) && entry.timestamp > 0
367
+ ? new Date(entry.timestamp).toISOString().slice(0, 10)
368
+ : "unknown";
369
+ return `${UNKNOWN_SOURCE_PREFIX}${entry.scope}:${day}`;
370
+ }
371
+
372
+ /** 兜底 key 的前缀。带这个前缀 = 我们其实不知道它来自哪一次。 */
373
+ export const UNKNOWN_SOURCE_PREFIX = "day:";
374
+
375
+ /** 一组记录覆盖了几个不同来源。1 = 全部来自同一次经历。 */
376
+ export function countDistinctSources(
377
+ entries: ReadonlyArray<Pick<MemoryEntry, "metadata" | "scope" | "timestamp">>,
378
+ ): number {
379
+ return new Set(entries.map(extractSourceKey)).size;
380
+ }
381
+
382
+ export type SourceVerdict =
383
+ | { status: "ok"; distinctSources: number }
384
+ | { status: "rejected"; distinctSources: number }
385
+ /** 没有可信的来源指针 —— 不知道就不判,放行。 */
386
+ | { status: "abstained" };
387
+
388
+ /**
389
+ * 判一个簇是否来自足够多的不同经历。**带弃权路径**。
390
+ *
391
+ * 为什么要弃权而不是一律判:兜底 key 是「同 scope 同一天算一次」,而一天内解决三个
392
+ * 不同问题写三条 case 是完全正常的——那是 3 个 episode,兜底会把它们并成 1 个,
393
+ * 于是一条真规律被当成「同一个坑踩三次」拒掉。**误伤的代价比漏判高**:漏判只是
394
+ * 维持现状(旧行为),误伤是把本来能升华的东西堵死,而且堵得无声无息。
395
+ *
396
+ * 所以判据只在**拿得到真来源指针**时生效。实际效果就是它只在 `memory:pivot` 池成立
397
+ * (2026-08-23 实测该池 cases 100% 带 `src:`,而全库 cases 只有 1.2%)——
398
+ * 这恰好是本方案的适用范围,不是巧合:§5.3 要求 pivot 写入必带来源指针,
399
+ * 那条规矩本来就是为了让「回原文核对」成为可能,跨来源计数是它的另一个消费者。
400
+ */
401
+ export function judgeDistinctSources(
402
+ entries: ReadonlyArray<Pick<MemoryEntry, "metadata" | "scope" | "timestamp">>,
403
+ minDistinctSources: number,
404
+ ): SourceVerdict {
405
+ if (minDistinctSources <= 1) {
406
+ return { status: "ok", distinctSources: countDistinctSources(entries) };
407
+ }
408
+ const keys = entries.map(extractSourceKey);
409
+ const known = keys.filter((k) => !k.startsWith(UNKNOWN_SOURCE_PREFIX));
410
+ if (known.length === 0) return { status: "abstained" };
411
+
412
+ // 有真指针的按真指针数;没指针的每条各算一次未知来源——不能让「不知道」
413
+ // 白白顶上一个来源,也不能因为混了几条没指针的就整簇弃权。
414
+ const distinctSources = new Set(keys).size;
415
+ return distinctSources >= minDistinctSources
416
+ ? { status: "ok", distinctSources }
417
+ : { status: "rejected", distinctSources };
418
+ }
419
+
271
420
  // ---------------------------------------------------------------------------
272
421
  // Read evidence (Artel-style read-driven promotion)
273
422
  // ---------------------------------------------------------------------------
@@ -348,6 +497,9 @@ export async function scanForPromotions(
348
497
  scannedPatterns: patterns.length,
349
498
  truncatedCases: 0,
350
499
  vectorlessSkipped: 0,
500
+ rejectedSingleSource: 0,
501
+ abstainedUnknownSource: 0,
502
+ antiPatterns: [],
351
503
  };
352
504
 
353
505
  // 2. Cluster cases bucket-by-bucket (topicTag, untagged fallback)。
@@ -378,6 +530,29 @@ export async function scanForPromotions(
378
530
  if (cluster.members.length < cfg.minCaseOccurrences) continue;
379
531
  if (caseCandidates >= cfg.maxCandidates) break;
380
532
 
533
+ // 跨来源判据:条数够不代表是规律。同一次经历重复 N 条走的是 failure-burst
534
+ // 反模式那条路,不是「这类子任务该怎么做」这条。拿不到来源指针时弃权放行。
535
+ const sourceVerdict = judgeDistinctSources(cluster.members, cfg.minDistinctSources);
536
+ if (sourceVerdict.status === "rejected") {
537
+ result.rejectedSingleSource++;
538
+ // P4:不晋升,但也不让它无声消失 —— 同一个坑重复本身就是要记的东西。
539
+ // 仍然 continue,晋升判据分毫未动。
540
+ result.antiPatterns.push({
541
+ source: "single_source_burst",
542
+ trigger: extractName(cluster.seed.text),
543
+ antiPattern: summarizeCluster(cluster.members),
544
+ occurrences: cluster.members.length,
545
+ distinctSources: sourceVerdict.distinctSources,
546
+ // Bayesian smoothing,与 candidates 的 confidence 同款,饱和于 1。
547
+ evidenceStrength: cluster.members.length / (cluster.members.length + 2),
548
+ sourceEntries: cluster.members.map(m => ({ id: m.id, text: m.text })),
549
+ needsJudgement: ["preference", "verification"],
550
+ });
551
+ continue;
552
+ }
553
+ if (sourceVerdict.status === "abstained") result.abstainedUnknownSource++;
554
+ const distinctSources = sourceVerdict.status === "ok" ? sourceVerdict.distinctSources : undefined;
555
+
381
556
  // Compute average intra-cluster similarity for scoring
382
557
  const avgSim = computeAverageIntraClusterSimilarity(cluster.members);
383
558
 
@@ -397,6 +572,7 @@ export async function scanForPromotions(
397
572
  caseReadEvidence.distinctReaders,
398
573
  ),
399
574
  readEvidence: caseReadEvidence,
575
+ distinctSources,
400
576
  });
401
577
  }
402
578
  }
@@ -433,6 +609,16 @@ export async function scanForPromotions(
433
609
 
434
610
  if (similarCases.length < 2) continue;
435
611
 
612
+ // 同一判据也管 pattern→skill:MemOS 的 L2/L3→Skill 门就是「N 个独立 episode」。
613
+ // 支撑证据全来自同一次经历时,这条 skill 学的是那一次,不是那一类。
614
+ const patternVerdict = judgeDistinctSources(similarCases.map(h => h.entry), cfg.minDistinctSources);
615
+ if (patternVerdict.status === "rejected") {
616
+ result.rejectedSingleSource++;
617
+ continue;
618
+ }
619
+ if (patternVerdict.status === "abstained") result.abstainedUnknownSource++;
620
+ const patternDistinctSources = patternVerdict.status === "ok" ? patternVerdict.distinctSources : undefined;
621
+
436
622
  // A2: zero-LLM verifier — pattern 当 draft,supporting cases 当 evidence。
437
623
  // steps 传 [] 不是漏传:pattern.text 已含完整 Steps 块,resonance 用整段文本即可;
438
624
  // extractSteps(pattern.text) 仅用于下面的 suggestedImplementation 字段。
@@ -462,6 +648,7 @@ export async function scanForPromotions(
462
648
  ),
463
649
  verification,
464
650
  readEvidence: patternReadEvidence,
651
+ distinctSources: patternDistinctSources,
465
652
  });
466
653
  }
467
654
 
@@ -495,6 +682,33 @@ export function formatPromotionResult(result: PromotionScanResult): string {
495
682
  if (result.vectorlessSkipped > 0) {
496
683
  lines.push(`⚠️ ${result.vectorlessSkipped} entries skipped (no vector in store).`);
497
684
  }
685
+ if (result.abstainedUnknownSource > 0) {
686
+ lines.push(
687
+ `ℹ️ ${result.abstainedUnknownSource} cluster(s) 没有可信来源指针,跨来源判据已弃权放行`
688
+ + `(这些 scope 里的条目没带 src: tag,判了就是瞎判)。`,
689
+ );
690
+ }
691
+ if (result.rejectedSingleSource > 0) {
692
+ lines.push(
693
+ `⚠️ ${result.rejectedSingleSource} cluster(s) had enough entries but too few distinct sources`
694
+ + ` — 同一次经历的重复不算规律(不晋升,改走下面的反模式清单)。`,
695
+ );
696
+ }
697
+
698
+ // P4:被拒的簇在这里落地。只显示计数等于让信号消失在一个数字里 ——
699
+ // 消费者看到的是这段文本,不是 result 对象。
700
+ // `?.` 不是防御性编程的装饰:PromotionScanResult 是导出接口,旧调用方构造的对象
701
+ // 没有这个字段,运行时会是 undefined(TS 编译期拦不住外部构造)。
702
+ if (result.antiPatterns?.length) {
703
+ lines.push(`\n🔁 ${result.antiPatterns.length} anti-pattern(s) — 同一个坑重复出现,不是规律但要记:\n`);
704
+ for (const [i, ap] of result.antiPatterns.entries()) {
705
+ lines.push(`### A${i + 1}. ${ap.trigger}`);
706
+ lines.push(`重复 ${ap.occurrences} 次,来自 ${ap.distinctSources} 个来源(证据强度 ${(ap.evidenceStrength * 100).toFixed(0)}%)`);
707
+ lines.push(`共性: ${ap.antiPattern}`);
708
+ lines.push(`待补判断: ${ap.needsJudgement.join(" / ")} — 扫描器只出证据,「该怎么做」和「怎么确认没再犯」要人或 LLM 填。`);
709
+ lines.push("");
710
+ }
711
+ }
498
712
 
499
713
  if (result.candidates.length === 0) {
500
714
  lines.push("No promotion candidates found.");
@@ -507,7 +721,10 @@ export function formatPromotionResult(result: PromotionScanResult): string {
507
721
  lines.push(`### ${i + 1}. [${c.type}] ${c.suggestedName}`);
508
722
  lines.push(`Confidence: ${(c.confidence * 100).toFixed(1)}%`);
509
723
  lines.push(`Description: ${c.suggestedDescription}`);
510
- lines.push(`Sources: ${c.sourceEntries.length} entries`);
724
+ lines.push(
725
+ `Sources: ${c.sourceEntries.length} entries`
726
+ + (c.distinctSources !== undefined ? ` from ${c.distinctSources} distinct source(s)` : ""),
727
+ );
511
728
  if (c.readEvidence) {
512
729
  lines.push(`Read evidence: ${c.readEvidence.totalAccess} total reads, ${c.readEvidence.distinctReaders} distinct reader(s)`);
513
730
  }