@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.
- package/ARCHITECTURE.md +11 -6
- package/docs/architecture/memory.md +11 -0
- package/docs/architecture/turn-actor.md +70 -0
- package/docs/flux-turn-detection-spike.md +243 -0
- package/docs/stt-provider-onboarding.md +3 -1
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
- package/node_modules/@vellumai/gateway-client/src/admission-policy-contract.ts +34 -0
- package/node_modules/@vellumai/gateway-client/src/index.ts +2 -0
- package/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
- package/openapi.yaml +139 -37
- package/package.json +1 -1
- package/scripts/voice-ttft-spike.ts +3 -3
- package/src/__tests__/app-compiler.test.ts +38 -3
- package/src/__tests__/attachments-store.test.ts +21 -12
- package/src/__tests__/byok-default-profile-ensure.test.ts +17 -0
- package/src/__tests__/call-setup-flow-name-capture.test.ts +0 -1
- package/src/__tests__/call-site-routing-provider.test.ts +1 -1
- package/src/__tests__/channel-availability-routes.test.ts +14 -1
- package/src/__tests__/channel-capabilities-dedupe.test.ts +214 -0
- package/src/__tests__/channel-delivery-store.test.ts +14 -14
- package/src/__tests__/config-loader-backfill.test.ts +3 -3
- package/src/__tests__/config-schema.test.ts +25 -10
- package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +8 -11
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +8 -11
- package/src/__tests__/conversation-agent-loop.test.ts +28 -20
- package/src/__tests__/conversation-attention-store.test.ts +63 -0
- package/src/__tests__/conversation-delete-schedule-cleanup.test.ts +0 -4
- package/src/__tests__/conversation-fork-crud.test.ts +69 -0
- package/src/__tests__/conversation-fork-referential.test.ts +67 -0
- package/src/__tests__/conversation-fork-retrospective.test.ts +24 -0
- package/src/__tests__/conversation-notifiers-provenance.test.ts +59 -0
- package/src/__tests__/conversation-queue.test.ts +177 -4
- package/src/__tests__/conversation-runtime-assembly.test.ts +134 -102
- package/src/__tests__/conversation-runtime-workspace.test.ts +14 -10
- package/src/__tests__/credential-prompt-route.test.ts +7 -10
- package/src/__tests__/custom-profile-ensure.test.ts +5 -1
- package/src/__tests__/discord-access-request-privacy.test.ts +5 -1
- package/src/__tests__/discord-requester-notice-privacy.test.ts +3 -3
- package/src/__tests__/document-append-idempotency.test.ts +233 -0
- package/src/__tests__/edit-propagation.test.ts +0 -7
- package/src/__tests__/helpers/mock-actor-context.ts +49 -0
- package/src/__tests__/helpers/mock-conversation.ts +13 -1
- package/src/__tests__/injector-chain.test.ts +63 -41
- package/src/__tests__/injector-disk-pressure.test.ts +11 -23
- package/src/__tests__/llm-context-resolution.test.ts +73 -1
- package/src/__tests__/llm-schema.test.ts +5 -2
- package/src/__tests__/mcp-list-plugin-servers.test.ts +250 -0
- package/src/__tests__/memory-retrieval-hook.test.ts +6 -5
- package/src/__tests__/messages-read-boundary-guard.test.ts +134 -0
- package/src/__tests__/mtime-cache.test.ts +1 -1
- package/src/__tests__/non-member-access-request.test.ts +0 -20
- package/src/__tests__/outbound-slack-persistence.test.ts +40 -1
- package/src/__tests__/plugin-import-boundary-guard.test.ts +5 -0
- package/src/__tests__/plugin-secret-pattern-contribution.test.ts +1 -1
- package/src/__tests__/post-compaction-reinjection-idempotency.test.ts +14 -7
- package/src/__tests__/provider-commit-message-generator.test.ts +20 -0
- package/src/__tests__/run-conversation-turn-persistence.test.ts +434 -105
- package/src/__tests__/scoped-approval-grants.test.ts +11 -6
- package/src/__tests__/secret-ingress-channel.test.ts +0 -1
- package/src/__tests__/skills.test.ts +32 -0
- package/src/__tests__/slack-edit-ordering-characterization.test.ts +0 -1
- package/src/__tests__/subagent-call-site-routing.test.ts +31 -19
- package/src/__tests__/subagent-spawn-and-await.test.ts +14 -10
- package/src/__tests__/turn-events-store.test.ts +43 -0
- package/src/__tests__/ui-shape-teaching.test.ts +33 -0
- package/src/__tests__/ui-voice-picker-surface.test.ts +128 -0
- package/src/__tests__/user-plugin-loader.test.ts +1 -1
- package/src/__tests__/visible-app-context.test.ts +16 -9
- package/src/__tests__/voice-config-update.test.ts +40 -0
- package/src/__tests__/worker-entrypoint-guards.test.ts +54 -0
- package/src/__tests__/worker-plugin-surface.test.ts +77 -0
- package/src/__tests__/workspace-migration-142-consolidate-voice-front-door.test.ts +158 -0
- package/src/__tests__/workspace-migration-143-repair-deprecated-codex-model-id.test.ts +133 -0
- package/src/__tests__/workspace-migration-144-convert-stranded-subscription-openai-profiles.test.ts +316 -0
- package/src/__tests__/workspace-migration-145-collapse-profile-bindings-to-entries.test.ts +325 -0
- package/src/__tests__/workspace-migration-146-repair-retired-fireworks-deepseek-flash-model-id.test.ts +235 -0
- package/src/acp/__tests__/acp-claude-oauth.test.ts +10 -2
- package/src/acp/__tests__/auth-required.test.ts +161 -0
- package/src/acp/acp-claude-oauth.ts +19 -2
- package/src/acp/agent-process.test.ts +100 -0
- package/src/acp/agent-process.ts +29 -26
- package/src/acp/auth-required.ts +102 -0
- package/src/acp/session-manager.test.ts +119 -0
- package/src/acp/session-manager.ts +68 -2
- package/src/api/events/acp-auth-required.ts +55 -0
- package/src/api/index.ts +7 -0
- package/src/api/surfaces.ts +7 -3
- package/src/apps/app-store.ts +3 -0
- package/src/bundler/package-resolver.ts +2 -30
- package/src/calls/__tests__/voice-session-bridge.test.ts +173 -1
- package/src/calls/__tests__/voice-triage-escalate.test.ts +94 -0
- package/src/calls/call-controller.ts +19 -3
- package/src/calls/call-setup-flow.ts +0 -1
- package/src/calls/media-stream-stt-session.ts +15 -0
- package/src/calls/voice-session-bridge.ts +71 -16
- package/src/calls/voice-triage-escalate.ts +104 -2
- package/src/channels/__tests__/plugin-channel-declarations.test.ts +161 -0
- package/src/channels/config.ts +13 -0
- package/src/channels/plugin-channel-declarations.ts +108 -0
- package/src/channels/types.ts +30 -0
- package/src/cli/AGENTS.md +5 -2
- package/src/cli/commands/credentials.help.ts +2 -2
- package/src/cli/commands/inference-providers.ts +1 -1
- package/src/cli/commands/mcp.help.ts +13 -4
- package/src/cli/commands/mcp.ts +9 -0
- package/src/cli/commands/memory/__tests__/memory-v3.test.ts +128 -5
- package/src/cli/commands/memory/index.help.ts +43 -1
- package/src/cli/commands/memory/memory-v3.ts +64 -0
- package/src/cli/commands/stt.help.ts +27 -2
- package/src/cli/lib/__tests__/upgrade-plugin.test.ts +39 -0
- package/src/cli/lib/bundled-marketplace.json +13 -0
- package/src/cli/lib/upgrade-plugin.ts +42 -0
- package/src/config/__tests__/default-profile-catalog.test.ts +34 -2
- package/src/config/__tests__/default-provider.test.ts +6 -1
- package/src/config/__tests__/profile-materialization.test.ts +75 -19
- package/src/config/bundled-skills/acp/SKILL.md +6 -7
- package/src/config/bundled-skills/document-editor/SKILL.md +2 -2
- package/src/config/bundled-skills/document-editor/TOOLS.json +2 -2
- package/src/config/bundled-skills/media-processing/services/preprocess.ts +14 -4
- package/src/config/bundled-skills/settings/TOOLS.json +3 -3
- package/src/config/bundled-skills/settings/tools/navigate-settings-tab.test.ts +65 -0
- package/src/config/bundled-skills/settings/tools/navigate-settings-tab.ts +7 -1
- package/src/config/bundled-skills/settings/tools/shared.ts +16 -0
- package/src/config/bundled-skills/settings/tools/voice-config-update.ts +19 -1
- package/src/config/bundled-skills/transcribe/tools/transcribe-media.test.ts +22 -1
- package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +9 -2
- package/src/config/call-site-defaults.ts +4 -5
- package/src/config/default-profile-catalog.ts +83 -12
- package/src/config/default-profile-names.ts +4 -1
- package/src/config/default-provider-resolution.ts +4 -0
- package/src/config/llm-context-resolution.ts +11 -3
- package/src/config/llm-resolver.ts +28 -1
- package/src/config/profile-materialization.ts +70 -22
- package/src/config/schemas/__tests__/live-voice.test.ts +107 -4
- package/src/config/schemas/call-site-catalog.ts +4 -4
- package/src/config/schemas/live-voice.ts +57 -23
- package/src/config/schemas/llm.ts +59 -32
- package/src/config/schemas/mcp.ts +23 -0
- package/src/config/schemas/plugin-updates.ts +6 -2
- package/src/config/schemas/stt.ts +1 -0
- package/src/context/outbound-sanitize.ts +96 -1
- package/src/daemon/__tests__/plugin-mcp-reconcile.test.ts +82 -0
- package/src/daemon/conversation-agent-loop-handlers.ts +15 -10
- package/src/daemon/conversation-agent-loop.ts +17 -6
- package/src/daemon/conversation-messaging.ts +5 -1
- package/src/daemon/conversation-notifiers.ts +9 -1
- package/src/daemon/conversation-process.ts +36 -6
- package/src/daemon/conversation-runtime-assembly.ts +3 -4
- package/src/daemon/conversation-surfaces.ts +27 -5
- package/src/daemon/conversation-tool-setup.ts +1 -2
- package/src/daemon/conversation.ts +48 -0
- package/src/daemon/interactive-turn-sender.ts +59 -0
- package/src/daemon/mcp-reload-service.ts +36 -6
- package/src/daemon/process-message.ts +24 -24
- package/src/daemon/providers-setup.ts +6 -3
- package/src/daemon/trust-context-types.ts +29 -0
- package/src/daemon/wake-conversation-ops.ts +3 -2
- package/src/documents/document-store.ts +138 -5
- package/src/hooks/hook-loader.ts +3 -3
- package/src/hooks/registry.ts +50 -6
- package/src/inbound/__tests__/oauth-callback-url.test.ts +83 -0
- package/src/inbound/oauth-callback-url.ts +61 -0
- package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +1 -104
- package/src/live-voice/__tests__/live-voice-events.test.ts +7 -8
- package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +932 -0
- package/src/live-voice/__tests__/live-voice-metrics.test.ts +115 -8
- package/src/live-voice/__tests__/live-voice-photo.test.ts +100 -0
- package/src/live-voice/__tests__/live-voice-progress.test.ts +60 -194
- package/src/live-voice/__tests__/live-voice-stt.test.ts +14 -0
- package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +29 -0
- package/src/live-voice/__tests__/live-voice-tts-session.test.ts +0 -483
- package/src/live-voice/__tests__/live-voice-vad.test.ts +0 -16
- package/src/live-voice/__tests__/progress-narration.test.ts +214 -0
- package/src/live-voice/live-voice-archive.ts +2 -0
- package/src/live-voice/live-voice-metrics.ts +57 -32
- package/src/live-voice/live-voice-photo.ts +1 -2
- package/src/live-voice/live-voice-session.ts +535 -314
- package/src/live-voice/progress-narration.ts +277 -0
- package/src/live-voice/protocol.ts +21 -1
- package/src/mcp/__tests__/effective-config.test.ts +238 -0
- package/src/mcp/__tests__/mcp-auth-orchestrator.test.ts +0 -1
- package/src/mcp/__tests__/mcp-oauth-client-registration.test.ts +200 -0
- package/src/mcp/__tests__/mcp-oauth-provider.test.ts +9 -9
- package/src/mcp/__tests__/plugin-server-credential-isolation.test.ts +95 -0
- package/src/mcp/client.ts +16 -11
- package/src/mcp/effective-config.ts +113 -0
- package/src/mcp/manager.ts +11 -6
- package/src/mcp/mcp-auth-orchestrator.ts +13 -22
- package/src/mcp/mcp-oauth-provider.ts +205 -240
- package/src/monitoring/__tests__/plugin-auto-update.test.ts +166 -3
- package/src/monitoring/plugin-auto-update.ts +128 -24
- package/src/notifications/signal.ts +1 -0
- package/src/permissions/confirmation-guardian-request.test.ts +15 -11
- package/src/permissions/confirmation-guardian-request.ts +2 -2
- package/src/permissions/question-guardian-request.test.ts +14 -6
- package/src/permissions/question-guardian-request.ts +1 -2
- package/src/persistence/attachments-store.ts +8 -1
- package/src/persistence/bookmark-crud.ts +3 -7
- package/src/persistence/conversation-attention-store.ts +16 -45
- package/src/persistence/conversation-crud.ts +33 -4
- package/src/persistence/conversation-lineage.ts +9 -0
- package/src/persistence/conversation-queries.ts +108 -41
- package/src/persistence/delivery-crud.ts +38 -29
- package/src/persistence/external-conversation-store.ts +32 -4
- package/src/persistence/llm-request-log-store.ts +4 -10
- package/src/persistence/llm-usage-store.ts +8 -3
- package/src/persistence/message-reads.test.ts +197 -0
- package/src/persistence/message-reads.ts +211 -0
- package/src/persistence/migrations/366-chatgpt-subscription-row-identity.test.ts +120 -0
- package/src/persistence/migrations/366-chatgpt-subscription-row-identity.ts +62 -0
- package/src/persistence/real-user-turn-filter.ts +27 -3
- package/src/persistence/steps.ts +9 -0
- package/src/plugin-api/__tests__/oauth-callback-url-export.test.ts +29 -0
- package/src/plugin-api/conversation-turn.ts +168 -5
- package/src/plugin-api/index.ts +21 -5
- package/src/plugin-api/vision-support.test.ts +1 -1
- package/src/plugins/__tests__/mcp-servers.test.ts +371 -0
- package/src/plugins/defaults/memory/AGENTS.md +4 -0
- package/src/plugins/defaults/memory/__tests__/buffer-format.test.ts +204 -0
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +72 -0
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +4 -1
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-provider-path.test.ts +4 -1
- package/src/plugins/defaults/memory/buffer-format.ts +165 -0
- package/src/plugins/defaults/memory/context-search/sources/conversations.ts +6 -0
- package/src/plugins/defaults/memory/graph/image-ref-utils.ts +3 -0
- package/src/plugins/defaults/memory/graph/tool-handlers.ts +1 -30
- package/src/plugins/defaults/memory/graph-topology/pending-buffer.test.ts +34 -0
- package/src/plugins/defaults/memory/graph-topology/pending-buffer.ts +8 -12
- package/src/plugins/defaults/memory/hooks/post-compact.ts +1 -4
- package/src/plugins/defaults/memory/indexer.ts +3 -1
- package/src/plugins/defaults/memory/memory-retrospective-accounting.ts +19 -7
- package/src/plugins/defaults/memory/src/__tests__/memory-v3-gate-stats.test.ts +281 -0
- package/src/plugins/defaults/memory/src/memory-v3-routes.ts +207 -0
- package/src/plugins/defaults/memory/substrate/__tests__/consolidation-job.test.ts +33 -0
- package/src/plugins/defaults/memory/substrate/__tests__/static-context.test.ts +199 -2
- package/src/plugins/defaults/memory/substrate/consolidation-job.ts +25 -18
- package/src/plugins/defaults/memory/substrate/skill-content.ts +8 -1
- package/src/plugins/defaults/memory/substrate/static-context.ts +160 -4
- package/src/plugins/defaults/memory/substrate/sweep-job.ts +2 -4
- package/src/plugins/defaults/memory/v1/graph/extraction.ts +3 -1
- package/src/plugins/defaults/memory/v3/prune.ts +2 -0
- package/src/plugins/defaults/memory/v3/selection-log-store.ts +2 -0
- package/src/plugins/defaults/memory/worker.ts +6 -3
- package/src/plugins/external-plugin-loader.ts +47 -0
- package/src/plugins/mcp-servers.ts +361 -0
- package/src/plugins/mtime-cache.ts +23 -49
- package/src/plugins/worker-plugin-surface.ts +33 -0
- package/src/providers/__tests__/connection-model-compat.test.ts +1 -1
- package/src/providers/__tests__/dispatch-connection-routing.test.ts +214 -2
- package/src/providers/__tests__/preflight-resolved-config.test.ts +57 -0
- package/src/providers/__tests__/retry-callsite.test.ts +5 -2
- package/src/providers/call-site-routing.ts +30 -3
- package/src/providers/connection-resolution.ts +194 -11
- package/src/providers/inference/auth.ts +6 -0
- package/src/providers/inference/connection-availability.ts +24 -2
- package/src/providers/inference/connections.ts +2 -0
- package/src/providers/model-catalog.ts +3 -3
- package/src/providers/model-intents.ts +28 -8
- package/src/providers/openai/chat-completions-provider.ts +5 -6
- package/src/providers/openai/codex-models.ts +2 -1
- package/src/providers/provider-send-message.ts +32 -3
- package/src/providers/speech-to-text/__tests__/deepgram-flux-frames.test.ts +433 -0
- package/src/providers/speech-to-text/__tests__/deepgram-flux-realtime.test.ts +620 -0
- package/src/providers/speech-to-text/__tests__/provider-catalog.test.ts +34 -0
- package/src/providers/speech-to-text/__tests__/resolve.test.ts +285 -6
- package/src/providers/speech-to-text/deepgram-flux-frames.ts +395 -0
- package/src/providers/speech-to-text/deepgram-flux-realtime.ts +719 -0
- package/src/providers/speech-to-text/provider-catalog.ts +99 -8
- package/src/providers/speech-to-text/resolve.ts +25 -2
- package/src/routes/worker.ts +17 -5
- package/src/runtime/access-request-helper.ts +9 -12
- package/src/runtime/agent-wake.ts +3 -3
- package/src/runtime/pre-first-message-gate.ts +4 -0
- package/src/runtime/routes/__tests__/acp-claude-auth-routes.test.ts +12 -4
- package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +219 -1
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +52 -0
- package/src/runtime/routes/__tests__/default-provider-routes.test.ts +61 -0
- package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +44 -0
- package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +102 -1
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +44 -0
- package/src/runtime/routes/__tests__/stt-routes.test.ts +25 -0
- package/src/runtime/routes/__tests__/user-route-dispatcher.test.ts +62 -1
- package/src/runtime/routes/channel-availability-routes.ts +32 -14
- package/src/runtime/routes/channel-route-shared.ts +0 -6
- package/src/runtime/routes/chatgpt-subscription-auth-routes.ts +6 -6
- package/src/runtime/routes/conversation-list-routes.ts +112 -1
- package/src/runtime/routes/conversation-query-routes.ts +40 -27
- package/src/runtime/routes/credential-prompt-routes.ts +4 -7
- package/src/runtime/routes/default-provider-routes.ts +15 -0
- package/src/runtime/routes/inbound-message-handler.ts +17 -41
- package/src/runtime/routes/inbound-stages/acl-enforcement.test.ts +0 -1
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +0 -9
- package/src/runtime/routes/inbound-stages/admission-policy.ts +1 -17
- package/src/runtime/routes/inbound-stages/bootstrap-intercept.test.ts +0 -1
- package/src/runtime/routes/inbound-stages/bootstrap-intercept.ts +2 -3
- package/src/runtime/routes/inbound-stages/edit-intercept.ts +1 -3
- package/src/runtime/routes/inbound-stages/guardian-reply-intercept.test.ts +0 -1
- package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +3 -4
- package/src/runtime/routes/inbound-stages/reaction-intercept.test.ts +0 -1
- package/src/runtime/routes/inbound-stages/reaction-intercept.ts +11 -20
- package/src/runtime/routes/inbound-stages/secret-ingress-check.ts +2 -3
- package/src/runtime/routes/inference-profiles-routes.ts +20 -11
- package/src/runtime/routes/inference-provider-connection-routes.ts +77 -15
- package/src/runtime/routes/log-export-routes.ts +3 -0
- package/src/runtime/routes/mcp-auth-routes.ts +148 -57
- package/src/runtime/routes/plugins-routes.ts +21 -3
- package/src/runtime/routes/stt-routes.ts +31 -25
- package/src/runtime/routes/surface-conversation-resolver.ts +3 -0
- package/src/runtime/routes/user-route-dispatcher.ts +39 -14
- package/src/runtime/routes/user-route-import.ts +108 -0
- package/src/schedule/worker.ts +6 -0
- package/src/security/oauth2.ts +6 -22
- package/src/stt/__tests__/daemon-batch-transcriber.test.ts +22 -0
- package/src/stt/__tests__/types.test.ts +94 -0
- package/src/stt/daemon-batch-transcriber.ts +10 -0
- package/src/stt/stt-stream-session.ts +8 -4
- package/src/stt/types.ts +103 -0
- package/src/subagent/manager.ts +1 -3
- package/src/subagent/types.ts +7 -6
- package/src/tools/acp/spawn.test.ts +97 -0
- package/src/tools/acp/spawn.ts +32 -0
- package/src/tools/document/document-tool.ts +12 -3
- package/src/tools/registry.ts +2 -1
- package/src/tools/ui-surface/surface-shape-docs.ts +11 -0
- package/src/tools/workflows/run-workflow.ts +1 -2
- package/src/tts/__tests__/reasoning-tag-filter.test.ts +78 -0
- package/src/tts/reasoning-tag-filter.ts +89 -0
- package/src/util/think-tag-stream.ts +95 -0
- package/src/workspace/byok-default-profile-ensure.ts +76 -24
- package/src/workspace/custom-profile-ensure.ts +4 -24
- package/src/workspace/migrations/142-consolidate-voice-front-door.ts +70 -0
- package/src/workspace/migrations/143-repair-deprecated-codex-model-id.ts +134 -0
- package/src/workspace/migrations/144-convert-stranded-subscription-openai-profiles.ts +265 -0
- package/src/workspace/migrations/145-collapse-profile-bindings-to-entries.ts +328 -0
- package/src/workspace/migrations/146-repair-retired-fireworks-deepseek-flash-model-id.ts +195 -0
- package/src/workspace/migrations/__tests__/141-stt-english-default-to-multilingual.test.ts +0 -10
- package/src/workspace/migrations/registry.ts +10 -0
- package/src/workspace/provider-commit-message-generator.ts +7 -5
- package/src/live-voice/__tests__/front-decision.test.ts +0 -645
- 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
|
+
}
|