recallnest 2.2.1 → 2.5.4

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 (227) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/README.md +46 -35
  3. package/README_CN.md +41 -26
  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/connectors/examples/email-adapter.ts +73 -0
  9. package/connectors/examples/logs-adapter.ts +103 -0
  10. package/connectors/examples/rss-adapter.ts +95 -0
  11. package/docs/api-reference.md +1291 -0
  12. package/docs/architecture.md +168 -0
  13. package/docs/connector-spec.md +126 -0
  14. package/eval/cases-canary.json +58 -0
  15. package/eval/cases-scope-robustness.json +278 -0
  16. package/eval/cases.json +117 -20
  17. package/package.json +22 -1
  18. package/src/__tests__/access-tracker-novelty.test.ts +238 -0
  19. package/src/__tests__/activity-counter.test.ts +125 -0
  20. package/src/__tests__/adaptive-fidelity.test.ts +102 -0
  21. package/src/__tests__/admission-control.test.ts +206 -0
  22. package/src/__tests__/anchor-generator.test.ts +67 -0
  23. package/src/__tests__/auto-consolidation.test.ts +168 -0
  24. package/src/__tests__/auto-recall.test.ts +131 -0
  25. package/src/__tests__/babel-integration.test.ts +144 -0
  26. package/src/__tests__/batch-dedup.test.ts +65 -0
  27. package/src/__tests__/canary-eval.test.ts +114 -0
  28. package/src/__tests__/capture-engine.test.ts +1238 -0
  29. package/src/__tests__/capture-heuristic.test.ts +171 -0
  30. package/src/__tests__/cascade-forget.test.ts +210 -0
  31. package/src/__tests__/category-min-scores.test.ts +33 -0
  32. package/src/__tests__/cc8-cc1-essential-context.test.ts +298 -0
  33. package/src/__tests__/circuit-breaker.test.ts +99 -0
  34. package/src/__tests__/cluster-consolidation.test.ts +782 -0
  35. package/src/__tests__/confidence-metadata.test.ts +125 -0
  36. package/src/__tests__/confidence-tracker.test.ts +261 -0
  37. package/src/__tests__/conflict-advisor.test.ts +206 -0
  38. package/src/__tests__/conflict-escalation.test.ts +149 -0
  39. package/src/__tests__/conflict-lifecycle.test.ts +83 -0
  40. package/src/__tests__/conflict-output.test.ts +85 -0
  41. package/src/__tests__/conflict-store.test.ts +374 -0
  42. package/src/__tests__/connector-v1.test.ts +181 -0
  43. package/src/__tests__/consolidation-engine.test.ts +333 -0
  44. package/src/__tests__/constructive-retrieval.test.ts +509 -0
  45. package/src/__tests__/context-collapse-renderer.test.ts +214 -0
  46. package/src/__tests__/context-composer-pins.test.ts +64 -0
  47. package/src/__tests__/context-composer-task-selection.test.ts +24 -0
  48. package/src/__tests__/context-reconstructor.test.ts +65 -0
  49. package/src/__tests__/context-renderer.test.ts +91 -0
  50. package/src/__tests__/continuity-eval.test.ts +275 -0
  51. package/src/__tests__/continuity-schema.test.ts +97 -0
  52. package/src/__tests__/contracts.test.ts +216 -0
  53. package/src/__tests__/conversation-importer.test.ts +692 -0
  54. package/src/__tests__/core-recall-eval.test.ts +235 -0
  55. package/src/__tests__/core-summary.test.ts +109 -0
  56. package/src/__tests__/data-checkup.test.ts +276 -0
  57. package/src/__tests__/decay-exemption.test.ts +84 -0
  58. package/src/__tests__/dedup-actions.test.ts +58 -0
  59. package/src/__tests__/dedup-guard.test.ts +39 -0
  60. package/src/__tests__/deterministic-id.test.ts +39 -0
  61. package/src/__tests__/distill-lock.test.ts +305 -0
  62. package/src/__tests__/distill-session-argshape.test.ts +79 -0
  63. package/src/__tests__/doctor.test.ts +319 -0
  64. package/src/__tests__/dream-pipeline.test.ts +321 -0
  65. package/src/__tests__/drill-down.test.ts +108 -0
  66. package/src/__tests__/embedder.test.ts +122 -0
  67. package/src/__tests__/emotion-decay.test.ts +46 -0
  68. package/src/__tests__/emotion-detector.test.ts +56 -0
  69. package/src/__tests__/emotion-phase1-integration.test.ts +153 -0
  70. package/src/__tests__/emotion-retrieval.test.ts +56 -0
  71. package/src/__tests__/entity-resolver.test.ts +58 -0
  72. package/src/__tests__/env-config.test.ts +151 -0
  73. package/src/__tests__/error-signature-boost.test.ts +87 -0
  74. package/src/__tests__/error-signature.test.ts +95 -0
  75. package/src/__tests__/error-taxonomy.test.ts +129 -0
  76. package/src/__tests__/ethics-layer.test.ts +476 -0
  77. package/src/__tests__/event-segmenter.test.ts +153 -0
  78. package/src/__tests__/feature-flags-integration.test.ts +54 -0
  79. package/src/__tests__/frequency-tracker.test.ts +113 -0
  80. package/src/__tests__/graph-export.test.ts +489 -0
  81. package/src/__tests__/hotness-blend.test.ts +76 -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-consolidation.test.ts +226 -0
  88. package/src/__tests__/maintenance-locks.test.ts +174 -0
  89. package/src/__tests__/mcp-efficiency.test.ts +288 -0
  90. package/src/__tests__/mcp-related-scopes.test.ts +151 -0
  91. package/src/__tests__/memory-boundaries.test.ts +250 -0
  92. package/src/__tests__/memory-evolution-b2-b3-e1.test.ts +345 -0
  93. package/src/__tests__/memory-evolution.test.ts +271 -0
  94. package/src/__tests__/memory-health-rebalance.test.ts +144 -0
  95. package/src/__tests__/memory-lint.test.ts +421 -0
  96. package/src/__tests__/memory-output.test.ts +215 -0
  97. package/src/__tests__/memory-promotion.test.ts +315 -0
  98. package/src/__tests__/migrate-dedup-ids.test.ts +65 -0
  99. package/src/__tests__/multi-vector.test.ts +242 -0
  100. package/src/__tests__/narrative-architecture.test.ts +456 -0
  101. package/src/__tests__/noise-filter.test.ts +205 -0
  102. package/src/__tests__/obsidian-connector.test.ts +363 -0
  103. package/src/__tests__/omitted-reporting.test.ts +98 -0
  104. package/src/__tests__/pending-review.test.ts +77 -0
  105. package/src/__tests__/pii-redact.test.ts +314 -0
  106. package/src/__tests__/post-retrieval-filter.test.ts +178 -0
  107. package/src/__tests__/ppr-traversal.test.ts +256 -0
  108. package/src/__tests__/prediction-engine.test.ts +349 -0
  109. package/src/__tests__/predictive-prospective-memory.test.ts +416 -0
  110. package/src/__tests__/preference-guard-smoke.test.ts +211 -0
  111. package/src/__tests__/preference-matcher.test.ts +230 -0
  112. package/src/__tests__/preference-slots.test.ts +205 -0
  113. package/src/__tests__/prospective-memory.test.ts +283 -0
  114. package/src/__tests__/query-entity-detector.test.ts +171 -0
  115. package/src/__tests__/query-expander-alias.test.ts +146 -0
  116. package/src/__tests__/query-expander.test.ts +34 -0
  117. package/src/__tests__/recall-governor.test.ts +227 -0
  118. package/src/__tests__/recall-mode.test.ts +64 -0
  119. package/src/__tests__/result-synthesizer.test.ts +198 -0
  120. package/src/__tests__/resume-context.test.ts +4898 -0
  121. package/src/__tests__/retrieval-trace.test.ts +213 -0
  122. package/src/__tests__/retriever-boundary-weight.test.ts +136 -0
  123. package/src/__tests__/retroactive-boost.test.ts +223 -0
  124. package/src/__tests__/salience-scoring.test.ts +106 -0
  125. package/src/__tests__/scope-inventory.test.ts +200 -0
  126. package/src/__tests__/scope-policy.test.ts +69 -0
  127. package/src/__tests__/scope-suggester.test.ts +80 -0
  128. package/src/__tests__/session-checkpoint.test.ts +291 -0
  129. package/src/__tests__/session-distiller.test.ts +640 -0
  130. package/src/__tests__/session-gc.test.ts +215 -0
  131. package/src/__tests__/skill-engine.test.ts +552 -0
  132. package/src/__tests__/skill-promotion.test.ts +601 -0
  133. package/src/__tests__/skill-verifier.test.ts +122 -0
  134. package/src/__tests__/source-heartbeat.test.ts +250 -0
  135. package/src/__tests__/store-patch-metadata.test.ts +205 -0
  136. package/src/__tests__/store-scope-audit.test.ts +81 -0
  137. package/src/__tests__/store-upsert.test.ts +184 -0
  138. package/src/__tests__/supersede-chain.test.ts +154 -0
  139. package/src/__tests__/temporal-parser.test.ts +241 -0
  140. package/src/__tests__/temporal-validity.test.ts +160 -0
  141. package/src/__tests__/term-registry.test.ts +121 -0
  142. package/src/__tests__/tool-error-surfacing.test.ts +98 -0
  143. package/src/__tests__/tool-output-compressor.test.ts +160 -0
  144. package/src/__tests__/topic-tag.test.ts +113 -0
  145. package/src/__tests__/trusted-memory.test.ts +839 -0
  146. package/src/__tests__/usage-tracker.test.ts +287 -0
  147. package/src/__tests__/version-manager.test.ts +213 -0
  148. package/src/__tests__/workflow-observation-skill-outcome.test.ts +352 -0
  149. package/src/__tests__/workflow-observation.test.ts +433 -0
  150. package/src/__tests__/write-verifier.test.ts +122 -0
  151. package/src/access-tracker.ts +38 -32
  152. package/src/activity-counter.ts +46 -18
  153. package/src/aliases.ts +133 -0
  154. package/src/api-server.ts +27 -7
  155. package/src/audit-log.ts +4 -2
  156. package/src/auto-consolidation.ts +8 -3
  157. package/src/auto-gc.ts +85 -21
  158. package/src/capture-engine.ts +54 -21
  159. package/src/cli.ts +199 -37
  160. package/src/confidence-tracker.ts +36 -18
  161. package/src/connector-types.ts +57 -0
  162. package/src/consolidation-engine.ts +109 -11
  163. package/src/context-composer.ts +8 -1
  164. package/src/conversation-importer.ts +25 -1
  165. package/src/data-checkup.ts +126 -7
  166. package/src/distill-lock.ts +194 -15
  167. package/src/doctor.ts +38 -2
  168. package/src/dream-pipeline.ts +118 -34
  169. package/src/env-config.ts +77 -0
  170. package/src/error-signature.ts +153 -0
  171. package/src/error-taxonomy.ts +115 -0
  172. package/src/eval.ts +251 -6
  173. package/src/idempotency.ts +8 -0
  174. package/src/ingest.ts +234 -10
  175. package/src/kg-extractor.ts +6 -2
  176. package/src/kg-store.ts +236 -44
  177. package/src/llm-consolidation.ts +2 -1
  178. package/src/mcp-server.ts +85 -1799
  179. package/src/mcp-tool-deps.ts +48 -0
  180. package/src/mcp-tools-advanced.ts +941 -0
  181. package/src/mcp-tools-core.ts +633 -0
  182. package/src/mcp-tools-governance.ts +475 -0
  183. package/src/memory-boundaries.ts +3 -1
  184. package/src/memory-evolution.ts +73 -1
  185. package/src/memory-lint.ts +183 -10
  186. package/src/memory-output.ts +114 -3
  187. package/src/memory-promotion.ts +246 -0
  188. package/src/memory-schema.ts +25 -2
  189. package/src/memory-store-port.ts +43 -0
  190. package/src/multi-vector.ts +2 -1
  191. package/src/narrative-schema.ts +2 -1
  192. package/src/noise-filter.ts +57 -6
  193. package/src/obsidian-connector.ts +282 -0
  194. package/src/pii-detector.ts +157 -5
  195. package/src/query-expander.ts +90 -9
  196. package/src/result-synthesizer.ts +2 -1
  197. package/src/retention-policy.ts +2 -1
  198. package/src/retriever.ts +259 -139
  199. package/src/runtime-config.ts +8 -1
  200. package/src/scope-suggester.ts +113 -0
  201. package/src/session-distiller.ts +23 -2
  202. package/src/session-engine.ts +2 -1
  203. package/src/session-schema.ts +3 -2
  204. package/src/session-store.ts +5 -0
  205. package/src/skill-engine.ts +78 -0
  206. package/src/skill-promotion.ts +247 -47
  207. package/src/skill-schema.ts +16 -3
  208. package/src/skill-verifier.ts +152 -0
  209. package/src/source-heartbeat.ts +108 -0
  210. package/src/store.ts +267 -38
  211. package/src/tool-output-compressor.ts +3 -1
  212. package/src/ui-server.ts +2 -1
  213. package/src/usage-tracker.ts +242 -0
  214. package/src/version-manager.ts +3 -2
  215. package/src/workflow-observation-engine.ts +2 -1
  216. package/src/workflow-observation-schema.ts +2 -1
  217. package/src/workflow-observation-store.ts +6 -2
  218. package/.env +0 -4
  219. package/CLAUDE.md +0 -46
  220. package/config.json +0 -55
  221. package/daily-recall.sh +0 -110
  222. package/logs/daily-recall.error.log +0 -0
  223. package/logs/daily-recall.log +0 -30
  224. package/logs/ingest-2026-04-11.log +0 -110
  225. package/logs/ingest-2026-04-12.log +0 -90
  226. package/logs/launchagent-stderr.log +0 -0
  227. package/logs/launchagent-stdout.log +0 -0
package/src/retriever.ts CHANGED
@@ -6,7 +6,10 @@
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";
@@ -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,
@@ -189,6 +194,12 @@ export interface RetrievalResult extends MemorySearchResult {
189
194
  */
190
195
  export type RetrievalResultSet = RetrievalResult[] & {
191
196
  reconstruction?: ReconstructionOutput;
197
+ /**
198
+ * P-omitted: 因 limit 上限被截断、未返回的达标结果数(提高 limit 可见)。
199
+ * 仅统计 finalizeResults 的 limit 截断,不含质量过滤(min-score/noise/MMR)丢弃——
200
+ * 那些提高 limit 也拿不到,报告出来反而误导调用方。
201
+ */
202
+ omitted?: { count: number; reason: "limit" };
192
203
  };
193
204
 
194
205
  // ============================================================================
@@ -209,13 +220,21 @@ export const DEFAULT_CATEGORY_MIN_SCORES: Record<string, number> = {
209
220
  patterns: 0.45,
210
221
  };
211
222
 
223
+ // P1.2(2026-06-25):默认 vector-only。canary eval 实测个人记忆库 vector 召回质量全面优于
224
+ // hybrid(robustness 带 scope WITH 6/12 + 不带 scope WITHOUT 10/12,完胜 hybrid 的 3/9;BM25 在
225
+ // 高度同质的个人记忆里是净噪声,会把 vector 本应 top 的目标如 dbcb317a 挤掉)。改频率层救低频
226
+ // 目标实测是死结(动 hotness 必伤靠 hotness 上位的记忆)。hybrid 路径代码全保留,mode 改回
227
+ // "hybrid" 即可切换(注:multiHopExpand 仍硬编码走 hybrid,默认 off,config.retrieval.multiHop
228
+ // 开了会破纯 vector)。数据盲区:canary 全是语义 query,未覆盖 BM25 强项的精确关键词/token 召回
229
+ // (变量名 / commit hash / alias-map 昵称)——待补 exact-token canary 验证。
212
230
  export const DEFAULT_RETRIEVAL_CONFIG: RetrievalConfig = {
213
- mode: "hybrid",
231
+ mode: "vector",
214
232
  vectorWeight: 0.7,
215
233
  bm25Weight: 0.3,
216
234
  minScore: 0.3,
217
235
  rerank: "cross-encoder",
218
- candidatePoolSize: 20,
236
+ candidatePoolSize: 30,
237
+ sourceDiversity: 1,
219
238
  recencyHalfLifeDays: 14,
220
239
  recencyWeight: 0.10,
221
240
  filterNoise: true,
@@ -227,6 +246,30 @@ export const DEFAULT_RETRIEVAL_CONFIG: RetrievalConfig = {
227
246
  hotnessWeight: 0,
228
247
  };
229
248
 
249
+ /**
250
+ * P1.2+: 检测 query 是否含「精确 token」——大写常量 / commit hash / 文件名 / snake/camelCase /
251
+ * 低元音 ID(如 rwmjhb)。这类 token vectorOnly 抓不到(embedding 做语义相似非精确匹配,跟语言
252
+ * 无关:实测纯/中文/中英混 query 都漏靠 token 区分的目标),需 BM25 兜底。刻意避开元音正常的普通词
253
+ * (mem0/recallnest/GitHub),避免日常语义 query 误触发 BM25 噪声。
254
+ */
255
+ export function hasExactToken(query: string): boolean {
256
+ for (const t of query.split(/[\s,。、;:()()【】「」"'`??!!]+/)) {
257
+ if (t.length < 5) continue;
258
+ // 全大写 + 下划线常量(必须含下划线,避免 JSON/HTTP/GitHub 等普通缩写/驼峰误触发)
259
+ if (/^[A-Z][A-Z0-9]*_[A-Z0-9_]+$/.test(t)) return true; // DEDUP_HARD_THRESHOLD
260
+ // snake_case 标识符(含下划线)
261
+ if (/^[a-z][a-z0-9]*_[a-z0-9_]+$/i.test(t)) return true; // capture_engine
262
+ // 文件名(基名 + 代码扩展名)
263
+ 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
264
+ // hex commit hash(7-40,含数字+字母,纯 hex)
265
+ if (/^[0-9a-f]{7,40}$/i.test(t) && /[0-9]/.test(t) && /[a-f]/i.test(t)) return true; // dd12fb5
266
+ // 注:camelCase(GitHub/TypeScript)与低元音 ID(rwmjhb/pytest/nginx)两条宽规则已移除——
267
+ // 前者误伤日常技术 query 引回 BM25 噪声;rwmjhb 类纯小写 ID 即便触发,fts 中英混分词也召回不到
268
+ // (根因在 tokenizer,是独立 backlog),靠中英一起 query 的中文锚点已能补回。
269
+ }
270
+ return false;
271
+ }
272
+
230
273
  // ============================================================================
231
274
  // Utility Functions
232
275
  // ============================================================================
@@ -314,6 +357,32 @@ function applyAnchorBoost(results: RetrievalResult[], query: string): RetrievalR
314
357
  });
315
358
  }
316
359
 
360
+ /** Multiplicative boost applied to a case on exact error-signature match. */
361
+ const ERROR_SIGNATURE_BOOST_FACTOR = 1.5;
362
+
363
+ /**
364
+ * A1 (P3-A): error-signature exact-match boost. When RECALLNEST_ERROR_SIGNATURE_BOOST
365
+ * is on, case memories whose metadata.error_signature overlaps the query's extracted
366
+ * signatures get a strong multiplicative boost ("上次一模一样的报错"精确召回). Flag off
367
+ * (default) returns the input unchanged — bit-identical, so the pure-move eval baseline holds.
368
+ * Only affects category="cases"; read and query side share extractErrorSignatures so the
369
+ * signatures compare apples-to-apples.
370
+ */
371
+ export function applyErrorSignatureBoost(results: RetrievalResult[], query: string): RetrievalResult[] {
372
+ if (!envConfig.errorSignatureBoost()) return results;
373
+ const querySignatures = extractErrorSignatures({ problem: query });
374
+ if (querySignatures.length === 0) return results;
375
+ const querySet = new Set(querySignatures.map(s => s.toLowerCase()));
376
+ return results.map(r => {
377
+ if (r.entry.category !== "cases") return r;
378
+ const sigs = parseMetadata(r.entry.metadata).error_signature;
379
+ if (!Array.isArray(sigs) || sigs.length === 0) return r;
380
+ const hit = sigs.some(s => typeof s === "string" && querySet.has(s.toLowerCase()));
381
+ if (!hit) return r;
382
+ return { ...r, score: Math.min(1, Math.max(0, r.score * ERROR_SIGNATURE_BOOST_FACTOR)) };
383
+ });
384
+ }
385
+
317
386
  /**
318
387
  * Emotion scoring stage: boost memories matching query emotional tone.
319
388
  * No-op when query is neutral or memory lacks emotion data.
@@ -668,6 +737,16 @@ export class MemoryRetriever {
668
737
  this.llmClient = client;
669
738
  }
670
739
 
740
+ /**
741
+ * Shadow-record which memories a reconstruction actually cited via [src:ID].
742
+ * 影子统计:写 metadata.usage(injection-vs-use 双计数),第一阶段不参与 ranking/forget。
743
+ * best-effort:不传 scope(影子写入不做权限过滤,避免跨 scope 扩展候选触发越权 throw);
744
+ * 内部 allSettled 不抛错,绝不阻断主检索/resume 流程。
745
+ */
746
+ async recordReconstructionUsage(citedIds: string[]): Promise<void> {
747
+ await runUsageShadow(this.store, citedIds);
748
+ }
749
+
671
750
  /** Suppress a memory ID for the current session (score × 0.3). */
672
751
  suppressForSession(id: string): void {
673
752
  this.sessionSuppressed.add(id);
@@ -687,14 +766,18 @@ export class MemoryRetriever {
687
766
  }
688
767
 
689
768
  async retrieve(context: RetrievalContext): Promise<RetrievalResultSet> {
690
- const { query, limit, scopeFilter, category, includeArchived, trace, graph } = context;
769
+ const { query: rawQuery, limit, scopeFilter, category, includeArchived, trace, graph } = context;
691
770
  const safeLimit = clampInt(limit, 1, 20);
692
771
 
693
772
  // Adaptive retrieval: skip trivial queries to save embedding API calls
694
- if (shouldSkipRetrieval(query)) {
773
+ if (shouldSkipRetrieval(rawQuery)) {
695
774
  return [];
696
775
  }
697
776
 
777
+ // Step 2: alias expansion — append canonical entity tokens for matching colloquial phrases.
778
+ // 不改原 query,仅追加,保留 vector embedding 原意;BM25 拿到更多锚点。
779
+ const query = expandQueryWithAliases(rawQuery);
780
+
698
781
  let results: RetrievalResult[];
699
782
 
700
783
  // For vector-only mode, use legacy behavior
@@ -705,6 +788,28 @@ export class MemoryRetriever {
705
788
  results = await this.hybridRetrieval(query, safeLimit, scopeFilter, category, includeArchived, trace, graph);
706
789
  }
707
790
 
791
+ // 0-hit fallback: minScore filter 把所有候选过滤光时,放宽阈值(=0)重试一次。
792
+ // 只在原 path 0 hit 触发;正常召回不受影响。
793
+ if (results.length === 0) {
794
+ if (this.config.mode === "vector" || !this.store.hasFtsSupport) {
795
+ results = await this.vectorOnlyRetrieval(query, safeLimit, scopeFilter, category, includeArchived, trace, 0);
796
+ } else {
797
+ results = await this.hybridRetrieval(query, safeLimit, scopeFilter, category, includeArchived, trace, graph, 0);
798
+ }
799
+ }
800
+
801
+ // P-omitted: 快照 finalize 阶段因 limit 截断的达标结果数(提高 limit 可见)。
802
+ // 必须在后续 validity/topicTag filter(以及 BM25 补偿等换数组操作)丢失 ResultSet 属性之前读取。
803
+ let omittedByLimit = (results as RetrievalResultSet).omitted?.count ?? 0;
804
+
805
+ // P1.2+: exact-token BM25 补偿——query 含精确 token(大写常量/commit hash/.ts 文件名/camelCase/
806
+ // 低元音 ID 如 rwmjhb)时,vector 漏掉的精确命中用 BM25 补回(vectorOnly 做语义相似、不做精确
807
+ // 字符串匹配,是 embedding 本质,跟语言无关)。普通词(mem0/recallnest/GitHub)不触发,日常
808
+ // 语义召回不受影响。
809
+ if (hasExactToken(rawQuery)) {
810
+ results = await this.supplementExactTokenWithBm25(query, results, safeLimit, scopeFilter, category, includeArchived);
811
+ }
812
+
708
813
  // LME-2: Multi-hop retrieval — extract entities from first-pass results,
709
814
  // run focused follow-up queries, merge to improve cross-session coverage.
710
815
  const useMultiHop = context.multiHop ?? this.config.multiHop ?? false;
@@ -773,7 +878,11 @@ export class MemoryRetriever {
773
878
 
774
879
  // Phase 4: Constructive retrieval — first-class reconstruction (no metadata hack)
775
880
  const resultSet = results as RetrievalResultSet;
776
- const constructiveFlag = process.env.RECALLNEST_CONSTRUCTIVE_RETRIEVAL === "true";
881
+ // P-omitted: 把快照的 limit 截断数挂回最终 resultSet(validity filter 已丢原数组属性)。
882
+ if (omittedByLimit > 0) {
883
+ resultSet.omitted = { count: omittedByLimit, reason: "limit" };
884
+ }
885
+ const constructiveFlag = envConfig.constructiveRetrieval();
777
886
  if (shouldReconstruct({
778
887
  flagEnabled: constructiveFlag,
779
888
  callerOptIn: context.reconstruct === true,
@@ -790,6 +899,8 @@ export class MemoryRetriever {
790
899
  expansionDeps,
791
900
  );
792
901
  resultSet.reconstruction = reconstruction;
902
+ // Shadow usage:记录被 reconstruction 真正引用的记忆(use 信号),不动 ranking/forget。
903
+ await this.recordReconstructionUsage(reconstruction.sources.map(s => s.id));
793
904
  } catch {
794
905
  // Silent degradation — return raw results
795
906
  }
@@ -798,6 +909,36 @@ export class MemoryRetriever {
798
909
  return resultSet;
799
910
  }
800
911
 
912
+ /**
913
+ * P1.2+: exact-token BM25 补偿。vectorOnly 主路径后,若 query 含精确 token,用 BM25 精确匹配把
914
+ * vector 漏掉的命中补回。hasFtsSupport 在 vectorOnly 触发 store init 后为真;fake store 为假时跳过。
915
+ * bm25 精确命中提到结果前(exact-token 意图=精确优先),vector 语义结果跟后,去重后 slice limit。
916
+ */
917
+ private async supplementExactTokenWithBm25(
918
+ query: string,
919
+ vectorResults: RetrievalResult[],
920
+ limit: number,
921
+ scopeFilter?: string[],
922
+ category?: string,
923
+ includeArchived?: boolean,
924
+ ): Promise<RetrievalResult[]> {
925
+ if (!this.store.hasFtsSupport) return vectorResults;
926
+ const ftsQuery = tokenizeFts(expandQuery(query), detectLang(query));
927
+ const bm25 = await this.runBM25Search(ftsQuery, Math.max(limit, 5), scopeFilter, category, includeArchived);
928
+ const vecIds = new Set(vectorResults.map(r => r.entry.id));
929
+ const supplements = bm25
930
+ .filter(b => !vecIds.has(b.entry.id))
931
+ .filter(b => isActiveMemory(b.entry.metadata)) // 排掉 superseded/archived/consolidated(补偿绕过了 finalize 的尾部过滤,这里补回 active 校验)
932
+ .slice(0, 3)
933
+ .map(b => ({
934
+ entry: b.entry,
935
+ score: b.score,
936
+ sources: { bm25: { score: b.score, rank: b.rank } },
937
+ } as RetrievalResult));
938
+ if (supplements.length === 0) return vectorResults;
939
+ return [...supplements, ...vectorResults].slice(0, limit);
940
+ }
941
+
801
942
  /**
802
943
  * LME-2: Multi-hop expansion — extract entities from first-pass results,
803
944
  * run entity-focused follow-up queries, merge with original results.
@@ -860,6 +1001,7 @@ export class MemoryRetriever {
860
1001
  category?: string,
861
1002
  includeArchived?: boolean,
862
1003
  trace?: TraceCollector,
1004
+ minScoreOverride?: number,
863
1005
  ): Promise<RetrievalResult[]> {
864
1006
  // Temporal reasoning for vector-only mode
865
1007
  const temporal = parseTemporalQuery(query);
@@ -867,12 +1009,20 @@ export class MemoryRetriever {
867
1009
 
868
1010
  trace?.startStage("vector_search", 0);
869
1011
  const queryVector = await this.embedder.embedQuery(searchQuery);
870
- // P0.1: Lower minScore for short queries
1012
+ // P0.1: Lower minScore for short queries; minScoreOverride 用于 0-hit fallback retry
871
1013
  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);
1014
+ const vectorMinScore = minScoreOverride !== undefined
1015
+ ? minScoreOverride
1016
+ : (shortQ
1017
+ ? this.config.minScore * SHORT_QUERY_MIN_SCORE_FACTOR
1018
+ : this.config.minScore);
1019
+ // P-omitted: over-fetch a wider candidate pool so finalizeResults can detect limit-capped
1020
+ // omissions. Previously fetched exactly `limit`, so vector-only omitted was always 0
1021
+ // (hybrid already over-fetches candidatePoolSize). Cost is minimal — ANN with a larger K
1022
+ // plus a few more local scoring passes, no extra API; P1.2's vector-only savings come from
1023
+ // skipping hybrid BM25/fusion/rerank, not from fetch size.
1024
+ const fetchLimit = Math.max(limit, this.config.candidatePoolSize ?? limit);
1025
+ const results = await this.store.vectorSearch(queryVector, fetchLimit, vectorMinScore, scopeFilter);
876
1026
  trace?.endStage(results.length, results.map(r => r.score));
877
1027
 
878
1028
  // Filter by category if specified
@@ -900,103 +1050,18 @@ export class MemoryRetriever {
900
1050
  // P0.1: Anchor boost for vector-only path
901
1051
  const anchorBoosted = applyAnchorBoost(mapped, searchQuery);
902
1052
 
903
- trace?.startStage("recency_boost", anchorBoosted.length);
904
- const boosted = this.applyRecencyBoost(anchorBoosted);
905
- trace?.endStage(boosted.length, boosted.map(r => r.score));
1053
+ const scored = this.applySharedScoring(anchorBoosted, query, trace);
906
1054
 
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));
1055
+ // Session suppression: temporarily penalize "do not disturb" memories (vector-only path)
1056
+ const afterSuppression = this.applySessionSuppression(scored);
926
1057
 
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;
994
-
995
- trace?.startStage("final_limit", diversified.length);
996
- const final = diversified.slice(0, limit);
997
- trace?.endStage(final.length, final.map(r => r.score));
998
-
999
- return final;
1058
+ return this.finalizeResults(afterSuppression, {
1059
+ shortQ,
1060
+ minScoreOverride,
1061
+ includeArchived,
1062
+ limit,
1063
+ trace,
1064
+ });
1000
1065
  }
1001
1066
 
1002
1067
  private async hybridRetrieval(
@@ -1007,6 +1072,7 @@ export class MemoryRetriever {
1007
1072
  includeArchived?: boolean,
1008
1073
  trace?: TraceCollector,
1009
1074
  graph?: boolean,
1075
+ minScoreOverride?: number,
1010
1076
  ): Promise<RetrievalResult[]> {
1011
1077
  // Adaptive pool: widen candidate pool for aggregation queries ("how many", "all the")
1012
1078
  const multiplier = (this.config.adaptivePoolMultiplier ?? 1) > 1 && isAggregationQuery(query)
@@ -1070,10 +1136,13 @@ export class MemoryRetriever {
1070
1136
 
1071
1137
  // Apply minimum score threshold
1072
1138
  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;
1139
+ // P0.1: Lower minScore for short queries to widen the candidate pool;
1140
+ // minScoreOverride 用于 retrieve() 顶层的 0-hit fallback retry。
1141
+ const effectiveMinScore = minScoreOverride !== undefined
1142
+ ? minScoreOverride
1143
+ : (isShortQuery(searchQuery)
1144
+ ? this.config.minScore * SHORT_QUERY_MIN_SCORE_FACTOR
1145
+ : this.config.minScore);
1077
1146
  const filtered = anchorBoosted.filter(r => r.score >= effectiveMinScore);
1078
1147
  trace?.endStage(filtered.length, filtered.map(r => r.score));
1079
1148
 
@@ -1084,46 +1153,61 @@ export class MemoryRetriever {
1084
1153
  : filtered;
1085
1154
  trace?.endStage(reranked.length, reranked.map(r => r.score));
1086
1155
 
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));
1156
+ const scored = this.applySharedScoring(reranked, query, trace);
1157
+
1158
+ return this.finalizeResults(scored, {
1159
+ shortQ,
1160
+ minScoreOverride,
1161
+ includeArchived,
1162
+ limit,
1163
+ trace,
1164
+ });
1165
+ }
1166
+
1167
+ /**
1168
+ * Shared scoring pipeline — the 12 stages identical across the vector-only and
1169
+ * hybrid paths. Order is load-bearing (Codex §3.6 "顺序即语义"); do not reorder.
1170
+ * `query` feeds the emotion stage; `trace` carries per-stage trace spans.
1171
+ * Path-specific stages stay in the caller: anchor / min_score / rerank / multi-vector
1172
+ * blend run BEFORE this; vector-only session suppression runs AFTER this.
1173
+ */
1174
+ private applySharedScoring(
1175
+ results: RetrievalResult[],
1176
+ query: string,
1177
+ trace?: TraceCollector,
1178
+ ): RetrievalResult[] {
1179
+ trace?.startStage("recency_boost", results.length);
1180
+ const boosted = this.applyRecencyBoost(results);
1181
+ trace?.endStage(boosted.length, boosted.map(r => r.score));
1091
1182
 
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));
1183
+ trace?.startStage("importance_weight", boosted.length);
1184
+ const weighted = this.applyImportanceWeight(boosted);
1185
+ trace?.endStage(weighted.length, weighted.map(r => r.score));
1096
1186
 
1097
- // Viewpoint confidence: penalize corrected/contradicted memories
1098
- trace?.startStage("confidence_weight", importanceWeighted.length);
1099
- const confidenceWeighted = this.applyConfidence(importanceWeighted);
1187
+ trace?.startStage("confidence_weight", weighted.length);
1188
+ const confidenceWeighted = this.applyConfidence(weighted);
1100
1189
  trace?.endStage(confidenceWeighted.length, confidenceWeighted.map(r => r.score));
1101
1190
 
1102
- // Emotion scoring: boost memories matching query emotional tone
1103
- let afterEmotion = confidenceWeighted;
1191
+ let afterEmotionVector = confidenceWeighted;
1104
1192
  if (isEmotionScoringEnabled()) {
1105
1193
  const queryEmotion = detectEmotion(query);
1106
1194
  const adapted = confidenceWeighted.map(r => ({ ...r, metadata: r.entry.metadata ?? "{}" }));
1107
1195
  const emotioned = applyEmotionWeight(adapted, queryEmotion);
1108
- afterEmotion = confidenceWeighted.map((r, i) => ({ ...r, score: emotioned[i]?.score ?? r.score }));
1196
+ afterEmotionVector = confidenceWeighted.map((r, i) => ({ ...r, score: emotioned[i]?.score ?? r.score }));
1109
1197
  }
1110
1198
 
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);
1199
+ trace?.startStage("boundary_weight", afterEmotionVector.length);
1200
+ const boundaryWeighted = this.applyBoundaryWeight(afterEmotionVector);
1114
1201
  trace?.endStage(boundaryWeighted.length, boundaryWeighted.map(r => r.score));
1115
1202
 
1116
- // Separate pinned assets from synthesized briefs.
1117
1203
  trace?.startStage("asset_type_weight", boundaryWeighted.length);
1118
1204
  const assetWeighted = this.applyAssetTypeWeight(boundaryWeighted);
1119
1205
  trace?.endStage(assetWeighted.length, assetWeighted.map(r => r.score));
1120
1206
 
1121
- // Apply length normalization (penalize long entries dominating via keyword density)
1122
1207
  trace?.startStage("length_norm", assetWeighted.length);
1123
1208
  const lengthNormalized = this.applyLengthNormalization(assetWeighted);
1124
1209
  trace?.endStage(lengthNormalized.length, lengthNormalized.map(r => r.score));
1125
1210
 
1126
- // Apply time decay (penalize stale entries)
1127
1211
  trace?.startStage("time_decay", lengthNormalized.length);
1128
1212
  const timeDecayed = this.applyTimeDecay(lengthNormalized);
1129
1213
  trace?.endStage(timeDecayed.length, timeDecayed.map(r => r.score));
@@ -1144,12 +1228,33 @@ export class MemoryRetriever {
1144
1228
  const frequencyBoosted = this.applyFrequencyBoost(hotnessBlended);
1145
1229
  trace?.endStage(frequencyBoosted.length, frequencyBoosted.map(r => r.score));
1146
1230
 
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);
1231
+ // A1 error-signature 精确匹配 boost(flag 门控,默认 off → no-op、bit-identical)
1232
+ return applyErrorSignatureBoost(frequencyBoosted, query);
1233
+ }
1234
+
1235
+ /**
1236
+ * Shared finalization tail — identical across both paths: sort → hard min →
1237
+ * evolution-status filter → noise → RIF → MMR → version/cluster dedup →
1238
+ * source diversity → limit. `shortQ` must be the value derived from the
1239
+ * path's searchQuery (do not recompute from the raw query here).
1240
+ */
1241
+ private finalizeResults(
1242
+ results: RetrievalResult[],
1243
+ opts: {
1244
+ shortQ: boolean;
1245
+ minScoreOverride?: number;
1246
+ includeArchived?: boolean;
1247
+ limit: number;
1248
+ trace?: TraceCollector;
1249
+ },
1250
+ ): RetrievalResult[] {
1251
+ const { shortQ, minScoreOverride, includeArchived, limit, trace } = opts;
1252
+ const rescored = [...results].sort((a, b) => b.score - a.score);
1150
1253
 
1151
1254
  trace?.startStage("hard_min_score", rescored.length);
1152
- const hardFiltered = rescored.filter(r => r.score >= this.minScoreFor(r.entry.category, shortQ));
1255
+ const hardFiltered = minScoreOverride !== undefined
1256
+ ? rescored
1257
+ : rescored.filter(r => r.score >= this.minScoreFor(r.entry.category, shortQ));
1153
1258
  trace?.endStage(hardFiltered.length, hardFiltered.map(r => r.score));
1154
1259
 
1155
1260
  // Evolution status filter: exclude superseded/archived/consolidated memories
@@ -1157,7 +1262,6 @@ export class MemoryRetriever {
1157
1262
  ? hardFiltered
1158
1263
  : hardFiltered.filter(r => isActiveMemory(r.entry.metadata));
1159
1264
 
1160
- // Filter noise
1161
1265
  trace?.startStage("noise_filter", evolutionFiltered.length);
1162
1266
  const denoised = this.config.filterNoise
1163
1267
  ? filterNoise(evolutionFiltered, r => r.entry.text)
@@ -1189,6 +1293,16 @@ export class MemoryRetriever {
1189
1293
  const final = diversified.slice(0, limit);
1190
1294
  trace?.endStage(final.length, final.map(r => r.score));
1191
1295
 
1296
+ // P-omitted: 从 source-diversity 之前的候选池(clusterDeduped)算 limit 截断数。
1297
+ // 若启用 sourceDiversity,applySourceDiversity 会先把 diversified 压到 limit,
1298
+ // 用 diversified.length 会让 omitted 恒为 0(Codex 复审 P3-1,与 vector-only 同类)。
1299
+ // 注意:clusterDeduped 仍可能含将被 retrieve 层 validity filter 过滤的 expired 项,
1300
+ // 故 omitted 是“上界估计”,输出层用 "up to N" 措辞反映(P3-2)。
1301
+ const omittedByLimit = Math.max(0, clusterDeduped.length - final.length);
1302
+ if (omittedByLimit > 0) {
1303
+ (final as RetrievalResultSet).omitted = { count: omittedByLimit, reason: "limit" };
1304
+ }
1305
+
1192
1306
  return final;
1193
1307
  }
1194
1308
 
@@ -1293,9 +1407,13 @@ export class MemoryRetriever {
1293
1407
  const objectScore = entityScoreMap.get(triple.object) ?? 0;
1294
1408
  const tripleScore = Math.max(subjectScore, objectScore) * triple.confidence;
1295
1409
 
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));
1410
+ if (tripleScore > 0) {
1411
+ // All contributing memories share the fact's score — not just the
1412
+ // first contributor (multi-source triples after mention counting)
1413
+ for (const mid of parseSourceIds(triple.source_memory_ids, triple.source_memory_id)) {
1414
+ const existing = memoryScoreMap.get(mid) ?? 0;
1415
+ memoryScoreMap.set(mid, Math.max(existing, tripleScore));
1416
+ }
1299
1417
  }
1300
1418
  }
1301
1419
  }
@@ -1831,8 +1949,10 @@ export class MemoryRetriever {
1831
1949
  const existingIds = new Set(results.map(r => r.entry.id));
1832
1950
  for (const n of neighborhood) {
1833
1951
  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);
1952
+ // Expand through ALL contributing memories of each fact, not just
1953
+ // the first contributor (multi-source triples after mention counting)
1954
+ for (const mid of parseSourceIds(t.source_memory_ids, t.source_memory_id)) {
1955
+ if (!existingIds.has(mid)) sourceIds.add(mid);
1836
1956
  }
1837
1957
  }
1838
1958
  }
@@ -11,6 +11,7 @@ import { AccessTracker } from "./access-tracker.js";
11
11
  import { FrequencyTracker } from "./frequency-tracker.js";
12
12
  import { createLLMClient, type LLMClient, type LLMConfig } from "./llm-client.js";
13
13
  import { logInfo } from "./stderr-log.js";
14
+ import * as envConfig from "./env-config.js";
14
15
 
15
16
  export type RecallMode = "full" | "light" | "summary" | "off";
16
17
 
@@ -33,6 +34,12 @@ export interface LocalMemoryConfig {
33
34
  };
34
35
  sources: Record<string, { path: string; glob: string; description: string }>;
35
36
  retrieval?: Partial<RetrievalConfig>;
37
+ /**
38
+ * Opt-in cross-scope sidecar map for search_memory.
39
+ * Main scoped search remains isolated; callers must pass includeRelatedScopes
40
+ * before configured related scopes are queried and shown separately.
41
+ */
42
+ scopeRelations?: Record<string, string[]>;
36
43
  /**
37
44
  * Default depth for auto-recall injection.
38
45
  * - "full": inject complete text (default, backward compatible)
@@ -179,7 +186,7 @@ export function resolveRecallMode(config: LocalMemoryConfig, perCallOverride?: s
179
186
  if (perCallOverride && VALID_RECALL_MODES.includes(perCallOverride as RecallMode)) {
180
187
  return perCallOverride as RecallMode;
181
188
  }
182
- const envMode = process.env.RECALLNEST_RECALL_MODE;
189
+ const envMode = envConfig.recallModeRaw();
183
190
  if (envMode && VALID_RECALL_MODES.includes(envMode as RecallMode)) {
184
191
  return envMode as RecallMode;
185
192
  }