@pouchy_ai/companion-sdk 0.35.0 → 0.37.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
@@ -22,6 +22,7 @@ import type {
22
22
  OutboundPayloadMap,
23
23
  OutboundType,
24
24
  PendingConfirm,
25
+ PendingToolCall,
25
26
  RenderInterfacePayload,
26
27
  SocialMessagePayload,
27
28
  ToolCallPayload,
@@ -62,7 +63,11 @@ export interface CompanionClientOptions {
62
63
  surface?: string;
63
64
  /** Requested I/O modalities (intersected with the token's grant server-side). */
64
65
  modalities?: string[];
65
- /** Action types this surface can perform (declared at handshake). */
66
+ /** Action types this surface can perform (declared at handshake). Stored but
67
+ * never consumed server-side today — a client-side capability hint whose one
68
+ * SDK effect is opting a tool-less embed into the auto-offered host-control
69
+ * verbs on connectCall() voice sessions. It does NOT register tools for text
70
+ * turns: a tool you want called on a text turn must be declared in `tools`. */
66
71
  handles?: string[];
67
72
  /** World-state kinds this surface emits (declared at handshake). */
68
73
  contextKinds?: string[];
@@ -117,6 +122,19 @@ export interface CompanionClientOptions {
117
122
  * error responses (4xx/5xx incl. 429) are NEVER queued — the server saw
118
123
  * those; they keep the normal throw semantics. Default false. */
119
124
  queueOffline?: boolean;
125
+ /** Replay of pause-orphaned tool calls (0.37.0). When `connect()` resumes a
126
+ * session whose turn is paused on tool calls THIS surface never answered
127
+ * (the page reloaded / crashed mid-pause), the handshake carries them as
128
+ * `pendingToolCalls` and the client re-emits each to your onToolCall
129
+ * handler with `replayed: true` — post the results as usual and the paused
130
+ * turn completes instead of being abandoned. Register onToolCall BEFORE
131
+ * calling connect()/start() to receive them. The result apply is
132
+ * idempotent per call id, but the APP-side effect may not be: if a tool
133
+ * has side effects, treat `call.id` as an idempotency key when
134
+ * `replayed` is set. Pass false to disable the re-emit — the calls stay
135
+ * readable on the `HelloAck.pendingToolCalls` you get from connect().
136
+ * Default true. */
137
+ replayPendingToolCalls?: boolean;
120
138
  /** Persistence for the offline queue (see `queueOffline`). Defaults to
121
139
  * localStorage when available (privacy-mode / quota failures fall back
122
140
  * silently), else an in-memory page-lifetime store. The key is
@@ -146,6 +164,11 @@ export interface HelloAck {
146
164
  * the owner (their two companions are friends). Lets you skip offering a
147
165
  * "pair" affordance to an already-connected visitor. */
148
166
  visitorPaired: boolean;
167
+ /** Outstanding tool calls of a turn that paused before this handshake —
168
+ * non-empty only when resuming mid-pause (0.37.0). Unless
169
+ * `replayPendingToolCalls: false`, each is also re-emitted to onToolCall
170
+ * with `replayed: true` right after connect() resolves. */
171
+ pendingToolCalls: PendingToolCall[];
149
172
  }
150
173
 
151
174
  /** A tool the embedding declares the companion may ask it to perform. */
@@ -393,6 +416,9 @@ export class CompanionClient {
393
416
  private readonly doFetch: typeof fetch;
394
417
  private _session: string | null = null;
395
418
  private cursor = 0;
419
+ /** Pause-orphaned call replay fired (0.37.0) — once per client instance, so
420
+ * a manual second connect() can't double-deliver to a live handler. */
421
+ private replayedPendingToolCalls = false;
396
422
  private streaming = false;
397
423
  private abort: AbortController | null = null;
398
424
  // Bumped on every start()/stop(): a loop whose generation is stale exits
@@ -734,6 +760,7 @@ export class CompanionClient {
734
760
  resumeCursor: number;
735
761
  representative?: boolean;
736
762
  visitorPaired?: boolean;
763
+ pendingToolCalls?: PendingToolCall[];
737
764
  }>('/api/companion/session', {
738
765
  surface: this.opts.surface ?? 'default',
739
766
  modalities: this.opts.modalities,
@@ -748,10 +775,44 @@ export class CompanionClient {
748
775
  // A successful handshake proves reachability — replay any sends queued
749
776
  // while offline (0.34.0; no-op when the queue is empty).
750
777
  if (this.opts.queueOffline) void this.flushOutbox().catch(() => {});
778
+ const pendingToolCalls = Array.isArray(json.pendingToolCalls)
779
+ ? json.pendingToolCalls.filter(
780
+ (c): c is PendingToolCall => !!c && typeof c.id === 'string' && typeof c.name === 'string'
781
+ )
782
+ : [];
783
+ // Pause-orphaned call replay (0.37.0): re-emit each outstanding call to
784
+ // onToolCall so a reloaded embed completes the paused turn. Deferred a
785
+ // tick so `await connect()` callers can still register handlers first;
786
+ // once-per-client so a second connect() (manual re-handshake) can't
787
+ // double-deliver alongside a live handler that already got them. The
788
+ // synthetic envelope id is derived from the callId, so the stream-replay
789
+ // of the ORIGINAL envelope (a client resuming with a persisted cursor)
790
+ // carries a different id and the app-facing `replayed` flag is the
791
+ // dedup signal — see the option's jsdoc on idempotency.
792
+ if (
793
+ pendingToolCalls.length &&
794
+ this.opts.replayPendingToolCalls !== false &&
795
+ !this.replayedPendingToolCalls
796
+ ) {
797
+ this.replayedPendingToolCalls = true;
798
+ setTimeout(() => {
799
+ for (const c of pendingToolCalls) {
800
+ this.emit({
801
+ v: PROTOCOL_VERSION,
802
+ id: `replay_${c.id}`,
803
+ ts: Date.now(),
804
+ session: json.session,
805
+ type: 'companion.tool_call',
806
+ payload: { id: c.id, name: c.name, args: c.args ?? '', replayed: true }
807
+ } as CompanionEnvelope);
808
+ }
809
+ }, 0);
810
+ }
751
811
  return {
752
812
  ...json,
753
813
  representative: json.representative ?? false,
754
- visitorPaired: json.visitorPaired ?? false
814
+ visitorPaired: json.visitorPaired ?? false,
815
+ pendingToolCalls
755
816
  };
756
817
  }
757
818
 
@@ -1576,10 +1637,20 @@ export class CompanionClient {
1576
1637
  handler: (call: ToolCallEvent, envelope: CompanionEnvelope) => void
1577
1638
  ): () => void {
1578
1639
  return this.on('companion.tool_call', (env) => {
1579
- const p = env.payload as { id?: unknown; name?: unknown; args?: unknown };
1640
+ const p = env.payload as { id?: unknown; name?: unknown; args?: unknown; replayed?: unknown };
1580
1641
  if (typeof p?.id === 'string' && typeof p?.name === 'string') {
1581
1642
  const args = typeof p.args === 'string' ? p.args : '';
1582
- handler({ id: p.id, name: p.name, args, argsJson: parseArgsJson(args) }, env);
1643
+ handler(
1644
+ {
1645
+ id: p.id,
1646
+ name: p.name,
1647
+ args,
1648
+ argsJson: parseArgsJson(args),
1649
+ // Pause-orphaned replay (0.37.0) — see replayPendingToolCalls.
1650
+ ...(p.replayed === true ? { replayed: true } : {})
1651
+ },
1652
+ env
1653
+ );
1583
1654
  }
1584
1655
  });
1585
1656
  }
package/src/index.ts CHANGED
@@ -64,6 +64,7 @@ export type {
64
64
  SocialMessagePayload,
65
65
  ConfirmRequestPayload,
66
66
  PendingConfirm,
67
+ PendingToolCall,
67
68
  ToolCallPayload,
68
69
  AudioClipPayload,
69
70
  ExpressionPayload,
package/src/protocol.ts CHANGED
@@ -161,6 +161,32 @@ export interface ToolCallPayload {
161
161
  name: string;
162
162
  /** The model's arguments as a raw JSON string. */
163
163
  args: string;
164
+ /** True when this delivery is a REPLAY of a still-outstanding call from a
165
+ * turn that paused before a reconnect (see hello.ack `pendingToolCalls`) —
166
+ * the app may have already performed it and crashed before posting the
167
+ * result. Side-effecting tools should treat `id` as an idempotency key.
168
+ * Absent on the original live delivery. */
169
+ replayed?: boolean;
170
+ }
171
+
172
+ /** One outstanding tool call from a turn that paused BEFORE this handshake —
173
+ * carried on `hello.ack` (0.37.0) so an embed that lost its state (tab
174
+ * reload / crash mid-pause) can rediscover what it owes and COMPLETE the
175
+ * paused turn instead of abandoning it via `/end`. Post each result with
176
+ * `sendToolResult(id, …)` exactly like a live `companion.tool_call`; the
177
+ * server's apply is idempotent per id, so double-posting is safe. */
178
+ export interface PendingToolCall {
179
+ /** Correlation id — echo it back on the tool result. */
180
+ id: string;
181
+ /** The declared tool name the model chose. */
182
+ name: string;
183
+ /** The model's arguments as a raw JSON string. */
184
+ args: string;
185
+ /** Correlation id of the paused turn (when the pausing client minted one). */
186
+ turnId?: string;
187
+ /** Epoch ms the turn paused / last progressed — judge staleness before
188
+ * replaying a side-effecting call. */
189
+ pausedAt?: number;
164
190
  }
165
191
 
166
192
  /** Payload of a `companion.ui_update` event — a live change to an
@@ -283,6 +309,11 @@ export interface HelloAckPayload {
283
309
  resumeCursor?: number;
284
310
  representative?: boolean;
285
311
  visitorPaired?: boolean;
312
+ /** Outstanding tool calls from a turn that paused before this handshake —
313
+ * present (non-empty) only when resuming a session mid-pause. See
314
+ * PendingToolCall; the SDK re-emits them to onToolCall with
315
+ * `replayed: true` unless `replayPendingToolCalls: false`. */
316
+ pendingToolCalls?: PendingToolCall[];
286
317
  }
287
318
 
288
319
  /** Payload of a `control.call_ready` event — the stream echo of `start_call`'s