@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 +54 -1
- package/README.md +5 -0
- package/dist/call.js +20 -6
- package/dist/client.js +40 -5
- package/dist/errors.d.ts +13 -4
- package/dist/errors.js +13 -0
- package/package.json +5 -2
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.
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
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
|
|
9
|
-
*
|
|
10
|
-
|
|
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.
|
|
4
|
-
"description": "Embed the Pouchy companion
|
|
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",
|