@pouchy_ai/companion-sdk 0.27.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 +110 -1
- package/README.md +13 -3
- package/dist/call.js +31 -7
- package/dist/client.d.ts +12 -1
- package/dist/client.js +249 -91
- package/dist/errors.d.ts +14 -1
- package/dist/errors.js +13 -6
- package/dist/index.d.ts +2 -1
- package/dist/protocol.d.ts +9 -1
- package/dist/protocol.js +5 -1
- package/package.json +5 -2
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,98 @@ 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
|
+
|
|
51
|
+
## [0.28.0] - 2026-07-12
|
|
52
|
+
|
|
53
|
+
### Added
|
|
54
|
+
|
|
55
|
+
- **Turn correlation (`turnId` → `replyTo`).** Every `sendText` now mints a
|
|
56
|
+
`turnId` and sends it with the turn; servers on API ≥ 1.1 echo it back as
|
|
57
|
+
`replyTo` on the reply's `companion.message` — including a reply that lands
|
|
58
|
+
after an app-tool pause. `awaitReply`'s buffered fallback now resolves ONLY
|
|
59
|
+
on the correlated reply on those servers, so a proactive world-state
|
|
60
|
+
reaction or a confirm outcome can no longer steal the resolution (the known
|
|
61
|
+
0.25–0.27 caveat). Against older servers the legacy resolve-on-first
|
|
62
|
+
behavior is kept automatically (capability-detected via
|
|
63
|
+
`X-Pouchy-Api-Version`). New `MessagePayload` type (`{ text, replyTo? }`).
|
|
64
|
+
- **`needs_event_stream` error.** With `awaitReply` and no `start()`, a turn
|
|
65
|
+
that pauses on app tool calls used to wait out the full `replyTimeoutMs`
|
|
66
|
+
and then time out — the post-resume reply can only arrive on the event
|
|
67
|
+
stream. It now rejects immediately with `code: "needs_event_stream"`.
|
|
68
|
+
- **Typed confirm errors.** The confirm/step-up endpoints now carry codes:
|
|
69
|
+
`confirm_not_found`, `confirm_resolved`, `step_up_required`,
|
|
70
|
+
`step_up_failed` (vocabulary is append-only). The client no longer burns an
|
|
71
|
+
`onAuthError` token refresh on a step-up 401.
|
|
72
|
+
- **`sendWorldState` returns `injected` and `reacted`** — whether the batch
|
|
73
|
+
triggered a live-call inject or a proactive text reaction (previously
|
|
74
|
+
discarded).
|
|
75
|
+
- **429 `hint` surfaced.** The demo-budget hint ("provision a project…") is
|
|
76
|
+
appended to the thrown `CompanionError` message instead of being dropped.
|
|
77
|
+
- **`CompanionErrorCodeValue`** — `CompanionError.code` is now typed as the
|
|
78
|
+
known vocabulary union (plus open `string`), so `switch` statements get
|
|
79
|
+
autocomplete and exhaustiveness help.
|
|
80
|
+
|
|
81
|
+
### Fixed
|
|
82
|
+
|
|
83
|
+
- **`replyTimeoutMs` now bounds the WHOLE awaitReply operation.** Previously
|
|
84
|
+
only the buffered-fallback wait was bounded — a stalled streaming response
|
|
85
|
+
could hang the promise indefinitely. The deadline now starts before the
|
|
86
|
+
POST and aborts the in-flight stream on expiry.
|
|
87
|
+
- **`stop()` immediately followed by `start()` no longer leaves two live
|
|
88
|
+
receive loops.** Loops carry a generation token; a stale loop exits instead
|
|
89
|
+
of resurrecting alongside the new one (it double-polled the stream forever).
|
|
90
|
+
- **`connectCall` cleans up on provider-initiated teardown.** A dropped call
|
|
91
|
+
(pc failure / provider disconnect) now runs the same unsubscribe +
|
|
92
|
+
`endSession({ transcript })` consolidation that `close()` runs — previously
|
|
93
|
+
the voice_inject listener leaked and the call was never consolidated into
|
|
94
|
+
memory. The buffered transcript is also capped (drop-oldest 200).
|
|
95
|
+
- **A throwing `onDelta` handler no longer leaks the streaming reader** — the
|
|
96
|
+
stream is cancelled before the error propagates.
|
|
97
|
+
- **`remember`'s `category` is now typed `'relationship' |
|
|
98
|
+
'shared_experience'`** — the only two values the server keeps; anything
|
|
99
|
+
else was silently discarded.
|
|
100
|
+
- **`/input` image constraints now error instead of silently dropping.** A
|
|
101
|
+
non-`data:` URL or a 5th image is a 400 `invalid_request` — previously the
|
|
102
|
+
image was filtered out and the model just never saw it.
|
|
103
|
+
- **OpenAPI drift**: `/input` documents `stream` + `turnId` + image rules;
|
|
104
|
+
confirm 200 documents `exec_failed`/`outcome`/`retryable`; hello.ack lists
|
|
105
|
+
`modalities`/`representative`/`visitorPaired`; `/pair` documents its body.
|
|
106
|
+
|
|
15
107
|
## [0.27.0] - 2026-07-12
|
|
16
108
|
|
|
17
109
|
### Added
|
|
@@ -507,7 +599,24 @@ self-hosted/CDN build; this entry captures the surface that ships publicly.
|
|
|
507
599
|
- **Versioned wire protocol.** `PROTOCOL_VERSION = 1`, kept in lockstep with the
|
|
508
600
|
server by `protocol.drift.test.ts` (fails CI on divergence).
|
|
509
601
|
|
|
510
|
-
[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
|
|
511
620
|
[0.12.1]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.12.0...companion-sdk-v0.12.1
|
|
512
621
|
[0.12.0]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.11.0...companion-sdk-v0.12.0
|
|
513
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
|
|
|
@@ -96,7 +101,7 @@ local/device skills).
|
|
|
96
101
|
|
|
97
102
|
| Method | What it does |
|
|
98
103
|
|---|---|
|
|
99
|
-
| `sendText(text, { awaitReply: true, replyTimeoutMs? })` | Request/response mode: the promise resolves with the completed turn's reply `{ seq, text, envelope }` (`SendTextReply`) — no `onMessage` wiring or hand-rolled turn gate. Works without `start()` (the reply rides the same streaming request)
|
|
104
|
+
| `sendText(text, { awaitReply: true, replyTimeoutMs? })` | Request/response mode: the promise resolves with the completed turn's reply `{ seq, text, envelope }` (`SendTextReply`) — no `onMessage` wiring or hand-rolled turn gate. Works without `start()` when the turn completes in text (the reply rides the same streaming request). A turn that pauses on YOUR declared tools resolves via the event stream, so it DOES need `start()` — without it the promise rejects immediately with `code: 'needs_event_stream'`. `replyTimeoutMs` (default 60s) bounds the whole operation (0.28.0); on expiry it aborts the in-flight stream and rejects with `code: 'reply_timeout'`. Each turn carries a minted `turnId` the server echoes as `replyTo` on the reply (API ≥ 1.1), so the fallback resolves on THE reply — proactive messages can't steal it. |
|
|
100
105
|
| `recall({ query?, limit? })` / `remember({ content, … })` | Read / write the token-scoped memory namespace. `query` runs SEMANTIC search over the facts server-side (embedding-based, ranked fallback); omit it for the plain importance/recency ranking. |
|
|
101
106
|
| `ingestKnowledge({ text, name, … })` / `ingestFile(file)` | Push documents into the user's knowledge base ("My materials"); needs `memory.write:core`. |
|
|
102
107
|
| `history({ limit? })` | Fetch the session transcript — restore chat on reload. |
|
|
@@ -157,9 +162,14 @@ a few client-side codes outside that HTTP vocabulary: `reply_timeout`
|
|
|
157
162
|
exhausted reconnects), and — 0.26.0 — the voice connect-step codes
|
|
158
163
|
`call_unsupported` (no WebRTC/mic in this environment), `call_connect_failed`
|
|
159
164
|
(mic timeout / SDP exchange failed) and `call_dependency_missing`
|
|
160
|
-
(`@elevenlabs/client` not installed).
|
|
165
|
+
(`@elevenlabs/client` not installed). 0.28.0 adds the confirm/step-up codes
|
|
166
|
+
(`confirm_not_found`, `confirm_resolved`, `step_up_required`, `step_up_failed`),
|
|
167
|
+
the client-side `needs_event_stream`, and types `CompanionError.code` as the
|
|
168
|
+
`CompanionErrorCodeValue` union (autocomplete in your `switch`; still open for
|
|
169
|
+
newer servers). Every
|
|
161
170
|
outbound payload has a named type; `ToolCallPayload` (`{ id, name, args }`) types
|
|
162
|
-
the `companion.tool_call` event,
|
|
171
|
+
the `companion.tool_call` event, `MessagePayload` (`{ text, replyTo? }`) types
|
|
172
|
+
`companion.message`, and `onToolCall`'s callback receives
|
|
163
173
|
`ToolCallEvent` — the payload plus `argsJson` (pre-parsed args).
|
|
164
174
|
|
|
165
175
|
For advanced voice hosts, the lower-level `openCompanionCall(client, options)`
|
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) {
|
|
@@ -234,7 +241,17 @@ async function openOpenAICall(creds, opts, bridge) {
|
|
|
234
241
|
const dc = pc.createDataChannel('oai-events');
|
|
235
242
|
// `response.function_call_arguments.done` carries the call_id + args but often
|
|
236
243
|
// not the name — the name arrives earlier on `response.output_item.added`; track it.
|
|
244
|
+
// Cancelled tool calls never reach `arguments.done`, so their entries were
|
|
245
|
+
// never deleted — cap the map (drop-oldest) so a long call can't grow it.
|
|
237
246
|
const callNames = new Map();
|
|
247
|
+
const rememberCallName = (id, name) => {
|
|
248
|
+
callNames.set(id, name);
|
|
249
|
+
if (callNames.size > 64) {
|
|
250
|
+
const first = callNames.keys().next().value;
|
|
251
|
+
if (first !== undefined)
|
|
252
|
+
callNames.delete(first);
|
|
253
|
+
}
|
|
254
|
+
};
|
|
238
255
|
if (bridge?.tools.length) {
|
|
239
256
|
dc.addEventListener('open', () => {
|
|
240
257
|
try {
|
|
@@ -271,7 +288,7 @@ async function openOpenAICall(creds, opts, bridge) {
|
|
|
271
288
|
msg.item?.type === 'function_call' &&
|
|
272
289
|
msg.item.call_id &&
|
|
273
290
|
msg.item.name) {
|
|
274
|
-
|
|
291
|
+
rememberCallName(msg.item.call_id, msg.item.name);
|
|
275
292
|
}
|
|
276
293
|
if (bridge && msg.type === 'response.function_call_arguments.done' && msg.call_id) {
|
|
277
294
|
const callId = msg.call_id;
|
|
@@ -279,7 +296,14 @@ async function openOpenAICall(creds, opts, bridge) {
|
|
|
279
296
|
callNames.delete(callId);
|
|
280
297
|
if (name && bridge.tools.some((t) => t.name === name)) {
|
|
281
298
|
const argsText = typeof msg.arguments === 'string' ? msg.arguments : '{}';
|
|
282
|
-
|
|
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) => {
|
|
283
307
|
try {
|
|
284
308
|
dc.send(JSON.stringify({
|
|
285
309
|
type: 'conversation.item.create',
|
package/dist/client.d.ts
CHANGED
|
@@ -205,6 +205,7 @@ export declare class CompanionClient {
|
|
|
205
205
|
private cursor;
|
|
206
206
|
private streaming;
|
|
207
207
|
private abort;
|
|
208
|
+
private streamGen;
|
|
208
209
|
private readonly handlers;
|
|
209
210
|
private readonly seen;
|
|
210
211
|
private readonly deltaHandlers;
|
|
@@ -224,7 +225,13 @@ export declare class CompanionClient {
|
|
|
224
225
|
/** doFetch with one transparent retry after a 401-triggered token refresh.
|
|
225
226
|
* Everything except the stream loop (which has its own auth handling) goes
|
|
226
227
|
* through here. */
|
|
228
|
+
private serverApiVersion;
|
|
229
|
+
/** True when the server echoes `replyTo` on companion.message (API ≥ 1.1) —
|
|
230
|
+
* the strict awaitReply correlation is only safe to enforce there. */
|
|
231
|
+
private serverEchoesReplyTo;
|
|
227
232
|
private fetchAuthed;
|
|
233
|
+
/** Peek the error `code` of a failure response without consuming its body. */
|
|
234
|
+
private errorCodeOf;
|
|
228
235
|
private url;
|
|
229
236
|
private jsonHeaders;
|
|
230
237
|
private requireSession;
|
|
@@ -308,6 +315,8 @@ export declare class CompanionClient {
|
|
|
308
315
|
sendWorldState(event: WorldStateInput | WorldStateInput[]): Promise<{
|
|
309
316
|
accepted: number;
|
|
310
317
|
dropped: number;
|
|
318
|
+
injected: number;
|
|
319
|
+
reacted: boolean;
|
|
311
320
|
}>;
|
|
312
321
|
private randomId;
|
|
313
322
|
/** Open the voice plane. Returns realtime credentials to connect DIRECTLY to
|
|
@@ -421,7 +430,9 @@ export declare class CompanionClient {
|
|
|
421
430
|
content: string;
|
|
422
431
|
importance?: number;
|
|
423
432
|
confidence?: number;
|
|
424
|
-
|
|
433
|
+
/** Only these two are kept server-side — anything else is discarded, so
|
|
434
|
+
* the type refuses it at compile time instead of dropping it silently. */
|
|
435
|
+
category?: 'relationship' | 'shared_experience';
|
|
425
436
|
kind?: string;
|
|
426
437
|
namespace?: string;
|
|
427
438
|
sensitivity?: 'public' | 'personal';
|
package/dist/client.js
CHANGED
|
@@ -50,6 +50,11 @@ export class CompanionClient {
|
|
|
50
50
|
cursor = 0;
|
|
51
51
|
streaming = false;
|
|
52
52
|
abort = null;
|
|
53
|
+
// Bumped on every start()/stop(): a loop whose generation is stale exits
|
|
54
|
+
// instead of resurrecting — stop() immediately followed by start() used to
|
|
55
|
+
// leave TWO concurrent receive loops polling forever (the old one saw
|
|
56
|
+
// `streaming === true` again after its backoff sleep).
|
|
57
|
+
streamGen = 0;
|
|
53
58
|
handlers = new Map();
|
|
54
59
|
seen = new Set();
|
|
55
60
|
// Token-streaming delta subscribers (P1-2). When any are registered,
|
|
@@ -101,13 +106,47 @@ export class CompanionClient {
|
|
|
101
106
|
/** doFetch with one transparent retry after a 401-triggered token refresh.
|
|
102
107
|
* Everything except the stream loop (which has its own auth handling) goes
|
|
103
108
|
* through here. */
|
|
109
|
+
// The running server's X-Pouchy-Api-Version (captured off every authed
|
|
110
|
+
// response) — capability detection for additive protocol features.
|
|
111
|
+
serverApiVersion = null;
|
|
112
|
+
/** True when the server echoes `replyTo` on companion.message (API ≥ 1.1) —
|
|
113
|
+
* the strict awaitReply correlation is only safe to enforce there. */
|
|
114
|
+
serverEchoesReplyTo() {
|
|
115
|
+
const m = /^(\d+)\.(\d+)/.exec(this.serverApiVersion ?? '');
|
|
116
|
+
if (!m)
|
|
117
|
+
return false;
|
|
118
|
+
const major = Number(m[1]);
|
|
119
|
+
const minor = Number(m[2]);
|
|
120
|
+
return major > 1 || (major === 1 && minor >= 1);
|
|
121
|
+
}
|
|
104
122
|
async fetchAuthed(url, init) {
|
|
105
123
|
const res = await this.doFetch(url, init);
|
|
106
|
-
|
|
124
|
+
const apiVersion = res.headers.get('x-pouchy-api-version');
|
|
125
|
+
if (apiVersion)
|
|
126
|
+
this.serverApiVersion = apiVersion;
|
|
127
|
+
if (res.status !== 401)
|
|
128
|
+
return res;
|
|
129
|
+
// A step-up 401 (biometric confirm) is NOT a token problem — refreshing
|
|
130
|
+
// and retrying would burn a backend re-mint on a doomed request. Only
|
|
131
|
+
// token-shaped 401s go through onAuthError.
|
|
132
|
+
const code = await this.errorCodeOf(res);
|
|
133
|
+
if (code === 'step_up_required' || code === 'step_up_failed')
|
|
134
|
+
return res;
|
|
135
|
+
if (!(await this.tryRefreshToken()))
|
|
107
136
|
return res;
|
|
108
137
|
init.headers.Authorization = `Bearer ${this.opts.token}`;
|
|
109
138
|
return this.doFetch(url, init);
|
|
110
139
|
}
|
|
140
|
+
/** Peek the error `code` of a failure response without consuming its body. */
|
|
141
|
+
async errorCodeOf(res) {
|
|
142
|
+
try {
|
|
143
|
+
const json = (await res.clone().json());
|
|
144
|
+
return typeof json?.code === 'string' ? json.code : undefined;
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
111
150
|
url(path) {
|
|
112
151
|
return this.opts.baseUrl.replace(/\/+$/, '') + path;
|
|
113
152
|
}
|
|
@@ -137,7 +176,11 @@ export class CompanionClient {
|
|
|
137
176
|
});
|
|
138
177
|
const json = (await res.json().catch(() => null));
|
|
139
178
|
if (!res.ok || !json || json.ok === false) {
|
|
140
|
-
|
|
179
|
+
// The 429 body carries an actionable `hint` (e.g. "provision a project
|
|
180
|
+
// …") — surface it instead of discarding it.
|
|
181
|
+
const hint = json?.hint;
|
|
182
|
+
const detail = typeof hint === 'string' && hint ? ` — ${hint}` : '';
|
|
183
|
+
throw new CompanionError((json?.error || `request failed (${res.status})`) + detail, res.status, json?.code, this.retryAfterFrom(res, json));
|
|
141
184
|
}
|
|
142
185
|
return json;
|
|
143
186
|
}
|
|
@@ -182,9 +225,14 @@ export class CompanionClient {
|
|
|
182
225
|
}
|
|
183
226
|
async sendText(text, opts) {
|
|
184
227
|
const id = this.requireSession();
|
|
185
|
-
|
|
228
|
+
// Client-minted correlation id (0.28.0): the server echoes it back as
|
|
229
|
+
// `replyTo` on this turn's companion.message — even across an app-tool
|
|
230
|
+
// pause — so awaitReply's fallback matches THE reply, not the first
|
|
231
|
+
// message that happens by (world-state reactions, confirm outcomes).
|
|
232
|
+
const turnId = `turn_${this.randomId()}`;
|
|
233
|
+
const body = { text, turnId, ...(opts?.images?.length ? { images: opts.images } : {}) };
|
|
186
234
|
if (opts?.awaitReply)
|
|
187
|
-
return this.sendTextAwaitingReply(id, body, opts);
|
|
235
|
+
return this.sendTextAwaitingReply(id, body, opts, turnId);
|
|
188
236
|
const wantStream = opts?.stream ?? this.deltaHandlers.size > 0;
|
|
189
237
|
if (!wantStream) {
|
|
190
238
|
const json = await this.postJson(`/api/companion/session/${id}/input`, body);
|
|
@@ -197,31 +245,61 @@ export class CompanionClient {
|
|
|
197
245
|
* `companion.message` off the event stream, bounded by a timeout. The
|
|
198
246
|
* fallback subscription opens BEFORE the POST so a fast event-stream reply
|
|
199
247
|
* can't slip through the gap. */
|
|
200
|
-
async sendTextAwaitingReply(sessionId, body, opts) {
|
|
248
|
+
async sendTextAwaitingReply(sessionId, body, opts, turnId) {
|
|
201
249
|
const timeoutMs = opts?.replyTimeoutMs ?? 60_000;
|
|
202
250
|
let resolveFallback;
|
|
203
251
|
const fallback = new Promise((res) => (resolveFallback = res));
|
|
204
|
-
|
|
252
|
+
// Correlate: resolve only on THIS turn's reply (`replyTo === turnId`).
|
|
253
|
+
// Servers on API ≥ 1.1 always echo it, so there an un-tagged message is
|
|
254
|
+
// PROACTIVE traffic (world-state reaction, confirm outcome) — skip it.
|
|
255
|
+
// Older servers never echo — keep the legacy resolve-on-first there.
|
|
256
|
+
const off = this.on('companion.message', (env) => {
|
|
257
|
+
const replyTo = env.payload?.replyTo;
|
|
258
|
+
if (replyTo === turnId)
|
|
259
|
+
return resolveFallback(env);
|
|
260
|
+
if (replyTo === undefined && !this.serverEchoesReplyTo())
|
|
261
|
+
resolveFallback(env);
|
|
262
|
+
});
|
|
263
|
+
// One deadline bounds the WHOLE operation (0.28.0): the timer starts
|
|
264
|
+
// before the POST, so a stalled streaming response can no longer hang the
|
|
265
|
+
// promise past replyTimeoutMs (previously only the fallback wait was
|
|
266
|
+
// bounded). The abort tears the in-flight stream down with it.
|
|
267
|
+
const ac = new AbortController();
|
|
205
268
|
let timer;
|
|
269
|
+
const deadline = new Promise((_, reject) => {
|
|
270
|
+
timer = setTimeout(() => {
|
|
271
|
+
ac.abort();
|
|
272
|
+
reject(new CompanionError(`timed out after ${timeoutMs}ms waiting for the reply — buffered mode needs start() polling the event stream`, 0, 'reply_timeout'));
|
|
273
|
+
}, timeoutMs);
|
|
274
|
+
});
|
|
206
275
|
try {
|
|
207
276
|
let seq = null;
|
|
208
277
|
let envelope;
|
|
278
|
+
let pausedOnTools = false;
|
|
209
279
|
if (opts?.stream === false) {
|
|
210
|
-
const json = await
|
|
280
|
+
const json = await Promise.race([
|
|
281
|
+
this.postJson(`/api/companion/session/${sessionId}/input`, body),
|
|
282
|
+
deadline
|
|
283
|
+
]);
|
|
211
284
|
seq = json.seq ?? null;
|
|
212
285
|
}
|
|
213
286
|
else {
|
|
214
|
-
const r = await
|
|
287
|
+
const r = await Promise.race([
|
|
288
|
+
this.sendTextStreaming(sessionId, body, ac.signal),
|
|
289
|
+
deadline
|
|
290
|
+
]);
|
|
215
291
|
seq = r.seq;
|
|
216
292
|
envelope = r.envelope;
|
|
293
|
+
pausedOnTools = r.kind === 'tool_calls';
|
|
217
294
|
}
|
|
218
295
|
if (!envelope) {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
296
|
+
// A turn paused on app tool calls resolves via the event stream once
|
|
297
|
+
// the app posts results — without start() those events never arrive,
|
|
298
|
+
// so waiting out the timer is a guaranteed hang. Fail fast + say why.
|
|
299
|
+
if (pausedOnTools && !this.streaming) {
|
|
300
|
+
throw new CompanionError('the turn paused on app tool calls — awaitReply needs start() (the event stream) to receive the post-resume reply', 0, 'needs_event_stream');
|
|
301
|
+
}
|
|
302
|
+
envelope = await Promise.race([fallback, deadline]);
|
|
225
303
|
}
|
|
226
304
|
const replyText = envelope.payload?.text;
|
|
227
305
|
return { seq, text: typeof replyText === 'string' ? replyText : '', envelope };
|
|
@@ -237,11 +315,12 @@ export class CompanionClient {
|
|
|
237
315
|
* final envelope locally so onMessage fires immediately and the log replay
|
|
238
316
|
* dedups by id). Falls back to buffered semantics if the server ever
|
|
239
317
|
* responds with plain JSON (e.g. an old deployment). */
|
|
240
|
-
async sendTextStreaming(sessionId, body) {
|
|
318
|
+
async sendTextStreaming(sessionId, body, signal) {
|
|
241
319
|
const res = await this.fetchAuthed(this.url(`/api/companion/session/${sessionId}/input`), {
|
|
242
320
|
method: 'POST',
|
|
243
321
|
headers: this.jsonHeaders(),
|
|
244
|
-
body: JSON.stringify({ ...body, stream: true })
|
|
322
|
+
body: JSON.stringify({ ...body, stream: true }),
|
|
323
|
+
...(signal ? { signal } : {})
|
|
245
324
|
});
|
|
246
325
|
const ctype = res.headers.get('content-type') ?? '';
|
|
247
326
|
if (!ctype.includes('text/event-stream')) {
|
|
@@ -261,67 +340,80 @@ export class CompanionClient {
|
|
|
261
340
|
for (const h of this.deltaHandlers)
|
|
262
341
|
h(chunk, meta);
|
|
263
342
|
};
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
343
|
+
try {
|
|
344
|
+
for (;;) {
|
|
345
|
+
const { done, value } = await reader.read();
|
|
346
|
+
if (value)
|
|
347
|
+
buf += decoder.decode(value, { stream: true });
|
|
348
|
+
const { events, rest } = parseSse(buf);
|
|
349
|
+
buf = rest;
|
|
350
|
+
for (const ev of events) {
|
|
351
|
+
if (ev.event === 'delta') {
|
|
352
|
+
// A malformed delta frame is dropped, not fatal — losing one text
|
|
353
|
+
// chunk beats throwing away the whole turn (the final message
|
|
354
|
+
// arrives intact in the done frame / event stream regardless).
|
|
355
|
+
let d;
|
|
356
|
+
try {
|
|
357
|
+
d = JSON.parse(ev.data);
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
if (typeof d.text === 'string' && d.text)
|
|
363
|
+
fireDelta(d.text, {});
|
|
281
364
|
}
|
|
282
|
-
if (
|
|
283
|
-
fireDelta(
|
|
284
|
-
}
|
|
285
|
-
else if (ev.event === 'reset') {
|
|
286
|
-
fireDelta('', { reset: true });
|
|
287
|
-
}
|
|
288
|
-
else if (ev.event === 'done') {
|
|
289
|
-
// The done frame carries the turn's resolution — unparseable means
|
|
290
|
-
// the turn's outcome is unknowable, so fail it explicitly instead
|
|
291
|
-
// of letting a raw SyntaxError escape the SDK.
|
|
292
|
-
let d;
|
|
293
|
-
try {
|
|
294
|
-
d = JSON.parse(ev.data);
|
|
365
|
+
else if (ev.event === 'reset') {
|
|
366
|
+
fireDelta('', { reset: true });
|
|
295
367
|
}
|
|
296
|
-
|
|
368
|
+
else if (ev.event === 'done') {
|
|
369
|
+
// The done frame carries the turn's resolution — unparseable means
|
|
370
|
+
// the turn's outcome is unknowable, so fail it explicitly instead
|
|
371
|
+
// of letting a raw SyntaxError escape the SDK.
|
|
372
|
+
let d;
|
|
373
|
+
try {
|
|
374
|
+
d = JSON.parse(ev.data);
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
try {
|
|
378
|
+
await reader.cancel();
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
/* stream already finished */
|
|
382
|
+
}
|
|
383
|
+
throw new CompanionError('malformed done frame', 502);
|
|
384
|
+
}
|
|
297
385
|
try {
|
|
298
386
|
await reader.cancel();
|
|
299
387
|
}
|
|
300
388
|
catch {
|
|
301
389
|
/* stream already finished */
|
|
302
390
|
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
if (d.ok === false) {
|
|
312
|
-
throw new CompanionError(d.error || 'turn failed', d.status ?? 500, d.code);
|
|
391
|
+
if (d.ok === false) {
|
|
392
|
+
throw new CompanionError(d.error || 'turn failed', d.status ?? 500, d.code);
|
|
393
|
+
}
|
|
394
|
+
// Emit the final envelope NOW so onMessage fires with zero poll
|
|
395
|
+
// latency; the event-stream replay of the same id is deduped.
|
|
396
|
+
if (d.envelope && typeof d.envelope === 'object')
|
|
397
|
+
this.emit(d.envelope);
|
|
398
|
+
return { seq: d.seq ?? null, envelope: d.envelope ?? undefined, kind: d.kind };
|
|
313
399
|
}
|
|
314
|
-
// Emit the final envelope NOW so onMessage fires with zero poll
|
|
315
|
-
// latency; the event-stream replay of the same id is deduped.
|
|
316
|
-
if (d.envelope && typeof d.envelope === 'object')
|
|
317
|
-
this.emit(d.envelope);
|
|
318
|
-
return { seq: d.seq ?? null, envelope: d.envelope ?? undefined };
|
|
319
400
|
}
|
|
401
|
+
if (done)
|
|
402
|
+
break;
|
|
320
403
|
}
|
|
321
|
-
|
|
322
|
-
|
|
404
|
+
throw new CompanionError('stream ended without a done frame', 502);
|
|
405
|
+
}
|
|
406
|
+
catch (e) {
|
|
407
|
+
// A throwing user onDelta handler (or an abort) must not leak the
|
|
408
|
+
// reader — cancel so the connection is released, then rethrow.
|
|
409
|
+
try {
|
|
410
|
+
await reader.cancel();
|
|
411
|
+
}
|
|
412
|
+
catch {
|
|
413
|
+
/* stream already finished */
|
|
414
|
+
}
|
|
415
|
+
throw e;
|
|
323
416
|
}
|
|
324
|
-
throw new CompanionError('stream ended without a done frame', 502);
|
|
325
417
|
}
|
|
326
418
|
/** Subscribe to token-streaming deltas of the reply (P1-2). Registering a
|
|
327
419
|
* subscriber makes sendText stream automatically. Returns an unsubscribe
|
|
@@ -348,7 +440,14 @@ export class CompanionClient {
|
|
|
348
440
|
});
|
|
349
441
|
const body = Array.isArray(event) ? { events: event.map(fill) } : fill(event);
|
|
350
442
|
const json = await this.postJson(`/api/companion/session/${id}/context`, body);
|
|
351
|
-
|
|
443
|
+
// injected/reacted tell the host a live-call inject or a proactive text
|
|
444
|
+
// reaction was triggered by this batch (0.28.0 — previously discarded).
|
|
445
|
+
return {
|
|
446
|
+
accepted: json.accepted ?? 0,
|
|
447
|
+
dropped: json.dropped ?? 0,
|
|
448
|
+
injected: json.injected ?? 0,
|
|
449
|
+
reacted: json.reacted === true
|
|
450
|
+
};
|
|
352
451
|
}
|
|
353
452
|
randomId() {
|
|
354
453
|
const c = globalThis.crypto;
|
|
@@ -384,12 +483,36 @@ export class CompanionClient {
|
|
|
384
483
|
// the caller's own onTranscript.
|
|
385
484
|
const transcript = [];
|
|
386
485
|
const callerOnTranscript = opts?.onTranscript;
|
|
486
|
+
const callerOnError = opts?.onError;
|
|
487
|
+
// Cleanup must run whichever side ends the call — the app calling close()
|
|
488
|
+
// OR a provider-initiated teardown (pc failure / EL disconnect), which
|
|
489
|
+
// surfaces as onError with no close() following. Idempotent. `unsub` is
|
|
490
|
+
// assigned after the call opens; a connect-phase onError finds the no-op.
|
|
491
|
+
let unsub = () => { };
|
|
492
|
+
let cleaned = false;
|
|
493
|
+
const cleanup = () => {
|
|
494
|
+
if (cleaned)
|
|
495
|
+
return;
|
|
496
|
+
cleaned = true;
|
|
497
|
+
unsub();
|
|
498
|
+
// Consolidate this call into durable memory (best-effort, keepalive).
|
|
499
|
+
void this.endSession({ transcript });
|
|
500
|
+
};
|
|
387
501
|
const wrappedOpts = {
|
|
388
502
|
...opts,
|
|
389
503
|
onTranscript: (e) => {
|
|
390
|
-
|
|
504
|
+
// Cap the buffer (endSession consolidates the tail anyway) so an
|
|
505
|
+
// hours-long call can't grow this array unbounded.
|
|
506
|
+
if (e?.text?.trim()) {
|
|
391
507
|
transcript.push({ role: e.role, text: e.text });
|
|
508
|
+
if (transcript.length > 200)
|
|
509
|
+
transcript.splice(0, transcript.length - 200);
|
|
510
|
+
}
|
|
392
511
|
callerOnTranscript?.(e);
|
|
512
|
+
},
|
|
513
|
+
onError: (err) => {
|
|
514
|
+
cleanup();
|
|
515
|
+
callerOnError?.(err);
|
|
393
516
|
}
|
|
394
517
|
};
|
|
395
518
|
// Let the live voice call invoke tools — routed through the SAME
|
|
@@ -419,7 +542,7 @@ export class CompanionClient {
|
|
|
419
542
|
// Bridge server-pushed voiceRelevant moments into the live call (item 2):
|
|
420
543
|
// the server emits companion.voice_inject while the call is active; inject
|
|
421
544
|
// each into the session so the companion reacts out loud. Needs start().
|
|
422
|
-
|
|
545
|
+
unsub = this.on('companion.voice_inject', (env) => {
|
|
423
546
|
const p = env.payload;
|
|
424
547
|
if (typeof p?.text === 'string' && p.text)
|
|
425
548
|
call.injectEvent(p.text, p.speak !== false);
|
|
@@ -428,10 +551,8 @@ export class CompanionClient {
|
|
|
428
551
|
return {
|
|
429
552
|
...call,
|
|
430
553
|
close: () => {
|
|
431
|
-
unsub();
|
|
432
554
|
close();
|
|
433
|
-
|
|
434
|
-
void this.endSession({ transcript });
|
|
555
|
+
cleanup();
|
|
435
556
|
}
|
|
436
557
|
};
|
|
437
558
|
}
|
|
@@ -553,15 +674,20 @@ export class CompanionClient {
|
|
|
553
674
|
const id = `vc_${this.randomId()}`;
|
|
554
675
|
return new Promise((resolve) => {
|
|
555
676
|
let settled = false;
|
|
677
|
+
let timer;
|
|
556
678
|
const finish = (out) => {
|
|
557
679
|
if (settled)
|
|
558
680
|
return;
|
|
559
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);
|
|
560
686
|
this.pendingVoiceTools.delete(id);
|
|
561
687
|
resolve(out);
|
|
562
688
|
};
|
|
563
689
|
this.pendingVoiceTools.set(id, finish);
|
|
564
|
-
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);
|
|
565
691
|
this.emit({
|
|
566
692
|
v: PROTOCOL_VERSION,
|
|
567
693
|
id: `evt_${this.randomId()}`,
|
|
@@ -898,19 +1024,20 @@ export class CompanionClient {
|
|
|
898
1024
|
return;
|
|
899
1025
|
this.requireSession();
|
|
900
1026
|
this.streaming = true;
|
|
1027
|
+
const gen = ++this.streamGen;
|
|
901
1028
|
const wsImpl = this.opts.webSocketImpl ?? globalThis.WebSocket;
|
|
902
1029
|
if (this.opts.stream === 'websocket' && wsImpl) {
|
|
903
|
-
void this.wsLoop(wsImpl);
|
|
1030
|
+
void this.wsLoop(wsImpl, gen);
|
|
904
1031
|
}
|
|
905
1032
|
else {
|
|
906
|
-
void this.loop();
|
|
1033
|
+
void this.loop(gen);
|
|
907
1034
|
}
|
|
908
1035
|
}
|
|
909
1036
|
/** WebSocket receive loop. Resolves when the socket closes; on any failure
|
|
910
1037
|
* while still streaming it falls back to the SSE loop, so opting into WS can
|
|
911
1038
|
* only ever DEGRADE to SSE, never drop replies. Input is still sent over
|
|
912
1039
|
* REST (sendInput) — receiving is the latency-sensitive half. */
|
|
913
|
-
async wsLoop(WS) {
|
|
1040
|
+
async wsLoop(WS, gen) {
|
|
914
1041
|
const id = this.requireSession();
|
|
915
1042
|
const { deriveWsStreamUrl, decodeWsMessage } = await import('./ws-transport.js');
|
|
916
1043
|
// Reconnect across CLEAN closes (a WS gateway with a bounded lifetime, like
|
|
@@ -918,8 +1045,10 @@ export class CompanionClient {
|
|
|
918
1045
|
// must not silently end reply delivery. Only a connection ERROR degrades to
|
|
919
1046
|
// SSE, and only once. Each connection's poll timer + socket are released in
|
|
920
1047
|
// the settle() finally so neither leaks after fallback/reconnect.
|
|
921
|
-
|
|
1048
|
+
let backoff = 500;
|
|
1049
|
+
while (this.streaming && gen === this.streamGen) {
|
|
922
1050
|
let errored = false;
|
|
1051
|
+
const connectedAt = Date.now();
|
|
923
1052
|
try {
|
|
924
1053
|
await new Promise((resolve, reject) => {
|
|
925
1054
|
const ws = new WS(deriveWsStreamUrl(this.opts.baseUrl, id, this.opts.token, this.cursor));
|
|
@@ -945,7 +1074,7 @@ export class CompanionClient {
|
|
|
945
1074
|
resolve();
|
|
946
1075
|
};
|
|
947
1076
|
ws.onmessage = (ev) => {
|
|
948
|
-
if (!this.streaming) {
|
|
1077
|
+
if (!this.streaming || gen !== this.streamGen) {
|
|
949
1078
|
settle();
|
|
950
1079
|
return;
|
|
951
1080
|
}
|
|
@@ -958,7 +1087,7 @@ export class CompanionClient {
|
|
|
958
1087
|
ws.onclose = () => settle();
|
|
959
1088
|
// Stopping the stream closes the socket + clears this timer.
|
|
960
1089
|
poll = setInterval(() => {
|
|
961
|
-
if (!this.streaming)
|
|
1090
|
+
if (!this.streaming || gen !== this.streamGen)
|
|
962
1091
|
settle();
|
|
963
1092
|
}, 250);
|
|
964
1093
|
});
|
|
@@ -968,16 +1097,28 @@ export class CompanionClient {
|
|
|
968
1097
|
}
|
|
969
1098
|
if (errored) {
|
|
970
1099
|
// Socket unavailable / rejected — degrade to the robust SSE loop (once).
|
|
971
|
-
if (this.streaming)
|
|
972
|
-
await this.loop();
|
|
1100
|
+
if (this.streaming && gen === this.streamGen)
|
|
1101
|
+
await this.loop(gen);
|
|
973
1102
|
return;
|
|
974
1103
|
}
|
|
975
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
|
+
}
|
|
976
1116
|
}
|
|
977
1117
|
}
|
|
978
1118
|
/** Stop the event stream. */
|
|
979
1119
|
stop() {
|
|
980
1120
|
this.streaming = false;
|
|
1121
|
+
this.streamGen++; // invalidate any loop still unwinding
|
|
981
1122
|
this.abort?.abort();
|
|
982
1123
|
this.abort = null;
|
|
983
1124
|
}
|
|
@@ -1014,10 +1155,27 @@ export class CompanionClient {
|
|
|
1014
1155
|
return;
|
|
1015
1156
|
}
|
|
1016
1157
|
}
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
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
|
+
}
|
|
1021
1179
|
}
|
|
1022
1180
|
handleFrame(event, data) {
|
|
1023
1181
|
if (event === 'reconnect') {
|
|
@@ -1047,11 +1205,11 @@ export class CompanionClient {
|
|
|
1047
1205
|
if (env && typeof env.type === 'string')
|
|
1048
1206
|
this.emit(env);
|
|
1049
1207
|
}
|
|
1050
|
-
async loop() {
|
|
1208
|
+
async loop(gen) {
|
|
1051
1209
|
const id = this.requireSession();
|
|
1052
1210
|
let backoff = 500;
|
|
1053
1211
|
let authRefreshes = 0; // consecutive 401→refresh cycles without a healthy connect
|
|
1054
|
-
while (this.streaming) {
|
|
1212
|
+
while (this.streaming && gen === this.streamGen) {
|
|
1055
1213
|
this.abort = new AbortController();
|
|
1056
1214
|
try {
|
|
1057
1215
|
const res = await this.doFetch(this.url(`/api/companion/session/${id}/stream?cursor=${this.cursor}`), { headers: { Authorization: `Bearer ${this.opts.token}` }, signal: this.abort.signal });
|
|
@@ -1084,7 +1242,7 @@ export class CompanionClient {
|
|
|
1084
1242
|
break;
|
|
1085
1243
|
}
|
|
1086
1244
|
if (!res.ok || !res.body) {
|
|
1087
|
-
if (!this.streaming)
|
|
1245
|
+
if (!this.streaming || gen !== this.streamGen)
|
|
1088
1246
|
break;
|
|
1089
1247
|
await this.sleep(backoff);
|
|
1090
1248
|
backoff = Math.min(backoff * 2, 8000);
|
|
@@ -1092,21 +1250,21 @@ export class CompanionClient {
|
|
|
1092
1250
|
}
|
|
1093
1251
|
backoff = 500; // healthy connection resets backoff
|
|
1094
1252
|
authRefreshes = 0;
|
|
1095
|
-
await this.consume(res.body);
|
|
1253
|
+
await this.consume(res.body, gen);
|
|
1096
1254
|
}
|
|
1097
1255
|
catch {
|
|
1098
|
-
if (!this.streaming)
|
|
1256
|
+
if (!this.streaming || gen !== this.streamGen)
|
|
1099
1257
|
break;
|
|
1100
1258
|
await this.sleep(backoff);
|
|
1101
1259
|
backoff = Math.min(backoff * 2, 8000);
|
|
1102
1260
|
}
|
|
1103
1261
|
}
|
|
1104
1262
|
}
|
|
1105
|
-
async consume(body) {
|
|
1263
|
+
async consume(body, gen) {
|
|
1106
1264
|
const reader = body.getReader();
|
|
1107
1265
|
const decoder = new TextDecoder();
|
|
1108
1266
|
let buf = '';
|
|
1109
|
-
while (this.streaming) {
|
|
1267
|
+
while (this.streaming && gen === this.streamGen) {
|
|
1110
1268
|
const { done, value } = await reader.read();
|
|
1111
1269
|
if (done)
|
|
1112
1270
|
break;
|
package/dist/errors.d.ts
CHANGED
|
@@ -1,12 +1,25 @@
|
|
|
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. */
|
|
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 & {});
|
|
7
20
|
export declare class CompanionError extends Error {
|
|
8
21
|
status: number;
|
|
9
|
-
code?:
|
|
22
|
+
code?: CompanionErrorCodeValue;
|
|
10
23
|
retryAfter?: number;
|
|
11
24
|
constructor(message: string, status: number, code?: string, retryAfter?: number);
|
|
12
25
|
}
|
package/dist/errors.js
CHANGED
|
@@ -3,12 +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
|
-
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
+
];
|
|
12
19
|
export class CompanionError extends Error {
|
|
13
20
|
status;
|
|
14
21
|
code;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { CompanionClient, type CompanionClientOptions } from './client.js';
|
|
2
2
|
export { CompanionClient, CompanionError, pouchyBrandIconUrl } from './client.js';
|
|
3
|
+
export type { CompanionErrorCodeValue } from './errors.js';
|
|
3
4
|
export type { CompanionClientOptions, HelloAck, RecalledMemory, CompanionTurn, CallCredentials, CompanionToolDecl, ToolCallEvent, WorldStateInput, CompanionAvatar, CompanionWallet, WalletBalance, BrandIconSize, EndSessionResult, SendTextReply } from './client.js';
|
|
4
5
|
export { openCompanionCall, HOST_CONTROL_TOOLS, HOST_CONTROL_TOOL_NAMES, AVATAR_VISUAL_TOOLS, AVATAR_VISUAL_TOOL_NAMES } from './call.js';
|
|
5
6
|
export type { CompanionCall, CompanionCallOptions, VoiceToolBridge } from './call.js';
|
|
6
7
|
export { COMPANION_SCOPES, COMPANION_MODALITIES, SENSITIVE_SCOPES, REPRESENT_SCOPES, DEFAULT_SCOPES, DEFAULT_MODALITIES, isSensitiveScope, isRepresentScope, hasScope } from './scopes.js';
|
|
7
8
|
export type { CompanionScope, CompanionModality } from './scopes.js';
|
|
8
|
-
export type { CompanionEnvelope, OutboundType, InboundType, WorldStateEvent, RenderInterfacePayload, InterfaceUpdatePayload, SocialMessagePayload, ConfirmRequestPayload, PendingConfirm, ToolCallPayload, AudioClipPayload, ExpressionPayload, TypingPayload, VoiceInjectPayload, UsagePayload } from './protocol.js';
|
|
9
|
+
export type { CompanionEnvelope, OutboundType, InboundType, WorldStateEvent, RenderInterfacePayload, InterfaceUpdatePayload, SocialMessagePayload, ConfirmRequestPayload, PendingConfirm, ToolCallPayload, AudioClipPayload, ExpressionPayload, TypingPayload, VoiceInjectPayload, UsagePayload, MessagePayload } from './protocol.js';
|
|
9
10
|
export { PROTOCOL_VERSION, INBOUND_TYPES, OUTBOUND_TYPES, COMPANION_ERROR_CODES } from './protocol.js';
|
|
10
11
|
export type { CompanionErrorCode } from './protocol.js';
|
|
11
12
|
/** Create a companion client. */
|
package/dist/protocol.d.ts
CHANGED
|
@@ -16,7 +16,7 @@ export type MessageType = InboundType | OutboundType;
|
|
|
16
16
|
* self-contained mirror of the server's api-error.ts list (drift-tested);
|
|
17
17
|
* APPEND-ONLY: renaming or removing a code is a breaking SDK change.
|
|
18
18
|
* Consumers can switch on these instead of string-matching error prose. */
|
|
19
|
-
export declare const COMPANION_ERROR_CODES: readonly ["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"];
|
|
19
|
+
export declare const COMPANION_ERROR_CODES: readonly ["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"];
|
|
20
20
|
export type CompanionErrorCode = (typeof COMPANION_ERROR_CODES)[number];
|
|
21
21
|
/** The single envelope every control/data-plane message shares. */
|
|
22
22
|
export interface CompanionEnvelope<T = unknown> {
|
|
@@ -163,6 +163,14 @@ export interface VoiceInjectPayload {
|
|
|
163
163
|
export interface TypingPayload {
|
|
164
164
|
active: boolean;
|
|
165
165
|
}
|
|
166
|
+
/** Payload of a `companion.message` event — the companion's text reply. When
|
|
167
|
+
* the turn was started by this client with sendText, `replyTo` echoes the
|
|
168
|
+
* request's turnId (0.28.0) so a reply can be correlated to its turn;
|
|
169
|
+
* proactive messages (world-state reactions, confirm outcomes) carry none. */
|
|
170
|
+
export interface MessagePayload {
|
|
171
|
+
text: string;
|
|
172
|
+
replyTo?: string;
|
|
173
|
+
}
|
|
166
174
|
/** A live world-state event the app streams in. `retained:true` = latest-value
|
|
167
175
|
* state (coalesced); `retained:false` = a transient moment carrying `salience`
|
|
168
176
|
* and optional `voiceRelevant`. */
|
package/dist/protocol.js
CHANGED
|
@@ -56,5 +56,9 @@ export const COMPANION_ERROR_CODES = [
|
|
|
56
56
|
'payload_too_large', // 413 — body/image over the documented cap
|
|
57
57
|
'rate_limited', // 429 — turn burst / demo daily budget spent; Retry-After + retryAfterSec say when
|
|
58
58
|
'forbidden', // 403 — authorization denial other than a missing scope
|
|
59
|
-
'unavailable' // 503 — backing store/provider not configured or down
|
|
59
|
+
'unavailable', // 503 — backing store/provider not configured or down
|
|
60
|
+
'confirm_not_found', // 404 — confirmId unknown or not on this session
|
|
61
|
+
'confirm_resolved', // 409 — confirm already approved/denied/expired (single-use)
|
|
62
|
+
'step_up_required', // 401 — approval needs a passkey assertion (fetch confirm/stepup)
|
|
63
|
+
'step_up_failed' // 401 — the passkey assertion didn't verify; retry the step-up
|
|
60
64
|
];
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pouchy_ai/companion-sdk",
|
|
3
|
-
"version": "0.
|
|
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",
|