@remnic/core 9.25.5 → 9.25.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/access-admin-ops-surface.d.ts +2 -2
  2. package/dist/access-authorization-probe.d.ts +2 -2
  3. package/dist/access-boundary.d.ts +2 -2
  4. package/dist/access-cli.js +1 -1
  5. package/dist/access-http.d.ts +2 -2
  6. package/dist/access-identity-continuity-surface.d.ts +1 -1
  7. package/dist/access-lcm-surface.d.ts +2 -2
  8. package/dist/access-mcp.d.ts +2 -2
  9. package/dist/access-observe-write-surface.d.ts +2 -2
  10. package/dist/access-operations.d.ts +5 -5
  11. package/dist/access-recall-concurrency.d.ts +2 -2
  12. package/dist/access-recall-response.d.ts +2 -2
  13. package/dist/access-recall-surface.d.ts +2 -2
  14. package/dist/access-schema.d.ts +76 -76
  15. package/dist/{access-service-BJQ22URi.d.ts → access-service-CIdWkP6J.d.ts} +1 -1
  16. package/dist/access-service.d.ts +2 -2
  17. package/dist/access-surface-catalog.d.ts +2 -2
  18. package/dist/bootstrap.d.ts +1 -1
  19. package/dist/{chunk-FR75B4UU.js → chunk-KZVPTJNZ.js} +334 -647
  20. package/dist/chunk-KZVPTJNZ.js.map +1 -0
  21. package/dist/{cli-1W7cNjJJ.d.ts → cli-Da5kesWC.d.ts} +2 -2
  22. package/dist/cli.d.ts +3 -3
  23. package/dist/explicit-capture.d.ts +1 -1
  24. package/dist/index.d.ts +392 -392
  25. package/dist/index.js +1 -1
  26. package/dist/mcp-memory-inspector-app.d.ts +2 -2
  27. package/dist/{orchestrator-BtA1wC5K.d.ts → orchestrator-CFJ_TG6D.d.ts} +15 -10
  28. package/dist/orchestrator.d.ts +1 -1
  29. package/dist/orchestrator.js +1 -1
  30. package/dist/schemas.d.ts +76 -76
  31. package/dist/shared-context/manager.d.ts +8 -8
  32. package/dist/transfer/types.d.ts +66 -66
  33. package/package.json +2 -2
  34. package/src/meetings/recall-exclusion.test.ts +4 -2
  35. package/src/orchestration/generic-recall-paths.ts +119 -0
  36. package/src/orchestration/namespace-read-fanout.ts +0 -12
  37. package/src/orchestration/orchestrator-helpers.ts +0 -35
  38. package/src/orchestration/recall-internal.ts +6 -6
  39. package/src/orchestration/recall-search-pipeline.test.ts +194 -28
  40. package/src/orchestration/recall-search-pipeline.ts +39 -89
  41. package/src/orchestrator.ts +5 -17
  42. package/src/recall-cold-deadline.test.ts +11 -13
  43. package/src/testing/subjects/generic-recall-paths.test.ts +157 -0
  44. package/dist/chunk-FR75B4UU.js.map +0 -1
  45. package/src/recall/archive-scoring.ts +0 -544
@@ -100,7 +100,7 @@ import {
100
100
  type QueryAwarePrefilter,
101
101
  type RecallInvocationOptions,
102
102
  } from "../orchestrator.js";
103
- import { isGenericRecallExcludedPath } from "./orchestrator-helpers.js";
103
+ import { isGenericRecallExcludedPath } from "./generic-recall-paths.js";
104
104
 
105
105
  import type {
106
106
  RecallSectionAppendOptions,
@@ -4165,8 +4165,7 @@ export class RecallInternalCoordinator {
4165
4165
  memoryResults = memoryResults.filter((r) =>
4166
4166
  recallNamespaces.includes(r.namespace ?? this.deps.namespaceFromPath(r.path)));
4167
4167
  }
4168
- // Artifacts + activity digests are dedicated surfaces, never generic recall.
4169
- memoryResults = memoryResults.filter((r) => !isGenericRecallExcludedPath(r.path, this.deps.config.memoryDir));
4168
+ memoryResults = memoryResults.filter((r) => !isGenericRecallExcludedPath(r.path, this.deps.config, "qmd"));
4170
4169
 
4171
4170
  const isFullModeGraphAssist =
4172
4171
  graphCaps.multiGraphMemory &&
@@ -4297,6 +4296,7 @@ export class RecallInternalCoordinator {
4297
4296
  }
4298
4297
  }
4299
4298
  }
4299
+ memoryResults = memoryResults.filter((r) => !isGenericRecallExcludedPath(r.path, this.deps.config, "qmd"));
4300
4300
 
4301
4301
  // Apply mandatory recall safety filters before deadline-bound scoring
4302
4302
  // enrichment. If scoring times out, we must fall back to this filtered
@@ -4550,7 +4550,7 @@ export class RecallInternalCoordinator {
4550
4550
  recallNamespaces,
4551
4551
  resolveNamespace: (p) => this.deps.namespaceFromPath(p),
4552
4552
  limit: embeddingFetchLimit,
4553
- memoryRoot: this.deps.config.memoryDir,
4553
+ pathPolicy: this.deps.config,
4554
4554
  },
4555
4555
  );
4556
4556
  const boostedScoped = await this.deps.boostSearchResults(
@@ -4745,7 +4745,7 @@ export class RecallInternalCoordinator {
4745
4745
  recallNamespaces,
4746
4746
  resolveNamespace: (p) => this.deps.namespaceFromPath(p),
4747
4747
  limit: embeddingFetchLimit,
4748
- memoryRoot: this.deps.config.memoryDir,
4748
+ pathPolicy: this.deps.config,
4749
4749
  },
4750
4750
  );
4751
4751
  const boostedScoped = await this.deps.boostSearchResults(
@@ -4868,7 +4868,7 @@ export class RecallInternalCoordinator {
4868
4868
  typeof asOfMs === "number" && Number.isFinite(asOfMs);
4869
4869
  const activeMemories = memories.filter(
4870
4870
  (m) => {
4871
- if (isGenericRecallExcludedPath(m.path, this.deps.config.memoryDir)) return false;
4871
+ if (isGenericRecallExcludedPath(m.path, this.deps.config)) return false;
4872
4872
  const status = m.frontmatter.status;
4873
4873
  if (!status || status === "active") return true;
4874
4874
  if (status === "superseded") {
@@ -78,13 +78,12 @@ function scopedCandidatesStub(
78
78
  seed.filter((r) => candidatePaths.has(r.path)).slice(0, Math.max(0, limit));
79
79
  }
80
80
 
81
- function fallbackCoordinator(seed: QmdSearchResult[], archived: MemoryFile[]): RecallSearchPipelineCoordinator {
82
- const readArchivedMemoriesForNamespaces: RecallSearchPipelineDeps["readArchivedMemoriesForNamespaces"] =
83
- async () => archived;
81
+ function queryAwareFallbackCoordinator(
82
+ seed: QmdSearchResult[],
83
+ ): RecallSearchPipelineCoordinator {
84
84
  const namespaceFromPath: RecallSearchPipelineDeps["namespaceFromPath"] = () => "default";
85
85
  const deps = {
86
86
  searchScopedMemoryCandidates: scopedCandidatesStub(seed),
87
- readArchivedMemoriesForNamespaces,
88
87
  namespaceFromPath,
89
88
  config: { memoryDir: "/mem" },
90
89
  } as unknown as RecallSearchPipelineDeps;
@@ -107,48 +106,85 @@ const SEED_PATHS = [MEETING_RECORD, "facts/a.md", "artifacts/y.md"];
107
106
  const SEED_WITH_NON_RECALLABLE = SEED_PATHS.map((p) => result("default", p));
108
107
  const PREFILTER = prefilter(SEED_PATHS);
109
108
 
110
- test("archive fallback excludes non-recallable seeds on the archived-empty early return", async () => {
111
- const coordinator = fallbackCoordinator(SEED_WITH_NON_RECALLABLE, []);
112
- // limit 10 > filtered seed count, tokens non-empty, archived empty → the
113
- // `return scopedSeedResults` early path at the archived-empty guard.
114
- const out = await coordinator.searchLongTermArchiveFallback("quarterly report", ["default"], 10, PREFILTER);
109
+ test("query-aware fallback filters non-recallable scoped candidates", async () => {
110
+ const coordinator = queryAwareFallbackCoordinator(SEED_WITH_NON_RECALLABLE);
111
+ const out = await coordinator.searchQueryAwareFallback("quarterly report", 10, PREFILTER);
115
112
  assert.deepEqual(
116
113
  out.map((r) => r.path),
117
114
  ["facts/a.md"],
118
- "meeting record + artifact must not leak through the direct-caller early return",
115
+ "meeting records and artifacts must not leak through query-aware fallback",
119
116
  );
120
117
  });
121
118
 
122
- test("archive fallback excludes non-recallable seeds on the empty-tokens early return", async () => {
123
- const coordinator = fallbackCoordinator(SEED_WITH_NON_RECALLABLE, [memory("facts/z.md", "archived")]);
124
- // Empty prompt zero tokens → the `return scopedSeedResults` early path
125
- // before archive scoring is even attempted.
126
- const out = await coordinator.searchLongTermArchiveFallback("", ["default"], 10, PREFILTER);
119
+ test("query-aware fallback filters non-recallable scoped candidates for an empty query", async () => {
120
+ const coordinator = queryAwareFallbackCoordinator(SEED_WITH_NON_RECALLABLE);
121
+ const out = await coordinator.searchQueryAwareFallback("", 10, PREFILTER);
127
122
  assert.deepEqual(out.map((r) => r.path), ["facts/a.md"]);
128
123
  });
129
124
 
130
- test("archive fallback applies the cap to FILTERED seeds, not raw non-recallable hits", async () => {
131
- // The scoped search returns a single non-recallable hit that would fill a cap
132
- // of 1 if counted raw; the method must count only recallable seeds, so it does
133
- // not early-return that hit and (archive empty) yields nothing.
134
- const coordinator = fallbackCoordinator([result("default", MEETING_RECORD)], []);
135
- const out = await coordinator.searchLongTermArchiveFallback("quarterly", ["default"], 1, prefilter([MEETING_RECORD]));
125
+ test("query-aware fallback applies the cap after filtering", async () => {
126
+ const coordinator = queryAwareFallbackCoordinator([result("default", MEETING_RECORD)]);
127
+ const out = await coordinator.searchQueryAwareFallback("quarterly", 1, prefilter([MEETING_RECORD]));
136
128
  assert.deepEqual(out.map((r) => r.path), []);
137
129
  });
138
130
 
139
- test("finding 1 overfetches scoped seeds before exclusion so a capped fetch still yields recallable hits", async () => {
131
+ test("query-aware fallback excludes root archive candidates before its cap", async () => {
132
+ const archivePath = "archive/2026-02-23/fact-archived.md";
133
+ const coordinator = queryAwareFallbackCoordinator([
134
+ result("default", archivePath),
135
+ result("default", "facts/a.md"),
136
+ ]);
137
+
138
+ const out = await coordinator.searchQueryAwareFallback(
139
+ "quarterly",
140
+ 1,
141
+ prefilter([archivePath, "facts/a.md"]),
142
+ );
143
+
144
+ assert.deepEqual(out.map((r) => r.path), ["facts/a.md"]);
145
+ });
146
+
147
+ test("query-aware fallback retains non-active candidates for historical filtering", async () => {
148
+ let optionsSeen: { allowArchived?: boolean } | undefined;
149
+ const searchScopedMemoryCandidates: RecallSearchPipelineDeps["searchScopedMemoryCandidates"] = async (
150
+ candidatePaths,
151
+ _query,
152
+ limit,
153
+ options,
154
+ ) => {
155
+ optionsSeen = options;
156
+ return [result("default", "facts/superseded.md")]
157
+ .filter((candidate) => candidatePaths.has(candidate.path))
158
+ .slice(0, limit);
159
+ };
160
+ const coordinator = new RecallSearchPipelineCoordinator({
161
+ config: { memoryDir: "/mem" },
162
+ namespaceFromPath: () => "default",
163
+ searchScopedMemoryCandidates,
164
+ } as unknown as RecallSearchPipelineDeps);
165
+
166
+ const out = await coordinator.searchQueryAwareFallback(
167
+ "historical incident",
168
+ 1,
169
+ prefilter(["facts/superseded.md"]),
170
+ );
171
+
172
+ assert.deepEqual(out.map((candidate) => candidate.path), ["facts/superseded.md"]);
173
+ assert.deepEqual(optionsSeen, { allowArchived: true });
174
+ });
175
+
176
+ test("query-aware fallback overfetches scoped candidates before exclusion", async () => {
140
177
  // The scoped candidate order puts the NON-recallable meeting record FIRST;
141
178
  // capping the scoped fetch to the caller's limit (1) BEFORE the generic-recall
142
179
  // exclusion would return only the meeting record, which is then filtered out —
143
180
  // dropping facts/a.md with no refill. Overfetching the full candidate set first
144
181
  // and excluding afterward must still surface the recallable hit at the cap.
145
- const coordinator = fallbackCoordinator(
146
- [result("default", MEETING_RECORD), result("default", "facts/a.md")],
147
- [],
148
- );
149
- const out = await coordinator.searchLongTermArchiveFallback(
182
+ const coordinator = queryAwareFallbackCoordinator([
183
+ result("default", MEETING_RECORD),
184
+ result("default", "facts/a.md"),
185
+ ]);
186
+ const out = await coordinator.searchQueryAwareFallback(
150
187
  "quarterly",
151
- ["default"],
152
188
  1,
153
189
  prefilter([MEETING_RECORD, "facts/a.md"]),
154
190
  );
@@ -159,6 +195,7 @@ test("finding 1 — overfetches scoped seeds before exclusion so a capped fetch
159
195
  );
160
196
  });
161
197
 
198
+
162
199
  function hotSeedCoordinator(seed: QmdSearchResult[]): RecallSearchPipelineCoordinator {
163
200
  const deps = {
164
201
  config: { memoryDir: "/mem", qmdSearchStrategy: "lex", searchBackend: "qmd" },
@@ -250,3 +287,132 @@ test("finding 1 (round-5) — excluded seeds must not fill the merge cap and sta
250
287
  "excluded seeds ranked ahead must not fill the fetchLimit cap and starve the recallable primary hit",
251
288
  );
252
289
  });
290
+
291
+ test("QMD widening continues past archive-only pages", async () => {
292
+ const archived = Array.from({ length: 45 }, (_, index) =>
293
+ resultScored("default", `archive/2026-07-25/archive-${index}.md`, 2),
294
+ );
295
+ const live = resultScored("default", "facts/live.md", 1);
296
+ const ranked = [...archived, live];
297
+ const coordinator = new RecallSearchPipelineCoordinator({
298
+ config: { memoryDir: "/mem", qmdSearchStrategy: "lex", searchBackend: "qmd" },
299
+ qmd: {},
300
+ namespaceFromPath: () => "default",
301
+ searchAcrossNamespaces: async ({ maxResults }: { maxResults: number }) =>
302
+ ranked.slice(0, maxResults),
303
+ } as unknown as RecallSearchPipelineDeps);
304
+
305
+ const out = await coordinator.fetchQmdMemoryResultsWithArtifactTopUp(
306
+ "archive-starved",
307
+ 20,
308
+ 20,
309
+ {
310
+ namespacesEnabled: false,
311
+ recallNamespaces: ["default"],
312
+ resolveNamespace: () => "default",
313
+ queryAwarePrefilter: {
314
+ candidatePaths: null,
315
+ temporalFromDate: null,
316
+ matchedTags: [],
317
+ expandedTags: [],
318
+ combination: "none",
319
+ filteredToFullSearch: false,
320
+ },
321
+ },
322
+ );
323
+
324
+ assert.deepEqual(out.map((candidate) => candidate.path), ["facts/live.md"]);
325
+ });
326
+
327
+ test("QMD hybrid top-up runs when primary results are archive-only", async () => {
328
+ const archived = Array.from({ length: 20 }, (_, index) =>
329
+ resultScored("default", `archive/2026-07-25/archive-${index}.md`, 2),
330
+ );
331
+ const live = resultScored("default", "facts/hybrid-live.md", 1);
332
+ let hybridCalls = 0;
333
+ const coordinator = new RecallSearchPipelineCoordinator({
334
+ config: {
335
+ memoryDir: "/mem",
336
+ qmdSearchStrategy: "hybrid",
337
+ searchBackend: "qmd",
338
+ },
339
+ qmd: {
340
+ search: async (
341
+ _query: string,
342
+ _collection: string,
343
+ maxResults: number,
344
+ ) => archived.slice(0, maxResults),
345
+ hybridSearch: async () => {
346
+ hybridCalls += 1;
347
+ return [live];
348
+ },
349
+ },
350
+ namespaceFromPath: () => "default",
351
+ } as unknown as RecallSearchPipelineDeps);
352
+
353
+ const out = await coordinator.fetchQmdMemoryResultsWithArtifactTopUp(
354
+ "archive-starved-hybrid",
355
+ 2,
356
+ 2,
357
+ {
358
+ namespacesEnabled: false,
359
+ recallNamespaces: ["default"],
360
+ resolveNamespace: () => "default",
361
+ collection: "openclaw-engram",
362
+ queryAwarePrefilter: {
363
+ candidatePaths: null,
364
+ temporalFromDate: null,
365
+ matchedTags: [],
366
+ expandedTags: [],
367
+ combination: "none",
368
+ filteredToFullSearch: false,
369
+ },
370
+ },
371
+ );
372
+
373
+ assert.ok(hybridCalls > 0, "archive-only primary pages must trigger hybrid top-up");
374
+ assert.deepEqual(out.map((candidate) => candidate.path), ["facts/hybrid-live.md"]);
375
+ });
376
+
377
+ test("QMD stops after an underfilled archive-only page", async () => {
378
+ const archived = [resultScored("default", "archive/2026-07-25/only.md", 2)];
379
+ let searchCalls = 0;
380
+ const coordinator = new RecallSearchPipelineCoordinator({
381
+ config: {
382
+ memoryDir: "/mem",
383
+ qmdSearchStrategy: "hybrid",
384
+ searchBackend: "qmd",
385
+ },
386
+ qmd: {
387
+ search: async () => {
388
+ searchCalls += 1;
389
+ return archived;
390
+ },
391
+ hybridSearch: async () => [],
392
+ },
393
+ namespaceFromPath: () => "default",
394
+ } as unknown as RecallSearchPipelineDeps);
395
+
396
+ const out = await coordinator.fetchQmdMemoryResultsWithArtifactTopUp(
397
+ "archive-underfilled",
398
+ 20,
399
+ 20,
400
+ {
401
+ namespacesEnabled: false,
402
+ recallNamespaces: ["default"],
403
+ resolveNamespace: () => "default",
404
+ collection: "openclaw-engram",
405
+ queryAwarePrefilter: {
406
+ candidatePaths: null,
407
+ temporalFromDate: null,
408
+ matchedTags: [],
409
+ expandedTags: [],
410
+ combination: "none",
411
+ filteredToFullSearch: false,
412
+ },
413
+ },
414
+ );
415
+
416
+ assert.deepEqual(out, []);
417
+ assert.equal(searchCalls, 1, "an underfilled raw page must not trigger widening");
418
+ });
@@ -28,7 +28,6 @@ import { NamespaceStorageRouter } from "../namespaces/storage.js";
28
28
  import { NegativeExampleStore } from "../negative.js";
29
29
  import { qmdCollectionPathParts } from "./qmd-result-resolver.js";
30
30
  import type { GraphRecallExpandedEntry } from "../recall-state.js";
31
- import { getDefaultArchiveScoring, memoryFileToScoreItem } from "../recall/archive-scoring.js";
32
31
  import { RelevanceStore } from "../relevance.js";
33
32
  import { RerankCache, rerankLocalOrNoop, reorderByRankedKeys } from "../rerank.js";
34
33
  import type { SearchBackend, SearchDegradation, SearchExecutionOptions, SearchQueryOptions } from "../search/port.js";
@@ -55,11 +54,11 @@ import {
55
54
  computeQmdHybridFetchLimit,
56
55
  filterRecallCandidates,
57
56
  throwIfRecallAborted,
58
- tokenizeRecallQuery,
59
57
  type QmdRecallSnapshot,
60
58
  type QueryAwarePrefilter,
61
59
  } from "../orchestrator.js";
62
- import { isActivityDigestPath, isGenericRecallExcludedPath } from "./orchestrator-helpers.js";
60
+ import { isActivityDigestPath } from "./orchestrator-helpers.js";
61
+ import { isGenericRecallExcludedPath, isTopLevelArchivePath } from "./generic-recall-paths.js";
63
62
 
64
63
  export interface RecallSearchPipelineDeps {
65
64
  applyMemoryWorthRerank(
@@ -185,7 +184,6 @@ export interface RecallSearchPipelineDeps {
185
184
  namespaceFromPath(p: string): string;
186
185
  readonly negatives: NegativeExampleStore;
187
186
  readonly qmd: SearchBackend;
188
- readArchivedMemoriesForNamespaces(namespaces: string[]): Promise<MemoryFile[]>;
189
187
  readQmdResultMemory(
190
188
  resultPath: string,
191
189
  fallbackStorage: StorageManager,
@@ -215,9 +213,8 @@ export interface RecallSearchPipelineDeps {
215
213
  searchOptions?: SearchQueryOptions;
216
214
  execution?: SearchExecutionOptions;
217
215
  }): Promise<QmdSearchResult[]>;
218
- searchLongTermArchiveFallback(
216
+ searchQueryAwareFallback(
219
217
  prompt: string,
220
- recallNamespaces: string[],
221
218
  limit: number,
222
219
  queryAwarePrefilter?: QueryAwarePrefilter,
223
220
  abortSignal?: AbortSignal,
@@ -422,26 +419,19 @@ export class RecallSearchPipelineCoordinator {
422
419
  ? await this.deps.searchScopedMemoryCandidates(
423
420
  queryAwarePrefilter.candidatePaths,
424
421
  prompt,
425
- // Overfetch the FULL scoped candidate set BEFORE the generic-recall
426
- // exclusion `filterRecallCandidates` applies below (L464). Capping to
427
- // `qmdFetchLimit` here lets excluded paths (artifacts, activity digests,
428
- // meeting records) consume the caller's budget and drop legitimate hits
429
- // with no refill — the hot-path mirror of the archive-fallback fix. The
430
- // candidate set is an already-bounded prefilter, so its size is the
431
- // natural ceiling; the post-exclusion cap is applied by filterRecallCandidates.
422
+ // Exclude dedicated surfaces before capping the bounded prefilter set.
432
423
  queryAwarePrefilter.candidatePaths.size,
433
424
  { allowArchived: options.collection !== undefined },
434
425
  )
435
426
  : [];
436
427
  // Drop generic-recall-excluded records before the fetchLimit cap so they can't starve valid memories (#1995).
437
- const recallable = (r: QmdSearchResult) => !isGenericRecallExcludedPath(r.path, this.deps.config.memoryDir);
428
+ const recallable = (r: QmdSearchResult) => !isGenericRecallExcludedPath(r.path, this.deps.config, "qmd");
438
429
 
439
430
  let fetchLimit = Math.max(qmdFetchLimit, qmdHybridFetchLimit);
440
431
  const maxFetchLimit = Math.min(
441
432
  320,
442
433
  Math.max(fetchLimit, qmdFetchLimit * 5),
443
434
  );
444
- const MAX_ATTEMPTS = 2;
445
435
  const qmdRecallBudgetMs = this.deps.config.recallEnrichmentDeadlineMs ?? 25_000;
446
436
  const qmdRecallBudgetEnabled = qmdRecallBudgetMs > 0;
447
437
  const startedAtMs = Date.now();
@@ -475,7 +465,7 @@ export class RecallSearchPipelineCoordinator {
475
465
  recallNamespaces: options.recallNamespaces,
476
466
  resolveNamespace: options.resolveNamespace,
477
467
  limit: qmdFetchLimit,
478
- memoryRoot: this.deps.config.memoryDir,
468
+ pathPolicy: this.deps.config,
479
469
  });
480
470
  const emitDebugSnapshot = async (
481
471
  results: QmdSearchResult[],
@@ -508,7 +498,7 @@ export class RecallSearchPipelineCoordinator {
508
498
  return [];
509
499
  }
510
500
 
511
- for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) {
501
+ for (;;) {
512
502
  throwIfRecallAborted(options.abortSignal);
513
503
  if (
514
504
  qmdRecallBudgetEnabled &&
@@ -554,10 +544,15 @@ export class RecallSearchPipelineCoordinator {
554
544
  lastHybridTopUpUsed = false;
555
545
  lastHybridTopUpSkippedReason = undefined;
556
546
  let mergedResults = primaryResults;
547
+ const primaryRecallableCount = primaryResults.filter(recallable).length;
548
+ const primaryIncludesArchivePath = primaryResults.some((result) =>
549
+ isTopLevelArchivePath(result.path, this.deps.config, "qmd"),
550
+ );
557
551
 
558
- // Backfill with hybrid results only when primary retrieval underfills.
552
+ // Backfill with hybrid results when the recallable primary page underfills.
559
553
  if (
560
- primaryResults.length < qmdFetchLimit &&
554
+ primaryRecallableCount < qmdFetchLimit &&
555
+ (primaryResults.length < qmdFetchLimit || primaryIncludesArchivePath) &&
561
556
  (!qmdRecallBudgetEnabled ||
562
557
  Date.now() - startedAtMs < qmdRecallBudgetMs)
563
558
  ) {
@@ -620,7 +615,7 @@ export class RecallSearchPipelineCoordinator {
620
615
  recallNamespaces: options.recallNamespaces,
621
616
  resolveNamespace: options.resolveNamespace,
622
617
  limit: fetchLimit,
623
- memoryRoot: this.deps.config.memoryDir,
618
+ pathPolicy: this.deps.config,
624
619
  });
625
620
 
626
621
  if (filteredResults.length >= qmdFetchLimit) {
@@ -631,11 +626,9 @@ export class RecallSearchPipelineCoordinator {
631
626
  if (filteredResults.length > bestFiltered.length) {
632
627
  bestFiltered = filteredResults;
633
628
  }
634
- if (mergedResults.length === 0) {
635
- await emitDebugSnapshot(filteredResults, fetchLimit);
636
- return filteredResults;
637
- }
638
- if (mergedResults.length < fetchLimit && filteredResults.length > 0) {
629
+ // A full raw page may filter down to nothing (for example, archive-only
630
+ // hits). Keep widening until the backend itself underfills the page.
631
+ if (primaryResults.length < fetchLimit) {
639
632
  await emitDebugSnapshot(filteredResults, fetchLimit);
640
633
  return filteredResults;
641
634
  }
@@ -679,20 +672,18 @@ export class RecallSearchPipelineCoordinator {
679
672
  }
680
673
 
681
674
  /**
682
- * Long-term fallback retrieval.
683
- * Searches archived memories only, and is invoked only when hot recall returns zero hits.
675
+ * Cold fallback retains qualifying query-aware candidates. Archive records
676
+ * belong to dedicated surfaces and never re-enter generic recall.
684
677
  */
685
- async searchLongTermArchiveFallback(
678
+ async searchQueryAwareFallback(
686
679
  prompt: string,
687
- recallNamespaces: string[],
688
680
  limit: number,
689
681
  queryAwarePrefilter?: QueryAwarePrefilter,
690
682
  abortSignal?: AbortSignal,
691
683
  ): Promise<QmdSearchResult[]> {
692
684
  throwIfRecallAborted(abortSignal);
693
685
  const cappedLimit = Math.max(0, limit);
694
- if (cappedLimit === 0) return [];
695
- if (queryAwarePrefilter?.candidatePaths?.size === 0) return [];
686
+ if (cappedLimit === 0 || queryAwarePrefilter?.candidatePaths?.size === 0) return [];
696
687
 
697
688
  const candidatePaths = queryAwarePrefilter?.candidatePaths;
698
689
  const scopedSeedResults = (
@@ -700,57 +691,12 @@ export class RecallSearchPipelineCoordinator {
700
691
  ? await this.deps.searchScopedMemoryCandidates(
701
692
  candidatePaths,
702
693
  prompt,
703
- // Overfetch the FULL scoped candidate set BEFORE the generic-recall
704
- // exclusion below. Capping to `cappedLimit` first lets excluded paths
705
- // (artifacts, activity digests, meeting records) consume the caller's
706
- // budget and drop legitimate hits with no refill — e.g. a prefilter of
707
- // [meetingRecord, facts/a.md] at limit 1 would return nothing. The
708
- // candidate set is an already-bounded prefilter, so its size is the
709
- // natural ceiling; the post-exclusion cap is applied below.
710
694
  candidatePaths.size,
711
695
  { allowArchived: true },
712
696
  )
713
697
  : []
714
- ).filter((result) => !isGenericRecallExcludedPath(result.path, this.deps.config.memoryDir));
715
- // Non-recallable seed paths (artifacts, activity digests, meeting records) are
716
- // excluded above AFTER an unconstrained candidate fetch, so the cap here counts
717
- // only recallable seeds — and EVERY early-return / direct-caller path below
718
- // returns already-filtered, budget-honoring seeds.
719
- if (scopedSeedResults.length >= cappedLimit) {
720
- return scopedSeedResults.slice(0, cappedLimit);
721
- }
722
-
723
- const tokens = Array.from(new Set(tokenizeRecallQuery(prompt)));
724
- if (tokens.length === 0) return scopedSeedResults;
725
-
726
- throwIfRecallAborted(abortSignal);
727
- const archivedMemories =
728
- await this.deps.readArchivedMemoriesForNamespaces(recallNamespaces);
729
- if (archivedMemories.length === 0) return scopedSeedResults;
730
-
731
- // Issue #1674: off-load the CPU-bound archive-scoring loop to a
732
- // worker_threads pool so concurrent recall requests run on separate
733
- // cores instead of serializing on the main JS thread. The pure scoring
734
- // function is identical to the old inline loop — only the execution
735
- // context changed. Aborts are checked at the boundaries (before submit
736
- // and after result); the worker's work is bounded by the file count.
737
- throwIfRecallAborted(abortSignal);
738
- const scoring = getDefaultArchiveScoring();
739
- const scoredResults = await scoring.score(archivedMemories.map(memoryFileToScoreItem), tokens, abortSignal);
740
- throwIfRecallAborted(abortSignal);
741
- const scored: QmdSearchResult[] = scoredResults.map((r) => ({
742
- docid: r.docid,
743
- path: r.path,
744
- score: r.score,
745
- snippet: r.snippet,
746
- }));
747
-
748
- return dedupeResultsByNamespace(
749
- [...scopedSeedResults, ...scored],
750
- this.deps.namespaceFromPath,
751
- cappedLimit,
752
- { filter: (result) => !isGenericRecallExcludedPath(result.path, this.deps.config.memoryDir) },
753
- );
698
+ ).filter((result) => !isGenericRecallExcludedPath(result.path, this.deps.config, "qmd"));
699
+ return scopedSeedResults.slice(0, cappedLimit);
754
700
  }
755
701
 
756
702
  async applyColdFallbackPipeline(options: {
@@ -935,17 +881,19 @@ export class RecallSearchPipelineCoordinator {
935
881
  }
936
882
  }
937
883
  if (longTerm.length === 0) {
938
- // The archive-scan scoring worker terminates when EITHER the caller
939
- // aborts or this step's own deadline wins — both flow through the
940
- // step signal injected by runColdStepWithinDeadline (#1674, #1907).
941
884
  longTerm = await runColdStepWithinDeadline(
942
- "archive scan", [],
943
- (stepSignal) => this.deps.searchLongTermArchiveFallback(
944
- options.prompt, options.recallNamespaces, options.recallResultLimit,
945
- options.queryAwarePrefilter, stepSignal),
885
+ "query-aware fallback",
886
+ [],
887
+ (stepSignal) =>
888
+ this.deps.searchQueryAwareFallback(
889
+ options.prompt,
890
+ options.recallResultLimit,
891
+ options.queryAwarePrefilter,
892
+ stepSignal,
893
+ ),
946
894
  );
947
895
  if (longTerm.length > 0) {
948
- log.debug("cold-tier recall source=archive-scan");
896
+ log.debug("cold-tier recall source=query-aware-fallback");
949
897
  }
950
898
  }
951
899
  if (longTerm.length === 0) return [];
@@ -997,9 +945,8 @@ export class RecallSearchPipelineCoordinator {
997
945
  }
998
946
  results = scopedResults;
999
947
  }
1000
- // Dedicated-surface isolation: generic recall must exclude artifacts and
1001
- // activity digests (both remain readable via explicit search).
1002
- results = results.filter((r) => !isGenericRecallExcludedPath(r.path, this.deps.config.memoryDir));
948
+ // Dedicated-surface isolation keeps generic recall out of artifacts, activity digests, and archives.
949
+ results = results.filter((r) => !isGenericRecallExcludedPath(r.path, this.deps.config, "qmd"));
1003
950
  if (results.length === 0) return [];
1004
951
 
1005
952
  const isFullModeGraphAssist =
@@ -1022,6 +969,9 @@ export class RecallSearchPipelineCoordinator {
1022
969
  });
1023
970
  results = merged;
1024
971
  }
972
+ results = results.filter(
973
+ (result) => !isGenericRecallExcludedPath(result.path, this.deps.config, "qmd"),
974
+ );
1025
975
 
1026
976
  const boostInput = await this.deps.filterSearchResultsForRecall(
1027
977
  results,
@@ -134,11 +134,11 @@ import {
134
134
  computeSemanticDedupScope,
135
135
  qmdCollectionNamespaceFromPrefix as computeQmdCollectionNamespaceFromPrefix,
136
136
  } from "./orchestration/orchestrator-namespace-scope.js";
137
+ import { isGenericRecallExcludedPath } from "./orchestration/generic-recall-paths.js";
137
138
  import {
138
139
  abortRecallError,
139
140
  buildCompressionGuidelinesMarkdown,
140
141
  buildQmdIntentHint,
141
- isGenericRecallExcludedPath,
142
142
  mergeArtifactRecallCandidates,
143
143
  tokenizeRecallQuery,
144
144
  type BulkImportBatchIngestResult,
@@ -163,7 +163,6 @@ export {
163
163
  computeQmdHybridFetchLimit,
164
164
  defaultWorkspaceDir,
165
165
  filterHourlySummaryMarkdownForLocalDay,
166
- filterRecallCandidates,
167
166
  formatDateInTimeZone,
168
167
  isArtifactMemoryPath,
169
168
  lifecycleRecallScoreAdjustment,
@@ -198,6 +197,7 @@ export {
198
197
  type RecallInvocationOptions,
199
198
  type RecallModeDecision,
200
199
  } from "./orchestration/orchestrator-helpers.js";
200
+ export { filterRecallCandidates } from "./orchestration/generic-recall-paths.js";
201
201
 
202
202
  export { hasIdentityRecoveryIntent, resolveEffectiveIdentityInjectionMode } from "./orchestration/recall-result-formatter.js";
203
203
  import {
@@ -541,7 +541,6 @@ import type {
541
541
  RecallSectionConfig,
542
542
  RecallTierExplain,
543
543
  } from "./types.js";
544
- import { disposeDefaultArchiveScoring, getDefaultArchiveScoring, memoryFileToScoreItem } from "./recall/archive-scoring.js";
545
544
 
546
545
  // Issue #1526 seam 15: ExtractionRunResult moved to orchestration/extraction-run.ts.
547
546
  export type { ExtractionRunResult } from "./orchestration/extraction-run.js";
@@ -997,8 +996,6 @@ export class Orchestrator {
997
996
  if (this.conversationQmd && this.conversationQmd !== this.qmd) {
998
997
  await (this.conversationQmd as { dispose?: () => void | Promise<void> }).dispose?.();
999
998
  }
1000
- // Issue #1674: terminate archive-scoring worker threads on destroy.
1001
- await disposeDefaultArchiveScoring();
1002
999
  } finally {
1003
1000
  if (bufferFlushError !== undefined) {
1004
1001
  log.warn(
@@ -2483,7 +2480,7 @@ export class Orchestrator {
2483
2480
  if (!paths) return null;
2484
2481
  const scoped = new Set<string>();
2485
2482
  for (const memoryPath of paths) {
2486
- if (!memoryPath || isGenericRecallExcludedPath(memoryPath, this.config.memoryDir)) continue;
2483
+ if (!memoryPath || isGenericRecallExcludedPath(memoryPath, this.config, "qmd")) continue;
2487
2484
  if (
2488
2485
  resolveNamespaceCapabilities(this.config).namespaces &&
2489
2486
  !recallNamespaces.includes(this.namespaceFromPath(memoryPath))
@@ -3600,16 +3597,14 @@ export class Orchestrator {
3600
3597
  );
3601
3598
  }
3602
3599
 
3603
- private async searchLongTermArchiveFallback(
3600
+ private async searchQueryAwareFallback(
3604
3601
  prompt: string,
3605
- recallNamespaces: string[],
3606
3602
  limit: number,
3607
3603
  queryAwarePrefilter?: QueryAwarePrefilter,
3608
3604
  abortSignal?: AbortSignal,
3609
3605
  ): Promise<QmdSearchResult[]> {
3610
- return this.recallSearchPipelineCoordinator.searchLongTermArchiveFallback(
3606
+ return this.recallSearchPipelineCoordinator.searchQueryAwareFallback(
3611
3607
  prompt,
3612
- recallNamespaces,
3613
3608
  limit,
3614
3609
  queryAwarePrefilter,
3615
3610
  abortSignal,
@@ -3968,11 +3963,4 @@ export class Orchestrator {
3968
3963
  namespaces,
3969
3964
  );
3970
3965
  }
3971
- private async readArchivedMemoriesForNamespaces(
3972
- namespaces: string[],
3973
- ): Promise<MemoryFile[]> {
3974
- return this.namespaceReadFanoutCoordinator.readArchivedMemoriesForNamespaces(
3975
- namespaces,
3976
- );
3977
- }
3978
3966
  }