vellum 0.2.0 → 0.2.2

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 (361) hide show
  1. package/README.md +15 -2
  2. package/bun.lock +5 -2
  3. package/package.json +4 -2
  4. package/scripts/capture-x-graphql.ts +562 -0
  5. package/scripts/ipc/check-swift-decoder-drift.ts +2 -1
  6. package/scripts/test.sh +5 -0
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +161 -34
  8. package/src/__tests__/account-registry.test.ts +2 -1
  9. package/src/__tests__/agent-heartbeat-service.test.ts +250 -0
  10. package/src/__tests__/app-bundler.test.ts +12 -33
  11. package/src/__tests__/asset-materialize-tool.test.ts +16 -15
  12. package/src/__tests__/asset-search-tool.test.ts +23 -22
  13. package/src/__tests__/attachments-store.test.ts +56 -127
  14. package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +5 -4
  15. package/src/__tests__/browser-skill-endstate.test.ts +5 -8
  16. package/src/__tests__/call-bridge.test.ts +385 -0
  17. package/src/__tests__/call-constants.test.ts +40 -0
  18. package/src/__tests__/call-orchestrator.test.ts +454 -0
  19. package/src/__tests__/call-recovery.test.ts +518 -0
  20. package/src/__tests__/call-routes-http.test.ts +459 -0
  21. package/src/__tests__/call-state-machine.test.ts +143 -0
  22. package/src/__tests__/call-state.test.ts +133 -0
  23. package/src/__tests__/call-store.test.ts +691 -0
  24. package/src/__tests__/cli-discover.test.ts +1 -1
  25. package/src/__tests__/commit-message-enrichment-service.test.ts +550 -0
  26. package/src/__tests__/compaction.benchmark.test.ts +176 -0
  27. package/src/__tests__/computer-use-tools.test.ts +250 -0
  28. package/src/__tests__/config-schema.test.ts +348 -3
  29. package/src/__tests__/conflict-store.test.ts +2 -1
  30. package/src/__tests__/contacts-tools.test.ts +331 -0
  31. package/src/__tests__/conversation-store.test.ts +30 -32
  32. package/src/__tests__/credential-security-invariants.test.ts +4 -0
  33. package/src/__tests__/date-context.test.ts +373 -0
  34. package/src/__tests__/db-schedule-syntax-migration.test.ts +129 -0
  35. package/src/__tests__/doordash-session.test.ts +9 -0
  36. package/src/__tests__/fixtures/media-reuse-fixtures.ts +3 -3
  37. package/src/__tests__/followup-tools.test.ts +303 -0
  38. package/src/__tests__/handlers-twitter-config.test.ts +718 -0
  39. package/src/__tests__/intent-routing.test.ts +64 -57
  40. package/src/__tests__/ipc-roundtrip.benchmark.test.ts +237 -0
  41. package/src/__tests__/ipc-snapshot.test.ts +96 -28
  42. package/src/__tests__/llm-usage-store.test.ts +3 -8
  43. package/src/__tests__/media-generate-image.test.ts +1 -1
  44. package/src/__tests__/media-reuse-story.e2e.test.ts +7 -7
  45. package/src/__tests__/memory-retrieval.benchmark.test.ts +430 -0
  46. package/src/__tests__/parallel-tool.benchmark.test.ts +294 -0
  47. package/src/__tests__/playbook-tools.test.ts +342 -0
  48. package/src/__tests__/profile-compiler.test.ts +2 -1
  49. package/src/__tests__/provider-streaming.benchmark.test.ts +773 -0
  50. package/src/__tests__/recurrence-engine-rruleset.test.ts +78 -0
  51. package/src/__tests__/recurrence-engine.test.ts +69 -0
  52. package/src/__tests__/recurrence-types.test.ts +71 -0
  53. package/src/__tests__/registry.test.ts +17 -10
  54. package/src/__tests__/relay-server.test.ts +633 -0
  55. package/src/__tests__/reminder-store.test.ts +6 -3
  56. package/src/__tests__/reminder.test.ts +43 -77
  57. package/src/__tests__/run-orchestrator-assistant-events.test.ts +222 -0
  58. package/src/__tests__/run-orchestrator.test.ts +7 -7
  59. package/src/__tests__/runtime-attachment-metadata.test.ts +19 -20
  60. package/src/__tests__/runtime-runs-http.test.ts +5 -23
  61. package/src/__tests__/runtime-runs.test.ts +11 -11
  62. package/src/__tests__/schedule-store.test.ts +482 -0
  63. package/src/__tests__/schedule-tools.test.ts +700 -0
  64. package/src/__tests__/scheduler-recurrence.test.ts +329 -0
  65. package/src/__tests__/server-history-render.test.ts +14 -13
  66. package/src/__tests__/session-error.test.ts +28 -0
  67. package/src/__tests__/session-init.benchmark.test.ts +462 -0
  68. package/src/__tests__/session-queue.test.ts +89 -16
  69. package/src/__tests__/session-runtime-assembly.test.ts +161 -0
  70. package/src/__tests__/session-surfaces-task-progress.test.ts +104 -0
  71. package/src/__tests__/signup-e2e.test.ts +2 -1
  72. package/src/__tests__/skill-projection.benchmark.test.ts +328 -0
  73. package/src/__tests__/skill-script-runner.test.ts +159 -0
  74. package/src/__tests__/speaker-identification.test.ts +52 -0
  75. package/src/__tests__/subagent-manager-notify.test.ts +42 -10
  76. package/src/__tests__/subagent-tools.test.ts +141 -41
  77. package/src/__tests__/task-compiler.test.ts +2 -1
  78. package/src/__tests__/task-runner.test.ts +2 -1
  79. package/src/__tests__/task-scheduler.test.ts +2 -1
  80. package/src/__tests__/task-tools.test.ts +49 -56
  81. package/src/__tests__/tool-audit-listener.test.ts +1 -0
  82. package/src/__tests__/tool-domain-event-publisher.test.ts +2 -0
  83. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +500 -0
  84. package/src/__tests__/tool-executor.test.ts +13 -17
  85. package/src/__tests__/turn-commit.test.ts +273 -2
  86. package/src/__tests__/twilio-provider.test.ts +143 -0
  87. package/src/__tests__/twilio-routes.test.ts +789 -0
  88. package/src/__tests__/twitter-auth-handler.test.ts +581 -0
  89. package/src/__tests__/view-image-tool.test.ts +217 -0
  90. package/src/__tests__/workspace-git-service.test.ts +403 -0
  91. package/src/__tests__/workspace-heartbeat-service.test.ts +141 -2
  92. package/src/agent-heartbeat/agent-heartbeat-service.ts +155 -0
  93. package/src/bundler/app-bundler.ts +35 -14
  94. package/src/calls/call-bridge.ts +95 -0
  95. package/src/calls/call-constants.ts +48 -0
  96. package/src/calls/call-domain.ts +276 -0
  97. package/src/calls/call-orchestrator.ts +390 -0
  98. package/src/calls/call-recovery.ts +207 -0
  99. package/src/calls/call-state-machine.ts +68 -0
  100. package/src/calls/call-state.ts +64 -0
  101. package/src/calls/call-store.ts +416 -0
  102. package/src/calls/relay-server.ts +335 -0
  103. package/src/calls/speaker-identification.ts +213 -0
  104. package/src/calls/twilio-config.ts +34 -0
  105. package/src/calls/twilio-provider.ts +173 -0
  106. package/src/calls/twilio-routes.ts +250 -0
  107. package/src/calls/types.ts +37 -0
  108. package/src/calls/voice-provider.ts +14 -0
  109. package/src/cli/config-commands.ts +334 -0
  110. package/src/cli/core-commands.ts +776 -0
  111. package/src/cli/doordash.ts +256 -25
  112. package/src/cli/ipc-client.ts +82 -0
  113. package/src/cli/map.ts +246 -0
  114. package/src/cli/twitter.ts +575 -0
  115. package/src/cli.ts +7 -5
  116. package/src/commands/__tests__/cc-command-registry.test.ts +319 -0
  117. package/src/commands/cc-command-registry.ts +209 -0
  118. package/src/config/bundled-skills/contacts/SKILL.md +39 -0
  119. package/src/config/bundled-skills/contacts/TOOLS.json +122 -0
  120. package/src/config/bundled-skills/contacts/tools/contact-merge.ts +9 -0
  121. package/src/config/bundled-skills/contacts/tools/contact-search.ts +9 -0
  122. package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +9 -0
  123. package/src/config/bundled-skills/document/SKILL.md +18 -0
  124. package/src/config/bundled-skills/document/TOOLS.json +53 -0
  125. package/src/config/bundled-skills/document/tools/document-create.ts +9 -0
  126. package/src/config/bundled-skills/document/tools/document-update.ts +9 -0
  127. package/src/config/bundled-skills/doordash/SKILL.md +163 -0
  128. package/src/config/bundled-skills/followups/SKILL.md +32 -0
  129. package/src/config/bundled-skills/followups/TOOLS.json +100 -0
  130. package/src/config/bundled-skills/followups/tools/followup-create.ts +9 -0
  131. package/src/config/bundled-skills/followups/tools/followup-list.ts +9 -0
  132. package/src/config/bundled-skills/followups/tools/followup-resolve.ts +9 -0
  133. package/src/config/bundled-skills/image-studio/TOOLS.json +2 -2
  134. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +2 -24
  135. package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +2 -1
  136. package/src/config/bundled-skills/playbooks/SKILL.md +31 -0
  137. package/src/config/bundled-skills/playbooks/TOOLS.json +126 -0
  138. package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +9 -0
  139. package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +9 -0
  140. package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +9 -0
  141. package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +9 -0
  142. package/src/config/bundled-skills/reminder/SKILL.md +20 -0
  143. package/src/config/bundled-skills/reminder/TOOLS.json +67 -0
  144. package/src/config/bundled-skills/reminder/tools/reminder-cancel.ts +9 -0
  145. package/src/config/bundled-skills/reminder/tools/reminder-create.ts +9 -0
  146. package/src/config/bundled-skills/reminder/tools/reminder-list.ts +9 -0
  147. package/src/config/bundled-skills/schedule/SKILL.md +74 -0
  148. package/src/config/bundled-skills/schedule/TOOLS.json +135 -0
  149. package/src/config/bundled-skills/schedule/tools/schedule-create.ts +9 -0
  150. package/src/config/bundled-skills/schedule/tools/schedule-delete.ts +9 -0
  151. package/src/config/bundled-skills/schedule/tools/schedule-list.ts +9 -0
  152. package/src/config/bundled-skills/schedule/tools/schedule-update.ts +9 -0
  153. package/src/config/bundled-skills/subagent/SKILL.md +25 -0
  154. package/src/config/bundled-skills/subagent/TOOLS.json +107 -0
  155. package/src/config/bundled-skills/subagent/tools/subagent-abort.ts +9 -0
  156. package/src/config/bundled-skills/subagent/tools/subagent-message.ts +9 -0
  157. package/src/config/bundled-skills/subagent/tools/subagent-read.ts +9 -0
  158. package/src/config/bundled-skills/subagent/tools/subagent-spawn.ts +9 -0
  159. package/src/config/bundled-skills/subagent/tools/subagent-status.ts +9 -0
  160. package/src/config/bundled-skills/tasks/SKILL.md +28 -0
  161. package/src/config/bundled-skills/tasks/TOOLS.json +256 -0
  162. package/src/config/bundled-skills/tasks/tools/task-delete.ts +9 -0
  163. package/src/config/bundled-skills/tasks/tools/task-list-add.ts +9 -0
  164. package/src/config/bundled-skills/tasks/tools/task-list-remove.ts +9 -0
  165. package/src/config/bundled-skills/tasks/tools/task-list-show.ts +9 -0
  166. package/src/config/bundled-skills/tasks/tools/task-list-update.ts +9 -0
  167. package/src/config/bundled-skills/tasks/tools/task-list.ts +9 -0
  168. package/src/config/bundled-skills/tasks/tools/task-run.ts +9 -0
  169. package/src/config/bundled-skills/tasks/tools/task-save.ts +9 -0
  170. package/src/config/bundled-skills/twitter/SKILL.md +134 -0
  171. package/src/config/bundled-skills/watcher/SKILL.md +27 -0
  172. package/src/config/bundled-skills/watcher/TOOLS.json +147 -0
  173. package/src/config/bundled-skills/watcher/tools/watcher-create.ts +9 -0
  174. package/src/config/bundled-skills/watcher/tools/watcher-delete.ts +9 -0
  175. package/src/config/bundled-skills/watcher/tools/watcher-digest.ts +9 -0
  176. package/src/config/bundled-skills/watcher/tools/watcher-list.ts +9 -0
  177. package/src/config/bundled-skills/watcher/tools/watcher-update.ts +9 -0
  178. package/src/config/defaults.ts +44 -0
  179. package/src/config/loader.ts +4 -1
  180. package/src/config/schema.ts +218 -1
  181. package/src/config/system-prompt.ts +100 -6
  182. package/src/config/templates/IDENTITY.md +7 -0
  183. package/src/config/types.ts +5 -0
  184. package/src/contacts/contact-store.ts +4 -4
  185. package/src/daemon/assistant-attachments.ts +10 -0
  186. package/src/daemon/classifier.ts +3 -1
  187. package/src/daemon/computer-use-session.ts +3 -1
  188. package/src/daemon/date-context.ts +136 -0
  189. package/src/daemon/handlers/apps.ts +16 -1
  190. package/src/daemon/handlers/browser.ts +54 -0
  191. package/src/daemon/handlers/computer-use.ts +7 -1
  192. package/src/daemon/handlers/config.ts +192 -4
  193. package/src/daemon/handlers/diagnostics.ts +5 -1
  194. package/src/daemon/handlers/documents.ts +18 -29
  195. package/src/daemon/handlers/home-base.ts +5 -1
  196. package/src/daemon/handlers/index.ts +40 -271
  197. package/src/daemon/handlers/misc.ts +9 -1
  198. package/src/daemon/handlers/publish.ts +6 -1
  199. package/src/daemon/handlers/sessions.ts +65 -12
  200. package/src/daemon/handlers/shared.ts +36 -1
  201. package/src/daemon/handlers/signing.ts +37 -0
  202. package/src/daemon/handlers/skills.ts +20 -6
  203. package/src/daemon/handlers/subagents.ts +8 -3
  204. package/src/daemon/handlers/twitter-auth.ts +169 -0
  205. package/src/daemon/handlers/work-items.ts +495 -39
  206. package/src/daemon/ipc-contract-inventory.json +40 -4
  207. package/src/daemon/ipc-contract.ts +185 -37
  208. package/src/daemon/ipc-protocol.ts +7 -2
  209. package/src/daemon/lifecycle.ts +48 -5
  210. package/src/daemon/main.ts +10 -4
  211. package/src/daemon/ride-shotgun-handler.ts +74 -10
  212. package/src/daemon/server.ts +144 -29
  213. package/src/daemon/session-agent-loop.ts +887 -0
  214. package/src/daemon/session-attachments.ts +28 -5
  215. package/src/daemon/session-error.ts +24 -3
  216. package/src/daemon/session-lifecycle.ts +147 -0
  217. package/src/daemon/session-media-retry.ts +147 -0
  218. package/src/daemon/session-messaging.ts +145 -0
  219. package/src/daemon/session-notifiers.ts +164 -0
  220. package/src/daemon/session-process.ts +2 -2
  221. package/src/daemon/session-queue-manager.ts +1 -0
  222. package/src/daemon/session-runtime-assembly.ts +52 -0
  223. package/src/daemon/session-skill-tools.ts +124 -5
  224. package/src/daemon/session-slash.ts +3 -0
  225. package/src/daemon/session-surfaces.ts +77 -2
  226. package/src/daemon/session-tool-setup.ts +222 -2
  227. package/src/daemon/session-usage.ts +0 -2
  228. package/src/daemon/session.ts +114 -1365
  229. package/src/daemon/video-thumbnail.ts +60 -0
  230. package/src/doordash/client.ts +121 -27
  231. package/src/doordash/queries.ts +1 -2
  232. package/src/export/formatter.ts +3 -1
  233. package/src/followups/followup-store.ts +4 -2
  234. package/src/followups/types.ts +6 -0
  235. package/src/hooks/templates.ts +1 -1
  236. package/src/index.ts +32 -1151
  237. package/src/media/gemini-image-service.ts +1 -1
  238. package/src/memory/attachments-store.ts +28 -83
  239. package/src/memory/channel-delivery-store.ts +7 -21
  240. package/src/memory/clarification-resolver.ts +6 -5
  241. package/src/memory/contradiction-checker.ts +3 -2
  242. package/src/memory/conversation-key-store.ts +10 -29
  243. package/src/memory/conversation-store.ts +2 -1
  244. package/src/memory/db.ts +362 -2
  245. package/src/memory/entity-extractor.ts +6 -3
  246. package/src/memory/items-extractor.ts +5 -4
  247. package/src/memory/jobs-store.ts +3 -2
  248. package/src/memory/llm-usage-store.ts +1 -2
  249. package/src/memory/runs-store.ts +1 -2
  250. package/src/memory/schema.ts +65 -2
  251. package/src/messaging/style-analyzer.ts +3 -2
  252. package/src/messaging/thread-summarizer.ts +8 -12
  253. package/src/messaging/triage-engine.ts +4 -2
  254. package/src/providers/openrouter/client.ts +20 -0
  255. package/src/providers/registry.ts +8 -0
  256. package/src/runtime/http-server.ts +277 -25
  257. package/src/runtime/http-types.ts +0 -2
  258. package/src/runtime/routes/attachment-routes.ts +5 -6
  259. package/src/runtime/routes/call-routes.ts +140 -0
  260. package/src/runtime/routes/channel-routes.ts +12 -19
  261. package/src/runtime/routes/conversation-routes.ts +5 -9
  262. package/src/runtime/routes/run-routes.ts +4 -8
  263. package/src/runtime/run-orchestrator.ts +39 -6
  264. package/src/schedule/recurrence-engine.ts +138 -0
  265. package/src/schedule/recurrence-types.ts +67 -0
  266. package/src/schedule/schedule-store.ts +102 -57
  267. package/src/schedule/scheduler.ts +9 -6
  268. package/src/security/oauth2.ts +29 -4
  269. package/src/security/secret-allowlist.ts +46 -0
  270. package/src/skills/clawhub.ts +1 -1
  271. package/src/subagent/manager.ts +40 -8
  272. package/src/swarm/backend-claude-code.ts +64 -9
  273. package/src/swarm/worker-prompts.ts +2 -1
  274. package/src/tasks/SPEC.md +34 -28
  275. package/src/tasks/ephemeral-permissions.ts +16 -7
  276. package/src/tasks/task-compiler.ts +5 -4
  277. package/src/tasks/task-runner.ts +10 -5
  278. package/src/tasks/task-scheduler.ts +1 -1
  279. package/src/tasks/tool-sanitizer.ts +36 -0
  280. package/src/tools/assets/search.ts +4 -4
  281. package/src/tools/browser/api-map.ts +220 -0
  282. package/src/tools/browser/auto-navigate.ts +270 -0
  283. package/src/tools/browser/browser-execution.ts +2 -1
  284. package/src/tools/browser/browser-manager.ts +2 -2
  285. package/src/tools/browser/network-recorder.ts +5 -4
  286. package/src/tools/browser/x-auto-navigate.ts +207 -0
  287. package/src/tools/calls/call-end.ts +67 -0
  288. package/src/tools/calls/call-start.ts +73 -0
  289. package/src/tools/calls/call-status.ts +81 -0
  290. package/src/tools/claude-code/claude-code.ts +77 -11
  291. package/src/tools/contacts/contact-merge.ts +46 -78
  292. package/src/tools/contacts/contact-search.ts +35 -79
  293. package/src/tools/contacts/contact-upsert.ts +35 -108
  294. package/src/tools/credentials/vault.ts +21 -5
  295. package/src/tools/document/document-tool.ts +71 -144
  296. package/src/tools/executor.ts +129 -10
  297. package/src/tools/followups/followup_create.ts +46 -88
  298. package/src/tools/followups/followup_list.ts +34 -74
  299. package/src/tools/followups/followup_resolve.ts +31 -66
  300. package/src/tools/host-terminal/cli-discover.ts +2 -1
  301. package/src/tools/host-terminal/host-shell.ts +10 -0
  302. package/src/tools/memory/handlers.ts +5 -4
  303. package/src/tools/network/__tests__/web-search.test.ts +427 -0
  304. package/src/tools/network/script-proxy/__tests__/logging.test.ts +248 -0
  305. package/src/tools/network/script-proxy/__tests__/policy.test.ts +234 -0
  306. package/src/tools/network/script-proxy/__tests__/router.test.ts +76 -0
  307. package/src/tools/network/web-fetch.ts +18 -6
  308. package/src/tools/playbooks/index.ts +4 -5
  309. package/src/tools/playbooks/playbook-create.ts +3 -47
  310. package/src/tools/playbooks/playbook-delete.ts +1 -25
  311. package/src/tools/playbooks/playbook-list.ts +1 -28
  312. package/src/tools/playbooks/playbook-update.ts +3 -51
  313. package/src/tools/registry.ts +2 -4
  314. package/src/tools/reminder/reminder.ts +5 -78
  315. package/src/tools/schedule/create.ts +69 -74
  316. package/src/tools/schedule/delete.ts +21 -47
  317. package/src/tools/schedule/list.ts +55 -74
  318. package/src/tools/schedule/update.ts +77 -84
  319. package/src/tools/subagent/abort.ts +29 -58
  320. package/src/tools/subagent/message.ts +30 -63
  321. package/src/tools/subagent/read.ts +53 -84
  322. package/src/tools/subagent/spawn.ts +43 -82
  323. package/src/tools/subagent/status.ts +42 -71
  324. package/src/tools/swarm/delegate.ts +2 -1
  325. package/src/tools/tasks/index.ts +8 -6
  326. package/src/tools/tasks/task-delete.ts +69 -56
  327. package/src/tools/tasks/task-list.ts +31 -52
  328. package/src/tools/tasks/task-run.ts +74 -102
  329. package/src/tools/tasks/task-save.ts +33 -65
  330. package/src/tools/tasks/work-item-enqueue.ts +192 -134
  331. package/src/tools/tasks/work-item-list.ts +33 -78
  332. package/src/tools/tasks/work-item-remove.ts +60 -0
  333. package/src/tools/tasks/work-item-update.ts +114 -0
  334. package/src/tools/terminal/backends/native.ts +3 -1
  335. package/src/tools/tool-manifest.ts +20 -74
  336. package/src/tools/types.ts +6 -0
  337. package/src/tools/ui-surface/definitions.ts +6 -1
  338. package/src/tools/watch/screen-watch.ts +3 -1
  339. package/src/tools/watcher/create.ts +52 -98
  340. package/src/tools/watcher/delete.ts +20 -46
  341. package/src/tools/watcher/digest.ts +36 -70
  342. package/src/tools/watcher/list.ts +49 -79
  343. package/src/tools/watcher/update.ts +45 -91
  344. package/src/twitter/client.ts +690 -0
  345. package/src/twitter/session.ts +91 -0
  346. package/src/usage/types.ts +0 -1
  347. package/src/util/truncate.ts +6 -0
  348. package/src/watcher/providers/slack.ts +2 -1
  349. package/src/watcher/watcher-store.ts +3 -2
  350. package/src/work-items/work-item-store.ts +236 -2
  351. package/src/workspace/commit-message-enrichment-service.ts +284 -0
  352. package/src/workspace/commit-message-provider.ts +95 -0
  353. package/src/workspace/git-service.ts +272 -52
  354. package/src/workspace/heartbeat-service.ts +70 -13
  355. package/src/workspace/provider-commit-message-generator.ts +242 -0
  356. package/src/workspace/turn-commit.ts +100 -51
  357. package/src/tools/contacts/index.ts +0 -4
  358. package/src/tools/document/index.ts +0 -5
  359. package/src/tools/followups/index.ts +0 -3
  360. package/src/tools/subagent/index.ts +0 -5
  361. /package/src/__tests__/{memory-context-benchmark.test.ts → memory-context-benchmark.benchmark.test.ts} +0 -0
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Shared domain functions for call operations.
3
+ *
4
+ * Both the tool implementations and the HTTP route handlers delegate
5
+ * to these functions so business logic lives in one place.
6
+ */
7
+
8
+ import { getLogger } from '../util/logger.js';
9
+ import { isDeniedNumber } from './call-constants.js';
10
+ import {
11
+ createCallSession,
12
+ getCallSession,
13
+ getActiveCallSessionForConversation,
14
+ updateCallSession,
15
+ getPendingQuestion,
16
+ answerPendingQuestion,
17
+ expirePendingQuestions,
18
+ } from './call-store.js';
19
+ import { getCallOrchestrator, unregisterCallOrchestrator } from './call-state.js';
20
+ import { activeRelayConnections } from './relay-server.js';
21
+ import { TwilioConversationRelayProvider } from './twilio-provider.js';
22
+ import { getTwilioConfig } from './twilio-config.js';
23
+ import type { CallSession } from './types.js';
24
+
25
+ const log = getLogger('call-domain');
26
+
27
+ const E164_REGEX = /^\+\d+$/;
28
+
29
+ // ── Result types ─────────────────────────────────────────────────────
30
+
31
+ export interface StartCallResult {
32
+ ok: true;
33
+ session: CallSession;
34
+ callSid: string;
35
+ }
36
+
37
+ export interface CallError {
38
+ ok: false;
39
+ error: string;
40
+ status?: number;
41
+ }
42
+
43
+ export type StartCallInput = {
44
+ phoneNumber: string;
45
+ task: string;
46
+ context?: string;
47
+ conversationId: string;
48
+ };
49
+
50
+ export type CancelCallInput = {
51
+ callSessionId: string;
52
+ reason?: string;
53
+ };
54
+
55
+ export type AnswerCallInput = {
56
+ callSessionId: string;
57
+ answer: string;
58
+ };
59
+
60
+ // ── Domain operations ────────────────────────────────────────────────
61
+
62
+ /**
63
+ * Initiate a new outbound call.
64
+ */
65
+ export async function startCall(input: StartCallInput): Promise<StartCallResult | CallError> {
66
+ const { phoneNumber, task, context: callContext, conversationId } = input;
67
+
68
+ if (!phoneNumber || typeof phoneNumber !== 'string') {
69
+ return { ok: false, error: 'phone_number is required and must be a string', status: 400 };
70
+ }
71
+
72
+ if (!E164_REGEX.test(phoneNumber)) {
73
+ return {
74
+ ok: false,
75
+ error: 'phone_number must be in E.164 format (starts with + followed by digits, e.g. +14155551234)',
76
+ status: 400,
77
+ };
78
+ }
79
+
80
+ if (!task || typeof task !== 'string' || task.trim().length === 0) {
81
+ return { ok: false, error: 'task is required and must be a non-empty string', status: 400 };
82
+ }
83
+
84
+ if (isDeniedNumber(phoneNumber)) {
85
+ return { ok: false, error: 'This phone number is not allowed to be called', status: 403 };
86
+ }
87
+
88
+ let sessionId: string | null = null;
89
+
90
+ try {
91
+ const config = getTwilioConfig();
92
+ const provider = new TwilioConversationRelayProvider();
93
+
94
+ const session = createCallSession({
95
+ conversationId,
96
+ provider: 'twilio',
97
+ fromNumber: config.phoneNumber,
98
+ toNumber: phoneNumber,
99
+ task: callContext ? `${task}\n\nContext: ${callContext}` : task,
100
+ });
101
+ sessionId = session.id;
102
+
103
+ log.info({ callSessionId: session.id, to: phoneNumber, task }, 'Initiating outbound call');
104
+
105
+ const baseUrl = config.webhookBaseUrl.replace(/\/$/, '');
106
+ const { callSid } = await provider.initiateCall({
107
+ from: config.phoneNumber,
108
+ to: phoneNumber,
109
+ webhookUrl: `${baseUrl}/webhooks/twilio/voice?callSessionId=${session.id}`,
110
+ statusCallbackUrl: `${baseUrl}/webhooks/twilio/status`,
111
+ });
112
+
113
+ updateCallSession(session.id, { providerCallSid: callSid });
114
+
115
+ log.info({ callSessionId: session.id, callSid }, 'Call initiated successfully');
116
+
117
+ return {
118
+ ok: true,
119
+ session: { ...session, providerCallSid: callSid },
120
+ callSid,
121
+ };
122
+ } catch (err) {
123
+ const msg = err instanceof Error ? err.message : String(err);
124
+ log.error({ err, phoneNumber }, 'Failed to initiate call');
125
+
126
+ // FK constraint failure on conversation_id means the conversationId is invalid
127
+ if (err instanceof Error && msg.includes('FOREIGN KEY constraint failed') && !sessionId) {
128
+ return { ok: false, error: `Invalid conversationId: no conversation found with ID ${conversationId}`, status: 400 };
129
+ }
130
+
131
+ if (sessionId) {
132
+ updateCallSession(sessionId, {
133
+ status: 'failed',
134
+ endedAt: Date.now(),
135
+ lastError: msg,
136
+ });
137
+ }
138
+
139
+ return { ok: false, error: `Error initiating call: ${msg}`, status: 500 };
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Get the status of a call session. If no callSessionId is provided,
145
+ * looks up the active call for the given conversationId.
146
+ */
147
+ export function getCallStatus(
148
+ callSessionId?: string,
149
+ conversationId?: string,
150
+ ): { ok: true; session: CallSession; pendingQuestion?: { id: string; questionText: string } } | CallError {
151
+ let session: CallSession | null = null;
152
+
153
+ if (callSessionId) {
154
+ session = getCallSession(callSessionId);
155
+ if (!session) {
156
+ return { ok: false, error: `No call session found with ID ${callSessionId}`, status: 404 };
157
+ }
158
+ } else if (conversationId) {
159
+ session = getActiveCallSessionForConversation(conversationId);
160
+ if (!session) {
161
+ return { ok: false, error: 'No active call found in the current conversation', status: 404 };
162
+ }
163
+ } else {
164
+ return { ok: false, error: 'Either callSessionId or conversationId is required', status: 400 };
165
+ }
166
+
167
+ log.info({ callSessionId: session.id, status: session.status }, 'Checking call status');
168
+
169
+ const pendingQuestion = getPendingQuestion(session.id);
170
+ return {
171
+ ok: true,
172
+ session,
173
+ pendingQuestion: pendingQuestion
174
+ ? { id: pendingQuestion.id, questionText: pendingQuestion.questionText }
175
+ : undefined,
176
+ };
177
+ }
178
+
179
+ /**
180
+ * Cancel an active call. Cleans up relay connections and orchestrators.
181
+ */
182
+ export async function cancelCall(input: CancelCallInput): Promise<{ ok: true; session: CallSession } | CallError> {
183
+ const { callSessionId, reason } = input;
184
+
185
+ const session = getCallSession(callSessionId);
186
+ if (!session) {
187
+ return { ok: false, error: `No call session found with ID ${callSessionId}`, status: 404 };
188
+ }
189
+
190
+ if (session.status === 'completed' || session.status === 'failed' || session.status === 'cancelled') {
191
+ return { ok: false, error: `Call session ${callSessionId} has already ended with status: ${session.status}`, status: 409 };
192
+ }
193
+
194
+ log.info({ callSessionId, reason }, 'Cancelling call');
195
+
196
+ // Terminate the call via the provider API
197
+ if (session.providerCallSid) {
198
+ try {
199
+ const provider = new TwilioConversationRelayProvider();
200
+ await provider.endCall(session.providerCallSid);
201
+ } catch (endErr) {
202
+ log.warn({ err: endErr, callSessionId, callSid: session.providerCallSid }, 'Failed to terminate call via provider API — proceeding with cleanup');
203
+ }
204
+ }
205
+
206
+ // End the relay connection if active
207
+ const relayConnection = activeRelayConnections.get(callSessionId);
208
+ if (relayConnection) {
209
+ relayConnection.endSession(reason);
210
+ relayConnection.destroy();
211
+ activeRelayConnections.delete(callSessionId);
212
+ }
213
+
214
+ // Clean up orchestrator
215
+ const orchestrator = getCallOrchestrator(callSessionId);
216
+ if (orchestrator) {
217
+ orchestrator.destroy();
218
+ unregisterCallOrchestrator(callSessionId);
219
+ }
220
+
221
+ // Update session status
222
+ updateCallSession(callSessionId, {
223
+ status: 'cancelled',
224
+ endedAt: Date.now(),
225
+ });
226
+
227
+ // Expire any pending questions so they don't linger
228
+ expirePendingQuestions(callSessionId);
229
+
230
+ // Re-check final status: a concurrent transition (e.g. Twilio callback) may have
231
+ // moved the session to a terminal state before our update, causing it to be skipped.
232
+ const updated = getCallSession(callSessionId);
233
+ if (updated && updated.status !== 'cancelled') {
234
+ log.warn({ callSessionId, finalStatus: updated.status }, 'Cancel lost race — session already transitioned to terminal state');
235
+ return { ok: false, error: `Call session ${callSessionId} transitioned to ${updated.status} before cancellation could be applied`, status: 409 };
236
+ }
237
+
238
+ log.info({ callSessionId }, 'Call cancelled successfully');
239
+
240
+ return { ok: true, session: updated ?? { ...session, status: 'cancelled', endedAt: Date.now() } };
241
+ }
242
+
243
+ /**
244
+ * Answer a pending question for an active call.
245
+ */
246
+ export async function answerCall(input: AnswerCallInput): Promise<{ ok: true; questionId: string } | CallError> {
247
+ const { callSessionId, answer } = input;
248
+
249
+ if (!answer || typeof answer !== 'string') {
250
+ return { ok: false, error: 'Missing answer', status: 400 };
251
+ }
252
+
253
+ const question = getPendingQuestion(callSessionId);
254
+ if (!question) {
255
+ return { ok: false, error: 'No pending question found', status: 404 };
256
+ }
257
+
258
+ const orchestrator = getCallOrchestrator(callSessionId);
259
+ if (!orchestrator) {
260
+ log.warn({ callSessionId }, 'answerCall: no active orchestrator for call session');
261
+ return { ok: false, error: 'No active orchestrator for this call', status: 409 };
262
+ }
263
+
264
+ const accepted = await orchestrator.handleUserAnswer(answer);
265
+ if (!accepted) {
266
+ log.warn(
267
+ { callSessionId },
268
+ 'answerCall: orchestrator rejected the answer (not in waiting_on_user state)',
269
+ );
270
+ return { ok: false, error: 'Orchestrator is not waiting for an answer', status: 409 };
271
+ }
272
+
273
+ answerPendingQuestion(question.id, answer);
274
+
275
+ return { ok: true, questionId: question.id };
276
+ }
@@ -0,0 +1,390 @@
1
+ /**
2
+ * LLM-driven call orchestrator.
3
+ *
4
+ * Manages the conversation loop for an active phone call: receives caller
5
+ * utterances, sends them to Claude via the Anthropic streaming API, and
6
+ * streams text tokens back through the RelayConnection for real-time TTS.
7
+ */
8
+
9
+ import Anthropic from '@anthropic-ai/sdk';
10
+ import { getConfig } from '../config/loader.js';
11
+ import { getLogger } from '../util/logger.js';
12
+ import {
13
+ getCallSession,
14
+ updateCallSession,
15
+ recordCallEvent,
16
+ createPendingQuestion,
17
+ expirePendingQuestions,
18
+ } from './call-store.js';
19
+ import { getMaxCallDurationMs, getUserConsultationTimeoutMs, SILENCE_TIMEOUT_MS } from './call-constants.js';
20
+ import type { RelayConnection } from './relay-server.js';
21
+ import { registerCallOrchestrator, unregisterCallOrchestrator, fireCallQuestionNotifier, fireCallCompletionNotifier } from './call-state.js';
22
+ import type { PromptSpeakerContext } from './speaker-identification.js';
23
+
24
+ const log = getLogger('call-orchestrator');
25
+
26
+ type OrchestratorState = 'idle' | 'processing' | 'waiting_on_user' | 'speaking';
27
+
28
+ const ASK_USER_REGEX = /\[ASK_USER:\s*(.+?)\]/;
29
+ const END_CALL_MARKER = '[END_CALL]';
30
+
31
+ export class CallOrchestrator {
32
+ private callSessionId: string;
33
+ private relay: RelayConnection;
34
+ private state: OrchestratorState = 'idle';
35
+ private conversationHistory: Array<{ role: 'user' | 'assistant'; content: string }> = [];
36
+ private abortController: AbortController = new AbortController();
37
+ private callStartTime: number = Date.now();
38
+ private silenceTimer: ReturnType<typeof setTimeout> | null = null;
39
+ private durationTimer: ReturnType<typeof setTimeout> | null = null;
40
+ private durationWarningTimer: ReturnType<typeof setTimeout> | null = null;
41
+ private consultationTimer: ReturnType<typeof setTimeout> | null = null;
42
+ private durationEndTimer: ReturnType<typeof setTimeout> | null = null;
43
+ private task: string | null;
44
+
45
+ constructor(callSessionId: string, relay: RelayConnection, task: string | null) {
46
+ this.callSessionId = callSessionId;
47
+ this.relay = relay;
48
+ this.task = task;
49
+ this.startDurationTimer();
50
+ this.resetSilenceTimer();
51
+ registerCallOrchestrator(callSessionId, this);
52
+ }
53
+
54
+ /**
55
+ * Returns the current orchestrator state.
56
+ */
57
+ getState(): OrchestratorState {
58
+ return this.state;
59
+ }
60
+
61
+ /**
62
+ * Handle a final caller utterance from the ConversationRelay.
63
+ */
64
+ async handleCallerUtterance(transcript: string, speaker?: PromptSpeakerContext): Promise<void> {
65
+ // If we're already processing or speaking, abort the in-flight generation
66
+ if (this.state === 'processing' || this.state === 'speaking') {
67
+ this.abortController.abort();
68
+ this.abortController = new AbortController();
69
+ }
70
+
71
+ this.state = 'processing';
72
+ this.resetSilenceTimer();
73
+
74
+ // Append caller utterance
75
+ this.conversationHistory.push({
76
+ role: 'user',
77
+ content: this.formatCallerUtterance(transcript, speaker),
78
+ });
79
+
80
+ await this.runLlm();
81
+ }
82
+
83
+ /**
84
+ * Called when the user (in the chat UI) answers a pending question.
85
+ */
86
+ async handleUserAnswer(answerText: string): Promise<boolean> {
87
+ if (this.state !== 'waiting_on_user') {
88
+ log.warn(
89
+ { callSessionId: this.callSessionId, state: this.state },
90
+ 'handleUserAnswer called but orchestrator is not in waiting_on_user state',
91
+ );
92
+ return false;
93
+ }
94
+
95
+ // Clear the consultation timeout
96
+ if (this.consultationTimer) {
97
+ clearTimeout(this.consultationTimer);
98
+ this.consultationTimer = null;
99
+ }
100
+
101
+ this.state = 'processing';
102
+ updateCallSession(this.callSessionId, { status: 'in_progress' });
103
+
104
+ // Append the user's answer as a special message the model recognizes
105
+ this.conversationHistory.push({ role: 'user', content: `[USER_ANSWERED: ${answerText}]` });
106
+
107
+ // Fire-and-forget: unblock the caller so the HTTP response and answer
108
+ // persistence happen immediately, before LLM streaming begins.
109
+ this.runLlm().catch((err) =>
110
+ log.error({ err, callSessionId: this.callSessionId }, 'runLlm failed after user answer'),
111
+ );
112
+ return true;
113
+ }
114
+
115
+ /**
116
+ * Handle caller interrupting the assistant's speech.
117
+ */
118
+ handleInterrupt(): void {
119
+ this.abortController.abort();
120
+ this.abortController = new AbortController();
121
+ this.state = 'idle';
122
+ }
123
+
124
+ /**
125
+ * Tear down all timers and abort any in-flight work.
126
+ */
127
+ destroy(): void {
128
+ if (this.silenceTimer) clearTimeout(this.silenceTimer);
129
+ if (this.durationTimer) clearTimeout(this.durationTimer);
130
+ if (this.durationWarningTimer) clearTimeout(this.durationWarningTimer);
131
+ if (this.consultationTimer) clearTimeout(this.consultationTimer);
132
+ if (this.durationEndTimer) { clearTimeout(this.durationEndTimer); this.durationEndTimer = null; }
133
+ this.abortController.abort();
134
+ unregisterCallOrchestrator(this.callSessionId);
135
+ log.info({ callSessionId: this.callSessionId }, 'CallOrchestrator destroyed');
136
+ }
137
+
138
+ // ── Private ──────────────────────────────────────────────────────
139
+
140
+ private buildSystemPrompt(): string {
141
+ const config = getConfig();
142
+ const disclosureRule = config.calls.disclosure.enabled
143
+ ? `1. ${config.calls.disclosure.text}`
144
+ : '1. Begin the conversation naturally.';
145
+
146
+ return [
147
+ 'You are on a live phone call on behalf of your user.',
148
+ this.task ? `Task: ${this.task}` : '',
149
+ '',
150
+ 'You are speaking directly to the person who answered the phone.',
151
+ 'Respond naturally and conversationally — speak as you would in a real phone conversation.',
152
+ '',
153
+ 'IMPORTANT RULES:',
154
+ disclosureRule,
155
+ '2. Be concise — phone conversations should be brief and natural.',
156
+ '3. If the callee asks something you don\'t know, include [ASK_USER: your question here] in your response along with a hold message like "Let me check on that for you."',
157
+ '4. If the callee provides information preceded by [USER_ANSWERED: ...], use that answer naturally in the conversation.',
158
+ '5. When the call\'s purpose is fulfilled, include [END_CALL] in your response along with a polite goodbye.',
159
+ '6. Do not make up information — ask the user if unsure.',
160
+ '7. Keep responses short — 1-3 sentences is ideal for phone conversation.',
161
+ '8. When caller text includes [SPEAKER id="..." label="..."], treat each speaker as a distinct person and personalize responses using that speaker\'s prior context in this call.',
162
+ ]
163
+ .filter(Boolean)
164
+ .join('\n');
165
+ }
166
+
167
+ private formatCallerUtterance(transcript: string, speaker?: PromptSpeakerContext): string {
168
+ if (!speaker) return transcript;
169
+ const safeId = speaker.speakerId.replaceAll('"', '\'');
170
+ const safeLabel = speaker.speakerLabel.replaceAll('"', '\'');
171
+ const confidencePart = speaker.speakerConfidence !== null ? ` confidence="${speaker.speakerConfidence.toFixed(2)}"` : '';
172
+ return `[SPEAKER id="${safeId}" label="${safeLabel}" source="${speaker.source}"${confidencePart}] ${transcript}`;
173
+ }
174
+
175
+ /**
176
+ * Run the LLM with the current conversation history and stream
177
+ * the response back through the relay.
178
+ */
179
+ private async runLlm(): Promise<void> {
180
+ const apiKey = getConfig().apiKeys.anthropic ?? process.env.ANTHROPIC_API_KEY;
181
+ if (!apiKey) {
182
+ log.error({ callSessionId: this.callSessionId }, 'No Anthropic API key available');
183
+ this.relay.sendTextToken('I\'m sorry, I\'m having a technical issue. Please try again later.', true);
184
+ this.state = 'idle';
185
+ return;
186
+ }
187
+
188
+ const client = new Anthropic({ apiKey });
189
+
190
+ try {
191
+ this.state = 'speaking';
192
+
193
+ const stream = client.messages.stream(
194
+ {
195
+ model: 'claude-sonnet-4-20250514',
196
+ max_tokens: 512,
197
+ system: this.buildSystemPrompt(),
198
+ messages: this.conversationHistory.map((m) => ({
199
+ role: m.role,
200
+ content: m.content,
201
+ })),
202
+ },
203
+ { signal: this.abortController.signal },
204
+ );
205
+
206
+ // Buffer incoming tokens so we can strip control markers ([ASK_USER:...], [END_CALL])
207
+ // before they reach TTS. We hold text whenever an unmatched '[' appears, since it
208
+ // could be the start of a control marker.
209
+ let ttsBuffer = '';
210
+
211
+ const flushSafeText = (_force: boolean): void => {
212
+ if (ttsBuffer.length === 0) return;
213
+ const bracketIdx = ttsBuffer.indexOf('[');
214
+ if (bracketIdx === -1) {
215
+ // No bracket at all — safe to flush everything
216
+ this.relay.sendTextToken(ttsBuffer, false);
217
+ ttsBuffer = '';
218
+ } else {
219
+ // Flush everything before the bracket
220
+ if (bracketIdx > 0) {
221
+ this.relay.sendTextToken(ttsBuffer.slice(0, bracketIdx), false);
222
+ ttsBuffer = ttsBuffer.slice(bracketIdx);
223
+ }
224
+
225
+ // Only hold the buffer if the bracket text could be the start of a
226
+ // known control marker. Otherwise flush immediately so ordinary
227
+ // bracketed text (e.g. "[A]", "[note]") doesn't stall TTS.
228
+ //
229
+ // The check must be bidirectional:
230
+ // - When the buffer is shorter than the prefix (e.g. "[ASK"), the
231
+ // buffer is a prefix of the control tag → hold it.
232
+ // - When the buffer is longer than the prefix (e.g. "[ASK_USER: what"),
233
+ // the buffer starts with the control tag prefix → hold it (the
234
+ // variable-length payload hasn't been closed yet).
235
+ const afterBracket = ttsBuffer;
236
+ const couldBeControl =
237
+ '[ASK_USER:'.startsWith(afterBracket) ||
238
+ '[END_CALL]'.startsWith(afterBracket) ||
239
+ afterBracket.startsWith('[ASK_USER:') ||
240
+ afterBracket === '[END_CALL' ||
241
+ afterBracket.startsWith('[END_CALL]');
242
+
243
+ if (!couldBeControl) {
244
+ // Not a control marker prefix — flush up to the next '[' (if any)
245
+ // so we don't accidentally flush a later partial control marker.
246
+ const nextBracket = ttsBuffer.indexOf('[', 1);
247
+ if (nextBracket === -1) {
248
+ this.relay.sendTextToken(ttsBuffer, false);
249
+ ttsBuffer = '';
250
+ } else {
251
+ this.relay.sendTextToken(ttsBuffer.slice(0, nextBracket), false);
252
+ ttsBuffer = ttsBuffer.slice(nextBracket);
253
+ }
254
+ }
255
+ // Otherwise hold it — might be a control marker still being streamed
256
+ }
257
+ };
258
+
259
+ stream.on('text', (text) => {
260
+ ttsBuffer += text;
261
+
262
+ // If the buffer contains a complete control marker, strip it
263
+ if (ASK_USER_REGEX.test(ttsBuffer)) {
264
+ ttsBuffer = ttsBuffer.replace(ASK_USER_REGEX, '');
265
+ }
266
+ if (ttsBuffer.includes(END_CALL_MARKER)) {
267
+ ttsBuffer = ttsBuffer.replace(END_CALL_MARKER, '');
268
+ }
269
+
270
+ flushSafeText(false);
271
+ });
272
+
273
+ const finalMessage = await stream.finalMessage();
274
+
275
+ // Final sweep: strip any remaining control markers from the buffer
276
+ ttsBuffer = ttsBuffer.replace(ASK_USER_REGEX, '').replace(END_CALL_MARKER, '');
277
+ if (ttsBuffer.length > 0) {
278
+ this.relay.sendTextToken(ttsBuffer, false);
279
+ }
280
+
281
+ // Signal end of this turn's speech
282
+ this.relay.sendTextToken('', true);
283
+
284
+ const responseText =
285
+ finalMessage.content
286
+ .filter((b): b is Anthropic.TextBlock => b.type === 'text')
287
+ .map((b) => b.text)
288
+ .join('') || '';
289
+
290
+ // Record the assistant response
291
+ this.conversationHistory.push({ role: 'assistant', content: responseText });
292
+ recordCallEvent(this.callSessionId, 'assistant_spoke', { text: responseText });
293
+
294
+ // Check for ASK_USER pattern
295
+ const askMatch = responseText.match(ASK_USER_REGEX);
296
+ if (askMatch) {
297
+ const questionText = askMatch[1];
298
+ createPendingQuestion(this.callSessionId, questionText);
299
+ this.state = 'waiting_on_user';
300
+ updateCallSession(this.callSessionId, { status: 'waiting_on_user' });
301
+ recordCallEvent(this.callSessionId, 'user_question_asked', { question: questionText });
302
+
303
+ // Notify the conversation that a question was asked
304
+ const session = getCallSession(this.callSessionId);
305
+ if (session) {
306
+ fireCallQuestionNotifier(session.conversationId, this.callSessionId, questionText);
307
+ }
308
+
309
+ // Set a consultation timeout
310
+ this.consultationTimer = setTimeout(() => {
311
+ if (this.state === 'waiting_on_user') {
312
+ log.info({ callSessionId: this.callSessionId }, 'User consultation timed out');
313
+ this.relay.sendTextToken(
314
+ 'I\'m sorry, I wasn\'t able to get that information in time. Let me move on.',
315
+ true,
316
+ );
317
+ this.state = 'idle';
318
+ updateCallSession(this.callSessionId, { status: 'in_progress' });
319
+ expirePendingQuestions(this.callSessionId);
320
+ }
321
+ }, getUserConsultationTimeoutMs());
322
+ return;
323
+ }
324
+
325
+ // Check for END_CALL marker
326
+ if (responseText.includes(END_CALL_MARKER)) {
327
+ this.relay.endSession('Call completed');
328
+ updateCallSession(this.callSessionId, { status: 'completed', endedAt: Date.now() });
329
+ recordCallEvent(this.callSessionId, 'call_ended', { reason: 'completed' });
330
+
331
+ // Notify the conversation that the call completed
332
+ const endSession = getCallSession(this.callSessionId);
333
+ if (endSession) {
334
+ fireCallCompletionNotifier(endSession.conversationId, this.callSessionId);
335
+ }
336
+ this.state = 'idle';
337
+ return;
338
+ }
339
+
340
+ // Normal turn complete
341
+ this.state = 'idle';
342
+ } catch (err: unknown) {
343
+ // Aborted requests are expected (interruptions, rapid utterances)
344
+ if (err instanceof Error && err.name === 'AbortError') {
345
+ log.debug({ callSessionId: this.callSessionId }, 'LLM request aborted');
346
+ return;
347
+ }
348
+ log.error({ err, callSessionId: this.callSessionId }, 'LLM streaming error');
349
+ this.relay.sendTextToken('I\'m sorry, I encountered a technical issue. Could you repeat that?', true);
350
+ this.state = 'idle';
351
+ }
352
+ }
353
+
354
+ private startDurationTimer(): void {
355
+ const maxDurationMs = getMaxCallDurationMs();
356
+ const warningMs = maxDurationMs - 2 * 60 * 1000; // 2 minutes before max
357
+
358
+ if (warningMs > 0) {
359
+ this.durationWarningTimer = setTimeout(() => {
360
+ log.info({ callSessionId: this.callSessionId }, 'Call duration warning');
361
+ this.relay.sendTextToken(
362
+ 'Just to let you know, we\'re running low on time for this call.',
363
+ true,
364
+ );
365
+ }, warningMs);
366
+ }
367
+
368
+ this.durationTimer = setTimeout(() => {
369
+ log.info({ callSessionId: this.callSessionId }, 'Call duration limit reached');
370
+ this.relay.sendTextToken(
371
+ 'I\'m sorry, but we\'ve reached the maximum time for this call. Thank you for your time. Goodbye!',
372
+ true,
373
+ );
374
+ // Give TTS a moment to play, then end
375
+ this.durationEndTimer = setTimeout(() => {
376
+ this.relay.endSession('Maximum call duration reached');
377
+ updateCallSession(this.callSessionId, { status: 'completed', endedAt: Date.now() });
378
+ recordCallEvent(this.callSessionId, 'call_ended', { reason: 'max_duration' });
379
+ }, 3000);
380
+ }, maxDurationMs);
381
+ }
382
+
383
+ private resetSilenceTimer(): void {
384
+ if (this.silenceTimer) clearTimeout(this.silenceTimer);
385
+ this.silenceTimer = setTimeout(() => {
386
+ log.info({ callSessionId: this.callSessionId }, 'Silence timeout triggered');
387
+ this.relay.sendTextToken('Are you still there?', true);
388
+ }, SILENCE_TIMEOUT_MS);
389
+ }
390
+ }