@pouchy_ai/companion-sdk 0.30.0 → 0.32.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.
Files changed (58) hide show
  1. package/CHANGELOG.md +74 -1
  2. package/README.md +46 -2
  3. package/dist/adapter-core.d.ts +58 -0
  4. package/dist/adapter-core.d.ts.map +1 -0
  5. package/dist/adapter-core.js +126 -0
  6. package/dist/adapter-core.js.map +1 -0
  7. package/dist/call.d.ts +1 -0
  8. package/dist/call.d.ts.map +1 -0
  9. package/dist/call.js +1 -0
  10. package/dist/call.js.map +1 -0
  11. package/dist/client.d.ts +46 -0
  12. package/dist/client.d.ts.map +1 -0
  13. package/dist/client.js +56 -3
  14. package/dist/client.js.map +1 -0
  15. package/dist/errors.d.ts +1 -0
  16. package/dist/errors.d.ts.map +1 -0
  17. package/dist/errors.js +1 -0
  18. package/dist/errors.js.map +1 -0
  19. package/dist/index.d.ts +4 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +5 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/protocol.d.ts +1 -0
  24. package/dist/protocol.d.ts.map +1 -0
  25. package/dist/protocol.js +1 -0
  26. package/dist/protocol.js.map +1 -0
  27. package/dist/scopes.d.ts +1 -0
  28. package/dist/scopes.d.ts.map +1 -0
  29. package/dist/scopes.js +1 -0
  30. package/dist/scopes.js.map +1 -0
  31. package/dist/sse.d.ts +1 -0
  32. package/dist/sse.d.ts.map +1 -0
  33. package/dist/sse.js +1 -0
  34. package/dist/sse.js.map +1 -0
  35. package/dist/svelte.d.ts +16 -0
  36. package/dist/svelte.d.ts.map +1 -0
  37. package/dist/svelte.js +25 -0
  38. package/dist/svelte.js.map +1 -0
  39. package/dist/vue.d.ts +15 -0
  40. package/dist/vue.d.ts.map +1 -0
  41. package/dist/vue.js +27 -0
  42. package/dist/vue.js.map +1 -0
  43. package/dist/ws-transport.d.ts +1 -0
  44. package/dist/ws-transport.d.ts.map +1 -0
  45. package/dist/ws-transport.js +1 -0
  46. package/dist/ws-transport.js.map +1 -0
  47. package/package.json +24 -6
  48. package/src/adapter-core.ts +193 -0
  49. package/src/call.ts +699 -0
  50. package/src/client.ts +1959 -0
  51. package/src/errors.ts +77 -0
  52. package/src/index.ts +104 -0
  53. package/src/protocol.ts +316 -0
  54. package/src/scopes.ts +94 -0
  55. package/src/sse.ts +41 -0
  56. package/src/svelte.ts +47 -0
  57. package/src/vue.ts +46 -0
  58. package/src/ws-transport.ts +70 -0
package/src/client.ts ADDED
@@ -0,0 +1,1959 @@
1
+ // Companion SDK — the client a third-party surface (web / app / game / hardware)
2
+ // uses to embed the Pouchy companion in a few lines. Wraps the REST/SSE plane:
3
+ // const c = createCompanion({ baseUrl, token });
4
+ // await c.connect();
5
+ // c.onMessage((text) => render(text));
6
+ // c.start();
7
+ // c.sendWorldState({ type: 'game.player.hp', data: { hp: 12 }, retained: true });
8
+ // await c.sendText('how am I doing?');
9
+ //
10
+ // Pure, dependency-free, isomorphic (browser + Node 18+ fetch). The transport is
11
+ // an internal detail: the default REST/SSE plane, or an opt-in WebSocket plane
12
+ // (ws-transport.ts) that degrades back to SSE on failure — both sit behind this
13
+ // same API. Protocol types are a self-contained copy of the server's contract,
14
+ // kept in lockstep by protocol.drift.test.ts.
15
+
16
+ import type {
17
+ AudioClipPayload,
18
+ CompanionEnvelope,
19
+ ConfirmRequestPayload,
20
+ ExpressionPayload,
21
+ InterfaceUpdatePayload,
22
+ OutboundPayloadMap,
23
+ OutboundType,
24
+ PendingConfirm,
25
+ RenderInterfacePayload,
26
+ SocialMessagePayload,
27
+ ToolCallPayload,
28
+ TypingPayload,
29
+ UsagePayload,
30
+ VoiceInjectPayload,
31
+ WorldStateEvent
32
+ } from './protocol';
33
+ import { PROTOCOL_VERSION } from './protocol';
34
+
35
+ /** Ergonomic world-state input: a WorldStateEvent with the CloudEvents plumbing
36
+ * (specversion / id / source) optional — sendWorldState fills them. */
37
+ export type WorldStateInput<D = unknown> = Omit<
38
+ WorldStateEvent<D>,
39
+ 'specversion' | 'id' | 'source'
40
+ > &
41
+ Partial<Pick<WorldStateEvent<D>, 'specversion' | 'id' | 'source'>>;
42
+ import { parseSse } from './sse';
43
+ import { CompanionError, type ControlErrorCodeValue } from './errors';
44
+ import {
45
+ openCompanionCall,
46
+ type CompanionCall,
47
+ type CompanionCallOptions,
48
+ type VoiceToolBridge,
49
+ HOST_CONTROL_TOOLS,
50
+ HOST_CONTROL_TOOL_NAMES,
51
+ AVATAR_VISUAL_TOOLS,
52
+ AVATAR_VISUAL_TOOL_NAMES
53
+ } from './call';
54
+
55
+ export interface CompanionClientOptions {
56
+ /** Origin of the Pouchy deployment, e.g. "https://pouchy.ai". */
57
+ baseUrl: string;
58
+ /** A Pouchy access token (PAT). */
59
+ token: string;
60
+ /** Logical surface (one resumable session per surface), default "default". */
61
+ surface?: string;
62
+ /** Requested I/O modalities (intersected with the token's grant server-side). */
63
+ modalities?: string[];
64
+ /** Action types this surface can perform (declared at handshake). */
65
+ handles?: string[];
66
+ /** World-state kinds this surface emits (declared at handshake). */
67
+ contextKinds?: string[];
68
+ /** Tools the companion may ask this surface to perform (companion.tool_call). */
69
+ tools?: CompanionToolDecl[];
70
+ /** Static description of THIS app/game so the companion is grounded in where it
71
+ * is (drives reasoning + the call opener). Live state still flows via
72
+ * sendWorldState. */
73
+ appContext?: { name?: string; description?: string };
74
+ /** Open a REPRESENTATIVE ("on-behalf-of" / 代聊) session: the companion fields
75
+ * this VISITOR's messages on the owner's behalf — customer-service style —
76
+ * drawing only on screened owner context (persona + share-screened facts, plus
77
+ * the knowledge base when the owner's token grants `expose:knowledge`). Each
78
+ * visitor (stable opaque `id`, 8-64 url-safe chars that YOU keep stable per
79
+ * end-user) gets their own continuous thread. Requires the token's `represent`
80
+ * scope — without it the handshake is rejected (never silently downgraded to an
81
+ * owner-facing session). */
82
+ visitor?: { id: string; displayName?: string };
83
+ /** Injectable fetch (for Node < 18 / tests). Defaults to global fetch. */
84
+ fetch?: typeof fetch;
85
+ /** Instrumentation (0.32.0). `true` logs structured events via
86
+ * console.debug('[pouchy-sdk]', …); a function receives every
87
+ * `CompanionDebugEvent` (HTTP request/response, delivered envelope,
88
+ * stream-state transition, synthesized error) for your own logger /
89
+ * devtools / test recorder. Events never carry the token or any header —
90
+ * method, path, status, timing, envelope type/id only. Zero cost when
91
+ * unset. */
92
+ debug?: boolean | ((event: CompanionDebugEvent) => void);
93
+ /** Receive transport for the reply stream. 'sse' (default) works on every
94
+ * deploy; 'websocket' opts into the lower-latency WS plane when the host
95
+ * supports it (adapter-node / a WS gateway) and falls back to SSE if the
96
+ * socket can't open — so enabling it can never stop reply delivery. */
97
+ stream?: 'sse' | 'websocket';
98
+ /** Injectable WebSocket constructor (Node without a global WebSocket, or
99
+ * tests). Defaults to globalThis.WebSocket when present. */
100
+ webSocketImpl?: typeof WebSocket;
101
+ /** Called when a request or the event stream is rejected with 401 (expired /
102
+ * revoked token — session tokens live 1h by default). Return a fresh token
103
+ * (e.g. re-minted by your backend via POST /v1/sessions) and the client
104
+ * retries transparently; return null to give up, surfacing the failure
105
+ * exactly as without this hook. Scope denials (403 `missing_scope`) are
106
+ * configuration errors and never trigger it. Alternative: call `setToken()`
107
+ * proactively on your own refresh schedule. */
108
+ onAuthError?: () => string | null | Promise<string | null>;
109
+ }
110
+
111
+ export interface HelloAck {
112
+ session: string;
113
+ grantedScopes: string[];
114
+ modalities: string[];
115
+ resumeCursor: number;
116
+ /** True when this is a representative (visitor-facing) session — i.e. a
117
+ * `visitor` was supplied and the token holds `represent`. */
118
+ representative: boolean;
119
+ /** Representative sessions only: whether this visitor is already paired with
120
+ * the owner (their two companions are friends). Lets you skip offering a
121
+ * "pair" affordance to an already-connected visitor. */
122
+ visitorPaired: boolean;
123
+ }
124
+
125
+ /** A tool the embedding declares the companion may ask it to perform. */
126
+ export interface CompanionToolDecl {
127
+ name: string;
128
+ description?: string;
129
+ parameters?: Record<string, unknown>;
130
+ }
131
+
132
+ /** What `onToolCall` hands your handler: the `companion.tool_call` wire payload
133
+ * plus `argsJson` — `args` pre-parsed as JSON. `undefined` when `args` is
134
+ * empty or not valid JSON; keep reading the raw `args` string when you need
135
+ * the exact model output. */
136
+ export interface ToolCallEvent extends ToolCallPayload {
137
+ argsJson?: unknown;
138
+ }
139
+
140
+ /** Model tool arguments are model output — tolerate junk without throwing. */
141
+ function parseArgsJson(args: string): unknown {
142
+ if (!args.trim()) return undefined;
143
+ try {
144
+ return JSON.parse(args) as unknown;
145
+ } catch {
146
+ return undefined;
147
+ }
148
+ }
149
+
150
+ /** Voice-plane credentials, discriminated by provider (see startCall). */
151
+ export type CallCredentials =
152
+ | {
153
+ provider: 'elevenlabs-convai';
154
+ /** EL conversation token (open WebRTC with @elevenlabs/client). */
155
+ token: string;
156
+ agentId: string;
157
+ /** Pass as overrides.agent.prompt.prompt when starting the EL session. */
158
+ instructions: string;
159
+ /** Server-resolved EL voice id (the user's cloned/localized voice). Pass
160
+ * as overrides.tts.voiceId so the call isn't a stock default voice. */
161
+ voice?: string;
162
+ /** Server-resolved agent language (ISO 639-1, e.g. 'zh'). Pass as
163
+ * overrides.agent.language so the call opens in the user's language. */
164
+ language?: string;
165
+ /** First-message override ('' = suppress the canned greeting so the call
166
+ * opens in-character / context-aware). Pass as overrides.agent.firstMessage. */
167
+ firstMessage?: string;
168
+ }
169
+ | {
170
+ provider: 'openai-realtime';
171
+ /** Ephemeral client secret (ek_…); open WebRTC directly to OpenAI. */
172
+ clientSecret: string;
173
+ model: string;
174
+ voice: string;
175
+ expiresAt: number | null;
176
+ };
177
+
178
+ export interface RecalledMemory {
179
+ content: string;
180
+ category?: string;
181
+ kind?: string;
182
+ importance: number;
183
+ namespace: string;
184
+ createdAt?: string;
185
+ }
186
+
187
+ /** One exchange from a session's conversation log (see `history`). `user` is the
188
+ * end-user's message, `assistant` the companion's reply; either may be `''` for
189
+ * a paused / assistant-only row. `ts` is epoch ms. */
190
+ export interface CompanionTurn {
191
+ user: string;
192
+ assistant: string;
193
+ ts: number;
194
+ }
195
+
196
+ /** The user's current companion avatar (see getAvatar). */
197
+ export interface CompanionAvatar {
198
+ /** Companion display name (the user's chosen name), or null. */
199
+ name: string | null;
200
+ /** Persona archetype driving the default look/voice. */
201
+ archetype: 'girlfriend' | 'boyfriend' | 'pet';
202
+ /** The active model id (e.g. "default-gf-luna", or a custom id), or null. */
203
+ modelId: string | null;
204
+ /** Absolute URL to the VRM 3D model — the avatar itself. CORS-enabled so a
205
+ * cross-origin renderer can fetch it. Null only if resolution failed. */
206
+ vrmUrl: string | null;
207
+ /** Absolute URL to a flat 2D portrait when available; null for VRM-only
208
+ * built-ins today (reserved for custom previews / server-rendered thumbs). */
209
+ imageUrl: string | null;
210
+ }
211
+
212
+ /** One nonzero stablecoin balance line. */
213
+ export interface WalletBalance {
214
+ /** Stablecoin code (USDT / USDC / DAI / USD1). */
215
+ currency: string;
216
+ /** Decimal amount as a string (not atomic units), e.g. "5.83". */
217
+ amount: string;
218
+ }
219
+ /** Read-only snapshot of the companion instance's OWN wallet (receive-only —
220
+ * there is no way to move funds via the SDK). Requires the `wallet.read` scope. */
221
+ export interface CompanionWallet {
222
+ /** Nonzero per-currency balances (empty when the wallet is empty). */
223
+ balances: WalletBalance[];
224
+ /** Total across the balances; the allowed stablecoins are USD-pegged 1:1. */
225
+ totalUsd: number;
226
+ /** Denomination of `totalUsd` (always "USD" today). */
227
+ currency: string;
228
+ }
229
+
230
+ /** Available sizes for the Pouchy brand icon (px). */
231
+ export type BrandIconSize = 256 | 512 | 1024;
232
+
233
+ /** Canonical URL of the official Pouchy brand icon at `size` px, derived from a
234
+ * deployment origin. Standalone (no client/token needed) so a surface can show
235
+ * the Pouchy mark before/without connecting. */
236
+ export function pouchyBrandIconUrl(baseUrl: string, size: BrandIconSize = 512): string {
237
+ return baseUrl.replace(/\/+$/, '') + `/brand-assets/icon/pouchy-icon-${size}.png`;
238
+ }
239
+
240
+ /** The single error type the SDK throws — for HTTP failures (`status` is the
241
+ * response code) AND for local precondition mistakes (`status: 0`, e.g. calling
242
+ * a method before `connect()`). `code` is a stable machine-readable tag you can
243
+ * switch on, shared with the `control.error` stream event vocabulary:
244
+ * `'not_connected'`, `'missing_option'`, `'not_representative'`, or the server's
245
+ * own error code for an HTTP failure (when it sends one). Defined in errors.ts
246
+ * (so call.ts shares it cycle-free); re-exported here so the public import
247
+ * path is unchanged. */
248
+ export { CompanionError } from './errors';
249
+
250
+ /** The server's memory-consolidation diagnostic from `endSession()` / `close()`.
251
+ * `ok` is whether consolidation ran; `facts` is how many memories it wrote;
252
+ * `skipped` (when present) says why nothing was written
253
+ * (`'no_session'` | `'no_content'` | `'throttled'`). */
254
+ export interface EndSessionResult {
255
+ ok: boolean;
256
+ facts?: number;
257
+ skipped?: string;
258
+ }
259
+
260
+ /** What `sendText(text, { awaitReply: true })` resolves with: the completed
261
+ * turn's reply. `text` is the authoritative final assistant text (same value
262
+ * onMessage receives); `envelope` is the full `companion.message` envelope
263
+ * for hosts that want the id/ts. */
264
+ export interface SendTextReply {
265
+ seq: number | null;
266
+ text: string;
267
+ envelope: CompanionEnvelope;
268
+ }
269
+
270
+ type Handler = (envelope: CompanionEnvelope) => void;
271
+
272
+ /** Token-streaming subscriber (P1-2). `chunk` is a raw text delta of the reply
273
+ * being generated; `meta.reset` (with an empty chunk) tells the renderer to
274
+ * CLEAR the partial text (a hop that streamed text turned out to be tool-call
275
+ * deliberation, not the reply). Deltas are advisory — the final
276
+ * companion.message (fired through onMessage as usual) is authoritative and
277
+ * should replace whatever the deltas rendered. */
278
+ type DeltaHandler = (chunk: string, meta: { reset?: boolean }) => void;
279
+ const SEEN_CAP = 512;
280
+
281
+ /** Structured instrumentation event (0.32.0) — see `debug` in
282
+ * CompanionClientOptions. `at` is Date.now(). Deliberately free of secrets:
283
+ * no token, no headers, no bodies — safe to pipe to any logger. */
284
+ export type CompanionDebugEvent =
285
+ | { kind: 'request'; at: number; method: string; path: string }
286
+ | {
287
+ kind: 'response';
288
+ at: number;
289
+ method: string;
290
+ path: string;
291
+ status: number;
292
+ /** Wall-clock ms from request start to headers. */
293
+ ms: number;
294
+ }
295
+ | { kind: 'envelope'; at: number; type: string; id: string }
296
+ | { kind: 'stream'; at: number; state: CompanionStreamState; prev: CompanionStreamState }
297
+ | { kind: 'error'; at: number; message: string; code?: string; status?: number };
298
+
299
+ /** Lifecycle of the receive event stream (0.30.0) — observable via
300
+ * `client.streamState` + `onStreamStateChange`, so a host can render a
301
+ * connection indicator without hand-rolling its own enum.
302
+ * - `idle` — before start()
303
+ * - `connecting` — start() called; first connection being established
304
+ * - `connected` — the event stream is live (WS or SSE as configured)
305
+ * - `reconnecting`— connection lost / closed; backing off before the next try
306
+ * - `degraded_sse`— the WebSocket transport errored and delivery continues on
307
+ * the SSE fallback (still live — replies keep arriving)
308
+ * - `stopped` — stop()/close() was called, or a permanent stream failure
309
+ * (the `stream_unauthorized` control.error) ended the loop */
310
+ export type CompanionStreamState =
311
+ | 'idle'
312
+ | 'connecting'
313
+ | 'connected'
314
+ | 'reconnecting'
315
+ | 'degraded_sse'
316
+ | 'stopped';
317
+
318
+ export class CompanionClient {
319
+ private readonly opts: CompanionClientOptions;
320
+ private readonly doFetch: typeof fetch;
321
+ private _session: string | null = null;
322
+ private cursor = 0;
323
+ private streaming = false;
324
+ private abort: AbortController | null = null;
325
+ // Bumped on every start()/stop(): a loop whose generation is stale exits
326
+ // instead of resurrecting — stop() immediately followed by start() used to
327
+ // leave TWO concurrent receive loops polling forever (the old one saw
328
+ // `streaming === true` again after its backoff sleep).
329
+ private streamGen = 0;
330
+ private readonly handlers = new Map<string, Set<Handler>>();
331
+ private readonly seen = new Set<string>();
332
+ // Token-streaming delta subscribers (P1-2). When any are registered,
333
+ // sendText automatically streams the reply.
334
+ private readonly deltaHandlers = new Set<DeltaHandler>();
335
+ // Voice tool-calls awaiting the app's sendToolResult, keyed by a synthetic id.
336
+ private readonly pendingVoiceTools = new Map<string, (out: string) => void>();
337
+ // Receive-stream lifecycle (0.30.0) — see CompanionStreamState.
338
+ private _streamState: CompanionStreamState = 'idle';
339
+ private readonly streamStateHandlers = new Set<
340
+ (state: CompanionStreamState, prev: CompanionStreamState) => void
341
+ >();
342
+
343
+ // Instrumentation sink (0.32.0) — null when `debug` is unset, so the hot
344
+ // paths pay one falsy check.
345
+ private readonly debugSink: ((event: CompanionDebugEvent) => void) | null;
346
+
347
+ constructor(opts: CompanionClientOptions) {
348
+ if (!opts.baseUrl)
349
+ throw new CompanionError('CompanionClient: baseUrl is required', 0, 'missing_option');
350
+ if (!opts.token)
351
+ throw new CompanionError('CompanionClient: token is required', 0, 'missing_option');
352
+ this.opts = opts;
353
+ this.doFetch = opts.fetch ?? globalThis.fetch.bind(globalThis);
354
+ this.debugSink =
355
+ opts.debug === true
356
+ ? (e) => console.debug('[pouchy-sdk]', e.kind, e)
357
+ : typeof opts.debug === 'function'
358
+ ? opts.debug
359
+ : null;
360
+ }
361
+
362
+ /** Fire an instrumentation event. A throwing sink must never break the
363
+ * request/stream path it observes. */
364
+ private dbg(event: CompanionDebugEvent): void {
365
+ if (!this.debugSink) return;
366
+ try {
367
+ this.debugSink(event);
368
+ } catch {
369
+ /* observer errors are the observer's problem */
370
+ }
371
+ }
372
+
373
+ /** The active session id, or null before connect(). */
374
+ get sessionId(): string | null {
375
+ return this._session;
376
+ }
377
+
378
+ /** Current lifecycle of the receive event stream (0.30.0) — `idle` before
379
+ * start(), then `connecting` / `connected` / `reconnecting` /
380
+ * `degraded_sse` (WS errored, SSE fallback carrying delivery) / `stopped`.
381
+ * Subscribe to transitions with `onStreamStateChange`. */
382
+ get streamState(): CompanionStreamState {
383
+ return this._streamState;
384
+ }
385
+
386
+ /** Subscribe to receive-stream lifecycle transitions (0.30.0) — drive a
387
+ * connection indicator: `client.onStreamStateChange((s) => setStatus(s))`.
388
+ * Fires only on change, with the previous state as the second argument.
389
+ * Returns an unsubscribe function. */
390
+ onStreamStateChange(
391
+ handler: (state: CompanionStreamState, prev: CompanionStreamState) => void
392
+ ): () => void {
393
+ this.streamStateHandlers.add(handler);
394
+ return () => this.streamStateHandlers.delete(handler);
395
+ }
396
+
397
+ private setStreamState(next: CompanionStreamState): void {
398
+ if (next === this._streamState) return;
399
+ const prev = this._streamState;
400
+ this._streamState = next;
401
+ this.dbg({ kind: 'stream', at: Date.now(), state: next, prev });
402
+ // Same isolation doctrine as emit(): a throwing consumer handler must not
403
+ // skip the remaining handlers or bubble into the stream loop.
404
+ for (const h of this.streamStateHandlers) {
405
+ try {
406
+ h(next, prev);
407
+ } catch (e) {
408
+ console.error('[companion-sdk] stream-state handler threw', e);
409
+ }
410
+ }
411
+ }
412
+
413
+ /** Swap the bearer token used by every subsequent request and stream
414
+ * reconnect. Session tokens expire (default 1h) — call this when your
415
+ * backend re-mints one, or wire `onAuthError` to do it on demand. */
416
+ setToken(token: string): void {
417
+ if (!token) throw new CompanionError('setToken: token is required', 0, 'missing_option');
418
+ this.opts.token = token;
419
+ }
420
+
421
+ // In-flight onAuthError call, shared so concurrent 401s trigger ONE refresh.
422
+ private refreshing: Promise<string | null> | null = null;
423
+
424
+ /** Run opts.onAuthError (deduped), apply the fresh token, and report whether
425
+ * the caller should retry. Never throws — a failed refresh means "surface
426
+ * the original 401". */
427
+ private async tryRefreshToken(): Promise<boolean> {
428
+ const refresh = this.opts.onAuthError;
429
+ if (!refresh) return false;
430
+ this.refreshing ??= Promise.resolve()
431
+ .then(refresh)
432
+ .catch(() => null)
433
+ .finally(() => {
434
+ this.refreshing = null;
435
+ });
436
+ const token = await this.refreshing;
437
+ if (!token) return false;
438
+ this.opts.token = token;
439
+ return true;
440
+ }
441
+
442
+ /** doFetch with one transparent retry after a 401-triggered token refresh.
443
+ * Everything except the stream loop (which has its own auth handling) goes
444
+ * through here. */
445
+ // The running server's X-Pouchy-Api-Version (captured off every authed
446
+ // response) — capability detection for additive protocol features.
447
+ private serverApiVersion: string | null = null;
448
+
449
+ /** True when the server echoes `replyTo` on companion.message (API ≥ 1.1) —
450
+ * the strict awaitReply correlation is only safe to enforce there. */
451
+ private serverEchoesReplyTo(): boolean {
452
+ const m = /^(\d+)\.(\d+)/.exec(this.serverApiVersion ?? '');
453
+ if (!m) return false;
454
+ const major = Number(m[1]);
455
+ const minor = Number(m[2]);
456
+ return major > 1 || (major === 1 && minor >= 1);
457
+ }
458
+
459
+ private async fetchAuthed(
460
+ url: string,
461
+ init: RequestInit & { headers: Record<string, string> }
462
+ ): Promise<Response> {
463
+ const method = init.method ?? 'GET';
464
+ const path = url.startsWith(this.opts.baseUrl) ? url.slice(this.opts.baseUrl.length) : url;
465
+ const startedAt = Date.now();
466
+ this.dbg({ kind: 'request', at: startedAt, method, path });
467
+ let res: Response;
468
+ try {
469
+ res = await this.doFetch(url, init);
470
+ } catch (e) {
471
+ // A caller-supplied AbortSignal makes fetch throw a native AbortError —
472
+ // convert it at this single choke point so cancellation honors the
473
+ // "every helper rejects with CompanionError" contract (code 'aborted').
474
+ const err = this.asCompanionError(e);
475
+ this.dbg({ kind: 'error', at: Date.now(), message: err.message, code: err.code, status: 0 });
476
+ throw err;
477
+ }
478
+ this.dbg({
479
+ kind: 'response',
480
+ at: Date.now(),
481
+ method,
482
+ path,
483
+ status: res.status,
484
+ ms: Date.now() - startedAt
485
+ });
486
+ const apiVersion = res.headers.get('x-pouchy-api-version');
487
+ if (apiVersion) this.serverApiVersion = apiVersion;
488
+ if (res.status !== 401) return res;
489
+ // A step-up 401 (biometric confirm) is NOT a token problem — refreshing
490
+ // and retrying would burn a backend re-mint on a doomed request. Only
491
+ // token-shaped 401s go through onAuthError.
492
+ const code = await this.errorCodeOf(res);
493
+ if (code === 'step_up_required' || code === 'step_up_failed') return res;
494
+ if (!(await this.tryRefreshToken())) return res;
495
+ init.headers.Authorization = `Bearer ${this.opts.token}`;
496
+ const retryAt = Date.now();
497
+ this.dbg({ kind: 'request', at: retryAt, method, path });
498
+ try {
499
+ const retryRes = await this.doFetch(url, init);
500
+ this.dbg({
501
+ kind: 'response',
502
+ at: Date.now(),
503
+ method,
504
+ path,
505
+ status: retryRes.status,
506
+ ms: Date.now() - retryAt
507
+ });
508
+ return retryRes;
509
+ } catch (e) {
510
+ const err = this.asCompanionError(e);
511
+ this.dbg({ kind: 'error', at: Date.now(), message: err.message, code: err.code, status: 0 });
512
+ throw err;
513
+ }
514
+ }
515
+
516
+ /** Normalize a thrown fetch error to a CompanionError. An AbortError (the
517
+ * caller's signal fired) becomes a switchable `code: 'aborted'`; anything
518
+ * else that is already a CompanionError passes through; the rest wrap as a
519
+ * status-0 network failure. */
520
+ private asCompanionError(e: unknown): CompanionError {
521
+ if (e instanceof CompanionError) return e;
522
+ const name = (e as { name?: unknown })?.name;
523
+ if (name === 'AbortError') return new CompanionError('request aborted', 0, 'aborted');
524
+ return new CompanionError(e instanceof Error ? e.message : 'network request failed', 0);
525
+ }
526
+
527
+ /** Peek the error `code` of a failure response without consuming its body. */
528
+ private async errorCodeOf(res: Response): Promise<string | undefined> {
529
+ try {
530
+ const json = (await res.clone().json()) as { code?: unknown } | null;
531
+ return typeof json?.code === 'string' ? json.code : undefined;
532
+ } catch {
533
+ return undefined;
534
+ }
535
+ }
536
+
537
+ private url(path: string): string {
538
+ return this.opts.baseUrl.replace(/\/+$/, '') + path;
539
+ }
540
+
541
+ private jsonHeaders(): Record<string, string> {
542
+ return { Authorization: `Bearer ${this.opts.token}`, 'Content-Type': 'application/json' };
543
+ }
544
+
545
+ private requireSession(): string {
546
+ if (!this._session)
547
+ throw new CompanionError('CompanionClient: call connect() first', 0, 'not_connected');
548
+ return this._session;
549
+ }
550
+
551
+ /** Retry-After in seconds for a throttled response: the JSON body's
552
+ * `retryAfterSec` (companion rate-limit shape) wins, else the standard
553
+ * Retry-After header. Undefined when the server named neither. */
554
+ private retryAfterFrom(res: Response, json: { retryAfterSec?: unknown } | null): number | undefined {
555
+ const fromBody = json?.retryAfterSec;
556
+ if (typeof fromBody === 'number' && Number.isFinite(fromBody) && fromBody >= 0) return fromBody;
557
+ // Missing/blank header must stay undefined — Number(null) is 0, which
558
+ // used to stamp retryAfter: 0 on EVERY error without the header (a 400,
559
+ // a 404 …), telling backoff code "retry immediately".
560
+ const raw = res.headers.get('Retry-After');
561
+ if (raw === null || raw.trim() === '') return undefined;
562
+ const header = Number(raw);
563
+ if (Number.isFinite(header) && header >= 0) return header;
564
+ // RFC 9110 also allows an HTTP-date (proxies/CDNs emit it) — convert to a
565
+ // non-negative seconds delta (0.30.0). Unparseable stays undefined.
566
+ const at = Date.parse(raw);
567
+ if (!Number.isNaN(at)) return Math.max(0, Math.ceil((at - Date.now()) / 1000));
568
+ return undefined;
569
+ }
570
+
571
+ private async postJson<T>(path: string, body: unknown, signal?: AbortSignal): Promise<T> {
572
+ const res = await this.fetchAuthed(this.url(path), {
573
+ method: 'POST',
574
+ headers: this.jsonHeaders(),
575
+ body: JSON.stringify(body),
576
+ // The signal rides `init`, so the 401-refresh retry in fetchAuthed
577
+ // reuses it automatically (init is passed to both doFetch calls).
578
+ ...(signal ? { signal } : {})
579
+ });
580
+ const json = (await res.json().catch(() => null)) as
581
+ | ({ ok?: boolean; error?: string; code?: string; retryAfterSec?: number } & T)
582
+ | null;
583
+ if (!res.ok || !json || json.ok === false) {
584
+ // The 429 body carries an actionable `hint` (e.g. "provision a project
585
+ // …") — surface it instead of discarding it.
586
+ const hint = (json as { hint?: unknown } | null)?.hint;
587
+ const detail = typeof hint === 'string' && hint ? ` — ${hint}` : '';
588
+ throw new CompanionError(
589
+ (json?.error || `request failed (${res.status})`) + detail,
590
+ res.status,
591
+ json?.code,
592
+ this.retryAfterFrom(res, json)
593
+ );
594
+ }
595
+ return json;
596
+ }
597
+
598
+ /** Handshake: start or resume the session for this surface. */
599
+ async connect(opts?: { signal?: AbortSignal }): Promise<HelloAck> {
600
+ const json = await this.postJson<{
601
+ session: string;
602
+ grantedScopes: string[];
603
+ modalities: string[];
604
+ resumeCursor: number;
605
+ representative?: boolean;
606
+ visitorPaired?: boolean;
607
+ }>('/api/companion/session', {
608
+ surface: this.opts.surface ?? 'default',
609
+ modalities: this.opts.modalities,
610
+ handles: this.opts.handles,
611
+ contextKinds: this.opts.contextKinds,
612
+ tools: this.opts.tools,
613
+ appContext: this.opts.appContext,
614
+ ...(this.opts.visitor ? { visitor: this.opts.visitor } : {})
615
+ }, opts?.signal);
616
+ this._session = json.session;
617
+ this.cursor = json.resumeCursor ?? 0;
618
+ return {
619
+ ...json,
620
+ representative: json.representative ?? false,
621
+ visitorPaired: json.visitorPaired ?? false
622
+ };
623
+ }
624
+
625
+ /** Pair this representative session's VISITOR with the owner so their two
626
+ * companions become friends — unlocking the A2A plane (messaging, gifts,
627
+ * visiting) between them. Only valid in a representative session (the client
628
+ * was created with a `visitor`).
629
+ *
630
+ * Two-token consent: this client's PAT (the owner's) must hold `represent:pair`,
631
+ * and you pass the VISITOR's own Pouchy PAT — which must hold `social.message`
632
+ * — as proof + consent. The visitor must therefore also be a Pouchy user. */
633
+ async pairVisitor(visitorToken: string, opts?: { signal?: AbortSignal }): Promise<{ pairId: string | null }> {
634
+ if (!this.opts.visitor) {
635
+ throw new CompanionError(
636
+ 'pairVisitor() requires a representative session (createCompanion({ visitor }))',
637
+ 0,
638
+ 'not_representative'
639
+ );
640
+ }
641
+ if (!visitorToken)
642
+ throw new CompanionError('pairVisitor: visitorToken is required', 0, 'missing_option');
643
+ const json = await this.postJson<{ pairId?: string }>('/api/companion/pair', {
644
+ visitorToken,
645
+ visitorId: this.opts.visitor.id
646
+ }, opts?.signal);
647
+ return { pairId: json.pairId ?? null };
648
+ }
649
+
650
+ /** Send a user text turn, optionally with images (data URLs) for a multimodal
651
+ * turn — images need the `files` scope. The reply arrives on the event stream
652
+ * as a `companion.message` — subscribe via onMessage()/start() to receive it.
653
+ *
654
+ * TOKEN STREAMING (P1-2): when any onDelta() subscriber is registered (or
655
+ * `opts.stream` is true), the reply streams token-by-token on this very
656
+ * request — delta subscribers fire as chunks arrive, and onMessage still
657
+ * fires exactly ONCE with the authoritative final text (the streamed copy
658
+ * is emitted locally; the event-stream replay of the same envelope is
659
+ * deduplicated by id). Pass `stream: false` to force the buffered path.
660
+ *
661
+ * AWAIT THE REPLY (request/response hosts — a REPL, an HTTP handler, a
662
+ * test): pass `awaitReply: true` and the promise resolves with the final
663
+ * reply `{ seq, text, envelope }` — no onMessage wiring, no hand-rolled
664
+ * turn gate. It rides the same streaming request (the terminal `done`
665
+ * frame carries the authoritative envelope), so it works without start();
666
+ * onMessage/onDelta subscribers still fire as usual. Only when the reply
667
+ * can't ride the request (`stream: false`, or an old server) does it fall
668
+ * back to the event stream — which then DOES need start() — resolving with
669
+ * the next `companion.message`, or rejecting with code `reply_timeout`
670
+ * after `replyTimeoutMs` (default 60s). */
671
+ async sendText(
672
+ text: string,
673
+ opts: { awaitReply: true; images?: string[]; stream?: boolean; replyTimeoutMs?: number; signal?: AbortSignal }
674
+ ): Promise<SendTextReply>;
675
+ async sendText(
676
+ text: string,
677
+ opts?: { images?: string[]; stream?: boolean; awaitReply?: boolean; replyTimeoutMs?: number; signal?: AbortSignal }
678
+ ): Promise<{ seq: number | null }>;
679
+ async sendText(
680
+ text: string,
681
+ opts?: { images?: string[]; stream?: boolean; awaitReply?: boolean; replyTimeoutMs?: number; signal?: AbortSignal }
682
+ ): Promise<{ seq: number | null } | SendTextReply> {
683
+ const id = this.requireSession();
684
+ // Client-minted correlation id (0.28.0): the server echoes it back as
685
+ // `replyTo` on this turn's companion.message — even across an app-tool
686
+ // pause — so awaitReply's fallback matches THE reply, not the first
687
+ // message that happens by (world-state reactions, confirm outcomes).
688
+ const turnId = `turn_${this.randomId()}`;
689
+ const body = { text, turnId, ...(opts?.images?.length ? { images: opts.images } : {}) };
690
+ if (opts?.awaitReply) return this.sendTextAwaitingReply(id, body, opts, turnId);
691
+ const wantStream = opts?.stream ?? this.deltaHandlers.size > 0;
692
+ if (!wantStream) {
693
+ const json = await this.postJson<{ seq: number | null }>(
694
+ `/api/companion/session/${id}/input`,
695
+ body,
696
+ opts?.signal
697
+ );
698
+ return { seq: json.seq ?? null };
699
+ }
700
+ return this.sendTextStreaming(id, body, opts?.signal);
701
+ }
702
+
703
+ /** The awaitReply leg of sendText. Prefers the in-request answer (the
704
+ * streaming `done` frame's envelope); falls back to the next
705
+ * `companion.message` off the event stream, bounded by a timeout. The
706
+ * fallback subscription opens BEFORE the POST so a fast event-stream reply
707
+ * can't slip through the gap. */
708
+ private async sendTextAwaitingReply(
709
+ sessionId: string,
710
+ body: Record<string, unknown>,
711
+ opts?: { stream?: boolean; replyTimeoutMs?: number; signal?: AbortSignal },
712
+ turnId?: string
713
+ ): Promise<SendTextReply> {
714
+ const timeoutMs = opts?.replyTimeoutMs ?? 60_000;
715
+ let resolveFallback!: (env: CompanionEnvelope) => void;
716
+ const fallback = new Promise<CompanionEnvelope>((res) => (resolveFallback = res));
717
+ // Correlate: resolve only on THIS turn's reply (`replyTo === turnId`).
718
+ // Servers on API ≥ 1.1 always echo it, so there an un-tagged message is
719
+ // PROACTIVE traffic (world-state reaction, confirm outcome) — skip it.
720
+ // Older servers never echo — keep the legacy resolve-on-first there.
721
+ const off = this.on('companion.message', (env) => {
722
+ const replyTo = (env.payload as { replyTo?: unknown } | null)?.replyTo;
723
+ if (replyTo === turnId) return resolveFallback(env);
724
+ if (replyTo === undefined && !this.serverEchoesReplyTo()) resolveFallback(env);
725
+ });
726
+ // One deadline bounds the WHOLE operation (0.28.0): the timer starts
727
+ // before the POST, so a stalled streaming response can no longer hang the
728
+ // promise past replyTimeoutMs (previously only the fallback wait was
729
+ // bounded). The abort tears the in-flight stream down with it.
730
+ const ac = new AbortController();
731
+ // Link the caller's signal: aborting it tears down the in-flight stream
732
+ // (via ac) AND rejects the whole operation with `aborted`, exactly like
733
+ // the deadline does. If it is already aborted, fail fast.
734
+ let onCallerAbort: (() => void) | undefined;
735
+ if (opts?.signal) {
736
+ if (opts.signal.aborted) throw new CompanionError('request aborted', 0, 'aborted');
737
+ onCallerAbort = () => ac.abort();
738
+ opts.signal.addEventListener('abort', onCallerAbort, { once: true });
739
+ }
740
+ let timer: ReturnType<typeof setTimeout> | undefined;
741
+ // Held so the finally can REMOVE it — a host reusing one long-lived signal
742
+ // across many awaitReply calls accumulated one orphan listener per call.
743
+ let onCallerAbortReject: (() => void) | undefined;
744
+ const deadline = new Promise<never>((_, reject) => {
745
+ timer = setTimeout(() => {
746
+ ac.abort();
747
+ reject(
748
+ new CompanionError(
749
+ `timed out after ${timeoutMs}ms waiting for the reply — buffered mode needs start() polling the event stream`,
750
+ 0,
751
+ 'reply_timeout'
752
+ )
753
+ );
754
+ }, timeoutMs);
755
+ // The caller's signal also rejects the operation (the stream teardown
756
+ // alone would otherwise leave the fallback wait hanging to the timer).
757
+ if (opts?.signal) {
758
+ onCallerAbortReject = () => reject(new CompanionError('request aborted', 0, 'aborted'));
759
+ opts.signal.addEventListener('abort', onCallerAbortReject, { once: true });
760
+ }
761
+ });
762
+ try {
763
+ let seq: number | null = null;
764
+ let envelope: CompanionEnvelope | undefined;
765
+ let pausedOnTools = false;
766
+ if (opts?.stream === false) {
767
+ const json = await Promise.race([
768
+ // ac.signal, like the streaming leg: the deadline and the caller's
769
+ // signal both abort it — without this the promise rejected but the
770
+ // billed POST kept running beyond cancellation.
771
+ this.postJson<{ seq: number | null }>(
772
+ `/api/companion/session/${sessionId}/input`,
773
+ body,
774
+ ac.signal
775
+ ),
776
+ deadline
777
+ ]);
778
+ seq = json.seq ?? null;
779
+ } else {
780
+ const r = await Promise.race([
781
+ this.sendTextStreaming(sessionId, body, ac.signal),
782
+ deadline
783
+ ]);
784
+ seq = r.seq;
785
+ envelope = r.envelope;
786
+ pausedOnTools = r.kind === 'tool_calls';
787
+ }
788
+ if (!envelope) {
789
+ // A turn paused on app tool calls resolves via the event stream once
790
+ // the app posts results — without start() those events never arrive,
791
+ // so waiting out the timer is a guaranteed hang. Fail fast + say why.
792
+ if (pausedOnTools && !this.streaming) {
793
+ throw new CompanionError(
794
+ 'the turn paused on app tool calls — awaitReply needs start() (the event stream) to receive the post-resume reply',
795
+ 0,
796
+ 'needs_event_stream'
797
+ );
798
+ }
799
+ envelope = await Promise.race([fallback, deadline]);
800
+ }
801
+ const replyText = (envelope.payload as { text?: unknown })?.text;
802
+ return { seq, text: typeof replyText === 'string' ? replyText : '', envelope };
803
+ } finally {
804
+ off();
805
+ if (timer !== undefined) clearTimeout(timer);
806
+ if (onCallerAbort) opts?.signal?.removeEventListener('abort', onCallerAbort);
807
+ if (onCallerAbortReject) opts?.signal?.removeEventListener('abort', onCallerAbortReject);
808
+ }
809
+ }
810
+
811
+ /** The streaming leg of sendText: parse the POST response's SSE frames —
812
+ * `delta` / `reset` → delta subscribers, `done` → resolve (emitting the
813
+ * final envelope locally so onMessage fires immediately and the log replay
814
+ * dedups by id). Falls back to buffered semantics if the server ever
815
+ * responds with plain JSON (e.g. an old deployment). */
816
+ private async sendTextStreaming(
817
+ sessionId: string,
818
+ body: Record<string, unknown>,
819
+ signal?: AbortSignal
820
+ ): Promise<{ seq: number | null; envelope?: CompanionEnvelope; kind?: string }> {
821
+ const res = await this.fetchAuthed(this.url(`/api/companion/session/${sessionId}/input`), {
822
+ method: 'POST',
823
+ headers: this.jsonHeaders(),
824
+ body: JSON.stringify({ ...body, stream: true }),
825
+ ...(signal ? { signal } : {})
826
+ });
827
+ const ctype = res.headers.get('content-type') ?? '';
828
+ if (!ctype.includes('text/event-stream')) {
829
+ // Old server / error before the stream opened — behave like postJson.
830
+ const json = (await res.json().catch(() => null)) as
831
+ | ({ ok?: boolean; error?: string; code?: string; retryAfterSec?: number; seq?: number | null } & Record<
832
+ string,
833
+ unknown
834
+ >)
835
+ | null;
836
+ if (!res.ok || !json || json.ok === false) {
837
+ // Same as postJson: the 429 body's actionable `hint` (e.g. "provision
838
+ // a project …") must survive the streaming leg too (0.30.0).
839
+ const hint = (json as { hint?: unknown } | null)?.hint;
840
+ const detail = typeof hint === 'string' && hint ? ` — ${hint}` : '';
841
+ throw new CompanionError(
842
+ (json?.error || `request failed (${res.status})`) + detail,
843
+ res.status,
844
+ json?.code,
845
+ this.retryAfterFrom(res, json)
846
+ );
847
+ }
848
+ return { seq: json.seq ?? null };
849
+ }
850
+ if (!res.body) throw new CompanionError('streaming response has no body', 502);
851
+ const reader = res.body.getReader();
852
+ const decoder = new TextDecoder();
853
+ let buf = '';
854
+ const fireDelta = (chunk: string, meta: { reset?: boolean }) => {
855
+ for (const h of this.deltaHandlers) h(chunk, meta);
856
+ };
857
+ try {
858
+ for (;;) {
859
+ const { done, value } = await reader.read();
860
+ if (value) buf += decoder.decode(value, { stream: true });
861
+ const { events, rest } = parseSse(buf);
862
+ buf = rest;
863
+ for (const ev of events) {
864
+ if (ev.event === 'delta') {
865
+ // A malformed delta frame is dropped, not fatal — losing one text
866
+ // chunk beats throwing away the whole turn (the final message
867
+ // arrives intact in the done frame / event stream regardless).
868
+ let d: { text?: string };
869
+ try {
870
+ d = JSON.parse(ev.data) as { text?: string };
871
+ } catch {
872
+ continue;
873
+ }
874
+ if (typeof d.text === 'string' && d.text) fireDelta(d.text, {});
875
+ } else if (ev.event === 'reset') {
876
+ fireDelta('', { reset: true });
877
+ } else if (ev.event === 'done') {
878
+ // The done frame carries the turn's resolution — unparseable means
879
+ // the turn's outcome is unknowable, so fail it explicitly instead
880
+ // of letting a raw SyntaxError escape the SDK.
881
+ let d: {
882
+ ok?: boolean;
883
+ error?: string;
884
+ code?: string;
885
+ status?: number;
886
+ kind?: string;
887
+ seq?: number | null;
888
+ envelope?: CompanionEnvelope | null;
889
+ };
890
+ try {
891
+ d = JSON.parse(ev.data) as typeof d;
892
+ } catch {
893
+ try {
894
+ await reader.cancel();
895
+ } catch {
896
+ /* stream already finished */
897
+ }
898
+ throw new CompanionError('malformed done frame', 502);
899
+ }
900
+ try {
901
+ await reader.cancel();
902
+ } catch {
903
+ /* stream already finished */
904
+ }
905
+ if (d.ok === false) {
906
+ throw new CompanionError(d.error || 'turn failed', d.status ?? 500, d.code);
907
+ }
908
+ // Emit the final envelope NOW so onMessage fires with zero poll
909
+ // latency; the event-stream replay of the same id is deduped.
910
+ if (d.envelope && typeof d.envelope === 'object') this.emit(d.envelope);
911
+ return { seq: d.seq ?? null, envelope: d.envelope ?? undefined, kind: d.kind };
912
+ }
913
+ }
914
+ if (done) break;
915
+ }
916
+ throw new CompanionError('stream ended without a done frame', 502);
917
+ } catch (e) {
918
+ // A throwing user onDelta handler (or an abort) must not leak the
919
+ // reader — cancel so the connection is released, then rethrow.
920
+ try {
921
+ await reader.cancel();
922
+ } catch {
923
+ /* stream already finished */
924
+ }
925
+ throw e;
926
+ }
927
+ }
928
+
929
+ /** Subscribe to token-streaming deltas of the reply (P1-2). Registering a
930
+ * subscriber makes sendText stream automatically. Returns an unsubscribe
931
+ * function. Render chunks as they arrive; on `meta.reset` clear the partial;
932
+ * when onMessage fires, replace the partial with the final text. */
933
+ onDelta(handler: DeltaHandler): () => void {
934
+ this.deltaHandlers.add(handler);
935
+ return () => this.deltaHandlers.delete(handler);
936
+ }
937
+
938
+ /** Push live world-state — a single event or a batch. Retained events
939
+ * (retained:true) coalesce per kind; transient ones carry a salience.
940
+ *
941
+ * Ergonomic input: `specversion` / `id` / `source` are filled automatically
942
+ * (id → a fresh handle, source → this surface), so callers only supply the
943
+ * meaningful fields: `{ type, data, retained?, salience?, voiceRelevant? }`.
944
+ * A full CloudEvents envelope is still accepted (its fields win). */
945
+ async sendWorldState(
946
+ event: WorldStateInput | WorldStateInput[],
947
+ opts?: { signal?: AbortSignal }
948
+ ): Promise<{ accepted: number; dropped: number; injected: number; reacted: boolean }> {
949
+ const id = this.requireSession();
950
+ const fill = (e: WorldStateInput): WorldStateEvent => ({
951
+ ...e,
952
+ specversion: '1.0',
953
+ id: e.id ?? this.randomId(),
954
+ source: e.source ?? this.opts.surface ?? 'companion-sdk'
955
+ });
956
+ const body = Array.isArray(event) ? { events: event.map(fill) } : fill(event);
957
+ const json = await this.postJson<{
958
+ accepted: number;
959
+ dropped: number;
960
+ injected?: number;
961
+ reacted?: boolean;
962
+ }>(`/api/companion/session/${id}/context`, body, opts?.signal);
963
+ // injected/reacted tell the host a live-call inject or a proactive text
964
+ // reaction was triggered by this batch (0.28.0 — previously discarded).
965
+ return {
966
+ accepted: json.accepted ?? 0,
967
+ dropped: json.dropped ?? 0,
968
+ injected: json.injected ?? 0,
969
+ reacted: json.reacted === true
970
+ };
971
+ }
972
+
973
+ private randomId(): string {
974
+ const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
975
+ return c?.randomUUID ? c.randomUUID() : `ws_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
976
+ }
977
+
978
+ /** Open the voice plane. Returns realtime credentials to connect DIRECTLY to
979
+ * the voice provider (the Pouchy server is out of the audio path). Provider
980
+ * precedence is server-side: ElevenLabs Convai (primary) → OpenAI Realtime
981
+ * (fallback). Requires the `call` scope.
982
+ *
983
+ * - `elevenlabs-convai`: open the EL session with the returned `token` and
984
+ * pass `instructions` as `overrides.agent.prompt.prompt` (the EL agent runs
985
+ * EL's LLM, so the persona/memory snapshot rides in client-side).
986
+ * - `openai-realtime`: open WebRTC with the short-lived `clientSecret`
987
+ * (instructions are baked into the session server-side).
988
+ */
989
+ async startCall(
990
+ opts?: { voice?: string; locale?: string; signal?: AbortSignal }
991
+ ): Promise<CallCredentials> {
992
+ const id = this.requireSession();
993
+ return this.postJson<CallCredentials>(
994
+ `/api/companion/session/${id}/call`,
995
+ { voice: opts?.voice, locale: opts?.locale },
996
+ opts?.signal
997
+ );
998
+ }
999
+
1000
+ /** Open a live voice call end-to-end: mints credentials (startCall) and opens a
1001
+ * WebRTC session directly to the provider — no first-party app code needed.
1002
+ * Browser-only. Returns a handle to hang up + inject live context. EL Convai
1003
+ * needs the optional '@elevenlabs/client' peer dependency; OpenAI Realtime has
1004
+ * no extra deps. */
1005
+ async connectCall(
1006
+ opts?: CompanionCallOptions & { voice?: string; locale?: string }
1007
+ ): Promise<CompanionCall> {
1008
+ // Buffer the voice transcript so we can hand it to the server at call end —
1009
+ // the audio is provider-direct, so this is the ONLY way a voice play-along
1010
+ // reaches the companion's long-term memory. We still forward every line to
1011
+ // the caller's own onTranscript.
1012
+ const transcript: { role: 'user' | 'assistant'; text: string }[] = [];
1013
+ const callerOnTranscript = opts?.onTranscript;
1014
+ const callerOnError = opts?.onError;
1015
+ // Cleanup must run whichever side ends the call — the app calling close()
1016
+ // OR a provider-initiated teardown (pc failure / EL disconnect), which
1017
+ // surfaces as onError with no close() following. Idempotent. `unsub` is
1018
+ // assigned after the call opens; a connect-phase onError finds the no-op.
1019
+ let unsub: () => void = () => {};
1020
+ let cleaned = false;
1021
+ const cleanup = () => {
1022
+ if (cleaned) return;
1023
+ cleaned = true;
1024
+ unsub();
1025
+ // Consolidate this call into durable memory (best-effort, keepalive).
1026
+ void this.endSession({ transcript });
1027
+ };
1028
+ const wrappedOpts: CompanionCallOptions = {
1029
+ ...opts,
1030
+ onTranscript: (e) => {
1031
+ // Cap the buffer (endSession consolidates the tail anyway) so an
1032
+ // hours-long call can't grow this array unbounded.
1033
+ if (e?.text?.trim()) {
1034
+ transcript.push({ role: e.role, text: e.text });
1035
+ if (transcript.length > 200) transcript.splice(0, transcript.length - 200);
1036
+ }
1037
+ callerOnTranscript?.(e);
1038
+ },
1039
+ onError: (err) => {
1040
+ cleanup();
1041
+ callerOnError?.(err);
1042
+ }
1043
+ };
1044
+
1045
+ // Let the live voice call invoke tools — routed through the SAME
1046
+ // onToolCall/sendToolResult the text path uses (one handler for voice +
1047
+ // text). We offer the app's DECLARED tools PLUS the universal host-control
1048
+ // verbs (pause/toggle/navigate/…) so the companion can act on any surface.
1049
+ // Only when the app signalled it wants to act (declared tools or handles) —
1050
+ // otherwise a pure-chat embed wouldn't get tools it can't service.
1051
+ const declared = this.opts.tools ?? [];
1052
+ const wantsTools = declared.length > 0 || (this.opts.handles?.length ?? 0) > 0;
1053
+ const hostVerbs = wantsTools
1054
+ ? HOST_CONTROL_TOOLS.filter((v) => !declared.some((d) => d.name === v.name))
1055
+ : [];
1056
+ // Avatar emote tools ride along ALWAYS — even a pure-chat / audio embed that
1057
+ // declared no tools — so the embodied companion can express itself without
1058
+ // each third party declaring play_gesture / play_expression, and so the EL
1059
+ // Convai path (whose shared agent already knows these tools) has a handler to
1060
+ // call. The SDK no-ops them unless the app opted in by declaring the same
1061
+ // name (see invokeVoiceTool); declared ones use the app's own schema.
1062
+ const avatarTools = AVATAR_VISUAL_TOOLS.filter((v) => !declared.some((d) => d.name === v.name));
1063
+ const voiceTools = [...declared, ...hostVerbs, ...avatarTools];
1064
+ const bridge: VoiceToolBridge | undefined = voiceTools.length
1065
+ ? { tools: voiceTools, invoke: (name, argsJson) => this.invokeVoiceTool(name, argsJson) }
1066
+ : undefined;
1067
+
1068
+ const creds = await this.startCall({ voice: opts?.voice, locale: opts?.locale });
1069
+ const call = await openCompanionCall(creds, wrappedOpts, bridge);
1070
+ // Bridge server-pushed voiceRelevant moments into the live call (item 2):
1071
+ // the server emits companion.voice_inject while the call is active; inject
1072
+ // each into the session so the companion reacts out loud. Needs start().
1073
+ unsub = this.on('companion.voice_inject', (env) => {
1074
+ const p = env.payload as { text?: unknown; speak?: unknown };
1075
+ if (typeof p?.text === 'string' && p.text) call.injectEvent(p.text, p.speak !== false);
1076
+ });
1077
+ const close = call.close;
1078
+ return {
1079
+ ...call,
1080
+ close: () => {
1081
+ close();
1082
+ cleanup();
1083
+ }
1084
+ };
1085
+ }
1086
+
1087
+ /** End the session: consolidate what happened into the companion's long-term
1088
+ * memory, so it remembers this experience in future first-party conversations.
1089
+ * Called automatically when a connectCall() handle is closed; call it directly
1090
+ * when a non-voice session ends. Best-effort + idempotent (safe to call twice;
1091
+ * uses keepalive so it survives a page unload). Pass the voice transcript when
1092
+ * you have it (connectCall does this for you).
1093
+ *
1094
+ * Returns the server's consolidation diagnostic — `{ ok, facts?, skipped? }`
1095
+ * — or `null` if there was no session or the (swallowed) keepalive request
1096
+ * failed. `skipped` tells you WHY nothing was written: `'no_session'` (you
1097
+ * passed the instance id, not the session id), `'no_content'` (empty turn log
1098
+ * / a voice transcript that never arrived), `'throttled'` (a duplicate end
1099
+ * beacon — harmless). `facts` is how many memories were consolidated. */
1100
+ async endSession(opts?: {
1101
+ transcript?: { role: string; text: string }[];
1102
+ }): Promise<EndSessionResult | null> {
1103
+ const id = this._session;
1104
+ if (!id) return null;
1105
+ // Cap the payload (fetch keepalive bodies are size-limited): last 60 lines.
1106
+ const transcript = (opts?.transcript ?? [])
1107
+ .filter((t) => t && typeof t.text === 'string' && t.text.trim())
1108
+ .slice(-60)
1109
+ .map((t) => ({ role: t.role, text: t.text.slice(0, 500) }));
1110
+ try {
1111
+ const res = await this.fetchAuthed(this.url(`/api/companion/session/${id}/end`), {
1112
+ method: 'POST',
1113
+ headers: this.jsonHeaders(),
1114
+ body: JSON.stringify({ transcript }),
1115
+ keepalive: true
1116
+ });
1117
+ return (await res.json().catch(() => null)) as EndSessionResult | null;
1118
+ } catch {
1119
+ /* best-effort — memory consolidation is not on the user's critical path */
1120
+ return null;
1121
+ }
1122
+ }
1123
+
1124
+ /** Tear the session down in one call: stop the event stream and consolidate
1125
+ * memory (endSession) exactly once. The single teardown entry point for a
1126
+ * NON-voice (text) session — mirror of what a connectCall() handle's close()
1127
+ * does for voice. Idempotent. Returns endSession's diagnostic (or null).
1128
+ * NOTE: if you drive `ping()` on your own timer for a background tab, clear
1129
+ * that timer yourself — the client doesn't own it. */
1130
+ async close(): Promise<EndSessionResult | null> {
1131
+ this.stop();
1132
+ return this.endSession();
1133
+ }
1134
+
1135
+ /** Report the result of a companion.tool_call this surface performed. When all
1136
+ * of the turn's calls are reported, the companion resumes and the continuation
1137
+ * (a companion.message or more tool calls) arrives on the event stream. */
1138
+ async sendToolResult(
1139
+ callId: string,
1140
+ result: { ok?: boolean; result?: unknown },
1141
+ opts?: { signal?: AbortSignal }
1142
+ ): Promise<{ allDone: boolean }> {
1143
+ // Voice tool-call: resolve it locally so the result flows back to the voice
1144
+ // provider, not to a server text turn. (Same call shape as the text path.)
1145
+ const pending = this.pendingVoiceTools.get(callId);
1146
+ if (pending) {
1147
+ pending(JSON.stringify({ ok: result.ok !== false, result: result.result ?? null }));
1148
+ return { allDone: true };
1149
+ }
1150
+ const id = this.requireSession();
1151
+ const json = await this.postJson<{ allDone: boolean }>(
1152
+ `/api/companion/session/${id}/tool-result`,
1153
+ { callId, ok: result.ok !== false, result: result.result },
1154
+ opts?.signal
1155
+ );
1156
+ return { allDone: json.allDone ?? false };
1157
+ }
1158
+
1159
+ /** A tool the live voice call asked the app to run. Fires the SAME
1160
+ * companion.tool_call event the text flow uses (so the app's onToolCall +
1161
+ * sendToolResult handle voice identically), and resolves with the result
1162
+ * string fed back to the voice model. Times out so a missing result can't
1163
+ * hang the call. */
1164
+ private invokeVoiceTool(name: string, argsJson: string): Promise<string> {
1165
+ const isHostVerb = HOST_CONTROL_TOOL_NAMES.includes(name);
1166
+ const isAvatarTool = AVATAR_VISUAL_TOOL_NAMES.includes(name);
1167
+ const isDeclared = this.opts.tools?.some((t) => t.name === name) ?? false;
1168
+ if (!isHostVerb && !isAvatarTool && !isDeclared) {
1169
+ return Promise.resolve(JSON.stringify({ ok: false, error: `unknown tool: ${name}` }));
1170
+ }
1171
+ // Avatar emote the app didn't opt into (didn't declare) → silent no-op
1172
+ // success: a pure audio / 2D embed never renders a body, so the gesture /
1173
+ // expression call resolves OK (the model's turn continues) but nothing
1174
+ // happens. Declared avatar tools fall through to the app's handler below.
1175
+ if (isAvatarTool && !isDeclared) {
1176
+ return Promise.resolve(JSON.stringify({ ok: true }));
1177
+ }
1178
+ // Don't make the voice model wait ~20s if nothing can service the call.
1179
+ if (!this.handlers.get('companion.tool_call')?.size) {
1180
+ return Promise.resolve(
1181
+ JSON.stringify({ ok: false, error: 'no tool handler (register companion.onToolCall)' })
1182
+ );
1183
+ }
1184
+ // invoke_action carries the REAL action name in its params — unwrap it so it
1185
+ // reaches onToolCall exactly like a declared tool. The other host verbs
1186
+ // (set_feature/set_value/navigate/highlight) pass through by name.
1187
+ let dispatchName = name;
1188
+ let dispatchArgs = argsJson;
1189
+ if (name === 'invoke_action') {
1190
+ try {
1191
+ const a = JSON.parse(argsJson || '{}') as { action?: unknown; params?: unknown };
1192
+ if (typeof a.action !== 'string' || !a.action) {
1193
+ return Promise.resolve(JSON.stringify({ ok: false, error: 'invoke_action needs "action"' }));
1194
+ }
1195
+ dispatchName = a.action;
1196
+ dispatchArgs = JSON.stringify(a.params ?? {});
1197
+ } catch {
1198
+ return Promise.resolve(JSON.stringify({ ok: false, error: 'invoke_action args not JSON' }));
1199
+ }
1200
+ } else if (name === 'set_feature') {
1201
+ // The ElevenLabs path can only send string params, so `on` arrives as
1202
+ // "true"/"false". Normalize to a real boolean so the app's handler is
1203
+ // provider-agnostic (OpenAI already sends a boolean).
1204
+ try {
1205
+ const a = JSON.parse(argsJson || '{}') as { feature?: unknown; on?: unknown };
1206
+ const on = a.on === true || a.on === 'true';
1207
+ dispatchArgs = JSON.stringify({ feature: a.feature, on });
1208
+ } catch {
1209
+ /* leave args as-is */
1210
+ }
1211
+ }
1212
+ const id = `vc_${this.randomId()}`;
1213
+ return new Promise<string>((resolve) => {
1214
+ let settled = false;
1215
+ let timer: ReturnType<typeof setTimeout> | undefined;
1216
+ const finish = (out: string) => {
1217
+ if (settled) return;
1218
+ settled = true;
1219
+ // Clear on normal completion too — a chatty tool-using call was
1220
+ // accumulating a live 20s timer (+closure) per invocation.
1221
+ if (timer) clearTimeout(timer);
1222
+ this.pendingVoiceTools.delete(id);
1223
+ resolve(out);
1224
+ };
1225
+ this.pendingVoiceTools.set(id, finish);
1226
+ timer = setTimeout(() => finish(JSON.stringify({ ok: false, error: 'tool result timed out' })), 20_000);
1227
+ this.emit({
1228
+ v: PROTOCOL_VERSION,
1229
+ id: `evt_${this.randomId()}`,
1230
+ ts: Date.now(),
1231
+ type: 'companion.tool_call',
1232
+ payload: { id, name: dispatchName, args: dispatchArgs }
1233
+ } as CompanionEnvelope);
1234
+ });
1235
+ }
1236
+
1237
+ /** Convenience: subscribe to tool-call requests from the companion. The call
1238
+ * carries the wire payload plus `argsJson` — `args` pre-parsed as JSON
1239
+ * (undefined when empty or malformed), so handlers don't each re-implement
1240
+ * the same try/catch around `JSON.parse(args)`. */
1241
+ onToolCall(
1242
+ handler: (call: ToolCallEvent, envelope: CompanionEnvelope) => void
1243
+ ): () => void {
1244
+ return this.on('companion.tool_call', (env) => {
1245
+ const p = env.payload as { id?: unknown; name?: unknown; args?: unknown };
1246
+ if (typeof p?.id === 'string' && typeof p?.name === 'string') {
1247
+ const args = typeof p.args === 'string' ? p.args : '';
1248
+ handler({ id: p.id, name: p.name, args, argsJson: parseArgsJson(args) }, env);
1249
+ }
1250
+ });
1251
+ }
1252
+
1253
+ /** Convenience: subscribe to errors the companion surfaces — server-side agent
1254
+ * errors and stream failures (e.g. a permanent `stream_unauthorized`) both
1255
+ * arrive as `control.error`. Since 0.30.0 `code` is typed as
1256
+ * `ControlErrorCodeValue` (the CONTROL_ERROR_CODES vocabulary + the
1257
+ * SDK-synthesized stream codes, open for newer servers) so a `switch` on it
1258
+ * autocompletes. Returns an unsubscribe fn. */
1259
+ onError(
1260
+ handler: (
1261
+ err: { code: ControlErrorCodeValue; message: string },
1262
+ envelope: CompanionEnvelope
1263
+ ) => void
1264
+ ): () => void {
1265
+ return this.on('control.error', (env) => {
1266
+ const p = env.payload as { code?: unknown; message?: unknown };
1267
+ handler(
1268
+ {
1269
+ code: typeof p?.code === 'string' ? p.code : 'error',
1270
+ message: typeof p?.message === 'string' ? p.message : 'unknown error'
1271
+ },
1272
+ env
1273
+ );
1274
+ });
1275
+ }
1276
+
1277
+ /** Recall the memory this token is authorized to see. Without `query` the
1278
+ * facts come back ranked by importance/recency; with `query` the server
1279
+ * runs semantic search over them (embedding-based, falls back to ranked
1280
+ * when embeddings are unavailable) — e.g.
1281
+ * `recall({ query: 'food preferences', limit: 10 })`. */
1282
+ async recall(opts?: { limit?: number; query?: string; signal?: AbortSignal }): Promise<RecalledMemory[]> {
1283
+ const params = new URLSearchParams();
1284
+ if (opts?.limit) params.set('limit', String(opts.limit));
1285
+ if (opts?.query?.trim()) params.set('q', opts.query.trim());
1286
+ const query = params.toString();
1287
+ const qs = query ? `?${query}` : '';
1288
+ const res = await this.fetchAuthed(this.url(`/api/companion/memory${qs}`), {
1289
+ headers: { Authorization: `Bearer ${this.opts.token}` },
1290
+ ...(opts?.signal ? { signal: opts.signal } : {})
1291
+ });
1292
+ const json = (await res.json().catch(() => null)) as
1293
+ | { ok?: boolean; error?: string; code?: string; memories?: RecalledMemory[] }
1294
+ | null;
1295
+ if (!res.ok || !json || json.ok === false) {
1296
+ throw new CompanionError(json?.error || `recall failed (${res.status})`, res.status, json?.code);
1297
+ }
1298
+ return json.memories ?? [];
1299
+ }
1300
+
1301
+ /** Fetch this session's recent conversation turns (oldest→newest) so a
1302
+ * reconnecting embed can restore its transcript. Distinct from `recall`
1303
+ * (durable memory / facts) — this is the raw exchange log. Returns the
1304
+ * token's OWN session turns only; requires a live session (call `connect`
1305
+ * first). `limit` defaults to 20, capped at 50. */
1306
+ async history(opts?: { limit?: number; signal?: AbortSignal }): Promise<CompanionTurn[]> {
1307
+ const id = this.requireSession();
1308
+ const qs = opts?.limit ? `?limit=${encodeURIComponent(opts.limit)}` : '';
1309
+ const res = await this.fetchAuthed(
1310
+ this.url(`/api/companion/session/${encodeURIComponent(id)}/history${qs}`),
1311
+ {
1312
+ headers: { Authorization: `Bearer ${this.opts.token}` },
1313
+ ...(opts?.signal ? { signal: opts.signal } : {})
1314
+ }
1315
+ );
1316
+ const json = (await res.json().catch(() => null)) as
1317
+ | { ok?: boolean; error?: string; code?: string; history?: CompanionTurn[] }
1318
+ | null;
1319
+ if (!res.ok || !json || json.ok === false) {
1320
+ throw new CompanionError(json?.error || `history failed (${res.status})`, res.status, json?.code);
1321
+ }
1322
+ return json.history ?? [];
1323
+ }
1324
+
1325
+ /** Change this session's I/O modalities mid-session (e.g. enable/disable
1326
+ * `voice`). The request is intersected with the token's granted modalities
1327
+ * server-side — a session can't widen past its key — and the EFFECTIVE set is
1328
+ * returned. Requires a live session. */
1329
+ async setModalities(modalities: string[], opts?: { signal?: AbortSignal }): Promise<{ modalities: string[] }> {
1330
+ const id = this.requireSession();
1331
+ const res = await this.fetchAuthed(
1332
+ this.url(`/api/companion/session/${encodeURIComponent(id)}/modalities`),
1333
+ {
1334
+ method: 'POST',
1335
+ headers: {
1336
+ Authorization: `Bearer ${this.opts.token}`,
1337
+ 'Content-Type': 'application/json'
1338
+ },
1339
+ body: JSON.stringify({ modalities }),
1340
+ ...(opts?.signal ? { signal: opts.signal } : {})
1341
+ }
1342
+ );
1343
+ const json = (await res.json().catch(() => null)) as
1344
+ | { ok?: boolean; error?: string; code?: string; modalities?: string[] }
1345
+ | null;
1346
+ if (!res.ok || !json || json.ok === false) {
1347
+ throw new CompanionError(json?.error || `setModalities failed (${res.status})`, res.status, json?.code);
1348
+ }
1349
+ return { modalities: json.modalities ?? modalities };
1350
+ }
1351
+
1352
+ /** Keepalive: bump this session's last-seen time so a long-idle embed stays
1353
+ * "live" within the session TTL (cross-app A2A friend messages keep reaching
1354
+ * it). Cheap; call it on a timer for a background tab. Requires a live session. */
1355
+ async ping(opts?: { signal?: AbortSignal }): Promise<void> {
1356
+ const id = this.requireSession();
1357
+ const res = await this.fetchAuthed(
1358
+ this.url(`/api/companion/session/${encodeURIComponent(id)}/ping`),
1359
+ {
1360
+ method: 'POST',
1361
+ headers: { Authorization: `Bearer ${this.opts.token}` },
1362
+ ...(opts?.signal ? { signal: opts.signal } : {})
1363
+ }
1364
+ );
1365
+ if (!res.ok) {
1366
+ const json = (await res.json().catch(() => null)) as { error?: string; code?: string } | null;
1367
+ throw new CompanionError(json?.error || `ping failed (${res.status})`, res.status, json?.code);
1368
+ }
1369
+ }
1370
+
1371
+ /** Remember a fact (into this app's namespace unless `namespace` is given and
1372
+ * the token has the core scope). Intimate-tier writes are rejected server-side. */
1373
+ async remember(fact: {
1374
+ content: string;
1375
+ importance?: number;
1376
+ confidence?: number;
1377
+ /** Only these two are kept server-side — anything else is discarded, so
1378
+ * the type refuses it at compile time instead of dropping it silently. */
1379
+ category?: 'relationship' | 'shared_experience';
1380
+ kind?: string;
1381
+ namespace?: string;
1382
+ sensitivity?: 'public' | 'personal';
1383
+ }, opts?: { signal?: AbortSignal }): Promise<{ cloudId: string; namespace: string }> {
1384
+ return this.postJson<{ cloudId: string; namespace: string }>('/api/companion/memory', fact, opts?.signal);
1385
+ }
1386
+
1387
+ /** Ingest a DOCUMENT into the user's knowledge base. Unlike `remember` (one
1388
+ * short fact), this distils already-extracted text — a PDF body, a meeting
1389
+ * transcript, notes — into a headline summary PLUS full-text chunks, each
1390
+ * embedded and semantically recallable, and surfaces it in the user's "My
1391
+ * materials" list with source attribution, exactly like a first-party upload.
1392
+ * So the materials entry point can live in YOUR app, not just Pouchy's.
1393
+ *
1394
+ * Extract the text yourself (your own parser, or Pouchy's /api/stt for audio —
1395
+ * send your pchy_ token as the bearer; the relay is auth-gated and metered)
1396
+ * and pass it here. Writes the user's SHARED knowledge, so the token must
1397
+ * hold `memory.write:core` (the user's consent) — otherwise 403. Embeddings
1398
+ * are computed on the user's next first-party open (eventually-consistent
1399
+ * semantic recall). `kind` is a free label for the source type (pdf / audio /
1400
+ * note / …) used only for display + the materials icon. */
1401
+ async ingestKnowledge(doc: {
1402
+ text: string;
1403
+ name: string;
1404
+ kind?: string;
1405
+ locale?: string;
1406
+ }, opts?: { signal?: AbortSignal }): Promise<{ ok: boolean; summary: string; chunks: number }> {
1407
+ return this.postJson<{ ok: boolean; summary: string; chunks: number }>(
1408
+ '/api/companion/knowledge',
1409
+ doc,
1410
+ opts?.signal
1411
+ );
1412
+ }
1413
+
1414
+ /** Convenience over `ingestKnowledge`: hand over a RAW file and Pouchy
1415
+ * understands it server-side before ingesting — no parser/transcriber/vision on
1416
+ * your side. Pass a `data:<mime>;base64,…` URL. Supported today: **PDF**
1417
+ * (server-side text extraction), **audio/video** (Whisper transcript), and
1418
+ * **image** (server-side vision caption — an objective description becomes the
1419
+ * material). For other types, extract the text yourself and call
1420
+ * `ingestKnowledge`. Same `memory.write:core` requirement and "My materials"
1421
+ * integration as `ingestKnowledge`. */
1422
+ async ingestFile(file: {
1423
+ dataUrl: string;
1424
+ name: string;
1425
+ kind?: string;
1426
+ locale?: string;
1427
+ }, opts?: { signal?: AbortSignal }): Promise<{ ok: boolean; summary: string; chunks: number }> {
1428
+ return this.postJson<{ ok: boolean; summary: string; chunks: number }>(
1429
+ '/api/companion/knowledge/file',
1430
+ file,
1431
+ opts?.signal
1432
+ );
1433
+ }
1434
+
1435
+ /** Fetch the user's CURRENT companion avatar so the embedding can render the
1436
+ * same virtual human Pouchy shows. The avatar is a VRM 3D model (`vrmUrl`,
1437
+ * load it with a VRM/glTF renderer e.g. three-vrm); `imageUrl` is a flat 2D
1438
+ * portrait when one exists (null today for built-in models). URLs are absolute
1439
+ * and the /models asset is CORS-enabled, so a cross-origin renderer can fetch
1440
+ * the VRM directly. Reflects the user's live choice (built-in or custom). */
1441
+ async getAvatar(opts?: { signal?: AbortSignal }): Promise<CompanionAvatar> {
1442
+ const res = await this.fetchAuthed(this.url('/api/companion/avatar'), {
1443
+ headers: { Authorization: `Bearer ${this.opts.token}` },
1444
+ ...(opts?.signal ? { signal: opts.signal } : {})
1445
+ });
1446
+ const json = (await res.json().catch(() => null)) as
1447
+ | ({ ok?: boolean; error?: string; code?: string } & Partial<CompanionAvatar>)
1448
+ | null;
1449
+ if (!res.ok || !json || json.ok === false) {
1450
+ throw new CompanionError(json?.error || `avatar fetch failed (${res.status})`, res.status, json?.code);
1451
+ }
1452
+ return {
1453
+ name: json.name ?? null,
1454
+ archetype: json.archetype ?? 'girlfriend',
1455
+ modelId: json.modelId ?? null,
1456
+ vrmUrl: json.vrmUrl ?? null,
1457
+ imageUrl: json.imageUrl ?? null
1458
+ };
1459
+ }
1460
+
1461
+ /** Read the companion instance's OWN wallet — nonzero stablecoin balances +
1462
+ * total USD. Read-only and receive-only (spends go through the confirm-gated
1463
+ * pay tools, never here). Requires the `wallet.read` scope (or `wallet.spend`,
1464
+ * which subsumes it). Same data the companion gives when asked "what's my
1465
+ * balance?". */
1466
+ async getWallet(opts?: { signal?: AbortSignal }): Promise<CompanionWallet> {
1467
+ const res = await this.fetchAuthed(this.url('/api/companion/wallet'), {
1468
+ headers: { Authorization: `Bearer ${this.opts.token}` },
1469
+ ...(opts?.signal ? { signal: opts.signal } : {})
1470
+ });
1471
+ const json = (await res.json().catch(() => null)) as
1472
+ | ({ ok?: boolean; error?: string; code?: string } & Partial<CompanionWallet>)
1473
+ | null;
1474
+ if (!res.ok || !json || json.ok === false) {
1475
+ throw new CompanionError(json?.error || `wallet fetch failed (${res.status})`, res.status, json?.code);
1476
+ }
1477
+ return {
1478
+ balances: json.balances ?? [],
1479
+ totalUsd: json.totalUsd ?? 0,
1480
+ currency: json.currency ?? 'USD'
1481
+ };
1482
+ }
1483
+
1484
+ /** Canonical URL of the official Pouchy brand icon (square PNG, transparent
1485
+ * background) at the given size. Static + public — needs no token — so it's
1486
+ * safe to drop straight into an `<img src>` for "powered by Pouchy" badges,
1487
+ * the companion's app tile, etc. Sizes: 256 | 512 | 1024 (default 512). */
1488
+ brandIconUrl(size: BrandIconSize = 512): string {
1489
+ return pouchyBrandIconUrl(this.opts.baseUrl, size);
1490
+ }
1491
+
1492
+ /** Subscribe to outbound events of a given type, or "*" for all. Returns an
1493
+ * unsubscribe function. Since 0.30.0 the envelope payload is NARROWED per
1494
+ * event name (OutboundPayloadMap) — e.g. `on('companion.typing', (env) =>
1495
+ * env.payload.active)` type-checks without a cast; `'*'` keeps the untyped
1496
+ * envelope. Type-only: runtime behavior is unchanged. */
1497
+ on<T extends OutboundType>(
1498
+ type: T,
1499
+ handler: (envelope: CompanionEnvelope<OutboundPayloadMap[T]>) => void
1500
+ ): () => void;
1501
+ on(type: '*', handler: Handler): () => void;
1502
+ on(type: OutboundType | '*', handler: Handler): () => void {
1503
+ let set = this.handlers.get(type);
1504
+ if (!set) {
1505
+ set = new Set();
1506
+ this.handlers.set(type, set);
1507
+ }
1508
+ set.add(handler);
1509
+ return () => set?.delete(handler);
1510
+ }
1511
+
1512
+ /** Convenience: subscribe to assistant text replies. */
1513
+ onMessage(handler: (text: string, envelope: CompanionEnvelope) => void): () => void {
1514
+ return this.on('companion.message', (env) => {
1515
+ const text = (env.payload as { text?: unknown })?.text;
1516
+ if (typeof text === 'string') handler(text, env);
1517
+ });
1518
+ }
1519
+
1520
+ /** Convenience: subscribe to Instant UI render surfaces (companion.ui_action).
1521
+ * The host draws `payload.interface` with its OWN renderer — a web component, a
1522
+ * native iOS/Android view tree, a CLI/TUI — all consuming the same
1523
+ * platform-neutral genui schema. If the panel set `reportChanges`, feed the
1524
+ * user's edits back via `sendText`/`sendWorldState` to continue the turn. */
1525
+ onRender(
1526
+ handler: (payload: RenderInterfacePayload, envelope: CompanionEnvelope) => void
1527
+ ): () => void {
1528
+ return this.on('companion.ui_action', (env) => {
1529
+ const p = env.payload as RenderInterfacePayload;
1530
+ if (p && typeof p === 'object' && Array.isArray(p.interface?.nodes)) handler(p, env);
1531
+ });
1532
+ }
1533
+
1534
+ /** Convenience: subscribe to TTS audio clips for the reply (companion.audio,
1535
+ * non-call modality) — play `url`, or resolve `ref` your way. */
1536
+ onAudio(handler: (payload: AudioClipPayload, envelope: CompanionEnvelope) => void): () => void {
1537
+ return this.on('companion.audio', (env) => {
1538
+ const p = env.payload as AudioClipPayload;
1539
+ if (p && typeof p === 'object' && (typeof p.url === 'string' || typeof p.ref === 'string')) {
1540
+ handler(p, env);
1541
+ }
1542
+ });
1543
+ }
1544
+
1545
+ /** Convenience: subscribe to avatar cues (companion.expression) — viseme /
1546
+ * expression / gesture for an embed rendering the companion's body. */
1547
+ onExpression(handler: (payload: ExpressionPayload, envelope: CompanionEnvelope) => void): () => void {
1548
+ return this.on('companion.expression', (env) => handler(env.payload as ExpressionPayload, env));
1549
+ }
1550
+
1551
+ /** Convenience: subscribe to the per-token metering echo (control.usage) for a
1552
+ * usage / billing view. */
1553
+ onUsage(handler: (payload: UsagePayload, envelope: CompanionEnvelope) => void): () => void {
1554
+ return this.on('control.usage', (env) => handler(env.payload as UsagePayload, env));
1555
+ }
1556
+
1557
+ /** Convenience: subscribe to sensitive-op confirmation requests
1558
+ * (companion.confirm_request) — a payment, skill run, or friend message the
1559
+ * companion wants the user to approve. Use it to surface the pending
1560
+ * approval in your UI.
1561
+ *
1562
+ * Whether YOU can resolve it depends on the token:
1563
+ * - **Platform session tokens** (minted via /v1/sessions for your end
1564
+ * users): show your own confirm card and call `confirmAction` — your end
1565
+ * user is the account's human, so the session may approve. This is how
1566
+ * confirm-gated custom skills (POST / credentialed) run.
1567
+ * - **First-party user tokens** (Login-with-Pouchy PATs): observe-only —
1568
+ * approval is authed as the Pouchy user (their app / a hosted confirm
1569
+ * page), which is also where the biometric (Face ID / passkey) gate
1570
+ * lives. `confirmAction` returns 401 on these tokens. */
1571
+ onConfirmRequest(
1572
+ handler: (payload: ConfirmRequestPayload, envelope: CompanionEnvelope) => void
1573
+ ): () => void {
1574
+ return this.on('companion.confirm_request', (env) => {
1575
+ const p = env.payload as ConfirmRequestPayload;
1576
+ if (p && typeof p === 'object' && typeof p.confirmId === 'string') handler(p, env);
1577
+ });
1578
+ }
1579
+
1580
+ /** Resolve a pending confirmation (platform session tokens only — see
1581
+ * `onConfirmRequest`). Show the user what they're approving (the event's
1582
+ * `summary`), collect an explicit tap, then pass their decision here. On
1583
+ * approval the recorded action runs server-side; its result comes back as
1584
+ * `outcome` in this response (render it where the user tapped) AND as a
1585
+ * normal companion.message on the stream (so the conversation shows it).
1586
+ * A denial returns/emits a brief decline the same way. Single-use:
1587
+ * re-resolving a settled confirmId fails with 409.
1588
+ *
1589
+ * RETRY: if an approved IDEMPOTENT action (a wallet payment) flakes on a
1590
+ * transient error, `status` is `'exec_failed'` and `retryable` is true — you
1591
+ * MAY call `confirmAction(sameConfirmId, true)` again to re-run it (the server
1592
+ * dedupes a partial first attempt). A non-idempotent action (a message / skill)
1593
+ * stays terminal and is never retryable. */
1594
+ async confirmAction(
1595
+ confirmId: string,
1596
+ approve: boolean,
1597
+ opts?: { signal?: AbortSignal }
1598
+ ): Promise<{
1599
+ ok: boolean;
1600
+ status: 'approved' | 'denied' | 'exec_failed';
1601
+ outcome?: string;
1602
+ retryable?: boolean;
1603
+ }> {
1604
+ const sessionId = this.requireSession();
1605
+ return this.postJson<{
1606
+ ok: boolean;
1607
+ status: 'approved' | 'denied' | 'exec_failed';
1608
+ outcome?: string;
1609
+ retryable?: boolean;
1610
+ }>(`/api/companion/session/${encodeURIComponent(sessionId)}/confirm`, { confirmId, approve }, opts?.signal);
1611
+ }
1612
+
1613
+ /** The session's still-pending confirmations (display-safe: id, summary,
1614
+ * scope, timestamps — never raw args). Use it to rebuild your confirm card
1615
+ * after a reload, since confirm_request events are not replayed. Platform
1616
+ * session tokens only, like `confirmAction`. */
1617
+ async pendingConfirms(opts?: { signal?: AbortSignal }): Promise<PendingConfirm[]> {
1618
+ const sessionId = this.requireSession();
1619
+ const res = await this.fetchAuthed(
1620
+ this.url(`/api/companion/session/${encodeURIComponent(sessionId)}/confirm`),
1621
+ {
1622
+ headers: { Authorization: `Bearer ${this.opts.token}` },
1623
+ ...(opts?.signal ? { signal: opts.signal } : {})
1624
+ }
1625
+ );
1626
+ const json = (await res.json().catch(() => null)) as
1627
+ | { ok?: boolean; error?: string; code?: string; pending?: PendingConfirm[] }
1628
+ | null;
1629
+ if (!res.ok || !json || json.ok === false) {
1630
+ throw new CompanionError(json?.error || `pending confirms failed (${res.status})`, res.status, json?.code);
1631
+ }
1632
+ return json.pending ?? [];
1633
+ }
1634
+
1635
+ /** Convenience: subscribe to live updates of an already-rendered Instant UI
1636
+ * panel (companion.ui_update). Apply each `{ key, value }` to the panel's state
1637
+ * bag and re-evaluate bound displays — no rebuild. */
1638
+ onInterfaceUpdate(
1639
+ handler: (payload: InterfaceUpdatePayload, envelope: CompanionEnvelope) => void
1640
+ ): () => void {
1641
+ return this.on('companion.ui_update', (env) => {
1642
+ const p = env.payload as InterfaceUpdatePayload;
1643
+ if (p && typeof p === 'object' && Array.isArray(p.update?.updates)) handler(p, env);
1644
+ });
1645
+ }
1646
+
1647
+ /** Convenience: subscribe to inbound A2A messages from the user's paired
1648
+ * friends (companion.social_message), delivered cross-app to any embed whose
1649
+ * token holds `social.message`. Surface them in your own UI / notify the user. */
1650
+ onSocialMessage(
1651
+ handler: (payload: SocialMessagePayload, envelope: CompanionEnvelope) => void
1652
+ ): () => void {
1653
+ return this.on('companion.social_message', (env) => {
1654
+ const p = env.payload as SocialMessagePayload;
1655
+ if (p && typeof p === 'object' && typeof p.content === 'string') handler(p, env);
1656
+ });
1657
+ }
1658
+
1659
+ /** Convenience: subscribe to the companion's activity indicator
1660
+ * (companion.typing). Fires `active:true` when a turn starts working and
1661
+ * `active:false` when it finishes or pauses for a tool result — spanning the
1662
+ * tool-loop / thinking phase before the first text delta. Drive a "typing…"
1663
+ * affordance from it: `client.onTyping(({ active }) => setTyping(active))`. */
1664
+ onTyping(handler: (payload: TypingPayload, envelope: CompanionEnvelope) => void): () => void {
1665
+ return this.on('companion.typing', (env) => {
1666
+ const p = env.payload as TypingPayload;
1667
+ if (p && typeof p === 'object' && typeof p.active === 'boolean') handler(p, env);
1668
+ });
1669
+ }
1670
+
1671
+ /** Convenience: subscribe to voice-inject cues (companion.voice_inject) — a
1672
+ * `voiceRelevant` world-state line the companion should say aloud during a
1673
+ * live call. Route `payload.text` to your active voice session when
1674
+ * `payload.speak`. A text-only host can ignore it. */
1675
+ onVoiceInject(
1676
+ handler: (payload: VoiceInjectPayload, envelope: CompanionEnvelope) => void
1677
+ ): () => void {
1678
+ return this.on('companion.voice_inject', (env) => {
1679
+ const p = env.payload as VoiceInjectPayload;
1680
+ if (p && typeof p === 'object' && typeof p.text === 'string') handler(p, env);
1681
+ });
1682
+ }
1683
+
1684
+ /** Open the event stream (auto-reconnecting with the resume cursor). Uses the
1685
+ * WebSocket plane when opted in + available, else SSE. */
1686
+ start(): void {
1687
+ if (this.streaming) return;
1688
+ this.requireSession();
1689
+ this.streaming = true;
1690
+ this.setStreamState('connecting');
1691
+ const gen = ++this.streamGen;
1692
+ const wsImpl = this.opts.webSocketImpl ?? (globalThis as { WebSocket?: typeof WebSocket }).WebSocket;
1693
+ if (this.opts.stream === 'websocket' && wsImpl) {
1694
+ void this.wsLoop(wsImpl, gen);
1695
+ } else {
1696
+ void this.loop(gen);
1697
+ }
1698
+ }
1699
+
1700
+ /** WebSocket receive loop. Resolves when the socket closes; on any failure
1701
+ * while still streaming it falls back to the SSE loop, so opting into WS can
1702
+ * only ever DEGRADE to SSE, never drop replies. Input is still sent over
1703
+ * REST (sendInput) — receiving is the latency-sensitive half. */
1704
+ private async wsLoop(WS: typeof WebSocket, gen: number): Promise<void> {
1705
+ const id = this.requireSession();
1706
+ const { deriveWsStreamUrl, decodeWsMessage } = await import('./ws-transport');
1707
+ // Reconnect across CLEAN closes (a WS gateway with a bounded lifetime, like
1708
+ // the SSE endpoint's window) just as the SSE loop does — a graceful close
1709
+ // must not silently end reply delivery. Only a connection ERROR degrades to
1710
+ // SSE, and only once. Each connection's poll timer + socket are released in
1711
+ // the settle() finally so neither leaks after fallback/reconnect.
1712
+ let backoff = 500;
1713
+ while (this.streaming && gen === this.streamGen) {
1714
+ let errored = false;
1715
+ const connectedAt = Date.now();
1716
+ try {
1717
+ await new Promise<void>((resolve, reject) => {
1718
+ const ws = new WS(deriveWsStreamUrl(this.opts.baseUrl, id, this.opts.token, this.cursor));
1719
+ let poll: ReturnType<typeof setInterval> | undefined;
1720
+ let settled = false;
1721
+ ws.onopen = () => {
1722
+ if (this.streaming && gen === this.streamGen) this.setStreamState('connected');
1723
+ };
1724
+ const settle = (err?: unknown) => {
1725
+ if (poll) {
1726
+ clearInterval(poll);
1727
+ poll = undefined;
1728
+ }
1729
+ try {
1730
+ ws.close();
1731
+ } catch {
1732
+ /* already closing */
1733
+ }
1734
+ if (settled) return;
1735
+ settled = true;
1736
+ if (err) reject(err);
1737
+ else resolve();
1738
+ };
1739
+ ws.onmessage = (ev: MessageEvent) => {
1740
+ if (!this.streaming || gen !== this.streamGen) {
1741
+ settle();
1742
+ return;
1743
+ }
1744
+ const raw = typeof ev.data === 'string' ? ev.data : '';
1745
+ const frame = raw ? decodeWsMessage(raw) : null;
1746
+ if (frame) this.handleFrame(frame.event, frame.data);
1747
+ };
1748
+ ws.onerror = () => settle(new Error('companion ws error'));
1749
+ ws.onclose = () => settle();
1750
+ // Stopping the stream closes the socket + clears this timer.
1751
+ poll = setInterval(() => {
1752
+ if (!this.streaming || gen !== this.streamGen) settle();
1753
+ }, 250);
1754
+ });
1755
+ } catch {
1756
+ errored = true;
1757
+ }
1758
+ if (errored) {
1759
+ // Socket unavailable / rejected — degrade to the robust SSE loop (once).
1760
+ // The SSE loop's healthy state then reads `degraded_sse`, so the host
1761
+ // can observe the (previously invisible) WS→SSE degrade.
1762
+ if (this.streaming && gen === this.streamGen) {
1763
+ this.setStreamState('reconnecting');
1764
+ await this.loop(gen, true);
1765
+ }
1766
+ return;
1767
+ }
1768
+ // Clean close: the while-guard reconnects if still streaming, else exits.
1769
+ // A SHORT-LIVED connection (gateway that accepts then immediately
1770
+ // closes — auth-as-close, LB idle policy, misconfigured proxy) must
1771
+ // back off like the SSE loop, or this reconnects in a zero-delay hot
1772
+ // loop. A connection that lived a while resets the backoff.
1773
+ if (this.streaming && gen === this.streamGen) this.setStreamState('reconnecting');
1774
+ if (Date.now() - connectedAt < 2000) {
1775
+ await this.backoffSleep(backoff);
1776
+ backoff = Math.min(backoff * 2, 8000);
1777
+ } else {
1778
+ backoff = 500;
1779
+ }
1780
+ }
1781
+ }
1782
+
1783
+ /** Stop the event stream. */
1784
+ stop(): void {
1785
+ this.streaming = false;
1786
+ this.streamGen++; // invalidate any loop still unwinding
1787
+ this.abort?.abort();
1788
+ this.abort = null;
1789
+ this.setStreamState('stopped');
1790
+ }
1791
+
1792
+ private emit(envelope: CompanionEnvelope): void {
1793
+ // Dedup by envelope id — an ungraceful drop reconnects from the last KNOWN
1794
+ // cursor (advanced only on the clean `reconnect` frame), which can replay a
1795
+ // few events; the id set collapses those.
1796
+ if (envelope.id) {
1797
+ if (this.seen.has(envelope.id)) return;
1798
+ this.seen.add(envelope.id);
1799
+ if (this.seen.size > SEEN_CAP) {
1800
+ const first = this.seen.values().next().value;
1801
+ if (first !== undefined) this.seen.delete(first);
1802
+ }
1803
+ }
1804
+ // Post-dedup: the debug channel sees each envelope exactly once, in
1805
+ // delivery order — replays collapsed above never reach it.
1806
+ this.dbg({ kind: 'envelope', at: Date.now(), type: envelope.type, id: envelope.id ?? '' });
1807
+ // Avatar emote tools the app didn't opt into resolve as a silent no-op, so a
1808
+ // pure audio / 2D embed never has to register a handler for them. (Voice
1809
+ // avatar calls are no-op'd earlier in invokeVoiceTool, before they reach
1810
+ // here; this covers the text / event-stream path.) Declared = the app opted
1811
+ // in → deliver to its onToolCall like any other tool.
1812
+ if (envelope.type === 'companion.tool_call') {
1813
+ const p = envelope.payload as { id?: unknown; name?: unknown };
1814
+ const name = typeof p?.name === 'string' ? p.name : '';
1815
+ const callId = typeof p?.id === 'string' ? p.id : '';
1816
+ const declared = this.opts.tools?.some((t) => t.name === name) ?? false;
1817
+ if (
1818
+ name &&
1819
+ AVATAR_VISUAL_TOOL_NAMES.includes(name) &&
1820
+ !declared &&
1821
+ !this.pendingVoiceTools.has(callId)
1822
+ ) {
1823
+ if (callId) void this.sendToolResult(callId, { ok: true }).catch(() => {});
1824
+ return;
1825
+ }
1826
+ }
1827
+ // Each handler is isolated: a THROWING consumer handler must not skip the
1828
+ // remaining handlers, and — worse — it used to propagate up through the
1829
+ // stream loop's catch, which read it as a CONNECTION failure (backoff +
1830
+ // reconnect + cursor replay); on the sendText local-emit path it rejected
1831
+ // a sendText whose turn had actually succeeded.
1832
+ for (const h of this.handlers.get(envelope.type) ?? []) {
1833
+ try {
1834
+ h(envelope);
1835
+ } catch (e) {
1836
+ console.error('[companion-sdk] event handler threw', e);
1837
+ }
1838
+ }
1839
+ for (const h of this.handlers.get('*') ?? []) {
1840
+ try {
1841
+ h(envelope);
1842
+ } catch (e) {
1843
+ console.error('[companion-sdk] event handler threw', e);
1844
+ }
1845
+ }
1846
+ }
1847
+
1848
+ private handleFrame(event: string, data: string): void {
1849
+ if (event === 'reconnect') {
1850
+ try {
1851
+ const d = JSON.parse(data) as { cursor?: number };
1852
+ if (typeof d.cursor === 'number') this.cursor = d.cursor;
1853
+ } catch {
1854
+ /* ignore */
1855
+ }
1856
+ return;
1857
+ }
1858
+ let env: CompanionEnvelope | null = null;
1859
+ try {
1860
+ env = JSON.parse(data) as CompanionEnvelope;
1861
+ } catch {
1862
+ return;
1863
+ }
1864
+ if (event === 'open') {
1865
+ const rc = (env?.payload as { resumeCursor?: number })?.resumeCursor;
1866
+ if (typeof rc === 'number') this.cursor = rc;
1867
+ return;
1868
+ }
1869
+ if (env && typeof env.type === 'string') this.emit(env);
1870
+ }
1871
+
1872
+ /** SSE receive loop. `degraded` marks that this loop is the WS transport's
1873
+ * fallback, so its healthy state reads `degraded_sse` (still live) instead
1874
+ * of `connected` — the host can tell "fine" from "fine, but on plan B". */
1875
+ private async loop(gen: number, degraded = false): Promise<void> {
1876
+ const id = this.requireSession();
1877
+ let backoff = 500;
1878
+ let authRefreshes = 0; // consecutive 401→refresh cycles without a healthy connect
1879
+ while (this.streaming && gen === this.streamGen) {
1880
+ this.abort = new AbortController();
1881
+ try {
1882
+ const res = await this.doFetch(
1883
+ this.url(`/api/companion/session/${id}/stream?cursor=${this.cursor}`),
1884
+ { headers: { Authorization: `Bearer ${this.opts.token}` }, signal: this.abort.signal }
1885
+ );
1886
+ if (res.status === 401 || res.status === 403) {
1887
+ // 401 = the token itself is bad — usually an EXPIRED session token
1888
+ // (they live ~1h). With an onAuthError hook this is recoverable:
1889
+ // refresh and reconnect instead of going permanently silent. The
1890
+ // counter stops a hot loop when the hook keeps returning a token
1891
+ // the server keeps rejecting.
1892
+ if (res.status === 401 && authRefreshes < 3 && (await this.tryRefreshToken())) {
1893
+ authRefreshes += 1;
1894
+ continue;
1895
+ }
1896
+ // Permanent: the token can't subscribe (most often it lacks the
1897
+ // `events.subscribe` scope; also revoked / wrong user). Retrying is
1898
+ // futile and would silently swallow every reply — surface it as a
1899
+ // control.error and stop the loop.
1900
+ this.streaming = false;
1901
+ this.emit({
1902
+ v: PROTOCOL_VERSION,
1903
+ id: `stream_err_${Date.now()}`,
1904
+ session: this._session ?? undefined,
1905
+ ts: Date.now(),
1906
+ type: 'control.error',
1907
+ payload: {
1908
+ code: 'stream_unauthorized',
1909
+ message: `event stream rejected (${res.status}); the token likely lacks the "events.subscribe" scope`
1910
+ }
1911
+ });
1912
+ this.setStreamState('stopped');
1913
+ break;
1914
+ }
1915
+ if (!res.ok || !res.body) {
1916
+ if (!this.streaming || gen !== this.streamGen) break;
1917
+ this.setStreamState('reconnecting');
1918
+ await this.backoffSleep(backoff);
1919
+ backoff = Math.min(backoff * 2, 8000);
1920
+ continue;
1921
+ }
1922
+ backoff = 500; // healthy connection resets backoff
1923
+ authRefreshes = 0;
1924
+ this.setStreamState(degraded ? 'degraded_sse' : 'connected');
1925
+ await this.consume(res.body, gen);
1926
+ } catch {
1927
+ if (!this.streaming || gen !== this.streamGen) break;
1928
+ this.setStreamState('reconnecting');
1929
+ await this.backoffSleep(backoff);
1930
+ backoff = Math.min(backoff * 2, 8000);
1931
+ }
1932
+ }
1933
+ }
1934
+
1935
+ private async consume(body: ReadableStream<Uint8Array>, gen: number): Promise<void> {
1936
+ const reader = body.getReader();
1937
+ const decoder = new TextDecoder();
1938
+ let buf = '';
1939
+ while (this.streaming && gen === this.streamGen) {
1940
+ const { done, value } = await reader.read();
1941
+ if (done) break;
1942
+ buf += decoder.decode(value, { stream: true });
1943
+ const { events, rest } = parseSse(buf);
1944
+ buf = rest;
1945
+ for (const ev of events) this.handleFrame(ev.event, ev.data);
1946
+ }
1947
+ }
1948
+
1949
+ private sleep(ms: number): Promise<void> {
1950
+ return new Promise((res) => setTimeout(res, ms));
1951
+ }
1952
+
1953
+ /** Backoff sleep with additive jitter (0–25%, 0.30.0) so a fleet of embeds
1954
+ * doesn't reconnect in lockstep after a server deploy/restart (thundering
1955
+ * herd) — every reconnect path sleeps through here, never bare sleep(). */
1956
+ private backoffSleep(ms: number): Promise<void> {
1957
+ return this.sleep(ms + Math.floor(Math.random() * ms * 0.25));
1958
+ }
1959
+ }