@openclaw/voice-call 2026.2.15 → 2026.2.19

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 2026.2.19
4
+
5
+ ### Changes
6
+
7
+ - Version alignment with core OpenClaw release numbers.
8
+
9
+ ## 2026.2.16
10
+
11
+ ### Changes
12
+
13
+ - Version alignment with core OpenClaw release numbers.
14
+
3
15
  ## 2026.2.15
4
16
 
5
17
  ### Changes
package/README.md CHANGED
@@ -87,6 +87,26 @@ Notes:
87
87
  - Telnyx requires `telnyx.publicKey` (or `TELNYX_PUBLIC_KEY`) unless `skipSignatureVerification` is true.
88
88
  - `tunnel.allowNgrokFreeTierLoopbackBypass: true` allows Twilio webhooks with invalid signatures **only** when `tunnel.provider="ngrok"` and `serve.bind` is loopback (ngrok local agent). Use for local dev only.
89
89
 
90
+ ## Stale call reaper
91
+
92
+ Use `staleCallReaperSeconds` to end calls that never receive a terminal webhook
93
+ (for example, notify-mode calls that never complete). The default is `0`
94
+ (disabled).
95
+
96
+ Recommended ranges:
97
+
98
+ - **Production:** `120`–`300` seconds for notify-style flows.
99
+ - Keep this value **higher than `maxDurationSeconds`** so normal calls can
100
+ finish. A good starting point is `maxDurationSeconds + 30–60` seconds.
101
+
102
+ Example:
103
+
104
+ ```json5
105
+ {
106
+ staleCallReaperSeconds: 360,
107
+ }
108
+ ```
109
+
90
110
  ## TTS for calls
91
111
 
92
112
  Voice Call uses the core `messages.tts` configuration (OpenAI or ElevenLabs) for
package/index.ts CHANGED
@@ -1,6 +1,5 @@
1
- import type { GatewayRequestHandlerOptions, OpenClawPluginApi } from "openclaw/plugin-sdk";
2
1
  import { Type } from "@sinclair/typebox";
3
- import type { CoreConfig } from "./src/core-bridge.js";
2
+ import type { GatewayRequestHandlerOptions, OpenClawPluginApi } from "openclaw/plugin-sdk";
4
3
  import { registerVoiceCallCli } from "./src/cli.js";
5
4
  import {
6
5
  VoiceCallConfigSchema,
@@ -8,6 +7,7 @@ import {
8
7
  validateProviderConfig,
9
8
  type VoiceCallConfig,
10
9
  } from "./src/config.js";
10
+ import type { CoreConfig } from "./src/core-bridge.js";
11
11
  import { createVoiceCallRuntime, type VoiceCallRuntime } from "./src/runtime.js";
12
12
 
13
13
  const voiceCallConfigSchema = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/voice-call",
3
- "version": "2026.2.15",
3
+ "version": "2026.2.19",
4
4
  "description": "OpenClaw voice-call plugin",
5
5
  "type": "module",
6
6
  "dependencies": {
package/src/cli.ts CHANGED
@@ -1,7 +1,7 @@
1
- import type { Command } from "commander";
2
1
  import fs from "node:fs";
3
2
  import os from "node:os";
4
3
  import path from "node:path";
4
+ import type { Command } from "commander";
5
5
  import { sleep } from "openclaw/plugin-sdk";
6
6
  import type { VoiceCallConfig } from "./config.js";
7
7
  import type { VoiceCallRuntime } from "./runtime.js";
@@ -41,6 +41,46 @@ function resolveDefaultStorePath(config: VoiceCallConfig): string {
41
41
  return path.join(base, "calls.jsonl");
42
42
  }
43
43
 
44
+ function percentile(values: number[], p: number): number {
45
+ if (values.length === 0) {
46
+ return 0;
47
+ }
48
+ const sorted = [...values].sort((a, b) => a - b);
49
+ const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1));
50
+ return sorted[idx] ?? 0;
51
+ }
52
+
53
+ function summarizeSeries(values: number[]): {
54
+ count: number;
55
+ minMs: number;
56
+ maxMs: number;
57
+ avgMs: number;
58
+ p50Ms: number;
59
+ p95Ms: number;
60
+ } {
61
+ if (values.length === 0) {
62
+ return { count: 0, minMs: 0, maxMs: 0, avgMs: 0, p50Ms: 0, p95Ms: 0 };
63
+ }
64
+
65
+ const minMs = values.reduce(
66
+ (min, value) => (value < min ? value : min),
67
+ Number.POSITIVE_INFINITY,
68
+ );
69
+ const maxMs = values.reduce(
70
+ (max, value) => (value > max ? value : max),
71
+ Number.NEGATIVE_INFINITY,
72
+ );
73
+ const avgMs = values.reduce((sum, value) => sum + value, 0) / values.length;
74
+ return {
75
+ count: values.length,
76
+ minMs,
77
+ maxMs,
78
+ avgMs,
79
+ p50Ms: percentile(values, 50),
80
+ p95Ms: percentile(values, 95),
81
+ };
82
+ }
83
+
44
84
  export function registerVoiceCallCli(params: {
45
85
  program: Command;
46
86
  config: VoiceCallConfig;
@@ -216,6 +256,57 @@ export function registerVoiceCallCli(params: {
216
256
  }
217
257
  });
218
258
 
259
+ root
260
+ .command("latency")
261
+ .description("Summarize turn latency metrics from voice-call JSONL logs")
262
+ .option("--file <path>", "Path to calls.jsonl", resolveDefaultStorePath(config))
263
+ .option("--last <n>", "Analyze last N records", "200")
264
+ .action(async (options: { file: string; last?: string }) => {
265
+ const file = options.file;
266
+ const last = Math.max(1, Number(options.last ?? 200));
267
+
268
+ if (!fs.existsSync(file)) {
269
+ throw new Error("No log file at " + file);
270
+ }
271
+
272
+ const content = fs.readFileSync(file, "utf8");
273
+ const lines = content.split("\n").filter(Boolean).slice(-last);
274
+
275
+ const turnLatencyMs: number[] = [];
276
+ const listenWaitMs: number[] = [];
277
+
278
+ for (const line of lines) {
279
+ try {
280
+ const parsed = JSON.parse(line) as {
281
+ metadata?: { lastTurnLatencyMs?: unknown; lastTurnListenWaitMs?: unknown };
282
+ };
283
+ const latency = parsed.metadata?.lastTurnLatencyMs;
284
+ const listenWait = parsed.metadata?.lastTurnListenWaitMs;
285
+ if (typeof latency === "number" && Number.isFinite(latency)) {
286
+ turnLatencyMs.push(latency);
287
+ }
288
+ if (typeof listenWait === "number" && Number.isFinite(listenWait)) {
289
+ listenWaitMs.push(listenWait);
290
+ }
291
+ } catch {
292
+ // ignore malformed JSON lines
293
+ }
294
+ }
295
+
296
+ // eslint-disable-next-line no-console
297
+ console.log(
298
+ JSON.stringify(
299
+ {
300
+ recordsScanned: lines.length,
301
+ turnLatency: summarizeSeries(turnLatencyMs),
302
+ listenWait: summarizeSeries(listenWaitMs),
303
+ },
304
+ null,
305
+ 2,
306
+ ),
307
+ );
308
+ });
309
+
219
310
  root
220
311
  .command("expose")
221
312
  .description("Enable/disable Tailscale serve/funnel for the webhook")
@@ -10,6 +10,7 @@ function createBaseConfig(provider: "telnyx" | "twilio" | "plivo" | "mock"): Voi
10
10
  allowFrom: [],
11
11
  outbound: { defaultMode: "notify", notifyHangupDelaySec: 3 },
12
12
  maxDurationSeconds: 300,
13
+ staleCallReaperSeconds: 600,
13
14
  silenceTimeoutMs: 800,
14
15
  transcriptTimeoutMs: 180000,
15
16
  ringTimeoutMs: 30000,
@@ -32,7 +33,10 @@ function createBaseConfig(provider: "telnyx" | "twilio" | "plivo" | "mock"): Voi
32
33
  },
33
34
  skipSignatureVerification: false,
34
35
  stt: { provider: "openai", model: "whisper-1" },
35
- tts: { provider: "openai", model: "gpt-4o-mini-tts", voice: "coral" },
36
+ tts: {
37
+ provider: "openai",
38
+ openai: { model: "gpt-4o-mini-tts", voice: "coral" },
39
+ },
36
40
  responseModel: "openai/gpt-4o-mini",
37
41
  responseTimeoutMs: 30000,
38
42
  };
@@ -40,9 +44,7 @@ function createBaseConfig(provider: "telnyx" | "twilio" | "plivo" | "mock"): Voi
40
44
 
41
45
  describe("validateProviderConfig", () => {
42
46
  const originalEnv = { ...process.env };
43
-
44
- beforeEach(() => {
45
- // Clear all relevant env vars before each test
47
+ const clearProviderEnv = () => {
46
48
  delete process.env.TWILIO_ACCOUNT_SID;
47
49
  delete process.env.TWILIO_AUTH_TOKEN;
48
50
  delete process.env.TELNYX_API_KEY;
@@ -50,6 +52,10 @@ describe("validateProviderConfig", () => {
50
52
  delete process.env.TELNYX_PUBLIC_KEY;
51
53
  delete process.env.PLIVO_AUTH_ID;
52
54
  delete process.env.PLIVO_AUTH_TOKEN;
55
+ };
56
+
57
+ beforeEach(() => {
58
+ clearProviderEnv();
53
59
  });
54
60
 
55
61
  afterEach(() => {
@@ -57,29 +63,43 @@ describe("validateProviderConfig", () => {
57
63
  process.env = { ...originalEnv };
58
64
  });
59
65
 
60
- describe("twilio provider", () => {
61
- it("passes validation when credentials are in config", () => {
62
- const config = createBaseConfig("twilio");
63
- config.twilio = { accountSid: "AC123", authToken: "secret" };
64
-
65
- const result = validateProviderConfig(config);
66
-
67
- expect(result.valid).toBe(true);
68
- expect(result.errors).toEqual([]);
69
- });
70
-
71
- it("passes validation when credentials are in environment variables", () => {
72
- process.env.TWILIO_ACCOUNT_SID = "AC123";
73
- process.env.TWILIO_AUTH_TOKEN = "secret";
74
- let config = createBaseConfig("twilio");
75
- config = resolveVoiceCallConfig(config);
76
-
77
- const result = validateProviderConfig(config);
78
-
79
- expect(result.valid).toBe(true);
80
- expect(result.errors).toEqual([]);
66
+ describe("provider credential sources", () => {
67
+ it("passes validation when credentials come from config or environment", () => {
68
+ for (const provider of ["twilio", "telnyx", "plivo"] as const) {
69
+ clearProviderEnv();
70
+ const fromConfig = createBaseConfig(provider);
71
+ if (provider === "twilio") {
72
+ fromConfig.twilio = { accountSid: "AC123", authToken: "secret" };
73
+ } else if (provider === "telnyx") {
74
+ fromConfig.telnyx = {
75
+ apiKey: "KEY123",
76
+ connectionId: "CONN456",
77
+ publicKey: "public-key",
78
+ };
79
+ } else {
80
+ fromConfig.plivo = { authId: "MA123", authToken: "secret" };
81
+ }
82
+ expect(validateProviderConfig(fromConfig)).toMatchObject({ valid: true, errors: [] });
83
+
84
+ clearProviderEnv();
85
+ if (provider === "twilio") {
86
+ process.env.TWILIO_ACCOUNT_SID = "AC123";
87
+ process.env.TWILIO_AUTH_TOKEN = "secret";
88
+ } else if (provider === "telnyx") {
89
+ process.env.TELNYX_API_KEY = "KEY123";
90
+ process.env.TELNYX_CONNECTION_ID = "CONN456";
91
+ process.env.TELNYX_PUBLIC_KEY = "public-key";
92
+ } else {
93
+ process.env.PLIVO_AUTH_ID = "MA123";
94
+ process.env.PLIVO_AUTH_TOKEN = "secret";
95
+ }
96
+ const fromEnv = resolveVoiceCallConfig(createBaseConfig(provider));
97
+ expect(validateProviderConfig(fromEnv)).toMatchObject({ valid: true, errors: [] });
98
+ }
81
99
  });
100
+ });
82
101
 
102
+ describe("twilio provider", () => {
83
103
  it("passes validation with mixed config and env vars", () => {
84
104
  process.env.TWILIO_AUTH_TOKEN = "secret";
85
105
  let config = createBaseConfig("twilio");
@@ -92,57 +112,27 @@ describe("validateProviderConfig", () => {
92
112
  expect(result.errors).toEqual([]);
93
113
  });
94
114
 
95
- it("fails validation when accountSid is missing everywhere", () => {
115
+ it("fails validation when required twilio credentials are missing", () => {
96
116
  process.env.TWILIO_AUTH_TOKEN = "secret";
97
- let config = createBaseConfig("twilio");
98
- config = resolveVoiceCallConfig(config);
99
-
100
- const result = validateProviderConfig(config);
101
-
102
- expect(result.valid).toBe(false);
103
- expect(result.errors).toContain(
117
+ const missingSid = validateProviderConfig(resolveVoiceCallConfig(createBaseConfig("twilio")));
118
+ expect(missingSid.valid).toBe(false);
119
+ expect(missingSid.errors).toContain(
104
120
  "plugins.entries.voice-call.config.twilio.accountSid is required (or set TWILIO_ACCOUNT_SID env)",
105
121
  );
106
- });
107
122
 
108
- it("fails validation when authToken is missing everywhere", () => {
123
+ delete process.env.TWILIO_AUTH_TOKEN;
109
124
  process.env.TWILIO_ACCOUNT_SID = "AC123";
110
- let config = createBaseConfig("twilio");
111
- config = resolveVoiceCallConfig(config);
112
-
113
- const result = validateProviderConfig(config);
114
-
115
- expect(result.valid).toBe(false);
116
- expect(result.errors).toContain(
125
+ const missingToken = validateProviderConfig(
126
+ resolveVoiceCallConfig(createBaseConfig("twilio")),
127
+ );
128
+ expect(missingToken.valid).toBe(false);
129
+ expect(missingToken.errors).toContain(
117
130
  "plugins.entries.voice-call.config.twilio.authToken is required (or set TWILIO_AUTH_TOKEN env)",
118
131
  );
119
132
  });
120
133
  });
121
134
 
122
135
  describe("telnyx provider", () => {
123
- it("passes validation when credentials are in config", () => {
124
- const config = createBaseConfig("telnyx");
125
- config.telnyx = { apiKey: "KEY123", connectionId: "CONN456", publicKey: "public-key" };
126
-
127
- const result = validateProviderConfig(config);
128
-
129
- expect(result.valid).toBe(true);
130
- expect(result.errors).toEqual([]);
131
- });
132
-
133
- it("passes validation when credentials are in environment variables", () => {
134
- process.env.TELNYX_API_KEY = "KEY123";
135
- process.env.TELNYX_CONNECTION_ID = "CONN456";
136
- process.env.TELNYX_PUBLIC_KEY = "public-key";
137
- let config = createBaseConfig("telnyx");
138
- config = resolveVoiceCallConfig(config);
139
-
140
- const result = validateProviderConfig(config);
141
-
142
- expect(result.valid).toBe(true);
143
- expect(result.errors).toEqual([]);
144
- });
145
-
146
136
  it("fails validation when apiKey is missing everywhere", () => {
147
137
  process.env.TELNYX_CONNECTION_ID = "CONN456";
148
138
  let config = createBaseConfig("telnyx");
@@ -156,69 +146,36 @@ describe("validateProviderConfig", () => {
156
146
  );
157
147
  });
158
148
 
159
- it("fails validation when allowlist inbound policy lacks public key", () => {
160
- const config = createBaseConfig("telnyx");
161
- config.inboundPolicy = "allowlist";
162
- config.telnyx = { apiKey: "KEY123", connectionId: "CONN456" };
163
-
164
- const result = validateProviderConfig(config);
165
-
166
- expect(result.valid).toBe(false);
167
- expect(result.errors).toContain(
149
+ it("requires a public key unless signature verification is skipped", () => {
150
+ const missingPublicKey = createBaseConfig("telnyx");
151
+ missingPublicKey.inboundPolicy = "allowlist";
152
+ missingPublicKey.telnyx = { apiKey: "KEY123", connectionId: "CONN456" };
153
+ const missingPublicKeyResult = validateProviderConfig(missingPublicKey);
154
+ expect(missingPublicKeyResult.valid).toBe(false);
155
+ expect(missingPublicKeyResult.errors).toContain(
168
156
  "plugins.entries.voice-call.config.telnyx.publicKey is required (or set TELNYX_PUBLIC_KEY env)",
169
157
  );
170
- });
171
158
 
172
- it("passes validation when allowlist inbound policy has public key", () => {
173
- const config = createBaseConfig("telnyx");
174
- config.inboundPolicy = "allowlist";
175
- config.telnyx = {
159
+ const withPublicKey = createBaseConfig("telnyx");
160
+ withPublicKey.inboundPolicy = "allowlist";
161
+ withPublicKey.telnyx = {
176
162
  apiKey: "KEY123",
177
163
  connectionId: "CONN456",
178
164
  publicKey: "public-key",
179
165
  };
180
-
181
- const result = validateProviderConfig(config);
182
-
183
- expect(result.valid).toBe(true);
184
- expect(result.errors).toEqual([]);
185
- });
186
-
187
- it("passes validation when skipSignatureVerification is true (even without public key)", () => {
188
- const config = createBaseConfig("telnyx");
189
- config.skipSignatureVerification = true;
190
- config.telnyx = { apiKey: "KEY123", connectionId: "CONN456" };
191
-
192
- const result = validateProviderConfig(config);
193
-
194
- expect(result.valid).toBe(true);
195
- expect(result.errors).toEqual([]);
166
+ expect(validateProviderConfig(withPublicKey)).toMatchObject({ valid: true, errors: [] });
167
+
168
+ const skippedVerification = createBaseConfig("telnyx");
169
+ skippedVerification.skipSignatureVerification = true;
170
+ skippedVerification.telnyx = { apiKey: "KEY123", connectionId: "CONN456" };
171
+ expect(validateProviderConfig(skippedVerification)).toMatchObject({
172
+ valid: true,
173
+ errors: [],
174
+ });
196
175
  });
197
176
  });
198
177
 
199
178
  describe("plivo provider", () => {
200
- it("passes validation when credentials are in config", () => {
201
- const config = createBaseConfig("plivo");
202
- config.plivo = { authId: "MA123", authToken: "secret" };
203
-
204
- const result = validateProviderConfig(config);
205
-
206
- expect(result.valid).toBe(true);
207
- expect(result.errors).toEqual([]);
208
- });
209
-
210
- it("passes validation when credentials are in environment variables", () => {
211
- process.env.PLIVO_AUTH_ID = "MA123";
212
- process.env.PLIVO_AUTH_TOKEN = "secret";
213
- let config = createBaseConfig("plivo");
214
- config = resolveVoiceCallConfig(config);
215
-
216
- const result = validateProviderConfig(config);
217
-
218
- expect(result.valid).toBe(true);
219
- expect(result.errors).toEqual([]);
220
- });
221
-
222
179
  it("fails validation when authId is missing everywhere", () => {
223
180
  process.env.PLIVO_AUTH_TOKEN = "secret";
224
181
  let config = createBaseConfig("plivo");
package/src/config.ts CHANGED
@@ -273,6 +273,14 @@ export const VoiceCallConfigSchema = z
273
273
  /** Maximum call duration in seconds */
274
274
  maxDurationSeconds: z.number().int().positive().default(300),
275
275
 
276
+ /**
277
+ * Maximum age of a call in seconds before it is automatically reaped.
278
+ * Catches calls stuck in unexpected states (e.g., notify-mode calls that
279
+ * never receive a terminal webhook). Set to 0 to disable.
280
+ * Default: 0 (disabled). Recommended: 120-300 for production.
281
+ */
282
+ staleCallReaperSeconds: z.number().int().nonnegative().default(0),
283
+
276
284
  /** Silence timeout for end-of-speech detection (ms) */
277
285
  silenceTimeoutMs: z.number().int().positive().default(800),
278
286
 
@@ -24,6 +24,7 @@ export type CallManagerRuntimeDeps = {
24
24
  };
25
25
 
26
26
  export type CallManagerTransientState = {
27
+ activeTurnCalls: Set<CallId>;
27
28
  transcriptWaiters: Map<CallId, TranscriptWaiter>;
28
29
  maxDurationTimers: Map<CallId, NodeJS.Timeout>;
29
30
  };
@@ -2,9 +2,10 @@ import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { describe, expect, it } from "vitest";
5
+ import { VoiceCallConfigSchema } from "../config.js";
6
+ import type { VoiceCallProvider } from "../providers/base.js";
5
7
  import type { HangupCallInput, NormalizedEvent } from "../types.js";
6
8
  import type { CallManagerContext } from "./context.js";
7
- import { VoiceCallConfigSchema } from "../config.js";
8
9
  import { processEvent } from "./events.js";
9
10
 
10
11
  function createContext(overrides: Partial<CallManagerContext> = {}): CallManagerContext {
@@ -23,21 +24,35 @@ function createContext(overrides: Partial<CallManagerContext> = {}): CallManager
23
24
  }),
24
25
  storePath,
25
26
  webhookUrl: null,
27
+ activeTurnCalls: new Set(),
26
28
  transcriptWaiters: new Map(),
27
29
  maxDurationTimers: new Map(),
28
30
  ...overrides,
29
31
  };
30
32
  }
31
33
 
34
+ function createProvider(overrides: Partial<VoiceCallProvider> = {}): VoiceCallProvider {
35
+ return {
36
+ name: "plivo",
37
+ verifyWebhook: () => ({ ok: true }),
38
+ parseWebhookEvent: () => ({ events: [] }),
39
+ initiateCall: async () => ({ providerCallId: "provider-call-id", status: "initiated" }),
40
+ hangupCall: async () => {},
41
+ playTts: async () => {},
42
+ startListening: async () => {},
43
+ stopListening: async () => {},
44
+ ...overrides,
45
+ };
46
+ }
47
+
32
48
  describe("processEvent (functional)", () => {
33
49
  it("calls provider hangup when rejecting inbound call", () => {
34
50
  const hangupCalls: HangupCallInput[] = [];
35
- const provider = {
36
- name: "plivo" as const,
37
- async hangupCall(input: HangupCallInput): Promise<void> {
51
+ const provider = createProvider({
52
+ hangupCall: async (input: HangupCallInput): Promise<void> => {
38
53
  hangupCalls.push(input);
39
54
  },
40
- };
55
+ });
41
56
 
42
57
  const ctx = createContext({
43
58
  config: VoiceCallConfigSchema.parse({
@@ -98,12 +113,11 @@ describe("processEvent (functional)", () => {
98
113
 
99
114
  it("calls hangup only once for duplicate events for same rejected call", () => {
100
115
  const hangupCalls: HangupCallInput[] = [];
101
- const provider = {
102
- name: "plivo" as const,
103
- async hangupCall(input: HangupCallInput): Promise<void> {
116
+ const provider = createProvider({
117
+ hangupCall: async (input: HangupCallInput): Promise<void> => {
104
118
  hangupCalls.push(input);
105
119
  },
106
- };
120
+ });
107
121
  const ctx = createContext({
108
122
  config: VoiceCallConfigSchema.parse({
109
123
  enabled: true,
@@ -208,12 +222,11 @@ describe("processEvent (functional)", () => {
208
222
  });
209
223
 
210
224
  it("when hangup throws, logs and does not throw", () => {
211
- const provider = {
212
- name: "plivo" as const,
213
- async hangupCall(): Promise<void> {
225
+ const provider = createProvider({
226
+ hangupCall: async (): Promise<void> => {
214
227
  throw new Error("provider down");
215
228
  },
216
- };
229
+ });
217
230
  const ctx = createContext({
218
231
  config: VoiceCallConfigSchema.parse({
219
232
  enabled: true,
@@ -1,7 +1,7 @@
1
1
  import crypto from "node:crypto";
2
+ import { isAllowlistedCaller, normalizePhoneNumber } from "../allowlist.js";
2
3
  import type { CallRecord, CallState, NormalizedEvent } from "../types.js";
3
4
  import type { CallManagerContext } from "./context.js";
4
- import { isAllowlistedCaller, normalizePhoneNumber } from "../allowlist.js";
5
5
  import { findCall } from "./lookup.js";
6
6
  import { endCall } from "./outbound.js";
7
7
  import { addTranscriptEntry, transitionState } from "./state.js";
@@ -1,6 +1,5 @@
1
1
  import crypto from "node:crypto";
2
2
  import type { CallMode } from "../config.js";
3
- import type { CallManagerContext } from "./context.js";
4
3
  import {
5
4
  TerminalStates,
6
5
  type CallId,
@@ -8,6 +7,7 @@ import {
8
7
  type OutboundCallOptions,
9
8
  } from "../types.js";
10
9
  import { mapVoiceToPolly } from "../voice-mapping.js";
10
+ import type { CallManagerContext } from "./context.js";
11
11
  import { getCallByProviderCallId } from "./lookup.js";
12
12
  import { addTranscriptEntry, transitionState } from "./state.js";
13
13
  import { persistCallRecord } from "./store.js";
@@ -36,6 +36,7 @@ type ConversationContext = Pick<
36
36
  | "provider"
37
37
  | "config"
38
38
  | "storePath"
39
+ | "activeTurnCalls"
39
40
  | "transcriptWaiters"
40
41
  | "maxDurationTimers"
41
42
  >;
@@ -158,7 +159,6 @@ export async function speak(
158
159
  if (TerminalStates.has(call.state)) {
159
160
  return { success: false, error: "Call has ended" };
160
161
  }
161
-
162
162
  try {
163
163
  transitionState(call, "speaking");
164
164
  persistCallRecord(ctx.storePath, call);
@@ -242,6 +242,12 @@ export async function continueCall(
242
242
  if (TerminalStates.has(call.state)) {
243
243
  return { success: false, error: "Call has ended" };
244
244
  }
245
+ if (ctx.activeTurnCalls.has(callId) || ctx.transcriptWaiters.has(callId)) {
246
+ return { success: false, error: "Already waiting for transcript" };
247
+ }
248
+ ctx.activeTurnCalls.add(callId);
249
+
250
+ const turnStartedAt = Date.now();
245
251
 
246
252
  try {
247
253
  await speak(ctx, callId, prompt);
@@ -249,17 +255,45 @@ export async function continueCall(
249
255
  transitionState(call, "listening");
250
256
  persistCallRecord(ctx.storePath, call);
251
257
 
258
+ const listenStartedAt = Date.now();
252
259
  await ctx.provider.startListening({ callId, providerCallId: call.providerCallId });
253
260
 
254
261
  const transcript = await waitForFinalTranscript(ctx, callId);
262
+ const transcriptReceivedAt = Date.now();
255
263
 
256
264
  // Best-effort: stop listening after final transcript.
257
265
  await ctx.provider.stopListening({ callId, providerCallId: call.providerCallId });
258
266
 
267
+ const lastTurnLatencyMs = transcriptReceivedAt - turnStartedAt;
268
+ const lastTurnListenWaitMs = transcriptReceivedAt - listenStartedAt;
269
+ const turnCount =
270
+ call.metadata && typeof call.metadata.turnCount === "number"
271
+ ? call.metadata.turnCount + 1
272
+ : 1;
273
+
274
+ call.metadata = {
275
+ ...(call.metadata ?? {}),
276
+ turnCount,
277
+ lastTurnLatencyMs,
278
+ lastTurnListenWaitMs,
279
+ lastTurnCompletedAt: transcriptReceivedAt,
280
+ };
281
+ persistCallRecord(ctx.storePath, call);
282
+
283
+ console.log(
284
+ "[voice-call] continueCall latency call=" +
285
+ call.callId +
286
+ " totalMs=" +
287
+ String(lastTurnLatencyMs) +
288
+ " listenWaitMs=" +
289
+ String(lastTurnListenWaitMs),
290
+ );
291
+
259
292
  return { success: true, transcript };
260
293
  } catch (err) {
261
294
  return { success: false, error: err instanceof Error ? err.message : String(err) };
262
295
  } finally {
296
+ ctx.activeTurnCalls.delete(callId);
263
297
  clearTranscriptWaiter(ctx, callId);
264
298
  }
265
299
  }
@@ -1,5 +1,5 @@
1
- import type { CallManagerContext } from "./context.js";
2
1
  import { TerminalStates, type CallId } from "../types.js";
2
+ import type { CallManagerContext } from "./context.js";
3
3
  import { persistCallRecord } from "./store.js";
4
4
 
5
5
  type TimerContext = Pick<
@@ -87,8 +87,9 @@ export function resolveTranscriptWaiter(
87
87
  }
88
88
 
89
89
  export function waitForFinalTranscript(ctx: TimerContext, callId: CallId): Promise<string> {
90
- // Only allow one in-flight waiter per call.
91
- rejectTranscriptWaiter(ctx, callId, "Transcript waiter replaced");
90
+ if (ctx.transcriptWaiters.has(callId)) {
91
+ return Promise.reject(new Error("Already waiting for transcript"));
92
+ }
92
93
 
93
94
  const timeoutMs = ctx.config.transcriptTimeoutMs;
94
95
  return new Promise((resolve, reject) => {