@remnic/core 9.3.647 → 9.3.648

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 (57) hide show
  1. package/dist/access-cli.js +9 -9
  2. package/dist/access-http.js +10 -10
  3. package/dist/access-mcp.js +9 -9
  4. package/dist/access-service.js +8 -8
  5. package/dist/{chunk-L7W5YW6Y.js → chunk-5ETA6OAS.js} +2 -2
  6. package/dist/{chunk-DCGT4FPP.js → chunk-76QTEJ2Q.js} +2 -2
  7. package/dist/{chunk-3D6L7CEP.js → chunk-AEIZEAP7.js} +6 -6
  8. package/dist/{chunk-RAELB5NX.js → chunk-CNRZ6WJU.js} +3 -3
  9. package/dist/{chunk-3MAONBX3.js → chunk-FOVPSMGI.js} +2 -2
  10. package/dist/{chunk-ZPPFKVSD.js → chunk-FUXV6HSO.js} +2 -2
  11. package/dist/{chunk-MUKXANAM.js → chunk-I4COC5XW.js} +49 -6
  12. package/dist/{chunk-MUKXANAM.js.map → chunk-I4COC5XW.js.map} +1 -1
  13. package/dist/{chunk-APWJRJFW.js → chunk-NMIOW7XG.js} +86 -8
  14. package/dist/chunk-NMIOW7XG.js.map +1 -0
  15. package/dist/{chunk-FAV25DUZ.js → chunk-QT4THOLT.js} +1 -1
  16. package/dist/{chunk-FAV25DUZ.js.map → chunk-QT4THOLT.js.map} +1 -1
  17. package/dist/{chunk-FAYDM5WD.js → chunk-RRRCNIPK.js} +2 -2
  18. package/dist/{chunk-RG3LBSGH.js → chunk-TQUWNX7C.js} +2 -2
  19. package/dist/{chunk-TA4LQ5SR.js → chunk-TWVRDGTX.js} +4 -4
  20. package/dist/{chunk-U55D5UD5.js → chunk-WPCCNSWO.js} +5 -5
  21. package/dist/{chunk-DC66QVL2.js → chunk-ZT6R3WR3.js} +2 -2
  22. package/dist/cli.js +13 -13
  23. package/dist/index.js +14 -14
  24. package/dist/namespaces/migrate.js +8 -8
  25. package/dist/namespaces/search.d.ts +1 -0
  26. package/dist/namespaces/search.js +7 -7
  27. package/dist/operator-toolkit.js +9 -9
  28. package/dist/orchestrator.js +8 -8
  29. package/dist/qmd.d.ts +1 -0
  30. package/dist/qmd.js +2 -2
  31. package/dist/schemas.d.ts +22 -22
  32. package/dist/search/factory.js +6 -6
  33. package/dist/search/index.js +6 -6
  34. package/dist/search/lancedb-backend.js +2 -2
  35. package/dist/search/meilisearch-backend.js +2 -2
  36. package/dist/search/orama-backend.js +2 -2
  37. package/dist/search/port.d.ts +6 -0
  38. package/dist/search/port.js +1 -1
  39. package/dist/transfer/types.d.ts +12 -12
  40. package/package.json +1 -1
  41. package/src/namespaces/search.test.ts +218 -18
  42. package/src/namespaces/search.ts +122 -7
  43. package/src/qmd-client.test.ts +74 -1
  44. package/src/qmd.ts +52 -6
  45. package/src/search/port.ts +9 -0
  46. package/dist/chunk-APWJRJFW.js.map +0 -1
  47. /package/dist/{chunk-L7W5YW6Y.js.map → chunk-5ETA6OAS.js.map} +0 -0
  48. /package/dist/{chunk-DCGT4FPP.js.map → chunk-76QTEJ2Q.js.map} +0 -0
  49. /package/dist/{chunk-3D6L7CEP.js.map → chunk-AEIZEAP7.js.map} +0 -0
  50. /package/dist/{chunk-RAELB5NX.js.map → chunk-CNRZ6WJU.js.map} +0 -0
  51. /package/dist/{chunk-3MAONBX3.js.map → chunk-FOVPSMGI.js.map} +0 -0
  52. /package/dist/{chunk-ZPPFKVSD.js.map → chunk-FUXV6HSO.js.map} +0 -0
  53. /package/dist/{chunk-FAYDM5WD.js.map → chunk-RRRCNIPK.js.map} +0 -0
  54. /package/dist/{chunk-RG3LBSGH.js.map → chunk-TQUWNX7C.js.map} +0 -0
  55. /package/dist/{chunk-TA4LQ5SR.js.map → chunk-TWVRDGTX.js.map} +0 -0
  56. /package/dist/{chunk-U55D5UD5.js.map → chunk-WPCCNSWO.js.map} +0 -0
  57. /package/dist/{chunk-DC66QVL2.js.map → chunk-ZT6R3WR3.js.map} +0 -0
@@ -1,20 +1,42 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
  import { NamespaceSearchRouter } from "./search.js";
4
- import type { SearchBackend } from "../search/port.js";
4
+ import type {
5
+ SearchBackend,
6
+ SearchExecutionOptions,
7
+ SearchQueryOptions,
8
+ } from "../search/port.js";
5
9
  import type { PluginConfig, QmdSearchResult } from "../types.js";
6
10
 
11
+ type CollectionState = "present" | "missing" | "unknown" | "skipped";
12
+
7
13
  class FakeBackend implements SearchBackend {
8
14
  updates = 0;
9
- calls: Array<{ method: string; collection: string | undefined }> = [];
15
+ calls: Array<{
16
+ method: string;
17
+ collection: string | undefined;
18
+ maxResults: number | undefined;
19
+ }> = [];
10
20
  ensureSignals: Array<AbortSignal | undefined> = [];
11
21
  ensureCollections: Array<string | undefined> = [];
22
+ checkSignals: Array<AbortSignal | undefined> = [];
23
+ checkCollections: Array<string | undefined> = [];
12
24
 
13
25
  constructor(
14
26
  private readonly globalUpdate: boolean,
15
27
  private readonly results: QmdSearchResult[] = [],
28
+ private readonly collectionStates: {
29
+ check?: CollectionState;
30
+ ensure?: CollectionState;
31
+ } = {},
16
32
  ) {}
17
33
 
34
+ private limitedResults(maxResults: number | undefined): QmdSearchResult[] {
35
+ return typeof maxResults === "number"
36
+ ? this.results.slice(0, maxResults)
37
+ : this.results;
38
+ }
39
+
18
40
  async probe(): Promise<boolean> {
19
41
  return true;
20
42
  }
@@ -27,28 +49,53 @@ class FakeBackend implements SearchBackend {
27
49
  return "fake";
28
50
  }
29
51
 
30
- async search(_query?: string, collection?: string): Promise<QmdSearchResult[]> {
31
- this.calls.push({ method: "search", collection });
32
- return this.results;
52
+ async search(
53
+ _query: string,
54
+ collection?: string,
55
+ maxResults?: number,
56
+ _options?: SearchQueryOptions,
57
+ _execution?: SearchExecutionOptions,
58
+ ): Promise<QmdSearchResult[]> {
59
+ this.calls.push({ method: "search", collection, maxResults });
60
+ return this.limitedResults(maxResults);
33
61
  }
34
62
 
35
- async searchGlobal(): Promise<QmdSearchResult[]> {
36
- return [];
63
+ async searchGlobal(
64
+ _query: string,
65
+ maxResults?: number,
66
+ _execution?: SearchExecutionOptions,
67
+ ): Promise<QmdSearchResult[]> {
68
+ return this.limitedResults(maxResults);
37
69
  }
38
70
 
39
- async bm25Search(_query?: string, collection?: string): Promise<QmdSearchResult[]> {
40
- this.calls.push({ method: "bm25", collection });
41
- return [];
71
+ async bm25Search(
72
+ _query: string,
73
+ collection?: string,
74
+ maxResults?: number,
75
+ _execution?: SearchExecutionOptions,
76
+ ): Promise<QmdSearchResult[]> {
77
+ this.calls.push({ method: "bm25", collection, maxResults });
78
+ return this.limitedResults(maxResults);
42
79
  }
43
80
 
44
- async vectorSearch(_query?: string, collection?: string): Promise<QmdSearchResult[]> {
45
- this.calls.push({ method: "vector", collection });
46
- return [];
81
+ async vectorSearch(
82
+ _query: string,
83
+ collection?: string,
84
+ maxResults?: number,
85
+ _execution?: SearchExecutionOptions,
86
+ ): Promise<QmdSearchResult[]> {
87
+ this.calls.push({ method: "vector", collection, maxResults });
88
+ return this.limitedResults(maxResults);
47
89
  }
48
90
 
49
- async hybridSearch(_query?: string, collection?: string): Promise<QmdSearchResult[]> {
50
- this.calls.push({ method: "hybrid", collection });
51
- return [];
91
+ async hybridSearch(
92
+ _query: string,
93
+ collection?: string,
94
+ maxResults?: number,
95
+ _execution?: SearchExecutionOptions,
96
+ ): Promise<QmdSearchResult[]> {
97
+ this.calls.push({ method: "hybrid", collection, maxResults });
98
+ return this.limitedResults(maxResults);
52
99
  }
53
100
 
54
101
  async update(): Promise<void> {
@@ -69,7 +116,7 @@ class FakeBackend implements SearchBackend {
69
116
  _memoryDir?: string,
70
117
  collectionOrExecution?: string | { signal?: AbortSignal },
71
118
  execution?: { signal?: AbortSignal },
72
- ): Promise<"present"> {
119
+ ): Promise<CollectionState> {
73
120
  const collection = typeof collectionOrExecution === "string"
74
121
  ? collectionOrExecution
75
122
  : undefined;
@@ -78,12 +125,29 @@ class FakeBackend implements SearchBackend {
78
125
  : collectionOrExecution ?? execution;
79
126
  this.ensureCollections.push(collection);
80
127
  this.ensureSignals.push(effectiveExecution?.signal);
81
- return "present";
128
+ return this.collectionStates.ensure ?? "present";
129
+ }
130
+
131
+ async checkCollection(
132
+ collectionOrExecution?: string | { signal?: AbortSignal },
133
+ execution?: { signal?: AbortSignal },
134
+ ): Promise<CollectionState> {
135
+ const collection = typeof collectionOrExecution === "string"
136
+ ? collectionOrExecution
137
+ : undefined;
138
+ const effectiveExecution = typeof collectionOrExecution === "string"
139
+ ? execution
140
+ : collectionOrExecution ?? execution;
141
+ this.checkCollections.push(collection);
142
+ this.checkSignals.push(effectiveExecution?.signal);
143
+ return this.collectionStates.check ?? "present";
82
144
  }
83
145
  }
84
146
 
85
147
  function config(): PluginConfig {
86
148
  return {
149
+ memoryDir: "/tmp/remnic",
150
+ namespacesEnabled: true,
87
151
  qmdCollection: "openclaw-engram",
88
152
  defaultNamespace: "main",
89
153
  qmdMaxResults: 10,
@@ -205,3 +269,139 @@ test("ensureNamespaceCollection forwards abort signals to backend collection che
205
269
  assert.deepEqual(backend.ensureSignals, [controller.signal]);
206
270
  assert.deepEqual(backend.ensureCollections, ["openclaw-engram--ns-6d61696e"]);
207
271
  });
272
+
273
+ test("legacy default namespace root checks collection without auto-creating broad root", async () => {
274
+ const backend = new FakeBackend(false);
275
+ const router = new NamespaceSearchRouter(
276
+ config(),
277
+ { storageFor: async () => ({ dir: "/tmp/remnic" }) },
278
+ () => backend,
279
+ );
280
+ const controller = new AbortController();
281
+
282
+ const state = await router.ensureNamespaceCollection("main", {
283
+ signal: controller.signal,
284
+ });
285
+
286
+ assert.equal(state, "present");
287
+ assert.deepEqual(backend.checkSignals, [controller.signal]);
288
+ assert.deepEqual(backend.checkCollections, ["openclaw-engram"]);
289
+ assert.deepEqual(backend.ensureCollections, []);
290
+ });
291
+
292
+ test("legacy default namespace root fail-opens missing guarded collections", async () => {
293
+ const backend = new FakeBackend(false, [], { check: "missing" });
294
+ const router = new NamespaceSearchRouter(
295
+ config(),
296
+ { storageFor: async () => ({ dir: "/tmp/remnic" }) },
297
+ () => backend,
298
+ );
299
+
300
+ const state = await router.ensureNamespaceCollection("main");
301
+
302
+ assert.equal(state, "unknown");
303
+ assert.deepEqual(backend.checkCollections, ["openclaw-engram"]);
304
+ assert.deepEqual(backend.ensureCollections, []);
305
+ });
306
+
307
+ test("legacy default namespace root filters nested namespace search results", async () => {
308
+ const router = new NamespaceSearchRouter(
309
+ config(),
310
+ { storageFor: async () => ({ dir: "/tmp/remnic" }) },
311
+ () => new FakeBackend(false, [
312
+ {
313
+ path: "/tmp/remnic/facts/main.md",
314
+ docid: "main",
315
+ score: 0.9,
316
+ snippet: "main",
317
+ },
318
+ {
319
+ path: "/tmp/remnic/namespaces/shared/facts/shared.md",
320
+ docid: "shared",
321
+ score: 0.95,
322
+ snippet: "shared",
323
+ },
324
+ {
325
+ path: "namespaces/project/facts/project.md",
326
+ docid: "project",
327
+ score: 0.8,
328
+ snippet: "project",
329
+ },
330
+ {
331
+ path: "qmd://openclaw-engram/facts/qmd-main.md",
332
+ docid: "qmd-main",
333
+ score: 0.85,
334
+ snippet: "qmd-main",
335
+ },
336
+ {
337
+ path: "qmd://openclaw-engram/namespaces/uri/facts/uri.md",
338
+ docid: "uri",
339
+ score: 0.99,
340
+ snippet: "uri",
341
+ },
342
+ {
343
+ path: "openclaw-engram/namespaces/prefix/facts/prefix.md",
344
+ docid: "prefix",
345
+ score: 0.98,
346
+ snippet: "prefix",
347
+ },
348
+ ]),
349
+ );
350
+
351
+ const results = await router.searchAcrossNamespaces({
352
+ query: "a",
353
+ namespaces: ["main"],
354
+ maxResults: 10,
355
+ });
356
+
357
+ assert.deepEqual(
358
+ results.map((result) => result.docid),
359
+ ["main", "qmd-main"],
360
+ );
361
+ });
362
+
363
+ test("legacy default namespace root overfetches before filtering nested namespace results", async () => {
364
+ const backend = new FakeBackend(false, [
365
+ {
366
+ path: "/tmp/remnic/namespaces/shared/facts/shared.md",
367
+ docid: "shared",
368
+ score: 0.99,
369
+ snippet: "shared",
370
+ },
371
+ {
372
+ path: "qmd://openclaw-engram/namespaces/project/facts/project.md",
373
+ docid: "project",
374
+ score: 0.98,
375
+ snippet: "project",
376
+ },
377
+ {
378
+ path: "/tmp/remnic/facts/main-a.md",
379
+ docid: "main-a",
380
+ score: 0.9,
381
+ snippet: "main-a",
382
+ },
383
+ {
384
+ path: "/tmp/remnic/facts/main-b.md",
385
+ docid: "main-b",
386
+ score: 0.8,
387
+ snippet: "main-b",
388
+ },
389
+ ]);
390
+ const router = new NamespaceSearchRouter(
391
+ config(),
392
+ { storageFor: async () => ({ dir: "/tmp/remnic" }) },
393
+ () => backend,
394
+ );
395
+
396
+ const results = await router.searchAcrossNamespaces({
397
+ query: "a",
398
+ namespaces: ["main"],
399
+ maxResults: 2,
400
+ });
401
+
402
+ assert.equal(backend.calls[0]?.maxResults, 50);
403
+ assert.deepEqual(
404
+ results.map((result) => result.docid),
405
+ ["main-a", "main-b"],
406
+ );
407
+ });
@@ -1,8 +1,16 @@
1
+ import path from "node:path";
1
2
  import type { PluginConfig, QmdSearchResult } from "../types.js";
2
- import type { SearchBackend, SearchExecutionOptions, SearchQueryOptions } from "../search/port.js";
3
+ import type {
4
+ SearchBackend,
5
+ SearchExecutionOptions,
6
+ SearchQueryOptions,
7
+ } from "../search/port.js";
3
8
  import { createSearchBackend } from "../search/factory.js";
4
9
  import { namespaceIdentityToken, normalizeNamespaceIdentity } from "./identity.js";
5
10
 
11
+ const NESTED_NAMESPACE_FILTER_OVERFETCH_FACTOR = 4;
12
+ const NESTED_NAMESPACE_FILTER_OVERFETCH_MIN = 50;
13
+
6
14
  export function namespaceCollectionName(
7
15
  baseCollection: string,
8
16
  namespace: string,
@@ -32,9 +40,12 @@ type NamespaceBackendRecord = {
32
40
  collection: string;
33
41
  memoryDir: string;
34
42
  available: boolean;
35
- collectionState: "present" | "missing" | "unknown" | "skipped";
43
+ collectionState: CollectionState;
44
+ filtersNestedNamespaces: boolean;
36
45
  };
37
46
 
47
+ type CollectionState = "present" | "missing" | "unknown" | "skipped";
48
+
38
49
  type NamespaceScopedSearchConfig = PluginConfig & {
39
50
  hostEmbeddingProviderScope?: string;
40
51
  };
@@ -75,27 +86,44 @@ export class NamespaceSearchRouter {
75
86
  if (!record.available || record.collectionState === "missing") {
76
87
  return { namespace, results: [] as QmdSearchResult[] };
77
88
  }
89
+ const backendLimit = backendSearchLimit(record, maxResults);
78
90
  let results: QmdSearchResult[];
79
91
  switch (method) {
80
92
  case "hybrid":
81
- results = await record.backend.hybridSearch(query, record.collection, maxResults, options.execution);
93
+ results = await record.backend.hybridSearch(
94
+ query,
95
+ record.collection,
96
+ backendLimit,
97
+ options.execution,
98
+ );
82
99
  break;
83
100
  case "bm25":
84
- results = await record.backend.bm25Search(query, record.collection, maxResults, options.execution);
101
+ results = await record.backend.bm25Search(
102
+ query,
103
+ record.collection,
104
+ backendLimit,
105
+ options.execution,
106
+ );
85
107
  break;
86
108
  case "vector":
87
- results = await record.backend.vectorSearch(query, record.collection, maxResults, options.execution);
109
+ results = await record.backend.vectorSearch(
110
+ query,
111
+ record.collection,
112
+ backendLimit,
113
+ options.execution,
114
+ );
88
115
  break;
89
116
  default:
90
117
  results = await record.backend.search(
91
118
  query,
92
119
  record.collection,
93
- maxResults,
120
+ backendLimit,
94
121
  options.searchOptions,
95
122
  options.execution,
96
123
  );
97
124
  break;
98
125
  }
126
+ results = filterNamespaceSubtreeResults(record, results);
99
127
  return { namespace, results };
100
128
  }),
101
129
  );
@@ -187,6 +215,8 @@ export class NamespaceSearchRouter {
187
215
  const storage = await this.storageRouter.storageFor(key);
188
216
  const useLegacyDefaultCollection =
189
217
  key === this.config.defaultNamespace && storage.dir === this.config.memoryDir;
218
+ const filtersNestedNamespaces =
219
+ this.config.namespacesEnabled === true && useLegacyDefaultCollection;
190
220
  const rootHostEmbeddingScope =
191
221
  (this.config as NamespaceScopedSearchConfig).hostEmbeddingProviderScope ??
192
222
  this.config.memoryDir;
@@ -203,7 +233,10 @@ export class NamespaceSearchRouter {
203
233
  const backend = this.createBackend(scopedConfig);
204
234
  const available = await backend.probe().catch(() => false);
205
235
  const collectionState = available
206
- ? await backend.ensureCollection(storage.dir, scopedConfig.qmdCollection, execution).catch(() => "unknown" as const)
236
+ ? await this.collectionStateForBackend(backend, storage.dir, scopedConfig.qmdCollection, {
237
+ skipAutoCreate: filtersNestedNamespaces,
238
+ execution,
239
+ })
207
240
  : "unknown";
208
241
  return {
209
242
  backend,
@@ -211,12 +244,94 @@ export class NamespaceSearchRouter {
211
244
  memoryDir: storage.dir,
212
245
  available,
213
246
  collectionState,
247
+ filtersNestedNamespaces,
214
248
  };
215
249
  })();
216
250
 
217
251
  this.cache.set(key, pending);
218
252
  return await pending;
219
253
  }
254
+
255
+ private async collectionStateForBackend(
256
+ backend: SearchBackend,
257
+ memoryDir: string,
258
+ collection: string,
259
+ options: {
260
+ skipAutoCreate: boolean;
261
+ execution?: SearchExecutionOptions;
262
+ },
263
+ ): Promise<CollectionState> {
264
+ if (options.skipAutoCreate) {
265
+ if (!backend.checkCollection) return "unknown";
266
+ const collectionState = await backend
267
+ .checkCollection(collection, options.execution)
268
+ .catch(() => "unknown" as const);
269
+ return collectionState === "missing" ? "unknown" : collectionState;
270
+ }
271
+ return await backend.ensureCollection(memoryDir, collection, options.execution).catch(() => "unknown" as const);
272
+ }
273
+ }
274
+
275
+ function filterNamespaceSubtreeResults(
276
+ record: NamespaceBackendRecord,
277
+ results: QmdSearchResult[],
278
+ ): QmdSearchResult[] {
279
+ if (!record.filtersNestedNamespaces) return results;
280
+ return results.filter((result) =>
281
+ !pathIsInsideNamespaceSubtree(record.memoryDir, record.collection, result.path)
282
+ );
283
+ }
284
+
285
+ function backendSearchLimit(
286
+ record: NamespaceBackendRecord,
287
+ maxResults: number,
288
+ ): number {
289
+ if (!record.filtersNestedNamespaces) return maxResults;
290
+ return Math.max(
291
+ maxResults,
292
+ maxResults * NESTED_NAMESPACE_FILTER_OVERFETCH_FACTOR,
293
+ NESTED_NAMESPACE_FILTER_OVERFETCH_MIN,
294
+ );
295
+ }
296
+
297
+ function pathIsInsideNamespaceSubtree(
298
+ memoryDir: string,
299
+ collection: string,
300
+ resultPath: string | undefined,
301
+ ): boolean {
302
+ if (!resultPath) return false;
303
+ const normalizedResultPath = normalizeQmdResultPath(resultPath, collection);
304
+ const memoryRoot = path.resolve(memoryDir);
305
+ const namespacesRoot = path.join(memoryRoot, "namespaces");
306
+ const candidate = path.isAbsolute(normalizedResultPath)
307
+ ? path.normalize(normalizedResultPath)
308
+ : path.resolve(memoryRoot, normalizedResultPath);
309
+ const relative = path.relative(namespacesRoot, candidate);
310
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
311
+ }
312
+
313
+ function normalizeQmdResultPath(resultPath: string, collection: string): string {
314
+ let value = resultPath.trim();
315
+ if (value.startsWith("qmd://")) {
316
+ try {
317
+ const parsed = new URL(value);
318
+ if (parsed.protocol === "qmd:" && parsed.hostname === collection) {
319
+ value = decodeURIComponent(parsed.pathname.replace(/^\/+/, ""));
320
+ }
321
+ } catch {
322
+ const remainder = value.slice("qmd://".length);
323
+ const slashIndex = remainder.indexOf("/");
324
+ if (slashIndex !== -1) {
325
+ value = remainder.slice(slashIndex + 1);
326
+ }
327
+ }
328
+ }
329
+
330
+ const collectionPrefix = `${collection}/`;
331
+ if (value.startsWith(collectionPrefix)) {
332
+ value = value.slice(collectionPrefix.length);
333
+ }
334
+ return value;
220
335
  }
221
336
 
222
337
  function mergeNamespaceSearchResults(
@@ -46,7 +46,11 @@ test("QmdClient rechecks daemon availability before returning unavailable", asyn
46
46
 
47
47
  type SubprocessInternals = {
48
48
  available: boolean;
49
- runQmdCommand: (args: string[]) => Promise<{ stdout: string; stderr: string }>;
49
+ runQmdCommand: (
50
+ args: string[],
51
+ timeoutMs?: number,
52
+ signal?: AbortSignal,
53
+ ) => Promise<{ stdout: string; stderr: string }>;
50
54
  searchViaSubprocess: (
51
55
  query: string,
52
56
  collection: string,
@@ -66,6 +70,75 @@ function captureSubprocessArgs(client: QmdClient): string[][] {
66
70
  return calls;
67
71
  }
68
72
 
73
+ test("ensureCollection treats cancelled auto-create as unknown", async () => {
74
+ const client = new QmdClient("memories", 3, {});
75
+ const internals = client as unknown as SubprocessInternals & {
76
+ daemonAvailable: boolean;
77
+ };
78
+ const controller = new AbortController();
79
+ const calls: string[][] = [];
80
+
81
+ internals.available = true;
82
+ internals.daemonAvailable = false;
83
+ internals.runQmdCommand = async (args, _timeoutMs, signal) => {
84
+ calls.push(args);
85
+ if (args[0] === "collection" && args[1] === "list") {
86
+ return { stdout: "", stderr: "" };
87
+ }
88
+ if (args[0] === "collection" && args[1] === "add") {
89
+ assert.equal(signal, controller.signal);
90
+ controller.abort();
91
+ throw new Error("startup timeout aborted collection add");
92
+ }
93
+ throw new Error(`unexpected qmd command: ${args.join(" ")}`);
94
+ };
95
+
96
+ const result = await client.ensureCollection("/tmp/remnic-memory", "memories", {
97
+ signal: controller.signal,
98
+ });
99
+
100
+ assert.equal(result, "unknown");
101
+ assert.deepEqual(calls, [
102
+ ["collection", "list"],
103
+ ["collection", "add", "/tmp/remnic-memory", "--name", "memories"],
104
+ ]);
105
+ });
106
+
107
+ test("ensureCollection rechecks collection state after auto-create failure", async () => {
108
+ const client = new QmdClient("memories", 3, {});
109
+ const internals = client as unknown as SubprocessInternals & {
110
+ daemonAvailable: boolean;
111
+ };
112
+ const calls: string[][] = [];
113
+ let listCount = 0;
114
+
115
+ internals.available = true;
116
+ internals.daemonAvailable = false;
117
+ internals.runQmdCommand = async (args) => {
118
+ calls.push(args);
119
+ if (args[0] === "collection" && args[1] === "list") {
120
+ listCount += 1;
121
+ return {
122
+ stdout: listCount === 1 ? "" : "memories (qmd://memories/)",
123
+ stderr: "",
124
+ };
125
+ }
126
+ if (args[0] === "collection" && args[1] === "add") {
127
+ throw new Error("qmd collection add timed out after indexing started");
128
+ }
129
+ throw new Error(`unexpected qmd command: ${args.join(" ")}`);
130
+ };
131
+
132
+ const result = await client.ensureCollection("/tmp/remnic-memory", "memories");
133
+
134
+ assert.equal(result, "present");
135
+ assert.deepEqual(calls, [
136
+ ["collection", "list"],
137
+ ["collection", "add", "/tmp/remnic-memory", "--name", "memories"],
138
+ ["collection", "list"],
139
+ ]);
140
+ });
141
+
69
142
  test("subprocess fallback defaults to `qmd query` for scoped and global recall", async () => {
70
143
  const client = new QmdClient("memories", 3, {});
71
144
  const calls = captureSubprocessArgs(client);
package/src/qmd.ts CHANGED
@@ -2700,8 +2700,7 @@ export class QmdClient implements SearchBackend {
2700
2700
  }
2701
2701
  }
2702
2702
 
2703
- async ensureCollection(
2704
- memoryDir: string,
2703
+ async checkCollection(
2705
2704
  collectionOrExecution?: string | SearchExecutionOptions,
2706
2705
  execution?: SearchExecutionOptions,
2707
2706
  ): Promise<"present" | "missing" | "unknown" | "skipped"> {
@@ -2723,6 +2722,7 @@ export class QmdClient implements SearchBackend {
2723
2722
  if (collectionRegex.test(stdout)) {
2724
2723
  return "present";
2725
2724
  }
2725
+ return "missing";
2726
2726
  } catch (err) {
2727
2727
  // Treat command/probe failures as unknown so callers do not disable features
2728
2728
  // permanently after a transient CLI or daemon hiccup.
@@ -2731,12 +2731,58 @@ export class QmdClient implements SearchBackend {
2731
2731
  );
2732
2732
  return "unknown";
2733
2733
  }
2734
+ }
2734
2735
 
2735
- log.info(
2736
- `QMD collection "${targetCollection}" not found. ` +
2737
- `Add it to ~/.config/qmd/index.yml pointing at ${memoryDir}`,
2736
+ async ensureCollection(
2737
+ memoryDir: string,
2738
+ collectionOrExecution?: string | SearchExecutionOptions,
2739
+ execution?: SearchExecutionOptions,
2740
+ ): Promise<"present" | "missing" | "unknown" | "skipped"> {
2741
+ const { collection, execution: effectiveExecution } = resolveEnsureCollectionArgs(
2742
+ collectionOrExecution,
2743
+ execution,
2738
2744
  );
2739
- return "missing";
2745
+ const targetCollection = collection ?? this.collection;
2746
+ const collectionState = await this.checkCollection(targetCollection, effectiveExecution);
2747
+ if (collectionState !== "missing") return collectionState;
2748
+
2749
+ try {
2750
+ await this.runQmdCommand(
2751
+ ["collection", "add", memoryDir, "--name", targetCollection],
2752
+ QMD_TIMEOUT_MS,
2753
+ effectiveExecution?.signal,
2754
+ );
2755
+ log.info(
2756
+ `QMD collection "${targetCollection}" auto-created at ${memoryDir}`,
2757
+ );
2758
+ return "present";
2759
+ } catch (err) {
2760
+ const msg = err instanceof Error ? err.message : String(err);
2761
+ if (isCallerCancellation(err, effectiveExecution?.signal)) {
2762
+ log.debug(
2763
+ `QMD collection auto-create for "${targetCollection}" was cancelled; keeping collection state unknown`,
2764
+ );
2765
+ return "unknown";
2766
+ }
2767
+ const postCreateState = await this.checkCollection(targetCollection, effectiveExecution);
2768
+ if (postCreateState === "present") {
2769
+ log.info(
2770
+ `QMD collection "${targetCollection}" is present after auto-create failure; continuing`,
2771
+ );
2772
+ return "present";
2773
+ }
2774
+ if (/\balready exists\b|\bexists already\b/i.test(msg)) {
2775
+ log.info(
2776
+ `QMD collection "${targetCollection}" already exists after concurrent auto-create; continuing`,
2777
+ );
2778
+ return "present";
2779
+ }
2780
+ if (postCreateState !== "missing") return postCreateState;
2781
+ log.warn(
2782
+ `QMD collection "${targetCollection}" not found and auto-create failed: ${msg}`,
2783
+ );
2784
+ return "missing";
2785
+ }
2740
2786
  }
2741
2787
  }
2742
2788
 
@@ -93,6 +93,15 @@ export interface SearchBackend {
93
93
  embedCollection(collection: string): Promise<void>;
94
94
 
95
95
  // ── Collection management ──
96
+ /**
97
+ * Optional non-mutating collection probe. Backends that can distinguish a
98
+ * missing collection from a transient probe failure should implement this so
99
+ * callers can avoid auto-creating collections in unsafe layouts.
100
+ */
101
+ checkCollection?(
102
+ collectionOrExecution?: string | SearchExecutionOptions,
103
+ execution?: SearchExecutionOptions,
104
+ ): Promise<"present" | "missing" | "unknown" | "skipped">;
96
105
  ensureCollection(
97
106
  memoryDir: string,
98
107
  execution?: SearchExecutionOptions,