@pouchy_ai/companion-sdk 0.26.1 → 0.27.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,35 @@ a protocol bump is always called out explicitly here.
12
12
 
13
13
  ## [Unreleased]
14
14
 
15
+ ## [0.27.0] - 2026-07-12
16
+
17
+ ### Added
18
+
19
+ - **`rate_limited` error code + `CompanionError.retryAfter`.** The runtime's
20
+ two cost gates (per-user turn burst ceiling, demo daily budget) answer 429;
21
+ those responses now carry `code: "rate_limited"`, so the typed-error contract
22
+ finally covers the most common failure a busy integration hits. On any 429
23
+ the thrown `CompanionError` also exposes `retryAfter` (seconds, from the
24
+ body's `retryAfterSec` or the standard `Retry-After` header) so consumers can
25
+ back off precisely:
26
+ `if (e.code === 'rate_limited') await sleep(e.retryAfter * 1000)`.
27
+ - **OpenAPI now documents the 429s** (shared `RateLimited` response on every
28
+ model-running path) and the `input` text cap below.
29
+
30
+ ### Changed
31
+
32
+ - **`input` text is capped at 32,000 chars** (parity with `tool-result`); an
33
+ over-limit turn returns 413 `payload_too_large` instead of riding a multi-MB
34
+ string into the model context.
35
+ - Malformed streaming frames no longer throw raw `SyntaxError` out of
36
+ `sendText`: a bad `delta` frame is skipped (the durable reply still lands),
37
+ and a bad `done` frame fails the turn with a clean `CompanionError` (502).
38
+
39
+ ### Fixed
40
+
41
+ - OpenAPI: `/api/companion/pair` no longer claims to deliver paired-friend
42
+ messages (pairing only; sends happen inside companion turns).
43
+
15
44
  ## [0.26.1] - 2026-07-12
16
45
 
17
46
  ### Changed
package/README.md CHANGED
@@ -147,7 +147,11 @@ HTTP `code` vocabulary behind `CompanionError.code`, e.g. `missing_scope` /
147
147
  `session_not_found` / `turn_pending`) — so you can assert the version, validate
148
148
  the event vocabulary, or switch on error codes without hard-coding strings.
149
149
  Every helper populates `CompanionError.code` when the server names a cause
150
- (0.24.0 — previously only the POST-backed calls did). The SDK also synthesizes
150
+ (0.24.0 — previously only the POST-backed calls did). 0.27.0 adds
151
+ `rate_limited` for the runtime's 429 cost gates (turn burst ceiling, demo
152
+ daily budget); on those failures `CompanionError.retryAfter` carries the
153
+ server's Retry-After in seconds, so back off with
154
+ `if (e.code === 'rate_limited') await sleep(e.retryAfter * 1000)`. The SDK also synthesizes
151
155
  a few client-side codes outside that HTTP vocabulary: `reply_timeout`
152
156
  (awaitReply fallback gave up), `stream_unauthorized` (event-stream 401
153
157
  exhausted reconnects), and — 0.26.0 — the voice connect-step codes
package/dist/client.d.ts CHANGED
@@ -228,6 +228,10 @@ export declare class CompanionClient {
228
228
  private url;
229
229
  private jsonHeaders;
230
230
  private requireSession;
231
+ /** Retry-After in seconds for a throttled response: the JSON body's
232
+ * `retryAfterSec` (companion rate-limit shape) wins, else the standard
233
+ * Retry-After header. Undefined when the server named neither. */
234
+ private retryAfterFrom;
231
235
  private postJson;
232
236
  /** Handshake: start or resume the session for this surface. */
233
237
  connect(): Promise<HelloAck>;
package/dist/client.js CHANGED
@@ -119,6 +119,16 @@ export class CompanionClient {
119
119
  throw new CompanionError('CompanionClient: call connect() first', 0, 'not_connected');
120
120
  return this._session;
121
121
  }
122
+ /** Retry-After in seconds for a throttled response: the JSON body's
123
+ * `retryAfterSec` (companion rate-limit shape) wins, else the standard
124
+ * Retry-After header. Undefined when the server named neither. */
125
+ retryAfterFrom(res, json) {
126
+ const fromBody = json?.retryAfterSec;
127
+ if (typeof fromBody === 'number' && Number.isFinite(fromBody) && fromBody >= 0)
128
+ return fromBody;
129
+ const header = Number(res.headers.get('Retry-After'));
130
+ return Number.isFinite(header) && header >= 0 ? header : undefined;
131
+ }
122
132
  async postJson(path, body) {
123
133
  const res = await this.fetchAuthed(this.url(path), {
124
134
  method: 'POST',
@@ -127,7 +137,7 @@ export class CompanionClient {
127
137
  });
128
138
  const json = (await res.json().catch(() => null));
129
139
  if (!res.ok || !json || json.ok === false) {
130
- throw new CompanionError(json?.error || `request failed (${res.status})`, res.status, json?.code);
140
+ throw new CompanionError(json?.error || `request failed (${res.status})`, res.status, json?.code, this.retryAfterFrom(res, json));
131
141
  }
132
142
  return json;
133
143
  }
@@ -238,7 +248,7 @@ export class CompanionClient {
238
248
  // Old server / error before the stream opened — behave like postJson.
239
249
  const json = (await res.json().catch(() => null));
240
250
  if (!res.ok || !json || json.ok === false) {
241
- throw new CompanionError(json?.error || `request failed (${res.status})`, res.status, json?.code);
251
+ throw new CompanionError(json?.error || `request failed (${res.status})`, res.status, json?.code, this.retryAfterFrom(res, json));
242
252
  }
243
253
  return { seq: json.seq ?? null };
244
254
  }
@@ -259,7 +269,16 @@ export class CompanionClient {
259
269
  buf = rest;
260
270
  for (const ev of events) {
261
271
  if (ev.event === 'delta') {
262
- const d = JSON.parse(ev.data);
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);
278
+ }
279
+ catch {
280
+ continue;
281
+ }
263
282
  if (typeof d.text === 'string' && d.text)
264
283
  fireDelta(d.text, {});
265
284
  }
@@ -267,7 +286,22 @@ export class CompanionClient {
267
286
  fireDelta('', { reset: true });
268
287
  }
269
288
  else if (ev.event === 'done') {
270
- const d = JSON.parse(ev.data);
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 {
297
+ try {
298
+ await reader.cancel();
299
+ }
300
+ catch {
301
+ /* stream already finished */
302
+ }
303
+ throw new CompanionError('malformed done frame', 502);
304
+ }
271
305
  try {
272
306
  await reader.cancel();
273
307
  }
package/dist/errors.d.ts CHANGED
@@ -1,9 +1,12 @@
1
1
  /** Thrown by every helper on an HTTP or client-side failure. `status` is the
2
2
  * HTTP status (0 for client-synthesized failures — timeouts, unsupported
3
3
  * environments, missing optional deps); `code` is machine-switchable when
4
- * the server (or the SDK itself) names a cause. */
4
+ * the server (or the SDK itself) names a cause. On a `rate_limited` (429)
5
+ * failure, `retryAfter` carries the server's Retry-After in SECONDS so a
6
+ * consumer can back off precisely instead of guessing. */
5
7
  export declare class CompanionError extends Error {
6
8
  status: number;
7
9
  code?: string;
8
- constructor(message: string, status: number, code?: string);
10
+ retryAfter?: number;
11
+ constructor(message: string, status: number, code?: string, retryAfter?: number);
9
12
  }
package/dist/errors.js CHANGED
@@ -6,15 +6,20 @@
6
6
  /** Thrown by every helper on an HTTP or client-side failure. `status` is the
7
7
  * HTTP status (0 for client-synthesized failures — timeouts, unsupported
8
8
  * environments, missing optional deps); `code` is machine-switchable when
9
- * the server (or the SDK itself) names a cause. */
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. */
10
12
  export class CompanionError extends Error {
11
13
  status;
12
14
  code;
13
- constructor(message, status, code) {
15
+ retryAfter;
16
+ constructor(message, status, code, retryAfter) {
14
17
  super(message);
15
18
  this.name = 'CompanionError';
16
19
  this.status = status;
17
20
  if (code)
18
21
  this.code = code;
22
+ if (typeof retryAfter === 'number' && Number.isFinite(retryAfter))
23
+ this.retryAfter = retryAfter;
19
24
  }
20
25
  }
@@ -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", "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"];
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> {
package/dist/protocol.js CHANGED
@@ -54,6 +54,7 @@ export const COMPANION_ERROR_CODES = [
54
54
  'no_pending_tools', // 409 — tool result posted but nothing is pending
55
55
  'unknown_call', // 404 — tool result for a callId the turn didn't issue
56
56
  'payload_too_large', // 413 — body/image over the documented cap
57
+ 'rate_limited', // 429 — turn burst / demo daily budget spent; Retry-After + retryAfterSec say when
57
58
  'forbidden', // 403 — authorization denial other than a missing scope
58
59
  'unavailable' // 503 — backing store/provider not configured or down
59
60
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pouchy_ai/companion-sdk",
3
- "version": "0.26.1",
3
+ "version": "0.27.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",