@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.
Files changed (52) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/context-search-conversations-source.test.ts +281 -1
  3. package/src/__tests__/plugin-import-boundary-guard.test.ts +3 -0
  4. package/src/daemon/conversation.ts +15 -1
  5. package/src/daemon/handlers/conversation-history.ts +3 -1
  6. package/src/persistence/__tests__/conversation-queries-search.test.ts +362 -0
  7. package/src/persistence/__tests__/slow-sync-log.test.ts +65 -0
  8. package/src/persistence/conversation-crud.ts +16 -7
  9. package/src/persistence/conversation-queries.ts +271 -73
  10. package/src/persistence/raw-query.ts +42 -15
  11. package/src/persistence/slow-sync-log.ts +106 -0
  12. package/src/plugins/defaults/memory/__tests__/conversation-queries.test.ts +15 -15
  13. package/src/plugins/defaults/memory/config.ts +11 -0
  14. package/src/plugins/defaults/memory/context-search/sources/conversations.ts +183 -12
  15. package/src/plugins/defaults/memory/embeddings.ts +77 -0
  16. package/src/plugins/defaults/memory/graph/bootstrap.ts +3 -5
  17. package/src/plugins/defaults/memory/graph/extraction.ts +1 -1
  18. package/src/plugins/defaults/memory/graph/graph-search.ts +12 -23
  19. package/src/plugins/defaults/memory/graph/retriever.ts +3 -5
  20. package/src/plugins/defaults/memory/hooks/init.ts +2 -2
  21. package/src/plugins/defaults/memory/indexer.ts +2 -3
  22. package/src/plugins/defaults/memory/injectors.ts +2 -2
  23. package/src/plugins/defaults/memory/job-handlers/embedding.test.ts +6 -19
  24. package/src/plugins/defaults/memory/job-handlers/embedding.ts +9 -22
  25. package/src/plugins/defaults/memory/job-handlers/index-maintenance.ts +2 -3
  26. package/src/plugins/defaults/memory/job-handlers/index-message-lexical.ts +11 -6
  27. package/src/plugins/defaults/memory/job-handlers.ts +5 -5
  28. package/src/plugins/defaults/memory/jobs/embed-concept-page.ts +1 -1
  29. package/src/plugins/defaults/memory/memory-retrospective-startup-cleanup.ts +2 -2
  30. package/src/plugins/defaults/memory/persistence-hooks.ts +2 -1
  31. package/src/plugins/defaults/memory/pkb/pkb-index.ts +1 -5
  32. package/src/plugins/defaults/memory/pkb/pkb-search.ts +2 -2
  33. package/src/plugins/defaults/memory/routes/memory-item-routes.ts +1 -1
  34. package/src/plugins/defaults/memory/search/semantic.ts +2 -2
  35. package/src/plugins/defaults/memory/startup.ts +3 -5
  36. package/src/plugins/defaults/memory/v2/activation.ts +2 -4
  37. package/src/plugins/defaults/memory/v2/cli-command-store.ts +2 -4
  38. package/src/plugins/defaults/memory/v2/qdrant.ts +2 -3
  39. package/src/plugins/defaults/memory/v2/sim.ts +2 -4
  40. package/src/plugins/defaults/memory/v2/skill-store.ts +2 -4
  41. package/src/plugins/defaults/memory/v3/dense.ts +7 -12
  42. package/src/plugins/defaults/memory/v3/maintain-job.ts +2 -4
  43. package/src/plugins/defaults/memory/v3/prune.ts +2 -2
  44. package/src/plugins/defaults/memory/v3/section-dense-store.ts +8 -11
  45. package/src/runtime/AGENTS.md +5 -2
  46. package/src/runtime/http-server.ts +12 -4
  47. package/src/runtime/middleware/__tests__/rate-limiter.test.ts +22 -0
  48. package/src/runtime/middleware/rate-limiter.ts +30 -0
  49. package/src/runtime/routes/conversation-query-routes.ts +4 -2
  50. package/src/runtime/routes/conversation-routes.ts +3 -3
  51. package/src/runtime/routes/global-search-routes.ts +1 -1
  52. package/src/tools/terminal/safe-env.ts +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.10.4-dev.202607010319.f2b69c7",
3
+ "version": "0.10.4-dev.202607010743.70cd9f0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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
- { conversationId: this.conversationId, count: this.messages.length },
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(params: ConversationSearchParams) {
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);