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