@absolutejs/voice 0.0.22-beta.622 → 0.0.22-beta.624

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.
@@ -803,6 +803,28 @@ export type VoicePluginConfig<TContext = unknown, TSession extends VoiceSessionR
803
803
  sttRecoveryLine?: string | ((input: {
804
804
  session: TSession;
805
805
  }) => string | Promise<string>);
806
+ stuckCallClose?: {
807
+ afterMs: number;
808
+ line?: string | ((input: {
809
+ session: TSession;
810
+ }) => string | Promise<string>);
811
+ reason?: string;
812
+ };
813
+ /** Gentle re-engagement before a silent call is given up on. When the caller
814
+ * makes no progress (no committed turn / user partial) for `afterMs`, the
815
+ * assistant speaks `line` ("still there?") instead of ending, and the close
816
+ * deadline (stuckCallClose) is pushed out — up to `maxReprompts` times (default
817
+ * 1). After the budget is spent the normal close path runs. Caller speech
818
+ * resets the budget. Assistant speech does NOT, so this can only extend a
819
+ * bounded amount. `afterMs` should be < stuckCallClose.afterMs so the nudge
820
+ * fires first. */
821
+ idleReprompt?: {
822
+ afterMs: number;
823
+ line: string | ((input: {
824
+ session: TSession;
825
+ }) => string | Promise<string>);
826
+ maxReprompts?: number;
827
+ };
806
828
  languageStrategy?: VoiceLanguageStrategy;
807
829
  lexicon?: VoiceLexiconEntry[] | VoiceLexiconResolver<TContext>;
808
830
  phraseHints?: VoicePhraseHint[] | VoicePhraseHintResolver<TContext>;
@@ -958,6 +980,35 @@ export type CreateVoiceSessionOptions<TContext = unknown, TSession extends Voice
958
980
  sttRecoveryLine?: string | ((input: {
959
981
  session: TSession;
960
982
  }) => string | Promise<string>);
983
+ /** Last-resort GRACEFUL terminal close for a wedged call. If no caller-side
984
+ * progress (committed turn / user partial) lands for `afterMs` on a live call
985
+ * — STT permanently deaf, or the caller left — the assistant speaks `line` and
986
+ * the session COMPLETES (disposition "completed") so onComplete still saves and
987
+ * the call ends with a real goodbye instead of dead air + "abandoned". Reset by
988
+ * real progress (committed turn / user partial / (re)connect), NOT by the
989
+ * assistant's own speech, so STT recovery re-prompts can't defer it forever. */
990
+ stuckCallClose?: {
991
+ afterMs: number;
992
+ line?: string | ((input: {
993
+ session: TSession;
994
+ }) => string | Promise<string>);
995
+ reason?: string;
996
+ };
997
+ /** Gentle re-engagement before a silent call is given up on. When the caller
998
+ * makes no progress (no committed turn / user partial) for `afterMs`, the
999
+ * assistant speaks `line` ("still there?") instead of ending, and the close
1000
+ * deadline (stuckCallClose) is pushed out — up to `maxReprompts` times (default
1001
+ * 1). After the budget is spent the normal close path runs. Caller speech
1002
+ * resets the budget. Assistant speech does NOT, so this can only extend a
1003
+ * bounded amount. `afterMs` should be < stuckCallClose.afterMs so the nudge
1004
+ * fires first. */
1005
+ idleReprompt?: {
1006
+ afterMs: number;
1007
+ line: string | ((input: {
1008
+ session: TSession;
1009
+ }) => string | Promise<string>);
1010
+ maxReprompts?: number;
1011
+ };
961
1012
  stt?: STTAdapter;
962
1013
  realtime?: RealtimeAdapter;
963
1014
  realtimeInputFormat?: AudioFormat;
package/dist/index.js CHANGED
@@ -4186,6 +4186,90 @@ var createVoiceSession = (options) => {
4186
4186
  clearCallSilenceWatchdog();
4187
4187
  callSilenceWatchdog = setTimeout(fireCallSilenceTimeout, callSilenceTimeoutMs);
4188
4188
  };
4189
+ const stuckCloseConfig = options.stuckCallClose;
4190
+ const stuckCloseAfterMs = stuckCloseConfig && stuckCloseConfig.afterMs > 0 ? stuckCloseConfig.afterMs : undefined;
4191
+ let stuckCloseWatchdog = null;
4192
+ let stuckCloseFired = false;
4193
+ const clearStuckCloseWatchdog = () => {
4194
+ if (stuckCloseWatchdog) {
4195
+ clearTimeout(stuckCloseWatchdog);
4196
+ stuckCloseWatchdog = null;
4197
+ }
4198
+ };
4199
+ const fireStuckClose = () => {
4200
+ stuckCloseWatchdog = null;
4201
+ if (stuckCloseFired) {
4202
+ return;
4203
+ }
4204
+ stuckCloseFired = true;
4205
+ runSerial("stuck-call-close", async () => {
4206
+ const snapshot = await readSession();
4207
+ if (snapshot.status === "completed" || snapshot.status === "failed" || snapshot.call?.endedAt) {
4208
+ return;
4209
+ }
4210
+ await appendTrace({
4211
+ payload: {
4212
+ action: "stuck-call-close",
4213
+ reason: `no caller progress for ${stuckCloseAfterMs}ms`
4214
+ },
4215
+ session: snapshot,
4216
+ type: "session.error"
4217
+ });
4218
+ if (stuckCloseConfig?.line) {
4219
+ await speakResolvedLine(stuckCloseConfig.line, snapshot);
4220
+ }
4221
+ await completeInternal(undefined, {
4222
+ disposition: "completed",
4223
+ reason: stuckCloseConfig?.reason ?? "stuck-call-close"
4224
+ });
4225
+ });
4226
+ };
4227
+ const kickStuckCloseWatchdog = () => {
4228
+ if (stuckCloseAfterMs === undefined || stuckCloseFired) {
4229
+ return;
4230
+ }
4231
+ clearStuckCloseWatchdog();
4232
+ stuckCloseWatchdog = setTimeout(fireStuckClose, stuckCloseAfterMs);
4233
+ };
4234
+ const idleRepromptConfig = options.idleReprompt;
4235
+ const idleRepromptAfterMs = idleRepromptConfig && idleRepromptConfig.afterMs > 0 ? idleRepromptConfig.afterMs : undefined;
4236
+ const idleRepromptMax = Math.max(1, idleRepromptConfig?.maxReprompts ?? 1);
4237
+ let idleRepromptsUsed = 0;
4238
+ let idleRepromptWatchdog = null;
4239
+ const clearIdleRepromptWatchdog = () => {
4240
+ if (idleRepromptWatchdog) {
4241
+ clearTimeout(idleRepromptWatchdog);
4242
+ idleRepromptWatchdog = null;
4243
+ }
4244
+ };
4245
+ const kickIdleRepromptWatchdog = (resetBudget = false) => {
4246
+ if (idleRepromptAfterMs === undefined) {
4247
+ return;
4248
+ }
4249
+ if (resetBudget) {
4250
+ idleRepromptsUsed = 0;
4251
+ }
4252
+ clearIdleRepromptWatchdog();
4253
+ if (idleRepromptsUsed >= idleRepromptMax) {
4254
+ return;
4255
+ }
4256
+ idleRepromptWatchdog = setTimeout(() => {
4257
+ idleRepromptWatchdog = null;
4258
+ if (idleRepromptConfig === undefined || idleRepromptsUsed >= idleRepromptMax) {
4259
+ return;
4260
+ }
4261
+ runSerial("idle-reprompt", async () => {
4262
+ const snapshot = await readSession();
4263
+ if (snapshot.status === "completed" || snapshot.status === "failed" || snapshot.call?.endedAt) {
4264
+ return;
4265
+ }
4266
+ idleRepromptsUsed += 1;
4267
+ await speakResolvedLine(idleRepromptConfig.line, snapshot);
4268
+ kickStuckCloseWatchdog();
4269
+ kickIdleRepromptWatchdog();
4270
+ });
4271
+ }, idleRepromptAfterMs);
4272
+ };
4189
4273
  const recordingConfig = options.recording;
4190
4274
  const recordingChannels = new Set(recordingConfig?.channels ?? ["assistant", "user"]);
4191
4275
  const recordingMaxBytes = recordingConfig?.maxBytesPerChannel ?? 50 * 1024 * 1024;
@@ -4682,6 +4766,8 @@ var createVoiceSession = (options) => {
4682
4766
  type: "error"
4683
4767
  });
4684
4768
  clearCallSilenceWatchdog();
4769
+ clearStuckCloseWatchdog();
4770
+ clearIdleRepromptWatchdog();
4685
4771
  clearAmdEvaluationTimer();
4686
4772
  await closeTTSSession("failed");
4687
4773
  await closeAdapter("failed");
@@ -4791,6 +4877,8 @@ var createVoiceSession = (options) => {
4791
4877
  type: "complete"
4792
4878
  });
4793
4879
  clearCallSilenceWatchdog();
4880
+ clearStuckCloseWatchdog();
4881
+ clearIdleRepromptWatchdog();
4794
4882
  clearAmdEvaluationTimer();
4795
4883
  await closeTTSSession("complete");
4796
4884
  await closeAdapter("complete");
@@ -5268,6 +5356,10 @@ var createVoiceSession = (options) => {
5268
5356
  };
5269
5357
  };
5270
5358
  const handlePartial = async (transcript) => {
5359
+ if (transcript.text.trim()) {
5360
+ kickStuckCloseWatchdog();
5361
+ kickIdleRepromptWatchdog(true);
5362
+ }
5271
5363
  if (activeTTSTurnId !== undefined) {
5272
5364
  const triggeringText = transcript.text.trim();
5273
5365
  if (triggeringText) {
@@ -5742,6 +5834,8 @@ var createVoiceSession = (options) => {
5742
5834
  };
5743
5835
  const completeTurn = async (session, turn) => {
5744
5836
  console.error(`[voice] completeTurn ENTER session=${options.id} turn=${turn.id} textLen=${turn.text?.length ?? 0}`);
5837
+ kickStuckCloseWatchdog();
5838
+ kickIdleRepromptWatchdog(true);
5745
5839
  const liveOpsControl = await options.liveOps?.getControl(options.id);
5746
5840
  if (liveOpsControl?.assistantPaused || liveOpsControl?.operatorTakeover) {
5747
5841
  await appendTrace({
@@ -6443,6 +6537,8 @@ var createVoiceSession = (options) => {
6443
6537
  await ensureAdapter();
6444
6538
  warmTTSSession();
6445
6539
  kickCallSilenceWatchdog();
6540
+ kickStuckCloseWatchdog();
6541
+ kickIdleRepromptWatchdog(true);
6446
6542
  startAmdEvaluationTimer();
6447
6543
  if (options.greeting && session.turns.length === 0) {
6448
6544
  await speakResolvedLine(options.greeting, session);
@@ -6588,6 +6684,8 @@ var createVoiceSession = (options) => {
6588
6684
  });
6589
6685
  clearSilenceTimer();
6590
6686
  clearCallSilenceWatchdog();
6687
+ clearStuckCloseWatchdog();
6688
+ clearIdleRepromptWatchdog();
6591
6689
  clearAmdEvaluationTimer();
6592
6690
  if (options.noiseSuppressor?.close) {
6593
6691
  try {
@@ -39705,6 +39803,7 @@ var voice = (config) => {
39705
39803
  greeting: config.greeting,
39706
39804
  resumeGreeting: config.resumeGreeting,
39707
39805
  sttRecoveryLine: config.sttRecoveryLine,
39806
+ stuckCallClose: config.stuckCallClose,
39708
39807
  handoff: config.handoff,
39709
39808
  languageStrategy: config.languageStrategy,
39710
39809
  lexicon,
@@ -6506,6 +6506,90 @@ var createVoiceSession = (options) => {
6506
6506
  clearCallSilenceWatchdog();
6507
6507
  callSilenceWatchdog = setTimeout(fireCallSilenceTimeout, callSilenceTimeoutMs);
6508
6508
  };
6509
+ const stuckCloseConfig = options.stuckCallClose;
6510
+ const stuckCloseAfterMs = stuckCloseConfig && stuckCloseConfig.afterMs > 0 ? stuckCloseConfig.afterMs : undefined;
6511
+ let stuckCloseWatchdog = null;
6512
+ let stuckCloseFired = false;
6513
+ const clearStuckCloseWatchdog = () => {
6514
+ if (stuckCloseWatchdog) {
6515
+ clearTimeout(stuckCloseWatchdog);
6516
+ stuckCloseWatchdog = null;
6517
+ }
6518
+ };
6519
+ const fireStuckClose = () => {
6520
+ stuckCloseWatchdog = null;
6521
+ if (stuckCloseFired) {
6522
+ return;
6523
+ }
6524
+ stuckCloseFired = true;
6525
+ runSerial("stuck-call-close", async () => {
6526
+ const snapshot = await readSession();
6527
+ if (snapshot.status === "completed" || snapshot.status === "failed" || snapshot.call?.endedAt) {
6528
+ return;
6529
+ }
6530
+ await appendTrace({
6531
+ payload: {
6532
+ action: "stuck-call-close",
6533
+ reason: `no caller progress for ${stuckCloseAfterMs}ms`
6534
+ },
6535
+ session: snapshot,
6536
+ type: "session.error"
6537
+ });
6538
+ if (stuckCloseConfig?.line) {
6539
+ await speakResolvedLine(stuckCloseConfig.line, snapshot);
6540
+ }
6541
+ await completeInternal(undefined, {
6542
+ disposition: "completed",
6543
+ reason: stuckCloseConfig?.reason ?? "stuck-call-close"
6544
+ });
6545
+ });
6546
+ };
6547
+ const kickStuckCloseWatchdog = () => {
6548
+ if (stuckCloseAfterMs === undefined || stuckCloseFired) {
6549
+ return;
6550
+ }
6551
+ clearStuckCloseWatchdog();
6552
+ stuckCloseWatchdog = setTimeout(fireStuckClose, stuckCloseAfterMs);
6553
+ };
6554
+ const idleRepromptConfig = options.idleReprompt;
6555
+ const idleRepromptAfterMs = idleRepromptConfig && idleRepromptConfig.afterMs > 0 ? idleRepromptConfig.afterMs : undefined;
6556
+ const idleRepromptMax = Math.max(1, idleRepromptConfig?.maxReprompts ?? 1);
6557
+ let idleRepromptsUsed = 0;
6558
+ let idleRepromptWatchdog = null;
6559
+ const clearIdleRepromptWatchdog = () => {
6560
+ if (idleRepromptWatchdog) {
6561
+ clearTimeout(idleRepromptWatchdog);
6562
+ idleRepromptWatchdog = null;
6563
+ }
6564
+ };
6565
+ const kickIdleRepromptWatchdog = (resetBudget = false) => {
6566
+ if (idleRepromptAfterMs === undefined) {
6567
+ return;
6568
+ }
6569
+ if (resetBudget) {
6570
+ idleRepromptsUsed = 0;
6571
+ }
6572
+ clearIdleRepromptWatchdog();
6573
+ if (idleRepromptsUsed >= idleRepromptMax) {
6574
+ return;
6575
+ }
6576
+ idleRepromptWatchdog = setTimeout(() => {
6577
+ idleRepromptWatchdog = null;
6578
+ if (idleRepromptConfig === undefined || idleRepromptsUsed >= idleRepromptMax) {
6579
+ return;
6580
+ }
6581
+ runSerial("idle-reprompt", async () => {
6582
+ const snapshot = await readSession();
6583
+ if (snapshot.status === "completed" || snapshot.status === "failed" || snapshot.call?.endedAt) {
6584
+ return;
6585
+ }
6586
+ idleRepromptsUsed += 1;
6587
+ await speakResolvedLine(idleRepromptConfig.line, snapshot);
6588
+ kickStuckCloseWatchdog();
6589
+ kickIdleRepromptWatchdog();
6590
+ });
6591
+ }, idleRepromptAfterMs);
6592
+ };
6509
6593
  const recordingConfig = options.recording;
6510
6594
  const recordingChannels = new Set(recordingConfig?.channels ?? ["assistant", "user"]);
6511
6595
  const recordingMaxBytes = recordingConfig?.maxBytesPerChannel ?? 50 * 1024 * 1024;
@@ -7002,6 +7086,8 @@ var createVoiceSession = (options) => {
7002
7086
  type: "error"
7003
7087
  });
7004
7088
  clearCallSilenceWatchdog();
7089
+ clearStuckCloseWatchdog();
7090
+ clearIdleRepromptWatchdog();
7005
7091
  clearAmdEvaluationTimer();
7006
7092
  await closeTTSSession("failed");
7007
7093
  await closeAdapter("failed");
@@ -7111,6 +7197,8 @@ var createVoiceSession = (options) => {
7111
7197
  type: "complete"
7112
7198
  });
7113
7199
  clearCallSilenceWatchdog();
7200
+ clearStuckCloseWatchdog();
7201
+ clearIdleRepromptWatchdog();
7114
7202
  clearAmdEvaluationTimer();
7115
7203
  await closeTTSSession("complete");
7116
7204
  await closeAdapter("complete");
@@ -7588,6 +7676,10 @@ var createVoiceSession = (options) => {
7588
7676
  };
7589
7677
  };
7590
7678
  const handlePartial = async (transcript) => {
7679
+ if (transcript.text.trim()) {
7680
+ kickStuckCloseWatchdog();
7681
+ kickIdleRepromptWatchdog(true);
7682
+ }
7591
7683
  if (activeTTSTurnId !== undefined) {
7592
7684
  const triggeringText = transcript.text.trim();
7593
7685
  if (triggeringText) {
@@ -8062,6 +8154,8 @@ var createVoiceSession = (options) => {
8062
8154
  };
8063
8155
  const completeTurn = async (session, turn) => {
8064
8156
  console.error(`[voice] completeTurn ENTER session=${options.id} turn=${turn.id} textLen=${turn.text?.length ?? 0}`);
8157
+ kickStuckCloseWatchdog();
8158
+ kickIdleRepromptWatchdog(true);
8065
8159
  const liveOpsControl = await options.liveOps?.getControl(options.id);
8066
8160
  if (liveOpsControl?.assistantPaused || liveOpsControl?.operatorTakeover) {
8067
8161
  await appendTrace({
@@ -8763,6 +8857,8 @@ var createVoiceSession = (options) => {
8763
8857
  await ensureAdapter();
8764
8858
  warmTTSSession();
8765
8859
  kickCallSilenceWatchdog();
8860
+ kickStuckCloseWatchdog();
8861
+ kickIdleRepromptWatchdog(true);
8766
8862
  startAmdEvaluationTimer();
8767
8863
  if (options.greeting && session.turns.length === 0) {
8768
8864
  await speakResolvedLine(options.greeting, session);
@@ -8908,6 +9004,8 @@ var createVoiceSession = (options) => {
8908
9004
  });
8909
9005
  clearSilenceTimer();
8910
9006
  clearCallSilenceWatchdog();
9007
+ clearStuckCloseWatchdog();
9008
+ clearIdleRepromptWatchdog();
8911
9009
  clearAmdEvaluationTimer();
8912
9010
  if (options.noiseSuppressor?.close) {
8913
9011
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@absolutejs/voice",
3
- "version": "0.0.22-beta.622",
3
+ "version": "0.0.22-beta.624",
4
4
  "description": "Voice primitives and Elysia plugin for AbsoluteJS",
5
5
  "repository": {
6
6
  "type": "git",