@vellumai/assistant 0.5.16 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (407) hide show
  1. package/ARCHITECTURE.md +1 -1
  2. package/Dockerfile +0 -3
  3. package/knip.json +2 -1
  4. package/openapi.yaml +660 -80
  5. package/package.json +1 -1
  6. package/src/__tests__/actor-token-service.test.ts +68 -0
  7. package/src/__tests__/agent-loop.test.ts +0 -32
  8. package/src/__tests__/always-loaded-tools-guard.test.ts +2 -2
  9. package/src/__tests__/anthropic-provider.test.ts +57 -3
  10. package/src/__tests__/app-compiler.test.ts +120 -0
  11. package/src/__tests__/assistant-feature-flags-integration.test.ts +2 -2
  12. package/src/__tests__/call-conversation-messages.test.ts +2 -6
  13. package/src/__tests__/call-domain.test.ts +2 -6
  14. package/src/__tests__/call-pointer-messages.test.ts +2 -14
  15. package/src/__tests__/call-recovery.test.ts +2 -6
  16. package/src/__tests__/call-routes-http.test.ts +2 -6
  17. package/src/__tests__/call-store.test.ts +2 -6
  18. package/src/__tests__/cancel-resolves-conversation-key.test.ts +2 -6
  19. package/src/__tests__/canonical-guardian-store.test.ts +2 -6
  20. package/src/__tests__/channel-delivery-store.test.ts +2 -6
  21. package/src/__tests__/channel-retry-sweep.test.ts +2 -6
  22. package/src/__tests__/checker.test.ts +25 -3
  23. package/src/__tests__/clawhub.test.ts +54 -24
  24. package/src/__tests__/cli-command-risk-guard.test.ts +14 -0
  25. package/src/__tests__/cli-memory.test.ts +74 -69
  26. package/src/__tests__/config-schema.test.ts +1 -1
  27. package/src/__tests__/config-set-platform-guard.test.ts +302 -0
  28. package/src/__tests__/confirmation-request-guardian-bridge.test.ts +2 -6
  29. package/src/__tests__/contacts-tools.test.ts +31 -0
  30. package/src/__tests__/context-overflow-reducer.test.ts +86 -0
  31. package/src/__tests__/context-token-estimator.test.ts +175 -10
  32. package/src/__tests__/conversation-agent-loop-overflow.test.ts +9 -0
  33. package/src/__tests__/conversation-agent-loop.test.ts +9 -0
  34. package/src/__tests__/conversation-attachments.test.ts +2 -6
  35. package/src/__tests__/conversation-attention-store.test.ts +2 -6
  36. package/src/__tests__/conversation-clear-safety.test.ts +2 -6
  37. package/src/__tests__/conversation-delete-schedule-cleanup.test.ts +4 -10
  38. package/src/__tests__/conversation-disk-view-integration.test.ts +2 -6
  39. package/src/__tests__/conversation-disk-view.test.ts +2 -6
  40. package/src/__tests__/conversation-error.test.ts +33 -2
  41. package/src/__tests__/conversation-fork-crud.test.ts +2 -6
  42. package/src/__tests__/conversation-history-web-search.test.ts +5 -0
  43. package/src/__tests__/conversation-load-history-repair.test.ts +5 -1
  44. package/src/__tests__/conversation-media-retry.test.ts +91 -0
  45. package/src/__tests__/conversation-starter-routes.test.ts +20 -11
  46. package/src/__tests__/conversation-store.test.ts +2 -6
  47. package/src/__tests__/conversation-usage.test.ts +2 -6
  48. package/src/__tests__/conversation-wipe.test.ts +11 -408
  49. package/src/__tests__/credential-execution-feature-gates.test.ts +3 -3
  50. package/src/__tests__/credential-execution-shell-lockdown.test.ts +2 -2
  51. package/src/__tests__/credential-security-e2e.test.ts +2 -0
  52. package/src/__tests__/followup-tools.test.ts +2 -6
  53. package/src/__tests__/graph-extraction-event-date.test.ts +186 -0
  54. package/src/__tests__/guardian-action-conversation-turn.test.ts +2 -6
  55. package/src/__tests__/guardian-action-followup-executor.test.ts +2 -6
  56. package/src/__tests__/guardian-action-followup-store.test.ts +2 -6
  57. package/src/__tests__/guardian-action-grant-mint-consume.test.ts +2 -6
  58. package/src/__tests__/guardian-action-late-reply.test.ts +2 -6
  59. package/src/__tests__/guardian-action-store.test.ts +2 -6
  60. package/src/__tests__/guardian-binding-drift-heal.test.ts +2 -6
  61. package/src/__tests__/guardian-decision-primitive-canonical.test.ts +8 -8
  62. package/src/__tests__/guardian-dispatch.test.ts +2 -6
  63. package/src/__tests__/guardian-grant-minting.test.ts +2 -14
  64. package/src/__tests__/guardian-principal-id-roundtrip.test.ts +2 -6
  65. package/src/__tests__/guardian-routing-invariants.test.ts +192 -6
  66. package/src/__tests__/guardian-routing-state.test.ts +2 -6
  67. package/src/__tests__/guardian-verification-voice-binding.test.ts +2 -6
  68. package/src/__tests__/inbound-invite-redemption.test.ts +2 -6
  69. package/src/__tests__/injection-block.test.ts +154 -0
  70. package/src/__tests__/install-meta.test.ts +506 -0
  71. package/src/__tests__/install-skill-routing.test.ts +292 -0
  72. package/src/__tests__/invite-redemption-service.test.ts +2 -6
  73. package/src/__tests__/invite-routes-http.test.ts +2 -6
  74. package/src/__tests__/jobs-store-qdrant-breaker.test.ts +2 -14
  75. package/src/__tests__/list-messages-attachments.test.ts +2 -6
  76. package/src/__tests__/llm-context-route-provider.test.ts +2 -6
  77. package/src/__tests__/llm-request-log-turn-query.test.ts +2 -6
  78. package/src/__tests__/llm-usage-store.test.ts +2 -6
  79. package/src/__tests__/log-export-workspace.test.ts +2 -6
  80. package/src/__tests__/managed-store.test.ts +38 -11
  81. package/src/__tests__/memory-jobs-worker-backoff.test.ts +2 -8
  82. package/src/__tests__/memory-recall-log-store.test.ts +2 -6
  83. package/src/__tests__/memory-upsert-concurrency.test.ts +4 -112
  84. package/src/__tests__/non-member-access-request.test.ts +2 -6
  85. package/src/__tests__/notification-guardian-path.test.ts +2 -6
  86. package/src/__tests__/oauth-cli.test.ts +364 -2
  87. package/src/__tests__/oauth2-gateway-transport.test.ts +18 -3
  88. package/src/__tests__/outlook-attachments.test.ts +301 -0
  89. package/src/__tests__/outlook-automation-tools.test.ts +425 -0
  90. package/src/__tests__/outlook-categories.test.ts +212 -0
  91. package/src/__tests__/outlook-client-automation.test.ts +246 -0
  92. package/src/__tests__/outlook-compose-tools.test.ts +325 -0
  93. package/src/__tests__/outlook-declutter-tools.test.ts +585 -0
  94. package/src/__tests__/outlook-email-watcher.test.ts +322 -0
  95. package/src/__tests__/outlook-follow-up.test.ts +196 -0
  96. package/src/__tests__/outlook-messaging-provider.test.ts +498 -3
  97. package/src/__tests__/outlook-trash.test.ts +77 -0
  98. package/src/__tests__/outlook-unsubscribe.test.ts +250 -0
  99. package/src/__tests__/platform-callback-registration.test.ts +4 -4
  100. package/src/__tests__/playbook-execution.test.ts +76 -80
  101. package/src/__tests__/playbook-tools.test.ts +5 -7
  102. package/src/__tests__/provider-error-scenarios.test.ts +21 -0
  103. package/src/__tests__/rebuild-index-graph-nodes.test.ts +273 -0
  104. package/src/__tests__/registry.test.ts +2 -2
  105. package/src/__tests__/require-fresh-approval.test.ts +64 -2
  106. package/src/__tests__/runtime-events-sse-parity.test.ts +2 -6
  107. package/src/__tests__/runtime-events-sse.test.ts +2 -6
  108. package/src/__tests__/schedule-store.test.ts +2 -6
  109. package/src/__tests__/schedule-tools.test.ts +2 -6
  110. package/src/__tests__/scheduler-recurrence.test.ts +1 -5
  111. package/src/__tests__/scoped-approval-grants.test.ts +2 -6
  112. package/src/__tests__/scoped-grant-security-matrix.test.ts +2 -6
  113. package/src/__tests__/search-skills-unified.test.ts +421 -0
  114. package/src/__tests__/secret-onetime-send.test.ts +2 -0
  115. package/src/__tests__/send-endpoint-busy.test.ts +2 -6
  116. package/src/__tests__/sequence-store.test.ts +2 -6
  117. package/src/__tests__/server-history-render.test.ts +2 -6
  118. package/src/__tests__/skill-feature-flags-integration.test.ts +38 -31
  119. package/src/__tests__/skill-feature-flags.test.ts +6 -6
  120. package/src/__tests__/skill-load-feature-flag.test.ts +11 -11
  121. package/src/__tests__/skill-memory.test.ts +140 -98
  122. package/src/__tests__/skills-uninstall.test.ts +2 -2
  123. package/src/__tests__/skills.test.ts +1 -1
  124. package/src/__tests__/slack-inbound-verification.test.ts +2 -6
  125. package/src/__tests__/task-compiler.test.ts +2 -6
  126. package/src/__tests__/task-management-tools.test.ts +2 -6
  127. package/src/__tests__/task-memory-cleanup.test.ts +173 -229
  128. package/src/__tests__/task-runner.test.ts +2 -6
  129. package/src/__tests__/task-scheduler.test.ts +2 -6
  130. package/src/__tests__/test-preload.ts +3 -0
  131. package/src/__tests__/tool-approval-handler.test.ts +2 -6
  132. package/src/__tests__/tool-grant-request-escalation.test.ts +2 -6
  133. package/src/__tests__/tool-side-effects-slack-dm.test.ts +276 -0
  134. package/src/__tests__/trust-store.test.ts +1 -1
  135. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +2 -6
  136. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +2 -6
  137. package/src/__tests__/trusted-contact-multichannel.test.ts +2 -6
  138. package/src/__tests__/trusted-contact-verification.test.ts +2 -6
  139. package/src/__tests__/turn-boundary-resolution.test.ts +2 -6
  140. package/src/__tests__/usage-cache-backfill-migration.test.ts +1 -6
  141. package/src/__tests__/usage-routes.test.ts +2 -6
  142. package/src/__tests__/verification-control-plane-policy.test.ts +0 -2
  143. package/src/__tests__/voice-invite-redemption.test.ts +2 -6
  144. package/src/__tests__/voice-scoped-grant-consumer.test.ts +2 -6
  145. package/src/__tests__/voice-session-bridge.test.ts +2 -6
  146. package/src/__tests__/volume-security-guard.test.ts +2 -0
  147. package/src/__tests__/workspace-lifecycle.test.ts +29 -1
  148. package/src/__tests__/workspace-migration-009-backfill-conversation-disk-view.test.ts +2 -6
  149. package/src/__tests__/workspace-migration-013-repair-conversation-disk-view.test.ts +2 -6
  150. package/src/__tests__/workspace-migration-026-backfill-install-meta.test.ts +558 -0
  151. package/src/__tests__/workspace-policy.test.ts +1 -1
  152. package/src/agent/attachments.ts +7 -2
  153. package/src/agent/image-optimize.ts +165 -0
  154. package/src/agent/loop.ts +1 -15
  155. package/src/bundler/app-compiler.ts +179 -2
  156. package/src/bundler/package-resolver.ts +3 -5
  157. package/src/cli/__tests__/notifications.test.ts +1 -2
  158. package/src/cli/cli-memory.ts +67 -64
  159. package/src/cli/commands/avatar.ts +3 -3
  160. package/src/cli/commands/config.ts +26 -13
  161. package/src/cli/commands/doctor.ts +2 -2
  162. package/src/cli/commands/memory.ts +41 -55
  163. package/src/cli/commands/oauth/__tests__/connect.test.ts +2 -2
  164. package/src/cli/commands/oauth/__tests__/disconnect.test.ts +2 -2
  165. package/src/cli/commands/oauth/__tests__/mode.test.ts +8 -1
  166. package/src/cli/commands/oauth/__tests__/status.test.ts +2 -2
  167. package/src/cli/commands/oauth/connect.ts +11 -6
  168. package/src/cli/commands/oauth/mode.ts +7 -0
  169. package/src/cli/commands/oauth/shared.ts +39 -3
  170. package/src/cli/commands/platform/__tests__/connect.test.ts +1 -1
  171. package/src/cli/commands/platform/__tests__/disconnect.test.ts +1 -1
  172. package/src/cli/commands/platform/__tests__/status.test.ts +5 -5
  173. package/src/cli/commands/platform/index.ts +16 -16
  174. package/src/cli/commands/skills.ts +88 -16
  175. package/src/cli/commands/trust.ts +2 -2
  176. package/src/cli/lib/daemon-credential-client.ts +2 -3
  177. package/src/config/bundled-skills/acp/TOOLS.json +1 -1
  178. package/src/config/bundled-skills/contacts/SKILL.md +0 -1
  179. package/src/config/bundled-skills/contacts/TOOLS.json +0 -8
  180. package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +0 -4
  181. package/src/config/bundled-skills/gmail/SKILL.md +2 -10
  182. package/src/config/bundled-skills/google-calendar/SKILL.md +1 -9
  183. package/src/config/bundled-skills/messaging/SKILL.md +10 -18
  184. package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +40 -33
  185. package/src/config/bundled-skills/outlook/SKILL.md +189 -0
  186. package/src/config/bundled-skills/outlook/TOOLS.json +530 -0
  187. package/src/config/bundled-skills/outlook/tools/outlook-attachments.ts +85 -0
  188. package/src/config/bundled-skills/outlook/tools/outlook-categories.ts +77 -0
  189. package/src/config/bundled-skills/outlook/tools/outlook-draft.ts +84 -0
  190. package/src/config/bundled-skills/outlook/tools/outlook-follow-up.ts +94 -0
  191. package/src/config/bundled-skills/outlook/tools/outlook-forward.ts +49 -0
  192. package/src/config/bundled-skills/outlook/tools/outlook-outreach-scan.ts +237 -0
  193. package/src/config/bundled-skills/outlook/tools/outlook-rules.ts +161 -0
  194. package/src/config/bundled-skills/outlook/tools/outlook-send-draft.ts +32 -0
  195. package/src/config/bundled-skills/outlook/tools/outlook-sender-digest.ts +272 -0
  196. package/src/config/bundled-skills/outlook/tools/outlook-trash.ts +29 -0
  197. package/src/config/bundled-skills/outlook/tools/outlook-unsubscribe.ts +129 -0
  198. package/src/config/bundled-skills/outlook/tools/outlook-vacation.ts +87 -0
  199. package/src/config/bundled-skills/outlook/tools/shared.ts +20 -0
  200. package/src/config/bundled-skills/outlook-calendar/SKILL.md +51 -0
  201. package/src/config/bundled-skills/outlook-calendar/TOOLS.json +221 -0
  202. package/src/config/bundled-skills/outlook-calendar/calendar-client.ts +252 -0
  203. package/src/config/bundled-skills/outlook-calendar/tools/outlook-calendar-check-availability.ts +53 -0
  204. package/src/config/bundled-skills/outlook-calendar/tools/outlook-calendar-create-event.ts +74 -0
  205. package/src/config/bundled-skills/outlook-calendar/tools/outlook-calendar-get-event.ts +18 -0
  206. package/src/config/bundled-skills/outlook-calendar/tools/outlook-calendar-list-events.ts +46 -0
  207. package/src/config/bundled-skills/outlook-calendar/tools/outlook-calendar-rsvp.ts +36 -0
  208. package/src/config/bundled-skills/outlook-calendar/tools/shared.ts +17 -0
  209. package/src/config/bundled-skills/outlook-calendar/types.ts +120 -0
  210. package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +47 -40
  211. package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +16 -29
  212. package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +16 -18
  213. package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +39 -47
  214. package/src/config/bundled-skills/slack/SKILL.md +1 -7
  215. package/src/config/bundled-tool-registry.ts +56 -4
  216. package/src/config/env-registry.ts +15 -8
  217. package/src/config/feature-flag-registry.json +21 -124
  218. package/src/config/schemas/platform.ts +8 -0
  219. package/src/config/schemas/timeouts.ts +1 -1
  220. package/src/config/skills.ts +18 -7
  221. package/src/context/token-estimator.ts +25 -18
  222. package/src/context/window-manager.ts +6 -2
  223. package/src/credential-execution/process-manager.ts +3 -1
  224. package/src/daemon/context-overflow-reducer.ts +46 -2
  225. package/src/daemon/conversation-agent-loop-handlers.ts +123 -82
  226. package/src/daemon/conversation-agent-loop.ts +96 -61
  227. package/src/daemon/conversation-error.ts +31 -8
  228. package/src/daemon/conversation-lifecycle.ts +33 -0
  229. package/src/daemon/conversation-media-retry.ts +85 -7
  230. package/src/daemon/conversation-notifiers.ts +4 -1
  231. package/src/daemon/conversation-runtime-assembly.ts +5 -0
  232. package/src/daemon/conversation.ts +41 -2
  233. package/src/daemon/daemon-control.ts +8 -2
  234. package/src/daemon/handlers/shared.ts +22 -12
  235. package/src/daemon/handlers/skills.ts +416 -202
  236. package/src/daemon/lifecycle.ts +40 -1
  237. package/src/daemon/main.ts +5 -1
  238. package/src/daemon/message-types/conversations.ts +4 -1
  239. package/src/daemon/message-types/messages.ts +3 -1
  240. package/src/daemon/message-types/skills.ts +97 -36
  241. package/src/daemon/providers-setup.ts +5 -0
  242. package/src/daemon/server.ts +11 -2
  243. package/src/daemon/tool-side-effects.ts +27 -5
  244. package/src/heartbeat/heartbeat-service.ts +1 -0
  245. package/src/hooks/cli.ts +2 -2
  246. package/src/hooks/runner.ts +15 -38
  247. package/src/inbound/platform-callback-registration.ts +14 -14
  248. package/src/memory/admin.ts +11 -45
  249. package/src/memory/conversation-bootstrap.ts +2 -0
  250. package/src/memory/conversation-crud.ts +242 -348
  251. package/src/memory/conversation-group-migration.ts +157 -0
  252. package/src/memory/conversation-queries.ts +4 -2
  253. package/src/memory/db-init.ts +30 -3
  254. package/src/memory/embed.ts +73 -0
  255. package/src/memory/embedding-backend.ts +8 -14
  256. package/src/memory/embedding-runtime-manager.ts +12 -114
  257. package/src/memory/fingerprint.ts +2 -2
  258. package/src/memory/graph/bootstrap.ts +512 -0
  259. package/src/memory/graph/capability-seed.ts +297 -0
  260. package/src/memory/graph/consolidation.ts +691 -0
  261. package/src/memory/graph/conversation-graph-memory.ts +630 -0
  262. package/src/memory/graph/decay.test.ts +208 -0
  263. package/src/memory/graph/decay.ts +195 -0
  264. package/src/memory/graph/extraction-job.ts +69 -0
  265. package/src/memory/graph/extraction.test.ts +936 -0
  266. package/src/memory/graph/extraction.ts +1254 -0
  267. package/src/memory/graph/graph-search.ts +266 -0
  268. package/src/memory/graph/image-ref-utils.ts +29 -0
  269. package/src/memory/graph/injection.test.ts +513 -0
  270. package/src/memory/graph/injection.ts +439 -0
  271. package/src/memory/graph/inspect.ts +534 -0
  272. package/src/memory/graph/narrative.ts +267 -0
  273. package/src/memory/graph/pattern-scan.ts +269 -0
  274. package/src/memory/graph/retriever.ts +1008 -0
  275. package/src/memory/graph/scoring.test.ts +548 -0
  276. package/src/memory/graph/scoring.ts +232 -0
  277. package/src/memory/graph/serendipity.ts +65 -0
  278. package/src/memory/graph/store.test.ts +1050 -0
  279. package/src/memory/graph/store.ts +699 -0
  280. package/src/memory/graph/tool-handlers.ts +426 -0
  281. package/src/memory/graph/tools.ts +141 -0
  282. package/src/memory/graph/triggers.test.ts +487 -0
  283. package/src/memory/graph/triggers.ts +223 -0
  284. package/src/memory/graph/types.ts +271 -0
  285. package/src/memory/group-crud.ts +191 -0
  286. package/src/memory/indexer.ts +37 -19
  287. package/src/memory/job-handlers/cleanup.ts +0 -53
  288. package/src/memory/job-handlers/conversation-starters.ts +91 -53
  289. package/src/memory/job-handlers/embedding.ts +5 -31
  290. package/src/memory/job-handlers/index-maintenance.ts +23 -11
  291. package/src/memory/job-handlers/summarization.ts +32 -17
  292. package/src/memory/job-utils.ts +1 -1
  293. package/src/memory/jobs-store.ts +50 -70
  294. package/src/memory/jobs-worker.ts +147 -112
  295. package/src/memory/message-content.ts +1 -0
  296. package/src/memory/migrations/202-memory-graph-tables.ts +130 -0
  297. package/src/memory/migrations/203-drop-memory-items-tables.ts +23 -0
  298. package/src/memory/migrations/204-rename-memory-graph-type-values.ts +46 -0
  299. package/src/memory/migrations/205-memory-graph-image-refs.ts +11 -0
  300. package/src/memory/migrations/index.ts +4 -0
  301. package/src/memory/migrations/registry.ts +8 -0
  302. package/src/memory/qdrant-client.ts +44 -17
  303. package/src/memory/schema/index.ts +1 -0
  304. package/src/memory/schema/memory-graph.ts +139 -0
  305. package/src/memory/search/semantic.ts +47 -91
  306. package/src/memory/task-memory-cleanup.ts +28 -50
  307. package/src/messaging/providers/outlook/adapter.ts +8 -1
  308. package/src/messaging/providers/outlook/client.ts +299 -0
  309. package/src/messaging/providers/outlook/types.ts +118 -0
  310. package/src/notifications/adapters/macos.ts +1 -0
  311. package/src/notifications/copy-composer.ts +9 -0
  312. package/src/notifications/signal.ts +16 -0
  313. package/src/oauth/seed-providers.ts +2 -1
  314. package/src/permissions/checker.ts +24 -3
  315. package/src/permissions/defaults.ts +4 -4
  316. package/src/permissions/workspace-policy.ts +1 -1
  317. package/src/playbooks/playbook-compiler.ts +19 -18
  318. package/src/playbooks/types.ts +4 -3
  319. package/src/prompts/system-prompt.ts +3 -29
  320. package/src/providers/anthropic/client.ts +47 -19
  321. package/src/providers/gemini/client.ts +1 -1
  322. package/src/providers/openai/client.ts +1 -1
  323. package/src/providers/registry.ts +1 -1
  324. package/src/providers/retry.ts +19 -3
  325. package/src/runtime/actor-trust-resolver.ts +5 -1
  326. package/src/runtime/auth/route-policy.ts +7 -0
  327. package/src/runtime/guardian-reply-router.ts +5 -1
  328. package/src/runtime/http-server.ts +23 -3
  329. package/src/runtime/middleware/auth.ts +20 -0
  330. package/src/runtime/routes/attachment-routes.test.ts +106 -0
  331. package/src/runtime/routes/attachment-routes.ts +106 -16
  332. package/src/runtime/routes/brain-graph-routes.ts +21 -22
  333. package/src/runtime/routes/btw-routes.ts +8 -0
  334. package/src/runtime/routes/conversation-management-routes.ts +2 -0
  335. package/src/runtime/routes/conversation-starter-routes.ts +2 -2
  336. package/src/runtime/routes/debug-routes.ts +1 -1
  337. package/src/runtime/routes/global-search-routes.ts +21 -19
  338. package/src/runtime/routes/group-routes.ts +207 -0
  339. package/src/runtime/routes/guardian-action-routes.ts +21 -10
  340. package/src/runtime/routes/guardian-bootstrap-routes.ts +23 -19
  341. package/src/runtime/routes/inbound-message-handler.ts +19 -0
  342. package/src/runtime/routes/inbound-stages/guardian-activation-intercept.test.ts +292 -0
  343. package/src/runtime/routes/inbound-stages/guardian-activation-intercept.ts +207 -0
  344. package/src/runtime/routes/memory-item-routes.test.ts +2 -14
  345. package/src/runtime/routes/memory-item-routes.ts +341 -388
  346. package/src/runtime/routes/schedule-routes.ts +2 -0
  347. package/src/runtime/routes/skills-routes.ts +103 -37
  348. package/src/runtime/routes/work-items-routes.test.ts +2 -6
  349. package/src/schedule/scheduler.ts +8 -1
  350. package/src/security/oauth2.ts +1 -1
  351. package/src/security/secure-keys.ts +4 -8
  352. package/src/shared/provider-env-vars.ts +19 -0
  353. package/src/skills/catalog-cache.ts +5 -0
  354. package/src/skills/catalog-install.ts +15 -14
  355. package/src/skills/clawhub.ts +134 -154
  356. package/src/skills/install-meta.ts +208 -0
  357. package/src/skills/managed-store.ts +27 -16
  358. package/src/skills/skill-memory.ts +152 -77
  359. package/src/skills/skillssh-registry.ts +19 -17
  360. package/src/tasks/task-runner.ts +3 -1
  361. package/src/telemetry/usage-telemetry-reporter.test.ts +3 -5
  362. package/src/tools/browser/runtime-check.ts +3 -1
  363. package/src/tools/memory/register.ts +63 -46
  364. package/src/tools/permission-checker.ts +7 -1
  365. package/src/tools/shared/filesystem/image-read.ts +22 -85
  366. package/src/tools/terminal/safe-env.ts +1 -0
  367. package/src/tools/tool-manifest.ts +3 -3
  368. package/src/util/browser.ts +25 -10
  369. package/src/util/bun-runtime.ts +172 -0
  370. package/src/watcher/providers/outlook-calendar.ts +343 -0
  371. package/src/watcher/providers/outlook.ts +198 -0
  372. package/src/workspace/migrations/025-remove-oauth-app-setup-skills.ts +76 -0
  373. package/src/workspace/migrations/026-backfill-install-meta.ts +325 -0
  374. package/src/workspace/migrations/027-remove-orphaned-optimized-images-cache.ts +42 -0
  375. package/src/workspace/migrations/registry.ts +6 -0
  376. package/src/__tests__/context-memory-e2e.test.ts +0 -415
  377. package/src/__tests__/journal-context.test.ts +0 -268
  378. package/src/__tests__/memory-context-benchmark.benchmark.test.ts +0 -297
  379. package/src/__tests__/memory-lifecycle-e2e.test.ts +0 -459
  380. package/src/__tests__/memory-query-builder.test.ts +0 -59
  381. package/src/__tests__/memory-recall-quality.test.ts +0 -1046
  382. package/src/__tests__/memory-regressions.experimental.test.ts +0 -629
  383. package/src/__tests__/memory-regressions.test.ts +0 -3696
  384. package/src/__tests__/memory-retrieval.benchmark.test.ts +0 -295
  385. package/src/daemon/conversation-memory.ts +0 -207
  386. package/src/memory/conversation-starters-cadence.ts +0 -74
  387. package/src/memory/items-extractor.ts +0 -860
  388. package/src/memory/job-handlers/batch-extraction.ts +0 -753
  389. package/src/memory/job-handlers/extraction.ts +0 -40
  390. package/src/memory/job-handlers/journal-carry-forward.test.ts +0 -355
  391. package/src/memory/job-handlers/journal-carry-forward.ts +0 -255
  392. package/src/memory/journal-memory.ts +0 -224
  393. package/src/memory/query-builder.ts +0 -47
  394. package/src/memory/query-expansion.ts +0 -83
  395. package/src/memory/retriever.test.ts +0 -1592
  396. package/src/memory/retriever.ts +0 -1331
  397. package/src/memory/search/formatting.test.ts +0 -140
  398. package/src/memory/search/formatting.ts +0 -262
  399. package/src/memory/search/mmr.ts +0 -139
  400. package/src/memory/search/ranking.ts +0 -15
  401. package/src/memory/search/staleness.ts +0 -40
  402. package/src/memory/search/tier-classifier.ts +0 -18
  403. package/src/memory/search/types.ts +0 -121
  404. package/src/prompts/journal-context.ts +0 -154
  405. package/src/tools/memory/definitions.ts +0 -69
  406. package/src/tools/memory/handlers.test.ts +0 -562
  407. package/src/tools/memory/handlers.ts +0 -434
@@ -1,1331 +0,0 @@
1
- import { and, asc, eq, inArray, notInArray, sql } from "drizzle-orm";
2
-
3
- import type { AssistantConfig } from "../config/types.js";
4
- import { estimateTextTokens } from "../context/token-estimator.js";
5
- import type { Message } from "../providers/types.js";
6
- import { getLogger } from "../util/logger.js";
7
- import {
8
- abortableSleep,
9
- computeRetryDelay,
10
- isRetryableNetworkError,
11
- } from "../util/retry.js";
12
- import { getConversationDirName } from "./conversation-directories.js";
13
- import { getDb } from "./db.js";
14
- import {
15
- embedWithBackend,
16
- generateSparseEmbedding,
17
- getMemoryBackendStatus,
18
- logMemoryEmbeddingWarning,
19
- } from "./embedding-backend.js";
20
- import { isQdrantBreakerOpen } from "./qdrant-circuit-breaker.js";
21
- import { expandQueryWithHyDE } from "./query-expansion.js";
22
- import {
23
- conversations,
24
- memoryItems,
25
- memoryItemSources,
26
- messages,
27
- } from "./schema.js";
28
- import { buildMemoryInjection } from "./search/formatting.js";
29
- import { applyMMR } from "./search/mmr.js";
30
- import { isQdrantConnectionError, semanticSearch } from "./search/semantic.js";
31
- import { computeStaleness } from "./search/staleness.js";
32
- import {
33
- filterByMinScore,
34
- type TieredCandidate,
35
- } from "./search/tier-classifier.js";
36
- import type {
37
- Candidate,
38
- DegradationReason,
39
- DegradationStatus,
40
- MemoryRecallCandiateDebug,
41
- MemoryRecallOptions,
42
- MemoryRecallResult,
43
- ScopePolicyOverride,
44
- } from "./search/types.js";
45
-
46
- // Re-export public types and functions so existing importers continue to work
47
- export {
48
- escapeXmlTags,
49
- formatAbsoluteTime,
50
- formatRelativeTime,
51
- lookupSupersessionChain,
52
- } from "./search/formatting.js";
53
- export type {
54
- DegradationReason,
55
- DegradationStatus,
56
- MemoryRecallCandiateDebug,
57
- MemoryRecallResult,
58
- ScopePolicyOverride,
59
- } from "./search/types.js";
60
-
61
- const log = getLogger("memory-retriever");
62
-
63
- const EMBED_MAX_RETRIES = 3;
64
- const EMBED_BASE_DELAY_MS = 500;
65
-
66
- /** MMR diversity penalty applied to near-duplicate items after score filtering.
67
- * 0 = no penalty, 1 = maximum penalty. */
68
- const MMR_PENALTY = 0.6;
69
-
70
- /**
71
- * Wrap embedWithBackend with retry + exponential backoff for transient failures
72
- * (network errors, 429s, 5xx). Aborts immediately if the caller's signal fires.
73
- */
74
- export async function embedWithRetry(
75
- config: AssistantConfig,
76
- texts: string[],
77
- opts?: { signal?: AbortSignal },
78
- ): ReturnType<typeof embedWithBackend> {
79
- let lastError: unknown;
80
- for (let attempt = 0; attempt <= EMBED_MAX_RETRIES; attempt++) {
81
- try {
82
- return await embedWithBackend(config, texts, opts);
83
- } catch (err) {
84
- lastError = err;
85
- if (opts?.signal?.aborted || isAbortError(err)) throw err;
86
- const isTransient =
87
- isRetryableNetworkError(err) || isHttpStatusError(err);
88
- if (!isTransient || attempt === EMBED_MAX_RETRIES) throw err;
89
- const delay = computeRetryDelay(attempt, EMBED_BASE_DELAY_MS);
90
- log.warn(
91
- { err, attempt: attempt + 1, delayMs: Math.round(delay) },
92
- "Transient embedding failure, retrying",
93
- );
94
- await abortableSleep(delay, opts?.signal);
95
- if (opts?.signal?.aborted) throw err;
96
- }
97
- }
98
- throw lastError;
99
- }
100
-
101
- /**
102
- * Build the list of scope IDs to include in queries.
103
- * - If a `scopePolicyOverride` is provided, it takes precedence over both
104
- * `scopeId` and `scopePolicy` — the override's `scopeId` is used as the
105
- * primary scope and `fallbackToDefault` controls whether 'default' is
106
- * included.
107
- * - If no scopeId is provided, returns undefined (no filtering).
108
- * - If scopePolicy is 'allow_global_fallback', includes both the
109
- * requested scope and the 'default' scope.
110
- * - If scopePolicy is 'strict', only includes the requested scope.
111
- */
112
- function buildScopeFilter(
113
- scopeId: string | undefined,
114
- scopePolicy: string,
115
- scopePolicyOverride?: ScopePolicyOverride,
116
- ): string[] | undefined {
117
- // Per-call override takes precedence over global config
118
- if (scopePolicyOverride) {
119
- const primary = scopePolicyOverride.scopeId;
120
- if (scopePolicyOverride.fallbackToDefault && primary !== "default") {
121
- return [primary, "default"];
122
- }
123
- return [primary];
124
- }
125
-
126
- if (!scopeId) return undefined;
127
- if (scopePolicy === "allow_global_fallback") {
128
- return scopeId === "default" ? ["default"] : [scopeId, "default"];
129
- }
130
- return [scopeId];
131
- }
132
-
133
- /**
134
- * Build a structured degradation status describing which retrieval
135
- * capabilities are unavailable and what fallback sources remain.
136
- */
137
- function buildDegradationStatus(
138
- reason: DegradationReason,
139
- _config: AssistantConfig,
140
- ): DegradationStatus {
141
- return {
142
- semanticUnavailable: true,
143
- reason,
144
- fallbackSources: [],
145
- };
146
- }
147
-
148
- /** Result of the embedding generation stage. */
149
- interface EmbeddingResult {
150
- queryVector: number[] | null;
151
- provider: string | undefined;
152
- model: string | undefined;
153
- degraded: boolean;
154
- degradation: DegradationStatus | undefined;
155
- reason: string | undefined;
156
- }
157
-
158
- /**
159
- * Generate an embedding vector for the query. Handles backend availability
160
- * checks, retry with backoff, and graceful degradation when embeddings are
161
- * optional.
162
- *
163
- * Returns `null` when the caller should return an early-exit `emptyResult`
164
- * (the empty result is included). Otherwise returns the embedding state.
165
- */
166
- async function generateQueryEmbedding(
167
- query: string,
168
- config: AssistantConfig,
169
- signal: AbortSignal | undefined,
170
- start: number,
171
- ): Promise<EmbeddingResult | { earlyExit: MemoryRecallResult }> {
172
- const backendStatus = await getMemoryBackendStatus(config);
173
- let queryVector: number[] | null = null;
174
- let provider: string | undefined;
175
- let model: string | undefined;
176
- let degraded = backendStatus.degraded;
177
- let degradation: DegradationStatus | undefined;
178
- let reason = backendStatus.reason ?? undefined;
179
-
180
- if (backendStatus.provider) {
181
- try {
182
- const embedded = await embedWithRetry(config, [query], { signal });
183
- queryVector = embedded.vectors[0] ?? null;
184
- provider = embedded.provider;
185
- model = embedded.model;
186
- degraded = false;
187
- reason = undefined;
188
- } catch (err) {
189
- if (signal?.aborted || isAbortError(err)) {
190
- return {
191
- earlyExit: emptyResult({
192
- enabled: true,
193
- degraded: false,
194
- reason: "memory.aborted",
195
- provider: backendStatus.provider,
196
- model: backendStatus.model ?? undefined,
197
- latencyMs: Date.now() - start,
198
- }),
199
- };
200
- }
201
- logMemoryEmbeddingWarning(err, "query");
202
- degraded = true;
203
- reason = `memory.embedding_failure: ${
204
- err instanceof Error ? err.message : String(err)
205
- }`;
206
- degradation = buildDegradationStatus(
207
- "embedding_generation_failed",
208
- config,
209
- );
210
- if (config.memory.embeddings.required) {
211
- return {
212
- earlyExit: emptyResult({
213
- enabled: true,
214
- degraded,
215
- degradation,
216
- reason,
217
- provider: backendStatus.provider,
218
- model: backendStatus.model ?? undefined,
219
- latencyMs: Date.now() - start,
220
- }),
221
- };
222
- }
223
- }
224
- } else if (config.memory.embeddings.required) {
225
- degradation = buildDegradationStatus("embedding_provider_down", config);
226
- return {
227
- earlyExit: emptyResult({
228
- enabled: true,
229
- degraded: true,
230
- degradation,
231
- reason: reason ?? "memory.embedding_backend_missing",
232
- latencyMs: Date.now() - start,
233
- }),
234
- };
235
- }
236
-
237
- return { queryVector, provider, model, degraded, degradation, reason };
238
- }
239
-
240
- /** Result from HyDE-expanded search. */
241
- interface HyDESearchResult {
242
- candidates: Candidate[];
243
- hydeExpanded: boolean;
244
- hydeDocCount: number;
245
- /** Whether any HyDE doc produced a sparse vector with non-empty indices */
246
- hydeSparseUsed: boolean;
247
- }
248
-
249
- /**
250
- * Run HyDE-expanded search: generate hypothetical documents, embed them
251
- * alongside the raw query in parallel, run parallel semantic searches,
252
- * and merge all candidate arrays.
253
- *
254
- * Falls back to raw-query-only search on any HyDE failure (expansion
255
- * error, embedding error for hypothetical docs). The raw query search
256
- * always runs regardless of HyDE success.
257
- */
258
- async function runHyDESearch(
259
- query: string,
260
- rawQueryVector: number[],
261
- config: AssistantConfig,
262
- signal: AbortSignal | undefined,
263
- provider: string,
264
- model: string,
265
- limit: number,
266
- excludeMessageIds: string[],
267
- scopeIds: string[] | undefined,
268
- sparseVector: { indices: number[]; values: number[] } | undefined,
269
- ): Promise<HyDESearchResult> {
270
- // Always search with the raw query — this is our baseline
271
- const rawSearchPromise = semanticSearch(
272
- rawQueryVector,
273
- provider,
274
- model,
275
- limit,
276
- excludeMessageIds,
277
- scopeIds,
278
- sparseVector,
279
- );
280
- // Suppress unhandled rejection if Qdrant rejects before we await
281
- rawSearchPromise.catch(() => {});
282
-
283
- // Attempt HyDE expansion — returns [] on any failure
284
- let hypotheticalDocs: string[];
285
- try {
286
- hypotheticalDocs = await expandQueryWithHyDE(query, config, signal);
287
- } catch (err) {
288
- if (isAbortError(err)) throw err;
289
- // expandQueryWithHyDE already catches internally, but be defensive
290
- hypotheticalDocs = [];
291
- }
292
-
293
- if (hypotheticalDocs.length === 0) {
294
- // No hypothetical docs — fall back to raw query only
295
- const rawResults = await rawSearchPromise;
296
- return {
297
- candidates: rawResults,
298
- hydeExpanded: false,
299
- hydeDocCount: 0,
300
- hydeSparseUsed: false,
301
- };
302
- }
303
-
304
- log.debug(
305
- { hydeDocCount: hypotheticalDocs.length },
306
- "HyDE expansion produced hypothetical documents",
307
- );
308
-
309
- // Embed all hypothetical docs in parallel with the raw search
310
- let hydeVectors: number[][] = [];
311
- try {
312
- const hydeEmbedResult = await embedWithRetry(config, hypotheticalDocs, {
313
- signal,
314
- });
315
- hydeVectors = hydeEmbedResult.vectors;
316
- } catch (err) {
317
- log.warn(
318
- { err: err instanceof Error ? err.message : String(err) },
319
- "Failed to embed HyDE hypothetical docs; falling back to raw query",
320
- );
321
- const rawResults = await rawSearchPromise;
322
- return {
323
- candidates: rawResults,
324
- hydeExpanded: false,
325
- hydeDocCount: 0,
326
- hydeSparseUsed: false,
327
- };
328
- }
329
-
330
- // Run parallel semantic searches for each hypothetical doc embedding,
331
- // generating per-doc sparse embeddings so sparse and dense components match.
332
- let hydeSparseUsed = false;
333
- const hydeSearchPromises = hydeVectors.map((vector, i) => {
334
- const docSparseVector = generateSparseEmbedding(hypotheticalDocs[i]!);
335
- if (docSparseVector.indices.length > 0) hydeSparseUsed = true;
336
- return semanticSearch(
337
- vector,
338
- provider,
339
- model,
340
- limit,
341
- excludeMessageIds,
342
- scopeIds,
343
- docSparseVector,
344
- ).catch((err) => {
345
- log.warn(
346
- { err: err instanceof Error ? err.message : String(err) },
347
- "HyDE hypothetical doc search failed; skipping",
348
- );
349
- return [] as Candidate[];
350
- });
351
- });
352
-
353
- // Await all searches in parallel (raw + hypothetical)
354
- const [rawResults, ...hydeResults] = await Promise.all([
355
- rawSearchPromise,
356
- ...hydeSearchPromises,
357
- ]);
358
-
359
- // Merge all candidate arrays into a single flat array
360
- const allCandidates = [rawResults, ...hydeResults].flat();
361
-
362
- return {
363
- candidates: allCandidates,
364
- hydeExpanded: true,
365
- hydeDocCount: hypotheticalDocs.length,
366
- hydeSparseUsed,
367
- };
368
- }
369
-
370
- /**
371
- * Memory recall pipeline: hybrid search → score filtering →
372
- * staleness annotation → unified XML injection.
373
- *
374
- * Pipeline steps:
375
- * 1. Build query text (caller provides via buildMemoryQuery)
376
- * 2. Generate dense + sparse embeddings
377
- * 3. Hybrid search on Qdrant (dense + sparse RRF fusion)
378
- * 4. Deduplicate results
379
- * 5. Filter by minimum score threshold
380
- * 6. Enrich candidates with source labels and item metadata
381
- * 7. Compute staleness per item (for debugging/logging)
382
- * 8. Build unified XML injection with budget allocation
383
- */
384
- export async function buildMemoryRecall(
385
- query: string,
386
- conversationId: string,
387
- config: AssistantConfig,
388
- options?: MemoryRecallOptions,
389
- ): Promise<MemoryRecallResult> {
390
- const start = Date.now();
391
- const excludeMessageIds =
392
- options?.excludeMessageIds?.filter((id) => id.length > 0) ?? [];
393
- const signal = options?.signal;
394
-
395
- if (!config.memory.enabled) {
396
- return emptyResult({
397
- enabled: false,
398
- degraded: false,
399
- reason: "memory.disabled",
400
- latencyMs: Date.now() - start,
401
- });
402
- }
403
- if (signal?.aborted) {
404
- return emptyResult({
405
- enabled: true,
406
- degraded: false,
407
- reason: "memory.aborted",
408
- latencyMs: Date.now() - start,
409
- });
410
- }
411
-
412
- // ── Step 1+2: Generate dense and sparse embeddings ──────────────
413
- const embeddingResult = await generateQueryEmbedding(
414
- query,
415
- config,
416
- signal,
417
- start,
418
- );
419
- if ("earlyExit" in embeddingResult) return embeddingResult.earlyExit;
420
-
421
- const { queryVector, provider, model } = embeddingResult;
422
-
423
- // Generate sparse embedding for the query text (TF-IDF based)
424
- const sparseVector = generateSparseEmbedding(query);
425
- const sparseVectorAvailable = sparseVector.indices.length > 0;
426
-
427
- // ── Step 3: Hybrid search on Qdrant ─────────────────────────────
428
- const scopePolicy = config.memory.retrieval.scopePolicy;
429
- const scopeIds = buildScopeFilter(
430
- options?.scopeId,
431
- scopePolicy,
432
- options?.scopePolicyOverride,
433
- );
434
-
435
- const HYBRID_LIMIT = 40;
436
-
437
- let hybridCandidates: Candidate[] = [];
438
- let semanticSearchFailed = false;
439
- let sparseVectorUsed = false;
440
- let hydeExpanded = false;
441
- let hydeDocCount = 0;
442
- const hybridSearchStart = Date.now();
443
-
444
- const qdrantBreakerOpen = isQdrantBreakerOpen();
445
- if (queryVector && !qdrantBreakerOpen) {
446
- try {
447
- if (options?.hydeEnabled) {
448
- // ── HyDE path: expand query into hypothetical docs and search in parallel ──
449
- const hydeCandidates = await runHyDESearch(
450
- query,
451
- queryVector,
452
- config,
453
- signal,
454
- provider ?? "unknown",
455
- model ?? "unknown",
456
- HYBRID_LIMIT,
457
- excludeMessageIds,
458
- scopeIds,
459
- sparseVectorAvailable ? sparseVector : undefined,
460
- );
461
- hybridCandidates = hydeCandidates.candidates;
462
- hydeExpanded = hydeCandidates.hydeExpanded;
463
- hydeDocCount = hydeCandidates.hydeDocCount;
464
- sparseVectorUsed = sparseVectorAvailable || hydeCandidates.hydeSparseUsed;
465
- } else {
466
- // ── Standard path: single raw query search ──
467
- hybridCandidates = await semanticSearch(
468
- queryVector,
469
- provider ?? "unknown",
470
- model ?? "unknown",
471
- HYBRID_LIMIT,
472
- excludeMessageIds,
473
- scopeIds,
474
- sparseVectorAvailable ? sparseVector : undefined,
475
- );
476
- sparseVectorUsed = sparseVectorAvailable;
477
- }
478
- } catch (err) {
479
- semanticSearchFailed = true;
480
- if (isQdrantConnectionError(err)) {
481
- log.warn({ err }, "Qdrant unavailable — hybrid search disabled");
482
- } else {
483
- log.warn({ err }, "Hybrid search failed");
484
- }
485
- }
486
- }
487
- const hybridSearchMs = Date.now() - hybridSearchStart;
488
-
489
- // ── Step 4: Deduplicate ────────────────────────────────────────
490
- const candidateMap = new Map<string, Candidate>();
491
- for (const c of [...hybridCandidates]) {
492
- const existing = candidateMap.get(c.key);
493
- if (!existing) {
494
- candidateMap.set(c.key, { ...c });
495
- continue;
496
- }
497
- // Keep highest scores from each source
498
- existing.semantic = Math.max(existing.semantic, c.semantic);
499
- existing.recency = Math.max(existing.recency, c.recency);
500
- existing.confidence = Math.max(existing.confidence, c.confidence);
501
- existing.importance = Math.max(existing.importance, c.importance);
502
- if (c.text.length > existing.text.length) {
503
- existing.text = c.text;
504
- }
505
- // Propagate metadata that the first source may lack (e.g. legacy
506
- // Qdrant points missing conversation_id / message_id).
507
- if (c.conversationId && !existing.conversationId) {
508
- existing.conversationId = c.conversationId;
509
- }
510
- if (c.messageId && !existing.messageId) {
511
- existing.messageId = c.messageId;
512
- }
513
- }
514
-
515
- // ── Step 4b: Filter out current-conversation segments still in context ──
516
- // Segments whose source message is still in the conversation's context
517
- // window are redundant (already visible to the model). However, segments
518
- // from messages that were removed by context compaction should be kept —
519
- // those messages are no longer in the conversation history and memory is
520
- // the only way they can influence the response.
521
- let inContextMessageIds: Set<string> | null = null;
522
- if (conversationId) {
523
- inContextMessageIds = getEffectiveInContextMessageIds(conversationId);
524
- if (inContextMessageIds) {
525
- for (const [key, c] of candidateMap) {
526
- if (c.type === "segment") {
527
- if (c.messageId) {
528
- // Segment has a known source message — filter only if that
529
- // message is still in the context window.
530
- if (inContextMessageIds.has(c.messageId)) {
531
- candidateMap.delete(key);
532
- }
533
- } else if (c.conversationId === conversationId) {
534
- // Segment from the current conversation but missing messageId
535
- // (e.g. legacy Qdrant points without message_id payload).
536
- // We can't determine whether it's compacted, so err on the
537
- // side of filtering to avoid token bloat from redundant segments.
538
- candidateMap.delete(key);
539
- }
540
- }
541
- }
542
-
543
- // ── Item filtering: exclude items whose ALL sources are in-context ──
544
- // Items distilled from messages the model can already see are redundant.
545
- // However, items with ANY source outside the in-context set carry
546
- // cross-conversation information and must be preserved.
547
- const itemCandidateIds = [...candidateMap.values()]
548
- .filter((c) => c.type === "item")
549
- .map((c) => c.id);
550
-
551
- if (itemCandidateIds.length > 0) {
552
- try {
553
- const db = getDb();
554
- const allSources = db
555
- .select({
556
- memoryItemId: memoryItemSources.memoryItemId,
557
- messageId: memoryItemSources.messageId,
558
- })
559
- .from(memoryItemSources)
560
- .where(inArray(memoryItemSources.memoryItemId, itemCandidateIds))
561
- .all();
562
-
563
- // Build item ID → source message IDs map
564
- const itemSourceMap = new Map<string, string[]>();
565
- for (const s of allSources) {
566
- const existing = itemSourceMap.get(s.memoryItemId);
567
- if (existing) existing.push(s.messageId);
568
- else itemSourceMap.set(s.memoryItemId, [s.messageId]);
569
- }
570
-
571
- // Filter items whose ALL sources are in-context
572
- const contextIds = inContextMessageIds;
573
- for (const [key, c] of candidateMap) {
574
- if (c.type !== "item") continue;
575
- const sourceMessageIds = itemSourceMap.get(c.id);
576
- if (!sourceMessageIds || sourceMessageIds.length === 0) continue;
577
- if (sourceMessageIds.every((mid) => contextIds.has(mid))) {
578
- candidateMap.delete(key);
579
- }
580
- }
581
- } catch (err) {
582
- log.warn(
583
- { err },
584
- "Failed to fetch item sources for in-context filtering; skipping",
585
- );
586
- }
587
- }
588
- }
589
- }
590
-
591
- // Compute RRF-style final scores for the merged candidates
592
- const allCandidates = [...candidateMap.values()];
593
- for (const c of allCandidates) {
594
- // Multiplicative scoring: importance, confidence, and recency amplify semantic
595
- // relevance but can't substitute for it. An irrelevant item (semantic ≈ 0)
596
- // stays low regardless of metadata. Multiplier range: 0.35 (all zero) to 1.0.
597
- const metadataMultiplier =
598
- 0.35 + c.importance * 0.3 + c.confidence * 0.1 + c.recency * 0.25;
599
- c.finalScore = c.semantic * metadataMultiplier;
600
- }
601
- allCandidates.sort((a, b) => b.finalScore - a.finalScore);
602
-
603
- // ── Step 5: Filter by minimum score threshold ───────────────────
604
- const filtered = filterByMinScore(allCandidates);
605
-
606
- // ── Step 5b: MMR diversity ranking ─────────────────────────────
607
- const mmrRanked = applyMMR(filtered, MMR_PENALTY);
608
-
609
- // MMR rewrites finalScore, so re-enforce the min-score threshold to
610
- // drop candidates whose adjusted score fell below the cutoff.
611
- const diversified = filterByMinScore(mmrRanked);
612
-
613
- // ── Step 5c: Enrich candidates with source labels ──────────────
614
- enrichSourceLabels(diversified);
615
-
616
- // ── Serendipity: sample random memories for unexpected connections ──
617
- const SERENDIPITY_COUNT = 3;
618
- const serendipityCandidates = sampleSerendipityItems(
619
- diversified,
620
- SERENDIPITY_COUNT,
621
- scopeIds,
622
- );
623
-
624
- // Filter serendipity items whose ALL sources are in-context (same logic
625
- // as Step 4b) to prevent current-turn content leaking via random sampling.
626
- if (inContextMessageIds && serendipityCandidates.length > 0) {
627
- filterInContextItems(serendipityCandidates, inContextMessageIds);
628
- }
629
-
630
- enrichSourceLabels(serendipityCandidates);
631
-
632
- // ── Step 6: Enrich with item metadata for staleness ─────────────
633
- const itemIds = diversified.filter((c) => c.type === "item").map((c) => c.id);
634
- const itemMetadataMap = enrichItemMetadata(itemIds);
635
-
636
- // ── Step 6b: Enrich item candidates with supersedes data ────────
637
- const itemCandidatesForSupersedes = diversified.filter(
638
- (c) => c.type === "item",
639
- );
640
- if (itemCandidatesForSupersedes.length > 0) {
641
- try {
642
- const db = getDb();
643
- const supersedesRows = db
644
- .select({ id: memoryItems.id, supersedes: memoryItems.supersedes })
645
- .from(memoryItems)
646
- .where(
647
- inArray(
648
- memoryItems.id,
649
- itemCandidatesForSupersedes.map((c) => c.id),
650
- ),
651
- )
652
- .all();
653
- const supersedesMap = new Map(
654
- supersedesRows.map((r) => [r.id, r.supersedes]),
655
- );
656
- for (const c of itemCandidatesForSupersedes) {
657
- const sup = supersedesMap.get(c.id);
658
- if (sup) c.supersedes = sup;
659
- }
660
- } catch (err) {
661
- log.warn({ err }, "Failed to enrich candidates with supersedes data");
662
- }
663
- }
664
-
665
- // ── Step 7: Compute staleness per item (for debugging/logging) ─
666
- const now = Date.now();
667
- for (const c of diversified) {
668
- if (c.type !== "item") continue;
669
- const meta = itemMetadataMap.get(c.id);
670
- if (!meta) continue;
671
- const { level } = computeStaleness(
672
- {
673
- kind: c.kind,
674
- firstSeenAt: meta.firstSeenAt,
675
- sourceConversationCount: meta.sourceConversationCount,
676
- },
677
- now,
678
- );
679
- c.staleness = level;
680
- }
681
-
682
- // ── Step 8: Budget allocation and unified injection ────────────
683
- const maxInjectTokens = Math.max(
684
- 1,
685
- Math.floor(
686
- options?.maxInjectTokensOverride ??
687
- config.memory.retrieval.maxInjectTokens,
688
- ),
689
- );
690
-
691
- const injectedText = buildMemoryInjection({
692
- candidates: diversified,
693
- serendipityItems: serendipityCandidates,
694
- totalBudgetTokens: maxInjectTokens,
695
- });
696
-
697
- // ── Assemble result ─────────────────────────────────────────────
698
- const selectedCount = diversified.length + serendipityCandidates.length;
699
-
700
- const stalenessStats = {
701
- fresh: diversified.filter((c) => c.staleness === "fresh").length,
702
- aging: diversified.filter((c) => c.staleness === "aging").length,
703
- stale: diversified.filter((c) => c.staleness === "stale").length,
704
- very_stale: diversified.filter((c) => c.staleness === "very_stale").length,
705
- };
706
-
707
- const topCandidates: MemoryRecallCandiateDebug[] = [...diversified]
708
- .sort((a, b) => b.finalScore - a.finalScore)
709
- .slice(0, 10)
710
- .map((c) => ({
711
- key: c.key,
712
- type: c.type,
713
- kind: c.kind,
714
- finalScore: c.finalScore,
715
- semantic: c.semantic,
716
- recency: c.recency,
717
- ...(c.sourceLabel ? { sourceLabel: c.sourceLabel } : {}),
718
- }));
719
-
720
- const latencyMs = Date.now() - start;
721
-
722
- // Propagate degradation from semantic search failure or breaker-open skip
723
- if (
724
- semanticSearchFailed ||
725
- qdrantBreakerOpen ||
726
- (!queryVector && config.memory.embeddings.required)
727
- ) {
728
- embeddingResult.degraded = true;
729
- embeddingResult.reason =
730
- embeddingResult.reason ??
731
- (qdrantBreakerOpen
732
- ? "memory.qdrant_breaker_open"
733
- : "memory.hybrid_search_failure");
734
- }
735
-
736
- log.debug(
737
- {
738
- query: truncate(query, 120),
739
- hybridHits: hybridCandidates.length,
740
- mergedCount: allCandidates.length,
741
- stalenessStats,
742
- selectedCount,
743
- maxInjectTokens,
744
- injectedTokens: estimateTextTokens(injectedText),
745
- latencyMs,
746
- ...(hydeExpanded ? { hydeExpanded, hydeDocCount } : {}),
747
- },
748
- "Memory recall completed",
749
- );
750
-
751
- const result: MemoryRecallResult = {
752
- enabled: true,
753
- degraded: embeddingResult.degraded,
754
- degradation: embeddingResult.degradation,
755
- reason: embeddingResult.reason,
756
- provider: embeddingResult.provider,
757
- model: embeddingResult.model,
758
- semanticHits: hybridCandidates.length,
759
- mergedCount: allCandidates.length,
760
- selectedCount,
761
- injectedTokens: estimateTextTokens(injectedText),
762
- injectedText,
763
- latencyMs,
764
- topCandidates,
765
- tier1Count: 0,
766
- tier2Count: 0,
767
- hybridSearchMs,
768
- sparseVectorUsed,
769
- hydeExpanded,
770
- hydeDocCount,
771
- mmrApplied: true,
772
- };
773
-
774
- return result;
775
- }
776
-
777
- /**
778
- * Get the set of message IDs that are effectively in the conversation's
779
- * context window. This includes:
780
- * 1. Messages still visible (not compacted) in the conversation history.
781
- * 2. Fork-source message IDs — when a conversation is forked, messages are
782
- * copied with new IDs but their metadata stores the original parent
783
- * message ID as `forkSourceMessageId`. Segments sourced from those parent
784
- * messages are redundant because the fork already contains their content.
785
- *
786
- * Uses `contextCompactedMessageCount` to determine the compaction offset:
787
- * messages ordered by createdAt after that count are still visible to the model.
788
- *
789
- * Returns `null` if the conversation is not found (deleted, or no DB row).
790
- */
791
- function getEffectiveInContextMessageIds(
792
- conversationId: string,
793
- ): Set<string> | null {
794
- try {
795
- const db = getDb();
796
-
797
- // Look up the conversation's compacted message count
798
- const conv = db
799
- .select({
800
- contextCompactedMessageCount:
801
- conversations.contextCompactedMessageCount,
802
- })
803
- .from(conversations)
804
- .where(eq(conversations.id, conversationId))
805
- .get();
806
-
807
- if (!conv) return null;
808
-
809
- const offset = conv.contextCompactedMessageCount;
810
-
811
- // Fetch message IDs and metadata ordered by creation time
812
- const rows = db
813
- .select({ id: messages.id, metadata: messages.metadata })
814
- .from(messages)
815
- .where(eq(messages.conversationId, conversationId))
816
- .orderBy(asc(messages.createdAt))
817
- .all();
818
-
819
- // Messages up to `offset` have been compacted out of context
820
- const inContextRows = rows.slice(offset);
821
- const idSet = new Set(inContextRows.map((r) => r.id));
822
-
823
- // Also include fork-source message IDs from in-context messages.
824
- // When a conversation is forked, each copied message's metadata contains
825
- // `forkSourceMessageId` pointing to the original (parent or grandparent)
826
- // message ID. Segments sourced from those original messages are redundant.
827
- for (const row of inContextRows) {
828
- if (!row.metadata) continue;
829
- try {
830
- const parsed = JSON.parse(row.metadata);
831
- if (
832
- parsed &&
833
- typeof parsed === "object" &&
834
- !Array.isArray(parsed) &&
835
- typeof parsed.forkSourceMessageId === "string"
836
- ) {
837
- idSet.add(parsed.forkSourceMessageId);
838
- }
839
- } catch {
840
- // Invalid metadata JSON — skip, don't break filtering.
841
- }
842
- }
843
-
844
- return idSet;
845
- } catch (err) {
846
- log.warn(
847
- { err },
848
- "Failed to fetch in-context message IDs; skipping segment filter",
849
- );
850
- return null;
851
- }
852
- }
853
-
854
- /**
855
- * Enrich item candidates with metadata needed for staleness computation:
856
- * - firstSeenAt: when the item was first extracted
857
- * - sourceConversationCount: number of distinct conversations that sourced this item
858
- */
859
- function enrichItemMetadata(
860
- itemIds: string[],
861
- ): Map<
862
- string,
863
- { firstSeenAt: number; sourceConversationCount: number; kind: string }
864
- > {
865
- const result = new Map<
866
- string,
867
- { firstSeenAt: number; sourceConversationCount: number; kind: string }
868
- >();
869
- if (itemIds.length === 0) return result;
870
-
871
- try {
872
- const db = getDb();
873
-
874
- // Fetch firstSeenAt and kind from memory_items
875
- const items = db
876
- .select({
877
- id: memoryItems.id,
878
- firstSeenAt: memoryItems.firstSeenAt,
879
- kind: memoryItems.kind,
880
- })
881
- .from(memoryItems)
882
- .where(inArray(memoryItems.id, itemIds))
883
- .all();
884
-
885
- for (const item of items) {
886
- result.set(item.id, {
887
- firstSeenAt: item.firstSeenAt,
888
- kind: item.kind,
889
- sourceConversationCount: 1, // default, updated below
890
- });
891
- }
892
-
893
- // Compute sourceConversationCount: count distinct conversation IDs
894
- // across the memory_item_sources → messages join.
895
- const sourceCountRows = db
896
- .select({
897
- memoryItemId: memoryItemSources.memoryItemId,
898
- conversationCount:
899
- sql<number>`COUNT(DISTINCT ${messages.conversationId})`.as(
900
- "conversation_count",
901
- ),
902
- })
903
- .from(memoryItemSources)
904
- .innerJoin(messages, sql`${memoryItemSources.messageId} = ${messages.id}`)
905
- .where(inArray(memoryItemSources.memoryItemId, itemIds))
906
- .groupBy(memoryItemSources.memoryItemId)
907
- .all();
908
-
909
- for (const row of sourceCountRows) {
910
- const existing = result.get(row.memoryItemId);
911
- if (existing) {
912
- existing.sourceConversationCount = row.conversationCount;
913
- }
914
- }
915
- } catch (err) {
916
- log.warn(
917
- { err },
918
- "Failed to enrich item metadata for staleness computation",
919
- );
920
- }
921
-
922
- return result;
923
- }
924
-
925
- /**
926
- * Enrich tiered candidates with source labels (conversation titles).
927
- *
928
- * For "item" candidates: joins through memoryItemSources → messages → conversations
929
- * to find the most recent conversation title associated with the item.
930
- * For "segment" / "summary" candidates: looks up the conversation title directly
931
- * via the candidate's key (which contains the conversationId for segments).
932
- *
933
- * Mutates the candidates in-place for efficiency.
934
- */
935
- function enrichSourceLabels(candidates: TieredCandidate[]): void {
936
- if (candidates.length === 0) return;
937
-
938
- try {
939
- const db = getDb();
940
-
941
- // ── Items: find conversation via memoryItemSources → messages → conversations ──
942
- const itemCandidates = candidates.filter((c) => c.type === "item");
943
- const itemIds = itemCandidates.map((c) => c.id);
944
-
945
- if (itemIds.length > 0) {
946
- const rows = db
947
- .select({
948
- memoryItemId: memoryItemSources.memoryItemId,
949
- conversationId: conversations.id,
950
- title: conversations.title,
951
- conversationCreatedAt: conversations.createdAt,
952
- conversationUpdatedAt: conversations.updatedAt,
953
- })
954
- .from(memoryItemSources)
955
- .innerJoin(
956
- messages,
957
- sql`${memoryItemSources.messageId} = ${messages.id}`,
958
- )
959
- .innerJoin(
960
- conversations,
961
- sql`${messages.conversationId} = ${conversations.id}`,
962
- )
963
- .where(inArray(memoryItemSources.memoryItemId, itemIds))
964
- .all();
965
-
966
- // Group by item ID and pick the most recently updated conversation
967
- const bestConvMap = new Map<
968
- string,
969
- {
970
- title: string | null;
971
- conversationId: string;
972
- createdAt: number;
973
- updatedAt: number;
974
- }
975
- >();
976
- for (const row of rows) {
977
- const existing = bestConvMap.get(row.memoryItemId);
978
- if (
979
- existing === undefined ||
980
- row.conversationUpdatedAt > existing.updatedAt
981
- ) {
982
- bestConvMap.set(row.memoryItemId, {
983
- title: row.title,
984
- conversationId: row.conversationId,
985
- createdAt: row.conversationCreatedAt,
986
- updatedAt: row.conversationUpdatedAt,
987
- });
988
- }
989
- }
990
-
991
- for (const c of itemCandidates) {
992
- const conv = bestConvMap.get(c.id);
993
- if (conv) {
994
- if (conv.title) c.sourceLabel = conv.title;
995
- const dirName = getConversationDirName(
996
- conv.conversationId,
997
- conv.createdAt,
998
- );
999
- c.sourcePath = `conversations/${dirName}/messages.jsonl`;
1000
- }
1001
- }
1002
- }
1003
-
1004
- // ── Segments: look up conversation via conversationId on the candidate ──
1005
- const segmentCandidates = candidates.filter(
1006
- (c) => (c.type === "segment" || c.type === "summary") && c.conversationId,
1007
- );
1008
-
1009
- if (segmentCandidates.length > 0) {
1010
- const convIds = [
1011
- ...new Set(segmentCandidates.map((c) => c.conversationId!)),
1012
- ];
1013
- const convRows = db
1014
- .select({
1015
- id: conversations.id,
1016
- title: conversations.title,
1017
- createdAt: conversations.createdAt,
1018
- })
1019
- .from(conversations)
1020
- .where(inArray(conversations.id, convIds))
1021
- .all();
1022
-
1023
- const convMap = new Map(convRows.map((r) => [r.id, r]));
1024
-
1025
- for (const c of segmentCandidates) {
1026
- const conv = convMap.get(c.conversationId!);
1027
- if (conv) {
1028
- if (conv.title) c.sourceLabel = conv.title;
1029
- const dirName = getConversationDirName(conv.id, conv.createdAt);
1030
- c.sourcePath = `conversations/${dirName}/messages.jsonl`;
1031
- }
1032
- }
1033
- }
1034
- } catch (err) {
1035
- log.warn({ err }, "Failed to enrich candidates with source labels");
1036
- }
1037
- }
1038
-
1039
- /**
1040
- * Remove items from the array (in-place) whose ALL source messages are
1041
- * in the given in-context set. This prevents current-turn content from
1042
- * leaking into the injection via serendipity or other DB-sourced paths.
1043
- */
1044
- function filterInContextItems(
1045
- candidates: TieredCandidate[],
1046
- inContextMessageIds: Set<string>,
1047
- ): void {
1048
- const itemIds = candidates.filter((c) => c.type === "item").map((c) => c.id);
1049
- if (itemIds.length === 0) return;
1050
-
1051
- try {
1052
- const db = getDb();
1053
- const allSources = db
1054
- .select({
1055
- memoryItemId: memoryItemSources.memoryItemId,
1056
- messageId: memoryItemSources.messageId,
1057
- })
1058
- .from(memoryItemSources)
1059
- .where(inArray(memoryItemSources.memoryItemId, itemIds))
1060
- .all();
1061
-
1062
- const itemSourceMap = new Map<string, string[]>();
1063
- for (const s of allSources) {
1064
- const existing = itemSourceMap.get(s.memoryItemId);
1065
- if (existing) existing.push(s.messageId);
1066
- else itemSourceMap.set(s.memoryItemId, [s.messageId]);
1067
- }
1068
-
1069
- for (let i = candidates.length - 1; i >= 0; i--) {
1070
- const c = candidates[i];
1071
- if (c.type !== "item") continue;
1072
- const sourceMessageIds = itemSourceMap.get(c.id);
1073
- if (!sourceMessageIds || sourceMessageIds.length === 0) continue;
1074
- if (sourceMessageIds.every((mid) => inContextMessageIds.has(mid))) {
1075
- candidates.splice(i, 1);
1076
- }
1077
- }
1078
- } catch (err) {
1079
- log.warn(
1080
- { err },
1081
- "Failed to filter in-context serendipity items; skipping",
1082
- );
1083
- }
1084
- }
1085
-
1086
- /**
1087
- * Sample random active memory items for serendipitous recall — items
1088
- * the user didn't ask about but might spark unexpected connections.
1089
- *
1090
- * Queries SQLite for random active items not already in the candidate pool,
1091
- * then selects up to `count` items with probability proportional to their
1092
- * importance value (importance-weighted sampling).
1093
- *
1094
- * Items with importance >= MIN_SERENDIPITY_IMPORTANCE are eligible, as are
1095
- * legacy items with NULL importance (not yet backfilled). This ensures
1096
- * genuinely significant memories and pre-importance-era items can both
1097
- * surface as echoes.
1098
- */
1099
- const MIN_SERENDIPITY_IMPORTANCE = 0.7;
1100
-
1101
- function sampleSerendipityItems(
1102
- existingCandidates: TieredCandidate[],
1103
- count: number,
1104
- scopeIds?: string[],
1105
- ): TieredCandidate[] {
1106
- if (count <= 0) return [];
1107
-
1108
- try {
1109
- const db = getDb();
1110
-
1111
- // Collect IDs of item candidates already in the filtered set to exclude them
1112
- const existingItemIds = existingCandidates
1113
- .filter((c) => c.type === "item")
1114
- .map((c) => c.id);
1115
-
1116
- const RANDOM_POOL_SIZE = 10;
1117
-
1118
- // Build scope condition: match allowed scopes, or default to 'default'
1119
- // when no scope filter is set (prevents leaking private-scope items)
1120
- const scopeCondition = scopeIds
1121
- ? inArray(memoryItems.scopeId, scopeIds)
1122
- : eq(memoryItems.scopeId, "default");
1123
-
1124
- const importanceFloor = sql`(${memoryItems.importance} >= ${MIN_SERENDIPITY_IMPORTANCE} OR ${memoryItems.importance} IS NULL)`;
1125
-
1126
- const baseConditions =
1127
- existingItemIds.length > 0
1128
- ? and(
1129
- eq(memoryItems.status, "active"),
1130
- scopeCondition,
1131
- importanceFloor,
1132
- notInArray(memoryItems.id, existingItemIds),
1133
- )
1134
- : and(
1135
- eq(memoryItems.status, "active"),
1136
- scopeCondition,
1137
- importanceFloor,
1138
- );
1139
-
1140
- // Use rowid-probe sampling instead of ORDER BY RANDOM() to avoid a
1141
- // full-table sort whose cost grows linearly with memory_items size.
1142
- // Strategy: get the rowid range, generate random rowids, and probe for
1143
- // the nearest eligible row with `rowid >= ?`. Each probe is O(log n)
1144
- // via B-tree lookup, so total cost is O(k·log n) instead of O(n·log n).
1145
- const range = db
1146
- .select({
1147
- minRowid: sql<number>`MIN(rowid)`,
1148
- maxRowid: sql<number>`MAX(rowid)`,
1149
- total: sql<number>`COUNT(*)`,
1150
- })
1151
- .from(memoryItems)
1152
- .where(baseConditions)
1153
- .get();
1154
-
1155
- if (!range || range.total === 0) return [];
1156
-
1157
- const columns = {
1158
- id: memoryItems.id,
1159
- kind: memoryItems.kind,
1160
- subject: memoryItems.subject,
1161
- statement: memoryItems.statement,
1162
- importance: memoryItems.importance,
1163
- firstSeenAt: memoryItems.firstSeenAt,
1164
- };
1165
-
1166
- let rows;
1167
- if (range.total <= RANDOM_POOL_SIZE) {
1168
- // Few enough eligible rows — fetch all, no randomness needed at DB level
1169
- rows = db
1170
- .select(columns)
1171
- .from(memoryItems)
1172
- .where(baseConditions)
1173
- .all();
1174
- } else {
1175
- // Probe random rowids in the eligible range
1176
- const seen = new Set<string>();
1177
- rows = [];
1178
- const rowidSpan = range.maxRowid - range.minRowid + 1;
1179
- const maxAttempts = RANDOM_POOL_SIZE * 5;
1180
- for (let i = 0; i < maxAttempts && rows.length < RANDOM_POOL_SIZE; i++) {
1181
- const randomRowid =
1182
- range.minRowid + Math.floor(Math.random() * rowidSpan);
1183
- const row = db
1184
- .select(columns)
1185
- .from(memoryItems)
1186
- .where(and(baseConditions, sql`rowid >= ${randomRowid}`))
1187
- .orderBy(sql`rowid`)
1188
- .limit(1)
1189
- .get();
1190
- if (row && !seen.has(row.id)) {
1191
- seen.add(row.id);
1192
- rows.push(row);
1193
- }
1194
- }
1195
- }
1196
-
1197
- if (rows.length === 0) return [];
1198
-
1199
- // Importance-weighted sampling: sort by importance * random() descending
1200
- // and take the top `count` items
1201
- const weighted = rows
1202
- .map((row) => ({
1203
- row,
1204
- score: (row.importance ?? 0.5) * Math.random(),
1205
- }))
1206
- .sort((a, b) => b.score - a.score)
1207
- .slice(0, count);
1208
-
1209
- // Convert to Candidate-compatible objects
1210
- return weighted.map(
1211
- ({ row }): TieredCandidate => ({
1212
- type: "item",
1213
- id: row.id,
1214
- key: `item:${row.id}`,
1215
- kind: row.kind,
1216
- text: row.statement,
1217
- source: "semantic",
1218
- importance: row.importance ?? 0.5,
1219
- confidence: 1,
1220
- semantic: 0,
1221
- recency: 0,
1222
- finalScore: 0,
1223
- createdAt: row.firstSeenAt,
1224
- }),
1225
- );
1226
- } catch (err) {
1227
- log.warn({ err }, "Failed to sample serendipity items");
1228
- return [];
1229
- }
1230
- }
1231
-
1232
- /**
1233
- * Inject memory recall as a text content block prepended to the last user
1234
- * message. This follows the same pattern as workspace, temporal, and other
1235
- * runtime injections — the memory context is a text block in the user
1236
- * message rather than a separate synthetic message pair.
1237
- *
1238
- * Stripping is handled by `stripUserTextBlocksByPrefix` matching the
1239
- * `<memory_context __injected>` prefix in `RUNTIME_INJECTION_PREFIXES`, so no
1240
- * dedicated strip function is needed.
1241
- */
1242
- export function injectMemoryRecallAsUserBlock(
1243
- messages: Message[],
1244
- memoryRecallText: string,
1245
- ): Message[] {
1246
- if (memoryRecallText.trim().length === 0) return messages;
1247
- if (messages.length === 0) return messages;
1248
- const userTail = messages[messages.length - 1];
1249
- if (!userTail || userTail.role !== "user") return messages;
1250
- return [
1251
- ...messages.slice(0, -1),
1252
- {
1253
- ...userTail,
1254
- content: [
1255
- { type: "text" as const, text: memoryRecallText },
1256
- ...userTail.content,
1257
- ],
1258
- },
1259
- ];
1260
- }
1261
-
1262
- export function queryMemoryForCli(
1263
- query: string,
1264
- conversationId: string,
1265
- config: AssistantConfig,
1266
- ): Promise<MemoryRecallResult> {
1267
- return buildMemoryRecall(query, conversationId, config);
1268
- }
1269
-
1270
- function emptyResult(
1271
- init: Partial<MemoryRecallResult> &
1272
- Pick<MemoryRecallResult, "enabled" | "degraded" | "latencyMs">,
1273
- ): MemoryRecallResult {
1274
- return {
1275
- enabled: init.enabled,
1276
- degraded: init.degraded,
1277
- degradation: init.degradation,
1278
- reason: init.reason,
1279
- provider: init.provider,
1280
- model: init.model,
1281
- semanticHits: 0,
1282
- mergedCount: 0,
1283
- selectedCount: 0,
1284
- injectedTokens: 0,
1285
- injectedText: "",
1286
- latencyMs: init.latencyMs,
1287
- topCandidates: [],
1288
- };
1289
- }
1290
-
1291
- function truncate(text: string, max: number): string {
1292
- if (text.length <= max) return text;
1293
- return `${text.slice(0, max - 3)}...`;
1294
- }
1295
-
1296
- function isAbortError(err: unknown): boolean {
1297
- if (!(err instanceof Error)) return false;
1298
- return err.name === "AbortError" || err.name === "APIUserAbortError";
1299
- }
1300
-
1301
- /**
1302
- * Check if an error represents a retryable HTTP status (429 or 5xx).
1303
- * Checks the error's `status` or `statusCode` property first (set by most
1304
- * HTTP/API clients), then falls back to looking for "status <code>" patterns
1305
- * in the message. This avoids false positives from dimension numbers like 512.
1306
- */
1307
- function getErrorStatusCode(err: Error): unknown {
1308
- if ("status" in err) {
1309
- const status = (err as { status: unknown }).status;
1310
- if (status != null) return status;
1311
- }
1312
- if ("statusCode" in err) return (err as { statusCode: unknown }).statusCode;
1313
- return undefined;
1314
- }
1315
-
1316
- function isHttpStatusError(err: unknown): boolean {
1317
- if (!(err instanceof Error)) return false;
1318
- const status = getErrorStatusCode(err);
1319
- if (typeof status === "number") {
1320
- return status === 429 || (status >= 500 && status < 600);
1321
- }
1322
- // Fall back to message matching, but only for patterns that clearly
1323
- // indicate an HTTP status code rather than arbitrary numbers.
1324
- // Matches: "status 503", "HTTP 500", "status code: 502", parenthesized
1325
- // codes like "failed (503)" from Gemini/Ollama (requires "failed" or
1326
- // "error" context to avoid false positives from dimension numbers like
1327
- // 512), and bare "429" (rate-limit).
1328
- return /\b429\b|(?:failed|error)\s*\((?:429|5\d{2})\)|(?:status|http)\s*(?:code\s*)?:?\s*5\d{2}\b/i.test(
1329
- err.message,
1330
- );
1331
- }