@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.
- 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-DCGT4FPP.js → chunk-76QTEJ2Q.js} +2 -2
- package/dist/{chunk-XUGQQPGO.js → chunk-AGRPGAKR.js} +12 -1
- package/dist/chunk-AGRPGAKR.js.map +1 -0
- package/dist/{chunk-RAELB5NX.js → chunk-CNRZ6WJU.js} +3 -3
- package/dist/{chunk-3MAONBX3.js → chunk-FOVPSMGI.js} +2 -2
- package/dist/{chunk-3D6L7CEP.js → chunk-FQYFMIKG.js} +14 -17
- package/dist/chunk-FQYFMIKG.js.map +1 -0
- 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-6BNFVP7Y.js → chunk-RZOBQ23O.js} +2 -2
- package/dist/{chunk-RG3LBSGH.js → chunk-TQUWNX7C.js} +2 -2
- package/dist/{chunk-U55D5UD5.js → chunk-WPCCNSWO.js} +5 -5
- package/dist/{chunk-TA4LQ5SR.js → chunk-XUGVP7ZU.js} +5 -5
- 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/schemas.d.ts +22 -22
- 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 +18 -1
- package/dist/transcript.js +5 -3
- package/dist/transfer/types.d.ts +12 -12
- package/package.json +1 -1
- package/src/cli.ts +10 -12
- 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-day-range.test.ts +101 -0
- package/src/transcript.ts +26 -0
- package/dist/chunk-3D6L7CEP.js.map +0 -1
- package/dist/chunk-APWJRJFW.js.map +0 -1
- package/dist/chunk-XUGQQPGO.js.map +0 -1
- /package/dist/{chunk-L7W5YW6Y.js.map → chunk-5ETA6OAS.js.map} +0 -0
- /package/dist/{chunk-DCGT4FPP.js.map → chunk-76QTEJ2Q.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-6BNFVP7Y.js.map → chunk-RZOBQ23O.js.map} +0 -0
- /package/dist/{chunk-RG3LBSGH.js.map → chunk-TQUWNX7C.js.map} +0 -0
- /package/dist/{chunk-U55D5UD5.js.map → chunk-WPCCNSWO.js.map} +0 -0
- /package/dist/{chunk-TA4LQ5SR.js.map → chunk-XUGVP7ZU.js.map} +0 -0
- /package/dist/{chunk-DC66QVL2.js.map → chunk-ZT6R3WR3.js.map} +0 -0
package/package.json
CHANGED
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
|
-
|
|
8635
|
-
|
|
8636
|
-
|
|
8637
|
-
|
|
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
|
|
8649
|
-
|
|
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 {
|
|
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);
|