recallnest 2.3.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (258) hide show
  1. package/CHANGELOG.md +187 -0
  2. package/README.md +49 -38
  3. package/README_CN.md +46 -31
  4. package/assets/ui/app.js +1031 -0
  5. package/assets/ui/index.html +224 -0
  6. package/assets/ui/styles.css +781 -0
  7. package/config.json.example +4 -1
  8. package/docs/api-reference.md +1291 -0
  9. package/docs/architecture.md +168 -0
  10. package/docs/connector-spec.md +126 -0
  11. package/eval/cases-canary.json +58 -0
  12. package/eval/cases-scope-robustness.json +278 -0
  13. package/eval/cases.json +117 -20
  14. package/package.json +25 -4
  15. package/src/__tests__/access-tracker-novelty.test.ts +304 -0
  16. package/src/__tests__/activity-counter.test.ts +125 -0
  17. package/src/__tests__/adaptive-fidelity.test.ts +102 -0
  18. package/src/__tests__/admission-control.test.ts +206 -0
  19. package/src/__tests__/anchor-generator.test.ts +67 -0
  20. package/src/__tests__/api-server-security.test.ts +102 -0
  21. package/src/__tests__/auto-consolidation.test.ts +168 -0
  22. package/src/__tests__/auto-recall.test.ts +131 -0
  23. package/src/__tests__/babel-integration.test.ts +144 -0
  24. package/src/__tests__/batch-dedup.test.ts +65 -0
  25. package/src/__tests__/belief-history.test.ts +220 -0
  26. package/src/__tests__/canary-eval.test.ts +114 -0
  27. package/src/__tests__/capture-engine.test.ts +1328 -0
  28. package/src/__tests__/capture-heuristic.test.ts +171 -0
  29. package/src/__tests__/cascade-forget.test.ts +210 -0
  30. package/src/__tests__/category-min-scores.test.ts +116 -0
  31. package/src/__tests__/cc8-cc1-essential-context.test.ts +298 -0
  32. package/src/__tests__/circuit-breaker.test.ts +99 -0
  33. package/src/__tests__/cluster-consolidation.test.ts +945 -0
  34. package/src/__tests__/confidence-metadata.test.ts +125 -0
  35. package/src/__tests__/confidence-tracker.test.ts +261 -0
  36. package/src/__tests__/conflict-advisor.test.ts +206 -0
  37. package/src/__tests__/conflict-escalation.test.ts +149 -0
  38. package/src/__tests__/conflict-lifecycle.test.ts +83 -0
  39. package/src/__tests__/conflict-output.test.ts +85 -0
  40. package/src/__tests__/conflict-store.test.ts +418 -0
  41. package/src/__tests__/connector-v1.test.ts +181 -0
  42. package/src/__tests__/consolidation-engine.test.ts +430 -0
  43. package/src/__tests__/constructive-retrieval.test.ts +509 -0
  44. package/src/__tests__/context-collapse-renderer.test.ts +214 -0
  45. package/src/__tests__/context-composer-pins.test.ts +64 -0
  46. package/src/__tests__/context-composer-task-selection.test.ts +24 -0
  47. package/src/__tests__/context-reconstructor.test.ts +65 -0
  48. package/src/__tests__/context-renderer.test.ts +91 -0
  49. package/src/__tests__/continuity-eval.test.ts +275 -0
  50. package/src/__tests__/continuity-schema.test.ts +97 -0
  51. package/src/__tests__/contracts.test.ts +262 -0
  52. package/src/__tests__/conversation-importer.test.ts +692 -0
  53. package/src/__tests__/core-recall-eval.test.ts +235 -0
  54. package/src/__tests__/core-summary.test.ts +109 -0
  55. package/src/__tests__/data-checkup.test.ts +276 -0
  56. package/src/__tests__/decay-exemption.test.ts +129 -0
  57. package/src/__tests__/dedup-actions.test.ts +58 -0
  58. package/src/__tests__/dedup-guard.test.ts +39 -0
  59. package/src/__tests__/deterministic-id.test.ts +39 -0
  60. package/src/__tests__/distill-lock.test.ts +305 -0
  61. package/src/__tests__/distill-session-argshape.test.ts +79 -0
  62. package/src/__tests__/doctor.test.ts +319 -0
  63. package/src/__tests__/dream-pipeline.test.ts +780 -0
  64. package/src/__tests__/drill-down.test.ts +108 -0
  65. package/src/__tests__/embedder.test.ts +122 -0
  66. package/src/__tests__/emotion-decay.test.ts +46 -0
  67. package/src/__tests__/emotion-detector.test.ts +56 -0
  68. package/src/__tests__/emotion-phase1-integration.test.ts +153 -0
  69. package/src/__tests__/emotion-retrieval.test.ts +56 -0
  70. package/src/__tests__/entity-resolver.test.ts +58 -0
  71. package/src/__tests__/env-config.test.ts +151 -0
  72. package/src/__tests__/error-signature-boost.test.ts +87 -0
  73. package/src/__tests__/error-signature.test.ts +95 -0
  74. package/src/__tests__/error-taxonomy.test.ts +129 -0
  75. package/src/__tests__/ethics-layer.test.ts +476 -0
  76. package/src/__tests__/event-segmenter.test.ts +153 -0
  77. package/src/__tests__/feature-flags-integration.test.ts +54 -0
  78. package/src/__tests__/frequency-tracker.test.ts +113 -0
  79. package/src/__tests__/freshness.test.ts +186 -0
  80. package/src/__tests__/graph-export.test.ts +489 -0
  81. package/src/__tests__/hotness-blend.test.ts +283 -0
  82. package/src/__tests__/ingest-chunking.test.ts +44 -0
  83. package/src/__tests__/ingest.test.ts +360 -0
  84. package/src/__tests__/interference-detector.test.ts +203 -0
  85. package/src/__tests__/kg-extractor.test.ts +192 -0
  86. package/src/__tests__/kg-store.test.ts +300 -0
  87. package/src/__tests__/llm-client-pivot.test.ts +628 -0
  88. package/src/__tests__/llm-consolidation.test.ts +226 -0
  89. package/src/__tests__/maintenance-locks.test.ts +189 -0
  90. package/src/__tests__/mcp-efficiency.test.ts +288 -0
  91. package/src/__tests__/mcp-related-scopes.test.ts +151 -0
  92. package/src/__tests__/memory-boundaries.test.ts +282 -0
  93. package/src/__tests__/memory-evolution-b2-b3-e1.test.ts +345 -0
  94. package/src/__tests__/memory-evolution.test.ts +271 -0
  95. package/src/__tests__/memory-health-rebalance.test.ts +144 -0
  96. package/src/__tests__/memory-lint.test.ts +421 -0
  97. package/src/__tests__/memory-output.test.ts +215 -0
  98. package/src/__tests__/memory-promotion.test.ts +315 -0
  99. package/src/__tests__/migrate-dedup-ids.test.ts +65 -0
  100. package/src/__tests__/multi-vector.test.ts +242 -0
  101. package/src/__tests__/narrative-architecture.test.ts +456 -0
  102. package/src/__tests__/noise-filter.test.ts +205 -0
  103. package/src/__tests__/noise-prototype-bank.test.ts +280 -0
  104. package/src/__tests__/obsidian-connector.test.ts +363 -0
  105. package/src/__tests__/omitted-reporting.test.ts +98 -0
  106. package/src/__tests__/pending-review.test.ts +77 -0
  107. package/src/__tests__/pii-redact.test.ts +333 -0
  108. package/src/__tests__/pivot-apply.test.ts +373 -0
  109. package/src/__tests__/pivot-distill-supervisor.test.ts +457 -0
  110. package/src/__tests__/pivot-distill.test.ts +1178 -0
  111. package/src/__tests__/post-retrieval-filter.test.ts +178 -0
  112. package/src/__tests__/ppr-traversal.test.ts +256 -0
  113. package/src/__tests__/prediction-engine.test.ts +349 -0
  114. package/src/__tests__/predictive-prospective-memory.test.ts +416 -0
  115. package/src/__tests__/preference-guard-smoke.test.ts +211 -0
  116. package/src/__tests__/preference-matcher.test.ts +282 -0
  117. package/src/__tests__/preference-slots.test.ts +205 -0
  118. package/src/__tests__/prospective-memory.test.ts +283 -0
  119. package/src/__tests__/pull-from-macbook-sync-only.test.ts +679 -0
  120. package/src/__tests__/query-entity-detector.test.ts +171 -0
  121. package/src/__tests__/query-expander-alias.test.ts +146 -0
  122. package/src/__tests__/query-expander.test.ts +34 -0
  123. package/src/__tests__/recall-gate.test.ts +266 -0
  124. package/src/__tests__/recall-governor.test.ts +227 -0
  125. package/src/__tests__/recall-mode.test.ts +64 -0
  126. package/src/__tests__/resolve-db-path.test.ts +130 -0
  127. package/src/__tests__/resolve-tier.test.ts +64 -0
  128. package/src/__tests__/result-synthesizer.test.ts +198 -0
  129. package/src/__tests__/resume-context.test.ts +4898 -0
  130. package/src/__tests__/retrieval-eval.test.ts +163 -0
  131. package/src/__tests__/retrieval-trace.test.ts +213 -0
  132. package/src/__tests__/retriever-boundary-weight.test.ts +136 -0
  133. package/src/__tests__/retriever-lazy-init-dispatch.test.ts +123 -0
  134. package/src/__tests__/retroactive-boost.test.ts +223 -0
  135. package/src/__tests__/salience-scoring.test.ts +106 -0
  136. package/src/__tests__/scope-inventory.test.ts +200 -0
  137. package/src/__tests__/scope-policy.test.ts +69 -0
  138. package/src/__tests__/scope-suggester.test.ts +80 -0
  139. package/src/__tests__/session-checkpoint.test.ts +291 -0
  140. package/src/__tests__/session-distiller.test.ts +640 -0
  141. package/src/__tests__/session-gc.test.ts +215 -0
  142. package/src/__tests__/skill-engine.test.ts +552 -0
  143. package/src/__tests__/skill-promotion.test.ts +654 -0
  144. package/src/__tests__/skill-verifier.test.ts +122 -0
  145. package/src/__tests__/source-heartbeat.test.ts +250 -0
  146. package/src/__tests__/stable-context-length-contract.test.ts +62 -0
  147. package/src/__tests__/store-patch-metadata.test.ts +205 -0
  148. package/src/__tests__/store-read-consistency.test.ts +122 -0
  149. package/src/__tests__/store-schema-migration.test.ts +87 -0
  150. package/src/__tests__/store-scope-audit.test.ts +81 -0
  151. package/src/__tests__/store-upsert.test.ts +201 -0
  152. package/src/__tests__/supersede-chain.test.ts +154 -0
  153. package/src/__tests__/temporal-parser.test.ts +241 -0
  154. package/src/__tests__/temporal-validity.test.ts +160 -0
  155. package/src/__tests__/term-registry-terminal-roster.test.ts +127 -0
  156. package/src/__tests__/term-registry.test.ts +121 -0
  157. package/src/__tests__/tool-error-surfacing.test.ts +98 -0
  158. package/src/__tests__/tool-output-compressor.test.ts +160 -0
  159. package/src/__tests__/topic-tag.test.ts +113 -0
  160. package/src/__tests__/trusted-memory.test.ts +839 -0
  161. package/src/__tests__/ui-server-security.test.ts +93 -0
  162. package/src/__tests__/usage-tracker.test.ts +287 -0
  163. package/src/__tests__/version-manager.test.ts +213 -0
  164. package/src/__tests__/workflow-observation-skill-outcome.test.ts +352 -0
  165. package/src/__tests__/workflow-observation.test.ts +433 -0
  166. package/src/__tests__/write-verifier.test.ts +122 -0
  167. package/src/access-tracker.ts +90 -34
  168. package/src/activity-counter.ts +46 -18
  169. package/src/adaptive-retrieval.ts +11 -0
  170. package/src/aliases.ts +133 -0
  171. package/src/api-server.ts +36 -9
  172. package/src/audit-log.ts +4 -2
  173. package/src/auto-consolidation.ts +8 -3
  174. package/src/auto-gc.ts +85 -21
  175. package/src/auto-recall.ts +61 -1
  176. package/src/belief-history.ts +164 -0
  177. package/src/capture-engine.ts +142 -32
  178. package/src/cli.ts +268 -41
  179. package/src/confidence-tracker.ts +36 -18
  180. package/src/conflict-engine.ts +24 -3
  181. package/src/consolidation-engine.ts +255 -23
  182. package/src/context-composer-stable-selection.ts +7 -1
  183. package/src/context-composer.ts +8 -1
  184. package/src/data-checkup.ts +96 -7
  185. package/src/decay-engine.ts +59 -7
  186. package/src/distill-lock.ts +194 -15
  187. package/src/doctor.ts +13 -4
  188. package/src/dream-pipeline.ts +473 -41
  189. package/src/env-config.ts +118 -0
  190. package/src/error-signature.ts +153 -0
  191. package/src/error-taxonomy.ts +115 -0
  192. package/src/eval.ts +402 -14
  193. package/src/freshness.ts +192 -0
  194. package/src/http-request-policy.ts +148 -0
  195. package/src/idempotency.ts +8 -0
  196. package/src/ingest.ts +308 -12
  197. package/src/kg-extractor.ts +13 -3
  198. package/src/kg-store.ts +236 -44
  199. package/src/llm-client.ts +106 -11
  200. package/src/llm-consolidation.ts +2 -1
  201. package/src/mcp-server.ts +87 -1800
  202. package/src/mcp-tool-deps.ts +48 -0
  203. package/src/mcp-tools-advanced.ts +954 -0
  204. package/src/mcp-tools-core.ts +637 -0
  205. package/src/mcp-tools-governance.ts +475 -0
  206. package/src/memory-boundaries.ts +26 -3
  207. package/src/memory-evolution.ts +73 -1
  208. package/src/memory-health-rebalance.ts +4 -0
  209. package/src/memory-lint.ts +183 -10
  210. package/src/memory-output.ts +129 -4
  211. package/src/memory-promotion.ts +246 -0
  212. package/src/memory-schema.ts +25 -2
  213. package/src/memory-store-port.ts +46 -0
  214. package/src/multi-vector.ts +2 -1
  215. package/src/narrative-schema.ts +2 -1
  216. package/src/noise-filter.ts +57 -6
  217. package/src/noise-prototype-bank.ts +408 -0
  218. package/src/pii-detector.ts +207 -5
  219. package/src/pivot-apply.ts +399 -0
  220. package/src/preference-matcher.ts +37 -3
  221. package/src/query-expander.ts +90 -9
  222. package/src/recall-gate.ts +305 -0
  223. package/src/result-synthesizer.ts +2 -1
  224. package/src/retention-policy.ts +2 -1
  225. package/src/retrieval-profiles.ts +46 -4
  226. package/src/retriever.ts +354 -157
  227. package/src/retroactive-boost.ts +6 -2
  228. package/src/runtime-config.ts +78 -6
  229. package/src/scope-suggester.ts +113 -0
  230. package/src/session-distiller.ts +23 -2
  231. package/src/session-engine.ts +2 -1
  232. package/src/session-schema.ts +1 -0
  233. package/src/session-store.ts +5 -0
  234. package/src/skill-engine.ts +78 -0
  235. package/src/skill-promotion.ts +304 -48
  236. package/src/skill-schema.ts +16 -3
  237. package/src/skill-verifier.ts +152 -0
  238. package/src/store.ts +321 -57
  239. package/src/term-registry.ts +35 -0
  240. package/src/tool-output-compressor.ts +3 -1
  241. package/src/ui-server.ts +9 -1
  242. package/src/usage-tracker.ts +242 -0
  243. package/src/version-manager.ts +3 -2
  244. package/src/version.ts +19 -0
  245. package/src/workflow-observation-engine.ts +2 -1
  246. package/src/workflow-observation-schema.ts +2 -1
  247. package/src/workflow-observation-store.ts +6 -2
  248. package/.env +0 -4
  249. package/CLAUDE.md +0 -46
  250. package/config.json +0 -55
  251. package/daily-recall.sh +0 -110
  252. package/logs/daily-recall.error.log +0 -0
  253. package/logs/daily-recall.log +0 -60
  254. package/logs/ingest-2026-04-11.log +0 -110
  255. package/logs/ingest-2026-04-12.log +0 -90
  256. package/logs/ingest-2026-04-13.log +0 -87
  257. package/logs/launchagent-stderr.log +0 -0
  258. package/logs/launchagent-stdout.log +0 -0
package/src/retriever.ts CHANGED
@@ -6,12 +6,15 @@
6
6
  import type { MemoryStore, MemorySearchResult } from "./store.js";
7
7
  import type { Embedder } from "./embedder.js";
8
8
  import { filterNoise } from "./noise-filter.js";
9
+ import * as envConfig from "./env-config.js";
10
+ import { extractErrorSignatures } from "./error-signature.js";
9
11
  import { shouldSkipRetrieval } from "./adaptive-retrieval.js";
12
+ import { expandQueryWithAliases } from "./aliases.js";
10
13
  import { expandQuery } from "./query-expander.js";
11
14
  import { detectLang, tokenizeFts } from "./language-hook.js";
12
15
  import { type AccessTracker, computeHotnessScore, parseAccessMetadata } from "./access-tracker.js";
13
16
  import { weibullDecay, resolveTier, isDecayExempt, adjustHalfLifeForEmotion, computeArousalBoost } from "./decay-engine.js";
14
- import { logWarn } from "./stderr-log.js";
17
+ import { logInfo, logWarn } from "./stderr-log.js";
15
18
  import { extractBoundaryMetadata, isDurableMemoryScope, isTranscriptScope } from "./memory-boundaries.js";
16
19
  import type { TraceCollector } from "./retrieval-trace.js";
17
20
  import { extractTopicTag } from "./topic-tag.js";
@@ -30,6 +33,7 @@ import {
30
33
  tokenize,
31
34
  } from "./multi-vector.js";
32
35
  import { parseTemporalQuery, matchesTemporalConstraint, type TemporalConstraint } from "./temporal-parser.js";
36
+ import { parseSourceIds } from "./kg-store.js";
33
37
  import type { KGStore } from "./kg-store.js";
34
38
  import { isKGModeEnabled } from "./kg-extractor.js";
35
39
  import { detectEntities } from "./query-entity-detector.js";
@@ -41,6 +45,7 @@ import { parseNarrative, isNarrativeModeEnabled } from "./narrative-schema.js";
41
45
  import type { EmotionMetadata } from "./memory-schema.js";
42
46
  import { detectEmotion } from "./emotion-detector.js";
43
47
  import { shouldReconstruct, reconstruct as runReconstruction } from "./context-reconstructor.js";
48
+ import { recordReconstructionUsage as runUsageShadow } from "./usage-tracker.js";
44
49
  import type {
45
50
  ReconstructionLLMClient,
46
51
  ReconstructionOutput,
@@ -102,6 +107,7 @@ export interface RetrievalConfig {
102
107
  /**
103
108
  * Hotness blend weight. Blends access-frequency hotness into final score.
104
109
  * Formula: final = score * (1-alpha) + hotness * alpha.
110
+ * Zero-access rows keep their score unchanged (bonus-only cold start).
105
111
  * 0 = disabled (default), 0.15 = recommended.
106
112
  */
107
113
  hotnessWeight: number;
@@ -189,6 +195,12 @@ export interface RetrievalResult extends MemorySearchResult {
189
195
  */
190
196
  export type RetrievalResultSet = RetrievalResult[] & {
191
197
  reconstruction?: ReconstructionOutput;
198
+ /**
199
+ * P-omitted: 因 limit 上限被截断、未返回的达标结果数(提高 limit 可见)。
200
+ * 仅统计 finalizeResults 的 limit 截断,不含质量过滤(min-score/noise/MMR)丢弃——
201
+ * 那些提高 limit 也拿不到,报告出来反而误导调用方。
202
+ */
203
+ omitted?: { count: number; reason: "limit" };
192
204
  };
193
205
 
194
206
  // ============================================================================
@@ -209,24 +221,73 @@ export const DEFAULT_CATEGORY_MIN_SCORES: Record<string, number> = {
209
221
  patterns: 0.45,
210
222
  };
211
223
 
224
+ // P1.2(2026-06-25):默认 vector-only。canary eval 实测个人记忆库 vector 召回质量全面优于
225
+ // hybrid(robustness 带 scope WITH 6/12 + 不带 scope WITHOUT 10/12,完胜 hybrid 的 3/9;BM25 在
226
+ // 高度同质的个人记忆里是净噪声,会把 vector 本应 top 的目标如 dbcb317a 挤掉)。改频率层救低频
227
+ // 目标实测是死结(动 hotness 必伤靠 hotness 上位的记忆)。hybrid 路径代码全保留,mode 改回
228
+ // "hybrid" 即可切换(注:multiHopExpand 仍硬编码走 hybrid,默认 off,config.retrieval.multiHop
229
+ // 开了会破纯 vector)。数据盲区:canary 全是语义 query,未覆盖 BM25 强项的精确关键词/token 召回
230
+ // (变量名 / commit hash / alias-map 昵称)——待补 exact-token canary 验证。
212
231
  export const DEFAULT_RETRIEVAL_CONFIG: RetrievalConfig = {
213
- mode: "hybrid",
232
+ mode: "vector",
214
233
  vectorWeight: 0.7,
215
234
  bm25Weight: 0.3,
216
235
  minScore: 0.3,
217
236
  rerank: "cross-encoder",
218
- candidatePoolSize: 20,
237
+ candidatePoolSize: 30,
238
+ sourceDiversity: 1,
219
239
  recencyHalfLifeDays: 14,
220
240
  recencyWeight: 0.10,
221
241
  filterNoise: true,
222
242
  rerankModel: "jina-reranker-v3",
223
243
  rerankEndpoint: "https://api.jina.ai/v1/rerank",
224
- lengthNormAnchor: 500,
244
+ // 2026-08-06:500 → 800。不是拍的——writing profile 早就在用 800
245
+ // (retrieval-profiles.ts),这里只是把一个已在生产跑着的值推广到 default。
246
+ // 实测(eval/ghost-scan.ts,14,949 条 active 全库快照):结构性幽灵
247
+ // 775 → 245(5.18% → 1.64%),救回 530 条,中位长度 1,356 字符那批正落在
248
+ // 打折最重的区间。挤占效应见 eval/lengthnorm-shadow.ts:20 条真实 query
249
+ // 里 14 条 top-6 完全不变,6 条掉出在 limit 放大到 20 后全部复现
250
+ // ——即没有任何条目被删掉,只是挤出窗口,而那 6 条的重排来自
251
+ // applySourceDiversity + MMR(候选数 ≤ limit 时它根本不轮转,
252
+ // anchor 放大让更多候选过阈值才把这个既有行为激活),与本参数无关。
253
+ lengthNormAnchor: 800,
225
254
  hardMinScore: 0.35,
226
255
  timeDecayHalfLifeDays: 60,
227
256
  hotnessWeight: 0,
228
257
  };
229
258
 
259
+ /**
260
+ * P1.2+: 检测 query 是否含「精确 token」——大写常量 / commit hash / 文件名 / snake/camelCase /
261
+ * 低元音 ID(如 rwmjhb)。这类 token vectorOnly 抓不到(embedding 做语义相似非精确匹配,跟语言
262
+ * 无关:实测纯/中文/中英混 query 都漏靠 token 区分的目标),需 BM25 兜底。刻意避开元音正常的普通词
263
+ * (mem0/recallnest/GitHub),避免日常语义 query 误触发 BM25 噪声。
264
+ */
265
+ export function hasExactToken(query: string): boolean {
266
+ // 【为什么中文 query 不触发本函数 —— 这是实测后的决定,不是 ASCII-only 的遗漏】
267
+ // 2026-08-01 用临时开关对 5 条真实中文 query 做过 A/B:强制让中文触发 BM25 补偿后,
268
+ // 3/4 的 query 零新增(BM25 捞回的条目 vector 早已召回,被 vecIds 去重滤掉),
269
+ // 唯一有新增的那次补进 3 条、其中 1 条是当天无关对话,且补偿结果是**插在队首**的。
270
+ // 中文分词本身没问题(babel-memory 已注册,detectLang 正确返回 zh,
271
+ // "双机契约" → "双机 契约"),所以这不是 tokenizer 问题 ——
272
+ // 是 BM25 对中文**语义类** query 的边际收益本就接近零,放开只会拿噪声占前三名。
273
+ // 若将来要重开,先复现那组 A/B,别只凭"中文没走 BM25"这个现象下判断。
274
+ for (const t of query.split(/[\s,。、;:()()【】「」"'`??!!]+/)) {
275
+ if (t.length < 5) continue;
276
+ // 全大写 + 下划线常量(必须含下划线,避免 JSON/HTTP/GitHub 等普通缩写/驼峰误触发)
277
+ if (/^[A-Z][A-Z0-9]*_[A-Z0-9_]+$/.test(t)) return true; // DEDUP_HARD_THRESHOLD
278
+ // snake_case 标识符(含下划线)
279
+ if (/^[a-z][a-z0-9]*_[a-z0-9_]+$/i.test(t)) return true; // capture_engine
280
+ // 文件名(基名 + 代码扩展名)
281
+ if (/^[\w./-]+\.(ts|tsx|js|jsx|py|md|sh|json|sql|go|rs|java|cpp|yml|yaml|toml)$/i.test(t)) return true; // capture-heuristic.ts
282
+ // hex commit hash(7-40,含数字+字母,纯 hex)
283
+ if (/^[0-9a-f]{7,40}$/i.test(t) && /[0-9]/.test(t) && /[a-f]/i.test(t)) return true; // dd12fb5
284
+ // 注:camelCase(GitHub/TypeScript)与低元音 ID(rwmjhb/pytest/nginx)两条宽规则已移除——
285
+ // 前者误伤日常技术 query 引回 BM25 噪声;rwmjhb 类纯小写 ID 即便触发,fts 中英混分词也召回不到
286
+ // (根因在 tokenizer,是独立 backlog),靠中英一起 query 的中文锚点已能补回。
287
+ }
288
+ return false;
289
+ }
290
+
230
291
  // ============================================================================
231
292
  // Utility Functions
232
293
  // ============================================================================
@@ -314,6 +375,32 @@ function applyAnchorBoost(results: RetrievalResult[], query: string): RetrievalR
314
375
  });
315
376
  }
316
377
 
378
+ /** Multiplicative boost applied to a case on exact error-signature match. */
379
+ const ERROR_SIGNATURE_BOOST_FACTOR = 1.5;
380
+
381
+ /**
382
+ * A1 (P3-A): error-signature exact-match boost. When RECALLNEST_ERROR_SIGNATURE_BOOST
383
+ * is on, case memories whose metadata.error_signature overlaps the query's extracted
384
+ * signatures get a strong multiplicative boost ("上次一模一样的报错"精确召回). Flag off
385
+ * (default) returns the input unchanged — bit-identical, so the pure-move eval baseline holds.
386
+ * Only affects category="cases"; read and query side share extractErrorSignatures so the
387
+ * signatures compare apples-to-apples.
388
+ */
389
+ export function applyErrorSignatureBoost(results: RetrievalResult[], query: string): RetrievalResult[] {
390
+ if (!envConfig.errorSignatureBoost()) return results;
391
+ const querySignatures = extractErrorSignatures({ problem: query });
392
+ if (querySignatures.length === 0) return results;
393
+ const querySet = new Set(querySignatures.map(s => s.toLowerCase()));
394
+ return results.map(r => {
395
+ if (r.entry.category !== "cases") return r;
396
+ const sigs = parseMetadata(r.entry.metadata).error_signature;
397
+ if (!Array.isArray(sigs) || sigs.length === 0) return r;
398
+ const hit = sigs.some(s => typeof s === "string" && querySet.has(s.toLowerCase()));
399
+ if (!hit) return r;
400
+ return { ...r, score: Math.min(1, Math.max(0, r.score * ERROR_SIGNATURE_BOOST_FACTOR)) };
401
+ });
402
+ }
403
+
317
404
  /**
318
405
  * Emotion scoring stage: boost memories matching query emotional tone.
319
406
  * No-op when query is neutral or memory lacks emotion data.
@@ -668,6 +755,16 @@ export class MemoryRetriever {
668
755
  this.llmClient = client;
669
756
  }
670
757
 
758
+ /**
759
+ * Shadow-record which memories a reconstruction actually cited via [src:ID].
760
+ * 影子统计:写 metadata.usage(injection-vs-use 双计数),第一阶段不参与 ranking/forget。
761
+ * best-effort:不传 scope(影子写入不做权限过滤,避免跨 scope 扩展候选触发越权 throw);
762
+ * 内部 allSettled 不抛错,绝不阻断主检索/resume 流程。
763
+ */
764
+ async recordReconstructionUsage(citedIds: string[]): Promise<void> {
765
+ await runUsageShadow(this.store, citedIds);
766
+ }
767
+
671
768
  /** Suppress a memory ID for the current session (score × 0.3). */
672
769
  suppressForSession(id: string): void {
673
770
  this.sessionSuppressed.add(id);
@@ -687,16 +784,30 @@ export class MemoryRetriever {
687
784
  }
688
785
 
689
786
  async retrieve(context: RetrievalContext): Promise<RetrievalResultSet> {
690
- const { query, limit, scopeFilter, category, includeArchived, trace, graph } = context;
787
+ const { query: rawQuery, limit, scopeFilter, category, includeArchived, trace, graph } = context;
691
788
  const safeLimit = clampInt(limit, 1, 20);
692
789
 
693
790
  // Adaptive retrieval: skip trivial queries to save embedding API calls
694
- if (shouldSkipRetrieval(query)) {
791
+ if (shouldSkipRetrieval(rawQuery)) {
695
792
  return [];
696
793
  }
697
794
 
795
+ // Step 2: alias expansion — append canonical entity tokens for matching colloquial phrases.
796
+ // 不改原 query,仅追加,保留 vector embedding 原意;BM25 拿到更多锚点。
797
+ const query = expandQueryWithAliases(rawQuery);
798
+
698
799
  let results: RetrievalResult[];
699
800
 
801
+ // 分派前必须先让 store 完成惰性初始化,否则下面读到的 hasFtsSupport 是初始化前的
802
+ // false,hybrid 模式的**首次**检索会静默落回 vector 路径(同一实例第二次才正常)。
803
+ // 该缺陷自 c10607a 初始版本就在,长期未暴露有两个原因:生产 mode 一直是 "vector",
804
+ // 第一个条件短路、根本读不到第二个;而 eval 的 createFreshEvalComponentsFactory 每
805
+ // case 新建组件,每个 case 都停在"首次",于是 eval 从未测到过 hybrid 路径。
806
+ // 注:supplementExactTokenWithBm25 那里的顺序依赖是被意识到的(见其注释"hasFtsSupport
807
+ // 在 vectorOnly 触发 store init 后为真"),漏的只是这个分派点。
808
+ // `?.` 是必需的:测试大量使用鸭子类型的 fake store,它们没有 ready()。
809
+ await this.store.ready?.();
810
+
700
811
  // For vector-only mode, use legacy behavior
701
812
  if (this.config.mode === "vector" || !this.store.hasFtsSupport) {
702
813
  results = await this.vectorOnlyRetrieval(query, safeLimit, scopeFilter, category, includeArchived, trace);
@@ -705,6 +816,28 @@ export class MemoryRetriever {
705
816
  results = await this.hybridRetrieval(query, safeLimit, scopeFilter, category, includeArchived, trace, graph);
706
817
  }
707
818
 
819
+ // 0-hit fallback: minScore filter 把所有候选过滤光时,放宽阈值(=0)重试一次。
820
+ // 只在原 path 0 hit 触发;正常召回不受影响。
821
+ if (results.length === 0) {
822
+ if (this.config.mode === "vector" || !this.store.hasFtsSupport) {
823
+ results = await this.vectorOnlyRetrieval(query, safeLimit, scopeFilter, category, includeArchived, trace, 0);
824
+ } else {
825
+ results = await this.hybridRetrieval(query, safeLimit, scopeFilter, category, includeArchived, trace, graph, 0);
826
+ }
827
+ }
828
+
829
+ // P-omitted: 快照 finalize 阶段因 limit 截断的达标结果数(提高 limit 可见)。
830
+ // 必须在后续 validity/topicTag filter(以及 BM25 补偿等换数组操作)丢失 ResultSet 属性之前读取。
831
+ let omittedByLimit = (results as RetrievalResultSet).omitted?.count ?? 0;
832
+
833
+ // P1.2+: exact-token BM25 补偿——query 含精确 token(大写常量/commit hash/.ts 文件名/camelCase/
834
+ // 低元音 ID 如 rwmjhb)时,vector 漏掉的精确命中用 BM25 补回(vectorOnly 做语义相似、不做精确
835
+ // 字符串匹配,是 embedding 本质,跟语言无关)。普通词(mem0/recallnest/GitHub)不触发,日常
836
+ // 语义召回不受影响。
837
+ if (hasExactToken(rawQuery)) {
838
+ results = await this.supplementExactTokenWithBm25(query, results, safeLimit, scopeFilter, category, includeArchived);
839
+ }
840
+
708
841
  // LME-2: Multi-hop retrieval — extract entities from first-pass results,
709
842
  // run focused follow-up queries, merge to improve cross-session coverage.
710
843
  const useMultiHop = context.multiHop ?? this.config.multiHop ?? false;
@@ -773,7 +906,11 @@ export class MemoryRetriever {
773
906
 
774
907
  // Phase 4: Constructive retrieval — first-class reconstruction (no metadata hack)
775
908
  const resultSet = results as RetrievalResultSet;
776
- const constructiveFlag = process.env.RECALLNEST_CONSTRUCTIVE_RETRIEVAL === "true";
909
+ // P-omitted: 把快照的 limit 截断数挂回最终 resultSet(validity filter 已丢原数组属性)。
910
+ if (omittedByLimit > 0) {
911
+ resultSet.omitted = { count: omittedByLimit, reason: "limit" };
912
+ }
913
+ const constructiveFlag = envConfig.constructiveRetrieval();
777
914
  if (shouldReconstruct({
778
915
  flagEnabled: constructiveFlag,
779
916
  callerOptIn: context.reconstruct === true,
@@ -790,6 +927,8 @@ export class MemoryRetriever {
790
927
  expansionDeps,
791
928
  );
792
929
  resultSet.reconstruction = reconstruction;
930
+ // Shadow usage:记录被 reconstruction 真正引用的记忆(use 信号),不动 ranking/forget。
931
+ await this.recordReconstructionUsage(reconstruction.sources.map(s => s.id));
793
932
  } catch {
794
933
  // Silent degradation — return raw results
795
934
  }
@@ -798,6 +937,36 @@ export class MemoryRetriever {
798
937
  return resultSet;
799
938
  }
800
939
 
940
+ /**
941
+ * P1.2+: exact-token BM25 补偿。vectorOnly 主路径后,若 query 含精确 token,用 BM25 精确匹配把
942
+ * vector 漏掉的命中补回。hasFtsSupport 在 vectorOnly 触发 store init 后为真;fake store 为假时跳过。
943
+ * bm25 精确命中提到结果前(exact-token 意图=精确优先),vector 语义结果跟后,去重后 slice limit。
944
+ */
945
+ private async supplementExactTokenWithBm25(
946
+ query: string,
947
+ vectorResults: RetrievalResult[],
948
+ limit: number,
949
+ scopeFilter?: string[],
950
+ category?: string,
951
+ includeArchived?: boolean,
952
+ ): Promise<RetrievalResult[]> {
953
+ if (!this.store.hasFtsSupport) return vectorResults;
954
+ const ftsQuery = tokenizeFts(expandQuery(query), detectLang(query));
955
+ const bm25 = await this.runBM25Search(ftsQuery, Math.max(limit, 5), scopeFilter, category, includeArchived);
956
+ const vecIds = new Set(vectorResults.map(r => r.entry.id));
957
+ const supplements = bm25
958
+ .filter(b => !vecIds.has(b.entry.id))
959
+ .filter(b => isActiveMemory(b.entry.metadata)) // 排掉 superseded/archived/consolidated(补偿绕过了 finalize 的尾部过滤,这里补回 active 校验)
960
+ .slice(0, 3)
961
+ .map(b => ({
962
+ entry: b.entry,
963
+ score: b.score,
964
+ sources: { bm25: { score: b.score, rank: b.rank } },
965
+ } as RetrievalResult));
966
+ if (supplements.length === 0) return vectorResults;
967
+ return [...supplements, ...vectorResults].slice(0, limit);
968
+ }
969
+
801
970
  /**
802
971
  * LME-2: Multi-hop expansion — extract entities from first-pass results,
803
972
  * run entity-focused follow-up queries, merge with original results.
@@ -860,6 +1029,7 @@ export class MemoryRetriever {
860
1029
  category?: string,
861
1030
  includeArchived?: boolean,
862
1031
  trace?: TraceCollector,
1032
+ minScoreOverride?: number,
863
1033
  ): Promise<RetrievalResult[]> {
864
1034
  // Temporal reasoning for vector-only mode
865
1035
  const temporal = parseTemporalQuery(query);
@@ -867,12 +1037,20 @@ export class MemoryRetriever {
867
1037
 
868
1038
  trace?.startStage("vector_search", 0);
869
1039
  const queryVector = await this.embedder.embedQuery(searchQuery);
870
- // P0.1: Lower minScore for short queries
1040
+ // P0.1: Lower minScore for short queries; minScoreOverride 用于 0-hit fallback retry
871
1041
  const shortQ = isShortQuery(searchQuery);
872
- const vectorMinScore = shortQ
873
- ? this.config.minScore * SHORT_QUERY_MIN_SCORE_FACTOR
874
- : this.config.minScore;
875
- const results = await this.store.vectorSearch(queryVector, limit, vectorMinScore, scopeFilter);
1042
+ const vectorMinScore = minScoreOverride !== undefined
1043
+ ? minScoreOverride
1044
+ : (shortQ
1045
+ ? this.config.minScore * SHORT_QUERY_MIN_SCORE_FACTOR
1046
+ : this.config.minScore);
1047
+ // P-omitted: over-fetch a wider candidate pool so finalizeResults can detect limit-capped
1048
+ // omissions. Previously fetched exactly `limit`, so vector-only omitted was always 0
1049
+ // (hybrid already over-fetches candidatePoolSize). Cost is minimal — ANN with a larger K
1050
+ // plus a few more local scoring passes, no extra API; P1.2's vector-only savings come from
1051
+ // skipping hybrid BM25/fusion/rerank, not from fetch size.
1052
+ const fetchLimit = Math.max(limit, this.config.candidatePoolSize ?? limit);
1053
+ const results = await this.store.vectorSearch(queryVector, fetchLimit, vectorMinScore, scopeFilter);
876
1054
  trace?.endStage(results.length, results.map(r => r.score));
877
1055
 
878
1056
  // Filter by category if specified
@@ -900,103 +1078,18 @@ export class MemoryRetriever {
900
1078
  // P0.1: Anchor boost for vector-only path
901
1079
  const anchorBoosted = applyAnchorBoost(mapped, searchQuery);
902
1080
 
903
- trace?.startStage("recency_boost", anchorBoosted.length);
904
- const boosted = this.applyRecencyBoost(anchorBoosted);
905
- trace?.endStage(boosted.length, boosted.map(r => r.score));
906
-
907
- trace?.startStage("importance_weight", boosted.length);
908
- const weighted = this.applyImportanceWeight(boosted);
909
- trace?.endStage(weighted.length, weighted.map(r => r.score));
910
-
911
- trace?.startStage("confidence_weight", weighted.length);
912
- const confidenceWeighted = this.applyConfidence(weighted);
913
- trace?.endStage(confidenceWeighted.length, confidenceWeighted.map(r => r.score));
914
-
915
- let afterEmotionVector = confidenceWeighted;
916
- if (isEmotionScoringEnabled()) {
917
- const queryEmotion = detectEmotion(query);
918
- const adapted = confidenceWeighted.map(r => ({ ...r, metadata: r.entry.metadata ?? "{}" }));
919
- const emotioned = applyEmotionWeight(adapted, queryEmotion);
920
- afterEmotionVector = confidenceWeighted.map((r, i) => ({ ...r, score: emotioned[i]?.score ?? r.score }));
921
- }
922
-
923
- trace?.startStage("boundary_weight", afterEmotionVector.length);
924
- const boundaryWeighted = this.applyBoundaryWeight(afterEmotionVector);
925
- trace?.endStage(boundaryWeighted.length, boundaryWeighted.map(r => r.score));
926
-
927
- trace?.startStage("asset_type_weight", boundaryWeighted.length);
928
- const assetWeighted = this.applyAssetTypeWeight(boundaryWeighted);
929
- trace?.endStage(assetWeighted.length, assetWeighted.map(r => r.score));
930
-
931
- trace?.startStage("length_norm", assetWeighted.length);
932
- const lengthNormalized = this.applyLengthNormalization(assetWeighted);
933
- trace?.endStage(lengthNormalized.length, lengthNormalized.map(r => r.score));
934
-
935
- trace?.startStage("time_decay", lengthNormalized.length);
936
- const timeDecayed = this.applyTimeDecay(lengthNormalized);
937
- trace?.endStage(timeDecayed.length, timeDecayed.map(r => r.score));
938
-
939
- // B-1/E-1: Evolution decay blend — boost memories with high composite decay score
940
- const evolutionBlended = this.applyEvolutionDecayBlend(timeDecayed);
941
-
942
- // E-1: Access count boost — memories retrieved more often get a score nudge
943
- const accessBoosted = this.applyAccessCountBoost(evolutionBlended);
944
-
945
- // Hotness blend: boost frequently + recently accessed memories
946
- trace?.startStage("hotness_blend", accessBoosted.length);
947
- const hotnessBlended = this.applyHotnessBlend(accessBoosted);
948
- trace?.endStage(hotnessBlended.length, hotnessBlended.map(r => r.score));
949
-
950
- // P0.2: Frequency boost — repeatedly retrieved memories score higher
951
- trace?.startStage("frequency_boost", hotnessBlended.length);
952
- const frequencyBoosted = this.applyFrequencyBoost(hotnessBlended);
953
- trace?.endStage(frequencyBoosted.length, frequencyBoosted.map(r => r.score));
954
-
955
- // Session suppression: temporarily penalize "do not disturb" memories
956
- const afterSuppression = this.applySessionSuppression(frequencyBoosted);
957
- const rescored = [...afterSuppression].sort((a, b) => b.score - a.score);
958
-
959
- trace?.startStage("hard_min_score", rescored.length);
960
- const hardFiltered = rescored.filter(r => r.score >= this.minScoreFor(r.entry.category, shortQ));
961
- trace?.endStage(hardFiltered.length, hardFiltered.map(r => r.score));
962
-
963
- // Evolution status filter: exclude superseded/archived/consolidated memories
964
- const evolutionFiltered = includeArchived
965
- ? hardFiltered
966
- : hardFiltered.filter(r => isActiveMemory(r.entry.metadata));
967
-
968
- trace?.startStage("noise_filter", evolutionFiltered.length);
969
- const denoised = this.config.filterNoise
970
- ? filterNoise(evolutionFiltered, r => r.entry.text)
971
- : evolutionFiltered;
972
- trace?.endStage(denoised.length, denoised.map(r => r.score));
973
-
974
- // RIF: demote near-duplicate weak results to improve diversity
975
- const afterRif = this.config.enableRIF
976
- ? filterInterference(denoised, this.config.rifThreshold ?? 0.85, this.config.rifScoreRatio ?? 0.80)
977
- : denoised;
978
-
979
- // MMR deduplication: avoid top-k filled with near-identical memories
980
- trace?.startStage("mmr_diversity", afterRif.length);
981
- const deduplicated = this.applyMMRDiversity(afterRif);
982
- trace?.endStage(deduplicated.length, deduplicated.map(r => r.score));
983
-
984
- // Tier 3.3: Version group dedup — keep only the top-ranked version per group
985
- const versionDeduped = deduplicateByVersionGroup(deduplicated);
986
-
987
- // LC-P2: Cluster insight dedup — prefer cluster summary over individual source memories
988
- const clusterDeduped = deduplicateByClusterInsight(versionDeduped);
989
-
990
- // Source diversity: round-robin across scopes for multi-session coverage
991
- const diversified = (this.config.sourceDiversity ?? 0) > 0
992
- ? applySourceDiversity(clusterDeduped, limit)
993
- : clusterDeduped;
1081
+ const scored = this.applySharedScoring(anchorBoosted, query, trace);
994
1082
 
995
- trace?.startStage("final_limit", diversified.length);
996
- const final = diversified.slice(0, limit);
997
- trace?.endStage(final.length, final.map(r => r.score));
1083
+ // Session suppression: temporarily penalize "do not disturb" memories (vector-only path)
1084
+ const afterSuppression = this.applySessionSuppression(scored);
998
1085
 
999
- return final;
1086
+ return this.finalizeResults(afterSuppression, {
1087
+ shortQ,
1088
+ minScoreOverride,
1089
+ includeArchived,
1090
+ limit,
1091
+ trace,
1092
+ });
1000
1093
  }
1001
1094
 
1002
1095
  private async hybridRetrieval(
@@ -1007,6 +1100,7 @@ export class MemoryRetriever {
1007
1100
  includeArchived?: boolean,
1008
1101
  trace?: TraceCollector,
1009
1102
  graph?: boolean,
1103
+ minScoreOverride?: number,
1010
1104
  ): Promise<RetrievalResult[]> {
1011
1105
  // Adaptive pool: widen candidate pool for aggregation queries ("how many", "all the")
1012
1106
  const multiplier = (this.config.adaptivePoolMultiplier ?? 1) > 1 && isAggregationQuery(query)
@@ -1070,10 +1164,13 @@ export class MemoryRetriever {
1070
1164
 
1071
1165
  // Apply minimum score threshold
1072
1166
  trace?.startStage("min_score_filter", anchorBoosted.length);
1073
- // P0.1: Lower minScore for short queries to widen the candidate pool
1074
- const effectiveMinScore = isShortQuery(searchQuery)
1075
- ? this.config.minScore * SHORT_QUERY_MIN_SCORE_FACTOR
1076
- : this.config.minScore;
1167
+ // P0.1: Lower minScore for short queries to widen the candidate pool;
1168
+ // minScoreOverride 用于 retrieve() 顶层的 0-hit fallback retry。
1169
+ const effectiveMinScore = minScoreOverride !== undefined
1170
+ ? minScoreOverride
1171
+ : (isShortQuery(searchQuery)
1172
+ ? this.config.minScore * SHORT_QUERY_MIN_SCORE_FACTOR
1173
+ : this.config.minScore);
1077
1174
  const filtered = anchorBoosted.filter(r => r.score >= effectiveMinScore);
1078
1175
  trace?.endStage(filtered.length, filtered.map(r => r.score));
1079
1176
 
@@ -1084,46 +1181,61 @@ export class MemoryRetriever {
1084
1181
  : filtered;
1085
1182
  trace?.endStage(reranked.length, reranked.map(r => r.score));
1086
1183
 
1087
- // Apply temporal re-ranking (recency boost)
1088
- trace?.startStage("recency_boost", reranked.length);
1089
- const temporalReranked = this.applyRecencyBoost(reranked);
1090
- trace?.endStage(temporalReranked.length, temporalReranked.map(r => r.score));
1184
+ const scored = this.applySharedScoring(reranked, query, trace);
1091
1185
 
1092
- // Apply importance weighting
1093
- trace?.startStage("importance_weight", temporalReranked.length);
1094
- const importanceWeighted = this.applyImportanceWeight(temporalReranked);
1095
- trace?.endStage(importanceWeighted.length, importanceWeighted.map(r => r.score));
1186
+ return this.finalizeResults(scored, {
1187
+ shortQ,
1188
+ minScoreOverride,
1189
+ includeArchived,
1190
+ limit,
1191
+ trace,
1192
+ });
1193
+ }
1096
1194
 
1097
- // Viewpoint confidence: penalize corrected/contradicted memories
1098
- trace?.startStage("confidence_weight", importanceWeighted.length);
1099
- const confidenceWeighted = this.applyConfidence(importanceWeighted);
1195
+ /**
1196
+ * Shared scoring pipeline — the 12 stages identical across the vector-only and
1197
+ * hybrid paths. Order is load-bearing (Codex §3.6 "顺序即语义"); do not reorder.
1198
+ * `query` feeds the emotion stage; `trace` carries per-stage trace spans.
1199
+ * Path-specific stages stay in the caller: anchor / min_score / rerank / multi-vector
1200
+ * blend run BEFORE this; vector-only session suppression runs AFTER this.
1201
+ */
1202
+ private applySharedScoring(
1203
+ results: RetrievalResult[],
1204
+ query: string,
1205
+ trace?: TraceCollector,
1206
+ ): RetrievalResult[] {
1207
+ trace?.startStage("recency_boost", results.length);
1208
+ const boosted = this.applyRecencyBoost(results);
1209
+ trace?.endStage(boosted.length, boosted.map(r => r.score));
1210
+
1211
+ trace?.startStage("importance_weight", boosted.length);
1212
+ const weighted = this.applyImportanceWeight(boosted);
1213
+ trace?.endStage(weighted.length, weighted.map(r => r.score));
1214
+
1215
+ trace?.startStage("confidence_weight", weighted.length);
1216
+ const confidenceWeighted = this.applyConfidence(weighted);
1100
1217
  trace?.endStage(confidenceWeighted.length, confidenceWeighted.map(r => r.score));
1101
1218
 
1102
- // Emotion scoring: boost memories matching query emotional tone
1103
- let afterEmotion = confidenceWeighted;
1219
+ let afterEmotionVector = confidenceWeighted;
1104
1220
  if (isEmotionScoringEnabled()) {
1105
1221
  const queryEmotion = detectEmotion(query);
1106
1222
  const adapted = confidenceWeighted.map(r => ({ ...r, metadata: r.entry.metadata ?? "{}" }));
1107
1223
  const emotioned = applyEmotionWeight(adapted, queryEmotion);
1108
- afterEmotion = confidenceWeighted.map((r, i) => ({ ...r, score: emotioned[i]?.score ?? r.score }));
1224
+ afterEmotionVector = confidenceWeighted.map((r, i) => ({ ...r, score: emotioned[i]?.score ?? r.score }));
1109
1225
  }
1110
1226
 
1111
- // Prefer higher-authority durable memories over raw evidence when scores are close.
1112
- trace?.startStage("boundary_weight", afterEmotion.length);
1113
- const boundaryWeighted = this.applyBoundaryWeight(afterEmotion);
1227
+ trace?.startStage("boundary_weight", afterEmotionVector.length);
1228
+ const boundaryWeighted = this.applyBoundaryWeight(afterEmotionVector);
1114
1229
  trace?.endStage(boundaryWeighted.length, boundaryWeighted.map(r => r.score));
1115
1230
 
1116
- // Separate pinned assets from synthesized briefs.
1117
1231
  trace?.startStage("asset_type_weight", boundaryWeighted.length);
1118
1232
  const assetWeighted = this.applyAssetTypeWeight(boundaryWeighted);
1119
1233
  trace?.endStage(assetWeighted.length, assetWeighted.map(r => r.score));
1120
1234
 
1121
- // Apply length normalization (penalize long entries dominating via keyword density)
1122
1235
  trace?.startStage("length_norm", assetWeighted.length);
1123
1236
  const lengthNormalized = this.applyLengthNormalization(assetWeighted);
1124
1237
  trace?.endStage(lengthNormalized.length, lengthNormalized.map(r => r.score));
1125
1238
 
1126
- // Apply time decay (penalize stale entries)
1127
1239
  trace?.startStage("time_decay", lengthNormalized.length);
1128
1240
  const timeDecayed = this.applyTimeDecay(lengthNormalized);
1129
1241
  trace?.endStage(timeDecayed.length, timeDecayed.map(r => r.score));
@@ -1144,12 +1256,33 @@ export class MemoryRetriever {
1144
1256
  const frequencyBoosted = this.applyFrequencyBoost(hotnessBlended);
1145
1257
  trace?.endStage(frequencyBoosted.length, frequencyBoosted.map(r => r.score));
1146
1258
 
1147
- // Hard minimum score cutoff (post all scoring stages)
1148
- // P0.1: lower thresholds for short queries (shortQ declared earlier for fuseResults)
1149
- const rescored = [...frequencyBoosted].sort((a, b) => b.score - a.score);
1259
+ // A1 error-signature 精确匹配 boost(flag 门控,默认 off → no-op、bit-identical)
1260
+ return applyErrorSignatureBoost(frequencyBoosted, query);
1261
+ }
1262
+
1263
+ /**
1264
+ * Shared finalization tail — identical across both paths: sort → hard min →
1265
+ * evolution-status filter → noise → RIF → MMR → version/cluster dedup →
1266
+ * source diversity → limit. `shortQ` must be the value derived from the
1267
+ * path's searchQuery (do not recompute from the raw query here).
1268
+ */
1269
+ private finalizeResults(
1270
+ results: RetrievalResult[],
1271
+ opts: {
1272
+ shortQ: boolean;
1273
+ minScoreOverride?: number;
1274
+ includeArchived?: boolean;
1275
+ limit: number;
1276
+ trace?: TraceCollector;
1277
+ },
1278
+ ): RetrievalResult[] {
1279
+ const { shortQ, minScoreOverride, includeArchived, limit, trace } = opts;
1280
+ const rescored = [...results].sort((a, b) => b.score - a.score);
1150
1281
 
1151
1282
  trace?.startStage("hard_min_score", rescored.length);
1152
- const hardFiltered = rescored.filter(r => r.score >= this.minScoreFor(r.entry.category, shortQ));
1283
+ const hardFiltered = minScoreOverride !== undefined
1284
+ ? rescored
1285
+ : rescored.filter(r => r.score >= this.minScoreFor(r.entry.category, shortQ));
1153
1286
  trace?.endStage(hardFiltered.length, hardFiltered.map(r => r.score));
1154
1287
 
1155
1288
  // Evolution status filter: exclude superseded/archived/consolidated memories
@@ -1157,11 +1290,14 @@ export class MemoryRetriever {
1157
1290
  ? hardFiltered
1158
1291
  : hardFiltered.filter(r => isActiveMemory(r.entry.metadata));
1159
1292
 
1160
- // Filter noise
1161
- trace?.startStage("noise_filter", evolutionFiltered.length);
1293
+ // LA-1: 资格层准入 —— 默认让 durable 层回答,evidence 留给 deja / 显式溯源。
1294
+ // 放在各类 dedup 之前:先定"谁有资格进候选集",再谈多样性与去重。
1295
+ const layerAdmitted = this.applyLayerAdmission(evolutionFiltered, trace);
1296
+
1297
+ trace?.startStage("noise_filter", layerAdmitted.length);
1162
1298
  const denoised = this.config.filterNoise
1163
- ? filterNoise(evolutionFiltered, r => r.entry.text)
1164
- : evolutionFiltered;
1299
+ ? filterNoise(layerAdmitted, r => r.entry.text)
1300
+ : layerAdmitted;
1165
1301
  trace?.endStage(denoised.length, denoised.map(r => r.score));
1166
1302
 
1167
1303
  // RIF: demote near-duplicate weak results to improve diversity
@@ -1189,6 +1325,16 @@ export class MemoryRetriever {
1189
1325
  const final = diversified.slice(0, limit);
1190
1326
  trace?.endStage(final.length, final.map(r => r.score));
1191
1327
 
1328
+ // P-omitted: 从 source-diversity 之前的候选池(clusterDeduped)算 limit 截断数。
1329
+ // 若启用 sourceDiversity,applySourceDiversity 会先把 diversified 压到 limit,
1330
+ // 用 diversified.length 会让 omitted 恒为 0(Codex 复审 P3-1,与 vector-only 同类)。
1331
+ // 注意:clusterDeduped 仍可能含将被 retrieve 层 validity filter 过滤的 expired 项,
1332
+ // 故 omitted 是“上界估计”,输出层用 "up to N" 措辞反映(P3-2)。
1333
+ const omittedByLimit = Math.max(0, clusterDeduped.length - final.length);
1334
+ if (omittedByLimit > 0) {
1335
+ (final as RetrievalResultSet).omitted = { count: omittedByLimit, reason: "limit" };
1336
+ }
1337
+
1192
1338
  return final;
1193
1339
  }
1194
1340
 
@@ -1293,9 +1439,13 @@ export class MemoryRetriever {
1293
1439
  const objectScore = entityScoreMap.get(triple.object) ?? 0;
1294
1440
  const tripleScore = Math.max(subjectScore, objectScore) * triple.confidence;
1295
1441
 
1296
- if (tripleScore > 0 && triple.source_memory_id) {
1297
- const existing = memoryScoreMap.get(triple.source_memory_id) ?? 0;
1298
- memoryScoreMap.set(triple.source_memory_id, Math.max(existing, tripleScore));
1442
+ if (tripleScore > 0) {
1443
+ // All contributing memories share the fact's score — not just the
1444
+ // first contributor (multi-source triples after mention counting)
1445
+ for (const mid of parseSourceIds(triple.source_memory_ids, triple.source_memory_id)) {
1446
+ const existing = memoryScoreMap.get(mid) ?? 0;
1447
+ memoryScoreMap.set(mid, Math.max(existing, tripleScore));
1448
+ }
1299
1449
  }
1300
1450
  }
1301
1451
  }
@@ -1648,6 +1798,49 @@ export class MemoryRetriever {
1648
1798
  return weighted.sort((a, b) => b.score - a.score);
1649
1799
  }
1650
1800
 
1801
+ /** LA-1: 一条结果是否属于 evidence 层(transcript 碎片)。判据与 applyBoundaryWeight 保持一致。 */
1802
+ private isEvidenceLayer(r: RetrievalResult): boolean {
1803
+ const boundary = extractBoundaryMetadata(r.entry.metadata);
1804
+ if (boundary) return boundary.layer === "evidence";
1805
+ return isTranscriptScope(r.entry.scope);
1806
+ }
1807
+
1808
+ /**
1809
+ * LA-1: 资格层准入 —— 决定"哪一层来回答这个 query",而非调整分数。
1810
+ *
1811
+ * 与 applyBoundaryWeight 的区别是本方法存在的全部理由:那个改分数(排序层),
1812
+ * 会让来源盖过相关性(`retriever-boundary-weight.test.ts` 明确禁止这点);
1813
+ * 本方法只挑选候选集,层内相关性排序原封不动。
1814
+ *
1815
+ * durable 命中不足 `layerAdmissionMin` 时回退到全量候选池 —— 新主题在结论层
1816
+ * 往往是空的,宁缺毋滥不能变成什么都不给。
1817
+ */
1818
+ private applyLayerAdmission(
1819
+ results: RetrievalResult[],
1820
+ trace?: TraceCollector,
1821
+ ): RetrievalResult[] {
1822
+ const mode = envConfig.layerAdmission();
1823
+ if (mode === "off" || results.length === 0) return results;
1824
+
1825
+ const durable = results.filter(r => !this.isEvidenceLayer(r));
1826
+ const dropped = results.length - durable.length;
1827
+ const thin = durable.length < envConfig.layerAdmissionMin();
1828
+
1829
+ if (mode === "observe") {
1830
+ logInfo(
1831
+ `[layer-admission] observe: durable=${durable.length} evidence=${dropped} ` +
1832
+ `total=${results.length} action=${thin ? "fallback(keep-all)" : "would-drop-evidence"}`,
1833
+ );
1834
+ return results;
1835
+ }
1836
+
1837
+ if (thin) return results;
1838
+
1839
+ trace?.startStage("layer_admission", results.length);
1840
+ trace?.endStage(durable.length, durable.map(r => r.score));
1841
+ return durable;
1842
+ }
1843
+
1651
1844
  private applyBoundaryWeight(results: RetrievalResult[]): RetrievalResult[] {
1652
1845
  const weighted = results.map(r => {
1653
1846
  const boundary = extractBoundaryMetadata(r.entry.metadata);
@@ -1718,8 +1911,8 @@ export class MemoryRetriever {
1718
1911
 
1719
1912
  const now = Date.now();
1720
1913
  const decayed = results.map(r => {
1721
- // HP-7: Exempt core/pinned/recently-accessed from decay
1722
- if (isDecayExempt(r.entry.metadata, r.entry.importance)) return r;
1914
+ // HP-7: Exempt core/pinned/recently-accessed (and procedural categories) from decay
1915
+ if (isDecayExempt(r.entry.metadata, r.entry.importance, r.entry.category)) return r;
1723
1916
 
1724
1917
  const ts = (r.entry.timestamp && r.entry.timestamp > 0) ? r.entry.timestamp : now;
1725
1918
  const ageDays = (now - ts) / 86_400_000;
@@ -1736,7 +1929,7 @@ export class MemoryRetriever {
1736
1929
  }
1737
1930
 
1738
1931
  // Resolve tier from metadata (defaults to peripheral)
1739
- const tier = resolveTier(r.entry.metadata);
1932
+ const tier = resolveTier(r.entry.metadata, r.entry.importance);
1740
1933
 
1741
1934
  // Weibull decay with tier-specific shape and floor
1742
1935
  const factor = weibullDecay(ageDays, halfLife, tier);
@@ -1751,14 +1944,6 @@ export class MemoryRetriever {
1751
1944
  return decayed.sort((a, b) => b.score - a.score);
1752
1945
  }
1753
1946
 
1754
- /**
1755
- * Blend access-frequency hotness into retrieval scores.
1756
- *
1757
- * Formula: final = score * (1 - alpha) + hotness * alpha
1758
- * where hotness = sigmoid(log1p(accessCount)) * exp(-decayRate * ageDays)
1759
- *
1760
- * No-op when hotnessWeight is 0 or AccessTracker is absent.
1761
- */
1762
1947
  /**
1763
1948
  * B-1/E-1: Evolution decay blend — factor in composite decay score
1764
1949
  * (time × frequency × importance) as a scoring signal.
@@ -1767,8 +1952,8 @@ export class MemoryRetriever {
1767
1952
  private applyEvolutionDecayBlend(results: RetrievalResult[]): RetrievalResult[] {
1768
1953
  const EVOLUTION_BLEND_WEIGHT = 0.10;
1769
1954
  return results.map(r => {
1770
- // HP-7: Exempt entries get max decay score (1.0) → no penalty
1771
- if (isDecayExempt(r.entry.metadata, r.entry.importance)) {
1955
+ // HP-7: Exempt entries (incl. procedural categories) get max decay score (1.0) → no penalty
1956
+ if (isDecayExempt(r.entry.metadata, r.entry.importance, r.entry.category)) {
1772
1957
  return {
1773
1958
  ...r,
1774
1959
  score: clamp01(r.score * (1 - EVOLUTION_BLEND_WEIGHT) + 1.0 * EVOLUTION_BLEND_WEIGHT, 0),
@@ -1831,8 +2016,10 @@ export class MemoryRetriever {
1831
2016
  const existingIds = new Set(results.map(r => r.entry.id));
1832
2017
  for (const n of neighborhood) {
1833
2018
  for (const t of n.triples) {
1834
- if (t.source_memory_id && !existingIds.has(t.source_memory_id)) {
1835
- sourceIds.add(t.source_memory_id);
2019
+ // Expand through ALL contributing memories of each fact, not just
2020
+ // the first contributor (multi-source triples after mention counting)
2021
+ for (const mid of parseSourceIds(t.source_memory_ids, t.source_memory_id)) {
2022
+ if (!existingIds.has(mid)) sourceIds.add(mid);
1836
2023
  }
1837
2024
  }
1838
2025
  }
@@ -1967,6 +2154,15 @@ export class MemoryRetriever {
1967
2154
  return [...results, ...siblings].sort((a, b) => b.score - a.score);
1968
2155
  }
1969
2156
 
2157
+ /**
2158
+ * Blend access-frequency hotness into retrieval scores.
2159
+ *
2160
+ * Formula: final = score * (1 - alpha) + hotness * alpha
2161
+ * where hotness = sigmoid(log1p(accessCount)) * exp(-decayRate * ageDays)
2162
+ * Zero-access rows keep their incoming score; accessed rows retain this formula.
2163
+ *
2164
+ * No-op when hotnessWeight is 0 or AccessTracker is absent.
2165
+ */
1970
2166
  private applyHotnessBlend(results: RetrievalResult[]): RetrievalResult[] {
1971
2167
  const raw = this.config.hotnessWeight;
1972
2168
  const alpha = Math.min(1, Math.max(0, Number.isFinite(raw) ? raw : 0));
@@ -1978,6 +2174,7 @@ export class MemoryRetriever {
1978
2174
  accessCount,
1979
2175
  lastAccessedAt || r.entry.timestamp,
1980
2176
  );
2177
+ if (accessCount === 0) return r;
1981
2178
  return {
1982
2179
  ...r,
1983
2180
  score: clamp01(r.score * (1 - alpha) + hotness * alpha, 0),