twilio-agent-connect 2.1.0 → 2.3.0

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/dist/index.js CHANGED
@@ -17,8 +17,10 @@ var TwilioMemoryConfigSchema = z.object({
17
17
  traitGroups: z.array(z.string()).optional(),
18
18
  observationsLimit: z.number().int().min(0).max(100).default(20),
19
19
  summariesLimit: z.number().int().min(0).max(100).default(5),
20
- // API default is 0 (no communications fetched). SDK defaults to 10 for a useful out-of-box experience.
21
- communicationsLimit: z.number().int().min(0).max(100).default(10),
20
+ // 0 matches the Memory API and Python SDK. Above 0, Memory requires a
21
+ // conversationId on every /Recall, forcing the query expansion that
22
+ // `memoryMode: "once"` exists to avoid.
23
+ communicationsLimit: z.number().int().min(0).max(100).default(0),
22
24
  relevanceThreshold: z.number().min(0).max(1).default(0),
23
25
  /**
24
26
  * Trait group name that holds the phone identifier on newly created profiles.
@@ -49,34 +51,28 @@ var TACConfigSchema = z.object({
49
51
  memoryConfig: TwilioMemoryConfigSchema.prefault({}),
50
52
  conversationConfigurationId: z.string().regex(/^conv_configuration_[0-9a-z]{26}$/, "Invalid Conversation Configuration ID format").optional(),
51
53
  /**
52
- * Public domain where voice routes are reachable (e.g. "abc123.ngrok.app").
53
- * Used by VoiceChannel to construct the public WebSocket URL and
54
- * ConversationRelay action URL. Required when using the Voice channel.
55
- *
56
- * Schemes (https://, wss://), surrounding whitespace, and trailing slashes
57
- * are stripped automatically before validation — a naive copy-paste from a
58
- * browser address bar like "https://example.ngrok.app/" is normalized to
59
- * "example.ngrok.app" rather than rejected.
60
- */
61
- voicePublicDomain: z.preprocess(
62
- (v) => {
63
- if (typeof v !== "string") return v;
64
- let s = v.trim();
65
- if (s.length === 0) return void 0;
66
- for (const scheme of ["https://", "http://", "wss://", "ws://"]) {
67
- if (s.toLowerCase().startsWith(scheme)) {
68
- s = s.slice(scheme.length);
69
- break;
70
- }
54
+ * Public domain where voice routes are reachable, optionally including a port
55
+ * and/or base path (e.g. "example.ngrok.app", "example.ngrok.app:8080",
56
+ * or "example.com/server1"). Used by VoiceChannel to construct the public
57
+ * WebSocket URL and ConversationRelay action URL. Required when using the Voice channel.
58
+ *
59
+ * Whitespace, schemes (https://, wss://), and trailing slashes are stripped
60
+ * automatically; anything else is passed through as given. Mirrors the Python
61
+ * SDK's `_normalize_voice_public_domain` — keep the two in step.
62
+ */
63
+ voicePublicDomain: z.preprocess((v) => {
64
+ if (typeof v !== "string") return v;
65
+ let s = v.trim();
66
+ if (s.length === 0) return void 0;
67
+ for (const scheme of ["https://", "http://", "wss://", "ws://"]) {
68
+ if (s.toLowerCase().startsWith(scheme)) {
69
+ s = s.slice(scheme.length);
70
+ break;
71
71
  }
72
- s = s.replace(/\/+$/, "");
73
- return s.length === 0 ? void 0 : s;
74
- },
75
- z.string().max(253, "Hostname too long (max 253 characters)").regex(
76
- /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/,
77
- 'Invalid hostname format. Must be a hostname without protocol, port, or path (e.g., "abc123.ngrok.app", "localhost", or "192.168.1.100")'
78
- ).optional()
79
- ).optional(),
72
+ }
73
+ s = s.replace(/\/+$/, "");
74
+ return s.length === 0 ? void 0 : s;
75
+ }, z.string().optional()).optional(),
80
76
  /**
81
77
  * Path the voice WebSocket is served at. Combined with voicePublicDomain to
82
78
  * build the public WebSocket URL the voice channel hands to Twilio in TwiML;
@@ -773,10 +769,16 @@ var InterruptMessageSchema = z.object({
773
769
  utteranceUntilInterrupt: z.string().optional(),
774
770
  durationUntilInterruptMs: z.number().int().nonnegative().optional()
775
771
  });
772
+ var DtmfMessageSchema = z.object({
773
+ type: z.literal("dtmf"),
774
+ /** The key pressed: `0`-`9`, `*`, `#`, or `A`-`D`. */
775
+ digit: z.string()
776
+ });
776
777
  var WebSocketMessageSchema = z.union([
777
778
  SetupMessageSchema,
778
779
  PromptMessageSchema,
779
- InterruptMessageSchema
780
+ InterruptMessageSchema,
781
+ DtmfMessageSchema
780
782
  ]);
781
783
  var TextTokenMessageSchema = z.object({
782
784
  type: z.literal("text"),
@@ -1429,7 +1431,7 @@ var TACConfig = class _TACConfig {
1429
1431
  * Optional environment variables:
1430
1432
  * - TWILIO_WHATSAPP_NUMBER: WhatsApp number for WhatsApp channel (e.g., 'whatsapp:+1234567890')
1431
1433
  * - TWILIO_CONVERSATION_CONFIGURATION_ID: Conversation Orchestrator configuration ID (enables orchestrated mode)
1432
- * - TWILIO_VOICE_PUBLIC_DOMAIN: Public domain for voice routes (required for voice; domain only, without protocol/port/path, e.g., 'abc123.ngrok.app')
1434
+ * - TWILIO_VOICE_PUBLIC_DOMAIN: Public domain for voice routes (required for voice; a port and/or base path are allowed, e.g., 'abc123.ngrok.app', 'example.ngrok.app:8080', or 'example.com/server1')
1433
1435
  * - TWILIO_VOICE_WEBSOCKET_PATH: Path for the voice WebSocket (default: /ws)
1434
1436
  * - TWILIO_VOICE_ACTION_PATH: Path for the ConversationRelay action callback (default: /conversation-relay-callback)
1435
1437
  * - TWILIO_VOICE_CALL_EVENT_PATH: Base path for the call-event callbacks — status, async AMD, recording (default: /twilio/call-events)
@@ -1685,7 +1687,7 @@ function createLogger(options) {
1685
1687
 
1686
1688
  // package.json
1687
1689
  var package_default = {
1688
- version: "2.1.0"};
1690
+ version: "2.3.0"};
1689
1691
  function buildUserAgent() {
1690
1692
  return `twilio-agent-connect-typescript/${package_default.version}`;
1691
1693
  }
@@ -1854,7 +1856,9 @@ var MemoryClient = class _MemoryClient extends BaseClient {
1854
1856
  profile_id: profileId,
1855
1857
  observation_count: observations.length,
1856
1858
  summary_count: summaries.length,
1857
- communication_count: communications.length
1859
+ communication_count: communications.length,
1860
+ // Server-side query time; dominates /Recall when expansion runs.
1861
+ query_time_ms: response.meta?.queryTime
1858
1862
  },
1859
1863
  "Memory retrieval succeeded"
1860
1864
  );
@@ -3045,6 +3049,10 @@ var TAC = class _TAC {
3045
3049
  *
3046
3050
  * @param session - Conversation session context
3047
3051
  * @param query - Optional semantic search query
3052
+ * @param conversationId - Passed through to `/Recall` as-is. Sending one
3053
+ * without a `query` makes Memory infer one from that conversation's history
3054
+ * — an expensive server-side step — so leave it unset when there is no
3055
+ * per-turn topic (e.g. `"once"` mode's cache-priming fetch).
3048
3056
  * @returns Promise containing TACMemoryResponse wrapper providing unified access to memory data.
3049
3057
  *
3050
3058
  * Attempts to retrieve from Memory API first:
@@ -3055,7 +3063,7 @@ var TAC = class _TAC {
3055
3063
  * - observations and summaries are empty arrays
3056
3064
  * - communications have basic fields only (no author name/type)
3057
3065
  */
3058
- async retrieveMemory(session, query) {
3066
+ async retrieveMemory(session, query, conversationId) {
3059
3067
  if (!this.isOrchestratorEnabled()) {
3060
3068
  return new TACMemoryResponse([]);
3061
3069
  }
@@ -3102,7 +3110,7 @@ var TAC = class _TAC {
3102
3110
  throw new Error("Memory client is not available");
3103
3111
  }
3104
3112
  const memoryResponse = await this.memoryClient.retrieveMemories(session.profileId, {
3105
- conversationId: session.conversationId,
3113
+ conversationId,
3106
3114
  query,
3107
3115
  observationsLimit: this.config.memoryConfig.observationsLimit,
3108
3116
  summariesLimit: this.config.memoryConfig.summariesLimit,
@@ -3448,9 +3456,9 @@ var BaseChannel = class {
3448
3456
  *
3449
3457
  * Modes:
3450
3458
  * - "always": Fetch with the provided query on every message.
3451
- * - "once": Fetch once with an empty query and cache the result on the
3452
- * session. Subsequent calls reuse the cache until it is invalidated on the
3453
- * INACTIVE transition.
3459
+ * - "once": Fetch once with neither a query nor a conversation id (skipping
3460
+ * query expansion) and cache the result on the session. Subsequent calls
3461
+ * reuse the cache until it is invalidated on the INACTIVE transition.
3454
3462
  * - "never": Skip retrieval.
3455
3463
  *
3456
3464
  * Memory retrieval failures are logged and swallowed so message processing
@@ -3465,9 +3473,11 @@ var BaseChannel = class {
3465
3473
  return session.cachedMemory;
3466
3474
  }
3467
3475
  try {
3476
+ const isAlways = this.memoryMode === "always";
3468
3477
  const memory = await this.tac.retrieveMemory(
3469
3478
  session,
3470
- this.memoryMode === "always" ? query : void 0
3479
+ isAlways ? query : void 0,
3480
+ isAlways ? session.conversationId : void 0
3471
3481
  );
3472
3482
  if (this.memoryMode === "once") {
3473
3483
  session.cachedMemory = memory;
@@ -4673,6 +4683,9 @@ function studioVoiceHandoffUrl(accountSid, flowSid) {
4673
4683
 
4674
4684
  // packages/core/src/channels/voice.ts
4675
4685
  var DEFAULT_WELCOME_GREETING = "Hello! How can I assist you today?";
4686
+ var POLL_ATTEMPTS = 10;
4687
+ var POLL_BASE_DELAY_MS = 250;
4688
+ var POLL_MAX_DELAY_MS = 1500;
4676
4689
  function stringifyParameterValue(value) {
4677
4690
  if (typeof value === "object") {
4678
4691
  return JSON.stringify(value);
@@ -4796,6 +4809,32 @@ var VoiceChannel = class _VoiceChannel extends BaseChannel {
4796
4809
  onRecording(callback) {
4797
4810
  this.onRecordingHandler = callback;
4798
4811
  }
4812
+ /**
4813
+ * Register a handler for DTMF keypresses, called once per key in order.
4814
+ *
4815
+ * Requires `dtmfDetection: true` on the ConversationRelay config — without it
4816
+ * Twilio sends nothing and this never fires. Digits aren't buffered, so
4817
+ * accumulating a multi-digit entry is the handler's job.
4818
+ *
4819
+ * A keypress initializes the conversation just as a prompt does, since a
4820
+ * caller can type without ever speaking; if that fails the digit still
4821
+ * arrives, with `conversationId` and `session` undefined. Keypresses don't
4822
+ * cancel in-flight streaming on their own — that's a separate `interrupt`
4823
+ * message, sent when `interruptible` includes `dtmf`.
4824
+ *
4825
+ * @example
4826
+ * ```typescript
4827
+ * const digits = new Map<string, string>();
4828
+ *
4829
+ * voiceChannel.onDtmf(({ conversationId, digit }) => {
4830
+ * if (!conversationId) return;
4831
+ * digits.set(conversationId, (digits.get(conversationId) ?? '') + digit);
4832
+ * });
4833
+ * ```
4834
+ */
4835
+ onDtmf(callback) {
4836
+ this.voiceCallbacks.onDtmf = callback;
4837
+ }
4799
4838
  /**
4800
4839
  * Resolve the public WebSocket URL from `TACConfig.voicePublicDomain` +
4801
4840
  * `TACConfig.voiceWebsocketPath`. Throws if `voicePublicDomain` isn't set.
@@ -4847,6 +4886,9 @@ var VoiceChannel = class _VoiceChannel extends BaseChannel {
4847
4886
  case "interrupt":
4848
4887
  this.voiceCallbacks.onInterrupt = callback;
4849
4888
  break;
4889
+ case "dtmf":
4890
+ this.voiceCallbacks.onDtmf = callback;
4891
+ break;
4850
4892
  case "webSocketConnected":
4851
4893
  this.voiceCallbacks.onWebSocketConnected = callback;
4852
4894
  break;
@@ -4927,6 +4969,57 @@ var VoiceChannel = class _VoiceChannel extends BaseChannel {
4927
4969
  getWebsocket(conversationId) {
4928
4970
  return this.webSocketConnections.get(conversationId) || null;
4929
4971
  }
4972
+ /**
4973
+ * Poll Conversation Orchestrator for the conversation ConversationRelay
4974
+ * created for `callSid`, then register the local session and WebSocket.
4975
+ * Runs in the background from `setup`, so those can exist before the
4976
+ * caller speaks.
4977
+ */
4978
+ async initializeOrchestratedConversation(callSid, fromNumber, ws) {
4979
+ if (!this.conversationClient) {
4980
+ throw new Error("Conversation client is required in orchestrated mode");
4981
+ }
4982
+ let conversations = [];
4983
+ for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt++) {
4984
+ conversations = await this.conversationClient.listConversations({
4985
+ channelId: callSid,
4986
+ status: ["ACTIVE"]
4987
+ });
4988
+ if (conversations.length === 1) break;
4989
+ if (attempt < POLL_ATTEMPTS - 1) {
4990
+ this.logger.debug(
4991
+ { call_sid: callSid, attempt: attempt + 1, found: conversations.length },
4992
+ "Conversation not ready yet, polling again"
4993
+ );
4994
+ const delayMs = Math.min(POLL_BASE_DELAY_MS * 2 ** attempt, POLL_MAX_DELAY_MS);
4995
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
4996
+ }
4997
+ }
4998
+ if (conversations.length !== 1) {
4999
+ throw new Error(
5000
+ `Expected exactly 1 conversation for callSid ${callSid}, but found ${conversations.length} after ${POLL_ATTEMPTS} attempts`
5001
+ );
5002
+ }
5003
+ const conversation = conversations[0];
5004
+ const conversationId = conversation.id;
5005
+ const participants = await this.conversationClient.listParticipants(conversationId);
5006
+ const customerParticipant = participants.find((p) => p.type === "CUSTOMER");
5007
+ const customerAddress = customerParticipant?.addresses?.find((a) => a.channel === "VOICE")?.address ?? fromNumber ?? void 0;
5008
+ const profileId = customerParticipant?.profileId ? customerParticipant.profileId : void 0;
5009
+ this.webSocketConnections.set(conversationId, ws);
5010
+ this.callSidToConversationId.set(callSid, conversationId);
5011
+ const session = this.startConversation(conversationId, profileId);
5012
+ session.callSid = callSid;
5013
+ if (customerAddress) {
5014
+ session.authorInfo = {
5015
+ address: customerAddress
5016
+ };
5017
+ }
5018
+ if (this.voiceCallbacks.onWebSocketConnected) {
5019
+ this.voiceCallbacks.onWebSocketConnected({ conversationId });
5020
+ }
5021
+ return conversationId;
5022
+ }
4930
5023
  /**
4931
5024
  * Handle WebSocket connection from ConversationRelay
4932
5025
  */
@@ -4935,6 +5028,65 @@ var VoiceChannel = class _VoiceChannel extends BaseChannel {
4935
5028
  let callSid = null;
4936
5029
  let fromNumber = null;
4937
5030
  let initializationFailed = false;
5031
+ let initPromise = null;
5032
+ const ensureConversation = async () => {
5033
+ if (conversationId) {
5034
+ return conversationId;
5035
+ }
5036
+ const sid = callSid;
5037
+ if (!sid) {
5038
+ return null;
5039
+ }
5040
+ const retryCount = this.initializationRetries.get(sid) ?? 0;
5041
+ if (retryCount >= this.MAX_INITIALIZATION_RETRIES) {
5042
+ throw new Error(
5043
+ `Cannot process message - conversation initialization failed after ${retryCount} attempts for callSid ${sid}`
5044
+ );
5045
+ }
5046
+ try {
5047
+ if (initializationFailed) {
5048
+ this.logger.info(
5049
+ { call_sid: sid, retry_count: retryCount },
5050
+ "Retrying conversation initialization after previous failure"
5051
+ );
5052
+ }
5053
+ if (!this.tac.isOrchestratorEnabled()) {
5054
+ conversationId = sid;
5055
+ this.webSocketConnections.set(conversationId, ws);
5056
+ this.callSidToConversationId.set(sid, conversationId);
5057
+ const session = this.startConversation(conversationId);
5058
+ session.callSid = sid;
5059
+ if (fromNumber) {
5060
+ session.authorInfo = { address: fromNumber };
5061
+ }
5062
+ if (this.voiceCallbacks.onWebSocketConnected) {
5063
+ this.voiceCallbacks.onWebSocketConnected({ conversationId });
5064
+ }
5065
+ } else {
5066
+ initPromise ??= this.initializeOrchestratedConversation(sid, fromNumber, ws);
5067
+ try {
5068
+ conversationId = await initPromise;
5069
+ } finally {
5070
+ initPromise = null;
5071
+ }
5072
+ }
5073
+ initializationFailed = false;
5074
+ this.initializationRetries.delete(sid);
5075
+ this.logger.info(
5076
+ { conversation_id: conversationId, call_sid: sid },
5077
+ "Conversation initialization succeeded"
5078
+ );
5079
+ return conversationId;
5080
+ } catch (err) {
5081
+ initializationFailed = true;
5082
+ this.initializationRetries.set(sid, retryCount + 1);
5083
+ this.logger.error(
5084
+ { err, call_sid: sid, retry_count: retryCount + 1 },
5085
+ "Conversation initialization failed"
5086
+ );
5087
+ throw err;
5088
+ }
5089
+ };
4938
5090
  ws.on("message", (data) => {
4939
5091
  (async () => {
4940
5092
  try {
@@ -4957,6 +5109,14 @@ var VoiceChannel = class _VoiceChannel extends BaseChannel {
4957
5109
  case "setup":
4958
5110
  callSid = message.callSid;
4959
5111
  fromNumber = message.from;
5112
+ if (this.tac.isOrchestratorEnabled()) {
5113
+ this.logger.debug(
5114
+ { call_sid: callSid },
5115
+ "Starting background conversation initialization"
5116
+ );
5117
+ initPromise = this.initializeOrchestratedConversation(callSid, fromNumber, ws);
5118
+ void initPromise.catch(() => void 0);
5119
+ }
4960
5120
  if (this.voiceCallbacks.onSetup) {
4961
5121
  this.voiceCallbacks.onSetup({
4962
5122
  callSid,
@@ -4967,89 +5127,7 @@ var VoiceChannel = class _VoiceChannel extends BaseChannel {
4967
5127
  }
4968
5128
  break;
4969
5129
  case "prompt":
4970
- if (!conversationId && callSid) {
4971
- const retryCount = this.initializationRetries.get(callSid) ?? 0;
4972
- if (retryCount >= this.MAX_INITIALIZATION_RETRIES) {
4973
- throw new Error(
4974
- `Cannot process prompt - conversation initialization failed after ${retryCount} attempts for callSid ${callSid}`
4975
- );
4976
- }
4977
- try {
4978
- if (initializationFailed) {
4979
- this.logger.info(
4980
- { call_sid: callSid, retry_count: retryCount },
4981
- "Retrying conversation initialization after previous failure"
4982
- );
4983
- }
4984
- if (!this.tac.isOrchestratorEnabled()) {
4985
- conversationId = callSid;
4986
- this.webSocketConnections.set(conversationId, ws);
4987
- this.callSidToConversationId.set(callSid, conversationId);
4988
- const session = this.startConversation(conversationId);
4989
- session.callSid = callSid;
4990
- if (fromNumber) {
4991
- session.authorInfo = { address: fromNumber };
4992
- }
4993
- } else {
4994
- if (!this.conversationClient) {
4995
- throw new Error("Conversation client is required in orchestrated mode");
4996
- }
4997
- const POLL_ATTEMPTS = 5;
4998
- const POLL_DELAY_MS = 500;
4999
- let conversations = [];
5000
- for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt++) {
5001
- conversations = await this.conversationClient.listConversations({
5002
- channelId: callSid
5003
- });
5004
- if (conversations.length === 1) break;
5005
- if (attempt < POLL_ATTEMPTS - 1) {
5006
- this.logger.debug(
5007
- { call_sid: callSid, attempt: attempt + 1, found: conversations.length },
5008
- "Conversation not ready yet, polling again"
5009
- );
5010
- await new Promise((resolve) => setTimeout(resolve, POLL_DELAY_MS));
5011
- }
5012
- }
5013
- if (conversations.length !== 1) {
5014
- throw new Error(
5015
- `Expected exactly 1 conversation for callSid ${callSid}, but found ${conversations.length} after ${POLL_ATTEMPTS} attempts`
5016
- );
5017
- }
5018
- const conversation = conversations[0];
5019
- conversationId = conversation.id;
5020
- const participants = await this.conversationClient.listParticipants(conversationId);
5021
- const customerParticipant = participants.find((p) => p.type === "CUSTOMER");
5022
- const customerAddress = customerParticipant?.addresses?.find((a) => a.channel === "VOICE")?.address ?? fromNumber ?? void 0;
5023
- const profileId = customerParticipant?.profileId ? customerParticipant.profileId : void 0;
5024
- this.webSocketConnections.set(conversationId, ws);
5025
- this.callSidToConversationId.set(callSid, conversationId);
5026
- const session = this.startConversation(conversationId, profileId);
5027
- session.callSid = callSid;
5028
- if (customerAddress) {
5029
- session.authorInfo = {
5030
- address: customerAddress
5031
- };
5032
- }
5033
- }
5034
- if (this.voiceCallbacks.onWebSocketConnected) {
5035
- this.voiceCallbacks.onWebSocketConnected({ conversationId });
5036
- }
5037
- initializationFailed = false;
5038
- this.initializationRetries.delete(callSid);
5039
- this.logger.info(
5040
- { conversation_id: conversationId, call_sid: callSid },
5041
- "Conversation initialization succeeded"
5042
- );
5043
- } catch (err) {
5044
- initializationFailed = true;
5045
- this.initializationRetries.set(callSid, retryCount + 1);
5046
- this.logger.error(
5047
- { err, call_sid: callSid, retry_count: retryCount + 1 },
5048
- "Conversation initialization failed"
5049
- );
5050
- throw err;
5051
- }
5052
- }
5130
+ await ensureConversation();
5053
5131
  if (conversationId) {
5054
5132
  const previousPrompt = this.promptQueues.get(conversationId) ?? Promise.resolve();
5055
5133
  const currentPrompt = previousPrompt.then(() => this.handlePromptMessage(conversationId, message)).catch((err) => {
@@ -5068,6 +5146,17 @@ var VoiceChannel = class _VoiceChannel extends BaseChannel {
5068
5146
  this.handleInterruptMessage(conversationId, message);
5069
5147
  }
5070
5148
  break;
5149
+ case "dtmf":
5150
+ try {
5151
+ await ensureConversation();
5152
+ } catch (err) {
5153
+ this.logger.warn(
5154
+ { err, call_sid: callSid },
5155
+ "Conversation initialization failed on DTMF keypress, delivering digit without a conversation"
5156
+ );
5157
+ }
5158
+ await this.handleDtmfMessage(conversationId, callSid, message);
5159
+ break;
5071
5160
  default:
5072
5161
  this.logger.debug(
5073
5162
  {
@@ -5090,6 +5179,19 @@ var VoiceChannel = class _VoiceChannel extends BaseChannel {
5090
5179
  });
5091
5180
  });
5092
5181
  ws.on("close", () => {
5182
+ const pendingInit = initPromise;
5183
+ initPromise = null;
5184
+ if (pendingInit && !conversationId) {
5185
+ void pendingInit.then(async (adoptedId) => {
5186
+ await this.handleWebSocketDisconnect(adoptedId);
5187
+ if (callSid) this.callSidToConversationId.delete(callSid);
5188
+ }).catch((err) => {
5189
+ this.logger.error(
5190
+ { err, call_sid: callSid },
5191
+ "Background conversation initialization failed after the call ended"
5192
+ );
5193
+ });
5194
+ }
5093
5195
  if (conversationId) {
5094
5196
  void this.handleWebSocketDisconnect(conversationId).catch((err) => {
5095
5197
  this.logger.error(
@@ -5160,6 +5262,23 @@ var VoiceChannel = class _VoiceChannel extends BaseChannel {
5160
5262
  });
5161
5263
  }
5162
5264
  }
5265
+ /**
5266
+ * Handle WebSocket DTMF message (caller keypress)
5267
+ */
5268
+ async handleDtmfMessage(conversationId, callSid, message) {
5269
+ const { digit } = message;
5270
+ this.logger.debug({ conversation_id: conversationId, call_sid: callSid }, "DTMF keypress");
5271
+ if (!this.voiceCallbacks.onDtmf) {
5272
+ return;
5273
+ }
5274
+ const session = conversationId ? this.getConversationSession(conversationId) : void 0;
5275
+ await this.voiceCallbacks.onDtmf({
5276
+ conversationId: conversationId ?? void 0,
5277
+ callSid: callSid ?? void 0,
5278
+ digit,
5279
+ ...session !== void 0 && { session }
5280
+ });
5281
+ }
5163
5282
  /**
5164
5283
  * Handle WebSocket disconnection. In orchestrated mode the conversation stays
5165
5284
  * tracked until the CLOSED webhook (so a follow-up call can reuse it); in
@@ -5476,8 +5595,8 @@ var VoiceChannel = class _VoiceChannel extends BaseChannel {
5476
5595
  *
5477
5596
  * Places an outbound call with inline TwiML that connects to ConversationRelay.
5478
5597
  * The conversationConfiguration attribute tells CO to create and manage the
5479
- * conversation during passive hydration. The session is initialized lazily
5480
- * on the first prompt when the conversation is discovered by callSid.
5598
+ * conversation during passive hydration. The session is initialized when the
5599
+ * background callSid lookup started at WebSocket setup finds it.
5481
5600
  *
5482
5601
  * TwiML fields are merged per-field, highest precedence first:
5483
5602
  * 1. `options.twimlOptions` — per-call overrides
@@ -5668,9 +5787,8 @@ var VoiceChannel = class _VoiceChannel extends BaseChannel {
5668
5787
  /**
5669
5788
  * Hang up a call and clean up its ConversationRelay session.
5670
5789
  *
5671
- * Works on `callSid` alone, in any mode and before a session exists, so it's
5672
- * safe from a call-event handler that fires before the first prompt. Session
5673
- * cleanup no-ops if no tracked session matches.
5790
+ * Works on `callSid` alone, whether or not a session exists yet. No-ops the
5791
+ * session cleanup if none is tracked.
5674
5792
  *
5675
5793
  * Does not throw — hanging up an already-ended call is routine (the callee
5676
5794
  * hangs up while AMD is still resolving), and handlers shouldn't have to
@@ -5704,11 +5822,10 @@ var VoiceChannel = class _VoiceChannel extends BaseChannel {
5704
5822
  * keyed by conversation id: the Orchestrator conversation id in orchestrator
5705
5823
  * mode, the CallSid only in ConversationRelay-only mode.
5706
5824
  *
5707
- * Sessions are created on the caller's first prompt, not at WebSocket setup,
5708
- * so this returns `undefined` for a call that connected but hasn't been spoken
5709
- * into. That includes `onAmd` under `machineDetection: 'Enable'`, which fires
5710
- * before the first prompt by design — hang up with {@link endCall}, which
5711
- * needs no session.
5825
+ * Relay-only mode creates the session on the first prompt; orchestrated
5826
+ * mode creates it when the lookup started at setup finishes, so it may
5827
+ * exist before the caller speaks — including before `onAmd` fires. Treat it
5828
+ * as racy and hang up with {@link endCall}, which needs no session.
5712
5829
  *
5713
5830
  * At the other end, orchestrator mode keeps the session until Conversation
5714
5831
  * Orchestrator's CLOSED webhook, so it outlives the call and `onCallStatus` /
@@ -5727,8 +5844,8 @@ var VoiceChannel = class _VoiceChannel extends BaseChannel {
5727
5844
  *
5728
5845
  * @param callSid - Twilio Call SID, e.g. from
5729
5846
  * `InitiateVoiceConversationResult.callSid` or a call event.
5730
- * @returns The session, or `undefined` — no first prompt yet, the call ended,
5731
- * or it landed on another instance (see the horizontal-scaling note in
5847
+ * @returns The session, or `undefined` — not created yet, the call ended, or
5848
+ * it landed on another instance (see the horizontal-scaling note in
5732
5849
  * CLAUDE.md).
5733
5850
  */
5734
5851
  getConversationSessionByCallSid(callSid) {
@@ -6967,6 +7084,6 @@ var TACServer = class {
6967
7084
  }
6968
7085
  };
6969
7086
 
6970
- export { ActionChannelSettingsSchema, ActionParticipantRefSchema, ActionResponseSchema, ActionTextContentSchema, AmdEventSchema, AnthropicToolSchema, AuthorInfoSchema, BaseChannel, BaseClient, BuiltInTools, CALL_EVENT_KINDS, CallEventKindSchema, CallOptionsSchema, CallStatusEventSchema, CaptureRuleSchema, ChannelSettingsSchema, ChannelTypeSchema, ChatChannel, CintelParticipantSchema, CommunicationContentSchema, CommunicationParticipantSchema, CommunicationSchema, ConversationAddressSchema, ConversationClient, ConversationConfigurationSchema, ConversationGroupingTypeSchema, ConversationIntelligenceConfigSchema, ConversationParticipantSchema, ConversationRelayAttributesSchema, ConversationRelayCallbackPayloadSchema, ConversationRelayConfigSchema, ConversationResponseSchema, ConversationSessionSchema, ConversationSummaryItemSchema, CreateConversationSummariesResponseSchema, CreateObservationResponseSchema, CreateObservationsRequestSchema, CustomParametersSchema, EMPTY_MEMORY_RESPONSE, EnvironmentVariables, ExecutionDetailsSchema, HandoffPayloadSchema, InitiateMessagingConversationOptionsSchema, InitiateVoiceConversationOptionsSchema, IntelligenceConfigurationSchema, InterruptMessageSchema, InterruptModeSchema, JSONSchemaSchema, KnowledgeBaseSchema, KnowledgeBaseStatusSchema, KnowledgeChunkResultSchema, KnowledgeClient, KnowledgeSearchResponseSchema, LanguageAttributesSchema, LanguageConfigSchema, ListCommunicationsResponseSchema, ListConversationsResponseSchema, ListParticipantsResponseSchema, MemoryChannelTypeSchema, MemoryClient, MemoryCommunicationContentSchema, MemoryCommunicationSchema, MemoryDeliveryStatusSchema, MemoryModeSchema, MemoryParticipantSchema, MemoryParticipantTypeSchema, MemoryPromptBuilder, MemoryRetrievalRequestSchema, MemoryRetrievalResponseSchema, MessageDirectionSchema, MessagingChannel, ObservationCreateRequestSchema, ObservationInfoSchema, OpenAIToolSchema, OperatorProcessingResultSchema, OperatorResultEventSchema, OperatorResultProcessor, OperatorResultSchema, OperatorSchema, ParticipantAddressSchema, ParticipantAddressTypeSchema, PendingHandoffDataSchema, ProfileLookupResponseSchema, ProfileResponseSchema, PromptMessageSchema, RCSChannel, RecordingEventSchema, SMSChannel, SendMessageActionPayloadSchema, SendMessageActionRequestSchema, SessionInfoSchema, SessionMessageSchema, SetupMessageSchema, StatusCallbackSchema, StatusTimeoutsSchema, SummaryInfoSchema, TAC, TACChannelTypeSchema, TACCommunicationAuthorSchema, TACCommunicationContentSchema, TACCommunicationSchema, TACConfig, TACConfigSchema, TACDeliveryStatusSchema, TACMemoryResponse, TACParticipantTypeSchema, TACServer, TACTool, TextTokenMessageSchema, ToolExecutionResultSchema, TranscriptionSchema, TranscriptionWordSchema, TwiMLOptionsSchema, TwiMLRequestSchema, TwilioMemoryConfigSchema, VoiceChannel, WebSocketMessageSchema, WhatsAppChannel, amdEventFromForm, buildHandoffPayload, callOptionsToCreateParams, callStatusEventFromForm, createKnowledgeSearchTool, createKnowledgeSearchToolAsync, createKnowledgeTools, createLogger, createMemoryRetrievalTool, createMemoryTools, createMessagingTools, createSendMessageTool, createStudioHandoffTool, defineTool, isConversationId, isParticipantId, isProfileId, maskAddress, maskEmail, maskPhone, postStudioHandoff, recordingEventFromForm, redactTwimlParameters, scrubObject, scrubPii, studioExecutionsUrl, studioVoiceHandoffUrl, twiMLRequestFromForm };
7087
+ export { ActionChannelSettingsSchema, ActionParticipantRefSchema, ActionResponseSchema, ActionTextContentSchema, AmdEventSchema, AnthropicToolSchema, AuthorInfoSchema, BaseChannel, BaseClient, BuiltInTools, CALL_EVENT_KINDS, CallEventKindSchema, CallOptionsSchema, CallStatusEventSchema, CaptureRuleSchema, ChannelSettingsSchema, ChannelTypeSchema, ChatChannel, CintelParticipantSchema, CommunicationContentSchema, CommunicationParticipantSchema, CommunicationSchema, ConversationAddressSchema, ConversationClient, ConversationConfigurationSchema, ConversationGroupingTypeSchema, ConversationIntelligenceConfigSchema, ConversationParticipantSchema, ConversationRelayAttributesSchema, ConversationRelayCallbackPayloadSchema, ConversationRelayConfigSchema, ConversationResponseSchema, ConversationSessionSchema, ConversationSummaryItemSchema, CreateConversationSummariesResponseSchema, CreateObservationResponseSchema, CreateObservationsRequestSchema, CustomParametersSchema, DtmfMessageSchema, EMPTY_MEMORY_RESPONSE, EnvironmentVariables, ExecutionDetailsSchema, HandoffPayloadSchema, InitiateMessagingConversationOptionsSchema, InitiateVoiceConversationOptionsSchema, IntelligenceConfigurationSchema, InterruptMessageSchema, InterruptModeSchema, JSONSchemaSchema, KnowledgeBaseSchema, KnowledgeBaseStatusSchema, KnowledgeChunkResultSchema, KnowledgeClient, KnowledgeSearchResponseSchema, LanguageAttributesSchema, LanguageConfigSchema, ListCommunicationsResponseSchema, ListConversationsResponseSchema, ListParticipantsResponseSchema, MemoryChannelTypeSchema, MemoryClient, MemoryCommunicationContentSchema, MemoryCommunicationSchema, MemoryDeliveryStatusSchema, MemoryModeSchema, MemoryParticipantSchema, MemoryParticipantTypeSchema, MemoryPromptBuilder, MemoryRetrievalRequestSchema, MemoryRetrievalResponseSchema, MessageDirectionSchema, MessagingChannel, ObservationCreateRequestSchema, ObservationInfoSchema, OpenAIToolSchema, OperatorProcessingResultSchema, OperatorResultEventSchema, OperatorResultProcessor, OperatorResultSchema, OperatorSchema, ParticipantAddressSchema, ParticipantAddressTypeSchema, PendingHandoffDataSchema, ProfileLookupResponseSchema, ProfileResponseSchema, PromptMessageSchema, RCSChannel, RecordingEventSchema, SMSChannel, SendMessageActionPayloadSchema, SendMessageActionRequestSchema, SessionInfoSchema, SessionMessageSchema, SetupMessageSchema, StatusCallbackSchema, StatusTimeoutsSchema, SummaryInfoSchema, TAC, TACChannelTypeSchema, TACCommunicationAuthorSchema, TACCommunicationContentSchema, TACCommunicationSchema, TACConfig, TACConfigSchema, TACDeliveryStatusSchema, TACMemoryResponse, TACParticipantTypeSchema, TACServer, TACTool, TextTokenMessageSchema, ToolExecutionResultSchema, TranscriptionSchema, TranscriptionWordSchema, TwiMLOptionsSchema, TwiMLRequestSchema, TwilioMemoryConfigSchema, VoiceChannel, WebSocketMessageSchema, WhatsAppChannel, amdEventFromForm, buildHandoffPayload, callOptionsToCreateParams, callStatusEventFromForm, createKnowledgeSearchTool, createKnowledgeSearchToolAsync, createKnowledgeTools, createLogger, createMemoryRetrievalTool, createMemoryTools, createMessagingTools, createSendMessageTool, createStudioHandoffTool, defineTool, isConversationId, isParticipantId, isProfileId, maskAddress, maskEmail, maskPhone, postStudioHandoff, recordingEventFromForm, redactTwimlParameters, scrubObject, scrubPii, studioExecutionsUrl, studioVoiceHandoffUrl, twiMLRequestFromForm };
6971
7088
  //# sourceMappingURL=index.js.map
6972
7089
  //# sourceMappingURL=index.js.map