@pouchy_ai/companion-sdk 0.28.0 → 0.28.1

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
@@ -12,6 +12,42 @@ a protocol bump is always called out explicitly here.
12
12
 
13
13
  ## [Unreleased]
14
14
 
15
+ ## [0.28.1] - 2026-07-12
16
+
17
+ ### Fixed
18
+
19
+ - **WebSocket reconnect storm on short-lived connections.** A gateway that
20
+ accepted the socket and immediately closed it cleanly (auth-expressed-as-
21
+ close, LB idle policy, misconfigured proxy) made `stream: 'websocket'`
22
+ reconnect in a zero-delay hot loop. A connection that lived under ~2s now
23
+ sits out the same 500ms→8s doubling backoff the SSE loop uses (a long-lived
24
+ connection still reconnects immediately).
25
+ - **A throwing event handler no longer poisons the stream loop.** Handler
26
+ exceptions used to propagate into the SSE/WS loop's catch — read as a
27
+ CONNECTION failure (backoff + reconnect + cursor replay) — and, on the
28
+ `sendText` local-emit path, rejected a `sendText` whose turn had actually
29
+ succeeded. Handlers are now isolated per-call (logged, remaining handlers
30
+ still run).
31
+ - **Custom voice-tool bridges may throw.** With `openCompanionCall` and a
32
+ bring-your-own bridge whose `invoke` rejected, the model never received a
33
+ `function_call_output` — the voice turn hung until the provider timeout,
34
+ plus an unhandled rejection. A rejecting bridge now answers the model with
35
+ `{ ok: false, error }`.
36
+ - Timer hygiene: the 20s voice-tool timeout and the 10s microphone-acquire
37
+ timeout are now cleared on normal completion instead of lingering.
38
+
39
+ ### Changed
40
+
41
+ - **`CompanionErrorCodeValue` is now DERIVED** from the drift-tested server
42
+ vocabulary plus the SDK-synthesized list, instead of hand-retyped — the old
43
+ copy had silently drifted 5 codes behind (`not_representative`,
44
+ `call_unsupported`, `call_connect_failed`, `call_dependency_missing` now
45
+ autocomplete). A new drift test pins every `new CompanionError(...)` call
46
+ site to a declared code. Types-only; no runtime behavior change.
47
+ - `package.json` now declares `"engines": { "node": ">=18" }` and the README
48
+ documents that the package ships **ESM-only** (modern bundlers and Node ≥ 18
49
+ consume it as-is; there is no CJS build).
50
+
15
51
  ## [0.28.0] - 2026-07-12
16
52
 
17
53
  ### Added
@@ -563,7 +599,24 @@ self-hosted/CDN build; this entry captures the surface that ships publicly.
563
599
  - **Versioned wire protocol.** `PROTOCOL_VERSION = 1`, kept in lockstep with the
564
600
  server by `protocol.drift.test.ts` (fails CI on divergence).
565
601
 
566
- [Unreleased]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.12.1...HEAD
602
+ [Unreleased]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.28.1...HEAD
603
+ [0.28.1]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.28.0...companion-sdk-v0.28.1
604
+ [0.28.0]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.27.0...companion-sdk-v0.28.0
605
+ [0.27.0]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.26.1...companion-sdk-v0.27.0
606
+ [0.26.1]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.26.0...companion-sdk-v0.26.1
607
+ [0.26.0]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.25.0...companion-sdk-v0.26.0
608
+ [0.25.0]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.24.0...companion-sdk-v0.25.0
609
+ [0.24.0]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.23.0...companion-sdk-v0.24.0
610
+ [0.23.0]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.22.0...companion-sdk-v0.23.0
611
+ [0.22.0]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.21.0...companion-sdk-v0.22.0
612
+ [0.21.0]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.20.0...companion-sdk-v0.21.0
613
+ [0.20.0]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.19.1...companion-sdk-v0.20.0
614
+ [0.19.1]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.19.0...companion-sdk-v0.19.1
615
+ [0.19.0]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.18.1...companion-sdk-v0.19.0
616
+ [0.18.1]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.18.0...companion-sdk-v0.18.1
617
+ [0.18.0]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.17.0...companion-sdk-v0.18.0
618
+ [0.17.0]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.13.0...companion-sdk-v0.17.0
619
+ [0.13.0]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.12.1...companion-sdk-v0.13.0
567
620
  [0.12.1]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.12.0...companion-sdk-v0.12.1
568
621
  [0.12.0]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.11.0...companion-sdk-v0.12.0
569
622
  [0.11.0]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.10.1...companion-sdk-v0.11.0
package/README.md CHANGED
@@ -16,6 +16,10 @@ npm i @pouchy_ai/companion-sdk
16
16
 
17
17
  No bundler? Pull it from a CDN via an import map instead — see
18
18
  [`docs/companion-integration-faq.md`](../../docs/companion-integration-faq.md).
19
+
20
+ The package ships **ESM-only** (`"type": "module"`, no CJS build). Every modern
21
+ bundler and Node ≥ 18 consumes it as-is; on Node ≥ 20.17 even `require()` of the
22
+ ESM entry works natively.
19
23
  Changes per release are tracked in [`CHANGELOG.md`](./CHANGELOG.md).
20
24
 
21
25
  **React?** [`@pouchy_ai/react`](../react-sdk/README.md) wraps this SDK in a
@@ -76,6 +80,7 @@ Subscribe with `on(type, fn)` (or `'*'`), or these typed convenience helpers:
76
80
  | `onExpression(fn)` | `companion.expression` | avatar viseme / expression / gesture. _(reserved — not emitted yet)_ |
77
81
  | `onVoiceInject(fn)` | `companion.voice_inject` | `fn({text, speak})` — a `voiceRelevant` world-state line to say aloud during a live call; route `text` to your voice session when `speak` |
78
82
  | `onTyping(fn)` | `companion.typing` | activity indicator — `fn({active})` fires `true` when a turn starts working and `false` when it finishes / pauses, spanning the tool-loop / thinking phase before the first text delta. Drive a "typing…" state |
83
+ | `on('control.call_ready', fn)` | `control.call_ready` | a voice call is ready — the stream echo of `startCall`'s accept. Deliberately **secret-free** (`{ provider, agentId/model, voice, … }` — the actual WebRTC credentials only ride the `startCall` HTTP response); useful for UI state on surfaces that didn't initiate the call |
79
84
  | `onUsage(fn)` | `control.usage` | per-token metering echo. _(reserved — not emitted yet)_ |
80
85
  | `onError(fn)` | `control.error` | agent / stream errors — `agent_error` (server-side turn failed after accept; safe to re-send), `call_mint_failed` (voice-credential mint failed — retry later or fall back to text) or the SDK-synthesized `stream_unauthorized` (stream 401 exhausted reconnects — refresh the token, `start()` again) |
81
86
 
package/dist/call.js CHANGED
@@ -175,11 +175,18 @@ async function getMicWithTimeout() {
175
175
  m.getTracks().forEach((t) => t.stop());
176
176
  })
177
177
  .catch(() => { });
178
- const timeout = new Promise((_, reject) => setTimeout(() => {
179
- timedOut = true;
180
- reject(new CompanionError('microphone request timed out', 0, 'call_connect_failed'));
181
- }, GUM_TIMEOUT_MS));
182
- return Promise.race([micPromise, timeout]);
178
+ let timer;
179
+ const timeout = new Promise((_, reject) => {
180
+ timer = setTimeout(() => {
181
+ timedOut = true;
182
+ reject(new CompanionError('microphone request timed out', 0, 'call_connect_failed'));
183
+ }, GUM_TIMEOUT_MS);
184
+ });
185
+ // Clear the timer when the mic wins the race — it held a live 10s closure.
186
+ return Promise.race([micPromise, timeout]).finally(() => {
187
+ if (timer)
188
+ clearTimeout(timer);
189
+ });
183
190
  }
184
191
  // ── OpenAI Realtime — self-contained WebRTC ─────────────────────────────────
185
192
  async function openOpenAICall(creds, opts, bridge) {
@@ -289,7 +296,14 @@ async function openOpenAICall(creds, opts, bridge) {
289
296
  callNames.delete(callId);
290
297
  if (name && bridge.tools.some((t) => t.name === name)) {
291
298
  const argsText = typeof msg.arguments === 'string' ? msg.arguments : '{}';
292
- void bridge.invoke(name, argsText).then((output) => {
299
+ // invokeVoiceTool never rejects, but openCompanionCall is a public
300
+ // export (bring-your-own bridge) — a throwing consumer bridge must
301
+ // still answer the model, or the voice turn hangs until provider
302
+ // timeout (plus an unhandled rejection).
303
+ void bridge
304
+ .invoke(name, argsText)
305
+ .catch((e) => JSON.stringify({ ok: false, error: e instanceof Error ? e.message : 'tool failed' }))
306
+ .then((output) => {
293
307
  try {
294
308
  dc.send(JSON.stringify({
295
309
  type: 'conversation.item.create',
package/dist/client.js CHANGED
@@ -674,15 +674,20 @@ export class CompanionClient {
674
674
  const id = `vc_${this.randomId()}`;
675
675
  return new Promise((resolve) => {
676
676
  let settled = false;
677
+ let timer;
677
678
  const finish = (out) => {
678
679
  if (settled)
679
680
  return;
680
681
  settled = true;
682
+ // Clear on normal completion too — a chatty tool-using call was
683
+ // accumulating a live 20s timer (+closure) per invocation.
684
+ if (timer)
685
+ clearTimeout(timer);
681
686
  this.pendingVoiceTools.delete(id);
682
687
  resolve(out);
683
688
  };
684
689
  this.pendingVoiceTools.set(id, finish);
685
- setTimeout(() => finish(JSON.stringify({ ok: false, error: 'tool result timed out' })), 20_000);
690
+ timer = setTimeout(() => finish(JSON.stringify({ ok: false, error: 'tool result timed out' })), 20_000);
686
691
  this.emit({
687
692
  v: PROTOCOL_VERSION,
688
693
  id: `evt_${this.randomId()}`,
@@ -1040,8 +1045,10 @@ export class CompanionClient {
1040
1045
  // must not silently end reply delivery. Only a connection ERROR degrades to
1041
1046
  // SSE, and only once. Each connection's poll timer + socket are released in
1042
1047
  // the settle() finally so neither leaks after fallback/reconnect.
1048
+ let backoff = 500;
1043
1049
  while (this.streaming && gen === this.streamGen) {
1044
1050
  let errored = false;
1051
+ const connectedAt = Date.now();
1045
1052
  try {
1046
1053
  await new Promise((resolve, reject) => {
1047
1054
  const ws = new WS(deriveWsStreamUrl(this.opts.baseUrl, id, this.opts.token, this.cursor));
@@ -1095,6 +1102,17 @@ export class CompanionClient {
1095
1102
  return;
1096
1103
  }
1097
1104
  // Clean close: the while-guard reconnects if still streaming, else exits.
1105
+ // A SHORT-LIVED connection (gateway that accepts then immediately
1106
+ // closes — auth-as-close, LB idle policy, misconfigured proxy) must
1107
+ // back off like the SSE loop, or this reconnects in a zero-delay hot
1108
+ // loop. A connection that lived a while resets the backoff.
1109
+ if (Date.now() - connectedAt < 2000) {
1110
+ await this.sleep(backoff);
1111
+ backoff = Math.min(backoff * 2, 8000);
1112
+ }
1113
+ else {
1114
+ backoff = 500;
1115
+ }
1098
1116
  }
1099
1117
  }
1100
1118
  /** Stop the event stream. */
@@ -1137,10 +1155,27 @@ export class CompanionClient {
1137
1155
  return;
1138
1156
  }
1139
1157
  }
1140
- for (const h of this.handlers.get(envelope.type) ?? [])
1141
- h(envelope);
1142
- for (const h of this.handlers.get('*') ?? [])
1143
- h(envelope);
1158
+ // Each handler is isolated: a THROWING consumer handler must not skip the
1159
+ // remaining handlers, and — worse — it used to propagate up through the
1160
+ // stream loop's catch, which read it as a CONNECTION failure (backoff +
1161
+ // reconnect + cursor replay); on the sendText local-emit path it rejected
1162
+ // a sendText whose turn had actually succeeded.
1163
+ for (const h of this.handlers.get(envelope.type) ?? []) {
1164
+ try {
1165
+ h(envelope);
1166
+ }
1167
+ catch (e) {
1168
+ console.error('[companion-sdk] event handler threw', e);
1169
+ }
1170
+ }
1171
+ for (const h of this.handlers.get('*') ?? []) {
1172
+ try {
1173
+ h(envelope);
1174
+ }
1175
+ catch (e) {
1176
+ console.error('[companion-sdk] event handler threw', e);
1177
+ }
1178
+ }
1144
1179
  }
1145
1180
  handleFrame(event, data) {
1146
1181
  if (event === 'reconnect') {
package/dist/errors.d.ts CHANGED
@@ -1,13 +1,22 @@
1
+ import type { CompanionErrorCode } from './protocol.js';
2
+ /** Codes the SDK itself synthesizes (status 0 — never sent by the server).
3
+ * Runtime array so the drift test can assert every `new CompanionError(...)`
4
+ * call site uses a declared code; APPEND-ONLY like the server vocabulary. */
5
+ export declare const SDK_SYNTHESIZED_ERROR_CODES: readonly ["reply_timeout", "needs_event_stream", "not_connected", "missing_option", "not_representative", "call_unsupported", "call_connect_failed", "call_dependency_missing"];
6
+ export type SdkSynthesizedErrorCode = (typeof SDK_SYNTHESIZED_ERROR_CODES)[number];
1
7
  /** Thrown by every helper on an HTTP or client-side failure. `status` is the
2
8
  * HTTP status (0 for client-synthesized failures — timeouts, unsupported
3
9
  * environments, missing optional deps); `code` is machine-switchable when
4
10
  * the server (or the SDK itself) names a cause. On a `rate_limited` (429)
5
11
  * failure, `retryAfter` carries the server's Retry-After in SECONDS so a
6
12
  * consumer can back off precisely instead of guessing. */
7
- /** `CompanionError.code` values: the server vocabulary (COMPANION_ERROR_CODES)
8
- * plus the SDK-synthesized ones. The `(string & {})` arm keeps the type open
9
- * for codes newer than this SDK build while preserving autocomplete. */
10
- export type CompanionErrorCodeValue = 'missing_token' | 'invalid_token' | 'missing_scope' | 'invalid_request' | 'session_not_found' | 'turn_pending' | 'no_pending_tools' | 'unknown_call' | 'payload_too_large' | 'rate_limited' | 'forbidden' | 'unavailable' | 'confirm_not_found' | 'confirm_resolved' | 'step_up_required' | 'step_up_failed' | 'reply_timeout' | 'needs_event_stream' | 'not_connected' | 'missing_option' | (string & {});
13
+ /** `CompanionError.code` values: the server vocabulary (COMPANION_ERROR_CODES,
14
+ * drift-tested against api-error.ts) plus the SDK-synthesized ones DERIVED
15
+ * from those runtime lists rather than hand-retyped, so a new code can never
16
+ * silently miss this union again (the old copy had drifted 5 codes behind).
17
+ * The `(string & {})` arm keeps the type open for codes newer than this SDK
18
+ * build while preserving autocomplete. */
19
+ export type CompanionErrorCodeValue = CompanionErrorCode | SdkSynthesizedErrorCode | (string & {});
11
20
  export declare class CompanionError extends Error {
12
21
  status: number;
13
22
  code?: CompanionErrorCodeValue;
package/dist/errors.js CHANGED
@@ -3,6 +3,19 @@
3
3
  // Lives in its own module so call.ts can use it without a runtime cycle
4
4
  // through client.ts (client.ts re-exports it, keeping the public import path
5
5
  // `@pouchy_ai/companion-sdk` → `CompanionError` unchanged).
6
+ /** Codes the SDK itself synthesizes (status 0 — never sent by the server).
7
+ * Runtime array so the drift test can assert every `new CompanionError(...)`
8
+ * call site uses a declared code; APPEND-ONLY like the server vocabulary. */
9
+ export const SDK_SYNTHESIZED_ERROR_CODES = [
10
+ 'reply_timeout', // awaitReply/replyTimeoutMs elapsed before the reply
11
+ 'needs_event_stream', // turn paused on tools but start() was never called
12
+ 'not_connected', // a call/stream helper was used before connect
13
+ 'missing_option', // a required option was omitted (e.g. sessionId)
14
+ 'not_representative', // pairVisitor on a non-representative token
15
+ 'call_unsupported', // startCall on a server/provider without voice
16
+ 'call_connect_failed', // WebRTC/provider handshake failed
17
+ 'call_dependency_missing' // optional voice dependency not installed
18
+ ];
6
19
  export class CompanionError extends Error {
7
20
  status;
8
21
  code;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pouchy_ai/companion-sdk",
3
- "version": "0.28.0",
4
- "description": "Embed the Pouchy companion chat, voice, tools, memory, live world-state, instant UI, and agent-to-agent messaging in any app, game, or site.",
3
+ "version": "0.28.1",
4
+ "description": "Embed the Pouchy companion \u2014 chat, voice, tools, memory, live world-state, instant UI, and agent-to-agent messaging \u2014 in any app, game, or site.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
7
7
  "homepage": "https://pouchy.ai",
@@ -28,6 +28,9 @@
28
28
  "LICENSE"
29
29
  ],
30
30
  "sideEffects": false,
31
+ "engines": {
32
+ "node": ">=18"
33
+ },
31
34
  "scripts": {
32
35
  "build": "tsc -p tsconfig.json && node scripts/fix-esm-extensions.mjs",
33
36
  "build:cdn": "esbuild ../../src/lib/companion-sdk/index.ts --bundle --format=esm --platform=browser --external:@elevenlabs/client --legal-comments=none --outfile=cdn/companion-sdk.js && node scripts/cdn-banner.mjs",