@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.
- package/CHANGELOG.md +86 -0
- package/LICENSE +41 -0
- package/README.md +136 -0
- package/dist/call.d.ts +59 -0
- package/dist/call.js +478 -0
- package/dist/client.d.ts +366 -0
- package/dist/client.js +716 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +17 -0
- package/dist/protocol.d.ts +124 -0
- package/dist/protocol.js +35 -0
- package/dist/sse.d.ts +12 -0
- package/dist/sse.js +38 -0
- package/dist/ws-transport.d.ts +21 -0
- package/dist/ws-transport.js +62 -0
- package/package.json +47 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,716 @@
|
|
|
1
|
+
// Companion SDK — the client a third-party surface (web / app / game / hardware)
|
|
2
|
+
// uses to embed the Pouchy companion in a few lines. Wraps the REST/SSE plane:
|
|
3
|
+
// const c = createCompanion({ baseUrl, token });
|
|
4
|
+
// await c.connect();
|
|
5
|
+
// c.onMessage((text) => render(text));
|
|
6
|
+
// c.start();
|
|
7
|
+
// c.sendWorldState({ type: 'game.player.hp', data: { hp: 12 }, retained: true });
|
|
8
|
+
// await c.sendText('how am I doing?');
|
|
9
|
+
//
|
|
10
|
+
// Pure, dependency-free, isomorphic (browser + Node 18+ fetch). The transport is
|
|
11
|
+
// an internal detail — when the WebSocket plane lands it slots in behind this
|
|
12
|
+
// same API. Protocol types are shared with the server via companion-protocol.
|
|
13
|
+
import { PROTOCOL_VERSION } from './protocol.js';
|
|
14
|
+
import { parseSse } from './sse.js';
|
|
15
|
+
import { openCompanionCall, HOST_CONTROL_TOOLS, HOST_CONTROL_TOOL_NAMES, AVATAR_VISUAL_TOOLS, AVATAR_VISUAL_TOOL_NAMES } from './call.js';
|
|
16
|
+
/** Canonical URL of the official Pouchy brand icon at `size` px, derived from a
|
|
17
|
+
* deployment origin. Standalone (no client/token needed) so a surface can show
|
|
18
|
+
* the Pouchy mark before/without connecting. */
|
|
19
|
+
export function pouchyBrandIconUrl(baseUrl, size = 512) {
|
|
20
|
+
return baseUrl.replace(/\/+$/, '') + `/brand-assets/icon/pouchy-icon-${size}.png`;
|
|
21
|
+
}
|
|
22
|
+
export class CompanionError extends Error {
|
|
23
|
+
status;
|
|
24
|
+
constructor(message, status) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = 'CompanionError';
|
|
27
|
+
this.status = status;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const SEEN_CAP = 512;
|
|
31
|
+
export class CompanionClient {
|
|
32
|
+
opts;
|
|
33
|
+
doFetch;
|
|
34
|
+
_session = null;
|
|
35
|
+
cursor = 0;
|
|
36
|
+
streaming = false;
|
|
37
|
+
abort = null;
|
|
38
|
+
handlers = new Map();
|
|
39
|
+
seen = new Set();
|
|
40
|
+
// Voice tool-calls awaiting the app's sendToolResult, keyed by a synthetic id.
|
|
41
|
+
pendingVoiceTools = new Map();
|
|
42
|
+
constructor(opts) {
|
|
43
|
+
if (!opts.baseUrl)
|
|
44
|
+
throw new Error('CompanionClient: baseUrl is required');
|
|
45
|
+
if (!opts.token)
|
|
46
|
+
throw new Error('CompanionClient: token is required');
|
|
47
|
+
this.opts = opts;
|
|
48
|
+
this.doFetch = opts.fetch ?? globalThis.fetch.bind(globalThis);
|
|
49
|
+
}
|
|
50
|
+
/** The active session id, or null before connect(). */
|
|
51
|
+
get sessionId() {
|
|
52
|
+
return this._session;
|
|
53
|
+
}
|
|
54
|
+
url(path) {
|
|
55
|
+
return this.opts.baseUrl.replace(/\/+$/, '') + path;
|
|
56
|
+
}
|
|
57
|
+
jsonHeaders() {
|
|
58
|
+
return { Authorization: `Bearer ${this.opts.token}`, 'Content-Type': 'application/json' };
|
|
59
|
+
}
|
|
60
|
+
requireSession() {
|
|
61
|
+
if (!this._session)
|
|
62
|
+
throw new Error('CompanionClient: call connect() first');
|
|
63
|
+
return this._session;
|
|
64
|
+
}
|
|
65
|
+
async postJson(path, body) {
|
|
66
|
+
const res = await this.doFetch(this.url(path), {
|
|
67
|
+
method: 'POST',
|
|
68
|
+
headers: this.jsonHeaders(),
|
|
69
|
+
body: JSON.stringify(body)
|
|
70
|
+
});
|
|
71
|
+
const json = (await res.json().catch(() => null));
|
|
72
|
+
if (!res.ok || !json || json.ok === false) {
|
|
73
|
+
throw new CompanionError(json?.error || `request failed (${res.status})`, res.status);
|
|
74
|
+
}
|
|
75
|
+
return json;
|
|
76
|
+
}
|
|
77
|
+
/** Handshake: start or resume the session for this surface. */
|
|
78
|
+
async connect() {
|
|
79
|
+
const json = await this.postJson('/api/companion/session', {
|
|
80
|
+
surface: this.opts.surface ?? 'default',
|
|
81
|
+
modalities: this.opts.modalities,
|
|
82
|
+
handles: this.opts.handles,
|
|
83
|
+
contextKinds: this.opts.contextKinds,
|
|
84
|
+
tools: this.opts.tools,
|
|
85
|
+
appContext: this.opts.appContext,
|
|
86
|
+
...(this.opts.visitor ? { visitor: this.opts.visitor } : {})
|
|
87
|
+
});
|
|
88
|
+
this._session = json.session;
|
|
89
|
+
this.cursor = json.resumeCursor ?? 0;
|
|
90
|
+
return {
|
|
91
|
+
...json,
|
|
92
|
+
representative: json.representative ?? false,
|
|
93
|
+
visitorPaired: json.visitorPaired ?? false
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/** Pair this representative session's VISITOR with the owner so their two
|
|
97
|
+
* companions become friends — unlocking the A2A plane (messaging, gifts,
|
|
98
|
+
* visiting) between them. Only valid in a representative session (the client
|
|
99
|
+
* was created with a `visitor`).
|
|
100
|
+
*
|
|
101
|
+
* Two-token consent: this client's PAT (the owner's) must hold `represent:pair`,
|
|
102
|
+
* and you pass the VISITOR's own Pouchy PAT — which must hold `social.message`
|
|
103
|
+
* — as proof + consent. The visitor must therefore also be a Pouchy user. */
|
|
104
|
+
async pairVisitor(visitorToken) {
|
|
105
|
+
if (!this.opts.visitor) {
|
|
106
|
+
throw new Error('pairVisitor() requires a representative session (createCompanion({ visitor }))');
|
|
107
|
+
}
|
|
108
|
+
if (!visitorToken)
|
|
109
|
+
throw new Error('pairVisitor: visitorToken is required');
|
|
110
|
+
const json = await this.postJson('/api/companion/pair', {
|
|
111
|
+
visitorToken,
|
|
112
|
+
visitorId: this.opts.visitor.id
|
|
113
|
+
});
|
|
114
|
+
return { pairId: json.pairId ?? null };
|
|
115
|
+
}
|
|
116
|
+
/** Send a user text turn, optionally with images (data URLs) for a multimodal
|
|
117
|
+
* turn — images need the `files` scope. The reply arrives on the event stream
|
|
118
|
+
* as a `companion.message` — subscribe via onMessage()/start() to receive it. */
|
|
119
|
+
async sendText(text, opts) {
|
|
120
|
+
const id = this.requireSession();
|
|
121
|
+
const json = await this.postJson(`/api/companion/session/${id}/input`, { text, ...(opts?.images?.length ? { images: opts.images } : {}) });
|
|
122
|
+
return { seq: json.seq ?? null };
|
|
123
|
+
}
|
|
124
|
+
/** Push live world-state — a single event or a batch. Retained events
|
|
125
|
+
* (retained:true) coalesce per kind; transient ones carry a salience.
|
|
126
|
+
*
|
|
127
|
+
* Ergonomic input: `specversion` / `id` / `source` are filled automatically
|
|
128
|
+
* (id → a fresh handle, source → this surface), so callers only supply the
|
|
129
|
+
* meaningful fields: `{ type, data, retained?, salience?, voiceRelevant? }`.
|
|
130
|
+
* A full CloudEvents envelope is still accepted (its fields win). */
|
|
131
|
+
async sendWorldState(event) {
|
|
132
|
+
const id = this.requireSession();
|
|
133
|
+
const fill = (e) => ({
|
|
134
|
+
...e,
|
|
135
|
+
specversion: '1.0',
|
|
136
|
+
id: e.id ?? this.randomId(),
|
|
137
|
+
source: e.source ?? this.opts.surface ?? 'companion-sdk'
|
|
138
|
+
});
|
|
139
|
+
const body = Array.isArray(event) ? { events: event.map(fill) } : fill(event);
|
|
140
|
+
const json = await this.postJson(`/api/companion/session/${id}/context`, body);
|
|
141
|
+
return { accepted: json.accepted ?? 0, dropped: json.dropped ?? 0 };
|
|
142
|
+
}
|
|
143
|
+
randomId() {
|
|
144
|
+
const c = globalThis.crypto;
|
|
145
|
+
return c?.randomUUID ? c.randomUUID() : `ws_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
|
146
|
+
}
|
|
147
|
+
/** Open the voice plane. Returns realtime credentials to connect DIRECTLY to
|
|
148
|
+
* the voice provider (the Pouchy server is out of the audio path). Provider
|
|
149
|
+
* precedence is server-side: ElevenLabs Convai (primary) → OpenAI Realtime
|
|
150
|
+
* (fallback). Requires the `call` scope.
|
|
151
|
+
*
|
|
152
|
+
* - `elevenlabs-convai`: open the EL session with the returned `token` and
|
|
153
|
+
* pass `instructions` as `overrides.agent.prompt.prompt` (the EL agent runs
|
|
154
|
+
* EL's LLM, so the persona/memory snapshot rides in client-side).
|
|
155
|
+
* - `openai-realtime`: open WebRTC with the short-lived `clientSecret`
|
|
156
|
+
* (instructions are baked into the session server-side).
|
|
157
|
+
*/
|
|
158
|
+
async startCall(opts) {
|
|
159
|
+
const id = this.requireSession();
|
|
160
|
+
return this.postJson(`/api/companion/session/${id}/call`, {
|
|
161
|
+
voice: opts?.voice,
|
|
162
|
+
locale: opts?.locale
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
/** Open a live voice call end-to-end: mints credentials (startCall) and opens a
|
|
166
|
+
* WebRTC session directly to the provider — no first-party app code needed.
|
|
167
|
+
* Browser-only. Returns a handle to hang up + inject live context. EL Convai
|
|
168
|
+
* needs the optional '@elevenlabs/client' peer dependency; OpenAI Realtime has
|
|
169
|
+
* no extra deps. */
|
|
170
|
+
async connectCall(opts) {
|
|
171
|
+
// Buffer the voice transcript so we can hand it to the server at call end —
|
|
172
|
+
// the audio is provider-direct, so this is the ONLY way a voice play-along
|
|
173
|
+
// reaches the companion's long-term memory. We still forward every line to
|
|
174
|
+
// the caller's own onTranscript.
|
|
175
|
+
const transcript = [];
|
|
176
|
+
const callerOnTranscript = opts?.onTranscript;
|
|
177
|
+
const wrappedOpts = {
|
|
178
|
+
...opts,
|
|
179
|
+
onTranscript: (e) => {
|
|
180
|
+
if (e?.text?.trim())
|
|
181
|
+
transcript.push({ role: e.role, text: e.text });
|
|
182
|
+
callerOnTranscript?.(e);
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
// Let the live voice call invoke tools — routed through the SAME
|
|
186
|
+
// onToolCall/sendToolResult the text path uses (one handler for voice +
|
|
187
|
+
// text). We offer the app's DECLARED tools PLUS the universal host-control
|
|
188
|
+
// verbs (pause/toggle/navigate/…) so the companion can act on any surface.
|
|
189
|
+
// Only when the app signalled it wants to act (declared tools or handles) —
|
|
190
|
+
// otherwise a pure-chat embed wouldn't get tools it can't service.
|
|
191
|
+
const declared = this.opts.tools ?? [];
|
|
192
|
+
const wantsTools = declared.length > 0 || (this.opts.handles?.length ?? 0) > 0;
|
|
193
|
+
const hostVerbs = wantsTools
|
|
194
|
+
? HOST_CONTROL_TOOLS.filter((v) => !declared.some((d) => d.name === v.name))
|
|
195
|
+
: [];
|
|
196
|
+
// Avatar emote tools ride along ALWAYS — even a pure-chat / audio embed that
|
|
197
|
+
// declared no tools — so the embodied companion can express itself without
|
|
198
|
+
// each third party declaring play_gesture / play_expression, and so the EL
|
|
199
|
+
// Convai path (whose shared agent already knows these tools) has a handler to
|
|
200
|
+
// call. The SDK no-ops them unless the app opted in by declaring the same
|
|
201
|
+
// name (see invokeVoiceTool); declared ones use the app's own schema.
|
|
202
|
+
const avatarTools = AVATAR_VISUAL_TOOLS.filter((v) => !declared.some((d) => d.name === v.name));
|
|
203
|
+
const voiceTools = [...declared, ...hostVerbs, ...avatarTools];
|
|
204
|
+
const bridge = voiceTools.length
|
|
205
|
+
? { tools: voiceTools, invoke: (name, argsJson) => this.invokeVoiceTool(name, argsJson) }
|
|
206
|
+
: undefined;
|
|
207
|
+
const creds = await this.startCall({ voice: opts?.voice, locale: opts?.locale });
|
|
208
|
+
const call = await openCompanionCall(creds, wrappedOpts, bridge);
|
|
209
|
+
// Bridge server-pushed voiceRelevant moments into the live call (item 2):
|
|
210
|
+
// the server emits companion.voice_inject while the call is active; inject
|
|
211
|
+
// each into the session so the companion reacts out loud. Needs start().
|
|
212
|
+
const unsub = this.on('companion.voice_inject', (env) => {
|
|
213
|
+
const p = env.payload;
|
|
214
|
+
if (typeof p?.text === 'string' && p.text)
|
|
215
|
+
call.injectEvent(p.text, p.speak !== false);
|
|
216
|
+
});
|
|
217
|
+
const close = call.close;
|
|
218
|
+
return {
|
|
219
|
+
...call,
|
|
220
|
+
close: () => {
|
|
221
|
+
unsub();
|
|
222
|
+
close();
|
|
223
|
+
// Consolidate this call into durable memory (best-effort, keepalive).
|
|
224
|
+
void this.endSession({ transcript });
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
/** End the session: consolidate what happened into the companion's long-term
|
|
229
|
+
* memory, so it remembers this experience in future first-party conversations.
|
|
230
|
+
* Called automatically when a connectCall() handle is closed; call it directly
|
|
231
|
+
* when a non-voice session ends. Best-effort + idempotent (safe to call twice;
|
|
232
|
+
* uses keepalive so it survives a page unload). Pass the voice transcript when
|
|
233
|
+
* you have it (connectCall does this for you). */
|
|
234
|
+
async endSession(opts) {
|
|
235
|
+
const id = this._session;
|
|
236
|
+
if (!id)
|
|
237
|
+
return;
|
|
238
|
+
// Cap the payload (fetch keepalive bodies are size-limited): last 60 lines.
|
|
239
|
+
const transcript = (opts?.transcript ?? [])
|
|
240
|
+
.filter((t) => t && typeof t.text === 'string' && t.text.trim())
|
|
241
|
+
.slice(-60)
|
|
242
|
+
.map((t) => ({ role: t.role, text: t.text.slice(0, 500) }));
|
|
243
|
+
try {
|
|
244
|
+
await this.doFetch(this.url(`/api/companion/session/${id}/end`), {
|
|
245
|
+
method: 'POST',
|
|
246
|
+
headers: this.jsonHeaders(),
|
|
247
|
+
body: JSON.stringify({ transcript }),
|
|
248
|
+
keepalive: true
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
/* best-effort — memory consolidation is not on the user's critical path */
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
/** Report the result of a companion.tool_call this surface performed. When all
|
|
256
|
+
* of the turn's calls are reported, the companion resumes and the continuation
|
|
257
|
+
* (a companion.message or more tool calls) arrives on the event stream. */
|
|
258
|
+
async sendToolResult(callId, result) {
|
|
259
|
+
// Voice tool-call: resolve it locally so the result flows back to the voice
|
|
260
|
+
// provider, not to a server text turn. (Same call shape as the text path.)
|
|
261
|
+
const pending = this.pendingVoiceTools.get(callId);
|
|
262
|
+
if (pending) {
|
|
263
|
+
pending(JSON.stringify({ ok: result.ok !== false, result: result.result ?? null }));
|
|
264
|
+
return { allDone: true };
|
|
265
|
+
}
|
|
266
|
+
const id = this.requireSession();
|
|
267
|
+
const json = await this.postJson(`/api/companion/session/${id}/tool-result`, { callId, ok: result.ok !== false, result: result.result });
|
|
268
|
+
return { allDone: json.allDone ?? false };
|
|
269
|
+
}
|
|
270
|
+
/** A tool the live voice call asked the app to run. Fires the SAME
|
|
271
|
+
* companion.tool_call event the text flow uses (so the app's onToolCall +
|
|
272
|
+
* sendToolResult handle voice identically), and resolves with the result
|
|
273
|
+
* string fed back to the voice model. Times out so a missing result can't
|
|
274
|
+
* hang the call. */
|
|
275
|
+
invokeVoiceTool(name, argsJson) {
|
|
276
|
+
const isHostVerb = HOST_CONTROL_TOOL_NAMES.includes(name);
|
|
277
|
+
const isAvatarTool = AVATAR_VISUAL_TOOL_NAMES.includes(name);
|
|
278
|
+
const isDeclared = this.opts.tools?.some((t) => t.name === name) ?? false;
|
|
279
|
+
if (!isHostVerb && !isAvatarTool && !isDeclared) {
|
|
280
|
+
return Promise.resolve(JSON.stringify({ ok: false, error: `unknown tool: ${name}` }));
|
|
281
|
+
}
|
|
282
|
+
// Avatar emote the app didn't opt into (didn't declare) → silent no-op
|
|
283
|
+
// success: a pure audio / 2D embed never renders a body, so the gesture /
|
|
284
|
+
// expression call resolves OK (the model's turn continues) but nothing
|
|
285
|
+
// happens. Declared avatar tools fall through to the app's handler below.
|
|
286
|
+
if (isAvatarTool && !isDeclared) {
|
|
287
|
+
return Promise.resolve(JSON.stringify({ ok: true }));
|
|
288
|
+
}
|
|
289
|
+
// Don't make the voice model wait ~20s if nothing can service the call.
|
|
290
|
+
if (!this.handlers.get('companion.tool_call')?.size) {
|
|
291
|
+
return Promise.resolve(JSON.stringify({ ok: false, error: 'no tool handler (register companion.onToolCall)' }));
|
|
292
|
+
}
|
|
293
|
+
// invoke_action carries the REAL action name in its params — unwrap it so it
|
|
294
|
+
// reaches onToolCall exactly like a declared tool. The other host verbs
|
|
295
|
+
// (set_feature/set_value/navigate/highlight) pass through by name.
|
|
296
|
+
let dispatchName = name;
|
|
297
|
+
let dispatchArgs = argsJson;
|
|
298
|
+
if (name === 'invoke_action') {
|
|
299
|
+
try {
|
|
300
|
+
const a = JSON.parse(argsJson || '{}');
|
|
301
|
+
if (typeof a.action !== 'string' || !a.action) {
|
|
302
|
+
return Promise.resolve(JSON.stringify({ ok: false, error: 'invoke_action needs "action"' }));
|
|
303
|
+
}
|
|
304
|
+
dispatchName = a.action;
|
|
305
|
+
dispatchArgs = JSON.stringify(a.params ?? {});
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
return Promise.resolve(JSON.stringify({ ok: false, error: 'invoke_action args not JSON' }));
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
else if (name === 'set_feature') {
|
|
312
|
+
// The ElevenLabs path can only send string params, so `on` arrives as
|
|
313
|
+
// "true"/"false". Normalize to a real boolean so the app's handler is
|
|
314
|
+
// provider-agnostic (OpenAI already sends a boolean).
|
|
315
|
+
try {
|
|
316
|
+
const a = JSON.parse(argsJson || '{}');
|
|
317
|
+
const on = a.on === true || a.on === 'true';
|
|
318
|
+
dispatchArgs = JSON.stringify({ feature: a.feature, on });
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
/* leave args as-is */
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
const id = `vc_${this.randomId()}`;
|
|
325
|
+
return new Promise((resolve) => {
|
|
326
|
+
let settled = false;
|
|
327
|
+
const finish = (out) => {
|
|
328
|
+
if (settled)
|
|
329
|
+
return;
|
|
330
|
+
settled = true;
|
|
331
|
+
this.pendingVoiceTools.delete(id);
|
|
332
|
+
resolve(out);
|
|
333
|
+
};
|
|
334
|
+
this.pendingVoiceTools.set(id, finish);
|
|
335
|
+
setTimeout(() => finish(JSON.stringify({ ok: false, error: 'tool result timed out' })), 20_000);
|
|
336
|
+
this.emit({
|
|
337
|
+
v: PROTOCOL_VERSION,
|
|
338
|
+
id: `evt_${this.randomId()}`,
|
|
339
|
+
ts: Date.now(),
|
|
340
|
+
type: 'companion.tool_call',
|
|
341
|
+
payload: { id, name: dispatchName, args: dispatchArgs }
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
/** Convenience: subscribe to tool-call requests from the companion. */
|
|
346
|
+
onToolCall(handler) {
|
|
347
|
+
return this.on('companion.tool_call', (env) => {
|
|
348
|
+
const p = env.payload;
|
|
349
|
+
if (typeof p?.id === 'string' && typeof p?.name === 'string') {
|
|
350
|
+
handler({ id: p.id, name: p.name, args: typeof p.args === 'string' ? p.args : '' }, env);
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
/** Convenience: subscribe to errors the companion surfaces — server-side agent
|
|
355
|
+
* errors and stream failures (e.g. a permanent `stream_unauthorized`) both
|
|
356
|
+
* arrive as `control.error`. Returns an unsubscribe fn. */
|
|
357
|
+
onError(handler) {
|
|
358
|
+
return this.on('control.error', (env) => {
|
|
359
|
+
const p = env.payload;
|
|
360
|
+
handler({
|
|
361
|
+
code: typeof p?.code === 'string' ? p.code : 'error',
|
|
362
|
+
message: typeof p?.message === 'string' ? p.message : 'unknown error'
|
|
363
|
+
}, env);
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
/** Recall the memory this token is authorized to see (ranked). */
|
|
367
|
+
async recall(opts) {
|
|
368
|
+
const qs = opts?.limit ? `?limit=${encodeURIComponent(opts.limit)}` : '';
|
|
369
|
+
const res = await this.doFetch(this.url(`/api/companion/memory${qs}`), {
|
|
370
|
+
headers: { Authorization: `Bearer ${this.opts.token}` }
|
|
371
|
+
});
|
|
372
|
+
const json = (await res.json().catch(() => null));
|
|
373
|
+
if (!res.ok || !json || json.ok === false) {
|
|
374
|
+
throw new CompanionError(json?.error || `recall failed (${res.status})`, res.status);
|
|
375
|
+
}
|
|
376
|
+
return json.memories ?? [];
|
|
377
|
+
}
|
|
378
|
+
/** Remember a fact (into this app's namespace unless `namespace` is given and
|
|
379
|
+
* the token has the core scope). Intimate-tier writes are rejected server-side. */
|
|
380
|
+
async remember(fact) {
|
|
381
|
+
return this.postJson('/api/companion/memory', fact);
|
|
382
|
+
}
|
|
383
|
+
/** Ingest a DOCUMENT into the user's knowledge base. Unlike `remember` (one
|
|
384
|
+
* short fact), this distils already-extracted text — a PDF body, a meeting
|
|
385
|
+
* transcript, notes — into a headline summary PLUS full-text chunks, each
|
|
386
|
+
* embedded and semantically recallable, and surfaces it in the user's "My
|
|
387
|
+
* materials" list with source attribution, exactly like a first-party upload.
|
|
388
|
+
* So the materials entry point can live in YOUR app, not just Pouchy's.
|
|
389
|
+
*
|
|
390
|
+
* Extract the text yourself (your own parser, or Pouchy's /api/stt for audio)
|
|
391
|
+
* and pass it here. Writes the user's SHARED knowledge, so the token must
|
|
392
|
+
* hold `memory.write:core` (the user's consent) — otherwise 403. Embeddings
|
|
393
|
+
* are computed on the user's next first-party open (eventually-consistent
|
|
394
|
+
* semantic recall). `kind` is a free label for the source type (pdf / audio /
|
|
395
|
+
* note / …) used only for display + the materials icon. */
|
|
396
|
+
async ingestKnowledge(doc) {
|
|
397
|
+
return this.postJson('/api/companion/knowledge', doc);
|
|
398
|
+
}
|
|
399
|
+
/** Convenience over `ingestKnowledge`: hand over a RAW file and Pouchy
|
|
400
|
+
* understands it server-side before ingesting — no parser/transcriber/vision on
|
|
401
|
+
* your side. Pass a `data:<mime>;base64,…` URL. Supported today: **PDF**
|
|
402
|
+
* (server-side text extraction), **audio/video** (Whisper transcript), and
|
|
403
|
+
* **image** (server-side vision caption — an objective description becomes the
|
|
404
|
+
* material). For other types, extract the text yourself and call
|
|
405
|
+
* `ingestKnowledge`. Same `memory.write:core` requirement and "My materials"
|
|
406
|
+
* integration as `ingestKnowledge`. */
|
|
407
|
+
async ingestFile(file) {
|
|
408
|
+
return this.postJson('/api/companion/knowledge/file', file);
|
|
409
|
+
}
|
|
410
|
+
/** Fetch the user's CURRENT companion avatar so the embedding can render the
|
|
411
|
+
* same virtual human Pouchy shows. The avatar is a VRM 3D model (`vrmUrl`,
|
|
412
|
+
* load it with a VRM/glTF renderer e.g. three-vrm); `imageUrl` is a flat 2D
|
|
413
|
+
* portrait when one exists (null today for built-in models). URLs are absolute
|
|
414
|
+
* and the /models asset is CORS-enabled, so a cross-origin renderer can fetch
|
|
415
|
+
* the VRM directly. Reflects the user's live choice (built-in or custom). */
|
|
416
|
+
async getAvatar() {
|
|
417
|
+
const res = await this.doFetch(this.url('/api/companion/avatar'), {
|
|
418
|
+
headers: { Authorization: `Bearer ${this.opts.token}` }
|
|
419
|
+
});
|
|
420
|
+
const json = (await res.json().catch(() => null));
|
|
421
|
+
if (!res.ok || !json || json.ok === false) {
|
|
422
|
+
throw new CompanionError(json?.error || `avatar fetch failed (${res.status})`, res.status);
|
|
423
|
+
}
|
|
424
|
+
return {
|
|
425
|
+
name: json.name ?? null,
|
|
426
|
+
archetype: json.archetype ?? 'girlfriend',
|
|
427
|
+
modelId: json.modelId ?? null,
|
|
428
|
+
vrmUrl: json.vrmUrl ?? null,
|
|
429
|
+
imageUrl: json.imageUrl ?? null
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
/** Canonical URL of the official Pouchy brand icon (square PNG, transparent
|
|
433
|
+
* background) at the given size. Static + public — needs no token — so it's
|
|
434
|
+
* safe to drop straight into an `<img src>` for "powered by Pouchy" badges,
|
|
435
|
+
* the companion's app tile, etc. Sizes: 256 | 512 | 1024 (default 512). */
|
|
436
|
+
brandIconUrl(size = 512) {
|
|
437
|
+
return pouchyBrandIconUrl(this.opts.baseUrl, size);
|
|
438
|
+
}
|
|
439
|
+
/** Subscribe to outbound events of a given type, or "*" for all. Returns an
|
|
440
|
+
* unsubscribe function. */
|
|
441
|
+
on(type, handler) {
|
|
442
|
+
let set = this.handlers.get(type);
|
|
443
|
+
if (!set) {
|
|
444
|
+
set = new Set();
|
|
445
|
+
this.handlers.set(type, set);
|
|
446
|
+
}
|
|
447
|
+
set.add(handler);
|
|
448
|
+
return () => set?.delete(handler);
|
|
449
|
+
}
|
|
450
|
+
/** Convenience: subscribe to assistant text replies. */
|
|
451
|
+
onMessage(handler) {
|
|
452
|
+
return this.on('companion.message', (env) => {
|
|
453
|
+
const text = env.payload?.text;
|
|
454
|
+
if (typeof text === 'string')
|
|
455
|
+
handler(text, env);
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
/** Convenience: subscribe to Instant UI render surfaces (companion.ui_action).
|
|
459
|
+
* The host draws `payload.interface` with its OWN renderer — a web component, a
|
|
460
|
+
* native iOS/Android view tree, a CLI/TUI — all consuming the same
|
|
461
|
+
* platform-neutral genui schema. If the panel set `reportChanges`, feed the
|
|
462
|
+
* user's edits back via `sendText`/`sendWorldState` to continue the turn. */
|
|
463
|
+
onRender(handler) {
|
|
464
|
+
return this.on('companion.ui_action', (env) => {
|
|
465
|
+
const p = env.payload;
|
|
466
|
+
if (p && typeof p === 'object' && Array.isArray(p.interface?.nodes))
|
|
467
|
+
handler(p, env);
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
/** Convenience: subscribe to TTS audio clips for the reply (companion.audio,
|
|
471
|
+
* non-call modality) — play `url`, or resolve `ref` your way. */
|
|
472
|
+
onAudio(handler) {
|
|
473
|
+
return this.on('companion.audio', (env) => {
|
|
474
|
+
const p = env.payload;
|
|
475
|
+
if (p && typeof p === 'object' && (typeof p.url === 'string' || typeof p.ref === 'string')) {
|
|
476
|
+
handler(p, env);
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
/** Convenience: subscribe to avatar cues (companion.expression) — viseme /
|
|
481
|
+
* expression / gesture for an embed rendering the companion's body. */
|
|
482
|
+
onExpression(handler) {
|
|
483
|
+
return this.on('companion.expression', (env) => handler(env.payload, env));
|
|
484
|
+
}
|
|
485
|
+
/** Convenience: subscribe to the per-token metering echo (control.usage) for a
|
|
486
|
+
* usage / billing view. */
|
|
487
|
+
onUsage(handler) {
|
|
488
|
+
return this.on('control.usage', (env) => handler(env.payload, env));
|
|
489
|
+
}
|
|
490
|
+
/** Convenience: OBSERVE sensitive-op confirmation requests
|
|
491
|
+
* (companion.confirm_request) — a payment, skill run, or friend message the
|
|
492
|
+
* companion wants the user to approve. Use it to surface the pending approval
|
|
493
|
+
* in your UI (e.g. "open Pouchy to approve"). NOTE: an embed cannot approve —
|
|
494
|
+
* approval is authed as the first-party Pouchy user (their app / a hosted
|
|
495
|
+
* confirm page), which is also where a biometric (Face ID / passkey) gate
|
|
496
|
+
* lives. This handler is observe-only. */
|
|
497
|
+
onConfirmRequest(handler) {
|
|
498
|
+
return this.on('companion.confirm_request', (env) => {
|
|
499
|
+
const p = env.payload;
|
|
500
|
+
if (p && typeof p === 'object' && typeof p.confirmId === 'string')
|
|
501
|
+
handler(p, env);
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
/** Convenience: subscribe to live updates of an already-rendered Instant UI
|
|
505
|
+
* panel (companion.ui_update). Apply each `{ key, value }` to the panel's state
|
|
506
|
+
* bag and re-evaluate bound displays — no rebuild. */
|
|
507
|
+
onInterfaceUpdate(handler) {
|
|
508
|
+
return this.on('companion.ui_update', (env) => {
|
|
509
|
+
const p = env.payload;
|
|
510
|
+
if (p && typeof p === 'object' && Array.isArray(p.update?.updates))
|
|
511
|
+
handler(p, env);
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
/** Convenience: subscribe to inbound A2A messages from the user's paired
|
|
515
|
+
* friends (companion.social_message), delivered cross-app to any embed whose
|
|
516
|
+
* token holds `social.message`. Surface them in your own UI / notify the user. */
|
|
517
|
+
onSocialMessage(handler) {
|
|
518
|
+
return this.on('companion.social_message', (env) => {
|
|
519
|
+
const p = env.payload;
|
|
520
|
+
if (p && typeof p === 'object' && typeof p.content === 'string')
|
|
521
|
+
handler(p, env);
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
/** Open the event stream (auto-reconnecting with the resume cursor). Uses the
|
|
525
|
+
* WebSocket plane when opted in + available, else SSE. */
|
|
526
|
+
start() {
|
|
527
|
+
if (this.streaming)
|
|
528
|
+
return;
|
|
529
|
+
this.requireSession();
|
|
530
|
+
this.streaming = true;
|
|
531
|
+
const wsImpl = this.opts.webSocketImpl ?? globalThis.WebSocket;
|
|
532
|
+
if (this.opts.stream === 'websocket' && wsImpl) {
|
|
533
|
+
void this.wsLoop(wsImpl);
|
|
534
|
+
}
|
|
535
|
+
else {
|
|
536
|
+
void this.loop();
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
/** WebSocket receive loop. Resolves when the socket closes; on any failure
|
|
540
|
+
* while still streaming it falls back to the SSE loop, so opting into WS can
|
|
541
|
+
* only ever DEGRADE to SSE, never drop replies. Input is still sent over
|
|
542
|
+
* REST (sendInput) — receiving is the latency-sensitive half. */
|
|
543
|
+
async wsLoop(WS) {
|
|
544
|
+
const id = this.requireSession();
|
|
545
|
+
const { deriveWsStreamUrl, decodeWsMessage } = await import('./ws-transport.js');
|
|
546
|
+
try {
|
|
547
|
+
await new Promise((resolve, reject) => {
|
|
548
|
+
const ws = new WS(deriveWsStreamUrl(this.opts.baseUrl, id, this.opts.token, this.cursor));
|
|
549
|
+
const done = (err) => (err ? reject(err) : resolve());
|
|
550
|
+
ws.onmessage = (ev) => {
|
|
551
|
+
if (!this.streaming) {
|
|
552
|
+
ws.close();
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
const raw = typeof ev.data === 'string' ? ev.data : '';
|
|
556
|
+
const frame = raw ? decodeWsMessage(raw) : null;
|
|
557
|
+
if (frame)
|
|
558
|
+
this.handleFrame(frame.event, frame.data);
|
|
559
|
+
};
|
|
560
|
+
ws.onerror = () => done(new Error('companion ws error'));
|
|
561
|
+
ws.onclose = () => done();
|
|
562
|
+
// Stopping the stream closes the socket.
|
|
563
|
+
const poll = setInterval(() => {
|
|
564
|
+
if (!this.streaming) {
|
|
565
|
+
clearInterval(poll);
|
|
566
|
+
try {
|
|
567
|
+
ws.close();
|
|
568
|
+
}
|
|
569
|
+
catch {
|
|
570
|
+
/* already closing */
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}, 250);
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
catch {
|
|
577
|
+
// Socket unavailable / rejected — degrade to the robust SSE loop.
|
|
578
|
+
if (this.streaming)
|
|
579
|
+
await this.loop();
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
/** Stop the event stream. */
|
|
583
|
+
stop() {
|
|
584
|
+
this.streaming = false;
|
|
585
|
+
this.abort?.abort();
|
|
586
|
+
this.abort = null;
|
|
587
|
+
}
|
|
588
|
+
emit(envelope) {
|
|
589
|
+
// Dedup by envelope id — an ungraceful drop reconnects from the last KNOWN
|
|
590
|
+
// cursor (advanced only on the clean `reconnect` frame), which can replay a
|
|
591
|
+
// few events; the id set collapses those.
|
|
592
|
+
if (envelope.id) {
|
|
593
|
+
if (this.seen.has(envelope.id))
|
|
594
|
+
return;
|
|
595
|
+
this.seen.add(envelope.id);
|
|
596
|
+
if (this.seen.size > SEEN_CAP) {
|
|
597
|
+
const first = this.seen.values().next().value;
|
|
598
|
+
if (first !== undefined)
|
|
599
|
+
this.seen.delete(first);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
// Avatar emote tools the app didn't opt into resolve as a silent no-op, so a
|
|
603
|
+
// pure audio / 2D embed never has to register a handler for them. (Voice
|
|
604
|
+
// avatar calls are no-op'd earlier in invokeVoiceTool, before they reach
|
|
605
|
+
// here; this covers the text / event-stream path.) Declared = the app opted
|
|
606
|
+
// in → deliver to its onToolCall like any other tool.
|
|
607
|
+
if (envelope.type === 'companion.tool_call') {
|
|
608
|
+
const p = envelope.payload;
|
|
609
|
+
const name = typeof p?.name === 'string' ? p.name : '';
|
|
610
|
+
const callId = typeof p?.id === 'string' ? p.id : '';
|
|
611
|
+
const declared = this.opts.tools?.some((t) => t.name === name) ?? false;
|
|
612
|
+
if (name &&
|
|
613
|
+
AVATAR_VISUAL_TOOL_NAMES.includes(name) &&
|
|
614
|
+
!declared &&
|
|
615
|
+
!this.pendingVoiceTools.has(callId)) {
|
|
616
|
+
if (callId)
|
|
617
|
+
void this.sendToolResult(callId, { ok: true }).catch(() => { });
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
for (const h of this.handlers.get(envelope.type) ?? [])
|
|
622
|
+
h(envelope);
|
|
623
|
+
for (const h of this.handlers.get('*') ?? [])
|
|
624
|
+
h(envelope);
|
|
625
|
+
}
|
|
626
|
+
handleFrame(event, data) {
|
|
627
|
+
if (event === 'reconnect') {
|
|
628
|
+
try {
|
|
629
|
+
const d = JSON.parse(data);
|
|
630
|
+
if (typeof d.cursor === 'number')
|
|
631
|
+
this.cursor = d.cursor;
|
|
632
|
+
}
|
|
633
|
+
catch {
|
|
634
|
+
/* ignore */
|
|
635
|
+
}
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
let env = null;
|
|
639
|
+
try {
|
|
640
|
+
env = JSON.parse(data);
|
|
641
|
+
}
|
|
642
|
+
catch {
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
if (event === 'open') {
|
|
646
|
+
const rc = env?.payload?.resumeCursor;
|
|
647
|
+
if (typeof rc === 'number')
|
|
648
|
+
this.cursor = rc;
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
if (env && typeof env.type === 'string')
|
|
652
|
+
this.emit(env);
|
|
653
|
+
}
|
|
654
|
+
async loop() {
|
|
655
|
+
const id = this.requireSession();
|
|
656
|
+
let backoff = 500;
|
|
657
|
+
while (this.streaming) {
|
|
658
|
+
this.abort = new AbortController();
|
|
659
|
+
try {
|
|
660
|
+
const res = await this.doFetch(this.url(`/api/companion/session/${id}/stream?cursor=${this.cursor}`), { headers: { Authorization: `Bearer ${this.opts.token}` }, signal: this.abort.signal });
|
|
661
|
+
if (res.status === 401 || res.status === 403) {
|
|
662
|
+
// Permanent: the token can't subscribe (most often it lacks the
|
|
663
|
+
// `events.subscribe` scope; also revoked / wrong user). Retrying is
|
|
664
|
+
// futile and would silently swallow every reply — surface it as a
|
|
665
|
+
// control.error and stop the loop.
|
|
666
|
+
this.streaming = false;
|
|
667
|
+
this.emit({
|
|
668
|
+
v: PROTOCOL_VERSION,
|
|
669
|
+
id: `stream_err_${Date.now()}`,
|
|
670
|
+
session: this._session ?? undefined,
|
|
671
|
+
ts: Date.now(),
|
|
672
|
+
type: 'control.error',
|
|
673
|
+
payload: {
|
|
674
|
+
code: 'stream_unauthorized',
|
|
675
|
+
message: `event stream rejected (${res.status}); the token likely lacks the "events.subscribe" scope`
|
|
676
|
+
}
|
|
677
|
+
});
|
|
678
|
+
break;
|
|
679
|
+
}
|
|
680
|
+
if (!res.ok || !res.body) {
|
|
681
|
+
if (!this.streaming)
|
|
682
|
+
break;
|
|
683
|
+
await this.sleep(backoff);
|
|
684
|
+
backoff = Math.min(backoff * 2, 8000);
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
backoff = 500; // healthy connection resets backoff
|
|
688
|
+
await this.consume(res.body);
|
|
689
|
+
}
|
|
690
|
+
catch {
|
|
691
|
+
if (!this.streaming)
|
|
692
|
+
break;
|
|
693
|
+
await this.sleep(backoff);
|
|
694
|
+
backoff = Math.min(backoff * 2, 8000);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
async consume(body) {
|
|
699
|
+
const reader = body.getReader();
|
|
700
|
+
const decoder = new TextDecoder();
|
|
701
|
+
let buf = '';
|
|
702
|
+
while (this.streaming) {
|
|
703
|
+
const { done, value } = await reader.read();
|
|
704
|
+
if (done)
|
|
705
|
+
break;
|
|
706
|
+
buf += decoder.decode(value, { stream: true });
|
|
707
|
+
const { events, rest } = parseSse(buf);
|
|
708
|
+
buf = rest;
|
|
709
|
+
for (const ev of events)
|
|
710
|
+
this.handleFrame(ev.event, ev.data);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
sleep(ms) {
|
|
714
|
+
return new Promise((res) => setTimeout(res, ms));
|
|
715
|
+
}
|
|
716
|
+
}
|