@pouchy_ai/companion-sdk 0.33.0 → 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.
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,