@pouchy_ai/companion-sdk 0.27.0 → 0.28.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/CHANGELOG.md CHANGED
@@ -12,6 +12,62 @@ a protocol bump is always called out explicitly here.
12
12
 
13
13
  ## [Unreleased]
14
14
 
15
+ ## [0.28.0] - 2026-07-12
16
+
17
+ ### Added
18
+
19
+ - **Turn correlation (`turnId` → `replyTo`).** Every `sendText` now mints a
20
+ `turnId` and sends it with the turn; servers on API ≥ 1.1 echo it back as
21
+ `replyTo` on the reply's `companion.message` — including a reply that lands
22
+ after an app-tool pause. `awaitReply`'s buffered fallback now resolves ONLY
23
+ on the correlated reply on those servers, so a proactive world-state
24
+ reaction or a confirm outcome can no longer steal the resolution (the known
25
+ 0.25–0.27 caveat). Against older servers the legacy resolve-on-first
26
+ behavior is kept automatically (capability-detected via
27
+ `X-Pouchy-Api-Version`). New `MessagePayload` type (`{ text, replyTo? }`).
28
+ - **`needs_event_stream` error.** With `awaitReply` and no `start()`, a turn
29
+ that pauses on app tool calls used to wait out the full `replyTimeoutMs`
30
+ and then time out — the post-resume reply can only arrive on the event
31
+ stream. It now rejects immediately with `code: "needs_event_stream"`.
32
+ - **Typed confirm errors.** The confirm/step-up endpoints now carry codes:
33
+ `confirm_not_found`, `confirm_resolved`, `step_up_required`,
34
+ `step_up_failed` (vocabulary is append-only). The client no longer burns an
35
+ `onAuthError` token refresh on a step-up 401.
36
+ - **`sendWorldState` returns `injected` and `reacted`** — whether the batch
37
+ triggered a live-call inject or a proactive text reaction (previously
38
+ discarded).
39
+ - **429 `hint` surfaced.** The demo-budget hint ("provision a project…") is
40
+ appended to the thrown `CompanionError` message instead of being dropped.
41
+ - **`CompanionErrorCodeValue`** — `CompanionError.code` is now typed as the
42
+ known vocabulary union (plus open `string`), so `switch` statements get
43
+ autocomplete and exhaustiveness help.
44
+
45
+ ### Fixed
46
+
47
+ - **`replyTimeoutMs` now bounds the WHOLE awaitReply operation.** Previously
48
+ only the buffered-fallback wait was bounded — a stalled streaming response
49
+ could hang the promise indefinitely. The deadline now starts before the
50
+ POST and aborts the in-flight stream on expiry.
51
+ - **`stop()` immediately followed by `start()` no longer leaves two live
52
+ receive loops.** Loops carry a generation token; a stale loop exits instead
53
+ of resurrecting alongside the new one (it double-polled the stream forever).
54
+ - **`connectCall` cleans up on provider-initiated teardown.** A dropped call
55
+ (pc failure / provider disconnect) now runs the same unsubscribe +
56
+ `endSession({ transcript })` consolidation that `close()` runs — previously
57
+ the voice_inject listener leaked and the call was never consolidated into
58
+ memory. The buffered transcript is also capped (drop-oldest 200).
59
+ - **A throwing `onDelta` handler no longer leaks the streaming reader** — the
60
+ stream is cancelled before the error propagates.
61
+ - **`remember`'s `category` is now typed `'relationship' |
62
+ 'shared_experience'`** — the only two values the server keeps; anything
63
+ else was silently discarded.
64
+ - **`/input` image constraints now error instead of silently dropping.** A
65
+ non-`data:` URL or a 5th image is a 400 `invalid_request` — previously the
66
+ image was filtered out and the model just never saw it.
67
+ - **OpenAPI drift**: `/input` documents `stream` + `turnId` + image rules;
68
+ confirm 200 documents `exec_failed`/`outcome`/`retryable`; hello.ack lists
69
+ `modalities`/`representative`/`visitorPaired`; `/pair` documents its body.
70
+
15
71
  ## [0.27.0] - 2026-07-12
16
72
 
17
73
  ### Added
package/README.md CHANGED
@@ -96,7 +96,7 @@ local/device skills).
96
96
 
97
97
  | Method | What it does |
98
98
  |---|---|
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); only the `stream: false` / old-server fallback needs the event stream, and it rejects with `code: 'reply_timeout'` after `replyTimeoutMs` (default 60s). |
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()` 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
100
  | `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
101
  | `ingestKnowledge({ text, name, … })` / `ingestFile(file)` | Push documents into the user's knowledge base ("My materials"); needs `memory.write:core`. |
102
102
  | `history({ limit? })` | Fetch the session transcript — restore chat on reload. |
@@ -157,9 +157,14 @@ a few client-side codes outside that HTTP vocabulary: `reply_timeout`
157
157
  exhausted reconnects), and — 0.26.0 — the voice connect-step codes
158
158
  `call_unsupported` (no WebRTC/mic in this environment), `call_connect_failed`
159
159
  (mic timeout / SDP exchange failed) and `call_dependency_missing`
160
- (`@elevenlabs/client` not installed). Every
160
+ (`@elevenlabs/client` not installed). 0.28.0 adds the confirm/step-up codes
161
+ (`confirm_not_found`, `confirm_resolved`, `step_up_required`, `step_up_failed`),
162
+ the client-side `needs_event_stream`, and types `CompanionError.code` as the
163
+ `CompanionErrorCodeValue` union (autocomplete in your `switch`; still open for
164
+ newer servers). Every
161
165
  outbound payload has a named type; `ToolCallPayload` (`{ id, name, args }`) types
162
- the `companion.tool_call` event, and `onToolCall`'s callback receives
166
+ the `companion.tool_call` event, `MessagePayload` (`{ text, replyTo? }`) types
167
+ `companion.message`, and `onToolCall`'s callback receives
163
168
  `ToolCallEvent` — the payload plus `argsJson` (pre-parsed args).
164
169
 
165
170
  For advanced voice hosts, the lower-level `openCompanionCall(client, options)`
package/dist/call.js CHANGED
@@ -234,7 +234,17 @@ async function openOpenAICall(creds, opts, bridge) {
234
234
  const dc = pc.createDataChannel('oai-events');
235
235
  // `response.function_call_arguments.done` carries the call_id + args but often
236
236
  // not the name — the name arrives earlier on `response.output_item.added`; track it.
237
+ // Cancelled tool calls never reach `arguments.done`, so their entries were
238
+ // never deleted — cap the map (drop-oldest) so a long call can't grow it.
237
239
  const callNames = new Map();
240
+ const rememberCallName = (id, name) => {
241
+ callNames.set(id, name);
242
+ if (callNames.size > 64) {
243
+ const first = callNames.keys().next().value;
244
+ if (first !== undefined)
245
+ callNames.delete(first);
246
+ }
247
+ };
238
248
  if (bridge?.tools.length) {
239
249
  dc.addEventListener('open', () => {
240
250
  try {
@@ -271,7 +281,7 @@ async function openOpenAICall(creds, opts, bridge) {
271
281
  msg.item?.type === 'function_call' &&
272
282
  msg.item.call_id &&
273
283
  msg.item.name) {
274
- callNames.set(msg.item.call_id, msg.item.name);
284
+ rememberCallName(msg.item.call_id, msg.item.name);
275
285
  }
276
286
  if (bridge && msg.type === 'response.function_call_arguments.done' && msg.call_id) {
277
287
  const callId = msg.call_id;
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
- category?: string;
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
- if (res.status !== 401 || !(await this.tryRefreshToken()))
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
- throw new CompanionError(json?.error || `request failed (${res.status})`, res.status, json?.code, this.retryAfterFrom(res, json));
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
- const body = { text, ...(opts?.images?.length ? { images: opts.images } : {}) };
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
- const off = this.on('companion.message', (env) => resolveFallback(env));
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 this.postJson(`/api/companion/session/${sessionId}/input`, body);
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 this.sendTextStreaming(sessionId, body);
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
- envelope = await Promise.race([
220
- fallback,
221
- new Promise((_, reject) => {
222
- timer = setTimeout(() => reject(new CompanionError(`timed out after ${timeoutMs}ms waiting for the reply — buffered mode needs start() polling the event stream`, 0, 'reply_timeout')), timeoutMs);
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
- for (;;) {
265
- const { done, value } = await reader.read();
266
- if (value)
267
- buf += decoder.decode(value, { stream: true });
268
- const { events, rest } = parseSse(buf);
269
- buf = rest;
270
- for (const ev of events) {
271
- if (ev.event === 'delta') {
272
- // A malformed delta frame is dropped, not fatal — losing one text
273
- // chunk beats throwing away the whole turn (the final message
274
- // arrives intact in the done frame / event stream regardless).
275
- let d;
276
- try {
277
- d = JSON.parse(ev.data);
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, {});
278
364
  }
279
- catch {
280
- continue;
365
+ else if (ev.event === 'reset') {
366
+ fireDelta('', { reset: true });
281
367
  }
282
- if (typeof d.text === 'string' && d.text)
283
- fireDelta(d.text, {});
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);
295
- }
296
- catch {
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
- throw new CompanionError('malformed done frame', 502);
304
- }
305
- try {
306
- await reader.cancel();
307
- }
308
- catch {
309
- /* stream already finished */
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
- if (done)
322
- break;
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
- return { accepted: json.accepted ?? 0, dropped: json.dropped ?? 0 };
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
- if (e?.text?.trim())
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
- const unsub = this.on('companion.voice_inject', (env) => {
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
- // Consolidate this call into durable memory (best-effort, keepalive).
434
- void this.endSession({ transcript });
555
+ cleanup();
435
556
  }
436
557
  };
437
558
  }
@@ -898,19 +1019,20 @@ export class CompanionClient {
898
1019
  return;
899
1020
  this.requireSession();
900
1021
  this.streaming = true;
1022
+ const gen = ++this.streamGen;
901
1023
  const wsImpl = this.opts.webSocketImpl ?? globalThis.WebSocket;
902
1024
  if (this.opts.stream === 'websocket' && wsImpl) {
903
- void this.wsLoop(wsImpl);
1025
+ void this.wsLoop(wsImpl, gen);
904
1026
  }
905
1027
  else {
906
- void this.loop();
1028
+ void this.loop(gen);
907
1029
  }
908
1030
  }
909
1031
  /** WebSocket receive loop. Resolves when the socket closes; on any failure
910
1032
  * while still streaming it falls back to the SSE loop, so opting into WS can
911
1033
  * only ever DEGRADE to SSE, never drop replies. Input is still sent over
912
1034
  * REST (sendInput) — receiving is the latency-sensitive half. */
913
- async wsLoop(WS) {
1035
+ async wsLoop(WS, gen) {
914
1036
  const id = this.requireSession();
915
1037
  const { deriveWsStreamUrl, decodeWsMessage } = await import('./ws-transport.js');
916
1038
  // Reconnect across CLEAN closes (a WS gateway with a bounded lifetime, like
@@ -918,7 +1040,7 @@ export class CompanionClient {
918
1040
  // must not silently end reply delivery. Only a connection ERROR degrades to
919
1041
  // SSE, and only once. Each connection's poll timer + socket are released in
920
1042
  // the settle() finally so neither leaks after fallback/reconnect.
921
- while (this.streaming) {
1043
+ while (this.streaming && gen === this.streamGen) {
922
1044
  let errored = false;
923
1045
  try {
924
1046
  await new Promise((resolve, reject) => {
@@ -945,7 +1067,7 @@ export class CompanionClient {
945
1067
  resolve();
946
1068
  };
947
1069
  ws.onmessage = (ev) => {
948
- if (!this.streaming) {
1070
+ if (!this.streaming || gen !== this.streamGen) {
949
1071
  settle();
950
1072
  return;
951
1073
  }
@@ -958,7 +1080,7 @@ export class CompanionClient {
958
1080
  ws.onclose = () => settle();
959
1081
  // Stopping the stream closes the socket + clears this timer.
960
1082
  poll = setInterval(() => {
961
- if (!this.streaming)
1083
+ if (!this.streaming || gen !== this.streamGen)
962
1084
  settle();
963
1085
  }, 250);
964
1086
  });
@@ -968,8 +1090,8 @@ export class CompanionClient {
968
1090
  }
969
1091
  if (errored) {
970
1092
  // Socket unavailable / rejected — degrade to the robust SSE loop (once).
971
- if (this.streaming)
972
- await this.loop();
1093
+ if (this.streaming && gen === this.streamGen)
1094
+ await this.loop(gen);
973
1095
  return;
974
1096
  }
975
1097
  // Clean close: the while-guard reconnects if still streaming, else exits.
@@ -978,6 +1100,7 @@ export class CompanionClient {
978
1100
  /** Stop the event stream. */
979
1101
  stop() {
980
1102
  this.streaming = false;
1103
+ this.streamGen++; // invalidate any loop still unwinding
981
1104
  this.abort?.abort();
982
1105
  this.abort = null;
983
1106
  }
@@ -1047,11 +1170,11 @@ export class CompanionClient {
1047
1170
  if (env && typeof env.type === 'string')
1048
1171
  this.emit(env);
1049
1172
  }
1050
- async loop() {
1173
+ async loop(gen) {
1051
1174
  const id = this.requireSession();
1052
1175
  let backoff = 500;
1053
1176
  let authRefreshes = 0; // consecutive 401→refresh cycles without a healthy connect
1054
- while (this.streaming) {
1177
+ while (this.streaming && gen === this.streamGen) {
1055
1178
  this.abort = new AbortController();
1056
1179
  try {
1057
1180
  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 +1207,7 @@ export class CompanionClient {
1084
1207
  break;
1085
1208
  }
1086
1209
  if (!res.ok || !res.body) {
1087
- if (!this.streaming)
1210
+ if (!this.streaming || gen !== this.streamGen)
1088
1211
  break;
1089
1212
  await this.sleep(backoff);
1090
1213
  backoff = Math.min(backoff * 2, 8000);
@@ -1092,21 +1215,21 @@ export class CompanionClient {
1092
1215
  }
1093
1216
  backoff = 500; // healthy connection resets backoff
1094
1217
  authRefreshes = 0;
1095
- await this.consume(res.body);
1218
+ await this.consume(res.body, gen);
1096
1219
  }
1097
1220
  catch {
1098
- if (!this.streaming)
1221
+ if (!this.streaming || gen !== this.streamGen)
1099
1222
  break;
1100
1223
  await this.sleep(backoff);
1101
1224
  backoff = Math.min(backoff * 2, 8000);
1102
1225
  }
1103
1226
  }
1104
1227
  }
1105
- async consume(body) {
1228
+ async consume(body, gen) {
1106
1229
  const reader = body.getReader();
1107
1230
  const decoder = new TextDecoder();
1108
1231
  let buf = '';
1109
- while (this.streaming) {
1232
+ while (this.streaming && gen === this.streamGen) {
1110
1233
  const { done, value } = await reader.read();
1111
1234
  if (done)
1112
1235
  break;
package/dist/errors.d.ts CHANGED
@@ -4,9 +4,13 @@
4
4
  * the server (or the SDK itself) names a cause. On a `rate_limited` (429)
5
5
  * failure, `retryAfter` carries the server's Retry-After in SECONDS so a
6
6
  * 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 & {});
7
11
  export declare class CompanionError extends Error {
8
12
  status: number;
9
- code?: string;
13
+ code?: CompanionErrorCodeValue;
10
14
  retryAfter?: number;
11
15
  constructor(message: string, status: number, code?: string, retryAfter?: number);
12
16
  }
package/dist/errors.js CHANGED
@@ -3,12 +3,6 @@
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
- /** Thrown by every helper on an HTTP or client-side failure. `status` is the
7
- * HTTP status (0 for client-synthesized failures — timeouts, unsupported
8
- * environments, missing optional deps); `code` is machine-switchable when
9
- * the server (or the SDK itself) names a cause. On a `rate_limited` (429)
10
- * failure, `retryAfter` carries the server's Retry-After in SECONDS so a
11
- * consumer can back off precisely instead of guessing. */
12
6
  export class CompanionError extends Error {
13
7
  status;
14
8
  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. */
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "@pouchy_ai/companion-sdk",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
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.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",