@pouchy_ai/companion-sdk 0.24.0 → 0.26.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,57 @@ a protocol bump is always called out explicitly here.
12
12
 
13
13
  ## [Unreleased]
14
14
 
15
+ ## [0.26.0] - 2026-07-11
16
+
17
+ ### Fixed
18
+
19
+ - **Voice-call connect failures now honor the typed-error contract.** The
20
+ call helpers (`connectCall` / `startCall` / `openCompanionCall`) used to
21
+ reject with plain `Error`s for the connect-step failures, so they carried
22
+ no `.code` and couldn't be `switch`ed like every other SDK rejection.
23
+ They now reject with `CompanionError` and three new client-synthesized
24
+ codes: `call_unsupported` (non-browser environment — no WebRTC/mic),
25
+ `call_connect_failed` (mic-permission timeout, SDP exchange failed or
26
+ timed out; `status` carries the HTTP status when the exchange answered
27
+ non-2xx), and `call_dependency_missing` (the optional
28
+ `@elevenlabs/client` peer dependency isn't installed). Like
29
+ `reply_timeout`/`stream_unauthorized` these are SDK-synthesized and
30
+ deliberately NOT part of the server-mirrored `COMPANION_ERROR_CODES`
31
+ HTTP vocabulary. Browser-native `getUserMedia` rejections (e.g.
32
+ `NotAllowedError` on permission denial) still propagate untouched.
33
+ `CompanionError` itself is unchanged and keeps its import path.
34
+
35
+ ## [0.25.0] - 2026-07-11
36
+
37
+ ### Added
38
+
39
+ - **`sendText(text, { awaitReply: true })` — await the reply as the return
40
+ value.** Resolves with `{ seq, text, envelope }` (a new exported
41
+ `SendTextReply` type) once the turn completes: `text` is the authoritative
42
+ final assistant text (the same value `onMessage` receives), `envelope` the
43
+ full `companion.message` envelope. Request/response hosts — a CLI REPL, an
44
+ HTTP handler, a test — no longer hand-roll the resolve-on-message +
45
+ timeout gate every integration was writing. It rides the same streaming
46
+ request (the terminal `done` frame carries the envelope), so it works
47
+ without `start()`; `onMessage`/`onDelta` subscribers still fire as usual.
48
+ With `stream: false` (or an old server that answers plain JSON) it falls
49
+ back to the event stream — which then does need `start()` — resolving with
50
+ the next `companion.message` or rejecting with a client-side
51
+ `CompanionError` `code: 'reply_timeout'` after `replyTimeoutMs`
52
+ (default 60 000 ms).
53
+
54
+ ### Docs
55
+
56
+ - OpenAPI spec corrections (served at `/api/companion/openapi.json`): the
57
+ memory-write body field is `content` (was wrongly documented as `text`),
58
+ knowledge ingest takes `name`/`kind`/`locale` (was `title`), the
59
+ modalities enum is `text|voice|call|files` (`avatar` never existed),
60
+ `GET …/confirm` (pending confirmations) is now documented, the session
61
+ handshake body documents `handles`/`contextKinds`/`visitor`, and memory
62
+ scopes use the real namespaced strings (`memory.read:app` …).
63
+ - The `/sdk` reference described the call handle as having `.end()` — the
64
+ method is `close()`.
65
+
15
66
  ## [0.24.0] - 2026-07-11
16
67
 
17
68
  ### Added
package/README.md CHANGED
@@ -96,6 +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
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. |
100
101
  | `ingestKnowledge({ text, name, … })` / `ingestFile(file)` | Push documents into the user's knowledge base ("My materials"); needs `memory.write:core`. |
101
102
  | `history({ limit? })` | Fetch the session transcript — restore chat on reload. |
@@ -146,7 +147,13 @@ HTTP `code` vocabulary behind `CompanionError.code`, e.g. `missing_scope` /
146
147
  `session_not_found` / `turn_pending`) — so you can assert the version, validate
147
148
  the event vocabulary, or switch on error codes without hard-coding strings.
148
149
  Every helper populates `CompanionError.code` when the server names a cause
149
- (0.24.0 — previously only the POST-backed calls did). Every
150
+ (0.24.0 — previously only the POST-backed calls did). The SDK also synthesizes
151
+ a few client-side codes outside that HTTP vocabulary: `reply_timeout`
152
+ (awaitReply fallback gave up), `stream_unauthorized` (event-stream 401
153
+ exhausted reconnects), and — 0.26.0 — the voice connect-step codes
154
+ `call_unsupported` (no WebRTC/mic in this environment), `call_connect_failed`
155
+ (mic timeout / SDP exchange failed) and `call_dependency_missing`
156
+ (`@elevenlabs/client` not installed). Every
150
157
  outbound payload has a named type; `ToolCallPayload` (`{ id, name, args }`) types
151
158
  the `companion.tool_call` event, and `onToolCall`'s callback receives
152
159
  `ToolCallEvent` — the payload plus `argsJson` (pre-parsed args).
package/dist/call.js CHANGED
@@ -10,6 +10,7 @@
10
10
  //
11
11
  // Browser-only (needs WebRTC + getUserMedia). Returns a handle to hang up and to
12
12
  // inject live context mid-call (the bridge the server uses for voiceRelevant).
13
+ import { CompanionError } from './errors.js';
13
14
  /** Universal "host control" verbs the companion can call on ANY surface (web /
14
15
  * game / app / hardware), regardless of provider. They're generic on purpose —
15
16
  * the SPECIFIC intent rides in the string params, which YOUR surface interprets
@@ -147,7 +148,7 @@ const OPENAI_REALTIME_CALLS = 'https://api.openai.com/v1/realtime/calls';
147
148
  /** Open a live call from pre-minted credentials. */
148
149
  export async function openCompanionCall(creds, opts = {}, bridge) {
149
150
  if (typeof navigator === 'undefined' || !navigator.mediaDevices) {
150
- throw new Error('connectCall is browser-only (needs WebRTC + microphone access)');
151
+ throw new CompanionError('connectCall is browser-only (needs WebRTC + microphone access)', 0, 'call_unsupported');
151
152
  }
152
153
  return creds.provider === 'openai-realtime'
153
154
  ? openOpenAICall(creds, opts, bridge)
@@ -176,7 +177,7 @@ async function getMicWithTimeout() {
176
177
  .catch(() => { });
177
178
  const timeout = new Promise((_, reject) => setTimeout(() => {
178
179
  timedOut = true;
179
- reject(new Error('microphone request timed out'));
180
+ reject(new CompanionError('microphone request timed out', 0, 'call_connect_failed'));
180
181
  }, GUM_TIMEOUT_MS));
181
182
  return Promise.race([micPromise, timeout]);
182
183
  }
@@ -343,14 +344,14 @@ async function openOpenAICall(creds, opts, bridge) {
343
344
  catch {
344
345
  clearTimeout(sdpTimer);
345
346
  cleanupPartial();
346
- throw new Error(sdpAbort.signal.aborted
347
+ throw new CompanionError(sdpAbort.signal.aborted
347
348
  ? 'OpenAI Realtime SDP exchange timed out'
348
- : 'OpenAI Realtime SDP exchange failed');
349
+ : 'OpenAI Realtime SDP exchange failed', 0, 'call_connect_failed');
349
350
  }
350
351
  clearTimeout(sdpTimer);
351
352
  if (!res.ok) {
352
353
  cleanupPartial();
353
- throw new Error(`OpenAI Realtime SDP exchange failed (${res.status})`);
354
+ throw new CompanionError(`OpenAI Realtime SDP exchange failed (${res.status})`, res.status, 'call_connect_failed');
354
355
  }
355
356
  await pc.setRemoteDescription({ type: 'answer', sdp: await res.text() });
356
357
  let closed = false;
@@ -458,7 +459,7 @@ async function openConvaiCall(creds, opts, bridge) {
458
459
  mod = await import('@elevenlabs/client');
459
460
  }
460
461
  catch {
461
- throw new Error("elevenlabs-convai call needs the optional peer dependency '@elevenlabs/client' — install it (npm i @elevenlabs/client)");
462
+ throw new CompanionError("elevenlabs-convai call needs the optional peer dependency '@elevenlabs/client' — install it (npm i @elevenlabs/client)", 0, 'call_dependency_missing');
462
463
  }
463
464
  // App tools → EL client tools. NOTE: ElevenLabs only CALLS a client tool the
464
465
  // agent already knows (declared on the agent config); a shared Convai agent
package/dist/client.d.ts CHANGED
@@ -166,12 +166,10 @@ export declare function pouchyBrandIconUrl(baseUrl: string, size?: BrandIconSize
166
166
  * a method before `connect()`). `code` is a stable machine-readable tag you can
167
167
  * switch on, shared with the `control.error` stream event vocabulary:
168
168
  * `'not_connected'`, `'missing_option'`, `'not_representative'`, or the server's
169
- * own error code for an HTTP failure (when it sends one). */
170
- export declare class CompanionError extends Error {
171
- status: number;
172
- code?: string;
173
- constructor(message: string, status: number, code?: string);
174
- }
169
+ * own error code for an HTTP failure (when it sends one). Defined in errors.ts
170
+ * (so call.ts shares it cycle-free); re-exported here so the public import
171
+ * path is unchanged. */
172
+ export { CompanionError } from './errors.js';
175
173
  /** The server's memory-consolidation diagnostic from `endSession()` / `close()`.
176
174
  * `ok` is whether consolidation ran; `facts` is how many memories it wrote;
177
175
  * `skipped` (when present) says why nothing was written
@@ -181,6 +179,15 @@ export interface EndSessionResult {
181
179
  facts?: number;
182
180
  skipped?: string;
183
181
  }
182
+ /** What `sendText(text, { awaitReply: true })` resolves with: the completed
183
+ * turn's reply. `text` is the authoritative final assistant text (same value
184
+ * onMessage receives); `envelope` is the full `companion.message` envelope
185
+ * for hosts that want the id/ts. */
186
+ export interface SendTextReply {
187
+ seq: number | null;
188
+ text: string;
189
+ envelope: CompanionEnvelope;
190
+ }
184
191
  type Handler = (envelope: CompanionEnvelope) => void;
185
192
  /** Token-streaming subscriber (P1-2). `chunk` is a raw text delta of the reply
186
193
  * being generated; `meta.reset` (with an empty chunk) tells the renderer to
@@ -244,13 +251,38 @@ export declare class CompanionClient {
244
251
  * request — delta subscribers fire as chunks arrive, and onMessage still
245
252
  * fires exactly ONCE with the authoritative final text (the streamed copy
246
253
  * is emitted locally; the event-stream replay of the same envelope is
247
- * deduplicated by id). Pass `stream: false` to force the buffered path. */
254
+ * deduplicated by id). Pass `stream: false` to force the buffered path.
255
+ *
256
+ * AWAIT THE REPLY (request/response hosts — a REPL, an HTTP handler, a
257
+ * test): pass `awaitReply: true` and the promise resolves with the final
258
+ * reply `{ seq, text, envelope }` — no onMessage wiring, no hand-rolled
259
+ * turn gate. It rides the same streaming request (the terminal `done`
260
+ * frame carries the authoritative envelope), so it works without start();
261
+ * onMessage/onDelta subscribers still fire as usual. Only when the reply
262
+ * can't ride the request (`stream: false`, or an old server) does it fall
263
+ * back to the event stream — which then DOES need start() — resolving with
264
+ * the next `companion.message`, or rejecting with code `reply_timeout`
265
+ * after `replyTimeoutMs` (default 60s). */
266
+ sendText(text: string, opts: {
267
+ awaitReply: true;
268
+ images?: string[];
269
+ stream?: boolean;
270
+ replyTimeoutMs?: number;
271
+ }): Promise<SendTextReply>;
248
272
  sendText(text: string, opts?: {
249
273
  images?: string[];
250
274
  stream?: boolean;
275
+ awaitReply?: boolean;
276
+ replyTimeoutMs?: number;
251
277
  }): Promise<{
252
278
  seq: number | null;
253
279
  }>;
280
+ /** The awaitReply leg of sendText. Prefers the in-request answer (the
281
+ * streaming `done` frame's envelope); falls back to the next
282
+ * `companion.message` off the event stream, bounded by a timeout. The
283
+ * fallback subscription opens BEFORE the POST so a fast event-stream reply
284
+ * can't slip through the gap. */
285
+ private sendTextAwaitingReply;
254
286
  /** The streaming leg of sendText: parse the POST response's SSE frames —
255
287
  * `delta` / `reset` → delta subscribers, `done` → resolve (emitting the
256
288
  * final envelope locally so onMessage fires immediately and the log replay
@@ -547,4 +579,3 @@ export declare class CompanionClient {
547
579
  private consume;
548
580
  private sleep;
549
581
  }
550
- export {};
package/dist/client.js CHANGED
@@ -14,6 +14,7 @@
14
14
  // kept in lockstep by protocol.drift.test.ts.
15
15
  import { PROTOCOL_VERSION } from './protocol.js';
16
16
  import { parseSse } from './sse.js';
17
+ import { CompanionError } from './errors.js';
17
18
  import { openCompanionCall, HOST_CONTROL_TOOLS, HOST_CONTROL_TOOL_NAMES, AVATAR_VISUAL_TOOLS, AVATAR_VISUAL_TOOL_NAMES } from './call.js';
18
19
  /** Model tool arguments are model output — tolerate junk without throwing. */
19
20
  function parseArgsJson(args) {
@@ -37,18 +38,10 @@ export function pouchyBrandIconUrl(baseUrl, size = 512) {
37
38
  * a method before `connect()`). `code` is a stable machine-readable tag you can
38
39
  * switch on, shared with the `control.error` stream event vocabulary:
39
40
  * `'not_connected'`, `'missing_option'`, `'not_representative'`, or the server's
40
- * own error code for an HTTP failure (when it sends one). */
41
- export class CompanionError extends Error {
42
- status;
43
- code;
44
- constructor(message, status, code) {
45
- super(message);
46
- this.name = 'CompanionError';
47
- this.status = status;
48
- if (code)
49
- this.code = code;
50
- }
51
- }
41
+ * own error code for an HTTP failure (when it sends one). Defined in errors.ts
42
+ * (so call.ts shares it cycle-free); re-exported here so the public import
43
+ * path is unchanged. */
44
+ export { CompanionError } from './errors.js';
52
45
  const SEEN_CAP = 512;
53
46
  export class CompanionClient {
54
47
  opts;
@@ -177,26 +170,58 @@ export class CompanionClient {
177
170
  });
178
171
  return { pairId: json.pairId ?? null };
179
172
  }
180
- /** Send a user text turn, optionally with images (data URLs) for a multimodal
181
- * turn — images need the `files` scope. The reply arrives on the event stream
182
- * as a `companion.message` — subscribe via onMessage()/start() to receive it.
183
- *
184
- * TOKEN STREAMING (P1-2): when any onDelta() subscriber is registered (or
185
- * `opts.stream` is true), the reply streams token-by-token on this very
186
- * request — delta subscribers fire as chunks arrive, and onMessage still
187
- * fires exactly ONCE with the authoritative final text (the streamed copy
188
- * is emitted locally; the event-stream replay of the same envelope is
189
- * deduplicated by id). Pass `stream: false` to force the buffered path. */
190
173
  async sendText(text, opts) {
191
174
  const id = this.requireSession();
192
- const wantStream = opts?.stream ?? this.deltaHandlers.size > 0;
193
175
  const body = { text, ...(opts?.images?.length ? { images: opts.images } : {}) };
176
+ if (opts?.awaitReply)
177
+ return this.sendTextAwaitingReply(id, body, opts);
178
+ const wantStream = opts?.stream ?? this.deltaHandlers.size > 0;
194
179
  if (!wantStream) {
195
180
  const json = await this.postJson(`/api/companion/session/${id}/input`, body);
196
181
  return { seq: json.seq ?? null };
197
182
  }
198
183
  return this.sendTextStreaming(id, body);
199
184
  }
185
+ /** The awaitReply leg of sendText. Prefers the in-request answer (the
186
+ * streaming `done` frame's envelope); falls back to the next
187
+ * `companion.message` off the event stream, bounded by a timeout. The
188
+ * fallback subscription opens BEFORE the POST so a fast event-stream reply
189
+ * can't slip through the gap. */
190
+ async sendTextAwaitingReply(sessionId, body, opts) {
191
+ const timeoutMs = opts?.replyTimeoutMs ?? 60_000;
192
+ let resolveFallback;
193
+ const fallback = new Promise((res) => (resolveFallback = res));
194
+ const off = this.on('companion.message', (env) => resolveFallback(env));
195
+ let timer;
196
+ try {
197
+ let seq = null;
198
+ let envelope;
199
+ if (opts?.stream === false) {
200
+ const json = await this.postJson(`/api/companion/session/${sessionId}/input`, body);
201
+ seq = json.seq ?? null;
202
+ }
203
+ else {
204
+ const r = await this.sendTextStreaming(sessionId, body);
205
+ seq = r.seq;
206
+ envelope = r.envelope;
207
+ }
208
+ if (!envelope) {
209
+ envelope = await Promise.race([
210
+ fallback,
211
+ new Promise((_, reject) => {
212
+ 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);
213
+ })
214
+ ]);
215
+ }
216
+ const replyText = envelope.payload?.text;
217
+ return { seq, text: typeof replyText === 'string' ? replyText : '', envelope };
218
+ }
219
+ finally {
220
+ off();
221
+ if (timer !== undefined)
222
+ clearTimeout(timer);
223
+ }
224
+ }
200
225
  /** The streaming leg of sendText: parse the POST response's SSE frames —
201
226
  * `delta` / `reset` → delta subscribers, `done` → resolve (emitting the
202
227
  * final envelope locally so onMessage fires immediately and the log replay
@@ -256,7 +281,7 @@ export class CompanionClient {
256
281
  // latency; the event-stream replay of the same id is deduped.
257
282
  if (d.envelope && typeof d.envelope === 'object')
258
283
  this.emit(d.envelope);
259
- return { seq: d.seq ?? null };
284
+ return { seq: d.seq ?? null, envelope: d.envelope ?? undefined };
260
285
  }
261
286
  }
262
287
  if (done)
@@ -0,0 +1,9 @@
1
+ /** Thrown by every helper on an HTTP or client-side failure. `status` is the
2
+ * HTTP status (0 for client-synthesized failures — timeouts, unsupported
3
+ * environments, missing optional deps); `code` is machine-switchable when
4
+ * the server (or the SDK itself) names a cause. */
5
+ export declare class CompanionError extends Error {
6
+ status: number;
7
+ code?: string;
8
+ constructor(message: string, status: number, code?: string);
9
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,20 @@
1
+ // CompanionError — the one error type every SDK helper rejects with (README
2
+ // contract: consumers `switch` on `.code` instead of string-matching prose).
3
+ // Lives in its own module so call.ts can use it without a runtime cycle
4
+ // through client.ts (client.ts re-exports it, keeping the public import path
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. */
10
+ export class CompanionError extends Error {
11
+ status;
12
+ code;
13
+ constructor(message, status, code) {
14
+ super(message);
15
+ this.name = 'CompanionError';
16
+ this.status = status;
17
+ if (code)
18
+ this.code = code;
19
+ }
20
+ }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { CompanionClient, type CompanionClientOptions } from './client.js';
2
2
  export { CompanionClient, CompanionError, pouchyBrandIconUrl } from './client.js';
3
- export type { CompanionClientOptions, HelloAck, RecalledMemory, CompanionTurn, CallCredentials, CompanionToolDecl, ToolCallEvent, WorldStateInput, CompanionAvatar, CompanionWallet, WalletBalance, BrandIconSize, EndSessionResult } from './client.js';
3
+ export type { CompanionClientOptions, HelloAck, RecalledMemory, CompanionTurn, CallCredentials, CompanionToolDecl, ToolCallEvent, WorldStateInput, CompanionAvatar, CompanionWallet, WalletBalance, BrandIconSize, EndSessionResult, SendTextReply } from './client.js';
4
4
  export { openCompanionCall, HOST_CONTROL_TOOLS, HOST_CONTROL_TOOL_NAMES, AVATAR_VISUAL_TOOLS, AVATAR_VISUAL_TOOL_NAMES } from './call.js';
5
5
  export type { CompanionCall, CompanionCallOptions, VoiceToolBridge } from './call.js';
6
6
  export { COMPANION_SCOPES, COMPANION_MODALITIES, SENSITIVE_SCOPES, REPRESENT_SCOPES, DEFAULT_SCOPES, DEFAULT_MODALITIES, isSensitiveScope, isRepresentScope, hasScope } from './scopes.js';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pouchy_ai/companion-sdk",
3
- "version": "0.24.0",
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.",
3
+ "version": "0.26.0",
4
+ "description": "Embed the Pouchy companion chat, voice, tools, memory, live world-state, instant UI, and agent-to-agent messaging in any app, game, or site.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
7
7
  "homepage": "https://pouchy.ai",