@openclaw/voice-call 2026.9.2 → 2026.9.4

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.
@@ -1,8 +1,8 @@
1
1
  import { sleep } from "./runtime-api.js";
2
2
  import "./api.js";
3
3
  import { l as resolveVoiceCallStreamExposurePaths, u as validateProviderConfig } from "./config-DQwvsxeV.js";
4
- import { _ as resolveUserPath, c as resolveWebhookExposureStatus, d as setupTailscaleExposureRoutes, g as resolveVoiceCallAgentId, l as cleanupTailscaleExposureRoute, u as getTailscaleSelfInfo } from "./runtime-entry-DNmi-vZR.js";
5
- import { b as setVoiceCallStateRuntime, d as loadActiveCallsFromStore, l as findCallInStore, o as MAX_CALL_RECORD_EVENTS, t as resolveDefaultVoiceCallStoreDir, u as getCallHistoryFromStore } from "./store-path-C_z9zKnA.js";
4
+ import { d as cleanupTailscaleExposureRoute, f as getTailscaleSelfInfo, p as setupTailscaleExposureRoutes, u as resolveWebhookExposureStatus, v as resolveVoiceCallAgentId, y as resolveUserPath } from "./runtime-entry-Ln4SWaYK.js";
5
+ import { b as setVoiceCallStateRuntime, d as loadActiveCallsFromStore, l as findCallInStore, o as MAX_CALL_RECORD_EVENTS, t as resolveDefaultVoiceCallStoreDir, u as getCallHistoryFromStore } from "./store-path-M0GXxUfv.js";
6
6
  import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
7
7
  import { callGatewayFromCli, isGatewayClientRequestError, isGatewayTransportError, redactSensitiveUrlLikeString } from "openclaw/plugin-sdk/gateway-runtime";
8
8
  import { isRecord, normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
@@ -1,4 +1,4 @@
1
- import { a as CALL_RECORD_EVENT_META_MAX_ENTRIES, c as buildVoiceCallLegacyJsonlEventKey, f as parseVoiceCallRecordLine, h as resolveVoiceCallLegacyCallLogPath, i as CALL_RECORD_EVENT_CHUNKS_NAMESPACE, m as prepareVoiceCallRecordForStorage, n as CALL_RECORD_CHUNK_MAX_ENTRIES, o as MAX_CALL_RECORD_EVENTS, r as CALL_RECORD_EVENTS_NAMESPACE, s as RAW_CALL_RECORD_CHUNK_BYTES, t as resolveDefaultVoiceCallStoreDir } from "./store-path-C_z9zKnA.js";
1
+ import { a as CALL_RECORD_EVENT_META_MAX_ENTRIES, c as buildVoiceCallLegacyJsonlEventKey, f as parseVoiceCallRecordLine, h as resolveVoiceCallLegacyCallLogPath, i as CALL_RECORD_EVENT_CHUNKS_NAMESPACE, m as prepareVoiceCallRecordForStorage, n as CALL_RECORD_CHUNK_MAX_ENTRIES, o as MAX_CALL_RECORD_EVENTS, r as CALL_RECORD_EVENTS_NAMESPACE, s as RAW_CALL_RECORD_CHUNK_BYTES, t as resolveDefaultVoiceCallStoreDir } from "./store-path-M0GXxUfv.js";
2
2
  import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
3
3
  import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
4
4
  import path from "node:path";
@@ -68,6 +68,8 @@ function describeVoiceCallSchemaMigration(migration) {
68
68
  case "state-consolidation-v13": return "cron jobs and subagent runs -> canonical JSON storage";
69
69
  case "creator-namespace-v14": return "cron creators -> explicit principal namespaces";
70
70
  case "conversation-binding-targets-v15": return "conversation bindings -> exact target keys without agent/session projections";
71
+ case "skill-workshop-directory-ownership-v16": return "Skill Workshop proposals -> per-agent Workshop directory ownership";
72
+ case "prepared-worker-ownership-v17": return "prepared workers -> one-use capacity and fixed workspace ownership";
71
73
  case "worker-placement-execution-mode-v8": return "cloud worker placements -> execution-mode claims";
72
74
  case "operator-approvals-system-agent": return "operator approvals -> OpenClaw system changes";
73
75
  case "session-watch-cursor-provenance-v4": return "session watch cursors -> provenance column";
@@ -1,6 +1,6 @@
1
1
  import { fetchWithSsrFGuard } from "./runtime-api.js";
2
2
  import "./api.js";
3
- import { n as normalizeProxyIp, s as getHeader } from "./runtime-entry-DNmi-vZR.js";
3
+ import { l as getHeader, n as normalizeProxyIp } from "./runtime-entry-Ln4SWaYK.js";
4
4
  import { n as reserveWebhookReplay, t as createWebhookReplayCache } from "./webhook-replay-CCQSoBNF.js";
5
5
  import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
6
6
  import { isLoopbackHost } from "openclaw/plugin-sdk/gateway-runtime";
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ import { definePluginEntry } from "./runtime-api.js";
2
2
  import "./api.js";
3
3
  import { VOICE_CALL_CLI_DESCRIPTOR } from "./cli-output-mode.js";
4
4
  import { i as resolveVoiceCallConfig, t as VoiceCallConfigSchema, u as validateProviderConfig } from "./config-DQwvsxeV.js";
5
- import { t as createVoiceCallRuntime } from "./runtime-entry-DNmi-vZR.js";
5
+ import { t as createVoiceCallRuntime } from "./runtime-entry-Ln4SWaYK.js";
6
6
  import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
7
7
  import { ErrorCodes, errorShape } from "openclaw/plugin-sdk/gateway-runtime";
8
8
  import { resolveGlobalSingleton } from "openclaw/plugin-sdk/global-singleton";
@@ -514,7 +514,7 @@ var voice_call_default = definePluginEntry({
514
514
  }
515
515
  }));
516
516
  api.registerCli(async ({ program }) => {
517
- const { registerVoiceCallCli } = await import("./cli-D3DeAqfd.js");
517
+ const { registerVoiceCallCli } = await import("./cli-BGdihqyA.js");
518
518
  registerVoiceCallCli({
519
519
  program,
520
520
  config,
@@ -1,5 +1,5 @@
1
- import { s as getHeader, v as escapeXml } from "./runtime-entry-DNmi-vZR.js";
2
- import { a as reconstructWebhookUrl, o as verifyPlivoWebhook, t as guardedJsonApiRequest } from "./guarded-json-api-BhLtja0F.js";
1
+ import { b as escapeXml, l as getHeader } from "./runtime-entry-Ln4SWaYK.js";
2
+ import { a as reconstructWebhookUrl, o as verifyPlivoWebhook, t as guardedJsonApiRequest } from "./guarded-json-api-3Jy2k-eX.js";
3
3
  import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
4
4
  import crypto from "node:crypto";
5
5
  //#region extensions/voice-call/src/providers/plivo.ts
@@ -1,5 +1,5 @@
1
1
  import { d as REALTIME_VOICE_END_CALL_TOOL_NAME, s as resolveVoiceCallPublicPathPrefix } from "./config-DQwvsxeV.js";
2
- import { o as canonicalizeVoiceCallMediaBase64 } from "./runtime-entry-DNmi-vZR.js";
2
+ import { c as canonicalizeVoiceCallMediaBase64, o as WebSocket, s as WebSocketServer } from "./runtime-entry-Ln4SWaYK.js";
3
3
  import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
4
4
  import { asNullableRecord, asOptionalObjectRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
5
5
  import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
@@ -9,13 +9,14 @@ import { isFutureDateTimestampMs, resolveExpiresAtMsFromDurationMs } from "openc
9
9
  import { randomUUID } from "node:crypto";
10
10
  import { sliceUtf16Safe, truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
11
11
  import "node:http";
12
- import WebSocket$1, { WebSocketServer } from "ws";
13
12
  //#region extensions/voice-call/src/webhook/realtime-audio-pacer.ts
14
13
  const TELEPHONY_SAMPLE_RATE = 8e3;
15
14
  const TELEPHONY_CHUNK_BYTES = 160;
16
15
  const LEAD_MS = 160;
17
16
  const DEFAULT_MAX_QUEUED_AUDIO_BYTES = TELEPHONY_SAMPLE_RATE * 120;
18
17
  const QUEUE_COMPACT_HEAD_THRESHOLD = 256;
18
+ const MAX_PLAYBACK_SEGMENTS = 128;
19
+ const MAX_PENDING_MARK_BOUNDARIES = 64;
19
20
  /** Paces outgoing mulaw audio frames at telephony cadence. */
20
21
  var RealtimeAudioPacer = class {
21
22
  constructor(params) {
@@ -26,9 +27,18 @@ var RealtimeAudioPacer = class {
26
27
  this.queuedAudioBytes = 0;
27
28
  this.closed = false;
28
29
  this.streamClockMs = null;
30
+ this.playbackSegments = [];
31
+ this.sentAudioMs = 0;
32
+ this.retiredAudioMs = 0;
33
+ this.confirmedPlayedMs = 0;
34
+ this.playbackMarkPrefix = `openclaw-playout-${randomUUID()}`;
35
+ this.playbackMarkSequence = 0;
36
+ this.lastPlaybackMarkMs = 0;
37
+ this.markBoundaries = [];
38
+ this.retiredItemOffsets = /* @__PURE__ */ new Map();
29
39
  }
30
40
  /** Queue mulaw audio and split it into 20ms-ish telephony chunks. */
31
- sendAudio(muLaw) {
41
+ sendAudio(muLaw, metadata) {
32
42
  if (this.closed || muLaw.length === 0) return;
33
43
  const maxQueuedAudioBytes = this.params.maxQueuedAudioBytes ?? DEFAULT_MAX_QUEUED_AUDIO_BYTES;
34
44
  for (let offset = 0; offset < muLaw.length; offset += TELEPHONY_CHUNK_BYTES) {
@@ -37,15 +47,42 @@ var RealtimeAudioPacer = class {
37
47
  this.failBackpressure();
38
48
  return;
39
49
  }
50
+ const durationMs = chunk.length / 8;
51
+ const segment = this.extendPlaybackSegment(metadata?.itemId, durationMs);
52
+ if (!segment) {
53
+ this.failBackpressure();
54
+ return;
55
+ }
40
56
  this.queue.push({
41
57
  type: "audio",
42
58
  chunk,
43
- durationMs: chunk.length / 8
59
+ durationMs,
60
+ segment
44
61
  });
45
62
  this.queuedAudioBytes += chunk.length;
46
63
  }
47
64
  this.ensurePump();
48
65
  }
66
+ /**
67
+ * Retained provider items in playback order with consumed playout duration.
68
+ * Queued audio has not reached the telephony line, so unplayed items report zero.
69
+ */
70
+ getPlaybackState() {
71
+ let remaining = Math.max(0, this.confirmedPlayedMs - this.retiredAudioMs);
72
+ const playedByItem = /* @__PURE__ */ new Map();
73
+ for (const segment of this.playbackSegments) {
74
+ const consumed = Math.min(remaining, segment.sentMs);
75
+ remaining -= consumed;
76
+ if (segment.itemId !== void 0) {
77
+ const previousMs = playedByItem.get(segment.itemId) ?? this.retiredItemOffsets.get(segment.itemId) ?? 0;
78
+ playedByItem.set(segment.itemId, previousMs + consumed);
79
+ }
80
+ }
81
+ return Array.from(playedByItem, ([itemId, audioEndMs]) => ({
82
+ itemId,
83
+ audioEndMs: Math.floor(audioEndMs)
84
+ }));
85
+ }
49
86
  /** Queue a provider mark frame after prior audio frames. */
50
87
  sendMark(name) {
51
88
  if (this.closed || !name) return;
@@ -55,14 +92,32 @@ var RealtimeAudioPacer = class {
55
92
  });
56
93
  this.ensurePump();
57
94
  }
95
+ /** Retire the playback prefix once the carrier confirms playout reached the mark. */
96
+ acknowledgeMark(name) {
97
+ if (this.closed || !name) return;
98
+ const boundaryIndex = this.markBoundaries.findIndex((boundary) => boundary.name === name);
99
+ if (boundaryIndex < 0) return;
100
+ const boundary = this.markBoundaries[boundaryIndex];
101
+ this.markBoundaries.splice(0, boundaryIndex + 1);
102
+ if (!boundary) return;
103
+ while (this.playbackSegments.length > 0) {
104
+ const head = this.playbackSegments[0];
105
+ if (!head || head.sentMs < head.totalMs || head.lastSentEndMs === void 0 || head.lastSentEndMs > boundary.sentMs) break;
106
+ const retired = this.playbackSegments.shift();
107
+ if (retired) {
108
+ this.retiredAudioMs += retired.sentMs;
109
+ if (retired.itemId !== void 0) this.retiredItemOffsets.set(retired.itemId, (this.retiredItemOffsets.get(retired.itemId) ?? 0) + retired.sentMs);
110
+ }
111
+ }
112
+ this.confirmedPlayedMs = Math.max(this.confirmedPlayedMs, Math.min(boundary.sentMs, this.sentAudioMs));
113
+ }
58
114
  /** Clear queued audio and notify the provider stream. */
59
115
  clearAudio() {
60
116
  if (this.closed) return 0;
61
117
  const clearedAudioBytes = this.queuedAudioBytes;
62
118
  this.clearTimer();
63
119
  this.resetQueue();
64
- this.queuedAudioBytes = 0;
65
- this.streamClockMs = null;
120
+ this.resetPlaybackState();
66
121
  this.params.send(this.params.serializer.clear());
67
122
  return clearedAudioBytes;
68
123
  }
@@ -75,8 +130,33 @@ var RealtimeAudioPacer = class {
75
130
  this.closed = true;
76
131
  this.clearTimer();
77
132
  this.resetQueue();
133
+ this.resetPlaybackState();
134
+ }
135
+ extendPlaybackSegment(itemId, durationMs) {
136
+ let segment = this.playbackSegments.at(-1);
137
+ if (!segment || segment.itemId !== itemId) {
138
+ if (this.playbackSegments.length >= MAX_PLAYBACK_SEGMENTS) return null;
139
+ segment = {
140
+ itemId,
141
+ totalMs: 0,
142
+ sentMs: 0
143
+ };
144
+ this.playbackSegments.push(segment);
145
+ }
146
+ segment.totalMs += durationMs;
147
+ return segment;
148
+ }
149
+ resetPlaybackState() {
150
+ this.playbackSegments = [];
78
151
  this.queuedAudioBytes = 0;
152
+ this.sentAudioMs = 0;
153
+ this.retiredAudioMs = 0;
154
+ this.confirmedPlayedMs = 0;
155
+ this.lastPlaybackMarkMs = 0;
156
+ this.markBoundaries = [];
157
+ this.retiredItemOffsets.clear();
79
158
  this.streamClockMs = null;
159
+ this.params.onPlaybackReset?.();
80
160
  }
81
161
  /** Clear the scheduled pump timer. */
82
162
  clearTimer() {
@@ -124,7 +204,7 @@ var RealtimeAudioPacer = class {
124
204
  while (this.pendingQueueSize > 0 && this.streamClockMs < now + LEAD_MS) {
125
205
  const item = this.takeNextItem();
126
206
  if (!item) break;
127
- if (!(item.type === "audio" ? this.sendAudioItem(item) : this.params.send(this.params.serializer.mark(item.name)))) {
207
+ if (!(item.type === "audio" ? this.sendAudioItem(item) : this.sendMarkItem(item))) {
128
208
  this.resetQueue();
129
209
  this.queuedAudioBytes = 0;
130
210
  this.streamClockMs = null;
@@ -141,7 +221,30 @@ var RealtimeAudioPacer = class {
141
221
  sendAudioItem(item) {
142
222
  this.queuedAudioBytes = Math.max(0, this.queuedAudioBytes - item.chunk.length);
143
223
  const sent = this.params.send(this.params.serializer.media(item.chunk.toString("base64")));
224
+ if (sent) {
225
+ item.segment.sentMs += item.durationMs;
226
+ this.sentAudioMs += item.durationMs;
227
+ item.segment.lastSentEndMs = this.sentAudioMs;
228
+ }
144
229
  this.streamClockMs = (this.streamClockMs ?? performance.now()) + item.durationMs;
230
+ if (!sent || item.segment.itemId === void 0 || this.sentAudioMs - this.lastPlaybackMarkMs < LEAD_MS) return sent;
231
+ this.lastPlaybackMarkMs = this.sentAudioMs;
232
+ this.playbackMarkSequence += 1;
233
+ return this.sendMarkItem({
234
+ type: "mark",
235
+ name: `${this.playbackMarkPrefix}-${this.playbackMarkSequence}`
236
+ });
237
+ }
238
+ /** Send a queued mark frame and bind it to the playback prefix before it. */
239
+ sendMarkItem(item) {
240
+ const sent = this.params.send(this.params.serializer.mark(item.name));
241
+ if (sent) {
242
+ this.markBoundaries.push({
243
+ name: item.name,
244
+ sentMs: this.sentAudioMs
245
+ });
246
+ if (this.markBoundaries.length > MAX_PENDING_MARK_BOUNDARIES) this.markBoundaries.shift();
247
+ }
145
248
  return sent;
146
249
  }
147
250
  };
@@ -443,6 +546,15 @@ function appendRecentTalkEventMetadata(call, event) {
443
546
  }].slice(-12);
444
547
  call.metadata = metadata;
445
548
  }
549
+ function rejectRealtimeUpgrade(socket, status) {
550
+ const reason = status === 401 ? "Unauthorized" : "Service Unavailable";
551
+ try {
552
+ socket.end(`HTTP/1.1 ${status} ${reason}\r\nConnection: close\r\n\r\n`, () => socket.destroy());
553
+ } catch (error) {
554
+ socket.destroy();
555
+ throw error;
556
+ }
557
+ }
446
558
  var RealtimeCallHandler = class {
447
559
  constructor(config, manager, resolveCallRegistration, servePath, streamDisconnectLifecycle, coreConfig) {
448
560
  this.config = config;
@@ -506,16 +618,15 @@ var RealtimeCallHandler = class {
506
618
  }
507
619
  }
508
620
  handleWebSocketUpgrade(request, socket, head) {
621
+ socket.once("error", () => socket.destroy());
509
622
  if (this.closing) {
510
- socket.write("HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n");
511
- socket.destroy();
623
+ rejectRealtimeUpgrade(socket, 503);
512
624
  return;
513
625
  }
514
626
  const token = new URL(request.url ?? "/", "wss://localhost").pathname.split("/").pop() ?? null;
515
627
  const callerMeta = token ? this.consumeStreamToken(token) : null;
516
628
  if (!callerMeta) {
517
- socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
518
- socket.destroy();
629
+ rejectRealtimeUpgrade(socket, 401);
519
630
  return;
520
631
  }
521
632
  const adapter = (callerMeta.providerName ?? "twilio") === "telnyx" ? new TelnyxStreamFrameAdapter() : new TwilioStreamFrameAdapter();
@@ -565,7 +676,8 @@ var RealtimeCallHandler = class {
565
676
  return;
566
677
  }
567
678
  if (frame.kind === "mark") {
568
- telephonyBinding.bridge.acknowledgeMark();
679
+ telephonyBinding.acknowledgeCarrierMark(frame.name);
680
+ telephonyBinding.bridge.acknowledgeMark(frame.name);
569
681
  return;
570
682
  }
571
683
  if (frame.kind === "error") {
@@ -598,7 +710,7 @@ var RealtimeCallHandler = class {
598
710
  this.pendingStreamTokens.clear();
599
711
  const sockets = [...this.activeSockets];
600
712
  this.closePromise = Promise.all([shutdownBarrier, ...sockets.map((ws) => new Promise((resolve) => {
601
- if (ws.readyState === WebSocket$1.CLOSED) {
713
+ if (ws.readyState === WebSocket.CLOSED) {
602
714
  resolve();
603
715
  return;
604
716
  }
@@ -790,7 +902,7 @@ var RealtimeCallHandler = class {
790
902
  });
791
903
  console.log(`[voice-call] Realtime bridge starting for call ${callId} (providerCallId=${callSid}, initialGreeting=${initialGreetingInstructions ? "queued" : "absent"})`);
792
904
  const sendString = (message) => {
793
- if (ws.readyState !== WebSocket$1.OPEN) return false;
905
+ if (ws.readyState !== WebSocket.OPEN) return false;
794
906
  if (ws.bufferedAmount > MAX_REALTIME_WS_BUFFERED_BYTES) {
795
907
  console.warn(`[voice-call] realtime outbound websocket backpressure before send callId=${callId} providerCallId=${callSid} bufferedBytes=${ws.bufferedAmount}`);
796
908
  ws.close(1013, "Backpressure: send buffer exceeded");
@@ -804,7 +916,9 @@ var RealtimeCallHandler = class {
804
916
  }
805
917
  return true;
806
918
  };
919
+ const pendingMarkAcks = /* @__PURE__ */ new Map();
807
920
  const audioPacer = new RealtimeAudioPacer({
921
+ onPlaybackReset: () => pendingMarkAcks.clear(),
808
922
  send: sendString,
809
923
  serializer: {
810
924
  media: (payload) => adapter.serializeMedia(payload),
@@ -813,7 +927,7 @@ var RealtimeCallHandler = class {
813
927
  },
814
928
  onBackpressure: () => {
815
929
  console.warn(`[voice-call] realtime paced audio backpressure callId=${callId} providerCallId=${callSid}`);
816
- if (ws.readyState === WebSocket$1.OPEN) ws.close(1013, "Backpressure: paced audio queue exceeded");
930
+ if (ws.readyState === WebSocket.OPEN) ws.close(1013, "Backpressure: paced audio queue exceeded");
817
931
  }
818
932
  });
819
933
  const speechDetector = createSpeechThresholdGate({
@@ -839,11 +953,12 @@ var RealtimeCallHandler = class {
839
953
  initialGreetingInstructions,
840
954
  triggerGreetingOnReady: Boolean(initialGreetingInstructions),
841
955
  audioSink: {
842
- isOpen: () => ws.readyState === WebSocket$1.OPEN,
843
- sendAudio: (muLaw) => {
956
+ isOpen: () => ws.readyState === WebSocket.OPEN,
957
+ sendAudio: (muLaw, metadata) => {
844
958
  harness.recordOutputAudio(muLaw);
845
- audioPacer.sendAudio(muLaw);
959
+ audioPacer.sendAudio(muLaw, metadata);
846
960
  },
961
+ getPlaybackState: () => audioPacer.getPlaybackState(),
847
962
  clearAudio: (reason) => {
848
963
  harness.flushOutput(() => {
849
964
  const clearedBytes = audioPacer.clearAudio();
@@ -855,8 +970,9 @@ var RealtimeCallHandler = class {
855
970
  harness.finishOutputAudio("clear");
856
971
  });
857
972
  },
858
- sendMark: (markName) => {
973
+ sendMark: (markName, acknowledge) => {
859
974
  audioPacer.sendMark(markName);
975
+ if (markName && acknowledge) pendingMarkAcks.set(markName, acknowledge);
860
976
  }
861
977
  },
862
978
  onTranscript: (role, text, isFinal) => {
@@ -1035,7 +1151,7 @@ var RealtimeCallHandler = class {
1035
1151
  if (ownsCallState) this.clearUserTranscriptState(callId, userTranscriptOwner);
1036
1152
  if (reason === "completed" && (!ownsCallState || sessionClosed)) return;
1037
1153
  this.streamDisconnectLifecycle.retire(callSid, streamSid);
1038
- if (ws.readyState === WebSocket$1.OPEN) ws.close(reason === "error" ? 1011 : 1e3, "Bridge disconnected");
1154
+ if (ws.readyState === WebSocket.OPEN) ws.close(reason === "error" ? 1011 : 1e3, "Bridge disconnected");
1039
1155
  if (ownsCallState) emitCallEnd(reason);
1040
1156
  }
1041
1157
  };
@@ -1056,7 +1172,7 @@ var RealtimeCallHandler = class {
1056
1172
  audioPacer.close();
1057
1173
  const reason = provisionalCloseReason ?? "error";
1058
1174
  if (!hadPredecessorOnAdmission || !this.activeBridgesByCallId.has(callId)) emitCallEnd(reason);
1059
- if (ws.readyState === WebSocket$1.OPEN) ws.close(reason === "error" ? 1011 : 1e3, "Failed to create realtime bridge");
1175
+ if (ws.readyState === WebSocket.OPEN) ws.close(reason === "error" ? 1011 : 1e3, "Failed to create realtime bridge");
1060
1176
  return null;
1061
1177
  }
1062
1178
  const session = candidate;
@@ -1125,10 +1241,19 @@ var RealtimeCallHandler = class {
1125
1241
  };
1126
1242
  const telephonyBinding = {
1127
1243
  bridge: session,
1244
+ acknowledgeCarrierMark: (markName) => {
1245
+ audioPacer.acknowledgeMark(markName);
1246
+ if (!markName) return;
1247
+ const acknowledge = pendingMarkAcks.get(markName);
1248
+ if (acknowledge) {
1249
+ pendingMarkAcks.delete(markName);
1250
+ acknowledge();
1251
+ }
1252
+ },
1128
1253
  close: (cause) => closeBinding(telephonyBinding, cause),
1129
1254
  endCall: () => {
1130
1255
  closeBinding(telephonyBinding);
1131
- if (ws.readyState === WebSocket$1.OPEN) ws.close(1e3, "Call ended");
1256
+ if (ws.readyState === WebSocket.OPEN) ws.close(1e3, "Call ended");
1132
1257
  },
1133
1258
  noteMediaActivity: () => {
1134
1259
  if (bindingClosed || this.activeTelephonyBindingsByCallId.get(callId) !== telephonyBinding) return;
@@ -1137,7 +1262,7 @@ var RealtimeCallHandler = class {
1137
1262
  console.warn(`[voice-call] Realtime media inactive callId=${callId} providerCallId=${callSid} timeoutMs=${REALTIME_MEDIA_INACTIVITY_TIMEOUT_MS} graceMs=${REALTIME_DISCONNECT_HANGUP_GRACE_MS}`);
1138
1263
  livenessTimer = setTimeout(() => {
1139
1264
  telephonyBinding.close("inactivity");
1140
- if (ws.readyState === WebSocket$1.OPEN) ws.close(1e3, "Media inactivity");
1265
+ if (ws.readyState === WebSocket.OPEN) ws.close(1e3, "Media inactivity");
1141
1266
  }, REALTIME_DISCONNECT_HANGUP_GRACE_MS);
1142
1267
  livenessTimer.unref?.();
1143
1268
  }, REALTIME_MEDIA_INACTIVITY_TIMEOUT_MS);
@@ -1,5 +1,5 @@
1
1
  import { c as resolveVoiceCallSessionKey } from "./config-DQwvsxeV.js";
2
- import { h as resolveCallAgentId, m as resolveVoiceResponseModel } from "./runtime-entry-DNmi-vZR.js";
2
+ import { _ as resolveCallAgentId, g as resolveVoiceResponseModel } from "./runtime-entry-Ln4SWaYK.js";
3
3
  import { filterStringEntries, isRecord, normalizeLowercaseStringOrEmpty, normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
4
4
  import { resolveAgentConfig } from "openclaw/plugin-sdk/agent-scope-runtime";
5
5
  import { isValidAgentHarnessSessionStoreEntry } from "openclaw/plugin-sdk/session-store-runtime";
@@ -1,7 +1,8 @@
1
1
  import { isBlockedHostnameOrIp, isRequestBodyLimitError, readRequestBodyWithLimit, requestBodyErrorToText, sendHttpRequestRejection } from "./runtime-api.js";
2
2
  import "./api.js";
3
3
  import { a as resolveVoiceCallEffectiveConfig, c as resolveVoiceCallSessionKey, f as resolveVoiceCallRealtimeTools, i as resolveVoiceCallConfig, l as resolveVoiceCallStreamExposurePaths, n as normalizeVoiceCallConfig, o as resolveVoiceCallNumberRouteKeyForCall, r as resolveTwilioAuthToken, u as validateProviderConfig } from "./config-DQwvsxeV.js";
4
- import { _ as releaseRejectedProviderCall, b as setVoiceCallStateRuntime, d as loadActiveCallsFromStore, g as appendCallReplayKey, l as findCallInStore, p as persistCallRecord, t as resolveDefaultVoiceCallStoreDir, u as getCallHistoryFromStore, v as rememberManagerReplayKey, x as TerminalStates, y as reserveRejectedProviderCall } from "./store-path-C_z9zKnA.js";
4
+ import { _ as releaseRejectedProviderCall, b as setVoiceCallStateRuntime, d as loadActiveCallsFromStore, g as appendCallReplayKey, l as findCallInStore, p as persistCallRecord, t as resolveDefaultVoiceCallStoreDir, u as getCallHistoryFromStore, v as rememberManagerReplayKey, x as TerminalStates, y as reserveRejectedProviderCall } from "./store-path-M0GXxUfv.js";
5
+ import { createRequire } from "node:module";
5
6
  import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
6
7
  import { isLoopbackHost } from "openclaw/plugin-sdk/gateway-runtime";
7
8
  import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
@@ -24,7 +25,7 @@ import { runCommandWithTimeout } from "openclaw/plugin-sdk/process-runtime";
24
25
  import http from "node:http";
25
26
  import { URL as URL$1 } from "node:url";
26
27
  import { resolveConfiguredCapabilityProvider } from "openclaw/plugin-sdk/provider-selection-runtime";
27
- import { WebSocket, WebSocketServer } from "ws";
28
+ import { rejectWebSocketUpgrade } from "openclaw/plugin-sdk/websocket-runtime";
28
29
  import { canonicalizeBase64 } from "openclaw/plugin-sdk/media-runtime";
29
30
  //#region extensions/voice-call/src/allowlist.ts
30
31
  /** Normalize a phone number to digits only. */
@@ -234,14 +235,14 @@ function escapeXml(text) {
234
235
  /**
235
236
  * Map of OpenAI voice names to similar Twilio Polly voices.
236
237
  */
237
- const OPENAI_TO_POLLY_MAP = {
238
- alloy: "Polly.Joanna",
239
- echo: "Polly.Matthew",
240
- fable: "Polly.Amy",
241
- onyx: "Polly.Brian",
242
- nova: "Polly.Salli",
243
- shimmer: "Polly.Kimberly"
244
- };
238
+ const OPENAI_TO_POLLY_MAP = /* @__PURE__ */ new Map([
239
+ ["alloy", "Polly.Joanna"],
240
+ ["echo", "Polly.Matthew"],
241
+ ["fable", "Polly.Amy"],
242
+ ["onyx", "Polly.Brian"],
243
+ ["nova", "Polly.Salli"],
244
+ ["shimmer", "Polly.Kimberly"]
245
+ ]);
245
246
  /**
246
247
  * Default Polly voice when no mapping is found.
247
248
  */
@@ -256,7 +257,7 @@ const DEFAULT_POLLY_VOICE = "Polly.Joanna";
256
257
  function mapVoiceToPolly(voice) {
257
258
  if (!voice) return DEFAULT_POLLY_VOICE;
258
259
  if (voice.startsWith("Polly.") || voice.startsWith("Google.")) return voice;
259
- return OPENAI_TO_POLLY_MAP[normalizeLowercaseStringOrEmpty(voice)] || DEFAULT_POLLY_VOICE;
260
+ return OPENAI_TO_POLLY_MAP.get(normalizeLowercaseStringOrEmpty(voice)) ?? DEFAULT_POLLY_VOICE;
260
261
  }
261
262
  //#endregion
262
263
  //#region extensions/voice-call/src/manager/twiml.ts
@@ -1850,6 +1851,10 @@ function canonicalizeVoiceCallMediaBase64(payloadBase64) {
1850
1851
  return canonicalizeBase64(payloadBase64.replaceAll("-", "+").replaceAll("_", "/"));
1851
1852
  }
1852
1853
  //#endregion
1854
+ //#region extensions/voice-call/src/websocket.ts
1855
+ const require = createRequire(import.meta.url);
1856
+ const { WebSocket, WebSocketServer } = require(path.join(path.dirname(require.resolve("ws/package.json")), "index.js"));
1857
+ //#endregion
1853
1858
  //#region extensions/voice-call/src/media-stream.ts
1854
1859
  const DEFAULT_PRE_START_TIMEOUT_MS = 5e3;
1855
1860
  const DEFAULT_MAX_PENDING_CONNECTIONS = 32;
@@ -2588,7 +2593,7 @@ function normalizeProviderStatus(status) {
2588
2593
  /** Map terminal provider status strings to OpenClaw end reasons. */
2589
2594
  function mapProviderStatusToEndReason(status) {
2590
2595
  const normalized = normalizeProviderStatus(status);
2591
- return TERMINAL_PROVIDER_STATUS_TO_END_REASON[normalized] ?? null;
2596
+ return Object.hasOwn(TERMINAL_PROVIDER_STATUS_TO_END_REASON, normalized) ? TERMINAL_PROVIDER_STATUS_TO_END_REASON[normalized] ?? null : null;
2592
2597
  }
2593
2598
  /** Return true when a provider status is terminal. */
2594
2599
  function isProviderStatusTerminal(status) {
@@ -2687,7 +2692,7 @@ const MAX_WEBHOOK_BODY_BYTES = WEBHOOK_BODY_READ_DEFAULTS.preAuth.maxBytes;
2687
2692
  const WEBHOOK_BODY_TIMEOUT_MS = WEBHOOK_BODY_READ_DEFAULTS.preAuth.timeoutMs;
2688
2693
  const MISSING_REMOTE_ADDRESS_IN_FLIGHT_KEY = "__voice_call_no_remote__";
2689
2694
  const loadRealtimeTranscriptionRuntime = createLazyRuntimeModule(() => import("./realtime-transcription.runtime-CbJAs5t_.js"));
2690
- const loadResponseGeneratorModule = createLazyRuntimeModule(() => import("./response-generator-BK43h7yV.js"));
2695
+ const loadResponseGeneratorModule = createLazyRuntimeModule(() => import("./response-generator-DsdC88Xg.js"));
2691
2696
  function appendRecentTalkEventMetadata(call, event) {
2692
2697
  const metadata = call.metadata ?? {};
2693
2698
  const recent = Array.isArray(metadata.recentTalkEvents) ? metadata.recentTalkEvents.filter((entry) => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry)) : [];
@@ -2997,7 +3002,10 @@ var VoiceCallWebhookServer = class {
2997
3002
  return;
2998
3003
  }
2999
3004
  if (this.getUpgradePathname(request) === streamPath && this.mediaStreamHandler) this.mediaStreamHandler?.handleUpgrade(request, socket, head);
3000
- else socket.destroy();
3005
+ else {
3006
+ socket.once("error", () => {});
3007
+ rejectWebSocketUpgrade(socket, { status: 404 });
3008
+ }
3001
3009
  });
3002
3010
  this.server.on("error", (err) => {
3003
3011
  this.server = null;
@@ -3413,12 +3421,12 @@ const REALTIME_VOICE_CONSULT_SYSTEM_PROMPT = [
3413
3421
  "Do not print secret values or dump environment variables; only check whether required configuration is present.",
3414
3422
  "Be accurate, brief, and speakable."
3415
3423
  ].join(" ");
3416
- const loadTelnyxProvider = createLazyRuntimeModule(() => import("./telnyx-DCbnTTjn.js"));
3417
- const loadTwilioProvider = createLazyRuntimeModule(() => import("./twilio-BwJFcA2z.js"));
3418
- const loadPlivoProvider = createLazyRuntimeModule(() => import("./plivo-qIMf0pd2.js"));
3424
+ const loadTelnyxProvider = createLazyRuntimeModule(() => import("./telnyx-eCxGOUfA.js"));
3425
+ const loadTwilioProvider = createLazyRuntimeModule(() => import("./twilio-DQKhnXXE.js"));
3426
+ const loadPlivoProvider = createLazyRuntimeModule(() => import("./plivo-BsdFT1gS.js"));
3419
3427
  const loadMockProvider = createLazyRuntimeModule(() => import("./mock-C57qj18W.js"));
3420
3428
  const loadRealtimeVoiceRuntime = createLazyRuntimeModule(() => import("./realtime-voice.runtime-vtdCOWg-.js"));
3421
- const loadRealtimeHandler = createLazyRuntimeModule(() => import("./realtime-handler-CHCnMJfl.js"));
3429
+ const loadRealtimeHandler = createLazyRuntimeModule(() => import("./realtime-handler-C7N4qzvf.js"));
3422
3430
  function resolveVoiceCallConsultSessionKey(call) {
3423
3431
  return resolveVoiceCallSessionKey({
3424
3432
  config: call.config,
@@ -3760,4 +3768,4 @@ async function createVoiceCallRuntime(params) {
3760
3768
  }
3761
3769
  }
3762
3770
  //#endregion
3763
- export { resolveUserPath as _, normalizeProviderStatus as a, resolveWebhookExposureStatus as c, setupTailscaleExposureRoutes as d, TELEPHONY_DEFAULT_TTS_TIMEOUT_MS as f, resolveVoiceCallAgentId as g, resolveCallAgentId as h, mapProviderStatusToEndReason as i, cleanupTailscaleExposureRoute as l, resolveVoiceResponseModel as m, normalizeProxyIp as n, canonicalizeVoiceCallMediaBase64 as o, chunkAudio as p, isProviderStatusTerminal as r, getHeader as s, createVoiceCallRuntime as t, getTailscaleSelfInfo as u, escapeXml as v, mapVoiceToPolly as y };
3771
+ export { resolveCallAgentId as _, normalizeProviderStatus as a, escapeXml as b, canonicalizeVoiceCallMediaBase64 as c, cleanupTailscaleExposureRoute as d, getTailscaleSelfInfo as f, resolveVoiceResponseModel as g, chunkAudio as h, mapProviderStatusToEndReason as i, getHeader as l, TELEPHONY_DEFAULT_TTS_TIMEOUT_MS as m, normalizeProxyIp as n, WebSocket as o, setupTailscaleExposureRoutes as p, isProviderStatusTerminal as r, WebSocketServer as s, createVoiceCallRuntime as t, resolveWebhookExposureStatus as u, resolveVoiceCallAgentId as v, mapVoiceToPolly as x, resolveUserPath as y };
@@ -1,2 +1,2 @@
1
- import { t as createVoiceCallRuntime } from "./runtime-entry-DNmi-vZR.js";
1
+ import { t as createVoiceCallRuntime } from "./runtime-entry-Ln4SWaYK.js";
2
2
  export { createVoiceCallRuntime };
@@ -10,34 +10,6 @@ const ProviderNameSchema = z.enum([
10
10
  "plivo",
11
11
  "mock"
12
12
  ]);
13
- const CallStateSchema = z.enum([
14
- "initiated",
15
- "ringing",
16
- "answered",
17
- "active",
18
- "speaking",
19
- "listening",
20
- "completed",
21
- "hangup-user",
22
- "hangup-bot",
23
- "timeout",
24
- "error",
25
- "failed",
26
- "no-answer",
27
- "busy",
28
- "voicemail"
29
- ]);
30
- const TerminalStates = /* @__PURE__ */ new Set([
31
- "completed",
32
- "hangup-user",
33
- "hangup-bot",
34
- "timeout",
35
- "error",
36
- "failed",
37
- "no-answer",
38
- "busy",
39
- "voicemail"
40
- ]);
41
13
  const EndReasonSchema = z.enum([
42
14
  "completed",
43
15
  "hangup-user",
@@ -49,54 +21,16 @@ const EndReasonSchema = z.enum([
49
21
  "busy",
50
22
  "voicemail"
51
23
  ]);
52
- const BaseEventSchema = z.object({
53
- id: z.string(),
54
- dedupeKey: z.string().optional(),
55
- callId: z.string(),
56
- providerCallId: z.string().optional(),
57
- timestamp: z.number(),
58
- turnToken: z.string().optional(),
59
- direction: z.enum(["inbound", "outbound"]).optional(),
60
- from: z.string().optional(),
61
- to: z.string().optional()
62
- });
63
- z.discriminatedUnion("type", [
64
- BaseEventSchema.extend({ type: z.literal("call.initiated") }),
65
- BaseEventSchema.extend({ type: z.literal("call.ringing") }),
66
- BaseEventSchema.extend({ type: z.literal("call.answered") }),
67
- BaseEventSchema.extend({ type: z.literal("call.active") }),
68
- BaseEventSchema.extend({
69
- type: z.literal("call.speaking"),
70
- text: z.string()
71
- }),
72
- BaseEventSchema.extend({
73
- type: z.literal("call.assistant-speech"),
74
- transcript: z.string()
75
- }),
76
- BaseEventSchema.extend({
77
- type: z.literal("call.speech"),
78
- transcript: z.string(),
79
- isFinal: z.boolean(),
80
- confidence: z.number().min(0).max(1).optional()
81
- }),
82
- BaseEventSchema.extend({
83
- type: z.literal("call.silence"),
84
- durationMs: z.number()
85
- }),
86
- BaseEventSchema.extend({
87
- type: z.literal("call.dtmf"),
88
- digits: z.string()
89
- }),
90
- BaseEventSchema.extend({
91
- type: z.literal("call.ended"),
92
- reason: EndReasonSchema
93
- }),
94
- BaseEventSchema.extend({
95
- type: z.literal("call.error"),
96
- error: z.string(),
97
- retryable: z.boolean().optional()
98
- })
24
+ const CallStateSchema = z.enum([
25
+ "initiated",
26
+ "ringing",
27
+ "answered",
28
+ "active",
29
+ "speaking",
30
+ "listening",
31
+ ...EndReasonSchema.options
99
32
  ]);
33
+ const TerminalStates = new Set(EndReasonSchema.options);
100
34
  const CallDirectionSchema = z.enum(["outbound", "inbound"]);
101
35
  const TranscriptEntrySchema = z.object({
102
36
  timestamp: z.number(),
@@ -358,12 +292,14 @@ function pruneCallRecordEvents(stores) {
358
292
  for (const row of sorted.slice(0, rows.length - MAX_CALL_RECORD_EVENTS)) deleteCallRecordEventRows(stores, row.key);
359
293
  }
360
294
  /** Read and reassemble one chunked call record event. */
361
- function readCallRecordEvent(stores, eventKey) {
362
- const meta = stores.events.lookup(eventKey);
363
- if (!meta) return null;
295
+ function readCallRecordEvent(stores, eventKey, meta) {
296
+ if (!Number.isSafeInteger(meta.chunkCount) || meta.chunkCount < 1 || meta.chunkCount > 48) return null;
297
+ const records = stores.chunks.lookupMany?.(Array.from({ length: meta.chunkCount }, (_, index) => buildChunkKey(eventKey, index)));
364
298
  const chunks = [];
365
299
  for (let index = 0; index < meta.chunkCount; index += 1) {
366
- const chunk = stores.chunks.lookup(buildChunkKey(eventKey, index));
300
+ const result = records?.[index];
301
+ if (result && !result.ok) throw result.error;
302
+ const chunk = records ? result?.value : stores.chunks.lookup(buildChunkKey(eventKey, index));
367
303
  if (!chunk || chunk.index !== index) return null;
368
304
  chunks.push(Buffer.from(chunk.dataBase64, "base64"));
369
305
  }
@@ -372,7 +308,7 @@ function readCallRecordEvent(stores, eventKey) {
372
308
  /** Read all persisted call records in stable persisted order. */
373
309
  function readCallRecordEvents(stores) {
374
310
  return stores.events.entries().toSorted((a, b) => a.createdAt - b.createdAt || a.key.localeCompare(b.key)).map((entry) => {
375
- const call = readCallRecordEvent(stores, entry.key);
311
+ const call = readCallRecordEvent(stores, entry.key, entry.value);
376
312
  return call ? {
377
313
  call,
378
314
  persistedAt: entry.value.persistedAt ?? entry.createdAt,
@@ -1,4 +1,4 @@
1
- import { s as verifyTelnyxWebhook, t as guardedJsonApiRequest } from "./guarded-json-api-BhLtja0F.js";
1
+ import { s as verifyTelnyxWebhook, t as guardedJsonApiRequest } from "./guarded-json-api-3Jy2k-eX.js";
2
2
  import crypto from "node:crypto";
3
3
  //#region extensions/voice-call/src/providers/telnyx.ts
4
4
  function normalizeTelnyxDirection(direction) {
@@ -1,8 +1,8 @@
1
1
  import { fetchWithSsrFGuard } from "./runtime-api.js";
2
2
  import "./api.js";
3
3
  import { n as requireSupportedTwilioApiHostname, r as resolveTwilioApiBaseUrl } from "./twilio-region-4fkgz3UG.js";
4
- import { a as normalizeProviderStatus, i as mapProviderStatusToEndReason, p as chunkAudio, r as isProviderStatusTerminal, s as getHeader, v as escapeXml, y as mapVoiceToPolly } from "./runtime-entry-DNmi-vZR.js";
5
- import { c as verifyTwilioWebhook, i as readVoiceCallProviderJsonResponse, n as cancelProviderResponseBody, r as readProviderErrorResponseSnippet, t as guardedJsonApiRequest } from "./guarded-json-api-BhLtja0F.js";
4
+ import { a as normalizeProviderStatus, b as escapeXml, h as chunkAudio, i as mapProviderStatusToEndReason, l as getHeader, r as isProviderStatusTerminal, x as mapVoiceToPolly } from "./runtime-entry-Ln4SWaYK.js";
5
+ import { c as verifyTwilioWebhook, i as readVoiceCallProviderJsonResponse, n as cancelProviderResponseBody, r as readProviderErrorResponseSnippet, t as guardedJsonApiRequest } from "./guarded-json-api-3Jy2k-eX.js";
6
6
  import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
7
7
  import { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env";
8
8
  import crypto from "node:crypto";
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "id": "voice-call",
3
+ "categories": ["voice", "tools"],
3
4
  "doctorContract": {
4
5
  "resolveSessionStoreAgentIds": true,
5
- "stateMigrations": true
6
+ "stateMigrations": [{ "id": "voice-call-calls-jsonl-to-plugin-state" }]
6
7
  },
7
8
  "name": "Voice Call",
8
9
  "description": "OpenClaw voice-call plugin for Twilio, Telnyx, and Plivo phone calls.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/voice-call",
3
- "version": "2026.9.2",
3
+ "version": "2026.9.4",
4
4
  "description": "OpenClaw voice-call plugin for Twilio, Telnyx, and Plivo phone calls.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -13,7 +13,7 @@
13
13
  "zod": "4.4.3"
14
14
  },
15
15
  "peerDependencies": {
16
- "openclaw": ">=2026.9.2"
16
+ "openclaw": ">=2026.9.4"
17
17
  },
18
18
  "peerDependenciesMeta": {
19
19
  "openclaw": {
@@ -31,10 +31,10 @@
31
31
  "minHostVersion": ">=2026.4.10"
32
32
  },
33
33
  "compat": {
34
- "pluginApi": ">=2026.9.2"
34
+ "pluginApi": ">=2026.9.4"
35
35
  },
36
36
  "build": {
37
- "openclawVersion": "2026.9.2"
37
+ "openclawVersion": "2026.9.4"
38
38
  },
39
39
  "release": {
40
40
  "publishToClawHub": true,