@openclaw/voice-call 2026.7.2-beta.1 → 2026.7.2-beta.3

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,7 +1,7 @@
1
- import { a as MAX_CALL_RECORD_EVENTS, d as parseVoiceCallRecordLine, i as CALL_RECORD_EVENT_META_MAX_ENTRIES, m as resolveVoiceCallLegacyCallLogPath, n as CALL_RECORD_EVENTS_NAMESPACE, o as RAW_CALL_RECORD_CHUNK_BYTES, p as prepareVoiceCallRecordForStorage, r as CALL_RECORD_EVENT_CHUNKS_NAMESPACE, s as buildVoiceCallLegacyJsonlEventKey, t as CALL_RECORD_CHUNK_MAX_ENTRIES } from "./store-C1pl3avP.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-nYL-yM0S.js";
2
2
  import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
3
- import os from "node:os";
4
3
  import path from "node:path";
4
+ import os from "node:os";
5
5
  import fs from "node:fs/promises";
6
6
  import { archiveLegacyStateSource, detectOpenClawStateDatabaseSchemaMigrations, repairOpenClawStateDatabaseSchema } from "openclaw/plugin-sdk/runtime-doctor";
7
7
  //#region extensions/voice-call/doctor-contract-api.ts
@@ -50,7 +50,7 @@ function resolveSessionStoreAgentIds(params) {
50
50
  function resolveVoiceCallStorePath(params) {
51
51
  const configuredStore = getVoiceCallConfigStore(params.config);
52
52
  if (configuredStore) return resolveUserPath(configuredStore, params.env);
53
- return path.join(resolveHome(params.env), ".openclaw", "voice-calls");
53
+ return resolveDefaultVoiceCallStoreDir(params.env);
54
54
  }
55
55
  function resolveVoiceCallStateDatabaseEnv(params) {
56
56
  return {
@@ -59,7 +59,14 @@ function resolveVoiceCallStateDatabaseEnv(params) {
59
59
  };
60
60
  }
61
61
  function describeVoiceCallSchemaMigration(migration) {
62
- return migration.kind === "agent-databases-composite-primary-key" ? "agent database registry primary key -> agent_id,path" : "audit event ledger -> versioned message lifecycle schema";
62
+ switch (migration.kind) {
63
+ case "agent-databases-composite-primary-key": return "agent database registry primary key -> agent_id,path";
64
+ case "audit-events-v2": return "audit event ledger -> versioned message lifecycle schema";
65
+ case "operator-approvals-system-agent": return "operator approvals -> OpenClaw system changes";
66
+ case "session-watch-cursor-provenance-v4": return "session watch cursors -> provenance column";
67
+ case "strict-tables-v3": return "tables -> SQLite STRICT typing";
68
+ }
69
+ return migration.kind;
63
70
  }
64
71
  /** Return true when a path exists and is a file. */
65
72
  /** Build the plugin state key for one migrated event chunk. */
@@ -190,15 +197,14 @@ const stateMigrations = [{
190
197
  const { entries, warnings: readWarnings } = await readLegacyCallRecords(filePath);
191
198
  warnings.push(...readWarnings);
192
199
  const stateDatabaseEnv = resolveVoiceCallStateDatabaseEnv(params);
193
- const schemaMigrations = detectOpenClawStateDatabaseSchemaMigrations({ env: stateDatabaseEnv });
194
- if (schemaMigrations.length > 0) {
200
+ if (detectOpenClawStateDatabaseSchemaMigrations({ env: stateDatabaseEnv }).length > 0) {
195
201
  const repaired = repairOpenClawStateDatabaseSchema({ env: stateDatabaseEnv });
196
202
  warnings.push(...repaired.warnings);
197
203
  if (repaired.warnings.length > 0) return {
198
204
  changes,
199
205
  warnings
200
206
  };
201
- changes.push(...schemaMigrations.map((migration) => `Migrated Voice Call SQLite ${describeVoiceCallSchemaMigration(migration)}`));
207
+ changes.push(...repaired.changes.map((change) => change.replace(/^Migrated shared state /, "Migrated Voice Call SQLite ").replaceAll("→", "->")));
202
208
  }
203
209
  if (entries.length === 0) return {
204
210
  changes,
@@ -1,6 +1,6 @@
1
1
  import { fetchWithSsrFGuard } from "./runtime-api.js";
2
2
  import "./api.js";
3
- import { n as normalizeProxyIp, o as getHeader } from "./runtime-entry-DhTLZ2uB.js";
3
+ import { n as normalizeProxyIp, o as getHeader } from "./runtime-entry-D8Mkwy8G.js";
4
4
  import { n as markWebhookReplay, t as createWebhookReplayCache } from "./webhook-replay-aaP79dI8.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
@@ -1,7 +1,7 @@
1
1
  import { definePluginEntry, sleep } from "./runtime-api.js";
2
2
  import "./api.js";
3
- import { c as cleanupTailscaleExposureRoute, h as resolveUserPath, l as getTailscaleSelfInfo, s as resolveWebhookExposureStatus, t as createVoiceCallRuntime, u as setupTailscaleExposureRoute, v as VoiceCallConfigSchema, x as validateProviderConfig, y as resolveVoiceCallConfig } from "./runtime-entry-DhTLZ2uB.js";
4
- import { h as setVoiceCallStateRuntime, l as getCallHistoryFromStore } from "./store-C1pl3avP.js";
3
+ import { c as cleanupTailscaleExposureRoute, h as resolveUserPath, l as getTailscaleSelfInfo, s as resolveWebhookExposureStatus, t as createVoiceCallRuntime, u as setupTailscaleExposureRoute, v as VoiceCallConfigSchema, x as validateProviderConfig, y as resolveVoiceCallConfig } from "./runtime-entry-D8Mkwy8G.js";
4
+ import { g as setVoiceCallStateRuntime, t as resolveDefaultVoiceCallStoreDir, u as getCallHistoryFromStore } from "./store-path-nYL-yM0S.js";
5
5
  import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
6
6
  import { ErrorCodes, callGatewayFromCli, errorShape } from "openclaw/plugin-sdk/gateway-runtime";
7
7
  import { MAX_TCP_PORT, MAX_TIMER_TIMEOUT_MS, addTimerTimeoutGraceMs, clampTimerTimeoutMs, parseStrictNonNegativeInteger, resolveTimerTimeoutMs, timestampMsToIsoString } from "openclaw/plugin-sdk/number-runtime";
@@ -10,16 +10,14 @@ import { asOptionalRecord, isRecord, normalizeOptionalLowercaseString, normalize
10
10
  import { jsonResult } from "openclaw/plugin-sdk/tool-results";
11
11
  import { Type } from "typebox";
12
12
  import fs from "node:fs";
13
- import os from "node:os";
14
- import path from "node:path";
15
13
  import { randomUUID } from "node:crypto";
14
+ import path from "node:path";
16
15
  import { format } from "node:util";
17
16
  //#region extensions/voice-call/src/cli.ts
18
17
  const VOICE_CALL_GATEWAY_DEFAULT_TIMEOUT_MS = 5e3;
19
18
  const VOICE_CALL_GATEWAY_OPERATION_TIMEOUT_MS = 3e4;
20
19
  const VOICE_CALL_GATEWAY_TRANSCRIPT_BUFFER_MS = 1e4;
21
20
  const VOICE_CALL_GATEWAY_POLL_INTERVAL_MS = 1e3;
22
- const voiceCallCliDeps = { callGatewayFromCli };
23
21
  function writeStdoutLine(...values) {
24
22
  process.stdout.write(`${format(...values)}\n`);
25
23
  }
@@ -41,7 +39,7 @@ async function callVoiceCallGateway(method, params, opts) {
41
39
  const timeoutMs = typeof opts?.timeoutMs === "number" && Number.isFinite(opts.timeoutMs) ? Math.max(1, Math.ceil(opts.timeoutMs)) : VOICE_CALL_GATEWAY_DEFAULT_TIMEOUT_MS;
42
40
  return {
43
41
  ok: true,
44
- payload: await voiceCallCliDeps.callGatewayFromCli(method, {
42
+ payload: await callGatewayFromCli(method, {
45
43
  json: true,
46
44
  timeout: String(timeoutMs)
47
45
  }, params, { progress: false })
@@ -105,15 +103,7 @@ function resolveMode(input) {
105
103
  return "funnel";
106
104
  }
107
105
  function resolveDefaultStorePath(config) {
108
- const resolvedPreferred = resolveUserPath(path.join(os.homedir(), ".openclaw", "voice-calls"));
109
- const existing = [resolvedPreferred].find((dir) => {
110
- try {
111
- return fs.existsSync(path.join(dir, "calls.jsonl")) || fs.existsSync(dir);
112
- } catch {
113
- return false;
114
- }
115
- }) ?? resolvedPreferred;
116
- const base = config.store?.trim() ? resolveUserPath(config.store) : existing;
106
+ const base = config.store?.trim() ? resolveUserPath(config.store) : resolveDefaultVoiceCallStoreDir();
117
107
  return path.join(base, "calls.jsonl");
118
108
  }
119
109
  function percentile(values, p) {
@@ -1,5 +1,5 @@
1
- import { g as escapeXml, o as getHeader } from "./runtime-entry-DhTLZ2uB.js";
2
- import { a as reconstructWebhookUrl, o as verifyPlivoWebhook, t as guardedJsonApiRequest } from "./guarded-json-api-DoLAoIRF.js";
1
+ import { g as escapeXml, o as getHeader } from "./runtime-entry-D8Mkwy8G.js";
2
+ import { a as reconstructWebhookUrl, o as verifyPlivoWebhook, t as guardedJsonApiRequest } from "./guarded-json-api-Dm4NvtGo.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,7 +1,7 @@
1
1
  import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
2
2
  import { isFutureDateTimestampMs, resolveExpiresAtMsFromDurationMs } from "openclaw/plugin-sdk/number-runtime";
3
3
  import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
4
- import { REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME, buildRealtimeVoiceAgentConsultWorkingResponse, createRealtimeVoiceBridgeSession, createRealtimeVoiceForcedConsultCoordinator, createTalkSessionController, readRealtimeVoiceConsultQuestion, readSpeakableRealtimeVoiceToolResult, recordTalkObservabilityEvent } from "openclaw/plugin-sdk/realtime-voice";
4
+ import { REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME, buildRealtimeVoiceAgentConsultWorkingResponse, calculateMulawRms, createRealtimeVoiceSessionHarness, createSpeechThresholdGate, readRealtimeVoiceConsultQuestion, readSpeakableRealtimeVoiceToolResult } from "openclaw/plugin-sdk/realtime-voice";
5
5
  import { normalizeWebhookPath } from "openclaw/plugin-sdk/webhook-ingress";
6
6
  import { randomUUID } from "node:crypto";
7
7
  import { sliceUtf16Safe, truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
@@ -11,13 +11,7 @@ import WebSocket$1, { WebSocketServer } from "ws";
11
11
  const TELEPHONY_SAMPLE_RATE = 8e3;
12
12
  const TELEPHONY_CHUNK_BYTES = 160;
13
13
  const TELEPHONY_CHUNK_MS = 20;
14
- const DEFAULT_SPEECH_RMS_THRESHOLD = .035;
15
- const DEFAULT_REQUIRED_LOUD_CHUNKS = 4;
16
- const DEFAULT_REQUIRED_QUIET_CHUNKS = 12;
17
14
  const DEFAULT_MAX_QUEUED_AUDIO_BYTES = TELEPHONY_SAMPLE_RATE * 120;
18
- const PCM16_MAX_AMPLITUDE = 32768;
19
- const MULAW_LINEAR_SAMPLES = /* @__PURE__ */ new Int16Array(256);
20
- for (let i = 0; i < MULAW_LINEAR_SAMPLES.length; i += 1) MULAW_LINEAR_SAMPLES[i] = decodeMulawSample(i);
21
15
  /** Paces outgoing mulaw audio frames at telephony cadence. */
22
16
  var RealtimeAudioPacer = class {
23
17
  constructor(params) {
@@ -112,52 +106,6 @@ var RealtimeAudioPacer = class {
112
106
  if (this.queue.length > 0) this.timer = setTimeout(() => this.pump(), delayMs);
113
107
  }
114
108
  };
115
- /** Calculate normalized RMS from mulaw bytes. */
116
- function calculateMulawRms(muLaw) {
117
- if (muLaw.length === 0) return 0;
118
- let sum = 0;
119
- for (const sample of muLaw) {
120
- const normalized = (MULAW_LINEAR_SAMPLES[sample] ?? 0) / PCM16_MAX_AMPLITUDE;
121
- sum += normalized * normalized;
122
- }
123
- return Math.sqrt(sum / muLaw.length);
124
- }
125
- /** Detect likely speech start from consecutive loud mulaw chunks. */
126
- var RealtimeMulawSpeechStartDetector = class {
127
- constructor(params = {}) {
128
- this.params = params;
129
- this.loudChunks = 0;
130
- this.quietChunks = DEFAULT_REQUIRED_QUIET_CHUNKS;
131
- this.speaking = false;
132
- }
133
- /** Accept one mulaw chunk and return true only on transition into speaking. */
134
- accept(muLaw) {
135
- if (calculateMulawRms(muLaw) >= (this.params.rmsThreshold ?? DEFAULT_SPEECH_RMS_THRESHOLD)) {
136
- this.quietChunks = 0;
137
- this.loudChunks += 1;
138
- const requiredLoudChunks = this.params.requiredLoudChunks ?? DEFAULT_REQUIRED_LOUD_CHUNKS;
139
- if (!this.speaking && this.loudChunks >= requiredLoudChunks) {
140
- this.speaking = true;
141
- return true;
142
- }
143
- return false;
144
- }
145
- this.loudChunks = 0;
146
- this.quietChunks += 1;
147
- const requiredQuietChunks = this.params.requiredQuietChunks ?? DEFAULT_REQUIRED_QUIET_CHUNKS;
148
- if (this.quietChunks >= requiredQuietChunks) this.speaking = false;
149
- return false;
150
- }
151
- };
152
- /** Decode one G.711 mulaw byte to a linear PCM sample. */
153
- function decodeMulawSample(value) {
154
- const muLaw = ~value & 255;
155
- const sign = muLaw & 128;
156
- const exponent = muLaw >> 4 & 7;
157
- let sample = ((muLaw & 15) << 3) + 132 << exponent;
158
- sample -= 132;
159
- return sign ? -sample : sample;
160
- }
161
109
  //#endregion
162
110
  //#region extensions/voice-call/src/webhook/stream-frame-adapter.ts
163
111
  /** Parse numeric timestamps sent as numbers or integer strings. */
@@ -475,7 +423,6 @@ var RealtimeCallHandler = class {
475
423
  this.partialUserTranscriptUpdatedAtByCallId = /* @__PURE__ */ new Map();
476
424
  this.recentFinalUserTranscriptsByCallId = /* @__PURE__ */ new Map();
477
425
  this.recentFinalUserTranscriptTimersByCallId = /* @__PURE__ */ new Map();
478
- this.forcedConsultCoordinatorsByCallId = /* @__PURE__ */ new Map();
479
426
  this.forcedConsultsByCallId = /* @__PURE__ */ new Map();
480
427
  this.nativeConsultsInFlightByCallId = /* @__PURE__ */ new Map();
481
428
  this.publicOrigin = null;
@@ -661,56 +608,51 @@ var RealtimeCallHandler = class {
661
608
  }
662
609
  const { callId, instructions, initialGreetingInstructions } = registration;
663
610
  const callRecord = this.manager.getCallByProviderCallId(callSid);
664
- const talk = createTalkSessionController({
665
- sessionId: `voice-call:${callId}:realtime`,
666
- mode: "realtime",
667
- transport: "gateway-relay",
668
- brain: "agent-consult",
669
- provider: this.realtimeProvider.id
670
- }, { onEvent: recordTalkObservabilityEvent });
671
- const rememberTalkEvent = (event) => {
672
- if (event) appendRecentTalkEventMetadata(callRecord, event);
673
- return event;
674
- };
675
- const emitTalkEvent = (input) => {
676
- return rememberTalkEvent(talk.emit(input));
677
- };
678
- const ensureTalkTurn = () => {
679
- const turn = talk.ensureTurn({ payload: {
680
- callId,
681
- providerCallId: callSid
682
- } });
683
- rememberTalkEvent(turn.event);
684
- return turn.turnId;
685
- };
686
- const endTalkTurn = (reason = "completed") => {
687
- const ended = talk.endTurn({ payload: {
688
- callId,
689
- providerCallId: callSid,
690
- reason
691
- } });
692
- if (ended.ok) rememberTalkEvent(ended.event);
693
- };
694
- const finishOutputAudio = (reason) => {
695
- rememberTalkEvent(talk.finishOutputAudio({ payload: {
696
- callId,
697
- providerCallId: callSid,
698
- reason
699
- } }));
700
- };
611
+ const harness = createRealtimeVoiceSessionHarness({
612
+ talk: {
613
+ sessionId: `voice-call:${callId}:realtime`,
614
+ mode: "realtime",
615
+ transport: "gateway-relay",
616
+ brain: "agent-consult",
617
+ provider: this.realtimeProvider.id
618
+ },
619
+ talkPayloads: {
620
+ turnStarted: () => ({
621
+ callId,
622
+ providerCallId: callSid
623
+ }),
624
+ turnEnded: (reason) => ({
625
+ callId,
626
+ providerCallId: callSid,
627
+ reason
628
+ }),
629
+ inputAudioDelta: (audio) => ({ byteLength: audio.byteLength }),
630
+ outputAudioStarted: () => ({
631
+ callId,
632
+ providerCallId: callSid
633
+ }),
634
+ outputAudioDelta: (audio) => ({ byteLength: audio.byteLength }),
635
+ outputAudioDone: (reason) => ({
636
+ callId,
637
+ providerCallId: callSid,
638
+ reason
639
+ })
640
+ },
641
+ onTalkEvent: (event) => appendRecentTalkEventMetadata(callRecord, event)
642
+ });
701
643
  const providerHandlesInputAudioBargeIn = this.realtimeProvider.capabilities?.handlesInputAudioBargeIn === true;
702
644
  const cancelOutputAudioForBargeIn = (source, interruptProvider, clearedAudioBytes = 0) => {
703
- const outputAudioActive = talk.outputAudioActive;
645
+ const outputAudioActive = harness.talk.outputAudioActive;
704
646
  const pendingTelephonyAudio = audioPacer.hasPendingAudio();
705
647
  if (source === "provider" && !outputAudioActive && !pendingTelephonyAudio && clearedAudioBytes === 0) return;
706
- const interruptedTurnId = talk.activeTurnId;
648
+ const interruptedTurnId = harness.talk.activeTurnId;
707
649
  interruptProvider?.(outputAudioActive || pendingTelephonyAudio);
708
650
  const clearedBytes = clearedAudioBytes + (source === "local" || pendingTelephonyAudio ? audioPacer.clearAudio() : 0);
709
651
  console.log(`[voice-call] realtime outbound audio cleared by ${source} barge-in callId=${callId} providerCallId=${callSid} queuedBytes=${clearedBytes}`);
710
652
  if (!outputAudioActive || !interruptedTurnId) return;
711
653
  const reason = `${source}-barge-in`;
712
- finishOutputAudio(reason);
713
- const cancelled = talk.cancelTurn({
654
+ harness.finishOutputAudio(reason);
655
+ harness.talk.cancelTurn({
714
656
  turnId: interruptedTurnId,
715
657
  payload: {
716
658
  callId,
@@ -718,9 +660,8 @@ var RealtimeCallHandler = class {
718
660
  reason
719
661
  }
720
662
  });
721
- if (cancelled.ok) rememberTalkEvent(cancelled.event);
722
663
  };
723
- emitTalkEvent({
664
+ harness.emit({
724
665
  type: "session.started",
725
666
  payload: {
726
667
  callId,
@@ -762,9 +703,13 @@ var RealtimeCallHandler = class {
762
703
  if (ws.readyState === WebSocket$1.OPEN) ws.close(1013, "Backpressure: paced audio queue exceeded");
763
704
  }
764
705
  });
765
- const speechDetector = new RealtimeMulawSpeechStartDetector({ requiredLoudChunks: BARGE_IN_REQUIRED_LOUD_CHUNKS });
706
+ const speechDetector = createSpeechThresholdGate({
707
+ rmsThreshold: .035,
708
+ speechFrames: BARGE_IN_REQUIRED_LOUD_CHUNKS,
709
+ silenceFrames: 12
710
+ });
766
711
  const interruptResponseOnInputAudio = typeof this.providerConfig.interruptResponseOnInputAudio === "boolean" ? this.providerConfig.interruptResponseOnInputAudio : void 0;
767
- const session = createRealtimeVoiceBridgeSession({
712
+ const session = harness.createBridge({
768
713
  provider: this.realtimeProvider,
769
714
  cfg: this.coreConfig,
770
715
  providerConfig: this.providerConfig,
@@ -776,46 +721,38 @@ var RealtimeCallHandler = class {
776
721
  audioSink: {
777
722
  isOpen: () => ws.readyState === WebSocket$1.OPEN,
778
723
  sendAudio: (muLaw) => {
779
- const turnId = ensureTalkTurn();
780
- rememberTalkEvent(talk.startOutputAudio({
781
- turnId,
782
- payload: {
783
- callId,
784
- providerCallId: callSid
785
- }
786
- }).event);
787
- emitTalkEvent({
788
- type: "output.audio.delta",
789
- turnId,
790
- payload: { byteLength: muLaw.length }
791
- });
724
+ harness.recordOutputAudio(muLaw);
792
725
  audioPacer.sendAudio(muLaw);
793
726
  },
794
727
  clearAudio: (reason) => {
795
- const clearedBytes = audioPacer.clearAudio();
796
- if (reason === "barge-in") {
797
- cancelOutputAudioForBargeIn("provider", void 0, clearedBytes);
798
- return;
799
- }
800
- console.log(`[voice-call] realtime outbound audio clear requested callId=${callId} providerCallId=${callSid} queuedBytes=${clearedBytes}`);
801
- finishOutputAudio("clear");
728
+ harness.flushOutput(() => {
729
+ const clearedBytes = audioPacer.clearAudio();
730
+ if (reason === "barge-in") {
731
+ cancelOutputAudioForBargeIn("provider", void 0, clearedBytes);
732
+ return;
733
+ }
734
+ console.log(`[voice-call] realtime outbound audio clear requested callId=${callId} providerCallId=${callSid} queuedBytes=${clearedBytes}`);
735
+ harness.finishOutputAudio("clear");
736
+ });
802
737
  },
803
738
  sendMark: (markName) => {
804
739
  audioPacer.sendMark(markName);
805
740
  }
806
741
  },
807
742
  onTranscript: (role, text, isFinal) => {
808
- const turnId = ensureTalkTurn();
809
- emitTalkEvent({
810
- type: role === "assistant" ? isFinal ? "output.text.done" : "output.text.delta" : isFinal ? "transcript.done" : "transcript.delta",
743
+ const turnId = harness.ensureTurn();
744
+ const eventType = role === "assistant" ? isFinal ? "output.text.done" : "output.text.delta" : isFinal ? "transcript.done" : "transcript.delta";
745
+ const payload = role === "assistant" ? { text } : {
746
+ role,
747
+ text
748
+ };
749
+ harness.emit({
750
+ type: eventType,
811
751
  turnId,
812
- payload: role === "assistant" ? { text } : {
813
- role,
814
- text
815
- },
752
+ payload,
816
753
  final: isFinal
817
754
  });
818
- if (role === "user" && isFinal) emitTalkEvent({
755
+ if (role === "user" && isFinal) harness.emit({
819
756
  type: "input.audio.committed",
820
757
  turnId,
821
758
  payload: {
@@ -851,6 +788,7 @@ var RealtimeCallHandler = class {
851
788
  };
852
789
  this.manager.processEvent(event);
853
790
  this.scheduleForcedAgentConsult({
791
+ harness,
854
792
  session,
855
793
  callId,
856
794
  callSid,
@@ -872,8 +810,8 @@ var RealtimeCallHandler = class {
872
810
  });
873
811
  },
874
812
  onToolCall: (toolEvent, sessionLocal) => {
875
- const turnId = ensureTalkTurn();
876
- emitTalkEvent({
813
+ const turnId = harness.ensureTurn();
814
+ harness.emit({
877
815
  type: "tool.call",
878
816
  turnId,
879
817
  itemId: toolEvent.itemId,
@@ -884,17 +822,17 @@ var RealtimeCallHandler = class {
884
822
  }
885
823
  });
886
824
  console.log(`[voice-call] realtime tool call received callId=${callId} providerCallId=${callSid} tool=${toolEvent.name}`);
887
- return this.executeToolCall(sessionLocal, callId, toolEvent.callId || toolEvent.itemId, toolEvent.name, toolEvent.args, turnId, emitTalkEvent);
825
+ return this.executeToolCall(sessionLocal, callId, toolEvent.callId || toolEvent.itemId, toolEvent.name, toolEvent.args, turnId, harness);
888
826
  },
889
827
  onEvent: (event) => {
890
828
  if (event.type === "input_audio_buffer.speech_started") {
891
- ensureTalkTurn();
829
+ harness.ensureTurn();
892
830
  return;
893
831
  }
894
832
  if (event.type === "input_audio_buffer.speech_stopped") {
895
- const turnId = talk.activeTurnId;
833
+ const turnId = harness.talk.activeTurnId;
896
834
  if (!turnId) return;
897
- emitTalkEvent({
835
+ harness.emit({
898
836
  type: "input.audio.committed",
899
837
  turnId,
900
838
  payload: {
@@ -907,18 +845,18 @@ var RealtimeCallHandler = class {
907
845
  return;
908
846
  }
909
847
  if (event.type === "response.done") {
910
- finishOutputAudio("response.done");
911
- endTalkTurn("response.done");
848
+ harness.finishOutputAudio("response.done");
849
+ harness.endTurn("response.done");
912
850
  return;
913
851
  }
914
- if (event.type === "error") emitTalkEvent({
852
+ if (event.type === "error") harness.emit({
915
853
  type: "session.error",
916
854
  payload: { message: event.detail ?? "Realtime provider error" },
917
855
  final: true
918
856
  });
919
857
  },
920
858
  onReady: () => {
921
- emitTalkEvent({
859
+ harness.emit({
922
860
  type: "session.ready",
923
861
  payload: {
924
862
  callId,
@@ -928,7 +866,7 @@ var RealtimeCallHandler = class {
928
866
  },
929
867
  onError: (error) => {
930
868
  console.error("[voice-call] realtime voice error:", error.message);
931
- emitTalkEvent({
869
+ harness.emit({
932
870
  type: "session.error",
933
871
  payload: { message: error.message },
934
872
  final: true
@@ -940,8 +878,8 @@ var RealtimeCallHandler = class {
940
878
  this.activeTelephonyClosersByCallId.delete(callId);
941
879
  this.activeTelephonyClosersByCallId.delete(callSid);
942
880
  this.clearUserTranscriptState(callId);
943
- finishOutputAudio(reason);
944
- emitTalkEvent({
881
+ harness.finishOutputAudio(reason);
882
+ harness.emit({
945
883
  type: "session.closed",
946
884
  payload: { reason },
947
885
  final: true
@@ -971,17 +909,16 @@ var RealtimeCallHandler = class {
971
909
  this.activeTelephonyClosersByCallId.set(callSid, closeTelephony);
972
910
  const sendAudioToSession = session.sendAudio.bind(session);
973
911
  session.sendAudio = (audio) => {
974
- if (speechDetector.accept(audio)) {
912
+ if (speechDetector.accept({
913
+ rms: calculateMulawRms(audio),
914
+ peak: 0
915
+ })) {
975
916
  console.log(`[voice-call] realtime local speech detected callId=${callId} providerCallId=${callSid}`);
976
917
  if (!providerHandlesInputAudioBargeIn) cancelOutputAudioForBargeIn("local", (audioPlaybackActive) => {
977
918
  session.handleBargeIn({ audioPlaybackActive });
978
919
  });
979
920
  }
980
- emitTalkEvent({
981
- type: "input.audio.delta",
982
- turnId: ensureTalkTurn(),
983
- payload: { byteLength: audio.length }
984
- });
921
+ harness.recordInputAudio(audio);
985
922
  sendAudioToSession(audio);
986
923
  };
987
924
  const closeSession = session.close.bind(session);
@@ -997,7 +934,8 @@ var RealtimeCallHandler = class {
997
934
  this.activeTelephonyClosersByCallId.delete(callId);
998
935
  this.activeTelephonyClosersByCallId.delete(callSid);
999
936
  this.clearUserTranscriptState(callId);
1000
- this.clearForcedConsultState(callId);
937
+ this.forcedConsultsByCallId.delete(callId);
938
+ harness.close();
1001
939
  audioPacer.close();
1002
940
  }
1003
941
  };
@@ -1080,18 +1018,6 @@ var RealtimeCallHandler = class {
1080
1018
  });
1081
1019
  }
1082
1020
  }
1083
- clearForcedConsultState(callId) {
1084
- this.forcedConsultCoordinatorsByCallId.get(callId)?.clear();
1085
- this.forcedConsultCoordinatorsByCallId.delete(callId);
1086
- this.forcedConsultsByCallId.delete(callId);
1087
- }
1088
- forcedConsultCoordinator(callId) {
1089
- const existing = this.forcedConsultCoordinatorsByCallId.get(callId);
1090
- if (existing) return existing;
1091
- const created = createRealtimeVoiceForcedConsultCoordinator();
1092
- this.forcedConsultCoordinatorsByCallId.set(callId, created);
1093
- return created;
1094
- }
1095
1021
  closeTelephonyBridge(callIdOrSid, bridge, reason) {
1096
1022
  const closer = this.activeTelephonyClosersByCallId.get(callIdOrSid);
1097
1023
  if (closer) {
@@ -1108,7 +1034,7 @@ var RealtimeCallHandler = class {
1108
1034
  if (!handler) return;
1109
1035
  const existingForcedConsult = this.forcedConsultsByCallId.get(params.callId);
1110
1036
  if (existingForcedConsult && !existingForcedConsult.completedAt) return;
1111
- const coordinator = this.forcedConsultCoordinator(params.callId);
1037
+ const coordinator = params.harness.forcedConsults;
1112
1038
  if (coordinator.hasRecentNativeConsult(question, { allowUnknownQuestion: true })) return;
1113
1039
  coordinator.clearPending();
1114
1040
  const pending = coordinator.prepare(question);
@@ -1124,7 +1050,7 @@ var RealtimeCallHandler = class {
1124
1050
  });
1125
1051
  }
1126
1052
  async runForcedAgentConsult(params) {
1127
- const coordinator = this.forcedConsultCoordinator(params.callId);
1053
+ const coordinator = params.harness.forcedConsults;
1128
1054
  coordinator.markStarted(params.handle);
1129
1055
  const startedAt = Date.now();
1130
1056
  logger.debug(`[voice-call] realtime forced agent consult reason=${FORCED_CONSULT_REASON} consultPolicy=always callId=${params.callId} providerCallId=${params.callSid} chars=${params.handle.question.length}`);
@@ -1216,14 +1142,14 @@ var RealtimeCallHandler = class {
1216
1142
  reason
1217
1143
  });
1218
1144
  }
1219
- async executeToolCall(bridge, callId, bridgeCallId, name, args, turnId, emitTalkEvent) {
1145
+ async executeToolCall(bridge, callId, bridgeCallId, name, args, turnId, harness) {
1220
1146
  const handler = this.toolHandlers.get(name);
1221
1147
  const startedAt = Date.now();
1222
1148
  const hasResultError = (result) => {
1223
1149
  return Boolean(result && typeof result === "object" && !Array.isArray(result) && "error" in result);
1224
1150
  };
1225
1151
  const emitFinalToolEvent = (result) => {
1226
- emitTalkEvent?.({
1152
+ harness.emit({
1227
1153
  type: hasResultError(result) ? "tool.error" : "tool.result",
1228
1154
  turnId,
1229
1155
  callId: bridgeCallId,
@@ -1241,7 +1167,7 @@ var RealtimeCallHandler = class {
1241
1167
  const submitWorkingResponse = async () => {
1242
1168
  if (handler && name === REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME && bridge.bridge.supportsToolResultContinuation && !this.config.fastContext.enabled) {
1243
1169
  await bridge.submitToolResult(bridgeCallId, buildRealtimeVoiceAgentConsultWorkingResponse("caller"), { willContinue: true });
1244
- emitTalkEvent?.({
1170
+ harness.emit({
1245
1171
  type: "tool.progress",
1246
1172
  turnId,
1247
1173
  callId: bridgeCallId,
@@ -1253,7 +1179,7 @@ var RealtimeCallHandler = class {
1253
1179
  }
1254
1180
  };
1255
1181
  if (name === REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME) {
1256
- const coordinator = this.forcedConsultCoordinator(callId);
1182
+ const coordinator = harness.forcedConsults;
1257
1183
  const forcedMatch = coordinator.recordNativeConsult(args, bridgeCallId);
1258
1184
  if (forcedMatch.kind === "none") {
1259
1185
  const pending = coordinator.consumePending();
@@ -1,4 +1,4 @@
1
- import { b as resolveVoiceCallSessionKey, m as resolveCallAgentId, p as resolveVoiceResponseModel } from "./runtime-entry-DhTLZ2uB.js";
1
+ import { b as resolveVoiceCallSessionKey, m as resolveCallAgentId, p as resolveVoiceResponseModel } from "./runtime-entry-D8Mkwy8G.js";
2
2
  import { isRecord, normalizeLowercaseStringOrEmpty, normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
3
3
  import crypto from "node:crypto";
4
4
  import { ModelSelectionLockedError, applyModelOverrideToSessionEntry, resolvePersistedSessionRuntimeId } from "openclaw/plugin-sdk/model-session-runtime";
@@ -1,7 +1,7 @@
1
1
  import { TtsConfigSchema, isBlockedHostnameOrIp, isRequestBodyLimitError, readRequestBodyWithLimit, requestBodyErrorToText } from "./runtime-api.js";
2
2
  import "./api.js";
3
3
  import { t as TWILIO_REGIONS } from "./twilio-region-4fkgz3UG.js";
4
- import { c as findCallMatchesInStore, f as persistCallRecord, g as TerminalStates, h as setVoiceCallStateRuntime, l as getCallHistoryFromStore, u as loadActiveCallsFromStore } from "./store-C1pl3avP.js";
4
+ import { _ as TerminalStates, d as loadActiveCallsFromStore, g as setVoiceCallStateRuntime, l as findCallMatchesInStore, p as persistCallRecord, t as resolveDefaultVoiceCallStoreDir, u as getCallHistoryFromStore } from "./store-path-nYL-yM0S.js";
5
5
  import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
6
6
  import { isLoopbackHost } from "openclaw/plugin-sdk/gateway-runtime";
7
7
  import { MAX_TIMER_TIMEOUT_MS, asDateTimestampMs, resolveExpiresAtMsFromDurationMs, resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
@@ -13,16 +13,16 @@ import { REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME, REALTIME_VOICE_AGENT_CONSULT_TO
13
13
  import { mergeDeep } from "openclaw/plugin-sdk/plugin-config-runtime";
14
14
  import { buildSecretInputSchema, hasConfiguredSecretInput, normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input";
15
15
  import { canonicalizeMainSessionAlias } from "openclaw/plugin-sdk/session-store-runtime";
16
+ import { resolveSpeechProviderApiKey } from "openclaw/plugin-sdk/speech-core";
16
17
  import { WEBHOOK_BODY_READ_DEFAULTS, createWebhookInFlightLimiter, normalizeWebhookPath } from "openclaw/plugin-sdk/webhook-ingress";
17
18
  import { z } from "zod";
18
19
  import fs from "node:fs";
19
- import os from "node:os";
20
- import path from "node:path";
21
20
  import crypto from "node:crypto";
22
21
  import { redactIdentifier } from "openclaw/plugin-sdk/logging-core";
22
+ import path from "node:path";
23
+ import os from "node:os";
23
24
  import { root } from "openclaw/plugin-sdk/security-runtime";
24
25
  import { sliceUtf16Safe, truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
25
- import { parseTtsDirectives } from "openclaw/plugin-sdk/speech";
26
26
  import { spawn } from "node:child_process";
27
27
  import { runCommandWithTimeout } from "openclaw/plugin-sdk/process-runtime";
28
28
  import http from "node:http";
@@ -586,28 +586,28 @@ function resolveVoiceCallConfig(config) {
586
586
  const resolved = normalizeVoiceCallConfig(config);
587
587
  if (resolved.provider === "telnyx") {
588
588
  resolved.telnyx = resolved.telnyx ?? {};
589
- resolved.telnyx.apiKey = resolved.telnyx.apiKey ?? process.env.TELNYX_API_KEY;
590
- resolved.telnyx.connectionId = resolved.telnyx.connectionId ?? process.env.TELNYX_CONNECTION_ID;
591
- resolved.telnyx.publicKey = resolved.telnyx.publicKey ?? process.env.TELNYX_PUBLIC_KEY;
589
+ resolved.telnyx.apiKey = resolved.telnyx.apiKey ?? resolveSpeechProviderApiKey(process.env.TELNYX_API_KEY);
590
+ resolved.telnyx.connectionId = resolved.telnyx.connectionId ?? resolveSpeechProviderApiKey(process.env.TELNYX_CONNECTION_ID);
591
+ resolved.telnyx.publicKey = resolved.telnyx.publicKey ?? resolveSpeechProviderApiKey(process.env.TELNYX_PUBLIC_KEY);
592
592
  }
593
593
  if (resolved.provider === "twilio") {
594
- resolved.fromNumber = resolved.fromNumber ?? process.env.TWILIO_FROM_NUMBER;
594
+ resolved.fromNumber = resolved.fromNumber ?? resolveSpeechProviderApiKey(process.env.TWILIO_FROM_NUMBER);
595
595
  resolved.twilio = resolved.twilio ?? {};
596
- resolved.twilio.accountSid = resolved.twilio.accountSid ?? process.env.TWILIO_ACCOUNT_SID;
597
- resolved.twilio.authToken = resolved.twilio.authToken ?? process.env.TWILIO_AUTH_TOKEN;
596
+ resolved.twilio.accountSid = resolved.twilio.accountSid ?? resolveSpeechProviderApiKey(process.env.TWILIO_ACCOUNT_SID);
597
+ resolved.twilio.authToken = resolved.twilio.authToken ?? resolveSpeechProviderApiKey(process.env.TWILIO_AUTH_TOKEN);
598
598
  }
599
599
  if (resolved.provider === "plivo") {
600
600
  resolved.plivo = resolved.plivo ?? {};
601
- resolved.plivo.authId = resolved.plivo.authId ?? process.env.PLIVO_AUTH_ID;
602
- resolved.plivo.authToken = resolved.plivo.authToken ?? process.env.PLIVO_AUTH_TOKEN;
601
+ resolved.plivo.authId = resolved.plivo.authId ?? resolveSpeechProviderApiKey(process.env.PLIVO_AUTH_ID);
602
+ resolved.plivo.authToken = resolved.plivo.authToken ?? resolveSpeechProviderApiKey(process.env.PLIVO_AUTH_TOKEN);
603
603
  }
604
604
  resolved.tunnel = resolved.tunnel ?? {
605
605
  provider: "none",
606
606
  allowNgrokFreeTierLoopbackBypass: false
607
607
  };
608
608
  resolved.tunnel.allowNgrokFreeTierLoopbackBypass = resolved.tunnel.allowNgrokFreeTierLoopbackBypass ?? false;
609
- resolved.tunnel.ngrokAuthToken = resolved.tunnel.ngrokAuthToken ?? process.env.NGROK_AUTHTOKEN;
610
- resolved.tunnel.ngrokDomain = resolved.tunnel.ngrokDomain ?? process.env.NGROK_DOMAIN;
609
+ resolved.tunnel.ngrokAuthToken = resolved.tunnel.ngrokAuthToken ?? resolveSpeechProviderApiKey(process.env.NGROK_AUTHTOKEN);
610
+ resolved.tunnel.ngrokDomain = resolved.tunnel.ngrokDomain ?? resolveSpeechProviderApiKey(process.env.NGROK_DOMAIN);
611
611
  resolved.webhookSecurity = resolved.webhookSecurity ?? {
612
612
  allowedHosts: [],
613
613
  trustForwardingHeaders: false,
@@ -1552,14 +1552,7 @@ function resolveRestoredMaxDurationAnchor(call) {
1552
1552
  function resolveDefaultStoreBase(config, storePath) {
1553
1553
  const rawOverride = storePath?.trim() || config.store?.trim();
1554
1554
  if (rawOverride) return resolveUserPath(rawOverride);
1555
- const preferred = path.join(os.homedir(), ".openclaw", "voice-calls");
1556
- return [preferred].map((dir) => resolveUserPath(dir)).find((dir) => {
1557
- try {
1558
- return fs.existsSync(path.join(dir, "calls.jsonl")) || fs.existsSync(dir);
1559
- } catch {
1560
- return false;
1561
- }
1562
- }) ?? resolveUserPath(preferred);
1555
+ return resolveDefaultVoiceCallStoreDir();
1563
1556
  }
1564
1557
  /**
1565
1558
  * Manages voice calls: state ownership and delegation to manager helper modules.
@@ -1919,26 +1912,26 @@ function chunkAudio(audio, chunkSize = 160) {
1919
1912
  /** Default timeout for one telephony synthesis request. */
1920
1913
  const TELEPHONY_DEFAULT_TTS_TIMEOUT_MS = 8e3;
1921
1914
  /** Create a TTS provider that honors voice-call overrides and converts PCM to mulaw. */
1922
- function createTelephonyTtsProvider(params) {
1915
+ async function createTelephonyTtsProvider(params) {
1923
1916
  const { coreConfig, ttsOverride, runtime, logger } = params;
1924
- const mergedConfig = applyTtsOverride(coreConfig, ttsOverride);
1925
- const ttsConfig = mergedConfig.messages?.tts;
1926
- const modelOverrides = resolveTelephonyModelOverridePolicy(readTelephonyModelOverrides(ttsConfig));
1927
- const providerConfigs = collectTelephonyProviderConfigs(ttsConfig);
1928
- const activeProvider = normalizeProviderId(ttsConfig?.provider);
1917
+ const preparedConfig = await runtime.prepareTtsRequest({
1918
+ cfg: coreConfig,
1919
+ override: ttsOverride,
1920
+ text: ""
1921
+ });
1929
1922
  return {
1930
- synthesisTimeoutMs: resolveTimerTimeoutMs(mergedConfig.messages?.tts?.timeoutMs, TELEPHONY_DEFAULT_TTS_TIMEOUT_MS),
1923
+ synthesisTimeoutMs: resolveTimerTimeoutMs(preparedConfig.cfg.messages?.tts?.timeoutMs, TELEPHONY_DEFAULT_TTS_TIMEOUT_MS),
1931
1924
  synthesizeForTelephony: async (text) => {
1932
- const directives = parseTtsDirectives(text, modelOverrides, {
1933
- cfg: mergedConfig,
1934
- providerConfigs,
1935
- preferredProviderId: activeProvider
1925
+ const prepared = await runtime.prepareTtsRequest({
1926
+ cfg: preparedConfig.cfg,
1927
+ text
1936
1928
  });
1929
+ const directives = prepared.directives;
1937
1930
  if (directives.warnings.length > 0) logger?.warn?.(`[voice-call] Ignored telephony TTS directive overrides (${directives.warnings.join("; ")})`);
1938
1931
  const cleanText = directives.hasDirective ? directives.ttsText?.trim() || directives.cleanedText.trim() : text;
1939
1932
  const result = await runtime.textToSpeechTelephony({
1940
1933
  text: cleanText,
1941
- cfg: mergedConfig,
1934
+ cfg: prepared.cfg,
1942
1935
  overrides: directives.overrides
1943
1936
  });
1944
1937
  if (!result.success || !result.audioBuffer || !result.sampleRate) throw new Error(result.error ?? "TTS conversion failed");
@@ -1950,94 +1943,6 @@ function createTelephonyTtsProvider(params) {
1950
1943
  }
1951
1944
  };
1952
1945
  }
1953
- /** Apply voice-call TTS overrides to core config without mutating the original object. */
1954
- function applyTtsOverride(coreConfig, override) {
1955
- if (!override) return coreConfig;
1956
- const base = coreConfig.messages?.tts;
1957
- const merged = mergeTtsConfig(base, override);
1958
- if (!merged) return coreConfig;
1959
- return {
1960
- ...coreConfig,
1961
- messages: {
1962
- ...coreConfig.messages,
1963
- tts: merged
1964
- }
1965
- };
1966
- }
1967
- /** Merge core and voice-call TTS config, keeping undefined override fields out. */
1968
- function mergeTtsConfig(base, override) {
1969
- if (!base && !override) return;
1970
- if (!override) return base;
1971
- if (!base) return override;
1972
- return mergeDeep(base, override);
1973
- }
1974
- /** Resolve directive override policy for telephony synthesis. */
1975
- function resolveTelephonyModelOverridePolicy(overrides) {
1976
- if (!(overrides?.enabled ?? true)) return {
1977
- enabled: false,
1978
- allowText: false,
1979
- allowProvider: false,
1980
- allowVoice: false,
1981
- allowModelId: false,
1982
- allowVoiceSettings: false,
1983
- allowNormalization: false,
1984
- allowSeed: false
1985
- };
1986
- const allow = (value, defaultValue = true) => value ?? defaultValue;
1987
- return {
1988
- enabled: true,
1989
- allowText: allow(overrides?.allowText),
1990
- allowProvider: allow(overrides?.allowProvider, false),
1991
- allowVoice: allow(overrides?.allowVoice),
1992
- allowModelId: allow(overrides?.allowModelId),
1993
- allowVoiceSettings: allow(overrides?.allowVoiceSettings),
1994
- allowNormalization: allow(overrides?.allowNormalization),
1995
- allowSeed: allow(overrides?.allowSeed)
1996
- };
1997
- }
1998
- /** Read model override policy from TTS config when present. */
1999
- function readTelephonyModelOverrides(ttsConfig) {
2000
- const value = ttsConfig?.modelOverrides;
2001
- return value && typeof value === "object" && !Array.isArray(value) ? value : void 0;
2002
- }
2003
- /** Normalize provider ids for config lookup. */
2004
- function normalizeProviderId(value) {
2005
- return typeof value === "string" ? value.trim().toLowerCase() || void 0 : void 0;
2006
- }
2007
- /** Coerce provider config objects while rejecting arrays and primitives. */
2008
- function asProviderConfig(value) {
2009
- return value && typeof value === "object" && !Array.isArray(value) ? value : {};
2010
- }
2011
- /** Collect named provider configs from canonical and legacy TTS config shapes. */
2012
- function collectTelephonyProviderConfigs(ttsConfig) {
2013
- if (!ttsConfig) return {};
2014
- const entries = {};
2015
- const rawProviders = ttsConfig.providers && typeof ttsConfig.providers === "object" && !Array.isArray(ttsConfig.providers) ? ttsConfig.providers : {};
2016
- for (const [providerId, value] of Object.entries(rawProviders)) {
2017
- const normalized = normalizeProviderId(providerId) ?? providerId;
2018
- entries[normalized] = asProviderConfig(value);
2019
- }
2020
- const reservedKeys = /* @__PURE__ */ new Set([
2021
- "auto",
2022
- "enabled",
2023
- "maxTextLength",
2024
- "mode",
2025
- "modelOverrides",
2026
- "persona",
2027
- "personas",
2028
- "prefsPath",
2029
- "provider",
2030
- "providers",
2031
- "summaryModel",
2032
- "timeoutMs"
2033
- ]);
2034
- for (const [key, value] of Object.entries(ttsConfig)) {
2035
- if (reservedKeys.has(key) || typeof value !== "object" || value === null || Array.isArray(value)) continue;
2036
- const normalized = normalizeProviderId(key) ?? key;
2037
- entries[normalized] ??= asProviderConfig(value);
2038
- }
2039
- return entries;
2040
- }
2041
1946
  //#endregion
2042
1947
  //#region extensions/voice-call/src/bounded-child-output.ts
2043
1948
  const DEFAULT_MAX_OUTPUT_CHARS = 16384;
@@ -2165,6 +2070,30 @@ async function cleanupTailscaleExposure(config) {
2165
2070
  const NGROK_LOG_BUFFER_MAX_CHARS = 16384;
2166
2071
  const NGROK_ERROR_MARKER = "ERR_NGROK";
2167
2072
  const TUNNEL_COMMAND_OUTPUT_MAX_BYTES = 16384;
2073
+ const NGROK_STOP_GRACE_MS = 2e3;
2074
+ const NGROK_FORCE_KILL_WAIT_MS = 1e3;
2075
+ async function terminateNgrokProcess(proc, isClosed) {
2076
+ if (isClosed()) return;
2077
+ await new Promise((resolve) => {
2078
+ let finished = false;
2079
+ let forceKillWaitTimer;
2080
+ const finish = () => {
2081
+ if (finished) return;
2082
+ finished = true;
2083
+ if (forceKillTimer) clearTimeout(forceKillTimer);
2084
+ if (forceKillWaitTimer) clearTimeout(forceKillWaitTimer);
2085
+ proc.off("close", finish);
2086
+ resolve();
2087
+ };
2088
+ proc.once("close", finish);
2089
+ const forceKillTimer = setTimeout(() => {
2090
+ forceKillWaitTimer = setTimeout(finish, NGROK_FORCE_KILL_WAIT_MS);
2091
+ if (!isClosed()) proc.kill("SIGKILL");
2092
+ }, NGROK_STOP_GRACE_MS);
2093
+ proc.kill("SIGTERM");
2094
+ if (isClosed()) finish();
2095
+ });
2096
+ }
2168
2097
  function listenForChildStreamErrors(proc, onError) {
2169
2098
  proc.stdout.on("error", (error) => onError("stdout", error));
2170
2099
  proc.stderr.on("error", (error) => onError("stderr", error));
@@ -2200,23 +2129,25 @@ async function startNgrokTunnel(config) {
2200
2129
  "pipe",
2201
2130
  "pipe"
2202
2131
  ] });
2203
- let resolved = false;
2204
- let closed = false;
2132
+ let startupSettled = false;
2133
+ let childClosed = false;
2205
2134
  let publicUrl = null;
2206
2135
  let outputBuffer = "";
2207
2136
  let stderrTail = "";
2208
2137
  const timeout = setTimeout(() => {
2209
- if (!resolved) {
2210
- resolved = true;
2211
- proc.kill("SIGTERM");
2212
- reject(/* @__PURE__ */ new Error("ngrok startup timed out (30s)"));
2138
+ if (!startupSettled) {
2139
+ startupSettled = true;
2140
+ terminateNgrokProcess(proc, () => childClosed).then(() => {
2141
+ reject(/* @__PURE__ */ new Error("ngrok startup timed out (30s)"));
2142
+ });
2213
2143
  }
2214
2144
  }, 3e4);
2145
+ timeout.unref();
2215
2146
  const rejectIfPending = (message, kill = false) => {
2216
- if (!resolved) {
2217
- resolved = true;
2147
+ if (!startupSettled) {
2148
+ startupSettled = true;
2218
2149
  clearTimeout(timeout);
2219
- if (kill && !closed) proc.kill("SIGKILL");
2150
+ if (kill && !childClosed) proc.kill("SIGKILL");
2220
2151
  reject(new Error(message));
2221
2152
  }
2222
2153
  };
@@ -2225,8 +2156,8 @@ async function startNgrokTunnel(config) {
2225
2156
  const log = JSON.parse(line);
2226
2157
  if (log.msg === "started tunnel" && log.url) publicUrl = log.url;
2227
2158
  if (log.addr && log.url && !publicUrl) publicUrl = log.url;
2228
- if (publicUrl && !resolved) {
2229
- resolved = true;
2159
+ if (publicUrl && !startupSettled) {
2160
+ startupSettled = true;
2230
2161
  clearTimeout(timeout);
2231
2162
  const fullUrl = publicUrl + config.path;
2232
2163
  console.log(`[voice-call] ngrok tunnel active: ${fullUrl}`);
@@ -2234,38 +2165,22 @@ async function startNgrokTunnel(config) {
2234
2165
  publicUrl: fullUrl,
2235
2166
  provider: "ngrok",
2236
2167
  stop: async () => {
2237
- if (closed) return;
2238
- await new Promise((res) => {
2239
- let finished = false;
2240
- const finish = () => {
2241
- if (finished) return;
2242
- finished = true;
2243
- clearTimeout(fallback);
2244
- proc.off("close", finish);
2245
- res();
2246
- };
2247
- if (closed) {
2248
- res();
2249
- return;
2250
- }
2251
- proc.once("close", finish);
2252
- const fallback = setTimeout(finish, 2e3);
2253
- proc.kill("SIGTERM");
2254
- if (closed) finish();
2255
- });
2168
+ await terminateNgrokProcess(proc, () => childClosed);
2256
2169
  }
2257
2170
  });
2258
2171
  }
2259
2172
  } catch {}
2260
2173
  };
2261
- proc.stdout.on("data", (data) => {
2262
- const lines = (outputBuffer + data.toString()).split("\n");
2174
+ proc.stdout.setEncoding("utf8");
2175
+ proc.stderr.setEncoding("utf8");
2176
+ proc.stdout.on("data", (chunk) => {
2177
+ const lines = (outputBuffer + chunk).split("\n");
2263
2178
  outputBuffer = lines.pop() || "";
2264
2179
  if (outputBuffer.length > NGROK_LOG_BUFFER_MAX_CHARS) outputBuffer = sliceUtf16Safe(outputBuffer, -16384);
2265
2180
  for (const line of lines) if (line.trim()) processLine(line);
2266
2181
  });
2267
- proc.stderr.on("data", (data) => {
2268
- const combined = stderrTail + data.toString();
2182
+ proc.stderr.on("data", (chunk) => {
2183
+ const combined = stderrTail + chunk;
2269
2184
  if (combined.includes(NGROK_ERROR_MARKER)) rejectIfPending(`ngrok error: ${formatBoundedChildOutput(appendBoundedChildOutput(emptyBoundedChildOutput(), combined))}`, true);
2270
2185
  stderrTail = sliceUtf16Safe(combined, -8);
2271
2186
  });
@@ -2276,9 +2191,9 @@ async function startNgrokTunnel(config) {
2276
2191
  rejectIfPending(`Failed to start ngrok: ${err.message}`);
2277
2192
  });
2278
2193
  proc.on("close", (code) => {
2279
- closed = true;
2280
- if (!resolved) {
2281
- resolved = true;
2194
+ childClosed = true;
2195
+ if (!startupSettled) {
2196
+ startupSettled = true;
2282
2197
  clearTimeout(timeout);
2283
2198
  reject(/* @__PURE__ */ new Error(`ngrok exited unexpectedly with code ${code}`));
2284
2199
  }
@@ -3119,15 +3034,19 @@ function startStaleCallReaper(params) {
3119
3034
  const maxAgeSeconds = params.staleCallReaperSeconds;
3120
3035
  if (!maxAgeSeconds || maxAgeSeconds <= 0) return null;
3121
3036
  const maxAgeMs = maxAgeSeconds * 1e3;
3037
+ const callsBeingReaped = /* @__PURE__ */ new Set();
3122
3038
  const interval = setInterval(() => {
3123
3039
  const now = Date.now();
3124
3040
  for (const call of params.manager.getActiveCalls()) {
3125
3041
  if (call.answeredAt || TerminalStates.has(call.state) || LiveConversationStates.has(call.state)) continue;
3126
3042
  const age = now - call.startedAt;
3127
- if (age > maxAgeMs) {
3043
+ if (age > maxAgeMs && !callsBeingReaped.has(call.callId)) {
3044
+ callsBeingReaped.add(call.callId);
3128
3045
  console.log(`[voice-call] Reaping stale call ${call.callId} (age: ${Math.round(age / 1e3)}s, state: ${call.state})`);
3129
3046
  params.manager.endCall(call.callId).catch((err) => {
3130
3047
  console.warn(`[voice-call] Reaper failed to end call ${call.callId}:`, err);
3048
+ }).finally(() => {
3049
+ callsBeingReaped.delete(call.callId);
3131
3050
  });
3132
3051
  }
3133
3052
  }
@@ -3143,7 +3062,7 @@ const WEBHOOK_BODY_TIMEOUT_MS = WEBHOOK_BODY_READ_DEFAULTS.preAuth.timeoutMs;
3143
3062
  const MISSING_REMOTE_ADDRESS_IN_FLIGHT_KEY = "__voice_call_no_remote__";
3144
3063
  const STREAM_DISCONNECT_HANGUP_GRACE_MS = 2e3;
3145
3064
  const loadRealtimeTranscriptionRuntime = createLazyRuntimeModule(() => import("./realtime-transcription.runtime-CbJAs5t_.js"));
3146
- const loadResponseGeneratorModule = createLazyRuntimeModule(() => import("./response-generator-hmAdGp5h.js"));
3065
+ const loadResponseGeneratorModule = createLazyRuntimeModule(() => import("./response-generator-r9wHPOEl.js"));
3147
3066
  function appendRecentTalkEventMetadata(call, event) {
3148
3067
  const metadata = call.metadata ?? {};
3149
3068
  const recent = Array.isArray(metadata.recentTalkEvents) ? metadata.recentTalkEvents.filter((entry) => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry)) : [];
@@ -3818,12 +3737,12 @@ const REALTIME_VOICE_CONSULT_SYSTEM_PROMPT = [
3818
3737
  "Do not print secret values or dump environment variables; only check whether required configuration is present.",
3819
3738
  "Be accurate, brief, and speakable."
3820
3739
  ].join(" ");
3821
- const loadTelnyxProvider = createLazyRuntimeModule(() => import("./telnyx-B9tF5jeJ.js"));
3822
- const loadTwilioProvider = createLazyRuntimeModule(() => import("./twilio-U_myvowz.js"));
3823
- const loadPlivoProvider = createLazyRuntimeModule(() => import("./plivo-BICQA_yK.js"));
3740
+ const loadTelnyxProvider = createLazyRuntimeModule(() => import("./telnyx-DTD9jTqC.js"));
3741
+ const loadTwilioProvider = createLazyRuntimeModule(() => import("./twilio-GzbIb5fv.js"));
3742
+ const loadPlivoProvider = createLazyRuntimeModule(() => import("./plivo-rISw-5A8.js"));
3824
3743
  const loadMockProvider = createLazyRuntimeModule(() => import("./mock-YsvBTX-7.js"));
3825
3744
  const loadRealtimeVoiceRuntime = createLazyRuntimeModule(() => import("./realtime-voice.runtime-vtdCOWg-.js"));
3826
- const loadRealtimeHandler = createLazyRuntimeModule(() => import("./realtime-handler-DJk-eYyf.js"));
3745
+ const loadRealtimeHandler = createLazyRuntimeModule(() => import("./realtime-handler-HSxcgwY_.js"));
3827
3746
  function resolveVoiceCallConsultSessionKey(call) {
3828
3747
  return resolveVoiceCallSessionKey({
3829
3748
  config: call.config,
@@ -4103,8 +4022,8 @@ async function createVoiceCallRuntime(params) {
4103
4022
  if (provider.name === "twilio" && config.streaming?.enabled) {
4104
4023
  const twilioProvider = provider;
4105
4024
  if (ttsRuntime?.textToSpeechTelephony) try {
4106
- const ttsProvider = createTelephonyTtsProvider({
4107
- coreConfig,
4025
+ const ttsProvider = await createTelephonyTtsProvider({
4026
+ coreConfig: cfg,
4108
4027
  ttsOverride: config.tts,
4109
4028
  runtime: ttsRuntime,
4110
4029
  logger: log
@@ -1,2 +1,2 @@
1
- import { t as createVoiceCallRuntime } from "./runtime-entry-DhTLZ2uB.js";
1
+ import { t as createVoiceCallRuntime } from "./runtime-entry-D8Mkwy8G.js";
2
2
  export { createVoiceCallRuntime };
@@ -1,7 +1,8 @@
1
1
  import { z } from "zod";
2
- import path from "node:path";
3
2
  import { createHash, randomUUID } from "node:crypto";
3
+ import path from "node:path";
4
4
  import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
5
+ import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
5
6
  //#region extensions/voice-call/src/types.ts
6
7
  const ProviderNameSchema = z.enum([
7
8
  "telnyx",
@@ -124,7 +125,7 @@ const CallRecordSchema = z.object({
124
125
  });
125
126
  //#endregion
126
127
  //#region extensions/voice-call/src/runtime-state.ts
127
- const { setRuntime: setVoiceCallStateRuntime, clearRuntime: clearVoiceCallStateRuntime, tryGetRuntime: getOptionalVoiceCallStateRuntime } = createPluginRuntimeStore({
128
+ const { setRuntime: setVoiceCallStateRuntime, tryGetRuntime: getOptionalVoiceCallStateRuntime } = createPluginRuntimeStore({
128
129
  pluginId: "voice-call-state",
129
130
  errorMessage: "Voice Call state runtime not initialized"
130
131
  });
@@ -400,4 +401,10 @@ async function getCallHistoryFromStore(storePath, limit = 50) {
400
401
  return readCallHistoryFromStore(storePath).slice(-limit);
401
402
  }
402
403
  //#endregion
403
- export { MAX_CALL_RECORD_EVENTS as a, findCallMatchesInStore as c, parseVoiceCallRecordLine as d, persistCallRecord as f, TerminalStates as g, setVoiceCallStateRuntime as h, CALL_RECORD_EVENT_META_MAX_ENTRIES as i, getCallHistoryFromStore as l, resolveVoiceCallLegacyCallLogPath as m, CALL_RECORD_EVENTS_NAMESPACE as n, RAW_CALL_RECORD_CHUNK_BYTES as o, prepareVoiceCallRecordForStorage as p, CALL_RECORD_EVENT_CHUNKS_NAMESPACE as r, buildVoiceCallLegacyJsonlEventKey as s, CALL_RECORD_CHUNK_MAX_ENTRIES as t, loadActiveCallsFromStore as u };
404
+ //#region extensions/voice-call/src/store-path.ts
405
+ /** Resolve the plugin-owned store below OpenClaw's canonical state directory. */
406
+ function resolveDefaultVoiceCallStoreDir(env = process.env) {
407
+ return path.join(resolveStateDir(env), "voice-calls");
408
+ }
409
+ //#endregion
410
+ export { TerminalStates as _, CALL_RECORD_EVENT_META_MAX_ENTRIES as a, buildVoiceCallLegacyJsonlEventKey as c, loadActiveCallsFromStore as d, parseVoiceCallRecordLine as f, setVoiceCallStateRuntime as g, resolveVoiceCallLegacyCallLogPath as h, CALL_RECORD_EVENT_CHUNKS_NAMESPACE as i, findCallMatchesInStore as l, prepareVoiceCallRecordForStorage as m, CALL_RECORD_CHUNK_MAX_ENTRIES as n, MAX_CALL_RECORD_EVENTS as o, persistCallRecord as p, CALL_RECORD_EVENTS_NAMESPACE as r, RAW_CALL_RECORD_CHUNK_BYTES as s, resolveDefaultVoiceCallStoreDir as t, getCallHistoryFromStore as u };
@@ -1,4 +1,4 @@
1
- import { s as verifyTelnyxWebhook, t as guardedJsonApiRequest } from "./guarded-json-api-DoLAoIRF.js";
1
+ import { s as verifyTelnyxWebhook, t as guardedJsonApiRequest } from "./guarded-json-api-Dm4NvtGo.js";
2
2
  import crypto from "node:crypto";
3
3
  //#region extensions/voice-call/src/providers/telnyx.ts
4
4
  function normalizeTelnyxDirection(direction) {
@@ -1,9 +1,10 @@
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 { _ as mapVoiceToPolly, a as normalizeProviderStatus, f as chunkAudio, g as escapeXml, i as mapProviderStatusToEndReason, o as getHeader, r as isProviderStatusTerminal } from "./runtime-entry-DhTLZ2uB.js";
5
- import { c as verifyTwilioWebhook, i as readProviderJsonResponseText, n as cancelProviderResponseBody, r as readProviderErrorResponseSnippet, t as guardedJsonApiRequest } from "./guarded-json-api-DoLAoIRF.js";
4
+ import { _ as mapVoiceToPolly, a as normalizeProviderStatus, f as chunkAudio, g as escapeXml, i as mapProviderStatusToEndReason, o as getHeader, r as isProviderStatusTerminal } from "./runtime-entry-D8Mkwy8G.js";
5
+ import { c as verifyTwilioWebhook, i as readProviderJsonResponseText, n as cancelProviderResponseBody, r as readProviderErrorResponseSnippet, t as guardedJsonApiRequest } from "./guarded-json-api-Dm4NvtGo.js";
6
6
  import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
7
+ import { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env";
7
8
  import crypto from "node:crypto";
8
9
  import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
9
10
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
@@ -628,9 +629,12 @@ var TwilioProvider = class TwilioProvider {
628
629
  chunkAttempts += 1;
629
630
  if (sendAudioChunk(chunk).sent) chunkDelivered += 1;
630
631
  const waitMs = nextChunkDueAt - Date.now();
631
- if (waitMs > 0) await new Promise((resolve) => {
632
- setTimeout(resolve, Math.ceil(waitMs));
633
- });
632
+ if (waitMs > 0) try {
633
+ await sleepWithAbort(Math.ceil(waitMs), signal);
634
+ } catch (error) {
635
+ if (!signal.aborted) throw error;
636
+ break;
637
+ }
634
638
  nextChunkDueAt += CHUNK_DELAY_MS;
635
639
  if (signal.aborted) break;
636
640
  }
@@ -1,19 +1,19 @@
1
1
  {
2
2
  "name": "@openclaw/voice-call",
3
- "version": "2026.7.2-beta.1",
3
+ "version": "2026.7.2-beta.3",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@openclaw/voice-call",
9
- "version": "2026.7.2-beta.1",
9
+ "version": "2026.7.2-beta.3",
10
10
  "dependencies": {
11
11
  "typebox": "1.3.3",
12
12
  "ws": "8.21.0",
13
13
  "zod": "4.4.3"
14
14
  },
15
15
  "peerDependencies": {
16
- "openclaw": ">=2026.7.2-beta.1"
16
+ "openclaw": ">=2026.7.2-beta.3"
17
17
  },
18
18
  "peerDependenciesMeta": {
19
19
  "openclaw": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/voice-call",
3
- "version": "2026.7.2-beta.1",
3
+ "version": "2026.7.2-beta.3",
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.7.2-beta.1"
16
+ "openclaw": ">=2026.7.2-beta.3"
17
17
  },
18
18
  "peerDependenciesMeta": {
19
19
  "openclaw": {
@@ -30,10 +30,10 @@
30
30
  "minHostVersion": ">=2026.4.10"
31
31
  },
32
32
  "compat": {
33
- "pluginApi": ">=2026.7.2-beta.1"
33
+ "pluginApi": ">=2026.7.2-beta.3"
34
34
  },
35
35
  "build": {
36
- "openclawVersion": "2026.7.2-beta.1"
36
+ "openclawVersion": "2026.7.2-beta.3"
37
37
  },
38
38
  "release": {
39
39
  "publishToClawHub": true,