@pouchy_ai/companion-sdk 0.34.0 → 0.36.0

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/src/call.ts CHANGED
@@ -187,6 +187,24 @@ export interface CompanionCall {
187
187
  /** Inject an out-of-band context line. `speak: true` makes the companion react
188
188
  * now; false stages it for the next turn. */
189
189
  injectEvent: (text: string, speak?: boolean) => void;
190
+ /** SILENT CUT (0.36.0): stop the utterance currently being spoken WITHOUT
191
+ * generating any new speech — the "stale commentary at a beat boundary"
192
+ * case (field request: fast-paced game, last hand's commentary still
193
+ * playing when the next hand starts). Idempotent: a no-op when nothing is
194
+ * playing, after close(), and mid-connect.
195
+ *
196
+ * Provider tiers (check `provider`):
197
+ * - `openai-realtime` — GUARANTEED: cancels the active response and clears
198
+ * the WebRTC output buffer (`response.cancel` + `output_audio_buffer.clear`);
199
+ * playback stops within ~an audio frame. The session continues normally.
200
+ * - `elevenlabs-convai` — BEST-EFFORT: the EL client exposes no public
201
+ * "stop playback" API; we signal `user_activity` (halts the agent's
202
+ * stream when the agent's interruption handling allows) and attempt the
203
+ * client's internal output-buffer cut when the installed build exposes
204
+ * it. Verify on your rig; worst case it degrades to a no-op — for a
205
+ * guaranteed preemption on EL, `injectEvent(text, true)` (which DOES
206
+ * speak new content) remains the reliable lever. */
207
+ interrupt: () => void;
190
208
  provider: CallCredentials['provider'];
191
209
  }
192
210
 
@@ -296,6 +314,12 @@ async function openOpenAICall(
296
314
  }, MIC_UNMUTE_TAIL_MS);
297
315
  };
298
316
 
317
+ // Whether the model is audibly speaking right now (driven by the
318
+ // output_audio_buffer events below). Gates interrupt(): cancelling with no
319
+ // active response makes the server emit a benign-but-noisy error event, so
320
+ // an idle interrupt() must be a true no-op.
321
+ let oaiSpeaking = false;
322
+
299
323
  const dc = pc.createDataChannel('oai-events');
300
324
  // `response.function_call_arguments.done` carries the call_id + args but often
301
325
  // not the name — the name arrives earlier on `response.output_item.added`; track it.
@@ -396,6 +420,7 @@ async function openOpenAICall(
396
420
  ) {
397
421
  opts.onTranscript?.({ role: 'user', text: msg.transcript });
398
422
  } else if (msg.type === 'output_audio_buffer.started') {
423
+ oaiSpeaking = true;
399
424
  opts.onSpeakingChange?.(true);
400
425
  // Model started speaking → mute the mic so its own audio can't
401
426
  // echo-transcribe into a self-barge-in. bargeIn opts out (B wave):
@@ -411,6 +436,7 @@ async function openOpenAICall(
411
436
  // and re-open the mic after a short tail so the last bit of the model's
412
437
  // audio doesn't get captured the instant we unmute. Several event names
413
438
  // can end a turn; any of them re-arms the mic.
439
+ oaiSpeaking = false;
414
440
  opts.onSpeakingChange?.(false);
415
441
  if (opts.bargeIn !== true) scheduleMicUnmute();
416
442
  }
@@ -548,6 +574,21 @@ async function openOpenAICall(
548
574
  })
549
575
  );
550
576
  if (speak) dc.send(JSON.stringify({ type: 'response.create' }));
577
+ },
578
+ interrupt: () => {
579
+ // Silent cut: stop generating (response.cancel) AND flush what's
580
+ // already buffered for playback (output_audio_buffer.clear — the
581
+ // WebRTC-mode control for exactly this). Order matters: cancel first
582
+ // so no new audio lands behind the clear. The `.cleared` event echoes
583
+ // back through the handler above, which resets the speaking state and
584
+ // re-arms the mic like any natural end of turn.
585
+ if (closed || !oaiSpeaking || dc.readyState !== 'open') return;
586
+ try {
587
+ dc.send(JSON.stringify({ type: 'response.cancel' }));
588
+ dc.send(JSON.stringify({ type: 'output_audio_buffer.clear' }));
589
+ } catch {
590
+ /* channel gone — the call is tearing down anyway */
591
+ }
551
592
  }
552
593
  };
553
594
  }
@@ -583,6 +624,9 @@ async function openConvaiCall(
583
624
  }
584
625
 
585
626
  let closed = false;
627
+ // Whether the agent is audibly speaking (onModeChange) — gates interrupt()
628
+ // so an idle call stays a true no-op.
629
+ let elSpeaking = false;
586
630
  // Half-duplex mic (mirrors the first-party path, #1524): mute while the agent
587
631
  // speaks so its own voice can't echo-transcribe into a self-barge-in (the SDK
588
632
  // voice "说一句就断了" report), re-open after a short tail. Convai exposes
@@ -612,6 +656,7 @@ async function openConvaiCall(
612
656
  onModeChange: ({ mode }: { mode: string }) => {
613
657
  if (closed) return;
614
658
  const speaking = mode === 'speaking';
659
+ elSpeaking = speaking;
615
660
  opts.onSpeakingChange?.(speaking);
616
661
  // bargeIn opts out of half-duplex (B wave): the mic stays open and
617
662
  // ElevenLabs' native interruption handling takes over.
@@ -694,6 +739,31 @@ async function openConvaiCall(
694
739
  } catch {
695
740
  /* channel not ready */
696
741
  }
742
+ },
743
+ interrupt: () => {
744
+ // BEST-EFFORT silent cut — the EL client has no public "stop playback"
745
+ // API (interruption is normally server-driven: a server `interruption`
746
+ // event → the client's internal OutputController.interrupt()). Two
747
+ // guarded levers, both no-ops when unavailable:
748
+ // 1. user_activity: tells the agent the user is acting; with the
749
+ // agent's interruption handling enabled the server halts the
750
+ // current utterance WITHOUT generating a reply (unlike
751
+ // sendUserMessage, which speaks new content).
752
+ // 2. The internal output cut, when the installed client build exposes
753
+ // it — same guarded-internal doctrine as setMicMuted above.
754
+ if (closed || !elSpeaking) return;
755
+ try {
756
+ (conv as unknown as { sendUserActivity?: () => void }).sendUserActivity?.();
757
+ } catch {
758
+ /* not supported on this client build */
759
+ }
760
+ try {
761
+ (
762
+ conv as unknown as { output?: { interrupt?: (resetDurationMs?: number) => void } }
763
+ ).output?.interrupt?.();
764
+ } catch {
765
+ /* internal shape changed — the user_activity lever above still applies */
766
+ }
697
767
  }
698
768
  };
699
769
  }
package/src/client.ts CHANGED
@@ -122,6 +122,16 @@ export interface CompanionClientOptions {
122
122
  * silently), else an in-memory page-lifetime store. The key is
123
123
  * `pouchy-outbox:${surface}`, so each surface keeps its own queue. */
124
124
  outboxStore?: OutboxStore;
125
+ /** Deadline for a request to produce RESPONSE HEADERS, in ms (0.35.0).
126
+ * Bounds every HTTP call the client makes (connect, sendText, recall, …) so
127
+ * a server that accepts the TCP connection but never answers can't hang a
128
+ * helper forever — previously only `sendText({ awaitReply })` had any
129
+ * deadline. Reading a response BODY (the SSE event stream, a streaming
130
+ * sendText reply) is never bounded by this — the timer is cleared the
131
+ * moment headers arrive. Composes with any per-call AbortSignal (whichever
132
+ * fires first wins); the timeout rejects as a status-0 CompanionError with
133
+ * code 'request_timeout'. Default 30_000; 0 disables. */
134
+ requestTimeoutMs?: number;
125
135
  }
126
136
 
127
137
  export interface HelloAck {
@@ -414,6 +424,10 @@ export class CompanionClient {
414
424
  private readonly outboxHandlers = new Set<(items: readonly QueuedSend[]) => void>();
415
425
  // In-flight flush, shared so concurrent triggers run ONE flush at a time.
416
426
  private outboxFlush: Promise<{ sent: number; remaining: number }> | null = null;
427
+ /** Epoch-ms before which flush attempts are skipped — set from a 429's
428
+ * Retry-After during replay so the auto-flush on every reconnect doesn't
429
+ * re-hammer a server that just told us to back off (0.35.0). */
430
+ private outboxBlockedUntil = 0;
417
431
 
418
432
  constructor(opts: CompanionClientOptions) {
419
433
  if (!opts.baseUrl)
@@ -530,6 +544,48 @@ export class CompanionClient {
530
544
  return major > 1 || (major === 1 && minor >= 1);
531
545
  }
532
546
 
547
+ /** doFetch raced against the `requestTimeoutMs` HEADERS deadline (0.35.0).
548
+ * The deadline only bounds time-to-response-headers: it does NOT swap the
549
+ * request's AbortSignal, so a streaming response body stays tied to the
550
+ * caller's own signal, and the timer is cleared the moment the race
551
+ * settles. On timeout the helper rejects `request_timeout` (status 0) —
552
+ * code-bearing, so the offline queue's network-failure predicate correctly
553
+ * ignores it (the server may have received the request). The abandoned
554
+ * fetch keeps its rejection silenced so a late network error can't surface
555
+ * as an unhandled rejection. */
556
+ private async doFetchBounded(
557
+ url: string,
558
+ init: RequestInit & { headers: Record<string, string> }
559
+ ): Promise<Response> {
560
+ const budget = this.opts.requestTimeoutMs ?? 30_000;
561
+ if (!budget) return this.doFetch(url, init);
562
+ const req = this.doFetch(url, init);
563
+ let timer: ReturnType<typeof setTimeout> | undefined;
564
+ try {
565
+ return await Promise.race([
566
+ req,
567
+ new Promise<never>((_, reject) => {
568
+ timer = setTimeout(
569
+ () =>
570
+ reject(
571
+ new CompanionError(
572
+ `request timed out after ${budget}ms waiting for response headers`,
573
+ 0,
574
+ 'request_timeout'
575
+ )
576
+ ),
577
+ budget
578
+ );
579
+ })
580
+ ]);
581
+ } catch (e) {
582
+ req.catch(() => {});
583
+ throw e;
584
+ } finally {
585
+ if (timer !== undefined) clearTimeout(timer);
586
+ }
587
+ }
588
+
533
589
  private async fetchAuthed(
534
590
  url: string,
535
591
  init: RequestInit & { headers: Record<string, string> }
@@ -540,7 +596,7 @@ export class CompanionClient {
540
596
  this.dbg({ kind: 'request', at: startedAt, method, path });
541
597
  let res: Response;
542
598
  try {
543
- res = await this.doFetch(url, init);
599
+ res = await this.doFetchBounded(url, init);
544
600
  } catch (e) {
545
601
  // A caller-supplied AbortSignal makes fetch throw a native AbortError —
546
602
  // convert it at this single choke point so cancellation honors the
@@ -570,7 +626,7 @@ export class CompanionClient {
570
626
  const retryAt = Date.now();
571
627
  this.dbg({ kind: 'request', at: retryAt, method, path });
572
628
  try {
573
- const retryRes = await this.doFetch(url, init);
629
+ const retryRes = await this.doFetchBounded(url, init);
574
630
  this.dbg({
575
631
  kind: 'response',
576
632
  at: Date.now(),
@@ -856,6 +912,12 @@ export class CompanionClient {
856
912
  turnId?: string
857
913
  ): Promise<SendTextReply> {
858
914
  const timeoutMs = opts?.replyTimeoutMs ?? 60_000;
915
+ // Already-aborted fast-fail comes FIRST (0.35.0): it used to sit after the
916
+ // companion.message subscription below, whose cleanup lives in the finally
917
+ // of the try we never entered — every call with a spent signal leaked one
918
+ // permanent handler (a host reusing one AbortController across turns
919
+ // accumulated them without bound).
920
+ if (opts?.signal?.aborted) throw new CompanionError('request aborted', 0, 'aborted');
859
921
  let resolveFallback!: (env: CompanionEnvelope) => void;
860
922
  const fallback = new Promise<CompanionEnvelope>((res) => (resolveFallback = res));
861
923
  // Correlate: resolve only on THIS turn's reply (`replyTo === turnId`).
@@ -874,10 +936,9 @@ export class CompanionClient {
874
936
  const ac = new AbortController();
875
937
  // Link the caller's signal: aborting it tears down the in-flight stream
876
938
  // (via ac) AND rejects the whole operation with `aborted`, exactly like
877
- // the deadline does. If it is already aborted, fail fast.
939
+ // the deadline does.
878
940
  let onCallerAbort: (() => void) | undefined;
879
941
  if (opts?.signal) {
880
- if (opts.signal.aborted) throw new CompanionError('request aborted', 0, 'aborted');
881
942
  onCallerAbort = () => ac.abort();
882
943
  opts.signal.addEventListener('abort', onCallerAbort, { once: true });
883
944
  }
@@ -1066,7 +1127,14 @@ export class CompanionClient {
1066
1127
  } catch {
1067
1128
  /* stream already finished */
1068
1129
  }
1069
- throw e;
1130
+ // Normalize (0.35.0): a mid-stream network drop rejects reader.read()
1131
+ // with a NATIVE TypeError. Rethrowing it raw broke the "every helper
1132
+ // rejects with CompanionError" contract AND slipped past the offline
1133
+ // queue's isNetworkSendFailure (which requires a code-less status-0
1134
+ // CompanionError) — the exact loss queueOffline exists to prevent.
1135
+ // Queueing the replay is safe: it reuses the ORIGINAL turnId and the
1136
+ // server dedupes completed turns, so a turn that did run isn't re-run.
1137
+ throw this.asCompanionError(e);
1070
1138
  }
1071
1139
  }
1072
1140
 
@@ -1152,6 +1220,10 @@ export class CompanionClient {
1152
1220
  private async runOutboxFlush(): Promise<{ sent: number; remaining: number }> {
1153
1221
  const id = this.requireSession();
1154
1222
  const items = this.loadOutbox();
1223
+ // Respect the last replay 429's Retry-After (0.35.0): the flush re-arms
1224
+ // on EVERY stream reconnect, so without this gate a rate-limited server
1225
+ // got re-hammered on each reconnect despite naming its own window.
1226
+ if (Date.now() < this.outboxBlockedUntil) return { sent: 0, remaining: items.length };
1155
1227
  let sent = 0;
1156
1228
  while (items.length) {
1157
1229
  const item = items[0];
@@ -1173,6 +1245,10 @@ export class CompanionClient {
1173
1245
  this.commitOutbox();
1174
1246
  continue;
1175
1247
  }
1248
+ // A throttle names its own retry window — honor it across flushes.
1249
+ if (status === 429 && e instanceof CompanionError && typeof e.retryAfter === 'number') {
1250
+ this.outboxBlockedUntil = Date.now() + e.retryAfter * 1000;
1251
+ }
1176
1252
  // Unreachable again (or throttled / a server-side failure): keep
1177
1253
  // this item and everything behind it for the next flush.
1178
1254
  break;
@@ -1513,7 +1589,10 @@ export class CompanionClient {
1513
1589
  * arrive as `control.error`. Since 0.30.0 `code` is typed as
1514
1590
  * `ControlErrorCodeValue` (the CONTROL_ERROR_CODES vocabulary + the
1515
1591
  * SDK-synthesized stream codes, open for newer servers) so a `switch` on it
1516
- * autocompletes. Returns an unsubscribe fn. */
1592
+ * autocompletes. A malformed frame with no string `code` is delivered with
1593
+ * the literal fallback `'error'` (not part of the exported vocabulary) —
1594
+ * give your `switch` a `default:` arm rather than enumerating every code.
1595
+ * Returns an unsubscribe fn. */
1517
1596
  onError(
1518
1597
  handler: (
1519
1598
  err: { code: ControlErrorCodeValue; message: string },
@@ -1970,13 +2049,19 @@ export class CompanionClient {
1970
2049
  let backoff = 500;
1971
2050
  while (this.streaming && gen === this.streamGen) {
1972
2051
  let errored = false;
1973
- const connectedAt = Date.now();
2052
+ // Stamped in onopen (0.35.0) — measuring from before the constructor
2053
+ // counted DNS/TCP/handshake time as "connection lifetime", so a gateway
2054
+ // that took ~2s to accept and then instantly closed read as long-lived
2055
+ // and RESET the backoff (a near-hot reconnect loop). 0 = never opened,
2056
+ // which always backs off.
2057
+ let openedAt = 0;
1974
2058
  try {
1975
2059
  await new Promise<void>((resolve, reject) => {
1976
2060
  const ws = new WS(deriveWsStreamUrl(this.opts.baseUrl, id, this.opts.token, this.cursor));
1977
2061
  let poll: ReturnType<typeof setInterval> | undefined;
1978
2062
  let settled = false;
1979
2063
  ws.onopen = () => {
2064
+ openedAt = Date.now();
1980
2065
  if (this.streaming && gen === this.streamGen) this.setStreamState('connected');
1981
2066
  };
1982
2067
  const settle = (err?: unknown) => {
@@ -2029,7 +2114,7 @@ export class CompanionClient {
2029
2114
  // back off like the SSE loop, or this reconnects in a zero-delay hot
2030
2115
  // loop. A connection that lived a while resets the backoff.
2031
2116
  if (this.streaming && gen === this.streamGen) this.setStreamState('reconnecting');
2032
- if (Date.now() - connectedAt < 2000) {
2117
+ if (openedAt === 0 || Date.now() - openedAt < 2000) {
2033
2118
  await this.backoffSleep(backoff);
2034
2119
  backoff = Math.min(backoff * 2, 8000);
2035
2120
  } else {
@@ -2194,13 +2279,33 @@ export class CompanionClient {
2194
2279
  const reader = body.getReader();
2195
2280
  const decoder = new TextDecoder();
2196
2281
  let buf = '';
2197
- while (this.streaming && gen === this.streamGen) {
2198
- const { done, value } = await reader.read();
2199
- if (done) break;
2200
- buf += decoder.decode(value, { stream: true });
2201
- const { events, rest } = parseSse(buf);
2202
- buf = rest;
2203
- for (const ev of events) this.handleFrame(ev.event, ev.data);
2282
+ // Liveness watchdog (0.35.0). The server writes a `: ping` comment every
2283
+ // ~1.5s and closes each stream window at 45s — a healthy connection is
2284
+ // NEVER byte-silent for long. On a silent half-open TCP drop (NAT/LB
2285
+ // idle-kill, laptop sleep) no FIN ever arrives: reader.read() blocks for
2286
+ // minutes while streamState still reads 'connected' and every reply is
2287
+ // silently lost. 10× the ping interval without a single byte can only be
2288
+ // a dead connection cancel the reader so read() returns and the loop
2289
+ // reconnects through its normal backoff + cursor resume (replays dedupe
2290
+ // by envelope id). Only the SSE plane gets this: the WS plane has no
2291
+ // heartbeat contract, so an idle-but-healthy socket can't be told apart.
2292
+ const SSE_STALL_MS = 15_000;
2293
+ let lastByteAt = Date.now();
2294
+ const watchdog = setInterval(() => {
2295
+ if (Date.now() - lastByteAt > SSE_STALL_MS) void reader.cancel().catch(() => {});
2296
+ }, 5_000);
2297
+ try {
2298
+ while (this.streaming && gen === this.streamGen) {
2299
+ const { done, value } = await reader.read();
2300
+ if (done) break;
2301
+ lastByteAt = Date.now();
2302
+ buf += decoder.decode(value, { stream: true });
2303
+ const { events, rest } = parseSse(buf);
2304
+ buf = rest;
2305
+ for (const ev of events) this.handleFrame(ev.event, ev.data);
2306
+ }
2307
+ } finally {
2308
+ clearInterval(watchdog);
2204
2309
  }
2205
2310
  }
2206
2311
 
package/src/errors.ts CHANGED
@@ -21,7 +21,8 @@ export const SDK_SYNTHESIZED_ERROR_CODES = [
21
21
  'call_connect_failed', // WebRTC/provider handshake failed
22
22
  'call_dependency_missing', // optional voice dependency not installed
23
23
  'aborted', // a caller-supplied AbortSignal cancelled the request (0.29.0)
24
- 'queued_offline' // queueOffline: the text was queued while offline and will send on reconnect (0.34.0)
24
+ 'queued_offline', // queueOffline: the text was queued while offline and will send on reconnect (0.34.0)
25
+ 'request_timeout' // requestTimeoutMs elapsed before response headers arrived (0.35.0)
25
26
  ] as const;
26
27
  export type SdkSynthesizedErrorCode = (typeof SDK_SYNTHESIZED_ERROR_CODES)[number];
27
28