@pouchy_ai/companion-sdk 0.32.1 → 0.34.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.
@@ -19,9 +19,11 @@ import type { CompanionStreamState } from './client';
19
19
  import type { ConfirmRequestPayload, TypingPayload } from './protocol';
20
20
  import { CompanionError } from './errors';
21
21
 
22
- /** One rendered line of the conversation, in arrival order. */
22
+ /** One rendered line of the conversation, in arrival order. `'assistant'`
23
+ * matches the industry role vocabulary and this package's own voice-call
24
+ * transcript + `@pouchy_ai/react`'s ChatMessage (0.33.0 — was 'companion'). */
23
25
  export interface CompanionTranscriptEntry {
24
- role: 'user' | 'companion';
26
+ role: 'user' | 'assistant';
25
27
  text: string;
26
28
  /** Client wall-clock ms when the entry was appended. */
27
29
  at: number;
@@ -40,6 +42,10 @@ export interface CompanionViewSnapshot {
40
42
  * through this view yet. Rebuild after a reload via client.pendingConfirms()
41
43
  * — confirm_request events are not replayed. */
42
44
  pendingConfirms: readonly ConfirmRequestPayload[];
45
+ /** Sends queued while offline (0.34.0 — the client's `queueOffline`
46
+ * option): `client.pendingOutbox().length`, live-updated. Always 0 on a
47
+ * client from an older SDK bundle without the offline queue. */
48
+ outbox: number;
43
49
  }
44
50
 
45
51
  export interface CompanionViewOptions {
@@ -96,7 +102,10 @@ export function createCompanionView(
96
102
  transcript: Object.freeze([]) as readonly CompanionTranscriptEntry[],
97
103
  draft: '',
98
104
  typing: false,
99
- pendingConfirms: Object.freeze([]) as readonly ConfirmRequestPayload[]
105
+ pendingConfirms: Object.freeze([]) as readonly ConfirmRequestPayload[],
106
+ // Feature-detect (0.34.0): a host may hand this view a client built from
107
+ // an older SDK bundle without the offline queue — permanently-empty then.
108
+ outbox: typeof client.pendingOutbox === 'function' ? client.pendingOutbox().length : 0
100
109
  });
101
110
 
102
111
  function commit(patch: Partial<CompanionViewSnapshot>): void {
@@ -129,7 +138,7 @@ export function createCompanionView(
129
138
  const restored: CompanionTranscriptEntry[] = [];
130
139
  for (const t of turns) {
131
140
  if (t.user) restored.push({ role: 'user', text: t.user, at: t.ts });
132
- if (t.assistant) restored.push({ role: 'companion', text: t.assistant, at: t.ts });
141
+ if (t.assistant) restored.push({ role: 'assistant', text: t.assistant, at: t.ts });
133
142
  }
134
143
  if (restored.length) commit({ transcript: Object.freeze(restored.slice(-cap)) });
135
144
  })
@@ -143,7 +152,7 @@ export function createCompanionView(
143
152
  client.onMessage((text: string) => {
144
153
  // The authoritative full reply replaces any streamed draft; typing is
145
154
  // definitionally over once the reply lands.
146
- const next = [...snapshot.transcript, { role: 'companion' as const, text, at: Date.now() }];
155
+ const next = [...snapshot.transcript, { role: 'assistant' as const, text, at: Date.now() }];
147
156
  commit({
148
157
  transcript: Object.freeze(next.slice(-cap)),
149
158
  draft: '',
@@ -158,7 +167,11 @@ export function createCompanionView(
158
167
  // The stream can redeliver on reconnect replay — dedupe by confirmId.
159
168
  if (snapshot.pendingConfirms.some((c) => c.confirmId === payload.confirmId)) return;
160
169
  commit({ pendingConfirms: Object.freeze([...snapshot.pendingConfirms, payload]) });
161
- })
170
+ }),
171
+ // Same feature-detect as the initial snapshot (0.34.0).
172
+ ...(typeof client.onOutboxChange === 'function'
173
+ ? [client.onOutboxChange((items) => commit({ outbox: items.length }))]
174
+ : [])
162
175
  ];
163
176
 
164
177
  return {
@@ -176,10 +189,15 @@ export function createCompanionView(
176
189
  } catch (err) {
177
190
  // The optimistic line must not survive a send the server never saw
178
191
  // (network drop / 429 turn-claim) — roll it back by identity so the
179
- // host's retry re-appends cleanly (0.32.1).
180
- commit({
181
- transcript: Object.freeze(snapshot.transcript.filter((e) => e !== entry))
182
- });
192
+ // host's retry re-appends cleanly (0.32.1). The one exception is
193
+ // `queued_offline` (0.34.0): the text IS on its way — queued, and
194
+ // replayed on reconnect so the line stays, mirroring the resolved
195
+ // `{ queued: true }` shape of a non-awaitReply queued send.
196
+ if (!(err instanceof CompanionError && err.code === 'queued_offline')) {
197
+ commit({
198
+ transcript: Object.freeze(snapshot.transcript.filter((e) => e !== entry))
199
+ });
200
+ }
183
201
  throw err;
184
202
  }
185
203
  },
package/src/client.ts CHANGED
@@ -107,6 +107,21 @@ export interface CompanionClientOptions {
107
107
  * configuration errors and never trigger it. Alternative: call `setToken()`
108
108
  * proactively on your own refresh schedule. */
109
109
  onAuthError?: () => string | null | Promise<string | null>;
110
+ /** Opt-in offline send queue (0.34.0). A sendText that fails at the NETWORK
111
+ * level (fetch rejected before any HTTP response, or `navigator.onLine` is
112
+ * false) is queued instead of lost, and replayed FIFO once the server is
113
+ * reachable again — after a successful connect(), on every stream
114
+ * reconnect, or via `flushOutbox()`. Replays reuse the ORIGINAL turnId:
115
+ * the server dedupes completed turnIds (10 min TTL, last 8 per session),
116
+ * so a retry after an ambiguous failure can never double-run a turn. HTTP
117
+ * error responses (4xx/5xx incl. 429) are NEVER queued — the server saw
118
+ * those; they keep the normal throw semantics. Default false. */
119
+ queueOffline?: boolean;
120
+ /** Persistence for the offline queue (see `queueOffline`). Defaults to
121
+ * localStorage when available (privacy-mode / quota failures fall back
122
+ * silently), else an in-memory page-lifetime store. The key is
123
+ * `pouchy-outbox:${surface}`, so each surface keeps its own queue. */
124
+ outboxStore?: OutboxStore;
110
125
  }
111
126
 
112
127
  export interface HelloAck {
@@ -268,6 +283,53 @@ export interface SendTextReply {
268
283
  envelope: CompanionEnvelope;
269
284
  }
270
285
 
286
+ /** One send queued while offline (see the `queueOffline` option) — replayed
287
+ * FIFO by flushOutbox with its ORIGINAL `turnId` (the server's turn-dedup is
288
+ * what makes the retry safe). `at` is Date.now() at enqueue. */
289
+ export interface QueuedSend {
290
+ turnId: string;
291
+ text: string;
292
+ images?: string[];
293
+ at: number;
294
+ }
295
+
296
+ /** Pluggable persistence for the offline send queue (the `outboxStore`
297
+ * option). Both methods receive the storage key
298
+ * (`pouchy-outbox:${surface}`); `load` returns the previously-saved value or
299
+ * null. */
300
+ export interface OutboxStore {
301
+ load(key: string): string | null;
302
+ save(key: string, value: string): void;
303
+ }
304
+
305
+ // Default outbox persistence: localStorage when the environment has one (and
306
+ // allows it — privacy mode / quota errors fall through), else a module-level
307
+ // Map so the queue at least survives for the page's lifetime.
308
+ const memoryOutbox = new Map<string, string>();
309
+ const defaultOutboxStore: OutboxStore = {
310
+ load(key) {
311
+ try {
312
+ if (typeof window !== 'undefined' && window.localStorage) {
313
+ return window.localStorage.getItem(key);
314
+ }
315
+ } catch {
316
+ /* storage denied — fall through to the in-memory copy */
317
+ }
318
+ return memoryOutbox.get(key) ?? null;
319
+ },
320
+ save(key, value) {
321
+ try {
322
+ if (typeof window !== 'undefined' && window.localStorage) {
323
+ window.localStorage.setItem(key, value);
324
+ return;
325
+ }
326
+ } catch {
327
+ /* quota / privacy mode — keep the in-memory copy below */
328
+ }
329
+ memoryOutbox.set(key, value);
330
+ }
331
+ };
332
+
271
333
  type Handler = (envelope: CompanionEnvelope) => void;
272
334
 
273
335
  /** Token-streaming subscriber (P1-2). `chunk` is a raw text delta of the reply
@@ -345,6 +407,14 @@ export class CompanionClient {
345
407
  // paths pay one falsy check.
346
408
  private readonly debugSink: ((event: CompanionDebugEvent) => void) | null;
347
409
 
410
+ // Offline send queue (0.34.0) — null until first use, then loaded lazily
411
+ // from the store so a reloaded tab resumes the queue its predecessor
412
+ // persisted.
413
+ private outbox: QueuedSend[] | null = null;
414
+ private readonly outboxHandlers = new Set<(items: readonly QueuedSend[]) => void>();
415
+ // In-flight flush, shared so concurrent triggers run ONE flush at a time.
416
+ private outboxFlush: Promise<{ sent: number; remaining: number }> | null = null;
417
+
348
418
  constructor(opts: CompanionClientOptions) {
349
419
  if (!opts.baseUrl)
350
420
  throw new CompanionError('CompanionClient: baseUrl is required', 0, 'missing_option');
@@ -409,6 +479,9 @@ export class CompanionClient {
409
479
  console.error('[companion-sdk] stream-state handler threw', e);
410
480
  }
411
481
  }
482
+ // The stream coming back up is the strongest "server reachable again"
483
+ // signal there is — drain any sends queued while offline (0.34.0).
484
+ if (next === 'connected' && this.opts.queueOffline) void this.flushOutbox().catch(() => {});
412
485
  }
413
486
 
414
487
  /** Swap the bearer token used by every subsequent request and stream
@@ -616,6 +689,9 @@ export class CompanionClient {
616
689
  }, opts?.signal);
617
690
  this._session = json.session;
618
691
  this.cursor = json.resumeCursor ?? 0;
692
+ // A successful handshake proves reachability — replay any sends queued
693
+ // while offline (0.34.0; no-op when the queue is empty).
694
+ if (this.opts.queueOffline) void this.flushOutbox().catch(() => {});
619
695
  return {
620
696
  ...json,
621
697
  representative: json.representative ?? false,
@@ -668,7 +744,15 @@ export class CompanionClient {
668
744
  * can't ride the request (`stream: false`, or an old server) does it fall
669
745
  * back to the event stream — which then DOES need start() — resolving with
670
746
  * the next `companion.message`, or rejecting with code `reply_timeout`
671
- * after `replyTimeoutMs` (default 60s). */
747
+ * after `replyTimeoutMs` (default 60s).
748
+ *
749
+ * OFFLINE QUEUE (0.34.0, opt-in `queueOffline: true`): a send that never
750
+ * REACHED the server — fetch rejected at the network level, or
751
+ * `navigator.onLine` is already false — is queued for replay instead of
752
+ * lost, and resolves `{ seq: null, queued: true, turnId }`. With
753
+ * `awaitReply: true` there is no reply to await this session, so it
754
+ * rejects with code `queued_offline` (the item IS queued first). HTTP
755
+ * error responses keep the throw semantics above — they are never queued. */
672
756
  async sendText(
673
757
  text: string,
674
758
  opts: { awaitReply: true; images?: string[]; stream?: boolean; replyTimeoutMs?: number; signal?: AbortSignal }
@@ -676,11 +760,11 @@ export class CompanionClient {
676
760
  async sendText(
677
761
  text: string,
678
762
  opts?: { images?: string[]; stream?: boolean; awaitReply?: boolean; replyTimeoutMs?: number; signal?: AbortSignal }
679
- ): Promise<{ seq: number | null }>;
763
+ ): Promise<{ seq: number | null; queued?: boolean; turnId?: string }>;
680
764
  async sendText(
681
765
  text: string,
682
766
  opts?: { images?: string[]; stream?: boolean; awaitReply?: boolean; replyTimeoutMs?: number; signal?: AbortSignal }
683
- ): Promise<{ seq: number | null } | SendTextReply> {
767
+ ): Promise<{ seq: number | null; queued?: boolean; turnId?: string } | SendTextReply> {
684
768
  const id = this.requireSession();
685
769
  // Client-minted correlation id (0.28.0): the server echoes it back as
686
770
  // `replyTo` on this turn's companion.message — even across an app-tool
@@ -688,6 +772,29 @@ export class CompanionClient {
688
772
  // message that happens by (world-state reactions, confirm outcomes).
689
773
  const turnId = `turn_${this.randomId()}`;
690
774
  const body = { text, turnId, ...(opts?.images?.length ? { images: opts.images } : {}) };
775
+ if (!this.opts.queueOffline) return this.dispatchText(id, body, opts, turnId);
776
+ // Offline queue: two triggers only — the browser already knows it's
777
+ // offline, or fetch itself rejected without an HTTP response. An HTTP
778
+ // error (4xx/5xx incl. 429) means the server SAW the turn: those keep
779
+ // the normal throw semantics and are never queued.
780
+ if (typeof navigator !== 'undefined' && navigator.onLine === false) {
781
+ return this.queueSend(turnId, text, opts);
782
+ }
783
+ try {
784
+ return await this.dispatchText(id, body, opts, turnId);
785
+ } catch (e) {
786
+ if (this.isNetworkSendFailure(e)) return this.queueSend(turnId, text, opts);
787
+ throw e;
788
+ }
789
+ }
790
+
791
+ /** The pre-queue dispatch of sendText: awaitReply / streaming / buffered. */
792
+ private async dispatchText(
793
+ id: string,
794
+ body: Record<string, unknown>,
795
+ opts?: { images?: string[]; stream?: boolean; awaitReply?: boolean; replyTimeoutMs?: number; signal?: AbortSignal },
796
+ turnId?: string
797
+ ): Promise<{ seq: number | null } | SendTextReply> {
691
798
  if (opts?.awaitReply) return this.sendTextAwaitingReply(id, body, opts, turnId);
692
799
  const wantStream = opts?.stream ?? this.deltaHandlers.size > 0;
693
800
  if (!wantStream) {
@@ -701,6 +808,42 @@ export class CompanionClient {
701
808
  return this.sendTextStreaming(id, body, opts?.signal);
702
809
  }
703
810
 
811
+ /** True only for a send that never reached the server: fetch rejected at
812
+ * the network level, which asCompanionError wraps as status 0 with NO
813
+ * code. Every code-carrying status-0 error is deliberate — `aborted` is
814
+ * the caller cancelling, `reply_timeout`/`needs_event_stream` mean the
815
+ * POST was accepted — and any nonzero status is a response the server
816
+ * produced. None of those may queue. */
817
+ private isNetworkSendFailure(e: unknown): boolean {
818
+ return e instanceof CompanionError && e.status === 0 && e.code === undefined;
819
+ }
820
+
821
+ /** Enqueue an offline send. Plain sends resolve `{ seq: null, queued: true,
822
+ * turnId }`; awaitReply can't await a reply that won't arrive this
823
+ * session, so it rejects with `queued_offline` — the item is queued
824
+ * either way. */
825
+ private queueSend(
826
+ turnId: string,
827
+ text: string,
828
+ opts?: { images?: string[]; awaitReply?: boolean }
829
+ ): { seq: null; queued: true; turnId: string } {
830
+ this.loadOutbox().push({
831
+ turnId,
832
+ text,
833
+ ...(opts?.images?.length ? { images: opts.images } : {}),
834
+ at: Date.now()
835
+ });
836
+ this.commitOutbox();
837
+ if (opts?.awaitReply) {
838
+ throw new CompanionError(
839
+ 'offline — the text was queued and will send when the connection returns; no reply to await',
840
+ 0,
841
+ 'queued_offline'
842
+ );
843
+ }
844
+ return { seq: null, queued: true, turnId };
845
+ }
846
+
704
847
  /** The awaitReply leg of sendText. Prefers the in-request answer (the
705
848
  * streaming `done` frame's envelope); falls back to the next
706
849
  * `companion.message` off the event stream, bounded by a timeout. The
@@ -927,6 +1070,120 @@ export class CompanionClient {
927
1070
  }
928
1071
  }
929
1072
 
1073
+ /** The live offline queue, loaded from the store on first use so a reloaded
1074
+ * tab resumes what its predecessor persisted. Unreadable or foreign store
1075
+ * content is discarded — a corrupt store must never break sending. */
1076
+ private loadOutbox(): QueuedSend[] {
1077
+ if (this.outbox) return this.outbox;
1078
+ let items: QueuedSend[] = [];
1079
+ try {
1080
+ const raw = (this.opts.outboxStore ?? defaultOutboxStore).load(this.outboxKey());
1081
+ const parsed = raw ? (JSON.parse(raw) as unknown) : null;
1082
+ if (Array.isArray(parsed)) {
1083
+ items = parsed.filter(
1084
+ (q): q is QueuedSend =>
1085
+ !!q &&
1086
+ typeof (q as QueuedSend).turnId === 'string' &&
1087
+ typeof (q as QueuedSend).text === 'string'
1088
+ );
1089
+ }
1090
+ } catch {
1091
+ /* unreadable store — start empty */
1092
+ }
1093
+ this.outbox = items;
1094
+ return items;
1095
+ }
1096
+
1097
+ private outboxKey(): string {
1098
+ return `pouchy-outbox:${this.opts.surface ?? 'default'}`;
1099
+ }
1100
+
1101
+ /** Persist + notify after EVERY queue mutation (enqueue / flushed / drop),
1102
+ * so the stored copy never lags what a reload would need. Handler
1103
+ * isolation matches emit(): a throwing consumer must not skip the rest. */
1104
+ private commitOutbox(): void {
1105
+ const items = this.loadOutbox();
1106
+ try {
1107
+ (this.opts.outboxStore ?? defaultOutboxStore).save(this.outboxKey(), JSON.stringify(items));
1108
+ } catch {
1109
+ /* store save failed — the in-memory queue still flushes this page */
1110
+ }
1111
+ const copy = Object.freeze([...items]) as readonly QueuedSend[];
1112
+ for (const h of this.outboxHandlers) {
1113
+ try {
1114
+ h(copy);
1115
+ } catch (e) {
1116
+ console.error('[companion-sdk] outbox handler threw', e);
1117
+ }
1118
+ }
1119
+ }
1120
+
1121
+ /** The sends queued while offline (oldest first), as a defensive copy —
1122
+ * see the `queueOffline` option. */
1123
+ pendingOutbox(): readonly QueuedSend[] {
1124
+ return [...this.loadOutbox()];
1125
+ }
1126
+
1127
+ /** Subscribe to offline-queue changes (enqueue / flushed / dropped) — drive
1128
+ * a "N pending" badge from `items.length`. Returns an unsubscribe fn. */
1129
+ onOutboxChange(handler: (items: readonly QueuedSend[]) => void): () => void {
1130
+ this.outboxHandlers.add(handler);
1131
+ return () => this.outboxHandlers.delete(handler);
1132
+ }
1133
+
1134
+ /** Replay the offline queue: FIFO, sequential, each item a normal buffered
1135
+ * input POST reusing its ORIGINAL `turnId` (the server dedupes completed
1136
+ * turnIds — see `queueOffline` — so a retry can never double-run a turn).
1137
+ * Runs automatically after connect() and on every stream reconnect; call
1138
+ * it yourself for a manual retry affordance. One flush at a time —
1139
+ * concurrent calls share the in-flight run. Per item: success (or a
1140
+ * server-side duplicate) → removed; network failure / 5xx / 429 → stop,
1141
+ * keep this item and the rest for the next flush; any other 4xx → the
1142
+ * item itself is invalid (the server deliberately rejected it) — drop it,
1143
+ * console.warn, and keep flushing. */
1144
+ async flushOutbox(): Promise<{ sent: number; remaining: number }> {
1145
+ this.requireSession();
1146
+ this.outboxFlush ??= this.runOutboxFlush().finally(() => {
1147
+ this.outboxFlush = null;
1148
+ });
1149
+ return this.outboxFlush;
1150
+ }
1151
+
1152
+ private async runOutboxFlush(): Promise<{ sent: number; remaining: number }> {
1153
+ const id = this.requireSession();
1154
+ const items = this.loadOutbox();
1155
+ let sent = 0;
1156
+ while (items.length) {
1157
+ const item = items[0];
1158
+ try {
1159
+ await this.postJson<{ seq: number | null; duplicate?: boolean }>(
1160
+ `/api/companion/session/${id}/input`,
1161
+ {
1162
+ text: item.text,
1163
+ turnId: item.turnId,
1164
+ ...(item.images?.length ? { images: item.images } : {})
1165
+ }
1166
+ );
1167
+ } catch (e) {
1168
+ const status = e instanceof CompanionError ? e.status : 0;
1169
+ if (status >= 400 && status < 500 && status !== 429) {
1170
+ // The server saw it and said no — retrying can't fix the item.
1171
+ console.warn('[companion-sdk] dropping queued send the server rejected', status, e);
1172
+ items.shift();
1173
+ this.commitOutbox();
1174
+ continue;
1175
+ }
1176
+ // Unreachable again (or throttled / a server-side failure): keep
1177
+ // this item and everything behind it for the next flush.
1178
+ break;
1179
+ }
1180
+ items.shift();
1181
+ sent += 1;
1182
+ this.commitOutbox();
1183
+ }
1184
+ return { sent, remaining: items.length };
1185
+ }
1186
+
930
1187
  /** Subscribe to token-streaming deltas of the reply (P1-2). Registering a
931
1188
  * subscriber makes sendText stream automatically. Returns an unsubscribe
932
1189
  * function. Render chunks as they arrive; on `meta.reset` clear the partial;
package/src/errors.ts CHANGED
@@ -20,7 +20,8 @@ export const SDK_SYNTHESIZED_ERROR_CODES = [
20
20
  'call_unsupported', // startCall on a server/provider without voice
21
21
  'call_connect_failed', // WebRTC/provider handshake failed
22
22
  'call_dependency_missing', // optional voice dependency not installed
23
- 'aborted' // a caller-supplied AbortSignal cancelled the request (0.29.0)
23
+ 'aborted', // a caller-supplied AbortSignal cancelled the request (0.29.0)
24
+ 'queued_offline' // queueOffline: the text was queued while offline and will send on reconnect (0.34.0)
24
25
  ] as const;
25
26
  export type SdkSynthesizedErrorCode = (typeof SDK_SYNTHESIZED_ERROR_CODES)[number];
26
27
 
package/src/index.ts CHANGED
@@ -30,7 +30,9 @@ export type {
30
30
  WalletBalance,
31
31
  BrandIconSize,
32
32
  EndSessionResult,
33
- SendTextReply
33
+ SendTextReply,
34
+ QueuedSend,
35
+ OutboxStore
34
36
  } from './client';
35
37
  export {
36
38
  openCompanionCall,