// Companion SDK — the client a third-party surface (web / app / game / hardware) // uses to embed the Pouchy companion in a few lines. Wraps the REST/SSE plane: // const c = createCompanion({ baseUrl, token }); // await c.connect(); // c.onMessage((text) => render(text)); // c.start(); // c.sendWorldState({ type: 'game.player.hp', data: { hp: 12 }, retained: true }); // await c.sendText('how am I doing?'); // // Pure, dependency-free, isomorphic (browser + Node 18+ fetch). The transport is // an internal detail: the default REST/SSE plane, or an opt-in WebSocket plane // (ws-transport.ts) that degrades back to SSE on failure — both sit behind this // same API. Protocol types are a self-contained copy of the server's contract, // kept in lockstep by protocol.drift.test.ts. import type { AudioClipPayload, CompanionEnvelope, ConfirmRequestPayload, ExpressionPayload, InterfaceUpdatePayload, MessagePayload, MessageAttachment, OutboundPayloadMap, OutboundType, PendingConfirm, PendingToolCall, RenderInterfacePayload, SocialMessagePayload, ToolCallPayload, TypingPayload, UsagePayload, VoiceInjectPayload, WorldStateEvent } from './protocol'; import { PROTOCOL_VERSION } from './protocol'; /** Ergonomic world-state input: a WorldStateEvent with the CloudEvents plumbing * (specversion / id / source) optional — sendWorldState fills them. */ export type WorldStateInput = Omit< WorldStateEvent, 'specversion' | 'id' | 'source' > & Partial, 'specversion' | 'id' | 'source'>>; import { parseSse } from './sse'; import { CompanionError, type ControlErrorCodeValue } from './errors'; import { openCompanionCall, type CompanionCall, type CompanionCallOptions, type VoiceToolBridge, HOST_CONTROL_TOOLS, HOST_CONTROL_TOOL_NAMES, AVATAR_VISUAL_TOOLS, AVATAR_VISUAL_TOOL_NAMES } from './call'; export interface CompanionClientOptions { /** Origin of the Pouchy deployment, e.g. "https://pouchy.ai". */ baseUrl: string; /** A Pouchy access token — normally a session token from POST /v1/sessions; * a PAT (pchy_…) for personal integrations. */ token: string; /** Logical surface (one resumable session per surface), default "default". */ surface?: string; /** Requested I/O modalities (intersected with the token's grant server-side). */ modalities?: string[]; /** Action types this surface can perform (declared at handshake). Stored but * never consumed server-side today — a client-side capability hint whose one * SDK effect is opting a tool-less embed into the auto-offered host-control * verbs on connectCall() voice sessions. It does NOT register tools for text * turns: a tool you want called on a text turn must be declared in `tools`. */ handles?: string[]; /** World-state kinds this surface emits (declared at handshake). */ contextKinds?: string[]; /** Tools the companion may ask this surface to perform (companion.tool_call). */ tools?: CompanionToolDecl[]; /** Static description of THIS app/game so the companion is grounded in where it * is (drives reasoning + the call opener). Live state still flows via * sendWorldState. */ appContext?: { name?: string; description?: string }; /** Open a REPRESENTATIVE ("on-behalf-of" / 代聊) session: the companion fields * this VISITOR's messages on the owner's behalf — customer-service style — * drawing only on screened owner context (persona + share-screened facts, plus * the knowledge base when the owner's token grants `expose:knowledge`). Each * visitor (stable opaque `id`, 8-64 url-safe chars that YOU keep stable per * end-user) gets their own continuous thread. Requires the token's `represent` * scope — without it the handshake is rejected (never silently downgraded to an * owner-facing session). */ visitor?: { id: string; displayName?: string }; /** Injectable fetch (for Node < 18 / tests). Defaults to global fetch. */ fetch?: typeof fetch; /** Instrumentation (0.32.0). `true` logs structured events via * console.debug('[pouchy-sdk]', …); a function receives every * `CompanionDebugEvent` (HTTP request/response, delivered envelope, * stream-state transition, synthesized error) for your own logger / * devtools / test recorder. Events never carry the token or any header — * method, path, status, timing, envelope type/id only. Zero cost when * unset. */ debug?: boolean | ((event: CompanionDebugEvent) => void); /** Receive transport for the reply stream. 'sse' (default) works on every * deploy; 'websocket' opts into the lower-latency WS plane when the host * supports it (adapter-node / a WS gateway) and falls back to SSE if the * socket can't open — so enabling it can never stop reply delivery. */ stream?: 'sse' | 'websocket'; /** Origin the WebSocket plane is served from (0.59.0), e.g. * "https://ws.example.com" — the self-hosted WS gateway * (`workers/ws-gateway`). Only the reply socket goes here; every REST call * and the SSE fallback still use `baseUrl`. Unset = derive the socket URL * from `baseUrl`, which against pouchy.ai (no socket route) degrades to * SSE. `http(s)://` is rewritten to `ws(s)://`. */ streamBaseUrl?: string; /** Injectable WebSocket constructor (Node without a global WebSocket, or * tests). Defaults to globalThis.WebSocket when present. */ webSocketImpl?: typeof WebSocket; /** Called when a request or the event stream is rejected with 401 (expired / * revoked token — session tokens live 1h by default). Return a fresh token * (e.g. re-minted by your backend via POST /v1/sessions) and the client * retries transparently; return null to give up, surfacing the failure * exactly as without this hook. Scope denials (403 `missing_scope`) are * configuration errors and never trigger it. Alternative: call `setToken()` * proactively on your own refresh schedule. */ onAuthError?: () => string | null | Promise; /** Opt-in offline send queue (0.34.0). A sendText that fails at the NETWORK * level (fetch rejected before any HTTP response, or `navigator.onLine` is * false) is queued instead of lost, and replayed FIFO once the server is * reachable again — after a successful connect(), on every stream * reconnect, or via `flushOutbox()`. Replays reuse the ORIGINAL turnId: * the server dedupes completed turnIds (60 min TTL, last 32 per session — * `RECENT_TURN_TTL_MS`, widened in 0.39.0), * so a retry after an ambiguous failure can never double-run a turn. HTTP * error responses (4xx/5xx incl. 429) are NEVER queued — the server saw * those; they keep the normal throw semantics. Default false. */ queueOffline?: boolean; /** Replay of pause-orphaned tool calls (0.37.0). When `connect()` resumes a * session whose turn is paused on tool calls THIS surface never answered * (the page reloaded / crashed mid-pause), the handshake carries them as * `pendingToolCalls` and the client re-emits each to your onToolCall * handler with `replayed: true` — post the results as usual and the paused * turn completes instead of being abandoned. Register onToolCall BEFORE * calling connect()/start() to receive them. The result apply is * idempotent per call id, but the APP-side effect may not be: if a tool * has side effects, treat `call.id` as an idempotency key when * `replayed` is set. Pass false to disable the re-emit — the calls stay * readable on the `HelloAck.pendingToolCalls` you get from connect(). * Default true. */ replayPendingToolCalls?: boolean; /** Persistence for the offline queue (see `queueOffline`). Defaults to * localStorage when available (privacy-mode / quota failures fall back * silently), else an in-memory page-lifetime store. The key is * `pouchy-outbox:${surface}`, so each surface keeps its own queue — * and, on a REPRESENTATIVE session (see `visitor`), * `pouchy-outbox-v:${surface}:${visitorId}`, so one visitor's queued * sends can never replay into another visitor's thread (0.50.1). */ outboxStore?: OutboxStore; /** Deadline for a request to produce RESPONSE HEADERS, in ms (0.35.0). * Bounds every HTTP call the client makes (connect, sendText, recall, …) so * a server that accepts the TCP connection but never answers can't hang a * helper forever — previously only `sendText({ awaitReply })` had any * deadline. Reading a response BODY (the SSE event stream, a streaming * sendText reply) is never bounded by this — the timer is cleared the * moment headers arrive. Composes with any per-call AbortSignal (whichever * fires first wins); the timeout rejects as a status-0 CompanionError with * code 'request_timeout'. Default 30_000; 0 disables. * * BUFFERED `/input` sends are the one exception to the 30s default * (0.40.0): there the server answers only when the TURN completes, so the * default deadline for those POSTs is 310s — the server's own turn ceiling * — instead of 30s (which declared healthy >30s turns dead while they kept * running, and billing, server-side). Setting `requestTimeoutMs` explicitly * bounds EVERY request at your value, buffered sends included. */ requestTimeoutMs?: number; } export interface HelloAck { session: string; grantedScopes: string[]; modalities: string[]; resumeCursor: number; /** True when this is a representative (visitor-facing) session — i.e. a * `visitor` was supplied and the token holds `represent`. */ representative: boolean; /** Representative sessions only: whether this visitor is already paired with * the owner (their two companions are friends). Lets you skip offering a * "pair" affordance to an already-connected visitor. */ visitorPaired: boolean; /** Outstanding tool calls of a turn that paused before this handshake — * non-empty only when resuming mid-pause (0.37.0). Unless * `replayPendingToolCalls: false`, each is also re-emitted to onToolCall * with `replayed: true` right after connect() resolves. */ pendingToolCalls: PendingToolCall[]; /** Declared `tools` the handshake refused (0.77.0) — present only when one * was. `index` is the position in the `tools` you sent; `reason` is * `missing_name` (no string `name`), `duplicate_name` (collides with an * earlier tool once mapped to the provider's 64-char name rule) or * `over_cap` (past the 32-tool cap). A refused tool is never offered to * the model, so this is the one place a typo'd decl shows up. A * `duplicate_name` entry also carries `name` (the refused tool) and * `collidesWith` (the earlier tool it maps onto). */ droppedTools?: DroppedToolDecl[]; } /** One `tools` declaration the handshake refused — see `HelloAck.droppedTools`. */ export interface DroppedToolDecl { index: number; // Open arm: a newer server may name a reason this build does not know. // eslint-disable-next-line @typescript-eslint/ban-types reason: 'missing_name' | 'duplicate_name' | 'over_cap' | (string & {}); /** On `duplicate_name`: the refused tool's name, as you declared it. */ name?: string; /** On `duplicate_name`: the earlier declared name it collides with once * both are mapped to the provider's name rule (CR-946). */ collidesWith?: string; } /** A tool the embedding declares the companion may ask it to perform. */ export interface CompanionToolDecl { name: string; description?: string; parameters?: Record; } /** What `onToolCall` hands your handler: the `companion.tool_call` wire payload * plus `argsJson` — `args` pre-parsed as JSON. `undefined` when `args` is * empty or not valid JSON; keep reading the raw `args` string when you need * the exact model output. */ export interface ToolCallEvent extends ToolCallPayload { argsJson?: unknown; } /** A host verb's object-typed `params` as the app's handler should see it * (0.78.1). The ElevenLabs model sometimes sends the whole object as a JSON * STRING (field reading, 2026-10-04), and the unwrap used to re-encode it as * a string, so `JSON.parse(args).game` read undefined unless every handler * parsed twice. A string that parses to a plain object is that object; * anything else — a non-JSON string, an array, a number — passes through * untouched, as before. */ function voiceObjectParam(v: unknown): unknown { if (typeof v !== 'string') return v; try { const parsed: unknown = JSON.parse(v); return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : v; } catch { return v; } } /** Model tool arguments are model output — tolerate junk without throwing. */ function parseArgsJson(args: string): unknown { if (!args.trim()) return undefined; try { return JSON.parse(args) as unknown; } catch { return undefined; } } /** Voice-plane credentials, discriminated by provider (see startCall). */ export type CallCredentials = | { provider: 'elevenlabs-convai'; /** EL conversation token (open WebRTC with @elevenlabs/client). */ token: string; agentId: string; /** Pass as overrides.agent.prompt.prompt when starting the EL session. */ instructions: string; /** Server-resolved EL voice id (the user's cloned/localized voice). Pass * as overrides.tts.voiceId so the call isn't a stock default voice. */ voice?: string; /** Server-resolved agent language (ISO 639-1, e.g. 'zh'). Pass as * overrides.agent.language so the call opens in the user's language. */ language?: string; /** First-message override ('' = suppress the canned greeting so the call * opens in-character / context-aware). Pass as overrides.agent.firstMessage. */ firstMessage?: string; /** Generation handle of the call-active window this mint stamped (0.41.0). * Self-plumbed integrations: pass it to endSession({ callGen }) at call * end so the server clears exactly this call's window — a stale handle's * late teardown then can't clear a newer call's window. connectCall() * handles this automatically. */ callGen?: string; /** Call-scoped bearer the agent's memory webhook tool presents back to us * mid-call (0.45.0). Self-plumbed integrations: pass it to * `Conversation.startSession` under BOTH dynamic variable names * (0.47.0) — * `dynamicVariables: { [VOICE_TOOL_VARIABLE]: creds.voiceToolToken, * [VOICE_TOOL_VARIABLE_PLAIN]: creds.voiceToolToken }`. * ElevenLabs IGNORES a client-supplied `secret__` variable, so the plain * twin is the one that arrives; it reaches the tool as a request-body * property the platform fills, never as prompt content the LLM sees. * Passing only the first name raises no error — it just leaves the * companion unable to recall anything. connectCall() handles this * automatically. * * ABSENT, not empty, when the session cannot read memory: a client with * no memory grant has nothing to pass, rather than something that fails * inside a live call. Omitting it does not break the call — recall just * answers "no stored memory". */ voiceToolToken?: string; /** `true` when this call was granted Eleven v4 performed cues * (`startCall({ audioTags: true })`, 0.64.0): `instructions` then * teach the model `[laughs]` / `[whispers]` and the transcript carries * them — strip them with `stripAudioTags` before you show or store a * line. `connectCall` does that for you. ABSENT when not granted. */ audioTags?: boolean; /** Whether this call is recorded (`startCall({ record: true })`, * 0.65.0). Present only when the call asked. `true` means the provider * keeps the whole call (both voices) and `getCallRecording(callGen)` * will return it after the call ends. */ recording?: boolean; /** Why a call that asked to be recorded is not (0.65.0). Tell the reader * BEFORE the call connects. */ recordingUnavailable?: CallRecordingUnavailable; /** Pouchy World role sessions only (0.67.0): whether the call carries * the story's current state (see `CallWorldBrief`). ABSENT on an * ordinary session. */ worldBrief?: CallWorldBrief; /** Pouchy World role sessions only (0.68.0): whether the role can * reach its private layer during this call (see `CallPrivateStory`). * ABSENT on an ordinary session. */ privateStory?: CallPrivateStory; /** A talking-face video avatar for this call (0.75.0), present when the * call asked (`startCall({ avatar: { faceId } })`) and got one. Open it * with simli-client: `new SimliClient(video.sessionToken, videoEl, * audioEl, video.iceServers)`, then feed it the companion's remote audio * track (`listenToMediastreamTrack`). The voice still comes from this * call; the face only lip-syncs to it. */ video?: CallVideo; /** Why a call that asked for an avatar has none (0.75.0). The call is * voice-only; tell the reader before it connects. */ videoUnavailable?: CallVideoUnavailable; /** What the server cut to keep `instructions` within the realtime cap * (0.68.0; see `CallTrimmed`). ABSENT when nothing was cut. */ trimmed?: CallTrimmed; } | { provider: 'openai-realtime'; /** Ephemeral client secret (ek_…); open WebRTC directly to OpenAI. */ clientSecret: string; model: string; voice: string; expiresAt: number | null; /** See the elevenlabs-convai branch — same window-generation handle. */ callGen?: string; /** OpenAI Realtime keeps no recording: `false` whenever the call asked * (0.65.0), with `recordingUnavailable` saying why. */ recording?: boolean; recordingUnavailable?: CallRecordingUnavailable; /** See the elevenlabs-convai branch (0.67.0). */ worldBrief?: CallWorldBrief; /** Always `'none'` on a world session here (0.68.0): the OpenAI * Realtime fallback has no webhook tools. */ privateStory?: CallPrivateStory; /** See the elevenlabs-convai branch (0.68.0). */ trimmed?: CallTrimmed; /** See the elevenlabs-convai branch (0.75.0). */ video?: CallVideo; videoUnavailable?: CallVideoUnavailable; }; /** A call's video avatar credentials (0.75.0; companion API 1.9.0). The server * holds the Simli key and hands out a short-lived session bound to one of * your project's READY faces — built with your admin key (admin-sdk * `createVideoFace`, `POST /v1/admin/video-faces`). */ export interface CallVideo { provider: 'simli'; faceId: string; /** For `new SimliClient(sessionToken, videoEl, audioEl, iceServers)`. */ sessionToken: string; /** For simli-client's P2P transport; may be empty — its LiveKit transport * needs none. */ iceServers: Array<{ urls: string; username?: string; credential?: string }>; /** Seconds Simli keeps the face open — the call window's own length. */ maxSessionLength: number; /** The Simli animation model this session was minted with (0.76.0) — * what you asked for in `avatar.model`, else `fasttalk`. */ model: CallVideoModel; } /** Simli's animation models (0.76.0). `fasttalk` is Simli's default; * `artalk` is the alternative — compare them on your own face. Both bill * at the same per-minute video rate. */ export type CallVideoModel = 'fasttalk' | 'artalk'; /** Why a call that asked for `avatar` has no video (0.75.0). * - `not_enabled` — the token lacks `call.video`: your project has no ready * face yet, or the minter withheld it (`withhold_scopes`). * - `not_configured` — video is not available on this deployment. * - `face_not_allowed` — `faceId` is not one of your project's faces. * - `face_not_ready` — it is yours but still being built (0.75.1). A * scheduled sweep marks a finished face `ready` within about 10 minutes; * the admin-sdk `listVideoFaces()` shows (and advances) it at once. * - `mint_failed` — the video provider refused or could not be reached; the * call itself is unaffected. */ export type CallVideoUnavailable = | 'not_enabled' | 'not_configured' | 'face_not_allowed' | 'face_not_ready' | 'mint_failed'; /** Whether a Pouchy World role's call carries the story (0.67.0). * - `included` — the call's instructions hold the story's PUBLIC state (scene, * story clock, flags, the facts already revealed) and this role's own last * beats. Never its secrets, notes, goals or unrevealed facts: `instructions` * reach the reader's device (companion API 1.5.3). Nothing said on the call * changes the story: fold the call back in as a beat on the world turn door * (`trigger.context` + `proposedPatches`). * - `no_story` — the world pins no story package. * - `unavailable` — the story could not be read; the call went ahead without * it. */ export type CallWorldBrief = 'included' | 'no_story' | 'unavailable'; /** Whether a Pouchy World role's call can reach the role's private layer * (0.68.0; companion API 1.6.1). * - `tool` — the call is told about, and carries the credential for, the * in-call `recall_private_story` tool: ElevenLabs asks Pouchy for the role's * secrets and unrevealed facts server-to-server, only when the conversation * needs them. * - `none` — it is not (the OpenAI Realtime fallback, a session with no memory * read scope, or the operator's switch is off): the role keeps its private * knowledge vague on this call. */ export type CallPrivateStory = 'tool' | 'none'; /** Characters the server removed to keep a call's `instructions` within the * realtime cap (0.68.0; companion API 1.5.2). `story` comes off the end of the * Pouchy World story block first, then `context` off the end of the persona / * memory context. Your `brief` and the call framing are never cut. */ export interface CallTrimmed { story?: number; context?: number; } /** Why a call that asked for `record: true` is not recorded (0.65.0). * - `not_enabled` — this agent's recording switch is off (the token lacks * `call.record`; turn it on in the dashboard, then mint a new token). * - `not_configured` — recording is not available on this deployment. * - `no_conversation_id` / `store_failed` — the recording could not be bound * to this call; it will not be retrievable. */ export type CallRecordingUnavailable = 'not_enabled' | 'not_configured' | 'no_conversation_id' | 'store_failed'; /** The answer of `getCallRecording` (0.65.0). Only `ready` carries audio. * - `pending` — the provider is still processing the call; retry after * `retryAfterSec`. * - `none` — nothing was recorded for this call (it did not ask, was not * granted, never connected, or is not this session's). Retrying will not * change it. * - `expired` — it was recorded and the retention window removed it. * - `deleted` — it was removed with `deleteCallRecording`. */ export type CallRecordingResult = | { state: 'ready'; audio: Blob; contentType: string } | { state: 'pending'; retryAfterSec: number } | { state: 'none' | 'expired' | 'deleted' }; /** A representative pairing awaiting the visitor's approval (0.74.0, CR-1049). * Hand the visitor `approveUrl` — a Pouchy page where they sign in to their own * account and approve or decline — then poll `getPairRequest(id)`. `pending` * and `declined` requests last until `expiresAt`; an `expired` one is replaced * by calling `pairVisitor` again. */ export interface PairRequestInfo { id: string; status: 'pending' | 'approved' | 'declined' | 'expired'; approveUrl: string; /** ISO 8601. */ expiresAt: string; } /** The answer of `pairVisitor` / `getPairRequest` (0.74.0). `paired: true` * carries the `pairId` — the visitor was already a friend, or is a platform * instance whose template consents. Otherwise `request` says what the visitor * still has to do. */ export interface PairVisitorResult { paired: boolean; pairId: string | null; /** Absent when the pairing completed at once. */ request?: PairRequestInfo; } export interface RecalledMemory { /** The fact's stable id — the handle `forget(id)` takes. Absent on legacy * rows written before ids crossed the wire; such rows cannot be forgotten * item-wise. */ id?: string; content: string; category?: string; kind?: string; importance: number; namespace: string; createdAt?: string; } /** One exchange from a session's conversation log (see `history`). `user` is the * end-user's message, `assistant` the companion's reply; either may be `''` for * a paused / assistant-only row. `ts` is epoch ms. */ export interface CompanionTurn { user: string; assistant: string; ts: number; /** Files the assistant reply produced (CR-729) — the same structural list * its `companion.message` carried, so a restored transcript keeps the * download affordance. Absent on a text-only turn and on older servers. */ attachments?: MessageAttachment[]; } /** The user's current companion avatar (see getAvatar). */ export interface CompanionAvatar { /** Companion display name (the user's chosen name), or null. */ name: string | null; /** Persona archetype driving the default look/voice. */ archetype: 'girlfriend' | 'boyfriend' | 'pet'; /** The active model id (e.g. "default-gf-luna", or a custom id), or null. */ modelId: string | null; /** Absolute URL to the VRM 3D model — the avatar itself. CORS-enabled so a * cross-origin renderer can fetch it. Null only if resolution failed. */ vrmUrl: string | null; /** Absolute URL to a flat 2D portrait when available; null for VRM-only * built-ins today (reserved for custom previews / server-rendered thumbs). */ imageUrl: string | null; } /** One nonzero stablecoin balance line. */ export interface WalletBalance { /** Stablecoin code (USDT / USDC / DAI / USD1). */ currency: string; /** Decimal amount as a string (not atomic units), e.g. "5.83". */ amount: string; } /** Read-only snapshot of the companion instance's OWN wallet (receive-only — * there is no way to move funds via the SDK). Requires the `wallet.read` scope. */ export interface CompanionWallet { /** Nonzero per-currency balances (empty when the wallet is empty). */ balances: WalletBalance[]; /** Total across the balances; the allowed stablecoins are USD-pegged 1:1. */ totalUsd: number; /** Denomination of `totalUsd` (always "USD" today). */ currency: string; } /** Available sizes for the Pouchy brand icon (px). */ export type BrandIconSize = 256 | 512 | 1024; /** Canonical URL of the official Pouchy brand icon at `size` px, derived from a * deployment origin. Standalone (no client/token needed) so a surface can show * the Pouchy mark before/without connecting. */ export function pouchyBrandIconUrl(baseUrl: string, size: BrandIconSize = 512): string { return baseUrl.replace(/\/+$/, '') + `/brand-assets/icon/pouchy-icon-${size}.png`; } /** The single error type the SDK throws — for HTTP failures (`status` is the * response code) AND for local precondition mistakes (`status: 0`, e.g. calling * a method before `connect()`). `code` is a stable machine-readable tag you can * switch on, shared with the `control.error` stream event vocabulary: * `'not_connected'`, `'missing_option'`, `'not_representative'`, or the server's * own error code for an HTTP failure (when it sends one). Defined in errors.ts * (so call.ts shares it cycle-free); re-exported here so the public import * path is unchanged. */ export { CompanionError } from './errors'; /** The server's memory-consolidation diagnostic from `endSession()` / `close()`. * `ok` is whether consolidation ran; `facts` is how many memories it wrote; * `skipped` (when present) says why nothing was written * (`'no_session'` | `'no_content'` | `'throttled'`). * * `extracted` and `writeError` (0.48.0) complete the "it didn't remember" * ladder — for a voice call this response is the ONLY signal you get about * whether the call became memory, and `facts: 0` alone collapses four causes * onto one value: `skipped: 'no_content'` = your transcript never arrived; * `skipped: 'throttled'` = duplicate end beacon (harmless); `extracted: 0` = * the transcript arrived and nothing durable was found in it; `extracted > 0` * with `facts: 0` = facts WERE found and the store refused them, with * `writeError` saying why (e.g. a token whose scopes cannot write the resolved * memory namespace refuses every fact). */ /** What the server's write-time near-duplicate match did with a `remember()` * (0.60.0; the wire has carried it since the M-05b release). Always present * on a successful write. `matched: true` means the fact was MERGED into an * existing row — `cloudId` then names that row, not a new one — so two * different writes can legitimately answer the same id; `revived` says the * merged-into row had been decay-archived and is live again. `skipped` says * why no match was attempted, or (`target-vanished`) why a match that was * found did not merge and a fresh row was minted instead. Typed optional so a * server behind this SDK cannot fail a consumer that reads it. */ export interface RememberDedup { matched: boolean; /** Cosine similarity of the row merged into (present only when matched). */ similarity?: number; revived?: boolean; /** The threshold the match ran against. */ threshold: number; skipped?: 'disabled' | 'opted-out' | 'no-embedding' | 'index-unavailable' | 'target-vanished'; } /** A server refusal of endSession() (0.77.0) — read from * `client.lastEndSessionError`. */ export interface EndSessionError { status: number; code?: string; } export interface EndSessionResult { ok: boolean; facts?: number; /** How many facts the extraction PROPOSED (present whenever it ran) — see * the interface doc for how it splits `facts: 0` into different repairs. */ extracted?: number; /** How many proposed facts the identifier filter refused (a card number, an * order id pasted into the transcript). Its own count so `extracted − * facts` does not read as a REFUSED WRITE and send you to scopes. Absent * when nothing was filtered. 0.56.0. */ filtered?: number; /** How many of `facts` folded into a memory the store ALREADY held rather * than minting a new one (server-side write-time dedup). 0.62.0. * * `facts` counts writes the store ACCEPTED, and a merge is accepted — so * `facts` alone reads as growth that may not have happened. This matters on * the ordinary path, not an edge one: a repeated `endSession()` re-reads the * same recent turns, so a second call routinely re-extracts facts it already * stored and answered `facts: 3` for three merges. `facts - merged` is the * number of NEW memories. Absent when nothing merged (including from a * server older than 0.62.0 — treat absent as 0). */ merged?: number; /** The SUBSET of `merged` whose target memory had been archived by decay and * was brought back by the merge (0.62.0) — a restored memory rather than a * fold into a live one. Absent when none were. */ revived?: number; /** Why the first refused write was refused. Absent when every write landed. */ writeError?: string; /** `'no_session'` / `'throttled'` / `'no_content'`, and since 0.56.0 * `'no_visitor_scope'`: a representative session whose token lacks * `represent:remember` (or that has no visitorId) — nothing was * consolidated anywhere. */ skipped?: string; } /** What `sendText(text, { awaitReply: true })` resolves with: the completed * turn's reply. `text` is the authoritative final assistant text (same value * onMessage receives); `envelope` is the full `companion.message` envelope * for hosts that want the id/ts. */ export interface SendTextReply { seq: number | null; text: string; /** Typed as the `companion.message` envelope, so * `reply.envelope.payload.attachments` compiles (CR-754). */ envelope: CompanionEnvelope; /** The id this turn went out under (0.72.0): the `turnId` you passed, or * the one the SDK minted. Pass it back as `turnId` to retry the SAME turn * after an ambiguous failure — the server replays it instead of re-running. */ turnId?: string; /** `true` when the server replayed an already-completed `turnId` instead of * running the turn (0.72.0): no model call, no billing. Absent — never * `false` — on a turn that ran. */ duplicate?: true; /** Turn-level token totals for cost accounting (#2955), when the server * reported them: whole-turn sums plus `reasoningTokens` (a subset of * completion) and the served `model` when known. Absent on turns that made * no billed model call. */ usage?: TurnUsage; /** The turn was refused by INBOUND MODERATION before any model ran (SDK * 0.63.0): `text` is the configured refusal line, not something a model * chose to say. Absent on every ordinary turn. * * It exists because the two were indistinguishable on the wire — same * shape, same `text` — so an integrator could only discover a block by * A/B experiment, which is exactly what issue #3379 had to do. */ blocked?: true; /** Which gate refused: `classifier` is the moderation verdict (see * `blockedCategories`), `term` is the operator's literal denylist. The * matched term is deliberately never sent — this response reaches the app * client, which may be the end user's own browser. */ blockedBy?: 'classifier' | 'term'; /** Categories that fired, on a `classifier` block. */ blockedCategories?: string[]; /** WHICH classifier window fired, and its char range in the scored text. * `blockedCategories` is a union across up to six overlapping 8,000-char * windows, so on a long input it says a category fired somewhere and * nothing more; this localises it. */ blockedWindows?: { index: number; start: number; end: number; categories: string[] }[]; } /** The moderation half of a turn ack, shared by the buffered and streaming * legs so the two can never disagree about whether a turn was refused. */ export interface TurnBlockSignal { blocked?: true; blockedBy?: 'classifier' | 'term'; blockedCategories?: string[]; blockedWindows?: { index: number; start: number; end: number; categories: string[] }[]; } /** Lift the moderation half off a turn ack. Absent-not-false by design: a * reader that checks for the key and one that checks its value must get the * same answer, so an ordinary turn carries none of these at all. */ function pickBlockSignal(d: TurnBlockSignal | null | undefined): TurnBlockSignal { if (!d?.blocked) return {}; return { blocked: true, ...(d.blockedBy ? { blockedBy: d.blockedBy } : {}), ...(d.blockedCategories ? { blockedCategories: d.blockedCategories } : {}), ...(d.blockedWindows ? { blockedWindows: d.blockedWindows } : {}) }; } /** The server answered a completed `turnId` from its dedupe window (0.72.0): * nothing ran and nothing was billed. Lifted ONLY when true, like the block * signal, so an ordinary turn carries no `duplicate` key at all. */ function pickDuplicate(d: unknown): { duplicate?: true } { return (d as { duplicate?: unknown } | null | undefined)?.duplicate === true ? { duplicate: true } : {}; } /** Turn-level token usage carried on the input done frame / non-stream ack. */ /** Which pages `ingestFile`'s PDF figure pass read VISUALLY (sent to the * vision provider as images), and why — the `/api/companion/knowledge/file` * route's `figurePass`. */ type IngestFigurePass = { selected: { page: number; reason: 'raster' | 'sparse' }[]; dropped: number; described: number; declined?: number[]; unanswered?: number[]; /** The scan hit its time budget; the tail was judged on text alone. */ scanTruncated?: boolean; /** Pages whose scan failed (raster signal unknown for them). */ scanFailures?: number; /** Why the pass stopped, when it did. */ halted?: string; }; export interface TurnUsage { promptTokens: number; completionTokens: number; cachedTokens: number; reasoningTokens?: number; model?: string; } /** One send queued while offline (see the `queueOffline` option) — replayed * FIFO by flushOutbox with its ORIGINAL `turnId` (the server's turn-dedup is * what makes the retry safe). `at` is Date.now() at enqueue. * * `unsent` (0.39.0, CR-140) records whether the send provably NEVER reached * the server: `true` when `navigator.onLine` was already false (the request * was never made), so the replay runs the turn exactly once at ANY age. * Absent/`false` = AMBIGUOUS — a dispatch was attempted and failed at the * network level (or a stream dropped mid-flight), so the turn MAY have run * server-side; flushOutbox only auto-replays these while they are younger than * `OUTBOX_AMBIGUOUS_MAX_AGE_MS` (past that the server may have forgotten the * turnId and a replay would double-run it). */ export interface QueuedSend { turnId: string; text: string; images?: string[]; /** Per-turn developer instructions the queued send carried (SDK 0.63.0). * Replayed with it — see `queueSend`. */ instructions?: string; at: number; unsent?: boolean; /** Failed replay attempts that carried NO readable status (SDK 0.50.0). * Absent on items queued by an older build and on items that have never * failed — both read as 0. See `OUTBOX_MAX_REPLAY_ATTEMPTS`. */ attempts?: number; } /** Max age at which flushOutbox will still auto-replay an AMBIGUOUS queued send * (one that may already have run server-side — see `QueuedSend.unsent`). Kept * comfortably BELOW the server's completed-turn dedup window * (`RECENT_TURN_TTL_MS`, 60min) so any ambiguous item replayed within this * bound is still deduped and cannot double-run; older ambiguous items are * dropped (a lost uncertain message is the lesser evil vs a double bill / * duplicate side effect). Provably-unsent items ignore this age bound — they * never ran, so replaying one is always safe — and are instead bounded by * `OUTBOX_MAX_REPLAY_ATTEMPTS` below. */ export const OUTBOX_AMBIGUOUS_MAX_AGE_MS = 30 * 60_000; /** Failed replay attempts after which `flushOutbox` stops treating a queued * send as transiently-blocked and DROPS it (SDK 0.50.0). * * WHY THIS EXISTS. The queue is FIFO and a failing head item holds up * everything behind it — correct for a message queue, where reordering a * conversation is worse than delaying it, but it assumes the head item can * eventually succeed. One class breaks that assumption: a request the HOST * refuses before it reaches the API (a body over the platform's ~4.5MB limit — * in practice an un-downscaled photo) is rejected without CORS headers, so * cross-origin `fetch` REJECTS and the SDK sees `status: 0` with no `code` — * indistinguishable from the network being down. `sendText` therefore queues * it, and it fails identically on every flush. For an AMBIGUOUS item the * 30-minute age bound above eventually dropped it; for a provably-unsent one * there was no exit at all and it replayed on every reconnect forever. * * ALL THREE CONDITIONS ARE LOAD-BEARING, because dropping a real message is a * worse failure than delaying one: * 1. `status === 0` only. Every readable status (5xx, 429, 409/408/423/425) * means the server saw the request and owns the condition; those still * wait indefinitely. * 2. Attempts are counted only while `navigator.onLine !== false`. A flapping * or absent connection explains the failure on its own and must not spend * the budget — a device offline overnight comes back with 0 attempts. * 3. The item must ALSO be older than `OUTBOX_AMBIGUOUS_MAX_AGE_MS`. A short * outage can produce several reconnects, hence several attempts, in a few * minutes; requiring age too means no realistic outage reaches the drop. * * A drop is announced on `console.warn` and fires `onOutboxChange`. */ export const OUTBOX_MAX_REPLAY_ATTEMPTS = 5; /** Default headers deadline for POSTs whose response waits on LONG server work * (no SSE): buffered `/input` sends, `/tool-result` (the server runs the whole * billed continuation before responding), and knowledge ingest (synchronous * summarize + per-chunk embeddings). On those paths response headers only * arrive when the work completes — the general 30s `requestTimeoutMs` default * declared a healthy >30s turn dead (`request_timeout`) while it kept running * (and billing) server-side. Sized to the server's own ceiling for those * routes (`config.maxDuration = 300`s) plus headroom, so a genuinely hung * server is still cut. An explicit `requestTimeoutMs` (or 0) always wins. */ const BUFFERED_TURN_TIMEOUT_MS = 310_000; /** Same class, smaller ceiling: `/confirm` executes the approved action before * responding under `maxDuration: 60` — the deterministic `outcome`-in-response * channel broke on any 30-60s action (the confirm is single-use, so a * timed-out approve can't be retried). 60s server ceiling + headroom. */ const CONFIRM_EXEC_TIMEOUT_MS = 65_000; /** Ceiling on how long a server-named `Retry-After` may hold the event stream * closed (see `reconnectDelay`) — and, since 0.71.1, the offline outbox's * replay gate (`outboxBlockedUntil`, CR-756). The platform limiter's own windows are a * minute and an hour, so an honest 429 can name up to 3600s — but the stream * is how every reply reaches the embed, and an hour of silence is a worse * failure than a handful of extra 429s. One minute is well past the per-minute * bucket and short enough that a recovered limiter is noticed promptly. */ const STREAM_RETRY_AFTER_CAP_MS = 60_000; /** Monotonic clock for DURATION measurements (connection lifetime, request * latency, the SSE stall watchdog). `Date.now()` is the wall clock: an NTP * step or a device resuming from sleep can move it in either direction, so a * duration derived from it can read negative or wildly long — the same hazard * the Dart client fixed in 0.1.8 and python/go carry by construction * (`time.monotonic` / Go's monotonic reads). Wall-clock stamps that MEAN wall * time stay on `Date.now()` deliberately: debug-record `at` fields, persisted * outbox item ages (`performance.now()` restarts every page load), and * server-named real-time deadlines (Retry-After math, `outboxBlockedUntil`). */ const mono: () => number = typeof performance !== 'undefined' && typeof performance.now === 'function' ? () => performance.now() : () => Date.now(); /** Pluggable persistence for the offline send queue (the `outboxStore` * option). Both methods receive the storage key * (`pouchy-outbox:${surface}`, or `pouchy-outbox-v:${surface}:${visitorId}` * on a representative session); `load` returns the previously-saved value or * null. */ export interface OutboxStore { load(key: string): string | null; save(key: string, value: string): void; } // Default outbox persistence: localStorage when the environment has one (and // allows it — privacy mode / quota errors fall through), else a module-level // Map so the queue at least survives for the page's lifetime. const memoryOutbox = new Map(); const defaultOutboxStore: OutboxStore = { load(key) { try { if (typeof window !== 'undefined' && window.localStorage) { return window.localStorage.getItem(key); } } catch { /* storage denied — fall through to the in-memory copy */ } return memoryOutbox.get(key) ?? null; }, save(key, value) { try { if (typeof window !== 'undefined' && window.localStorage) { window.localStorage.setItem(key, value); return; } } catch { /* quota / privacy mode — keep the in-memory copy below */ } memoryOutbox.set(key, value); } }; type Handler = (envelope: CompanionEnvelope) => void; /** Token-streaming subscriber (P1-2). `chunk` is a raw text delta of the reply * being generated; `meta.reset` (with an empty chunk) tells the renderer to * CLEAR the partial text (a hop that streamed text turned out to be tool-call * deliberation, not the reply). Deltas are advisory — the final * companion.message (fired through onMessage as usual) is authoritative and * should replace whatever the deltas rendered. */ type DeltaHandler = (chunk: string, meta: { reset?: boolean }) => void; const SEEN_CAP = 512; /** Structured instrumentation event (0.32.0) — see `debug` in * CompanionClientOptions. `at` is Date.now(). Deliberately free of secrets: * no token, no headers, no bodies — safe to pipe to any logger. */ export type CompanionDebugEvent = | { kind: 'request'; at: number; method: string; path: string } | { kind: 'response'; at: number; method: string; path: string; status: number; /** Wall-clock ms from request start to headers. */ ms: number; } | { kind: 'envelope'; at: number; type: string; id: string } | { kind: 'stream'; at: number; state: CompanionStreamState; prev: CompanionStreamState } | { kind: 'error'; at: number; message: string; code?: string; status?: number }; /** Lifecycle of the receive event stream (0.30.0) — observable via * `client.streamState` + `onStreamStateChange`, so a host can render a * connection indicator without hand-rolling its own enum. * - `idle` — before start() * - `connecting` — start() called; first connection being established * - `connected` — the event stream is live (WS or SSE as configured) * - `reconnecting`— connection lost / closed; backing off before the next try * - `degraded_sse`— the WebSocket transport errored and delivery continues on * the SSE fallback (still live — replies keep arriving) * - `stopped` — stop()/close() was called, or a permanent stream failure * (the `stream_unauthorized` control.error) ended the loop */ export type CompanionStreamState = | 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'degraded_sse' | 'stopped'; /** The machine-readable verdict on a resolved confirmation (0.55.0). See * `confirmAction`. `denied` is a decline (nothing ran) and is deliberately * distinct from `rejected` (the APP refused an action the user approved). */ export type ConfirmOutcomeClass = | 'success' | 'error' | 'unknown' | 'rejected' | 'denied' | 'unrecognized'; /** `confirmAction`'s response. `outcome` is prose for a person; `outcomeClass` * is the field to switch on. */ export interface ConfirmActionResult { ok: boolean; status: 'approved' | 'denied' | 'exec_failed'; outcome?: string; retryable?: boolean; /** Absent from older servers — read absent as `unrecognized`. */ outcomeClass?: ConfirmOutcomeClass; /** 0.57.0 — the presentable half of the user-signed approval, present only * when the approval passed a passkey step-up on the first-party surface: * `intentDigest` is a sha256 of the confirm's canonical intent (tool, * arguments, scope, id, time), `credentialId` the passkey that signed a * challenge bound to it, `signedAt` when. The signature itself stays on * the server; this is the receipt a host can show and log. */ mandate?: { intentDigest: string; credentialId: string; signedAt: number }; } /** `getMandate`'s response (0.59.0) — the full user-signed approval the * `mandate` receipt summarizes. Field shapes follow the WebAuthn assertion * the server kept; `verified` is the server's own re-verification. */ export interface MandateDocument { confirmId: string; status: string; /** The canonical intent the digest covers: `{ v, confirmId, tool, args, scope, createdAt, action }`. */ intent: Record; intentDigest: string; mandate: Record; /** The credential that signed the mandate — `publicKey` is COSE, base64url. * `null` when the passkey has since been removed from the account: the * server then has no key to verify with, and `verified` is `null` too * (0.59.1 — typed non-null before, which read a revoked passkey as a * TypeError at `credential.publicKey`). */ credential: { credentialId: string; publicKey: string; format: string } | null; verify: { challenge: string; rpID: string; origin: string }; /** The server's own re-verification: `true` / `false` (see `verifyError`), * or `null` when it could not run (no credential) — "could not verify" is * not "did not verify"; a counterparty verifies offline either way. */ verified: boolean | null; /** Why `verified` is `false` (absent otherwise). */ verifyError?: string; [k: string]: unknown; } /** The server's `errorId` off a JSON error envelope — present on a persisted * 5xx only (CR-723). One reader for every HTTP funnel, so the field cannot be * dropped at one site and kept at another (round #638 found nine of twelve * funnels omitting it while the CHANGELOG said "every HTTP funnel"). */ function pairResultOf(json: { pairId?: string | null; paired?: boolean; request?: PairRequestInfo }): PairVisitorResult { return { paired: json.paired === true, pairId: typeof json.pairId === 'string' ? json.pairId : null, ...(json.request ? { request: json.request } : {}) }; } function errorIdOf(json: { errorId?: unknown } | null | undefined): string | undefined { return typeof json?.errorId === 'string' && json.errorId ? json.errorId : undefined; } /** The refusal's `degraded` label (CR-1040) — `'limiter_unreachable'` on a * 429 from the limiter-outage half budget — or undefined. */ function degradedOf(json: unknown): string | undefined { const d = (json as { degraded?: unknown } | null | undefined)?.degraded; return typeof d === 'string' && d ? d : undefined; } /** `bad_response` (0.77.0, CR-1036) when a 2xx carried no JSON body — every * platform success is JSON, so that is an intermediary (captive portal, * proxy page), not the API. Without it the error had status 200 and no code, * which nothing could switch on and the offline queue would not hold. */ function nonJsonOkCode(res: Response, json: unknown): 'bad_response' | undefined { return res.ok && (json === null || typeof json !== 'object') ? 'bad_response' : undefined; } export class CompanionClient { private readonly opts: CompanionClientOptions; private readonly doFetch: typeof fetch; private _session: string | null = null; private cursor = 0; /** Generation handle of the last call-active window THIS client minted * (startCall → server callGen, 0.41.0). endSession sends it by default so * the server's window clear is fenced to our own call — and sends an * explicit null when this client never minted one, so a text client's * close() can no longer kill a live call's window on the same session. */ private lastCallGen: string | null = null; private _lastEndSessionError: EndSessionError | null = null; /** Pause-orphaned call replay fired (0.37.0) — once per client instance, so * a manual second connect() can't double-deliver to a live handler. */ private replayedPendingToolCalls = false; private streaming = false; private abort: AbortController | null = null; // Bumped on every start()/stop(): a loop whose generation is stale exits // instead of resurrecting — stop() immediately followed by start() used to // leave TWO concurrent receive loops polling forever (the old one saw // `streaming === true` again after its backoff sleep). private streamGen = 0; private readonly handlers = new Map>(); private readonly seen = new Set(); // Token-streaming delta subscribers (P1-2). When any are registered, // sendText automatically streams the reply. private readonly deltaHandlers = new Set(); // Voice tool-calls awaiting the app's sendToolResult, keyed by a synthetic id. private readonly pendingVoiceTools = new Map void>(); // Receive-stream lifecycle (0.30.0) — see CompanionStreamState. private _streamState: CompanionStreamState = 'idle'; private readonly streamStateHandlers = new Set< (state: CompanionStreamState, prev: CompanionStreamState) => void >(); // Instrumentation sink (0.32.0) — null when `debug` is unset, so the hot // paths pay one falsy check. private readonly debugSink: ((event: CompanionDebugEvent) => void) | null; // Offline send queue (0.34.0) — null until first use, then loaded lazily // from the store so a reloaded tab resumes the queue its predecessor // persisted. private outbox: QueuedSend[] | null = null; private readonly outboxHandlers = new Set<(items: readonly QueuedSend[]) => void>(); // In-flight flush, shared so concurrent triggers run ONE flush at a time. private outboxFlush: Promise<{ sent: number; remaining: number }> | null = null; /** Epoch-ms before which flush attempts are skipped — set from a 429's * Retry-After during replay so the auto-flush on every reconnect doesn't * re-hammer a server that just told us to back off (0.35.0). */ private outboxBlockedUntil = 0; constructor(opts: CompanionClientOptions) { if (!opts.baseUrl) throw new CompanionError('CompanionClient: baseUrl is required', 0, 'missing_option'); if (!opts.token) throw new CompanionError('CompanionClient: token is required', 0, 'missing_option'); this.opts = opts; this.doFetch = opts.fetch ?? globalThis.fetch.bind(globalThis); this.debugSink = opts.debug === true ? (e) => console.debug('[pouchy-sdk]', e.kind, e) : typeof opts.debug === 'function' ? opts.debug : null; } /** Fire an instrumentation event. A throwing sink must never break the * request/stream path it observes. */ private dbg(event: CompanionDebugEvent): void { if (!this.debugSink) return; try { this.debugSink(event); } catch { /* observer errors are the observer's problem */ } } /** The session id of the LAST successful connect(), or null before the * first. Deliberately not cleared by endSession()/close()/setToken(), so * teardown-time logging can still correlate — which means it is an * identifier, NOT a liveness check: `if (client.sessionId)` stays truthy * forever after the first connect. Gate liveness on your own connect/close * bookkeeping (or `streamState`) instead. */ get sessionId(): string | null { return this._session; } /** Why the last endSession() / close() returned `null` when the SERVER * refused it (0.77.0): `{ status, code? }` — e.g. 401 `invalid_token`, 404 * `session_not_found`, 429 `rate_limited`. `null` when the last attempt got * a diagnostic, or never reached the server (no session / fetch rejected). * endSession keeps returning `null` on a refusal, so a host can tell * "token expired, nothing consolidated" apart without wiring `debug`. */ get lastEndSessionError(): EndSessionError | null { return this._lastEndSessionError; } /** Current lifecycle of the receive event stream (0.30.0) — `idle` before * start(), then `connecting` / `connected` / `reconnecting` / * `degraded_sse` (WS errored, SSE fallback carrying delivery) / `stopped`. * Subscribe to transitions with `onStreamStateChange`. */ get streamState(): CompanionStreamState { return this._streamState; } /** Subscribe to receive-stream lifecycle transitions (0.30.0) — drive a * connection indicator: `client.onStreamStateChange((s) => setStatus(s))`. * Fires only on change, with the previous state as the second argument. * Returns an unsubscribe function. */ onStreamStateChange( handler: (state: CompanionStreamState, prev: CompanionStreamState) => void ): () => void { this.streamStateHandlers.add(handler); return () => this.streamStateHandlers.delete(handler); } private setStreamState(next: CompanionStreamState): void { if (next === this._streamState) return; const prev = this._streamState; this._streamState = next; this.dbg({ kind: 'stream', at: Date.now(), state: next, prev }); // Same isolation doctrine as emit(): a throwing consumer handler must not // skip the remaining handlers or bubble into the stream loop. for (const h of this.streamStateHandlers) { try { h(next, prev); } catch (e) { console.error('[companion-sdk] stream-state handler threw', e); } } // The stream coming back up is the strongest "server reachable again" // signal there is — drain any sends queued while offline (0.34.0). if (next === 'connected' && this.opts.queueOffline) void this.flushOutbox().catch(() => {}); } /** Swap the bearer token used by every subsequent request and stream * reconnect. Session tokens expire (default 1h) — call this when your * backend re-mints one, or wire `onAuthError` to do it on demand. */ setToken(token: string): void { if (!token) throw new CompanionError('setToken: token is required', 0, 'missing_option'); this.opts.token = token; } // In-flight onAuthError call, shared so concurrent 401s trigger ONE refresh. private refreshing: Promise | null = null; /** Run opts.onAuthError (deduped), apply the fresh token, and report whether * the caller should retry. Never throws — a failed refresh means "surface * the original 401". */ private async tryRefreshToken(): Promise { const refresh = this.opts.onAuthError; if (!refresh) return false; this.refreshing ??= Promise.resolve() .then(refresh) .catch(() => null) .finally(() => { this.refreshing = null; }); const token = await this.refreshing; if (!token) return false; this.opts.token = token; return true; } /** doFetch with one transparent retry after a 401-triggered token refresh. * Everything except the stream loop (which has its own auth handling) goes * through here. */ // The running server's X-Pouchy-Api-Version (captured off every authed // response) — capability detection for additive protocol features. private serverApiVersion: string | null = null; /** True when the server echoes `replyTo` on companion.message (API ≥ 1.1) — * the strict awaitReply correlation is only safe to enforce there. */ private serverEchoesReplyTo(): boolean { const m = /^(\d+)\.(\d+)/.exec(this.serverApiVersion ?? ''); if (!m) return false; const major = Number(m[1]); const minor = Number(m[2]); return major > 1 || (major === 1 && minor >= 1); } /** doFetch raced against the `requestTimeoutMs` HEADERS deadline (0.35.0). * The deadline only bounds time-to-response-headers: it does NOT swap the * request's AbortSignal, so a streaming response body stays tied to the * caller's own signal, and the timer is cleared the moment the race * settles. On timeout the helper rejects `request_timeout` (status 0) — * code-bearing, so the offline queue's network-failure predicate correctly * ignores it (the server may have received the request). The abandoned * fetch keeps its rejection silenced so a late network error can't surface * as an unhandled rejection. `defaultBudgetMs` is the deadline used when the * host did NOT set `requestTimeoutMs` — buffered `/input` POSTs raise it to * `BUFFERED_TURN_TIMEOUT_MS` because their headers only arrive when the turn * completes; an explicit `requestTimeoutMs` (including 0) always wins. */ private async doFetchBounded( url: string, init: RequestInit & { headers: Record }, defaultBudgetMs = 30_000 ): Promise { const budget = this.opts.requestTimeoutMs ?? defaultBudgetMs; if (!budget) return this.doFetch(url, init); const req = this.doFetch(url, init); let timer: ReturnType | undefined; try { return await Promise.race([ req, new Promise((_, reject) => { timer = setTimeout( () => reject( new CompanionError( `request timed out after ${budget}ms waiting for response headers`, 0, 'request_timeout' ) ), budget ); }) ]); } catch (e) { req.catch(() => {}); throw e; } finally { if (timer !== undefined) clearTimeout(timer); } } private async fetchAuthed( url: string, init: RequestInit & { headers: Record }, defaultTimeoutMs?: number ): Promise { const method = init.method ?? 'GET'; const path = url.startsWith(this.opts.baseUrl) ? url.slice(this.opts.baseUrl.length) : url; const startedAt = mono(); // duration base; the debug `at` stays wall-clock this.dbg({ kind: 'request', at: Date.now(), method, path }); let res: Response; try { res = await this.doFetchBounded(url, init, defaultTimeoutMs); } catch (e) { // A caller-supplied AbortSignal makes fetch throw a native AbortError — // convert it at this single choke point so cancellation honors the // "every helper rejects with CompanionError" contract (code 'aborted'). const err = this.asCompanionError(e); this.dbg({ kind: 'error', at: Date.now(), message: err.message, code: err.code, status: 0 }); throw err; } this.dbg({ kind: 'response', at: Date.now(), method, path, status: res.status, ms: Math.round(mono() - startedAt) }); const apiVersion = res.headers.get('x-pouchy-api-version'); if (apiVersion) this.serverApiVersion = apiVersion; if (res.status !== 401) return res; // A step-up 401 (biometric confirm) is NOT a token problem — refreshing // and retrying would burn a backend re-mint on a doomed request. Only // token-shaped 401s go through onAuthError. const code = await this.errorCodeOf(res); if (code === 'step_up_required' || code === 'step_up_failed') return res; if (!(await this.tryRefreshToken())) return res; init.headers.Authorization = `Bearer ${this.opts.token}`; const retryAt = mono(); // duration base; the debug `at` stays wall-clock this.dbg({ kind: 'request', at: Date.now(), method, path }); try { const retryRes = await this.doFetchBounded(url, init, defaultTimeoutMs); this.dbg({ kind: 'response', at: Date.now(), method, path, status: retryRes.status, ms: Math.round(mono() - retryAt) }); return retryRes; } catch (e) { const err = this.asCompanionError(e); this.dbg({ kind: 'error', at: Date.now(), message: err.message, code: err.code, status: 0 }); throw err; } } /** Normalize a thrown fetch error to a CompanionError. An AbortError (the * caller's signal fired) becomes a switchable `code: 'aborted'`; anything * else that is already a CompanionError passes through; the rest wrap as a * status-0 network failure. */ private asCompanionError(e: unknown): CompanionError { if (e instanceof CompanionError) return e; const name = (e as { name?: unknown })?.name; if (name === 'AbortError') return new CompanionError('request aborted', 0, 'aborted'); return new CompanionError(e instanceof Error ? e.message : 'network request failed', 0); } /** Peek the error `code` of a failure response without consuming its body. */ private async errorCodeOf(res: Response): Promise { try { const json = (await res.clone().json()) as { code?: unknown } | null; return typeof json?.code === 'string' ? json.code : undefined; } catch { return undefined; } } private url(path: string): string { return this.opts.baseUrl.replace(/\/+$/, '') + path; } private jsonHeaders(): Record { return { Authorization: `Bearer ${this.opts.token}`, 'Content-Type': 'application/json' }; } private requireSession(): string { if (!this._session) throw new CompanionError('CompanionClient: call connect() first', 0, 'not_connected'); return this._session; } /** Retry-After in seconds for a throttled response: the JSON body's * `retryAfterSec` (companion rate-limit shape) wins, else the standard * Retry-After header. Undefined when the server named neither. * * EVERY response-derived `CompanionError` must carry this — that is the * `CompanionError.retryAfter` contract in errors.ts, and the Python and * Unity clients hold it by construction because each funnels all HTTP * through ONE error factory (`_raise_from` / the CompanionError ctor). * This client's funnel is partial (`postJson` + the turn stream), so the * one-off helpers below have to pass it explicitly. `retry-after.drift` * fails the build if a new one forgets. */ private retryAfterFrom(res: Response, json: { retryAfterSec?: unknown } | null): number | undefined { const fromBody = json?.retryAfterSec; if (typeof fromBody === 'number' && Number.isFinite(fromBody) && fromBody >= 0) return fromBody; // Missing/blank header must stay undefined — Number(null) is 0, which // used to stamp retryAfter: 0 on EVERY error without the header (a 400, // a 404 …), telling backoff code "retry immediately". const raw = res.headers.get('Retry-After'); if (raw === null || raw.trim() === '') return undefined; const header = Number(raw); if (Number.isFinite(header) && header >= 0) return header; // RFC 9110 also allows an HTTP-date (proxies/CDNs emit it) — convert to a // non-negative seconds delta (0.30.0). Unparseable stays undefined. const at = Date.parse(raw); if (!Number.isNaN(at)) return Math.max(0, Math.ceil((at - Date.now()) / 1000)); return undefined; } private async postJson( path: string, body: unknown, signal?: AbortSignal, defaultTimeoutMs?: number ): Promise { const res = await this.fetchAuthed( this.url(path), { method: 'POST', headers: this.jsonHeaders(), body: JSON.stringify(body), // The signal rides `init`, so the 401-refresh retry in fetchAuthed // reuses it automatically (init is passed to both doFetch calls). ...(signal ? { signal } : {}) }, defaultTimeoutMs ); const json = (await res.json().catch(() => null)) as | ({ ok?: boolean; error?: string; code?: string; retryAfterSec?: number; errorId?: string } & T) | null; if (!res.ok || !json || json.ok === false) { // The 429 body carries an actionable `hint` (e.g. "provision a project // …") — surface it instead of discarding it. const hint = (json as { hint?: unknown } | null)?.hint; const detail = typeof hint === 'string' && hint ? ` — ${hint}` : ''; throw new CompanionError( (json?.error || `request failed (${res.status})`) + detail, res.status, json?.code ?? nonJsonOkCode(res, json), this.retryAfterFrom(res, json), // A persisted 5xx names its server_errors row (CR-723). typeof json?.errorId === 'string' ? json.errorId : undefined, degradedOf(json) ); } return json; } /** Handshake: start or resume the session for this surface. */ async connect(opts?: { signal?: AbortSignal }): Promise { const json = await this.postJson<{ session: string; grantedScopes: string[]; modalities: string[]; resumeCursor: number; representative?: boolean; visitorPaired?: boolean; pendingToolCalls?: PendingToolCall[]; droppedTools?: DroppedToolDecl[]; }>('/api/companion/session', { surface: this.opts.surface ?? 'default', modalities: this.opts.modalities, handles: this.opts.handles, contextKinds: this.opts.contextKinds, tools: this.opts.tools, appContext: this.opts.appContext, ...(this.opts.visitor ? { visitor: this.opts.visitor } : {}) }, opts?.signal); const previousSession = this._session; this._session = json.session; this.cursor = json.resumeCursor ?? 0; // A re-handshake that minted a NEW session (idle past the server's resume // TTL, or after endSession()) must move the event stream with it (0.54.2): // the receive loops capture the session id ONCE when they open, and // start() is a no-op while one is running — so without this the stream // stayed bound to the old id, still 200 + pings (streamState read // `connected`) while every reply for the new session went undelivered. // stop() + start() are the public, idempotent pair; the new loop opens on // the fresh id and the cursor set just above. if (this.streaming && previousSession && previousSession !== json.session) { this.stop(); this.start(); } // A successful handshake proves reachability — replay any sends queued // while offline (0.34.0; no-op when the queue is empty). if (this.opts.queueOffline) void this.flushOutbox().catch(() => {}); const pendingToolCalls = Array.isArray(json.pendingToolCalls) ? json.pendingToolCalls.filter( (c): c is PendingToolCall => !!c && typeof c.id === 'string' && typeof c.name === 'string' ) : []; // Pause-orphaned call replay (0.37.0): re-emit each outstanding call to // onToolCall so a reloaded embed completes the paused turn. Deferred a // tick so `await connect()` callers can still register handlers first; // once-per-client so a second connect() (manual re-handshake) can't // double-deliver alongside a live handler that already got them. The // synthetic envelope id is derived from the callId, so the stream-replay // of the ORIGINAL envelope (a client resuming with a persisted cursor) // carries a different id and the app-facing `replayed` flag is the // dedup signal — see the option's jsdoc on idempotency. if ( pendingToolCalls.length && this.opts.replayPendingToolCalls !== false && !this.replayedPendingToolCalls ) { this.replayedPendingToolCalls = true; setTimeout(() => { for (const c of pendingToolCalls) { this.emit({ v: PROTOCOL_VERSION, id: `replay_${c.id}`, ts: Date.now(), session: json.session, type: 'companion.tool_call', payload: { id: c.id, name: c.name, args: c.args ?? '', replayed: true } } as CompanionEnvelope); } }, 0); } return { ...json, representative: json.representative ?? false, visitorPaired: json.visitorPaired ?? false, pendingToolCalls }; } /** Pair this representative session's VISITOR with the owner so their two * companions become friends (the A2A plane: messaging, gifts, visiting). * Representative sessions only. This client's PAT needs `represent:pair`; * `visitorToken` is the visitor's own Pouchy PAT with `social.message`. * A new pairing resolves `paired: false` with a `request`: the visitor * approves at `request.approveUrl` on pouchy.ai, and `getPairRequest` reads * the status (0.74.0). An existing friend resolves `paired: true` at once. */ async pairVisitor(visitorToken: string, opts?: { signal?: AbortSignal }): Promise { if (!this.opts.visitor) { throw new CompanionError( 'pairVisitor() requires a representative session (createCompanion({ visitor }))', 0, 'not_representative' ); } if (!visitorToken) throw new CompanionError('pairVisitor: visitorToken is required', 0, 'missing_option'); const json = await this.postJson<{ pairId?: string | null; paired?: boolean; request?: PairRequestInfo }>( '/api/companion/pair', { visitorToken, visitorId: this.opts.visitor.id }, opts?.signal ); return pairResultOf(json); } /** Status of a request `pairVisitor` opened (0.74.0). */ async getPairRequest(requestId: string, opts?: { signal?: AbortSignal }): Promise { if (!requestId) throw new CompanionError('getPairRequest: requestId is required', 0, 'missing_option'); const res = await this.fetchAuthed( this.url(`/api/companion/pair?request=${encodeURIComponent(requestId)}`), { method: 'GET', headers: { Authorization: `Bearer ${this.opts.token}` }, ...(opts?.signal ? { signal: opts.signal } : {}) } ); const json = (await res.json().catch(() => null)) as | { ok?: boolean; error?: string; code?: string; retryAfterSec?: number; errorId?: string; pairId?: string | null; paired?: boolean; request?: PairRequestInfo } | null; if (!res.ok || !json || json.ok === false) { throw new CompanionError( json?.error || `getPairRequest failed (${res.status})`, res.status, json?.code, this.retryAfterFrom(res, json), errorIdOf(json), degradedOf(json) ); } return pairResultOf(json); } /** Send a user text turn, optionally with images (data URLs) for a multimodal * turn — images need the `files` scope. The reply arrives on the event stream * as a `companion.message` — subscribe via onMessage()/start() to receive it. * * TOKEN STREAMING (P1-2): when any onDelta() subscriber is registered (or * `opts.stream` is true), the reply streams token-by-token on this very * request — delta subscribers fire as chunks arrive, and onMessage still * fires exactly ONCE with the authoritative final text (the streamed copy * is emitted locally; the event-stream replay of the same envelope is * deduplicated by id). Pass `stream: false` to force the buffered path. * * AWAIT THE REPLY (request/response hosts — a REPL, an HTTP handler, a * test): pass `awaitReply: true` and the promise resolves with the final * reply `{ seq, text, envelope }` — no onMessage wiring, no hand-rolled * turn gate. It rides the same streaming request (the terminal `done` * frame carries the authoritative envelope), so it works without start(); * onMessage/onDelta subscribers still fire as usual. Only when the reply * can't ride the request (`stream: false`, or an old server) does it fall * back to the event stream — which then DOES need start() — resolving with * the next `companion.message`, or rejecting with code `reply_timeout` * after `replyTimeoutMs` (default 60s). * * OFFLINE QUEUE (0.34.0, opt-in `queueOffline: true`): a send that never * REACHED the server — fetch rejected at the network level, or * `navigator.onLine` is already false — is queued for replay instead of * lost, and resolves `{ seq: null, queued: true, turnId }`. With * `awaitReply: true` there is no reply to await this session, so it * rejects with code `queued_offline` (the item IS queued first). HTTP * error responses keep the throw semantics above — they are never queued. * * RETRY (0.72.0): every result carries the `turnId` it went out under; * pass it back as `opts.turnId` after an ambiguous failure and the server * replays the completed turn (`duplicate: true`, no model call, no bill) * instead of running a second one. 1–256 chars (64 before 0.73.2), else * `invalid_option`. */ async sendText( text: string, opts: { awaitReply: true; images?: string[]; instructions?: string; stream?: boolean; replyTimeoutMs?: number; signal?: AbortSignal; turnId?: string } ): Promise; async sendText( text: string, opts?: { images?: string[]; instructions?: string; stream?: boolean; awaitReply?: boolean; replyTimeoutMs?: number; signal?: AbortSignal; turnId?: string } ): Promise<{ seq: number | null; queued?: boolean; turnId?: string; duplicate?: true }>; async sendText( text: string, opts?: { images?: string[]; instructions?: string; stream?: boolean; awaitReply?: boolean; replyTimeoutMs?: number; signal?: AbortSignal; turnId?: string } ): Promise<{ seq: number | null; queued?: boolean; turnId?: string; duplicate?: true } | SendTextReply> { const id = this.requireSession(); // Client-minted correlation id (0.28.0): the server echoes it back as // `replyTo` on this turn's companion.message — even across an app-tool // pause — so awaitReply's fallback matches THE reply, not the first // message that happens by (world-state reactions, confirm outcomes). // // A caller-supplied `turnId` (0.72.0) is what makes a RETRY safe: the // server dedupes completed turn ids and replays the recorded turn // (`duplicate: true`) instead of running and billing it again. Before it, // every retry minted a fresh id, so a turn that timed out client-side but // ran server-side ran twice. The server honours 1–256 chars (64 until // round #863, which made it refuse a longer id instead of ignoring it); // refusing here too keeps the error local and before any request. const ownTurnId = opts?.turnId; if ( ownTurnId !== undefined && (typeof ownTurnId !== 'string' || ownTurnId.length === 0 || ownTurnId.length > 256) ) { throw new CompanionError( 'turnId must be 1-256 characters', 0, 'invalid_option' ); } const turnId = ownTurnId ?? `turn_${this.randomId()}`; // Every non-queued result names that id too (0.72.0), so a caller can // retry under it — before, only a queued send returned it. // `instructions` (0.63.0) is developer-authored guidance for THIS turn. // It joins the prompt and is NEVER scored as user speech by inbound // moderation — before it existed, an app with per-turn rules had to // prepend them to `text`, where the classifier read the developer's own // prose as the user's (issue #3379). Needs the `chat.instructions` // scope; without it the server answers 403 `missing_scope`. const body = { text, turnId, ...(opts?.images?.length ? { images: opts.images } : {}), ...(opts?.instructions ? { instructions: opts.instructions } : {}) }; if (!this.opts.queueOffline) return { ...(await this.dispatchText(id, body, opts, turnId)), turnId }; // Offline queue: two triggers only — the browser already knows it's // offline, or fetch itself rejected without an HTTP response. An HTTP // error (4xx/5xx incl. 429) means the server SAW the turn: those keep // the normal throw semantics and are never queued. if (typeof navigator !== 'undefined' && navigator.onLine === false) { // The browser is offline — the request is never made, so this send // provably never reached the server: safe to replay at any age. return this.queueSend(turnId, text, opts, true); } try { return { ...(await this.dispatchText(id, body, opts, turnId)), turnId }; } catch (e) { // A network-level failure AFTER dispatch is AMBIGUOUS — the turn may // have run server-side (mid-flight drop / lost response). Mark it so // flushOutbox won't replay it past the server's dedup window (CR-140). if (this.isNetworkSendFailure(e)) return this.queueSend(turnId, text, opts, false); throw e; } } /** The pre-queue dispatch of sendText: awaitReply / streaming / buffered. */ private async dispatchText( id: string, body: Record, opts?: { images?: string[]; instructions?: string; stream?: boolean; awaitReply?: boolean; replyTimeoutMs?: number; signal?: AbortSignal }, turnId?: string ): Promise<{ seq: number | null; duplicate?: true } | SendTextReply> { if (opts?.awaitReply) return this.sendTextAwaitingReply(id, body, opts, turnId); const wantStream = opts?.stream ?? this.deltaHandlers.size > 0; if (!wantStream) { // Buffered: the response only comes back when the turn completes, so // the default headers deadline is the TURN ceiling, not the general 30s // (CR-141 — a >30s turn used to reject request_timeout while succeeding // server-side, and the react rollback then erased the user's message). const json = await this.postJson<{ seq: number | null; duplicate?: boolean }>( `/api/companion/session/${id}/input`, body, opts?.signal, BUFFERED_TURN_TIMEOUT_MS ); return { seq: json.seq ?? null, ...pickDuplicate(json) }; } return this.sendTextStreaming(id, body, opts?.signal); } /** True only for a send that never reached the server: fetch rejected at * the network level, which asCompanionError wraps as status 0 with NO * code. Every code-carrying status-0 error is deliberate — `aborted` is * the caller cancelling, `reply_timeout`/`needs_event_stream` mean the * POST was accepted — and any nonzero status is a response the server * produced. None of those may queue. The one exception is `bad_response` * (0.77.0): a 2xx whose body is not JSON is something in the path (a * captive portal's HTML), not the platform, so it queues like a drop — * as AMBIGUOUS, since a proxy may have forwarded it (CR-1036). */ private isNetworkSendFailure(e: unknown): boolean { if (!(e instanceof CompanionError)) return false; if (e.code === 'bad_response') return true; return e.status === 0 && e.code === undefined; } /** Enqueue an offline send. Plain sends resolve `{ seq: null, queued: true, * turnId }`; awaitReply can't await a reply that won't arrive this * session, so it rejects with `queued_offline` — the item is queued * either way. */ private queueSend( turnId: string, text: string, opts: { images?: string[]; instructions?: string; awaitReply?: boolean } | undefined, unsent: boolean ): { seq: null; queued: true; turnId: string } { this.loadOutbox().push({ turnId, text, ...(opts?.images?.length ? { images: opts.images } : {}), // Queued WITH the turn: instructions govern the turn they ride, and // the replay is that same turn arriving late. Dropping them here // would send the user's words under different rules than the app // chose for them, which is worse than sending them late. ...(opts?.instructions ? { instructions: opts.instructions } : {}), at: Date.now(), // Only stamp the provably-unsent case — an absent flag reads as // AMBIGUOUS (the conservative default) in flushOutbox. ...(unsent ? { unsent: true } : {}) }); this.commitOutbox(); if (opts?.awaitReply) { throw new CompanionError( 'offline — the text was queued and will send when the connection returns; no reply to await', 0, 'queued_offline' ); } return { seq: null, queued: true, turnId }; } /** The awaitReply leg of sendText. Prefers the in-request answer (the * streaming `done` frame's envelope); falls back to the next * `companion.message` off the event stream, bounded by a timeout. The * fallback subscription opens BEFORE the POST so a fast event-stream reply * can't slip through the gap. */ private async sendTextAwaitingReply( sessionId: string, body: Record, opts?: { stream?: boolean; replyTimeoutMs?: number; signal?: AbortSignal }, turnId?: string ): Promise { const timeoutMs = opts?.replyTimeoutMs ?? 60_000; // Already-aborted fast-fail comes FIRST (0.35.0): it used to sit after the // companion.message subscription below, whose cleanup lives in the finally // of the try we never entered — every call with a spent signal leaked one // permanent handler (a host reusing one AbortController across turns // accumulated them without bound). if (opts?.signal?.aborted) throw new CompanionError('request aborted', 0, 'aborted'); let resolveFallback!: (env: CompanionEnvelope) => void; const fallback = new Promise((res) => (resolveFallback = res)); // Correlate: resolve only on THIS turn's reply (`replyTo === turnId`). // Servers on API ≥ 1.1 always echo it, so there an un-tagged message is // PROACTIVE traffic (world-state reaction, confirm outcome) — skip it. // Older servers never echo — keep the legacy resolve-on-first there. const off = this.on('companion.message', (env) => { const replyTo = (env.payload as { replyTo?: unknown } | null)?.replyTo; if (replyTo === turnId) return resolveFallback(env); if (replyTo === undefined && !this.serverEchoesReplyTo()) resolveFallback(env); }); // One deadline bounds the WHOLE operation (0.28.0): the timer starts // before the POST, so a stalled streaming response can no longer hang the // promise past replyTimeoutMs (previously only the fallback wait was // bounded). The abort tears the in-flight stream down with it. const ac = new AbortController(); // Link the caller's signal: aborting it tears down the in-flight stream // (via ac) AND rejects the whole operation with `aborted`, exactly like // the deadline does. let onCallerAbort: (() => void) | undefined; if (opts?.signal) { onCallerAbort = () => ac.abort(); opts.signal.addEventListener('abort', onCallerAbort, { once: true }); } let timer: ReturnType | undefined; // Held so the finally can REMOVE it — a host reusing one long-lived signal // across many awaitReply calls accumulated one orphan listener per call. let onCallerAbortReject: (() => void) | undefined; const deadline = new Promise((_, reject) => { timer = setTimeout(() => { ac.abort(); reject( new CompanionError( `timed out after ${timeoutMs}ms waiting for the reply — buffered mode needs start() polling the event stream`, 0, 'reply_timeout' ) ); }, timeoutMs); // The caller's signal also rejects the operation (the stream teardown // alone would otherwise leave the fallback wait hanging to the timer). if (opts?.signal) { onCallerAbortReject = () => reject(new CompanionError('request aborted', 0, 'aborted')); opts.signal.addEventListener('abort', onCallerAbortReject, { once: true }); } }); try { let seq: number | null = null; let envelope: CompanionEnvelope | undefined; let pausedOnTools = false; let usage: TurnUsage | undefined; let block: TurnBlockSignal = {}; let dup: { duplicate?: true } = {}; if (opts?.stream === false) { const json = await Promise.race([ // ac.signal, like the streaming leg: the deadline and the caller's // signal both abort it — without this the promise rejected but the // billed POST kept running beyond cancellation. this.postJson<{ seq: number | null; usage?: TurnUsage | null; // The buffered ack carries the same `kind` as the stream's // done frame; reading it is what makes `needs_event_stream` // reachable on this leg (0.54.2 — before, a tool-pausing turn // waited out the full replyTimeoutMs and reported reply_timeout). kind?: 'message' | 'tool_calls'; duplicate?: boolean; // A duplicate replay carries the recorded reply (server #861). envelope?: CompanionEnvelope | null; } & TurnBlockSignal>( `/api/companion/session/${sessionId}/input`, body, ac.signal, // Buffered turn: without this floor the default 30s headers // deadline fired BEFORE replyTimeoutMs (60s), so the documented // reply_timeout contract was unreachable (CR-141). The outer // `deadline` still bounds the whole operation. BUFFERED_TURN_TIMEOUT_MS ), deadline ]); seq = json.seq ?? null; usage = json.usage ?? undefined; pausedOnTools = json.kind === 'tool_calls'; block = pickBlockSignal(json); dup = pickDuplicate(json); if (dup.duplicate && json.envelope) envelope = json.envelope; } else { const r = await Promise.race([ this.sendTextStreaming(sessionId, body, ac.signal), deadline ]); seq = r.seq; envelope = r.envelope; pausedOnTools = r.kind === 'tool_calls'; usage = r.usage; block = pickBlockSignal(r); dup = pickDuplicate(r); } if (!envelope) { // A turn paused on app tool calls resolves via the event stream once // the app posts results — without start() those events never arrive, // so waiting out the timer is a guaranteed hang. Fail fast + say why. if (pausedOnTools && !this.streaming) { throw new CompanionError( 'the turn paused on app tool calls — awaitReply needs start() (the event stream) to receive the post-resume reply', 0, 'needs_event_stream' ); } // A replayed turn's reply was delivered when it first ran, so the // stream will never carry it again: waiting would only end in a // reply_timeout that reads like the turn never answered (0.73.0). if (dup.duplicate) { throw new CompanionError( 'this turnId already completed and its recorded reply is no longer available', 0, 'reply_unavailable' ); } envelope = await Promise.race([fallback, deadline]); } const replyText = (envelope.payload as { text?: unknown })?.text; return { seq, text: typeof replyText === 'string' ? replyText : '', // the awaited reply IS the companion.message envelope (CR-754) envelope: envelope as CompanionEnvelope, ...(usage ? { usage } : {}), ...block, ...dup }; } finally { off(); if (timer !== undefined) clearTimeout(timer); if (onCallerAbort) opts?.signal?.removeEventListener('abort', onCallerAbort); if (onCallerAbortReject) opts?.signal?.removeEventListener('abort', onCallerAbortReject); } } /** The streaming leg of sendText: parse the POST response's SSE frames — * `delta` / `reset` → delta subscribers, `done` → resolve (emitting the * final envelope locally so onMessage fires immediately and the log replay * dedups by id). Falls back to buffered semantics if the server ever * responds with plain JSON (e.g. an old deployment). */ private async sendTextStreaming( sessionId: string, body: Record, signal?: AbortSignal ): Promise< { seq: number | null; envelope?: CompanionEnvelope; kind?: string; usage?: TurnUsage; duplicate?: true } & TurnBlockSignal > { const res = await this.fetchAuthed(this.url(`/api/companion/session/${sessionId}/input`), { method: 'POST', headers: this.jsonHeaders(), body: JSON.stringify({ ...body, stream: true }), ...(signal ? { signal } : {}) }); const ctype = res.headers.get('content-type') ?? ''; if (!ctype.includes('text/event-stream')) { // Old server / error before the stream opened — behave like postJson. const json = (await res.json().catch(() => null)) as | ({ ok?: boolean; error?: string; code?: string; retryAfterSec?: number; errorId?: string; seq?: number | null; } & Record) | null; if (!res.ok || !json || json.ok === false) { // Same as postJson: the 429 body's actionable `hint` (e.g. "provision // a project …") must survive the streaming leg too (0.30.0). const hint = (json as { hint?: unknown } | null)?.hint; const detail = typeof hint === 'string' && hint ? ` — ${hint}` : ''; throw new CompanionError( (json?.error || `request failed (${res.status})`) + detail, res.status, json?.code ?? nonJsonOkCode(res, json), this.retryAfterFrom(res, json), typeof json?.errorId === 'string' ? json.errorId : undefined, degradedOf(json) ); } return { seq: json.seq ?? null, usage: (json.usage as TurnUsage) ?? undefined, ...pickDuplicate(json) }; } if (!res.body) throw new CompanionError('streaming response has no body', res.status, 'bad_response', this.retryAfterFrom(res, null)); const reader = res.body.getReader(); const decoder = new TextDecoder(); let buf = ''; const fireDelta = (chunk: string, meta: { reset?: boolean }) => { for (const h of this.deltaHandlers) h(chunk, meta); }; try { for (;;) { const { done, value } = await reader.read(); if (value) buf += decoder.decode(value, { stream: true }); const { events, rest } = parseSse(buf); buf = rest; for (const ev of events) { if (ev.event === 'delta') { // A malformed delta frame is dropped, not fatal — losing one text // chunk beats throwing away the whole turn (the final message // arrives intact in the done frame / event stream regardless). let d: { text?: string }; try { d = JSON.parse(ev.data) as { text?: string }; } catch { continue; } if (typeof d.text === 'string' && d.text) fireDelta(d.text, {}); } else if (ev.event === 'reset') { fireDelta('', { reset: true }); } else if (ev.event === 'done') { // The done frame carries the turn's resolution — unparseable means // the turn's outcome is unknowable, so fail it explicitly instead // of letting a raw SyntaxError escape the SDK. let d: { ok?: boolean; error?: string; code?: string; status?: number; /** The failure shape's server-error reference (CR-723). */ errorId?: string; kind?: string; seq?: number | null; envelope?: CompanionEnvelope | null; usage?: TurnUsage | null; duplicate?: boolean; } & TurnBlockSignal; try { d = JSON.parse(ev.data) as typeof d; } catch { try { await reader.cancel(); } catch { /* stream already finished */ } throw new CompanionError('malformed done frame', res.status, 'bad_response', this.retryAfterFrom(res, null)); } try { await reader.cancel(); } catch { /* stream already finished */ } if (d.ok === false) { // The failure frame's `errorId` is the same server reference // the JSON envelope carries (CR-723). throw new CompanionError( d.error || 'turn failed', d.status ?? 500, d.code, undefined, typeof d.errorId === 'string' ? d.errorId : undefined ); } // Emit the final envelope NOW so onMessage fires with zero poll // latency; the event-stream replay of the same id is deduped. if (d.envelope && typeof d.envelope === 'object') this.emit(d.envelope); return { seq: d.seq ?? null, envelope: d.envelope ?? undefined, kind: d.kind, usage: d.usage ?? undefined, ...pickBlockSignal(d), ...pickDuplicate(d) }; } } if (done) break; } // CR-997: a truncated stream (a function killed at maxDuration mid-turn, // a dropped connection) is client-observed: status 0 and a code, never a // fake upstream 502. throw new CompanionError('stream ended without a done frame', 0, 'stream_incomplete'); } catch (e) { // A throwing user onDelta handler (or an abort) must not leak the // reader — cancel so the connection is released, then rethrow. try { await reader.cancel(); } catch { /* stream already finished */ } // Normalize (0.35.0): a mid-stream network drop rejects reader.read() // with a NATIVE TypeError. Rethrowing it raw broke the "every helper // rejects with CompanionError" contract AND slipped past the offline // queue's isNetworkSendFailure (which requires a code-less status-0 // CompanionError) — the exact loss queueOffline exists to prevent. // Queueing the replay is safe: it reuses the ORIGINAL turnId and the // server dedupes completed turns, so a turn that did run isn't re-run. throw this.asCompanionError(e); } } /** The live offline queue, loaded from the store on first use so a reloaded * tab resumes what its predecessor persisted. Unreadable or foreign store * content is discarded — a corrupt store must never break sending. */ private loadOutbox(): QueuedSend[] { if (this.outbox) return this.outbox; let items: QueuedSend[] = []; try { const raw = (this.opts.outboxStore ?? defaultOutboxStore).load(this.outboxKey()); const parsed = raw ? (JSON.parse(raw) as unknown) : null; if (Array.isArray(parsed)) { items = parsed.filter( (q): q is QueuedSend => !!q && typeof (q as QueuedSend).turnId === 'string' && typeof (q as QueuedSend).text === 'string' ); } } catch { /* unreadable store — start empty */ } this.outbox = items; return items; } /** The storage key for THIS client's queue. A queued send is addressed to a * session, and `connect()` resolves the session from BOTH `surface` and * `visitor` — so the key must carry both or the queue is shared with a * session it was never meant for. A representative client therefore gets * its own namespace: without one, visitor A's queue was loaded by visitor * B's client on the same surface and replayed into B's thread (B reads A's * private text, it is consolidated into the wrong memory, and the owner is * billed for the turn). * * The visitor namespace is a distinct PREFIX rather than a suffix, for two * reasons. (a) Back-compat: an owner-facing client's key is byte-identical * to what every release since 0.34.0 wrote, so upgrading never orphans a * queue that is already correct. (b) No ambiguity: `pouchy-outbox-v:` can * never collide with `pouchy-outbox:` + any surface (the character after * `pouchy-outbox` differs), and percent-encoding both components keeps the * separator unambiguous, so surface `s` + visitor `x` and the surface * literally named `s:x` stay distinct. */ private outboxKey(): string { const surface = this.opts.surface ?? 'default'; const visitorId = this.opts.visitor?.id; if (!visitorId) return `pouchy-outbox:${surface}`; return `pouchy-outbox-v:${encodeURIComponent(surface)}:${encodeURIComponent(visitorId)}`; } /** Persist + notify after EVERY queue mutation (enqueue / flushed / drop), * so the stored copy never lags what a reload would need. Handler * isolation matches emit(): a throwing consumer must not skip the rest. */ private commitOutbox(): void { const items = this.loadOutbox(); try { (this.opts.outboxStore ?? defaultOutboxStore).save(this.outboxKey(), JSON.stringify(items)); } catch { /* store save failed — the in-memory queue still flushes this page */ } const copy = Object.freeze([...items]) as readonly QueuedSend[]; for (const h of this.outboxHandlers) { try { h(copy); } catch (e) { console.error('[companion-sdk] outbox handler threw', e); } } } /** The sends queued while offline (oldest first), as a defensive copy — * see the `queueOffline` option. */ pendingOutbox(): readonly QueuedSend[] { return [...this.loadOutbox()]; } /** Subscribe to offline-queue changes (enqueue / flushed / dropped) — drive * a "N pending" badge from `items.length`. Returns an unsubscribe fn. */ onOutboxChange(handler: (items: readonly QueuedSend[]) => void): () => void { this.outboxHandlers.add(handler); return () => this.outboxHandlers.delete(handler); } /** Replay the offline queue: FIFO, sequential, each item a normal buffered * input POST reusing its ORIGINAL `turnId` (the server dedupes completed * turnIds — see `queueOffline` — so a retry can never double-run a turn). * Runs automatically after connect() and on every stream reconnect; call * it yourself for a manual retry affordance. One flush at a time — * concurrent calls share the in-flight run. Per item: success (or a * server-side duplicate) → removed; network failure / 5xx / 429 → stop, * keep this item and the rest for the next flush; any other 4xx → the * item itself is invalid (the server deliberately rejected it) — drop it, * console.warn, and keep flushing. Since 0.50.0 the "keep" arm is bounded * for the ONE failure class that carries no status at all: after * `OUTBOX_MAX_REPLAY_ATTEMPTS` online attempts that rejected at the network * level, an item older than `OUTBOX_AMBIGUOUS_MAX_AGE_MS` is dropped rather * than left to block the queue behind it forever. */ async flushOutbox(): Promise<{ sent: number; remaining: number }> { this.requireSession(); this.outboxFlush ??= this.runOutboxFlush().finally(() => { this.outboxFlush = null; }); return this.outboxFlush; } private async runOutboxFlush(): Promise<{ sent: number; remaining: number }> { const id = this.requireSession(); const items = this.loadOutbox(); // Respect the last replay 429's Retry-After (0.35.0): the flush re-arms // on EVERY stream reconnect, so without this gate a rate-limited server // got re-hammered on each reconnect despite naming its own window. if (Date.now() < this.outboxBlockedUntil) return { sent: 0, remaining: items.length }; let sent = 0; while (items.length) { const item = items[0]; // CR-140 staleness guard: an AMBIGUOUS item (may already have run // server-side) older than the server's dedup window can no longer be // replayed safely — the turnId may have aged out, so a replay would // double-run the turn (double bill + duplicate reply + duplicate tool // side effects). Drop it rather than risk that; a provably-unsent item // (navigator.onLine was false) never ran and replays at any age. if (!item.unsent && Date.now() - item.at > OUTBOX_AMBIGUOUS_MAX_AGE_MS) { console.warn( '[companion-sdk] dropping a stale unacknowledged queued send (older than the server turn-dedup window — replaying it could double-run the turn)', item.turnId ); items.shift(); this.commitOutbox(); continue; } try { await this.postJson<{ seq: number | null; duplicate?: boolean }>( `/api/companion/session/${id}/input`, { text: item.text, turnId: item.turnId, ...(item.images?.length ? { images: item.images } : {}), ...(item.instructions ? { instructions: item.instructions } : {}) }, undefined, // Replays are buffered turns too — same headers-arrive-at-turn-end // deadline as dispatchText's buffered leg (CR-141). BUFFERED_TURN_TIMEOUT_MS ); } catch (e) { const status = e instanceof CompanionError ? e.status : 0; // A 409 means a PREVIOUS turn is still mid-flight on this session // (turn_pending awaiting tool results, or turn_active) — a TRANSIENT // state, not a rejection of this item; keep it (and the rest) for // the next flush. 408/423/425 are likewise retry-worthy. Only a // genuine terminal 4xx (400/401/403/404/422) drops the item. const transient = status === 409 || status === 408 || status === 423 || status === 425; if (status >= 400 && status < 500 && status !== 429 && !transient) { // The server saw it and said no — retrying can't fix the item. console.warn('[companion-sdk] dropping queued send the server rejected', status, e); items.shift(); this.commitOutbox(); continue; } // A throttle names its own retry window — honor it across flushes. // A 429 is the SERVER pacing us, never this item's fault, so it must // not spend the futility budget below. if (status === 429) { // Capped like the stream (CR-756): a proxy's `Retry-After: 3600` // or a skewed HTTP-date must not silence the queue — and the // host's manual flushOutbox() — for an hour. if (e instanceof CompanionError && typeof e.retryAfter === 'number') { this.outboxBlockedUntil = Date.now() + Math.min(e.retryAfter * 1000, STREAM_RETRY_AFTER_CAP_MS); } break; } // Every remaining status is READABLE — 5xx, or the 409/408/423/425 // transients above — which means the server saw the request and the // condition is its own to clear. Those keep waiting indefinitely, as // they always have. if (status !== 0) break; // status 0 is the one class carrying NO information: `fetch` rejected, // so the SDK cannot tell "the network is down" (retry is right, for as // long as it takes) from "this request can never be sent" (retry is // futile — and because the queue is FIFO, it blocks every send behind // it). Before 0.50.0 the second case had no exit at all for a // provably-unsent item: it replayed on every reconnect forever. Bound // it — see OUTBOX_MAX_REPLAY_ATTEMPTS for why all three conditions. if (typeof navigator === 'undefined' || navigator.onLine !== false) { item.attempts = (item.attempts ?? 0) + 1; } if ( (item.attempts ?? 0) >= OUTBOX_MAX_REPLAY_ATTEMPTS && Date.now() - item.at > OUTBOX_AMBIGUOUS_MAX_AGE_MS ) { console.warn( `[companion-sdk] dropping a queued send after ${item.attempts} failed replay attempts — the failure is not transient. A request body the host refuses (images are the usual cause; downscale before sending) fails this way, because the rejection arrives with no readable status.`, item.turnId ); items.shift(); this.commitOutbox(); continue; } // Keep this item and everything behind it for the next flush. this.commitOutbox(); break; } items.shift(); sent += 1; this.commitOutbox(); } return { sent, remaining: items.length }; } /** Subscribe to token-streaming deltas of the reply (P1-2). Registering a * subscriber makes sendText stream automatically. Returns an unsubscribe * function. Render chunks as they arrive; on `meta.reset` clear the partial; * when onMessage fires, replace the partial with the final text. */ onDelta(handler: DeltaHandler): () => void { this.deltaHandlers.add(handler); return () => this.deltaHandlers.delete(handler); } /** Push live world-state — a single event or a batch. Retained events * (retained:true) coalesce per kind; transient ones carry a salience. * * Ergonomic input: `specversion` / `id` / `source` are filled automatically * (id → a fresh handle, source → this surface), so callers only supply the * meaningful fields: `{ type, data, retained?, salience?, voiceRelevant? }`. * A full CloudEvents envelope is still accepted (its fields win). */ async sendWorldState( event: WorldStateInput | WorldStateInput[], opts?: { signal?: AbortSignal } ): Promise<{ accepted: number; dropped: number; injected: number; reacted: boolean; duplicates: number; /** How many of `accepted` were transients under the 0.25 salience floor * (an omitted `salience` is 0), which the server discards (0.77.0). * Absent when the server predates the field — unknown, not zero. */ belowFloor?: number; }> { const id = this.requireSession(); const fill = (e: WorldStateInput): WorldStateEvent => ({ ...e, specversion: '1.0', id: e.id ?? this.randomId(), source: e.source ?? this.opts.surface ?? 'companion-sdk' }); const body = Array.isArray(event) ? { events: event.map(fill) } : fill(event); const json = await this.postJson<{ accepted: number; dropped: number; injected?: number; reacted?: boolean; duplicates?: number; belowFloor?: number; }>(`/api/companion/session/${id}/context`, body, opts?.signal); // injected/reacted tell the host a live-call inject or a proactive text // reaction was triggered by this batch (0.28.0 — previously discarded). // duplicates (0.41.0) is how much of `accepted` was replay dedup: the // server is envelope-id idempotent, so stamp stable `id`s on host-level // retries and a re-sent batch folds nothing while reporting the same // accepted count. return { accepted: json.accepted ?? 0, dropped: json.dropped ?? 0, injected: json.injected ?? 0, reacted: json.reacted === true, duplicates: json.duplicates ?? 0, ...(typeof json.belowFloor === 'number' ? { belowFloor: json.belowFloor } : {}) }; } private randomId(): string { const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto; return c?.randomUUID ? c.randomUUID() : `ws_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`; } /** Open the voice plane. Returns realtime credentials to connect DIRECTLY to * the voice provider (the Pouchy server is out of the audio path). Provider * precedence is server-side: ElevenLabs Convai (primary) → OpenAI Realtime * (fallback). Requires the `call` scope. * * - `elevenlabs-convai`: open the EL session with the returned `token` and * pass `instructions` as `overrides.agent.prompt.prompt` (the EL agent runs * EL's LLM, so the persona/memory snapshot rides in client-side). * - `openai-realtime`: open WebRTC with the short-lived `clientSecret` * (instructions are baked into the session server-side). */ async startCall( opts?: { voice?: string; locale?: string; /** Ask for Eleven v4 performed cues — the companion laughs, sighs and * whispers instead of only saying words (0.64.0). ElevenLabs calls * only; the server may decline (`creds.audioTags` says whether it * did). When granted, transcripts carry `[laughs]`-style cues: strip * them with `stripAudioTags` (connectCall does). */ audioTags?: boolean; /** Record the whole call, both voices (0.65.0). ElevenLabs calls on an * agent with recording turned on (`call.record`, the agent's dashboard * switch); `creds.recording` says whether * it is, and `creds.recordingUnavailable` why not. Fetch the audio * after the call with `getCallRecording(creds.callGen)`. */ record?: boolean; /** What THIS call is about, from your application (0.67.0): "she just * got the acceptance letter", "you are calling from the station". The * companion opens on it and keeps to it; it is never read out. Up to * 1,000 characters. Needs the `chat.instructions` scope (the agent's * per-turn instructions switch) — without it the call is refused with * `missing_scope`, so send it from a token your backend minted. */ brief?: string; /** A talking-face video avatar on this call (0.75.0): one of your * project's ready faces (built with your admin key — admin-sdk * `createVideoFace`). Needs the `call.video` scope; * `creds.video` carries the Simli session, `creds.videoUnavailable` * why there is none. Billed per minute on top of the voice. * `model` (0.76.0) picks Simli's animation model — `fasttalk` * (default) or `artalk`; `creds.video.model` says which ran. */ avatar?: { faceId: string; model?: CallVideoModel }; signal?: AbortSignal; } ): Promise { const id = this.requireSession(); const creds = await this.postJson( `/api/companion/session/${id}/call`, { voice: opts?.voice, locale: opts?.locale, ...(opts?.audioTags ? { audioTags: true } : {}), ...(opts?.record ? { record: true } : {}), ...(opts?.brief ? { brief: opts.brief } : {}), ...(opts?.avatar ? { avatar: { faceId: opts.avatar.faceId, ...(opts.avatar.model ? { model: opts.avatar.model } : {}) } } : {}) }, opts?.signal ); // Remember the minted window's generation so a later endSession()/close() // (without an explicit callGen) fences its clear to OUR call (0.41.0). this.lastCallGen = creds.callGen ?? null; return creds; } /** Open a live voice call end-to-end: mints credentials (startCall) and opens a * WebRTC session directly to the provider — no first-party app code needed. * Browser-only. Returns a handle to hang up + inject live context. EL Convai * needs the optional '@elevenlabs/client' peer dependency; OpenAI Realtime has * no extra deps. */ async connectCall( opts?: CompanionCallOptions & { voice?: string; locale?: string; /** Eleven v4 performed cues (0.64.0) — see `startCall`. Transcripts * passed to `onTranscript` and to memory are stripped of them. */ audioTags?: boolean; /** Record the whole call (0.65.0) — see `startCall`. The returned call * carries `recording` and `callGen`. */ record?: boolean; /** What this call is about (0.67.0) — see `startCall`. */ brief?: string; /** A video avatar (0.75.0) — see `startCall`. The returned call carries * `video` / `videoUnavailable`; drawing the face is yours (simli-client). */ avatar?: { faceId: string; model?: CallVideoModel }; } ): Promise { // Buffer the voice transcript so we can hand it to the server at call end — // the audio is provider-direct, so this is the ONLY way a voice play-along // reaches the companion's long-term memory. We still forward every line to // the caller's own onTranscript. const transcript: { role: 'user' | 'assistant'; text: string }[] = []; const callerOnTranscript = opts?.onTranscript; const callerOnError = opts?.onError; const callerOnEnd = opts?.onEnd; // Cleanup must run whichever side ends the call — the app calling close() // OR a provider-initiated teardown (pc failure / EL disconnect), which // surfaces as onError with no close() following. Idempotent. `unsub` is // assigned after the call opens; a connect-phase onError finds the no-op. let unsub: () => void = () => {}; let cleaned = false; // Captured PER HANDLE at mint time (0.41.0): a second connectCall on this // client overwrites lastCallGen, so the first handle's (possibly late) // teardown must carry the gen of ITS OWN mint — the server then no-ops the // clear instead of silencing the new call's window (CR-142 leg d). let mintedGen: string | null = null; const cleanup = () => { if (cleaned) return; cleaned = true; unsub(); // Consolidate this call into durable memory (best-effort, keepalive). void this.endSession({ transcript, callGen: mintedGen }); }; const wrappedOpts: CompanionCallOptions = { ...opts, onTranscript: (e) => { // Cap the buffer (endSession consolidates the tail anyway) so an // hours-long call can't grow this array unbounded. if (e?.text?.trim()) { transcript.push({ role: e.role, text: e.text }); if (transcript.length > 200) transcript.splice(0, transcript.length - 200); } callerOnTranscript?.(e); }, onError: (err) => { cleanup(); callerOnError?.(err); }, // Always passed down (0.71.0), so the provider leg reports every end // here; the caller's own contract is applied on this side. A clean // remote end still consolidates memory (/end), and a caller WITHOUT // onEnd still gets it on onError as `call disconnected`, as before. onEnd: (end) => { cleanup(); if (callerOnEnd) callerOnEnd(end); else if (end.reason === 'agent' || end.reason === 'remote') { callerOnError?.(new Error('call disconnected')); } } }; // Let the live voice call invoke tools — routed through the SAME // onToolCall/sendToolResult the text path uses (one handler for voice + // text). We offer the app's DECLARED tools PLUS the universal host-control // verbs (pause/toggle/navigate/…) so the companion can act on any surface. // Only when the app signalled it wants to act (declared tools or handles) — // otherwise a pure-chat embed wouldn't get tools it can't service. const declared = this.opts.tools ?? []; const wantsTools = declared.length > 0 || (this.opts.handles?.length ?? 0) > 0; const hostVerbs = wantsTools ? HOST_CONTROL_TOOLS.filter((v) => !declared.some((d) => d.name === v.name)) : []; // Avatar emote tools ride along ALWAYS — even a pure-chat / audio embed that // declared no tools — so the embodied companion can express itself without // each third party declaring play_gesture / play_expression, and so the EL // Convai path (whose shared agent already knows these tools) has a handler to // call. The SDK no-ops them unless the app opted in by declaring the same // name (see invokeVoiceTool); declared ones use the app's own schema. const avatarTools = AVATAR_VISUAL_TOOLS.filter((v) => !declared.some((d) => d.name === v.name)); const voiceTools = [...declared, ...hostVerbs, ...avatarTools]; const bridge: VoiceToolBridge | undefined = voiceTools.length ? { tools: voiceTools, invoke: (name, argsJson) => this.invokeVoiceTool(name, argsJson) } : undefined; const creds = await this.startCall({ voice: opts?.voice, locale: opts?.locale, audioTags: opts?.audioTags, record: opts?.record, brief: opts?.brief, avatar: opts?.avatar }); mintedGen = creds.callGen ?? null; let call: CompanionCall; try { call = await openCompanionCall(creds, wrappedOpts, bridge); } catch (e) { // The mint SUCCEEDED (the server stamped a 20-min call-active window // when it issued creds) but the call never opened — mic denied, missing // '@elevenlabs/client' peer dep, SDP failure. Every connect-phase leg // THROWS rather than firing onError, so without this the window // outlived a call that never existed: voice_inject burned rate/budget // slots into nothing and text play-along stayed suppressed for ≤20min. // Tear down exactly like an instant hang-up (cleanup → /end clears the // window server-side), then surface the original error. startCall // failures stay OUTSIDE this catch: a 403/429 mint never stamped the // window, and ending the session on a budget reject would be wrong. cleanup(); throw e; } // Bridge server-pushed voiceRelevant moments into the live call (item 2): // the server emits companion.voice_inject while the call is active; inject // each into the session so the companion reacts out loud. Needs start(). unsub = this.on('companion.voice_inject', (env) => { const p = env.payload as { text?: unknown; speak?: unknown }; if (typeof p?.text === 'string' && p.text) call.injectEvent(p.text, p.speak !== false); }); const close = call.close; return { ...call, ...(creds.callGen ? { callGen: creds.callGen } : {}), ...(creds.recording !== undefined ? { recording: creds.recording } : {}), ...(creds.recordingUnavailable ? { recordingUnavailable: creds.recordingUnavailable } : {}), ...(creds.worldBrief ? { worldBrief: creds.worldBrief } : {}), ...(creds.privateStory ? { privateStory: creds.privateStory } : {}), ...(creds.trimmed ? { trimmed: creds.trimmed } : {}), ...(creds.video ? { video: creds.video } : {}), ...(creds.videoUnavailable ? { videoUnavailable: creds.videoUnavailable } : {}), close: () => { close(); cleanup(); } }; } /** End the session: consolidate what happened into the companion's long-term * memory, so it remembers this experience in future first-party conversations. * Called automatically when a connectCall() handle is closed; call it directly * when a non-voice session ends. Best-effort + idempotent (safe to call twice; * uses keepalive so it survives a page unload). Pass the voice transcript when * you have it (connectCall does this for you). * * Returns the server's consolidation diagnostic — `{ ok, facts?, extracted?, * writeError?, skipped? }` — or `null` if there was no session or the * (swallowed) keepalive request failed. `skipped` tells you WHY nothing was * written: `'no_session'` (you passed the instance id, not the session id), * `'no_content'` (empty turn log / a voice transcript that never arrived), * `'throttled'` (a duplicate end beacon — harmless). `facts` is how many * memories were STORED; `extracted` (0.48.0) is how many the extraction * proposed, so `extracted > 0 && facts === 0` means the store refused them * and `writeError` says why (see `EndSessionResult`). */ async endSession(opts?: { transcript?: { role: string; text: string }[]; /** Which call-active window this teardown owns (0.41.0). A string fences * the server's window clear to that call's mint; `null` says "I hold no * call — leave any live window alone". Defaults to the gen of the last * call THIS client minted (null if none), so a text client's close() no * longer kills a live call's window on the same session. Self-plumbed * integrations pass startCall's `creds.callGen` here at call end. */ callGen?: string | null; }): Promise { this._lastEndSessionError = null; const id = this._session; if (!id) return null; const callGen = opts?.callGen !== undefined ? opts.callGen : this.lastCallGen; // Cap the payload (fetch keepalive bodies are size-limited): last 60 lines. const transcript = (opts?.transcript ?? []) .filter((t) => t && typeof t.text === 'string' && t.text.trim()) .slice(-60) .map((t) => ({ role: t.role, text: t.text.slice(0, 500) })); try { // Deliberately keeps the general 30s headers deadline (unlike the // buffered /input and /tool-result legs): a teardown call must not hold // its awaiter for minutes on a hung server. A >30s consolidation still // COMPLETES server-side — only this diagnostic return is lost (null). const res = await this.fetchAuthed(this.url(`/api/companion/session/${id}/end`), { method: 'POST', headers: this.jsonHeaders(), body: JSON.stringify({ transcript, callGen }), keepalive: true }); // An HTTP rejection (401 expired token / 403 / 404 / 429 / 5xx) must // not be CAST into EndSessionResult: the server's err() body is // `{ ok:false, error, code }`, whose two informative fields the // declared type cannot carry — the caller got a manufactured // `{ ok:false }` state the documented skipped-ladder never names, // indistinguishable from "consolidation ran and failed". `null` is the // documented value for "the request failed"; the status and server // code go to the debug channel, which exists for exactly this // (Python/Dart raise here — JS stays best-effort by design, teardown // runs from pagehide). if (!res.ok) { const body = (await res.json().catch(() => null)) as { code?: string } | null; this.dbg({ kind: 'error', at: Date.now(), message: `endSession rejected (${res.status})`, code: body?.code, status: res.status }); this._lastEndSessionError = { status: res.status, ...(typeof body?.code === 'string' ? { code: body.code } : {}) }; return null; } return (await res.json().catch(() => null)) as EndSessionResult | null; } catch { /* best-effort — memory consolidation is not on the user's critical path */ return null; } } /** Tear the session down in one call: stop the event stream and consolidate * memory (endSession) exactly once. The single teardown entry point for a * NON-voice (text) session — mirror of what a connectCall() handle's close() * does for voice. Idempotent. Returns endSession's diagnostic (or null). * NOTE: if you drive `ping()` on your own timer for a background tab, clear * that timer yourself — the client doesn't own it. */ async close(): Promise { this.stop(); return this.endSession(); } /** Report the result of a companion.tool_call this surface performed. When all * of the turn's calls are reported, the companion resumes and the continuation * (a companion.message or more tool calls) arrives on the event stream. */ async sendToolResult( callId: string, result: { ok?: boolean; result?: unknown }, opts?: { signal?: AbortSignal } ): Promise<{ allDone: boolean }> { // Voice tool-call: resolve it locally so the result flows back to the voice // provider, not to a server text turn. (Same call shape as the text path.) const pending = this.pendingVoiceTools.get(callId); if (pending) { pending(JSON.stringify({ ok: result.ok !== false, result: result.result ?? null })); return { allDone: true }; } const id = this.requireSession(); const json = await this.postJson<{ allDone: boolean }>( `/api/companion/session/${id}/tool-result`, { callId, ok: result.ok !== false, result: result.result }, opts?.signal, // The winning result post answers only after resumeTurn completes — // the full billed continuation (server maxDuration 300). Same // headers-arrive-at-turn-end deadline as the buffered /input legs; the // 30s default rejected request_timeout on any >30s continuation while // it kept running server-side (CR-141 class, missed in 0.40.0). BUFFERED_TURN_TIMEOUT_MS ); return { allDone: json.allDone ?? false }; } /** A tool the live voice call asked the app to run. Fires the SAME * companion.tool_call event the text flow uses (so the app's onToolCall + * sendToolResult handle voice identically), and resolves with the result * string fed back to the voice model. Times out so a missing result can't * hang the call. */ private invokeVoiceTool(name: string, argsJson: string): Promise { const isHostVerb = HOST_CONTROL_TOOL_NAMES.includes(name); const isAvatarTool = AVATAR_VISUAL_TOOL_NAMES.includes(name); const isDeclared = this.opts.tools?.some((t) => t.name === name) ?? false; if (!isHostVerb && !isAvatarTool && !isDeclared) { return Promise.resolve(JSON.stringify({ ok: false, error: `unknown tool: ${name}` })); } // Avatar emote the app didn't opt into (didn't declare) → silent no-op // success: a pure audio / 2D embed never renders a body, so the gesture / // expression call resolves OK (the model's turn continues) but nothing // happens. Declared avatar tools fall through to the app's handler below. if (isAvatarTool && !isDeclared) { return Promise.resolve(JSON.stringify({ ok: true })); } // Don't make the voice model wait ~20s if nothing can service the call. if (!this.handlers.get('companion.tool_call')?.size) { return Promise.resolve( JSON.stringify({ ok: false, error: 'no tool handler (register companion.onToolCall)' }) ); } // invoke_action carries the REAL action name in its params — unwrap it so it // reaches onToolCall exactly like a declared tool. The other host verbs // (set_feature/set_value/navigate/highlight) pass through by name. let dispatchName = name; let dispatchArgs = argsJson; if (name === 'invoke_action') { try { const a = JSON.parse(argsJson || '{}') as { action?: unknown; params?: unknown }; if (typeof a.action !== 'string' || !a.action) { return Promise.resolve(JSON.stringify({ ok: false, error: 'invoke_action needs "action"' })); } dispatchName = a.action; dispatchArgs = JSON.stringify(voiceObjectParam(a.params) ?? {}); } catch { return Promise.resolve(JSON.stringify({ ok: false, error: 'invoke_action args not JSON' })); } } else if (name === 'navigate') { // Same object-typed `params` as invoke_action, same string delivery. // Rewritten only when it changes, so other args pass byte-for-byte. try { const a = JSON.parse(argsJson || '{}') as Record; const params = voiceObjectParam(a.params); if (params !== a.params) dispatchArgs = JSON.stringify({ ...a, params }); } catch { /* leave args as-is */ } } else if (name === 'set_feature') { // The ElevenLabs path can only send string params, so `on` arrives as // "true"/"false". Normalize to a real boolean so the app's handler is // provider-agnostic (OpenAI already sends a boolean). try { const a = JSON.parse(argsJson || '{}') as { feature?: unknown; on?: unknown }; const on = a.on === true || a.on === 'true'; dispatchArgs = JSON.stringify({ feature: a.feature, on }); } catch { /* leave args as-is */ } } const id = `vc_${this.randomId()}`; return new Promise((resolve) => { let settled = false; let timer: ReturnType | undefined; const finish = (out: string) => { if (settled) return; settled = true; // Clear on normal completion too — a chatty tool-using call was // accumulating a live 20s timer (+closure) per invocation. if (timer) clearTimeout(timer); this.pendingVoiceTools.delete(id); resolve(out); }; this.pendingVoiceTools.set(id, finish); timer = setTimeout(() => finish(JSON.stringify({ ok: false, error: 'tool result timed out' })), 20_000); this.emit({ v: PROTOCOL_VERSION, id: `evt_${this.randomId()}`, ts: Date.now(), type: 'companion.tool_call', payload: { id, name: dispatchName, args: dispatchArgs } } as CompanionEnvelope); }); } /** Convenience: subscribe to tool-call requests from the companion. The call * carries the wire payload plus `argsJson` — `args` pre-parsed as JSON * (undefined when empty or malformed), so handlers don't each re-implement * the same try/catch around `JSON.parse(args)`. */ onToolCall( handler: (call: ToolCallEvent, envelope: CompanionEnvelope) => void ): () => void { return this.on('companion.tool_call', (env) => { const p = env.payload as { id?: unknown; name?: unknown; args?: unknown; replayed?: unknown }; if (typeof p?.id === 'string' && typeof p?.name === 'string') { const args = typeof p.args === 'string' ? p.args : ''; handler( { id: p.id, name: p.name, args, argsJson: parseArgsJson(args), // Pause-orphaned replay (0.37.0) — see replayPendingToolCalls. ...(p.replayed === true ? { replayed: true } : {}) }, env ); } }); } /** Convenience: subscribe to errors the companion surfaces — server-side agent * errors and stream failures (e.g. a permanent `stream_unauthorized`) both * arrive as `control.error`. Since 0.30.0 `code` is typed as * `ControlErrorCodeValue` (the CONTROL_ERROR_CODES vocabulary + the * SDK-synthesized stream codes, open for newer servers) so a `switch` on it * autocompletes. A malformed frame with no string `code` is delivered with * the literal fallback `'error'` (not part of the exported vocabulary) — * give your `switch` a `default:` arm rather than enumerating every code. * Returns an unsubscribe fn. */ onError( handler: ( err: { code: ControlErrorCodeValue; message: string }, envelope: CompanionEnvelope ) => void ): () => void { return this.on('control.error', (env) => { const p = env.payload as { code?: unknown; message?: unknown }; handler( { code: typeof p?.code === 'string' ? p.code : 'error', message: typeof p?.message === 'string' ? p.message : 'unknown error' }, env ); }); } /** Recall the memory this token is authorized to see. Without `query` the * facts come back ranked by importance/recency; with `query` the server * runs semantic search over them (embedding-based, falls back to ranked * when embeddings are unavailable) — e.g. * `recall({ query: 'food preferences', limit: 10 })`. */ async recall(opts?: { limit?: number; query?: string; signal?: AbortSignal }): Promise { const params = new URLSearchParams(); if (opts?.limit) params.set('limit', String(opts.limit)); if (opts?.query?.trim()) params.set('q', opts.query.trim()); const query = params.toString(); const qs = query ? `?${query}` : ''; const res = await this.fetchAuthed(this.url(`/api/companion/memory${qs}`), { headers: { Authorization: `Bearer ${this.opts.token}` }, ...(opts?.signal ? { signal: opts.signal } : {}) }); const json = (await res.json().catch(() => null)) as | { ok?: boolean; error?: string; code?: string; retryAfterSec?: number; errorId?: string; memories?: RecalledMemory[] } | null; if (!res.ok || !json || json.ok === false) { throw new CompanionError( json?.error || `recall failed (${res.status})`, res.status, json?.code, this.retryAfterFrom(res, json), errorIdOf(json), degradedOf(json) ); } return json.memories ?? []; } /** Fetch this session's recent conversation turns (oldest→newest) so a * reconnecting embed can restore its transcript. Distinct from `recall` * (durable memory / facts) — this is the raw exchange log. Returns the * token's OWN session turns only; requires a live session (call `connect` * first). `limit` defaults to 20, capped at 50. */ async history(opts?: { limit?: number; signal?: AbortSignal }): Promise { const id = this.requireSession(); const qs = opts?.limit ? `?limit=${encodeURIComponent(opts.limit)}` : ''; const res = await this.fetchAuthed( this.url(`/api/companion/session/${encodeURIComponent(id)}/history${qs}`), { headers: { Authorization: `Bearer ${this.opts.token}` }, ...(opts?.signal ? { signal: opts.signal } : {}) } ); const json = (await res.json().catch(() => null)) as | { ok?: boolean; error?: string; code?: string; retryAfterSec?: number; errorId?: string; history?: CompanionTurn[] } | null; if (!res.ok || !json || json.ok === false) { throw new CompanionError( json?.error || `history failed (${res.status})`, res.status, json?.code, this.retryAfterFrom(res, json), errorIdOf(json), degradedOf(json) ); } return json.history ?? []; } /** Change this session's I/O modalities mid-session (e.g. enable/disable * `voice`). The request is intersected with the token's granted modalities * server-side — a session can't widen past its key — and the EFFECTIVE set is * returned. Requires a live session. */ async setModalities(modalities: string[], opts?: { signal?: AbortSignal }): Promise<{ modalities: string[] }> { const id = this.requireSession(); const res = await this.fetchAuthed( this.url(`/api/companion/session/${encodeURIComponent(id)}/modalities`), { method: 'POST', headers: { Authorization: `Bearer ${this.opts.token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ modalities }), ...(opts?.signal ? { signal: opts.signal } : {}) } ); const json = (await res.json().catch(() => null)) as | { ok?: boolean; error?: string; code?: string; retryAfterSec?: number; errorId?: string; modalities?: string[] } | null; if (!res.ok || !json || json.ok === false) { throw new CompanionError( json?.error || `setModalities failed (${res.status})`, res.status, json?.code, this.retryAfterFrom(res, json), errorIdOf(json), degradedOf(json) ); } return { modalities: json.modalities ?? modalities }; } /** Keepalive: bump this session's last-seen time so a long-idle embed stays * "live" within the session TTL (cross-app A2A friend messages keep reaching * it). Cheap; call it on a timer for a background tab. Requires a live session. */ async ping(opts?: { signal?: AbortSignal }): Promise { const id = this.requireSession(); const res = await this.fetchAuthed( this.url(`/api/companion/session/${encodeURIComponent(id)}/ping`), { method: 'POST', headers: { Authorization: `Bearer ${this.opts.token}` }, ...(opts?.signal ? { signal: opts.signal } : {}) } ); if (!res.ok) { const json = (await res.json().catch(() => null)) as | { error?: string; code?: string; retryAfterSec?: number; errorId?: string } | null; throw new CompanionError( json?.error || `ping failed (${res.status})`, res.status, json?.code, this.retryAfterFrom(res, json), errorIdOf(json), degradedOf(json) ); } } /** Remember a fact (into this app's namespace unless `namespace` is given and * the token has the core scope). Intimate-tier writes are rejected server-side. * `dedup` (0.60.0) says whether the write MERGED into a row the user already * had — in which case `cloudId` is that existing row's id, the same handle * `forget(id)` takes — or minted a new one. */ async remember(fact: { content: string; importance?: number; confidence?: number; /** Only these two are kept server-side — anything else is discarded, so * the type refuses it at compile time instead of dropping it silently. */ category?: 'relationship' | 'shared_experience'; kind?: string; namespace?: string; sensitivity?: 'public' | 'personal'; }, opts?: { signal?: AbortSignal }): Promise<{ cloudId: string; namespace: string; dedup?: RememberDedup }> { return this.postJson<{ cloudId: string; namespace: string; dedup?: RememberDedup }>( '/api/companion/memory', fact, opts?.signal ); } /** The whole-call recording of a call started with `record: true` (0.65.0): * both voices, mixed by the voice provider, as MP3. Call it after the call * ends; the provider needs a little time, which answers `pending` with a * `retryAfterSec`. Recordings are kept for 7 days — copy what you need to * your own storage. `callGen` is the call's `creds.callGen` (or the * `callGen` of the handle `connectCall` returned); it must belong to THIS * session. Other failures (auth, provider outage) throw `CompanionError`. */ async getCallRecording(callGen: string, opts?: { signal?: AbortSignal }): Promise { const id = this.requireSession(); const res = await this.fetchAuthed( this.url(`/api/companion/session/${id}/call/${encodeURIComponent(callGen)}/audio`), { method: 'GET', headers: { Authorization: `Bearer ${this.opts.token}` }, ...(opts?.signal ? { signal: opts.signal } : {}) } ); if (res.status === 200) { const contentType = res.headers.get('content-type') || 'audio/mpeg'; // The body is read AFTER the 200 headers; a drop mid-download used to // reject with the raw TypeError (CR-1148d). Same normalization as a // failed fetch: status 0 (retryable), or `aborted` for the caller's signal. let audio: Blob; try { audio = await res.blob(); } catch (e) { throw this.asCompanionError(e); } return { state: 'ready', audio, contentType }; } const json = (await res.json().catch(() => null)) as | { ok?: boolean; error?: string; code?: string; state?: string; retryAfterSec?: number; errorId?: string } | null; if (res.status === 202 && json?.state === 'pending') { return { state: 'pending', retryAfterSec: this.retryAfterFrom(res, json) ?? 30 }; } if (res.status === 404 && (json?.state === 'none' || json?.state === 'expired' || json?.state === 'deleted')) { return { state: json.state }; } throw new CompanionError( json?.error || `getCallRecording failed (${res.status})`, res.status, json?.code, this.retryAfterFrom(res, json), errorIdOf(json), degradedOf(json) ); } /** Delete the recording of a call (0.65.0) — the provider's copy, audio and * transcript. Idempotent. A call with no recording answers `{ deleted: * false }`; your own copy is yours to delete. */ async deleteCallRecording(callGen: string, opts?: { signal?: AbortSignal }): Promise<{ deleted: boolean }> { const id = this.requireSession(); const res = await this.fetchAuthed( this.url(`/api/companion/session/${id}/call/${encodeURIComponent(callGen)}/audio`), { method: 'DELETE', headers: { Authorization: `Bearer ${this.opts.token}` }, ...(opts?.signal ? { signal: opts.signal } : {}) } ); const json = (await res.json().catch(() => null)) as | { ok?: boolean; error?: string; code?: string; state?: string; retryAfterSec?: number; errorId?: string } | null; if (res.ok && json?.ok !== false) return { deleted: true }; if (res.status === 404 && json?.state === 'none') return { deleted: false }; throw new CompanionError( json?.error || `deleteCallRecording failed (${res.status})`, res.status, json?.code, this.retryAfterFrom(res, json), errorIdOf(json), degradedOf(json) ); } /** Forget ONE fact by the `id` a `recall()` row carries. This is the user's * authoritative "stop telling me this": the fact stops recalling and stays * forgotten (background re-derivation will not resurrect it) — the same * gesture the first-party surface uses, not a document delete. Requires * the write scope for the fact's namespace (`memory.write:app` for this * app's rows, `memory.write:core` for core). A fact this token cannot see * — another app's, the intimate tier, or nonexistent — answers the same * 404. Idempotent: forgetting a forgotten fact succeeds and says so. */ async forget( id: string, opts?: { signal?: AbortSignal } ): Promise<{ forgotten: boolean; alreadyForgotten: boolean }> { const res = await this.fetchAuthed(this.url(`/api/companion/memory/${encodeURIComponent(id)}`), { method: 'DELETE', headers: { Authorization: `Bearer ${this.opts.token}` }, ...(opts?.signal ? { signal: opts.signal } : {}) }); const json = (await res.json().catch(() => null)) as | { ok?: boolean; error?: string; code?: string; retryAfterSec?: number; errorId?: string; forgotten?: boolean; alreadyForgotten?: boolean } | null; if (!res.ok || !json || json.ok === false) { // Same as postJson: a body's actionable `hint` survives (CR-681). const hint = (json as { hint?: unknown } | null)?.hint; const detail = typeof hint === 'string' && hint ? ` — ${hint}` : ''; throw new CompanionError( (json?.error || `forget failed (${res.status})`) + detail, res.status, json?.code, this.retryAfterFrom(res, json), errorIdOf(json), degradedOf(json) ); } return { forgotten: json.forgotten === true, alreadyForgotten: json.alreadyForgotten === true }; } /** Ingest a DOCUMENT into the user's knowledge base. Unlike `remember` (one * short fact), this distils already-extracted text — a PDF body, a meeting * transcript, notes — into a headline summary PLUS full-text chunks, each * embedded and semantically recallable, and surfaces it in the user's "My * materials" list with source attribution, exactly like a first-party upload. * So the materials entry point can live in YOUR app, not just Pouchy's. * * Extract the text yourself (your own parser, or Pouchy's /api/stt for audio — * send your pchy_ token as the bearer; the relay is auth-gated and metered) * and pass it here. Writes the user's SHARED knowledge, so the token must * hold `memory.write:core` (the user's consent) — otherwise 403. The ingest * is SYNCHRONOUS server-side (summarize + one embedding per chunk, up to * ~2min for a large doc) — the response arrives when the material is fully * recallable. `kind` is a free label for the source type (pdf / audio / * note / …) used only for display + the materials icon. * * `truncated: true` means the document was NOT fully ingested — the text ran * past the 60,000-char cap, or produced more chunks than the 120-chunk * ceiling. What came back IS recallable; the remainder was dropped, so split * the source and ingest the rest. * * `chunksFailed` (when present) counts chunk writes that FAILED: the * document is only partly stored, and a blind retry duplicates the part * that landed. When no chunk could be stored at all, the call throws a 503 * `unavailable` instead — nothing was written, so a retry is safe. */ async ingestKnowledge(doc: { text: string; name: string; kind?: string; locale?: string; }, opts?: { signal?: AbortSignal }): Promise<{ ok: boolean; summary: string; chunks: number; chunksFailed?: number; truncated: boolean }> { return this.postJson<{ ok: boolean; summary: string; chunks: number; chunksFailed?: number; truncated: boolean }>( '/api/companion/knowledge', doc, opts?.signal, // Synchronous ingest under maxDuration 300 — headers arrive when every // chunk is summarized + embedded. The 30s default timed out real PDFs // mid-ingest, and the natural retry then raced the still-running first // ingest (the register's CR-14 interleave, made systematic). BUFFERED_TURN_TIMEOUT_MS ); } /** Convenience over `ingestKnowledge`: hand over a RAW file and Pouchy * understands it server-side before ingesting — no parser/transcriber/vision on * your side. Pass a `data:;base64,…` URL. Supported today: **PDF** * (server-side text extraction), **audio/video** (Whisper transcript), and * **image** (server-side vision caption — an objective description becomes the * material). For other types, extract the text yourself and call * `ingestKnowledge`. Same `memory.write:core` requirement and "My materials" * integration as `ingestKnowledge`. * * `truncated` carries the same meaning as on `ingestKnowledge`, and covers * the server-side UNDERSTANDING step too: a PDF whose text layer overruns * the extraction cap reports `true`. `chunksFailed` as on * `ingestKnowledge`. * * `figurePass` (on a deployment with the PDF figure pass enabled) says * which pages were read VISUALLY — sent to the vision provider as images — * and why: `selected` (`raster` / `sparse`), `dropped` by the per-document * cap, `described`, and the pages the model `declined` or left * `unanswered`. Absent when the pass did not run. */ async ingestFile(file: { dataUrl: string; name: string; kind?: string; locale?: string; }, opts?: { signal?: AbortSignal }): Promise<{ ok: boolean; summary: string; chunks: number; chunksFailed?: number; truncated: boolean; figurePass?: IngestFigurePass }> { return this.postJson<{ ok: boolean; summary: string; chunks: number; chunksFailed?: number; truncated: boolean; figurePass?: IngestFigurePass }>( '/api/companion/knowledge/file', file, opts?.signal, // Whisper/vision/PDF understanding + the synchronous ingest above, all // before headers (maxDuration 300) — same deadline as ingestKnowledge. BUFFERED_TURN_TIMEOUT_MS ); } /** Fetch the user's CURRENT companion avatar so the embedding can render the * same virtual human Pouchy shows. The avatar is a VRM 3D model (`vrmUrl`, * load it with a VRM/glTF renderer e.g. three-vrm); `imageUrl` is a flat 2D * portrait when one exists (null today for built-in models). URLs are absolute * and the /models asset is CORS-enabled, so a cross-origin renderer can fetch * the VRM directly. Reflects the user's live choice (built-in or custom). */ async getAvatar(opts?: { signal?: AbortSignal }): Promise { const res = await this.fetchAuthed(this.url('/api/companion/avatar'), { headers: { Authorization: `Bearer ${this.opts.token}` }, ...(opts?.signal ? { signal: opts.signal } : {}) }); const json = (await res.json().catch(() => null)) as | ({ ok?: boolean; error?: string; code?: string; retryAfterSec?: number; errorId?: string } & Partial) | null; if (!res.ok || !json || json.ok === false) { throw new CompanionError( json?.error || `avatar fetch failed (${res.status})`, res.status, json?.code, this.retryAfterFrom(res, json), errorIdOf(json), degradedOf(json) ); } return { name: json.name ?? null, archetype: json.archetype ?? 'girlfriend', modelId: json.modelId ?? null, vrmUrl: json.vrmUrl ?? null, imageUrl: json.imageUrl ?? null }; } /** Read the companion instance's OWN wallet — nonzero stablecoin balances + * total USD. Read-only and receive-only (spends go through the confirm-gated * pay tools, never here). Requires the `wallet.read` scope (or `wallet.spend`, * which subsumes it). Same data the companion gives when asked "what's my * balance?". */ async getWallet(opts?: { signal?: AbortSignal }): Promise { const res = await this.fetchAuthed(this.url('/api/companion/wallet'), { headers: { Authorization: `Bearer ${this.opts.token}` }, ...(opts?.signal ? { signal: opts.signal } : {}) }); const json = (await res.json().catch(() => null)) as | ({ ok?: boolean; error?: string; code?: string; retryAfterSec?: number; errorId?: string } & Partial) | null; if (!res.ok || !json || json.ok === false) { throw new CompanionError( json?.error || `wallet fetch failed (${res.status})`, res.status, json?.code, this.retryAfterFrom(res, json), errorIdOf(json), degradedOf(json) ); } return { balances: json.balances ?? [], totalUsd: json.totalUsd ?? 0, currency: json.currency ?? 'USD' }; } /** Canonical URL of the official Pouchy brand icon (square PNG, transparent * background) at the given size. Static + public — needs no token — so it's * safe to drop straight into an `` for "powered by Pouchy" badges, * the companion's app tile, etc. Sizes: 256 | 512 | 1024 (default 512). */ brandIconUrl(size: BrandIconSize = 512): string { return pouchyBrandIconUrl(this.opts.baseUrl, size); } /** Subscribe to outbound events of a given type, or "*" for all. Returns an * unsubscribe function. Since 0.30.0 the envelope payload is NARROWED per * event name (OutboundPayloadMap) — e.g. `on('companion.typing', (env) => * env.payload.active)` type-checks without a cast; `'*'` keeps the untyped * envelope. Type-only: runtime behavior is unchanged. */ on( type: T, handler: (envelope: CompanionEnvelope) => void ): () => void; on(type: '*', handler: Handler): () => void; on(type: OutboundType | '*', handler: Handler): () => void { let set = this.handlers.get(type); if (!set) { set = new Set(); this.handlers.set(type, set); } set.add(handler); return () => set?.delete(handler); } /** Convenience: subscribe to assistant text replies. */ onMessage(handler: (text: string, envelope: CompanionEnvelope) => void): () => void { return this.on('companion.message', (env) => { const text = (env.payload as { text?: unknown })?.text; // CR-754: the envelope is the `companion.message` one, typed so the // documented `envelope.payload.attachments` access compiles. if (typeof text === 'string') handler(text, env as CompanionEnvelope); }); } /** Convenience: subscribe to Instant UI render surfaces (companion.ui_action). * The host draws `payload.interface` with its OWN renderer — a web component, a * native iOS/Android view tree, a CLI/TUI — all consuming the same * platform-neutral genui schema. If the panel set `reportChanges`, feed the * user's edits back via `sendText`/`sendWorldState` to continue the turn. */ onRender( handler: (payload: RenderInterfacePayload, envelope: CompanionEnvelope) => void ): () => void { return this.on('companion.ui_action', (env) => { const p = env.payload as RenderInterfacePayload; if (p && typeof p === 'object' && Array.isArray(p.interface?.nodes)) handler(p, env); }); } /** Convenience: subscribe to TTS audio clips for the reply (companion.audio, * non-call modality) — play `url`, or resolve `ref` your way. */ onAudio(handler: (payload: AudioClipPayload, envelope: CompanionEnvelope) => void): () => void { return this.on('companion.audio', (env) => { const p = env.payload as AudioClipPayload; if (p && typeof p === 'object' && (typeof p.url === 'string' || typeof p.ref === 'string')) { handler(p, env); } }); } /** Convenience: subscribe to avatar cues (companion.expression) — viseme / * expression / gesture for an embed rendering the companion's body. */ onExpression(handler: (payload: ExpressionPayload, envelope: CompanionEnvelope) => void): () => void { return this.on('companion.expression', (env) => handler(env.payload as ExpressionPayload, env)); } /** Convenience: subscribe to the per-token metering echo (control.usage) for a * usage / billing view. */ onUsage(handler: (payload: UsagePayload, envelope: CompanionEnvelope) => void): () => void { return this.on('control.usage', (env) => handler(env.payload as UsagePayload, env)); } /** Convenience: subscribe to sensitive-op confirmation requests * (companion.confirm_request) — a payment, skill run, or friend message the * companion wants the user to approve. Use it to surface the pending * approval in your UI. * * Whether YOU can resolve it depends on the token: * - **Platform session tokens** (minted via /v1/sessions for your end * users): show your own confirm card and call `confirmAction` — your end * user is the account's human, so the session may approve. This is how * confirm-gated custom skills (POST / credentialed) run. * - **First-party user tokens** (Login-with-Pouchy PATs): observe-only — * approval is authed as the Pouchy user (their app / a hosted confirm * page), which is also where the biometric (Face ID / passkey) gate * lives. `confirmAction` returns 403 (code `forbidden`) on these tokens. */ onConfirmRequest( handler: (payload: ConfirmRequestPayload, envelope: CompanionEnvelope) => void ): () => void { return this.on('companion.confirm_request', (env) => { const p = env.payload as ConfirmRequestPayload; if (p && typeof p === 'object' && typeof p.confirmId === 'string') handler(p, env); }); } /** Resolve a pending confirmation (platform session tokens only — see * `onConfirmRequest`). Show the user what they're approving (the event's * `summary`), collect an explicit tap, then pass their decision here. On * approval the recorded action runs server-side; a short NATURAL-LANGUAGE * summary of what it did comes back as `outcome` in this response (render * it where the user tapped) AND as a normal companion.message on the * stream (so the conversation shows it). A denial returns/emits a brief * decline the same way. Single-use: re-resolving a settled confirmId fails * with 409. * * `outcome` is PROSE FOR A PERSON, not a machine-readable shape: the * upstream/MCP payload the action produced is an internal implementation * detail and is summarized before it leaves the server — the same * discipline an ordinary (non-confirmed) tool call already follows, where * the raw result only ever reaches the model. Don't parse it; if you need * structured data, declare your own app tool and read its arguments. * * RETRY: if an approved IDEMPOTENT action (a wallet payment) flakes on a * transient error, `status` is `'exec_failed'` and `retryable` is true — you * MAY call `confirmAction(sameConfirmId, true)` again to re-run it (the server * dedupes a partial first attempt). A non-idempotent action (a message / skill) * stays terminal and is never retryable. * * `outcomeClass` (0.55.0) is the machine-readable half: `status:'approved'` * alone means only "the approval was accepted", and covers an action that * ran, one that failed terminally, and one the server refused. Switch on * `outcomeClass` to tell them apart — `success` (the action proved it * completed), `error`, `unknown` (it may or may not have gone through; do * not repeat it), `rejected` (the app declined it), `denied` (the user * declined; nothing ran), `unrecognized` (ordinary prose that neither * proves nor denies completion). Absent from servers older than this * field — treat absent as `unrecognized`, never as success. */ async confirmAction( confirmId: string, approve: boolean, opts?: { signal?: AbortSignal } ): Promise { const sessionId = this.requireSession(); return this.postJson( `/api/companion/session/${encodeURIComponent(sessionId)}/confirm`, { confirmId, approve }, opts?.signal, // An approve EXECUTES the action before responding (maxDuration 60). // Under the 30s default a 30-60s action timed out client-side AFTER it // ran — and the confirm is single-use, so the retry-approve 409'd and // the outcome-in-response channel silently degraded to the stream. CONFIRM_EXEC_TIMEOUT_MS ); } /** The full user-signed approval document for one confirm (0.59.0; design * #27 Phase 2) — what `confirmAction`'s `mandate` receipt summarizes: * `{ confirmId, status, intent, intentDigest, mandate, credential: * { credentialId, publicKey (COSE, base64url) }, verify: { challenge, rpID, * origin }, verified }`. A counterparty can verify the same document * offline against the keys the handle publishes at * `/a2a/{handle}/mandate-keys` (the Agent Card's `x_pouchy.mandateKeysUrl`). * Same subject rule as `confirmAction`. Resolves null on `404 * mandate_absent` — a deny, an approval that needed no step-up, or a row * from before the feature — and throws on any other failure. */ async getMandate(confirmId: string, opts?: { signal?: AbortSignal }): Promise { const sessionId = this.requireSession(); const res = await this.fetchAuthed( this.url( `/api/companion/session/${encodeURIComponent(sessionId)}/confirm/${encodeURIComponent(confirmId)}/mandate` ), { headers: { Authorization: `Bearer ${this.opts.token}` }, ...(opts?.signal ? { signal: opts.signal } : {}) } ); const json = (await res.json().catch(() => null)) as | ({ error?: string; code?: string; retryAfterSec?: number; errorId?: string } & Partial) | null; if (res.status === 404 && json?.code === 'mandate_absent') return null; if (!res.ok || !json) { throw new CompanionError( json?.error || `mandate fetch failed (${res.status})`, res.status, json?.code, this.retryAfterFrom(res, json), errorIdOf(json), degradedOf(json) ); } return json as MandateDocument; } /** The session's still-pending confirmations (display-safe: id, summary, * scope, timestamps — never raw args). Use it to rebuild your confirm card * after a reload, since confirm_request events are not replayed. Platform * session tokens only, like `confirmAction`. * * Since 0.61.0 a row carries the three things a rebuilt card has to say and * could not: `status` (`exec_failed` is one the user ALREADY approved whose * run flaked — approving again retries the SAME request, keyed on * `confirmId`, rather than starting a new one), `execUnsettled` (that flake * was the execution DEADLINE, so it may still complete on its own) and * `stepUp` (the approval will demand a passkey / Face ID — the same * advisory the live `companion.confirm_request` carries). Treat an absent * or unrecognized `status` as `pending`. */ async pendingConfirms(opts?: { signal?: AbortSignal }): Promise { const sessionId = this.requireSession(); const res = await this.fetchAuthed( this.url(`/api/companion/session/${encodeURIComponent(sessionId)}/confirm`), { headers: { Authorization: `Bearer ${this.opts.token}` }, ...(opts?.signal ? { signal: opts.signal } : {}) } ); const json = (await res.json().catch(() => null)) as | { ok?: boolean; error?: string; code?: string; retryAfterSec?: number; errorId?: string; pending?: PendingConfirm[] } | null; if (!res.ok || !json || json.ok === false) { throw new CompanionError( json?.error || `pending confirms failed (${res.status})`, res.status, json?.code, this.retryAfterFrom(res, json), errorIdOf(json), degradedOf(json) ); } return json.pending ?? []; } /** Convenience: subscribe to live updates of an already-rendered Instant UI * panel (companion.ui_update). Apply each `{ key, value }` to the panel's state * bag and re-evaluate bound displays — no rebuild. */ onInterfaceUpdate( handler: (payload: InterfaceUpdatePayload, envelope: CompanionEnvelope) => void ): () => void { return this.on('companion.ui_update', (env) => { const p = env.payload as InterfaceUpdatePayload; if (p && typeof p === 'object' && Array.isArray(p.update?.updates)) handler(p, env); }); } /** Convenience: subscribe to inbound A2A messages from the user's paired * friends (companion.social_message), delivered cross-app to any embed whose * token holds `social.message`. Surface them in your own UI / notify the user. */ onSocialMessage( handler: (payload: SocialMessagePayload, envelope: CompanionEnvelope) => void ): () => void { return this.on('companion.social_message', (env) => { const p = env.payload as SocialMessagePayload; if (p && typeof p === 'object' && typeof p.content === 'string') handler(p, env); }); } /** Convenience: subscribe to the companion's activity indicator * (companion.typing). Fires `active:true` when a turn starts working and * `active:false` when it finishes or pauses for a tool result — spanning the * tool-loop / thinking phase before the first text delta. Drive a "typing…" * affordance from it: `client.onTyping(({ active }) => setTyping(active))`. */ onTyping(handler: (payload: TypingPayload, envelope: CompanionEnvelope) => void): () => void { return this.on('companion.typing', (env) => { const p = env.payload as TypingPayload; if (p && typeof p === 'object' && typeof p.active === 'boolean') handler(p, env); }); } /** Convenience: subscribe to voice-inject cues (companion.voice_inject) — a * `voiceRelevant` world-state line the companion should say aloud during a * live call. Route `payload.text` to your active voice session when * `payload.speak`. A text-only host can ignore it. */ onVoiceInject( handler: (payload: VoiceInjectPayload, envelope: CompanionEnvelope) => void ): () => void { return this.on('companion.voice_inject', (env) => { const p = env.payload as VoiceInjectPayload; if (p && typeof p === 'object' && typeof p.text === 'string') handler(p, env); }); } /** Open the event stream (auto-reconnecting with the resume cursor). Uses the * WebSocket plane when opted in + available, else SSE. */ start(): void { if (this.streaming) return; this.requireSession(); this.streaming = true; this.setStreamState('connecting'); const gen = ++this.streamGen; const wsImpl = this.opts.webSocketImpl ?? (globalThis as { WebSocket?: typeof WebSocket }).WebSocket; if (this.opts.stream === 'websocket' && wsImpl) { void this.wsLoop(wsImpl, gen); } else { void this.loop(gen); } } /** WebSocket receive loop. Resolves when the socket closes; on any failure * while still streaming it falls back to the SSE loop, so opting into WS can * only ever DEGRADE to SSE, never drop replies. Input is still sent over * REST (sendInput) — receiving is the latency-sensitive half. */ private async wsLoop(WS: typeof WebSocket, gen: number): Promise { const id = this.requireSession(); const { deriveWsStreamUrl, decodeWsMessage } = await import('./ws-transport'); // Reconnect across CLEAN closes (a WS gateway with a bounded lifetime, like // the SSE endpoint's window) just as the SSE loop does — a graceful close // must not silently end reply delivery. Only a connection ERROR degrades to // SSE, and only once. Each connection's poll timer + socket are released in // the settle() finally so neither leaks after fallback/reconnect. let backoff = 500; while (this.streaming && gen === this.streamGen) { let errored = false; // Stamped in onopen (0.35.0) — measuring from before the constructor // counted DNS/TCP/handshake time as "connection lifetime", so a gateway // that took ~2s to accept and then instantly closed read as long-lived // and RESET the backoff (a near-hot reconnect loop). 0 = never opened, // which always backs off. Monotonic (0.51.2): a wall-clock stamp read // a suspend/NTP step as lifetime, in either direction. let openedAt = 0; // A window that decoded at least one protocol frame is exempt from the // short-close clamp (0.51.2): delivery proves a real server spoke, not // a gateway that accepts and instantly closes — the same delivered- // window exemption every other binding in the anti-storm roster // carries (python/go/dart/csharp and the Unity pump). let delivered = false; try { await new Promise((resolve, reject) => { const ws = new WS( deriveWsStreamUrl(this.opts.streamBaseUrl ?? this.opts.baseUrl, id, this.opts.token, this.cursor) ); let poll: ReturnType | undefined; let connectTimer: ReturnType | undefined; let settled = false; ws.onopen = () => { if (connectTimer !== undefined) { clearTimeout(connectTimer); connectTimer = undefined; } openedAt = mono(); if (this.streaming && gen === this.streamGen) this.setStreamState('connected'); }; const settle = (err?: unknown) => { if (connectTimer !== undefined) { clearTimeout(connectTimer); connectTimer = undefined; } if (poll) { clearInterval(poll); poll = undefined; } try { ws.close(); } catch { /* already closing */ } if (settled) return; settled = true; if (err) reject(err); else resolve(); }; ws.onmessage = (ev: MessageEvent) => { if (!this.streaming || gen !== this.streamGen) { settle(); return; } const raw = typeof ev.data === 'string' ? ev.data : ''; const frame = raw ? decodeWsMessage(raw) : null; // Only a real delivery (handleFrame: greeting or typed // envelope) arms the clamp exemption — a reconnect control // frame or an undecodable payload does not. if (frame && this.handleFrame(frame.event, frame.data)) delivered = true; }; ws.onerror = () => settle(new Error('companion ws error')); // A 4xxx close is an application REFUSAL (the ws-gateway sends // 4401 for an upstream 401/403, 4502 for any other upstream // failure). A server-initiated close fires only onclose, so // reading it as clean re-dialled a refusing gateway forever; // degrading lets the SSE plane surface the real status (0.73.1). ws.onclose = (ev?: { code?: number }) => { const code = ev?.code ?? 0; if (code >= 4000 && code <= 4999) settle(new Error(`companion ws refused (close ${code})`)); else settle(); }; // Bounded CONNECTING phase (#533, CR-638's JS-WS leg): a socket // black-holed mid-handshake fires neither onopen nor onerror — // browsers impose no WebSocket connect deadline of their own — // so this promise never settled and the WS plane hung without // ever reaching its SSE fallback. Same budget contract as // doFetchBounded on the SSE leg: `requestTimeoutMs`, 30s // default, 0 opts out. Expiry settles as an ordinary ws error, // so the existing errored path degrades to SSE — the right // outcome for a gateway whose WS endpoint has gone dark. const connectBudget = this.opts.requestTimeoutMs ?? 30_000; if (connectBudget) { connectTimer = setTimeout(() => { if (!settled && openedAt === 0) { settle(new Error(`companion ws connect timed out after ${connectBudget}ms`)); } }, connectBudget); } // Stopping the stream closes the socket + clears this timer. poll = setInterval(() => { if (!this.streaming || gen !== this.streamGen) settle(); }, 250); }); } catch (e) { errored = true; // The WS plane's failure is otherwise INVISIBLE: `streamState` goes // to `degraded_sse` and delivery continues, so an embed that has // silently fallen back to plan B looks identical to one that chose // SSE. The synthesized `companion ws error` (browser `onerror` // carries nothing more by design) or a constructor throw is the only // signal this transport produces — swallowing it left the `debug` // JSDoc's "synthesized errors" promise unkept on the one path that // had nothing else. Emitting is free when no sink is wired. this.dbg({ kind: 'error', at: Date.now(), message: e instanceof Error ? e.message : 'companion ws error', code: 'ws_degraded', status: 0 }); } if (errored) { // Socket unavailable / rejected — degrade to the robust SSE loop (once). // The SSE loop's healthy state then reads `degraded_sse`, so the host // can observe the (previously invisible) WS→SSE degrade. if (this.streaming && gen === this.streamGen) { this.setStreamState('reconnecting'); await this.loop(gen, true); } return; } // Clean close: the while-guard reconnects if still streaming, else exits. // A SHORT-LIVED connection (gateway that accepts then immediately // closes — auth-as-close, LB idle policy, misconfigured proxy) must // back off like the SSE loop, or this reconnects in a zero-delay hot // loop. A connection that lived a while — or that DELIVERED a frame, // however short — resets the backoff. if (this.streaming && gen === this.streamGen) this.setStreamState('reconnecting'); if (!delivered && (openedAt === 0 || mono() - openedAt < 2000)) { await this.backoffSleep(backoff); backoff = Math.min(backoff * 2, 8000); } else { backoff = 500; } } } /** Stop the event stream. */ stop(): void { this.streaming = false; this.streamGen++; // invalidate any loop still unwinding this.abort?.abort(); this.abort = null; this.setStreamState('stopped'); } private emit(envelope: CompanionEnvelope): void { // Dedup by envelope id — an ungraceful drop reconnects from the last KNOWN // cursor (advanced only on the clean `reconnect` frame), which can replay a // few events; the id set collapses those. if (envelope.id) { if (this.seen.has(envelope.id)) return; this.seen.add(envelope.id); if (this.seen.size > SEEN_CAP) { const first = this.seen.values().next().value; if (first !== undefined) this.seen.delete(first); } } // Post-dedup: the debug channel sees each envelope exactly once, in // delivery order — replays collapsed above never reach it. this.dbg({ kind: 'envelope', at: Date.now(), type: envelope.type, id: envelope.id ?? '' }); // Avatar emote tools the app didn't opt into resolve as a silent no-op, so a // pure audio / 2D embed never has to register a handler for them. (Voice // avatar calls are no-op'd earlier in invokeVoiceTool, before they reach // here; this covers the text / event-stream path.) Declared = the app opted // in → deliver to its onToolCall like any other tool. if (envelope.type === 'companion.tool_call') { const p = envelope.payload as { id?: unknown; name?: unknown }; const name = typeof p?.name === 'string' ? p.name : ''; const callId = typeof p?.id === 'string' ? p.id : ''; const declared = this.opts.tools?.some((t) => t.name === name) ?? false; if ( name && AVATAR_VISUAL_TOOL_NAMES.includes(name) && !declared && !this.pendingVoiceTools.has(callId) ) { if (callId) void this.sendToolResult(callId, { ok: true }).catch(() => {}); return; } } // Each handler is isolated: a THROWING consumer handler must not skip the // remaining handlers, and — worse — it used to propagate up through the // stream loop's catch, which read it as a CONNECTION failure (backoff + // reconnect + cursor replay); on the sendText local-emit path it rejected // a sendText whose turn had actually succeeded. for (const h of this.handlers.get(envelope.type) ?? []) { try { h(envelope); } catch (e) { console.error('[companion-sdk] event handler threw', e); } } for (const h of this.handlers.get('*') ?? []) { try { h(envelope); } catch (e) { console.error('[companion-sdk] event handler threw', e); } } } /** Returns whether the frame was a real protocol DELIVERY — the `open` * greeting or a typed envelope. The `reconnect` window-control frame and * undecodable payloads return false: they must not arm the delivered-window * clamp exemption, mirroring the rest of the anti-storm roster (python * skips type-less control dicts, go's takeFrame yields only envelopes) — * otherwise a gateway that opens a window, emits one reconnect frame and * EOFs would reconnect at network speed with zero backoff forever. */ private handleFrame(event: string, data: string): boolean { if (event === 'reconnect') { try { const d = JSON.parse(data) as { cursor?: number }; if (typeof d.cursor === 'number') this.cursor = d.cursor; } catch { /* ignore */ } return false; } let env: CompanionEnvelope | null = null; try { env = JSON.parse(data) as CompanionEnvelope; } catch { return false; } // A frame that parses to a non-object (`data: null`, a bare number, a // string) is not a protocol delivery on ANY arm — without this guard a // degenerate `open` frame armed the delivered-window exemption, the // one frame shape past the two 0.51.3 closed (CR-630). Arrays are the // typeof-object non-object (#533): `data: []` walked the 0.51.4 guard // and armed the exemption through the `open` arm exactly as `null` // did, while python's isinstance(dict) sibling never admitted them. if (!env || typeof env !== 'object' || Array.isArray(env)) return false; if (event === 'open') { const rc = (env?.payload as { resumeCursor?: number })?.resumeCursor; if (typeof rc === 'number') this.cursor = rc; // The greeting is a typed `hello.ack` envelope in OUTBOUND_TYPES, so // `on('hello.ack')` / `on('*')` must see it (CR-744). One per open; // `emit` dedups a replayed id. if (env.type === 'hello.ack') this.emit(env); return true; } if (env && typeof env.type === 'string') { this.emit(env); return true; } return false; } /** SSE receive loop. `degraded` marks that this loop is the WS transport's * fallback, so its healthy state reads `degraded_sse` (still live) instead * of `connected` — the host can tell "fine" from "fine, but on plan B". */ private async loop(gen: number, degraded = false): Promise { const id = this.requireSession(); let backoff = 500; let authRefreshes = 0; // consecutive 401→refresh cycles without a healthy connect while (this.streaming && gen === this.streamGen) { this.abort = new AbortController(); try { // Bounded headers phase (CR-631): a reconnect where TCP/TLS // completes but response headers never arrive (hung function, // LB black-hole, half-open socket after resume-from-sleep) // otherwise waits on the platform's own backstop — minutes — // while every roster sibling bounds it at ~30s (python's httpx // connect timeout, dart's kDefaultTimeout). SSE_STALL_MS only // arms INSIDE consume(), i.e. after headers. The timeout lands // in the catch below as an ordinary stream_reconnect on the // 500ms→8s ladder. Honors `requestTimeoutMs` (0 = opt out), // like every other bounded request. const res = await this.doFetchBounded( this.url(`/api/companion/session/${id}/stream?cursor=${this.cursor}`), { headers: { Authorization: `Bearer ${this.opts.token}` }, signal: this.abort.signal } ); if (res.status === 401 || res.status === 403) { // 401 = the token itself is bad — usually an EXPIRED session token // (they live ~1h). With an onAuthError hook this is recoverable: // refresh and reconnect instead of going permanently silent. The // counter stops a hot loop when the hook keeps returning a token // the server keeps rejecting. if (res.status === 401 && authRefreshes < 3 && (await this.tryRefreshToken())) { authRefreshes += 1; continue; } // Permanent: the token can't subscribe (most often it lacks the // `events.subscribe` scope; also revoked / wrong user). Retrying is // futile and would silently swallow every reply — surface it as a // control.error and stop the loop. this.streaming = false; this.emit({ v: PROTOCOL_VERSION, id: `stream_err_${Date.now()}`, session: this._session ?? undefined, ts: Date.now(), type: 'control.error', payload: { code: 'stream_unauthorized', // The one message a developer sees when replies go silent — // it must name the right repair per status. 401 = the token // itself was rejected (session tokens live ~1h). 403 = the // token is valid but cannot subscribe (missing scope). // // The 401 arm splits on `authRefreshes` because the two // sub-cases need OPPOSITE advice, and the flat message gave // the wrong one to the harder case: `authRefreshes > 0` is // reached only after a wired onAuthError already handed over // tokens the server also rejected, so "wire onAuthError to // refresh automatically" told that developer to do the exact // thing they had just done three times. Their bug is in what // the hook MINTS, not in its absence. message: res.status === 401 ? authRefreshes > 0 ? `event stream rejected (401) after ${authRefreshes} onAuthError refresh${authRefreshes === 1 ? '' : 'es'}; the server rejected every token the hook returned — check that it mints for THIS user and carries the "events.subscribe" scope` : 'event stream rejected (401); the session token was rejected — likely expired (they live ~1h). Re-mint it, or wire onAuthError to refresh automatically' : `event stream rejected (${res.status}); the token likely lacks the "events.subscribe" scope` } }); this.setStreamState('stopped'); break; } if (res.status === 404 || res.status === 410 || res.status === 400) { // CR-790: terminal, like 401/403 — a 404 `session_not_found` (the // session doc is gone: a GDPR wipe, a wrong uid), a 410 or a 400 // will answer the same on every retry, so the backoff ladder used // to loop forever with only a dbg() line. Surface it and stop. this.streaming = false; let serverCode = ''; try { const j = (await res.json()) as { code?: unknown } | null; if (j && typeof j.code === 'string') serverCode = j.code; } catch { /* body not JSON — the status is the diagnosis */ } this.emit({ v: PROTOCOL_VERSION, id: `stream_err_${Date.now()}`, session: this._session ?? undefined, ts: Date.now(), type: 'control.error', payload: { code: 'stream_rejected', message: `event stream rejected (${res.status}${serverCode ? ` ${serverCode}` : ''}); retrying cannot succeed — the session is gone or the request is invalid. connect() again for a fresh session, then start()` } }); this.setStreamState('stopped'); break; } if (!res.ok || !res.body) { if (!this.streaming || gen !== this.streamGen) break; // `res.status` reached `reconnectDelay` and was then dropped, so a // host watching a stream that never goes healthy saw only // reconnecting↔reconnecting transitions — a 502 from a proxy, a // 429, and a body-less 200 were one indistinguishable symptom. // The status is the whole diagnosis; carry it. this.dbg({ kind: 'error', at: Date.now(), message: res.ok ? 'event stream response carried no body' : `event stream reconnecting after HTTP ${res.status}`, code: 'stream_reconnect', status: res.status }); this.setStreamState('reconnecting'); await this.backoffSleep(this.reconnectDelay(res, backoff)); backoff = Math.min(backoff * 2, 8000); continue; } authRefreshes = 0; this.setStreamState(degraded ? 'degraded_sse' : 'connected'); // Stamped at headers-received, same instant as the WS leg's onopen // stamp — measuring from before the fetch would count DNS/TCP/TLS // time as "connection lifetime" (the 0.35.0 lesson). Monotonic // (0.51.2): a wall-clock stamp read a suspend/NTP step as lifetime. const openedAt = mono(); // Whether the window decoded at least one protocol frame — flipped // by consume() AT delivery time (0.51.3), not carried on its return // value: a window that delivered and then DIED mid-read used to // lose the flag to the thrown read, so the ladder escalated against // an origin that had just proven itself healthy. Every other roster // binding clears at first delivery, not at window end. A delivered // window is exempt from the short-close clamp (0.51.2): delivery // proves a real server spoke, not a gateway that accepts and // instantly closes. let delivered = false; try { await this.consume(res.body, gen, () => { delivered = true; }); } finally { // A connection that LIVED — or that DELIVERED — resets the // backoff whether it closed cleanly or died mid-read; a // short-lived empty one keeps escalating. (Replaces the old // unconditional reset at headers-received, which let a // serve-headers-then-EOF gateway re-arm 500ms forever.) if (delivered || mono() - openedAt >= 2000) backoff = 500; } // Clean close. The server ends every window at ~45s by design, so // a long-lived close reconnects immediately (backoff was just // reset). A SHORT-LIVED clean close that delivered NOTHING — a // gateway that accepts, serves headers, and instantly EOFs // (auth-as-close, an LB idle policy, a misconfigured proxy) — // must sit out the same anti-storm backoff the WS leg has had // since 0.35.0, or this (default) leg reconnects in a zero-delay // hot loop. if ( !delivered && mono() - openedAt < 2000 && this.streaming && gen === this.streamGen ) { this.setStreamState('reconnecting'); await this.backoffSleep(backoff); backoff = Math.min(backoff * 2, 8000); } } catch (e) { // The guard runs FIRST and stays first: `stop()` clears `streaming` // before aborting, so an intentional stop lands here as an // AbortError and must not be reported as a stream failure. if (!this.streaming || gen !== this.streamGen) break; this.dbg({ kind: 'error', at: Date.now(), message: e instanceof Error ? e.message : 'event stream connection failed', code: 'stream_reconnect', status: 0 }); this.setStreamState('reconnecting'); await this.backoffSleep(backoff); backoff = Math.min(backoff * 2, 8000); } } } /** Drain one SSE window. Calls `onDelivered` (once) the moment the first * real protocol frame is decoded — through a callback rather than the * return value, so the caller's delivered-window clamp exemption survives * a read that throws AFTER delivery (mid-window RST). Control frames and * undecodable payloads don't count (see handleFrame). */ private async consume( body: ReadableStream, gen: number, onDelivered: () => void ): Promise { const reader = body.getReader(); const decoder = new TextDecoder(); let buf = ''; let delivered = false; // Liveness watchdog (0.35.0). The server writes a `: ping` comment every // ~1.5s and closes each stream window at 45s — a healthy connection is // NEVER byte-silent for long. On a silent half-open TCP drop (NAT/LB // idle-kill, laptop sleep) no FIN ever arrives: reader.read() blocks for // minutes while streamState still reads 'connected' and every reply is // silently lost. 10× the ping interval without a single byte can only be // a dead connection — cancel the reader so read() returns and the loop // reconnects through its normal backoff + cursor resume (replays dedupe // by envelope id). Only the SSE plane gets this: the WS plane has no // heartbeat contract, so an idle-but-healthy socket can't be told apart. const SSE_STALL_MS = 15_000; let lastByteAt = mono(); const watchdog = setInterval(() => { if (mono() - lastByteAt > SSE_STALL_MS) void reader.cancel().catch(() => {}); }, 5_000); try { while (this.streaming && gen === this.streamGen) { const { done, value } = await reader.read(); if (done) break; lastByteAt = mono(); buf += decoder.decode(value, { stream: true }); const { events, rest } = parseSse(buf); buf = rest; for (const ev of events) { const wasDelivery = this.handleFrame(ev.event, ev.data); if (wasDelivery && !delivered) { delivered = true; onDelivered(); } } } } finally { clearInterval(watchdog); } } private sleep(ms: number): Promise { return new Promise((res) => setTimeout(res, ms)); } /** Backoff sleep with additive jitter (0–25%, 0.30.0) so a fleet of embeds * doesn't reconnect in lockstep after a server deploy/restart (thundering * herd) — every reconnect path sleeps through here, never bare sleep(). */ private backoffSleep(ms: number): Promise { return this.sleep(ms + Math.floor(Math.random() * ms * 0.25)); } /** How long to wait before re-opening the event stream after a non-OK * response. The `/api/companion/session` prefix is rate-limited, and a * refusal names its own recovery time: a cap is a 429 with Retry-After, * and a limiter outage is a 503 `unavailable` with Retry-After 30 — or, * where the prefix degrades to its outage budget instead of refusing, a * 429 from that budget. Any non-OK Retry-After is honoured here, whichever * it is. The reconnect loop's ceiling * is 8s, so ignoring it meant re-asking ~7×/min for the whole window and * keeping the bucket empty. The outbox flush has respected the server's * Retry-After since 0.35.0; this is the same rule on the stream. * * Clamped on BOTH sides on purpose: never SHORTER than the backoff we * would have slept anyway (so this can only ever reduce request rate, * never raise it), and never longer than STREAM_RETRY_AFTER_CAP_MS — a * proxy is free to emit `Retry-After: 3600`, and a silent hour of no * replies is worse than a few extra polls. Header only: the loop never * reads the error body (it may be a stream that outlives the request), and * the platform 429 sets the header alongside `retryAfterSec`. */ private reconnectDelay(res: Response, backoff: number): number { const sec = this.retryAfterFrom(res, null); if (sec === undefined) return backoff; return Math.max(backoff, Math.min(sec * 1000, STREAM_RETRY_AFTER_CAP_MS)); } }