@remnic/core 9.3.651 → 9.3.652

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 (76) hide show
  1. package/dist/access-cli.js +9 -9
  2. package/dist/access-http.d.ts +3 -2
  3. package/dist/access-http.js +10 -10
  4. package/dist/access-mcp.d.ts +3 -2
  5. package/dist/access-mcp.js +9 -9
  6. package/dist/{access-service-DIZRHQ7Q.d.ts → access-service-CdJFd3_b.d.ts} +23 -2
  7. package/dist/access-service.d.ts +3 -2
  8. package/dist/access-service.js +8 -8
  9. package/dist/bootstrap.d.ts +2 -1
  10. package/dist/{chunk-QT4THOLT.js → chunk-2DGQLOOM.js} +1 -1
  11. package/dist/chunk-2DGQLOOM.js.map +1 -0
  12. package/dist/{chunk-SLYD3AH4.js → chunk-5V3TAB7D.js} +176 -4
  13. package/dist/chunk-5V3TAB7D.js.map +1 -0
  14. package/dist/{chunk-FOVPSMGI.js → chunk-7WEB3FLJ.js} +2 -2
  15. package/dist/{chunk-WJK75OCH.js → chunk-GI45G4BK.js} +2 -2
  16. package/dist/{chunk-76QTEJ2Q.js → chunk-JBHXMCYN.js} +2 -2
  17. package/dist/{chunk-4PTKFBST.js → chunk-JVRPJ7D4.js} +126 -26
  18. package/dist/chunk-JVRPJ7D4.js.map +1 -0
  19. package/dist/{chunk-TQUWNX7C.js → chunk-JX2RINDR.js} +2 -2
  20. package/dist/{chunk-RSS2KWN6.js → chunk-MGGNV3H2.js} +2 -2
  21. package/dist/{chunk-I4COC5XW.js → chunk-PYWNNF2I.js} +47 -9
  22. package/dist/chunk-PYWNNF2I.js.map +1 -0
  23. package/dist/{chunk-5WSDHTBO.js → chunk-TCX4WLKK.js} +7 -4
  24. package/dist/chunk-TCX4WLKK.js.map +1 -0
  25. package/dist/{chunk-RKN5J4RO.js → chunk-WSFNYPAT.js} +6 -6
  26. package/dist/{chunk-LFTLXOFX.js → chunk-WTI35CVJ.js} +2 -2
  27. package/dist/{chunk-6UKL6IXM.js → chunk-YM3LR4LS.js} +5 -5
  28. package/dist/{chunk-MF32AL7N.js → chunk-YOVKPOMD.js} +3 -3
  29. package/dist/{cli-BG4ybtJr.d.ts → cli-DDo7Qgs-.d.ts} +2 -2
  30. package/dist/cli.d.ts +4 -3
  31. package/dist/cli.js +13 -13
  32. package/dist/explicit-capture.d.ts +2 -1
  33. package/dist/index.d.ts +5 -4
  34. package/dist/index.js +14 -14
  35. package/dist/mcp-memory-inspector-app.d.ts +3 -2
  36. package/dist/namespaces/migrate.js +8 -8
  37. package/dist/namespaces/search.d.ts +18 -1
  38. package/dist/namespaces/search.js +7 -7
  39. package/dist/operator-toolkit.js +9 -9
  40. package/dist/{orchestrator-CX-oqwJq.d.ts → orchestrator-8fTZsa0y.d.ts} +2 -0
  41. package/dist/orchestrator.d.ts +2 -1
  42. package/dist/orchestrator.js +8 -8
  43. package/dist/qmd.d.ts +2 -1
  44. package/dist/qmd.js +2 -2
  45. package/dist/schemas.d.ts +22 -22
  46. package/dist/search/factory.js +6 -6
  47. package/dist/search/index.js +6 -6
  48. package/dist/search/lancedb-backend.js +2 -2
  49. package/dist/search/meilisearch-backend.js +2 -2
  50. package/dist/search/orama-backend.js +2 -2
  51. package/dist/search/port.d.ts +6 -0
  52. package/dist/search/port.js +1 -1
  53. package/dist/transfer/types.d.ts +12 -12
  54. package/package.json +1 -1
  55. package/src/access-service-health.test.ts +402 -0
  56. package/src/access-service.ts +274 -2
  57. package/src/namespaces/search.test.ts +258 -3
  58. package/src/namespaces/search.ts +184 -30
  59. package/src/orchestrator.ts +11 -1
  60. package/src/qmd.test.ts +102 -0
  61. package/src/qmd.ts +54 -7
  62. package/src/search/port.ts +6 -0
  63. package/dist/chunk-4PTKFBST.js.map +0 -1
  64. package/dist/chunk-5WSDHTBO.js.map +0 -1
  65. package/dist/chunk-I4COC5XW.js.map +0 -1
  66. package/dist/chunk-QT4THOLT.js.map +0 -1
  67. package/dist/chunk-SLYD3AH4.js.map +0 -1
  68. /package/dist/{chunk-FOVPSMGI.js.map → chunk-7WEB3FLJ.js.map} +0 -0
  69. /package/dist/{chunk-WJK75OCH.js.map → chunk-GI45G4BK.js.map} +0 -0
  70. /package/dist/{chunk-76QTEJ2Q.js.map → chunk-JBHXMCYN.js.map} +0 -0
  71. /package/dist/{chunk-TQUWNX7C.js.map → chunk-JX2RINDR.js.map} +0 -0
  72. /package/dist/{chunk-RSS2KWN6.js.map → chunk-MGGNV3H2.js.map} +0 -0
  73. /package/dist/{chunk-RKN5J4RO.js.map → chunk-WSFNYPAT.js.map} +0 -0
  74. /package/dist/{chunk-LFTLXOFX.js.map → chunk-WTI35CVJ.js.map} +0 -0
  75. /package/dist/{chunk-6UKL6IXM.js.map → chunk-YM3LR4LS.js.map} +0 -0
  76. /package/dist/{chunk-MF32AL7N.js.map → chunk-YOVKPOMD.js.map} +0 -0
@@ -6,17 +6,22 @@ import type {
6
6
  SearchExecutionOptions,
7
7
  SearchQueryOptions,
8
8
  } from "../search/port.js";
9
+ import type { QmdCapabilities, QmdVersionStatus } from "../qmd.js";
9
10
  import type { PluginConfig, QmdSearchResult } from "../types.js";
10
11
 
11
12
  type CollectionState = "present" | "missing" | "unknown" | "skipped";
12
13
 
13
14
  class FakeBackend implements SearchBackend {
14
15
  updates = 0;
16
+ disposed = 0;
17
+ available = true;
15
18
  calls: Array<{
16
19
  method: string;
17
20
  collection: string | undefined;
18
21
  maxResults: number | undefined;
19
22
  }> = [];
23
+ availabilitySignals: Array<AbortSignal | undefined> = [];
24
+ probeCalls = 0;
20
25
  ensureSignals: Array<AbortSignal | undefined> = [];
21
26
  ensureCollections: Array<string | undefined> = [];
22
27
  checkSignals: Array<AbortSignal | undefined> = [];
@@ -29,6 +34,11 @@ class FakeBackend implements SearchBackend {
29
34
  check?: CollectionState;
30
35
  ensure?: CollectionState;
31
36
  } = {},
37
+ private readonly daemonMode = false,
38
+ private readonly diagnostics: {
39
+ debugStatus?: string;
40
+ versionStatus?: QmdVersionStatus;
41
+ } = {},
32
42
  ) {}
33
43
 
34
44
  private limitedResults(maxResults: number | undefined): QmdSearchResult[] {
@@ -38,15 +48,33 @@ class FakeBackend implements SearchBackend {
38
48
  }
39
49
 
40
50
  async probe(): Promise<boolean> {
41
- return true;
51
+ this.probeCalls += 1;
52
+ return this.available;
53
+ }
54
+
55
+ async checkAvailability(execution?: SearchExecutionOptions): Promise<boolean> {
56
+ this.availabilitySignals.push(execution?.signal);
57
+ return this.available;
42
58
  }
43
59
 
44
60
  isAvailable(): boolean {
45
- return true;
61
+ return this.available;
46
62
  }
47
63
 
48
64
  debugStatus(): string {
49
- return "fake";
65
+ return this.diagnostics.debugStatus ?? "fake";
66
+ }
67
+
68
+ isDaemonMode(): boolean {
69
+ return this.daemonMode;
70
+ }
71
+
72
+ getVersionStatus(): QmdVersionStatus | null {
73
+ return this.diagnostics.versionStatus ?? null;
74
+ }
75
+
76
+ async dispose(): Promise<void> {
77
+ this.disposed += 1;
50
78
  }
51
79
 
52
80
  async search(
@@ -154,6 +182,41 @@ function config(): PluginConfig {
154
182
  } as PluginConfig;
155
183
  }
156
184
 
185
+ function qmdCapabilities(enabled: boolean): QmdCapabilities {
186
+ return {
187
+ version: enabled ? "2.5.3" : null,
188
+ parsedVersion: enabled ? [2, 5, 3] : null,
189
+ stableSdk: enabled,
190
+ unifiedSearch: enabled,
191
+ getDocumentBody: enabled,
192
+ maintenanceApi: enabled,
193
+ legacySkillInstall: enabled,
194
+ intentHints: enabled,
195
+ explainTraces: enabled,
196
+ candidateLimit: enabled,
197
+ v2McpQueryTool: enabled,
198
+ structuredSearches: enabled,
199
+ queryRerankToggle: enabled,
200
+ chunkStrategy: enabled,
201
+ qmdBench: enabled,
202
+ perCollectionModels: enabled,
203
+ jsonLineNumbers: enabled,
204
+ editorLinks: enabled,
205
+ doctor: enabled,
206
+ versionedSkills: enabled,
207
+ absoluteSnippetLines: enabled,
208
+ fullQueryOutput: enabled,
209
+ forceCpu: enabled,
210
+ gpuBackendOverride: enabled,
211
+ embedParallelism: enabled,
212
+ modelEnvConsistency: enabled,
213
+ scopedEmbed: enabled,
214
+ safeStatusDeviceProbe: enabled,
215
+ mcpIndexSelection: enabled,
216
+ outputFormatFlag: enabled,
217
+ };
218
+ }
219
+
157
220
  test("updateNamespaces runs a global-update backend only once", async () => {
158
221
  const created: FakeBackend[] = [];
159
222
  const router = new NamespaceSearchRouter(
@@ -304,6 +367,198 @@ test("legacy default namespace root fail-opens missing guarded collections", asy
304
367
  assert.deepEqual(backend.ensureCollections, []);
305
368
  });
306
369
 
370
+ test("healthForNamespace checks namespace collection without auto-creating or caching state", async () => {
371
+ const created: FakeBackend[] = [];
372
+ const router = new NamespaceSearchRouter(
373
+ config(),
374
+ { storageFor: async (namespace: string) => ({ dir: `/tmp/remnic/${namespace}` }) },
375
+ () => {
376
+ const backend = new FakeBackend(false, [], created.length === 0
377
+ ? { check: "missing" }
378
+ : { ensure: "present" });
379
+ created.push(backend);
380
+ return backend;
381
+ },
382
+ );
383
+
384
+ const health = await router.healthForNamespace("shared");
385
+
386
+ assert.equal(health.collectionState, "missing");
387
+ assert.equal(health.collection, "openclaw-engram--ns-736861726564");
388
+ assert.deepEqual(created[0]?.checkCollections, ["openclaw-engram--ns-736861726564"]);
389
+ assert.deepEqual(created[0]?.ensureCollections, []);
390
+ assert.equal(created[0]?.probeCalls, 0);
391
+ assert.equal(created[0]?.availabilitySignals.length, 1);
392
+ assert.equal(created[0]?.disposed, 1);
393
+
394
+ const ensured = await router.ensureNamespaceCollection("shared");
395
+
396
+ assert.equal(ensured, "present");
397
+ assert.equal(created.length, 2);
398
+ assert.deepEqual(created[1]?.ensureCollections, ["openclaw-engram--ns-736861726564"]);
399
+ });
400
+
401
+ test("healthForNamespace reports daemon mode from live cached namespace backend", async () => {
402
+ const created: FakeBackend[] = [];
403
+ const router = new NamespaceSearchRouter(
404
+ config(),
405
+ { storageFor: async (namespace: string) => ({ dir: `/tmp/remnic/${namespace}` }) },
406
+ () => {
407
+ const backend = created.length === 0
408
+ ? new FakeBackend(false, [
409
+ {
410
+ path: "facts/a.md",
411
+ docid: "a",
412
+ score: 1,
413
+ snippet: "a",
414
+ },
415
+ ], { ensure: "present" }, true, {
416
+ debugStatus: "live-daemon",
417
+ versionStatus: {
418
+ installedVersion: "qmd 2.5.3",
419
+ supportedVersion: "2.5.3",
420
+ supported: true,
421
+ newerThanSupported: false,
422
+ upgradeAvailable: false,
423
+ capabilities: qmdCapabilities(true),
424
+ },
425
+ })
426
+ : new FakeBackend(false, [], { check: "present" }, false, {
427
+ debugStatus: "probe-unavailable",
428
+ versionStatus: {
429
+ installedVersion: null,
430
+ supportedVersion: "2.5.3",
431
+ supported: false,
432
+ newerThanSupported: false,
433
+ upgradeAvailable: false,
434
+ capabilities: qmdCapabilities(false),
435
+ },
436
+ });
437
+ if (created.length === 1) {
438
+ backend.available = false;
439
+ }
440
+ created.push(backend);
441
+ return backend;
442
+ },
443
+ );
444
+
445
+ await router.searchAcrossNamespaces({
446
+ query: "a",
447
+ namespaces: ["shared"],
448
+ maxResults: 1,
449
+ });
450
+ const health = await router.healthForNamespace("shared");
451
+
452
+ assert.equal(health.available, true);
453
+ assert.equal(health.daemonMode, true);
454
+ assert.equal(health.debugStatus, "live-daemon");
455
+ assert.equal(health.installedVersion, "qmd 2.5.3");
456
+ assert.equal(health.supportedVersion, "2.5.3");
457
+ assert.equal(health.supported, true);
458
+ assert.equal(health.upgradeAvailable, false);
459
+ assert.equal(health.doctorAvailable, true);
460
+ assert.equal(health.collectionState, "unknown");
461
+ assert.equal(created.length, 2);
462
+ assert.equal(created[0]?.disposed, 0);
463
+ assert.equal(created[1]?.disposed, 1);
464
+ assert.deepEqual(created[1]?.checkCollections, []);
465
+ });
466
+
467
+ test("healthForNamespace preserves cached missing collection state", async () => {
468
+ const created: FakeBackend[] = [];
469
+ const router = new NamespaceSearchRouter(
470
+ config(),
471
+ { storageFor: async (namespace: string) => ({ dir: `/tmp/remnic/${namespace}` }) },
472
+ () => {
473
+ const backend = created.length === 0
474
+ ? new FakeBackend(false, [], { ensure: "missing" }, true)
475
+ : new FakeBackend(false, [], { check: "present" }, false);
476
+ created.push(backend);
477
+ return backend;
478
+ },
479
+ );
480
+
481
+ assert.deepEqual(
482
+ await router.searchAcrossNamespaces({
483
+ query: "a",
484
+ namespaces: ["shared"],
485
+ maxResults: 1,
486
+ }),
487
+ [],
488
+ );
489
+ const health = await router.healthForNamespace("shared");
490
+
491
+ assert.equal(health.available, true);
492
+ assert.equal(health.daemonMode, true);
493
+ assert.equal(health.collectionState, "missing");
494
+ assert.equal(created.length, 2);
495
+ assert.deepEqual(created[1]?.checkCollections, ["openclaw-engram--ns-736861726564"]);
496
+ });
497
+
498
+ test("healthForNamespace stops waiting when namespace availability probe aborts", async () => {
499
+ const backend = new class extends FakeBackend {
500
+ override async checkAvailability(execution?: SearchExecutionOptions): Promise<boolean> {
501
+ this.availabilitySignals.push(execution?.signal);
502
+ return await new Promise<boolean>(() => {});
503
+ }
504
+ }(false);
505
+ const router = new NamespaceSearchRouter(
506
+ config(),
507
+ { storageFor: async (namespace: string) => ({ dir: `/tmp/remnic/${namespace}` }) },
508
+ () => backend,
509
+ );
510
+ const controller = new AbortController();
511
+ controller.abort();
512
+
513
+ const health = await router.healthForNamespace("shared", {
514
+ signal: controller.signal,
515
+ });
516
+
517
+ assert.equal(health.available, false);
518
+ assert.equal(health.collectionState, "unknown");
519
+ assert.equal(backend.probeCalls, 0);
520
+ assert.equal(backend.availabilitySignals[0], controller.signal);
521
+ assert.deepEqual(backend.checkCollections, []);
522
+ assert.deepEqual(backend.ensureCollections, []);
523
+ assert.equal(backend.disposed, 1);
524
+ });
525
+
526
+ test("ensureNamespaceCollection does not cache aborted namespace backend probes", async () => {
527
+ const created: FakeBackend[] = [];
528
+ const router = new NamespaceSearchRouter(
529
+ config(),
530
+ { storageFor: async (namespace: string) => ({ dir: `/tmp/remnic/${namespace}` }) },
531
+ () => {
532
+ const backend = created.length === 0
533
+ ? new class extends FakeBackend {
534
+ override async probe(): Promise<boolean> {
535
+ return await new Promise<boolean>(() => {});
536
+ }
537
+ }(false)
538
+ : new FakeBackend(false, [], { ensure: "present" });
539
+ created.push(backend);
540
+ return backend;
541
+ },
542
+ );
543
+ const controller = new AbortController();
544
+ controller.abort();
545
+
546
+ await assert.rejects(
547
+ () => router.ensureNamespaceCollection("shared", { signal: controller.signal }),
548
+ /operation aborted/,
549
+ );
550
+
551
+ assert.equal(created[0]?.disposed, 1);
552
+ assert.deepEqual(created[0]?.checkCollections, []);
553
+ assert.deepEqual(created[0]?.ensureCollections, []);
554
+
555
+ const ensured = await router.ensureNamespaceCollection("shared");
556
+
557
+ assert.equal(ensured, "present");
558
+ assert.equal(created.length, 2);
559
+ assert.deepEqual(created[1]?.ensureCollections, ["openclaw-engram--ns-736861726564"]);
560
+ });
561
+
307
562
  test("legacy default namespace root filters nested namespace search results", async () => {
308
563
  const router = new NamespaceSearchRouter(
309
564
  config(),
@@ -44,12 +44,32 @@ type NamespaceBackendRecord = {
44
44
  filtersNestedNamespaces: boolean;
45
45
  };
46
46
 
47
- type CollectionState = "present" | "missing" | "unknown" | "skipped";
47
+ export type CollectionState = "present" | "missing" | "unknown" | "skipped";
48
+
49
+ export interface NamespaceSearchHealth {
50
+ collection: string;
51
+ memoryDir: string;
52
+ available: boolean;
53
+ collectionState: CollectionState;
54
+ debugStatus: string;
55
+ installedVersion: string | null;
56
+ supportedVersion: string | null;
57
+ supported: boolean | null;
58
+ upgradeAvailable: boolean | null;
59
+ doctorAvailable: boolean | null;
60
+ daemonMode: boolean | null;
61
+ }
48
62
 
49
63
  type NamespaceScopedSearchConfig = PluginConfig & {
50
64
  hostEmbeddingProviderScope?: string;
51
65
  };
52
66
 
67
+ type BackendRecordOptions = {
68
+ autoCreateCollection: boolean;
69
+ abortAsUnavailable: boolean;
70
+ failOpenMissingGuardedCollection: boolean;
71
+ };
72
+
53
73
  export class NamespaceSearchRouter {
54
74
  private readonly cache = new Map<string, Promise<NamespaceBackendRecord>>();
55
75
 
@@ -184,6 +204,67 @@ export class NamespaceSearchRouter {
184
204
  return record.collectionState;
185
205
  }
186
206
 
207
+ async healthForNamespace(
208
+ namespace: string,
209
+ execution?: SearchExecutionOptions,
210
+ ): Promise<NamespaceSearchHealth> {
211
+ const key = namespace.trim() || this.config.defaultNamespace;
212
+ const record = await this.createBackendRecordFor(
213
+ key,
214
+ execution,
215
+ {
216
+ autoCreateCollection: false,
217
+ abortAsUnavailable: true,
218
+ failOpenMissingGuardedCollection: false,
219
+ },
220
+ );
221
+ try {
222
+ const liveRecord = await this.liveCachedRecordForHealth(key, record, execution);
223
+ const diagnosticBackend = liveRecord?.backend ?? record.backend;
224
+ const versionStatus =
225
+ "getVersionStatus" in diagnosticBackend &&
226
+ typeof diagnosticBackend.getVersionStatus === "function"
227
+ ? diagnosticBackend.getVersionStatus()
228
+ : null;
229
+ const daemonMode = daemonModeForBackend(diagnosticBackend);
230
+ const collectionState =
231
+ liveRecord?.collectionState === "missing"
232
+ ? "missing"
233
+ : record.collectionState;
234
+
235
+ return {
236
+ collection: record.collection,
237
+ memoryDir: record.memoryDir,
238
+ available: liveRecord?.available ?? record.available,
239
+ collectionState,
240
+ debugStatus: diagnosticBackend.debugStatus(),
241
+ installedVersion: versionStatus?.installedVersion ?? null,
242
+ supportedVersion: versionStatus?.supportedVersion ?? null,
243
+ supported: versionStatus?.supported ?? null,
244
+ upgradeAvailable: versionStatus?.upgradeAvailable ?? null,
245
+ doctorAvailable: versionStatus?.capabilities?.doctor ?? null,
246
+ daemonMode,
247
+ };
248
+ } finally {
249
+ const dispose = (record.backend as { dispose?: () => void | Promise<void> }).dispose;
250
+ await dispose?.call(record.backend);
251
+ }
252
+ }
253
+
254
+ private async liveCachedRecordForHealth(
255
+ key: string,
256
+ disposableRecord: NamespaceBackendRecord,
257
+ execution?: SearchExecutionOptions,
258
+ ): Promise<NamespaceBackendRecord | null> {
259
+ const pending = this.cache.get(key);
260
+ if (!pending) return null;
261
+ const cachedRecord = await awaitWithAbort(pending, execution?.signal).catch(() => null);
262
+ if (!cachedRecord) return null;
263
+ if (cachedRecord.collection !== disposableRecord.collection) return null;
264
+ if (cachedRecord.memoryDir !== disposableRecord.memoryDir) return null;
265
+ return cachedRecord;
266
+ }
267
+
187
268
  /** Clear cached backend records so the next access re-probes availability. */
188
269
  clearCache(): void {
189
270
  this.cache.clear();
@@ -211,31 +292,69 @@ export class NamespaceSearchRouter {
211
292
  const existing = this.cache.get(key);
212
293
  if (existing) return await existing;
213
294
 
214
- const pending = (async (): Promise<NamespaceBackendRecord> => {
215
- const storage = await this.storageRouter.storageFor(key);
216
- const useLegacyDefaultCollection =
217
- key === this.config.defaultNamespace && storage.dir === this.config.memoryDir;
218
- const filtersNestedNamespaces =
219
- this.config.namespacesEnabled === true && useLegacyDefaultCollection;
220
- const rootHostEmbeddingScope =
221
- (this.config as NamespaceScopedSearchConfig).hostEmbeddingProviderScope ??
222
- this.config.memoryDir;
223
- const scopedConfig: NamespaceScopedSearchConfig = {
224
- ...this.config,
225
- memoryDir: storage.dir,
226
- hostEmbeddingProviderScope: rootHostEmbeddingScope,
227
- qmdCollection: namespaceCollectionName(this.config.qmdCollection, key, {
228
- defaultNamespace: this.config.defaultNamespace,
229
- useLegacyDefaultCollection,
230
- }),
231
- };
295
+ const pending = this.createBackendRecordFor(key, execution, {
296
+ autoCreateCollection: true,
297
+ abortAsUnavailable: false,
298
+ failOpenMissingGuardedCollection: true,
299
+ }).catch((error) => {
300
+ this.cache.delete(key);
301
+ throw error;
302
+ });
303
+
304
+ this.cache.set(key, pending);
305
+ return await pending;
306
+ }
307
+
308
+ private async createBackendRecordFor(
309
+ namespace: string,
310
+ execution: SearchExecutionOptions | undefined,
311
+ options: BackendRecordOptions,
312
+ ): Promise<NamespaceBackendRecord> {
313
+ const key = namespace.trim() || this.config.defaultNamespace;
314
+ const storage = await this.storageRouter.storageFor(key);
315
+ const useLegacyDefaultCollection =
316
+ key === this.config.defaultNamespace && storage.dir === this.config.memoryDir;
317
+ const filtersNestedNamespaces =
318
+ this.config.namespacesEnabled === true && useLegacyDefaultCollection;
319
+ const rootHostEmbeddingScope =
320
+ (this.config as NamespaceScopedSearchConfig).hostEmbeddingProviderScope ??
321
+ this.config.memoryDir;
322
+ const scopedConfig: NamespaceScopedSearchConfig = {
323
+ ...this.config,
324
+ memoryDir: storage.dir,
325
+ hostEmbeddingProviderScope: rootHostEmbeddingScope,
326
+ qmdCollection: namespaceCollectionName(this.config.qmdCollection, key, {
327
+ defaultNamespace: this.config.defaultNamespace,
328
+ useLegacyDefaultCollection,
329
+ }),
330
+ };
232
331
 
233
- const backend = this.createBackend(scopedConfig);
234
- const available = await backend.probe().catch(() => false);
332
+ const backend = this.createBackend(scopedConfig);
333
+ try {
334
+ const availabilityProbe =
335
+ options.autoCreateCollection || typeof backend.checkAvailability !== "function"
336
+ ? backend.probe()
337
+ : backend.checkAvailability({ signal: execution?.signal });
338
+ const available = await awaitWithAbort(availabilityProbe, execution?.signal).catch((error) => {
339
+ if (error instanceof NamespaceSearchAbortError && !options.abortAsUnavailable) {
340
+ throw error;
341
+ }
342
+ return false;
343
+ });
235
344
  const collectionState = available
236
- ? await this.collectionStateForBackend(backend, storage.dir, scopedConfig.qmdCollection, {
237
- skipAutoCreate: filtersNestedNamespaces,
238
- execution,
345
+ ? await awaitWithAbort(
346
+ this.collectionStateForBackend(backend, storage.dir, scopedConfig.qmdCollection, {
347
+ autoCreate: options.autoCreateCollection,
348
+ failOpenMissingGuardedCollection: options.failOpenMissingGuardedCollection,
349
+ skipAutoCreate: filtersNestedNamespaces,
350
+ execution,
351
+ }),
352
+ execution?.signal,
353
+ ).catch((error) => {
354
+ if (error instanceof NamespaceSearchAbortError && !options.abortAsUnavailable) {
355
+ throw error;
356
+ }
357
+ return "unknown" as const;
239
358
  })
240
359
  : "unknown";
241
360
  return {
@@ -246,10 +365,13 @@ export class NamespaceSearchRouter {
246
365
  collectionState,
247
366
  filtersNestedNamespaces,
248
367
  };
249
- })();
250
-
251
- this.cache.set(key, pending);
252
- return await pending;
368
+ } catch (error) {
369
+ const dispose = (backend as { dispose?: () => void | Promise<void> }).dispose;
370
+ if (dispose) {
371
+ await Promise.resolve(dispose.call(backend)).catch(() => {});
372
+ }
373
+ throw error;
374
+ }
253
375
  }
254
376
 
255
377
  private async collectionStateForBackend(
@@ -257,21 +379,47 @@ export class NamespaceSearchRouter {
257
379
  memoryDir: string,
258
380
  collection: string,
259
381
  options: {
382
+ autoCreate: boolean;
383
+ failOpenMissingGuardedCollection: boolean;
260
384
  skipAutoCreate: boolean;
261
385
  execution?: SearchExecutionOptions;
262
386
  },
263
387
  ): Promise<CollectionState> {
264
- if (options.skipAutoCreate) {
388
+ if (!options.autoCreate || options.skipAutoCreate) {
265
389
  if (!backend.checkCollection) return "unknown";
266
390
  const collectionState = await backend
267
391
  .checkCollection(collection, options.execution)
268
392
  .catch(() => "unknown" as const);
269
- return collectionState === "missing" ? "unknown" : collectionState;
393
+ return options.failOpenMissingGuardedCollection && collectionState === "missing"
394
+ ? "unknown"
395
+ : collectionState;
270
396
  }
271
397
  return await backend.ensureCollection(memoryDir, collection, options.execution).catch(() => "unknown" as const);
272
398
  }
273
399
  }
274
400
 
401
+ class NamespaceSearchAbortError extends Error {
402
+ constructor() {
403
+ super("operation aborted");
404
+ }
405
+ }
406
+
407
+ function awaitWithAbort<T>(operation: Promise<T>, signal?: AbortSignal): Promise<T> {
408
+ if (!signal) return operation;
409
+ if (signal.aborted) return Promise.reject(new NamespaceSearchAbortError());
410
+
411
+ return new Promise<T>((resolve, reject) => {
412
+ const onAbort = () => {
413
+ signal.removeEventListener("abort", onAbort);
414
+ reject(new NamespaceSearchAbortError());
415
+ };
416
+ signal.addEventListener("abort", onAbort, { once: true });
417
+ operation.then(resolve, reject).finally(() => {
418
+ signal.removeEventListener("abort", onAbort);
419
+ });
420
+ });
421
+ }
422
+
275
423
  function filterNamespaceSubtreeResults(
276
424
  record: NamespaceBackendRecord,
277
425
  results: QmdSearchResult[],
@@ -294,6 +442,12 @@ function backendSearchLimit(
294
442
  );
295
443
  }
296
444
 
445
+ function daemonModeForBackend(backend: SearchBackend): boolean | null {
446
+ return "isDaemonMode" in backend && typeof backend.isDaemonMode === "function"
447
+ ? backend.isDaemonMode() === true
448
+ : null;
449
+ }
450
+
297
451
  function pathIsInsideNamespaceSubtree(
298
452
  memoryDir: string,
299
453
  collection: string,
@@ -311,7 +311,10 @@ import {
311
311
  resolveCodingNamespaceOverlay,
312
312
  } from "./coding/coding-namespace.js";
313
313
  import type { CodingContext } from "./types.js";
314
- import { NamespaceSearchRouter } from "./namespaces/search.js";
314
+ import {
315
+ NamespaceSearchRouter,
316
+ type NamespaceSearchHealth,
317
+ } from "./namespaces/search.js";
315
318
  import { SharedContextManager } from "./shared-context/manager.js";
316
319
  import {
317
320
  CompoundingEngine,
@@ -2324,6 +2327,13 @@ export class Orchestrator {
2324
2327
  });
2325
2328
  }
2326
2329
 
2330
+ async searchHealthForNamespace(
2331
+ namespace: string,
2332
+ execution?: SearchExecutionOptions,
2333
+ ): Promise<NamespaceSearchHealth> {
2334
+ return await this.namespaceSearchRouter.healthForNamespace(namespace, execution);
2335
+ }
2336
+
2327
2337
  private isSearchAvailableForNamespaceRouting(): boolean {
2328
2338
  if (this.config.namespacesEnabled) return true;
2329
2339
  return this.qmd.isAvailable();