@pouchy_ai/companion-sdk 0.10.0 → 0.12.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 +60 -1
- package/README.md +1 -1
- package/dist/call.js +128 -13
- package/dist/client.d.ts +29 -7
- package/dist/client.js +90 -34
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/protocol.d.ts +19 -5
- package/package.json +14 -3
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,61 @@ a protocol bump is always called out explicitly here.
|
|
|
12
12
|
|
|
13
13
|
## [Unreleased]
|
|
14
14
|
|
|
15
|
+
## [0.12.0] - 2026-07-05
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- **`confirmAction(confirmId, approve)`** — resolve a pending sensitive-op
|
|
20
|
+
confirmation from the embed. Platform **session tokens** only (end-user
|
|
21
|
+
instances minted via `/v1/sessions`): the embedding app's end user is that
|
|
22
|
+
account's only human, so the session may approve. First-party user tokens
|
|
23
|
+
remain observe-only (401) — approval of a real Pouchy user's ops stays on
|
|
24
|
+
their first-party surface with its biometric step-up. On approve the
|
|
25
|
+
recorded action runs server-side and its outcome arrives as a normal
|
|
26
|
+
`companion.message`. This is the run path for the platform's new
|
|
27
|
+
**confirm-gated custom skills** (POST / credentialed tools, keys served
|
|
28
|
+
from the project credential vault).
|
|
29
|
+
- **`pendingConfirms()`** — the session's still-pending confirmations
|
|
30
|
+
(display-safe: `confirmId`, `scope`, `summary`, `createdAt`), for rebuilding
|
|
31
|
+
a confirm card after a reload. Same session-token-only auth.
|
|
32
|
+
- **`PendingConfirm`** type export; `ConfirmRequestPayload` /
|
|
33
|
+
`onConfirmRequest` docs updated for the two-token model. No wire-protocol
|
|
34
|
+
change (`PROTOCOL_VERSION` stays `1`); additive surface.
|
|
35
|
+
|
|
36
|
+
## [0.11.0] - 2026-07-02
|
|
37
|
+
|
|
38
|
+
### Added
|
|
39
|
+
|
|
40
|
+
- **`AVATAR_VISUAL_TOOLS` / `AVATAR_VISUAL_TOOL_NAMES` are now exported from the
|
|
41
|
+
package entry point.** `docs/companion-host-control.md` has documented these
|
|
42
|
+
since the avatar-emote work, but the public `index.ts` never re-exported them —
|
|
43
|
+
hosts that wanted to filter or inspect the avatar visual tool set had to
|
|
44
|
+
reach into internal module paths. Additive; no behavior change.
|
|
45
|
+
|
|
46
|
+
### Docs
|
|
47
|
+
|
|
48
|
+
- The protocol document now reflects the shipped transport reality: REST + SSE
|
|
49
|
+
is the live control/data plane, `stream: 'websocket'` is a client-side option
|
|
50
|
+
that automatically falls back to SSE (the server WebSocket endpoint is not
|
|
51
|
+
yet available), and the server agent loop is live. The voice and poker
|
|
52
|
+
integration guides now install the SDK from npm instead of a local build /
|
|
53
|
+
bundled tarball.
|
|
54
|
+
|
|
55
|
+
## [0.10.1] - 2026-06-29
|
|
56
|
+
|
|
57
|
+
### Fixed
|
|
58
|
+
|
|
59
|
+
- **Voice calls no longer drop on a transient network blip.** A WebRTC
|
|
60
|
+
`connectionState` of `disconnected` is transient per spec — a brief Wi-Fi
|
|
61
|
+
hiccup or cell handoff routinely hits it and recovers to `connected` on its
|
|
62
|
+
own. `connectCall()` previously tore the call down the instant `disconnected`
|
|
63
|
+
appeared, killing calls that would have healed. It now holds a short grace
|
|
64
|
+
window (6s) and only tears down if the connection is still `disconnected` when
|
|
65
|
+
it elapses; recovery to `connected` cancels the teardown. `failed` / `closed`
|
|
66
|
+
remain immediately fatal, and a genuine silent drop still tears down (so the
|
|
67
|
+
mic can't keep recording). No surface change — `connectCall` signature,
|
|
68
|
+
events, payloads, and scopes are unchanged.
|
|
69
|
+
|
|
15
70
|
## [0.10.0] - 2026-06-28
|
|
16
71
|
|
|
17
72
|
Completes the SDK surface for all six companion capabilities, with Instant UI and
|
|
@@ -82,5 +137,9 @@ self-hosted/CDN build; this entry captures the surface that ships publicly.
|
|
|
82
137
|
- **Versioned wire protocol.** `PROTOCOL_VERSION = 1`, kept in lockstep with the
|
|
83
138
|
server by `protocol.drift.test.ts` (fails CI on divergence).
|
|
84
139
|
|
|
85
|
-
[Unreleased]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.
|
|
140
|
+
[Unreleased]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.12.0...HEAD
|
|
141
|
+
[0.12.0]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.11.0...companion-sdk-v0.12.0
|
|
142
|
+
[0.11.0]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.10.1...companion-sdk-v0.11.0
|
|
143
|
+
[0.10.1]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.10.0...companion-sdk-v0.10.1
|
|
144
|
+
[0.10.0]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.9.0...companion-sdk-v0.10.0
|
|
86
145
|
[0.9.0]: https://github.com/oviswang/Pouchy/releases/tag/companion-sdk-v0.9.0
|
package/README.md
CHANGED
|
@@ -47,7 +47,7 @@ Subscribe with `on(type, fn)` (or `'*'`), or these typed convenience helpers:
|
|
|
47
47
|
| `onRender(fn)` | `companion.ui_action` | **Instant UI** — draw `payload.interface` (platform-neutral genui schema) with your own renderer (web / iOS / Android / CLI). Needs `ui.render` |
|
|
48
48
|
| `onInterfaceUpdate(fn)` | `companion.ui_update` | live `{key,value}` update to an already-rendered panel (no rebuild) |
|
|
49
49
|
| `onSocialMessage(fn)` | `companion.social_message` | inbound A2A friend message, delivered cross-app. Needs `social.message` |
|
|
50
|
-
| `onConfirmRequest(fn)` | `companion.confirm_request` |
|
|
50
|
+
| `onConfirmRequest(fn)` | `companion.confirm_request` | a sensitive-op approval request. **Platform session tokens** (your end users): show your own confirm card and resolve it with `confirmAction` — this is how confirm-gated custom skills (POST / credentialed) run. First-party user tokens: observe-only — approval stays first-party (where the `stepUp:true` biometric gate lives) |
|
|
51
51
|
| `onAudio(fn)` | `companion.audio` | TTS clip (non-call modality) |
|
|
52
52
|
| `onExpression(fn)` | `companion.expression` | avatar viseme / expression / gesture |
|
|
53
53
|
| `onUsage(fn)` | `control.usage` | per-token metering echo |
|
package/dist/call.js
CHANGED
|
@@ -153,8 +153,35 @@ export async function openCompanionCall(creds, opts = {}, bridge) {
|
|
|
153
153
|
? openOpenAICall(creds, opts, bridge)
|
|
154
154
|
: openConvaiCall(creds, opts, bridge);
|
|
155
155
|
}
|
|
156
|
+
// Connect-step timeouts (mirror the first-party realtime path). Without them a
|
|
157
|
+
// stuck mic (Android WebView holding the device) or a hung SDP exchange hangs
|
|
158
|
+
// the connect promise forever — and if gUM already resolved, with a hot mic.
|
|
159
|
+
const GUM_TIMEOUT_MS = 10_000;
|
|
160
|
+
const SDP_TIMEOUT_MS = 8_000;
|
|
161
|
+
/** getUserMedia with a timeout. If the timeout wins, a late-resolving mic is
|
|
162
|
+
* stopped so a slow permission grant can't leave the device recording after
|
|
163
|
+
* we've already given up on the call. */
|
|
164
|
+
async function getMicWithTimeout() {
|
|
165
|
+
let timedOut = false;
|
|
166
|
+
const micPromise = navigator.mediaDevices.getUserMedia({ audio: true });
|
|
167
|
+
micPromise
|
|
168
|
+
.then((m) => {
|
|
169
|
+
if (timedOut)
|
|
170
|
+
m.getTracks().forEach((t) => t.stop());
|
|
171
|
+
})
|
|
172
|
+
.catch(() => { });
|
|
173
|
+
const timeout = new Promise((_, reject) => setTimeout(() => {
|
|
174
|
+
timedOut = true;
|
|
175
|
+
reject(new Error('microphone request timed out'));
|
|
176
|
+
}, GUM_TIMEOUT_MS));
|
|
177
|
+
return Promise.race([micPromise, timeout]);
|
|
178
|
+
}
|
|
156
179
|
// ── OpenAI Realtime — self-contained WebRTC ─────────────────────────────────
|
|
157
180
|
async function openOpenAICall(creds, opts, bridge) {
|
|
181
|
+
// Acquire the mic FIRST (with a timeout): if gUM fails/times out there is no
|
|
182
|
+
// RTCPeerConnection yet to leak. Creating pc before gUM (the old order) leaked
|
|
183
|
+
// a peer connection on every permission denial.
|
|
184
|
+
const mic = await getMicWithTimeout();
|
|
158
185
|
const pc = new RTCPeerConnection();
|
|
159
186
|
const audioEl = opts.audioElement ?? new Audio();
|
|
160
187
|
audioEl.autoplay = true;
|
|
@@ -162,7 +189,6 @@ async function openOpenAICall(creds, opts, bridge) {
|
|
|
162
189
|
audioEl.srcObject = e.streams[0];
|
|
163
190
|
void audioEl.play().catch(() => { });
|
|
164
191
|
};
|
|
165
|
-
const mic = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
166
192
|
for (const track of mic.getAudioTracks())
|
|
167
193
|
pc.addTrack(track, mic);
|
|
168
194
|
// Half-duplex mic (mirrors the first-party realtime path, #1524): on a phone
|
|
@@ -290,22 +316,57 @@ async function openOpenAICall(creds, opts, bridge) {
|
|
|
290
316
|
};
|
|
291
317
|
const offer = await pc.createOffer();
|
|
292
318
|
await pc.setLocalDescription(offer);
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
if (!res.ok) {
|
|
319
|
+
// Bound the SDP exchange so a hung network can't leave the pc + hot mic open
|
|
320
|
+
// forever. On any failure, close both before throwing.
|
|
321
|
+
const sdpAbort = new AbortController();
|
|
322
|
+
const sdpTimer = setTimeout(() => sdpAbort.abort(), SDP_TIMEOUT_MS);
|
|
323
|
+
const cleanupPartial = () => {
|
|
299
324
|
pc.close();
|
|
300
325
|
mic.getTracks().forEach((t) => t.stop());
|
|
326
|
+
};
|
|
327
|
+
let res;
|
|
328
|
+
try {
|
|
329
|
+
res = await fetch(OPENAI_REALTIME_CALLS, {
|
|
330
|
+
method: 'POST',
|
|
331
|
+
headers: { Authorization: `Bearer ${creds.clientSecret}`, 'Content-Type': 'application/sdp' },
|
|
332
|
+
body: offer.sdp,
|
|
333
|
+
signal: sdpAbort.signal
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
clearTimeout(sdpTimer);
|
|
338
|
+
cleanupPartial();
|
|
339
|
+
throw new Error(sdpAbort.signal.aborted
|
|
340
|
+
? 'OpenAI Realtime SDP exchange timed out'
|
|
341
|
+
: 'OpenAI Realtime SDP exchange failed');
|
|
342
|
+
}
|
|
343
|
+
clearTimeout(sdpTimer);
|
|
344
|
+
if (!res.ok) {
|
|
345
|
+
cleanupPartial();
|
|
301
346
|
throw new Error(`OpenAI Realtime SDP exchange failed (${res.status})`);
|
|
302
347
|
}
|
|
303
348
|
await pc.setRemoteDescription({ type: 'answer', sdp: await res.text() });
|
|
304
349
|
let closed = false;
|
|
350
|
+
// `disconnected` is TRANSIENT per the WebRTC spec — a brief Wi-Fi hiccup or a
|
|
351
|
+
// cell handoff routinely drops to `disconnected` and recovers to `connected`
|
|
352
|
+
// on its own. Tearing down the instant it appears killed calls that would have
|
|
353
|
+
// healed (field report: SDK voice "动不动就掉线"). Give it a grace window to
|
|
354
|
+
// come back; only if it's STILL disconnected when the timer fires do we tear
|
|
355
|
+
// down — so the mic still can't record forever on a genuine silent drop (the
|
|
356
|
+
// original concern). `failed` / `closed` stay immediately fatal.
|
|
357
|
+
const DISCONNECT_GRACE_MS = 6000;
|
|
358
|
+
let disconnectTimer = null;
|
|
359
|
+
const clearDisconnectTimer = () => {
|
|
360
|
+
if (disconnectTimer) {
|
|
361
|
+
clearTimeout(disconnectTimer);
|
|
362
|
+
disconnectTimer = null;
|
|
363
|
+
}
|
|
364
|
+
};
|
|
305
365
|
const teardown = () => {
|
|
306
366
|
if (closed)
|
|
307
367
|
return;
|
|
308
368
|
closed = true;
|
|
369
|
+
clearDisconnectTimer();
|
|
309
370
|
if (micUnmuteTimer) {
|
|
310
371
|
clearTimeout(micUnmuteTimer);
|
|
311
372
|
micUnmuteTimer = null;
|
|
@@ -320,15 +381,54 @@ async function openOpenAICall(creds, opts, bridge) {
|
|
|
320
381
|
mic.getTracks().forEach((t) => t.stop());
|
|
321
382
|
};
|
|
322
383
|
pc.onconnectionstatechange = () => {
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
// (
|
|
326
|
-
|
|
327
|
-
|
|
384
|
+
const st = pc.connectionState;
|
|
385
|
+
if (st === 'connected') {
|
|
386
|
+
// (Re)connected — cancel any pending disconnect teardown.
|
|
387
|
+
clearDisconnectTimer();
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
if (st === 'failed' || st === 'closed') {
|
|
391
|
+
// Genuinely dead — tear down immediately, BEFORE surfacing the error, so a
|
|
392
|
+
// silent transport drop can't leave the mic recording (mirrors the
|
|
393
|
+
// first-party realtime/connection.ts fail() ordering).
|
|
394
|
+
clearDisconnectTimer();
|
|
328
395
|
teardown();
|
|
329
|
-
opts.onError?.(new Error(`call ${
|
|
396
|
+
opts.onError?.(new Error(`call ${st}`));
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
if (st === 'disconnected') {
|
|
400
|
+
// Hold a grace window; a recovery to `connected` (handled above) cancels
|
|
401
|
+
// the timer. Still disconnected when it fires → tear down.
|
|
402
|
+
if (disconnectTimer || closed)
|
|
403
|
+
return;
|
|
404
|
+
disconnectTimer = setTimeout(() => {
|
|
405
|
+
disconnectTimer = null;
|
|
406
|
+
if (closed)
|
|
407
|
+
return;
|
|
408
|
+
if (pc.connectionState === 'disconnected') {
|
|
409
|
+
teardown();
|
|
410
|
+
opts.onError?.(new Error('call disconnected'));
|
|
411
|
+
}
|
|
412
|
+
}, DISCONNECT_GRACE_MS);
|
|
330
413
|
}
|
|
331
414
|
};
|
|
415
|
+
// Data-channel liveness (mirrors the first-party dataChannel.onclose/onerror).
|
|
416
|
+
// A dc-only death — the channel closes/errors while pc.connectionState still
|
|
417
|
+
// reads 'connected' — otherwise goes undetected and the mic stays hot.
|
|
418
|
+
// teardown flips `closed` before it calls dc.close(), so a normal teardown's
|
|
419
|
+
// own channel close is guarded out here (no double-fire / spurious error).
|
|
420
|
+
dc.onclose = () => {
|
|
421
|
+
if (closed)
|
|
422
|
+
return;
|
|
423
|
+
teardown();
|
|
424
|
+
opts.onError?.(new Error('call data channel closed'));
|
|
425
|
+
};
|
|
426
|
+
dc.onerror = () => {
|
|
427
|
+
if (closed)
|
|
428
|
+
return;
|
|
429
|
+
teardown();
|
|
430
|
+
opts.onError?.(new Error('call data channel error'));
|
|
431
|
+
};
|
|
332
432
|
return {
|
|
333
433
|
provider: 'openai-realtime',
|
|
334
434
|
close: teardown,
|
|
@@ -447,6 +547,21 @@ async function openConvaiCall(creds, opts, bridge) {
|
|
|
447
547
|
/* session not yet established */
|
|
448
548
|
}
|
|
449
549
|
opts.onError?.(new Error('call disconnected'));
|
|
550
|
+
},
|
|
551
|
+
// Liveness backstop (first-party F3): a transport wedge can flip status to
|
|
552
|
+
// 'disconnected' WITHOUT firing onDisconnect, leaving "connected" with a hot
|
|
553
|
+
// mic forever. Treat a disconnected status the same as onDisconnect.
|
|
554
|
+
onStatusChange: ({ status }) => {
|
|
555
|
+
if (closed || status !== 'disconnected')
|
|
556
|
+
return;
|
|
557
|
+
closed = true;
|
|
558
|
+
try {
|
|
559
|
+
void conv.endSession().catch(() => { });
|
|
560
|
+
}
|
|
561
|
+
catch {
|
|
562
|
+
/* session not yet established */
|
|
563
|
+
}
|
|
564
|
+
opts.onError?.(new Error('call disconnected'));
|
|
450
565
|
}
|
|
451
566
|
});
|
|
452
567
|
return {
|
package/dist/client.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AudioClipPayload, CompanionEnvelope, ConfirmRequestPayload, ExpressionPayload, InterfaceUpdatePayload, OutboundType, RenderInterfacePayload, SocialMessagePayload, UsagePayload, WorldStateEvent } from './protocol.js';
|
|
1
|
+
import type { AudioClipPayload, CompanionEnvelope, ConfirmRequestPayload, ExpressionPayload, InterfaceUpdatePayload, OutboundType, PendingConfirm, RenderInterfacePayload, SocialMessagePayload, UsagePayload, WorldStateEvent } from './protocol.js';
|
|
2
2
|
/** Ergonomic world-state input: a WorldStateEvent with the CloudEvents plumbing
|
|
3
3
|
* (specversion / id / source) optional — sendWorldState fills them. */
|
|
4
4
|
export type WorldStateInput<D = unknown> = Omit<WorldStateEvent<D>, 'specversion' | 'id' | 'source'> & Partial<Pick<WorldStateEvent<D>, 'specversion' | 'id' | 'source'>>;
|
|
@@ -331,14 +331,36 @@ export declare class CompanionClient {
|
|
|
331
331
|
/** Convenience: subscribe to the per-token metering echo (control.usage) for a
|
|
332
332
|
* usage / billing view. */
|
|
333
333
|
onUsage(handler: (payload: UsagePayload, envelope: CompanionEnvelope) => void): () => void;
|
|
334
|
-
/** Convenience:
|
|
334
|
+
/** Convenience: subscribe to sensitive-op confirmation requests
|
|
335
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
|
|
337
|
-
* in your UI
|
|
338
|
-
*
|
|
339
|
-
*
|
|
340
|
-
*
|
|
336
|
+
* companion wants the user to approve. Use it to surface the pending
|
|
337
|
+
* approval in your UI.
|
|
338
|
+
*
|
|
339
|
+
* Whether YOU can resolve it depends on the token:
|
|
340
|
+
* - **Platform session tokens** (minted via /v1/sessions for your end
|
|
341
|
+
* users): show your own confirm card and call `confirmAction` — your end
|
|
342
|
+
* user is the account's human, so the session may approve. This is how
|
|
343
|
+
* confirm-gated custom skills (POST / credentialed) run.
|
|
344
|
+
* - **First-party user tokens** (Login-with-Pouchy PATs): observe-only —
|
|
345
|
+
* approval is authed as the Pouchy user (their app / a hosted confirm
|
|
346
|
+
* page), which is also where the biometric (Face ID / passkey) gate
|
|
347
|
+
* lives. `confirmAction` returns 401 on these tokens. */
|
|
341
348
|
onConfirmRequest(handler: (payload: ConfirmRequestPayload, envelope: CompanionEnvelope) => void): () => void;
|
|
349
|
+
/** Resolve a pending confirmation (platform session tokens only — see
|
|
350
|
+
* `onConfirmRequest`). Show the user what they're approving (the event's
|
|
351
|
+
* `summary`), collect an explicit tap, then pass their decision here. On
|
|
352
|
+
* approval the recorded action runs server-side and its outcome arrives as
|
|
353
|
+
* a normal companion.message; a denial emits a brief decline. One-time:
|
|
354
|
+
* re-resolving a settled confirmId fails with 409. */
|
|
355
|
+
confirmAction(confirmId: string, approve: boolean): Promise<{
|
|
356
|
+
ok: boolean;
|
|
357
|
+
status: 'approved' | 'denied';
|
|
358
|
+
}>;
|
|
359
|
+
/** The session's still-pending confirmations (display-safe: id, summary,
|
|
360
|
+
* scope, timestamps — never raw args). Use it to rebuild your confirm card
|
|
361
|
+
* after a reload, since confirm_request events are not replayed. Platform
|
|
362
|
+
* session tokens only, like `confirmAction`. */
|
|
363
|
+
pendingConfirms(): Promise<PendingConfirm[]>;
|
|
342
364
|
/** Convenience: subscribe to live updates of an already-rendered Instant UI
|
|
343
365
|
* panel (companion.ui_update). Apply each `{ key, value }` to the panel's state
|
|
344
366
|
* bag and re-evaluate bound displays — no rebuild. */
|
package/dist/client.js
CHANGED
|
@@ -487,13 +487,20 @@ export class CompanionClient {
|
|
|
487
487
|
onUsage(handler) {
|
|
488
488
|
return this.on('control.usage', (env) => handler(env.payload, env));
|
|
489
489
|
}
|
|
490
|
-
/** Convenience:
|
|
490
|
+
/** Convenience: subscribe to sensitive-op confirmation requests
|
|
491
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
|
|
493
|
-
* in your UI
|
|
494
|
-
*
|
|
495
|
-
*
|
|
496
|
-
*
|
|
492
|
+
* companion wants the user to approve. Use it to surface the pending
|
|
493
|
+
* approval in your UI.
|
|
494
|
+
*
|
|
495
|
+
* Whether YOU can resolve it depends on the token:
|
|
496
|
+
* - **Platform session tokens** (minted via /v1/sessions for your end
|
|
497
|
+
* users): show your own confirm card and call `confirmAction` — your end
|
|
498
|
+
* user is the account's human, so the session may approve. This is how
|
|
499
|
+
* confirm-gated custom skills (POST / credentialed) run.
|
|
500
|
+
* - **First-party user tokens** (Login-with-Pouchy PATs): observe-only —
|
|
501
|
+
* approval is authed as the Pouchy user (their app / a hosted confirm
|
|
502
|
+
* page), which is also where the biometric (Face ID / passkey) gate
|
|
503
|
+
* lives. `confirmAction` returns 401 on these tokens. */
|
|
497
504
|
onConfirmRequest(handler) {
|
|
498
505
|
return this.on('companion.confirm_request', (env) => {
|
|
499
506
|
const p = env.payload;
|
|
@@ -501,6 +508,29 @@ export class CompanionClient {
|
|
|
501
508
|
handler(p, env);
|
|
502
509
|
});
|
|
503
510
|
}
|
|
511
|
+
/** Resolve a pending confirmation (platform session tokens only — see
|
|
512
|
+
* `onConfirmRequest`). Show the user what they're approving (the event's
|
|
513
|
+
* `summary`), collect an explicit tap, then pass their decision here. On
|
|
514
|
+
* approval the recorded action runs server-side and its outcome arrives as
|
|
515
|
+
* a normal companion.message; a denial emits a brief decline. One-time:
|
|
516
|
+
* re-resolving a settled confirmId fails with 409. */
|
|
517
|
+
async confirmAction(confirmId, approve) {
|
|
518
|
+
const sessionId = this.requireSession();
|
|
519
|
+
return this.postJson(`/api/companion/session/${encodeURIComponent(sessionId)}/confirm`, { confirmId, approve });
|
|
520
|
+
}
|
|
521
|
+
/** The session's still-pending confirmations (display-safe: id, summary,
|
|
522
|
+
* scope, timestamps — never raw args). Use it to rebuild your confirm card
|
|
523
|
+
* after a reload, since confirm_request events are not replayed. Platform
|
|
524
|
+
* session tokens only, like `confirmAction`. */
|
|
525
|
+
async pendingConfirms() {
|
|
526
|
+
const sessionId = this.requireSession();
|
|
527
|
+
const res = await this.doFetch(this.url(`/api/companion/session/${encodeURIComponent(sessionId)}/confirm`), { headers: { Authorization: `Bearer ${this.opts.token}` } });
|
|
528
|
+
const json = (await res.json().catch(() => null));
|
|
529
|
+
if (!res.ok || !json || json.ok === false) {
|
|
530
|
+
throw new CompanionError(json?.error || `pending confirms failed (${res.status})`, res.status);
|
|
531
|
+
}
|
|
532
|
+
return json.pending ?? [];
|
|
533
|
+
}
|
|
504
534
|
/** Convenience: subscribe to live updates of an already-rendered Instant UI
|
|
505
535
|
* panel (companion.ui_update). Apply each `{ key, value }` to the panel's state
|
|
506
536
|
* bag and re-evaluate bound displays — no rebuild. */
|
|
@@ -543,40 +573,66 @@ export class CompanionClient {
|
|
|
543
573
|
async wsLoop(WS) {
|
|
544
574
|
const id = this.requireSession();
|
|
545
575
|
const { deriveWsStreamUrl, decodeWsMessage } = await import('./ws-transport.js');
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
const
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
const poll = setInterval(() => {
|
|
564
|
-
if (!this.streaming) {
|
|
565
|
-
clearInterval(poll);
|
|
576
|
+
// Reconnect across CLEAN closes (a WS gateway with a bounded lifetime, like
|
|
577
|
+
// the SSE endpoint's window) just as the SSE loop does — a graceful close
|
|
578
|
+
// must not silently end reply delivery. Only a connection ERROR degrades to
|
|
579
|
+
// SSE, and only once. Each connection's poll timer + socket are released in
|
|
580
|
+
// the settle() finally so neither leaks after fallback/reconnect.
|
|
581
|
+
while (this.streaming) {
|
|
582
|
+
let errored = false;
|
|
583
|
+
try {
|
|
584
|
+
await new Promise((resolve, reject) => {
|
|
585
|
+
const ws = new WS(deriveWsStreamUrl(this.opts.baseUrl, id, this.opts.token, this.cursor));
|
|
586
|
+
let poll;
|
|
587
|
+
let settled = false;
|
|
588
|
+
const settle = (err) => {
|
|
589
|
+
if (poll) {
|
|
590
|
+
clearInterval(poll);
|
|
591
|
+
poll = undefined;
|
|
592
|
+
}
|
|
566
593
|
try {
|
|
567
594
|
ws.close();
|
|
568
595
|
}
|
|
569
596
|
catch {
|
|
570
597
|
/* already closing */
|
|
571
598
|
}
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
599
|
+
if (settled)
|
|
600
|
+
return;
|
|
601
|
+
settled = true;
|
|
602
|
+
if (err)
|
|
603
|
+
reject(err);
|
|
604
|
+
else
|
|
605
|
+
resolve();
|
|
606
|
+
};
|
|
607
|
+
ws.onmessage = (ev) => {
|
|
608
|
+
if (!this.streaming) {
|
|
609
|
+
settle();
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
const raw = typeof ev.data === 'string' ? ev.data : '';
|
|
613
|
+
const frame = raw ? decodeWsMessage(raw) : null;
|
|
614
|
+
if (frame)
|
|
615
|
+
this.handleFrame(frame.event, frame.data);
|
|
616
|
+
};
|
|
617
|
+
ws.onerror = () => settle(new Error('companion ws error'));
|
|
618
|
+
ws.onclose = () => settle();
|
|
619
|
+
// Stopping the stream closes the socket + clears this timer.
|
|
620
|
+
poll = setInterval(() => {
|
|
621
|
+
if (!this.streaming)
|
|
622
|
+
settle();
|
|
623
|
+
}, 250);
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
catch {
|
|
627
|
+
errored = true;
|
|
628
|
+
}
|
|
629
|
+
if (errored) {
|
|
630
|
+
// Socket unavailable / rejected — degrade to the robust SSE loop (once).
|
|
631
|
+
if (this.streaming)
|
|
632
|
+
await this.loop();
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
// Clean close: the while-guard reconnects if still streaming, else exits.
|
|
580
636
|
}
|
|
581
637
|
}
|
|
582
638
|
/** Stop the event stream. */
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { CompanionClient, type CompanionClientOptions } from './client.js';
|
|
2
2
|
export { CompanionClient, CompanionError, pouchyBrandIconUrl } from './client.js';
|
|
3
3
|
export type { CompanionClientOptions, HelloAck, RecalledMemory, CallCredentials, CompanionToolDecl, WorldStateInput, CompanionAvatar, BrandIconSize } from './client.js';
|
|
4
|
-
export { openCompanionCall, HOST_CONTROL_TOOLS, HOST_CONTROL_TOOL_NAMES } from './call.js';
|
|
4
|
+
export { openCompanionCall, HOST_CONTROL_TOOLS, HOST_CONTROL_TOOL_NAMES, AVATAR_VISUAL_TOOLS, AVATAR_VISUAL_TOOL_NAMES } from './call.js';
|
|
5
5
|
export type { CompanionCall, CompanionCallOptions, VoiceToolBridge } from './call.js';
|
|
6
|
-
export type { CompanionEnvelope, OutboundType, InboundType, WorldStateEvent, RenderInterfacePayload, InterfaceUpdatePayload, SocialMessagePayload, ConfirmRequestPayload, AudioClipPayload, ExpressionPayload, UsagePayload } from './protocol.js';
|
|
6
|
+
export type { CompanionEnvelope, OutboundType, InboundType, WorldStateEvent, RenderInterfacePayload, InterfaceUpdatePayload, SocialMessagePayload, ConfirmRequestPayload, PendingConfirm, AudioClipPayload, ExpressionPayload, UsagePayload } from './protocol.js';
|
|
7
7
|
/** Create a companion client. */
|
|
8
8
|
export declare function createCompanion(opts: CompanionClientOptions): CompanionClient;
|
package/dist/index.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// await c.sendText('what should I do?');
|
|
11
11
|
import { CompanionClient } from './client.js';
|
|
12
12
|
export { CompanionClient, CompanionError, pouchyBrandIconUrl } from './client.js';
|
|
13
|
-
export { openCompanionCall, HOST_CONTROL_TOOLS, HOST_CONTROL_TOOL_NAMES } from './call.js';
|
|
13
|
+
export { openCompanionCall, HOST_CONTROL_TOOLS, HOST_CONTROL_TOOL_NAMES, AVATAR_VISUAL_TOOLS, AVATAR_VISUAL_TOOL_NAMES } from './call.js';
|
|
14
14
|
/** Create a companion client. */
|
|
15
15
|
export function createCompanion(opts) {
|
|
16
16
|
return new CompanionClient(opts);
|
package/dist/protocol.d.ts
CHANGED
|
@@ -38,11 +38,14 @@ export interface RenderInterfacePayload {
|
|
|
38
38
|
}
|
|
39
39
|
/** Payload of a `companion.confirm_request` event — the companion is asking the
|
|
40
40
|
* user to approve a sensitive op (a payment, a skill run, a friend message).
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* A first-party
|
|
45
|
-
*
|
|
41
|
+
* Who may approve depends on the token: a PLATFORM SESSION TOKEN (an end-user
|
|
42
|
+
* instance minted via /v1/sessions) resolves it directly with
|
|
43
|
+
* `client.confirmAction` — the embedding app's end user is that account's
|
|
44
|
+
* human. A first-party user's token can only OBSERVE: approval of a real
|
|
45
|
+
* Pouchy user's ops is authed as that user (their app / a hosted confirm
|
|
46
|
+
* page), so a third-party DOM can never confirm a spend, and the first-party
|
|
47
|
+
* host can gate approval behind a biometric (Face ID / passkey) — see
|
|
48
|
+
* docs/companion-wallet-noncustodial.md. */
|
|
46
49
|
export interface ConfirmRequestPayload {
|
|
47
50
|
/** Opaque id the first-party surface passes to the confirm endpoint. */
|
|
48
51
|
confirmId: string;
|
|
@@ -56,6 +59,17 @@ export interface ConfirmRequestPayload {
|
|
|
56
59
|
* Cryptographic enforcement is server-side follow-up; this is the host's cue. */
|
|
57
60
|
stepUp?: boolean;
|
|
58
61
|
}
|
|
62
|
+
/** One still-pending confirmation, as returned by `client.pendingConfirms()` —
|
|
63
|
+
* display-safe (never the recorded tool call or its raw args). */
|
|
64
|
+
export interface PendingConfirm {
|
|
65
|
+
confirmId: string;
|
|
66
|
+
/** The scope of the op needing approval (e.g. `skills.execute`). */
|
|
67
|
+
scope: string;
|
|
68
|
+
/** A human, user-facing summary of what approving will do. */
|
|
69
|
+
summary: string;
|
|
70
|
+
/** Epoch ms when the companion asked. */
|
|
71
|
+
createdAt: number;
|
|
72
|
+
}
|
|
59
73
|
/** Payload of a `companion.ui_update` event — a live change to an
|
|
60
74
|
* already-rendered Instant UI panel: write each `{ key, value }` into the
|
|
61
75
|
* panel's state bag (re-evaluating templates / progress / `when`), without
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pouchy_ai/companion-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Embed the Pouchy companion — chat, voice, tools, memory, live world-state, instant UI, and agent-to-agent messaging — in any app, game, or site.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE",
|
|
@@ -21,7 +21,12 @@
|
|
|
21
21
|
},
|
|
22
22
|
"main": "./dist/index.js",
|
|
23
23
|
"types": "./dist/index.d.ts",
|
|
24
|
-
"files": [
|
|
24
|
+
"files": [
|
|
25
|
+
"dist",
|
|
26
|
+
"README.md",
|
|
27
|
+
"CHANGELOG.md",
|
|
28
|
+
"LICENSE"
|
|
29
|
+
],
|
|
25
30
|
"sideEffects": false,
|
|
26
31
|
"scripts": {
|
|
27
32
|
"build": "tsc -p tsconfig.json && node scripts/fix-esm-extensions.mjs",
|
|
@@ -40,7 +45,13 @@
|
|
|
40
45
|
"esbuild": "^0.24.0",
|
|
41
46
|
"typescript": "^5.5.0"
|
|
42
47
|
},
|
|
43
|
-
"keywords": [
|
|
48
|
+
"keywords": [
|
|
49
|
+
"pouchy",
|
|
50
|
+
"ai-companion",
|
|
51
|
+
"voice",
|
|
52
|
+
"agent",
|
|
53
|
+
"sdk"
|
|
54
|
+
],
|
|
44
55
|
"publishConfig": {
|
|
45
56
|
"access": "public"
|
|
46
57
|
}
|