@pouchy_ai/companion-sdk 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,366 @@
1
+ import type { AudioClipPayload, CompanionEnvelope, ConfirmRequestPayload, ExpressionPayload, InterfaceUpdatePayload, OutboundType, RenderInterfacePayload, SocialMessagePayload, UsagePayload, WorldStateEvent } from './protocol.js';
2
+ /** Ergonomic world-state input: a WorldStateEvent with the CloudEvents plumbing
3
+ * (specversion / id / source) optional — sendWorldState fills them. */
4
+ export type WorldStateInput<D = unknown> = Omit<WorldStateEvent<D>, 'specversion' | 'id' | 'source'> & Partial<Pick<WorldStateEvent<D>, 'specversion' | 'id' | 'source'>>;
5
+ import { type CompanionCall, type CompanionCallOptions } from './call.js';
6
+ export interface CompanionClientOptions {
7
+ /** Origin of the Pouchy deployment, e.g. "https://www.pouchy.ai". */
8
+ baseUrl: string;
9
+ /** A Pouchy access token (PAT). */
10
+ token: string;
11
+ /** Logical surface (one resumable session per surface), default "default". */
12
+ surface?: string;
13
+ /** Requested I/O modalities (intersected with the token's grant server-side). */
14
+ modalities?: string[];
15
+ /** Action types this surface can perform (declared at handshake). */
16
+ handles?: string[];
17
+ /** World-state kinds this surface emits (declared at handshake). */
18
+ contextKinds?: string[];
19
+ /** Tools the companion may ask this surface to perform (companion.tool_call). */
20
+ tools?: CompanionToolDecl[];
21
+ /** Static description of THIS app/game so the companion is grounded in where it
22
+ * is (drives reasoning + the call opener). Live state still flows via
23
+ * sendWorldState. */
24
+ appContext?: {
25
+ name?: string;
26
+ description?: string;
27
+ };
28
+ /** Open a REPRESENTATIVE ("on-behalf-of" / 代聊) session: the companion fields
29
+ * this VISITOR's messages on the owner's behalf — customer-service style —
30
+ * drawing only on screened owner context (persona + share-screened facts, plus
31
+ * the knowledge base when the owner's token grants `expose:knowledge`). Each
32
+ * visitor (stable opaque `id`, 8-64 url-safe chars that YOU keep stable per
33
+ * end-user) gets their own continuous thread. Requires the token's `represent`
34
+ * scope — without it the handshake is rejected (never silently downgraded to an
35
+ * owner-facing session). */
36
+ visitor?: {
37
+ id: string;
38
+ displayName?: string;
39
+ };
40
+ /** Injectable fetch (for Node < 18 / tests). Defaults to global fetch. */
41
+ fetch?: typeof fetch;
42
+ /** Receive transport for the reply stream. 'sse' (default) works on every
43
+ * deploy; 'websocket' opts into the lower-latency WS plane when the host
44
+ * supports it (adapter-node / a WS gateway) and falls back to SSE if the
45
+ * socket can't open — so enabling it can never stop reply delivery. */
46
+ stream?: 'sse' | 'websocket';
47
+ /** Injectable WebSocket constructor (Node without a global WebSocket, or
48
+ * tests). Defaults to globalThis.WebSocket when present. */
49
+ webSocketImpl?: typeof WebSocket;
50
+ }
51
+ export interface HelloAck {
52
+ session: string;
53
+ grantedScopes: string[];
54
+ modalities: string[];
55
+ resumeCursor: number;
56
+ /** True when this is a representative (visitor-facing) session — i.e. a
57
+ * `visitor` was supplied and the token holds `represent`. */
58
+ representative: boolean;
59
+ /** Representative sessions only: whether this visitor is already paired with
60
+ * the owner (their two companions are friends). Lets you skip offering a
61
+ * "pair" affordance to an already-connected visitor. */
62
+ visitorPaired: boolean;
63
+ }
64
+ /** A tool the embedding declares the companion may ask it to perform. */
65
+ export interface CompanionToolDecl {
66
+ name: string;
67
+ description?: string;
68
+ parameters?: Record<string, unknown>;
69
+ }
70
+ /** Voice-plane credentials, discriminated by provider (see startCall). */
71
+ export type CallCredentials = {
72
+ provider: 'elevenlabs-convai';
73
+ /** EL conversation token (open WebRTC with @elevenlabs/client). */
74
+ token: string;
75
+ agentId: string;
76
+ /** Pass as overrides.agent.prompt.prompt when starting the EL session. */
77
+ instructions: string;
78
+ /** Server-resolved EL voice id (the user's cloned/localized voice). Pass
79
+ * as overrides.tts.voiceId so the call isn't a stock default voice. */
80
+ voice?: string;
81
+ /** Server-resolved agent language (ISO 639-1, e.g. 'zh'). Pass as
82
+ * overrides.agent.language so the call opens in the user's language. */
83
+ language?: string;
84
+ /** First-message override ('' = suppress the canned greeting so the call
85
+ * opens in-character / context-aware). Pass as overrides.agent.firstMessage. */
86
+ firstMessage?: string;
87
+ } | {
88
+ provider: 'openai-realtime';
89
+ /** Ephemeral client secret (ek_…); open WebRTC directly to OpenAI. */
90
+ clientSecret: string;
91
+ model: string;
92
+ voice: string;
93
+ expiresAt: number | null;
94
+ };
95
+ export interface RecalledMemory {
96
+ content: string;
97
+ category?: string;
98
+ kind?: string;
99
+ importance: number;
100
+ namespace: string;
101
+ createdAt?: string;
102
+ }
103
+ /** The user's current companion avatar (see getAvatar). */
104
+ export interface CompanionAvatar {
105
+ /** Companion display name (the user's chosen name), or null. */
106
+ name: string | null;
107
+ /** Persona archetype driving the default look/voice. */
108
+ archetype: 'girlfriend' | 'boyfriend' | 'pet';
109
+ /** The active model id (e.g. "default-gf-luna", or a custom id), or null. */
110
+ modelId: string | null;
111
+ /** Absolute URL to the VRM 3D model — the avatar itself. CORS-enabled so a
112
+ * cross-origin renderer can fetch it. Null only if resolution failed. */
113
+ vrmUrl: string | null;
114
+ /** Absolute URL to a flat 2D portrait when available; null for VRM-only
115
+ * built-ins today (reserved for custom previews / server-rendered thumbs). */
116
+ imageUrl: string | null;
117
+ }
118
+ /** Available sizes for the Pouchy brand icon (px). */
119
+ export type BrandIconSize = 256 | 512 | 1024;
120
+ /** Canonical URL of the official Pouchy brand icon at `size` px, derived from a
121
+ * deployment origin. Standalone (no client/token needed) so a surface can show
122
+ * the Pouchy mark before/without connecting. */
123
+ export declare function pouchyBrandIconUrl(baseUrl: string, size?: BrandIconSize): string;
124
+ export declare class CompanionError extends Error {
125
+ status: number;
126
+ constructor(message: string, status: number);
127
+ }
128
+ type Handler = (envelope: CompanionEnvelope) => void;
129
+ export declare class CompanionClient {
130
+ private readonly opts;
131
+ private readonly doFetch;
132
+ private _session;
133
+ private cursor;
134
+ private streaming;
135
+ private abort;
136
+ private readonly handlers;
137
+ private readonly seen;
138
+ private readonly pendingVoiceTools;
139
+ constructor(opts: CompanionClientOptions);
140
+ /** The active session id, or null before connect(). */
141
+ get sessionId(): string | null;
142
+ private url;
143
+ private jsonHeaders;
144
+ private requireSession;
145
+ private postJson;
146
+ /** Handshake: start or resume the session for this surface. */
147
+ connect(): Promise<HelloAck>;
148
+ /** Pair this representative session's VISITOR with the owner so their two
149
+ * companions become friends — unlocking the A2A plane (messaging, gifts,
150
+ * visiting) between them. Only valid in a representative session (the client
151
+ * was created with a `visitor`).
152
+ *
153
+ * Two-token consent: this client's PAT (the owner's) must hold `represent:pair`,
154
+ * and you pass the VISITOR's own Pouchy PAT — which must hold `social.message`
155
+ * — as proof + consent. The visitor must therefore also be a Pouchy user. */
156
+ pairVisitor(visitorToken: string): Promise<{
157
+ pairId: string | null;
158
+ }>;
159
+ /** Send a user text turn, optionally with images (data URLs) for a multimodal
160
+ * turn — images need the `files` scope. The reply arrives on the event stream
161
+ * as a `companion.message` — subscribe via onMessage()/start() to receive it. */
162
+ sendText(text: string, opts?: {
163
+ images?: string[];
164
+ }): Promise<{
165
+ seq: number | null;
166
+ }>;
167
+ /** Push live world-state — a single event or a batch. Retained events
168
+ * (retained:true) coalesce per kind; transient ones carry a salience.
169
+ *
170
+ * Ergonomic input: `specversion` / `id` / `source` are filled automatically
171
+ * (id → a fresh handle, source → this surface), so callers only supply the
172
+ * meaningful fields: `{ type, data, retained?, salience?, voiceRelevant? }`.
173
+ * A full CloudEvents envelope is still accepted (its fields win). */
174
+ sendWorldState(event: WorldStateInput | WorldStateInput[]): Promise<{
175
+ accepted: number;
176
+ dropped: number;
177
+ }>;
178
+ private randomId;
179
+ /** Open the voice plane. Returns realtime credentials to connect DIRECTLY to
180
+ * the voice provider (the Pouchy server is out of the audio path). Provider
181
+ * precedence is server-side: ElevenLabs Convai (primary) → OpenAI Realtime
182
+ * (fallback). Requires the `call` scope.
183
+ *
184
+ * - `elevenlabs-convai`: open the EL session with the returned `token` and
185
+ * pass `instructions` as `overrides.agent.prompt.prompt` (the EL agent runs
186
+ * EL's LLM, so the persona/memory snapshot rides in client-side).
187
+ * - `openai-realtime`: open WebRTC with the short-lived `clientSecret`
188
+ * (instructions are baked into the session server-side).
189
+ */
190
+ startCall(opts?: {
191
+ voice?: string;
192
+ locale?: string;
193
+ }): Promise<CallCredentials>;
194
+ /** Open a live voice call end-to-end: mints credentials (startCall) and opens a
195
+ * WebRTC session directly to the provider — no first-party app code needed.
196
+ * Browser-only. Returns a handle to hang up + inject live context. EL Convai
197
+ * needs the optional '@elevenlabs/client' peer dependency; OpenAI Realtime has
198
+ * no extra deps. */
199
+ connectCall(opts?: CompanionCallOptions & {
200
+ voice?: string;
201
+ locale?: string;
202
+ }): Promise<CompanionCall>;
203
+ /** End the session: consolidate what happened into the companion's long-term
204
+ * memory, so it remembers this experience in future first-party conversations.
205
+ * Called automatically when a connectCall() handle is closed; call it directly
206
+ * when a non-voice session ends. Best-effort + idempotent (safe to call twice;
207
+ * uses keepalive so it survives a page unload). Pass the voice transcript when
208
+ * you have it (connectCall does this for you). */
209
+ endSession(opts?: {
210
+ transcript?: {
211
+ role: string;
212
+ text: string;
213
+ }[];
214
+ }): Promise<void>;
215
+ /** Report the result of a companion.tool_call this surface performed. When all
216
+ * of the turn's calls are reported, the companion resumes and the continuation
217
+ * (a companion.message or more tool calls) arrives on the event stream. */
218
+ sendToolResult(callId: string, result: {
219
+ ok?: boolean;
220
+ result?: unknown;
221
+ }): Promise<{
222
+ allDone: boolean;
223
+ }>;
224
+ /** A tool the live voice call asked the app to run. Fires the SAME
225
+ * companion.tool_call event the text flow uses (so the app's onToolCall +
226
+ * sendToolResult handle voice identically), and resolves with the result
227
+ * string fed back to the voice model. Times out so a missing result can't
228
+ * hang the call. */
229
+ private invokeVoiceTool;
230
+ /** Convenience: subscribe to tool-call requests from the companion. */
231
+ onToolCall(handler: (call: {
232
+ id: string;
233
+ name: string;
234
+ args: string;
235
+ }, envelope: CompanionEnvelope) => void): () => void;
236
+ /** Convenience: subscribe to errors the companion surfaces — server-side agent
237
+ * errors and stream failures (e.g. a permanent `stream_unauthorized`) both
238
+ * arrive as `control.error`. Returns an unsubscribe fn. */
239
+ onError(handler: (err: {
240
+ code: string;
241
+ message: string;
242
+ }, envelope: CompanionEnvelope) => void): () => void;
243
+ /** Recall the memory this token is authorized to see (ranked). */
244
+ recall(opts?: {
245
+ limit?: number;
246
+ }): Promise<RecalledMemory[]>;
247
+ /** Remember a fact (into this app's namespace unless `namespace` is given and
248
+ * the token has the core scope). Intimate-tier writes are rejected server-side. */
249
+ remember(fact: {
250
+ content: string;
251
+ importance?: number;
252
+ confidence?: number;
253
+ category?: string;
254
+ kind?: string;
255
+ namespace?: string;
256
+ sensitivity?: 'public' | 'personal';
257
+ }): Promise<{
258
+ cloudId: string;
259
+ namespace: string;
260
+ }>;
261
+ /** Ingest a DOCUMENT into the user's knowledge base. Unlike `remember` (one
262
+ * short fact), this distils already-extracted text — a PDF body, a meeting
263
+ * transcript, notes — into a headline summary PLUS full-text chunks, each
264
+ * embedded and semantically recallable, and surfaces it in the user's "My
265
+ * materials" list with source attribution, exactly like a first-party upload.
266
+ * So the materials entry point can live in YOUR app, not just Pouchy's.
267
+ *
268
+ * Extract the text yourself (your own parser, or Pouchy's /api/stt for audio)
269
+ * and pass it here. Writes the user's SHARED knowledge, so the token must
270
+ * hold `memory.write:core` (the user's consent) — otherwise 403. Embeddings
271
+ * are computed on the user's next first-party open (eventually-consistent
272
+ * semantic recall). `kind` is a free label for the source type (pdf / audio /
273
+ * note / …) used only for display + the materials icon. */
274
+ ingestKnowledge(doc: {
275
+ text: string;
276
+ name: string;
277
+ kind?: string;
278
+ locale?: string;
279
+ }): Promise<{
280
+ ok: boolean;
281
+ summary: string;
282
+ chunks: number;
283
+ }>;
284
+ /** Convenience over `ingestKnowledge`: hand over a RAW file and Pouchy
285
+ * understands it server-side before ingesting — no parser/transcriber/vision on
286
+ * your side. Pass a `data:<mime>;base64,…` URL. Supported today: **PDF**
287
+ * (server-side text extraction), **audio/video** (Whisper transcript), and
288
+ * **image** (server-side vision caption — an objective description becomes the
289
+ * material). For other types, extract the text yourself and call
290
+ * `ingestKnowledge`. Same `memory.write:core` requirement and "My materials"
291
+ * integration as `ingestKnowledge`. */
292
+ ingestFile(file: {
293
+ dataUrl: string;
294
+ name: string;
295
+ kind?: string;
296
+ locale?: string;
297
+ }): Promise<{
298
+ ok: boolean;
299
+ summary: string;
300
+ chunks: number;
301
+ }>;
302
+ /** Fetch the user's CURRENT companion avatar so the embedding can render the
303
+ * same virtual human Pouchy shows. The avatar is a VRM 3D model (`vrmUrl`,
304
+ * load it with a VRM/glTF renderer e.g. three-vrm); `imageUrl` is a flat 2D
305
+ * portrait when one exists (null today for built-in models). URLs are absolute
306
+ * and the /models asset is CORS-enabled, so a cross-origin renderer can fetch
307
+ * the VRM directly. Reflects the user's live choice (built-in or custom). */
308
+ getAvatar(): Promise<CompanionAvatar>;
309
+ /** Canonical URL of the official Pouchy brand icon (square PNG, transparent
310
+ * background) at the given size. Static + public — needs no token — so it's
311
+ * safe to drop straight into an `<img src>` for "powered by Pouchy" badges,
312
+ * the companion's app tile, etc. Sizes: 256 | 512 | 1024 (default 512). */
313
+ brandIconUrl(size?: BrandIconSize): string;
314
+ /** Subscribe to outbound events of a given type, or "*" for all. Returns an
315
+ * unsubscribe function. */
316
+ on(type: OutboundType | '*', handler: Handler): () => void;
317
+ /** Convenience: subscribe to assistant text replies. */
318
+ onMessage(handler: (text: string, envelope: CompanionEnvelope) => void): () => void;
319
+ /** Convenience: subscribe to Instant UI render surfaces (companion.ui_action).
320
+ * The host draws `payload.interface` with its OWN renderer — a web component, a
321
+ * native iOS/Android view tree, a CLI/TUI — all consuming the same
322
+ * platform-neutral genui schema. If the panel set `reportChanges`, feed the
323
+ * user's edits back via `sendText`/`sendWorldState` to continue the turn. */
324
+ onRender(handler: (payload: RenderInterfacePayload, envelope: CompanionEnvelope) => void): () => void;
325
+ /** Convenience: subscribe to TTS audio clips for the reply (companion.audio,
326
+ * non-call modality) — play `url`, or resolve `ref` your way. */
327
+ onAudio(handler: (payload: AudioClipPayload, envelope: CompanionEnvelope) => void): () => void;
328
+ /** Convenience: subscribe to avatar cues (companion.expression) — viseme /
329
+ * expression / gesture for an embed rendering the companion's body. */
330
+ onExpression(handler: (payload: ExpressionPayload, envelope: CompanionEnvelope) => void): () => void;
331
+ /** Convenience: subscribe to the per-token metering echo (control.usage) for a
332
+ * usage / billing view. */
333
+ onUsage(handler: (payload: UsagePayload, envelope: CompanionEnvelope) => void): () => void;
334
+ /** Convenience: OBSERVE sensitive-op confirmation requests
335
+ * (companion.confirm_request) — a payment, skill run, or friend message the
336
+ * companion wants the user to approve. Use it to surface the pending approval
337
+ * in your UI (e.g. "open Pouchy to approve"). NOTE: an embed cannot approve —
338
+ * approval is authed as the first-party Pouchy user (their app / a hosted
339
+ * confirm page), which is also where a biometric (Face ID / passkey) gate
340
+ * lives. This handler is observe-only. */
341
+ onConfirmRequest(handler: (payload: ConfirmRequestPayload, envelope: CompanionEnvelope) => void): () => void;
342
+ /** Convenience: subscribe to live updates of an already-rendered Instant UI
343
+ * panel (companion.ui_update). Apply each `{ key, value }` to the panel's state
344
+ * bag and re-evaluate bound displays — no rebuild. */
345
+ onInterfaceUpdate(handler: (payload: InterfaceUpdatePayload, envelope: CompanionEnvelope) => void): () => void;
346
+ /** Convenience: subscribe to inbound A2A messages from the user's paired
347
+ * friends (companion.social_message), delivered cross-app to any embed whose
348
+ * token holds `social.message`. Surface them in your own UI / notify the user. */
349
+ onSocialMessage(handler: (payload: SocialMessagePayload, envelope: CompanionEnvelope) => void): () => void;
350
+ /** Open the event stream (auto-reconnecting with the resume cursor). Uses the
351
+ * WebSocket plane when opted in + available, else SSE. */
352
+ start(): void;
353
+ /** WebSocket receive loop. Resolves when the socket closes; on any failure
354
+ * while still streaming it falls back to the SSE loop, so opting into WS can
355
+ * only ever DEGRADE to SSE, never drop replies. Input is still sent over
356
+ * REST (sendInput) — receiving is the latency-sensitive half. */
357
+ private wsLoop;
358
+ /** Stop the event stream. */
359
+ stop(): void;
360
+ private emit;
361
+ private handleFrame;
362
+ private loop;
363
+ private consume;
364
+ private sleep;
365
+ }
366
+ export {};