@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/CHANGELOG.md +57 -4
- package/README.md +24 -5
- package/dist/call.d.ts +50 -2
- package/dist/call.d.ts.map +1 -1
- package/dist/call.js +112 -8
- package/dist/call.js.map +1 -1
- package/dist/client.d.ts +36 -5
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +2 -0
- package/dist/client.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/call.ts +158 -8
- package/src/client.ts +40 -5
- package/src/index.ts +2 -0
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 {
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
895
|
+
speechMuted = true;
|
|
896
|
+
applyMic();
|
|
781
897
|
} else {
|
|
782
898
|
if (micUnmuteTimer) clearTimeout(micUnmuteTimer);
|
|
783
899
|
micUnmuteTimer = setTimeout(() => {
|
|
784
900
|
micUnmuteTimer = null;
|
|
785
|
-
if (
|
|
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
|
|
297
|
-
*
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
*
|
|
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();
|