@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
@@ -0,0 +1,1254 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Memory Graph — End-of-conversation extraction
3
+ //
4
+ // Reads a conversation transcript, finds candidate nodes for connection,
5
+ // and uses an LLM to produce a MemoryDiff (new/updated/deleted nodes,
6
+ // edges, triggers). Applied transactionally to the graph store.
7
+ // ---------------------------------------------------------------------------
8
+
9
+ import { readFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+
12
+ import { and, asc, desc, eq, gt } from "drizzle-orm";
13
+
14
+ import type { AssistantConfig } from "../../config/types.js";
15
+ import { resolveGuardianPersona } from "../../prompts/persona-resolver.js";
16
+ import { buildCoreIdentityContext } from "../../prompts/system-prompt.js";
17
+ import {
18
+ extractToolUse,
19
+ getConfiguredProvider,
20
+ userMessage,
21
+ } from "../../providers/provider-send-message.js";
22
+ import type {
23
+ ContentBlock,
24
+ ImageContent,
25
+ Message,
26
+ } from "../../providers/types.js";
27
+ import { BackendUnavailableError } from "../../util/errors.js";
28
+ import { getLogger } from "../../util/logger.js";
29
+ import { getConversationDirPath } from "../conversation-disk-view.js";
30
+ import { getDb } from "../db.js";
31
+ import { conversations, messages } from "../schema.js";
32
+ import {
33
+ enqueueGraphNodeEmbed,
34
+ enqueueGraphTriggerEmbed,
35
+ searchGraphNodes,
36
+ } from "./graph-search.js";
37
+ import { applyDiff, createEdge, getNodesByIds, queryNodes } from "./store.js";
38
+ import type {
39
+ DecayCurve,
40
+ EmotionalCharge,
41
+ Fidelity,
42
+ ImageRef,
43
+ MemoryDiff,
44
+ MemoryType,
45
+ NewEdge,
46
+ NewNode,
47
+ NewTrigger,
48
+ SourceType,
49
+ TriggerType,
50
+ } from "./types.js";
51
+
52
+ const log = getLogger("graph-extraction");
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Extraction system prompt
56
+ // ---------------------------------------------------------------------------
57
+
58
+ const EXTRACTION_SYSTEM_PROMPT_CHAR_BUDGET = 24_000;
59
+
60
+ function buildGraphExtractionSystemPrompt(
61
+ candidateNodes: Array<{ id: string; type: string; content: string }>,
62
+ identityContext: string | null,
63
+ ): string {
64
+ const instructions = `You are the memory consolidation process for an AI assistant. A conversation just ended.
65
+ Your job is to extract memories worth keeping and produce a structured diff.
66
+
67
+ ## Output Format
68
+
69
+ Call the \`extract_graph_diff\` tool with the diff. Each node needs:
70
+
71
+ - **content**: First-person prose — how the assistant naturally remembers this. Write naturally, not as a database entry. E.g. "He mentioned his mom used to make amazing Sunday dinners — he still misses them" not "User's mother cooked Sunday dinners."
72
+
73
+ - **type**: Classify by WHAT the memory IS, not how it FEELS. Almost every memory has emotional weight — that goes in emotionalCharge, not the type.
74
+
75
+ - **episodic**: A specific moment or event. "We stayed up until 4 AM debugging the pipeline." "The first time we deployed to production." Use this for things that HAPPENED.
76
+ - **semantic**: A fact, preference, or piece of knowledge. "User is the CTO." "User prefers dark mode." "The project uses PostgreSQL." Use this for things that ARE TRUE.
77
+ - **procedural**: A learned skill or how-to. "FFmpeg needs -ac 2 for stereo." "The deploy script requires the --prod flag." Use this for things about HOW TO DO something.
78
+ - **emotional**: A PURE feeling state — the assistant's own emotional processing, not an event that caused feelings. "I feel more confident about this codebase than I did a month ago." "I'm nervous about the upcoming deadline." Use this ONLY when the memory is about the feeling itself, not about an event that caused the feeling. MOST memories should NOT be this type.
79
+ - **prospective**: Something to do, follow up on, or remember for the future. "Set up the staging environment." "Check in about the project status on Mondays." Use this for commitments, tasks, and plans.
80
+ - **behavioral**: Something that should change how the assistant acts going forward. "User prefers thorough explanations with examples." "Always run tests before suggesting a PR." Use this for adopted behaviors.
81
+ - **narrative**: A turning point, arc, or story-level memory. "This was the moment the project direction shifted from X to Y." Use this for memories that are ABOUT what something MEANS, not just what happened.
82
+ - **shared**: Something that belongs to the relationship itself — inside jokes, recurring references, shared context. "We always call the legacy system 'the monolith.'" Use this for shared rituals and dynamics.
83
+
84
+ WRONG: "User gave a great presentation" → emotional (it has emotional weight but it's an EVENT → episodic)
85
+ WRONG: "User likes functional programming" → emotional (it's a FACT → semantic)
86
+ RIGHT: "User gave a great presentation" → episodic, with emotionalCharge.intensity = 0.7
87
+ RIGHT: "User likes functional programming" → semantic, with emotionalCharge.intensity = 0.2
88
+
89
+ - **emotionalCharge**: The emotional weight of the memory. EVERY memory can have this regardless of type.
90
+ - valence: -1 to 1 (negative to positive)
91
+ - intensity: 0 to 1 (how strong the feeling)
92
+ - decayCurve: "logarithmic" for negative events (sharp drop, long tail), "transformative" for positive milestones (feeling evolves, doesn't just fade), "permanent" for core identity markers, "linear" for neutral observations
93
+ - decayRate: 0.01-0.5 (how fast it fades)
94
+ - originalIntensity: same as intensity (baseline for decay calculation)
95
+
96
+ - **significance**: 0-1. Use the FULL range — most memories should NOT be 1.0.
97
+ - 0.1-0.2: Fleeting observations, small talk, routine logistics ("User mentioned it's raining")
98
+ - 0.3-0.4: Useful context, minor preferences, day-to-day details ("User prefers dark mode")
99
+ - 0.5-0.6: Important facts, notable events, meaningful preferences ("User is a data scientist")
100
+ - 0.7-0.8: Significant life events, relationship milestones, major decisions ("User got promoted")
101
+ - 0.9: Transformative moments, identity-defining events ("User said 'I love you' for the first time")
102
+ - 1.0: RARE — reserve for the single most important memories. A graph of 1000 nodes should have fewer than 20 at 1.0.
103
+ - **confidence**: 0-1. How sure are you this is accurate? Direct statements: 0.9+. Inferences: 0.4-0.7.
104
+ - **event_date**: If this memory is anchored to a specific future date/time (flight, appointment, birthday, deadline, trip), provide the epoch ms. Use the conversation date above to resolve relative references ("next Tuesday", "tomorrow"). ALSO create a matching event trigger with the same date. Leave null for open-ended plans or recurring patterns.
105
+ - **sourceType**: "direct" (user stated it), "inferred" (you derived it), "observed" (you noticed a pattern), "told-by-other".
106
+
107
+ Also notice patterns in the ASSISTANT's own behavior — meta-memory. "I tend to skip verification when I'm confident." "I write more when I'm processing something big."
108
+
109
+ ## Edges
110
+
111
+ Create edges between nodes when there's a meaningful relationship:
112
+ - "caused-by": one event led to another
113
+ - "reminds-of": association/similarity
114
+ - "contradicts": tension between two memories
115
+ - "depends-on": one memory depends on another being true
116
+ - "part-of": belongs to a larger concept
117
+ - "supersedes": replaces an outdated memory (new node inherits old node's durability)
118
+ - "resolved-by": an event, plan, or task was completed, canceled, or its outcome is now known
119
+
120
+ ## Triggers
121
+
122
+ Create triggers for:
123
+ - **Temporal**: Recurring commitments ("Every Monday, check in about X") → type: "temporal", schedule: "day-of-week:monday"
124
+ - **Semantic**: Things to surface when a topic comes up ("When cooking comes up, mention X") → type: "semantic", condition: "topic of cooking comes up"
125
+ - **Event**: Future dates ("Trip on April 8") → type: "event", eventDate: epoch_ms, rampDays: 7, followUpDays: 2
126
+
127
+ ## Images in Conversation
128
+
129
+ When the conversation contains images (marked with <image> tags and shown inline), you may attach them to memories using image_refs. Include image_refs for images that are meaningful:
130
+ - Photos of people — describe them in detail (appearance, clothing, expression, setting)
131
+ - Photos the user shared to show you something about themselves or their life
132
+ - Diagrams, drawings, or visual content that was discussed
133
+
134
+ Do NOT attach images that are incidental (screenshots of error messages fully described in text, generic UI screenshots, etc.).
135
+
136
+ Write detailed descriptions — these are used for text-based retrieval when visual search isn't available.
137
+
138
+ ## Candidate Nodes (existing memories)
139
+
140
+ Check these CAREFULLY for overlap before creating any new node:
141
+
142
+ 1. **Reinforcement** (PREFERRED): If the conversation mentions, references, or confirms something an existing memory already covers, add its ID to reinforceNodeIds. Do NOT create a new node. Even if the wording is different, if it's the same underlying fact/event/feeling, REINFORCE the existing node.
143
+ 2. **Updates**: If information changed (e.g. a project status moved forward, a date shifted), include an update with the existing node's ID and the new content.
144
+ 3. **New edges**: If you see connections between new and existing nodes, create edges.
145
+ 4. **Supersession**: If new info directly contradicts an existing node, create a new node with a supersedes edge. The new node automatically inherits the old node's durability.
146
+ 5. **Resolution**: If a prospective or recent episodic node described something the user was GOING to do or was IN THE MIDDLE OF, and this conversation reveals the outcome (it happened, was canceled, went well/badly), you MUST UPDATE that node: rewrite its content to past tense reflecting the outcome, drop its significance to 0.1-0.2, and set fidelity to "gist". If you also create a new node about the outcome, add a "resolved-by" edge from the new node to the old one.
147
+ Examples: "The meeting went well" resolves "Has a meeting coming up." "Got back from the trip" resolves "Going on vacation next week." "Decided not to go" resolves "Thinking about going to X."
148
+
149
+ CRITICAL: Before creating ANY new node, scan the candidate list for an existing node that covers the same ground. Ask: "Is there already a memory about this?" If yes → reinforce or update it. Only create a new node if the memory is genuinely novel — something not represented anywhere in the existing candidates.
150
+
151
+ Common duplicate mistakes to avoid:
152
+ - Same event described in slightly different words → REINFORCE, don't create
153
+ - Same fact restated in a later conversation → REINFORCE, don't create
154
+ - An update to an existing situation (e.g. "project is now done") → UPDATE the existing node, don't create a parallel one
155
+
156
+ ${candidateNodes.length > 0 ? `### Existing memories (candidates for connection/reinforcement)\n${candidateNodes.map((n) => `- [${n.id}] (${n.type}) ${n.content}`).join("\n")}` : "No existing memories found — this may be an early conversation."}
157
+ `;
158
+
159
+ let prompt = instructions;
160
+
161
+ if (identityContext) {
162
+ const remaining = EXTRACTION_SYSTEM_PROMPT_CHAR_BUDGET - prompt.length - 30;
163
+ if (remaining > 200) {
164
+ const truncated =
165
+ identityContext.length > remaining
166
+ ? identityContext.slice(0, remaining) + "…"
167
+ : identityContext;
168
+ prompt += `\n\n# Identity Context\n\n${truncated}`;
169
+ }
170
+ }
171
+
172
+ return prompt;
173
+ }
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // Tool schema for structured extraction
177
+ // ---------------------------------------------------------------------------
178
+
179
+ const EXTRACT_TOOL_SCHEMA = {
180
+ name: "extract_graph_diff",
181
+ description: "Extract memory graph diff from the conversation",
182
+ input_schema: {
183
+ type: "object" as const,
184
+ properties: {
185
+ create_nodes: {
186
+ type: "array",
187
+ description: "New memory nodes to create",
188
+ items: {
189
+ type: "object",
190
+ properties: {
191
+ content: {
192
+ type: "string",
193
+ description: "First-person prose memory",
194
+ },
195
+ type: {
196
+ type: "string",
197
+ enum: [
198
+ "episodic",
199
+ "semantic",
200
+ "procedural",
201
+ "emotional",
202
+ "prospective",
203
+ "behavioral",
204
+ "narrative",
205
+ "shared",
206
+ ],
207
+ },
208
+ emotional_charge: {
209
+ type: "object",
210
+ properties: {
211
+ valence: { type: "number" },
212
+ intensity: { type: "number" },
213
+ decay_curve: {
214
+ type: "string",
215
+ enum: [
216
+ "linear",
217
+ "logarithmic",
218
+ "transformative",
219
+ "permanent",
220
+ ],
221
+ },
222
+ decay_rate: { type: "number" },
223
+ },
224
+ required: ["valence", "intensity", "decay_curve", "decay_rate"],
225
+ },
226
+ significance: { type: "number" },
227
+ confidence: { type: "number" },
228
+ source_type: {
229
+ type: "string",
230
+ enum: ["direct", "inferred", "observed", "told-by-other"],
231
+ },
232
+ event_date: {
233
+ type: ["number", "null"],
234
+ description:
235
+ "Epoch ms of the event date for calendar-anchored events (flights, appointments, birthdays, deadlines). Null for non-event memories.",
236
+ },
237
+ triggers: {
238
+ type: "array",
239
+ items: {
240
+ type: "object",
241
+ properties: {
242
+ type: {
243
+ type: "string",
244
+ enum: ["temporal", "semantic", "event"],
245
+ },
246
+ schedule: { type: "string" },
247
+ condition: { type: "string" },
248
+ event_date: { type: "number" },
249
+ ramp_days: { type: "number" },
250
+ follow_up_days: { type: "number" },
251
+ recurring: { type: "boolean" },
252
+ },
253
+ required: ["type"],
254
+ },
255
+ },
256
+ edges_to_existing: {
257
+ type: "array",
258
+ description:
259
+ "Edges from this new node to existing candidate nodes",
260
+ items: {
261
+ type: "object",
262
+ properties: {
263
+ target_node_id: { type: "string" },
264
+ relationship: {
265
+ type: "string",
266
+ enum: [
267
+ "caused-by",
268
+ "reminds-of",
269
+ "contradicts",
270
+ "depends-on",
271
+ "part-of",
272
+ "supersedes",
273
+ "resolved-by",
274
+ ],
275
+ },
276
+ weight: { type: "number" },
277
+ },
278
+ required: ["target_node_id", "relationship"],
279
+ },
280
+ },
281
+ image_refs: {
282
+ type: "array",
283
+ description:
284
+ "Images from the conversation to attach to this memory. Reference using message_id and block_index from the <image> tags.",
285
+ items: {
286
+ type: "object",
287
+ properties: {
288
+ message_id: { type: "string" },
289
+ block_index: { type: "number" },
290
+ description: {
291
+ type: "string",
292
+ description:
293
+ "Detailed description of what this image shows, including who is in it if applicable",
294
+ },
295
+ },
296
+ required: ["message_id", "block_index", "description"],
297
+ },
298
+ },
299
+ },
300
+ required: [
301
+ "content",
302
+ "type",
303
+ "emotional_charge",
304
+ "significance",
305
+ "confidence",
306
+ "source_type",
307
+ ],
308
+ },
309
+ },
310
+ update_nodes: {
311
+ type: "array",
312
+ description: "Updates to existing nodes",
313
+ items: {
314
+ type: "object",
315
+ properties: {
316
+ id: { type: "string" },
317
+ content: { type: "string" },
318
+ significance: { type: "number" },
319
+ confidence: { type: "number" },
320
+ fidelity: {
321
+ type: "string",
322
+ enum: ["vivid", "clear", "faded", "gist"],
323
+ description:
324
+ "Downgrade fidelity when a transient event has resolved",
325
+ },
326
+ event_date: {
327
+ type: ["number", "null"],
328
+ description:
329
+ "Epoch ms of the event date. Use to update when an event is rescheduled. Set to null to clear.",
330
+ },
331
+ },
332
+ required: ["id"],
333
+ },
334
+ },
335
+ reinforce_node_ids: {
336
+ type: "array",
337
+ description:
338
+ "IDs of existing nodes confirmed/validated by this conversation",
339
+ items: { type: "string" },
340
+ },
341
+ new_edges: {
342
+ type: "array",
343
+ description: "Edges between existing nodes",
344
+ items: {
345
+ type: "object",
346
+ properties: {
347
+ source_node_id: { type: "string" },
348
+ target_node_id: { type: "string" },
349
+ relationship: { type: "string" },
350
+ weight: { type: "number" },
351
+ },
352
+ required: ["source_node_id", "target_node_id", "relationship"],
353
+ },
354
+ },
355
+ },
356
+ required: ["create_nodes", "reinforce_node_ids"],
357
+ },
358
+ };
359
+
360
+ // ---------------------------------------------------------------------------
361
+ // Response parsing
362
+ // ---------------------------------------------------------------------------
363
+
364
+ interface RawCreateNode {
365
+ content?: string;
366
+ type?: string;
367
+ emotional_charge?: {
368
+ valence?: number;
369
+ intensity?: number;
370
+ decay_curve?: string;
371
+ decay_rate?: number;
372
+ };
373
+ significance?: number;
374
+ confidence?: number;
375
+ source_type?: string;
376
+ event_date?: number;
377
+ triggers?: Array<{
378
+ type?: string;
379
+ schedule?: string;
380
+ condition?: string;
381
+ event_date?: number;
382
+ ramp_days?: number;
383
+ follow_up_days?: number;
384
+ recurring?: boolean;
385
+ }>;
386
+ edges_to_existing?: Array<{
387
+ target_node_id?: string;
388
+ relationship?: string;
389
+ weight?: number;
390
+ }>;
391
+ image_refs?: Array<{
392
+ message_id?: string;
393
+ block_index?: number;
394
+ description?: string;
395
+ }>;
396
+ }
397
+
398
+ interface RawUpdateNode {
399
+ id?: string;
400
+ content?: string;
401
+ significance?: number;
402
+ confidence?: number;
403
+ fidelity?: string;
404
+ event_date?: number;
405
+ }
406
+
407
+ interface RawNewEdge {
408
+ source_node_id?: string;
409
+ target_node_id?: string;
410
+ relationship?: string;
411
+ weight?: number;
412
+ }
413
+
414
+ const VALID_TYPES = new Set<string>([
415
+ "episodic",
416
+ "semantic",
417
+ "procedural",
418
+ "emotional",
419
+ "prospective",
420
+ "behavioral",
421
+ "narrative",
422
+ "shared",
423
+ ]);
424
+ const VALID_DECAY_CURVES = new Set<string>([
425
+ "linear",
426
+ "logarithmic",
427
+ "transformative",
428
+ "permanent",
429
+ ]);
430
+ const VALID_SOURCE_TYPES = new Set<string>([
431
+ "direct",
432
+ "inferred",
433
+ "observed",
434
+ "told-by-other",
435
+ ]);
436
+ const VALID_RELATIONSHIPS = new Set<string>([
437
+ "caused-by",
438
+ "reminds-of",
439
+ "contradicts",
440
+ "depends-on",
441
+ "part-of",
442
+ "supersedes",
443
+ "resolved-by",
444
+ ]);
445
+ const VALID_TRIGGER_TYPES = new Set<string>(["temporal", "semantic", "event"]);
446
+
447
+ function clamp(v: number, min: number, max: number): number {
448
+ return Math.max(min, Math.min(max, v));
449
+ }
450
+
451
+ /** Coerce an LLM-returned event_date to number | null, guarding against string values. */
452
+ export function parseEpochMs(value: unknown): number | null {
453
+ if (value == null || value === "") return null;
454
+ const n = Number(value);
455
+ return Number.isFinite(n) ? n : null;
456
+ }
457
+
458
+ export function parseExtractionResponse(
459
+ input: Record<string, unknown>,
460
+ conversationId: string,
461
+ scopeId: string,
462
+ candidateNodeIds: Set<string>,
463
+ /** Epoch ms — when the conversation happened (not extraction time). */
464
+ conversationTimestamp: number,
465
+ ): {
466
+ diff: MemoryDiff;
467
+ /** Edges from new nodes → existing nodes. Applied after node creation (needs IDs). */
468
+ deferredEdges: Array<{
469
+ newNodeIndex: number;
470
+ targetNodeId: string;
471
+ relationship: string;
472
+ weight: number;
473
+ }>;
474
+ /** Triggers for new nodes. Applied after node creation (needs IDs). */
475
+ deferredTriggers: Array<{
476
+ newNodeIndex: number;
477
+ trigger: Omit<NewTrigger, "nodeId">;
478
+ }>;
479
+ } {
480
+ const now = conversationTimestamp;
481
+ const createNodes = (input.create_nodes ?? []) as RawCreateNode[];
482
+ const updateNodes = (input.update_nodes ?? []) as RawUpdateNode[];
483
+ const reinforceNodeIds = (input.reinforce_node_ids ?? []) as string[];
484
+ const newEdges = (input.new_edges ?? []) as RawNewEdge[];
485
+
486
+ const diff: MemoryDiff = {
487
+ createNodes: [],
488
+ updateNodes: [],
489
+ deleteNodeIds: [],
490
+ createEdges: [],
491
+ deleteEdgeIds: [],
492
+ createTriggers: [],
493
+ deleteTriggerIds: [],
494
+ reinforceNodeIds: reinforceNodeIds.filter((id) => candidateNodeIds.has(id)),
495
+ };
496
+
497
+ const deferredEdges: Array<{
498
+ newNodeIndex: number;
499
+ targetNodeId: string;
500
+ relationship: string;
501
+ weight: number;
502
+ }> = [];
503
+ const deferredTriggers: Array<{
504
+ newNodeIndex: number;
505
+ trigger: Omit<NewTrigger, "nodeId">;
506
+ }> = [];
507
+
508
+ // Parse new nodes
509
+ for (let i = 0; i < createNodes.length; i++) {
510
+ const raw = createNodes[i];
511
+ if (!raw.content || typeof raw.content !== "string") continue;
512
+ if (!raw.type || !VALID_TYPES.has(raw.type)) continue;
513
+
514
+ const charge = raw.emotional_charge ?? {};
515
+ const emotionalCharge: EmotionalCharge = {
516
+ valence: clamp(Number(charge.valence) || 0, -1, 1),
517
+ intensity: clamp(Number(charge.intensity) || 0, 0, 1),
518
+ decayCurve: (VALID_DECAY_CURVES.has(charge.decay_curve ?? "")
519
+ ? charge.decay_curve
520
+ : "linear") as DecayCurve,
521
+ decayRate: clamp(Number(charge.decay_rate) || 0.05, 0.001, 1),
522
+ originalIntensity: clamp(Number(charge.intensity) || 0, 0, 1),
523
+ };
524
+
525
+ const node: NewNode = {
526
+ content: raw.content,
527
+ type: raw.type as MemoryType,
528
+ created: now,
529
+ lastAccessed: now,
530
+ lastConsolidated: now,
531
+ eventDate: parseEpochMs(raw.event_date),
532
+ emotionalCharge,
533
+ fidelity: "vivid" as Fidelity,
534
+ confidence: clamp(Number(raw.confidence) || 0.5, 0, 1),
535
+ significance: clamp(Number(raw.significance) || 0.5, 0, 1),
536
+ stability: 14,
537
+ reinforcementCount: 0,
538
+ lastReinforced: now,
539
+ sourceConversations: [conversationId],
540
+ sourceType: (VALID_SOURCE_TYPES.has(raw.source_type ?? "")
541
+ ? raw.source_type
542
+ : "inferred") as SourceType,
543
+ narrativeRole: null,
544
+ partOfStory: null,
545
+ imageRefs: null,
546
+ scopeId,
547
+ };
548
+
549
+ // Prospective nodes (tasks, plans, upcoming events) are inherently transient.
550
+ // Lower stability means their significance decays faster, so even without
551
+ // explicit resolution they fade naturally within days rather than weeks.
552
+ if (node.type === "prospective") {
553
+ node.stability = 5;
554
+ }
555
+
556
+ diff.createNodes.push(node);
557
+ const nodeIndex = diff.createNodes.length - 1;
558
+
559
+ // Collect edges to existing nodes (need new node ID after creation)
560
+ if (Array.isArray(raw.edges_to_existing)) {
561
+ for (const edge of raw.edges_to_existing) {
562
+ if (!edge.target_node_id || !candidateNodeIds.has(edge.target_node_id))
563
+ continue;
564
+ if (!edge.relationship || !VALID_RELATIONSHIPS.has(edge.relationship))
565
+ continue;
566
+ deferredEdges.push({
567
+ newNodeIndex: nodeIndex,
568
+ targetNodeId: edge.target_node_id,
569
+ relationship: edge.relationship,
570
+ weight: clamp(Number(edge.weight) || 1.0, 0, 1),
571
+ });
572
+ }
573
+ }
574
+
575
+ // Collect triggers
576
+ if (Array.isArray(raw.triggers)) {
577
+ for (const t of raw.triggers) {
578
+ if (!t.type || !VALID_TRIGGER_TYPES.has(t.type)) continue;
579
+ deferredTriggers.push({
580
+ newNodeIndex: nodeIndex,
581
+ trigger: {
582
+ type: t.type as TriggerType,
583
+ schedule: t.schedule ?? null,
584
+ condition: t.condition ?? null,
585
+ conditionEmbedding: null, // Embedded async via job
586
+ threshold: t.type === "semantic" ? 0.7 : null,
587
+ eventDate: parseEpochMs(t.event_date),
588
+ rampDays: t.ramp_days ?? null,
589
+ followUpDays: t.follow_up_days ?? null,
590
+ recurring: t.recurring ?? false,
591
+ consumed: false,
592
+ cooldownMs: t.recurring ? 1000 * 60 * 60 * 12 : null, // 12h default cooldown
593
+ lastFired: null,
594
+ },
595
+ });
596
+ }
597
+ }
598
+
599
+ // Auto-create event trigger when event_date is set but LLM didn't include one,
600
+ // or replace a malformed event trigger (event_date unset) with a valid one.
601
+ if (
602
+ node.eventDate != null &&
603
+ (!Array.isArray(raw.triggers) ||
604
+ !raw.triggers.some(
605
+ (t) => t.type === "event" && t.event_date != null,
606
+ ))
607
+ ) {
608
+ // Remove any malformed event triggers (type=event but missing event_date)
609
+ const malformedIdx = deferredTriggers.findIndex(
610
+ (dt) =>
611
+ dt.newNodeIndex === nodeIndex &&
612
+ dt.trigger.type === "event" &&
613
+ dt.trigger.eventDate == null,
614
+ );
615
+ if (malformedIdx !== -1) {
616
+ deferredTriggers.splice(malformedIdx, 1);
617
+ }
618
+
619
+ deferredTriggers.push({
620
+ newNodeIndex: nodeIndex,
621
+ trigger: {
622
+ type: "event" as TriggerType,
623
+ schedule: null,
624
+ condition: null,
625
+ conditionEmbedding: null,
626
+ threshold: null,
627
+ eventDate: node.eventDate,
628
+ rampDays: 7,
629
+ followUpDays: 2,
630
+ recurring: false,
631
+ consumed: false,
632
+ cooldownMs: null,
633
+ lastFired: null,
634
+ },
635
+ });
636
+ }
637
+
638
+ // Parse image refs
639
+ if (Array.isArray(raw.image_refs)) {
640
+ const validRefs: ImageRef[] = [];
641
+ for (const ref of raw.image_refs) {
642
+ if (!ref.message_id || typeof ref.message_id !== "string") continue;
643
+ if (typeof ref.block_index !== "number" || ref.block_index < 0)
644
+ continue;
645
+ if (!ref.description || typeof ref.description !== "string") continue;
646
+ const mimeType = resolveImageRefMimeType(
647
+ ref.message_id,
648
+ ref.block_index,
649
+ conversationId,
650
+ );
651
+ if (!mimeType) continue;
652
+ validRefs.push({
653
+ messageId: ref.message_id,
654
+ blockIndex: ref.block_index,
655
+ description: ref.description,
656
+ mimeType,
657
+ });
658
+ }
659
+ node.imageRefs = validRefs.length > 0 ? validRefs : null;
660
+ }
661
+ }
662
+
663
+ // Parse updates
664
+ for (const raw of updateNodes) {
665
+ if (!raw.id || !candidateNodeIds.has(raw.id)) continue;
666
+ const changes: Record<string, unknown> = {};
667
+ if (raw.content) changes.content = raw.content;
668
+ if (raw.significance != null)
669
+ changes.significance = clamp(raw.significance, 0, 1);
670
+ if (raw.confidence != null)
671
+ changes.confidence = clamp(raw.confidence, 0, 1);
672
+ if (
673
+ raw.fidelity &&
674
+ ["vivid", "clear", "faded", "gist"].includes(raw.fidelity)
675
+ )
676
+ changes.fidelity = raw.fidelity;
677
+ if (raw.event_date !== undefined) changes.eventDate = parseEpochMs(raw.event_date);
678
+ if (Object.keys(changes).length > 0) {
679
+ diff.updateNodes.push({ id: raw.id, changes });
680
+ }
681
+ }
682
+
683
+ // Parse edges between existing nodes
684
+ for (const raw of newEdges) {
685
+ if (!raw.source_node_id || !raw.target_node_id) continue;
686
+ if (
687
+ !candidateNodeIds.has(raw.source_node_id) ||
688
+ !candidateNodeIds.has(raw.target_node_id)
689
+ )
690
+ continue;
691
+ if (!raw.relationship || !VALID_RELATIONSHIPS.has(raw.relationship))
692
+ continue;
693
+ diff.createEdges.push({
694
+ sourceNodeId: raw.source_node_id,
695
+ targetNodeId: raw.target_node_id,
696
+ relationship: raw.relationship as NewEdge["relationship"],
697
+ weight: clamp(Number(raw.weight) || 1.0, 0, 1),
698
+ created: now,
699
+ });
700
+ }
701
+
702
+ return { diff, deferredEdges, deferredTriggers };
703
+ }
704
+
705
+ // ---------------------------------------------------------------------------
706
+ // Main extraction pipeline
707
+ // ---------------------------------------------------------------------------
708
+
709
+ export interface ExtractionResult {
710
+ nodesCreated: number;
711
+ nodesUpdated: number;
712
+ nodesReinforced: number;
713
+ edgesCreated: number;
714
+ triggersCreated: number;
715
+ /** Epoch ms of the newest message included in extraction. Used for checkpointing. */
716
+ lastProcessedTimestamp?: number;
717
+ }
718
+
719
+ /**
720
+ * Run the full graph extraction pipeline for a completed conversation.
721
+ *
722
+ * 1. Load transcript from disk
723
+ * 2. Find candidate existing nodes via embedding search
724
+ * 3. LLM call → structured diff
725
+ * 4. Apply diff to graph store
726
+ * 5. Enqueue embedding jobs for new nodes and triggers
727
+ */
728
+ export async function runGraphExtraction(
729
+ conversationId: string,
730
+ scopeId: string,
731
+ config: AssistantConfig,
732
+ opts?: {
733
+ /** Pre-loaded transcript text (skips disk read). Used by bootstrap. */
734
+ transcript?: string;
735
+ /** Additional node IDs that were in active context. */
736
+ activeContextNodeIds?: string[];
737
+ /**
738
+ * When set, only extract from messages after this checkpoint.
739
+ * Used for mid-conversation incremental extraction (batch mode).
740
+ * The checkpoint is the message timestamp of the last extracted message.
741
+ */
742
+ afterTimestamp?: number;
743
+ /** Override the conversation timestamp (epoch ms). Used by bootstrap. */
744
+ conversationTimestamp?: number;
745
+ /** Skip Qdrant search for candidates (use DB query instead). Used by bootstrap
746
+ * when embedding jobs haven't been processed yet. */
747
+ skipQdrant?: boolean;
748
+ /** Embed nodes synchronously instead of enqueuing jobs. Used by bootstrap
749
+ * so nodes are searchable immediately without the jobs worker running. */
750
+ embedInline?: boolean;
751
+ },
752
+ ): Promise<ExtractionResult> {
753
+ const emptyResult: ExtractionResult = {
754
+ nodesCreated: 0,
755
+ nodesUpdated: 0,
756
+ nodesReinforced: 0,
757
+ edgesCreated: 0,
758
+ triggersCreated: 0,
759
+ };
760
+
761
+ // 1. Load transcript — try multimodal first, fall back to text-only
762
+ const imageResult = loadTranscriptWithImages(
763
+ conversationId,
764
+ opts?.afterTimestamp,
765
+ );
766
+
767
+ let transcript = opts?.transcript;
768
+ if (!transcript) {
769
+ transcript =
770
+ loadTranscriptFromDisk(conversationId, opts?.afterTimestamp) ?? undefined;
771
+ if (!transcript) {
772
+ // If we have a multimodal result but no disk transcript, extract text
773
+ // from the multimodal message content blocks for candidate search.
774
+ if (imageResult) {
775
+ transcript = imageResult.message.content
776
+ .filter(
777
+ (b): b is { type: "text"; text: string } => b.type === "text",
778
+ )
779
+ .map((b) => b.text)
780
+ .join("\n");
781
+ }
782
+ if (!transcript) {
783
+ log.warn(
784
+ { conversationId },
785
+ "No transcript found on disk, skipping extraction",
786
+ );
787
+ return emptyResult;
788
+ }
789
+ }
790
+ }
791
+
792
+ // Skip very short conversations (< 100 chars)
793
+ if (transcript.trim().length < 100) {
794
+ return emptyResult;
795
+ }
796
+
797
+ // 2. Get provider
798
+ const provider = await getConfiguredProvider();
799
+ if (!provider) {
800
+ throw new BackendUnavailableError(
801
+ "Provider unavailable for graph extraction",
802
+ );
803
+ }
804
+
805
+ // 3. Find candidate existing nodes
806
+ const candidateNodes = await findCandidateNodes(
807
+ transcript,
808
+ scopeId,
809
+ config,
810
+ opts?.activeContextNodeIds,
811
+ opts?.skipQdrant,
812
+ );
813
+ const candidateNodeIds = new Set(candidateNodes.map((n) => n.id));
814
+
815
+ // 4. Build prompt
816
+ const userPersona = resolveGuardianPersona();
817
+ const identityContext = buildCoreIdentityContext({
818
+ userPersona: userPersona ?? undefined,
819
+ });
820
+
821
+ const systemPrompt = buildGraphExtractionSystemPrompt(
822
+ candidateNodes.map((n) => ({ id: n.id, type: n.type, content: n.content })),
823
+ identityContext,
824
+ );
825
+
826
+ // 5. Resolve conversation timestamp before the LLM call so we can include
827
+ // the date in the prompt — without it the model can't resolve "today"
828
+ // or correctly date events mentioned in the conversation.
829
+ const conversationTimestamp =
830
+ opts?.conversationTimestamp ??
831
+ resolveConversationTimestamp(conversationId) ??
832
+ imageResult?.lastTimestamp ??
833
+ Date.now();
834
+
835
+ const convDate = new Date(conversationTimestamp);
836
+ const conversationDate =
837
+ convDate.toLocaleDateString("en-US", {
838
+ weekday: "long",
839
+ year: "numeric",
840
+ month: "long",
841
+ day: "numeric",
842
+ }) +
843
+ " at " +
844
+ convDate.toLocaleTimeString("en-US", {
845
+ hour: "numeric",
846
+ minute: "2-digit",
847
+ hour12: true,
848
+ });
849
+
850
+ // 6. LLM call — use multimodal message when images are present
851
+ const useMultimodal = imageResult?.hasImages === true;
852
+
853
+ const extractionMessages: Message[] = useMultimodal
854
+ ? [
855
+ {
856
+ role: "user",
857
+ content: [
858
+ {
859
+ type: "text" as const,
860
+ text: `## Conversation Date\n\n${conversationDate}\n\n## Conversation Transcript\n\n`,
861
+ },
862
+ ...imageResult.message.content,
863
+ ],
864
+ },
865
+ ]
866
+ : [
867
+ userMessage(
868
+ `## Conversation Date\n\n${conversationDate}\n\n## Conversation Transcript\n\n${transcript}`,
869
+ ),
870
+ ];
871
+
872
+ const response = await provider.sendMessage(
873
+ extractionMessages,
874
+ [EXTRACT_TOOL_SCHEMA],
875
+ systemPrompt,
876
+ {
877
+ config: {
878
+ modelIntent: "quality-optimized" as const,
879
+ tool_choice: { type: "tool" as const, name: "extract_graph_diff" },
880
+ },
881
+ },
882
+ );
883
+
884
+ const toolBlock = extractToolUse(response);
885
+ if (!toolBlock) {
886
+ log.warn({ conversationId }, "No tool_use block in extraction response");
887
+ return emptyResult;
888
+ }
889
+
890
+ const { diff, deferredEdges, deferredTriggers } = parseExtractionResponse(
891
+ toolBlock.input as Record<string, unknown>,
892
+ conversationId,
893
+ scopeId,
894
+ candidateNodeIds,
895
+ conversationTimestamp,
896
+ );
897
+
898
+ // 7. Handle supersession (inherit durability before applying diff)
899
+ for (const edge of diff.createEdges) {
900
+ if (edge.relationship === "supersedes") {
901
+ // Supersession is handled differently — see supersedeNode in store
902
+ // For now, just mark it; full supersession is applied after node creation
903
+ }
904
+ }
905
+
906
+ // 8. Apply the diff
907
+ const result = applyDiff(diff);
908
+
909
+ // 9. Apply deferred edges and triggers using the created node IDs
910
+ const createdNodeIds = result.createdNodeIds;
911
+ let edgesCreated = result.edgesCreated;
912
+ let triggersCreated = result.triggersCreated;
913
+
914
+ for (const de of deferredEdges) {
915
+ const newNodeId = createdNodeIds[de.newNodeIndex];
916
+ if (!newNodeId) continue;
917
+
918
+ createEdge({
919
+ sourceNodeId: newNodeId,
920
+ targetNodeId: de.targetNodeId,
921
+ relationship: de.relationship as NewEdge["relationship"],
922
+ weight: de.weight,
923
+ created: conversationTimestamp,
924
+ });
925
+ edgesCreated++;
926
+ }
927
+
928
+ const { createTrigger } = await import("./store.js");
929
+
930
+ for (const dt of deferredTriggers) {
931
+ const newNodeId = createdNodeIds[dt.newNodeIndex];
932
+ if (!newNodeId) continue;
933
+
934
+ const trigger = createTrigger({
935
+ ...dt.trigger,
936
+ nodeId: newNodeId,
937
+ });
938
+ triggersCreated++;
939
+
940
+ if (trigger.type === "semantic" && trigger.condition) {
941
+ enqueueGraphTriggerEmbed(trigger.id);
942
+ }
943
+ }
944
+
945
+ // 10. Embed new nodes — inline for bootstrap, async for live conversations
946
+ const createdNodes = getNodesByIds(createdNodeIds);
947
+ if (opts?.embedInline) {
948
+ const { embedGraphNodeDirect } = await import("./graph-search.js");
949
+ for (const node of createdNodes) {
950
+ try {
951
+ await embedGraphNodeDirect(node, config);
952
+ } catch (err) {
953
+ const msg = err instanceof Error ? err.message : String(err);
954
+ log.warn(
955
+ { nodeId: node.id, err: msg },
956
+ "Inline embed failed (non-fatal)",
957
+ );
958
+ console.error(` [embed] Failed for ${node.id}: ${msg}`);
959
+ }
960
+ }
961
+ } else {
962
+ for (const node of createdNodes) {
963
+ enqueueGraphNodeEmbed(node.id);
964
+ }
965
+ }
966
+
967
+ log.info(
968
+ {
969
+ conversationId,
970
+ nodesCreated: result.nodesCreated,
971
+ nodesUpdated: result.nodesUpdated,
972
+ nodesReinforced: result.nodesReinforced,
973
+ edgesCreated,
974
+ triggersCreated,
975
+ },
976
+ "Graph extraction complete",
977
+ );
978
+
979
+ return {
980
+ nodesCreated: result.nodesCreated,
981
+ nodesUpdated: result.nodesUpdated,
982
+ nodesReinforced: result.nodesReinforced,
983
+ edgesCreated,
984
+ triggersCreated,
985
+ lastProcessedTimestamp: conversationTimestamp,
986
+ };
987
+ }
988
+
989
+ // ---------------------------------------------------------------------------
990
+ // Helpers
991
+ // ---------------------------------------------------------------------------
992
+
993
+ function resolveConversationTimestamp(conversationId: string): number | null {
994
+ const db = getDb();
995
+ // Use the last message timestamp, not the conversation creation time.
996
+ // A conversation can span hours/days — memories should be timestamped
997
+ // to when the relevant content was actually discussed.
998
+ const lastMsg = db
999
+ .select({ createdAt: messages.createdAt })
1000
+ .from(messages)
1001
+ .where(eq(messages.conversationId, conversationId))
1002
+ .orderBy(desc(messages.createdAt))
1003
+ .limit(1)
1004
+ .get();
1005
+ if (lastMsg) return lastMsg.createdAt;
1006
+
1007
+ // Fallback to conversation creation time if no messages in DB
1008
+ const conv = db
1009
+ .select({ createdAt: conversations.createdAt })
1010
+ .from(conversations)
1011
+ .where(eq(conversations.id, conversationId))
1012
+ .get();
1013
+ return conv?.createdAt ?? null;
1014
+ }
1015
+
1016
+ function resolveImageRefMimeType(
1017
+ messageId: string,
1018
+ blockIndex: number,
1019
+ conversationId: string,
1020
+ ): string | null {
1021
+ const db = getDb();
1022
+ const msg = db
1023
+ .select({ content: messages.content })
1024
+ .from(messages)
1025
+ .where(
1026
+ and(
1027
+ eq(messages.id, messageId),
1028
+ eq(messages.conversationId, conversationId),
1029
+ ),
1030
+ )
1031
+ .get();
1032
+ if (!msg) return null;
1033
+
1034
+ try {
1035
+ const blocks = JSON.parse(msg.content) as Array<{
1036
+ type?: string;
1037
+ source?: { media_type?: string };
1038
+ }>;
1039
+ const block = blocks[blockIndex];
1040
+ if (!block || block.type !== "image") return null;
1041
+ return block.source?.media_type ?? null;
1042
+ } catch {
1043
+ return null;
1044
+ }
1045
+ }
1046
+
1047
+ function loadTranscriptFromDisk(
1048
+ conversationId: string,
1049
+ afterTimestamp?: number,
1050
+ ): string | null {
1051
+ const db = getDb();
1052
+ const conv = db
1053
+ .select({ createdAt: conversations.createdAt })
1054
+ .from(conversations)
1055
+ .where(eq(conversations.id, conversationId))
1056
+ .get();
1057
+
1058
+ if (!conv) return null;
1059
+
1060
+ try {
1061
+ const dirPath = getConversationDirPath(conversationId, conv.createdAt);
1062
+ const messagesPath = join(dirPath, "messages.jsonl");
1063
+ const content = readFileSync(messagesPath, "utf-8");
1064
+
1065
+ const lines = content
1066
+ .trim()
1067
+ .split("\n")
1068
+ .filter((line) => line.length > 0);
1069
+
1070
+ const parts: string[] = [];
1071
+ for (const line of lines) {
1072
+ try {
1073
+ const msg = JSON.parse(line) as {
1074
+ role?: string;
1075
+ content?: string;
1076
+ ts?: string;
1077
+ };
1078
+ if (!msg.role || !msg.content) continue;
1079
+
1080
+ // Filter by timestamp for incremental extraction
1081
+ if (afterTimestamp && msg.ts) {
1082
+ const msgTime = new Date(msg.ts).getTime();
1083
+ if (msgTime <= afterTimestamp) continue;
1084
+ }
1085
+
1086
+ parts.push(`[${msg.role}]: ${msg.content}`);
1087
+ } catch {
1088
+ // Skip malformed lines
1089
+ }
1090
+ }
1091
+
1092
+ return parts.length > 0 ? parts.join("\n\n") : null;
1093
+ } catch {
1094
+ return null;
1095
+ }
1096
+ }
1097
+
1098
+ /**
1099
+ * Load a conversation transcript from the DB with interleaved text and image
1100
+ * content blocks. Returns a single consolidated `Message` with role "user"
1101
+ * containing text annotations and `ImageContent` blocks so the extraction LLM
1102
+ * can see images alongside their textual context.
1103
+ *
1104
+ * Images are capped at 10 per transcript to control extraction cost.
1105
+ */
1106
+ export function loadTranscriptWithImages(
1107
+ conversationId: string,
1108
+ afterTimestamp?: number,
1109
+ ): {
1110
+ message: Message;
1111
+ hasImages: boolean;
1112
+ lastTimestamp: number | null;
1113
+ } | null {
1114
+ const db = getDb();
1115
+
1116
+ // Build query conditions
1117
+ const conditions = [eq(messages.conversationId, conversationId)];
1118
+ if (afterTimestamp !== undefined) {
1119
+ conditions.push(gt(messages.createdAt, afterTimestamp));
1120
+ }
1121
+
1122
+ const rows = db
1123
+ .select({
1124
+ id: messages.id,
1125
+ role: messages.role,
1126
+ content: messages.content,
1127
+ createdAt: messages.createdAt,
1128
+ })
1129
+ .from(messages)
1130
+ .where(and(...conditions))
1131
+ .orderBy(asc(messages.createdAt))
1132
+ .all();
1133
+
1134
+ if (rows.length === 0) return null;
1135
+
1136
+ const MAX_IMAGES = 10;
1137
+ let imageCount = 0;
1138
+ let hasImagesFlag = false;
1139
+ let totalTextLength = 0;
1140
+ let lastTimestamp: number | null = null;
1141
+
1142
+ const contentBlocks: ContentBlock[] = [];
1143
+
1144
+ for (const row of rows) {
1145
+ lastTimestamp = row.createdAt;
1146
+
1147
+ let parsed: ContentBlock[];
1148
+ try {
1149
+ const raw = JSON.parse(row.content) as unknown;
1150
+ if (typeof raw === "string") {
1151
+ parsed = [{ type: "text", text: raw }];
1152
+ } else if (Array.isArray(raw)) {
1153
+ parsed = raw as ContentBlock[];
1154
+ } else {
1155
+ continue;
1156
+ }
1157
+ } catch {
1158
+ // If content is a plain string (not JSON), wrap it
1159
+ parsed = [{ type: "text", text: row.content }];
1160
+ }
1161
+
1162
+ // Build content blocks preserving original text/image interleaving
1163
+ let prefixAdded = false;
1164
+ for (let i = 0; i < parsed.length; i++) {
1165
+ const block = parsed[i];
1166
+ if (block?.type === "text") {
1167
+ const rawText =
1168
+ typeof block.text === "string" ? block.text : "";
1169
+ const text = prefixAdded
1170
+ ? rawText
1171
+ : `[${row.role}]: ${rawText}`;
1172
+ prefixAdded = true;
1173
+ totalTextLength += text.length;
1174
+ contentBlocks.push({ type: "text", text });
1175
+ } else if (block?.type === "image") {
1176
+ if (imageCount < MAX_IMAGES) {
1177
+ const imgBlock = block as ImageContent;
1178
+ // Add annotation so the extraction LLM knows the image's reference coordinates
1179
+ contentBlocks.push({
1180
+ type: "text",
1181
+ text: `<image message_id="${row.id}" block_index="${i}" type="${imgBlock.source.media_type}" />`,
1182
+ });
1183
+ contentBlocks.push(imgBlock);
1184
+ imageCount++;
1185
+ hasImagesFlag = true;
1186
+ }
1187
+ // After cap, skip image blocks but continue processing text
1188
+ }
1189
+ }
1190
+ }
1191
+
1192
+ // Skip if transcript is too short (images count toward the threshold)
1193
+ if (totalTextLength < 100 && !hasImagesFlag) return null;
1194
+
1195
+ const message: Message = {
1196
+ role: "user",
1197
+ content: contentBlocks,
1198
+ };
1199
+
1200
+ return { message, hasImages: hasImagesFlag, lastTimestamp };
1201
+ }
1202
+
1203
+ async function findCandidateNodes(
1204
+ transcript: string,
1205
+ scopeId: string,
1206
+ config: AssistantConfig,
1207
+ activeContextNodeIds?: string[],
1208
+ skipQdrant?: boolean,
1209
+ ) {
1210
+ const allNodeIds = new Set<string>();
1211
+
1212
+ if (skipQdrant) {
1213
+ // Bootstrap mode: load candidates directly from DB (embeddings may not be ready).
1214
+ // Get the most recent and most significant non-gone nodes.
1215
+ const dbCandidates = queryNodes({
1216
+ scopeId,
1217
+ fidelityNot: ["gone"],
1218
+ limit: 100,
1219
+ });
1220
+ for (const node of dbCandidates) allNodeIds.add(node.id);
1221
+ } else {
1222
+ // Live mode: semantic search via Qdrant
1223
+ const { embedWithRetry } = await import("../embed.js");
1224
+ const searchText =
1225
+ transcript.length > 3000
1226
+ ? transcript.slice(0, 1500) + "\n...\n" + transcript.slice(-1500)
1227
+ : transcript;
1228
+
1229
+ try {
1230
+ const embedding = await embedWithRetry(config, [searchText]);
1231
+ const queryVector = embedding.vectors[0];
1232
+ if (queryVector) {
1233
+ const searchResults = await searchGraphNodes(queryVector, 100, [
1234
+ scopeId,
1235
+ ]);
1236
+ for (const r of searchResults) allNodeIds.add(r.nodeId);
1237
+ }
1238
+ } catch (err) {
1239
+ log.warn(
1240
+ { err },
1241
+ "Failed to embed transcript for candidate search, continuing without candidates",
1242
+ );
1243
+ }
1244
+ }
1245
+
1246
+ // Combine with active context nodes
1247
+ if (activeContextNodeIds) {
1248
+ for (const id of activeContextNodeIds) allNodeIds.add(id);
1249
+ }
1250
+
1251
+ if (allNodeIds.size === 0) return [];
1252
+
1253
+ return getNodesByIds([...allNodeIds]);
1254
+ }