@vellumai/assistant 0.11.3 → 0.11.4-staging.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (341) hide show
  1. package/ARCHITECTURE.md +11 -6
  2. package/docs/architecture/memory.md +11 -0
  3. package/docs/architecture/turn-actor.md +70 -0
  4. package/docs/flux-turn-detection-spike.md +243 -0
  5. package/docs/stt-provider-onboarding.md +3 -1
  6. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
  7. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
  8. package/node_modules/@vellumai/gateway-client/src/admission-policy-contract.ts +34 -0
  9. package/node_modules/@vellumai/gateway-client/src/index.ts +2 -0
  10. package/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
  11. package/openapi.yaml +139 -37
  12. package/package.json +1 -1
  13. package/scripts/voice-ttft-spike.ts +3 -3
  14. package/src/__tests__/app-compiler.test.ts +38 -3
  15. package/src/__tests__/attachments-store.test.ts +21 -12
  16. package/src/__tests__/byok-default-profile-ensure.test.ts +17 -0
  17. package/src/__tests__/call-setup-flow-name-capture.test.ts +0 -1
  18. package/src/__tests__/call-site-routing-provider.test.ts +1 -1
  19. package/src/__tests__/channel-availability-routes.test.ts +14 -1
  20. package/src/__tests__/channel-capabilities-dedupe.test.ts +214 -0
  21. package/src/__tests__/channel-delivery-store.test.ts +14 -14
  22. package/src/__tests__/config-loader-backfill.test.ts +3 -3
  23. package/src/__tests__/config-schema.test.ts +25 -10
  24. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +8 -11
  25. package/src/__tests__/conversation-agent-loop-overflow.test.ts +8 -11
  26. package/src/__tests__/conversation-agent-loop.test.ts +28 -20
  27. package/src/__tests__/conversation-attention-store.test.ts +63 -0
  28. package/src/__tests__/conversation-delete-schedule-cleanup.test.ts +0 -4
  29. package/src/__tests__/conversation-fork-crud.test.ts +69 -0
  30. package/src/__tests__/conversation-fork-referential.test.ts +67 -0
  31. package/src/__tests__/conversation-fork-retrospective.test.ts +24 -0
  32. package/src/__tests__/conversation-notifiers-provenance.test.ts +59 -0
  33. package/src/__tests__/conversation-queue.test.ts +177 -4
  34. package/src/__tests__/conversation-runtime-assembly.test.ts +134 -102
  35. package/src/__tests__/conversation-runtime-workspace.test.ts +14 -10
  36. package/src/__tests__/credential-prompt-route.test.ts +7 -10
  37. package/src/__tests__/custom-profile-ensure.test.ts +5 -1
  38. package/src/__tests__/discord-access-request-privacy.test.ts +5 -1
  39. package/src/__tests__/discord-requester-notice-privacy.test.ts +3 -3
  40. package/src/__tests__/document-append-idempotency.test.ts +233 -0
  41. package/src/__tests__/edit-propagation.test.ts +0 -7
  42. package/src/__tests__/helpers/mock-actor-context.ts +49 -0
  43. package/src/__tests__/helpers/mock-conversation.ts +13 -1
  44. package/src/__tests__/injector-chain.test.ts +63 -41
  45. package/src/__tests__/injector-disk-pressure.test.ts +11 -23
  46. package/src/__tests__/llm-context-resolution.test.ts +73 -1
  47. package/src/__tests__/llm-schema.test.ts +5 -2
  48. package/src/__tests__/mcp-list-plugin-servers.test.ts +250 -0
  49. package/src/__tests__/memory-retrieval-hook.test.ts +6 -5
  50. package/src/__tests__/messages-read-boundary-guard.test.ts +134 -0
  51. package/src/__tests__/mtime-cache.test.ts +1 -1
  52. package/src/__tests__/non-member-access-request.test.ts +0 -20
  53. package/src/__tests__/outbound-slack-persistence.test.ts +40 -1
  54. package/src/__tests__/plugin-import-boundary-guard.test.ts +5 -0
  55. package/src/__tests__/plugin-secret-pattern-contribution.test.ts +1 -1
  56. package/src/__tests__/post-compaction-reinjection-idempotency.test.ts +14 -7
  57. package/src/__tests__/provider-commit-message-generator.test.ts +20 -0
  58. package/src/__tests__/run-conversation-turn-persistence.test.ts +434 -105
  59. package/src/__tests__/scoped-approval-grants.test.ts +11 -6
  60. package/src/__tests__/secret-ingress-channel.test.ts +0 -1
  61. package/src/__tests__/skills.test.ts +32 -0
  62. package/src/__tests__/slack-edit-ordering-characterization.test.ts +0 -1
  63. package/src/__tests__/subagent-call-site-routing.test.ts +31 -19
  64. package/src/__tests__/subagent-spawn-and-await.test.ts +14 -10
  65. package/src/__tests__/turn-events-store.test.ts +43 -0
  66. package/src/__tests__/ui-shape-teaching.test.ts +33 -0
  67. package/src/__tests__/ui-voice-picker-surface.test.ts +128 -0
  68. package/src/__tests__/user-plugin-loader.test.ts +1 -1
  69. package/src/__tests__/visible-app-context.test.ts +16 -9
  70. package/src/__tests__/voice-config-update.test.ts +40 -0
  71. package/src/__tests__/worker-entrypoint-guards.test.ts +54 -0
  72. package/src/__tests__/worker-plugin-surface.test.ts +77 -0
  73. package/src/__tests__/workspace-migration-142-consolidate-voice-front-door.test.ts +158 -0
  74. package/src/__tests__/workspace-migration-143-repair-deprecated-codex-model-id.test.ts +133 -0
  75. package/src/__tests__/workspace-migration-144-convert-stranded-subscription-openai-profiles.test.ts +316 -0
  76. package/src/__tests__/workspace-migration-145-collapse-profile-bindings-to-entries.test.ts +325 -0
  77. package/src/__tests__/workspace-migration-146-repair-retired-fireworks-deepseek-flash-model-id.test.ts +235 -0
  78. package/src/acp/__tests__/acp-claude-oauth.test.ts +10 -2
  79. package/src/acp/__tests__/auth-required.test.ts +161 -0
  80. package/src/acp/acp-claude-oauth.ts +19 -2
  81. package/src/acp/agent-process.test.ts +100 -0
  82. package/src/acp/agent-process.ts +29 -26
  83. package/src/acp/auth-required.ts +102 -0
  84. package/src/acp/session-manager.test.ts +119 -0
  85. package/src/acp/session-manager.ts +68 -2
  86. package/src/api/events/acp-auth-required.ts +55 -0
  87. package/src/api/index.ts +7 -0
  88. package/src/api/surfaces.ts +7 -3
  89. package/src/apps/app-store.ts +3 -0
  90. package/src/bundler/package-resolver.ts +2 -30
  91. package/src/calls/__tests__/voice-session-bridge.test.ts +173 -1
  92. package/src/calls/__tests__/voice-triage-escalate.test.ts +94 -0
  93. package/src/calls/call-controller.ts +19 -3
  94. package/src/calls/call-setup-flow.ts +0 -1
  95. package/src/calls/media-stream-stt-session.ts +15 -0
  96. package/src/calls/voice-session-bridge.ts +71 -16
  97. package/src/calls/voice-triage-escalate.ts +104 -2
  98. package/src/channels/__tests__/plugin-channel-declarations.test.ts +161 -0
  99. package/src/channels/config.ts +13 -0
  100. package/src/channels/plugin-channel-declarations.ts +108 -0
  101. package/src/channels/types.ts +30 -0
  102. package/src/cli/AGENTS.md +5 -2
  103. package/src/cli/commands/credentials.help.ts +2 -2
  104. package/src/cli/commands/inference-providers.ts +1 -1
  105. package/src/cli/commands/mcp.help.ts +13 -4
  106. package/src/cli/commands/mcp.ts +9 -0
  107. package/src/cli/commands/memory/__tests__/memory-v3.test.ts +128 -5
  108. package/src/cli/commands/memory/index.help.ts +43 -1
  109. package/src/cli/commands/memory/memory-v3.ts +64 -0
  110. package/src/cli/commands/stt.help.ts +27 -2
  111. package/src/cli/lib/__tests__/upgrade-plugin.test.ts +39 -0
  112. package/src/cli/lib/bundled-marketplace.json +13 -0
  113. package/src/cli/lib/upgrade-plugin.ts +42 -0
  114. package/src/config/__tests__/default-profile-catalog.test.ts +34 -2
  115. package/src/config/__tests__/default-provider.test.ts +6 -1
  116. package/src/config/__tests__/profile-materialization.test.ts +75 -19
  117. package/src/config/bundled-skills/acp/SKILL.md +6 -7
  118. package/src/config/bundled-skills/document-editor/SKILL.md +2 -2
  119. package/src/config/bundled-skills/document-editor/TOOLS.json +2 -2
  120. package/src/config/bundled-skills/media-processing/services/preprocess.ts +14 -4
  121. package/src/config/bundled-skills/settings/TOOLS.json +3 -3
  122. package/src/config/bundled-skills/settings/tools/navigate-settings-tab.test.ts +65 -0
  123. package/src/config/bundled-skills/settings/tools/navigate-settings-tab.ts +7 -1
  124. package/src/config/bundled-skills/settings/tools/shared.ts +16 -0
  125. package/src/config/bundled-skills/settings/tools/voice-config-update.ts +19 -1
  126. package/src/config/bundled-skills/transcribe/tools/transcribe-media.test.ts +22 -1
  127. package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +9 -2
  128. package/src/config/call-site-defaults.ts +4 -5
  129. package/src/config/default-profile-catalog.ts +83 -12
  130. package/src/config/default-profile-names.ts +4 -1
  131. package/src/config/default-provider-resolution.ts +4 -0
  132. package/src/config/llm-context-resolution.ts +11 -3
  133. package/src/config/llm-resolver.ts +28 -1
  134. package/src/config/profile-materialization.ts +70 -22
  135. package/src/config/schemas/__tests__/live-voice.test.ts +107 -4
  136. package/src/config/schemas/call-site-catalog.ts +4 -4
  137. package/src/config/schemas/live-voice.ts +57 -23
  138. package/src/config/schemas/llm.ts +59 -32
  139. package/src/config/schemas/mcp.ts +23 -0
  140. package/src/config/schemas/plugin-updates.ts +6 -2
  141. package/src/config/schemas/stt.ts +1 -0
  142. package/src/context/outbound-sanitize.ts +96 -1
  143. package/src/daemon/__tests__/plugin-mcp-reconcile.test.ts +82 -0
  144. package/src/daemon/conversation-agent-loop-handlers.ts +15 -10
  145. package/src/daemon/conversation-agent-loop.ts +17 -6
  146. package/src/daemon/conversation-messaging.ts +5 -1
  147. package/src/daemon/conversation-notifiers.ts +9 -1
  148. package/src/daemon/conversation-process.ts +36 -6
  149. package/src/daemon/conversation-runtime-assembly.ts +3 -4
  150. package/src/daemon/conversation-surfaces.ts +27 -5
  151. package/src/daemon/conversation-tool-setup.ts +1 -2
  152. package/src/daemon/conversation.ts +48 -0
  153. package/src/daemon/interactive-turn-sender.ts +59 -0
  154. package/src/daemon/mcp-reload-service.ts +36 -6
  155. package/src/daemon/process-message.ts +24 -24
  156. package/src/daemon/providers-setup.ts +6 -3
  157. package/src/daemon/trust-context-types.ts +29 -0
  158. package/src/daemon/wake-conversation-ops.ts +3 -2
  159. package/src/documents/document-store.ts +138 -5
  160. package/src/hooks/hook-loader.ts +3 -3
  161. package/src/hooks/registry.ts +50 -6
  162. package/src/inbound/__tests__/oauth-callback-url.test.ts +83 -0
  163. package/src/inbound/oauth-callback-url.ts +61 -0
  164. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +1 -104
  165. package/src/live-voice/__tests__/live-voice-events.test.ts +7 -8
  166. package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +932 -0
  167. package/src/live-voice/__tests__/live-voice-metrics.test.ts +115 -8
  168. package/src/live-voice/__tests__/live-voice-photo.test.ts +100 -0
  169. package/src/live-voice/__tests__/live-voice-progress.test.ts +60 -194
  170. package/src/live-voice/__tests__/live-voice-stt.test.ts +14 -0
  171. package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +29 -0
  172. package/src/live-voice/__tests__/live-voice-tts-session.test.ts +0 -483
  173. package/src/live-voice/__tests__/live-voice-vad.test.ts +0 -16
  174. package/src/live-voice/__tests__/progress-narration.test.ts +214 -0
  175. package/src/live-voice/live-voice-archive.ts +2 -0
  176. package/src/live-voice/live-voice-metrics.ts +57 -32
  177. package/src/live-voice/live-voice-photo.ts +1 -2
  178. package/src/live-voice/live-voice-session.ts +535 -314
  179. package/src/live-voice/progress-narration.ts +277 -0
  180. package/src/live-voice/protocol.ts +21 -1
  181. package/src/mcp/__tests__/effective-config.test.ts +238 -0
  182. package/src/mcp/__tests__/mcp-auth-orchestrator.test.ts +0 -1
  183. package/src/mcp/__tests__/mcp-oauth-client-registration.test.ts +200 -0
  184. package/src/mcp/__tests__/mcp-oauth-provider.test.ts +9 -9
  185. package/src/mcp/__tests__/plugin-server-credential-isolation.test.ts +95 -0
  186. package/src/mcp/client.ts +16 -11
  187. package/src/mcp/effective-config.ts +113 -0
  188. package/src/mcp/manager.ts +11 -6
  189. package/src/mcp/mcp-auth-orchestrator.ts +13 -22
  190. package/src/mcp/mcp-oauth-provider.ts +205 -240
  191. package/src/monitoring/__tests__/plugin-auto-update.test.ts +166 -3
  192. package/src/monitoring/plugin-auto-update.ts +128 -24
  193. package/src/notifications/signal.ts +1 -0
  194. package/src/permissions/confirmation-guardian-request.test.ts +15 -11
  195. package/src/permissions/confirmation-guardian-request.ts +2 -2
  196. package/src/permissions/question-guardian-request.test.ts +14 -6
  197. package/src/permissions/question-guardian-request.ts +1 -2
  198. package/src/persistence/attachments-store.ts +8 -1
  199. package/src/persistence/bookmark-crud.ts +3 -7
  200. package/src/persistence/conversation-attention-store.ts +16 -45
  201. package/src/persistence/conversation-crud.ts +33 -4
  202. package/src/persistence/conversation-lineage.ts +9 -0
  203. package/src/persistence/conversation-queries.ts +108 -41
  204. package/src/persistence/delivery-crud.ts +38 -29
  205. package/src/persistence/external-conversation-store.ts +32 -4
  206. package/src/persistence/llm-request-log-store.ts +4 -10
  207. package/src/persistence/llm-usage-store.ts +8 -3
  208. package/src/persistence/message-reads.test.ts +197 -0
  209. package/src/persistence/message-reads.ts +211 -0
  210. package/src/persistence/migrations/366-chatgpt-subscription-row-identity.test.ts +120 -0
  211. package/src/persistence/migrations/366-chatgpt-subscription-row-identity.ts +62 -0
  212. package/src/persistence/real-user-turn-filter.ts +27 -3
  213. package/src/persistence/steps.ts +9 -0
  214. package/src/plugin-api/__tests__/oauth-callback-url-export.test.ts +29 -0
  215. package/src/plugin-api/conversation-turn.ts +168 -5
  216. package/src/plugin-api/index.ts +21 -5
  217. package/src/plugin-api/vision-support.test.ts +1 -1
  218. package/src/plugins/__tests__/mcp-servers.test.ts +371 -0
  219. package/src/plugins/defaults/memory/AGENTS.md +4 -0
  220. package/src/plugins/defaults/memory/__tests__/buffer-format.test.ts +204 -0
  221. package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +72 -0
  222. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +4 -1
  223. package/src/plugins/defaults/memory/__tests__/memory-retrospective-provider-path.test.ts +4 -1
  224. package/src/plugins/defaults/memory/buffer-format.ts +165 -0
  225. package/src/plugins/defaults/memory/context-search/sources/conversations.ts +6 -0
  226. package/src/plugins/defaults/memory/graph/image-ref-utils.ts +3 -0
  227. package/src/plugins/defaults/memory/graph/tool-handlers.ts +1 -30
  228. package/src/plugins/defaults/memory/graph-topology/pending-buffer.test.ts +34 -0
  229. package/src/plugins/defaults/memory/graph-topology/pending-buffer.ts +8 -12
  230. package/src/plugins/defaults/memory/hooks/post-compact.ts +1 -4
  231. package/src/plugins/defaults/memory/indexer.ts +3 -1
  232. package/src/plugins/defaults/memory/memory-retrospective-accounting.ts +19 -7
  233. package/src/plugins/defaults/memory/src/__tests__/memory-v3-gate-stats.test.ts +281 -0
  234. package/src/plugins/defaults/memory/src/memory-v3-routes.ts +207 -0
  235. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-job.test.ts +33 -0
  236. package/src/plugins/defaults/memory/substrate/__tests__/static-context.test.ts +199 -2
  237. package/src/plugins/defaults/memory/substrate/consolidation-job.ts +25 -18
  238. package/src/plugins/defaults/memory/substrate/skill-content.ts +8 -1
  239. package/src/plugins/defaults/memory/substrate/static-context.ts +160 -4
  240. package/src/plugins/defaults/memory/substrate/sweep-job.ts +2 -4
  241. package/src/plugins/defaults/memory/v1/graph/extraction.ts +3 -1
  242. package/src/plugins/defaults/memory/v3/prune.ts +2 -0
  243. package/src/plugins/defaults/memory/v3/selection-log-store.ts +2 -0
  244. package/src/plugins/defaults/memory/worker.ts +6 -3
  245. package/src/plugins/external-plugin-loader.ts +47 -0
  246. package/src/plugins/mcp-servers.ts +361 -0
  247. package/src/plugins/mtime-cache.ts +23 -49
  248. package/src/plugins/worker-plugin-surface.ts +33 -0
  249. package/src/providers/__tests__/connection-model-compat.test.ts +1 -1
  250. package/src/providers/__tests__/dispatch-connection-routing.test.ts +214 -2
  251. package/src/providers/__tests__/preflight-resolved-config.test.ts +57 -0
  252. package/src/providers/__tests__/retry-callsite.test.ts +5 -2
  253. package/src/providers/call-site-routing.ts +30 -3
  254. package/src/providers/connection-resolution.ts +194 -11
  255. package/src/providers/inference/auth.ts +6 -0
  256. package/src/providers/inference/connection-availability.ts +24 -2
  257. package/src/providers/inference/connections.ts +2 -0
  258. package/src/providers/model-catalog.ts +3 -3
  259. package/src/providers/model-intents.ts +28 -8
  260. package/src/providers/openai/chat-completions-provider.ts +5 -6
  261. package/src/providers/openai/codex-models.ts +2 -1
  262. package/src/providers/provider-send-message.ts +32 -3
  263. package/src/providers/speech-to-text/__tests__/deepgram-flux-frames.test.ts +433 -0
  264. package/src/providers/speech-to-text/__tests__/deepgram-flux-realtime.test.ts +620 -0
  265. package/src/providers/speech-to-text/__tests__/provider-catalog.test.ts +34 -0
  266. package/src/providers/speech-to-text/__tests__/resolve.test.ts +285 -6
  267. package/src/providers/speech-to-text/deepgram-flux-frames.ts +395 -0
  268. package/src/providers/speech-to-text/deepgram-flux-realtime.ts +719 -0
  269. package/src/providers/speech-to-text/provider-catalog.ts +99 -8
  270. package/src/providers/speech-to-text/resolve.ts +25 -2
  271. package/src/routes/worker.ts +17 -5
  272. package/src/runtime/access-request-helper.ts +9 -12
  273. package/src/runtime/agent-wake.ts +3 -3
  274. package/src/runtime/pre-first-message-gate.ts +4 -0
  275. package/src/runtime/routes/__tests__/acp-claude-auth-routes.test.ts +12 -4
  276. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +219 -1
  277. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +52 -0
  278. package/src/runtime/routes/__tests__/default-provider-routes.test.ts +61 -0
  279. package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +44 -0
  280. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +102 -1
  281. package/src/runtime/routes/__tests__/plugins-routes.test.ts +44 -0
  282. package/src/runtime/routes/__tests__/stt-routes.test.ts +25 -0
  283. package/src/runtime/routes/__tests__/user-route-dispatcher.test.ts +62 -1
  284. package/src/runtime/routes/channel-availability-routes.ts +32 -14
  285. package/src/runtime/routes/channel-route-shared.ts +0 -6
  286. package/src/runtime/routes/chatgpt-subscription-auth-routes.ts +6 -6
  287. package/src/runtime/routes/conversation-list-routes.ts +112 -1
  288. package/src/runtime/routes/conversation-query-routes.ts +40 -27
  289. package/src/runtime/routes/credential-prompt-routes.ts +4 -7
  290. package/src/runtime/routes/default-provider-routes.ts +15 -0
  291. package/src/runtime/routes/inbound-message-handler.ts +17 -41
  292. package/src/runtime/routes/inbound-stages/acl-enforcement.test.ts +0 -1
  293. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +0 -9
  294. package/src/runtime/routes/inbound-stages/admission-policy.ts +1 -17
  295. package/src/runtime/routes/inbound-stages/bootstrap-intercept.test.ts +0 -1
  296. package/src/runtime/routes/inbound-stages/bootstrap-intercept.ts +2 -3
  297. package/src/runtime/routes/inbound-stages/edit-intercept.ts +1 -3
  298. package/src/runtime/routes/inbound-stages/guardian-reply-intercept.test.ts +0 -1
  299. package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +3 -4
  300. package/src/runtime/routes/inbound-stages/reaction-intercept.test.ts +0 -1
  301. package/src/runtime/routes/inbound-stages/reaction-intercept.ts +11 -20
  302. package/src/runtime/routes/inbound-stages/secret-ingress-check.ts +2 -3
  303. package/src/runtime/routes/inference-profiles-routes.ts +20 -11
  304. package/src/runtime/routes/inference-provider-connection-routes.ts +77 -15
  305. package/src/runtime/routes/log-export-routes.ts +3 -0
  306. package/src/runtime/routes/mcp-auth-routes.ts +148 -57
  307. package/src/runtime/routes/plugins-routes.ts +21 -3
  308. package/src/runtime/routes/stt-routes.ts +31 -25
  309. package/src/runtime/routes/surface-conversation-resolver.ts +3 -0
  310. package/src/runtime/routes/user-route-dispatcher.ts +39 -14
  311. package/src/runtime/routes/user-route-import.ts +108 -0
  312. package/src/schedule/worker.ts +6 -0
  313. package/src/security/oauth2.ts +6 -22
  314. package/src/stt/__tests__/daemon-batch-transcriber.test.ts +22 -0
  315. package/src/stt/__tests__/types.test.ts +94 -0
  316. package/src/stt/daemon-batch-transcriber.ts +10 -0
  317. package/src/stt/stt-stream-session.ts +8 -4
  318. package/src/stt/types.ts +103 -0
  319. package/src/subagent/manager.ts +1 -3
  320. package/src/subagent/types.ts +7 -6
  321. package/src/tools/acp/spawn.test.ts +97 -0
  322. package/src/tools/acp/spawn.ts +32 -0
  323. package/src/tools/document/document-tool.ts +12 -3
  324. package/src/tools/registry.ts +2 -1
  325. package/src/tools/ui-surface/surface-shape-docs.ts +11 -0
  326. package/src/tools/workflows/run-workflow.ts +1 -2
  327. package/src/tts/__tests__/reasoning-tag-filter.test.ts +78 -0
  328. package/src/tts/reasoning-tag-filter.ts +89 -0
  329. package/src/util/think-tag-stream.ts +95 -0
  330. package/src/workspace/byok-default-profile-ensure.ts +76 -24
  331. package/src/workspace/custom-profile-ensure.ts +4 -24
  332. package/src/workspace/migrations/142-consolidate-voice-front-door.ts +70 -0
  333. package/src/workspace/migrations/143-repair-deprecated-codex-model-id.ts +134 -0
  334. package/src/workspace/migrations/144-convert-stranded-subscription-openai-profiles.ts +265 -0
  335. package/src/workspace/migrations/145-collapse-profile-bindings-to-entries.ts +328 -0
  336. package/src/workspace/migrations/146-repair-retired-fireworks-deepseek-flash-model-id.ts +195 -0
  337. package/src/workspace/migrations/__tests__/141-stt-english-default-to-multilingual.test.ts +0 -10
  338. package/src/workspace/migrations/registry.ts +10 -0
  339. package/src/workspace/provider-commit-message-generator.ts +7 -5
  340. package/src/live-voice/__tests__/front-decision.test.ts +0 -645
  341. package/src/live-voice/front-decision.ts +0 -476
@@ -0,0 +1,719 @@
1
+ /**
2
+ * Deepgram Flux realtime streaming STT adapter (`wss://api.deepgram.com/v2/listen`).
3
+ *
4
+ * Flux is Deepgram's conversational speech API: the model itself decides where
5
+ * a turn ends, so this adapter carries no endpointing heuristics of its own.
6
+ * It owns the socket lifecycle (connect, keepalive, teardown) and delegates
7
+ * every inbound transcript frame to {@link parseFluxFrame}, the pure protocol
8
+ * module, which maps Flux's wire shapes onto the daemon's
9
+ * {@link SttStreamServerEvent} contract.
10
+ *
11
+ * Lifecycle:
12
+ * 1. {@link start} opens the WebSocket and resolves once it is established.
13
+ * 2. {@link sendAudio} forwards raw audio with a backpressure guard.
14
+ * 3. {@link stop} sends `CloseStream` and waits for Flux to flush the turn
15
+ * in progress before closing.
16
+ * 4. The `onEvent` callback receives `partial`, `final`, the four
17
+ * turn-detection events, `error`, and finally `closed`.
18
+ *
19
+ * There is **no `finalizeUtterance`**. Flux commits a transcript only when
20
+ * its model closes a turn, and its wire protocol offers no mid-stream flush:
21
+ * `CloseStream` is the only way to make it answer for a turn still in
22
+ * progress. A method that returned `finalized` without flushing would claim a
23
+ * commit the provider never made and lose the tail of every turn released on
24
+ * a caller-side boundary, so the optional method is left off and callers
25
+ * feature-detect it and fall back to {@link stop}.
26
+ *
27
+ * Error handling mirrors `deepgram-realtime.ts`: socket closes and errors map
28
+ * onto {@link SttErrorCategory} values (`auth`, `rate-limit`, `timeout`,
29
+ * `provider-error`), in-session failures surface as `error` events, and
30
+ * teardown always emits `closed`. One failure produces exactly one `error`:
31
+ * a fatal `Error` frame is reported with the provider's own diagnostic, and
32
+ * the close Deepgram sends immediately after it goes straight to `closed`.
33
+ */
34
+
35
+ import { getConfig } from "../../config/loader.js";
36
+ import type { LiveVoiceFluxConfig } from "../../config/schemas/live-voice.js";
37
+ import type {
38
+ StreamingTranscriber,
39
+ SttErrorCategory,
40
+ SttStreamServerEvent,
41
+ } from "../../stt/types.js";
42
+ import { SttError } from "../../stt/types.js";
43
+ import { getLogger } from "../../util/logger.js";
44
+ import type { FluxEncoding } from "./deepgram-flux-frames.js";
45
+ import {
46
+ buildFluxQueryParams,
47
+ parseFluxFrame,
48
+ } from "./deepgram-flux-frames.js";
49
+
50
+ const log = getLogger("deepgram-flux-realtime");
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Constants
54
+ // ---------------------------------------------------------------------------
55
+
56
+ const WS_BASE_URL = "wss://api.deepgram.com";
57
+
58
+ /** Flux lives on the v2 listen route; the v1 route speaks a different protocol. */
59
+ const FLUX_PATH = "/v2/listen";
60
+
61
+ /** Timeout (ms) for the WebSocket handshake before {@link start} rejects. */
62
+ const DEFAULT_CONNECT_TIMEOUT_MS = 10_000;
63
+
64
+ /**
65
+ * Inactivity timeout (ms). If audio has been sent but Flux says nothing back
66
+ * for this long, the adapter closes with a `timeout` error. A stream with no
67
+ * audio awaiting a response (mic gated while the assistant speaks) is
68
+ * legitimately silent and never times out.
69
+ */
70
+ const DEFAULT_INACTIVITY_TIMEOUT_MS = 30_000;
71
+
72
+ /**
73
+ * Interval (ms) between `KeepAlive` control frames. Deepgram closes a socket
74
+ * that carries no audio for ~10s, and raw silence does not reset that timer.
75
+ * Only the explicit control message does.
76
+ */
77
+ const DEFAULT_KEEPALIVE_INTERVAL_MS = 5_000;
78
+
79
+ /** Outbound buffer ceiling (bytes) before {@link sendAudio} drops frames. */
80
+ const MAX_BUFFERED_AMOUNT = 1024 * 1024; // 1 MiB
81
+
82
+ /** Grace (ms) after `CloseStream` before the socket is force-closed. */
83
+ const CLOSE_GRACE_MS = 5_000;
84
+
85
+ /** Raw-audio encoding of the stream: clients send linear16 PCM. */
86
+ const AUDIO_ENCODING: FluxEncoding = "linear16";
87
+
88
+ /** Default sample rate (Hz) when the client negotiates none. */
89
+ const DEFAULT_SAMPLE_RATE = 16_000;
90
+
91
+ /** Bytes per sample of mono linear16 PCM. */
92
+ const LINEAR16_BYTES_PER_SAMPLE = 2;
93
+
94
+ /**
95
+ * Chunk duration Deepgram recommends for Flux. Logged alongside the observed
96
+ * duration so a runbook can compare the two without instrumenting capture.
97
+ */
98
+ const RECOMMENDED_CHUNK_MS = 80;
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Options
102
+ // ---------------------------------------------------------------------------
103
+
104
+ /**
105
+ * Transport-level wiring for a Flux session. Turn-detection tuning (model,
106
+ * thresholds, force-end timeout) is not here: it comes from `liveVoice.flux`,
107
+ * which the adapter reads itself, so there is exactly one place to turn those
108
+ * dials.
109
+ */
110
+ export interface DeepgramFluxRealtimeOptions {
111
+ /** Audio sample rate in Hz (default: 16000). */
112
+ sampleRate?: number;
113
+ /** Connect timeout in milliseconds. Default: 10_000. */
114
+ connectTimeoutMs?: number;
115
+ /** Inactivity timeout in milliseconds. Default: 30_000. */
116
+ inactivityTimeoutMs?: number;
117
+ /**
118
+ * Interval (ms) between `KeepAlive` control frames. Default: 5_000. Set to
119
+ * 0 to disable (tests only, because Deepgram closes silent sockets after ~10s).
120
+ */
121
+ keepaliveIntervalMs?: number;
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // Minimal WebSocket interface
126
+ // ---------------------------------------------------------------------------
127
+
128
+ /**
129
+ * Minimal structural WebSocket interface so tests can substitute a mock
130
+ * without depending on Bun's global WebSocket type at the type level.
131
+ */
132
+ interface WsLike {
133
+ readonly readyState: number;
134
+ readonly bufferedAmount: number;
135
+ send(data: string | ArrayBufferLike | ArrayBuffer | Uint8Array): void;
136
+ close(code?: number, reason?: string): void;
137
+ addEventListener(type: "open", listener: () => void): void;
138
+ addEventListener(
139
+ type: "close",
140
+ listener: (ev: { code: number; reason: string }) => void,
141
+ ): void;
142
+ addEventListener(type: "error", listener: (ev: unknown) => void): void;
143
+ addEventListener(
144
+ type: "message",
145
+ listener: (ev: { data: unknown }) => void,
146
+ ): void;
147
+ removeEventListener(type: string, listener: unknown): void;
148
+ }
149
+
150
+ const WS_OPEN = 1;
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // Adapter implementation
154
+ // ---------------------------------------------------------------------------
155
+
156
+ /**
157
+ * Deepgram Flux streaming transcriber.
158
+ *
159
+ * Implements the daemon {@link StreamingTranscriber} contract on top of
160
+ * Deepgram's conversational `/v2/listen` WebSocket API.
161
+ */
162
+ export class DeepgramFluxRealtimeTranscriber implements StreamingTranscriber {
163
+ readonly providerId = "deepgram-flux" as const;
164
+ readonly boundaryId = "daemon-streaming" as const;
165
+
166
+ private readonly apiKey: string;
167
+ /** Turn-detection tuning, snapshotted when the transcriber is built. */
168
+ private readonly flux: LiveVoiceFluxConfig;
169
+ private readonly sampleRate: number;
170
+ private readonly connectTimeoutMs: number;
171
+ private readonly inactivityTimeoutMs: number;
172
+ private readonly keepaliveIntervalMs: number;
173
+
174
+ /** The live WebSocket connection, set during start(). */
175
+ private ws: WsLike | null = null;
176
+
177
+ /** Callback for emitting events to the session orchestrator. */
178
+ private onEvent: ((event: SttStreamServerEvent) => void) | null = null;
179
+
180
+ /** Whether the session has been fully closed. */
181
+ private closed = false;
182
+
183
+ /** Whether stop() has been called. */
184
+ private stopping = false;
185
+
186
+ /**
187
+ * Whether a fatal `Error` frame has already been reported as an `error`
188
+ * event. Deepgram closes the socket right after that frame, so the close
189
+ * that follows carries no information the provider's own diagnostic did not
190
+ * already carry and must not raise a second, more generic error.
191
+ */
192
+ private fatalErrorReported = false;
193
+
194
+ /** Whether the per-session chunk-cadence line has already been logged. */
195
+ private chunkCadenceLogged = false;
196
+
197
+ /** Inactivity timer handle. */
198
+ private inactivityTimer: ReturnType<typeof setTimeout> | null = null;
199
+
200
+ /**
201
+ * When the first audio frame went out after the last inbound provider
202
+ * message; null while nothing is owed a response. The inactivity watchdog
203
+ * only rules "hung" while this is set: Flux says nothing during silence,
204
+ * so inbound quiet alone is not evidence of a hang.
205
+ */
206
+ private awaitingResponseSinceMs: number | null = null;
207
+
208
+ /** Close grace timer handle. */
209
+ private closeGraceTimer: ReturnType<typeof setTimeout> | null = null;
210
+
211
+ /** Periodic `KeepAlive` timer. */
212
+ private keepaliveTimer: ReturnType<typeof setInterval> | null = null;
213
+
214
+ constructor(apiKey: string, options: DeepgramFluxRealtimeOptions = {}) {
215
+ this.apiKey = apiKey;
216
+ this.flux = getConfig().liveVoice.flux;
217
+ this.sampleRate = options.sampleRate ?? DEFAULT_SAMPLE_RATE;
218
+ this.connectTimeoutMs =
219
+ options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
220
+ this.inactivityTimeoutMs =
221
+ options.inactivityTimeoutMs ?? DEFAULT_INACTIVITY_TIMEOUT_MS;
222
+ this.keepaliveIntervalMs =
223
+ options.keepaliveIntervalMs ?? DEFAULT_KEEPALIVE_INTERVAL_MS;
224
+ }
225
+
226
+ // ── StreamingTranscriber interface ──────────────────────────────────
227
+
228
+ async start(onEvent: (event: SttStreamServerEvent) => void): Promise<void> {
229
+ if (this.ws) {
230
+ throw new Error("DeepgramFluxRealtimeTranscriber: start() called twice");
231
+ }
232
+ this.onEvent = onEvent;
233
+
234
+ const url = this.buildWebSocketUrl();
235
+ log.info({ url }, "Opening Deepgram Flux session");
236
+
237
+ const ws = this.createWebSocket(url);
238
+ this.ws = ws;
239
+
240
+ // Wait for the WebSocket to open or fail. Failures reject as SttError so
241
+ // the caller gets the same normalized category an in-session failure
242
+ // would carry. A bad key is an `auth` problem whether it lands during
243
+ // the handshake or after it.
244
+ await new Promise<void>((resolve, reject) => {
245
+ let settled = false;
246
+
247
+ const connectTimer = setTimeout(() => {
248
+ if (settled) {
249
+ return;
250
+ }
251
+ settled = true;
252
+ this.forceClose();
253
+ reject(
254
+ new SttError("timeout", "Deepgram Flux realtime connect timeout"),
255
+ );
256
+ }, this.connectTimeoutMs);
257
+
258
+ const onOpen = () => {
259
+ if (settled) {
260
+ return;
261
+ }
262
+ settled = true;
263
+ clearTimeout(connectTimer);
264
+ resolve();
265
+ };
266
+
267
+ const onError = (ev: unknown) => {
268
+ if (settled) {
269
+ return;
270
+ }
271
+ settled = true;
272
+ clearTimeout(connectTimer);
273
+ reject(
274
+ new SttError(
275
+ "provider-error",
276
+ `Deepgram Flux realtime connect error: ${describeSocketEvent(ev)}`,
277
+ ),
278
+ );
279
+ };
280
+
281
+ const onClose = (ev: { code: number; reason: string }) => {
282
+ if (settled) {
283
+ return;
284
+ }
285
+ settled = true;
286
+ clearTimeout(connectTimer);
287
+ reject(
288
+ new SttError(
289
+ closeCodeCategory(ev.code),
290
+ `Deepgram Flux WebSocket closed before open (code=${ev.code}, reason=${ev.reason})`,
291
+ ),
292
+ );
293
+ };
294
+
295
+ ws.addEventListener("open", onOpen);
296
+ ws.addEventListener("error", onError);
297
+ ws.addEventListener("close", onClose);
298
+ });
299
+
300
+ // Socket is open. Attach the handlers for the active session lifetime.
301
+ this.attachSessionHandlers(ws);
302
+ this.resetInactivityTimer();
303
+ this.startKeepaliveTimer();
304
+
305
+ log.info({ model: this.flux.model }, "Deepgram Flux session opened");
306
+ }
307
+
308
+ sendAudio(audio: Buffer, _mimeType: string): void {
309
+ if (this.closed || this.stopping) {
310
+ return;
311
+ }
312
+
313
+ const ws = this.ws;
314
+ if (!ws || ws.readyState !== WS_OPEN) {
315
+ return;
316
+ }
317
+
318
+ // Backpressure check: drop frames rather than grow the outbound buffer
319
+ // without bound when the network cannot keep up with the audio rate.
320
+ if (ws.bufferedAmount > MAX_BUFFERED_AMOUNT) {
321
+ log.warn(
322
+ { bufferedAmount: ws.bufferedAmount },
323
+ "Deepgram Flux backpressure: dropping audio frame",
324
+ );
325
+ return;
326
+ }
327
+
328
+ ws.send(new Uint8Array(audio));
329
+ this.awaitingResponseSinceMs ??= Date.now();
330
+ this.logChunkCadenceOnce(audio.byteLength);
331
+ }
332
+
333
+ stop(): void {
334
+ if (this.closed || this.stopping) {
335
+ return;
336
+ }
337
+ this.stopping = true;
338
+
339
+ log.info("Stopping Deepgram Flux session");
340
+
341
+ const ws = this.ws;
342
+ if (!ws || ws.readyState !== WS_OPEN) {
343
+ this.emitClosedAndCleanup();
344
+ return;
345
+ }
346
+
347
+ // `CloseStream` tells Flux to finish the turn in progress and answer
348
+ // before it terminates the socket.
349
+ try {
350
+ ws.send(JSON.stringify({ type: "CloseStream" }));
351
+ } catch {
352
+ this.emitClosedAndCleanup();
353
+ return;
354
+ }
355
+
356
+ this.closeGraceTimer = setTimeout(() => {
357
+ log.warn("Deepgram Flux close grace timeout, forcing close");
358
+ this.emitClosedAndCleanup();
359
+ }, CLOSE_GRACE_MS);
360
+ }
361
+
362
+ // ── WebSocket lifecycle ─────────────────────────────────────────────
363
+
364
+ /**
365
+ * Create a WebSocket instance. Factored out for test mockability.
366
+ *
367
+ * The key travels in the `Authorization: Token` header. Flux needs no
368
+ * query auth, so no URL ever carries the credential and nothing here needs
369
+ * redacting before it reaches a log.
370
+ */
371
+ private createWebSocket(url: string): WsLike {
372
+ const WebSocketCtor = (
373
+ globalThis as unknown as {
374
+ WebSocket: new (
375
+ url: string,
376
+ options?: { headers?: Record<string, string> },
377
+ ) => WsLike;
378
+ }
379
+ ).WebSocket;
380
+ if (typeof WebSocketCtor !== "function") {
381
+ throw new Error("global WebSocket is not available in this runtime");
382
+ }
383
+ return new WebSocketCtor(url, {
384
+ headers: { Authorization: `Token ${this.apiKey}` },
385
+ });
386
+ }
387
+
388
+ private attachSessionHandlers(ws: WsLike): void {
389
+ ws.addEventListener("message", (ev: { data: unknown }) => {
390
+ this.handleProviderMessage(ev.data);
391
+ });
392
+
393
+ ws.addEventListener("close", (ev: { code: number; reason: string }) => {
394
+ this.handleProviderClose(ev.code, ev.reason);
395
+ });
396
+
397
+ ws.addEventListener("error", (ev: unknown) => {
398
+ this.handleProviderError(ev);
399
+ });
400
+ }
401
+
402
+ // ── Provider message handling ───────────────────────────────────────
403
+
404
+ /**
405
+ * Normalize one inbound Flux frame into daemon events.
406
+ *
407
+ * Frames go straight to {@link parseFluxFrame}, which owns JSON decoding,
408
+ * the wire shapes, and the graceful handling of anything it does not
409
+ * recognize.
410
+ */
411
+ private handleProviderMessage(data: unknown): void {
412
+ if (this.closed) {
413
+ return;
414
+ }
415
+
416
+ this.resetInactivityTimer();
417
+
418
+ const raw =
419
+ typeof data === "string"
420
+ ? data
421
+ : data instanceof ArrayBuffer
422
+ ? new TextDecoder().decode(data)
423
+ : null;
424
+ if (raw === null) {
425
+ // Unexpected binary format, ignore.
426
+ return;
427
+ }
428
+
429
+ for (const event of parseFluxFrame(raw)) {
430
+ if (event.type === "error") {
431
+ this.fatalErrorReported = true;
432
+ }
433
+ this.emitEvent(event);
434
+ }
435
+ }
436
+
437
+ /** Handle provider-side WebSocket close. */
438
+ private handleProviderClose(code: number, reason: string): void {
439
+ if (this.closed) {
440
+ return;
441
+ }
442
+
443
+ // Normal close (1000) or going-away (1001) after stop() is expected.
444
+ if (this.stopping && (code === 1000 || code === 1001)) {
445
+ log.info({ code, reason }, "Deepgram Flux session closed normally");
446
+ this.emitClosedAndCleanup();
447
+ return;
448
+ }
449
+
450
+ // A fatal `Error` frame already reported the provider's own diagnostic,
451
+ // and the close is that frame's second half. Go straight to `closed` so
452
+ // callers see exactly one error for one failure.
453
+ if (this.fatalErrorReported) {
454
+ log.info(
455
+ { code, reason },
456
+ "Deepgram Flux session closed after a fatal error frame",
457
+ );
458
+ this.emitClosedAndCleanup();
459
+ return;
460
+ }
461
+
462
+ log.warn({ code, reason }, "Deepgram Flux session closed unexpectedly");
463
+
464
+ this.emitEvent({
465
+ type: "error",
466
+ category: closeCodeCategory(code),
467
+ message: `Deepgram Flux WebSocket closed (code=${code}, reason=${reason})`,
468
+ });
469
+ this.emitClosedAndCleanup();
470
+ }
471
+
472
+ /** Handle provider-side WebSocket error. */
473
+ private handleProviderError(ev: unknown): void {
474
+ if (this.closed) {
475
+ return;
476
+ }
477
+
478
+ const message = describeSocketEvent(ev);
479
+
480
+ // Same one-error-per-failure rule as the close path: a socket error that
481
+ // trails a fatal `Error` frame is that failure surfacing again.
482
+ if (this.fatalErrorReported) {
483
+ log.info(
484
+ { error: message },
485
+ "Deepgram Flux WebSocket error after a fatal error frame",
486
+ );
487
+ this.emitClosedAndCleanup();
488
+ return;
489
+ }
490
+
491
+ log.error({ error: message }, "Deepgram Flux WebSocket error");
492
+
493
+ this.emitEvent({
494
+ type: "error",
495
+ category: "provider-error",
496
+ message: `Deepgram Flux WebSocket error: ${message}`,
497
+ });
498
+ this.emitClosedAndCleanup();
499
+ }
500
+
501
+ // ── Event emission & cleanup ────────────────────────────────────────
502
+
503
+ /**
504
+ * Emit a server event to the session orchestrator. Swallows listener errors
505
+ * so a bad consumer cannot tear down the adapter.
506
+ */
507
+ private emitEvent(event: SttStreamServerEvent): void {
508
+ if (!this.onEvent) {
509
+ return;
510
+ }
511
+ try {
512
+ this.onEvent(event);
513
+ } catch (err) {
514
+ log.warn({ error: err }, "Listener error in Deepgram Flux adapter");
515
+ }
516
+ }
517
+
518
+ /**
519
+ * Log the observed audio chunk duration once per session, next to the
520
+ * cadence Deepgram recommends for Flux. Capture cadence is a client
521
+ * concern; this only makes it measurable from the daemon side.
522
+ */
523
+ private logChunkCadenceOnce(byteLength: number): void {
524
+ if (this.chunkCadenceLogged) {
525
+ return;
526
+ }
527
+ this.chunkCadenceLogged = true;
528
+
529
+ const observedChunkMs =
530
+ this.sampleRate > 0
531
+ ? Math.round(
532
+ (byteLength / LINEAR16_BYTES_PER_SAMPLE / this.sampleRate) * 1_000,
533
+ )
534
+ : undefined;
535
+
536
+ log.info(
537
+ {
538
+ byteLength,
539
+ sampleRate: this.sampleRate,
540
+ encoding: AUDIO_ENCODING,
541
+ observedChunkMs,
542
+ recommendedChunkMs: RECOMMENDED_CHUNK_MS,
543
+ },
544
+ "Deepgram Flux audio chunk cadence",
545
+ );
546
+ }
547
+
548
+ /**
549
+ * Emit `closed` and release every resource. Idempotent, safe to call from
550
+ * any teardown path.
551
+ */
552
+ private emitClosedAndCleanup(): void {
553
+ if (this.closed) {
554
+ return;
555
+ }
556
+ this.closed = true;
557
+
558
+ this.clearTimers();
559
+ this.forceClose();
560
+
561
+ this.emitEvent({ type: "closed" });
562
+ this.onEvent = null;
563
+ }
564
+
565
+ /** Force-close the WebSocket without emitting events. */
566
+ private forceClose(): void {
567
+ const ws = this.ws;
568
+ this.ws = null;
569
+ if (!ws) {
570
+ return;
571
+ }
572
+
573
+ try {
574
+ ws.close();
575
+ } catch {
576
+ // Best effort: already closed sockets may throw.
577
+ }
578
+ }
579
+
580
+ private clearTimers(): void {
581
+ if (this.inactivityTimer !== null) {
582
+ clearTimeout(this.inactivityTimer);
583
+ this.inactivityTimer = null;
584
+ }
585
+ if (this.closeGraceTimer !== null) {
586
+ clearTimeout(this.closeGraceTimer);
587
+ this.closeGraceTimer = null;
588
+ }
589
+ if (this.keepaliveTimer !== null) {
590
+ clearInterval(this.keepaliveTimer);
591
+ this.keepaliveTimer = null;
592
+ }
593
+ }
594
+
595
+ /**
596
+ * Start the periodic keepalive. A `KeepAlive` control frame is the only
597
+ * thing that resets Deepgram's server-side inactivity timer while the
598
+ * stream carries silence. Raw silent PCM does not count.
599
+ */
600
+ private startKeepaliveTimer(): void {
601
+ if (this.closed || this.stopping || this.keepaliveIntervalMs <= 0) {
602
+ return;
603
+ }
604
+ this.keepaliveTimer = setInterval(() => {
605
+ if (this.closed || this.stopping) {
606
+ return;
607
+ }
608
+ const ws = this.ws;
609
+ if (!ws || ws.readyState !== WS_OPEN) {
610
+ return;
611
+ }
612
+ try {
613
+ ws.send(JSON.stringify({ type: "KeepAlive" }));
614
+ } catch (err) {
615
+ log.warn({ err }, "Deepgram Flux KeepAlive send failed");
616
+ }
617
+ }, this.keepaliveIntervalMs);
618
+ }
619
+
620
+ /**
621
+ * Reset the inactivity watchdog on inbound provider messages. Not reset on
622
+ * outbound audio: continuous audio from the caller must not mask a silent
623
+ * provider.
624
+ */
625
+ private resetInactivityTimer(): void {
626
+ this.awaitingResponseSinceMs = null;
627
+ this.armInactivityTimer(this.inactivityTimeoutMs);
628
+ }
629
+
630
+ /**
631
+ * (Re)arm the inactivity watchdog. On fire it only rules "hung" when audio
632
+ * has been awaiting a response for a full timeout window; otherwise the
633
+ * stream is merely idle and the timer re-arms for the remainder.
634
+ */
635
+ private armInactivityTimer(delayMs: number): void {
636
+ if (this.closed || this.stopping) {
637
+ return;
638
+ }
639
+
640
+ if (this.inactivityTimer !== null) {
641
+ clearTimeout(this.inactivityTimer);
642
+ }
643
+
644
+ this.inactivityTimer = setTimeout(() => {
645
+ if (this.closed) {
646
+ return;
647
+ }
648
+
649
+ const since = this.awaitingResponseSinceMs;
650
+ if (since === null) {
651
+ this.armInactivityTimer(this.inactivityTimeoutMs);
652
+ return;
653
+ }
654
+ const waitedMs = Date.now() - since;
655
+ if (waitedMs < this.inactivityTimeoutMs) {
656
+ this.armInactivityTimer(this.inactivityTimeoutMs - waitedMs);
657
+ return;
658
+ }
659
+
660
+ log.warn("Deepgram Flux inactivity timeout");
661
+ this.emitEvent({
662
+ type: "error",
663
+ category: "timeout",
664
+ message: "Deepgram Flux session timed out due to inactivity",
665
+ });
666
+ this.emitClosedAndCleanup();
667
+ }, delayMs);
668
+ }
669
+
670
+ // ── URL construction ────────────────────────────────────────────────
671
+
672
+ /**
673
+ * Build the Flux WebSocket URL. Query construction (including threshold
674
+ * clamping and omitting `eager_eot_threshold` when unset) belongs to
675
+ * {@link buildFluxQueryParams}.
676
+ */
677
+ private buildWebSocketUrl(): string {
678
+ const query = buildFluxQueryParams({
679
+ model: this.flux.model,
680
+ encoding: AUDIO_ENCODING,
681
+ sampleRate: this.sampleRate,
682
+ eotThreshold: this.flux.eotThreshold,
683
+ eagerEotThreshold: this.flux.eagerEotThreshold,
684
+ eotTimeoutMs: this.flux.eotTimeoutMs,
685
+ });
686
+ return `${WS_BASE_URL}${FLUX_PATH}?${query}`;
687
+ }
688
+ }
689
+
690
+ // ---------------------------------------------------------------------------
691
+ // Helpers
692
+ // ---------------------------------------------------------------------------
693
+
694
+ /**
695
+ * Map a WebSocket close code onto a normalized STT error category, matching
696
+ * how `DeepgramRealtimeTranscriber` classifies the same codes: 1008 (policy
697
+ * violation) and 4001 are how Deepgram rejects credentials, and 1013 (try
698
+ * again later) is how it sheds load.
699
+ */
700
+ function closeCodeCategory(code: number): SttErrorCategory {
701
+ if (code === 1008 || code === 4001) {
702
+ return "auth";
703
+ }
704
+ if (code === 1013) {
705
+ return "rate-limit";
706
+ }
707
+ return "provider-error";
708
+ }
709
+
710
+ /** Best-effort human-readable text for a WebSocket error event. */
711
+ function describeSocketEvent(ev: unknown): string {
712
+ if (ev instanceof Error) {
713
+ return ev.message;
714
+ }
715
+ if (typeof ev === "object" && ev !== null && "message" in ev) {
716
+ return String((ev as { message: unknown }).message);
717
+ }
718
+ return "WebSocket error";
719
+ }