@remnic/core 9.3.746 → 9.3.747

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.
@@ -0,0 +1,1710 @@
1
+ /**
2
+ * Recall search-pipeline coordinator — extracted from the orchestrator
3
+ * (issue #1526, seam 19).
4
+ *
5
+ * Owns the QMD search post-processing helpers that feed recall assembly:
6
+ * - query-aware prefiltering and artifact top-up fetch
7
+ * - embedding/archive/cold-collection fallback pipelines
8
+ * - recall-safety filtering and memory-map loading
9
+ * - result boosting (recency, access, importance, relevance)
10
+ *
11
+ * Behavior-preserving move from orchestrator.ts. No logic changes — the
12
+ * orchestrator keeps thin delegating methods (the private API callers and
13
+ * tests keep working), and every member the moved code consults flows back
14
+ * through RecallSearchPipelineDeps into the orchestrator's own overridable
15
+ * members, so instance-level test stubs keep taking effect (the same
16
+ * late-binding rule as seam 18).
17
+ */
18
+
19
+ import { createHash } from "node:crypto";
20
+ import path from "node:path";
21
+ import { type CapabilitySet, type GraphConstructionCapabilitySet, resolveCapabilities, resolveConversationContextCapabilities, resolveGraphConstructionCapabilities, resolveIndexingCapabilities, resolveMemoryLifecycleCapabilities, resolveNamespaceCapabilities, resolvePipelineProcessingCapabilities, resolveQmdCapabilities, resolveRecallEnhancementCapabilities } from "../capabilities.js";
22
+ import { EmbeddingFallback } from "../embedding-fallback.js";
23
+ import { StorageManager } from "../index.js";
24
+ import { inferIntentFromText, intentCompatibilityScore } from "../intent.js";
25
+ import { log } from "../logger.js";
26
+ import { NamespaceStorageRouter } from "../namespaces/storage.js";
27
+ import { NegativeExampleStore } from "../negative.js";
28
+ import { qmdCollectionPathParts } from "./qmd-result-resolver.js";
29
+ import type { GraphRecallExpandedEntry } from "../recall-state.js";
30
+ import { getDefaultArchiveScoring, memoryFileToScoreItem } from "../recall/archive-scoring.js";
31
+ import { RelevanceStore } from "../relevance.js";
32
+ import { RerankCache, rerankLocalOrNoop } from "../rerank.js";
33
+ import type { SearchBackend, SearchDegradation, SearchExecutionOptions, SearchQueryOptions } from "../search/port.js";
34
+ import { SecureStoreLockedError } from "../secure-store/index.js";
35
+ import { isPathInsideStorageRoot } from "../storage-paths.js";
36
+ import { extractTagsFromPrompt, isTemporalQuery, queryByDateRangeAsync, queryByTagsAsync, recencyWindowFromPrompt, resolvePromptTagPrefilterAsync } from "../temporal-index.js";
37
+ import { shouldFilterSupersededFromRecall } from "../temporal-supersession.js";
38
+ import { isValidAsOf, isValidityExpiredNow } from "../temporal-validity.js";
39
+ import type { TrustStageResultItem } from "../trust-score-stage.js";
40
+ import type { MemoryFile, PluginConfig, QmdSearchResult, RecallPlanMode } from "../types.js";
41
+ import { type UtilityRuntimeValues, applyUtilityRankingRuntimeDelta } from "../utility-runtime.js";
42
+ import {
43
+ computeArtifactCandidateFetchLimit,
44
+ lifecycleRecallScoreAdjustment,
45
+ shouldFilterLifecycleRecallCandidate,
46
+ computeQmdHybridFetchLimit,
47
+ filterRecallCandidates,
48
+ isArtifactMemoryPath,
49
+ throwIfRecallAborted,
50
+ tokenizeRecallQuery,
51
+ type QmdRecallSnapshot,
52
+ type QueryAwarePrefilter,
53
+ } from "../orchestrator.js";
54
+
55
+ export interface RecallSearchPipelineDeps {
56
+ applyMemoryWorthRerank(
57
+ results: QmdSearchResult[],
58
+ namespaces: string[],
59
+ ): Promise<QmdSearchResult[]>;
60
+ applyTrustScoreRerank(
61
+ results: QmdSearchResult[],
62
+ namespaces: string[],
63
+ ): Promise<{
64
+ results: QmdSearchResult[];
65
+ trustByPath: Map<string, TrustStageResultItem> | null;
66
+ }>;
67
+ boostSearchResults(
68
+ results: QmdSearchResult[],
69
+ _recallNamespaces: string[],
70
+ prompt?: string,
71
+ preloadedMemoryMap?: Map<string, MemoryFile>,
72
+ options?: {
73
+ allowLifecycleFiltered?: boolean;
74
+ allowDedicatedSurface?: boolean;
75
+ /**
76
+ * Historical recall point in ms-since-epoch (issue #680). When
77
+ * set, drops candidates that were not authoritative at this
78
+ * instant per `temporal-validity.isValidAsOf`. Caller is
79
+ * responsible for parsing/validating the user-supplied ISO
80
+ * string at the input boundary (CLI / HTTP / MCP).
81
+ */
82
+ asOfMs?: number;
83
+ },
84
+ ): Promise<QmdSearchResult[]>;
85
+ buildConfiguredQmdSearchOptions(
86
+ queryText: string,
87
+ ): SearchQueryOptions | undefined;
88
+ buildQueryAwarePrefilter(
89
+ prompt: string,
90
+ recallNamespaces: string[],
91
+ ): Promise<QueryAwarePrefilter>;
92
+ readonly config: PluginConfig;
93
+ diversifyAndLimitRecallResults(
94
+ sectionId: string,
95
+ results: QmdSearchResult[],
96
+ limit: number,
97
+ retrievalQuery?: string,
98
+ caps?: CapabilitySet,
99
+ ): QmdSearchResult[];
100
+ effectiveRecencyWeight(): number;
101
+ readonly embeddingFallback: EmbeddingFallback;
102
+ expandResultsViaGraph(options: {
103
+ memoryResults: QmdSearchResult[];
104
+ recallNamespaces: string[];
105
+ recallResultLimit: number;
106
+ deadlineAtMs?: number | null;
107
+ includeLowConfidence?: boolean;
108
+ }): Promise<{
109
+ merged: QmdSearchResult[];
110
+ seedPaths: string[];
111
+ expandedPaths: GraphRecallExpandedEntry[];
112
+ seedResults: QmdSearchResult[];
113
+ }>;
114
+ readonly fastLlmForRerank: {
115
+ chatCompletion: (
116
+ messages: Array<{ role: string; content: string }>,
117
+ options?: { maxTokens?: number; temperature?: number; timeoutMs?: number; operation?: string; priority?: "recall-critical" | "background" },
118
+ ) => Promise<{ content: string } | null>;
119
+ };
120
+ fetchQmdMemoryResultsWithArtifactTopUp(
121
+ prompt: string,
122
+ qmdFetchLimit: number,
123
+ qmdHybridFetchLimit: number,
124
+ options: {
125
+ namespacesEnabled: boolean;
126
+ recallNamespaces: string[];
127
+ resolveNamespace: (path: string) => string;
128
+ collection?: string;
129
+ queryAwarePrefilter?: QueryAwarePrefilter;
130
+ searchOptions?: SearchQueryOptions;
131
+ onDebugSnapshot?: (snapshot: QmdRecallSnapshot) => Promise<void>;
132
+ /** Backend degradation observer, threaded into every QMD call (#1536). */
133
+ onDegradation?: (degradation: SearchDegradation) => void;
134
+ abortSignal?: AbortSignal;
135
+ },
136
+ ): Promise<QmdSearchResult[]>;
137
+ filterSearchResultsByRecallSafety(
138
+ results: QmdSearchResult[],
139
+ memoryByPath: Map<string, MemoryFile>,
140
+ options?: {
141
+ allowLifecycleFiltered?: boolean;
142
+ allowDedicatedSurface?: boolean;
143
+ asOfMs?: number;
144
+ blockedPaths?: Set<string>;
145
+ },
146
+ ): QmdSearchResult[];
147
+ filterSearchResultsForRecall(
148
+ results: QmdSearchResult[],
149
+ preloadedMemoryMap?: Map<string, MemoryFile>,
150
+ options?: {
151
+ allowLifecycleFiltered?: boolean;
152
+ allowDedicatedSurface?: boolean;
153
+ asOfMs?: number;
154
+ deadlineAtMs?: number | null;
155
+ abortSignal?: AbortSignal;
156
+ dropUnresolved?: boolean;
157
+ recallNamespaces?: readonly string[];
158
+ },
159
+ ): Promise<{ results: QmdSearchResult[]; memoryByPath: Map<string, MemoryFile> }>;
160
+ loadSearchResultMemoryMap(
161
+ results: QmdSearchResult[],
162
+ preloadedMemoryMap?: Map<string, MemoryFile>,
163
+ options?: {
164
+ deadlineAtMs?: number | null;
165
+ abortSignal?: AbortSignal;
166
+ recallNamespaces?: readonly string[];
167
+ },
168
+ ): Promise<{
169
+ memoryByPath: Map<string, MemoryFile>;
170
+ checkedPaths: Set<string>;
171
+ unreadablePaths: Set<string>;
172
+ completed: boolean;
173
+ }>;
174
+ namespaceFromPath(p: string): string;
175
+ readonly negatives: NegativeExampleStore;
176
+ readonly qmd: SearchBackend;
177
+ readArchivedMemoriesForNamespaces(
178
+ namespaces: string[],
179
+ ): Promise<MemoryFile[]>;
180
+ readQmdResultMemory(
181
+ resultPath: string,
182
+ fallbackStorage: StorageManager,
183
+ recallNamespaces?: readonly string[],
184
+ ): Promise<MemoryFile | null>;
185
+ readonly relevance: RelevanceStore;
186
+ readonly rerankCache: RerankCache;
187
+ resolveArtifactSourceStatuses(
188
+ storage: StorageManager,
189
+ sourceIds: string[],
190
+ ): Promise<Map<string, "active" | "superseded" | "archived" | "missing">>;
191
+ resolveColdQmdResultForRecall(
192
+ result: QmdSearchResult,
193
+ fallbackStorage: StorageManager,
194
+ recallNamespaces?: readonly string[],
195
+ ): Promise<{ namespace: string; result: QmdSearchResult } | null>;
196
+ scopeQueryAwarePaths(
197
+ paths: Set<string> | null,
198
+ recallNamespaces: string[],
199
+ ): Set<string> | null;
200
+ searchAcrossNamespaces(options: {
201
+ query: string;
202
+ namespaces?: string[];
203
+ maxResults?: number;
204
+ mode?: "search" | "hybrid" | "bm25" | "vector";
205
+ searchOptions?: SearchQueryOptions;
206
+ execution?: SearchExecutionOptions;
207
+ }): Promise<QmdSearchResult[]>;
208
+ searchLongTermArchiveFallback(
209
+ prompt: string,
210
+ recallNamespaces: string[],
211
+ limit: number,
212
+ queryAwarePrefilter?: QueryAwarePrefilter,
213
+ abortSignal?: AbortSignal,
214
+ ): Promise<QmdSearchResult[]>;
215
+ searchScopedMemoryCandidates(
216
+ candidatePaths: Set<string>,
217
+ query: string,
218
+ limit: number,
219
+ options?: {
220
+ allowArchived?: boolean;
221
+ },
222
+ ): Promise<QmdSearchResult[]>;
223
+ readonly storage: StorageManager;
224
+ readonly storageRouter: NamespaceStorageRouter;
225
+ readonly utilityRuntimeValues: UtilityRuntimeValues | null;
226
+ }
227
+
228
+ export class RecallSearchPipelineCoordinator {
229
+ constructor(
230
+ private readonly deps: RecallSearchPipelineDeps,
231
+ ) {}
232
+
233
+ async fetchActiveArtifactsForNamespace(
234
+ namespace: string,
235
+ prompt: string,
236
+ targetCount: number,
237
+ ): Promise<MemoryFile[]> {
238
+ const storage = await this.deps.storageRouter.storageFor(namespace);
239
+ let fetchLimit = computeArtifactCandidateFetchLimit(targetCount);
240
+ const maxFetchLimit = Math.min(800, Math.max(fetchLimit, targetCount * 8));
241
+ const MAX_ATTEMPTS = 4;
242
+ let bestFiltered: MemoryFile[] = [];
243
+
244
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) {
245
+ const rawResults = await storage.searchArtifacts(prompt, fetchLimit);
246
+ const sourceIds = Array.from(
247
+ new Set(
248
+ rawResults
249
+ .map((a) => a.frontmatter.sourceMemoryId)
250
+ .filter(
251
+ (id): id is string => typeof id === "string" && id.length > 0,
252
+ ),
253
+ ),
254
+ );
255
+ const sourceStatus =
256
+ sourceIds.length > 0
257
+ ? await this.deps.resolveArtifactSourceStatuses(storage, sourceIds)
258
+ : new Map<string, "active" | "superseded" | "archived" | "missing">();
259
+
260
+ const filtered: MemoryFile[] = [];
261
+ for (const artifact of rawResults) {
262
+ const sourceId = artifact.frontmatter.sourceMemoryId;
263
+ if (!sourceId) {
264
+ filtered.push(artifact);
265
+ if (filtered.length >= targetCount) break;
266
+ continue;
267
+ }
268
+ const status = sourceStatus.get(sourceId) ?? "missing";
269
+ if (status !== "active") continue;
270
+ filtered.push(artifact);
271
+ if (filtered.length >= targetCount) break;
272
+ }
273
+
274
+ if (filtered.length >= targetCount) return filtered.slice(0, targetCount);
275
+ if (filtered.length > bestFiltered.length) {
276
+ bestFiltered = filtered;
277
+ }
278
+ if (rawResults.length === 0) return filtered;
279
+ if (rawResults.length < fetchLimit && filtered.length > 0)
280
+ return filtered;
281
+ if (fetchLimit >= maxFetchLimit) return filtered;
282
+
283
+ const growth = Math.max(targetCount * 2, 12);
284
+ fetchLimit = Math.min(maxFetchLimit, fetchLimit + growth);
285
+ }
286
+
287
+ return bestFiltered;
288
+ }
289
+
290
+ async buildQueryAwarePrefilter(
291
+ prompt: string,
292
+ recallNamespaces: string[],
293
+ ): Promise<QueryAwarePrefilter> {
294
+ if (!resolveIndexingCapabilities(this.deps.config).queryAwareIndexing || !prompt.trim()) {
295
+ return {
296
+ candidatePaths: null,
297
+ temporalFromDate: null,
298
+ matchedTags: [],
299
+ expandedTags: [],
300
+ combination: "none",
301
+ filteredToFullSearch: false,
302
+ };
303
+ }
304
+
305
+ const temporalFromDate = isTemporalQuery(prompt)
306
+ ? recencyWindowFromPrompt(prompt, Date.now())
307
+ : null;
308
+ const [rawTemporal, tagSignals] = await Promise.all([
309
+ temporalFromDate
310
+ ? queryByDateRangeAsync(this.deps.config.memoryDir, temporalFromDate)
311
+ : Promise.resolve<Set<string> | null>(null),
312
+ resolvePromptTagPrefilterAsync(this.deps.config.memoryDir, prompt).catch(
313
+ () => ({
314
+ matchedTags: extractTagsFromPrompt(prompt),
315
+ expandedTags: extractTagsFromPrompt(prompt),
316
+ paths: null,
317
+ }),
318
+ ),
319
+ ]);
320
+
321
+ const temporalCandidates = this.deps.scopeQueryAwarePaths(
322
+ rawTemporal,
323
+ recallNamespaces,
324
+ );
325
+ const tagCandidates = this.deps.scopeQueryAwarePaths(
326
+ tagSignals.paths,
327
+ recallNamespaces,
328
+ );
329
+ const maxCandidates = this.deps.config.queryAwareIndexingMaxCandidates;
330
+
331
+ let candidatePaths: Set<string> | null = null;
332
+ let combination: QueryAwarePrefilter["combination"] = "none";
333
+ let filteredToFullSearch = false;
334
+
335
+ if (
336
+ tagSignals.matchedTags.length > 0 &&
337
+ tagCandidates !== null &&
338
+ tagCandidates.size === 0
339
+ ) {
340
+ candidatePaths = tagCandidates;
341
+ combination = "tag";
342
+ } else if (temporalCandidates !== null && tagCandidates !== null) {
343
+ const intersection = new Set(
344
+ Array.from(temporalCandidates).filter((memoryPath) =>
345
+ tagCandidates.has(memoryPath),
346
+ ),
347
+ );
348
+ if (intersection.size > 0) {
349
+ candidatePaths = intersection;
350
+ combination = "intersection";
351
+ } else {
352
+ candidatePaths = new Set([...temporalCandidates, ...tagCandidates]);
353
+ combination = "union";
354
+ }
355
+ } else if (temporalCandidates !== null) {
356
+ candidatePaths = temporalCandidates;
357
+ combination = "temporal";
358
+ } else if (tagCandidates !== null) {
359
+ candidatePaths = tagCandidates;
360
+ combination = "tag";
361
+ }
362
+
363
+ if (
364
+ candidatePaths &&
365
+ maxCandidates > 0 &&
366
+ candidatePaths.size > maxCandidates
367
+ ) {
368
+ filteredToFullSearch = true;
369
+ candidatePaths = null;
370
+ }
371
+
372
+ return {
373
+ candidatePaths,
374
+ temporalFromDate,
375
+ matchedTags: tagSignals.matchedTags,
376
+ expandedTags: tagSignals.expandedTags,
377
+ combination,
378
+ filteredToFullSearch,
379
+ };
380
+ }
381
+
382
+ async fetchQmdMemoryResultsWithArtifactTopUp(
383
+ prompt: string,
384
+ qmdFetchLimit: number,
385
+ qmdHybridFetchLimit: number,
386
+ options: {
387
+ namespacesEnabled: boolean;
388
+ recallNamespaces: string[];
389
+ resolveNamespace: (path: string) => string;
390
+ collection?: string;
391
+ queryAwarePrefilter?: QueryAwarePrefilter;
392
+ searchOptions?: SearchQueryOptions;
393
+ onDebugSnapshot?: (snapshot: QmdRecallSnapshot) => Promise<void>;
394
+ /** Backend degradation observer, threaded into every QMD call (#1536). */
395
+ onDegradation?: (degradation: SearchDegradation) => void;
396
+ abortSignal?: AbortSignal;
397
+ },
398
+ ): Promise<QmdSearchResult[]> {
399
+ throwIfRecallAborted(options.abortSignal);
400
+ const queryAwarePrefilter =
401
+ options.queryAwarePrefilter ??
402
+ (await this.deps.buildQueryAwarePrefilter(prompt, options.recallNamespaces));
403
+ const scopedSeedResults = queryAwarePrefilter.candidatePaths?.size
404
+ ? await this.deps.searchScopedMemoryCandidates(
405
+ queryAwarePrefilter.candidatePaths,
406
+ prompt,
407
+ qmdFetchLimit,
408
+ { allowArchived: options.collection !== undefined },
409
+ )
410
+ : [];
411
+
412
+ let fetchLimit = Math.max(qmdFetchLimit, qmdHybridFetchLimit);
413
+ const maxFetchLimit = Math.min(
414
+ 320,
415
+ Math.max(fetchLimit, qmdFetchLimit * 5),
416
+ );
417
+ const MAX_ATTEMPTS = 2;
418
+ const qmdRecallBudgetMs = this.deps.config.recallEnrichmentDeadlineMs ?? 25_000;
419
+ const qmdRecallBudgetEnabled = qmdRecallBudgetMs > 0;
420
+ const startedAtMs = Date.now();
421
+ let lastPrimaryResultCount = 0;
422
+ let lastHybridResultCount = 0;
423
+ let lastHybridTopUpUsed = false;
424
+ let lastHybridTopUpSkippedReason: string | undefined;
425
+ const backendHonorsQmdSearchSignals =
426
+ (this.deps.config.searchBackend ?? "qmd") === "qmd";
427
+ const resolvedSearchOptions = (() => {
428
+ const resolver = (
429
+ this.deps.qmd as {
430
+ resolveSupportedSearchOptions?: (
431
+ options?: SearchQueryOptions,
432
+ ) => SearchQueryOptions | undefined;
433
+ }
434
+ ).resolveSupportedSearchOptions;
435
+ if (typeof resolver === "function") {
436
+ return resolver.call(this.deps.qmd, options.searchOptions);
437
+ }
438
+ return options.searchOptions;
439
+ })();
440
+ const primarySearchOptions = backendHonorsQmdSearchSignals
441
+ ? resolvedSearchOptions
442
+ : options.searchOptions;
443
+ const debugSearchOptions = backendHonorsQmdSearchSignals
444
+ ? resolvedSearchOptions
445
+ : undefined;
446
+ let bestFiltered = filterRecallCandidates(scopedSeedResults, {
447
+ namespacesEnabled: options.namespacesEnabled,
448
+ recallNamespaces: options.recallNamespaces,
449
+ resolveNamespace: options.resolveNamespace,
450
+ limit: qmdFetchLimit,
451
+ });
452
+ const emitDebugSnapshot = async (
453
+ results: QmdSearchResult[],
454
+ currentFetchLimit: number,
455
+ ) => {
456
+ if (!options.onDebugSnapshot) return;
457
+ await options.onDebugSnapshot({
458
+ recordedAt: new Date().toISOString(),
459
+ queryHash: createHash("sha256").update(prompt).digest("hex"),
460
+ queryLength: prompt.length,
461
+ collection: options.collection,
462
+ namespaces: options.recallNamespaces,
463
+ fetchLimit: currentFetchLimit,
464
+ primaryResultCount: lastPrimaryResultCount,
465
+ hybridResultCount: lastHybridResultCount,
466
+ queryAwareSeedCount: scopedSeedResults.length,
467
+ resultCount: results.length,
468
+ intentHint: debugSearchOptions?.intent,
469
+ explainEnabled: debugSearchOptions?.explain === true,
470
+ hybridTopUpUsed: lastHybridTopUpUsed,
471
+ hybridTopUpSkippedReason: lastHybridTopUpSkippedReason,
472
+ results: results.slice(0, 32).map((result) => ({
473
+ ...result,
474
+ snippet: result.snippet.slice(0, 280),
475
+ })),
476
+ });
477
+ };
478
+ if (queryAwarePrefilter.candidatePaths?.size === 0) {
479
+ await emitDebugSnapshot([], fetchLimit);
480
+ return [];
481
+ }
482
+
483
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) {
484
+ throwIfRecallAborted(options.abortSignal);
485
+ if (
486
+ qmdRecallBudgetEnabled &&
487
+ Date.now() - startedAtMs >= qmdRecallBudgetMs
488
+ ) {
489
+ break;
490
+ }
491
+
492
+ const primaryResults = options.collection
493
+ ? options.abortSignal
494
+ ? await this.deps.qmd.search(
495
+ prompt,
496
+ options.collection,
497
+ fetchLimit,
498
+ primarySearchOptions,
499
+ {
500
+ signal: options.abortSignal,
501
+ onDegradation: options.onDegradation,
502
+ },
503
+ )
504
+ : await this.deps.qmd.search(
505
+ prompt,
506
+ options.collection,
507
+ fetchLimit,
508
+ primarySearchOptions,
509
+ { onDegradation: options.onDegradation },
510
+ )
511
+ : await this.deps.searchAcrossNamespaces({
512
+ query: prompt,
513
+ namespaces: options.namespacesEnabled
514
+ ? options.recallNamespaces
515
+ : undefined,
516
+ maxResults: fetchLimit,
517
+ mode: "search",
518
+ searchOptions: primarySearchOptions,
519
+ execution: {
520
+ signal: options.abortSignal,
521
+ onDegradation: options.onDegradation,
522
+ },
523
+ });
524
+ lastPrimaryResultCount = primaryResults.length;
525
+ lastHybridResultCount = 0;
526
+ lastHybridTopUpUsed = false;
527
+ lastHybridTopUpSkippedReason = undefined;
528
+ let mergedResults = primaryResults;
529
+
530
+ // Backfill with hybrid results only when primary retrieval underfills.
531
+ if (
532
+ primaryResults.length < qmdFetchLimit &&
533
+ (!qmdRecallBudgetEnabled ||
534
+ Date.now() - startedAtMs < qmdRecallBudgetMs)
535
+ ) {
536
+ if (debugSearchOptions?.intent) {
537
+ lastHybridTopUpSkippedReason = "intent_hint_active";
538
+ } else if (this.deps.config.qmdSearchStrategy === "lex") {
539
+ // BM25-only strategy: a hybrid top-up runs vectorSearch (see
540
+ // QmdClient.hybridSearch), which would reintroduce the vector path the
541
+ // operator opted out of. Keep "lex" BM25-only end-to-end so the gate is
542
+ // uniform across primary + top-up (gotcha #39). Issue #1335 (codex review #1422).
543
+ lastHybridTopUpSkippedReason = "lex_strategy";
544
+ } else {
545
+ const hybridResults = options.collection
546
+ ? await this.deps.qmd.hybridSearch(
547
+ prompt,
548
+ options.collection,
549
+ fetchLimit,
550
+ {
551
+ signal: options.abortSignal,
552
+ onDegradation: options.onDegradation,
553
+ },
554
+ )
555
+ : await this.deps.searchAcrossNamespaces({
556
+ query: prompt,
557
+ namespaces: options.namespacesEnabled
558
+ ? options.recallNamespaces
559
+ : undefined,
560
+ maxResults: fetchLimit,
561
+ mode: "hybrid",
562
+ execution: {
563
+ signal: options.abortSignal,
564
+ onDegradation: options.onDegradation,
565
+ },
566
+ });
567
+ lastHybridResultCount = hybridResults.length;
568
+ lastHybridTopUpUsed = hybridResults.length > 0;
569
+ if (hybridResults.length > 0) {
570
+ const mergedByPath = new Map<string, QmdSearchResult>();
571
+ for (const result of [...primaryResults, ...hybridResults]) {
572
+ const key = result.path || result.docid;
573
+ const existing = mergedByPath.get(key);
574
+ if (!existing || result.score > existing.score) {
575
+ mergedByPath.set(key, {
576
+ ...result,
577
+ transport: result.transport ?? "hybrid",
578
+ snippet: result.snippet || existing?.snippet || "",
579
+ });
580
+ }
581
+ }
582
+ mergedResults = [...mergedByPath.values()]
583
+ .sort((a, b) => b.score - a.score)
584
+ .slice(0, fetchLimit);
585
+ }
586
+ }
587
+ }
588
+
589
+ if (scopedSeedResults.length > 0) {
590
+ const mergedByPath = new Map<string, QmdSearchResult>();
591
+ for (const result of [...scopedSeedResults, ...mergedResults]) {
592
+ const key = result.path || result.docid;
593
+ const existing = mergedByPath.get(key);
594
+ if (!existing || result.score > existing.score) {
595
+ mergedByPath.set(key, {
596
+ ...result,
597
+ snippet: result.snippet || existing?.snippet || "",
598
+ });
599
+ }
600
+ }
601
+ mergedResults = [...mergedByPath.values()]
602
+ .sort((a, b) => b.score - a.score)
603
+ .slice(0, fetchLimit);
604
+ }
605
+
606
+ const filteredResults = filterRecallCandidates(mergedResults, {
607
+ namespacesEnabled: options.namespacesEnabled,
608
+ recallNamespaces: options.recallNamespaces,
609
+ resolveNamespace: options.resolveNamespace,
610
+ limit: fetchLimit,
611
+ });
612
+
613
+ if (filteredResults.length >= qmdFetchLimit) {
614
+ const capped = filteredResults.slice(0, qmdFetchLimit);
615
+ await emitDebugSnapshot(capped, fetchLimit);
616
+ return capped;
617
+ }
618
+ if (filteredResults.length > bestFiltered.length) {
619
+ bestFiltered = filteredResults;
620
+ }
621
+ if (mergedResults.length === 0) {
622
+ await emitDebugSnapshot(filteredResults, fetchLimit);
623
+ return filteredResults;
624
+ }
625
+ if (mergedResults.length < fetchLimit && filteredResults.length > 0) {
626
+ await emitDebugSnapshot(filteredResults, fetchLimit);
627
+ return filteredResults;
628
+ }
629
+ if (fetchLimit >= maxFetchLimit) {
630
+ break;
631
+ }
632
+
633
+ const growth = Math.max(20, Math.floor(fetchLimit / 2));
634
+ fetchLimit = Math.min(maxFetchLimit, fetchLimit + growth);
635
+ }
636
+
637
+ const capped = bestFiltered.slice(0, qmdFetchLimit);
638
+ await emitDebugSnapshot(capped, fetchLimit);
639
+ return capped;
640
+ }
641
+
642
+ async searchEmbeddingFallback(
643
+ query: string,
644
+ limit: number,
645
+ ): Promise<QmdSearchResult[]> {
646
+ if (!resolveMemoryLifecycleCapabilities(this.deps.config).embeddingFallback) return [];
647
+ if (!(await this.deps.embeddingFallback.isAvailable())) return [];
648
+ const hits = await this.deps.embeddingFallback.search(query, limit);
649
+ if (hits.length === 0) return [];
650
+
651
+ const results: QmdSearchResult[] = [];
652
+ for (const hit of hits) {
653
+ const fullPath = path.isAbsolute(hit.path)
654
+ ? hit.path
655
+ : path.join(this.deps.config.memoryDir, hit.path);
656
+ const memory = await this.deps.storage.readMemoryByPath(fullPath);
657
+ if (!memory) continue;
658
+ results.push({
659
+ docid: hit.id,
660
+ path: fullPath,
661
+ score: hit.score,
662
+ snippet: memory.content.slice(0, 400).replace(/\n/g, " "),
663
+ });
664
+ }
665
+ return results;
666
+ }
667
+
668
+ /**
669
+ * Long-term fallback retrieval.
670
+ * Searches archived memories only, and is invoked only when hot recall returns zero hits.
671
+ */
672
+ async searchLongTermArchiveFallback(
673
+ prompt: string,
674
+ recallNamespaces: string[],
675
+ limit: number,
676
+ queryAwarePrefilter?: QueryAwarePrefilter,
677
+ abortSignal?: AbortSignal,
678
+ ): Promise<QmdSearchResult[]> {
679
+ throwIfRecallAborted(abortSignal);
680
+ const cappedLimit = Math.max(0, limit);
681
+ if (cappedLimit === 0) return [];
682
+ if (queryAwarePrefilter?.candidatePaths?.size === 0) return [];
683
+
684
+ const scopedSeedResults = queryAwarePrefilter?.candidatePaths?.size
685
+ ? await this.deps.searchScopedMemoryCandidates(
686
+ queryAwarePrefilter.candidatePaths,
687
+ prompt,
688
+ cappedLimit,
689
+ { allowArchived: true },
690
+ )
691
+ : [];
692
+ if (scopedSeedResults.length >= cappedLimit) {
693
+ return scopedSeedResults
694
+ .filter((result) => !isArtifactMemoryPath(result.path))
695
+ .slice(0, cappedLimit);
696
+ }
697
+
698
+ const tokens = Array.from(new Set(tokenizeRecallQuery(prompt)));
699
+ if (tokens.length === 0) return scopedSeedResults;
700
+
701
+ throwIfRecallAborted(abortSignal);
702
+ const archivedMemories =
703
+ await this.deps.readArchivedMemoriesForNamespaces(recallNamespaces);
704
+ if (archivedMemories.length === 0) return scopedSeedResults;
705
+
706
+ // Issue #1674: off-load the CPU-bound archive-scoring loop to a
707
+ // worker_threads pool so concurrent recall requests run on separate
708
+ // cores instead of serializing on the main JS thread. The pure scoring
709
+ // function is identical to the old inline loop — only the execution
710
+ // context changed. Aborts are checked at the boundaries (before submit
711
+ // and after result); the worker's work is bounded by the file count.
712
+ throwIfRecallAborted(abortSignal);
713
+ const scoring = getDefaultArchiveScoring();
714
+ const scoredResults = await scoring.score(archivedMemories.map(memoryFileToScoreItem), tokens, abortSignal);
715
+ throwIfRecallAborted(abortSignal);
716
+ const scored: QmdSearchResult[] = scoredResults.map((r) => ({
717
+ docid: r.docid,
718
+ path: r.path,
719
+ score: r.score,
720
+ snippet: r.snippet,
721
+ }));
722
+
723
+ const mergedByPath = new Map<string, QmdSearchResult>();
724
+ for (const result of [...scopedSeedResults, ...scored]) {
725
+ const key = result.path || result.docid;
726
+ const existing = mergedByPath.get(key);
727
+ if (!existing || result.score > existing.score) {
728
+ mergedByPath.set(key, {
729
+ ...result,
730
+ snippet: result.snippet || existing?.snippet || "",
731
+ });
732
+ }
733
+ }
734
+
735
+ return [...mergedByPath.values()]
736
+ .filter((result) => !isArtifactMemoryPath(result.path))
737
+ .sort((a, b) => b.score - a.score)
738
+ .slice(0, cappedLimit);
739
+ }
740
+
741
+ async applyColdFallbackPipeline(options: {
742
+ prompt: string;
743
+ recallNamespaces: string[];
744
+ recallResultLimit: number;
745
+ recallMode: RecallPlanMode;
746
+ /**
747
+ * Recall-operation capability gates resolved once at recall entry (#1523).
748
+ * OPTIONAL and additive: the recall pipeline threads a resolved set, but
749
+ * callers that omit it (e.g. direct unit-test invocations) get an
750
+ * equivalent config-derived set — behavior-preserving.
751
+ */
752
+ caps?: CapabilitySet;
753
+ /** Graph-construction gates resolved at recall entry (#1566 Cluster A). */
754
+ graphCaps?: GraphConstructionCapabilitySet;
755
+ queryAwarePrefilter?: QueryAwarePrefilter;
756
+ abortSignal?: AbortSignal;
757
+ /** Backend degradation observer — cold-tier QMD must report like hot (#1536). */
758
+ onDegradation?: (degradation: SearchDegradation) => void;
759
+ /** Issue #680 — historical recall point in ms-since-epoch. */
760
+ asOfMs?: number;
761
+ /**
762
+ * Optional out-parameter that receives the pre-MMR / pre-truncation
763
+ * pool size captured inside the pipeline (issue #570 PR 1). The
764
+ * X-ray capture block in `recallInternal` passes a small sink so
765
+ * the cold-fallback branch's pre-truncation pool size can be
766
+ * attributed back to the branch when `recallSource === "cold_fallback"`.
767
+ * Unset by default so existing call sites are unaffected.
768
+ */
769
+ xrayPoolSizeSink?: { size: number };
770
+ /**
771
+ * Issue #1577 — out-parameter that receives the TrustScore stage's
772
+ * per-path trust map (admitted + quarantined) when the cold path runs
773
+ * trust scoring. Mirrors the xrayPoolSizeSink pattern so recallInternal
774
+ can propagate trust data for epistemic rendering and X-ray visibility
775
+ without changing the cold pipeline's return type.
776
+ */
777
+ trustByPathSink?: { trustByPath: Map<string, TrustStageResultItem> | null };
778
+ deadlineAtMs?: number | null;
779
+ /** Issue #681 — when true, bypass graphTraversalConfidenceFloor. */
780
+ includeLowConfidence?: boolean;
781
+ }): Promise<QmdSearchResult[]> {
782
+ // Prefer the threaded set; fall back to a config-derived set so direct
783
+ // callers (unit tests) behave identically to the recall pipeline (#1523).
784
+ const caps = options.caps ?? resolveCapabilities(this.deps.config);
785
+ const graphCaps = options.graphCaps ?? resolveGraphConstructionCapabilities(this.deps.config);
786
+ if (options.queryAwarePrefilter?.candidatePaths?.size === 0) {
787
+ if (options.xrayPoolSizeSink) options.xrayPoolSizeSink.size = 0;
788
+ return [];
789
+ }
790
+ const deadlineRemainingMs = (): number | null =>
791
+ typeof options.deadlineAtMs === "number"
792
+ ? Math.max(0, options.deadlineAtMs - Date.now())
793
+ : null;
794
+ const runColdStepWithinDeadline = async <T>(
795
+ label: string,
796
+ fallback: T,
797
+ task: () => Promise<T>,
798
+ // Invoked when the deadline abandons this step (before it started or
799
+ // while it runs), so callers can report the abandonment and gate off
800
+ // late observer callbacks (#1536, cursor round-6 on #1544).
801
+ onDeadline?: () => void,
802
+ ): Promise<T> => {
803
+ throwIfRecallAborted(options.abortSignal);
804
+ const remainingMs = deadlineRemainingMs();
805
+ if (remainingMs === 0) {
806
+ try {
807
+ onDeadline?.();
808
+ } catch {
809
+ // Observers must never break recall.
810
+ }
811
+ log.debug(`cold-tier recall ${label} skipped: shared assembly deadline expired`);
812
+ return fallback;
813
+ }
814
+ if (remainingMs === null) return task();
815
+
816
+ let timeoutHandle: NodeJS.Timeout | undefined;
817
+ let timedOut = false;
818
+ const taskPromise = task().catch((err) => {
819
+ if (timedOut) {
820
+ log.debug(`cold-tier recall ${label} failed after deadline: ${err}`);
821
+ return fallback;
822
+ }
823
+ throw err;
824
+ });
825
+
826
+ try {
827
+ return await Promise.race<T>([
828
+ taskPromise,
829
+ new Promise<T>((resolve) => {
830
+ timeoutHandle = setTimeout(() => {
831
+ timedOut = true;
832
+ try {
833
+ onDeadline?.();
834
+ } catch {
835
+ // Observers must never break recall.
836
+ }
837
+ log.debug(
838
+ `cold-tier recall ${label} skipped: shared assembly deadline expired`,
839
+ );
840
+ resolve(fallback);
841
+ }, remainingMs);
842
+ }),
843
+ ]);
844
+ } finally {
845
+ if (timeoutHandle) clearTimeout(timeoutHandle);
846
+ }
847
+ };
848
+
849
+ const coldQmdEnabled = resolveQmdCapabilities(this.deps.config).qmdColdTier === true;
850
+ const coldCollection =
851
+ this.deps.config.qmdColdCollection ?? "openclaw-engram-cold";
852
+ const coldMaxResults =
853
+ this.deps.config.qmdColdMaxResults ?? this.deps.config.qmdMaxResults;
854
+
855
+ let longTerm: QmdSearchResult[] = [];
856
+ if (coldQmdEnabled && this.deps.qmd.isAvailable()) {
857
+ const coldFetchLimit = Math.max(
858
+ 0,
859
+ Math.min(options.recallResultLimit, Math.max(0, coldMaxResults)),
860
+ );
861
+ if (coldFetchLimit > 0) {
862
+ const coldHybridLimit = computeQmdHybridFetchLimit(
863
+ coldFetchLimit,
864
+ false,
865
+ 0,
866
+ );
867
+ // Deadline-gated observer (#1536, cursor round-6 on #1544): when the
868
+ // shared assembly deadline abandons this lookup, the still-running
869
+ // fetch's LATE reports must not land after the recall snapshot has
870
+ // been recorded — gate them off and report the abandonment itself
871
+ // deterministically at resolution time instead.
872
+ let coldQmdObserverActive = true;
873
+ const reportColdQmdDeadline = () => {
874
+ coldQmdObserverActive = false;
875
+ try {
876
+ options.onDegradation?.({
877
+ backend: "qmd",
878
+ code: "deadline_exceeded",
879
+ detail: "cold-tier qmd lookup abandoned (assembly deadline)",
880
+ });
881
+ } catch {
882
+ // Observers must never break recall.
883
+ }
884
+ };
885
+ longTerm = await runColdStepWithinDeadline(
886
+ "qmd lookup",
887
+ [],
888
+ () =>
889
+ this.deps.fetchQmdMemoryResultsWithArtifactTopUp(
890
+ options.prompt,
891
+ coldFetchLimit,
892
+ coldHybridLimit,
893
+ {
894
+ namespacesEnabled: resolveNamespaceCapabilities(this.deps.config).namespaces,
895
+ recallNamespaces: options.recallNamespaces,
896
+ resolveNamespace: (p) => this.deps.namespaceFromPath(p),
897
+ collection: coldCollection,
898
+ queryAwarePrefilter: options.queryAwarePrefilter,
899
+ searchOptions: this.deps.buildConfiguredQmdSearchOptions(options.prompt),
900
+ abortSignal: options.abortSignal,
901
+ onDegradation: (degradation) => {
902
+ if (coldQmdObserverActive) {
903
+ options.onDegradation?.(degradation);
904
+ }
905
+ },
906
+ },
907
+ ),
908
+ reportColdQmdDeadline,
909
+ );
910
+ // Normal completion also closes the gate: a deadline that fires
911
+ // after this await has nothing left to suppress, and a fetch that
912
+ // limps home later cannot mutate a recorded recall's collector.
913
+ coldQmdObserverActive = false;
914
+ if (longTerm.length > 0) {
915
+ log.debug(
916
+ `cold-tier recall source=cold-qmd collection=${coldCollection} hits=${longTerm.length}`,
917
+ );
918
+ }
919
+ }
920
+ }
921
+ if (longTerm.length === 0) {
922
+ // Deadline-aware abort: terminate the scoring worker when the shared
923
+ // assembly deadline wins, not just when the caller aborts (#1674).
924
+ const da = new AbortController();
925
+ if (options.abortSignal?.aborted) da.abort();
926
+ else options.abortSignal?.addEventListener("abort", () => da.abort(), { once: true });
927
+ longTerm = await runColdStepWithinDeadline(
928
+ "archive scan", [],
929
+ () => this.deps.searchLongTermArchiveFallback(
930
+ options.prompt, options.recallNamespaces, options.recallResultLimit,
931
+ options.queryAwarePrefilter, da.signal),
932
+ () => da.abort(),
933
+ );
934
+ if (longTerm.length > 0) {
935
+ log.debug("cold-tier recall source=archive-scan");
936
+ }
937
+ }
938
+ if (longTerm.length === 0) return [];
939
+
940
+ let results = longTerm;
941
+ if (resolveNamespaceCapabilities(this.deps.config).namespaces) {
942
+ const recallRoots: string[] = [];
943
+ const seenRecallRoots = new Set<string>();
944
+ for (const namespace of options.recallNamespaces) {
945
+ try {
946
+ const storage = await this.deps.storageRouter.storageFor(namespace);
947
+ const storageDir =
948
+ typeof (storage as { dir?: unknown }).dir === "string" &&
949
+ (storage as { dir?: string }).dir
950
+ ? (storage as { dir: string }).dir
951
+ : null;
952
+ if (!storageDir) continue;
953
+ const recallRoot = path.resolve(storageDir);
954
+ if (seenRecallRoots.has(recallRoot)) continue;
955
+ seenRecallRoots.add(recallRoot);
956
+ recallRoots.push(recallRoot);
957
+ } catch (err) {
958
+ log.debug("cold-tier recall namespace root lookup skipped", {
959
+ namespace,
960
+ error: (err as Error).message,
961
+ });
962
+ }
963
+ }
964
+ const scopedResults: QmdSearchResult[] = [];
965
+ for (const result of results) {
966
+ if (options.abortSignal?.aborted || deadlineRemainingMs() === 0) break;
967
+ const parts = qmdCollectionPathParts(result.path);
968
+ if (parts?.collection === coldCollection) {
969
+ const resolvedCold = await this.deps.resolveColdQmdResultForRecall(
970
+ result,
971
+ this.deps.storage,
972
+ options.recallNamespaces,
973
+ );
974
+ if (resolvedCold) scopedResults.push(resolvedCold.result);
975
+ continue;
976
+ }
977
+ if (path.isAbsolute(result.path)) {
978
+ const resolvedPath = path.resolve(result.path);
979
+ if (
980
+ recallRoots.some((recallRoot) =>
981
+ isPathInsideStorageRoot(recallRoot, resolvedPath),
982
+ )
983
+ ) {
984
+ scopedResults.push(result);
985
+ }
986
+ continue;
987
+ }
988
+ if (options.recallNamespaces.includes(this.deps.namespaceFromPath(result.path))) {
989
+ scopedResults.push(result);
990
+ }
991
+ }
992
+ results = scopedResults;
993
+ }
994
+ // Artifact isolation contract: generic recall paths must exclude artifacts.
995
+ results = results.filter((r) => !isArtifactMemoryPath(r.path));
996
+ if (results.length === 0) return [];
997
+
998
+ const isFullModeGraphAssist =
999
+ resolveQmdCapabilities(this.deps.config).qmdTierParityGraph &&
1000
+ graphCaps.multiGraphMemory &&
1001
+ caps.graphAssistInFullMode &&
1002
+ options.recallMode === "full" &&
1003
+ results.length >= Math.max(1, this.deps.config.graphAssistMinSeedResults ?? 3);
1004
+ const shouldRunGraphExpansion =
1005
+ resolveQmdCapabilities(this.deps.config).qmdTierParityGraph &&
1006
+ (options.recallMode === "graph_mode" || isFullModeGraphAssist);
1007
+
1008
+ if (shouldRunGraphExpansion) {
1009
+ const { merged } = await this.deps.expandResultsViaGraph({
1010
+ memoryResults: results,
1011
+ recallNamespaces: options.recallNamespaces,
1012
+ recallResultLimit: options.recallResultLimit,
1013
+ deadlineAtMs: options.deadlineAtMs,
1014
+ ...(options.includeLowConfidence === true ? { includeLowConfidence: true } : {}),
1015
+ });
1016
+ results = merged;
1017
+ }
1018
+
1019
+ const boostInput = await this.deps.filterSearchResultsForRecall(
1020
+ results,
1021
+ undefined,
1022
+ {
1023
+ allowLifecycleFiltered: true,
1024
+ asOfMs: options.asOfMs,
1025
+ deadlineAtMs: options.deadlineAtMs,
1026
+ abortSignal: options.abortSignal,
1027
+ dropUnresolved: true,
1028
+ recallNamespaces: options.recallNamespaces,
1029
+ },
1030
+ );
1031
+ results = boostInput.results;
1032
+ const boostTimeoutMs =
1033
+ typeof options.deadlineAtMs === "number"
1034
+ ? Math.max(0, options.deadlineAtMs - Date.now())
1035
+ : null;
1036
+ if (boostTimeoutMs !== 0) {
1037
+ let timeoutHandle: NodeJS.Timeout | undefined;
1038
+ try {
1039
+ const boosted = await (boostTimeoutMs !== null
1040
+ ? Promise.race<QmdSearchResult[] | { status: "timed_out" }>([
1041
+ this.deps.boostSearchResults(
1042
+ boostInput.results,
1043
+ options.recallNamespaces,
1044
+ options.prompt,
1045
+ boostInput.memoryByPath,
1046
+ { allowLifecycleFiltered: true, asOfMs: options.asOfMs },
1047
+ ),
1048
+ new Promise<{ status: "timed_out" }>((resolve) => {
1049
+ timeoutHandle = setTimeout(
1050
+ () => resolve({ status: "timed_out" }),
1051
+ boostTimeoutMs,
1052
+ );
1053
+ }),
1054
+ ])
1055
+ : this.deps.boostSearchResults(
1056
+ boostInput.results,
1057
+ options.recallNamespaces,
1058
+ options.prompt,
1059
+ boostInput.memoryByPath,
1060
+ { allowLifecycleFiltered: true, asOfMs: options.asOfMs },
1061
+ ));
1062
+ if (
1063
+ typeof boosted === "object" &&
1064
+ boosted !== null &&
1065
+ "status" in boosted &&
1066
+ boosted.status === "timed_out"
1067
+ ) {
1068
+ log.debug("cold-tier recall boost skipped: shared assembly deadline expired");
1069
+ } else if (Array.isArray(boosted)) {
1070
+ results = boosted;
1071
+ } else {
1072
+ results = boostInput.results;
1073
+ }
1074
+ } catch (err) {
1075
+ log.debug(`cold-tier recall boost failed open: ${err}`);
1076
+ } finally {
1077
+ if (timeoutHandle) clearTimeout(timeoutHandle);
1078
+ }
1079
+ } else {
1080
+ log.debug("cold-tier recall boost skipped: shared assembly deadline already expired");
1081
+ }
1082
+
1083
+ if (caps.rerank && this.deps.config.rerankProvider === "local") {
1084
+ const ranked = await rerankLocalOrNoop({
1085
+ query: options.prompt,
1086
+ candidates: results
1087
+ .slice(0, this.deps.config.rerankMaxCandidates)
1088
+ .map((r) => ({
1089
+ id: r.path,
1090
+ snippet: r.snippet || r.path,
1091
+ })),
1092
+ local: this.deps.fastLlmForRerank,
1093
+ enabled: true,
1094
+ timeoutMs: this.deps.config.rerankTimeoutMs,
1095
+ maxCandidates: this.deps.config.rerankMaxCandidates,
1096
+ cache: this.deps.rerankCache,
1097
+ cacheEnabled: caps.rerankCache,
1098
+ cacheTtlMs: this.deps.config.rerankCacheTtlMs,
1099
+ });
1100
+ if (ranked && ranked.length > 0) {
1101
+ const byPath = new Map(results.map((r) => [r.path, r]));
1102
+ const reordered: QmdSearchResult[] = [];
1103
+ for (const p of ranked) {
1104
+ const it = byPath.get(p);
1105
+ if (it) reordered.push(it);
1106
+ }
1107
+ const rankedSet = new Set(ranked);
1108
+ for (const r of results) {
1109
+ if (!rankedSet.has(r.path)) reordered.push(r);
1110
+ }
1111
+ results = reordered;
1112
+ }
1113
+ }
1114
+ if (caps.rerank && this.deps.config.rerankProvider === "cloud") {
1115
+ log.debug(
1116
+ "rerankProvider=cloud is reserved/experimental in v2.2.0; skipping rerank",
1117
+ );
1118
+ }
1119
+
1120
+ // Trust-reweighting — must fire on the cold fallback path too, or the
1121
+ // feature flag produces divergent behavior by retrieval path (rule 39).
1122
+ // TrustScore subsumes the Memory Worth multiplier; run exactly one.
1123
+ // Fail-open on lookup errors.
1124
+ if (caps.recallTrustScore && results.length > 0) {
1125
+ try {
1126
+ const trustOutcome = await this.deps.applyTrustScoreRerank(results, options.recallNamespaces);
1127
+ results = trustOutcome.results;
1128
+ if (options.trustByPathSink) options.trustByPathSink.trustByPath = trustOutcome.trustByPath;
1129
+ } catch (err) {
1130
+ log.debug("trust-score stage (cold) failed open", {
1131
+ error: (err as Error).message,
1132
+ });
1133
+ }
1134
+ } else if (caps.recallMemoryWorthFilter && results.length > 0) {
1135
+ try {
1136
+ results = await this.deps.applyMemoryWorthRerank(results, options.recallNamespaces);
1137
+ } catch (err) {
1138
+ log.debug("memory-worth filter (cold) failed open", {
1139
+ error: (err as Error).message,
1140
+ });
1141
+ }
1142
+ }
1143
+
1144
+ // Apply MMR before final truncation so the cold fallback path mirrors
1145
+ // the diversification policy applied in the hot QMD/embedding/recent
1146
+ // paths. Running MMR post-slice would be unable to promote diverse
1147
+ // candidates sitting just below the cutoff.
1148
+ if (options.xrayPoolSizeSink) {
1149
+ options.xrayPoolSizeSink.size = Math.max(
1150
+ options.xrayPoolSizeSink.size,
1151
+ results.length,
1152
+ );
1153
+ }
1154
+ return this.deps.diversifyAndLimitRecallResults(
1155
+ "memories",
1156
+ results,
1157
+ options.recallResultLimit,
1158
+ options.prompt,
1159
+ caps,
1160
+ );
1161
+ }
1162
+
1163
+ async loadSearchResultMemoryMap(
1164
+ results: QmdSearchResult[],
1165
+ preloadedMemoryMap?: Map<string, MemoryFile>,
1166
+ options?: {
1167
+ deadlineAtMs?: number | null;
1168
+ abortSignal?: AbortSignal;
1169
+ recallNamespaces?: readonly string[];
1170
+ },
1171
+ ): Promise<{
1172
+ memoryByPath: Map<string, MemoryFile>;
1173
+ checkedPaths: Set<string>;
1174
+ unreadablePaths: Set<string>;
1175
+ completed: boolean;
1176
+ }> {
1177
+ const memoryByPath: Map<string, MemoryFile> = preloadedMemoryMap
1178
+ ? new Map(preloadedMemoryMap)
1179
+ : new Map();
1180
+ const checkedPaths = new Set<string>();
1181
+ const unreadablePaths = new Set<string>();
1182
+
1183
+ const markChecked = (result: QmdSearchResult): void => {
1184
+ if (result.path) checkedPaths.add(result.path);
1185
+ };
1186
+ const markUnreadable = (result: QmdSearchResult, err: unknown): void => {
1187
+ if (!result.path) return;
1188
+ checkedPaths.add(result.path);
1189
+ unreadablePaths.add(result.path);
1190
+ log.warn("recall safety filter dropped unreadable secure-store candidate", {
1191
+ path: result.path,
1192
+ error: (err as Error).message,
1193
+ });
1194
+ };
1195
+ const deadlineExpired = (): boolean =>
1196
+ typeof options?.deadlineAtMs === "number" &&
1197
+ Date.now() >= options.deadlineAtMs;
1198
+
1199
+ if (options?.deadlineAtMs == null) {
1200
+ const batchSize = options?.abortSignal ? 16 : results.length;
1201
+ for (let offset = 0; offset < results.length; offset += batchSize) {
1202
+ if (options?.abortSignal?.aborted) {
1203
+ return {
1204
+ memoryByPath,
1205
+ checkedPaths,
1206
+ unreadablePaths,
1207
+ completed: false,
1208
+ };
1209
+ }
1210
+ await Promise.all(
1211
+ results.slice(offset, offset + batchSize).map(async (r) => {
1212
+ if (!r.path) return;
1213
+ if (memoryByPath.has(r.path)) {
1214
+ markChecked(r);
1215
+ return;
1216
+ }
1217
+ try {
1218
+ const mem = await this.deps.readQmdResultMemory(
1219
+ r.path,
1220
+ this.deps.storage,
1221
+ options?.recallNamespaces,
1222
+ );
1223
+ markChecked(r);
1224
+ if (mem) memoryByPath.set(r.path, mem);
1225
+ } catch (err) {
1226
+ if (err instanceof SecureStoreLockedError) {
1227
+ markUnreadable(r, err);
1228
+ return;
1229
+ }
1230
+ throw err;
1231
+ }
1232
+ }),
1233
+ );
1234
+ }
1235
+
1236
+ return { memoryByPath, checkedPaths, unreadablePaths, completed: true };
1237
+ }
1238
+
1239
+ for (const result of results) {
1240
+ if (!result.path) continue;
1241
+ if (memoryByPath.has(result.path)) {
1242
+ markChecked(result);
1243
+ continue;
1244
+ }
1245
+ if (options?.abortSignal?.aborted || deadlineExpired()) {
1246
+ return { memoryByPath, checkedPaths, unreadablePaths, completed: false };
1247
+ }
1248
+ try {
1249
+ const mem = await this.deps.readQmdResultMemory(
1250
+ result.path,
1251
+ this.deps.storage,
1252
+ options?.recallNamespaces,
1253
+ );
1254
+ markChecked(result);
1255
+ if (mem) memoryByPath.set(result.path, mem);
1256
+ } catch (err) {
1257
+ if (err instanceof SecureStoreLockedError) {
1258
+ markUnreadable(result, err);
1259
+ continue;
1260
+ }
1261
+ throw err;
1262
+ }
1263
+ }
1264
+
1265
+ return { memoryByPath, checkedPaths, unreadablePaths, completed: true };
1266
+ }
1267
+
1268
+ filterSearchResultsByRecallSafety(
1269
+ results: QmdSearchResult[],
1270
+ memoryByPath: Map<string, MemoryFile>,
1271
+ options?: {
1272
+ allowLifecycleFiltered?: boolean;
1273
+ allowDedicatedSurface?: boolean;
1274
+ asOfMs?: number;
1275
+ blockedPaths?: Set<string>;
1276
+ },
1277
+ ): QmdSearchResult[] {
1278
+ const lifecycleCaps = resolveMemoryLifecycleCapabilities(this.deps.config);
1279
+ let lifecycleFilteredCount = 0;
1280
+ let temporalSupersededFilteredCount = 0;
1281
+ let biTemporalExpiredFilteredCount = 0;
1282
+ let dedicatedSurfaceFilteredCount = 0;
1283
+ let forgottenFilteredCount = 0;
1284
+ let blockedPathFilteredCount = 0;
1285
+ const filtered: QmdSearchResult[] = [];
1286
+ for (const r of results) {
1287
+ if (r.path && options?.blockedPaths?.has(r.path)) {
1288
+ blockedPathFilteredCount += 1;
1289
+ continue;
1290
+ }
1291
+ const memory = memoryByPath.get(r.path);
1292
+ if (memory) {
1293
+ // Review-lifecycle statuses never enter active recall injection
1294
+ // (forgotten, pending_review, rejected, quarantined). Superseded and
1295
+ // archived have dedicated filters below. #1576: the faithfulness gate
1296
+ // routes unsupported/contradicted facts to pending_review — they must
1297
+ // not leak back via the QMD/embedding path. chatgpt P2.
1298
+ const recallStatus = memory.frontmatter.status;
1299
+ if (
1300
+ recallStatus === "forgotten" ||
1301
+ recallStatus === "pending_review" ||
1302
+ recallStatus === "rejected" ||
1303
+ recallStatus === "quarantined"
1304
+ ) {
1305
+ forgottenFilteredCount += 1;
1306
+ continue;
1307
+ }
1308
+
1309
+ if (
1310
+ options?.allowLifecycleFiltered !== true &&
1311
+ shouldFilterLifecycleRecallCandidate(memory.frontmatter, {
1312
+ lifecyclePolicyEnabled: lifecycleCaps.lifecyclePolicy,
1313
+ lifecycleFilterStaleEnabled:
1314
+ lifecycleCaps.lifecycleFilterStale,
1315
+ })
1316
+ ) {
1317
+ lifecycleFilteredCount += 1;
1318
+ continue;
1319
+ }
1320
+
1321
+ // Historical recall (issue #680): when the caller pinned the
1322
+ // recall to a specific point in time, evaluate temporal validity
1323
+ // at that instant FIRST and bypass the supersession filter
1324
+ // entirely. A fact that is currently superseded but was valid
1325
+ // at `as_of` is exactly what historical recall should surface;
1326
+ // running supersession filtering before the as_of check would
1327
+ // drop it and break the worked example in docs/temporal-recall.md
1328
+ // (codex P1 / cursor High on PR #713).
1329
+ const asOfActive =
1330
+ typeof options?.asOfMs === "number" && Number.isFinite(options.asOfMs);
1331
+ if (asOfActive) {
1332
+ if (!isValidAsOf(memory.frontmatter, options!.asOfMs!)) {
1333
+ temporalSupersededFilteredCount += 1;
1334
+ continue;
1335
+ }
1336
+ } else if (
1337
+ // Temporal supersession filter (issue #375): drop memories that
1338
+ // a newer fact has retired, unless the caller opted in to history.
1339
+ // NOTE: This check is intentionally independent of
1340
+ // allowLifecycleFiltered (Finding A fix) — cold fallback sets
1341
+ // allowLifecycleFiltered=true to include archived/retired
1342
+ // candidates, but superseded memories must still be filtered
1343
+ // unless temporalSupersessionIncludeInRecall is set.
1344
+ // Skipped entirely when `as_of` is active (above branch); the
1345
+ // half-open `[valid_at, invalid_at)` evaluation in isValidAsOf
1346
+ // is the authoritative gate for historical recall.
1347
+ shouldFilterSupersededFromRecall(memory.frontmatter, {
1348
+ enabled: lifecycleCaps.temporalSupersession,
1349
+ includeInRecall: this.deps.config.temporalSupersessionIncludeInRecall,
1350
+ })
1351
+ ) {
1352
+ temporalSupersededFilteredCount += 1;
1353
+ continue;
1354
+ }
1355
+ // Bi-temporal INJECTION filter (issue #1578): when the master gate
1356
+ // is on and the caller did NOT pin `as_of`, drop facts whose event-
1357
+ // time interval has ended before now. This lives ONLY in the recall
1358
+ // injection path (filterSearchResultsByRecallSafety) — explicit
1359
+ // search (access-service.memorySearch → searchAcrossNamespaces /
1360
+ // qmd.search) never routes through here, so expired-validity facts
1361
+ // remain findable by memory_search and `as_of` queries (escape
1362
+ // hatch: the as_of branch above also admits historically-valid
1363
+ // records). Gated off entirely when `temporalExpiredInInjection` is
1364
+ // set. Status-orthogonal: an `active` fact can be validity-expired;
1365
+ // a `superseded` one may still be within its window.
1366
+ if (
1367
+ !asOfActive &&
1368
+ this.deps.config.temporalBiTemporal &&
1369
+ !this.deps.config.temporalExpiredInInjection &&
1370
+ isValidityExpiredNow(memory.frontmatter, Date.now())
1371
+ ) {
1372
+ biTemporalExpiredFilteredCount += 1;
1373
+ continue;
1374
+ }
1375
+
1376
+ if (
1377
+ options?.allowDedicatedSurface !== true &&
1378
+ (memory.frontmatter.memoryKind === "dream" ||
1379
+ memory.frontmatter.memoryKind === "procedural")
1380
+ ) {
1381
+ dedicatedSurfaceFilteredCount += 1;
1382
+ continue;
1383
+ }
1384
+ }
1385
+ filtered.push(r);
1386
+ }
1387
+ if (lifecycleFilteredCount > 0) {
1388
+ log.debug(
1389
+ `lifecycle retrieval filter removed ${lifecycleFilteredCount} stale/archived candidates`,
1390
+ );
1391
+ }
1392
+ if (temporalSupersededFilteredCount > 0) {
1393
+ log.debug(
1394
+ `temporal supersession filter removed ${temporalSupersededFilteredCount} superseded candidates`,
1395
+ );
1396
+ }
1397
+ if (biTemporalExpiredFilteredCount > 0) {
1398
+ log.debug(
1399
+ `bi-temporal validity filter removed ${biTemporalExpiredFilteredCount} expired-validity candidates from injection (temporal.biTemporal on)`,
1400
+ );
1401
+ }
1402
+ if (dedicatedSurfaceFilteredCount > 0) {
1403
+ log.debug(
1404
+ `dedicated surface filter removed ${dedicatedSurfaceFilteredCount} dream/procedural candidates from generic recall`,
1405
+ );
1406
+ }
1407
+ if (forgottenFilteredCount > 0) {
1408
+ log.debug(
1409
+ `forgotten status filter removed ${forgottenFilteredCount} candidates from recall`,
1410
+ );
1411
+ }
1412
+ if (blockedPathFilteredCount > 0) {
1413
+ log.debug(
1414
+ `unreadable-path filter removed ${blockedPathFilteredCount} candidates from recall`,
1415
+ );
1416
+ }
1417
+ return filtered;
1418
+ }
1419
+
1420
+ async filterSearchResultsForRecall(
1421
+ results: QmdSearchResult[],
1422
+ preloadedMemoryMap?: Map<string, MemoryFile>,
1423
+ options?: {
1424
+ allowLifecycleFiltered?: boolean;
1425
+ allowDedicatedSurface?: boolean;
1426
+ asOfMs?: number;
1427
+ deadlineAtMs?: number | null;
1428
+ abortSignal?: AbortSignal;
1429
+ dropUnresolved?: boolean;
1430
+ recallNamespaces?: readonly string[];
1431
+ },
1432
+ ): Promise<{ results: QmdSearchResult[]; memoryByPath: Map<string, MemoryFile> }> {
1433
+ if (results.length === 0) {
1434
+ return {
1435
+ results,
1436
+ memoryByPath: preloadedMemoryMap ? new Map(preloadedMemoryMap) : new Map(),
1437
+ };
1438
+ }
1439
+ const loaded = await this.deps.loadSearchResultMemoryMap(
1440
+ results,
1441
+ preloadedMemoryMap,
1442
+ options,
1443
+ );
1444
+ const candidateResults = loaded.completed
1445
+ ? results
1446
+ : results.filter((result) => !result.path || loaded.checkedPaths.has(result.path));
1447
+ if (!loaded.completed) {
1448
+ log.debug(
1449
+ `recall safety filter stopped before validating all candidates (${candidateResults.length}/${results.length} eligible)`,
1450
+ );
1451
+ }
1452
+ const blockedPaths = new Set(loaded.unreadablePaths);
1453
+ if (options?.dropUnresolved === true) {
1454
+ for (const resultPath of loaded.checkedPaths) {
1455
+ if (!loaded.memoryByPath.has(resultPath)) {
1456
+ blockedPaths.add(resultPath);
1457
+ }
1458
+ }
1459
+ }
1460
+ return {
1461
+ results: this.deps.filterSearchResultsByRecallSafety(
1462
+ candidateResults,
1463
+ loaded.memoryByPath,
1464
+ { ...options, blockedPaths },
1465
+ ),
1466
+ memoryByPath: loaded.memoryByPath,
1467
+ };
1468
+ }
1469
+
1470
+ /**
1471
+ * Apply recency, access count, and importance boosting to QMD search results.
1472
+ * Returns re-ranked results.
1473
+ */
1474
+ async boostSearchResults(
1475
+ results: QmdSearchResult[],
1476
+ _recallNamespaces: string[],
1477
+ prompt?: string,
1478
+ preloadedMemoryMap?: Map<string, MemoryFile>,
1479
+ options?: {
1480
+ allowLifecycleFiltered?: boolean;
1481
+ allowDedicatedSurface?: boolean;
1482
+ /**
1483
+ * Historical recall point in ms-since-epoch (issue #680). When
1484
+ * set, drops candidates that were not authoritative at this
1485
+ * instant per `temporal-validity.isValidAsOf`. Caller is
1486
+ * responsible for parsing/validating the user-supplied ISO
1487
+ * string at the input boundary (CLI / HTTP / MCP).
1488
+ */
1489
+ asOfMs?: number;
1490
+ },
1491
+ ): Promise<QmdSearchResult[]> {
1492
+ const lifecycleCaps = resolveMemoryLifecycleCapabilities(this.deps.config);
1493
+ if (results.length === 0) return results;
1494
+
1495
+ const safety = await this.deps.filterSearchResultsForRecall(
1496
+ results,
1497
+ preloadedMemoryMap,
1498
+ { ...options, recallNamespaces: _recallNamespaces },
1499
+ );
1500
+ const safeResults = safety.results;
1501
+ const memoryByPath = safety.memoryByPath;
1502
+ if (safeResults.length === 0) return safeResults;
1503
+
1504
+ const now = Date.now();
1505
+
1506
+ // Determine temporal/tag query params before index I/O (pure computation).
1507
+ const resultPaths = new Set(
1508
+ safeResults.map((r) => r.path).filter(Boolean) as string[],
1509
+ );
1510
+ let temporalFromDate: string | null = null;
1511
+ let promptTags: string[] = [];
1512
+ if (resolveIndexingCapabilities(this.deps.config).queryAwareIndexing && prompt) {
1513
+ if (isTemporalQuery(prompt)) {
1514
+ temporalFromDate = recencyWindowFromPrompt(prompt, now);
1515
+ }
1516
+ promptTags = extractTagsFromPrompt(prompt);
1517
+ }
1518
+
1519
+ const [rawTemporal, rawTags] = await Promise.all([
1520
+ temporalFromDate !== null
1521
+ ? queryByDateRangeAsync(this.deps.config.memoryDir, temporalFromDate)
1522
+ : Promise.resolve<Set<string> | null>(null),
1523
+ promptTags.length > 0
1524
+ ? queryByTagsAsync(this.deps.config.memoryDir, promptTags)
1525
+ : Promise.resolve<Set<string> | null>(null),
1526
+ ]);
1527
+
1528
+ const queryIntent =
1529
+ resolveConversationContextCapabilities(this.deps.config).intentRouting && prompt
1530
+ ? inferIntentFromText(prompt)
1531
+ : null;
1532
+
1533
+ // v8.1: Temporal + Tag prefilter candidate set
1534
+ // Scope to result paths first so cross-namespace paths don't consume the cap.
1535
+ let temporalCandidates: Set<string> | null = null;
1536
+ let tagCandidates: Set<string> | null = null;
1537
+ if (resolveIndexingCapabilities(this.deps.config).queryAwareIndexing && prompt) {
1538
+ const maxCandidates = this.deps.config.queryAwareIndexingMaxCandidates;
1539
+ const capSet = (s: Set<string> | null): Set<string> | null => {
1540
+ if (!s) return null;
1541
+ // Intersect with result paths first so out-of-scope paths don't exhaust the budget
1542
+ const scoped = new Set(Array.from(s).filter((p) => resultPaths.has(p)));
1543
+ if (maxCandidates === 0 || scoped.size <= maxCandidates)
1544
+ return scoped;
1545
+ return new Set(Array.from(scoped).slice(0, maxCandidates));
1546
+ };
1547
+ if (temporalFromDate !== null) {
1548
+ temporalCandidates = capSet(rawTemporal);
1549
+ }
1550
+ if (promptTags.length > 0) {
1551
+ tagCandidates = capSet(rawTags);
1552
+ }
1553
+ }
1554
+
1555
+ const boosted: QmdSearchResult[] = [];
1556
+ const recencyWeight = this.deps.effectiveRecencyWeight();
1557
+ for (const r of safeResults) {
1558
+ const memory = memoryByPath.get(r.path);
1559
+ let score = r.score;
1560
+
1561
+ if (memory) {
1562
+ // Recency boost: exponential decay over 7 days
1563
+ if (recencyWeight > 0) {
1564
+ const createdAt = new Date(memory.frontmatter.created).getTime();
1565
+ const ageMs = now - createdAt;
1566
+ const ageDays = ageMs / (1000 * 60 * 60 * 24);
1567
+ const halfLifeDays = 7;
1568
+ const recencyScore = Math.pow(0.5, ageDays / halfLifeDays);
1569
+ score = score * (1 - recencyWeight) + recencyScore * recencyWeight;
1570
+ }
1571
+
1572
+ // Access count boost: log scale, capped
1573
+ if (this.deps.config.boostAccessCount && memory.frontmatter.accessCount) {
1574
+ const accessBoost =
1575
+ Math.log10(memory.frontmatter.accessCount + 1) / 3;
1576
+ score += applyUtilityRankingRuntimeDelta(
1577
+ Math.min(accessBoost, 0.1),
1578
+ this.deps.utilityRuntimeValues,
1579
+ "boost",
1580
+ );
1581
+ }
1582
+
1583
+ // Importance boost (Phase 1B): higher importance = higher rank
1584
+ if (memory.frontmatter.importance) {
1585
+ const importanceScore = memory.frontmatter.importance.score;
1586
+ // Boost important memories, slightly penalize trivial ones
1587
+ // Scale: trivial (-0.05) to critical (+0.15)
1588
+ const importanceBoost = (importanceScore - 0.4) * 0.25;
1589
+ score += applyUtilityRankingRuntimeDelta(
1590
+ importanceBoost,
1591
+ this.deps.utilityRuntimeValues,
1592
+ importanceBoost >= 0 ? "boost" : "suppress",
1593
+ );
1594
+ }
1595
+
1596
+ // Feedback bias (v2.2): apply small user-provided up/down vote adjustments.
1597
+ if (resolveRecallEnhancementCapabilities(this.deps.config).feedback) {
1598
+ const match = memory.path.match(/([^/]+)\.md$/);
1599
+ const memoryId = match ? match[1] : null;
1600
+ if (memoryId) {
1601
+ const feedbackDelta = this.deps.relevance.adjustment(memoryId);
1602
+ score += applyUtilityRankingRuntimeDelta(
1603
+ feedbackDelta,
1604
+ this.deps.utilityRuntimeValues,
1605
+ feedbackDelta >= 0 ? "boost" : "suppress",
1606
+ );
1607
+ }
1608
+ }
1609
+
1610
+ // Negative examples (v2.2): apply a small penalty for memories repeatedly marked "not useful".
1611
+ if (resolvePipelineProcessingCapabilities(this.deps.config).negativeExamples) {
1612
+ const match = memory.path.match(/([^/]+)\.md$/);
1613
+ const memoryId = match ? match[1] : null;
1614
+ if (memoryId) {
1615
+ const negativePenalty = this.deps.negatives.penalty(memoryId, {
1616
+ perHit: this.deps.config.negativeExamplesPenaltyPerHit,
1617
+ cap: this.deps.config.negativeExamplesPenaltyCap,
1618
+ });
1619
+ score -= applyUtilityRankingRuntimeDelta(
1620
+ negativePenalty,
1621
+ this.deps.utilityRuntimeValues,
1622
+ "suppress",
1623
+ );
1624
+ }
1625
+ }
1626
+
1627
+ if (
1628
+ queryIntent &&
1629
+ memory.frontmatter.intentGoal &&
1630
+ memory.frontmatter.intentActionType
1631
+ ) {
1632
+ const compatibility = intentCompatibilityScore(queryIntent, {
1633
+ goal: memory.frontmatter.intentGoal,
1634
+ actionType: memory.frontmatter.intentActionType,
1635
+ entityTypes: memory.frontmatter.intentEntityTypes ?? [],
1636
+ });
1637
+ score += applyUtilityRankingRuntimeDelta(
1638
+ compatibility * this.deps.config.intentRoutingBoost,
1639
+ this.deps.utilityRuntimeValues,
1640
+ "boost",
1641
+ );
1642
+ }
1643
+
1644
+ // v8.1: Temporal + Tag index boost
1645
+ // Results that match the detected temporal window or tag query get a small additive boost.
1646
+ if (resolveIndexingCapabilities(this.deps.config).queryAwareIndexing && r.path) {
1647
+ if (temporalCandidates?.has(r.path)) {
1648
+ score += applyUtilityRankingRuntimeDelta(
1649
+ 0.08,
1650
+ this.deps.utilityRuntimeValues,
1651
+ "boost",
1652
+ );
1653
+ }
1654
+ if (tagCandidates?.has(r.path)) {
1655
+ score += applyUtilityRankingRuntimeDelta(
1656
+ 0.06,
1657
+ this.deps.utilityRuntimeValues,
1658
+ "boost",
1659
+ );
1660
+ }
1661
+ }
1662
+
1663
+ // v8.3: lifecycle retrieval weighting (fail-open on legacy memories).
1664
+ const lifecycleDelta = lifecycleRecallScoreAdjustment(
1665
+ memory.frontmatter,
1666
+ {
1667
+ lifecyclePolicyEnabled: lifecycleCaps.lifecyclePolicy,
1668
+ },
1669
+ );
1670
+ score += applyUtilityRankingRuntimeDelta(
1671
+ lifecycleDelta,
1672
+ this.deps.utilityRuntimeValues,
1673
+ lifecycleDelta >= 0 ? "boost" : "suppress",
1674
+ );
1675
+
1676
+ // Reinforcement recall boost (issue #687 PR 3/4).
1677
+ // Applies an additive score bonus proportional to how many times the
1678
+ // pattern-reinforcement job has promoted this memory as a canonical.
1679
+ // Formula: min(reinforcement_count * weight, max).
1680
+ // Gated by reinforcementRecallBoostEnabled (default false).
1681
+ let reinforcementBoost = 0;
1682
+ if (
1683
+ resolveRecallEnhancementCapabilities(this.deps.config).reinforcementRecallBoost &&
1684
+ typeof memory.frontmatter.reinforcement_count === "number" &&
1685
+ memory.frontmatter.reinforcement_count > 0
1686
+ ) {
1687
+ reinforcementBoost = Math.min(
1688
+ memory.frontmatter.reinforcement_count *
1689
+ this.deps.config.reinforcementRecallBoostWeight,
1690
+ this.deps.config.reinforcementRecallBoostMax,
1691
+ );
1692
+ score += reinforcementBoost;
1693
+ }
1694
+ if (reinforcementBoost > 0) {
1695
+ boosted.push({
1696
+ ...r,
1697
+ score,
1698
+ explain: { ...(r.explain ?? {}), reinforcementBoost },
1699
+ });
1700
+ continue;
1701
+ }
1702
+ }
1703
+
1704
+ boosted.push({ ...r, score });
1705
+ }
1706
+
1707
+ // Re-sort by boosted score
1708
+ return boosted.sort((a, b) => b.score - a.score);
1709
+ }
1710
+ }