@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 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(text)` | Read / write the token-scoped memory namespace. |
100
- | `ingestKnowledge(text, opts?)` / `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. |
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 (ranked). */
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.doFetch(this.url(path), {
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.doFetch(this.url(`/api/companion/session/${sessionId}/input`), {
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.doFetch(this.url(`/api/companion/session/${id}/end`), {
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 (ranked). */
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 qs = opts?.limit ? `?limit=${encodeURIComponent(opts.limit)}` : '';
487
- const res = await this.doFetch(this.url(`/api/companion/memory${qs}`), {
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.doFetch(this.url(`/api/companion/session/${encodeURIComponent(id)}/history${qs}`), { headers: { Authorization: `Bearer ${this.opts.token}` } });
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.doFetch(this.url(`/api/companion/session/${encodeURIComponent(id)}/modalities`), {
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.doFetch(this.url(`/api/companion/session/${encodeURIComponent(id)}/ping`), { method: 'POST', headers: { Authorization: `Bearer ${this.opts.token}` } });
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.doFetch(this.url('/api/companion/avatar'), {
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.doFetch(this.url('/api/companion/wallet'), {
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.doFetch(this.url(`/api/companion/session/${encodeURIComponent(sessionId)}/confirm`), { headers: { Authorization: `Bearer ${this.opts.token}` } });
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.20.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",