@vellumai/assistant 0.5.15 → 0.6.0

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 (503) hide show
  1. package/ARCHITECTURE.md +3 -3
  2. package/Dockerfile +0 -3
  3. package/docs/architecture/integrations.md +15 -14
  4. package/knip.json +4 -1
  5. package/openapi.yaml +670 -122
  6. package/package.json +1 -1
  7. package/src/__tests__/actor-token-service.test.ts +68 -0
  8. package/src/__tests__/agent-loop.test.ts +0 -32
  9. package/src/__tests__/always-loaded-tools-guard.test.ts +2 -2
  10. package/src/__tests__/anthropic-provider.test.ts +57 -3
  11. package/src/__tests__/app-compiler.test.ts +120 -0
  12. package/src/__tests__/assistant-feature-flags-integration.test.ts +5 -377
  13. package/src/__tests__/call-conversation-messages.test.ts +2 -6
  14. package/src/__tests__/call-domain.test.ts +2 -6
  15. package/src/__tests__/call-pointer-messages.test.ts +2 -14
  16. package/src/__tests__/call-recovery.test.ts +2 -6
  17. package/src/__tests__/call-routes-http.test.ts +2 -6
  18. package/src/__tests__/call-store.test.ts +2 -6
  19. package/src/__tests__/cancel-resolves-conversation-key.test.ts +2 -6
  20. package/src/__tests__/canonical-guardian-store.test.ts +2 -6
  21. package/src/__tests__/ces-rpc-credential-backend.test.ts +4 -1
  22. package/src/__tests__/channel-delivery-store.test.ts +2 -6
  23. package/src/__tests__/channel-retry-sweep.test.ts +2 -6
  24. package/src/__tests__/checker.test.ts +84 -3
  25. package/src/__tests__/clawhub.test.ts +54 -24
  26. package/src/__tests__/cli-command-risk-guard.test.ts +108 -6
  27. package/src/__tests__/cli-memory.test.ts +377 -0
  28. package/src/__tests__/computer-use-skill-manifest-regression.test.ts +12 -2
  29. package/src/__tests__/config-schema.test.ts +1 -3
  30. package/src/__tests__/config-set-platform-guard.test.ts +302 -0
  31. package/src/__tests__/config-watcher-feature-flags.test.ts +211 -0
  32. package/src/__tests__/confirmation-request-guardian-bridge.test.ts +2 -6
  33. package/src/__tests__/contacts-tools.test.ts +31 -0
  34. package/src/__tests__/context-overflow-reducer.test.ts +86 -0
  35. package/src/__tests__/context-token-estimator.test.ts +175 -10
  36. package/src/__tests__/conversation-agent-loop-overflow.test.ts +9 -0
  37. package/src/__tests__/conversation-agent-loop.test.ts +9 -0
  38. package/src/__tests__/conversation-attachments.test.ts +2 -6
  39. package/src/__tests__/conversation-attention-store.test.ts +2 -6
  40. package/src/__tests__/conversation-clear-safety.test.ts +2 -6
  41. package/src/__tests__/conversation-delete-schedule-cleanup.test.ts +4 -10
  42. package/src/__tests__/conversation-disk-view-integration.test.ts +2 -6
  43. package/src/__tests__/conversation-disk-view.test.ts +2 -6
  44. package/src/__tests__/conversation-error.test.ts +33 -2
  45. package/src/__tests__/conversation-fork-crud.test.ts +2 -6
  46. package/src/__tests__/conversation-history-web-search.test.ts +5 -0
  47. package/src/__tests__/conversation-load-history-repair.test.ts +5 -1
  48. package/src/__tests__/conversation-media-retry.test.ts +91 -0
  49. package/src/__tests__/conversation-runtime-assembly.test.ts +7 -4
  50. package/src/__tests__/conversation-slash-commands.test.ts +2 -6
  51. package/src/__tests__/conversation-starter-routes.test.ts +20 -11
  52. package/src/__tests__/conversation-store.test.ts +2 -6
  53. package/src/__tests__/conversation-usage.test.ts +3 -6
  54. package/src/__tests__/conversation-wipe.test.ts +11 -408
  55. package/src/__tests__/credential-execution-feature-gates.test.ts +3 -3
  56. package/src/__tests__/credential-execution-shell-lockdown.test.ts +2 -2
  57. package/src/__tests__/credential-security-e2e.test.ts +6 -1
  58. package/src/__tests__/docker-signing-key-bootstrap.test.ts +7 -73
  59. package/src/__tests__/dynamic-skill-workflow-prompt.test.ts +6 -7
  60. package/src/__tests__/followup-tools.test.ts +2 -6
  61. package/src/__tests__/graph-extraction-event-date.test.ts +186 -0
  62. package/src/__tests__/guardian-action-conversation-turn.test.ts +2 -6
  63. package/src/__tests__/guardian-action-followup-executor.test.ts +2 -6
  64. package/src/__tests__/guardian-action-followup-store.test.ts +2 -6
  65. package/src/__tests__/guardian-action-grant-mint-consume.test.ts +2 -6
  66. package/src/__tests__/guardian-action-late-reply.test.ts +2 -6
  67. package/src/__tests__/guardian-action-store.test.ts +2 -6
  68. package/src/__tests__/guardian-binding-drift-heal.test.ts +2 -6
  69. package/src/__tests__/guardian-decision-primitive-canonical.test.ts +8 -8
  70. package/src/__tests__/guardian-dispatch.test.ts +2 -6
  71. package/src/__tests__/guardian-grant-minting.test.ts +2 -14
  72. package/src/__tests__/guardian-principal-id-roundtrip.test.ts +2 -6
  73. package/src/__tests__/guardian-routing-invariants.test.ts +343 -6
  74. package/src/__tests__/guardian-routing-state.test.ts +2 -6
  75. package/src/__tests__/guardian-verification-voice-binding.test.ts +2 -6
  76. package/src/__tests__/heartbeat-service.test.ts +1 -3
  77. package/src/__tests__/inbound-invite-redemption.test.ts +2 -6
  78. package/src/__tests__/injection-block.test.ts +154 -0
  79. package/src/__tests__/install-meta.test.ts +506 -0
  80. package/src/__tests__/install-skill-routing.test.ts +292 -0
  81. package/src/__tests__/intent-routing.test.ts +6 -18
  82. package/src/__tests__/invite-redemption-service.test.ts +2 -6
  83. package/src/__tests__/invite-routes-http.test.ts +2 -6
  84. package/src/__tests__/jobs-store-qdrant-breaker.test.ts +2 -14
  85. package/src/__tests__/list-messages-attachments.test.ts +2 -6
  86. package/src/__tests__/llm-context-route-provider.test.ts +2 -6
  87. package/src/__tests__/llm-request-log-turn-query.test.ts +2 -6
  88. package/src/__tests__/llm-usage-store.test.ts +2 -6
  89. package/src/__tests__/log-export-workspace.test.ts +4 -34
  90. package/src/__tests__/managed-skill-lifecycle.test.ts +7 -37
  91. package/src/__tests__/managed-store.test.ts +40 -21
  92. package/src/__tests__/memory-jobs-worker-backoff.test.ts +2 -8
  93. package/src/__tests__/memory-recall-log-store.test.ts +2 -6
  94. package/src/__tests__/memory-upsert-concurrency.test.ts +4 -112
  95. package/src/__tests__/messaging-send-tool.test.ts +6 -6
  96. package/src/__tests__/migration-cross-version-compatibility.test.ts +1 -29
  97. package/src/__tests__/migration-export-http.test.ts +3 -34
  98. package/src/__tests__/migration-import-commit-http.test.ts +1 -29
  99. package/src/__tests__/migration-import-preflight-http.test.ts +3 -34
  100. package/src/__tests__/no-domain-routing-in-prompt-guard.test.ts +2 -1
  101. package/src/__tests__/non-member-access-request.test.ts +2 -6
  102. package/src/__tests__/notification-guardian-path.test.ts +2 -6
  103. package/src/__tests__/oauth-apps-routes.test.ts +120 -10
  104. package/src/__tests__/oauth-cli.test.ts +364 -2
  105. package/src/__tests__/oauth-connect-orchestrator.test.ts +709 -0
  106. package/src/__tests__/oauth-provider-serializer.test.ts +2 -1
  107. package/src/__tests__/oauth-provider-visibility.test.ts +149 -0
  108. package/src/__tests__/oauth-providers-routes.test.ts +5 -2
  109. package/src/__tests__/oauth-store.test.ts +0 -5
  110. package/src/__tests__/oauth2-gateway-transport.test.ts +18 -3
  111. package/src/__tests__/outlook-attachments.test.ts +301 -0
  112. package/src/__tests__/outlook-automation-tools.test.ts +425 -0
  113. package/src/__tests__/outlook-categories.test.ts +212 -0
  114. package/src/__tests__/outlook-client-automation.test.ts +246 -0
  115. package/src/__tests__/outlook-compose-tools.test.ts +325 -0
  116. package/src/__tests__/outlook-declutter-tools.test.ts +585 -0
  117. package/src/__tests__/outlook-email-watcher.test.ts +322 -0
  118. package/src/__tests__/outlook-follow-up.test.ts +196 -0
  119. package/src/__tests__/outlook-messaging-provider.test.ts +1071 -0
  120. package/src/__tests__/outlook-trash.test.ts +77 -0
  121. package/src/__tests__/outlook-unsubscribe.test.ts +250 -0
  122. package/src/__tests__/path-policy.test.ts +2 -17
  123. package/src/__tests__/permission-types.test.ts +0 -1
  124. package/src/__tests__/platform-callback-registration.test.ts +7 -11
  125. package/src/__tests__/playbook-execution.test.ts +76 -80
  126. package/src/__tests__/playbook-tools.test.ts +5 -7
  127. package/src/__tests__/provider-commit-message-generator.test.ts +0 -1
  128. package/src/__tests__/provider-error-scenarios.test.ts +21 -2
  129. package/src/__tests__/qdrant-manager.test.ts +68 -21
  130. package/src/__tests__/rebuild-index-graph-nodes.test.ts +273 -0
  131. package/src/__tests__/registry.test.ts +2 -2
  132. package/src/__tests__/require-fresh-approval.test.ts +64 -3
  133. package/src/__tests__/runtime-events-sse-parity.test.ts +2 -6
  134. package/src/__tests__/runtime-events-sse.test.ts +2 -6
  135. package/src/__tests__/sandbox-diagnostics.test.ts +20 -29
  136. package/src/__tests__/scaffold-managed-skill-tool.test.ts +2 -10
  137. package/src/__tests__/schedule-store.test.ts +2 -6
  138. package/src/__tests__/schedule-tools.test.ts +2 -6
  139. package/src/__tests__/scheduler-recurrence.test.ts +1 -5
  140. package/src/__tests__/scoped-approval-grants.test.ts +2 -6
  141. package/src/__tests__/scoped-grant-security-matrix.test.ts +2 -6
  142. package/src/__tests__/search-skills-unified.test.ts +421 -0
  143. package/src/__tests__/secret-allowlist.test.ts +20 -35
  144. package/src/__tests__/secret-onetime-send.test.ts +2 -0
  145. package/src/__tests__/send-endpoint-busy.test.ts +2 -6
  146. package/src/__tests__/sequence-store.test.ts +2 -6
  147. package/src/__tests__/server-history-render.test.ts +2 -6
  148. package/src/__tests__/shell-credential-ref.test.ts +0 -5
  149. package/src/__tests__/skill-feature-flags-integration.test.ts +38 -31
  150. package/src/__tests__/skill-feature-flags.test.ts +6 -6
  151. package/src/__tests__/skill-load-feature-flag.test.ts +13 -54
  152. package/src/__tests__/skill-load-inline-command.test.ts +3 -65
  153. package/src/__tests__/skill-load-inline-includes.test.ts +3 -65
  154. package/src/__tests__/skill-load-tool.test.ts +3 -67
  155. package/src/__tests__/skill-memory.test.ts +480 -195
  156. package/src/__tests__/skills-uninstall.test.ts +2 -2
  157. package/src/__tests__/skills.test.ts +23 -50
  158. package/src/__tests__/slack-channel-config.test.ts +2 -21
  159. package/src/__tests__/slack-inbound-verification.test.ts +2 -6
  160. package/src/__tests__/starter-bundle.test.ts +2 -8
  161. package/src/__tests__/stt-hints.test.ts +7 -2
  162. package/src/__tests__/system-prompt.test.ts +25 -45
  163. package/src/__tests__/task-compiler.test.ts +2 -27
  164. package/src/__tests__/task-management-tools.test.ts +2 -27
  165. package/src/__tests__/task-memory-cleanup.test.ts +173 -250
  166. package/src/__tests__/task-runner.test.ts +2 -27
  167. package/src/__tests__/task-scheduler.test.ts +2 -27
  168. package/src/__tests__/terminal-tools.test.ts +1 -17
  169. package/src/__tests__/test-preload.ts +3 -0
  170. package/src/__tests__/token-estimator-accuracy.benchmark.test.ts +0 -79
  171. package/src/__tests__/tool-approval-handler.test.ts +4 -27
  172. package/src/__tests__/tool-execution-abort-cleanup.test.ts +2 -11
  173. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +1 -25
  174. package/src/__tests__/tool-executor-lifecycle-events.test.ts +0 -1
  175. package/src/__tests__/tool-executor.test.ts +0 -1
  176. package/src/__tests__/tool-grant-request-escalation.test.ts +4 -27
  177. package/src/__tests__/tool-preview-lifecycle.test.ts +0 -20
  178. package/src/__tests__/tool-side-effects-slack-dm.test.ts +276 -0
  179. package/src/__tests__/trust-store.test.ts +10 -42
  180. package/src/__tests__/trusted-contact-approval-notifier.test.ts +1 -30
  181. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +3 -27
  182. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +2 -28
  183. package/src/__tests__/trusted-contact-multichannel.test.ts +2 -28
  184. package/src/__tests__/trusted-contact-verification.test.ts +2 -28
  185. package/src/__tests__/turn-boundary-resolution.test.ts +2 -34
  186. package/src/__tests__/twilio-provider.test.ts +0 -16
  187. package/src/__tests__/twilio-routes-twiml.test.ts +7 -12
  188. package/src/__tests__/twilio-routes.test.ts +0 -24
  189. package/src/__tests__/update-bulletin.test.ts +17 -89
  190. package/src/__tests__/usage-cache-backfill-migration.test.ts +1 -26
  191. package/src/__tests__/usage-routes.test.ts +2 -27
  192. package/src/__tests__/user-reference.test.ts +1 -5
  193. package/src/__tests__/vbundle-pax-and-symlink.test.ts +4 -34
  194. package/src/__tests__/vellum-self-knowledge-inline-command.test.ts +2 -53
  195. package/src/__tests__/verification-control-plane-policy.test.ts +0 -2
  196. package/src/__tests__/voice-invite-redemption.test.ts +2 -27
  197. package/src/__tests__/voice-scoped-grant-consumer.test.ts +2 -30
  198. package/src/__tests__/voice-session-bridge.test.ts +2 -27
  199. package/src/__tests__/volume-security-guard.test.ts +2 -0
  200. package/src/__tests__/workspace-lifecycle.test.ts +29 -1
  201. package/src/__tests__/workspace-migration-009-backfill-conversation-disk-view.test.ts +4 -29
  202. package/src/__tests__/workspace-migration-012-rename-conversation-disk-view-dirs.test.ts +2 -2
  203. package/src/__tests__/workspace-migration-013-repair-conversation-disk-view.test.ts +4 -29
  204. package/src/__tests__/workspace-migration-026-backfill-install-meta.test.ts +558 -0
  205. package/src/__tests__/workspace-migration-down-functions.test.ts +0 -6
  206. package/src/__tests__/workspace-policy.test.ts +1 -1
  207. package/src/acp/client-handler.ts +1 -2
  208. package/src/agent/attachments.ts +7 -2
  209. package/src/agent/image-optimize.ts +165 -0
  210. package/src/agent/loop.ts +1 -15
  211. package/src/bundler/app-compiler.ts +179 -2
  212. package/src/bundler/package-resolver.ts +3 -5
  213. package/src/cli/__tests__/notifications.test.ts +1 -24
  214. package/src/cli/cli-memory.ts +179 -0
  215. package/src/cli/commands/avatar.ts +3 -3
  216. package/src/cli/commands/config.ts +26 -13
  217. package/src/cli/commands/doctor.ts +2 -2
  218. package/src/cli/commands/memory.ts +41 -55
  219. package/src/cli/commands/oauth/__tests__/connect.test.ts +2 -2
  220. package/src/cli/commands/oauth/__tests__/disconnect.test.ts +2 -2
  221. package/src/cli/commands/oauth/__tests__/mode.test.ts +8 -1
  222. package/src/cli/commands/oauth/__tests__/providers-update.test.ts +1 -1
  223. package/src/cli/commands/oauth/__tests__/status.test.ts +2 -2
  224. package/src/cli/commands/oauth/connect.ts +26 -6
  225. package/src/cli/commands/oauth/mode.ts +7 -0
  226. package/src/cli/commands/oauth/providers.ts +49 -42
  227. package/src/cli/commands/oauth/shared.ts +39 -3
  228. package/src/cli/commands/platform/__tests__/connect.test.ts +3 -49
  229. package/src/cli/commands/platform/__tests__/disconnect.test.ts +3 -49
  230. package/src/cli/commands/platform/__tests__/status.test.ts +5 -55
  231. package/src/cli/commands/platform/index.ts +16 -16
  232. package/src/cli/commands/skills.ts +88 -16
  233. package/src/cli/commands/trust.ts +2 -2
  234. package/src/cli/lib/daemon-credential-client.ts +2 -3
  235. package/src/config/bundled-skills/acp/TOOLS.json +1 -1
  236. package/src/config/bundled-skills/computer-use/TOOLS.json +7 -7
  237. package/src/config/bundled-skills/contacts/SKILL.md +0 -1
  238. package/src/config/bundled-skills/contacts/TOOLS.json +0 -8
  239. package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +0 -4
  240. package/src/config/bundled-skills/gmail/SKILL.md +2 -10
  241. package/src/config/bundled-skills/google-calendar/SKILL.md +1 -9
  242. package/src/config/bundled-skills/messaging/SKILL.md +26 -19
  243. package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +40 -33
  244. package/src/config/bundled-skills/outlook/SKILL.md +189 -0
  245. package/src/config/bundled-skills/outlook/TOOLS.json +530 -0
  246. package/src/config/bundled-skills/outlook/tools/outlook-attachments.ts +85 -0
  247. package/src/config/bundled-skills/outlook/tools/outlook-categories.ts +77 -0
  248. package/src/config/bundled-skills/outlook/tools/outlook-draft.ts +84 -0
  249. package/src/config/bundled-skills/outlook/tools/outlook-follow-up.ts +94 -0
  250. package/src/config/bundled-skills/outlook/tools/outlook-forward.ts +49 -0
  251. package/src/config/bundled-skills/outlook/tools/outlook-outreach-scan.ts +237 -0
  252. package/src/config/bundled-skills/outlook/tools/outlook-rules.ts +161 -0
  253. package/src/config/bundled-skills/outlook/tools/outlook-send-draft.ts +32 -0
  254. package/src/config/bundled-skills/outlook/tools/outlook-sender-digest.ts +272 -0
  255. package/src/config/bundled-skills/outlook/tools/outlook-trash.ts +29 -0
  256. package/src/config/bundled-skills/outlook/tools/outlook-unsubscribe.ts +129 -0
  257. package/src/config/bundled-skills/outlook/tools/outlook-vacation.ts +87 -0
  258. package/src/config/bundled-skills/outlook/tools/shared.ts +20 -0
  259. package/src/config/bundled-skills/outlook-calendar/SKILL.md +51 -0
  260. package/src/config/bundled-skills/outlook-calendar/TOOLS.json +221 -0
  261. package/src/config/bundled-skills/outlook-calendar/calendar-client.ts +252 -0
  262. package/src/config/bundled-skills/outlook-calendar/tools/outlook-calendar-check-availability.ts +53 -0
  263. package/src/config/bundled-skills/outlook-calendar/tools/outlook-calendar-create-event.ts +74 -0
  264. package/src/config/bundled-skills/outlook-calendar/tools/outlook-calendar-get-event.ts +18 -0
  265. package/src/config/bundled-skills/outlook-calendar/tools/outlook-calendar-list-events.ts +46 -0
  266. package/src/config/bundled-skills/outlook-calendar/tools/outlook-calendar-rsvp.ts +36 -0
  267. package/src/config/bundled-skills/outlook-calendar/tools/shared.ts +17 -0
  268. package/src/config/bundled-skills/outlook-calendar/types.ts +120 -0
  269. package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +47 -40
  270. package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +16 -29
  271. package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +16 -18
  272. package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +39 -47
  273. package/src/config/bundled-skills/settings/TOOLS.json +3 -3
  274. package/src/config/bundled-skills/slack/SKILL.md +1 -7
  275. package/src/config/bundled-tool-registry.ts +56 -4
  276. package/src/config/env-registry.ts +15 -8
  277. package/src/config/feature-flag-registry.json +29 -116
  278. package/src/config/loader.ts +4 -0
  279. package/src/config/schemas/platform.ts +8 -0
  280. package/src/config/schemas/security.ts +0 -6
  281. package/src/config/schemas/services.ts +8 -0
  282. package/src/config/schemas/timeouts.ts +1 -1
  283. package/src/config/skills.ts +18 -7
  284. package/src/context/token-estimator.ts +25 -18
  285. package/src/context/window-manager.ts +32 -9
  286. package/src/credential-execution/approval-bridge.ts +0 -1
  287. package/src/credential-execution/process-manager.ts +3 -1
  288. package/src/daemon/config-watcher.ts +51 -0
  289. package/src/daemon/context-overflow-reducer.ts +46 -2
  290. package/src/daemon/conversation-agent-loop-handlers.ts +123 -82
  291. package/src/daemon/conversation-agent-loop.ts +99 -63
  292. package/src/daemon/conversation-error.ts +31 -8
  293. package/src/daemon/conversation-lifecycle.ts +33 -0
  294. package/src/daemon/conversation-media-retry.ts +85 -7
  295. package/src/daemon/conversation-notifiers.ts +4 -1
  296. package/src/daemon/conversation-process.ts +1 -0
  297. package/src/daemon/conversation-runtime-assembly.ts +5 -0
  298. package/src/daemon/conversation-usage.ts +1 -0
  299. package/src/daemon/conversation.ts +41 -2
  300. package/src/daemon/daemon-control.ts +8 -2
  301. package/src/daemon/handlers/shared.ts +22 -12
  302. package/src/daemon/handlers/skills.ts +423 -201
  303. package/src/daemon/lifecycle.ts +52 -4
  304. package/src/daemon/main.ts +5 -1
  305. package/src/daemon/message-types/conversations.ts +5 -1
  306. package/src/daemon/message-types/messages.ts +3 -1
  307. package/src/daemon/message-types/skills.ts +97 -36
  308. package/src/daemon/providers-setup.ts +7 -0
  309. package/src/daemon/server.ts +35 -22
  310. package/src/daemon/tool-side-effects.ts +27 -5
  311. package/src/events/domain-events.ts +1 -2
  312. package/src/heartbeat/heartbeat-service.ts +1 -0
  313. package/src/hooks/cli.ts +2 -2
  314. package/src/hooks/runner.ts +15 -38
  315. package/src/inbound/platform-callback-registration.ts +14 -14
  316. package/src/memory/admin.ts +11 -45
  317. package/src/memory/conversation-bootstrap.ts +2 -0
  318. package/src/memory/conversation-crud.ts +242 -348
  319. package/src/memory/conversation-group-migration.ts +157 -0
  320. package/src/memory/conversation-queries.ts +4 -2
  321. package/src/memory/db-init.ts +39 -3
  322. package/src/memory/embed.ts +73 -0
  323. package/src/memory/embedding-backend.ts +8 -14
  324. package/src/memory/embedding-runtime-manager.ts +12 -114
  325. package/src/memory/fingerprint.ts +2 -2
  326. package/src/memory/graph/bootstrap.ts +512 -0
  327. package/src/memory/graph/capability-seed.ts +297 -0
  328. package/src/memory/graph/consolidation.ts +691 -0
  329. package/src/memory/graph/conversation-graph-memory.ts +630 -0
  330. package/src/memory/graph/decay.test.ts +208 -0
  331. package/src/memory/graph/decay.ts +195 -0
  332. package/src/memory/graph/extraction-job.ts +69 -0
  333. package/src/memory/graph/extraction.test.ts +936 -0
  334. package/src/memory/graph/extraction.ts +1254 -0
  335. package/src/memory/graph/graph-search.ts +266 -0
  336. package/src/memory/graph/image-ref-utils.ts +29 -0
  337. package/src/memory/graph/injection.test.ts +513 -0
  338. package/src/memory/graph/injection.ts +439 -0
  339. package/src/memory/graph/inspect.ts +534 -0
  340. package/src/memory/graph/narrative.ts +267 -0
  341. package/src/memory/graph/pattern-scan.ts +269 -0
  342. package/src/memory/graph/retriever.ts +1008 -0
  343. package/src/memory/graph/scoring.test.ts +548 -0
  344. package/src/memory/graph/scoring.ts +232 -0
  345. package/src/memory/graph/serendipity.ts +65 -0
  346. package/src/memory/graph/store.test.ts +1050 -0
  347. package/src/memory/graph/store.ts +699 -0
  348. package/src/memory/graph/tool-handlers.ts +426 -0
  349. package/src/memory/graph/tools.ts +141 -0
  350. package/src/memory/graph/triggers.test.ts +487 -0
  351. package/src/memory/graph/triggers.ts +223 -0
  352. package/src/memory/graph/types.ts +271 -0
  353. package/src/memory/group-crud.ts +191 -0
  354. package/src/memory/indexer.ts +37 -19
  355. package/src/memory/job-handlers/cleanup.ts +0 -53
  356. package/src/memory/job-handlers/conversation-starters.ts +91 -53
  357. package/src/memory/job-handlers/embedding.test.ts +3 -27
  358. package/src/memory/job-handlers/embedding.ts +5 -31
  359. package/src/memory/job-handlers/index-maintenance.ts +23 -11
  360. package/src/memory/job-handlers/summarization.ts +32 -17
  361. package/src/memory/job-utils.ts +1 -1
  362. package/src/memory/jobs-store.ts +50 -70
  363. package/src/memory/jobs-worker.ts +147 -112
  364. package/src/memory/llm-usage-store.ts +35 -2
  365. package/src/memory/message-content.ts +1 -0
  366. package/src/memory/migrations/201-oauth-providers-feature-flag.ts +11 -0
  367. package/src/memory/migrations/202-drop-callback-transport-column.ts +13 -0
  368. package/src/memory/migrations/202-memory-graph-tables.ts +130 -0
  369. package/src/memory/migrations/203-drop-memory-items-tables.ts +23 -0
  370. package/src/memory/migrations/204-rename-memory-graph-type-values.ts +46 -0
  371. package/src/memory/migrations/205-memory-graph-image-refs.ts +11 -0
  372. package/src/memory/migrations/index.ts +6 -0
  373. package/src/memory/migrations/registry.ts +8 -0
  374. package/src/memory/qdrant-client.ts +44 -17
  375. package/src/memory/qdrant-manager.ts +26 -5
  376. package/src/memory/schema/index.ts +1 -0
  377. package/src/memory/schema/memory-graph.ts +139 -0
  378. package/src/memory/schema/oauth.ts +1 -1
  379. package/src/memory/search/semantic.ts +47 -91
  380. package/src/memory/slack-thread-store.ts +17 -0
  381. package/src/memory/task-memory-cleanup.ts +28 -50
  382. package/src/messaging/providers/outlook/adapter.ts +200 -0
  383. package/src/messaging/providers/outlook/client.ts +610 -0
  384. package/src/messaging/providers/outlook/types.ts +201 -0
  385. package/src/notifications/adapters/macos.ts +1 -0
  386. package/src/notifications/adapters/slack.ts +1 -1
  387. package/src/notifications/copy-composer.ts +9 -0
  388. package/src/notifications/signal.ts +16 -0
  389. package/src/oauth/__tests__/identity-verifier.test.ts +1 -1
  390. package/src/oauth/connect-orchestrator.ts +10 -3
  391. package/src/oauth/oauth-store.ts +10 -11
  392. package/src/oauth/provider-serializer.ts +3 -0
  393. package/src/oauth/provider-visibility.ts +16 -0
  394. package/src/oauth/seed-providers.ts +50 -17
  395. package/src/permissions/checker.ts +62 -9
  396. package/src/permissions/defaults.ts +4 -4
  397. package/src/permissions/types.ts +2 -4
  398. package/src/permissions/workspace-policy.ts +1 -1
  399. package/src/playbooks/playbook-compiler.ts +19 -18
  400. package/src/playbooks/types.ts +4 -3
  401. package/src/prompts/system-prompt.ts +6 -93
  402. package/src/prompts/templates/UPDATES.md +6 -0
  403. package/src/providers/anthropic/client.ts +47 -19
  404. package/src/providers/gemini/client.ts +1 -1
  405. package/src/providers/openai/client.ts +1 -1
  406. package/src/providers/registry.ts +1 -1
  407. package/src/providers/retry.ts +19 -3
  408. package/src/runtime/actor-trust-resolver.ts +5 -1
  409. package/src/runtime/auth/__tests__/credential-service.test.ts +1 -27
  410. package/src/runtime/auth/__tests__/token-service.test.ts +1 -25
  411. package/src/runtime/auth/route-policy.ts +7 -4
  412. package/src/runtime/guardian-reply-router.ts +10 -2
  413. package/src/runtime/http-server.ts +23 -3
  414. package/src/runtime/middleware/auth.ts +20 -0
  415. package/src/runtime/routes/attachment-routes.test.ts +106 -0
  416. package/src/runtime/routes/attachment-routes.ts +106 -16
  417. package/src/runtime/routes/brain-graph-routes.ts +21 -22
  418. package/src/runtime/routes/btw-routes.ts +8 -0
  419. package/src/runtime/routes/conversation-management-routes.ts +2 -0
  420. package/src/runtime/routes/conversation-query-routes.ts +2 -58
  421. package/src/runtime/routes/conversation-starter-routes.ts +2 -2
  422. package/src/runtime/routes/debug-routes.ts +1 -1
  423. package/src/runtime/routes/global-search-routes.ts +21 -19
  424. package/src/runtime/routes/group-routes.ts +207 -0
  425. package/src/runtime/routes/guardian-action-routes.ts +21 -10
  426. package/src/runtime/routes/guardian-bootstrap-routes.ts +23 -19
  427. package/src/runtime/routes/inbound-message-handler.ts +19 -0
  428. package/src/runtime/routes/inbound-stages/background-dispatch.ts +43 -2
  429. package/src/runtime/routes/inbound-stages/guardian-activation-intercept.test.ts +292 -0
  430. package/src/runtime/routes/inbound-stages/guardian-activation-intercept.ts +207 -0
  431. package/src/runtime/routes/memory-item-routes.test.ts +2 -31
  432. package/src/runtime/routes/memory-item-routes.ts +385 -341
  433. package/src/runtime/routes/oauth-apps.ts +18 -1
  434. package/src/runtime/routes/oauth-providers.ts +13 -1
  435. package/src/runtime/routes/schedule-routes.ts +2 -0
  436. package/src/runtime/routes/settings-routes.ts +1 -0
  437. package/src/runtime/routes/skills-routes.ts +103 -37
  438. package/src/runtime/routes/usage-routes.ts +19 -2
  439. package/src/runtime/routes/work-items-routes.test.ts +2 -27
  440. package/src/runtime/routes/workspace-routes.test.ts +3 -27
  441. package/src/schedule/scheduler.ts +8 -1
  442. package/src/security/oauth2.ts +1 -1
  443. package/src/security/secret-allowlist.ts +4 -4
  444. package/src/security/secure-keys.ts +4 -8
  445. package/src/shared/provider-env-vars.ts +19 -0
  446. package/src/skills/catalog-cache.ts +5 -0
  447. package/src/skills/catalog-install.ts +15 -14
  448. package/src/skills/clawhub.ts +134 -154
  449. package/src/skills/install-meta.ts +208 -0
  450. package/src/skills/managed-store.ts +27 -16
  451. package/src/skills/skill-memory.ts +210 -96
  452. package/src/skills/skillssh-registry.ts +19 -17
  453. package/src/tasks/task-runner.ts +3 -1
  454. package/src/telemetry/usage-telemetry-reporter.test.ts +3 -5
  455. package/src/tools/browser/runtime-check.ts +3 -1
  456. package/src/tools/memory/register.ts +63 -46
  457. package/src/tools/permission-checker.ts +7 -19
  458. package/src/tools/shared/filesystem/image-read.ts +22 -85
  459. package/src/tools/skills/skill-script-runner.ts +1 -1
  460. package/src/tools/terminal/safe-env.ts +1 -0
  461. package/src/tools/tool-manifest.ts +3 -3
  462. package/src/util/browser.ts +25 -10
  463. package/src/util/bun-runtime.ts +172 -0
  464. package/src/util/device-id.ts +3 -65
  465. package/src/watcher/providers/outlook-calendar.ts +343 -0
  466. package/src/watcher/providers/outlook.ts +198 -0
  467. package/src/workspace/git-service.ts +27 -6
  468. package/src/workspace/migrations/025-remove-oauth-app-setup-skills.ts +76 -0
  469. package/src/workspace/migrations/026-backfill-install-meta.ts +325 -0
  470. package/src/workspace/migrations/027-remove-orphaned-optimized-images-cache.ts +42 -0
  471. package/src/workspace/migrations/registry.ts +6 -0
  472. package/src/__tests__/context-memory-e2e.test.ts +0 -415
  473. package/src/__tests__/journal-context.test.ts +0 -268
  474. package/src/__tests__/memory-context-benchmark.benchmark.test.ts +0 -297
  475. package/src/__tests__/memory-lifecycle-e2e.test.ts +0 -459
  476. package/src/__tests__/memory-query-builder.test.ts +0 -59
  477. package/src/__tests__/memory-recall-quality.test.ts +0 -1046
  478. package/src/__tests__/memory-regressions.experimental.test.ts +0 -629
  479. package/src/__tests__/memory-regressions.test.ts +0 -3696
  480. package/src/__tests__/memory-retrieval.benchmark.test.ts +0 -295
  481. package/src/daemon/conversation-memory.ts +0 -207
  482. package/src/memory/conversation-starters-cadence.ts +0 -74
  483. package/src/memory/items-extractor.ts +0 -860
  484. package/src/memory/job-handlers/batch-extraction.ts +0 -741
  485. package/src/memory/job-handlers/extraction.ts +0 -40
  486. package/src/memory/job-handlers/journal-carry-forward.test.ts +0 -383
  487. package/src/memory/job-handlers/journal-carry-forward.ts +0 -255
  488. package/src/memory/journal-memory.ts +0 -224
  489. package/src/memory/query-builder.ts +0 -47
  490. package/src/memory/query-expansion.ts +0 -83
  491. package/src/memory/retriever.test.ts +0 -1590
  492. package/src/memory/retriever.ts +0 -1323
  493. package/src/memory/search/formatting.test.ts +0 -140
  494. package/src/memory/search/formatting.ts +0 -262
  495. package/src/memory/search/mmr.ts +0 -136
  496. package/src/memory/search/ranking.ts +0 -15
  497. package/src/memory/search/staleness.ts +0 -40
  498. package/src/memory/search/tier-classifier.ts +0 -18
  499. package/src/memory/search/types.ts +0 -121
  500. package/src/prompts/journal-context.ts +0 -156
  501. package/src/tools/memory/definitions.ts +0 -69
  502. package/src/tools/memory/handlers.test.ts +0 -590
  503. package/src/tools/memory/handlers.ts +0 -434
@@ -1,1590 +0,0 @@
1
- /**
2
- * Tests for the memory retrieval pipeline.
3
- *
4
- * Covers: hybrid search → tier classification → staleness → injection,
5
- * empty results → no injection, superseded items filtered out,
6
- * staleness demotion, budget allocation, and degradation scenarios.
7
- */
8
- import { mkdtempSync, rmSync } from "node:fs";
9
- import { tmpdir } from "node:os";
10
- import { join } from "node:path";
11
- import {
12
- afterAll,
13
- beforeAll,
14
- beforeEach,
15
- describe,
16
- expect,
17
- mock,
18
- test,
19
- } from "bun:test";
20
-
21
- const testDir = mkdtempSync(join(tmpdir(), "memory-retriever-"));
22
-
23
- mock.module("../util/platform.js", () => ({
24
- getDataDir: () => testDir,
25
- isMacOS: () => process.platform === "darwin",
26
- isLinux: () => process.platform === "linux",
27
- isWindows: () => process.platform === "win32",
28
- getPidPath: () => join(testDir, "test.pid"),
29
- getDbPath: () => join(testDir, "test.db"),
30
- getLogPath: () => join(testDir, "test.log"),
31
- ensureDataDir: () => {},
32
- }));
33
-
34
- mock.module("../util/logger.js", () => ({
35
- getLogger: () =>
36
- new Proxy({} as Record<string, unknown>, {
37
- get: () => () => {},
38
- }),
39
- }));
40
-
41
- // Stub local embedding backend to avoid loading ONNX runtime.
42
- mock.module("../memory/embedding-local.js", () => ({
43
- LocalEmbeddingBackend: class {
44
- readonly provider = "local" as const;
45
- readonly model: string;
46
- constructor(model: string) {
47
- this.model = model;
48
- }
49
- async embed(texts: string[]): Promise<number[][]> {
50
- return texts.map(() => new Array(384).fill(0));
51
- }
52
- },
53
- }));
54
-
55
- // Mock Qdrant client so semantic search returns empty results by default.
56
- // Tests can push entries into `mockQdrantResults` to simulate Qdrant returning
57
- // specific hits (e.g. item candidates).
58
- const mockQdrantResults: Array<{
59
- id: string;
60
- score: number;
61
- payload: Record<string, unknown>;
62
- }> = [];
63
-
64
- mock.module("../memory/qdrant-client.js", () => ({
65
- getQdrantClient: () => ({
66
- searchWithFilter: async () => [...mockQdrantResults],
67
- hybridSearch: async () => [...mockQdrantResults],
68
- upsertPoints: async () => {},
69
- deletePoints: async () => {},
70
- }),
71
- initQdrantClient: () => {},
72
- }));
73
-
74
- import { DEFAULT_CONFIG } from "../config/defaults.js";
75
- import type { AssistantConfig } from "../config/types.js";
76
-
77
- const TEST_CONFIG: AssistantConfig = {
78
- ...DEFAULT_CONFIG,
79
- memory: {
80
- ...DEFAULT_CONFIG.memory,
81
- extraction: {
82
- ...DEFAULT_CONFIG.memory.extraction,
83
- useLLM: false,
84
- },
85
- embeddings: {
86
- ...DEFAULT_CONFIG.memory.embeddings,
87
- required: false,
88
- },
89
- },
90
- };
91
-
92
- mock.module("../config/loader.js", () => ({
93
- loadConfig: () => TEST_CONFIG,
94
- getConfig: () => TEST_CONFIG,
95
- invalidateConfigCache: () => {},
96
- }));
97
-
98
- import { getDb, initializeDb, resetDb } from "../memory/db.js";
99
- import { clearEmbeddingBackendCache } from "../memory/embedding-backend.js";
100
- import {
101
- _resetQdrantBreaker,
102
- isQdrantBreakerOpen,
103
- } from "../memory/qdrant-circuit-breaker.js";
104
- import {
105
- buildMemoryRecall,
106
- injectMemoryRecallAsUserBlock,
107
- } from "../memory/retriever.js";
108
- import {
109
- conversations,
110
- memoryItems,
111
- memoryItemSources,
112
- messages,
113
- } from "../memory/schema.js";
114
- import type { ContentBlock, Message } from "../providers/types.js";
115
-
116
- // ---------------------------------------------------------------------------
117
- // Helpers
118
- // ---------------------------------------------------------------------------
119
-
120
- /** Extract text from a content block, asserting it is a text block. */
121
- function textOf(block: ContentBlock): string {
122
- if (block.type !== "text")
123
- throw new Error(`Expected text block, got ${block.type}`);
124
- return block.text;
125
- }
126
-
127
- function insertConversation(
128
- db: ReturnType<typeof getDb>,
129
- id: string,
130
- createdAt: number,
131
- opts?: { contextCompactedMessageCount?: number },
132
- ) {
133
- db.insert(conversations)
134
- .values({
135
- id,
136
- title: null,
137
- createdAt,
138
- updatedAt: createdAt,
139
- totalInputTokens: 0,
140
- totalOutputTokens: 0,
141
- totalEstimatedCost: 0,
142
- contextSummary: null,
143
- contextCompactedMessageCount: opts?.contextCompactedMessageCount ?? 0,
144
- contextCompactedAt: null,
145
- })
146
- .run();
147
- }
148
-
149
- function insertMessage(
150
- db: ReturnType<typeof getDb>,
151
- id: string,
152
- conversationId: string,
153
- role: string,
154
- text: string,
155
- createdAt: number,
156
- opts?: { metadata?: string | null },
157
- ) {
158
- db.insert(messages)
159
- .values({
160
- id,
161
- conversationId,
162
- role,
163
- content: JSON.stringify([{ type: "text", text }]),
164
- createdAt,
165
- metadata: opts?.metadata ?? null,
166
- })
167
- .run();
168
- }
169
-
170
- function insertSegment(
171
- db: ReturnType<typeof getDb>,
172
- id: string,
173
- messageId: string,
174
- conversationId: string,
175
- role: string,
176
- text: string,
177
- createdAt: number,
178
- ) {
179
- db.run(`
180
- INSERT INTO memory_segments (
181
- id, message_id, conversation_id, role, segment_index, text, token_estimate, created_at, updated_at
182
- ) VALUES (
183
- '${id}', '${messageId}', '${conversationId}', '${role}', 0, '${text.replace(
184
- /'/g,
185
- "''",
186
- )}', ${Math.ceil(text.split(/\s+/).length * 1.3)}, ${createdAt}, ${createdAt}
187
- )
188
- `);
189
- }
190
-
191
- function insertItem(
192
- db: ReturnType<typeof getDb>,
193
- opts: {
194
- id: string;
195
- kind: string;
196
- subject: string;
197
- statement: string;
198
- status?: string;
199
- confidence?: number;
200
- importance?: number;
201
- firstSeenAt: number;
202
- lastSeenAt?: number;
203
- },
204
- ) {
205
- db.insert(memoryItems)
206
- .values({
207
- id: opts.id,
208
- kind: opts.kind,
209
- subject: opts.subject,
210
- statement: opts.statement,
211
- status: opts.status ?? "active",
212
- confidence: opts.confidence ?? 0.8,
213
- importance: opts.importance ?? 0.6,
214
- accessCount: 0,
215
- fingerprint: `fp-${opts.id}`,
216
- firstSeenAt: opts.firstSeenAt,
217
- lastSeenAt: opts.lastSeenAt ?? opts.firstSeenAt,
218
- lastUsedAt: null,
219
- })
220
- .run();
221
- }
222
-
223
- function insertItemSource(
224
- db: ReturnType<typeof getDb>,
225
- itemId: string,
226
- messageId: string,
227
- createdAt: number,
228
- ) {
229
- db.insert(memoryItemSources)
230
- .values({
231
- memoryItemId: itemId,
232
- messageId,
233
- evidence: `evidence for ${itemId}`,
234
- createdAt,
235
- })
236
- .run();
237
- }
238
-
239
- /** Seed the database with some searchable memory content. */
240
- function seedMemory() {
241
- const db = getDb();
242
- const now = Date.now();
243
- const convId = "conv-test";
244
-
245
- insertConversation(db, convId, now - 60_000);
246
- insertMessage(
247
- db,
248
- "msg-1",
249
- convId,
250
- "user",
251
- "discuss API design",
252
- now - 50_000,
253
- );
254
- insertMessage(
255
- db,
256
- "msg-2",
257
- convId,
258
- "assistant",
259
- "The API design uses REST endpoints",
260
- now - 40_000,
261
- );
262
-
263
- insertSegment(
264
- db,
265
- "seg-1",
266
- "msg-1",
267
- convId,
268
- "user",
269
- "discuss API design patterns",
270
- now - 50_000,
271
- );
272
- insertSegment(
273
- db,
274
- "seg-2",
275
- "msg-2",
276
- convId,
277
- "assistant",
278
- "The API design uses REST endpoints with JSON responses",
279
- now - 40_000,
280
- );
281
-
282
- insertItem(db, {
283
- id: "item-1",
284
- kind: "preference",
285
- subject: "API design",
286
- statement: "User prefers REST over GraphQL for API design",
287
- firstSeenAt: now - 30_000,
288
- });
289
- insertItemSource(db, "item-1", "msg-1", now - 30_000);
290
- }
291
-
292
- // ---------------------------------------------------------------------------
293
- // Suite
294
- // ---------------------------------------------------------------------------
295
-
296
- describe("Memory Retriever Pipeline", () => {
297
- beforeAll(() => {
298
- initializeDb();
299
- });
300
-
301
- beforeEach(() => {
302
- const db = getDb();
303
- db.run("DELETE FROM memory_item_sources");
304
- db.run("DELETE FROM memory_items");
305
- db.run("DELETE FROM memory_segments");
306
- db.run("DELETE FROM messages");
307
- db.run("DELETE FROM conversations");
308
- _resetQdrantBreaker();
309
- clearEmbeddingBackendCache();
310
- mockQdrantResults.length = 0;
311
- });
312
-
313
- afterAll(() => {
314
- resetDb();
315
- rmSync(testDir, { recursive: true, force: true });
316
- });
317
-
318
- // -----------------------------------------------------------------------
319
- // Hybrid search → tier classification → injection
320
- // -----------------------------------------------------------------------
321
-
322
- test("baseline: pipeline completes non-degraded with mock Qdrant returning empty", async () => {
323
- seedMemory();
324
-
325
- const result = await buildMemoryRecall(
326
- "API design",
327
- "conv-test",
328
- TEST_CONFIG,
329
- );
330
-
331
- expect(result.enabled).toBe(true);
332
- expect(result.degraded).toBe(false);
333
- expect(result.degradation).toBeUndefined();
334
- // With Qdrant mocked empty, no candidates are found.
335
- // The pipeline still completes successfully with tier metadata.
336
- expect(result.tier1Count).toBeDefined();
337
- expect(result.tier2Count).toBeDefined();
338
- expect(result.hybridSearchMs).toBeDefined();
339
- // Without semantic search, no candidates are found.
340
- expect(result.mergedCount).toBe(0);
341
- });
342
-
343
- // -----------------------------------------------------------------------
344
- // Current-conversation segment filtering
345
- // -----------------------------------------------------------------------
346
-
347
- test("current-conversation segments are filtered from search results", async () => {
348
- const db = getDb();
349
- const now = Date.now();
350
- const activeConv = "conv-active";
351
- const otherConv = "conv-other";
352
-
353
- insertConversation(db, activeConv, now - 60_000);
354
- insertConversation(db, otherConv, now - 120_000);
355
-
356
- // Messages and segments in the active conversation (should be filtered)
357
- insertMessage(
358
- db,
359
- "msg-a1",
360
- activeConv,
361
- "user",
362
- "hello world",
363
- now - 50_000,
364
- );
365
- insertSegment(
366
- db,
367
- "seg-a1",
368
- "msg-a1",
369
- activeConv,
370
- "user",
371
- "hello world",
372
- now - 50_000,
373
- );
374
-
375
- // Messages and segments in a different conversation (should be kept)
376
- insertMessage(
377
- db,
378
- "msg-o1",
379
- otherConv,
380
- "user",
381
- "hello world from other",
382
- now - 100_000,
383
- );
384
- insertSegment(
385
- db,
386
- "seg-o1",
387
- "msg-o1",
388
- otherConv,
389
- "user",
390
- "hello world from other",
391
- now - 100_000,
392
- );
393
-
394
- // Query from the active conversation
395
- const result = await buildMemoryRecall(
396
- "hello world",
397
- activeConv,
398
- TEST_CONFIG,
399
- );
400
-
401
- expect(result.enabled).toBe(true);
402
- // Without semantic search, no candidates are found.
403
- expect(result.mergedCount).toBe(0);
404
- });
405
-
406
- test("compacted segments from current conversation are preserved in memory", async () => {
407
- const db = getDb();
408
- const now = Date.now();
409
- const convId = "conv-compacted";
410
-
411
- // Create a conversation where 2 messages have been compacted away
412
- insertConversation(db, convId, now - 120_000, {
413
- contextCompactedMessageCount: 2,
414
- });
415
-
416
- // Older messages (compacted out of context window) — their segments
417
- // should NOT be filtered because the model can no longer see them
418
- insertMessage(
419
- db,
420
- "msg-old-1",
421
- convId,
422
- "user",
423
- "old discussion topic",
424
- now - 100_000,
425
- );
426
- insertMessage(
427
- db,
428
- "msg-old-2",
429
- convId,
430
- "assistant",
431
- "old response",
432
- now - 90_000,
433
- );
434
-
435
- // Newer messages (still in context window) — their segments should
436
- // be filtered since the model can still see them
437
- insertMessage(
438
- db,
439
- "msg-new-1",
440
- convId,
441
- "user",
442
- "recent discussion",
443
- now - 50_000,
444
- );
445
- insertMessage(
446
- db,
447
- "msg-new-2",
448
- convId,
449
- "assistant",
450
- "recent response",
451
- now - 40_000,
452
- );
453
-
454
- // Segments from compacted messages (should survive filtering)
455
- insertSegment(
456
- db,
457
- "seg-old-1",
458
- "msg-old-1",
459
- convId,
460
- "user",
461
- "old discussion topic details",
462
- now - 100_000,
463
- );
464
- insertSegment(
465
- db,
466
- "seg-old-2",
467
- "msg-old-2",
468
- convId,
469
- "assistant",
470
- "old response details",
471
- now - 90_000,
472
- );
473
-
474
- // Segments from in-context messages (should be filtered)
475
- insertSegment(
476
- db,
477
- "seg-new-1",
478
- "msg-new-1",
479
- convId,
480
- "user",
481
- "recent discussion details",
482
- now - 50_000,
483
- );
484
- insertSegment(
485
- db,
486
- "seg-new-2",
487
- "msg-new-2",
488
- convId,
489
- "assistant",
490
- "recent response details",
491
- now - 40_000,
492
- );
493
-
494
- const result = await buildMemoryRecall(
495
- "discussion topic",
496
- convId,
497
- TEST_CONFIG,
498
- );
499
-
500
- expect(result.enabled).toBe(true);
501
- });
502
-
503
- // -----------------------------------------------------------------------
504
- // Empty results → no injection
505
- // -----------------------------------------------------------------------
506
-
507
- test("empty results: no injection when no memory content exists", async () => {
508
- // Don't seed any memory
509
- const result = await buildMemoryRecall(
510
- "nonexistent topic",
511
- "conv-empty",
512
- TEST_CONFIG,
513
- );
514
-
515
- expect(result.enabled).toBe(true);
516
- expect(result.selectedCount).toBe(0);
517
- expect(result.injectedText).toBe("");
518
- expect(result.mergedCount).toBe(0);
519
- });
520
-
521
- // -----------------------------------------------------------------------
522
- // Memory disabled
523
- // -----------------------------------------------------------------------
524
-
525
- test("disabled: returns enabled=false when memory is disabled", async () => {
526
- const disabledConfig: AssistantConfig = {
527
- ...TEST_CONFIG,
528
- memory: {
529
- ...TEST_CONFIG.memory,
530
- enabled: false,
531
- },
532
- };
533
-
534
- const result = await buildMemoryRecall(
535
- "test query",
536
- "conv-test",
537
- disabledConfig,
538
- );
539
-
540
- expect(result.enabled).toBe(false);
541
- expect(result.reason).toBe("memory.disabled");
542
- });
543
-
544
- // -----------------------------------------------------------------------
545
- // Superseded items filtered out
546
- // -----------------------------------------------------------------------
547
-
548
- test("superseded items are not included in results", async () => {
549
- const db = getDb();
550
- const now = Date.now();
551
- const convId = "conv-superseded";
552
-
553
- insertConversation(db, convId, now - 60_000);
554
- insertMessage(
555
- db,
556
- "msg-s1",
557
- convId,
558
- "user",
559
- "test superseded",
560
- now - 50_000,
561
- );
562
-
563
- insertSegment(
564
- db,
565
- "seg-s1",
566
- "msg-s1",
567
- convId,
568
- "user",
569
- "test superseded content",
570
- now - 50_000,
571
- );
572
-
573
- // Insert an active item and a superseded item
574
- insertItem(db, {
575
- id: "item-active",
576
- kind: "fact",
577
- subject: "test",
578
- statement: "Active fact about testing",
579
- status: "active",
580
- firstSeenAt: now - 30_000,
581
- });
582
- insertItem(db, {
583
- id: "item-superseded",
584
- kind: "fact",
585
- subject: "test",
586
- statement: "Old fact that was superseded",
587
- status: "superseded",
588
- firstSeenAt: now - 30_000,
589
- });
590
-
591
- const result = await buildMemoryRecall(
592
- "test superseded",
593
- convId,
594
- TEST_CONFIG,
595
- );
596
-
597
- // The injected text should not contain the superseded item statement
598
- if (result.injectedText.length > 0) {
599
- expect(result.injectedText).not.toContain("Old fact that was superseded");
600
- }
601
- });
602
-
603
- // -----------------------------------------------------------------------
604
- // Staleness demotion (very_stale tier 1 → tier 2)
605
- // -----------------------------------------------------------------------
606
-
607
- test("staleness: very old items get demoted from tier 1 to tier 2", async () => {
608
- const db = getDb();
609
- const now = Date.now();
610
- const convId = "conv-stale";
611
- const MS_PER_DAY = 86_400_000;
612
-
613
- insertConversation(db, convId, now - MS_PER_DAY * 200);
614
-
615
- // Create a message from 200 days ago (staleness test anchor)
616
- insertMessage(
617
- db,
618
- "msg-old",
619
- convId,
620
- "user",
621
- "ancient discussion about TypeScript",
622
- now - MS_PER_DAY * 200,
623
- );
624
- insertSegment(
625
- db,
626
- "seg-old",
627
- "msg-old",
628
- convId,
629
- "user",
630
- "ancient discussion about TypeScript patterns",
631
- now - MS_PER_DAY * 200,
632
- );
633
-
634
- // Insert a very old item (200 days) — should be marked as very_stale
635
- insertItem(db, {
636
- id: "item-old",
637
- kind: "fact",
638
- subject: "TypeScript",
639
- statement: "User uses TypeScript for all projects",
640
- firstSeenAt: now - MS_PER_DAY * 200,
641
- });
642
- insertItemSource(db, "item-old", "msg-old", now - MS_PER_DAY * 200);
643
-
644
- const result = await buildMemoryRecall(
645
- "TypeScript patterns",
646
- convId,
647
- TEST_CONFIG,
648
- );
649
-
650
- // The pipeline should still return results (just potentially in tier 2)
651
- expect(result.enabled).toBe(true);
652
- // Very old items should still appear but may be in tier 2 after demotion
653
- expect(result.tier1Count).toBeDefined();
654
- expect(result.tier2Count).toBeDefined();
655
- });
656
-
657
- // -----------------------------------------------------------------------
658
- // Budget allocation (tier 1 priority)
659
- // -----------------------------------------------------------------------
660
-
661
- test("budget: respects maxInjectTokens override", async () => {
662
- seedMemory();
663
-
664
- // Use a very small token budget
665
- const result = await buildMemoryRecall(
666
- "API design",
667
- "conv-test",
668
- TEST_CONFIG,
669
- { maxInjectTokensOverride: 10 },
670
- );
671
-
672
- expect(result.enabled).toBe(true);
673
- // With a 10-token budget, most content should be truncated
674
- expect(result.injectedTokens).toBeLessThanOrEqual(10);
675
- });
676
-
677
- // -----------------------------------------------------------------------
678
- // Degradation: Qdrant circuit breaker open
679
- // -----------------------------------------------------------------------
680
-
681
- test("Qdrant unavailable: pipeline completes with empty results", async () => {
682
- seedMemory();
683
-
684
- // Force the Qdrant circuit breaker open
685
- const { withQdrantBreaker } =
686
- await import("../memory/qdrant-circuit-breaker.js");
687
- for (let i = 0; i < 5; i++) {
688
- try {
689
- await withQdrantBreaker(async () => {
690
- throw new Error("simulated qdrant failure");
691
- });
692
- } catch {
693
- // expected
694
- }
695
- }
696
- expect(isQdrantBreakerOpen()).toBe(true);
697
-
698
- const result = await buildMemoryRecall(
699
- "API design",
700
- "conv-test",
701
- TEST_CONFIG,
702
- );
703
-
704
- expect(result.enabled).toBe(true);
705
- // Semantic/hybrid search should be skipped
706
- expect(result.semanticHits).toBe(0);
707
- // Without semantic search, no candidates are found.
708
- expect(result.mergedCount).toBe(0);
709
- });
710
-
711
- // -----------------------------------------------------------------------
712
- // Degradation: embedding provider down
713
- // -----------------------------------------------------------------------
714
-
715
- test("embedding provider down: returns degraded when embeddings required", async () => {
716
- seedMemory();
717
-
718
- const requiredEmbedConfig: AssistantConfig = {
719
- ...TEST_CONFIG,
720
- memory: {
721
- ...TEST_CONFIG.memory,
722
- embeddings: {
723
- ...TEST_CONFIG.memory.embeddings,
724
- provider: "openai",
725
- required: true,
726
- },
727
- },
728
- };
729
-
730
- const result = await buildMemoryRecall(
731
- "API design",
732
- "conv-test",
733
- requiredEmbedConfig,
734
- );
735
-
736
- expect(result.enabled).toBe(true);
737
- expect(result.degraded).toBe(true);
738
- expect(result.degradation).toBeDefined();
739
- expect(result.degradation!.semanticUnavailable).toBe(true);
740
- expect(result.degradation!.reason).toBe("embedding_provider_down");
741
- expect(result.degradation!.fallbackSources).toEqual([]);
742
- });
743
-
744
- // -----------------------------------------------------------------------
745
- // Signal abort
746
- // -----------------------------------------------------------------------
747
-
748
- test("abort: returns early when signal is aborted", async () => {
749
- seedMemory();
750
- const controller = new AbortController();
751
- controller.abort();
752
-
753
- const result = await buildMemoryRecall(
754
- "API design",
755
- "conv-test",
756
- TEST_CONFIG,
757
- { signal: controller.signal },
758
- );
759
-
760
- expect(result.enabled).toBe(true);
761
- expect(result.reason).toBe("memory.aborted");
762
- expect(result.injectedText).toBe("");
763
- });
764
-
765
- // -----------------------------------------------------------------------
766
- // injectMemoryRecallAsUserBlock
767
- // -----------------------------------------------------------------------
768
-
769
- test("injectMemoryRecallAsUserBlock: prepends memory context to last user message", () => {
770
- const msgs: Message[] = [
771
- {
772
- role: "user",
773
- content: [{ type: "text", text: "Hello" }],
774
- },
775
- ];
776
-
777
- const recallText =
778
- "<memory_context __injected>\n\n<relevant_context>\ntest\n</relevant_context>\n\n</memory_context>";
779
- const result = injectMemoryRecallAsUserBlock(msgs, recallText);
780
-
781
- // Same number of messages — no synthetic pair added
782
- expect(result).toHaveLength(1);
783
- expect(result[0].role).toBe("user");
784
- // Memory context prepended as first content block
785
- expect(result[0].content).toHaveLength(2);
786
- expect(textOf(result[0].content[0])).toBe(recallText);
787
- // Original user text preserved as second block
788
- expect(textOf(result[0].content[1])).toBe("Hello");
789
- });
790
-
791
- test("injectMemoryRecallAsUserBlock: no-op for empty text", () => {
792
- const msgs: Message[] = [
793
- {
794
- role: "user",
795
- content: [{ type: "text", text: "Hello" }],
796
- },
797
- ];
798
-
799
- const result = injectMemoryRecallAsUserBlock(msgs, "");
800
- expect(result).toHaveLength(1);
801
- expect(textOf(result[0].content[0])).toBe("Hello");
802
- });
803
-
804
- test("injectMemoryRecallAsUserBlock: preserves history before last user message", () => {
805
- const msgs: Message[] = [
806
- { role: "user", content: [{ type: "text", text: "First" }] },
807
- { role: "assistant", content: [{ type: "text", text: "Response" }] },
808
- { role: "user", content: [{ type: "text", text: "Second" }] },
809
- ];
810
-
811
- const recallText =
812
- "<memory_context __injected>\n\n<relevant_context>\nfact\n</relevant_context>\n\n</memory_context>";
813
- const result = injectMemoryRecallAsUserBlock(msgs, recallText);
814
-
815
- expect(result).toHaveLength(3);
816
- // Earlier messages unchanged
817
- expect(result[0]).toBe(msgs[0]);
818
- expect(result[1]).toBe(msgs[1]);
819
- // Last user message has memory prepended
820
- expect(textOf(result[2].content[0])).toBe(recallText);
821
- expect(textOf(result[2].content[1])).toBe("Second");
822
- });
823
-
824
- // -----------------------------------------------------------------------
825
- // Local embedding stub end-to-end
826
- // -----------------------------------------------------------------------
827
-
828
- test("local embedding: pipeline completes non-degraded", async () => {
829
- seedMemory();
830
-
831
- const localEmbedConfig: AssistantConfig = {
832
- ...TEST_CONFIG,
833
- memory: {
834
- ...TEST_CONFIG.memory,
835
- embeddings: {
836
- ...TEST_CONFIG.memory.embeddings,
837
- provider: "local",
838
- required: false,
839
- },
840
- },
841
- };
842
-
843
- const result = await buildMemoryRecall(
844
- "API design",
845
- "conv-test",
846
- localEmbedConfig,
847
- );
848
-
849
- // The local stub returns zero vectors — embedding "succeeds" so the
850
- // pipeline proceeds non-degraded end-to-end.
851
- expect(result.enabled).toBe(true);
852
- expect(result.degraded).toBe(false);
853
- // Without semantic search, no candidates are found.
854
- expect(result.mergedCount).toBe(0);
855
- });
856
-
857
- // -----------------------------------------------------------------------
858
- // Step 5b: in-context item filtering
859
- // -----------------------------------------------------------------------
860
-
861
- describe("step 5b: in-context item filtering", () => {
862
- test("filters items whose all sources are in-context messages", async () => {
863
- const db = getDb();
864
- const now = Date.now();
865
- const convId = "conv-item-filter";
866
-
867
- insertConversation(db, convId, now - 60_000);
868
- insertMessage(db, "msg-if-1", convId, "user", "hello", now - 50_000);
869
- insertMessage(db, "msg-if-2", convId, "assistant", "world", now - 40_000);
870
- insertMessage(
871
- db,
872
- "msg-if-3",
873
- convId,
874
- "user",
875
- "memory items test",
876
- now - 30_000,
877
- );
878
-
879
- // Insert a memory item sourced from msg-if-2 (in-context)
880
- insertItem(db, {
881
- id: "item-in-ctx",
882
- kind: "fact",
883
- subject: "test",
884
- statement: "A fact from in-context message",
885
- firstSeenAt: now - 35_000,
886
- });
887
- insertItemSource(db, "item-in-ctx", "msg-if-2", now - 35_000);
888
-
889
- // Simulate Qdrant returning this item as a semantic hit
890
- mockQdrantResults.push({
891
- id: "qdrant-pt-1",
892
- score: 0.9,
893
- payload: {
894
- target_type: "item",
895
- target_id: "item-in-ctx",
896
- text: "test: A fact from in-context message",
897
- created_at: now - 35_000,
898
- },
899
- });
900
-
901
- const result = await buildMemoryRecall(
902
- "memory items test",
903
- convId,
904
- TEST_CONFIG,
905
- );
906
-
907
- expect(result.enabled).toBe(true);
908
- // The item should be filtered because its only source is in-context
909
- expect(result.mergedCount).toBe(0);
910
- });
911
-
912
- test("keeps items from compacted messages", async () => {
913
- const db = getDb();
914
- const now = Date.now();
915
- const convId = "conv-item-compacted";
916
-
917
- // 2 messages compacted away
918
- insertConversation(db, convId, now - 120_000, {
919
- contextCompactedMessageCount: 2,
920
- });
921
-
922
- // Compacted messages (first 2 by createdAt order)
923
- insertMessage(
924
- db,
925
- "msg-ic-1",
926
- convId,
927
- "user",
928
- "compacted old topic",
929
- now - 100_000,
930
- );
931
- insertMessage(
932
- db,
933
- "msg-ic-2",
934
- convId,
935
- "assistant",
936
- "compacted old reply",
937
- now - 90_000,
938
- );
939
-
940
- // Still in context
941
- insertMessage(
942
- db,
943
- "msg-ic-3",
944
- convId,
945
- "user",
946
- "item compaction test",
947
- now - 50_000,
948
- );
949
-
950
- // Item sourced from a compacted message — should be kept
951
- insertItem(db, {
952
- id: "item-compacted",
953
- kind: "fact",
954
- subject: "compaction",
955
- statement: "A fact from a compacted message",
956
- firstSeenAt: now - 95_000,
957
- });
958
- insertItemSource(db, "item-compacted", "msg-ic-1", now - 95_000);
959
-
960
- // Simulate Qdrant returning this item as a semantic hit
961
- mockQdrantResults.push({
962
- id: "qdrant-pt-2",
963
- score: 0.9,
964
- payload: {
965
- target_type: "item",
966
- target_id: "item-compacted",
967
- text: "compaction: A fact from a compacted message",
968
- created_at: now - 95_000,
969
- },
970
- });
971
-
972
- const result = await buildMemoryRecall(
973
- "item compaction test",
974
- convId,
975
- TEST_CONFIG,
976
- );
977
-
978
- expect(result.enabled).toBe(true);
979
- // The item sourced from a compacted message should survive filtering
980
- // because its source is no longer in the context window
981
- expect(result.mergedCount).toBeGreaterThan(0);
982
- });
983
-
984
- test("keeps items with cross-conversation sources", async () => {
985
- const db = getDb();
986
- const now = Date.now();
987
- const convId = "conv-item-cross";
988
- const otherConvId = "conv-item-other";
989
-
990
- insertConversation(db, convId, now - 60_000);
991
- insertConversation(db, otherConvId, now - 120_000);
992
-
993
- // Messages in current conversation
994
- insertMessage(
995
- db,
996
- "msg-cr-1",
997
- convId,
998
- "user",
999
- "cross conv test",
1000
- now - 50_000,
1001
- );
1002
- insertMessage(
1003
- db,
1004
- "msg-cr-2",
1005
- convId,
1006
- "assistant",
1007
- "cross conv reply",
1008
- now - 40_000,
1009
- );
1010
-
1011
- // Message in the other conversation
1012
- insertMessage(
1013
- db,
1014
- "msg-cr-other",
1015
- otherConvId,
1016
- "user",
1017
- "other conv msg",
1018
- now - 100_000,
1019
- );
1020
-
1021
- // Item sourced from BOTH the current conversation AND a different one
1022
- insertItem(db, {
1023
- id: "item-cross",
1024
- kind: "fact",
1025
- subject: "cross",
1026
- statement: "A cross-conversation fact",
1027
- firstSeenAt: now - 95_000,
1028
- });
1029
- insertItemSource(db, "item-cross", "msg-cr-1", now - 45_000);
1030
- insertItemSource(db, "item-cross", "msg-cr-other", now - 95_000);
1031
-
1032
- // Simulate Qdrant returning this item as a semantic hit
1033
- mockQdrantResults.push({
1034
- id: "qdrant-pt-3",
1035
- score: 0.9,
1036
- payload: {
1037
- target_type: "item",
1038
- target_id: "item-cross",
1039
- text: "cross: A cross-conversation fact",
1040
- created_at: now - 95_000,
1041
- },
1042
- });
1043
-
1044
- const result = await buildMemoryRecall(
1045
- "cross conv test",
1046
- convId,
1047
- TEST_CONFIG,
1048
- );
1049
-
1050
- expect(result.enabled).toBe(true);
1051
- // The item has a source outside the in-context set (from other conv),
1052
- // so it should NOT be filtered — it carries cross-conversation info
1053
- expect(result.mergedCount).toBeGreaterThan(0);
1054
- });
1055
- });
1056
-
1057
- // -----------------------------------------------------------------------
1058
- // Step 5b: fork-aware filtering
1059
- // -----------------------------------------------------------------------
1060
-
1061
- describe("step 5b: fork-aware filtering", () => {
1062
- test("filters segments sourced from fork-parent messages", async () => {
1063
- const db = getDb();
1064
- const now = Date.now();
1065
-
1066
- // Parent conversation with messages
1067
- const parentConv = "conv-parent";
1068
- insertConversation(db, parentConv, now - 120_000);
1069
- insertMessage(
1070
- db,
1071
- "parent-msg-1",
1072
- parentConv,
1073
- "user",
1074
- "discuss fork patterns",
1075
- now - 110_000,
1076
- );
1077
- insertMessage(
1078
- db,
1079
- "parent-msg-2",
1080
- parentConv,
1081
- "assistant",
1082
- "fork patterns are useful",
1083
- now - 100_000,
1084
- );
1085
-
1086
- // Fork conversation — messages are copies with forkSourceMessageId metadata
1087
- const forkConv = "conv-fork";
1088
- insertConversation(db, forkConv, now - 50_000);
1089
- insertMessage(
1090
- db,
1091
- "fork-msg-1",
1092
- forkConv,
1093
- "user",
1094
- "discuss fork patterns",
1095
- now - 50_000,
1096
- {
1097
- metadata: JSON.stringify({
1098
- forkSourceMessageId: "parent-msg-1",
1099
- }),
1100
- },
1101
- );
1102
- insertMessage(
1103
- db,
1104
- "fork-msg-2",
1105
- forkConv,
1106
- "assistant",
1107
- "fork patterns are useful",
1108
- now - 49_000,
1109
- {
1110
- metadata: JSON.stringify({
1111
- forkSourceMessageId: "parent-msg-2",
1112
- }),
1113
- },
1114
- );
1115
-
1116
- // Segment sourced from a parent message — should be filtered when
1117
- // recalling for the fork conversation since the fork copy is in context.
1118
- insertSegment(
1119
- db,
1120
- "seg-parent-1",
1121
- "parent-msg-1",
1122
- parentConv,
1123
- "user",
1124
- "discuss fork patterns detail",
1125
- now - 110_000,
1126
- );
1127
-
1128
- // Simulate Qdrant returning the parent-conversation segment as a
1129
- // semantic hit so it enters the candidate map.
1130
- mockQdrantResults.push({
1131
- id: "qdrant-fork-1",
1132
- score: 0.9,
1133
- payload: {
1134
- target_type: "segment",
1135
- target_id: "seg-parent-1",
1136
- text: "discuss fork patterns detail",
1137
- created_at: now - 110_000,
1138
- message_id: "parent-msg-1",
1139
- conversation_id: parentConv,
1140
- },
1141
- });
1142
-
1143
- const result = await buildMemoryRecall(
1144
- "fork patterns",
1145
- forkConv,
1146
- TEST_CONFIG,
1147
- );
1148
-
1149
- expect(result.enabled).toBe(true);
1150
- // The segment entered the candidate map via semantic search…
1151
- expect(result.semanticHits).toBeGreaterThanOrEqual(1);
1152
- // …but the fork-source filtering removed it because parent-msg-1 is
1153
- // in the in-context set (via forkSourceMessageId on fork-msg-1).
1154
- expect(result.mergedCount).toBe(0);
1155
- });
1156
-
1157
- test("keeps segments from compacted fork messages' parents", async () => {
1158
- const db = getDb();
1159
- const now = Date.now();
1160
-
1161
- // Parent conversation
1162
- const parentConv = "conv-parent-compact";
1163
- insertConversation(db, parentConv, now - 200_000);
1164
- insertMessage(
1165
- db,
1166
- "parent-compact-msg-1",
1167
- parentConv,
1168
- "user",
1169
- "compacted parent topic",
1170
- now - 190_000,
1171
- );
1172
- insertMessage(
1173
- db,
1174
- "parent-compact-msg-2",
1175
- parentConv,
1176
- "assistant",
1177
- "compacted parent response",
1178
- now - 180_000,
1179
- );
1180
-
1181
- // Fork conversation with compaction — first 2 messages are compacted
1182
- const forkConv = "conv-fork-compact";
1183
- insertConversation(db, forkConv, now - 100_000, {
1184
- contextCompactedMessageCount: 2,
1185
- });
1186
-
1187
- // These two messages are compacted (offset=2 means first 2 are compacted)
1188
- insertMessage(
1189
- db,
1190
- "fork-compact-msg-1",
1191
- forkConv,
1192
- "user",
1193
- "compacted parent topic",
1194
- now - 100_000,
1195
- {
1196
- metadata: JSON.stringify({
1197
- forkSourceMessageId: "parent-compact-msg-1",
1198
- }),
1199
- },
1200
- );
1201
- insertMessage(
1202
- db,
1203
- "fork-compact-msg-2",
1204
- forkConv,
1205
- "assistant",
1206
- "compacted parent response",
1207
- now - 99_000,
1208
- {
1209
- metadata: JSON.stringify({
1210
- forkSourceMessageId: "parent-compact-msg-2",
1211
- }),
1212
- },
1213
- );
1214
-
1215
- // A newer message still in context
1216
- insertMessage(
1217
- db,
1218
- "fork-compact-msg-3",
1219
- forkConv,
1220
- "user",
1221
- "recent fork topic",
1222
- now - 50_000,
1223
- );
1224
-
1225
- // Segment in the fork conversation sourced from a compacted fork
1226
- // message. Since the fork message is compacted, its forkSourceMessageId
1227
- // is NOT added to the in-context set, so the segment should survive.
1228
- insertSegment(
1229
- db,
1230
- "seg-compact-fork",
1231
- "fork-compact-msg-1",
1232
- forkConv,
1233
- "user",
1234
- "compacted parent topic detail",
1235
- now - 100_000,
1236
- );
1237
-
1238
- // Also insert a segment from an in-context message for contrast —
1239
- // this one SHOULD be filtered.
1240
- insertSegment(
1241
- db,
1242
- "seg-in-context-fork",
1243
- "fork-compact-msg-3",
1244
- forkConv,
1245
- "user",
1246
- "recent fork topic detail",
1247
- now - 50_000,
1248
- );
1249
-
1250
- // Simulate Qdrant returning both segments as semantic hits so they
1251
- // enter the candidate map (recency search was removed).
1252
- mockQdrantResults.push(
1253
- {
1254
- id: "qdrant-compact-fork-1",
1255
- score: 0.9,
1256
- payload: {
1257
- target_type: "segment",
1258
- target_id: "seg-compact-fork",
1259
- text: "compacted parent topic detail",
1260
- created_at: now - 100_000,
1261
- message_id: "fork-compact-msg-1",
1262
- conversation_id: forkConv,
1263
- },
1264
- },
1265
- {
1266
- id: "qdrant-compact-fork-2",
1267
- score: 0.85,
1268
- payload: {
1269
- target_type: "segment",
1270
- target_id: "seg-in-context-fork",
1271
- text: "recent fork topic detail",
1272
- created_at: now - 50_000,
1273
- message_id: "fork-compact-msg-3",
1274
- conversation_id: forkConv,
1275
- },
1276
- },
1277
- );
1278
-
1279
- const result = await buildMemoryRecall(
1280
- "compacted parent topic",
1281
- forkConv,
1282
- TEST_CONFIG,
1283
- );
1284
-
1285
- expect(result.enabled).toBe(true);
1286
- // The segment from the compacted fork message survives filtering
1287
- // (its source message is no longer in context). The in-context segment
1288
- // is filtered out. Semantic search returns both, but only the compacted
1289
- // one survives step 5b.
1290
- expect(result.mergedCount).toBeGreaterThan(0);
1291
- });
1292
-
1293
- test("handles multi-level forks", async () => {
1294
- const db = getDb();
1295
- const now = Date.now();
1296
-
1297
- // Grandparent conversation
1298
- const grandparentConv = "conv-grandparent";
1299
- insertConversation(db, grandparentConv, now - 300_000);
1300
- insertMessage(
1301
- db,
1302
- "gp-msg-1",
1303
- grandparentConv,
1304
- "user",
1305
- "grandparent topic",
1306
- now - 290_000,
1307
- );
1308
-
1309
- // Parent conversation (fork of grandparent)
1310
- // The fork metadata preserves the original grandparent message ID
1311
- const parentConv = "conv-parent-multi";
1312
- insertConversation(db, parentConv, now - 200_000);
1313
- insertMessage(
1314
- db,
1315
- "parent-multi-msg-1",
1316
- parentConv,
1317
- "user",
1318
- "grandparent topic",
1319
- now - 200_000,
1320
- {
1321
- metadata: JSON.stringify({
1322
- forkSourceMessageId: "gp-msg-1",
1323
- }),
1324
- },
1325
- );
1326
-
1327
- // Child conversation (fork of parent)
1328
- // forkSourceMessageId still points to the original grandparent message
1329
- const childConv = "conv-child-multi";
1330
- insertConversation(db, childConv, now - 100_000);
1331
- insertMessage(
1332
- db,
1333
- "child-multi-msg-1",
1334
- childConv,
1335
- "user",
1336
- "grandparent topic",
1337
- now - 100_000,
1338
- {
1339
- metadata: JSON.stringify({
1340
- forkSourceMessageId: "gp-msg-1",
1341
- }),
1342
- },
1343
- );
1344
-
1345
- // Segment sourced from the grandparent message
1346
- insertSegment(
1347
- db,
1348
- "seg-gp",
1349
- "gp-msg-1",
1350
- grandparentConv,
1351
- "user",
1352
- "grandparent topic detail",
1353
- now - 290_000,
1354
- );
1355
-
1356
- // Simulate Qdrant returning the grandparent segment as a semantic hit
1357
- // so it enters the candidate map.
1358
- mockQdrantResults.push({
1359
- id: "qdrant-gp-1",
1360
- score: 0.9,
1361
- payload: {
1362
- target_type: "segment",
1363
- target_id: "seg-gp",
1364
- text: "grandparent topic detail",
1365
- created_at: now - 290_000,
1366
- message_id: "gp-msg-1",
1367
- conversation_id: grandparentConv,
1368
- },
1369
- });
1370
-
1371
- const result = await buildMemoryRecall(
1372
- "grandparent topic",
1373
- childConv,
1374
- TEST_CONFIG,
1375
- );
1376
-
1377
- expect(result.enabled).toBe(true);
1378
- // The segment entered the candidate map via semantic search…
1379
- expect(result.semanticHits).toBeGreaterThanOrEqual(1);
1380
- // …but the fork-source filtering removed it because gp-msg-1 is in the
1381
- // in-context set (via forkSourceMessageId on child-multi-msg-1).
1382
- expect(result.mergedCount).toBe(0);
1383
- });
1384
-
1385
- test("handles missing or invalid metadata gracefully", async () => {
1386
- const db = getDb();
1387
- const now = Date.now();
1388
-
1389
- const forkConv = "conv-fork-bad-meta";
1390
- insertConversation(db, forkConv, now - 50_000);
1391
-
1392
- // Message with null metadata (no forkSourceMessageId)
1393
- insertMessage(
1394
- db,
1395
- "fork-null-meta",
1396
- forkConv,
1397
- "user",
1398
- "null metadata topic",
1399
- now - 50_000,
1400
- );
1401
-
1402
- // Message with malformed JSON metadata
1403
- insertMessage(
1404
- db,
1405
- "fork-bad-json",
1406
- forkConv,
1407
- "assistant",
1408
- "bad json topic",
1409
- now - 49_000,
1410
- { metadata: "not valid json {{{" },
1411
- );
1412
-
1413
- // Message with metadata that is a JSON array (not an object)
1414
- insertMessage(
1415
- db,
1416
- "fork-array-meta",
1417
- forkConv,
1418
- "user",
1419
- "array metadata topic",
1420
- now - 48_000,
1421
- { metadata: JSON.stringify([1, 2, 3]) },
1422
- );
1423
-
1424
- // Message with metadata object but no forkSourceMessageId field
1425
- insertMessage(
1426
- db,
1427
- "fork-no-field",
1428
- forkConv,
1429
- "assistant",
1430
- "no field topic",
1431
- now - 47_000,
1432
- { metadata: JSON.stringify({ someOtherField: "value" }) },
1433
- );
1434
-
1435
- // Message with forkSourceMessageId that is not a string
1436
- insertMessage(
1437
- db,
1438
- "fork-non-string",
1439
- forkConv,
1440
- "user",
1441
- "non-string fork id",
1442
- now - 46_000,
1443
- { metadata: JSON.stringify({ forkSourceMessageId: 12345 }) },
1444
- );
1445
-
1446
- // Insert a segment from this conversation — should be filtered normally
1447
- // (it's an in-context segment from the active conversation)
1448
- insertSegment(
1449
- db,
1450
- "seg-bad-meta",
1451
- "fork-null-meta",
1452
- forkConv,
1453
- "user",
1454
- "null metadata topic detail",
1455
- now - 50_000,
1456
- );
1457
-
1458
- // This should not crash despite various malformed metadata
1459
- const result = await buildMemoryRecall(
1460
- "metadata topic",
1461
- forkConv,
1462
- TEST_CONFIG,
1463
- );
1464
-
1465
- expect(result.enabled).toBe(true);
1466
- // No crash — the pipeline completes successfully
1467
- // The in-context segment is still filtered normally
1468
- expect(result.mergedCount).toBe(0);
1469
- });
1470
- });
1471
-
1472
- // -----------------------------------------------------------------------
1473
- // Serendipity layer
1474
- // -----------------------------------------------------------------------
1475
-
1476
- describe("serendipity sampling", () => {
1477
- test("samples random active items and renders them in <echoes>", async () => {
1478
- const db = getDb();
1479
- const now = Date.now();
1480
- const convId = "conv-serendipity";
1481
-
1482
- insertConversation(db, convId, now - 60_000);
1483
- insertMessage(db, "msg-s-1", convId, "user", "hello", now - 50_000);
1484
-
1485
- // Items sourced from a different conversation so in-context filtering
1486
- // doesn't remove them (serendipity is cross-conversation recall).
1487
- const otherConvId = "conv-serendipity-other";
1488
- insertConversation(db, otherConvId, now - 120_000);
1489
- insertMessage(db, "msg-s-other", otherConvId, "user", "other", now - 110_000);
1490
-
1491
- // Insert several active items that are NOT returned by Qdrant
1492
- for (let i = 1; i <= 5; i++) {
1493
- insertItem(db, {
1494
- id: `serendipity-item-${i}`,
1495
- kind: "fact",
1496
- subject: `topic ${i}`,
1497
- statement: `Serendipity fact number ${i}`,
1498
- importance: i * 0.15, // 0.15..0.75
1499
- firstSeenAt: now - i * 10_000,
1500
- });
1501
- insertItemSource(db, `serendipity-item-${i}`, "msg-s-other", now - i * 10_000);
1502
- }
1503
-
1504
- // Qdrant returns nothing — no recalled candidates
1505
- mockQdrantResults.length = 0;
1506
-
1507
- const result = await buildMemoryRecall(
1508
- "unrelated query",
1509
- convId,
1510
- TEST_CONFIG,
1511
- );
1512
-
1513
- expect(result.enabled).toBe(true);
1514
- // No semantic hits, so no recalled candidates
1515
- expect(result.mergedCount).toBe(0);
1516
- // But serendipity items should appear in the injection
1517
- expect(result.injectedText).toContain("<echoes>");
1518
- expect(result.injectedText).toContain("</echoes>");
1519
- // At most 3 serendipity items
1520
- const itemMatches = result.injectedText.match(/<item /g);
1521
- expect(itemMatches).toBeTruthy();
1522
- expect(itemMatches!.length).toBeLessThanOrEqual(3);
1523
- expect(itemMatches!.length).toBeGreaterThanOrEqual(1);
1524
- // selectedCount includes serendipity items
1525
- expect(result.selectedCount).toBeGreaterThan(0);
1526
- });
1527
-
1528
- test("excludes items already in the candidate pool from serendipity", async () => {
1529
- const db = getDb();
1530
- const now = Date.now();
1531
- const convId = "conv-serendipity-excl";
1532
-
1533
- insertConversation(db, convId, now - 60_000);
1534
- insertMessage(db, "msg-se-1", convId, "user", "query about X", now - 50_000);
1535
-
1536
- // This item will be returned by Qdrant as a recalled candidate
1537
- insertItem(db, {
1538
- id: "recalled-item",
1539
- kind: "fact",
1540
- subject: "X",
1541
- statement: "Recalled fact about X",
1542
- importance: 0.9,
1543
- firstSeenAt: now - 30_000,
1544
- });
1545
- insertItemSource(db, "recalled-item", "msg-se-1", now - 30_000);
1546
-
1547
- // Qdrant returns the recalled item
1548
- mockQdrantResults.push({
1549
- id: "qdrant-recalled",
1550
- score: 0.9,
1551
- payload: {
1552
- target_type: "item",
1553
- target_id: "recalled-item",
1554
- text: "X: Recalled fact about X",
1555
- created_at: now - 30_000,
1556
- },
1557
- });
1558
-
1559
- const result = await buildMemoryRecall(
1560
- "query about X",
1561
- convId,
1562
- TEST_CONFIG,
1563
- );
1564
-
1565
- expect(result.enabled).toBe(true);
1566
- // The recalled item is in <recalled>, not in <echoes>
1567
- if (result.injectedText.includes("<echoes>")) {
1568
- // If echoes exists, the recalled item should NOT be duplicated there
1569
- const echoesMatch = result.injectedText.match(
1570
- /<echoes>([\s\S]*?)<\/echoes>/,
1571
- );
1572
- if (echoesMatch) {
1573
- expect(echoesMatch[1]).not.toContain("recalled-item");
1574
- }
1575
- }
1576
- });
1577
-
1578
- test("no <echoes> section when no active items exist", async () => {
1579
- // No items seeded at all
1580
- const result = await buildMemoryRecall(
1581
- "anything",
1582
- "conv-empty-seren",
1583
- TEST_CONFIG,
1584
- );
1585
-
1586
- expect(result.enabled).toBe(true);
1587
- expect(result.injectedText).not.toContain("<echoes>");
1588
- });
1589
- });
1590
- });