@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,3696 +0,0 @@
1
- import { mkdirSync, rmSync, writeFileSync } from "node:fs";
2
- import { join } from "node:path";
3
- import {
4
- afterAll,
5
- beforeAll,
6
- beforeEach,
7
- describe,
8
- expect,
9
- mock,
10
- test,
11
- } from "bun:test";
12
-
13
- const testWorkspaceDir = process.env.VELLUM_WORKSPACE_DIR!;
14
-
15
- mock.module("../util/logger.js", () => ({
16
- getLogger: () =>
17
- new Proxy({} as Record<string, unknown>, {
18
- get: () => () => {},
19
- }),
20
- }));
21
-
22
- // Stub the local embedding backend so the real ONNX model (2.5 GB RSS) never
23
- // loads — avoids a Bun v1.3.9 panic on process exit.
24
- mock.module("../memory/embedding-local.js", () => ({
25
- LocalEmbeddingBackend: class {
26
- readonly provider = "local" as const;
27
- readonly model: string;
28
- constructor(model: string) {
29
- this.model = model;
30
- }
31
- async embed(texts: string[]): Promise<number[][]> {
32
- return texts.map(() => new Array(384).fill(0));
33
- }
34
- },
35
- }));
36
-
37
- // Dynamic Qdrant mock: tests can push results to be returned by hybridSearch
38
- let mockQdrantResults: Array<{
39
- id: string;
40
- score: number;
41
- payload: Record<string, unknown>;
42
- }> = [];
43
-
44
- mock.module("../memory/qdrant-client.js", () => ({
45
- getQdrantClient: () => ({
46
- searchWithFilter: async () => mockQdrantResults,
47
- hybridSearch: async () => mockQdrantResults,
48
- upsertPoints: async () => {},
49
- deletePoints: async () => {},
50
- }),
51
- initQdrantClient: () => {},
52
- }));
53
-
54
- import { and, eq } from "drizzle-orm";
55
-
56
- import { DEFAULT_CONFIG } from "../config/defaults.js";
57
- import { vectorToBlob } from "../memory/job-utils.js";
58
-
59
- // Disable LLM extraction and summarization in tests to avoid real API calls.
60
- const TEST_CONFIG = {
61
- ...DEFAULT_CONFIG,
62
- memory: {
63
- ...DEFAULT_CONFIG.memory,
64
- extraction: {
65
- ...DEFAULT_CONFIG.memory.extraction,
66
- useLLM: false,
67
- },
68
- summarization: {
69
- ...DEFAULT_CONFIG.memory.summarization,
70
- useLLM: false,
71
- },
72
- },
73
- };
74
-
75
- mock.module("../config/loader.js", () => ({
76
- loadConfig: () => TEST_CONFIG,
77
- getConfig: () => TEST_CONFIG,
78
- invalidateConfigCache: () => {},
79
- }));
80
- import { estimateTextTokens } from "../context/token-estimator.js";
81
- import { stripUserTextBlocksByPrefix } from "../daemon/conversation-runtime-assembly.js";
82
- import {
83
- getMemorySystemStatus,
84
- requestMemoryBackfill,
85
- requestMemoryCleanup,
86
- } from "../memory/admin.js";
87
- import {
88
- addMessage,
89
- createConversation,
90
- getConversationMemoryScopeId,
91
- messageMetadataSchema,
92
- provenanceFromTrustContext,
93
- } from "../memory/conversation-crud.js";
94
- import { getDb, initializeDb, resetDb } from "../memory/db.js";
95
- import { selectEmbeddingBackend } from "../memory/embedding-backend.js";
96
- import {
97
- getRecentSegmentsForConversation,
98
- indexMessageNow,
99
- } from "../memory/indexer.js";
100
- import { backfillJob } from "../memory/job-handlers/backfill.js";
101
- import { buildConversationSummaryJob } from "../memory/job-handlers/summarization.js";
102
- import { claimMemoryJobs, enqueueMemoryJob } from "../memory/jobs-store.js";
103
- import {
104
- maybeEnqueueScheduledCleanupJobs,
105
- resetCleanupScheduleThrottle,
106
- resetStaleSweepThrottle,
107
- runMemoryJobsOnce,
108
- sweepStaleItems,
109
- } from "../memory/jobs-worker.js";
110
- import {
111
- buildMemoryRecall,
112
- escapeXmlTags,
113
- formatAbsoluteTime,
114
- formatRelativeTime,
115
- injectMemoryRecallAsUserBlock,
116
- lookupSupersessionChain,
117
- } from "../memory/retriever.js";
118
- import {
119
- conversations,
120
- memoryEmbeddings,
121
- memoryItems,
122
- memoryItemSources,
123
- memoryJobs,
124
- memorySegments,
125
- memorySummaries,
126
- messages,
127
- } from "../memory/schema.js";
128
- import { buildMemoryInjection } from "../memory/search/formatting.js";
129
- import { buildCoreIdentityContext } from "../prompts/system-prompt.js";
130
- import type { Message } from "../providers/types.js";
131
-
132
- describe("Memory regressions", () => {
133
- beforeAll(() => {
134
- initializeDb();
135
- });
136
-
137
- beforeEach(() => {
138
- const db = getDb();
139
- db.run("DELETE FROM memory_item_sources");
140
- db.run("DELETE FROM memory_embeddings");
141
- db.run("DELETE FROM memory_summaries");
142
- db.run("DELETE FROM memory_items");
143
-
144
- db.run("DELETE FROM memory_segments");
145
- db.run("DELETE FROM messages");
146
- db.run("DELETE FROM conversations");
147
- db.run("DELETE FROM memory_jobs");
148
- db.run("DELETE FROM memory_checkpoints");
149
- mockQdrantResults = [];
150
- resetCleanupScheduleThrottle();
151
- resetStaleSweepThrottle();
152
- });
153
-
154
- afterAll(() => {
155
- resetDb();
156
- });
157
-
158
- function semanticRecallConfig() {
159
- return {
160
- ...DEFAULT_CONFIG,
161
- memory: {
162
- ...DEFAULT_CONFIG.memory,
163
- embeddings: {
164
- ...DEFAULT_CONFIG.memory.embeddings,
165
- provider: "ollama" as const,
166
- required: true,
167
- },
168
- retrieval: {
169
- ...DEFAULT_CONFIG.memory.retrieval,
170
- maxInjectTokens: 2000,
171
- },
172
- },
173
- };
174
- }
175
-
176
- // Baseline: indexMessageNow without explicit scopeId defaults to 'default'
177
- test('baseline: memory segments default to scope "default" when no scopeId given', async () => {
178
- const db = getDb();
179
- const now = Date.now();
180
- db.insert(conversations)
181
- .values({
182
- id: "conv-baseline-scope",
183
- title: null,
184
- createdAt: now,
185
- updatedAt: now,
186
- totalInputTokens: 0,
187
- totalOutputTokens: 0,
188
- totalEstimatedCost: 0,
189
- contextSummary: null,
190
- contextCompactedMessageCount: 0,
191
- contextCompactedAt: null,
192
- })
193
- .run();
194
- db.insert(messages)
195
- .values({
196
- id: "msg-baseline-scope",
197
- conversationId: "conv-baseline-scope",
198
- role: "user",
199
- content: JSON.stringify([
200
- {
201
- type: "text",
202
- text: "The user strongly prefers dark mode for all editor themes and UIs.",
203
- },
204
- ]),
205
- createdAt: now,
206
- })
207
- .run();
208
-
209
- // Index without explicit scopeId — should use 'default'
210
- await indexMessageNow(
211
- {
212
- messageId: "msg-baseline-scope",
213
- conversationId: "conv-baseline-scope",
214
- role: "user",
215
- content: JSON.stringify([
216
- {
217
- type: "text",
218
- text: "The user strongly prefers dark mode for all editor themes and UIs.",
219
- },
220
- ]),
221
- createdAt: now,
222
- },
223
- DEFAULT_CONFIG.memory,
224
- );
225
-
226
- const segs = db
227
- .select()
228
- .from(memorySegments)
229
- .where(eq(memorySegments.messageId, "msg-baseline-scope"))
230
- .all();
231
-
232
- expect(segs.length).toBeGreaterThan(0);
233
- for (const seg of segs) {
234
- expect(seg.scopeId).toBe("default");
235
- }
236
- });
237
-
238
- test("recall excludes current-turn message ids from injected candidates", async () => {
239
- const db = getDb();
240
- const now = 1_700_000_100_000;
241
- db.insert(conversations)
242
- .values({
243
- id: "conv-exclude",
244
- title: null,
245
- createdAt: now,
246
- updatedAt: now,
247
- totalInputTokens: 0,
248
- totalOutputTokens: 0,
249
- totalEstimatedCost: 0,
250
- contextSummary: null,
251
- contextCompactedMessageCount: 0,
252
- contextCompactedAt: null,
253
- })
254
- .run();
255
- db.insert(messages)
256
- .values({
257
- id: "msg-old",
258
- conversationId: "conv-exclude",
259
- role: "user",
260
- content: JSON.stringify([
261
- { type: "text", text: "Remember my timezone is PST." },
262
- ]),
263
- createdAt: now - 10_000,
264
- })
265
- .run();
266
- db.insert(messages)
267
- .values({
268
- id: "msg-current",
269
- conversationId: "conv-exclude",
270
- role: "user",
271
- content: JSON.stringify([
272
- { type: "text", text: "What is my timezone again?" },
273
- ]),
274
- createdAt: now,
275
- })
276
- .run();
277
- db.run(`
278
- INSERT INTO memory_segments (
279
- id, message_id, conversation_id, role, segment_index, text, token_estimate, created_at, updated_at
280
- ) VALUES
281
- ('seg-old', 'msg-old', 'conv-exclude', 'user', 0, 'Remember my timezone is PST.', 7, ${
282
- now - 10_000
283
- }, ${now - 10_000}),
284
- ('seg-current', 'msg-current', 'conv-exclude', 'user', 0, 'What is my timezone again?', 7, ${now}, ${now})
285
- `);
286
-
287
- const config = {
288
- ...DEFAULT_CONFIG,
289
- memory: {
290
- ...DEFAULT_CONFIG.memory,
291
- embeddings: {
292
- ...DEFAULT_CONFIG.memory.embeddings,
293
- required: false,
294
- },
295
- },
296
- };
297
-
298
- const recall = await buildMemoryRecall("timezone", "conv-exclude", config, {
299
- excludeMessageIds: ["msg-current"],
300
- });
301
- expect(recall.enabled).toBe(true);
302
- });
303
-
304
- test("memory recall injection as user block and stripped from runtime history", () => {
305
- const memoryRecallText =
306
- "<memory_context __injected>\n\n<relevant_context>\nuser prefers concise answers\n</relevant_context>\n\n</memory_context>";
307
- const originalMessages: Message[] = [
308
- {
309
- role: "user",
310
- content: [{ type: "text" as const, text: "Actual user request" }],
311
- },
312
- ];
313
- const injected = injectMemoryRecallAsUserBlock(
314
- originalMessages,
315
- memoryRecallText,
316
- );
317
-
318
- // Memory context prepended to last user message as content block
319
- expect(injected).toHaveLength(1);
320
- expect(injected[0].role).toBe("user");
321
- expect(injected[0].content).toHaveLength(2);
322
- const b0 = injected[0].content[0];
323
- const b1 = injected[0].content[1];
324
- expect(b0.type === "text" && b0.text).toBe(memoryRecallText);
325
- expect(b1.type === "text" && b1.text).toBe("Actual user request");
326
-
327
- // Stripped by prefix-based stripping
328
- const cleaned = stripUserTextBlocksByPrefix(injected, [
329
- "<memory_context __injected>",
330
- ]);
331
- expect(cleaned).toHaveLength(1);
332
- expect(cleaned[0].content).toHaveLength(1);
333
- const cb0 = cleaned[0].content[0];
334
- expect(cb0.type === "text" && cb0.text).toBe("Actual user request");
335
- });
336
-
337
- test("prefix-based stripping removes all <memory_context> blocks from merged content", () => {
338
- const memoryRecallText =
339
- "<memory_context __injected>\n\n<relevant_context>\nuser prefers concise answers\n</relevant_context>\n\n</memory_context>";
340
- // Simulate deep-repair merging where multiple memory context blocks exist.
341
- // Prefix-based stripping removes all blocks starting with <memory_context __injected>.
342
- const mergedUserMessage: Message = {
343
- role: "user",
344
- content: [
345
- { type: "text" as const, text: memoryRecallText },
346
- { type: "text" as const, text: "Earlier user request" },
347
- { type: "text" as const, text: memoryRecallText },
348
- { type: "text" as const, text: "Latest user request" },
349
- ],
350
- };
351
-
352
- const cleaned = stripUserTextBlocksByPrefix(
353
- [mergedUserMessage],
354
- ["<memory_context __injected>"],
355
- );
356
- expect(cleaned).toHaveLength(1);
357
- expect(cleaned[0].content).toEqual([
358
- { type: "text", text: "Earlier user request" },
359
- { type: "text", text: "Latest user request" },
360
- ]);
361
- });
362
-
363
- test("injectMemoryRecallAsUserBlock prepends memory to last user message", () => {
364
- const history: Message[] = [
365
- { role: "user", content: [{ type: "text" as const, text: "Hello" }] },
366
- { role: "assistant", content: [{ type: "text" as const, text: "Hi!" }] },
367
- {
368
- role: "user",
369
- content: [{ type: "text" as const, text: "Tell me about X" }],
370
- },
371
- ];
372
- const recallText =
373
- "<memory_context __injected>\n\n<relevant_context>\nSome recalled fact\n</relevant_context>\n\n</memory_context>";
374
- const result = injectMemoryRecallAsUserBlock(history, recallText);
375
- // Same number of messages — no synthetic pair
376
- expect(result).toHaveLength(3);
377
- expect(result[0]).toBe(history[0]);
378
- expect(result[1]).toBe(history[1]);
379
- // Last user message has memory prepended
380
- const r0 = result[2].content[0];
381
- const r1 = result[2].content[1];
382
- expect(r0.type === "text" && r0.text).toBe(recallText);
383
- expect(r1.type === "text" && r1.text).toBe("Tell me about X");
384
- });
385
-
386
- test("injectMemoryRecallAsUserBlock with empty text is a no-op", () => {
387
- const history: Message[] = [
388
- { role: "user", content: [{ type: "text" as const, text: "Hello" }] },
389
- ];
390
- const result = injectMemoryRecallAsUserBlock(history, " ");
391
- expect(result).toBe(history);
392
- });
393
-
394
- test("stripUserTextBlocksByPrefix removes memory_context block from user message", () => {
395
- const recallText =
396
- "<memory_context __injected>\n\n<relevant_context>\nSome recalled fact\n</relevant_context>\n\n</memory_context>";
397
- const msgs: Message[] = [
398
- { role: "user", content: [{ type: "text" as const, text: "Hello" }] },
399
- {
400
- role: "assistant",
401
- content: [{ type: "text" as const, text: "Hi!" }],
402
- },
403
- {
404
- role: "user",
405
- content: [
406
- { type: "text" as const, text: recallText },
407
- { type: "text" as const, text: "Tell me about X" },
408
- ],
409
- },
410
- ];
411
- const cleaned = stripUserTextBlocksByPrefix(msgs, [
412
- "<memory_context __injected>",
413
- ]);
414
- expect(cleaned).toHaveLength(3);
415
- const c0 = cleaned[0].content[0];
416
- const c1 = cleaned[1].content[0];
417
- const c2 = cleaned[2].content[0];
418
- expect(c0.type === "text" && c0.text).toBe("Hello");
419
- expect(c1.type === "text" && c1.text).toBe("Hi!");
420
- expect(cleaned[2].content).toHaveLength(1);
421
- expect(c2.type === "text" && c2.text).toBe("Tell me about X");
422
- });
423
-
424
- test("aborting memory recall embedding returns a non-degraded aborted recall result", async () => {
425
- const originalFetch = globalThis.fetch;
426
- const controller = new AbortController();
427
- let seenSignal: AbortSignal | undefined;
428
-
429
- globalThis.fetch = ((_: string | URL | Request, init?: RequestInit) => {
430
- seenSignal = init?.signal as AbortSignal | undefined;
431
- return new Promise<Response>((_resolve, reject) => {
432
- const signal = init?.signal as AbortSignal | undefined;
433
- if (!signal) {
434
- reject(new Error("Expected abort signal"));
435
- return;
436
- }
437
- const abortError = new Error("Aborted");
438
- abortError.name = "AbortError";
439
- if (signal.aborted) {
440
- reject(abortError);
441
- return;
442
- }
443
- signal.addEventListener("abort", () => reject(abortError), {
444
- once: true,
445
- });
446
- });
447
- }) as typeof globalThis.fetch;
448
-
449
- try {
450
- const recallPromise = buildMemoryRecall(
451
- "timezone",
452
- "conv-abort",
453
- semanticRecallConfig(),
454
- { signal: controller.signal },
455
- );
456
- controller.abort();
457
- const recall = await recallPromise;
458
- expect(seenSignal).toBe(controller.signal);
459
- expect(recall.degraded).toBe(false);
460
- expect(recall.reason).toBe("memory.aborted");
461
- expect(recall.injectedText).toBe("");
462
- expect(recall.injectedTokens).toBe(0);
463
- } finally {
464
- globalThis.fetch = originalFetch;
465
- }
466
- });
467
-
468
- test("memory item lastSeenAt does not move backwards on duplicate save", async () => {
469
- const { handleMemorySave } = await import("../tools/memory/handlers.js");
470
-
471
- // First save creates the item
472
- const r1 = await handleMemorySave(
473
- {
474
- statement: "We decided to use sqlite for local persistence",
475
- kind: "decision",
476
- },
477
- DEFAULT_CONFIG,
478
- "conv-lastseen-1",
479
- "msg-lastseen-1",
480
- );
481
- expect(r1.isError).toBe(false);
482
-
483
- const db = getDb();
484
- const firstSave = db
485
- .select()
486
- .from(memoryItems)
487
- .where(eq(memoryItems.kind, "decision"))
488
- .get();
489
- expect(firstSave).not.toBeNull();
490
- const firstLastSeenAt = firstSave!.lastSeenAt;
491
- expect(firstLastSeenAt).toBeGreaterThan(0);
492
-
493
- // Second save of the same statement should update lastSeenAt monotonically
494
- const r2 = await handleMemorySave(
495
- {
496
- statement: "We decided to use sqlite for local persistence",
497
- kind: "decision",
498
- },
499
- DEFAULT_CONFIG,
500
- "conv-lastseen-2",
501
- "msg-lastseen-2",
502
- );
503
- expect(r2.isError).toBe(false);
504
-
505
- const secondSave = db
506
- .select()
507
- .from(memoryItems)
508
- .where(eq(memoryItems.kind, "decision"))
509
- .get();
510
- expect(secondSave!.lastSeenAt).toBeGreaterThanOrEqual(firstLastSeenAt);
511
- });
512
-
513
- test("memory_save sets verificationState to user_confirmed", async () => {
514
- const { handleMemorySave } = await import("../tools/memory/handlers.js");
515
-
516
- const result = await handleMemorySave(
517
- {
518
- statement: "User explicitly saved this preference",
519
- kind: "preference",
520
- },
521
- DEFAULT_CONFIG,
522
- "conv-verify-save",
523
- "msg-verify-save",
524
- );
525
- expect(result.isError).toBe(false);
526
-
527
- const db = getDb();
528
- const items = db.select().from(memoryItems).all();
529
- const saved = items.find(
530
- (i) => i.statement === "User explicitly saved this preference",
531
- );
532
- expect(saved).toBeDefined();
533
- expect(saved!.verificationState).toBe("user_confirmed");
534
- });
535
-
536
- test("memory_save in different scopes creates separate items", async () => {
537
- const { handleMemorySave } = await import("../tools/memory/handlers.js");
538
-
539
- const sharedArgs = { statement: "I prefer dark mode", kind: "preference" };
540
-
541
- // Save in the default scope
542
- const r1 = await handleMemorySave(
543
- sharedArgs,
544
- DEFAULT_CONFIG,
545
- "conv-scope-1",
546
- "msg-scope-1",
547
- "default",
548
- );
549
- expect(r1.isError).toBe(false);
550
- expect(r1.content).toContain("Saved to memory");
551
-
552
- // Save the identical statement in a private scope
553
- const r2 = await handleMemorySave(
554
- sharedArgs,
555
- DEFAULT_CONFIG,
556
- "conv-scope-2",
557
- "msg-scope-2",
558
- "private-abc",
559
- );
560
- expect(r2.isError).toBe(false);
561
- expect(r2.content).toContain("Saved to memory");
562
-
563
- // Both items should exist with distinct IDs
564
- const db = getDb();
565
- const items = db
566
- .select()
567
- .from(memoryItems)
568
- .where(eq(memoryItems.statement, "I prefer dark mode"))
569
- .all();
570
- expect(items.length).toBe(2);
571
-
572
- const scopes = new Set(items.map((i) => i.scopeId));
573
- expect(scopes.has("default")).toBe(true);
574
- expect(scopes.has("private-abc")).toBe(true);
575
-
576
- // Saving the same statement again in default scope should dedup (not create a third)
577
- const r3 = await handleMemorySave(
578
- sharedArgs,
579
- DEFAULT_CONFIG,
580
- "conv-scope-3",
581
- "msg-scope-3",
582
- "default",
583
- );
584
- expect(r3.isError).toBe(false);
585
- expect(r3.content).toContain("already exists");
586
-
587
- const afterDedup = db
588
- .select()
589
- .from(memoryItems)
590
- .where(eq(memoryItems.statement, "I prefer dark mode"))
591
- .all();
592
- expect(afterDedup.length).toBe(2);
593
- });
594
-
595
- test("memory_update promotes verificationState to user_confirmed", async () => {
596
- const db = getDb();
597
- const now = Date.now();
598
- const { handleMemoryUpdate } = await import("../tools/memory/handlers.js");
599
-
600
- // Pre-seed an assistant-inferred item
601
- db.insert(memoryItems)
602
- .values({
603
- id: "item-update-verify",
604
- kind: "fact",
605
- subject: "update test",
606
- statement: "Original assistant inferred statement",
607
- status: "active",
608
- confidence: 0.6,
609
- importance: 0.4,
610
- fingerprint: "fp-update-verify-original",
611
- verificationState: "assistant_inferred",
612
- firstSeenAt: now,
613
- lastSeenAt: now,
614
- lastUsedAt: null,
615
- })
616
- .run();
617
-
618
- const result = await handleMemoryUpdate(
619
- {
620
- memory_id: "item-update-verify",
621
- statement: "User corrected statement",
622
- },
623
- DEFAULT_CONFIG,
624
- );
625
- expect(result.isError).toBe(false);
626
-
627
- const updated = db
628
- .select()
629
- .from(memoryItems)
630
- .where(eq(memoryItems.id, "item-update-verify"))
631
- .get();
632
- expect(updated).toBeDefined();
633
- expect(updated!.statement).toBe("User corrected statement");
634
- expect(updated!.verificationState).toBe("user_confirmed");
635
- });
636
-
637
- test("private conversation cannot update default-scope item by ID", async () => {
638
- const db = getDb();
639
- const now = Date.now();
640
- const { handleMemoryUpdate } = await import("../tools/memory/handlers.js");
641
-
642
- // Pre-seed an item in the default scope
643
- db.insert(memoryItems)
644
- .values({
645
- id: "item-default-no-cross",
646
- kind: "fact",
647
- subject: "cross-scope update",
648
- statement: "Original default-scope statement",
649
- status: "active",
650
- confidence: 0.8,
651
- importance: 0.6,
652
- fingerprint: "fp-default-no-cross",
653
- verificationState: "assistant_inferred",
654
- scopeId: "default",
655
- firstSeenAt: now,
656
- lastSeenAt: now,
657
- lastUsedAt: null,
658
- })
659
- .run();
660
-
661
- // Attempt to update from a private scope — should fail with "not found"
662
- const result = await handleMemoryUpdate(
663
- { memory_id: "item-default-no-cross", statement: "Hijacked statement" },
664
- DEFAULT_CONFIG,
665
- "private-thread-xyz",
666
- );
667
- expect(result.isError).toBe(true);
668
- expect(result.content).toContain("not found");
669
-
670
- // Verify the original item is unchanged
671
- const item = db
672
- .select()
673
- .from(memoryItems)
674
- .where(eq(memoryItems.id, "item-default-no-cross"))
675
- .get();
676
- expect(item).toBeDefined();
677
- expect(item!.statement).toBe("Original default-scope statement");
678
- });
679
-
680
- test("standard conversation cannot update private-scope item by ID", async () => {
681
- const db = getDb();
682
- const now = Date.now();
683
- const { handleMemoryUpdate } = await import("../tools/memory/handlers.js");
684
-
685
- // Pre-seed an item in a private scope
686
- db.insert(memoryItems)
687
- .values({
688
- id: "item-private-no-cross",
689
- kind: "preference",
690
- subject: "cross-scope update reverse",
691
- statement: "Private scope secret preference",
692
- status: "active",
693
- confidence: 0.9,
694
- importance: 0.7,
695
- fingerprint: "fp-private-no-cross",
696
- verificationState: "user_confirmed",
697
- scopeId: "private-thread-abc",
698
- firstSeenAt: now,
699
- lastSeenAt: now,
700
- lastUsedAt: null,
701
- })
702
- .run();
703
-
704
- // Attempt to update from the default scope — should fail with "not found"
705
- const result = await handleMemoryUpdate(
706
- {
707
- memory_id: "item-private-no-cross",
708
- statement: "Overwritten from default",
709
- },
710
- DEFAULT_CONFIG,
711
- "default",
712
- );
713
- expect(result.isError).toBe(true);
714
- expect(result.content).toContain("not found");
715
-
716
- // Verify the original item is unchanged
717
- const item = db
718
- .select()
719
- .from(memoryItems)
720
- .where(eq(memoryItems.id, "item-private-no-cross"))
721
- .get();
722
- expect(item).toBeDefined();
723
- expect(item!.statement).toBe("Private scope secret preference");
724
- });
725
-
726
- test("sourceMessageRole=user items default to user_reported verificationState", () => {
727
- const db = getDb();
728
- const now = Date.now();
729
-
730
- db.insert(memoryItems)
731
- .values({
732
- id: "item-src-user",
733
- kind: "preference",
734
- subject: "editor theme",
735
- statement: "I prefer dark mode for all my editors",
736
- status: "active",
737
- confidence: 0.8,
738
- importance: 0.7,
739
- fingerprint: "fp-src-user",
740
- sourceType: "extraction",
741
- sourceMessageRole: "user",
742
- verificationState: "user_reported",
743
- firstSeenAt: now,
744
- lastSeenAt: now,
745
- })
746
- .run();
747
-
748
- const item = db
749
- .select()
750
- .from(memoryItems)
751
- .where(eq(memoryItems.id, "item-src-user"))
752
- .get();
753
- expect(item).toBeDefined();
754
- expect(item!.sourceType).toBe("extraction");
755
- expect(item!.sourceMessageRole).toBe("user");
756
- expect(item!.verificationState).toBe("user_reported");
757
- });
758
-
759
- test("sourceMessageRole=assistant items default to assistant_inferred verificationState", () => {
760
- const db = getDb();
761
- const now = Date.now();
762
-
763
- db.insert(memoryItems)
764
- .values({
765
- id: "item-src-assistant",
766
- kind: "preference",
767
- subject: "language preference",
768
- statement: "User prefers TypeScript for all projects",
769
- status: "active",
770
- confidence: 0.6,
771
- importance: 0.5,
772
- fingerprint: "fp-src-assistant",
773
- sourceType: "extraction",
774
- sourceMessageRole: "assistant",
775
- verificationState: "assistant_inferred",
776
- firstSeenAt: now,
777
- lastSeenAt: now,
778
- })
779
- .run();
780
-
781
- const item = db
782
- .select()
783
- .from(memoryItems)
784
- .where(eq(memoryItems.id, "item-src-assistant"))
785
- .get();
786
- expect(item).toBeDefined();
787
- expect(item!.sourceType).toBe("extraction");
788
- expect(item!.sourceMessageRole).toBe("assistant");
789
- expect(item!.verificationState).toBe("assistant_inferred");
790
- });
791
-
792
- test("verification state defaults to assistant_inferred for legacy rows", () => {
793
- const db = getDb();
794
- const raw = (
795
- db as unknown as {
796
- $client: {
797
- query: (q: string) => { get: (...params: unknown[]) => unknown };
798
- };
799
- }
800
- ).$client;
801
- // Simulate a legacy row without explicit verification_state
802
- raw
803
- .query(
804
- `
805
- INSERT INTO memory_items (id, kind, subject, statement, status, confidence, fingerprint, first_seen_at, last_seen_at)
806
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
807
- `,
808
- )
809
- .get(
810
- "item-legacy-verify",
811
- "fact",
812
- "Legacy item",
813
- "This is a legacy item",
814
- "active",
815
- 0.5,
816
- "fp-legacy-verify",
817
- Date.now(),
818
- Date.now(),
819
- );
820
-
821
- const item = db
822
- .select()
823
- .from(memoryItems)
824
- .where(eq(memoryItems.id, "item-legacy-verify"))
825
- .get();
826
- expect(item).toBeDefined();
827
- expect(item!.verificationState).toBe("assistant_inferred");
828
- });
829
-
830
- test("recent segment helper returns newest segments first", () => {
831
- const db = getDb();
832
- db.insert(conversations)
833
- .values({
834
- id: "conv-recent",
835
- title: null,
836
- createdAt: 2_200,
837
- updatedAt: 2_200,
838
- totalInputTokens: 0,
839
- totalOutputTokens: 0,
840
- totalEstimatedCost: 0,
841
- contextSummary: null,
842
- contextCompactedMessageCount: 0,
843
- contextCompactedAt: null,
844
- })
845
- .run();
846
- db.insert(messages)
847
- .values([
848
- {
849
- id: "msg-recent-1",
850
- conversationId: "conv-recent",
851
- role: "user",
852
- content: JSON.stringify([{ type: "text", text: "old" }]),
853
- createdAt: 2_201,
854
- },
855
- {
856
- id: "msg-recent-2",
857
- conversationId: "conv-recent",
858
- role: "user",
859
- content: JSON.stringify([{ type: "text", text: "newer" }]),
860
- createdAt: 2_202,
861
- },
862
- {
863
- id: "msg-recent-3",
864
- conversationId: "conv-recent",
865
- role: "user",
866
- content: JSON.stringify([{ type: "text", text: "newest" }]),
867
- createdAt: 2_203,
868
- },
869
- ])
870
- .run();
871
- db.run(`
872
- INSERT INTO memory_segments (
873
- id, message_id, conversation_id, role, segment_index, text, token_estimate, created_at, updated_at
874
- ) VALUES
875
- ('seg-recent-1', 'msg-recent-1', 'conv-recent', 'user', 0, 'old', 1, 2201, 2201),
876
- ('seg-recent-2', 'msg-recent-2', 'conv-recent', 'user', 0, 'newer', 1, 2202, 2202),
877
- ('seg-recent-3', 'msg-recent-3', 'conv-recent', 'user', 0, 'newest', 1, 2203, 2203)
878
- `);
879
-
880
- const recent = getRecentSegmentsForConversation("conv-recent", 2);
881
- expect(recent).toHaveLength(2);
882
- expect(recent[0]?.id).toBe("seg-recent-3");
883
- expect(recent[1]?.id).toBe("seg-recent-2");
884
- });
885
-
886
- test("explicit ollama memory embedding provider is honored without extra ollama config", async () => {
887
- const config = {
888
- ...DEFAULT_CONFIG,
889
- provider: "anthropic" as const,
890
- memory: {
891
- ...DEFAULT_CONFIG.memory,
892
- embeddings: {
893
- ...DEFAULT_CONFIG.memory.embeddings,
894
- provider: "ollama" as const,
895
- },
896
- },
897
- };
898
-
899
- const selection = await selectEmbeddingBackend(config);
900
- expect(selection.backend?.provider).toBe("ollama");
901
- expect(selection.reason).toBeNull();
902
- });
903
-
904
- test("memory backfill request resumes by default and only restarts when forced", () => {
905
- const db = getDb();
906
- const resumeJobId = requestMemoryBackfill();
907
- const forceJobId = requestMemoryBackfill(true);
908
-
909
- const resumeRow = db
910
- .select()
911
- .from(memoryJobs)
912
- .where(eq(memoryJobs.id, resumeJobId))
913
- .get();
914
- const forceRow = db
915
- .select()
916
- .from(memoryJobs)
917
- .where(eq(memoryJobs.id, forceJobId))
918
- .get();
919
-
920
- expect(resumeRow).not.toBeNull();
921
- expect(forceRow).not.toBeNull();
922
- expect(JSON.parse(resumeRow?.payload ?? "{}")).toMatchObject({
923
- force: false,
924
- });
925
- expect(JSON.parse(forceRow?.payload ?? "{}")).toMatchObject({
926
- force: true,
927
- });
928
- });
929
-
930
- test("scheduled cleanup enqueue respects throttle and config retention values", () => {
931
- const db = getDb();
932
- const originalCleanup = { ...TEST_CONFIG.memory.cleanup };
933
- TEST_CONFIG.memory.cleanup.enabled = true;
934
- TEST_CONFIG.memory.cleanup.enqueueIntervalMs = 1_000;
935
- TEST_CONFIG.memory.cleanup.supersededItemRetentionMs = 67_890;
936
-
937
- try {
938
- const first = maybeEnqueueScheduledCleanupJobs(TEST_CONFIG, 5_000);
939
- expect(first).toBe(true);
940
-
941
- const tooSoon = maybeEnqueueScheduledCleanupJobs(TEST_CONFIG, 5_500);
942
- expect(tooSoon).toBe(false);
943
-
944
- const jobsAfterFirst = db.select().from(memoryJobs).all();
945
- const supersededJob = jobsAfterFirst.find(
946
- (row) => row.type === "cleanup_stale_superseded_items",
947
- );
948
- expect(supersededJob).toBeDefined();
949
- expect(JSON.parse(supersededJob?.payload ?? "{}")).toMatchObject({
950
- retentionMs: 67_890,
951
- });
952
-
953
- const secondWindow = maybeEnqueueScheduledCleanupJobs(TEST_CONFIG, 6_500);
954
- expect(secondWindow).toBe(true);
955
- const jobsAfterSecond = db.select().from(memoryJobs).all();
956
- expect(
957
- jobsAfterSecond.filter(
958
- (row) => row.type === "cleanup_stale_superseded_items",
959
- ).length,
960
- ).toBe(1);
961
- } finally {
962
- TEST_CONFIG.memory.cleanup = originalCleanup;
963
- }
964
- });
965
-
966
- test("cleanup_stale_superseded_items removes stale superseded rows and embeddings", async () => {
967
- const db = getDb();
968
- const now = Date.now();
969
-
970
- db.insert(memoryItems)
971
- .values([
972
- {
973
- id: "cleanup-stale-item",
974
- kind: "decision",
975
- subject: "deploy strategy",
976
- statement: "Deploy manually every Friday.",
977
- status: "superseded",
978
- confidence: 0.7,
979
- fingerprint: "fp-cleanup-stale-item",
980
- verificationState: "assistant_inferred",
981
- scopeId: "default",
982
- firstSeenAt: now - 200_000,
983
- lastSeenAt: now - 200_000,
984
- invalidAt: now - 200_000,
985
- },
986
- {
987
- id: "cleanup-recent-item",
988
- kind: "decision",
989
- subject: "deploy strategy",
990
- statement: "Deploy continuously via CI.",
991
- status: "superseded",
992
- confidence: 0.7,
993
- fingerprint: "fp-cleanup-recent-item",
994
- verificationState: "assistant_inferred",
995
- scopeId: "default",
996
- firstSeenAt: now - 200_000,
997
- lastSeenAt: now - 200_000,
998
- invalidAt: now - 100,
999
- },
1000
- ])
1001
- .run();
1002
-
1003
- db.insert(memoryEmbeddings)
1004
- .values([
1005
- {
1006
- id: "cleanup-embed-stale",
1007
- targetType: "item",
1008
- targetId: "cleanup-stale-item",
1009
- provider: "openai",
1010
- model: "text-embedding-3-small",
1011
- dimensions: 3,
1012
- vectorBlob: vectorToBlob([0, 0, 0]),
1013
- createdAt: now - 1000,
1014
- updatedAt: now - 1000,
1015
- },
1016
- {
1017
- id: "cleanup-embed-recent",
1018
- targetType: "item",
1019
- targetId: "cleanup-recent-item",
1020
- provider: "openai",
1021
- model: "text-embedding-3-small",
1022
- dimensions: 3,
1023
- vectorBlob: vectorToBlob([0, 0, 0]),
1024
- createdAt: now - 1000,
1025
- updatedAt: now - 1000,
1026
- },
1027
- ])
1028
- .run();
1029
-
1030
- enqueueMemoryJob("cleanup_stale_superseded_items", { retentionMs: 10_000 });
1031
- const processed = await runMemoryJobsOnce();
1032
- expect(processed).toBe(1);
1033
-
1034
- const staleItem = db
1035
- .select()
1036
- .from(memoryItems)
1037
- .where(eq(memoryItems.id, "cleanup-stale-item"))
1038
- .get();
1039
- const recentItem = db
1040
- .select()
1041
- .from(memoryItems)
1042
- .where(eq(memoryItems.id, "cleanup-recent-item"))
1043
- .get();
1044
- const staleEmbedding = db
1045
- .select()
1046
- .from(memoryEmbeddings)
1047
- .where(eq(memoryEmbeddings.id, "cleanup-embed-stale"))
1048
- .get();
1049
- const recentEmbedding = db
1050
- .select()
1051
- .from(memoryEmbeddings)
1052
- .where(eq(memoryEmbeddings.id, "cleanup-embed-recent"))
1053
- .get();
1054
-
1055
- expect(staleItem).toBeUndefined();
1056
- expect(recentItem).toBeDefined();
1057
- expect(staleEmbedding).toBeUndefined();
1058
- expect(recentEmbedding).toBeDefined();
1059
- });
1060
-
1061
- test("memory admin status reports cleanup backlog and 24h throughput metrics", async () => {
1062
- const db = getDb();
1063
- const now = Date.now();
1064
- const yesterday = now - 20 * 60 * 60 * 1000;
1065
- const old = now - 40 * 60 * 60 * 1000;
1066
-
1067
- db.insert(memoryJobs)
1068
- .values([
1069
- {
1070
- id: "cleanup-status-running-superseded",
1071
- type: "cleanup_stale_superseded_items",
1072
- payload: "{}",
1073
- status: "running",
1074
- attempts: 0,
1075
- deferrals: 0,
1076
- runAfter: now,
1077
- lastError: null,
1078
- createdAt: now,
1079
- updatedAt: now,
1080
- },
1081
- {
1082
- id: "cleanup-status-completed-superseded-recent",
1083
- type: "cleanup_stale_superseded_items",
1084
- payload: "{}",
1085
- status: "completed",
1086
- attempts: 1,
1087
- deferrals: 0,
1088
- runAfter: yesterday,
1089
- lastError: null,
1090
- createdAt: yesterday,
1091
- updatedAt: yesterday,
1092
- },
1093
- {
1094
- id: "cleanup-status-completed-superseded-old",
1095
- type: "cleanup_stale_superseded_items",
1096
- payload: "{}",
1097
- status: "completed",
1098
- attempts: 1,
1099
- deferrals: 0,
1100
- runAfter: old,
1101
- lastError: null,
1102
- createdAt: old,
1103
- updatedAt: old,
1104
- },
1105
- ])
1106
- .run();
1107
-
1108
- const status = await getMemorySystemStatus();
1109
- expect(status.cleanup.supersededBacklog).toBe(1);
1110
- expect(status.cleanup.supersededCompleted24h).toBe(1);
1111
- });
1112
-
1113
- test("requestMemoryCleanup queues cleanup job", () => {
1114
- const db = getDb();
1115
- const queued = requestMemoryCleanup(9_999);
1116
- expect(queued.staleSupersededItemsJobId).toBeTruthy();
1117
-
1118
- const supersededRow = db
1119
- .select()
1120
- .from(memoryJobs)
1121
- .where(eq(memoryJobs.id, queued.staleSupersededItemsJobId))
1122
- .get();
1123
- expect(supersededRow?.type).toBe("cleanup_stale_superseded_items");
1124
- });
1125
-
1126
- test("memory recall token budgeting includes recall marker overhead", async () => {
1127
- const db = getDb();
1128
- const createdAt = 1_700_000_300_000;
1129
- db.insert(conversations)
1130
- .values({
1131
- id: "conv-budget",
1132
- title: null,
1133
- createdAt,
1134
- updatedAt: createdAt,
1135
- totalInputTokens: 0,
1136
- totalOutputTokens: 0,
1137
- totalEstimatedCost: 0,
1138
- contextSummary: null,
1139
- contextCompactedMessageCount: 0,
1140
- contextCompactedAt: null,
1141
- })
1142
- .run();
1143
- db.insert(messages)
1144
- .values({
1145
- id: "msg-budget",
1146
- conversationId: "conv-budget",
1147
- role: "user",
1148
- content: JSON.stringify([
1149
- { type: "text", text: "remember budget token sentinel" },
1150
- ]),
1151
- createdAt,
1152
- })
1153
- .run();
1154
- db.run(`
1155
- INSERT INTO memory_segments (
1156
- id, message_id, conversation_id, role, segment_index, text, token_estimate, created_at, updated_at
1157
- ) VALUES (
1158
- 'seg-budget', 'msg-budget', 'conv-budget', 'user', 0, 'remember budget token sentinel', 6, ${createdAt}, ${createdAt}
1159
- )
1160
- `);
1161
-
1162
- const candidateLine =
1163
- "- <kind>segment:seg-budget</kind> remember budget token sentinel";
1164
- const lineOnlyTokens = estimateTextTokens(candidateLine);
1165
- const fullRecallTokens = estimateTextTokens(
1166
- '<memory source="long_term_memory" confidence="approximate">\n' +
1167
- `## Relevant Context\n${candidateLine}\n</memory>`,
1168
- );
1169
- expect(fullRecallTokens).toBeGreaterThan(lineOnlyTokens);
1170
-
1171
- const config = {
1172
- ...DEFAULT_CONFIG,
1173
- memory: {
1174
- ...DEFAULT_CONFIG.memory,
1175
- embeddings: {
1176
- ...DEFAULT_CONFIG.memory.embeddings,
1177
- required: false,
1178
- },
1179
- retrieval: {
1180
- ...DEFAULT_CONFIG.memory.retrieval,
1181
- maxInjectTokens: lineOnlyTokens,
1182
- },
1183
- },
1184
- };
1185
-
1186
- const recall = await buildMemoryRecall(
1187
- "budget sentinel",
1188
- "conv-budget",
1189
- config,
1190
- );
1191
- expect(recall.injectedText).toBe("");
1192
- expect(recall.injectedTokens).toBe(0);
1193
- });
1194
-
1195
- test("memory recall respects maxInjectTokensOverride when provided", async () => {
1196
- const db = getDb();
1197
- const createdAt = 1_700_000_301_000;
1198
- db.insert(conversations)
1199
- .values({
1200
- id: "conv-budget-override",
1201
- title: null,
1202
- createdAt,
1203
- updatedAt: createdAt,
1204
- totalInputTokens: 0,
1205
- totalOutputTokens: 0,
1206
- totalEstimatedCost: 0,
1207
- contextSummary: null,
1208
- contextCompactedMessageCount: 0,
1209
- contextCompactedAt: null,
1210
- })
1211
- .run();
1212
-
1213
- for (let i = 0; i < 4; i++) {
1214
- const msgId = `msg-budget-override-${i}`;
1215
- const segId = `seg-budget-override-${i}`;
1216
- const text = `budget override sentinel item ${i} with enough text to exceed tiny limits`;
1217
- db.insert(messages)
1218
- .values({
1219
- id: msgId,
1220
- conversationId: "conv-budget-override",
1221
- role: "user",
1222
- content: JSON.stringify([{ type: "text", text }]),
1223
- createdAt: createdAt + i,
1224
- })
1225
- .run();
1226
- db.run(`
1227
- INSERT INTO memory_segments (
1228
- id, message_id, conversation_id, role, segment_index, text, token_estimate, created_at, updated_at
1229
- ) VALUES (
1230
- '${segId}', '${msgId}', 'conv-budget-override', 'user', 0, '${text}', 20, ${
1231
- createdAt + i
1232
- }, ${createdAt + i}
1233
- )
1234
- `);
1235
- }
1236
-
1237
- const config = {
1238
- ...DEFAULT_CONFIG,
1239
- memory: {
1240
- ...DEFAULT_CONFIG.memory,
1241
- embeddings: {
1242
- ...DEFAULT_CONFIG.memory.embeddings,
1243
- provider: "openai" as const,
1244
- required: false,
1245
- },
1246
- retrieval: {
1247
- ...DEFAULT_CONFIG.memory.retrieval,
1248
- maxInjectTokens: 5000,
1249
- },
1250
- },
1251
- };
1252
-
1253
- const override = 120;
1254
- const recall = await buildMemoryRecall(
1255
- "budget override sentinel",
1256
- "conv-budget-override",
1257
- config,
1258
- { maxInjectTokensOverride: override },
1259
- );
1260
- expect(recall.injectedTokens).toBeLessThanOrEqual(override);
1261
- });
1262
-
1263
- test("claimMemoryJobs only returns rows it actually claimed", () => {
1264
- const db = getDb();
1265
- const jobId = enqueueMemoryJob("build_conversation_summary", {
1266
- conversationId: "conv-lock",
1267
- });
1268
- db.run(`
1269
- CREATE TEMP TRIGGER memory_jobs_claim_ignore
1270
- BEFORE UPDATE ON memory_jobs
1271
- WHEN NEW.status = 'running' AND OLD.id = '${jobId}'
1272
- BEGIN
1273
- SELECT RAISE(IGNORE);
1274
- END;
1275
- `);
1276
-
1277
- try {
1278
- const claimed = claimMemoryJobs(10);
1279
- expect(claimed).toHaveLength(0);
1280
- const row = db
1281
- .select()
1282
- .from(memoryJobs)
1283
- .where(eq(memoryJobs.id, jobId))
1284
- .get();
1285
- expect(row?.status).toBe("pending");
1286
- } finally {
1287
- db.run("DROP TRIGGER IF EXISTS memory_jobs_claim_ignore");
1288
- }
1289
- });
1290
-
1291
- test("formatAbsoluteTime returns YYYY-MM-DD HH:mm TZ format", () => {
1292
- // Use a fixed epoch-ms value; the rendered string depends on the local timezone,
1293
- // so we verify the structural format rather than exact values.
1294
- const epochMs = 1_707_850_200_000; // 2024-02-13 in UTC
1295
- const result = formatAbsoluteTime(epochMs);
1296
-
1297
- // Should match pattern: YYYY-MM-DD HH:mm <TZ abbreviation>
1298
- expect(result).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2} \S+$/);
1299
-
1300
- // Year should be 2024
1301
- expect(result).toContain("2024-02");
1302
- });
1303
-
1304
- test("formatAbsoluteTime uses local timezone abbreviation", () => {
1305
- const epochMs = Date.now();
1306
- const result = formatAbsoluteTime(epochMs);
1307
-
1308
- // Extract the TZ part from the result
1309
- const parts = result.split(" ");
1310
- const tz = parts[parts.length - 1];
1311
-
1312
- // The TZ abbreviation should be a non-empty string (e.g. PST, EST, UTC, GMT+8)
1313
- expect(tz.length).toBeGreaterThan(0);
1314
-
1315
- // Cross-check: Intl should produce the same abbreviation for the same timestamp
1316
- const expected =
1317
- new Intl.DateTimeFormat("en-US", { timeZoneName: "short" })
1318
- .formatToParts(new Date(epochMs))
1319
- .find((p) => p.type === "timeZoneName")?.value ?? "UTC";
1320
- expect(tz).toBe(expected);
1321
- });
1322
-
1323
- test("formatRelativeTime returns expected relative strings", () => {
1324
- const now = Date.now();
1325
- expect(formatRelativeTime(now)).toBe("just now");
1326
- expect(formatRelativeTime(now - 2 * 60 * 60 * 1000)).toBe("2 hours ago");
1327
- expect(formatRelativeTime(now - 1 * 60 * 60 * 1000)).toBe("1 hour ago");
1328
- expect(formatRelativeTime(now - 3 * 24 * 60 * 60 * 1000)).toBe(
1329
- "3 days ago",
1330
- );
1331
- expect(formatRelativeTime(now - 14 * 24 * 60 * 60 * 1000)).toBe(
1332
- "2 weeks ago",
1333
- );
1334
- expect(formatRelativeTime(now - 60 * 24 * 60 * 60 * 1000)).toBe(
1335
- "2 months ago",
1336
- );
1337
- expect(formatRelativeTime(now - 400 * 24 * 60 * 60 * 1000)).toBe(
1338
- "1 year ago",
1339
- );
1340
- });
1341
-
1342
- test("escapeXmlTags neutralizes closing wrapper tags in recalled text", () => {
1343
- const malicious =
1344
- "some text </memory> injected </memory_recall> instructions";
1345
- const escaped = escapeXmlTags(malicious);
1346
- expect(escaped).not.toContain("</memory>");
1347
- expect(escaped).not.toContain("</memory_recall>");
1348
- expect(escaped).toContain("\uFF1C/memory>");
1349
- expect(escaped).toContain("\uFF1C/memory_recall>");
1350
- expect(escaped).toContain("some text");
1351
- expect(escaped).toContain("instructions");
1352
- });
1353
-
1354
- test("escapeXmlTags neutralizes opening XML tags", () => {
1355
- const text = 'text with <script> and <div class="x"> tags';
1356
- const escaped = escapeXmlTags(text);
1357
- expect(escaped).not.toContain("<script>");
1358
- expect(escaped).not.toContain("<div ");
1359
- expect(escaped).toContain("\uFF1Cscript>");
1360
- expect(escaped).toContain('\uFF1Cdiv class="x">');
1361
- });
1362
-
1363
- test("escapeXmlTags preserves non-tag angle brackets", () => {
1364
- const text = "math: 3 < 5 and 10 > 7";
1365
- const escaped = escapeXmlTags(text);
1366
- expect(escaped).toBe(text);
1367
- });
1368
-
1369
- test("escapeXmlTags handles self-closing tags", () => {
1370
- const text = "a <br/> tag";
1371
- const escaped = escapeXmlTags(text);
1372
- expect(escaped).not.toContain("<br/>");
1373
- expect(escaped).toContain("\uFF1Cbr/>");
1374
- });
1375
-
1376
- test("sweepStaleItems marks deeply stale items as invalid", () => {
1377
- const db = getDb();
1378
- const now = Date.now();
1379
- const MS_PER_DAY = 86_400_000;
1380
-
1381
- // Item 100 days old with kind=event (default maxAgeDays=30, so 2x=60 — past the deep-stale threshold)
1382
- db.insert(memoryItems)
1383
- .values({
1384
- id: "item-deeply-stale",
1385
- kind: "event",
1386
- subject: "sweep test",
1387
- statement: "Old event that should be swept",
1388
- status: "active",
1389
- confidence: 0.8,
1390
- importance: 0.5,
1391
- fingerprint: "fp-sweep-stale",
1392
- firstSeenAt: now - 100 * MS_PER_DAY,
1393
- lastSeenAt: now - 100 * MS_PER_DAY,
1394
- accessCount: 0,
1395
- verificationState: "assistant_inferred",
1396
- })
1397
- .run();
1398
-
1399
- // Fresh event item — should NOT be swept
1400
- db.insert(memoryItems)
1401
- .values({
1402
- id: "item-sweep-fresh",
1403
- kind: "event",
1404
- subject: "sweep test",
1405
- statement: "Recent event that should not be swept",
1406
- status: "active",
1407
- confidence: 0.8,
1408
- importance: 0.5,
1409
- fingerprint: "fp-sweep-fresh",
1410
- firstSeenAt: now - 5 * MS_PER_DAY,
1411
- lastSeenAt: now - 5 * MS_PER_DAY,
1412
- accessCount: 0,
1413
- verificationState: "assistant_inferred",
1414
- })
1415
- .run();
1416
-
1417
- const marked = sweepStaleItems(DEFAULT_CONFIG);
1418
- expect(marked).toBeGreaterThanOrEqual(1);
1419
-
1420
- const staleItem = db
1421
- .select()
1422
- .from(memoryItems)
1423
- .where(eq(memoryItems.id, "item-deeply-stale"))
1424
- .get();
1425
- expect(staleItem).toBeDefined();
1426
- expect(staleItem!.invalidAt).not.toBeNull();
1427
-
1428
- const freshItem = db
1429
- .select()
1430
- .from(memoryItems)
1431
- .where(eq(memoryItems.id, "item-sweep-fresh"))
1432
- .get();
1433
- expect(freshItem).toBeDefined();
1434
- expect(freshItem!.invalidAt).toBeNull();
1435
- });
1436
-
1437
- test("sweepStaleItems shields items with recent lastUsedAt", () => {
1438
- const db = getDb();
1439
- const now = Date.now();
1440
- const MS_PER_DAY = 86_400_000;
1441
-
1442
- // Old event (100 days) but recently retrieved (lastUsedAt = 2 days ago)
1443
- // reinforcementShieldDays defaults to 14, so this should be shielded
1444
- db.insert(memoryItems)
1445
- .values({
1446
- id: "item-sweep-shielded",
1447
- kind: "event",
1448
- subject: "sweep shield test",
1449
- statement: "Old event that was recently used",
1450
- status: "active",
1451
- confidence: 0.8,
1452
- importance: 0.5,
1453
- fingerprint: "fp-sweep-shielded",
1454
- firstSeenAt: now - 100 * MS_PER_DAY,
1455
- lastSeenAt: now - 100 * MS_PER_DAY,
1456
- lastUsedAt: now - 2 * MS_PER_DAY,
1457
- accessCount: 3,
1458
- verificationState: "assistant_inferred",
1459
- })
1460
- .run();
1461
-
1462
- const marked = sweepStaleItems(DEFAULT_CONFIG);
1463
-
1464
- // Sweep ran but shielded item was not marked — should return 0
1465
- expect(marked).toBe(0);
1466
-
1467
- const shieldedItem = db
1468
- .select()
1469
- .from(memoryItems)
1470
- .where(eq(memoryItems.id, "item-sweep-shielded"))
1471
- .get();
1472
- expect(shieldedItem).toBeDefined();
1473
- expect(shieldedItem!.invalidAt).toBeNull();
1474
- });
1475
-
1476
- test("scope columns: memory items default to scope_id=default", () => {
1477
- const db = getDb();
1478
- const now = Date.now();
1479
-
1480
- db.insert(memoryItems)
1481
- .values({
1482
- id: "item-scope-default",
1483
- kind: "fact",
1484
- subject: "scope test",
1485
- statement: "This item should have default scope",
1486
- status: "active",
1487
- confidence: 0.8,
1488
- importance: 0.5,
1489
- fingerprint: "fp-scope-default",
1490
- firstSeenAt: now,
1491
- lastSeenAt: now,
1492
- accessCount: 0,
1493
- verificationState: "user_confirmed",
1494
- })
1495
- .run();
1496
-
1497
- const item = db
1498
- .select()
1499
- .from(memoryItems)
1500
- .where(eq(memoryItems.id, "item-scope-default"))
1501
- .get();
1502
- expect(item).toBeDefined();
1503
- expect(item!.scopeId).toBe("default");
1504
- });
1505
-
1506
- test("scope columns: memory items can be inserted with explicit scope_id", () => {
1507
- const db = getDb();
1508
- const now = Date.now();
1509
-
1510
- db.insert(memoryItems)
1511
- .values({
1512
- id: "item-scope-custom",
1513
- kind: "fact",
1514
- subject: "scope test",
1515
- statement: "This item has a custom scope",
1516
- status: "active",
1517
- confidence: 0.8,
1518
- importance: 0.5,
1519
- fingerprint: "fp-scope-custom",
1520
- scopeId: "project-abc",
1521
- firstSeenAt: now,
1522
- lastSeenAt: now,
1523
- accessCount: 0,
1524
- verificationState: "user_confirmed",
1525
- })
1526
- .run();
1527
-
1528
- const item = db
1529
- .select()
1530
- .from(memoryItems)
1531
- .where(eq(memoryItems.id, "item-scope-custom"))
1532
- .get();
1533
- expect(item).toBeDefined();
1534
- expect(item!.scopeId).toBe("project-abc");
1535
- });
1536
-
1537
- test("scope columns: segments get scopeId from indexer input", async () => {
1538
- const db = getDb();
1539
- const now = Date.now();
1540
-
1541
- db.insert(conversations)
1542
- .values({
1543
- id: "conv-scope-test",
1544
- title: null,
1545
- createdAt: now,
1546
- updatedAt: now,
1547
- totalInputTokens: 0,
1548
- totalOutputTokens: 0,
1549
- totalEstimatedCost: 0,
1550
- contextSummary: null,
1551
- contextCompactedMessageCount: 0,
1552
- contextCompactedAt: null,
1553
- })
1554
- .run();
1555
- db.insert(messages)
1556
- .values({
1557
- id: "msg-scope-test",
1558
- conversationId: "conv-scope-test",
1559
- role: "user",
1560
- content: JSON.stringify([
1561
- {
1562
- type: "text",
1563
- text: "Remember my scope preference for organizing projects by team and priority level.",
1564
- },
1565
- ]),
1566
- createdAt: now,
1567
- })
1568
- .run();
1569
-
1570
- await indexMessageNow(
1571
- {
1572
- messageId: "msg-scope-test",
1573
- conversationId: "conv-scope-test",
1574
- role: "user",
1575
- content: JSON.stringify([
1576
- {
1577
- type: "text",
1578
- text: "Remember my scope preference for organizing projects by team and priority level.",
1579
- },
1580
- ]),
1581
- createdAt: now,
1582
- scopeId: "project-xyz",
1583
- },
1584
- DEFAULT_CONFIG.memory,
1585
- );
1586
-
1587
- const segments = db
1588
- .select()
1589
- .from(memorySegments)
1590
- .where(eq(memorySegments.messageId, "msg-scope-test"))
1591
- .all();
1592
- expect(segments.length).toBeGreaterThan(0);
1593
- for (const seg of segments) {
1594
- expect(seg.scopeId).toBe("project-xyz");
1595
- }
1596
- });
1597
-
1598
- test("scope filtering: retrieval excludes items from other scopes", async () => {
1599
- const db = getDb();
1600
- const now = Date.now();
1601
- const convId = "conv-scope-filter";
1602
-
1603
- db.insert(conversations)
1604
- .values({
1605
- id: convId,
1606
- title: null,
1607
- createdAt: now,
1608
- updatedAt: now,
1609
- totalInputTokens: 0,
1610
- totalOutputTokens: 0,
1611
- totalEstimatedCost: 0,
1612
- contextSummary: null,
1613
- contextCompactedMessageCount: 0,
1614
- contextCompactedAt: null,
1615
- })
1616
- .run();
1617
- db.insert(messages)
1618
- .values({
1619
- id: "msg-scope-filter",
1620
- conversationId: convId,
1621
- role: "user",
1622
- content: JSON.stringify([{ type: "text", text: "scope test" }]),
1623
- createdAt: now,
1624
- })
1625
- .run();
1626
-
1627
- // Insert segment in scope "project-a"
1628
- db.run(`
1629
- INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
1630
- VALUES ('seg-scope-a', 'msg-scope-filter', '${convId}', 'user', 0, 'The quick brown fox jumps over the lazy dog in project alpha', 12, 'project-a', ${now}, ${now})
1631
- `);
1632
-
1633
- // Insert segment in scope "project-b"
1634
- db.run(`
1635
- INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
1636
- VALUES ('seg-scope-b', 'msg-scope-filter', '${convId}', 'user', 1, 'The quick brown fox jumps over the lazy dog in project beta', 12, 'project-b', ${now}, ${now})
1637
- `);
1638
-
1639
- // Insert item in scope "project-a"
1640
- db.insert(memoryItems)
1641
- .values({
1642
- id: "item-scope-a",
1643
- kind: "fact",
1644
- subject: "fox",
1645
- statement: "The fox is quick and brown in project alpha",
1646
- status: "active",
1647
- confidence: 0.9,
1648
- importance: 0.8,
1649
- fingerprint: "fp-scope-a",
1650
- verificationState: "user_confirmed",
1651
- scopeId: "project-a",
1652
- firstSeenAt: now,
1653
- lastSeenAt: now,
1654
- })
1655
- .run();
1656
-
1657
- // Insert item in scope "project-b"
1658
- db.insert(memoryItems)
1659
- .values({
1660
- id: "item-scope-b",
1661
- kind: "fact",
1662
- subject: "fox",
1663
- statement: "The fox is quick and brown in project beta",
1664
- status: "active",
1665
- confidence: 0.9,
1666
- importance: 0.8,
1667
- fingerprint: "fp-scope-b",
1668
- verificationState: "user_confirmed",
1669
- scopeId: "project-b",
1670
- firstSeenAt: now,
1671
- lastSeenAt: now,
1672
- })
1673
- .run();
1674
-
1675
- // Query with scopeId "project-a" — should only find project-a items
1676
- const config = {
1677
- ...TEST_CONFIG,
1678
- memory: {
1679
- ...TEST_CONFIG.memory,
1680
- embeddings: { ...TEST_CONFIG.memory.embeddings, required: false },
1681
- },
1682
- };
1683
- const result = await buildMemoryRecall("quick brown fox", convId, config, {
1684
- scopeId: "project-a",
1685
- });
1686
-
1687
- // Qdrant is mocked empty; no candidates pass tier classification, so topCandidates is empty.
1688
- expect(result.enabled).toBe(true);
1689
- });
1690
-
1691
- test("scope filtering: allow_global_fallback includes default scope", async () => {
1692
- const db = getDb();
1693
- const now = Date.now();
1694
- const convId = "conv-scope-fallback";
1695
-
1696
- db.insert(conversations)
1697
- .values({
1698
- id: convId,
1699
- title: null,
1700
- createdAt: now,
1701
- updatedAt: now,
1702
- totalInputTokens: 0,
1703
- totalOutputTokens: 0,
1704
- totalEstimatedCost: 0,
1705
- contextSummary: null,
1706
- contextCompactedMessageCount: 0,
1707
- contextCompactedAt: null,
1708
- })
1709
- .run();
1710
- db.insert(messages)
1711
- .values({
1712
- id: "msg-scope-fallback",
1713
- conversationId: convId,
1714
- role: "user",
1715
- content: JSON.stringify([{ type: "text", text: "fallback test" }]),
1716
- createdAt: now,
1717
- })
1718
- .run();
1719
-
1720
- // Insert segment in default scope
1721
- db.run(`
1722
- INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
1723
- VALUES ('seg-default-scope', 'msg-scope-fallback', '${convId}', 'user', 0, 'Universal knowledge about programming languages and paradigms', 10, 'default', ${now}, ${now})
1724
- `);
1725
-
1726
- // Insert segment in custom scope
1727
- db.run(`
1728
- INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
1729
- VALUES ('seg-custom-scope', 'msg-scope-fallback', '${convId}', 'user', 1, 'Project-specific knowledge about programming languages and paradigms', 10, 'my-project', ${now}, ${now})
1730
- `);
1731
-
1732
- // With allow_global_fallback (the default), querying with scopeId "my-project"
1733
- // should include both "my-project" and "default" scope items
1734
- const config = {
1735
- ...TEST_CONFIG,
1736
- memory: {
1737
- ...TEST_CONFIG.memory,
1738
- embeddings: { ...TEST_CONFIG.memory.embeddings, required: false },
1739
- },
1740
- };
1741
- const result = await buildMemoryRecall(
1742
- "programming languages",
1743
- convId,
1744
- config,
1745
- { scopeId: "my-project" },
1746
- );
1747
-
1748
- // With allow_global_fallback, semantic search includes both scopes.
1749
- expect(result.enabled).toBe(true);
1750
- });
1751
-
1752
- test("scope filtering: strict policy excludes default scope", async () => {
1753
- const db = getDb();
1754
- const now = Date.now();
1755
- const convId = "conv-scope-strict";
1756
-
1757
- db.insert(conversations)
1758
- .values({
1759
- id: convId,
1760
- title: null,
1761
- createdAt: now,
1762
- updatedAt: now,
1763
- totalInputTokens: 0,
1764
- totalOutputTokens: 0,
1765
- totalEstimatedCost: 0,
1766
- contextSummary: null,
1767
- contextCompactedMessageCount: 1,
1768
- contextCompactedAt: null,
1769
- })
1770
- .run();
1771
- db.insert(messages)
1772
- .values({
1773
- id: "msg-scope-strict",
1774
- conversationId: convId,
1775
- role: "user",
1776
- content: JSON.stringify([{ type: "text", text: "strict test" }]),
1777
- createdAt: now,
1778
- })
1779
- .run();
1780
-
1781
- // Insert segment in default scope
1782
- db.run(`
1783
- INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
1784
- VALUES ('seg-strict-default', 'msg-scope-strict', '${convId}', 'user', 0, 'Global memory about database optimization techniques', 8, 'default', ${now}, ${now})
1785
- `);
1786
-
1787
- // Insert segment in custom scope
1788
- db.run(`
1789
- INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
1790
- VALUES ('seg-strict-custom', 'msg-scope-strict', '${convId}', 'user', 1, 'Project-specific memory about database optimization techniques', 8, 'strict-project', ${now}, ${now})
1791
- `);
1792
-
1793
- // Mock Qdrant to return both segments as semantic hits
1794
- mockQdrantResults = [
1795
- {
1796
- id: "emb-strict-default",
1797
- score: 0.9,
1798
- payload: {
1799
- target_type: "segment",
1800
- target_id: "seg-strict-default",
1801
- text: "Global memory about database optimization techniques",
1802
- conversation_id: convId,
1803
- message_id: "msg-scope-strict",
1804
- created_at: now,
1805
- },
1806
- },
1807
- {
1808
- id: "emb-strict-custom",
1809
- score: 0.85,
1810
- payload: {
1811
- target_type: "segment",
1812
- target_id: "seg-strict-custom",
1813
- text: "Project-specific memory about database optimization techniques",
1814
- conversation_id: convId,
1815
- message_id: "msg-scope-strict",
1816
- created_at: now,
1817
- },
1818
- },
1819
- ];
1820
-
1821
- // With strict policy, querying with scopeId should only include that scope
1822
- const strictConfig = {
1823
- ...TEST_CONFIG,
1824
- memory: {
1825
- ...TEST_CONFIG.memory,
1826
- embeddings: { ...TEST_CONFIG.memory.embeddings, required: false },
1827
- retrieval: {
1828
- ...TEST_CONFIG.memory.retrieval,
1829
- scopePolicy: "strict" as const,
1830
- },
1831
- },
1832
- };
1833
-
1834
- const result = await buildMemoryRecall(
1835
- "database optimization",
1836
- convId,
1837
- strictConfig,
1838
- { scopeId: "strict-project" },
1839
- );
1840
-
1841
- // With strict policy, only "strict-project" scope segments should be found.
1842
- // The default scope segment should be excluded.
1843
- // Assert the returned candidate is specifically from the strict-project scope,
1844
- // not the default scope segment (privacy boundary check).
1845
- expect(result.topCandidates.length).toBe(1);
1846
- expect(result.topCandidates[0].key).toBe("segment:seg-strict-custom");
1847
- expect(result.injectedText).toContain("Project-specific memory");
1848
- expect(result.injectedText).not.toContain("Global memory");
1849
- });
1850
-
1851
- test("scope columns: summaries default to scope_id=default", () => {
1852
- const db = getDb();
1853
- const now = Date.now();
1854
-
1855
- db.insert(memorySummaries)
1856
- .values({
1857
- id: "summary-scope-test",
1858
- scope: "weekly_global",
1859
- scopeKey: "2025-W01",
1860
- summary: "Test summary for scope",
1861
- tokenEstimate: 10,
1862
- startAt: now - 7 * 86_400_000,
1863
- endAt: now,
1864
- createdAt: now,
1865
- updatedAt: now,
1866
- })
1867
- .run();
1868
-
1869
- const summary = db
1870
- .select()
1871
- .from(memorySummaries)
1872
- .where(eq(memorySummaries.id, "summary-scope-test"))
1873
- .get();
1874
- expect(summary).toBeDefined();
1875
- expect(summary!.scopeId).toBe("default");
1876
- });
1877
-
1878
- test("scopePolicyOverride with fallbackToDefault includes both scopes even when global policy is strict", async () => {
1879
- const db = getDb();
1880
- const now = Date.now();
1881
- const convId = "conv-scope-override-fallback";
1882
-
1883
- db.insert(conversations)
1884
- .values({
1885
- id: convId,
1886
- title: null,
1887
- createdAt: now,
1888
- updatedAt: now,
1889
- totalInputTokens: 0,
1890
- totalOutputTokens: 0,
1891
- totalEstimatedCost: 0,
1892
- contextSummary: null,
1893
- contextCompactedMessageCount: 0,
1894
- contextCompactedAt: null,
1895
- })
1896
- .run();
1897
- db.insert(messages)
1898
- .values({
1899
- id: "msg-override-fallback",
1900
- conversationId: convId,
1901
- role: "user",
1902
- content: JSON.stringify([
1903
- { type: "text", text: "override fallback test" },
1904
- ]),
1905
- createdAt: now,
1906
- })
1907
- .run();
1908
-
1909
- // Insert segment in default scope
1910
- db.run(`
1911
- INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
1912
- VALUES ('seg-ovr-default', 'msg-override-fallback', '${convId}', 'user', 0, 'Global memory about microservices architecture patterns', 10, 'default', ${now}, ${now})
1913
- `);
1914
-
1915
- // Insert segment in private conversation scope
1916
- db.run(`
1917
- INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
1918
- VALUES ('seg-ovr-private', 'msg-override-fallback', '${convId}', 'user', 1, 'Private thread memory about microservices architecture patterns', 10, 'private-thread-42', ${now}, ${now})
1919
- `);
1920
-
1921
- // Global policy is strict, but override requests fallback to default
1922
- const strictConfig = {
1923
- ...TEST_CONFIG,
1924
- memory: {
1925
- ...TEST_CONFIG.memory,
1926
- embeddings: { ...TEST_CONFIG.memory.embeddings, required: false },
1927
- retrieval: {
1928
- ...TEST_CONFIG.memory.retrieval,
1929
- scopePolicy: "strict" as const,
1930
- },
1931
- },
1932
- };
1933
-
1934
- const result = await buildMemoryRecall(
1935
- "microservices architecture",
1936
- convId,
1937
- strictConfig,
1938
- {
1939
- scopePolicyOverride: {
1940
- scopeId: "private-thread-42",
1941
- fallbackToDefault: true,
1942
- },
1943
- },
1944
- );
1945
-
1946
- // Override with fallbackToDefault=true should include both
1947
- // "private-thread-42" and "default" scopes, despite strict global policy.
1948
- expect(result.enabled).toBe(true);
1949
- });
1950
-
1951
- test("scopePolicyOverride without fallback excludes default scope even when global policy is allow_global_fallback", async () => {
1952
- const db = getDb();
1953
- const now = Date.now();
1954
- const convId = "conv-scope-override-nofallback";
1955
-
1956
- db.insert(conversations)
1957
- .values({
1958
- id: convId,
1959
- title: null,
1960
- createdAt: now,
1961
- updatedAt: now,
1962
- totalInputTokens: 0,
1963
- totalOutputTokens: 0,
1964
- totalEstimatedCost: 0,
1965
- contextSummary: null,
1966
- contextCompactedMessageCount: 0,
1967
- contextCompactedAt: null,
1968
- })
1969
- .run();
1970
- db.insert(messages)
1971
- .values({
1972
- id: "msg-override-nofallback",
1973
- conversationId: convId,
1974
- role: "user",
1975
- content: JSON.stringify([
1976
- { type: "text", text: "override no fallback" },
1977
- ]),
1978
- createdAt: now,
1979
- })
1980
- .run();
1981
-
1982
- // Insert segment in default scope
1983
- db.run(`
1984
- INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
1985
- VALUES ('seg-ovr-nf-default', 'msg-override-nofallback', '${convId}', 'user', 0, 'Global memory about container orchestration strategies', 10, 'default', ${now}, ${now})
1986
- `);
1987
-
1988
- // Insert segment in isolated scope
1989
- db.run(`
1990
- INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
1991
- VALUES ('seg-ovr-nf-isolated', 'msg-override-nofallback', '${convId}', 'user', 1, 'Isolated memory about container orchestration strategies', 10, 'isolated-scope', ${now}, ${now})
1992
- `);
1993
-
1994
- // Global policy allows fallback, but override says no fallback
1995
- const fallbackConfig = {
1996
- ...TEST_CONFIG,
1997
- memory: {
1998
- ...TEST_CONFIG.memory,
1999
- embeddings: { ...TEST_CONFIG.memory.embeddings, required: false },
2000
- retrieval: {
2001
- ...TEST_CONFIG.memory.retrieval,
2002
- scopePolicy: "allow_global_fallback" as const,
2003
- },
2004
- },
2005
- };
2006
-
2007
- const result = await buildMemoryRecall(
2008
- "container orchestration",
2009
- convId,
2010
- fallbackConfig,
2011
- {
2012
- scopePolicyOverride: {
2013
- scopeId: "isolated-scope",
2014
- fallbackToDefault: false,
2015
- },
2016
- },
2017
- );
2018
-
2019
- // Override disables fallback — only isolated scope segments found.
2020
- expect(result.enabled).toBe(true);
2021
- });
2022
-
2023
- test("scopePolicyOverride takes precedence over scopeId option", async () => {
2024
- const db = getDb();
2025
- const now = Date.now();
2026
- const convId = "conv-scope-override-precedence";
2027
-
2028
- db.insert(conversations)
2029
- .values({
2030
- id: convId,
2031
- title: null,
2032
- createdAt: now,
2033
- updatedAt: now,
2034
- totalInputTokens: 0,
2035
- totalOutputTokens: 0,
2036
- totalEstimatedCost: 0,
2037
- contextSummary: null,
2038
- contextCompactedMessageCount: 1,
2039
- contextCompactedAt: null,
2040
- })
2041
- .run();
2042
- db.insert(messages)
2043
- .values({
2044
- id: "msg-override-precedence",
2045
- conversationId: convId,
2046
- role: "user",
2047
- content: JSON.stringify([{ type: "text", text: "precedence test" }]),
2048
- createdAt: now,
2049
- })
2050
- .run();
2051
-
2052
- // Insert segment in scope-a (what scopeId would resolve to)
2053
- db.run(`
2054
- INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
2055
- VALUES ('seg-ovr-prec-a', 'msg-override-precedence', '${convId}', 'user', 0, 'Scope A memory about distributed caching patterns', 10, 'scope-a', ${now}, ${now})
2056
- `);
2057
-
2058
- // Insert segment in scope-b (what the override targets)
2059
- db.run(`
2060
- INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
2061
- VALUES ('seg-ovr-prec-b', 'msg-override-precedence', '${convId}', 'user', 1, 'Scope B memory about distributed caching patterns', 10, 'scope-b', ${now}, ${now})
2062
- `);
2063
-
2064
- // Mock Qdrant to return both segments
2065
- mockQdrantResults = [
2066
- {
2067
- id: "emb-ovr-prec-a",
2068
- score: 0.9,
2069
- payload: {
2070
- target_type: "segment",
2071
- target_id: "seg-ovr-prec-a",
2072
- text: "Scope A memory about distributed caching patterns",
2073
- conversation_id: convId,
2074
- message_id: "msg-override-precedence",
2075
- created_at: now,
2076
- },
2077
- },
2078
- {
2079
- id: "emb-ovr-prec-b",
2080
- score: 0.85,
2081
- payload: {
2082
- target_type: "segment",
2083
- target_id: "seg-ovr-prec-b",
2084
- text: "Scope B memory about distributed caching patterns",
2085
- conversation_id: convId,
2086
- message_id: "msg-override-precedence",
2087
- created_at: now,
2088
- },
2089
- },
2090
- ];
2091
-
2092
- const config = {
2093
- ...TEST_CONFIG,
2094
- memory: {
2095
- ...TEST_CONFIG.memory,
2096
- embeddings: { ...TEST_CONFIG.memory.embeddings, required: false },
2097
- retrieval: {
2098
- ...TEST_CONFIG.memory.retrieval,
2099
- scopePolicy: "strict" as const,
2100
- },
2101
- },
2102
- };
2103
-
2104
- // scopeId says 'scope-a', but override says 'scope-b' — override wins
2105
- const result = await buildMemoryRecall(
2106
- "distributed caching",
2107
- convId,
2108
- config,
2109
- {
2110
- scopeId: "scope-a",
2111
- scopePolicyOverride: {
2112
- scopeId: "scope-b",
2113
- fallbackToDefault: false,
2114
- },
2115
- },
2116
- );
2117
-
2118
- // Only scope-b segment should be found (override takes precedence)
2119
- // Verify identity of the returned candidate (scope-b, not scope-a)
2120
- expect(result.injectedText).toContain("Scope B memory");
2121
- expect(result.injectedText).not.toContain("Scope A memory");
2122
- });
2123
-
2124
- test("scopePolicyOverride with default as primary scope and fallback=true returns only default", async () => {
2125
- const db = getDb();
2126
- const now = Date.now();
2127
- const convId = "conv-scope-override-default-primary";
2128
-
2129
- db.insert(conversations)
2130
- .values({
2131
- id: convId,
2132
- title: null,
2133
- createdAt: now,
2134
- updatedAt: now,
2135
- totalInputTokens: 0,
2136
- totalOutputTokens: 0,
2137
- totalEstimatedCost: 0,
2138
- contextSummary: null,
2139
- contextCompactedMessageCount: 1,
2140
- contextCompactedAt: null,
2141
- })
2142
- .run();
2143
- db.insert(messages)
2144
- .values({
2145
- id: "msg-override-default-primary",
2146
- conversationId: convId,
2147
- role: "user",
2148
- content: JSON.stringify([{ type: "text", text: "default primary" }]),
2149
- createdAt: now,
2150
- })
2151
- .run();
2152
-
2153
- // Insert segment in default scope
2154
- db.run(`
2155
- INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
2156
- VALUES ('seg-ovr-dp-default', 'msg-override-default-primary', '${convId}', 'user', 0, 'Default scope memory about event driven design', 10, 'default', ${now}, ${now})
2157
- `);
2158
-
2159
- // Insert segment in other scope
2160
- db.run(`
2161
- INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
2162
- VALUES ('seg-ovr-dp-other', 'msg-override-default-primary', '${convId}', 'user', 1, 'Other scope memory about event driven design', 10, 'other-scope', ${now}, ${now})
2163
- `);
2164
-
2165
- // Mock Qdrant to return both segments
2166
- mockQdrantResults = [
2167
- {
2168
- id: "emb-ovr-dp-default",
2169
- score: 0.9,
2170
- payload: {
2171
- target_type: "segment",
2172
- target_id: "seg-ovr-dp-default",
2173
- text: "Default scope memory about event driven design",
2174
- conversation_id: convId,
2175
- message_id: "msg-override-default-primary",
2176
- created_at: now,
2177
- },
2178
- },
2179
- {
2180
- id: "emb-ovr-dp-other",
2181
- score: 0.85,
2182
- payload: {
2183
- target_type: "segment",
2184
- target_id: "seg-ovr-dp-other",
2185
- text: "Other scope memory about event driven design",
2186
- conversation_id: convId,
2187
- message_id: "msg-override-default-primary",
2188
- created_at: now,
2189
- },
2190
- },
2191
- ];
2192
-
2193
- const config = {
2194
- ...TEST_CONFIG,
2195
- memory: {
2196
- ...TEST_CONFIG.memory,
2197
- embeddings: { ...TEST_CONFIG.memory.embeddings, required: false },
2198
- },
2199
- };
2200
-
2201
- // When primary scope IS 'default' with fallback=true, no duplication —
2202
- // just ['default'] is used
2203
- const result = await buildMemoryRecall(
2204
- "event driven design",
2205
- convId,
2206
- config,
2207
- {
2208
- scopePolicyOverride: {
2209
- scopeId: "default",
2210
- fallbackToDefault: true,
2211
- },
2212
- },
2213
- );
2214
-
2215
- // Only default scope segment should be found (other-scope excluded)
2216
- // Verify identity: default-scope segment returned, other-scope excluded
2217
- expect(result.injectedText).toContain("Default scope memory");
2218
- expect(result.injectedText).not.toContain("Other scope memory");
2219
- });
2220
-
2221
- // PR-17: addMessage() passes conversation scope to the indexer
2222
- test("addMessage inherits private conversation scope on memory segments", async () => {
2223
- const conv = createConversation({
2224
- title: "Private conversation",
2225
- conversationType: "private",
2226
- });
2227
- expect(conv.memoryScopeId).toMatch(/^private:/);
2228
-
2229
- const msg = await addMessage(
2230
- conv.id,
2231
- "user",
2232
- "My secret project details for the private conversation.",
2233
- );
2234
-
2235
- const db = getDb();
2236
- const segments = db
2237
- .select()
2238
- .from(memorySegments)
2239
- .where(eq(memorySegments.messageId, msg.id))
2240
- .all();
2241
-
2242
- expect(segments.length).toBeGreaterThan(0);
2243
- for (const seg of segments) {
2244
- expect(seg.scopeId).toBe(conv.memoryScopeId);
2245
- }
2246
- });
2247
-
2248
- test("addMessage uses default scope for standard conversations", async () => {
2249
- const conv = createConversation({
2250
- title: "Standard conversation",
2251
- conversationType: "standard",
2252
- });
2253
- expect(conv.memoryScopeId).toBe("default");
2254
-
2255
- const msg = await addMessage(
2256
- conv.id,
2257
- "user",
2258
- "Normal conversation content for testing scope defaults.",
2259
- );
2260
-
2261
- const db = getDb();
2262
- const segments = db
2263
- .select()
2264
- .from(memorySegments)
2265
- .where(eq(memorySegments.messageId, msg.id))
2266
- .all();
2267
-
2268
- expect(segments.length).toBeGreaterThan(0);
2269
- for (const seg of segments) {
2270
- expect(seg.scopeId).toBe("default");
2271
- }
2272
- });
2273
-
2274
- // PR-18: batch_extract jobs carry scopeId through the async pipeline
2275
- test("batch_extract job payload includes scopeId from private conversation", async () => {
2276
- // These tests verify job payload contents, so LLM extraction must be
2277
- // enabled — otherwise the indexer skips enqueuing batch_extract entirely.
2278
- // Set batchSize=1 so a single message triggers immediate batch_extract.
2279
- TEST_CONFIG.memory.extraction.useLLM = true;
2280
- TEST_CONFIG.memory.extraction.batchSize = 1;
2281
- try {
2282
- const conv = createConversation({
2283
- title: "Private scope job test",
2284
- conversationType: "private",
2285
- });
2286
- expect(conv.memoryScopeId).toMatch(/^private:/);
2287
-
2288
- await addMessage(
2289
- conv.id,
2290
- "user",
2291
- "Important data that should trigger extraction in private scope.",
2292
- );
2293
-
2294
- const db = getDb();
2295
- const extractJobs = db
2296
- .select()
2297
- .from(memoryJobs)
2298
- .where(eq(memoryJobs.type, "batch_extract"))
2299
- .all()
2300
- .filter(
2301
- (j) =>
2302
- JSON.parse(j.payload).conversationId === conv.id &&
2303
- JSON.parse(j.payload).scopeId != null,
2304
- );
2305
-
2306
- expect(extractJobs.length).toBeGreaterThan(0);
2307
- const lastJob = extractJobs[extractJobs.length - 1];
2308
- const payload = JSON.parse(lastJob.payload) as Record<string, unknown>;
2309
- expect(payload.scopeId).toBe(conv.memoryScopeId);
2310
- } finally {
2311
- TEST_CONFIG.memory.extraction.useLLM = false;
2312
- TEST_CONFIG.memory.extraction.batchSize = 10;
2313
- }
2314
- });
2315
-
2316
- test("batch_extract job payload defaults scopeId to default for standard conversations", async () => {
2317
- TEST_CONFIG.memory.extraction.useLLM = true;
2318
- TEST_CONFIG.memory.extraction.batchSize = 1;
2319
- try {
2320
- const conv = createConversation({
2321
- title: "Standard scope job test",
2322
- conversationType: "standard",
2323
- });
2324
- expect(conv.memoryScopeId).toBe("default");
2325
-
2326
- await addMessage(
2327
- conv.id,
2328
- "user",
2329
- "Regular content for extraction in default scope.",
2330
- );
2331
-
2332
- const db = getDb();
2333
- const extractJobs = db
2334
- .select()
2335
- .from(memoryJobs)
2336
- .where(eq(memoryJobs.type, "batch_extract"))
2337
- .all()
2338
- .filter(
2339
- (j) =>
2340
- JSON.parse(j.payload).conversationId === conv.id &&
2341
- JSON.parse(j.payload).scopeId != null,
2342
- );
2343
-
2344
- expect(extractJobs.length).toBeGreaterThan(0);
2345
- const lastJob = extractJobs[extractJobs.length - 1];
2346
- const payload = JSON.parse(lastJob.payload) as Record<string, unknown>;
2347
- expect(payload.scopeId).toBe("default");
2348
- } finally {
2349
- TEST_CONFIG.memory.extraction.useLLM = false;
2350
- TEST_CONFIG.memory.extraction.batchSize = 10;
2351
- }
2352
- });
2353
-
2354
- // PR-19: memory_save respects explicit scopeId parameter
2355
- test("handleMemorySave places items in the requested scope", async () => {
2356
- const { handleMemorySave } = await import("../tools/memory/handlers.js");
2357
-
2358
- // Save without explicit scopeId — defaults to "default"
2359
- const r1 = await handleMemorySave(
2360
- {
2361
- statement: "I prefer TypeScript over JavaScript for all new projects.",
2362
- kind: "preference",
2363
- },
2364
- DEFAULT_CONFIG,
2365
- "conv-scope-pass",
2366
- "msg-scope-pass",
2367
- );
2368
- expect(r1.isError).toBe(false);
2369
-
2370
- // Save with explicit private scopeId
2371
- const r2 = await handleMemorySave(
2372
- {
2373
- statement: "I dislike using var in JavaScript, prefer const and let.",
2374
- kind: "preference",
2375
- },
2376
- DEFAULT_CONFIG,
2377
- "conv-scope-pass-2",
2378
- "msg-scope-pass-2",
2379
- "private:thread-42",
2380
- );
2381
- expect(r2.isError).toBe(false);
2382
-
2383
- const db = getDb();
2384
- const defaultItems = db
2385
- .select()
2386
- .from(memoryItems)
2387
- .where(eq(memoryItems.scopeId, "default"))
2388
- .all();
2389
- const privateItems = db
2390
- .select()
2391
- .from(memoryItems)
2392
- .where(eq(memoryItems.scopeId, "private:thread-42"))
2393
- .all();
2394
-
2395
- expect(defaultItems.length).toBe(1);
2396
- expect(privateItems.length).toBe(1);
2397
- });
2398
-
2399
- // PR-20: same statement in different scopes produces separate active items
2400
- test("same statement in different scopes produces separate active memory items", async () => {
2401
- const { handleMemorySave } = await import("../tools/memory/handlers.js");
2402
-
2403
- const statement = "I prefer dark mode for all my editors and terminals.";
2404
-
2405
- // Save into default scope
2406
- const r1 = await handleMemorySave(
2407
- { statement, kind: "preference" },
2408
- DEFAULT_CONFIG,
2409
- "conv-scope-separate-1",
2410
- "msg-scope-default",
2411
- "default",
2412
- );
2413
- expect(r1.isError).toBe(false);
2414
-
2415
- // Save identical statement into a private scope
2416
- const r2 = await handleMemorySave(
2417
- { statement, kind: "preference" },
2418
- DEFAULT_CONFIG,
2419
- "conv-scope-separate-2",
2420
- "msg-scope-private",
2421
- "private:thread-99",
2422
- );
2423
- expect(r2.isError).toBe(false);
2424
-
2425
- const db = getDb();
2426
- // Both scopes should have separate active items
2427
- const defaultItems = db
2428
- .select()
2429
- .from(memoryItems)
2430
- .where(
2431
- and(
2432
- eq(memoryItems.scopeId, "default"),
2433
- eq(memoryItems.status, "active"),
2434
- ),
2435
- )
2436
- .all();
2437
- const privateItems = db
2438
- .select()
2439
- .from(memoryItems)
2440
- .where(
2441
- and(
2442
- eq(memoryItems.scopeId, "private:thread-99"),
2443
- eq(memoryItems.status, "active"),
2444
- ),
2445
- )
2446
- .all();
2447
-
2448
- expect(defaultItems.length).toBeGreaterThan(0);
2449
- expect(privateItems.length).toBeGreaterThan(0);
2450
-
2451
- // Scope-salted fingerprints: same content in different scopes yields distinct fingerprints
2452
- const defaultFingerprints = new Set(defaultItems.map((i) => i.fingerprint));
2453
- const matchingPrivate = privateItems.filter((i) =>
2454
- defaultFingerprints.has(i.fingerprint),
2455
- );
2456
- expect(matchingPrivate.length).toBe(0);
2457
- });
2458
-
2459
- // PR-21: identical fact in default vs private scopes gets distinct fingerprints
2460
- test("identical content in different scopes produces distinct fingerprints", async () => {
2461
- const { handleMemorySave } = await import("../tools/memory/handlers.js");
2462
-
2463
- const statement = "I prefer using Vim keybindings in all my text editors.";
2464
-
2465
- await handleMemorySave(
2466
- { statement, kind: "preference" },
2467
- DEFAULT_CONFIG,
2468
- "conv-fp-salt-1",
2469
- "msg-fp-salt-default",
2470
- "default",
2471
- );
2472
- await handleMemorySave(
2473
- { statement, kind: "preference" },
2474
- DEFAULT_CONFIG,
2475
- "conv-fp-salt-2",
2476
- "msg-fp-salt-private",
2477
- "private:fp-test",
2478
- );
2479
-
2480
- const db = getDb();
2481
- const defaultItems = db
2482
- .select()
2483
- .from(memoryItems)
2484
- .where(eq(memoryItems.scopeId, "default"))
2485
- .all()
2486
- .filter((i) => i.statement === statement);
2487
- const privateItems = db
2488
- .select()
2489
- .from(memoryItems)
2490
- .where(eq(memoryItems.scopeId, "private:fp-test"))
2491
- .all()
2492
- .filter((i) => i.statement === statement);
2493
-
2494
- expect(defaultItems.length).toBe(1);
2495
- expect(privateItems.length).toBe(1);
2496
- // Same content, different scopes — fingerprints must differ
2497
- expect(defaultItems[0].fingerprint).not.toBe(privateItems[0].fingerprint);
2498
- // But the actual content should be identical
2499
- expect(defaultItems[0].kind).toBe(privateItems[0].kind);
2500
- expect(defaultItems[0].subject).toBe(privateItems[0].subject);
2501
- expect(defaultItems[0].statement).toBe(privateItems[0].statement);
2502
- });
2503
-
2504
- // PR-20: default scope items are not affected by private scope operations
2505
- test("default scope items are not superseded by private scope operations", async () => {
2506
- const { handleMemorySave } = await import("../tools/memory/handlers.js");
2507
-
2508
- // Save a decision in the default scope
2509
- const r1 = await handleMemorySave(
2510
- {
2511
- statement: "We decided to use PostgreSQL for the production database.",
2512
- kind: "decision",
2513
- },
2514
- DEFAULT_CONFIG,
2515
- "conv-scope-isolate-1",
2516
- "msg-decision-default",
2517
- "default",
2518
- );
2519
- expect(r1.isError).toBe(false);
2520
-
2521
- const db = getDb();
2522
- const defaultBefore = db
2523
- .select()
2524
- .from(memoryItems)
2525
- .where(
2526
- and(
2527
- eq(memoryItems.scopeId, "default"),
2528
- eq(memoryItems.status, "active"),
2529
- ),
2530
- )
2531
- .all();
2532
- expect(defaultBefore.length).toBeGreaterThan(0);
2533
-
2534
- // Now save a different decision in a private scope
2535
- const r2 = await handleMemorySave(
2536
- {
2537
- statement:
2538
- "We decided to use SQLite for the production database instead.",
2539
- kind: "decision",
2540
- },
2541
- DEFAULT_CONFIG,
2542
- "conv-scope-isolate-2",
2543
- "msg-decision-private",
2544
- "private:thread-55",
2545
- );
2546
- expect(r2.isError).toBe(false);
2547
-
2548
- // The default scope items should still be active — private scope must not affect them
2549
- const defaultAfter = db
2550
- .select()
2551
- .from(memoryItems)
2552
- .where(
2553
- and(
2554
- eq(memoryItems.scopeId, "default"),
2555
- eq(memoryItems.status, "active"),
2556
- ),
2557
- )
2558
- .all();
2559
-
2560
- expect(defaultAfter.length).toBe(defaultBefore.length);
2561
- for (const item of defaultAfter) {
2562
- expect(item.status).toBe("active");
2563
- }
2564
- });
2565
-
2566
- test("private conversation summary inherits private scope_id", async () => {
2567
- const db = getDb();
2568
- const conv = createConversation({ conversationType: "private" });
2569
- const scopeId = getConversationMemoryScopeId(conv.id);
2570
- expect(scopeId).toMatch(/^private:/);
2571
-
2572
- // Insert messages and segments so the summarizer has input
2573
- db.insert(messages)
2574
- .values({
2575
- id: "msg-priv-sum-1",
2576
- conversationId: conv.id,
2577
- role: "user",
2578
- content: JSON.stringify([
2579
- { type: "text", text: "Secret project details" },
2580
- ]),
2581
- createdAt: conv.createdAt + 1,
2582
- })
2583
- .run();
2584
- db.run(`
2585
- INSERT INTO memory_segments (
2586
- id, message_id, conversation_id, role, segment_index, text,
2587
- token_estimate, scope_id, created_at, updated_at
2588
- ) VALUES (
2589
- 'seg-priv-sum-1', 'msg-priv-sum-1', '${conv.id}', 'user', 0,
2590
- 'Secret project details', 5, '${scopeId}',
2591
- ${conv.createdAt + 1}, ${conv.createdAt + 1}
2592
- )
2593
- `);
2594
-
2595
- // Run the conversation summarizer
2596
- const fakeJob = {
2597
- id: "job-priv-sum",
2598
- type: "build_conversation_summary" as const,
2599
- payload: { conversationId: conv.id },
2600
- status: "running" as const,
2601
- attempts: 0,
2602
- deferrals: 0,
2603
- runAfter: 0,
2604
- lastError: null,
2605
- startedAt: Date.now(),
2606
- createdAt: Date.now(),
2607
- updatedAt: Date.now(),
2608
- };
2609
- await buildConversationSummaryJob(fakeJob, TEST_CONFIG);
2610
-
2611
- const summary = db
2612
- .select()
2613
- .from(memorySummaries)
2614
- .where(
2615
- and(
2616
- eq(memorySummaries.scope, "conversation"),
2617
- eq(memorySummaries.scopeKey, conv.id),
2618
- ),
2619
- )
2620
- .get();
2621
-
2622
- expect(summary).toBeDefined();
2623
- expect(summary!.scopeId).toBe(scopeId);
2624
- });
2625
-
2626
- test("default-scope summary retrieval excludes private summaries", async () => {
2627
- const db = getDb();
2628
- const now = Date.now();
2629
-
2630
- // Create a private conversation and build its summary
2631
- const privConv = createConversation({ conversationType: "private" });
2632
- const privScope = getConversationMemoryScopeId(privConv.id);
2633
-
2634
- db.insert(messages)
2635
- .values({
2636
- id: "msg-scope-excl-1",
2637
- conversationId: privConv.id,
2638
- role: "user",
2639
- content: JSON.stringify([{ type: "text", text: "Private memo" }]),
2640
- createdAt: now + 1,
2641
- })
2642
- .run();
2643
- db.run(`
2644
- INSERT INTO memory_segments (
2645
- id, message_id, conversation_id, role, segment_index, text,
2646
- token_estimate, scope_id, created_at, updated_at
2647
- ) VALUES (
2648
- 'seg-scope-excl-1', 'msg-scope-excl-1', '${privConv.id}', 'user', 0,
2649
- 'Private memo', 3, '${privScope}',
2650
- ${now + 1}, ${now + 1}
2651
- )
2652
- `);
2653
-
2654
- await buildConversationSummaryJob(
2655
- {
2656
- id: "job-scope-excl-priv",
2657
- type: "build_conversation_summary" as const,
2658
- payload: { conversationId: privConv.id },
2659
- status: "running" as const,
2660
- attempts: 0,
2661
- deferrals: 0,
2662
- runAfter: 0,
2663
- lastError: null,
2664
- startedAt: now,
2665
- createdAt: now,
2666
- updatedAt: now,
2667
- },
2668
- TEST_CONFIG,
2669
- );
2670
-
2671
- // Create a standard conversation and build its summary
2672
- const stdConv = createConversation({ title: "Standard conv" });
2673
- const stdScope = getConversationMemoryScopeId(stdConv.id);
2674
- expect(stdScope).toBe("default");
2675
-
2676
- db.insert(messages)
2677
- .values({
2678
- id: "msg-scope-excl-2",
2679
- conversationId: stdConv.id,
2680
- role: "user",
2681
- content: JSON.stringify([{ type: "text", text: "Public notes" }]),
2682
- createdAt: now + 2,
2683
- })
2684
- .run();
2685
- db.run(`
2686
- INSERT INTO memory_segments (
2687
- id, message_id, conversation_id, role, segment_index, text,
2688
- token_estimate, scope_id, created_at, updated_at
2689
- ) VALUES (
2690
- 'seg-scope-excl-2', 'msg-scope-excl-2', '${stdConv.id}', 'user', 0,
2691
- 'Public notes', 3, 'default',
2692
- ${now + 2}, ${now + 2}
2693
- )
2694
- `);
2695
-
2696
- await buildConversationSummaryJob(
2697
- {
2698
- id: "job-scope-excl-std",
2699
- type: "build_conversation_summary" as const,
2700
- payload: { conversationId: stdConv.id },
2701
- status: "running" as const,
2702
- attempts: 0,
2703
- deferrals: 0,
2704
- runAfter: 0,
2705
- lastError: null,
2706
- startedAt: now,
2707
- createdAt: now,
2708
- updatedAt: now,
2709
- },
2710
- TEST_CONFIG,
2711
- );
2712
-
2713
- // Query summaries scoped to 'default' — should only include the standard one
2714
- const defaultSummaries = db
2715
- .select()
2716
- .from(memorySummaries)
2717
- .where(eq(memorySummaries.scopeId, "default"))
2718
- .all();
2719
- const privateSummaries = db
2720
- .select()
2721
- .from(memorySummaries)
2722
- .where(eq(memorySummaries.scopeId, privScope))
2723
- .all();
2724
-
2725
- expect(defaultSummaries).toHaveLength(1);
2726
- expect(defaultSummaries[0].scopeKey).toBe(stdConv.id);
2727
-
2728
- expect(privateSummaries).toHaveLength(1);
2729
- expect(privateSummaries[0].scopeKey).toBe(privConv.id);
2730
- });
2731
-
2732
- // ── End-to-end memory-boundary regression tests ─────────────────────
2733
-
2734
- test("e2e: private-only facts are recalled in private conversation but not in standard conversation", async () => {
2735
- const db = getDb();
2736
- const { handleMemorySave } = await import("../tools/memory/handlers.js");
2737
- const now = Date.now();
2738
-
2739
- // 1. Create a private conversation and save a distinctive fact
2740
- const privConv = createConversation({
2741
- title: "Private e2e test",
2742
- conversationType: "private",
2743
- });
2744
- const privScope = getConversationMemoryScopeId(privConv.id);
2745
- expect(privScope).toMatch(/^private:/);
2746
-
2747
- db.insert(messages)
2748
- .values({
2749
- id: "msg-priv-e2e-zephyr",
2750
- conversationId: privConv.id,
2751
- role: "user",
2752
- content: JSON.stringify([
2753
- {
2754
- type: "text",
2755
- text: "I prefer using the Zephyr framework for all backend microservices.",
2756
- },
2757
- ]),
2758
- createdAt: now,
2759
- })
2760
- .run();
2761
-
2762
- const r1 = await handleMemorySave(
2763
- {
2764
- statement:
2765
- "I prefer using the Zephyr framework for all backend microservices.",
2766
- kind: "preference",
2767
- },
2768
- DEFAULT_CONFIG,
2769
- privConv.id,
2770
- "msg-priv-e2e-zephyr",
2771
- privScope,
2772
- );
2773
- expect(r1.isError).toBe(false);
2774
-
2775
- // Verify items were stored with the private scope
2776
- const privateItems = db
2777
- .select()
2778
- .from(memoryItems)
2779
- .where(eq(memoryItems.scopeId, privScope))
2780
- .all();
2781
- expect(privateItems.length).toBeGreaterThan(0);
2782
- expect(
2783
- privateItems.some((i) => i.statement.toLowerCase().includes("zephyr")),
2784
- ).toBe(true);
2785
-
2786
- // Add item source (handleMemorySave doesn't create sources; semantic search requires them)
2787
- db.insert(memoryItemSources)
2788
- .values({
2789
- memoryItemId: privateItems[0].id,
2790
- messageId: "msg-priv-e2e-zephyr",
2791
- evidence: "Zephyr framework preference",
2792
- createdAt: now,
2793
- })
2794
- .run();
2795
-
2796
- // Mark the source message as compacted so the item isn't filtered
2797
- // as "already in context"
2798
- db.update(conversations)
2799
- .set({ contextCompactedMessageCount: 1 })
2800
- .where(eq(conversations.id, privConv.id))
2801
- .run();
2802
-
2803
- const privateItemKeys = privateItems.map((i) => `item:${i.id}`);
2804
-
2805
- // 2. Mock Qdrant to return the private item
2806
- mockQdrantResults = [
2807
- {
2808
- id: "emb-zephyr",
2809
- score: 0.9,
2810
- payload: {
2811
- target_type: "item",
2812
- target_id: privateItems[0].id,
2813
- text: privateItems[0].statement,
2814
- kind: "preference",
2815
- status: "active",
2816
- created_at: now,
2817
- last_seen_at: now,
2818
- },
2819
- },
2820
- ];
2821
-
2822
- // 3. Create a standard conversation
2823
- const stdConv = createConversation({
2824
- title: "Standard e2e test",
2825
- conversationType: "standard",
2826
- });
2827
- const stdScope = getConversationMemoryScopeId(stdConv.id);
2828
- expect(stdScope).toBe("default");
2829
-
2830
- db.insert(messages)
2831
- .values({
2832
- id: "msg-std-e2e-noleak",
2833
- conversationId: stdConv.id,
2834
- role: "user",
2835
- content: JSON.stringify([
2836
- { type: "text", text: "placeholder for standard conv" },
2837
- ]),
2838
- createdAt: now,
2839
- })
2840
- .run();
2841
-
2842
- const recallConfig = {
2843
- ...TEST_CONFIG,
2844
- memory: {
2845
- ...TEST_CONFIG.memory,
2846
- embeddings: { ...TEST_CONFIG.memory.embeddings, required: false },
2847
- },
2848
- };
2849
-
2850
- // 4. Private conversation recall — should find the Zephyr fact
2851
- const privRecall = await buildMemoryRecall(
2852
- "Zephyr framework microservices",
2853
- privConv.id,
2854
- recallConfig,
2855
- {
2856
- scopePolicyOverride: {
2857
- scopeId: privScope,
2858
- fallbackToDefault: true,
2859
- },
2860
- },
2861
- );
2862
- expect(privRecall.enabled).toBe(true);
2863
- const privCandidateKeys = privRecall.topCandidates.map((c) => c.key);
2864
- expect(privCandidateKeys.some((k) => privateItemKeys.includes(k))).toBe(
2865
- true,
2866
- );
2867
-
2868
- // 5. Standard conversation recall — must NOT find the Zephyr fact (no leak)
2869
- const stdRecall = await buildMemoryRecall(
2870
- "Zephyr framework microservices",
2871
- stdConv.id,
2872
- recallConfig,
2873
- {
2874
- scopeId: stdScope,
2875
- scopePolicyOverride: undefined,
2876
- },
2877
- );
2878
- const stdCandidateKeys = stdRecall.topCandidates.map((c) => c.key);
2879
- const hasZephyrInStandard = privateItemKeys.some((k) =>
2880
- stdCandidateKeys.includes(k),
2881
- );
2882
- expect(hasZephyrInStandard).toBe(false);
2883
- expect(stdRecall.injectedText.toLowerCase()).not.toContain("zephyr");
2884
- });
2885
-
2886
- test("e2e: private conversation still recalls facts from default memory scope", async () => {
2887
- const db = getDb();
2888
- const { handleMemorySave } = await import("../tools/memory/handlers.js");
2889
- const now = Date.now();
2890
-
2891
- // 1. Save a fact to default scope via a standard conversation
2892
- const stdConv = createConversation({
2893
- title: "Default scope source",
2894
- conversationType: "standard",
2895
- });
2896
- const stdScope = getConversationMemoryScopeId(stdConv.id);
2897
- expect(stdScope).toBe("default");
2898
-
2899
- db.insert(messages)
2900
- .values({
2901
- id: "msg-std-e2e-obsidian",
2902
- conversationId: stdConv.id,
2903
- role: "user",
2904
- content: JSON.stringify([
2905
- {
2906
- type: "text",
2907
- text: "I prefer using the Obsidian editor for all my note-taking workflows.",
2908
- },
2909
- ]),
2910
- createdAt: now,
2911
- })
2912
- .run();
2913
-
2914
- const r1 = await handleMemorySave(
2915
- {
2916
- statement:
2917
- "I prefer using the Obsidian editor for all my note-taking workflows.",
2918
- kind: "preference",
2919
- },
2920
- DEFAULT_CONFIG,
2921
- stdConv.id,
2922
- "msg-std-e2e-obsidian",
2923
- "default",
2924
- );
2925
- expect(r1.isError).toBe(false);
2926
-
2927
- // Verify items landed in the default scope
2928
- const defaultItems = db
2929
- .select()
2930
- .from(memoryItems)
2931
- .where(
2932
- and(
2933
- eq(memoryItems.scopeId, "default"),
2934
- eq(memoryItems.status, "active"),
2935
- ),
2936
- )
2937
- .all();
2938
- const obsidianItem = defaultItems.find((i) =>
2939
- i.statement.toLowerCase().includes("obsidian"),
2940
- );
2941
- expect(obsidianItem).toBeDefined();
2942
-
2943
- // Add item source (handleMemorySave doesn't create sources; semantic search requires them)
2944
- db.insert(memoryItemSources)
2945
- .values({
2946
- memoryItemId: obsidianItem!.id,
2947
- messageId: "msg-std-e2e-obsidian",
2948
- evidence: "Obsidian editor preference",
2949
- createdAt: now,
2950
- })
2951
- .run();
2952
-
2953
- // 2. Create a private conversation
2954
- const privConv = createConversation({
2955
- title: "Private fallback test",
2956
- conversationType: "private",
2957
- });
2958
- const privScope = getConversationMemoryScopeId(privConv.id);
2959
- expect(privScope).toMatch(/^private:/);
2960
-
2961
- db.insert(messages)
2962
- .values({
2963
- id: "msg-priv-e2e-fallback",
2964
- conversationId: privConv.id,
2965
- role: "user",
2966
- content: JSON.stringify([
2967
- { type: "text", text: "placeholder for private conv fallback test" },
2968
- ]),
2969
- createdAt: now + 1,
2970
- })
2971
- .run();
2972
-
2973
- // Mock Qdrant to return the default-scope Obsidian item
2974
- mockQdrantResults = [
2975
- {
2976
- id: "emb-obsidian",
2977
- score: 0.9,
2978
- payload: {
2979
- target_type: "item",
2980
- target_id: obsidianItem!.id,
2981
- text: obsidianItem!.statement,
2982
- kind: "preference",
2983
- status: "active",
2984
- created_at: now,
2985
- last_seen_at: now,
2986
- },
2987
- },
2988
- ];
2989
-
2990
- const recallConfig = {
2991
- ...TEST_CONFIG,
2992
- memory: {
2993
- ...TEST_CONFIG.memory,
2994
- embeddings: { ...TEST_CONFIG.memory.embeddings, required: false },
2995
- },
2996
- };
2997
-
2998
- // 3. Private conversation recall with fallback to default — should find the Obsidian fact
2999
- const privRecall = await buildMemoryRecall(
3000
- "Obsidian editor note-taking",
3001
- privConv.id,
3002
- recallConfig,
3003
- {
3004
- scopePolicyOverride: {
3005
- scopeId: privScope,
3006
- fallbackToDefault: true,
3007
- },
3008
- },
3009
- );
3010
- expect(privRecall).toBeDefined();
3011
- expect(privRecall.injectedText.toLowerCase()).toContain("obsidian");
3012
- });
3013
-
3014
- // Backfill preserves private conversation scope on memory segments
3015
- test("backfillJob preserves private conversation scope during reindex", async () => {
3016
- const db = getDb();
3017
-
3018
- // Create a private conversation with a message
3019
- const conv = createConversation({
3020
- title: "Backfill scope test",
3021
- conversationType: "private",
3022
- });
3023
- expect(conv.memoryScopeId).toMatch(/^private:/);
3024
-
3025
- // Insert a message directly (bypassing addMessage to avoid pre-indexing)
3026
- const msgId = "msg-backfill-scope-test";
3027
- db.insert(messages)
3028
- .values({
3029
- id: msgId,
3030
- conversationId: conv.id,
3031
- role: "user",
3032
- content:
3033
- "My confidential backfill test content for private conversation preservation.",
3034
- createdAt: conv.createdAt + 1,
3035
- })
3036
- .run();
3037
-
3038
- // Run the backfill job — it should look up the conversation scope
3039
- const fakeJob = {
3040
- id: "job-backfill-scope",
3041
- type: "backfill" as const,
3042
- payload: { force: true },
3043
- status: "running" as const,
3044
- attempts: 0,
3045
- deferrals: 0,
3046
- runAfter: 0,
3047
- lastError: null,
3048
- startedAt: Date.now(),
3049
- createdAt: Date.now(),
3050
- updatedAt: Date.now(),
3051
- };
3052
- await backfillJob(fakeJob, TEST_CONFIG);
3053
-
3054
- // Verify the segments were indexed with the private scope
3055
- const segments = db
3056
- .select()
3057
- .from(memorySegments)
3058
- .where(eq(memorySegments.messageId, msgId))
3059
- .all();
3060
-
3061
- expect(segments.length).toBeGreaterThan(0);
3062
- for (const seg of segments) {
3063
- expect(seg.scopeId).toBe(conv.memoryScopeId);
3064
- }
3065
- });
3066
-
3067
- test("backfillJob preserves provenance trust gating during reindex", async () => {
3068
- const db = getDb();
3069
-
3070
- const conv = createConversation("Backfill provenance trust gate");
3071
- const msgId = "msg-backfill-untrusted-provenance";
3072
- db.insert(messages)
3073
- .values({
3074
- id: msgId,
3075
- conversationId: conv.id,
3076
- role: "user",
3077
- content:
3078
- "Untrusted sender says preferences should not become durable profile memory.",
3079
- metadata: JSON.stringify({
3080
- provenanceTrustClass: "trusted_contact",
3081
- provenanceSourceChannel: "telegram",
3082
- }),
3083
- createdAt: conv.createdAt + 1,
3084
- })
3085
- .run();
3086
-
3087
- const fakeJob = {
3088
- id: "job-backfill-untrusted-provenance",
3089
- type: "backfill" as const,
3090
- payload: { force: true },
3091
- status: "running" as const,
3092
- attempts: 0,
3093
- deferrals: 0,
3094
- runAfter: 0,
3095
- lastError: null,
3096
- startedAt: Date.now(),
3097
- createdAt: Date.now(),
3098
- updatedAt: Date.now(),
3099
- };
3100
- await backfillJob(fakeJob, TEST_CONFIG);
3101
-
3102
- const segments = db
3103
- .select()
3104
- .from(memorySegments)
3105
- .where(eq(memorySegments.messageId, msgId))
3106
- .all();
3107
- expect(segments.length).toBeGreaterThan(0);
3108
-
3109
- const extractJobs = db
3110
- .select()
3111
- .from(memoryJobs)
3112
- .where(eq(memoryJobs.type, "extract_items"))
3113
- .all()
3114
- .filter((job) => JSON.parse(job.payload).messageId === msgId);
3115
- expect(extractJobs).toHaveLength(0);
3116
- });
3117
-
3118
- test("provenance fields are preserved in stored message metadata", async () => {
3119
- const conv = createConversation("provenance-preserve");
3120
- const metadata = {
3121
- userMessageChannel: "telegram" as const,
3122
- provenanceTrustClass: "trusted_contact" as const,
3123
- provenanceSourceChannel: "telegram" as const,
3124
- provenanceGuardianExternalUserId: "guardian-123",
3125
- provenanceRequesterIdentifier: "Alice",
3126
- };
3127
- const msg = await addMessage(
3128
- conv.id,
3129
- "user",
3130
- "Hello from telegram",
3131
- metadata,
3132
- );
3133
-
3134
- const db = getDb();
3135
- const stored = db
3136
- .select({ metadata: messages.metadata })
3137
- .from(messages)
3138
- .where(eq(messages.id, msg.id))
3139
- .get();
3140
-
3141
- expect(stored).toBeTruthy();
3142
- const parsed = JSON.parse(stored!.metadata!);
3143
- expect(parsed.provenanceTrustClass).toBe("trusted_contact");
3144
- expect(parsed.provenanceSourceChannel).toBe("telegram");
3145
- expect(parsed.provenanceGuardianExternalUserId).toBe("guardian-123");
3146
- expect(parsed.provenanceRequesterIdentifier).toBe("Alice");
3147
- });
3148
-
3149
- test("messageMetadataSchema validates provenance fields", () => {
3150
- const valid = messageMetadataSchema.safeParse({
3151
- provenanceTrustClass: "guardian",
3152
- provenanceSourceChannel: "vellum",
3153
- });
3154
- expect(valid.success).toBe(true);
3155
-
3156
- const validNonGuardian = messageMetadataSchema.safeParse({
3157
- provenanceTrustClass: "trusted_contact",
3158
- provenanceSourceChannel: "telegram",
3159
- provenanceGuardianExternalUserId: "g-123",
3160
- provenanceRequesterIdentifier: "Bob",
3161
- });
3162
- expect(validNonGuardian.success).toBe(true);
3163
-
3164
- const validUnverified = messageMetadataSchema.safeParse({
3165
- provenanceTrustClass: "unknown",
3166
- });
3167
- expect(validUnverified.success).toBe(true);
3168
- });
3169
-
3170
- test("provenanceFromTrustContext returns unverified_channel default when no context", () => {
3171
- const result = provenanceFromTrustContext(null);
3172
- expect(result.provenanceTrustClass).toBe("unknown");
3173
- expect(result.provenanceSourceChannel).toBeUndefined();
3174
-
3175
- const result2 = provenanceFromTrustContext(undefined);
3176
- expect(result2.provenanceTrustClass).toBe("unknown");
3177
- });
3178
-
3179
- test("provenanceFromTrustContext extracts fields from guardian context", () => {
3180
- const ctx = {
3181
- sourceChannel: "telegram" as const,
3182
- trustClass: "trusted_contact" as const,
3183
- guardianExternalUserId: "g-456",
3184
- requesterIdentifier: "Charlie",
3185
- };
3186
- const result = provenanceFromTrustContext(ctx);
3187
- expect(result.provenanceTrustClass).toBe("trusted_contact");
3188
- expect(result.provenanceSourceChannel).toBe("telegram");
3189
- expect(result.provenanceGuardianExternalUserId).toBe("g-456");
3190
- expect(result.provenanceRequesterIdentifier).toBe("Charlie");
3191
- });
3192
-
3193
- test("indexMessageNow receives provenanceTrustClass when metadata includes it", async () => {
3194
- const conv = createConversation("provenance-indexer");
3195
- const metadata = {
3196
- provenanceTrustClass: "trusted_contact" as const,
3197
- provenanceSourceChannel: "telegram" as const,
3198
- };
3199
- // addMessage parses metadata and passes provenanceTrustClass to indexMessageNow.
3200
- // We verify indirectly: the message is persisted with metadata and segments are indexed.
3201
- const msg = await addMessage(
3202
- conv.id,
3203
- "user",
3204
- "Test provenance indexing message with enough content to segment",
3205
- metadata,
3206
- );
3207
- expect(msg.id).toBeTruthy();
3208
-
3209
- // Verify segments were created (indexMessageNow was called successfully)
3210
- const segments = getDb()
3211
- .select()
3212
- .from(memorySegments)
3213
- .where(eq(memorySegments.messageId, msg.id))
3214
- .all();
3215
- expect(segments.length).toBeGreaterThan(0);
3216
- });
3217
-
3218
- // ── Trust-aware extraction gating tests (M3) ───────────────────────
3219
-
3220
- test("untrusted actor messages do not enqueue batch_extract", async () => {
3221
- const db = getDb();
3222
- const now = Date.now();
3223
- db.insert(conversations)
3224
- .values({
3225
- id: "conv-untrusted-gate",
3226
- title: null,
3227
- createdAt: now,
3228
- updatedAt: now,
3229
- totalInputTokens: 0,
3230
- totalOutputTokens: 0,
3231
- totalEstimatedCost: 0,
3232
- contextSummary: null,
3233
- contextCompactedMessageCount: 0,
3234
- contextCompactedAt: null,
3235
- })
3236
- .run();
3237
- db.insert(messages)
3238
- .values({
3239
- id: "msg-untrusted-gate",
3240
- conversationId: "conv-untrusted-gate",
3241
- role: "user",
3242
- content: JSON.stringify([
3243
- {
3244
- type: "text",
3245
- text: "Untrusted user preference for dark mode across all editor themes and interfaces.",
3246
- },
3247
- ]),
3248
- createdAt: now,
3249
- })
3250
- .run();
3251
-
3252
- const result = await indexMessageNow(
3253
- {
3254
- messageId: "msg-untrusted-gate",
3255
- conversationId: "conv-untrusted-gate",
3256
- role: "user",
3257
- content: JSON.stringify([
3258
- {
3259
- type: "text",
3260
- text: "Untrusted user preference for dark mode across all editor themes and interfaces.",
3261
- },
3262
- ]),
3263
- createdAt: now,
3264
- provenanceTrustClass: "trusted_contact",
3265
- },
3266
- DEFAULT_CONFIG.memory,
3267
- );
3268
-
3269
- expect(result.indexedSegments).toBeGreaterThan(0);
3270
-
3271
- // No batch_extract jobs should be enqueued for untrusted actors
3272
- const extractJobs = db
3273
- .select()
3274
- .from(memoryJobs)
3275
- .where(eq(memoryJobs.type, "batch_extract"))
3276
- .all()
3277
- .filter(
3278
- (j) => JSON.parse(j.payload).conversationId === "conv-untrusted-gate",
3279
- );
3280
- expect(extractJobs.length).toBe(0);
3281
-
3282
- // enqueuedJobs reflects embed jobs only (no extraction for untrusted)
3283
- expect(result.enqueuedJobs).toBe(result.indexedSegments);
3284
- });
3285
-
3286
- test("trusted guardian messages still enqueue extraction", async () => {
3287
- const db = getDb();
3288
- const now = Date.now();
3289
- db.insert(conversations)
3290
- .values({
3291
- id: "conv-trusted-gate",
3292
- title: null,
3293
- createdAt: now,
3294
- updatedAt: now,
3295
- totalInputTokens: 0,
3296
- totalOutputTokens: 0,
3297
- totalEstimatedCost: 0,
3298
- contextSummary: null,
3299
- contextCompactedMessageCount: 0,
3300
- contextCompactedAt: null,
3301
- })
3302
- .run();
3303
- db.insert(messages)
3304
- .values({
3305
- id: "msg-trusted-gate",
3306
- conversationId: "conv-trusted-gate",
3307
- role: "user",
3308
- content: JSON.stringify([
3309
- {
3310
- type: "text",
3311
- text: "Trusted guardian preference for light mode with high contrast accessibility settings.",
3312
- },
3313
- ]),
3314
- createdAt: now,
3315
- })
3316
- .run();
3317
-
3318
- const result = await indexMessageNow(
3319
- {
3320
- messageId: "msg-trusted-gate",
3321
- conversationId: "conv-trusted-gate",
3322
- role: "user",
3323
- content: JSON.stringify([
3324
- {
3325
- type: "text",
3326
- text: "Trusted guardian preference for light mode with high contrast accessibility settings.",
3327
- },
3328
- ]),
3329
- createdAt: now,
3330
- provenanceTrustClass: "guardian",
3331
- },
3332
- DEFAULT_CONFIG.memory,
3333
- );
3334
-
3335
- expect(result.indexedSegments).toBeGreaterThan(0);
3336
-
3337
- // batch_extract job should be enqueued (debounced) for trusted guardian
3338
- const extractJobs = db
3339
- .select()
3340
- .from(memoryJobs)
3341
- .where(eq(memoryJobs.type, "batch_extract"))
3342
- .all()
3343
- .filter(
3344
- (j) => JSON.parse(j.payload).conversationId === "conv-trusted-gate",
3345
- );
3346
- expect(extractJobs.length).toBe(1);
3347
- });
3348
-
3349
- test("legacy messages without provenance still enqueue extraction", async () => {
3350
- const db = getDb();
3351
- const now = Date.now();
3352
- db.insert(conversations)
3353
- .values({
3354
- id: "conv-legacy-gate",
3355
- title: null,
3356
- createdAt: now,
3357
- updatedAt: now,
3358
- totalInputTokens: 0,
3359
- totalOutputTokens: 0,
3360
- totalEstimatedCost: 0,
3361
- contextSummary: null,
3362
- contextCompactedMessageCount: 0,
3363
- contextCompactedAt: null,
3364
- })
3365
- .run();
3366
- db.insert(messages)
3367
- .values({
3368
- id: "msg-legacy-gate",
3369
- conversationId: "conv-legacy-gate",
3370
- role: "user",
3371
- content: JSON.stringify([
3372
- {
3373
- type: "text",
3374
- text: "Legacy message with no provenance info that still needs full extraction processing.",
3375
- },
3376
- ]),
3377
- createdAt: now,
3378
- })
3379
- .run();
3380
-
3381
- const result = await indexMessageNow(
3382
- {
3383
- messageId: "msg-legacy-gate",
3384
- conversationId: "conv-legacy-gate",
3385
- role: "user",
3386
- content: JSON.stringify([
3387
- {
3388
- type: "text",
3389
- text: "Legacy message with no provenance info that still needs full extraction processing.",
3390
- },
3391
- ]),
3392
- createdAt: now,
3393
- // provenanceTrustClass is intentionally omitted (undefined) to test default behavior
3394
- },
3395
- DEFAULT_CONFIG.memory,
3396
- );
3397
-
3398
- expect(result.indexedSegments).toBeGreaterThan(0);
3399
-
3400
- // batch_extract job should still be enqueued (debounced) for messages without provenance
3401
- const extractJobs = db
3402
- .select()
3403
- .from(memoryJobs)
3404
- .where(eq(memoryJobs.type, "batch_extract"))
3405
- .all()
3406
- .filter(
3407
- (j) => JSON.parse(j.payload).conversationId === "conv-legacy-gate",
3408
- );
3409
- expect(extractJobs.length).toBe(1);
3410
- });
3411
-
3412
- test("unverified_channel messages do not enqueue batch_extract", async () => {
3413
- const db = getDb();
3414
- const now = Date.now();
3415
- db.insert(conversations)
3416
- .values({
3417
- id: "conv-unverified-gate",
3418
- title: null,
3419
- createdAt: now,
3420
- updatedAt: now,
3421
- totalInputTokens: 0,
3422
- totalOutputTokens: 0,
3423
- totalEstimatedCost: 0,
3424
- contextSummary: null,
3425
- contextCompactedMessageCount: 0,
3426
- contextCompactedAt: null,
3427
- })
3428
- .run();
3429
- db.insert(messages)
3430
- .values({
3431
- id: "msg-unverified-gate",
3432
- conversationId: "conv-unverified-gate",
3433
- role: "user",
3434
- content: JSON.stringify([
3435
- {
3436
- type: "text",
3437
- text: "Unverified channel preference for compact layout with sidebar navigation always visible.",
3438
- },
3439
- ]),
3440
- createdAt: now,
3441
- })
3442
- .run();
3443
-
3444
- const result = await indexMessageNow(
3445
- {
3446
- messageId: "msg-unverified-gate",
3447
- conversationId: "conv-unverified-gate",
3448
- role: "user",
3449
- content: JSON.stringify([
3450
- {
3451
- type: "text",
3452
- text: "Unverified channel preference for compact layout with sidebar navigation always visible.",
3453
- },
3454
- ]),
3455
- createdAt: now,
3456
- provenanceTrustClass: "unknown",
3457
- },
3458
- DEFAULT_CONFIG.memory,
3459
- );
3460
-
3461
- expect(result.indexedSegments).toBeGreaterThan(0);
3462
-
3463
- // No batch_extract jobs should be enqueued for unverified channel
3464
- const extractJobs = db
3465
- .select()
3466
- .from(memoryJobs)
3467
- .where(eq(memoryJobs.type, "batch_extract"))
3468
- .all()
3469
- .filter(
3470
- (j) => JSON.parse(j.payload).conversationId === "conv-unverified-gate",
3471
- );
3472
- expect(extractJobs.length).toBe(0);
3473
-
3474
- // enqueuedJobs reflects embed jobs only (no extraction for untrusted)
3475
- expect(result.enqueuedJobs).toBe(result.indexedSegments);
3476
- });
3477
-
3478
- test("buildCoreIdentityContext includes identity files when they exist", () => {
3479
- // Create workspace directory and write prompt files
3480
- mkdirSync(testWorkspaceDir, { recursive: true });
3481
- writeFileSync(
3482
- join(testWorkspaceDir, "SOUL.md"),
3483
- "You are a helpful assistant named Jarvis.",
3484
- );
3485
- writeFileSync(
3486
- join(testWorkspaceDir, "USER.md"),
3487
- "The user's name is Aaron Levin.",
3488
- );
3489
-
3490
- const context = buildCoreIdentityContext();
3491
- expect(context).not.toBeNull();
3492
- expect(context).toContain("helpful assistant named Jarvis");
3493
- expect(context).toContain("Aaron Levin");
3494
- });
3495
-
3496
- test("buildCoreIdentityContext returns null when no prompt files exist", () => {
3497
- // Remove workspace prompt files to simulate a clean state
3498
- try {
3499
- rmSync(join(testWorkspaceDir, "SOUL.md"), { force: true });
3500
- rmSync(join(testWorkspaceDir, "IDENTITY.md"), { force: true });
3501
- rmSync(join(testWorkspaceDir, "USER.md"), { force: true });
3502
- } catch {
3503
- // files may not exist
3504
- }
3505
-
3506
- const context = buildCoreIdentityContext();
3507
- expect(context).toBeNull();
3508
- });
3509
-
3510
- // ── Inline supersession rendering tests ──────────────────────────
3511
-
3512
- test("buildMemoryInjection renders inline supersedes tag for items with supersession chain", () => {
3513
- const db = getDb();
3514
- const now = Date.now();
3515
-
3516
- // Create the superseded (predecessor) item in the DB
3517
- db.insert(memoryItems)
3518
- .values({
3519
- id: "item-predecessor-render",
3520
- kind: "preference",
3521
- subject: "color",
3522
- statement: "Favorite color is blue",
3523
- status: "active",
3524
- confidence: 0.9,
3525
- importance: 0.7,
3526
- fingerprint: "fp-pred-render",
3527
- firstSeenAt: now - 86_400_000,
3528
- lastSeenAt: now - 86_400_000,
3529
- accessCount: 1,
3530
- verificationState: "assistant_inferred",
3531
- })
3532
- .run();
3533
-
3534
- const candidate = {
3535
- key: "item:item-superseding-render",
3536
- type: "item" as const,
3537
- id: "item-superseding-render",
3538
- source: "semantic" as const,
3539
- text: "Favorite color is green",
3540
- kind: "preference",
3541
- confidence: 0.9,
3542
- importance: 0.8,
3543
- createdAt: now,
3544
- semantic: 0.9,
3545
- recency: 0.8,
3546
- finalScore: 0.85,
3547
- supersedes: "item-predecessor-render",
3548
- };
3549
-
3550
- const injection = buildMemoryInjection({
3551
- candidates: [candidate],
3552
- totalBudgetTokens: 5000,
3553
- });
3554
-
3555
- expect(injection).toContain("<supersedes count=");
3556
- expect(injection).toContain('count="1"');
3557
- expect(injection).toContain("Favorite color is blue");
3558
- expect(injection).toContain("</supersedes>");
3559
- // The supersedes tag should be inside the item tag
3560
- expect(injection).toMatch(
3561
- /<item[^>]*>.*<supersedes.*<\/supersedes><\/item>/,
3562
- );
3563
-
3564
- // Clean up
3565
- db.delete(memoryItems)
3566
- .where(eq(memoryItems.id, "item-predecessor-render"))
3567
- .run();
3568
- });
3569
-
3570
- test("lookupSupersessionChain counts chain depth correctly", () => {
3571
- const db = getDb();
3572
- const now = Date.now();
3573
- const MS_PER_DAY = 86_400_000;
3574
-
3575
- // Create a chain of 3 items: grandparent → parent → child
3576
- db.insert(memoryItems)
3577
- .values({
3578
- id: "item-chain-grandparent",
3579
- kind: "fact",
3580
- subject: "address",
3581
- statement: "Lives at 123 Main St",
3582
- status: "active",
3583
- confidence: 0.8,
3584
- importance: 0.6,
3585
- fingerprint: "fp-chain-gp",
3586
- firstSeenAt: now - 3 * MS_PER_DAY,
3587
- lastSeenAt: now - 3 * MS_PER_DAY,
3588
- accessCount: 1,
3589
- verificationState: "assistant_inferred",
3590
- })
3591
- .run();
3592
-
3593
- db.insert(memoryItems)
3594
- .values({
3595
- id: "item-chain-parent",
3596
- kind: "fact",
3597
- subject: "address",
3598
- statement: "Lives at 456 Oak Ave",
3599
- status: "active",
3600
- confidence: 0.8,
3601
- importance: 0.6,
3602
- fingerprint: "fp-chain-p",
3603
- supersedes: "item-chain-grandparent",
3604
- firstSeenAt: now - 2 * MS_PER_DAY,
3605
- lastSeenAt: now - 2 * MS_PER_DAY,
3606
- accessCount: 1,
3607
- verificationState: "assistant_inferred",
3608
- })
3609
- .run();
3610
-
3611
- db.insert(memoryItems)
3612
- .values({
3613
- id: "item-chain-child",
3614
- kind: "fact",
3615
- subject: "address",
3616
- statement: "Lives at 789 Pine Blvd",
3617
- status: "active",
3618
- confidence: 0.9,
3619
- importance: 0.7,
3620
- fingerprint: "fp-chain-c",
3621
- supersedes: "item-chain-parent",
3622
- firstSeenAt: now - 1 * MS_PER_DAY,
3623
- lastSeenAt: now - 1 * MS_PER_DAY,
3624
- accessCount: 1,
3625
- verificationState: "assistant_inferred",
3626
- })
3627
- .run();
3628
-
3629
- // Look up from the child's perspective (supersedes parent)
3630
- const result = lookupSupersessionChain("item-chain-parent");
3631
- expect(result).not.toBeNull();
3632
- expect(result!.previousStatement).toBe("Lives at 456 Oak Ave");
3633
- expect(result!.previousTimestamp).toBe(now - 2 * MS_PER_DAY);
3634
- // Chain: parent → grandparent = depth 2
3635
- expect(result!.chainDepth).toBe(2);
3636
-
3637
- // Look up direct predecessor (grandparent has no supersedes)
3638
- const gpResult = lookupSupersessionChain("item-chain-grandparent");
3639
- expect(gpResult).not.toBeNull();
3640
- expect(gpResult!.previousStatement).toBe("Lives at 123 Main St");
3641
- expect(gpResult!.chainDepth).toBe(1);
3642
-
3643
- // Non-existent ID returns null
3644
- const nullResult = lookupSupersessionChain("item-nonexistent");
3645
- expect(nullResult).toBeNull();
3646
-
3647
- // Clean up
3648
- db.delete(memoryItems).where(eq(memoryItems.id, "item-chain-child")).run();
3649
- db.delete(memoryItems).where(eq(memoryItems.id, "item-chain-parent")).run();
3650
- db.delete(memoryItems)
3651
- .where(eq(memoryItems.id, "item-chain-grandparent"))
3652
- .run();
3653
- });
3654
-
3655
- test("escapeXmlTags escapes memory_context, recalled, and item delimiter tags", () => {
3656
- // Verify new tag vocabulary is escaped by the existing generic escaper
3657
- expect(escapeXmlTags("</memory_context>")).toBe("\uFF1C/memory_context>");
3658
- expect(escapeXmlTags("</recalled>")).toBe("\uFF1C/recalled>");
3659
- expect(escapeXmlTags("</item>")).toBe("\uFF1C/item>");
3660
- expect(escapeXmlTags("</segment>")).toBe("\uFF1C/segment>");
3661
- expect(escapeXmlTags("</supersedes>")).toBe("\uFF1C/supersedes>");
3662
- expect(escapeXmlTags("</echoes>")).toBe("\uFF1C/echoes>");
3663
-
3664
- // Opening tags too
3665
- expect(escapeXmlTags("<memory_context>")).toBe("\uFF1Cmemory_context>");
3666
- expect(escapeXmlTags("<recalled>")).toBe("\uFF1Crecalled>");
3667
- expect(escapeXmlTags("<item>")).toBe("\uFF1Citem>");
3668
- });
3669
-
3670
- test("buildMemoryInjection renders items without supersedes normally", () => {
3671
- const now = Date.now();
3672
- const candidate = {
3673
- key: "item:item-no-supersedes",
3674
- type: "item" as const,
3675
- id: "item-no-supersedes",
3676
- source: "semantic" as const,
3677
- text: "User prefers dark mode",
3678
- kind: "preference",
3679
- confidence: 0.9,
3680
- importance: 0.8,
3681
- createdAt: now,
3682
- semantic: 0.9,
3683
- recency: 0.8,
3684
- finalScore: 0.85,
3685
- };
3686
-
3687
- const injection = buildMemoryInjection({
3688
- candidates: [candidate],
3689
- totalBudgetTokens: 5000,
3690
- });
3691
-
3692
- expect(injection).toContain("User prefers dark mode");
3693
- expect(injection).not.toContain("<supersedes");
3694
- expect(injection).not.toContain("</supersedes>");
3695
- });
3696
- });