@absolutejs/voice 0.0.22-beta.476 → 0.0.22-beta.478

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.
@@ -29,6 +29,7 @@ export type VoiceTransferCallToolDestination = {
29
29
  message?: string;
30
30
  metadata?: Record<string, unknown>;
31
31
  target: string;
32
+ transferMode?: "cold" | "warm";
32
33
  };
33
34
  export type VoiceTransferCallToolArgs = {
34
35
  destinationId: string;
@@ -7,6 +7,7 @@ import { type StoredVoiceTraceEvent, type VoiceTraceSinkDeliveryRecord, type Voi
7
7
  import type { StoredVoiceIntegrationEvent, StoredVoiceExternalObjectMap, StoredVoiceOpsTask, VoiceExternalObjectMap, VoiceExternalObjectMapStore, VoiceIntegrationEvent, VoiceIntegrationEventStore, VoiceOpsTask, VoiceOpsTaskStore } from "./ops";
8
8
  import type { StoredVoiceCallReviewArtifact, VoiceCallReviewArtifact, VoiceCallReviewStore } from "./testing/review";
9
9
  import type { VoiceSessionRecord, VoiceSessionStore } from "./types";
10
+ import type { VoiceRecordingStore } from "./recordingStore";
10
11
  export type VoiceFileStoreOptions = {
11
12
  directory: string;
12
13
  pretty?: boolean;
@@ -50,3 +51,4 @@ export declare const createStoredVoiceIntegrationEvent: <TEvent extends Omit<Voi
50
51
  export declare const createStoredVoiceExternalObjectMap: <TMapping extends Omit<VoiceExternalObjectMap, "id" | "createdAt" | "updatedAt"> = Omit<VoiceExternalObjectMap, "id" | "createdAt" | "updatedAt">>(mapping: TMapping & {
51
52
  at?: number;
52
53
  }) => VoiceExternalObjectMap;
54
+ export declare const createVoiceFileRecordingStore: (options: VoiceFileStoreOptions) => VoiceRecordingStore;
package/dist/index.d.ts CHANGED
@@ -87,7 +87,9 @@ export { createVoiceTurnQualityHTMLHandler, createVoiceTurnQualityJSONHandler, c
87
87
  export { assertVoiceOutcomeContractEvidence, createVoiceOutcomeContractHTMLHandler, createVoiceOutcomeContractJSONHandler, createVoiceOutcomeContractRoutes, evaluateVoiceOutcomeContractEvidence, renderVoiceOutcomeContractHTML, runVoiceOutcomeContractSuite, } from "./outcomeContract";
88
88
  export { applyVoiceTelephonyOutcome, assertVoiceTelephonyWebhookNormalizationEvidence, createMemoryVoiceTelephonyWebhookIdempotencyStore, createVoiceTelephonyOutcomePolicy, createVoiceTelephonyWebhookHandler, createVoiceTelephonyWebhookRoutes, evaluateVoiceTelephonyWebhookNormalizationEvidence, parseVoiceTelephonyWebhookEvent, resolveVoiceTelephonyOutcome, signVoiceTwilioWebhook, verifyVoiceTwilioWebhookSignature, voiceTelephonyOutcomeToRouteResult, } from "./telephonyOutcome";
89
89
  export { assertVoicePhoneCallControlEvidence, assertVoicePhoneAssistantEvidence, createVoicePhoneAgent, evaluateVoicePhoneCallControlEvidence, evaluateVoicePhoneAssistantEvidence, } from "./phoneAgent";
90
- export { createStoredVoiceCallReviewArtifact, createStoredVoiceExternalObjectMap, createStoredVoiceIntegrationEvent, createStoredVoiceOpsTask, createVoiceFileIncidentBundleStore, createVoiceFileExternalObjectMapStore, createVoiceFileAssistantMemoryStore, createVoiceFileAuditEventStore, createVoiceFileAuditSinkDeliveryStore, createVoiceFileCampaignStore, createVoiceFileIntegrationEventStore, createVoiceFileReviewStore, createVoiceFileRuntimeStorage, createVoiceFileSessionStore, createVoiceFileTaskStore, createVoiceFileTraceSinkDeliveryStore, createVoiceFileTraceEventStore, } from "./fileStore";
90
+ export { createStoredVoiceCallReviewArtifact, createStoredVoiceExternalObjectMap, createStoredVoiceIntegrationEvent, createStoredVoiceOpsTask, createVoiceFileIncidentBundleStore, createVoiceFileExternalObjectMapStore, createVoiceFileAssistantMemoryStore, createVoiceFileAuditEventStore, createVoiceFileAuditSinkDeliveryStore, createVoiceFileCampaignStore, createVoiceFileIntegrationEventStore, createVoiceFileRecordingStore, createVoiceFileReviewStore, createVoiceFileRuntimeStorage, createVoiceFileSessionStore, createVoiceFileTaskStore, createVoiceFileTraceSinkDeliveryStore, createVoiceFileTraceEventStore, } from "./fileStore";
91
+ export { computePcmDurationMs, createVoiceMemoryRecordingStore, encodePcmAsWav, } from "./recordingStore";
92
+ export type { StoredVoiceRecordingArtifact, VoiceRecordingArtifact, VoiceRecordingChannel, VoiceRecordingStore, } from "./recordingStore";
91
93
  export { createVoiceAssistantMemoryHandle, createVoiceAssistantMemoryRecord, createVoiceMemoryAssistantMemoryStore, resolveVoiceAssistantMemoryNamespace, } from "./assistantMemory";
92
94
  export { createAnthropicVoiceAssistantModel, createGeminiVoiceAssistantModel, createJSONVoiceAssistantModel, createOpenAIVoiceAssistantModel, createVoiceProviderOrchestrationProfile, resolveVoiceProviderRoutingPolicyPreset, createVoiceProviderRouter, } from "./modelAdapters";
93
95
  export { createOpenAIVoiceTTS } from "./openaiTTS";
@@ -118,7 +120,7 @@ export { buildVoiceTraceDeliveryReport, createVoiceTraceDeliveryHTMLHandler, cre
118
120
  export { createVoiceTraceTimelineRoutes, renderVoiceTraceTimelineHTML, renderVoiceTraceTimelineSessionHTML, summarizeVoiceTraceTimeline, } from "./traceTimeline";
119
121
  export { createVoiceSQLiteAuditEventStore, createVoiceSQLiteAuditSinkDeliveryStore, createVoiceSQLiteCampaignStore, createVoiceSQLiteExternalObjectMapStore, createVoiceSQLiteIntegrationEventStore, createVoiceSQLiteReviewStore, createVoiceSQLiteRuntimeStorage, createVoiceSQLiteSessionStore, createVoiceSQLiteTaskStore, createVoiceSQLiteTelephonyWebhookIdempotencyStore, createVoiceSQLiteTraceSinkDeliveryStore, createVoiceSQLiteTraceEventStore, } from "./sqliteStore";
120
122
  export { createVoicePostgresAuditEventStore, createVoicePostgresAuditSinkDeliveryStore, createVoicePostgresCampaignStore, createVoicePostgresExternalObjectMapStore, createVoicePostgresIntegrationEventStore, createVoicePostgresReviewStore, createVoicePostgresRuntimeStorage, createVoicePostgresSessionStore, createVoicePostgresTaskStore, createVoicePostgresTelephonyWebhookIdempotencyStore, createVoicePostgresTraceSinkDeliveryStore, createVoicePostgresTraceEventStore, } from "./postgresStore";
121
- export { createVoiceS3ReviewStore } from "./s3Store";
123
+ export { createVoiceS3RecordingStore, createVoiceS3ReviewStore } from "./s3Store";
122
124
  export { createVoiceMemoryStore } from "./memoryStore";
123
125
  export { createVoiceCRMActivitySink, createVoiceHelpdeskTicketSink, createVoiceIntegrationHTTPSink, createVoiceHubSpotTaskSink, createVoiceHubSpotTaskSyncSinks, createVoiceHubSpotTaskUpdateSink, createVoiceLinearIssueSink, createVoiceLinearIssueSyncSinks, createVoiceLinearIssueUpdateSink, createVoiceZendeskTicketSink, createVoiceZendeskTicketSyncSinks, createVoiceZendeskTicketUpdateSink, deliverVoiceIntegrationEventToSinks, } from "./opsSinks";
124
126
  export { createVoiceOpsWebhookEnvelope, createVoiceOpsWebhookReceiverRoutes, createVoiceOpsWebhookSink, verifyVoiceOpsWebhookSignature, } from "./opsWebhook";
package/dist/index.js CHANGED
@@ -3372,6 +3372,74 @@ var buildTurnText = (transcripts, partialText, options = {}) => {
3372
3372
  // src/types.ts
3373
3373
  var ttsAdapterSessionCanCancel = (session) => typeof session.cancel === "function";
3374
3374
 
3375
+ // src/recordingStore.ts
3376
+ var writeUint32LE = (view, offset, value) => {
3377
+ view.setUint32(offset, value, true);
3378
+ };
3379
+ var writeUint16LE = (view, offset, value) => {
3380
+ view.setUint16(offset, value, true);
3381
+ };
3382
+ var writeAscii = (view, offset, value) => {
3383
+ for (let index = 0;index < value.length; index += 1) {
3384
+ view.setUint8(offset + index, value.charCodeAt(index));
3385
+ }
3386
+ };
3387
+ var encodePcmAsWav = (pcm, format) => {
3388
+ if (format.container !== "raw" || format.encoding !== "pcm_s16le") {
3389
+ throw new Error(`encodePcmAsWav only supports raw pcm_s16le input (got container=${format.container}, encoding=${format.encoding})`);
3390
+ }
3391
+ const channels = format.channels;
3392
+ const sampleRate = format.sampleRateHz;
3393
+ const bitsPerSample = 16;
3394
+ const byteRate = sampleRate * channels * bitsPerSample / 8;
3395
+ const blockAlign = channels * bitsPerSample / 8;
3396
+ const dataSize = pcm.byteLength;
3397
+ const buffer = new ArrayBuffer(44 + dataSize);
3398
+ const view = new DataView(buffer);
3399
+ writeAscii(view, 0, "RIFF");
3400
+ writeUint32LE(view, 4, 36 + dataSize);
3401
+ writeAscii(view, 8, "WAVE");
3402
+ writeAscii(view, 12, "fmt ");
3403
+ writeUint32LE(view, 16, 16);
3404
+ writeUint16LE(view, 20, 1);
3405
+ writeUint16LE(view, 22, channels);
3406
+ writeUint32LE(view, 24, sampleRate);
3407
+ writeUint32LE(view, 28, byteRate);
3408
+ writeUint16LE(view, 32, blockAlign);
3409
+ writeUint16LE(view, 34, bitsPerSample);
3410
+ writeAscii(view, 36, "data");
3411
+ writeUint32LE(view, 40, dataSize);
3412
+ const output = new Uint8Array(buffer);
3413
+ output.set(pcm, 44);
3414
+ return output;
3415
+ };
3416
+ var computePcmDurationMs = (pcmByteLength, format) => {
3417
+ if (format.container !== "raw" || format.encoding !== "pcm_s16le") {
3418
+ return 0;
3419
+ }
3420
+ const bytesPerSecond = format.sampleRateHz * format.channels * 2;
3421
+ if (bytesPerSecond === 0) {
3422
+ return 0;
3423
+ }
3424
+ return Math.round(pcmByteLength / bytesPerSecond * 1000);
3425
+ };
3426
+ var createVoiceMemoryRecordingStore = () => {
3427
+ const records = new Map;
3428
+ const key = (sessionId, channel) => `${sessionId}::${channel}`;
3429
+ return {
3430
+ get: async (sessionId, channel) => records.get(key(sessionId, channel)),
3431
+ list: async (sessionId) => Array.from(records.values()).filter((record) => record.sessionId === sessionId),
3432
+ put: async (artifact) => {
3433
+ const stored = {
3434
+ ...artifact,
3435
+ recordingUrl: `memory://recording/${artifact.sessionId}/${artifact.channel}.wav`
3436
+ };
3437
+ records.set(key(artifact.sessionId, artifact.channel), stored);
3438
+ return stored;
3439
+ }
3440
+ };
3441
+ };
3442
+
3375
3443
  // src/session.ts
3376
3444
  var DEFAULT_RECONNECT_TIMEOUT = 30000;
3377
3445
  var DEFAULT_MAX_RECONNECT_ATTEMPTS = 10;
@@ -3614,6 +3682,63 @@ var createVoiceSession = (options) => {
3614
3682
  const currentTurnAudio = [];
3615
3683
  let fallbackAttemptsForCurrentTurn = 0;
3616
3684
  let fallbackReplayAudioMsForCurrentTurn = 0;
3685
+ const callSilenceTimeoutMs = options.callSilenceTimeoutMs && options.callSilenceTimeoutMs > 0 ? options.callSilenceTimeoutMs : undefined;
3686
+ let callSilenceWatchdog = null;
3687
+ let callSilenceFired = false;
3688
+ const clearCallSilenceWatchdog = () => {
3689
+ if (callSilenceWatchdog) {
3690
+ clearTimeout(callSilenceWatchdog);
3691
+ callSilenceWatchdog = null;
3692
+ }
3693
+ };
3694
+ const fireCallSilenceTimeout = () => {
3695
+ callSilenceWatchdog = null;
3696
+ if (callSilenceFired) {
3697
+ return;
3698
+ }
3699
+ callSilenceFired = true;
3700
+ api.close("silence-timeout");
3701
+ };
3702
+ const kickCallSilenceWatchdog = () => {
3703
+ if (callSilenceTimeoutMs === undefined || callSilenceFired) {
3704
+ return;
3705
+ }
3706
+ clearCallSilenceWatchdog();
3707
+ callSilenceWatchdog = setTimeout(fireCallSilenceTimeout, callSilenceTimeoutMs);
3708
+ };
3709
+ const recordingConfig = options.recording;
3710
+ const recordingChannels = new Set(recordingConfig?.channels ?? ["assistant", "user"]);
3711
+ const recordingMaxBytes = recordingConfig?.maxBytesPerChannel ?? 50 * 1024 * 1024;
3712
+ const recordingBuffers = {
3713
+ assistant: [],
3714
+ user: []
3715
+ };
3716
+ const recordingByteTotals = {
3717
+ assistant: 0,
3718
+ user: 0
3719
+ };
3720
+ const recordingFormats = {};
3721
+ let recordingPersisted = false;
3722
+ const captureRecordingChunk = (channel, bytes, format) => {
3723
+ if (!recordingConfig || recordingPersisted) {
3724
+ return;
3725
+ }
3726
+ if (!recordingChannels.has(channel)) {
3727
+ return;
3728
+ }
3729
+ if (format.container !== "raw" || format.encoding !== "pcm_s16le") {
3730
+ return;
3731
+ }
3732
+ const currentTotal = recordingByteTotals[channel];
3733
+ if (currentTotal >= recordingMaxBytes) {
3734
+ return;
3735
+ }
3736
+ const remaining = recordingMaxBytes - currentTotal;
3737
+ const slice = bytes.byteLength <= remaining ? bytes : bytes.subarray(0, remaining);
3738
+ recordingBuffers[channel].push(new Uint8Array(slice));
3739
+ recordingByteTotals[channel] += slice.byteLength;
3740
+ recordingFormats[channel] = format;
3741
+ };
3617
3742
  const pruneTurnAudio = () => {
3618
3743
  const replayWindowMs = sttFallback?.replayWindowMs ?? DEFAULT_FALLBACK_REPLAY_MS;
3619
3744
  const cutoffAt = Date.now() - replayWindowMs;
@@ -3792,6 +3917,59 @@ var createVoiceSession = (options) => {
3792
3917
  });
3793
3918
  }
3794
3919
  };
3920
+ const persistRecordings = async () => {
3921
+ if (!recordingConfig || recordingPersisted) {
3922
+ return;
3923
+ }
3924
+ recordingPersisted = true;
3925
+ const channels = ["assistant", "user"];
3926
+ for (const channel of channels) {
3927
+ if (!recordingChannels.has(channel)) {
3928
+ continue;
3929
+ }
3930
+ const chunks = recordingBuffers[channel];
3931
+ const format = recordingFormats[channel];
3932
+ if (chunks.length === 0 || !format) {
3933
+ continue;
3934
+ }
3935
+ const totalBytes = recordingByteTotals[channel];
3936
+ const merged = new Uint8Array(totalBytes);
3937
+ let offset = 0;
3938
+ for (const chunk of chunks) {
3939
+ merged.set(chunk, offset);
3940
+ offset += chunk.byteLength;
3941
+ }
3942
+ try {
3943
+ const stored = await recordingConfig.store.put({
3944
+ audioBytes: merged,
3945
+ capturedAt: Date.now(),
3946
+ channel,
3947
+ durationMs: computePcmDurationMs(totalBytes, format),
3948
+ format,
3949
+ sessionId: options.id
3950
+ });
3951
+ await appendTrace({
3952
+ payload: {
3953
+ channel,
3954
+ durationMs: stored.durationMs,
3955
+ recordingUrl: stored.recordingUrl,
3956
+ sessionId: options.id,
3957
+ sizeBytes: merged.byteLength
3958
+ },
3959
+ type: "recording.ready"
3960
+ });
3961
+ } catch (error) {
3962
+ logger.warn("voice recording persist failed", {
3963
+ channel,
3964
+ error: toError(error).message,
3965
+ sessionId: options.id
3966
+ });
3967
+ } finally {
3968
+ recordingBuffers[channel] = [];
3969
+ recordingByteTotals[channel] = 0;
3970
+ }
3971
+ }
3972
+ };
3795
3973
  const cancelActiveTTS = async (reason) => {
3796
3974
  const activeSession = ttsSession;
3797
3975
  const cancelledTurnId = activeTTSTurnId;
@@ -3815,6 +3993,8 @@ var createVoiceSession = (options) => {
3815
3993
  };
3816
3994
  const sendAssistantAudio = async (chunk, input) => {
3817
3995
  const normalizedChunk = chunk instanceof Uint8Array ? new Uint8Array(chunk) : chunk instanceof ArrayBuffer ? new Uint8Array(chunk.slice(0)) : new Uint8Array(chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength));
3996
+ captureRecordingChunk("assistant", normalizedChunk, input.format);
3997
+ kickCallSilenceWatchdog();
3818
3998
  await send({
3819
3999
  chunkBase64: encodeBase64(normalizedChunk),
3820
4000
  format: input.format,
@@ -3911,6 +4091,7 @@ var createVoiceSession = (options) => {
3911
4091
  });
3912
4092
  await closeTTSSession("failed");
3913
4093
  await closeAdapter("failed");
4094
+ await persistRecordings();
3914
4095
  speechDetected = false;
3915
4096
  rewindFallbackTurnAudio();
3916
4097
  await options.route.onError?.({
@@ -3979,6 +4160,7 @@ var createVoiceSession = (options) => {
3979
4160
  });
3980
4161
  await closeTTSSession("complete");
3981
4162
  await closeAdapter("complete");
4163
+ await persistRecordings();
3982
4164
  speechDetected = false;
3983
4165
  rewindFallbackTurnAudio();
3984
4166
  if (disposition === "transferred" && input.target) {
@@ -4034,19 +4216,21 @@ var createVoiceSession = (options) => {
4034
4216
  });
4035
4217
  };
4036
4218
  const transferInternal = async (input) => {
4219
+ const transferMetadata = input.transferMode === undefined ? input.metadata : { ...input.metadata ?? {}, transferMode: input.transferMode };
4037
4220
  const session = await writeSession((currentSession) => {
4038
4221
  pushCallLifecycleEvent(currentSession, {
4039
- metadata: input.metadata,
4222
+ metadata: transferMetadata,
4040
4223
  reason: input.reason,
4041
4224
  target: input.target,
4042
4225
  type: "transfer"
4043
4226
  });
4044
4227
  });
4045
4228
  await appendTrace({
4046
- metadata: input.metadata,
4229
+ metadata: transferMetadata,
4047
4230
  payload: {
4048
4231
  reason: input.reason,
4049
4232
  target: input.target,
4233
+ transferMode: input.transferMode,
4050
4234
  type: "transfer"
4051
4235
  },
4052
4236
  session,
@@ -5077,6 +5261,7 @@ var createVoiceSession = (options) => {
5077
5261
  resumePendingTurnCommit(session);
5078
5262
  await ensureAdapter();
5079
5263
  warmTTSSession();
5264
+ kickCallSilenceWatchdog();
5080
5265
  };
5081
5266
  const disconnectInternal = async (event) => {
5082
5267
  clearSilenceTimer();
@@ -5120,12 +5305,17 @@ var createVoiceSession = (options) => {
5120
5305
  if (shouldStoreAudio) {
5121
5306
  pushTurnAudio(conditionedAudio);
5122
5307
  }
5308
+ if (recordingConfig?.userInputFormat) {
5309
+ const userBytes = conditionedAudio instanceof Uint8Array ? conditionedAudio : conditionedAudio instanceof ArrayBuffer ? new Uint8Array(conditionedAudio) : new Uint8Array(conditionedAudio.buffer, conditionedAudio.byteOffset, conditionedAudio.byteLength);
5310
+ captureRecordingChunk("user", userBytes, recordingConfig.userInputFormat);
5311
+ }
5123
5312
  if (audioLevel >= turnDetection.speechThreshold) {
5124
5313
  if (!speechDetected && activeTTSTurnId !== undefined) {
5125
5314
  cancelActiveTTS("barge-in");
5126
5315
  }
5127
5316
  speechDetected = true;
5128
5317
  clearSilenceTimer();
5318
+ kickCallSilenceWatchdog();
5129
5319
  } else if (speechDetected) {
5130
5320
  const currentSession = await readSession();
5131
5321
  const hasTurnText = Boolean(buildTurnText(currentSession.currentTurn.transcripts, currentSession.currentTurn.partialText, {
@@ -5138,43 +5328,49 @@ var createVoiceSession = (options) => {
5138
5328
  }
5139
5329
  await adapter.send(conditionedAudio);
5140
5330
  };
5331
+ const closeInternal = async (reason, disposition = "closed") => {
5332
+ const session = await writeSession((currentSession) => {
5333
+ if (currentSession.status !== "completed" && currentSession.status !== "failed" && !currentSession.call?.endedAt) {
5334
+ currentSession.lastActivityAt = Date.now();
5335
+ currentSession.status = "completed";
5336
+ pushCallLifecycleEvent(currentSession, {
5337
+ disposition,
5338
+ reason,
5339
+ type: "end"
5340
+ });
5341
+ }
5342
+ });
5343
+ clearSilenceTimer();
5344
+ clearCallSilenceWatchdog();
5345
+ await closeTTSSession(reason);
5346
+ await closeAdapter(reason);
5347
+ await persistRecordings();
5348
+ await Promise.resolve(socket.close(1000, reason));
5349
+ if (session.call?.endedAt && session.call.disposition === disposition) {
5350
+ await appendTrace({
5351
+ payload: {
5352
+ disposition,
5353
+ reason,
5354
+ type: "end"
5355
+ },
5356
+ session,
5357
+ type: "call.lifecycle"
5358
+ });
5359
+ await options.route.onCallEnd?.({
5360
+ api,
5361
+ context: options.context,
5362
+ disposition,
5363
+ reason,
5364
+ session
5365
+ });
5366
+ }
5367
+ };
5141
5368
  const api = {
5142
5369
  id: options.id,
5143
5370
  close: async (reason) => {
5144
5371
  await runSerial("api.close", async () => {
5145
- const session = await writeSession((currentSession) => {
5146
- if (currentSession.status !== "completed" && currentSession.status !== "failed" && !currentSession.call?.endedAt) {
5147
- currentSession.lastActivityAt = Date.now();
5148
- currentSession.status = "completed";
5149
- pushCallLifecycleEvent(currentSession, {
5150
- disposition: "closed",
5151
- reason,
5152
- type: "end"
5153
- });
5154
- }
5155
- });
5156
- clearSilenceTimer();
5157
- await closeTTSSession(reason);
5158
- await closeAdapter(reason);
5159
- await Promise.resolve(socket.close(1000, reason));
5160
- if (session.call?.endedAt && session.call.disposition === "closed") {
5161
- await appendTrace({
5162
- payload: {
5163
- disposition: "closed",
5164
- reason,
5165
- type: "end"
5166
- },
5167
- session,
5168
- type: "call.lifecycle"
5169
- });
5170
- await options.route.onCallEnd?.({
5171
- api,
5172
- context: options.context,
5173
- disposition: "closed",
5174
- reason,
5175
- session
5176
- });
5177
- }
5372
+ const disposition = reason === "silence-timeout" ? "silence-timeout" : "closed";
5373
+ await closeInternal(reason, disposition);
5178
5374
  });
5179
5375
  },
5180
5376
  commitTurn: async (reason = "manual") => runSerial("api.commitTurn", async () => {
@@ -8920,6 +9116,18 @@ var RECIPE_DEFAULTS = {
8920
9116
  defaultQueue: "transfer-verification",
8921
9117
  description: "Creates transfer verification work for transferred calls and escalation work when the handoff fails.",
8922
9118
  escalationQueue: "transfer-escalations"
9119
+ },
9120
+ "cold-transfer": {
9121
+ completedAction: "Verify the SIP REFER landed and the caller reached the destination without re-introduction.",
9122
+ completedDescription: "The call was cold-transferred (SIP REFER) \u2014 confirm the destination picked up.",
9123
+ completedKind: "transfer-check",
9124
+ completedTitle: "Verify cold transfer",
9125
+ defaultCompletedCreatesTask: false,
9126
+ defaultDueInMs: 5 * 60000,
9127
+ defaultPriority: "normal",
9128
+ defaultQueue: "transfer-verification",
9129
+ description: "Creates verification work for cold-transferred (REFER) calls and escalation work when the handoff fails.",
9130
+ escalationQueue: "transfer-escalations"
8923
9131
  }
8924
9132
  };
8925
9133
  var buildRecipeTask = (input) => {
@@ -9049,7 +9257,7 @@ var resolveVoiceOutcomeRecipe = (name, options = {}) => {
9049
9257
  dueInMs: Math.min(options.dueInMs ?? defaults.defaultDueInMs, 20 * 60000),
9050
9258
  name: `${name}-transfer-check`,
9051
9259
  priority: options.priority ?? defaults.defaultPriority,
9052
- queue: name === "warm-transfer" ? options.queue ?? defaults.defaultQueue : "transfer-verification"
9260
+ queue: name === "warm-transfer" || name === "cold-transfer" ? options.queue ?? defaults.defaultQueue : "transfer-verification"
9053
9261
  },
9054
9262
  voicemail: {
9055
9263
  assignee: options.assignee,
@@ -34837,7 +35045,8 @@ ${destinationDocs}`,
34837
35045
  metadata: destination.metadata,
34838
35046
  reason: args?.reason,
34839
35047
  result,
34840
- target: destination.target
35048
+ target: destination.target,
35049
+ transferMode: destination.transferMode
34841
35050
  });
34842
35051
  return {
34843
35052
  destinationId: destination.id,
@@ -37101,6 +37310,66 @@ var createStoredVoiceExternalObjectMap = (mapping) => createVoiceExternalObjectM
37101
37310
  sourceId: mapping.sourceId,
37102
37311
  sourceType: mapping.sourceType
37103
37312
  });
37313
+ var recordingFileName = (sessionId, channel) => `${encodeURIComponent(sessionId)}_${channel}.wav`;
37314
+ var recordingMetadataFileName = (sessionId, channel) => `${encodeURIComponent(sessionId)}_${channel}.json`;
37315
+ var createVoiceFileRecordingStore = (options) => {
37316
+ const ensureDir = async () => {
37317
+ await mkdir4(options.directory, { recursive: true });
37318
+ };
37319
+ const put = async (artifact) => {
37320
+ await ensureDir();
37321
+ const wavPath = join3(options.directory, recordingFileName(artifact.sessionId, artifact.channel));
37322
+ const metadataPath = join3(options.directory, recordingMetadataFileName(artifact.sessionId, artifact.channel));
37323
+ const wav = encodePcmAsWav(artifact.audioBytes, artifact.format);
37324
+ await writeFile(wavPath, wav);
37325
+ const recordingUrl = `file://${wavPath}`;
37326
+ const metadata = {
37327
+ capturedAt: artifact.capturedAt,
37328
+ channel: artifact.channel,
37329
+ durationMs: artifact.durationMs,
37330
+ format: artifact.format,
37331
+ recordingUrl,
37332
+ sessionId: artifact.sessionId
37333
+ };
37334
+ await writeFile(metadataPath, options.pretty ? JSON.stringify(metadata, null, 2) : JSON.stringify(metadata));
37335
+ return {
37336
+ ...artifact,
37337
+ recordingUrl
37338
+ };
37339
+ };
37340
+ const readMetadata = async (sessionId, channel) => {
37341
+ const metadataPath = join3(options.directory, recordingMetadataFileName(sessionId, channel));
37342
+ const wavPath = join3(options.directory, recordingFileName(sessionId, channel));
37343
+ try {
37344
+ const [metaText, wavBytes] = await Promise.all([
37345
+ readFile2(metadataPath, "utf8"),
37346
+ readFile2(wavPath)
37347
+ ]);
37348
+ const meta = JSON.parse(metaText);
37349
+ return {
37350
+ audioBytes: new Uint8Array(wavBytes.buffer, wavBytes.byteOffset, wavBytes.byteLength),
37351
+ capturedAt: meta.capturedAt,
37352
+ channel: meta.channel,
37353
+ durationMs: meta.durationMs,
37354
+ format: meta.format,
37355
+ recordingUrl: meta.recordingUrl,
37356
+ sessionId: meta.sessionId
37357
+ };
37358
+ } catch (error) {
37359
+ if (error.code === "ENOENT") {
37360
+ return;
37361
+ }
37362
+ throw error;
37363
+ }
37364
+ };
37365
+ const get = (sessionId, channel) => readMetadata(sessionId, channel);
37366
+ const list = async (sessionId) => {
37367
+ const channels = ["assistant", "user"];
37368
+ const records = await Promise.all(channels.map((channel) => readMetadata(sessionId, channel)));
37369
+ return records.filter((record) => record !== undefined);
37370
+ };
37371
+ return { get, list, put };
37372
+ };
37104
37373
  // src/modelAdapters.ts
37105
37374
  var isVoiceProviderRoutingPolicyPreset = (value) => value === "balanced" || value === "cost-cap" || value === "cost-first" || value === "latency-first" || value === "quality-first";
37106
37375
  var resolveVoiceProviderRoutingPolicyPreset = (preset, options = {}) => {
@@ -41033,6 +41302,62 @@ var createVoiceS3ReviewStore = (options) => {
41033
41302
  set
41034
41303
  };
41035
41304
  };
41305
+ var normalizeRecordingKeyPrefix = (prefix) => prefix?.trim().replace(/^\/+|\/+$/g, "") ?? "voice/recordings";
41306
+ var recordingWavKey = (prefix, sessionId, channel) => `${prefix}/${encodeURIComponent(sessionId)}_${channel}.wav`;
41307
+ var recordingMetadataKey = (prefix, sessionId, channel) => `${prefix}/${encodeURIComponent(sessionId)}_${channel}.json`;
41308
+ var createVoiceS3RecordingStore = (options) => {
41309
+ const client = options.client ?? new Bun.S3Client(options);
41310
+ const keyPrefix = normalizeRecordingKeyPrefix(options.keyPrefix);
41311
+ const publicUrlBase = options.publicUrlBase?.replace(/\/+$/, "");
41312
+ const getFile = (key) => client.file(key, options);
41313
+ const resolveUrl = (key) => publicUrlBase ? `${publicUrlBase}/${key}` : `s3://${key}`;
41314
+ const put = async (artifact) => {
41315
+ const wavKey = recordingWavKey(keyPrefix, artifact.sessionId, artifact.channel);
41316
+ const metadataKey = recordingMetadataKey(keyPrefix, artifact.sessionId, artifact.channel);
41317
+ const wav = encodePcmAsWav(artifact.audioBytes, artifact.format);
41318
+ await getFile(wavKey).write(wav);
41319
+ const recordingUrl = resolveUrl(wavKey);
41320
+ const metadata = {
41321
+ capturedAt: artifact.capturedAt,
41322
+ channel: artifact.channel,
41323
+ durationMs: artifact.durationMs,
41324
+ format: artifact.format,
41325
+ recordingUrl,
41326
+ sessionId: artifact.sessionId
41327
+ };
41328
+ await getFile(metadataKey).write(JSON.stringify(metadata));
41329
+ return {
41330
+ ...artifact,
41331
+ recordingUrl
41332
+ };
41333
+ };
41334
+ const readMetadata = async (sessionId, channel) => {
41335
+ const metadataKey = recordingMetadataKey(keyPrefix, sessionId, channel);
41336
+ const wavKey = recordingWavKey(keyPrefix, sessionId, channel);
41337
+ const metadataFile = getFile(metadataKey);
41338
+ if (!await metadataFile.exists()) {
41339
+ return;
41340
+ }
41341
+ const meta = JSON.parse(await metadataFile.text());
41342
+ const wavBytes = await getFile(wavKey).bytes();
41343
+ return {
41344
+ audioBytes: wavBytes,
41345
+ capturedAt: meta.capturedAt,
41346
+ channel: meta.channel,
41347
+ durationMs: meta.durationMs,
41348
+ format: meta.format,
41349
+ recordingUrl: meta.recordingUrl,
41350
+ sessionId: meta.sessionId
41351
+ };
41352
+ };
41353
+ const get = (sessionId, channel) => readMetadata(sessionId, channel);
41354
+ const list = async (sessionId) => {
41355
+ const channels = ["assistant", "user"];
41356
+ const records = await Promise.all(channels.map((channel) => readMetadata(sessionId, channel)));
41357
+ return records.filter((record) => record !== undefined);
41358
+ };
41359
+ return { get, list, put };
41360
+ };
41036
41361
  // src/memoryStore.ts
41037
41362
  var createVoiceMemoryStore = () => {
41038
41363
  const sessions = new Map;
@@ -45361,6 +45686,7 @@ export {
45361
45686
  evaluateVoiceBrowserCallProfileEvidence,
45362
45687
  evaluateVoiceAgentSquadContractEvidence,
45363
45688
  encodeTwilioMulawBase64,
45689
+ encodePcmAsWav,
45364
45690
  deliverVoiceTraceEventsToSinks,
45365
45691
  deliverVoiceObservabilityExport,
45366
45692
  deliverVoiceMonitorIssueNotifications,
@@ -45464,6 +45790,7 @@ export {
45464
45790
  createVoiceSQLiteAuditSinkDeliveryStore,
45465
45791
  createVoiceSQLiteAuditEventStore,
45466
45792
  createVoiceS3ReviewStore,
45793
+ createVoiceS3RecordingStore,
45467
45794
  createVoiceS3DeliverySink,
45468
45795
  createVoiceRoutingDecisionSummary,
45469
45796
  createVoiceReviewSavedEvent,
@@ -45574,6 +45901,7 @@ export {
45574
45901
  createVoiceMemoryTraceSinkDeliveryStore,
45575
45902
  createVoiceMemoryTraceEventStore,
45576
45903
  createVoiceMemoryStore,
45904
+ createVoiceMemoryRecordingStore,
45577
45905
  createVoiceMemoryObservabilityExportDeliveryReceiptStore,
45578
45906
  createVoiceMemoryMonitorNotifierDeliveryReceiptStore,
45579
45907
  createVoiceMemoryMonitorIssueStore,
@@ -45620,6 +45948,7 @@ export {
45620
45948
  createVoiceFileScenarioFixtureStore,
45621
45949
  createVoiceFileRuntimeStorage,
45622
45950
  createVoiceFileReviewStore,
45951
+ createVoiceFileRecordingStore,
45623
45952
  createVoiceFileObservabilityExportDeliveryReceiptStore,
45624
45953
  createVoiceFileIntegrationEventStore,
45625
45954
  createVoiceFileIncidentBundleStore,
@@ -45714,6 +46043,7 @@ export {
45714
46043
  createAnthropicVoiceAssistantModel,
45715
46044
  createAIVoiceModel,
45716
46045
  conditionAudioChunk,
46046
+ computePcmDurationMs,
45717
46047
  completeVoiceOpsTask,
45718
46048
  compareVoiceEvalBaseline,
45719
46049
  claimVoiceOpsTask,
@@ -1,6 +1,6 @@
1
1
  import type { VoiceOpsTaskPriority } from "./ops";
2
2
  import type { VoiceRuntimeOpsConfig, VoiceSessionRecord } from "./types";
3
- export type VoiceOutcomeRecipeName = "appointment-booking" | "lead-qualification" | "support-triage" | "voicemail-callback" | "warm-transfer";
3
+ export type VoiceOutcomeRecipeName = "appointment-booking" | "cold-transfer" | "lead-qualification" | "support-triage" | "voicemail-callback" | "warm-transfer";
4
4
  export type VoiceOutcomeRecipeOptions = {
5
5
  assignee?: string;
6
6
  completedCreatesTask?: boolean;
@@ -0,0 +1,21 @@
1
+ import type { AudioFormat } from "./types";
2
+ export type VoiceRecordingChannel = "assistant" | "user";
3
+ export type VoiceRecordingArtifact = {
4
+ audioBytes: Uint8Array;
5
+ capturedAt: number;
6
+ channel: VoiceRecordingChannel;
7
+ durationMs: number;
8
+ format: AudioFormat;
9
+ sessionId: string;
10
+ };
11
+ export type StoredVoiceRecordingArtifact = VoiceRecordingArtifact & {
12
+ recordingUrl?: string;
13
+ };
14
+ export type VoiceRecordingStore = {
15
+ get: (sessionId: string, channel: VoiceRecordingChannel) => Promise<StoredVoiceRecordingArtifact | undefined>;
16
+ list: (sessionId: string) => Promise<StoredVoiceRecordingArtifact[]>;
17
+ put: (artifact: VoiceRecordingArtifact) => Promise<StoredVoiceRecordingArtifact>;
18
+ };
19
+ export declare const encodePcmAsWav: (pcm: Uint8Array, format: AudioFormat) => Uint8Array;
20
+ export declare const computePcmDurationMs: (pcmByteLength: number, format: AudioFormat) => number;
21
+ export declare const createVoiceMemoryRecordingStore: () => VoiceRecordingStore;
package/dist/s3Store.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { S3Client, S3Options } from "bun";
2
2
  import type { StoredVoiceCallReviewArtifact, VoiceCallReviewStore } from "./testing/review";
3
+ import type { VoiceRecordingStore } from "./recordingStore";
3
4
  export type VoiceS3ReviewStoreFile = {
4
5
  delete: () => Promise<void>;
5
6
  exists: () => Promise<boolean>;
@@ -12,3 +13,17 @@ export type VoiceS3ReviewStoreOptions = S3Options & {
12
13
  keyPrefix?: string;
13
14
  };
14
15
  export declare const createVoiceS3ReviewStore: <TArtifact extends StoredVoiceCallReviewArtifact = StoredVoiceCallReviewArtifact>(options: VoiceS3ReviewStoreOptions) => VoiceCallReviewStore<TArtifact>;
16
+ export type VoiceS3RecordingStoreFile = {
17
+ delete: () => Promise<void>;
18
+ exists: () => Promise<boolean>;
19
+ text: () => Promise<string>;
20
+ bytes: () => Promise<Uint8Array>;
21
+ write: (data: string | Uint8Array) => Promise<number>;
22
+ };
23
+ export type VoiceS3RecordingStoreClient = Pick<S3Client, "file" | "list">;
24
+ export type VoiceS3RecordingStoreOptions = S3Options & {
25
+ client?: VoiceS3RecordingStoreClient;
26
+ keyPrefix?: string;
27
+ publicUrlBase?: string;
28
+ };
29
+ export declare const createVoiceS3RecordingStore: (options: VoiceS3RecordingStoreOptions) => VoiceRecordingStore;
@@ -5340,6 +5340,74 @@ var resolveLogger = (logger) => ({
5340
5340
  // src/types.ts
5341
5341
  var ttsAdapterSessionCanCancel = (session) => typeof session.cancel === "function";
5342
5342
 
5343
+ // src/recordingStore.ts
5344
+ var writeUint32LE = (view, offset, value) => {
5345
+ view.setUint32(offset, value, true);
5346
+ };
5347
+ var writeUint16LE = (view, offset, value) => {
5348
+ view.setUint16(offset, value, true);
5349
+ };
5350
+ var writeAscii = (view, offset, value) => {
5351
+ for (let index = 0;index < value.length; index += 1) {
5352
+ view.setUint8(offset + index, value.charCodeAt(index));
5353
+ }
5354
+ };
5355
+ var encodePcmAsWav = (pcm, format) => {
5356
+ if (format.container !== "raw" || format.encoding !== "pcm_s16le") {
5357
+ throw new Error(`encodePcmAsWav only supports raw pcm_s16le input (got container=${format.container}, encoding=${format.encoding})`);
5358
+ }
5359
+ const channels = format.channels;
5360
+ const sampleRate = format.sampleRateHz;
5361
+ const bitsPerSample = 16;
5362
+ const byteRate = sampleRate * channels * bitsPerSample / 8;
5363
+ const blockAlign = channels * bitsPerSample / 8;
5364
+ const dataSize = pcm.byteLength;
5365
+ const buffer = new ArrayBuffer(44 + dataSize);
5366
+ const view = new DataView(buffer);
5367
+ writeAscii(view, 0, "RIFF");
5368
+ writeUint32LE(view, 4, 36 + dataSize);
5369
+ writeAscii(view, 8, "WAVE");
5370
+ writeAscii(view, 12, "fmt ");
5371
+ writeUint32LE(view, 16, 16);
5372
+ writeUint16LE(view, 20, 1);
5373
+ writeUint16LE(view, 22, channels);
5374
+ writeUint32LE(view, 24, sampleRate);
5375
+ writeUint32LE(view, 28, byteRate);
5376
+ writeUint16LE(view, 32, blockAlign);
5377
+ writeUint16LE(view, 34, bitsPerSample);
5378
+ writeAscii(view, 36, "data");
5379
+ writeUint32LE(view, 40, dataSize);
5380
+ const output = new Uint8Array(buffer);
5381
+ output.set(pcm, 44);
5382
+ return output;
5383
+ };
5384
+ var computePcmDurationMs = (pcmByteLength, format) => {
5385
+ if (format.container !== "raw" || format.encoding !== "pcm_s16le") {
5386
+ return 0;
5387
+ }
5388
+ const bytesPerSecond = format.sampleRateHz * format.channels * 2;
5389
+ if (bytesPerSecond === 0) {
5390
+ return 0;
5391
+ }
5392
+ return Math.round(pcmByteLength / bytesPerSecond * 1000);
5393
+ };
5394
+ var createVoiceMemoryRecordingStore = () => {
5395
+ const records = new Map;
5396
+ const key = (sessionId, channel) => `${sessionId}::${channel}`;
5397
+ return {
5398
+ get: async (sessionId, channel) => records.get(key(sessionId, channel)),
5399
+ list: async (sessionId) => Array.from(records.values()).filter((record) => record.sessionId === sessionId),
5400
+ put: async (artifact) => {
5401
+ const stored = {
5402
+ ...artifact,
5403
+ recordingUrl: `memory://recording/${artifact.sessionId}/${artifact.channel}.wav`
5404
+ };
5405
+ records.set(key(artifact.sessionId, artifact.channel), stored);
5406
+ return stored;
5407
+ }
5408
+ };
5409
+ };
5410
+
5343
5411
  // src/session.ts
5344
5412
  var DEFAULT_RECONNECT_TIMEOUT = 30000;
5345
5413
  var DEFAULT_MAX_RECONNECT_ATTEMPTS2 = 10;
@@ -5582,6 +5650,63 @@ var createVoiceSession = (options) => {
5582
5650
  const currentTurnAudio = [];
5583
5651
  let fallbackAttemptsForCurrentTurn = 0;
5584
5652
  let fallbackReplayAudioMsForCurrentTurn = 0;
5653
+ const callSilenceTimeoutMs = options.callSilenceTimeoutMs && options.callSilenceTimeoutMs > 0 ? options.callSilenceTimeoutMs : undefined;
5654
+ let callSilenceWatchdog = null;
5655
+ let callSilenceFired = false;
5656
+ const clearCallSilenceWatchdog = () => {
5657
+ if (callSilenceWatchdog) {
5658
+ clearTimeout(callSilenceWatchdog);
5659
+ callSilenceWatchdog = null;
5660
+ }
5661
+ };
5662
+ const fireCallSilenceTimeout = () => {
5663
+ callSilenceWatchdog = null;
5664
+ if (callSilenceFired) {
5665
+ return;
5666
+ }
5667
+ callSilenceFired = true;
5668
+ api.close("silence-timeout");
5669
+ };
5670
+ const kickCallSilenceWatchdog = () => {
5671
+ if (callSilenceTimeoutMs === undefined || callSilenceFired) {
5672
+ return;
5673
+ }
5674
+ clearCallSilenceWatchdog();
5675
+ callSilenceWatchdog = setTimeout(fireCallSilenceTimeout, callSilenceTimeoutMs);
5676
+ };
5677
+ const recordingConfig = options.recording;
5678
+ const recordingChannels = new Set(recordingConfig?.channels ?? ["assistant", "user"]);
5679
+ const recordingMaxBytes = recordingConfig?.maxBytesPerChannel ?? 50 * 1024 * 1024;
5680
+ const recordingBuffers = {
5681
+ assistant: [],
5682
+ user: []
5683
+ };
5684
+ const recordingByteTotals = {
5685
+ assistant: 0,
5686
+ user: 0
5687
+ };
5688
+ const recordingFormats = {};
5689
+ let recordingPersisted = false;
5690
+ const captureRecordingChunk = (channel, bytes, format) => {
5691
+ if (!recordingConfig || recordingPersisted) {
5692
+ return;
5693
+ }
5694
+ if (!recordingChannels.has(channel)) {
5695
+ return;
5696
+ }
5697
+ if (format.container !== "raw" || format.encoding !== "pcm_s16le") {
5698
+ return;
5699
+ }
5700
+ const currentTotal = recordingByteTotals[channel];
5701
+ if (currentTotal >= recordingMaxBytes) {
5702
+ return;
5703
+ }
5704
+ const remaining = recordingMaxBytes - currentTotal;
5705
+ const slice = bytes.byteLength <= remaining ? bytes : bytes.subarray(0, remaining);
5706
+ recordingBuffers[channel].push(new Uint8Array(slice));
5707
+ recordingByteTotals[channel] += slice.byteLength;
5708
+ recordingFormats[channel] = format;
5709
+ };
5585
5710
  const pruneTurnAudio = () => {
5586
5711
  const replayWindowMs = sttFallback?.replayWindowMs ?? DEFAULT_FALLBACK_REPLAY_MS;
5587
5712
  const cutoffAt = Date.now() - replayWindowMs;
@@ -5760,6 +5885,59 @@ var createVoiceSession = (options) => {
5760
5885
  });
5761
5886
  }
5762
5887
  };
5888
+ const persistRecordings = async () => {
5889
+ if (!recordingConfig || recordingPersisted) {
5890
+ return;
5891
+ }
5892
+ recordingPersisted = true;
5893
+ const channels = ["assistant", "user"];
5894
+ for (const channel of channels) {
5895
+ if (!recordingChannels.has(channel)) {
5896
+ continue;
5897
+ }
5898
+ const chunks = recordingBuffers[channel];
5899
+ const format = recordingFormats[channel];
5900
+ if (chunks.length === 0 || !format) {
5901
+ continue;
5902
+ }
5903
+ const totalBytes = recordingByteTotals[channel];
5904
+ const merged = new Uint8Array(totalBytes);
5905
+ let offset = 0;
5906
+ for (const chunk of chunks) {
5907
+ merged.set(chunk, offset);
5908
+ offset += chunk.byteLength;
5909
+ }
5910
+ try {
5911
+ const stored = await recordingConfig.store.put({
5912
+ audioBytes: merged,
5913
+ capturedAt: Date.now(),
5914
+ channel,
5915
+ durationMs: computePcmDurationMs(totalBytes, format),
5916
+ format,
5917
+ sessionId: options.id
5918
+ });
5919
+ await appendTrace({
5920
+ payload: {
5921
+ channel,
5922
+ durationMs: stored.durationMs,
5923
+ recordingUrl: stored.recordingUrl,
5924
+ sessionId: options.id,
5925
+ sizeBytes: merged.byteLength
5926
+ },
5927
+ type: "recording.ready"
5928
+ });
5929
+ } catch (error) {
5930
+ logger.warn("voice recording persist failed", {
5931
+ channel,
5932
+ error: toError(error).message,
5933
+ sessionId: options.id
5934
+ });
5935
+ } finally {
5936
+ recordingBuffers[channel] = [];
5937
+ recordingByteTotals[channel] = 0;
5938
+ }
5939
+ }
5940
+ };
5763
5941
  const cancelActiveTTS = async (reason) => {
5764
5942
  const activeSession = ttsSession;
5765
5943
  const cancelledTurnId = activeTTSTurnId;
@@ -5783,6 +5961,8 @@ var createVoiceSession = (options) => {
5783
5961
  };
5784
5962
  const sendAssistantAudio = async (chunk, input) => {
5785
5963
  const normalizedChunk = chunk instanceof Uint8Array ? new Uint8Array(chunk) : chunk instanceof ArrayBuffer ? new Uint8Array(chunk.slice(0)) : new Uint8Array(chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength));
5964
+ captureRecordingChunk("assistant", normalizedChunk, input.format);
5965
+ kickCallSilenceWatchdog();
5786
5966
  await send({
5787
5967
  chunkBase64: encodeBase64(normalizedChunk),
5788
5968
  format: input.format,
@@ -5879,6 +6059,7 @@ var createVoiceSession = (options) => {
5879
6059
  });
5880
6060
  await closeTTSSession("failed");
5881
6061
  await closeAdapter("failed");
6062
+ await persistRecordings();
5882
6063
  speechDetected = false;
5883
6064
  rewindFallbackTurnAudio();
5884
6065
  await options.route.onError?.({
@@ -5947,6 +6128,7 @@ var createVoiceSession = (options) => {
5947
6128
  });
5948
6129
  await closeTTSSession("complete");
5949
6130
  await closeAdapter("complete");
6131
+ await persistRecordings();
5950
6132
  speechDetected = false;
5951
6133
  rewindFallbackTurnAudio();
5952
6134
  if (disposition === "transferred" && input.target) {
@@ -6002,19 +6184,21 @@ var createVoiceSession = (options) => {
6002
6184
  });
6003
6185
  };
6004
6186
  const transferInternal = async (input) => {
6187
+ const transferMetadata = input.transferMode === undefined ? input.metadata : { ...input.metadata ?? {}, transferMode: input.transferMode };
6005
6188
  const session = await writeSession((currentSession) => {
6006
6189
  pushCallLifecycleEvent(currentSession, {
6007
- metadata: input.metadata,
6190
+ metadata: transferMetadata,
6008
6191
  reason: input.reason,
6009
6192
  target: input.target,
6010
6193
  type: "transfer"
6011
6194
  });
6012
6195
  });
6013
6196
  await appendTrace({
6014
- metadata: input.metadata,
6197
+ metadata: transferMetadata,
6015
6198
  payload: {
6016
6199
  reason: input.reason,
6017
6200
  target: input.target,
6201
+ transferMode: input.transferMode,
6018
6202
  type: "transfer"
6019
6203
  },
6020
6204
  session,
@@ -7045,6 +7229,7 @@ var createVoiceSession = (options) => {
7045
7229
  resumePendingTurnCommit(session);
7046
7230
  await ensureAdapter();
7047
7231
  warmTTSSession();
7232
+ kickCallSilenceWatchdog();
7048
7233
  };
7049
7234
  const disconnectInternal = async (event) => {
7050
7235
  clearSilenceTimer();
@@ -7088,12 +7273,17 @@ var createVoiceSession = (options) => {
7088
7273
  if (shouldStoreAudio) {
7089
7274
  pushTurnAudio(conditionedAudio);
7090
7275
  }
7276
+ if (recordingConfig?.userInputFormat) {
7277
+ const userBytes = conditionedAudio instanceof Uint8Array ? conditionedAudio : conditionedAudio instanceof ArrayBuffer ? new Uint8Array(conditionedAudio) : new Uint8Array(conditionedAudio.buffer, conditionedAudio.byteOffset, conditionedAudio.byteLength);
7278
+ captureRecordingChunk("user", userBytes, recordingConfig.userInputFormat);
7279
+ }
7091
7280
  if (audioLevel >= turnDetection.speechThreshold) {
7092
7281
  if (!speechDetected && activeTTSTurnId !== undefined) {
7093
7282
  cancelActiveTTS("barge-in");
7094
7283
  }
7095
7284
  speechDetected = true;
7096
7285
  clearSilenceTimer();
7286
+ kickCallSilenceWatchdog();
7097
7287
  } else if (speechDetected) {
7098
7288
  const currentSession = await readSession();
7099
7289
  const hasTurnText = Boolean(buildTurnText(currentSession.currentTurn.transcripts, currentSession.currentTurn.partialText, {
@@ -7106,43 +7296,49 @@ var createVoiceSession = (options) => {
7106
7296
  }
7107
7297
  await adapter.send(conditionedAudio);
7108
7298
  };
7299
+ const closeInternal = async (reason, disposition = "closed") => {
7300
+ const session = await writeSession((currentSession) => {
7301
+ if (currentSession.status !== "completed" && currentSession.status !== "failed" && !currentSession.call?.endedAt) {
7302
+ currentSession.lastActivityAt = Date.now();
7303
+ currentSession.status = "completed";
7304
+ pushCallLifecycleEvent(currentSession, {
7305
+ disposition,
7306
+ reason,
7307
+ type: "end"
7308
+ });
7309
+ }
7310
+ });
7311
+ clearSilenceTimer();
7312
+ clearCallSilenceWatchdog();
7313
+ await closeTTSSession(reason);
7314
+ await closeAdapter(reason);
7315
+ await persistRecordings();
7316
+ await Promise.resolve(socket.close(1000, reason));
7317
+ if (session.call?.endedAt && session.call.disposition === disposition) {
7318
+ await appendTrace({
7319
+ payload: {
7320
+ disposition,
7321
+ reason,
7322
+ type: "end"
7323
+ },
7324
+ session,
7325
+ type: "call.lifecycle"
7326
+ });
7327
+ await options.route.onCallEnd?.({
7328
+ api,
7329
+ context: options.context,
7330
+ disposition,
7331
+ reason,
7332
+ session
7333
+ });
7334
+ }
7335
+ };
7109
7336
  const api = {
7110
7337
  id: options.id,
7111
7338
  close: async (reason) => {
7112
7339
  await runSerial("api.close", async () => {
7113
- const session = await writeSession((currentSession) => {
7114
- if (currentSession.status !== "completed" && currentSession.status !== "failed" && !currentSession.call?.endedAt) {
7115
- currentSession.lastActivityAt = Date.now();
7116
- currentSession.status = "completed";
7117
- pushCallLifecycleEvent(currentSession, {
7118
- disposition: "closed",
7119
- reason,
7120
- type: "end"
7121
- });
7122
- }
7123
- });
7124
- clearSilenceTimer();
7125
- await closeTTSSession(reason);
7126
- await closeAdapter(reason);
7127
- await Promise.resolve(socket.close(1000, reason));
7128
- if (session.call?.endedAt && session.call.disposition === "closed") {
7129
- await appendTrace({
7130
- payload: {
7131
- disposition: "closed",
7132
- reason,
7133
- type: "end"
7134
- },
7135
- session,
7136
- type: "call.lifecycle"
7137
- });
7138
- await options.route.onCallEnd?.({
7139
- api,
7140
- context: options.context,
7141
- disposition: "closed",
7142
- reason,
7143
- session
7144
- });
7145
- }
7340
+ const disposition = reason === "silence-timeout" ? "silence-timeout" : "closed";
7341
+ await closeInternal(reason, disposition);
7146
7342
  });
7147
7343
  },
7148
7344
  commitTurn: async (reason = "manual") => runSerial("api.commitTurn", async () => {
package/dist/trace.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { S3Client, S3Options } from "bun";
2
- export type VoiceTraceEventType = "assistant.guardrail" | "assistant.memory" | "assistant.run" | "agent.context" | "agent.handoff" | "agent.model" | "agent.result" | "agent.tool" | "call.handoff" | "call.lifecycle" | "client.barge_in" | "client.browser_media" | "client.live_latency" | "client.reconnect" | "client.telephony_media" | "operator.action" | "provider.decision" | "session.error" | "turn.assistant" | "turn.committed" | "turn.cost" | "turn_latency.stage" | "turn.transcript" | "workflow.contract";
2
+ export type VoiceTraceEventType = "assistant.guardrail" | "assistant.memory" | "assistant.run" | "agent.context" | "agent.handoff" | "agent.model" | "agent.result" | "agent.tool" | "call.handoff" | "call.lifecycle" | "client.barge_in" | "client.browser_media" | "client.live_latency" | "client.reconnect" | "client.telephony_media" | "operator.action" | "provider.decision" | "recording.ready" | "session.error" | "turn.assistant" | "turn.committed" | "turn.cost" | "turn_latency.stage" | "turn.transcript" | "workflow.contract";
3
3
  export type VoiceTraceEvent<TPayload extends Record<string, unknown> = Record<string, unknown>> = {
4
4
  at: number;
5
5
  id?: string;
package/dist/types.d.ts CHANGED
@@ -271,7 +271,7 @@ export type VoiceSessionSummary = {
271
271
  status: VoiceSessionStatus;
272
272
  turnCount: number;
273
273
  };
274
- export type VoiceCallDisposition = "completed" | "transferred" | "escalated" | "voicemail" | "no-answer" | "failed" | "closed";
274
+ export type VoiceCallDisposition = "completed" | "transferred" | "escalated" | "voicemail" | "no-answer" | "failed" | "silence-timeout" | "closed";
275
275
  export type VoiceCallLifecycleEvent = {
276
276
  at: number;
277
277
  type: "start" | "end" | "transfer" | "escalation" | "voicemail" | "no-answer";
@@ -455,6 +455,7 @@ export type VoiceSessionHandle<TContext = unknown, TSession extends VoiceSession
455
455
  reason?: string;
456
456
  result?: TResult;
457
457
  target: string;
458
+ transferMode?: "cold" | "warm";
458
459
  }) => Promise<void>;
459
460
  close: (reason?: string) => Promise<void>;
460
461
  snapshot: () => Promise<TSession>;
@@ -467,6 +468,7 @@ export type VoiceRouteResult<TResult = unknown> = {
467
468
  metadata?: Record<string, unknown>;
468
469
  reason?: string;
469
470
  target: string;
471
+ transferMode?: "cold" | "warm";
470
472
  };
471
473
  escalate?: {
472
474
  metadata?: Record<string, unknown>;
@@ -701,6 +703,12 @@ export type VoicePluginConfig<TContext = unknown, TSession extends VoiceSessionR
701
703
  profileSwitchGuard?: VoicePluginProfileSwitchGuardConfig<TContext, TSession, TResult>;
702
704
  trace?: VoiceTraceEventStore;
703
705
  } & VoiceRouteConfig<TContext, TSession, TResult>;
706
+ export type VoiceSessionRecordingConfig = {
707
+ channels?: ReadonlyArray<"assistant" | "user">;
708
+ maxBytesPerChannel?: number;
709
+ store: import("./recordingStore").VoiceRecordingStore;
710
+ userInputFormat?: AudioFormat;
711
+ };
704
712
  export type CreateVoiceSessionOptions<TContext = unknown, TSession extends VoiceSessionRecord = VoiceSessionRecord, TResult = unknown> = {
705
713
  costTelemetry?: VoiceCostTelemetryConfig<TContext, TSession, TResult>;
706
714
  id: string;
@@ -715,6 +723,8 @@ export type CreateVoiceSessionOptions<TContext = unknown, TSession extends Voice
715
723
  sttFallback?: VoiceResolvedSTTFallbackConfig;
716
724
  store: VoiceSessionStore<TSession>;
717
725
  trace?: VoiceTraceEventStore;
726
+ recording?: VoiceSessionRecordingConfig;
727
+ callSilenceTimeoutMs?: number;
718
728
  reconnect: Required<VoiceReconnectConfig>;
719
729
  phraseHints?: VoicePhraseHint[];
720
730
  sessionMetadata?: Record<string, unknown>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@absolutejs/voice",
3
- "version": "0.0.22-beta.476",
3
+ "version": "0.0.22-beta.478",
4
4
  "description": "Voice primitives and Elysia plugin for AbsoluteJS",
5
5
  "repository": {
6
6
  "type": "git",