@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/CHANGELOG.md +38 -0
- package/README.md +18 -1
- package/dist/adapter-core.d.ts +4 -0
- package/dist/adapter-core.d.ts.map +1 -1
- package/dist/adapter-core.js +18 -6
- package/dist/adapter-core.js.map +1 -1
- package/dist/client.d.ts +90 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +196 -0
- package/dist/client.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +2 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/adapter-core.ts +22 -6
- package/src/client.ts +260 -3
- package/src/errors.ts +2 -1
- package/src/index.ts +3 -1
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
|
|