@absolutejs/voice 0.0.22-beta.582 → 0.0.22-beta.584

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.
@@ -0,0 +1,11 @@
1
+ export declare const logVoiceTiming: (sessionId: string, stage: string, elapsedMs: number, detail?: Record<string, unknown>) => void;
2
+ /**
3
+ * A per-turn stopwatch. `stamp(stage, detail?)` logs the elapsed since the timer
4
+ * was created, so one timer per turn lays every stage out on a single timeline:
5
+ *
6
+ * const stamp = startVoiceTimer(session.id);
7
+ * stamp("agent.system-resolved", { chars });
8
+ * stamp("agent.round0.generate-done", { ms });
9
+ */
10
+ export declare const startVoiceTimer: (sessionId: string) => (stage: string, detail?: Record<string, unknown>) => void;
11
+ export declare const voiceTimingEnabled: () => boolean;
@@ -0,0 +1,3 @@
1
+ export declare const hardenFetch: (baseFetch?: typeof fetch) => ((input: Parameters<typeof fetch>[0], init: Parameters<typeof fetch>[1]) => Promise<Response>) & {
2
+ preconnect: typeof fetch.preconnect;
3
+ };
package/dist/index.d.ts CHANGED
@@ -71,6 +71,8 @@ export { createVoiceSessionListRoutes, createVoiceSessionReplayHTMLHandler, crea
71
71
  export { createVoiceAgent, createVoiceAgentSquad, createVoiceAgentTool, } from "./core/agent";
72
72
  export { createPersonaVoiceCaller, createScriptedVoiceCaller, renderVoiceSimulationTranscript, runVoiceConversationSimulation, } from "./core/conversationSimulator";
73
73
  export type { RunVoiceConversationSimulationInput, VoiceConversationSimulationEndedReason, VoiceConversationSimulationResult, VoicePersonaCallerCompletion, VoiceScriptedCallerStep, VoiceSimulatedSpeaker, VoiceSimulatedTurn, VoiceSimulatorCaller, VoiceSimulatorCallerModel, VoiceSimulatorCallerReply, } from "./core/conversationSimulator";
74
+ export { logVoiceTiming, startVoiceTimer, voiceTimingEnabled, } from "./core/debugTiming";
75
+ export { hardenFetch } from "./core/hardenedFetch";
74
76
  export { createVoiceMCPToolset } from "./core/mcpToolset";
75
77
  export type { CreateVoiceMCPToolsetOptions, MCPClientLike, MCPToolCallResult, MCPToolContentBlock, MCPToolDefinition, VoiceMCPToolResult, } from "./core/mcpToolset";
76
78
  export { createAIVoiceModel } from "./core/aiVoiceModel";
package/dist/index.js CHANGED
@@ -3091,6 +3091,21 @@ var toVoiceSessionSummary = (session) => ({
3091
3091
  // src/core/session.ts
3092
3092
  import { Buffer as Buffer2 } from "buffer";
3093
3093
 
3094
+ // src/core/debugTiming.ts
3095
+ var timingEnabled = () => process.env.ABSOLUTEJS_VOICE_TIMING === "1" || process.env.ABSOLUTEJS_VOICE_TIMING === "true";
3096
+ var emitTiming = (sessionId, stage, elapsedMs, detail) => {
3097
+ if (!timingEnabled())
3098
+ return;
3099
+ const extra = detail ? ` ${JSON.stringify(detail)}` : "";
3100
+ console.info(`[voice][timing] session=${sessionId} ${stage} +${Math.round(elapsedMs)}ms${extra}`);
3101
+ };
3102
+ var logVoiceTiming = (sessionId, stage, elapsedMs, detail) => emitTiming(sessionId, stage, elapsedMs, detail);
3103
+ var startVoiceTimer = (sessionId) => {
3104
+ const startedAt = Date.now();
3105
+ return (stage, detail) => emitTiming(sessionId, stage, Date.now() - startedAt, detail);
3106
+ };
3107
+ var voiceTimingEnabled = () => timingEnabled();
3108
+
3094
3109
  // src/core/backchannel.ts
3095
3110
  var DEFAULT_CUES = [
3096
3111
  { text: "mm-hmm" },
@@ -5534,6 +5549,7 @@ var createVoiceSession = (options) => {
5534
5549
  const onTurnTimeoutMs = options.routeOnTurnTimeoutMs ?? 45000;
5535
5550
  let committedOutput;
5536
5551
  const onTurnStartedAt = Date.now();
5552
+ logVoiceTiming(session.id, "session.commit-to-onturn", onTurnStartedAt - (turn.committedAt || onTurnStartedAt), { fillerScheduled: fillerTimer !== null });
5537
5553
  try {
5538
5554
  const onTurnPromise = options.route.onTurn({
5539
5555
  api,
@@ -7446,7 +7462,9 @@ var createVoiceAgent = (options) => {
7446
7462
  const maxToolRounds = Math.max(0, options.maxToolRounds ?? 2);
7447
7463
  const audit = resolveVoiceAgentAuditLogger(options.audit);
7448
7464
  const run = async (input) => {
7465
+ const stamp = startVoiceTimer(input.session.id);
7449
7466
  const messages = input.messages ?? createHistoryMessages(input.session, input.turn);
7467
+ stamp("agent.history-built", { messages: messages.length });
7450
7468
  const toolResults = [];
7451
7469
  const baseSystem = typeof options.system === "function" ? await options.system({
7452
7470
  context: input.context,
@@ -7456,9 +7474,11 @@ var createVoiceAgent = (options) => {
7456
7474
  const system = [baseSystem, input.system].filter((value) => Boolean(value?.trim())).join(`
7457
7475
 
7458
7476
  `) || undefined;
7477
+ stamp("agent.system-resolved", { systemChars: system?.length ?? 0 });
7459
7478
  let output = {};
7460
7479
  for (let round = 0;round <= maxToolRounds; round += 1) {
7461
7480
  const modelStartedAt = Date.now();
7481
+ stamp(`agent.round${round}.generate-start`);
7462
7482
  try {
7463
7483
  output = await options.model.generate({
7464
7484
  agentId: options.id,
@@ -7474,6 +7494,11 @@ var createVoiceAgent = (options) => {
7474
7494
  })),
7475
7495
  turn: input.turn
7476
7496
  });
7497
+ stamp(`agent.round${round}.generate-done`, {
7498
+ ms: Date.now() - modelStartedAt,
7499
+ textChars: output.assistantText?.length ?? 0,
7500
+ toolCalls: output.toolCalls?.length ?? 0
7501
+ });
7477
7502
  await audit?.providerCall({
7478
7503
  actor: {
7479
7504
  id: options.id,
@@ -7487,6 +7512,9 @@ var createVoiceAgent = (options) => {
7487
7512
  sessionId: input.session.id
7488
7513
  });
7489
7514
  } catch (error) {
7515
+ stamp(`agent.round${round}.generate-error`, {
7516
+ ms: Date.now() - modelStartedAt
7517
+ });
7490
7518
  await audit?.providerCall({
7491
7519
  actor: {
7492
7520
  id: options.id,
@@ -40814,6 +40842,44 @@ Respond with only your spoken line. When your goal is met or you want to hang up
40814
40842
  persona: options.persona
40815
40843
  };
40816
40844
  };
40845
+ // src/core/hardenedFetch.ts
40846
+ var ATTEMPT_TIMEOUT_MS = 6000;
40847
+ var isBun = "Bun" in globalThis;
40848
+ var oneAttempt = async (baseFetch, input, init) => {
40849
+ const controller = new AbortController;
40850
+ const callerSignal = init?.signal ?? undefined;
40851
+ const onCallerAbort = () => controller.abort(callerSignal?.reason);
40852
+ if (callerSignal?.aborted)
40853
+ controller.abort(callerSignal.reason);
40854
+ else
40855
+ callerSignal?.addEventListener("abort", onCallerAbort, { once: true });
40856
+ const timer = setTimeout(() => {
40857
+ controller.abort(new Error(`fetch exceeded ${ATTEMPT_TIMEOUT_MS}ms before response headers (stale Bun keep-alive socket?)`));
40858
+ }, ATTEMPT_TIMEOUT_MS);
40859
+ const headers = new Headers(init?.headers);
40860
+ if (isBun)
40861
+ headers.set("Connection", "close");
40862
+ try {
40863
+ return await baseFetch(input, {
40864
+ ...init,
40865
+ headers,
40866
+ signal: controller.signal
40867
+ });
40868
+ } finally {
40869
+ clearTimeout(timer);
40870
+ callerSignal?.removeEventListener("abort", onCallerAbort);
40871
+ }
40872
+ };
40873
+ var hardenFetch = (baseFetch = globalThis.fetch) => Object.assign(async (input, init) => {
40874
+ try {
40875
+ return await oneAttempt(baseFetch, input, init);
40876
+ } catch (error) {
40877
+ if (init?.signal?.aborted)
40878
+ throw error;
40879
+ console.warn(`[voice] hardened fetch retrying on a fresh connection: ${error instanceof Error ? error.message : String(error)}`);
40880
+ return oneAttempt(baseFetch, input, init);
40881
+ }
40882
+ }, { preconnect: baseFetch.preconnect.bind(baseFetch) });
40817
40883
  // src/core/mcpToolset.ts
40818
40884
  var flattenContent = (result) => {
40819
40885
  const blocks = result.content ?? [];
@@ -45362,12 +45428,13 @@ var consumeOpenAIResponsesStream = async (response, onTextDelta, abortOptions) =
45362
45428
  return { assistantText, toolCalls: finalizeToolCalls(calls), usage };
45363
45429
  };
45364
45430
  var createOpenAIVoiceAssistantModel = (options) => {
45365
- const fetchImpl = options.fetch ?? globalThis.fetch;
45431
+ const fetchImpl = hardenFetch(options.fetch);
45366
45432
  const baseUrl = options.baseUrl ?? "https://api.openai.com/v1";
45367
45433
  const model = options.model ?? "gpt-4.1-mini";
45368
45434
  const timeoutMs = options.timeoutMs ?? 60000;
45369
45435
  return {
45370
45436
  generate: async (input) => {
45437
+ const stamp = startVoiceTimer(input.session.id);
45371
45438
  const ac = new AbortController;
45372
45439
  const timer = setTimeout(() => {
45373
45440
  ac.abort(new Error(`OpenAI /responses timed out after ${timeoutMs}ms (no completion event received)`));
@@ -45408,6 +45475,10 @@ var createOpenAIVoiceAssistantModel = (options) => {
45408
45475
  clearTimeout(timer);
45409
45476
  throw error;
45410
45477
  }
45478
+ stamp("openai.fetch-returned", {
45479
+ messages: input.messages.length,
45480
+ status: response.status
45481
+ });
45411
45482
  if (!response.ok) {
45412
45483
  clearTimeout(timer);
45413
45484
  throw createHTTPError("OpenAI", response);
@@ -45415,11 +45486,23 @@ var createOpenAIVoiceAssistantModel = (options) => {
45415
45486
  let assistantText;
45416
45487
  let toolCalls;
45417
45488
  let usage;
45489
+ let firstDeltaSeen = false;
45490
+ const onTextDelta = input.onTextDelta ? (delta) => {
45491
+ if (!firstDeltaSeen) {
45492
+ firstDeltaSeen = true;
45493
+ stamp("openai.first-delta");
45494
+ }
45495
+ input.onTextDelta?.(delta);
45496
+ } : undefined;
45418
45497
  try {
45419
- ({ assistantText, toolCalls, usage } = await consumeOpenAIResponsesStream(response, input.onTextDelta, {
45498
+ ({ assistantText, toolCalls, usage } = await consumeOpenAIResponsesStream(response, onTextDelta, {
45420
45499
  signal: ac.signal,
45421
45500
  inactivityMs: 1e4
45422
45501
  }));
45502
+ stamp("openai.stream-done", {
45503
+ textChars: assistantText?.length ?? 0,
45504
+ toolCalls: toolCalls.length
45505
+ });
45423
45506
  } finally {
45424
45507
  clearTimeout(timer);
45425
45508
  }
@@ -45470,7 +45553,7 @@ var consumeAnthropicStream = async (response, onTextDelta) => {
45470
45553
  return { assistantText, toolCalls: finalizeToolCalls(calls), usage };
45471
45554
  };
45472
45555
  var createAnthropicVoiceAssistantModel = (options) => {
45473
- const fetchImpl = options.fetch ?? globalThis.fetch;
45556
+ const fetchImpl = hardenFetch(options.fetch);
45474
45557
  const baseUrl = options.baseUrl ?? "https://api.anthropic.com/v1";
45475
45558
  const model = options.model ?? "claude-sonnet-4-5";
45476
45559
  return {
@@ -45556,7 +45639,7 @@ var consumeGeminiStream = async (response, onTextDelta) => {
45556
45639
  return { assistantText, toolCalls, usage };
45557
45640
  };
45558
45641
  var createGeminiVoiceAssistantModel = (options) => {
45559
- const fetchImpl = options.fetch ?? globalThis.fetch;
45642
+ const fetchImpl = hardenFetch(options.fetch);
45560
45643
  const baseUrl = options.baseUrl ?? "https://generativelanguage.googleapis.com/v1beta";
45561
45644
  const model = options.model ?? "gemini-2.5-flash";
45562
45645
  const maxRetries = Math.max(0, options.maxRetries ?? 2);
@@ -52391,6 +52474,7 @@ export {
52391
52474
  wrapVoiceHTMLInHTMXContainer,
52392
52475
  withVoiceOpsTaskId,
52393
52476
  withVoiceIntegrationEventId,
52477
+ voiceTimingEnabled,
52394
52478
  voiceTelephonyOutcomeToRouteResult,
52395
52479
  voiceObservabilityExportSchemaVersion,
52396
52480
  voiceObservabilityExportSchemaId,
@@ -52448,6 +52532,7 @@ export {
52448
52532
  summarizeVoiceAuditSinkDeliveries,
52449
52533
  summarizeVoiceAssistantRuns,
52450
52534
  summarizeVoiceAssistantHealth,
52535
+ startVoiceTimer,
52451
52536
  startVoiceOpsTask,
52452
52537
  signVoiceWebhookBody,
52453
52538
  signVoiceTwilioWebhook,
@@ -52648,6 +52733,7 @@ export {
52648
52733
  matchesVoiceOpsTaskAssignmentRule,
52649
52734
  markVoiceOpsTaskSLABreached,
52650
52735
  mapVoiceProofTargetsWithConcurrency,
52736
+ logVoiceTiming,
52651
52737
  loadVoiceRealCallProfileEvidenceFromTraceStore,
52652
52738
  loadVoiceRealCallProfileEvidenceFromStore,
52653
52739
  loadVoiceObservabilityExportReplaySource,
@@ -52663,6 +52749,7 @@ export {
52663
52749
  importVoiceCampaignRecipients,
52664
52750
  heartbeatVoiceOpsTask,
52665
52751
  hasVoiceOpsTaskSLABreach,
52752
+ hardenFetch,
52666
52753
  getVoiceProofTargetLogicalFailure,
52667
52754
  getVoiceLiveOpsControlStatus,
52668
52755
  getVoiceCampaignDialerProofStatus,
@@ -4195,6 +4195,60 @@ var createVoiceIOProviderFailureSimulator = (options) => {
4195
4195
  run
4196
4196
  };
4197
4197
  };
4198
+ // src/core/debugTiming.ts
4199
+ var timingEnabled = () => process.env.ABSOLUTEJS_VOICE_TIMING === "1" || process.env.ABSOLUTEJS_VOICE_TIMING === "true";
4200
+ var emitTiming = (sessionId, stage, elapsedMs, detail) => {
4201
+ if (!timingEnabled())
4202
+ return;
4203
+ const extra = detail ? ` ${JSON.stringify(detail)}` : "";
4204
+ console.info(`[voice][timing] session=${sessionId} ${stage} +${Math.round(elapsedMs)}ms${extra}`);
4205
+ };
4206
+ var logVoiceTiming = (sessionId, stage, elapsedMs, detail) => emitTiming(sessionId, stage, elapsedMs, detail);
4207
+ var startVoiceTimer = (sessionId) => {
4208
+ const startedAt = Date.now();
4209
+ return (stage, detail) => emitTiming(sessionId, stage, Date.now() - startedAt, detail);
4210
+ };
4211
+ var voiceTimingEnabled = () => timingEnabled();
4212
+
4213
+ // src/core/hardenedFetch.ts
4214
+ var ATTEMPT_TIMEOUT_MS = 6000;
4215
+ var isBun = "Bun" in globalThis;
4216
+ var oneAttempt = async (baseFetch, input, init) => {
4217
+ const controller = new AbortController;
4218
+ const callerSignal = init?.signal ?? undefined;
4219
+ const onCallerAbort = () => controller.abort(callerSignal?.reason);
4220
+ if (callerSignal?.aborted)
4221
+ controller.abort(callerSignal.reason);
4222
+ else
4223
+ callerSignal?.addEventListener("abort", onCallerAbort, { once: true });
4224
+ const timer = setTimeout(() => {
4225
+ controller.abort(new Error(`fetch exceeded ${ATTEMPT_TIMEOUT_MS}ms before response headers (stale Bun keep-alive socket?)`));
4226
+ }, ATTEMPT_TIMEOUT_MS);
4227
+ const headers = new Headers(init?.headers);
4228
+ if (isBun)
4229
+ headers.set("Connection", "close");
4230
+ try {
4231
+ return await baseFetch(input, {
4232
+ ...init,
4233
+ headers,
4234
+ signal: controller.signal
4235
+ });
4236
+ } finally {
4237
+ clearTimeout(timer);
4238
+ callerSignal?.removeEventListener("abort", onCallerAbort);
4239
+ }
4240
+ };
4241
+ var hardenFetch = (baseFetch = globalThis.fetch) => Object.assign(async (input, init) => {
4242
+ try {
4243
+ return await oneAttempt(baseFetch, input, init);
4244
+ } catch (error) {
4245
+ if (init?.signal?.aborted)
4246
+ throw error;
4247
+ console.warn(`[voice] hardened fetch retrying on a fresh connection: ${error instanceof Error ? error.message : String(error)}`);
4248
+ return oneAttempt(baseFetch, input, init);
4249
+ }
4250
+ }, { preconnect: baseFetch.preconnect.bind(baseFetch) });
4251
+
4198
4252
  // src/core/modelAdapters.ts
4199
4253
  var isVoiceProviderRoutingPolicyPreset = (value) => value === "balanced" || value === "cost-cap" || value === "cost-first" || value === "latency-first" || value === "quality-first";
4200
4254
  var resolveVoiceProviderRoutingPolicyPreset = (preset, options = {}) => {
@@ -4899,12 +4953,13 @@ var consumeOpenAIResponsesStream = async (response, onTextDelta, abortOptions) =
4899
4953
  return { assistantText, toolCalls: finalizeToolCalls(calls), usage };
4900
4954
  };
4901
4955
  var createOpenAIVoiceAssistantModel = (options) => {
4902
- const fetchImpl = options.fetch ?? globalThis.fetch;
4956
+ const fetchImpl = hardenFetch(options.fetch);
4903
4957
  const baseUrl = options.baseUrl ?? "https://api.openai.com/v1";
4904
4958
  const model = options.model ?? "gpt-4.1-mini";
4905
4959
  const timeoutMs = options.timeoutMs ?? 60000;
4906
4960
  return {
4907
4961
  generate: async (input) => {
4962
+ const stamp = startVoiceTimer(input.session.id);
4908
4963
  const ac = new AbortController;
4909
4964
  const timer = setTimeout(() => {
4910
4965
  ac.abort(new Error(`OpenAI /responses timed out after ${timeoutMs}ms (no completion event received)`));
@@ -4945,6 +5000,10 @@ var createOpenAIVoiceAssistantModel = (options) => {
4945
5000
  clearTimeout(timer);
4946
5001
  throw error;
4947
5002
  }
5003
+ stamp("openai.fetch-returned", {
5004
+ messages: input.messages.length,
5005
+ status: response.status
5006
+ });
4948
5007
  if (!response.ok) {
4949
5008
  clearTimeout(timer);
4950
5009
  throw createHTTPError("OpenAI", response);
@@ -4952,11 +5011,23 @@ var createOpenAIVoiceAssistantModel = (options) => {
4952
5011
  let assistantText;
4953
5012
  let toolCalls;
4954
5013
  let usage;
5014
+ let firstDeltaSeen = false;
5015
+ const onTextDelta = input.onTextDelta ? (delta) => {
5016
+ if (!firstDeltaSeen) {
5017
+ firstDeltaSeen = true;
5018
+ stamp("openai.first-delta");
5019
+ }
5020
+ input.onTextDelta?.(delta);
5021
+ } : undefined;
4955
5022
  try {
4956
- ({ assistantText, toolCalls, usage } = await consumeOpenAIResponsesStream(response, input.onTextDelta, {
5023
+ ({ assistantText, toolCalls, usage } = await consumeOpenAIResponsesStream(response, onTextDelta, {
4957
5024
  signal: ac.signal,
4958
5025
  inactivityMs: 1e4
4959
5026
  }));
5027
+ stamp("openai.stream-done", {
5028
+ textChars: assistantText?.length ?? 0,
5029
+ toolCalls: toolCalls.length
5030
+ });
4960
5031
  } finally {
4961
5032
  clearTimeout(timer);
4962
5033
  }
@@ -5007,7 +5078,7 @@ var consumeAnthropicStream = async (response, onTextDelta) => {
5007
5078
  return { assistantText, toolCalls: finalizeToolCalls(calls), usage };
5008
5079
  };
5009
5080
  var createAnthropicVoiceAssistantModel = (options) => {
5010
- const fetchImpl = options.fetch ?? globalThis.fetch;
5081
+ const fetchImpl = hardenFetch(options.fetch);
5011
5082
  const baseUrl = options.baseUrl ?? "https://api.anthropic.com/v1";
5012
5083
  const model = options.model ?? "claude-sonnet-4-5";
5013
5084
  return {
@@ -5093,7 +5164,7 @@ var consumeGeminiStream = async (response, onTextDelta) => {
5093
5164
  return { assistantText, toolCalls, usage };
5094
5165
  };
5095
5166
  var createGeminiVoiceAssistantModel = (options) => {
5096
- const fetchImpl = options.fetch ?? globalThis.fetch;
5167
+ const fetchImpl = hardenFetch(options.fetch);
5097
5168
  const baseUrl = options.baseUrl ?? "https://generativelanguage.googleapis.com/v1beta";
5098
5169
  const model = options.model ?? "gemini-2.5-flash";
5099
5170
  const maxRetries = Math.max(0, options.maxRetries ?? 2);
@@ -7676,6 +7747,7 @@ var createVoiceSession = (options) => {
7676
7747
  const onTurnTimeoutMs = options.routeOnTurnTimeoutMs ?? 45000;
7677
7748
  let committedOutput;
7678
7749
  const onTurnStartedAt = Date.now();
7750
+ logVoiceTiming(session.id, "session.commit-to-onturn", onTurnStartedAt - (turn.committedAt || onTurnStartedAt), { fillerScheduled: fillerTimer !== null });
7679
7751
  try {
7680
7752
  const onTurnPromise = options.route.onTurn({
7681
7753
  api,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@absolutejs/voice",
3
- "version": "0.0.22-beta.582",
3
+ "version": "0.0.22-beta.584",
4
4
  "description": "Voice primitives and Elysia plugin for AbsoluteJS",
5
5
  "repository": {
6
6
  "type": "git",