@vellumai/assistant 0.10.4-dev.202607010319.f2b69c7 → 0.10.4-dev.202607010743.70cd9f0
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/package.json +1 -1
- package/src/__tests__/context-search-conversations-source.test.ts +281 -1
- package/src/__tests__/plugin-import-boundary-guard.test.ts +3 -0
- package/src/daemon/conversation.ts +15 -1
- package/src/daemon/handlers/conversation-history.ts +3 -1
- package/src/persistence/__tests__/conversation-queries-search.test.ts +362 -0
- package/src/persistence/__tests__/slow-sync-log.test.ts +65 -0
- package/src/persistence/conversation-crud.ts +16 -7
- package/src/persistence/conversation-queries.ts +271 -73
- package/src/persistence/raw-query.ts +42 -15
- package/src/persistence/slow-sync-log.ts +106 -0
- package/src/plugins/defaults/memory/__tests__/conversation-queries.test.ts +15 -15
- package/src/plugins/defaults/memory/config.ts +11 -0
- package/src/plugins/defaults/memory/context-search/sources/conversations.ts +183 -12
- package/src/plugins/defaults/memory/embeddings.ts +77 -0
- package/src/plugins/defaults/memory/graph/bootstrap.ts +3 -5
- package/src/plugins/defaults/memory/graph/extraction.ts +1 -1
- package/src/plugins/defaults/memory/graph/graph-search.ts +12 -23
- package/src/plugins/defaults/memory/graph/retriever.ts +3 -5
- package/src/plugins/defaults/memory/hooks/init.ts +2 -2
- package/src/plugins/defaults/memory/indexer.ts +2 -3
- package/src/plugins/defaults/memory/injectors.ts +2 -2
- package/src/plugins/defaults/memory/job-handlers/embedding.test.ts +6 -19
- package/src/plugins/defaults/memory/job-handlers/embedding.ts +9 -22
- package/src/plugins/defaults/memory/job-handlers/index-maintenance.ts +2 -3
- package/src/plugins/defaults/memory/job-handlers/index-message-lexical.ts +11 -6
- package/src/plugins/defaults/memory/job-handlers.ts +5 -5
- package/src/plugins/defaults/memory/jobs/embed-concept-page.ts +1 -1
- package/src/plugins/defaults/memory/memory-retrospective-startup-cleanup.ts +2 -2
- package/src/plugins/defaults/memory/persistence-hooks.ts +2 -1
- package/src/plugins/defaults/memory/pkb/pkb-index.ts +1 -5
- package/src/plugins/defaults/memory/pkb/pkb-search.ts +2 -2
- package/src/plugins/defaults/memory/routes/memory-item-routes.ts +1 -1
- package/src/plugins/defaults/memory/search/semantic.ts +2 -2
- package/src/plugins/defaults/memory/startup.ts +3 -5
- package/src/plugins/defaults/memory/v2/activation.ts +2 -4
- package/src/plugins/defaults/memory/v2/cli-command-store.ts +2 -4
- package/src/plugins/defaults/memory/v2/qdrant.ts +2 -3
- package/src/plugins/defaults/memory/v2/sim.ts +2 -4
- package/src/plugins/defaults/memory/v2/skill-store.ts +2 -4
- package/src/plugins/defaults/memory/v3/dense.ts +7 -12
- package/src/plugins/defaults/memory/v3/maintain-job.ts +2 -4
- package/src/plugins/defaults/memory/v3/prune.ts +2 -2
- package/src/plugins/defaults/memory/v3/section-dense-store.ts +8 -11
- package/src/runtime/AGENTS.md +5 -2
- package/src/runtime/http-server.ts +12 -4
- package/src/runtime/middleware/__tests__/rate-limiter.test.ts +22 -0
- package/src/runtime/middleware/rate-limiter.ts +30 -0
- package/src/runtime/routes/conversation-query-routes.ts +4 -2
- package/src/runtime/routes/conversation-routes.ts +3 -3
- package/src/runtime/routes/global-search-routes.ts +1 -1
- package/src/tools/terminal/safe-env.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,57 @@
|
|
|
1
|
-
import { beforeEach, describe, expect, test } from "bun:test";
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
2
|
|
|
3
3
|
import { writeSlackMetadata } from "../messaging/providers/slack/message-metadata.js";
|
|
4
|
+
import type { MessageLexicalSearchResult } from "../persistence/embeddings/messages-lexical-index.js";
|
|
5
|
+
import { setOverridesForTesting } from "./feature-flag-test-helpers.js";
|
|
6
|
+
|
|
7
|
+
// Mutable stand-in for the Qdrant lexical candidate helper. The default
|
|
8
|
+
// throws so any qdrant-path test that forgets to set an implementation fails
|
|
9
|
+
// loudly rather than silently exercising an empty candidate set. FTS-path
|
|
10
|
+
// tests never reach it (the flag defaults to fts5), so its value is irrelevant
|
|
11
|
+
// there.
|
|
12
|
+
let lexicalMockImpl: (
|
|
13
|
+
query: string,
|
|
14
|
+
limit: number,
|
|
15
|
+
opts?: { conversationId?: string },
|
|
16
|
+
) => Promise<MessageLexicalSearchResult[]> = () => {
|
|
17
|
+
throw new Error("searchMessageIdsLexical mock not configured for this test");
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// Records the arguments of every mock invocation so tests can assert the
|
|
21
|
+
// candidate over-fetch count the qdrant branch requests.
|
|
22
|
+
let lexicalCalls: Array<{
|
|
23
|
+
query: string;
|
|
24
|
+
limit: number;
|
|
25
|
+
opts?: { conversationId?: string };
|
|
26
|
+
}> = [];
|
|
27
|
+
|
|
28
|
+
mock.module("../persistence/conversation-search-lexical.js", () => ({
|
|
29
|
+
searchMessageIdsLexical: (
|
|
30
|
+
query: string,
|
|
31
|
+
limit: number,
|
|
32
|
+
opts?: { conversationId?: string },
|
|
33
|
+
) => {
|
|
34
|
+
lexicalCalls.push({ query, limit, opts });
|
|
35
|
+
return lexicalMockImpl(query, limit, opts);
|
|
36
|
+
},
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
// Drives the real recall backend gate: when true the source must fall back to
|
|
40
|
+
// FTS regardless of the flag, because the lexical index write path is
|
|
41
|
+
// suppressed and Qdrant is never populated. Defaults false so every other test
|
|
42
|
+
// exercises its intended backend. Spread the real module so the other 9 exports
|
|
43
|
+
// (job handlers, enqueue helpers) stay intact for transitive importers.
|
|
44
|
+
let suppressIndexing = false;
|
|
45
|
+
const realLexicalModule =
|
|
46
|
+
await import("../plugins/defaults/memory/job-handlers/index-message-lexical.js");
|
|
47
|
+
mock.module(
|
|
48
|
+
"../plugins/defaults/memory/job-handlers/index-message-lexical.js",
|
|
49
|
+
() => ({
|
|
50
|
+
...realLexicalModule,
|
|
51
|
+
isMemoryIndexingSuppressed: () => suppressIndexing,
|
|
52
|
+
}),
|
|
53
|
+
);
|
|
54
|
+
|
|
4
55
|
import { getDb } from "../persistence/db-connection.js";
|
|
5
56
|
import { initializeDb } from "../persistence/db-init.js";
|
|
6
57
|
import { rawRun } from "../persistence/raw-query.js";
|
|
@@ -345,6 +396,235 @@ describe("searchConversationSource", () => {
|
|
|
345
396
|
});
|
|
346
397
|
});
|
|
347
398
|
|
|
399
|
+
describe("searchConversationSource with the qdrant backend", () => {
|
|
400
|
+
beforeEach(() => {
|
|
401
|
+
getDb().run("DELETE FROM messages");
|
|
402
|
+
getDb().run("DELETE FROM conversations");
|
|
403
|
+
lexicalCalls = [];
|
|
404
|
+
suppressIndexing = false;
|
|
405
|
+
setOverridesForTesting({ "messages-search-backend": true });
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
afterEach(() => {
|
|
409
|
+
setOverridesForTesting({});
|
|
410
|
+
suppressIndexing = false;
|
|
411
|
+
lexicalMockImpl = () => {
|
|
412
|
+
throw new Error(
|
|
413
|
+
"searchMessageIdsLexical mock not configured for this test",
|
|
414
|
+
);
|
|
415
|
+
};
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test("falls back to FTS when memory indexing is suppressed, even with the qdrant flag on", async () => {
|
|
419
|
+
const match = await seedConversation({
|
|
420
|
+
title: "Suppressed indexing notes",
|
|
421
|
+
content: "The suppressedtoken decision is recorded here.",
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
// The lexical index is not populated while indexing is suppressed, so the
|
|
425
|
+
// source must NOT query Qdrant — it must use the FTS path.
|
|
426
|
+
suppressIndexing = true;
|
|
427
|
+
lexicalMockImpl = async () => {
|
|
428
|
+
throw new Error("searchMessageIdsLexical must not run while suppressed");
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
const result = await searchConversationSource(
|
|
432
|
+
"suppressedtoken",
|
|
433
|
+
makeContext(),
|
|
434
|
+
5,
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
// FTS path found the row (proves the backend fell back), and the Qdrant
|
|
438
|
+
// candidate helper was never called.
|
|
439
|
+
expect(lexicalCalls).toHaveLength(0);
|
|
440
|
+
expect(result.evidence.map((item) => item.locator)).toEqual([
|
|
441
|
+
`${match.conversation.id}#${match.message.id}`,
|
|
442
|
+
]);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
test("routes short/punctuation-only queries to the exact LIKE path, not Qdrant", async () => {
|
|
446
|
+
const match = await seedConversation({
|
|
447
|
+
title: "C++ notes",
|
|
448
|
+
role: "user",
|
|
449
|
+
content: "Use C++ when the example needs deterministic lifetime notes.",
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
// `C++` produces no usable ≥2-char FTS match shape, so the source must use
|
|
453
|
+
// the exact LIKE path rather than the sparse encoder (which would still
|
|
454
|
+
// emit a noisy 1-char `c` token). The mock throws to prove it never runs.
|
|
455
|
+
lexicalMockImpl = async () => {
|
|
456
|
+
throw new Error("searchMessageIdsLexical must not run for a short query");
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
const result = await searchConversationSource("C++", makeContext(), 5);
|
|
460
|
+
|
|
461
|
+
expect(lexicalCalls).toHaveLength(0);
|
|
462
|
+
expect(result.evidence.map((item) => item.locator)).toEqual([
|
|
463
|
+
`${match.conversation.id}#${match.message.id}`,
|
|
464
|
+
]);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
test("over-fetches a wide candidate pool from Qdrant, not the FTS prefetch window", async () => {
|
|
468
|
+
const match = await seedConversation({
|
|
469
|
+
title: "Launch notes",
|
|
470
|
+
content: "The widecandidatetoken launch checklist is recorded here.",
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
lexicalMockImpl = async () => [{ messageId: match.message.id, score: 0.9 }];
|
|
474
|
+
|
|
475
|
+
// limit = 5 → FTS would prefetch 5 × 5 = 25. The qdrant branch instead
|
|
476
|
+
// over-fetches max(5 × 20, 200) = 200 candidates so post-filter yield stays
|
|
477
|
+
// healthy when top lexical hits are excluded by the SQL predicates.
|
|
478
|
+
const result = await searchConversationSource(
|
|
479
|
+
"widecandidatetoken",
|
|
480
|
+
makeContext(),
|
|
481
|
+
5,
|
|
482
|
+
);
|
|
483
|
+
|
|
484
|
+
expect(lexicalCalls).toHaveLength(1);
|
|
485
|
+
expect(lexicalCalls[0]?.limit).toBe(200);
|
|
486
|
+
// Filtering still yields the correctly-scored surviving row.
|
|
487
|
+
expect(result.evidence.map((item) => item.locator)).toEqual([
|
|
488
|
+
`${match.conversation.id}#${match.message.id}`,
|
|
489
|
+
]);
|
|
490
|
+
expect(result.evidence[0]?.score).toBeGreaterThan(0);
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
test("returns app-scored evidence for Qdrant-supplied candidates", async () => {
|
|
494
|
+
const match = await seedConversation({
|
|
495
|
+
title: "Launch notes",
|
|
496
|
+
content: "The alpha launch checklist includes database backups.",
|
|
497
|
+
});
|
|
498
|
+
// A candidate the lexical index also surfaces but that the query does not
|
|
499
|
+
// actually match — proves the app-side scorer, not Qdrant order, ranks.
|
|
500
|
+
const weak = await seedConversation({
|
|
501
|
+
title: "Unrelated",
|
|
502
|
+
content: "Nothing salient in this conversation at all.",
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
lexicalMockImpl = async () => [
|
|
506
|
+
{ messageId: weak.message.id, score: 0.9 },
|
|
507
|
+
{ messageId: match.message.id, score: 0.1 },
|
|
508
|
+
];
|
|
509
|
+
|
|
510
|
+
const result = await searchConversationSource(
|
|
511
|
+
"alpha launch",
|
|
512
|
+
makeContext(),
|
|
513
|
+
5,
|
|
514
|
+
);
|
|
515
|
+
|
|
516
|
+
expect(result.evidence[0]).toMatchObject({
|
|
517
|
+
id: `conversations:${match.conversation.id}:${match.message.id}`,
|
|
518
|
+
source: "conversations",
|
|
519
|
+
title: "Launch notes",
|
|
520
|
+
locator: `${match.conversation.id}#${match.message.id}`,
|
|
521
|
+
excerpt: "The alpha launch checklist includes database backups.",
|
|
522
|
+
metadata: {
|
|
523
|
+
role: "assistant",
|
|
524
|
+
conversationId: match.conversation.id,
|
|
525
|
+
},
|
|
526
|
+
});
|
|
527
|
+
expect(result.evidence[0]?.score).toBeGreaterThan(
|
|
528
|
+
result.evidence[1]?.score ?? -1,
|
|
529
|
+
);
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
test("applies source, type, and excluded-conversation filters to Qdrant candidates", async () => {
|
|
533
|
+
const visible = await seedConversation({
|
|
534
|
+
title: "User conversation",
|
|
535
|
+
content: "derivedtoken belongs to a user-authored conversation.",
|
|
536
|
+
});
|
|
537
|
+
const subagent = await seedConversation({
|
|
538
|
+
title: "Subagent conversation",
|
|
539
|
+
source: "subagent",
|
|
540
|
+
content: "derivedtoken should not include subagent output.",
|
|
541
|
+
});
|
|
542
|
+
const autoAnalysis = await seedConversation({
|
|
543
|
+
title: "Auto-analysis conversation",
|
|
544
|
+
source: "auto-analysis",
|
|
545
|
+
content: "derivedtoken should not include auto-analysis output.",
|
|
546
|
+
});
|
|
547
|
+
const notification = await seedConversation({
|
|
548
|
+
title: "Notification conversation",
|
|
549
|
+
source: "notification",
|
|
550
|
+
content: "derivedtoken should not include notification output.",
|
|
551
|
+
});
|
|
552
|
+
const current = await seedConversation({
|
|
553
|
+
title: "Current conversation",
|
|
554
|
+
content: "derivedtoken appears in the active conversation.",
|
|
555
|
+
});
|
|
556
|
+
const legacyPrivate = await seedConversation({
|
|
557
|
+
title: "Legacy private conversation",
|
|
558
|
+
content: "derivedtoken belongs to legacy private history.",
|
|
559
|
+
});
|
|
560
|
+
rawRun(
|
|
561
|
+
"UPDATE conversations SET conversation_type = 'private' WHERE id = ?",
|
|
562
|
+
legacyPrivate.conversation.id,
|
|
563
|
+
);
|
|
564
|
+
|
|
565
|
+
// The lexical index does not filter — it hands back every candidate,
|
|
566
|
+
// including the ones SQL must exclude.
|
|
567
|
+
lexicalMockImpl = async () => [
|
|
568
|
+
{ messageId: visible.message.id, score: 0.9 },
|
|
569
|
+
{ messageId: subagent.message.id, score: 0.85 },
|
|
570
|
+
{ messageId: autoAnalysis.message.id, score: 0.8 },
|
|
571
|
+
{ messageId: notification.message.id, score: 0.75 },
|
|
572
|
+
{ messageId: current.message.id, score: 0.7 },
|
|
573
|
+
{ messageId: legacyPrivate.message.id, score: 0.65 },
|
|
574
|
+
];
|
|
575
|
+
|
|
576
|
+
const result = await searchConversationSource(
|
|
577
|
+
"derivedtoken",
|
|
578
|
+
makeContext({ conversationId: current.conversation.id }),
|
|
579
|
+
10,
|
|
580
|
+
);
|
|
581
|
+
|
|
582
|
+
expect(result.evidence.map((item) => item.locator)).toEqual([
|
|
583
|
+
`${visible.conversation.id}#${visible.message.id}`,
|
|
584
|
+
]);
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
test("falls back to LIKE when the Qdrant lookup throws", async () => {
|
|
588
|
+
const match = await seedConversation({
|
|
589
|
+
title: "Fallback notes",
|
|
590
|
+
content: "The likefallbacktoken decision is recorded here.",
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
lexicalMockImpl = async () => {
|
|
594
|
+
throw new Error("qdrant unavailable");
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
const result = await searchConversationSource(
|
|
598
|
+
"likefallbacktoken",
|
|
599
|
+
makeContext(),
|
|
600
|
+
5,
|
|
601
|
+
);
|
|
602
|
+
|
|
603
|
+
expect(result.evidence.map((item) => item.locator)).toEqual([
|
|
604
|
+
`${match.conversation.id}#${match.message.id}`,
|
|
605
|
+
]);
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
test("falls back to LIKE when Qdrant returns no candidates", async () => {
|
|
609
|
+
const match = await seedConversation({
|
|
610
|
+
title: "Empty candidate notes",
|
|
611
|
+
content: "The emptycandidatetoken decision is recorded here.",
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
lexicalMockImpl = async () => [];
|
|
615
|
+
|
|
616
|
+
const result = await searchConversationSource(
|
|
617
|
+
"emptycandidatetoken",
|
|
618
|
+
makeContext(),
|
|
619
|
+
5,
|
|
620
|
+
);
|
|
621
|
+
|
|
622
|
+
expect(result.evidence.map((item) => item.locator)).toEqual([
|
|
623
|
+
`${match.conversation.id}#${match.message.id}`,
|
|
624
|
+
]);
|
|
625
|
+
});
|
|
626
|
+
});
|
|
627
|
+
|
|
348
628
|
function seedConversation(opts: {
|
|
349
629
|
title?: string;
|
|
350
630
|
conversationType?: "standard" | "background" | "scheduled";
|
|
@@ -78,10 +78,12 @@ const BASELINE: Record<string, readonly string[]> = {
|
|
|
78
78
|
"../../../util/logger.js",
|
|
79
79
|
],
|
|
80
80
|
memory: [
|
|
81
|
+
"../../../../../config/assistant-feature-flags.js",
|
|
81
82
|
"../../../../../config/types.js",
|
|
82
83
|
"../../../../../messaging/providers/slack/message-metadata.js",
|
|
83
84
|
"../../../../../persistence/auto-analysis-constants.js",
|
|
84
85
|
"../../../../../persistence/conversation-queries.js",
|
|
86
|
+
"../../../../../persistence/conversation-search-lexical.js",
|
|
85
87
|
"../../../../../persistence/db-connection.js",
|
|
86
88
|
"../../../../../persistence/embeddings/embed.js",
|
|
87
89
|
"../../../../../persistence/embeddings/embedding-backend.js",
|
|
@@ -185,6 +187,7 @@ const BASELINE: Record<string, readonly string[]> = {
|
|
|
185
187
|
"../../../persistence/embeddings/messages-lexical-index.js",
|
|
186
188
|
"../../../persistence/embeddings/qdrant-client.js",
|
|
187
189
|
"../../../persistence/embeddings/qdrant-manager.js",
|
|
190
|
+
"../../../persistence/job-utils.js",
|
|
188
191
|
"../../../persistence/jobs-store.js",
|
|
189
192
|
"../../../persistence/jobs-worker.js",
|
|
190
193
|
"../../../persistence/memory-lifecycle-hooks.js",
|
|
@@ -56,6 +56,7 @@ import {
|
|
|
56
56
|
setConversationProcessingStartedAt,
|
|
57
57
|
} from "../persistence/conversation-crud.js";
|
|
58
58
|
import { getResolvedConversationDirPath } from "../persistence/conversation-directories.js";
|
|
59
|
+
import { reportSlowSync } from "../persistence/slow-sync-log.js";
|
|
59
60
|
import { defaultCompact } from "../plugins/defaults/compaction/compact.js";
|
|
60
61
|
import {
|
|
61
62
|
createContextWindowManager,
|
|
@@ -913,6 +914,7 @@ export class Conversation {
|
|
|
913
914
|
// ── Lifecycle ────────────────────────────────────────────────────
|
|
914
915
|
|
|
915
916
|
async loadFromDb(): Promise<void> {
|
|
917
|
+
const loadStartedAt = performance.now();
|
|
916
918
|
const trustClass = this.trustContext?.trustClass;
|
|
917
919
|
const canAccessMemory = resolveCapabilities(trustClass).canAccessMemory;
|
|
918
920
|
const allDbMessages = getMessages(this.conversationId);
|
|
@@ -1246,10 +1248,22 @@ export class Conversation {
|
|
|
1246
1248
|
this.loadedHistoryTrustClass = trustClass;
|
|
1247
1249
|
this.loadedHistoryPersonalMemoryAllowed = personalMemoryAllowed;
|
|
1248
1250
|
|
|
1251
|
+
const loadElapsedMs = performance.now() - loadStartedAt;
|
|
1249
1252
|
log.info(
|
|
1250
|
-
{
|
|
1253
|
+
{
|
|
1254
|
+
conversationId: this.conversationId,
|
|
1255
|
+
count: this.messages.length,
|
|
1256
|
+
elapsedMs: loadElapsedMs,
|
|
1257
|
+
},
|
|
1251
1258
|
"Loaded messages from DB",
|
|
1252
1259
|
);
|
|
1260
|
+
// Whole read+parse+repair section — attributes an event-loop freeze to
|
|
1261
|
+
// this conversation load (getMessages times the read alone; the delta is
|
|
1262
|
+
// parse/repair CPU). See slow-sync-log / event-loop-watchdog.
|
|
1263
|
+
reportSlowSync("conversation:load-from-db", loadElapsedMs, {
|
|
1264
|
+
conversationId: this.conversationId,
|
|
1265
|
+
messageCount: this.messages.length,
|
|
1266
|
+
});
|
|
1253
1267
|
|
|
1254
1268
|
this.restoreSurfaceStateFromHistory();
|
|
1255
1269
|
this.graphMemory.restoreState();
|
|
@@ -16,7 +16,9 @@ export interface ConversationSearchParams {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
/** Search conversations and return results (no transport dependency). */
|
|
19
|
-
export function performConversationSearch(
|
|
19
|
+
export async function performConversationSearch(
|
|
20
|
+
params: ConversationSearchParams,
|
|
21
|
+
) {
|
|
20
22
|
// Treat "*" as a list-all wildcard — FTS treats it as a literal character.
|
|
21
23
|
if (params.query.trim() === "*") {
|
|
22
24
|
const rows = listConversations(params.limit);
|