@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.
- package/dist/access-boundary.d.ts +2 -2
- package/dist/access-cli.js +1 -1
- package/dist/access-http.d.ts +2 -2
- package/dist/access-mcp.d.ts +2 -2
- package/dist/access-operations.d.ts +2 -2
- package/dist/{access-service-Cx16nmCv.d.ts → access-service-CH1aqqb1.d.ts} +1 -1
- package/dist/access-service.d.ts +2 -2
- package/dist/access-surface-catalog.d.ts +2 -2
- package/dist/bootstrap.d.ts +1 -1
- package/dist/{chunk-PJE6D3NH.js → chunk-ZWGXZ7ID.js} +11555 -10846
- package/dist/chunk-ZWGXZ7ID.js.map +1 -0
- package/dist/{cli-D1y3vooB.d.ts → cli-CE5olss2.d.ts} +2 -2
- package/dist/cli.d.ts +3 -3
- package/dist/explicit-capture.d.ts +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.js +1 -1
- package/dist/mcp-memory-inspector-app.d.ts +2 -2
- package/dist/{orchestrator-CnCE3Hvq.d.ts → orchestrator-CIOqdNzF.d.ts} +77 -85
- package/dist/orchestrator.d.ts +1 -1
- package/dist/orchestrator.js +27 -3
- package/package.json +2 -2
- package/src/orchestration/orchestrator-init.ts +712 -0
- package/src/orchestration/recall-internal.ts +5479 -0
- package/src/orchestration/recall-introspection.ts +709 -0
- package/src/orchestration/recall-search-pipeline.ts +1710 -0
- package/src/orchestration/turn-ingestion.ts +792 -0
- package/src/orchestrator.ts +423 -8077
- package/dist/chunk-PJE6D3NH.js.map +0 -1
|
@@ -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
|
+
}
|