@convai/web-sdk 1.8.0-beta.5 → 1.8.0-beta.6

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.
Files changed (60) hide show
  1. package/README.md +49 -13
  2. package/dist/core/CharacterRoster.d.ts +84 -0
  3. package/dist/core/CharacterRoster.d.ts.map +1 -0
  4. package/dist/core/CharacterRoster.js +348 -0
  5. package/dist/core/CharacterRoster.js.map +1 -0
  6. package/dist/core/ConvaiClient.d.ts +94 -1
  7. package/dist/core/ConvaiClient.d.ts.map +1 -1
  8. package/dist/core/ConvaiClient.js +583 -47
  9. package/dist/core/ConvaiClient.js.map +1 -1
  10. package/dist/core/ConvaiRoomError.d.ts +48 -0
  11. package/dist/core/ConvaiRoomError.d.ts.map +1 -0
  12. package/dist/core/ConvaiRoomError.js +111 -0
  13. package/dist/core/ConvaiRoomError.js.map +1 -0
  14. package/dist/core/MessageHandler.d.ts +14 -0
  15. package/dist/core/MessageHandler.d.ts.map +1 -1
  16. package/dist/core/MessageHandler.js +111 -5
  17. package/dist/core/MessageHandler.js.map +1 -1
  18. package/dist/core/SSESession.d.ts +15 -0
  19. package/dist/core/SSESession.d.ts.map +1 -1
  20. package/dist/core/SSESession.js +92 -3
  21. package/dist/core/SSESession.js.map +1 -1
  22. package/dist/core/connectRequest.d.ts +1 -0
  23. package/dist/core/connectRequest.d.ts.map +1 -1
  24. package/dist/core/connectRequest.js +11 -0
  25. package/dist/core/connectRequest.js.map +1 -1
  26. package/dist/core/index.d.ts +3 -0
  27. package/dist/core/index.d.ts.map +1 -1
  28. package/dist/core/index.js +3 -0
  29. package/dist/core/index.js.map +1 -1
  30. package/dist/core/rosterRequest.d.ts +57 -0
  31. package/dist/core/rosterRequest.d.ts.map +1 -0
  32. package/dist/core/rosterRequest.js +210 -0
  33. package/dist/core/rosterRequest.js.map +1 -0
  34. package/dist/core/types.d.ts +462 -5
  35. package/dist/core/types.d.ts.map +1 -1
  36. package/dist/core/types.js.map +1 -1
  37. package/dist/react/components/rtc-widget/components/MarkdownRenderer.d.ts.map +1 -1
  38. package/dist/react/components/rtc-widget/components/MarkdownRenderer.js +39 -44
  39. package/dist/react/components/rtc-widget/components/MarkdownRenderer.js.map +1 -1
  40. package/dist/react/hooks/useConvaiClient.d.ts.map +1 -1
  41. package/dist/react/hooks/useConvaiClient.js +8 -0
  42. package/dist/react/hooks/useConvaiClient.js.map +1 -1
  43. package/dist/utils/inlineMarkdown.d.ts +38 -0
  44. package/dist/utils/inlineMarkdown.d.ts.map +1 -0
  45. package/dist/utils/inlineMarkdown.js +106 -0
  46. package/dist/utils/inlineMarkdown.js.map +1 -0
  47. package/dist/vanilla/AudioRenderer.d.ts.map +1 -1
  48. package/dist/vanilla/AudioRenderer.js +6 -0
  49. package/dist/vanilla/AudioRenderer.js.map +1 -1
  50. package/dist/vanilla/ConvaiWidget.d.ts.map +1 -1
  51. package/dist/vanilla/ConvaiWidget.js +71 -57
  52. package/dist/vanilla/ConvaiWidget.js.map +1 -1
  53. package/dist/vanilla/index.d.ts +1 -0
  54. package/dist/vanilla/index.d.ts.map +1 -1
  55. package/dist/vanilla/index.js +1 -0
  56. package/dist/vanilla/index.js.map +1 -1
  57. package/dist/version.d.ts +1 -1
  58. package/dist/version.js +1 -1
  59. package/dist/version.js.map +1 -1
  60. package/package.json +1 -1
@@ -5,13 +5,16 @@ import { AudioManager } from "./AudioManager.js";
5
5
  import { VideoManager } from "./VideoManager.js";
6
6
  import { ScreenShareManager } from "./ScreenShareManager.js";
7
7
  import { MessageHandler } from "./MessageHandler.js";
8
+ import { CharacterRoster } from "./CharacterRoster.js";
9
+ import { buildJoinRequestBody, buildRosterConnectFields, isRosterConfig, validateJoinOptions, validateProtocolTopology, validateRosterConfig, } from "./rosterRequest.js";
10
+ import { ConvaiRoomError, roomErrorFromResponse } from "./ConvaiRoomError.js";
8
11
  import { SDK_VERSION } from "../version.js";
9
12
  import { EventEmitter } from "./EventEmitter.js";
10
13
  import { MemoryManager } from "./MemoryManager.js";
11
14
  import { CharacterVersionManager } from "./CharacterVersionManager.js";
12
15
  import { resolveCharacterReference, } from "./characterReference.js";
13
16
  import { ConnectionStateHandler } from "./ConnectionStateHandler.js";
14
- import { buildActionConnectConfig, buildBlendshapeConnectConfig, buildEmotionConnectConfig, } from "./connectRequest.js";
17
+ import { buildActionConnectConfig, buildBlendshapeConnectConfig, buildEmotionConnectConfig, serializeActionProtocolCapabilities, } from "./connectRequest.js";
15
18
  import { shouldPreemptForContextUpdateResponse, shouldPreemptForExplicitRespondMode, } from "./contextUpdateRequest.js";
16
19
  import { resolveVisionInputConfig, serializeVisionInputConfig, } from "./visionRequest.js";
17
20
  import { SSESession } from "./SSESession.js";
@@ -88,6 +91,11 @@ export class ConvaiClient extends EventEmitter {
88
91
  this._characterVersionManager = null;
89
92
  this._characterVersionManagerKey = null;
90
93
  this._characterSessionId = "-1";
94
+ /** Roster state for multi-character rooms. Empty for single-character sessions. */
95
+ this._roster = new CharacterRoster();
96
+ /** Roster commands awaiting their ack, keyed by command id. */
97
+ this._pendingRosterCommands = new Map();
98
+ this._rosterCommandSeq = 0;
91
99
  this._isBotReady = false;
92
100
  this._participantSid = "";
93
101
  this._storedConfig = null;
@@ -116,6 +124,23 @@ export class ConvaiClient extends EventEmitter {
116
124
  if (config) {
117
125
  this._storedConfig = config;
118
126
  this._logRtviMessages = config.logRtviMessages !== false;
127
+ // Derive the version readouts from the config eagerly, as
128
+ // characterVersions already is, so a React consumer destructuring
129
+ // characterReference off the hook sees the selector on first render
130
+ // rather than null until connect(). A malformed selector is left for
131
+ // connect() to reject -- the constructor has never thrown on config and
132
+ // starting to would be a behaviour change.
133
+ if (config.characterId && !isRosterConfig(config)) {
134
+ try {
135
+ const ref = resolveCharacterReference(config.characterId, config.characterVersion);
136
+ this._characterId = ref.characterId;
137
+ this._characterVersion = ref.version;
138
+ this._characterReference = ref.reference;
139
+ }
140
+ catch {
141
+ /* reported by connect() */
142
+ }
143
+ }
119
144
  }
120
145
  // Initialize room with no reconnect policy
121
146
  this._room = new Room({
@@ -133,6 +158,8 @@ export class ConvaiClient extends EventEmitter {
133
158
  endUserId: null,
134
159
  endUserMetadata: null,
135
160
  metrics: [],
161
+ characters: [],
162
+ activeMembershipId: null,
136
163
  disconnectReason: null,
137
164
  };
138
165
  // Default audio settings for optimal interruption handling
@@ -223,6 +250,41 @@ export class ConvaiClient extends EventEmitter {
223
250
  get characterSessionId() {
224
251
  return this._characterSessionId;
225
252
  }
253
+ /**
254
+ * Server-owned snapshot of a multi-character room, or null for a
255
+ * single-character session. Carries the room id, epochs and roster.
256
+ */
257
+ get roomSession() {
258
+ return this._roster.roomSession;
259
+ }
260
+ /**
261
+ * Character instances in a multi-character room, in roster order. Empty for a
262
+ * single-character session, so `length` distinguishes the two.
263
+ */
264
+ get characters() {
265
+ return this._roster.characters;
266
+ }
267
+ /** Membership currently receiving user turns, or null. */
268
+ get activeMembershipId() {
269
+ return this._roster.activeMembershipId;
270
+ }
271
+ /**
272
+ * Start or stop the server streaming per-turn usage/cost breakdowns as
273
+ * `usageUpdate` events. Informational only; server-side usage tracking and
274
+ * billing are unaffected either way.
275
+ */
276
+ toggleUsageUpdates(enabled) {
277
+ if (!this.isTransportReady())
278
+ return;
279
+ this.publishMessage("usage-toggle", { enabled });
280
+ }
281
+ /** Insert a transcript line locally. Nothing is sent; nothing is billed. */
282
+ appendMessage(message) {
283
+ if (!message?.content?.trim()) {
284
+ throw new Error("appendMessage requires non-empty content");
285
+ }
286
+ return this._messageHandler.appendLocalMessage(message);
287
+ }
226
288
  get isBotReady() {
227
289
  return this._isBotReady;
228
290
  }
@@ -249,12 +311,60 @@ export class ConvaiClient extends EventEmitter {
249
311
  */
250
312
  setupEventListeners() {
251
313
  // Room event listeners
314
+ // Per-membership audio binding. In a roster room every character is its
315
+ // own participant with identity `character:{membership_id}`; the guide's
316
+ // rule is to bind a track to the exact member or drop it, never to fall
317
+ // back to the active character. Single-character rooms keep the legacy
318
+ // behaviour: the track is surfaced with no attribution.
319
+ this._room.on(RoomEvent.TrackSubscribed, (track, _publication, participant) => {
320
+ if (track.kind !== "audio")
321
+ return;
322
+ const identity = participant.identity;
323
+ if (!this._roster.isMultiCharacter) {
324
+ this.emit("characterAudioTrack", {
325
+ membershipId: null,
326
+ characterId: this._characterId,
327
+ participantIdentity: identity,
328
+ track: track.mediaStreamTrack,
329
+ });
330
+ return;
331
+ }
332
+ const membershipId = identity.startsWith("character:")
333
+ ? identity.slice("character:".length)
334
+ : null;
335
+ const member = membershipId ? this._roster.find(membershipId) : undefined;
336
+ if (!member) {
337
+ // Unattributable in a roster room: quarantine rather than guess.
338
+ if (this._logRtviMessages) {
339
+ console.warn(`[ConvaiClient] dropped audio track from unattributed participant "${identity}"`);
340
+ }
341
+ return;
342
+ }
343
+ this.emit("characterAudioTrack", {
344
+ membershipId: member.membershipId,
345
+ characterId: member.characterId,
346
+ participantIdentity: identity,
347
+ track: track.mediaStreamTrack,
348
+ });
349
+ });
252
350
  this._room.on(RoomEvent.Disconnected, (reason) => {
253
351
  this._connectionStateHandler.handleDisconnected(reason);
254
352
  });
255
353
  this._room.on(RoomEvent.ConnectionStateChanged, this._connectionStateHandler.handleConnectionStateChanged.bind(this._connectionStateHandler));
256
354
  // Message handler events
257
- this._messageHandler.on("botReady", () => {
355
+ this._messageHandler.on("botReady", (about) => {
356
+ // Each roster member sends its own bot-ready. Feed the roster before the
357
+ // de-dupe below, which only guards the single consumer-facing event --
358
+ // otherwise every member after the first would be dropped.
359
+ if (this._roster.applyBotReady(about)) {
360
+ this.updateState({
361
+ characters: this._roster.characters,
362
+ activeMembershipId: this._roster.activeMembershipId,
363
+ });
364
+ const membershipId = about?.membership_id;
365
+ if (membershipId)
366
+ this.emit("characterReady", this._roster.find(membershipId));
367
+ }
258
368
  // Server may send multiple bot-ready frames (one per client-ready
259
369
  // we retried). Only surface the first to consumers per connect.
260
370
  if (this._isBotReady) {
@@ -348,6 +458,9 @@ export class ConvaiClient extends EventEmitter {
348
458
  this._messageHandler.on("llmNoResponse", () => {
349
459
  this.emit("llmNoResponse");
350
460
  });
461
+ this._messageHandler.on("botTurnCompleted", (data) => {
462
+ this.emit("botTurnCompleted", data);
463
+ });
351
464
  // Forward bot-output event (aggregated output with spoken status)
352
465
  this._messageHandler.on("botOutput", (data) => {
353
466
  this.emit("botOutput", data);
@@ -373,7 +486,20 @@ export class ConvaiClient extends EventEmitter {
373
486
  this._messageHandler.on("actionResponse", (data) => {
374
487
  this.emit("actionResponse", data);
375
488
  });
489
+ this._messageHandler.on("usageUpdate", (data) => {
490
+ this.emit("usageUpdate", data);
491
+ });
492
+ this._messageHandler.on("characterStatus", (status) => {
493
+ if (this._roster.applyCharacterStatus(status)) {
494
+ this.updateState({
495
+ characters: this._roster.characters,
496
+ activeMembershipId: this._roster.activeMembershipId,
497
+ });
498
+ }
499
+ this.emit("characterStatus", status);
500
+ });
376
501
  this._messageHandler.on("serverResponse", (response) => {
502
+ this._handleRosterServerResponse(response);
377
503
  this.emit("serverResponse", response);
378
504
  });
379
505
  this._messageHandler.on("interactionCreated", (data) => {
@@ -520,6 +646,14 @@ export class ConvaiClient extends EventEmitter {
520
646
  enableEmotion: configWithDefaults.enableEmotion,
521
647
  emotionConfig: configWithDefaults.emotionConfig,
522
648
  actionConfig: configWithDefaults.actionConfig,
649
+ capabilities: configWithDefaults.capabilities,
650
+ dynamicInfo: configWithDefaults.dynamicInfo,
651
+ keepInContext: configWithDefaults.keepInContext,
652
+ narrativeTemplateKeys: configWithDefaults.narrativeTemplateKeys,
653
+ sceneDescription: configWithDefaults.sceneDescription,
654
+ respondModes: configWithDefaults.respondModes,
655
+ useResponseLifecycleContract: configWithDefaults.useResponseLifecycleContract,
656
+ invocationMetadata: configWithDefaults.invocationMetadata,
523
657
  debug: configWithDefaults.debug,
524
658
  onMessage: (payload) => this._messageHandler.handleDataReceivedPublic(payload),
525
659
  onError: (error) => this.emit("error", error),
@@ -539,8 +673,11 @@ export class ConvaiClient extends EventEmitter {
539
673
  }
540
674
  // Prepare request body with required parameters
541
675
  const characterSessionIdToSend = configWithDefaults.characterSessionId ?? this._characterSessionId;
676
+ const isRoster = isRosterConfig(configWithDefaults);
542
677
  const requestBody = {
543
- character_id: configWithDefaults.characterReference,
678
+ ...(isRoster
679
+ ? buildRosterConnectFields(configWithDefaults)
680
+ : { character_id: configWithDefaults.characterReference }),
544
681
  ...(stateOfMind ? { state_of_mind: stateOfMind } : {}),
545
682
  ...(configWithDefaults.endUserId && {
546
683
  end_user_id: configWithDefaults.endUserId,
@@ -551,7 +688,6 @@ export class ConvaiClient extends EventEmitter {
551
688
  transport: transportType,
552
689
  connection_type: connType,
553
690
  ...buildBlendshapeConnectConfig(configWithDefaults),
554
- llm_provider: "dynamic",
555
691
  ...(buildEmotionConnectConfig(configWithDefaults) && {
556
692
  emotion_config: buildEmotionConnectConfig(configWithDefaults),
557
693
  }),
@@ -565,7 +701,16 @@ export class ConvaiClient extends EventEmitter {
565
701
  ...(configWithDefaults.respondModes && {
566
702
  respond_modes: this.serializeRespondModes(configWithDefaults.respondModes),
567
703
  }),
568
- ...(characterSessionIdToSend &&
704
+ ...(serializeActionProtocolCapabilities(configWithDefaults.capabilities) && {
705
+ capabilities: serializeActionProtocolCapabilities(configWithDefaults.capabilities),
706
+ }),
707
+ ...(configWithDefaults.sceneDescription && {
708
+ scene_description: configWithDefaults.sceneDescription,
709
+ }),
710
+ // A roster room owns its members' sessions; the singular resume id is
711
+ // a compatibility mirror and must not go back on the wire.
712
+ ...(!isRoster &&
713
+ characterSessionIdToSend &&
569
714
  characterSessionIdToSend !== "-1" && {
570
715
  character_session_id: characterSessionIdToSend,
571
716
  }),
@@ -587,6 +732,12 @@ export class ConvaiClient extends EventEmitter {
587
732
  ...(configWithDefaults.debug !== undefined && {
588
733
  debug: configWithDefaults.debug,
589
734
  }),
735
+ ...(configWithDefaults.preserveLinksInOutput !== undefined && {
736
+ preserve_links_in_output: configWithDefaults.preserveLinksInOutput,
737
+ }),
738
+ ...(configWithDefaults.useResponseLifecycleContract !== undefined && {
739
+ use_response_lifecycle_contract: configWithDefaults.useResponseLifecycleContract,
740
+ }),
590
741
  invocation_metadata: {
591
742
  source: configWithDefaults.invocationMetadata?.source ?? "web_sdk",
592
743
  client_version: configWithDefaults.invocationMetadata?.clientVersion ??
@@ -605,41 +756,62 @@ export class ConvaiClient extends EventEmitter {
605
756
  else if (configWithDefaults.apiKey) {
606
757
  connectHeaders["X-API-Key"] = configWithDefaults.apiKey;
607
758
  }
608
- // HTTP POST /connect — same request for both transports
609
- const response = await fetch(`${configWithDefaults.url}/connect`, {
610
- method: "POST",
611
- headers: connectHeaders,
612
- body: JSON.stringify(requestBody),
613
- });
614
- if (!response.ok) {
615
- const errorText = await response.text();
616
- let errorMessage = `HTTP ${response.status}`;
617
- try {
618
- const errorData = JSON.parse(errorText);
619
- const raw = errorData.message ?? errorData.error ?? errorData.detail;
620
- if (typeof raw === "string") {
621
- errorMessage = raw;
622
- }
623
- else if (Array.isArray(raw)) {
624
- // FastAPI/Pydantic 422 detail is an array of validation error objects
625
- errorMessage = raw
626
- .map((e) => [e.loc?.join("."), e.msg].filter(Boolean).join(": "))
627
- .join("; ") || errorMessage;
759
+ // HTTP POST /connect — same request for both transports. A roster room
760
+ // may still be provisioning from an identical concurrent create, which
761
+ // the runtime answers with a retryable 409; the wrapper replays the
762
+ // identical payload and is a straight pass-through otherwise.
763
+ const connectOnce = async () => {
764
+ const response = await fetch(`${configWithDefaults.url}/connect`, {
765
+ method: "POST",
766
+ headers: connectHeaders,
767
+ body: JSON.stringify(requestBody),
768
+ });
769
+ if (!response.ok) {
770
+ const errorText = await response.text();
771
+ let errorMessage = `HTTP ${response.status}`;
772
+ try {
773
+ const errorData = JSON.parse(errorText);
774
+ const raw = errorData.message ?? errorData.error ?? errorData.detail;
775
+ if (typeof raw === "string") {
776
+ errorMessage = raw;
777
+ }
778
+ else if (Array.isArray(raw)) {
779
+ // FastAPI/Pydantic 422 — detail is an array of validation error objects
780
+ errorMessage = raw
781
+ .map((e) => [e.loc?.join("."), e.msg].filter(Boolean).join(": "))
782
+ .join("; ") || errorMessage;
783
+ }
784
+ else if (raw != null) {
785
+ errorMessage = JSON.stringify(raw);
786
+ }
787
+ else if (errorText) {
788
+ errorMessage = `${errorMessage}: ${errorText}`;
789
+ }
628
790
  }
629
- else if (raw != null) {
630
- errorMessage = JSON.stringify(raw);
791
+ catch {
792
+ if (errorText)
793
+ errorMessage = `${errorMessage}: ${errorText}`;
631
794
  }
632
- else if (errorText) {
633
- errorMessage = `${errorMessage}: ${errorText}`;
795
+ // A roster room's failures are named and machine-actionable, and the
796
+ // trace id is what backend support correlates on, so they get a typed
797
+ // error. Single-character behaviour is unchanged.
798
+ if (isRoster) {
799
+ let parsed = errorText;
800
+ try {
801
+ parsed = JSON.parse(errorText);
802
+ }
803
+ catch {
804
+ /* keep the raw text as the detail */
805
+ }
806
+ throw roomErrorFromResponse(response.status, parsed);
634
807
  }
808
+ throw new Error(errorMessage);
635
809
  }
636
- catch {
637
- if (errorText)
638
- errorMessage = `${errorMessage}: ${errorText}`;
639
- }
640
- throw new Error(errorMessage);
641
- }
642
- const connectionData = await response.json();
810
+ return await response.json();
811
+ };
812
+ const connectionData = isRoster
813
+ ? await this._withProvisioningRetry(configWithDefaults, connectOnce)
814
+ : await connectOnce();
643
815
  return await this.consumeConnectionData(connectionData, configWithDefaults);
644
816
  }
645
817
  catch (error) {
@@ -703,6 +875,270 @@ export class ConvaiClient extends EventEmitter {
703
875
  throw error;
704
876
  }
705
877
  }
878
+ /** Correlate roster acks back to the promise that issued the command. */
879
+ _handleRosterServerResponse(response) {
880
+ const eventType = response.event_type;
881
+ if (eventType !== "interaction-target" &&
882
+ eventType !== "character-roster-update") {
883
+ return;
884
+ }
885
+ const extras = response.extras ?? null;
886
+ const commandId = extras?.command_id;
887
+ const pending = commandId
888
+ ? this._pendingRosterCommands.get(commandId)
889
+ : undefined;
890
+ if (response.status === "error") {
891
+ // Adopt the server's epochs even from a rejection -- a stale-epoch error
892
+ // carries the authoritative value, and that is what makes a retry
893
+ // meaningful.
894
+ this._roster.adoptEpochs(extras);
895
+ this._pushRosterState();
896
+ if (pending && commandId) {
897
+ this._pendingRosterCommands.delete(commandId);
898
+ clearTimeout(pending.timer);
899
+ pending.reject(Object.assign(new Error(response.message ?? `${eventType} was rejected`), { code: extras?.code ?? null, extras }));
900
+ }
901
+ return;
902
+ }
903
+ // Apply the change whether or not this client asked for it: another
904
+ // participant's switch reaches us through the same event.
905
+ let change;
906
+ if (eventType === "interaction-target") {
907
+ change = this._roster.applyInteractionTarget(extras);
908
+ this._pushRosterState();
909
+ this.emit("activeCharacterChange", change);
910
+ }
911
+ else {
912
+ change = this._roster.applyRosterUpdate(extras);
913
+ this._pushRosterState();
914
+ this.emit("rosterChange", change);
915
+ }
916
+ if (pending && commandId) {
917
+ this._pendingRosterCommands.delete(commandId);
918
+ clearTimeout(pending.timer);
919
+ pending.resolve(change);
920
+ }
921
+ }
922
+ /**
923
+ * Settle every in-flight roster command.
924
+ *
925
+ * Without this a caller who disconnects mid-command keeps a live 10s timer
926
+ * and gets a rejection long after the session ended -- an unhandled rejection
927
+ * if they stopped awaiting when they tore the client down. The roster is
928
+ * reset alongside, so the command could not be applied meaningfully anyway.
929
+ */
930
+ _failPendingRosterCommands(reason) {
931
+ for (const [, pending] of this._pendingRosterCommands) {
932
+ clearTimeout(pending.timer);
933
+ pending.reject(new Error(reason));
934
+ }
935
+ this._pendingRosterCommands.clear();
936
+ }
937
+ _pushRosterState() {
938
+ this.updateState({
939
+ characters: this._roster.characters,
940
+ activeMembershipId: this._roster.activeMembershipId,
941
+ });
942
+ }
943
+ /** Publish a roster command and settle when its ack arrives. */
944
+ _sendRosterCommand(type, build) {
945
+ if (!this._roster.isMultiCharacter) {
946
+ return Promise.reject(new Error(`Cannot send ${type}: this session was not created with a characters array`));
947
+ }
948
+ if (!this.isTransportReady()) {
949
+ return Promise.reject(new Error(`Cannot send ${type}: not connected`));
950
+ }
951
+ const commandId = `${type}-${++this._rosterCommandSeq}-${Date.now()}`;
952
+ return new Promise((resolve, reject) => {
953
+ const timer = setTimeout(() => {
954
+ this._pendingRosterCommands.delete(commandId);
955
+ reject(new Error(`${type} was not acknowledged within ${ConvaiClient.ROSTER_COMMAND_TIMEOUT_MS}ms. ` +
956
+ `The outcome is unknown -- acks can be suppressed as duplicates. ` +
957
+ `Reconcile from the next authoritative room state before sending a conflicting command.`));
958
+ }, ConvaiClient.ROSTER_COMMAND_TIMEOUT_MS);
959
+ this._pendingRosterCommands.set(commandId, { resolve, reject, timer });
960
+ this.publishMessage(type, build(commandId), commandId);
961
+ });
962
+ }
963
+ /**
964
+ * Route subsequent user turns to a different character instance.
965
+ *
966
+ * Pass null to close every gate, so no member consumes the user until a
967
+ * target is set again.
968
+ */
969
+ async setInteractionTarget(membershipId) {
970
+ if (!this._roster.isMultiCharacter) {
971
+ throw new Error("Cannot send interaction-target: this session was not created with a characters array");
972
+ }
973
+ if (membershipId !== null) {
974
+ if (!membershipId) {
975
+ throw new Error("setInteractionTarget requires a membershipId, or null to close all gates");
976
+ }
977
+ const instance = this._roster.find(membershipId);
978
+ if (this._roster.isMultiCharacter && !instance) {
979
+ throw new Error(`Unknown membershipId "${membershipId}". Address instances by membershipId, not characterId.`);
980
+ }
981
+ if (instance && !instance.isReady) {
982
+ throw new Error(`Membership "${membershipId}" has not sent bot-ready yet; the runtime refuses an unready target`);
983
+ }
984
+ }
985
+ const send = () => this._sendRosterCommand("interaction-target", (commandId) => this._roster.buildInteractionTarget(membershipId, commandId));
986
+ try {
987
+ return (await send());
988
+ }
989
+ catch (error) {
990
+ // Another participant switched first. The roster adopted the server's
991
+ // epoch when the rejection arrived, so exactly one retry is now fenced
992
+ // correctly; a second would mean we are losing a race we should report.
993
+ if (error?.code !== "stale_route_epoch")
994
+ throw error;
995
+ return (await send());
996
+ }
997
+ }
998
+ /** Add or remove character instances on a live room, without reconnecting. */
999
+ async updateCharacterRoster(options) {
1000
+ // Report the wrong-session-shape first. Otherwise a single-character
1001
+ // client, whose roster is empty, trips the "cannot be emptied" rule below
1002
+ // and gets an error about a room it never had.
1003
+ if (!this._roster.isMultiCharacter) {
1004
+ throw new Error("Cannot send character-roster-update: this session was not created with a characters array");
1005
+ }
1006
+ const add = options?.add ?? [];
1007
+ const remove = options?.remove ?? [];
1008
+ if (add.length === 0 && remove.length === 0) {
1009
+ throw new Error("updateCharacterRoster requires at least one add or remove");
1010
+ }
1011
+ if (add.some((c) => !c?.characterId?.trim())) {
1012
+ throw new Error("Every entry in add requires a characterId");
1013
+ }
1014
+ if (remove.length && remove.length >= this._roster.characters.length && add.length === 0) {
1015
+ throw new Error("A roster room cannot be emptied; add a replacement or keep one member");
1016
+ }
1017
+ // Removing the active member needs an explicit, ready replacement --
1018
+ // the runtime answers `replacement_target_required` otherwise, and
1019
+ // `replacement_target_unavailable` for one that has not sent bot-ready.
1020
+ const active = this._roster.activeMembershipId;
1021
+ const effective = !options.replacementTarget && active && remove.includes(active)
1022
+ ? { ...options, replacementTarget: this._roster.pickReplacement(remove) }
1023
+ : options;
1024
+ return (await this._sendRosterCommand("character-roster-update", (commandId) => this._roster.buildRosterUpdate(effective, commandId)));
1025
+ }
1026
+ /**
1027
+ * Run a room request, retrying only the conflicts that mean "still working".
1028
+ *
1029
+ * The runtime answers a create that collides with an in-flight identical
1030
+ * create with 409 `ROSTER_PROVISIONING_IN_PROGRESS`, and the documented
1031
+ * recovery is to retry the same key with bounded backoff. Left to callers,
1032
+ * every integration reimplements that, usually without a cap. The payload is
1033
+ * replayed verbatim -- changing it would turn the retry into a fingerprint
1034
+ * mismatch.
1035
+ */
1036
+ async _withProvisioningRetry(config, run) {
1037
+ const setting = config.rosterProvisioningRetry;
1038
+ if (setting === false)
1039
+ return run();
1040
+ const attempts = Math.max(1, setting?.attempts ?? ConvaiClient.ROSTER_RETRY_ATTEMPTS);
1041
+ const initialDelayMs = Math.max(0, setting?.initialDelayMs ?? ConvaiClient.ROSTER_RETRY_INITIAL_MS);
1042
+ let lastError;
1043
+ for (let attempt = 0; attempt < attempts; attempt++) {
1044
+ try {
1045
+ return await run();
1046
+ }
1047
+ catch (error) {
1048
+ lastError = error;
1049
+ const retryable = error instanceof ConvaiRoomError && error.isRetryable;
1050
+ if (!retryable || attempt === attempts - 1)
1051
+ throw error;
1052
+ // Jitter so a lobby of clients waking on the same room does not
1053
+ // retry in lockstep.
1054
+ const base = initialDelayMs * 2 ** attempt;
1055
+ const jittered = base * (0.8 + Math.random() * 0.4);
1056
+ await new Promise((resolve) => setTimeout(resolve, jittered));
1057
+ }
1058
+ }
1059
+ throw lastError;
1060
+ }
1061
+ /**
1062
+ * Join a multi-character room that already exists.
1063
+ *
1064
+ * A join supplies a locator and the joining human, and nothing else: the
1065
+ * roster is server-owned, so resending topology is rejected. It never spawns,
1066
+ * removes or repairs a character -- only room creation does that.
1067
+ *
1068
+ * Capacity counts distinct speakers derived from `endUserId`, and defaults to
1069
+ * one, so raise `maxNumParticipants` at creation before expecting a second
1070
+ * human to get in.
1071
+ *
1072
+ * @example
1073
+ * ```ts
1074
+ * const client = new ConvaiClient({ apiKey: 'YOUR_API_KEY' })
1075
+ * await client.joinRoom({
1076
+ * roomSessionId: 'the-room-session-id',
1077
+ * endUserId: 'player-99',
1078
+ * })
1079
+ * ```
1080
+ */
1081
+ async joinRoom(options) {
1082
+ validateJoinOptions(options);
1083
+ const stored = this._storedConfig ?? {};
1084
+ const url = stored.url ?? "https://realtime-api.convai.com";
1085
+ if (!stored.apiKey && !stored.authToken) {
1086
+ throw new Error("Either apiKey or authToken is required to join a room");
1087
+ }
1088
+ this._logRtviMessages = stored.logRtviMessages !== false;
1089
+ this._messageHandler.setRtviMessageLogging(this._logRtviMessages);
1090
+ this.updateState({ isConnecting: true });
1091
+ try {
1092
+ const connectionData = await this._withProvisioningRetry(stored, async () => {
1093
+ const response = await fetch(`${url}/connect`, {
1094
+ method: "POST",
1095
+ headers: {
1096
+ "Content-Type": "application/json",
1097
+ ...(stored.apiKey
1098
+ ? { "X-API-Key": stored.apiKey }
1099
+ : { "API-AUTH-TOKEN": stored.authToken }),
1100
+ },
1101
+ body: JSON.stringify(buildJoinRequestBody(options)),
1102
+ });
1103
+ if (!response.ok) {
1104
+ const text = await response.text();
1105
+ let parsed = text;
1106
+ try {
1107
+ parsed = JSON.parse(text);
1108
+ }
1109
+ catch {
1110
+ /* keep the raw text as the detail */
1111
+ }
1112
+ throw roomErrorFromResponse(response.status, parsed);
1113
+ }
1114
+ return (await response.json());
1115
+ });
1116
+ // The join response carries the persisted roster, so the joining client
1117
+ // learns the topology it never sent.
1118
+ const rosterCharacters = connectionData.characters;
1119
+ const initialCharacterId = rosterCharacters?.[0]?.character_id;
1120
+ const joinConfig = {
1121
+ ...stored,
1122
+ characters: undefined,
1123
+ characterId: initialCharacterId ?? stored.characterId,
1124
+ endUserId: options.endUserId,
1125
+ ...(options.endUserMetadata
1126
+ ? { endUserMetadata: options.endUserMetadata }
1127
+ : {}),
1128
+ };
1129
+ this._storedConfig = joinConfig;
1130
+ const configWithDefaults = this.applyConfigDefaults(joinConfig, {
1131
+ requireCredential: false,
1132
+ });
1133
+ return await this.consumeConnectionData(connectionData, configWithDefaults);
1134
+ }
1135
+ catch (error) {
1136
+ await this.cleanupWebSocketSessionAfterConnectFailure();
1137
+ this.updateState({ isConnected: false, isConnecting: false });
1138
+ this.emit("error", error);
1139
+ throw error;
1140
+ }
1141
+ }
706
1142
  /**
707
1143
  * Add default URL, apply RTVI-logging config, and validate that either an
708
1144
  * API key or auth token plus a character ID are present.
@@ -727,9 +1163,27 @@ export class ConvaiClient extends EventEmitter {
727
1163
  const hasApiKey = Boolean(configWithDefaults.apiKey);
728
1164
  const hasAuthToken = Boolean(configWithDefaults.authToken);
729
1165
  const missingCredential = requireCredential && !hasApiKey && !hasAuthToken;
730
- if (missingCredential || !configWithDefaults.characterId) {
1166
+ // Runs for every topology: the v2 protocol constraints rule out
1167
+ // sharedSessionKey and extra human capacity in single-character rooms too.
1168
+ validateProtocolTopology(configWithDefaults);
1169
+ const roster = isRosterConfig(configWithDefaults);
1170
+ if (missingCredential || (!roster && !configWithDefaults.characterId)) {
731
1171
  throw new Error("Either apiKey or authToken is required, and characterId is required");
732
1172
  }
1173
+ // A roster names its members individually; every roster rule is checked
1174
+ // before a request is built.
1175
+ if (roster) {
1176
+ validateRosterConfig(configWithDefaults);
1177
+ const initial = configWithDefaults.characters[0].characterId.trim();
1178
+ return {
1179
+ ...configWithDefaults,
1180
+ // Singular readouts mirror characters[0], exactly as the wire's own
1181
+ // top-level session_id/character_session_id aliases do.
1182
+ characterId: initial,
1183
+ characterVersion: null,
1184
+ characterReference: initial,
1185
+ };
1186
+ }
733
1187
  // Split the character id from its version selector. The bare UUID names
734
1188
  // the character everywhere inside the SDK (memory, character info); the
735
1189
  // joined reference is what the runtime receives.
@@ -790,6 +1244,14 @@ export class ConvaiClient extends EventEmitter {
790
1244
  characterSessionId: connectionData.character_session_id,
791
1245
  };
792
1246
  }
1247
+ // Seed the roster before the transport comes up, so the first bot-ready and
1248
+ // any early roster change already have instances to match against.
1249
+ if (this._roster.applyConnectResponse(connectionData)) {
1250
+ this.updateState({
1251
+ characters: this._roster.characters,
1252
+ activeMembershipId: this._roster.activeMembershipId,
1253
+ });
1254
+ }
793
1255
  if (connectionData.end_user_id) {
794
1256
  this._endUserId = connectionData.end_user_id;
795
1257
  }
@@ -1027,6 +1489,10 @@ export class ConvaiClient extends EventEmitter {
1027
1489
  */
1028
1490
  async disconnect() {
1029
1491
  this._stopClientReadyHandshake();
1492
+ // Unconditional: the branches below each depend on a transport being
1493
+ // matched, and none of them runs when there is nothing to tear down. A
1494
+ // command in flight must still be settled either way.
1495
+ this._failPendingRosterCommands("Disconnected before the roster command was acknowledged; its outcome is unknown");
1030
1496
  if (this._activeTransport === "sse" && this._sseSession) {
1031
1497
  await this._sseSession.disconnect();
1032
1498
  this._connectionStateHandler.resetConnectionState();
@@ -1059,6 +1525,15 @@ export class ConvaiClient extends EventEmitter {
1059
1525
  this.clearAllManagers();
1060
1526
  }
1061
1527
  }
1528
+ else if (this._state.isConnected || this._state.isConnecting) {
1529
+ // No transport matched, but the client believes it is (or is becoming)
1530
+ // connected -- a connect() that failed after flipping isConnecting, or
1531
+ // was torn down underneath us. Without this branch disconnect() was a
1532
+ // silent no-op here and the flags stayed stuck, so a UI bound to
1533
+ // stateChange kept showing a session that did not exist.
1534
+ this._connectionStateHandler.resetConnectionState();
1535
+ this.clearAllManagers();
1536
+ }
1062
1537
  }
1063
1538
  /**
1064
1539
  * Clear all manager states
@@ -1067,6 +1542,8 @@ export class ConvaiClient extends EventEmitter {
1067
1542
  this._connectionType = null;
1068
1543
  this._activeTransport = null;
1069
1544
  this._wsSession = null;
1545
+ this._failPendingRosterCommands("Disconnected before the roster command was acknowledged; its outcome is unknown");
1546
+ this._roster.reset();
1070
1547
  this._sseSession = null;
1071
1548
  this._apiKey = null;
1072
1549
  this._authToken = null;
@@ -1095,6 +1572,13 @@ export class ConvaiClient extends EventEmitter {
1095
1572
  * Reset the session ID to start a new conversation
1096
1573
  */
1097
1574
  resetSession() {
1575
+ // In a roster room the singular session id is a compatibility mirror of
1576
+ // characters[0], and character sessions are server-owned -- there is no
1577
+ // client-side reset for them. Throwing names the situation; a silent no-op
1578
+ // would look like it worked.
1579
+ if (this._roster.isMultiCharacter) {
1580
+ throw new Error("resetSession() is not supported in a multi-character room; character sessions are server-owned");
1581
+ }
1098
1582
  this._characterSessionId = "-1";
1099
1583
  this.emit("characterSessionId", "-1");
1100
1584
  this._messageHandler.reset();
@@ -1136,15 +1620,28 @@ export class ConvaiClient extends EventEmitter {
1136
1620
  const stateOfMind = normalizeStateOfMind(options.stateOfMind) ?? null;
1137
1621
  if (stateOfMind)
1138
1622
  this._stateOfMind = stateOfMind;
1139
- if (stateOfMind && this._activeTransport === "sse") {
1140
- this._sseSession?.sendMessages([
1141
- { type: "update-emotion", data: { state_of_mind: stateOfMind } },
1142
- {
1143
- type: "user_text_message",
1144
- data: userTextMessage,
1145
- ...(logicalTurnId !== undefined ? { id: logicalTurnId } : {}),
1146
- },
1147
- ]);
1623
+ if (this._activeTransport === "sse") {
1624
+ const requestedDynamicInfo = options.dynamicInfo?.trim();
1625
+ if (requestedDynamicInfo) {
1626
+ this._sseSession?.updateDynamicInfo(requestedDynamicInfo);
1627
+ }
1628
+ const messages = [];
1629
+ const dynamicInfo = this._sseSession?.getDynamicInfo()?.trim();
1630
+ if (dynamicInfo) {
1631
+ messages.push({
1632
+ type: "update-dynamic-info",
1633
+ data: { dynamic_info: { text: dynamicInfo } },
1634
+ });
1635
+ }
1636
+ if (stateOfMind) {
1637
+ messages.push({ type: "update-emotion", data: { state_of_mind: stateOfMind } });
1638
+ }
1639
+ messages.push({
1640
+ type: "user_text_message",
1641
+ data: userTextMessage,
1642
+ ...(logicalTurnId !== undefined ? { id: logicalTurnId } : {}),
1643
+ });
1644
+ this._sseSession?.sendMessages(messages);
1148
1645
  }
1149
1646
  else {
1150
1647
  if (stateOfMind) {
@@ -1260,8 +1757,16 @@ export class ConvaiClient extends EventEmitter {
1260
1757
  updateDynamicInfo(dynamicInfo) {
1261
1758
  if (!this.isTransportReady() || !dynamicInfo?.trim())
1262
1759
  return;
1760
+ const nextDynamicInfo = dynamicInfo.trim();
1761
+ if (this._storedConfig) {
1762
+ this._storedConfig = { ...this._storedConfig, dynamicInfo: nextDynamicInfo };
1763
+ }
1764
+ if (this._activeTransport === "sse" && this._sseSession) {
1765
+ this._sseSession.updateDynamicInfo(nextDynamicInfo);
1766
+ return;
1767
+ }
1263
1768
  this.publishMessage("update-dynamic-info", {
1264
- dynamic_info: { text: dynamicInfo },
1769
+ dynamic_info: { text: nextDynamicInfo },
1265
1770
  participant_sid: this._participantSid || this._room.localParticipant?.sid,
1266
1771
  });
1267
1772
  }
@@ -1309,6 +1814,22 @@ export class ConvaiClient extends EventEmitter {
1309
1814
  if (shouldPreemptLipsync) {
1310
1815
  this.startTrackedConversation(`[context-update:${options.mode ?? "append"}]`);
1311
1816
  }
1817
+ if (this._activeTransport === "sse" && this._sseSession) {
1818
+ const data = {
1819
+ ...(hasText && { text: options.text.trim() }),
1820
+ ...(options.mode && { mode: options.mode }),
1821
+ ...(options.run_llm && { run_llm: options.run_llm }),
1822
+ ...(options.respond_mode && { respond_mode: options.respond_mode }),
1823
+ ...(hasAttention && { current_attention_object: options.current_attention_object }),
1824
+ };
1825
+ if (options.run_llm === "false") {
1826
+ this._sseSession.queueMessage("context-update", data);
1827
+ }
1828
+ else {
1829
+ this._sseSession.sendMessage("context-update", data);
1830
+ }
1831
+ return;
1832
+ }
1312
1833
  this.publishMessage("context-update", {
1313
1834
  ...(hasText && { text: options.text.trim() }),
1314
1835
  ...(options.mode && { mode: options.mode }),
@@ -1370,6 +1891,8 @@ export class ConvaiClient extends EventEmitter {
1370
1891
  }
1371
1892
  if (!this.isTransportReady())
1372
1893
  return;
1894
+ if (this._activeTransport === "sse")
1895
+ return;
1373
1896
  this.publishMessage("update-emotion", {
1374
1897
  state_of_mind: normalized,
1375
1898
  participant_sid: this._participantSid || this._room.localParticipant?.sid,
@@ -1464,4 +1987,17 @@ export class ConvaiClient extends EventEmitter {
1464
1987
  }
1465
1988
  ConvaiClient.CLIENT_READY_RETRY_MS = 500;
1466
1989
  ConvaiClient.CLIENT_READY_TIMEOUT_MS = 45000;
1990
+ /**
1991
+ * How long to wait for a roster command's ack.
1992
+ *
1993
+ * A timeout is *not* a failure. Every character participant observes the
1994
+ * broadcast and duplicate forwards are response-suppressed after one actor
1995
+ * answers, so a lost ack is an expected outcome. The contract's rule is to
1996
+ * treat it as unknown and reconcile from the next authoritative snapshot,
1997
+ * which is what the rejection below tells the caller to do.
1998
+ */
1999
+ ConvaiClient.ROSTER_COMMAND_TIMEOUT_MS = 10000;
2000
+ /** Default bounded retry while a roster room is still provisioning. */
2001
+ ConvaiClient.ROSTER_RETRY_ATTEMPTS = 4;
2002
+ ConvaiClient.ROSTER_RETRY_INITIAL_MS = 500;
1467
2003
  //# sourceMappingURL=ConvaiClient.js.map