@vellumai/assistant 0.5.7 → 0.5.8

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 (197) hide show
  1. package/Dockerfile +2 -1
  2. package/docker-entrypoint.sh +9 -0
  3. package/docs/architecture/memory.md +13 -11
  4. package/node_modules/@vellumai/ces-contracts/src/error.ts +1 -1
  5. package/node_modules/@vellumai/ces-contracts/src/grants.ts +1 -1
  6. package/node_modules/@vellumai/ces-contracts/src/handles.ts +1 -1
  7. package/node_modules/@vellumai/ces-contracts/src/index.ts +1 -1
  8. package/node_modules/@vellumai/ces-contracts/src/rpc.ts +1 -1
  9. package/package.json +1 -1
  10. package/src/__tests__/approval-cascade.test.ts +0 -1
  11. package/src/__tests__/browser-fill-credential.test.ts +1 -1
  12. package/src/__tests__/call-controller.test.ts +0 -1
  13. package/src/__tests__/ces-rpc-credential-backend.test.ts +3 -3
  14. package/src/__tests__/ces-startup-timeout.test.ts +40 -0
  15. package/src/__tests__/config-schema-cmd.test.ts +0 -1
  16. package/src/__tests__/config-schema.test.ts +2 -0
  17. package/src/__tests__/conversation-abort-tool-results.test.ts +0 -1
  18. package/src/__tests__/conversation-agent-loop-overflow.test.ts +0 -2
  19. package/src/__tests__/conversation-agent-loop.test.ts +2 -4
  20. package/src/__tests__/conversation-confirmation-signals.test.ts +0 -1
  21. package/src/__tests__/conversation-error.test.ts +15 -1
  22. package/src/__tests__/conversation-messaging-secret-redirect.test.ts +1 -1
  23. package/src/__tests__/conversation-pre-run-repair.test.ts +0 -1
  24. package/src/__tests__/conversation-provider-retry-repair.test.ts +0 -1
  25. package/src/__tests__/conversation-queue.test.ts +0 -1
  26. package/src/__tests__/conversation-slash-queue.test.ts +0 -1
  27. package/src/__tests__/conversation-slash-unknown.test.ts +0 -1
  28. package/src/__tests__/conversation-workspace-injection.test.ts +0 -1
  29. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +0 -1
  30. package/src/__tests__/credential-execution-client.test.ts +5 -2
  31. package/src/__tests__/credential-execution-feature-gates.test.ts +31 -16
  32. package/src/__tests__/credential-execution-managed-contract.test.ts +2 -2
  33. package/src/__tests__/credential-security-e2e.test.ts +1 -1
  34. package/src/__tests__/credential-security-invariants.test.ts +2 -5
  35. package/src/__tests__/credentials-cli.test.ts +4 -3
  36. package/src/__tests__/daemon-credential-client.test.ts +123 -0
  37. package/src/__tests__/deterministic-verification-control-plane.test.ts +1 -0
  38. package/src/__tests__/gateway-client-managed-outbound.test.ts +79 -1
  39. package/src/__tests__/journal-context.test.ts +335 -0
  40. package/src/__tests__/memory-context-benchmark.benchmark.test.ts +0 -3
  41. package/src/__tests__/memory-lifecycle-e2e.test.ts +70 -25
  42. package/src/__tests__/memory-recall-quality.test.ts +48 -17
  43. package/src/__tests__/memory-regressions.test.ts +408 -363
  44. package/src/__tests__/memory-retrieval.benchmark.test.ts +0 -3
  45. package/src/__tests__/non-member-access-request.test.ts +2 -2
  46. package/src/__tests__/notification-decision-strategy.test.ts +71 -0
  47. package/src/__tests__/oauth-cli.test.ts +5 -1
  48. package/src/__tests__/provider-commit-message-generator.test.ts +0 -37
  49. package/src/__tests__/provider-error-scenarios.test.ts +0 -267
  50. package/src/__tests__/provider-streaming.benchmark.test.ts +2 -81
  51. package/src/__tests__/relay-server.test.ts +1 -2
  52. package/src/__tests__/script-proxy-injection-runtime.test.ts +1 -1
  53. package/src/__tests__/secret-onetime-send.test.ts +1 -1
  54. package/src/__tests__/secure-keys.test.ts +18 -15
  55. package/src/__tests__/skill-memory.test.ts +17 -3
  56. package/src/__tests__/stale-approval-dedup.test.ts +171 -0
  57. package/src/__tests__/stt-hints.test.ts +437 -0
  58. package/src/__tests__/task-memory-cleanup.test.ts +14 -0
  59. package/src/__tests__/twilio-routes-twiml.test.ts +139 -1
  60. package/src/__tests__/voice-quality.test.ts +58 -0
  61. package/src/__tests__/voice-scoped-grant-consumer.test.ts +0 -1
  62. package/src/__tests__/workspace-migration-016-migrate-credentials-from-keychain.test.ts +5 -3
  63. package/src/acp/agent-process.ts +9 -1
  64. package/src/agent/loop.ts +1 -1
  65. package/src/approvals/guardian-request-resolvers.ts +164 -38
  66. package/src/calls/__tests__/tts-text-sanitizer.test.ts +254 -0
  67. package/src/calls/call-controller.ts +9 -5
  68. package/src/calls/fish-audio-client.ts +26 -14
  69. package/src/calls/stt-hints.ts +189 -0
  70. package/src/calls/tts-text-sanitizer.ts +61 -0
  71. package/src/calls/twilio-routes.ts +32 -4
  72. package/src/calls/voice-quality.ts +15 -3
  73. package/src/calls/voice-session-bridge.ts +1 -0
  74. package/src/cli/commands/avatar.ts +2 -2
  75. package/src/cli/commands/credentials.ts +110 -94
  76. package/src/cli/commands/doctor.ts +2 -2
  77. package/src/cli/commands/keys.ts +7 -7
  78. package/src/cli/commands/memory.ts +1 -1
  79. package/src/cli/commands/oauth/connections.ts +11 -29
  80. package/src/cli/commands/oauth/platform.ts +389 -43
  81. package/src/cli/lib/daemon-credential-client.ts +284 -0
  82. package/src/cli.ts +1 -1
  83. package/src/config/bundled-skills/AGENTS.md +34 -0
  84. package/src/config/bundled-skills/acp/SKILL.md +10 -0
  85. package/src/config/bundled-skills/app-builder/SKILL.md +0 -4
  86. package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +2 -2
  87. package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +1 -0
  88. package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +1 -0
  89. package/src/config/bundled-skills/settings/SKILL.md +15 -2
  90. package/src/config/bundled-skills/settings/TOOLS.json +46 -1
  91. package/src/config/bundled-skills/settings/tools/avatar-remove.ts +59 -0
  92. package/src/config/bundled-skills/settings/tools/avatar-update.ts +80 -0
  93. package/src/config/bundled-skills/slack/SKILL.md +1 -1
  94. package/src/config/bundled-tool-registry.ts +4 -0
  95. package/src/config/defaults.ts +0 -2
  96. package/src/config/env-registry.ts +4 -4
  97. package/src/config/env.ts +14 -1
  98. package/src/config/feature-flag-registry.json +1 -1
  99. package/src/config/loader.ts +8 -11
  100. package/src/config/schema.ts +5 -16
  101. package/src/config/schemas/calls.ts +17 -0
  102. package/src/config/schemas/inference.ts +2 -2
  103. package/src/config/schemas/journal.ts +16 -0
  104. package/src/config/schemas/memory-processing.ts +2 -2
  105. package/src/config/types.ts +1 -0
  106. package/src/contacts/contact-store.ts +2 -2
  107. package/src/credential-execution/executable-discovery.ts +1 -1
  108. package/src/credential-execution/startup-timeout.ts +36 -0
  109. package/src/daemon/approval-generators.ts +3 -9
  110. package/src/daemon/conversation-error.ts +13 -1
  111. package/src/daemon/conversation-memory.ts +1 -2
  112. package/src/daemon/conversation-process.ts +18 -1
  113. package/src/daemon/conversation-surfaces.ts +30 -1
  114. package/src/daemon/conversation.ts +20 -9
  115. package/src/daemon/guardian-action-generators.ts +3 -9
  116. package/src/daemon/lifecycle.ts +18 -11
  117. package/src/daemon/message-types/conversations.ts +1 -0
  118. package/src/daemon/server.ts +2 -3
  119. package/src/memory/app-store.ts +31 -0
  120. package/src/memory/db-init.ts +4 -0
  121. package/src/memory/indexer.ts +19 -10
  122. package/src/memory/items-extractor.ts +315 -322
  123. package/src/memory/job-handlers/summarization.ts +26 -16
  124. package/src/memory/jobs-store.ts +33 -1
  125. package/src/memory/journal-memory.ts +214 -0
  126. package/src/memory/migrations/193-add-source-type-columns.ts +81 -0
  127. package/src/memory/migrations/index.ts +1 -0
  128. package/src/memory/migrations/registry.ts +8 -0
  129. package/src/memory/retriever.test.ts +37 -25
  130. package/src/memory/retriever.ts +24 -49
  131. package/src/memory/schema/memory-core.ts +2 -0
  132. package/src/memory/search/formatting.ts +7 -44
  133. package/src/memory/search/staleness.ts +4 -0
  134. package/src/memory/search/tier-classifier.ts +10 -2
  135. package/src/memory/search/types.ts +2 -5
  136. package/src/memory/task-memory-cleanup.ts +4 -3
  137. package/src/notifications/adapters/slack.ts +168 -6
  138. package/src/notifications/broadcaster.ts +1 -0
  139. package/src/notifications/copy-composer.ts +59 -2
  140. package/src/notifications/signal.ts +2 -0
  141. package/src/notifications/types.ts +2 -0
  142. package/src/prompts/journal-context.ts +133 -0
  143. package/src/prompts/persona-resolver.ts +80 -24
  144. package/src/prompts/system-prompt.ts +8 -0
  145. package/src/prompts/templates/SOUL.md +10 -0
  146. package/src/providers/provider-send-message.ts +3 -32
  147. package/src/providers/registry.ts +2 -139
  148. package/src/providers/types.ts +1 -1
  149. package/src/runtime/access-request-helper.ts +4 -0
  150. package/src/runtime/auth/__tests__/guard-tests.test.ts +9 -50
  151. package/src/runtime/auth/route-policy.ts +2 -0
  152. package/src/runtime/gateway-client.ts +47 -4
  153. package/src/runtime/guardian-decision-types.ts +45 -4
  154. package/src/runtime/http-server.ts +5 -2
  155. package/src/runtime/routes/access-request-decision.ts +2 -2
  156. package/src/runtime/routes/app-management-routes.ts +2 -1
  157. package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +219 -30
  158. package/src/runtime/routes/approval-strategies/guardian-text-engine-strategy.ts +37 -14
  159. package/src/runtime/routes/channel-readiness-routes.ts +9 -4
  160. package/src/runtime/routes/debug-routes.ts +12 -9
  161. package/src/runtime/routes/guardian-approval-interception.ts +168 -11
  162. package/src/runtime/routes/guardian-approval-prompt.ts +6 -1
  163. package/src/runtime/routes/guardian-approval-reply-helpers.ts +103 -21
  164. package/src/runtime/routes/identity-routes.ts +1 -1
  165. package/src/runtime/routes/inbound-message-handler.ts +31 -1
  166. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +64 -5
  167. package/src/runtime/routes/inbound-stages/background-dispatch.ts +52 -40
  168. package/src/runtime/routes/integrations/twilio.ts +52 -10
  169. package/src/runtime/routes/memory-item-routes.test.ts +3 -3
  170. package/src/runtime/routes/memory-item-routes.ts +25 -11
  171. package/src/runtime/routes/secret-routes.ts +141 -10
  172. package/src/runtime/routes/tts-routes.ts +11 -1
  173. package/src/security/ces-credential-client.ts +18 -9
  174. package/src/security/ces-rpc-credential-backend.ts +4 -3
  175. package/src/security/credential-backend.ts +10 -4
  176. package/src/security/secure-keys.ts +21 -4
  177. package/src/skills/catalog-install.ts +4 -36
  178. package/src/skills/skill-memory.ts +1 -0
  179. package/src/subagent/manager.ts +2 -5
  180. package/src/tools/acp/spawn.ts +78 -1
  181. package/src/tools/credentials/vault.ts +5 -3
  182. package/src/tools/memory/definitions.ts +3 -2
  183. package/src/tools/memory/handlers.ts +10 -7
  184. package/src/tools/terminal/safe-env.ts +1 -0
  185. package/src/util/browser.ts +15 -0
  186. package/src/util/platform.ts +1 -1
  187. package/src/workspace/migrations/016-migrate-credentials-from-keychain.ts +4 -4
  188. package/src/workspace/migrations/017-seed-persona-dirs.ts +2 -1
  189. package/src/workspace/migrations/018-rekey-compound-credential-keys.ts +184 -0
  190. package/src/workspace/migrations/019-scope-journal-to-guardian.ts +103 -0
  191. package/src/workspace/migrations/migrate-to-workspace-volume.ts +4 -4
  192. package/src/workspace/migrations/registry.ts +4 -0
  193. package/src/workspace/provider-commit-message-generator.ts +12 -21
  194. package/src/__tests__/provider-fail-open-selection.test.ts +0 -271
  195. package/src/__tests__/provider-failover-actual-provider.test.ts +0 -66
  196. package/src/memory/search/lexical.ts +0 -48
  197. package/src/providers/failover.ts +0 -186
@@ -50,12 +50,17 @@ mock.module("../memory/embedding-local.js", () => ({
50
50
  },
51
51
  }));
52
52
 
53
- // Mock Qdrant client so semantic search returns empty results instead of
54
- // throwing "Qdrant client not initialized".
53
+ // Dynamic Qdrant mock: tests can push results to be returned by hybridSearch
54
+ let mockQdrantResults: Array<{
55
+ id: string;
56
+ score: number;
57
+ payload: Record<string, unknown>;
58
+ }> = [];
59
+
55
60
  mock.module("../memory/qdrant-client.js", () => ({
56
61
  getQdrantClient: () => ({
57
- searchWithFilter: async () => [],
58
- hybridSearch: async () => [],
62
+ searchWithFilter: async () => mockQdrantResults,
63
+ hybridSearch: async () => mockQdrantResults,
59
64
  upsertPoints: async () => {},
60
65
  deletePoints: async () => {},
61
66
  }),
@@ -67,8 +72,7 @@ import { and, eq } from "drizzle-orm";
67
72
  import { DEFAULT_CONFIG } from "../config/defaults.js";
68
73
  import { vectorToBlob } from "../memory/job-utils.js";
69
74
 
70
- // Disable LLM extraction and summarization in tests to avoid real API calls
71
- // and ensure deterministic pattern-based extraction / fallback summaries.
75
+ // Disable LLM extraction and summarization in tests to avoid real API calls.
72
76
  const TEST_CONFIG = {
73
77
  ...DEFAULT_CONFIG,
74
78
  memory: {
@@ -109,7 +113,6 @@ import {
109
113
  getRecentSegmentsForConversation,
110
114
  indexMessageNow,
111
115
  } from "../memory/indexer.js";
112
- import { extractAndUpsertMemoryItemsForMessage } from "../memory/items-extractor.js";
113
116
  import { backfillJob } from "../memory/job-handlers/backfill.js";
114
117
  import { buildConversationSummaryJob } from "../memory/job-handlers/summarization.js";
115
118
  import { claimMemoryJobs, enqueueMemoryJob } from "../memory/jobs-store.js";
@@ -131,6 +134,7 @@ import {
131
134
  conversations,
132
135
  memoryEmbeddings,
133
136
  memoryItems,
137
+ memoryItemSources,
134
138
  memoryJobs,
135
139
  memorySegments,
136
140
  memorySummaries,
@@ -156,6 +160,7 @@ describe("Memory regressions", () => {
156
160
  db.run("DELETE FROM conversations");
157
161
  db.run("DELETE FROM memory_jobs");
158
162
  db.run("DELETE FROM memory_checkpoints");
163
+ mockQdrantResults = [];
159
164
  resetCleanupScheduleThrottle();
160
165
  resetStaleSweepThrottle();
161
166
  });
@@ -306,10 +311,6 @@ describe("Memory regressions", () => {
306
311
  const recall = await buildMemoryRecall("timezone", "conv-exclude", config, {
307
312
  excludeMessageIds: ["msg-current"],
308
313
  });
309
- // Recency candidates don't pass tier classification (score < 0.6) with
310
- // Qdrant mocked, so injectedText is empty. Verify recency search ran
311
- // and excluded the current message correctly.
312
- expect(recall.recencyHits).toBeGreaterThan(0);
313
314
  expect(recall.enabled).toBe(true);
314
315
  });
315
316
 
@@ -477,65 +478,49 @@ describe("Memory regressions", () => {
477
478
  }
478
479
  });
479
480
 
480
- test("memory item lastSeenAt follows message.createdAt and does not move backwards", async () => {
481
- const db = getDb();
482
- db.insert(conversations)
483
- .values({
484
- id: "conv-2",
485
- title: null,
486
- createdAt: 1_000,
487
- updatedAt: 1_000,
488
- totalInputTokens: 0,
489
- totalOutputTokens: 0,
490
- totalEstimatedCost: 0,
491
- contextSummary: null,
492
- contextCompactedMessageCount: 0,
493
- contextCompactedAt: null,
494
- })
495
- .run();
496
-
497
- db.insert(messages)
498
- .values({
499
- id: "msg-newer",
500
- conversationId: "conv-2",
501
- role: "user",
502
- content: JSON.stringify([
503
- {
504
- type: "text",
505
- text: "We decided to use sqlite for local persistence because reliability matters.",
506
- },
507
- ]),
508
- createdAt: 1_000,
509
- })
510
- .run();
511
- db.insert(messages)
512
- .values({
513
- id: "msg-older",
514
- conversationId: "conv-2",
515
- role: "user",
516
- content: JSON.stringify([
517
- {
518
- type: "text",
519
- text: "We decided to use sqlite for local persistence because reliability matters.",
520
- },
521
- ]),
522
- createdAt: 500,
523
- })
524
- .run();
481
+ test("memory item lastSeenAt does not move backwards on duplicate save", async () => {
482
+ const { handleMemorySave } = await import("../tools/memory/handlers.js");
525
483
 
526
- await extractAndUpsertMemoryItemsForMessage("msg-newer");
527
- await extractAndUpsertMemoryItemsForMessage("msg-older");
484
+ // First save creates the item
485
+ const r1 = await handleMemorySave(
486
+ {
487
+ statement: "We decided to use sqlite for local persistence",
488
+ kind: "decision",
489
+ },
490
+ DEFAULT_CONFIG,
491
+ "conv-lastseen-1",
492
+ "msg-lastseen-1",
493
+ );
494
+ expect(r1.isError).toBe(false);
528
495
 
529
- const row = db
496
+ const db = getDb();
497
+ const firstSave = db
530
498
  .select()
531
499
  .from(memoryItems)
532
- .where(
533
- and(eq(memoryItems.kind, "decision"), eq(memoryItems.status, "active")),
534
- )
500
+ .where(eq(memoryItems.kind, "decision"))
535
501
  .get();
502
+ expect(firstSave).not.toBeNull();
503
+ const firstLastSeenAt = firstSave!.lastSeenAt;
504
+ expect(firstLastSeenAt).toBeGreaterThan(0);
505
+
506
+ // Second save of the same statement should update lastSeenAt monotonically
507
+ const r2 = await handleMemorySave(
508
+ {
509
+ statement: "We decided to use sqlite for local persistence",
510
+ kind: "decision",
511
+ },
512
+ DEFAULT_CONFIG,
513
+ "conv-lastseen-2",
514
+ "msg-lastseen-2",
515
+ );
516
+ expect(r2.isError).toBe(false);
536
517
 
537
- expect(row).not.toBeNull();
538
- expect(row?.lastSeenAt).toBe(1_000);
518
+ const secondSave = db
519
+ .select()
520
+ .from(memoryItems)
521
+ .where(eq(memoryItems.kind, "decision"))
522
+ .get();
523
+ expect(secondSave!.lastSeenAt).toBeGreaterThanOrEqual(firstLastSeenAt);
539
524
  });
540
525
 
541
526
  test("memory_save sets verificationState to user_confirmed", async () => {
@@ -751,91 +736,70 @@ describe("Memory regressions", () => {
751
736
  expect(item!.statement).toBe("Private scope secret preference");
752
737
  });
753
738
 
754
- test("extracted items from user messages get user_reported verification state", async () => {
739
+ test("sourceMessageRole=user items default to user_reported verificationState", () => {
755
740
  const db = getDb();
756
741
  const now = Date.now();
757
- db.insert(conversations)
758
- .values({
759
- id: "conv-verify-extract",
760
- title: null,
761
- createdAt: now,
762
- updatedAt: now,
763
- totalInputTokens: 0,
764
- totalOutputTokens: 0,
765
- totalEstimatedCost: 0,
766
- contextSummary: null,
767
- contextCompactedMessageCount: 0,
768
- contextCompactedAt: null,
769
- })
770
- .run();
771
- db.insert(messages)
742
+
743
+ db.insert(memoryItems)
772
744
  .values({
773
- id: "msg-verify-user",
774
- conversationId: "conv-verify-extract",
775
- role: "user",
776
- content: JSON.stringify([
777
- {
778
- type: "text",
779
- text: "I prefer dark mode for all my editors and terminals.",
780
- },
781
- ]),
782
- createdAt: now,
745
+ id: "item-src-user",
746
+ kind: "preference",
747
+ subject: "editor theme",
748
+ statement: "I prefer dark mode for all my editors",
749
+ status: "active",
750
+ confidence: 0.8,
751
+ importance: 0.7,
752
+ fingerprint: "fp-src-user",
753
+ sourceType: "extraction",
754
+ sourceMessageRole: "user",
755
+ verificationState: "user_reported",
756
+ firstSeenAt: now,
757
+ lastSeenAt: now,
783
758
  })
784
759
  .run();
785
760
 
786
- const upserted =
787
- await extractAndUpsertMemoryItemsForMessage("msg-verify-user");
788
- expect(upserted).toBeGreaterThan(0);
789
-
790
- const items = db.select().from(memoryItems).all();
791
- const userItems = items.filter(
792
- (i) => i.verificationState === "user_reported",
793
- );
794
- expect(userItems.length).toBeGreaterThan(0);
761
+ const item = db
762
+ .select()
763
+ .from(memoryItems)
764
+ .where(eq(memoryItems.id, "item-src-user"))
765
+ .get();
766
+ expect(item).toBeDefined();
767
+ expect(item!.sourceType).toBe("extraction");
768
+ expect(item!.sourceMessageRole).toBe("user");
769
+ expect(item!.verificationState).toBe("user_reported");
795
770
  });
796
771
 
797
- test("extracted items from assistant messages get assistant_inferred verification state", async () => {
772
+ test("sourceMessageRole=assistant items default to assistant_inferred verificationState", () => {
798
773
  const db = getDb();
799
774
  const now = Date.now();
800
- db.insert(conversations)
801
- .values({
802
- id: "conv-verify-assistant",
803
- title: null,
804
- createdAt: now,
805
- updatedAt: now,
806
- totalInputTokens: 0,
807
- totalOutputTokens: 0,
808
- totalEstimatedCost: 0,
809
- contextSummary: null,
810
- contextCompactedMessageCount: 0,
811
- contextCompactedAt: null,
812
- })
813
- .run();
814
- db.insert(messages)
775
+
776
+ db.insert(memoryItems)
815
777
  .values({
816
- id: "msg-verify-assistant",
817
- conversationId: "conv-verify-assistant",
818
- role: "assistant",
819
- content: JSON.stringify([
820
- {
821
- type: "text",
822
- text: "I noted that you prefer using TypeScript for all your projects.",
823
- },
824
- ]),
825
- createdAt: now,
778
+ id: "item-src-assistant",
779
+ kind: "preference",
780
+ subject: "language preference",
781
+ statement: "User prefers TypeScript for all projects",
782
+ status: "active",
783
+ confidence: 0.6,
784
+ importance: 0.5,
785
+ fingerprint: "fp-src-assistant",
786
+ sourceType: "extraction",
787
+ sourceMessageRole: "assistant",
788
+ verificationState: "assistant_inferred",
789
+ firstSeenAt: now,
790
+ lastSeenAt: now,
826
791
  })
827
792
  .run();
828
793
 
829
- const upserted = await extractAndUpsertMemoryItemsForMessage(
830
- "msg-verify-assistant",
831
- );
832
- expect(upserted).toBeGreaterThan(0);
833
-
834
- const items = db.select().from(memoryItems).all();
835
- const assistantItems = items.filter(
836
- (i) => i.verificationState === "assistant_inferred",
837
- );
838
- expect(assistantItems.length).toBeGreaterThan(0);
794
+ const item = db
795
+ .select()
796
+ .from(memoryItems)
797
+ .where(eq(memoryItems.id, "item-src-assistant"))
798
+ .get();
799
+ expect(item).toBeDefined();
800
+ expect(item!.sourceType).toBe("extraction");
801
+ expect(item!.sourceMessageRole).toBe("assistant");
802
+ expect(item!.verificationState).toBe("assistant_inferred");
839
803
  });
840
804
 
841
805
  test("verification state defaults to assistant_inferred for legacy rows", () => {
@@ -1729,11 +1693,6 @@ describe("Memory regressions", () => {
1729
1693
 
1730
1694
  // With Qdrant mocked, only recency search runs. Recency candidates
1731
1695
  // don't pass tier classification (score < 0.6), so topCandidates is empty.
1732
- // Verify scope filtering works by checking recencyHits count: should
1733
- // only find segments from project-a scope (via allow_global_fallback,
1734
- // default scope is also included).
1735
- // The 2 segments in project-a scope + default-scope segments = recencyHits
1736
- expect(result.recencyHits).toBeGreaterThan(0);
1737
1696
  expect(result.enabled).toBe(true);
1738
1697
  });
1739
1698
 
@@ -1794,10 +1753,8 @@ describe("Memory regressions", () => {
1794
1753
  { scopeId: "my-project" },
1795
1754
  );
1796
1755
 
1797
- // With allow_global_fallback, recency search finds segments from both
1798
- // "my-project" and "default" scopes. Candidates don't pass tier
1799
- // classification but recencyHits should include both.
1800
- expect(result.recencyHits).toBe(2);
1756
+ // With allow_global_fallback, semantic search includes both scopes.
1757
+ expect(result.enabled).toBe(true);
1801
1758
  });
1802
1759
 
1803
1760
  test("scope filtering: strict policy excludes default scope", async () => {
@@ -1841,6 +1798,34 @@ describe("Memory regressions", () => {
1841
1798
  VALUES ('seg-strict-custom', 'msg-scope-strict', '${convId}', 'user', 1, 'Project-specific memory about database optimization techniques', 8, 'strict-project', ${now}, ${now})
1842
1799
  `);
1843
1800
 
1801
+ // Mock Qdrant to return both segments as semantic hits
1802
+ mockQdrantResults = [
1803
+ {
1804
+ id: "emb-strict-default",
1805
+ score: 0.9,
1806
+ payload: {
1807
+ target_type: "segment",
1808
+ target_id: "seg-strict-default",
1809
+ text: "Global memory about database optimization techniques",
1810
+ conversation_id: convId,
1811
+ message_id: "msg-scope-strict",
1812
+ created_at: now,
1813
+ },
1814
+ },
1815
+ {
1816
+ id: "emb-strict-custom",
1817
+ score: 0.85,
1818
+ payload: {
1819
+ target_type: "segment",
1820
+ target_id: "seg-strict-custom",
1821
+ text: "Project-specific memory about database optimization techniques",
1822
+ conversation_id: convId,
1823
+ message_id: "msg-scope-strict",
1824
+ created_at: now,
1825
+ },
1826
+ },
1827
+ ];
1828
+
1844
1829
  // With strict policy, querying with scopeId should only include that scope
1845
1830
  const strictConfig = {
1846
1831
  ...TEST_CONFIG,
@@ -1863,7 +1848,6 @@ describe("Memory regressions", () => {
1863
1848
 
1864
1849
  // With strict policy, only "strict-project" scope segments should be found.
1865
1850
  // The default scope segment should be excluded.
1866
- expect(result.recencyHits).toBe(1);
1867
1851
  // Assert the returned candidate is specifically from the strict-project scope,
1868
1852
  // not the default scope segment (privacy boundary check).
1869
1853
  expect(result.topCandidates.length).toBe(1);
@@ -1967,9 +1951,9 @@ describe("Memory regressions", () => {
1967
1951
  },
1968
1952
  );
1969
1953
 
1970
- // Override with fallbackToDefault=true should find segments from both
1954
+ // Override with fallbackToDefault=true should include both
1971
1955
  // "private-thread-42" and "default" scopes, despite strict global policy.
1972
- expect(result.recencyHits).toBe(2);
1956
+ expect(result.enabled).toBe(true);
1973
1957
  });
1974
1958
 
1975
1959
  test("scopePolicyOverride without fallback excludes default scope even when global policy is allow_global_fallback", async () => {
@@ -2041,8 +2025,7 @@ describe("Memory regressions", () => {
2041
2025
  );
2042
2026
 
2043
2027
  // Override disables fallback — only isolated scope segments found.
2044
- // Only 1 segment (isolated-scope), default scope excluded.
2045
- expect(result.recencyHits).toBe(1);
2028
+ expect(result.enabled).toBe(true);
2046
2029
  });
2047
2030
 
2048
2031
  test("scopePolicyOverride takes precedence over scopeId option", async () => {
@@ -2086,6 +2069,34 @@ describe("Memory regressions", () => {
2086
2069
  VALUES ('seg-ovr-prec-b', 'msg-override-precedence', '${convId}', 'user', 1, 'Scope B memory about distributed caching patterns', 10, 'scope-b', ${now}, ${now})
2087
2070
  `);
2088
2071
 
2072
+ // Mock Qdrant to return both segments
2073
+ mockQdrantResults = [
2074
+ {
2075
+ id: "emb-ovr-prec-a",
2076
+ score: 0.9,
2077
+ payload: {
2078
+ target_type: "segment",
2079
+ target_id: "seg-ovr-prec-a",
2080
+ text: "Scope A memory about distributed caching patterns",
2081
+ conversation_id: convId,
2082
+ message_id: "msg-override-precedence",
2083
+ created_at: now,
2084
+ },
2085
+ },
2086
+ {
2087
+ id: "emb-ovr-prec-b",
2088
+ score: 0.85,
2089
+ payload: {
2090
+ target_type: "segment",
2091
+ target_id: "seg-ovr-prec-b",
2092
+ text: "Scope B memory about distributed caching patterns",
2093
+ conversation_id: convId,
2094
+ message_id: "msg-override-precedence",
2095
+ created_at: now,
2096
+ },
2097
+ },
2098
+ ];
2099
+
2089
2100
  const config = {
2090
2101
  ...TEST_CONFIG,
2091
2102
  memory: {
@@ -2113,7 +2124,6 @@ describe("Memory regressions", () => {
2113
2124
  );
2114
2125
 
2115
2126
  // Only scope-b segment should be found (override takes precedence)
2116
- expect(result.recencyHits).toBe(1);
2117
2127
  // Verify identity of the returned candidate (scope-b, not scope-a)
2118
2128
  expect(result.injectedText).toContain("Scope B memory");
2119
2129
  expect(result.injectedText).not.toContain("Scope A memory");
@@ -2160,6 +2170,34 @@ describe("Memory regressions", () => {
2160
2170
  VALUES ('seg-ovr-dp-other', 'msg-override-default-primary', '${convId}', 'user', 1, 'Other scope memory about event driven design', 10, 'other-scope', ${now}, ${now})
2161
2171
  `);
2162
2172
 
2173
+ // Mock Qdrant to return both segments
2174
+ mockQdrantResults = [
2175
+ {
2176
+ id: "emb-ovr-dp-default",
2177
+ score: 0.9,
2178
+ payload: {
2179
+ target_type: "segment",
2180
+ target_id: "seg-ovr-dp-default",
2181
+ text: "Default scope memory about event driven design",
2182
+ conversation_id: convId,
2183
+ message_id: "msg-override-default-primary",
2184
+ created_at: now,
2185
+ },
2186
+ },
2187
+ {
2188
+ id: "emb-ovr-dp-other",
2189
+ score: 0.85,
2190
+ payload: {
2191
+ target_type: "segment",
2192
+ target_id: "seg-ovr-dp-other",
2193
+ text: "Other scope memory about event driven design",
2194
+ conversation_id: convId,
2195
+ message_id: "msg-override-default-primary",
2196
+ created_at: now,
2197
+ },
2198
+ },
2199
+ ];
2200
+
2163
2201
  const config = {
2164
2202
  ...TEST_CONFIG,
2165
2203
  memory: {
@@ -2183,7 +2221,6 @@ describe("Memory regressions", () => {
2183
2221
  );
2184
2222
 
2185
2223
  // Only default scope segment should be found (other-scope excluded)
2186
- expect(result.recencyHits).toBe(1);
2187
2224
  // Verify identity: default-scope segment returned, other-scope excluded
2188
2225
  expect(result.injectedText).toContain("Default scope memory");
2189
2226
  expect(result.injectedText).not.toContain("Other scope memory");
@@ -2295,121 +2332,78 @@ describe("Memory regressions", () => {
2295
2332
  expect(payload.scopeId).toBe("default");
2296
2333
  });
2297
2334
 
2298
- // PR-19: extractItemsJob forwards scopeId to extractAndUpsertMemoryItemsForMessage
2299
- test("extractAndUpsertMemoryItemsForMessage accepts optional scopeId without breaking", async () => {
2300
- const db = getDb();
2301
- const now = Date.now();
2302
-
2303
- db.insert(conversations)
2304
- .values({
2305
- id: "conv-scope-pass",
2306
- title: null,
2307
- createdAt: now,
2308
- updatedAt: now,
2309
- conversationType: "standard",
2310
- memoryScopeId: "default",
2311
- })
2312
- .run();
2313
- db.insert(messages)
2314
- .values({
2315
- id: "msg-scope-pass",
2316
- conversationId: "conv-scope-pass",
2317
- role: "user",
2318
- content: JSON.stringify([
2319
- {
2320
- type: "text",
2321
- text: "I prefer TypeScript over JavaScript for all new projects.",
2322
- },
2323
- ]),
2324
- createdAt: now,
2325
- })
2326
- .run();
2335
+ // PR-19: memory_save respects explicit scopeId parameter
2336
+ test("handleMemorySave places items in the requested scope", async () => {
2337
+ const { handleMemorySave } = await import("../tools/memory/handlers.js");
2327
2338
 
2328
- // Call without scopeId — optional parameter omitted
2329
- const withoutScope =
2330
- await extractAndUpsertMemoryItemsForMessage("msg-scope-pass");
2331
- expect(withoutScope).toBeGreaterThan(0);
2339
+ // Save without explicit scopeId — defaults to "default"
2340
+ const r1 = await handleMemorySave(
2341
+ {
2342
+ statement: "I prefer TypeScript over JavaScript for all new projects.",
2343
+ kind: "preference",
2344
+ },
2345
+ DEFAULT_CONFIG,
2346
+ "conv-scope-pass",
2347
+ "msg-scope-pass",
2348
+ );
2349
+ expect(r1.isError).toBe(false);
2332
2350
 
2333
- // Call with explicit scopeId — should also succeed and scope items accordingly
2334
- db.insert(messages)
2335
- .values({
2336
- id: "msg-scope-pass-2",
2337
- conversationId: "conv-scope-pass",
2338
- role: "user",
2339
- content: JSON.stringify([
2340
- {
2341
- type: "text",
2342
- text: "I dislike using var in JavaScript, prefer const and let.",
2343
- },
2344
- ]),
2345
- createdAt: now + 1,
2346
- })
2347
- .run();
2348
- const withScope = await extractAndUpsertMemoryItemsForMessage(
2351
+ // Save with explicit private scopeId
2352
+ const r2 = await handleMemorySave(
2353
+ {
2354
+ statement: "I dislike using var in JavaScript, prefer const and let.",
2355
+ kind: "preference",
2356
+ },
2357
+ DEFAULT_CONFIG,
2358
+ "conv-scope-pass-2",
2349
2359
  "msg-scope-pass-2",
2350
2360
  "private:thread-42",
2351
2361
  );
2352
- expect(withScope).toBeGreaterThan(0);
2362
+ expect(r2.isError).toBe(false);
2363
+
2364
+ const db = getDb();
2365
+ const defaultItems = db
2366
+ .select()
2367
+ .from(memoryItems)
2368
+ .where(eq(memoryItems.scopeId, "default"))
2369
+ .all();
2370
+ const privateItems = db
2371
+ .select()
2372
+ .from(memoryItems)
2373
+ .where(eq(memoryItems.scopeId, "private:thread-42"))
2374
+ .all();
2375
+
2376
+ expect(defaultItems.length).toBe(1);
2377
+ expect(privateItems.length).toBe(1);
2353
2378
  });
2354
2379
 
2355
2380
  // PR-20: same statement in different scopes produces separate active items
2356
2381
  test("same statement in different scopes produces separate active memory items", async () => {
2357
- const db = getDb();
2358
- const now = Date.now();
2382
+ const { handleMemorySave } = await import("../tools/memory/handlers.js");
2359
2383
 
2360
- db.insert(conversations)
2361
- .values({
2362
- id: "conv-scope-separate",
2363
- title: null,
2364
- createdAt: now,
2365
- updatedAt: now,
2366
- conversationType: "standard",
2367
- memoryScopeId: "default",
2368
- })
2369
- .run();
2384
+ const statement = "I prefer dark mode for all my editors and terminals.";
2370
2385
 
2371
- // Insert two messages with identical content
2372
- db.insert(messages)
2373
- .values({
2374
- id: "msg-scope-default",
2375
- conversationId: "conv-scope-separate",
2376
- role: "user",
2377
- content: JSON.stringify([
2378
- {
2379
- type: "text",
2380
- text: "I prefer dark mode for all my editors and terminals.",
2381
- },
2382
- ]),
2383
- createdAt: now,
2384
- })
2385
- .run();
2386
- db.insert(messages)
2387
- .values({
2388
- id: "msg-scope-private",
2389
- conversationId: "conv-scope-separate",
2390
- role: "user",
2391
- content: JSON.stringify([
2392
- {
2393
- type: "text",
2394
- text: "I prefer dark mode for all my editors and terminals.",
2395
- },
2396
- ]),
2397
- createdAt: now + 1,
2398
- })
2399
- .run();
2400
-
2401
- // Extract into default scope
2402
- const defaultCount =
2403
- await extractAndUpsertMemoryItemsForMessage("msg-scope-default");
2404
- expect(defaultCount).toBeGreaterThan(0);
2386
+ // Save into default scope
2387
+ const r1 = await handleMemorySave(
2388
+ { statement, kind: "preference" },
2389
+ DEFAULT_CONFIG,
2390
+ "conv-scope-separate-1",
2391
+ "msg-scope-default",
2392
+ "default",
2393
+ );
2394
+ expect(r1.isError).toBe(false);
2405
2395
 
2406
- // Extract identical statement into a private scope
2407
- const privateCount = await extractAndUpsertMemoryItemsForMessage(
2396
+ // Save identical statement into a private scope
2397
+ const r2 = await handleMemorySave(
2398
+ { statement, kind: "preference" },
2399
+ DEFAULT_CONFIG,
2400
+ "conv-scope-separate-2",
2408
2401
  "msg-scope-private",
2409
2402
  "private:thread-99",
2410
2403
  );
2411
- expect(privateCount).toBeGreaterThan(0);
2404
+ expect(r2.isError).toBe(false);
2412
2405
 
2406
+ const db = getDb();
2413
2407
  // Both scopes should have separate active items
2414
2408
  const defaultItems = db
2415
2409
  .select()
@@ -2445,46 +2439,26 @@ describe("Memory regressions", () => {
2445
2439
 
2446
2440
  // PR-21: identical fact in default vs private scopes gets distinct fingerprints
2447
2441
  test("identical content in different scopes produces distinct fingerprints", async () => {
2448
- const db = getDb();
2449
- const now = Date.now();
2450
- const statement = "I prefer using Vim keybindings in all my text editors.";
2451
-
2452
- db.insert(conversations)
2453
- .values({
2454
- id: "conv-fp-salt",
2455
- title: null,
2456
- createdAt: now,
2457
- updatedAt: now,
2458
- conversationType: "standard",
2459
- memoryScopeId: "default",
2460
- })
2461
- .run();
2442
+ const { handleMemorySave } = await import("../tools/memory/handlers.js");
2462
2443
 
2463
- db.insert(messages)
2464
- .values({
2465
- id: "msg-fp-salt-default",
2466
- conversationId: "conv-fp-salt",
2467
- role: "user",
2468
- content: JSON.stringify([{ type: "text", text: statement }]),
2469
- createdAt: now,
2470
- })
2471
- .run();
2472
- db.insert(messages)
2473
- .values({
2474
- id: "msg-fp-salt-private",
2475
- conversationId: "conv-fp-salt",
2476
- role: "user",
2477
- content: JSON.stringify([{ type: "text", text: statement }]),
2478
- createdAt: now + 1,
2479
- })
2480
- .run();
2444
+ const statement = "I prefer using Vim keybindings in all my text editors.";
2481
2445
 
2482
- await extractAndUpsertMemoryItemsForMessage("msg-fp-salt-default");
2483
- await extractAndUpsertMemoryItemsForMessage(
2446
+ await handleMemorySave(
2447
+ { statement, kind: "preference" },
2448
+ DEFAULT_CONFIG,
2449
+ "conv-fp-salt-1",
2450
+ "msg-fp-salt-default",
2451
+ "default",
2452
+ );
2453
+ await handleMemorySave(
2454
+ { statement, kind: "preference" },
2455
+ DEFAULT_CONFIG,
2456
+ "conv-fp-salt-2",
2484
2457
  "msg-fp-salt-private",
2485
2458
  "private:fp-test",
2486
2459
  );
2487
2460
 
2461
+ const db = getDb();
2488
2462
  const defaultItems = db
2489
2463
  .select()
2490
2464
  .from(memoryItems)
@@ -2510,37 +2484,22 @@ describe("Memory regressions", () => {
2510
2484
 
2511
2485
  // PR-20: default scope items are not affected by private scope operations
2512
2486
  test("default scope items are not superseded by private scope operations", async () => {
2513
- const db = getDb();
2514
- const now = Date.now();
2515
-
2516
- db.insert(conversations)
2517
- .values({
2518
- id: "conv-scope-isolate",
2519
- title: null,
2520
- createdAt: now,
2521
- updatedAt: now,
2522
- conversationType: "standard",
2523
- memoryScopeId: "default",
2524
- })
2525
- .run();
2487
+ const { handleMemorySave } = await import("../tools/memory/handlers.js");
2526
2488
 
2527
- // Insert a decision in the default scope
2528
- db.insert(messages)
2529
- .values({
2530
- id: "msg-decision-default",
2531
- conversationId: "conv-scope-isolate",
2532
- role: "user",
2533
- content: JSON.stringify([
2534
- {
2535
- type: "text",
2536
- text: "We decided to use PostgreSQL for the production database.",
2537
- },
2538
- ]),
2539
- createdAt: now,
2540
- })
2541
- .run();
2542
- await extractAndUpsertMemoryItemsForMessage("msg-decision-default");
2489
+ // Save a decision in the default scope
2490
+ const r1 = await handleMemorySave(
2491
+ {
2492
+ statement: "We decided to use PostgreSQL for the production database.",
2493
+ kind: "decision",
2494
+ },
2495
+ DEFAULT_CONFIG,
2496
+ "conv-scope-isolate-1",
2497
+ "msg-decision-default",
2498
+ "default",
2499
+ );
2500
+ expect(r1.isError).toBe(false);
2543
2501
 
2502
+ const db = getDb();
2544
2503
  const defaultBefore = db
2545
2504
  .select()
2546
2505
  .from(memoryItems)
@@ -2553,27 +2512,21 @@ describe("Memory regressions", () => {
2553
2512
  .all();
2554
2513
  expect(defaultBefore.length).toBeGreaterThan(0);
2555
2514
 
2556
- // Now insert a superseding decision in a private scope
2557
- db.insert(messages)
2558
- .values({
2559
- id: "msg-decision-private",
2560
- conversationId: "conv-scope-isolate",
2561
- role: "user",
2562
- content: JSON.stringify([
2563
- {
2564
- type: "text",
2565
- text: "We decided to use SQLite for the production database instead.",
2566
- },
2567
- ]),
2568
- createdAt: now + 1,
2569
- })
2570
- .run();
2571
- await extractAndUpsertMemoryItemsForMessage(
2515
+ // Now save a different decision in a private scope
2516
+ const r2 = await handleMemorySave(
2517
+ {
2518
+ statement:
2519
+ "We decided to use SQLite for the production database instead.",
2520
+ kind: "decision",
2521
+ },
2522
+ DEFAULT_CONFIG,
2523
+ "conv-scope-isolate-2",
2572
2524
  "msg-decision-private",
2573
2525
  "private:thread-55",
2574
2526
  );
2527
+ expect(r2.isError).toBe(false);
2575
2528
 
2576
- // The default scope items should still be active — private scope supersede must not affect them
2529
+ // The default scope items should still be active — private scope must not affect them
2577
2530
  const defaultAfter = db
2578
2531
  .select()
2579
2532
  .from(memoryItems)
@@ -2761,8 +2714,10 @@ describe("Memory regressions", () => {
2761
2714
 
2762
2715
  test("e2e: private-only facts are recalled in private conversation but not in standard conversation", async () => {
2763
2716
  const db = getDb();
2717
+ const { handleMemorySave } = await import("../tools/memory/handlers.js");
2718
+ const now = Date.now();
2764
2719
 
2765
- // 1. Create a private conversation and add a message with a distinctive fact
2720
+ // 1. Create a private conversation and save a distinctive fact
2766
2721
  const privConv = createConversation({
2767
2722
  title: "Private e2e test",
2768
2723
  conversationType: "private",
@@ -2770,18 +2725,33 @@ describe("Memory regressions", () => {
2770
2725
  const privScope = getConversationMemoryScopeId(privConv.id);
2771
2726
  expect(privScope).toMatch(/^private:/);
2772
2727
 
2773
- const privMsg = await addMessage(
2774
- privConv.id,
2775
- "user",
2776
- "I prefer using the Zephyr framework for all backend microservices.",
2777
- );
2728
+ db.insert(messages)
2729
+ .values({
2730
+ id: "msg-priv-e2e-zephyr",
2731
+ conversationId: privConv.id,
2732
+ role: "user",
2733
+ content: JSON.stringify([
2734
+ {
2735
+ type: "text",
2736
+ text: "I prefer using the Zephyr framework for all backend microservices.",
2737
+ },
2738
+ ]),
2739
+ createdAt: now,
2740
+ })
2741
+ .run();
2778
2742
 
2779
- // 2. Extract memory items — they inherit the private scope
2780
- const upserted = await extractAndUpsertMemoryItemsForMessage(
2781
- privMsg.id,
2743
+ const r1 = await handleMemorySave(
2744
+ {
2745
+ statement:
2746
+ "I prefer using the Zephyr framework for all backend microservices.",
2747
+ kind: "preference",
2748
+ },
2749
+ DEFAULT_CONFIG,
2750
+ privConv.id,
2751
+ "msg-priv-e2e-zephyr",
2782
2752
  privScope,
2783
2753
  );
2784
- expect(upserted).toBeGreaterThan(0);
2754
+ expect(r1.isError).toBe(false);
2785
2755
 
2786
2756
  // Verify items were stored with the private scope
2787
2757
  const privateItems = db
@@ -2794,10 +2764,43 @@ describe("Memory regressions", () => {
2794
2764
  privateItems.some((i) => i.statement.toLowerCase().includes("zephyr")),
2795
2765
  ).toBe(true);
2796
2766
 
2797
- // Collect the item IDs so we can check them in recall results
2767
+ // Add item source (handleMemorySave doesn't create sources; semantic search requires them)
2768
+ db.insert(memoryItemSources)
2769
+ .values({
2770
+ memoryItemId: privateItems[0].id,
2771
+ messageId: "msg-priv-e2e-zephyr",
2772
+ evidence: "Zephyr framework preference",
2773
+ createdAt: now,
2774
+ })
2775
+ .run();
2776
+
2777
+ // Mark the source message as compacted so the item isn't filtered
2778
+ // as "already in context"
2779
+ db.update(conversations)
2780
+ .set({ contextCompactedMessageCount: 1 })
2781
+ .where(eq(conversations.id, privConv.id))
2782
+ .run();
2783
+
2798
2784
  const privateItemKeys = privateItems.map((i) => `item:${i.id}`);
2799
2785
 
2800
- // 3. Create a standard conversation for the "standard conversation" perspective
2786
+ // 2. Mock Qdrant to return the private item
2787
+ mockQdrantResults = [
2788
+ {
2789
+ id: "emb-zephyr",
2790
+ score: 0.9,
2791
+ payload: {
2792
+ target_type: "item",
2793
+ target_id: privateItems[0].id,
2794
+ text: privateItems[0].statement,
2795
+ kind: "preference",
2796
+ status: "active",
2797
+ created_at: now,
2798
+ last_seen_at: now,
2799
+ },
2800
+ },
2801
+ ];
2802
+
2803
+ // 3. Create a standard conversation
2801
2804
  const stdConv = createConversation({
2802
2805
  title: "Standard e2e test",
2803
2806
  conversationType: "standard",
@@ -2813,7 +2816,7 @@ describe("Memory regressions", () => {
2813
2816
  content: JSON.stringify([
2814
2817
  { type: "text", text: "placeholder for standard conv" },
2815
2818
  ]),
2816
- createdAt: Date.now(),
2819
+ createdAt: now,
2817
2820
  })
2818
2821
  .run();
2819
2822
 
@@ -2837,13 +2840,13 @@ describe("Memory regressions", () => {
2837
2840
  },
2838
2841
  },
2839
2842
  );
2840
- // With Qdrant mocked, candidates don't pass tier classification.
2841
- // Verify the pipeline ran and recency search found segments.
2842
- expect(privRecall.recencyHits).toBeGreaterThan(0);
2843
+ expect(privRecall.enabled).toBe(true);
2844
+ const privCandidateKeys = privRecall.topCandidates.map((c) => c.key);
2845
+ expect(privCandidateKeys.some((k) => privateItemKeys.includes(k))).toBe(
2846
+ true,
2847
+ );
2843
2848
 
2844
2849
  // 5. Standard conversation recall — must NOT find the Zephyr fact (no leak)
2845
- // Mirror the production call in conversation-memory.ts: for standard conversations
2846
- // (scopeId === 'default'), scopePolicyOverride is undefined.
2847
2850
  const stdRecall = await buildMemoryRecall(
2848
2851
  "Zephyr framework microservices",
2849
2852
  stdConv.id,
@@ -2863,9 +2866,10 @@ describe("Memory regressions", () => {
2863
2866
 
2864
2867
  test("e2e: private conversation still recalls facts from default memory scope", async () => {
2865
2868
  const db = getDb();
2869
+ const { handleMemorySave } = await import("../tools/memory/handlers.js");
2866
2870
  const now = Date.now();
2867
2871
 
2868
- // 1. Create a standard conversation and add a fact to default scope
2872
+ // 1. Save a fact to default scope via a standard conversation
2869
2873
  const stdConv = createConversation({
2870
2874
  title: "Default scope source",
2871
2875
  conversationType: "standard",
@@ -2873,17 +2877,33 @@ describe("Memory regressions", () => {
2873
2877
  const stdScope = getConversationMemoryScopeId(stdConv.id);
2874
2878
  expect(stdScope).toBe("default");
2875
2879
 
2876
- const stdMsg = await addMessage(
2877
- stdConv.id,
2878
- "user",
2879
- "I prefer using the Obsidian editor for all my note-taking workflows.",
2880
- );
2880
+ db.insert(messages)
2881
+ .values({
2882
+ id: "msg-std-e2e-obsidian",
2883
+ conversationId: stdConv.id,
2884
+ role: "user",
2885
+ content: JSON.stringify([
2886
+ {
2887
+ type: "text",
2888
+ text: "I prefer using the Obsidian editor for all my note-taking workflows.",
2889
+ },
2890
+ ]),
2891
+ createdAt: now,
2892
+ })
2893
+ .run();
2881
2894
 
2882
- const upsertedDefault = await extractAndUpsertMemoryItemsForMessage(
2883
- stdMsg.id,
2884
- stdScope,
2895
+ const r1 = await handleMemorySave(
2896
+ {
2897
+ statement:
2898
+ "I prefer using the Obsidian editor for all my note-taking workflows.",
2899
+ kind: "preference",
2900
+ },
2901
+ DEFAULT_CONFIG,
2902
+ stdConv.id,
2903
+ "msg-std-e2e-obsidian",
2904
+ "default",
2885
2905
  );
2886
- expect(upsertedDefault).toBeGreaterThan(0);
2906
+ expect(r1.isError).toBe(false);
2887
2907
 
2888
2908
  // Verify items landed in the default scope
2889
2909
  const defaultItems = db
@@ -2896,10 +2916,20 @@ describe("Memory regressions", () => {
2896
2916
  ),
2897
2917
  )
2898
2918
  .all();
2899
- const hasObsidian = defaultItems.some((i) =>
2919
+ const obsidianItem = defaultItems.find((i) =>
2900
2920
  i.statement.toLowerCase().includes("obsidian"),
2901
2921
  );
2902
- expect(hasObsidian).toBe(true);
2922
+ expect(obsidianItem).toBeDefined();
2923
+
2924
+ // Add item source (handleMemorySave doesn't create sources; semantic search requires them)
2925
+ db.insert(memoryItemSources)
2926
+ .values({
2927
+ memoryItemId: obsidianItem!.id,
2928
+ messageId: "msg-std-e2e-obsidian",
2929
+ evidence: "Obsidian editor preference",
2930
+ createdAt: now,
2931
+ })
2932
+ .run();
2903
2933
 
2904
2934
  // 2. Create a private conversation
2905
2935
  const privConv = createConversation({
@@ -2921,6 +2951,23 @@ describe("Memory regressions", () => {
2921
2951
  })
2922
2952
  .run();
2923
2953
 
2954
+ // Mock Qdrant to return the default-scope Obsidian item
2955
+ mockQdrantResults = [
2956
+ {
2957
+ id: "emb-obsidian",
2958
+ score: 0.9,
2959
+ payload: {
2960
+ target_type: "item",
2961
+ target_id: obsidianItem!.id,
2962
+ text: obsidianItem!.statement,
2963
+ kind: "preference",
2964
+ status: "active",
2965
+ created_at: now,
2966
+ last_seen_at: now,
2967
+ },
2968
+ },
2969
+ ];
2970
+
2924
2971
  const recallConfig = {
2925
2972
  ...TEST_CONFIG,
2926
2973
  memory: {
@@ -2941,10 +2988,8 @@ describe("Memory regressions", () => {
2941
2988
  },
2942
2989
  },
2943
2990
  );
2944
- // Without semantic search, items from a different conversation are
2945
- // unreachable (recency search is conversation-scoped). Verify recall
2946
- // completes without error.
2947
2991
  expect(privRecall).toBeDefined();
2992
+ expect(privRecall.injectedText.toLowerCase()).toContain("obsidian");
2948
2993
  });
2949
2994
 
2950
2995
  // Backfill preserves private conversation scope on memory segments