@pouchy_ai/companion-sdk 0.67.0 → 0.69.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/call.ts CHANGED
@@ -11,7 +11,13 @@
11
11
  // Browser-only (needs WebRTC + getUserMedia). Returns a handle to hang up and to
12
12
  // inject live context mid-call (the bridge the server uses for voiceRelevant).
13
13
 
14
- import type { CallCredentials, CallRecordingUnavailable, CallWorldBrief } from './client';
14
+ import type {
15
+ CallCredentials,
16
+ CallPrivateStory,
17
+ CallRecordingUnavailable,
18
+ CallTrimmed,
19
+ CallWorldBrief
20
+ } from './client';
15
21
  import { CompanionError } from './errors';
16
22
  import { VOICE_TOOL_VARIABLE, VOICE_TOOL_VARIABLE_PLAIN } from './voice-tool';
17
23
 
@@ -197,14 +203,57 @@ export interface CompanionCallOptions {
197
203
  * "说一句就断了" field report). Enable only where AEC actually holds:
198
204
  * headphones, desktop, or devices you have verified. */
199
205
  bargeIn?: boolean;
206
+ /** Open the call with the reader's microphone MUTED (0.69.0); see
207
+ * `CompanionCall.setMuted`. On `openai-realtime` the track is disabled
208
+ * before it is attached, so nothing is ever sent. On `elevenlabs-convai`
209
+ * the mute is applied the moment the session is up; the provider client
210
+ * has no start-muted option, so the connect handshake itself (before the
211
+ * agent's first line) can carry audio. */
212
+ muted?: boolean;
200
213
  }
201
214
 
202
215
  export interface CompanionCall {
203
216
  /** Hang up. Idempotent. */
204
217
  close: () => void;
205
218
  /** Inject an out-of-band context line. `speak: true` makes the companion react
206
- * now; false stages it for the next turn. */
219
+ * now; false stages it for the next turn.
220
+ *
221
+ * NOT the reader's words: the line does not reach `onTranscript` or the
222
+ * memory `/end` consolidates, and `connectCall` uses this same lever for
223
+ * server-pushed world moments. For something the reader TYPED, use
224
+ * `sendText`. */
207
225
  injectEvent: (text: string, speak?: boolean) => void;
226
+ /** Send a line the READER typed, as their turn in the call (0.69.0). The
227
+ * companion answers it as something the reader said; it is reported to
228
+ * `onTranscript` as `role: 'user'` (once, by the SDK, the moment it is
229
+ * sent) and so reaches the memory `connectCall` hands to `/end` at hang-up,
230
+ * in order with the spoken lines.
231
+ *
232
+ * It takes the floor the way speech does: an utterance the companion is
233
+ * in the middle of is cut, then it replies to this line.
234
+ *
235
+ * Nothing is sent before this call — the provider sees the text only when
236
+ * you pass it here, so a check you run first runs before anyone reads it.
237
+ * Returns `false` (and sends nothing) for blank text, after `close()`, or
238
+ * when the call's channel is not open; `true` once it was handed to the
239
+ * provider. Same behaviour on both providers. */
240
+ sendText: (text: string) => boolean;
241
+ /** The reader's own mute (0.69.0). `true` stops the microphone sending
242
+ * audio until `setMuted(false)`. Distinct from the SDK's half-duplex mute
243
+ * (the mic is muted while the companion speaks, unless `bargeIn`): a
244
+ * reader who muted stays muted when the companion finishes a line. A
245
+ * no-op after `close()`. Check `canMute` before showing a mute control. */
246
+ setMuted: (muted: boolean) => void;
247
+ /** The reader's mute as last set (0.69.0) — the `setMuted` / `muted`
248
+ * state, not whether the half-duplex mute is momentarily engaged. A
249
+ * method, not a field, so it stays live on a spread copy of the handle. */
250
+ isMuted: () => boolean;
251
+ /** Whether `setMuted` actually controls the microphone on this call
252
+ * (0.69.0). `true` on `openai-realtime`; on `elevenlabs-convai` it is
253
+ * `true` when the installed `@elevenlabs/client` exposes `setMicMuted`
254
+ * (every version the peer range allows does). When `false`, `setMuted`
255
+ * only records the state: do not show a mute control. */
256
+ canMute: boolean;
208
257
  /** SILENT CUT (0.36.0): stop the utterance currently being spoken WITHOUT
209
258
  * generating any new speech — the "stale commentary at a beat boundary"
210
259
  * case (field request: fast-paced game, last hand's commentary still
@@ -235,6 +284,11 @@ export interface CompanionCall {
235
284
  /** Pouchy World role sessions (0.67.0): whether the call carries the
236
285
  * story's current state. See `CallWorldBrief`. */
237
286
  worldBrief?: CallWorldBrief;
287
+ /** Pouchy World role sessions (0.68.0): whether the role can reach its
288
+ * private layer on this call. See `CallPrivateStory`. */
289
+ privateStory?: CallPrivateStory;
290
+ /** What the server cut to fit the realtime cap (0.68.0). See `CallTrimmed`. */
291
+ trimmed?: CallTrimmed;
238
292
  }
239
293
 
240
294
  const OPENAI_REALTIME_CALLS = 'https://api.openai.com/v1/realtime/calls';
@@ -348,9 +402,16 @@ async function openOpenAICall(
348
402
  // the companion interrupting itself.
349
403
  const MIC_UNMUTE_TAIL_MS = 350;
350
404
  let micUnmuteTimer: ReturnType<typeof setTimeout> | null = null;
351
- const setMicEnabled = (enabled: boolean) => {
405
+ // Two independent reasons the mic is off (0.69.0): the READER muted
406
+ // (setMuted), or the companion is speaking (half-duplex). The track is on
407
+ // only when neither holds, so the end-of-speech re-open can never undo a
408
+ // reader's mute — which is what made a mute layered on top of this SDK a
409
+ // button that switched itself back on.
410
+ let userMuted = opts.muted === true;
411
+ let speechMuted = false;
412
+ const applyMic = () => {
352
413
  try {
353
- for (const tr of mic.getAudioTracks()) tr.enabled = enabled;
414
+ for (const tr of mic.getAudioTracks()) tr.enabled = !userMuted && !speechMuted;
354
415
  } catch {
355
416
  /* track gone — ignore */
356
417
  }
@@ -360,13 +421,16 @@ async function openOpenAICall(
360
421
  clearTimeout(micUnmuteTimer);
361
422
  micUnmuteTimer = null;
362
423
  }
363
- setMicEnabled(false);
424
+ speechMuted = true;
425
+ applyMic();
364
426
  };
365
427
  const scheduleMicUnmute = () => {
366
428
  if (micUnmuteTimer) clearTimeout(micUnmuteTimer);
367
429
  micUnmuteTimer = setTimeout(() => {
368
430
  micUnmuteTimer = null;
369
- if (!closed) setMicEnabled(true);
431
+ if (closed) return;
432
+ speechMuted = false;
433
+ applyMic();
370
434
  }, MIC_UNMUTE_TAIL_MS);
371
435
  };
372
436
 
@@ -375,6 +439,15 @@ async function openOpenAICall(
375
439
  // active response makes the server emit a benign-but-noisy error event, so
376
440
  // an idle interrupt() must be a true no-op.
377
441
  let oaiSpeaking = false;
442
+ // Whether a response is in flight (response.created → response.done) —
443
+ // broader than `oaiSpeaking`, which starts only with audio. `sendText`
444
+ // cancels an in-flight response before creating its own: the server
445
+ // refuses a second concurrent response, which would leave the typed line
446
+ // in the conversation with no reply.
447
+ let oaiResponseActive = false;
448
+ // A reader who asked to start muted never sends a frame: the track is
449
+ // disabled before the offer is even created.
450
+ applyMic();
378
451
 
379
452
  const dc = pc.createDataChannel('oai-events');
380
453
  // `response.function_call_arguments.done` carries the call_id + args but often
@@ -475,6 +548,8 @@ async function openOpenAICall(
475
548
  msg.transcript
476
549
  ) {
477
550
  opts.onTranscript?.({ role: 'user', text: msg.transcript });
551
+ } else if (msg.type === 'response.created') {
552
+ oaiResponseActive = true;
478
553
  } else if (msg.type === 'output_audio_buffer.started') {
479
554
  oaiSpeaking = true;
480
555
  opts.onSpeakingChange?.(true);
@@ -493,6 +568,7 @@ async function openOpenAICall(
493
568
  // audio doesn't get captured the instant we unmute. Several event names
494
569
  // can end a turn; any of them re-arms the mic.
495
570
  oaiSpeaking = false;
571
+ if (msg.type === 'response.done') oaiResponseActive = false;
496
572
  opts.onSpeakingChange?.(false);
497
573
  if (opts.bargeIn !== true) scheduleMicUnmute();
498
574
  }
@@ -664,6 +740,36 @@ async function openOpenAICall(
664
740
  );
665
741
  if (speak) dc.send(JSON.stringify({ type: 'response.create' }));
666
742
  },
743
+ sendText: (text) => {
744
+ const line = typeof text === 'string' ? text.trim() : '';
745
+ if (closed || dc.readyState !== 'open' || !line) return false;
746
+ try {
747
+ if (oaiResponseActive) {
748
+ dc.send(JSON.stringify({ type: 'response.cancel' }));
749
+ if (oaiSpeaking) dc.send(JSON.stringify({ type: 'output_audio_buffer.clear' }));
750
+ }
751
+ dc.send(
752
+ JSON.stringify({
753
+ type: 'conversation.item.create',
754
+ item: { type: 'message', role: 'user', content: [{ type: 'input_text', text: line }] }
755
+ })
756
+ );
757
+ dc.send(JSON.stringify({ type: 'response.create' }));
758
+ } catch {
759
+ return false;
760
+ }
761
+ // A typed item produces no input-audio transcription event, so the SDK
762
+ // reports it itself — that is what puts it in the caller's transcript
763
+ // and in the memory connectCall consolidates at hang-up.
764
+ opts.onTranscript?.({ role: 'user', text: line });
765
+ return true;
766
+ },
767
+ setMuted: (muted) => {
768
+ userMuted = muted === true;
769
+ if (!closed) applyMic();
770
+ },
771
+ isMuted: () => userMuted,
772
+ canMute: true,
667
773
  interrupt: () => {
668
774
  // Silent cut: stop generating (response.cancel) AND flush what's
669
775
  // already buffered for playback (output_audio_buffer.clear — the
@@ -729,6 +835,15 @@ async function openConvaiCall(
729
835
  /* not supported on this client build — ignore */
730
836
  }
731
837
  };
838
+ // The reader's mute and the half-duplex mute are separate reasons (0.69.0,
839
+ // see the OpenAI leg); the mic opens only when neither holds.
840
+ let userMuted = opts.muted === true;
841
+ let speechMuted = false;
842
+ const applyMic = () => setConvMicMuted(userMuted || speechMuted);
843
+ // Lines `sendText` already reported. Should the provider ever echo a typed
844
+ // user_message back as a user transcript, it must not be reported twice
845
+ // (and land in memory twice). Matched by exact text, consumed once.
846
+ const typedEcho: string[] = [];
732
847
  const conv = await mod.Conversation.startSession({
733
848
  conversationToken: creds.token,
734
849
  connectionType: 'webrtc',
@@ -777,12 +892,15 @@ async function openConvaiCall(
777
892
  clearTimeout(micUnmuteTimer);
778
893
  micUnmuteTimer = null;
779
894
  }
780
- setConvMicMuted(true);
895
+ speechMuted = true;
896
+ applyMic();
781
897
  } else {
782
898
  if (micUnmuteTimer) clearTimeout(micUnmuteTimer);
783
899
  micUnmuteTimer = setTimeout(() => {
784
900
  micUnmuteTimer = null;
785
- if (!closed) setConvMicMuted(false);
901
+ if (closed) return;
902
+ speechMuted = false;
903
+ applyMic();
786
904
  }, MIC_UNMUTE_TAIL_MS);
787
905
  }
788
906
  },
@@ -792,6 +910,13 @@ async function openConvaiCall(
792
910
  const raw = (message ?? '').trim();
793
911
  const text = creds.audioTags && role === 'agent' ? stripAudioTags(raw) : raw;
794
912
  if (!closed && text) {
913
+ if (role !== 'agent') {
914
+ const echo = typedEcho.indexOf(text);
915
+ if (echo !== -1) {
916
+ typedEcho.splice(echo, 1);
917
+ return;
918
+ }
919
+ }
795
920
  opts.onTranscript?.({ role: role === 'agent' ? 'assistant' : 'user', text });
796
921
  }
797
922
  },
@@ -834,6 +959,10 @@ async function openConvaiCall(
834
959
  }
835
960
  });
836
961
 
962
+ // The session is up: honour a start-muted request now (the EL client has no
963
+ // start-muted option — see CompanionCallOptions.muted).
964
+ if (userMuted) applyMic();
965
+
837
966
  return {
838
967
  provider: 'elevenlabs-convai',
839
968
  close: () => {
@@ -854,6 +983,27 @@ async function openConvaiCall(
854
983
  /* channel not ready */
855
984
  }
856
985
  },
986
+ sendText: (text) => {
987
+ const line = typeof text === 'string' ? text.trim() : '';
988
+ if (closed || !line) return false;
989
+ try {
990
+ // A `user_message`: the agent answers it as the user's turn, and
991
+ // the server's own turn-taking cuts an utterance in progress.
992
+ conv.sendUserMessage(line);
993
+ } catch {
994
+ return false;
995
+ }
996
+ typedEcho.push(line);
997
+ if (typedEcho.length > 20) typedEcho.shift();
998
+ opts.onTranscript?.({ role: 'user', text: line });
999
+ return true;
1000
+ },
1001
+ setMuted: (muted) => {
1002
+ userMuted = muted === true;
1003
+ if (!closed) applyMic();
1004
+ },
1005
+ isMuted: () => userMuted,
1006
+ canMute: typeof (conv as unknown as { setMicMuted?: unknown }).setMicMuted === 'function',
857
1007
  interrupt: () => {
858
1008
  // BEST-EFFORT silent cut — the EL client has no public "stop playback"
859
1009
  // API (interruption is normally server-driven: a server `interruption`
package/src/client.ts CHANGED
@@ -274,6 +274,13 @@ export type CallCredentials =
274
274
  * the story's current state (see `CallWorldBrief`). ABSENT on an
275
275
  * ordinary session. */
276
276
  worldBrief?: CallWorldBrief;
277
+ /** Pouchy World role sessions only (0.68.0): whether the role can
278
+ * reach its private layer during this call (see `CallPrivateStory`).
279
+ * ABSENT on an ordinary session. */
280
+ privateStory?: CallPrivateStory;
281
+ /** What the server cut to keep `instructions` within the realtime cap
282
+ * (0.68.0; see `CallTrimmed`). ABSENT when nothing was cut. */
283
+ trimmed?: CallTrimmed;
277
284
  }
278
285
  | {
279
286
  provider: 'openai-realtime';
@@ -290,19 +297,45 @@ export type CallCredentials =
290
297
  recordingUnavailable?: CallRecordingUnavailable;
291
298
  /** See the elevenlabs-convai branch (0.67.0). */
292
299
  worldBrief?: CallWorldBrief;
300
+ /** Always `'none'` on a world session here (0.68.0): the OpenAI
301
+ * Realtime fallback has no webhook tools. */
302
+ privateStory?: CallPrivateStory;
303
+ /** See the elevenlabs-convai branch (0.68.0). */
304
+ trimmed?: CallTrimmed;
293
305
  };
294
306
 
295
307
  /** Whether a Pouchy World role's call carries the story (0.67.0).
296
- * - `included` — the call's instructions hold where the story is, as THIS
297
- * role sees it (scene, clock, flags, the facts and notes it may know) and
298
- * its own last beats. Nothing said on the call changes the story: fold the
299
- * call back in as a beat on the world turn door (`trigger.context` +
300
- * `proposedPatches`).
308
+ * - `included` — the call's instructions hold the story's PUBLIC state (scene,
309
+ * story clock, flags, the facts already revealed) and this role's own last
310
+ * beats. Never its secrets, notes, goals or unrevealed facts: `instructions`
311
+ * reach the reader's device (companion API 1.5.3). Nothing said on the call
312
+ * changes the story: fold the call back in as a beat on the world turn door
313
+ * (`trigger.context` + `proposedPatches`).
301
314
  * - `no_story` — the world pins no story package.
302
315
  * - `unavailable` — the story could not be read; the call went ahead without
303
316
  * it. */
304
317
  export type CallWorldBrief = 'included' | 'no_story' | 'unavailable';
305
318
 
319
+ /** Whether a Pouchy World role's call can reach the role's private layer
320
+ * (0.68.0; companion API 1.6.1).
321
+ * - `tool` — the call is told about, and carries the credential for, the
322
+ * in-call `recall_private_story` tool: ElevenLabs asks Pouchy for the role's
323
+ * secrets and unrevealed facts server-to-server, only when the conversation
324
+ * needs them.
325
+ * - `none` — it is not (the OpenAI Realtime fallback, a session with no memory
326
+ * read scope, or the operator's switch is off): the role keeps its private
327
+ * knowledge vague on this call. */
328
+ export type CallPrivateStory = 'tool' | 'none';
329
+
330
+ /** Characters the server removed to keep a call's `instructions` within the
331
+ * realtime cap (0.68.0; companion API 1.5.2). `story` comes off the end of the
332
+ * Pouchy World story block first, then `context` off the end of the persona /
333
+ * memory context. Your `brief` and the call framing are never cut. */
334
+ export interface CallTrimmed {
335
+ story?: number;
336
+ context?: number;
337
+ }
338
+
306
339
  /** Why a call that asked for `record: true` is not recorded (0.65.0).
307
340
  * - `not_enabled` — this agent's recording switch is off (the token lacks
308
341
  * `call.record`; turn it on in the dashboard, then mint a new token).
@@ -2150,6 +2183,8 @@ export class CompanionClient {
2150
2183
  ...(creds.recording !== undefined ? { recording: creds.recording } : {}),
2151
2184
  ...(creds.recordingUnavailable ? { recordingUnavailable: creds.recordingUnavailable } : {}),
2152
2185
  ...(creds.worldBrief ? { worldBrief: creds.worldBrief } : {}),
2186
+ ...(creds.privateStory ? { privateStory: creds.privateStory } : {}),
2187
+ ...(creds.trimmed ? { trimmed: creds.trimmed } : {}),
2153
2188
  close: () => {
2154
2189
  close();
2155
2190
  cleanup();
package/src/index.ts CHANGED
@@ -31,6 +31,8 @@ export type {
31
31
  CallRecordingUnavailable,
32
32
  CallRecordingResult,
33
33
  CallWorldBrief,
34
+ CallPrivateStory,
35
+ CallTrimmed,
34
36
  CompanionToolDecl,
35
37
  ToolCallEvent,
36
38
  WorldStateInput,