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