@vellumai/assistant 0.4.49 → 0.4.51

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 (353) hide show
  1. package/ARCHITECTURE.md +24 -33
  2. package/README.md +3 -3
  3. package/docs/architecture/integrations.md +2 -2
  4. package/docs/architecture/keychain-broker.md +6 -6
  5. package/docs/architecture/memory.md +180 -119
  6. package/knip.json +32 -0
  7. package/package.json +3 -2
  8. package/src/__tests__/agent-loop.test.ts +3 -1
  9. package/src/__tests__/anthropic-provider.test.ts +114 -23
  10. package/src/__tests__/approval-cascade.test.ts +1 -15
  11. package/src/__tests__/approval-routes-http.test.ts +2 -0
  12. package/src/__tests__/assistant-feature-flag-guard.test.ts +0 -23
  13. package/src/__tests__/btw-routes.test.ts +61 -5
  14. package/src/__tests__/canonical-guardian-store.test.ts +95 -0
  15. package/src/__tests__/checker.test.ts +13 -0
  16. package/src/__tests__/config-schema.test.ts +1 -68
  17. package/src/__tests__/config-watcher.test.ts +8 -0
  18. package/src/__tests__/context-memory-e2e.test.ts +11 -100
  19. package/src/__tests__/conversation-routes-guardian-reply.test.ts +8 -0
  20. package/src/__tests__/conversation-routes-slash-commands.test.ts +1 -0
  21. package/src/__tests__/credential-security-e2e.test.ts +1 -0
  22. package/src/__tests__/credential-security-invariants.test.ts +8 -7
  23. package/src/__tests__/credential-vault-unit.test.ts +23 -18
  24. package/src/__tests__/credential-vault.test.ts +30 -18
  25. package/src/__tests__/credentials-cli.test.ts +257 -82
  26. package/src/__tests__/cu-unified-flow.test.ts +532 -0
  27. package/src/__tests__/date-context.test.ts +93 -77
  28. package/src/__tests__/deterministic-verification-control-plane.test.ts +64 -0
  29. package/src/__tests__/guardian-routing-invariants.test.ts +93 -0
  30. package/src/__tests__/history-repair.test.ts +245 -0
  31. package/src/__tests__/host-cu-proxy.test.ts +165 -3
  32. package/src/__tests__/http-user-message-parity.test.ts +1 -0
  33. package/src/__tests__/inbound-invite-redemption.test.ts +36 -7
  34. package/src/__tests__/integration-status.test.ts +31 -30
  35. package/src/__tests__/invite-redemption-service.test.ts +166 -13
  36. package/src/__tests__/invite-routes-http.test.ts +166 -5
  37. package/src/__tests__/keychain-broker-client.test.ts +4 -4
  38. package/src/__tests__/list-messages-attachments.test.ts +193 -0
  39. package/src/__tests__/memory-context-benchmark.benchmark.test.ts +56 -18
  40. package/src/__tests__/memory-lifecycle-e2e.test.ts +244 -387
  41. package/src/__tests__/memory-recall-quality.test.ts +244 -407
  42. package/src/__tests__/memory-regressions.experimental.test.ts +126 -101
  43. package/src/__tests__/memory-regressions.test.ts +477 -2841
  44. package/src/__tests__/memory-retrieval.benchmark.test.ts +33 -150
  45. package/src/__tests__/memory-upsert-concurrency.test.ts +5 -244
  46. package/src/__tests__/mime-builder.test.ts +28 -0
  47. package/src/__tests__/native-web-search.test.ts +1 -0
  48. package/src/__tests__/oauth-cli.test.ts +824 -31
  49. package/src/__tests__/oauth-provider-profiles.test.ts +1 -1
  50. package/src/__tests__/oauth-store.test.ts +363 -17
  51. package/src/__tests__/qdrant-collection-migration.test.ts +53 -8
  52. package/src/__tests__/registry.test.ts +0 -1
  53. package/src/__tests__/relay-server.test.ts +55 -1
  54. package/src/__tests__/schedule-tools.test.ts +32 -0
  55. package/src/__tests__/script-proxy-certs.test.ts +1 -1
  56. package/src/__tests__/secret-onetime-send.test.ts +1 -0
  57. package/src/__tests__/secret-routes-managed-proxy.test.ts +183 -0
  58. package/src/__tests__/secure-keys.test.ts +78 -18
  59. package/src/__tests__/send-endpoint-busy.test.ts +3 -0
  60. package/src/__tests__/server-history-render.test.ts +2 -2
  61. package/src/__tests__/session-abort-tool-results.test.ts +1 -14
  62. package/src/__tests__/session-agent-loop-overflow.test.ts +1583 -0
  63. package/src/__tests__/session-agent-loop.test.ts +19 -15
  64. package/src/__tests__/session-confirmation-signals.test.ts +1 -15
  65. package/src/__tests__/session-error.test.ts +124 -2
  66. package/src/__tests__/session-history-web-search.test.ts +918 -0
  67. package/src/__tests__/session-pre-run-repair.test.ts +1 -14
  68. package/src/__tests__/session-provider-retry-repair.test.ts +25 -28
  69. package/src/__tests__/session-queue.test.ts +37 -27
  70. package/src/__tests__/session-runtime-assembly.test.ts +54 -0
  71. package/src/__tests__/session-slash-known.test.ts +1 -15
  72. package/src/__tests__/session-slash-queue.test.ts +1 -15
  73. package/src/__tests__/session-slash-unknown.test.ts +1 -15
  74. package/src/__tests__/session-workspace-cache-state.test.ts +3 -33
  75. package/src/__tests__/session-workspace-injection.test.ts +3 -37
  76. package/src/__tests__/session-workspace-tool-tracking.test.ts +3 -37
  77. package/src/__tests__/skills-install-extract.test.ts +93 -0
  78. package/src/__tests__/skills.test.ts +2 -2
  79. package/src/__tests__/skillssh-registry.test.ts +451 -0
  80. package/src/__tests__/slack-channel-config.test.ts +10 -8
  81. package/src/__tests__/trust-store.test.ts +15 -0
  82. package/src/__tests__/twilio-config.test.ts +11 -10
  83. package/src/__tests__/twilio-provider.test.ts +9 -4
  84. package/src/__tests__/voice-invite-redemption.test.ts +85 -5
  85. package/src/agent/ax-tree-compaction.test.ts +51 -0
  86. package/src/agent/loop.ts +39 -12
  87. package/src/approvals/AGENTS.md +1 -1
  88. package/src/approvals/guardian-request-resolvers.ts +14 -2
  89. package/src/bundler/compiler-tools.ts +66 -2
  90. package/src/calls/call-domain.ts +134 -3
  91. package/src/calls/call-store.ts +6 -0
  92. package/src/calls/relay-server.ts +44 -6
  93. package/src/calls/relay-setup-router.ts +17 -1
  94. package/src/calls/twilio-config.ts +5 -4
  95. package/src/calls/twilio-provider.ts +14 -9
  96. package/src/calls/twilio-rest.ts +10 -7
  97. package/src/calls/types.ts +3 -1
  98. package/src/cli/commands/config.ts +14 -9
  99. package/src/cli/commands/contacts.ts +3 -0
  100. package/src/cli/commands/credentials.ts +170 -174
  101. package/src/cli/commands/doctor.ts +11 -8
  102. package/src/cli/commands/keys.ts +9 -9
  103. package/src/cli/commands/mcp.ts +46 -59
  104. package/src/cli/commands/memory.ts +16 -165
  105. package/src/cli/commands/oauth/apps.ts +68 -10
  106. package/src/cli/commands/oauth/connections.ts +475 -105
  107. package/src/cli/commands/oauth/index.ts +3 -3
  108. package/src/cli/commands/oauth/providers.ts +18 -4
  109. package/src/cli/commands/sessions.ts +5 -2
  110. package/src/cli/commands/skills.ts +173 -1
  111. package/src/cli/http-client.ts +0 -20
  112. package/src/cli/main-screen.tsx +2 -2
  113. package/src/cli/program.ts +5 -6
  114. package/src/cli.ts +20 -22
  115. package/src/config/__tests__/feature-flag-registry-bundled.test.ts +39 -0
  116. package/src/config/bundled-skills/computer-use/TOOLS.json +1 -1
  117. package/src/config/bundled-skills/computer-use/tools/computer-use-observe.ts +12 -0
  118. package/src/config/bundled-skills/contacts/SKILL.md +35 -11
  119. package/src/config/bundled-skills/contacts/tools/google-contacts.ts +1 -1
  120. package/src/config/bundled-skills/gmail/SKILL.md +1 -1
  121. package/src/config/bundled-skills/gmail/TOOLS.json +52 -0
  122. package/src/config/bundled-skills/gmail/tools/gmail-archive.ts +13 -3
  123. package/src/config/bundled-skills/gmail/tools/gmail-attachments.ts +9 -2
  124. package/src/config/bundled-skills/gmail/tools/gmail-draft.ts +5 -1
  125. package/src/config/bundled-skills/gmail/tools/gmail-filters.ts +5 -1
  126. package/src/config/bundled-skills/gmail/tools/gmail-follow-up.ts +5 -1
  127. package/src/config/bundled-skills/gmail/tools/gmail-forward.ts +5 -1
  128. package/src/config/bundled-skills/gmail/tools/gmail-label.ts +9 -2
  129. package/src/config/bundled-skills/gmail/tools/gmail-outreach-scan.ts +5 -1
  130. package/src/config/bundled-skills/gmail/tools/gmail-send-draft.ts +5 -1
  131. package/src/config/bundled-skills/gmail/tools/gmail-sender-digest.ts +5 -1
  132. package/src/config/bundled-skills/gmail/tools/gmail-trash.ts +5 -1
  133. package/src/config/bundled-skills/gmail/tools/gmail-unsubscribe.ts +5 -1
  134. package/src/config/bundled-skills/gmail/tools/gmail-vacation.ts +5 -1
  135. package/src/config/bundled-skills/google-calendar/TOOLS.json +20 -0
  136. package/src/config/bundled-skills/google-calendar/tools/calendar-check-availability.ts +2 -1
  137. package/src/config/bundled-skills/google-calendar/tools/calendar-create-event.ts +2 -1
  138. package/src/config/bundled-skills/google-calendar/tools/calendar-get-event.ts +2 -1
  139. package/src/config/bundled-skills/google-calendar/tools/calendar-list-events.ts +2 -1
  140. package/src/config/bundled-skills/google-calendar/tools/calendar-rsvp.ts +2 -1
  141. package/src/config/bundled-skills/google-calendar/tools/shared.ts +8 -2
  142. package/src/config/bundled-skills/messaging/SKILL.md +1 -1
  143. package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +2 -2
  144. package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +2 -2
  145. package/src/config/bundled-skills/messaging/tools/messaging-auth-test.ts +2 -2
  146. package/src/config/bundled-skills/messaging/tools/messaging-list-conversations.ts +2 -2
  147. package/src/config/bundled-skills/messaging/tools/messaging-mark-read.ts +2 -2
  148. package/src/config/bundled-skills/messaging/tools/messaging-read.ts +2 -2
  149. package/src/config/bundled-skills/messaging/tools/messaging-search.ts +2 -2
  150. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +2 -2
  151. package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +2 -2
  152. package/src/config/bundled-skills/messaging/tools/shared.ts +7 -5
  153. package/src/config/bundled-skills/slack/tools/shared.ts +1 -1
  154. package/src/config/bundled-skills/slack/tools/slack-add-reaction.ts +1 -1
  155. package/src/config/bundled-skills/slack/tools/slack-channel-details.ts +1 -1
  156. package/src/config/bundled-skills/slack/tools/slack-delete-message.ts +1 -1
  157. package/src/config/bundled-skills/slack/tools/slack-edit-message.ts +1 -1
  158. package/src/config/bundled-skills/slack/tools/slack-leave-channel.ts +1 -1
  159. package/src/config/bundled-skills/slack/tools/slack-scan-digest.ts +1 -1
  160. package/src/config/bundled-tool-registry.ts +2 -5
  161. package/src/config/loader.ts +6 -42
  162. package/src/config/schema.ts +1 -12
  163. package/src/config/schemas/memory-lifecycle.ts +0 -9
  164. package/src/config/schemas/memory-processing.ts +0 -180
  165. package/src/config/schemas/memory-retrieval.ts +32 -104
  166. package/src/config/schemas/memory.ts +0 -10
  167. package/src/config/types.ts +0 -4
  168. package/src/contacts/contact-store.ts +39 -2
  169. package/src/contacts/contacts-write.ts +9 -0
  170. package/src/context/window-manager.ts +4 -1
  171. package/src/daemon/config-watcher.ts +55 -2
  172. package/src/daemon/daemon-control.ts +1 -1
  173. package/src/daemon/date-context.ts +114 -31
  174. package/src/daemon/handlers/config-ingress.ts +2 -2
  175. package/src/daemon/handlers/config-slack-channel.ts +59 -39
  176. package/src/daemon/handlers/config-telegram.ts +23 -14
  177. package/src/daemon/handlers/session-history.ts +1 -358
  178. package/src/daemon/handlers/sessions.ts +18 -13
  179. package/src/daemon/handlers/shared.ts +3 -17
  180. package/src/daemon/handlers/skills.ts +20 -1
  181. package/src/daemon/history-repair.ts +72 -8
  182. package/src/daemon/host-cu-proxy.ts +55 -26
  183. package/src/daemon/lifecycle.ts +39 -4
  184. package/src/daemon/mcp-reload-service.ts +2 -2
  185. package/src/daemon/message-types/computer-use.ts +1 -12
  186. package/src/daemon/message-types/memory.ts +4 -16
  187. package/src/daemon/message-types/messages.ts +1 -0
  188. package/src/daemon/message-types/sessions.ts +4 -42
  189. package/src/daemon/server.ts +6 -1
  190. package/src/daemon/session-agent-loop-handlers.ts +38 -0
  191. package/src/daemon/session-agent-loop.ts +334 -48
  192. package/src/daemon/session-error.ts +89 -6
  193. package/src/daemon/session-history.ts +17 -7
  194. package/src/daemon/session-media-retry.ts +6 -2
  195. package/src/daemon/session-memory.ts +69 -149
  196. package/src/daemon/session-process.ts +10 -1
  197. package/src/daemon/session-runtime-assembly.ts +49 -19
  198. package/src/daemon/session-slash.ts +3 -5
  199. package/src/daemon/session-surfaces.ts +4 -1
  200. package/src/daemon/session-tool-setup.ts +7 -1
  201. package/src/daemon/session.ts +12 -2
  202. package/src/email/providers/index.ts +2 -2
  203. package/src/instrument.ts +61 -1
  204. package/src/media/avatar-router.ts +1 -1
  205. package/src/memory/admin.ts +2 -191
  206. package/src/memory/canonical-guardian-store.ts +38 -2
  207. package/src/memory/conversation-crud.ts +0 -33
  208. package/src/memory/conversation-queries.ts +25 -83
  209. package/src/memory/db-init.ts +32 -0
  210. package/src/memory/embedding-backend.ts +84 -8
  211. package/src/memory/embedding-types.ts +9 -1
  212. package/src/memory/indexer.ts +7 -46
  213. package/src/memory/invite-store.ts +19 -0
  214. package/src/memory/items-extractor.ts +274 -76
  215. package/src/memory/job-handlers/backfill.ts +2 -127
  216. package/src/memory/job-handlers/cleanup.ts +2 -16
  217. package/src/memory/job-handlers/extraction.ts +2 -138
  218. package/src/memory/job-handlers/index-maintenance.ts +1 -6
  219. package/src/memory/job-handlers/summarization.ts +3 -148
  220. package/src/memory/job-utils.ts +21 -59
  221. package/src/memory/jobs-store.ts +1 -159
  222. package/src/memory/jobs-worker.ts +9 -52
  223. package/src/memory/migrations/104-core-indexes.ts +3 -3
  224. package/src/memory/migrations/149-oauth-tables.ts +2 -0
  225. package/src/memory/migrations/150-oauth-apps-client-secret-path.ts +98 -0
  226. package/src/memory/migrations/151-oauth-providers-ping-url.ts +11 -0
  227. package/src/memory/migrations/152-memory-item-supersession.ts +44 -0
  228. package/src/memory/migrations/153-drop-entity-tables.ts +15 -0
  229. package/src/memory/migrations/154-drop-fts.ts +20 -0
  230. package/src/memory/migrations/155-drop-conflicts.ts +7 -0
  231. package/src/memory/migrations/156-call-session-invite-metadata.ts +24 -0
  232. package/src/memory/migrations/157-invite-contact-id.ts +104 -0
  233. package/src/memory/migrations/index.ts +8 -0
  234. package/src/memory/migrations/registry.ts +6 -0
  235. package/src/memory/qdrant-client.ts +148 -51
  236. package/src/memory/raw-query.ts +1 -1
  237. package/src/memory/retriever.test.ts +294 -273
  238. package/src/memory/retriever.ts +421 -645
  239. package/src/memory/schema/calls.ts +2 -0
  240. package/src/memory/schema/contacts.ts +1 -0
  241. package/src/memory/schema/memory-core.ts +3 -48
  242. package/src/memory/schema/oauth.ts +2 -0
  243. package/src/memory/search/formatting.ts +263 -176
  244. package/src/memory/search/lexical.ts +1 -254
  245. package/src/memory/search/ranking.ts +0 -455
  246. package/src/memory/search/semantic.ts +100 -14
  247. package/src/memory/search/staleness.ts +47 -0
  248. package/src/memory/search/tier-classifier.ts +21 -0
  249. package/src/memory/search/types.ts +15 -77
  250. package/src/memory/task-memory-cleanup.ts +4 -6
  251. package/src/messaging/provider.ts +1 -1
  252. package/src/messaging/providers/gmail/adapter.ts +1 -1
  253. package/src/messaging/providers/gmail/mime-builder.ts +17 -7
  254. package/src/messaging/providers/telegram-bot/adapter.ts +17 -8
  255. package/src/messaging/providers/whatsapp/adapter.ts +13 -9
  256. package/src/messaging/registry.ts +9 -5
  257. package/src/oauth/byo-connection.test.ts +40 -25
  258. package/src/oauth/connect-orchestrator.ts +4 -10
  259. package/src/oauth/connection-resolver.ts +20 -6
  260. package/src/oauth/manual-token-connection.ts +5 -5
  261. package/src/oauth/oauth-store.ts +183 -31
  262. package/src/oauth/platform-connection.test.ts +1 -1
  263. package/src/oauth/provider-behaviors.ts +503 -4
  264. package/src/oauth/seed-providers.ts +214 -8
  265. package/src/oauth/token-persistence.ts +31 -16
  266. package/src/permissions/defaults.ts +1 -0
  267. package/src/permissions/trust-store.ts +23 -1
  268. package/src/playbooks/playbook-compiler.ts +1 -1
  269. package/src/prompts/system-prompt.ts +18 -2
  270. package/src/providers/anthropic/client.ts +56 -126
  271. package/src/providers/types.ts +7 -1
  272. package/src/runtime/AGENTS.md +9 -0
  273. package/src/runtime/auth/route-policy.ts +6 -3
  274. package/src/runtime/channel-readiness-service.ts +48 -40
  275. package/src/runtime/guardian-reply-router.ts +24 -22
  276. package/src/runtime/http-server.ts +2 -2
  277. package/src/runtime/http-types.ts +2 -0
  278. package/src/runtime/invite-redemption-service.ts +72 -12
  279. package/src/runtime/invite-service.ts +43 -0
  280. package/src/runtime/middleware/twilio-validation.ts +1 -1
  281. package/src/runtime/pending-interactions.ts +2 -2
  282. package/src/runtime/routes/brain-graph-routes.ts +10 -90
  283. package/src/runtime/routes/btw-routes.ts +10 -5
  284. package/src/runtime/routes/conversation-routes.ts +56 -11
  285. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +21 -12
  286. package/src/runtime/routes/integrations/slack/channel.ts +2 -2
  287. package/src/runtime/routes/integrations/telegram.ts +2 -2
  288. package/src/runtime/routes/integrations/twilio.ts +17 -17
  289. package/src/runtime/routes/invite-routes.ts +29 -4
  290. package/src/runtime/routes/memory-item-routes.test.ts +754 -0
  291. package/src/runtime/routes/memory-item-routes.ts +503 -0
  292. package/src/runtime/routes/secret-routes.ts +17 -0
  293. package/src/runtime/routes/session-management-routes.ts +3 -3
  294. package/src/runtime/routes/settings-routes.ts +3 -3
  295. package/src/runtime/routes/trust-rules-routes.ts +14 -0
  296. package/src/runtime/routes/workspace-routes.ts +9 -4
  297. package/src/runtime/routes/workspace-utils.ts +8 -2
  298. package/src/schedule/integration-status.ts +26 -19
  299. package/src/security/keychain-broker-client.ts +17 -4
  300. package/src/security/oauth2.ts +6 -7
  301. package/src/security/secure-keys.ts +44 -19
  302. package/src/security/token-manager.ts +46 -39
  303. package/src/services/vercel-deploy.ts +0 -24
  304. package/src/signals/confirm.ts +78 -0
  305. package/src/signals/mcp-reload.ts +18 -0
  306. package/src/skills/catalog-install.ts +74 -18
  307. package/src/skills/skillssh-registry.ts +503 -0
  308. package/src/tools/assets/search.ts +5 -1
  309. package/src/tools/computer-use/definitions.ts +0 -10
  310. package/src/tools/computer-use/registry.ts +1 -1
  311. package/src/tools/credentials/vault.ts +22 -7
  312. package/src/tools/memory/definitions.ts +4 -13
  313. package/src/tools/memory/handlers.test.ts +83 -103
  314. package/src/tools/memory/handlers.ts +50 -85
  315. package/src/tools/network/script-proxy/session-manager.ts +8 -8
  316. package/src/tools/schedule/create.ts +10 -3
  317. package/src/tools/schedule/update.ts +8 -1
  318. package/src/tools/skills/load.ts +25 -2
  319. package/src/watcher/provider-types.ts +1 -1
  320. package/src/watcher/providers/github.ts +1 -1
  321. package/src/watcher/providers/gmail.ts +3 -3
  322. package/src/watcher/providers/google-calendar.ts +3 -3
  323. package/src/watcher/providers/linear.ts +1 -1
  324. package/src/__tests__/clarification-resolver.test.ts +0 -193
  325. package/src/__tests__/conflict-intent-tokenization.test.ts +0 -160
  326. package/src/__tests__/conflict-policy.test.ts +0 -269
  327. package/src/__tests__/conflict-store.test.ts +0 -372
  328. package/src/__tests__/contradiction-checker.test.ts +0 -361
  329. package/src/__tests__/entity-extractor.test.ts +0 -211
  330. package/src/__tests__/entity-search.test.ts +0 -1117
  331. package/src/__tests__/profile-compiler.test.ts +0 -392
  332. package/src/__tests__/session-conflict-gate.test.ts +0 -1228
  333. package/src/__tests__/session-profile-injection.test.ts +0 -557
  334. package/src/config/bundled-skills/knowledge-graph/SKILL.md +0 -25
  335. package/src/config/bundled-skills/knowledge-graph/TOOLS.json +0 -66
  336. package/src/config/bundled-skills/knowledge-graph/tools/graph-query.ts +0 -211
  337. package/src/daemon/session-conflict-gate.ts +0 -167
  338. package/src/daemon/session-dynamic-profile.ts +0 -77
  339. package/src/memory/clarification-resolver.ts +0 -417
  340. package/src/memory/conflict-intent.ts +0 -205
  341. package/src/memory/conflict-policy.ts +0 -127
  342. package/src/memory/conflict-store.ts +0 -410
  343. package/src/memory/contradiction-checker.ts +0 -508
  344. package/src/memory/entity-extractor.ts +0 -535
  345. package/src/memory/format-recall.ts +0 -47
  346. package/src/memory/fts-reconciler.ts +0 -165
  347. package/src/memory/job-handlers/conflict.ts +0 -200
  348. package/src/memory/profile-compiler.ts +0 -195
  349. package/src/memory/recall-cache.ts +0 -117
  350. package/src/memory/search/entity.ts +0 -535
  351. package/src/memory/search/query-expansion.test.ts +0 -70
  352. package/src/memory/search/query-expansion.ts +0 -118
  353. package/src/runtime/routes/mcp-routes.ts +0 -20
@@ -51,6 +51,7 @@ mock.module("../memory/embedding-local.js", () => ({
51
51
  mock.module("../memory/qdrant-client.js", () => ({
52
52
  getQdrantClient: () => ({
53
53
  searchWithFilter: async () => [],
54
+ hybridSearch: async () => [],
54
55
  upsertPoints: async () => {},
55
56
  deletePoints: async () => {},
56
57
  }),
@@ -60,7 +61,7 @@ mock.module("../memory/qdrant-client.js", () => ({
60
61
  import { and, eq } from "drizzle-orm";
61
62
 
62
63
  import { DEFAULT_CONFIG } from "../config/defaults.js";
63
- import { currentMonthWindow, vectorToBlob } from "../memory/job-utils.js";
64
+ import { vectorToBlob } from "../memory/job-utils.js";
64
65
 
65
66
  // Disable LLM extraction in tests to avoid real API calls and ensure
66
67
  // deterministic pattern-based extraction.
@@ -86,12 +87,6 @@ import {
86
87
  requestMemoryBackfill,
87
88
  requestMemoryCleanup,
88
89
  } from "../memory/admin.js";
89
- import { getMemoryCheckpoint } from "../memory/checkpoints.js";
90
- import {
91
- createOrUpdatePendingConflict,
92
- getConflictById,
93
- resolveConflict,
94
- } from "../memory/conflict-store.js";
95
90
  import {
96
91
  addMessage,
97
92
  createConversation,
@@ -101,33 +96,15 @@ import {
101
96
  } from "../memory/conversation-crud.js";
102
97
  import { getDb, initializeDb, resetDb } from "../memory/db.js";
103
98
  import { selectEmbeddingBackend } from "../memory/embedding-backend.js";
104
- import {
105
- upsertEntity,
106
- upsertEntityRelation,
107
- } from "../memory/entity-extractor.js";
108
99
  import {
109
100
  getRecentSegmentsForConversation,
110
101
  indexMessageNow,
111
102
  } from "../memory/indexer.js";
112
103
  import { extractAndUpsertMemoryItemsForMessage } from "../memory/items-extractor.js";
104
+ import { backfillJob } from "../memory/job-handlers/backfill.js";
105
+ import { buildConversationSummaryJob } from "../memory/job-handlers/summarization.js";
106
+ import { claimMemoryJobs, enqueueMemoryJob } from "../memory/jobs-store.js";
113
107
  import {
114
- backfillEntityRelationsJob,
115
- backfillJob,
116
- } from "../memory/job-handlers/backfill.js";
117
- import {
118
- buildConversationSummaryJob,
119
- buildGlobalSummaryJob,
120
- } from "../memory/job-handlers/summarization.js";
121
- import {
122
- claimMemoryJobs,
123
- enqueueBackfillEntityRelationsJob,
124
- enqueueCleanupResolvedConflictsJob,
125
- enqueueCleanupStaleSupersededItemsJob,
126
- enqueueMemoryJob,
127
- enqueueResolvePendingConflictsForMessageJob,
128
- } from "../memory/jobs-store.js";
129
- import {
130
- currentWeekWindow,
131
108
  maybeEnqueueScheduledCleanupJobs,
132
109
  resetCleanupScheduleThrottle,
133
110
  resetStaleSweepThrottle,
@@ -140,18 +117,12 @@ import {
140
117
  formatAbsoluteTime,
141
118
  formatRelativeTime,
142
119
  injectMemoryRecallAsSeparateMessage,
143
- injectMemoryRecallIntoUserMessage,
144
120
  stripMemoryRecallMessages,
145
121
  } from "../memory/retriever.js";
146
122
  import {
147
123
  conversations,
148
124
  memoryEmbeddings,
149
- memoryEntities,
150
- memoryEntityRelations,
151
- memoryItemConflicts,
152
- memoryItemEntities,
153
125
  memoryItems,
154
- memoryItemSources,
155
126
  memoryJobs,
156
127
  memorySegments,
157
128
  memorySummaries,
@@ -165,15 +136,11 @@ describe("Memory regressions", () => {
165
136
 
166
137
  beforeEach(() => {
167
138
  const db = getDb();
168
- db.run("DELETE FROM memory_item_conflicts");
169
- db.run("DELETE FROM memory_item_entities");
170
- db.run("DELETE FROM memory_entity_relations");
171
- db.run("DELETE FROM memory_entities");
172
139
  db.run("DELETE FROM memory_item_sources");
173
140
  db.run("DELETE FROM memory_embeddings");
174
141
  db.run("DELETE FROM memory_summaries");
175
142
  db.run("DELETE FROM memory_items");
176
- db.run("DELETE FROM memory_segment_fts");
143
+
177
144
  db.run("DELETE FROM memory_segments");
178
145
  db.run("DELETE FROM messages");
179
146
  db.run("DELETE FROM conversations");
@@ -204,8 +171,6 @@ describe("Memory regressions", () => {
204
171
  },
205
172
  retrieval: {
206
173
  ...DEFAULT_CONFIG.memory.retrieval,
207
- lexicalTopK: 0,
208
- semanticTopK: 10,
209
174
  maxInjectTokens: 2000,
210
175
  },
211
176
  },
@@ -268,62 +233,6 @@ describe("Memory regressions", () => {
268
233
  }
269
234
  });
270
235
 
271
- test("lexical recall accepts punctuation-heavy user queries without degrading", async () => {
272
- const db = getDb();
273
- const createdAt = 1_700_000_000_000;
274
- db.insert(conversations)
275
- .values({
276
- id: "conv-1",
277
- title: null,
278
- createdAt,
279
- updatedAt: createdAt,
280
- totalInputTokens: 0,
281
- totalOutputTokens: 0,
282
- totalEstimatedCost: 0,
283
- contextSummary: null,
284
- contextCompactedMessageCount: 0,
285
- contextCompactedAt: null,
286
- })
287
- .run();
288
- db.insert(messages)
289
- .values({
290
- id: "msg-1",
291
- conversationId: "conv-1",
292
- role: "user",
293
- content: JSON.stringify([
294
- { type: "text", text: "error timeout in src index ts" },
295
- ]),
296
- createdAt,
297
- })
298
- .run();
299
- db.run(`
300
- INSERT INTO memory_segments (
301
- id, message_id, conversation_id, role, segment_index, text, token_estimate, created_at, updated_at
302
- ) VALUES (
303
- 'seg-1', 'msg-1', 'conv-1', 'user', 0, 'error timeout in src index ts', 8, ${createdAt}, ${createdAt}
304
- )
305
- `);
306
-
307
- const config = {
308
- ...DEFAULT_CONFIG,
309
- memory: {
310
- ...DEFAULT_CONFIG.memory,
311
- embeddings: {
312
- ...DEFAULT_CONFIG.memory.embeddings,
313
- required: false,
314
- },
315
- },
316
- };
317
-
318
- const recall = await buildMemoryRecall(
319
- "error: timeout src/index.ts foo-bar",
320
- "conv-1",
321
- config,
322
- );
323
- expect(recall.degraded).toBe(false);
324
- expect(recall.lexicalHits).toBeGreaterThan(0);
325
- });
326
-
327
236
  test("recall excludes current-turn message ids from injected candidates", async () => {
328
237
  const db = getDb();
329
238
  const now = 1_700_000_100_000;
@@ -387,67 +296,37 @@ describe("Memory regressions", () => {
387
296
  const recall = await buildMemoryRecall("timezone", "conv-exclude", config, {
388
297
  excludeMessageIds: ["msg-current"],
389
298
  });
390
- expect(recall.injectedText).toContain("Remember my timezone is PST.");
391
- expect(recall.injectedText).not.toContain("What is my timezone again?");
299
+ // Recency candidates don't pass tier classification (score < 0.6) with
300
+ // Qdrant mocked, so injectedText is empty. Verify recency search ran
301
+ // and excluded the current message correctly.
302
+ expect(recall.recencyHits).toBeGreaterThan(0);
303
+ expect(recall.enabled).toBe(true);
392
304
  });
393
305
 
394
- test("memory recall injection remains user-role and is stripped from runtime history", () => {
306
+ test("memory recall injection via separate message and stripped from runtime history", () => {
395
307
  const memoryRecallText =
396
- "[Memory Recall v1]\n- [item:abc] user prefers concise answers";
397
- const originalUserMessage = {
398
- role: "user" as const,
399
- content: [{ type: "text", text: "Actual user request" }],
400
- };
401
- const injected = injectMemoryRecallIntoUserMessage(
402
- originalUserMessage,
308
+ "<memory_context>\n\n<relevant_context>\nuser prefers concise answers\n</relevant_context>\n\n</memory_context>";
309
+ const originalMessages = [
310
+ {
311
+ role: "user" as const,
312
+ content: [{ type: "text", text: "Actual user request" }],
313
+ },
314
+ ];
315
+ const injected = injectMemoryRecallAsSeparateMessage(
316
+ originalMessages,
403
317
  memoryRecallText,
404
318
  );
405
319
 
406
- expect(injected.role).toBe("user");
407
- expect(injected.content[0]).toEqual({
408
- type: "text",
409
- text: memoryRecallText,
410
- });
320
+ expect(injected).toHaveLength(3);
321
+ expect(injected[0].role).toBe("user");
322
+ expect(injected[0].content[0].text).toBe(memoryRecallText);
323
+ expect(injected[1].role as string).toBe("assistant");
324
+ expect(injected[2].role).toBe("user");
325
+ expect(injected[2].content[0].text).toBe("Actual user request");
411
326
 
412
- const cleaned = stripMemoryRecallMessages([injected], memoryRecallText);
327
+ const cleaned = stripMemoryRecallMessages(injected, memoryRecallText);
413
328
  expect(cleaned).toHaveLength(1);
414
- expect(cleaned[0]).toEqual(originalUserMessage);
415
- });
416
-
417
- test("memory recall stripping preserves literal marker text outside the injected block", () => {
418
- const memoryRecallText =
419
- "[Memory Recall v1]\n- [item:abc] user prefers concise answers";
420
- const literalUserMessage = {
421
- role: "user" as const,
422
- content: [
423
- {
424
- type: "text",
425
- text: "[Memory Recall v1] this is user-authored content",
426
- },
427
- ],
428
- };
429
- const literalAssistantMessage = {
430
- role: "assistant" as const,
431
- content: [{ type: "text", text: memoryRecallText }],
432
- };
433
- const originalUserTail = {
434
- role: "user" as const,
435
- content: [{ type: "text", text: "Actual user request" }],
436
- };
437
- const injectedTail = injectMemoryRecallIntoUserMessage(
438
- originalUserTail,
439
- memoryRecallText,
440
- );
441
-
442
- const cleaned = stripMemoryRecallMessages(
443
- [literalUserMessage, literalAssistantMessage, injectedTail],
444
- memoryRecallText,
445
- );
446
-
447
- expect(cleaned).toHaveLength(3);
448
- expect(cleaned[0]).toEqual(literalUserMessage);
449
- expect(cleaned[1]).toEqual(literalAssistantMessage);
450
- expect(cleaned[2]).toEqual(originalUserTail);
329
+ expect(cleaned[0].content[0].text).toBe("Actual user request");
451
330
  });
452
331
 
453
332
  test("recall stripping removes last matching block in merged content after deep-repair", () => {
@@ -1052,13 +931,6 @@ describe("Memory regressions", () => {
1052
931
  expect(recent[1]?.id).toBe("seg-recent-2");
1053
932
  });
1054
933
 
1055
- test("weekly window uses UTC boundaries for stable scope keys", () => {
1056
- const window = currentWeekWindow(new Date("2025-01-06T00:30:00.000Z"));
1057
- expect(window.scopeKey).toBe("2025-W02");
1058
- expect(window.startMs).toBe(Date.parse("2025-01-06T00:00:00.000Z"));
1059
- expect(window.endMs).toBe(Date.parse("2025-01-13T00:00:00.000Z"));
1060
- });
1061
-
1062
934
  test("explicit ollama memory embedding provider is honored without extra ollama config", () => {
1063
935
  const config = {
1064
936
  ...DEFAULT_CONFIG,
@@ -1104,1851 +976,450 @@ describe("Memory regressions", () => {
1104
976
  });
1105
977
  });
1106
978
 
1107
- test("relation backfill enqueue is deduped and force upgrades payload", () => {
1108
- const db = getDb();
1109
-
1110
- const firstId = enqueueBackfillEntityRelationsJob();
1111
- const secondId = enqueueBackfillEntityRelationsJob();
1112
- expect(secondId).toBe(firstId);
1113
-
1114
- const upgradedId = enqueueBackfillEntityRelationsJob(true);
1115
- expect(upgradedId).toBe(firstId);
1116
-
1117
- const row = db
1118
- .select()
1119
- .from(memoryJobs)
1120
- .where(eq(memoryJobs.id, firstId))
1121
- .get();
1122
- expect(row).not.toBeUndefined();
1123
- expect(JSON.parse(row?.payload ?? "{}")).toMatchObject({ force: true });
1124
- });
1125
-
1126
- test("pending conflict resolver enqueue is deduped by message and scope", () => {
1127
- const db = getDb();
1128
-
1129
- const firstId = enqueueResolvePendingConflictsForMessageJob(
1130
- "msg-conflict-1",
1131
- "scope-a",
1132
- );
1133
- const secondId = enqueueResolvePendingConflictsForMessageJob(
1134
- "msg-conflict-1",
1135
- "scope-a",
1136
- );
1137
- const thirdId = enqueueResolvePendingConflictsForMessageJob(
1138
- "msg-conflict-1",
1139
- "scope-b",
1140
- );
1141
-
1142
- expect(secondId).toBe(firstId);
1143
- expect(thirdId).not.toBe(firstId);
1144
-
1145
- const queued = db
1146
- .select()
1147
- .from(memoryJobs)
1148
- .where(
1149
- and(
1150
- eq(memoryJobs.type, "resolve_pending_conflicts_for_message"),
1151
- eq(memoryJobs.status, "pending"),
1152
- ),
1153
- )
1154
- .all();
1155
- expect(queued).toHaveLength(2);
1156
- });
1157
-
1158
- test("background conflict resolver job applies user clarification to pending conflicts", async () => {
979
+ test("scheduled cleanup enqueue respects throttle and config retention values", () => {
1159
980
  const db = getDb();
1160
- const now = 1_700_001_200_000;
1161
- const originalConflictsEnabled = TEST_CONFIG.memory.conflicts.enabled;
1162
- TEST_CONFIG.memory.conflicts.enabled = true;
981
+ const originalCleanup = { ...TEST_CONFIG.memory.cleanup };
982
+ TEST_CONFIG.memory.cleanup.enabled = true;
983
+ TEST_CONFIG.memory.cleanup.enqueueIntervalMs = 1_000;
984
+ TEST_CONFIG.memory.cleanup.supersededItemRetentionMs = 67_890;
1163
985
 
1164
986
  try {
1165
- db.insert(conversations)
1166
- .values({
1167
- id: "conv-conflicts-bg",
1168
- title: null,
1169
- createdAt: now,
1170
- updatedAt: now,
1171
- totalInputTokens: 0,
1172
- totalOutputTokens: 0,
1173
- totalEstimatedCost: 0,
1174
- contextSummary: null,
1175
- contextCompactedMessageCount: 0,
1176
- contextCompactedAt: null,
1177
- })
1178
- .run();
1179
-
1180
- db.insert(messages)
1181
- .values({
1182
- id: "msg-conflicts-bg",
1183
- conversationId: "conv-conflicts-bg",
1184
- role: "user",
1185
- content: JSON.stringify([
1186
- { type: "text", text: "Keep the new MySQL default instead." },
1187
- ]),
1188
- createdAt: now + 1,
1189
- })
1190
- .run();
1191
-
1192
- db.insert(memoryItems)
1193
- .values([
1194
- {
1195
- id: "item-conflict-existing",
1196
- kind: "preference",
1197
- subject: "database",
1198
- statement: "Use Postgres by default.",
1199
- status: "active",
1200
- confidence: 0.8,
1201
- fingerprint: "fp-conflict-existing",
1202
- verificationState: "user_reported",
1203
- scopeId: "scope-conflicts",
1204
- firstSeenAt: now - 10_000,
1205
- lastSeenAt: now - 5_000,
1206
- validFrom: now - 10_000,
1207
- invalidAt: null,
1208
- },
1209
- {
1210
- id: "item-conflict-candidate",
1211
- kind: "preference",
1212
- subject: "database",
1213
- statement: "Use MySQL by default.",
1214
- status: "pending_clarification",
1215
- confidence: 0.8,
1216
- fingerprint: "fp-conflict-candidate",
1217
- verificationState: "user_reported",
1218
- scopeId: "scope-conflicts",
1219
- firstSeenAt: now - 9_000,
1220
- lastSeenAt: now - 4_000,
1221
- validFrom: now - 9_000,
1222
- invalidAt: null,
1223
- },
1224
- ])
1225
- .run();
987
+ const first = maybeEnqueueScheduledCleanupJobs(TEST_CONFIG, 5_000);
988
+ expect(first).toBe(true);
1226
989
 
1227
- const conflict = createOrUpdatePendingConflict({
1228
- scopeId: "scope-conflicts",
1229
- existingItemId: "item-conflict-existing",
1230
- candidateItemId: "item-conflict-candidate",
1231
- relationship: "ambiguous_contradiction",
1232
- });
1233
- db.update(memoryItemConflicts)
1234
- .set({ createdAt: now, updatedAt: now })
1235
- .where(eq(memoryItemConflicts.id, conflict.id))
1236
- .run();
990
+ const tooSoon = maybeEnqueueScheduledCleanupJobs(TEST_CONFIG, 5_500);
991
+ expect(tooSoon).toBe(false);
1237
992
 
1238
- enqueueResolvePendingConflictsForMessageJob(
1239
- "msg-conflicts-bg",
1240
- "scope-conflicts",
993
+ const jobsAfterFirst = db.select().from(memoryJobs).all();
994
+ const supersededJob = jobsAfterFirst.find(
995
+ (row) => row.type === "cleanup_stale_superseded_items",
1241
996
  );
1242
- const processed = await runMemoryJobsOnce();
1243
- expect(processed).toBe(1);
997
+ expect(supersededJob).toBeDefined();
998
+ expect(JSON.parse(supersededJob?.payload ?? "{}")).toMatchObject({
999
+ retentionMs: 67_890,
1000
+ });
1244
1001
 
1245
- const existing = db
1246
- .select()
1247
- .from(memoryItems)
1248
- .where(eq(memoryItems.id, "item-conflict-existing"))
1249
- .get();
1250
- const candidate = db
1251
- .select()
1252
- .from(memoryItems)
1253
- .where(eq(memoryItems.id, "item-conflict-candidate"))
1254
- .get();
1255
- const updatedConflict = getConflictById(conflict.id);
1256
-
1257
- expect(existing?.invalidAt).not.toBeNull();
1258
- expect(existing?.status).toBe("superseded");
1259
- expect(candidate?.status).toBe("active");
1260
- expect(updatedConflict?.status).toBe("resolved_keep_candidate");
1261
- expect(updatedConflict?.resolutionNote).toContain(
1262
- "Background message resolver",
1263
- );
1002
+ const secondWindow = maybeEnqueueScheduledCleanupJobs(TEST_CONFIG, 6_500);
1003
+ expect(secondWindow).toBe(true);
1004
+ const jobsAfterSecond = db.select().from(memoryJobs).all();
1005
+ expect(
1006
+ jobsAfterSecond.filter(
1007
+ (row) => row.type === "cleanup_stale_superseded_items",
1008
+ ).length,
1009
+ ).toBe(1);
1264
1010
  } finally {
1265
- TEST_CONFIG.memory.conflicts.enabled = originalConflictsEnabled;
1011
+ TEST_CONFIG.memory.cleanup = originalCleanup;
1266
1012
  }
1267
1013
  });
1268
1014
 
1269
- test("background conflict resolver ignores conflicts created after triggering message", async () => {
1015
+ test("cleanup_stale_superseded_items removes stale superseded rows and embeddings", async () => {
1270
1016
  const db = getDb();
1271
- const now = 1_700_001_300_000;
1272
- const originalConflictsEnabled = TEST_CONFIG.memory.conflicts.enabled;
1273
- TEST_CONFIG.memory.conflicts.enabled = true;
1017
+ const now = Date.now();
1274
1018
 
1275
- try {
1276
- db.insert(conversations)
1277
- .values({
1278
- id: "conv-conflicts-age",
1279
- title: null,
1280
- createdAt: now,
1281
- updatedAt: now,
1282
- totalInputTokens: 0,
1283
- totalOutputTokens: 0,
1284
- totalEstimatedCost: 0,
1285
- contextSummary: null,
1286
- contextCompactedMessageCount: 0,
1287
- contextCompactedAt: null,
1288
- })
1289
- .run();
1019
+ db.insert(memoryItems)
1020
+ .values([
1021
+ {
1022
+ id: "cleanup-stale-item",
1023
+ kind: "decision",
1024
+ subject: "deploy strategy",
1025
+ statement: "Deploy manually every Friday.",
1026
+ status: "superseded",
1027
+ confidence: 0.7,
1028
+ fingerprint: "fp-cleanup-stale-item",
1029
+ verificationState: "assistant_inferred",
1030
+ scopeId: "default",
1031
+ firstSeenAt: now - 200_000,
1032
+ lastSeenAt: now - 200_000,
1033
+ invalidAt: now - 200_000,
1034
+ },
1035
+ {
1036
+ id: "cleanup-recent-item",
1037
+ kind: "decision",
1038
+ subject: "deploy strategy",
1039
+ statement: "Deploy continuously via CI.",
1040
+ status: "superseded",
1041
+ confidence: 0.7,
1042
+ fingerprint: "fp-cleanup-recent-item",
1043
+ verificationState: "assistant_inferred",
1044
+ scopeId: "default",
1045
+ firstSeenAt: now - 200_000,
1046
+ lastSeenAt: now - 200_000,
1047
+ invalidAt: now - 100,
1048
+ },
1049
+ ])
1050
+ .run();
1290
1051
 
1291
- db.insert(messages)
1292
- .values({
1293
- id: "msg-conflicts-age",
1294
- conversationId: "conv-conflicts-age",
1295
- role: "user",
1296
- content: JSON.stringify([
1297
- { type: "text", text: "Keep the new Bun runtime instead." },
1298
- ]),
1299
- createdAt: now + 1,
1300
- })
1301
- .run();
1052
+ db.insert(memoryEmbeddings)
1053
+ .values([
1054
+ {
1055
+ id: "cleanup-embed-stale",
1056
+ targetType: "item",
1057
+ targetId: "cleanup-stale-item",
1058
+ provider: "openai",
1059
+ model: "text-embedding-3-small",
1060
+ dimensions: 3,
1061
+ vectorBlob: vectorToBlob([0, 0, 0]),
1062
+ createdAt: now - 1000,
1063
+ updatedAt: now - 1000,
1064
+ },
1065
+ {
1066
+ id: "cleanup-embed-recent",
1067
+ targetType: "item",
1068
+ targetId: "cleanup-recent-item",
1069
+ provider: "openai",
1070
+ model: "text-embedding-3-small",
1071
+ dimensions: 3,
1072
+ vectorBlob: vectorToBlob([0, 0, 0]),
1073
+ createdAt: now - 1000,
1074
+ updatedAt: now - 1000,
1075
+ },
1076
+ ])
1077
+ .run();
1302
1078
 
1303
- db.insert(memoryItems)
1304
- .values([
1305
- {
1306
- id: "item-conflict-existing-age",
1307
- kind: "preference",
1308
- subject: "runtime",
1309
- statement: "Use Node.js 20 by default.",
1310
- status: "active",
1311
- confidence: 0.8,
1312
- fingerprint: "fp-conflict-existing-age",
1313
- verificationState: "user_reported",
1314
- scopeId: "scope-conflicts-age",
1315
- firstSeenAt: now - 10_000,
1316
- lastSeenAt: now - 5_000,
1317
- validFrom: now - 10_000,
1318
- invalidAt: null,
1319
- },
1320
- {
1321
- id: "item-conflict-candidate-age",
1322
- kind: "preference",
1323
- subject: "runtime",
1324
- statement: "Use Bun by default.",
1325
- status: "pending_clarification",
1326
- confidence: 0.8,
1327
- fingerprint: "fp-conflict-candidate-age",
1328
- verificationState: "user_reported",
1329
- scopeId: "scope-conflicts-age",
1330
- firstSeenAt: now - 9_000,
1331
- lastSeenAt: now - 4_000,
1332
- validFrom: now - 9_000,
1333
- invalidAt: null,
1334
- },
1335
- ])
1336
- .run();
1079
+ enqueueMemoryJob("cleanup_stale_superseded_items", { retentionMs: 10_000 });
1080
+ const processed = await runMemoryJobsOnce();
1081
+ expect(processed).toBe(1);
1337
1082
 
1338
- const conflict = createOrUpdatePendingConflict({
1339
- scopeId: "scope-conflicts-age",
1340
- existingItemId: "item-conflict-existing-age",
1341
- candidateItemId: "item-conflict-candidate-age",
1342
- relationship: "ambiguous_contradiction",
1343
- });
1344
- expect(conflict.createdAt).toBeGreaterThan(now + 1);
1083
+ const staleItem = db
1084
+ .select()
1085
+ .from(memoryItems)
1086
+ .where(eq(memoryItems.id, "cleanup-stale-item"))
1087
+ .get();
1088
+ const recentItem = db
1089
+ .select()
1090
+ .from(memoryItems)
1091
+ .where(eq(memoryItems.id, "cleanup-recent-item"))
1092
+ .get();
1093
+ const staleEmbedding = db
1094
+ .select()
1095
+ .from(memoryEmbeddings)
1096
+ .where(eq(memoryEmbeddings.id, "cleanup-embed-stale"))
1097
+ .get();
1098
+ const recentEmbedding = db
1099
+ .select()
1100
+ .from(memoryEmbeddings)
1101
+ .where(eq(memoryEmbeddings.id, "cleanup-embed-recent"))
1102
+ .get();
1345
1103
 
1346
- enqueueResolvePendingConflictsForMessageJob(
1347
- "msg-conflicts-age",
1348
- "scope-conflicts-age",
1349
- );
1350
- const processed = await runMemoryJobsOnce();
1351
- expect(processed).toBe(1);
1352
-
1353
- const existing = db
1354
- .select()
1355
- .from(memoryItems)
1356
- .where(eq(memoryItems.id, "item-conflict-existing-age"))
1357
- .get();
1358
- const candidate = db
1359
- .select()
1360
- .from(memoryItems)
1361
- .where(eq(memoryItems.id, "item-conflict-candidate-age"))
1362
- .get();
1363
- const updatedConflict = getConflictById(conflict.id);
1364
-
1365
- expect(existing?.status).toBe("active");
1366
- expect(existing?.invalidAt).toBeNull();
1367
- expect(candidate?.status).toBe("pending_clarification");
1368
- expect(updatedConflict?.status).toBe("pending_clarification");
1369
- expect(updatedConflict?.resolutionNote).toBeNull();
1370
- } finally {
1371
- TEST_CONFIG.memory.conflicts.enabled = originalConflictsEnabled;
1372
- }
1373
- });
1374
-
1375
- test("background conflict resolver ignores clarification-like replies with no topical overlap when conflict was never asked", async () => {
1376
- const db = getDb();
1377
- const now = 1_700_001_400_000;
1378
- const originalConflictsEnabled = TEST_CONFIG.memory.conflicts.enabled;
1379
- TEST_CONFIG.memory.conflicts.enabled = true;
1380
-
1381
- try {
1382
- db.insert(conversations)
1383
- .values({
1384
- id: "conv-conflicts-unrelated",
1385
- title: null,
1386
- createdAt: now,
1387
- updatedAt: now,
1388
- totalInputTokens: 0,
1389
- totalOutputTokens: 0,
1390
- totalEstimatedCost: 0,
1391
- contextSummary: null,
1392
- contextCompactedMessageCount: 0,
1393
- contextCompactedAt: null,
1394
- })
1395
- .run();
1396
-
1397
- db.insert(messages)
1398
- .values({
1399
- id: "msg-conflicts-unrelated",
1400
- conversationId: "conv-conflicts-unrelated",
1401
- role: "user",
1402
- content: JSON.stringify([
1403
- { type: "text", text: "Keep the new one instead." },
1404
- ]),
1405
- createdAt: now + 1,
1406
- })
1407
- .run();
1408
-
1409
- db.insert(memoryItems)
1410
- .values([
1411
- {
1412
- id: "item-conflict-existing-unrelated",
1413
- kind: "preference",
1414
- subject: "database",
1415
- statement: "Use Postgres by default.",
1416
- status: "active",
1417
- confidence: 0.8,
1418
- fingerprint: "fp-conflict-existing-unrelated",
1419
- verificationState: "user_reported",
1420
- scopeId: "scope-conflicts-unrelated",
1421
- firstSeenAt: now - 10_000,
1422
- lastSeenAt: now - 5_000,
1423
- validFrom: now - 10_000,
1424
- invalidAt: null,
1425
- },
1426
- {
1427
- id: "item-conflict-candidate-unrelated",
1428
- kind: "preference",
1429
- subject: "database",
1430
- statement: "Use MySQL by default.",
1431
- status: "pending_clarification",
1432
- confidence: 0.8,
1433
- fingerprint: "fp-conflict-candidate-unrelated",
1434
- verificationState: "user_reported",
1435
- scopeId: "scope-conflicts-unrelated",
1436
- firstSeenAt: now - 9_000,
1437
- lastSeenAt: now - 4_000,
1438
- validFrom: now - 9_000,
1439
- invalidAt: null,
1440
- },
1441
- ])
1442
- .run();
1443
-
1444
- const conflict = createOrUpdatePendingConflict({
1445
- scopeId: "scope-conflicts-unrelated",
1446
- existingItemId: "item-conflict-existing-unrelated",
1447
- candidateItemId: "item-conflict-candidate-unrelated",
1448
- relationship: "ambiguous_contradiction",
1449
- });
1450
- db.update(memoryItemConflicts)
1451
- .set({ createdAt: now, updatedAt: now, lastAskedAt: null })
1452
- .where(eq(memoryItemConflicts.id, conflict.id))
1453
- .run();
1454
-
1455
- enqueueResolvePendingConflictsForMessageJob(
1456
- "msg-conflicts-unrelated",
1457
- "scope-conflicts-unrelated",
1458
- );
1459
- const processed = await runMemoryJobsOnce();
1460
- expect(processed).toBe(1);
1461
-
1462
- const existing = db
1463
- .select()
1464
- .from(memoryItems)
1465
- .where(eq(memoryItems.id, "item-conflict-existing-unrelated"))
1466
- .get();
1467
- const candidate = db
1468
- .select()
1469
- .from(memoryItems)
1470
- .where(eq(memoryItems.id, "item-conflict-candidate-unrelated"))
1471
- .get();
1472
- const updatedConflict = getConflictById(conflict.id);
1473
-
1474
- expect(existing?.status).toBe("active");
1475
- expect(existing?.invalidAt).toBeNull();
1476
- expect(candidate?.status).toBe("pending_clarification");
1477
- expect(updatedConflict?.status).toBe("pending_clarification");
1478
- expect(updatedConflict?.resolutionNote).toBeNull();
1479
- } finally {
1480
- TEST_CONFIG.memory.conflicts.enabled = originalConflictsEnabled;
1481
- }
1482
- });
1483
-
1484
- test("background conflict resolver dismisses transient/non-durable conflicts without LLM call", async () => {
1485
- const db = getDb();
1486
- const now = 1_700_001_500_000;
1487
- const originalConflictsEnabled = TEST_CONFIG.memory.conflicts.enabled;
1488
- TEST_CONFIG.memory.conflicts.enabled = true;
1489
-
1490
- try {
1491
- db.insert(conversations)
1492
- .values({
1493
- id: "conv-conflicts-transient",
1494
- title: null,
1495
- createdAt: now,
1496
- updatedAt: now,
1497
- totalInputTokens: 0,
1498
- totalOutputTokens: 0,
1499
- totalEstimatedCost: 0,
1500
- contextSummary: null,
1501
- contextCompactedMessageCount: 0,
1502
- contextCompactedAt: null,
1503
- })
1504
- .run();
1505
-
1506
- db.insert(messages)
1507
- .values({
1508
- id: "msg-conflicts-transient",
1509
- conversationId: "conv-conflicts-transient",
1510
- role: "user",
1511
- content: JSON.stringify([
1512
- { type: "text", text: "Keep the new one instead." },
1513
- ]),
1514
- createdAt: now + 1,
1515
- })
1516
- .run();
1517
-
1518
- // Create a transient conflict: PR tracking statements should be dismissed
1519
- db.insert(memoryItems)
1520
- .values([
1521
- {
1522
- id: "item-conflict-existing-transient",
1523
- kind: "preference",
1524
- subject: "pr-tracking",
1525
- statement: "Currently tracking PR #42 for review.",
1526
- status: "active",
1527
- confidence: 0.8,
1528
- fingerprint: "fp-conflict-existing-transient",
1529
- verificationState: "assistant_inferred",
1530
- scopeId: "scope-conflicts-transient",
1531
- firstSeenAt: now - 10_000,
1532
- lastSeenAt: now - 5_000,
1533
- validFrom: now - 10_000,
1534
- invalidAt: null,
1535
- },
1536
- {
1537
- id: "item-conflict-candidate-transient",
1538
- kind: "preference",
1539
- subject: "pr-tracking",
1540
- statement: "Currently tracking PR #99 for review.",
1541
- status: "pending_clarification",
1542
- confidence: 0.8,
1543
- fingerprint: "fp-conflict-candidate-transient",
1544
- verificationState: "assistant_inferred",
1545
- scopeId: "scope-conflicts-transient",
1546
- firstSeenAt: now - 9_000,
1547
- lastSeenAt: now - 4_000,
1548
- validFrom: now - 9_000,
1549
- invalidAt: null,
1550
- },
1551
- ])
1552
- .run();
1553
-
1554
- const conflict = createOrUpdatePendingConflict({
1555
- scopeId: "scope-conflicts-transient",
1556
- existingItemId: "item-conflict-existing-transient",
1557
- candidateItemId: "item-conflict-candidate-transient",
1558
- relationship: "ambiguous_contradiction",
1559
- });
1560
- db.update(memoryItemConflicts)
1561
- .set({ createdAt: now, updatedAt: now })
1562
- .where(eq(memoryItemConflicts.id, conflict.id))
1563
- .run();
1564
-
1565
- enqueueResolvePendingConflictsForMessageJob(
1566
- "msg-conflicts-transient",
1567
- "scope-conflicts-transient",
1568
- );
1569
- const processed = await runMemoryJobsOnce();
1570
- expect(processed).toBe(1);
1571
-
1572
- const updatedConflict = getConflictById(conflict.id);
1573
- expect(updatedConflict?.status).toBe("dismissed");
1574
- expect(updatedConflict?.resolutionNote).toContain("conflict policy");
1575
-
1576
- // Memory items should remain untouched (no LLM resolution was attempted)
1577
- const existing = db
1578
- .select()
1579
- .from(memoryItems)
1580
- .where(eq(memoryItems.id, "item-conflict-existing-transient"))
1581
- .get();
1582
- const candidate = db
1583
- .select()
1584
- .from(memoryItems)
1585
- .where(eq(memoryItems.id, "item-conflict-candidate-transient"))
1586
- .get();
1587
- expect(existing?.status).toBe("active");
1588
- expect(candidate?.status).toBe("pending_clarification");
1589
- } finally {
1590
- TEST_CONFIG.memory.conflicts.enabled = originalConflictsEnabled;
1591
- }
1592
- });
1593
-
1594
- test("cleanup job enqueue is deduped and retention overrides upgrade payload", () => {
1595
- const db = getDb();
1596
-
1597
- const resolvedFirst = enqueueCleanupResolvedConflictsJob();
1598
- const resolvedSecond = enqueueCleanupResolvedConflictsJob();
1599
- expect(resolvedSecond).toBe(resolvedFirst);
1600
- const resolvedUpgraded = enqueueCleanupResolvedConflictsJob(12_345);
1601
- expect(resolvedUpgraded).toBe(resolvedFirst);
1602
-
1603
- const supersededFirst = enqueueCleanupStaleSupersededItemsJob();
1604
- const supersededSecond = enqueueCleanupStaleSupersededItemsJob();
1605
- expect(supersededSecond).toBe(supersededFirst);
1606
- const supersededUpgraded = enqueueCleanupStaleSupersededItemsJob(67_890);
1607
- expect(supersededUpgraded).toBe(supersededFirst);
1608
-
1609
- const resolvedRow = db
1610
- .select()
1611
- .from(memoryJobs)
1612
- .where(eq(memoryJobs.id, resolvedFirst))
1613
- .get();
1614
- const supersededRow = db
1615
- .select()
1616
- .from(memoryJobs)
1617
- .where(eq(memoryJobs.id, supersededFirst))
1618
- .get();
1619
- expect(JSON.parse(resolvedRow?.payload ?? "{}")).toMatchObject({
1620
- retentionMs: 12_345,
1621
- });
1622
- expect(JSON.parse(supersededRow?.payload ?? "{}")).toMatchObject({
1623
- retentionMs: 67_890,
1624
- });
1625
- });
1626
-
1627
- test("cleanup job enqueue dedupes against running jobs without mutating payload", () => {
1628
- const db = getDb();
1629
-
1630
- const resolvedId = enqueueCleanupResolvedConflictsJob(10_000);
1631
- const supersededId = enqueueCleanupStaleSupersededItemsJob(20_000);
1632
-
1633
- db.update(memoryJobs)
1634
- .set({ status: "running" })
1635
- .where(eq(memoryJobs.id, resolvedId))
1636
- .run();
1637
- db.update(memoryJobs)
1638
- .set({ status: "running" })
1639
- .where(eq(memoryJobs.id, supersededId))
1640
- .run();
1641
-
1642
- const resolvedDedupedId = enqueueCleanupResolvedConflictsJob(11_111);
1643
- const supersededDedupedId = enqueueCleanupStaleSupersededItemsJob(22_222);
1644
- expect(resolvedDedupedId).toBe(resolvedId);
1645
- expect(supersededDedupedId).toBe(supersededId);
1646
-
1647
- const resolvedRow = db
1648
- .select()
1649
- .from(memoryJobs)
1650
- .where(eq(memoryJobs.id, resolvedId))
1651
- .get();
1652
- const supersededRow = db
1653
- .select()
1654
- .from(memoryJobs)
1655
- .where(eq(memoryJobs.id, supersededId))
1656
- .get();
1657
- expect(JSON.parse(resolvedRow?.payload ?? "{}")).toMatchObject({
1658
- retentionMs: 10_000,
1659
- });
1660
- expect(JSON.parse(supersededRow?.payload ?? "{}")).toMatchObject({
1661
- retentionMs: 20_000,
1662
- });
1663
- });
1664
-
1665
- test("scheduled cleanup enqueue respects throttle and config retention values", () => {
1666
- const db = getDb();
1667
- const originalCleanup = { ...TEST_CONFIG.memory.cleanup };
1668
- TEST_CONFIG.memory.cleanup.enabled = true;
1669
- TEST_CONFIG.memory.cleanup.enqueueIntervalMs = 1_000;
1670
- TEST_CONFIG.memory.cleanup.resolvedConflictRetentionMs = 12_345;
1671
- TEST_CONFIG.memory.cleanup.supersededItemRetentionMs = 67_890;
1672
-
1673
- try {
1674
- const first = maybeEnqueueScheduledCleanupJobs(TEST_CONFIG, 5_000);
1675
- expect(first).toBe(true);
1676
-
1677
- const tooSoon = maybeEnqueueScheduledCleanupJobs(TEST_CONFIG, 5_500);
1678
- expect(tooSoon).toBe(false);
1679
-
1680
- const jobsAfterFirst = db.select().from(memoryJobs).all();
1681
- const resolvedJob = jobsAfterFirst.find(
1682
- (row) => row.type === "cleanup_resolved_conflicts",
1683
- );
1684
- const supersededJob = jobsAfterFirst.find(
1685
- (row) => row.type === "cleanup_stale_superseded_items",
1686
- );
1687
- expect(resolvedJob).toBeDefined();
1688
- expect(supersededJob).toBeDefined();
1689
- expect(JSON.parse(resolvedJob?.payload ?? "{}")).toMatchObject({
1690
- retentionMs: 12_345,
1691
- });
1692
- expect(JSON.parse(supersededJob?.payload ?? "{}")).toMatchObject({
1693
- retentionMs: 67_890,
1694
- });
1695
-
1696
- const secondWindow = maybeEnqueueScheduledCleanupJobs(TEST_CONFIG, 6_500);
1697
- expect(secondWindow).toBe(true);
1698
- const jobsAfterSecond = db.select().from(memoryJobs).all();
1699
- expect(
1700
- jobsAfterSecond.filter(
1701
- (row) => row.type === "cleanup_resolved_conflicts",
1702
- ).length,
1703
- ).toBe(1);
1704
- expect(
1705
- jobsAfterSecond.filter(
1706
- (row) => row.type === "cleanup_stale_superseded_items",
1707
- ).length,
1708
- ).toBe(1);
1709
- } finally {
1710
- TEST_CONFIG.memory.cleanup = originalCleanup;
1711
- }
1712
- });
1713
-
1714
- test("cleanup jobs use config retention defaults when payload retention is missing", async () => {
1715
- const db = getDb();
1716
- const now = Date.now();
1717
- const originalCleanup = { ...TEST_CONFIG.memory.cleanup };
1718
- TEST_CONFIG.memory.cleanup.resolvedConflictRetentionMs = 10_000;
1719
- TEST_CONFIG.memory.cleanup.supersededItemRetentionMs = 10_000;
1720
-
1721
- try {
1722
- db.insert(memoryItems)
1723
- .values([
1724
- {
1725
- id: "cleanup-config-existing",
1726
- kind: "fact",
1727
- subject: "stack",
1728
- statement: "Use Bun",
1729
- status: "active",
1730
- confidence: 0.8,
1731
- fingerprint: "fp-cleanup-config-existing",
1732
- verificationState: "assistant_inferred",
1733
- scopeId: "default",
1734
- firstSeenAt: now - 20_000,
1735
- lastSeenAt: now - 20_000,
1736
- },
1737
- {
1738
- id: "cleanup-config-candidate",
1739
- kind: "fact",
1740
- subject: "stack",
1741
- statement: "Use Node",
1742
- status: "pending_clarification",
1743
- confidence: 0.8,
1744
- fingerprint: "fp-cleanup-config-candidate",
1745
- verificationState: "assistant_inferred",
1746
- scopeId: "default",
1747
- firstSeenAt: now - 20_000,
1748
- lastSeenAt: now - 20_000,
1749
- },
1750
- {
1751
- id: "cleanup-config-stale-item",
1752
- kind: "decision",
1753
- subject: "deploy strategy",
1754
- statement: "Manual deploy Fridays.",
1755
- status: "superseded",
1756
- confidence: 0.7,
1757
- fingerprint: "fp-cleanup-config-stale-item",
1758
- verificationState: "assistant_inferred",
1759
- scopeId: "default",
1760
- firstSeenAt: now - 200_000,
1761
- lastSeenAt: now - 200_000,
1762
- invalidAt: now - 200_000,
1763
- },
1764
- ])
1765
- .run();
1766
-
1767
- const conflict = createOrUpdatePendingConflict({
1768
- scopeId: "default",
1769
- existingItemId: "cleanup-config-existing",
1770
- candidateItemId: "cleanup-config-candidate",
1771
- relationship: "ambiguous_contradiction",
1772
- });
1773
- resolveConflict(conflict.id, { status: "resolved_keep_existing" });
1774
- db.run(`
1775
- UPDATE memory_item_conflicts
1776
- SET resolved_at = ${now - 100_000}, updated_at = ${now - 100_000}
1777
- WHERE id = '${conflict.id}'
1778
- `);
1779
-
1780
- enqueueMemoryJob("cleanup_resolved_conflicts", {});
1781
- enqueueMemoryJob("cleanup_stale_superseded_items", {});
1782
- const processed = await runMemoryJobsOnce();
1783
- expect(processed).toBe(2);
1784
-
1785
- const conflictRow = db
1786
- .select()
1787
- .from(memoryItemConflicts)
1788
- .where(eq(memoryItemConflicts.id, conflict.id))
1789
- .get();
1790
- const staleItem = db
1791
- .select()
1792
- .from(memoryItems)
1793
- .where(eq(memoryItems.id, "cleanup-config-stale-item"))
1794
- .get();
1795
- expect(conflictRow).toBeUndefined();
1796
- expect(staleItem).toBeUndefined();
1797
- } finally {
1798
- TEST_CONFIG.memory.cleanup = originalCleanup;
1799
- }
1800
- });
1801
-
1802
- test("cleanup_resolved_conflicts removes stale resolved rows but keeps recent/pending", async () => {
1803
- const db = getDb();
1804
- const now = Date.now();
1805
-
1806
- db.insert(memoryItems)
1807
- .values([
1808
- {
1809
- id: "cleanup-conflict-existing-a",
1810
- kind: "fact",
1811
- subject: "db",
1812
- statement: "Use Postgres.",
1813
- status: "active",
1814
- confidence: 0.8,
1815
- fingerprint: "fp-cleanup-conflict-existing-a",
1816
- verificationState: "assistant_inferred",
1817
- scopeId: "default",
1818
- firstSeenAt: now - 20_000,
1819
- lastSeenAt: now - 20_000,
1820
- },
1821
- {
1822
- id: "cleanup-conflict-candidate-a",
1823
- kind: "fact",
1824
- subject: "db",
1825
- statement: "Use MySQL.",
1826
- status: "pending_clarification",
1827
- confidence: 0.8,
1828
- fingerprint: "fp-cleanup-conflict-candidate-a",
1829
- verificationState: "assistant_inferred",
1830
- scopeId: "default",
1831
- firstSeenAt: now - 20_000,
1832
- lastSeenAt: now - 20_000,
1833
- },
1834
- {
1835
- id: "cleanup-conflict-existing-b",
1836
- kind: "fact",
1837
- subject: "frontend",
1838
- statement: "Use React.",
1839
- status: "active",
1840
- confidence: 0.8,
1841
- fingerprint: "fp-cleanup-conflict-existing-b",
1842
- verificationState: "assistant_inferred",
1843
- scopeId: "default",
1844
- firstSeenAt: now - 20_000,
1845
- lastSeenAt: now - 20_000,
1846
- },
1847
- {
1848
- id: "cleanup-conflict-candidate-b",
1849
- kind: "fact",
1850
- subject: "frontend",
1851
- statement: "Use Vue.",
1852
- status: "pending_clarification",
1853
- confidence: 0.8,
1854
- fingerprint: "fp-cleanup-conflict-candidate-b",
1855
- verificationState: "assistant_inferred",
1856
- scopeId: "default",
1857
- firstSeenAt: now - 20_000,
1858
- lastSeenAt: now - 20_000,
1859
- },
1860
- {
1861
- id: "cleanup-conflict-existing-c",
1862
- kind: "fact",
1863
- subject: "orm",
1864
- statement: "Use Drizzle.",
1865
- status: "active",
1866
- confidence: 0.8,
1867
- fingerprint: "fp-cleanup-conflict-existing-c",
1868
- verificationState: "assistant_inferred",
1869
- scopeId: "default",
1870
- firstSeenAt: now - 20_000,
1871
- lastSeenAt: now - 20_000,
1872
- },
1873
- {
1874
- id: "cleanup-conflict-candidate-c",
1875
- kind: "fact",
1876
- subject: "orm",
1877
- statement: "Use Prisma.",
1878
- status: "pending_clarification",
1879
- confidence: 0.8,
1880
- fingerprint: "fp-cleanup-conflict-candidate-c",
1881
- verificationState: "assistant_inferred",
1882
- scopeId: "default",
1883
- firstSeenAt: now - 20_000,
1884
- lastSeenAt: now - 20_000,
1885
- },
1886
- ])
1887
- .run();
1888
-
1889
- const staleResolved = createOrUpdatePendingConflict({
1890
- scopeId: "default",
1891
- existingItemId: "cleanup-conflict-existing-a",
1892
- candidateItemId: "cleanup-conflict-candidate-a",
1893
- relationship: "ambiguous_contradiction",
1894
- });
1895
- const pendingConflict = createOrUpdatePendingConflict({
1896
- scopeId: "default",
1897
- existingItemId: "cleanup-conflict-existing-b",
1898
- candidateItemId: "cleanup-conflict-candidate-b",
1899
- relationship: "ambiguous_contradiction",
1900
- });
1901
- const recentResolved = createOrUpdatePendingConflict({
1902
- scopeId: "default",
1903
- existingItemId: "cleanup-conflict-existing-c",
1904
- candidateItemId: "cleanup-conflict-candidate-c",
1905
- relationship: "ambiguous_contradiction",
1906
- clarificationQuestion: "Recent resolution row",
1907
- });
1908
-
1909
- resolveConflict(staleResolved.id, { status: "resolved_keep_existing" });
1910
- resolveConflict(recentResolved.id, { status: "resolved_keep_candidate" });
1911
-
1912
- db.run(`
1913
- UPDATE memory_item_conflicts
1914
- SET resolved_at = ${now - 100_000}, updated_at = ${now - 100_000}
1915
- WHERE id = '${staleResolved.id}'
1916
- `);
1917
- db.run(`
1918
- UPDATE memory_item_conflicts
1919
- SET resolved_at = ${now - 100}, updated_at = ${now - 100}
1920
- WHERE id = '${recentResolved.id}'
1921
- `);
1922
-
1923
- enqueueMemoryJob("cleanup_resolved_conflicts", { retentionMs: 10_000 });
1924
- const processed = await runMemoryJobsOnce();
1925
- expect(processed).toBe(1);
1926
-
1927
- const staleRow = db
1928
- .select()
1929
- .from(memoryItemConflicts)
1930
- .where(eq(memoryItemConflicts.id, staleResolved.id))
1931
- .get();
1932
- const pendingRow = db
1933
- .select()
1934
- .from(memoryItemConflicts)
1935
- .where(eq(memoryItemConflicts.id, pendingConflict.id))
1936
- .get();
1937
- const recentRow = db
1938
- .select()
1939
- .from(memoryItemConflicts)
1940
- .where(eq(memoryItemConflicts.id, recentResolved.id))
1941
- .get();
1942
- expect(staleRow).toBeUndefined();
1943
- expect(pendingRow?.status).toBe("pending_clarification");
1944
- expect(recentRow?.status).toBe("resolved_keep_candidate");
1945
- });
1946
-
1947
- test("cleanup_stale_superseded_items removes stale superseded rows, embeddings, and entity links", async () => {
1948
- const db = getDb();
1949
- const now = Date.now();
1950
-
1951
- db.insert(memoryItems)
1952
- .values([
1953
- {
1954
- id: "cleanup-stale-item",
1955
- kind: "decision",
1956
- subject: "deploy strategy",
1957
- statement: "Deploy manually every Friday.",
1958
- status: "superseded",
1959
- confidence: 0.7,
1960
- fingerprint: "fp-cleanup-stale-item",
1961
- verificationState: "assistant_inferred",
1962
- scopeId: "default",
1963
- firstSeenAt: now - 200_000,
1964
- lastSeenAt: now - 200_000,
1965
- invalidAt: now - 200_000,
1966
- },
1967
- {
1968
- id: "cleanup-recent-item",
1969
- kind: "decision",
1970
- subject: "deploy strategy",
1971
- statement: "Deploy continuously via CI.",
1972
- status: "superseded",
1973
- confidence: 0.7,
1974
- fingerprint: "fp-cleanup-recent-item",
1975
- verificationState: "assistant_inferred",
1976
- scopeId: "default",
1977
- firstSeenAt: now - 200_000,
1978
- lastSeenAt: now - 200_000,
1979
- invalidAt: now - 100,
1980
- },
1981
- ])
1982
- .run();
1983
-
1984
- db.insert(memoryEmbeddings)
1985
- .values([
1986
- {
1987
- id: "cleanup-embed-stale",
1988
- targetType: "item",
1989
- targetId: "cleanup-stale-item",
1990
- provider: "openai",
1991
- model: "text-embedding-3-small",
1992
- dimensions: 3,
1993
- vectorBlob: vectorToBlob([0, 0, 0]),
1994
- createdAt: now - 1000,
1995
- updatedAt: now - 1000,
1996
- },
1997
- {
1998
- id: "cleanup-embed-recent",
1999
- targetType: "item",
2000
- targetId: "cleanup-recent-item",
2001
- provider: "openai",
2002
- model: "text-embedding-3-small",
2003
- dimensions: 3,
2004
- vectorBlob: vectorToBlob([0, 0, 0]),
2005
- createdAt: now - 1000,
2006
- updatedAt: now - 1000,
2007
- },
2008
- ])
2009
- .run();
2010
-
2011
- // Create entity links for both items (no FK cascade on this table)
2012
- db.insert(memoryEntities)
2013
- .values({
2014
- id: "cleanup-entity",
2015
- name: "Deployment",
2016
- type: "concept",
2017
- aliases: JSON.stringify([]),
2018
- description: null,
2019
- firstSeenAt: now - 200_000,
2020
- lastSeenAt: now - 200_000,
2021
- mentionCount: 2,
2022
- })
2023
- .run();
2024
- db.insert(memoryItemEntities)
2025
- .values([
2026
- { memoryItemId: "cleanup-stale-item", entityId: "cleanup-entity" },
2027
- { memoryItemId: "cleanup-recent-item", entityId: "cleanup-entity" },
2028
- ])
2029
- .run();
2030
-
2031
- enqueueMemoryJob("cleanup_stale_superseded_items", { retentionMs: 10_000 });
2032
- const processed = await runMemoryJobsOnce();
2033
- expect(processed).toBe(1);
2034
-
2035
- const staleItem = db
2036
- .select()
2037
- .from(memoryItems)
2038
- .where(eq(memoryItems.id, "cleanup-stale-item"))
2039
- .get();
2040
- const recentItem = db
2041
- .select()
2042
- .from(memoryItems)
2043
- .where(eq(memoryItems.id, "cleanup-recent-item"))
2044
- .get();
2045
- const staleEmbedding = db
2046
- .select()
2047
- .from(memoryEmbeddings)
2048
- .where(eq(memoryEmbeddings.id, "cleanup-embed-stale"))
2049
- .get();
2050
- const recentEmbedding = db
2051
- .select()
2052
- .from(memoryEmbeddings)
2053
- .where(eq(memoryEmbeddings.id, "cleanup-embed-recent"))
2054
- .get();
2055
-
2056
- // Entity links for stale item should be removed; recent item's links should remain
2057
- const staleEntityLinks = db
2058
- .select()
2059
- .from(memoryItemEntities)
2060
- .where(eq(memoryItemEntities.memoryItemId, "cleanup-stale-item"))
2061
- .all();
2062
- const recentEntityLinks = db
2063
- .select()
2064
- .from(memoryItemEntities)
2065
- .where(eq(memoryItemEntities.memoryItemId, "cleanup-recent-item"))
2066
- .all();
2067
-
2068
- expect(staleItem).toBeUndefined();
2069
- expect(recentItem).toBeDefined();
2070
- expect(staleEmbedding).toBeUndefined();
2071
- expect(recentEmbedding).toBeDefined();
2072
- expect(staleEntityLinks).toHaveLength(0);
2073
- expect(recentEntityLinks).toHaveLength(1);
2074
- });
2075
-
2076
- test("memory admin status reports pending/resolved conflicts and oldest pending age", () => {
2077
- const db = getDb();
2078
- const now = Date.now();
2079
-
2080
- db.insert(memoryItems)
2081
- .values([
2082
- {
2083
- id: "status-conflict-existing",
2084
- kind: "fact",
2085
- subject: "editor",
2086
- statement: "Use Neovim.",
2087
- status: "active",
2088
- confidence: 0.8,
2089
- fingerprint: "fp-status-existing",
2090
- verificationState: "assistant_inferred",
2091
- scopeId: "default",
2092
- firstSeenAt: now - 10_000,
2093
- lastSeenAt: now - 10_000,
2094
- },
2095
- {
2096
- id: "status-conflict-candidate",
2097
- kind: "fact",
2098
- subject: "editor",
2099
- statement: "Use VS Code.",
2100
- status: "pending_clarification",
2101
- confidence: 0.8,
2102
- fingerprint: "fp-status-candidate",
2103
- verificationState: "assistant_inferred",
2104
- scopeId: "default",
2105
- firstSeenAt: now - 10_000,
2106
- lastSeenAt: now - 10_000,
2107
- },
2108
- {
2109
- id: "status-conflict-existing-2",
2110
- kind: "fact",
2111
- subject: "shell",
2112
- statement: "Use zsh.",
2113
- status: "active",
2114
- confidence: 0.8,
2115
- fingerprint: "fp-status-existing-2",
2116
- verificationState: "assistant_inferred",
2117
- scopeId: "default",
2118
- firstSeenAt: now - 10_000,
2119
- lastSeenAt: now - 10_000,
2120
- },
2121
- {
2122
- id: "status-conflict-candidate-2",
2123
- kind: "fact",
2124
- subject: "shell",
2125
- statement: "Use fish.",
2126
- status: "pending_clarification",
2127
- confidence: 0.8,
2128
- fingerprint: "fp-status-candidate-2",
2129
- verificationState: "assistant_inferred",
2130
- scopeId: "default",
2131
- firstSeenAt: now - 10_000,
2132
- lastSeenAt: now - 10_000,
2133
- },
2134
- ])
2135
- .run();
2136
-
2137
- const pending = createOrUpdatePendingConflict({
2138
- scopeId: "default",
2139
- existingItemId: "status-conflict-existing",
2140
- candidateItemId: "status-conflict-candidate",
2141
- relationship: "ambiguous_contradiction",
2142
- });
2143
- const resolved = createOrUpdatePendingConflict({
2144
- scopeId: "default",
2145
- existingItemId: "status-conflict-existing-2",
2146
- candidateItemId: "status-conflict-candidate-2",
2147
- relationship: "ambiguous_contradiction",
2148
- clarificationQuestion: "resolved-row",
2149
- });
2150
- resolveConflict(resolved.id, { status: "resolved_merge" });
2151
-
2152
- db.run(
2153
- `UPDATE memory_item_conflicts SET created_at = ${
2154
- now - 5_000
2155
- } WHERE id = '${pending.id}'`,
2156
- );
2157
-
2158
- const status = getMemorySystemStatus();
2159
- expect(status.conflicts.pending).toBe(1);
2160
- expect(status.conflicts.resolved).toBe(1);
2161
- expect(status.conflicts.oldestPendingAgeMs).not.toBeNull();
2162
- expect((status.conflicts.oldestPendingAgeMs ?? 0) >= 4_000).toBe(true);
2163
- expect(status.cleanup.resolvedBacklog).toBe(0);
2164
- expect(status.cleanup.supersededBacklog).toBe(0);
2165
- expect(status.cleanup.resolvedCompleted24h).toBe(0);
2166
- expect(status.cleanup.supersededCompleted24h).toBe(0);
2167
- });
2168
-
2169
- test("memory admin status reports cleanup backlog and 24h throughput metrics", () => {
2170
- const db = getDb();
2171
- const now = Date.now();
2172
- const yesterday = now - 20 * 60 * 60 * 1000;
2173
- const old = now - 40 * 60 * 60 * 1000;
2174
-
2175
- db.insert(memoryJobs)
2176
- .values([
2177
- {
2178
- id: "cleanup-status-pending-resolved",
2179
- type: "cleanup_resolved_conflicts",
2180
- payload: "{}",
2181
- status: "pending",
2182
- attempts: 0,
2183
- deferrals: 0,
2184
- runAfter: now,
2185
- lastError: null,
2186
- createdAt: now,
2187
- updatedAt: now,
2188
- },
2189
- {
2190
- id: "cleanup-status-running-superseded",
2191
- type: "cleanup_stale_superseded_items",
2192
- payload: "{}",
2193
- status: "running",
2194
- attempts: 0,
2195
- deferrals: 0,
2196
- runAfter: now,
2197
- lastError: null,
2198
- createdAt: now,
2199
- updatedAt: now,
2200
- },
2201
- {
2202
- id: "cleanup-status-completed-resolved-recent",
2203
- type: "cleanup_resolved_conflicts",
2204
- payload: "{}",
2205
- status: "completed",
2206
- attempts: 1,
2207
- deferrals: 0,
2208
- runAfter: yesterday,
2209
- lastError: null,
2210
- createdAt: yesterday,
2211
- updatedAt: yesterday,
2212
- },
2213
- {
2214
- id: "cleanup-status-completed-superseded-recent",
2215
- type: "cleanup_stale_superseded_items",
2216
- payload: "{}",
2217
- status: "completed",
2218
- attempts: 1,
2219
- deferrals: 0,
2220
- runAfter: yesterday,
2221
- lastError: null,
2222
- createdAt: yesterday,
2223
- updatedAt: yesterday,
2224
- },
2225
- {
2226
- id: "cleanup-status-completed-resolved-old",
2227
- type: "cleanup_resolved_conflicts",
2228
- payload: "{}",
2229
- status: "completed",
2230
- attempts: 1,
2231
- deferrals: 0,
2232
- runAfter: old,
2233
- lastError: null,
2234
- createdAt: old,
2235
- updatedAt: old,
2236
- },
2237
- ])
2238
- .run();
2239
-
2240
- const status = getMemorySystemStatus();
2241
- expect(status.cleanup.resolvedBacklog).toBe(1);
2242
- expect(status.cleanup.supersededBacklog).toBe(1);
2243
- expect(status.cleanup.resolvedCompleted24h).toBe(1);
2244
- expect(status.cleanup.supersededCompleted24h).toBe(1);
2245
- });
2246
-
2247
- test("requestMemoryCleanup queues both cleanup job types", () => {
2248
- const db = getDb();
2249
- const queued = requestMemoryCleanup(9_999);
2250
- expect(queued.resolvedConflictsJobId).toBeTruthy();
2251
- expect(queued.staleSupersededItemsJobId).toBeTruthy();
2252
-
2253
- const resolvedRow = db
2254
- .select()
2255
- .from(memoryJobs)
2256
- .where(eq(memoryJobs.id, queued.resolvedConflictsJobId))
2257
- .get();
2258
- const supersededRow = db
2259
- .select()
2260
- .from(memoryJobs)
2261
- .where(eq(memoryJobs.id, queued.staleSupersededItemsJobId))
2262
- .get();
2263
- expect(resolvedRow?.type).toBe("cleanup_resolved_conflicts");
2264
- expect(supersededRow?.type).toBe("cleanup_stale_superseded_items");
2265
- });
2266
-
2267
- test("relation backfill advances checkpoints in deterministic batches", async () => {
2268
- const db = getDb();
2269
- const now = 1_700_001_000_000;
2270
- const originalEnabled = TEST_CONFIG.memory.entity.extractRelations.enabled;
2271
- const originalBatchSize =
2272
- TEST_CONFIG.memory.entity.extractRelations.backfillBatchSize;
2273
- TEST_CONFIG.memory.entity.extractRelations.enabled = true;
2274
- TEST_CONFIG.memory.entity.extractRelations.backfillBatchSize = 2;
2275
-
2276
- try {
2277
- db.insert(conversations)
2278
- .values({
2279
- id: "conv-rel-backfill",
2280
- title: null,
2281
- createdAt: now,
2282
- updatedAt: now,
2283
- totalInputTokens: 0,
2284
- totalOutputTokens: 0,
2285
- totalEstimatedCost: 0,
2286
- contextSummary: null,
2287
- contextCompactedMessageCount: 0,
2288
- contextCompactedAt: null,
2289
- })
2290
- .run();
2291
-
2292
- db.insert(messages)
2293
- .values([
2294
- {
2295
- id: "msg-rel-backfill-1",
2296
- conversationId: "conv-rel-backfill",
2297
- role: "user",
2298
- content: JSON.stringify([
2299
- {
2300
- type: "text",
2301
- text: "Project Atlas uses Qdrant for memory search.",
2302
- },
2303
- ]),
2304
- createdAt: now + 1,
2305
- },
2306
- {
2307
- id: "msg-rel-backfill-2",
2308
- conversationId: "conv-rel-backfill",
2309
- role: "user",
2310
- content: JSON.stringify([
2311
- { type: "text", text: "Atlas collaborates with Orion." },
2312
- ]),
2313
- createdAt: now + 2,
2314
- },
2315
- {
2316
- id: "msg-rel-backfill-3",
2317
- conversationId: "conv-rel-backfill",
2318
- role: "user",
2319
- content: JSON.stringify([
2320
- { type: "text", text: "Orion depends on Redis caching." },
2321
- ]),
2322
- createdAt: now + 3,
2323
- },
2324
- ])
2325
- .run();
2326
-
2327
- enqueueBackfillEntityRelationsJob(true);
2328
-
2329
- const firstProcessed = await runMemoryJobsOnce();
2330
- expect(firstProcessed).toBe(1);
2331
- expect(
2332
- getMemoryCheckpoint("memory:relation_backfill:last_created_at"),
2333
- ).toBe(String(now + 2));
2334
- expect(
2335
- getMemoryCheckpoint("memory:relation_backfill:last_message_id"),
2336
- ).toBe("msg-rel-backfill-2");
2337
-
2338
- db.run(
2339
- `DELETE FROM memory_jobs WHERE type = 'extract_entities' AND status = 'pending'`,
2340
- );
2341
-
2342
- const secondProcessed = await runMemoryJobsOnce();
2343
- expect(secondProcessed).toBe(1);
2344
- expect(
2345
- getMemoryCheckpoint("memory:relation_backfill:last_created_at"),
2346
- ).toBe(String(now + 3));
2347
- expect(
2348
- getMemoryCheckpoint("memory:relation_backfill:last_message_id"),
2349
- ).toBe("msg-rel-backfill-3");
2350
-
2351
- const pendingBackfill = db
2352
- .select()
2353
- .from(memoryJobs)
2354
- .where(
2355
- and(
2356
- eq(memoryJobs.type, "backfill_entity_relations"),
2357
- eq(memoryJobs.status, "pending"),
2358
- ),
2359
- )
2360
- .all();
2361
- expect(pendingBackfill).toHaveLength(0);
2362
- } finally {
2363
- TEST_CONFIG.memory.entity.extractRelations.enabled = originalEnabled;
2364
- TEST_CONFIG.memory.entity.extractRelations.backfillBatchSize =
2365
- originalBatchSize;
2366
- }
2367
- });
2368
-
2369
- test("memory recall token budgeting includes recall marker overhead", async () => {
2370
- const db = getDb();
2371
- const createdAt = 1_700_000_300_000;
2372
- db.insert(conversations)
2373
- .values({
2374
- id: "conv-budget",
2375
- title: null,
2376
- createdAt,
2377
- updatedAt: createdAt,
2378
- totalInputTokens: 0,
2379
- totalOutputTokens: 0,
2380
- totalEstimatedCost: 0,
2381
- contextSummary: null,
2382
- contextCompactedMessageCount: 0,
2383
- contextCompactedAt: null,
2384
- })
2385
- .run();
2386
- db.insert(messages)
2387
- .values({
2388
- id: "msg-budget",
2389
- conversationId: "conv-budget",
2390
- role: "user",
2391
- content: JSON.stringify([
2392
- { type: "text", text: "remember budget token sentinel" },
2393
- ]),
2394
- createdAt,
2395
- })
2396
- .run();
2397
- db.run(`
2398
- INSERT INTO memory_segments (
2399
- id, message_id, conversation_id, role, segment_index, text, token_estimate, created_at, updated_at
2400
- ) VALUES (
2401
- 'seg-budget', 'msg-budget', 'conv-budget', 'user', 0, 'remember budget token sentinel', 6, ${createdAt}, ${createdAt}
2402
- )
2403
- `);
2404
-
2405
- const candidateLine =
2406
- "- <kind>segment:seg-budget</kind> remember budget token sentinel";
2407
- const lineOnlyTokens = estimateTextTokens(candidateLine);
2408
- const fullRecallTokens = estimateTextTokens(
2409
- '<memory source="long_term_memory" confidence="approximate">\n' +
2410
- `## Relevant Context\n${candidateLine}\n</memory>`,
2411
- );
2412
- expect(fullRecallTokens).toBeGreaterThan(lineOnlyTokens);
2413
-
2414
- const config = {
2415
- ...DEFAULT_CONFIG,
2416
- memory: {
2417
- ...DEFAULT_CONFIG.memory,
2418
- embeddings: {
2419
- ...DEFAULT_CONFIG.memory.embeddings,
2420
- required: false,
2421
- },
2422
- retrieval: {
2423
- ...DEFAULT_CONFIG.memory.retrieval,
2424
- maxInjectTokens: lineOnlyTokens,
2425
- },
2426
- },
2427
- };
2428
-
2429
- const recall = await buildMemoryRecall(
2430
- "budget sentinel",
2431
- "conv-budget",
2432
- config,
2433
- );
2434
- expect(recall.injectedText).toBe("");
2435
- expect(recall.injectedTokens).toBe(0);
2436
- });
2437
-
2438
- test("memory recall respects maxInjectTokensOverride when provided", async () => {
2439
- const db = getDb();
2440
- const createdAt = 1_700_000_301_000;
2441
- db.insert(conversations)
2442
- .values({
2443
- id: "conv-budget-override",
2444
- title: null,
2445
- createdAt,
2446
- updatedAt: createdAt,
2447
- totalInputTokens: 0,
2448
- totalOutputTokens: 0,
2449
- totalEstimatedCost: 0,
2450
- contextSummary: null,
2451
- contextCompactedMessageCount: 0,
2452
- contextCompactedAt: null,
2453
- })
2454
- .run();
2455
-
2456
- for (let i = 0; i < 4; i++) {
2457
- const msgId = `msg-budget-override-${i}`;
2458
- const segId = `seg-budget-override-${i}`;
2459
- const text = `budget override sentinel item ${i} with enough text to exceed tiny limits`;
2460
- db.insert(messages)
2461
- .values({
2462
- id: msgId,
2463
- conversationId: "conv-budget-override",
2464
- role: "user",
2465
- content: JSON.stringify([{ type: "text", text }]),
2466
- createdAt: createdAt + i,
2467
- })
2468
- .run();
2469
- db.run(`
2470
- INSERT INTO memory_segments (
2471
- id, message_id, conversation_id, role, segment_index, text, token_estimate, created_at, updated_at
2472
- ) VALUES (
2473
- '${segId}', '${msgId}', 'conv-budget-override', 'user', 0, '${text}', 20, ${
2474
- createdAt + i
2475
- }, ${createdAt + i}
2476
- )
2477
- `);
2478
- }
2479
-
2480
- const config = {
2481
- ...DEFAULT_CONFIG,
2482
- memory: {
2483
- ...DEFAULT_CONFIG.memory,
2484
- embeddings: {
2485
- ...DEFAULT_CONFIG.memory.embeddings,
2486
- provider: "openai" as const,
2487
- required: false,
2488
- },
2489
- retrieval: {
2490
- ...DEFAULT_CONFIG.memory.retrieval,
2491
- maxInjectTokens: 5000,
2492
- lexicalTopK: 10,
2493
- },
2494
- },
2495
- };
2496
-
2497
- const override = 120;
2498
- const recall = await buildMemoryRecall(
2499
- "budget override sentinel",
2500
- "conv-budget-override",
2501
- config,
2502
- { maxInjectTokensOverride: override },
2503
- );
2504
- expect(recall.injectedTokens).toBeLessThanOrEqual(override);
2505
- });
2506
-
2507
- test("claimMemoryJobs only returns rows it actually claimed", () => {
2508
- const db = getDb();
2509
- const jobId = enqueueMemoryJob("build_conversation_summary", {
2510
- conversationId: "conv-lock",
2511
- });
2512
- db.run(`
2513
- CREATE TEMP TRIGGER memory_jobs_claim_ignore
2514
- BEFORE UPDATE ON memory_jobs
2515
- WHEN NEW.status = 'running' AND OLD.id = '${jobId}'
2516
- BEGIN
2517
- SELECT RAISE(IGNORE);
2518
- END;
2519
- `);
2520
-
2521
- try {
2522
- const claimed = claimMemoryJobs(10);
2523
- expect(claimed).toHaveLength(0);
2524
- const row = db
2525
- .select()
2526
- .from(memoryJobs)
2527
- .where(eq(memoryJobs.id, jobId))
2528
- .get();
2529
- expect(row?.status).toBe("pending");
2530
- } finally {
2531
- db.run("DROP TRIGGER IF EXISTS memory_jobs_claim_ignore");
2532
- }
2533
- });
2534
-
2535
- test("formatAbsoluteTime returns YYYY-MM-DD HH:mm TZ format", () => {
2536
- // Use a fixed epoch-ms value; the rendered string depends on the local timezone,
2537
- // so we verify the structural format rather than exact values.
2538
- const epochMs = 1_707_850_200_000; // 2024-02-13 in UTC
2539
- const result = formatAbsoluteTime(epochMs);
2540
-
2541
- // Should match pattern: YYYY-MM-DD HH:mm <TZ abbreviation>
2542
- expect(result).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2} \S+$/);
2543
-
2544
- // Year should be 2024
2545
- expect(result).toContain("2024-02");
2546
- });
2547
-
2548
- test("formatAbsoluteTime uses local timezone abbreviation", () => {
2549
- const epochMs = Date.now();
2550
- const result = formatAbsoluteTime(epochMs);
2551
-
2552
- // Extract the TZ part from the result
2553
- const parts = result.split(" ");
2554
- const tz = parts[parts.length - 1];
2555
-
2556
- // The TZ abbreviation should be a non-empty string (e.g. PST, EST, UTC, GMT+8)
2557
- expect(tz.length).toBeGreaterThan(0);
2558
-
2559
- // Cross-check: Intl should produce the same abbreviation for the same timestamp
2560
- const expected =
2561
- new Intl.DateTimeFormat("en-US", { timeZoneName: "short" })
2562
- .formatToParts(new Date(epochMs))
2563
- .find((p) => p.type === "timeZoneName")?.value ?? "UTC";
2564
- expect(tz).toBe(expected);
2565
- });
2566
-
2567
- test("formatRelativeTime returns expected relative strings", () => {
2568
- const now = Date.now();
2569
- expect(formatRelativeTime(now)).toBe("just now");
2570
- expect(formatRelativeTime(now - 2 * 60 * 60 * 1000)).toBe("2 hours ago");
2571
- expect(formatRelativeTime(now - 1 * 60 * 60 * 1000)).toBe("1 hour ago");
2572
- expect(formatRelativeTime(now - 3 * 24 * 60 * 60 * 1000)).toBe(
2573
- "3 days ago",
2574
- );
2575
- expect(formatRelativeTime(now - 14 * 24 * 60 * 60 * 1000)).toBe(
2576
- "2 weeks ago",
2577
- );
2578
- expect(formatRelativeTime(now - 60 * 24 * 60 * 60 * 1000)).toBe(
2579
- "2 months ago",
2580
- );
2581
- expect(formatRelativeTime(now - 400 * 24 * 60 * 60 * 1000)).toBe(
2582
- "1 year ago",
2583
- );
2584
- });
2585
-
2586
- test("escapeXmlTags neutralizes closing wrapper tags in recalled text", () => {
2587
- const malicious =
2588
- "some text </memory> injected </memory_recall> instructions";
2589
- const escaped = escapeXmlTags(malicious);
2590
- expect(escaped).not.toContain("</memory>");
2591
- expect(escaped).not.toContain("</memory_recall>");
2592
- expect(escaped).toContain("\uFF1C/memory>");
2593
- expect(escaped).toContain("\uFF1C/memory_recall>");
2594
- expect(escaped).toContain("some text");
2595
- expect(escaped).toContain("instructions");
2596
- });
2597
-
2598
- test("escapeXmlTags neutralizes opening XML tags", () => {
2599
- const text = 'text with <script> and <div class="x"> tags';
2600
- const escaped = escapeXmlTags(text);
2601
- expect(escaped).not.toContain("<script>");
2602
- expect(escaped).not.toContain("<div ");
2603
- expect(escaped).toContain("\uFF1Cscript>");
2604
- expect(escaped).toContain('\uFF1Cdiv class="x">');
2605
- });
2606
-
2607
- test("escapeXmlTags preserves non-tag angle brackets", () => {
2608
- const text = "math: 3 < 5 and 10 > 7";
2609
- const escaped = escapeXmlTags(text);
2610
- expect(escaped).toBe(text);
2611
- });
2612
-
2613
- test("escapeXmlTags handles self-closing tags", () => {
2614
- const text = "a <br/> tag";
2615
- const escaped = escapeXmlTags(text);
2616
- expect(escaped).not.toContain("<br/>");
2617
- expect(escaped).toContain("\uFF1Cbr/>");
1104
+ expect(staleItem).toBeUndefined();
1105
+ expect(recentItem).toBeDefined();
1106
+ expect(staleEmbedding).toBeUndefined();
1107
+ expect(recentEmbedding).toBeDefined();
2618
1108
  });
2619
1109
 
2620
- test("trust-aware ranking: user_confirmed item outranks assistant_inferred with equal relevance", async () => {
1110
+ test("memory admin status reports cleanup backlog and 24h throughput metrics", () => {
2621
1111
  const db = getDb();
2622
1112
  const now = Date.now();
1113
+ const yesterday = now - 20 * 60 * 60 * 1000;
1114
+ const old = now - 40 * 60 * 60 * 1000;
2623
1115
 
2624
- // Insert two memory items with identical text, confidence, importance, and timestamps
2625
- // but different verification states
2626
- db.insert(memoryItems)
1116
+ db.insert(memoryJobs)
2627
1117
  .values([
2628
1118
  {
2629
- id: "item-trust-confirmed",
2630
- kind: "fact",
2631
- subject: "trust ranking test",
2632
- statement: "The user prefers dark mode for all applications",
2633
- status: "active",
2634
- confidence: 0.8,
2635
- importance: 0.5,
2636
- fingerprint: "fp-trust-confirmed",
2637
- firstSeenAt: now,
2638
- lastSeenAt: now,
2639
- accessCount: 0,
2640
- verificationState: "user_confirmed",
2641
- },
2642
- {
2643
- id: "item-trust-inferred",
2644
- kind: "fact",
2645
- subject: "trust ranking test",
2646
- statement: "The user prefers dark mode for all editors",
2647
- status: "active",
2648
- confidence: 0.8,
2649
- importance: 0.5,
2650
- fingerprint: "fp-trust-inferred",
2651
- firstSeenAt: now,
2652
- lastSeenAt: now,
2653
- accessCount: 0,
2654
- verificationState: "assistant_inferred",
2655
- },
2656
- ])
2657
- .run();
2658
-
2659
- const config = {
2660
- ...DEFAULT_CONFIG,
2661
- memory: {
2662
- ...DEFAULT_CONFIG.memory,
2663
- embeddings: {
2664
- ...DEFAULT_CONFIG.memory.embeddings,
2665
- required: false,
1119
+ id: "cleanup-status-running-superseded",
1120
+ type: "cleanup_stale_superseded_items",
1121
+ payload: "{}",
1122
+ status: "running",
1123
+ attempts: 0,
1124
+ deferrals: 0,
1125
+ runAfter: now,
1126
+ lastError: null,
1127
+ createdAt: now,
1128
+ updatedAt: now,
2666
1129
  },
2667
- },
2668
- };
2669
-
2670
- const recall = await buildMemoryRecall(
2671
- "dark mode",
2672
- "conv-trust-test",
2673
- config,
2674
- );
2675
-
2676
- // Both items should be found (directItemSearch matches on "dark" and "mode")
2677
- const confirmed = recall.topCandidates.find(
2678
- (c) => c.key === "item:item-trust-confirmed",
2679
- );
2680
- const inferred = recall.topCandidates.find(
2681
- (c) => c.key === "item:item-trust-inferred",
2682
- );
2683
- expect(confirmed).toBeDefined();
2684
- expect(inferred).toBeDefined();
2685
-
2686
- // user_confirmed (weight 1.0) should have a higher finalScore than assistant_inferred (weight 0.7)
2687
- expect(confirmed!.finalScore).toBeGreaterThan(inferred!.finalScore);
2688
- });
2689
-
2690
- test("trust-aware ranking: user_reported item outranks assistant_inferred", async () => {
2691
- const db = getDb();
2692
- const now = Date.now();
2693
-
2694
- db.insert(memoryItems)
2695
- .values([
2696
1130
  {
2697
- id: "item-trust-reported",
2698
- kind: "fact",
2699
- subject: "trust ranking reported",
2700
- statement: "The user uses vim keybindings in their editor",
2701
- status: "active",
2702
- confidence: 0.8,
2703
- importance: 0.5,
2704
- fingerprint: "fp-trust-reported",
2705
- firstSeenAt: now,
2706
- lastSeenAt: now,
2707
- accessCount: 0,
2708
- verificationState: "user_reported",
1131
+ id: "cleanup-status-completed-superseded-recent",
1132
+ type: "cleanup_stale_superseded_items",
1133
+ payload: "{}",
1134
+ status: "completed",
1135
+ attempts: 1,
1136
+ deferrals: 0,
1137
+ runAfter: yesterday,
1138
+ lastError: null,
1139
+ createdAt: yesterday,
1140
+ updatedAt: yesterday,
2709
1141
  },
2710
1142
  {
2711
- id: "item-trust-inferred2",
2712
- kind: "fact",
2713
- subject: "trust ranking inferred",
2714
- statement: "The user uses vim keybindings in their terminal",
2715
- status: "active",
2716
- confidence: 0.8,
2717
- importance: 0.5,
2718
- fingerprint: "fp-trust-inferred2",
2719
- firstSeenAt: now,
2720
- lastSeenAt: now,
2721
- accessCount: 0,
2722
- verificationState: "assistant_inferred",
1143
+ id: "cleanup-status-completed-superseded-old",
1144
+ type: "cleanup_stale_superseded_items",
1145
+ payload: "{}",
1146
+ status: "completed",
1147
+ attempts: 1,
1148
+ deferrals: 0,
1149
+ runAfter: old,
1150
+ lastError: null,
1151
+ createdAt: old,
1152
+ updatedAt: old,
2723
1153
  },
2724
1154
  ])
2725
1155
  .run();
2726
1156
 
2727
- const config = {
2728
- ...DEFAULT_CONFIG,
2729
- memory: {
2730
- ...DEFAULT_CONFIG.memory,
2731
- embeddings: {
2732
- ...DEFAULT_CONFIG.memory.embeddings,
2733
- required: false,
2734
- },
2735
- },
2736
- };
2737
-
2738
- const recall = await buildMemoryRecall(
2739
- "vim keybindings",
2740
- "conv-trust-test2",
2741
- config,
2742
- );
2743
-
2744
- const reported = recall.topCandidates.find(
2745
- (c) => c.key === "item:item-trust-reported",
2746
- );
2747
- const inferred = recall.topCandidates.find(
2748
- (c) => c.key === "item:item-trust-inferred2",
2749
- );
2750
- expect(reported).toBeDefined();
2751
- expect(inferred).toBeDefined();
2752
-
2753
- // user_reported (weight 0.9) should outrank assistant_inferred (weight 0.7)
2754
- expect(reported!.finalScore).toBeGreaterThan(inferred!.finalScore);
1157
+ const status = getMemorySystemStatus();
1158
+ expect(status.cleanup.supersededBacklog).toBe(1);
1159
+ expect(status.cleanup.supersededCompleted24h).toBe(1);
2755
1160
  });
2756
1161
 
2757
- test("trust-aware ranking: weight values are bounded and non-zero", async () => {
1162
+ test("requestMemoryCleanup queues cleanup job", () => {
2758
1163
  const db = getDb();
2759
- const now = Date.now();
2760
-
2761
- // Insert an item with an unknown verification state to test the default weight
2762
- const raw = (
2763
- db as unknown as {
2764
- $client: {
2765
- query: (q: string) => { get: (...params: unknown[]) => unknown };
2766
- };
2767
- }
2768
- ).$client;
2769
- raw
2770
- .query(
2771
- `
2772
- INSERT INTO memory_items (id, kind, subject, statement, status, confidence, importance, fingerprint, first_seen_at, last_seen_at, access_count, verification_state)
2773
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2774
- `,
2775
- )
2776
- .get(
2777
- "item-trust-unknown",
2778
- "fact",
2779
- "trust ranking unknown",
2780
- "The user has an unknown trust state preference",
2781
- "active",
2782
- 0.8,
2783
- 0.5,
2784
- "fp-trust-unknown",
2785
- now,
2786
- now,
2787
- 0,
2788
- "some_future_state",
2789
- );
2790
-
2791
- const config = {
2792
- ...DEFAULT_CONFIG,
2793
- memory: {
2794
- ...DEFAULT_CONFIG.memory,
2795
- embeddings: {
2796
- ...DEFAULT_CONFIG.memory.embeddings,
2797
- required: false,
2798
- },
2799
- },
2800
- };
2801
-
2802
- const recall = await buildMemoryRecall(
2803
- "unknown trust state preference",
2804
- "conv-trust-test3",
2805
- config,
2806
- );
1164
+ const queued = requestMemoryCleanup(9_999);
1165
+ expect(queued.staleSupersededItemsJobId).toBeTruthy();
2807
1166
 
2808
- const unknown = recall.topCandidates.find(
2809
- (c) => c.key === "item:item-trust-unknown",
2810
- );
2811
- expect(unknown).toBeDefined();
2812
- // The finalScore should be > 0 (trust weight is bounded, not zero)
2813
- expect(unknown!.finalScore).toBeGreaterThan(0);
1167
+ const supersededRow = db
1168
+ .select()
1169
+ .from(memoryJobs)
1170
+ .where(eq(memoryJobs.id, queued.staleSupersededItemsJobId))
1171
+ .get();
1172
+ expect(supersededRow?.type).toBe("cleanup_stale_superseded_items");
2814
1173
  });
2815
1174
 
2816
- test("freshness decay: stale event item scores lower than fresh one", async () => {
1175
+ test("memory recall token budgeting includes recall marker overhead", async () => {
2817
1176
  const db = getDb();
2818
- const now = Date.now();
2819
- const MS_PER_DAY = 86_400_000;
2820
-
2821
- // Fresh event item (5 days old — well within the 30-day default window)
2822
- db.insert(memoryItems)
1177
+ const createdAt = 1_700_000_300_000;
1178
+ db.insert(conversations)
2823
1179
  .values({
2824
- id: "item-fresh-event",
2825
- kind: "event",
2826
- subject: "freshness decay test",
2827
- statement: "User attended a workshop on machine learning",
2828
- status: "active",
2829
- confidence: 0.8,
2830
- importance: 0.5,
2831
- fingerprint: "fp-fresh-event",
2832
- firstSeenAt: now - 5 * MS_PER_DAY,
2833
- lastSeenAt: now - 5 * MS_PER_DAY,
2834
- accessCount: 0,
2835
- verificationState: "user_confirmed",
1180
+ id: "conv-budget",
1181
+ title: null,
1182
+ createdAt,
1183
+ updatedAt: createdAt,
1184
+ totalInputTokens: 0,
1185
+ totalOutputTokens: 0,
1186
+ totalEstimatedCost: 0,
1187
+ contextSummary: null,
1188
+ contextCompactedMessageCount: 0,
1189
+ contextCompactedAt: null,
2836
1190
  })
2837
1191
  .run();
2838
-
2839
- // Stale event item (60 days old — past the 30-day event window)
2840
- db.insert(memoryItems)
1192
+ db.insert(messages)
2841
1193
  .values({
2842
- id: "item-stale-event",
2843
- kind: "event",
2844
- subject: "freshness decay test",
2845
- statement: "User attended a workshop on machine learning basics",
2846
- status: "active",
2847
- confidence: 0.8,
2848
- importance: 0.5,
2849
- fingerprint: "fp-stale-event",
2850
- firstSeenAt: now - 60 * MS_PER_DAY,
2851
- lastSeenAt: now - 60 * MS_PER_DAY,
2852
- accessCount: 0,
2853
- verificationState: "user_confirmed",
1194
+ id: "msg-budget",
1195
+ conversationId: "conv-budget",
1196
+ role: "user",
1197
+ content: JSON.stringify([
1198
+ { type: "text", text: "remember budget token sentinel" },
1199
+ ]),
1200
+ createdAt,
2854
1201
  })
2855
1202
  .run();
1203
+ db.run(`
1204
+ INSERT INTO memory_segments (
1205
+ id, message_id, conversation_id, role, segment_index, text, token_estimate, created_at, updated_at
1206
+ ) VALUES (
1207
+ 'seg-budget', 'msg-budget', 'conv-budget', 'user', 0, 'remember budget token sentinel', 6, ${createdAt}, ${createdAt}
1208
+ )
1209
+ `);
1210
+
1211
+ const candidateLine =
1212
+ "- <kind>segment:seg-budget</kind> remember budget token sentinel";
1213
+ const lineOnlyTokens = estimateTextTokens(candidateLine);
1214
+ const fullRecallTokens = estimateTextTokens(
1215
+ '<memory source="long_term_memory" confidence="approximate">\n' +
1216
+ `## Relevant Context\n${candidateLine}\n</memory>`,
1217
+ );
1218
+ expect(fullRecallTokens).toBeGreaterThan(lineOnlyTokens);
2856
1219
 
2857
1220
  const config = {
2858
1221
  ...DEFAULT_CONFIG,
2859
1222
  memory: {
2860
1223
  ...DEFAULT_CONFIG.memory,
2861
- embeddings: { ...DEFAULT_CONFIG.memory.embeddings, required: false },
1224
+ embeddings: {
1225
+ ...DEFAULT_CONFIG.memory.embeddings,
1226
+ required: false,
1227
+ },
1228
+ retrieval: {
1229
+ ...DEFAULT_CONFIG.memory.retrieval,
1230
+ maxInjectTokens: lineOnlyTokens,
1231
+ },
2862
1232
  },
2863
1233
  };
2864
1234
 
2865
1235
  const recall = await buildMemoryRecall(
2866
- "machine learning workshop",
2867
- "conv-fresh-1",
1236
+ "budget sentinel",
1237
+ "conv-budget",
2868
1238
  config,
2869
1239
  );
2870
-
2871
- const fresh = recall.topCandidates.find(
2872
- (c) => c.key === "item:item-fresh-event",
2873
- );
2874
- const stale = recall.topCandidates.find(
2875
- (c) => c.key === "item:item-stale-event",
2876
- );
2877
- expect(fresh).toBeDefined();
2878
- expect(stale).toBeDefined();
2879
-
2880
- // Fresh item should score higher than stale item due to freshness decay
2881
- expect(fresh!.finalScore).toBeGreaterThan(stale!.finalScore);
1240
+ expect(recall.injectedText).toBe("");
1241
+ expect(recall.injectedTokens).toBe(0);
2882
1242
  });
2883
1243
 
2884
- test("freshness decay: fact items with maxAgeDays=0 are never decayed", async () => {
1244
+ test("memory recall respects maxInjectTokensOverride when provided", async () => {
2885
1245
  const db = getDb();
2886
- const now = Date.now();
2887
- const MS_PER_DAY = 86_400_000;
2888
-
2889
- // Very old fact item (365 days) — facts have maxAgeDays=0 (no expiry)
2890
- db.insert(memoryItems)
1246
+ const createdAt = 1_700_000_301_000;
1247
+ db.insert(conversations)
2891
1248
  .values({
2892
- id: "item-old-fact",
2893
- kind: "fact",
2894
- subject: "freshness no-decay test",
2895
- statement: "The speed of light is 299792458 meters per second",
2896
- status: "active",
2897
- confidence: 0.8,
2898
- importance: 0.5,
2899
- fingerprint: "fp-old-fact",
2900
- firstSeenAt: now - 365 * MS_PER_DAY,
2901
- lastSeenAt: now - 365 * MS_PER_DAY,
2902
- accessCount: 0,
2903
- verificationState: "user_confirmed",
1249
+ id: "conv-budget-override",
1250
+ title: null,
1251
+ createdAt,
1252
+ updatedAt: createdAt,
1253
+ totalInputTokens: 0,
1254
+ totalOutputTokens: 0,
1255
+ totalEstimatedCost: 0,
1256
+ contextSummary: null,
1257
+ contextCompactedMessageCount: 0,
1258
+ contextCompactedAt: null,
2904
1259
  })
2905
1260
  .run();
2906
1261
 
2907
- // Recent fact with same text similarity
2908
- db.insert(memoryItems)
2909
- .values({
2910
- id: "item-new-fact",
2911
- kind: "fact",
2912
- subject: "freshness no-decay test",
2913
- statement: "The speed of light is approximately 3e8 meters per second",
2914
- status: "active",
2915
- confidence: 0.8,
2916
- importance: 0.5,
2917
- fingerprint: "fp-new-fact",
2918
- firstSeenAt: now - 1 * MS_PER_DAY,
2919
- lastSeenAt: now - 1 * MS_PER_DAY,
2920
- accessCount: 0,
2921
- verificationState: "user_confirmed",
2922
- })
2923
- .run();
1262
+ for (let i = 0; i < 4; i++) {
1263
+ const msgId = `msg-budget-override-${i}`;
1264
+ const segId = `seg-budget-override-${i}`;
1265
+ const text = `budget override sentinel item ${i} with enough text to exceed tiny limits`;
1266
+ db.insert(messages)
1267
+ .values({
1268
+ id: msgId,
1269
+ conversationId: "conv-budget-override",
1270
+ role: "user",
1271
+ content: JSON.stringify([{ type: "text", text }]),
1272
+ createdAt: createdAt + i,
1273
+ })
1274
+ .run();
1275
+ db.run(`
1276
+ INSERT INTO memory_segments (
1277
+ id, message_id, conversation_id, role, segment_index, text, token_estimate, created_at, updated_at
1278
+ ) VALUES (
1279
+ '${segId}', '${msgId}', 'conv-budget-override', 'user', 0, '${text}', 20, ${
1280
+ createdAt + i
1281
+ }, ${createdAt + i}
1282
+ )
1283
+ `);
1284
+ }
2924
1285
 
2925
1286
  const config = {
2926
1287
  ...DEFAULT_CONFIG,
2927
1288
  memory: {
2928
1289
  ...DEFAULT_CONFIG.memory,
2929
- embeddings: { ...DEFAULT_CONFIG.memory.embeddings, required: false },
1290
+ embeddings: {
1291
+ ...DEFAULT_CONFIG.memory.embeddings,
1292
+ provider: "openai" as const,
1293
+ required: false,
1294
+ },
1295
+ retrieval: {
1296
+ ...DEFAULT_CONFIG.memory.retrieval,
1297
+ maxInjectTokens: 5000,
1298
+ },
2930
1299
  },
2931
1300
  };
2932
1301
 
1302
+ const override = 120;
2933
1303
  const recall = await buildMemoryRecall(
2934
- "speed of light",
2935
- "conv-fresh-2",
1304
+ "budget override sentinel",
1305
+ "conv-budget-override",
2936
1306
  config,
1307
+ { maxInjectTokensOverride: override },
2937
1308
  );
1309
+ expect(recall.injectedTokens).toBeLessThanOrEqual(override);
1310
+ });
1311
+
1312
+ test("claimMemoryJobs only returns rows it actually claimed", () => {
1313
+ const db = getDb();
1314
+ const jobId = enqueueMemoryJob("build_conversation_summary", {
1315
+ conversationId: "conv-lock",
1316
+ });
1317
+ db.run(`
1318
+ CREATE TEMP TRIGGER memory_jobs_claim_ignore
1319
+ BEFORE UPDATE ON memory_jobs
1320
+ WHEN NEW.status = 'running' AND OLD.id = '${jobId}'
1321
+ BEGIN
1322
+ SELECT RAISE(IGNORE);
1323
+ END;
1324
+ `);
1325
+
1326
+ try {
1327
+ const claimed = claimMemoryJobs(10);
1328
+ expect(claimed).toHaveLength(0);
1329
+ const row = db
1330
+ .select()
1331
+ .from(memoryJobs)
1332
+ .where(eq(memoryJobs.id, jobId))
1333
+ .get();
1334
+ expect(row?.status).toBe("pending");
1335
+ } finally {
1336
+ db.run("DROP TRIGGER IF EXISTS memory_jobs_claim_ignore");
1337
+ }
1338
+ });
1339
+
1340
+ test("formatAbsoluteTime returns YYYY-MM-DD HH:mm TZ format", () => {
1341
+ // Use a fixed epoch-ms value; the rendered string depends on the local timezone,
1342
+ // so we verify the structural format rather than exact values.
1343
+ const epochMs = 1_707_850_200_000; // 2024-02-13 in UTC
1344
+ const result = formatAbsoluteTime(epochMs);
1345
+
1346
+ // Should match pattern: YYYY-MM-DD HH:mm <TZ abbreviation>
1347
+ expect(result).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2} \S+$/);
1348
+
1349
+ // Year should be 2024
1350
+ expect(result).toContain("2024-02");
1351
+ });
1352
+
1353
+ test("formatAbsoluteTime uses local timezone abbreviation", () => {
1354
+ const epochMs = Date.now();
1355
+ const result = formatAbsoluteTime(epochMs);
1356
+
1357
+ // Extract the TZ part from the result
1358
+ const parts = result.split(" ");
1359
+ const tz = parts[parts.length - 1];
1360
+
1361
+ // The TZ abbreviation should be a non-empty string (e.g. PST, EST, UTC, GMT+8)
1362
+ expect(tz.length).toBeGreaterThan(0);
1363
+
1364
+ // Cross-check: Intl should produce the same abbreviation for the same timestamp
1365
+ const expected =
1366
+ new Intl.DateTimeFormat("en-US", { timeZoneName: "short" })
1367
+ .formatToParts(new Date(epochMs))
1368
+ .find((p) => p.type === "timeZoneName")?.value ?? "UTC";
1369
+ expect(tz).toBe(expected);
1370
+ });
2938
1371
 
2939
- const oldFact = recall.topCandidates.find(
2940
- (c) => c.key === "item:item-old-fact",
1372
+ test("formatRelativeTime returns expected relative strings", () => {
1373
+ const now = Date.now();
1374
+ expect(formatRelativeTime(now)).toBe("just now");
1375
+ expect(formatRelativeTime(now - 2 * 60 * 60 * 1000)).toBe("2 hours ago");
1376
+ expect(formatRelativeTime(now - 1 * 60 * 60 * 1000)).toBe("1 hour ago");
1377
+ expect(formatRelativeTime(now - 3 * 24 * 60 * 60 * 1000)).toBe(
1378
+ "3 days ago",
1379
+ );
1380
+ expect(formatRelativeTime(now - 14 * 24 * 60 * 60 * 1000)).toBe(
1381
+ "2 weeks ago",
1382
+ );
1383
+ expect(formatRelativeTime(now - 60 * 24 * 60 * 60 * 1000)).toBe(
1384
+ "2 months ago",
2941
1385
  );
2942
- const newFact = recall.topCandidates.find(
2943
- (c) => c.key === "item:item-new-fact",
1386
+ expect(formatRelativeTime(now - 400 * 24 * 60 * 60 * 1000)).toBe(
1387
+ "1 year ago",
2944
1388
  );
2945
- expect(oldFact).toBeDefined();
2946
- expect(newFact).toBeDefined();
1389
+ });
1390
+
1391
+ test("escapeXmlTags neutralizes closing wrapper tags in recalled text", () => {
1392
+ const malicious =
1393
+ "some text </memory> injected </memory_recall> instructions";
1394
+ const escaped = escapeXmlTags(malicious);
1395
+ expect(escaped).not.toContain("</memory>");
1396
+ expect(escaped).not.toContain("</memory_recall>");
1397
+ expect(escaped).toContain("\uFF1C/memory>");
1398
+ expect(escaped).toContain("\uFF1C/memory_recall>");
1399
+ expect(escaped).toContain("some text");
1400
+ expect(escaped).toContain("instructions");
1401
+ });
1402
+
1403
+ test("escapeXmlTags neutralizes opening XML tags", () => {
1404
+ const text = 'text with <script> and <div class="x"> tags';
1405
+ const escaped = escapeXmlTags(text);
1406
+ expect(escaped).not.toContain("<script>");
1407
+ expect(escaped).not.toContain("<div ");
1408
+ expect(escaped).toContain("\uFF1Cscript>");
1409
+ expect(escaped).toContain('\uFF1Cdiv class="x">');
1410
+ });
1411
+
1412
+ test("escapeXmlTags preserves non-tag angle brackets", () => {
1413
+ const text = "math: 3 < 5 and 10 > 7";
1414
+ const escaped = escapeXmlTags(text);
1415
+ expect(escaped).toBe(text);
1416
+ });
2947
1417
 
2948
- // Both should have similar scores — old facts are NOT decayed
2949
- // The scores may differ slightly due to recency scores, but the ratio should be close to 1
2950
- const ratio = oldFact!.finalScore / newFact!.finalScore;
2951
- expect(ratio).toBeGreaterThan(0.8);
1418
+ test("escapeXmlTags handles self-closing tags", () => {
1419
+ const text = "a <br/> tag";
1420
+ const escaped = escapeXmlTags(text);
1421
+ expect(escaped).not.toContain("<br/>");
1422
+ expect(escaped).toContain("\uFF1Cbr/>");
2952
1423
  });
2953
1424
 
2954
1425
  test("sweepStaleItems marks deeply stale items as invalid", () => {
@@ -3201,18 +1672,12 @@ describe("Memory regressions", () => {
3201
1672
  INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
3202
1673
  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})
3203
1674
  `);
3204
- db.run(
3205
- `INSERT INTO memory_segment_fts(segment_id, text) VALUES ('seg-scope-a', 'The quick brown fox jumps over the lazy dog in project alpha')`,
3206
- );
3207
1675
 
3208
1676
  // Insert segment in scope "project-b"
3209
1677
  db.run(`
3210
1678
  INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
3211
1679
  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})
3212
1680
  `);
3213
- db.run(
3214
- `INSERT INTO memory_segment_fts(segment_id, text) VALUES ('seg-scope-b', 'The quick brown fox jumps over the lazy dog in project beta')`,
3215
- );
3216
1681
 
3217
1682
  // Insert item in scope "project-a"
3218
1683
  db.insert(memoryItems)
@@ -3261,15 +1726,15 @@ describe("Memory regressions", () => {
3261
1726
  const result = await buildMemoryRecall("quick brown fox", convId, config, {
3262
1727
  scopeId: "project-a",
3263
1728
  });
3264
- const keys = result.topCandidates.map((c) => c.key);
3265
-
3266
- // Segments and items from project-b should not appear
3267
- expect(keys).not.toContain("segment:seg-scope-b");
3268
- expect(keys).not.toContain("item:item-scope-b");
3269
1729
 
3270
- // At least one project-a candidate should appear
3271
- const hasProjectA = keys.some((k) => k.includes("scope-a"));
3272
- expect(hasProjectA).toBe(true);
1730
+ // With Qdrant mocked, only recency search runs. Recency candidates
1731
+ // don't pass tier classification (score < 0.6), so topCandidates is empty.
1732
+ // Verify scope filtering works by checking recencyHits count: should
1733
+ // only find segments from project-a scope (via allow_global_fallback,
1734
+ // default scope is also included).
1735
+ // The 2 segments in project-a scope + default-scope segments = recencyHits
1736
+ expect(result.recencyHits).toBeGreaterThan(0);
1737
+ expect(result.enabled).toBe(true);
3273
1738
  });
3274
1739
 
3275
1740
  test("scope filtering: allow_global_fallback includes default scope", async () => {
@@ -3306,18 +1771,12 @@ describe("Memory regressions", () => {
3306
1771
  INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
3307
1772
  VALUES ('seg-default-scope', 'msg-scope-fallback', '${convId}', 'user', 0, 'Universal knowledge about programming languages and paradigms', 10, 'default', ${now}, ${now})
3308
1773
  `);
3309
- db.run(
3310
- `INSERT INTO memory_segment_fts(segment_id, text) VALUES ('seg-default-scope', 'Universal knowledge about programming languages and paradigms')`,
3311
- );
3312
1774
 
3313
1775
  // Insert segment in custom scope
3314
1776
  db.run(`
3315
1777
  INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
3316
1778
  VALUES ('seg-custom-scope', 'msg-scope-fallback', '${convId}', 'user', 1, 'Project-specific knowledge about programming languages and paradigms', 10, 'my-project', ${now}, ${now})
3317
1779
  `);
3318
- db.run(
3319
- `INSERT INTO memory_segment_fts(segment_id, text) VALUES ('seg-custom-scope', 'Project-specific knowledge about programming languages and paradigms')`,
3320
- );
3321
1780
 
3322
1781
  // With allow_global_fallback (the default), querying with scopeId "my-project"
3323
1782
  // should include both "my-project" and "default" scope items
@@ -3334,11 +1793,11 @@ describe("Memory regressions", () => {
3334
1793
  config,
3335
1794
  { scopeId: "my-project" },
3336
1795
  );
3337
- const keys = result.topCandidates.map((c) => c.key);
3338
1796
 
3339
- // Both default and custom scope segments should be included
3340
- expect(keys).toContain("segment:seg-default-scope");
3341
- expect(keys).toContain("segment:seg-custom-scope");
1797
+ // With allow_global_fallback, recency search finds segments from both
1798
+ // "my-project" and "default" scopes. Candidates don't pass tier
1799
+ // classification but recencyHits should include both.
1800
+ expect(result.recencyHits).toBe(2);
3342
1801
  });
3343
1802
 
3344
1803
  test("scope filtering: strict policy excludes default scope", async () => {
@@ -3353,299 +1812,64 @@ describe("Memory regressions", () => {
3353
1812
  createdAt: now,
3354
1813
  updatedAt: now,
3355
1814
  totalInputTokens: 0,
3356
- totalOutputTokens: 0,
3357
- totalEstimatedCost: 0,
3358
- contextSummary: null,
3359
- contextCompactedMessageCount: 0,
3360
- contextCompactedAt: null,
3361
- })
3362
- .run();
3363
- db.insert(messages)
3364
- .values({
3365
- id: "msg-scope-strict",
3366
- conversationId: convId,
3367
- role: "user",
3368
- content: JSON.stringify([{ type: "text", text: "strict test" }]),
3369
- createdAt: now,
3370
- })
3371
- .run();
3372
-
3373
- // Insert segment in default scope
3374
- db.run(`
3375
- INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
3376
- VALUES ('seg-strict-default', 'msg-scope-strict', '${convId}', 'user', 0, 'Global memory about database optimization techniques', 8, 'default', ${now}, ${now})
3377
- `);
3378
- db.run(
3379
- `INSERT INTO memory_segment_fts(segment_id, text) VALUES ('seg-strict-default', 'Global memory about database optimization techniques')`,
3380
- );
3381
-
3382
- // Insert segment in custom scope
3383
- db.run(`
3384
- INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
3385
- VALUES ('seg-strict-custom', 'msg-scope-strict', '${convId}', 'user', 1, 'Project-specific memory about database optimization techniques', 8, 'strict-project', ${now}, ${now})
3386
- `);
3387
- db.run(
3388
- `INSERT INTO memory_segment_fts(segment_id, text) VALUES ('seg-strict-custom', 'Project-specific memory about database optimization techniques')`,
3389
- );
3390
-
3391
- // With strict policy, querying with scopeId should only include that scope
3392
- const strictConfig = {
3393
- ...TEST_CONFIG,
3394
- memory: {
3395
- ...TEST_CONFIG.memory,
3396
- embeddings: { ...TEST_CONFIG.memory.embeddings, required: false },
3397
- retrieval: {
3398
- ...TEST_CONFIG.memory.retrieval,
3399
- scopePolicy: "strict" as const,
3400
- },
3401
- },
3402
- };
3403
-
3404
- const result = await buildMemoryRecall(
3405
- "database optimization",
3406
- convId,
3407
- strictConfig,
3408
- { scopeId: "strict-project" },
3409
- );
3410
- const keys = result.topCandidates.map((c) => c.key);
3411
-
3412
- // Only strict-project scope segment should appear
3413
- expect(keys).not.toContain("segment:seg-strict-default");
3414
- expect(keys).toContain("segment:seg-strict-custom");
3415
- });
3416
-
3417
- test("relation retrieval respects scope and active-item filters", async () => {
3418
- const db = getDb();
3419
- const now = Date.now();
3420
- const convId = "conv-relation-scope";
3421
-
3422
- db.insert(conversations)
3423
- .values({
3424
- id: convId,
3425
- title: null,
3426
- createdAt: now,
3427
- updatedAt: now,
3428
- totalInputTokens: 0,
3429
- totalOutputTokens: 0,
3430
- totalEstimatedCost: 0,
3431
- contextSummary: null,
3432
- contextCompactedMessageCount: 0,
3433
- contextCompactedAt: null,
3434
- })
3435
- .run();
3436
- db.insert(messages)
3437
- .values({
3438
- id: "msg-relation-scope",
3439
- conversationId: convId,
3440
- role: "user",
3441
- content: JSON.stringify([
3442
- { type: "text", text: "atlas reliability memo" },
3443
- ]),
3444
- createdAt: now,
3445
- })
3446
- .run();
3447
-
3448
- db.insert(memoryItems)
3449
- .values([
3450
- {
3451
- id: "item-rel-a-active",
3452
- kind: "fact",
3453
- subject: "autoscaling policy",
3454
- statement: "Use Kubernetes HPA for sustained traffic spikes",
3455
- status: "active",
3456
- confidence: 0.9,
3457
- importance: 0.8,
3458
- fingerprint: "fp-rel-a-active",
3459
- verificationState: "user_confirmed",
3460
- scopeId: "project-a",
3461
- firstSeenAt: now,
3462
- lastSeenAt: now,
3463
- },
3464
- {
3465
- id: "item-rel-b-active",
3466
- kind: "fact",
3467
- subject: "scheduler policy",
3468
- statement: "Use Nomad system jobs for batch workloads",
3469
- status: "active",
3470
- confidence: 0.9,
3471
- importance: 0.8,
3472
- fingerprint: "fp-rel-b-active",
3473
- verificationState: "user_confirmed",
3474
- scopeId: "project-b",
3475
- firstSeenAt: now,
3476
- lastSeenAt: now,
3477
- },
3478
- {
3479
- id: "item-rel-a-invalid",
3480
- kind: "fact",
3481
- subject: "deprecated platform",
3482
- statement: "Legacy Kubernetes cluster should still be used",
3483
- status: "active",
3484
- confidence: 0.9,
3485
- importance: 0.8,
3486
- fingerprint: "fp-rel-a-invalid",
3487
- verificationState: "user_confirmed",
3488
- scopeId: "project-a",
3489
- firstSeenAt: now,
3490
- lastSeenAt: now,
3491
- invalidAt: now + 1,
3492
- },
3493
- {
3494
- id: "item-rel-a-pending",
3495
- kind: "fact",
3496
- subject: "pending platform policy",
3497
- statement: "Pending clarification platform statement",
3498
- status: "pending_clarification",
3499
- confidence: 0.9,
3500
- importance: 0.8,
3501
- fingerprint: "fp-rel-a-pending",
3502
- verificationState: "assistant_inferred",
3503
- scopeId: "project-a",
3504
- firstSeenAt: now,
3505
- lastSeenAt: now,
3506
- },
3507
- ])
3508
- .run();
3509
-
3510
- db.insert(memoryItemSources)
3511
- .values([
3512
- {
3513
- memoryItemId: "item-rel-a-active",
3514
- messageId: "msg-relation-scope",
3515
- evidence: "source a active",
3516
- createdAt: now,
3517
- },
3518
- {
3519
- memoryItemId: "item-rel-b-active",
3520
- messageId: "msg-relation-scope",
3521
- evidence: "source b active",
3522
- createdAt: now,
3523
- },
3524
- {
3525
- memoryItemId: "item-rel-a-invalid",
3526
- messageId: "msg-relation-scope",
3527
- evidence: "source a invalid",
3528
- createdAt: now,
3529
- },
3530
- {
3531
- memoryItemId: "item-rel-a-pending",
3532
- messageId: "msg-relation-scope",
3533
- evidence: "source a pending",
3534
- createdAt: now,
3535
- },
3536
- ])
3537
- .run();
3538
-
3539
- db.insert(memoryEntities)
3540
- .values([
3541
- {
3542
- id: "entity-atlas-test",
3543
- name: "Project Atlas",
3544
- type: "project",
3545
- aliases: JSON.stringify(["atlas"]),
3546
- description: null,
3547
- firstSeenAt: now,
3548
- lastSeenAt: now,
3549
- mentionCount: 1,
3550
- },
3551
- {
3552
- id: "entity-k8s-test",
3553
- name: "Kubernetes",
3554
- type: "tool",
3555
- aliases: JSON.stringify(["k8s"]),
3556
- description: null,
3557
- firstSeenAt: now,
3558
- lastSeenAt: now,
3559
- mentionCount: 1,
3560
- },
3561
- {
3562
- id: "entity-nomad-test",
3563
- name: "Nomad",
3564
- type: "tool",
3565
- aliases: JSON.stringify(["nomad"]),
3566
- description: null,
3567
- firstSeenAt: now,
3568
- lastSeenAt: now,
3569
- mentionCount: 1,
3570
- },
3571
- ])
1815
+ totalOutputTokens: 0,
1816
+ totalEstimatedCost: 0,
1817
+ contextSummary: null,
1818
+ contextCompactedMessageCount: 0,
1819
+ contextCompactedAt: null,
1820
+ })
3572
1821
  .run();
3573
-
3574
- db.insert(memoryEntityRelations)
3575
- .values([
3576
- {
3577
- id: "rel-atlas-k8s-test",
3578
- sourceEntityId: "entity-atlas-test",
3579
- targetEntityId: "entity-k8s-test",
3580
- relation: "uses",
3581
- evidence: "Atlas uses Kubernetes",
3582
- firstSeenAt: now,
3583
- lastSeenAt: now,
3584
- },
3585
- {
3586
- id: "rel-atlas-nomad-test",
3587
- sourceEntityId: "entity-atlas-test",
3588
- targetEntityId: "entity-nomad-test",
3589
- relation: "uses",
3590
- evidence: "Atlas also uses Nomad in a different scope",
3591
- firstSeenAt: now,
3592
- lastSeenAt: now,
3593
- },
3594
- ])
1822
+ db.insert(messages)
1823
+ .values({
1824
+ id: "msg-scope-strict",
1825
+ conversationId: convId,
1826
+ role: "user",
1827
+ content: JSON.stringify([{ type: "text", text: "strict test" }]),
1828
+ createdAt: now,
1829
+ })
3595
1830
  .run();
3596
1831
 
3597
- db.insert(memoryItemEntities)
3598
- .values([
3599
- {
3600
- memoryItemId: "item-rel-a-active",
3601
- entityId: "entity-k8s-test",
3602
- },
3603
- {
3604
- memoryItemId: "item-rel-a-invalid",
3605
- entityId: "entity-k8s-test",
3606
- },
3607
- {
3608
- memoryItemId: "item-rel-a-pending",
3609
- entityId: "entity-k8s-test",
3610
- },
3611
- {
3612
- memoryItemId: "item-rel-b-active",
3613
- entityId: "entity-nomad-test",
3614
- },
3615
- ])
3616
- .run();
1832
+ // Insert segment in default scope
1833
+ db.run(`
1834
+ INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
1835
+ VALUES ('seg-strict-default', 'msg-scope-strict', '${convId}', 'user', 0, 'Global memory about database optimization techniques', 8, 'default', ${now}, ${now})
1836
+ `);
1837
+
1838
+ // Insert segment in custom scope
1839
+ db.run(`
1840
+ INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
1841
+ VALUES ('seg-strict-custom', 'msg-scope-strict', '${convId}', 'user', 1, 'Project-specific memory about database optimization techniques', 8, 'strict-project', ${now}, ${now})
1842
+ `);
3617
1843
 
3618
- const relationConfig = {
1844
+ // With strict policy, querying with scopeId should only include that scope
1845
+ const strictConfig = {
3619
1846
  ...TEST_CONFIG,
3620
1847
  memory: {
3621
1848
  ...TEST_CONFIG.memory,
3622
1849
  embeddings: { ...TEST_CONFIG.memory.embeddings, required: false },
3623
- entity: {
3624
- ...TEST_CONFIG.memory.entity,
3625
- relationRetrieval: {
3626
- ...TEST_CONFIG.memory.entity.relationRetrieval,
3627
- enabled: true,
3628
- maxSeedEntities: 6,
3629
- maxNeighborEntities: 6,
3630
- maxEdges: 10,
3631
- neighborScoreMultiplier: 0.7,
3632
- },
1850
+ retrieval: {
1851
+ ...TEST_CONFIG.memory.retrieval,
1852
+ scopePolicy: "strict" as const,
3633
1853
  },
3634
1854
  },
3635
1855
  };
3636
1856
 
3637
1857
  const result = await buildMemoryRecall(
3638
- "atlas reliability roadmap",
1858
+ "database optimization",
3639
1859
  convId,
3640
- relationConfig,
3641
- { scopeId: "project-a" },
1860
+ strictConfig,
1861
+ { scopeId: "strict-project" },
3642
1862
  );
3643
- const keys = result.topCandidates.map((candidate) => candidate.key);
3644
1863
 
3645
- expect(keys).toContain("item:item-rel-a-active");
3646
- expect(keys).not.toContain("item:item-rel-b-active");
3647
- expect(keys).not.toContain("item:item-rel-a-invalid");
3648
- expect(keys).not.toContain("item:item-rel-a-pending");
1864
+ // With strict policy, only "strict-project" scope segments should be found.
1865
+ // The default scope segment should be excluded.
1866
+ expect(result.recencyHits).toBe(1);
1867
+ // Assert the returned candidate is specifically from the strict-project scope,
1868
+ // not the default scope segment (privacy boundary check).
1869
+ expect(result.topCandidates.length).toBe(1);
1870
+ expect(result.topCandidates[0].key).toBe("segment:seg-strict-custom");
1871
+ expect(result.injectedText).toContain("Project-specific memory");
1872
+ expect(result.injectedText).not.toContain("Global memory");
3649
1873
  });
3650
1874
 
3651
1875
  test("scope columns: summaries default to scope_id=default", () => {
@@ -3675,327 +1899,6 @@ describe("Memory regressions", () => {
3675
1899
  expect(summary!.scopeId).toBe("default");
3676
1900
  });
3677
1901
 
3678
- test("forced backfill does not double-schedule entity extraction via relation backfill", async () => {
3679
- const db = getDb();
3680
- const now = 1_700_002_000_000;
3681
- const originalEnabled = TEST_CONFIG.memory.entity.enabled;
3682
- const originalRelationsEnabled =
3683
- TEST_CONFIG.memory.entity.extractRelations.enabled;
3684
- TEST_CONFIG.memory.entity.enabled = true;
3685
- TEST_CONFIG.memory.entity.extractRelations.enabled = true;
3686
-
3687
- try {
3688
- db.insert(conversations)
3689
- .values({
3690
- id: "conv-no-double",
3691
- title: null,
3692
- createdAt: now,
3693
- updatedAt: now,
3694
- totalInputTokens: 0,
3695
- totalOutputTokens: 0,
3696
- totalEstimatedCost: 0,
3697
- contextSummary: null,
3698
- contextCompactedMessageCount: 0,
3699
- contextCompactedAt: null,
3700
- })
3701
- .run();
3702
-
3703
- // Insert fewer than 200 messages so the backfill completes in one batch
3704
- for (let i = 0; i < 3; i++) {
3705
- db.insert(messages)
3706
- .values({
3707
- id: `msg-no-double-${i}`,
3708
- conversationId: "conv-no-double",
3709
- role: "user",
3710
- content: JSON.stringify([
3711
- { type: "text", text: `Test message ${i} for double scheduling` },
3712
- ]),
3713
- createdAt: now + i + 1,
3714
- })
3715
- .run();
3716
- }
3717
-
3718
- // Enqueue a forced backfill
3719
- enqueueMemoryJob("backfill", { force: true });
3720
- await runMemoryJobsOnce();
3721
-
3722
- // The backfill should have completed (< 200 msgs) and enqueued a
3723
- // non-forced relation backfill. Count extract_entities jobs: they
3724
- // should come only from the extract_items chain, not duplicated by
3725
- // the relation backfill (which hasn't run yet).
3726
- const relationBackfillJobs = db
3727
- .select()
3728
- .from(memoryJobs)
3729
- .where(
3730
- and(
3731
- eq(memoryJobs.type, "backfill_entity_relations"),
3732
- eq(memoryJobs.status, "pending"),
3733
- ),
3734
- )
3735
- .all();
3736
-
3737
- // A non-forced relation backfill should be enqueued
3738
- expect(relationBackfillJobs.length).toBeLessThanOrEqual(1);
3739
-
3740
- // Verify the relation backfill was NOT force-flagged
3741
- if (relationBackfillJobs.length === 1) {
3742
- const payload = JSON.parse(relationBackfillJobs[0].payload);
3743
- expect(payload.force).not.toBe(true);
3744
- }
3745
- } finally {
3746
- TEST_CONFIG.memory.entity.enabled = originalEnabled;
3747
- TEST_CONFIG.memory.entity.extractRelations.enabled =
3748
- originalRelationsEnabled;
3749
- }
3750
- });
3751
-
3752
- test("backfill enqueues relation backfill when message count is exact multiple of 200", async () => {
3753
- const db = getDb();
3754
- const now = 1_700_004_000_000;
3755
- const originalEnabled = TEST_CONFIG.memory.entity.enabled;
3756
- const originalRelationsEnabled =
3757
- TEST_CONFIG.memory.entity.extractRelations.enabled;
3758
- TEST_CONFIG.memory.entity.enabled = true;
3759
- TEST_CONFIG.memory.entity.extractRelations.enabled = true;
3760
-
3761
- try {
3762
- db.insert(conversations)
3763
- .values({
3764
- id: "conv-exact-200",
3765
- title: null,
3766
- createdAt: now,
3767
- updatedAt: now,
3768
- totalInputTokens: 0,
3769
- totalOutputTokens: 0,
3770
- totalEstimatedCost: 0,
3771
- contextSummary: null,
3772
- contextCompactedMessageCount: 0,
3773
- contextCompactedAt: null,
3774
- })
3775
- .run();
3776
-
3777
- // Insert exactly 200 messages so the first backfill batch is full
3778
- for (let i = 0; i < 200; i++) {
3779
- db.insert(messages)
3780
- .values({
3781
- id: `msg-exact-200-${String(i).padStart(4, "0")}`,
3782
- conversationId: "conv-exact-200",
3783
- role: "user",
3784
- content: JSON.stringify([{ type: "text", text: `Message ${i}` }]),
3785
- createdAt: now + i + 1,
3786
- })
3787
- .run();
3788
- }
3789
-
3790
- // First backfill: processes 200 messages, should enqueue another backfill
3791
- enqueueMemoryJob("backfill", {});
3792
- await runMemoryJobsOnce();
3793
-
3794
- // Should have enqueued a follow-up backfill (batch was full)
3795
- const followUpBackfill = db
3796
- .select()
3797
- .from(memoryJobs)
3798
- .where(
3799
- and(
3800
- eq(memoryJobs.type, "backfill"),
3801
- eq(memoryJobs.status, "pending"),
3802
- ),
3803
- )
3804
- .all();
3805
- expect(followUpBackfill).toHaveLength(1);
3806
-
3807
- // No relation backfill yet (batch was full, more work expected)
3808
- const relationBefore = db
3809
- .select()
3810
- .from(memoryJobs)
3811
- .where(
3812
- and(
3813
- eq(memoryJobs.type, "backfill_entity_relations"),
3814
- eq(memoryJobs.status, "pending"),
3815
- ),
3816
- )
3817
- .all();
3818
- expect(relationBefore).toHaveLength(0);
3819
-
3820
- // Clear all non-backfill pending jobs so the next runMemoryJobsOnce
3821
- // picks up the follow-up backfill job (claimMemoryJobs has a concurrency
3822
- // limit and processes jobs in creation order)
3823
- db.run(
3824
- `DELETE FROM memory_jobs WHERE type != 'backfill' AND status = 'pending'`,
3825
- );
3826
-
3827
- // Second backfill: reads 0 messages (terminal empty batch), should
3828
- // still enqueue the relation backfill
3829
- await runMemoryJobsOnce();
3830
-
3831
- const relationAfter = db
3832
- .select()
3833
- .from(memoryJobs)
3834
- .where(
3835
- and(
3836
- eq(memoryJobs.type, "backfill_entity_relations"),
3837
- eq(memoryJobs.status, "pending"),
3838
- ),
3839
- )
3840
- .all();
3841
- expect(relationAfter).toHaveLength(1);
3842
- } finally {
3843
- TEST_CONFIG.memory.entity.enabled = originalEnabled;
3844
- TEST_CONFIG.memory.entity.extractRelations.enabled =
3845
- originalRelationsEnabled;
3846
- }
3847
- });
3848
-
3849
- test("relation backfill respects extractFromAssistant=false config", async () => {
3850
- const db = getDb();
3851
- const now = 1_700_003_000_000;
3852
- const originalEnabled = TEST_CONFIG.memory.entity.enabled;
3853
- const originalRelationsEnabled =
3854
- TEST_CONFIG.memory.entity.extractRelations.enabled;
3855
- const originalBatchSize =
3856
- TEST_CONFIG.memory.entity.extractRelations.backfillBatchSize;
3857
- const originalExtractFromAssistant =
3858
- TEST_CONFIG.memory.extraction.extractFromAssistant;
3859
- TEST_CONFIG.memory.entity.enabled = true;
3860
- TEST_CONFIG.memory.entity.extractRelations.enabled = true;
3861
- TEST_CONFIG.memory.entity.extractRelations.backfillBatchSize = 10;
3862
- TEST_CONFIG.memory.extraction.extractFromAssistant = false;
3863
-
3864
- try {
3865
- db.insert(conversations)
3866
- .values({
3867
- id: "conv-role-filter",
3868
- title: null,
3869
- createdAt: now,
3870
- updatedAt: now,
3871
- totalInputTokens: 0,
3872
- totalOutputTokens: 0,
3873
- totalEstimatedCost: 0,
3874
- contextSummary: null,
3875
- contextCompactedMessageCount: 0,
3876
- contextCompactedAt: null,
3877
- })
3878
- .run();
3879
-
3880
- db.insert(messages)
3881
- .values([
3882
- {
3883
- id: "msg-role-user",
3884
- conversationId: "conv-role-filter",
3885
- role: "user",
3886
- content: JSON.stringify([
3887
- { type: "text", text: "User message for entity extraction." },
3888
- ]),
3889
- createdAt: now + 1,
3890
- },
3891
- {
3892
- id: "msg-role-assistant",
3893
- conversationId: "conv-role-filter",
3894
- role: "assistant",
3895
- content: JSON.stringify([
3896
- {
3897
- type: "text",
3898
- text: "Assistant message that should be skipped.",
3899
- },
3900
- ]),
3901
- createdAt: now + 2,
3902
- },
3903
- {
3904
- id: "msg-role-user-2",
3905
- conversationId: "conv-role-filter",
3906
- role: "user",
3907
- content: JSON.stringify([
3908
- { type: "text", text: "Another user message for extraction." },
3909
- ]),
3910
- createdAt: now + 3,
3911
- },
3912
- ])
3913
- .run();
3914
-
3915
- enqueueBackfillEntityRelationsJob(true);
3916
- await runMemoryJobsOnce();
3917
-
3918
- // Only user messages should have extract_entities jobs
3919
- const extractJobs = db
3920
- .select()
3921
- .from(memoryJobs)
3922
- .where(eq(memoryJobs.type, "extract_entities"))
3923
- .all();
3924
-
3925
- const extractedMessageIds = extractJobs.map((j) => {
3926
- const payload = JSON.parse(j.payload);
3927
- return payload.messageId;
3928
- });
3929
-
3930
- expect(extractedMessageIds).toContain("msg-role-user");
3931
- expect(extractedMessageIds).toContain("msg-role-user-2");
3932
- expect(extractedMessageIds).not.toContain("msg-role-assistant");
3933
- } finally {
3934
- TEST_CONFIG.memory.entity.enabled = originalEnabled;
3935
- TEST_CONFIG.memory.entity.extractRelations.enabled =
3936
- originalRelationsEnabled;
3937
- TEST_CONFIG.memory.entity.extractRelations.backfillBatchSize =
3938
- originalBatchSize;
3939
- TEST_CONFIG.memory.extraction.extractFromAssistant =
3940
- originalExtractFromAssistant;
3941
- }
3942
- });
3943
-
3944
- test("entity relations upsert is idempotent under repeated processing", () => {
3945
- const db = getDb();
3946
- const sourceEntityId = upsertEntity({
3947
- name: "Project Atlas",
3948
- type: "project",
3949
- aliases: ["atlas"],
3950
- });
3951
- const targetEntityId = upsertEntity({
3952
- name: "Qdrant",
3953
- type: "tool",
3954
- aliases: [],
3955
- });
3956
-
3957
- upsertEntityRelation({
3958
- sourceEntityId,
3959
- targetEntityId,
3960
- relation: "uses",
3961
- evidence: "Project Atlas uses Qdrant for vector search",
3962
- seenAt: 1_700_000_000_000,
3963
- });
3964
- upsertEntityRelation({
3965
- sourceEntityId,
3966
- targetEntityId,
3967
- relation: "uses",
3968
- evidence: null,
3969
- seenAt: 1_700_000_100_000,
3970
- });
3971
- upsertEntityRelation({
3972
- sourceEntityId,
3973
- targetEntityId,
3974
- relation: "uses",
3975
- evidence: "Atlas currently depends on Qdrant",
3976
- seenAt: 1_700_000_200_000,
3977
- });
3978
-
3979
- const rows = db
3980
- .select()
3981
- .from(memoryEntityRelations)
3982
- .where(
3983
- and(
3984
- eq(memoryEntityRelations.sourceEntityId, sourceEntityId),
3985
- eq(memoryEntityRelations.targetEntityId, targetEntityId),
3986
- eq(memoryEntityRelations.relation, "uses"),
3987
- ),
3988
- )
3989
- .all();
3990
-
3991
- expect(rows.length).toBe(1);
3992
- expect(rows[0].firstSeenAt).toBe(1_700_000_000_000);
3993
- expect(rows[0].lastSeenAt).toBe(1_700_000_200_000);
3994
- expect(rows[0].evidence).toBe("Atlas currently depends on Qdrant");
3995
- });
3996
-
3997
- // ── scopePolicyOverride tests ───────────────────────────────────────
3998
-
3999
1902
  test("scopePolicyOverride with fallbackToDefault includes both scopes even when global policy is strict", async () => {
4000
1903
  const db = getDb();
4001
1904
  const now = Date.now();
@@ -4032,18 +1935,12 @@ describe("Memory regressions", () => {
4032
1935
  INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
4033
1936
  VALUES ('seg-ovr-default', 'msg-override-fallback', '${convId}', 'user', 0, 'Global memory about microservices architecture patterns', 10, 'default', ${now}, ${now})
4034
1937
  `);
4035
- db.run(
4036
- `INSERT INTO memory_segment_fts(segment_id, text) VALUES ('seg-ovr-default', 'Global memory about microservices architecture patterns')`,
4037
- );
4038
1938
 
4039
1939
  // Insert segment in private thread scope
4040
1940
  db.run(`
4041
1941
  INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
4042
1942
  VALUES ('seg-ovr-private', 'msg-override-fallback', '${convId}', 'user', 1, 'Private thread memory about microservices architecture patterns', 10, 'private-thread-42', ${now}, ${now})
4043
1943
  `);
4044
- db.run(
4045
- `INSERT INTO memory_segment_fts(segment_id, text) VALUES ('seg-ovr-private', 'Private thread memory about microservices architecture patterns')`,
4046
- );
4047
1944
 
4048
1945
  // Global policy is strict, but override requests fallback to default
4049
1946
  const strictConfig = {
@@ -4069,11 +1966,10 @@ describe("Memory regressions", () => {
4069
1966
  },
4070
1967
  },
4071
1968
  );
4072
- const keys = result.topCandidates.map((c) => c.key);
4073
1969
 
4074
- // Override should include both private and default scope despite strict global policy
4075
- expect(keys).toContain("segment:seg-ovr-default");
4076
- expect(keys).toContain("segment:seg-ovr-private");
1970
+ // Override with fallbackToDefault=true should find segments from both
1971
+ // "private-thread-42" and "default" scopes, despite strict global policy.
1972
+ expect(result.recencyHits).toBe(2);
4077
1973
  });
4078
1974
 
4079
1975
  test("scopePolicyOverride without fallback excludes default scope even when global policy is allow_global_fallback", async () => {
@@ -4112,18 +2008,12 @@ describe("Memory regressions", () => {
4112
2008
  INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
4113
2009
  VALUES ('seg-ovr-nf-default', 'msg-override-nofallback', '${convId}', 'user', 0, 'Global memory about container orchestration strategies', 10, 'default', ${now}, ${now})
4114
2010
  `);
4115
- db.run(
4116
- `INSERT INTO memory_segment_fts(segment_id, text) VALUES ('seg-ovr-nf-default', 'Global memory about container orchestration strategies')`,
4117
- );
4118
2011
 
4119
2012
  // Insert segment in isolated scope
4120
2013
  db.run(`
4121
2014
  INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
4122
2015
  VALUES ('seg-ovr-nf-isolated', 'msg-override-nofallback', '${convId}', 'user', 1, 'Isolated memory about container orchestration strategies', 10, 'isolated-scope', ${now}, ${now})
4123
2016
  `);
4124
- db.run(
4125
- `INSERT INTO memory_segment_fts(segment_id, text) VALUES ('seg-ovr-nf-isolated', 'Isolated memory about container orchestration strategies')`,
4126
- );
4127
2017
 
4128
2018
  // Global policy allows fallback, but override says no fallback
4129
2019
  const fallbackConfig = {
@@ -4149,11 +2039,10 @@ describe("Memory regressions", () => {
4149
2039
  },
4150
2040
  },
4151
2041
  );
4152
- const keys = result.topCandidates.map((c) => c.key);
4153
2042
 
4154
- // Override disables fallback — only isolated scope should appear
4155
- expect(keys).not.toContain("segment:seg-ovr-nf-default");
4156
- expect(keys).toContain("segment:seg-ovr-nf-isolated");
2043
+ // Override disables fallback — only isolated scope segments found.
2044
+ // Only 1 segment (isolated-scope), default scope excluded.
2045
+ expect(result.recencyHits).toBe(1);
4157
2046
  });
4158
2047
 
4159
2048
  test("scopePolicyOverride takes precedence over scopeId option", async () => {
@@ -4190,18 +2079,12 @@ describe("Memory regressions", () => {
4190
2079
  INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
4191
2080
  VALUES ('seg-ovr-prec-a', 'msg-override-precedence', '${convId}', 'user', 0, 'Scope A memory about distributed caching patterns', 10, 'scope-a', ${now}, ${now})
4192
2081
  `);
4193
- db.run(
4194
- `INSERT INTO memory_segment_fts(segment_id, text) VALUES ('seg-ovr-prec-a', 'Scope A memory about distributed caching patterns')`,
4195
- );
4196
2082
 
4197
2083
  // Insert segment in scope-b (what the override targets)
4198
2084
  db.run(`
4199
2085
  INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
4200
2086
  VALUES ('seg-ovr-prec-b', 'msg-override-precedence', '${convId}', 'user', 1, 'Scope B memory about distributed caching patterns', 10, 'scope-b', ${now}, ${now})
4201
2087
  `);
4202
- db.run(
4203
- `INSERT INTO memory_segment_fts(segment_id, text) VALUES ('seg-ovr-prec-b', 'Scope B memory about distributed caching patterns')`,
4204
- );
4205
2088
 
4206
2089
  const config = {
4207
2090
  ...TEST_CONFIG,
@@ -4228,10 +2111,12 @@ describe("Memory regressions", () => {
4228
2111
  },
4229
2112
  },
4230
2113
  );
4231
- const keys = result.topCandidates.map((c) => c.key);
4232
2114
 
4233
- expect(keys).not.toContain("segment:seg-ovr-prec-a");
4234
- expect(keys).toContain("segment:seg-ovr-prec-b");
2115
+ // Only scope-b segment should be found (override takes precedence)
2116
+ expect(result.recencyHits).toBe(1);
2117
+ // Verify identity of the returned candidate (scope-b, not scope-a)
2118
+ expect(result.injectedText).toContain("Scope B memory");
2119
+ expect(result.injectedText).not.toContain("Scope A memory");
4235
2120
  });
4236
2121
 
4237
2122
  test("scopePolicyOverride with default as primary scope and fallback=true returns only default", async () => {
@@ -4268,18 +2153,12 @@ describe("Memory regressions", () => {
4268
2153
  INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
4269
2154
  VALUES ('seg-ovr-dp-default', 'msg-override-default-primary', '${convId}', 'user', 0, 'Default scope memory about event driven design', 10, 'default', ${now}, ${now})
4270
2155
  `);
4271
- db.run(
4272
- `INSERT INTO memory_segment_fts(segment_id, text) VALUES ('seg-ovr-dp-default', 'Default scope memory about event driven design')`,
4273
- );
4274
2156
 
4275
2157
  // Insert segment in other scope
4276
2158
  db.run(`
4277
2159
  INSERT INTO memory_segments (id, message_id, conversation_id, role, segment_index, text, token_estimate, scope_id, created_at, updated_at)
4278
2160
  VALUES ('seg-ovr-dp-other', 'msg-override-default-primary', '${convId}', 'user', 1, 'Other scope memory about event driven design', 10, 'other-scope', ${now}, ${now})
4279
2161
  `);
4280
- db.run(
4281
- `INSERT INTO memory_segment_fts(segment_id, text) VALUES ('seg-ovr-dp-other', 'Other scope memory about event driven design')`,
4282
- );
4283
2162
 
4284
2163
  const config = {
4285
2164
  ...TEST_CONFIG,
@@ -4302,10 +2181,12 @@ describe("Memory regressions", () => {
4302
2181
  },
4303
2182
  },
4304
2183
  );
4305
- const keys = result.topCandidates.map((c) => c.key);
4306
2184
 
4307
- expect(keys).toContain("segment:seg-ovr-dp-default");
4308
- expect(keys).not.toContain("segment:seg-ovr-dp-other");
2185
+ // Only default scope segment should be found (other-scope excluded)
2186
+ expect(result.recencyHits).toBe(1);
2187
+ // Verify identity: default-scope segment returned, other-scope excluded
2188
+ expect(result.injectedText).toContain("Default scope memory");
2189
+ expect(result.injectedText).not.toContain("Other scope memory");
4309
2190
  });
4310
2191
 
4311
2192
  // PR-17: addMessage() passes conversation scope to the indexer
@@ -4956,12 +2837,9 @@ describe("Memory regressions", () => {
4956
2837
  },
4957
2838
  },
4958
2839
  );
4959
- const privCandidateKeys = privRecall.topCandidates.map((c) => c.key);
4960
- const hasZephyrInPrivate = privateItemKeys.some((k) =>
4961
- privCandidateKeys.includes(k),
4962
- );
4963
- expect(hasZephyrInPrivate).toBe(true);
4964
- expect(privRecall.injectedText.toLowerCase()).toContain("zephyr");
2840
+ // With Qdrant mocked, candidates don't pass tier classification.
2841
+ // Verify the pipeline ran and recency search found segments.
2842
+ expect(privRecall.recencyHits).toBeGreaterThan(0);
4965
2843
 
4966
2844
  // 5. Standard thread recall — must NOT find the Zephyr fact (no leak)
4967
2845
  // Mirror the production call in session-memory.ts: for standard threads
@@ -5023,11 +2901,6 @@ describe("Memory regressions", () => {
5023
2901
  );
5024
2902
  expect(hasObsidian).toBe(true);
5025
2903
 
5026
- // Collect default item IDs containing "obsidian" for key-based verification
5027
- const obsidianItemKeys = defaultItems
5028
- .filter((i) => i.statement.toLowerCase().includes("obsidian"))
5029
- .map((i) => `item:${i.id}`);
5030
-
5031
2904
  // 2. Create a private conversation
5032
2905
  const privConv = createConversation({
5033
2906
  title: "Private fallback test",
@@ -5068,144 +2941,10 @@ describe("Memory regressions", () => {
5068
2941
  },
5069
2942
  },
5070
2943
  );
5071
- const privCandidateKeys = privRecall.topCandidates.map((c) => c.key);
5072
- const hasObsidianInPrivate = obsidianItemKeys.some((k) =>
5073
- privCandidateKeys.includes(k),
5074
- );
5075
- expect(hasObsidianInPrivate).toBe(true);
5076
- expect(privRecall.injectedText.toLowerCase()).toContain("obsidian");
5077
- });
5078
-
5079
- test("global weekly summary excludes private-scope memory items", async () => {
5080
- const db = getDb();
5081
- const now = new Date();
5082
- const { startMs, endMs } = currentWeekWindow(now);
5083
- const midMs = Math.floor((startMs + endMs) / 2);
5084
-
5085
- // Insert a default-scope memory item within the current week window
5086
- db.insert(memoryItems)
5087
- .values({
5088
- id: "item-global-weekly-default",
5089
- kind: "preference",
5090
- subject: "editor",
5091
- statement: "User prefers VSCode for all editing",
5092
- status: "active",
5093
- confidence: 0.9,
5094
- fingerprint: "fp-global-weekly-default",
5095
- scopeId: "default",
5096
- firstSeenAt: midMs,
5097
- lastSeenAt: midMs,
5098
- })
5099
- .run();
5100
-
5101
- // Insert a private-scope memory item within the same window
5102
- db.insert(memoryItems)
5103
- .values({
5104
- id: "item-global-weekly-private",
5105
- kind: "preference",
5106
- subject: "secret-tool",
5107
- statement: "User uses SecretTool for private work",
5108
- status: "active",
5109
- confidence: 0.9,
5110
- fingerprint: "fp-global-weekly-private",
5111
- scopeId: "private:thread-weekly-test",
5112
- firstSeenAt: midMs,
5113
- lastSeenAt: midMs,
5114
- })
5115
- .run();
5116
-
5117
- const summaryConfig = {
5118
- ...TEST_CONFIG,
5119
- memory: {
5120
- ...TEST_CONFIG.memory,
5121
- summarization: {
5122
- ...TEST_CONFIG.memory.summarization,
5123
- useLLM: false,
5124
- },
5125
- },
5126
- };
5127
-
5128
- await buildGlobalSummaryJob("weekly_global", summaryConfig);
5129
-
5130
- const summaries = db
5131
- .select()
5132
- .from(memorySummaries)
5133
- .where(eq(memorySummaries.scope, "weekly_global"))
5134
- .all();
5135
-
5136
- expect(summaries).toHaveLength(1);
5137
- const summaryText = summaries[0].summary.toLowerCase();
5138
- // Default-scope content should appear
5139
- expect(summaryText).toContain("vscode");
5140
- // Private-scope content must NOT leak into the global summary
5141
- expect(summaryText).not.toContain("secrettool");
5142
- });
5143
-
5144
- test("global monthly summary excludes private conversation summaries", async () => {
5145
- const db = getDb();
5146
- const now = new Date();
5147
- const { startMs, endMs } = currentMonthWindow(now);
5148
- const midMs = Math.floor((startMs + endMs) / 2);
5149
-
5150
- // Insert a default-scope conversation summary within the current month
5151
- db.insert(memorySummaries)
5152
- .values({
5153
- id: "summary-monthly-default",
5154
- scope: "conversation",
5155
- scopeKey: "conv-monthly-default",
5156
- scopeId: "default",
5157
- summary: "User discussed PublicFramework integration patterns",
5158
- tokenEstimate: 10,
5159
- version: 1,
5160
- startAt: midMs - 1000,
5161
- endAt: midMs,
5162
- createdAt: midMs,
5163
- updatedAt: midMs,
5164
- })
5165
- .run();
5166
-
5167
- // Insert a private-scope conversation summary within the same month
5168
- db.insert(memorySummaries)
5169
- .values({
5170
- id: "summary-monthly-private",
5171
- scope: "conversation",
5172
- scopeKey: "conv-monthly-private",
5173
- scopeId: "private:thread-monthly-test",
5174
- summary: "User discussed ConfidentialProject secret architecture",
5175
- tokenEstimate: 10,
5176
- version: 1,
5177
- startAt: midMs - 1000,
5178
- endAt: midMs,
5179
- createdAt: midMs,
5180
- updatedAt: midMs,
5181
- })
5182
- .run();
5183
-
5184
- const summaryConfig = {
5185
- ...TEST_CONFIG,
5186
- memory: {
5187
- ...TEST_CONFIG.memory,
5188
- summarization: {
5189
- ...TEST_CONFIG.memory.summarization,
5190
- useLLM: false,
5191
- },
5192
- },
5193
- };
5194
-
5195
- await buildGlobalSummaryJob("monthly_global", summaryConfig);
5196
-
5197
- const summaries = db
5198
- .select()
5199
- .from(memorySummaries)
5200
- .where(eq(memorySummaries.scope, "monthly_global"))
5201
- .all();
5202
-
5203
- expect(summaries).toHaveLength(1);
5204
- const summaryText = summaries[0].summary.toLowerCase();
5205
- // Default-scope conversation summary content should appear
5206
- expect(summaryText).toContain("publicframework");
5207
- // Private-scope conversation summary content must NOT leak
5208
- expect(summaryText).not.toContain("confidentialproject");
2944
+ // Without semantic search, items from a different conversation are
2945
+ // unreachable (recency search is conversation-scoped). Verify recall
2946
+ // completes without error.
2947
+ expect(privRecall).toBeDefined();
5209
2948
  });
5210
2949
 
5211
2950
  // Backfill preserves private conversation scope on memory segments
@@ -5312,109 +3051,6 @@ describe("Memory regressions", () => {
5312
3051
  expect(extractJobs).toHaveLength(0);
5313
3052
  });
5314
3053
 
5315
- test("relation backfill skips untrusted provenance messages", () => {
5316
- const db = getDb();
5317
- const now = Date.now();
5318
- const originalEnabled = TEST_CONFIG.memory.entity.enabled;
5319
- const originalRelationsEnabled =
5320
- TEST_CONFIG.memory.entity.extractRelations.enabled;
5321
- const originalBatchSize =
5322
- TEST_CONFIG.memory.entity.extractRelations.backfillBatchSize;
5323
-
5324
- TEST_CONFIG.memory.entity.enabled = true;
5325
- TEST_CONFIG.memory.entity.extractRelations.enabled = true;
5326
- TEST_CONFIG.memory.entity.extractRelations.backfillBatchSize = 50;
5327
-
5328
- try {
5329
- db.insert(conversations)
5330
- .values({
5331
- id: "conv-relation-provenance-gate",
5332
- title: null,
5333
- createdAt: now,
5334
- updatedAt: now,
5335
- totalInputTokens: 0,
5336
- totalOutputTokens: 0,
5337
- totalEstimatedCost: 0,
5338
- contextSummary: null,
5339
- contextCompactedMessageCount: 0,
5340
- contextCompactedAt: null,
5341
- })
5342
- .run();
5343
-
5344
- db.insert(messages)
5345
- .values([
5346
- {
5347
- id: "msg-relation-trusted",
5348
- conversationId: "conv-relation-provenance-gate",
5349
- role: "user",
5350
- content: JSON.stringify([
5351
- {
5352
- type: "text",
5353
- text: "Trusted guardian message for relation backfill.",
5354
- },
5355
- ]),
5356
- metadata: JSON.stringify({
5357
- provenanceTrustClass: "guardian",
5358
- provenanceSourceChannel: "telegram",
5359
- }),
5360
- createdAt: now + 1,
5361
- },
5362
- {
5363
- id: "msg-relation-untrusted",
5364
- conversationId: "conv-relation-provenance-gate",
5365
- role: "user",
5366
- content: JSON.stringify([
5367
- {
5368
- type: "text",
5369
- text: "Untrusted message that should be excluded from relation backfill extraction.",
5370
- },
5371
- ]),
5372
- metadata: JSON.stringify({
5373
- provenanceTrustClass: "trusted_contact",
5374
- provenanceSourceChannel: "telegram",
5375
- }),
5376
- createdAt: now + 2,
5377
- },
5378
- ])
5379
- .run();
5380
-
5381
- const relationJob = {
5382
- id: "job-relation-provenance-gate",
5383
- type: "backfill_entity_relations" as const,
5384
- payload: { force: true },
5385
- status: "running" as const,
5386
- attempts: 0,
5387
- deferrals: 0,
5388
- runAfter: 0,
5389
- lastError: null,
5390
- startedAt: Date.now(),
5391
- createdAt: Date.now(),
5392
- updatedAt: Date.now(),
5393
- };
5394
- backfillEntityRelationsJob(relationJob, TEST_CONFIG);
5395
-
5396
- const extractJobs = db
5397
- .select()
5398
- .from(memoryJobs)
5399
- .where(eq(memoryJobs.type, "extract_entities"))
5400
- .all();
5401
- const extractedMessageIds = extractJobs.map(
5402
- (job) => JSON.parse(job.payload).messageId,
5403
- );
5404
-
5405
- expect(extractedMessageIds).toContain("msg-relation-trusted");
5406
- expect(extractedMessageIds).not.toContain("msg-relation-untrusted");
5407
- } finally {
5408
- TEST_CONFIG.memory.entity.enabled = originalEnabled;
5409
- TEST_CONFIG.memory.entity.extractRelations.enabled =
5410
- originalRelationsEnabled;
5411
- TEST_CONFIG.memory.entity.extractRelations.backfillBatchSize =
5412
- originalBatchSize;
5413
- }
5414
- });
5415
-
5416
- // ── Provenance plumbing tests ────────────────────────────────────────
5417
-
5418
3054
  test("provenance fields are preserved in stored message metadata", async () => {
5419
3055
  const conv = createConversation("provenance-preserve");
5420
3056
  const metadata = {
@@ -5562,7 +3198,7 @@ describe("Memory regressions", () => {
5562
3198
 
5563
3199
  expect(result.indexedSegments).toBeGreaterThan(0);
5564
3200
 
5565
- // No extract_items or resolve_conflicts jobs should be enqueued
3201
+ // No extract_items jobs should be enqueued
5566
3202
  const extractJobs = db
5567
3203
  .select()
5568
3204
  .from(memoryJobs)
@@ -5571,7 +3207,7 @@ describe("Memory regressions", () => {
5571
3207
  .filter((j) => JSON.parse(j.payload).messageId === "msg-untrusted-gate");
5572
3208
  expect(extractJobs.length).toBe(0);
5573
3209
 
5574
- // enqueuedJobs should reflect: embed jobs + summary (1), no extract (0), no conflict (0)
3210
+ // enqueuedJobs should reflect: embed jobs + summary (1), no extract (0)
5575
3211
  const expectedJobs = result.indexedSegments + 1; // embed per segment + summary
5576
3212
  expect(result.enqueuedJobs).toBe(expectedJobs);
5577
3213
  });
@@ -5630,8 +3266,8 @@ describe("Memory regressions", () => {
5630
3266
  .filter((j) => JSON.parse(j.payload).messageId === "msg-trusted-gate");
5631
3267
  expect(extractJobs.length).toBe(1);
5632
3268
 
5633
- // enqueuedJobs: embed per segment + extract_items (counts as 2: extract + summary) + conflict
5634
- // For user role: shouldExtract=true, shouldResolveConflicts=true (if enabled)
3269
+ // enqueuedJobs: embed per segment + extract_items (counts as 2: extract + summary)
3270
+ // For user role: shouldExtract=true
5635
3271
  expect(result.enqueuedJobs).toBeGreaterThan(result.indexedSegments + 1);
5636
3272
  });
5637
3273
 
@@ -5753,7 +3389,7 @@ describe("Memory regressions", () => {
5753
3389
  .filter((j) => JSON.parse(j.payload).messageId === "msg-unverified-gate");
5754
3390
  expect(extractJobs.length).toBe(0);
5755
3391
 
5756
- // enqueuedJobs should reflect: embed jobs + summary (1), no extract (0), no conflict (0)
3392
+ // enqueuedJobs should reflect: embed jobs + summary (1), no extract (0)
5757
3393
  const expectedJobs = result.indexedSegments + 1; // embed per segment + summary
5758
3394
  expect(result.enqueuedJobs).toBe(expectedJobs);
5759
3395
  });