@pouchy_ai/companion-sdk 0.20.0 → 0.21.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 +29 -0
- package/README.md +4 -3
- package/dist/client.d.ts +27 -1
- package/dist/client.js +72 -12
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,35 @@ a protocol bump is always called out explicitly here.
|
|
|
12
12
|
|
|
13
13
|
## [Unreleased]
|
|
14
14
|
|
|
15
|
+
## [0.21.0] - 2026-07-11
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- **Token refresh:** `client.setToken(token)` swaps the bearer used by every
|
|
20
|
+
subsequent request and stream reconnect, and the new `onAuthError` client
|
|
21
|
+
option refreshes on demand — a 401 (REST **or** event stream) calls it; return
|
|
22
|
+
a fresh token (e.g. re-minted via `POST /v1/sessions`) and the client retries
|
|
23
|
+
transparently, return `null` to surface the failure exactly as before.
|
|
24
|
+
Concurrent 401s share one refresh; the stream loop caps consecutive
|
|
25
|
+
refresh-and-reject cycles at 3 so a bad hook can't hot-loop. Previously a
|
|
26
|
+
1-hour session token expiring mid-embed silently stopped reply delivery
|
|
27
|
+
(`stream_unauthorized`) with no recovery short of rebuilding the client.
|
|
28
|
+
- **Semantic recall:** `recall({ query })` — the server has always supported
|
|
29
|
+
`?q=` semantic search over the token-visible facts; the client now exposes it.
|
|
30
|
+
Without `query` the ranking is importance/recency, as before.
|
|
31
|
+
- **Machine-readable error codes:** companion API error responses now carry a
|
|
32
|
+
stable `code` (`missing_token` / `invalid_token` / `missing_scope` /
|
|
33
|
+
`invalid_request` / `session_not_found` / `turn_pending` / `no_pending_tools` /
|
|
34
|
+
`unknown_call` / `payload_too_large` / `forbidden` / `unavailable`), so
|
|
35
|
+
`CompanionError.code` is finally switchable for HTTP failures — the type
|
|
36
|
+
always promised it; the server now delivers it. The vocabulary is append-only.
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
|
|
40
|
+
- README method table showed pre-0.18 signatures for `recall` / `remember` /
|
|
41
|
+
`ingestKnowledge` / `history` (positional args instead of the actual object
|
|
42
|
+
args). Corrected to match the shipped API.
|
|
43
|
+
|
|
15
44
|
## [0.20.0] - 2026-07-11
|
|
16
45
|
|
|
17
46
|
### Added
|
package/README.md
CHANGED
|
@@ -96,11 +96,12 @@ local/device skills).
|
|
|
96
96
|
|
|
97
97
|
| Method | What it does |
|
|
98
98
|
|---|---|
|
|
99
|
-
| `recall(query)` / `remember(
|
|
100
|
-
| `ingestKnowledge(text,
|
|
101
|
-
| `history(limit?)` | Fetch the session transcript — restore chat on reload. |
|
|
99
|
+
| `recall({ query?, limit? })` / `remember({ content, … })` | Read / write the token-scoped memory namespace. `query` runs SEMANTIC search over the facts server-side (embedding-based, ranked fallback); omit it for the plain importance/recency ranking. |
|
|
100
|
+
| `ingestKnowledge({ text, name, … })` / `ingestFile(file)` | Push documents into the user's knowledge base ("My materials"); needs `memory.write:core`. |
|
|
101
|
+
| `history({ limit? })` | Fetch the session transcript — restore chat on reload. |
|
|
102
102
|
| `setModalities([...])` | Switch active I/O mid-session (within the token's grant). |
|
|
103
103
|
| `ping()` | Keepalive for long-idle embeds. |
|
|
104
|
+
| `setToken(token)` | Swap the bearer for every subsequent request + stream reconnect — session tokens expire (1h default), so call this when your backend re-mints one. Prefer the `onAuthError` constructor option to refresh on demand instead. |
|
|
104
105
|
| `pendingConfirms()` / `confirmAction(id, approve)` | List / resolve pending sensitive-action confirms (platform session tokens). On `{ status: 'exec_failed', retryable: true }` you MAY re-call the same confirmId — idempotent actions only. |
|
|
105
106
|
| `endSession()` | Distill the session into durable memory now; returns `{ skipped?: 'no_session' \| 'no_content' \| 'throttled' }`. |
|
|
106
107
|
| `close()` | One-call teardown: `stop()` + `endSession()` — the text-session mirror of the call handle's `close()`. |
|
package/dist/client.d.ts
CHANGED
|
@@ -47,6 +47,14 @@ export interface CompanionClientOptions {
|
|
|
47
47
|
/** Injectable WebSocket constructor (Node without a global WebSocket, or
|
|
48
48
|
* tests). Defaults to globalThis.WebSocket when present. */
|
|
49
49
|
webSocketImpl?: typeof WebSocket;
|
|
50
|
+
/** Called when a request or the event stream is rejected with 401 (expired /
|
|
51
|
+
* revoked token — session tokens live 1h by default). Return a fresh token
|
|
52
|
+
* (e.g. re-minted by your backend via POST /v1/sessions) and the client
|
|
53
|
+
* retries transparently; return null to give up, surfacing the failure
|
|
54
|
+
* exactly as without this hook. Scope denials (403 `missing_scope`) are
|
|
55
|
+
* configuration errors and never trigger it. Alternative: call `setToken()`
|
|
56
|
+
* proactively on your own refresh schedule. */
|
|
57
|
+
onAuthError?: () => string | null | Promise<string | null>;
|
|
50
58
|
}
|
|
51
59
|
export interface HelloAck {
|
|
52
60
|
session: string;
|
|
@@ -190,6 +198,19 @@ export declare class CompanionClient {
|
|
|
190
198
|
constructor(opts: CompanionClientOptions);
|
|
191
199
|
/** The active session id, or null before connect(). */
|
|
192
200
|
get sessionId(): string | null;
|
|
201
|
+
/** Swap the bearer token used by every subsequent request and stream
|
|
202
|
+
* reconnect. Session tokens expire (default 1h) — call this when your
|
|
203
|
+
* backend re-mints one, or wire `onAuthError` to do it on demand. */
|
|
204
|
+
setToken(token: string): void;
|
|
205
|
+
private refreshing;
|
|
206
|
+
/** Run opts.onAuthError (deduped), apply the fresh token, and report whether
|
|
207
|
+
* the caller should retry. Never throws — a failed refresh means "surface
|
|
208
|
+
* the original 401". */
|
|
209
|
+
private tryRefreshToken;
|
|
210
|
+
/** doFetch with one transparent retry after a 401-triggered token refresh.
|
|
211
|
+
* Everything except the stream loop (which has its own auth handling) goes
|
|
212
|
+
* through here. */
|
|
213
|
+
private fetchAuthed;
|
|
193
214
|
private url;
|
|
194
215
|
private jsonHeaders;
|
|
195
216
|
private requireSession;
|
|
@@ -324,9 +345,14 @@ export declare class CompanionClient {
|
|
|
324
345
|
code: string;
|
|
325
346
|
message: string;
|
|
326
347
|
}, envelope: CompanionEnvelope) => void): () => void;
|
|
327
|
-
/** Recall the memory this token is authorized to see
|
|
348
|
+
/** Recall the memory this token is authorized to see. Without `query` the
|
|
349
|
+
* facts come back ranked by importance/recency; with `query` the server
|
|
350
|
+
* runs semantic search over them (embedding-based, falls back to ranked
|
|
351
|
+
* when embeddings are unavailable) — e.g.
|
|
352
|
+
* `recall({ query: 'food preferences', limit: 10 })`. */
|
|
328
353
|
recall(opts?: {
|
|
329
354
|
limit?: number;
|
|
355
|
+
query?: string;
|
|
330
356
|
}): Promise<RecalledMemory[]>;
|
|
331
357
|
/** Fetch this session's recent conversation turns (oldest→newest) so a
|
|
332
358
|
* reconnecting embed can restore its transcript. Distinct from `recall`
|
package/dist/client.js
CHANGED
|
@@ -63,6 +63,45 @@ export class CompanionClient {
|
|
|
63
63
|
get sessionId() {
|
|
64
64
|
return this._session;
|
|
65
65
|
}
|
|
66
|
+
/** Swap the bearer token used by every subsequent request and stream
|
|
67
|
+
* reconnect. Session tokens expire (default 1h) — call this when your
|
|
68
|
+
* backend re-mints one, or wire `onAuthError` to do it on demand. */
|
|
69
|
+
setToken(token) {
|
|
70
|
+
if (!token)
|
|
71
|
+
throw new CompanionError('setToken: token is required', 0, 'missing_option');
|
|
72
|
+
this.opts.token = token;
|
|
73
|
+
}
|
|
74
|
+
// In-flight onAuthError call, shared so concurrent 401s trigger ONE refresh.
|
|
75
|
+
refreshing = null;
|
|
76
|
+
/** Run opts.onAuthError (deduped), apply the fresh token, and report whether
|
|
77
|
+
* the caller should retry. Never throws — a failed refresh means "surface
|
|
78
|
+
* the original 401". */
|
|
79
|
+
async tryRefreshToken() {
|
|
80
|
+
const refresh = this.opts.onAuthError;
|
|
81
|
+
if (!refresh)
|
|
82
|
+
return false;
|
|
83
|
+
this.refreshing ??= Promise.resolve()
|
|
84
|
+
.then(refresh)
|
|
85
|
+
.catch(() => null)
|
|
86
|
+
.finally(() => {
|
|
87
|
+
this.refreshing = null;
|
|
88
|
+
});
|
|
89
|
+
const token = await this.refreshing;
|
|
90
|
+
if (!token)
|
|
91
|
+
return false;
|
|
92
|
+
this.opts.token = token;
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
/** doFetch with one transparent retry after a 401-triggered token refresh.
|
|
96
|
+
* Everything except the stream loop (which has its own auth handling) goes
|
|
97
|
+
* through here. */
|
|
98
|
+
async fetchAuthed(url, init) {
|
|
99
|
+
const res = await this.doFetch(url, init);
|
|
100
|
+
if (res.status !== 401 || !(await this.tryRefreshToken()))
|
|
101
|
+
return res;
|
|
102
|
+
init.headers.Authorization = `Bearer ${this.opts.token}`;
|
|
103
|
+
return this.doFetch(url, init);
|
|
104
|
+
}
|
|
66
105
|
url(path) {
|
|
67
106
|
return this.opts.baseUrl.replace(/\/+$/, '') + path;
|
|
68
107
|
}
|
|
@@ -75,7 +114,7 @@ export class CompanionClient {
|
|
|
75
114
|
return this._session;
|
|
76
115
|
}
|
|
77
116
|
async postJson(path, body) {
|
|
78
|
-
const res = await this.
|
|
117
|
+
const res = await this.fetchAuthed(this.url(path), {
|
|
79
118
|
method: 'POST',
|
|
80
119
|
headers: this.jsonHeaders(),
|
|
81
120
|
body: JSON.stringify(body)
|
|
@@ -151,7 +190,7 @@ export class CompanionClient {
|
|
|
151
190
|
* dedups by id). Falls back to buffered semantics if the server ever
|
|
152
191
|
* responds with plain JSON (e.g. an old deployment). */
|
|
153
192
|
async sendTextStreaming(sessionId, body) {
|
|
154
|
-
const res = await this.
|
|
193
|
+
const res = await this.fetchAuthed(this.url(`/api/companion/session/${sessionId}/input`), {
|
|
155
194
|
method: 'POST',
|
|
156
195
|
headers: this.jsonHeaders(),
|
|
157
196
|
body: JSON.stringify({ ...body, stream: true })
|
|
@@ -347,7 +386,7 @@ export class CompanionClient {
|
|
|
347
386
|
.slice(-60)
|
|
348
387
|
.map((t) => ({ role: t.role, text: t.text.slice(0, 500) }));
|
|
349
388
|
try {
|
|
350
|
-
const res = await this.
|
|
389
|
+
const res = await this.fetchAuthed(this.url(`/api/companion/session/${id}/end`), {
|
|
351
390
|
method: 'POST',
|
|
352
391
|
headers: this.jsonHeaders(),
|
|
353
392
|
body: JSON.stringify({ transcript }),
|
|
@@ -481,10 +520,20 @@ export class CompanionClient {
|
|
|
481
520
|
}, env);
|
|
482
521
|
});
|
|
483
522
|
}
|
|
484
|
-
/** Recall the memory this token is authorized to see
|
|
523
|
+
/** Recall the memory this token is authorized to see. Without `query` the
|
|
524
|
+
* facts come back ranked by importance/recency; with `query` the server
|
|
525
|
+
* runs semantic search over them (embedding-based, falls back to ranked
|
|
526
|
+
* when embeddings are unavailable) — e.g.
|
|
527
|
+
* `recall({ query: 'food preferences', limit: 10 })`. */
|
|
485
528
|
async recall(opts) {
|
|
486
|
-
const
|
|
487
|
-
|
|
529
|
+
const params = new URLSearchParams();
|
|
530
|
+
if (opts?.limit)
|
|
531
|
+
params.set('limit', String(opts.limit));
|
|
532
|
+
if (opts?.query?.trim())
|
|
533
|
+
params.set('q', opts.query.trim());
|
|
534
|
+
const query = params.toString();
|
|
535
|
+
const qs = query ? `?${query}` : '';
|
|
536
|
+
const res = await this.fetchAuthed(this.url(`/api/companion/memory${qs}`), {
|
|
488
537
|
headers: { Authorization: `Bearer ${this.opts.token}` }
|
|
489
538
|
});
|
|
490
539
|
const json = (await res.json().catch(() => null));
|
|
@@ -501,7 +550,7 @@ export class CompanionClient {
|
|
|
501
550
|
async history(opts) {
|
|
502
551
|
const id = this.requireSession();
|
|
503
552
|
const qs = opts?.limit ? `?limit=${encodeURIComponent(opts.limit)}` : '';
|
|
504
|
-
const res = await this.
|
|
553
|
+
const res = await this.fetchAuthed(this.url(`/api/companion/session/${encodeURIComponent(id)}/history${qs}`), { headers: { Authorization: `Bearer ${this.opts.token}` } });
|
|
505
554
|
const json = (await res.json().catch(() => null));
|
|
506
555
|
if (!res.ok || !json || json.ok === false) {
|
|
507
556
|
throw new CompanionError(json?.error || `history failed (${res.status})`, res.status);
|
|
@@ -514,7 +563,7 @@ export class CompanionClient {
|
|
|
514
563
|
* returned. Requires a live session. */
|
|
515
564
|
async setModalities(modalities) {
|
|
516
565
|
const id = this.requireSession();
|
|
517
|
-
const res = await this.
|
|
566
|
+
const res = await this.fetchAuthed(this.url(`/api/companion/session/${encodeURIComponent(id)}/modalities`), {
|
|
518
567
|
method: 'POST',
|
|
519
568
|
headers: {
|
|
520
569
|
Authorization: `Bearer ${this.opts.token}`,
|
|
@@ -533,7 +582,7 @@ export class CompanionClient {
|
|
|
533
582
|
* it). Cheap; call it on a timer for a background tab. Requires a live session. */
|
|
534
583
|
async ping() {
|
|
535
584
|
const id = this.requireSession();
|
|
536
|
-
const res = await this.
|
|
585
|
+
const res = await this.fetchAuthed(this.url(`/api/companion/session/${encodeURIComponent(id)}/ping`), { method: 'POST', headers: { Authorization: `Bearer ${this.opts.token}` } });
|
|
537
586
|
if (!res.ok) {
|
|
538
587
|
const json = (await res.json().catch(() => null));
|
|
539
588
|
throw new CompanionError(json?.error || `ping failed (${res.status})`, res.status);
|
|
@@ -578,7 +627,7 @@ export class CompanionClient {
|
|
|
578
627
|
* and the /models asset is CORS-enabled, so a cross-origin renderer can fetch
|
|
579
628
|
* the VRM directly. Reflects the user's live choice (built-in or custom). */
|
|
580
629
|
async getAvatar() {
|
|
581
|
-
const res = await this.
|
|
630
|
+
const res = await this.fetchAuthed(this.url('/api/companion/avatar'), {
|
|
582
631
|
headers: { Authorization: `Bearer ${this.opts.token}` }
|
|
583
632
|
});
|
|
584
633
|
const json = (await res.json().catch(() => null));
|
|
@@ -599,7 +648,7 @@ export class CompanionClient {
|
|
|
599
648
|
* which subsumes it). Same data the companion gives when asked "what's my
|
|
600
649
|
* balance?". */
|
|
601
650
|
async getWallet() {
|
|
602
|
-
const res = await this.
|
|
651
|
+
const res = await this.fetchAuthed(this.url('/api/companion/wallet'), {
|
|
603
652
|
headers: { Authorization: `Bearer ${this.opts.token}` }
|
|
604
653
|
});
|
|
605
654
|
const json = (await res.json().catch(() => null));
|
|
@@ -715,7 +764,7 @@ export class CompanionClient {
|
|
|
715
764
|
* session tokens only, like `confirmAction`. */
|
|
716
765
|
async pendingConfirms() {
|
|
717
766
|
const sessionId = this.requireSession();
|
|
718
|
-
const res = await this.
|
|
767
|
+
const res = await this.fetchAuthed(this.url(`/api/companion/session/${encodeURIComponent(sessionId)}/confirm`), { headers: { Authorization: `Bearer ${this.opts.token}` } });
|
|
719
768
|
const json = (await res.json().catch(() => null));
|
|
720
769
|
if (!res.ok || !json || json.ok === false) {
|
|
721
770
|
throw new CompanionError(json?.error || `pending confirms failed (${res.status})`, res.status);
|
|
@@ -924,11 +973,21 @@ export class CompanionClient {
|
|
|
924
973
|
async loop() {
|
|
925
974
|
const id = this.requireSession();
|
|
926
975
|
let backoff = 500;
|
|
976
|
+
let authRefreshes = 0; // consecutive 401→refresh cycles without a healthy connect
|
|
927
977
|
while (this.streaming) {
|
|
928
978
|
this.abort = new AbortController();
|
|
929
979
|
try {
|
|
930
980
|
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 });
|
|
931
981
|
if (res.status === 401 || res.status === 403) {
|
|
982
|
+
// 401 = the token itself is bad — usually an EXPIRED session token
|
|
983
|
+
// (they live ~1h). With an onAuthError hook this is recoverable:
|
|
984
|
+
// refresh and reconnect instead of going permanently silent. The
|
|
985
|
+
// counter stops a hot loop when the hook keeps returning a token
|
|
986
|
+
// the server keeps rejecting.
|
|
987
|
+
if (res.status === 401 && authRefreshes < 3 && (await this.tryRefreshToken())) {
|
|
988
|
+
authRefreshes += 1;
|
|
989
|
+
continue;
|
|
990
|
+
}
|
|
932
991
|
// Permanent: the token can't subscribe (most often it lacks the
|
|
933
992
|
// `events.subscribe` scope; also revoked / wrong user). Retrying is
|
|
934
993
|
// futile and would silently swallow every reply — surface it as a
|
|
@@ -955,6 +1014,7 @@ export class CompanionClient {
|
|
|
955
1014
|
continue;
|
|
956
1015
|
}
|
|
957
1016
|
backoff = 500; // healthy connection resets backoff
|
|
1017
|
+
authRefreshes = 0;
|
|
958
1018
|
await this.consume(res.body);
|
|
959
1019
|
}
|
|
960
1020
|
catch {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pouchy_ai/companion-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.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",
|