@vellumai/assistant 0.10.4 → 0.10.5-dev.202607022329.d89bf2f
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 +94 -91
- package/Dockerfile +5 -16
- package/README.md +7 -10
- package/bun.lock +0 -4
- package/docs/architecture/security.md +20 -20
- package/docs/stt-provider-onboarding.md +4 -5
- package/docs/trusted-contact-access.md +7 -7
- package/eslint-rules/cli-no-daemon-internals.js +4 -0
- package/eslint.config.mjs +7 -1
- package/knip.json +0 -1
- package/node_modules/@vellumai/gateway-client/src/__tests__/contact-read-contracts.test.ts +36 -0
- package/node_modules/@vellumai/gateway-client/src/__tests__/invite-contract.test.ts +233 -0
- package/node_modules/@vellumai/gateway-client/src/__tests__/trust-verdict-contract.test.ts +40 -0
- package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +20 -8
- package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +10 -0
- package/node_modules/@vellumai/gateway-client/src/index.ts +33 -0
- package/node_modules/@vellumai/gateway-client/src/invite-contract.ts +216 -0
- package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +91 -0
- package/node_modules/@vellumai/gateway-client/src/trust-verdict-contract.ts +39 -3
- package/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +0 -8
- package/node_modules/@vellumai/service-contracts/src/__tests__/package-boundary.test.ts +1 -5
- package/node_modules/@vellumai/service-contracts/src/twilio-ingress.ts +3 -14
- package/openapi.yaml +1073 -225
- package/package.json +1 -4
- package/scripts/test.sh +35 -13
- package/src/__tests__/access-request-seed-content-blocks.test.ts +60 -4
- package/src/__tests__/acp-session.test.ts +4 -1
- package/src/__tests__/actor-trust-resolver-address-fallback.test.ts +0 -6
- package/src/__tests__/add-message-preassigned-id.test.ts +56 -0
- package/src/__tests__/agent-loop-set-system-prompt.test.ts +139 -0
- package/src/__tests__/app-conversation-ids-backfill.test.ts +3 -0
- package/src/__tests__/app-source-watcher.test.ts +11 -0
- package/src/__tests__/approval-interception-trust-gates.test.ts +148 -3
- package/src/__tests__/assistant-attachments.test.ts +62 -4
- package/src/__tests__/assistant-id-boundary-guard.test.ts +0 -1
- package/src/__tests__/attachments-store-heic-normalize.test.ts +230 -0
- package/src/__tests__/attachments-store.test.ts +3 -0
- package/src/__tests__/background-shell-bash.test.ts +34 -0
- package/src/__tests__/background-shell-host-bash.test.ts +12 -0
- package/src/__tests__/background-tool-registry.test.ts +97 -0
- package/src/__tests__/background-tool-routes.test.ts +72 -1
- package/src/__tests__/background-workers-disk-pressure.test.ts +2 -1
- package/src/__tests__/btw-routes.test.ts +57 -3
- package/src/__tests__/call-controller.test.ts +474 -84
- package/src/__tests__/call-domain.test.ts +48 -1
- package/src/__tests__/call-pointer-messages.test.ts +57 -19
- package/src/__tests__/call-pointer-no-hardcoded-copy.guard.test.ts +2 -6
- package/src/__tests__/call-routes-http.test.ts +15 -14
- package/src/__tests__/call-setup-flow-invite.test.ts +568 -0
- package/src/__tests__/call-setup-flow-midcall-trust.test.ts +231 -0
- package/src/__tests__/call-setup-flow-name-capture.test.ts +754 -0
- package/src/__tests__/call-setup-flow-verification.test.ts +925 -0
- package/src/__tests__/call-setup-flow.test.ts +311 -0
- package/src/__tests__/cancel-resolves-conversation-key.test.ts +0 -1
- package/src/__tests__/channel-guardian.test.ts +4 -7
- package/src/__tests__/channel-readiness-service.test.ts +0 -6
- package/src/__tests__/channel-readiness-slack-remote.test.ts +0 -4
- package/src/__tests__/channel-retry-sweep.test.ts +76 -64
- package/src/__tests__/channel-setup-panel-ack.test.ts +184 -0
- package/src/__tests__/clear-all-lexical.test.ts +90 -0
- package/src/__tests__/compactor-media-strip.test.ts +177 -0
- package/src/__tests__/config-loader-backfill.test.ts +5 -5
- package/src/__tests__/config-schema.test.ts +17 -0
- package/src/__tests__/contact-store-interaction-info.test.ts +27 -70
- package/src/__tests__/contacts-relay-reads.test.ts +188 -23
- package/src/__tests__/contacts-tools.test.ts +42 -1
- package/src/__tests__/contacts-write.test.ts +181 -0
- package/src/__tests__/context-search-conversations-source.test.ts +220 -81
- package/src/__tests__/conversation-agent-loop-disk-pressure.test.ts +1 -0
- package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -0
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +1 -0
- package/src/__tests__/conversation-agent-loop.test.ts +39 -0
- package/src/__tests__/conversation-analysis-routes.test.ts +1 -1
- package/src/__tests__/conversation-app-control-instantiation.test.ts +2 -0
- package/src/__tests__/conversation-clear-safety.test.ts +0 -1
- package/src/__tests__/conversation-crud-enabled-plugins.test.ts +60 -0
- package/src/__tests__/conversation-delete-schedule-cleanup.test.ts +0 -1
- package/src/__tests__/conversation-disk-view.test.ts +1 -0
- package/src/__tests__/conversation-enabledplugins-route.test.ts +184 -0
- package/src/__tests__/conversation-error.test.ts +0 -18
- package/src/__tests__/conversation-fork-crud.test.ts +67 -0
- package/src/__tests__/conversation-history-web-search.test.ts +7 -137
- package/src/__tests__/conversation-initial-prompt.test.ts +110 -0
- package/src/__tests__/conversation-loop-db-lock-resilience.test.ts +191 -0
- package/src/__tests__/conversation-process-callsite.test.ts +37 -0
- package/src/__tests__/conversation-queue.test.ts +149 -0
- package/src/__tests__/conversation-routes-enabled-plugins.test.ts +392 -0
- package/src/__tests__/conversation-routes-guardian-reply.test.ts +84 -0
- package/src/__tests__/conversation-routes-hidden-queue.test.ts +407 -0
- package/src/__tests__/conversation-runtime-assembly.test.ts +36 -16
- package/src/__tests__/conversation-skill-tools.test.ts +73 -0
- package/src/__tests__/conversation-wipe.test.ts +20 -5
- package/src/__tests__/credential-prompt-route.test.ts +69 -0
- package/src/__tests__/credential-security-invariants.test.ts +1 -3
- package/src/__tests__/db-drop-messages-fts.test.ts +115 -0
- package/src/__tests__/deterministic-verification-control-plane.test.ts +1 -62
- package/src/__tests__/edit-propagation.test.ts +56 -2
- package/src/__tests__/empty-state-greeting-cache.test.ts +9 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +15 -110
- package/src/__tests__/guardian-expiry-notifier.test.ts +1 -0
- package/src/__tests__/guardian-question-mode.test.ts +57 -0
- package/src/__tests__/guardian-verification-voice-binding.test.ts +1 -1
- package/src/__tests__/guardian-wait-controller.test.ts +619 -0
- package/src/__tests__/handlers-skills-memory-v2-reseed.test.ts +33 -2
- package/src/__tests__/heic-fixture.ts +55 -0
- package/src/__tests__/helpers/seed-contact-channel.ts +0 -2
- package/src/__tests__/http-conversation-lineage.test.ts +6 -1
- package/src/__tests__/image-conversion.test.ts +170 -0
- package/src/__tests__/inbound-invite-redemption.test.ts +72 -341
- package/src/__tests__/introduction-card-resolver.test.ts +354 -0
- package/src/__tests__/invite-redeemed-ipc.test.ts +97 -0
- package/src/__tests__/invite-routes-http.test.ts +246 -420
- package/src/__tests__/job-handler-registration-guard.test.ts +104 -0
- package/src/__tests__/lexical-index-dual-write.test.ts +426 -0
- package/src/__tests__/lifecycle-memory-v2-seed.test.ts +2 -2
- package/src/__tests__/list-messages-attachments.test.ts +159 -0
- package/src/__tests__/list-messages-background-tool-completion.test.ts +141 -0
- package/src/__tests__/list-messages-queued.test.ts +25 -0
- package/src/__tests__/managed-store.test.ts +129 -1
- package/src/__tests__/media-stream-audio-transcode.test.ts +113 -0
- package/src/__tests__/media-stream-output.test.ts +350 -19
- package/src/__tests__/media-stream-server-integration.test.ts +943 -169
- package/src/__tests__/media-stream-stt-session.test.ts +498 -1
- package/src/__tests__/memory-jobs-worker-lanes.test.ts +2 -2
- package/src/__tests__/model-intents.test.ts +1 -1
- package/src/__tests__/mtime-cache.test.ts +19 -0
- package/src/__tests__/non-member-access-request.test.ts +349 -3
- package/src/__tests__/notification-decision-fallback.test.ts +104 -8
- package/src/__tests__/notification-decision-strategy.test.ts +57 -8
- package/src/__tests__/onboarding-template-contract.test.ts +27 -43
- package/src/__tests__/openai-provider.test.ts +33 -0
- package/src/__tests__/openai-responses-provider.test.ts +40 -0
- package/src/__tests__/persistence-layering-guard.test.ts +29 -20
- package/src/__tests__/persona-resolver.test.ts +56 -2
- package/src/__tests__/personal-memory-auth-bypass.test.ts +62 -0
- package/src/__tests__/phone.test.ts +1 -15
- package/src/__tests__/plugin-bootstrap.test.ts +25 -196
- package/src/__tests__/plugin-disabled-state.test.ts +33 -0
- package/src/__tests__/plugin-effective-enabled-set.test.ts +227 -0
- package/src/__tests__/plugin-event-hub-facade.test.ts +323 -0
- package/src/__tests__/plugin-import-boundary-guard.test.ts +156 -42
- package/src/__tests__/plugin-injector-contribution.test.ts +4 -20
- package/src/__tests__/plugin-pipeline.test.ts +63 -1
- package/src/__tests__/plugin-route-contribution.test.ts +62 -115
- package/src/__tests__/plugin-tool-contribution.test.ts +0 -22
- package/src/__tests__/plugin-types.test.ts +0 -1
- package/src/__tests__/pointer-conversation-trust.test.ts +38 -6
- package/src/__tests__/pre-model-call-sanitize.test.ts +1 -1
- package/src/__tests__/prune-old-tool-invocations-job.test.ts +97 -0
- package/src/__tests__/public-ingress-urls.test.ts +0 -48
- package/src/__tests__/qdrant-collection-migration.test.ts +145 -2
- package/src/__tests__/reaction-intercept-member-verdict-warm.test.ts +0 -1
- package/src/__tests__/resolve-trust-class.test.ts +28 -10
- package/src/__tests__/scaffold-managed-skill-tool.test.ts +137 -0
- package/src/__tests__/schedule-retry.test.ts +38 -14
- package/src/__tests__/schedule-routes-workflow-validation.test.ts +3 -3
- package/src/__tests__/schedule-routes.test.ts +14 -6
- package/src/__tests__/schedule-store.test.ts +3 -1
- package/src/__tests__/scheduler-disk-pressure.test.ts +17 -6
- package/src/__tests__/scheduler-recurrence.test.ts +57 -107
- package/src/__tests__/scheduler-reuse-conversation.test.ts +51 -46
- package/src/__tests__/scheduler-wake.test.ts +13 -23
- package/src/__tests__/scheduler-worker-standdown.test.ts +220 -0
- package/src/__tests__/secret-routes-platform-proxy.test.ts +1 -1
- package/src/__tests__/shell-observability.test.ts +1 -0
- package/src/__tests__/skill-boundary-guard.test.ts +3 -4
- package/src/__tests__/skill-load-plugin-scope.test.ts +238 -0
- package/src/__tests__/skill-projection-feature-flag.test.ts +2 -0
- package/src/__tests__/skill-script-runner-host.test.ts +53 -0
- package/src/__tests__/skill-script-runner-sandbox.test.ts +5 -2
- package/src/__tests__/skill-tool-factory.test.ts +73 -1
- package/src/__tests__/slack-app-setup-skill-regression.test.ts +37 -14
- package/src/__tests__/slack-inbound-verification.test.ts +36 -0
- package/src/__tests__/slack-notification-approval-card.test.ts +89 -2
- package/src/__tests__/slack-reaction-canonical-approval.test.ts +51 -1
- package/src/__tests__/stt-hints.test.ts +2 -256
- package/src/__tests__/subagent-allowlist-validation.test.ts +47 -0
- package/src/__tests__/subagent-call-site-routing.test.ts +72 -0
- package/src/__tests__/subagent-fork-notifications.test.ts +14 -2
- package/src/__tests__/subagent-persistence.test.ts +122 -0
- package/src/__tests__/subagent-spawn-and-await.test.ts +1 -0
- package/src/__tests__/subagent-terminal-message.test.ts +99 -0
- package/src/__tests__/subagent-tool-gate-mode.test.ts +78 -1
- package/src/__tests__/system-prompt.test.ts +17 -118
- package/src/__tests__/task-scheduler.test.ts +68 -89
- package/src/__tests__/telephony-credential-preflight.test.ts +312 -0
- package/src/__tests__/telephony-tts-guard.test.ts +538 -0
- package/src/__tests__/title-generate-hook.test.ts +16 -0
- package/src/__tests__/tool-approval-handler.test.ts +87 -1
- package/src/__tests__/tool-approval-seed-content-blocks.test.ts +45 -20
- package/src/__tests__/tool-grant-request-escalation.test.ts +39 -0
- package/src/__tests__/trust-context-guards.test.ts +21 -10
- package/src/__tests__/trusted-contact-multichannel.test.ts +4 -0
- package/src/__tests__/twilio-provider.test.ts +17 -17
- package/src/__tests__/twilio-routes-twiml.test.ts +6 -543
- package/src/__tests__/twilio-routes.test.ts +257 -387
- package/src/__tests__/twilio-validation.test.ts +1 -1
- package/src/__tests__/unified-turn-context-client-os.test.ts +45 -0
- package/src/__tests__/usage-cache-backfill-migration.test.ts +5 -0
- package/src/__tests__/voice-approval-resolver-relay.test.ts +150 -0
- package/src/__tests__/voice-ingress-preflight.test.ts +61 -0
- package/src/__tests__/voice-quality.test.ts +2 -2
- package/src/__tests__/voice-scoped-grant-consumer.test.ts +1 -7
- package/src/__tests__/voice-session-bridge-processing-wait.test.ts +27 -0
- package/src/__tests__/voice-session-bridge.test.ts +1 -10
- package/src/__tests__/workspace-git-service.test.ts +1 -1
- package/src/__tests__/workspace-migration-013-repair-conversation-disk-view.test.ts +1 -0
- package/src/__tests__/workspace-migration-118-seed-now-md.test.ts +120 -0
- package/src/__tests__/workspace-migration-120-revise-onboarding-threads.test.ts +175 -0
- package/src/__tests__/workspace-migration-121-seed-default-user-guardrails.test.ts +118 -0
- package/src/__tests__/workspace-migration-122-relocate-default-user-boundary.test.ts +119 -0
- package/src/__tests__/workspace-migration-123-swap-quality-profile-to-fable.test.ts +191 -0
- package/src/__tests__/workspace-migration-backfill-installation-id.test.ts +235 -241
- package/src/acp/__tests__/prepare-agent-env.test.ts +0 -97
- package/src/acp/__tests__/session-manager-persistence.test.ts +2 -0
- package/src/acp/__tests__/session-manager-resume.test.ts +8 -0
- package/src/acp/__tests__/session-manager-usage.test.ts +6 -0
- package/src/acp/__tests__/session-manager.test.ts +6 -0
- package/src/acp/agent-process.test.ts +116 -0
- package/src/acp/agent-process.ts +71 -0
- package/src/acp/auto-install.ts +12 -8
- package/src/acp/failure-error.test.ts +109 -0
- package/src/acp/failure-error.ts +111 -0
- package/src/acp/prepare-agent-env.ts +0 -12
- package/src/acp/resolve-agent.test.ts +6 -45
- package/src/acp/resolve-agent.ts +2 -4
- package/src/acp/session-manager.test.ts +312 -0
- package/src/acp/session-manager.ts +102 -46
- package/src/agent/ax-tree-compaction.test.ts +4 -1
- package/src/agent/image-optimize.ts +17 -127
- package/src/agent/loop.ts +45 -192
- package/src/api/events/background-tool-completed.test.ts +51 -0
- package/src/api/events/background-tool-completed.ts +29 -0
- package/src/api/events/background-tool-started.test.ts +36 -0
- package/src/api/events/background-tool-started.ts +28 -0
- package/src/api/events/conversation-error.ts +0 -1
- package/src/api/events/open-panel.ts +33 -0
- package/src/api/events/subagent-status-changed.ts +4 -1
- package/src/api/index.ts +19 -0
- package/src/api/responses/conversation-message.ts +34 -0
- package/src/approvals/guardian-decision-primitive.ts +74 -24
- package/src/approvals/guardian-request-resolvers.ts +450 -122
- package/src/apps/app-store.ts +1 -0
- package/src/apps/shared-app-links-store.ts +1 -0
- package/src/avatar/identity-avatar.ts +3 -7
- package/src/background-wake/wake-intent-hooks.test.ts +1 -1
- package/src/calls/__tests__/fish-audio-client.test.ts +73 -0
- package/src/calls/__tests__/gateway-invite-reader.test.ts +170 -0
- package/src/calls/__tests__/relay-setup-router.test.ts +126 -102
- package/src/calls/call-controller.ts +134 -48
- package/src/calls/call-domain.ts +18 -42
- package/src/calls/call-pointer-messages.ts +51 -67
- package/src/calls/call-setup-flow-types.ts +76 -0
- package/src/calls/call-setup-flow.ts +1743 -0
- package/src/calls/call-speech-output.ts +87 -23
- package/src/calls/call-store.ts +4 -0
- package/src/calls/call-transport.ts +22 -36
- package/src/calls/fish-audio-client.ts +3 -0
- package/src/calls/gateway-invite-reader.ts +98 -0
- package/src/calls/guardian-wait-controller.ts +451 -0
- package/src/calls/media-stream-audio-transcode.ts +82 -2
- package/src/calls/media-stream-output.ts +142 -36
- package/src/calls/media-stream-server.ts +297 -178
- package/src/calls/media-stream-stt-session.ts +393 -50
- package/src/calls/media-turn-detector.ts +4 -6
- package/src/calls/relay-access-wait.ts +3 -4
- package/src/calls/relay-setup-router.ts +22 -43
- package/src/calls/relay-verification.ts +17 -24
- package/src/calls/resolve-call-tts-provider.ts +96 -29
- package/src/calls/stt-hints.ts +24 -87
- package/src/calls/telephony-credential-preflight.ts +159 -0
- package/src/calls/telephony-tts-capability.ts +183 -0
- package/src/calls/tts-call-strategy.ts +18 -20
- package/src/calls/twilio-provider.ts +3 -2
- package/src/calls/twilio-routes.ts +47 -351
- package/src/calls/types.ts +5 -3
- package/src/calls/voice-ingress-preflight.ts +9 -0
- package/src/calls/voice-quality.ts +13 -18
- package/src/calls/voice-session-bridge.ts +25 -35
- package/src/channels/__tests__/types.test.ts +28 -0
- package/src/channels/channel-verification-sessions.ts +7 -2
- package/src/channels/config.ts +0 -42
- package/src/channels/slack-thread-store.ts +12 -0
- package/src/channels/types.ts +23 -0
- package/src/cli/commands/__tests__/conversations-slack.test.ts +1 -1
- package/src/cli/commands/__tests__/schedules.test.ts +83 -34
- package/src/cli/commands/__tests__/status.test.ts +42 -2
- package/src/cli/commands/contacts.ts +2 -2
- package/src/cli/commands/credentials.ts +17 -0
- package/src/cli/commands/monitoring.ts +191 -0
- package/src/cli/commands/schedules-worker.ts +175 -0
- package/src/cli/commands/schedules.ts +169 -43
- package/src/cli/commands/status.ts +12 -1
- package/src/cli/commands/tools.ts +5 -1
- package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +1 -0
- package/src/cli/lib/__tests__/search-plugins.test.ts +29 -1
- package/src/cli/lib/__tests__/toggle-plugin.test.ts +7 -0
- package/src/cli/lib/search-plugins.ts +6 -0
- package/src/cli/lib/toggle-plugin.ts +21 -2
- package/src/cli/program.ts +3 -1
- package/src/config/__tests__/deployment-context-defaults.test.ts +23 -0
- package/src/config/acp-defaults.test.ts +0 -10
- package/src/config/acp-defaults.ts +0 -6
- package/src/config/bundled-skills/acp/SKILL.md +13 -45
- package/src/config/bundled-skills/acp/TOOLS.json +3 -3
- package/src/config/bundled-skills/app-builder/SKILL.md +3 -3
- package/src/config/bundled-skills/app-builder/references/RESPONSIVE.md +9 -9
- package/src/config/bundled-skills/contacts/tools/contact-merge.ts +1 -1
- package/src/config/bundled-skills/contacts/tools/contact-search.ts +1 -1
- package/src/config/bundled-skills/phone-calls/SKILL.md +1 -1
- package/src/config/bundled-skills/phone-calls/references/CONFIG.md +9 -9
- package/src/config/bundled-skills/phone-calls/references/TROUBLESHOOTING.md +10 -7
- package/src/config/bundled-skills/skill-management/TOOLS.json +10 -0
- package/src/config/call-site-defaults.ts +0 -2
- package/src/config/feature-flag-registry.json +16 -8
- package/src/config/loader.ts +11 -0
- package/src/config/schema.ts +9 -1
- package/src/config/schemas/__tests__/memory-v2.test.ts +2 -2
- package/src/config/schemas/__tests__/memory-v3.test.ts +64 -0
- package/src/config/schemas/call-site-catalog.ts +0 -12
- package/src/config/schemas/calls.ts +8 -0
- package/src/config/schemas/elevenlabs.ts +1 -1
- package/src/config/schemas/llm.ts +0 -2
- package/src/config/schemas/memory-v2.ts +1 -1
- package/src/config/schemas/memory-v3.ts +82 -0
- package/src/config/schemas/monitoring.ts +61 -0
- package/src/config/schemas/schedules.ts +35 -0
- package/src/config/seed-inference-profiles.ts +2 -2
- package/src/config/skills-plugin-filter.test.ts +96 -0
- package/src/config/skills.ts +26 -0
- package/src/contacts/__tests__/member-write-relay.test.ts +156 -4
- package/src/contacts/canonical-guardian-store.ts +5 -0
- package/src/contacts/contact-store.ts +81 -35
- package/src/contacts/contacts-write.ts +50 -25
- package/src/contacts/member-write-relay.ts +115 -31
- package/src/contacts/types.ts +0 -8
- package/src/context/compactor.ts +29 -15
- package/src/context/outbound-sanitize.ts +202 -0
- package/src/daemon/__tests__/abort-null-controller.test.ts +167 -0
- package/src/daemon/__tests__/conversation-tool-setup-plugin-scope.test.ts +122 -0
- package/src/daemon/__tests__/embedding-reconcile.test.ts +99 -16
- package/src/daemon/__tests__/wake-conversation-ops-completion.test.ts +107 -0
- package/src/daemon/abort-watchdog.ts +7 -0
- package/src/daemon/app-source-watcher.ts +9 -0
- package/src/daemon/assistant-attachments.ts +42 -0
- package/src/daemon/conversation-agent-loop-handlers.ts +256 -147
- package/src/daemon/conversation-agent-loop.ts +61 -68
- package/src/daemon/conversation-error.ts +1 -15
- package/src/daemon/conversation-history.ts +32 -236
- package/src/daemon/conversation-initial-prompt.ts +77 -0
- package/src/daemon/conversation-lifecycle.ts +25 -1
- package/src/daemon/conversation-messaging.ts +2 -2
- package/src/daemon/conversation-plugin-scope.ts +38 -0
- package/src/daemon/conversation-process.ts +50 -14
- package/src/daemon/conversation-runtime-assembly.ts +36 -6
- package/src/daemon/conversation-skill-tools.ts +19 -3
- package/src/daemon/conversation-store.ts +3 -3
- package/src/daemon/conversation-surfaces.ts +171 -0
- package/src/daemon/conversation-tool-setup.test.ts +95 -0
- package/src/daemon/conversation-tool-setup.ts +116 -1
- package/src/daemon/conversation-turn-finalize.ts +211 -0
- package/src/daemon/conversation.ts +92 -1
- package/src/daemon/disk-pressure-policy.ts +2 -1
- package/src/daemon/event-loop-watchdog.ts +22 -6
- package/src/daemon/external-plugins-bootstrap.ts +47 -134
- package/src/daemon/handlers/__tests__/config-a2a-accept.test.ts +1 -1
- package/src/daemon/handlers/__tests__/config-a2a-complete.test.ts +26 -14
- package/src/daemon/handlers/__tests__/config-a2a-invite.test.ts +27 -14
- package/src/daemon/handlers/__tests__/config-channels.test.ts +0 -5
- package/src/daemon/handlers/config-a2a.ts +7 -14
- package/src/daemon/handlers/config-channels.ts +2 -10
- package/src/daemon/handlers/conversation-history.ts +3 -1
- package/src/daemon/handlers/conversations.ts +0 -52
- package/src/daemon/handlers/shared.ts +4 -24
- package/src/daemon/handlers/skills.ts +4 -1
- package/src/daemon/host-cu-proxy.ts +1 -1
- package/src/daemon/lifecycle.ts +59 -472
- package/src/daemon/message-protocol.ts +3 -3
- package/src/daemon/message-provenance.ts +12 -14
- package/src/daemon/message-types/background-tools.ts +28 -0
- package/src/daemon/message-types/contacts.ts +2 -10
- package/src/daemon/message-types/conversations.ts +10 -0
- package/src/daemon/message-types/integrations.ts +2 -0
- package/src/daemon/message-types/surfaces.ts +1 -0
- package/src/daemon/message-types/sync.ts +1 -0
- package/src/daemon/pointer-conversation-trust.ts +23 -9
- package/src/daemon/pointer-turn-runner.ts +174 -0
- package/src/daemon/providers-setup.ts +16 -0
- package/src/daemon/shutdown-handlers.ts +139 -47
- package/src/daemon/skill-memory-refresh.ts +2 -2
- package/src/daemon/sqlite-corruption-watchdog.test.ts +217 -0
- package/src/daemon/sqlite-corruption-watchdog.ts +155 -0
- package/src/daemon/tool-setup-types.ts +8 -0
- package/src/daemon/trust-context.ts +21 -6
- package/src/daemon/turn-latency-tracker.test.ts +93 -0
- package/src/daemon/turn-latency-tracker.ts +38 -0
- package/src/daemon/wake-conversation-ops.ts +10 -8
- package/src/documents/document-comments-store.test.ts +7 -1
- package/src/documents/document-comments-store.ts +8 -0
- package/src/documents/document-store.ts +15 -0
- package/src/export/__tests__/transcript-formatter.test.ts +11 -0
- package/src/export/transcript-formatter.ts +49 -67
- package/src/home/__tests__/feed-writer.test.ts +247 -1
- package/src/home/conversation-starter-checkpoints.ts +1 -0
- package/src/home/feed-writer.ts +61 -0
- package/src/home/job-handlers/conversation-starters.ts +1 -0
- package/src/hooks/hook-loader.ts +12 -0
- package/src/hooks/registry.ts +32 -4
- package/src/inbound/public-ingress-urls.ts +0 -17
- package/src/ipc/__tests__/contacts-info-ipc.test.ts +285 -0
- package/src/ipc/assistant-server.ts +28 -7
- package/src/ipc/routes/__tests__/contacts-mirror-apply-atomicity.test.ts +62 -0
- package/src/ipc/routes/__tests__/contacts-mirror-apply.test.ts +272 -0
- package/src/ipc/routes/__tests__/contacts-mirror-ipc-routes.test.ts +322 -0
- package/src/ipc/routes/__tests__/invite-ipc-routes.test.ts +60 -4
- package/src/ipc/routes/contacts-info-ipc-routes.ts +239 -0
- package/src/ipc/routes/contacts-mirror-ipc-routes.ts +202 -0
- package/src/ipc/routes/db-proxy.ts +5 -2
- package/src/ipc/routes/invite-ipc-routes.ts +53 -39
- package/src/jobs/worker.ts +9 -2
- package/src/live-voice/__tests__/live-voice-archive.test.ts +10 -1
- package/src/live-voice/live-voice-archive.ts +5 -0
- package/src/messaging/providers/__tests__/transport-dispatch.test.ts +22 -6
- package/src/messaging/providers/channel-transport.ts +6 -0
- package/src/messaging/providers/index.ts +3 -0
- package/src/messaging/providers/slack/api.test.ts +49 -1
- package/src/messaging/providers/slack/api.ts +110 -0
- package/src/messaging/providers/slack/send.test.ts +85 -0
- package/src/messaging/providers/slack/send.ts +100 -28
- package/src/messaging/providers/slack/transport.ts +7 -13
- package/src/messaging/providers/whatsapp/send.ts +29 -9
- package/src/monitoring/__tests__/control.test.ts +235 -0
- package/src/monitoring/__tests__/sample-ring-buffer.test.ts +96 -0
- package/src/monitoring/control.ts +105 -0
- package/src/monitoring/resource-sampler.ts +258 -0
- package/src/monitoring/sample-ring-buffer.ts +108 -0
- package/src/monitoring/worker.ts +94 -0
- package/src/notifications/README.md +1 -1
- package/src/notifications/access-request-copy.ts +94 -23
- package/src/notifications/adapters/slack.ts +89 -21
- package/src/notifications/approval-card-builder.ts +33 -18
- package/src/notifications/approval-card-data.ts +48 -15
- package/src/notifications/broadcaster.ts +25 -16
- package/src/notifications/decision-engine.ts +131 -32
- package/src/notifications/guardian-question-mode.ts +58 -10
- package/src/permissions/approval-policy.ts +8 -1
- package/src/permissions/checker.ts +2 -0
- package/src/permissions/gateway-threshold-reader.ts +7 -6
- package/src/permissions/ipc-risk-types.ts +1 -0
- package/src/permissions/secret-prompter.ts +11 -3
- package/src/permissions/types.ts +2 -2
- package/src/persistence/__tests__/conversation-queries-search.test.ts +397 -0
- package/src/persistence/__tests__/conversation-search-lexical.test.ts +103 -0
- package/src/persistence/__tests__/jobs-worker-memory-disabled.test.ts +95 -0
- package/src/persistence/__tests__/slow-query-log.test.ts +266 -0
- package/src/persistence/__tests__/slow-sync-log.test.ts +155 -0
- package/src/persistence/a2a-invite-store.ts +205 -0
- package/src/persistence/attachments-store.ts +94 -10
- package/src/persistence/checkpoints.ts +45 -0
- package/src/persistence/conversation-crud.ts +453 -260
- package/src/persistence/conversation-display-order-migration.ts +8 -2
- package/src/persistence/conversation-group-migration.test.ts +8 -0
- package/src/persistence/conversation-group-migration.ts +9 -0
- package/src/persistence/conversation-queries.ts +244 -125
- package/src/persistence/conversation-row-batch-delete.ts +9 -5
- package/src/persistence/conversation-search-lexical.ts +49 -0
- package/src/persistence/db-async-query.ts +42 -0
- package/src/persistence/db-connection.ts +13 -0
- package/src/persistence/delivery-channels.ts +0 -82
- package/src/persistence/delivery-crud.ts +27 -6
- package/src/persistence/embeddings/__tests__/embedding-identity.test.ts +18 -5
- package/src/persistence/embeddings/__tests__/messages-lexical-index.test.ts +334 -0
- package/src/persistence/embeddings/embedding-identity.ts +17 -5
- package/src/persistence/embeddings/embedding-runtime-manager.ts +31 -0
- package/src/persistence/embeddings/messages-lexical-index.ts +396 -0
- package/src/persistence/embeddings/qdrant-client.ts +81 -5
- package/src/persistence/group-crud.ts +13 -1
- package/src/persistence/job-handlers/__tests__/message-lexical-backfill.test.ts +506 -0
- package/src/persistence/job-handlers/__tests__/message-lexical-enqueue.test.ts +164 -0
- package/src/persistence/job-handlers/__tests__/message-lexical.test.ts +259 -0
- package/src/persistence/job-handlers/cleanup.ts +57 -9
- package/src/persistence/job-handlers/message-lexical-backfill.ts +187 -0
- package/src/persistence/job-handlers/message-lexical.ts +251 -0
- package/src/persistence/jobs-store.ts +102 -8
- package/src/persistence/jobs-worker.ts +68 -28
- package/src/persistence/llm-request-log-store.ts +35 -19
- package/src/persistence/llm-usage-store.ts +11 -0
- package/src/persistence/memory-lifecycle-hooks.test.ts +65 -0
- package/src/persistence/memory-lifecycle-hooks.ts +55 -1
- package/src/persistence/migrations/028-call-session-mode.ts +1 -1
- package/src/persistence/migrations/116-messages-fts.ts +8 -1
- package/src/persistence/migrations/311-create-subagents-table.ts +50 -0
- package/src/persistence/migrations/312-drop-inbox-conversation-state-table.ts +15 -0
- package/src/persistence/migrations/313-drop-messages-fts.ts +17 -0
- package/src/persistence/migrations/314-add-conversation-enabled-plugins.ts +25 -0
- package/src/persistence/migrations/315-create-a2a-invites.ts +58 -0
- package/src/persistence/migrations/316-drop-contact-channels-invite-id.ts +28 -0
- package/src/persistence/migrations/317-canonical-guardian-requester-signals.ts +24 -0
- package/src/persistence/migrations/318-drop-contact-channel-telemetry.ts +32 -0
- package/src/persistence/migrations/__tests__/315-create-a2a-invites.test.ts +161 -0
- package/src/persistence/migrations/__tests__/316-drop-contact-channels-invite-id.test.ts +156 -0
- package/src/persistence/migrations/__tests__/318-drop-contact-channel-telemetry.test.ts +134 -0
- package/src/persistence/migrations/__tests__/migration-prefix-guard.test.ts +99 -0
- package/src/persistence/raw-query.ts +50 -11
- package/src/persistence/schema/a2a.ts +14 -0
- package/src/persistence/schema/contacts.ts +0 -62
- package/src/persistence/schema/conversation-groups.ts +12 -0
- package/src/persistence/schema/conversations.ts +2 -0
- package/src/persistence/schema/documents.ts +65 -0
- package/src/persistence/schema/guardian.ts +4 -0
- package/src/persistence/schema/index.ts +4 -0
- package/src/persistence/schema/memory-injection.ts +62 -0
- package/src/persistence/schema/workflows.ts +55 -0
- package/src/persistence/slow-query-log.ts +437 -0
- package/src/persistence/slow-sync-log.ts +204 -0
- package/src/persistence/steps.ts +16 -0
- package/src/persistence/subagent-store.ts +125 -0
- package/src/persistence/worker-control.ts +45 -238
- package/src/plugin-api/event-hub-facade.ts +172 -0
- package/src/plugin-api/index.ts +9 -1
- package/src/plugin-api/types.ts +40 -9
- package/src/plugins/defaults/default-plugin-names.ts +60 -0
- package/src/plugins/defaults/index.ts +19 -29
- package/src/plugins/defaults/memory/__tests__/conversation-queries.test.ts +46 -67
- package/src/plugins/defaults/memory/__tests__/db-async-query.test.ts +36 -1
- package/src/plugins/defaults/memory/__tests__/find-most-recent-retrospective-for.test.ts +2 -4
- package/src/plugins/defaults/memory/__tests__/jobs-worker-v2-graph-trigger-embed.test.ts +2 -2
- package/src/plugins/defaults/memory/__tests__/jobs-worker-v2-schedule.test.ts +9 -0
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +7 -4
- package/src/plugins/defaults/memory/config.ts +11 -0
- package/src/plugins/defaults/memory/context-search/agent-runner.ts +1 -1
- package/src/plugins/defaults/memory/context-search/sources/conversations.ts +124 -113
- package/src/plugins/defaults/memory/embeddings.ts +77 -0
- package/src/plugins/defaults/memory/find-most-recent-retrospective-for.ts +66 -0
- package/src/plugins/defaults/memory/frontmatter.ts +50 -0
- package/src/plugins/defaults/memory/graph/bootstrap.test.ts +36 -10
- package/src/plugins/defaults/memory/graph/bootstrap.ts +6 -5
- package/src/plugins/defaults/memory/graph/consolidation.ts +3 -5
- package/src/plugins/defaults/memory/graph/extraction.ts +3 -6
- package/src/plugins/defaults/memory/graph/graph-search.ts +12 -23
- package/src/plugins/defaults/memory/graph/narrative.ts +3 -5
- package/src/plugins/defaults/memory/graph/pattern-scan.ts +3 -5
- package/src/plugins/defaults/memory/graph/retriever.test.ts +1 -6
- package/src/plugins/defaults/memory/graph/retriever.ts +5 -10
- package/src/plugins/defaults/memory/graph/tools.ts +1 -1
- package/src/plugins/defaults/memory/hooks/init.ts +31 -5
- package/src/plugins/defaults/memory/indexer.ts +2 -3
- package/src/plugins/defaults/memory/injectors.ts +2 -2
- package/src/plugins/defaults/memory/job-handler-registration.ts +96 -0
- package/src/plugins/defaults/memory/job-handlers/backfill.ts +4 -26
- package/src/plugins/defaults/memory/job-handlers/embedding.test.ts +6 -19
- package/src/plugins/defaults/memory/job-handlers/embedding.ts +9 -22
- package/src/plugins/defaults/memory/job-handlers/index-maintenance.ts +2 -3
- package/src/plugins/defaults/memory/job-handlers.ts +13 -12
- package/src/plugins/defaults/memory/jobs/embed-concept-page.ts +1 -1
- package/src/plugins/defaults/memory/llm-helpers.ts +76 -0
- package/src/plugins/defaults/memory/memory-retrospective-job.ts +8 -5
- package/src/plugins/defaults/memory/memory-retrospective-startup-cleanup.ts +2 -2
- package/src/plugins/defaults/memory/memory-v2-concept-frequency.ts +2 -0
- package/src/plugins/defaults/memory/persistence-hooks.ts +46 -1
- package/src/plugins/defaults/memory/pkb/pkb-index.ts +1 -5
- package/src/plugins/defaults/memory/pkb/pkb-search.ts +2 -2
- package/src/plugins/defaults/memory/routes/__tests__/memory-v2-simulate-route.test.ts +4 -6
- package/src/plugins/defaults/memory/routes/memory-item-routes.ts +1 -1
- package/src/plugins/defaults/memory/search/semantic.ts +2 -2
- package/src/plugins/defaults/memory/startup.ts +244 -0
- package/src/plugins/defaults/memory/task-memory-cleanup.ts +8 -0
- package/src/{daemon → plugins/defaults/memory/v2}/__tests__/memory-v2-startup.test.ts +6 -6
- package/src/plugins/defaults/memory/v2/__tests__/migration.test.ts +2 -13
- package/src/plugins/defaults/memory/v2/__tests__/router.test.ts +4 -5
- package/src/plugins/defaults/memory/v2/__tests__/sweep-job.test.ts +1 -8
- package/src/plugins/defaults/memory/v2/activation.ts +2 -4
- package/src/plugins/defaults/memory/v2/cli-command-store.ts +2 -4
- package/src/plugins/defaults/memory/v2/frontmatter-sweep.ts +1 -1
- package/src/{daemon → plugins/defaults/memory/v2}/memory-v2-startup.test.ts +8 -8
- package/src/{daemon → plugins/defaults/memory/v2}/memory-v2-startup.ts +20 -22
- package/src/plugins/defaults/memory/v2/migration.ts +2 -5
- package/src/plugins/defaults/memory/v2/page-store.ts +1 -1
- package/src/plugins/defaults/memory/v2/qdrant.ts +2 -3
- package/src/plugins/defaults/memory/v2/router.ts +5 -5
- package/src/plugins/defaults/memory/v2/sim.ts +2 -4
- package/src/plugins/defaults/memory/v2/skill-store.ts +2 -4
- package/src/plugins/defaults/memory/v2/sweep-job.ts +6 -6
- package/src/plugins/defaults/memory/v3/__tests__/carry-integration.test.ts +11 -9
- package/src/plugins/defaults/memory/v3/__tests__/dense.test.ts +107 -2
- package/src/plugins/defaults/memory/v3/__tests__/gate-flag.test.ts +69 -0
- package/src/plugins/defaults/memory/v3/__tests__/gate.test.ts +181 -0
- package/src/plugins/defaults/memory/v3/__tests__/live-integration.test.ts +7 -3
- package/src/plugins/defaults/memory/v3/__tests__/orchestrate.test.ts +345 -8
- package/src/plugins/defaults/memory/v3/__tests__/pool-select.test.ts +1 -4
- package/src/plugins/defaults/memory/v3/__tests__/section-needle.test.ts +64 -0
- package/src/plugins/defaults/memory/v3/__tests__/shadow-integration.test.ts +18 -13
- package/src/plugins/defaults/memory/v3/__tests__/shadow-plugin.test.ts +62 -1
- package/src/plugins/defaults/memory/v3/card.ts +1 -4
- package/src/plugins/defaults/memory/v3/dense.ts +60 -25
- package/src/plugins/defaults/memory/v3/edge.ts +1 -1
- package/src/plugins/defaults/memory/v3/gate-flag.ts +20 -0
- package/src/plugins/defaults/memory/v3/gate.ts +158 -0
- package/src/plugins/defaults/memory/v3/maintain-job.ts +2 -4
- package/src/plugins/defaults/memory/v3/orchestrate.ts +186 -25
- package/src/plugins/defaults/memory/v3/pool-select.test.ts +7 -9
- package/src/plugins/defaults/memory/v3/pool-select.ts +6 -6
- package/src/plugins/defaults/memory/v3/prune.ts +2 -2
- package/src/plugins/defaults/memory/v3/section-dense-store.ts +8 -11
- package/src/plugins/defaults/memory/v3/section-needle.ts +27 -7
- package/src/plugins/defaults/memory/v3/shadow-plugin.ts +10 -0
- package/src/plugins/defaults/memory/v3-eval/eval-packets.ts +1 -4
- package/src/plugins/defaults/title-generate/hooks/user-prompt-submit.ts +5 -0
- package/src/plugins/defaults/turn-context/injectors.ts +1 -0
- package/src/plugins/defaults/turn-context/unified-turn-context.ts +12 -0
- package/src/plugins/injector-registry.ts +13 -2
- package/src/plugins/mtime-cache.ts +36 -47
- package/src/plugins/pipeline.ts +15 -1
- package/src/plugins/types.ts +14 -23
- package/src/prompts/__tests__/system-prompt.test.ts +170 -32
- package/src/prompts/persona-resolver.ts +13 -9
- package/src/prompts/system-prompt.ts +30 -9
- package/src/prompts/templates/BOOTSTRAP.md +16 -119
- package/src/prompts/templates/SOUL.md +43 -13
- package/src/prompts/templates/system-sections.ts +32 -27
- package/src/prompts/templates/users/default.md +19 -0
- package/src/providers/__tests__/placeholder-sentinels.test.ts +56 -0
- package/src/providers/inference/__tests__/base-url-route-validation.test.ts +96 -1
- package/src/providers/model-intents.ts +2 -2
- package/src/providers/openai/chat-completions-provider.ts +14 -1
- package/src/providers/openai/responses-provider.ts +18 -0
- package/src/providers/placeholder-sentinels.ts +18 -8
- package/src/providers/retry.ts +1 -0
- package/src/providers/speech-to-text/__tests__/provider-catalog.test.ts +0 -64
- package/src/providers/speech-to-text/__tests__/resolve.test.ts +92 -79
- package/src/providers/speech-to-text/deepgram-realtime.test.ts +98 -0
- package/src/providers/speech-to-text/deepgram-realtime.ts +62 -3
- package/src/providers/speech-to-text/provider-catalog.ts +0 -89
- package/src/providers/speech-to-text/resolve.ts +55 -0
- package/src/providers/tool-progress-events.test.ts +50 -0
- package/src/providers/tool-progress-events.ts +99 -0
- package/src/providers/types.ts +1 -1
- package/src/providers/vellum-model-routing.test.ts +51 -0
- package/src/providers/vellum-model-routing.ts +84 -0
- package/src/runtime/AGENTS.md +5 -4
- package/src/runtime/__tests__/agent-wake.test.ts +158 -1
- package/src/runtime/__tests__/channel-verification-service.test.ts +2 -2
- package/src/runtime/__tests__/trust-verdict-consumer.test.ts +37 -8
- package/src/runtime/access-request-helper.ts +154 -3
- package/src/runtime/actor-trust-resolver.ts +7 -21
- package/src/runtime/agent-wake.ts +41 -1
- package/src/runtime/assistant-event.ts +86 -12
- package/src/runtime/auth/__tests__/route-policy.test.ts +31 -0
- package/src/runtime/channel-approval-types.ts +52 -2
- package/src/runtime/channel-approvals.ts +23 -8
- package/src/runtime/channel-invite-transport.ts +2 -2
- package/src/runtime/channel-invite-transports/voice.ts +1 -1
- package/src/runtime/channel-readiness-service.ts +3 -5
- package/src/runtime/channel-retry-sweep.ts +18 -24
- package/src/runtime/channel-verification-service.ts +9 -11
- package/src/runtime/finalize-event-delivery.ts +30 -16
- package/src/runtime/guardian-action-service.ts +10 -11
- package/src/runtime/guardian-reply-router.ts +122 -21
- package/src/runtime/http-server.ts +44 -101
- package/src/runtime/introduction-policy.test.ts +214 -0
- package/src/runtime/introduction-policy.ts +284 -0
- package/src/runtime/invite-service.ts +104 -361
- package/src/runtime/local-actor-identity.test.ts +38 -0
- package/src/runtime/local-actor-identity.ts +14 -0
- package/src/runtime/middleware/__tests__/rate-limiter.test.ts +22 -0
- package/src/runtime/middleware/rate-limiter.ts +30 -0
- package/src/runtime/middleware/twilio-validation.ts +5 -7
- package/src/runtime/migrations/__tests__/gcs-signed-url.test.ts +28 -0
- package/src/runtime/migrations/origin-mode.ts +6 -6
- package/src/runtime/pre-first-message-gate.ts +1 -0
- package/src/runtime/routes/__tests__/channel-verification-revoke.test.ts +6 -35
- package/src/runtime/routes/__tests__/consolidation-routes.test.ts +24 -3
- package/src/runtime/routes/__tests__/contact-routes.test.ts +80 -15
- package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +7 -1
- package/src/runtime/routes/__tests__/global-search-routes.test.ts +0 -3
- package/src/runtime/routes/__tests__/home-feed-routes.test.ts +142 -1
- package/src/runtime/routes/__tests__/invite-relay-routes.test.ts +119 -30
- package/src/runtime/routes/__tests__/migration-import-allowed-hosts-env.test.ts +70 -0
- package/src/runtime/routes/__tests__/monitoring-routes.test.ts +194 -0
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +755 -29
- package/src/runtime/routes/__tests__/retrospective-routes.test.ts +4 -0
- package/src/runtime/routes/__tests__/schedule-routes-create.test.ts +70 -0
- package/src/runtime/routes/__tests__/schedule-worker-routes.test.ts +161 -0
- package/src/runtime/routes/__tests__/surface-action-routes.test.ts +1 -1
- package/src/runtime/routes/__tests__/surface-content-routes.test.ts +1 -1
- package/src/runtime/routes/acp-routes.ts +15 -3
- package/src/runtime/routes/app-management-routes.ts +4 -7
- package/src/runtime/routes/approval-routes.ts +4 -0
- package/src/runtime/routes/attachment-routes.ts +54 -3
- package/src/runtime/routes/avatar-routes.ts +0 -4
- package/src/runtime/routes/background-tool-routes.ts +34 -5
- package/src/runtime/routes/btw-routes.ts +32 -4
- package/src/runtime/routes/channel-route-shared.ts +18 -14
- package/src/runtime/routes/channel-verification-routes.ts +0 -3
- package/src/runtime/routes/contact-routes.ts +298 -117
- package/src/runtime/routes/conversation-list-routes.ts +6 -0
- package/src/runtime/routes/conversation-management-routes.ts +74 -37
- package/src/runtime/routes/conversation-query-routes.ts +5 -2
- package/src/runtime/routes/conversation-routes.ts +222 -82
- package/src/runtime/routes/conversations-import-routes.ts +8 -1
- package/src/runtime/routes/credential-prompt-routes.ts +20 -5
- package/src/runtime/routes/debug-routes.ts +1 -0
- package/src/runtime/routes/documents-routes.ts +5 -2
- package/src/runtime/routes/empty-state-greeting-cache.ts +37 -10
- package/src/runtime/routes/global-search-routes.ts +2 -1
- package/src/runtime/routes/guardian-approval-interception.ts +51 -0
- package/src/runtime/routes/home-feed-routes.ts +60 -1
- package/src/runtime/routes/identity-routes.ts +4 -87
- package/src/runtime/routes/inbound-message-handler.ts +82 -41
- package/src/runtime/routes/inbound-stages/acl-enforcement.test.ts +430 -57
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +302 -572
- package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +76 -270
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +21 -116
- package/src/runtime/routes/inbound-stages/edit-intercept.ts +9 -1
- package/src/runtime/routes/inbound-stages/escalation-intercept.test.ts +158 -0
- package/src/runtime/routes/inbound-stages/escalation-intercept.ts +22 -1
- package/src/runtime/routes/index.ts +6 -0
- package/src/runtime/routes/inference-provider-connection-routes.ts +22 -16
- package/src/runtime/routes/internal-twilio-routes.ts +0 -15
- package/src/runtime/routes/messages-lexical-routes.ts +75 -0
- package/src/runtime/routes/migration-routes.ts +69 -6
- package/src/runtime/routes/monitoring-routes.ts +237 -0
- package/src/runtime/routes/plugins-routes.ts +329 -8
- package/src/runtime/routes/schedule-routes.ts +49 -8
- package/src/runtime/routes/schedule-worker-routes.ts +210 -0
- package/src/runtime/routes/secret-routes.ts +1 -1
- package/src/runtime/routes/surface-conversation-resolver.ts +1 -0
- package/src/runtime/services/__tests__/analyze-conversation.test.ts +54 -9
- package/src/runtime/services/__tests__/auto-analysis-enqueue.test.ts +30 -6
- package/src/runtime/services/__tests__/conversation-analyze-job.test.ts +43 -8
- package/src/runtime/services/__tests__/conversation-serializer.test.ts +20 -0
- package/src/runtime/services/analyze-conversation.ts +14 -4
- package/src/runtime/services/conversation-serializer.ts +5 -0
- package/src/runtime/slack-no-response.ts +47 -0
- package/src/runtime/slack-reply-session.test.ts +633 -0
- package/src/runtime/slack-reply-session.ts +382 -0
- package/src/runtime/slack-task-progress.ts +95 -0
- package/src/runtime/sync/resource-sync-events.ts +25 -0
- package/src/runtime/tool-grant-request-helper.ts +17 -1
- package/src/runtime/trust-class.test.ts +141 -0
- package/src/runtime/trust-class.ts +121 -0
- package/src/runtime/trust-verdict-consumer.ts +7 -6
- package/src/schedule/__tests__/run-script.test.ts +21 -0
- package/src/schedule/__tests__/worker-control.test.ts +241 -0
- package/src/schedule/run-script.ts +13 -6
- package/src/schedule/schedule-usage-store.ts +1 -0
- package/src/schedule/scheduler.ts +178 -127
- package/src/schedule/worker-control.ts +118 -0
- package/src/schedule/worker.ts +131 -0
- package/src/skills/__tests__/categories-cache.test.ts +38 -0
- package/src/skills/categories-cache.ts +40 -5
- package/src/skills/managed-store.ts +41 -6
- package/src/skills/validate-input.ts +47 -0
- package/src/stt/types.ts +5 -9
- package/src/subagent/manager.ts +267 -39
- package/src/subagent/types.ts +6 -2
- package/src/subagent/validate-allowlists.ts +65 -0
- package/src/telemetry/watchdog-direct-emit.test.ts +109 -0
- package/src/telemetry/watchdog-direct-emit.ts +89 -0
- package/src/tools/acp/list-agents.test.ts +0 -6
- package/src/tools/background-tool-registry.ts +61 -0
- package/src/tools/document/document-comment-tool.test.ts +4 -0
- package/src/tools/host-terminal/host-shell.test.ts +397 -0
- package/src/tools/host-terminal/host-shell.ts +203 -14
- package/src/tools/skills/find-similar-skills.test.ts +152 -1
- package/src/tools/skills/find-similar-skills.ts +30 -4
- package/src/tools/skills/load.ts +36 -0
- package/src/tools/skills/scaffold-managed.ts +66 -34
- package/src/tools/skills/skill-script-runner.ts +17 -0
- package/src/tools/skills/skill-tool-factory.ts +5 -1
- package/src/tools/terminal/safe-env.ts +3 -0
- package/src/tools/terminal/shell.test.ts +177 -0
- package/src/tools/terminal/shell.ts +98 -4
- package/src/tools/tool-approval-handler.ts +106 -29
- package/{node_modules/@vellumai/skill-host-contracts/src → src/tools}/tool-types.ts +16 -17
- package/src/tools/types.ts +29 -17
- package/src/tools/ui-surface/definitions.ts +3 -1
- package/src/tts/__tests__/provider-adapters.test.ts +37 -0
- package/src/tts/__tests__/provider-catalog.test.ts +21 -0
- package/src/tts/provider-catalog.ts +40 -9
- package/src/tts/providers/fish-audio-provider.ts +19 -10
- package/src/tts/types.ts +3 -4
- package/src/util/__tests__/process-tree.test.ts +10 -2
- package/src/util/__tests__/text-spacing.test.ts +53 -0
- package/src/util/cgroup-memory.ts +142 -0
- package/src/util/image-conversion.ts +281 -0
- package/src/util/phone.ts +1 -8
- package/src/util/platform.ts +33 -0
- package/src/util/process-tree.ts +10 -2
- package/src/util/text-spacing.ts +53 -0
- package/src/util/worker-process.ts +271 -0
- package/src/workflows/journal-store.ts +16 -1
- package/src/workflows/library.test.ts +35 -20
- package/src/workflows/library.ts +105 -66
- package/src/workspace/git-service.ts +1 -0
- package/src/workspace/migrations/011-backfill-installation-id.ts +83 -31
- package/src/workspace/migrations/041-backfill-google-gmail-settings-scope.ts +41 -22
- package/src/workspace/migrations/070-memory-v2-summary-schema-rebuild.ts +1 -1
- package/src/workspace/migrations/118-seed-now-md.ts +64 -0
- package/src/workspace/migrations/119-strip-persisted-memory-v3-tuning-defaults.ts +169 -0
- package/src/workspace/migrations/120-revise-onboarding-threads.ts +47 -0
- package/src/workspace/migrations/121-seed-default-user-guardrails.ts +82 -0
- package/src/workspace/migrations/122-relocate-default-user-boundary.ts +111 -0
- package/src/workspace/migrations/123-swap-quality-profile-to-fable.ts +85 -0
- package/src/workspace/migrations/__tests__/119-strip-persisted-memory-v3-tuning-defaults.test.ts +181 -0
- package/src/workspace/migrations/registry.ts +12 -0
- package/drizzle/0000_dizzy_maggott.sql +0 -301
- package/drizzle/meta/0000_snapshot.json +0 -1933
- package/drizzle/meta/_journal.json +0 -13
- package/node_modules/@vellumai/skill-host-contracts/__tests__/client.test.ts +0 -887
- package/node_modules/@vellumai/skill-host-contracts/bun.lock +0 -24
- package/node_modules/@vellumai/skill-host-contracts/package.json +0 -18
- package/node_modules/@vellumai/skill-host-contracts/src/assistant-event.ts +0 -97
- package/node_modules/@vellumai/skill-host-contracts/src/client.ts +0 -1349
- package/node_modules/@vellumai/skill-host-contracts/src/index.ts +0 -6
- package/node_modules/@vellumai/skill-host-contracts/src/runtime-mode.ts +0 -11
- package/node_modules/@vellumai/skill-host-contracts/src/server-message.ts +0 -32
- package/node_modules/@vellumai/skill-host-contracts/src/skill-host.ts +0 -333
- package/node_modules/@vellumai/skill-host-contracts/tsconfig.json +0 -20
- package/node_modules/@vellumai/skill-host-contracts/tsconfig.test.json +0 -12
- package/src/__tests__/invite-redemption-service.test.ts +0 -1180
- package/src/__tests__/invite-service-ipc.test.ts +0 -350
- package/src/__tests__/job-handler-registry-guard.test.ts +0 -125
- package/src/__tests__/regenerate-fire-and-forget-trace.test.ts +0 -203
- package/src/__tests__/relay-server.test.ts +0 -6153
- package/src/__tests__/telephony-stt-routing.test.ts +0 -329
- package/src/__tests__/voice-invite-redemption.test.ts +0 -702
- package/src/calls/relay-server.ts +0 -2463
- package/src/calls/telephony-stt-routing.ts +0 -145
- package/src/contacts/__tests__/contacts-write-revoke-relay.test.ts +0 -128
- package/src/daemon/__tests__/daemon-skill-host.test.ts +0 -273
- package/src/daemon/daemon-skill-host.ts +0 -262
- package/src/daemon/message-types/meet.ts +0 -143
- package/src/daemon/shutdown-registry.ts +0 -40
- package/src/ipc/__tests__/skill-server-bidirectional.test.ts +0 -253
- package/src/ipc/__tests__/skill-server.test.ts +0 -182
- package/src/ipc/skill-ipc-types.ts +0 -54
- package/src/ipc/skill-routes/__tests__/config.test.ts +0 -146
- package/src/ipc/skill-routes/__tests__/events-ipc.test.ts +0 -448
- package/src/ipc/skill-routes/__tests__/identity.test.ts +0 -62
- package/src/ipc/skill-routes/__tests__/log.test.ts +0 -133
- package/src/ipc/skill-routes/__tests__/memory.test.ts +0 -203
- package/src/ipc/skill-routes/__tests__/platform.test.ts +0 -111
- package/src/ipc/skill-routes/__tests__/providers.test.ts +0 -265
- package/src/ipc/skill-routes/__tests__/registries.test.ts +0 -400
- package/src/ipc/skill-routes/config.ts +0 -47
- package/src/ipc/skill-routes/events.ts +0 -147
- package/src/ipc/skill-routes/identity.ts +0 -21
- package/src/ipc/skill-routes/index.ts +0 -37
- package/src/ipc/skill-routes/log.ts +0 -40
- package/src/ipc/skill-routes/memory.ts +0 -92
- package/src/ipc/skill-routes/platform.ts +0 -39
- package/src/ipc/skill-routes/providers.ts +0 -162
- package/src/ipc/skill-routes/registries.ts +0 -338
- package/src/ipc/skill-server.ts +0 -831
- package/src/ipc/skill-socket-path.ts +0 -20
- package/src/jobs/register-job-handlers.ts +0 -74
- package/src/persistence/invite-store.ts +0 -481
- package/src/plugins/job-handler-registry.ts +0 -94
- package/src/providers/cache-control.ts +0 -26
- package/src/runtime/__tests__/runtime-mode.test.ts +0 -62
- package/src/runtime/invite-redemption-service.ts +0 -828
- package/src/runtime/invite-redemption-templates.ts +0 -46
- package/src/runtime/runtime-mode.ts +0 -40
- package/src/runtime/slack-dm-text-delivery.ts +0 -177
- package/src/util/voice-code.ts +0 -31
|
@@ -1,2463 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* WebSocket handler for Twilio ConversationRelay protocol.
|
|
3
|
-
*
|
|
4
|
-
* Manages real-time voice conversations over WebSocket. Each active call
|
|
5
|
-
* has a single RelayConnection instance that processes inbound messages
|
|
6
|
-
* from Twilio and can send text tokens back for TTS.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { randomInt } from "node:crypto";
|
|
10
|
-
|
|
11
|
-
import type { AdmissionPolicy, TrustVerdict } from "@vellumai/gateway-client";
|
|
12
|
-
import type { ServerWebSocket } from "bun";
|
|
13
|
-
|
|
14
|
-
import { revokeScopedApprovalGrantsForContext } from "../approvals/scoped-approval-grants.js";
|
|
15
|
-
import { getCanonicalGuardianRequest } from "../contacts/canonical-guardian-store.js";
|
|
16
|
-
import {
|
|
17
|
-
getGuardianDelivery,
|
|
18
|
-
getGuardianDeliveryFresh,
|
|
19
|
-
voiceGuardianDisplayName,
|
|
20
|
-
} from "../contacts/guardian-delivery-reader.js";
|
|
21
|
-
import { getAssistantName } from "../daemon/identity-helpers.js";
|
|
22
|
-
import type { ServerMessage } from "../daemon/message-protocol.js";
|
|
23
|
-
import type { TrustContext } from "../daemon/trust-context.js";
|
|
24
|
-
import { addMessage } from "../persistence/conversation-crud.js";
|
|
25
|
-
import { resolveGuardianName } from "../prompts/user-reference.js";
|
|
26
|
-
import { notifyGuardianOfAccessRequest } from "../runtime/access-request-helper.js";
|
|
27
|
-
import {
|
|
28
|
-
resolveActorTrust,
|
|
29
|
-
toTrustContext,
|
|
30
|
-
} from "../runtime/actor-trust-resolver.js";
|
|
31
|
-
import {
|
|
32
|
-
trustContextFromVerdict,
|
|
33
|
-
verdictHasMemberIdentity,
|
|
34
|
-
verdictMemberUnresolvable,
|
|
35
|
-
} from "../runtime/trust-verdict-consumer.js";
|
|
36
|
-
import {
|
|
37
|
-
composeVerificationVoice,
|
|
38
|
-
GUARDIAN_VERIFY_TEMPLATE_KEYS,
|
|
39
|
-
} from "../runtime/verification-templates.js";
|
|
40
|
-
import { parseJsonSafe } from "../util/json.js";
|
|
41
|
-
import { getLogger } from "../util/logger.js";
|
|
42
|
-
import {
|
|
43
|
-
getAccessRequestPollIntervalMs,
|
|
44
|
-
getTtsPlaybackDelayMs,
|
|
45
|
-
getUserConsultationTimeoutMs,
|
|
46
|
-
} from "./call-constants.js";
|
|
47
|
-
import { CallController } from "./call-controller.js";
|
|
48
|
-
import { addPointerMessage, formatDuration } from "./call-pointer-messages.js";
|
|
49
|
-
import { speakSystemPrompt } from "./call-speech-output.js";
|
|
50
|
-
import { fireCallTranscriptNotifier } from "./call-state.js";
|
|
51
|
-
import { isTerminalState } from "./call-state-machine.js";
|
|
52
|
-
import {
|
|
53
|
-
getCallSession,
|
|
54
|
-
recordCallEvent,
|
|
55
|
-
updateCallSession,
|
|
56
|
-
} from "./call-store.js";
|
|
57
|
-
import { ConversationRelayTransport } from "./call-transport.js";
|
|
58
|
-
import { getChannelAdmissionPolicy } from "./channel-admission-reader.js";
|
|
59
|
-
import { finalizeCall } from "./finalize-call.js";
|
|
60
|
-
import {
|
|
61
|
-
getInboundTrustVerdict,
|
|
62
|
-
getPhoneCallerVerdict,
|
|
63
|
-
} from "./inbound-trust-reader.js";
|
|
64
|
-
import {
|
|
65
|
-
classifyWaitUtterance,
|
|
66
|
-
emitAccessRequestCallbackHandoff,
|
|
67
|
-
scheduleNextHeartbeat,
|
|
68
|
-
} from "./relay-access-wait.js";
|
|
69
|
-
import { routeSetup, type SetupResolved } from "./relay-setup-router.js";
|
|
70
|
-
import {
|
|
71
|
-
attemptInviteCodeRedemption,
|
|
72
|
-
attemptVerificationCode,
|
|
73
|
-
parseDigitsFromSpeech,
|
|
74
|
-
} from "./relay-verification.js";
|
|
75
|
-
import {
|
|
76
|
-
extractPromptSpeakerMetadata,
|
|
77
|
-
type PromptSpeakerContext,
|
|
78
|
-
SpeakerIdentityTracker,
|
|
79
|
-
} from "./speaker-identification.js";
|
|
80
|
-
|
|
81
|
-
const log = getLogger("relay-server");
|
|
82
|
-
const UUID_SHAPED_NAME =
|
|
83
|
-
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Return the first whitespace-delimited token of a name, or `null` when the
|
|
87
|
-
* input is null/blank. Used for greetings so "Carolina Flaherty" -> "Carolina".
|
|
88
|
-
*/
|
|
89
|
-
function firstToken(name: string | null | undefined): string | null {
|
|
90
|
-
if (!name) return null;
|
|
91
|
-
const trimmed = name.trim();
|
|
92
|
-
if (!trimmed) return null;
|
|
93
|
-
return trimmed.split(/\s+/)[0] ?? null;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// ── ConversationRelay message types ──────────────────────────────────
|
|
97
|
-
|
|
98
|
-
// Messages FROM Twilio
|
|
99
|
-
interface RelaySetupMessage {
|
|
100
|
-
type: "setup";
|
|
101
|
-
callSid: string;
|
|
102
|
-
from: string;
|
|
103
|
-
to: string;
|
|
104
|
-
customParameters?: Record<string, string>;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
interface RelayPromptMessage {
|
|
108
|
-
type: "prompt";
|
|
109
|
-
voicePrompt: string;
|
|
110
|
-
lang: string;
|
|
111
|
-
last: boolean;
|
|
112
|
-
speakerId?: string;
|
|
113
|
-
speakerLabel?: string;
|
|
114
|
-
speakerName?: string;
|
|
115
|
-
speakerConfidence?: number;
|
|
116
|
-
participantId?: string;
|
|
117
|
-
participant?: {
|
|
118
|
-
id?: string;
|
|
119
|
-
name?: string;
|
|
120
|
-
};
|
|
121
|
-
speaker?: {
|
|
122
|
-
id?: string;
|
|
123
|
-
label?: string;
|
|
124
|
-
name?: string;
|
|
125
|
-
confidence?: number;
|
|
126
|
-
};
|
|
127
|
-
metadata?: Record<string, unknown>;
|
|
128
|
-
providerMetadata?: Record<string, unknown>;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
interface RelayInterruptMessage {
|
|
132
|
-
type: "interrupt";
|
|
133
|
-
utteranceUntilInterrupt: string;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
interface RelayDtmfMessage {
|
|
137
|
-
type: "dtmf";
|
|
138
|
-
digit: string;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
interface RelayErrorMessage {
|
|
142
|
-
type: "error";
|
|
143
|
-
description: string;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
type RelayInboundMessage =
|
|
147
|
-
| RelaySetupMessage
|
|
148
|
-
| RelayPromptMessage
|
|
149
|
-
| RelayInterruptMessage
|
|
150
|
-
| RelayDtmfMessage
|
|
151
|
-
| RelayErrorMessage;
|
|
152
|
-
|
|
153
|
-
// Messages TO Twilio
|
|
154
|
-
interface RelayTextMessage {
|
|
155
|
-
type: "text";
|
|
156
|
-
token: string;
|
|
157
|
-
last: boolean;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
interface RelayEndMessage {
|
|
161
|
-
type: "end";
|
|
162
|
-
handoffData?: string;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
interface RelayPlayMessage {
|
|
166
|
-
type: "play";
|
|
167
|
-
source: string;
|
|
168
|
-
interruptible: boolean;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// ── WebSocket data type ──────────────────────────────────────────────
|
|
172
|
-
|
|
173
|
-
export interface RelayWebSocketData {
|
|
174
|
-
callSessionId: string;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// ── Module-level state ───────────────────────────────────────────────
|
|
178
|
-
|
|
179
|
-
/** Active relay connections keyed by callSessionId. */
|
|
180
|
-
export const activeRelayConnections = new Map<string, RelayConnection>();
|
|
181
|
-
|
|
182
|
-
/** Module-level broadcast function, set by the HTTP server during startup. */
|
|
183
|
-
let globalBroadcast: ((msg: ServerMessage) => void) | undefined;
|
|
184
|
-
|
|
185
|
-
/** Register a broadcast function so RelayConnection can forward events to connected clients. */
|
|
186
|
-
export function setRelayBroadcast(fn: (msg: ServerMessage) => void): void {
|
|
187
|
-
globalBroadcast = fn;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// ── RelayConnection ──────────────────────────────────────────────────
|
|
191
|
-
|
|
192
|
-
/**
|
|
193
|
-
* Manages a single WebSocket connection for one call.
|
|
194
|
-
*/
|
|
195
|
-
type RelayConnectionState =
|
|
196
|
-
| "connected"
|
|
197
|
-
// Transient state held synchronously from the start of handleSetup until
|
|
198
|
-
// routing/ACL completes. Prompts arriving in this window are dropped so a
|
|
199
|
-
// not-yet-authorized caller's speech is never persisted/emitted before the
|
|
200
|
-
// admission floor and trust ACL are applied.
|
|
201
|
-
| "setting_up"
|
|
202
|
-
| "verification_pending"
|
|
203
|
-
| "awaiting_name"
|
|
204
|
-
| "awaiting_guardian_decision"
|
|
205
|
-
| "disconnecting";
|
|
206
|
-
|
|
207
|
-
export class RelayConnection {
|
|
208
|
-
private ws: ServerWebSocket<RelayWebSocketData>;
|
|
209
|
-
private callSessionId: string;
|
|
210
|
-
private conversationHistory: Array<{
|
|
211
|
-
role: "caller" | "assistant";
|
|
212
|
-
text: string;
|
|
213
|
-
timestamp: number;
|
|
214
|
-
speaker?: PromptSpeakerContext;
|
|
215
|
-
}>;
|
|
216
|
-
private abortController: AbortController;
|
|
217
|
-
private controller: CallController | null = null;
|
|
218
|
-
private speakerIdentityTracker: SpeakerIdentityTracker;
|
|
219
|
-
|
|
220
|
-
// Verification state (outbound callee verification)
|
|
221
|
-
private connectionState: RelayConnectionState = "connected";
|
|
222
|
-
private verificationCode: string | null = null;
|
|
223
|
-
private verificationAttempts = 0;
|
|
224
|
-
private verificationMaxAttempts = 3;
|
|
225
|
-
private verificationCodeLength = 6;
|
|
226
|
-
private dtmfBuffer = "";
|
|
227
|
-
|
|
228
|
-
// Inbound voice guardian verification state
|
|
229
|
-
private verificationSessionActive = false;
|
|
230
|
-
private verificationAssistantId: string | null = null;
|
|
231
|
-
private verificationFromNumber: string | null = null;
|
|
232
|
-
|
|
233
|
-
// Outbound guardian verification state (system calls the guardian)
|
|
234
|
-
private outboundVerificationSessionId: string | null = null;
|
|
235
|
-
|
|
236
|
-
// Inbound voice invite redemption state
|
|
237
|
-
private inviteRedemptionActive = false;
|
|
238
|
-
// In-flight guard: true while an async redemption attempt is awaiting the
|
|
239
|
-
// gateway-backed claim, so a rapidly-repeated code is deduped rather than
|
|
240
|
-
// launching a second concurrent redeemVoiceInviteCode.
|
|
241
|
-
private inviteRedemptionInFlight = false;
|
|
242
|
-
private inviteRedemptionAssistantId: string | null = null;
|
|
243
|
-
private inviteRedemptionFromNumber: string | null = null;
|
|
244
|
-
private inviteRedemptionCodeLength = 6;
|
|
245
|
-
private inviteRedemptionInviteeName: string | null = null;
|
|
246
|
-
|
|
247
|
-
// In-call guardian approval wait state (friend-initiated)
|
|
248
|
-
private accessRequestWaitActive = false;
|
|
249
|
-
private accessRequestId: string | null = null;
|
|
250
|
-
private accessRequestAssistantId: string | null = null;
|
|
251
|
-
private accessRequestFromNumber: string | null = null;
|
|
252
|
-
private accessRequestPollTimer: ReturnType<typeof setInterval> | null = null;
|
|
253
|
-
private accessRequestTimeoutTimer: ReturnType<typeof setTimeout> | null =
|
|
254
|
-
null;
|
|
255
|
-
private accessRequestCallerName: string | null = null;
|
|
256
|
-
|
|
257
|
-
// Name capture timeout (unknown inbound callers)
|
|
258
|
-
private nameCaptureTimeoutTimer: ReturnType<typeof setTimeout> | null = null;
|
|
259
|
-
|
|
260
|
-
// Guardian wait heartbeat state
|
|
261
|
-
private accessRequestHeartbeatTimer: ReturnType<typeof setTimeout> | null =
|
|
262
|
-
null;
|
|
263
|
-
private accessRequestWaitStartedAt: number = 0;
|
|
264
|
-
private heartbeatSequence = 0;
|
|
265
|
-
|
|
266
|
-
// Guardian displayName primed from the gateway binding at setup, read
|
|
267
|
-
// synchronously by the heartbeat-driven wait-label path.
|
|
268
|
-
private primedGuardianDisplayName: string | undefined;
|
|
269
|
-
|
|
270
|
-
// In-wait prompt handling state
|
|
271
|
-
private lastInWaitReplyAt = 0;
|
|
272
|
-
private static readonly IN_WAIT_REPLY_COOLDOWN_MS = 3000;
|
|
273
|
-
|
|
274
|
-
// Callback offer state (in-memory per-call)
|
|
275
|
-
private callbackOfferMade = false;
|
|
276
|
-
private callbackOptIn = false;
|
|
277
|
-
private callbackHandoffNotified = false;
|
|
278
|
-
|
|
279
|
-
// True while mid-call trust is being re-resolved (async). handlePrompt defers
|
|
280
|
-
// prompts to trustReResolvePending so a verified caller can't start a turn
|
|
281
|
-
// under the stale pre-verification trust context during the await window.
|
|
282
|
-
private trustReResolving = false;
|
|
283
|
-
private trustReResolvePending: RelayPromptMessage[] = [];
|
|
284
|
-
|
|
285
|
-
constructor(ws: ServerWebSocket<RelayWebSocketData>, callSessionId: string) {
|
|
286
|
-
this.ws = ws;
|
|
287
|
-
this.callSessionId = callSessionId;
|
|
288
|
-
this.conversationHistory = [];
|
|
289
|
-
this.abortController = new AbortController();
|
|
290
|
-
this.speakerIdentityTracker = new SpeakerIdentityTracker();
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
/**
|
|
294
|
-
* Get the verification code for this connection (if verification is active).
|
|
295
|
-
*/
|
|
296
|
-
getVerificationCode(): string | null {
|
|
297
|
-
return this.verificationCode;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
/**
|
|
301
|
-
* Whether inbound guardian voice verification is currently active.
|
|
302
|
-
*/
|
|
303
|
-
isVerificationSessionActive(): boolean {
|
|
304
|
-
return this.verificationSessionActive;
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
/**
|
|
308
|
-
* Get the current connection state.
|
|
309
|
-
*/
|
|
310
|
-
getConnectionState(): RelayConnectionState {
|
|
311
|
-
return this.connectionState;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
/**
|
|
315
|
-
* Handle an inbound message from Twilio via the ConversationRelay WebSocket.
|
|
316
|
-
*/
|
|
317
|
-
async handleMessage(data: string): Promise<void> {
|
|
318
|
-
const parsed = parseJsonSafe<RelayInboundMessage>(data);
|
|
319
|
-
if (!parsed) {
|
|
320
|
-
log.warn(
|
|
321
|
-
{ callSessionId: this.callSessionId, data },
|
|
322
|
-
"Failed to parse relay message",
|
|
323
|
-
);
|
|
324
|
-
return;
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
switch (parsed.type) {
|
|
328
|
-
case "setup":
|
|
329
|
-
await this.handleSetup(parsed);
|
|
330
|
-
break;
|
|
331
|
-
case "prompt":
|
|
332
|
-
await this.handlePrompt(parsed);
|
|
333
|
-
break;
|
|
334
|
-
case "interrupt":
|
|
335
|
-
this.handleInterrupt(parsed);
|
|
336
|
-
break;
|
|
337
|
-
case "dtmf":
|
|
338
|
-
this.handleDtmf(parsed);
|
|
339
|
-
break;
|
|
340
|
-
case "error":
|
|
341
|
-
this.handleError(parsed);
|
|
342
|
-
break;
|
|
343
|
-
default:
|
|
344
|
-
log.warn(
|
|
345
|
-
{
|
|
346
|
-
callSessionId: this.callSessionId,
|
|
347
|
-
type: (parsed as { type: unknown }).type,
|
|
348
|
-
},
|
|
349
|
-
"Unknown relay message type",
|
|
350
|
-
);
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
/**
|
|
355
|
-
* Send a text token to the caller for TTS playback.
|
|
356
|
-
*/
|
|
357
|
-
sendTextToken(token: string, last: boolean): void {
|
|
358
|
-
const message: RelayTextMessage = { type: "text", token, last };
|
|
359
|
-
try {
|
|
360
|
-
this.ws.send(JSON.stringify(message));
|
|
361
|
-
} catch (err) {
|
|
362
|
-
log.error(
|
|
363
|
-
{ err, callSessionId: this.callSessionId },
|
|
364
|
-
"Failed to send text token",
|
|
365
|
-
);
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
/**
|
|
370
|
-
* Send a play-audio URL to the caller. Used when the assistant handles
|
|
371
|
-
* TTS synthesis itself (e.g. Fish Audio) instead of relying on
|
|
372
|
-
* ConversationRelay's built-in TTS.
|
|
373
|
-
*/
|
|
374
|
-
sendPlayUrl(url: string): void {
|
|
375
|
-
const message: RelayPlayMessage = {
|
|
376
|
-
type: "play",
|
|
377
|
-
source: url,
|
|
378
|
-
interruptible: true,
|
|
379
|
-
};
|
|
380
|
-
try {
|
|
381
|
-
this.ws.send(JSON.stringify(message));
|
|
382
|
-
} catch (err) {
|
|
383
|
-
log.error(
|
|
384
|
-
{ err, callSessionId: this.callSessionId },
|
|
385
|
-
"Failed to send play URL",
|
|
386
|
-
);
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
/**
|
|
391
|
-
* End the ConversationRelay session.
|
|
392
|
-
*/
|
|
393
|
-
endSession(reason?: string): void {
|
|
394
|
-
const message: RelayEndMessage = { type: "end" };
|
|
395
|
-
if (reason) {
|
|
396
|
-
message.handoffData = JSON.stringify({ reason });
|
|
397
|
-
}
|
|
398
|
-
try {
|
|
399
|
-
this.ws.send(JSON.stringify(message));
|
|
400
|
-
} catch (err) {
|
|
401
|
-
log.error(
|
|
402
|
-
{ err, callSessionId: this.callSessionId },
|
|
403
|
-
"Failed to send end message",
|
|
404
|
-
);
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
/**
|
|
409
|
-
* Get the conversation history for context.
|
|
410
|
-
*/
|
|
411
|
-
getConversationHistory(): Array<{
|
|
412
|
-
role: string;
|
|
413
|
-
text: string;
|
|
414
|
-
speaker?: PromptSpeakerContext;
|
|
415
|
-
}> {
|
|
416
|
-
return this.conversationHistory.map(({ role, text, speaker }) => ({
|
|
417
|
-
role,
|
|
418
|
-
text,
|
|
419
|
-
speaker,
|
|
420
|
-
}));
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
/**
|
|
424
|
-
* Get the call session ID for this connection.
|
|
425
|
-
*/
|
|
426
|
-
getCallSessionId(): string {
|
|
427
|
-
return this.callSessionId;
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
/**
|
|
431
|
-
* Set the controller for this connection.
|
|
432
|
-
*/
|
|
433
|
-
setController(controller: CallController): void {
|
|
434
|
-
this.controller = controller;
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
/**
|
|
438
|
-
* Get the controller for this connection.
|
|
439
|
-
*/
|
|
440
|
-
getController(): CallController | null {
|
|
441
|
-
return this.controller;
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
/**
|
|
445
|
-
* Clean up resources on disconnect.
|
|
446
|
-
*/
|
|
447
|
-
destroy(): void {
|
|
448
|
-
if (this.controller) {
|
|
449
|
-
this.controller.destroy();
|
|
450
|
-
this.controller = null;
|
|
451
|
-
}
|
|
452
|
-
if (this.accessRequestPollTimer) {
|
|
453
|
-
clearInterval(this.accessRequestPollTimer);
|
|
454
|
-
this.accessRequestPollTimer = null;
|
|
455
|
-
}
|
|
456
|
-
if (this.accessRequestTimeoutTimer) {
|
|
457
|
-
clearTimeout(this.accessRequestTimeoutTimer);
|
|
458
|
-
this.accessRequestTimeoutTimer = null;
|
|
459
|
-
}
|
|
460
|
-
if (this.accessRequestHeartbeatTimer) {
|
|
461
|
-
clearTimeout(this.accessRequestHeartbeatTimer);
|
|
462
|
-
this.accessRequestHeartbeatTimer = null;
|
|
463
|
-
}
|
|
464
|
-
if (this.nameCaptureTimeoutTimer) {
|
|
465
|
-
clearTimeout(this.nameCaptureTimeoutTimer);
|
|
466
|
-
this.nameCaptureTimeoutTimer = null;
|
|
467
|
-
}
|
|
468
|
-
this.accessRequestWaitActive = false;
|
|
469
|
-
this.abortController.abort();
|
|
470
|
-
log.info(
|
|
471
|
-
{ callSessionId: this.callSessionId },
|
|
472
|
-
"RelayConnection destroyed",
|
|
473
|
-
);
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
/**
|
|
477
|
-
* Handle transport-level close from the relay websocket.
|
|
478
|
-
*
|
|
479
|
-
* Twilio status callbacks are best-effort; if they are delayed or absent,
|
|
480
|
-
* we still finalize the call lifecycle from the relay close signal.
|
|
481
|
-
*/
|
|
482
|
-
handleTransportClosed(code?: number, reason?: string): void {
|
|
483
|
-
// If the call was still in guardian-wait with callback opt-in, emit the
|
|
484
|
-
// handoff notification before cleaning up wait state.
|
|
485
|
-
if (this.accessRequestWaitActive && this.callbackOptIn) {
|
|
486
|
-
this.emitAccessRequestCallbackHandoffForReason("transport_closed");
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
// Clean up access request wait state on disconnect to stop polling
|
|
490
|
-
this.clearAccessRequestWait();
|
|
491
|
-
if (this.nameCaptureTimeoutTimer) {
|
|
492
|
-
clearTimeout(this.nameCaptureTimeoutTimer);
|
|
493
|
-
this.nameCaptureTimeoutTimer = null;
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
const session = getCallSession(this.callSessionId);
|
|
497
|
-
if (!session) return;
|
|
498
|
-
if (isTerminalState(session.status)) return;
|
|
499
|
-
|
|
500
|
-
const isNormalClose = code === 1000;
|
|
501
|
-
if (isNormalClose) {
|
|
502
|
-
updateCallSession(this.callSessionId, {
|
|
503
|
-
status: "completed",
|
|
504
|
-
endedAt: Date.now(),
|
|
505
|
-
});
|
|
506
|
-
recordCallEvent(this.callSessionId, "call_ended", {
|
|
507
|
-
reason: reason || "relay_closed",
|
|
508
|
-
closeCode: code,
|
|
509
|
-
});
|
|
510
|
-
|
|
511
|
-
// Post a pointer message in the initiating conversation
|
|
512
|
-
if (session.initiatedFromConversationId) {
|
|
513
|
-
const durationMs = session.startedAt
|
|
514
|
-
? Date.now() - session.startedAt
|
|
515
|
-
: 0;
|
|
516
|
-
addPointerMessage(
|
|
517
|
-
session.initiatedFromConversationId,
|
|
518
|
-
"completed",
|
|
519
|
-
session.toNumber,
|
|
520
|
-
{
|
|
521
|
-
duration: durationMs > 0 ? formatDuration(durationMs) : undefined,
|
|
522
|
-
},
|
|
523
|
-
).catch((err) => {
|
|
524
|
-
log.warn(
|
|
525
|
-
{ conversationId: session.initiatedFromConversationId, err },
|
|
526
|
-
"Skipping pointer write — origin conversation may no longer exist",
|
|
527
|
-
);
|
|
528
|
-
});
|
|
529
|
-
}
|
|
530
|
-
} else {
|
|
531
|
-
const detail =
|
|
532
|
-
reason || (code ? `relay_closed_${code}` : "relay_closed_abnormal");
|
|
533
|
-
updateCallSession(this.callSessionId, {
|
|
534
|
-
status: "failed",
|
|
535
|
-
endedAt: Date.now(),
|
|
536
|
-
lastError: `Relay websocket closed unexpectedly: ${detail}`,
|
|
537
|
-
});
|
|
538
|
-
recordCallEvent(this.callSessionId, "call_failed", {
|
|
539
|
-
reason: detail,
|
|
540
|
-
closeCode: code,
|
|
541
|
-
});
|
|
542
|
-
|
|
543
|
-
// Post a failure pointer message in the initiating conversation
|
|
544
|
-
if (session.initiatedFromConversationId) {
|
|
545
|
-
addPointerMessage(
|
|
546
|
-
session.initiatedFromConversationId,
|
|
547
|
-
"failed",
|
|
548
|
-
session.toNumber,
|
|
549
|
-
{
|
|
550
|
-
reason: detail,
|
|
551
|
-
},
|
|
552
|
-
).catch((err) => {
|
|
553
|
-
log.warn(
|
|
554
|
-
{ conversationId: session.initiatedFromConversationId, err },
|
|
555
|
-
"Skipping pointer write — origin conversation may no longer exist",
|
|
556
|
-
);
|
|
557
|
-
});
|
|
558
|
-
}
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
// Revoke any scoped approval grants bound to this call session.
|
|
562
|
-
// Revoke by both callSessionId and conversationId because the
|
|
563
|
-
// guardian-approval-interception minting path sets callSessionId: null
|
|
564
|
-
// but always sets conversationId.
|
|
565
|
-
try {
|
|
566
|
-
revokeScopedApprovalGrantsForContext({
|
|
567
|
-
callSessionId: this.callSessionId,
|
|
568
|
-
});
|
|
569
|
-
revokeScopedApprovalGrantsForContext({
|
|
570
|
-
conversationId: session.conversationId,
|
|
571
|
-
});
|
|
572
|
-
} catch (err) {
|
|
573
|
-
log.warn(
|
|
574
|
-
{ err, callSessionId: this.callSessionId },
|
|
575
|
-
"Failed to revoke scoped grants on transport close",
|
|
576
|
-
);
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
finalizeCall(this.callSessionId, session.conversationId);
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
// ── Private handlers ─────────────────────────────────────────────
|
|
583
|
-
|
|
584
|
-
private async handleSetup(msg: RelaySetupMessage): Promise<void> {
|
|
585
|
-
log.info(
|
|
586
|
-
{
|
|
587
|
-
callSessionId: this.callSessionId,
|
|
588
|
-
callSid: msg.callSid,
|
|
589
|
-
from: msg.from,
|
|
590
|
-
to: msg.to,
|
|
591
|
-
},
|
|
592
|
-
"ConversationRelay setup received",
|
|
593
|
-
);
|
|
594
|
-
|
|
595
|
-
// Enter the setup-pending state synchronously, before any await. The
|
|
596
|
-
// admission-policy read below yields the event loop, and inbound frames are
|
|
597
|
-
// dispatched fire-and-forget, so a `prompt` frame can arrive before routing
|
|
598
|
-
// completes. handlePrompt drops prompts while in "setting_up" so a blocked
|
|
599
|
-
// or admission-denied caller's speech is never stored before the ACL runs.
|
|
600
|
-
this.connectionState = "setting_up";
|
|
601
|
-
|
|
602
|
-
const session = getCallSession(this.callSessionId);
|
|
603
|
-
this.recordSetupBookkeeping(session, msg);
|
|
604
|
-
|
|
605
|
-
// Prime the guardian displayName and warm the phone-channel guardian-
|
|
606
|
-
// delivery cache before routeSetup's SYNC resolveActorTrust fallback. That
|
|
607
|
-
// fallback reads the IO-free cache snapshot keyed per channel-filter; daemon
|
|
608
|
-
// startup warms only `vellum`, so a cold-process phone call would otherwise
|
|
609
|
-
// misclassify a guardian during a gateway verdict blip. Read fresh: gateway-
|
|
610
|
-
// side binding writes don't invalidate the daemon cache, so a stale empty
|
|
611
|
-
// snapshot from an earlier setup would survive the TTL otherwise. Both are
|
|
612
|
-
// independent IPC reads on different cache keys, so run them concurrently
|
|
613
|
-
// off the sync resolver's hot path.
|
|
614
|
-
await Promise.all([
|
|
615
|
-
this.primeGuardianDisplayName(),
|
|
616
|
-
getGuardianDeliveryFresh({ channelTypes: ["phone"] }),
|
|
617
|
-
]);
|
|
618
|
-
|
|
619
|
-
// Resolve the phone channel's inbound admission floor. The reader fails
|
|
620
|
-
// open to `null` by contract, so a transport hiccup admits the caller.
|
|
621
|
-
const admissionPolicy = await getChannelAdmissionPolicy("phone");
|
|
622
|
-
|
|
623
|
-
// Verdict-first caller trust. routeSetup uses it when present and not
|
|
624
|
-
// resolutionFailed, else falls back to local resolution. The reader
|
|
625
|
-
// returns null on failure, so a gateway blip keeps the local path.
|
|
626
|
-
const isInbound = session?.initiatedFromConversationId == null;
|
|
627
|
-
const otherPartyNumber = isInbound ? msg.from : msg.to;
|
|
628
|
-
const verdict = await getPhoneCallerVerdict(otherPartyNumber);
|
|
629
|
-
|
|
630
|
-
try {
|
|
631
|
-
await this.routeSetupOutcome(msg, session, admissionPolicy, verdict);
|
|
632
|
-
} catch (err) {
|
|
633
|
-
// Never leave the connection stranded in "setting_up": a setup that
|
|
634
|
-
// throws before reaching a terminal outcome would otherwise drop every
|
|
635
|
-
// prompt forever. Tear the call down instead.
|
|
636
|
-
log.error(
|
|
637
|
-
{ err, callSessionId: this.callSessionId },
|
|
638
|
-
"Setup routing failed — disconnecting",
|
|
639
|
-
);
|
|
640
|
-
this.connectionState = "disconnecting";
|
|
641
|
-
updateCallSession(this.callSessionId, {
|
|
642
|
-
status: "failed",
|
|
643
|
-
endedAt: Date.now(),
|
|
644
|
-
lastError: "Setup routing failed",
|
|
645
|
-
});
|
|
646
|
-
this.endSession("Setup routing failed");
|
|
647
|
-
}
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
/**
|
|
651
|
-
* Apply the routing/ACL outcome for a setup frame. Synchronous helpers that
|
|
652
|
-
* fire-and-forget (greeting, verification, name capture) are awaited here only
|
|
653
|
-
* when they themselves are async. Each reachable outcome transitions the
|
|
654
|
-
* connection out of "setting_up" into its terminal state.
|
|
655
|
-
*/
|
|
656
|
-
private async routeSetupOutcome(
|
|
657
|
-
msg: RelaySetupMessage,
|
|
658
|
-
session: ReturnType<typeof getCallSession>,
|
|
659
|
-
admissionPolicy: AdmissionPolicy | null,
|
|
660
|
-
verdict: TrustVerdict | null,
|
|
661
|
-
): Promise<void> {
|
|
662
|
-
const { outcome, resolved } = routeSetup({
|
|
663
|
-
callSessionId: this.callSessionId,
|
|
664
|
-
session,
|
|
665
|
-
from: msg.from,
|
|
666
|
-
to: msg.to,
|
|
667
|
-
customParameters: msg.customParameters,
|
|
668
|
-
admissionPolicy,
|
|
669
|
-
verdict,
|
|
670
|
-
});
|
|
671
|
-
|
|
672
|
-
const initialTrustContext = toTrustContext(
|
|
673
|
-
resolved.actorTrust,
|
|
674
|
-
resolved.otherPartyNumber,
|
|
675
|
-
);
|
|
676
|
-
const transport = new ConversationRelayTransport(this);
|
|
677
|
-
const controller = new CallController(
|
|
678
|
-
this.callSessionId,
|
|
679
|
-
transport,
|
|
680
|
-
session?.task ?? null,
|
|
681
|
-
{
|
|
682
|
-
broadcast: globalBroadcast,
|
|
683
|
-
assistantId: resolved.assistantId,
|
|
684
|
-
trustContext: initialTrustContext,
|
|
685
|
-
},
|
|
686
|
-
);
|
|
687
|
-
this.setController(controller);
|
|
688
|
-
|
|
689
|
-
switch (outcome.action) {
|
|
690
|
-
case "outbound_verification":
|
|
691
|
-
this.startOutboundVerification(
|
|
692
|
-
outcome.assistantId,
|
|
693
|
-
outcome.sessionId,
|
|
694
|
-
outcome.toNumber,
|
|
695
|
-
);
|
|
696
|
-
return;
|
|
697
|
-
case "callee_verification":
|
|
698
|
-
await this.startVerification(session, outcome.verificationConfig);
|
|
699
|
-
return;
|
|
700
|
-
case "deny":
|
|
701
|
-
await this.denyInboundCall(msg.from, resolved, outcome);
|
|
702
|
-
return;
|
|
703
|
-
case "invite_redemption":
|
|
704
|
-
this.startInviteRedemption(
|
|
705
|
-
outcome.assistantId,
|
|
706
|
-
outcome.fromNumber,
|
|
707
|
-
outcome.inviteeName,
|
|
708
|
-
!resolved.isInbound,
|
|
709
|
-
);
|
|
710
|
-
return;
|
|
711
|
-
case "name_capture":
|
|
712
|
-
recordCallEvent(
|
|
713
|
-
this.callSessionId,
|
|
714
|
-
"inbound_acl_name_capture_started",
|
|
715
|
-
{
|
|
716
|
-
from: msg.from,
|
|
717
|
-
trustClass: resolved.actorTrust.trustClass,
|
|
718
|
-
},
|
|
719
|
-
);
|
|
720
|
-
this.startNameCapture(outcome.assistantId, outcome.fromNumber);
|
|
721
|
-
return;
|
|
722
|
-
case "unverified_caller":
|
|
723
|
-
await this.handleUnverifiedCaller(
|
|
724
|
-
outcome.displayName,
|
|
725
|
-
outcome.isGuardian,
|
|
726
|
-
);
|
|
727
|
-
return;
|
|
728
|
-
case "verification":
|
|
729
|
-
if (this.controller && resolved.actorTrust.trustClass !== "unknown") {
|
|
730
|
-
this.controller.setTrustContext(
|
|
731
|
-
toTrustContext(resolved.actorTrust, msg.from),
|
|
732
|
-
);
|
|
733
|
-
}
|
|
734
|
-
this.startInboundVerification(outcome.assistantId, outcome.fromNumber);
|
|
735
|
-
return;
|
|
736
|
-
case "normal_call":
|
|
737
|
-
if (outcome.isInbound) {
|
|
738
|
-
if (this.controller && resolved.actorTrust.trustClass !== "unknown") {
|
|
739
|
-
this.controller.setTrustContext(
|
|
740
|
-
toTrustContext(resolved.actorTrust, msg.from),
|
|
741
|
-
);
|
|
742
|
-
}
|
|
743
|
-
}
|
|
744
|
-
// Routing/ACL cleared — leave the setup-pending state so subsequent
|
|
745
|
-
// prompts are handled normally.
|
|
746
|
-
this.connectionState = "connected";
|
|
747
|
-
this.startNormalCallFlow(controller, outcome.isInbound);
|
|
748
|
-
return;
|
|
749
|
-
}
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
/** Bookkeeping side-effects that run on every setup regardless of routing outcome. */
|
|
753
|
-
private recordSetupBookkeeping(
|
|
754
|
-
session: ReturnType<typeof getCallSession>,
|
|
755
|
-
msg: RelaySetupMessage,
|
|
756
|
-
): void {
|
|
757
|
-
if (session) {
|
|
758
|
-
const updates: Parameters<typeof updateCallSession>[1] = {
|
|
759
|
-
providerCallSid: msg.callSid,
|
|
760
|
-
};
|
|
761
|
-
if (
|
|
762
|
-
!isTerminalState(session.status) &&
|
|
763
|
-
session.status !== "in_progress" &&
|
|
764
|
-
session.status !== "waiting_on_user"
|
|
765
|
-
) {
|
|
766
|
-
updates.status = "in_progress";
|
|
767
|
-
if (!session.startedAt) updates.startedAt = Date.now();
|
|
768
|
-
}
|
|
769
|
-
updateCallSession(this.callSessionId, updates);
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
const safeCustomParameters = msg.customParameters
|
|
773
|
-
? Object.fromEntries(
|
|
774
|
-
Object.entries(msg.customParameters).filter(
|
|
775
|
-
([key]) => !key.toLowerCase().includes("secret"),
|
|
776
|
-
),
|
|
777
|
-
)
|
|
778
|
-
: undefined;
|
|
779
|
-
|
|
780
|
-
recordCallEvent(this.callSessionId, "call_connected", {
|
|
781
|
-
callSid: msg.callSid,
|
|
782
|
-
from: msg.from,
|
|
783
|
-
to: msg.to,
|
|
784
|
-
customParameters: safeCustomParameters,
|
|
785
|
-
});
|
|
786
|
-
}
|
|
787
|
-
|
|
788
|
-
/** Speak verification guidance to a known-but-unverified caller, then disconnect. */
|
|
789
|
-
private async handleUnverifiedCaller(
|
|
790
|
-
displayName: string,
|
|
791
|
-
isGuardian: boolean,
|
|
792
|
-
): Promise<void> {
|
|
793
|
-
recordCallEvent(this.callSessionId, "inbound_acl_unverified_caller", {
|
|
794
|
-
callSessionId: this.callSessionId,
|
|
795
|
-
isGuardian,
|
|
796
|
-
});
|
|
797
|
-
this.connectionState = "disconnecting";
|
|
798
|
-
updateCallSession(this.callSessionId, {
|
|
799
|
-
status: "failed",
|
|
800
|
-
endedAt: Date.now(),
|
|
801
|
-
lastError: "Inbound voice ACL: caller channel unverified",
|
|
802
|
-
});
|
|
803
|
-
const action = isGuardian
|
|
804
|
-
? `To verify, open your assistant's contacts page, click Verify next to the phone channel, ` +
|
|
805
|
-
`and follow the prompts. Then call back once the verification session is active.`
|
|
806
|
-
: `Please reach out to the account guardian to start a new verification session, ` +
|
|
807
|
-
`then call back once the verification session is active.`;
|
|
808
|
-
const message =
|
|
809
|
-
`This number is registered as ${displayName}'s phone but has not been verified yet. ` +
|
|
810
|
-
action;
|
|
811
|
-
await speakSystemPrompt(this, message);
|
|
812
|
-
setTimeout(() => {
|
|
813
|
-
this.endSession("Inbound voice ACL: caller channel unverified");
|
|
814
|
-
}, getTtsPlaybackDelayMs());
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
/** Deny an inbound call with a TTS message and schedule disconnect. */
|
|
818
|
-
private async denyInboundCall(
|
|
819
|
-
from: string,
|
|
820
|
-
resolved: SetupResolved,
|
|
821
|
-
outcome: { message: string; logReason: string },
|
|
822
|
-
): Promise<void> {
|
|
823
|
-
recordCallEvent(this.callSessionId, "inbound_acl_denied", {
|
|
824
|
-
from,
|
|
825
|
-
trustClass: resolved.actorTrust.trustClass,
|
|
826
|
-
channelId: resolved.actorTrust.memberRecord?.channel.id,
|
|
827
|
-
memberPolicy: resolved.actorTrust.memberRecord?.policy,
|
|
828
|
-
});
|
|
829
|
-
this.connectionState = "disconnecting";
|
|
830
|
-
updateCallSession(this.callSessionId, {
|
|
831
|
-
status: "failed",
|
|
832
|
-
endedAt: Date.now(),
|
|
833
|
-
lastError: outcome.logReason,
|
|
834
|
-
});
|
|
835
|
-
await speakSystemPrompt(this, outcome.message);
|
|
836
|
-
setTimeout(() => {
|
|
837
|
-
this.endSession(outcome.logReason);
|
|
838
|
-
}, getTtsPlaybackDelayMs());
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
/**
|
|
842
|
-
* Generate a verification code and prompt the callee to enter it via DTMF.
|
|
843
|
-
*/
|
|
844
|
-
private async startVerification(
|
|
845
|
-
session: ReturnType<typeof getCallSession>,
|
|
846
|
-
verificationConfig: { maxAttempts: number; codeLength: number },
|
|
847
|
-
): Promise<void> {
|
|
848
|
-
this.verificationMaxAttempts = verificationConfig.maxAttempts;
|
|
849
|
-
this.verificationCodeLength = verificationConfig.codeLength;
|
|
850
|
-
this.verificationAttempts = 0;
|
|
851
|
-
this.dtmfBuffer = "";
|
|
852
|
-
|
|
853
|
-
// Generate a random numeric code
|
|
854
|
-
const maxValue = Math.pow(10, this.verificationCodeLength);
|
|
855
|
-
const code = randomInt(0, maxValue)
|
|
856
|
-
.toString()
|
|
857
|
-
.padStart(this.verificationCodeLength, "0");
|
|
858
|
-
this.verificationCode = code;
|
|
859
|
-
this.connectionState = "verification_pending";
|
|
860
|
-
|
|
861
|
-
recordCallEvent(this.callSessionId, "callee_verification_started", {
|
|
862
|
-
codeLength: this.verificationCodeLength,
|
|
863
|
-
maxAttempts: this.verificationMaxAttempts,
|
|
864
|
-
});
|
|
865
|
-
|
|
866
|
-
// Send a TTS prompt with the code spoken digit by digit
|
|
867
|
-
const spokenCode = code.split("").join(". ");
|
|
868
|
-
void speakSystemPrompt(
|
|
869
|
-
this,
|
|
870
|
-
`Please enter the verification code: ${spokenCode}.`,
|
|
871
|
-
);
|
|
872
|
-
|
|
873
|
-
// Post the verification code to the initiating conversation so the
|
|
874
|
-
// guardian (user) can share it with the callee.
|
|
875
|
-
if (session?.initiatedFromConversationId) {
|
|
876
|
-
const codeMsg = `\u{1F510} Verification code for call to ${session.toNumber}: ${code}`;
|
|
877
|
-
await addMessage(
|
|
878
|
-
session.initiatedFromConversationId,
|
|
879
|
-
"assistant",
|
|
880
|
-
JSON.stringify([{ type: "text", text: codeMsg }]),
|
|
881
|
-
{
|
|
882
|
-
metadata: {
|
|
883
|
-
userMessageChannel: "phone",
|
|
884
|
-
assistantMessageChannel: "phone",
|
|
885
|
-
userMessageInterface: "phone",
|
|
886
|
-
assistantMessageInterface: "phone",
|
|
887
|
-
},
|
|
888
|
-
},
|
|
889
|
-
);
|
|
890
|
-
}
|
|
891
|
-
|
|
892
|
-
log.info(
|
|
893
|
-
{
|
|
894
|
-
callSessionId: this.callSessionId,
|
|
895
|
-
codeLength: this.verificationCodeLength,
|
|
896
|
-
},
|
|
897
|
-
"Callee verification started",
|
|
898
|
-
);
|
|
899
|
-
}
|
|
900
|
-
|
|
901
|
-
/**
|
|
902
|
-
* Start normal call flow — fire the controller greeting.
|
|
903
|
-
*/
|
|
904
|
-
private startNormalCallFlow(
|
|
905
|
-
controller: CallController,
|
|
906
|
-
isInbound: boolean,
|
|
907
|
-
): void {
|
|
908
|
-
controller
|
|
909
|
-
.startInitialGreeting()
|
|
910
|
-
.catch((err) =>
|
|
911
|
-
log.error(
|
|
912
|
-
{ err, callSessionId: this.callSessionId },
|
|
913
|
-
`Failed to start initial ${isInbound ? "inbound" : "outbound"} greeting`,
|
|
914
|
-
),
|
|
915
|
-
);
|
|
916
|
-
}
|
|
917
|
-
|
|
918
|
-
/**
|
|
919
|
-
* Re-resolve caller trust after a mid-call verification/activation. Prefers
|
|
920
|
-
* the gateway verdict (authoritative right after the gateway updated the
|
|
921
|
-
* binding); falls back to local resolution on a missing/failed/unusable
|
|
922
|
-
* verdict so a blip never drops the call. Mirrors the setup path's
|
|
923
|
-
* verdict-first-with-fallback condition.
|
|
924
|
-
*/
|
|
925
|
-
private async resolveMidCallTrustContext(
|
|
926
|
-
assistantId: string,
|
|
927
|
-
fromNumber: string,
|
|
928
|
-
): Promise<TrustContext> {
|
|
929
|
-
const verdict = await getInboundTrustVerdict({
|
|
930
|
-
channelType: "phone",
|
|
931
|
-
actorExternalId: fromNumber,
|
|
932
|
-
});
|
|
933
|
-
|
|
934
|
-
// Only a MEMBERLESS unknown verdict is treated as a stale gateway view and
|
|
935
|
-
// falls back to local: the caller was just activated, and invite redemption
|
|
936
|
-
// writes the channel assistant-side, so the gateway may not see the member
|
|
937
|
-
// yet — local resolution has it. A MEMBERFUL unknown verdict (blocked/revoked
|
|
938
|
-
// member, carrying contactId/channelId) is honored so its deny ACL is
|
|
939
|
-
// enforced; falling back could lose the gateway's member status if local
|
|
940
|
-
// state is stale.
|
|
941
|
-
const memberlessUnknown =
|
|
942
|
-
verdict?.trustClass === "unknown" && !verdictHasMemberIdentity(verdict);
|
|
943
|
-
const usable =
|
|
944
|
-
verdict &&
|
|
945
|
-
!verdict.resolutionFailed &&
|
|
946
|
-
!verdictMemberUnresolvable(verdict) &&
|
|
947
|
-
!memberlessUnknown;
|
|
948
|
-
|
|
949
|
-
if (usable) {
|
|
950
|
-
return trustContextFromVerdict(verdict, {
|
|
951
|
-
sourceChannel: "phone",
|
|
952
|
-
conversationExternalId: fromNumber,
|
|
953
|
-
});
|
|
954
|
-
}
|
|
955
|
-
|
|
956
|
-
// Warm the phone-channel guardian-delivery cache before the SYNC
|
|
957
|
-
// resolveActorTrust fallback, which reads the IO-free per-channel snapshot
|
|
958
|
-
// that daemon startup leaves cold for `phone`. Read fresh: gateway-side
|
|
959
|
-
// binding writes don't invalidate the daemon cache, so a stale empty
|
|
960
|
-
// snapshot would otherwise survive the TTL and misclassify the guardian.
|
|
961
|
-
await getGuardianDeliveryFresh({ channelTypes: ["phone"] });
|
|
962
|
-
|
|
963
|
-
return toTrustContext(
|
|
964
|
-
resolveActorTrust({
|
|
965
|
-
assistantId,
|
|
966
|
-
sourceChannel: "phone",
|
|
967
|
-
conversationExternalId: fromNumber,
|
|
968
|
-
actorExternalId: fromNumber,
|
|
969
|
-
}),
|
|
970
|
-
fromNumber,
|
|
971
|
-
);
|
|
972
|
-
}
|
|
973
|
-
|
|
974
|
-
/**
|
|
975
|
-
* Re-resolve mid-call trust and install it on the controller, deferring any
|
|
976
|
-
* prompt that arrives during the async window. Guards the gap between
|
|
977
|
-
* connectionState flipping to "connected" and the upgraded context landing so
|
|
978
|
-
* a verified caller never starts a turn under the stale pre-verification
|
|
979
|
-
* trust. Clears the guard in a finally and flushes buffered prompts under the
|
|
980
|
-
* new context, so an IPC error can't wedge the call.
|
|
981
|
-
*/
|
|
982
|
-
private async reResolveAndApplyTrustContext(
|
|
983
|
-
assistantId: string,
|
|
984
|
-
fromNumber: string,
|
|
985
|
-
): Promise<void> {
|
|
986
|
-
if (!this.controller) return;
|
|
987
|
-
this.trustReResolving = true;
|
|
988
|
-
try {
|
|
989
|
-
const context = await this.resolveMidCallTrustContext(
|
|
990
|
-
assistantId,
|
|
991
|
-
fromNumber,
|
|
992
|
-
);
|
|
993
|
-
this.controller.setTrustContext(context);
|
|
994
|
-
} finally {
|
|
995
|
-
this.trustReResolving = false;
|
|
996
|
-
this.flushDeferredPrompts();
|
|
997
|
-
}
|
|
998
|
-
}
|
|
999
|
-
|
|
1000
|
-
/**
|
|
1001
|
-
* Replay prompts buffered while trust was re-resolving. Runs after the
|
|
1002
|
-
* upgraded context is installed so deferred utterances are answered under the
|
|
1003
|
-
* correct trust.
|
|
1004
|
-
*/
|
|
1005
|
-
private flushDeferredPrompts(): void {
|
|
1006
|
-
if (this.trustReResolvePending.length === 0) return;
|
|
1007
|
-
const pending = this.trustReResolvePending;
|
|
1008
|
-
this.trustReResolvePending = [];
|
|
1009
|
-
for (const msg of pending) {
|
|
1010
|
-
void this.handlePrompt(msg);
|
|
1011
|
-
}
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
|
-
/**
|
|
1015
|
-
* Shared post-activation handoff for all trusted-contact success paths
|
|
1016
|
-
* (access-request approval, invite redemption, verification code).
|
|
1017
|
-
* Activates the caller, updates guardian context, delivers deterministic
|
|
1018
|
-
* transition copy, and marks the next utterance as opening-ack so the
|
|
1019
|
-
* LLM continues naturally.
|
|
1020
|
-
*/
|
|
1021
|
-
private async continueCallAfterTrustedContactActivation(params: {
|
|
1022
|
-
assistantId: string;
|
|
1023
|
-
fromNumber: string;
|
|
1024
|
-
activationReason?:
|
|
1025
|
-
| "invite_redeemed"
|
|
1026
|
-
| "access_approved"
|
|
1027
|
-
| "trusted_contact_verified";
|
|
1028
|
-
/**
|
|
1029
|
-
* Display name resolved from the bound contact (or, for outbound invite
|
|
1030
|
-
* calls, the session's recorded invitee name). Greeting uses only the
|
|
1031
|
-
* first whitespace-delimited token; an empty/blank value triggers the
|
|
1032
|
-
* neutral "Hi there" greeting rather than substituting the channel
|
|
1033
|
-
* address.
|
|
1034
|
-
*/
|
|
1035
|
-
inviteeName?: string | null;
|
|
1036
|
-
}): Promise<void> {
|
|
1037
|
-
const { assistantId, fromNumber } = params;
|
|
1038
|
-
|
|
1039
|
-
// Contact activation is handled by the gateway — the assistant no
|
|
1040
|
-
// longer writes contact/channel records on inbound voice calls.
|
|
1041
|
-
|
|
1042
|
-
// Reach connected and clear wait/verification flags before re-resolving
|
|
1043
|
-
// trust, so a prompt buffered during re-resolution flushes onto the
|
|
1044
|
-
// real-turn path, not the verification/wait branches.
|
|
1045
|
-
this.connectionState = "connected";
|
|
1046
|
-
this.verificationSessionActive = false;
|
|
1047
|
-
this.inviteRedemptionActive = false;
|
|
1048
|
-
this.accessRequestWaitActive = false;
|
|
1049
|
-
updateCallSession(this.callSessionId, { status: "in_progress" });
|
|
1050
|
-
|
|
1051
|
-
if (this.controller) {
|
|
1052
|
-
await this.reResolveAndApplyTrustContext(assistantId, fromNumber);
|
|
1053
|
-
}
|
|
1054
|
-
|
|
1055
|
-
const guardianLabel = this.resolveGuardianLabel();
|
|
1056
|
-
let handoffText: string;
|
|
1057
|
-
|
|
1058
|
-
if (params.activationReason === "invite_redeemed") {
|
|
1059
|
-
const firstName = firstToken(params.inviteeName);
|
|
1060
|
-
const assistantName = this.resolveAssistantLabel();
|
|
1061
|
-
if (firstName) {
|
|
1062
|
-
handoffText = assistantName
|
|
1063
|
-
? `Great, I've verified that you are ${firstName}. It's nice to meet you! I'm ${assistantName}, ${guardianLabel}'s assistant. How can I help?`
|
|
1064
|
-
: `Great, I've verified that you are ${firstName}. It's nice to meet you! How can I help?`;
|
|
1065
|
-
} else {
|
|
1066
|
-
handoffText = assistantName
|
|
1067
|
-
? `Hi there! I'm ${assistantName}, ${guardianLabel}'s assistant. How can I help?`
|
|
1068
|
-
: `Hi there! How can I help?`;
|
|
1069
|
-
}
|
|
1070
|
-
} else {
|
|
1071
|
-
handoffText = `Great! ${guardianLabel} said I can speak with you. How can I help?`;
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
void speakSystemPrompt(this, handoffText);
|
|
1075
|
-
|
|
1076
|
-
recordCallEvent(this.callSessionId, "assistant_spoke", {
|
|
1077
|
-
text: handoffText,
|
|
1078
|
-
});
|
|
1079
|
-
const session = getCallSession(this.callSessionId);
|
|
1080
|
-
if (session) {
|
|
1081
|
-
fireCallTranscriptNotifier(
|
|
1082
|
-
session.conversationId,
|
|
1083
|
-
this.callSessionId,
|
|
1084
|
-
"assistant",
|
|
1085
|
-
handoffText,
|
|
1086
|
-
);
|
|
1087
|
-
}
|
|
1088
|
-
|
|
1089
|
-
if (this.controller) {
|
|
1090
|
-
this.controller.markNextCallerTurnAsOpeningAck();
|
|
1091
|
-
}
|
|
1092
|
-
}
|
|
1093
|
-
|
|
1094
|
-
/**
|
|
1095
|
-
* Enter verification-pending state for an inbound call with a pending
|
|
1096
|
-
* voice guardian challenge. Prompts the caller to enter their six-digit
|
|
1097
|
-
* verification code via DTMF or by speaking it.
|
|
1098
|
-
*/
|
|
1099
|
-
private startInboundVerification(
|
|
1100
|
-
assistantId: string,
|
|
1101
|
-
fromNumber: string,
|
|
1102
|
-
): void {
|
|
1103
|
-
this.verificationSessionActive = true;
|
|
1104
|
-
this.verificationAssistantId = assistantId;
|
|
1105
|
-
this.verificationFromNumber = fromNumber;
|
|
1106
|
-
this.connectionState = "verification_pending";
|
|
1107
|
-
this.verificationAttempts = 0;
|
|
1108
|
-
this.verificationMaxAttempts = 3;
|
|
1109
|
-
this.verificationCodeLength = 6;
|
|
1110
|
-
this.dtmfBuffer = "";
|
|
1111
|
-
|
|
1112
|
-
recordCallEvent(this.callSessionId, "voice_verification_started", {
|
|
1113
|
-
assistantId,
|
|
1114
|
-
maxAttempts: this.verificationMaxAttempts,
|
|
1115
|
-
});
|
|
1116
|
-
|
|
1117
|
-
void speakSystemPrompt(
|
|
1118
|
-
this,
|
|
1119
|
-
"Welcome. Please enter your six-digit verification code using your keypad, or speak the digits now.",
|
|
1120
|
-
);
|
|
1121
|
-
|
|
1122
|
-
log.info(
|
|
1123
|
-
{ callSessionId: this.callSessionId, assistantId },
|
|
1124
|
-
"Inbound guardian voice verification started",
|
|
1125
|
-
);
|
|
1126
|
-
}
|
|
1127
|
-
|
|
1128
|
-
/**
|
|
1129
|
-
* Enter verification-pending state for an outbound guardian verification
|
|
1130
|
-
* call. The system called the guardian's phone; prompt them to enter the
|
|
1131
|
-
* verification code via DTMF or speech.
|
|
1132
|
-
*/
|
|
1133
|
-
private startOutboundVerification(
|
|
1134
|
-
assistantId: string,
|
|
1135
|
-
verificationSessionId: string,
|
|
1136
|
-
toNumber: string,
|
|
1137
|
-
): void {
|
|
1138
|
-
this.verificationSessionActive = true;
|
|
1139
|
-
this.outboundVerificationSessionId = verificationSessionId;
|
|
1140
|
-
this.verificationAssistantId = assistantId;
|
|
1141
|
-
// For outbound guardian calls, the "to" number is the guardian's phone
|
|
1142
|
-
this.verificationFromNumber = toNumber;
|
|
1143
|
-
this.connectionState = "verification_pending";
|
|
1144
|
-
this.verificationAttempts = 0;
|
|
1145
|
-
this.verificationMaxAttempts = 3;
|
|
1146
|
-
this.verificationCodeLength = 6;
|
|
1147
|
-
this.dtmfBuffer = "";
|
|
1148
|
-
|
|
1149
|
-
recordCallEvent(this.callSessionId, "outbound_voice_verification_started", {
|
|
1150
|
-
assistantId,
|
|
1151
|
-
verificationSessionId,
|
|
1152
|
-
maxAttempts: this.verificationMaxAttempts,
|
|
1153
|
-
});
|
|
1154
|
-
|
|
1155
|
-
const introText = composeVerificationVoice(
|
|
1156
|
-
GUARDIAN_VERIFY_TEMPLATE_KEYS.VOICE_CALL_INTRO,
|
|
1157
|
-
{ codeDigits: this.verificationCodeLength },
|
|
1158
|
-
);
|
|
1159
|
-
void speakSystemPrompt(this, introText);
|
|
1160
|
-
|
|
1161
|
-
log.info(
|
|
1162
|
-
{
|
|
1163
|
-
callSessionId: this.callSessionId,
|
|
1164
|
-
assistantId,
|
|
1165
|
-
verificationSessionId,
|
|
1166
|
-
},
|
|
1167
|
-
"Outbound guardian voice verification started",
|
|
1168
|
-
);
|
|
1169
|
-
}
|
|
1170
|
-
|
|
1171
|
-
/**
|
|
1172
|
-
* Validate an entered code against the pending voice guardian challenge.
|
|
1173
|
-
* Delegates to the extracted attemptVerificationCode() and
|
|
1174
|
-
* interprets the structured result to drive side-effects.
|
|
1175
|
-
*/
|
|
1176
|
-
private async handleVerificationCodeResult(
|
|
1177
|
-
enteredCode: string,
|
|
1178
|
-
): Promise<void> {
|
|
1179
|
-
if (!this.verificationAssistantId || !this.verificationFromNumber) {
|
|
1180
|
-
return;
|
|
1181
|
-
}
|
|
1182
|
-
|
|
1183
|
-
const isOutbound = this.outboundVerificationSessionId != null;
|
|
1184
|
-
const assistantId = this.verificationAssistantId;
|
|
1185
|
-
const fromNumber = this.verificationFromNumber;
|
|
1186
|
-
|
|
1187
|
-
const result = await attemptVerificationCode({
|
|
1188
|
-
verificationAssistantId: assistantId,
|
|
1189
|
-
verificationFromNumber: fromNumber,
|
|
1190
|
-
enteredCode,
|
|
1191
|
-
isOutbound,
|
|
1192
|
-
codeDigits: this.verificationCodeLength,
|
|
1193
|
-
verificationAttempts: this.verificationAttempts,
|
|
1194
|
-
verificationMaxAttempts: this.verificationMaxAttempts,
|
|
1195
|
-
});
|
|
1196
|
-
|
|
1197
|
-
if (result.outcome === "success") {
|
|
1198
|
-
this.connectionState = "connected";
|
|
1199
|
-
this.verificationSessionActive = false;
|
|
1200
|
-
this.verificationAttempts = 0;
|
|
1201
|
-
this.dtmfBuffer = "";
|
|
1202
|
-
|
|
1203
|
-
recordCallEvent(this.callSessionId, result.eventName, {
|
|
1204
|
-
verificationType: result.verificationType,
|
|
1205
|
-
});
|
|
1206
|
-
log.info(
|
|
1207
|
-
{ callSessionId: this.callSessionId, isOutbound },
|
|
1208
|
-
"Guardian voice verification succeeded",
|
|
1209
|
-
);
|
|
1210
|
-
|
|
1211
|
-
if (isOutbound) {
|
|
1212
|
-
// Keep the pointer message back to the initiating conversation
|
|
1213
|
-
const successSession = getCallSession(this.callSessionId);
|
|
1214
|
-
if (successSession?.initiatedFromConversationId) {
|
|
1215
|
-
addPointerMessage(
|
|
1216
|
-
successSession.initiatedFromConversationId,
|
|
1217
|
-
"verification_succeeded",
|
|
1218
|
-
successSession.toNumber,
|
|
1219
|
-
{ channel: "phone" },
|
|
1220
|
-
).catch((err) => {
|
|
1221
|
-
log.warn(
|
|
1222
|
-
{
|
|
1223
|
-
conversationId: successSession.initiatedFromConversationId,
|
|
1224
|
-
err,
|
|
1225
|
-
},
|
|
1226
|
-
"Skipping pointer write — origin conversation may no longer exist",
|
|
1227
|
-
);
|
|
1228
|
-
});
|
|
1229
|
-
}
|
|
1230
|
-
|
|
1231
|
-
// Update trust context on the controller so the LLM knows this is the guardian
|
|
1232
|
-
if (this.controller) {
|
|
1233
|
-
await this.reResolveAndApplyTrustContext(assistantId, fromNumber);
|
|
1234
|
-
}
|
|
1235
|
-
|
|
1236
|
-
// Mark session as in-progress and transition to guardian conversation
|
|
1237
|
-
// with verification context so the LLM greets naturally.
|
|
1238
|
-
updateCallSession(this.callSessionId, { status: "in_progress" });
|
|
1239
|
-
if (this.controller) {
|
|
1240
|
-
this.controller
|
|
1241
|
-
.startPostVerificationGreeting()
|
|
1242
|
-
.catch((err) =>
|
|
1243
|
-
log.error(
|
|
1244
|
-
{ err, callSessionId: this.callSessionId },
|
|
1245
|
-
"Failed to start post-verification greeting",
|
|
1246
|
-
),
|
|
1247
|
-
);
|
|
1248
|
-
}
|
|
1249
|
-
} else if (result.verificationType === "trusted_contact") {
|
|
1250
|
-
await this.continueCallAfterTrustedContactActivation({
|
|
1251
|
-
assistantId,
|
|
1252
|
-
fromNumber,
|
|
1253
|
-
activationReason: "trusted_contact_verified",
|
|
1254
|
-
});
|
|
1255
|
-
} else {
|
|
1256
|
-
// Inbound guardian verification: binding already handled above,
|
|
1257
|
-
// proceed to normal call flow.
|
|
1258
|
-
if (this.controller) {
|
|
1259
|
-
await this.reResolveAndApplyTrustContext(assistantId, fromNumber);
|
|
1260
|
-
this.startNormalCallFlow(this.controller, true);
|
|
1261
|
-
}
|
|
1262
|
-
}
|
|
1263
|
-
} else if (result.outcome === "failure") {
|
|
1264
|
-
this.verificationSessionActive = false;
|
|
1265
|
-
this.verificationAttempts = result.attempts;
|
|
1266
|
-
|
|
1267
|
-
recordCallEvent(this.callSessionId, result.eventName, {
|
|
1268
|
-
attempts: result.attempts,
|
|
1269
|
-
});
|
|
1270
|
-
log.warn(
|
|
1271
|
-
{
|
|
1272
|
-
callSessionId: this.callSessionId,
|
|
1273
|
-
attempts: result.attempts,
|
|
1274
|
-
isOutbound,
|
|
1275
|
-
},
|
|
1276
|
-
"Guardian voice verification failed — max attempts reached",
|
|
1277
|
-
);
|
|
1278
|
-
|
|
1279
|
-
updateCallSession(this.callSessionId, {
|
|
1280
|
-
status: "failed",
|
|
1281
|
-
endedAt: Date.now(),
|
|
1282
|
-
lastError: "Guardian voice verification failed — max attempts exceeded",
|
|
1283
|
-
});
|
|
1284
|
-
|
|
1285
|
-
const failSession = getCallSession(this.callSessionId);
|
|
1286
|
-
if (failSession) {
|
|
1287
|
-
finalizeCall(this.callSessionId, failSession.conversationId);
|
|
1288
|
-
|
|
1289
|
-
if (isOutbound && failSession.initiatedFromConversationId) {
|
|
1290
|
-
addPointerMessage(
|
|
1291
|
-
failSession.initiatedFromConversationId,
|
|
1292
|
-
"verification_failed",
|
|
1293
|
-
failSession.toNumber,
|
|
1294
|
-
{
|
|
1295
|
-
channel: "phone",
|
|
1296
|
-
reason: "Max verification attempts exceeded",
|
|
1297
|
-
},
|
|
1298
|
-
).catch((err) => {
|
|
1299
|
-
log.warn(
|
|
1300
|
-
{
|
|
1301
|
-
conversationId: failSession.initiatedFromConversationId,
|
|
1302
|
-
err,
|
|
1303
|
-
},
|
|
1304
|
-
"Skipping pointer write — origin conversation may no longer exist",
|
|
1305
|
-
);
|
|
1306
|
-
});
|
|
1307
|
-
}
|
|
1308
|
-
}
|
|
1309
|
-
|
|
1310
|
-
await speakSystemPrompt(this, result.ttsMessage);
|
|
1311
|
-
setTimeout(() => {
|
|
1312
|
-
this.endSession("Verification failed — challenge rejected");
|
|
1313
|
-
}, getTtsPlaybackDelayMs());
|
|
1314
|
-
} else {
|
|
1315
|
-
// retry
|
|
1316
|
-
this.verificationAttempts = result.attempt;
|
|
1317
|
-
|
|
1318
|
-
log.info(
|
|
1319
|
-
{
|
|
1320
|
-
callSessionId: this.callSessionId,
|
|
1321
|
-
attempt: result.attempt,
|
|
1322
|
-
maxAttempts: result.maxAttempts,
|
|
1323
|
-
isOutbound,
|
|
1324
|
-
},
|
|
1325
|
-
"Guardian voice verification attempt failed — retrying",
|
|
1326
|
-
);
|
|
1327
|
-
void speakSystemPrompt(this, result.ttsMessage);
|
|
1328
|
-
}
|
|
1329
|
-
}
|
|
1330
|
-
|
|
1331
|
-
/**
|
|
1332
|
-
* Enter the invite redemption subflow for an inbound unknown caller
|
|
1333
|
-
* who has an active voice invite. Prompts the caller to enter their
|
|
1334
|
-
* invite code via DTMF or speech.
|
|
1335
|
-
*/
|
|
1336
|
-
private startInviteRedemption(
|
|
1337
|
-
assistantId: string,
|
|
1338
|
-
fromNumber: string,
|
|
1339
|
-
inviteeName: string | null,
|
|
1340
|
-
isOutbound: boolean,
|
|
1341
|
-
): void {
|
|
1342
|
-
this.inviteRedemptionActive = true;
|
|
1343
|
-
this.inviteRedemptionInFlight = false;
|
|
1344
|
-
this.inviteRedemptionAssistantId = assistantId;
|
|
1345
|
-
this.inviteRedemptionFromNumber = fromNumber;
|
|
1346
|
-
this.inviteRedemptionInviteeName = inviteeName;
|
|
1347
|
-
this.connectionState = "verification_pending";
|
|
1348
|
-
this.verificationAttempts = 0;
|
|
1349
|
-
this.verificationMaxAttempts = 1;
|
|
1350
|
-
this.inviteRedemptionCodeLength = 6;
|
|
1351
|
-
this.dtmfBuffer = "";
|
|
1352
|
-
|
|
1353
|
-
recordCallEvent(this.callSessionId, "invite_redemption_started", {
|
|
1354
|
-
assistantId,
|
|
1355
|
-
codeLength: 6,
|
|
1356
|
-
maxAttempts: this.verificationMaxAttempts,
|
|
1357
|
-
});
|
|
1358
|
-
|
|
1359
|
-
const displayFriend = firstToken(inviteeName) ?? "there";
|
|
1360
|
-
const displayGuardian = this.resolveGuardianLabel();
|
|
1361
|
-
|
|
1362
|
-
let promptText: string;
|
|
1363
|
-
if (isOutbound) {
|
|
1364
|
-
const assistantName = this.resolveAssistantLabel();
|
|
1365
|
-
promptText = assistantName
|
|
1366
|
-
? `Hi ${displayFriend}, this is ${assistantName}, ${displayGuardian}'s assistant. To get started, please enter the 6-digit code that ${displayGuardian} shared with you.`
|
|
1367
|
-
: `Hi ${displayFriend}, this is ${displayGuardian}'s assistant. To get started, please enter the 6-digit code that ${displayGuardian} shared with you.`;
|
|
1368
|
-
} else {
|
|
1369
|
-
promptText = `Welcome ${displayFriend}. Please enter the 6-digit code that ${displayGuardian} provided you to verify your identity.`;
|
|
1370
|
-
}
|
|
1371
|
-
void speakSystemPrompt(this, promptText);
|
|
1372
|
-
|
|
1373
|
-
log.info(
|
|
1374
|
-
{ callSessionId: this.callSessionId, assistantId },
|
|
1375
|
-
`${isOutbound ? "Outbound" : "Inbound"} voice invite redemption started`,
|
|
1376
|
-
);
|
|
1377
|
-
}
|
|
1378
|
-
|
|
1379
|
-
/**
|
|
1380
|
-
* Enter the name capture subflow for unknown inbound callers.
|
|
1381
|
-
* Prompts the caller to provide their name so we can include it
|
|
1382
|
-
* in the guardian notification.
|
|
1383
|
-
*/
|
|
1384
|
-
private startNameCapture(assistantId: string, fromNumber: string): void {
|
|
1385
|
-
this.accessRequestAssistantId = assistantId;
|
|
1386
|
-
this.accessRequestFromNumber = fromNumber;
|
|
1387
|
-
this.connectionState = "awaiting_name";
|
|
1388
|
-
|
|
1389
|
-
const guardianLabel = this.resolveGuardianLabel();
|
|
1390
|
-
const assistantName = this.resolveAssistantLabel();
|
|
1391
|
-
|
|
1392
|
-
const greeting = assistantName
|
|
1393
|
-
? `Hi, this is ${assistantName}, ${guardianLabel}'s assistant. Sorry, I don't recognize this number. I'll let ${guardianLabel} know you called and see if I have permission to speak with you. Can I get your name?`
|
|
1394
|
-
: `Hi, this is ${guardianLabel}'s assistant. Sorry, I don't recognize this number. I'll let ${guardianLabel} know you called and see if I have permission to speak with you. Can I get your name?`;
|
|
1395
|
-
|
|
1396
|
-
void speakSystemPrompt(this, greeting);
|
|
1397
|
-
|
|
1398
|
-
// Start a timeout so silent callers don't keep the call open indefinitely.
|
|
1399
|
-
// Uses a 30-second window — enough time to speak a name but short enough
|
|
1400
|
-
// to avoid wasting resources on callers who never respond.
|
|
1401
|
-
const NAME_CAPTURE_TIMEOUT_MS = 30_000;
|
|
1402
|
-
this.nameCaptureTimeoutTimer = setTimeout(() => {
|
|
1403
|
-
if (this.connectionState !== "awaiting_name") return;
|
|
1404
|
-
void this.handleNameCaptureTimeout();
|
|
1405
|
-
}, NAME_CAPTURE_TIMEOUT_MS);
|
|
1406
|
-
|
|
1407
|
-
log.info(
|
|
1408
|
-
{
|
|
1409
|
-
callSessionId: this.callSessionId,
|
|
1410
|
-
assistantId,
|
|
1411
|
-
timeoutMs: NAME_CAPTURE_TIMEOUT_MS,
|
|
1412
|
-
},
|
|
1413
|
-
"Name capture started for unknown inbound caller",
|
|
1414
|
-
);
|
|
1415
|
-
}
|
|
1416
|
-
|
|
1417
|
-
/**
|
|
1418
|
-
* Handle the caller's name response during the name capture subflow.
|
|
1419
|
-
* Creates a canonical access request, notifies the guardian, and
|
|
1420
|
-
* enters the bounded wait loop for the guardian decision.
|
|
1421
|
-
*/
|
|
1422
|
-
private async handleNameCaptureResponse(callerName: string): Promise<void> {
|
|
1423
|
-
if (!this.accessRequestAssistantId || !this.accessRequestFromNumber) {
|
|
1424
|
-
return;
|
|
1425
|
-
}
|
|
1426
|
-
|
|
1427
|
-
// Clear the name capture timeout since the caller responded.
|
|
1428
|
-
if (this.nameCaptureTimeoutTimer) {
|
|
1429
|
-
clearTimeout(this.nameCaptureTimeoutTimer);
|
|
1430
|
-
this.nameCaptureTimeoutTimer = null;
|
|
1431
|
-
}
|
|
1432
|
-
|
|
1433
|
-
this.accessRequestCallerName = callerName;
|
|
1434
|
-
|
|
1435
|
-
recordCallEvent(this.callSessionId, "inbound_acl_name_captured", {
|
|
1436
|
-
from: this.accessRequestFromNumber,
|
|
1437
|
-
callerName,
|
|
1438
|
-
});
|
|
1439
|
-
|
|
1440
|
-
// Create canonical access request and notify the guardian, including
|
|
1441
|
-
// the caller's spoken name and voice channel metadata.
|
|
1442
|
-
try {
|
|
1443
|
-
const accessResult = await notifyGuardianOfAccessRequest({
|
|
1444
|
-
canonicalAssistantId: this.accessRequestAssistantId,
|
|
1445
|
-
sourceChannel: "phone",
|
|
1446
|
-
conversationExternalId: this.accessRequestFromNumber,
|
|
1447
|
-
actorExternalId: this.accessRequestFromNumber,
|
|
1448
|
-
actorDisplayName: callerName,
|
|
1449
|
-
});
|
|
1450
|
-
|
|
1451
|
-
if (accessResult.notified) {
|
|
1452
|
-
this.accessRequestId = accessResult.requestId;
|
|
1453
|
-
log.info(
|
|
1454
|
-
{
|
|
1455
|
-
callSessionId: this.callSessionId,
|
|
1456
|
-
requestId: accessResult.requestId,
|
|
1457
|
-
callerName,
|
|
1458
|
-
},
|
|
1459
|
-
"Guardian notified of voice access request with caller name",
|
|
1460
|
-
);
|
|
1461
|
-
} else {
|
|
1462
|
-
log.warn(
|
|
1463
|
-
{ callSessionId: this.callSessionId },
|
|
1464
|
-
"Failed to notify guardian of voice access request — no sender ID",
|
|
1465
|
-
);
|
|
1466
|
-
}
|
|
1467
|
-
} catch (err) {
|
|
1468
|
-
log.error(
|
|
1469
|
-
{ err, callSessionId: this.callSessionId },
|
|
1470
|
-
"Failed to create access request for voice caller",
|
|
1471
|
-
);
|
|
1472
|
-
}
|
|
1473
|
-
|
|
1474
|
-
// If the access request was not successfully created (notifyGuardianOfAccessRequest
|
|
1475
|
-
// threw or returned notified: false), fail closed rather than leaving the caller
|
|
1476
|
-
// stuck on hold with no guardian poll target.
|
|
1477
|
-
if (!this.accessRequestId) {
|
|
1478
|
-
log.warn(
|
|
1479
|
-
{ callSessionId: this.callSessionId },
|
|
1480
|
-
"Access request ID is null after notification attempt — failing closed",
|
|
1481
|
-
);
|
|
1482
|
-
void this.handleAccessRequestTimeout();
|
|
1483
|
-
return;
|
|
1484
|
-
}
|
|
1485
|
-
|
|
1486
|
-
// Enter the bounded wait loop for the guardian decision
|
|
1487
|
-
this.startAccessRequestWait();
|
|
1488
|
-
}
|
|
1489
|
-
|
|
1490
|
-
/**
|
|
1491
|
-
* Start a bounded in-call wait loop polling the canonical request
|
|
1492
|
-
* status until approved, denied, or timeout.
|
|
1493
|
-
*/
|
|
1494
|
-
private startAccessRequestWait(): void {
|
|
1495
|
-
this.accessRequestWaitActive = true;
|
|
1496
|
-
this.connectionState = "awaiting_guardian_decision";
|
|
1497
|
-
|
|
1498
|
-
const timeoutMs = getUserConsultationTimeoutMs();
|
|
1499
|
-
const pollIntervalMs = getAccessRequestPollIntervalMs();
|
|
1500
|
-
|
|
1501
|
-
const guardianLabel = this.resolveGuardianLabel();
|
|
1502
|
-
void speakSystemPrompt(
|
|
1503
|
-
this,
|
|
1504
|
-
`Thank you. I've let ${guardianLabel} know. Please hold while I check if I have permission to speak with you.`,
|
|
1505
|
-
);
|
|
1506
|
-
|
|
1507
|
-
updateCallSession(this.callSessionId, { status: "waiting_on_user" });
|
|
1508
|
-
|
|
1509
|
-
// Start the heartbeat timer for periodic progress updates.
|
|
1510
|
-
// Delay the first heartbeat by the estimated TTS playback duration so
|
|
1511
|
-
// the initial hold message finishes before any heartbeat fires.
|
|
1512
|
-
this.heartbeatSequence = 0;
|
|
1513
|
-
// Set the wait start time now so scheduleNextHeartbeat() always has a
|
|
1514
|
-
// valid reference point — even if the TTS delay timer is cancelled early
|
|
1515
|
-
// (e.g. by handleWaitStatePrompt when the caller speaks during playback).
|
|
1516
|
-
// The callback below re-stamps it to exclude the TTS delay if it fires.
|
|
1517
|
-
this.accessRequestWaitStartedAt = Date.now();
|
|
1518
|
-
this.accessRequestHeartbeatTimer = setTimeout(() => {
|
|
1519
|
-
this.accessRequestWaitStartedAt = Date.now();
|
|
1520
|
-
this.scheduleNextHeartbeat();
|
|
1521
|
-
}, getTtsPlaybackDelayMs());
|
|
1522
|
-
|
|
1523
|
-
// Poll the canonical request status
|
|
1524
|
-
this.accessRequestPollTimer = setInterval(() => {
|
|
1525
|
-
if (!this.accessRequestWaitActive || !this.accessRequestId) {
|
|
1526
|
-
this.clearAccessRequestWait();
|
|
1527
|
-
return;
|
|
1528
|
-
}
|
|
1529
|
-
|
|
1530
|
-
const request = getCanonicalGuardianRequest(this.accessRequestId);
|
|
1531
|
-
if (!request) {
|
|
1532
|
-
return;
|
|
1533
|
-
}
|
|
1534
|
-
|
|
1535
|
-
if (request.status === "approved") {
|
|
1536
|
-
void this.handleAccessRequestApproved();
|
|
1537
|
-
} else if (request.status === "denied") {
|
|
1538
|
-
void this.handleAccessRequestDenied();
|
|
1539
|
-
}
|
|
1540
|
-
// 'pending' continues polling; 'expired'/'cancelled' handled by timeout
|
|
1541
|
-
}, pollIntervalMs);
|
|
1542
|
-
|
|
1543
|
-
// Timeout: give up waiting for the guardian
|
|
1544
|
-
this.accessRequestTimeoutTimer = setTimeout(() => {
|
|
1545
|
-
if (!this.accessRequestWaitActive) return;
|
|
1546
|
-
|
|
1547
|
-
log.info(
|
|
1548
|
-
{ callSessionId: this.callSessionId, requestId: this.accessRequestId },
|
|
1549
|
-
"Access request in-call wait timed out",
|
|
1550
|
-
);
|
|
1551
|
-
|
|
1552
|
-
void this.handleAccessRequestTimeout();
|
|
1553
|
-
}, timeoutMs);
|
|
1554
|
-
|
|
1555
|
-
log.info(
|
|
1556
|
-
{
|
|
1557
|
-
callSessionId: this.callSessionId,
|
|
1558
|
-
requestId: this.accessRequestId,
|
|
1559
|
-
timeoutMs,
|
|
1560
|
-
},
|
|
1561
|
-
"Access request in-call wait started",
|
|
1562
|
-
);
|
|
1563
|
-
}
|
|
1564
|
-
|
|
1565
|
-
/**
|
|
1566
|
-
* Clean up access request wait state (timers, flags).
|
|
1567
|
-
*/
|
|
1568
|
-
private clearAccessRequestWait(): void {
|
|
1569
|
-
this.accessRequestWaitActive = false;
|
|
1570
|
-
if (this.accessRequestPollTimer) {
|
|
1571
|
-
clearInterval(this.accessRequestPollTimer);
|
|
1572
|
-
this.accessRequestPollTimer = null;
|
|
1573
|
-
}
|
|
1574
|
-
if (this.accessRequestTimeoutTimer) {
|
|
1575
|
-
clearTimeout(this.accessRequestTimeoutTimer);
|
|
1576
|
-
this.accessRequestTimeoutTimer = null;
|
|
1577
|
-
}
|
|
1578
|
-
if (this.accessRequestHeartbeatTimer) {
|
|
1579
|
-
clearTimeout(this.accessRequestHeartbeatTimer);
|
|
1580
|
-
this.accessRequestHeartbeatTimer = null;
|
|
1581
|
-
}
|
|
1582
|
-
}
|
|
1583
|
-
|
|
1584
|
-
/**
|
|
1585
|
-
* Handle an approved access request: activate the caller as a trusted
|
|
1586
|
-
* contact, update runtime context, and continue with normal call flow.
|
|
1587
|
-
*/
|
|
1588
|
-
private async handleAccessRequestApproved(): Promise<void> {
|
|
1589
|
-
this.clearAccessRequestWait();
|
|
1590
|
-
|
|
1591
|
-
const assistantId = this.accessRequestAssistantId!;
|
|
1592
|
-
const fromNumber = this.accessRequestFromNumber!;
|
|
1593
|
-
const callerName = this.accessRequestCallerName;
|
|
1594
|
-
|
|
1595
|
-
recordCallEvent(this.callSessionId, "inbound_acl_access_approved", {
|
|
1596
|
-
from: fromNumber,
|
|
1597
|
-
callerName,
|
|
1598
|
-
requestId: this.accessRequestId,
|
|
1599
|
-
});
|
|
1600
|
-
|
|
1601
|
-
log.info(
|
|
1602
|
-
{ callSessionId: this.callSessionId, from: fromNumber },
|
|
1603
|
-
"Access request approved — caller activated and continuing call",
|
|
1604
|
-
);
|
|
1605
|
-
|
|
1606
|
-
await this.continueCallAfterTrustedContactActivation({
|
|
1607
|
-
assistantId,
|
|
1608
|
-
fromNumber,
|
|
1609
|
-
activationReason: "access_approved",
|
|
1610
|
-
});
|
|
1611
|
-
|
|
1612
|
-
recordCallEvent(
|
|
1613
|
-
this.callSessionId,
|
|
1614
|
-
"inbound_acl_post_approval_handoff_spoken",
|
|
1615
|
-
{
|
|
1616
|
-
from: fromNumber,
|
|
1617
|
-
},
|
|
1618
|
-
);
|
|
1619
|
-
}
|
|
1620
|
-
|
|
1621
|
-
/**
|
|
1622
|
-
* Handle a denied access request: deliver deterministic copy and hang up.
|
|
1623
|
-
*/
|
|
1624
|
-
private async handleAccessRequestDenied(): Promise<void> {
|
|
1625
|
-
this.clearAccessRequestWait();
|
|
1626
|
-
|
|
1627
|
-
const guardianLabel = this.resolveGuardianLabel();
|
|
1628
|
-
|
|
1629
|
-
recordCallEvent(this.callSessionId, "inbound_acl_access_denied", {
|
|
1630
|
-
from: this.accessRequestFromNumber,
|
|
1631
|
-
requestId: this.accessRequestId,
|
|
1632
|
-
});
|
|
1633
|
-
|
|
1634
|
-
this.connectionState = "disconnecting";
|
|
1635
|
-
|
|
1636
|
-
updateCallSession(this.callSessionId, {
|
|
1637
|
-
status: "failed",
|
|
1638
|
-
endedAt: Date.now(),
|
|
1639
|
-
lastError: "Inbound voice ACL: guardian denied access request",
|
|
1640
|
-
});
|
|
1641
|
-
|
|
1642
|
-
log.info(
|
|
1643
|
-
{ callSessionId: this.callSessionId },
|
|
1644
|
-
"Access request denied — ending call",
|
|
1645
|
-
);
|
|
1646
|
-
|
|
1647
|
-
await speakSystemPrompt(
|
|
1648
|
-
this,
|
|
1649
|
-
`Sorry, ${guardianLabel} says I'm not allowed to speak with you. Goodbye.`,
|
|
1650
|
-
);
|
|
1651
|
-
setTimeout(() => {
|
|
1652
|
-
this.endSession("Access request denied");
|
|
1653
|
-
}, getTtsPlaybackDelayMs());
|
|
1654
|
-
}
|
|
1655
|
-
|
|
1656
|
-
/**
|
|
1657
|
-
* Handle an access request timeout: deliver deterministic copy and hang up.
|
|
1658
|
-
*/
|
|
1659
|
-
private async handleAccessRequestTimeout(): Promise<void> {
|
|
1660
|
-
// Emit callback handoff notification before clearing wait state
|
|
1661
|
-
this.emitAccessRequestCallbackHandoffForReason("timeout");
|
|
1662
|
-
|
|
1663
|
-
this.clearAccessRequestWait();
|
|
1664
|
-
|
|
1665
|
-
const guardianLabel = this.resolveGuardianLabel();
|
|
1666
|
-
|
|
1667
|
-
recordCallEvent(this.callSessionId, "inbound_acl_access_timeout", {
|
|
1668
|
-
from: this.accessRequestFromNumber,
|
|
1669
|
-
requestId: this.accessRequestId,
|
|
1670
|
-
callbackOptIn: this.callbackOptIn,
|
|
1671
|
-
});
|
|
1672
|
-
|
|
1673
|
-
const callbackNote = this.callbackOptIn
|
|
1674
|
-
? ` I've noted that you'd like a callback — I'll pass that along to ${guardianLabel}.`
|
|
1675
|
-
: "";
|
|
1676
|
-
|
|
1677
|
-
this.connectionState = "disconnecting";
|
|
1678
|
-
|
|
1679
|
-
updateCallSession(this.callSessionId, {
|
|
1680
|
-
status: "failed",
|
|
1681
|
-
endedAt: Date.now(),
|
|
1682
|
-
lastError: "Inbound voice ACL: guardian approval wait timed out",
|
|
1683
|
-
});
|
|
1684
|
-
|
|
1685
|
-
log.info(
|
|
1686
|
-
{ callSessionId: this.callSessionId },
|
|
1687
|
-
"Access request timed out — ending call",
|
|
1688
|
-
);
|
|
1689
|
-
|
|
1690
|
-
await speakSystemPrompt(
|
|
1691
|
-
this,
|
|
1692
|
-
`Sorry, I can't get ahold of ${guardianLabel} right now. I'll let them know you called.${callbackNote}`,
|
|
1693
|
-
);
|
|
1694
|
-
setTimeout(() => {
|
|
1695
|
-
this.endSession("Access request timed out");
|
|
1696
|
-
}, getTtsPlaybackDelayMs());
|
|
1697
|
-
}
|
|
1698
|
-
|
|
1699
|
-
private emitAccessRequestCallbackHandoffForReason(
|
|
1700
|
-
reason: "timeout" | "transport_closed",
|
|
1701
|
-
): void {
|
|
1702
|
-
const result = emitAccessRequestCallbackHandoff({
|
|
1703
|
-
reason,
|
|
1704
|
-
callbackOptIn: this.callbackOptIn,
|
|
1705
|
-
accessRequestId: this.accessRequestId,
|
|
1706
|
-
callbackHandoffNotified: this.callbackHandoffNotified,
|
|
1707
|
-
accessRequestAssistantId: this.accessRequestAssistantId,
|
|
1708
|
-
accessRequestFromNumber: this.accessRequestFromNumber,
|
|
1709
|
-
accessRequestCallerName: this.accessRequestCallerName,
|
|
1710
|
-
callSessionId: this.callSessionId,
|
|
1711
|
-
});
|
|
1712
|
-
this.callbackHandoffNotified = result.callbackHandoffNotified;
|
|
1713
|
-
}
|
|
1714
|
-
|
|
1715
|
-
/**
|
|
1716
|
-
* Handle a name capture timeout: the caller never provided their name
|
|
1717
|
-
* within the allotted window. Deliver deterministic copy and hang up.
|
|
1718
|
-
*/
|
|
1719
|
-
private async handleNameCaptureTimeout(): Promise<void> {
|
|
1720
|
-
if (this.nameCaptureTimeoutTimer) {
|
|
1721
|
-
clearTimeout(this.nameCaptureTimeoutTimer);
|
|
1722
|
-
this.nameCaptureTimeoutTimer = null;
|
|
1723
|
-
}
|
|
1724
|
-
|
|
1725
|
-
recordCallEvent(this.callSessionId, "inbound_acl_name_capture_timeout", {
|
|
1726
|
-
from: this.accessRequestFromNumber,
|
|
1727
|
-
});
|
|
1728
|
-
|
|
1729
|
-
this.connectionState = "disconnecting";
|
|
1730
|
-
|
|
1731
|
-
updateCallSession(this.callSessionId, {
|
|
1732
|
-
status: "failed",
|
|
1733
|
-
endedAt: Date.now(),
|
|
1734
|
-
lastError: "Inbound voice ACL: name capture timed out",
|
|
1735
|
-
});
|
|
1736
|
-
|
|
1737
|
-
log.info(
|
|
1738
|
-
{ callSessionId: this.callSessionId },
|
|
1739
|
-
"Name capture timed out — ending call",
|
|
1740
|
-
);
|
|
1741
|
-
|
|
1742
|
-
await speakSystemPrompt(
|
|
1743
|
-
this,
|
|
1744
|
-
"Sorry, I didn't catch your name. Please try calling back. Goodbye.",
|
|
1745
|
-
);
|
|
1746
|
-
setTimeout(() => {
|
|
1747
|
-
this.endSession("Name capture timed out");
|
|
1748
|
-
}, getTtsPlaybackDelayMs());
|
|
1749
|
-
}
|
|
1750
|
-
|
|
1751
|
-
/**
|
|
1752
|
-
* Validate an entered invite code against active voice invites.
|
|
1753
|
-
* Delegates to the extracted attemptInviteCodeRedemption() and
|
|
1754
|
-
* interprets the structured result to drive side-effects.
|
|
1755
|
-
*/
|
|
1756
|
-
private async handleInviteCodeRedemptionResult(
|
|
1757
|
-
enteredCode: string,
|
|
1758
|
-
): Promise<void> {
|
|
1759
|
-
if (!this.inviteRedemptionAssistantId || !this.inviteRedemptionFromNumber) {
|
|
1760
|
-
return;
|
|
1761
|
-
}
|
|
1762
|
-
|
|
1763
|
-
// Dedup concurrent attempts: a repeated code (re-spoken / re-entered)
|
|
1764
|
-
// arriving while the async gateway-backed claim is still in flight is
|
|
1765
|
-
// ignored, so we never run a second redeemVoiceInviteCode that would see
|
|
1766
|
-
// the invite already consumed and wrongly mark the call failed. The flag
|
|
1767
|
-
// is set synchronously before awaiting and cleared in finally.
|
|
1768
|
-
if (this.inviteRedemptionInFlight) {
|
|
1769
|
-
log.info(
|
|
1770
|
-
{ callSessionId: this.callSessionId },
|
|
1771
|
-
"Ignoring repeated invite code — redemption already in flight",
|
|
1772
|
-
);
|
|
1773
|
-
return;
|
|
1774
|
-
}
|
|
1775
|
-
this.inviteRedemptionInFlight = true;
|
|
1776
|
-
|
|
1777
|
-
try {
|
|
1778
|
-
await this.runInviteCodeRedemption(enteredCode);
|
|
1779
|
-
} finally {
|
|
1780
|
-
this.inviteRedemptionInFlight = false;
|
|
1781
|
-
}
|
|
1782
|
-
}
|
|
1783
|
-
|
|
1784
|
-
private async runInviteCodeRedemption(enteredCode: string): Promise<void> {
|
|
1785
|
-
if (!this.inviteRedemptionAssistantId || !this.inviteRedemptionFromNumber) {
|
|
1786
|
-
return;
|
|
1787
|
-
}
|
|
1788
|
-
|
|
1789
|
-
const result = await attemptInviteCodeRedemption({
|
|
1790
|
-
inviteRedemptionAssistantId: this.inviteRedemptionAssistantId,
|
|
1791
|
-
inviteRedemptionFromNumber: this.inviteRedemptionFromNumber,
|
|
1792
|
-
enteredCode,
|
|
1793
|
-
guardianLabel: this.resolveGuardianLabel(),
|
|
1794
|
-
});
|
|
1795
|
-
|
|
1796
|
-
if (result.outcome === "success") {
|
|
1797
|
-
this.inviteRedemptionActive = false;
|
|
1798
|
-
this.verificationAttempts = 0;
|
|
1799
|
-
this.dtmfBuffer = "";
|
|
1800
|
-
|
|
1801
|
-
recordCallEvent(this.callSessionId, "invite_redemption_succeeded", {
|
|
1802
|
-
memberId: result.memberId,
|
|
1803
|
-
...(result.inviteId ? { inviteId: result.inviteId } : {}),
|
|
1804
|
-
});
|
|
1805
|
-
log.info(
|
|
1806
|
-
{
|
|
1807
|
-
callSessionId: this.callSessionId,
|
|
1808
|
-
memberId: result.memberId,
|
|
1809
|
-
type: result.type,
|
|
1810
|
-
},
|
|
1811
|
-
"Voice invite redemption succeeded",
|
|
1812
|
-
);
|
|
1813
|
-
|
|
1814
|
-
await this.continueCallAfterTrustedContactActivation({
|
|
1815
|
-
assistantId: this.inviteRedemptionAssistantId,
|
|
1816
|
-
fromNumber: this.inviteRedemptionFromNumber,
|
|
1817
|
-
activationReason: "invite_redeemed",
|
|
1818
|
-
inviteeName: this.inviteRedemptionInviteeName,
|
|
1819
|
-
});
|
|
1820
|
-
} else {
|
|
1821
|
-
this.inviteRedemptionActive = false;
|
|
1822
|
-
|
|
1823
|
-
recordCallEvent(this.callSessionId, "invite_redemption_failed", {
|
|
1824
|
-
attempts: 1,
|
|
1825
|
-
});
|
|
1826
|
-
log.warn(
|
|
1827
|
-
{ callSessionId: this.callSessionId },
|
|
1828
|
-
"Voice invite redemption failed — invalid or expired code",
|
|
1829
|
-
);
|
|
1830
|
-
|
|
1831
|
-
this.connectionState = "disconnecting";
|
|
1832
|
-
|
|
1833
|
-
updateCallSession(this.callSessionId, {
|
|
1834
|
-
status: "failed",
|
|
1835
|
-
endedAt: Date.now(),
|
|
1836
|
-
lastError: "Voice invite redemption failed — invalid or expired code",
|
|
1837
|
-
});
|
|
1838
|
-
|
|
1839
|
-
const failSession = getCallSession(this.callSessionId);
|
|
1840
|
-
if (failSession) {
|
|
1841
|
-
finalizeCall(this.callSessionId, failSession.conversationId);
|
|
1842
|
-
}
|
|
1843
|
-
|
|
1844
|
-
await speakSystemPrompt(this, result.ttsMessage);
|
|
1845
|
-
setTimeout(() => {
|
|
1846
|
-
this.endSession("Invite redemption failed");
|
|
1847
|
-
}, getTtsPlaybackDelayMs());
|
|
1848
|
-
}
|
|
1849
|
-
}
|
|
1850
|
-
|
|
1851
|
-
// ── Guardian wait UX layer ─────────────────────────────────────
|
|
1852
|
-
|
|
1853
|
-
/**
|
|
1854
|
-
* Resolve a human-readable guardian label for voice wait copy.
|
|
1855
|
-
* Delegates to the shared resolveGuardianName() which checks the
|
|
1856
|
-
* guardian's per-user persona file (users/<slug>.md) first, then falls
|
|
1857
|
-
* back to the primed gateway-binding displayName, then
|
|
1858
|
-
* DEFAULT_USER_REFERENCE.
|
|
1859
|
-
*/
|
|
1860
|
-
private resolveGuardianLabel(): string {
|
|
1861
|
-
return resolveGuardianName(this.primedGuardianDisplayName);
|
|
1862
|
-
}
|
|
1863
|
-
|
|
1864
|
-
/**
|
|
1865
|
-
* Prime the guardian displayName from the gateway binding so the
|
|
1866
|
-
* synchronous wait-label path can read it without an IPC round-trip.
|
|
1867
|
-
*/
|
|
1868
|
-
private async primeGuardianDisplayName(): Promise<void> {
|
|
1869
|
-
const list = await getGuardianDelivery();
|
|
1870
|
-
this.primedGuardianDisplayName = voiceGuardianDisplayName(list);
|
|
1871
|
-
}
|
|
1872
|
-
|
|
1873
|
-
/**
|
|
1874
|
-
* Resolve the assistant's display name from identity configuration.
|
|
1875
|
-
* Returns the trimmed name or null if unavailable.
|
|
1876
|
-
*/
|
|
1877
|
-
private resolveAssistantLabel(): string | null {
|
|
1878
|
-
try {
|
|
1879
|
-
const name = getAssistantName();
|
|
1880
|
-
const trimmedName = name?.trim();
|
|
1881
|
-
if (!trimmedName || UUID_SHAPED_NAME.test(trimmedName)) {
|
|
1882
|
-
return null;
|
|
1883
|
-
}
|
|
1884
|
-
return trimmedName;
|
|
1885
|
-
} catch {
|
|
1886
|
-
return null;
|
|
1887
|
-
}
|
|
1888
|
-
}
|
|
1889
|
-
|
|
1890
|
-
private scheduleNextHeartbeat(): void {
|
|
1891
|
-
this.accessRequestHeartbeatTimer = scheduleNextHeartbeat({
|
|
1892
|
-
isWaitActive: () => this.accessRequestWaitActive,
|
|
1893
|
-
accessRequestWaitStartedAt: this.accessRequestWaitStartedAt,
|
|
1894
|
-
callSessionId: this.callSessionId,
|
|
1895
|
-
consumeSequence: () => this.heartbeatSequence++,
|
|
1896
|
-
resolveGuardianLabel: () => this.resolveGuardianLabel(),
|
|
1897
|
-
sendTextToken: (text, _last) => void speakSystemPrompt(this, text),
|
|
1898
|
-
scheduleNext: () => this.scheduleNextHeartbeat(),
|
|
1899
|
-
});
|
|
1900
|
-
}
|
|
1901
|
-
|
|
1902
|
-
private classifyWaitUtterance(text: string) {
|
|
1903
|
-
return classifyWaitUtterance(text, this.callbackOfferMade);
|
|
1904
|
-
}
|
|
1905
|
-
|
|
1906
|
-
/**
|
|
1907
|
-
* Handle a caller utterance during the guardian decision wait state.
|
|
1908
|
-
* Provides reassurance, impatience detection, and callback offer.
|
|
1909
|
-
*/
|
|
1910
|
-
private handleWaitStatePrompt(text: string): void {
|
|
1911
|
-
const now = Date.now();
|
|
1912
|
-
const classification = this.classifyWaitUtterance(text);
|
|
1913
|
-
|
|
1914
|
-
recordCallEvent(
|
|
1915
|
-
this.callSessionId,
|
|
1916
|
-
"voice_guardian_wait_prompt_classified",
|
|
1917
|
-
{
|
|
1918
|
-
classification,
|
|
1919
|
-
transcript: text,
|
|
1920
|
-
},
|
|
1921
|
-
);
|
|
1922
|
-
|
|
1923
|
-
if (classification === "empty") return;
|
|
1924
|
-
|
|
1925
|
-
const guardianLabel = this.resolveGuardianLabel();
|
|
1926
|
-
|
|
1927
|
-
// Callback decisions must always be processed regardless of cooldown —
|
|
1928
|
-
// the caller is answering a direct question and dropping their response
|
|
1929
|
-
// would silently discard their decision.
|
|
1930
|
-
switch (classification) {
|
|
1931
|
-
case "callback_opt_in": {
|
|
1932
|
-
this.callbackOptIn = true;
|
|
1933
|
-
this.lastInWaitReplyAt = now;
|
|
1934
|
-
recordCallEvent(
|
|
1935
|
-
this.callSessionId,
|
|
1936
|
-
"voice_guardian_wait_callback_opt_in_set",
|
|
1937
|
-
{},
|
|
1938
|
-
);
|
|
1939
|
-
if (this.accessRequestHeartbeatTimer) {
|
|
1940
|
-
clearTimeout(this.accessRequestHeartbeatTimer);
|
|
1941
|
-
this.accessRequestHeartbeatTimer = null;
|
|
1942
|
-
}
|
|
1943
|
-
void speakSystemPrompt(
|
|
1944
|
-
this,
|
|
1945
|
-
`Noted, I'll make sure ${guardianLabel} knows you'd like a callback. For now, I'll keep trying to reach them.`,
|
|
1946
|
-
);
|
|
1947
|
-
this.scheduleNextHeartbeat();
|
|
1948
|
-
return;
|
|
1949
|
-
}
|
|
1950
|
-
case "callback_decline": {
|
|
1951
|
-
this.callbackOptIn = false;
|
|
1952
|
-
this.lastInWaitReplyAt = now;
|
|
1953
|
-
recordCallEvent(
|
|
1954
|
-
this.callSessionId,
|
|
1955
|
-
"voice_guardian_wait_callback_opt_in_declined",
|
|
1956
|
-
{},
|
|
1957
|
-
);
|
|
1958
|
-
if (this.accessRequestHeartbeatTimer) {
|
|
1959
|
-
clearTimeout(this.accessRequestHeartbeatTimer);
|
|
1960
|
-
this.accessRequestHeartbeatTimer = null;
|
|
1961
|
-
}
|
|
1962
|
-
void speakSystemPrompt(
|
|
1963
|
-
this,
|
|
1964
|
-
`No problem, I'll keep holding. Still waiting on ${guardianLabel}.`,
|
|
1965
|
-
);
|
|
1966
|
-
this.scheduleNextHeartbeat();
|
|
1967
|
-
return;
|
|
1968
|
-
}
|
|
1969
|
-
default:
|
|
1970
|
-
break;
|
|
1971
|
-
}
|
|
1972
|
-
|
|
1973
|
-
// Enforce cooldown on non-callback utterances to prevent spam
|
|
1974
|
-
if (
|
|
1975
|
-
now - this.lastInWaitReplyAt <
|
|
1976
|
-
RelayConnection.IN_WAIT_REPLY_COOLDOWN_MS
|
|
1977
|
-
) {
|
|
1978
|
-
log.debug(
|
|
1979
|
-
{ callSessionId: this.callSessionId },
|
|
1980
|
-
"In-wait reply suppressed by cooldown",
|
|
1981
|
-
);
|
|
1982
|
-
return;
|
|
1983
|
-
}
|
|
1984
|
-
this.lastInWaitReplyAt = now;
|
|
1985
|
-
|
|
1986
|
-
switch (classification) {
|
|
1987
|
-
case "impatient": {
|
|
1988
|
-
if (this.accessRequestHeartbeatTimer) {
|
|
1989
|
-
clearTimeout(this.accessRequestHeartbeatTimer);
|
|
1990
|
-
this.accessRequestHeartbeatTimer = null;
|
|
1991
|
-
}
|
|
1992
|
-
if (!this.callbackOfferMade) {
|
|
1993
|
-
this.callbackOfferMade = true;
|
|
1994
|
-
recordCallEvent(
|
|
1995
|
-
this.callSessionId,
|
|
1996
|
-
"voice_guardian_wait_callback_offer_sent",
|
|
1997
|
-
{},
|
|
1998
|
-
);
|
|
1999
|
-
void speakSystemPrompt(
|
|
2000
|
-
this,
|
|
2001
|
-
`I understand this is taking a while. I can have ${guardianLabel} call you back once I hear from them. Would you like that, or would you prefer to keep holding?`,
|
|
2002
|
-
);
|
|
2003
|
-
} else {
|
|
2004
|
-
// Already offered callback — just reassure
|
|
2005
|
-
void speakSystemPrompt(
|
|
2006
|
-
this,
|
|
2007
|
-
`I hear you, I'm sorry for the wait. Still trying to reach ${guardianLabel}.`,
|
|
2008
|
-
);
|
|
2009
|
-
}
|
|
2010
|
-
this.scheduleNextHeartbeat();
|
|
2011
|
-
break;
|
|
2012
|
-
}
|
|
2013
|
-
case "patience_check": {
|
|
2014
|
-
// Immediate reassurance — reset the heartbeat timer so we
|
|
2015
|
-
// don't double up with a scheduled heartbeat
|
|
2016
|
-
if (this.accessRequestHeartbeatTimer) {
|
|
2017
|
-
clearTimeout(this.accessRequestHeartbeatTimer);
|
|
2018
|
-
this.accessRequestHeartbeatTimer = null;
|
|
2019
|
-
}
|
|
2020
|
-
void speakSystemPrompt(
|
|
2021
|
-
this,
|
|
2022
|
-
`Yes, I'm still here. Still waiting to hear back from ${guardianLabel}.`,
|
|
2023
|
-
);
|
|
2024
|
-
this.scheduleNextHeartbeat();
|
|
2025
|
-
break;
|
|
2026
|
-
}
|
|
2027
|
-
case "neutral":
|
|
2028
|
-
default: {
|
|
2029
|
-
if (this.accessRequestHeartbeatTimer) {
|
|
2030
|
-
clearTimeout(this.accessRequestHeartbeatTimer);
|
|
2031
|
-
this.accessRequestHeartbeatTimer = null;
|
|
2032
|
-
}
|
|
2033
|
-
void speakSystemPrompt(
|
|
2034
|
-
this,
|
|
2035
|
-
`Thanks for that. I'm still waiting on ${guardianLabel}. I'll let you know as soon as I hear back.`,
|
|
2036
|
-
);
|
|
2037
|
-
this.scheduleNextHeartbeat();
|
|
2038
|
-
break;
|
|
2039
|
-
}
|
|
2040
|
-
}
|
|
2041
|
-
}
|
|
2042
|
-
|
|
2043
|
-
private async handlePrompt(msg: RelayPromptMessage): Promise<void> {
|
|
2044
|
-
if (this.connectionState === "disconnecting") {
|
|
2045
|
-
return;
|
|
2046
|
-
}
|
|
2047
|
-
|
|
2048
|
-
// Defer (don't drop) prompts while trust is being re-resolved mid-call so a
|
|
2049
|
-
// verified caller's first utterance runs under the upgraded context, not the
|
|
2050
|
-
// stale pre-verification one. flushDeferredPrompts replays them in order
|
|
2051
|
-
// once the new context lands.
|
|
2052
|
-
if (this.trustReResolving) {
|
|
2053
|
-
this.trustReResolvePending.push(msg);
|
|
2054
|
-
return;
|
|
2055
|
-
}
|
|
2056
|
-
|
|
2057
|
-
// Prompts arriving before setup routing/ACL completes are dropped — never
|
|
2058
|
-
// persisted or emitted — so a not-yet-authorized caller's speech can't be
|
|
2059
|
-
// stored ahead of the admission floor / trust ACL decision.
|
|
2060
|
-
if (this.connectionState === "setting_up") {
|
|
2061
|
-
return;
|
|
2062
|
-
}
|
|
2063
|
-
|
|
2064
|
-
if (!msg.last) {
|
|
2065
|
-
// Partial transcript, wait for final
|
|
2066
|
-
return;
|
|
2067
|
-
}
|
|
2068
|
-
|
|
2069
|
-
// During name capture, the caller's response is their name.
|
|
2070
|
-
if (this.connectionState === "awaiting_name") {
|
|
2071
|
-
const callerName = msg.voicePrompt.trim();
|
|
2072
|
-
if (!callerName) {
|
|
2073
|
-
// Whitespace-only or empty transcript (e.g. silence/noise) —
|
|
2074
|
-
// keep waiting for a real name. The name-capture timeout will
|
|
2075
|
-
// still fire if the caller never provides one.
|
|
2076
|
-
return;
|
|
2077
|
-
}
|
|
2078
|
-
log.info(
|
|
2079
|
-
{ callSessionId: this.callSessionId, callerName },
|
|
2080
|
-
"Name captured from unknown inbound caller",
|
|
2081
|
-
);
|
|
2082
|
-
await this.handleNameCaptureResponse(callerName);
|
|
2083
|
-
return;
|
|
2084
|
-
}
|
|
2085
|
-
|
|
2086
|
-
// During guardian decision wait, classify caller speech for
|
|
2087
|
-
// reassurance, impatience detection, and callback offer.
|
|
2088
|
-
if (this.connectionState === "awaiting_guardian_decision") {
|
|
2089
|
-
this.handleWaitStatePrompt(msg.voicePrompt);
|
|
2090
|
-
return;
|
|
2091
|
-
}
|
|
2092
|
-
|
|
2093
|
-
// During guardian verification (inbound or outbound), attempt to parse
|
|
2094
|
-
// spoken digits from the transcript and validate them.
|
|
2095
|
-
if (
|
|
2096
|
-
this.connectionState === "verification_pending" &&
|
|
2097
|
-
this.verificationSessionActive
|
|
2098
|
-
) {
|
|
2099
|
-
const spokenDigits = parseDigitsFromSpeech(msg.voicePrompt);
|
|
2100
|
-
log.info(
|
|
2101
|
-
{
|
|
2102
|
-
callSessionId: this.callSessionId,
|
|
2103
|
-
transcript: msg.voicePrompt,
|
|
2104
|
-
spokenDigits,
|
|
2105
|
-
},
|
|
2106
|
-
"Speech received during guardian voice verification",
|
|
2107
|
-
);
|
|
2108
|
-
if (spokenDigits.length >= this.verificationCodeLength) {
|
|
2109
|
-
const enteredCode = spokenDigits.slice(0, this.verificationCodeLength);
|
|
2110
|
-
void this.handleVerificationCodeResult(enteredCode);
|
|
2111
|
-
} else if (spokenDigits.length > 0) {
|
|
2112
|
-
void speakSystemPrompt(
|
|
2113
|
-
this,
|
|
2114
|
-
`I heard ${spokenDigits.length} digits. Please enter all ${this.verificationCodeLength} digits of your code.`,
|
|
2115
|
-
);
|
|
2116
|
-
}
|
|
2117
|
-
return;
|
|
2118
|
-
}
|
|
2119
|
-
|
|
2120
|
-
// During invite redemption, attempt to parse spoken digits from the
|
|
2121
|
-
// transcript and validate against the caller's active voice invite.
|
|
2122
|
-
if (
|
|
2123
|
-
this.connectionState === "verification_pending" &&
|
|
2124
|
-
this.inviteRedemptionActive
|
|
2125
|
-
) {
|
|
2126
|
-
const spokenDigits = parseDigitsFromSpeech(msg.voicePrompt);
|
|
2127
|
-
log.info(
|
|
2128
|
-
{
|
|
2129
|
-
callSessionId: this.callSessionId,
|
|
2130
|
-
transcript: msg.voicePrompt,
|
|
2131
|
-
spokenDigits,
|
|
2132
|
-
},
|
|
2133
|
-
"Speech received during invite redemption",
|
|
2134
|
-
);
|
|
2135
|
-
if (spokenDigits.length >= this.inviteRedemptionCodeLength) {
|
|
2136
|
-
const enteredCode = spokenDigits.slice(
|
|
2137
|
-
0,
|
|
2138
|
-
this.inviteRedemptionCodeLength,
|
|
2139
|
-
);
|
|
2140
|
-
void this.handleInviteCodeRedemptionResult(enteredCode);
|
|
2141
|
-
} else if (spokenDigits.length > 0) {
|
|
2142
|
-
void speakSystemPrompt(
|
|
2143
|
-
this,
|
|
2144
|
-
`I heard ${spokenDigits.length} digits. Please enter all ${this.inviteRedemptionCodeLength} digits of your code.`,
|
|
2145
|
-
);
|
|
2146
|
-
}
|
|
2147
|
-
return;
|
|
2148
|
-
}
|
|
2149
|
-
|
|
2150
|
-
// During outbound callee verification, ignore voice prompts — the callee
|
|
2151
|
-
// should be entering DTMF digits, not speaking.
|
|
2152
|
-
if (this.connectionState === "verification_pending") {
|
|
2153
|
-
log.debug(
|
|
2154
|
-
{ callSessionId: this.callSessionId },
|
|
2155
|
-
"Ignoring voice prompt during callee verification",
|
|
2156
|
-
);
|
|
2157
|
-
return;
|
|
2158
|
-
}
|
|
2159
|
-
|
|
2160
|
-
log.info(
|
|
2161
|
-
{
|
|
2162
|
-
callSessionId: this.callSessionId,
|
|
2163
|
-
transcript: msg.voicePrompt,
|
|
2164
|
-
lang: msg.lang,
|
|
2165
|
-
},
|
|
2166
|
-
"Caller transcript received (final)",
|
|
2167
|
-
);
|
|
2168
|
-
|
|
2169
|
-
// Spread to widen the typed message into a plain record — extractPromptSpeakerMetadata
|
|
2170
|
-
// probes for snake_case and nested property variants not on RelayPromptMessage.
|
|
2171
|
-
const speakerMetadata = extractPromptSpeakerMetadata({ ...msg });
|
|
2172
|
-
const speaker =
|
|
2173
|
-
this.speakerIdentityTracker.identifySpeaker(speakerMetadata);
|
|
2174
|
-
|
|
2175
|
-
// Record in conversation history
|
|
2176
|
-
this.conversationHistory.push({
|
|
2177
|
-
role: "caller",
|
|
2178
|
-
text: msg.voicePrompt,
|
|
2179
|
-
timestamp: Date.now(),
|
|
2180
|
-
speaker,
|
|
2181
|
-
});
|
|
2182
|
-
|
|
2183
|
-
// Record event
|
|
2184
|
-
recordCallEvent(this.callSessionId, "caller_spoke", {
|
|
2185
|
-
transcript: msg.voicePrompt,
|
|
2186
|
-
lang: msg.lang,
|
|
2187
|
-
speakerId: speaker.speakerId,
|
|
2188
|
-
speakerLabel: speaker.speakerLabel,
|
|
2189
|
-
speakerConfidence: speaker.speakerConfidence,
|
|
2190
|
-
speakerSource: speaker.source,
|
|
2191
|
-
});
|
|
2192
|
-
|
|
2193
|
-
const session = getCallSession(this.callSessionId);
|
|
2194
|
-
if (session) {
|
|
2195
|
-
// User message persistence is handled by the conversation pipeline
|
|
2196
|
-
// (voice-session-bridge -> conversation.persistUserMessage) so we only
|
|
2197
|
-
// need to fire the transcript notifier for UI subscribers here.
|
|
2198
|
-
fireCallTranscriptNotifier(
|
|
2199
|
-
session.conversationId,
|
|
2200
|
-
this.callSessionId,
|
|
2201
|
-
"caller",
|
|
2202
|
-
msg.voicePrompt,
|
|
2203
|
-
);
|
|
2204
|
-
}
|
|
2205
|
-
|
|
2206
|
-
// Route to controller for conversation-backed response
|
|
2207
|
-
if (this.controller) {
|
|
2208
|
-
await this.controller.handleCallerUtterance(msg.voicePrompt, speaker);
|
|
2209
|
-
} else {
|
|
2210
|
-
// Fallback if controller not yet initialized — persist the caller's
|
|
2211
|
-
// transcript so it is available in conversation history once setup
|
|
2212
|
-
// completes. The conversation pipeline normally handles persistence, but
|
|
2213
|
-
// this early-utterance path bypasses it entirely.
|
|
2214
|
-
if (session) {
|
|
2215
|
-
try {
|
|
2216
|
-
await addMessage(
|
|
2217
|
-
session.conversationId,
|
|
2218
|
-
"user",
|
|
2219
|
-
JSON.stringify([{ type: "text", text: msg.voicePrompt }]),
|
|
2220
|
-
{
|
|
2221
|
-
metadata: {
|
|
2222
|
-
userMessageChannel: "phone",
|
|
2223
|
-
assistantMessageChannel: "phone",
|
|
2224
|
-
userMessageInterface: "phone",
|
|
2225
|
-
assistantMessageInterface: "phone",
|
|
2226
|
-
},
|
|
2227
|
-
},
|
|
2228
|
-
);
|
|
2229
|
-
} catch (err) {
|
|
2230
|
-
// Best-effort — don't let persistence failures prevent the hold
|
|
2231
|
-
// response from reaching the caller.
|
|
2232
|
-
log.warn(
|
|
2233
|
-
{ err, callSessionId: this.callSessionId },
|
|
2234
|
-
"Failed to persist early caller utterance",
|
|
2235
|
-
);
|
|
2236
|
-
}
|
|
2237
|
-
}
|
|
2238
|
-
void speakSystemPrompt(this, "I'm still setting up. Please hold.");
|
|
2239
|
-
}
|
|
2240
|
-
}
|
|
2241
|
-
|
|
2242
|
-
private handleInterrupt(msg: RelayInterruptMessage): void {
|
|
2243
|
-
log.info(
|
|
2244
|
-
{
|
|
2245
|
-
callSessionId: this.callSessionId,
|
|
2246
|
-
utteranceUntilInterrupt: msg.utteranceUntilInterrupt,
|
|
2247
|
-
},
|
|
2248
|
-
"Caller interrupted assistant",
|
|
2249
|
-
);
|
|
2250
|
-
|
|
2251
|
-
// Abort any in-flight processing
|
|
2252
|
-
this.abortController.abort();
|
|
2253
|
-
this.abortController = new AbortController();
|
|
2254
|
-
|
|
2255
|
-
// Notify the controller of the interruption
|
|
2256
|
-
if (this.controller) {
|
|
2257
|
-
this.controller.handleInterrupt();
|
|
2258
|
-
}
|
|
2259
|
-
}
|
|
2260
|
-
|
|
2261
|
-
private handleDtmf(msg: RelayDtmfMessage): void {
|
|
2262
|
-
if (this.connectionState === "disconnecting") {
|
|
2263
|
-
return;
|
|
2264
|
-
}
|
|
2265
|
-
|
|
2266
|
-
// Drop DTMF arriving before setup routing/ACL completes (same reasoning as
|
|
2267
|
-
// handlePrompt): don't record input from a not-yet-authorized caller.
|
|
2268
|
-
if (this.connectionState === "setting_up") {
|
|
2269
|
-
return;
|
|
2270
|
-
}
|
|
2271
|
-
|
|
2272
|
-
// Ignore DTMF during name capture and guardian decision wait
|
|
2273
|
-
if (
|
|
2274
|
-
this.connectionState === "awaiting_name" ||
|
|
2275
|
-
this.connectionState === "awaiting_guardian_decision"
|
|
2276
|
-
) {
|
|
2277
|
-
return;
|
|
2278
|
-
}
|
|
2279
|
-
|
|
2280
|
-
log.info(
|
|
2281
|
-
{ callSessionId: this.callSessionId, digit: msg.digit },
|
|
2282
|
-
"DTMF digit received",
|
|
2283
|
-
);
|
|
2284
|
-
|
|
2285
|
-
recordCallEvent(this.callSessionId, "caller_spoke", {
|
|
2286
|
-
dtmfDigit: msg.digit,
|
|
2287
|
-
});
|
|
2288
|
-
|
|
2289
|
-
// If guardian verification (inbound or outbound) is pending, accumulate
|
|
2290
|
-
// digits and validate against the challenge via the guardian service.
|
|
2291
|
-
if (
|
|
2292
|
-
this.connectionState === "verification_pending" &&
|
|
2293
|
-
this.verificationSessionActive
|
|
2294
|
-
) {
|
|
2295
|
-
this.dtmfBuffer += msg.digit;
|
|
2296
|
-
|
|
2297
|
-
if (this.dtmfBuffer.length >= this.verificationCodeLength) {
|
|
2298
|
-
const enteredCode = this.dtmfBuffer.slice(
|
|
2299
|
-
0,
|
|
2300
|
-
this.verificationCodeLength,
|
|
2301
|
-
);
|
|
2302
|
-
this.dtmfBuffer = "";
|
|
2303
|
-
void this.handleVerificationCodeResult(enteredCode);
|
|
2304
|
-
}
|
|
2305
|
-
return;
|
|
2306
|
-
}
|
|
2307
|
-
|
|
2308
|
-
// If invite redemption is pending, accumulate digits and validate
|
|
2309
|
-
// the code against the caller's active voice invite.
|
|
2310
|
-
if (
|
|
2311
|
-
this.connectionState === "verification_pending" &&
|
|
2312
|
-
this.inviteRedemptionActive
|
|
2313
|
-
) {
|
|
2314
|
-
this.dtmfBuffer += msg.digit;
|
|
2315
|
-
|
|
2316
|
-
if (this.dtmfBuffer.length >= this.inviteRedemptionCodeLength) {
|
|
2317
|
-
const enteredCode = this.dtmfBuffer.slice(
|
|
2318
|
-
0,
|
|
2319
|
-
this.inviteRedemptionCodeLength,
|
|
2320
|
-
);
|
|
2321
|
-
this.dtmfBuffer = "";
|
|
2322
|
-
void this.handleInviteCodeRedemptionResult(enteredCode);
|
|
2323
|
-
}
|
|
2324
|
-
return;
|
|
2325
|
-
}
|
|
2326
|
-
|
|
2327
|
-
// If outbound callee verification is pending, accumulate digits and check the code
|
|
2328
|
-
if (
|
|
2329
|
-
this.connectionState === "verification_pending" &&
|
|
2330
|
-
this.verificationCode
|
|
2331
|
-
) {
|
|
2332
|
-
this.dtmfBuffer += msg.digit;
|
|
2333
|
-
|
|
2334
|
-
if (this.dtmfBuffer.length >= this.verificationCodeLength) {
|
|
2335
|
-
const enteredCode = this.dtmfBuffer.slice(
|
|
2336
|
-
0,
|
|
2337
|
-
this.verificationCodeLength,
|
|
2338
|
-
);
|
|
2339
|
-
this.dtmfBuffer = "";
|
|
2340
|
-
|
|
2341
|
-
if (enteredCode === this.verificationCode) {
|
|
2342
|
-
// Verification succeeded
|
|
2343
|
-
this.connectionState = "connected";
|
|
2344
|
-
this.verificationCode = null;
|
|
2345
|
-
this.verificationAttempts = 0;
|
|
2346
|
-
|
|
2347
|
-
recordCallEvent(
|
|
2348
|
-
this.callSessionId,
|
|
2349
|
-
"callee_verification_succeeded",
|
|
2350
|
-
{},
|
|
2351
|
-
);
|
|
2352
|
-
log.info(
|
|
2353
|
-
{ callSessionId: this.callSessionId },
|
|
2354
|
-
"Callee verification succeeded",
|
|
2355
|
-
);
|
|
2356
|
-
|
|
2357
|
-
// Proceed to the normal call flow
|
|
2358
|
-
if (this.controller) {
|
|
2359
|
-
this.controller
|
|
2360
|
-
.startInitialGreeting()
|
|
2361
|
-
.catch((err) =>
|
|
2362
|
-
log.error(
|
|
2363
|
-
{ err, callSessionId: this.callSessionId },
|
|
2364
|
-
"Failed to start initial outbound greeting after verification",
|
|
2365
|
-
),
|
|
2366
|
-
);
|
|
2367
|
-
}
|
|
2368
|
-
} else {
|
|
2369
|
-
// Verification failed for this attempt
|
|
2370
|
-
this.verificationAttempts++;
|
|
2371
|
-
|
|
2372
|
-
if (this.verificationAttempts >= this.verificationMaxAttempts) {
|
|
2373
|
-
// Max attempts reached — end the call
|
|
2374
|
-
recordCallEvent(this.callSessionId, "callee_verification_failed", {
|
|
2375
|
-
attempts: this.verificationAttempts,
|
|
2376
|
-
});
|
|
2377
|
-
log.warn(
|
|
2378
|
-
{
|
|
2379
|
-
callSessionId: this.callSessionId,
|
|
2380
|
-
attempts: this.verificationAttempts,
|
|
2381
|
-
},
|
|
2382
|
-
"Callee verification failed — max attempts reached",
|
|
2383
|
-
);
|
|
2384
|
-
|
|
2385
|
-
// Mark failed immediately so a relay close during the goodbye TTS
|
|
2386
|
-
// window cannot race this into a terminal "completed" status.
|
|
2387
|
-
updateCallSession(this.callSessionId, {
|
|
2388
|
-
status: "failed",
|
|
2389
|
-
endedAt: Date.now(),
|
|
2390
|
-
lastError: "Callee verification failed — max attempts exceeded",
|
|
2391
|
-
});
|
|
2392
|
-
|
|
2393
|
-
const session = getCallSession(this.callSessionId);
|
|
2394
|
-
if (session) {
|
|
2395
|
-
finalizeCall(this.callSessionId, session.conversationId);
|
|
2396
|
-
if (session.initiatedFromConversationId) {
|
|
2397
|
-
addPointerMessage(
|
|
2398
|
-
session.initiatedFromConversationId,
|
|
2399
|
-
"failed",
|
|
2400
|
-
session.toNumber,
|
|
2401
|
-
{
|
|
2402
|
-
reason: "Callee verification failed",
|
|
2403
|
-
},
|
|
2404
|
-
).catch((err) => {
|
|
2405
|
-
log.warn(
|
|
2406
|
-
{
|
|
2407
|
-
conversationId: session.initiatedFromConversationId,
|
|
2408
|
-
err,
|
|
2409
|
-
},
|
|
2410
|
-
"Skipping pointer write — origin conversation may no longer exist",
|
|
2411
|
-
);
|
|
2412
|
-
});
|
|
2413
|
-
}
|
|
2414
|
-
}
|
|
2415
|
-
|
|
2416
|
-
// Wait for synthesis to complete before starting teardown timer
|
|
2417
|
-
// so the caller hears the goodbye message.
|
|
2418
|
-
void speakSystemPrompt(this, "Verification failed. Goodbye.")
|
|
2419
|
-
.then(() => {
|
|
2420
|
-
setTimeout(() => {
|
|
2421
|
-
this.endSession("Verification failed");
|
|
2422
|
-
}, getTtsPlaybackDelayMs());
|
|
2423
|
-
})
|
|
2424
|
-
.catch((err) => {
|
|
2425
|
-
log.error(
|
|
2426
|
-
{ err, callSessionId: this.callSessionId },
|
|
2427
|
-
"System prompt TTS failed during verification teardown",
|
|
2428
|
-
);
|
|
2429
|
-
setTimeout(() => {
|
|
2430
|
-
this.endSession("Verification failed");
|
|
2431
|
-
}, getTtsPlaybackDelayMs());
|
|
2432
|
-
});
|
|
2433
|
-
} else {
|
|
2434
|
-
// Allow another attempt
|
|
2435
|
-
log.info(
|
|
2436
|
-
{
|
|
2437
|
-
callSessionId: this.callSessionId,
|
|
2438
|
-
attempt: this.verificationAttempts,
|
|
2439
|
-
maxAttempts: this.verificationMaxAttempts,
|
|
2440
|
-
},
|
|
2441
|
-
"Callee verification attempt failed — retrying",
|
|
2442
|
-
);
|
|
2443
|
-
void speakSystemPrompt(
|
|
2444
|
-
this,
|
|
2445
|
-
"That code was incorrect. Please try again.",
|
|
2446
|
-
);
|
|
2447
|
-
}
|
|
2448
|
-
}
|
|
2449
|
-
}
|
|
2450
|
-
}
|
|
2451
|
-
}
|
|
2452
|
-
|
|
2453
|
-
private handleError(msg: RelayErrorMessage): void {
|
|
2454
|
-
log.error(
|
|
2455
|
-
{ callSessionId: this.callSessionId, description: msg.description },
|
|
2456
|
-
"ConversationRelay error",
|
|
2457
|
-
);
|
|
2458
|
-
|
|
2459
|
-
recordCallEvent(this.callSessionId, "call_failed", {
|
|
2460
|
-
error: msg.description,
|
|
2461
|
-
});
|
|
2462
|
-
}
|
|
2463
|
-
}
|