@xdarkicex/openclaw-memory-libravdb 1.4.2 → 1.4.4

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.
@@ -0,0 +1,627 @@
1
+ import fs from "node:fs";
2
+ import fsp from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import type { LoggerLike, PluginConfig } from "./types.js";
6
+ import { hashBytes } from "./markdown-hash.js";
7
+
8
+ const DEFAULT_DEBOUNCE_MS = 150;
9
+ const DEFAULT_TOKENIZER_ID = "markdown-ingest:v1";
10
+ const MARKDOWN_INGEST_VERSION = 3;
11
+ const HASH_BACKEND = "wasm-fnv1a64";
12
+ type Disposable = { close(): void };
13
+
14
+ interface RpcLike {
15
+ call<T>(method: string, params: unknown): Promise<T>;
16
+ }
17
+
18
+ type RpcGetterLike = () => Promise<RpcLike>;
19
+
20
+ interface FsDirentLike {
21
+ name: string;
22
+ isDirectory(): boolean;
23
+ isFile(): boolean;
24
+ }
25
+
26
+ interface FsWatcherLike extends Disposable {
27
+ on(event: "error", handler: (error: Error) => void): void;
28
+ }
29
+
30
+ interface FsApi {
31
+ readdir(dir: string): Promise<FsDirentLike[]>;
32
+ readFile(file: string): Promise<Uint8Array>;
33
+ stat(file: string): Promise<{ size: number; mtimeMs: number }>;
34
+ watch(dir: string, onChange: (event: string, filename: string | Buffer | null) => void): FsWatcherLike;
35
+ }
36
+
37
+ export interface MarkdownSourceAdapter {
38
+ kind: string;
39
+ start(): Promise<void>;
40
+ refresh(): Promise<void>;
41
+ stop(): Promise<void>;
42
+ }
43
+
44
+ export interface MarkdownIngestionHandle {
45
+ start(): Promise<void>;
46
+ refresh(): Promise<void>;
47
+ stop(): Promise<void>;
48
+ }
49
+
50
+ export interface MarkdownIngestionSnapshot {
51
+ fileHash: string;
52
+ size: number;
53
+ mtimeMs: number;
54
+ }
55
+
56
+ interface RootState {
57
+ root: string;
58
+ scanState: {
59
+ scanning: boolean;
60
+ dirty: boolean;
61
+ timer: ReturnType<typeof setTimeout> | null;
62
+ };
63
+ knownFiles: Set<string>;
64
+ directoryWatchers: Map<string, FsWatcherLike>;
65
+ }
66
+
67
+ interface FileState extends MarkdownIngestionSnapshot {
68
+ root: string;
69
+ sourceDoc: string;
70
+ relativePath: string;
71
+ }
72
+
73
+ interface GenericMarkdownSourceConfig {
74
+ roots: string[];
75
+ include?: string[];
76
+ exclude?: string[];
77
+ debounceMs?: number;
78
+ }
79
+
80
+ interface IngestMarkdownDocumentParams {
81
+ sourceDoc: string;
82
+ text: string;
83
+ tokenizerId: string;
84
+ coreDoc: boolean;
85
+ sourceMeta: {
86
+ sourceRoot: string;
87
+ sourcePath: string;
88
+ sourceKind: string;
89
+ fileHash: string;
90
+ sourceSize: number;
91
+ sourceMtimeMs: number;
92
+ ingestVersion: number;
93
+ hashBackend: string;
94
+ };
95
+ }
96
+
97
+ interface DeleteAuthoredDocumentParams {
98
+ sourceDoc: string;
99
+ }
100
+
101
+ export function createMarkdownIngestionHandle(
102
+ cfg: PluginConfig,
103
+ getRpc: RpcGetterLike,
104
+ logger: LoggerLike = console,
105
+ fsApi: FsApi = createRealFsApi(),
106
+ ): MarkdownIngestionHandle {
107
+ const adapters: MarkdownSourceAdapter[] = [];
108
+
109
+ const genericRoots = normalizeMarkdownRoots(cfg.markdownIngestionRoots);
110
+ if (isMarkdownIngestionEnabled(cfg, genericRoots)) {
111
+ adapters.push(
112
+ new DirectoryMarkdownSourceAdapter(
113
+ "generic",
114
+ {
115
+ roots: genericRoots,
116
+ include: cfg.markdownIngestionInclude,
117
+ exclude: cfg.markdownIngestionExclude,
118
+ debounceMs: cfg.markdownIngestionDebounceMs ?? DEFAULT_DEBOUNCE_MS,
119
+ },
120
+ getRpc,
121
+ logger,
122
+ fsApi,
123
+ ),
124
+ );
125
+ }
126
+
127
+ const obsidianRoots = normalizeMarkdownRoots(cfg.markdownIngestionObsidianRoots);
128
+ if (cfg.markdownIngestionObsidianEnabled !== false && obsidianRoots.length > 0) {
129
+ adapters.push(
130
+ new DirectoryMarkdownSourceAdapter(
131
+ "obsidian",
132
+ {
133
+ roots: obsidianRoots,
134
+ include: cfg.markdownIngestionObsidianInclude,
135
+ exclude: cfg.markdownIngestionObsidianExclude,
136
+ debounceMs: cfg.markdownIngestionObsidianDebounceMs ?? cfg.markdownIngestionDebounceMs ?? DEFAULT_DEBOUNCE_MS,
137
+ },
138
+ getRpc,
139
+ logger,
140
+ fsApi,
141
+ ),
142
+ );
143
+ }
144
+
145
+ if (adapters.length === 0) {
146
+ return {
147
+ async start() {},
148
+ async refresh() {},
149
+ async stop() {},
150
+ };
151
+ }
152
+
153
+ const adapter = new CompositeMarkdownSourceAdapter(adapters);
154
+
155
+ return {
156
+ start: () => adapter.start(),
157
+ refresh: () => adapter.refresh(),
158
+ stop: () => adapter.stop(),
159
+ };
160
+ }
161
+
162
+ class CompositeMarkdownSourceAdapter implements MarkdownSourceAdapter {
163
+ kind = "composite";
164
+ constructor(private readonly adapters: MarkdownSourceAdapter[]) {}
165
+
166
+ async start(): Promise<void> {
167
+ for (const adapter of this.adapters) {
168
+ await adapter.start();
169
+ }
170
+ }
171
+
172
+ async refresh(): Promise<void> {
173
+ for (const adapter of this.adapters) {
174
+ await adapter.refresh();
175
+ }
176
+ }
177
+
178
+ async stop(): Promise<void> {
179
+ for (const adapter of this.adapters) {
180
+ await adapter.stop();
181
+ }
182
+ }
183
+ }
184
+
185
+ class DirectoryMarkdownSourceAdapter implements MarkdownSourceAdapter {
186
+ readonly kind: string;
187
+ private readonly roots: string[];
188
+ private readonly includePatterns: string[];
189
+ private readonly excludePatterns: string[];
190
+ private readonly debounceMs: number;
191
+ private readonly fsApi: FsApi;
192
+ private readonly getRpc: RpcGetterLike;
193
+ private readonly logger: LoggerLike;
194
+ private readonly states = new Map<string, RootState>();
195
+ private readonly fileStates = new Map<string, FileState>();
196
+ private readonly tokenizerId: string;
197
+ private readonly coreDoc: boolean;
198
+ private started = false;
199
+
200
+ constructor(kind: string, config: GenericMarkdownSourceConfig, getRpc: RpcGetterLike, logger: LoggerLike, fsApi: FsApi) {
201
+ this.kind = kind;
202
+ this.roots = config.roots;
203
+ this.includePatterns = config.include?.length ? config.include : [];
204
+ this.excludePatterns = config.exclude?.length ? config.exclude : [];
205
+ this.debounceMs = config.debounceMs ?? DEFAULT_DEBOUNCE_MS;
206
+ this.fsApi = fsApi;
207
+ this.getRpc = getRpc;
208
+ this.logger = logger;
209
+ this.tokenizerId = DEFAULT_TOKENIZER_ID;
210
+ this.coreDoc = true;
211
+ }
212
+
213
+ async start(): Promise<void> {
214
+ if (this.started) {
215
+ return;
216
+ }
217
+ this.started = true;
218
+ await this.refresh();
219
+ }
220
+
221
+ async refresh(): Promise<void> {
222
+ if (!this.started) {
223
+ return;
224
+ }
225
+ for (const root of this.roots) {
226
+ await this.scanRoot(root);
227
+ }
228
+ }
229
+
230
+ async stop(): Promise<void> {
231
+ for (const state of this.states.values()) {
232
+ if (state.scanState.timer) {
233
+ clearTimeout(state.scanState.timer);
234
+ state.scanState.timer = null;
235
+ }
236
+ for (const watcher of state.directoryWatchers.values()) {
237
+ watcher.close();
238
+ }
239
+ state.directoryWatchers.clear();
240
+ }
241
+ this.states.clear();
242
+ this.fileStates.clear();
243
+ this.started = false;
244
+ }
245
+
246
+ private getRootState(root: string): RootState {
247
+ const resolved = path.resolve(root);
248
+ const existing = this.states.get(resolved);
249
+ if (existing) {
250
+ return existing;
251
+ }
252
+ const created: RootState = {
253
+ root: resolved,
254
+ scanState: {
255
+ scanning: false,
256
+ dirty: false,
257
+ timer: null,
258
+ },
259
+ knownFiles: new Set<string>(),
260
+ directoryWatchers: new Map<string, FsWatcherLike>(),
261
+ };
262
+ this.states.set(resolved, created);
263
+ return created;
264
+ }
265
+
266
+ private async scanRoot(root: string): Promise<void> {
267
+ const rootState = this.getRootState(root);
268
+ if (rootState.scanState.scanning) {
269
+ rootState.scanState.dirty = true;
270
+ return;
271
+ }
272
+
273
+ rootState.scanState.scanning = true;
274
+ try {
275
+ const currentFiles = new Set<string>();
276
+ await this.walkDirectory(rootState, rootState.root, currentFiles);
277
+ await this.pruneDeletedFiles(rootState, currentFiles);
278
+ rootState.knownFiles = currentFiles;
279
+ } finally {
280
+ rootState.scanState.scanning = false;
281
+ if (rootState.scanState.dirty) {
282
+ rootState.scanState.dirty = false;
283
+ this.scheduleRootScan(rootState);
284
+ }
285
+ }
286
+ }
287
+
288
+ private scheduleRootScan(rootState: RootState): void {
289
+ if (rootState.scanState.scanning) {
290
+ rootState.scanState.dirty = true;
291
+ return;
292
+ }
293
+ if (rootState.scanState.timer) {
294
+ return;
295
+ }
296
+ rootState.scanState.timer = setTimeout(() => {
297
+ rootState.scanState.timer = null;
298
+ void this.scanRoot(rootState.root).catch((error) => {
299
+ this.logger.warn?.(`[markdown-ingest] root scan failed for ${rootState.root}: ${formatError(error)}`);
300
+ });
301
+ }, this.debounceMs);
302
+ }
303
+
304
+ private async walkDirectory(rootState: RootState, dir: string, currentFiles: Set<string>): Promise<void> {
305
+ await this.ensureDirectoryWatcher(rootState, dir);
306
+
307
+ let entries: FsDirentLike[];
308
+ try {
309
+ entries = await this.fsApi.readdir(dir);
310
+ } catch (error) {
311
+ const message = formatError(error);
312
+ if (!message.includes("ENOENT")) {
313
+ this.logger.warn?.(`[markdown-ingest] readdir failed for ${dir}: ${message}`);
314
+ }
315
+ return;
316
+ }
317
+
318
+ for (const entry of entries) {
319
+ const child = path.join(dir, entry.name);
320
+ if (entry.isDirectory()) {
321
+ await this.walkDirectory(rootState, child, currentFiles);
322
+ continue;
323
+ }
324
+ if (!entry.isFile() || !isMarkdownFile(entry.name)) {
325
+ continue;
326
+ }
327
+ if (!this.shouldIncludeFile(rootState.root, child)) {
328
+ continue;
329
+ }
330
+ currentFiles.add(child);
331
+ try {
332
+ await this.syncMarkdownFile(rootState, child);
333
+ } catch (error) {
334
+ this.logger.warn?.(`[markdown-ingest] sync failed for ${child}: ${formatError(error)}`);
335
+ }
336
+ }
337
+ }
338
+
339
+ private async ensureDirectoryWatcher(rootState: RootState, dir: string): Promise<void> {
340
+ if (rootState.directoryWatchers.has(dir)) {
341
+ return;
342
+ }
343
+
344
+ try {
345
+ const watcher = this.fsApi.watch(dir, () => {
346
+ this.scheduleRootScan(rootState);
347
+ });
348
+ watcher.on("error", (error) => {
349
+ this.logger.warn?.(`[markdown-ingest] watch error for ${dir}: ${formatError(error)}`);
350
+ });
351
+ rootState.directoryWatchers.set(dir, watcher);
352
+ } catch (error) {
353
+ this.logger.warn?.(`[markdown-ingest] watch unavailable for ${dir}: ${formatError(error)}`);
354
+ }
355
+ }
356
+
357
+ private shouldIncludeFile(root: string, filePath: string): boolean {
358
+ if (isOpenClawMemoryFile(filePath)) {
359
+ return true;
360
+ }
361
+ const relative = toPosixPath(path.relative(root, filePath));
362
+ if (this.excludePatterns.length > 0) {
363
+ for (const pattern of this.excludePatterns) {
364
+ if (matchesGlob(relative, pattern)) {
365
+ return false;
366
+ }
367
+ }
368
+ }
369
+ if (this.includePatterns.length > 0) {
370
+ for (const pattern of this.includePatterns) {
371
+ if (matchesGlob(relative, pattern)) {
372
+ return true;
373
+ }
374
+ }
375
+ return false;
376
+ }
377
+ return true;
378
+ }
379
+
380
+ private async pruneDeletedFiles(rootState: RootState, currentFiles: Set<string>): Promise<void> {
381
+ const removed: string[] = [];
382
+ for (const previous of rootState.knownFiles) {
383
+ if (!currentFiles.has(previous)) {
384
+ removed.push(previous);
385
+ }
386
+ }
387
+ if (removed.length === 0) {
388
+ return;
389
+ }
390
+ for (const filePath of removed) {
391
+ await this.deleteSourceDocument(filePath);
392
+ this.fileStates.delete(filePath);
393
+ }
394
+ }
395
+
396
+ private async syncMarkdownFile(rootState: RootState, filePath: string): Promise<void> {
397
+ const sourceDoc = filePath;
398
+ const relativePath = toPosixPath(path.relative(rootState.root, filePath));
399
+ const stat = await this.safeStat(filePath);
400
+ if (!stat) {
401
+ await this.deleteSourceDocument(sourceDoc);
402
+ this.fileStates.delete(sourceDoc);
403
+ return;
404
+ }
405
+
406
+ const cached = this.fileStates.get(sourceDoc);
407
+ if (cached && cached.size === stat.size && cached.mtimeMs === stat.mtimeMs) {
408
+ return;
409
+ }
410
+
411
+ const bytes = await this.safeReadFile(filePath);
412
+ if (!bytes) {
413
+ await this.deleteSourceDocument(sourceDoc);
414
+ this.fileStates.delete(sourceDoc);
415
+ return;
416
+ }
417
+
418
+ const fileHash = hashBytes(bytes);
419
+ if (cached && cached.fileHash === fileHash) {
420
+ this.fileStates.set(sourceDoc, {
421
+ root: rootState.root,
422
+ sourceDoc,
423
+ relativePath,
424
+ fileHash,
425
+ size: stat.size,
426
+ mtimeMs: stat.mtimeMs,
427
+ });
428
+ return;
429
+ }
430
+
431
+ const text = textDecoder.decode(bytes);
432
+ if (this.kind === "obsidian" && this.includePatterns.length === 0 && !looksLikeObsidianNote(filePath, text)) {
433
+ await this.deleteSourceDocument(sourceDoc);
434
+ this.fileStates.delete(sourceDoc);
435
+ return;
436
+ }
437
+ await this.ingestMarkdownDocument(sourceDoc, text, rootState.root, relativePath, fileHash, stat.size, stat.mtimeMs);
438
+ this.fileStates.set(sourceDoc, {
439
+ root: rootState.root,
440
+ sourceDoc,
441
+ relativePath,
442
+ fileHash,
443
+ size: stat.size,
444
+ mtimeMs: stat.mtimeMs,
445
+ });
446
+ }
447
+
448
+ private async ingestMarkdownDocument(
449
+ sourceDoc: string,
450
+ text: string,
451
+ sourceRoot: string,
452
+ sourcePath: string,
453
+ fileHash: string,
454
+ sourceSize: number,
455
+ sourceMtimeMs: number,
456
+ ): Promise<void> {
457
+ const rpc = await this.getRpc();
458
+ const params: IngestMarkdownDocumentParams = {
459
+ sourceDoc,
460
+ text,
461
+ tokenizerId: this.tokenizerId,
462
+ coreDoc: this.coreDoc,
463
+ sourceMeta: {
464
+ sourceRoot,
465
+ sourcePath,
466
+ sourceKind: this.kind,
467
+ fileHash,
468
+ sourceSize,
469
+ sourceMtimeMs,
470
+ ingestVersion: MARKDOWN_INGEST_VERSION,
471
+ hashBackend: HASH_BACKEND,
472
+ },
473
+ };
474
+ await rpc.call("ingest_markdown_document", params);
475
+ }
476
+
477
+ private async deleteSourceDocument(sourceDoc: string): Promise<void> {
478
+ const rpc = await this.getRpc();
479
+ const params: DeleteAuthoredDocumentParams = { sourceDoc };
480
+ await rpc.call("delete_authored_document", params);
481
+ }
482
+
483
+ private async safeStat(filePath: string): Promise<{ size: number; mtimeMs: number } | null> {
484
+ try {
485
+ return await this.fsApi.stat(filePath);
486
+ } catch {
487
+ return null;
488
+ }
489
+ }
490
+
491
+ private async safeReadFile(filePath: string): Promise<Uint8Array | null> {
492
+ try {
493
+ return await this.fsApi.readFile(filePath);
494
+ } catch {
495
+ return null;
496
+ }
497
+ }
498
+ }
499
+
500
+ function toPosixPath(value: string): string {
501
+ return value.split(path.sep).join("/");
502
+ }
503
+
504
+ const textDecoder = new TextDecoder();
505
+
506
+ function normalizeMarkdownRoots(roots?: string[]): string[] {
507
+ if (!roots?.length) {
508
+ return [];
509
+ }
510
+ const resolved = new Set<string>();
511
+ for (const root of roots) {
512
+ const trimmed = root.trim();
513
+ if (!trimmed) {
514
+ continue;
515
+ }
516
+ resolved.add(path.resolve(trimmed));
517
+ }
518
+ return [...resolved];
519
+ }
520
+
521
+ function isMarkdownIngestionEnabled(cfg: PluginConfig, roots: string[]): boolean {
522
+ if (cfg.markdownIngestionEnabled === false) {
523
+ return false;
524
+ }
525
+ return roots.length > 0;
526
+ }
527
+
528
+ function createRealFsApi(): FsApi {
529
+ return {
530
+ readdir: async (dir: string) => fsp.readdir(dir, { withFileTypes: true }) as Promise<FsDirentLike[]>,
531
+ readFile: async (file: string) => fsp.readFile(file),
532
+ stat: async (file: string) => {
533
+ const stat = await fsp.stat(file);
534
+ return { size: stat.size, mtimeMs: stat.mtimeMs };
535
+ },
536
+ watch: (dir: string, onChange: (event: string, filename: string | Buffer | null) => void) => fs.watch(dir, onChange),
537
+ };
538
+ }
539
+
540
+ function isMarkdownFile(fileName: string): boolean {
541
+ const lower = fileName.toLowerCase();
542
+ return lower.endsWith(".md") || lower.endsWith(".markdown");
543
+ }
544
+
545
+ function matchesGlob(value: string, pattern: string): boolean {
546
+ const escaped = pattern
547
+ .split("*")
548
+ .map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
549
+ .join(".*");
550
+ return new RegExp(`^${escaped}$`).test(value);
551
+ }
552
+
553
+ function formatError(error: unknown): string {
554
+ if (error instanceof Error) {
555
+ return error.message;
556
+ }
557
+ return String(error);
558
+ }
559
+
560
+ function looksLikeObsidianNote(filePath: string, text: string): boolean {
561
+ if (!text.startsWith("---\n")) {
562
+ return hasInlineObsidianTag(text);
563
+ }
564
+
565
+ const frontmatterEnd = findFrontmatterEnd(text, 4);
566
+ if (frontmatterEnd < 0) {
567
+ return hasInlineObsidianTag(text);
568
+ }
569
+
570
+ const frontmatter = text.slice(4, frontmatterEnd);
571
+ const lines = frontmatter.split("\n");
572
+ for (const line of lines) {
573
+ const trimmed = line.trimStart();
574
+ if (
575
+ trimmed.startsWith("tags:") ||
576
+ trimmed.startsWith("tag:") ||
577
+ trimmed.startsWith("openclaw:") ||
578
+ trimmed.startsWith("memory:")
579
+ ) {
580
+ return true;
581
+ }
582
+ }
583
+
584
+ return hasInlineObsidianTag(text.slice(frontmatterEnd + 4));
585
+ }
586
+
587
+ function findFrontmatterEnd(text: string, offset: number): number {
588
+ for (let i = offset; i < text.length - 3; i++) {
589
+ if (text.charCodeAt(i) !== 45 || text.charCodeAt(i + 1) !== 45 || text.charCodeAt(i + 2) !== 45) {
590
+ continue;
591
+ }
592
+ const next = text.charCodeAt(i + 3);
593
+ if (next === 10) {
594
+ return i;
595
+ }
596
+ if (next === 13 && text.charCodeAt(i + 4) === 10) {
597
+ return i;
598
+ }
599
+ }
600
+ return -1;
601
+ }
602
+
603
+ function hasInlineObsidianTag(text: string): boolean {
604
+ let inFence = false;
605
+ const lines = text.split("\n");
606
+ for (const line of lines) {
607
+ const trimmed = line.trimStart();
608
+ if (trimmed.startsWith("```") || trimmed.startsWith("~~~")) {
609
+ inFence = !inFence;
610
+ continue;
611
+ }
612
+ if (inFence) {
613
+ continue;
614
+ }
615
+ if (trimmed.startsWith("#")) {
616
+ continue;
617
+ }
618
+ if (/(^|[^A-Za-z0-9_])#([A-Za-z][A-Za-z0-9/_-]*)\b/.test(line)) {
619
+ return true;
620
+ }
621
+ }
622
+ return false;
623
+ }
624
+
625
+ function isOpenClawMemoryFile(filePath: string): boolean {
626
+ return path.basename(filePath).toLowerCase() === "memory.md";
627
+ }
@@ -1,7 +1,12 @@
1
1
  import type { RpcGetter } from "./plugin-runtime.js";
2
2
  import { resolveDurableNamespace } from "./durable-namespace.js";
3
+ import { detectDreamQuerySignal, resolveDreamCollection } from "./dream-routing.js";
3
4
  import type { PluginConfig, SearchResult } from "./types.js";
4
5
 
6
+ type RpcLike = {
7
+ call<T>(method: string, params: unknown): Promise<T>;
8
+ };
9
+
5
10
  type MemorySearchParams = {
6
11
  query?: string;
7
12
  text?: string;
@@ -78,6 +83,7 @@ function createMemorySearchManager(
78
83
  return legacyCall ? { results: [], error: "Missing query text for LibraVDB memory search" } : [];
79
84
  }
80
85
 
86
+ const dreamQuery = detectDreamQuerySignal(queryText);
81
87
  const sessionId = firstString(params.sessionId, params.context?.sessionId);
82
88
  const userId = resolveDurableNamespace({
83
89
  userId: firstString(params.userId, params.context?.userId),
@@ -86,21 +92,15 @@ function createMemorySearchManager(
86
92
  fallback: sessionId ? `session:${sessionId}` : undefined,
87
93
  });
88
94
  const k = normalizePositiveInteger(params.k, params.limit, params.topK, cfg.topK, 8);
89
- const collections = resolveSearchCollections(cfg, userId, sessionId);
90
95
  const rpc = await getRpc();
91
96
 
92
- const result = collections.length === 1
97
+ const result = dreamQuery.active
93
98
  ? await rpc.call<{ results: SearchResult[] }>("search_text", {
94
- collection: collections[0],
99
+ collection: resolveDreamCollection(userId),
95
100
  text: queryText,
96
101
  k,
97
102
  })
98
- : await rpc.call<{ results: SearchResult[] }>("search_text_collections", {
99
- collections,
100
- text: queryText,
101
- k,
102
- excludeByCollection: {},
103
- });
103
+ : await searchResolvedCollections(rpc, cfg, userId, sessionId, queryText, k);
104
104
 
105
105
  const legacyResults = result.results.map((item) => ({
106
106
  ...item,
@@ -150,6 +150,29 @@ function createMemorySearchManager(
150
150
  };
151
151
  }
152
152
 
153
+ async function searchResolvedCollections(
154
+ rpc: RpcLike,
155
+ cfg: PluginConfig,
156
+ userId: string,
157
+ sessionId: string | undefined,
158
+ queryText: string,
159
+ k: number,
160
+ ): Promise<{ results: SearchResult[] }> {
161
+ const collections = resolveSearchCollections(cfg, userId, sessionId);
162
+ return collections.length === 1
163
+ ? await rpc.call<{ results: SearchResult[] }>("search_text", {
164
+ collection: collections[0],
165
+ text: queryText,
166
+ k,
167
+ })
168
+ : await rpc.call<{ results: SearchResult[] }>("search_text_collections", {
169
+ collections,
170
+ text: queryText,
171
+ k,
172
+ excludeByCollection: {},
173
+ });
174
+ }
175
+
153
176
  function resolveSearchCollections(cfg: PluginConfig, userId: string, sessionId?: string): string[] {
154
177
  const collections = [`user:${userId}`, "global"];
155
178
  if (!sessionId) {