@oxvo/ai-live-assist 7.5.0 → 7.5.6

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/lib/control.js CHANGED
@@ -3,6 +3,7 @@ const PROTOCOL_VIOLATION_CLOSE_CODE = 4008;
3
3
  const SERVER_SEQUENCE_GAP_CLOSE_CODE = 4009;
4
4
  const HANDLER_FAILURE_CLOSE_CODE = 4010;
5
5
  const HANDLER_TIMEOUT_MS = 15000;
6
+ const TRANSPORT_RECOVERY_TIMEOUT_MS = 5000;
6
7
  const MAX_PENDING_HANDLER_MESSAGES = 64;
7
8
  const MAX_PENDING_STATEFUL_OPERATIONS = 64;
8
9
  const MAX_SETTLED_OPERATION_HISTORY = 100;
@@ -35,13 +36,14 @@ const STATEFUL_CONTROL_TYPES = new Set([
35
36
  ]);
36
37
  const outcomeKey = (messageId, sequence) => `${messageId}\u0000${sequence}`;
37
38
  export class ControlChannel {
38
- constructor(result, tabId, clientVersion, onDisconnect, initialClientSequence = 0, onClientSequence = () => undefined, onUnsafeDisconnect = () => undefined) {
39
+ constructor(result, tabId, clientVersion, onDisconnect, initialClientSequence = 0, onClientSequence = () => undefined, onUnsafeDisconnect = () => undefined, transportRecovery) {
39
40
  this.result = result;
40
41
  this.tabId = tabId;
41
42
  this.clientVersion = clientVersion;
42
43
  this.onDisconnect = onDisconnect;
43
44
  this.onClientSequence = onClientSequence;
44
45
  this.onUnsafeDisconnect = onUnsafeDisconnect;
46
+ this.transportRecovery = transportRecovery;
45
47
  this.socket = null;
46
48
  this.serverSequence = 0;
47
49
  this.serverBaselineEstablished = false;
@@ -64,6 +66,8 @@ export class ControlChannel {
64
66
  this.pendingConnection = null;
65
67
  this.helloOperation = null;
66
68
  this.fencedAuthorityConfirmed = false;
69
+ this.transportRecoveryAttempted = false;
70
+ this.recoveringTransport = false;
67
71
  this.clientSequence =
68
72
  Number.isSafeInteger(initialClientSequence) && initialClientSequence >= 0
69
73
  ? initialClientSequence
@@ -76,7 +80,7 @@ export class ControlChannel {
76
80
  return this.clientSequence;
77
81
  }
78
82
  get isOpen() {
79
- return this.socket?.readyState === WebSocket.OPEN;
83
+ return (!this.recoveringTransport && this.socket?.readyState === WebSocket.OPEN);
80
84
  }
81
85
  get connectionHelloOperation() {
82
86
  return this.helloOperation ? { ...this.helloOperation } : null;
@@ -177,10 +181,19 @@ export class ControlChannel {
177
181
  this.rejectPendingConnection(socket, generation, new Error("The AI Guide control channel could not connect."));
178
182
  }
179
183
  }, { once: true });
180
- socket.addEventListener("close", () => {
184
+ socket.addEventListener("close", (event) => {
181
185
  if (!this.isCurrentSocket(socket, generation))
182
186
  return;
183
187
  const wasIntentionallyClosed = this.intentionallyClosed;
188
+ // A settled transport loss is not a new session. Keep its provider
189
+ // connection alive for one bounded retry, but never carry ambiguous
190
+ // browser writes, an active handler, or a protocol failure across it.
191
+ const canRecover = event.code === 1006 &&
192
+ this.fencedAuthorityConfirmed &&
193
+ !this.transportRecoveryAttempted &&
194
+ !this.sequenceGapDetected &&
195
+ this.pendingHandlerMessages === 0 &&
196
+ this.unsettledStatefulOperations.size === 0;
184
197
  this.socket = null;
185
198
  this.socketGeneration += 1;
186
199
  this.stopHeartbeat();
@@ -194,12 +207,24 @@ export class ControlChannel {
194
207
  this.pendingHandlerMessages = 0;
195
208
  this.rejectPendingConnection(socket, generation, new Error("The AI Guide control channel closed before connecting."));
196
209
  if (!wasIntentionallyClosed && !signal.aborted) {
210
+ try {
211
+ if (canRecover && this.transportRecovery?.canRecover()) {
212
+ void this.recoverTransport(signal);
213
+ return;
214
+ }
215
+ }
216
+ catch {
217
+ /* A failed eligibility check uses full recovery. */
218
+ }
197
219
  this.notifyUnsafeDisconnect();
198
220
  }
199
221
  });
200
222
  });
201
223
  }
202
224
  send(type, payload, stableMessageId) {
225
+ if (this.recoveringTransport && type !== "hello" && type !== "heartbeat") {
226
+ throw new Error("The AI Guide control channel is reconnecting.");
227
+ }
203
228
  if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
204
229
  throw new Error("The AI Guide control channel is not connected.");
205
230
  }
@@ -510,6 +535,59 @@ export class ControlChannel {
510
535
  this.onDisconnect();
511
536
  }
512
537
  }
538
+ async recoverTransport(signal) {
539
+ this.transportRecoveryAttempted = true;
540
+ this.recoveringTransport = true;
541
+ const controller = new AbortController();
542
+ const abort = () => controller.abort();
543
+ signal.addEventListener("abort", abort, { once: true });
544
+ let timeout;
545
+ const deadline = new Promise((_resolve, reject) => {
546
+ timeout = setTimeout(() => {
547
+ controller.abort();
548
+ reject(new Error("The control reconnect timed out."));
549
+ }, TRANSPORT_RECOVERY_TIMEOUT_MS);
550
+ });
551
+ const fencingToken = this.fencingToken;
552
+ let recovered = false;
553
+ try {
554
+ await Promise.race([
555
+ deadline,
556
+ (async () => {
557
+ if (signal.aborted)
558
+ throw new Error("The control connection was retired.");
559
+ await this.connect(controller.signal, true);
560
+ const hello = await this.waitForHelloOutcome(2000);
561
+ if (hello.status !== "ack" || controller.signal.aborted) {
562
+ throw new Error("The control reconnect was not acknowledged.");
563
+ }
564
+ const authority = await this.proveFencedAuthority(2000);
565
+ if (authority.status !== "ack" ||
566
+ controller.signal.aborted ||
567
+ this.fencingToken !== fencingToken ||
568
+ !this.transportRecovery?.canRecover()) {
569
+ throw new Error("The control reconnect no longer owns this session.");
570
+ }
571
+ this.recoveringTransport = false;
572
+ await this.transportRecovery.onRecovered();
573
+ recovered = !controller.signal.aborted && this.hasFencedAuthority;
574
+ })(),
575
+ ]);
576
+ }
577
+ catch {
578
+ // The existing full-resume path revokes local executable authority and
579
+ // handles expired credentials, changed ownership, and longer outages.
580
+ }
581
+ finally {
582
+ clearTimeout(timeout);
583
+ signal.removeEventListener("abort", abort);
584
+ this.recoveringTransport = false;
585
+ if (!recovered && !signal.aborted && !this.intentionallyClosed) {
586
+ this.retireSocket("control reconnect failed");
587
+ this.notifyUnsafeDisconnect();
588
+ }
589
+ }
590
+ }
513
591
  enqueueHandlers(message, socket, generation) {
514
592
  const isCurrent = () => this.isMessageSourceCurrent(socket, generation);
515
593
  if (!isCurrent())
@@ -1,5 +1,5 @@
1
1
  import { type LiveDuplexMediaOptions, type LiveDuplexMediaDependencies } from './liveDuplexMedia.js';
2
- export declare const LIVE_CONTROL_CLIENT_VERSION = "7.5.0";
2
+ export declare const LIVE_CONTROL_CLIENT_VERSION = "7.5.6";
3
3
  export declare const LIVE_CONTROL_CLIENT_CAPABILITIES: readonly ["live_duplex_v1", "live_local_wake_v1", "live_fragment_captions_v1"];
4
4
  declare const phases: readonly ["voice_off", "local_listening", "wake_buffering", "starting", "active", "tentative_idle", "closing", "uncertain", "muted", "suspended", "budget_blocked", "ended"];
5
5
  export type LiveControlState = {
package/lib/media.d.ts CHANGED
@@ -36,6 +36,7 @@ export declare class RealtimeMedia {
36
36
  connect(result: LaunchResult, mode: AssistMode, signal: AbortSignal, offerIdempotencyKey?: string, expectedSessionState?: "active_text" | "active_voice"): Promise<number>;
37
37
  setMuted(muted: boolean): void;
38
38
  get isMuted(): boolean;
39
+ get isConnected(): boolean;
39
40
  setAssistantPlaybackAudible(audible: boolean): void;
40
41
  setManualTurn(manual: boolean): void;
41
42
  beginPushToTalk(): void;
package/lib/media.js CHANGED
@@ -134,6 +134,9 @@ export class RealtimeMedia {
134
134
  get isMuted() {
135
135
  return this.muted;
136
136
  }
137
+ get isConnected() {
138
+ return !this.closed && this.connection?.connectionState === "connected";
139
+ }
137
140
  setAssistantPlaybackAudible(audible) {
138
141
  if (this.closed)
139
142
  return;
package/lib/types.d.ts CHANGED
@@ -331,6 +331,7 @@ export type SanitizedTarget = {
331
331
  inputType?: string;
332
332
  autocomplete?: string;
333
333
  semanticRegion?: string;
334
+ navigationMenu?: true;
334
335
  destinationPath?: string;
335
336
  sensitivity: Sensitivity;
336
337
  protectedRegion?: boolean;
@@ -356,6 +357,7 @@ export type PageContextSnapshot = {
356
357
  pageId: string;
357
358
  revision: number;
358
359
  url: string;
360
+ locationHasQuery?: boolean;
359
361
  title: string;
360
362
  locale: string;
361
363
  viewport: {
package/lib/ui.js CHANGED
@@ -6790,7 +6790,9 @@ export class WidgetView {
6790
6790
  this.setActionStateRunning(status === "running");
6791
6791
  if (this.actionStateRunning) {
6792
6792
  this.clearAssistantReadyVisualTimer();
6793
+ const previousState = this.assistantState;
6793
6794
  this.assistantState = "thinking";
6795
+ this.updateRemoteAudioEvidenceExpectation(previousState, "thinking");
6794
6796
  const assistantCaption = this.captions.querySelector('[data-caption="assistant-partial"]');
6795
6797
  if (!assistantCaption || assistantCaption.classList.contains("pending")) {
6796
6798
  this.ensurePendingCaption("assistant", this.messages.thinking);
package/lib/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "7.5.0";
1
+ export declare const VERSION = "7.5.6";
package/lib/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '7.5.0';
1
+ export const VERSION = '7.5.6';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@oxvo/ai-live-assist",
3
3
  "description": "Secure AI Guide browser plugin for OXVO Sessions.",
4
- "version": "7.5.0",
4
+ "version": "7.5.6",
5
5
  "keywords": [
6
6
  "ai-live-assist",
7
7
  "webrtc",