@pouchy_ai/companion-sdk 0.28.1 → 0.29.1
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 +51 -1
- package/README.md +16 -0
- package/dist/client.d.ts +45 -9
- package/dist/client.js +112 -40
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +2 -1
- package/package.json +4 -3
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,54 @@ a protocol bump is always called out explicitly here.
|
|
|
12
12
|
|
|
13
13
|
## [Unreleased]
|
|
14
14
|
|
|
15
|
+
## [0.29.1] - 2026-07-12
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- **`CompanionError.retryAfter` no longer reports a spurious `0`.** A missing
|
|
20
|
+
`Retry-After` header was parsed as `Number(null) === 0`, so EVERY error
|
|
21
|
+
thrown from a POST without the header (a `400 invalid_request`, a
|
|
22
|
+
`404 session_not_found`, …) carried `retryAfter: 0` — telling backoff code
|
|
23
|
+
"retry immediately" on non-retryable errors. It is now `undefined` unless
|
|
24
|
+
the body's `retryAfterSec` or a real header names a value, as documented.
|
|
25
|
+
- **`sendText({ awaitReply: true, stream: false })` now cancels its POST.**
|
|
26
|
+
The buffered leg never passed the operation's `AbortSignal` to the network
|
|
27
|
+
call, so the promise rejected on abort/deadline but the billed turn kept
|
|
28
|
+
running server-side. It now aborts with the same linked controller the
|
|
29
|
+
streaming leg uses.
|
|
30
|
+
- **Abort-listener leak on a reused `AbortSignal` in `awaitReply`.** The
|
|
31
|
+
deadline's reject listener was never removed, so a host reusing one
|
|
32
|
+
long-lived signal across many `sendText({ awaitReply, signal })` calls
|
|
33
|
+
accumulated one orphan listener per call (memory growth + Node's
|
|
34
|
+
MaxListenersExceeded warning). Removed in the same `finally` as the rest.
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
|
|
38
|
+
- **`signal` on `sendToolResult` and `startCall`** — the two request-performing
|
|
39
|
+
methods 0.29.0's "AbortSignal on every network method" pass missed
|
|
40
|
+
(backwards-compatible addition, per this package's patch policy).
|
|
41
|
+
- **`require()` of the package now works on Node ≥ 20.17** (require-ESM): the
|
|
42
|
+
`exports` map gained a `default` condition — previously `require()` failed
|
|
43
|
+
with `ERR_PACKAGE_PATH_NOT_EXPORTED` before the feature could apply, despite
|
|
44
|
+
the README documenting it.
|
|
45
|
+
|
|
46
|
+
## [0.29.0] - 2026-07-12
|
|
47
|
+
|
|
48
|
+
### Added
|
|
49
|
+
|
|
50
|
+
- **`AbortSignal` on every network method.** All request-performing public
|
|
51
|
+
methods now accept an optional `signal` — cancel an in-flight `sendText`
|
|
52
|
+
(including its `awaitReply` deadline + event-stream teardown), `recall`,
|
|
53
|
+
`history`, `sendWorldState`, `ingestKnowledge`, `ingestFile`, `remember`,
|
|
54
|
+
`getAvatar`, `getWallet`, `confirmAction`, `pendingConfirms`,
|
|
55
|
+
`setModalities`, `ping`, `pairVisitor`, or `connect`. Methods with an
|
|
56
|
+
existing options object gained a `signal?` field; the rest gained a trailing
|
|
57
|
+
`opts?: { signal?: AbortSignal }`. Fully additive — no existing call
|
|
58
|
+
changes. A fired signal rejects with `CompanionError` `code: 'aborted'`
|
|
59
|
+
(new synthesized code), converted at the single fetch choke point so the
|
|
60
|
+
"every helper rejects with CompanionError" contract holds; the signal also
|
|
61
|
+
rides the 401-refresh retry automatically.
|
|
62
|
+
|
|
15
63
|
## [0.28.1] - 2026-07-12
|
|
16
64
|
|
|
17
65
|
### Fixed
|
|
@@ -599,7 +647,9 @@ self-hosted/CDN build; this entry captures the surface that ships publicly.
|
|
|
599
647
|
- **Versioned wire protocol.** `PROTOCOL_VERSION = 1`, kept in lockstep with the
|
|
600
648
|
server by `protocol.drift.test.ts` (fails CI on divergence).
|
|
601
649
|
|
|
602
|
-
[Unreleased]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.
|
|
650
|
+
[Unreleased]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.29.1...HEAD
|
|
651
|
+
[0.29.1]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.29.0...companion-sdk-v0.29.1
|
|
652
|
+
[0.29.0]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.28.1...companion-sdk-v0.29.0
|
|
603
653
|
[0.28.1]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.28.0...companion-sdk-v0.28.1
|
|
604
654
|
[0.28.0]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.27.0...companion-sdk-v0.28.0
|
|
605
655
|
[0.27.0]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.26.1...companion-sdk-v0.27.0
|
package/README.md
CHANGED
|
@@ -122,6 +122,22 @@ are offered automatically once you declare `tools`/`handles`, and
|
|
|
122
122
|
embeds receive expression cues — declare them or they no-op silently. Full
|
|
123
123
|
contract: `docs/companion-host-control.md`.
|
|
124
124
|
|
|
125
|
+
**Cancellation (0.29.0, completed in 0.29.1):** every request-performing method
|
|
126
|
+
accepts an optional `AbortSignal` — including `sendToolResult(callId, result,
|
|
127
|
+
{ signal })` and `startCall({ signal })` since 0.29.1. Methods with an options
|
|
128
|
+
object take a `signal?` field (`sendText(text, { signal })`,
|
|
129
|
+
`recall({ signal })`, …); the rest take a trailing `opts`
|
|
130
|
+
(`getAvatar({ signal })`, `ping({ signal })`, …). Aborting rejects with
|
|
131
|
+
`CompanionError` `code: 'aborted'`, and for `sendText({ awaitReply })` it also
|
|
132
|
+
tears down the in-flight event stream.
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
const ac = new AbortController();
|
|
136
|
+
const p = companion.sendText('summarize this', { awaitReply: true, signal: ac.signal });
|
|
137
|
+
// …user navigated away
|
|
138
|
+
ac.abort(); // p rejects with code: 'aborted'
|
|
139
|
+
```
|
|
140
|
+
|
|
125
141
|
## Scopes (typed)
|
|
126
142
|
|
|
127
143
|
The full capability-scope vocabulary ships as typed constants — no more
|
package/dist/client.d.ts
CHANGED
|
@@ -230,6 +230,11 @@ export declare class CompanionClient {
|
|
|
230
230
|
* the strict awaitReply correlation is only safe to enforce there. */
|
|
231
231
|
private serverEchoesReplyTo;
|
|
232
232
|
private fetchAuthed;
|
|
233
|
+
/** Normalize a thrown fetch error to a CompanionError. An AbortError (the
|
|
234
|
+
* caller's signal fired) becomes a switchable `code: 'aborted'`; anything
|
|
235
|
+
* else that is already a CompanionError passes through; the rest wrap as a
|
|
236
|
+
* status-0 network failure. */
|
|
237
|
+
private asCompanionError;
|
|
233
238
|
/** Peek the error `code` of a failure response without consuming its body. */
|
|
234
239
|
private errorCodeOf;
|
|
235
240
|
private url;
|
|
@@ -241,7 +246,9 @@ export declare class CompanionClient {
|
|
|
241
246
|
private retryAfterFrom;
|
|
242
247
|
private postJson;
|
|
243
248
|
/** Handshake: start or resume the session for this surface. */
|
|
244
|
-
connect(
|
|
249
|
+
connect(opts?: {
|
|
250
|
+
signal?: AbortSignal;
|
|
251
|
+
}): Promise<HelloAck>;
|
|
245
252
|
/** Pair this representative session's VISITOR with the owner so their two
|
|
246
253
|
* companions become friends — unlocking the A2A plane (messaging, gifts,
|
|
247
254
|
* visiting) between them. Only valid in a representative session (the client
|
|
@@ -250,7 +257,9 @@ export declare class CompanionClient {
|
|
|
250
257
|
* Two-token consent: this client's PAT (the owner's) must hold `represent:pair`,
|
|
251
258
|
* and you pass the VISITOR's own Pouchy PAT — which must hold `social.message`
|
|
252
259
|
* — as proof + consent. The visitor must therefore also be a Pouchy user. */
|
|
253
|
-
pairVisitor(visitorToken: string
|
|
260
|
+
pairVisitor(visitorToken: string, opts?: {
|
|
261
|
+
signal?: AbortSignal;
|
|
262
|
+
}): Promise<{
|
|
254
263
|
pairId: string | null;
|
|
255
264
|
}>;
|
|
256
265
|
/** Send a user text turn, optionally with images (data URLs) for a multimodal
|
|
@@ -279,12 +288,14 @@ export declare class CompanionClient {
|
|
|
279
288
|
images?: string[];
|
|
280
289
|
stream?: boolean;
|
|
281
290
|
replyTimeoutMs?: number;
|
|
291
|
+
signal?: AbortSignal;
|
|
282
292
|
}): Promise<SendTextReply>;
|
|
283
293
|
sendText(text: string, opts?: {
|
|
284
294
|
images?: string[];
|
|
285
295
|
stream?: boolean;
|
|
286
296
|
awaitReply?: boolean;
|
|
287
297
|
replyTimeoutMs?: number;
|
|
298
|
+
signal?: AbortSignal;
|
|
288
299
|
}): Promise<{
|
|
289
300
|
seq: number | null;
|
|
290
301
|
}>;
|
|
@@ -312,7 +323,9 @@ export declare class CompanionClient {
|
|
|
312
323
|
* (id → a fresh handle, source → this surface), so callers only supply the
|
|
313
324
|
* meaningful fields: `{ type, data, retained?, salience?, voiceRelevant? }`.
|
|
314
325
|
* A full CloudEvents envelope is still accepted (its fields win). */
|
|
315
|
-
sendWorldState(event: WorldStateInput | WorldStateInput[]
|
|
326
|
+
sendWorldState(event: WorldStateInput | WorldStateInput[], opts?: {
|
|
327
|
+
signal?: AbortSignal;
|
|
328
|
+
}): Promise<{
|
|
316
329
|
accepted: number;
|
|
317
330
|
dropped: number;
|
|
318
331
|
injected: number;
|
|
@@ -333,6 +346,7 @@ export declare class CompanionClient {
|
|
|
333
346
|
startCall(opts?: {
|
|
334
347
|
voice?: string;
|
|
335
348
|
locale?: string;
|
|
349
|
+
signal?: AbortSignal;
|
|
336
350
|
}): Promise<CallCredentials>;
|
|
337
351
|
/** Open a live voice call end-to-end: mints credentials (startCall) and opens a
|
|
338
352
|
* WebRTC session directly to the provider — no first-party app code needed.
|
|
@@ -375,6 +389,8 @@ export declare class CompanionClient {
|
|
|
375
389
|
sendToolResult(callId: string, result: {
|
|
376
390
|
ok?: boolean;
|
|
377
391
|
result?: unknown;
|
|
392
|
+
}, opts?: {
|
|
393
|
+
signal?: AbortSignal;
|
|
378
394
|
}): Promise<{
|
|
379
395
|
allDone: boolean;
|
|
380
396
|
}>;
|
|
@@ -404,6 +420,7 @@ export declare class CompanionClient {
|
|
|
404
420
|
recall(opts?: {
|
|
405
421
|
limit?: number;
|
|
406
422
|
query?: string;
|
|
423
|
+
signal?: AbortSignal;
|
|
407
424
|
}): Promise<RecalledMemory[]>;
|
|
408
425
|
/** Fetch this session's recent conversation turns (oldest→newest) so a
|
|
409
426
|
* reconnecting embed can restore its transcript. Distinct from `recall`
|
|
@@ -412,18 +429,23 @@ export declare class CompanionClient {
|
|
|
412
429
|
* first). `limit` defaults to 20, capped at 50. */
|
|
413
430
|
history(opts?: {
|
|
414
431
|
limit?: number;
|
|
432
|
+
signal?: AbortSignal;
|
|
415
433
|
}): Promise<CompanionTurn[]>;
|
|
416
434
|
/** Change this session's I/O modalities mid-session (e.g. enable/disable
|
|
417
435
|
* `voice`). The request is intersected with the token's granted modalities
|
|
418
436
|
* server-side — a session can't widen past its key — and the EFFECTIVE set is
|
|
419
437
|
* returned. Requires a live session. */
|
|
420
|
-
setModalities(modalities: string[]
|
|
438
|
+
setModalities(modalities: string[], opts?: {
|
|
439
|
+
signal?: AbortSignal;
|
|
440
|
+
}): Promise<{
|
|
421
441
|
modalities: string[];
|
|
422
442
|
}>;
|
|
423
443
|
/** Keepalive: bump this session's last-seen time so a long-idle embed stays
|
|
424
444
|
* "live" within the session TTL (cross-app A2A friend messages keep reaching
|
|
425
445
|
* it). Cheap; call it on a timer for a background tab. Requires a live session. */
|
|
426
|
-
ping(
|
|
446
|
+
ping(opts?: {
|
|
447
|
+
signal?: AbortSignal;
|
|
448
|
+
}): Promise<void>;
|
|
427
449
|
/** Remember a fact (into this app's namespace unless `namespace` is given and
|
|
428
450
|
* the token has the core scope). Intimate-tier writes are rejected server-side. */
|
|
429
451
|
remember(fact: {
|
|
@@ -436,6 +458,8 @@ export declare class CompanionClient {
|
|
|
436
458
|
kind?: string;
|
|
437
459
|
namespace?: string;
|
|
438
460
|
sensitivity?: 'public' | 'personal';
|
|
461
|
+
}, opts?: {
|
|
462
|
+
signal?: AbortSignal;
|
|
439
463
|
}): Promise<{
|
|
440
464
|
cloudId: string;
|
|
441
465
|
namespace: string;
|
|
@@ -459,6 +483,8 @@ export declare class CompanionClient {
|
|
|
459
483
|
name: string;
|
|
460
484
|
kind?: string;
|
|
461
485
|
locale?: string;
|
|
486
|
+
}, opts?: {
|
|
487
|
+
signal?: AbortSignal;
|
|
462
488
|
}): Promise<{
|
|
463
489
|
ok: boolean;
|
|
464
490
|
summary: string;
|
|
@@ -477,6 +503,8 @@ export declare class CompanionClient {
|
|
|
477
503
|
name: string;
|
|
478
504
|
kind?: string;
|
|
479
505
|
locale?: string;
|
|
506
|
+
}, opts?: {
|
|
507
|
+
signal?: AbortSignal;
|
|
480
508
|
}): Promise<{
|
|
481
509
|
ok: boolean;
|
|
482
510
|
summary: string;
|
|
@@ -488,13 +516,17 @@ export declare class CompanionClient {
|
|
|
488
516
|
* portrait when one exists (null today for built-in models). URLs are absolute
|
|
489
517
|
* and the /models asset is CORS-enabled, so a cross-origin renderer can fetch
|
|
490
518
|
* the VRM directly. Reflects the user's live choice (built-in or custom). */
|
|
491
|
-
getAvatar(
|
|
519
|
+
getAvatar(opts?: {
|
|
520
|
+
signal?: AbortSignal;
|
|
521
|
+
}): Promise<CompanionAvatar>;
|
|
492
522
|
/** Read the companion instance's OWN wallet — nonzero stablecoin balances +
|
|
493
523
|
* total USD. Read-only and receive-only (spends go through the confirm-gated
|
|
494
524
|
* pay tools, never here). Requires the `wallet.read` scope (or `wallet.spend`,
|
|
495
525
|
* which subsumes it). Same data the companion gives when asked "what's my
|
|
496
526
|
* balance?". */
|
|
497
|
-
getWallet(
|
|
527
|
+
getWallet(opts?: {
|
|
528
|
+
signal?: AbortSignal;
|
|
529
|
+
}): Promise<CompanionWallet>;
|
|
498
530
|
/** Canonical URL of the official Pouchy brand icon (square PNG, transparent
|
|
499
531
|
* background) at the given size. Static + public — needs no token — so it's
|
|
500
532
|
* safe to drop straight into an `<img src>` for "powered by Pouchy" badges,
|
|
@@ -549,7 +581,9 @@ export declare class CompanionClient {
|
|
|
549
581
|
* MAY call `confirmAction(sameConfirmId, true)` again to re-run it (the server
|
|
550
582
|
* dedupes a partial first attempt). A non-idempotent action (a message / skill)
|
|
551
583
|
* stays terminal and is never retryable. */
|
|
552
|
-
confirmAction(confirmId: string, approve: boolean
|
|
584
|
+
confirmAction(confirmId: string, approve: boolean, opts?: {
|
|
585
|
+
signal?: AbortSignal;
|
|
586
|
+
}): Promise<{
|
|
553
587
|
ok: boolean;
|
|
554
588
|
status: 'approved' | 'denied' | 'exec_failed';
|
|
555
589
|
outcome?: string;
|
|
@@ -559,7 +593,9 @@ export declare class CompanionClient {
|
|
|
559
593
|
* scope, timestamps — never raw args). Use it to rebuild your confirm card
|
|
560
594
|
* after a reload, since confirm_request events are not replayed. Platform
|
|
561
595
|
* session tokens only, like `confirmAction`. */
|
|
562
|
-
pendingConfirms(
|
|
596
|
+
pendingConfirms(opts?: {
|
|
597
|
+
signal?: AbortSignal;
|
|
598
|
+
}): Promise<PendingConfirm[]>;
|
|
563
599
|
/** Convenience: subscribe to live updates of an already-rendered Instant UI
|
|
564
600
|
* panel (companion.ui_update). Apply each `{ key, value }` to the panel's state
|
|
565
601
|
* bag and re-evaluate bound displays — no rebuild. */
|
package/dist/client.js
CHANGED
|
@@ -120,7 +120,16 @@ export class CompanionClient {
|
|
|
120
120
|
return major > 1 || (major === 1 && minor >= 1);
|
|
121
121
|
}
|
|
122
122
|
async fetchAuthed(url, init) {
|
|
123
|
-
|
|
123
|
+
let res;
|
|
124
|
+
try {
|
|
125
|
+
res = await this.doFetch(url, init);
|
|
126
|
+
}
|
|
127
|
+
catch (e) {
|
|
128
|
+
// A caller-supplied AbortSignal makes fetch throw a native AbortError —
|
|
129
|
+
// convert it at this single choke point so cancellation honors the
|
|
130
|
+
// "every helper rejects with CompanionError" contract (code 'aborted').
|
|
131
|
+
throw this.asCompanionError(e);
|
|
132
|
+
}
|
|
124
133
|
const apiVersion = res.headers.get('x-pouchy-api-version');
|
|
125
134
|
if (apiVersion)
|
|
126
135
|
this.serverApiVersion = apiVersion;
|
|
@@ -135,7 +144,24 @@ export class CompanionClient {
|
|
|
135
144
|
if (!(await this.tryRefreshToken()))
|
|
136
145
|
return res;
|
|
137
146
|
init.headers.Authorization = `Bearer ${this.opts.token}`;
|
|
138
|
-
|
|
147
|
+
try {
|
|
148
|
+
return await this.doFetch(url, init);
|
|
149
|
+
}
|
|
150
|
+
catch (e) {
|
|
151
|
+
throw this.asCompanionError(e);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/** Normalize a thrown fetch error to a CompanionError. An AbortError (the
|
|
155
|
+
* caller's signal fired) becomes a switchable `code: 'aborted'`; anything
|
|
156
|
+
* else that is already a CompanionError passes through; the rest wrap as a
|
|
157
|
+
* status-0 network failure. */
|
|
158
|
+
asCompanionError(e) {
|
|
159
|
+
if (e instanceof CompanionError)
|
|
160
|
+
return e;
|
|
161
|
+
const name = e?.name;
|
|
162
|
+
if (name === 'AbortError')
|
|
163
|
+
return new CompanionError('request aborted', 0, 'aborted');
|
|
164
|
+
return new CompanionError(e instanceof Error ? e.message : 'network request failed', 0);
|
|
139
165
|
}
|
|
140
166
|
/** Peek the error `code` of a failure response without consuming its body. */
|
|
141
167
|
async errorCodeOf(res) {
|
|
@@ -165,14 +191,23 @@ export class CompanionClient {
|
|
|
165
191
|
const fromBody = json?.retryAfterSec;
|
|
166
192
|
if (typeof fromBody === 'number' && Number.isFinite(fromBody) && fromBody >= 0)
|
|
167
193
|
return fromBody;
|
|
168
|
-
|
|
194
|
+
// Missing/blank header must stay undefined — Number(null) is 0, which
|
|
195
|
+
// used to stamp retryAfter: 0 on EVERY error without the header (a 400,
|
|
196
|
+
// a 404 …), telling backoff code "retry immediately".
|
|
197
|
+
const raw = res.headers.get('Retry-After');
|
|
198
|
+
if (raw === null || raw.trim() === '')
|
|
199
|
+
return undefined;
|
|
200
|
+
const header = Number(raw);
|
|
169
201
|
return Number.isFinite(header) && header >= 0 ? header : undefined;
|
|
170
202
|
}
|
|
171
|
-
async postJson(path, body) {
|
|
203
|
+
async postJson(path, body, signal) {
|
|
172
204
|
const res = await this.fetchAuthed(this.url(path), {
|
|
173
205
|
method: 'POST',
|
|
174
206
|
headers: this.jsonHeaders(),
|
|
175
|
-
body: JSON.stringify(body)
|
|
207
|
+
body: JSON.stringify(body),
|
|
208
|
+
// The signal rides `init`, so the 401-refresh retry in fetchAuthed
|
|
209
|
+
// reuses it automatically (init is passed to both doFetch calls).
|
|
210
|
+
...(signal ? { signal } : {})
|
|
176
211
|
});
|
|
177
212
|
const json = (await res.json().catch(() => null));
|
|
178
213
|
if (!res.ok || !json || json.ok === false) {
|
|
@@ -185,7 +220,7 @@ export class CompanionClient {
|
|
|
185
220
|
return json;
|
|
186
221
|
}
|
|
187
222
|
/** Handshake: start or resume the session for this surface. */
|
|
188
|
-
async connect() {
|
|
223
|
+
async connect(opts) {
|
|
189
224
|
const json = await this.postJson('/api/companion/session', {
|
|
190
225
|
surface: this.opts.surface ?? 'default',
|
|
191
226
|
modalities: this.opts.modalities,
|
|
@@ -194,7 +229,7 @@ export class CompanionClient {
|
|
|
194
229
|
tools: this.opts.tools,
|
|
195
230
|
appContext: this.opts.appContext,
|
|
196
231
|
...(this.opts.visitor ? { visitor: this.opts.visitor } : {})
|
|
197
|
-
});
|
|
232
|
+
}, opts?.signal);
|
|
198
233
|
this._session = json.session;
|
|
199
234
|
this.cursor = json.resumeCursor ?? 0;
|
|
200
235
|
return {
|
|
@@ -211,7 +246,7 @@ export class CompanionClient {
|
|
|
211
246
|
* Two-token consent: this client's PAT (the owner's) must hold `represent:pair`,
|
|
212
247
|
* and you pass the VISITOR's own Pouchy PAT — which must hold `social.message`
|
|
213
248
|
* — as proof + consent. The visitor must therefore also be a Pouchy user. */
|
|
214
|
-
async pairVisitor(visitorToken) {
|
|
249
|
+
async pairVisitor(visitorToken, opts) {
|
|
215
250
|
if (!this.opts.visitor) {
|
|
216
251
|
throw new CompanionError('pairVisitor() requires a representative session (createCompanion({ visitor }))', 0, 'not_representative');
|
|
217
252
|
}
|
|
@@ -220,7 +255,7 @@ export class CompanionClient {
|
|
|
220
255
|
const json = await this.postJson('/api/companion/pair', {
|
|
221
256
|
visitorToken,
|
|
222
257
|
visitorId: this.opts.visitor.id
|
|
223
|
-
});
|
|
258
|
+
}, opts?.signal);
|
|
224
259
|
return { pairId: json.pairId ?? null };
|
|
225
260
|
}
|
|
226
261
|
async sendText(text, opts) {
|
|
@@ -235,10 +270,10 @@ export class CompanionClient {
|
|
|
235
270
|
return this.sendTextAwaitingReply(id, body, opts, turnId);
|
|
236
271
|
const wantStream = opts?.stream ?? this.deltaHandlers.size > 0;
|
|
237
272
|
if (!wantStream) {
|
|
238
|
-
const json = await this.postJson(`/api/companion/session/${id}/input`, body);
|
|
273
|
+
const json = await this.postJson(`/api/companion/session/${id}/input`, body, opts?.signal);
|
|
239
274
|
return { seq: json.seq ?? null };
|
|
240
275
|
}
|
|
241
|
-
return this.sendTextStreaming(id, body);
|
|
276
|
+
return this.sendTextStreaming(id, body, opts?.signal);
|
|
242
277
|
}
|
|
243
278
|
/** The awaitReply leg of sendText. Prefers the in-request answer (the
|
|
244
279
|
* streaming `done` frame's envelope); falls back to the next
|
|
@@ -265,12 +300,31 @@ export class CompanionClient {
|
|
|
265
300
|
// promise past replyTimeoutMs (previously only the fallback wait was
|
|
266
301
|
// bounded). The abort tears the in-flight stream down with it.
|
|
267
302
|
const ac = new AbortController();
|
|
303
|
+
// Link the caller's signal: aborting it tears down the in-flight stream
|
|
304
|
+
// (via ac) AND rejects the whole operation with `aborted`, exactly like
|
|
305
|
+
// the deadline does. If it is already aborted, fail fast.
|
|
306
|
+
let onCallerAbort;
|
|
307
|
+
if (opts?.signal) {
|
|
308
|
+
if (opts.signal.aborted)
|
|
309
|
+
throw new CompanionError('request aborted', 0, 'aborted');
|
|
310
|
+
onCallerAbort = () => ac.abort();
|
|
311
|
+
opts.signal.addEventListener('abort', onCallerAbort, { once: true });
|
|
312
|
+
}
|
|
268
313
|
let timer;
|
|
314
|
+
// Held so the finally can REMOVE it — a host reusing one long-lived signal
|
|
315
|
+
// across many awaitReply calls accumulated one orphan listener per call.
|
|
316
|
+
let onCallerAbortReject;
|
|
269
317
|
const deadline = new Promise((_, reject) => {
|
|
270
318
|
timer = setTimeout(() => {
|
|
271
319
|
ac.abort();
|
|
272
320
|
reject(new CompanionError(`timed out after ${timeoutMs}ms waiting for the reply — buffered mode needs start() polling the event stream`, 0, 'reply_timeout'));
|
|
273
321
|
}, timeoutMs);
|
|
322
|
+
// The caller's signal also rejects the operation (the stream teardown
|
|
323
|
+
// alone would otherwise leave the fallback wait hanging to the timer).
|
|
324
|
+
if (opts?.signal) {
|
|
325
|
+
onCallerAbortReject = () => reject(new CompanionError('request aborted', 0, 'aborted'));
|
|
326
|
+
opts.signal.addEventListener('abort', onCallerAbortReject, { once: true });
|
|
327
|
+
}
|
|
274
328
|
});
|
|
275
329
|
try {
|
|
276
330
|
let seq = null;
|
|
@@ -278,7 +332,10 @@ export class CompanionClient {
|
|
|
278
332
|
let pausedOnTools = false;
|
|
279
333
|
if (opts?.stream === false) {
|
|
280
334
|
const json = await Promise.race([
|
|
281
|
-
|
|
335
|
+
// ac.signal, like the streaming leg: the deadline and the caller's
|
|
336
|
+
// signal both abort it — without this the promise rejected but the
|
|
337
|
+
// billed POST kept running beyond cancellation.
|
|
338
|
+
this.postJson(`/api/companion/session/${sessionId}/input`, body, ac.signal),
|
|
282
339
|
deadline
|
|
283
340
|
]);
|
|
284
341
|
seq = json.seq ?? null;
|
|
@@ -308,6 +365,10 @@ export class CompanionClient {
|
|
|
308
365
|
off();
|
|
309
366
|
if (timer !== undefined)
|
|
310
367
|
clearTimeout(timer);
|
|
368
|
+
if (onCallerAbort)
|
|
369
|
+
opts?.signal?.removeEventListener('abort', onCallerAbort);
|
|
370
|
+
if (onCallerAbortReject)
|
|
371
|
+
opts?.signal?.removeEventListener('abort', onCallerAbortReject);
|
|
311
372
|
}
|
|
312
373
|
}
|
|
313
374
|
/** The streaming leg of sendText: parse the POST response's SSE frames —
|
|
@@ -430,7 +491,7 @@ export class CompanionClient {
|
|
|
430
491
|
* (id → a fresh handle, source → this surface), so callers only supply the
|
|
431
492
|
* meaningful fields: `{ type, data, retained?, salience?, voiceRelevant? }`.
|
|
432
493
|
* A full CloudEvents envelope is still accepted (its fields win). */
|
|
433
|
-
async sendWorldState(event) {
|
|
494
|
+
async sendWorldState(event, opts) {
|
|
434
495
|
const id = this.requireSession();
|
|
435
496
|
const fill = (e) => ({
|
|
436
497
|
...e,
|
|
@@ -439,7 +500,7 @@ export class CompanionClient {
|
|
|
439
500
|
source: e.source ?? this.opts.surface ?? 'companion-sdk'
|
|
440
501
|
});
|
|
441
502
|
const body = Array.isArray(event) ? { events: event.map(fill) } : fill(event);
|
|
442
|
-
const json = await this.postJson(`/api/companion/session/${id}/context`, body);
|
|
503
|
+
const json = await this.postJson(`/api/companion/session/${id}/context`, body, opts?.signal);
|
|
443
504
|
// injected/reacted tell the host a live-call inject or a proactive text
|
|
444
505
|
// reaction was triggered by this batch (0.28.0 — previously discarded).
|
|
445
506
|
return {
|
|
@@ -466,10 +527,7 @@ export class CompanionClient {
|
|
|
466
527
|
*/
|
|
467
528
|
async startCall(opts) {
|
|
468
529
|
const id = this.requireSession();
|
|
469
|
-
return this.postJson(`/api/companion/session/${id}/call`, {
|
|
470
|
-
voice: opts?.voice,
|
|
471
|
-
locale: opts?.locale
|
|
472
|
-
});
|
|
530
|
+
return this.postJson(`/api/companion/session/${id}/call`, { voice: opts?.voice, locale: opts?.locale }, opts?.signal);
|
|
473
531
|
}
|
|
474
532
|
/** Open a live voice call end-to-end: mints credentials (startCall) and opens a
|
|
475
533
|
* WebRTC session directly to the provider — no first-party app code needed.
|
|
@@ -605,7 +663,7 @@ export class CompanionClient {
|
|
|
605
663
|
/** Report the result of a companion.tool_call this surface performed. When all
|
|
606
664
|
* of the turn's calls are reported, the companion resumes and the continuation
|
|
607
665
|
* (a companion.message or more tool calls) arrives on the event stream. */
|
|
608
|
-
async sendToolResult(callId, result) {
|
|
666
|
+
async sendToolResult(callId, result, opts) {
|
|
609
667
|
// Voice tool-call: resolve it locally so the result flows back to the voice
|
|
610
668
|
// provider, not to a server text turn. (Same call shape as the text path.)
|
|
611
669
|
const pending = this.pendingVoiceTools.get(callId);
|
|
@@ -614,7 +672,7 @@ export class CompanionClient {
|
|
|
614
672
|
return { allDone: true };
|
|
615
673
|
}
|
|
616
674
|
const id = this.requireSession();
|
|
617
|
-
const json = await this.postJson(`/api/companion/session/${id}/tool-result`, { callId, ok: result.ok !== false, result: result.result });
|
|
675
|
+
const json = await this.postJson(`/api/companion/session/${id}/tool-result`, { callId, ok: result.ok !== false, result: result.result }, opts?.signal);
|
|
618
676
|
return { allDone: json.allDone ?? false };
|
|
619
677
|
}
|
|
620
678
|
/** A tool the live voice call asked the app to run. Fires the SAME
|
|
@@ -736,7 +794,8 @@ export class CompanionClient {
|
|
|
736
794
|
const query = params.toString();
|
|
737
795
|
const qs = query ? `?${query}` : '';
|
|
738
796
|
const res = await this.fetchAuthed(this.url(`/api/companion/memory${qs}`), {
|
|
739
|
-
headers: { Authorization: `Bearer ${this.opts.token}` }
|
|
797
|
+
headers: { Authorization: `Bearer ${this.opts.token}` },
|
|
798
|
+
...(opts?.signal ? { signal: opts.signal } : {})
|
|
740
799
|
});
|
|
741
800
|
const json = (await res.json().catch(() => null));
|
|
742
801
|
if (!res.ok || !json || json.ok === false) {
|
|
@@ -752,7 +811,10 @@ export class CompanionClient {
|
|
|
752
811
|
async history(opts) {
|
|
753
812
|
const id = this.requireSession();
|
|
754
813
|
const qs = opts?.limit ? `?limit=${encodeURIComponent(opts.limit)}` : '';
|
|
755
|
-
const res = await this.fetchAuthed(this.url(`/api/companion/session/${encodeURIComponent(id)}/history${qs}`), {
|
|
814
|
+
const res = await this.fetchAuthed(this.url(`/api/companion/session/${encodeURIComponent(id)}/history${qs}`), {
|
|
815
|
+
headers: { Authorization: `Bearer ${this.opts.token}` },
|
|
816
|
+
...(opts?.signal ? { signal: opts.signal } : {})
|
|
817
|
+
});
|
|
756
818
|
const json = (await res.json().catch(() => null));
|
|
757
819
|
if (!res.ok || !json || json.ok === false) {
|
|
758
820
|
throw new CompanionError(json?.error || `history failed (${res.status})`, res.status, json?.code);
|
|
@@ -763,7 +825,7 @@ export class CompanionClient {
|
|
|
763
825
|
* `voice`). The request is intersected with the token's granted modalities
|
|
764
826
|
* server-side — a session can't widen past its key — and the EFFECTIVE set is
|
|
765
827
|
* returned. Requires a live session. */
|
|
766
|
-
async setModalities(modalities) {
|
|
828
|
+
async setModalities(modalities, opts) {
|
|
767
829
|
const id = this.requireSession();
|
|
768
830
|
const res = await this.fetchAuthed(this.url(`/api/companion/session/${encodeURIComponent(id)}/modalities`), {
|
|
769
831
|
method: 'POST',
|
|
@@ -771,7 +833,8 @@ export class CompanionClient {
|
|
|
771
833
|
Authorization: `Bearer ${this.opts.token}`,
|
|
772
834
|
'Content-Type': 'application/json'
|
|
773
835
|
},
|
|
774
|
-
body: JSON.stringify({ modalities })
|
|
836
|
+
body: JSON.stringify({ modalities }),
|
|
837
|
+
...(opts?.signal ? { signal: opts.signal } : {})
|
|
775
838
|
});
|
|
776
839
|
const json = (await res.json().catch(() => null));
|
|
777
840
|
if (!res.ok || !json || json.ok === false) {
|
|
@@ -782,9 +845,13 @@ export class CompanionClient {
|
|
|
782
845
|
/** Keepalive: bump this session's last-seen time so a long-idle embed stays
|
|
783
846
|
* "live" within the session TTL (cross-app A2A friend messages keep reaching
|
|
784
847
|
* it). Cheap; call it on a timer for a background tab. Requires a live session. */
|
|
785
|
-
async ping() {
|
|
848
|
+
async ping(opts) {
|
|
786
849
|
const id = this.requireSession();
|
|
787
|
-
const res = await this.fetchAuthed(this.url(`/api/companion/session/${encodeURIComponent(id)}/ping`), {
|
|
850
|
+
const res = await this.fetchAuthed(this.url(`/api/companion/session/${encodeURIComponent(id)}/ping`), {
|
|
851
|
+
method: 'POST',
|
|
852
|
+
headers: { Authorization: `Bearer ${this.opts.token}` },
|
|
853
|
+
...(opts?.signal ? { signal: opts.signal } : {})
|
|
854
|
+
});
|
|
788
855
|
if (!res.ok) {
|
|
789
856
|
const json = (await res.json().catch(() => null));
|
|
790
857
|
throw new CompanionError(json?.error || `ping failed (${res.status})`, res.status, json?.code);
|
|
@@ -792,8 +859,8 @@ export class CompanionClient {
|
|
|
792
859
|
}
|
|
793
860
|
/** Remember a fact (into this app's namespace unless `namespace` is given and
|
|
794
861
|
* the token has the core scope). Intimate-tier writes are rejected server-side. */
|
|
795
|
-
async remember(fact) {
|
|
796
|
-
return this.postJson('/api/companion/memory', fact);
|
|
862
|
+
async remember(fact, opts) {
|
|
863
|
+
return this.postJson('/api/companion/memory', fact, opts?.signal);
|
|
797
864
|
}
|
|
798
865
|
/** Ingest a DOCUMENT into the user's knowledge base. Unlike `remember` (one
|
|
799
866
|
* short fact), this distils already-extracted text — a PDF body, a meeting
|
|
@@ -809,8 +876,8 @@ export class CompanionClient {
|
|
|
809
876
|
* are computed on the user's next first-party open (eventually-consistent
|
|
810
877
|
* semantic recall). `kind` is a free label for the source type (pdf / audio /
|
|
811
878
|
* note / …) used only for display + the materials icon. */
|
|
812
|
-
async ingestKnowledge(doc) {
|
|
813
|
-
return this.postJson('/api/companion/knowledge', doc);
|
|
879
|
+
async ingestKnowledge(doc, opts) {
|
|
880
|
+
return this.postJson('/api/companion/knowledge', doc, opts?.signal);
|
|
814
881
|
}
|
|
815
882
|
/** Convenience over `ingestKnowledge`: hand over a RAW file and Pouchy
|
|
816
883
|
* understands it server-side before ingesting — no parser/transcriber/vision on
|
|
@@ -820,8 +887,8 @@ export class CompanionClient {
|
|
|
820
887
|
* material). For other types, extract the text yourself and call
|
|
821
888
|
* `ingestKnowledge`. Same `memory.write:core` requirement and "My materials"
|
|
822
889
|
* integration as `ingestKnowledge`. */
|
|
823
|
-
async ingestFile(file) {
|
|
824
|
-
return this.postJson('/api/companion/knowledge/file', file);
|
|
890
|
+
async ingestFile(file, opts) {
|
|
891
|
+
return this.postJson('/api/companion/knowledge/file', file, opts?.signal);
|
|
825
892
|
}
|
|
826
893
|
/** Fetch the user's CURRENT companion avatar so the embedding can render the
|
|
827
894
|
* same virtual human Pouchy shows. The avatar is a VRM 3D model (`vrmUrl`,
|
|
@@ -829,9 +896,10 @@ export class CompanionClient {
|
|
|
829
896
|
* portrait when one exists (null today for built-in models). URLs are absolute
|
|
830
897
|
* and the /models asset is CORS-enabled, so a cross-origin renderer can fetch
|
|
831
898
|
* the VRM directly. Reflects the user's live choice (built-in or custom). */
|
|
832
|
-
async getAvatar() {
|
|
899
|
+
async getAvatar(opts) {
|
|
833
900
|
const res = await this.fetchAuthed(this.url('/api/companion/avatar'), {
|
|
834
|
-
headers: { Authorization: `Bearer ${this.opts.token}` }
|
|
901
|
+
headers: { Authorization: `Bearer ${this.opts.token}` },
|
|
902
|
+
...(opts?.signal ? { signal: opts.signal } : {})
|
|
835
903
|
});
|
|
836
904
|
const json = (await res.json().catch(() => null));
|
|
837
905
|
if (!res.ok || !json || json.ok === false) {
|
|
@@ -850,9 +918,10 @@ export class CompanionClient {
|
|
|
850
918
|
* pay tools, never here). Requires the `wallet.read` scope (or `wallet.spend`,
|
|
851
919
|
* which subsumes it). Same data the companion gives when asked "what's my
|
|
852
920
|
* balance?". */
|
|
853
|
-
async getWallet() {
|
|
921
|
+
async getWallet(opts) {
|
|
854
922
|
const res = await this.fetchAuthed(this.url('/api/companion/wallet'), {
|
|
855
|
-
headers: { Authorization: `Bearer ${this.opts.token}` }
|
|
923
|
+
headers: { Authorization: `Bearer ${this.opts.token}` },
|
|
924
|
+
...(opts?.signal ? { signal: opts.signal } : {})
|
|
856
925
|
});
|
|
857
926
|
const json = (await res.json().catch(() => null));
|
|
858
927
|
if (!res.ok || !json || json.ok === false) {
|
|
@@ -957,17 +1026,20 @@ export class CompanionClient {
|
|
|
957
1026
|
* MAY call `confirmAction(sameConfirmId, true)` again to re-run it (the server
|
|
958
1027
|
* dedupes a partial first attempt). A non-idempotent action (a message / skill)
|
|
959
1028
|
* stays terminal and is never retryable. */
|
|
960
|
-
async confirmAction(confirmId, approve) {
|
|
1029
|
+
async confirmAction(confirmId, approve, opts) {
|
|
961
1030
|
const sessionId = this.requireSession();
|
|
962
|
-
return this.postJson(`/api/companion/session/${encodeURIComponent(sessionId)}/confirm`, { confirmId, approve });
|
|
1031
|
+
return this.postJson(`/api/companion/session/${encodeURIComponent(sessionId)}/confirm`, { confirmId, approve }, opts?.signal);
|
|
963
1032
|
}
|
|
964
1033
|
/** The session's still-pending confirmations (display-safe: id, summary,
|
|
965
1034
|
* scope, timestamps — never raw args). Use it to rebuild your confirm card
|
|
966
1035
|
* after a reload, since confirm_request events are not replayed. Platform
|
|
967
1036
|
* session tokens only, like `confirmAction`. */
|
|
968
|
-
async pendingConfirms() {
|
|
1037
|
+
async pendingConfirms(opts) {
|
|
969
1038
|
const sessionId = this.requireSession();
|
|
970
|
-
const res = await this.fetchAuthed(this.url(`/api/companion/session/${encodeURIComponent(sessionId)}/confirm`), {
|
|
1039
|
+
const res = await this.fetchAuthed(this.url(`/api/companion/session/${encodeURIComponent(sessionId)}/confirm`), {
|
|
1040
|
+
headers: { Authorization: `Bearer ${this.opts.token}` },
|
|
1041
|
+
...(opts?.signal ? { signal: opts.signal } : {})
|
|
1042
|
+
});
|
|
971
1043
|
const json = (await res.json().catch(() => null));
|
|
972
1044
|
if (!res.ok || !json || json.ok === false) {
|
|
973
1045
|
throw new CompanionError(json?.error || `pending confirms failed (${res.status})`, res.status, json?.code);
|
package/dist/errors.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { CompanionErrorCode } from './protocol.js';
|
|
|
2
2
|
/** Codes the SDK itself synthesizes (status 0 — never sent by the server).
|
|
3
3
|
* Runtime array so the drift test can assert every `new CompanionError(...)`
|
|
4
4
|
* call site uses a declared code; APPEND-ONLY like the server vocabulary. */
|
|
5
|
-
export declare const SDK_SYNTHESIZED_ERROR_CODES: readonly ["reply_timeout", "needs_event_stream", "not_connected", "missing_option", "not_representative", "call_unsupported", "call_connect_failed", "call_dependency_missing"];
|
|
5
|
+
export declare const SDK_SYNTHESIZED_ERROR_CODES: readonly ["reply_timeout", "needs_event_stream", "not_connected", "missing_option", "not_representative", "call_unsupported", "call_connect_failed", "call_dependency_missing", "aborted"];
|
|
6
6
|
export type SdkSynthesizedErrorCode = (typeof SDK_SYNTHESIZED_ERROR_CODES)[number];
|
|
7
7
|
/** Thrown by every helper on an HTTP or client-side failure. `status` is the
|
|
8
8
|
* HTTP status (0 for client-synthesized failures — timeouts, unsupported
|
package/dist/errors.js
CHANGED
|
@@ -14,7 +14,8 @@ export const SDK_SYNTHESIZED_ERROR_CODES = [
|
|
|
14
14
|
'not_representative', // pairVisitor on a non-representative token
|
|
15
15
|
'call_unsupported', // startCall on a server/provider without voice
|
|
16
16
|
'call_connect_failed', // WebRTC/provider handshake failed
|
|
17
|
-
'call_dependency_missing' // optional voice dependency not installed
|
|
17
|
+
'call_dependency_missing', // optional voice dependency not installed
|
|
18
|
+
'aborted' // a caller-supplied AbortSignal cancelled the request (0.29.0)
|
|
18
19
|
];
|
|
19
20
|
export class CompanionError extends Error {
|
|
20
21
|
status;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pouchy_ai/companion-sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Embed the Pouchy companion
|
|
3
|
+
"version": "0.29.1",
|
|
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",
|
|
7
7
|
"homepage": "https://pouchy.ai",
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
"exports": {
|
|
17
17
|
".": {
|
|
18
18
|
"types": "./dist/index.d.ts",
|
|
19
|
-
"import": "./dist/index.js"
|
|
19
|
+
"import": "./dist/index.js",
|
|
20
|
+
"default": "./dist/index.js"
|
|
20
21
|
}
|
|
21
22
|
},
|
|
22
23
|
"main": "./dist/index.js",
|