@vellumai/assistant 0.5.16 → 0.6.1

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 (592) hide show
  1. package/AGENTS.md +4 -0
  2. package/ARCHITECTURE.md +69 -16
  3. package/Dockerfile +2 -5
  4. package/bun.lock +6 -2
  5. package/docker-entrypoint.sh +32 -1
  6. package/docs/architecture/integrations.md +1 -1
  7. package/docs/architecture/memory.md +21 -24
  8. package/knip.json +2 -1
  9. package/openapi.yaml +1198 -83
  10. package/package.json +5 -1
  11. package/src/__tests__/actor-token-service.test.ts +68 -0
  12. package/src/__tests__/agent-loop.test.ts +0 -32
  13. package/src/__tests__/always-loaded-tools-guard.test.ts +2 -2
  14. package/src/__tests__/anthropic-provider.test.ts +217 -98
  15. package/src/__tests__/app-compiler.test.ts +120 -0
  16. package/src/__tests__/app-dir-path-guard.test.ts +1 -0
  17. package/src/__tests__/app-executors.test.ts +47 -1
  18. package/src/__tests__/app-source-watcher.test.ts +159 -0
  19. package/src/__tests__/assistant-feature-flags-integration.test.ts +2 -2
  20. package/src/__tests__/call-conversation-messages.test.ts +2 -6
  21. package/src/__tests__/call-domain.test.ts +2 -6
  22. package/src/__tests__/call-pointer-messages.test.ts +2 -14
  23. package/src/__tests__/call-recovery.test.ts +2 -6
  24. package/src/__tests__/call-routes-http.test.ts +2 -6
  25. package/src/__tests__/call-store.test.ts +2 -6
  26. package/src/__tests__/cancel-resolves-conversation-key.test.ts +2 -6
  27. package/src/__tests__/canonical-guardian-store.test.ts +2 -6
  28. package/src/__tests__/channel-delivery-store.test.ts +2 -6
  29. package/src/__tests__/channel-retry-sweep.test.ts +2 -6
  30. package/src/__tests__/checker.test.ts +63 -9
  31. package/src/__tests__/clawhub.test.ts +54 -24
  32. package/src/__tests__/cli-command-risk-guard.test.ts +14 -0
  33. package/src/__tests__/config-schema.test.ts +6 -1
  34. package/src/__tests__/config-set-platform-guard.test.ts +302 -0
  35. package/src/__tests__/confirmation-request-guardian-bridge.test.ts +2 -6
  36. package/src/__tests__/contacts-tools.test.ts +31 -0
  37. package/src/__tests__/context-overflow-reducer.test.ts +86 -0
  38. package/src/__tests__/context-token-estimator.test.ts +175 -10
  39. package/src/__tests__/conversation-agent-loop-overflow.test.ts +13 -6
  40. package/src/__tests__/conversation-agent-loop.test.ts +13 -51
  41. package/src/__tests__/conversation-attachments.test.ts +2 -6
  42. package/src/__tests__/conversation-attention-store.test.ts +2 -6
  43. package/src/__tests__/conversation-clear-safety.test.ts +2 -6
  44. package/src/__tests__/conversation-delete-schedule-cleanup.test.ts +4 -10
  45. package/src/__tests__/conversation-disk-view-integration.test.ts +2 -6
  46. package/src/__tests__/conversation-disk-view.test.ts +2 -6
  47. package/src/__tests__/conversation-error.test.ts +33 -2
  48. package/src/__tests__/conversation-fork-crud.test.ts +2 -6
  49. package/src/__tests__/conversation-history-web-search.test.ts +6 -1
  50. package/src/__tests__/conversation-load-history-repair.test.ts +5 -1
  51. package/src/__tests__/conversation-media-retry.test.ts +91 -0
  52. package/src/__tests__/conversation-runtime-assembly.test.ts +653 -832
  53. package/src/__tests__/conversation-runtime-workspace.test.ts +1 -93
  54. package/src/__tests__/conversation-starter-routes.test.ts +20 -11
  55. package/src/__tests__/conversation-store.test.ts +2 -6
  56. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +17 -4
  57. package/src/__tests__/conversation-usage.test.ts +2 -6
  58. package/src/__tests__/conversation-wipe.test.ts +13 -414
  59. package/src/__tests__/conversation-workspace-cache-state.test.ts +6 -12
  60. package/src/__tests__/conversation-workspace-injection.test.ts +25 -26
  61. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +1 -1
  62. package/src/__tests__/copy-composer-tc-templates.test.ts +335 -0
  63. package/src/__tests__/credential-execution-feature-gates.test.ts +3 -3
  64. package/src/__tests__/credential-execution-shell-lockdown.test.ts +2 -2
  65. package/src/__tests__/credential-security-e2e.test.ts +2 -0
  66. package/src/__tests__/date-context.test.ts +76 -210
  67. package/src/__tests__/db-schedule-syntax-migration.test.ts +16 -1
  68. package/src/__tests__/file-list-tool.test.ts +219 -0
  69. package/src/__tests__/first-greeting.test.ts +1 -1
  70. package/src/__tests__/followup-tools.test.ts +2 -6
  71. package/src/__tests__/graph-extraction-event-date.test.ts +186 -0
  72. package/src/__tests__/guardian-action-conversation-turn.test.ts +2 -6
  73. package/src/__tests__/guardian-action-followup-executor.test.ts +2 -6
  74. package/src/__tests__/guardian-action-followup-store.test.ts +2 -6
  75. package/src/__tests__/guardian-action-grant-mint-consume.test.ts +2 -6
  76. package/src/__tests__/guardian-action-late-reply.test.ts +2 -6
  77. package/src/__tests__/guardian-action-store.test.ts +2 -6
  78. package/src/__tests__/guardian-binding-drift-heal.test.ts +2 -6
  79. package/src/__tests__/guardian-decision-primitive-canonical.test.ts +8 -8
  80. package/src/__tests__/guardian-dispatch.test.ts +2 -6
  81. package/src/__tests__/guardian-grant-minting.test.ts +2 -14
  82. package/src/__tests__/guardian-principal-id-roundtrip.test.ts +2 -6
  83. package/src/__tests__/guardian-routing-invariants.test.ts +192 -6
  84. package/src/__tests__/guardian-routing-state.test.ts +2 -6
  85. package/src/__tests__/guardian-verification-voice-binding.test.ts +2 -6
  86. package/src/__tests__/heartbeat-service.test.ts +180 -3
  87. package/src/__tests__/identity-routes.test.ts +328 -0
  88. package/src/__tests__/inbound-invite-redemption.test.ts +2 -6
  89. package/src/__tests__/injection-block.test.ts +178 -0
  90. package/src/__tests__/install-meta.test.ts +506 -0
  91. package/src/__tests__/install-skill-routing.test.ts +293 -0
  92. package/src/__tests__/invite-redemption-service.test.ts +2 -6
  93. package/src/__tests__/invite-routes-http.test.ts +2 -6
  94. package/src/__tests__/jobs-store-qdrant-breaker.test.ts +17 -28
  95. package/src/__tests__/list-messages-attachments.test.ts +2 -6
  96. package/src/__tests__/list-messages-tool-merge.test.ts +300 -0
  97. package/src/__tests__/llm-context-normalization.test.ts +18 -18
  98. package/src/__tests__/llm-context-route-provider.test.ts +103 -6
  99. package/src/__tests__/llm-request-log-turn-query.test.ts +164 -6
  100. package/src/__tests__/llm-usage-store.test.ts +2 -6
  101. package/src/__tests__/log-export-workspace.test.ts +74 -111
  102. package/src/__tests__/managed-store.test.ts +38 -11
  103. package/src/__tests__/mcp-abort-signal.test.ts +5 -0
  104. package/src/__tests__/mcp-client-auth.test.ts +5 -0
  105. package/src/__tests__/memory-jobs-worker-backoff.test.ts +2 -8
  106. package/src/__tests__/memory-recall-log-store.test.ts +134 -6
  107. package/src/__tests__/memory-upsert-concurrency.test.ts +4 -112
  108. package/src/__tests__/migration-export-streaming.test.ts +304 -0
  109. package/src/__tests__/migration-import-commit-http.test.ts +11 -10
  110. package/src/__tests__/mock-fetch.ts +87 -0
  111. package/src/__tests__/non-member-access-request.test.ts +2 -6
  112. package/src/__tests__/notification-decision-recipient-context.test.ts +282 -0
  113. package/src/__tests__/notification-guardian-path.test.ts +2 -6
  114. package/src/__tests__/oauth-cli.test.ts +364 -2
  115. package/src/__tests__/oauth2-gateway-transport.test.ts +18 -3
  116. package/src/__tests__/onboarding-template-contract.test.ts +62 -14
  117. package/src/__tests__/outlook-attachments.test.ts +301 -0
  118. package/src/__tests__/outlook-automation-tools.test.ts +425 -0
  119. package/src/__tests__/outlook-categories.test.ts +212 -0
  120. package/src/__tests__/outlook-client-automation.test.ts +246 -0
  121. package/src/__tests__/outlook-compose-tools.test.ts +325 -0
  122. package/src/__tests__/outlook-declutter-tools.test.ts +585 -0
  123. package/src/__tests__/outlook-email-watcher.test.ts +322 -0
  124. package/src/__tests__/outlook-follow-up.test.ts +196 -0
  125. package/src/__tests__/outlook-messaging-provider.test.ts +498 -3
  126. package/src/__tests__/outlook-trash.test.ts +77 -0
  127. package/src/__tests__/outlook-unsubscribe.test.ts +250 -0
  128. package/src/__tests__/parser.test.ts +32 -0
  129. package/src/__tests__/permission-checker-host-gate.test.ts +452 -0
  130. package/src/__tests__/permission-controls-v2-flag.test.ts +55 -0
  131. package/src/__tests__/permission-mode-sse.test.ts +418 -0
  132. package/src/__tests__/permission-mode-store.test.ts +277 -0
  133. package/src/__tests__/permission-mode.test.ts +101 -0
  134. package/src/__tests__/platform-bash-auto-approve.test.ts +359 -0
  135. package/src/__tests__/platform-callback-registration.test.ts +4 -4
  136. package/src/__tests__/playbook-execution.test.ts +76 -80
  137. package/src/__tests__/playbook-tools.test.ts +5 -7
  138. package/src/__tests__/profiler-routes.test.ts +502 -0
  139. package/src/__tests__/profiler-run-store.test.ts +441 -0
  140. package/src/__tests__/provider-error-scenarios.test.ts +21 -0
  141. package/src/__tests__/proxy-approval-callback.test.ts +4 -75
  142. package/src/__tests__/rebuild-index-graph-nodes.test.ts +273 -0
  143. package/src/__tests__/registry.test.ts +3 -3
  144. package/src/__tests__/require-fresh-approval.test.ts +64 -2
  145. package/src/__tests__/runtime-events-sse-parity.test.ts +2 -6
  146. package/src/__tests__/runtime-events-sse.test.ts +2 -6
  147. package/src/__tests__/sandbox-host-parity.test.ts +5 -4
  148. package/src/__tests__/schedule-store.test.ts +2 -6
  149. package/src/__tests__/schedule-tools.test.ts +2 -6
  150. package/src/__tests__/scheduler-recurrence.test.ts +1 -5
  151. package/src/__tests__/scheduler-reuse-conversation.test.ts +368 -0
  152. package/src/__tests__/scoped-approval-grants.test.ts +2 -6
  153. package/src/__tests__/scoped-grant-security-matrix.test.ts +2 -6
  154. package/src/__tests__/scrub-corrupted-image-attachments.test.ts +278 -0
  155. package/src/__tests__/search-skills-unified.test.ts +422 -0
  156. package/src/__tests__/secret-onetime-send.test.ts +2 -0
  157. package/src/__tests__/send-endpoint-busy.test.ts +44 -9
  158. package/src/__tests__/sequence-store.test.ts +2 -6
  159. package/src/__tests__/server-history-render.test.ts +2 -6
  160. package/src/__tests__/set-permission-mode.test.ts +274 -0
  161. package/src/__tests__/skill-feature-flags-integration.test.ts +38 -31
  162. package/src/__tests__/skill-feature-flags.test.ts +6 -6
  163. package/src/__tests__/skill-load-feature-flag.test.ts +23 -11
  164. package/src/__tests__/skill-memory.test.ts +2 -741
  165. package/src/__tests__/skills-uninstall.test.ts +2 -2
  166. package/src/__tests__/skills.test.ts +1 -1
  167. package/src/__tests__/slack-inbound-verification.test.ts +2 -6
  168. package/src/__tests__/strip-memory-injections.test.ts +187 -0
  169. package/src/__tests__/subagent-detail.test.ts +84 -0
  170. package/src/__tests__/subagent-disposal.test.ts +308 -0
  171. package/src/__tests__/subagent-manager-notify.test.ts +19 -10
  172. package/src/__tests__/subagent-notify-parent.test.ts +390 -0
  173. package/src/__tests__/subagent-role-registry.test.ts +108 -0
  174. package/src/__tests__/subagent-tool-filtering.test.ts +71 -0
  175. package/src/__tests__/subagent-tools.test.ts +464 -4
  176. package/src/__tests__/system-prompt-ask-mode.test.ts +139 -0
  177. package/src/__tests__/task-compiler.test.ts +2 -6
  178. package/src/__tests__/task-management-tools.test.ts +2 -6
  179. package/src/__tests__/task-memory-cleanup.test.ts +185 -241
  180. package/src/__tests__/task-runner.test.ts +2 -6
  181. package/src/__tests__/task-scheduler.test.ts +2 -6
  182. package/src/__tests__/terminal-tools.test.ts +17 -27
  183. package/src/__tests__/test-preload.ts +7 -0
  184. package/src/__tests__/tool-approval-handler.test.ts +2 -6
  185. package/src/__tests__/tool-executor.test.ts +4 -26
  186. package/src/__tests__/tool-grant-request-escalation.test.ts +2 -6
  187. package/src/__tests__/tool-side-effects-slack-dm.test.ts +277 -0
  188. package/src/__tests__/top-level-renderer.test.ts +10 -13
  189. package/src/__tests__/trust-store.test.ts +1 -1
  190. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +2 -6
  191. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +118 -8
  192. package/src/__tests__/trusted-contact-multichannel.test.ts +2 -6
  193. package/src/__tests__/trusted-contact-verification.test.ts +2 -6
  194. package/src/__tests__/turn-boundary-resolution.test.ts +2 -6
  195. package/src/__tests__/usage-cache-backfill-migration.test.ts +1 -6
  196. package/src/__tests__/usage-routes.test.ts +2 -6
  197. package/src/__tests__/verification-control-plane-policy.test.ts +0 -2
  198. package/src/__tests__/voice-invite-redemption.test.ts +2 -6
  199. package/src/__tests__/voice-scoped-grant-consumer.test.ts +2 -6
  200. package/src/__tests__/voice-session-bridge.test.ts +2 -6
  201. package/src/__tests__/volume-security-guard.test.ts +2 -0
  202. package/src/__tests__/workspace-lifecycle.test.ts +29 -1
  203. package/src/__tests__/workspace-migration-009-backfill-conversation-disk-view.test.ts +2 -6
  204. package/src/__tests__/workspace-migration-013-repair-conversation-disk-view.test.ts +2 -6
  205. package/src/__tests__/workspace-migration-026-backfill-install-meta.test.ts +558 -0
  206. package/src/__tests__/workspace-migration-028-recover-conversations-from-disk-view.test.ts +387 -0
  207. package/src/__tests__/workspace-policy.test.ts +1 -1
  208. package/src/agent/attachments.ts +7 -2
  209. package/src/agent/image-optimize.ts +165 -0
  210. package/src/agent/loop.ts +7 -15
  211. package/src/approvals/guardian-request-resolvers.ts +24 -0
  212. package/src/avatar/traits-png-sync.ts +3 -3
  213. package/src/bundler/app-compiler.ts +179 -2
  214. package/src/bundler/package-resolver.ts +3 -5
  215. package/src/cli/__tests__/notifications.test.ts +1 -2
  216. package/src/cli/__tests__/run-assistant-command.ts +29 -0
  217. package/src/cli/commands/__tests__/email-download.test.ts +245 -0
  218. package/src/cli/commands/__tests__/email-list.test.ts +192 -0
  219. package/src/cli/commands/__tests__/email-register.test.ts +186 -0
  220. package/src/cli/commands/__tests__/email-send.test.ts +291 -0
  221. package/src/cli/commands/__tests__/email-status.test.ts +181 -0
  222. package/src/cli/commands/__tests__/email-unregister.test.ts +139 -0
  223. package/src/cli/commands/__tests__/routes.test.ts +562 -0
  224. package/src/cli/commands/avatar.ts +3 -3
  225. package/src/cli/commands/config.ts +26 -13
  226. package/src/cli/commands/conversations.ts +1 -8
  227. package/src/cli/commands/doctor.ts +2 -2
  228. package/src/cli/commands/email.ts +584 -835
  229. package/src/cli/commands/memory.ts +37 -84
  230. package/src/cli/commands/notifications.ts +7 -2
  231. package/src/cli/commands/oauth/__tests__/connect.test.ts +2 -2
  232. package/src/cli/commands/oauth/__tests__/disconnect.test.ts +2 -2
  233. package/src/cli/commands/oauth/__tests__/mode.test.ts +8 -1
  234. package/src/cli/commands/oauth/__tests__/status.test.ts +2 -2
  235. package/src/cli/commands/oauth/connect.ts +25 -11
  236. package/src/cli/commands/oauth/mode.ts +7 -0
  237. package/src/cli/commands/oauth/shared.ts +39 -3
  238. package/src/cli/commands/platform/__tests__/connect.test.ts +1 -1
  239. package/src/cli/commands/platform/__tests__/disconnect.test.ts +1 -1
  240. package/src/cli/commands/platform/__tests__/status.test.ts +5 -5
  241. package/src/cli/commands/platform/index.ts +16 -16
  242. package/src/cli/commands/routes.ts +396 -0
  243. package/src/cli/commands/skills.ts +218 -36
  244. package/src/cli/commands/trust.ts +2 -2
  245. package/src/cli/lib/daemon-credential-client.ts +2 -3
  246. package/src/cli/program.ts +2 -0
  247. package/src/cli.ts +1 -120
  248. package/src/config/bundled-skills/acp/TOOLS.json +1 -1
  249. package/src/config/bundled-skills/app-builder/SKILL.md +4 -1
  250. package/src/config/bundled-skills/contacts/SKILL.md +0 -1
  251. package/src/config/bundled-skills/contacts/TOOLS.json +0 -8
  252. package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +0 -4
  253. package/src/config/bundled-skills/gmail/SKILL.md +4 -12
  254. package/src/config/bundled-skills/google-calendar/SKILL.md +1 -9
  255. package/src/config/bundled-skills/messaging/SKILL.md +17 -18
  256. package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +40 -33
  257. package/src/config/bundled-skills/outlook/SKILL.md +189 -0
  258. package/src/config/bundled-skills/outlook/TOOLS.json +530 -0
  259. package/src/config/bundled-skills/outlook/tools/outlook-attachments.ts +85 -0
  260. package/src/config/bundled-skills/outlook/tools/outlook-categories.ts +77 -0
  261. package/src/config/bundled-skills/outlook/tools/outlook-draft.ts +84 -0
  262. package/src/config/bundled-skills/outlook/tools/outlook-follow-up.ts +94 -0
  263. package/src/config/bundled-skills/outlook/tools/outlook-forward.ts +49 -0
  264. package/src/config/bundled-skills/outlook/tools/outlook-outreach-scan.ts +237 -0
  265. package/src/config/bundled-skills/outlook/tools/outlook-rules.ts +161 -0
  266. package/src/config/bundled-skills/outlook/tools/outlook-send-draft.ts +32 -0
  267. package/src/config/bundled-skills/outlook/tools/outlook-sender-digest.ts +272 -0
  268. package/src/config/bundled-skills/outlook/tools/outlook-trash.ts +29 -0
  269. package/src/config/bundled-skills/outlook/tools/outlook-unsubscribe.ts +129 -0
  270. package/src/config/bundled-skills/outlook/tools/outlook-vacation.ts +87 -0
  271. package/src/config/bundled-skills/outlook/tools/shared.ts +20 -0
  272. package/src/config/bundled-skills/outlook-calendar/SKILL.md +51 -0
  273. package/src/config/bundled-skills/outlook-calendar/TOOLS.json +221 -0
  274. package/src/config/bundled-skills/outlook-calendar/calendar-client.ts +252 -0
  275. package/src/config/bundled-skills/outlook-calendar/tools/outlook-calendar-check-availability.ts +53 -0
  276. package/src/config/bundled-skills/outlook-calendar/tools/outlook-calendar-create-event.ts +74 -0
  277. package/src/config/bundled-skills/outlook-calendar/tools/outlook-calendar-get-event.ts +18 -0
  278. package/src/config/bundled-skills/outlook-calendar/tools/outlook-calendar-list-events.ts +46 -0
  279. package/src/config/bundled-skills/outlook-calendar/tools/outlook-calendar-rsvp.ts +36 -0
  280. package/src/config/bundled-skills/outlook-calendar/tools/shared.ts +17 -0
  281. package/src/config/bundled-skills/outlook-calendar/types.ts +120 -0
  282. package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +47 -40
  283. package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +16 -29
  284. package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +16 -18
  285. package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +39 -47
  286. package/src/config/bundled-skills/schedule/SKILL.md +22 -2
  287. package/src/config/bundled-skills/schedule/TOOLS.json +8 -0
  288. package/src/config/bundled-skills/settings/tools/avatar-get.ts +3 -13
  289. package/src/config/bundled-skills/settings/tools/avatar-remove.ts +2 -4
  290. package/src/config/bundled-skills/settings/tools/avatar-update.ts +5 -2
  291. package/src/config/bundled-skills/slack/SKILL.md +3 -7
  292. package/src/config/bundled-skills/subagent/SKILL.md +43 -3
  293. package/src/config/bundled-skills/subagent/TOOLS.json +29 -4
  294. package/src/config/bundled-tool-registry.ts +56 -4
  295. package/src/config/env-registry.ts +78 -8
  296. package/src/config/feature-flag-registry.json +38 -125
  297. package/src/config/schema.ts +8 -0
  298. package/src/config/schemas/filing.ts +51 -0
  299. package/src/config/schemas/heartbeat.ts +15 -12
  300. package/src/config/schemas/memory-lifecycle.ts +12 -0
  301. package/src/config/schemas/platform.ts +8 -0
  302. package/src/config/schemas/security.ts +14 -0
  303. package/src/config/schemas/timeouts.ts +1 -1
  304. package/src/config/skills.ts +18 -7
  305. package/src/context/token-estimator.ts +25 -18
  306. package/src/context/window-manager.ts +6 -2
  307. package/src/credential-execution/process-manager.ts +3 -1
  308. package/src/daemon/app-source-watcher.ts +93 -0
  309. package/src/daemon/config-watcher.ts +79 -1
  310. package/src/daemon/context-overflow-reducer.ts +46 -2
  311. package/src/daemon/conversation-agent-loop-handlers.ts +143 -82
  312. package/src/daemon/conversation-agent-loop.ts +236 -108
  313. package/src/daemon/conversation-error.ts +31 -8
  314. package/src/daemon/conversation-history.ts +4 -19
  315. package/src/daemon/conversation-lifecycle.ts +36 -9
  316. package/src/daemon/conversation-media-retry.ts +85 -7
  317. package/src/daemon/conversation-notifiers.ts +4 -1
  318. package/src/daemon/conversation-process.ts +13 -7
  319. package/src/daemon/conversation-runtime-assembly.ts +305 -306
  320. package/src/daemon/conversation-tool-setup.ts +44 -14
  321. package/src/daemon/conversation-workspace.ts +1 -2
  322. package/src/daemon/conversation.ts +59 -2
  323. package/src/daemon/daemon-control.ts +8 -2
  324. package/src/daemon/date-context.ts +26 -53
  325. package/src/daemon/first-greeting.ts +1 -1
  326. package/src/daemon/handlers/conversations.ts +4 -7
  327. package/src/daemon/handlers/shared.test.ts +143 -0
  328. package/src/daemon/handlers/shared.ts +85 -17
  329. package/src/daemon/handlers/skills.ts +416 -209
  330. package/src/daemon/lifecycle.ts +212 -131
  331. package/src/daemon/main.ts +5 -1
  332. package/src/daemon/message-types/conversations.ts +29 -7
  333. package/src/daemon/message-types/messages.ts +12 -2
  334. package/src/daemon/message-types/schedules.ts +1 -0
  335. package/src/daemon/message-types/settings.ts +6 -0
  336. package/src/daemon/message-types/skills.ts +97 -36
  337. package/src/daemon/profiler-run-store.ts +557 -0
  338. package/src/daemon/providers-setup.ts +5 -0
  339. package/src/daemon/server.ts +100 -11
  340. package/src/daemon/shutdown-handlers.ts +5 -0
  341. package/src/daemon/tool-side-effects.ts +50 -8
  342. package/src/export/transcript-formatter.ts +148 -0
  343. package/src/filing/filing-service.ts +228 -0
  344. package/src/heartbeat/heartbeat-service.ts +97 -7
  345. package/src/hooks/cli.ts +2 -2
  346. package/src/hooks/runner.ts +15 -38
  347. package/src/inbound/platform-callback-registration.ts +14 -14
  348. package/src/mcp/client.ts +6 -0
  349. package/src/mcp/mcp-oauth-provider.ts +149 -27
  350. package/src/memory/admin.ts +42 -75
  351. package/src/memory/app-store.ts +69 -0
  352. package/src/memory/conversation-bootstrap.ts +3 -1
  353. package/src/memory/conversation-crud.ts +211 -288
  354. package/src/memory/conversation-group-migration.ts +157 -0
  355. package/src/memory/conversation-queries.ts +61 -13
  356. package/src/memory/conversation-title-service.ts +1 -0
  357. package/src/memory/db-init.ts +194 -361
  358. package/src/memory/embed.ts +73 -0
  359. package/src/memory/embedding-backend.ts +8 -14
  360. package/src/memory/embedding-runtime-manager.ts +12 -114
  361. package/src/memory/fingerprint.ts +2 -2
  362. package/src/memory/graph/bootstrap.ts +521 -0
  363. package/src/memory/graph/capability-seed.ts +449 -0
  364. package/src/memory/graph/consolidation.ts +725 -0
  365. package/src/memory/graph/conversation-graph-memory.ts +659 -0
  366. package/src/memory/graph/decay.test.ts +208 -0
  367. package/src/memory/graph/decay.ts +195 -0
  368. package/src/memory/graph/extraction-job.ts +74 -0
  369. package/src/memory/graph/extraction.test.ts +936 -0
  370. package/src/memory/graph/extraction.ts +1297 -0
  371. package/src/memory/graph/graph-memory-state-store.ts +37 -0
  372. package/src/memory/graph/graph-search.ts +280 -0
  373. package/src/memory/graph/image-ref-utils.ts +29 -0
  374. package/src/memory/graph/injection.test.ts +513 -0
  375. package/src/memory/graph/injection.ts +469 -0
  376. package/src/memory/graph/inspect.ts +543 -0
  377. package/src/memory/graph/narrative.ts +267 -0
  378. package/src/memory/graph/pattern-scan.ts +269 -0
  379. package/src/memory/graph/retriever.ts +1111 -0
  380. package/src/memory/graph/scoring.test.ts +548 -0
  381. package/src/memory/graph/scoring.ts +232 -0
  382. package/src/memory/graph/serendipity.ts +65 -0
  383. package/src/memory/graph/store.test.ts +1098 -0
  384. package/src/memory/graph/store.ts +838 -0
  385. package/src/memory/graph/tool-handlers.ts +301 -0
  386. package/src/memory/graph/tools.ts +97 -0
  387. package/src/memory/graph/triggers.test.ts +487 -0
  388. package/src/memory/graph/triggers.ts +223 -0
  389. package/src/memory/graph/types.ts +295 -0
  390. package/src/memory/group-crud.ts +191 -0
  391. package/src/memory/indexer.ts +37 -19
  392. package/src/memory/job-handlers/cleanup.ts +32 -42
  393. package/src/memory/job-handlers/conversation-starters.ts +91 -53
  394. package/src/memory/job-handlers/embedding.ts +5 -31
  395. package/src/memory/job-handlers/index-maintenance.ts +23 -11
  396. package/src/memory/job-handlers/summarization.ts +32 -17
  397. package/src/memory/job-utils.ts +1 -1
  398. package/src/memory/jobs-store.ts +21 -31
  399. package/src/memory/jobs-worker.ts +180 -129
  400. package/src/memory/llm-request-log-store.ts +96 -12
  401. package/src/memory/memory-recall-log-store.ts +49 -5
  402. package/src/memory/message-content.ts +1 -0
  403. package/src/memory/migrations/202-memory-graph-tables.ts +130 -0
  404. package/src/memory/migrations/203-drop-memory-items-tables.ts +55 -0
  405. package/src/memory/migrations/204-rename-memory-graph-type-values.ts +46 -0
  406. package/src/memory/migrations/205-memory-graph-image-refs.ts +11 -0
  407. package/src/memory/migrations/206-memory-graph-node-edits.ts +19 -0
  408. package/src/memory/migrations/206-scrub-corrupted-image-attachments.ts +131 -0
  409. package/src/memory/migrations/207-conversation-graph-memory-state.ts +20 -0
  410. package/src/memory/migrations/208-conversations-last-message-at.ts +35 -0
  411. package/src/memory/migrations/209-strip-thinking-from-consolidated.ts +85 -0
  412. package/src/memory/migrations/210-schedule-reuse-conversation.ts +13 -0
  413. package/src/memory/migrations/211-memory-recall-logs-query-context.ts +21 -0
  414. package/src/memory/migrations/212-llm-request-logs-created-at-index.ts +19 -0
  415. package/src/memory/migrations/index.ts +12 -0
  416. package/src/memory/migrations/registry.ts +16 -0
  417. package/src/memory/qdrant-client.ts +44 -17
  418. package/src/memory/schema/conversations.ts +14 -0
  419. package/src/memory/schema/index.ts +1 -0
  420. package/src/memory/schema/infrastructure.ts +8 -1
  421. package/src/memory/schema/memory-core.ts +0 -51
  422. package/src/memory/schema/memory-graph.ts +154 -0
  423. package/src/memory/search/semantic.ts +47 -91
  424. package/src/memory/task-memory-cleanup.ts +58 -61
  425. package/src/messaging/providers/outlook/adapter.ts +8 -1
  426. package/src/messaging/providers/outlook/client.ts +299 -0
  427. package/src/messaging/providers/outlook/types.ts +118 -0
  428. package/src/notifications/adapters/macos.ts +1 -0
  429. package/src/notifications/copy-composer.ts +95 -0
  430. package/src/notifications/decision-engine.ts +35 -0
  431. package/src/notifications/signal.ts +16 -0
  432. package/src/oauth/seed-providers.ts +2 -1
  433. package/src/permissions/checker.ts +36 -4
  434. package/src/permissions/defaults.ts +4 -4
  435. package/src/permissions/permission-mode-store.ts +180 -0
  436. package/src/permissions/permission-mode.ts +31 -0
  437. package/src/permissions/workspace-policy.ts +10 -1
  438. package/src/playbooks/playbook-compiler.ts +19 -18
  439. package/src/playbooks/types.ts +4 -3
  440. package/src/prompts/system-prompt.ts +62 -36
  441. package/src/prompts/templates/BOOTSTRAP-REFERENCE.md +100 -0
  442. package/src/prompts/templates/BOOTSTRAP.md +70 -165
  443. package/src/prompts/templates/HEARTBEAT.md +3 -1
  444. package/src/prompts/templates/SOUL.md +25 -4
  445. package/src/prompts/templates/UPDATES.md +8 -0
  446. package/src/providers/anthropic/client.ts +136 -220
  447. package/src/providers/gemini/client.ts +1 -1
  448. package/src/providers/openai/client.ts +1 -1
  449. package/src/providers/registry.ts +1 -1
  450. package/src/providers/retry.ts +19 -3
  451. package/src/runtime/actor-trust-resolver.ts +5 -1
  452. package/src/runtime/auth/route-policy.ts +30 -0
  453. package/src/runtime/guardian-reply-router.ts +5 -1
  454. package/src/runtime/http-server.ts +55 -5
  455. package/src/runtime/http-types.ts +12 -1
  456. package/src/runtime/middleware/auth.ts +20 -0
  457. package/src/runtime/migrations/vbundle-builder.ts +389 -3
  458. package/src/runtime/migrations/vbundle-importer.ts +8 -6
  459. package/src/runtime/routes/__tests__/user-route-dispatcher.test.ts +378 -0
  460. package/src/runtime/routes/app-management-routes.ts +1 -11
  461. package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +26 -0
  462. package/src/runtime/routes/archive-utils.ts +29 -0
  463. package/src/runtime/routes/attachment-routes.test.ts +106 -0
  464. package/src/runtime/routes/attachment-routes.ts +106 -16
  465. package/src/runtime/routes/avatar-routes.ts +2 -9
  466. package/src/runtime/routes/brain-graph-routes.ts +21 -22
  467. package/src/runtime/routes/btw-routes.ts +22 -1
  468. package/src/runtime/routes/conversation-analysis-routes.ts +173 -0
  469. package/src/runtime/routes/conversation-management-routes.ts +3 -14
  470. package/src/runtime/routes/conversation-query-routes.ts +49 -3
  471. package/src/runtime/routes/conversation-routes.ts +264 -44
  472. package/src/runtime/routes/conversation-starter-routes.ts +2 -2
  473. package/src/runtime/routes/debug-routes.ts +1 -1
  474. package/src/runtime/routes/global-search-routes.ts +21 -19
  475. package/src/runtime/routes/group-routes.ts +207 -0
  476. package/src/runtime/routes/guardian-action-routes.ts +21 -10
  477. package/src/runtime/routes/guardian-bootstrap-routes.ts +23 -19
  478. package/src/runtime/routes/heartbeat-routes.ts +4 -10
  479. package/src/runtime/routes/identity-routes.ts +53 -18
  480. package/src/runtime/routes/inbound-message-handler.ts +19 -0
  481. package/src/runtime/routes/inbound-stages/guardian-activation-intercept.test.ts +292 -0
  482. package/src/runtime/routes/inbound-stages/guardian-activation-intercept.ts +207 -0
  483. package/src/runtime/routes/llm-context-normalization.ts +14 -10
  484. package/src/runtime/routes/log-export-routes.ts +23 -275
  485. package/src/runtime/routes/memory-item-routes.test.ts +170 -247
  486. package/src/runtime/routes/memory-item-routes.ts +341 -388
  487. package/src/runtime/routes/migration-routes.ts +18 -7
  488. package/src/runtime/routes/profiler-routes.ts +350 -0
  489. package/src/runtime/routes/schedule-routes.ts +28 -11
  490. package/src/runtime/routes/settings-routes.ts +95 -8
  491. package/src/runtime/routes/skills-routes.ts +103 -37
  492. package/src/runtime/routes/subagents-routes.ts +28 -7
  493. package/src/runtime/routes/user-route-dispatcher.ts +223 -0
  494. package/src/runtime/routes/user-routes.ts +41 -0
  495. package/src/runtime/routes/work-items-routes.test.ts +2 -6
  496. package/src/runtime/routes/workspace-routes.ts +0 -1
  497. package/src/schedule/schedule-store.ts +30 -0
  498. package/src/schedule/scheduler.ts +52 -18
  499. package/src/security/oauth2.ts +1 -1
  500. package/src/security/secure-keys.ts +4 -8
  501. package/src/shared/provider-env-vars.ts +19 -0
  502. package/src/skills/catalog-cache.ts +5 -0
  503. package/src/skills/catalog-install.ts +25 -16
  504. package/src/skills/clawhub.ts +134 -154
  505. package/src/skills/install-meta.ts +208 -0
  506. package/src/skills/managed-store.ts +29 -18
  507. package/src/skills/skill-memory.ts +12 -229
  508. package/src/skills/skillssh-registry.ts +19 -17
  509. package/src/subagent/index.ts +13 -3
  510. package/src/subagent/manager.ts +308 -29
  511. package/src/subagent/types.ts +68 -0
  512. package/src/tasks/task-runner.ts +7 -5
  513. package/src/telemetry/usage-telemetry-reporter.test.ts +3 -5
  514. package/src/tools/apps/executors.ts +29 -4
  515. package/src/tools/browser/runtime-check.ts +3 -1
  516. package/src/tools/filesystem/list.ts +93 -0
  517. package/src/tools/memory/register.ts +63 -46
  518. package/src/tools/permission-checker.ts +85 -1
  519. package/src/tools/registry.ts +4 -0
  520. package/src/tools/schedule/create.ts +3 -0
  521. package/src/tools/schedule/list.ts +1 -0
  522. package/src/tools/schedule/update.ts +6 -0
  523. package/src/tools/shared/filesystem/errors.ts +5 -0
  524. package/src/tools/shared/filesystem/file-ops-service.ts +90 -2
  525. package/src/tools/shared/filesystem/image-read.ts +22 -85
  526. package/src/tools/shared/filesystem/types.ts +17 -0
  527. package/src/tools/shared/shell-output.ts +31 -2
  528. package/src/tools/subagent/abort.ts +12 -2
  529. package/src/tools/subagent/message.ts +9 -2
  530. package/src/tools/subagent/notify-parent.ts +79 -0
  531. package/src/tools/subagent/read.ts +29 -8
  532. package/src/tools/subagent/resolve.ts +21 -0
  533. package/src/tools/subagent/spawn.ts +2 -0
  534. package/src/tools/subagent/status.ts +11 -1
  535. package/src/tools/system/avatar-generator.ts +3 -3
  536. package/src/tools/system/register.ts +23 -0
  537. package/src/tools/system/set-permission-mode.ts +103 -0
  538. package/src/tools/terminal/parser.ts +30 -5
  539. package/src/tools/terminal/safe-env.ts +17 -1
  540. package/src/tools/tool-manifest.ts +9 -3
  541. package/src/tools/types.ts +2 -0
  542. package/src/util/browser.ts +25 -10
  543. package/src/util/bun-runtime.ts +172 -0
  544. package/src/util/logger.ts +1 -1
  545. package/src/util/platform.ts +50 -17
  546. package/src/watcher/providers/outlook-calendar.ts +343 -0
  547. package/src/watcher/providers/outlook.ts +198 -0
  548. package/src/workspace/migrations/023-move-config-files-to-workspace.ts +2 -2
  549. package/src/workspace/migrations/024-move-runtime-files-to-workspace.ts +2 -2
  550. package/src/workspace/migrations/025-remove-oauth-app-setup-skills.ts +76 -0
  551. package/src/workspace/migrations/026-backfill-install-meta.ts +325 -0
  552. package/src/workspace/migrations/027-remove-orphaned-optimized-images-cache.ts +42 -0
  553. package/src/workspace/migrations/028-recover-conversations-from-disk-view.ts +270 -0
  554. package/src/workspace/migrations/029-seed-pkb.ts +84 -0
  555. package/src/workspace/migrations/registry.ts +10 -0
  556. package/src/workspace/top-level-renderer.ts +5 -9
  557. package/src/__tests__/cli-memory.test.ts +0 -372
  558. package/src/__tests__/clipboard.test.ts +0 -88
  559. package/src/__tests__/context-memory-e2e.test.ts +0 -415
  560. package/src/__tests__/journal-context.test.ts +0 -268
  561. package/src/__tests__/memory-context-benchmark.benchmark.test.ts +0 -297
  562. package/src/__tests__/memory-lifecycle-e2e.test.ts +0 -459
  563. package/src/__tests__/memory-query-builder.test.ts +0 -59
  564. package/src/__tests__/memory-recall-quality.test.ts +0 -1046
  565. package/src/__tests__/memory-regressions.experimental.test.ts +0 -629
  566. package/src/__tests__/memory-regressions.test.ts +0 -3696
  567. package/src/__tests__/memory-retrieval.benchmark.test.ts +0 -295
  568. package/src/cli/cli-memory.ts +0 -176
  569. package/src/daemon/conversation-memory.ts +0 -207
  570. package/src/memory/conversation-starters-cadence.ts +0 -74
  571. package/src/memory/items-extractor.ts +0 -860
  572. package/src/memory/job-handlers/batch-extraction.ts +0 -753
  573. package/src/memory/job-handlers/extraction.ts +0 -40
  574. package/src/memory/job-handlers/journal-carry-forward.test.ts +0 -355
  575. package/src/memory/job-handlers/journal-carry-forward.ts +0 -255
  576. package/src/memory/journal-memory.ts +0 -224
  577. package/src/memory/query-builder.ts +0 -47
  578. package/src/memory/query-expansion.ts +0 -83
  579. package/src/memory/retriever.test.ts +0 -1592
  580. package/src/memory/retriever.ts +0 -1331
  581. package/src/memory/search/formatting.test.ts +0 -140
  582. package/src/memory/search/formatting.ts +0 -262
  583. package/src/memory/search/mmr.ts +0 -139
  584. package/src/memory/search/ranking.ts +0 -15
  585. package/src/memory/search/staleness.ts +0 -40
  586. package/src/memory/search/tier-classifier.ts +0 -18
  587. package/src/memory/search/types.ts +0 -121
  588. package/src/prompts/journal-context.ts +0 -154
  589. package/src/tools/memory/definitions.ts +0 -69
  590. package/src/tools/memory/handlers.test.ts +0 -562
  591. package/src/tools/memory/handlers.ts +0 -434
  592. package/src/util/clipboard.ts +0 -34
@@ -0,0 +1,1111 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Memory Graph — Retrieval pipeline
3
+ //
4
+ // Two modes:
5
+ // 1. Context load (conversation start) — full retrieval with re-ranking
6
+ // 2. Per-turn injection — lightweight embedding search for new memories
7
+ // ---------------------------------------------------------------------------
8
+
9
+ import type { AssistantConfig } from "../../config/types.js";
10
+ import {
11
+ extractToolUse,
12
+ getConfiguredProvider,
13
+ userMessage,
14
+ } from "../../providers/provider-send-message.js";
15
+ import type { ContentBlock, ImageContent } from "../../providers/types.js";
16
+ import { getLogger } from "../../util/logger.js";
17
+ import { embedWithRetry } from "../embed.js";
18
+ import { selectedBackendSupportsMultimodal } from "../embedding-backend.js";
19
+ import { searchGraphNodes } from "./graph-search.js";
20
+ import type { InContextTracker } from "./injection.js";
21
+ import {
22
+ computeActivationSpread,
23
+ computeEffectiveSignificance,
24
+ computeRecencyBoost,
25
+ computeTemporalBoost,
26
+ PER_TURN_WEIGHTS,
27
+ scoreCandidate,
28
+ } from "./scoring.js";
29
+ import { sampleSerendipity } from "./serendipity.js";
30
+ import { getEdgesForNode, getNodesByIds, queryNodes } from "./store.js";
31
+ import { getActiveTriggersByType } from "./store.js";
32
+ import {
33
+ evaluateEventTriggers,
34
+ evaluateSemanticTriggers,
35
+ evaluateTemporalTriggers,
36
+ type TriggeredResult,
37
+ } from "./triggers.js";
38
+ import type {
39
+ MemoryEdge,
40
+ MemoryNode,
41
+ RetrievalMetrics,
42
+ ScoredNode,
43
+ } from "./types.js";
44
+ import { isCapabilityNode } from "./types.js";
45
+
46
+ const log = getLogger("graph-retriever");
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // LLM re-ranking + deduplication
50
+ // ---------------------------------------------------------------------------
51
+
52
+ const RERANK_TOOL = {
53
+ name: "select_memories",
54
+ description:
55
+ "Select and order the best memories to load into context, removing duplicates",
56
+ input_schema: {
57
+ type: "object" as const,
58
+ properties: {
59
+ selected: {
60
+ type: "array" as const,
61
+ description:
62
+ "Ordered list of item numbers to include (best first). Remove duplicates — keep only the richest version of each topic.",
63
+ items: { type: "number" as const },
64
+ },
65
+ },
66
+ required: ["selected"] as const,
67
+ },
68
+ };
69
+
70
+ /**
71
+ * LLM re-ranking pass: takes ~60 scored candidates, removes duplicates,
72
+ * and selects the best ~40 for context injection. Falls back to the
73
+ * original scored list on any failure.
74
+ */
75
+ async function rerankAndDedup(
76
+ candidates: ScoredNode[],
77
+ maxNodes: number,
78
+ _config: AssistantConfig,
79
+ ): Promise<ScoredNode[]> {
80
+ if (candidates.length <= maxNodes) return candidates;
81
+
82
+ try {
83
+ const provider = await getConfiguredProvider();
84
+ if (!provider) return candidates.slice(0, maxNodes);
85
+
86
+ // Numbered listing for the LLM: index + age + full content
87
+ const now = Date.now();
88
+ const listing = candidates
89
+ .map((s, i) => {
90
+ const ageDays = (now - s.node.created) / (1000 * 60 * 60 * 24);
91
+ const age =
92
+ ageDays < 1
93
+ ? `${Math.floor(ageDays * 24)}h`
94
+ : `${Math.floor(ageDays)}d`;
95
+ return `${i + 1}. (${age}) ${s.node.content}`;
96
+ })
97
+ .join("\n");
98
+
99
+ const response = await provider.sendMessage(
100
+ [userMessage(listing)],
101
+ [RERANK_TOOL],
102
+ `You are selecting memories for an AI assistant's context at conversation start. You see ${candidates.length} candidate memories ranked by algorithmic score.
103
+
104
+ Your job:
105
+ 1. REMOVE DUPLICATES: If multiple entries describe the same event/fact/topic, keep ONLY the most complete version. Be aggressive — even partial overlaps should be deduplicated.
106
+ 2. SELECT the best ${maxNodes} memories for a well-rounded context. Prioritize:
107
+ - Recency (recent events should be well-represented)
108
+ - Diversity (don't load 5 memories about the same topic)
109
+ - Importance (key relationship moments, active commitments, identity-defining events)
110
+ 3. Return the IDs in order of importance (most important first).`,
111
+ {
112
+ config: {
113
+ modelIntent: "quality-optimized" as const,
114
+ tool_choice: { type: "tool" as const, name: "select_memories" },
115
+ thinking: { type: "disabled" },
116
+ temperature: 0,
117
+ },
118
+ },
119
+ );
120
+
121
+ const toolBlock = extractToolUse(response);
122
+ if (!toolBlock) return candidates.slice(0, maxNodes);
123
+
124
+ const input = toolBlock.input as { selected?: number[] };
125
+ if (!input.selected?.length) return candidates.slice(0, maxNodes);
126
+
127
+ // Rebuild scored list in the LLM's chosen order (1-indexed → 0-indexed)
128
+ const reranked: ScoredNode[] = [];
129
+ const seen = new Set<number>();
130
+ for (const num of input.selected) {
131
+ const idx = num - 1;
132
+ if (idx >= 0 && idx < candidates.length && !seen.has(idx)) {
133
+ reranked.push(candidates[idx]);
134
+ seen.add(idx);
135
+ }
136
+ }
137
+
138
+ if (reranked.length === 0) return candidates.slice(0, maxNodes);
139
+ return reranked.slice(0, maxNodes);
140
+ } catch (err) {
141
+ log.warn(
142
+ { err: err instanceof Error ? err.message : String(err) },
143
+ "LLM rerank failed, using scored order",
144
+ );
145
+ return candidates.slice(0, maxNodes);
146
+ }
147
+ }
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // Per-turn dedup — lightweight duplicate removal with a fast model
151
+ // ---------------------------------------------------------------------------
152
+
153
+ const SELECT_ITEMS_TOOL = {
154
+ name: "select_items",
155
+ description:
156
+ "Select the most relevant items after deduplication, ordered by relevance to the query",
157
+ input_schema: {
158
+ type: "object" as const,
159
+ properties: {
160
+ items: {
161
+ type: "array" as const,
162
+ description:
163
+ "Item numbers to keep (1-indexed), ordered by relevance to the query. Remove duplicates — when multiple entries describe the same event/fact, keep ONLY the richest version.",
164
+ items: { type: "number" as const },
165
+ },
166
+ },
167
+ required: ["items"] as const,
168
+ },
169
+ };
170
+
171
+ /**
172
+ * Fast dedup + rerank pass for per-turn injection. Uses a latency-optimized
173
+ * model to remove duplicates and reorder by relevance to the user's query.
174
+ * Falls back to score-based truncation on any failure.
175
+ */
176
+ async function dedupForTurn(
177
+ candidates: ScoredNode[],
178
+ maxNodes: number,
179
+ query: string,
180
+ ): Promise<{ nodes: ScoredNode[]; llmApplied: boolean }> {
181
+ try {
182
+ const provider = await getConfiguredProvider();
183
+ if (!provider)
184
+ return { nodes: candidates.slice(0, maxNodes), llmApplied: false };
185
+
186
+ const now = Date.now();
187
+ const listing = candidates
188
+ .map((s, i) => {
189
+ const ageDays = (now - s.node.created) / (1000 * 60 * 60 * 24);
190
+ const age =
191
+ ageDays < 1
192
+ ? `${Math.floor(ageDays * 24)}h`
193
+ : `${Math.floor(ageDays)}d`;
194
+ return `${i + 1}. (${age}) ${s.node.content}`;
195
+ })
196
+ .join("\n");
197
+
198
+ const response = await provider.sendMessage(
199
+ [userMessage(`query:\n${query}\n\nitems:\n\n${listing}`)],
200
+ [SELECT_ITEMS_TOOL],
201
+ `Dedupe + rerank the following numbered items. Pick the most relevant items to the query. Call the select_items tool.\n\nBe aggressive on dedup — when multiple items describe the same event, fact, or status, keep ONLY the richest version. But be generous on relevance — only cut items that are completely irrelevant to the query. If it's even tangentially related, keep it.`,
202
+ {
203
+ config: {
204
+ modelIntent: "latency-optimized" as const,
205
+ tool_choice: { type: "tool" as const, name: "select_items" },
206
+ thinking: { type: "disabled" },
207
+ temperature: 0,
208
+ },
209
+ },
210
+ );
211
+
212
+ const toolBlock = extractToolUse(response);
213
+ if (!toolBlock)
214
+ return { nodes: candidates.slice(0, maxNodes), llmApplied: false };
215
+
216
+ const input = toolBlock.input as { items?: number[] };
217
+ if (!input.items?.length)
218
+ return { nodes: candidates.slice(0, maxNodes), llmApplied: false };
219
+
220
+ const reranked: ScoredNode[] = [];
221
+ const seen = new Set<number>();
222
+ for (const num of input.items) {
223
+ const idx = num - 1;
224
+ if (idx >= 0 && idx < candidates.length && !seen.has(idx)) {
225
+ reranked.push(candidates[idx]);
226
+ seen.add(idx);
227
+ }
228
+ }
229
+
230
+ return reranked.length > 0
231
+ ? { nodes: reranked.slice(0, maxNodes), llmApplied: true }
232
+ : { nodes: candidates.slice(0, maxNodes), llmApplied: false };
233
+ } catch (err) {
234
+ log.warn(
235
+ { err: err instanceof Error ? err.message : String(err) },
236
+ "Per-turn dedup+rerank failed, using scored order",
237
+ );
238
+ return { nodes: candidates.slice(0, maxNodes), llmApplied: false };
239
+ }
240
+ }
241
+
242
+ // ---------------------------------------------------------------------------
243
+ // Cross-category dedup — dedup-only (no relevance filtering)
244
+ // ---------------------------------------------------------------------------
245
+
246
+ const DEDUP_ITEMS_TOOL = {
247
+ name: "select_items",
248
+ description:
249
+ "Select ALL items that survive deduplication. When multiple items describe the same event/fact, keep only the richest version. Do not filter by relevance — keep everything that is not a duplicate.",
250
+ input_schema: {
251
+ type: "object" as const,
252
+ properties: {
253
+ items: {
254
+ type: "array" as const,
255
+ description:
256
+ "Item numbers to keep (1-indexed). Remove duplicates — when multiple entries describe the same event/fact, keep ONLY the richest version. Keep all non-duplicate items.",
257
+ items: { type: "number" as const },
258
+ },
259
+ },
260
+ required: ["items"] as const,
261
+ },
262
+ };
263
+
264
+ /**
265
+ * Dedup-only pass for cross-category duplicate removal. Unlike `dedupForTurn`,
266
+ * this does NOT filter by relevance to a query — it ONLY removes duplicates
267
+ * and keeps everything else. Used after context load to catch topic-level
268
+ * duplicates across reserved categories and serendipity.
269
+ */
270
+ async function dedupCrossCategory(
271
+ candidates: ScoredNode[],
272
+ maxNodes: number,
273
+ ): Promise<ScoredNode[]> {
274
+ try {
275
+ const provider = await getConfiguredProvider();
276
+ if (!provider) return candidates.slice(0, maxNodes);
277
+
278
+ const now = Date.now();
279
+ const listing = candidates
280
+ .map((s, i) => {
281
+ const ageDays = (now - s.node.created) / (1000 * 60 * 60 * 24);
282
+ const age =
283
+ ageDays < 1
284
+ ? `${Math.floor(ageDays * 24)}h`
285
+ : `${Math.floor(ageDays)}d`;
286
+ return `${i + 1}. (${age}) ${s.node.content}`;
287
+ })
288
+ .join("\n");
289
+
290
+ const response = await provider.sendMessage(
291
+ [userMessage(listing)],
292
+ [DEDUP_ITEMS_TOOL],
293
+ `Deduplicate the following numbered items. When multiple items describe the same event, fact, or status, keep ONLY the richest version. Keep ALL items that are not duplicates — do not filter by relevance or topic. Call the select_items tool with every item that survives dedup.`,
294
+ {
295
+ config: {
296
+ modelIntent: "latency-optimized" as const,
297
+ tool_choice: { type: "tool" as const, name: "select_items" },
298
+ thinking: { type: "disabled" },
299
+ temperature: 0,
300
+ },
301
+ },
302
+ );
303
+
304
+ const toolBlock = extractToolUse(response);
305
+ if (!toolBlock) return candidates.slice(0, maxNodes);
306
+
307
+ const input = toolBlock.input as { items?: number[] };
308
+ if (!input.items?.length) return candidates.slice(0, maxNodes);
309
+
310
+ const reranked: ScoredNode[] = [];
311
+ const seen = new Set<number>();
312
+ for (const num of input.items) {
313
+ const idx = num - 1;
314
+ if (idx >= 0 && idx < candidates.length && !seen.has(idx)) {
315
+ reranked.push(candidates[idx]);
316
+ seen.add(idx);
317
+ }
318
+ }
319
+
320
+ return reranked.length > 0
321
+ ? reranked.slice(0, maxNodes)
322
+ : candidates.slice(0, maxNodes);
323
+ } catch (err) {
324
+ log.warn(
325
+ { err: err instanceof Error ? err.message : String(err) },
326
+ "Cross-category dedup failed, using original order",
327
+ );
328
+ return candidates.slice(0, maxNodes);
329
+ }
330
+ }
331
+
332
+ // ---------------------------------------------------------------------------
333
+ // Context load — conversation start
334
+ // ---------------------------------------------------------------------------
335
+
336
+ export interface ContextLoadOpts {
337
+ /** Scope for memory isolation. */
338
+ scopeId: string;
339
+ /** Recent conversation summaries (used as retrieval queries). */
340
+ recentSummaries: string[];
341
+ /** Embedding config. */
342
+ config: AssistantConfig;
343
+ /** Abort signal. */
344
+ signal?: AbortSignal;
345
+ /** Number of serendipity slots (default 5). */
346
+ serendipitySlots?: number;
347
+ /** Maximum nodes to return (default 40). */
348
+ maxNodes?: number;
349
+ }
350
+
351
+ export interface ContextLoadResult {
352
+ nodes: ScoredNode[];
353
+ serendipityNodes: ScoredNode[];
354
+ triggeredNodes: TriggeredResult[];
355
+ latencyMs: number;
356
+ metrics: RetrievalMetrics;
357
+ }
358
+
359
+ /**
360
+ * Full retrieval pipeline for conversation start. Budget: p90 < 2s.
361
+ *
362
+ * 1. Embed recent conversation summaries
363
+ * 2. Hybrid retrieval from Qdrant
364
+ * 3. Evaluate triggers (temporal + semantic + event)
365
+ * 4. Activation spreading from triggered/top nodes
366
+ * 5. Score all candidates
367
+ * 6. Serendipity sampling
368
+ * 7. Return top N
369
+ */
370
+ export async function loadContextMemory(
371
+ opts: ContextLoadOpts,
372
+ ): Promise<ContextLoadResult> {
373
+ const start = Date.now();
374
+ const maxNodes = opts.maxNodes ?? 40;
375
+ const serendipitySlots = opts.serendipitySlots ?? 10;
376
+ const now = new Date();
377
+ const nowMs = now.getTime();
378
+
379
+ // 1. Embed recent conversation summaries as retrieval queries
380
+ let queryVector: number[] | null = null;
381
+ let embeddingProvider: string | null = null;
382
+ let embeddingModel: string | null = null;
383
+ let contextQueryText: string | null = null;
384
+ if (opts.recentSummaries.length > 0) {
385
+ try {
386
+ const queryText = opts.recentSummaries.join("\n\n");
387
+ const truncated =
388
+ queryText.length > 3000 ? queryText.slice(0, 3000) : queryText;
389
+ contextQueryText = truncated;
390
+ const result = await embedWithRetry(opts.config, [truncated], {
391
+ signal: opts.signal,
392
+ });
393
+ queryVector = result.vectors[0] ?? null;
394
+ embeddingProvider = result.provider;
395
+ embeddingModel = result.model;
396
+ } catch (err) {
397
+ log.warn({ err }, "Failed to embed summaries for context load");
398
+ }
399
+ }
400
+
401
+ // 2. Hybrid retrieval from Qdrant (dense search on graph_node points)
402
+ const semanticCandidateIds = new Map<string, number>(); // nodeId → score
403
+ let hybridSearchLatencyMs = 0;
404
+ if (queryVector) {
405
+ const searchStart = Date.now();
406
+ try {
407
+ const results = await searchGraphNodes(queryVector, maxNodes * 3, [
408
+ opts.scopeId,
409
+ ]);
410
+ for (const r of results) {
411
+ semanticCandidateIds.set(r.nodeId, r.score);
412
+ }
413
+ } catch (err) {
414
+ log.warn({ err }, "Qdrant search failed for context load");
415
+ } finally {
416
+ hybridSearchLatencyMs = Date.now() - searchStart;
417
+ }
418
+ }
419
+ const pureSemanticHits = semanticCandidateIds.size;
420
+
421
+ // Also include top-significance nodes as a fallback
422
+ const topSignificance = queryNodes({
423
+ scopeId: opts.scopeId,
424
+ fidelityNot: ["gone"],
425
+ limit: maxNodes,
426
+ });
427
+ for (const node of topSignificance) {
428
+ if (!semanticCandidateIds.has(node.id)) {
429
+ semanticCandidateIds.set(node.id, 0); // no semantic score, ranked by significance
430
+ }
431
+ }
432
+
433
+ // Include recent nodes (last 7 days) so recency is always represented.
434
+ // Exclude procedural nodes (capabilities) — they have reserved slots
435
+ // and shouldn't compete with organic memories on recency alone.
436
+ const recentNodes = queryNodes({
437
+ scopeId: opts.scopeId,
438
+ fidelityNot: ["gone"],
439
+ createdAfter: nowMs - 7 * 24 * 60 * 60 * 1000,
440
+ limit: maxNodes,
441
+ });
442
+ for (const node of recentNodes) {
443
+ if (isCapabilityNode(node)) continue;
444
+ if (!semanticCandidateIds.has(node.id)) {
445
+ semanticCandidateIds.set(node.id, 0);
446
+ }
447
+ }
448
+
449
+ // Hydrate all candidate nodes
450
+ const allCandidateIds = [...semanticCandidateIds.keys()];
451
+ const candidateNodes = getNodesByIds(allCandidateIds);
452
+ const nodeMap = new Map(candidateNodes.map((n) => [n.id, n]));
453
+
454
+ // 3. Evaluate triggers
455
+ const temporalTriggers = getActiveTriggersByType("temporal", opts.scopeId);
456
+ const semanticTriggers = getActiveTriggersByType("semantic", opts.scopeId);
457
+ const eventTriggers = getActiveTriggersByType("event", opts.scopeId);
458
+
459
+ const triggeredTemporal = evaluateTemporalTriggers(temporalTriggers, now);
460
+ const triggeredSemantic = queryVector
461
+ ? evaluateSemanticTriggers(semanticTriggers, queryVector)
462
+ : [];
463
+ const triggeredEvent = evaluateEventTriggers(eventTriggers, now);
464
+
465
+ const allTriggered = [
466
+ ...triggeredTemporal,
467
+ ...triggeredSemantic,
468
+ ...triggeredEvent,
469
+ ];
470
+
471
+ // Build trigger boost map (nodeId → max trigger boost)
472
+ const triggerBoostMap = new Map<string, number>();
473
+ for (const t of allTriggered) {
474
+ const current = triggerBoostMap.get(t.trigger.nodeId) ?? 0;
475
+ triggerBoostMap.set(t.trigger.nodeId, Math.max(current, t.boost));
476
+
477
+ // Ensure triggered nodes are in the candidate set
478
+ if (!nodeMap.has(t.trigger.nodeId)) {
479
+ const node = getNodesByIds([t.trigger.nodeId])[0];
480
+ if (node) {
481
+ nodeMap.set(node.id, node);
482
+ semanticCandidateIds.set(node.id, 0);
483
+ }
484
+ }
485
+ }
486
+
487
+ // 4. Activation spreading
488
+ // Collect edges for all candidate nodes
489
+ const allEdges: MemoryEdge[] = [];
490
+ for (const id of nodeMap.keys()) {
491
+ allEdges.push(...getEdgesForNode(id));
492
+ }
493
+
494
+ // Start spreading from top semantic hits + triggered nodes
495
+ const spreadStartIds = [
496
+ ...allTriggered.map((t) => t.trigger.nodeId),
497
+ ...[...semanticCandidateIds.entries()]
498
+ .sort((a, b) => b[1] - a[1])
499
+ .slice(0, 10)
500
+ .map(([id]) => id),
501
+ ];
502
+
503
+ const activationBoosts = computeActivationSpread(spreadStartIds, allEdges, 2);
504
+
505
+ // Hydrate any newly discovered nodes from activation spreading
506
+ const newNodeIds = [...activationBoosts.keys()].filter(
507
+ (id) => !nodeMap.has(id),
508
+ );
509
+ if (newNodeIds.length > 0) {
510
+ const newNodes = getNodesByIds(newNodeIds);
511
+ for (const node of newNodes) {
512
+ nodeMap.set(node.id, node);
513
+ }
514
+ }
515
+
516
+ // 5. Score all candidates
517
+ const scored: ScoredNode[] = [];
518
+ for (const [nodeId, node] of nodeMap) {
519
+ if (node.fidelity === "gone") continue;
520
+
521
+ const semanticSim = semanticCandidateIds.get(nodeId) ?? 0;
522
+ const effectiveSig = computeEffectiveSignificance(node, nowMs);
523
+ const temporal = computeTemporalBoost(node, now);
524
+ const triggerBoost = triggerBoostMap.get(nodeId) ?? 0;
525
+ const activation = activationBoosts.get(nodeId) ?? 0;
526
+
527
+ // Normalize temporal boost from [-1,1] to [0,1]
528
+ const normalizedTemporal = (temporal + 1) / 2;
529
+ const recency = computeRecencyBoost(node, nowMs);
530
+
531
+ scored.push(
532
+ scoreCandidate(node, {
533
+ semanticSimilarity: semanticSim,
534
+ effectiveSignificance: effectiveSig,
535
+ emotionalIntensity: node.emotionalCharge.intensity,
536
+ temporalBoost: normalizedTemporal,
537
+ recencyBoost: recency,
538
+ triggerBoost,
539
+ activationBoost: activation,
540
+ }),
541
+ );
542
+ }
543
+
544
+ // Sort by score descending
545
+ scored.sort((a, b) => b.score - a.score);
546
+
547
+ // 5b. Reserve slots for skill/CLI capabilities. Queried directly from
548
+ // SQLite — no Qdrant vectors needed — so capabilities surface even on
549
+ // fresh assistants whose embedding jobs haven't completed yet.
550
+ const CAPABILITY_RESERVE = 5;
551
+ const rawCapabilityNodes = queryNodes({
552
+ scopeId: opts.scopeId,
553
+ types: ["procedural"],
554
+ fidelityNot: ["gone"],
555
+ limit: CAPABILITY_RESERVE * 4,
556
+ });
557
+
558
+ // Dedup: both seeding systems may create nodes for the same capability.
559
+ // Extract capability ID from content and keep only the first node per ID.
560
+ const seenCapabilityIds = new Set<string>();
561
+ const capabilityNodes = rawCapabilityNodes
562
+ .filter(isCapabilityNode)
563
+ .filter((node) => {
564
+ const match = node.content.match(
565
+ /^skill:(\S+)\n|^cli:(\S+)\n|^\s*The ".*?" skill \(([^)]+)\)|^\s*The "assistant (\S+)" CLI command/,
566
+ );
567
+ const capId = match?.[1] ?? match?.[2] ?? match?.[3] ?? match?.[4];
568
+ if (capId) {
569
+ if (seenCapabilityIds.has(capId)) return false;
570
+ seenCapabilityIds.add(capId);
571
+ }
572
+ return true;
573
+ });
574
+
575
+ // Rank by semantic similarity when a query vector exists
576
+ let selectedCapabilities: MemoryNode[];
577
+ if (queryVector && capabilityNodes.length > CAPABILITY_RESERVE) {
578
+ selectedCapabilities = capabilityNodes
579
+ .map((node) => ({ node, sim: semanticCandidateIds.get(node.id) ?? 0 }))
580
+ .sort((a, b) => b.sim - a.sim)
581
+ .slice(0, CAPABILITY_RESERVE)
582
+ .map((e) => e.node);
583
+ } else {
584
+ selectedCapabilities = capabilityNodes.slice(0, CAPABILITY_RESERVE);
585
+ }
586
+
587
+ const reservedCapabilities: ScoredNode[] = selectedCapabilities.map(
588
+ (node) => {
589
+ const existing = scored.find((s) => s.node.id === node.id);
590
+ if (existing) return existing;
591
+ return scoreCandidate(node, {
592
+ semanticSimilarity: semanticCandidateIds.get(node.id) ?? 0,
593
+ effectiveSignificance: computeEffectiveSignificance(node, nowMs),
594
+ emotionalIntensity: node.emotionalCharge.intensity,
595
+ temporalBoost: (computeTemporalBoost(node, now) + 1) / 2,
596
+ recencyBoost: 0,
597
+ triggerBoost: 0,
598
+ activationBoost: 0,
599
+ });
600
+ },
601
+ );
602
+
603
+ // 6. Remove procedural nodes from the main pool — they have dedicated
604
+ // reserved slots and shouldn't compete with organic memories.
605
+ // Prospective/upcoming reserves were removed in favor of the PKB
606
+ // (personal knowledge base) which handles commitments and schedule
607
+ // via always-loaded flat files.
608
+ const mainPool = scored.filter((s) => !isCapabilityNode(s.node));
609
+ const mainSlots = Math.max(
610
+ 0,
611
+ maxNodes - serendipitySlots - reservedCapabilities.length,
612
+ );
613
+
614
+ // 7. LLM re-ranking on the main pool: dedup + select
615
+ const reranked = await rerankAndDedup(
616
+ mainPool.slice(0, 100),
617
+ mainSlots,
618
+ opts.config,
619
+ );
620
+
621
+ // 8. Combine: reserved capabilities + reranked main pool
622
+ const deterministic = [...reservedCapabilities, ...reranked].slice(
623
+ 0,
624
+ maxNodes - serendipitySlots,
625
+ );
626
+ // Exclude procedural nodes from serendipity — they have reserved slots
627
+ // and shouldn't appear as random wildcard picks.
628
+ const serendipityPool = scored.filter((s) => !isCapabilityNode(s.node));
629
+ const serendipityPicks = sampleSerendipity(serendipityPool, serendipitySlots);
630
+
631
+ // Deduplicate serendipity against deterministic
632
+ const deterministicIds = new Set(deterministic.map((s) => s.node.id));
633
+ const uniqueSerendipity = serendipityPicks.filter(
634
+ (s) => !deterministicIds.has(s.node.id),
635
+ );
636
+
637
+ // 9. Cross-category dedup: catch topic-level duplicates across reserved
638
+ // categories (prospective, upcoming, capabilities) and serendipity.
639
+ // Only runs when the combined set is large enough to warrant an LLM call.
640
+ const CROSS_DEDUP_THRESHOLD = 15;
641
+ const combined = [...deterministic, ...uniqueSerendipity];
642
+ let dedupedDeterministic = deterministic;
643
+ let dedupedSerendipity = uniqueSerendipity;
644
+
645
+ if (combined.length > CROSS_DEDUP_THRESHOLD) {
646
+ const deduped = await dedupCrossCategory(
647
+ combined,
648
+ combined.length, // preserve all non-duplicate nodes
649
+ );
650
+
651
+ // Re-split into deterministic vs serendipity by checking original membership
652
+ dedupedDeterministic = deduped.filter((s) =>
653
+ deterministicIds.has(s.node.id),
654
+ );
655
+ dedupedSerendipity = deduped.filter(
656
+ (s) => !deterministicIds.has(s.node.id),
657
+ );
658
+ }
659
+
660
+ const TOP_N = 20;
661
+ const topCandidates = scored.slice(0, TOP_N).map((s) => ({
662
+ nodeId: s.node.id,
663
+ type: s.node.type,
664
+ score: s.score,
665
+ semanticSimilarity: s.scoreBreakdown.semanticSimilarity,
666
+ recencyBoost: s.scoreBreakdown.recencyBoost,
667
+ }));
668
+
669
+ return {
670
+ nodes: dedupedDeterministic,
671
+ serendipityNodes: dedupedSerendipity,
672
+ triggeredNodes: allTriggered,
673
+ latencyMs: Date.now() - start,
674
+ metrics: {
675
+ semanticHits: pureSemanticHits,
676
+ mergedCount: scored.length,
677
+ selectedCount: dedupedDeterministic.length + dedupedSerendipity.length,
678
+ tier1Count: 0,
679
+ tier2Count: reservedCapabilities.length,
680
+ hybridSearchLatencyMs,
681
+ sparseVectorUsed: false,
682
+ embeddingProvider,
683
+ embeddingModel,
684
+ queryContext: contextQueryText,
685
+ topCandidates,
686
+ },
687
+ };
688
+ }
689
+
690
+ // ---------------------------------------------------------------------------
691
+ // Per-turn retrieval — mid-conversation injection
692
+ // ---------------------------------------------------------------------------
693
+
694
+ export interface TurnRetrievalOpts {
695
+ /** The assistant's last message content. */
696
+ assistantLastMessage: string;
697
+ /** The user's last message content. */
698
+ userLastMessage: string;
699
+ /** Raw content blocks from the user's last message (for image extraction). */
700
+ userLastMessageBlocks?: ContentBlock[];
701
+ scopeId: string;
702
+ config: AssistantConfig;
703
+ tracker: InContextTracker;
704
+ signal?: AbortSignal;
705
+ }
706
+
707
+ export interface TurnRetrievalResult {
708
+ /** New nodes to inject (not already in context). */
709
+ nodes: ScoredNode[];
710
+ /** Serendipity picks included in nodes. */
711
+ serendipityNodes: ScoredNode[];
712
+ /** Triggers that fired this turn. */
713
+ triggeredNodes: TriggeredResult[];
714
+ latencyMs: number;
715
+ metrics: RetrievalMetrics;
716
+ }
717
+
718
+ /**
719
+ * Lightweight per-turn retrieval. Budget: p90 < 1s.
720
+ *
721
+ * 1. Embed last exchange (assistant + user message)
722
+ * 2. Vector search + semantic trigger evaluation
723
+ * 3. Filter against InContextTracker
724
+ * 4. Score and threshold
725
+ */
726
+ export async function retrieveForTurn(
727
+ opts: TurnRetrievalOpts,
728
+ ): Promise<TurnRetrievalResult> {
729
+ const start = Date.now();
730
+ const now = new Date();
731
+ const nowMs = now.getTime();
732
+
733
+ let embeddingProvider: string | null = null;
734
+ let embeddingModel: string | null = null;
735
+ let hybridSearchLatencyMs = 0;
736
+
737
+ const ZERO_METRICS: RetrievalMetrics = {
738
+ semanticHits: 0,
739
+ mergedCount: 0,
740
+ selectedCount: 0,
741
+ tier1Count: 0,
742
+ tier2Count: 0,
743
+ hybridSearchLatencyMs: 0,
744
+ sparseVectorUsed: false,
745
+ embeddingProvider: null,
746
+ embeddingModel: null,
747
+ queryContext: null,
748
+ topCandidates: [],
749
+ };
750
+
751
+ // 1. Build query from last exchange
752
+ const queryText = [opts.assistantLastMessage, opts.userLastMessage]
753
+ .filter((m) => m.length > 0)
754
+ .join("\n\n");
755
+
756
+ // Image-to-image search: embed incoming user images as queries
757
+ // Runs before the text-empty early return so image-only turns are handled
758
+ const imageBlocks = (opts.userLastMessageBlocks ?? []).filter(
759
+ (b): b is ImageContent => b.type === "image",
760
+ );
761
+ const allCandidateIds = new Map<string, number>(); // nodeId → best score
762
+ const searchStart = Date.now();
763
+
764
+ if (imageBlocks.length > 0) {
765
+ try {
766
+ const isMultimodal = await selectedBackendSupportsMultimodal(opts.config);
767
+ if (isMultimodal) {
768
+ const maxImageQueries = 2;
769
+ for (
770
+ let i = 0;
771
+ i < Math.min(imageBlocks.length, maxImageQueries);
772
+ i++
773
+ ) {
774
+ const img = imageBlocks[i];
775
+ const imageInput = {
776
+ type: "image" as const,
777
+ data: Buffer.from(img.source.data, "base64"),
778
+ mimeType: img.source.media_type,
779
+ };
780
+ const imgResult = await embedWithRetry(opts.config, [imageInput], {
781
+ signal: opts.signal,
782
+ });
783
+ if (!embeddingProvider) {
784
+ embeddingProvider = imgResult.provider;
785
+ embeddingModel = imgResult.model;
786
+ }
787
+ const imgVector = imgResult.vectors[0];
788
+ if (imgVector) {
789
+ const imgResults = await searchGraphNodes(imgVector, 40, [
790
+ opts.scopeId,
791
+ ]);
792
+ for (const r of imgResults) {
793
+ const current = allCandidateIds.get(r.nodeId) ?? 0;
794
+ allCandidateIds.set(r.nodeId, Math.max(current, r.score));
795
+ }
796
+ }
797
+ }
798
+ }
799
+ } catch (err) {
800
+ log.warn({ err }, "Image-to-image search failed (non-fatal)");
801
+ }
802
+ }
803
+
804
+ if (queryText.trim().length === 0 && allCandidateIds.size === 0) {
805
+ return {
806
+ nodes: [],
807
+ serendipityNodes: [],
808
+ triggeredNodes: [],
809
+ latencyMs: Date.now() - start,
810
+ metrics: {
811
+ ...ZERO_METRICS,
812
+ hybridSearchLatencyMs:
813
+ imageBlocks.length > 0 ? Date.now() - searchStart : 0,
814
+ embeddingProvider,
815
+ embeddingModel,
816
+ queryContext: queryText || null,
817
+ },
818
+ };
819
+ }
820
+
821
+ // Chunk if too large (8k token ≈ 32k chars conservative estimate)
822
+ const maxQueryChars = 32_000;
823
+ const chunks: string[] = [];
824
+ if (queryText.trim().length === 0) {
825
+ // No text to embed — skip chunking (image results may still exist)
826
+ } else if (queryText.length <= maxQueryChars) {
827
+ chunks.push(queryText);
828
+ } else {
829
+ // Split at message boundary
830
+ if (opts.assistantLastMessage.length <= maxQueryChars) {
831
+ chunks.push(opts.assistantLastMessage);
832
+ }
833
+ if (opts.userLastMessage.length <= maxQueryChars) {
834
+ chunks.push(opts.userLastMessage);
835
+ } else {
836
+ // Split large message at paragraph boundaries
837
+ const paragraphs = opts.userLastMessage.split(/\n\n+/);
838
+ let current = "";
839
+ for (const p of paragraphs) {
840
+ if (current.length + p.length > maxQueryChars) {
841
+ if (current.length > 0) chunks.push(current);
842
+ current = p;
843
+ } else {
844
+ current += (current ? "\n\n" : "") + p;
845
+ }
846
+ }
847
+ if (current.length > 0) chunks.push(current);
848
+ }
849
+ }
850
+
851
+ // 2. Embed chunks and search (parallel)
852
+ let queryEmbeddings: number[][] = [];
853
+
854
+ if (chunks.length > 0) {
855
+ try {
856
+ const embedResults = await embedWithRetry(opts.config, chunks, {
857
+ signal: opts.signal,
858
+ });
859
+ embeddingProvider = embedResults.provider;
860
+ embeddingModel = embedResults.model;
861
+ queryEmbeddings = embedResults.vectors;
862
+
863
+ const searchPromises = queryEmbeddings.map((vec) =>
864
+ searchGraphNodes(vec, 40, [opts.scopeId]),
865
+ );
866
+ const searchResults = await Promise.all(searchPromises);
867
+
868
+ for (const results of searchResults) {
869
+ for (const r of results) {
870
+ const current = allCandidateIds.get(r.nodeId) ?? 0;
871
+ allCandidateIds.set(r.nodeId, Math.max(current, r.score));
872
+ }
873
+ }
874
+ hybridSearchLatencyMs = Date.now() - searchStart;
875
+ } catch (err) {
876
+ log.warn({ err }, "Embedding/search failed for turn retrieval");
877
+ if (allCandidateIds.size === 0) {
878
+ return {
879
+ nodes: [],
880
+ serendipityNodes: [],
881
+ triggeredNodes: [],
882
+ latencyMs: Date.now() - start,
883
+ metrics: {
884
+ ...ZERO_METRICS,
885
+ hybridSearchLatencyMs: Date.now() - searchStart,
886
+ embeddingProvider,
887
+ embeddingModel,
888
+ queryContext: queryText || null,
889
+ },
890
+ };
891
+ }
892
+ }
893
+ }
894
+
895
+ // Capture search latency for image-only searches (text path sets it inside its try block)
896
+ if (hybridSearchLatencyMs === 0 && allCandidateIds.size > 0) {
897
+ hybridSearchLatencyMs = Date.now() - searchStart;
898
+ }
899
+
900
+ // Snapshot pure vector-search results before triggers inflate the set
901
+ const pureSemanticHits = allCandidateIds.size;
902
+
903
+ // 3. Evaluate semantic triggers
904
+ const semanticTriggers = getActiveTriggersByType("semantic", opts.scopeId);
905
+ const triggeredSemantic =
906
+ queryEmbeddings.length > 0
907
+ ? evaluateSemanticTriggers(semanticTriggers, queryEmbeddings[0])
908
+ : [];
909
+
910
+ // Add triggered nodes to candidates
911
+ for (const t of triggeredSemantic) {
912
+ if (!allCandidateIds.has(t.trigger.nodeId)) {
913
+ allCandidateIds.set(t.trigger.nodeId, 0);
914
+ }
915
+ }
916
+
917
+ const triggerBoostMap = new Map<string, number>();
918
+ for (const t of triggeredSemantic) {
919
+ const current = triggerBoostMap.get(t.trigger.nodeId) ?? 0;
920
+ triggerBoostMap.set(t.trigger.nodeId, Math.max(current, t.boost));
921
+ }
922
+
923
+ // 4. Filter against InContextTracker
924
+ const newCandidateIds = [...allCandidateIds.keys()].filter(
925
+ (id) => !opts.tracker.isInContext(id),
926
+ );
927
+
928
+ if (newCandidateIds.length === 0) {
929
+ return {
930
+ nodes: [],
931
+ serendipityNodes: [],
932
+ triggeredNodes: triggeredSemantic,
933
+ latencyMs: Date.now() - start,
934
+ metrics: {
935
+ ...ZERO_METRICS,
936
+ semanticHits: pureSemanticHits,
937
+ hybridSearchLatencyMs,
938
+ embeddingProvider,
939
+ embeddingModel,
940
+ queryContext: queryText || null,
941
+ },
942
+ };
943
+ }
944
+
945
+ // 5. Hydrate and score
946
+ const nodes = getNodesByIds(newCandidateIds);
947
+ const scored: ScoredNode[] = [];
948
+ const capabilityCandidates: { node: MemoryNode; sim: number }[] = [];
949
+
950
+ for (const node of nodes) {
951
+ if (node.fidelity === "gone") continue;
952
+ // Capability nodes (auto-seeded skills/CLI) are excluded from the general
953
+ // scoring pool — they compete in the dedicated procedural reserve below.
954
+ if (isCapabilityNode(node)) {
955
+ capabilityCandidates.push({
956
+ node,
957
+ sim: allCandidateIds.get(node.id) ?? 0,
958
+ });
959
+ continue;
960
+ }
961
+
962
+ const semanticSim = allCandidateIds.get(node.id) ?? 0;
963
+ const effectiveSig = computeEffectiveSignificance(node, nowMs);
964
+ const temporal = computeTemporalBoost(node, now);
965
+ const triggerBoost = triggerBoostMap.get(node.id) ?? 0;
966
+
967
+ const normalizedTemporal = (temporal + 1) / 2;
968
+ const recency = computeRecencyBoost(node, nowMs);
969
+
970
+ scored.push(
971
+ scoreCandidate(
972
+ node,
973
+ {
974
+ semanticSimilarity: semanticSim,
975
+ effectiveSignificance: effectiveSig,
976
+ emotionalIntensity: node.emotionalCharge.intensity,
977
+ temporalBoost: normalizedTemporal,
978
+ recencyBoost: recency,
979
+ triggerBoost,
980
+ activationBoost: 0, // Skip activation spreading for per-turn (latency)
981
+ },
982
+ PER_TURN_WEIGHTS,
983
+ ),
984
+ );
985
+ }
986
+
987
+ // 5b. Reserve slots for capability nodes (skills/CLI).
988
+ // Sourced from vector search candidates — only semantically relevant
989
+ // capabilities compete for reserved slots.
990
+ const PROCEDURAL_RESERVE = 3;
991
+
992
+ const proceduralCandidates = capabilityCandidates
993
+ .filter(({ node }) => !opts.tracker.isInContext(node.id))
994
+ .sort((a, b) => b.sim - a.sim);
995
+
996
+ const seenProcCapIds = new Set<string>();
997
+ const rankedProcedural = proceduralCandidates
998
+ .filter(({ node }) => {
999
+ const match = node.content.match(
1000
+ /^skill:(\S+)\n|^cli:(\S+)\n|^\s*The ".*?" skill \(([^)]+)\)|^\s*The "assistant (\S+)" CLI command/,
1001
+ );
1002
+ const capId = match?.[1] ?? match?.[2] ?? match?.[3] ?? match?.[4];
1003
+ if (capId) {
1004
+ if (seenProcCapIds.has(capId)) return false;
1005
+ seenProcCapIds.add(capId);
1006
+ }
1007
+ return true;
1008
+ })
1009
+ .slice(0, PROCEDURAL_RESERVE);
1010
+
1011
+ const proceduralScored: ScoredNode[] = rankedProcedural.map(({ node, sim }) =>
1012
+ scoreCandidate(
1013
+ node,
1014
+ {
1015
+ semanticSimilarity: sim,
1016
+ effectiveSignificance: computeEffectiveSignificance(node, nowMs),
1017
+ emotionalIntensity: node.emotionalCharge.intensity,
1018
+ temporalBoost: (computeTemporalBoost(node, now) + 1) / 2,
1019
+ recencyBoost: computeRecencyBoost(node, nowMs),
1020
+ triggerBoost: triggerBoostMap.get(node.id) ?? 0,
1021
+ activationBoost: 0,
1022
+ },
1023
+ PER_TURN_WEIGHTS,
1024
+ ),
1025
+ );
1026
+
1027
+ const PROCEDURAL_SIM_FLOOR = 0.15;
1028
+ const proceduralInjected = proceduralScored.filter(
1029
+ (s) => s.scoreBreakdown.semanticSimilarity >= PROCEDURAL_SIM_FLOOR,
1030
+ );
1031
+ const proceduralIds = new Set(proceduralInjected.map((s) => s.node.id));
1032
+
1033
+ // Sort and apply threshold — pull a wider pool for dedup, then trim
1034
+ scored.sort((a, b) => b.score - a.score);
1035
+ const INJECTION_THRESHOLD = 0.3;
1036
+ const PRE_DEDUP_POOL = 20;
1037
+ const MAX_INJECTED = 4;
1038
+ const pool = scored
1039
+ .filter((s) => s.score >= INJECTION_THRESHOLD)
1040
+ .slice(0, PRE_DEDUP_POOL);
1041
+
1042
+ // Dedup + rerank with a fast model when the pool is large enough to warrant it
1043
+ let injected: ScoredNode[];
1044
+ let llmDedupApplied = false;
1045
+ if (pool.length > MAX_INJECTED) {
1046
+ const result = await dedupForTurn(pool, MAX_INJECTED, opts.userLastMessage);
1047
+ injected = result.nodes;
1048
+ llmDedupApplied = result.llmApplied;
1049
+ } else {
1050
+ injected = pool;
1051
+ }
1052
+
1053
+ // Remove procedural-reserved nodes from general set to avoid double-counting
1054
+ const generalInjected = injected.filter((s) => !proceduralIds.has(s.node.id));
1055
+
1056
+ // Backfill vacated general slots from the remaining pool so we always
1057
+ // return up to MAX_INJECTED general memories when eligible candidates exist.
1058
+ // Only skip backfill when LLM dedup genuinely ran — it intentionally rejected
1059
+ // items as duplicates/irrelevant. When dedupForTurn fell back to a plain
1060
+ // top-N slice (no provider, tool call failure), backfill is still appropriate.
1061
+ if (generalInjected.length < MAX_INJECTED && !llmDedupApplied) {
1062
+ const usedIds = new Set([
1063
+ ...generalInjected.map((s) => s.node.id),
1064
+ ...proceduralIds,
1065
+ ]);
1066
+ const backfillCandidates = pool.filter((s) => !usedIds.has(s.node.id));
1067
+ const needed = MAX_INJECTED - generalInjected.length;
1068
+ for (let i = 0; i < Math.min(needed, backfillCandidates.length); i++) {
1069
+ generalInjected.push(backfillCandidates[i]);
1070
+ }
1071
+ }
1072
+
1073
+ const allDeterministic = [...generalInjected, ...proceduralInjected];
1074
+ const deterministicIds = new Set(allDeterministic.map((n) => n.node.id));
1075
+
1076
+ // Reserve 1 serendipity slot from scored candidates not in the deterministic set
1077
+ const serendipityPool = scored.filter(
1078
+ (s) => s.score >= INJECTION_THRESHOLD && !deterministicIds.has(s.node.id),
1079
+ );
1080
+ const serendipityPicks = sampleSerendipity(serendipityPool, 1);
1081
+ const allInjected = [...allDeterministic, ...serendipityPicks];
1082
+
1083
+ const TOP_N = 20;
1084
+ const topCandidates = scored.slice(0, TOP_N).map((s) => ({
1085
+ nodeId: s.node.id,
1086
+ type: s.node.type,
1087
+ score: s.score,
1088
+ semanticSimilarity: s.scoreBreakdown.semanticSimilarity,
1089
+ recencyBoost: s.scoreBreakdown.recencyBoost,
1090
+ }));
1091
+
1092
+ return {
1093
+ nodes: allInjected,
1094
+ serendipityNodes: serendipityPicks,
1095
+ triggeredNodes: triggeredSemantic,
1096
+ latencyMs: Date.now() - start,
1097
+ metrics: {
1098
+ semanticHits: pureSemanticHits,
1099
+ mergedCount: scored.length,
1100
+ selectedCount: allInjected.length,
1101
+ tier1Count: 0,
1102
+ tier2Count: 0,
1103
+ hybridSearchLatencyMs,
1104
+ sparseVectorUsed: false,
1105
+ embeddingProvider,
1106
+ embeddingModel,
1107
+ queryContext: queryText || null,
1108
+ topCandidates,
1109
+ },
1110
+ };
1111
+ }