@vellumai/assistant 0.10.4 → 0.10.5-dev.202607022329.d89bf2f

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 (866) hide show
  1. package/ARCHITECTURE.md +94 -91
  2. package/Dockerfile +5 -16
  3. package/README.md +7 -10
  4. package/bun.lock +0 -4
  5. package/docs/architecture/security.md +20 -20
  6. package/docs/stt-provider-onboarding.md +4 -5
  7. package/docs/trusted-contact-access.md +7 -7
  8. package/eslint-rules/cli-no-daemon-internals.js +4 -0
  9. package/eslint.config.mjs +7 -1
  10. package/knip.json +0 -1
  11. package/node_modules/@vellumai/gateway-client/src/__tests__/contact-read-contracts.test.ts +36 -0
  12. package/node_modules/@vellumai/gateway-client/src/__tests__/invite-contract.test.ts +233 -0
  13. package/node_modules/@vellumai/gateway-client/src/__tests__/trust-verdict-contract.test.ts +40 -0
  14. package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +20 -8
  15. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +10 -0
  16. package/node_modules/@vellumai/gateway-client/src/index.ts +33 -0
  17. package/node_modules/@vellumai/gateway-client/src/invite-contract.ts +216 -0
  18. package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +91 -0
  19. package/node_modules/@vellumai/gateway-client/src/trust-verdict-contract.ts +39 -3
  20. package/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +0 -8
  21. package/node_modules/@vellumai/service-contracts/src/__tests__/package-boundary.test.ts +1 -5
  22. package/node_modules/@vellumai/service-contracts/src/twilio-ingress.ts +3 -14
  23. package/openapi.yaml +1073 -225
  24. package/package.json +1 -4
  25. package/scripts/test.sh +35 -13
  26. package/src/__tests__/access-request-seed-content-blocks.test.ts +60 -4
  27. package/src/__tests__/acp-session.test.ts +4 -1
  28. package/src/__tests__/actor-trust-resolver-address-fallback.test.ts +0 -6
  29. package/src/__tests__/add-message-preassigned-id.test.ts +56 -0
  30. package/src/__tests__/agent-loop-set-system-prompt.test.ts +139 -0
  31. package/src/__tests__/app-conversation-ids-backfill.test.ts +3 -0
  32. package/src/__tests__/app-source-watcher.test.ts +11 -0
  33. package/src/__tests__/approval-interception-trust-gates.test.ts +148 -3
  34. package/src/__tests__/assistant-attachments.test.ts +62 -4
  35. package/src/__tests__/assistant-id-boundary-guard.test.ts +0 -1
  36. package/src/__tests__/attachments-store-heic-normalize.test.ts +230 -0
  37. package/src/__tests__/attachments-store.test.ts +3 -0
  38. package/src/__tests__/background-shell-bash.test.ts +34 -0
  39. package/src/__tests__/background-shell-host-bash.test.ts +12 -0
  40. package/src/__tests__/background-tool-registry.test.ts +97 -0
  41. package/src/__tests__/background-tool-routes.test.ts +72 -1
  42. package/src/__tests__/background-workers-disk-pressure.test.ts +2 -1
  43. package/src/__tests__/btw-routes.test.ts +57 -3
  44. package/src/__tests__/call-controller.test.ts +474 -84
  45. package/src/__tests__/call-domain.test.ts +48 -1
  46. package/src/__tests__/call-pointer-messages.test.ts +57 -19
  47. package/src/__tests__/call-pointer-no-hardcoded-copy.guard.test.ts +2 -6
  48. package/src/__tests__/call-routes-http.test.ts +15 -14
  49. package/src/__tests__/call-setup-flow-invite.test.ts +568 -0
  50. package/src/__tests__/call-setup-flow-midcall-trust.test.ts +231 -0
  51. package/src/__tests__/call-setup-flow-name-capture.test.ts +754 -0
  52. package/src/__tests__/call-setup-flow-verification.test.ts +925 -0
  53. package/src/__tests__/call-setup-flow.test.ts +311 -0
  54. package/src/__tests__/cancel-resolves-conversation-key.test.ts +0 -1
  55. package/src/__tests__/channel-guardian.test.ts +4 -7
  56. package/src/__tests__/channel-readiness-service.test.ts +0 -6
  57. package/src/__tests__/channel-readiness-slack-remote.test.ts +0 -4
  58. package/src/__tests__/channel-retry-sweep.test.ts +76 -64
  59. package/src/__tests__/channel-setup-panel-ack.test.ts +184 -0
  60. package/src/__tests__/clear-all-lexical.test.ts +90 -0
  61. package/src/__tests__/compactor-media-strip.test.ts +177 -0
  62. package/src/__tests__/config-loader-backfill.test.ts +5 -5
  63. package/src/__tests__/config-schema.test.ts +17 -0
  64. package/src/__tests__/contact-store-interaction-info.test.ts +27 -70
  65. package/src/__tests__/contacts-relay-reads.test.ts +188 -23
  66. package/src/__tests__/contacts-tools.test.ts +42 -1
  67. package/src/__tests__/contacts-write.test.ts +181 -0
  68. package/src/__tests__/context-search-conversations-source.test.ts +220 -81
  69. package/src/__tests__/conversation-agent-loop-disk-pressure.test.ts +1 -0
  70. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -0
  71. package/src/__tests__/conversation-agent-loop-overflow.test.ts +1 -0
  72. package/src/__tests__/conversation-agent-loop.test.ts +39 -0
  73. package/src/__tests__/conversation-analysis-routes.test.ts +1 -1
  74. package/src/__tests__/conversation-app-control-instantiation.test.ts +2 -0
  75. package/src/__tests__/conversation-clear-safety.test.ts +0 -1
  76. package/src/__tests__/conversation-crud-enabled-plugins.test.ts +60 -0
  77. package/src/__tests__/conversation-delete-schedule-cleanup.test.ts +0 -1
  78. package/src/__tests__/conversation-disk-view.test.ts +1 -0
  79. package/src/__tests__/conversation-enabledplugins-route.test.ts +184 -0
  80. package/src/__tests__/conversation-error.test.ts +0 -18
  81. package/src/__tests__/conversation-fork-crud.test.ts +67 -0
  82. package/src/__tests__/conversation-history-web-search.test.ts +7 -137
  83. package/src/__tests__/conversation-initial-prompt.test.ts +110 -0
  84. package/src/__tests__/conversation-loop-db-lock-resilience.test.ts +191 -0
  85. package/src/__tests__/conversation-process-callsite.test.ts +37 -0
  86. package/src/__tests__/conversation-queue.test.ts +149 -0
  87. package/src/__tests__/conversation-routes-enabled-plugins.test.ts +392 -0
  88. package/src/__tests__/conversation-routes-guardian-reply.test.ts +84 -0
  89. package/src/__tests__/conversation-routes-hidden-queue.test.ts +407 -0
  90. package/src/__tests__/conversation-runtime-assembly.test.ts +36 -16
  91. package/src/__tests__/conversation-skill-tools.test.ts +73 -0
  92. package/src/__tests__/conversation-wipe.test.ts +20 -5
  93. package/src/__tests__/credential-prompt-route.test.ts +69 -0
  94. package/src/__tests__/credential-security-invariants.test.ts +1 -3
  95. package/src/__tests__/db-drop-messages-fts.test.ts +115 -0
  96. package/src/__tests__/deterministic-verification-control-plane.test.ts +1 -62
  97. package/src/__tests__/edit-propagation.test.ts +56 -2
  98. package/src/__tests__/empty-state-greeting-cache.test.ts +9 -0
  99. package/src/__tests__/gateway-only-enforcement.test.ts +15 -110
  100. package/src/__tests__/guardian-expiry-notifier.test.ts +1 -0
  101. package/src/__tests__/guardian-question-mode.test.ts +57 -0
  102. package/src/__tests__/guardian-verification-voice-binding.test.ts +1 -1
  103. package/src/__tests__/guardian-wait-controller.test.ts +619 -0
  104. package/src/__tests__/handlers-skills-memory-v2-reseed.test.ts +33 -2
  105. package/src/__tests__/heic-fixture.ts +55 -0
  106. package/src/__tests__/helpers/seed-contact-channel.ts +0 -2
  107. package/src/__tests__/http-conversation-lineage.test.ts +6 -1
  108. package/src/__tests__/image-conversion.test.ts +170 -0
  109. package/src/__tests__/inbound-invite-redemption.test.ts +72 -341
  110. package/src/__tests__/introduction-card-resolver.test.ts +354 -0
  111. package/src/__tests__/invite-redeemed-ipc.test.ts +97 -0
  112. package/src/__tests__/invite-routes-http.test.ts +246 -420
  113. package/src/__tests__/job-handler-registration-guard.test.ts +104 -0
  114. package/src/__tests__/lexical-index-dual-write.test.ts +426 -0
  115. package/src/__tests__/lifecycle-memory-v2-seed.test.ts +2 -2
  116. package/src/__tests__/list-messages-attachments.test.ts +159 -0
  117. package/src/__tests__/list-messages-background-tool-completion.test.ts +141 -0
  118. package/src/__tests__/list-messages-queued.test.ts +25 -0
  119. package/src/__tests__/managed-store.test.ts +129 -1
  120. package/src/__tests__/media-stream-audio-transcode.test.ts +113 -0
  121. package/src/__tests__/media-stream-output.test.ts +350 -19
  122. package/src/__tests__/media-stream-server-integration.test.ts +943 -169
  123. package/src/__tests__/media-stream-stt-session.test.ts +498 -1
  124. package/src/__tests__/memory-jobs-worker-lanes.test.ts +2 -2
  125. package/src/__tests__/model-intents.test.ts +1 -1
  126. package/src/__tests__/mtime-cache.test.ts +19 -0
  127. package/src/__tests__/non-member-access-request.test.ts +349 -3
  128. package/src/__tests__/notification-decision-fallback.test.ts +104 -8
  129. package/src/__tests__/notification-decision-strategy.test.ts +57 -8
  130. package/src/__tests__/onboarding-template-contract.test.ts +27 -43
  131. package/src/__tests__/openai-provider.test.ts +33 -0
  132. package/src/__tests__/openai-responses-provider.test.ts +40 -0
  133. package/src/__tests__/persistence-layering-guard.test.ts +29 -20
  134. package/src/__tests__/persona-resolver.test.ts +56 -2
  135. package/src/__tests__/personal-memory-auth-bypass.test.ts +62 -0
  136. package/src/__tests__/phone.test.ts +1 -15
  137. package/src/__tests__/plugin-bootstrap.test.ts +25 -196
  138. package/src/__tests__/plugin-disabled-state.test.ts +33 -0
  139. package/src/__tests__/plugin-effective-enabled-set.test.ts +227 -0
  140. package/src/__tests__/plugin-event-hub-facade.test.ts +323 -0
  141. package/src/__tests__/plugin-import-boundary-guard.test.ts +156 -42
  142. package/src/__tests__/plugin-injector-contribution.test.ts +4 -20
  143. package/src/__tests__/plugin-pipeline.test.ts +63 -1
  144. package/src/__tests__/plugin-route-contribution.test.ts +62 -115
  145. package/src/__tests__/plugin-tool-contribution.test.ts +0 -22
  146. package/src/__tests__/plugin-types.test.ts +0 -1
  147. package/src/__tests__/pointer-conversation-trust.test.ts +38 -6
  148. package/src/__tests__/pre-model-call-sanitize.test.ts +1 -1
  149. package/src/__tests__/prune-old-tool-invocations-job.test.ts +97 -0
  150. package/src/__tests__/public-ingress-urls.test.ts +0 -48
  151. package/src/__tests__/qdrant-collection-migration.test.ts +145 -2
  152. package/src/__tests__/reaction-intercept-member-verdict-warm.test.ts +0 -1
  153. package/src/__tests__/resolve-trust-class.test.ts +28 -10
  154. package/src/__tests__/scaffold-managed-skill-tool.test.ts +137 -0
  155. package/src/__tests__/schedule-retry.test.ts +38 -14
  156. package/src/__tests__/schedule-routes-workflow-validation.test.ts +3 -3
  157. package/src/__tests__/schedule-routes.test.ts +14 -6
  158. package/src/__tests__/schedule-store.test.ts +3 -1
  159. package/src/__tests__/scheduler-disk-pressure.test.ts +17 -6
  160. package/src/__tests__/scheduler-recurrence.test.ts +57 -107
  161. package/src/__tests__/scheduler-reuse-conversation.test.ts +51 -46
  162. package/src/__tests__/scheduler-wake.test.ts +13 -23
  163. package/src/__tests__/scheduler-worker-standdown.test.ts +220 -0
  164. package/src/__tests__/secret-routes-platform-proxy.test.ts +1 -1
  165. package/src/__tests__/shell-observability.test.ts +1 -0
  166. package/src/__tests__/skill-boundary-guard.test.ts +3 -4
  167. package/src/__tests__/skill-load-plugin-scope.test.ts +238 -0
  168. package/src/__tests__/skill-projection-feature-flag.test.ts +2 -0
  169. package/src/__tests__/skill-script-runner-host.test.ts +53 -0
  170. package/src/__tests__/skill-script-runner-sandbox.test.ts +5 -2
  171. package/src/__tests__/skill-tool-factory.test.ts +73 -1
  172. package/src/__tests__/slack-app-setup-skill-regression.test.ts +37 -14
  173. package/src/__tests__/slack-inbound-verification.test.ts +36 -0
  174. package/src/__tests__/slack-notification-approval-card.test.ts +89 -2
  175. package/src/__tests__/slack-reaction-canonical-approval.test.ts +51 -1
  176. package/src/__tests__/stt-hints.test.ts +2 -256
  177. package/src/__tests__/subagent-allowlist-validation.test.ts +47 -0
  178. package/src/__tests__/subagent-call-site-routing.test.ts +72 -0
  179. package/src/__tests__/subagent-fork-notifications.test.ts +14 -2
  180. package/src/__tests__/subagent-persistence.test.ts +122 -0
  181. package/src/__tests__/subagent-spawn-and-await.test.ts +1 -0
  182. package/src/__tests__/subagent-terminal-message.test.ts +99 -0
  183. package/src/__tests__/subagent-tool-gate-mode.test.ts +78 -1
  184. package/src/__tests__/system-prompt.test.ts +17 -118
  185. package/src/__tests__/task-scheduler.test.ts +68 -89
  186. package/src/__tests__/telephony-credential-preflight.test.ts +312 -0
  187. package/src/__tests__/telephony-tts-guard.test.ts +538 -0
  188. package/src/__tests__/title-generate-hook.test.ts +16 -0
  189. package/src/__tests__/tool-approval-handler.test.ts +87 -1
  190. package/src/__tests__/tool-approval-seed-content-blocks.test.ts +45 -20
  191. package/src/__tests__/tool-grant-request-escalation.test.ts +39 -0
  192. package/src/__tests__/trust-context-guards.test.ts +21 -10
  193. package/src/__tests__/trusted-contact-multichannel.test.ts +4 -0
  194. package/src/__tests__/twilio-provider.test.ts +17 -17
  195. package/src/__tests__/twilio-routes-twiml.test.ts +6 -543
  196. package/src/__tests__/twilio-routes.test.ts +257 -387
  197. package/src/__tests__/twilio-validation.test.ts +1 -1
  198. package/src/__tests__/unified-turn-context-client-os.test.ts +45 -0
  199. package/src/__tests__/usage-cache-backfill-migration.test.ts +5 -0
  200. package/src/__tests__/voice-approval-resolver-relay.test.ts +150 -0
  201. package/src/__tests__/voice-ingress-preflight.test.ts +61 -0
  202. package/src/__tests__/voice-quality.test.ts +2 -2
  203. package/src/__tests__/voice-scoped-grant-consumer.test.ts +1 -7
  204. package/src/__tests__/voice-session-bridge-processing-wait.test.ts +27 -0
  205. package/src/__tests__/voice-session-bridge.test.ts +1 -10
  206. package/src/__tests__/workspace-git-service.test.ts +1 -1
  207. package/src/__tests__/workspace-migration-013-repair-conversation-disk-view.test.ts +1 -0
  208. package/src/__tests__/workspace-migration-118-seed-now-md.test.ts +120 -0
  209. package/src/__tests__/workspace-migration-120-revise-onboarding-threads.test.ts +175 -0
  210. package/src/__tests__/workspace-migration-121-seed-default-user-guardrails.test.ts +118 -0
  211. package/src/__tests__/workspace-migration-122-relocate-default-user-boundary.test.ts +119 -0
  212. package/src/__tests__/workspace-migration-123-swap-quality-profile-to-fable.test.ts +191 -0
  213. package/src/__tests__/workspace-migration-backfill-installation-id.test.ts +235 -241
  214. package/src/acp/__tests__/prepare-agent-env.test.ts +0 -97
  215. package/src/acp/__tests__/session-manager-persistence.test.ts +2 -0
  216. package/src/acp/__tests__/session-manager-resume.test.ts +8 -0
  217. package/src/acp/__tests__/session-manager-usage.test.ts +6 -0
  218. package/src/acp/__tests__/session-manager.test.ts +6 -0
  219. package/src/acp/agent-process.test.ts +116 -0
  220. package/src/acp/agent-process.ts +71 -0
  221. package/src/acp/auto-install.ts +12 -8
  222. package/src/acp/failure-error.test.ts +109 -0
  223. package/src/acp/failure-error.ts +111 -0
  224. package/src/acp/prepare-agent-env.ts +0 -12
  225. package/src/acp/resolve-agent.test.ts +6 -45
  226. package/src/acp/resolve-agent.ts +2 -4
  227. package/src/acp/session-manager.test.ts +312 -0
  228. package/src/acp/session-manager.ts +102 -46
  229. package/src/agent/ax-tree-compaction.test.ts +4 -1
  230. package/src/agent/image-optimize.ts +17 -127
  231. package/src/agent/loop.ts +45 -192
  232. package/src/api/events/background-tool-completed.test.ts +51 -0
  233. package/src/api/events/background-tool-completed.ts +29 -0
  234. package/src/api/events/background-tool-started.test.ts +36 -0
  235. package/src/api/events/background-tool-started.ts +28 -0
  236. package/src/api/events/conversation-error.ts +0 -1
  237. package/src/api/events/open-panel.ts +33 -0
  238. package/src/api/events/subagent-status-changed.ts +4 -1
  239. package/src/api/index.ts +19 -0
  240. package/src/api/responses/conversation-message.ts +34 -0
  241. package/src/approvals/guardian-decision-primitive.ts +74 -24
  242. package/src/approvals/guardian-request-resolvers.ts +450 -122
  243. package/src/apps/app-store.ts +1 -0
  244. package/src/apps/shared-app-links-store.ts +1 -0
  245. package/src/avatar/identity-avatar.ts +3 -7
  246. package/src/background-wake/wake-intent-hooks.test.ts +1 -1
  247. package/src/calls/__tests__/fish-audio-client.test.ts +73 -0
  248. package/src/calls/__tests__/gateway-invite-reader.test.ts +170 -0
  249. package/src/calls/__tests__/relay-setup-router.test.ts +126 -102
  250. package/src/calls/call-controller.ts +134 -48
  251. package/src/calls/call-domain.ts +18 -42
  252. package/src/calls/call-pointer-messages.ts +51 -67
  253. package/src/calls/call-setup-flow-types.ts +76 -0
  254. package/src/calls/call-setup-flow.ts +1743 -0
  255. package/src/calls/call-speech-output.ts +87 -23
  256. package/src/calls/call-store.ts +4 -0
  257. package/src/calls/call-transport.ts +22 -36
  258. package/src/calls/fish-audio-client.ts +3 -0
  259. package/src/calls/gateway-invite-reader.ts +98 -0
  260. package/src/calls/guardian-wait-controller.ts +451 -0
  261. package/src/calls/media-stream-audio-transcode.ts +82 -2
  262. package/src/calls/media-stream-output.ts +142 -36
  263. package/src/calls/media-stream-server.ts +297 -178
  264. package/src/calls/media-stream-stt-session.ts +393 -50
  265. package/src/calls/media-turn-detector.ts +4 -6
  266. package/src/calls/relay-access-wait.ts +3 -4
  267. package/src/calls/relay-setup-router.ts +22 -43
  268. package/src/calls/relay-verification.ts +17 -24
  269. package/src/calls/resolve-call-tts-provider.ts +96 -29
  270. package/src/calls/stt-hints.ts +24 -87
  271. package/src/calls/telephony-credential-preflight.ts +159 -0
  272. package/src/calls/telephony-tts-capability.ts +183 -0
  273. package/src/calls/tts-call-strategy.ts +18 -20
  274. package/src/calls/twilio-provider.ts +3 -2
  275. package/src/calls/twilio-routes.ts +47 -351
  276. package/src/calls/types.ts +5 -3
  277. package/src/calls/voice-ingress-preflight.ts +9 -0
  278. package/src/calls/voice-quality.ts +13 -18
  279. package/src/calls/voice-session-bridge.ts +25 -35
  280. package/src/channels/__tests__/types.test.ts +28 -0
  281. package/src/channels/channel-verification-sessions.ts +7 -2
  282. package/src/channels/config.ts +0 -42
  283. package/src/channels/slack-thread-store.ts +12 -0
  284. package/src/channels/types.ts +23 -0
  285. package/src/cli/commands/__tests__/conversations-slack.test.ts +1 -1
  286. package/src/cli/commands/__tests__/schedules.test.ts +83 -34
  287. package/src/cli/commands/__tests__/status.test.ts +42 -2
  288. package/src/cli/commands/contacts.ts +2 -2
  289. package/src/cli/commands/credentials.ts +17 -0
  290. package/src/cli/commands/monitoring.ts +191 -0
  291. package/src/cli/commands/schedules-worker.ts +175 -0
  292. package/src/cli/commands/schedules.ts +169 -43
  293. package/src/cli/commands/status.ts +12 -1
  294. package/src/cli/commands/tools.ts +5 -1
  295. package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +1 -0
  296. package/src/cli/lib/__tests__/search-plugins.test.ts +29 -1
  297. package/src/cli/lib/__tests__/toggle-plugin.test.ts +7 -0
  298. package/src/cli/lib/search-plugins.ts +6 -0
  299. package/src/cli/lib/toggle-plugin.ts +21 -2
  300. package/src/cli/program.ts +3 -1
  301. package/src/config/__tests__/deployment-context-defaults.test.ts +23 -0
  302. package/src/config/acp-defaults.test.ts +0 -10
  303. package/src/config/acp-defaults.ts +0 -6
  304. package/src/config/bundled-skills/acp/SKILL.md +13 -45
  305. package/src/config/bundled-skills/acp/TOOLS.json +3 -3
  306. package/src/config/bundled-skills/app-builder/SKILL.md +3 -3
  307. package/src/config/bundled-skills/app-builder/references/RESPONSIVE.md +9 -9
  308. package/src/config/bundled-skills/contacts/tools/contact-merge.ts +1 -1
  309. package/src/config/bundled-skills/contacts/tools/contact-search.ts +1 -1
  310. package/src/config/bundled-skills/phone-calls/SKILL.md +1 -1
  311. package/src/config/bundled-skills/phone-calls/references/CONFIG.md +9 -9
  312. package/src/config/bundled-skills/phone-calls/references/TROUBLESHOOTING.md +10 -7
  313. package/src/config/bundled-skills/skill-management/TOOLS.json +10 -0
  314. package/src/config/call-site-defaults.ts +0 -2
  315. package/src/config/feature-flag-registry.json +16 -8
  316. package/src/config/loader.ts +11 -0
  317. package/src/config/schema.ts +9 -1
  318. package/src/config/schemas/__tests__/memory-v2.test.ts +2 -2
  319. package/src/config/schemas/__tests__/memory-v3.test.ts +64 -0
  320. package/src/config/schemas/call-site-catalog.ts +0 -12
  321. package/src/config/schemas/calls.ts +8 -0
  322. package/src/config/schemas/elevenlabs.ts +1 -1
  323. package/src/config/schemas/llm.ts +0 -2
  324. package/src/config/schemas/memory-v2.ts +1 -1
  325. package/src/config/schemas/memory-v3.ts +82 -0
  326. package/src/config/schemas/monitoring.ts +61 -0
  327. package/src/config/schemas/schedules.ts +35 -0
  328. package/src/config/seed-inference-profiles.ts +2 -2
  329. package/src/config/skills-plugin-filter.test.ts +96 -0
  330. package/src/config/skills.ts +26 -0
  331. package/src/contacts/__tests__/member-write-relay.test.ts +156 -4
  332. package/src/contacts/canonical-guardian-store.ts +5 -0
  333. package/src/contacts/contact-store.ts +81 -35
  334. package/src/contacts/contacts-write.ts +50 -25
  335. package/src/contacts/member-write-relay.ts +115 -31
  336. package/src/contacts/types.ts +0 -8
  337. package/src/context/compactor.ts +29 -15
  338. package/src/context/outbound-sanitize.ts +202 -0
  339. package/src/daemon/__tests__/abort-null-controller.test.ts +167 -0
  340. package/src/daemon/__tests__/conversation-tool-setup-plugin-scope.test.ts +122 -0
  341. package/src/daemon/__tests__/embedding-reconcile.test.ts +99 -16
  342. package/src/daemon/__tests__/wake-conversation-ops-completion.test.ts +107 -0
  343. package/src/daemon/abort-watchdog.ts +7 -0
  344. package/src/daemon/app-source-watcher.ts +9 -0
  345. package/src/daemon/assistant-attachments.ts +42 -0
  346. package/src/daemon/conversation-agent-loop-handlers.ts +256 -147
  347. package/src/daemon/conversation-agent-loop.ts +61 -68
  348. package/src/daemon/conversation-error.ts +1 -15
  349. package/src/daemon/conversation-history.ts +32 -236
  350. package/src/daemon/conversation-initial-prompt.ts +77 -0
  351. package/src/daemon/conversation-lifecycle.ts +25 -1
  352. package/src/daemon/conversation-messaging.ts +2 -2
  353. package/src/daemon/conversation-plugin-scope.ts +38 -0
  354. package/src/daemon/conversation-process.ts +50 -14
  355. package/src/daemon/conversation-runtime-assembly.ts +36 -6
  356. package/src/daemon/conversation-skill-tools.ts +19 -3
  357. package/src/daemon/conversation-store.ts +3 -3
  358. package/src/daemon/conversation-surfaces.ts +171 -0
  359. package/src/daemon/conversation-tool-setup.test.ts +95 -0
  360. package/src/daemon/conversation-tool-setup.ts +116 -1
  361. package/src/daemon/conversation-turn-finalize.ts +211 -0
  362. package/src/daemon/conversation.ts +92 -1
  363. package/src/daemon/disk-pressure-policy.ts +2 -1
  364. package/src/daemon/event-loop-watchdog.ts +22 -6
  365. package/src/daemon/external-plugins-bootstrap.ts +47 -134
  366. package/src/daemon/handlers/__tests__/config-a2a-accept.test.ts +1 -1
  367. package/src/daemon/handlers/__tests__/config-a2a-complete.test.ts +26 -14
  368. package/src/daemon/handlers/__tests__/config-a2a-invite.test.ts +27 -14
  369. package/src/daemon/handlers/__tests__/config-channels.test.ts +0 -5
  370. package/src/daemon/handlers/config-a2a.ts +7 -14
  371. package/src/daemon/handlers/config-channels.ts +2 -10
  372. package/src/daemon/handlers/conversation-history.ts +3 -1
  373. package/src/daemon/handlers/conversations.ts +0 -52
  374. package/src/daemon/handlers/shared.ts +4 -24
  375. package/src/daemon/handlers/skills.ts +4 -1
  376. package/src/daemon/host-cu-proxy.ts +1 -1
  377. package/src/daemon/lifecycle.ts +59 -472
  378. package/src/daemon/message-protocol.ts +3 -3
  379. package/src/daemon/message-provenance.ts +12 -14
  380. package/src/daemon/message-types/background-tools.ts +28 -0
  381. package/src/daemon/message-types/contacts.ts +2 -10
  382. package/src/daemon/message-types/conversations.ts +10 -0
  383. package/src/daemon/message-types/integrations.ts +2 -0
  384. package/src/daemon/message-types/surfaces.ts +1 -0
  385. package/src/daemon/message-types/sync.ts +1 -0
  386. package/src/daemon/pointer-conversation-trust.ts +23 -9
  387. package/src/daemon/pointer-turn-runner.ts +174 -0
  388. package/src/daemon/providers-setup.ts +16 -0
  389. package/src/daemon/shutdown-handlers.ts +139 -47
  390. package/src/daemon/skill-memory-refresh.ts +2 -2
  391. package/src/daemon/sqlite-corruption-watchdog.test.ts +217 -0
  392. package/src/daemon/sqlite-corruption-watchdog.ts +155 -0
  393. package/src/daemon/tool-setup-types.ts +8 -0
  394. package/src/daemon/trust-context.ts +21 -6
  395. package/src/daemon/turn-latency-tracker.test.ts +93 -0
  396. package/src/daemon/turn-latency-tracker.ts +38 -0
  397. package/src/daemon/wake-conversation-ops.ts +10 -8
  398. package/src/documents/document-comments-store.test.ts +7 -1
  399. package/src/documents/document-comments-store.ts +8 -0
  400. package/src/documents/document-store.ts +15 -0
  401. package/src/export/__tests__/transcript-formatter.test.ts +11 -0
  402. package/src/export/transcript-formatter.ts +49 -67
  403. package/src/home/__tests__/feed-writer.test.ts +247 -1
  404. package/src/home/conversation-starter-checkpoints.ts +1 -0
  405. package/src/home/feed-writer.ts +61 -0
  406. package/src/home/job-handlers/conversation-starters.ts +1 -0
  407. package/src/hooks/hook-loader.ts +12 -0
  408. package/src/hooks/registry.ts +32 -4
  409. package/src/inbound/public-ingress-urls.ts +0 -17
  410. package/src/ipc/__tests__/contacts-info-ipc.test.ts +285 -0
  411. package/src/ipc/assistant-server.ts +28 -7
  412. package/src/ipc/routes/__tests__/contacts-mirror-apply-atomicity.test.ts +62 -0
  413. package/src/ipc/routes/__tests__/contacts-mirror-apply.test.ts +272 -0
  414. package/src/ipc/routes/__tests__/contacts-mirror-ipc-routes.test.ts +322 -0
  415. package/src/ipc/routes/__tests__/invite-ipc-routes.test.ts +60 -4
  416. package/src/ipc/routes/contacts-info-ipc-routes.ts +239 -0
  417. package/src/ipc/routes/contacts-mirror-ipc-routes.ts +202 -0
  418. package/src/ipc/routes/db-proxy.ts +5 -2
  419. package/src/ipc/routes/invite-ipc-routes.ts +53 -39
  420. package/src/jobs/worker.ts +9 -2
  421. package/src/live-voice/__tests__/live-voice-archive.test.ts +10 -1
  422. package/src/live-voice/live-voice-archive.ts +5 -0
  423. package/src/messaging/providers/__tests__/transport-dispatch.test.ts +22 -6
  424. package/src/messaging/providers/channel-transport.ts +6 -0
  425. package/src/messaging/providers/index.ts +3 -0
  426. package/src/messaging/providers/slack/api.test.ts +49 -1
  427. package/src/messaging/providers/slack/api.ts +110 -0
  428. package/src/messaging/providers/slack/send.test.ts +85 -0
  429. package/src/messaging/providers/slack/send.ts +100 -28
  430. package/src/messaging/providers/slack/transport.ts +7 -13
  431. package/src/messaging/providers/whatsapp/send.ts +29 -9
  432. package/src/monitoring/__tests__/control.test.ts +235 -0
  433. package/src/monitoring/__tests__/sample-ring-buffer.test.ts +96 -0
  434. package/src/monitoring/control.ts +105 -0
  435. package/src/monitoring/resource-sampler.ts +258 -0
  436. package/src/monitoring/sample-ring-buffer.ts +108 -0
  437. package/src/monitoring/worker.ts +94 -0
  438. package/src/notifications/README.md +1 -1
  439. package/src/notifications/access-request-copy.ts +94 -23
  440. package/src/notifications/adapters/slack.ts +89 -21
  441. package/src/notifications/approval-card-builder.ts +33 -18
  442. package/src/notifications/approval-card-data.ts +48 -15
  443. package/src/notifications/broadcaster.ts +25 -16
  444. package/src/notifications/decision-engine.ts +131 -32
  445. package/src/notifications/guardian-question-mode.ts +58 -10
  446. package/src/permissions/approval-policy.ts +8 -1
  447. package/src/permissions/checker.ts +2 -0
  448. package/src/permissions/gateway-threshold-reader.ts +7 -6
  449. package/src/permissions/ipc-risk-types.ts +1 -0
  450. package/src/permissions/secret-prompter.ts +11 -3
  451. package/src/permissions/types.ts +2 -2
  452. package/src/persistence/__tests__/conversation-queries-search.test.ts +397 -0
  453. package/src/persistence/__tests__/conversation-search-lexical.test.ts +103 -0
  454. package/src/persistence/__tests__/jobs-worker-memory-disabled.test.ts +95 -0
  455. package/src/persistence/__tests__/slow-query-log.test.ts +266 -0
  456. package/src/persistence/__tests__/slow-sync-log.test.ts +155 -0
  457. package/src/persistence/a2a-invite-store.ts +205 -0
  458. package/src/persistence/attachments-store.ts +94 -10
  459. package/src/persistence/checkpoints.ts +45 -0
  460. package/src/persistence/conversation-crud.ts +453 -260
  461. package/src/persistence/conversation-display-order-migration.ts +8 -2
  462. package/src/persistence/conversation-group-migration.test.ts +8 -0
  463. package/src/persistence/conversation-group-migration.ts +9 -0
  464. package/src/persistence/conversation-queries.ts +244 -125
  465. package/src/persistence/conversation-row-batch-delete.ts +9 -5
  466. package/src/persistence/conversation-search-lexical.ts +49 -0
  467. package/src/persistence/db-async-query.ts +42 -0
  468. package/src/persistence/db-connection.ts +13 -0
  469. package/src/persistence/delivery-channels.ts +0 -82
  470. package/src/persistence/delivery-crud.ts +27 -6
  471. package/src/persistence/embeddings/__tests__/embedding-identity.test.ts +18 -5
  472. package/src/persistence/embeddings/__tests__/messages-lexical-index.test.ts +334 -0
  473. package/src/persistence/embeddings/embedding-identity.ts +17 -5
  474. package/src/persistence/embeddings/embedding-runtime-manager.ts +31 -0
  475. package/src/persistence/embeddings/messages-lexical-index.ts +396 -0
  476. package/src/persistence/embeddings/qdrant-client.ts +81 -5
  477. package/src/persistence/group-crud.ts +13 -1
  478. package/src/persistence/job-handlers/__tests__/message-lexical-backfill.test.ts +506 -0
  479. package/src/persistence/job-handlers/__tests__/message-lexical-enqueue.test.ts +164 -0
  480. package/src/persistence/job-handlers/__tests__/message-lexical.test.ts +259 -0
  481. package/src/persistence/job-handlers/cleanup.ts +57 -9
  482. package/src/persistence/job-handlers/message-lexical-backfill.ts +187 -0
  483. package/src/persistence/job-handlers/message-lexical.ts +251 -0
  484. package/src/persistence/jobs-store.ts +102 -8
  485. package/src/persistence/jobs-worker.ts +68 -28
  486. package/src/persistence/llm-request-log-store.ts +35 -19
  487. package/src/persistence/llm-usage-store.ts +11 -0
  488. package/src/persistence/memory-lifecycle-hooks.test.ts +65 -0
  489. package/src/persistence/memory-lifecycle-hooks.ts +55 -1
  490. package/src/persistence/migrations/028-call-session-mode.ts +1 -1
  491. package/src/persistence/migrations/116-messages-fts.ts +8 -1
  492. package/src/persistence/migrations/311-create-subagents-table.ts +50 -0
  493. package/src/persistence/migrations/312-drop-inbox-conversation-state-table.ts +15 -0
  494. package/src/persistence/migrations/313-drop-messages-fts.ts +17 -0
  495. package/src/persistence/migrations/314-add-conversation-enabled-plugins.ts +25 -0
  496. package/src/persistence/migrations/315-create-a2a-invites.ts +58 -0
  497. package/src/persistence/migrations/316-drop-contact-channels-invite-id.ts +28 -0
  498. package/src/persistence/migrations/317-canonical-guardian-requester-signals.ts +24 -0
  499. package/src/persistence/migrations/318-drop-contact-channel-telemetry.ts +32 -0
  500. package/src/persistence/migrations/__tests__/315-create-a2a-invites.test.ts +161 -0
  501. package/src/persistence/migrations/__tests__/316-drop-contact-channels-invite-id.test.ts +156 -0
  502. package/src/persistence/migrations/__tests__/318-drop-contact-channel-telemetry.test.ts +134 -0
  503. package/src/persistence/migrations/__tests__/migration-prefix-guard.test.ts +99 -0
  504. package/src/persistence/raw-query.ts +50 -11
  505. package/src/persistence/schema/a2a.ts +14 -0
  506. package/src/persistence/schema/contacts.ts +0 -62
  507. package/src/persistence/schema/conversation-groups.ts +12 -0
  508. package/src/persistence/schema/conversations.ts +2 -0
  509. package/src/persistence/schema/documents.ts +65 -0
  510. package/src/persistence/schema/guardian.ts +4 -0
  511. package/src/persistence/schema/index.ts +4 -0
  512. package/src/persistence/schema/memory-injection.ts +62 -0
  513. package/src/persistence/schema/workflows.ts +55 -0
  514. package/src/persistence/slow-query-log.ts +437 -0
  515. package/src/persistence/slow-sync-log.ts +204 -0
  516. package/src/persistence/steps.ts +16 -0
  517. package/src/persistence/subagent-store.ts +125 -0
  518. package/src/persistence/worker-control.ts +45 -238
  519. package/src/plugin-api/event-hub-facade.ts +172 -0
  520. package/src/plugin-api/index.ts +9 -1
  521. package/src/plugin-api/types.ts +40 -9
  522. package/src/plugins/defaults/default-plugin-names.ts +60 -0
  523. package/src/plugins/defaults/index.ts +19 -29
  524. package/src/plugins/defaults/memory/__tests__/conversation-queries.test.ts +46 -67
  525. package/src/plugins/defaults/memory/__tests__/db-async-query.test.ts +36 -1
  526. package/src/plugins/defaults/memory/__tests__/find-most-recent-retrospective-for.test.ts +2 -4
  527. package/src/plugins/defaults/memory/__tests__/jobs-worker-v2-graph-trigger-embed.test.ts +2 -2
  528. package/src/plugins/defaults/memory/__tests__/jobs-worker-v2-schedule.test.ts +9 -0
  529. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +7 -4
  530. package/src/plugins/defaults/memory/config.ts +11 -0
  531. package/src/plugins/defaults/memory/context-search/agent-runner.ts +1 -1
  532. package/src/plugins/defaults/memory/context-search/sources/conversations.ts +124 -113
  533. package/src/plugins/defaults/memory/embeddings.ts +77 -0
  534. package/src/plugins/defaults/memory/find-most-recent-retrospective-for.ts +66 -0
  535. package/src/plugins/defaults/memory/frontmatter.ts +50 -0
  536. package/src/plugins/defaults/memory/graph/bootstrap.test.ts +36 -10
  537. package/src/plugins/defaults/memory/graph/bootstrap.ts +6 -5
  538. package/src/plugins/defaults/memory/graph/consolidation.ts +3 -5
  539. package/src/plugins/defaults/memory/graph/extraction.ts +3 -6
  540. package/src/plugins/defaults/memory/graph/graph-search.ts +12 -23
  541. package/src/plugins/defaults/memory/graph/narrative.ts +3 -5
  542. package/src/plugins/defaults/memory/graph/pattern-scan.ts +3 -5
  543. package/src/plugins/defaults/memory/graph/retriever.test.ts +1 -6
  544. package/src/plugins/defaults/memory/graph/retriever.ts +5 -10
  545. package/src/plugins/defaults/memory/graph/tools.ts +1 -1
  546. package/src/plugins/defaults/memory/hooks/init.ts +31 -5
  547. package/src/plugins/defaults/memory/indexer.ts +2 -3
  548. package/src/plugins/defaults/memory/injectors.ts +2 -2
  549. package/src/plugins/defaults/memory/job-handler-registration.ts +96 -0
  550. package/src/plugins/defaults/memory/job-handlers/backfill.ts +4 -26
  551. package/src/plugins/defaults/memory/job-handlers/embedding.test.ts +6 -19
  552. package/src/plugins/defaults/memory/job-handlers/embedding.ts +9 -22
  553. package/src/plugins/defaults/memory/job-handlers/index-maintenance.ts +2 -3
  554. package/src/plugins/defaults/memory/job-handlers.ts +13 -12
  555. package/src/plugins/defaults/memory/jobs/embed-concept-page.ts +1 -1
  556. package/src/plugins/defaults/memory/llm-helpers.ts +76 -0
  557. package/src/plugins/defaults/memory/memory-retrospective-job.ts +8 -5
  558. package/src/plugins/defaults/memory/memory-retrospective-startup-cleanup.ts +2 -2
  559. package/src/plugins/defaults/memory/memory-v2-concept-frequency.ts +2 -0
  560. package/src/plugins/defaults/memory/persistence-hooks.ts +46 -1
  561. package/src/plugins/defaults/memory/pkb/pkb-index.ts +1 -5
  562. package/src/plugins/defaults/memory/pkb/pkb-search.ts +2 -2
  563. package/src/plugins/defaults/memory/routes/__tests__/memory-v2-simulate-route.test.ts +4 -6
  564. package/src/plugins/defaults/memory/routes/memory-item-routes.ts +1 -1
  565. package/src/plugins/defaults/memory/search/semantic.ts +2 -2
  566. package/src/plugins/defaults/memory/startup.ts +244 -0
  567. package/src/plugins/defaults/memory/task-memory-cleanup.ts +8 -0
  568. package/src/{daemon → plugins/defaults/memory/v2}/__tests__/memory-v2-startup.test.ts +6 -6
  569. package/src/plugins/defaults/memory/v2/__tests__/migration.test.ts +2 -13
  570. package/src/plugins/defaults/memory/v2/__tests__/router.test.ts +4 -5
  571. package/src/plugins/defaults/memory/v2/__tests__/sweep-job.test.ts +1 -8
  572. package/src/plugins/defaults/memory/v2/activation.ts +2 -4
  573. package/src/plugins/defaults/memory/v2/cli-command-store.ts +2 -4
  574. package/src/plugins/defaults/memory/v2/frontmatter-sweep.ts +1 -1
  575. package/src/{daemon → plugins/defaults/memory/v2}/memory-v2-startup.test.ts +8 -8
  576. package/src/{daemon → plugins/defaults/memory/v2}/memory-v2-startup.ts +20 -22
  577. package/src/plugins/defaults/memory/v2/migration.ts +2 -5
  578. package/src/plugins/defaults/memory/v2/page-store.ts +1 -1
  579. package/src/plugins/defaults/memory/v2/qdrant.ts +2 -3
  580. package/src/plugins/defaults/memory/v2/router.ts +5 -5
  581. package/src/plugins/defaults/memory/v2/sim.ts +2 -4
  582. package/src/plugins/defaults/memory/v2/skill-store.ts +2 -4
  583. package/src/plugins/defaults/memory/v2/sweep-job.ts +6 -6
  584. package/src/plugins/defaults/memory/v3/__tests__/carry-integration.test.ts +11 -9
  585. package/src/plugins/defaults/memory/v3/__tests__/dense.test.ts +107 -2
  586. package/src/plugins/defaults/memory/v3/__tests__/gate-flag.test.ts +69 -0
  587. package/src/plugins/defaults/memory/v3/__tests__/gate.test.ts +181 -0
  588. package/src/plugins/defaults/memory/v3/__tests__/live-integration.test.ts +7 -3
  589. package/src/plugins/defaults/memory/v3/__tests__/orchestrate.test.ts +345 -8
  590. package/src/plugins/defaults/memory/v3/__tests__/pool-select.test.ts +1 -4
  591. package/src/plugins/defaults/memory/v3/__tests__/section-needle.test.ts +64 -0
  592. package/src/plugins/defaults/memory/v3/__tests__/shadow-integration.test.ts +18 -13
  593. package/src/plugins/defaults/memory/v3/__tests__/shadow-plugin.test.ts +62 -1
  594. package/src/plugins/defaults/memory/v3/card.ts +1 -4
  595. package/src/plugins/defaults/memory/v3/dense.ts +60 -25
  596. package/src/plugins/defaults/memory/v3/edge.ts +1 -1
  597. package/src/plugins/defaults/memory/v3/gate-flag.ts +20 -0
  598. package/src/plugins/defaults/memory/v3/gate.ts +158 -0
  599. package/src/plugins/defaults/memory/v3/maintain-job.ts +2 -4
  600. package/src/plugins/defaults/memory/v3/orchestrate.ts +186 -25
  601. package/src/plugins/defaults/memory/v3/pool-select.test.ts +7 -9
  602. package/src/plugins/defaults/memory/v3/pool-select.ts +6 -6
  603. package/src/plugins/defaults/memory/v3/prune.ts +2 -2
  604. package/src/plugins/defaults/memory/v3/section-dense-store.ts +8 -11
  605. package/src/plugins/defaults/memory/v3/section-needle.ts +27 -7
  606. package/src/plugins/defaults/memory/v3/shadow-plugin.ts +10 -0
  607. package/src/plugins/defaults/memory/v3-eval/eval-packets.ts +1 -4
  608. package/src/plugins/defaults/title-generate/hooks/user-prompt-submit.ts +5 -0
  609. package/src/plugins/defaults/turn-context/injectors.ts +1 -0
  610. package/src/plugins/defaults/turn-context/unified-turn-context.ts +12 -0
  611. package/src/plugins/injector-registry.ts +13 -2
  612. package/src/plugins/mtime-cache.ts +36 -47
  613. package/src/plugins/pipeline.ts +15 -1
  614. package/src/plugins/types.ts +14 -23
  615. package/src/prompts/__tests__/system-prompt.test.ts +170 -32
  616. package/src/prompts/persona-resolver.ts +13 -9
  617. package/src/prompts/system-prompt.ts +30 -9
  618. package/src/prompts/templates/BOOTSTRAP.md +16 -119
  619. package/src/prompts/templates/SOUL.md +43 -13
  620. package/src/prompts/templates/system-sections.ts +32 -27
  621. package/src/prompts/templates/users/default.md +19 -0
  622. package/src/providers/__tests__/placeholder-sentinels.test.ts +56 -0
  623. package/src/providers/inference/__tests__/base-url-route-validation.test.ts +96 -1
  624. package/src/providers/model-intents.ts +2 -2
  625. package/src/providers/openai/chat-completions-provider.ts +14 -1
  626. package/src/providers/openai/responses-provider.ts +18 -0
  627. package/src/providers/placeholder-sentinels.ts +18 -8
  628. package/src/providers/retry.ts +1 -0
  629. package/src/providers/speech-to-text/__tests__/provider-catalog.test.ts +0 -64
  630. package/src/providers/speech-to-text/__tests__/resolve.test.ts +92 -79
  631. package/src/providers/speech-to-text/deepgram-realtime.test.ts +98 -0
  632. package/src/providers/speech-to-text/deepgram-realtime.ts +62 -3
  633. package/src/providers/speech-to-text/provider-catalog.ts +0 -89
  634. package/src/providers/speech-to-text/resolve.ts +55 -0
  635. package/src/providers/tool-progress-events.test.ts +50 -0
  636. package/src/providers/tool-progress-events.ts +99 -0
  637. package/src/providers/types.ts +1 -1
  638. package/src/providers/vellum-model-routing.test.ts +51 -0
  639. package/src/providers/vellum-model-routing.ts +84 -0
  640. package/src/runtime/AGENTS.md +5 -4
  641. package/src/runtime/__tests__/agent-wake.test.ts +158 -1
  642. package/src/runtime/__tests__/channel-verification-service.test.ts +2 -2
  643. package/src/runtime/__tests__/trust-verdict-consumer.test.ts +37 -8
  644. package/src/runtime/access-request-helper.ts +154 -3
  645. package/src/runtime/actor-trust-resolver.ts +7 -21
  646. package/src/runtime/agent-wake.ts +41 -1
  647. package/src/runtime/assistant-event.ts +86 -12
  648. package/src/runtime/auth/__tests__/route-policy.test.ts +31 -0
  649. package/src/runtime/channel-approval-types.ts +52 -2
  650. package/src/runtime/channel-approvals.ts +23 -8
  651. package/src/runtime/channel-invite-transport.ts +2 -2
  652. package/src/runtime/channel-invite-transports/voice.ts +1 -1
  653. package/src/runtime/channel-readiness-service.ts +3 -5
  654. package/src/runtime/channel-retry-sweep.ts +18 -24
  655. package/src/runtime/channel-verification-service.ts +9 -11
  656. package/src/runtime/finalize-event-delivery.ts +30 -16
  657. package/src/runtime/guardian-action-service.ts +10 -11
  658. package/src/runtime/guardian-reply-router.ts +122 -21
  659. package/src/runtime/http-server.ts +44 -101
  660. package/src/runtime/introduction-policy.test.ts +214 -0
  661. package/src/runtime/introduction-policy.ts +284 -0
  662. package/src/runtime/invite-service.ts +104 -361
  663. package/src/runtime/local-actor-identity.test.ts +38 -0
  664. package/src/runtime/local-actor-identity.ts +14 -0
  665. package/src/runtime/middleware/__tests__/rate-limiter.test.ts +22 -0
  666. package/src/runtime/middleware/rate-limiter.ts +30 -0
  667. package/src/runtime/middleware/twilio-validation.ts +5 -7
  668. package/src/runtime/migrations/__tests__/gcs-signed-url.test.ts +28 -0
  669. package/src/runtime/migrations/origin-mode.ts +6 -6
  670. package/src/runtime/pre-first-message-gate.ts +1 -0
  671. package/src/runtime/routes/__tests__/channel-verification-revoke.test.ts +6 -35
  672. package/src/runtime/routes/__tests__/consolidation-routes.test.ts +24 -3
  673. package/src/runtime/routes/__tests__/contact-routes.test.ts +80 -15
  674. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +7 -1
  675. package/src/runtime/routes/__tests__/global-search-routes.test.ts +0 -3
  676. package/src/runtime/routes/__tests__/home-feed-routes.test.ts +142 -1
  677. package/src/runtime/routes/__tests__/invite-relay-routes.test.ts +119 -30
  678. package/src/runtime/routes/__tests__/migration-import-allowed-hosts-env.test.ts +70 -0
  679. package/src/runtime/routes/__tests__/monitoring-routes.test.ts +194 -0
  680. package/src/runtime/routes/__tests__/plugins-routes.test.ts +755 -29
  681. package/src/runtime/routes/__tests__/retrospective-routes.test.ts +4 -0
  682. package/src/runtime/routes/__tests__/schedule-routes-create.test.ts +70 -0
  683. package/src/runtime/routes/__tests__/schedule-worker-routes.test.ts +161 -0
  684. package/src/runtime/routes/__tests__/surface-action-routes.test.ts +1 -1
  685. package/src/runtime/routes/__tests__/surface-content-routes.test.ts +1 -1
  686. package/src/runtime/routes/acp-routes.ts +15 -3
  687. package/src/runtime/routes/app-management-routes.ts +4 -7
  688. package/src/runtime/routes/approval-routes.ts +4 -0
  689. package/src/runtime/routes/attachment-routes.ts +54 -3
  690. package/src/runtime/routes/avatar-routes.ts +0 -4
  691. package/src/runtime/routes/background-tool-routes.ts +34 -5
  692. package/src/runtime/routes/btw-routes.ts +32 -4
  693. package/src/runtime/routes/channel-route-shared.ts +18 -14
  694. package/src/runtime/routes/channel-verification-routes.ts +0 -3
  695. package/src/runtime/routes/contact-routes.ts +298 -117
  696. package/src/runtime/routes/conversation-list-routes.ts +6 -0
  697. package/src/runtime/routes/conversation-management-routes.ts +74 -37
  698. package/src/runtime/routes/conversation-query-routes.ts +5 -2
  699. package/src/runtime/routes/conversation-routes.ts +222 -82
  700. package/src/runtime/routes/conversations-import-routes.ts +8 -1
  701. package/src/runtime/routes/credential-prompt-routes.ts +20 -5
  702. package/src/runtime/routes/debug-routes.ts +1 -0
  703. package/src/runtime/routes/documents-routes.ts +5 -2
  704. package/src/runtime/routes/empty-state-greeting-cache.ts +37 -10
  705. package/src/runtime/routes/global-search-routes.ts +2 -1
  706. package/src/runtime/routes/guardian-approval-interception.ts +51 -0
  707. package/src/runtime/routes/home-feed-routes.ts +60 -1
  708. package/src/runtime/routes/identity-routes.ts +4 -87
  709. package/src/runtime/routes/inbound-message-handler.ts +82 -41
  710. package/src/runtime/routes/inbound-stages/acl-enforcement.test.ts +430 -57
  711. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +302 -572
  712. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +76 -270
  713. package/src/runtime/routes/inbound-stages/background-dispatch.ts +21 -116
  714. package/src/runtime/routes/inbound-stages/edit-intercept.ts +9 -1
  715. package/src/runtime/routes/inbound-stages/escalation-intercept.test.ts +158 -0
  716. package/src/runtime/routes/inbound-stages/escalation-intercept.ts +22 -1
  717. package/src/runtime/routes/index.ts +6 -0
  718. package/src/runtime/routes/inference-provider-connection-routes.ts +22 -16
  719. package/src/runtime/routes/internal-twilio-routes.ts +0 -15
  720. package/src/runtime/routes/messages-lexical-routes.ts +75 -0
  721. package/src/runtime/routes/migration-routes.ts +69 -6
  722. package/src/runtime/routes/monitoring-routes.ts +237 -0
  723. package/src/runtime/routes/plugins-routes.ts +329 -8
  724. package/src/runtime/routes/schedule-routes.ts +49 -8
  725. package/src/runtime/routes/schedule-worker-routes.ts +210 -0
  726. package/src/runtime/routes/secret-routes.ts +1 -1
  727. package/src/runtime/routes/surface-conversation-resolver.ts +1 -0
  728. package/src/runtime/services/__tests__/analyze-conversation.test.ts +54 -9
  729. package/src/runtime/services/__tests__/auto-analysis-enqueue.test.ts +30 -6
  730. package/src/runtime/services/__tests__/conversation-analyze-job.test.ts +43 -8
  731. package/src/runtime/services/__tests__/conversation-serializer.test.ts +20 -0
  732. package/src/runtime/services/analyze-conversation.ts +14 -4
  733. package/src/runtime/services/conversation-serializer.ts +5 -0
  734. package/src/runtime/slack-no-response.ts +47 -0
  735. package/src/runtime/slack-reply-session.test.ts +633 -0
  736. package/src/runtime/slack-reply-session.ts +382 -0
  737. package/src/runtime/slack-task-progress.ts +95 -0
  738. package/src/runtime/sync/resource-sync-events.ts +25 -0
  739. package/src/runtime/tool-grant-request-helper.ts +17 -1
  740. package/src/runtime/trust-class.test.ts +141 -0
  741. package/src/runtime/trust-class.ts +121 -0
  742. package/src/runtime/trust-verdict-consumer.ts +7 -6
  743. package/src/schedule/__tests__/run-script.test.ts +21 -0
  744. package/src/schedule/__tests__/worker-control.test.ts +241 -0
  745. package/src/schedule/run-script.ts +13 -6
  746. package/src/schedule/schedule-usage-store.ts +1 -0
  747. package/src/schedule/scheduler.ts +178 -127
  748. package/src/schedule/worker-control.ts +118 -0
  749. package/src/schedule/worker.ts +131 -0
  750. package/src/skills/__tests__/categories-cache.test.ts +38 -0
  751. package/src/skills/categories-cache.ts +40 -5
  752. package/src/skills/managed-store.ts +41 -6
  753. package/src/skills/validate-input.ts +47 -0
  754. package/src/stt/types.ts +5 -9
  755. package/src/subagent/manager.ts +267 -39
  756. package/src/subagent/types.ts +6 -2
  757. package/src/subagent/validate-allowlists.ts +65 -0
  758. package/src/telemetry/watchdog-direct-emit.test.ts +109 -0
  759. package/src/telemetry/watchdog-direct-emit.ts +89 -0
  760. package/src/tools/acp/list-agents.test.ts +0 -6
  761. package/src/tools/background-tool-registry.ts +61 -0
  762. package/src/tools/document/document-comment-tool.test.ts +4 -0
  763. package/src/tools/host-terminal/host-shell.test.ts +397 -0
  764. package/src/tools/host-terminal/host-shell.ts +203 -14
  765. package/src/tools/skills/find-similar-skills.test.ts +152 -1
  766. package/src/tools/skills/find-similar-skills.ts +30 -4
  767. package/src/tools/skills/load.ts +36 -0
  768. package/src/tools/skills/scaffold-managed.ts +66 -34
  769. package/src/tools/skills/skill-script-runner.ts +17 -0
  770. package/src/tools/skills/skill-tool-factory.ts +5 -1
  771. package/src/tools/terminal/safe-env.ts +3 -0
  772. package/src/tools/terminal/shell.test.ts +177 -0
  773. package/src/tools/terminal/shell.ts +98 -4
  774. package/src/tools/tool-approval-handler.ts +106 -29
  775. package/{node_modules/@vellumai/skill-host-contracts/src → src/tools}/tool-types.ts +16 -17
  776. package/src/tools/types.ts +29 -17
  777. package/src/tools/ui-surface/definitions.ts +3 -1
  778. package/src/tts/__tests__/provider-adapters.test.ts +37 -0
  779. package/src/tts/__tests__/provider-catalog.test.ts +21 -0
  780. package/src/tts/provider-catalog.ts +40 -9
  781. package/src/tts/providers/fish-audio-provider.ts +19 -10
  782. package/src/tts/types.ts +3 -4
  783. package/src/util/__tests__/process-tree.test.ts +10 -2
  784. package/src/util/__tests__/text-spacing.test.ts +53 -0
  785. package/src/util/cgroup-memory.ts +142 -0
  786. package/src/util/image-conversion.ts +281 -0
  787. package/src/util/phone.ts +1 -8
  788. package/src/util/platform.ts +33 -0
  789. package/src/util/process-tree.ts +10 -2
  790. package/src/util/text-spacing.ts +53 -0
  791. package/src/util/worker-process.ts +271 -0
  792. package/src/workflows/journal-store.ts +16 -1
  793. package/src/workflows/library.test.ts +35 -20
  794. package/src/workflows/library.ts +105 -66
  795. package/src/workspace/git-service.ts +1 -0
  796. package/src/workspace/migrations/011-backfill-installation-id.ts +83 -31
  797. package/src/workspace/migrations/041-backfill-google-gmail-settings-scope.ts +41 -22
  798. package/src/workspace/migrations/070-memory-v2-summary-schema-rebuild.ts +1 -1
  799. package/src/workspace/migrations/118-seed-now-md.ts +64 -0
  800. package/src/workspace/migrations/119-strip-persisted-memory-v3-tuning-defaults.ts +169 -0
  801. package/src/workspace/migrations/120-revise-onboarding-threads.ts +47 -0
  802. package/src/workspace/migrations/121-seed-default-user-guardrails.ts +82 -0
  803. package/src/workspace/migrations/122-relocate-default-user-boundary.ts +111 -0
  804. package/src/workspace/migrations/123-swap-quality-profile-to-fable.ts +85 -0
  805. package/src/workspace/migrations/__tests__/119-strip-persisted-memory-v3-tuning-defaults.test.ts +181 -0
  806. package/src/workspace/migrations/registry.ts +12 -0
  807. package/drizzle/0000_dizzy_maggott.sql +0 -301
  808. package/drizzle/meta/0000_snapshot.json +0 -1933
  809. package/drizzle/meta/_journal.json +0 -13
  810. package/node_modules/@vellumai/skill-host-contracts/__tests__/client.test.ts +0 -887
  811. package/node_modules/@vellumai/skill-host-contracts/bun.lock +0 -24
  812. package/node_modules/@vellumai/skill-host-contracts/package.json +0 -18
  813. package/node_modules/@vellumai/skill-host-contracts/src/assistant-event.ts +0 -97
  814. package/node_modules/@vellumai/skill-host-contracts/src/client.ts +0 -1349
  815. package/node_modules/@vellumai/skill-host-contracts/src/index.ts +0 -6
  816. package/node_modules/@vellumai/skill-host-contracts/src/runtime-mode.ts +0 -11
  817. package/node_modules/@vellumai/skill-host-contracts/src/server-message.ts +0 -32
  818. package/node_modules/@vellumai/skill-host-contracts/src/skill-host.ts +0 -333
  819. package/node_modules/@vellumai/skill-host-contracts/tsconfig.json +0 -20
  820. package/node_modules/@vellumai/skill-host-contracts/tsconfig.test.json +0 -12
  821. package/src/__tests__/invite-redemption-service.test.ts +0 -1180
  822. package/src/__tests__/invite-service-ipc.test.ts +0 -350
  823. package/src/__tests__/job-handler-registry-guard.test.ts +0 -125
  824. package/src/__tests__/regenerate-fire-and-forget-trace.test.ts +0 -203
  825. package/src/__tests__/relay-server.test.ts +0 -6153
  826. package/src/__tests__/telephony-stt-routing.test.ts +0 -329
  827. package/src/__tests__/voice-invite-redemption.test.ts +0 -702
  828. package/src/calls/relay-server.ts +0 -2463
  829. package/src/calls/telephony-stt-routing.ts +0 -145
  830. package/src/contacts/__tests__/contacts-write-revoke-relay.test.ts +0 -128
  831. package/src/daemon/__tests__/daemon-skill-host.test.ts +0 -273
  832. package/src/daemon/daemon-skill-host.ts +0 -262
  833. package/src/daemon/message-types/meet.ts +0 -143
  834. package/src/daemon/shutdown-registry.ts +0 -40
  835. package/src/ipc/__tests__/skill-server-bidirectional.test.ts +0 -253
  836. package/src/ipc/__tests__/skill-server.test.ts +0 -182
  837. package/src/ipc/skill-ipc-types.ts +0 -54
  838. package/src/ipc/skill-routes/__tests__/config.test.ts +0 -146
  839. package/src/ipc/skill-routes/__tests__/events-ipc.test.ts +0 -448
  840. package/src/ipc/skill-routes/__tests__/identity.test.ts +0 -62
  841. package/src/ipc/skill-routes/__tests__/log.test.ts +0 -133
  842. package/src/ipc/skill-routes/__tests__/memory.test.ts +0 -203
  843. package/src/ipc/skill-routes/__tests__/platform.test.ts +0 -111
  844. package/src/ipc/skill-routes/__tests__/providers.test.ts +0 -265
  845. package/src/ipc/skill-routes/__tests__/registries.test.ts +0 -400
  846. package/src/ipc/skill-routes/config.ts +0 -47
  847. package/src/ipc/skill-routes/events.ts +0 -147
  848. package/src/ipc/skill-routes/identity.ts +0 -21
  849. package/src/ipc/skill-routes/index.ts +0 -37
  850. package/src/ipc/skill-routes/log.ts +0 -40
  851. package/src/ipc/skill-routes/memory.ts +0 -92
  852. package/src/ipc/skill-routes/platform.ts +0 -39
  853. package/src/ipc/skill-routes/providers.ts +0 -162
  854. package/src/ipc/skill-routes/registries.ts +0 -338
  855. package/src/ipc/skill-server.ts +0 -831
  856. package/src/ipc/skill-socket-path.ts +0 -20
  857. package/src/jobs/register-job-handlers.ts +0 -74
  858. package/src/persistence/invite-store.ts +0 -481
  859. package/src/plugins/job-handler-registry.ts +0 -94
  860. package/src/providers/cache-control.ts +0 -26
  861. package/src/runtime/__tests__/runtime-mode.test.ts +0 -62
  862. package/src/runtime/invite-redemption-service.ts +0 -828
  863. package/src/runtime/invite-redemption-templates.ts +0 -46
  864. package/src/runtime/runtime-mode.ts +0 -40
  865. package/src/runtime/slack-dm-text-delivery.ts +0 -177
  866. package/src/util/voice-code.ts +0 -31
@@ -1,2463 +0,0 @@
1
- /**
2
- * WebSocket handler for Twilio ConversationRelay protocol.
3
- *
4
- * Manages real-time voice conversations over WebSocket. Each active call
5
- * has a single RelayConnection instance that processes inbound messages
6
- * from Twilio and can send text tokens back for TTS.
7
- */
8
-
9
- import { randomInt } from "node:crypto";
10
-
11
- import type { AdmissionPolicy, TrustVerdict } from "@vellumai/gateway-client";
12
- import type { ServerWebSocket } from "bun";
13
-
14
- import { revokeScopedApprovalGrantsForContext } from "../approvals/scoped-approval-grants.js";
15
- import { getCanonicalGuardianRequest } from "../contacts/canonical-guardian-store.js";
16
- import {
17
- getGuardianDelivery,
18
- getGuardianDeliveryFresh,
19
- voiceGuardianDisplayName,
20
- } from "../contacts/guardian-delivery-reader.js";
21
- import { getAssistantName } from "../daemon/identity-helpers.js";
22
- import type { ServerMessage } from "../daemon/message-protocol.js";
23
- import type { TrustContext } from "../daemon/trust-context.js";
24
- import { addMessage } from "../persistence/conversation-crud.js";
25
- import { resolveGuardianName } from "../prompts/user-reference.js";
26
- import { notifyGuardianOfAccessRequest } from "../runtime/access-request-helper.js";
27
- import {
28
- resolveActorTrust,
29
- toTrustContext,
30
- } from "../runtime/actor-trust-resolver.js";
31
- import {
32
- trustContextFromVerdict,
33
- verdictHasMemberIdentity,
34
- verdictMemberUnresolvable,
35
- } from "../runtime/trust-verdict-consumer.js";
36
- import {
37
- composeVerificationVoice,
38
- GUARDIAN_VERIFY_TEMPLATE_KEYS,
39
- } from "../runtime/verification-templates.js";
40
- import { parseJsonSafe } from "../util/json.js";
41
- import { getLogger } from "../util/logger.js";
42
- import {
43
- getAccessRequestPollIntervalMs,
44
- getTtsPlaybackDelayMs,
45
- getUserConsultationTimeoutMs,
46
- } from "./call-constants.js";
47
- import { CallController } from "./call-controller.js";
48
- import { addPointerMessage, formatDuration } from "./call-pointer-messages.js";
49
- import { speakSystemPrompt } from "./call-speech-output.js";
50
- import { fireCallTranscriptNotifier } from "./call-state.js";
51
- import { isTerminalState } from "./call-state-machine.js";
52
- import {
53
- getCallSession,
54
- recordCallEvent,
55
- updateCallSession,
56
- } from "./call-store.js";
57
- import { ConversationRelayTransport } from "./call-transport.js";
58
- import { getChannelAdmissionPolicy } from "./channel-admission-reader.js";
59
- import { finalizeCall } from "./finalize-call.js";
60
- import {
61
- getInboundTrustVerdict,
62
- getPhoneCallerVerdict,
63
- } from "./inbound-trust-reader.js";
64
- import {
65
- classifyWaitUtterance,
66
- emitAccessRequestCallbackHandoff,
67
- scheduleNextHeartbeat,
68
- } from "./relay-access-wait.js";
69
- import { routeSetup, type SetupResolved } from "./relay-setup-router.js";
70
- import {
71
- attemptInviteCodeRedemption,
72
- attemptVerificationCode,
73
- parseDigitsFromSpeech,
74
- } from "./relay-verification.js";
75
- import {
76
- extractPromptSpeakerMetadata,
77
- type PromptSpeakerContext,
78
- SpeakerIdentityTracker,
79
- } from "./speaker-identification.js";
80
-
81
- const log = getLogger("relay-server");
82
- const UUID_SHAPED_NAME =
83
- /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
84
-
85
- /**
86
- * Return the first whitespace-delimited token of a name, or `null` when the
87
- * input is null/blank. Used for greetings so "Carolina Flaherty" -> "Carolina".
88
- */
89
- function firstToken(name: string | null | undefined): string | null {
90
- if (!name) return null;
91
- const trimmed = name.trim();
92
- if (!trimmed) return null;
93
- return trimmed.split(/\s+/)[0] ?? null;
94
- }
95
-
96
- // ── ConversationRelay message types ──────────────────────────────────
97
-
98
- // Messages FROM Twilio
99
- interface RelaySetupMessage {
100
- type: "setup";
101
- callSid: string;
102
- from: string;
103
- to: string;
104
- customParameters?: Record<string, string>;
105
- }
106
-
107
- interface RelayPromptMessage {
108
- type: "prompt";
109
- voicePrompt: string;
110
- lang: string;
111
- last: boolean;
112
- speakerId?: string;
113
- speakerLabel?: string;
114
- speakerName?: string;
115
- speakerConfidence?: number;
116
- participantId?: string;
117
- participant?: {
118
- id?: string;
119
- name?: string;
120
- };
121
- speaker?: {
122
- id?: string;
123
- label?: string;
124
- name?: string;
125
- confidence?: number;
126
- };
127
- metadata?: Record<string, unknown>;
128
- providerMetadata?: Record<string, unknown>;
129
- }
130
-
131
- interface RelayInterruptMessage {
132
- type: "interrupt";
133
- utteranceUntilInterrupt: string;
134
- }
135
-
136
- interface RelayDtmfMessage {
137
- type: "dtmf";
138
- digit: string;
139
- }
140
-
141
- interface RelayErrorMessage {
142
- type: "error";
143
- description: string;
144
- }
145
-
146
- type RelayInboundMessage =
147
- | RelaySetupMessage
148
- | RelayPromptMessage
149
- | RelayInterruptMessage
150
- | RelayDtmfMessage
151
- | RelayErrorMessage;
152
-
153
- // Messages TO Twilio
154
- interface RelayTextMessage {
155
- type: "text";
156
- token: string;
157
- last: boolean;
158
- }
159
-
160
- interface RelayEndMessage {
161
- type: "end";
162
- handoffData?: string;
163
- }
164
-
165
- interface RelayPlayMessage {
166
- type: "play";
167
- source: string;
168
- interruptible: boolean;
169
- }
170
-
171
- // ── WebSocket data type ──────────────────────────────────────────────
172
-
173
- export interface RelayWebSocketData {
174
- callSessionId: string;
175
- }
176
-
177
- // ── Module-level state ───────────────────────────────────────────────
178
-
179
- /** Active relay connections keyed by callSessionId. */
180
- export const activeRelayConnections = new Map<string, RelayConnection>();
181
-
182
- /** Module-level broadcast function, set by the HTTP server during startup. */
183
- let globalBroadcast: ((msg: ServerMessage) => void) | undefined;
184
-
185
- /** Register a broadcast function so RelayConnection can forward events to connected clients. */
186
- export function setRelayBroadcast(fn: (msg: ServerMessage) => void): void {
187
- globalBroadcast = fn;
188
- }
189
-
190
- // ── RelayConnection ──────────────────────────────────────────────────
191
-
192
- /**
193
- * Manages a single WebSocket connection for one call.
194
- */
195
- type RelayConnectionState =
196
- | "connected"
197
- // Transient state held synchronously from the start of handleSetup until
198
- // routing/ACL completes. Prompts arriving in this window are dropped so a
199
- // not-yet-authorized caller's speech is never persisted/emitted before the
200
- // admission floor and trust ACL are applied.
201
- | "setting_up"
202
- | "verification_pending"
203
- | "awaiting_name"
204
- | "awaiting_guardian_decision"
205
- | "disconnecting";
206
-
207
- export class RelayConnection {
208
- private ws: ServerWebSocket<RelayWebSocketData>;
209
- private callSessionId: string;
210
- private conversationHistory: Array<{
211
- role: "caller" | "assistant";
212
- text: string;
213
- timestamp: number;
214
- speaker?: PromptSpeakerContext;
215
- }>;
216
- private abortController: AbortController;
217
- private controller: CallController | null = null;
218
- private speakerIdentityTracker: SpeakerIdentityTracker;
219
-
220
- // Verification state (outbound callee verification)
221
- private connectionState: RelayConnectionState = "connected";
222
- private verificationCode: string | null = null;
223
- private verificationAttempts = 0;
224
- private verificationMaxAttempts = 3;
225
- private verificationCodeLength = 6;
226
- private dtmfBuffer = "";
227
-
228
- // Inbound voice guardian verification state
229
- private verificationSessionActive = false;
230
- private verificationAssistantId: string | null = null;
231
- private verificationFromNumber: string | null = null;
232
-
233
- // Outbound guardian verification state (system calls the guardian)
234
- private outboundVerificationSessionId: string | null = null;
235
-
236
- // Inbound voice invite redemption state
237
- private inviteRedemptionActive = false;
238
- // In-flight guard: true while an async redemption attempt is awaiting the
239
- // gateway-backed claim, so a rapidly-repeated code is deduped rather than
240
- // launching a second concurrent redeemVoiceInviteCode.
241
- private inviteRedemptionInFlight = false;
242
- private inviteRedemptionAssistantId: string | null = null;
243
- private inviteRedemptionFromNumber: string | null = null;
244
- private inviteRedemptionCodeLength = 6;
245
- private inviteRedemptionInviteeName: string | null = null;
246
-
247
- // In-call guardian approval wait state (friend-initiated)
248
- private accessRequestWaitActive = false;
249
- private accessRequestId: string | null = null;
250
- private accessRequestAssistantId: string | null = null;
251
- private accessRequestFromNumber: string | null = null;
252
- private accessRequestPollTimer: ReturnType<typeof setInterval> | null = null;
253
- private accessRequestTimeoutTimer: ReturnType<typeof setTimeout> | null =
254
- null;
255
- private accessRequestCallerName: string | null = null;
256
-
257
- // Name capture timeout (unknown inbound callers)
258
- private nameCaptureTimeoutTimer: ReturnType<typeof setTimeout> | null = null;
259
-
260
- // Guardian wait heartbeat state
261
- private accessRequestHeartbeatTimer: ReturnType<typeof setTimeout> | null =
262
- null;
263
- private accessRequestWaitStartedAt: number = 0;
264
- private heartbeatSequence = 0;
265
-
266
- // Guardian displayName primed from the gateway binding at setup, read
267
- // synchronously by the heartbeat-driven wait-label path.
268
- private primedGuardianDisplayName: string | undefined;
269
-
270
- // In-wait prompt handling state
271
- private lastInWaitReplyAt = 0;
272
- private static readonly IN_WAIT_REPLY_COOLDOWN_MS = 3000;
273
-
274
- // Callback offer state (in-memory per-call)
275
- private callbackOfferMade = false;
276
- private callbackOptIn = false;
277
- private callbackHandoffNotified = false;
278
-
279
- // True while mid-call trust is being re-resolved (async). handlePrompt defers
280
- // prompts to trustReResolvePending so a verified caller can't start a turn
281
- // under the stale pre-verification trust context during the await window.
282
- private trustReResolving = false;
283
- private trustReResolvePending: RelayPromptMessage[] = [];
284
-
285
- constructor(ws: ServerWebSocket<RelayWebSocketData>, callSessionId: string) {
286
- this.ws = ws;
287
- this.callSessionId = callSessionId;
288
- this.conversationHistory = [];
289
- this.abortController = new AbortController();
290
- this.speakerIdentityTracker = new SpeakerIdentityTracker();
291
- }
292
-
293
- /**
294
- * Get the verification code for this connection (if verification is active).
295
- */
296
- getVerificationCode(): string | null {
297
- return this.verificationCode;
298
- }
299
-
300
- /**
301
- * Whether inbound guardian voice verification is currently active.
302
- */
303
- isVerificationSessionActive(): boolean {
304
- return this.verificationSessionActive;
305
- }
306
-
307
- /**
308
- * Get the current connection state.
309
- */
310
- getConnectionState(): RelayConnectionState {
311
- return this.connectionState;
312
- }
313
-
314
- /**
315
- * Handle an inbound message from Twilio via the ConversationRelay WebSocket.
316
- */
317
- async handleMessage(data: string): Promise<void> {
318
- const parsed = parseJsonSafe<RelayInboundMessage>(data);
319
- if (!parsed) {
320
- log.warn(
321
- { callSessionId: this.callSessionId, data },
322
- "Failed to parse relay message",
323
- );
324
- return;
325
- }
326
-
327
- switch (parsed.type) {
328
- case "setup":
329
- await this.handleSetup(parsed);
330
- break;
331
- case "prompt":
332
- await this.handlePrompt(parsed);
333
- break;
334
- case "interrupt":
335
- this.handleInterrupt(parsed);
336
- break;
337
- case "dtmf":
338
- this.handleDtmf(parsed);
339
- break;
340
- case "error":
341
- this.handleError(parsed);
342
- break;
343
- default:
344
- log.warn(
345
- {
346
- callSessionId: this.callSessionId,
347
- type: (parsed as { type: unknown }).type,
348
- },
349
- "Unknown relay message type",
350
- );
351
- }
352
- }
353
-
354
- /**
355
- * Send a text token to the caller for TTS playback.
356
- */
357
- sendTextToken(token: string, last: boolean): void {
358
- const message: RelayTextMessage = { type: "text", token, last };
359
- try {
360
- this.ws.send(JSON.stringify(message));
361
- } catch (err) {
362
- log.error(
363
- { err, callSessionId: this.callSessionId },
364
- "Failed to send text token",
365
- );
366
- }
367
- }
368
-
369
- /**
370
- * Send a play-audio URL to the caller. Used when the assistant handles
371
- * TTS synthesis itself (e.g. Fish Audio) instead of relying on
372
- * ConversationRelay's built-in TTS.
373
- */
374
- sendPlayUrl(url: string): void {
375
- const message: RelayPlayMessage = {
376
- type: "play",
377
- source: url,
378
- interruptible: true,
379
- };
380
- try {
381
- this.ws.send(JSON.stringify(message));
382
- } catch (err) {
383
- log.error(
384
- { err, callSessionId: this.callSessionId },
385
- "Failed to send play URL",
386
- );
387
- }
388
- }
389
-
390
- /**
391
- * End the ConversationRelay session.
392
- */
393
- endSession(reason?: string): void {
394
- const message: RelayEndMessage = { type: "end" };
395
- if (reason) {
396
- message.handoffData = JSON.stringify({ reason });
397
- }
398
- try {
399
- this.ws.send(JSON.stringify(message));
400
- } catch (err) {
401
- log.error(
402
- { err, callSessionId: this.callSessionId },
403
- "Failed to send end message",
404
- );
405
- }
406
- }
407
-
408
- /**
409
- * Get the conversation history for context.
410
- */
411
- getConversationHistory(): Array<{
412
- role: string;
413
- text: string;
414
- speaker?: PromptSpeakerContext;
415
- }> {
416
- return this.conversationHistory.map(({ role, text, speaker }) => ({
417
- role,
418
- text,
419
- speaker,
420
- }));
421
- }
422
-
423
- /**
424
- * Get the call session ID for this connection.
425
- */
426
- getCallSessionId(): string {
427
- return this.callSessionId;
428
- }
429
-
430
- /**
431
- * Set the controller for this connection.
432
- */
433
- setController(controller: CallController): void {
434
- this.controller = controller;
435
- }
436
-
437
- /**
438
- * Get the controller for this connection.
439
- */
440
- getController(): CallController | null {
441
- return this.controller;
442
- }
443
-
444
- /**
445
- * Clean up resources on disconnect.
446
- */
447
- destroy(): void {
448
- if (this.controller) {
449
- this.controller.destroy();
450
- this.controller = null;
451
- }
452
- if (this.accessRequestPollTimer) {
453
- clearInterval(this.accessRequestPollTimer);
454
- this.accessRequestPollTimer = null;
455
- }
456
- if (this.accessRequestTimeoutTimer) {
457
- clearTimeout(this.accessRequestTimeoutTimer);
458
- this.accessRequestTimeoutTimer = null;
459
- }
460
- if (this.accessRequestHeartbeatTimer) {
461
- clearTimeout(this.accessRequestHeartbeatTimer);
462
- this.accessRequestHeartbeatTimer = null;
463
- }
464
- if (this.nameCaptureTimeoutTimer) {
465
- clearTimeout(this.nameCaptureTimeoutTimer);
466
- this.nameCaptureTimeoutTimer = null;
467
- }
468
- this.accessRequestWaitActive = false;
469
- this.abortController.abort();
470
- log.info(
471
- { callSessionId: this.callSessionId },
472
- "RelayConnection destroyed",
473
- );
474
- }
475
-
476
- /**
477
- * Handle transport-level close from the relay websocket.
478
- *
479
- * Twilio status callbacks are best-effort; if they are delayed or absent,
480
- * we still finalize the call lifecycle from the relay close signal.
481
- */
482
- handleTransportClosed(code?: number, reason?: string): void {
483
- // If the call was still in guardian-wait with callback opt-in, emit the
484
- // handoff notification before cleaning up wait state.
485
- if (this.accessRequestWaitActive && this.callbackOptIn) {
486
- this.emitAccessRequestCallbackHandoffForReason("transport_closed");
487
- }
488
-
489
- // Clean up access request wait state on disconnect to stop polling
490
- this.clearAccessRequestWait();
491
- if (this.nameCaptureTimeoutTimer) {
492
- clearTimeout(this.nameCaptureTimeoutTimer);
493
- this.nameCaptureTimeoutTimer = null;
494
- }
495
-
496
- const session = getCallSession(this.callSessionId);
497
- if (!session) return;
498
- if (isTerminalState(session.status)) return;
499
-
500
- const isNormalClose = code === 1000;
501
- if (isNormalClose) {
502
- updateCallSession(this.callSessionId, {
503
- status: "completed",
504
- endedAt: Date.now(),
505
- });
506
- recordCallEvent(this.callSessionId, "call_ended", {
507
- reason: reason || "relay_closed",
508
- closeCode: code,
509
- });
510
-
511
- // Post a pointer message in the initiating conversation
512
- if (session.initiatedFromConversationId) {
513
- const durationMs = session.startedAt
514
- ? Date.now() - session.startedAt
515
- : 0;
516
- addPointerMessage(
517
- session.initiatedFromConversationId,
518
- "completed",
519
- session.toNumber,
520
- {
521
- duration: durationMs > 0 ? formatDuration(durationMs) : undefined,
522
- },
523
- ).catch((err) => {
524
- log.warn(
525
- { conversationId: session.initiatedFromConversationId, err },
526
- "Skipping pointer write — origin conversation may no longer exist",
527
- );
528
- });
529
- }
530
- } else {
531
- const detail =
532
- reason || (code ? `relay_closed_${code}` : "relay_closed_abnormal");
533
- updateCallSession(this.callSessionId, {
534
- status: "failed",
535
- endedAt: Date.now(),
536
- lastError: `Relay websocket closed unexpectedly: ${detail}`,
537
- });
538
- recordCallEvent(this.callSessionId, "call_failed", {
539
- reason: detail,
540
- closeCode: code,
541
- });
542
-
543
- // Post a failure pointer message in the initiating conversation
544
- if (session.initiatedFromConversationId) {
545
- addPointerMessage(
546
- session.initiatedFromConversationId,
547
- "failed",
548
- session.toNumber,
549
- {
550
- reason: detail,
551
- },
552
- ).catch((err) => {
553
- log.warn(
554
- { conversationId: session.initiatedFromConversationId, err },
555
- "Skipping pointer write — origin conversation may no longer exist",
556
- );
557
- });
558
- }
559
- }
560
-
561
- // Revoke any scoped approval grants bound to this call session.
562
- // Revoke by both callSessionId and conversationId because the
563
- // guardian-approval-interception minting path sets callSessionId: null
564
- // but always sets conversationId.
565
- try {
566
- revokeScopedApprovalGrantsForContext({
567
- callSessionId: this.callSessionId,
568
- });
569
- revokeScopedApprovalGrantsForContext({
570
- conversationId: session.conversationId,
571
- });
572
- } catch (err) {
573
- log.warn(
574
- { err, callSessionId: this.callSessionId },
575
- "Failed to revoke scoped grants on transport close",
576
- );
577
- }
578
-
579
- finalizeCall(this.callSessionId, session.conversationId);
580
- }
581
-
582
- // ── Private handlers ─────────────────────────────────────────────
583
-
584
- private async handleSetup(msg: RelaySetupMessage): Promise<void> {
585
- log.info(
586
- {
587
- callSessionId: this.callSessionId,
588
- callSid: msg.callSid,
589
- from: msg.from,
590
- to: msg.to,
591
- },
592
- "ConversationRelay setup received",
593
- );
594
-
595
- // Enter the setup-pending state synchronously, before any await. The
596
- // admission-policy read below yields the event loop, and inbound frames are
597
- // dispatched fire-and-forget, so a `prompt` frame can arrive before routing
598
- // completes. handlePrompt drops prompts while in "setting_up" so a blocked
599
- // or admission-denied caller's speech is never stored before the ACL runs.
600
- this.connectionState = "setting_up";
601
-
602
- const session = getCallSession(this.callSessionId);
603
- this.recordSetupBookkeeping(session, msg);
604
-
605
- // Prime the guardian displayName and warm the phone-channel guardian-
606
- // delivery cache before routeSetup's SYNC resolveActorTrust fallback. That
607
- // fallback reads the IO-free cache snapshot keyed per channel-filter; daemon
608
- // startup warms only `vellum`, so a cold-process phone call would otherwise
609
- // misclassify a guardian during a gateway verdict blip. Read fresh: gateway-
610
- // side binding writes don't invalidate the daemon cache, so a stale empty
611
- // snapshot from an earlier setup would survive the TTL otherwise. Both are
612
- // independent IPC reads on different cache keys, so run them concurrently
613
- // off the sync resolver's hot path.
614
- await Promise.all([
615
- this.primeGuardianDisplayName(),
616
- getGuardianDeliveryFresh({ channelTypes: ["phone"] }),
617
- ]);
618
-
619
- // Resolve the phone channel's inbound admission floor. The reader fails
620
- // open to `null` by contract, so a transport hiccup admits the caller.
621
- const admissionPolicy = await getChannelAdmissionPolicy("phone");
622
-
623
- // Verdict-first caller trust. routeSetup uses it when present and not
624
- // resolutionFailed, else falls back to local resolution. The reader
625
- // returns null on failure, so a gateway blip keeps the local path.
626
- const isInbound = session?.initiatedFromConversationId == null;
627
- const otherPartyNumber = isInbound ? msg.from : msg.to;
628
- const verdict = await getPhoneCallerVerdict(otherPartyNumber);
629
-
630
- try {
631
- await this.routeSetupOutcome(msg, session, admissionPolicy, verdict);
632
- } catch (err) {
633
- // Never leave the connection stranded in "setting_up": a setup that
634
- // throws before reaching a terminal outcome would otherwise drop every
635
- // prompt forever. Tear the call down instead.
636
- log.error(
637
- { err, callSessionId: this.callSessionId },
638
- "Setup routing failed — disconnecting",
639
- );
640
- this.connectionState = "disconnecting";
641
- updateCallSession(this.callSessionId, {
642
- status: "failed",
643
- endedAt: Date.now(),
644
- lastError: "Setup routing failed",
645
- });
646
- this.endSession("Setup routing failed");
647
- }
648
- }
649
-
650
- /**
651
- * Apply the routing/ACL outcome for a setup frame. Synchronous helpers that
652
- * fire-and-forget (greeting, verification, name capture) are awaited here only
653
- * when they themselves are async. Each reachable outcome transitions the
654
- * connection out of "setting_up" into its terminal state.
655
- */
656
- private async routeSetupOutcome(
657
- msg: RelaySetupMessage,
658
- session: ReturnType<typeof getCallSession>,
659
- admissionPolicy: AdmissionPolicy | null,
660
- verdict: TrustVerdict | null,
661
- ): Promise<void> {
662
- const { outcome, resolved } = routeSetup({
663
- callSessionId: this.callSessionId,
664
- session,
665
- from: msg.from,
666
- to: msg.to,
667
- customParameters: msg.customParameters,
668
- admissionPolicy,
669
- verdict,
670
- });
671
-
672
- const initialTrustContext = toTrustContext(
673
- resolved.actorTrust,
674
- resolved.otherPartyNumber,
675
- );
676
- const transport = new ConversationRelayTransport(this);
677
- const controller = new CallController(
678
- this.callSessionId,
679
- transport,
680
- session?.task ?? null,
681
- {
682
- broadcast: globalBroadcast,
683
- assistantId: resolved.assistantId,
684
- trustContext: initialTrustContext,
685
- },
686
- );
687
- this.setController(controller);
688
-
689
- switch (outcome.action) {
690
- case "outbound_verification":
691
- this.startOutboundVerification(
692
- outcome.assistantId,
693
- outcome.sessionId,
694
- outcome.toNumber,
695
- );
696
- return;
697
- case "callee_verification":
698
- await this.startVerification(session, outcome.verificationConfig);
699
- return;
700
- case "deny":
701
- await this.denyInboundCall(msg.from, resolved, outcome);
702
- return;
703
- case "invite_redemption":
704
- this.startInviteRedemption(
705
- outcome.assistantId,
706
- outcome.fromNumber,
707
- outcome.inviteeName,
708
- !resolved.isInbound,
709
- );
710
- return;
711
- case "name_capture":
712
- recordCallEvent(
713
- this.callSessionId,
714
- "inbound_acl_name_capture_started",
715
- {
716
- from: msg.from,
717
- trustClass: resolved.actorTrust.trustClass,
718
- },
719
- );
720
- this.startNameCapture(outcome.assistantId, outcome.fromNumber);
721
- return;
722
- case "unverified_caller":
723
- await this.handleUnverifiedCaller(
724
- outcome.displayName,
725
- outcome.isGuardian,
726
- );
727
- return;
728
- case "verification":
729
- if (this.controller && resolved.actorTrust.trustClass !== "unknown") {
730
- this.controller.setTrustContext(
731
- toTrustContext(resolved.actorTrust, msg.from),
732
- );
733
- }
734
- this.startInboundVerification(outcome.assistantId, outcome.fromNumber);
735
- return;
736
- case "normal_call":
737
- if (outcome.isInbound) {
738
- if (this.controller && resolved.actorTrust.trustClass !== "unknown") {
739
- this.controller.setTrustContext(
740
- toTrustContext(resolved.actorTrust, msg.from),
741
- );
742
- }
743
- }
744
- // Routing/ACL cleared — leave the setup-pending state so subsequent
745
- // prompts are handled normally.
746
- this.connectionState = "connected";
747
- this.startNormalCallFlow(controller, outcome.isInbound);
748
- return;
749
- }
750
- }
751
-
752
- /** Bookkeeping side-effects that run on every setup regardless of routing outcome. */
753
- private recordSetupBookkeeping(
754
- session: ReturnType<typeof getCallSession>,
755
- msg: RelaySetupMessage,
756
- ): void {
757
- if (session) {
758
- const updates: Parameters<typeof updateCallSession>[1] = {
759
- providerCallSid: msg.callSid,
760
- };
761
- if (
762
- !isTerminalState(session.status) &&
763
- session.status !== "in_progress" &&
764
- session.status !== "waiting_on_user"
765
- ) {
766
- updates.status = "in_progress";
767
- if (!session.startedAt) updates.startedAt = Date.now();
768
- }
769
- updateCallSession(this.callSessionId, updates);
770
- }
771
-
772
- const safeCustomParameters = msg.customParameters
773
- ? Object.fromEntries(
774
- Object.entries(msg.customParameters).filter(
775
- ([key]) => !key.toLowerCase().includes("secret"),
776
- ),
777
- )
778
- : undefined;
779
-
780
- recordCallEvent(this.callSessionId, "call_connected", {
781
- callSid: msg.callSid,
782
- from: msg.from,
783
- to: msg.to,
784
- customParameters: safeCustomParameters,
785
- });
786
- }
787
-
788
- /** Speak verification guidance to a known-but-unverified caller, then disconnect. */
789
- private async handleUnverifiedCaller(
790
- displayName: string,
791
- isGuardian: boolean,
792
- ): Promise<void> {
793
- recordCallEvent(this.callSessionId, "inbound_acl_unverified_caller", {
794
- callSessionId: this.callSessionId,
795
- isGuardian,
796
- });
797
- this.connectionState = "disconnecting";
798
- updateCallSession(this.callSessionId, {
799
- status: "failed",
800
- endedAt: Date.now(),
801
- lastError: "Inbound voice ACL: caller channel unverified",
802
- });
803
- const action = isGuardian
804
- ? `To verify, open your assistant's contacts page, click Verify next to the phone channel, ` +
805
- `and follow the prompts. Then call back once the verification session is active.`
806
- : `Please reach out to the account guardian to start a new verification session, ` +
807
- `then call back once the verification session is active.`;
808
- const message =
809
- `This number is registered as ${displayName}'s phone but has not been verified yet. ` +
810
- action;
811
- await speakSystemPrompt(this, message);
812
- setTimeout(() => {
813
- this.endSession("Inbound voice ACL: caller channel unverified");
814
- }, getTtsPlaybackDelayMs());
815
- }
816
-
817
- /** Deny an inbound call with a TTS message and schedule disconnect. */
818
- private async denyInboundCall(
819
- from: string,
820
- resolved: SetupResolved,
821
- outcome: { message: string; logReason: string },
822
- ): Promise<void> {
823
- recordCallEvent(this.callSessionId, "inbound_acl_denied", {
824
- from,
825
- trustClass: resolved.actorTrust.trustClass,
826
- channelId: resolved.actorTrust.memberRecord?.channel.id,
827
- memberPolicy: resolved.actorTrust.memberRecord?.policy,
828
- });
829
- this.connectionState = "disconnecting";
830
- updateCallSession(this.callSessionId, {
831
- status: "failed",
832
- endedAt: Date.now(),
833
- lastError: outcome.logReason,
834
- });
835
- await speakSystemPrompt(this, outcome.message);
836
- setTimeout(() => {
837
- this.endSession(outcome.logReason);
838
- }, getTtsPlaybackDelayMs());
839
- }
840
-
841
- /**
842
- * Generate a verification code and prompt the callee to enter it via DTMF.
843
- */
844
- private async startVerification(
845
- session: ReturnType<typeof getCallSession>,
846
- verificationConfig: { maxAttempts: number; codeLength: number },
847
- ): Promise<void> {
848
- this.verificationMaxAttempts = verificationConfig.maxAttempts;
849
- this.verificationCodeLength = verificationConfig.codeLength;
850
- this.verificationAttempts = 0;
851
- this.dtmfBuffer = "";
852
-
853
- // Generate a random numeric code
854
- const maxValue = Math.pow(10, this.verificationCodeLength);
855
- const code = randomInt(0, maxValue)
856
- .toString()
857
- .padStart(this.verificationCodeLength, "0");
858
- this.verificationCode = code;
859
- this.connectionState = "verification_pending";
860
-
861
- recordCallEvent(this.callSessionId, "callee_verification_started", {
862
- codeLength: this.verificationCodeLength,
863
- maxAttempts: this.verificationMaxAttempts,
864
- });
865
-
866
- // Send a TTS prompt with the code spoken digit by digit
867
- const spokenCode = code.split("").join(". ");
868
- void speakSystemPrompt(
869
- this,
870
- `Please enter the verification code: ${spokenCode}.`,
871
- );
872
-
873
- // Post the verification code to the initiating conversation so the
874
- // guardian (user) can share it with the callee.
875
- if (session?.initiatedFromConversationId) {
876
- const codeMsg = `\u{1F510} Verification code for call to ${session.toNumber}: ${code}`;
877
- await addMessage(
878
- session.initiatedFromConversationId,
879
- "assistant",
880
- JSON.stringify([{ type: "text", text: codeMsg }]),
881
- {
882
- metadata: {
883
- userMessageChannel: "phone",
884
- assistantMessageChannel: "phone",
885
- userMessageInterface: "phone",
886
- assistantMessageInterface: "phone",
887
- },
888
- },
889
- );
890
- }
891
-
892
- log.info(
893
- {
894
- callSessionId: this.callSessionId,
895
- codeLength: this.verificationCodeLength,
896
- },
897
- "Callee verification started",
898
- );
899
- }
900
-
901
- /**
902
- * Start normal call flow — fire the controller greeting.
903
- */
904
- private startNormalCallFlow(
905
- controller: CallController,
906
- isInbound: boolean,
907
- ): void {
908
- controller
909
- .startInitialGreeting()
910
- .catch((err) =>
911
- log.error(
912
- { err, callSessionId: this.callSessionId },
913
- `Failed to start initial ${isInbound ? "inbound" : "outbound"} greeting`,
914
- ),
915
- );
916
- }
917
-
918
- /**
919
- * Re-resolve caller trust after a mid-call verification/activation. Prefers
920
- * the gateway verdict (authoritative right after the gateway updated the
921
- * binding); falls back to local resolution on a missing/failed/unusable
922
- * verdict so a blip never drops the call. Mirrors the setup path's
923
- * verdict-first-with-fallback condition.
924
- */
925
- private async resolveMidCallTrustContext(
926
- assistantId: string,
927
- fromNumber: string,
928
- ): Promise<TrustContext> {
929
- const verdict = await getInboundTrustVerdict({
930
- channelType: "phone",
931
- actorExternalId: fromNumber,
932
- });
933
-
934
- // Only a MEMBERLESS unknown verdict is treated as a stale gateway view and
935
- // falls back to local: the caller was just activated, and invite redemption
936
- // writes the channel assistant-side, so the gateway may not see the member
937
- // yet — local resolution has it. A MEMBERFUL unknown verdict (blocked/revoked
938
- // member, carrying contactId/channelId) is honored so its deny ACL is
939
- // enforced; falling back could lose the gateway's member status if local
940
- // state is stale.
941
- const memberlessUnknown =
942
- verdict?.trustClass === "unknown" && !verdictHasMemberIdentity(verdict);
943
- const usable =
944
- verdict &&
945
- !verdict.resolutionFailed &&
946
- !verdictMemberUnresolvable(verdict) &&
947
- !memberlessUnknown;
948
-
949
- if (usable) {
950
- return trustContextFromVerdict(verdict, {
951
- sourceChannel: "phone",
952
- conversationExternalId: fromNumber,
953
- });
954
- }
955
-
956
- // Warm the phone-channel guardian-delivery cache before the SYNC
957
- // resolveActorTrust fallback, which reads the IO-free per-channel snapshot
958
- // that daemon startup leaves cold for `phone`. Read fresh: gateway-side
959
- // binding writes don't invalidate the daemon cache, so a stale empty
960
- // snapshot would otherwise survive the TTL and misclassify the guardian.
961
- await getGuardianDeliveryFresh({ channelTypes: ["phone"] });
962
-
963
- return toTrustContext(
964
- resolveActorTrust({
965
- assistantId,
966
- sourceChannel: "phone",
967
- conversationExternalId: fromNumber,
968
- actorExternalId: fromNumber,
969
- }),
970
- fromNumber,
971
- );
972
- }
973
-
974
- /**
975
- * Re-resolve mid-call trust and install it on the controller, deferring any
976
- * prompt that arrives during the async window. Guards the gap between
977
- * connectionState flipping to "connected" and the upgraded context landing so
978
- * a verified caller never starts a turn under the stale pre-verification
979
- * trust. Clears the guard in a finally and flushes buffered prompts under the
980
- * new context, so an IPC error can't wedge the call.
981
- */
982
- private async reResolveAndApplyTrustContext(
983
- assistantId: string,
984
- fromNumber: string,
985
- ): Promise<void> {
986
- if (!this.controller) return;
987
- this.trustReResolving = true;
988
- try {
989
- const context = await this.resolveMidCallTrustContext(
990
- assistantId,
991
- fromNumber,
992
- );
993
- this.controller.setTrustContext(context);
994
- } finally {
995
- this.trustReResolving = false;
996
- this.flushDeferredPrompts();
997
- }
998
- }
999
-
1000
- /**
1001
- * Replay prompts buffered while trust was re-resolving. Runs after the
1002
- * upgraded context is installed so deferred utterances are answered under the
1003
- * correct trust.
1004
- */
1005
- private flushDeferredPrompts(): void {
1006
- if (this.trustReResolvePending.length === 0) return;
1007
- const pending = this.trustReResolvePending;
1008
- this.trustReResolvePending = [];
1009
- for (const msg of pending) {
1010
- void this.handlePrompt(msg);
1011
- }
1012
- }
1013
-
1014
- /**
1015
- * Shared post-activation handoff for all trusted-contact success paths
1016
- * (access-request approval, invite redemption, verification code).
1017
- * Activates the caller, updates guardian context, delivers deterministic
1018
- * transition copy, and marks the next utterance as opening-ack so the
1019
- * LLM continues naturally.
1020
- */
1021
- private async continueCallAfterTrustedContactActivation(params: {
1022
- assistantId: string;
1023
- fromNumber: string;
1024
- activationReason?:
1025
- | "invite_redeemed"
1026
- | "access_approved"
1027
- | "trusted_contact_verified";
1028
- /**
1029
- * Display name resolved from the bound contact (or, for outbound invite
1030
- * calls, the session's recorded invitee name). Greeting uses only the
1031
- * first whitespace-delimited token; an empty/blank value triggers the
1032
- * neutral "Hi there" greeting rather than substituting the channel
1033
- * address.
1034
- */
1035
- inviteeName?: string | null;
1036
- }): Promise<void> {
1037
- const { assistantId, fromNumber } = params;
1038
-
1039
- // Contact activation is handled by the gateway — the assistant no
1040
- // longer writes contact/channel records on inbound voice calls.
1041
-
1042
- // Reach connected and clear wait/verification flags before re-resolving
1043
- // trust, so a prompt buffered during re-resolution flushes onto the
1044
- // real-turn path, not the verification/wait branches.
1045
- this.connectionState = "connected";
1046
- this.verificationSessionActive = false;
1047
- this.inviteRedemptionActive = false;
1048
- this.accessRequestWaitActive = false;
1049
- updateCallSession(this.callSessionId, { status: "in_progress" });
1050
-
1051
- if (this.controller) {
1052
- await this.reResolveAndApplyTrustContext(assistantId, fromNumber);
1053
- }
1054
-
1055
- const guardianLabel = this.resolveGuardianLabel();
1056
- let handoffText: string;
1057
-
1058
- if (params.activationReason === "invite_redeemed") {
1059
- const firstName = firstToken(params.inviteeName);
1060
- const assistantName = this.resolveAssistantLabel();
1061
- if (firstName) {
1062
- handoffText = assistantName
1063
- ? `Great, I've verified that you are ${firstName}. It's nice to meet you! I'm ${assistantName}, ${guardianLabel}'s assistant. How can I help?`
1064
- : `Great, I've verified that you are ${firstName}. It's nice to meet you! How can I help?`;
1065
- } else {
1066
- handoffText = assistantName
1067
- ? `Hi there! I'm ${assistantName}, ${guardianLabel}'s assistant. How can I help?`
1068
- : `Hi there! How can I help?`;
1069
- }
1070
- } else {
1071
- handoffText = `Great! ${guardianLabel} said I can speak with you. How can I help?`;
1072
- }
1073
-
1074
- void speakSystemPrompt(this, handoffText);
1075
-
1076
- recordCallEvent(this.callSessionId, "assistant_spoke", {
1077
- text: handoffText,
1078
- });
1079
- const session = getCallSession(this.callSessionId);
1080
- if (session) {
1081
- fireCallTranscriptNotifier(
1082
- session.conversationId,
1083
- this.callSessionId,
1084
- "assistant",
1085
- handoffText,
1086
- );
1087
- }
1088
-
1089
- if (this.controller) {
1090
- this.controller.markNextCallerTurnAsOpeningAck();
1091
- }
1092
- }
1093
-
1094
- /**
1095
- * Enter verification-pending state for an inbound call with a pending
1096
- * voice guardian challenge. Prompts the caller to enter their six-digit
1097
- * verification code via DTMF or by speaking it.
1098
- */
1099
- private startInboundVerification(
1100
- assistantId: string,
1101
- fromNumber: string,
1102
- ): void {
1103
- this.verificationSessionActive = true;
1104
- this.verificationAssistantId = assistantId;
1105
- this.verificationFromNumber = fromNumber;
1106
- this.connectionState = "verification_pending";
1107
- this.verificationAttempts = 0;
1108
- this.verificationMaxAttempts = 3;
1109
- this.verificationCodeLength = 6;
1110
- this.dtmfBuffer = "";
1111
-
1112
- recordCallEvent(this.callSessionId, "voice_verification_started", {
1113
- assistantId,
1114
- maxAttempts: this.verificationMaxAttempts,
1115
- });
1116
-
1117
- void speakSystemPrompt(
1118
- this,
1119
- "Welcome. Please enter your six-digit verification code using your keypad, or speak the digits now.",
1120
- );
1121
-
1122
- log.info(
1123
- { callSessionId: this.callSessionId, assistantId },
1124
- "Inbound guardian voice verification started",
1125
- );
1126
- }
1127
-
1128
- /**
1129
- * Enter verification-pending state for an outbound guardian verification
1130
- * call. The system called the guardian's phone; prompt them to enter the
1131
- * verification code via DTMF or speech.
1132
- */
1133
- private startOutboundVerification(
1134
- assistantId: string,
1135
- verificationSessionId: string,
1136
- toNumber: string,
1137
- ): void {
1138
- this.verificationSessionActive = true;
1139
- this.outboundVerificationSessionId = verificationSessionId;
1140
- this.verificationAssistantId = assistantId;
1141
- // For outbound guardian calls, the "to" number is the guardian's phone
1142
- this.verificationFromNumber = toNumber;
1143
- this.connectionState = "verification_pending";
1144
- this.verificationAttempts = 0;
1145
- this.verificationMaxAttempts = 3;
1146
- this.verificationCodeLength = 6;
1147
- this.dtmfBuffer = "";
1148
-
1149
- recordCallEvent(this.callSessionId, "outbound_voice_verification_started", {
1150
- assistantId,
1151
- verificationSessionId,
1152
- maxAttempts: this.verificationMaxAttempts,
1153
- });
1154
-
1155
- const introText = composeVerificationVoice(
1156
- GUARDIAN_VERIFY_TEMPLATE_KEYS.VOICE_CALL_INTRO,
1157
- { codeDigits: this.verificationCodeLength },
1158
- );
1159
- void speakSystemPrompt(this, introText);
1160
-
1161
- log.info(
1162
- {
1163
- callSessionId: this.callSessionId,
1164
- assistantId,
1165
- verificationSessionId,
1166
- },
1167
- "Outbound guardian voice verification started",
1168
- );
1169
- }
1170
-
1171
- /**
1172
- * Validate an entered code against the pending voice guardian challenge.
1173
- * Delegates to the extracted attemptVerificationCode() and
1174
- * interprets the structured result to drive side-effects.
1175
- */
1176
- private async handleVerificationCodeResult(
1177
- enteredCode: string,
1178
- ): Promise<void> {
1179
- if (!this.verificationAssistantId || !this.verificationFromNumber) {
1180
- return;
1181
- }
1182
-
1183
- const isOutbound = this.outboundVerificationSessionId != null;
1184
- const assistantId = this.verificationAssistantId;
1185
- const fromNumber = this.verificationFromNumber;
1186
-
1187
- const result = await attemptVerificationCode({
1188
- verificationAssistantId: assistantId,
1189
- verificationFromNumber: fromNumber,
1190
- enteredCode,
1191
- isOutbound,
1192
- codeDigits: this.verificationCodeLength,
1193
- verificationAttempts: this.verificationAttempts,
1194
- verificationMaxAttempts: this.verificationMaxAttempts,
1195
- });
1196
-
1197
- if (result.outcome === "success") {
1198
- this.connectionState = "connected";
1199
- this.verificationSessionActive = false;
1200
- this.verificationAttempts = 0;
1201
- this.dtmfBuffer = "";
1202
-
1203
- recordCallEvent(this.callSessionId, result.eventName, {
1204
- verificationType: result.verificationType,
1205
- });
1206
- log.info(
1207
- { callSessionId: this.callSessionId, isOutbound },
1208
- "Guardian voice verification succeeded",
1209
- );
1210
-
1211
- if (isOutbound) {
1212
- // Keep the pointer message back to the initiating conversation
1213
- const successSession = getCallSession(this.callSessionId);
1214
- if (successSession?.initiatedFromConversationId) {
1215
- addPointerMessage(
1216
- successSession.initiatedFromConversationId,
1217
- "verification_succeeded",
1218
- successSession.toNumber,
1219
- { channel: "phone" },
1220
- ).catch((err) => {
1221
- log.warn(
1222
- {
1223
- conversationId: successSession.initiatedFromConversationId,
1224
- err,
1225
- },
1226
- "Skipping pointer write — origin conversation may no longer exist",
1227
- );
1228
- });
1229
- }
1230
-
1231
- // Update trust context on the controller so the LLM knows this is the guardian
1232
- if (this.controller) {
1233
- await this.reResolveAndApplyTrustContext(assistantId, fromNumber);
1234
- }
1235
-
1236
- // Mark session as in-progress and transition to guardian conversation
1237
- // with verification context so the LLM greets naturally.
1238
- updateCallSession(this.callSessionId, { status: "in_progress" });
1239
- if (this.controller) {
1240
- this.controller
1241
- .startPostVerificationGreeting()
1242
- .catch((err) =>
1243
- log.error(
1244
- { err, callSessionId: this.callSessionId },
1245
- "Failed to start post-verification greeting",
1246
- ),
1247
- );
1248
- }
1249
- } else if (result.verificationType === "trusted_contact") {
1250
- await this.continueCallAfterTrustedContactActivation({
1251
- assistantId,
1252
- fromNumber,
1253
- activationReason: "trusted_contact_verified",
1254
- });
1255
- } else {
1256
- // Inbound guardian verification: binding already handled above,
1257
- // proceed to normal call flow.
1258
- if (this.controller) {
1259
- await this.reResolveAndApplyTrustContext(assistantId, fromNumber);
1260
- this.startNormalCallFlow(this.controller, true);
1261
- }
1262
- }
1263
- } else if (result.outcome === "failure") {
1264
- this.verificationSessionActive = false;
1265
- this.verificationAttempts = result.attempts;
1266
-
1267
- recordCallEvent(this.callSessionId, result.eventName, {
1268
- attempts: result.attempts,
1269
- });
1270
- log.warn(
1271
- {
1272
- callSessionId: this.callSessionId,
1273
- attempts: result.attempts,
1274
- isOutbound,
1275
- },
1276
- "Guardian voice verification failed — max attempts reached",
1277
- );
1278
-
1279
- updateCallSession(this.callSessionId, {
1280
- status: "failed",
1281
- endedAt: Date.now(),
1282
- lastError: "Guardian voice verification failed — max attempts exceeded",
1283
- });
1284
-
1285
- const failSession = getCallSession(this.callSessionId);
1286
- if (failSession) {
1287
- finalizeCall(this.callSessionId, failSession.conversationId);
1288
-
1289
- if (isOutbound && failSession.initiatedFromConversationId) {
1290
- addPointerMessage(
1291
- failSession.initiatedFromConversationId,
1292
- "verification_failed",
1293
- failSession.toNumber,
1294
- {
1295
- channel: "phone",
1296
- reason: "Max verification attempts exceeded",
1297
- },
1298
- ).catch((err) => {
1299
- log.warn(
1300
- {
1301
- conversationId: failSession.initiatedFromConversationId,
1302
- err,
1303
- },
1304
- "Skipping pointer write — origin conversation may no longer exist",
1305
- );
1306
- });
1307
- }
1308
- }
1309
-
1310
- await speakSystemPrompt(this, result.ttsMessage);
1311
- setTimeout(() => {
1312
- this.endSession("Verification failed — challenge rejected");
1313
- }, getTtsPlaybackDelayMs());
1314
- } else {
1315
- // retry
1316
- this.verificationAttempts = result.attempt;
1317
-
1318
- log.info(
1319
- {
1320
- callSessionId: this.callSessionId,
1321
- attempt: result.attempt,
1322
- maxAttempts: result.maxAttempts,
1323
- isOutbound,
1324
- },
1325
- "Guardian voice verification attempt failed — retrying",
1326
- );
1327
- void speakSystemPrompt(this, result.ttsMessage);
1328
- }
1329
- }
1330
-
1331
- /**
1332
- * Enter the invite redemption subflow for an inbound unknown caller
1333
- * who has an active voice invite. Prompts the caller to enter their
1334
- * invite code via DTMF or speech.
1335
- */
1336
- private startInviteRedemption(
1337
- assistantId: string,
1338
- fromNumber: string,
1339
- inviteeName: string | null,
1340
- isOutbound: boolean,
1341
- ): void {
1342
- this.inviteRedemptionActive = true;
1343
- this.inviteRedemptionInFlight = false;
1344
- this.inviteRedemptionAssistantId = assistantId;
1345
- this.inviteRedemptionFromNumber = fromNumber;
1346
- this.inviteRedemptionInviteeName = inviteeName;
1347
- this.connectionState = "verification_pending";
1348
- this.verificationAttempts = 0;
1349
- this.verificationMaxAttempts = 1;
1350
- this.inviteRedemptionCodeLength = 6;
1351
- this.dtmfBuffer = "";
1352
-
1353
- recordCallEvent(this.callSessionId, "invite_redemption_started", {
1354
- assistantId,
1355
- codeLength: 6,
1356
- maxAttempts: this.verificationMaxAttempts,
1357
- });
1358
-
1359
- const displayFriend = firstToken(inviteeName) ?? "there";
1360
- const displayGuardian = this.resolveGuardianLabel();
1361
-
1362
- let promptText: string;
1363
- if (isOutbound) {
1364
- const assistantName = this.resolveAssistantLabel();
1365
- promptText = assistantName
1366
- ? `Hi ${displayFriend}, this is ${assistantName}, ${displayGuardian}'s assistant. To get started, please enter the 6-digit code that ${displayGuardian} shared with you.`
1367
- : `Hi ${displayFriend}, this is ${displayGuardian}'s assistant. To get started, please enter the 6-digit code that ${displayGuardian} shared with you.`;
1368
- } else {
1369
- promptText = `Welcome ${displayFriend}. Please enter the 6-digit code that ${displayGuardian} provided you to verify your identity.`;
1370
- }
1371
- void speakSystemPrompt(this, promptText);
1372
-
1373
- log.info(
1374
- { callSessionId: this.callSessionId, assistantId },
1375
- `${isOutbound ? "Outbound" : "Inbound"} voice invite redemption started`,
1376
- );
1377
- }
1378
-
1379
- /**
1380
- * Enter the name capture subflow for unknown inbound callers.
1381
- * Prompts the caller to provide their name so we can include it
1382
- * in the guardian notification.
1383
- */
1384
- private startNameCapture(assistantId: string, fromNumber: string): void {
1385
- this.accessRequestAssistantId = assistantId;
1386
- this.accessRequestFromNumber = fromNumber;
1387
- this.connectionState = "awaiting_name";
1388
-
1389
- const guardianLabel = this.resolveGuardianLabel();
1390
- const assistantName = this.resolveAssistantLabel();
1391
-
1392
- const greeting = assistantName
1393
- ? `Hi, this is ${assistantName}, ${guardianLabel}'s assistant. Sorry, I don't recognize this number. I'll let ${guardianLabel} know you called and see if I have permission to speak with you. Can I get your name?`
1394
- : `Hi, this is ${guardianLabel}'s assistant. Sorry, I don't recognize this number. I'll let ${guardianLabel} know you called and see if I have permission to speak with you. Can I get your name?`;
1395
-
1396
- void speakSystemPrompt(this, greeting);
1397
-
1398
- // Start a timeout so silent callers don't keep the call open indefinitely.
1399
- // Uses a 30-second window — enough time to speak a name but short enough
1400
- // to avoid wasting resources on callers who never respond.
1401
- const NAME_CAPTURE_TIMEOUT_MS = 30_000;
1402
- this.nameCaptureTimeoutTimer = setTimeout(() => {
1403
- if (this.connectionState !== "awaiting_name") return;
1404
- void this.handleNameCaptureTimeout();
1405
- }, NAME_CAPTURE_TIMEOUT_MS);
1406
-
1407
- log.info(
1408
- {
1409
- callSessionId: this.callSessionId,
1410
- assistantId,
1411
- timeoutMs: NAME_CAPTURE_TIMEOUT_MS,
1412
- },
1413
- "Name capture started for unknown inbound caller",
1414
- );
1415
- }
1416
-
1417
- /**
1418
- * Handle the caller's name response during the name capture subflow.
1419
- * Creates a canonical access request, notifies the guardian, and
1420
- * enters the bounded wait loop for the guardian decision.
1421
- */
1422
- private async handleNameCaptureResponse(callerName: string): Promise<void> {
1423
- if (!this.accessRequestAssistantId || !this.accessRequestFromNumber) {
1424
- return;
1425
- }
1426
-
1427
- // Clear the name capture timeout since the caller responded.
1428
- if (this.nameCaptureTimeoutTimer) {
1429
- clearTimeout(this.nameCaptureTimeoutTimer);
1430
- this.nameCaptureTimeoutTimer = null;
1431
- }
1432
-
1433
- this.accessRequestCallerName = callerName;
1434
-
1435
- recordCallEvent(this.callSessionId, "inbound_acl_name_captured", {
1436
- from: this.accessRequestFromNumber,
1437
- callerName,
1438
- });
1439
-
1440
- // Create canonical access request and notify the guardian, including
1441
- // the caller's spoken name and voice channel metadata.
1442
- try {
1443
- const accessResult = await notifyGuardianOfAccessRequest({
1444
- canonicalAssistantId: this.accessRequestAssistantId,
1445
- sourceChannel: "phone",
1446
- conversationExternalId: this.accessRequestFromNumber,
1447
- actorExternalId: this.accessRequestFromNumber,
1448
- actorDisplayName: callerName,
1449
- });
1450
-
1451
- if (accessResult.notified) {
1452
- this.accessRequestId = accessResult.requestId;
1453
- log.info(
1454
- {
1455
- callSessionId: this.callSessionId,
1456
- requestId: accessResult.requestId,
1457
- callerName,
1458
- },
1459
- "Guardian notified of voice access request with caller name",
1460
- );
1461
- } else {
1462
- log.warn(
1463
- { callSessionId: this.callSessionId },
1464
- "Failed to notify guardian of voice access request — no sender ID",
1465
- );
1466
- }
1467
- } catch (err) {
1468
- log.error(
1469
- { err, callSessionId: this.callSessionId },
1470
- "Failed to create access request for voice caller",
1471
- );
1472
- }
1473
-
1474
- // If the access request was not successfully created (notifyGuardianOfAccessRequest
1475
- // threw or returned notified: false), fail closed rather than leaving the caller
1476
- // stuck on hold with no guardian poll target.
1477
- if (!this.accessRequestId) {
1478
- log.warn(
1479
- { callSessionId: this.callSessionId },
1480
- "Access request ID is null after notification attempt — failing closed",
1481
- );
1482
- void this.handleAccessRequestTimeout();
1483
- return;
1484
- }
1485
-
1486
- // Enter the bounded wait loop for the guardian decision
1487
- this.startAccessRequestWait();
1488
- }
1489
-
1490
- /**
1491
- * Start a bounded in-call wait loop polling the canonical request
1492
- * status until approved, denied, or timeout.
1493
- */
1494
- private startAccessRequestWait(): void {
1495
- this.accessRequestWaitActive = true;
1496
- this.connectionState = "awaiting_guardian_decision";
1497
-
1498
- const timeoutMs = getUserConsultationTimeoutMs();
1499
- const pollIntervalMs = getAccessRequestPollIntervalMs();
1500
-
1501
- const guardianLabel = this.resolveGuardianLabel();
1502
- void speakSystemPrompt(
1503
- this,
1504
- `Thank you. I've let ${guardianLabel} know. Please hold while I check if I have permission to speak with you.`,
1505
- );
1506
-
1507
- updateCallSession(this.callSessionId, { status: "waiting_on_user" });
1508
-
1509
- // Start the heartbeat timer for periodic progress updates.
1510
- // Delay the first heartbeat by the estimated TTS playback duration so
1511
- // the initial hold message finishes before any heartbeat fires.
1512
- this.heartbeatSequence = 0;
1513
- // Set the wait start time now so scheduleNextHeartbeat() always has a
1514
- // valid reference point — even if the TTS delay timer is cancelled early
1515
- // (e.g. by handleWaitStatePrompt when the caller speaks during playback).
1516
- // The callback below re-stamps it to exclude the TTS delay if it fires.
1517
- this.accessRequestWaitStartedAt = Date.now();
1518
- this.accessRequestHeartbeatTimer = setTimeout(() => {
1519
- this.accessRequestWaitStartedAt = Date.now();
1520
- this.scheduleNextHeartbeat();
1521
- }, getTtsPlaybackDelayMs());
1522
-
1523
- // Poll the canonical request status
1524
- this.accessRequestPollTimer = setInterval(() => {
1525
- if (!this.accessRequestWaitActive || !this.accessRequestId) {
1526
- this.clearAccessRequestWait();
1527
- return;
1528
- }
1529
-
1530
- const request = getCanonicalGuardianRequest(this.accessRequestId);
1531
- if (!request) {
1532
- return;
1533
- }
1534
-
1535
- if (request.status === "approved") {
1536
- void this.handleAccessRequestApproved();
1537
- } else if (request.status === "denied") {
1538
- void this.handleAccessRequestDenied();
1539
- }
1540
- // 'pending' continues polling; 'expired'/'cancelled' handled by timeout
1541
- }, pollIntervalMs);
1542
-
1543
- // Timeout: give up waiting for the guardian
1544
- this.accessRequestTimeoutTimer = setTimeout(() => {
1545
- if (!this.accessRequestWaitActive) return;
1546
-
1547
- log.info(
1548
- { callSessionId: this.callSessionId, requestId: this.accessRequestId },
1549
- "Access request in-call wait timed out",
1550
- );
1551
-
1552
- void this.handleAccessRequestTimeout();
1553
- }, timeoutMs);
1554
-
1555
- log.info(
1556
- {
1557
- callSessionId: this.callSessionId,
1558
- requestId: this.accessRequestId,
1559
- timeoutMs,
1560
- },
1561
- "Access request in-call wait started",
1562
- );
1563
- }
1564
-
1565
- /**
1566
- * Clean up access request wait state (timers, flags).
1567
- */
1568
- private clearAccessRequestWait(): void {
1569
- this.accessRequestWaitActive = false;
1570
- if (this.accessRequestPollTimer) {
1571
- clearInterval(this.accessRequestPollTimer);
1572
- this.accessRequestPollTimer = null;
1573
- }
1574
- if (this.accessRequestTimeoutTimer) {
1575
- clearTimeout(this.accessRequestTimeoutTimer);
1576
- this.accessRequestTimeoutTimer = null;
1577
- }
1578
- if (this.accessRequestHeartbeatTimer) {
1579
- clearTimeout(this.accessRequestHeartbeatTimer);
1580
- this.accessRequestHeartbeatTimer = null;
1581
- }
1582
- }
1583
-
1584
- /**
1585
- * Handle an approved access request: activate the caller as a trusted
1586
- * contact, update runtime context, and continue with normal call flow.
1587
- */
1588
- private async handleAccessRequestApproved(): Promise<void> {
1589
- this.clearAccessRequestWait();
1590
-
1591
- const assistantId = this.accessRequestAssistantId!;
1592
- const fromNumber = this.accessRequestFromNumber!;
1593
- const callerName = this.accessRequestCallerName;
1594
-
1595
- recordCallEvent(this.callSessionId, "inbound_acl_access_approved", {
1596
- from: fromNumber,
1597
- callerName,
1598
- requestId: this.accessRequestId,
1599
- });
1600
-
1601
- log.info(
1602
- { callSessionId: this.callSessionId, from: fromNumber },
1603
- "Access request approved — caller activated and continuing call",
1604
- );
1605
-
1606
- await this.continueCallAfterTrustedContactActivation({
1607
- assistantId,
1608
- fromNumber,
1609
- activationReason: "access_approved",
1610
- });
1611
-
1612
- recordCallEvent(
1613
- this.callSessionId,
1614
- "inbound_acl_post_approval_handoff_spoken",
1615
- {
1616
- from: fromNumber,
1617
- },
1618
- );
1619
- }
1620
-
1621
- /**
1622
- * Handle a denied access request: deliver deterministic copy and hang up.
1623
- */
1624
- private async handleAccessRequestDenied(): Promise<void> {
1625
- this.clearAccessRequestWait();
1626
-
1627
- const guardianLabel = this.resolveGuardianLabel();
1628
-
1629
- recordCallEvent(this.callSessionId, "inbound_acl_access_denied", {
1630
- from: this.accessRequestFromNumber,
1631
- requestId: this.accessRequestId,
1632
- });
1633
-
1634
- this.connectionState = "disconnecting";
1635
-
1636
- updateCallSession(this.callSessionId, {
1637
- status: "failed",
1638
- endedAt: Date.now(),
1639
- lastError: "Inbound voice ACL: guardian denied access request",
1640
- });
1641
-
1642
- log.info(
1643
- { callSessionId: this.callSessionId },
1644
- "Access request denied — ending call",
1645
- );
1646
-
1647
- await speakSystemPrompt(
1648
- this,
1649
- `Sorry, ${guardianLabel} says I'm not allowed to speak with you. Goodbye.`,
1650
- );
1651
- setTimeout(() => {
1652
- this.endSession("Access request denied");
1653
- }, getTtsPlaybackDelayMs());
1654
- }
1655
-
1656
- /**
1657
- * Handle an access request timeout: deliver deterministic copy and hang up.
1658
- */
1659
- private async handleAccessRequestTimeout(): Promise<void> {
1660
- // Emit callback handoff notification before clearing wait state
1661
- this.emitAccessRequestCallbackHandoffForReason("timeout");
1662
-
1663
- this.clearAccessRequestWait();
1664
-
1665
- const guardianLabel = this.resolveGuardianLabel();
1666
-
1667
- recordCallEvent(this.callSessionId, "inbound_acl_access_timeout", {
1668
- from: this.accessRequestFromNumber,
1669
- requestId: this.accessRequestId,
1670
- callbackOptIn: this.callbackOptIn,
1671
- });
1672
-
1673
- const callbackNote = this.callbackOptIn
1674
- ? ` I've noted that you'd like a callback — I'll pass that along to ${guardianLabel}.`
1675
- : "";
1676
-
1677
- this.connectionState = "disconnecting";
1678
-
1679
- updateCallSession(this.callSessionId, {
1680
- status: "failed",
1681
- endedAt: Date.now(),
1682
- lastError: "Inbound voice ACL: guardian approval wait timed out",
1683
- });
1684
-
1685
- log.info(
1686
- { callSessionId: this.callSessionId },
1687
- "Access request timed out — ending call",
1688
- );
1689
-
1690
- await speakSystemPrompt(
1691
- this,
1692
- `Sorry, I can't get ahold of ${guardianLabel} right now. I'll let them know you called.${callbackNote}`,
1693
- );
1694
- setTimeout(() => {
1695
- this.endSession("Access request timed out");
1696
- }, getTtsPlaybackDelayMs());
1697
- }
1698
-
1699
- private emitAccessRequestCallbackHandoffForReason(
1700
- reason: "timeout" | "transport_closed",
1701
- ): void {
1702
- const result = emitAccessRequestCallbackHandoff({
1703
- reason,
1704
- callbackOptIn: this.callbackOptIn,
1705
- accessRequestId: this.accessRequestId,
1706
- callbackHandoffNotified: this.callbackHandoffNotified,
1707
- accessRequestAssistantId: this.accessRequestAssistantId,
1708
- accessRequestFromNumber: this.accessRequestFromNumber,
1709
- accessRequestCallerName: this.accessRequestCallerName,
1710
- callSessionId: this.callSessionId,
1711
- });
1712
- this.callbackHandoffNotified = result.callbackHandoffNotified;
1713
- }
1714
-
1715
- /**
1716
- * Handle a name capture timeout: the caller never provided their name
1717
- * within the allotted window. Deliver deterministic copy and hang up.
1718
- */
1719
- private async handleNameCaptureTimeout(): Promise<void> {
1720
- if (this.nameCaptureTimeoutTimer) {
1721
- clearTimeout(this.nameCaptureTimeoutTimer);
1722
- this.nameCaptureTimeoutTimer = null;
1723
- }
1724
-
1725
- recordCallEvent(this.callSessionId, "inbound_acl_name_capture_timeout", {
1726
- from: this.accessRequestFromNumber,
1727
- });
1728
-
1729
- this.connectionState = "disconnecting";
1730
-
1731
- updateCallSession(this.callSessionId, {
1732
- status: "failed",
1733
- endedAt: Date.now(),
1734
- lastError: "Inbound voice ACL: name capture timed out",
1735
- });
1736
-
1737
- log.info(
1738
- { callSessionId: this.callSessionId },
1739
- "Name capture timed out — ending call",
1740
- );
1741
-
1742
- await speakSystemPrompt(
1743
- this,
1744
- "Sorry, I didn't catch your name. Please try calling back. Goodbye.",
1745
- );
1746
- setTimeout(() => {
1747
- this.endSession("Name capture timed out");
1748
- }, getTtsPlaybackDelayMs());
1749
- }
1750
-
1751
- /**
1752
- * Validate an entered invite code against active voice invites.
1753
- * Delegates to the extracted attemptInviteCodeRedemption() and
1754
- * interprets the structured result to drive side-effects.
1755
- */
1756
- private async handleInviteCodeRedemptionResult(
1757
- enteredCode: string,
1758
- ): Promise<void> {
1759
- if (!this.inviteRedemptionAssistantId || !this.inviteRedemptionFromNumber) {
1760
- return;
1761
- }
1762
-
1763
- // Dedup concurrent attempts: a repeated code (re-spoken / re-entered)
1764
- // arriving while the async gateway-backed claim is still in flight is
1765
- // ignored, so we never run a second redeemVoiceInviteCode that would see
1766
- // the invite already consumed and wrongly mark the call failed. The flag
1767
- // is set synchronously before awaiting and cleared in finally.
1768
- if (this.inviteRedemptionInFlight) {
1769
- log.info(
1770
- { callSessionId: this.callSessionId },
1771
- "Ignoring repeated invite code — redemption already in flight",
1772
- );
1773
- return;
1774
- }
1775
- this.inviteRedemptionInFlight = true;
1776
-
1777
- try {
1778
- await this.runInviteCodeRedemption(enteredCode);
1779
- } finally {
1780
- this.inviteRedemptionInFlight = false;
1781
- }
1782
- }
1783
-
1784
- private async runInviteCodeRedemption(enteredCode: string): Promise<void> {
1785
- if (!this.inviteRedemptionAssistantId || !this.inviteRedemptionFromNumber) {
1786
- return;
1787
- }
1788
-
1789
- const result = await attemptInviteCodeRedemption({
1790
- inviteRedemptionAssistantId: this.inviteRedemptionAssistantId,
1791
- inviteRedemptionFromNumber: this.inviteRedemptionFromNumber,
1792
- enteredCode,
1793
- guardianLabel: this.resolveGuardianLabel(),
1794
- });
1795
-
1796
- if (result.outcome === "success") {
1797
- this.inviteRedemptionActive = false;
1798
- this.verificationAttempts = 0;
1799
- this.dtmfBuffer = "";
1800
-
1801
- recordCallEvent(this.callSessionId, "invite_redemption_succeeded", {
1802
- memberId: result.memberId,
1803
- ...(result.inviteId ? { inviteId: result.inviteId } : {}),
1804
- });
1805
- log.info(
1806
- {
1807
- callSessionId: this.callSessionId,
1808
- memberId: result.memberId,
1809
- type: result.type,
1810
- },
1811
- "Voice invite redemption succeeded",
1812
- );
1813
-
1814
- await this.continueCallAfterTrustedContactActivation({
1815
- assistantId: this.inviteRedemptionAssistantId,
1816
- fromNumber: this.inviteRedemptionFromNumber,
1817
- activationReason: "invite_redeemed",
1818
- inviteeName: this.inviteRedemptionInviteeName,
1819
- });
1820
- } else {
1821
- this.inviteRedemptionActive = false;
1822
-
1823
- recordCallEvent(this.callSessionId, "invite_redemption_failed", {
1824
- attempts: 1,
1825
- });
1826
- log.warn(
1827
- { callSessionId: this.callSessionId },
1828
- "Voice invite redemption failed — invalid or expired code",
1829
- );
1830
-
1831
- this.connectionState = "disconnecting";
1832
-
1833
- updateCallSession(this.callSessionId, {
1834
- status: "failed",
1835
- endedAt: Date.now(),
1836
- lastError: "Voice invite redemption failed — invalid or expired code",
1837
- });
1838
-
1839
- const failSession = getCallSession(this.callSessionId);
1840
- if (failSession) {
1841
- finalizeCall(this.callSessionId, failSession.conversationId);
1842
- }
1843
-
1844
- await speakSystemPrompt(this, result.ttsMessage);
1845
- setTimeout(() => {
1846
- this.endSession("Invite redemption failed");
1847
- }, getTtsPlaybackDelayMs());
1848
- }
1849
- }
1850
-
1851
- // ── Guardian wait UX layer ─────────────────────────────────────
1852
-
1853
- /**
1854
- * Resolve a human-readable guardian label for voice wait copy.
1855
- * Delegates to the shared resolveGuardianName() which checks the
1856
- * guardian's per-user persona file (users/<slug>.md) first, then falls
1857
- * back to the primed gateway-binding displayName, then
1858
- * DEFAULT_USER_REFERENCE.
1859
- */
1860
- private resolveGuardianLabel(): string {
1861
- return resolveGuardianName(this.primedGuardianDisplayName);
1862
- }
1863
-
1864
- /**
1865
- * Prime the guardian displayName from the gateway binding so the
1866
- * synchronous wait-label path can read it without an IPC round-trip.
1867
- */
1868
- private async primeGuardianDisplayName(): Promise<void> {
1869
- const list = await getGuardianDelivery();
1870
- this.primedGuardianDisplayName = voiceGuardianDisplayName(list);
1871
- }
1872
-
1873
- /**
1874
- * Resolve the assistant's display name from identity configuration.
1875
- * Returns the trimmed name or null if unavailable.
1876
- */
1877
- private resolveAssistantLabel(): string | null {
1878
- try {
1879
- const name = getAssistantName();
1880
- const trimmedName = name?.trim();
1881
- if (!trimmedName || UUID_SHAPED_NAME.test(trimmedName)) {
1882
- return null;
1883
- }
1884
- return trimmedName;
1885
- } catch {
1886
- return null;
1887
- }
1888
- }
1889
-
1890
- private scheduleNextHeartbeat(): void {
1891
- this.accessRequestHeartbeatTimer = scheduleNextHeartbeat({
1892
- isWaitActive: () => this.accessRequestWaitActive,
1893
- accessRequestWaitStartedAt: this.accessRequestWaitStartedAt,
1894
- callSessionId: this.callSessionId,
1895
- consumeSequence: () => this.heartbeatSequence++,
1896
- resolveGuardianLabel: () => this.resolveGuardianLabel(),
1897
- sendTextToken: (text, _last) => void speakSystemPrompt(this, text),
1898
- scheduleNext: () => this.scheduleNextHeartbeat(),
1899
- });
1900
- }
1901
-
1902
- private classifyWaitUtterance(text: string) {
1903
- return classifyWaitUtterance(text, this.callbackOfferMade);
1904
- }
1905
-
1906
- /**
1907
- * Handle a caller utterance during the guardian decision wait state.
1908
- * Provides reassurance, impatience detection, and callback offer.
1909
- */
1910
- private handleWaitStatePrompt(text: string): void {
1911
- const now = Date.now();
1912
- const classification = this.classifyWaitUtterance(text);
1913
-
1914
- recordCallEvent(
1915
- this.callSessionId,
1916
- "voice_guardian_wait_prompt_classified",
1917
- {
1918
- classification,
1919
- transcript: text,
1920
- },
1921
- );
1922
-
1923
- if (classification === "empty") return;
1924
-
1925
- const guardianLabel = this.resolveGuardianLabel();
1926
-
1927
- // Callback decisions must always be processed regardless of cooldown —
1928
- // the caller is answering a direct question and dropping their response
1929
- // would silently discard their decision.
1930
- switch (classification) {
1931
- case "callback_opt_in": {
1932
- this.callbackOptIn = true;
1933
- this.lastInWaitReplyAt = now;
1934
- recordCallEvent(
1935
- this.callSessionId,
1936
- "voice_guardian_wait_callback_opt_in_set",
1937
- {},
1938
- );
1939
- if (this.accessRequestHeartbeatTimer) {
1940
- clearTimeout(this.accessRequestHeartbeatTimer);
1941
- this.accessRequestHeartbeatTimer = null;
1942
- }
1943
- void speakSystemPrompt(
1944
- this,
1945
- `Noted, I'll make sure ${guardianLabel} knows you'd like a callback. For now, I'll keep trying to reach them.`,
1946
- );
1947
- this.scheduleNextHeartbeat();
1948
- return;
1949
- }
1950
- case "callback_decline": {
1951
- this.callbackOptIn = false;
1952
- this.lastInWaitReplyAt = now;
1953
- recordCallEvent(
1954
- this.callSessionId,
1955
- "voice_guardian_wait_callback_opt_in_declined",
1956
- {},
1957
- );
1958
- if (this.accessRequestHeartbeatTimer) {
1959
- clearTimeout(this.accessRequestHeartbeatTimer);
1960
- this.accessRequestHeartbeatTimer = null;
1961
- }
1962
- void speakSystemPrompt(
1963
- this,
1964
- `No problem, I'll keep holding. Still waiting on ${guardianLabel}.`,
1965
- );
1966
- this.scheduleNextHeartbeat();
1967
- return;
1968
- }
1969
- default:
1970
- break;
1971
- }
1972
-
1973
- // Enforce cooldown on non-callback utterances to prevent spam
1974
- if (
1975
- now - this.lastInWaitReplyAt <
1976
- RelayConnection.IN_WAIT_REPLY_COOLDOWN_MS
1977
- ) {
1978
- log.debug(
1979
- { callSessionId: this.callSessionId },
1980
- "In-wait reply suppressed by cooldown",
1981
- );
1982
- return;
1983
- }
1984
- this.lastInWaitReplyAt = now;
1985
-
1986
- switch (classification) {
1987
- case "impatient": {
1988
- if (this.accessRequestHeartbeatTimer) {
1989
- clearTimeout(this.accessRequestHeartbeatTimer);
1990
- this.accessRequestHeartbeatTimer = null;
1991
- }
1992
- if (!this.callbackOfferMade) {
1993
- this.callbackOfferMade = true;
1994
- recordCallEvent(
1995
- this.callSessionId,
1996
- "voice_guardian_wait_callback_offer_sent",
1997
- {},
1998
- );
1999
- void speakSystemPrompt(
2000
- this,
2001
- `I understand this is taking a while. I can have ${guardianLabel} call you back once I hear from them. Would you like that, or would you prefer to keep holding?`,
2002
- );
2003
- } else {
2004
- // Already offered callback — just reassure
2005
- void speakSystemPrompt(
2006
- this,
2007
- `I hear you, I'm sorry for the wait. Still trying to reach ${guardianLabel}.`,
2008
- );
2009
- }
2010
- this.scheduleNextHeartbeat();
2011
- break;
2012
- }
2013
- case "patience_check": {
2014
- // Immediate reassurance — reset the heartbeat timer so we
2015
- // don't double up with a scheduled heartbeat
2016
- if (this.accessRequestHeartbeatTimer) {
2017
- clearTimeout(this.accessRequestHeartbeatTimer);
2018
- this.accessRequestHeartbeatTimer = null;
2019
- }
2020
- void speakSystemPrompt(
2021
- this,
2022
- `Yes, I'm still here. Still waiting to hear back from ${guardianLabel}.`,
2023
- );
2024
- this.scheduleNextHeartbeat();
2025
- break;
2026
- }
2027
- case "neutral":
2028
- default: {
2029
- if (this.accessRequestHeartbeatTimer) {
2030
- clearTimeout(this.accessRequestHeartbeatTimer);
2031
- this.accessRequestHeartbeatTimer = null;
2032
- }
2033
- void speakSystemPrompt(
2034
- this,
2035
- `Thanks for that. I'm still waiting on ${guardianLabel}. I'll let you know as soon as I hear back.`,
2036
- );
2037
- this.scheduleNextHeartbeat();
2038
- break;
2039
- }
2040
- }
2041
- }
2042
-
2043
- private async handlePrompt(msg: RelayPromptMessage): Promise<void> {
2044
- if (this.connectionState === "disconnecting") {
2045
- return;
2046
- }
2047
-
2048
- // Defer (don't drop) prompts while trust is being re-resolved mid-call so a
2049
- // verified caller's first utterance runs under the upgraded context, not the
2050
- // stale pre-verification one. flushDeferredPrompts replays them in order
2051
- // once the new context lands.
2052
- if (this.trustReResolving) {
2053
- this.trustReResolvePending.push(msg);
2054
- return;
2055
- }
2056
-
2057
- // Prompts arriving before setup routing/ACL completes are dropped — never
2058
- // persisted or emitted — so a not-yet-authorized caller's speech can't be
2059
- // stored ahead of the admission floor / trust ACL decision.
2060
- if (this.connectionState === "setting_up") {
2061
- return;
2062
- }
2063
-
2064
- if (!msg.last) {
2065
- // Partial transcript, wait for final
2066
- return;
2067
- }
2068
-
2069
- // During name capture, the caller's response is their name.
2070
- if (this.connectionState === "awaiting_name") {
2071
- const callerName = msg.voicePrompt.trim();
2072
- if (!callerName) {
2073
- // Whitespace-only or empty transcript (e.g. silence/noise) —
2074
- // keep waiting for a real name. The name-capture timeout will
2075
- // still fire if the caller never provides one.
2076
- return;
2077
- }
2078
- log.info(
2079
- { callSessionId: this.callSessionId, callerName },
2080
- "Name captured from unknown inbound caller",
2081
- );
2082
- await this.handleNameCaptureResponse(callerName);
2083
- return;
2084
- }
2085
-
2086
- // During guardian decision wait, classify caller speech for
2087
- // reassurance, impatience detection, and callback offer.
2088
- if (this.connectionState === "awaiting_guardian_decision") {
2089
- this.handleWaitStatePrompt(msg.voicePrompt);
2090
- return;
2091
- }
2092
-
2093
- // During guardian verification (inbound or outbound), attempt to parse
2094
- // spoken digits from the transcript and validate them.
2095
- if (
2096
- this.connectionState === "verification_pending" &&
2097
- this.verificationSessionActive
2098
- ) {
2099
- const spokenDigits = parseDigitsFromSpeech(msg.voicePrompt);
2100
- log.info(
2101
- {
2102
- callSessionId: this.callSessionId,
2103
- transcript: msg.voicePrompt,
2104
- spokenDigits,
2105
- },
2106
- "Speech received during guardian voice verification",
2107
- );
2108
- if (spokenDigits.length >= this.verificationCodeLength) {
2109
- const enteredCode = spokenDigits.slice(0, this.verificationCodeLength);
2110
- void this.handleVerificationCodeResult(enteredCode);
2111
- } else if (spokenDigits.length > 0) {
2112
- void speakSystemPrompt(
2113
- this,
2114
- `I heard ${spokenDigits.length} digits. Please enter all ${this.verificationCodeLength} digits of your code.`,
2115
- );
2116
- }
2117
- return;
2118
- }
2119
-
2120
- // During invite redemption, attempt to parse spoken digits from the
2121
- // transcript and validate against the caller's active voice invite.
2122
- if (
2123
- this.connectionState === "verification_pending" &&
2124
- this.inviteRedemptionActive
2125
- ) {
2126
- const spokenDigits = parseDigitsFromSpeech(msg.voicePrompt);
2127
- log.info(
2128
- {
2129
- callSessionId: this.callSessionId,
2130
- transcript: msg.voicePrompt,
2131
- spokenDigits,
2132
- },
2133
- "Speech received during invite redemption",
2134
- );
2135
- if (spokenDigits.length >= this.inviteRedemptionCodeLength) {
2136
- const enteredCode = spokenDigits.slice(
2137
- 0,
2138
- this.inviteRedemptionCodeLength,
2139
- );
2140
- void this.handleInviteCodeRedemptionResult(enteredCode);
2141
- } else if (spokenDigits.length > 0) {
2142
- void speakSystemPrompt(
2143
- this,
2144
- `I heard ${spokenDigits.length} digits. Please enter all ${this.inviteRedemptionCodeLength} digits of your code.`,
2145
- );
2146
- }
2147
- return;
2148
- }
2149
-
2150
- // During outbound callee verification, ignore voice prompts — the callee
2151
- // should be entering DTMF digits, not speaking.
2152
- if (this.connectionState === "verification_pending") {
2153
- log.debug(
2154
- { callSessionId: this.callSessionId },
2155
- "Ignoring voice prompt during callee verification",
2156
- );
2157
- return;
2158
- }
2159
-
2160
- log.info(
2161
- {
2162
- callSessionId: this.callSessionId,
2163
- transcript: msg.voicePrompt,
2164
- lang: msg.lang,
2165
- },
2166
- "Caller transcript received (final)",
2167
- );
2168
-
2169
- // Spread to widen the typed message into a plain record — extractPromptSpeakerMetadata
2170
- // probes for snake_case and nested property variants not on RelayPromptMessage.
2171
- const speakerMetadata = extractPromptSpeakerMetadata({ ...msg });
2172
- const speaker =
2173
- this.speakerIdentityTracker.identifySpeaker(speakerMetadata);
2174
-
2175
- // Record in conversation history
2176
- this.conversationHistory.push({
2177
- role: "caller",
2178
- text: msg.voicePrompt,
2179
- timestamp: Date.now(),
2180
- speaker,
2181
- });
2182
-
2183
- // Record event
2184
- recordCallEvent(this.callSessionId, "caller_spoke", {
2185
- transcript: msg.voicePrompt,
2186
- lang: msg.lang,
2187
- speakerId: speaker.speakerId,
2188
- speakerLabel: speaker.speakerLabel,
2189
- speakerConfidence: speaker.speakerConfidence,
2190
- speakerSource: speaker.source,
2191
- });
2192
-
2193
- const session = getCallSession(this.callSessionId);
2194
- if (session) {
2195
- // User message persistence is handled by the conversation pipeline
2196
- // (voice-session-bridge -> conversation.persistUserMessage) so we only
2197
- // need to fire the transcript notifier for UI subscribers here.
2198
- fireCallTranscriptNotifier(
2199
- session.conversationId,
2200
- this.callSessionId,
2201
- "caller",
2202
- msg.voicePrompt,
2203
- );
2204
- }
2205
-
2206
- // Route to controller for conversation-backed response
2207
- if (this.controller) {
2208
- await this.controller.handleCallerUtterance(msg.voicePrompt, speaker);
2209
- } else {
2210
- // Fallback if controller not yet initialized — persist the caller's
2211
- // transcript so it is available in conversation history once setup
2212
- // completes. The conversation pipeline normally handles persistence, but
2213
- // this early-utterance path bypasses it entirely.
2214
- if (session) {
2215
- try {
2216
- await addMessage(
2217
- session.conversationId,
2218
- "user",
2219
- JSON.stringify([{ type: "text", text: msg.voicePrompt }]),
2220
- {
2221
- metadata: {
2222
- userMessageChannel: "phone",
2223
- assistantMessageChannel: "phone",
2224
- userMessageInterface: "phone",
2225
- assistantMessageInterface: "phone",
2226
- },
2227
- },
2228
- );
2229
- } catch (err) {
2230
- // Best-effort — don't let persistence failures prevent the hold
2231
- // response from reaching the caller.
2232
- log.warn(
2233
- { err, callSessionId: this.callSessionId },
2234
- "Failed to persist early caller utterance",
2235
- );
2236
- }
2237
- }
2238
- void speakSystemPrompt(this, "I'm still setting up. Please hold.");
2239
- }
2240
- }
2241
-
2242
- private handleInterrupt(msg: RelayInterruptMessage): void {
2243
- log.info(
2244
- {
2245
- callSessionId: this.callSessionId,
2246
- utteranceUntilInterrupt: msg.utteranceUntilInterrupt,
2247
- },
2248
- "Caller interrupted assistant",
2249
- );
2250
-
2251
- // Abort any in-flight processing
2252
- this.abortController.abort();
2253
- this.abortController = new AbortController();
2254
-
2255
- // Notify the controller of the interruption
2256
- if (this.controller) {
2257
- this.controller.handleInterrupt();
2258
- }
2259
- }
2260
-
2261
- private handleDtmf(msg: RelayDtmfMessage): void {
2262
- if (this.connectionState === "disconnecting") {
2263
- return;
2264
- }
2265
-
2266
- // Drop DTMF arriving before setup routing/ACL completes (same reasoning as
2267
- // handlePrompt): don't record input from a not-yet-authorized caller.
2268
- if (this.connectionState === "setting_up") {
2269
- return;
2270
- }
2271
-
2272
- // Ignore DTMF during name capture and guardian decision wait
2273
- if (
2274
- this.connectionState === "awaiting_name" ||
2275
- this.connectionState === "awaiting_guardian_decision"
2276
- ) {
2277
- return;
2278
- }
2279
-
2280
- log.info(
2281
- { callSessionId: this.callSessionId, digit: msg.digit },
2282
- "DTMF digit received",
2283
- );
2284
-
2285
- recordCallEvent(this.callSessionId, "caller_spoke", {
2286
- dtmfDigit: msg.digit,
2287
- });
2288
-
2289
- // If guardian verification (inbound or outbound) is pending, accumulate
2290
- // digits and validate against the challenge via the guardian service.
2291
- if (
2292
- this.connectionState === "verification_pending" &&
2293
- this.verificationSessionActive
2294
- ) {
2295
- this.dtmfBuffer += msg.digit;
2296
-
2297
- if (this.dtmfBuffer.length >= this.verificationCodeLength) {
2298
- const enteredCode = this.dtmfBuffer.slice(
2299
- 0,
2300
- this.verificationCodeLength,
2301
- );
2302
- this.dtmfBuffer = "";
2303
- void this.handleVerificationCodeResult(enteredCode);
2304
- }
2305
- return;
2306
- }
2307
-
2308
- // If invite redemption is pending, accumulate digits and validate
2309
- // the code against the caller's active voice invite.
2310
- if (
2311
- this.connectionState === "verification_pending" &&
2312
- this.inviteRedemptionActive
2313
- ) {
2314
- this.dtmfBuffer += msg.digit;
2315
-
2316
- if (this.dtmfBuffer.length >= this.inviteRedemptionCodeLength) {
2317
- const enteredCode = this.dtmfBuffer.slice(
2318
- 0,
2319
- this.inviteRedemptionCodeLength,
2320
- );
2321
- this.dtmfBuffer = "";
2322
- void this.handleInviteCodeRedemptionResult(enteredCode);
2323
- }
2324
- return;
2325
- }
2326
-
2327
- // If outbound callee verification is pending, accumulate digits and check the code
2328
- if (
2329
- this.connectionState === "verification_pending" &&
2330
- this.verificationCode
2331
- ) {
2332
- this.dtmfBuffer += msg.digit;
2333
-
2334
- if (this.dtmfBuffer.length >= this.verificationCodeLength) {
2335
- const enteredCode = this.dtmfBuffer.slice(
2336
- 0,
2337
- this.verificationCodeLength,
2338
- );
2339
- this.dtmfBuffer = "";
2340
-
2341
- if (enteredCode === this.verificationCode) {
2342
- // Verification succeeded
2343
- this.connectionState = "connected";
2344
- this.verificationCode = null;
2345
- this.verificationAttempts = 0;
2346
-
2347
- recordCallEvent(
2348
- this.callSessionId,
2349
- "callee_verification_succeeded",
2350
- {},
2351
- );
2352
- log.info(
2353
- { callSessionId: this.callSessionId },
2354
- "Callee verification succeeded",
2355
- );
2356
-
2357
- // Proceed to the normal call flow
2358
- if (this.controller) {
2359
- this.controller
2360
- .startInitialGreeting()
2361
- .catch((err) =>
2362
- log.error(
2363
- { err, callSessionId: this.callSessionId },
2364
- "Failed to start initial outbound greeting after verification",
2365
- ),
2366
- );
2367
- }
2368
- } else {
2369
- // Verification failed for this attempt
2370
- this.verificationAttempts++;
2371
-
2372
- if (this.verificationAttempts >= this.verificationMaxAttempts) {
2373
- // Max attempts reached — end the call
2374
- recordCallEvent(this.callSessionId, "callee_verification_failed", {
2375
- attempts: this.verificationAttempts,
2376
- });
2377
- log.warn(
2378
- {
2379
- callSessionId: this.callSessionId,
2380
- attempts: this.verificationAttempts,
2381
- },
2382
- "Callee verification failed — max attempts reached",
2383
- );
2384
-
2385
- // Mark failed immediately so a relay close during the goodbye TTS
2386
- // window cannot race this into a terminal "completed" status.
2387
- updateCallSession(this.callSessionId, {
2388
- status: "failed",
2389
- endedAt: Date.now(),
2390
- lastError: "Callee verification failed — max attempts exceeded",
2391
- });
2392
-
2393
- const session = getCallSession(this.callSessionId);
2394
- if (session) {
2395
- finalizeCall(this.callSessionId, session.conversationId);
2396
- if (session.initiatedFromConversationId) {
2397
- addPointerMessage(
2398
- session.initiatedFromConversationId,
2399
- "failed",
2400
- session.toNumber,
2401
- {
2402
- reason: "Callee verification failed",
2403
- },
2404
- ).catch((err) => {
2405
- log.warn(
2406
- {
2407
- conversationId: session.initiatedFromConversationId,
2408
- err,
2409
- },
2410
- "Skipping pointer write — origin conversation may no longer exist",
2411
- );
2412
- });
2413
- }
2414
- }
2415
-
2416
- // Wait for synthesis to complete before starting teardown timer
2417
- // so the caller hears the goodbye message.
2418
- void speakSystemPrompt(this, "Verification failed. Goodbye.")
2419
- .then(() => {
2420
- setTimeout(() => {
2421
- this.endSession("Verification failed");
2422
- }, getTtsPlaybackDelayMs());
2423
- })
2424
- .catch((err) => {
2425
- log.error(
2426
- { err, callSessionId: this.callSessionId },
2427
- "System prompt TTS failed during verification teardown",
2428
- );
2429
- setTimeout(() => {
2430
- this.endSession("Verification failed");
2431
- }, getTtsPlaybackDelayMs());
2432
- });
2433
- } else {
2434
- // Allow another attempt
2435
- log.info(
2436
- {
2437
- callSessionId: this.callSessionId,
2438
- attempt: this.verificationAttempts,
2439
- maxAttempts: this.verificationMaxAttempts,
2440
- },
2441
- "Callee verification attempt failed — retrying",
2442
- );
2443
- void speakSystemPrompt(
2444
- this,
2445
- "That code was incorrect. Please try again.",
2446
- );
2447
- }
2448
- }
2449
- }
2450
- }
2451
- }
2452
-
2453
- private handleError(msg: RelayErrorMessage): void {
2454
- log.error(
2455
- { callSessionId: this.callSessionId, description: msg.description },
2456
- "ConversationRelay error",
2457
- );
2458
-
2459
- recordCallEvent(this.callSessionId, "call_failed", {
2460
- error: msg.description,
2461
- });
2462
- }
2463
- }