vellum 0.2.1 → 0.2.7

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 +71 -100
  3. package/package.json +5 -3
  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 +133 -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__/asset-materialize-tool.test.ts +16 -15
  11. package/src/__tests__/asset-search-tool.test.ts +23 -22
  12. package/src/__tests__/attachments-store.test.ts +56 -127
  13. package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +5 -4
  14. package/src/__tests__/browser-skill-endstate.test.ts +4 -3
  15. package/src/__tests__/call-bridge.test.ts +385 -0
  16. package/src/__tests__/call-constants.test.ts +40 -0
  17. package/src/__tests__/call-orchestrator.test.ts +130 -4
  18. package/src/__tests__/call-recovery.test.ts +518 -0
  19. package/src/__tests__/call-routes-http.test.ts +459 -0
  20. package/src/__tests__/call-state-machine.test.ts +143 -0
  21. package/src/__tests__/call-store.test.ts +216 -1
  22. package/src/__tests__/cli-discover.test.ts +1 -1
  23. package/src/__tests__/commit-message-enrichment-service.test.ts +148 -7
  24. package/src/__tests__/compaction.benchmark.test.ts +176 -0
  25. package/src/__tests__/computer-use-tools.test.ts +250 -0
  26. package/src/__tests__/config-schema.test.ts +305 -3
  27. package/src/__tests__/conflict-store.test.ts +2 -1
  28. package/src/__tests__/contacts-tools.test.ts +331 -0
  29. package/src/__tests__/conversation-store.test.ts +30 -32
  30. package/src/__tests__/credential-security-invariants.test.ts +4 -0
  31. package/src/__tests__/date-context.test.ts +373 -0
  32. package/src/__tests__/db-schedule-syntax-migration.test.ts +129 -0
  33. package/src/__tests__/fixtures/media-reuse-fixtures.ts +3 -3
  34. package/src/__tests__/followup-tools.test.ts +303 -0
  35. package/src/__tests__/handlers-twilio-config.test.ts +221 -0
  36. package/src/__tests__/handlers-twitter-config.test.ts +718 -0
  37. package/src/__tests__/intent-routing.test.ts +64 -57
  38. package/src/__tests__/ipc-roundtrip.benchmark.test.ts +237 -0
  39. package/src/__tests__/ipc-snapshot.test.ts +71 -28
  40. package/src/__tests__/llm-usage-store.test.ts +3 -8
  41. package/src/__tests__/media-generate-image.test.ts +1 -1
  42. package/src/__tests__/media-reuse-story.e2e.test.ts +7 -7
  43. package/src/__tests__/memory-regressions.test.ts +100 -2
  44. package/src/__tests__/memory-retrieval.benchmark.test.ts +430 -0
  45. package/src/__tests__/parallel-tool.benchmark.test.ts +294 -0
  46. package/src/__tests__/playbook-tools.test.ts +342 -0
  47. package/src/__tests__/profile-compiler.test.ts +2 -1
  48. package/src/__tests__/provider-commit-message-generator.test.ts +303 -0
  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 +5 -3
  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 +8 -4
  58. package/src/__tests__/run-orchestrator.test.ts +4 -4
  59. package/src/__tests__/runtime-attachment-metadata.test.ts +7 -6
  60. package/src/__tests__/runtime-runs-http.test.ts +4 -4
  61. package/src/__tests__/runtime-runs.test.ts +4 -4
  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-conflict-gate.test.ts +28 -25
  67. package/src/__tests__/session-error.test.ts +28 -0
  68. package/src/__tests__/session-init.benchmark.test.ts +462 -0
  69. package/src/__tests__/session-queue.test.ts +71 -48
  70. package/src/__tests__/session-runtime-assembly.test.ts +161 -0
  71. package/src/__tests__/session-surfaces-task-progress.test.ts +104 -0
  72. package/src/__tests__/signup-e2e.test.ts +2 -1
  73. package/src/__tests__/skill-projection.benchmark.test.ts +328 -0
  74. package/src/__tests__/skill-script-runner.test.ts +159 -0
  75. package/src/__tests__/speaker-identification.test.ts +52 -0
  76. package/src/__tests__/subagent-manager-notify.test.ts +42 -10
  77. package/src/__tests__/subagent-tools.test.ts +141 -41
  78. package/src/__tests__/task-compiler.test.ts +2 -1
  79. package/src/__tests__/task-runner.test.ts +2 -1
  80. package/src/__tests__/task-scheduler.test.ts +2 -1
  81. package/src/__tests__/task-tools.test.ts +49 -56
  82. package/src/__tests__/tool-audit-listener.test.ts +1 -0
  83. package/src/__tests__/tool-domain-event-publisher.test.ts +2 -0
  84. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +500 -0
  85. package/src/__tests__/tool-executor.test.ts +13 -17
  86. package/src/__tests__/turn-commit.test.ts +218 -3
  87. package/src/__tests__/twilio-provider.test.ts +143 -0
  88. package/src/__tests__/twilio-routes.test.ts +789 -0
  89. package/src/__tests__/twitter-auth-handler.test.ts +581 -0
  90. package/src/__tests__/view-image-tool.test.ts +217 -0
  91. package/src/__tests__/workspace-git-service.test.ts +186 -0
  92. package/src/__tests__/workspace-heartbeat-service.test.ts +13 -3
  93. package/src/agent-heartbeat/agent-heartbeat-service.ts +155 -0
  94. package/src/bundler/app-bundler.ts +12 -8
  95. package/src/calls/__tests__/twilio-webhook-urls.test.ts +162 -0
  96. package/src/calls/call-bridge.ts +95 -0
  97. package/src/calls/call-constants.ts +43 -5
  98. package/src/calls/call-domain.ts +276 -0
  99. package/src/calls/call-orchestrator.ts +43 -17
  100. package/src/calls/call-recovery.ts +207 -0
  101. package/src/calls/call-state-machine.ts +68 -0
  102. package/src/calls/call-store.ts +192 -5
  103. package/src/calls/relay-server.ts +41 -4
  104. package/src/calls/speaker-identification.ts +213 -0
  105. package/src/calls/twilio-config.ts +8 -8
  106. package/src/calls/twilio-provider.ts +13 -9
  107. package/src/calls/twilio-routes.ts +90 -76
  108. package/src/calls/twilio-webhook-urls.ts +50 -0
  109. package/src/calls/types.ts +1 -1
  110. package/src/cli/config-commands.ts +334 -0
  111. package/src/cli/core-commands.ts +776 -0
  112. package/src/cli/doordash.ts +251 -1
  113. package/src/cli/ipc-client.ts +82 -0
  114. package/src/cli/map.ts +270 -0
  115. package/src/cli/twitter.ts +575 -0
  116. package/src/cli.ts +7 -5
  117. package/src/commands/__tests__/cc-command-registry.test.ts +319 -0
  118. package/src/commands/cc-command-registry.ts +209 -0
  119. package/src/config/bundled-skills/contacts/SKILL.md +39 -0
  120. package/src/config/bundled-skills/contacts/TOOLS.json +122 -0
  121. package/src/config/bundled-skills/contacts/tools/contact-merge.ts +9 -0
  122. package/src/config/bundled-skills/contacts/tools/contact-search.ts +9 -0
  123. package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +9 -0
  124. package/src/config/bundled-skills/document/SKILL.md +18 -0
  125. package/src/config/bundled-skills/document/TOOLS.json +53 -0
  126. package/src/config/bundled-skills/document/tools/document-create.ts +9 -0
  127. package/src/config/bundled-skills/document/tools/document-update.ts +9 -0
  128. package/src/config/bundled-skills/doordash/SKILL.md +82 -23
  129. package/src/config/bundled-skills/followups/SKILL.md +32 -0
  130. package/src/config/bundled-skills/followups/TOOLS.json +100 -0
  131. package/src/config/bundled-skills/followups/tools/followup-create.ts +9 -0
  132. package/src/config/bundled-skills/followups/tools/followup-list.ts +9 -0
  133. package/src/config/bundled-skills/followups/tools/followup-resolve.ts +9 -0
  134. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +1 -23
  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 +34 -0
  179. package/src/config/loader.ts +4 -1
  180. package/src/config/schema.ts +165 -1
  181. package/src/config/system-prompt.ts +61 -16
  182. package/src/config/templates/IDENTITY.md +7 -0
  183. package/src/config/types.ts +4 -0
  184. package/src/config/vellum-skills/telegram-setup/SKILL.md +1 -5
  185. package/src/contacts/contact-store.ts +4 -4
  186. package/src/daemon/assistant-attachments.ts +10 -0
  187. package/src/daemon/classifier.ts +3 -1
  188. package/src/daemon/computer-use-session.ts +3 -1
  189. package/src/daemon/date-context.ts +136 -0
  190. package/src/daemon/handlers/apps.ts +16 -1
  191. package/src/daemon/handlers/browser.ts +54 -0
  192. package/src/daemon/handlers/computer-use.ts +7 -1
  193. package/src/daemon/handlers/config.ts +205 -5
  194. package/src/daemon/handlers/diagnostics.ts +5 -1
  195. package/src/daemon/handlers/documents.ts +18 -29
  196. package/src/daemon/handlers/home-base.ts +5 -1
  197. package/src/daemon/handlers/index.ts +40 -277
  198. package/src/daemon/handlers/misc.ts +9 -1
  199. package/src/daemon/handlers/publish.ts +6 -1
  200. package/src/daemon/handlers/sessions.ts +65 -12
  201. package/src/daemon/handlers/shared.ts +36 -1
  202. package/src/daemon/handlers/signing.ts +37 -0
  203. package/src/daemon/handlers/skills.ts +20 -6
  204. package/src/daemon/handlers/subagents.ts +8 -3
  205. package/src/daemon/handlers/twitter-auth.ts +169 -0
  206. package/src/daemon/handlers/work-items.ts +384 -68
  207. package/src/daemon/ipc-contract-inventory.json +32 -4
  208. package/src/daemon/ipc-contract.ts +156 -37
  209. package/src/daemon/ipc-protocol.ts +7 -2
  210. package/src/daemon/lifecycle.ts +21 -0
  211. package/src/daemon/main.ts +10 -4
  212. package/src/daemon/ride-shotgun-handler.ts +75 -10
  213. package/src/daemon/server.ts +143 -26
  214. package/src/daemon/session-agent-loop.ts +922 -0
  215. package/src/daemon/session-attachments.ts +28 -5
  216. package/src/daemon/session-conflict-gate.ts +18 -109
  217. package/src/daemon/session-error.ts +24 -3
  218. package/src/daemon/session-lifecycle.ts +147 -0
  219. package/src/daemon/session-media-retry.ts +147 -0
  220. package/src/daemon/session-messaging.ts +145 -0
  221. package/src/daemon/session-notifiers.ts +164 -0
  222. package/src/daemon/session-process.ts +2 -2
  223. package/src/daemon/session-queue-manager.ts +1 -0
  224. package/src/daemon/session-runtime-assembly.ts +52 -0
  225. package/src/daemon/session-skill-tools.ts +124 -5
  226. package/src/daemon/session-slash.ts +3 -0
  227. package/src/daemon/session-surfaces.ts +77 -2
  228. package/src/daemon/session-tool-setup.ts +216 -2
  229. package/src/daemon/session-usage.ts +0 -2
  230. package/src/daemon/session.ts +114 -1404
  231. package/src/daemon/video-thumbnail.ts +60 -0
  232. package/src/doordash/client.ts +121 -27
  233. package/src/doordash/queries.ts +1 -2
  234. package/src/export/formatter.ts +3 -1
  235. package/src/followups/followup-store.ts +4 -2
  236. package/src/followups/types.ts +6 -0
  237. package/src/hooks/templates.ts +1 -1
  238. package/src/index.ts +32 -1153
  239. package/src/memory/attachments-store.ts +28 -83
  240. package/src/memory/channel-delivery-store.ts +7 -21
  241. package/src/memory/clarification-resolver.ts +6 -5
  242. package/src/memory/conflict-intent.ts +114 -0
  243. package/src/memory/contradiction-checker.ts +3 -2
  244. package/src/memory/conversation-key-store.ts +10 -29
  245. package/src/memory/conversation-store.ts +2 -1
  246. package/src/memory/db.ts +96 -2
  247. package/src/memory/entity-extractor.ts +6 -3
  248. package/src/memory/items-extractor.ts +5 -4
  249. package/src/memory/job-handlers/conflict.ts +23 -1
  250. package/src/memory/jobs-store.ts +3 -2
  251. package/src/memory/llm-usage-store.ts +1 -2
  252. package/src/memory/runs-store.ts +1 -2
  253. package/src/memory/schema.ts +23 -2
  254. package/src/messaging/style-analyzer.ts +3 -2
  255. package/src/messaging/thread-summarizer.ts +8 -12
  256. package/src/messaging/triage-engine.ts +4 -2
  257. package/src/providers/openrouter/client.ts +20 -0
  258. package/src/providers/registry.ts +8 -0
  259. package/src/runtime/gateway-client.ts +36 -0
  260. package/src/runtime/http-server.ts +166 -22
  261. package/src/runtime/routes/attachment-routes.ts +2 -3
  262. package/src/runtime/routes/call-routes.ts +140 -0
  263. package/src/runtime/routes/channel-routes.ts +125 -88
  264. package/src/runtime/routes/conversation-routes.ts +5 -5
  265. package/src/runtime/routes/run-routes.ts +2 -2
  266. package/src/runtime/run-orchestrator.ts +9 -3
  267. package/src/schedule/recurrence-engine.ts +138 -0
  268. package/src/schedule/recurrence-types.ts +67 -0
  269. package/src/schedule/schedule-store.ts +102 -57
  270. package/src/schedule/scheduler.ts +9 -6
  271. package/src/security/oauth2.ts +29 -4
  272. package/src/security/secret-allowlist.ts +46 -0
  273. package/src/skills/clawhub.ts +1 -1
  274. package/src/subagent/manager.ts +40 -8
  275. package/src/swarm/backend-claude-code.ts +64 -9
  276. package/src/swarm/worker-prompts.ts +2 -1
  277. package/src/tasks/SPEC.md +34 -28
  278. package/src/tasks/ephemeral-permissions.ts +16 -7
  279. package/src/tasks/task-compiler.ts +5 -4
  280. package/src/tasks/task-runner.ts +10 -5
  281. package/src/tasks/task-scheduler.ts +1 -1
  282. package/src/tasks/tool-sanitizer.ts +36 -0
  283. package/src/tools/assets/search.ts +4 -4
  284. package/src/tools/browser/api-map.ts +293 -0
  285. package/src/tools/browser/auto-navigate.ts +270 -0
  286. package/src/tools/browser/browser-execution.ts +2 -1
  287. package/src/tools/browser/browser-manager.ts +2 -2
  288. package/src/tools/browser/network-recorder.ts +5 -4
  289. package/src/tools/browser/x-auto-navigate.ts +207 -0
  290. package/src/tools/calls/call-end.ts +17 -67
  291. package/src/tools/calls/call-start.ts +24 -85
  292. package/src/tools/calls/call-status.ts +35 -51
  293. package/src/tools/claude-code/claude-code.ts +207 -11
  294. package/src/tools/contacts/contact-merge.ts +46 -78
  295. package/src/tools/contacts/contact-search.ts +35 -79
  296. package/src/tools/contacts/contact-upsert.ts +35 -108
  297. package/src/tools/credentials/vault.ts +20 -4
  298. package/src/tools/document/document-tool.ts +71 -144
  299. package/src/tools/executor.ts +129 -10
  300. package/src/tools/followups/followup_create.ts +46 -88
  301. package/src/tools/followups/followup_list.ts +34 -74
  302. package/src/tools/followups/followup_resolve.ts +31 -66
  303. package/src/tools/host-terminal/cli-discover.ts +2 -1
  304. package/src/tools/host-terminal/host-shell.ts +10 -0
  305. package/src/tools/memory/handlers.ts +5 -4
  306. package/src/tools/network/__tests__/web-search.test.ts +427 -0
  307. package/src/tools/network/script-proxy/__tests__/logging.test.ts +248 -0
  308. package/src/tools/network/script-proxy/__tests__/policy.test.ts +234 -0
  309. package/src/tools/network/script-proxy/__tests__/router.test.ts +76 -0
  310. package/src/tools/network/web-fetch.ts +18 -6
  311. package/src/tools/playbooks/index.ts +4 -5
  312. package/src/tools/playbooks/playbook-create.ts +3 -47
  313. package/src/tools/playbooks/playbook-delete.ts +1 -25
  314. package/src/tools/playbooks/playbook-list.ts +1 -28
  315. package/src/tools/playbooks/playbook-update.ts +3 -51
  316. package/src/tools/reminder/reminder.ts +5 -78
  317. package/src/tools/schedule/create.ts +69 -74
  318. package/src/tools/schedule/delete.ts +21 -47
  319. package/src/tools/schedule/list.ts +55 -74
  320. package/src/tools/schedule/update.ts +77 -84
  321. package/src/tools/subagent/abort.ts +29 -58
  322. package/src/tools/subagent/message.ts +30 -63
  323. package/src/tools/subagent/read.ts +53 -84
  324. package/src/tools/subagent/spawn.ts +43 -82
  325. package/src/tools/subagent/status.ts +42 -71
  326. package/src/tools/swarm/delegate.ts +2 -1
  327. package/src/tools/tasks/index.ts +8 -8
  328. package/src/tools/tasks/task-delete.ts +60 -88
  329. package/src/tools/tasks/task-list.ts +31 -52
  330. package/src/tools/tasks/task-run.ts +72 -108
  331. package/src/tools/tasks/task-save.ts +33 -65
  332. package/src/tools/tasks/work-item-enqueue.ts +183 -215
  333. package/src/tools/tasks/work-item-list.ts +33 -63
  334. package/src/tools/tasks/work-item-remove.ts +45 -97
  335. package/src/tools/tasks/work-item-update.ts +91 -163
  336. package/src/tools/terminal/backends/native.ts +3 -1
  337. package/src/tools/tool-manifest.ts +0 -62
  338. package/src/tools/types.ts +6 -0
  339. package/src/tools/ui-surface/definitions.ts +3 -1
  340. package/src/tools/watch/screen-watch.ts +3 -1
  341. package/src/tools/watcher/create.ts +52 -98
  342. package/src/tools/watcher/delete.ts +20 -46
  343. package/src/tools/watcher/digest.ts +36 -70
  344. package/src/tools/watcher/list.ts +49 -79
  345. package/src/tools/watcher/update.ts +45 -91
  346. package/src/twitter/client.ts +690 -0
  347. package/src/twitter/session.ts +91 -0
  348. package/src/usage/types.ts +0 -1
  349. package/src/util/truncate.ts +6 -0
  350. package/src/watcher/providers/slack.ts +2 -1
  351. package/src/watcher/watcher-store.ts +3 -2
  352. package/src/work-items/work-item-store.ts +27 -2
  353. package/src/workspace/commit-message-enrichment-service.ts +31 -7
  354. package/src/workspace/git-service.ts +87 -22
  355. package/src/workspace/provider-commit-message-generator.ts +269 -0
  356. package/src/workspace/turn-commit.ts +62 -3
  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
@@ -14,6 +14,11 @@ import {
14
14
  recordCallEvent,
15
15
  } from './call-store.js';
16
16
  import { CallOrchestrator } from './call-orchestrator.js';
17
+ import {
18
+ extractPromptSpeakerMetadata,
19
+ SpeakerIdentityTracker,
20
+ type PromptSpeakerContext,
21
+ } from './speaker-identification.js';
17
22
 
18
23
  const log = getLogger('relay-server');
19
24
 
@@ -33,6 +38,23 @@ export interface RelayPromptMessage {
33
38
  voicePrompt: string;
34
39
  lang: string;
35
40
  last: boolean;
41
+ speakerId?: string;
42
+ speakerLabel?: string;
43
+ speakerName?: string;
44
+ speakerConfidence?: number;
45
+ participantId?: string;
46
+ participant?: {
47
+ id?: string;
48
+ name?: string;
49
+ };
50
+ speaker?: {
51
+ id?: string;
52
+ label?: string;
53
+ name?: string;
54
+ confidence?: number;
55
+ };
56
+ metadata?: Record<string, unknown>;
57
+ providerMetadata?: Record<string, unknown>;
36
58
  }
37
59
 
38
60
  export interface RelayInterruptMessage {
@@ -88,15 +110,22 @@ export const activeRelayConnections = new Map<string, RelayConnection>();
88
110
  export class RelayConnection {
89
111
  private ws: ServerWebSocket<RelayWebSocketData>;
90
112
  private callSessionId: string;
91
- private conversationHistory: Array<{ role: 'caller' | 'assistant'; text: string; timestamp: number }>;
113
+ private conversationHistory: Array<{
114
+ role: 'caller' | 'assistant';
115
+ text: string;
116
+ timestamp: number;
117
+ speaker?: PromptSpeakerContext;
118
+ }>;
92
119
  private abortController: AbortController;
93
120
  private orchestrator: CallOrchestrator | null = null;
121
+ private speakerIdentityTracker: SpeakerIdentityTracker;
94
122
 
95
123
  constructor(ws: ServerWebSocket<RelayWebSocketData>, callSessionId: string) {
96
124
  this.ws = ws;
97
125
  this.callSessionId = callSessionId;
98
126
  this.conversationHistory = [];
99
127
  this.abortController = new AbortController();
128
+ this.speakerIdentityTracker = new SpeakerIdentityTracker();
100
129
  }
101
130
 
102
131
  /**
@@ -162,8 +191,8 @@ export class RelayConnection {
162
191
  /**
163
192
  * Get the conversation history for context.
164
193
  */
165
- getConversationHistory(): Array<{ role: string; text: string }> {
166
- return this.conversationHistory.map(({ role, text }) => ({ role, text }));
194
+ getConversationHistory(): Array<{ role: string; text: string; speaker?: PromptSpeakerContext }> {
195
+ return this.conversationHistory.map(({ role, text, speaker }) => ({ role, text, speaker }));
167
196
  }
168
197
 
169
198
  /**
@@ -236,22 +265,30 @@ export class RelayConnection {
236
265
  'Caller transcript received (final)',
237
266
  );
238
267
 
268
+ const speakerMetadata = extractPromptSpeakerMetadata(msg as unknown as Record<string, unknown>);
269
+ const speaker = this.speakerIdentityTracker.identifySpeaker(speakerMetadata);
270
+
239
271
  // Record in conversation history
240
272
  this.conversationHistory.push({
241
273
  role: 'caller',
242
274
  text: msg.voicePrompt,
243
275
  timestamp: Date.now(),
276
+ speaker,
244
277
  });
245
278
 
246
279
  // Record event
247
280
  recordCallEvent(this.callSessionId, 'caller_spoke', {
248
281
  transcript: msg.voicePrompt,
249
282
  lang: msg.lang,
283
+ speakerId: speaker.speakerId,
284
+ speakerLabel: speaker.speakerLabel,
285
+ speakerConfidence: speaker.speakerConfidence,
286
+ speakerSource: speaker.source,
250
287
  });
251
288
 
252
289
  // Route to orchestrator for LLM-driven response
253
290
  if (this.orchestrator) {
254
- await this.orchestrator.handleCallerUtterance(msg.voicePrompt);
291
+ await this.orchestrator.handleCallerUtterance(msg.voicePrompt, speaker);
255
292
  } else {
256
293
  // Fallback if orchestrator not yet initialized
257
294
  this.sendTextToken('I\'m still setting up. Please hold.', true);
@@ -0,0 +1,213 @@
1
+ export interface PromptSpeakerContext {
2
+ speakerId: string;
3
+ speakerLabel: string;
4
+ speakerConfidence: number | null;
5
+ source: 'provider' | 'inferred';
6
+ }
7
+
8
+ export interface PromptSpeakerMetadata {
9
+ speakerId?: string;
10
+ speakerLabel?: string;
11
+ speakerName?: string;
12
+ speakerConfidence?: number;
13
+ participantId?: string;
14
+ }
15
+
16
+ interface SpeakerProfile {
17
+ speakerId: string;
18
+ speakerLabel: string;
19
+ speakerConfidence: number | null;
20
+ source: 'provider' | 'inferred';
21
+ utteranceCount: number;
22
+ firstSeenAt: number;
23
+ lastSeenAt: number;
24
+ }
25
+
26
+ function toCleanString(value: unknown): string | null {
27
+ if (typeof value !== 'string') return null;
28
+ const trimmed = value.trim();
29
+ return trimmed.length > 0 ? trimmed : null;
30
+ }
31
+
32
+ function toNumber(value: unknown): number | null {
33
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
34
+ if (typeof value === 'string') {
35
+ const parsed = Number(value);
36
+ if (Number.isFinite(parsed)) return parsed;
37
+ }
38
+ return null;
39
+ }
40
+
41
+ function getObject(value: unknown): Record<string, unknown> | null {
42
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
43
+ return value as Record<string, unknown>;
44
+ }
45
+
46
+ function normalizeSpeakerLabel(metadata: PromptSpeakerMetadata, fallbackIndex: number): string {
47
+ const preferredLabel = toCleanString(metadata.speakerName) ?? toCleanString(metadata.speakerLabel);
48
+ if (preferredLabel) return preferredLabel;
49
+ return `Speaker ${fallbackIndex}`;
50
+ }
51
+
52
+ export function extractPromptSpeakerMetadata(message: Record<string, unknown>): PromptSpeakerMetadata {
53
+ const providerMetadata = getObject(message.providerMetadata);
54
+ const metadata = getObject(message.metadata);
55
+ const participant = getObject(message.participant);
56
+ const speaker = getObject(message.speaker);
57
+
58
+ const pick = (...values: unknown[]): string | undefined => {
59
+ for (const value of values) {
60
+ const cleaned = toCleanString(value);
61
+ if (cleaned) return cleaned;
62
+ }
63
+ return undefined;
64
+ };
65
+
66
+ const pickNumber = (...values: unknown[]): number | undefined => {
67
+ for (const value of values) {
68
+ const parsed = toNumber(value);
69
+ if (parsed !== null) return parsed;
70
+ }
71
+ return undefined;
72
+ };
73
+
74
+ return {
75
+ speakerId: pick(
76
+ message.speakerId,
77
+ message.speaker_id,
78
+ speaker?.id,
79
+ speaker?.speakerId,
80
+ metadata?.speakerId,
81
+ providerMetadata?.speakerId,
82
+ metadata?.speaker_id,
83
+ providerMetadata?.speaker_id,
84
+ ),
85
+ speakerLabel: pick(
86
+ message.speakerLabel,
87
+ message.speaker_label,
88
+ message.speaker,
89
+ speaker?.label,
90
+ speaker?.name,
91
+ metadata?.speakerLabel,
92
+ providerMetadata?.speakerLabel,
93
+ metadata?.speaker_label,
94
+ providerMetadata?.speaker_label,
95
+ ),
96
+ speakerName: pick(
97
+ message.speakerName,
98
+ message.speaker_name,
99
+ participant?.name,
100
+ metadata?.speakerName,
101
+ providerMetadata?.speakerName,
102
+ metadata?.speaker_name,
103
+ providerMetadata?.speaker_name,
104
+ ),
105
+ speakerConfidence: pickNumber(
106
+ message.speakerConfidence,
107
+ message.speaker_confidence,
108
+ message.confidence,
109
+ speaker?.confidence,
110
+ metadata?.speakerConfidence,
111
+ providerMetadata?.speakerConfidence,
112
+ metadata?.speaker_confidence,
113
+ providerMetadata?.speaker_confidence,
114
+ ),
115
+ participantId: pick(
116
+ message.participantId,
117
+ message.participant_id,
118
+ participant?.id,
119
+ metadata?.participantId,
120
+ providerMetadata?.participantId,
121
+ metadata?.participant_id,
122
+ providerMetadata?.participant_id,
123
+ ),
124
+ };
125
+ }
126
+
127
+ export class SpeakerIdentityTracker {
128
+ private profiles = new Map<string, SpeakerProfile>();
129
+ private nextInferredIndex = 1;
130
+
131
+ identifySpeaker(metadata: PromptSpeakerMetadata): PromptSpeakerContext {
132
+ const providerSpeakerId =
133
+ toCleanString(metadata.speakerId)
134
+ ?? toCleanString(metadata.participantId)
135
+ ?? null;
136
+
137
+ if (providerSpeakerId) {
138
+ const existing = this.profiles.get(providerSpeakerId);
139
+ if (existing) {
140
+ existing.lastSeenAt = Date.now();
141
+ existing.utteranceCount += 1;
142
+ if (metadata.speakerConfidence !== undefined) {
143
+ existing.speakerConfidence = metadata.speakerConfidence;
144
+ }
145
+ return {
146
+ speakerId: existing.speakerId,
147
+ speakerLabel: existing.speakerLabel,
148
+ speakerConfidence: existing.speakerConfidence,
149
+ source: existing.source,
150
+ };
151
+ }
152
+
153
+ const profile: SpeakerProfile = {
154
+ speakerId: providerSpeakerId,
155
+ speakerLabel: normalizeSpeakerLabel(metadata, this.nextInferredIndex),
156
+ speakerConfidence: metadata.speakerConfidence ?? null,
157
+ source: 'provider',
158
+ utteranceCount: 1,
159
+ firstSeenAt: Date.now(),
160
+ lastSeenAt: Date.now(),
161
+ };
162
+ this.profiles.set(providerSpeakerId, profile);
163
+ this.nextInferredIndex += 1;
164
+ return {
165
+ speakerId: profile.speakerId,
166
+ speakerLabel: profile.speakerLabel,
167
+ speakerConfidence: profile.speakerConfidence,
168
+ source: profile.source,
169
+ };
170
+ }
171
+
172
+ const inferredSpeakerId = 'primary-speaker';
173
+ const existingPrimary = this.profiles.get(inferredSpeakerId);
174
+ if (existingPrimary) {
175
+ existingPrimary.lastSeenAt = Date.now();
176
+ existingPrimary.utteranceCount += 1;
177
+ return {
178
+ speakerId: existingPrimary.speakerId,
179
+ speakerLabel: existingPrimary.speakerLabel,
180
+ speakerConfidence: existingPrimary.speakerConfidence,
181
+ source: existingPrimary.source,
182
+ };
183
+ }
184
+
185
+ const inferredProfile: SpeakerProfile = {
186
+ speakerId: inferredSpeakerId,
187
+ speakerLabel: normalizeSpeakerLabel(metadata, this.nextInferredIndex),
188
+ speakerConfidence: metadata.speakerConfidence ?? null,
189
+ source: 'inferred',
190
+ utteranceCount: 1,
191
+ firstSeenAt: Date.now(),
192
+ lastSeenAt: Date.now(),
193
+ };
194
+ this.profiles.set(inferredSpeakerId, inferredProfile);
195
+ this.nextInferredIndex += 1;
196
+
197
+ return {
198
+ speakerId: inferredProfile.speakerId,
199
+ speakerLabel: inferredProfile.speakerLabel,
200
+ speakerConfidence: inferredProfile.speakerConfidence,
201
+ source: inferredProfile.source,
202
+ };
203
+ }
204
+
205
+ listProfiles(): PromptSpeakerContext[] {
206
+ return [...this.profiles.values()].map((profile) => ({
207
+ speakerId: profile.speakerId,
208
+ speakerLabel: profile.speakerLabel,
209
+ speakerConfidence: profile.speakerConfidence,
210
+ source: profile.source,
211
+ }));
212
+ }
213
+ }
@@ -1,5 +1,7 @@
1
1
  import { getSecureKey } from '../security/secure-keys.js';
2
2
  import { getLogger } from '../util/logger.js';
3
+ import { loadConfig } from '../config/loader.js';
4
+ import { getWebhookBaseUrl } from './twilio-webhook-urls.js';
3
5
 
4
6
  const log = getLogger('twilio-config');
5
7
 
@@ -12,21 +14,19 @@ export interface TwilioConfig {
12
14
  }
13
15
 
14
16
  export function getTwilioConfig(): TwilioConfig {
15
- const accountSid = getSecureKey('twilio_account_sid');
16
- const authToken = getSecureKey('twilio_auth_token');
17
- const phoneNumber = process.env.TWILIO_PHONE_NUMBER || getSecureKey('twilio_phone_number') || '';
18
- const webhookBaseUrl = process.env.TWILIO_WEBHOOK_BASE_URL || '';
17
+ const accountSid = getSecureKey('credential:twilio:account_sid');
18
+ const authToken = getSecureKey('credential:twilio:auth_token');
19
+ const phoneNumber = process.env.TWILIO_PHONE_NUMBER || getSecureKey('credential:twilio:phone_number') || '';
20
+ const config = loadConfig();
21
+ const webhookBaseUrl = getWebhookBaseUrl(config);
19
22
  const wssBaseUrl = process.env.TWILIO_WSS_BASE_URL || '';
20
23
 
21
24
  if (!accountSid || !authToken) {
22
- throw new Error('Twilio credentials not configured. Set twilio_account_sid and twilio_auth_token via the credential_store tool.');
25
+ throw new Error('Twilio credentials not configured. Set credential:twilio:account_sid and credential:twilio:auth_token via the credential_store tool.');
23
26
  }
24
27
  if (!phoneNumber) {
25
28
  throw new Error('TWILIO_PHONE_NUMBER not configured.');
26
29
  }
27
- if (!webhookBaseUrl) {
28
- throw new Error('TWILIO_WEBHOOK_BASE_URL not configured.');
29
- }
30
30
 
31
31
  log.debug('Twilio config loaded successfully');
32
32
 
@@ -17,11 +17,11 @@ export class TwilioConversationRelayProvider implements VoiceProvider {
17
17
  // ── Credential helpers ──────────────────────────────────────────────
18
18
 
19
19
  private getCredentials(): { accountSid: string; authToken: string } {
20
- const accountSid = getSecureKey('twilio_account_sid');
21
- const authToken = getSecureKey('twilio_auth_token');
20
+ const accountSid = getSecureKey('credential:twilio:account_sid');
21
+ const authToken = getSecureKey('credential:twilio:auth_token');
22
22
  if (!accountSid || !authToken) {
23
23
  throw new Error(
24
- 'Twilio credentials not configured. Set twilio_account_sid and twilio_auth_token via the credential_store tool.',
24
+ 'Twilio credentials not configured. Set credential:twilio:account_sid and credential:twilio:auth_token via the credential_store tool.',
25
25
  );
26
26
  }
27
27
  return { accountSid, authToken };
@@ -128,6 +128,15 @@ export class TwilioConversationRelayProvider implements VoiceProvider {
128
128
 
129
129
  // ── Webhook signature verification ──────────────────────────────────
130
130
 
131
+ /**
132
+ * Returns the Twilio auth token from the secure key store, or null if
133
+ * not configured. Exposed as a static method so callers (e.g. the
134
+ * HTTP server webhook middleware) can check availability independently.
135
+ */
136
+ static getAuthToken(): string | null {
137
+ return getSecureKey('credential:twilio:auth_token') ?? null;
138
+ }
139
+
131
140
  /**
132
141
  * Validates an X-Twilio-Signature header using HMAC-SHA1.
133
142
  *
@@ -143,13 +152,8 @@ export class TwilioConversationRelayProvider implements VoiceProvider {
143
152
  url: string,
144
153
  params: Record<string, string>,
145
154
  signature: string,
155
+ authToken: string,
146
156
  ): boolean {
147
- const authToken = getSecureKey('twilio_auth_token');
148
- if (!authToken) {
149
- log.error('Cannot verify Twilio webhook signature: auth token not configured');
150
- return false;
151
- }
152
-
153
157
  const sortedKeys = Object.keys(params).sort();
154
158
  let data = url;
155
159
  for (const key of sortedKeys) {
@@ -13,11 +13,15 @@ import {
13
13
  updateCallSession,
14
14
  recordCallEvent,
15
15
  expirePendingQuestions,
16
- getPendingQuestion,
17
- answerPendingQuestion,
16
+ buildCallbackDedupeKey,
17
+ claimCallback,
18
+ releaseCallbackClaim,
19
+ finalizeCallbackClaim,
18
20
  } from './call-store.js';
19
21
  import type { CallStatus } from './types.js';
20
- import { getCallOrchestrator } from './call-state.js';
22
+ import { logDeadLetterEvent } from './call-recovery.js';
23
+ import { isTerminalState } from './call-state-machine.js';
24
+ import { getTwilioConfig } from './twilio-config.js';
21
25
 
22
26
  const log = getLogger('twilio-routes');
23
27
 
@@ -32,12 +36,12 @@ function escapeXml(str: string): string {
32
36
  .replace(/'/g, '&apos;');
33
37
  }
34
38
 
35
- function generateTwiML(callSessionId: string, wssBaseUrl: string, welcomeGreeting: string): string {
39
+ function generateTwiML(callSessionId: string, relayUrl: string, welcomeGreeting: string): string {
36
40
  return `<?xml version="1.0" encoding="UTF-8"?>
37
41
  <Response>
38
42
  <Connect>
39
43
  <ConversationRelay
40
- url="${escapeXml(wssBaseUrl)}/v1/calls/relay?callSessionId=${escapeXml(callSessionId)}"
44
+ url="${escapeXml(relayUrl)}?callSessionId=${escapeXml(callSessionId)}"
41
45
  welcomeGreeting="${escapeXml(welcomeGreeting)}"
42
46
  voice="Google.en-US-Journey-O"
43
47
  language="en-US"
@@ -50,6 +54,19 @@ function generateTwiML(callSessionId: string, wssBaseUrl: string, welcomeGreetin
50
54
  </Response>`;
51
55
  }
52
56
 
57
+ /**
58
+ * Resolve the WebSocket relay URL from Twilio config.
59
+ *
60
+ * Treats wssBaseUrl as present only when it is non-empty after trimming.
61
+ * Falls back to webhookBaseUrl, normalizing the scheme from http(s) to ws(s)
62
+ * and stripping any trailing slash.
63
+ */
64
+ export function resolveRelayUrl(wssBaseUrl: string, webhookBaseUrl: string): string {
65
+ const base = wssBaseUrl.trim() || webhookBaseUrl;
66
+ const normalized = base.replace(/\/$/, '').replace(/^http(s?)/, 'ws$1');
67
+ return `${normalized}/v1/calls/relay`;
68
+ }
69
+
53
70
  /**
54
71
  * Map Twilio call status strings to our internal CallStatus.
55
72
  */
@@ -93,6 +110,11 @@ export async function handleVoiceWebhook(req: Request): Promise<Response> {
93
110
  return new Response('Call session not found', { status: 404 });
94
111
  }
95
112
 
113
+ if (isTerminalState(session.status)) {
114
+ log.warn({ callSessionId, status: session.status }, 'Voice webhook: call session is in terminal state');
115
+ return new Response('Call session is no longer active', { status: 410 });
116
+ }
117
+
96
118
  // Parse the Twilio POST body to capture CallSid immediately, so status
97
119
  // callbacks (keyed by CallSid) can locate this session even if the
98
120
  // WebSocket relay hasn't been set up yet.
@@ -103,10 +125,11 @@ export async function handleVoiceWebhook(req: Request): Promise<Response> {
103
125
  log.info({ callSessionId, callSid }, 'Stored CallSid from voice webhook');
104
126
  }
105
127
 
106
- const wssBaseUrl = process.env.WSS_BASE_URL ?? process.env.BASE_URL ?? 'wss://localhost:7821';
128
+ const config = getTwilioConfig();
129
+ const relayUrl = resolveRelayUrl(config.wssBaseUrl, config.webhookBaseUrl);
107
130
  const welcomeGreeting = process.env.CALL_WELCOME_GREETING ?? 'Hello, how can I help you today?';
108
131
 
109
- const twiml = generateTwiML(callSessionId, wssBaseUrl, welcomeGreeting);
132
+ const twiml = generateTwiML(callSessionId, relayUrl, welcomeGreeting);
110
133
 
111
134
  log.info({ callSessionId }, 'Returning ConversationRelay TwiML');
112
135
 
@@ -126,7 +149,8 @@ export async function handleStatusCallback(req: Request): Promise<Response> {
126
149
  const callStatus = formBody.get('CallStatus');
127
150
 
128
151
  if (!callSid || !callStatus) {
129
- log.warn({ callSid, callStatus }, 'Status callback missing CallSid or CallStatus');
152
+ const rawPayload = Object.fromEntries(formBody.entries());
153
+ logDeadLetterEvent('Status callback missing CallSid or CallStatus', rawPayload, log);
130
154
  return new Response(null, { status: 200 });
131
155
  }
132
156
 
@@ -140,39 +164,70 @@ export async function handleStatusCallback(req: Request): Promise<Response> {
140
164
 
141
165
  const mappedStatus = mapTwilioStatus(callStatus);
142
166
  if (!mappedStatus) {
143
- log.warn({ callSid, callStatus }, 'Status callback: unknown Twilio status');
167
+ const rawPayload = Object.fromEntries(formBody.entries());
168
+ logDeadLetterEvent(`Unknown Twilio status: ${callStatus}`, rawPayload, log);
144
169
  return new Response(null, { status: 200 });
145
170
  }
146
171
 
147
- // Build updates
148
- const updates: Parameters<typeof updateCallSession>[1] = {
149
- status: mappedStatus,
150
- };
172
+ // ── Atomic idempotency claim ────────────────────────────────────
173
+ const timestamp = formBody.get('Timestamp');
174
+ const sequenceNumber = formBody.get('SequenceNumber');
175
+ const dedupeKey = buildCallbackDedupeKey(callSid, callStatus, timestamp, sequenceNumber);
151
176
 
152
- if (mappedStatus === 'in_progress' && !session.startedAt) {
153
- updates.startedAt = Date.now();
154
- }
155
-
156
- const isTerminal = mappedStatus === 'completed' || mappedStatus === 'failed';
157
- if (isTerminal) {
158
- updates.endedAt = Date.now();
177
+ const claimId = claimCallback(dedupeKey, session.id);
178
+ if (!claimId) {
179
+ log.info({ callSid, callStatus, dedupeKey }, 'Duplicate status callback — skipping');
180
+ return new Response(null, { status: 200 });
159
181
  }
160
182
 
161
- updateCallSession(session.id, updates);
162
-
163
- // Record event
164
- const eventType = isTerminal
165
- ? (mappedStatus === 'completed' ? 'call_ended' : 'call_failed')
166
- : (mappedStatus === 'in_progress' ? 'call_connected' : 'call_started');
167
-
168
- recordCallEvent(session.id, eventType, {
169
- twilioStatus: callStatus,
170
- callSid,
171
- });
172
-
173
- // Expire pending questions on terminal status
174
- if (isTerminal) {
175
- expirePendingQuestions(session.id);
183
+ try {
184
+ // Build updates
185
+ const updates: Parameters<typeof updateCallSession>[1] = {
186
+ status: mappedStatus,
187
+ };
188
+
189
+ if (mappedStatus === 'in_progress' && !session.startedAt) {
190
+ updates.startedAt = Date.now();
191
+ }
192
+
193
+ const isTerminal = mappedStatus === 'completed' || mappedStatus === 'failed';
194
+ if (isTerminal) {
195
+ updates.endedAt = Date.now();
196
+ }
197
+
198
+ updateCallSession(session.id, updates);
199
+
200
+ // Record event
201
+ const eventType = isTerminal
202
+ ? (mappedStatus === 'completed' ? 'call_ended' : 'call_failed')
203
+ : (mappedStatus === 'in_progress' ? 'call_connected' : 'call_started');
204
+
205
+ recordCallEvent(session.id, eventType, {
206
+ twilioStatus: callStatus,
207
+ callSid,
208
+ });
209
+
210
+ // Expire pending questions on terminal status
211
+ if (isTerminal) {
212
+ expirePendingQuestions(session.id);
213
+ }
214
+
215
+ // Mark the claim as permanently processed so it never expires.
216
+ // If finalization returns false, another handler reclaimed this key
217
+ // after our claim expired — our business writes already landed but
218
+ // the dedupe row now belongs to the other handler, risking duplicate
219
+ // processing on later retries.
220
+ const finalized = finalizeCallbackClaim(dedupeKey, claimId);
221
+ if (!finalized) {
222
+ log.warn(
223
+ { dedupeKey, claimId, callSid, callStatus },
224
+ 'Lost claim during finalization — business writes committed but dedupe ownership was taken by another handler',
225
+ );
226
+ }
227
+ } catch (err) {
228
+ // Release claim so Twilio retries can reprocess
229
+ releaseCallbackClaim(dedupeKey, claimId);
230
+ throw err;
176
231
  }
177
232
 
178
233
  return new Response(null, { status: 200 });
@@ -193,44 +248,3 @@ export async function handleConnectAction(_req: Request): Promise<Response> {
193
248
  );
194
249
  }
195
250
 
196
- /**
197
- * Answer a pending question for an active call.
198
- * POST /v1/calls/:callSessionId/answer
199
- * Body: { answer: string }
200
- */
201
- export async function handleCallAnswer(req: Request, callSessionId: string): Promise<Response> {
202
- const body = await req.json() as { answer?: string };
203
- if (!body.answer) {
204
- return Response.json({ error: 'Missing answer' }, { status: 400 });
205
- }
206
-
207
- const question = getPendingQuestion(callSessionId);
208
- if (!question) {
209
- return Response.json({ error: 'No pending question found' }, { status: 404 });
210
- }
211
-
212
- // Verify the orchestrator exists before attempting to route the answer.
213
- const orchestrator = getCallOrchestrator(callSessionId);
214
- if (!orchestrator) {
215
- log.warn({ callSessionId }, 'handleCallAnswer: no active orchestrator for call session');
216
- return Response.json({ error: 'No active orchestrator for this call' }, { status: 409 });
217
- }
218
-
219
- // Route answer to the orchestrator FIRST — it atomically checks whether it is
220
- // in the `waiting_on_user` state and transitions to `processing`. Only persist
221
- // the answer to the DB if the orchestrator actually accepted it, preventing a
222
- // race where the consultation timer expires between our check and the persist.
223
- const accepted = await orchestrator.handleUserAnswer(body.answer);
224
- if (!accepted) {
225
- log.warn(
226
- { callSessionId },
227
- 'handleCallAnswer: orchestrator rejected the answer (not in waiting_on_user state)',
228
- );
229
- return Response.json({ error: 'Orchestrator is not waiting for an answer' }, { status: 409 });
230
- }
231
-
232
- // Mark question as answered — only after the orchestrator has accepted
233
- answerPendingQuestion(question.id, body.answer);
234
-
235
- return Response.json({ ok: true, questionId: question.id });
236
- }
@@ -0,0 +1,50 @@
1
+ import { getLogger } from '../util/logger.js';
2
+
3
+ const log = getLogger('twilio-webhook-urls');
4
+
5
+ /**
6
+ * Resolve the webhook base URL from config, falling back to the
7
+ * TWILIO_WEBHOOK_BASE_URL environment variable with a deprecation warning.
8
+ * Throws if neither source provides a value.
9
+ */
10
+ export function getWebhookBaseUrl(config: { calls: { webhookBaseUrl?: string } }): string {
11
+ const configValue = config.calls.webhookBaseUrl;
12
+ if (configValue) {
13
+ const normalized = normalizeBaseUrl(configValue);
14
+ if (normalized) return normalized;
15
+ }
16
+
17
+ const envValue = process.env.TWILIO_WEBHOOK_BASE_URL;
18
+ if (envValue) {
19
+ log.warn(
20
+ 'TWILIO_WEBHOOK_BASE_URL env var is deprecated — set calls.webhookBaseUrl in config instead.',
21
+ );
22
+ const normalized = normalizeBaseUrl(envValue);
23
+ if (normalized) return normalized;
24
+ }
25
+
26
+ throw new Error(
27
+ 'No webhook base URL configured. Set calls.webhookBaseUrl in config or TWILIO_WEBHOOK_BASE_URL env var.',
28
+ );
29
+ }
30
+
31
+ /**
32
+ * Trim whitespace and strip trailing slash from a URL string.
33
+ */
34
+ export function normalizeBaseUrl(url: string): string {
35
+ return url.trim().replace(/\/+$/, '');
36
+ }
37
+
38
+ /**
39
+ * Build the Twilio voice webhook URL for a given call session.
40
+ */
41
+ export function buildTwilioVoiceWebhookUrl(baseUrl: string, callSessionId: string): string {
42
+ return `${normalizeBaseUrl(baseUrl)}/webhooks/twilio/voice?callSessionId=${callSessionId}`;
43
+ }
44
+
45
+ /**
46
+ * Build the Twilio status callback URL.
47
+ */
48
+ export function buildTwilioStatusCallbackUrl(baseUrl: string): string {
49
+ return `${normalizeBaseUrl(baseUrl)}/webhooks/twilio/status`;
50
+ }
@@ -1,4 +1,4 @@
1
- export type CallStatus = 'initiated' | 'ringing' | 'in_progress' | 'waiting_on_user' | 'completed' | 'failed';
1
+ export type CallStatus = 'initiated' | 'ringing' | 'in_progress' | 'waiting_on_user' | 'completed' | 'failed' | 'cancelled';
2
2
  export type CallEventType = 'call_started' | 'call_connected' | 'caller_spoke' | 'assistant_spoke' | 'user_question_asked' | 'user_answered' | 'call_ended' | 'call_failed';
3
3
  export type PendingQuestionStatus = 'pending' | 'answered' | 'expired' | 'cancelled';
4
4