twilio-agent-connect 2.0.1 → 2.1.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
@@ -33,6 +33,8 @@ var voicePathSchema = (defaultPath) => z.preprocess((v) => {
33
33
  const trimmed = v.trim();
34
34
  return trimmed.length === 0 ? void 0 : trimmed;
35
35
  }, z.string().startsWith("/", 'Path must start with "/"').default(defaultPath));
36
+ var CallEventKindSchema = z.enum(["status", "amd", "recording"]);
37
+ var CALL_EVENT_KINDS = CallEventKindSchema.options;
36
38
  var TACConfigSchema = z.object({
37
39
  accountSid: z.string().min(1, "Twilio Account SID is required"),
38
40
  authToken: z.string().min(1, "Twilio Auth Token is required"),
@@ -88,8 +90,14 @@ var TACConfigSchema = z.object({
88
90
  * Must start with '/'.
89
91
  */
90
92
  voiceActionPath: voicePathSchema("/conversation-relay-callback"),
93
+ /**
94
+ * Base path for the call-event callbacks (status, async AMD, recording).
95
+ * TACServer registers one route per callback under it — `<base>/status`,
96
+ * `<base>/amd`, `<base>/recording` — so the route identifies the event. Same
97
+ * role as voiceActionPath. Must start with '/'.
98
+ */
99
+ voiceCallEventPath: voicePathSchema("/twilio/call-events"),
91
100
  cintelConfigurationId: z.string().optional(),
92
- cintelObservationOperatorSid: z.string().optional(),
93
101
  cintelSummaryOperatorSid: z.string().optional(),
94
102
  region: z.string().max(63, "Invalid Twilio region format (must be a valid DNS label)").regex(
95
103
  /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/,
@@ -124,8 +132,8 @@ var EnvironmentVariables = {
124
132
  TWILIO_VOICE_PUBLIC_DOMAIN: "TWILIO_VOICE_PUBLIC_DOMAIN",
125
133
  TWILIO_VOICE_WEBSOCKET_PATH: "TWILIO_VOICE_WEBSOCKET_PATH",
126
134
  TWILIO_VOICE_ACTION_PATH: "TWILIO_VOICE_ACTION_PATH",
135
+ TWILIO_VOICE_CALL_EVENT_PATH: "TWILIO_VOICE_CALL_EVENT_PATH",
127
136
  TWILIO_TAC_CI_CONFIGURATION_ID: "TWILIO_TAC_CI_CONFIGURATION_ID",
128
- TWILIO_TAC_CI_OBSERVATION_OPERATOR_SID: "TWILIO_TAC_CI_OBSERVATION_OPERATOR_SID",
129
137
  TWILIO_TAC_CI_SUMMARY_OPERATOR_SID: "TWILIO_TAC_CI_SUMMARY_OPERATOR_SID",
130
138
  TWILIO_REGION: "TWILIO_REGION",
131
139
  TWILIO_STUDIO_HANDOFF_FLOW_SID: "TWILIO_STUDIO_HANDOFF_FLOW_SID"
@@ -232,6 +240,13 @@ var AuthorInfoSchema = z.object({
232
240
  });
233
241
  var ConversationSessionSchema = z.object({
234
242
  conversationId: z.string().min(1, "Conversation ID is required"),
243
+ /**
244
+ * Twilio Call SID on the Voice channel, unset on messaging. The correlation
245
+ * key for call events (`VoiceChannel.onCallStatus` / `onAmd` / `onRecording`)
246
+ * and `endCall`. Equals `conversationId` in relay-only mode; look the session
247
+ * up the other way with `VoiceChannel.getConversationSessionByCallSid`.
248
+ */
249
+ callSid: z.string().optional(),
235
250
  profileId: z.string().optional(),
236
251
  serviceId: z.string().optional(),
237
252
  channel: ChannelTypeSchema,
@@ -548,7 +563,13 @@ var MemoryChannelTypeSchema = z.enum([
548
563
  "API",
549
564
  "SYSTEM"
550
565
  ]);
551
- var MemoryParticipantTypeSchema = z.enum(["HUMAN_AGENT", "CUSTOMER", "AI_AGENT", "AGENT"]);
566
+ var MemoryParticipantTypeSchema = z.enum([
567
+ "HUMAN_AGENT",
568
+ "CUSTOMER",
569
+ "AI_AGENT",
570
+ "AGENT",
571
+ "UNKNOWN"
572
+ ]);
552
573
  var MemoryDeliveryStatusSchema = z.enum([
553
574
  "INITIATED",
554
575
  "IN_PROGRESS",
@@ -640,11 +661,17 @@ var EMPTY_MEMORY_RESPONSE = {
640
661
  summaries: [],
641
662
  communications: []
642
663
  };
643
- var CreateObservationResponseSchema = z.object({
664
+ var ObservationCreateRequestSchema = z.object({
644
665
  content: z.string(),
645
666
  source: z.string(),
646
667
  occurredAt: z.string(),
647
- conversationIds: z.array(z.string())
668
+ conversationIds: z.array(z.string()).optional()
669
+ });
670
+ var CreateObservationsRequestSchema = z.object({
671
+ observations: z.array(ObservationCreateRequestSchema)
672
+ });
673
+ var CreateObservationResponseSchema = z.object({
674
+ message: z.string()
648
675
  });
649
676
  var CreateConversationSummariesResponseSchema = z.object({
650
677
  message: z.string()
@@ -1026,10 +1053,193 @@ var ConversationRelayCallbackPayloadSchema = z.object({
1026
1053
  SessionStatus: z.string().optional(),
1027
1054
  SessionDuration: z.string().optional()
1028
1055
  });
1056
+ function splitCallEventForm(form, aliases) {
1057
+ const known = {};
1058
+ const extra = {};
1059
+ for (const [key, value] of Object.entries(form)) {
1060
+ const alias = aliases[key];
1061
+ if (alias) {
1062
+ known[alias] = value;
1063
+ } else {
1064
+ extra[key] = value;
1065
+ }
1066
+ }
1067
+ return { known, extra };
1068
+ }
1069
+ var CallEventBaseShape = {
1070
+ callSid: z.string().min(1, "CallSid is required"),
1071
+ accountSid: z.string().optional(),
1072
+ /** Any other Twilio webhook fields not surfaced above. */
1073
+ extra: z.record(z.string(), z.string()).default({})
1074
+ };
1075
+ var CALL_EVENT_BASE_ALIASES = {
1076
+ CallSid: "callSid",
1077
+ AccountSid: "accountSid"
1078
+ };
1079
+ var UNREACHED_CALL_STATUSES = ["busy", "no-answer", "failed", "canceled"];
1080
+ var CallStatusEventSchema = z.object({
1081
+ ...CallEventBaseShape,
1082
+ callStatus: z.string().optional(),
1083
+ callDuration: z.string().optional(),
1084
+ sipResponseCode: z.string().optional()
1085
+ }).transform((event) => ({
1086
+ ...event,
1087
+ /** Call ended without reaching the callee — i.e. worth a retry. */
1088
+ isUnreached: UNREACHED_CALL_STATUSES.includes(event.callStatus ?? "")
1089
+ }));
1090
+ var CALL_STATUS_EVENT_ALIASES = {
1091
+ ...CALL_EVENT_BASE_ALIASES,
1092
+ CallStatus: "callStatus",
1093
+ CallDuration: "callDuration",
1094
+ SipResponseCode: "sipResponseCode"
1095
+ };
1096
+ function callStatusEventFromForm(form) {
1097
+ const { known, extra } = splitCallEventForm(form, CALL_STATUS_EVENT_ALIASES);
1098
+ return CallStatusEventSchema.parse({ ...known, extra });
1099
+ }
1100
+ var AmdEventSchema = z.object({
1101
+ ...CallEventBaseShape,
1102
+ answeredBy: z.string().optional(),
1103
+ machineDetectionDuration: z.string().optional()
1104
+ }).transform((event) => ({
1105
+ ...event,
1106
+ /**
1107
+ * A machine answered — any `machine_*` value, either mode. `unknown`
1108
+ * (detection timed out) is false, so a call is never hung up on a guess.
1109
+ */
1110
+ isMachine: (event.answeredBy ?? "").startsWith("machine")
1111
+ }));
1112
+ var AMD_EVENT_ALIASES = {
1113
+ ...CALL_EVENT_BASE_ALIASES,
1114
+ AnsweredBy: "answeredBy",
1115
+ MachineDetectionDuration: "machineDetectionDuration"
1116
+ };
1117
+ function amdEventFromForm(form) {
1118
+ const { known, extra } = splitCallEventForm(form, AMD_EVENT_ALIASES);
1119
+ return AmdEventSchema.parse({ ...known, extra });
1120
+ }
1121
+ var RecordingEventSchema = z.object({
1122
+ ...CallEventBaseShape,
1123
+ recordingSid: z.string().optional(),
1124
+ recordingUrl: z.string().optional(),
1125
+ recordingStatus: z.string().optional(),
1126
+ recordingDuration: z.string().optional()
1127
+ });
1128
+ var RECORDING_EVENT_ALIASES = {
1129
+ ...CALL_EVENT_BASE_ALIASES,
1130
+ RecordingSid: "recordingSid",
1131
+ RecordingUrl: "recordingUrl",
1132
+ RecordingStatus: "recordingStatus",
1133
+ RecordingDuration: "recordingDuration"
1134
+ };
1135
+ function recordingEventFromForm(form) {
1136
+ const { known, extra } = splitCallEventForm(form, RECORDING_EVENT_ALIASES);
1137
+ return RecordingEventSchema.parse({ ...known, extra });
1138
+ }
1139
+ var CALLS_CREATE_PARAMS = [
1140
+ "applicationSid",
1141
+ "asyncAmd",
1142
+ "asyncAmdStatusCallback",
1143
+ "asyncAmdStatusCallbackMethod",
1144
+ "byoc",
1145
+ "callerId",
1146
+ "callReason",
1147
+ "callToken",
1148
+ "clientNotificationUrl",
1149
+ "fallbackMethod",
1150
+ "fallbackUrl",
1151
+ "from",
1152
+ "machineDetection",
1153
+ "machineDetectionSilenceTimeout",
1154
+ "machineDetectionSpeechEndThreshold",
1155
+ "machineDetectionSpeechThreshold",
1156
+ "machineDetectionTimeout",
1157
+ "method",
1158
+ "record",
1159
+ "recordingChannels",
1160
+ "recordingStatusCallback",
1161
+ "recordingStatusCallbackEvent",
1162
+ "recordingStatusCallbackMethod",
1163
+ "recordingTrack",
1164
+ "sendDigits",
1165
+ "sipAuthPassword",
1166
+ "sipAuthUsername",
1167
+ "statusCallback",
1168
+ "statusCallbackEvent",
1169
+ "statusCallbackMethod",
1170
+ "timeLimit",
1171
+ "timeout",
1172
+ "to",
1173
+ "trim",
1174
+ "twiml",
1175
+ "url"
1176
+ ];
1177
+ var RESERVED_CALL_PARAMS = ["to", "from", "twiml", "url", "applicationSid"];
1178
+ var ACCEPTED_CALL_PARAMS = new Set(CALLS_CREATE_PARAMS);
1179
+ function isAsyncAmdEnabled(value) {
1180
+ if (typeof value === "boolean") return value;
1181
+ if (typeof value === "string") return value.trim().toLowerCase() === "true";
1182
+ return false;
1183
+ }
1184
+ var CallOptionsObjectSchema = z.looseObject({
1185
+ machineDetection: z.enum(["Enable", "DetectMessageEnd"]).optional(),
1186
+ asyncAmd: z.union([z.boolean(), z.string()]).optional(),
1187
+ asyncAmdStatusCallback: z.string().optional(),
1188
+ asyncAmdStatusCallbackMethod: z.string().optional(),
1189
+ machineDetectionTimeout: z.number().int().optional(),
1190
+ machineDetectionSpeechThreshold: z.number().int().optional(),
1191
+ machineDetectionSpeechEndThreshold: z.number().int().optional(),
1192
+ machineDetectionSilenceTimeout: z.number().int().optional(),
1193
+ record: z.boolean().optional(),
1194
+ recordingStatusCallback: z.string().optional(),
1195
+ recordingStatusCallbackEvent: z.array(z.string()).optional(),
1196
+ recordingChannels: z.string().optional(),
1197
+ recordingTrack: z.string().optional(),
1198
+ statusCallback: z.string().optional(),
1199
+ statusCallbackEvent: z.array(z.string()).optional(),
1200
+ statusCallbackMethod: z.string().optional(),
1201
+ timeout: z.number().int().optional()
1202
+ }).superRefine((value, ctx) => {
1203
+ const supplied = Object.keys(value);
1204
+ const conflict = supplied.filter(
1205
+ (key) => RESERVED_CALL_PARAMS.includes(key)
1206
+ );
1207
+ if (conflict.length > 0) {
1208
+ ctx.addIssue({
1209
+ code: "custom",
1210
+ message: `callOptions may not set TAC-owned call parameters: ${conflict.sort().join(", ")}. TAC builds the call and its TwiML.`
1211
+ });
1212
+ }
1213
+ const unknown = supplied.filter((key) => !ACCEPTED_CALL_PARAMS.has(key));
1214
+ if (unknown.length > 0) {
1215
+ ctx.addIssue({
1216
+ code: "custom",
1217
+ message: `callOptions has parameters Twilio's calls.create() does not accept: ${unknown.sort().join(", ")}. Check for a typo, or upgrade the twilio package.`
1218
+ });
1219
+ }
1220
+ if (Boolean(value.machineDetection) !== isAsyncAmdEnabled(value.asyncAmd)) {
1221
+ ctx.addIssue({
1222
+ code: "custom",
1223
+ message: `AMD requires both machineDetection and asyncAmd; got machineDetection=${JSON.stringify(value.machineDetection)}, asyncAmd=${JSON.stringify(value.asyncAmd)}.`
1224
+ });
1225
+ }
1226
+ });
1227
+ var CallOptionsSchema = CallOptionsObjectSchema.transform(
1228
+ (value) => value
1229
+ );
1230
+ function callOptionsToCreateParams(options) {
1231
+ const params = {};
1232
+ for (const [key, value] of Object.entries(options)) {
1233
+ if (value === void 0) continue;
1234
+ params[key] = key === "asyncAmd" && typeof value === "boolean" ? String(value) : value;
1235
+ }
1236
+ return params;
1237
+ }
1029
1238
  var InitiateVoiceConversationOptionsSchema = z.object({
1030
1239
  to: z.string().min(1, "Recipient phone number is required"),
1031
1240
  websocketUrl: z.url().optional(),
1032
- twimlOptions: TwiMLOptionsSchema.optional()
1241
+ twimlOptions: TwiMLOptionsSchema.optional(),
1242
+ callOptions: CallOptionsSchema.optional()
1033
1243
  }).strict();
1034
1244
  var JSONSchemaSchema = z.object({
1035
1245
  type: z.enum(["object", "string", "number", "boolean", "array"]),
@@ -1047,6 +1257,11 @@ var OpenAIToolSchema = z.object({
1047
1257
  parameters: JSONSchemaSchema
1048
1258
  })
1049
1259
  });
1260
+ var AnthropicToolSchema = z.object({
1261
+ name: z.string(),
1262
+ description: z.string(),
1263
+ input_schema: JSONSchemaSchema
1264
+ });
1050
1265
  var ToolExecutionResultSchema = z.object({
1051
1266
  success: z.boolean(),
1052
1267
  data: z.any().optional(),
@@ -1101,7 +1316,6 @@ var OperatorProcessingResultSchema = z.object({
1101
1316
  });
1102
1317
  var ConversationIntelligenceConfigSchema = z.object({
1103
1318
  configurationId: z.string(),
1104
- observationOperatorSid: z.string().optional(),
1105
1319
  summaryOperatorSid: z.string().optional()
1106
1320
  });
1107
1321
  var ConversationSummaryItemSchema = z.object({
@@ -1152,8 +1366,9 @@ var TACConfig = class _TACConfig {
1152
1366
  voiceWebsocketPath;
1153
1367
  /** Path the ConversationRelay action callback is served at (default '/conversation-relay-callback'). */
1154
1368
  voiceActionPath;
1369
+ /** Base path the call-event callbacks are served under (default '/twilio/call-events'). */
1370
+ voiceCallEventPath;
1155
1371
  cintelConfigurationId;
1156
- cintelObservationOperatorSid;
1157
1372
  cintelSummaryOperatorSid;
1158
1373
  /** Optional Twilio region subdomain for API routing (e.g. transforms base URLs to `https://{product}.{region}.twilio.com`) */
1159
1374
  region;
@@ -1187,12 +1402,10 @@ var TACConfig = class _TACConfig {
1187
1402
  }
1188
1403
  this.voiceWebsocketPath = validatedConfig.voiceWebsocketPath;
1189
1404
  this.voiceActionPath = validatedConfig.voiceActionPath;
1405
+ this.voiceCallEventPath = validatedConfig.voiceCallEventPath;
1190
1406
  if (validatedConfig.cintelConfigurationId) {
1191
1407
  this.cintelConfigurationId = validatedConfig.cintelConfigurationId;
1192
1408
  }
1193
- if (validatedConfig.cintelObservationOperatorSid) {
1194
- this.cintelObservationOperatorSid = validatedConfig.cintelObservationOperatorSid;
1195
- }
1196
1409
  if (validatedConfig.cintelSummaryOperatorSid) {
1197
1410
  this.cintelSummaryOperatorSid = validatedConfig.cintelSummaryOperatorSid;
1198
1411
  }
@@ -1219,6 +1432,7 @@ var TACConfig = class _TACConfig {
1219
1432
  * - TWILIO_VOICE_PUBLIC_DOMAIN: Public domain for voice routes (required for voice; domain only, without protocol/port/path, e.g., 'abc123.ngrok.app')
1220
1433
  * - TWILIO_VOICE_WEBSOCKET_PATH: Path for the voice WebSocket (default: /ws)
1221
1434
  * - TWILIO_VOICE_ACTION_PATH: Path for the ConversationRelay action callback (default: /conversation-relay-callback)
1435
+ * - TWILIO_VOICE_CALL_EVENT_PATH: Base path for the call-event callbacks — status, async AMD, recording (default: /twilio/call-events)
1222
1436
  * - TWILIO_REGION: Twilio region subdomain for API routing (e.g. transforms base URLs to `https://{product}.{region}.twilio.com`)
1223
1437
  * - TWILIO_STUDIO_HANDOFF_FLOW_SID: Studio Flow SID used by createStudioHandoffTool for human handoff
1224
1438
  * - TWILIO_RCS_SENDER_ID: RCS Sender ID for the RCS channel
@@ -1330,14 +1544,32 @@ var TACConfig = class _TACConfig {
1330
1544
  voicePublicDomain: process.env[EnvironmentVariables.TWILIO_VOICE_PUBLIC_DOMAIN],
1331
1545
  voiceWebsocketPath: process.env[EnvironmentVariables.TWILIO_VOICE_WEBSOCKET_PATH] || void 0,
1332
1546
  voiceActionPath: process.env[EnvironmentVariables.TWILIO_VOICE_ACTION_PATH] || void 0,
1547
+ voiceCallEventPath: process.env[EnvironmentVariables.TWILIO_VOICE_CALL_EVENT_PATH] || void 0,
1333
1548
  cintelConfigurationId: process.env[EnvironmentVariables.TWILIO_TAC_CI_CONFIGURATION_ID],
1334
- cintelObservationOperatorSid: process.env[EnvironmentVariables.TWILIO_TAC_CI_OBSERVATION_OPERATOR_SID],
1335
1549
  cintelSummaryOperatorSid: process.env[EnvironmentVariables.TWILIO_TAC_CI_SUMMARY_OPERATOR_SID],
1336
1550
  region: process.env[EnvironmentVariables.TWILIO_REGION],
1337
1551
  studioHandoffFlowSid: process.env[EnvironmentVariables.TWILIO_STUDIO_HANDOFF_FLOW_SID]
1338
1552
  };
1339
1553
  return new _TACConfig(rawConfig);
1340
1554
  }
1555
+ /**
1556
+ * Path a call-event callback is served at.
1557
+ *
1558
+ * Single source of truth: the voice channel builds callback URLs from this
1559
+ * and TACServer registers routes at it, so the two can't drift.
1560
+ */
1561
+ callEventPath(kind) {
1562
+ return `${this.voiceCallEventPath.replace(/\/+$/, "")}/${kind}`;
1563
+ }
1564
+ /**
1565
+ * Public URL for a call-event callback, or `undefined` without a public domain.
1566
+ */
1567
+ callEventUrl(kind) {
1568
+ if (!this.voicePublicDomain) {
1569
+ return void 0;
1570
+ }
1571
+ return `https://${this.voicePublicDomain}${this.callEventPath(kind)}`;
1572
+ }
1341
1573
  /**
1342
1574
  * Whether Conversation Orchestrator is configured.
1343
1575
  * Returns false in voice-only mode (no conversationConfigurationId).
@@ -1427,6 +1659,13 @@ function maskAddress(address) {
1427
1659
  if (address.length <= 1) return "***";
1428
1660
  return `${address[0]}***`;
1429
1661
  }
1662
+ function redactTwimlParameters(twiml) {
1663
+ if (!twiml) return "";
1664
+ const parameterValueRe = /(<Parameter\b[^>]*?\bvalue=)(["'])(.*?)\2/gi;
1665
+ return twiml.replace(parameterValueRe, (_match, prefix, quote) => {
1666
+ return `${prefix}${quote}***${quote}`;
1667
+ });
1668
+ }
1430
1669
 
1431
1670
  // packages/core/src/lib/logger.ts
1432
1671
  function piiLogMethod(args, method) {
@@ -1446,7 +1685,7 @@ function createLogger(options) {
1446
1685
 
1447
1686
  // package.json
1448
1687
  var package_default = {
1449
- version: "2.0.1"};
1688
+ version: "2.1.0"};
1450
1689
  function buildUserAgent() {
1451
1690
  return `twilio-agent-connect-typescript/${package_default.version}`;
1452
1691
  }
@@ -1527,7 +1766,7 @@ var BaseClient = class {
1527
1766
  };
1528
1767
 
1529
1768
  // packages/core/src/clients/memory.ts
1530
- var MemoryClient = class extends BaseClient {
1769
+ var MemoryClient = class _MemoryClient extends BaseClient {
1531
1770
  storeId;
1532
1771
  constructor(config, storeId, logger) {
1533
1772
  const baseUrl = config.region ? `https://memory.${config.region}.twilio.com` : "https://memory.twilio.com";
@@ -1580,28 +1819,46 @@ var MemoryClient = class extends BaseClient {
1580
1819
  },
1581
1820
  "Raw memory response received"
1582
1821
  );
1583
- const validatedResponse = MemoryRetrievalResponseSchema.safeParse(data);
1584
- if (!validatedResponse.success) {
1822
+ if (data === null || typeof data !== "object") {
1585
1823
  this.logger.warn(
1586
1824
  {
1587
1825
  profile_id: profileId,
1588
- memory_store_id: this.storeId,
1589
- validation_errors: validatedResponse.error.issues
1826
+ memory_store_id: this.storeId
1590
1827
  },
1591
1828
  "Invalid memory response format"
1592
1829
  );
1593
1830
  return EMPTY_MEMORY_RESPONSE;
1594
1831
  }
1832
+ const response = data;
1833
+ const observations = this.parseItems(
1834
+ response.observations,
1835
+ ObservationInfoSchema,
1836
+ "observation",
1837
+ profileId
1838
+ );
1839
+ const summaries = this.parseItems(
1840
+ response.summaries,
1841
+ SummaryInfoSchema,
1842
+ "summary",
1843
+ profileId
1844
+ );
1845
+ const communications = this.parseItems(
1846
+ response.communications,
1847
+ MemoryCommunicationSchema,
1848
+ "communication",
1849
+ profileId
1850
+ );
1595
1851
  this.logger.debug(
1596
1852
  {
1597
1853
  memory_store_id: this.storeId,
1598
1854
  profile_id: profileId,
1599
- observation_count: validatedResponse.data.observations.length,
1600
- summary_count: validatedResponse.data.summaries.length
1855
+ observation_count: observations.length,
1856
+ summary_count: summaries.length,
1857
+ communication_count: communications.length
1601
1858
  },
1602
1859
  "Memory retrieval succeeded"
1603
1860
  );
1604
- return validatedResponse.data;
1861
+ return { observations, summaries, communications };
1605
1862
  } catch (error) {
1606
1863
  this.logger.warn(
1607
1864
  {
@@ -1614,6 +1871,67 @@ var MemoryClient = class extends BaseClient {
1614
1871
  return EMPTY_MEMORY_RESPONSE;
1615
1872
  }
1616
1873
  }
1874
+ /** Cap on per-item validation warnings logged per response, to avoid flooding logs. */
1875
+ static MAX_INVALID_ITEM_LOGS = 10;
1876
+ /**
1877
+ * Validate an array of memory items one at a time, dropping (and logging)
1878
+ * any that fail validation while keeping the rest.
1879
+ *
1880
+ * Per-item warnings are capped at {@link MemoryClient.MAX_INVALID_ITEM_LOGS}
1881
+ * and followed by a single summary warning with the total `invalid_count`.
1882
+ */
1883
+ parseItems(value, schema, itemType, profileId) {
1884
+ if (value === void 0) {
1885
+ return [];
1886
+ }
1887
+ if (!Array.isArray(value)) {
1888
+ this.logger.warn(
1889
+ {
1890
+ profile_id: profileId,
1891
+ memory_store_id: this.storeId,
1892
+ item_type: itemType,
1893
+ received_type: typeof value
1894
+ },
1895
+ "Expected an array of memory items but received a non-array value"
1896
+ );
1897
+ return [];
1898
+ }
1899
+ const parsed = [];
1900
+ let invalidCount = 0;
1901
+ for (const [index, item] of value.entries()) {
1902
+ const result = schema.safeParse(item);
1903
+ if (result.success) {
1904
+ parsed.push(result.data);
1905
+ continue;
1906
+ }
1907
+ invalidCount += 1;
1908
+ if (invalidCount <= _MemoryClient.MAX_INVALID_ITEM_LOGS) {
1909
+ this.logger.warn(
1910
+ {
1911
+ profile_id: profileId,
1912
+ memory_store_id: this.storeId,
1913
+ item_type: itemType,
1914
+ item_index: index,
1915
+ validation_errors: result.error.issues
1916
+ },
1917
+ "Dropping invalid memory item"
1918
+ );
1919
+ }
1920
+ }
1921
+ if (invalidCount > 0) {
1922
+ this.logger.warn(
1923
+ {
1924
+ profile_id: profileId,
1925
+ memory_store_id: this.storeId,
1926
+ item_type: itemType,
1927
+ invalid_count: invalidCount,
1928
+ total_count: value.length
1929
+ },
1930
+ "Dropped invalid memory items"
1931
+ );
1932
+ }
1933
+ return parsed;
1934
+ }
1617
1935
  /**
1618
1936
  * Find profiles that contain a specific identifier value
1619
1937
  *
@@ -1701,27 +2019,30 @@ var MemoryClient = class extends BaseClient {
1701
2019
  }
1702
2020
  }
1703
2021
  /**
1704
- * Create an observation for a profile
2022
+ * Create an observation for a profile.
2023
+ *
2024
+ * The Memory API Observations endpoint is a batch create: the observation is
2025
+ * wrapped in an `observations` array and `occurredAt` is required, so it
2026
+ * defaults to the current time (ISO 8601) when omitted or blank.
1705
2027
  *
1706
2028
  * @param profileId - The profile ID to create the observation for
1707
2029
  * @param content - The observation content
1708
2030
  * @param source - Source of the observation (default: 'conversation-intelligence')
1709
2031
  * @param conversationIds - Optional array of conversation IDs associated with this observation
1710
- * @param occurredAt - Optional timestamp when the observation occurred
1711
- * @returns Promise containing the created observation
2032
+ * @param occurredAt - Timestamp when the observation occurred (ISO 8601); defaults to now when omitted or blank
2033
+ * @returns Promise containing the API confirmation message
1712
2034
  */
1713
2035
  async createObservation(profileId, content, source = "conversation-intelligence", conversationIds, occurredAt) {
1714
2036
  const url = `/v1/Stores/${this.storeId}/Profiles/${profileId}/Observations`;
1715
- const requestBody = {
2037
+ const observation = {
1716
2038
  content,
1717
- source
2039
+ source,
2040
+ occurredAt: occurredAt && occurredAt.trim() ? occurredAt : (/* @__PURE__ */ new Date()).toISOString()
1718
2041
  };
1719
2042
  if (conversationIds && conversationIds.length > 0) {
1720
- requestBody.conversationIds = conversationIds;
1721
- }
1722
- if (occurredAt) {
1723
- requestBody.occurredAt = occurredAt;
2043
+ observation.conversationIds = conversationIds;
1724
2044
  }
2045
+ const requestBody = { observations: [observation] };
1725
2046
  try {
1726
2047
  const data = await this.makeRequest(url, "POST", requestBody);
1727
2048
  return CreateObservationResponseSchema.parse(data);
@@ -2130,22 +2451,6 @@ function generateContent(operatorResult) {
2130
2451
  const jsonString = JSON.stringify(result);
2131
2452
  return jsonString === "{}" || jsonString === "[]" ? void 0 : jsonString;
2132
2453
  }
2133
- function parseObservationsContent(jsonContent) {
2134
- try {
2135
- const parsed = JSON.parse(jsonContent);
2136
- if (typeof parsed === "object" && parsed !== null && "observations" in parsed) {
2137
- const observations = parsed.observations;
2138
- if (Array.isArray(observations)) {
2139
- return observations.filter(
2140
- (obs) => typeof obs === "string" && obs.trim() !== ""
2141
- );
2142
- }
2143
- }
2144
- return [];
2145
- } catch {
2146
- return [];
2147
- }
2148
- }
2149
2454
  function parseSummariesContent(jsonContent) {
2150
2455
  try {
2151
2456
  const parsed = JSON.parse(jsonContent);
@@ -2283,13 +2588,11 @@ var OperatorResultProcessor = class {
2283
2588
  */
2284
2589
  async processOperatorResult(event, operatorResult) {
2285
2590
  const operatorSid = operatorResult.operator.id;
2286
- const isObservationOperator = this.config.observationOperatorSid === operatorSid;
2287
2591
  const isSummaryOperator = this.config.summaryOperatorSid === operatorSid;
2288
- if (!isObservationOperator && !isSummaryOperator) {
2592
+ if (!isSummaryOperator) {
2289
2593
  this.logger.debug(
2290
2594
  {
2291
2595
  operator_sid: operatorSid,
2292
- observation_operator_sid: this.config.observationOperatorSid,
2293
2596
  summary_operator_sid: this.config.summaryOperatorSid
2294
2597
  },
2295
2598
  "Skipping unconfigured operator"
@@ -2327,75 +2630,7 @@ var OperatorResultProcessor = class {
2327
2630
  createdCount: 0
2328
2631
  };
2329
2632
  }
2330
- if (isObservationOperator) {
2331
- return this.processObservationEvent(event, operatorResult, content, profileIds);
2332
- } else {
2333
- return this.processSummaryEvent(event, operatorResult, content, profileIds);
2334
- }
2335
- }
2336
- /**
2337
- * Process an observation operator result
2338
- */
2339
- async processObservationEvent(event, operatorResult, content, profileIds) {
2340
- const observations = parseObservationsContent(content);
2341
- if (observations.length === 0) {
2342
- this.logger.debug(
2343
- { operator_sid: operatorResult.operator.id },
2344
- "No observations found in content"
2345
- );
2346
- return {
2347
- success: true,
2348
- eventType: "observation",
2349
- skipped: true,
2350
- skipReason: "No observations found in operator result content",
2351
- createdCount: 0
2352
- };
2353
- }
2354
- let createdCount = 0;
2355
- for (const profileId of profileIds) {
2356
- for (const observation of observations) {
2357
- try {
2358
- await this.memoryClient.createObservation(
2359
- profileId,
2360
- observation,
2361
- "conversation-intelligence",
2362
- [event.conversationId],
2363
- operatorResult.dateCreated
2364
- );
2365
- createdCount++;
2366
- this.logger.debug(
2367
- {
2368
- profile_id: profileId,
2369
- conversation_id: event.conversationId,
2370
- observation_preview: observation.substring(0, 100)
2371
- },
2372
- "Created observation"
2373
- );
2374
- } catch (error) {
2375
- this.logger.error(
2376
- {
2377
- err: error,
2378
- profile_id: profileId,
2379
- conversation_id: event.conversationId
2380
- },
2381
- "Failed to create observation"
2382
- );
2383
- return {
2384
- success: false,
2385
- eventType: "observation",
2386
- skipped: false,
2387
- error: `Failed to create observation: ${error instanceof Error ? error.message : String(error)}`,
2388
- createdCount
2389
- };
2390
- }
2391
- }
2392
- }
2393
- return {
2394
- success: true,
2395
- eventType: "observation",
2396
- skipped: false,
2397
- createdCount
2398
- };
2633
+ return this.processSummaryEvent(event, operatorResult, content, profileIds);
2399
2634
  }
2400
2635
  /**
2401
2636
  * Process a summary operator result
@@ -2517,7 +2752,6 @@ var TAC = class _TAC {
2517
2752
  tac.memoryClient,
2518
2753
  {
2519
2754
  configurationId: tac.config.cintelConfigurationId,
2520
- observationOperatorSid: tac.config.cintelObservationOperatorSid,
2521
2755
  summaryOperatorSid: tac.config.cintelSummaryOperatorSid
2522
2756
  },
2523
2757
  tac.logger.child({ component: "cintel" })
@@ -3502,10 +3736,16 @@ var MessagingChannel = class extends BaseChannel {
3502
3736
  if (!session.aiAgentInfo) {
3503
3737
  const resolved = await this.reconcileParticipants(conversationId);
3504
3738
  if (!resolved) {
3505
- this.logger.warn(
3506
- { conversation_id: conversationId },
3507
- "Reconciliation failed; skipping callback for this inbound"
3739
+ const error = new Error(
3740
+ `Participant reconciliation failed for conversation ${conversationId}; inbound message dropped because no sendable participants could be resolved.`
3508
3741
  );
3742
+ error.name = "ParticipantReconciliationError";
3743
+ this.handleError(error, {
3744
+ conversation_id: conversationId,
3745
+ channel: this.channelType,
3746
+ dropped_inbound: true,
3747
+ error_code: "participant_reconciliation_failed"
3748
+ });
3509
3749
  return;
3510
3750
  }
3511
3751
  const [agentParticipant, customerParticipant] = resolved;
@@ -4450,6 +4690,9 @@ var VoiceChannel = class _VoiceChannel extends BaseChannel {
4450
4690
  twilioClient;
4451
4691
  voiceConfig;
4452
4692
  onInboundCallTwimlHandler;
4693
+ onCallStatusHandler;
4694
+ onAmdHandler;
4695
+ onRecordingHandler;
4453
4696
  constructor(tac, options) {
4454
4697
  super(tac, options);
4455
4698
  this.voiceConfig = options ?? {};
@@ -4485,6 +4728,74 @@ var VoiceChannel = class _VoiceChannel extends BaseChannel {
4485
4728
  onInboundCallTwiml(callback) {
4486
4729
  this.onInboundCallTwimlHandler = callback;
4487
4730
  }
4731
+ /**
4732
+ * Register a handler for Twilio `statusCallback` webhooks.
4733
+ *
4734
+ * This is the Calls-API status callback (call disposition), not the
4735
+ * ConversationRelay session callback — see
4736
+ * {@link handleConversationRelayCallback}.
4737
+ *
4738
+ * Registering does two things: it stores the handler, and it makes later
4739
+ * outbound calls pass `statusCallback` to `calls.create`. With no handler
4740
+ * registered TAC omits that parameter, so Twilio has nowhere to post and the
4741
+ * event never arrives.
4742
+ *
4743
+ * Twilio reports only the terminal event by default, which covers every
4744
+ * disposition; set `CallOptions.statusCallbackEvent` for ringing/answered.
4745
+ *
4746
+ * @example
4747
+ * ```typescript
4748
+ * voiceChannel.onCallStatus(async event => {
4749
+ * if (event.isUnreached) {
4750
+ * // queue a retry
4751
+ * }
4752
+ * });
4753
+ * ```
4754
+ */
4755
+ onCallStatus(callback) {
4756
+ this.onCallStatusHandler = callback;
4757
+ }
4758
+ /**
4759
+ * Register a handler for Twilio `asyncAmdStatusCallback` webhooks.
4760
+ *
4761
+ * Registering makes later outbound calls pass `asyncAmdStatusCallback` to
4762
+ * `calls.create`; without a handler TAC omits it and Twilio has nowhere to
4763
+ * post the result. It does not enable detection — that's per-call, via
4764
+ * `CallOptions.machineDetection` and `asyncAmd`, both of which are required
4765
+ * for this to fire (at most once per call).
4766
+ *
4767
+ * @example
4768
+ * ```typescript
4769
+ * voiceChannel.onAmd(async event => {
4770
+ * if (event.isMachine) {
4771
+ * await voiceChannel.endCall(event.callSid); // voicemail → hang up
4772
+ * }
4773
+ * });
4774
+ * ```
4775
+ */
4776
+ onAmd(callback) {
4777
+ this.onAmdHandler = callback;
4778
+ }
4779
+ /**
4780
+ * Register a handler for Twilio `recordingStatusCallback` webhooks.
4781
+ *
4782
+ * Registering makes later outbound calls pass `recordingStatusCallback` to
4783
+ * `calls.create`; without a handler TAC omits it and Twilio has nowhere to
4784
+ * post. It does not start recording — that's `CallOptions.record`, which is
4785
+ * required for this to fire.
4786
+ *
4787
+ * @example
4788
+ * ```typescript
4789
+ * voiceChannel.onRecording(async event => {
4790
+ * if (event.recordingStatus === 'completed') {
4791
+ * // store event.recordingUrl
4792
+ * }
4793
+ * });
4794
+ * ```
4795
+ */
4796
+ onRecording(callback) {
4797
+ this.onRecordingHandler = callback;
4798
+ }
4488
4799
  /**
4489
4800
  * Resolve the public WebSocket URL from `TACConfig.voicePublicDomain` +
4490
4801
  * `TACConfig.voiceWebsocketPath`. Throws if `voicePublicDomain` isn't set.
@@ -4675,6 +4986,7 @@ var VoiceChannel = class _VoiceChannel extends BaseChannel {
4675
4986
  this.webSocketConnections.set(conversationId, ws);
4676
4987
  this.callSidToConversationId.set(callSid, conversationId);
4677
4988
  const session = this.startConversation(conversationId);
4989
+ session.callSid = callSid;
4678
4990
  if (fromNumber) {
4679
4991
  session.authorInfo = { address: fromNumber };
4680
4992
  }
@@ -4712,6 +5024,7 @@ var VoiceChannel = class _VoiceChannel extends BaseChannel {
4712
5024
  this.webSocketConnections.set(conversationId, ws);
4713
5025
  this.callSidToConversationId.set(callSid, conversationId);
4714
5026
  const session = this.startConversation(conversationId, profileId);
5027
+ session.callSid = callSid;
4715
5028
  if (customerAddress) {
4716
5029
  session.authorInfo = {
4717
5030
  address: customerAddress
@@ -5098,6 +5411,66 @@ var VoiceChannel = class _VoiceChannel extends BaseChannel {
5098
5411
  // =========================================================================
5099
5412
  // Outbound Call Handling
5100
5413
  // =========================================================================
5414
+ /**
5415
+ * Overlay `perCall` onto `VoiceChannelConfig.defaultCallOptions`.
5416
+ *
5417
+ * Per-field via key presence, the same convention {@link overlayFields} uses
5418
+ * for TwiML options — so a per-call `{ machineDetection: undefined }`
5419
+ * explicitly clears the channel default rather than falling through to it.
5420
+ *
5421
+ * The result is always validated, for two reasons: a combination only
5422
+ * reachable by layering — per-call clearing `machineDetection` while the
5423
+ * default set `asyncAmd` — must still fail instead of reaching Twilio, and
5424
+ * `VoiceChannelConfig` is a plain interface, so `defaultCallOptions` has had
5425
+ * no runtime validation of its own.
5426
+ */
5427
+ mergeCallOptions(perCall) {
5428
+ const defaults = this.voiceConfig.defaultCallOptions;
5429
+ if (!defaults && !perCall) {
5430
+ return void 0;
5431
+ }
5432
+ const merged = {};
5433
+ for (const [key, value] of Object.entries(defaults ?? {})) {
5434
+ if (value !== void 0) {
5435
+ merged[key] = value;
5436
+ }
5437
+ }
5438
+ for (const key of Object.keys(perCall ?? {})) {
5439
+ merged[key] = perCall[key];
5440
+ }
5441
+ return CallOptionsSchema.parse(merged);
5442
+ }
5443
+ /**
5444
+ * Build the extra arguments for `client.calls.create`.
5445
+ *
5446
+ * Layers, highest precedence first: this call's `callOptions`,
5447
+ * `VoiceChannelConfig.defaultCallOptions`, then callback URLs derived from
5448
+ * `voicePublicDomain` + `voiceCallEventPath`.
5449
+ *
5450
+ * A URL is derived only when its handler is registered. That's a deliberate
5451
+ * deviation from `websocketUrl` / `actionUrl`, which derive unconditionally:
5452
+ * those are load-bearing, so a wrong one fails loudly on the first call,
5453
+ * whereas an unwanted call-event URL fails as silent 11200 alerts for a
5454
+ * feature nobody asked for. Set the URLs in `defaultCallOptions` when TAC
5455
+ * isn't serving the routes.
5456
+ */
5457
+ buildCallParams(callOptions) {
5458
+ const merged = this.mergeCallOptions(callOptions);
5459
+ const params = merged ? callOptionsToCreateParams(merged) : {};
5460
+ const wiring = [
5461
+ ["status", "statusCallback", this.onCallStatusHandler],
5462
+ ["amd", "asyncAmdStatusCallback", this.onAmdHandler],
5463
+ ["recording", "recordingStatusCallback", this.onRecordingHandler]
5464
+ ];
5465
+ for (const [kind, param, handler] of wiring) {
5466
+ if (!handler) continue;
5467
+ const url = this.config.callEventUrl(kind);
5468
+ if (url !== void 0 && params[param] === void 0) {
5469
+ params[param] = url;
5470
+ }
5471
+ }
5472
+ return params;
5473
+ }
5101
5474
  /**
5102
5475
  * Initiate an outbound voice conversation
5103
5476
  *
@@ -5113,6 +5486,12 @@ var VoiceChannel = class _VoiceChannel extends BaseChannel {
5113
5486
  * `TACConfig`, and `actionUrl` from Studio handoff (if configured), else
5114
5487
  * derived from `TACConfig.voicePublicDomain` + `voiceActionPath`.
5115
5488
  *
5489
+ * Calls-API parameters merge the same way:
5490
+ * 1. `options.callOptions` — per-call overrides
5491
+ * 2. `VoiceChannelConfig.defaultCallOptions` — channel-wide defaults
5492
+ * 3. Callback URLs derived from `TACConfig.voicePublicDomain` +
5493
+ * `voiceCallEventPath`, for handlers that are registered
5494
+ *
5116
5495
  * The WebSocket URL is derived from `TACConfig.voicePublicDomain` +
5117
5496
  * `TACConfig.voiceWebsocketPath`, unless overridden per-call via
5118
5497
  * `options.websocketUrl`.
@@ -5128,11 +5507,17 @@ var VoiceChannel = class _VoiceChannel extends BaseChannel {
5128
5507
  const merged = this.buildTwimlOptions(void 0, validated.twimlOptions);
5129
5508
  const websocketUrl = validated.websocketUrl ?? merged.websocketUrl ?? this.resolveWebsocketUrl("initiateOutboundConversation");
5130
5509
  const twiml = this.generateTwiml(websocketUrl, merged);
5510
+ const callParams = this.buildCallParams(validated.callOptions);
5511
+ this.logger.debug(
5512
+ { twiml: redactTwimlParameters(twiml), to: maskAddress(validated.to) },
5513
+ "Outbound call TwiML"
5514
+ );
5131
5515
  const client = this.getTwilioClient();
5132
5516
  const call = await client.calls.create({
5133
5517
  to: validated.to,
5134
5518
  from: fromNumber,
5135
- twiml
5519
+ twiml,
5520
+ ...callParams
5136
5521
  });
5137
5522
  this.logger.info(
5138
5523
  { call_sid: call.sid, to: maskAddress(validated.to) },
@@ -5182,6 +5567,179 @@ var VoiceChannel = class _VoiceChannel extends BaseChannel {
5182
5567
  return { status: 200, content: "OK", contentType: "text/plain" };
5183
5568
  }
5184
5569
  // =========================================================================
5570
+ // Call Event Handling (status callback, async AMD, recording)
5571
+ // =========================================================================
5572
+ /**
5573
+ * Whether a call-webhook payload belongs to the configured account.
5574
+ *
5575
+ * Twilio signature validation already gates the route; this is defense in
5576
+ * depth. A payload with no `AccountSid` is allowed through.
5577
+ *
5578
+ * Subaccounts: events carry the SID the call was placed on, so configure TAC
5579
+ * with that account or its events get dropped here.
5580
+ */
5581
+ callEventAccountOk(form) {
5582
+ const accountSid = form["AccountSid"];
5583
+ if (accountSid && accountSid !== this.config.accountSid) {
5584
+ this.logger.warn(
5585
+ { expected: this.config.accountSid, received: accountSid },
5586
+ "Call event AccountSid mismatch, ignoring"
5587
+ );
5588
+ return false;
5589
+ }
5590
+ return true;
5591
+ }
5592
+ /**
5593
+ * Parse a call-event webhook form and dispatch it to its handler.
5594
+ *
5595
+ * Returns 400 when the payload can't be parsed (no `CallSid`) or the handler
5596
+ * throws — better than handing Twilio a 200 for an event that wasn't
5597
+ * processed. Everything else, including no handler registered and an
5598
+ * account mismatch, is a 200 no-op.
5599
+ */
5600
+ async dispatchCallEvent(kind, form, handler, parse, logFields) {
5601
+ const ok = { status: 200, content: "OK", contentType: "text/plain" };
5602
+ if (!handler || !this.callEventAccountOk(form)) {
5603
+ return ok;
5604
+ }
5605
+ try {
5606
+ const event = parse(form);
5607
+ this.logger.debug(logFields(event), `Call ${kind} event received`);
5608
+ await handler(event);
5609
+ } catch (error) {
5610
+ this.logger.error({ err: error, kind }, "Failed to process call event callback");
5611
+ return { status: 400, content: "Bad Request", contentType: "text/plain" };
5612
+ }
5613
+ return ok;
5614
+ }
5615
+ /**
5616
+ * Handle a Twilio `statusCallback` webhook.
5617
+ *
5618
+ * The developer routes the request here (`TACServer` does this automatically
5619
+ * for its `/status` call-event route). Parsed into a {@link CallStatusEvent}
5620
+ * and dispatched to the {@link onCallStatus} handler. No-op if no handler is
5621
+ * registered.
5622
+ *
5623
+ * @param form - Raw form data from the webhook request.
5624
+ */
5625
+ async handleCallStatusEvent(form) {
5626
+ return this.dispatchCallEvent(
5627
+ "status",
5628
+ form,
5629
+ this.onCallStatusHandler,
5630
+ callStatusEventFromForm,
5631
+ (event) => ({ call_sid: event.callSid, call_status: event.callStatus })
5632
+ );
5633
+ }
5634
+ /**
5635
+ * Handle a Twilio `asyncAmdStatusCallback` webhook.
5636
+ *
5637
+ * The developer routes the request here (`TACServer` does this automatically
5638
+ * for its `/amd` call-event route). Parsed into an {@link AmdEvent} and
5639
+ * dispatched to the {@link onAmd} handler. No-op if no handler is registered.
5640
+ *
5641
+ * @param form - Raw form data from the webhook request.
5642
+ */
5643
+ async handleAmdEvent(form) {
5644
+ return this.dispatchCallEvent("amd", form, this.onAmdHandler, amdEventFromForm, (event) => ({
5645
+ call_sid: event.callSid,
5646
+ answered_by: event.answeredBy
5647
+ }));
5648
+ }
5649
+ /**
5650
+ * Handle a Twilio `recordingStatusCallback` webhook.
5651
+ *
5652
+ * The developer routes the request here (`TACServer` does this automatically
5653
+ * for its `/recording` call-event route). Parsed into a
5654
+ * {@link RecordingEvent} and dispatched to the {@link onRecording} handler.
5655
+ * No-op if no handler is registered.
5656
+ *
5657
+ * @param form - Raw form data from the webhook request.
5658
+ */
5659
+ async handleRecordingEvent(form) {
5660
+ return this.dispatchCallEvent(
5661
+ "recording",
5662
+ form,
5663
+ this.onRecordingHandler,
5664
+ recordingEventFromForm,
5665
+ (event) => ({ call_sid: event.callSid, recording_status: event.recordingStatus })
5666
+ );
5667
+ }
5668
+ /**
5669
+ * Hang up a call and clean up its ConversationRelay session.
5670
+ *
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.
5674
+ *
5675
+ * Does not throw — hanging up an already-ended call is routine (the callee
5676
+ * hangs up while AMD is still resolving), and handlers shouldn't have to
5677
+ * guard against it.
5678
+ *
5679
+ * @param callSid - Twilio Call SID (from a call event, the outbound result, or
5680
+ * `ConversationSession.callSid`).
5681
+ * @returns True if Twilio accepted the hangup, false if it failed (logged).
5682
+ * Session cleanup runs either way.
5683
+ */
5684
+ async endCall(callSid) {
5685
+ const client = this.getTwilioClient();
5686
+ let hungUp = true;
5687
+ try {
5688
+ await client.calls(callSid).update({ status: "completed" });
5689
+ } catch (error) {
5690
+ hungUp = false;
5691
+ this.logger.error({ err: error, call_sid: callSid }, "Failed to hang up call");
5692
+ }
5693
+ const session = this.getConversationSessionByCallSid(callSid);
5694
+ if (session) {
5695
+ await this.endConversation(session.conversationId);
5696
+ }
5697
+ return hungUp;
5698
+ }
5699
+ /**
5700
+ * Look up the active voice session for a Twilio Call SID.
5701
+ *
5702
+ * Out-of-band code holding a CallSid — a dashboard route, an operator action,
5703
+ * a call-event handler — can't reach the session-facing methods, which are
5704
+ * keyed by conversation id: the Orchestrator conversation id in orchestrator
5705
+ * mode, the CallSid only in ConversationRelay-only mode.
5706
+ *
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.
5712
+ *
5713
+ * At the other end, orchestrator mode keeps the session until Conversation
5714
+ * Orchestrator's CLOSED webhook, so it outlives the call and `onCallStatus` /
5715
+ * `onRecording` do resolve. Relay-only mode tears down on the
5716
+ * ConversationRelay callback instead, which races them.
5717
+ *
5718
+ * @example
5719
+ * ```typescript
5720
+ * async function nudge(callSid: string): Promise<void> {
5721
+ * const session = voiceChannel.getConversationSessionByCallSid(callSid);
5722
+ * if (session) {
5723
+ * await voiceChannel.sendResponse(session.conversationId, 'Still there?');
5724
+ * }
5725
+ * }
5726
+ * ```
5727
+ *
5728
+ * @param callSid - Twilio Call SID, e.g. from
5729
+ * `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
5732
+ * CLAUDE.md).
5733
+ */
5734
+ getConversationSessionByCallSid(callSid) {
5735
+ for (const session of this.activeConversations.values()) {
5736
+ if (session.callSid === callSid) {
5737
+ return session;
5738
+ }
5739
+ }
5740
+ return void 0;
5741
+ }
5742
+ // =========================================================================
5185
5743
  // Stream Task Management
5186
5744
  // =========================================================================
5187
5745
  /**
@@ -5971,6 +6529,9 @@ var DEFAULT_CONFIG = {
5971
6529
  twiml: "/twiml"
5972
6530
  }
5973
6531
  };
6532
+ function stripTrailingSlash(path) {
6533
+ return path.replace(/\/+$/, "");
6534
+ }
5974
6535
  var TACServer = class {
5975
6536
  fastify;
5976
6537
  tac;
@@ -6003,6 +6564,7 @@ var TACServer = class {
6003
6564
  "Voice channel is configured but TACConfig.voicePublicDomain is not set. Set it directly or via the TWILIO_VOICE_PUBLIC_DOMAIN env var."
6004
6565
  );
6005
6566
  }
6567
+ this.validateCallEventPaths();
6006
6568
  this.messagingChannels = config.messagingChannels ?? [
6007
6569
  tac.getChannel("sms"),
6008
6570
  tac.getChannel("rcs"),
@@ -6030,6 +6592,38 @@ var TACServer = class {
6030
6592
  });
6031
6593
  }
6032
6594
  }
6595
+ /**
6596
+ * Validate `voiceCallEventPath`, the one path that isn't literal.
6597
+ *
6598
+ * Every other TAC path registers as configured, so a bad value is visible.
6599
+ * This one expands into three sub-paths, hiding a mistake the base path looks
6600
+ * innocent for: a sub-path colliding with another route while the base looks
6601
+ * unrelated (base `/hooks` vs `webhookPaths.twiml = '/hooks/status'`). Both
6602
+ * would register as POST routes and requests would reach the wrong handler.
6603
+ *
6604
+ * The leading-slash requirement is enforced by `TACConfigSchema` at parse
6605
+ * time, so it doesn't need re-checking here.
6606
+ */
6607
+ validateCallEventPaths() {
6608
+ const cfg = this.tac.getConfig();
6609
+ const others = {
6610
+ [stripTrailingSlash(this.config.webhookPaths.twiml || "/twiml")]: "webhookPaths.twiml",
6611
+ [stripTrailingSlash(cfg.voiceActionPath)]: "TACConfig.voiceActionPath",
6612
+ [stripTrailingSlash(this.config.webhookPaths.conversation || "/webhook")]: "webhookPaths.conversation"
6613
+ };
6614
+ if (this.config.webhookPaths.cintel) {
6615
+ others[stripTrailingSlash(this.config.webhookPaths.cintel)] = "webhookPaths.cintel";
6616
+ }
6617
+ for (const kind of CALL_EVENT_KINDS) {
6618
+ const path = cfg.callEventPath(kind);
6619
+ const clash = others[stripTrailingSlash(path)];
6620
+ if (clash !== void 0) {
6621
+ throw new Error(
6622
+ `TACConfig.voiceCallEventPath expands to '${path}', which collides with ${clash}. Both would register as POST routes and requests would reach the wrong handler.`
6623
+ );
6624
+ }
6625
+ }
6626
+ }
6033
6627
  getForwardedProto(request) {
6034
6628
  const raw = request.headers["x-forwarded-proto"];
6035
6629
  return raw?.split(",")[0]?.trim() || "https";
@@ -6154,6 +6748,34 @@ var TACServer = class {
6154
6748
  }
6155
6749
  }
6156
6750
  );
6751
+ const callEventHandlers = {
6752
+ status: (channel, form) => channel.handleCallStatusEvent(form),
6753
+ amd: (channel, form) => channel.handleAmdEvent(form),
6754
+ recording: (channel, form) => channel.handleRecordingEvent(form)
6755
+ };
6756
+ for (const kind of CALL_EVENT_KINDS) {
6757
+ const handle = callEventHandlers[kind];
6758
+ this.fastify.post(
6759
+ this.tac.getConfig().callEventPath(kind),
6760
+ validateSignature,
6761
+ async (request, reply) => {
6762
+ try {
6763
+ if (!this.voiceChannel) {
6764
+ await reply.code(500).send({ error: "Voice channel not available" });
6765
+ return;
6766
+ }
6767
+ const formData = request.body ?? {};
6768
+ const result = await handle(this.voiceChannel, formData);
6769
+ await reply.code(result.status).type(result.contentType).send(result.content);
6770
+ } catch (error) {
6771
+ this.fastify.log.error(
6772
+ `Call ${kind} callback error: ` + (error instanceof Error ? error.message : String(error))
6773
+ );
6774
+ await reply.code(500).send({ error: "Internal server error" });
6775
+ }
6776
+ }
6777
+ );
6778
+ }
6157
6779
  await this.fastify.register((fastify) => {
6158
6780
  fastify.get(
6159
6781
  this.tac.getConfig().voiceWebsocketPath,
@@ -6277,6 +6899,11 @@ var TACServer = class {
6277
6899
  twiml_webhook: this.config.webhookPaths.twiml,
6278
6900
  ws_websocket: this.tac.getConfig().voiceWebsocketPath,
6279
6901
  conversation_relay_callback: this.tac.getConfig().voiceActionPath,
6902
+ ...this.voiceChannel && {
6903
+ call_event_callbacks: CALL_EVENT_KINDS.map(
6904
+ (kind) => this.tac.getConfig().callEventPath(kind)
6905
+ )
6906
+ },
6280
6907
  ...this.config.webhookPaths.cintel && {
6281
6908
  cintel_webhook: this.config.webhookPaths.cintel
6282
6909
  }
@@ -6340,6 +6967,6 @@ var TACServer = class {
6340
6967
  }
6341
6968
  };
6342
6969
 
6343
- export { ActionChannelSettingsSchema, ActionParticipantRefSchema, ActionResponseSchema, ActionTextContentSchema, AuthorInfoSchema, BaseChannel, BaseClient, BuiltInTools, CaptureRuleSchema, ChannelSettingsSchema, ChannelTypeSchema, ChatChannel, CintelParticipantSchema, CommunicationContentSchema, CommunicationParticipantSchema, CommunicationSchema, ConversationAddressSchema, ConversationClient, ConversationConfigurationSchema, ConversationGroupingTypeSchema, ConversationIntelligenceConfigSchema, ConversationParticipantSchema, ConversationRelayAttributesSchema, ConversationRelayCallbackPayloadSchema, ConversationRelayConfigSchema, ConversationResponseSchema, ConversationSessionSchema, ConversationSummaryItemSchema, CreateConversationSummariesResponseSchema, CreateObservationResponseSchema, 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, ObservationInfoSchema, OpenAIToolSchema, OperatorProcessingResultSchema, OperatorResultEventSchema, OperatorResultProcessor, OperatorResultSchema, OperatorSchema, ParticipantAddressSchema, ParticipantAddressTypeSchema, PendingHandoffDataSchema, ProfileLookupResponseSchema, ProfileResponseSchema, PromptMessageSchema, RCSChannel, 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, buildHandoffPayload, createKnowledgeSearchTool, createKnowledgeSearchToolAsync, createKnowledgeTools, createLogger, createMemoryRetrievalTool, createMemoryTools, createMessagingTools, createSendMessageTool, createStudioHandoffTool, defineTool, isConversationId, isParticipantId, isProfileId, maskAddress, maskEmail, maskPhone, postStudioHandoff, scrubObject, scrubPii, studioExecutionsUrl, studioVoiceHandoffUrl, twiMLRequestFromForm };
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 };
6344
6971
  //# sourceMappingURL=index.js.map
6345
6972
  //# sourceMappingURL=index.js.map