@openclaw/voice-call 2026.8.1-beta.2 → 2026.8.1

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.
Files changed (141) hide show
  1. package/README.md +4 -3
  2. package/dist/doctor-contract-api.js +10 -5
  3. package/dist/{guarded-json-api-DEDwgf4J.js → guarded-json-api-CQXCe4aR.js} +1 -1
  4. package/dist/index.js +390 -338
  5. package/dist/{mock-BCq0q36l.js → mock-Dv7KxYGt.js} +3 -1
  6. package/dist/{plivo-DbI17uAc.js → plivo-Ct5BT5LQ.js} +2 -2
  7. package/dist/{realtime-handler-uXR0QgVa.js → realtime-handler-CSK1A1Gg.js} +270 -144
  8. package/dist/{response-generator-D-hkzo5Y.js → response-generator-xbT37lYx.js} +59 -12
  9. package/dist/{runtime-entry-F13eJ4pp.js → runtime-entry-q02qkX8F.js} +435 -231
  10. package/dist/runtime-entry.js +1 -1
  11. package/dist/{store-path-51_oC_xn.js → store-path-Cf6k3F2U.js} +3 -10
  12. package/dist/{telnyx-sIS_WgnY.js → telnyx-C9Yizvox.js} +12 -8
  13. package/dist/{twilio-CbeVrVAl.js → twilio-BLjVv246.js} +40 -27
  14. package/node_modules/typebox/build/format/date.d.mts +1 -1
  15. package/node_modules/typebox/build/format/date.mjs +1 -1
  16. package/node_modules/typebox/build/format/date_time.d.mts +2 -2
  17. package/node_modules/typebox/build/format/date_time.mjs +3 -3
  18. package/node_modules/typebox/build/format/email.d.mts +1 -1
  19. package/node_modules/typebox/build/format/email.mjs +2 -2
  20. package/node_modules/typebox/build/format/hostname.mjs +2 -10
  21. package/node_modules/typebox/build/format/idn_email.d.mts +1 -1
  22. package/node_modules/typebox/build/format/idn_email.mjs +3 -3
  23. package/node_modules/typebox/build/format/idn_hostname.d.mts +1 -0
  24. package/node_modules/typebox/build/format/idn_hostname.mjs +3 -11
  25. package/node_modules/typebox/build/format/idna/format/bidi.d.mts +3 -0
  26. package/node_modules/typebox/build/format/idna/format/bidi.mjs +98 -0
  27. package/node_modules/typebox/build/format/idna/format/puny.d.mts +3 -0
  28. package/node_modules/typebox/build/format/idna/format/puny.mjs +144 -0
  29. package/node_modules/typebox/build/format/idna/hostname.d.mts +1 -0
  30. package/node_modules/typebox/build/format/idna/hostname.mjs +16 -0
  31. package/node_modules/typebox/build/format/idna/idn-hostname.d.mts +1 -0
  32. package/node_modules/typebox/build/format/idna/idn-hostname.mjs +23 -0
  33. package/node_modules/typebox/build/format/idna/index.d.mts +2 -0
  34. package/node_modules/typebox/build/format/idna/index.mjs +2 -0
  35. package/node_modules/typebox/build/format/idna/label/ascii.d.mts +1 -0
  36. package/node_modules/typebox/build/format/idna/label/ascii.mjs +9 -0
  37. package/node_modules/typebox/build/format/idna/label/puny.d.mts +1 -0
  38. package/node_modules/typebox/build/format/idna/label/puny.mjs +28 -0
  39. package/node_modules/typebox/build/format/idna/label/unicode.d.mts +1 -0
  40. package/node_modules/typebox/build/format/idna/label/unicode.mjs +106 -0
  41. package/node_modules/typebox/build/format/idna/pattern/pattern.d.mts +30 -0
  42. package/node_modules/typebox/build/format/idna/pattern/pattern.mjs +70 -0
  43. package/node_modules/typebox/build/format/ipv4.d.mts +0 -1
  44. package/node_modules/typebox/build/format/ipv4.mjs +2 -32
  45. package/node_modules/typebox/build/format/ipv6.mjs +2 -61
  46. package/node_modules/typebox/build/format/iri.d.mts +2 -2
  47. package/node_modules/typebox/build/format/iri.mjs +26 -8
  48. package/node_modules/typebox/build/format/iri_reference.d.mts +2 -2
  49. package/node_modules/typebox/build/format/iri_reference.mjs +9 -55
  50. package/node_modules/typebox/build/format/regex.d.mts +1 -2
  51. package/node_modules/typebox/build/format/regex.mjs +2 -6
  52. package/node_modules/typebox/build/format/time.d.mts +2 -2
  53. package/node_modules/typebox/build/format/time.mjs +19 -14
  54. package/node_modules/typebox/build/format/uri.d.mts +1 -1
  55. package/node_modules/typebox/build/format/uri.mjs +3 -130
  56. package/node_modules/typebox/build/format/uri_reference.d.mts +1 -1
  57. package/node_modules/typebox/build/format/uri_reference.mjs +2 -3
  58. package/node_modules/typebox/build/format/uri_template.d.mts +2 -3
  59. package/node_modules/typebox/build/format/uri_template.mjs +4 -5
  60. package/node_modules/typebox/build/format/url.d.mts +3 -3
  61. package/node_modules/typebox/build/format/url.mjs +4 -5
  62. package/node_modules/typebox/build/guard/emit.d.mts +3 -1
  63. package/node_modules/typebox/build/guard/emit.mjs +12 -5
  64. package/node_modules/typebox/build/guard/guard.d.mts +8 -2
  65. package/node_modules/typebox/build/guard/guard.mjs +25 -4
  66. package/node_modules/typebox/build/guard/string.d.mts +2 -2
  67. package/node_modules/typebox/build/guard/string.mjs +7 -7
  68. package/node_modules/typebox/build/schema/engine/_context.mjs +3 -3
  69. package/node_modules/typebox/build/schema/engine/_functions.mjs +1 -2
  70. package/node_modules/typebox/build/schema/engine/_regexp.d.mts +2 -0
  71. package/node_modules/typebox/build/schema/engine/_regexp.mjs +4 -0
  72. package/node_modules/typebox/build/schema/engine/_stack.mjs +12 -13
  73. package/node_modules/typebox/build/schema/engine/additionalItems.mjs +17 -7
  74. package/node_modules/typebox/build/schema/engine/additionalProperties.mjs +4 -3
  75. package/node_modules/typebox/build/schema/engine/contains.mjs +18 -6
  76. package/node_modules/typebox/build/schema/engine/enum.mjs +1 -1
  77. package/node_modules/typebox/build/schema/engine/items.mjs +26 -6
  78. package/node_modules/typebox/build/schema/engine/maxContains.mjs +3 -3
  79. package/node_modules/typebox/build/schema/engine/minContains.mjs +14 -4
  80. package/node_modules/typebox/build/schema/engine/not.mjs +4 -2
  81. package/node_modules/typebox/build/schema/engine/oneOf.mjs +7 -6
  82. package/node_modules/typebox/build/schema/engine/pattern.mjs +3 -2
  83. package/node_modules/typebox/build/schema/engine/patternProperties.mjs +4 -3
  84. package/node_modules/typebox/build/schema/engine/ref.mjs +1 -7
  85. package/node_modules/typebox/build/schema/engine/schema.mjs +13 -4
  86. package/node_modules/typebox/build/schema/engine/type.mjs +1 -1
  87. package/node_modules/typebox/build/schema/resolve/resolve.d.mts +4 -1
  88. package/node_modules/typebox/build/schema/resolve/resolve.mjs +202 -1
  89. package/node_modules/typebox/build/schema/static/propertyNames.d.mts +5 -0
  90. package/node_modules/typebox/build/schema/static/propertyNames.mjs +3 -0
  91. package/node_modules/typebox/build/schema/static/schema.d.mts +3 -0
  92. package/node_modules/typebox/build/schema/types/index.d.mts +0 -2
  93. package/node_modules/typebox/build/schema/types/index.mjs +0 -2
  94. package/node_modules/typebox/build/system/memory/clone.mjs +14 -11
  95. package/node_modules/typebox/build/system/memory/discard.mjs +6 -5
  96. package/node_modules/typebox/build/system/settings/settings.d.mts +9 -0
  97. package/node_modules/typebox/build/system/settings/settings.mjs +2 -0
  98. package/node_modules/typebox/build/type/engine/call/distribute_arguments.d.mts +6 -1
  99. package/node_modules/typebox/build/type/engine/call/distribute_arguments.mjs +16 -3
  100. package/node_modules/typebox/build/type/engine/call/instantiate.mjs +34 -4
  101. package/node_modules/typebox/build/type/engine/evaluate/broaden.d.mts +5 -5
  102. package/node_modules/typebox/build/type/engine/evaluate/broaden.mjs +20 -26
  103. package/node_modules/typebox/build/type/engine/evaluate/compare.d.mts +6 -10
  104. package/node_modules/typebox/build/type/engine/evaluate/compare.mjs +9 -13
  105. package/node_modules/typebox/build/type/engine/evaluate/composite.d.mts +7 -4
  106. package/node_modules/typebox/build/type/engine/evaluate/composite.mjs +12 -19
  107. package/node_modules/typebox/build/type/engine/evaluate/distribute.d.mts +4 -20
  108. package/node_modules/typebox/build/type/engine/evaluate/distribute.mjs +8 -20
  109. package/node_modules/typebox/build/type/engine/evaluate/evaluate.d.mts +4 -4
  110. package/node_modules/typebox/build/type/engine/evaluate/evaluate.mjs +11 -8
  111. package/node_modules/typebox/build/type/engine/evaluate/flatten.d.mts +1 -1
  112. package/node_modules/typebox/build/type/engine/evaluate/flatten.mjs +3 -4
  113. package/node_modules/typebox/build/type/engine/evaluate/narrow.d.mts +17 -2
  114. package/node_modules/typebox/build/type/engine/evaluate/narrow.mjs +26 -6
  115. package/node_modules/typebox/build/type/engine/exclude/operation.mjs +3 -4
  116. package/node_modules/typebox/build/type/engine/extract/operation.d.mts +1 -1
  117. package/node_modules/typebox/build/type/engine/extract/operation.mjs +3 -4
  118. package/node_modules/typebox/build/type/engine/priority/priority.d.mts +2 -2
  119. package/node_modules/typebox/build/type/engine/priority/priority.mjs +3 -4
  120. package/node_modules/typebox/build/type/script/mapping.d.mts +32 -29
  121. package/node_modules/typebox/build/type/script/mapping.mjs +18 -25
  122. package/node_modules/typebox/build/type/script/parser.d.mts +19 -25
  123. package/node_modules/typebox/build/type/script/parser.mjs +27 -23
  124. package/node_modules/typebox/build/value/errors/errors.d.mts +2 -2
  125. package/node_modules/typebox/build/value/errors/errors.mjs +1 -1
  126. package/node_modules/typebox/package.json +27 -27
  127. package/node_modules/typebox/readme.md +79 -82
  128. package/node_modules/ws/lib/permessage-deflate.js +3 -1
  129. package/node_modules/ws/package.json +1 -1
  130. package/openclaw.plugin.json +20 -2
  131. package/package.json +6 -6
  132. package/node_modules/typebox/build/format/_idna.d.mts +0 -2
  133. package/node_modules/typebox/build/format/_idna.mjs +0 -246
  134. package/node_modules/typebox/build/format/_puny.d.mts +0 -1
  135. package/node_modules/typebox/build/format/_puny.mjs +0 -77
  136. package/node_modules/typebox/build/schema/resolve/ref.d.mts +0 -3
  137. package/node_modules/typebox/build/schema/resolve/ref.mjs +0 -146
  138. package/node_modules/typebox/build/schema/types/contentEncoding.d.mts +0 -9
  139. package/node_modules/typebox/build/schema/types/contentEncoding.mjs +0 -13
  140. package/node_modules/typebox/build/schema/types/contentMediaType.d.mts +0 -9
  141. package/node_modules/typebox/build/schema/types/contentMediaType.mjs +0 -13
package/dist/index.js CHANGED
@@ -1,41 +1,183 @@
1
1
  import { definePluginEntry, sleep } from "./runtime-api.js";
2
2
  import "./api.js";
3
3
  import { VOICE_CALL_CLI_DESCRIPTOR } from "./cli-output-mode.js";
4
- import { S as validateProviderConfig, b as resolveVoiceCallConfig, c as resolveWebhookExposureStatus, d as setupTailscaleExposureRoute, g as resolveUserPath, l as cleanupTailscaleExposureRoute, t as createVoiceCallRuntime, u as getTailscaleSelfInfo, y as VoiceCallConfigSchema } from "./runtime-entry-F13eJ4pp.js";
5
- import { b as setVoiceCallStateRuntime, t as resolveDefaultVoiceCallStoreDir, u as getCallHistoryFromStore } from "./store-path-51_oC_xn.js";
4
+ import { C as resolveVoiceCallStreamExposurePaths, b as resolveVoiceCallConfig, c as resolveWebhookExposureStatus, d as setupTailscaleExposureRoutes, g as resolveUserPath, l as cleanupTailscaleExposureRoute, t as createVoiceCallRuntime, u as getTailscaleSelfInfo, w as validateProviderConfig, y as VoiceCallConfigSchema } from "./runtime-entry-q02qkX8F.js";
5
+ import { b as setVoiceCallStateRuntime, d as loadActiveCallsFromStore, l as findCallMatchesInStore, t as resolveDefaultVoiceCallStoreDir, u as getCallHistoryFromStore } from "./store-path-Cf6k3F2U.js";
6
6
  import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
7
- import { ErrorCodes, callGatewayFromCli, errorShape } from "openclaw/plugin-sdk/gateway-runtime";
7
+ import { ErrorCodes, callGatewayFromCli, errorShape, isGatewayClientRequestError, isGatewayTransportError, redactSensitiveUrlLikeString } from "openclaw/plugin-sdk/gateway-runtime";
8
8
  import { resolveGlobalSingleton } from "openclaw/plugin-sdk/global-singleton";
9
9
  import { normalizeAgentId, parseAgentSessionKey } from "openclaw/plugin-sdk/routing";
10
10
  import { asNonArrayRecord, asOptionalRecord, isRecord, normalizeOptionalLowercaseString, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
11
11
  import { jsonResult } from "openclaw/plugin-sdk/tool-results";
12
12
  import { Type } from "typebox";
13
+ import { MAX_TCP_PORT, MAX_TIMER_TIMEOUT_MS, addTimerTimeoutGraceMs, clampTimerTimeoutMs, parseStrictNonNegativeInteger, resolveTimerTimeoutMs, timestampMsToIsoString } from "openclaw/plugin-sdk/number-runtime";
13
14
  import fs from "node:fs";
14
15
  import { randomUUID } from "node:crypto";
15
16
  import path from "node:path";
16
- import { MAX_TCP_PORT, MAX_TIMER_TIMEOUT_MS, addTimerTimeoutGraceMs, clampTimerTimeoutMs, parseStrictNonNegativeInteger, resolveTimerTimeoutMs, timestampMsToIsoString } from "openclaw/plugin-sdk/number-runtime";
17
17
  import { StringDecoder } from "node:string_decoder";
18
18
  import { format } from "node:util";
19
- //#region extensions/voice-call/src/cli.ts
20
- const VOICE_CALL_GATEWAY_DEFAULT_TIMEOUT_MS = 5e3;
21
- const VOICE_CALL_GATEWAY_OPERATION_TIMEOUT_MS = 3e4;
22
- const VOICE_CALL_GATEWAY_TRANSCRIPT_BUFFER_MS = 1e4;
23
- const VOICE_CALL_GATEWAY_POLL_INTERVAL_MS = 1e3;
24
- function writeStdoutLine(...values) {
19
+ //#region extensions/voice-call/src/cli-command-io.ts
20
+ function writeCliLine(...values) {
25
21
  process.stdout.write(`${format(...values)}\n`);
26
22
  }
27
- function writeStdoutJson(value) {
23
+ function writeCliJson(value) {
28
24
  process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
29
25
  }
30
- function parseVoiceCallIntOption(raw, optionName, opts) {
26
+ function parseCliInteger(raw, optionName, opts) {
31
27
  const min = opts?.min ?? 0;
32
28
  const parsed = parseStrictNonNegativeInteger(raw?.trim() ?? "");
33
29
  if (parsed === void 0 || parsed < min || opts?.max !== void 0 && parsed > opts.max) throw new Error(`Invalid numeric value for ${optionName}: ${raw ?? ""}`);
34
30
  return parsed;
35
31
  }
32
+ //#endregion
33
+ //#region extensions/voice-call/src/cli-call-log.ts
34
+ function percentile(values, p) {
35
+ if (values.length === 0) return 0;
36
+ const sorted = [...values].toSorted((a, b) => a - b);
37
+ return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(p / 100 * sorted.length) - 1))] ?? 0;
38
+ }
39
+ function summarizeSeries(values) {
40
+ if (values.length === 0) return {
41
+ count: 0,
42
+ minMs: 0,
43
+ maxMs: 0,
44
+ avgMs: 0,
45
+ p50Ms: 0,
46
+ p95Ms: 0
47
+ };
48
+ const minMs = values.reduce((min, value) => value < min ? value : min);
49
+ const maxMs = values.reduce((max, value) => value > max ? value : max);
50
+ const avgMs = values.reduce((sum, value) => sum + value, 0) / values.length;
51
+ return {
52
+ count: values.length,
53
+ minMs,
54
+ maxMs,
55
+ avgMs,
56
+ p50Ms: percentile(values, 50),
57
+ p95Ms: percentile(values, 95)
58
+ };
59
+ }
60
+ function writeVoiceCallLatencySummary(calls) {
61
+ const turnLatencyMs = [];
62
+ const listenWaitMs = [];
63
+ for (const call of calls) {
64
+ const metadata = isRecord(call) && isRecord(call.metadata) ? call.metadata : void 0;
65
+ const latency = metadata?.lastTurnLatencyMs;
66
+ const listenWait = metadata?.lastTurnListenWaitMs;
67
+ if (typeof latency === "number" && Number.isFinite(latency)) turnLatencyMs.push(latency);
68
+ if (typeof listenWait === "number" && Number.isFinite(listenWait)) listenWaitMs.push(listenWait);
69
+ }
70
+ writeCliJson({
71
+ recordsScanned: calls.length,
72
+ turnLatency: summarizeSeries(turnLatencyMs),
73
+ listenWait: summarizeSeries(listenWaitMs)
74
+ });
75
+ }
76
+ function registerVoiceCallLogs(params) {
77
+ params.root.command("tail").description("Tail voice-call JSONL logs (prints new lines; useful during provider tests)").option("--file <path>", "Path to calls.jsonl", params.defaultFile).option("--since <n>", "Print last N lines first", "25").option("--poll <ms>", "Poll interval in ms", "250").action(async (options) => {
78
+ const file = options.file;
79
+ const since = parseCliInteger(options.since, "--since", { min: 0 });
80
+ const pollMs = parseCliInteger(options.poll, "--poll", { min: 50 });
81
+ const tailSqliteHistory = async (initialLimit) => {
82
+ params.ensureHistoryStateRuntime();
83
+ const seen = /* @__PURE__ */ new Set();
84
+ const printCall = (call) => {
85
+ const line = JSON.stringify(call);
86
+ if (!seen.has(line)) {
87
+ seen.add(line);
88
+ writeCliLine(line);
89
+ }
90
+ };
91
+ if (initialLimit > 0) for (const call of await getCallHistoryFromStore(path.dirname(file), initialLimit)) printCall(call);
92
+ for (;;) {
93
+ try {
94
+ for (const call of await getCallHistoryFromStore(path.dirname(file), 1e3)) printCall(call);
95
+ } catch {}
96
+ await sleep(pollMs);
97
+ }
98
+ };
99
+ if (fs.existsSync(file) && path.basename(file) !== "calls.jsonl") {
100
+ const initial = fs.readFileSync(file);
101
+ let decoder = new StringDecoder("utf8");
102
+ const initialLines = decoder.write(initial).split("\n");
103
+ let pendingLine = initialLines.pop() ?? "";
104
+ const lines = initialLines.filter(Boolean);
105
+ for (const line of lines.slice(Math.max(0, lines.length - since))) writeCliLine(line);
106
+ let offset = initial.length;
107
+ let lastObservedSize = initial.length;
108
+ for (;;) {
109
+ try {
110
+ const stat = fs.statSync(file);
111
+ if (stat.size < lastObservedSize) {
112
+ offset = 0;
113
+ decoder = new StringDecoder("utf8");
114
+ pendingLine = "";
115
+ }
116
+ lastObservedSize = stat.size;
117
+ if (stat.size > offset) {
118
+ const fd = fs.openSync(file, "r");
119
+ try {
120
+ const buf = Buffer.alloc(stat.size - offset);
121
+ const bytesRead = fs.readSync(fd, buf, 0, buf.length, offset);
122
+ offset += bytesRead;
123
+ const text = decoder.write(buf.subarray(0, bytesRead));
124
+ const completeLines = `${pendingLine}${text}`.split("\n");
125
+ pendingLine = completeLines.pop() ?? "";
126
+ for (const line of completeLines.filter(Boolean)) writeCliLine(line);
127
+ } finally {
128
+ fs.closeSync(fd);
129
+ }
130
+ }
131
+ } catch {}
132
+ await sleep(pollMs);
133
+ }
134
+ } else await tailSqliteHistory(since);
135
+ });
136
+ params.root.command("latency").description("Summarize turn latency metrics from voice-call JSONL logs").option("--file <path>", "Path to calls.jsonl", params.defaultFile).option("--last <n>", "Analyze last N records", "200").action(async (options) => {
137
+ const file = options.file;
138
+ const last = parseCliInteger(options.last, "--last", { min: 1 });
139
+ if (fs.existsSync(file) && path.basename(file) !== "calls.jsonl") writeVoiceCallLatencySummary(fs.readFileSync(file, "utf8").split("\n").filter(Boolean).slice(-last).map((line) => {
140
+ try {
141
+ const parsed = JSON.parse(line);
142
+ return (isRecord(parsed) ? parsed.call : void 0) ?? parsed;
143
+ } catch {
144
+ return null;
145
+ }
146
+ }).filter((call) => call !== null));
147
+ else {
148
+ params.ensureHistoryStateRuntime();
149
+ writeVoiceCallLatencySummary(await getCallHistoryFromStore(path.dirname(file), last));
150
+ }
151
+ });
152
+ }
153
+ //#endregion
154
+ //#region extensions/voice-call/src/cli-gateway-call.ts
155
+ const VOICE_CALL_GATEWAY_DEFAULT_TIMEOUT_MS = 5e3;
156
+ const VOICE_CALL_GATEWAY_OPERATION_TIMEOUT_MS = 3e4;
157
+ const VOICE_CALL_GATEWAY_TRANSCRIPT_BUFFER_MS = 1e4;
158
+ const VOICE_CALL_GATEWAY_POLL_INTERVAL_MS = 1e3;
36
159
  function isGatewayUnavailableForLocalFallback(err) {
160
+ return isGatewayTransportError(err) && err.kind === "closed" && (err.code === void 0 || err.code === 1006);
161
+ }
162
+ function isGatewayCredentialFailure(err) {
163
+ return err instanceof Error && (err.name === "GatewayCredentialsRequiredError" || err.name === "GatewayExplicitAuthRequiredError" || err.name === "GatewaySecretRefUnavailableError");
164
+ }
165
+ function gatewayOperationalError(err) {
37
166
  const message = formatErrorMessage(err);
38
- return message.includes("ECONNREFUSED") || message.includes("ECONNRESET") || message.includes("EHOSTUNREACH") || message.includes("ENOTFOUND") || message.includes("gateway closed (1006") || message.includes("gateway not connected");
167
+ const detail = (() => {
168
+ if (isGatewayClientRequestError(err)) return `Gateway responded but voicecall failed: ${message}\nThe running Gateway owns the voice-call runtime; check \`openclaw gateway status\` or restart it.`;
169
+ if (isGatewayCredentialFailure(err)) return `Gateway requires credentials: ${message}\nConfigure gateway.auth or pair this device with \`openclaw devices approve --latest\`.`;
170
+ if (isGatewayTransportError(err)) {
171
+ const url = err.connectionDetails.url;
172
+ if (err.kind === "timeout") return `Gateway at ${url} did not answer within ${err.timeoutMs === void 0 ? "the configured timeout" : `${err.timeoutMs}ms`}: ${message}\nIt may be starting or wedged; check \`openclaw gateway status\`.`;
173
+ return `Gateway connection at ${url} failed: ${message}\nCheck gateway.auth and \`openclaw gateway status\`, then retry.`;
174
+ }
175
+ return `Gateway voicecall request failed: ${message}\nCheck \`openclaw gateway status\`, then retry.`;
176
+ })();
177
+ return new Error(redactSensitiveUrlLikeString(detail));
178
+ }
179
+ function isUnknownMethod(err, method) {
180
+ return formatErrorMessage(err).includes(`unknown method: ${method}`);
39
181
  }
40
182
  async function callVoiceCallGateway(method, params, opts) {
41
183
  try {
@@ -52,21 +194,18 @@ async function callVoiceCallGateway(method, params, opts) {
52
194
  ok: false,
53
195
  error: err
54
196
  };
55
- throw err;
197
+ throw gatewayOperationalError(err);
56
198
  }
57
199
  }
58
- function resolveGatewayOperationTimeoutMs(config) {
200
+ function resolveOperationTimeout(config) {
59
201
  return Math.max(VOICE_CALL_GATEWAY_OPERATION_TIMEOUT_MS, addTimerTimeoutGraceMs(config.ringTimeoutMs) ?? 1);
60
202
  }
61
- function resolveGatewayContinueTimeoutMs(config) {
203
+ function resolveContinueTimeout(config) {
62
204
  return clampTimerTimeoutMs(config.transcriptTimeoutMs + VOICE_CALL_GATEWAY_OPERATION_TIMEOUT_MS + VOICE_CALL_GATEWAY_TRANSCRIPT_BUFFER_MS) ?? 1;
63
205
  }
64
206
  function resolveVoiceCallDeadlineMs(timeoutMs, nowMs = Date.now()) {
65
207
  return nowMs + (clampTimerTimeoutMs(timeoutMs) ?? MAX_TIMER_TIMEOUT_MS);
66
208
  }
67
- function isUnknownGatewayMethod(err, method) {
68
- return formatErrorMessage(err).includes(`unknown method: ${method}`);
69
- }
70
209
  function readGatewayOperationId(payload) {
71
210
  if (isRecord(payload) && typeof payload.operationId === "string" && payload.operationId) return payload.operationId;
72
211
  throw new Error("voicecall gateway response missing operationId");
@@ -88,7 +227,12 @@ function readCompletedContinueResult(payload) {
88
227
  };
89
228
  throw new Error("voicecall gateway response has unknown operation status");
90
229
  }
91
- async function pollVoiceCallContinueGateway(params) {
230
+ async function pollContinueGateway(payload, fallbackTimeoutMs) {
231
+ if (!isRecord(payload) || typeof payload.operationId !== "string") return payload;
232
+ const params = {
233
+ operationId: readGatewayOperationId(payload),
234
+ timeoutMs: readGatewayPollTimeoutMs(payload, fallbackTimeoutMs)
235
+ };
92
236
  const deadlineMs = resolveVoiceCallDeadlineMs(params.timeoutMs);
93
237
  for (;;) {
94
238
  const remainingMs = deadlineMs - Date.now();
@@ -104,6 +248,54 @@ async function pollVoiceCallContinueGateway(params) {
104
248
  }
105
249
  throw new Error("voicecall continue timed out waiting for gateway operation");
106
250
  }
251
+ async function ensureStandaloneRuntime(params) {
252
+ try {
253
+ return await params.ensureRuntime();
254
+ } catch (err) {
255
+ if (err instanceof Error && "code" in err && err.code === "EADDRINUSE") throw new Error(`Voice-call webhook port ${params.config.serve.port} is already in use. A running Gateway probably already serves it; operational commands route through that Gateway. Check \`openclaw gateway status\` and retry.`, { cause: err });
256
+ throw err;
257
+ }
258
+ }
259
+ async function runGatewayManagerCommand(params) {
260
+ const gateway = await params.gatewayCall();
261
+ if (gateway.ok) {
262
+ writeCliJson(params.resolveGatewayPayload ? await params.resolveGatewayPayload(gateway.payload) : gateway.payload);
263
+ return;
264
+ }
265
+ const runtime = await ensureStandaloneRuntime(params);
266
+ const result = await params.managerFallback(runtime.manager);
267
+ if (!result.success) throw new Error(result.error || `${params.failureLabel} failed`);
268
+ writeCliJson(result);
269
+ }
270
+ function readGatewayCallId(payload, invalidCallIdMessage) {
271
+ if (isRecord(payload) && typeof payload.callId === "string") {
272
+ if (!invalidCallIdMessage || payload.callId) return payload.callId;
273
+ }
274
+ if (invalidCallIdMessage) throw new Error(invalidCallIdMessage);
275
+ if (isRecord(payload) && typeof payload.error === "string") throw new Error(payload.error);
276
+ throw new Error("voicecall gateway response missing callId");
277
+ }
278
+ async function initiateVoiceCall(params) {
279
+ const mode = params.mode === "notify" || params.mode === "conversation" ? params.mode : params.defaultMode;
280
+ const gateway = await callVoiceCallGateway(params.method, {
281
+ ...params.to ? { to: params.to } : {},
282
+ ...params.message ? { message: params.message } : {},
283
+ ...mode ? { mode } : {}
284
+ }, { timeoutMs: resolveOperationTimeout(params.config) });
285
+ if (gateway.ok) return readGatewayCallId(gateway.payload, params.failureMessage);
286
+ const runtime = await ensureStandaloneRuntime(params);
287
+ const to = params.to ?? runtime.config.toNumber;
288
+ if (!to) throw new Error("Missing --to and no toNumber configured");
289
+ const result = await runtime.manager.initiateCall(to, void 0, {
290
+ message: params.message,
291
+ mode
292
+ });
293
+ if (!result.success) throw new Error(result.error || params.failureMessage || "initiate failed");
294
+ if (params.failureMessage && !result.callId) throw new Error(params.failureMessage);
295
+ return result.callId;
296
+ }
297
+ //#endregion
298
+ //#region extensions/voice-call/src/cli.ts
107
299
  function resolveMode(input) {
108
300
  const raw = normalizeOptionalLowercaseString(input) ?? "";
109
301
  if (raw === "serve" || raw === "off") return raw;
@@ -113,35 +305,6 @@ function resolveDefaultStorePath(config) {
113
305
  const base = config.store?.trim() ? resolveUserPath(config.store) : resolveDefaultVoiceCallStoreDir();
114
306
  return path.join(base, "calls.jsonl");
115
307
  }
116
- function percentile(values, p) {
117
- if (values.length === 0) return 0;
118
- const sorted = [...values].toSorted((a, b) => a - b);
119
- return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(p / 100 * sorted.length) - 1))] ?? 0;
120
- }
121
- function summarizeSeries(values) {
122
- if (values.length === 0) return {
123
- count: 0,
124
- minMs: 0,
125
- maxMs: 0,
126
- avgMs: 0,
127
- p50Ms: 0,
128
- p95Ms: 0
129
- };
130
- const minMs = values.reduce((min, value) => value < min ? value : min, Number.POSITIVE_INFINITY);
131
- const maxMs = values.reduce((max, value) => value > max ? value : max, Number.NEGATIVE_INFINITY);
132
- const avgMs = values.reduce((sum, value) => sum + value, 0) / values.length;
133
- return {
134
- count: values.length,
135
- minMs,
136
- maxMs,
137
- avgMs,
138
- p50Ms: percentile(values, 50),
139
- p95Ms: percentile(values, 95)
140
- };
141
- }
142
- function resolveCallMode(mode) {
143
- return mode === "notify" || mode === "conversation" ? mode : void 0;
144
- }
145
308
  function buildSetupStatus(config) {
146
309
  const validation = validateProviderConfig(config);
147
310
  const webhookExposure = resolveWebhookExposureStatus(config);
@@ -178,45 +341,8 @@ function buildSetupStatus(config) {
178
341
  };
179
342
  }
180
343
  function writeSetupStatus(status) {
181
- writeStdoutLine("Voice Call setup: %s", status.ok ? "OK" : "needs attention");
182
- for (const check of status.checks) writeStdoutLine("%s %s: %s", check.ok ? "OK" : "FAIL", check.id, check.message);
183
- }
184
- async function initiateCallAndPrintId(params) {
185
- const result = await params.runtime.manager.initiateCall(params.to, void 0, {
186
- message: params.message,
187
- mode: resolveCallMode(params.mode)
188
- });
189
- if (!result.success) throw new Error(result.error || "initiate failed");
190
- writeStdoutJson({ callId: result.callId });
191
- }
192
- function writeGatewayCallId(payload) {
193
- if (isRecord(payload) && typeof payload.callId === "string") {
194
- writeStdoutJson({ callId: payload.callId });
195
- return;
196
- }
197
- if (isRecord(payload) && typeof payload.error === "string") throw new Error(payload.error);
198
- throw new Error("voicecall gateway response missing callId");
199
- }
200
- async function initiateCallViaGatewayOrRuntime(params) {
201
- const mode = resolveCallMode(params.mode);
202
- const gateway = await callVoiceCallGateway(params.method, {
203
- ...params.to ? { to: params.to } : {},
204
- ...params.message ? { message: params.message } : {},
205
- ...mode ? { mode } : {}
206
- }, { timeoutMs: resolveGatewayOperationTimeoutMs(params.config) });
207
- if (gateway.ok) {
208
- writeGatewayCallId(gateway.payload);
209
- return;
210
- }
211
- const rt = await params.ensureRuntime();
212
- const to = params.to ?? rt.config.toNumber;
213
- if (!to) throw new Error("Missing --to and no toNumber configured");
214
- await initiateCallAndPrintId({
215
- runtime: rt,
216
- to,
217
- message: params.message,
218
- mode: params.mode
219
- });
344
+ writeCliLine("Voice Call setup: %s", status.ok ? "OK" : "needs attention");
345
+ for (const check of status.checks) writeCliLine("%s %s: %s", check.ok ? "OK" : "FAIL", check.id, check.message);
220
346
  }
221
347
  function registerVoiceCallCli(params) {
222
348
  const { program, config, ensureRuntime, stateRuntime } = params;
@@ -227,7 +353,7 @@ function registerVoiceCallCli(params) {
227
353
  root.command("setup").description("Show Voice Call provider and webhook setup status").option("--json", "Print machine-readable JSON").action((options) => {
228
354
  const status = buildSetupStatus(config);
229
355
  if (options.json) {
230
- writeStdoutJson(status);
356
+ writeCliJson(status);
231
357
  return;
232
358
  }
233
359
  writeSetupStatus(status);
@@ -235,7 +361,7 @@ function registerVoiceCallCli(params) {
235
361
  root.command("smoke").description("Check Voice Call readiness and optionally place a short outbound test call").option("-t, --to <phone>", "Phone number to call for a live smoke").option("--message <text>", "Message to speak during the smoke call", "OpenClaw voice call smoke test.").option("--mode <mode>", "Call mode: notify or conversation", "notify").option("--yes", "Actually place the live outbound call").option("--json", "Print machine-readable JSON").action(async (options) => {
236
362
  const setup = buildSetupStatus(config);
237
363
  if (!setup.ok) {
238
- if (options.json) writeStdoutJson({
364
+ if (options.json) writeCliJson({
239
365
  ok: false,
240
366
  setup
241
367
  });
@@ -244,19 +370,19 @@ function registerVoiceCallCli(params) {
244
370
  return;
245
371
  }
246
372
  if (!options.to) {
247
- if (options.json) writeStdoutJson({
373
+ if (options.json) writeCliJson({
248
374
  ok: true,
249
375
  setup,
250
376
  liveCall: false
251
377
  });
252
378
  else {
253
379
  writeSetupStatus(setup);
254
- writeStdoutLine("live-call: skipped (pass --to and --yes to place one)");
380
+ writeCliLine("live-call: skipped (pass --to and --yes to place one)");
255
381
  }
256
382
  return;
257
383
  }
258
384
  if (!options.yes) {
259
- if (options.json) writeStdoutJson({
385
+ if (options.json) writeCliJson({
260
386
  ok: true,
261
387
  setup,
262
388
  liveCall: false,
@@ -264,29 +390,22 @@ function registerVoiceCallCli(params) {
264
390
  });
265
391
  else {
266
392
  writeSetupStatus(setup);
267
- writeStdoutLine("live-call: dry run for %s (add --yes to place it)", options.to);
393
+ writeCliLine("live-call: dry run for %s (add --yes to place it)", options.to);
268
394
  }
269
395
  return;
270
396
  }
271
- const mode = resolveCallMode(options.mode) ?? "notify";
272
- const gateway = await callVoiceCallGateway("voicecall.start", {
397
+ const callId = await initiateVoiceCall({
398
+ ensureRuntime,
399
+ config,
400
+ method: "voicecall.start",
273
401
  to: options.to,
274
- ...options.message ? { message: options.message } : {},
275
- mode
276
- }, { timeoutMs: resolveGatewayOperationTimeoutMs(config) });
277
- let callId;
278
- if (gateway.ok) callId = isRecord(gateway.payload) ? gateway.payload.callId : void 0;
279
- else {
280
- const result = await (await ensureRuntime()).manager.initiateCall(options.to, void 0, {
281
- message: options.message,
282
- mode
283
- });
284
- if (!result.success) throw new Error(result.error || "smoke call failed");
285
- callId = result.callId;
286
- }
287
- if (typeof callId !== "string" || !callId) throw new Error("smoke call failed");
402
+ message: options.message,
403
+ mode: options.mode,
404
+ defaultMode: "notify",
405
+ failureMessage: "smoke call failed"
406
+ });
288
407
  if (options.json) {
289
- writeStdoutJson({
408
+ writeCliJson({
290
409
  ok: true,
291
410
  setup,
292
411
  liveCall: true,
@@ -295,245 +414,162 @@ function registerVoiceCallCli(params) {
295
414
  return;
296
415
  }
297
416
  writeSetupStatus(setup);
298
- writeStdoutLine("live-call: started %s", callId);
417
+ writeCliLine("live-call: started %s", callId);
299
418
  });
300
419
  root.command("call").description("Initiate an outbound voice call").requiredOption("-m, --message <text>", "Message to speak when call connects").option("-t, --to <phone>", "Phone number to call (E.164 format, uses config toNumber if not set)").option("--mode <mode>", "Call mode: notify (hangup after message) or conversation (stay open)", "conversation").action(async (options) => {
301
- await initiateCallViaGatewayOrRuntime({
420
+ writeCliJson({ callId: await initiateVoiceCall({
302
421
  ensureRuntime,
303
422
  config,
304
423
  method: "voicecall.initiate",
305
424
  to: options.to,
306
425
  message: options.message,
307
426
  mode: options.mode
308
- });
427
+ }) });
309
428
  });
310
429
  root.command("start").description("Alias for voicecall call").requiredOption("--to <phone>", "Phone number to call").option("--message <text>", "Message to speak when call connects").option("--mode <mode>", "Call mode: notify (hangup after message) or conversation (stay open)", "conversation").action(async (options) => {
311
- await initiateCallViaGatewayOrRuntime({
430
+ writeCliJson({ callId: await initiateVoiceCall({
312
431
  ensureRuntime,
313
432
  config,
314
433
  method: "voicecall.start",
315
434
  to: options.to,
316
435
  message: options.message,
317
436
  mode: options.mode
318
- });
437
+ }) });
319
438
  });
320
439
  root.command("continue").description("Speak a message and wait for a response").requiredOption("--call-id <id>", "Call ID").requiredOption("--message <text>", "Message to speak").action(async (options) => {
321
- let gateway;
322
- try {
323
- gateway = await callVoiceCallGateway("voicecall.continue.start", {
324
- callId: options.callId,
325
- message: options.message
326
- }, { timeoutMs: resolveGatewayOperationTimeoutMs(config) });
327
- } catch (err) {
328
- if (!isUnknownGatewayMethod(err, "voicecall.continue.start")) throw err;
329
- gateway = await callVoiceCallGateway("voicecall.continue", {
330
- callId: options.callId,
331
- message: options.message
332
- }, { timeoutMs: resolveGatewayContinueTimeoutMs(config) });
333
- }
334
- if (gateway.ok) {
335
- if (isRecord(gateway.payload) && typeof gateway.payload.operationId === "string") {
336
- writeStdoutJson(await pollVoiceCallContinueGateway({
337
- operationId: readGatewayOperationId(gateway.payload),
338
- timeoutMs: readGatewayPollTimeoutMs(gateway.payload, resolveGatewayContinueTimeoutMs(config))
339
- }));
340
- return;
341
- }
342
- writeStdoutJson(gateway.payload);
343
- return;
344
- }
345
- const result = await (await ensureRuntime()).manager.continueCall(options.callId, options.message);
346
- if (!result.success) throw new Error(result.error || "continue failed");
347
- writeStdoutJson(result);
348
- });
349
- root.command("speak").description("Speak a message without waiting for response").requiredOption("--call-id <id>", "Call ID").requiredOption("--message <text>", "Message to speak").action(async (options) => {
350
- const gateway = await callVoiceCallGateway("voicecall.speak", {
440
+ const gatewayParams = {
351
441
  callId: options.callId,
352
442
  message: options.message
443
+ };
444
+ const continueTimeoutMs = resolveContinueTimeout(config);
445
+ await runGatewayManagerCommand({
446
+ config,
447
+ ensureRuntime,
448
+ gatewayCall: async () => {
449
+ try {
450
+ return await callVoiceCallGateway("voicecall.continue.start", gatewayParams, { timeoutMs: resolveOperationTimeout(config) });
451
+ } catch (err) {
452
+ if (!isUnknownMethod(err, "voicecall.continue.start")) throw err;
453
+ return callVoiceCallGateway("voicecall.continue", gatewayParams, { timeoutMs: continueTimeoutMs });
454
+ }
455
+ },
456
+ resolveGatewayPayload: (payload) => pollContinueGateway(payload, continueTimeoutMs),
457
+ managerFallback: (manager) => manager.continueCall(options.callId, options.message),
458
+ failureLabel: "continue"
459
+ });
460
+ });
461
+ root.command("speak").description("Speak a message without waiting for response").requiredOption("--call-id <id>", "Call ID").requiredOption("--message <text>", "Message to speak").action(async (options) => {
462
+ await runGatewayManagerCommand({
463
+ config,
464
+ ensureRuntime,
465
+ gatewayCall: () => callVoiceCallGateway("voicecall.speak", {
466
+ callId: options.callId,
467
+ message: options.message
468
+ }),
469
+ managerFallback: (manager) => manager.speak(options.callId, options.message),
470
+ failureLabel: "speak"
353
471
  });
354
- if (gateway.ok) {
355
- writeStdoutJson(gateway.payload);
356
- return;
357
- }
358
- const result = await (await ensureRuntime()).manager.speak(options.callId, options.message);
359
- if (!result.success) throw new Error(result.error || "speak failed");
360
- writeStdoutJson(result);
361
472
  });
362
473
  root.command("dtmf").description("Send DTMF digits to an active call").requiredOption("--call-id <id>", "Call ID").requiredOption("--digits <digits>", "DTMF digits").action(async (options) => {
363
- const gateway = await callVoiceCallGateway("voicecall.dtmf", {
364
- callId: options.callId,
365
- digits: options.digits
474
+ await runGatewayManagerCommand({
475
+ config,
476
+ ensureRuntime,
477
+ gatewayCall: () => callVoiceCallGateway("voicecall.dtmf", {
478
+ callId: options.callId,
479
+ digits: options.digits
480
+ }),
481
+ managerFallback: (manager) => manager.sendDtmf(options.callId, options.digits),
482
+ failureLabel: "dtmf"
366
483
  });
367
- if (gateway.ok) {
368
- writeStdoutJson(gateway.payload);
369
- return;
370
- }
371
- const result = await (await ensureRuntime()).manager.sendDtmf(options.callId, options.digits);
372
- if (!result.success) throw new Error(result.error || "dtmf failed");
373
- writeStdoutJson(result);
374
484
  });
375
485
  root.command("end").description("Hang up an active call").requiredOption("--call-id <id>", "Call ID").action(async (options) => {
376
- const gateway = await callVoiceCallGateway("voicecall.end", { callId: options.callId });
377
- if (gateway.ok) {
378
- writeStdoutJson(gateway.payload);
379
- return;
380
- }
381
- const result = await (await ensureRuntime()).manager.endCall(options.callId);
382
- if (!result.success) throw new Error(result.error || "end failed");
383
- writeStdoutJson(result);
486
+ await runGatewayManagerCommand({
487
+ config,
488
+ ensureRuntime,
489
+ gatewayCall: () => callVoiceCallGateway("voicecall.end", { callId: options.callId }),
490
+ managerFallback: (manager) => manager.endCall(options.callId),
491
+ failureLabel: "end"
492
+ });
384
493
  });
385
494
  root.command("status").description("Show call status").option("--call-id <id>", "Call ID").option("--json", "Print machine-readable JSON").action(async (options) => {
386
495
  const gateway = await callVoiceCallGateway("voicecall.status", options.callId ? { callId: options.callId } : void 0);
387
496
  if (gateway.ok) {
388
497
  if (options.callId && isRecord(gateway.payload)) {
389
498
  if (gateway.payload.found === true && "call" in gateway.payload) {
390
- writeStdoutJson(gateway.payload.call);
499
+ writeCliJson(gateway.payload.call);
391
500
  return;
392
501
  }
393
502
  if (gateway.payload.found === false) {
394
- writeStdoutJson({ found: false });
503
+ writeCliJson({ found: false });
395
504
  return;
396
505
  }
397
506
  }
398
- writeStdoutJson(gateway.payload);
507
+ writeCliJson(gateway.payload);
399
508
  return;
400
509
  }
401
- const rt = await ensureRuntime();
510
+ ensureHistoryStateRuntime();
511
+ const storePath = path.dirname(resolveDefaultStorePath(config));
402
512
  if (options.callId) {
403
- writeStdoutJson(await rt.manager.getCallFromMemoryOrStore(options.callId) ?? { found: false });
513
+ const persisted = await findCallMatchesInStore(storePath, options.callId);
514
+ writeCliJson(persisted.byCallId ?? persisted.byProviderCallId ?? { found: false });
404
515
  return;
405
516
  }
406
- writeStdoutJson({
517
+ writeCliJson({
407
518
  found: true,
408
- calls: rt.manager.getActiveCalls()
519
+ calls: Array.from(loadActiveCallsFromStore(storePath).activeCalls.values())
409
520
  });
410
521
  });
411
- root.command("tail").description("Tail voice-call JSONL logs (prints new lines; useful during provider tests)").option("--file <path>", "Path to calls.jsonl", resolveDefaultStorePath(config)).option("--since <n>", "Print last N lines first", "25").option("--poll <ms>", "Poll interval in ms", "250").action(async (options) => {
412
- const file = options.file;
413
- const since = parseVoiceCallIntOption(options.since, "--since", { min: 0 });
414
- const pollMs = parseVoiceCallIntOption(options.poll, "--poll", { min: 50 });
415
- const tailSqliteHistory = async (initialLimit) => {
416
- ensureHistoryStateRuntime();
417
- const seen = /* @__PURE__ */ new Set();
418
- const printCall = (call) => {
419
- const line = JSON.stringify(call);
420
- if (!seen.has(line)) {
421
- seen.add(line);
422
- writeStdoutLine(line);
423
- }
424
- };
425
- if (initialLimit > 0) for (const call of await getCallHistoryFromStore(path.dirname(file), initialLimit)) printCall(call);
426
- for (;;) {
427
- try {
428
- for (const call of await getCallHistoryFromStore(path.dirname(file), 1e3)) printCall(call);
429
- } catch {}
430
- await sleep(pollMs);
431
- }
432
- };
433
- if (fs.existsSync(file) && path.basename(file) !== "calls.jsonl") {
434
- const initial = fs.readFileSync(file);
435
- let decoder = new StringDecoder("utf8");
436
- const initialLines = decoder.write(initial).split("\n");
437
- let pendingLine = initialLines.pop() ?? "";
438
- const lines = initialLines.filter(Boolean);
439
- for (const line of lines.slice(Math.max(0, lines.length - since))) writeStdoutLine(line);
440
- let offset = initial.length;
441
- let lastObservedSize = initial.length;
442
- for (;;) {
443
- try {
444
- const stat = fs.statSync(file);
445
- if (stat.size < lastObservedSize) {
446
- offset = 0;
447
- decoder = new StringDecoder("utf8");
448
- pendingLine = "";
449
- }
450
- lastObservedSize = stat.size;
451
- if (stat.size > offset) {
452
- const fd = fs.openSync(file, "r");
453
- try {
454
- const buf = Buffer.alloc(stat.size - offset);
455
- const bytesRead = fs.readSync(fd, buf, 0, buf.length, offset);
456
- offset += bytesRead;
457
- const text = decoder.write(buf.subarray(0, bytesRead));
458
- const completeLines = `${pendingLine}${text}`.split("\n");
459
- pendingLine = completeLines.pop() ?? "";
460
- for (const line of completeLines.filter(Boolean)) writeStdoutLine(line);
461
- } finally {
462
- fs.closeSync(fd);
463
- }
464
- }
465
- } catch {}
466
- await sleep(pollMs);
467
- }
468
- } else await tailSqliteHistory(since);
469
- });
470
- root.command("latency").description("Summarize turn latency metrics from voice-call JSONL logs").option("--file <path>", "Path to calls.jsonl", resolveDefaultStorePath(config)).option("--last <n>", "Analyze last N records", "200").action(async (options) => {
471
- const file = options.file;
472
- const last = parseVoiceCallIntOption(options.last, "--last", { min: 1 });
473
- if (fs.existsSync(file) && path.basename(file) !== "calls.jsonl") writeVoiceCallLatencySummary(fs.readFileSync(file, "utf8").split("\n").filter(Boolean).slice(-last).map((line) => {
474
- try {
475
- const parsed = JSON.parse(line);
476
- return parsed.call ?? parsed;
477
- } catch {
478
- return null;
479
- }
480
- }).filter((call) => call !== null));
481
- else {
482
- ensureHistoryStateRuntime();
483
- writeVoiceCallLatencySummary(await getCallHistoryFromStore(path.dirname(file), last));
484
- }
522
+ registerVoiceCallLogs({
523
+ root,
524
+ defaultFile: resolveDefaultStorePath(config),
525
+ ensureHistoryStateRuntime
485
526
  });
486
- function writeVoiceCallLatencySummary(calls) {
487
- const turnLatencyMs = [];
488
- const listenWaitMs = [];
489
- for (const call of calls) {
490
- const latency = call.metadata?.lastTurnLatencyMs;
491
- const listenWait = call.metadata?.lastTurnListenWaitMs;
492
- if (typeof latency === "number" && Number.isFinite(latency)) turnLatencyMs.push(latency);
493
- if (typeof listenWait === "number" && Number.isFinite(listenWait)) listenWaitMs.push(listenWait);
494
- }
495
- writeStdoutJson({
496
- recordsScanned: calls.length,
497
- turnLatency: summarizeSeries(turnLatencyMs),
498
- listenWait: summarizeSeries(listenWaitMs)
499
- });
500
- }
501
527
  root.command("expose").description("Enable/disable Tailscale serve/funnel for the webhook").option("--mode <mode>", "off | serve (tailnet) | funnel (public)", "funnel").option("--path <path>", "Tailscale path to expose (recommend matching serve.path)").option("--port <port>", "Local webhook port").option("--serve-path <path>", "Local webhook path").action(async (options) => {
502
528
  const mode = resolveMode(options.mode ?? "funnel");
503
- const servePort = parseVoiceCallIntOption(options.port ?? String(config.serve.port ?? 3334), "--port", {
529
+ const servePort = parseCliInteger(options.port ?? String(config.serve.port ?? 3334), "--port", {
504
530
  min: 1,
505
531
  max: MAX_TCP_PORT
506
532
  });
507
533
  const servePath = options.servePath ?? config.serve.path ?? "/voice/webhook";
508
534
  const tsPath = options.path ?? config.tailscale?.path ?? servePath;
509
- const localUrl = `http://127.0.0.1:${servePort}`;
535
+ const streamExposurePaths = resolveVoiceCallStreamExposurePaths(config, {
536
+ publicWebhookPath: tsPath,
537
+ localWebhookPath: servePath
538
+ });
539
+ const streamPaths = streamExposurePaths.map(({ publicPath }) => publicPath);
540
+ const localUrl = `http://127.0.0.1:${servePort}${servePath}`;
510
541
  if (mode === "off") {
511
- await cleanupTailscaleExposureRoute({
512
- mode: "serve",
513
- path: tsPath
514
- });
515
- await cleanupTailscaleExposureRoute({
516
- mode: "funnel",
517
- path: tsPath
542
+ for (const exposurePath of [tsPath, ...streamPaths]) for (const tailscaleMode of ["serve", "funnel"]) await cleanupTailscaleExposureRoute({
543
+ mode: tailscaleMode,
544
+ port: config.tailscale.port,
545
+ path: exposurePath
518
546
  });
519
- writeStdoutJson({
547
+ writeCliJson({
520
548
  ok: true,
521
549
  mode: "off",
522
- path: tsPath
550
+ path: tsPath,
551
+ streamPaths
523
552
  });
524
553
  return;
525
554
  }
526
- const publicUrl = await setupTailscaleExposureRoute({
555
+ const publicUrl = await setupTailscaleExposureRoutes({
527
556
  mode,
528
- path: tsPath,
529
- localUrl
557
+ port: config.tailscale.port,
558
+ routes: [{
559
+ path: tsPath,
560
+ localUrl
561
+ }, ...streamExposurePaths.map(({ publicPath, localPath }) => ({
562
+ path: publicPath,
563
+ localUrl: `http://127.0.0.1:${servePort}${localPath}`
564
+ }))]
530
565
  });
531
566
  const tsInfo = publicUrl ? null : await getTailscaleSelfInfo();
532
567
  const enableUrl = tsInfo?.nodeId ? `https://login.tailscale.com/f/${mode}?node=${tsInfo.nodeId}` : null;
533
- writeStdoutJson({
568
+ writeCliJson({
534
569
  ok: Boolean(publicUrl),
535
570
  mode,
536
571
  path: tsPath,
572
+ streamPaths,
537
573
  localUrl,
538
574
  publicUrl,
539
575
  hint: publicUrl ? void 0 : {
@@ -826,20 +862,32 @@ function isCliOnlyProcess() {
826
862
  const VOICE_CALL_RUNTIME_COORDINATOR_KEY = Symbol.for("openclaw.voice-call.runtimeCoordinator");
827
863
  var VoiceCallRuntimeLifecycleError = class extends Error {};
828
864
  function getVoiceCallRuntimeCoordinator() {
829
- return resolveGlobalSingleton(VOICE_CALL_RUNTIME_COORDINATOR_KEY, () => ({
830
- epochCounter: 0,
831
- registeredEpoch: 0
832
- }));
865
+ return resolveGlobalSingleton(VOICE_CALL_RUNTIME_COORDINATOR_KEY, () => ({ epochCounter: 0 }));
833
866
  }
834
- function activateVoiceCallRuntimeGeneration(coordinator, generation) {
835
- if (generation.epoch < coordinator.registeredEpoch) throw new VoiceCallRuntimeLifecycleError("Voice call runtime generation was superseded; use the current plugin registration");
867
+ function activateVoiceCallRuntimeGeneration(coordinator, registration, generation) {
868
+ if (registration.epoch < (coordinator.current?.epoch ?? 0) || registration.generation !== generation) throw new VoiceCallRuntimeLifecycleError("Voice call runtime generation was superseded; use the current plugin registration");
836
869
  if (generation.retired) throw new VoiceCallRuntimeLifecycleError("Voice call runtime generation is retired; use the current plugin registration");
837
- if (coordinator.current !== generation) {
838
- if (coordinator.current) coordinator.current.retired = true;
839
- coordinator.current = generation;
840
- coordinator.registeredEpoch = generation.epoch;
870
+ if (coordinator.current !== registration) {
871
+ if (coordinator.current) coordinator.current.generation.retired = true;
872
+ coordinator.current = registration;
841
873
  }
842
874
  }
875
+ function stopVoiceCallRuntimeGeneration(coordinator, generation) {
876
+ const ownedSlot = coordinator.slot?.owner === generation ? coordinator.slot : void 0;
877
+ if (!ownedSlot || ownedSlot.state === "stopping") return ownedSlot?.promise ?? Promise.resolve();
878
+ const stopPromise = Promise.resolve().then(async () => {
879
+ await (ownedSlot.state === "running" ? ownedSlot.runtime : await ownedSlot.promise).stop();
880
+ });
881
+ const stoppingSlot = {
882
+ state: "stopping",
883
+ owner: generation,
884
+ promise: stopPromise
885
+ };
886
+ if (coordinator.slot === ownedSlot) coordinator.slot = stoppingSlot;
887
+ return stopPromise.finally(() => {
888
+ if (coordinator.slot === stoppingSlot) coordinator.slot = void 0;
889
+ });
890
+ }
843
891
  var voice_call_default = definePluginEntry({
844
892
  id: "voice-call",
845
893
  name: "Voice Call",
@@ -849,25 +897,26 @@ var voice_call_default = definePluginEntry({
849
897
  const config = resolveVoiceCallConfig(voiceCallConfigSchema.parse(api.pluginConfig));
850
898
  const validation = validateProviderConfig(config);
851
899
  const runtimeCoordinator = getVoiceCallRuntimeCoordinator();
852
- const runtimeGeneration = api.registrationMode !== "full" && runtimeCoordinator.current ? runtimeCoordinator.current : {
900
+ const runtimeRegistration = api.registrationMode !== "full" && runtimeCoordinator.current ? runtimeCoordinator.current : {
853
901
  epoch: ++runtimeCoordinator.epochCounter,
854
- retired: false
902
+ generation: { retired: false }
855
903
  };
856
904
  const continueOperationStore = createVoiceCallContinueOperationStore({
857
905
  config,
858
906
  coreConfig: api.config
859
907
  });
860
- const ensureRuntime = async () => {
861
- activateVoiceCallRuntimeGeneration(runtimeCoordinator, runtimeGeneration);
908
+ const activateRuntimeGeneration = (generation) => activateVoiceCallRuntimeGeneration(runtimeCoordinator, runtimeRegistration, generation);
909
+ const ensureRuntimeForGeneration = async (runtimeGeneration) => {
910
+ activateRuntimeGeneration(runtimeGeneration);
862
911
  if (!config.enabled) throw new Error("Voice call disabled in plugin config");
863
912
  if (!validation.valid) throw new Error(validation.errors.join("; "));
864
913
  while (true) {
865
- activateVoiceCallRuntimeGeneration(runtimeCoordinator, runtimeGeneration);
914
+ activateRuntimeGeneration(runtimeGeneration);
866
915
  const slot = runtimeCoordinator.slot;
867
916
  if (slot) {
868
917
  if (slot.owner !== runtimeGeneration) {
869
- if (slot.state === "stopping") {
870
- await slot.promise;
918
+ if (slot.owner.retired) {
919
+ await stopVoiceCallRuntimeGeneration(runtimeCoordinator, slot.owner);
871
920
  continue;
872
921
  }
873
922
  throw new VoiceCallRuntimeLifecycleError("A previous voice call runtime generation is still active; retry after it stops");
@@ -884,7 +933,7 @@ var voice_call_default = definePluginEntry({
884
933
  if (runtimeCoordinator.slot === slot) runtimeCoordinator.slot = void 0;
885
934
  throw err;
886
935
  }
887
- activateVoiceCallRuntimeGeneration(runtimeCoordinator, runtimeGeneration);
936
+ activateRuntimeGeneration(runtimeGeneration);
888
937
  if (runtimeCoordinator.slot !== slot) continue;
889
938
  runtimeCoordinator.slot = {
890
939
  state: "running",
@@ -893,20 +942,30 @@ var voice_call_default = definePluginEntry({
893
942
  };
894
943
  return createdRuntime;
895
944
  }
896
- const runtimePromise = createVoiceCallRuntime({
897
- config,
898
- coreConfig: api.config,
899
- fullConfig: api.config,
900
- agentRuntime: api.runtime.agent,
901
- stateRuntime: api.runtime.state,
902
- ttsRuntime: api.runtime.tts,
903
- logger: api.logger
904
- });
905
- runtimeCoordinator.slot = {
945
+ const startingSlot = {
906
946
  state: "starting",
907
947
  owner: runtimeGeneration,
908
- promise: runtimePromise
948
+ promise: createVoiceCallRuntime({
949
+ config,
950
+ coreConfig: api.config,
951
+ fullConfig: api.config,
952
+ agentRuntime: api.runtime.agent,
953
+ stateRuntime: api.runtime.state,
954
+ ttsRuntime: api.runtime.tts,
955
+ logger: api.logger
956
+ })
909
957
  };
958
+ runtimeCoordinator.slot = startingSlot;
959
+ }
960
+ };
961
+ const ensureRuntime = async (runtimeGeneration = runtimeRegistration.generation) => {
962
+ try {
963
+ const runtime = await ensureRuntimeForGeneration(runtimeGeneration);
964
+ runtimeGeneration.serviceHealth?.clearFailure();
965
+ return runtime;
966
+ } catch (err) {
967
+ if (!(err instanceof VoiceCallRuntimeLifecycleError)) runtimeGeneration.serviceHealth?.reportFailure(err);
968
+ throw err;
910
969
  }
911
970
  };
912
971
  const commands = createVoiceCallCommandService(ensureRuntime);
@@ -1034,48 +1093,41 @@ var voice_call_default = definePluginEntry({
1034
1093
  });
1035
1094
  api.registerService({
1036
1095
  id: "voicecall",
1037
- start: () => {
1096
+ start: (ctx) => {
1038
1097
  if (isCliOnlyProcess()) return;
1039
1098
  try {
1040
- activateVoiceCallRuntimeGeneration(runtimeCoordinator, runtimeGeneration);
1099
+ if (runtimeRegistration.generation.retired) {
1100
+ if (runtimeCoordinator.current !== runtimeRegistration) throw new VoiceCallRuntimeLifecycleError("Voice call runtime generation was superseded; use the current plugin registration");
1101
+ runtimeRegistration.generation = { retired: false };
1102
+ }
1103
+ runtimeRegistration.generation.serviceHealth = ctx.serviceHealth;
1104
+ activateRuntimeGeneration(runtimeRegistration.generation);
1041
1105
  } catch (err) {
1042
- if (err instanceof VoiceCallRuntimeLifecycleError) return;
1043
- throw err;
1106
+ ctx.serviceHealth?.reportFailure(err);
1107
+ api.logger.error(`[voice-call] Failed to start runtime: ${formatErrorMessage(err)}`);
1108
+ return;
1044
1109
  }
1045
1110
  if (!config.enabled) return;
1046
1111
  if (!validation.valid) {
1047
- api.logger.warn(`[voice-call] Runtime not started; setup incomplete: ${validation.errors.join("; ")}`);
1112
+ const error = /* @__PURE__ */ new Error(`setup incomplete: ${validation.errors.join("; ")}`);
1113
+ ctx.serviceHealth?.reportFailure(error);
1114
+ api.logger.error(`[voice-call] Runtime not started: ${error.message}`);
1048
1115
  return;
1049
1116
  }
1050
- ensureRuntime().catch((err) => {
1051
- const staleGeneration = runtimeGeneration.retired || runtimeGeneration.epoch < runtimeCoordinator.registeredEpoch;
1052
- if (err instanceof VoiceCallRuntimeLifecycleError && staleGeneration) return;
1117
+ const startingGeneration = runtimeRegistration.generation;
1118
+ ensureRuntime(startingGeneration).catch((err) => {
1119
+ if (err instanceof VoiceCallRuntimeLifecycleError) return;
1120
+ ctx.serviceHealth?.reportFailure(err);
1053
1121
  api.logger.error(`[voice-call] Failed to start runtime: ${formatErrorMessage(err)}`);
1054
1122
  });
1055
1123
  },
1056
1124
  stop: async () => {
1057
- if (runtimeGeneration.stopPromise) return await runtimeGeneration.stopPromise;
1125
+ const runtimeGeneration = runtimeRegistration.generation;
1058
1126
  runtimeGeneration.retired = true;
1059
- const ownedSlot = runtimeCoordinator.slot?.owner === runtimeGeneration ? runtimeCoordinator.slot : void 0;
1060
- let stoppingSlot;
1061
- const stopPromise = ownedSlot?.state === "stopping" ? ownedSlot.promise : Promise.resolve().then(async () => {
1062
- if (!ownedSlot) return;
1063
- await (ownedSlot.state === "running" ? ownedSlot.runtime : await ownedSlot.promise).stop();
1064
- });
1065
- runtimeGeneration.stopPromise = stopPromise;
1066
- if (ownedSlot && ownedSlot.state !== "stopping") {
1067
- stoppingSlot = {
1068
- state: "stopping",
1069
- owner: runtimeGeneration,
1070
- promise: stopPromise
1071
- };
1072
- if (runtimeCoordinator.slot === ownedSlot) runtimeCoordinator.slot = stoppingSlot;
1073
- } else if (ownedSlot) stoppingSlot = ownedSlot;
1074
1127
  try {
1075
- await stopPromise;
1128
+ await stopVoiceCallRuntimeGeneration(runtimeCoordinator, runtimeGeneration);
1076
1129
  } finally {
1077
- if (stoppingSlot && runtimeCoordinator.slot === stoppingSlot) runtimeCoordinator.slot = void 0;
1078
- if (runtimeCoordinator.current === runtimeGeneration) runtimeCoordinator.current = void 0;
1130
+ runtimeGeneration.serviceHealth = void 0;
1079
1131
  }
1080
1132
  }
1081
1133
  });