@remnic/core 9.3.646 → 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.
- package/dist/access-cli.js +10 -10
- package/dist/access-http.js +10 -10
- package/dist/access-mcp.js +9 -9
- package/dist/access-service.js +8 -8
- package/dist/{chunk-L7W5YW6Y.js → chunk-5ETA6OAS.js} +2 -2
- package/dist/{chunk-I3IWTRYB.js → chunk-6BNFVP7Y.js} +2 -2
- package/dist/{chunk-DCGT4FPP.js → chunk-76QTEJ2Q.js} +2 -2
- package/dist/{chunk-3JSWINVD.js → chunk-AEIZEAP7.js} +7 -7
- package/dist/{chunk-RAELB5NX.js → chunk-CNRZ6WJU.js} +3 -3
- package/dist/{chunk-3MAONBX3.js → chunk-FOVPSMGI.js} +2 -2
- package/dist/{chunk-ZPPFKVSD.js → chunk-FUXV6HSO.js} +2 -2
- package/dist/{chunk-MUKXANAM.js → chunk-I4COC5XW.js} +49 -6
- package/dist/{chunk-MUKXANAM.js.map → chunk-I4COC5XW.js.map} +1 -1
- package/dist/{chunk-APWJRJFW.js → chunk-NMIOW7XG.js} +86 -8
- package/dist/chunk-NMIOW7XG.js.map +1 -0
- package/dist/{chunk-FAV25DUZ.js → chunk-QT4THOLT.js} +1 -1
- package/dist/{chunk-FAV25DUZ.js.map → chunk-QT4THOLT.js.map} +1 -1
- package/dist/{chunk-FAYDM5WD.js → chunk-RRRCNIPK.js} +2 -2
- package/dist/{chunk-RG3LBSGH.js → chunk-TQUWNX7C.js} +2 -2
- package/dist/{chunk-H67ZTMTL.js → chunk-TWVRDGTX.js} +5 -5
- package/dist/{chunk-U55D5UD5.js → chunk-WPCCNSWO.js} +5 -5
- package/dist/{chunk-JLOJ5RJ7.js → chunk-XUGQQPGO.js} +10 -3
- package/dist/chunk-XUGQQPGO.js.map +1 -0
- package/dist/{chunk-DC66QVL2.js → chunk-ZT6R3WR3.js} +2 -2
- package/dist/cli.js +15 -15
- package/dist/index.js +16 -16
- package/dist/namespaces/migrate.js +8 -8
- package/dist/namespaces/search.d.ts +1 -0
- package/dist/namespaces/search.js +7 -7
- package/dist/operator-toolkit.js +9 -9
- package/dist/orchestrator.js +9 -9
- package/dist/qmd.d.ts +1 -0
- package/dist/qmd.js +2 -2
- package/dist/resume-bundles.js +2 -2
- package/dist/search/factory.js +6 -6
- package/dist/search/index.js +6 -6
- package/dist/search/lancedb-backend.js +2 -2
- package/dist/search/meilisearch-backend.js +2 -2
- package/dist/search/orama-backend.js +2 -2
- package/dist/search/port.d.ts +6 -0
- package/dist/search/port.js +1 -1
- package/dist/transcript.d.ts +6 -0
- package/dist/transcript.js +1 -1
- package/package.json +1 -1
- package/src/namespaces/search.test.ts +218 -18
- package/src/namespaces/search.ts +122 -7
- package/src/qmd-client.test.ts +74 -1
- package/src/qmd.ts +52 -6
- package/src/search/port.ts +9 -0
- package/src/transcript.test.ts +110 -0
- package/src/transcript.ts +21 -3
- package/dist/chunk-APWJRJFW.js.map +0 -1
- package/dist/chunk-JLOJ5RJ7.js.map +0 -1
- /package/dist/{chunk-L7W5YW6Y.js.map → chunk-5ETA6OAS.js.map} +0 -0
- /package/dist/{chunk-I3IWTRYB.js.map → chunk-6BNFVP7Y.js.map} +0 -0
- /package/dist/{chunk-DCGT4FPP.js.map → chunk-76QTEJ2Q.js.map} +0 -0
- /package/dist/{chunk-3JSWINVD.js.map → chunk-AEIZEAP7.js.map} +0 -0
- /package/dist/{chunk-RAELB5NX.js.map → chunk-CNRZ6WJU.js.map} +0 -0
- /package/dist/{chunk-3MAONBX3.js.map → chunk-FOVPSMGI.js.map} +0 -0
- /package/dist/{chunk-ZPPFKVSD.js.map → chunk-FUXV6HSO.js.map} +0 -0
- /package/dist/{chunk-FAYDM5WD.js.map → chunk-RRRCNIPK.js.map} +0 -0
- /package/dist/{chunk-RG3LBSGH.js.map → chunk-TQUWNX7C.js.map} +0 -0
- /package/dist/{chunk-H67ZTMTL.js.map → chunk-TWVRDGTX.js.map} +0 -0
- /package/dist/{chunk-U55D5UD5.js.map → chunk-WPCCNSWO.js.map} +0 -0
- /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 {
|
|
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<{
|
|
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(
|
|
31
|
-
|
|
32
|
-
|
|
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(
|
|
36
|
-
|
|
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(
|
|
40
|
-
|
|
41
|
-
|
|
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(
|
|
45
|
-
|
|
46
|
-
|
|
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(
|
|
50
|
-
|
|
51
|
-
|
|
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<
|
|
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
|
+
});
|
package/src/namespaces/search.ts
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
|
+
import path from "node:path";
|
|
1
2
|
import type { PluginConfig, QmdSearchResult } from "../types.js";
|
|
2
|
-
import type {
|
|
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:
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
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(
|
package/src/qmd-client.test.ts
CHANGED
|
@@ -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: (
|
|
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
|
|
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
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
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
|
-
|
|
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
|
|
package/src/search/port.ts
CHANGED
|
@@ -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,
|