@remnic/core 9.3.647 → 9.3.649

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 (69) hide show
  1. package/dist/access-cli.js +10 -10
  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-XUGQQPGO.js → chunk-AGRPGAKR.js} +12 -1
  8. package/dist/chunk-AGRPGAKR.js.map +1 -0
  9. package/dist/{chunk-RAELB5NX.js → chunk-CNRZ6WJU.js} +3 -3
  10. package/dist/{chunk-3MAONBX3.js → chunk-FOVPSMGI.js} +2 -2
  11. package/dist/{chunk-3D6L7CEP.js → chunk-FQYFMIKG.js} +14 -17
  12. package/dist/chunk-FQYFMIKG.js.map +1 -0
  13. package/dist/{chunk-ZPPFKVSD.js → chunk-FUXV6HSO.js} +2 -2
  14. package/dist/{chunk-MUKXANAM.js → chunk-I4COC5XW.js} +49 -6
  15. package/dist/{chunk-MUKXANAM.js.map → chunk-I4COC5XW.js.map} +1 -1
  16. package/dist/{chunk-APWJRJFW.js → chunk-NMIOW7XG.js} +86 -8
  17. package/dist/chunk-NMIOW7XG.js.map +1 -0
  18. package/dist/{chunk-FAV25DUZ.js → chunk-QT4THOLT.js} +1 -1
  19. package/dist/{chunk-FAV25DUZ.js.map → chunk-QT4THOLT.js.map} +1 -1
  20. package/dist/{chunk-FAYDM5WD.js → chunk-RRRCNIPK.js} +2 -2
  21. package/dist/{chunk-6BNFVP7Y.js → chunk-RZOBQ23O.js} +2 -2
  22. package/dist/{chunk-RG3LBSGH.js → chunk-TQUWNX7C.js} +2 -2
  23. package/dist/{chunk-U55D5UD5.js → chunk-WPCCNSWO.js} +5 -5
  24. package/dist/{chunk-TA4LQ5SR.js → chunk-XUGVP7ZU.js} +5 -5
  25. package/dist/{chunk-DC66QVL2.js → chunk-ZT6R3WR3.js} +2 -2
  26. package/dist/cli.js +15 -15
  27. package/dist/index.js +16 -16
  28. package/dist/namespaces/migrate.js +8 -8
  29. package/dist/namespaces/search.d.ts +1 -0
  30. package/dist/namespaces/search.js +7 -7
  31. package/dist/operator-toolkit.js +9 -9
  32. package/dist/orchestrator.js +9 -9
  33. package/dist/qmd.d.ts +1 -0
  34. package/dist/qmd.js +2 -2
  35. package/dist/resume-bundles.js +2 -2
  36. package/dist/schemas.d.ts +22 -22
  37. package/dist/search/factory.js +6 -6
  38. package/dist/search/index.js +6 -6
  39. package/dist/search/lancedb-backend.js +2 -2
  40. package/dist/search/meilisearch-backend.js +2 -2
  41. package/dist/search/orama-backend.js +2 -2
  42. package/dist/search/port.d.ts +6 -0
  43. package/dist/search/port.js +1 -1
  44. package/dist/transcript.d.ts +18 -1
  45. package/dist/transcript.js +5 -3
  46. package/dist/transfer/types.d.ts +12 -12
  47. package/package.json +1 -1
  48. package/src/cli.ts +10 -12
  49. package/src/namespaces/search.test.ts +218 -18
  50. package/src/namespaces/search.ts +122 -7
  51. package/src/qmd-client.test.ts +74 -1
  52. package/src/qmd.ts +52 -6
  53. package/src/search/port.ts +9 -0
  54. package/src/transcript-day-range.test.ts +101 -0
  55. package/src/transcript.ts +26 -0
  56. package/dist/chunk-3D6L7CEP.js.map +0 -1
  57. package/dist/chunk-APWJRJFW.js.map +0 -1
  58. package/dist/chunk-XUGQQPGO.js.map +0 -1
  59. /package/dist/{chunk-L7W5YW6Y.js.map → chunk-5ETA6OAS.js.map} +0 -0
  60. /package/dist/{chunk-DCGT4FPP.js.map → chunk-76QTEJ2Q.js.map} +0 -0
  61. /package/dist/{chunk-RAELB5NX.js.map → chunk-CNRZ6WJU.js.map} +0 -0
  62. /package/dist/{chunk-3MAONBX3.js.map → chunk-FOVPSMGI.js.map} +0 -0
  63. /package/dist/{chunk-ZPPFKVSD.js.map → chunk-FUXV6HSO.js.map} +0 -0
  64. /package/dist/{chunk-FAYDM5WD.js.map → chunk-RRRCNIPK.js.map} +0 -0
  65. /package/dist/{chunk-6BNFVP7Y.js.map → chunk-RZOBQ23O.js.map} +0 -0
  66. /package/dist/{chunk-RG3LBSGH.js.map → chunk-TQUWNX7C.js.map} +0 -0
  67. /package/dist/{chunk-U55D5UD5.js.map → chunk-WPCCNSWO.js.map} +0 -0
  68. /package/dist/{chunk-TA4LQ5SR.js.map → chunk-XUGVP7ZU.js.map} +0 -0
  69. /package/dist/{chunk-DC66QVL2.js.map → chunk-ZT6R3WR3.js.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remnic/core",
3
- "version": "9.3.647",
3
+ "version": "9.3.649",
4
4
  "description": "Framework-agnostic Remnic memory engine — orchestrator, storage, extraction, search, trust zones",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/src/cli.ts CHANGED
@@ -5,6 +5,7 @@ import { createHash } from "node:crypto";
5
5
  import type { Readable, Writable } from "node:stream";
6
6
  import type { Orchestrator } from "./orchestrator.js";
7
7
  import { ThreadingManager } from "./threading.js";
8
+ import { utcDayRange } from "./transcript.js";
8
9
  import { runWearablesCliCommand } from "./wearables/cli.js";
9
10
  import type {
10
11
  BehaviorSignalEvent,
@@ -8630,12 +8631,11 @@ export function registerCli(
8630
8631
  }
8631
8632
 
8632
8633
  if (date) {
8633
- // Read specific date
8634
- const entries = await orchestrator.transcript.readRange(
8635
- `${date}T00:00:00Z`,
8636
- `${date}T23:59:59Z`,
8637
- channel,
8638
- );
8634
+ // Read specific date. Use a half-open [start, next-day-00:00:00Z)
8635
+ // window so `readRange`'s exclusive upper bound still covers the
8636
+ // final second of the day (rule #35).
8637
+ const { start, end } = utcDayRange(date);
8638
+ const entries = await orchestrator.transcript.readRange(start, end, channel);
8639
8639
  console.log(formatTranscript(entries));
8640
8640
  } else if (recent) {
8641
8641
  // Parse duration (e.g., "12h", "30m")
@@ -8643,13 +8643,11 @@ export function registerCli(
8643
8643
  const entries = await orchestrator.transcript.readRecent(hours, channel);
8644
8644
  console.log(formatTranscript(entries));
8645
8645
  } else {
8646
- // Default: show today's transcript
8646
+ // Default: show today's transcript. Same half-open day window as
8647
+ // the --date branch so the final second of the day is included.
8647
8648
  const today = new Date().toISOString().slice(0, 10);
8648
- const entries = await orchestrator.transcript.readRange(
8649
- `${today}T00:00:00Z`,
8650
- `${today}T23:59:59Z`,
8651
- channel,
8652
- );
8649
+ const { start, end } = utcDayRange(today);
8650
+ const entries = await orchestrator.transcript.readRange(start, end, channel);
8653
8651
  console.log(formatTranscript(entries));
8654
8652
  }
8655
8653
  });
@@ -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);