@convai/web-sdk 1.8.0-beta.4 → 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 (68) hide show
  1. package/README.md +64 -3
  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/CharacterVersionManager.d.ts +100 -0
  7. package/dist/core/CharacterVersionManager.d.ts.map +1 -0
  8. package/dist/core/CharacterVersionManager.js +227 -0
  9. package/dist/core/CharacterVersionManager.js.map +1 -0
  10. package/dist/core/ConvaiClient.d.ts +109 -1
  11. package/dist/core/ConvaiClient.d.ts.map +1 -1
  12. package/dist/core/ConvaiClient.js +642 -49
  13. package/dist/core/ConvaiClient.js.map +1 -1
  14. package/dist/core/ConvaiRoomError.d.ts +48 -0
  15. package/dist/core/ConvaiRoomError.d.ts.map +1 -0
  16. package/dist/core/ConvaiRoomError.js +111 -0
  17. package/dist/core/ConvaiRoomError.js.map +1 -0
  18. package/dist/core/MessageHandler.d.ts +14 -0
  19. package/dist/core/MessageHandler.d.ts.map +1 -1
  20. package/dist/core/MessageHandler.js +111 -5
  21. package/dist/core/MessageHandler.js.map +1 -1
  22. package/dist/core/SSESession.d.ts +15 -0
  23. package/dist/core/SSESession.d.ts.map +1 -1
  24. package/dist/core/SSESession.js +92 -3
  25. package/dist/core/SSESession.js.map +1 -1
  26. package/dist/core/characterReference.d.ts +40 -0
  27. package/dist/core/characterReference.d.ts.map +1 -0
  28. package/dist/core/characterReference.js +72 -0
  29. package/dist/core/characterReference.js.map +1 -0
  30. package/dist/core/connectRequest.d.ts +1 -0
  31. package/dist/core/connectRequest.d.ts.map +1 -1
  32. package/dist/core/connectRequest.js +11 -0
  33. package/dist/core/connectRequest.js.map +1 -1
  34. package/dist/core/index.d.ts +5 -0
  35. package/dist/core/index.d.ts.map +1 -1
  36. package/dist/core/index.js +6 -0
  37. package/dist/core/index.js.map +1 -1
  38. package/dist/core/rosterRequest.d.ts +57 -0
  39. package/dist/core/rosterRequest.d.ts.map +1 -0
  40. package/dist/core/rosterRequest.js +210 -0
  41. package/dist/core/rosterRequest.js.map +1 -0
  42. package/dist/core/types.d.ts +717 -7
  43. package/dist/core/types.d.ts.map +1 -1
  44. package/dist/core/types.js.map +1 -1
  45. package/dist/react/components/rtc-widget/components/MarkdownRenderer.d.ts.map +1 -1
  46. package/dist/react/components/rtc-widget/components/MarkdownRenderer.js +39 -44
  47. package/dist/react/components/rtc-widget/components/MarkdownRenderer.js.map +1 -1
  48. package/dist/react/hooks/useConvaiClient.d.ts.map +1 -1
  49. package/dist/react/hooks/useConvaiClient.js +15 -2
  50. package/dist/react/hooks/useConvaiClient.js.map +1 -1
  51. package/dist/utils/inlineMarkdown.d.ts +38 -0
  52. package/dist/utils/inlineMarkdown.d.ts.map +1 -0
  53. package/dist/utils/inlineMarkdown.js +106 -0
  54. package/dist/utils/inlineMarkdown.js.map +1 -0
  55. package/dist/vanilla/AudioRenderer.d.ts.map +1 -1
  56. package/dist/vanilla/AudioRenderer.js +6 -0
  57. package/dist/vanilla/AudioRenderer.js.map +1 -1
  58. package/dist/vanilla/ConvaiWidget.d.ts.map +1 -1
  59. package/dist/vanilla/ConvaiWidget.js +71 -57
  60. package/dist/vanilla/ConvaiWidget.js.map +1 -1
  61. package/dist/vanilla/index.d.ts +2 -0
  62. package/dist/vanilla/index.d.ts.map +1 -1
  63. package/dist/vanilla/index.js +2 -0
  64. package/dist/vanilla/index.js.map +1 -1
  65. package/dist/version.d.ts +1 -1
  66. package/dist/version.js +1 -1
  67. package/dist/version.js.map +1 -1
  68. package/package.json +1 -1
@@ -5,11 +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";
14
+ import { CharacterVersionManager } from "./CharacterVersionManager.js";
15
+ import { resolveCharacterReference, } from "./characterReference.js";
11
16
  import { ConnectionStateHandler } from "./ConnectionStateHandler.js";
12
- import { buildActionConnectConfig, buildBlendshapeConnectConfig, buildEmotionConnectConfig, } from "./connectRequest.js";
17
+ import { buildActionConnectConfig, buildBlendshapeConnectConfig, buildEmotionConnectConfig, serializeActionProtocolCapabilities, } from "./connectRequest.js";
13
18
  import { shouldPreemptForContextUpdateResponse, shouldPreemptForExplicitRespondMode, } from "./contextUpdateRequest.js";
14
19
  import { resolveVisionInputConfig, serializeVisionInputConfig, } from "./visionRequest.js";
15
20
  import { SSESession } from "./SSESession.js";
@@ -81,7 +86,16 @@ export class ConvaiClient extends EventEmitter {
81
86
  this._apiKey = null;
82
87
  this._authToken = null;
83
88
  this._characterId = null;
89
+ this._characterVersion = null;
90
+ this._characterReference = null;
91
+ this._characterVersionManager = null;
92
+ this._characterVersionManagerKey = null;
84
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;
85
99
  this._isBotReady = false;
86
100
  this._participantSid = "";
87
101
  this._storedConfig = null;
@@ -110,6 +124,23 @@ export class ConvaiClient extends EventEmitter {
110
124
  if (config) {
111
125
  this._storedConfig = config;
112
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
+ }
113
144
  }
114
145
  // Initialize room with no reconnect policy
115
146
  this._room = new Room({
@@ -127,6 +158,8 @@ export class ConvaiClient extends EventEmitter {
127
158
  endUserId: null,
128
159
  endUserMetadata: null,
129
160
  metrics: [],
161
+ characters: [],
162
+ activeMembershipId: null,
130
163
  disconnectReason: null,
131
164
  };
132
165
  // Default audio settings for optimal interruption handling
@@ -169,6 +202,42 @@ export class ConvaiClient extends EventEmitter {
169
202
  get characterId() {
170
203
  return this._characterId;
171
204
  }
205
+ get characterVersion() {
206
+ return this._characterVersion;
207
+ }
208
+ get characterReference() {
209
+ return this._characterReference;
210
+ }
211
+ /**
212
+ * Character version manager for the configured character. Built from the
213
+ * live connection when there is one, otherwise from the stored config, so
214
+ * versions can be listed and released before connecting. Null without an
215
+ * API key (the Character REST platform does not accept realtime auth tokens).
216
+ */
217
+ get characterVersions() {
218
+ const apiKey = this._apiKey ?? this._storedConfig?.apiKey ?? null;
219
+ const rawCharacterId = this._characterId ?? this._storedConfig?.characterId ?? null;
220
+ if (!apiKey || !rawCharacterId)
221
+ return null;
222
+ let characterId = rawCharacterId;
223
+ try {
224
+ characterId = resolveCharacterReference(rawCharacterId).characterId;
225
+ }
226
+ catch {
227
+ // A malformed id is reported by connect(); use it verbatim here.
228
+ }
229
+ const baseUrl = this._storedConfig?.characterApiUrl ?? undefined;
230
+ const key = `${apiKey}\u0000${characterId}\u0000${baseUrl ?? ""}`;
231
+ if (this._characterVersionManagerKey !== key) {
232
+ this._characterVersionManager = new CharacterVersionManager({
233
+ apiKey,
234
+ characterId,
235
+ baseUrl,
236
+ });
237
+ this._characterVersionManagerKey = key;
238
+ }
239
+ return this._characterVersionManager;
240
+ }
172
241
  get room() {
173
242
  return this._room;
174
243
  }
@@ -181,6 +250,41 @@ export class ConvaiClient extends EventEmitter {
181
250
  get characterSessionId() {
182
251
  return this._characterSessionId;
183
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
+ }
184
288
  get isBotReady() {
185
289
  return this._isBotReady;
186
290
  }
@@ -207,12 +311,60 @@ export class ConvaiClient extends EventEmitter {
207
311
  */
208
312
  setupEventListeners() {
209
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
+ });
210
350
  this._room.on(RoomEvent.Disconnected, (reason) => {
211
351
  this._connectionStateHandler.handleDisconnected(reason);
212
352
  });
213
353
  this._room.on(RoomEvent.ConnectionStateChanged, this._connectionStateHandler.handleConnectionStateChanged.bind(this._connectionStateHandler));
214
354
  // Message handler events
215
- 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
+ }
216
368
  // Server may send multiple bot-ready frames (one per client-ready
217
369
  // we retried). Only surface the first to consumers per connect.
218
370
  if (this._isBotReady) {
@@ -306,6 +458,9 @@ export class ConvaiClient extends EventEmitter {
306
458
  this._messageHandler.on("llmNoResponse", () => {
307
459
  this.emit("llmNoResponse");
308
460
  });
461
+ this._messageHandler.on("botTurnCompleted", (data) => {
462
+ this.emit("botTurnCompleted", data);
463
+ });
309
464
  // Forward bot-output event (aggregated output with spoken status)
310
465
  this._messageHandler.on("botOutput", (data) => {
311
466
  this.emit("botOutput", data);
@@ -331,7 +486,20 @@ export class ConvaiClient extends EventEmitter {
331
486
  this._messageHandler.on("actionResponse", (data) => {
332
487
  this.emit("actionResponse", data);
333
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
+ });
334
501
  this._messageHandler.on("serverResponse", (response) => {
502
+ this._handleRosterServerResponse(response);
335
503
  this.emit("serverResponse", response);
336
504
  });
337
505
  this._messageHandler.on("interactionCreated", (data) => {
@@ -430,6 +598,8 @@ export class ConvaiClient extends EventEmitter {
430
598
  this._apiKey = configWithDefaults.apiKey ?? null;
431
599
  this._authToken = configWithDefaults.authToken ?? null;
432
600
  this._characterId = configWithDefaults.characterId;
601
+ this._characterVersion = configWithDefaults.characterVersion ?? null;
602
+ this._characterReference = configWithDefaults.characterReference;
433
603
  // Determine connection type based on enableVideo
434
604
  const connType = configWithDefaults.enableVideo ? "video" : "audio";
435
605
  this._connectionType = connType;
@@ -453,7 +623,7 @@ export class ConvaiClient extends EventEmitter {
453
623
  this._sseSession = new SSESession({
454
624
  interactionApiUrl: configWithDefaults.interactionApiUrl,
455
625
  authorization: configWithDefaults.authToken ?? configWithDefaults.apiKey,
456
- characterId: configWithDefaults.characterId,
626
+ characterId: configWithDefaults.characterReference,
457
627
  getCharacterSessionId: () => this._characterSessionId,
458
628
  takeStateOfMind: () => {
459
629
  const stateOfMind = this._stateOfMind;
@@ -476,6 +646,14 @@ export class ConvaiClient extends EventEmitter {
476
646
  enableEmotion: configWithDefaults.enableEmotion,
477
647
  emotionConfig: configWithDefaults.emotionConfig,
478
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,
479
657
  debug: configWithDefaults.debug,
480
658
  onMessage: (payload) => this._messageHandler.handleDataReceivedPublic(payload),
481
659
  onError: (error) => this.emit("error", error),
@@ -495,8 +673,11 @@ export class ConvaiClient extends EventEmitter {
495
673
  }
496
674
  // Prepare request body with required parameters
497
675
  const characterSessionIdToSend = configWithDefaults.characterSessionId ?? this._characterSessionId;
676
+ const isRoster = isRosterConfig(configWithDefaults);
498
677
  const requestBody = {
499
- character_id: configWithDefaults.characterId,
678
+ ...(isRoster
679
+ ? buildRosterConnectFields(configWithDefaults)
680
+ : { character_id: configWithDefaults.characterReference }),
500
681
  ...(stateOfMind ? { state_of_mind: stateOfMind } : {}),
501
682
  ...(configWithDefaults.endUserId && {
502
683
  end_user_id: configWithDefaults.endUserId,
@@ -507,7 +688,6 @@ export class ConvaiClient extends EventEmitter {
507
688
  transport: transportType,
508
689
  connection_type: connType,
509
690
  ...buildBlendshapeConnectConfig(configWithDefaults),
510
- llm_provider: "dynamic",
511
691
  ...(buildEmotionConnectConfig(configWithDefaults) && {
512
692
  emotion_config: buildEmotionConnectConfig(configWithDefaults),
513
693
  }),
@@ -521,7 +701,16 @@ export class ConvaiClient extends EventEmitter {
521
701
  ...(configWithDefaults.respondModes && {
522
702
  respond_modes: this.serializeRespondModes(configWithDefaults.respondModes),
523
703
  }),
524
- ...(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 &&
525
714
  characterSessionIdToSend !== "-1" && {
526
715
  character_session_id: characterSessionIdToSend,
527
716
  }),
@@ -543,6 +732,12 @@ export class ConvaiClient extends EventEmitter {
543
732
  ...(configWithDefaults.debug !== undefined && {
544
733
  debug: configWithDefaults.debug,
545
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
+ }),
546
741
  invocation_metadata: {
547
742
  source: configWithDefaults.invocationMetadata?.source ?? "web_sdk",
548
743
  client_version: configWithDefaults.invocationMetadata?.clientVersion ??
@@ -561,41 +756,62 @@ export class ConvaiClient extends EventEmitter {
561
756
  else if (configWithDefaults.apiKey) {
562
757
  connectHeaders["X-API-Key"] = configWithDefaults.apiKey;
563
758
  }
564
- // HTTP POST /connect — same request for both transports
565
- const response = await fetch(`${configWithDefaults.url}/connect`, {
566
- method: "POST",
567
- headers: connectHeaders,
568
- body: JSON.stringify(requestBody),
569
- });
570
- if (!response.ok) {
571
- const errorText = await response.text();
572
- let errorMessage = `HTTP ${response.status}`;
573
- try {
574
- const errorData = JSON.parse(errorText);
575
- const raw = errorData.message ?? errorData.error ?? errorData.detail;
576
- if (typeof raw === "string") {
577
- errorMessage = raw;
578
- }
579
- else if (Array.isArray(raw)) {
580
- // FastAPI/Pydantic 422 detail is an array of validation error objects
581
- errorMessage = raw
582
- .map((e) => [e.loc?.join("."), e.msg].filter(Boolean).join(": "))
583
- .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
+ }
584
790
  }
585
- else if (raw != null) {
586
- errorMessage = JSON.stringify(raw);
791
+ catch {
792
+ if (errorText)
793
+ errorMessage = `${errorMessage}: ${errorText}`;
587
794
  }
588
- else if (errorText) {
589
- 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);
590
807
  }
808
+ throw new Error(errorMessage);
591
809
  }
592
- catch {
593
- if (errorText)
594
- errorMessage = `${errorMessage}: ${errorText}`;
595
- }
596
- throw new Error(errorMessage);
597
- }
598
- const connectionData = await response.json();
810
+ return await response.json();
811
+ };
812
+ const connectionData = isRoster
813
+ ? await this._withProvisioningRetry(configWithDefaults, connectOnce)
814
+ : await connectOnce();
599
815
  return await this.consumeConnectionData(connectionData, configWithDefaults);
600
816
  }
601
817
  catch (error) {
@@ -659,6 +875,270 @@ export class ConvaiClient extends EventEmitter {
659
875
  throw error;
660
876
  }
661
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
+ }
662
1142
  /**
663
1143
  * Add default URL, apply RTVI-logging config, and validate that either an
664
1144
  * API key or auth token plus a character ID are present.
@@ -683,10 +1163,37 @@ export class ConvaiClient extends EventEmitter {
683
1163
  const hasApiKey = Boolean(configWithDefaults.apiKey);
684
1164
  const hasAuthToken = Boolean(configWithDefaults.authToken);
685
1165
  const missingCredential = requireCredential && !hasApiKey && !hasAuthToken;
686
- 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)) {
687
1171
  throw new Error("Either apiKey or authToken is required, and characterId is required");
688
1172
  }
689
- return configWithDefaults;
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
+ }
1187
+ // Split the character id from its version selector. The bare UUID names
1188
+ // the character everywhere inside the SDK (memory, character info); the
1189
+ // joined reference is what the runtime receives.
1190
+ const reference = resolveCharacterReference(configWithDefaults.characterId, configWithDefaults.characterVersion);
1191
+ return {
1192
+ ...configWithDefaults,
1193
+ characterId: reference.characterId,
1194
+ characterVersion: reference.version,
1195
+ characterReference: reference.reference,
1196
+ };
690
1197
  }
691
1198
  /**
692
1199
  * Consume a /connect response and bring up the transport.
@@ -718,6 +1225,8 @@ export class ConvaiClient extends EventEmitter {
718
1225
  this._apiKey = configWithDefaults.apiKey ?? null;
719
1226
  this._authToken = configWithDefaults.authToken ?? null;
720
1227
  this._characterId = configWithDefaults.characterId;
1228
+ this._characterVersion = configWithDefaults.characterVersion ?? null;
1229
+ this._characterReference = configWithDefaults.characterReference;
721
1230
  const connType = configWithDefaults.enableVideo ? "video" : "audio";
722
1231
  this._connectionType = connType;
723
1232
  const transportType = configWithDefaults.transport ?? "livekit";
@@ -735,6 +1244,14 @@ export class ConvaiClient extends EventEmitter {
735
1244
  characterSessionId: connectionData.character_session_id,
736
1245
  };
737
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
+ }
738
1255
  if (connectionData.end_user_id) {
739
1256
  this._endUserId = connectionData.end_user_id;
740
1257
  }
@@ -972,6 +1489,10 @@ export class ConvaiClient extends EventEmitter {
972
1489
  */
973
1490
  async disconnect() {
974
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");
975
1496
  if (this._activeTransport === "sse" && this._sseSession) {
976
1497
  await this._sseSession.disconnect();
977
1498
  this._connectionStateHandler.resetConnectionState();
@@ -1004,6 +1525,15 @@ export class ConvaiClient extends EventEmitter {
1004
1525
  this.clearAllManagers();
1005
1526
  }
1006
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
+ }
1007
1537
  }
1008
1538
  /**
1009
1539
  * Clear all manager states
@@ -1012,10 +1542,14 @@ export class ConvaiClient extends EventEmitter {
1012
1542
  this._connectionType = null;
1013
1543
  this._activeTransport = null;
1014
1544
  this._wsSession = null;
1545
+ this._failPendingRosterCommands("Disconnected before the roster command was acknowledged; its outcome is unknown");
1546
+ this._roster.reset();
1015
1547
  this._sseSession = null;
1016
1548
  this._apiKey = null;
1017
1549
  this._authToken = null;
1018
1550
  this._characterId = null;
1551
+ this._characterVersion = null;
1552
+ this._characterReference = null;
1019
1553
  this._endUserId = null;
1020
1554
  this._endUserMetadata = null;
1021
1555
  this._memoryManager = null; // Clear memory manager on disconnect
@@ -1038,6 +1572,13 @@ export class ConvaiClient extends EventEmitter {
1038
1572
  * Reset the session ID to start a new conversation
1039
1573
  */
1040
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
+ }
1041
1582
  this._characterSessionId = "-1";
1042
1583
  this.emit("characterSessionId", "-1");
1043
1584
  this._messageHandler.reset();
@@ -1079,15 +1620,28 @@ export class ConvaiClient extends EventEmitter {
1079
1620
  const stateOfMind = normalizeStateOfMind(options.stateOfMind) ?? null;
1080
1621
  if (stateOfMind)
1081
1622
  this._stateOfMind = stateOfMind;
1082
- if (stateOfMind && this._activeTransport === "sse") {
1083
- this._sseSession?.sendMessages([
1084
- { type: "update-emotion", data: { state_of_mind: stateOfMind } },
1085
- {
1086
- type: "user_text_message",
1087
- data: userTextMessage,
1088
- ...(logicalTurnId !== undefined ? { id: logicalTurnId } : {}),
1089
- },
1090
- ]);
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);
1091
1645
  }
1092
1646
  else {
1093
1647
  if (stateOfMind) {
@@ -1203,8 +1757,16 @@ export class ConvaiClient extends EventEmitter {
1203
1757
  updateDynamicInfo(dynamicInfo) {
1204
1758
  if (!this.isTransportReady() || !dynamicInfo?.trim())
1205
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
+ }
1206
1768
  this.publishMessage("update-dynamic-info", {
1207
- dynamic_info: { text: dynamicInfo },
1769
+ dynamic_info: { text: nextDynamicInfo },
1208
1770
  participant_sid: this._participantSid || this._room.localParticipant?.sid,
1209
1771
  });
1210
1772
  }
@@ -1252,6 +1814,22 @@ export class ConvaiClient extends EventEmitter {
1252
1814
  if (shouldPreemptLipsync) {
1253
1815
  this.startTrackedConversation(`[context-update:${options.mode ?? "append"}]`);
1254
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
+ }
1255
1833
  this.publishMessage("context-update", {
1256
1834
  ...(hasText && { text: options.text.trim() }),
1257
1835
  ...(options.mode && { mode: options.mode }),
@@ -1313,6 +1891,8 @@ export class ConvaiClient extends EventEmitter {
1313
1891
  }
1314
1892
  if (!this.isTransportReady())
1315
1893
  return;
1894
+ if (this._activeTransport === "sse")
1895
+ return;
1316
1896
  this.publishMessage("update-emotion", {
1317
1897
  state_of_mind: normalized,
1318
1898
  participant_sid: this._participantSid || this._room.localParticipant?.sid,
@@ -1407,4 +1987,17 @@ export class ConvaiClient extends EventEmitter {
1407
1987
  }
1408
1988
  ConvaiClient.CLIENT_READY_RETRY_MS = 500;
1409
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;
1410
2003
  //# sourceMappingURL=ConvaiClient.js.map