@ouro.bot/cli 0.1.0-alpha.58 → 0.1.0-alpha.581

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 (404) hide show
  1. package/README.md +127 -23
  2. package/RepairGuide.ouro/agent.json +5 -0
  3. package/RepairGuide.ouro/psyche/IDENTITY.md +19 -0
  4. package/RepairGuide.ouro/psyche/SOUL.md +55 -0
  5. package/RepairGuide.ouro/skills/diagnose-broken-remote.md +63 -0
  6. package/RepairGuide.ouro/skills/diagnose-stacked-typed-issues.md +35 -0
  7. package/RepairGuide.ouro/skills/diagnose-sync-blocked.md +54 -0
  8. package/RepairGuide.ouro/skills/diagnose-vault-expired.md +60 -0
  9. package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/agent.json +4 -2
  10. package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/SOUL.md +2 -2
  11. package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/the-serpent.md +1 -1
  12. package/changelog.json +3745 -0
  13. package/dist/arc/attention-types.js +8 -0
  14. package/dist/arc/cares.js +140 -0
  15. package/dist/arc/episodes.js +117 -0
  16. package/dist/arc/intentions.js +133 -0
  17. package/dist/arc/json-store.js +117 -0
  18. package/dist/arc/obligations.js +237 -0
  19. package/dist/arc/packets.js +193 -0
  20. package/dist/arc/presence.js +185 -0
  21. package/dist/arc/task-lifecycle.js +65 -0
  22. package/dist/heart/active-work.js +837 -26
  23. package/dist/heart/agent-entry.js +69 -3
  24. package/dist/heart/attachments/image-normalize.js +194 -0
  25. package/dist/heart/attachments/materialize.js +97 -0
  26. package/dist/heart/attachments/originals.js +88 -0
  27. package/dist/heart/attachments/render.js +29 -0
  28. package/dist/heart/attachments/sources/adapter.js +2 -0
  29. package/dist/heart/attachments/sources/bluebubbles.js +156 -0
  30. package/dist/heart/attachments/sources/cli-local-file.js +78 -0
  31. package/dist/heart/attachments/sources/index.js +16 -0
  32. package/dist/heart/attachments/store.js +103 -0
  33. package/dist/heart/attachments/types.js +93 -0
  34. package/dist/heart/auth/auth-flow.js +479 -0
  35. package/dist/heart/background-operations.js +281 -0
  36. package/dist/heart/bundle-state.js +168 -0
  37. package/dist/heart/commitments.js +111 -0
  38. package/dist/heart/config-registry.js +322 -0
  39. package/dist/heart/config.js +114 -118
  40. package/dist/heart/core.js +909 -246
  41. package/dist/heart/cross-chat-delivery.js +3 -18
  42. package/dist/heart/daemon/agent-config-check.js +419 -0
  43. package/dist/heart/daemon/agent-discovery.js +102 -3
  44. package/dist/heart/daemon/agent-service.js +522 -0
  45. package/dist/heart/daemon/agentic-repair.js +547 -0
  46. package/dist/heart/daemon/bluebubbles-health-diagnostics.js +122 -0
  47. package/dist/heart/daemon/boot-sync-probe.js +197 -0
  48. package/dist/heart/daemon/cadence.js +70 -0
  49. package/dist/heart/daemon/cli-defaults.js +776 -0
  50. package/dist/heart/daemon/cli-exec.js +7559 -0
  51. package/dist/heart/daemon/cli-help.js +498 -0
  52. package/dist/heart/daemon/cli-parse.js +1592 -0
  53. package/dist/heart/daemon/cli-render-doctor.js +57 -0
  54. package/dist/heart/daemon/cli-render.js +763 -0
  55. package/dist/heart/daemon/cli-types.js +8 -0
  56. package/dist/heart/daemon/connect-bay.js +323 -0
  57. package/dist/heart/daemon/daemon-cli.js +29 -1703
  58. package/dist/heart/daemon/daemon-entry.js +387 -2
  59. package/dist/heart/daemon/daemon-health.js +176 -0
  60. package/dist/heart/daemon/daemon-rollup.js +57 -0
  61. package/dist/heart/daemon/daemon-runtime-sync.js +88 -13
  62. package/dist/heart/daemon/daemon-tombstone.js +236 -0
  63. package/dist/heart/daemon/daemon.js +816 -71
  64. package/dist/heart/daemon/dns-workflow.js +394 -0
  65. package/dist/heart/daemon/doctor-types.js +8 -0
  66. package/dist/heart/daemon/doctor.js +873 -0
  67. package/dist/heart/daemon/health-monitor.js +122 -1
  68. package/dist/heart/daemon/hooks/agent-config-v2.js +33 -0
  69. package/dist/heart/daemon/hooks/bundle-meta.js +115 -1
  70. package/dist/heart/daemon/http-health-probe.js +80 -0
  71. package/dist/heart/daemon/human-command-screens.js +234 -0
  72. package/dist/heart/daemon/human-readiness.js +114 -0
  73. package/dist/heart/daemon/inner-status.js +89 -0
  74. package/dist/heart/daemon/interactive-repair.js +394 -0
  75. package/dist/heart/daemon/launchd.js +37 -8
  76. package/dist/heart/daemon/log-tailer.js +82 -12
  77. package/dist/heart/daemon/logs-prune.js +110 -0
  78. package/dist/heart/daemon/mcp-canary.js +297 -0
  79. package/dist/heart/daemon/message-router.js +2 -2
  80. package/dist/heart/daemon/os-cron-deps.js +135 -0
  81. package/dist/heart/daemon/os-cron.js +14 -12
  82. package/dist/heart/daemon/ouro-bot-entry.js +4 -2
  83. package/dist/heart/daemon/ouro-entry.js +3 -1
  84. package/dist/heart/daemon/process-manager.js +375 -33
  85. package/dist/heart/daemon/provider-discovery.js +137 -0
  86. package/dist/heart/daemon/provider-ping-progress.js +83 -0
  87. package/dist/heart/daemon/pulse.js +475 -0
  88. package/dist/heart/daemon/readiness-repair.js +365 -0
  89. package/dist/heart/daemon/run-hooks.js +2 -0
  90. package/dist/heart/daemon/runtime-logging.js +30 -6
  91. package/dist/heart/daemon/runtime-metadata.js +3 -31
  92. package/dist/heart/daemon/safe-mode.js +161 -0
  93. package/dist/heart/daemon/sense-manager.js +462 -38
  94. package/dist/heart/daemon/session-id-resolver.js +131 -0
  95. package/dist/heart/daemon/skill-management-installer.js +94 -0
  96. package/dist/heart/daemon/socket-client.js +158 -11
  97. package/dist/heart/daemon/stale-bundle-prune.js +96 -0
  98. package/dist/heart/daemon/startup-tui.js +330 -0
  99. package/dist/heart/daemon/task-scheduler.js +3 -25
  100. package/dist/heart/daemon/terminal-ui.js +499 -0
  101. package/dist/heart/daemon/thoughts.js +162 -17
  102. package/dist/heart/daemon/up-progress.js +366 -0
  103. package/dist/heart/daemon/vault-items.js +56 -0
  104. package/dist/heart/delegation.js +1 -1
  105. package/dist/heart/habits/habit-migration.js +189 -0
  106. package/dist/heart/habits/habit-parser.js +140 -0
  107. package/dist/heart/habits/habit-runtime-state.js +100 -0
  108. package/dist/heart/habits/habit-scheduler.js +372 -0
  109. package/dist/heart/{daemon → hatch}/hatch-flow.js +32 -56
  110. package/dist/heart/{daemon → hatch}/hatch-specialist.js +6 -8
  111. package/dist/heart/{daemon → hatch}/specialist-prompt.js +12 -9
  112. package/dist/heart/{daemon → hatch}/specialist-tools.js +35 -12
  113. package/dist/heart/identity.js +203 -57
  114. package/dist/heart/kept-notes.js +357 -0
  115. package/dist/heart/kicks.js +1 -1
  116. package/dist/heart/machine-identity.js +161 -0
  117. package/dist/heart/mail-import-discovery.js +353 -0
  118. package/dist/heart/mailbox/mailbox-http-hooks.js +66 -0
  119. package/dist/heart/mailbox/mailbox-http-response.js +7 -0
  120. package/dist/heart/mailbox/mailbox-http-routes.js +246 -0
  121. package/dist/heart/mailbox/mailbox-http-static.js +103 -0
  122. package/dist/heart/mailbox/mailbox-http-transport.js +116 -0
  123. package/dist/heart/mailbox/mailbox-http.js +99 -0
  124. package/dist/heart/mailbox/mailbox-read.js +31 -0
  125. package/dist/heart/mailbox/mailbox-types.js +27 -0
  126. package/dist/heart/mailbox/mailbox-view.js +195 -0
  127. package/dist/heart/mailbox/readers/agent-machine.js +382 -0
  128. package/dist/heart/mailbox/readers/continuity-readers.js +338 -0
  129. package/dist/heart/mailbox/readers/mail.js +362 -0
  130. package/dist/heart/mailbox/readers/runtime-readers.js +651 -0
  131. package/dist/heart/mailbox/readers/sessions.js +232 -0
  132. package/dist/heart/mailbox/readers/shared.js +111 -0
  133. package/dist/heart/mcp/mcp-server.js +656 -0
  134. package/dist/heart/migrate-config.js +100 -0
  135. package/dist/heart/model-capabilities.js +19 -0
  136. package/dist/heart/platform.js +81 -0
  137. package/dist/heart/provider-attempt.js +134 -0
  138. package/dist/heart/provider-binding-resolver.js +267 -0
  139. package/dist/heart/provider-credentials.js +425 -0
  140. package/dist/heart/provider-failover.js +301 -0
  141. package/dist/heart/provider-models.js +81 -0
  142. package/dist/heart/provider-ping.js +262 -0
  143. package/dist/heart/provider-readiness-cache.js +40 -0
  144. package/dist/heart/provider-visibility.js +188 -0
  145. package/dist/heart/providers/anthropic-token.js +131 -0
  146. package/dist/heart/providers/anthropic.js +139 -52
  147. package/dist/heart/providers/azure.js +97 -13
  148. package/dist/heart/providers/error-classification.js +127 -0
  149. package/dist/heart/providers/github-copilot.js +145 -0
  150. package/dist/heart/providers/minimax-vlm.js +189 -0
  151. package/dist/heart/providers/minimax.js +26 -8
  152. package/dist/heart/providers/openai-codex.js +55 -40
  153. package/dist/heart/runtime-capability-check.js +170 -0
  154. package/dist/heart/runtime-credentials.js +367 -0
  155. package/dist/heart/runtime-cwd.js +87 -0
  156. package/dist/heart/sense-truth.js +13 -4
  157. package/dist/heart/session-activity.js +43 -22
  158. package/dist/heart/session-events.js +1149 -0
  159. package/dist/heart/session-playback-cli-main.js +5 -0
  160. package/dist/heart/session-playback-cli.js +36 -0
  161. package/dist/heart/session-playback.js +231 -0
  162. package/dist/heart/session-stats-cli-main.js +5 -0
  163. package/dist/heart/session-stats.js +182 -0
  164. package/dist/heart/session-transcript.js +243 -0
  165. package/dist/heart/start-of-turn-packet.js +345 -0
  166. package/dist/heart/streaming.js +44 -27
  167. package/dist/heart/sync-classification.js +176 -0
  168. package/dist/heart/sync.js +449 -0
  169. package/dist/heart/target-resolution.js +9 -5
  170. package/dist/heart/tempo.js +93 -0
  171. package/dist/heart/temporal-view.js +41 -0
  172. package/dist/heart/timeouts.js +101 -0
  173. package/dist/heart/tool-activity-callbacks.js +59 -0
  174. package/dist/heart/tool-description.js +143 -0
  175. package/dist/heart/tool-friction.js +55 -0
  176. package/dist/heart/tool-loop.js +200 -0
  177. package/dist/heart/turn-context.js +421 -0
  178. package/dist/heart/{daemon → versioning}/ouro-bot-global-installer.js +6 -5
  179. package/dist/heart/{daemon → versioning}/ouro-bot-wrapper.js +1 -1
  180. package/dist/heart/versioning/ouro-path-installer.js +426 -0
  181. package/dist/heart/versioning/ouro-version-manager.js +295 -0
  182. package/dist/heart/{daemon → versioning}/staged-restart.js +40 -8
  183. package/dist/heart/{daemon → versioning}/update-checker.js +6 -1
  184. package/dist/heart/{daemon → versioning}/update-hooks.js +63 -59
  185. package/dist/mailbox-ui/assets/index-B-461hes.js +61 -0
  186. package/dist/mailbox-ui/assets/index-BPr5vNuM.css +1 -0
  187. package/dist/mailbox-ui/index.html +15 -0
  188. package/dist/mailroom/attention.js +167 -0
  189. package/dist/mailroom/autonomy.js +209 -0
  190. package/dist/mailroom/blob-store.js +674 -0
  191. package/dist/mailroom/body-cache.js +61 -0
  192. package/dist/mailroom/core.js +720 -0
  193. package/dist/mailroom/entry.js +160 -0
  194. package/dist/mailroom/file-store.js +430 -0
  195. package/dist/mailroom/mbox-import.js +383 -0
  196. package/dist/mailroom/outbound.js +380 -0
  197. package/dist/mailroom/policy.js +263 -0
  198. package/dist/mailroom/reader.js +233 -0
  199. package/dist/mailroom/search-cache.js +256 -0
  200. package/dist/mailroom/search-relevance.js +319 -0
  201. package/dist/mailroom/smtp-ingress.js +176 -0
  202. package/dist/mailroom/source-state.js +176 -0
  203. package/dist/mailroom/thread.js +109 -0
  204. package/dist/mailroom/travel-extract.js +89 -0
  205. package/dist/mind/bundle-manifest.js +7 -1
  206. package/dist/mind/context.js +165 -101
  207. package/dist/mind/diary-integrity.js +60 -0
  208. package/dist/mind/{memory.js → diary.js} +62 -75
  209. package/dist/mind/embedding-provider.js +60 -0
  210. package/dist/mind/file-state.js +179 -0
  211. package/dist/mind/friends/channel.js +39 -0
  212. package/dist/mind/friends/resolver.js +54 -2
  213. package/dist/mind/friends/store-file.js +39 -3
  214. package/dist/mind/friends/types.js +2 -2
  215. package/dist/mind/journal-index.js +161 -0
  216. package/dist/mind/note-search.js +268 -0
  217. package/dist/mind/obligation-steering.js +221 -0
  218. package/dist/mind/pending.js +4 -0
  219. package/dist/mind/prompt-refresh.js +3 -2
  220. package/dist/mind/prompt.js +1039 -135
  221. package/dist/mind/provenance-trust.js +26 -0
  222. package/dist/mind/scrutiny.js +173 -0
  223. package/dist/nerves/cli-logging.js +7 -1
  224. package/dist/nerves/coverage/audit-rules.js +15 -6
  225. package/dist/nerves/coverage/audit.js +28 -2
  226. package/dist/nerves/coverage/cli.js +1 -1
  227. package/dist/nerves/coverage/contract.js +5 -5
  228. package/dist/nerves/coverage/file-completeness.js +129 -5
  229. package/dist/nerves/coverage/run-artifacts.js +1 -1
  230. package/dist/nerves/event-buffer.js +111 -0
  231. package/dist/nerves/index.js +224 -4
  232. package/dist/nerves/observation.js +20 -0
  233. package/dist/nerves/redact.js +79 -0
  234. package/dist/nerves/review/cli-main.js +5 -0
  235. package/dist/nerves/review/cli.js +156 -0
  236. package/dist/nerves/review/core.js +152 -0
  237. package/dist/nerves/runtime.js +5 -1
  238. package/dist/repertoire/ado-client.js +15 -56
  239. package/dist/repertoire/ado-semantic.js +11 -10
  240. package/dist/repertoire/api-client.js +97 -0
  241. package/dist/repertoire/bitwarden-store.js +997 -0
  242. package/dist/repertoire/bundle-templates.js +72 -0
  243. package/dist/repertoire/bw-installer.js +180 -0
  244. package/dist/repertoire/coding/codex-jsonl.js +64 -0
  245. package/dist/repertoire/coding/context-pack.js +330 -0
  246. package/dist/repertoire/coding/feedback.js +197 -30
  247. package/dist/repertoire/coding/manager.js +158 -9
  248. package/dist/repertoire/coding/spawner.js +55 -9
  249. package/dist/repertoire/coding/tools.js +170 -7
  250. package/dist/repertoire/commerce-errors.js +109 -0
  251. package/dist/repertoire/commerce-self-test.js +156 -0
  252. package/dist/repertoire/credential-access.js +178 -0
  253. package/dist/repertoire/duffel-client.js +185 -0
  254. package/dist/repertoire/github-client.js +14 -55
  255. package/dist/repertoire/graph-client.js +11 -52
  256. package/dist/repertoire/guardrails.js +396 -0
  257. package/dist/repertoire/mcp-client.js +295 -0
  258. package/dist/repertoire/mcp-manager.js +362 -0
  259. package/dist/repertoire/mcp-tools.js +63 -0
  260. package/dist/repertoire/shell-sessions.js +133 -0
  261. package/dist/repertoire/skills.js +15 -24
  262. package/dist/repertoire/stripe-client.js +131 -0
  263. package/dist/repertoire/tasks/board.js +31 -5
  264. package/dist/repertoire/tasks/fix.js +182 -0
  265. package/dist/repertoire/tasks/index.js +16 -4
  266. package/dist/repertoire/tasks/lifecycle.js +2 -2
  267. package/dist/repertoire/tasks/parser.js +3 -2
  268. package/dist/repertoire/tasks/scanner.js +194 -37
  269. package/dist/repertoire/tasks/transitions.js +16 -78
  270. package/dist/repertoire/tool-results.js +29 -0
  271. package/dist/repertoire/tools-attachments.js +317 -0
  272. package/dist/repertoire/tools-base.js +47 -1075
  273. package/dist/repertoire/tools-bluebubbles.js +1 -0
  274. package/dist/repertoire/tools-bridge.js +142 -0
  275. package/dist/repertoire/tools-bundle.js +984 -0
  276. package/dist/repertoire/tools-config.js +185 -0
  277. package/dist/repertoire/tools-continuity.js +248 -0
  278. package/dist/repertoire/tools-credential.js +381 -0
  279. package/dist/repertoire/tools-files.js +342 -0
  280. package/dist/repertoire/tools-flight.js +224 -0
  281. package/dist/repertoire/tools-flow.js +119 -0
  282. package/dist/repertoire/tools-github.js +1 -7
  283. package/dist/repertoire/tools-mail.js +1857 -0
  284. package/dist/repertoire/tools-notes.js +421 -0
  285. package/dist/repertoire/tools-session.js +809 -0
  286. package/dist/repertoire/tools-shell.js +120 -0
  287. package/dist/repertoire/tools-stripe.js +180 -0
  288. package/dist/repertoire/tools-surface.js +345 -0
  289. package/dist/repertoire/tools-teams.js +9 -39
  290. package/dist/repertoire/tools-travel.js +125 -0
  291. package/dist/repertoire/tools-trip.js +604 -0
  292. package/dist/repertoire/tools-user-profile.js +144 -0
  293. package/dist/repertoire/tools-vault.js +40 -0
  294. package/dist/repertoire/tools-voice.js +144 -0
  295. package/dist/repertoire/tools.js +115 -103
  296. package/dist/repertoire/travel-api-client.js +360 -0
  297. package/dist/repertoire/user-profile.js +131 -0
  298. package/dist/repertoire/vault-setup.js +246 -0
  299. package/dist/repertoire/vault-unlock.js +594 -0
  300. package/dist/scripts/claude-code-hook.js +41 -0
  301. package/dist/scripts/claude-code-stop-hook.js +47 -0
  302. package/dist/senses/attention-queue.js +116 -0
  303. package/dist/senses/bluebubbles/active-turns.js +216 -0
  304. package/dist/senses/bluebubbles/attachment-cache.js +53 -0
  305. package/dist/senses/bluebubbles/attachment-download.js +137 -0
  306. package/dist/senses/{bluebubbles-client.js → bluebubbles/client.js} +219 -18
  307. package/dist/senses/bluebubbles/entry.js +77 -0
  308. package/dist/senses/{bluebubbles-inbound-log.js → bluebubbles/inbound-log.js} +20 -3
  309. package/dist/senses/bluebubbles/index.js +2420 -0
  310. package/dist/senses/{bluebubbles-media.js → bluebubbles/media.js} +121 -70
  311. package/dist/senses/{bluebubbles-model.js → bluebubbles/model.js} +33 -12
  312. package/dist/senses/{bluebubbles-mutation-log.js → bluebubbles/mutation-log.js} +3 -3
  313. package/dist/senses/bluebubbles/processed-log.js +133 -0
  314. package/dist/senses/bluebubbles/replay.js +137 -0
  315. package/dist/senses/{bluebubbles-runtime-state.js → bluebubbles/runtime-state.js} +30 -2
  316. package/dist/senses/{bluebubbles-session-cleanup.js → bluebubbles/session-cleanup.js} +1 -1
  317. package/dist/senses/bluebubbles-meta-guard.js +40 -0
  318. package/dist/senses/cli/bracketed-paste.js +82 -0
  319. package/dist/senses/cli/image-paste.js +287 -0
  320. package/dist/senses/cli/image-ref-navigation.js +75 -0
  321. package/dist/senses/cli/ink-app.js +156 -0
  322. package/dist/senses/cli/inline-diff.js +64 -0
  323. package/dist/senses/cli/input-keys.js +174 -0
  324. package/dist/senses/cli/kill-ring.js +86 -0
  325. package/dist/senses/cli/message-list.js +51 -0
  326. package/dist/senses/cli/ouro-tui.js +607 -0
  327. package/dist/senses/cli/spinner-imperative.js +135 -0
  328. package/dist/senses/cli/spinner.js +101 -0
  329. package/dist/senses/cli/status-line.js +60 -0
  330. package/dist/senses/cli/streaming-markdown.js +526 -0
  331. package/dist/senses/cli/tool-display.js +85 -0
  332. package/dist/senses/cli/tool-render.js +85 -0
  333. package/dist/senses/cli/tui-store.js +240 -0
  334. package/dist/senses/cli/virtual-list.js +35 -0
  335. package/dist/senses/cli-entry.js +60 -8
  336. package/dist/senses/cli-layout.js +100 -0
  337. package/dist/senses/cli.js +516 -204
  338. package/dist/senses/commands.js +66 -3
  339. package/dist/senses/habit-turn-message.js +108 -0
  340. package/dist/senses/inner-dialog-worker.js +175 -21
  341. package/dist/senses/inner-dialog.js +330 -27
  342. package/dist/senses/mail-entry.js +66 -0
  343. package/dist/senses/mail.js +379 -0
  344. package/dist/senses/pipeline.js +654 -181
  345. package/dist/senses/proactive-content-guard.js +51 -0
  346. package/dist/senses/shared-turn.js +392 -0
  347. package/dist/senses/surface-tool.js +70 -0
  348. package/dist/senses/teams-entry.js +60 -8
  349. package/dist/senses/teams.js +387 -98
  350. package/dist/senses/trust-gate.js +100 -5
  351. package/dist/senses/voice/audio-playback.js +237 -0
  352. package/dist/senses/voice/audio-routing.js +119 -0
  353. package/dist/senses/voice/elevenlabs.js +202 -0
  354. package/dist/senses/voice/golden-path.js +116 -0
  355. package/dist/senses/voice/index.js +28 -0
  356. package/dist/senses/voice/meeting.js +113 -0
  357. package/dist/senses/voice/outbound.js +190 -0
  358. package/dist/senses/voice/phone.js +33 -0
  359. package/dist/senses/voice/playback.js +139 -0
  360. package/dist/senses/voice/transcript.js +70 -0
  361. package/dist/senses/voice/turn.js +191 -0
  362. package/dist/senses/voice/twilio-phone-runtime.js +755 -0
  363. package/dist/senses/voice/twilio-phone.js +4484 -0
  364. package/dist/senses/voice/types.js +2 -0
  365. package/dist/senses/voice/whisper.js +161 -0
  366. package/dist/senses/voice-entry.js +81 -0
  367. package/dist/senses/voice-twilio-entry.js +87 -0
  368. package/dist/trips/core.js +138 -0
  369. package/dist/trips/store.js +265 -0
  370. package/package.json +40 -7
  371. package/skills/agent-commerce.md +106 -0
  372. package/skills/browser-navigation.md +117 -0
  373. package/skills/commerce-setup-guide.md +116 -0
  374. package/skills/commerce-setup.md +84 -0
  375. package/skills/configure-dev-tools.md +99 -0
  376. package/skills/travel-planning.md +138 -0
  377. package/dist/heart/daemon/auth-flow.js +0 -351
  378. package/dist/heart/daemon/ouro-path-installer.js +0 -178
  379. package/dist/heart/daemon/subagent-installer.js +0 -166
  380. package/dist/heart/session-recall.js +0 -116
  381. package/dist/mind/associative-recall.js +0 -209
  382. package/dist/senses/bluebubbles-entry.js +0 -13
  383. package/dist/senses/bluebubbles.js +0 -1177
  384. package/dist/senses/debug-activity.js +0 -148
  385. package/subagents/README.md +0 -86
  386. package/subagents/work-doer.md +0 -237
  387. package/subagents/work-merger.md +0 -618
  388. package/subagents/work-planner.md +0 -390
  389. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/basilisk.md +0 -0
  390. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/jafar.md +0 -0
  391. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/jormungandr.md +0 -0
  392. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/kaa.md +0 -0
  393. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/medusa.md +0 -0
  394. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/monty.md +0 -0
  395. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/nagini.md +0 -0
  396. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/ouroboros.md +0 -0
  397. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/python.md +0 -0
  398. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/quetzalcoatl.md +0 -0
  399. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/sir-hiss.md +0 -0
  400. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/the-snake.md +0 -0
  401. /package/dist/heart/{daemon → hatch}/hatch-animation.js +0 -0
  402. /package/dist/heart/{daemon → hatch}/specialist-orchestrator.js +0 -0
  403. /package/dist/heart/{daemon → versioning}/ouro-uti.js +0 -0
  404. /package/dist/heart/{daemon → versioning}/wrapper-publish-guard.js +0 -0
@@ -0,0 +1,4484 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.DEFAULT_TWILIO_MEDIA_MAX_UTTERANCE_MS = exports.DEFAULT_TWILIO_MEDIA_MIN_SPEECH_MS = exports.DEFAULT_TWILIO_MEDIA_SILENCE_END_MS = exports.DEFAULT_TWILIO_MEDIA_SPEECH_RMS_THRESHOLD = exports.DEFAULT_TWILIO_PHONE_TRANSPORT_MODE = exports.DEFAULT_TWILIO_PHONE_PLAYBACK_MODE = exports.TWILIO_PHONE_WEBHOOK_BASE_PATH = exports.DEFAULT_TWILIO_GREETING_PREBUFFER_MS = exports.DEFAULT_TWILIO_RECORD_MAX_LENGTH_SECONDS = exports.DEFAULT_TWILIO_RECORD_TIMEOUT_SECONDS = exports.DEFAULT_TWILIO_PHONE_PORT = exports.normalizeTwilioE164PhoneNumber = void 0;
37
+ exports.normalizeTwilioPhoneBasePath = normalizeTwilioPhoneBasePath;
38
+ exports.normalizeTwilioPhonePlaybackMode = normalizeTwilioPhonePlaybackMode;
39
+ exports.normalizeTwilioPhoneTransportMode = normalizeTwilioPhoneTransportMode;
40
+ exports.normalizeTwilioPhoneConversationEngine = normalizeTwilioPhoneConversationEngine;
41
+ exports.twilioPhoneWebhookUrl = twilioPhoneWebhookUrl;
42
+ exports.openAISipWebhookPath = openAISipWebhookPath;
43
+ exports.openAISipWebhookUrl = openAISipWebhookUrl;
44
+ exports.twilioOutboundCallWebhookUrl = twilioOutboundCallWebhookUrl;
45
+ exports.twilioOutboundCallStatusCallbackUrl = twilioOutboundCallStatusCallbackUrl;
46
+ exports.twilioOutboundCallAmdCallbackUrl = twilioOutboundCallAmdCallbackUrl;
47
+ exports.twilioPhoneVoiceSessionKey = twilioPhoneVoiceSessionKey;
48
+ exports.outboundCallAnsweredPrompt = outboundCallAnsweredPrompt;
49
+ exports.computeOpenAIWebhookSignature = computeOpenAIWebhookSignature;
50
+ exports.validateOpenAIWebhookSignature = validateOpenAIWebhookSignature;
51
+ exports.computeTwilioSignature = computeTwilioSignature;
52
+ exports.validateTwilioSignature = validateTwilioSignature;
53
+ exports.twilioRecordingMediaUrl = twilioRecordingMediaUrl;
54
+ exports.defaultTwilioRecordingDownloader = defaultTwilioRecordingDownloader;
55
+ exports.twilioOutboundCallJobPath = twilioOutboundCallJobPath;
56
+ exports.writeTwilioOutboundCallJob = writeTwilioOutboundCallJob;
57
+ exports.updateTwilioOutboundCallJob = updateTwilioOutboundCallJob;
58
+ exports.readRecentTwilioOutboundCallJobs = readRecentTwilioOutboundCallJobs;
59
+ exports.createTwilioOutboundCall = createTwilioOutboundCall;
60
+ exports.createTwilioPhoneBridge = createTwilioPhoneBridge;
61
+ exports.startTwilioPhoneBridgeServer = startTwilioPhoneBridgeServer;
62
+ const crypto = __importStar(require("node:crypto"));
63
+ const fs = __importStar(require("fs/promises"));
64
+ const http = __importStar(require("http"));
65
+ const path = __importStar(require("path"));
66
+ const ws_1 = require("ws");
67
+ const context_1 = require("../../mind/context");
68
+ const channel_1 = require("../../mind/friends/channel");
69
+ const resolver_1 = require("../../mind/friends/resolver");
70
+ const store_file_1 = require("../../mind/friends/store-file");
71
+ const identity_1 = require("../../heart/identity");
72
+ const mcp_manager_1 = require("../../repertoire/mcp-manager");
73
+ const tools_1 = require("../../repertoire/tools");
74
+ const config_1 = require("../../heart/config");
75
+ const runtime_1 = require("../../nerves/runtime");
76
+ const playback_1 = require("./playback");
77
+ const transcript_1 = require("./transcript");
78
+ const turn_1 = require("./turn");
79
+ const phone_1 = require("./phone");
80
+ const audio_playback_1 = require("./audio-playback");
81
+ var phone_2 = require("./phone");
82
+ Object.defineProperty(exports, "normalizeTwilioE164PhoneNumber", { enumerable: true, get: function () { return phone_2.normalizeTwilioE164PhoneNumber; } });
83
+ exports.DEFAULT_TWILIO_PHONE_PORT = 18910;
84
+ exports.DEFAULT_TWILIO_RECORD_TIMEOUT_SECONDS = 1;
85
+ exports.DEFAULT_TWILIO_RECORD_MAX_LENGTH_SECONDS = 30;
86
+ exports.DEFAULT_TWILIO_GREETING_PREBUFFER_MS = 3_500;
87
+ exports.TWILIO_PHONE_WEBHOOK_BASE_PATH = "/voice/twilio";
88
+ exports.DEFAULT_TWILIO_PHONE_PLAYBACK_MODE = "stream";
89
+ exports.DEFAULT_TWILIO_PHONE_TRANSPORT_MODE = "record-play";
90
+ exports.DEFAULT_TWILIO_MEDIA_SPEECH_RMS_THRESHOLD = 650;
91
+ exports.DEFAULT_TWILIO_MEDIA_SILENCE_END_MS = 450;
92
+ exports.DEFAULT_TWILIO_MEDIA_MIN_SPEECH_MS = 120;
93
+ exports.DEFAULT_TWILIO_MEDIA_MAX_UTTERANCE_MS = 15_000;
94
+ const TWILIO_MEDIA_HANGUP_FALLBACK_MS = 10_000;
95
+ const TWILIO_STREAM_FAILURE_SILENCE_MP3 = Buffer.from("SUQzBAAAAAAAIlRTU0UAAAAOAAADTGF2ZjYyLjMuMTAwAAAAAAAAAAAAAAD/+0DAAAAAAAAAAAAAAAAAAAAAAABJbmZvAAAADwAAAAsAAAUuADc3Nzc3Nzc3N0tLS0tLS0tLS19fX19fX19fX3Nzc3Nzc3Nzc4eHh4eHh4eHh5ubm5ubm5ubm6+vr6+vr6+vr8PDw8PDw8PDw9fX19fX19fX1+vr6+vr6+vr6////////////wAAAABMYXZjNjIuMTEAAAAAAAAAAAAAAAAkBC8AAAAAAAAFLpJQTFMAAAAAAP/7EMQAA8AAAaQAAAAgAAA0gAAABExBTUUzLjEwMFVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVMQU1FMy4xMDBVVVVV//sQxCmDwAABpAAAACAAADSAAAAEVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVUxBTUUzLjEwMFVVVVX/+xDEUwPAAAGkAAAAIAAANIAAAARVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVTEFNRTMuMTAwVVVVVf/7EMR8g8AAAaQAAAAgAAA0gAAABFVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVMQU1FMy4xMDBVVVVV//sQxKYDwAABpAAAACAAADSAAAAEVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVUxBTUUzLjEwMFVVVVX/+xDEz4PAAAGkAAAAIAAANIAAAARVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVf/7EMTWA8AAAaQAAAAgAAA0gAAABFVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//sQxNYDwAABpAAAACAAADSAAAAEVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVX/+xDE1gPAAAGkAAAAIAAANIAAAARVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVf/7EMTWA8AAAaQAAAAgAAA0gAAABFVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//sQxNYDwAABpAAAACAAADSAAAAEVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVU=", "base64");
96
+ function bodyText(body) {
97
+ if (body === undefined)
98
+ return "";
99
+ if (typeof body === "string")
100
+ return body;
101
+ return Buffer.from(body).toString("utf8");
102
+ }
103
+ function formParams(rawBody) {
104
+ const parsed = new URLSearchParams(rawBody);
105
+ const params = {};
106
+ for (const [key, value] of parsed) {
107
+ params[key] = value;
108
+ }
109
+ return params;
110
+ }
111
+ function headerValue(headers, name) {
112
+ const wanted = name.toLowerCase();
113
+ for (const [key, value] of Object.entries(headers)) {
114
+ if (key.toLowerCase() !== wanted)
115
+ continue;
116
+ if (Array.isArray(value))
117
+ return value[0] ?? "";
118
+ return value ?? "";
119
+ }
120
+ return "";
121
+ }
122
+ function xmlResponse(body) {
123
+ return {
124
+ statusCode: 200,
125
+ headers: { "content-type": "text/xml; charset=utf-8" },
126
+ body: `<?xml version="1.0" encoding="UTF-8"?><Response>${body}</Response>`,
127
+ };
128
+ }
129
+ function textResponse(statusCode, body) {
130
+ return {
131
+ statusCode,
132
+ headers: { "content-type": "text/plain; charset=utf-8" },
133
+ body,
134
+ };
135
+ }
136
+ function binaryResponse(body, contentType) {
137
+ return {
138
+ statusCode: 200,
139
+ headers: {
140
+ "content-type": contentType,
141
+ "cache-control": "private, max-age=300",
142
+ },
143
+ body,
144
+ };
145
+ }
146
+ function streamResponse(body, contentType) {
147
+ return {
148
+ statusCode: 200,
149
+ headers: {
150
+ "content-type": contentType,
151
+ "cache-control": "no-store",
152
+ },
153
+ body,
154
+ };
155
+ }
156
+ function isAsyncIterableBody(body) {
157
+ return typeof body === "object"
158
+ && body !== null
159
+ && Symbol.asyncIterator in body;
160
+ }
161
+ function escapeXml(input) {
162
+ return input
163
+ .replace(/&/g, "&amp;")
164
+ .replace(/</g, "&lt;")
165
+ .replace(/>/g, "&gt;")
166
+ .replace(/"/g, "&quot;")
167
+ .replace(/'/g, "&apos;");
168
+ }
169
+ function routeUrl(publicBaseUrl, route) {
170
+ return new URL(route, publicBaseUrl).toString();
171
+ }
172
+ function normalizeTwilioPhoneBasePath(value = exports.TWILIO_PHONE_WEBHOOK_BASE_PATH) {
173
+ const trimmed = value.trim();
174
+ const withLeadingSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
175
+ const withoutTrailingSlash = withLeadingSlash.replace(/\/+$/, "");
176
+ if (!withoutTrailingSlash || withoutTrailingSlash === "/") {
177
+ throw new Error("Twilio phone webhook base path is empty");
178
+ }
179
+ if (!/^\/[A-Za-z0-9._~/-]+$/.test(withoutTrailingSlash) || withoutTrailingSlash.includes("//")) {
180
+ throw new Error(`invalid Twilio phone webhook base path: ${value}`);
181
+ }
182
+ return withoutTrailingSlash;
183
+ }
184
+ function normalizeTwilioPhonePlaybackMode(value) {
185
+ const normalized = (value ?? exports.DEFAULT_TWILIO_PHONE_PLAYBACK_MODE).trim().toLowerCase();
186
+ if (normalized === "stream" || normalized === "buffered")
187
+ return normalized;
188
+ throw new Error(`invalid Twilio phone playback mode: ${value}`);
189
+ }
190
+ function normalizeTwilioPhoneTransportMode(value) {
191
+ const normalized = (value ?? exports.DEFAULT_TWILIO_PHONE_TRANSPORT_MODE).trim().toLowerCase();
192
+ if (normalized === "record-play" || normalized === "media-stream")
193
+ return normalized;
194
+ throw new Error(`invalid Twilio phone transport mode: ${value}`);
195
+ }
196
+ function normalizeTwilioPhoneConversationEngine(value) {
197
+ const normalized = (value ?? "cascade").trim().toLowerCase();
198
+ if (normalized === "cascade" || normalized === "openai-realtime" || normalized === "openai-sip")
199
+ return normalized;
200
+ throw new Error(`invalid Twilio phone conversation engine: ${value}`);
201
+ }
202
+ function usesOpenAIRealtimeConversationEngine(options) {
203
+ return normalizeTwilioPhoneConversationEngine(options.conversationEngine) === "openai-realtime";
204
+ }
205
+ function usesOpenAISipConversationEngine(options) {
206
+ return normalizeTwilioPhoneConversationEngine(options.conversationEngine) === "openai-sip";
207
+ }
208
+ function twilioPhoneWebhookUrl(publicBaseUrl, basePath = exports.TWILIO_PHONE_WEBHOOK_BASE_PATH) {
209
+ return routeUrl(publicBaseUrl, `${normalizeTwilioPhoneBasePath(basePath)}/incoming`);
210
+ }
211
+ function openAISipWebhookPath(agentName) {
212
+ return `/voice/agents/${safeSegment(agentName.toLowerCase())}/sip/openai`;
213
+ }
214
+ function openAISipWebhookUrl(publicBaseUrl, webhookPath) {
215
+ return routeUrl(publicBaseUrl, normalizeTwilioPhoneBasePath(webhookPath));
216
+ }
217
+ function twilioOutboundCallWebhookUrl(publicBaseUrl, basePath, outboundId) {
218
+ return routeUrl(publicBaseUrl, `${normalizeTwilioPhoneBasePath(basePath)}/outgoing/${encodeURIComponent(safeSegment(outboundId))}`);
219
+ }
220
+ function twilioOutboundCallStatusCallbackUrl(publicBaseUrl, basePath, outboundId) {
221
+ return routeUrl(publicBaseUrl, `${normalizeTwilioPhoneBasePath(basePath)}/outgoing/${encodeURIComponent(safeSegment(outboundId))}/status`);
222
+ }
223
+ function twilioOutboundCallAmdCallbackUrl(publicBaseUrl, basePath, outboundId) {
224
+ return routeUrl(publicBaseUrl, `${normalizeTwilioPhoneBasePath(basePath)}/outgoing/${encodeURIComponent(safeSegment(outboundId))}/amd`);
225
+ }
226
+ function requestPublicUrl(publicBaseUrl, requestPath) {
227
+ return routeUrl(publicBaseUrl, requestPath);
228
+ }
229
+ function recordTwiml(options) {
230
+ return `<Record action="${escapeXml(routeUrl(options.publicBaseUrl, `${options.basePath}/recording`))}" method="POST" playBeep="false" timeout="${options.timeoutSeconds}" maxLength="${options.maxLengthSeconds}" trim="trim-silence" />`;
231
+ }
232
+ function redirectTwiml(publicBaseUrl, basePath) {
233
+ return `<Redirect method="POST">${escapeXml(routeUrl(publicBaseUrl, `${basePath}/listen`))}</Redirect>`;
234
+ }
235
+ function sayTwiml(message) {
236
+ return `<Say>${escapeXml(message)}</Say>`;
237
+ }
238
+ function playTwiml(url) {
239
+ return `<Play>${escapeXml(url)}</Play>`;
240
+ }
241
+ function parameterTwiml(name, value) {
242
+ const trimmed = value?.trim();
243
+ if (!trimmed)
244
+ return "";
245
+ return `<Parameter name="${escapeXml(name)}" value="${escapeXml(trimmed)}" />`;
246
+ }
247
+ function websocketRouteUrl(publicBaseUrl, route) {
248
+ const url = new URL(route, publicBaseUrl);
249
+ /* v8 ignore next -- resolveTwilioPhoneTransportRuntime rejects non-HTTPS public URLs before runtime start @preserve */
250
+ if (url.protocol !== "https:") {
251
+ throw new Error("Twilio Media Streams require an https public voice URL");
252
+ }
253
+ url.protocol = "wss:";
254
+ return url.toString();
255
+ }
256
+ function mediaStreamTwiml(options, basePath, params, greetingJobId, customParams = {}) {
257
+ const streamUrl = websocketRouteUrl(options.publicBaseUrl, `${basePath}/media-stream`);
258
+ const twimlParams = {
259
+ From: params.From,
260
+ To: params.To,
261
+ Agent: options.agentName,
262
+ ...customParams,
263
+ GreetingJobId: greetingJobId,
264
+ };
265
+ return [
266
+ `<Connect><Stream url="${escapeXml(streamUrl)}">`,
267
+ ...Object.entries(twimlParams).map(([name, value]) => parameterTwiml(name, value)),
268
+ `</Stream></Connect>`,
269
+ ].join("");
270
+ }
271
+ /* v8 ignore start -- private SIP URI query permutations are exercised through bridge routes; the queryless helper shape is not externally reachable today @preserve */
272
+ function openAISipUri(options, customHeaders = {}) {
273
+ const projectId = options.openaiSip?.projectId?.trim();
274
+ /* v8 ignore next -- SIP runtime resolution requires projectId before the bridge is started @preserve */
275
+ if (!projectId) {
276
+ throw new Error("missing voice.openaiSipProjectId; configure the OpenAI project id before routing phone calls over SIP");
277
+ }
278
+ const headers = new URLSearchParams();
279
+ for (const [name, value] of Object.entries(customHeaders)) {
280
+ const trimmed = value?.trim();
281
+ if (!trimmed)
282
+ continue;
283
+ headers.set(name, trimmed);
284
+ }
285
+ const query = headers.toString();
286
+ return `sip:${projectId}@sip.api.openai.com;transport=tls${query ? `?${query}` : ""}`;
287
+ }
288
+ function openAISipDialTwiml(options, customHeaders = {}) {
289
+ return `<Dial answerOnBridge="true"><Sip>${escapeXml(openAISipUri(options, customHeaders))}</Sip></Dial>`;
290
+ }
291
+ /* v8 ignore stop */
292
+ function safeSegment(input) {
293
+ const cleaned = input.trim().replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
294
+ return cleaned || "unknown";
295
+ }
296
+ function nonHumanAnsweredStatus(answeredBy) {
297
+ const normalized = answeredBy?.trim().toLowerCase();
298
+ if (!normalized)
299
+ return undefined;
300
+ if (normalized === "fax")
301
+ return "fax";
302
+ if (normalized.startsWith("machine"))
303
+ return "voicemail";
304
+ return undefined;
305
+ }
306
+ /* v8 ignore start -- exact voicemail menu phrase permutations vary by carrier; bridge tests cover the voicemail-menu behavior path @preserve */
307
+ function isVoicemailMenuTranscript(text) {
308
+ const normalized = text.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
309
+ if (!normalized)
310
+ return false;
311
+ return normalized.includes("if you re satisfied with the message")
312
+ || normalized.includes("if you are satisfied with the message")
313
+ || (normalized.includes("press 1")
314
+ && normalized.includes("listen to your message")
315
+ && normalized.includes("erase")
316
+ && normalized.includes("rerecord"));
317
+ }
318
+ /* v8 ignore stop */
319
+ function decodeSafeSegment(input) {
320
+ try {
321
+ const decoded = decodeURIComponent(input);
322
+ if (!/^[A-Za-z0-9._-]+$/.test(decoded))
323
+ return null;
324
+ if (decoded === "." || decoded === "..")
325
+ return null;
326
+ return decoded;
327
+ }
328
+ catch {
329
+ return null;
330
+ }
331
+ }
332
+ function contentTypeForAudio(fileName) {
333
+ const ext = path.extname(fileName).toLowerCase();
334
+ if (ext === ".mp3")
335
+ return "audio/mpeg";
336
+ if (ext === ".wav")
337
+ return "audio/wav";
338
+ if (ext === ".pcm")
339
+ return "audio/pcm";
340
+ return "application/octet-stream";
341
+ }
342
+ function friendIdFromCaller(from, callSid) {
343
+ const phoneish = from.replace(/[^0-9A-Za-z]+/g, "");
344
+ return phoneish ? `twilio-${phoneish}` : `twilio-${safeSegment(callSid)}`;
345
+ }
346
+ function voiceFriendId(options, from, callSid) {
347
+ return options.defaultFriendId?.trim() || friendIdFromCaller(from, callSid);
348
+ }
349
+ function phoneIdentitySegment(input) {
350
+ const phoneish = input.replace(/[^0-9A-Za-z]+/g, "");
351
+ return phoneish || safeSegment(input);
352
+ }
353
+ function twilioPhoneVoiceSessionKey(options) {
354
+ const friendSegment = options.defaultFriendId?.trim()
355
+ ? safeSegment(options.defaultFriendId)
356
+ : options.from?.trim()
357
+ ? phoneIdentitySegment(options.from)
358
+ : "";
359
+ const lineSegment = options.to?.trim() ? phoneIdentitySegment(options.to) : "";
360
+ if (friendSegment && lineSegment)
361
+ return `twilio-phone-${friendSegment}-via-${lineSegment}`;
362
+ if (friendSegment)
363
+ return `twilio-phone-${friendSegment}`;
364
+ if (lineSegment)
365
+ return `twilio-phone-line-${lineSegment}`;
366
+ return `twilio-phone-${safeSegment(options.callSid ?? "incoming")}`;
367
+ }
368
+ function callConnectedPrompt(params) {
369
+ const from = params.From?.trim();
370
+ const to = params.To?.trim();
371
+ return [
372
+ "A Twilio phone voice call just connected.",
373
+ "This is the first audible turn in the call.",
374
+ from ? `Twilio caller ID: ${from}.` : "Twilio did not provide caller ID.",
375
+ to ? `Dialed line: ${to}.` : "Twilio did not provide the dialed line.",
376
+ "Respond through the voice channel as yourself. Greet the caller naturally and briefly, then invite them to speak.",
377
+ ].join("\n");
378
+ }
379
+ function outboundCallAnsweredPrompt(job, params) {
380
+ const from = params.From?.trim() || job.from;
381
+ const to = params.To?.trim() || job.to;
382
+ return [
383
+ "A Twilio outbound phone voice call was answered.",
384
+ "This is the first audible turn in a call I chose to place.",
385
+ `Call reason/context: ${job.reason.trim() || "No additional reason was recorded."}`,
386
+ to ? `Callee phone: ${to}.` : "Twilio did not provide the callee phone.",
387
+ from ? `Ouro phone line: ${from}.` : "Twilio did not provide the Ouro phone line.",
388
+ "Respond through the voice channel as yourself. Briefly greet them and state why you called. Keep this first turn short and conversational.",
389
+ ].join("\n");
390
+ }
391
+ function noSpeechPrompt() {
392
+ return [
393
+ "The last Twilio phone recording contained no intelligible speech.",
394
+ "The caller is still on the line.",
395
+ "Respond through the voice channel as yourself. Briefly ask them to try again or check whether they are there.",
396
+ ].join("\n");
397
+ }
398
+ function isNoSpeechTranscript(text) {
399
+ const normalized = text.trim().replace(/[.!?]+$/g, "").toUpperCase();
400
+ return normalized === "[BLANK_AUDIO]"
401
+ || normalized === "BLANK_AUDIO"
402
+ || normalized === "[NO_SPEECH]"
403
+ || normalized === "NO_SPEECH";
404
+ }
405
+ function isNoSpeechTranscriptionError(error) {
406
+ const normalized = errorMessage(error).toLowerCase();
407
+ return normalized.includes("empty whisper.cpp transcript")
408
+ || normalized.includes("voice transcript text is empty");
409
+ }
410
+ function buildNoSpeechTranscript(utteranceId) {
411
+ return (0, transcript_1.buildVoiceTranscript)({
412
+ utteranceId: `${utteranceId}-nospeech`,
413
+ text: noSpeechPrompt(),
414
+ source: "loopback",
415
+ });
416
+ }
417
+ /* v8 ignore start -- ws RawData variants are provider/runtime transport shapes; session tests cover valid and invalid stream behavior @preserve */
418
+ function parseTwilioMediaStreamMessage(raw) {
419
+ const text = Buffer.isBuffer(raw)
420
+ ? raw.toString("utf8")
421
+ : Array.isArray(raw)
422
+ ? Buffer.concat(raw).toString("utf8")
423
+ : Buffer.from(raw).toString("utf8");
424
+ try {
425
+ const parsed = JSON.parse(text);
426
+ return parsed && typeof parsed === "object" ? parsed : null;
427
+ }
428
+ catch { /* v8 ignore next -- invalid provider socket JSON is observed at the session boundary @preserve */
429
+ return null;
430
+ }
431
+ }
432
+ /* v8 ignore stop */
433
+ function stringField(value) {
434
+ return typeof value === "string" ? value.trim() : "";
435
+ }
436
+ /* v8 ignore start -- custom parameter shape validation is defensive around Twilio payloads; stream behavior tests cover the supported object shape @preserve */
437
+ function customParameter(start, name) {
438
+ const params = start?.customParameters;
439
+ if (!params || typeof params !== "object" || Array.isArray(params))
440
+ return "";
441
+ return stringField(params[name]);
442
+ }
443
+ /* v8 ignore stop */
444
+ /* v8 ignore start -- Twilio custom parameter field-combination branches are exercised through outbound route behavior; exact sparse-object permutations are not first-class logic @preserve */
445
+ function encodeVoiceCallAudioCustomParameter(request) {
446
+ if (!request)
447
+ return undefined;
448
+ const value = JSON.stringify({
449
+ ...(request.source ? { source: request.source } : {}),
450
+ ...(request.url ? { url: request.url } : {}),
451
+ ...(request.path ? { path: request.path } : {}),
452
+ ...(request.label ? { label: request.label } : {}),
453
+ ...(Number.isFinite(request.toneHz) ? { toneHz: request.toneHz } : {}),
454
+ ...(Number.isFinite(request.durationMs) ? { durationMs: request.durationMs } : {}),
455
+ });
456
+ return value.length <= 1_500 ? value : undefined;
457
+ }
458
+ function decodeVoiceCallAudioCustomParameter(value) {
459
+ if (!value.trim())
460
+ return undefined;
461
+ try {
462
+ const parsed = JSON.parse(value);
463
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
464
+ return undefined;
465
+ const record = parsed;
466
+ const source = record.source === "tone" || record.source === "url" || record.source === "file"
467
+ ? record.source
468
+ : undefined;
469
+ return {
470
+ ...(source ? { source } : {}),
471
+ ...(typeof record.url === "string" && record.url.trim() ? { url: record.url.trim() } : {}),
472
+ ...(typeof record.path === "string" && record.path.trim() ? { path: record.path.trim() } : {}),
473
+ ...(typeof record.label === "string" && record.label.trim() ? { label: record.label.trim() } : {}),
474
+ ...(typeof record.toneHz === "number" && Number.isFinite(record.toneHz) ? { toneHz: record.toneHz } : {}),
475
+ ...(typeof record.durationMs === "number" && Number.isFinite(record.durationMs) ? { durationMs: record.durationMs } : {}),
476
+ };
477
+ }
478
+ catch { /* v8 ignore next -- invalid Twilio custom parameter JSON is treated as absent audio metadata @preserve */
479
+ return undefined;
480
+ }
481
+ }
482
+ /* v8 ignore stop */
483
+ function mulawByteToPcm16(value) {
484
+ const decoded = (~value) & 0xff;
485
+ let sample = ((decoded & 0x0f) << 3) + 0x84;
486
+ sample <<= (decoded & 0x70) >> 4;
487
+ return (decoded & 0x80) ? 0x84 - sample : sample - 0x84;
488
+ }
489
+ /* v8 ignore start -- empty media frames are a defensive provider edge; barge-in behavior is covered through non-empty frame tests @preserve */
490
+ function mulawFrameRms(frame) {
491
+ if (frame.byteLength === 0)
492
+ return 0;
493
+ let sumSquares = 0;
494
+ for (const byte of frame) {
495
+ const sample = mulawByteToPcm16(byte);
496
+ sumSquares += sample * sample;
497
+ }
498
+ return Math.sqrt(sumSquares / frame.byteLength);
499
+ }
500
+ /* v8 ignore stop */
501
+ function pcm16WavHeader(dataByteLength, sampleRate) {
502
+ const header = Buffer.alloc(44);
503
+ header.write("RIFF", 0);
504
+ header.writeUInt32LE(36 + dataByteLength, 4);
505
+ header.write("WAVE", 8);
506
+ header.write("fmt ", 12);
507
+ header.writeUInt32LE(16, 16);
508
+ header.writeUInt16LE(1, 20);
509
+ header.writeUInt16LE(1, 22);
510
+ header.writeUInt32LE(sampleRate, 24);
511
+ header.writeUInt32LE(sampleRate * 2, 28);
512
+ header.writeUInt16LE(2, 32);
513
+ header.writeUInt16LE(16, 34);
514
+ header.write("data", 36);
515
+ header.writeUInt32LE(dataByteLength, 40);
516
+ return header;
517
+ }
518
+ function mulawFramesToPcm16Wav(frames, sampleRate = 16_000) {
519
+ const sampleCount = frames.reduce((sum, frame) => sum + frame.byteLength, 0) * 2;
520
+ const pcm = Buffer.alloc(sampleCount * 2);
521
+ let offset = 0;
522
+ for (const frame of frames) {
523
+ for (const byte of frame) {
524
+ const sample = mulawByteToPcm16(byte);
525
+ pcm.writeInt16LE(sample, offset);
526
+ offset += 2;
527
+ pcm.writeInt16LE(sample, offset);
528
+ offset += 2;
529
+ }
530
+ }
531
+ return Buffer.concat([pcm16WavHeader(pcm.byteLength, sampleRate), pcm]);
532
+ }
533
+ function frameLimitForMs(ms, frameMs = 20) {
534
+ return Math.max(1, Math.ceil(ms / frameMs));
535
+ }
536
+ async function transcribeRecordingOrNoSpeech(options) {
537
+ try {
538
+ const transcript = await options.transcriber.transcribe({
539
+ utteranceId: options.utteranceId,
540
+ audioPath: options.inputPath,
541
+ });
542
+ return isNoSpeechTranscript(transcript.text)
543
+ ? buildNoSpeechTranscript(options.utteranceId)
544
+ : transcript;
545
+ }
546
+ catch (error) {
547
+ if (isNoSpeechTranscriptionError(error)) {
548
+ return buildNoSpeechTranscript(options.utteranceId);
549
+ }
550
+ throw error;
551
+ }
552
+ }
553
+ /* v8 ignore start -- legacy cascade Media Streams socket loop is covered by bridge-level WebSocket tests; per-event socket/error permutations are transport-runtime edges @preserve */
554
+ class TwilioMediaStreamSession {
555
+ ws;
556
+ options;
557
+ mediaGreetingJobs;
558
+ lifecycle;
559
+ streamSid = "";
560
+ callSid = "media-stream";
561
+ direction = "";
562
+ outboundId = "";
563
+ from = "";
564
+ to = "";
565
+ friendId = "";
566
+ sessionKey = "";
567
+ callDir = "";
568
+ utteranceIndex = 0;
569
+ playbackGeneration = 0;
570
+ playbackActive = false;
571
+ closed = false;
572
+ inSpeech = false;
573
+ currentFrames = [];
574
+ preRollFrames = [];
575
+ currentVoicedFrames = 0;
576
+ currentSilenceFrames = 0;
577
+ currentWasBargeIn = false;
578
+ turnQueue = Promise.resolve();
579
+ hangupRequested = false;
580
+ hangupReason = "";
581
+ hangupFallbackTimer = null;
582
+ playbackBytesByGeneration = new Map();
583
+ speechRmsThreshold;
584
+ preRollLimitFrames = frameLimitForMs(200);
585
+ silenceEndFrames;
586
+ minSpeechFrames;
587
+ maxUtteranceFrames;
588
+ constructor(ws, options, mediaGreetingJobs, lifecycle) {
589
+ this.ws = ws;
590
+ this.options = options;
591
+ this.mediaGreetingJobs = mediaGreetingJobs;
592
+ this.lifecycle = lifecycle;
593
+ this.speechRmsThreshold = options.mediaSpeechRmsThreshold ?? exports.DEFAULT_TWILIO_MEDIA_SPEECH_RMS_THRESHOLD;
594
+ this.silenceEndFrames = frameLimitForMs(options.mediaSilenceEndMs ?? exports.DEFAULT_TWILIO_MEDIA_SILENCE_END_MS);
595
+ this.minSpeechFrames = frameLimitForMs(options.mediaMinSpeechMs ?? exports.DEFAULT_TWILIO_MEDIA_MIN_SPEECH_MS);
596
+ this.maxUtteranceFrames = frameLimitForMs(options.mediaMaxUtteranceMs ?? exports.DEFAULT_TWILIO_MEDIA_MAX_UTTERANCE_MS);
597
+ }
598
+ attach() {
599
+ this.ws.on("message", (raw) => this.handleRawMessage(raw));
600
+ this.ws.on("close", () => this.close());
601
+ this.ws.on("error", (error) => {
602
+ (0, runtime_1.emitNervesEvent)({
603
+ level: "error",
604
+ component: "senses",
605
+ event: "senses.voice_twilio_media_socket_error",
606
+ message: "Twilio Media Stream socket failed",
607
+ meta: { agentName: this.options.agentName, callSid: safeSegment(this.callSid), error: errorMessage(error) },
608
+ });
609
+ });
610
+ }
611
+ end() {
612
+ if (this.ws.readyState === ws_1.WebSocket.OPEN || this.ws.readyState === ws_1.WebSocket.CONNECTING) {
613
+ this.ws.close();
614
+ }
615
+ this.close();
616
+ }
617
+ handleRawMessage(raw) {
618
+ const message = parseTwilioMediaStreamMessage(raw);
619
+ if (!message) {
620
+ (0, runtime_1.emitNervesEvent)({
621
+ level: "warn",
622
+ component: "senses",
623
+ event: "senses.voice_twilio_media_message_rejected",
624
+ message: "Twilio Media Stream message was not valid JSON",
625
+ meta: { agentName: this.options.agentName, callSid: safeSegment(this.callSid) },
626
+ });
627
+ return;
628
+ }
629
+ const event = stringField(message.event);
630
+ if (event === "start") {
631
+ void this.handleStart(message.start);
632
+ return;
633
+ }
634
+ if (event === "media") {
635
+ this.handleMedia(message.media);
636
+ return;
637
+ }
638
+ if (event === "mark") {
639
+ this.handleMark(message.mark);
640
+ return;
641
+ }
642
+ if (event === "stop") {
643
+ this.close();
644
+ }
645
+ }
646
+ async handleStart(start) {
647
+ this.streamSid = stringField(start?.streamSid);
648
+ this.callSid = stringField(start?.callSid) || this.callSid;
649
+ const direction = customParameter(start, "Direction");
650
+ this.direction = direction;
651
+ this.outboundId = customParameter(start, "OutboundId");
652
+ const explicitFriendId = customParameter(start, "FriendId");
653
+ if (direction === "outbound") {
654
+ this.from = customParameter(start, "Remote") || customParameter(start, "To");
655
+ this.to = customParameter(start, "Line") || customParameter(start, "From");
656
+ }
657
+ else {
658
+ this.from = customParameter(start, "From");
659
+ this.to = customParameter(start, "To");
660
+ }
661
+ this.friendId = explicitFriendId || voiceFriendId(this.options, this.from, this.callSid);
662
+ this.sessionKey = twilioPhoneVoiceSessionKey({
663
+ defaultFriendId: explicitFriendId || this.options.defaultFriendId,
664
+ from: this.from,
665
+ to: this.to,
666
+ callSid: this.callSid,
667
+ });
668
+ this.lifecycle?.onIdentityChange?.(this, { callSid: this.callSid, outboundId: this.outboundId });
669
+ this.callDir = path.join(this.options.outputDir, safeSegment(this.callSid));
670
+ await fs.mkdir(this.callDir, { recursive: true });
671
+ (0, runtime_1.emitNervesEvent)({
672
+ component: "senses",
673
+ event: "senses.voice_twilio_media_start",
674
+ message: "Twilio Media Stream started",
675
+ meta: {
676
+ agentName: this.options.agentName,
677
+ callSid: safeSegment(this.callSid),
678
+ streamSid: safeSegment(this.streamSid || "stream"),
679
+ sessionKey: this.sessionKey,
680
+ },
681
+ });
682
+ const greetingJobId = customParameter(start, "GreetingJobId");
683
+ const greetingJob = greetingJobId
684
+ ? this.mediaGreetingJobs.get(safeSegment(this.callSid), greetingJobId)
685
+ : null;
686
+ if (greetingJob) {
687
+ this.enqueueGreetingJob(greetingJobId, greetingJob);
688
+ }
689
+ else {
690
+ this.enqueuePrompt({
691
+ utteranceId: `twilio-${safeSegment(this.callSid)}-connected`,
692
+ promptText: callConnectedPrompt({ From: this.from, To: this.to }),
693
+ wasBargeIn: false,
694
+ });
695
+ }
696
+ }
697
+ handleMedia(media) {
698
+ const payload = stringField(media?.payload);
699
+ if (!payload)
700
+ return;
701
+ const frame = Buffer.from(payload, "base64");
702
+ if (frame.byteLength === 0)
703
+ return;
704
+ const voiced = mulawFrameRms(frame) >= this.speechRmsThreshold;
705
+ const bargeIn = voiced && this.interruptPlayback();
706
+ if (!this.inSpeech) {
707
+ if (voiced) {
708
+ this.inSpeech = true;
709
+ this.currentFrames = [...this.preRollFrames, frame];
710
+ this.preRollFrames = [];
711
+ this.currentVoicedFrames = 1;
712
+ this.currentSilenceFrames = 0;
713
+ this.currentWasBargeIn = bargeIn;
714
+ }
715
+ else {
716
+ this.preRollFrames.push(frame);
717
+ if (this.preRollFrames.length > this.preRollLimitFrames) {
718
+ this.preRollFrames.shift();
719
+ }
720
+ }
721
+ return;
722
+ }
723
+ this.currentFrames.push(frame);
724
+ if (voiced) {
725
+ this.currentVoicedFrames += 1;
726
+ this.currentSilenceFrames = 0;
727
+ this.currentWasBargeIn = this.currentWasBargeIn || bargeIn;
728
+ }
729
+ else {
730
+ this.currentSilenceFrames += 1;
731
+ }
732
+ if (this.currentSilenceFrames >= this.silenceEndFrames || this.currentFrames.length >= this.maxUtteranceFrames) {
733
+ this.finishCurrentUtterance();
734
+ }
735
+ }
736
+ handleMark(mark) {
737
+ const name = stringField(mark?.name);
738
+ if (!name || !name.startsWith(`voice-${this.playbackGeneration}-`))
739
+ return;
740
+ this.playbackActive = false;
741
+ (0, runtime_1.emitNervesEvent)({
742
+ component: "senses",
743
+ event: "senses.voice_twilio_media_playback_mark",
744
+ message: "Twilio Media Stream playback mark reached",
745
+ meta: { agentName: this.options.agentName, callSid: safeSegment(this.callSid), mark: name },
746
+ });
747
+ this.completeHangupIfRequested("playback_mark");
748
+ }
749
+ close() {
750
+ if (this.closed)
751
+ return;
752
+ this.closed = true;
753
+ this.clearHangupFallback();
754
+ if (this.inSpeech)
755
+ this.finishCurrentUtterance();
756
+ this.playbackGeneration += 1;
757
+ this.playbackActive = false;
758
+ this.lifecycle?.onClose?.(this, { callSid: this.callSid, outboundId: this.outboundId });
759
+ (0, runtime_1.emitNervesEvent)({
760
+ component: "senses",
761
+ event: "senses.voice_twilio_media_stop",
762
+ message: "Twilio Media Stream stopped",
763
+ meta: { agentName: this.options.agentName, callSid: safeSegment(this.callSid) },
764
+ });
765
+ }
766
+ finishCurrentUtterance() {
767
+ const frames = this.currentFrames;
768
+ const voicedFrames = this.currentVoicedFrames;
769
+ const wasBargeIn = this.currentWasBargeIn;
770
+ this.inSpeech = false;
771
+ this.currentFrames = [];
772
+ this.currentVoicedFrames = 0;
773
+ this.currentSilenceFrames = 0;
774
+ this.currentWasBargeIn = false;
775
+ if (voicedFrames < this.minSpeechFrames)
776
+ return;
777
+ this.utteranceIndex += 1;
778
+ this.enqueueUtterance({
779
+ utteranceId: `twilio-${safeSegment(this.callSid)}-${this.utteranceIndex}`,
780
+ frames,
781
+ wasBargeIn,
782
+ });
783
+ }
784
+ enqueuePrompt(input) {
785
+ const transcript = (0, transcript_1.buildVoiceTranscript)({
786
+ utteranceId: input.utteranceId,
787
+ text: input.promptText,
788
+ source: "loopback",
789
+ });
790
+ this.enqueueTurn(transcript, input.wasBargeIn);
791
+ }
792
+ enqueueUtterance(utterance) {
793
+ this.turnQueue = this.turnQueue
794
+ .catch(() => undefined)
795
+ .then(() => this.processUtterance(utterance))
796
+ .catch((error) => {
797
+ (0, runtime_1.emitNervesEvent)({
798
+ level: "error",
799
+ component: "senses",
800
+ event: "senses.voice_twilio_media_turn_error",
801
+ message: "Twilio Media Stream voice turn failed",
802
+ meta: {
803
+ agentName: this.options.agentName,
804
+ callSid: safeSegment(this.callSid),
805
+ utteranceId: utterance.utteranceId,
806
+ error: errorMessage(error),
807
+ },
808
+ });
809
+ });
810
+ }
811
+ enqueueGreetingJob(jobId, job) {
812
+ this.turnQueue = this.turnQueue
813
+ .catch(() => undefined)
814
+ .then(() => this.streamGreetingJob(jobId, job))
815
+ .catch((error) => {
816
+ (0, runtime_1.emitNervesEvent)({
817
+ level: "error",
818
+ component: "senses",
819
+ event: "senses.voice_twilio_media_turn_error",
820
+ message: "Twilio Media Stream greeting playback failed",
821
+ meta: {
822
+ agentName: this.options.agentName,
823
+ callSid: safeSegment(this.callSid),
824
+ utteranceId: jobId,
825
+ error: errorMessage(error),
826
+ },
827
+ });
828
+ });
829
+ }
830
+ enqueueTurn(transcript, wasBargeIn) {
831
+ this.turnQueue = this.turnQueue
832
+ .catch(() => undefined)
833
+ .then(() => this.runTranscriptTurn(transcript, wasBargeIn))
834
+ .catch((error) => {
835
+ (0, runtime_1.emitNervesEvent)({
836
+ level: "error",
837
+ component: "senses",
838
+ event: "senses.voice_twilio_media_turn_error",
839
+ message: "Twilio Media Stream voice turn failed",
840
+ meta: {
841
+ agentName: this.options.agentName,
842
+ callSid: safeSegment(this.callSid),
843
+ utteranceId: transcript.utteranceId,
844
+ error: errorMessage(error),
845
+ },
846
+ });
847
+ });
848
+ }
849
+ async processUtterance(utterance) {
850
+ await fs.mkdir(this.callDir, { recursive: true });
851
+ const inputPath = path.join(this.callDir, `${safeSegment(utterance.utteranceId)}.wav`);
852
+ await fs.writeFile(inputPath, mulawFramesToPcm16Wav(utterance.frames));
853
+ const transcript = await transcribeRecordingOrNoSpeech({
854
+ transcriber: this.options.transcriber,
855
+ utteranceId: utterance.utteranceId,
856
+ inputPath,
857
+ });
858
+ if (this.direction === "outbound" && isVoicemailMenuTranscript(transcript.text)) {
859
+ if (this.outboundId) {
860
+ await updateTwilioOutboundCallJob(this.options.outputDir, this.outboundId, {
861
+ status: "voicemail",
862
+ answeredBy: "voicemail_menu",
863
+ transportCallSid: this.callSid,
864
+ }).catch(() => null);
865
+ }
866
+ (0, runtime_1.emitNervesEvent)({
867
+ component: "senses",
868
+ event: "senses.voice_twilio_voicemail_menu_detected",
869
+ message: "Twilio outbound voice stream detected voicemail menu",
870
+ meta: {
871
+ agentName: this.options.agentName,
872
+ callSid: safeSegment(this.callSid),
873
+ outboundId: safeSegment(this.outboundId || "unknown"),
874
+ },
875
+ });
876
+ this.ws.close();
877
+ this.close();
878
+ return;
879
+ }
880
+ const turnTranscript = utterance.wasBargeIn
881
+ ? (0, transcript_1.buildVoiceTranscript)({
882
+ utteranceId: transcript.utteranceId,
883
+ text: [
884
+ "The caller spoke while my previous voice output was still playing.",
885
+ "Treat this as an interruption or follow-up, acknowledge it first, and do not repeat the interrupted answer unless it is still useful.",
886
+ `Caller said: ${transcript.text}`,
887
+ ].join("\n"),
888
+ audioPath: transcript.audioPath ?? undefined,
889
+ language: transcript.language ?? undefined,
890
+ source: transcript.source,
891
+ })
892
+ : transcript;
893
+ await this.runTranscriptTurn(turnTranscript, utterance.wasBargeIn);
894
+ }
895
+ async runTranscriptTurn(transcript, wasBargeIn) {
896
+ if (this.closed || !this.streamSid)
897
+ return;
898
+ const generation = this.startPlayback();
899
+ const turn = await (0, turn_1.runVoiceLoopbackTurn)({
900
+ agentName: this.options.agentName,
901
+ friendId: this.friendId || voiceFriendId(this.options, this.from, this.callSid),
902
+ sessionKey: this.sessionKey || twilioPhoneVoiceSessionKey({
903
+ defaultFriendId: this.options.defaultFriendId,
904
+ from: this.from,
905
+ to: this.to,
906
+ callSid: this.callSid,
907
+ }),
908
+ transcript,
909
+ tts: this.options.tts,
910
+ runSenseTurn: this.options.runSenseTurn,
911
+ onAudioChunk: (chunk) => this.sendAudioChunk(chunk, generation),
912
+ voiceCall: {
913
+ requestEnd: (reason) => this.requestHangupAfterPlayback(reason),
914
+ playAudio: (request) => this.playPreparedAudio(request),
915
+ },
916
+ });
917
+ if (generation !== this.playbackGeneration || this.closed)
918
+ return;
919
+ const deliveries = deliveredSegments(turn);
920
+ if ((this.playbackBytesByGeneration.get(generation) ?? 0) === 0) {
921
+ for (const delivery of deliveries) {
922
+ this.sendAudioChunk(delivery.audio, generation);
923
+ }
924
+ }
925
+ if (deliveries.length === 0) {
926
+ this.playbackActive = false;
927
+ this.completeHangupIfRequested("no_playback");
928
+ return;
929
+ }
930
+ this.sendMark(generation, transcript.utteranceId);
931
+ if (this.hangupRequested)
932
+ this.armHangupFallback();
933
+ (0, runtime_1.emitNervesEvent)({
934
+ component: "senses",
935
+ event: "senses.voice_twilio_media_turn_end",
936
+ message: "Twilio Media Stream voice turn delivered playback",
937
+ meta: {
938
+ agentName: this.options.agentName,
939
+ callSid: safeSegment(this.callSid),
940
+ utteranceId: transcript.utteranceId,
941
+ bargeIn: String(wasBargeIn),
942
+ segmentCount: String(turn.speechSegments.length),
943
+ },
944
+ });
945
+ }
946
+ async streamGreetingJob(jobId, job) {
947
+ if (this.closed || !this.streamSid)
948
+ return;
949
+ const generation = this.startPlayback();
950
+ try {
951
+ for await (const chunk of job.stream()) {
952
+ if (generation !== this.playbackGeneration || this.closed)
953
+ return;
954
+ this.sendAudioChunk(chunk, generation);
955
+ }
956
+ }
957
+ catch (error) {
958
+ if (generation === this.playbackGeneration)
959
+ this.playbackActive = false;
960
+ (0, runtime_1.emitNervesEvent)({
961
+ level: "error",
962
+ component: "senses",
963
+ event: "senses.voice_twilio_media_greeting_error",
964
+ message: "Twilio Media Stream prebuffered greeting failed",
965
+ meta: { agentName: this.options.agentName, callSid: safeSegment(this.callSid), utteranceId: jobId, error: errorMessage(error) },
966
+ });
967
+ return;
968
+ }
969
+ if (generation !== this.playbackGeneration || this.closed)
970
+ return;
971
+ if ((this.playbackBytesByGeneration.get(generation) ?? 0) === 0) {
972
+ this.playbackActive = false;
973
+ return;
974
+ }
975
+ this.sendMark(generation, jobId);
976
+ (0, runtime_1.emitNervesEvent)({
977
+ component: "senses",
978
+ event: "senses.voice_twilio_media_greeting_end",
979
+ message: "Twilio Media Stream prebuffered greeting delivered playback",
980
+ meta: {
981
+ agentName: this.options.agentName,
982
+ callSid: safeSegment(this.callSid),
983
+ utteranceId: jobId,
984
+ byteLength: String(this.playbackBytesByGeneration.get(generation) ?? 0),
985
+ },
986
+ });
987
+ }
988
+ startPlayback() {
989
+ this.playbackGeneration += 1;
990
+ this.playbackActive = true;
991
+ this.clearHangupFallback();
992
+ return this.playbackGeneration;
993
+ }
994
+ sendAudioChunk(chunk, generation) {
995
+ if (this.closed || generation !== this.playbackGeneration || !this.streamSid || this.ws.readyState !== ws_1.WebSocket.OPEN)
996
+ return;
997
+ this.ws.send(JSON.stringify({
998
+ event: "media",
999
+ streamSid: this.streamSid,
1000
+ media: { payload: Buffer.from(chunk).toString("base64") },
1001
+ }));
1002
+ this.playbackBytesByGeneration.set(generation, (this.playbackBytesByGeneration.get(generation) ?? 0) + chunk.byteLength);
1003
+ }
1004
+ sendMark(generation, utteranceId) {
1005
+ if (this.closed || generation !== this.playbackGeneration || !this.streamSid || this.ws.readyState !== ws_1.WebSocket.OPEN)
1006
+ return;
1007
+ this.ws.send(JSON.stringify({
1008
+ event: "mark",
1009
+ streamSid: this.streamSid,
1010
+ mark: { name: `voice-${generation}-${safeSegment(utteranceId)}` },
1011
+ }));
1012
+ }
1013
+ async playPreparedAudio(request) {
1014
+ const prepared = await (0, audio_playback_1.prepareVoiceCallAudio)(request, {
1015
+ agentRoot: this.options.agentRoot ?? (0, identity_1.getAgentRoot)(this.options.agentName),
1016
+ });
1017
+ const generation = this.startPlayback();
1018
+ for (let offset = 0; offset < prepared.audio.byteLength; offset += 160) {
1019
+ if (this.closed || generation !== this.playbackGeneration)
1020
+ break;
1021
+ this.sendAudioChunk(prepared.audio.subarray(offset, offset + 160), generation);
1022
+ await delay(20);
1023
+ }
1024
+ if (!this.closed && generation === this.playbackGeneration) {
1025
+ this.sendMark(generation, `audio-${prepared.label}`);
1026
+ }
1027
+ (0, runtime_1.emitNervesEvent)({
1028
+ component: "senses",
1029
+ event: "senses.voice_twilio_media_tool_audio_played",
1030
+ message: "played tool-requested audio into Twilio Media Stream call",
1031
+ meta: {
1032
+ agentName: this.options.agentName,
1033
+ callSid: safeSegment(this.callSid),
1034
+ label: prepared.label,
1035
+ durationMs: String(prepared.durationMs),
1036
+ },
1037
+ });
1038
+ return { label: prepared.label, durationMs: prepared.durationMs };
1039
+ }
1040
+ interruptPlayback() {
1041
+ if (!this.playbackActive || !this.streamSid || this.ws.readyState !== ws_1.WebSocket.OPEN)
1042
+ return false;
1043
+ this.cancelPendingHangup("barge_in");
1044
+ this.playbackGeneration += 1;
1045
+ this.playbackActive = false;
1046
+ this.ws.send(JSON.stringify({ event: "clear", streamSid: this.streamSid }));
1047
+ (0, runtime_1.emitNervesEvent)({
1048
+ component: "senses",
1049
+ event: "senses.voice_twilio_media_barge_in",
1050
+ message: "caller interrupted Twilio Media Stream playback",
1051
+ meta: { agentName: this.options.agentName, callSid: safeSegment(this.callSid) },
1052
+ });
1053
+ return true;
1054
+ }
1055
+ requestHangupAfterPlayback(reason) {
1056
+ if (this.closed)
1057
+ return;
1058
+ this.hangupRequested = true;
1059
+ this.hangupReason = typeof reason === "string" ? reason : "";
1060
+ (0, runtime_1.emitNervesEvent)({
1061
+ component: "senses",
1062
+ event: "senses.voice_twilio_media_hangup_requested",
1063
+ message: "agent requested Twilio Media Stream hangup",
1064
+ meta: {
1065
+ agentName: this.options.agentName,
1066
+ callSid: safeSegment(this.callSid),
1067
+ reasonLength: String(this.hangupReason.length),
1068
+ playbackActive: String(this.playbackActive),
1069
+ },
1070
+ });
1071
+ if (!this.playbackActive)
1072
+ this.armHangupFallback();
1073
+ }
1074
+ completeHangupIfRequested(trigger) {
1075
+ if (!this.hangupRequested || this.closed)
1076
+ return;
1077
+ (0, runtime_1.emitNervesEvent)({
1078
+ component: "senses",
1079
+ event: "senses.voice_twilio_media_hangup_end",
1080
+ message: "ending Twilio Media Stream after agent hangup request",
1081
+ meta: { agentName: this.options.agentName, callSid: safeSegment(this.callSid), trigger },
1082
+ });
1083
+ this.end();
1084
+ }
1085
+ cancelPendingHangup(trigger) {
1086
+ if (!this.hangupRequested)
1087
+ return;
1088
+ this.hangupRequested = false;
1089
+ this.hangupReason = "";
1090
+ this.clearHangupFallback();
1091
+ (0, runtime_1.emitNervesEvent)({
1092
+ component: "senses",
1093
+ event: "senses.voice_twilio_media_hangup_cancelled",
1094
+ message: "cancelled Twilio Media Stream hangup request",
1095
+ meta: { agentName: this.options.agentName, callSid: safeSegment(this.callSid), trigger },
1096
+ });
1097
+ }
1098
+ armHangupFallback() {
1099
+ if (!this.hangupRequested || this.closed || this.hangupFallbackTimer)
1100
+ return;
1101
+ this.hangupFallbackTimer = setTimeout(() => {
1102
+ this.hangupFallbackTimer = null;
1103
+ this.completeHangupIfRequested("fallback_timer");
1104
+ }, TWILIO_MEDIA_HANGUP_FALLBACK_MS);
1105
+ this.hangupFallbackTimer.unref?.();
1106
+ }
1107
+ clearHangupFallback() {
1108
+ if (!this.hangupFallbackTimer)
1109
+ return;
1110
+ clearTimeout(this.hangupFallbackTimer);
1111
+ this.hangupFallbackTimer = null;
1112
+ }
1113
+ }
1114
+ /* v8 ignore stop */
1115
+ const REALTIME_TOOL_FLOW_NAMES = new Set(["speak", "settle", "rest", "observe", "ponder"]);
1116
+ const OPENAI_REALTIME_DEFAULT_MODEL = "gpt-realtime-2";
1117
+ const OPENAI_REALTIME_DEFAULT_VOICE = "cedar";
1118
+ const OPENAI_REALTIME_DEFAULT_TRANSCRIPTION_MODEL = "gpt-realtime-whisper";
1119
+ const OPENAI_REALTIME_BOOTSTRAP_TIMEOUT_MS = 250;
1120
+ const OPENAI_REALTIME_PCMS_BYTES_PER_MS = 8;
1121
+ const OPENAI_REALTIME_DEFAULT_NOISE_REDUCTION = "near_field";
1122
+ const OPENAI_REALTIME_DEFAULT_VAD_THRESHOLD = 0.68;
1123
+ const OPENAI_REALTIME_DEFAULT_VAD_PREFIX_PADDING_MS = 220;
1124
+ const OPENAI_REALTIME_DEFAULT_VAD_SILENCE_DURATION_MS = 320;
1125
+ const OPENAI_REALTIME_DEFAULT_VAD_IDLE_TIMEOUT_MS = 15_000;
1126
+ const OPENAI_REALTIME_MAX_OUTPUT_TOKENS = 220;
1127
+ const OPENAI_REALTIME_BARGE_IN_MIN_SPEECH_MS = 160;
1128
+ const OPENAI_REALTIME_BARGE_IN_RMS_THRESHOLD = 900;
1129
+ const OPENAI_REALTIME_MIN_VOICE_SPEED = 0.25;
1130
+ const OPENAI_REALTIME_MAX_VOICE_SPEED = 1.5;
1131
+ const OPENAI_REALTIME_RESPONSE_CREATE_GRACE_MS = 50;
1132
+ const OPENAI_SIP_OUTBOUND_AMD_GREETING_TIMEOUT_MS = 10_000;
1133
+ const OPENAI_SIP_UNSUPPORTED_TOOL_NAMES = new Set();
1134
+ const OPENAI_SIP_DEFAULT_API_BASE_URL = "https://api.openai.com/v1";
1135
+ const OPENAI_SIP_DEFAULT_WEBSOCKET_BASE_URL = "wss://api.openai.com/v1/realtime";
1136
+ /* v8 ignore start -- OpenAI Realtime/SIP provider adapter helpers are exercised through bridge-level SIP/Realtimes tests; branch permutations are provider-shape fallbacks @preserve */
1137
+ function openAIRealtimeWebSocketUrl(options) {
1138
+ return options.websocketUrl?.trim()
1139
+ || `wss://api.openai.com/v1/realtime?model=${encodeURIComponent(options.model?.trim() || OPENAI_REALTIME_DEFAULT_MODEL)}`;
1140
+ }
1141
+ function computeOpenAIWebhookSignature(input) {
1142
+ const secret = input.secret.startsWith("whsec_")
1143
+ ? Buffer.from(input.secret.slice("whsec_".length), "base64")
1144
+ : Buffer.from(input.secret, "utf8");
1145
+ const signedPayload = input.webhookId
1146
+ ? `${input.webhookId}.${input.timestamp}.${input.payload}`
1147
+ : `${input.timestamp}.${input.payload}`;
1148
+ return crypto.createHmac("sha256", secret).update(signedPayload).digest("base64");
1149
+ }
1150
+ function validateOpenAIWebhookSignature(input) {
1151
+ const secret = input.secret.trim();
1152
+ if (!secret)
1153
+ return false;
1154
+ const timestamp = headerValue(input.headers, "webhook-timestamp");
1155
+ const signatureHeader = headerValue(input.headers, "webhook-signature");
1156
+ const webhookId = headerValue(input.headers, "webhook-id");
1157
+ if (!timestamp || !signatureHeader)
1158
+ return false;
1159
+ const timestampSeconds = Number.parseInt(timestamp, 10);
1160
+ if (!Number.isFinite(timestampSeconds))
1161
+ return false;
1162
+ const nowSeconds = input.nowSeconds ?? Math.floor(Date.now() / 1_000);
1163
+ const toleranceSeconds = input.toleranceSeconds ?? 300;
1164
+ if (nowSeconds - timestampSeconds > toleranceSeconds)
1165
+ return false;
1166
+ if (timestampSeconds - nowSeconds > toleranceSeconds)
1167
+ return false;
1168
+ const expected = Buffer.from(computeOpenAIWebhookSignature({
1169
+ secret,
1170
+ webhookId: webhookId || undefined,
1171
+ timestamp,
1172
+ payload: input.payload,
1173
+ }));
1174
+ for (const candidate of signatureHeader.split(" ")) {
1175
+ const raw = candidate.trim();
1176
+ if (!raw)
1177
+ continue;
1178
+ const signature = Buffer.from(raw.startsWith("v1,") ? raw.slice(3) : raw);
1179
+ if (signature.length === expected.length && crypto.timingSafeEqual(signature, expected))
1180
+ return true;
1181
+ }
1182
+ return false;
1183
+ }
1184
+ function openAISipCallActionUrl(options, callId, action) {
1185
+ const base = (options.apiBaseUrl?.trim() || OPENAI_SIP_DEFAULT_API_BASE_URL).replace(/\/+$/, "");
1186
+ return `${base}/realtime/calls/${encodeURIComponent(callId)}/${action}`;
1187
+ }
1188
+ function openAISipControlWebSocketUrl(options, callId) {
1189
+ const url = new URL(options.websocketBaseUrl?.trim() || OPENAI_SIP_DEFAULT_WEBSOCKET_BASE_URL);
1190
+ url.searchParams.set("call_id", callId);
1191
+ return url.toString();
1192
+ }
1193
+ function parseOpenAISipWebhookEvent(rawBody) {
1194
+ try {
1195
+ const parsed = JSON.parse(rawBody);
1196
+ return parsed && typeof parsed === "object" ? parsed : null;
1197
+ }
1198
+ catch {
1199
+ return null;
1200
+ }
1201
+ }
1202
+ function openAISipHeaders(value) {
1203
+ if (!Array.isArray(value))
1204
+ return [];
1205
+ return value.filter((item) => item && typeof item === "object");
1206
+ }
1207
+ function openAISipHeaderValue(headers, name) {
1208
+ const wanted = name.toLowerCase();
1209
+ for (const header of headers) {
1210
+ if (stringField(header.name).toLowerCase() === wanted)
1211
+ return stringField(header.value);
1212
+ }
1213
+ return "";
1214
+ }
1215
+ function phoneFromSipHeader(value) {
1216
+ const trimmed = value.trim();
1217
+ if (!trimmed)
1218
+ return "";
1219
+ const tel = trimmed.match(/tel:([^>;]+)/i)?.[1];
1220
+ if (tel)
1221
+ return tel.trim();
1222
+ const sip = trimmed.match(/sip:([^@>;]+)/i)?.[1];
1223
+ if (sip)
1224
+ return sip.trim();
1225
+ const bracketed = trimmed.match(/<([^>]+)>/)?.[1];
1226
+ return bracketed?.trim() || trimmed;
1227
+ }
1228
+ function openAISipCallMetadata(event) {
1229
+ const data = event.data;
1230
+ if (!data || typeof data !== "object")
1231
+ return null;
1232
+ const callId = stringField(data.call_id);
1233
+ if (!callId)
1234
+ return null;
1235
+ const headers = openAISipHeaders(data.sip_headers);
1236
+ const from = openAISipHeaderValue(headers, "X-Ouro-From") || phoneFromSipHeader(openAISipHeaderValue(headers, "From"));
1237
+ const to = openAISipHeaderValue(headers, "X-Ouro-To") || phoneFromSipHeader(openAISipHeaderValue(headers, "To"));
1238
+ const friendId = openAISipHeaderValue(headers, "X-Ouro-Friend-Id");
1239
+ return {
1240
+ callId,
1241
+ from,
1242
+ to,
1243
+ direction: openAISipHeaderValue(headers, "X-Ouro-Direction") || "inbound",
1244
+ outboundId: openAISipHeaderValue(headers, "X-Ouro-Outbound-Id"),
1245
+ reason: openAISipHeaderValue(headers, "X-Ouro-Reason"),
1246
+ friendId,
1247
+ };
1248
+ }
1249
+ function openAISipCallConnectedPrompt(metadata, voiceStyle) {
1250
+ const styleLine = voiceStyle?.trim()
1251
+ ? `Phone voice target for this first turn: ${voiceStyle.trim()}`
1252
+ : "";
1253
+ if (metadata.direction === "outbound") {
1254
+ return [
1255
+ "An outbound phone voice call just connected over OpenAI SIP.",
1256
+ "This is the first audible turn in a call I chose to place.",
1257
+ styleLine,
1258
+ `Call reason/context: ${metadata.reason.trim() || "No additional reason was recorded."}`,
1259
+ metadata.from ? `Callee phone: ${metadata.from}.` : "The callee phone number was not provided.",
1260
+ metadata.to ? `Ouro phone line: ${metadata.to}.` : "The Ouro phone line was not provided.",
1261
+ "Respond through the voice channel as yourself. Briefly greet them and state why you called. Keep this first turn short and conversational.",
1262
+ ].filter(Boolean).join("\n");
1263
+ }
1264
+ return [
1265
+ "A phone voice call just connected over OpenAI SIP.",
1266
+ "This is the first audible turn in the call.",
1267
+ styleLine,
1268
+ metadata.from ? `Caller phone: ${metadata.from}.` : "Caller phone was not provided.",
1269
+ metadata.to ? `Dialed line: ${metadata.to}.` : "Dialed line was not provided.",
1270
+ "Respond through the voice channel as yourself. Greet the caller naturally and briefly, then invite them to speak.",
1271
+ ].filter(Boolean).join("\n");
1272
+ }
1273
+ function openAISipResponseHeaders(params, extra = {}) {
1274
+ return {
1275
+ "X-Ouro-Agent": params.Agent,
1276
+ "X-Ouro-Direction": params.Direction,
1277
+ "X-Ouro-From": params.From,
1278
+ "X-Ouro-To": params.To,
1279
+ ...extra,
1280
+ };
1281
+ }
1282
+ function boundedNumber(value, min, max) {
1283
+ if (value === undefined || !Number.isFinite(value))
1284
+ return undefined;
1285
+ return Math.min(max, Math.max(min, value));
1286
+ }
1287
+ function boundedInteger(value, min, max) {
1288
+ const bounded = boundedNumber(value, min, max);
1289
+ return bounded === undefined ? undefined : Math.round(bounded);
1290
+ }
1291
+ function realtimeNoiseReductionConfig(realtime) {
1292
+ const mode = realtime.noiseReduction ?? OPENAI_REALTIME_DEFAULT_NOISE_REDUCTION;
1293
+ if (mode === "none")
1294
+ return null;
1295
+ return { type: mode };
1296
+ }
1297
+ function realtimeTurnDetectionConfig(realtime, overrides = {}) {
1298
+ const turnDetection = realtime.turnDetection;
1299
+ const createResponse = overrides.createResponse ?? turnDetection?.createResponse ?? true;
1300
+ const interruptResponse = overrides.interruptResponse ?? turnDetection?.interruptResponse ?? false;
1301
+ if (turnDetection?.mode === "semantic_vad") {
1302
+ return {
1303
+ type: "semantic_vad",
1304
+ create_response: createResponse,
1305
+ interrupt_response: interruptResponse,
1306
+ eagerness: turnDetection.eagerness ?? "medium",
1307
+ };
1308
+ }
1309
+ return {
1310
+ type: "server_vad",
1311
+ create_response: createResponse,
1312
+ interrupt_response: interruptResponse,
1313
+ threshold: boundedNumber(turnDetection?.threshold, 0, 1) ?? OPENAI_REALTIME_DEFAULT_VAD_THRESHOLD,
1314
+ prefix_padding_ms: boundedInteger(turnDetection?.prefixPaddingMs, 0, 2_000) ?? OPENAI_REALTIME_DEFAULT_VAD_PREFIX_PADDING_MS,
1315
+ silence_duration_ms: boundedInteger(turnDetection?.silenceDurationMs, 100, 2_000) ?? OPENAI_REALTIME_DEFAULT_VAD_SILENCE_DURATION_MS,
1316
+ idle_timeout_ms: boundedInteger(turnDetection?.idleTimeoutMs, 5_000, 30_000) ?? OPENAI_REALTIME_DEFAULT_VAD_IDLE_TIMEOUT_MS,
1317
+ };
1318
+ }
1319
+ function realtimeVoiceSpeed(realtime) {
1320
+ return boundedNumber(realtime.voiceSpeed, OPENAI_REALTIME_MIN_VOICE_SPEED, OPENAI_REALTIME_MAX_VOICE_SPEED);
1321
+ }
1322
+ function realtimeOutputAudioConfig(realtime, format) {
1323
+ const speed = realtimeVoiceSpeed(realtime);
1324
+ return {
1325
+ ...(format ? { format } : {}),
1326
+ voice: realtime.voice?.trim() || OPENAI_REALTIME_DEFAULT_VOICE,
1327
+ ...(speed === undefined ? {} : { speed }),
1328
+ };
1329
+ }
1330
+ function realtimeToolsFromChatTools(tools, excludedToolNames = new Set()) {
1331
+ return tools
1332
+ .filter((tool) => !REALTIME_TOOL_FLOW_NAMES.has(tool.function.name) && !excludedToolNames.has(tool.function.name))
1333
+ .map((tool) => ({
1334
+ type: "function",
1335
+ name: tool.function.name,
1336
+ ...(tool.function.description ? { description: tool.function.description } : {}),
1337
+ parameters: tool.function.parameters ?? { type: "object", properties: {} },
1338
+ }));
1339
+ }
1340
+ function parseToolArguments(raw) {
1341
+ if (!raw.trim())
1342
+ return {};
1343
+ const parsed = JSON.parse(raw);
1344
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
1345
+ return {};
1346
+ const args = {};
1347
+ for (const [key, value] of Object.entries(parsed)) {
1348
+ if (typeof value === "string") {
1349
+ args[key] = value;
1350
+ }
1351
+ else if (value === undefined) {
1352
+ args[key] = "";
1353
+ }
1354
+ else {
1355
+ args[key] = JSON.stringify(value);
1356
+ }
1357
+ }
1358
+ return args;
1359
+ }
1360
+ function transcriptMessageText(messages) {
1361
+ const recent = messages
1362
+ .filter((message) => message.role === "user" || message.role === "assistant")
1363
+ .slice(-8)
1364
+ .map((message) => {
1365
+ const content = typeof message.content === "string" ? message.content.trim() : "";
1366
+ return content ? `${message.role}: ${content}` : "";
1367
+ })
1368
+ .filter(Boolean);
1369
+ if (recent.length === 0)
1370
+ return "";
1371
+ return [
1372
+ "Recent durable voice transcript for this same voice session:",
1373
+ ...recent,
1374
+ ].join("\n");
1375
+ }
1376
+ function looksLikeShortHumanPhoneGreeting(transcript) {
1377
+ const normalized = transcript.trim().toLowerCase().replace(/[^\p{L}\p{N}\s']/gu, " ");
1378
+ if (!normalized)
1379
+ return false;
1380
+ const words = normalized.split(/\s+/).filter(Boolean);
1381
+ if (words.length === 0 || words.length > 6)
1382
+ return false;
1383
+ const text = words.join(" ");
1384
+ if (/\b(voicemail|mailbox|unavailable|leave|message|reached|record)\b/.test(text))
1385
+ return false;
1386
+ return /^(hi|hello|hey|yo|yes|yeah|yep|ari|slugger)(\b|$)/.test(text);
1387
+ }
1388
+ async function readOptionalText(filePath, maxChars) {
1389
+ try {
1390
+ const text = (await fs.readFile(filePath, "utf8")).trim();
1391
+ if (text.length <= maxChars)
1392
+ return text;
1393
+ return `${text.slice(0, maxChars).trim()}\n[truncated for low-latency voice]`;
1394
+ }
1395
+ catch {
1396
+ return "";
1397
+ }
1398
+ }
1399
+ async function buildRealtimeVoiceInstructions(options) {
1400
+ const psycheDir = path.join(options.agentRoot, "psyche");
1401
+ const [soul, identity, tacit] = await Promise.all([
1402
+ readOptionalText(path.join(psycheDir, "SOUL.md"), 1_600),
1403
+ readOptionalText(path.join(psycheDir, "IDENTITY.md"), 3_200),
1404
+ readOptionalText(path.join(psycheDir, "TACIT.md"), 1_400),
1405
+ ]);
1406
+ return [
1407
+ `You are ${options.agentName} in the live Voice sense.`,
1408
+ "This is the same agent identity as every other Ouro surface. Voice is not a reduced or alternate self.",
1409
+ `Current native Realtime provider config for this call: model=${options.realtimeModel?.trim() || OPENAI_REALTIME_DEFAULT_MODEL}, voice=${options.realtimeVoice?.trim() || OPENAI_REALTIME_DEFAULT_VOICE}${options.realtimeVoiceSpeed === undefined ? "" : `, speed=${options.realtimeVoiceSpeed}`}.`,
1410
+ options.realtimeVoiceStyle?.trim()
1411
+ ? `Phone voice target: ${options.realtimeVoiceStyle.trim()}`
1412
+ : "",
1413
+ "Speak as yourself through live audio. Follow voice/style preferences from identity notes; do not say you lack identity, preferences, or agency because the provider voice is configured by the transport.",
1414
+ "Audio is synchronous. Default to one short sentence. Use two short sentences only when needed. Do not use markdown, lists, or long explanations unless the caller explicitly asks.",
1415
+ "If the caller interrupts, stop the older path and answer the newest thing first.",
1416
+ "If the caller says they are counting, measuring latency, testing lag, waiting, or wants you quiet, say at most 'got it' and then stay silent until they ask or say something that needs an answer.",
1417
+ "Use tools for outside facts or side effects. While a tool is running, give at most one tiny preamble, then summarize the result compactly when it returns.",
1418
+ options.audioToolMode === "none"
1419
+ ? "This voice lane cannot inject non-speech audio. If the caller asks for a tone, clip, or sample, answer transparently and offer a spoken alternative."
1420
+ : options.audioToolMode === "realtime-cue"
1421
+ ? "If the caller asks for a beep or tone, use voice_play_audio with source=tone. This direct SIP lane can ask Realtime to render short audio cues, but arbitrary URL/file clip bytes still require a media bridge; if the tool reports that limitation, explain it briefly."
1422
+ : "If the caller asks to hear audio, a tone, a sample, or a clip, use voice_play_audio; people on phone calls can do more than talk.",
1423
+ "If the caller is done, asks to hang up, or you need to end the call, say a brief natural goodbye first, then call voice_end_call. After voice_end_call, do not say anything else.",
1424
+ soul ? `# SOUL\n${soul}` : "",
1425
+ identity ? `# IDENTITY\n${identity}` : "",
1426
+ tacit ? `# TACIT\n${tacit}` : "",
1427
+ options.priorTranscript,
1428
+ ].filter(Boolean).join("\n\n");
1429
+ }
1430
+ function realtimeBootstrapInstructions(agentName, voiceStyle) {
1431
+ return [
1432
+ `You are ${agentName} on a live phone call.`,
1433
+ voiceStyle?.trim() ? `Phone voice target: ${voiceStyle.trim()}.` : "",
1434
+ "Speak naturally through live audio. Keep turns very short, answer quickly, and accept interruptions immediately.",
1435
+ "If the caller is done or asks to end the call, say a brief goodbye and call voice_end_call.",
1436
+ ].filter(Boolean).join(" ");
1437
+ }
1438
+ function realtimeBootstrapTools() {
1439
+ return realtimeToolsFromChatTools((0, tools_1.getToolsForChannel)((0, channel_1.getChannelCapabilities)("voice")));
1440
+ }
1441
+ function timeoutAfter(ms) {
1442
+ return new Promise((resolve) => {
1443
+ const timer = setTimeout(() => resolve(undefined), ms);
1444
+ timer.unref?.();
1445
+ });
1446
+ }
1447
+ function delay(ms) {
1448
+ return new Promise((resolve) => {
1449
+ const timer = setTimeout(() => resolve(), ms);
1450
+ timer.unref?.();
1451
+ });
1452
+ }
1453
+ function numberField(value) {
1454
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
1455
+ }
1456
+ function realtimeResponseId(event) {
1457
+ const direct = stringField(event.response_id);
1458
+ if (direct)
1459
+ return direct;
1460
+ const response = event.response;
1461
+ if (!response || typeof response !== "object" || Array.isArray(response))
1462
+ return "";
1463
+ return stringField(response.id);
1464
+ }
1465
+ function pcmuPayloadDurationMs(payload) {
1466
+ const byteLength = Buffer.from(payload, "base64").byteLength;
1467
+ if (byteLength <= 0)
1468
+ return 0;
1469
+ return Math.max(1, Math.round(byteLength / OPENAI_REALTIME_PCMS_BYTES_PER_MS));
1470
+ }
1471
+ /* v8 ignore stop */
1472
+ /* v8 ignore start -- Twilio Media Streams Realtime bridge is a fallback transport; direct SIP is the primary low-latency path and WebSocket integration tests cover representative behavior @preserve */
1473
+ class TwilioOpenAIRealtimeMediaStreamSession {
1474
+ ws;
1475
+ options;
1476
+ lifecycle;
1477
+ streamSid = "";
1478
+ callSid = "media-stream";
1479
+ direction = "";
1480
+ outboundId = "";
1481
+ outboundReason = "";
1482
+ from = "";
1483
+ to = "";
1484
+ friendId = "";
1485
+ sessionKey = "";
1486
+ sessionPath = "";
1487
+ closed = false;
1488
+ openaiReady = false;
1489
+ greetingSent = false;
1490
+ hangupRequested = false;
1491
+ pendingAudioPayloads = [];
1492
+ openaiWs = null;
1493
+ toolContext;
1494
+ sessionMessages = [];
1495
+ playbackState;
1496
+ playbackMarkIndex = 0;
1497
+ playbackMarks = new Map();
1498
+ toolResponses = new Map();
1499
+ completedRealtimeResponseIds = new Set();
1500
+ activeRealtimeResponseId = null;
1501
+ pendingRealtimeResponse = null;
1502
+ pendingRealtimeResponseTimer = null;
1503
+ responseCreateHoldUntilMs = 0;
1504
+ initialAudio;
1505
+ initialAudioPlayed = false;
1506
+ callerBargeInSpeechMs = 0;
1507
+ lastCallerBargeInSpeechAt = 0;
1508
+ constructor(ws, options, lifecycle) {
1509
+ this.ws = ws;
1510
+ this.options = options;
1511
+ this.lifecycle = lifecycle;
1512
+ }
1513
+ attach() {
1514
+ this.ws.on("message", (raw) => this.handleRawMessage(raw));
1515
+ this.ws.on("close", () => this.close());
1516
+ this.ws.on("error", (error) => {
1517
+ (0, runtime_1.emitNervesEvent)({
1518
+ level: "error",
1519
+ component: "senses",
1520
+ event: "senses.voice_twilio_realtime_socket_error",
1521
+ message: "Twilio OpenAI Realtime socket failed",
1522
+ meta: { agentName: this.options.agentName, callSid: safeSegment(this.callSid), error: errorMessage(error) },
1523
+ });
1524
+ });
1525
+ }
1526
+ end() {
1527
+ if (this.ws.readyState === ws_1.WebSocket.OPEN || this.ws.readyState === ws_1.WebSocket.CONNECTING) {
1528
+ this.ws.close();
1529
+ }
1530
+ this.close();
1531
+ }
1532
+ handleRawMessage(raw) {
1533
+ const message = parseTwilioMediaStreamMessage(raw);
1534
+ if (!message) {
1535
+ (0, runtime_1.emitNervesEvent)({
1536
+ level: "warn",
1537
+ component: "senses",
1538
+ event: "senses.voice_twilio_realtime_message_rejected",
1539
+ message: "Twilio OpenAI Realtime message was not valid JSON",
1540
+ meta: { agentName: this.options.agentName, callSid: safeSegment(this.callSid) },
1541
+ });
1542
+ return;
1543
+ }
1544
+ const event = stringField(message.event);
1545
+ if (event === "start") {
1546
+ void this.handleStart(message.start);
1547
+ return;
1548
+ }
1549
+ if (event === "media") {
1550
+ this.handleMedia(message.media);
1551
+ return;
1552
+ }
1553
+ if (event === "mark") {
1554
+ this.handleMark(message.mark);
1555
+ return;
1556
+ }
1557
+ if (event === "stop") {
1558
+ this.close();
1559
+ }
1560
+ }
1561
+ async handleStart(start) {
1562
+ this.streamSid = stringField(start?.streamSid);
1563
+ this.callSid = stringField(start?.callSid) || this.callSid;
1564
+ this.direction = customParameter(start, "Direction");
1565
+ this.outboundId = customParameter(start, "OutboundId");
1566
+ this.outboundReason = customParameter(start, "Reason");
1567
+ this.initialAudio = decodeVoiceCallAudioCustomParameter(customParameter(start, "InitialAudio"));
1568
+ const explicitFriendId = customParameter(start, "FriendId");
1569
+ if (this.direction === "outbound") {
1570
+ this.from = customParameter(start, "Remote") || customParameter(start, "To");
1571
+ this.to = customParameter(start, "Line") || customParameter(start, "From");
1572
+ }
1573
+ else {
1574
+ this.from = customParameter(start, "From");
1575
+ this.to = customParameter(start, "To");
1576
+ }
1577
+ this.friendId = explicitFriendId || voiceFriendId(this.options, this.from, this.callSid);
1578
+ this.sessionKey = twilioPhoneVoiceSessionKey({
1579
+ defaultFriendId: explicitFriendId || this.options.defaultFriendId,
1580
+ from: this.from,
1581
+ to: this.to,
1582
+ callSid: this.callSid,
1583
+ });
1584
+ this.lifecycle?.onIdentityChange?.(this, { callSid: this.callSid, outboundId: this.outboundId });
1585
+ (0, runtime_1.emitNervesEvent)({
1586
+ component: "senses",
1587
+ event: "senses.voice_twilio_realtime_start",
1588
+ message: "Twilio OpenAI Realtime stream started",
1589
+ meta: {
1590
+ agentName: this.options.agentName,
1591
+ callSid: safeSegment(this.callSid),
1592
+ sessionKey: this.sessionKey,
1593
+ },
1594
+ });
1595
+ try {
1596
+ await this.startOpenAIRealtimeSession();
1597
+ }
1598
+ catch (error) {
1599
+ (0, runtime_1.emitNervesEvent)({
1600
+ level: "error",
1601
+ component: "senses",
1602
+ event: "senses.voice_twilio_realtime_start_error",
1603
+ message: "Twilio OpenAI Realtime stream could not connect",
1604
+ meta: { agentName: this.options.agentName, callSid: safeSegment(this.callSid), error: errorMessage(error) },
1605
+ });
1606
+ this.end();
1607
+ }
1608
+ }
1609
+ async startOpenAIRealtimeSession() {
1610
+ const realtime = this.options.openaiRealtime;
1611
+ if (!realtime?.apiKey?.trim()) {
1612
+ throw new Error("OpenAI Realtime API key is not configured");
1613
+ }
1614
+ this.ensureVoiceToolContext();
1615
+ const instructionsPromise = this.buildInstructions()
1616
+ .catch(() => realtimeBootstrapInstructions(this.options.agentName));
1617
+ const toolsPromise = this.buildRealtimeTools()
1618
+ .then((tools) => realtimeToolsFromChatTools(tools))
1619
+ .catch(() => realtimeBootstrapTools());
1620
+ const ws = new ws_1.WebSocket(openAIRealtimeWebSocketUrl(realtime), {
1621
+ headers: {
1622
+ Authorization: `Bearer ${realtime.apiKey.trim()}`,
1623
+ "OpenAI-Safety-Identifier": safeSegment(`${this.options.agentName}-${this.friendId}`),
1624
+ },
1625
+ });
1626
+ this.openaiWs = ws;
1627
+ ws.on("open", () => {
1628
+ this.openaiReady = true;
1629
+ void this.configureOpenAIRealtimeSession(realtime, instructionsPromise, toolsPromise);
1630
+ (0, runtime_1.emitNervesEvent)({
1631
+ component: "senses",
1632
+ event: "senses.voice_twilio_realtime_openai_open",
1633
+ message: "OpenAI Realtime session connected for Twilio call",
1634
+ meta: {
1635
+ agentName: this.options.agentName,
1636
+ callSid: safeSegment(this.callSid),
1637
+ model: realtime.model?.trim() || OPENAI_REALTIME_DEFAULT_MODEL,
1638
+ voice: realtime.voice?.trim() || OPENAI_REALTIME_DEFAULT_VOICE,
1639
+ apiKeySource: realtime.apiKeySource ?? "unknown",
1640
+ },
1641
+ });
1642
+ });
1643
+ ws.on("message", (raw) => this.handleOpenAIMessage(raw));
1644
+ ws.on("close", () => {
1645
+ this.openaiReady = false;
1646
+ if (!this.closed)
1647
+ this.end();
1648
+ });
1649
+ ws.on("error", (error) => {
1650
+ (0, runtime_1.emitNervesEvent)({
1651
+ level: "error",
1652
+ component: "senses",
1653
+ event: "senses.voice_twilio_realtime_openai_error",
1654
+ message: "OpenAI Realtime socket failed during Twilio call",
1655
+ meta: { agentName: this.options.agentName, callSid: safeSegment(this.callSid), error: errorMessage(error) },
1656
+ });
1657
+ });
1658
+ }
1659
+ async configureOpenAIRealtimeSession(realtime, instructionsPromise, toolsPromise) {
1660
+ const ready = await Promise.race([
1661
+ Promise.all([instructionsPromise, toolsPromise]),
1662
+ timeoutAfter(OPENAI_REALTIME_BOOTSTRAP_TIMEOUT_MS),
1663
+ ]);
1664
+ const usedBootstrap = ready === undefined;
1665
+ const [instructions, tools] = ready ?? [
1666
+ realtimeBootstrapInstructions(this.options.agentName, realtime.voiceStyle),
1667
+ realtimeBootstrapTools(),
1668
+ ];
1669
+ this.sendOpenAIRealtimeSessionUpdate(realtime, instructions, tools);
1670
+ this.flushPendingAudio();
1671
+ this.sendInitialGreeting();
1672
+ if (!usedBootstrap)
1673
+ return;
1674
+ Promise.all([instructionsPromise, toolsPromise])
1675
+ .then(([fullInstructions, fullTools]) => {
1676
+ if (this.closed)
1677
+ return;
1678
+ this.sendOpenAI({
1679
+ type: "session.update",
1680
+ session: {
1681
+ type: "realtime",
1682
+ instructions: fullInstructions,
1683
+ tools: fullTools,
1684
+ tool_choice: "auto",
1685
+ },
1686
+ });
1687
+ })
1688
+ .catch(() => undefined);
1689
+ }
1690
+ sendOpenAIRealtimeSessionUpdate(realtime, instructions, tools) {
1691
+ this.sendOpenAI({
1692
+ type: "session.update",
1693
+ session: {
1694
+ type: "realtime",
1695
+ model: realtime.model?.trim() || OPENAI_REALTIME_DEFAULT_MODEL,
1696
+ instructions,
1697
+ audio: {
1698
+ input: {
1699
+ format: { type: "audio/pcmu" },
1700
+ noise_reduction: realtimeNoiseReductionConfig(realtime),
1701
+ transcription: { model: OPENAI_REALTIME_DEFAULT_TRANSCRIPTION_MODEL },
1702
+ turn_detection: realtimeTurnDetectionConfig(realtime),
1703
+ },
1704
+ output: realtimeOutputAudioConfig(realtime, { type: "audio/pcmu" }),
1705
+ },
1706
+ tools,
1707
+ tool_choice: "auto",
1708
+ max_output_tokens: OPENAI_REALTIME_MAX_OUTPUT_TOKENS,
1709
+ ...(realtime.reasoningEffort ? { reasoning: { effort: realtime.reasoningEffort } } : {}),
1710
+ },
1711
+ });
1712
+ }
1713
+ async buildInstructions() {
1714
+ (0, identity_1.setAgentName)(this.options.agentName);
1715
+ const agentRoot = this.options.agentRoot ?? (0, identity_1.getAgentRoot)(this.options.agentName);
1716
+ const sessionDir = path.join(agentRoot, "state", "sessions", this.friendId, "voice");
1717
+ await fs.mkdir(sessionDir, { recursive: true });
1718
+ this.sessionPath = path.join(sessionDir, `${(0, config_1.sanitizeKey)(this.sessionKey)}.json`);
1719
+ const existing = (0, context_1.loadSession)(this.sessionPath);
1720
+ const prior = existing?.messages ? transcriptMessageText(existing.messages) : "";
1721
+ const realtimeSystem = await buildRealtimeVoiceInstructions({
1722
+ agentName: this.options.agentName,
1723
+ agentRoot,
1724
+ priorTranscript: prior,
1725
+ realtimeVoice: this.options.openaiRealtime?.voice,
1726
+ realtimeVoiceStyle: this.options.openaiRealtime?.voiceStyle,
1727
+ realtimeVoiceSpeed: this.options.openaiRealtime ? realtimeVoiceSpeed(this.options.openaiRealtime) : undefined,
1728
+ realtimeModel: this.options.openaiRealtime?.model,
1729
+ });
1730
+ this.sessionMessages = existing?.messages && existing.messages.length > 0
1731
+ ? existing.messages
1732
+ : [{ role: "system", content: realtimeSystem }];
1733
+ if (!existing)
1734
+ (0, context_1.saveSession)(this.sessionPath, this.sessionMessages);
1735
+ return realtimeSystem;
1736
+ }
1737
+ requestHangupFromTool() {
1738
+ this.hangupRequested = true;
1739
+ setTimeout(() => this.completeHangupIfReady("tool_fallback"), 7_500).unref?.();
1740
+ }
1741
+ ensureVoiceToolContext() {
1742
+ if (this.toolContext)
1743
+ return;
1744
+ this.toolContext = {
1745
+ signin: async () => undefined,
1746
+ voiceCall: {
1747
+ requestEnd: () => this.requestHangupFromTool(),
1748
+ playAudio: (request) => this.playPreparedAudio(request),
1749
+ },
1750
+ };
1751
+ }
1752
+ async buildRealtimeTools() {
1753
+ const agentRoot = this.options.agentRoot ?? (0, identity_1.getAgentRoot)(this.options.agentName);
1754
+ const friendsPath = path.join(agentRoot, "friends");
1755
+ const friendStore = new store_file_1.FileFriendStore(friendsPath);
1756
+ const resolver = new resolver_1.FriendResolver(friendStore, {
1757
+ provider: "local",
1758
+ externalId: this.friendId,
1759
+ displayName: this.friendId,
1760
+ channel: "voice",
1761
+ });
1762
+ const resolved = await resolver.resolve();
1763
+ this.toolContext = {
1764
+ signin: async () => undefined,
1765
+ context: resolved,
1766
+ friendStore,
1767
+ voiceCall: {
1768
+ requestEnd: () => this.requestHangupFromTool(),
1769
+ playAudio: (request) => this.playPreparedAudio(request),
1770
+ },
1771
+ };
1772
+ void this.refreshRealtimeToolsWithMcp(resolved);
1773
+ return (0, tools_1.getToolsForChannel)((0, channel_1.getChannelCapabilities)("voice"), resolved.friend.toolPreferences, resolved, undefined, undefined);
1774
+ }
1775
+ async refreshRealtimeToolsWithMcp(resolved) {
1776
+ try {
1777
+ const mcpManager = await (0, mcp_manager_1.getSharedMcpManager)() ?? undefined;
1778
+ if (!mcpManager || this.closed)
1779
+ return;
1780
+ const tools = realtimeToolsFromChatTools((0, tools_1.getToolsForChannel)((0, channel_1.getChannelCapabilities)("voice"), resolved.friend.toolPreferences, resolved, undefined, mcpManager));
1781
+ this.sendOpenAI({
1782
+ type: "session.update",
1783
+ session: {
1784
+ type: "realtime",
1785
+ tools,
1786
+ tool_choice: "auto",
1787
+ },
1788
+ });
1789
+ }
1790
+ catch {
1791
+ // Keep realtime calls conversational even if optional MCP tool discovery is slow or unavailable.
1792
+ }
1793
+ }
1794
+ sendInitialGreeting() {
1795
+ if (this.greetingSent)
1796
+ return;
1797
+ this.greetingSent = true;
1798
+ const promptText = this.direction === "outbound" && this.outboundId
1799
+ ? outboundCallAnsweredPrompt({
1800
+ schemaVersion: 1,
1801
+ outboundId: this.outboundId,
1802
+ agentName: this.options.agentName,
1803
+ ...(this.friendId ? { friendId: this.friendId } : {}),
1804
+ to: this.from,
1805
+ from: this.to,
1806
+ reason: this.outboundReason || "Voice call connected.",
1807
+ createdAt: new Date().toISOString(),
1808
+ }, { From: this.to, To: this.from })
1809
+ : callConnectedPrompt({ From: this.from, To: this.to });
1810
+ this.requestRealtimeResponse({ instructions: promptText });
1811
+ }
1812
+ handleMedia(media) {
1813
+ const payload = stringField(media?.payload);
1814
+ if (!payload)
1815
+ return;
1816
+ this.trackCallerBargeInEnergy(payload);
1817
+ if (!this.openaiReady) {
1818
+ this.pendingAudioPayloads.push(payload);
1819
+ if (this.pendingAudioPayloads.length > 250)
1820
+ this.pendingAudioPayloads.shift();
1821
+ return;
1822
+ }
1823
+ this.sendOpenAI({ type: "input_audio_buffer.append", audio: payload });
1824
+ }
1825
+ trackCallerBargeInEnergy(payload) {
1826
+ const frame = Buffer.from(payload, "base64");
1827
+ if (frame.byteLength === 0)
1828
+ return;
1829
+ const rms = mulawFrameRms(frame);
1830
+ if (rms >= OPENAI_REALTIME_BARGE_IN_RMS_THRESHOLD) {
1831
+ this.callerBargeInSpeechMs += pcmuPayloadDurationMs(payload);
1832
+ this.lastCallerBargeInSpeechAt = Date.now();
1833
+ return;
1834
+ }
1835
+ this.callerBargeInSpeechMs = Math.max(0, this.callerBargeInSpeechMs - pcmuPayloadDurationMs(payload));
1836
+ }
1837
+ hasReliableCallerBargeInSpeech() {
1838
+ if (Date.now() - this.lastCallerBargeInSpeechAt > 600)
1839
+ return false;
1840
+ return this.callerBargeInSpeechMs >= OPENAI_REALTIME_BARGE_IN_MIN_SPEECH_MS;
1841
+ }
1842
+ handleMark(mark) {
1843
+ const name = stringField(mark?.name);
1844
+ if (!name)
1845
+ return;
1846
+ const playback = this.playbackMarks.get(name);
1847
+ if (!playback)
1848
+ return;
1849
+ this.playbackMarks.delete(name);
1850
+ if (this.playbackState
1851
+ && this.playbackState.itemId === playback.itemId
1852
+ && this.playbackState.contentIndex === playback.contentIndex) {
1853
+ this.playbackState.playedMs = Math.max(this.playbackState.playedMs, playback.audioEndMs);
1854
+ }
1855
+ }
1856
+ handleOpenAIMessage(raw) {
1857
+ let event;
1858
+ try {
1859
+ event = JSON.parse(Buffer.from(raw).toString("utf8"));
1860
+ }
1861
+ catch {
1862
+ return;
1863
+ }
1864
+ const type = typeof event.type === "string" ? event.type : "";
1865
+ if (type === "response.created") {
1866
+ this.noteRealtimeResponseCreated(event);
1867
+ return;
1868
+ }
1869
+ if (type === "response.output_audio.delta" && typeof event.delta === "string") {
1870
+ this.handleOpenAIAudioDelta(event);
1871
+ return;
1872
+ }
1873
+ if (type === "input_audio_buffer.speech_started") {
1874
+ this.handleCallerSpeechStarted();
1875
+ return;
1876
+ }
1877
+ if (type === "conversation.item.input_audio_transcription.completed" && typeof event.transcript === "string") {
1878
+ this.appendTranscript("user", event.transcript);
1879
+ return;
1880
+ }
1881
+ if (type === "response.output_audio_transcript.done" && typeof event.transcript === "string") {
1882
+ this.appendTranscript("assistant", event.transcript);
1883
+ return;
1884
+ }
1885
+ if (type === "response.function_call_arguments.done") {
1886
+ void this.runRealtimeTool(event);
1887
+ return;
1888
+ }
1889
+ if (type === "response.done") {
1890
+ const responseId = realtimeResponseId(event);
1891
+ this.noteRealtimeResponseDone(responseId);
1892
+ if (this.completeRealtimeToolResponse(responseId))
1893
+ return;
1894
+ void this.playInitialAudioAfterGreeting();
1895
+ this.completeHangupIfReady("response_done");
1896
+ return;
1897
+ }
1898
+ if (type === "error") {
1899
+ (0, runtime_1.emitNervesEvent)({
1900
+ level: "error",
1901
+ component: "senses",
1902
+ event: "senses.voice_twilio_realtime_openai_event_error",
1903
+ message: "OpenAI Realtime emitted an error during Twilio call",
1904
+ meta: { agentName: this.options.agentName, callSid: safeSegment(this.callSid), event: JSON.stringify(event).slice(0, 500) },
1905
+ });
1906
+ }
1907
+ }
1908
+ handleOpenAIAudioDelta(event) {
1909
+ const payload = stringField(event.delta);
1910
+ if (!payload)
1911
+ return;
1912
+ const itemId = stringField(event.item_id);
1913
+ const contentIndex = numberField(event.content_index) ?? 0;
1914
+ let audioEndMs;
1915
+ if (itemId) {
1916
+ let current = this.playbackState;
1917
+ if (!current || current.itemId !== itemId || current.contentIndex !== contentIndex) {
1918
+ current = { itemId, contentIndex, sentMs: 0, playedMs: 0 };
1919
+ this.playbackState = current;
1920
+ }
1921
+ current.sentMs += pcmuPayloadDurationMs(payload);
1922
+ audioEndMs = current.sentMs;
1923
+ }
1924
+ this.sendTwilioMedia(payload);
1925
+ if (itemId && audioEndMs !== undefined)
1926
+ this.sendTwilioMark({ itemId, contentIndex, audioEndMs });
1927
+ }
1928
+ handleCallerSpeechStarted() {
1929
+ const playback = this.playbackState;
1930
+ if (!this.hasReliableCallerBargeInSpeech()) {
1931
+ (0, runtime_1.emitNervesEvent)({
1932
+ component: "senses",
1933
+ event: "senses.voice_twilio_realtime_barge_in_ignored",
1934
+ message: "ignored low-confidence OpenAI Realtime barge-in signal",
1935
+ meta: {
1936
+ agentName: this.options.agentName,
1937
+ callSid: safeSegment(this.callSid),
1938
+ speechMs: String(this.callerBargeInSpeechMs),
1939
+ },
1940
+ });
1941
+ return;
1942
+ }
1943
+ this.playbackMarks.clear();
1944
+ this.sendTwilioClear();
1945
+ if (!playback?.itemId)
1946
+ return;
1947
+ this.sendOpenAI({
1948
+ type: "conversation.item.truncate",
1949
+ item_id: playback.itemId,
1950
+ content_index: playback.contentIndex,
1951
+ audio_end_ms: playback.playedMs,
1952
+ });
1953
+ (0, runtime_1.emitNervesEvent)({
1954
+ component: "senses",
1955
+ event: "senses.voice_twilio_realtime_output_truncated",
1956
+ message: "truncated interrupted OpenAI Realtime voice output",
1957
+ meta: {
1958
+ agentName: this.options.agentName,
1959
+ callSid: safeSegment(this.callSid),
1960
+ audioEndMs: playback.playedMs,
1961
+ },
1962
+ });
1963
+ this.playbackState = undefined;
1964
+ }
1965
+ registerRealtimeToolResponse(responseId, callId) {
1966
+ if (!responseId)
1967
+ return undefined;
1968
+ const existing = this.toolResponses.get(responseId);
1969
+ const state = existing ?? {
1970
+ pendingCallIds: new Set(),
1971
+ responseDone: this.completedRealtimeResponseIds.has(responseId),
1972
+ followupRequested: false,
1973
+ suppressFollowup: false,
1974
+ };
1975
+ state.pendingCallIds.add(callId);
1976
+ if (!existing)
1977
+ this.toolResponses.set(responseId, state);
1978
+ return state;
1979
+ }
1980
+ completeRealtimeToolCall(responseId, callId) {
1981
+ if (!responseId)
1982
+ return false;
1983
+ const state = this.toolResponses.get(responseId);
1984
+ if (!state)
1985
+ return false;
1986
+ state.pendingCallIds.delete(callId);
1987
+ return this.maybeCreateRealtimeToolFollowup(responseId, state);
1988
+ }
1989
+ completeRealtimeToolResponse(responseId) {
1990
+ if (!responseId)
1991
+ return false;
1992
+ this.completedRealtimeResponseIds.add(responseId);
1993
+ const state = this.toolResponses.get(responseId);
1994
+ if (!state)
1995
+ return false;
1996
+ state.responseDone = true;
1997
+ this.maybeCreateRealtimeToolFollowup(responseId, state);
1998
+ return true;
1999
+ }
2000
+ maybeCreateRealtimeToolFollowup(responseId, state) {
2001
+ if (!state.responseDone || state.pendingCallIds.size > 0 || state.followupRequested)
2002
+ return false;
2003
+ state.followupRequested = true;
2004
+ this.toolResponses.delete(responseId);
2005
+ if (state.suppressFollowup)
2006
+ return true;
2007
+ this.requestRealtimeResponse();
2008
+ return true;
2009
+ }
2010
+ async runRealtimeTool(event) {
2011
+ const name = typeof event.name === "string" ? event.name : "";
2012
+ const callId = typeof event.call_id === "string" ? event.call_id : "";
2013
+ if (!name || !callId)
2014
+ return;
2015
+ const responseId = realtimeResponseId(event);
2016
+ const toolState = this.registerRealtimeToolResponse(responseId, callId);
2017
+ const coordinated = !!toolState;
2018
+ if (name === "voice_end_call" && toolState)
2019
+ toolState.suppressFollowup = true;
2020
+ let output;
2021
+ try {
2022
+ const args = parseToolArguments(typeof event.arguments === "string" ? event.arguments : "");
2023
+ (0, runtime_1.emitNervesEvent)({
2024
+ component: "senses",
2025
+ event: "senses.voice_twilio_realtime_tool_start",
2026
+ message: "OpenAI Realtime voice tool call started",
2027
+ meta: { agentName: this.options.agentName, callSid: safeSegment(this.callSid), tool: name },
2028
+ });
2029
+ output = await (0, tools_1.execTool)(name, args, this.toolContext);
2030
+ (0, runtime_1.emitNervesEvent)({
2031
+ component: "senses",
2032
+ event: "senses.voice_twilio_realtime_tool_end",
2033
+ message: "OpenAI Realtime voice tool call completed",
2034
+ meta: { agentName: this.options.agentName, callSid: safeSegment(this.callSid), tool: name },
2035
+ });
2036
+ }
2037
+ catch (error) {
2038
+ output = `[tool error] ${errorMessage(error)}`;
2039
+ (0, runtime_1.emitNervesEvent)({
2040
+ level: "error",
2041
+ component: "senses",
2042
+ event: "senses.voice_twilio_realtime_tool_error",
2043
+ message: "OpenAI Realtime voice tool call failed",
2044
+ meta: { agentName: this.options.agentName, callSid: safeSegment(this.callSid), tool: name, error: errorMessage(error) },
2045
+ });
2046
+ }
2047
+ this.sendOpenAI({
2048
+ type: "conversation.item.create",
2049
+ item: {
2050
+ type: "function_call_output",
2051
+ call_id: callId,
2052
+ output,
2053
+ },
2054
+ });
2055
+ if (!this.completeRealtimeToolCall(responseId, callId) && !coordinated) {
2056
+ this.requestRealtimeResponse();
2057
+ }
2058
+ }
2059
+ noteRealtimeResponseCreated(event) {
2060
+ const responseId = realtimeResponseId(event);
2061
+ if (responseId)
2062
+ this.activeRealtimeResponseId = responseId;
2063
+ }
2064
+ noteRealtimeResponseDone(responseId) {
2065
+ if (!responseId || this.activeRealtimeResponseId === responseId) {
2066
+ this.activeRealtimeResponseId = null;
2067
+ }
2068
+ this.responseCreateHoldUntilMs = Math.max(this.responseCreateHoldUntilMs, Date.now() + OPENAI_REALTIME_RESPONSE_CREATE_GRACE_MS);
2069
+ this.schedulePendingRealtimeResponse(OPENAI_REALTIME_RESPONSE_CREATE_GRACE_MS);
2070
+ }
2071
+ requestRealtimeResponse(response) {
2072
+ if (this.closed)
2073
+ return;
2074
+ const waitMs = Math.max(0, this.responseCreateHoldUntilMs - Date.now());
2075
+ if (this.activeRealtimeResponseId || waitMs > 0) {
2076
+ const pendingResponse = response ?? this.pendingRealtimeResponse?.response;
2077
+ this.pendingRealtimeResponse = pendingResponse ? { response: pendingResponse } : {};
2078
+ if (!this.activeRealtimeResponseId)
2079
+ this.schedulePendingRealtimeResponse(waitMs);
2080
+ return;
2081
+ }
2082
+ this.sendRealtimeResponseCreate(response ? { response } : {});
2083
+ }
2084
+ schedulePendingRealtimeResponse(delayMs) {
2085
+ if (!this.pendingRealtimeResponse)
2086
+ return;
2087
+ if (this.pendingRealtimeResponseTimer)
2088
+ clearTimeout(this.pendingRealtimeResponseTimer);
2089
+ this.pendingRealtimeResponseTimer = setTimeout(() => {
2090
+ this.pendingRealtimeResponseTimer = null;
2091
+ this.flushPendingRealtimeResponse();
2092
+ }, Math.max(0, delayMs));
2093
+ this.pendingRealtimeResponseTimer.unref?.();
2094
+ }
2095
+ flushPendingRealtimeResponse() {
2096
+ if (!this.pendingRealtimeResponse || this.closed || this.activeRealtimeResponseId)
2097
+ return;
2098
+ const waitMs = Math.max(0, this.responseCreateHoldUntilMs - Date.now());
2099
+ if (waitMs > 0) {
2100
+ this.schedulePendingRealtimeResponse(waitMs);
2101
+ return;
2102
+ }
2103
+ const pending = this.pendingRealtimeResponse;
2104
+ this.pendingRealtimeResponse = null;
2105
+ this.sendRealtimeResponseCreate(pending);
2106
+ }
2107
+ sendRealtimeResponseCreate(request) {
2108
+ this.sendOpenAI({
2109
+ type: "response.create",
2110
+ ...(request.response ? { response: request.response } : {}),
2111
+ });
2112
+ }
2113
+ flushPendingAudio() {
2114
+ const pending = this.pendingAudioPayloads.splice(0);
2115
+ for (const payload of pending) {
2116
+ this.sendOpenAI({ type: "input_audio_buffer.append", audio: payload });
2117
+ }
2118
+ }
2119
+ sendOpenAI(event) {
2120
+ if (!this.openaiWs || this.openaiWs.readyState !== ws_1.WebSocket.OPEN)
2121
+ return;
2122
+ this.openaiWs.send(JSON.stringify(event));
2123
+ }
2124
+ async playInitialAudioAfterGreeting() {
2125
+ if (this.initialAudioPlayed || !this.initialAudio)
2126
+ return;
2127
+ this.initialAudioPlayed = true;
2128
+ try {
2129
+ await this.playPreparedAudio(this.initialAudio, { clearFirst: false });
2130
+ }
2131
+ catch (error) {
2132
+ (0, runtime_1.emitNervesEvent)({
2133
+ level: "error",
2134
+ component: "senses",
2135
+ event: "senses.voice_twilio_realtime_initial_audio_error",
2136
+ message: "failed to play initial audio into Twilio Realtime call",
2137
+ meta: { agentName: this.options.agentName, callSid: safeSegment(this.callSid), error: errorMessage(error) },
2138
+ });
2139
+ }
2140
+ }
2141
+ async playPreparedAudio(request, playbackOptions = {}) {
2142
+ if (!this.streamSid || this.ws.readyState !== ws_1.WebSocket.OPEN) {
2143
+ throw new Error("voice call media stream is not ready");
2144
+ }
2145
+ const prepared = await (0, audio_playback_1.prepareVoiceCallAudio)(request, {
2146
+ agentRoot: this.options.agentRoot ?? (0, identity_1.getAgentRoot)(this.options.agentName),
2147
+ });
2148
+ this.playbackMarks.clear();
2149
+ this.playbackState = undefined;
2150
+ if (playbackOptions.clearFirst ?? true)
2151
+ this.sendTwilioClear();
2152
+ for (let offset = 0; offset < prepared.audio.byteLength; offset += 160) {
2153
+ if (this.closed || this.ws.readyState !== ws_1.WebSocket.OPEN)
2154
+ break;
2155
+ this.sendTwilioMedia(Buffer.from(prepared.audio.subarray(offset, offset + 160)).toString("base64"));
2156
+ await delay(20);
2157
+ }
2158
+ if (!this.closed && this.ws.readyState === ws_1.WebSocket.OPEN) {
2159
+ this.ws.send(JSON.stringify({
2160
+ event: "mark",
2161
+ streamSid: this.streamSid,
2162
+ mark: { name: `tool-audio-${Date.now()}` },
2163
+ }));
2164
+ }
2165
+ (0, runtime_1.emitNervesEvent)({
2166
+ component: "senses",
2167
+ event: "senses.voice_twilio_realtime_tool_audio_played",
2168
+ message: "played tool-requested audio into Twilio Realtime call",
2169
+ meta: {
2170
+ agentName: this.options.agentName,
2171
+ callSid: safeSegment(this.callSid),
2172
+ label: prepared.label,
2173
+ durationMs: String(prepared.durationMs),
2174
+ },
2175
+ });
2176
+ return { label: prepared.label, durationMs: prepared.durationMs };
2177
+ }
2178
+ sendTwilioMedia(payload) {
2179
+ if (this.closed || !this.streamSid || this.ws.readyState !== ws_1.WebSocket.OPEN)
2180
+ return;
2181
+ this.ws.send(JSON.stringify({
2182
+ event: "media",
2183
+ streamSid: this.streamSid,
2184
+ media: { payload },
2185
+ }));
2186
+ }
2187
+ sendTwilioMark(playback) {
2188
+ if (this.closed || !this.streamSid || this.ws.readyState !== ws_1.WebSocket.OPEN)
2189
+ return;
2190
+ const name = `rt-${++this.playbackMarkIndex}`;
2191
+ this.playbackMarks.set(name, playback);
2192
+ this.ws.send(JSON.stringify({
2193
+ event: "mark",
2194
+ streamSid: this.streamSid,
2195
+ mark: { name },
2196
+ }));
2197
+ }
2198
+ sendTwilioClear() {
2199
+ if (this.closed || !this.streamSid || this.ws.readyState !== ws_1.WebSocket.OPEN)
2200
+ return;
2201
+ this.ws.send(JSON.stringify({ event: "clear", streamSid: this.streamSid }));
2202
+ }
2203
+ appendTranscript(role, text) {
2204
+ const content = text.trim();
2205
+ if (!content || !this.sessionPath)
2206
+ return;
2207
+ this.sessionMessages.push({ role, content });
2208
+ (0, context_1.saveSession)(this.sessionPath, this.sessionMessages);
2209
+ }
2210
+ completeHangupIfReady(trigger) {
2211
+ if (!this.hangupRequested || this.closed)
2212
+ return;
2213
+ (0, runtime_1.emitNervesEvent)({
2214
+ component: "senses",
2215
+ event: "senses.voice_twilio_realtime_hangup_end",
2216
+ message: "ending Twilio OpenAI Realtime call after hangup request",
2217
+ meta: { agentName: this.options.agentName, callSid: safeSegment(this.callSid), trigger },
2218
+ });
2219
+ this.end();
2220
+ }
2221
+ close() {
2222
+ if (this.closed)
2223
+ return;
2224
+ this.closed = true;
2225
+ if (this.openaiWs && (this.openaiWs.readyState === ws_1.WebSocket.OPEN || this.openaiWs.readyState === ws_1.WebSocket.CONNECTING)) {
2226
+ this.openaiWs.close();
2227
+ }
2228
+ if (this.pendingRealtimeResponseTimer) {
2229
+ clearTimeout(this.pendingRealtimeResponseTimer);
2230
+ this.pendingRealtimeResponseTimer = null;
2231
+ }
2232
+ this.lifecycle?.onClose?.(this, { callSid: this.callSid, outboundId: this.outboundId });
2233
+ (0, runtime_1.emitNervesEvent)({
2234
+ component: "senses",
2235
+ event: "senses.voice_twilio_realtime_stop",
2236
+ message: "Twilio OpenAI Realtime stream stopped",
2237
+ meta: { agentName: this.options.agentName, callSid: safeSegment(this.callSid) },
2238
+ });
2239
+ }
2240
+ }
2241
+ /* v8 ignore stop */
2242
+ /* v8 ignore start -- direct SIP control has bridge-level coverage; provider failures, bootstrap races, and AMD timers are live-network edge permutations @preserve */
2243
+ class OpenAISipPhoneSession {
2244
+ options;
2245
+ metadata;
2246
+ registry;
2247
+ friendId = "";
2248
+ sessionKey = "";
2249
+ sessionPath = "";
2250
+ closed = false;
2251
+ hangupRequested = false;
2252
+ hangupStarted = false;
2253
+ initialGreetingSent = false;
2254
+ outboundAmdState = "not_needed";
2255
+ outboundAmdGreetingTimer = null;
2256
+ outboundAmdHumanGreetingCandidate = false;
2257
+ autoResponsesSuppressedForAmd = false;
2258
+ openaiWs = null;
2259
+ toolContext;
2260
+ sessionMessages = [];
2261
+ toolResponses = new Map();
2262
+ completedRealtimeResponseIds = new Set();
2263
+ activeRealtimeResponseId = null;
2264
+ pendingRealtimeResponse = null;
2265
+ pendingRealtimeResponseTimer = null;
2266
+ responseCreateHoldUntilMs = 0;
2267
+ constructor(options, metadata, registry) {
2268
+ this.options = options;
2269
+ this.metadata = metadata;
2270
+ this.registry = registry;
2271
+ }
2272
+ get callId() {
2273
+ return this.metadata.callId;
2274
+ }
2275
+ get outboundId() {
2276
+ return this.metadata.outboundId;
2277
+ }
2278
+ async start() {
2279
+ try {
2280
+ const realtime = this.options.openaiRealtime;
2281
+ const sip = this.options.openaiSip;
2282
+ if (!realtime?.apiKey?.trim())
2283
+ throw new Error("OpenAI Realtime API key is not configured");
2284
+ if (!sip)
2285
+ throw new Error("OpenAI SIP options are not configured");
2286
+ this.friendId = this.metadata.friendId
2287
+ || this.options.defaultFriendId?.trim()
2288
+ || voiceFriendId(this.options, this.metadata.from, this.metadata.callId);
2289
+ this.sessionKey = twilioPhoneVoiceSessionKey({
2290
+ defaultFriendId: this.friendId || this.options.defaultFriendId,
2291
+ from: this.metadata.from,
2292
+ to: this.metadata.to,
2293
+ callSid: this.metadata.callId,
2294
+ });
2295
+ const initialGreetingMode = await this.outboundAmdInitialGreetingMode();
2296
+ if (initialGreetingMode === "reject") {
2297
+ await this.rejectOpenAISipCall(realtime, sip, "amd_preclassified_nonhuman");
2298
+ return;
2299
+ }
2300
+ this.outboundAmdState = initialGreetingMode === "hold" ? "pending" : "not_needed";
2301
+ this.autoResponsesSuppressedForAmd = initialGreetingMode === "hold";
2302
+ await this.updateOutboundJobIfNeeded();
2303
+ this.ensureVoiceToolContext();
2304
+ (0, runtime_1.emitNervesEvent)({
2305
+ component: "senses",
2306
+ event: "senses.voice_openai_sip_call_start",
2307
+ message: "OpenAI SIP phone call webhook accepted for voice handling",
2308
+ meta: {
2309
+ agentName: this.options.agentName,
2310
+ callId: safeSegment(this.metadata.callId),
2311
+ sessionKey: this.sessionKey,
2312
+ direction: this.metadata.direction,
2313
+ },
2314
+ });
2315
+ this.registry?.register(this);
2316
+ const fullConfigPromise = Promise.all([
2317
+ this.buildInstructions(),
2318
+ this.buildRealtimeTools()
2319
+ .then((tools) => realtimeToolsFromChatTools(tools, OPENAI_SIP_UNSUPPORTED_TOOL_NAMES)),
2320
+ ]);
2321
+ const ready = await Promise.race([
2322
+ fullConfigPromise,
2323
+ timeoutAfter(OPENAI_REALTIME_BOOTSTRAP_TIMEOUT_MS),
2324
+ ]);
2325
+ const usedBootstrap = ready === undefined;
2326
+ const [instructions, tools] = ready ?? [
2327
+ realtimeBootstrapInstructions(this.options.agentName, realtime.voiceStyle),
2328
+ realtimeBootstrapTools(),
2329
+ ];
2330
+ if (this.closed || this.outboundAmdStopped())
2331
+ return;
2332
+ await this.acceptOpenAISipCall(realtime, sip, instructions, tools, this.autoResponsesSuppressedForAmd);
2333
+ if (this.closed || this.outboundAmdStopped())
2334
+ return;
2335
+ this.openControlWebSocket(realtime, sip, fullConfigPromise, usedBootstrap);
2336
+ }
2337
+ catch (error) {
2338
+ (0, runtime_1.emitNervesEvent)({
2339
+ level: "error",
2340
+ component: "senses",
2341
+ event: "senses.voice_openai_sip_call_error",
2342
+ message: "OpenAI SIP phone call could not be started",
2343
+ meta: { agentName: this.options.agentName, callId: safeSegment(this.metadata.callId), error: errorMessage(error) },
2344
+ });
2345
+ this.close("start_error");
2346
+ throw error;
2347
+ }
2348
+ }
2349
+ async updateOutboundJobIfNeeded() {
2350
+ if (this.metadata.direction !== "outbound" || !this.metadata.outboundId)
2351
+ return;
2352
+ const job = await readTwilioOutboundCallJob(this.options.outputDir, this.metadata.outboundId);
2353
+ if (!job)
2354
+ return;
2355
+ await updateTwilioOutboundCallJob(this.options.outputDir, job.outboundId, {
2356
+ status: "answered",
2357
+ transportCallSid: this.metadata.callId,
2358
+ events: [
2359
+ ...(job.events ?? []),
2360
+ { at: new Date().toISOString(), status: "answered", callSid: this.metadata.callId },
2361
+ ],
2362
+ });
2363
+ }
2364
+ async outboundAmdInitialGreetingMode() {
2365
+ if (this.metadata.direction !== "outbound" || !this.metadata.outboundId)
2366
+ return "send";
2367
+ const job = await readTwilioOutboundCallJob(this.options.outputDir, this.metadata.outboundId);
2368
+ if (!job)
2369
+ return "send";
2370
+ const answeredBy = job.answeredBy?.trim();
2371
+ if (nonHumanAnsweredStatus(answeredBy) || job.status === "voicemail" || job.status === "fax")
2372
+ return "reject";
2373
+ if (answeredBy?.toLowerCase() === "human")
2374
+ return "send";
2375
+ return "hold";
2376
+ }
2377
+ outboundAmdStopped() {
2378
+ return this.outboundAmdState === "nonhuman" || this.outboundAmdState === "timeout";
2379
+ }
2380
+ async acceptOpenAISipCall(realtime, sip, instructions, tools, suppressAutoResponsesForAmd = false) {
2381
+ const fetchImpl = sip.fetch ?? fetch;
2382
+ const response = await fetchImpl(openAISipCallActionUrl(sip, this.metadata.callId, "accept"), {
2383
+ method: "POST",
2384
+ headers: {
2385
+ authorization: `Bearer ${realtime.apiKey.trim()}`,
2386
+ "content-type": "application/json",
2387
+ },
2388
+ body: JSON.stringify({
2389
+ type: "realtime",
2390
+ model: realtime.model?.trim() || OPENAI_REALTIME_DEFAULT_MODEL,
2391
+ instructions,
2392
+ audio: {
2393
+ input: {
2394
+ noise_reduction: realtimeNoiseReductionConfig(realtime),
2395
+ transcription: { model: OPENAI_REALTIME_DEFAULT_TRANSCRIPTION_MODEL },
2396
+ turn_detection: realtimeTurnDetectionConfig(realtime, suppressAutoResponsesForAmd ? { createResponse: false } : {}),
2397
+ },
2398
+ output: realtimeOutputAudioConfig(realtime),
2399
+ },
2400
+ tools,
2401
+ tool_choice: "auto",
2402
+ max_output_tokens: OPENAI_REALTIME_MAX_OUTPUT_TOKENS,
2403
+ }),
2404
+ });
2405
+ if (!response.ok) {
2406
+ const responseText = await response.text().catch(() => "");
2407
+ throw new Error(`OpenAI SIP call accept failed: ${response.status} ${responseText}`.trim());
2408
+ }
2409
+ (0, runtime_1.emitNervesEvent)({
2410
+ component: "senses",
2411
+ event: "senses.voice_openai_sip_call_accepted",
2412
+ message: "OpenAI SIP phone call accepted",
2413
+ meta: {
2414
+ agentName: this.options.agentName,
2415
+ callId: safeSegment(this.metadata.callId),
2416
+ model: realtime.model?.trim() || OPENAI_REALTIME_DEFAULT_MODEL,
2417
+ voice: realtime.voice?.trim() || OPENAI_REALTIME_DEFAULT_VOICE,
2418
+ },
2419
+ });
2420
+ }
2421
+ async rejectOpenAISipCall(realtime, sip, trigger) {
2422
+ try {
2423
+ const response = await (sip.fetch ?? fetch)(openAISipCallActionUrl(sip, this.metadata.callId, "reject"), {
2424
+ method: "POST",
2425
+ headers: { authorization: `Bearer ${realtime.apiKey.trim()}` },
2426
+ });
2427
+ if (!response.ok) {
2428
+ const responseText = await response.text().catch(() => "");
2429
+ throw new Error(`OpenAI SIP call reject failed: ${response.status} ${responseText}`.trim());
2430
+ }
2431
+ (0, runtime_1.emitNervesEvent)({
2432
+ component: "senses",
2433
+ event: "senses.voice_openai_sip_call_rejected",
2434
+ message: "OpenAI SIP outbound call rejected before media start",
2435
+ meta: { agentName: this.options.agentName, callId: safeSegment(this.metadata.callId), trigger },
2436
+ });
2437
+ }
2438
+ catch (error) {
2439
+ (0, runtime_1.emitNervesEvent)({
2440
+ level: "error",
2441
+ component: "senses",
2442
+ event: "senses.voice_openai_sip_call_reject_error",
2443
+ message: "OpenAI SIP outbound call reject request failed",
2444
+ meta: { agentName: this.options.agentName, callId: safeSegment(this.metadata.callId), trigger, error: errorMessage(error) },
2445
+ });
2446
+ }
2447
+ finally {
2448
+ this.close(trigger);
2449
+ }
2450
+ }
2451
+ openControlWebSocket(realtime, sip, fullConfigPromise, usedBootstrap) {
2452
+ const ws = new ws_1.WebSocket(openAISipControlWebSocketUrl(sip, this.metadata.callId), {
2453
+ headers: {
2454
+ Authorization: `Bearer ${realtime.apiKey.trim()}`,
2455
+ "OpenAI-Safety-Identifier": safeSegment(`${this.options.agentName}-${this.friendId}`),
2456
+ },
2457
+ });
2458
+ this.openaiWs = ws;
2459
+ ws.on("open", () => {
2460
+ (0, runtime_1.emitNervesEvent)({
2461
+ component: "senses",
2462
+ event: "senses.voice_openai_sip_control_open",
2463
+ message: "OpenAI SIP Realtime control socket connected",
2464
+ meta: { agentName: this.options.agentName, callId: safeSegment(this.metadata.callId) },
2465
+ });
2466
+ this.startInitialGreetingFlow();
2467
+ if (!usedBootstrap)
2468
+ return;
2469
+ fullConfigPromise
2470
+ .then(([instructions, tools]) => {
2471
+ if (this.closed)
2472
+ return;
2473
+ this.sendOpenAI({
2474
+ type: "session.update",
2475
+ session: {
2476
+ type: "realtime",
2477
+ instructions,
2478
+ tools,
2479
+ tool_choice: "auto",
2480
+ ...(realtime.reasoningEffort ? { reasoning: { effort: realtime.reasoningEffort } } : {}),
2481
+ },
2482
+ });
2483
+ })
2484
+ .catch(() => undefined);
2485
+ });
2486
+ ws.on("message", (raw) => this.handleOpenAIMessage(raw));
2487
+ ws.on("close", () => {
2488
+ if (!this.closed)
2489
+ this.close("control_socket_closed");
2490
+ });
2491
+ ws.on("error", (error) => {
2492
+ (0, runtime_1.emitNervesEvent)({
2493
+ level: "error",
2494
+ component: "senses",
2495
+ event: "senses.voice_openai_sip_control_error",
2496
+ message: "OpenAI SIP Realtime control socket failed",
2497
+ meta: { agentName: this.options.agentName, callId: safeSegment(this.metadata.callId), error: errorMessage(error) },
2498
+ });
2499
+ });
2500
+ }
2501
+ async buildInstructions() {
2502
+ (0, identity_1.setAgentName)(this.options.agentName);
2503
+ const agentRoot = this.options.agentRoot ?? (0, identity_1.getAgentRoot)(this.options.agentName);
2504
+ const sessionDir = path.join(agentRoot, "state", "sessions", this.friendId, "voice");
2505
+ await fs.mkdir(sessionDir, { recursive: true });
2506
+ this.sessionPath = path.join(sessionDir, `${(0, config_1.sanitizeKey)(this.sessionKey)}.json`);
2507
+ const existing = (0, context_1.loadSession)(this.sessionPath);
2508
+ const prior = existing?.messages ? transcriptMessageText(existing.messages) : "";
2509
+ const realtimeSystem = await buildRealtimeVoiceInstructions({
2510
+ agentName: this.options.agentName,
2511
+ agentRoot,
2512
+ priorTranscript: prior,
2513
+ realtimeVoice: this.options.openaiRealtime?.voice,
2514
+ realtimeVoiceStyle: this.options.openaiRealtime?.voiceStyle,
2515
+ realtimeVoiceSpeed: this.options.openaiRealtime ? realtimeVoiceSpeed(this.options.openaiRealtime) : undefined,
2516
+ realtimeModel: this.options.openaiRealtime?.model,
2517
+ audioToolMode: "realtime-cue",
2518
+ });
2519
+ this.sessionMessages = existing?.messages && existing.messages.length > 0
2520
+ ? existing.messages
2521
+ : [{ role: "system", content: realtimeSystem }];
2522
+ if (!existing)
2523
+ (0, context_1.saveSession)(this.sessionPath, this.sessionMessages);
2524
+ return realtimeSystem;
2525
+ }
2526
+ ensureVoiceToolContext() {
2527
+ if (this.toolContext)
2528
+ return;
2529
+ this.toolContext = {
2530
+ signin: async () => undefined,
2531
+ voiceCall: {
2532
+ requestEnd: () => this.requestHangupFromTool(),
2533
+ playAudio: (request) => this.playRealtimeAudioCue(request),
2534
+ },
2535
+ };
2536
+ }
2537
+ async buildRealtimeTools() {
2538
+ const agentRoot = this.options.agentRoot ?? (0, identity_1.getAgentRoot)(this.options.agentName);
2539
+ const friendsPath = path.join(agentRoot, "friends");
2540
+ const friendStore = new store_file_1.FileFriendStore(friendsPath);
2541
+ const resolver = new resolver_1.FriendResolver(friendStore, {
2542
+ provider: "local",
2543
+ externalId: this.friendId,
2544
+ displayName: this.friendId,
2545
+ channel: "voice",
2546
+ });
2547
+ const resolved = await resolver.resolve();
2548
+ this.toolContext = {
2549
+ signin: async () => undefined,
2550
+ context: resolved,
2551
+ friendStore,
2552
+ voiceCall: {
2553
+ requestEnd: () => this.requestHangupFromTool(),
2554
+ playAudio: (request) => this.playRealtimeAudioCue(request),
2555
+ },
2556
+ };
2557
+ void this.refreshRealtimeToolsWithMcp(resolved);
2558
+ return (0, tools_1.getToolsForChannel)((0, channel_1.getChannelCapabilities)("voice"), resolved.friend.toolPreferences, resolved, undefined, undefined);
2559
+ }
2560
+ playRealtimeAudioCue(request) {
2561
+ const source = request.source ?? "tone";
2562
+ const label = request.label?.trim() || (source === "tone" ? "tone cue" : "audio clip");
2563
+ if (source !== "tone") {
2564
+ (0, runtime_1.emitNervesEvent)({
2565
+ component: "senses",
2566
+ event: "senses.voice_openai_sip_audio_clip_unsupported",
2567
+ message: "OpenAI SIP voice audio clip requested without byte-injection support",
2568
+ meta: { agentName: this.options.agentName, callId: safeSegment(this.metadata.callId), source, label },
2569
+ });
2570
+ return {
2571
+ label,
2572
+ durationMs: 0,
2573
+ toolResult: [
2574
+ "Direct OpenAI SIP cannot inject arbitrary external audio bytes yet.",
2575
+ "Briefly tell the caller that this SIP path can do short generated cues, but URL/file clips need the media bridge work before they can be played into the call.",
2576
+ ].join(" "),
2577
+ };
2578
+ }
2579
+ const durationMs = boundedInteger(request.durationMs, 80, 4_000) ?? 700;
2580
+ const toneHz = boundedInteger(request.toneHz, 80, 3_000) ?? 660;
2581
+ (0, runtime_1.emitNervesEvent)({
2582
+ component: "senses",
2583
+ event: "senses.voice_openai_sip_audio_cue_requested",
2584
+ message: "OpenAI SIP voice audio cue requested",
2585
+ meta: {
2586
+ agentName: this.options.agentName,
2587
+ callId: safeSegment(this.metadata.callId),
2588
+ label,
2589
+ durationMs: String(durationMs),
2590
+ toneHz: String(toneHz),
2591
+ },
2592
+ });
2593
+ return {
2594
+ label,
2595
+ durationMs,
2596
+ toolResult: [
2597
+ `Render the requested audio cue now: a short, clear, nonverbal beep-like tone around ${toneHz} Hz for about ${durationMs} ms.`,
2598
+ "Do not describe the tone first and do not add words unless the caller asks afterward.",
2599
+ ].join(" "),
2600
+ };
2601
+ }
2602
+ async refreshRealtimeToolsWithMcp(resolved) {
2603
+ try {
2604
+ const mcpManager = await (0, mcp_manager_1.getSharedMcpManager)() ?? undefined;
2605
+ if (!mcpManager || this.closed)
2606
+ return;
2607
+ const tools = realtimeToolsFromChatTools((0, tools_1.getToolsForChannel)((0, channel_1.getChannelCapabilities)("voice"), resolved.friend.toolPreferences, resolved, undefined, mcpManager), OPENAI_SIP_UNSUPPORTED_TOOL_NAMES);
2608
+ this.sendOpenAI({
2609
+ type: "session.update",
2610
+ session: {
2611
+ type: "realtime",
2612
+ tools,
2613
+ tool_choice: "auto",
2614
+ },
2615
+ });
2616
+ }
2617
+ catch {
2618
+ // Keep SIP calls conversational even if optional MCP tool discovery is slow or unavailable.
2619
+ }
2620
+ }
2621
+ recordOutboundAmdTranscriptCandidate(transcript) {
2622
+ if (this.outboundAmdState !== "pending")
2623
+ return;
2624
+ if (!looksLikeShortHumanPhoneGreeting(transcript))
2625
+ return;
2626
+ this.outboundAmdHumanGreetingCandidate = true;
2627
+ (0, runtime_1.emitNervesEvent)({
2628
+ component: "senses",
2629
+ event: "senses.voice_openai_sip_amd_human_candidate",
2630
+ message: "OpenAI SIP outbound AMD saw a short human-like greeting while waiting for Twilio classification",
2631
+ meta: {
2632
+ agentName: this.options.agentName,
2633
+ callId: safeSegment(this.metadata.callId),
2634
+ outboundId: safeSegment(this.metadata.outboundId || "unknown"),
2635
+ },
2636
+ });
2637
+ }
2638
+ handleAsyncAmd(answeredBy, nonHumanStatus) {
2639
+ if (this.metadata.direction !== "outbound" || !this.metadata.outboundId)
2640
+ return;
2641
+ if (nonHumanStatus) {
2642
+ this.outboundAmdState = "nonhuman";
2643
+ this.clearOutboundAmdGreetingTimeout();
2644
+ (0, runtime_1.emitNervesEvent)({
2645
+ component: "senses",
2646
+ event: "senses.voice_openai_sip_amd_nonhuman",
2647
+ message: "OpenAI SIP outbound call is hanging up after async AMD reported a non-human answer",
2648
+ meta: {
2649
+ agentName: this.options.agentName,
2650
+ callId: safeSegment(this.metadata.callId),
2651
+ outboundId: safeSegment(this.metadata.outboundId),
2652
+ answeredBy,
2653
+ status: nonHumanStatus,
2654
+ },
2655
+ });
2656
+ void this.hangup("amd_nonhuman");
2657
+ return;
2658
+ }
2659
+ const normalizedAnsweredBy = answeredBy.trim().toLowerCase();
2660
+ if (normalizedAnsweredBy !== "human") {
2661
+ if (normalizedAnsweredBy === "unknown" && this.outboundAmdHumanGreetingCandidate) {
2662
+ this.releaseOutboundAmdGreeting("amd_unknown_human_candidate");
2663
+ }
2664
+ return;
2665
+ }
2666
+ this.releaseOutboundAmdGreeting("amd_human");
2667
+ }
2668
+ releaseOutboundAmdGreeting(trigger) {
2669
+ if (this.outboundAmdState === "nonhuman" || this.outboundAmdState === "timeout")
2670
+ return;
2671
+ this.outboundAmdState = "human";
2672
+ this.clearOutboundAmdGreetingTimeout();
2673
+ (0, runtime_1.emitNervesEvent)({
2674
+ component: "senses",
2675
+ event: "senses.voice_openai_sip_amd_human",
2676
+ message: "OpenAI SIP outbound greeting released after human AMD evidence",
2677
+ meta: {
2678
+ agentName: this.options.agentName,
2679
+ callId: safeSegment(this.metadata.callId),
2680
+ outboundId: safeSegment(this.metadata.outboundId),
2681
+ trigger,
2682
+ },
2683
+ });
2684
+ this.resumeAfterHumanAmdIfNeeded();
2685
+ }
2686
+ resumeAfterHumanAmdIfNeeded() {
2687
+ if (this.closed)
2688
+ return;
2689
+ if (!this.openaiWs || this.openaiWs.readyState !== ws_1.WebSocket.OPEN)
2690
+ return;
2691
+ if (this.autoResponsesSuppressedForAmd) {
2692
+ const realtime = this.options.openaiRealtime;
2693
+ if (realtime) {
2694
+ this.sendOpenAI({
2695
+ type: "session.update",
2696
+ session: {
2697
+ type: "realtime",
2698
+ audio: {
2699
+ input: {
2700
+ turn_detection: realtimeTurnDetectionConfig(realtime),
2701
+ },
2702
+ },
2703
+ },
2704
+ });
2705
+ }
2706
+ this.autoResponsesSuppressedForAmd = false;
2707
+ }
2708
+ this.sendInitialGreeting();
2709
+ }
2710
+ armOutboundAmdGreetingTimeout() {
2711
+ if (this.outboundAmdGreetingTimer || this.outboundAmdState !== "pending")
2712
+ return;
2713
+ this.outboundAmdGreetingTimer = setTimeout(() => {
2714
+ this.outboundAmdGreetingTimer = null;
2715
+ if (this.closed || this.outboundAmdState !== "pending")
2716
+ return;
2717
+ this.outboundAmdState = "timeout";
2718
+ void this.markOutboundAmdTimeout();
2719
+ (0, runtime_1.emitNervesEvent)({
2720
+ level: "warn",
2721
+ component: "senses",
2722
+ event: "senses.voice_openai_sip_amd_timeout",
2723
+ message: "OpenAI SIP outbound call hung up because async AMD did not report a human answer",
2724
+ meta: {
2725
+ agentName: this.options.agentName,
2726
+ callId: safeSegment(this.metadata.callId),
2727
+ outboundId: safeSegment(this.metadata.outboundId || "unknown"),
2728
+ },
2729
+ });
2730
+ void this.hangup("amd_timeout");
2731
+ }, OPENAI_SIP_OUTBOUND_AMD_GREETING_TIMEOUT_MS);
2732
+ this.outboundAmdGreetingTimer.unref?.();
2733
+ }
2734
+ clearOutboundAmdGreetingTimeout() {
2735
+ if (!this.outboundAmdGreetingTimer)
2736
+ return;
2737
+ clearTimeout(this.outboundAmdGreetingTimer);
2738
+ this.outboundAmdGreetingTimer = null;
2739
+ }
2740
+ async markOutboundAmdTimeout() {
2741
+ if (!this.metadata.outboundId)
2742
+ return;
2743
+ const job = await readTwilioOutboundCallJob(this.options.outputDir, this.metadata.outboundId);
2744
+ if (!job)
2745
+ return;
2746
+ await updateTwilioOutboundCallJob(this.options.outputDir, job.outboundId, {
2747
+ status: "amd-timeout",
2748
+ transportCallSid: this.metadata.callId,
2749
+ events: [
2750
+ ...(job.events ?? []),
2751
+ { at: new Date().toISOString(), status: "amd-timeout", callSid: this.metadata.callId },
2752
+ ],
2753
+ });
2754
+ }
2755
+ startInitialGreetingFlow() {
2756
+ if (this.outboundAmdState === "pending") {
2757
+ this.armOutboundAmdGreetingTimeout();
2758
+ (0, runtime_1.emitNervesEvent)({
2759
+ component: "senses",
2760
+ event: "senses.voice_openai_sip_amd_greeting_hold",
2761
+ message: "OpenAI SIP outbound greeting is held until async AMD reports a human answer",
2762
+ meta: {
2763
+ agentName: this.options.agentName,
2764
+ callId: safeSegment(this.metadata.callId),
2765
+ outboundId: safeSegment(this.metadata.outboundId || "unknown"),
2766
+ },
2767
+ });
2768
+ return;
2769
+ }
2770
+ if (this.outboundAmdState === "nonhuman" || this.outboundAmdState === "timeout") {
2771
+ void this.hangup(`amd_${this.outboundAmdState}`);
2772
+ return;
2773
+ }
2774
+ this.resumeAfterHumanAmdIfNeeded();
2775
+ }
2776
+ sendInitialGreeting() {
2777
+ if (this.initialGreetingSent)
2778
+ return;
2779
+ if (!this.openaiWs || this.openaiWs.readyState !== ws_1.WebSocket.OPEN)
2780
+ return;
2781
+ this.initialGreetingSent = true;
2782
+ this.requestRealtimeResponse({
2783
+ instructions: openAISipCallConnectedPrompt(this.metadata, this.options.openaiRealtime?.voiceStyle),
2784
+ });
2785
+ }
2786
+ handleOpenAIMessage(raw) {
2787
+ let event;
2788
+ try {
2789
+ event = JSON.parse(Buffer.from(raw).toString("utf8"));
2790
+ }
2791
+ catch {
2792
+ return;
2793
+ }
2794
+ const type = typeof event.type === "string" ? event.type : "";
2795
+ if (type === "response.created") {
2796
+ this.noteRealtimeResponseCreated(event);
2797
+ return;
2798
+ }
2799
+ if (type === "conversation.item.input_audio_transcription.completed" && typeof event.transcript === "string") {
2800
+ this.recordOutboundAmdTranscriptCandidate(event.transcript);
2801
+ this.appendTranscript("user", event.transcript);
2802
+ return;
2803
+ }
2804
+ if (type === "response.output_audio_transcript.done" && typeof event.transcript === "string") {
2805
+ this.appendTranscript("assistant", event.transcript);
2806
+ return;
2807
+ }
2808
+ if (type === "response.function_call_arguments.done") {
2809
+ void this.runRealtimeTool(event);
2810
+ return;
2811
+ }
2812
+ if (type === "response.done") {
2813
+ const responseId = realtimeResponseId(event);
2814
+ this.noteRealtimeResponseDone(responseId);
2815
+ if (this.completeRealtimeToolResponse(responseId))
2816
+ return;
2817
+ this.completeHangupIfReady("response_done");
2818
+ return;
2819
+ }
2820
+ if (type === "error") {
2821
+ (0, runtime_1.emitNervesEvent)({
2822
+ level: "error",
2823
+ component: "senses",
2824
+ event: "senses.voice_openai_sip_event_error",
2825
+ message: "OpenAI Realtime emitted an error during SIP call",
2826
+ meta: { agentName: this.options.agentName, callId: safeSegment(this.metadata.callId), event: JSON.stringify(event).slice(0, 500) },
2827
+ });
2828
+ }
2829
+ }
2830
+ registerRealtimeToolResponse(responseId, callId) {
2831
+ if (!responseId)
2832
+ return undefined;
2833
+ const existing = this.toolResponses.get(responseId);
2834
+ const state = existing ?? {
2835
+ pendingCallIds: new Set(),
2836
+ responseDone: this.completedRealtimeResponseIds.has(responseId),
2837
+ followupRequested: false,
2838
+ suppressFollowup: false,
2839
+ };
2840
+ state.pendingCallIds.add(callId);
2841
+ if (!existing)
2842
+ this.toolResponses.set(responseId, state);
2843
+ return state;
2844
+ }
2845
+ completeRealtimeToolCall(responseId, callId) {
2846
+ if (!responseId)
2847
+ return false;
2848
+ const state = this.toolResponses.get(responseId);
2849
+ if (!state)
2850
+ return false;
2851
+ state.pendingCallIds.delete(callId);
2852
+ return this.maybeCreateRealtimeToolFollowup(responseId, state);
2853
+ }
2854
+ completeRealtimeToolResponse(responseId) {
2855
+ if (!responseId)
2856
+ return false;
2857
+ this.completedRealtimeResponseIds.add(responseId);
2858
+ const state = this.toolResponses.get(responseId);
2859
+ if (!state)
2860
+ return false;
2861
+ state.responseDone = true;
2862
+ this.maybeCreateRealtimeToolFollowup(responseId, state);
2863
+ return true;
2864
+ }
2865
+ maybeCreateRealtimeToolFollowup(responseId, state) {
2866
+ if (!state.responseDone || state.pendingCallIds.size > 0 || state.followupRequested)
2867
+ return false;
2868
+ state.followupRequested = true;
2869
+ this.toolResponses.delete(responseId);
2870
+ if (state.suppressFollowup) {
2871
+ this.completeHangupIfReady("tool_response_done");
2872
+ return true;
2873
+ }
2874
+ this.requestRealtimeResponse();
2875
+ return true;
2876
+ }
2877
+ async runRealtimeTool(event) {
2878
+ const name = typeof event.name === "string" ? event.name : "";
2879
+ const callId = typeof event.call_id === "string" ? event.call_id : "";
2880
+ if (!name || !callId)
2881
+ return;
2882
+ const responseId = realtimeResponseId(event);
2883
+ const toolState = this.registerRealtimeToolResponse(responseId, callId);
2884
+ const coordinated = !!toolState;
2885
+ if (name === "voice_end_call" && toolState)
2886
+ toolState.suppressFollowup = true;
2887
+ let output;
2888
+ try {
2889
+ const args = parseToolArguments(typeof event.arguments === "string" ? event.arguments : "");
2890
+ (0, runtime_1.emitNervesEvent)({
2891
+ component: "senses",
2892
+ event: "senses.voice_openai_sip_tool_start",
2893
+ message: "OpenAI SIP voice tool call started",
2894
+ meta: { agentName: this.options.agentName, callId: safeSegment(this.metadata.callId), tool: name },
2895
+ });
2896
+ output = await (0, tools_1.execTool)(name, args, this.toolContext);
2897
+ (0, runtime_1.emitNervesEvent)({
2898
+ component: "senses",
2899
+ event: "senses.voice_openai_sip_tool_end",
2900
+ message: "OpenAI SIP voice tool call completed",
2901
+ meta: { agentName: this.options.agentName, callId: safeSegment(this.metadata.callId), tool: name },
2902
+ });
2903
+ }
2904
+ catch (error) {
2905
+ output = `[tool error] ${errorMessage(error)}`;
2906
+ (0, runtime_1.emitNervesEvent)({
2907
+ level: "error",
2908
+ component: "senses",
2909
+ event: "senses.voice_openai_sip_tool_error",
2910
+ message: "OpenAI SIP voice tool call failed",
2911
+ meta: { agentName: this.options.agentName, callId: safeSegment(this.metadata.callId), tool: name, error: errorMessage(error) },
2912
+ });
2913
+ }
2914
+ this.sendOpenAI({
2915
+ type: "conversation.item.create",
2916
+ item: {
2917
+ type: "function_call_output",
2918
+ call_id: callId,
2919
+ output,
2920
+ },
2921
+ });
2922
+ if (!this.completeRealtimeToolCall(responseId, callId) && !coordinated) {
2923
+ this.requestRealtimeResponse();
2924
+ }
2925
+ }
2926
+ noteRealtimeResponseCreated(event) {
2927
+ const responseId = realtimeResponseId(event);
2928
+ if (responseId)
2929
+ this.activeRealtimeResponseId = responseId;
2930
+ }
2931
+ noteRealtimeResponseDone(responseId) {
2932
+ if (!responseId || this.activeRealtimeResponseId === responseId) {
2933
+ this.activeRealtimeResponseId = null;
2934
+ }
2935
+ this.responseCreateHoldUntilMs = Math.max(this.responseCreateHoldUntilMs, Date.now() + OPENAI_REALTIME_RESPONSE_CREATE_GRACE_MS);
2936
+ this.schedulePendingRealtimeResponse(OPENAI_REALTIME_RESPONSE_CREATE_GRACE_MS);
2937
+ }
2938
+ requestRealtimeResponse(response) {
2939
+ if (this.closed)
2940
+ return;
2941
+ const waitMs = Math.max(0, this.responseCreateHoldUntilMs - Date.now());
2942
+ if (this.activeRealtimeResponseId || waitMs > 0) {
2943
+ const pendingResponse = response ?? this.pendingRealtimeResponse?.response;
2944
+ this.pendingRealtimeResponse = pendingResponse ? { response: pendingResponse } : {};
2945
+ if (!this.activeRealtimeResponseId)
2946
+ this.schedulePendingRealtimeResponse(waitMs);
2947
+ return;
2948
+ }
2949
+ this.sendRealtimeResponseCreate(response ? { response } : {});
2950
+ }
2951
+ schedulePendingRealtimeResponse(delayMs) {
2952
+ if (!this.pendingRealtimeResponse)
2953
+ return;
2954
+ if (this.pendingRealtimeResponseTimer)
2955
+ clearTimeout(this.pendingRealtimeResponseTimer);
2956
+ this.pendingRealtimeResponseTimer = setTimeout(() => {
2957
+ this.pendingRealtimeResponseTimer = null;
2958
+ this.flushPendingRealtimeResponse();
2959
+ }, Math.max(0, delayMs));
2960
+ this.pendingRealtimeResponseTimer.unref?.();
2961
+ }
2962
+ flushPendingRealtimeResponse() {
2963
+ if (!this.pendingRealtimeResponse || this.closed || this.activeRealtimeResponseId)
2964
+ return;
2965
+ const waitMs = Math.max(0, this.responseCreateHoldUntilMs - Date.now());
2966
+ if (waitMs > 0) {
2967
+ this.schedulePendingRealtimeResponse(waitMs);
2968
+ return;
2969
+ }
2970
+ const pending = this.pendingRealtimeResponse;
2971
+ this.pendingRealtimeResponse = null;
2972
+ this.sendRealtimeResponseCreate(pending);
2973
+ }
2974
+ sendRealtimeResponseCreate(request) {
2975
+ this.sendOpenAI({
2976
+ type: "response.create",
2977
+ ...(request.response ? { response: request.response } : {}),
2978
+ });
2979
+ }
2980
+ requestHangupFromTool() {
2981
+ if (this.closed)
2982
+ return;
2983
+ this.hangupRequested = true;
2984
+ setTimeout(() => this.completeHangupIfReady("tool_fallback"), 7_500).unref?.();
2985
+ }
2986
+ completeHangupIfReady(trigger) {
2987
+ if (!this.hangupRequested || this.closed || this.hangupStarted)
2988
+ return;
2989
+ this.hangupStarted = true;
2990
+ void this.hangup(trigger);
2991
+ }
2992
+ async hangup(trigger) {
2993
+ const realtime = this.options.openaiRealtime;
2994
+ const sip = this.options.openaiSip;
2995
+ if (!realtime?.apiKey?.trim() || !sip) {
2996
+ this.close(trigger);
2997
+ return;
2998
+ }
2999
+ try {
3000
+ const response = await (sip.fetch ?? fetch)(openAISipCallActionUrl(sip, this.metadata.callId, "hangup"), {
3001
+ method: "POST",
3002
+ headers: { authorization: `Bearer ${realtime.apiKey.trim()}` },
3003
+ });
3004
+ if (!response.ok) {
3005
+ const responseText = await response.text().catch(() => "");
3006
+ throw new Error(`OpenAI SIP call hangup failed: ${response.status} ${responseText}`.trim());
3007
+ }
3008
+ (0, runtime_1.emitNervesEvent)({
3009
+ component: "senses",
3010
+ event: "senses.voice_openai_sip_hangup_end",
3011
+ message: "OpenAI SIP phone call hangup requested",
3012
+ meta: { agentName: this.options.agentName, callId: safeSegment(this.metadata.callId), trigger },
3013
+ });
3014
+ }
3015
+ catch (error) {
3016
+ (0, runtime_1.emitNervesEvent)({
3017
+ level: "error",
3018
+ component: "senses",
3019
+ event: "senses.voice_openai_sip_hangup_error",
3020
+ message: "OpenAI SIP phone call hangup request failed",
3021
+ meta: { agentName: this.options.agentName, callId: safeSegment(this.metadata.callId), trigger, error: errorMessage(error) },
3022
+ });
3023
+ }
3024
+ finally {
3025
+ this.close(trigger);
3026
+ }
3027
+ }
3028
+ sendOpenAI(event) {
3029
+ if (!this.openaiWs || this.openaiWs.readyState !== ws_1.WebSocket.OPEN)
3030
+ return;
3031
+ this.openaiWs.send(JSON.stringify(event));
3032
+ }
3033
+ appendTranscript(role, text) {
3034
+ const content = text.trim();
3035
+ if (!content || !this.sessionPath)
3036
+ return;
3037
+ this.sessionMessages.push({ role, content });
3038
+ (0, context_1.saveSession)(this.sessionPath, this.sessionMessages);
3039
+ }
3040
+ close(trigger) {
3041
+ if (this.closed)
3042
+ return;
3043
+ this.closed = true;
3044
+ this.clearOutboundAmdGreetingTimeout();
3045
+ this.registry?.unregister(this);
3046
+ if (this.openaiWs && (this.openaiWs.readyState === ws_1.WebSocket.OPEN || this.openaiWs.readyState === ws_1.WebSocket.CONNECTING)) {
3047
+ this.openaiWs.close();
3048
+ }
3049
+ if (this.pendingRealtimeResponseTimer) {
3050
+ clearTimeout(this.pendingRealtimeResponseTimer);
3051
+ this.pendingRealtimeResponseTimer = null;
3052
+ }
3053
+ (0, runtime_1.emitNervesEvent)({
3054
+ component: "senses",
3055
+ event: "senses.voice_openai_sip_call_stop",
3056
+ message: "OpenAI SIP phone call control session stopped",
3057
+ meta: { agentName: this.options.agentName, callId: safeSegment(this.metadata.callId), trigger },
3058
+ });
3059
+ }
3060
+ }
3061
+ /* v8 ignore stop */
3062
+ /* v8 ignore start -- active SIP registry map edge permutations belong with the post-D-012 transport split; SIP call lifecycle is covered through bridge tests @preserve */
3063
+ class ActiveOpenAISipSessions {
3064
+ byCallId = new Map();
3065
+ byOutboundId = new Map();
3066
+ register(session) {
3067
+ if (session.callId)
3068
+ this.byCallId.set(session.callId, session);
3069
+ if (session.outboundId)
3070
+ this.byOutboundId.set(session.outboundId, session);
3071
+ }
3072
+ unregister(session) {
3073
+ if (session.callId && this.byCallId.get(session.callId) === session)
3074
+ this.byCallId.delete(session.callId);
3075
+ if (session.outboundId && this.byOutboundId.get(session.outboundId) === session)
3076
+ this.byOutboundId.delete(session.outboundId);
3077
+ }
3078
+ getByOutboundId(outboundId) {
3079
+ return this.byOutboundId.get(outboundId);
3080
+ }
3081
+ }
3082
+ function parseRecordingParams(params) {
3083
+ const callSid = params.CallSid?.trim();
3084
+ const recordingSid = params.RecordingSid?.trim();
3085
+ const recordingUrl = params.RecordingUrl?.trim();
3086
+ if (!callSid || !recordingSid || !recordingUrl)
3087
+ return null;
3088
+ return {
3089
+ callSid,
3090
+ recordingSid,
3091
+ recordingUrl,
3092
+ from: params.From?.trim() ?? "",
3093
+ to: params.To?.trim() ?? "",
3094
+ };
3095
+ }
3096
+ function recordAgainResponse(options, basePath, message) {
3097
+ return xmlResponse(`${sayTwiml(message)}${recordTwiml({
3098
+ publicBaseUrl: options.publicBaseUrl,
3099
+ basePath,
3100
+ timeoutSeconds: options.recordTimeoutSeconds ?? exports.DEFAULT_TWILIO_RECORD_TIMEOUT_SECONDS,
3101
+ maxLengthSeconds: options.recordMaxLengthSeconds ?? exports.DEFAULT_TWILIO_RECORD_MAX_LENGTH_SECONDS,
3102
+ })}`);
3103
+ }
3104
+ function errorMessage(error) {
3105
+ return error instanceof Error ? error.message : String(error);
3106
+ }
3107
+ function nextInputTwiml(options, basePath, mode) {
3108
+ if (mode === "redirect")
3109
+ return redirectTwiml(options.publicBaseUrl, basePath);
3110
+ return recordTwiml({
3111
+ publicBaseUrl: options.publicBaseUrl,
3112
+ basePath,
3113
+ timeoutSeconds: options.recordTimeoutSeconds ?? exports.DEFAULT_TWILIO_RECORD_TIMEOUT_SECONDS,
3114
+ maxLengthSeconds: options.recordMaxLengthSeconds ?? exports.DEFAULT_TWILIO_RECORD_MAX_LENGTH_SECONDS,
3115
+ });
3116
+ }
3117
+ /* v8 ignore start -- streaming job internals are covered through Twilio bridge playback routes; timeout/failure interleavings depend on request timing @preserve */
3118
+ class TwilioAudioStreamJob {
3119
+ callSid;
3120
+ jobId;
3121
+ mimeType;
3122
+ chunks = [];
3123
+ waiters = new Set();
3124
+ status = "pending";
3125
+ failure = null;
3126
+ byteLength = 0;
3127
+ constructor(callSid, jobId, mimeType) {
3128
+ this.callSid = callSid;
3129
+ this.jobId = jobId;
3130
+ this.mimeType = mimeType;
3131
+ }
3132
+ append(chunk) {
3133
+ /* v8 ignore next -- append is only called while pending with non-empty chunks in bridge flow @preserve */
3134
+ if (this.status !== "pending" || chunk.byteLength === 0)
3135
+ return;
3136
+ const buffered = Buffer.from(chunk);
3137
+ this.chunks.push(buffered);
3138
+ this.byteLength += buffered.byteLength;
3139
+ this.notify();
3140
+ }
3141
+ complete() {
3142
+ /* v8 ignore next -- completion is single-shot inside startTwilioPlaybackStreamJob @preserve */
3143
+ if (this.status !== "pending")
3144
+ return;
3145
+ this.status = "completed";
3146
+ this.notify();
3147
+ }
3148
+ fail(error) {
3149
+ /* v8 ignore next -- failure is single-shot inside startTwilioPlaybackStreamJob @preserve */
3150
+ if (this.status !== "pending")
3151
+ return;
3152
+ if (this.byteLength === 0) {
3153
+ this.append(TWILIO_STREAM_FAILURE_SILENCE_MP3);
3154
+ }
3155
+ this.status = "failed";
3156
+ this.failure = errorMessage(error);
3157
+ this.notify();
3158
+ }
3159
+ waitForFirstChunk(timeoutMs) {
3160
+ if (this.byteLength > 0)
3161
+ return Promise.resolve("ready");
3162
+ if (this.status === "completed")
3163
+ return Promise.resolve("completed");
3164
+ if (this.status === "failed")
3165
+ return Promise.resolve("failed");
3166
+ if (timeoutMs <= 0)
3167
+ return Promise.resolve("timeout");
3168
+ return new Promise((resolve) => {
3169
+ let settled = false;
3170
+ let timeout;
3171
+ const finish = (state) => {
3172
+ if (settled)
3173
+ return;
3174
+ settled = true;
3175
+ if (timeout)
3176
+ clearTimeout(timeout);
3177
+ this.waiters.delete(waiter);
3178
+ resolve(state);
3179
+ };
3180
+ const waiter = () => {
3181
+ if (this.byteLength > 0) {
3182
+ finish("ready");
3183
+ }
3184
+ else if (this.status === "completed") {
3185
+ finish("completed");
3186
+ }
3187
+ else if (this.status === "failed") {
3188
+ finish("failed");
3189
+ }
3190
+ };
3191
+ this.waiters.add(waiter);
3192
+ timeout = setTimeout(() => finish("timeout"), timeoutMs);
3193
+ timeout.unref?.();
3194
+ });
3195
+ }
3196
+ async *stream() {
3197
+ let index = 0;
3198
+ let yielded = false;
3199
+ for (;;) {
3200
+ while (index < this.chunks.length) {
3201
+ yielded = true;
3202
+ yield this.chunks[index++];
3203
+ }
3204
+ if (this.status === "completed")
3205
+ return;
3206
+ if (this.status === "failed") {
3207
+ if (yielded)
3208
+ return;
3209
+ throw new Error(this.failure);
3210
+ }
3211
+ await new Promise((resolve) => {
3212
+ this.waiters.add(resolve);
3213
+ });
3214
+ }
3215
+ }
3216
+ notify() {
3217
+ const waiters = [...this.waiters];
3218
+ this.waiters.clear();
3219
+ for (const waiter of waiters)
3220
+ waiter();
3221
+ }
3222
+ }
3223
+ /* v8 ignore stop */
3224
+ class TwilioAudioStreamJobStore {
3225
+ jobs = new Map();
3226
+ create(callSid, jobId, mimeType = "audio/mpeg") {
3227
+ const key = this.key(callSid, jobId);
3228
+ const job = new TwilioAudioStreamJob(callSid, jobId, mimeType);
3229
+ this.jobs.set(key, job);
3230
+ return job;
3231
+ }
3232
+ get(callSid, jobId) {
3233
+ return this.jobs.get(this.key(callSid, jobId)) ?? null;
3234
+ }
3235
+ /* v8 ignore start -- stream job cleanup is delayed beyond request-scope tests @preserve */
3236
+ delete(callSid, jobId) {
3237
+ this.jobs.delete(this.key(callSid, jobId));
3238
+ }
3239
+ /* v8 ignore stop */
3240
+ key(callSid, jobId) {
3241
+ return `${callSid}/${jobId}`;
3242
+ }
3243
+ }
3244
+ function deliveredSegments(turn) {
3245
+ return turn.speechSegments.map((segment) => segment.tts);
3246
+ }
3247
+ async function writeVoiceTurnPlaybackArtifacts(options) {
3248
+ const urls = [];
3249
+ for (const segment of options.turn.speechSegments) {
3250
+ const playback = await (0, playback_1.writeVoicePlaybackArtifact)({
3251
+ utteranceId: segment.utteranceId,
3252
+ delivery: segment.tts,
3253
+ outputDir: options.callDir,
3254
+ });
3255
+ urls.push(routeUrl(options.bridgeOptions.publicBaseUrl, `${options.basePath}/audio/${encodeURIComponent(options.safeCallSid)}/${encodeURIComponent(path.basename(playback.audioPath))}`));
3256
+ }
3257
+ return urls;
3258
+ }
3259
+ function playManyTwiml(urls) {
3260
+ return urls.map(playTwiml).join("");
3261
+ }
3262
+ function streamAudioUrl(options, basePath, safeCallSid, jobId) {
3263
+ return routeUrl(options.publicBaseUrl, `${basePath}/audio-stream/${encodeURIComponent(safeCallSid)}/${encodeURIComponent(`${jobId}.mp3`)}`);
3264
+ }
3265
+ function scheduleJobCleanup(jobs, safeCallSid, jobId) {
3266
+ /* v8 ignore start -- stream job cleanup is delayed beyond request-scope tests @preserve */
3267
+ const cleanup = setTimeout(() => {
3268
+ jobs.delete(safeCallSid, jobId);
3269
+ }, 5 * 60_000);
3270
+ cleanup.unref?.();
3271
+ /* v8 ignore stop */
3272
+ }
3273
+ function startTwilioPlaybackStreamJob(options) {
3274
+ const job = options.jobs.create(options.safeCallSid, options.jobId);
3275
+ void (async () => {
3276
+ try {
3277
+ const turn = await options.runTurn((chunk) => job.append(chunk));
3278
+ const deliveries = deliveredSegments(turn);
3279
+ if (job.byteLength === 0 && deliveries.length > 0) {
3280
+ for (const delivery of deliveries)
3281
+ job.append(delivery.audio);
3282
+ }
3283
+ if (deliveries.length === 0) {
3284
+ /* v8 ignore next -- runVoiceLoopbackTurn cannot return delivered TTS with zero speech segments @preserve */
3285
+ if (turn.tts.status === "failed")
3286
+ throw new Error(turn.tts.error);
3287
+ /* v8 ignore next -- runVoiceLoopbackTurn emits a speech segment whenever TTS is delivered @preserve */
3288
+ throw new Error("voice turn produced no audio");
3289
+ }
3290
+ try {
3291
+ await writeVoiceTurnPlaybackArtifacts({
3292
+ bridgeOptions: options.bridgeOptions,
3293
+ basePath: options.basePath,
3294
+ callDir: options.callDir,
3295
+ safeCallSid: options.safeCallSid,
3296
+ baseUtteranceId: options.baseUtteranceId,
3297
+ turn,
3298
+ });
3299
+ }
3300
+ catch (artifactError) {
3301
+ (0, runtime_1.emitNervesEvent)({
3302
+ level: "warn",
3303
+ component: "senses",
3304
+ event: "senses.voice_twilio_stream_artifact_error",
3305
+ message: "Twilio stream audio was delivered but artifact persistence failed",
3306
+ meta: { ...options.meta, error: errorMessage(artifactError) },
3307
+ });
3308
+ }
3309
+ job.complete();
3310
+ (0, runtime_1.emitNervesEvent)({
3311
+ component: "senses",
3312
+ event: "senses.voice_twilio_stream_end",
3313
+ message: "finished Twilio streaming voice playback job",
3314
+ meta: { ...options.meta, byteLength: String(job.byteLength), segmentCount: String(deliveries.length) },
3315
+ });
3316
+ }
3317
+ catch (error) {
3318
+ job.fail(error);
3319
+ (0, runtime_1.emitNervesEvent)({
3320
+ level: "error",
3321
+ component: "senses",
3322
+ event: "senses.voice_twilio_stream_error",
3323
+ message: "Twilio streaming voice playback job failed",
3324
+ meta: { ...options.meta, error: errorMessage(error) },
3325
+ });
3326
+ }
3327
+ finally {
3328
+ scheduleJobCleanup(options.jobs, options.safeCallSid, options.jobId);
3329
+ }
3330
+ })();
3331
+ return job;
3332
+ }
3333
+ async function runPhonePromptTurn(options) {
3334
+ const transcript = (0, transcript_1.buildVoiceTranscript)({
3335
+ utteranceId: options.utteranceId,
3336
+ text: options.promptText,
3337
+ source: "loopback",
3338
+ });
3339
+ const turn = await (0, turn_1.runVoiceLoopbackTurn)({
3340
+ agentName: options.bridgeOptions.agentName,
3341
+ friendId: options.friendId,
3342
+ sessionKey: options.sessionKey,
3343
+ transcript,
3344
+ tts: options.bridgeOptions.tts,
3345
+ runSenseTurn: options.bridgeOptions.runSenseTurn,
3346
+ });
3347
+ const after = nextInputTwiml(options.bridgeOptions, options.basePath, options.afterPlayback);
3348
+ if (turn.tts.status !== "delivered") {
3349
+ return xmlResponse(`${sayTwiml("voice output failed after the text response was captured.")}${after}`);
3350
+ }
3351
+ const audioUrls = await writeVoiceTurnPlaybackArtifacts({
3352
+ bridgeOptions: options.bridgeOptions,
3353
+ basePath: options.basePath,
3354
+ callDir: options.callDir,
3355
+ safeCallSid: options.safeCallSid,
3356
+ baseUtteranceId: options.utteranceId,
3357
+ turn,
3358
+ });
3359
+ return xmlResponse(`${playManyTwiml(audioUrls)}${after}`);
3360
+ }
3361
+ function computeTwilioSignature(input) {
3362
+ const payload = input.url + Object.keys(input.params)
3363
+ .sort()
3364
+ .map((key) => `${key}${input.params[key]}`)
3365
+ .join("");
3366
+ return crypto.createHmac("sha1", input.authToken).update(payload).digest("base64");
3367
+ }
3368
+ function validateTwilioSignature(input) {
3369
+ if (!input.authToken.trim())
3370
+ return true;
3371
+ if (!input.signature.trim())
3372
+ return false;
3373
+ const expected = Buffer.from(computeTwilioSignature(input));
3374
+ const actual = Buffer.from(input.signature);
3375
+ return actual.length === expected.length && crypto.timingSafeEqual(actual, expected);
3376
+ }
3377
+ function twilioRecordingMediaUrl(recordingUrl) {
3378
+ const url = new URL(recordingUrl);
3379
+ if (!/\.[A-Za-z0-9]+$/.test(url.pathname)) {
3380
+ url.pathname = `${url.pathname}.wav`;
3381
+ }
3382
+ return url.toString();
3383
+ }
3384
+ async function defaultTwilioRecordingDownloader(request) {
3385
+ const headers = {};
3386
+ if (request.accountSid && request.authToken) {
3387
+ headers.Authorization = `Basic ${Buffer.from(`${request.accountSid}:${request.authToken}`).toString("base64")}`;
3388
+ }
3389
+ const response = await fetch(request.recordingUrl, { headers });
3390
+ if (!response.ok) {
3391
+ throw new Error(`Twilio recording download failed: ${response.status} ${response.statusText}`.trim());
3392
+ }
3393
+ return Buffer.from(await response.arrayBuffer());
3394
+ }
3395
+ function twilioOutboundCallJobDir(outputDir) {
3396
+ return path.join(outputDir, "outbound");
3397
+ }
3398
+ function twilioOutboundCallJobPath(outputDir, outboundId) {
3399
+ return path.join(twilioOutboundCallJobDir(outputDir), `${safeSegment(outboundId)}.json`);
3400
+ }
3401
+ async function writeTwilioOutboundCallJob(outputDir, job) {
3402
+ await fs.mkdir(twilioOutboundCallJobDir(outputDir), { recursive: true });
3403
+ await fs.writeFile(twilioOutboundCallJobPath(outputDir, job.outboundId), `${JSON.stringify(job, null, 2)}\n`, "utf8");
3404
+ }
3405
+ async function readTwilioOutboundCallJob(outputDir, outboundId) {
3406
+ try {
3407
+ const raw = await fs.readFile(twilioOutboundCallJobPath(outputDir, outboundId), "utf8");
3408
+ const parsed = JSON.parse(raw);
3409
+ return parsed && typeof parsed === "object" && parsed.schemaVersion === 1 ? parsed : null;
3410
+ }
3411
+ catch {
3412
+ return null;
3413
+ }
3414
+ }
3415
+ async function updateTwilioOutboundCallJob(outputDir, outboundId, update) {
3416
+ const existing = await readTwilioOutboundCallJob(outputDir, outboundId);
3417
+ if (!existing)
3418
+ return null;
3419
+ const next = {
3420
+ ...existing,
3421
+ ...update,
3422
+ outboundId: existing.outboundId,
3423
+ schemaVersion: 1,
3424
+ updatedAt: update.updatedAt ?? new Date().toISOString(),
3425
+ };
3426
+ await writeTwilioOutboundCallJob(outputDir, next);
3427
+ return next;
3428
+ }
3429
+ async function readRecentTwilioOutboundCallJobs(options) {
3430
+ const now = options.now ?? Date.now();
3431
+ let files;
3432
+ try {
3433
+ files = await fs.readdir(twilioOutboundCallJobDir(options.outputDir));
3434
+ }
3435
+ catch {
3436
+ return [];
3437
+ }
3438
+ const jobs = [];
3439
+ for (const file of files) {
3440
+ if (!file.endsWith(".json"))
3441
+ continue;
3442
+ const outboundId = file.slice(0, -".json".length);
3443
+ const job = await readTwilioOutboundCallJob(options.outputDir, outboundId);
3444
+ if (!job)
3445
+ continue;
3446
+ const createdAtMs = Date.parse(job.createdAt);
3447
+ if (!Number.isFinite(createdAtMs) || now - createdAtMs > options.sinceMs)
3448
+ continue;
3449
+ if (options.to && (0, phone_1.normalizeTwilioE164PhoneNumber)(job.to) !== (0, phone_1.normalizeTwilioE164PhoneNumber)(options.to))
3450
+ continue;
3451
+ if (options.friendId && job.friendId !== options.friendId)
3452
+ continue;
3453
+ jobs.push(job);
3454
+ }
3455
+ return jobs;
3456
+ }
3457
+ async function createTwilioOutboundCall(request, fetchImpl = fetch) {
3458
+ const accountSid = request.accountSid.trim();
3459
+ const authToken = request.authToken.trim();
3460
+ const to = (0, phone_1.normalizeTwilioE164PhoneNumber)(request.to);
3461
+ const from = (0, phone_1.normalizeTwilioE164PhoneNumber)(request.from);
3462
+ if (!accountSid)
3463
+ throw new Error("missing Twilio account SID for outbound voice call");
3464
+ if (!authToken)
3465
+ throw new Error("missing Twilio auth token for outbound voice call");
3466
+ if (!to)
3467
+ throw new Error("outbound voice call target must be an E.164 phone number");
3468
+ if (!from)
3469
+ throw new Error("outbound voice call caller ID must be an E.164 phone number");
3470
+ const body = new URLSearchParams();
3471
+ body.set("To", to);
3472
+ body.set("From", from);
3473
+ body.set("Url", request.twimlUrl);
3474
+ body.set("Method", "POST");
3475
+ if (request.machineDetection) {
3476
+ body.set("MachineDetection", request.machineDetection);
3477
+ }
3478
+ if (request.asyncAmd === true) {
3479
+ body.set("AsyncAmd", "true");
3480
+ }
3481
+ if (request.asyncAmdStatusCallbackUrl) {
3482
+ body.set("AsyncAmdStatusCallback", request.asyncAmdStatusCallbackUrl);
3483
+ body.set("AsyncAmdStatusCallbackMethod", "POST");
3484
+ }
3485
+ if (request.statusCallbackUrl) {
3486
+ body.set("StatusCallback", request.statusCallbackUrl);
3487
+ body.set("StatusCallbackMethod", "POST");
3488
+ for (const event of ["initiated", "ringing", "answered", "completed"]) {
3489
+ body.append("StatusCallbackEvent", event);
3490
+ }
3491
+ }
3492
+ const response = await fetchImpl(`https://api.twilio.com/2010-04-01/Accounts/${encodeURIComponent(accountSid)}/Calls.json`, {
3493
+ method: "POST",
3494
+ headers: {
3495
+ authorization: `Basic ${Buffer.from(`${accountSid}:${authToken}`).toString("base64")}`,
3496
+ "content-type": "application/x-www-form-urlencoded",
3497
+ },
3498
+ body,
3499
+ });
3500
+ const responseText = await response.text();
3501
+ let parsed = {};
3502
+ if (responseText.trim()) {
3503
+ try {
3504
+ parsed = JSON.parse(responseText);
3505
+ }
3506
+ catch {
3507
+ parsed = {};
3508
+ }
3509
+ }
3510
+ if (!response.ok) {
3511
+ const message = typeof parsed.message === "string" ? parsed.message : responseText;
3512
+ throw new Error(`Twilio outbound voice call failed: ${response.status} ${message}`.trim());
3513
+ }
3514
+ return {
3515
+ callSid: typeof parsed.sid === "string" ? parsed.sid : undefined,
3516
+ status: typeof parsed.status === "string" ? parsed.status : undefined,
3517
+ queueTime: typeof parsed.queue_time === "string" ? parsed.queue_time : undefined,
3518
+ };
3519
+ }
3520
+ function verifyRequest(options, request, params) {
3521
+ const authToken = options.twilioAuthToken?.trim();
3522
+ if (!authToken)
3523
+ return true;
3524
+ return validateTwilioSignature({
3525
+ authToken,
3526
+ url: requestPublicUrl(options.publicBaseUrl, request.path),
3527
+ params,
3528
+ signature: headerValue(request.headers, "x-twilio-signature"),
3529
+ });
3530
+ }
3531
+ async function handleOpenAISipWebhook(options, request, activeSipSessions) {
3532
+ const rawBody = bodyText(request.body);
3533
+ const sip = options.openaiSip;
3534
+ const webhookSecret = sip?.webhookSecret?.trim();
3535
+ if (!webhookSecret && !sip?.allowUnsignedWebhooks) {
3536
+ (0, runtime_1.emitNervesEvent)({
3537
+ level: "warn",
3538
+ component: "senses",
3539
+ event: "senses.voice_openai_sip_webhook_unsigned_rejected",
3540
+ message: "rejected OpenAI SIP webhook because no signing secret is configured",
3541
+ meta: { agentName: options.agentName, path: request.path },
3542
+ });
3543
+ return textResponse(401, "OpenAI SIP webhook signing secret is not configured");
3544
+ }
3545
+ if (webhookSecret && !validateOpenAIWebhookSignature({
3546
+ secret: webhookSecret,
3547
+ headers: request.headers,
3548
+ payload: rawBody,
3549
+ })) {
3550
+ (0, runtime_1.emitNervesEvent)({
3551
+ level: "warn",
3552
+ component: "senses",
3553
+ event: "senses.voice_openai_sip_signature_rejected",
3554
+ message: "rejected OpenAI SIP webhook with invalid signature",
3555
+ meta: { agentName: options.agentName, path: request.path },
3556
+ });
3557
+ return textResponse(400, "invalid OpenAI webhook signature");
3558
+ }
3559
+ const event = parseOpenAISipWebhookEvent(rawBody);
3560
+ if (!event)
3561
+ return textResponse(400, "invalid OpenAI webhook payload");
3562
+ if (event.type !== "realtime.call.incoming")
3563
+ return textResponse(200, "ok");
3564
+ const metadata = openAISipCallMetadata(event);
3565
+ if (!metadata)
3566
+ return textResponse(400, "missing OpenAI SIP call metadata");
3567
+ const session = new OpenAISipPhoneSession(options, metadata, activeSipSessions);
3568
+ /* v8 ignore next -- async SIP session startup failures are logged inside the session; webhook request intentionally returns immediately @preserve */
3569
+ void session.start().catch(() => undefined);
3570
+ return textResponse(200, "ok");
3571
+ }
3572
+ /* v8 ignore start -- Twilio webhook routing is covered by bridge/server tests; branch matrix mostly reflects transport fallbacks and provider callback variants @preserve */
3573
+ async function handleIncoming(options, basePath, params, jobs) {
3574
+ const callSid = params.CallSid?.trim() || "incoming";
3575
+ const safeCallSid = safeSegment(callSid);
3576
+ const callDir = path.join(options.outputDir, safeCallSid);
3577
+ const utteranceId = `twilio-${safeCallSid}-connected`;
3578
+ const friendId = voiceFriendId(options, params.From?.trim() ?? "", callSid);
3579
+ const sessionKey = twilioPhoneVoiceSessionKey({
3580
+ defaultFriendId: options.defaultFriendId,
3581
+ from: params.From?.trim() ?? "",
3582
+ to: params.To?.trim() ?? "",
3583
+ callSid,
3584
+ });
3585
+ (0, runtime_1.emitNervesEvent)({
3586
+ component: "senses",
3587
+ event: "senses.voice_twilio_incoming",
3588
+ message: "Twilio voice call connected",
3589
+ meta: { agentName: options.agentName, callSid: safeCallSid, sessionKey },
3590
+ });
3591
+ if (usesOpenAISipConversationEngine(options)) {
3592
+ (0, runtime_1.emitNervesEvent)({
3593
+ component: "senses",
3594
+ event: "senses.voice_twilio_sip_connect",
3595
+ message: "answering Twilio call by dialing OpenAI SIP",
3596
+ meta: { agentName: options.agentName, callSid: safeCallSid, sessionKey, conversationEngine: "openai-sip" },
3597
+ });
3598
+ return xmlResponse(openAISipDialTwiml(options, openAISipResponseHeaders({
3599
+ Agent: options.agentName,
3600
+ Direction: "inbound",
3601
+ From: params.From,
3602
+ To: params.To,
3603
+ })));
3604
+ }
3605
+ if (normalizeTwilioPhoneTransportMode(options.transportMode) === "media-stream") {
3606
+ if (usesOpenAIRealtimeConversationEngine(options)) {
3607
+ (0, runtime_1.emitNervesEvent)({
3608
+ component: "senses",
3609
+ event: "senses.voice_twilio_media_connect",
3610
+ message: "answering Twilio call with OpenAI Realtime Media Stream",
3611
+ meta: { agentName: options.agentName, callSid: safeCallSid, sessionKey, conversationEngine: "openai-realtime" },
3612
+ });
3613
+ return xmlResponse(mediaStreamTwiml(options, basePath, params));
3614
+ }
3615
+ try {
3616
+ await fs.mkdir(callDir, { recursive: true });
3617
+ const transcript = (0, transcript_1.buildVoiceTranscript)({
3618
+ utteranceId,
3619
+ text: callConnectedPrompt(params),
3620
+ source: "loopback",
3621
+ });
3622
+ const greetingJobId = safeSegment(utteranceId);
3623
+ const job = startTwilioPlaybackStreamJob({
3624
+ jobs,
3625
+ bridgeOptions: options,
3626
+ basePath,
3627
+ callDir,
3628
+ safeCallSid,
3629
+ jobId: greetingJobId,
3630
+ baseUtteranceId: utteranceId,
3631
+ runTurn: (onAudioChunk) => (0, turn_1.runVoiceLoopbackTurn)({
3632
+ agentName: options.agentName,
3633
+ friendId,
3634
+ sessionKey,
3635
+ transcript,
3636
+ tts: options.tts,
3637
+ runSenseTurn: options.runSenseTurn,
3638
+ onAudioChunk,
3639
+ }),
3640
+ meta: { agentName: options.agentName, callSid: safeCallSid, utteranceId, transportMode: "media-stream" },
3641
+ });
3642
+ const prebufferState = await job.waitForFirstChunk(options.greetingPrebufferMs ?? exports.DEFAULT_TWILIO_GREETING_PREBUFFER_MS);
3643
+ (0, runtime_1.emitNervesEvent)({
3644
+ component: "senses",
3645
+ event: "senses.voice_twilio_greeting_prebuffer",
3646
+ message: "Twilio Media Stream greeting prebuffer completed",
3647
+ meta: { agentName: options.agentName, callSid: safeCallSid, utteranceId, state: prebufferState, transportMode: "media-stream" },
3648
+ });
3649
+ (0, runtime_1.emitNervesEvent)({
3650
+ component: "senses",
3651
+ event: "senses.voice_twilio_media_connect",
3652
+ message: "answering Twilio call with a bidirectional Media Stream",
3653
+ meta: { agentName: options.agentName, callSid: safeCallSid, sessionKey, greetingJob: prebufferState },
3654
+ });
3655
+ return xmlResponse(mediaStreamTwiml(options, basePath, params, prebufferState === "failed" ? undefined : greetingJobId));
3656
+ }
3657
+ catch (error) {
3658
+ (0, runtime_1.emitNervesEvent)({
3659
+ level: "error",
3660
+ component: "senses",
3661
+ event: "senses.voice_twilio_incoming_error",
3662
+ message: "Twilio incoming media-stream greeting turn failed",
3663
+ meta: { agentName: options.agentName, callSid: safeCallSid, error: errorMessage(error), transportMode: "media-stream" },
3664
+ });
3665
+ return xmlResponse(mediaStreamTwiml(options, basePath, params));
3666
+ }
3667
+ }
3668
+ try {
3669
+ await fs.mkdir(callDir, { recursive: true });
3670
+ if (normalizeTwilioPhonePlaybackMode(options.playbackMode) === "stream") {
3671
+ const transcript = (0, transcript_1.buildVoiceTranscript)({
3672
+ utteranceId,
3673
+ text: callConnectedPrompt(params),
3674
+ source: "loopback",
3675
+ });
3676
+ const jobId = safeSegment(utteranceId);
3677
+ const job = startTwilioPlaybackStreamJob({
3678
+ jobs,
3679
+ bridgeOptions: options,
3680
+ basePath,
3681
+ callDir,
3682
+ safeCallSid,
3683
+ jobId,
3684
+ baseUtteranceId: utteranceId,
3685
+ runTurn: (onAudioChunk) => (0, turn_1.runVoiceLoopbackTurn)({
3686
+ agentName: options.agentName,
3687
+ friendId,
3688
+ sessionKey,
3689
+ transcript,
3690
+ tts: options.tts,
3691
+ runSenseTurn: options.runSenseTurn,
3692
+ onAudioChunk,
3693
+ }),
3694
+ meta: { agentName: options.agentName, callSid: safeCallSid, utteranceId },
3695
+ });
3696
+ const prebufferState = await job.waitForFirstChunk(options.greetingPrebufferMs ?? exports.DEFAULT_TWILIO_GREETING_PREBUFFER_MS);
3697
+ (0, runtime_1.emitNervesEvent)({
3698
+ component: "senses",
3699
+ event: "senses.voice_twilio_greeting_prebuffer",
3700
+ message: "Twilio greeting prebuffer completed",
3701
+ meta: { agentName: options.agentName, callSid: safeCallSid, utteranceId, state: prebufferState },
3702
+ });
3703
+ if (prebufferState === "failed") {
3704
+ return xmlResponse(recordTwiml({
3705
+ publicBaseUrl: options.publicBaseUrl,
3706
+ basePath,
3707
+ timeoutSeconds: options.recordTimeoutSeconds ?? exports.DEFAULT_TWILIO_RECORD_TIMEOUT_SECONDS,
3708
+ maxLengthSeconds: options.recordMaxLengthSeconds ?? exports.DEFAULT_TWILIO_RECORD_MAX_LENGTH_SECONDS,
3709
+ }));
3710
+ }
3711
+ return xmlResponse(`${playTwiml(streamAudioUrl(options, basePath, safeCallSid, jobId))}${nextInputTwiml(options, basePath, "record")}`);
3712
+ }
3713
+ return await runPhonePromptTurn({
3714
+ bridgeOptions: options,
3715
+ basePath,
3716
+ callDir,
3717
+ safeCallSid,
3718
+ utteranceId,
3719
+ friendId,
3720
+ sessionKey,
3721
+ promptText: callConnectedPrompt(params),
3722
+ afterPlayback: "record",
3723
+ });
3724
+ }
3725
+ catch (error) {
3726
+ (0, runtime_1.emitNervesEvent)({
3727
+ level: "error",
3728
+ component: "senses",
3729
+ event: "senses.voice_twilio_incoming_error",
3730
+ message: "Twilio incoming voice greeting turn failed",
3731
+ meta: { agentName: options.agentName, callSid: safeCallSid, error: errorMessage(error) },
3732
+ });
3733
+ return xmlResponse(recordTwiml({
3734
+ publicBaseUrl: options.publicBaseUrl,
3735
+ basePath,
3736
+ timeoutSeconds: options.recordTimeoutSeconds ?? exports.DEFAULT_TWILIO_RECORD_TIMEOUT_SECONDS,
3737
+ maxLengthSeconds: options.recordMaxLengthSeconds ?? exports.DEFAULT_TWILIO_RECORD_MAX_LENGTH_SECONDS,
3738
+ }));
3739
+ }
3740
+ }
3741
+ async function handleOutgoing(options, basePath, outboundId, params, jobs) {
3742
+ const job = await readTwilioOutboundCallJob(options.outputDir, outboundId);
3743
+ if (!job)
3744
+ return textResponse(404, "outbound voice call not found");
3745
+ const callSid = params.CallSid?.trim() || job.transportCallSid || `outbound-${job.outboundId}`;
3746
+ const safeCallSid = safeSegment(callSid);
3747
+ const answeredBy = params.AnsweredBy?.trim() || undefined;
3748
+ const nonHumanStatus = nonHumanAnsweredStatus(answeredBy);
3749
+ if (nonHumanStatus) {
3750
+ await updateTwilioOutboundCallJob(options.outputDir, job.outboundId, {
3751
+ status: nonHumanStatus,
3752
+ answeredBy,
3753
+ transportCallSid: callSid,
3754
+ events: [
3755
+ ...(job.events ?? []),
3756
+ { at: new Date().toISOString(), status: nonHumanStatus, callSid, ...(answeredBy ? { answeredBy } : {}) },
3757
+ ],
3758
+ });
3759
+ (0, runtime_1.emitNervesEvent)({
3760
+ component: "senses",
3761
+ event: "senses.voice_twilio_outgoing_nonhuman_answer",
3762
+ message: "Twilio outbound voice call reached voicemail or fax",
3763
+ meta: {
3764
+ agentName: options.agentName,
3765
+ callSid: safeCallSid,
3766
+ outboundId: safeSegment(job.outboundId),
3767
+ status: nonHumanStatus,
3768
+ answeredBy: answeredBy ?? "unknown",
3769
+ },
3770
+ });
3771
+ return xmlResponse("<Hangup />");
3772
+ }
3773
+ const callDir = path.join(options.outputDir, safeCallSid);
3774
+ const utteranceId = `twilio-${safeCallSid}-outbound-connected`;
3775
+ const friendId = job.friendId?.trim() || voiceFriendId(options, job.to, callSid);
3776
+ const from = (0, phone_1.normalizeTwilioE164PhoneNumber)(params.From) ?? (0, phone_1.normalizeTwilioE164PhoneNumber)(job.from) ?? job.from;
3777
+ const to = (0, phone_1.normalizeTwilioE164PhoneNumber)(params.To) ?? (0, phone_1.normalizeTwilioE164PhoneNumber)(job.to) ?? job.to;
3778
+ const sessionKey = twilioPhoneVoiceSessionKey({
3779
+ defaultFriendId: friendId,
3780
+ from: to,
3781
+ to: from,
3782
+ callSid,
3783
+ });
3784
+ await updateTwilioOutboundCallJob(options.outputDir, job.outboundId, {
3785
+ status: "answered",
3786
+ ...(answeredBy ? { answeredBy } : {}),
3787
+ transportCallSid: callSid,
3788
+ events: [
3789
+ ...(job.events ?? []),
3790
+ { at: new Date().toISOString(), status: "answered", callSid, ...(answeredBy ? { answeredBy } : {}) },
3791
+ ],
3792
+ });
3793
+ (0, runtime_1.emitNervesEvent)({
3794
+ component: "senses",
3795
+ event: "senses.voice_twilio_outgoing_answered",
3796
+ message: "Twilio outbound voice call answered",
3797
+ meta: { agentName: options.agentName, callSid: safeCallSid, outboundId: safeSegment(job.outboundId), sessionKey },
3798
+ });
3799
+ const streamParams = {
3800
+ Direction: "outbound",
3801
+ Remote: to,
3802
+ Line: from,
3803
+ FriendId: friendId,
3804
+ OutboundId: job.outboundId,
3805
+ Reason: job.reason,
3806
+ InitialAudio: encodeVoiceCallAudioCustomParameter(job.initialAudio),
3807
+ };
3808
+ if (usesOpenAISipConversationEngine(options)) {
3809
+ (0, runtime_1.emitNervesEvent)({
3810
+ component: "senses",
3811
+ event: "senses.voice_twilio_sip_connect",
3812
+ message: "answering Twilio outbound call by dialing OpenAI SIP",
3813
+ meta: { agentName: options.agentName, callSid: safeCallSid, outboundId: safeSegment(job.outboundId), sessionKey, conversationEngine: "openai-sip" },
3814
+ });
3815
+ return xmlResponse(openAISipDialTwiml(options, openAISipResponseHeaders({
3816
+ Agent: options.agentName,
3817
+ Direction: "outbound",
3818
+ From: to,
3819
+ To: from,
3820
+ }, {
3821
+ "X-Ouro-Outbound-Id": job.outboundId,
3822
+ "X-Ouro-Friend-Id": friendId,
3823
+ "X-Ouro-Reason": job.reason,
3824
+ })));
3825
+ }
3826
+ if (normalizeTwilioPhoneTransportMode(options.transportMode) === "media-stream") {
3827
+ if (usesOpenAIRealtimeConversationEngine(options)) {
3828
+ return xmlResponse(mediaStreamTwiml(options, basePath, { From: from, To: to }, undefined, streamParams));
3829
+ }
3830
+ try {
3831
+ await fs.mkdir(callDir, { recursive: true });
3832
+ const greetingJobId = safeSegment(utteranceId);
3833
+ if (job.prewarmedGreeting?.audioPath) {
3834
+ try {
3835
+ const streamJob = jobs.create(safeCallSid, greetingJobId, job.prewarmedGreeting.mimeType);
3836
+ streamJob.append(await fs.readFile(job.prewarmedGreeting.audioPath));
3837
+ streamJob.complete();
3838
+ (0, runtime_1.emitNervesEvent)({
3839
+ component: "senses",
3840
+ event: "senses.voice_twilio_greeting_prewarmed",
3841
+ message: "Twilio Media Stream outbound greeting was ready before answer",
3842
+ meta: {
3843
+ agentName: options.agentName,
3844
+ callSid: safeCallSid,
3845
+ outboundId: safeSegment(job.outboundId),
3846
+ utteranceId,
3847
+ byteLength: String(job.prewarmedGreeting.byteLength),
3848
+ },
3849
+ });
3850
+ return xmlResponse(mediaStreamTwiml(options, basePath, { From: from, To: to }, greetingJobId, streamParams));
3851
+ }
3852
+ catch (error) {
3853
+ (0, runtime_1.emitNervesEvent)({
3854
+ level: "warn",
3855
+ component: "senses",
3856
+ event: "senses.voice_twilio_greeting_prewarm_unavailable",
3857
+ message: "Twilio Media Stream outbound greeting prewarm could not be used",
3858
+ meta: {
3859
+ agentName: options.agentName,
3860
+ callSid: safeCallSid,
3861
+ outboundId: safeSegment(job.outboundId),
3862
+ utteranceId,
3863
+ error: errorMessage(error),
3864
+ },
3865
+ });
3866
+ }
3867
+ }
3868
+ const transcript = (0, transcript_1.buildVoiceTranscript)({
3869
+ utteranceId,
3870
+ text: outboundCallAnsweredPrompt(job, { From: from, To: to }),
3871
+ source: "loopback",
3872
+ });
3873
+ const streamJob = startTwilioPlaybackStreamJob({
3874
+ jobs,
3875
+ bridgeOptions: options,
3876
+ basePath,
3877
+ callDir,
3878
+ safeCallSid,
3879
+ jobId: greetingJobId,
3880
+ baseUtteranceId: utteranceId,
3881
+ runTurn: (onAudioChunk) => (0, turn_1.runVoiceLoopbackTurn)({
3882
+ agentName: options.agentName,
3883
+ friendId,
3884
+ sessionKey,
3885
+ transcript,
3886
+ tts: options.tts,
3887
+ runSenseTurn: options.runSenseTurn,
3888
+ onAudioChunk,
3889
+ }),
3890
+ meta: { agentName: options.agentName, callSid: safeCallSid, utteranceId, transportMode: "media-stream", outboundId: safeSegment(job.outboundId) },
3891
+ });
3892
+ const prebufferState = await streamJob.waitForFirstChunk(options.greetingPrebufferMs ?? exports.DEFAULT_TWILIO_GREETING_PREBUFFER_MS);
3893
+ (0, runtime_1.emitNervesEvent)({
3894
+ component: "senses",
3895
+ event: "senses.voice_twilio_greeting_prebuffer",
3896
+ message: "Twilio Media Stream greeting prebuffer completed",
3897
+ meta: { agentName: options.agentName, callSid: safeCallSid, utteranceId, state: prebufferState, transportMode: "media-stream" },
3898
+ });
3899
+ return xmlResponse(mediaStreamTwiml(options, basePath, { From: from, To: to }, prebufferState === "failed" ? undefined : greetingJobId, streamParams));
3900
+ }
3901
+ catch (error) {
3902
+ (0, runtime_1.emitNervesEvent)({
3903
+ level: "error",
3904
+ component: "senses",
3905
+ event: "senses.voice_twilio_incoming_error",
3906
+ message: "Twilio outbound media-stream greeting turn failed",
3907
+ meta: { agentName: options.agentName, callSid: safeCallSid, outboundId: safeSegment(job.outboundId), error: errorMessage(error), transportMode: "media-stream" },
3908
+ });
3909
+ return xmlResponse(mediaStreamTwiml(options, basePath, { From: from, To: to }, undefined, {
3910
+ ...streamParams,
3911
+ }));
3912
+ }
3913
+ }
3914
+ try {
3915
+ await fs.mkdir(callDir, { recursive: true });
3916
+ return await runPhonePromptTurn({
3917
+ bridgeOptions: options,
3918
+ basePath,
3919
+ callDir,
3920
+ safeCallSid,
3921
+ utteranceId,
3922
+ friendId,
3923
+ sessionKey,
3924
+ promptText: outboundCallAnsweredPrompt(job, { From: from, To: to }),
3925
+ afterPlayback: "record",
3926
+ });
3927
+ }
3928
+ catch (error) {
3929
+ (0, runtime_1.emitNervesEvent)({
3930
+ level: "error",
3931
+ component: "senses",
3932
+ event: "senses.voice_twilio_incoming_error",
3933
+ message: "Twilio outbound voice greeting turn failed",
3934
+ meta: { agentName: options.agentName, callSid: safeCallSid, outboundId: safeSegment(job.outboundId), error: errorMessage(error) },
3935
+ });
3936
+ return xmlResponse(recordTwiml({
3937
+ publicBaseUrl: options.publicBaseUrl,
3938
+ basePath,
3939
+ timeoutSeconds: options.recordTimeoutSeconds ?? exports.DEFAULT_TWILIO_RECORD_TIMEOUT_SECONDS,
3940
+ maxLengthSeconds: options.recordMaxLengthSeconds ?? exports.DEFAULT_TWILIO_RECORD_MAX_LENGTH_SECONDS,
3941
+ }));
3942
+ }
3943
+ }
3944
+ async function handleOutgoingStatus(options, outboundId, params) {
3945
+ const job = await readTwilioOutboundCallJob(options.outputDir, outboundId);
3946
+ if (!job)
3947
+ return textResponse(404, "outbound voice call not found");
3948
+ const rawStatus = params.CallStatus?.trim() || params.CallStatusCallbackEvent?.trim() || "unknown";
3949
+ const callSid = params.CallSid?.trim() || job.transportCallSid;
3950
+ const answeredBy = params.AnsweredBy?.trim() || undefined;
3951
+ const existingTerminalNonHuman = job.status === "voicemail" || job.status === "fax" ? job.status : undefined;
3952
+ const status = nonHumanAnsweredStatus(answeredBy) ?? existingTerminalNonHuman ?? rawStatus;
3953
+ await updateTwilioOutboundCallJob(options.outputDir, job.outboundId, {
3954
+ status,
3955
+ ...(answeredBy ? { answeredBy } : {}),
3956
+ transportCallSid: callSid,
3957
+ events: [
3958
+ ...(job.events ?? []),
3959
+ { at: new Date().toISOString(), status, ...(callSid ? { callSid } : {}), ...(answeredBy ? { answeredBy } : {}) },
3960
+ ],
3961
+ });
3962
+ (0, runtime_1.emitNervesEvent)({
3963
+ component: "senses",
3964
+ event: "senses.voice_twilio_outgoing_status",
3965
+ message: "Twilio outbound voice call status changed",
3966
+ meta: { agentName: options.agentName, callSid: safeSegment(callSid ?? "unknown"), outboundId: safeSegment(job.outboundId), status },
3967
+ });
3968
+ return textResponse(200, "ok");
3969
+ }
3970
+ async function handleOutgoingAmdStatus(options, outboundId, params, activeMediaStreams, activeSipSessions) {
3971
+ const job = await readTwilioOutboundCallJob(options.outputDir, outboundId);
3972
+ if (!job)
3973
+ return textResponse(404, "outbound voice call not found");
3974
+ const callSid = params.CallSid?.trim() || job.transportCallSid;
3975
+ const answeredBy = params.AnsweredBy?.trim() || "unknown";
3976
+ const nonHumanStatus = nonHumanAnsweredStatus(answeredBy);
3977
+ const status = nonHumanStatus ?? job.status ?? "answered";
3978
+ await updateTwilioOutboundCallJob(options.outputDir, job.outboundId, {
3979
+ status,
3980
+ answeredBy,
3981
+ transportCallSid: callSid,
3982
+ events: [
3983
+ ...(job.events ?? []),
3984
+ { at: new Date().toISOString(), status: nonHumanStatus ? status : `amd-${answeredBy}`, ...(callSid ? { callSid } : {}), answeredBy },
3985
+ ],
3986
+ });
3987
+ if (nonHumanStatus) {
3988
+ const session = activeMediaStreams.byOutboundId.get(job.outboundId)
3989
+ ?? (callSid ? activeMediaStreams.byCallSid.get(callSid) : undefined);
3990
+ session?.end();
3991
+ activeSipSessions.getByOutboundId(job.outboundId)?.handleAsyncAmd(answeredBy, nonHumanStatus);
3992
+ (0, runtime_1.emitNervesEvent)({
3993
+ component: "senses",
3994
+ event: "senses.voice_twilio_outgoing_async_amd_nonhuman",
3995
+ message: "Twilio async AMD reported a non-human outbound answer",
3996
+ meta: {
3997
+ agentName: options.agentName,
3998
+ callSid: safeSegment(callSid ?? "unknown"),
3999
+ outboundId: safeSegment(job.outboundId),
4000
+ answeredBy,
4001
+ status,
4002
+ },
4003
+ });
4004
+ return textResponse(200, "ok");
4005
+ }
4006
+ activeSipSessions.getByOutboundId(job.outboundId)?.handleAsyncAmd(answeredBy);
4007
+ (0, runtime_1.emitNervesEvent)({
4008
+ component: "senses",
4009
+ event: "senses.voice_twilio_outgoing_async_amd",
4010
+ message: "Twilio async AMD reported an outbound answer classification",
4011
+ meta: {
4012
+ agentName: options.agentName,
4013
+ callSid: safeSegment(callSid ?? "unknown"),
4014
+ outboundId: safeSegment(job.outboundId),
4015
+ answeredBy,
4016
+ status,
4017
+ },
4018
+ });
4019
+ return textResponse(200, "ok");
4020
+ }
4021
+ async function handleListen(options, basePath) {
4022
+ return xmlResponse(recordTwiml({
4023
+ publicBaseUrl: options.publicBaseUrl,
4024
+ basePath,
4025
+ timeoutSeconds: options.recordTimeoutSeconds ?? exports.DEFAULT_TWILIO_RECORD_TIMEOUT_SECONDS,
4026
+ maxLengthSeconds: options.recordMaxLengthSeconds ?? exports.DEFAULT_TWILIO_RECORD_MAX_LENGTH_SECONDS,
4027
+ }));
4028
+ }
4029
+ async function handleRecording(options, basePath, params, jobs) {
4030
+ const recording = parseRecordingParams(params);
4031
+ if (!recording) {
4032
+ (0, runtime_1.emitNervesEvent)({
4033
+ level: "warn",
4034
+ component: "senses",
4035
+ event: "senses.voice_twilio_recording_rejected",
4036
+ message: "Twilio recording callback was missing required fields",
4037
+ meta: { agentName: options.agentName },
4038
+ });
4039
+ return recordAgainResponse(options, basePath, "I did not receive audio. Please try again.");
4040
+ }
4041
+ const safeCallSid = safeSegment(recording.callSid);
4042
+ const safeRecordingSid = safeSegment(recording.recordingSid);
4043
+ const callDir = path.join(options.outputDir, safeCallSid);
4044
+ const inputPath = path.join(callDir, `${safeRecordingSid}.wav`);
4045
+ const utteranceId = `twilio-${safeCallSid}-${safeRecordingSid}`;
4046
+ const downloadRecording = options.downloadRecording ?? defaultTwilioRecordingDownloader;
4047
+ const friendId = voiceFriendId(options, recording.from, recording.callSid);
4048
+ const sessionKey = twilioPhoneVoiceSessionKey({
4049
+ defaultFriendId: options.defaultFriendId,
4050
+ from: recording.from,
4051
+ to: recording.to,
4052
+ callSid: recording.callSid,
4053
+ });
4054
+ (0, runtime_1.emitNervesEvent)({
4055
+ component: "senses",
4056
+ event: "senses.voice_twilio_turn_start",
4057
+ message: "starting Twilio voice turn",
4058
+ meta: { agentName: options.agentName, callSid: safeCallSid, recordingSid: safeRecordingSid, sessionKey },
4059
+ });
4060
+ try {
4061
+ if (normalizeTwilioPhonePlaybackMode(options.playbackMode) === "stream") {
4062
+ const jobId = safeSegment(utteranceId);
4063
+ startTwilioPlaybackStreamJob({
4064
+ jobs,
4065
+ bridgeOptions: options,
4066
+ basePath,
4067
+ callDir,
4068
+ safeCallSid,
4069
+ jobId,
4070
+ baseUtteranceId: utteranceId,
4071
+ runTurn: async (onAudioChunk) => {
4072
+ await fs.mkdir(callDir, { recursive: true });
4073
+ const mediaUrl = twilioRecordingMediaUrl(recording.recordingUrl);
4074
+ const audio = await downloadRecording({
4075
+ recordingUrl: mediaUrl,
4076
+ accountSid: options.twilioAccountSid?.trim() || undefined,
4077
+ authToken: options.twilioAuthToken?.trim() || undefined,
4078
+ });
4079
+ await fs.writeFile(inputPath, audio);
4080
+ const turnTranscript = await transcribeRecordingOrNoSpeech({
4081
+ transcriber: options.transcriber,
4082
+ utteranceId,
4083
+ inputPath,
4084
+ });
4085
+ return (0, turn_1.runVoiceLoopbackTurn)({
4086
+ agentName: options.agentName,
4087
+ friendId,
4088
+ sessionKey,
4089
+ transcript: turnTranscript,
4090
+ tts: options.tts,
4091
+ runSenseTurn: options.runSenseTurn,
4092
+ onAudioChunk,
4093
+ });
4094
+ },
4095
+ meta: { agentName: options.agentName, callSid: safeCallSid, recordingSid: safeRecordingSid, utteranceId },
4096
+ });
4097
+ return xmlResponse(`${playTwiml(streamAudioUrl(options, basePath, safeCallSid, jobId))}${redirectTwiml(options.publicBaseUrl, basePath)}`);
4098
+ }
4099
+ await fs.mkdir(callDir, { recursive: true });
4100
+ const mediaUrl = twilioRecordingMediaUrl(recording.recordingUrl);
4101
+ const audio = await downloadRecording({
4102
+ recordingUrl: mediaUrl,
4103
+ accountSid: options.twilioAccountSid?.trim() || undefined,
4104
+ authToken: options.twilioAuthToken?.trim() || undefined,
4105
+ });
4106
+ await fs.writeFile(inputPath, audio);
4107
+ const transcript = await transcribeRecordingOrNoSpeech({
4108
+ transcriber: options.transcriber,
4109
+ utteranceId,
4110
+ inputPath,
4111
+ });
4112
+ if (transcript.utteranceId === `${utteranceId}-nospeech`) {
4113
+ return await runPhonePromptTurn({
4114
+ bridgeOptions: options,
4115
+ basePath,
4116
+ callDir,
4117
+ safeCallSid,
4118
+ utteranceId: `${utteranceId}-nospeech`,
4119
+ friendId,
4120
+ sessionKey,
4121
+ promptText: noSpeechPrompt(),
4122
+ afterPlayback: "redirect",
4123
+ });
4124
+ }
4125
+ const turn = await (0, turn_1.runVoiceLoopbackTurn)({
4126
+ agentName: options.agentName,
4127
+ friendId,
4128
+ sessionKey,
4129
+ transcript,
4130
+ tts: options.tts,
4131
+ runSenseTurn: options.runSenseTurn,
4132
+ });
4133
+ if (turn.tts.status !== "delivered") {
4134
+ return xmlResponse(`${sayTwiml("voice output failed after the text response was captured.")}${redirectTwiml(options.publicBaseUrl, basePath)}`);
4135
+ }
4136
+ const audioUrls = await writeVoiceTurnPlaybackArtifacts({
4137
+ bridgeOptions: options,
4138
+ basePath,
4139
+ callDir,
4140
+ safeCallSid,
4141
+ baseUtteranceId: utteranceId,
4142
+ turn,
4143
+ });
4144
+ (0, runtime_1.emitNervesEvent)({
4145
+ component: "senses",
4146
+ event: "senses.voice_twilio_turn_end",
4147
+ message: "finished Twilio voice turn",
4148
+ meta: { agentName: options.agentName, callSid: safeCallSid, recordingSid: safeRecordingSid, playbackCount: audioUrls.length },
4149
+ });
4150
+ return xmlResponse(`${playManyTwiml(audioUrls)}${redirectTwiml(options.publicBaseUrl, basePath)}`);
4151
+ }
4152
+ catch (error) {
4153
+ (0, runtime_1.emitNervesEvent)({
4154
+ level: "error",
4155
+ component: "senses",
4156
+ event: "senses.voice_twilio_turn_error",
4157
+ message: "Twilio voice turn failed",
4158
+ meta: {
4159
+ agentName: options.agentName,
4160
+ callSid: safeCallSid,
4161
+ recordingSid: safeRecordingSid,
4162
+ error: errorMessage(error),
4163
+ },
4164
+ });
4165
+ return xmlResponse(`${sayTwiml("I could not process that audio. Please try again.")}${redirectTwiml(options.publicBaseUrl, basePath)}`);
4166
+ }
4167
+ }
4168
+ async function handleAudio(options, basePath, requestPath) {
4169
+ const prefix = `${basePath}/audio/`;
4170
+ const pathOnly = requestPath.split("?")[0];
4171
+ const rest = pathOnly.slice(prefix.length);
4172
+ const parts = rest.split("/");
4173
+ if (parts.length !== 2)
4174
+ return textResponse(404, "not found");
4175
+ const [callSidPart, fileNamePart] = parts;
4176
+ const callSid = decodeSafeSegment(callSidPart);
4177
+ const fileName = decodeSafeSegment(fileNamePart);
4178
+ if (!callSid || !fileName)
4179
+ return textResponse(404, "not found");
4180
+ const baseDir = path.resolve(options.outputDir, callSid);
4181
+ const audioPath = path.resolve(baseDir, fileName);
4182
+ try {
4183
+ const audio = await fs.readFile(audioPath);
4184
+ (0, runtime_1.emitNervesEvent)({
4185
+ component: "senses",
4186
+ event: "senses.voice_twilio_audio_served",
4187
+ message: "served Twilio voice audio artifact",
4188
+ meta: { agentName: options.agentName, callSid, fileName },
4189
+ });
4190
+ return binaryResponse(audio, contentTypeForAudio(fileName));
4191
+ }
4192
+ catch {
4193
+ return textResponse(404, "not found");
4194
+ }
4195
+ }
4196
+ async function handleAudioStream(options, basePath, requestPath, jobs) {
4197
+ const prefix = `${basePath}/audio-stream/`;
4198
+ const pathOnly = requestPath.split("?")[0];
4199
+ const rest = pathOnly.slice(prefix.length);
4200
+ const parts = rest.split("/");
4201
+ if (parts.length !== 2)
4202
+ return textResponse(404, "not found");
4203
+ const [callSidPart, fileNamePart] = parts;
4204
+ const callSid = decodeSafeSegment(callSidPart);
4205
+ const fileName = decodeSafeSegment(fileNamePart);
4206
+ if (!callSid || !fileName)
4207
+ return textResponse(404, "not found");
4208
+ const jobId = fileName.replace(/\.[A-Za-z0-9]+$/, "");
4209
+ const job = jobs.get(callSid, jobId);
4210
+ if (!job)
4211
+ return textResponse(404, "not found");
4212
+ (0, runtime_1.emitNervesEvent)({
4213
+ component: "senses",
4214
+ event: "senses.voice_twilio_stream_served",
4215
+ message: "served Twilio voice streaming audio job",
4216
+ meta: { agentName: options.agentName, callSid, jobId },
4217
+ });
4218
+ return streamResponse(job.stream(), job.mimeType);
4219
+ }
4220
+ function createTwilioPhoneBridge(options) {
4221
+ new URL(options.publicBaseUrl);
4222
+ const basePath = normalizeTwilioPhoneBasePath(options.basePath);
4223
+ const sipWebhookPath = normalizeTwilioPhoneBasePath(options.openaiSip?.webhookPath ?? openAISipWebhookPath(options.agentName));
4224
+ const jobs = new TwilioAudioStreamJobStore();
4225
+ const mediaStreams = new ws_1.WebSocketServer({ noServer: true });
4226
+ const activeMediaStreams = {
4227
+ byCallSid: new Map(),
4228
+ byOutboundId: new Map(),
4229
+ };
4230
+ const activeSipSessions = new ActiveOpenAISipSessions();
4231
+ mediaStreams.on("connection", (ws) => {
4232
+ const lifecycle = {
4233
+ onIdentityChange: (activeSession, identity) => {
4234
+ if (identity.callSid)
4235
+ activeMediaStreams.byCallSid.set(identity.callSid, activeSession);
4236
+ if (identity.outboundId)
4237
+ activeMediaStreams.byOutboundId.set(identity.outboundId, activeSession);
4238
+ },
4239
+ onClose: (activeSession, identity) => {
4240
+ if (identity.callSid && activeMediaStreams.byCallSid.get(identity.callSid) === activeSession) {
4241
+ activeMediaStreams.byCallSid.delete(identity.callSid);
4242
+ }
4243
+ if (identity.outboundId && activeMediaStreams.byOutboundId.get(identity.outboundId) === activeSession) {
4244
+ activeMediaStreams.byOutboundId.delete(identity.outboundId);
4245
+ }
4246
+ },
4247
+ };
4248
+ const session = usesOpenAIRealtimeConversationEngine(options)
4249
+ ? new TwilioOpenAIRealtimeMediaStreamSession(ws, options, lifecycle)
4250
+ : new TwilioMediaStreamSession(ws, options, jobs, lifecycle);
4251
+ session.attach();
4252
+ });
4253
+ return {
4254
+ async handle(request) {
4255
+ const method = request.method.toUpperCase();
4256
+ const requestPath = request.path.startsWith("/") ? request.path : `/${request.path}`;
4257
+ const routePath = requestPath.split("?")[0];
4258
+ if (method === "GET" && requestPath.startsWith(`${basePath}/audio/`)) {
4259
+ return handleAudio(options, basePath, requestPath);
4260
+ }
4261
+ if (method === "GET" && requestPath.startsWith(`${basePath}/audio-stream/`)) {
4262
+ return handleAudioStream(options, basePath, requestPath, jobs);
4263
+ }
4264
+ if (method === "GET" && routePath === `${basePath}/health`) {
4265
+ return textResponse(200, "ok");
4266
+ }
4267
+ if (method === "GET" && routePath === `${sipWebhookPath}/health`) {
4268
+ return textResponse(200, "ok");
4269
+ }
4270
+ if (method === "GET")
4271
+ return textResponse(404, "not found");
4272
+ if (method !== "POST")
4273
+ return textResponse(405, "method not allowed");
4274
+ if (routePath === sipWebhookPath) {
4275
+ return handleOpenAISipWebhook(options, { ...request, path: requestPath }, activeSipSessions);
4276
+ }
4277
+ const params = formParams(bodyText(request.body));
4278
+ if (!verifyRequest(options, { ...request, path: requestPath }, params)) {
4279
+ (0, runtime_1.emitNervesEvent)({
4280
+ level: "warn",
4281
+ component: "senses",
4282
+ event: "senses.voice_twilio_signature_rejected",
4283
+ message: "rejected Twilio webhook with invalid signature",
4284
+ meta: { agentName: options.agentName, path: requestPath },
4285
+ });
4286
+ return textResponse(403, "invalid Twilio signature");
4287
+ }
4288
+ if (routePath === `${basePath}/incoming`)
4289
+ return handleIncoming(options, basePath, params, jobs);
4290
+ if (routePath.startsWith(`${basePath}/outgoing/`)) {
4291
+ const outgoingRest = routePath.slice(`${basePath}/outgoing/`.length);
4292
+ const [outboundIdPart, suffix] = outgoingRest.split("/");
4293
+ const outboundId = outboundIdPart ? decodeSafeSegment(outboundIdPart) : null;
4294
+ if (!outboundId)
4295
+ return textResponse(404, "not found");
4296
+ if (suffix === "status")
4297
+ return handleOutgoingStatus(options, outboundId, params);
4298
+ if (suffix === "amd")
4299
+ return handleOutgoingAmdStatus(options, outboundId, params, activeMediaStreams, activeSipSessions);
4300
+ if (suffix === undefined)
4301
+ return handleOutgoing(options, basePath, outboundId, params, jobs);
4302
+ }
4303
+ if (routePath === `${basePath}/listen`)
4304
+ return handleListen(options, basePath);
4305
+ if (routePath === `${basePath}/recording`)
4306
+ return handleRecording(options, basePath, params, jobs);
4307
+ return textResponse(404, "not found");
4308
+ },
4309
+ handleUpgrade(request, socket, head) {
4310
+ const requestPath = request.url?.startsWith("/") ? request.url : `/${request.url ?? ""}`;
4311
+ const routePath = requestPath.split("?")[0];
4312
+ if (routePath !== `${basePath}/media-stream`
4313
+ || normalizeTwilioPhoneTransportMode(options.transportMode) !== "media-stream") {
4314
+ return false;
4315
+ }
4316
+ mediaStreams.handleUpgrade(request, socket, head, (ws) => {
4317
+ mediaStreams.emit("connection", ws, request);
4318
+ });
4319
+ (0, runtime_1.emitNervesEvent)({
4320
+ component: "senses",
4321
+ event: "senses.voice_twilio_media_upgrade",
4322
+ message: "accepted Twilio Media Stream WebSocket upgrade",
4323
+ meta: { agentName: options.agentName, path: routePath },
4324
+ });
4325
+ return true;
4326
+ },
4327
+ close() {
4328
+ return new Promise((resolve, reject) => {
4329
+ mediaStreams.close((error) => error ? reject(error) : resolve());
4330
+ });
4331
+ },
4332
+ };
4333
+ }
4334
+ /* v8 ignore stop */
4335
+ /* v8 ignore start -- HTTP server adapter behavior is covered through startTwilioPhoneBridgeServer smoke tests; low-level stream disconnect branches are platform-dependent @preserve */
4336
+ function readRequestBody(req, limitBytes = 1_000_000) {
4337
+ return new Promise((resolve, reject) => {
4338
+ const chunks = [];
4339
+ let byteLength = 0;
4340
+ req.on("data", (chunk) => {
4341
+ byteLength += chunk.byteLength;
4342
+ if (byteLength > limitBytes) {
4343
+ reject(new Error("request body too large"));
4344
+ req.destroy();
4345
+ return;
4346
+ }
4347
+ chunks.push(chunk);
4348
+ });
4349
+ req.on("end", () => resolve(Buffer.concat(chunks)));
4350
+ req.on("error", reject);
4351
+ });
4352
+ }
4353
+ function waitForDrain(res) {
4354
+ return new Promise((resolve, reject) => {
4355
+ const cleanup = () => {
4356
+ res.off("drain", onDrain);
4357
+ res.off("error", onError);
4358
+ res.off("close", onClose);
4359
+ };
4360
+ const onDrain = () => {
4361
+ cleanup();
4362
+ resolve();
4363
+ };
4364
+ const onError = (error) => {
4365
+ cleanup();
4366
+ reject(error);
4367
+ };
4368
+ const onClose = () => {
4369
+ cleanup();
4370
+ const error = new Error("response closed before drain");
4371
+ error.code = "ERR_STREAM_PREMATURE_CLOSE";
4372
+ reject(error);
4373
+ };
4374
+ res.once("drain", onDrain);
4375
+ res.once("error", onError);
4376
+ res.once("close", onClose);
4377
+ });
4378
+ }
4379
+ function isClientDisconnectError(error) {
4380
+ const code = typeof error === "object" && error !== null && "code" in error
4381
+ ? String(error.code ?? "")
4382
+ : "";
4383
+ if (code === "ECONNRESET" || code === "EPIPE" || code === "ERR_STREAM_DESTROYED" || code === "ERR_STREAM_PREMATURE_CLOSE") {
4384
+ return true;
4385
+ }
4386
+ const message = errorMessage(error).toLowerCase();
4387
+ return message.includes("aborted")
4388
+ || message.includes("socket hang up")
4389
+ || message.includes("premature close")
4390
+ || message.includes("stream destroyed")
4391
+ || message.includes("write after end")
4392
+ || message.includes("response closed before drain");
4393
+ }
4394
+ async function writeResponseBody(res, body) {
4395
+ if (!isAsyncIterableBody(body)) {
4396
+ res.end(body);
4397
+ return;
4398
+ }
4399
+ try {
4400
+ for await (const chunk of body) {
4401
+ if (res.destroyed || res.writableEnded)
4402
+ return;
4403
+ /* v8 ignore next -- exercised only when Node reports socket backpressure @preserve */
4404
+ if (!res.write(chunk)) {
4405
+ await waitForDrain(res);
4406
+ }
4407
+ }
4408
+ }
4409
+ catch (error) {
4410
+ if (isClientDisconnectError(error))
4411
+ return;
4412
+ throw error;
4413
+ }
4414
+ if (!res.destroyed && !res.writableEnded) {
4415
+ res.end();
4416
+ }
4417
+ }
4418
+ async function startTwilioPhoneBridgeServer(options) {
4419
+ const port = options.port ?? exports.DEFAULT_TWILIO_PHONE_PORT;
4420
+ const host = options.host ?? "127.0.0.1";
4421
+ const bridge = createTwilioPhoneBridge(options);
4422
+ const server = http.createServer(async (req, res) => {
4423
+ try {
4424
+ const body = await readRequestBody(req);
4425
+ const response = await bridge.handle({
4426
+ method: req.method,
4427
+ path: req.url,
4428
+ headers: req.headers,
4429
+ body,
4430
+ });
4431
+ res.writeHead(response.statusCode, response.headers);
4432
+ await writeResponseBody(res, response.body);
4433
+ }
4434
+ catch (error) {
4435
+ (0, runtime_1.emitNervesEvent)({
4436
+ level: "error",
4437
+ component: "senses",
4438
+ event: "senses.voice_twilio_server_error",
4439
+ message: "Twilio voice bridge server failed a request",
4440
+ meta: { agentName: options.agentName, error: errorMessage(error) },
4441
+ });
4442
+ /* v8 ignore next -- defensive path for async stream failures after headers @preserve */
4443
+ if (res.headersSent) {
4444
+ res.destroy(error instanceof Error ? error : new Error(String(error)));
4445
+ }
4446
+ else {
4447
+ res.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
4448
+ res.end("internal server error");
4449
+ }
4450
+ }
4451
+ });
4452
+ server.on("upgrade", (req, socket, head) => {
4453
+ const handled = bridge.handleUpgrade?.(req, socket, head) ?? false;
4454
+ if (!handled) {
4455
+ socket.destroy();
4456
+ }
4457
+ });
4458
+ await new Promise((resolve, reject) => {
4459
+ const onError = (error) => {
4460
+ server.off("listening", onListening);
4461
+ reject(error);
4462
+ };
4463
+ const onListening = () => {
4464
+ server.off("error", onError);
4465
+ resolve();
4466
+ };
4467
+ server.once("error", onError);
4468
+ server.once("listening", onListening);
4469
+ server.listen(port, host);
4470
+ });
4471
+ (0, runtime_1.emitNervesEvent)({
4472
+ component: "senses",
4473
+ event: "senses.voice_twilio_server_start",
4474
+ message: "Twilio voice bridge server started",
4475
+ meta: { agentName: options.agentName, host, port, publicBaseUrl: options.publicBaseUrl },
4476
+ });
4477
+ const actualPort = server.address().port;
4478
+ return {
4479
+ bridge,
4480
+ server,
4481
+ localUrl: `http://${host}:${actualPort}`,
4482
+ };
4483
+ }
4484
+ /* v8 ignore stop */