@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
@@ -1,325 +1,3 @@
1
- import { inArray, sql } from "drizzle-orm";
2
-
3
- import type {
4
- AssistantConfig,
5
- MemoryRerankingConfig,
6
- } from "../../config/types.js";
7
- import { estimateTextTokens } from "../../context/token-estimator.js";
8
- import {
9
- extractText,
10
- getConfiguredProvider,
11
- userMessage,
12
- } from "../../providers/provider-send-message.js";
13
- import { getLogger } from "../../util/logger.js";
14
- import { getDb } from "../db.js";
15
- import { memoryItems } from "../schema.js";
16
- import { buildInjectedText } from "./formatting.js";
17
- import type { Candidate, CandidateSource, ItemMetadata } from "./types.js";
18
-
19
- const log = getLogger("memory-retriever");
20
-
21
- /**
22
- * Trust weight by verification state. Higher = more trusted.
23
- * Bounded: lowest weight is 0.7, never zero -- low-trust items are
24
- * down-ranked but not suppressed.
25
- */
26
- const TRUST_WEIGHTS: Record<string, number> = {
27
- user_confirmed: 1.0,
28
- user_reported: 0.9,
29
- assistant_inferred: 0.7,
30
- };
31
- const DEFAULT_TRUST_WEIGHT = 0.85;
32
-
33
- export const SOURCE_WEIGHTS: Record<CandidateSource, number> = {
34
- lexical: 1.0,
35
- semantic: 1.0,
36
- recency: 1.0,
37
- entity_direct: 1.0,
38
- item_direct: 0.95,
39
- entity_relation: 1.0,
40
- };
41
-
42
- const MS_PER_DAY = 86_400_000;
43
-
44
- /**
45
- * Reciprocal Rank Fusion (RRF) -- merge candidates from independent ranking
46
- * lists without assuming comparable score scales.
47
- *
48
- * Each candidate's RRF contribution from a list is `1 / (k + rank)` where
49
- * rank is 1-based position in that list sorted by its native score.
50
- * The final score is further modulated by importance so that high-importance
51
- * memories surface more readily.
52
- *
53
- * For item-type candidates we also apply retrieval reinforcement: access_count
54
- * from the DB boosts effective importance via `min(1, importance + 0.03 * accessCount)`.
55
- */
56
- export function mergeCandidates(
57
- lexical: Candidate[],
58
- semantic: Candidate[],
59
- recency: Candidate[],
60
- entity: Candidate[] = [],
61
- freshnessConfig?: {
62
- enabled: boolean;
63
- maxAgeDays: Record<string, number>;
64
- staleDecay: number;
65
- reinforcementShieldDays: number;
66
- },
67
- relationScoreMultiplier?: number,
68
- candidateDepthMap?: Map<string, number>,
69
- ): Candidate[] {
70
- // Build effective weight map that reflects the actual scoring weight for
71
- // each source. For entity_relation the static SOURCE_WEIGHTS entry is 1.0
72
- // (a neutral placeholder) but the real multiplier comes from the config
73
- // (relationScoreMultiplier). Using the effective weight in the dedup
74
- // upgrade comparison ensures item_direct (0.95) correctly outranks
75
- // entity_relation (e.g. 0.7) when both sources return the same candidate.
76
- const effectiveWeights: Record<string, number> = { ...SOURCE_WEIGHTS };
77
- if (relationScoreMultiplier != null) {
78
- effectiveWeights["entity_relation"] = relationScoreMultiplier;
79
- }
80
-
81
- // Build merged candidate map (dedup by key, keep best metadata)
82
- const merged = new Map<string, Candidate>();
83
- for (const candidate of [...lexical, ...semantic, ...recency, ...entity]) {
84
- const existing = merged.get(candidate.key);
85
- if (!existing) {
86
- merged.set(candidate.key, { ...candidate });
87
- continue;
88
- }
89
- existing.lexical = Math.max(existing.lexical, candidate.lexical);
90
- existing.semantic = Math.max(existing.semantic, candidate.semantic);
91
- existing.recency = Math.max(existing.recency, candidate.recency);
92
- existing.confidence = Math.max(existing.confidence, candidate.confidence);
93
- existing.importance = Math.max(existing.importance, candidate.importance);
94
- if (candidate.text.length > existing.text.length) {
95
- existing.text = candidate.text;
96
- }
97
- // Upgrade source to whichever has the higher effective weight so scoring
98
- // and caps reflect the strongest retrieval signal for this candidate.
99
- const existingWeight = effectiveWeights[existing.source] ?? 1.0;
100
- const candidateWeight = effectiveWeights[candidate.source] ?? 1.0;
101
- if (candidateWeight > existingWeight) {
102
- existing.source = candidate.source;
103
- }
104
- }
105
-
106
- // Build 1-based rank maps from each list (sorted by native score desc)
107
- const lexicalRanks = buildRankMap(lexical, (c) => c.lexical);
108
- const semanticRanks = buildRankMap(semantic, (c) => c.semantic);
109
- const recencyRanks = buildRankMap(recency, (c) => c.recency);
110
- const entityRanks = buildRankMap(entity, (c) => c.confidence);
111
-
112
- // Look up access_count and verification_state for item-type candidates
113
- const itemIds = [...merged.values()]
114
- .filter((c) => c.type === "item")
115
- .map((c) => c.id);
116
- const itemMetadata = lookupItemMetadata(itemIds);
117
-
118
- const rows = [...merged.values()];
119
- for (const row of rows) {
120
- const ranks: number[] = [];
121
- if (lexicalRanks.has(row.key)) ranks.push(lexicalRanks.get(row.key)!);
122
- if (semanticRanks.has(row.key)) ranks.push(semanticRanks.get(row.key)!);
123
- if (recencyRanks.has(row.key)) ranks.push(recencyRanks.get(row.key)!);
124
- if (entityRanks.has(row.key)) ranks.push(entityRanks.get(row.key)!);
125
-
126
- const rrfScore = rrf(ranks);
127
-
128
- // Retrieval reinforcement: boost importance by accessCount
129
- const meta = itemMetadata.get(row.id);
130
- const accessCount = meta?.accessCount ?? 0;
131
- const effectiveImportance = Math.min(
132
- 1,
133
- row.importance + 0.03 * accessCount,
134
- );
135
-
136
- // Trust-aware ranking: only apply to item candidates (segments/summaries have no metadata)
137
- const trustWeight =
138
- row.type === "item" && meta
139
- ? (TRUST_WEIGHTS[meta.verificationState] ?? DEFAULT_TRUST_WEIGHT)
140
- : 1.0;
141
-
142
- // Freshness decay: down-rank stale items unless recently reinforced
143
- const lastUsedAt = meta?.lastUsedAt ?? null;
144
- const freshnessWeight = computeFreshnessWeight(
145
- row,
146
- accessCount,
147
- lastUsedAt,
148
- freshnessConfig,
149
- );
150
-
151
- let sourceWeight = effectiveWeights[row.source] ?? 1.0;
152
- if (
153
- row.source === "entity_relation" &&
154
- candidateDepthMap &&
155
- relationScoreMultiplier != null
156
- ) {
157
- const depth = candidateDepthMap.get(row.key) ?? 1;
158
- sourceWeight = Math.pow(relationScoreMultiplier, depth);
159
- }
160
- row.finalScore =
161
- rrfScore *
162
- (0.5 + 0.5 * effectiveImportance) *
163
- trustWeight *
164
- freshnessWeight *
165
- sourceWeight;
166
- }
167
-
168
- rows.sort((a, b) => {
169
- const scoreDelta = b.finalScore - a.finalScore;
170
- if (scoreDelta !== 0) return scoreDelta;
171
- const createdAtDelta = b.createdAt - a.createdAt;
172
- if (createdAtDelta !== 0) return createdAtDelta;
173
- return a.key.localeCompare(b.key);
174
- });
175
- return rows;
176
- }
177
-
178
- export function applySourceCaps(
179
- candidates: Candidate[],
180
- config: AssistantConfig,
181
- ): Candidate[] {
182
- if (candidates.length === 0) return candidates;
183
- const sourceCaps = buildSourceCaps(config);
184
- const counts: Partial<Record<CandidateSource, number>> = {};
185
- const capped: Candidate[] = [];
186
-
187
- for (const candidate of candidates) {
188
- const cap = sourceCaps[candidate.source];
189
- const current = counts[candidate.source] ?? 0;
190
- if (current >= cap) continue;
191
- counts[candidate.source] = current + 1;
192
- capped.push(candidate);
193
- }
194
-
195
- return capped;
196
- }
197
-
198
- function buildSourceCaps(
199
- config: AssistantConfig,
200
- ): Record<CandidateSource, number> {
201
- const lexicalTopK = Math.max(1, config.memory.retrieval.lexicalTopK);
202
- const semanticTopK = Math.max(1, config.memory.retrieval.semanticTopK);
203
- const relationLimit = Math.max(
204
- 3,
205
- Math.floor(
206
- Math.min(
207
- config.memory.entity.relationRetrieval.maxNeighborEntities,
208
- config.memory.entity.relationRetrieval.maxEdges,
209
- semanticTopK,
210
- ) * 0.4,
211
- ),
212
- );
213
-
214
- return {
215
- lexical: Math.max(12, lexicalTopK),
216
- semantic: Math.max(8, semanticTopK),
217
- recency: Math.max(6, Math.floor(semanticTopK / 2)),
218
- entity_direct: Math.max(6, Math.floor(semanticTopK / 2)),
219
- item_direct: Math.max(8, Math.floor(lexicalTopK / 2)),
220
- entity_relation: relationLimit,
221
- };
222
- }
223
-
224
- /** Reciprocal Rank Fusion score: sum of 1/(k+rank) across all lists. */
225
- function rrf(ranks: number[], k = 60): number {
226
- return ranks.reduce((sum, rank) => sum + 1 / (k + rank), 0);
227
- }
228
-
229
- /**
230
- * Build a map from candidate key to 1-based rank within a list,
231
- * sorted descending by the given score accessor.
232
- */
233
- function buildRankMap(
234
- candidates: Candidate[],
235
- scoreAccessor: (c: Candidate) => number,
236
- ): Map<string, number> {
237
- const sorted = [...candidates].sort(
238
- (a, b) => scoreAccessor(b) - scoreAccessor(a),
239
- );
240
- const rankMap = new Map<string, number>();
241
- for (let i = 0; i < sorted.length; i++) {
242
- rankMap.set(sorted[i].key, i + 1);
243
- }
244
- return rankMap;
245
- }
246
-
247
- /**
248
- * Look up access_count and verification_state from memory_items for a batch of item IDs.
249
- */
250
- function lookupItemMetadata(itemIds: string[]): Map<string, ItemMetadata> {
251
- const metadata = new Map<string, ItemMetadata>();
252
- if (itemIds.length === 0) return metadata;
253
- try {
254
- const db = getDb();
255
- const rows = db
256
- .select({
257
- id: memoryItems.id,
258
- accessCount: memoryItems.accessCount,
259
- lastUsedAt: memoryItems.lastUsedAt,
260
- verificationState: memoryItems.verificationState,
261
- })
262
- .from(memoryItems)
263
- .where(inArray(memoryItems.id, itemIds))
264
- .all();
265
- for (const row of rows) {
266
- metadata.set(row.id, {
267
- accessCount: row.accessCount,
268
- lastUsedAt: row.lastUsedAt,
269
- verificationState: row.verificationState,
270
- });
271
- }
272
- } catch (err) {
273
- log.warn({ err }, "Failed to look up item metadata for retrieval ranking");
274
- }
275
- return metadata;
276
- }
277
-
278
- /**
279
- * Compute a freshness weight for a candidate based on its kind and age.
280
- * Returns 1.0 for fresh items and `staleDecay` for items past their window.
281
- * Items with recent reinforcement (accessed via lastUsedAt within the shield
282
- * window) are shielded from decay.
283
- */
284
- export function computeFreshnessWeight(
285
- candidate: { type: string; kind: string; createdAt: number },
286
- accessCount: number,
287
- lastUsedAt: number | null,
288
- config?: {
289
- enabled: boolean;
290
- maxAgeDays: Record<string, number>;
291
- staleDecay: number;
292
- reinforcementShieldDays: number;
293
- },
294
- ): number {
295
- if (!config?.enabled) return 1.0;
296
-
297
- // Only apply freshness to item-type candidates
298
- if (candidate.type !== "item") return 1.0;
299
-
300
- const maxAgeDays = config.maxAgeDays[candidate.kind] ?? 0;
301
- // maxAgeDays of 0 means no expiry for this kind
302
- if (maxAgeDays <= 0) return 1.0;
303
-
304
- const now = Date.now();
305
- const ageMs = now - candidate.createdAt;
306
- const ageDays = ageMs / MS_PER_DAY;
307
-
308
- if (ageDays <= maxAgeDays) return 1.0;
309
-
310
- // Check reinforcement shield: items retrieved within the shield window are protected
311
- if (
312
- accessCount > 0 &&
313
- lastUsedAt != null &&
314
- config.reinforcementShieldDays > 0
315
- ) {
316
- const shieldCutoff = now - config.reinforcementShieldDays * MS_PER_DAY;
317
- if (lastUsedAt >= shieldCutoff) return 1.0;
318
- }
319
-
320
- return config.staleDecay;
321
- }
322
-
323
1
  /**
324
2
  * Logarithmic recency decay (ACT-R inspired).
325
3
  *
@@ -335,136 +13,3 @@ export function computeRecencyScore(createdAt: number): number {
335
13
  const ageDays = ageMs / (24 * 60 * 60 * 1000);
336
14
  return 1 / (1 + Math.log2(1 + ageDays));
337
15
  }
338
-
339
- /**
340
- * LLM re-ranking: send candidate memories to Haiku for relevance scoring.
341
- * Returns candidates re-sorted by LLM-assigned relevance score.
342
- */
343
- export async function rerankWithLLM(
344
- query: string,
345
- candidates: Candidate[],
346
- rerankingConfig: MemoryRerankingConfig,
347
- ): Promise<Candidate[]> {
348
- const provider = getConfiguredProvider();
349
- if (!provider) {
350
- log.debug("Configured provider unavailable for LLM re-ranking, skipping");
351
- return candidates;
352
- }
353
-
354
- const candidateList = candidates.map((c, i) => ({
355
- index: i,
356
- id: c.key,
357
- text: truncate(c.text, 200),
358
- }));
359
-
360
- const response = await provider.sendMessage(
361
- [
362
- userMessage(
363
- `Query: ${truncate(query, 200)}\n\nCandidates:\n${candidateList
364
- .map((c) => `[${c.index}] ${c.text}`)
365
- .join("\n")}`,
366
- ),
367
- ],
368
- undefined,
369
- 'You are a relevance scoring assistant. Given a query and a list of memory candidates, rate each candidate\'s relevance to the query on a scale of 0-10. Return ONLY a JSON array of objects with "index" (the candidate index) and "score" (0-10 integer). No explanation.',
370
- {
371
- config: {
372
- modelIntent: rerankingConfig.modelIntent,
373
- max_tokens: 1024,
374
- },
375
- },
376
- );
377
-
378
- // Extract text from the response
379
- const responseText = extractText(response);
380
- if (!responseText) {
381
- log.warn("LLM re-ranking returned no text block, skipping");
382
- return candidates;
383
- }
384
-
385
- // Parse the JSON array from the response
386
- const jsonMatch = responseText.match(/\[[\s\S]*\]/);
387
- if (!jsonMatch) {
388
- log.warn("LLM re-ranking response did not contain JSON array, skipping");
389
- return candidates;
390
- }
391
-
392
- let scores: Array<{ index: number; score: number }>;
393
- try {
394
- scores = JSON.parse(jsonMatch[0]) as Array<{
395
- index: number;
396
- score: number;
397
- }>;
398
- } catch {
399
- log.warn("Failed to parse LLM re-ranking JSON response, skipping");
400
- return candidates;
401
- }
402
-
403
- // Build a score map from LLM results
404
- const scoreMap = new Map<number, number>();
405
- for (const entry of scores) {
406
- if (typeof entry.index === "number" && typeof entry.score === "number") {
407
- scoreMap.set(entry.index, Math.max(0, Math.min(10, entry.score)));
408
- }
409
- }
410
-
411
- // Re-sort candidates by LLM score (desc); unscored candidates keep original order after scored ones
412
- const reranked = candidates.map((c, i) => ({
413
- candidate: c,
414
- llmScore: scoreMap.has(i) ? scoreMap.get(i)! : null,
415
- originalIndex: i,
416
- }));
417
-
418
- reranked.sort((a, b) => {
419
- // Scored items come before unscored items
420
- if (a.llmScore != null && b.llmScore == null) return -1;
421
- if (a.llmScore == null && b.llmScore != null) return 1;
422
- // Both scored: sort by score descending
423
- if (a.llmScore != null && b.llmScore != null) {
424
- const scoreDelta = b.llmScore - a.llmScore;
425
- if (scoreDelta !== 0) return scoreDelta;
426
- }
427
- // Both unscored or tie: preserve original RRF order
428
- return a.originalIndex - b.originalIndex;
429
- });
430
-
431
- return reranked.map((r) => r.candidate);
432
- }
433
-
434
- export function trimToTokenBudget(
435
- candidates: Candidate[],
436
- maxTokens: number,
437
- format: string = "markdown",
438
- ): Candidate[] {
439
- if (maxTokens <= 0) return [];
440
- const selected: Candidate[] = [];
441
- for (const candidate of candidates) {
442
- const tentativeText = buildInjectedText([...selected, candidate], format);
443
- const cost = estimateTextTokens(tentativeText);
444
- if (cost > maxTokens) continue;
445
- selected.push(candidate);
446
- if (cost >= maxTokens) break;
447
- }
448
- return selected;
449
- }
450
-
451
- export function markItemUsage(candidates: Candidate[]): void {
452
- const itemIds = candidates
453
- .filter((candidate) => candidate.type === "item")
454
- .map((candidate) => candidate.id);
455
- if (itemIds.length === 0) return;
456
- const db = getDb();
457
- const now = Date.now();
458
- db.update(memoryItems)
459
- .set({
460
- lastUsedAt: now,
461
- accessCount: sql`${memoryItems.accessCount} + 1`,
462
- })
463
- .where(inArray(memoryItems.id, itemIds))
464
- .run();
465
- }
466
-
467
- function truncate(text: string, max: number): string {
468
- if (text.length <= max) return text;
469
- return `${text.slice(0, max - 3)}...`;
470
- }
@@ -7,7 +7,10 @@ import {
7
7
  _resetQdrantBreaker,
8
8
  withQdrantBreaker,
9
9
  } from "../qdrant-circuit-breaker.js";
10
- import type { QdrantSearchResult } from "../qdrant-client.js";
10
+ import type {
11
+ QdrantSearchResult,
12
+ QdrantSparseVector,
13
+ } from "../qdrant-client.js";
11
14
  import { getQdrantClient } from "../qdrant-client.js";
12
15
  import {
13
16
  conversations,
@@ -31,6 +34,7 @@ export async function semanticSearch(
31
34
  limit: number,
32
35
  excludedMessageIds: string[] = [],
33
36
  scopeIds?: string[],
37
+ sparseVector?: QdrantSparseVector,
34
38
  ): Promise<Candidate[]> {
35
39
  if (limit <= 0) return [];
36
40
 
@@ -40,14 +44,33 @@ export async function semanticSearch(
40
44
  // Use 3x when exclusions are active to ensure enough results survive filtering
41
45
  const overfetchMultiplier = excludedMessageIds.length > 0 ? 3 : 2;
42
46
  const fetchLimit = limit * overfetchMultiplier;
43
- const results: QdrantSearchResult[] = await withQdrantBreaker(() =>
44
- qdrant.searchWithFilter(
45
- queryVector,
46
- fetchLimit,
47
- ["item", "summary", "segment", "media"],
48
- excludedMessageIds,
49
- ),
50
- );
47
+
48
+ // When a sparse vector is available, use hybrid search (dense + sparse RRF fusion)
49
+ // for better recall; otherwise fall back to dense-only search.
50
+ let results: QdrantSearchResult[];
51
+ let isHybrid = false;
52
+ if (sparseVector && sparseVector.indices.length > 0) {
53
+ isHybrid = true;
54
+ const filter = buildHybridFilter(excludedMessageIds, scopeIds);
55
+ results = await withQdrantBreaker(() =>
56
+ qdrant.hybridSearch({
57
+ denseVector: queryVector,
58
+ sparseVector,
59
+ filter,
60
+ limit: fetchLimit,
61
+ prefetchLimit: fetchLimit,
62
+ }),
63
+ );
64
+ } else {
65
+ results = await withQdrantBreaker(() =>
66
+ qdrant.searchWithFilter(
67
+ queryVector,
68
+ fetchLimit,
69
+ ["item", "summary", "segment", "media"],
70
+ excludedMessageIds,
71
+ ),
72
+ );
73
+ }
51
74
 
52
75
  const db = getDb();
53
76
 
@@ -137,7 +160,8 @@ export async function semanticSearch(
137
160
  const candidates: Candidate[] = [];
138
161
  for (const result of results) {
139
162
  const { payload, score } = result;
140
- const semantic = mapCosineToUnit(score);
163
+ // Store raw score; hybrid RRF normalization happens after filtering
164
+ const semantic = isHybrid ? score : mapCosineToUnit(score);
141
165
  const createdAt = payload.created_at ?? Date.now();
142
166
 
143
167
  if (payload.target_type === "item") {
@@ -160,7 +184,6 @@ export async function semanticSearch(
160
184
  confidence: item.confidence,
161
185
  importance: item.importance ?? 0.5,
162
186
  createdAt: item.lastSeenAt,
163
- lexical: 0,
164
187
  semantic,
165
188
  recency: computeRecencyScore(item.lastSeenAt),
166
189
  finalScore: 0,
@@ -181,7 +204,6 @@ export async function semanticSearch(
181
204
  confidence: 0.6,
182
205
  importance: 0.6,
183
206
  createdAt: payload.last_seen_at ?? createdAt,
184
- lexical: 0,
185
207
  semantic,
186
208
  recency: computeRecencyScore(payload.last_seen_at ?? createdAt),
187
209
  finalScore: 0,
@@ -214,7 +236,6 @@ export async function semanticSearch(
214
236
  confidence: 0.7,
215
237
  importance: 0.6,
216
238
  createdAt,
217
- lexical: 0,
218
239
  semantic,
219
240
  recency: computeRecencyScore(createdAt),
220
241
  finalScore: 0,
@@ -234,7 +255,6 @@ export async function semanticSearch(
234
255
  confidence: 0.55,
235
256
  importance: 0.5,
236
257
  createdAt,
237
- lexical: 0,
238
258
  semantic,
239
259
  recency: computeRecencyScore(createdAt),
240
260
  finalScore: 0,
@@ -242,9 +262,75 @@ export async function semanticSearch(
242
262
  }
243
263
  if (candidates.length >= limit) break;
244
264
  }
265
+
266
+ // For hybrid search (RRF fusion), normalize semantic scores relative to
267
+ // the surviving candidates' maximum — not the raw Qdrant batch. Filtered-out
268
+ // high-scoring hits must not anchor normalization and deflate survivors.
269
+ if (isHybrid && candidates.length > 0) {
270
+ const maxScore = Math.max(...candidates.map((c) => c.semantic));
271
+ if (maxScore > 0) {
272
+ for (const c of candidates) {
273
+ c.semantic = c.semantic / maxScore;
274
+ }
275
+ }
276
+ }
277
+
245
278
  return candidates;
246
279
  }
247
280
 
281
+ /**
282
+ * Build a Qdrant filter for hybrid search. Mirrors the logic in
283
+ * `searchWithFilter` but as a standalone object for the query API.
284
+ *
285
+ * Scope filtering: items and media store `memory_scope_id` on the Qdrant
286
+ * point payload, so we can filter at the Qdrant level. Segments and
287
+ * summaries rely on post-query DB filtering (same as dense-only search).
288
+ */
289
+ function buildHybridFilter(
290
+ excludeMessageIds: string[],
291
+ _scopeIds?: string[],
292
+ ): Record<string, unknown> {
293
+ const mustConditions: Array<Record<string, unknown>> = [
294
+ {
295
+ key: "target_type",
296
+ match: { any: ["item", "summary", "segment", "media"] },
297
+ },
298
+ ];
299
+
300
+ if (excludeMessageIds.length > 0) {
301
+ // Only require status=active for items; segments and summaries don't have a status field
302
+ mustConditions.push({
303
+ should: [
304
+ {
305
+ must: [
306
+ { key: "target_type", match: { value: "item" } },
307
+ { key: "status", match: { value: "active" } },
308
+ ],
309
+ },
310
+ {
311
+ key: "target_type",
312
+ match: { any: ["segment", "summary", "media"] },
313
+ },
314
+ ],
315
+ });
316
+ }
317
+
318
+ const mustNotConditions: Array<Record<string, unknown>> = [
319
+ { key: "_meta", match: { value: true } },
320
+ ];
321
+ if (excludeMessageIds.length > 0) {
322
+ mustNotConditions.push({
323
+ key: "message_id",
324
+ match: { any: excludeMessageIds },
325
+ });
326
+ }
327
+
328
+ return {
329
+ must: mustConditions,
330
+ must_not: mustNotConditions,
331
+ };
332
+ }
333
+
248
334
  export function mapCosineToUnit(value: number): number {
249
335
  return Math.max(0, Math.min(1, (value + 1) / 2));
250
336
  }
@@ -0,0 +1,47 @@
1
+ import type { TieredCandidate } from "./tier-classifier.js";
2
+ import type { StalenessLevel } from "./types.js";
3
+
4
+ const BASE_LIFETIME_MS: Record<string, number> = {
5
+ identity: 180 * 86_400_000, // 6 months
6
+ preference: 90 * 86_400_000, // 3 months
7
+ constraint: 30 * 86_400_000, // 1 month
8
+ project: 14 * 86_400_000, // 2 weeks
9
+ decision: 14 * 86_400_000, // 2 weeks
10
+ event: 3 * 86_400_000, // 3 days
11
+ };
12
+
13
+ const DEFAULT_LIFETIME_MS = 30 * 86_400_000;
14
+
15
+ export function computeStaleness(
16
+ item: {
17
+ kind: string;
18
+ firstSeenAt: number;
19
+ sourceConversationCount: number;
20
+ },
21
+ now: number,
22
+ ): { level: StalenessLevel; ratio: number } {
23
+ const baseLifetime = BASE_LIFETIME_MS[item.kind] ?? DEFAULT_LIFETIME_MS;
24
+ const reinforcement = Math.max(1, 1 + 0.3 * (item.sourceConversationCount - 1));
25
+ const effectiveLifetime = baseLifetime * reinforcement;
26
+ const age = now - item.firstSeenAt;
27
+ const ratio = age / effectiveLifetime;
28
+
29
+ if (ratio < 0.5) return { level: "fresh", ratio };
30
+ if (ratio <= 1) return { level: "aging", ratio };
31
+ if (ratio <= 2) return { level: "stale", ratio };
32
+ return { level: "very_stale", ratio };
33
+ }
34
+
35
+ /**
36
+ * Demote very_stale tier-1 candidates to tier 2.
37
+ */
38
+ export function applyStaleDemotion(
39
+ candidates: TieredCandidate[],
40
+ ): TieredCandidate[] {
41
+ return candidates.map((c) => {
42
+ if (c.tier === 1 && c.staleness === "very_stale") {
43
+ return { ...c, tier: 2 as const };
44
+ }
45
+ return c;
46
+ });
47
+ }
@@ -0,0 +1,21 @@
1
+ import type { Candidate } from "./types.js";
2
+
3
+ export type Tier = 1 | 2;
4
+
5
+ export interface TieredCandidate extends Candidate {
6
+ tier: Tier;
7
+ /** Human-readable label for the source conversation/summary (e.g. conversation title). */
8
+ sourceLabel?: string;
9
+ }
10
+
11
+ export function classifyTier(score: number): Tier | null {
12
+ if (score > 0.8) return 1;
13
+ if (score > 0.6) return 2;
14
+ return null;
15
+ }
16
+
17
+ export function classifyTiers(candidates: Candidate[]): TieredCandidate[] {
18
+ return candidates
19
+ .map((c) => ({ ...c, tier: classifyTier(c.finalScore) }))
20
+ .filter((c): c is TieredCandidate => c.tier != null);
21
+ }