@kodelyth/voice-call 2026.5.39 → 2026.5.42

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 (137) hide show
  1. package/README.md +167 -0
  2. package/api.ts +16 -0
  3. package/cli-metadata.ts +10 -0
  4. package/config-api.ts +12 -0
  5. package/dist/api.js +2 -0
  6. package/dist/cli-metadata.js +12 -0
  7. package/dist/config-DAwbG2aw.js +621 -0
  8. package/dist/config-compat-BYfJ5ueI.js +129 -0
  9. package/dist/guarded-json-api-xAIbFPZh.js +591 -0
  10. package/dist/index.js +1341 -0
  11. package/dist/mock-jtSdKDQN.js +135 -0
  12. package/dist/plivo-L-JTeuEc.js +392 -0
  13. package/dist/realtime-handler-5pSItXxX.js +1227 -0
  14. package/dist/realtime-transcription.runtime-CAbQKwCN.js +2 -0
  15. package/dist/realtime-voice.runtime-vCpCAutg.js +2 -0
  16. package/dist/response-generator-B-MjbtsM.js +199 -0
  17. package/dist/runtime-api.js +6 -0
  18. package/dist/runtime-entry-ohPMJR46.js +3435 -0
  19. package/dist/runtime-entry.js +2 -0
  20. package/dist/setup-api.js +37 -0
  21. package/dist/telnyx-BWr9EZ4x.js +278 -0
  22. package/dist/twilio-D9B0zY1k.js +679 -0
  23. package/index.test.ts +1075 -0
  24. package/index.ts +863 -0
  25. package/klaw.plugin.json +30 -133
  26. package/package.json +3 -3
  27. package/runtime-api.ts +20 -0
  28. package/runtime-entry.ts +1 -0
  29. package/setup-api.ts +47 -0
  30. package/src/allowlist.test.ts +18 -0
  31. package/src/allowlist.ts +19 -0
  32. package/src/cli.test.ts +12 -0
  33. package/src/cli.ts +866 -0
  34. package/src/config-compat.test.ts +130 -0
  35. package/src/config-compat.ts +227 -0
  36. package/src/config.test.ts +542 -0
  37. package/src/config.ts +883 -0
  38. package/src/core-bridge.ts +14 -0
  39. package/src/deep-merge.test.ts +40 -0
  40. package/src/deep-merge.ts +23 -0
  41. package/src/gateway-continue-operation.ts +200 -0
  42. package/src/http-headers.test.ts +16 -0
  43. package/src/http-headers.ts +15 -0
  44. package/src/manager/context.ts +50 -0
  45. package/src/manager/events.test.ts +578 -0
  46. package/src/manager/events.ts +332 -0
  47. package/src/manager/lifecycle.ts +53 -0
  48. package/src/manager/lookup.test.ts +52 -0
  49. package/src/manager/lookup.ts +35 -0
  50. package/src/manager/outbound.test.ts +629 -0
  51. package/src/manager/outbound.ts +508 -0
  52. package/src/manager/state.ts +48 -0
  53. package/src/manager/store.ts +107 -0
  54. package/src/manager/timers.test.ts +127 -0
  55. package/src/manager/timers.ts +113 -0
  56. package/src/manager/twiml.test.ts +13 -0
  57. package/src/manager/twiml.ts +17 -0
  58. package/src/manager.closed-loop.test.ts +259 -0
  59. package/src/manager.inbound-allowlist.test.ts +183 -0
  60. package/src/manager.notify.test.ts +390 -0
  61. package/src/manager.restore.test.ts +310 -0
  62. package/src/manager.test-harness.ts +127 -0
  63. package/src/manager.ts +441 -0
  64. package/src/media-stream.test.ts +953 -0
  65. package/src/media-stream.ts +876 -0
  66. package/src/providers/base.ts +99 -0
  67. package/src/providers/mock.test.ts +86 -0
  68. package/src/providers/mock.ts +185 -0
  69. package/src/providers/plivo.test.ts +93 -0
  70. package/src/providers/plivo.ts +601 -0
  71. package/src/providers/shared/call-status.test.ts +24 -0
  72. package/src/providers/shared/call-status.ts +24 -0
  73. package/src/providers/shared/guarded-json-api.test.ts +127 -0
  74. package/src/providers/shared/guarded-json-api.ts +49 -0
  75. package/src/providers/telnyx.test.ts +489 -0
  76. package/src/providers/telnyx.ts +419 -0
  77. package/src/providers/twilio/api.test.ts +184 -0
  78. package/src/providers/twilio/api.ts +100 -0
  79. package/src/providers/twilio/twiml-policy.test.ts +84 -0
  80. package/src/providers/twilio/twiml-policy.ts +87 -0
  81. package/src/providers/twilio/webhook.ts +34 -0
  82. package/src/providers/twilio.test.ts +607 -0
  83. package/src/providers/twilio.ts +861 -0
  84. package/src/providers/twilio.types.ts +17 -0
  85. package/src/realtime-agent-context.test.ts +101 -0
  86. package/src/realtime-agent-context.ts +149 -0
  87. package/src/realtime-defaults.ts +3 -0
  88. package/src/realtime-fast-context.test.ts +74 -0
  89. package/src/realtime-fast-context.ts +27 -0
  90. package/src/realtime-transcription.runtime.ts +4 -0
  91. package/src/realtime-voice.runtime.ts +5 -0
  92. package/src/response-generator.test.ts +385 -0
  93. package/src/response-generator.ts +348 -0
  94. package/src/response-model.test.ts +71 -0
  95. package/src/response-model.ts +23 -0
  96. package/src/runtime.test.ts +625 -0
  97. package/src/runtime.ts +528 -0
  98. package/src/telephony-audio.test.ts +61 -0
  99. package/src/telephony-audio.ts +12 -0
  100. package/src/telephony-tts.test.ts +196 -0
  101. package/src/telephony-tts.ts +235 -0
  102. package/src/test-fixtures.ts +82 -0
  103. package/src/tts-provider-voice.test.ts +34 -0
  104. package/src/tts-provider-voice.ts +21 -0
  105. package/src/tunnel.test.ts +173 -0
  106. package/src/tunnel.ts +314 -0
  107. package/src/types.ts +311 -0
  108. package/src/utils.test.ts +17 -0
  109. package/src/utils.ts +14 -0
  110. package/src/voice-mapping.test.ts +32 -0
  111. package/src/voice-mapping.ts +65 -0
  112. package/src/webhook/realtime-audio-pacer.test.ts +146 -0
  113. package/src/webhook/realtime-audio-pacer.ts +204 -0
  114. package/src/webhook/realtime-handler.test.ts +1450 -0
  115. package/src/webhook/realtime-handler.ts +1382 -0
  116. package/src/webhook/stale-call-reaper.test.ts +89 -0
  117. package/src/webhook/stale-call-reaper.ts +38 -0
  118. package/src/webhook/stream-frame-adapter.test.ts +187 -0
  119. package/src/webhook/stream-frame-adapter.ts +219 -0
  120. package/src/webhook/tailscale.test.ts +216 -0
  121. package/src/webhook/tailscale.ts +129 -0
  122. package/src/webhook-exposure.test.ts +33 -0
  123. package/src/webhook-exposure.ts +84 -0
  124. package/src/webhook-security.test.ts +813 -0
  125. package/src/webhook-security.ts +982 -0
  126. package/src/webhook.hangup-once.lifecycle.test.ts +179 -0
  127. package/src/webhook.test.ts +1615 -0
  128. package/src/webhook.ts +933 -0
  129. package/src/webhook.types.ts +5 -0
  130. package/src/websocket-test-support.ts +72 -0
  131. package/tsconfig.json +16 -0
  132. package/api.js +0 -7
  133. package/cli-metadata.js +0 -7
  134. package/index.js +0 -7
  135. package/runtime-api.js +0 -7
  136. package/runtime-entry.js +0 -7
  137. package/setup-api.js +0 -7
package/src/cli.ts ADDED
@@ -0,0 +1,866 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { format } from "node:util";
5
+ import type { Command } from "commander";
6
+ import { formatErrorMessage } from "klaw/plugin-sdk/error-runtime";
7
+ import { callGatewayFromCli } from "klaw/plugin-sdk/gateway-runtime";
8
+ import { normalizeOptionalLowercaseString } from "klaw/plugin-sdk/string-coerce-runtime";
9
+ import { sleep } from "../api.js";
10
+ import { validateProviderConfig, type VoiceCallConfig } from "./config.js";
11
+ import type { VoiceCallRuntime } from "./runtime.js";
12
+ import { resolveUserPath } from "./utils.js";
13
+ import { resolveWebhookExposureStatus } from "./webhook-exposure.js";
14
+ import {
15
+ cleanupTailscaleExposureRoute,
16
+ getTailscaleSelfInfo,
17
+ setupTailscaleExposureRoute,
18
+ } from "./webhook/tailscale.js";
19
+
20
+ type Logger = {
21
+ info: (message: string) => void;
22
+ warn: (message: string) => void;
23
+ error: (message: string) => void;
24
+ };
25
+
26
+ type SetupCheck = {
27
+ id: string;
28
+ ok: boolean;
29
+ message: string;
30
+ };
31
+
32
+ type SetupStatus = {
33
+ ok: boolean;
34
+ checks: SetupCheck[];
35
+ };
36
+
37
+ type VoiceCallGatewayMethod =
38
+ | "voicecall.initiate"
39
+ | "voicecall.start"
40
+ | "voicecall.continue"
41
+ | "voicecall.continue.start"
42
+ | "voicecall.continue.result"
43
+ | "voicecall.speak"
44
+ | "voicecall.dtmf"
45
+ | "voicecall.end"
46
+ | "voicecall.status";
47
+
48
+ type VoiceCallGatewayCallResult = { ok: true; payload: unknown } | { ok: false; error: unknown };
49
+
50
+ const VOICE_CALL_GATEWAY_DEFAULT_TIMEOUT_MS = 5000;
51
+ const VOICE_CALL_GATEWAY_OPERATION_TIMEOUT_MS = 30000;
52
+ const VOICE_CALL_GATEWAY_TRANSCRIPT_BUFFER_MS = 10000;
53
+ const VOICE_CALL_GATEWAY_POLL_INTERVAL_MS = 1000;
54
+
55
+ const voiceCallCliDeps = {
56
+ callGatewayFromCli,
57
+ };
58
+
59
+ export const testing = {
60
+ setCallGatewayFromCliForTests(next?: typeof callGatewayFromCli): void {
61
+ voiceCallCliDeps.callGatewayFromCli = next ?? callGatewayFromCli;
62
+ },
63
+ isGatewayUnavailableForLocalFallback,
64
+ parseVoiceCallIntOption,
65
+ };
66
+
67
+ function writeStdoutLine(...values: unknown[]): void {
68
+ process.stdout.write(`${format(...values)}\n`);
69
+ }
70
+
71
+ function writeStdoutJson(value: unknown): void {
72
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
73
+ }
74
+
75
+ function parseVoiceCallIntOption(
76
+ raw: string | undefined,
77
+ optionName: string,
78
+ opts?: { min?: number },
79
+ ): number {
80
+ const min = opts?.min ?? 0;
81
+ const parsed = Number(raw);
82
+ if (!Number.isInteger(parsed) || parsed < min) {
83
+ throw new Error(`Invalid numeric value for ${optionName}: ${raw ?? ""}`);
84
+ }
85
+ return parsed;
86
+ }
87
+
88
+ function isRecord(value: unknown): value is Record<string, unknown> {
89
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
90
+ }
91
+
92
+ function isGatewayUnavailableForLocalFallback(err: unknown): boolean {
93
+ const message = formatErrorMessage(err);
94
+ return (
95
+ message.includes("ECONNREFUSED") ||
96
+ message.includes("ECONNRESET") ||
97
+ message.includes("EHOSTUNREACH") ||
98
+ message.includes("ENOTFOUND") ||
99
+ message.includes("gateway closed (1006") ||
100
+ message.includes("gateway not connected")
101
+ );
102
+ }
103
+
104
+ async function callVoiceCallGateway(
105
+ method: VoiceCallGatewayMethod,
106
+ params?: Record<string, unknown>,
107
+ opts?: { timeoutMs?: number },
108
+ ): Promise<VoiceCallGatewayCallResult> {
109
+ try {
110
+ const timeoutMs =
111
+ typeof opts?.timeoutMs === "number" && Number.isFinite(opts.timeoutMs)
112
+ ? Math.max(1, Math.ceil(opts.timeoutMs))
113
+ : VOICE_CALL_GATEWAY_DEFAULT_TIMEOUT_MS;
114
+ const payload = await voiceCallCliDeps.callGatewayFromCli(
115
+ method,
116
+ { json: true, timeout: String(timeoutMs) },
117
+ params,
118
+ { progress: false },
119
+ );
120
+ return { ok: true, payload };
121
+ } catch (err) {
122
+ if (isGatewayUnavailableForLocalFallback(err)) {
123
+ return { ok: false, error: err };
124
+ }
125
+ throw err;
126
+ }
127
+ }
128
+
129
+ function resolveGatewayOperationTimeoutMs(config: VoiceCallConfig): number {
130
+ return Math.max(VOICE_CALL_GATEWAY_OPERATION_TIMEOUT_MS, config.ringTimeoutMs + 5000);
131
+ }
132
+
133
+ function resolveGatewayContinueTimeoutMs(config: VoiceCallConfig): number {
134
+ return (
135
+ config.transcriptTimeoutMs +
136
+ VOICE_CALL_GATEWAY_OPERATION_TIMEOUT_MS +
137
+ VOICE_CALL_GATEWAY_TRANSCRIPT_BUFFER_MS
138
+ );
139
+ }
140
+
141
+ function isUnknownGatewayMethod(err: unknown, method: VoiceCallGatewayMethod): boolean {
142
+ return formatErrorMessage(err).includes(`unknown method: ${method}`);
143
+ }
144
+
145
+ function readGatewayOperationId(payload: unknown): string {
146
+ if (isRecord(payload) && typeof payload.operationId === "string" && payload.operationId) {
147
+ return payload.operationId;
148
+ }
149
+ throw new Error("voicecall gateway response missing operationId");
150
+ }
151
+
152
+ function readGatewayPollTimeoutMs(payload: unknown, fallbackTimeoutMs: number): number {
153
+ if (isRecord(payload) && typeof payload.pollTimeoutMs === "number") {
154
+ return Math.max(1, Math.ceil(payload.pollTimeoutMs));
155
+ }
156
+ return fallbackTimeoutMs;
157
+ }
158
+
159
+ function readCompletedContinueResult(
160
+ payload: unknown,
161
+ ):
162
+ | { status: "pending" }
163
+ | { status: "completed"; result: unknown }
164
+ | { status: "failed"; error: string } {
165
+ if (!isRecord(payload)) {
166
+ throw new Error("voicecall gateway response missing operation status");
167
+ }
168
+ if (payload.status === "pending") {
169
+ return { status: "pending" };
170
+ }
171
+ if (payload.status === "failed") {
172
+ return {
173
+ status: "failed",
174
+ error: typeof payload.error === "string" ? payload.error : "continue failed",
175
+ };
176
+ }
177
+ if (payload.status === "completed") {
178
+ return { status: "completed", result: payload.result };
179
+ }
180
+ throw new Error("voicecall gateway response has unknown operation status");
181
+ }
182
+
183
+ async function pollVoiceCallContinueGateway(params: {
184
+ operationId: string;
185
+ timeoutMs: number;
186
+ }): Promise<unknown> {
187
+ const deadlineMs = Date.now() + params.timeoutMs;
188
+
189
+ while (Date.now() <= deadlineMs) {
190
+ const gateway = await callVoiceCallGateway(
191
+ "voicecall.continue.result",
192
+ { operationId: params.operationId },
193
+ { timeoutMs: VOICE_CALL_GATEWAY_DEFAULT_TIMEOUT_MS },
194
+ );
195
+ if (!gateway.ok) {
196
+ throw new Error(
197
+ `gateway unavailable while waiting for voicecall continue result: ${formatErrorMessage(
198
+ gateway.error,
199
+ )}`,
200
+ );
201
+ }
202
+ const result = readCompletedContinueResult(gateway.payload);
203
+ if (result.status === "completed") {
204
+ return result.result;
205
+ }
206
+ if (result.status === "failed") {
207
+ throw new Error(result.error);
208
+ }
209
+ await sleep(
210
+ Math.min(VOICE_CALL_GATEWAY_POLL_INTERVAL_MS, Math.max(1, deadlineMs - Date.now())),
211
+ );
212
+ }
213
+
214
+ throw new Error("voicecall continue timed out waiting for gateway operation");
215
+ }
216
+
217
+ function resolveMode(input: string): "off" | "serve" | "funnel" {
218
+ const raw = normalizeOptionalLowercaseString(input) ?? "";
219
+ if (raw === "serve" || raw === "off") {
220
+ return raw;
221
+ }
222
+ return "funnel";
223
+ }
224
+
225
+ function resolveDefaultStorePath(config: VoiceCallConfig): string {
226
+ const preferred = path.join(os.homedir(), ".klaw", "voice-calls");
227
+ const resolvedPreferred = resolveUserPath(preferred);
228
+ const existing =
229
+ [resolvedPreferred].find((dir) => {
230
+ try {
231
+ return fs.existsSync(path.join(dir, "calls.jsonl")) || fs.existsSync(dir);
232
+ } catch {
233
+ return false;
234
+ }
235
+ }) ?? resolvedPreferred;
236
+ const base = config.store?.trim() ? resolveUserPath(config.store) : existing;
237
+ return path.join(base, "calls.jsonl");
238
+ }
239
+
240
+ function percentile(values: number[], p: number): number {
241
+ if (values.length === 0) {
242
+ return 0;
243
+ }
244
+ const sorted = [...values].toSorted((a, b) => a - b);
245
+ const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1));
246
+ return sorted[idx] ?? 0;
247
+ }
248
+
249
+ function summarizeSeries(values: number[]): {
250
+ count: number;
251
+ minMs: number;
252
+ maxMs: number;
253
+ avgMs: number;
254
+ p50Ms: number;
255
+ p95Ms: number;
256
+ } {
257
+ if (values.length === 0) {
258
+ return { count: 0, minMs: 0, maxMs: 0, avgMs: 0, p50Ms: 0, p95Ms: 0 };
259
+ }
260
+
261
+ const minMs = values.reduce(
262
+ (min, value) => (value < min ? value : min),
263
+ Number.POSITIVE_INFINITY,
264
+ );
265
+ const maxMs = values.reduce(
266
+ (max, value) => (value > max ? value : max),
267
+ Number.NEGATIVE_INFINITY,
268
+ );
269
+ const avgMs = values.reduce((sum, value) => sum + value, 0) / values.length;
270
+ return {
271
+ count: values.length,
272
+ minMs,
273
+ maxMs,
274
+ avgMs,
275
+ p50Ms: percentile(values, 50),
276
+ p95Ms: percentile(values, 95),
277
+ };
278
+ }
279
+
280
+ function resolveCallMode(mode?: string): "notify" | "conversation" | undefined {
281
+ return mode === "notify" || mode === "conversation" ? mode : undefined;
282
+ }
283
+
284
+ function buildSetupStatus(config: VoiceCallConfig): SetupStatus {
285
+ const validation = validateProviderConfig(config);
286
+ const webhookExposure = resolveWebhookExposureStatus(config);
287
+ const checks: SetupCheck[] = [
288
+ {
289
+ id: "plugin-enabled",
290
+ ok: config.enabled,
291
+ message: config.enabled
292
+ ? "Voice Call plugin is enabled"
293
+ : "Enable plugins.entries.voice-call.enabled",
294
+ },
295
+ {
296
+ id: "provider",
297
+ ok: Boolean(config.provider),
298
+ message: config.provider
299
+ ? `Provider configured: ${config.provider}`
300
+ : "Set plugins.entries.voice-call.config.provider",
301
+ },
302
+ {
303
+ id: "provider-config",
304
+ ok: validation.valid,
305
+ message: validation.valid
306
+ ? "Provider credentials/config look complete"
307
+ : validation.errors.join("; "),
308
+ },
309
+ {
310
+ id: "webhook-exposure",
311
+ ok: webhookExposure.ok,
312
+ message: webhookExposure.message,
313
+ },
314
+ {
315
+ id: "mode",
316
+ ok: !(config.streaming.enabled && config.realtime.enabled),
317
+ message:
318
+ config.streaming.enabled && config.realtime.enabled
319
+ ? "streaming.enabled and realtime.enabled cannot both be true"
320
+ : config.realtime.enabled
321
+ ? `Realtime voice enabled (${config.realtime.provider ?? "first registered provider"})`
322
+ : config.streaming.enabled
323
+ ? `Streaming transcription enabled (${config.streaming.provider ?? "first registered provider"})`
324
+ : "Notify/conversation calls use normal TTS/STT flow",
325
+ },
326
+ ];
327
+ return {
328
+ ok: checks.every((check) => check.ok),
329
+ checks,
330
+ };
331
+ }
332
+
333
+ function writeSetupStatus(status: SetupStatus): void {
334
+ writeStdoutLine("Voice Call setup: %s", status.ok ? "OK" : "needs attention");
335
+ for (const check of status.checks) {
336
+ writeStdoutLine("%s %s: %s", check.ok ? "OK" : "FAIL", check.id, check.message);
337
+ }
338
+ }
339
+
340
+ async function initiateCallAndPrintId(params: {
341
+ runtime: VoiceCallRuntime;
342
+ to: string;
343
+ message?: string;
344
+ mode?: string;
345
+ }) {
346
+ const result = await params.runtime.manager.initiateCall(params.to, undefined, {
347
+ message: params.message,
348
+ mode: resolveCallMode(params.mode),
349
+ });
350
+ if (!result.success) {
351
+ throw new Error(result.error || "initiate failed");
352
+ }
353
+ writeStdoutJson({ callId: result.callId });
354
+ }
355
+
356
+ function writeGatewayCallId(payload: unknown): void {
357
+ if (isRecord(payload) && typeof payload.callId === "string") {
358
+ writeStdoutJson({ callId: payload.callId });
359
+ return;
360
+ }
361
+ if (isRecord(payload) && typeof payload.error === "string") {
362
+ throw new Error(payload.error);
363
+ }
364
+ throw new Error("voicecall gateway response missing callId");
365
+ }
366
+
367
+ async function initiateCallViaGatewayOrRuntime(params: {
368
+ ensureRuntime: () => Promise<VoiceCallRuntime>;
369
+ config: VoiceCallConfig;
370
+ method: "voicecall.initiate" | "voicecall.start";
371
+ to?: string;
372
+ message?: string;
373
+ mode?: string;
374
+ }) {
375
+ const mode = resolveCallMode(params.mode);
376
+ const gateway = await callVoiceCallGateway(
377
+ params.method,
378
+ {
379
+ ...(params.to ? { to: params.to } : {}),
380
+ ...(params.message ? { message: params.message } : {}),
381
+ ...(mode ? { mode } : {}),
382
+ },
383
+ {
384
+ timeoutMs: resolveGatewayOperationTimeoutMs(params.config),
385
+ },
386
+ );
387
+ if (gateway.ok) {
388
+ writeGatewayCallId(gateway.payload);
389
+ return;
390
+ }
391
+
392
+ const rt = await params.ensureRuntime();
393
+ const to = params.to ?? rt.config.toNumber;
394
+ if (!to) {
395
+ throw new Error("Missing --to and no toNumber configured");
396
+ }
397
+ await initiateCallAndPrintId({
398
+ runtime: rt,
399
+ to,
400
+ message: params.message,
401
+ mode: params.mode,
402
+ });
403
+ }
404
+
405
+ export function registerVoiceCallCli(params: {
406
+ program: Command;
407
+ config: VoiceCallConfig;
408
+ ensureRuntime: () => Promise<VoiceCallRuntime>;
409
+ logger: Logger;
410
+ }) {
411
+ const { program, config, ensureRuntime, logger } = params;
412
+ const root = program
413
+ .command("voicecall")
414
+ .description("Voice call utilities")
415
+ .addHelpText("after", () => `\nDocs: https://klaw.kodelyth.com/cli/voicecall\n`);
416
+
417
+ root
418
+ .command("setup")
419
+ .description("Show Voice Call provider and webhook setup status")
420
+ .option("--json", "Print machine-readable JSON")
421
+ .action((options: { json?: boolean }) => {
422
+ const status = buildSetupStatus(config);
423
+ if (options.json) {
424
+ writeStdoutJson(status);
425
+ return;
426
+ }
427
+ writeSetupStatus(status);
428
+ });
429
+
430
+ root
431
+ .command("smoke")
432
+ .description("Check Voice Call readiness and optionally place a short outbound test call")
433
+ .option("-t, --to <phone>", "Phone number to call for a live smoke")
434
+ .option(
435
+ "--message <text>",
436
+ "Message to speak during the smoke call",
437
+ "Klaw voice call smoke test.",
438
+ )
439
+ .option("--mode <mode>", "Call mode: notify or conversation", "notify")
440
+ .option("--yes", "Actually place the live outbound call")
441
+ .option("--json", "Print machine-readable JSON")
442
+ .action(
443
+ async (options: {
444
+ to?: string;
445
+ message?: string;
446
+ mode?: string;
447
+ yes?: boolean;
448
+ json?: boolean;
449
+ }) => {
450
+ const setup = buildSetupStatus(config);
451
+ if (!setup.ok) {
452
+ if (options.json) {
453
+ writeStdoutJson({ ok: false, setup });
454
+ } else {
455
+ writeSetupStatus(setup);
456
+ }
457
+ process.exitCode = 1;
458
+ return;
459
+ }
460
+ if (!options.to) {
461
+ if (options.json) {
462
+ writeStdoutJson({ ok: true, setup, liveCall: false });
463
+ } else {
464
+ writeSetupStatus(setup);
465
+ writeStdoutLine("live-call: skipped (pass --to and --yes to place one)");
466
+ }
467
+ return;
468
+ }
469
+ if (!options.yes) {
470
+ if (options.json) {
471
+ writeStdoutJson({ ok: true, setup, liveCall: false, wouldCall: options.to });
472
+ } else {
473
+ writeSetupStatus(setup);
474
+ writeStdoutLine("live-call: dry run for %s (add --yes to place it)", options.to);
475
+ }
476
+ return;
477
+ }
478
+ const mode = resolveCallMode(options.mode) ?? "notify";
479
+ const gateway = await callVoiceCallGateway(
480
+ "voicecall.start",
481
+ {
482
+ to: options.to,
483
+ ...(options.message ? { message: options.message } : {}),
484
+ mode,
485
+ },
486
+ {
487
+ timeoutMs: resolveGatewayOperationTimeoutMs(config),
488
+ },
489
+ );
490
+ let callId: unknown;
491
+ if (gateway.ok) {
492
+ callId = isRecord(gateway.payload) ? gateway.payload.callId : undefined;
493
+ } else {
494
+ const rt = await ensureRuntime();
495
+ const result = await rt.manager.initiateCall(options.to, undefined, {
496
+ message: options.message,
497
+ mode,
498
+ });
499
+ if (!result.success) {
500
+ throw new Error(result.error || "smoke call failed");
501
+ }
502
+ callId = result.callId;
503
+ }
504
+ if (typeof callId !== "string" || !callId) {
505
+ throw new Error("smoke call failed");
506
+ }
507
+ if (options.json) {
508
+ writeStdoutJson({ ok: true, setup, liveCall: true, callId });
509
+ return;
510
+ }
511
+ writeSetupStatus(setup);
512
+ writeStdoutLine("live-call: started %s", callId);
513
+ },
514
+ );
515
+
516
+ root
517
+ .command("call")
518
+ .description("Initiate an outbound voice call")
519
+ .requiredOption("-m, --message <text>", "Message to speak when call connects")
520
+ .option(
521
+ "-t, --to <phone>",
522
+ "Phone number to call (E.164 format, uses config toNumber if not set)",
523
+ )
524
+ .option(
525
+ "--mode <mode>",
526
+ "Call mode: notify (hangup after message) or conversation (stay open)",
527
+ "conversation",
528
+ )
529
+ .action(async (options: { message: string; to?: string; mode?: string }) => {
530
+ await initiateCallViaGatewayOrRuntime({
531
+ ensureRuntime,
532
+ config,
533
+ method: "voicecall.initiate",
534
+ to: options.to,
535
+ message: options.message,
536
+ mode: options.mode,
537
+ });
538
+ });
539
+
540
+ root
541
+ .command("start")
542
+ .description("Alias for voicecall call")
543
+ .requiredOption("--to <phone>", "Phone number to call")
544
+ .option("--message <text>", "Message to speak when call connects")
545
+ .option(
546
+ "--mode <mode>",
547
+ "Call mode: notify (hangup after message) or conversation (stay open)",
548
+ "conversation",
549
+ )
550
+ .action(async (options: { to: string; message?: string; mode?: string }) => {
551
+ await initiateCallViaGatewayOrRuntime({
552
+ ensureRuntime,
553
+ config,
554
+ method: "voicecall.start",
555
+ to: options.to,
556
+ message: options.message,
557
+ mode: options.mode,
558
+ });
559
+ });
560
+
561
+ root
562
+ .command("continue")
563
+ .description("Speak a message and wait for a response")
564
+ .requiredOption("--call-id <id>", "Call ID")
565
+ .requiredOption("--message <text>", "Message to speak")
566
+ .action(async (options: { callId: string; message: string }) => {
567
+ let gateway: VoiceCallGatewayCallResult;
568
+ try {
569
+ gateway = await callVoiceCallGateway(
570
+ "voicecall.continue.start",
571
+ {
572
+ callId: options.callId,
573
+ message: options.message,
574
+ },
575
+ {
576
+ timeoutMs: resolveGatewayOperationTimeoutMs(config),
577
+ },
578
+ );
579
+ } catch (err) {
580
+ if (!isUnknownGatewayMethod(err, "voicecall.continue.start")) {
581
+ throw err;
582
+ }
583
+ gateway = await callVoiceCallGateway(
584
+ "voicecall.continue",
585
+ {
586
+ callId: options.callId,
587
+ message: options.message,
588
+ },
589
+ {
590
+ timeoutMs: resolveGatewayContinueTimeoutMs(config),
591
+ },
592
+ );
593
+ }
594
+ if (gateway.ok) {
595
+ if (isRecord(gateway.payload) && typeof gateway.payload.operationId === "string") {
596
+ const result = await pollVoiceCallContinueGateway({
597
+ operationId: readGatewayOperationId(gateway.payload),
598
+ timeoutMs: readGatewayPollTimeoutMs(
599
+ gateway.payload,
600
+ resolveGatewayContinueTimeoutMs(config),
601
+ ),
602
+ });
603
+ writeStdoutJson(result);
604
+ return;
605
+ }
606
+ writeStdoutJson(gateway.payload);
607
+ return;
608
+ }
609
+ const rt = await ensureRuntime();
610
+ const result = await rt.manager.continueCall(options.callId, options.message);
611
+ if (!result.success) {
612
+ throw new Error(result.error || "continue failed");
613
+ }
614
+ writeStdoutJson(result);
615
+ });
616
+
617
+ root
618
+ .command("speak")
619
+ .description("Speak a message without waiting for response")
620
+ .requiredOption("--call-id <id>", "Call ID")
621
+ .requiredOption("--message <text>", "Message to speak")
622
+ .action(async (options: { callId: string; message: string }) => {
623
+ const gateway = await callVoiceCallGateway("voicecall.speak", {
624
+ callId: options.callId,
625
+ message: options.message,
626
+ });
627
+ if (gateway.ok) {
628
+ writeStdoutJson(gateway.payload);
629
+ return;
630
+ }
631
+ const rt = await ensureRuntime();
632
+ const result = await rt.manager.speak(options.callId, options.message);
633
+ if (!result.success) {
634
+ throw new Error(result.error || "speak failed");
635
+ }
636
+ writeStdoutJson(result);
637
+ });
638
+
639
+ root
640
+ .command("dtmf")
641
+ .description("Send DTMF digits to an active call")
642
+ .requiredOption("--call-id <id>", "Call ID")
643
+ .requiredOption("--digits <digits>", "DTMF digits")
644
+ .action(async (options: { callId: string; digits: string }) => {
645
+ const gateway = await callVoiceCallGateway("voicecall.dtmf", {
646
+ callId: options.callId,
647
+ digits: options.digits,
648
+ });
649
+ if (gateway.ok) {
650
+ writeStdoutJson(gateway.payload);
651
+ return;
652
+ }
653
+ const rt = await ensureRuntime();
654
+ const result = await rt.manager.sendDtmf(options.callId, options.digits);
655
+ if (!result.success) {
656
+ throw new Error(result.error || "dtmf failed");
657
+ }
658
+ writeStdoutJson(result);
659
+ });
660
+
661
+ root
662
+ .command("end")
663
+ .description("Hang up an active call")
664
+ .requiredOption("--call-id <id>", "Call ID")
665
+ .action(async (options: { callId: string }) => {
666
+ const gateway = await callVoiceCallGateway("voicecall.end", {
667
+ callId: options.callId,
668
+ });
669
+ if (gateway.ok) {
670
+ writeStdoutJson(gateway.payload);
671
+ return;
672
+ }
673
+ const rt = await ensureRuntime();
674
+ const result = await rt.manager.endCall(options.callId);
675
+ if (!result.success) {
676
+ throw new Error(result.error || "end failed");
677
+ }
678
+ writeStdoutJson(result);
679
+ });
680
+
681
+ root
682
+ .command("status")
683
+ .description("Show call status")
684
+ .option("--call-id <id>", "Call ID")
685
+ .option("--json", "Print machine-readable JSON")
686
+ .action(async (options: { callId?: string; json?: boolean }) => {
687
+ const gateway = await callVoiceCallGateway(
688
+ "voicecall.status",
689
+ options.callId ? { callId: options.callId } : undefined,
690
+ );
691
+ if (gateway.ok) {
692
+ if (options.callId && isRecord(gateway.payload)) {
693
+ if (gateway.payload.found === true && "call" in gateway.payload) {
694
+ writeStdoutJson(gateway.payload.call);
695
+ return;
696
+ }
697
+ if (gateway.payload.found === false) {
698
+ writeStdoutJson({ found: false });
699
+ return;
700
+ }
701
+ }
702
+ writeStdoutJson(gateway.payload);
703
+ return;
704
+ }
705
+ const rt = await ensureRuntime();
706
+ if (options.callId) {
707
+ const call = rt.manager.getCall(options.callId);
708
+ writeStdoutJson(call ?? { found: false });
709
+ return;
710
+ }
711
+ writeStdoutJson({
712
+ found: true,
713
+ calls: rt.manager.getActiveCalls(),
714
+ });
715
+ });
716
+
717
+ root
718
+ .command("tail")
719
+ .description("Tail voice-call JSONL logs (prints new lines; useful during provider tests)")
720
+ .option("--file <path>", "Path to calls.jsonl", resolveDefaultStorePath(config))
721
+ .option("--since <n>", "Print last N lines first", "25")
722
+ .option("--poll <ms>", "Poll interval in ms", "250")
723
+ .action(async (options: { file: string; since?: string; poll?: string }) => {
724
+ const file = options.file;
725
+ const since = parseVoiceCallIntOption(options.since, "--since", { min: 0 });
726
+ const pollMs = parseVoiceCallIntOption(options.poll, "--poll", { min: 50 });
727
+
728
+ if (!fs.existsSync(file)) {
729
+ logger.error(`No log file at ${file}`);
730
+ process.exit(1);
731
+ }
732
+
733
+ const initial = fs.readFileSync(file, "utf8");
734
+ const lines = initial.split("\n").filter(Boolean);
735
+ for (const line of lines.slice(Math.max(0, lines.length - since))) {
736
+ writeStdoutLine(line);
737
+ }
738
+
739
+ let offset = Buffer.byteLength(initial, "utf8");
740
+
741
+ for (;;) {
742
+ try {
743
+ const stat = fs.statSync(file);
744
+ if (stat.size < offset) {
745
+ offset = 0;
746
+ }
747
+ if (stat.size > offset) {
748
+ const fd = fs.openSync(file, "r");
749
+ try {
750
+ const buf = Buffer.alloc(stat.size - offset);
751
+ fs.readSync(fd, buf, 0, buf.length, offset);
752
+ offset = stat.size;
753
+ const text = buf.toString("utf8");
754
+ for (const line of text.split("\n").filter(Boolean)) {
755
+ writeStdoutLine(line);
756
+ }
757
+ } finally {
758
+ fs.closeSync(fd);
759
+ }
760
+ }
761
+ } catch {
762
+ // ignore and retry
763
+ }
764
+ await sleep(pollMs);
765
+ }
766
+ });
767
+
768
+ root
769
+ .command("latency")
770
+ .description("Summarize turn latency metrics from voice-call JSONL logs")
771
+ .option("--file <path>", "Path to calls.jsonl", resolveDefaultStorePath(config))
772
+ .option("--last <n>", "Analyze last N records", "200")
773
+ .action(async (options: { file: string; last?: string }) => {
774
+ const file = options.file;
775
+ const last = parseVoiceCallIntOption(options.last, "--last", { min: 1 });
776
+
777
+ if (!fs.existsSync(file)) {
778
+ throw new Error("No log file at " + file);
779
+ }
780
+
781
+ const content = fs.readFileSync(file, "utf8");
782
+ const lines = content.split("\n").filter(Boolean).slice(-last);
783
+
784
+ const turnLatencyMs: number[] = [];
785
+ const listenWaitMs: number[] = [];
786
+
787
+ for (const line of lines) {
788
+ try {
789
+ const parsed = JSON.parse(line) as {
790
+ metadata?: { lastTurnLatencyMs?: unknown; lastTurnListenWaitMs?: unknown };
791
+ };
792
+ const latency = parsed.metadata?.lastTurnLatencyMs;
793
+ const listenWait = parsed.metadata?.lastTurnListenWaitMs;
794
+ if (typeof latency === "number" && Number.isFinite(latency)) {
795
+ turnLatencyMs.push(latency);
796
+ }
797
+ if (typeof listenWait === "number" && Number.isFinite(listenWait)) {
798
+ listenWaitMs.push(listenWait);
799
+ }
800
+ } catch {
801
+ // ignore malformed JSON lines
802
+ }
803
+ }
804
+
805
+ writeStdoutJson({
806
+ recordsScanned: lines.length,
807
+ turnLatency: summarizeSeries(turnLatencyMs),
808
+ listenWait: summarizeSeries(listenWaitMs),
809
+ });
810
+ });
811
+
812
+ root
813
+ .command("expose")
814
+ .description("Enable/disable Tailscale serve/funnel for the webhook")
815
+ .option("--mode <mode>", "off | serve (tailnet) | funnel (public)", "funnel")
816
+ .option("--path <path>", "Tailscale path to expose (recommend matching serve.path)")
817
+ .option("--port <port>", "Local webhook port")
818
+ .option("--serve-path <path>", "Local webhook path")
819
+ .action(
820
+ async (options: { mode?: string; port?: string; path?: string; servePath?: string }) => {
821
+ const mode = resolveMode(options.mode ?? "funnel");
822
+ const servePort = parseVoiceCallIntOption(
823
+ options.port ?? String(config.serve.port ?? 3334),
824
+ "--port",
825
+ { min: 1 },
826
+ );
827
+ const servePath = options.servePath ?? config.serve.path ?? "/voice/webhook";
828
+ const tsPath = options.path ?? config.tailscale?.path ?? servePath;
829
+
830
+ const localUrl = `http://127.0.0.1:${servePort}`;
831
+
832
+ if (mode === "off") {
833
+ await cleanupTailscaleExposureRoute({ mode: "serve", path: tsPath });
834
+ await cleanupTailscaleExposureRoute({ mode: "funnel", path: tsPath });
835
+ writeStdoutJson({ ok: true, mode: "off", path: tsPath });
836
+ return;
837
+ }
838
+
839
+ const publicUrl = await setupTailscaleExposureRoute({
840
+ mode,
841
+ path: tsPath,
842
+ localUrl,
843
+ });
844
+
845
+ const tsInfo = publicUrl ? null : await getTailscaleSelfInfo();
846
+ const enableUrl = tsInfo?.nodeId
847
+ ? `https://login.tailscale.com/f/${mode}?node=${tsInfo.nodeId}`
848
+ : null;
849
+
850
+ writeStdoutJson({
851
+ ok: Boolean(publicUrl),
852
+ mode,
853
+ path: tsPath,
854
+ localUrl,
855
+ publicUrl,
856
+ hint: publicUrl
857
+ ? undefined
858
+ : {
859
+ note: "Tailscale serve/funnel may be disabled on this tailnet (or require admin enable).",
860
+ enableUrl,
861
+ },
862
+ });
863
+ },
864
+ );
865
+ }
866
+ export { testing as __testing };