@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
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  **Real-time conversational AI characters for the web.**
4
4
 
5
- [![npm](https://img.shields.io/badge/npm-1.7.0-blue)](https://www.npmjs.com/package/@convai/web-sdk)
5
+ [![npm](https://img.shields.io/badge/npm-1.8.0--beta.5-blue)](https://www.npmjs.com/package/@convai/web-sdk)
6
6
  [![TypeScript](https://img.shields.io/badge/TypeScript-first-3178C6)](https://www.typescriptlang.org/)
7
7
  [![License](https://img.shields.io/badge/license-Apache--2.0-green)](./LICENSE)
8
8
 
@@ -10,14 +10,23 @@ TypeScript-first SDK for embedding Convai AI characters into React and vanilla J
10
10
 
11
11
  ---
12
12
 
13
- ## What's new in 1.7.0
13
+ ## What's new in 1.8.0 (beta)
14
+
15
+ Install the beta with `npm install @convai/web-sdk@beta`.
16
+
17
+ - **Character versioning** — connect to a character's editable draft, its promoted latest release, or an immutable tag with the `characterVersion` config option, and list, compare, release, promote, fork and discard versions through `client.characterVersions`. [Usage below.](#character-versioning-beta)
18
+ - **SSE interaction transport** — text-only streamed interactions through the interaction API, selected by `interactionApiUrl`; the one transport that runs under Node. [Usage below.](#sse-interaction-transport-beta)
19
+ - **Embeddable vanilla widget** — the vanilla widget runs inside a shadow root, with a character header and a connecting overlay, so it can be dropped into any page.
20
+ - **Node-importable dist** — `@convai/web-sdk/core`, `/vanilla`, `/vanilla/websocket` and `/lipsync-helpers` load in plain Node, SSR and Next.js server components.
21
+
22
+ ### Previously in 1.7.0
14
23
 
15
24
  - **Character state of mind** — set a temporary generation mood with the `stateOfMind` config option, and change it mid-session with `updateEmotion()` without triggering a response. [Usage below.](#character-state-of-mind)
16
25
  - **Send-ahead enabled by default** — NeuroSync lipsync ahead-delivery is now on by default, reversing the 1.6.0 opt-in. Fall back to the legacy paced path with `blendshapeConfig.deliver_chunks_ahead: false`.
17
26
  - **Lipsync naturalness pipeline** — modular naturalness processing with a tuned MetaHuman profile.
18
27
  - **Adaptive glass widget styling** — `ConvaiWidget` adapts to light and dark backgrounds.
19
28
 
20
- ### Previously in 1.6.0
29
+ ### 1.6.0
21
30
 
22
31
  - **Narrative Design template keys** — personalize one narrative graph per session: seed values at connect with the `narrativeTemplateKeys` config option, replace them mid-session with `updateTemplateKeys()`. [Usage below.](#narrative-design-template-keys)
23
32
  - **Typed parameterized actions** — `actionResponse` is now typed via the exported `ConvaiAction` / `ActionResponseEvent`, including the `target` of parameterized actions. [Usage below.](#actions)
@@ -34,6 +43,7 @@ TypeScript-first SDK for embedding Convai AI characters into React and vanilla J
34
43
  - **Dynamic context & vision** — inject text state, scene metadata, and LiveKit video frames mid-session
35
44
  - **Actions & Narrative Design** — typed action decisions with parameterized targets, named triggers, and per-session template keys
36
45
  - **Long-term memory** — persistent cross-session memories scoped to each end user
46
+ - **Character versioning** — connect to a draft, latest, or tagged version, and manage releases from the client
37
47
  - **File upload** — send images to the character during a live session
38
48
  - **WebSocket transport** — opt-in alternative to WebRTC for constrained networks
39
49
  - **Auth tokens** — server-side token exchange for production deployments
@@ -114,6 +124,7 @@ Full documentation is at **<a href="https://docs.convai.com/api-docs/plugins-and
114
124
  | <a href="https://docs.convai.com/api-docs/plugins-and-integrations/web-plugins/convai-web-sdk/auth-tokens" target="_blank" rel="noopener noreferrer">Auth Tokens</a> | Server-side token exchange for production |
115
125
  | <a href="https://docs.convai.com/api-docs/plugins-and-integrations/web-plugins/convai-web-sdk/websocket-transport-layer" target="_blank" rel="noopener noreferrer">WebSocket Transport</a> | Alternative transport for WebRTC-constrained environments |
116
126
  | <a href="https://docs.convai.com/api-docs/plugins-and-integrations/web-plugins/convai-web-sdk" target="_blank" rel="noopener noreferrer">SSE Transport</a> | Text-only interaction transport; the one that runs under Node |
127
+ | [Character Versioning](docs/convai_character_versioning.md) | Connect to a draft, latest, or tagged version; list, compare, release and promote versions |
117
128
 
118
129
  ---
119
130
 
@@ -181,6 +192,56 @@ Full guide: [SSE Interaction Transport](docs/convai_sse_transport.md).
181
192
 
182
193
  ---
183
194
 
195
+ ## Character versioning (beta)
196
+
197
+ Every character has an editable **draft** and, once released, immutable **tagged versions**
198
+ (`1.0`, `1.1`, …). A movable `latest` pointer names the release the runtime uses when a client
199
+ connects without a selector. Pick the version to run with `characterVersion`:
200
+
201
+ | `characterVersion` | Runs |
202
+ | ------------------ | ----------------------------------------------------------------- |
203
+ | *(omitted)* | The effective latest — how characters that predate versioning behave |
204
+ | `"draft"` | The editable draft, for testing unreleased changes |
205
+ | `"latest"` | The promoted latest release, explicitly |
206
+ | `"1.2"` / `"1.2.3"` | An immutable tagged version |
207
+
208
+ ```tsx
209
+ const client = useConvaiClient({
210
+ apiKey: "...",
211
+ characterId: "...",
212
+ characterVersion: "draft",
213
+ });
214
+ ```
215
+
216
+ On the wire the selector is joined to the id as `<uuid>-draft`; `client.characterId` keeps
217
+ returning the bare UUID, and `client.characterReference` returns the joined form.
218
+
219
+ Manage versions from the same client — `client.characterVersions` is available as soon as the
220
+ config has an `apiKey`, before connecting:
221
+
222
+ ```ts
223
+ const versions = client.characterVersions!;
224
+
225
+ const { has_unpublished_changes, draft_revision_id } = await versions.list();
226
+ const diff = await versions.diff("latest", "draft", { view: "semantic" });
227
+
228
+ await versions.create("1.1", { makeLatest: true }); // release the draft as a tag
229
+ await versions.promote("1.0"); // roll back: latest → 1.0
230
+ await versions.discardDraft(draft_revision_id!); // throw away unreleased edits
231
+ ```
232
+
233
+ `CharacterVersionManager` can also be constructed standalone with an `apiKey` or a Convai
234
+ personal access token; every method rejects with `CharacterApiError` (`status`, `detail`) on a
235
+ non-2xx answer. Point `characterApiUrl` at `https://api2-stg.convai.com` to author against
236
+ staging.
237
+
238
+ Explicit selectors are resolved by the runtime through the Character REST platform. Staging has
239
+ this today; production answers explicit selectors with `503` until it is promoted there.
240
+ Full guide: [Character Versioning](docs/convai_character_versioning.md).
241
+
242
+ ---
243
+
244
+
184
245
  ## Vision dynamic context beta
185
246
 
186
247
  Vision dynamic context is the default WebRTC/LiveKit vision path when `enableVideo: true`. Camera, screen, canvas, and custom video tracks can feed unified vision context; set `visionInputConfig.enabled: false` only when you need to keep the video channel while opting out.
@@ -0,0 +1,84 @@
1
+ import type { ActiveCharacterChange, CharacterRosterChange, ConvaiCharacterInstance, ConvaiRoomSession, ServerResponseExtras, UpdateCharacterRosterOptions } from "./types.js";
2
+ export declare class CharacterRoster {
3
+ private _characters;
4
+ private _activeMembershipId;
5
+ private _roomSessionId;
6
+ private _roomName;
7
+ private _requestTraceId;
8
+ private _routeEpoch;
9
+ private _rosterEpoch;
10
+ private _partialDispatch;
11
+ /** Character instances in roster order. Empty in single-character mode. */
12
+ get characters(): ConvaiCharacterInstance[];
13
+ /** Membership currently receiving user turns, or null. */
14
+ get activeMembershipId(): string | null;
15
+ get roomSessionId(): string | null;
16
+ get routeEpoch(): number;
17
+ get rosterEpoch(): number;
18
+ /** True once a multi-character connect response has been applied. */
19
+ get isMultiCharacter(): boolean;
20
+ /** The server-owned snapshot, or null for a single-character session. */
21
+ get roomSession(): ConvaiRoomSession | null;
22
+ find(membershipId: string): ConvaiCharacterInstance | undefined;
23
+ /** Members that have sent their own `bot-ready`. */
24
+ get readyCharacters(): ConvaiCharacterInstance[];
25
+ /**
26
+ * Mark a member ready from its own `bot-ready`, filling in any detail the
27
+ * connect response did not yet have.
28
+ *
29
+ * Returns whether anything changed, so the caller only pushes state when it
30
+ * did. A single-character `bot-ready` also carries these fields, so nothing
31
+ * is tracked until a roster is actually seeded.
32
+ */
33
+ applyBotReady(about: Record<string, unknown> | undefined): boolean;
34
+ /**
35
+ * Apply a `character-status` transition. Readiness recorded here is
36
+ * bookkeeping for replacement selection; `bot-ready` remains what gates
37
+ * interaction.
38
+ */
39
+ applyCharacterStatus(status: {
40
+ membershipId: string;
41
+ status: "starting" | "ready" | "failed";
42
+ failureCode: string | null;
43
+ }): boolean;
44
+ /**
45
+ * Pick a member to take over routing when the active one is removed.
46
+ *
47
+ * The runtime refuses a replacement that has not sent `bot-ready`
48
+ * (`replacement_target_unavailable`), so ready survivors come first. Falling
49
+ * back to any survivor at least produces a nameable rejection rather than
50
+ * `replacement_target_required`.
51
+ */
52
+ pickReplacement(removing: string[]): string | undefined;
53
+ /** Payload that switches the active member. */
54
+ buildInteractionTarget(membershipId: string | null, commandId: string): Record<string, unknown>;
55
+ /** Payload that adds and removes members. */
56
+ buildRosterUpdate(options: UpdateCharacterRosterOptions, commandId: string): Record<string, unknown>;
57
+ /**
58
+ * Adopt the server's authoritative epochs and active member from any
59
+ * response, errors included.
60
+ *
61
+ * A `stale_route_epoch` rejection carries the real epoch, and adopting it is
62
+ * precisely what makes the single retry meaningful rather than a repeat of
63
+ * the same losing bet.
64
+ */
65
+ adoptEpochs(extras: ServerResponseExtras | null | undefined): void;
66
+ /**
67
+ * Apply a successful `interaction-target` ack. Returns the change even when
68
+ * the target did not move, so a caller awaiting the promise always learns the
69
+ * authoritative state.
70
+ */
71
+ applyInteractionTarget(extras: ServerResponseExtras | null | undefined): ActiveCharacterChange;
72
+ /** Apply a successful `character-roster-update` ack, mutating the roster. */
73
+ applyRosterUpdate(extras: ServerResponseExtras | null | undefined): CharacterRosterChange;
74
+ reset(): void;
75
+ /**
76
+ * Seed the roster from a `/connect` response, for either a create or a join.
77
+ *
78
+ * Returns false for a response with no `characters` array — that is an
79
+ * ordinary single-character session, and the roster stays empty so
80
+ * `isMultiCharacter` keeps distinguishing the two.
81
+ */
82
+ applyConnectResponse(data: Record<string, unknown>): boolean;
83
+ }
84
+ //# sourceMappingURL=CharacterRoster.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"CharacterRoster.d.ts","sourceRoot":"","sources":["../../src/core/CharacterRoster.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,qBAAqB,EAErB,qBAAqB,EACrB,uBAAuB,EAEvB,iBAAiB,EACjB,oBAAoB,EACpB,4BAA4B,EAC7B,MAAM,SAAS,CAAC;AA8EjB,qBAAa,eAAe;IAC1B,OAAO,CAAC,WAAW,CAAiC;IACpD,OAAO,CAAC,mBAAmB,CAAuB;IAClD,OAAO,CAAC,cAAc,CAAuB;IAC7C,OAAO,CAAC,SAAS,CAAuB;IACxC,OAAO,CAAC,eAAe,CAAuB;IAC9C,OAAO,CAAC,WAAW,CAAK;IACxB,OAAO,CAAC,YAAY,CAAK;IACzB,OAAO,CAAC,gBAAgB,CAAS;IAEjC,2EAA2E;IAC3E,IAAI,UAAU,IAAI,uBAAuB,EAAE,CAE1C;IAED,0DAA0D;IAC1D,IAAI,kBAAkB,IAAI,MAAM,GAAG,IAAI,CAEtC;IAED,IAAI,aAAa,IAAI,MAAM,GAAG,IAAI,CAEjC;IAED,IAAI,UAAU,IAAI,MAAM,CAEvB;IAED,IAAI,WAAW,IAAI,MAAM,CAExB;IAED,qEAAqE;IACrE,IAAI,gBAAgB,IAAI,OAAO,CAE9B;IAED,yEAAyE;IACzE,IAAI,WAAW,IAAI,iBAAiB,GAAG,IAAI,CAY1C;IAED,IAAI,CAAC,YAAY,EAAE,MAAM,GAAG,uBAAuB,GAAG,SAAS;IAI/D,oDAAoD;IACpD,IAAI,eAAe,IAAI,uBAAuB,EAAE,CAE/C;IAED;;;;;;;OAOG;IACH,aAAa,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,GAAG,OAAO;IAyClE;;;;OAIG;IACH,oBAAoB,CAAC,MAAM,EAAE;QAC3B,YAAY,EAAE,MAAM,CAAC;QACrB,MAAM,EAAE,UAAU,GAAG,OAAO,GAAG,QAAQ,CAAC;QACxC,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;KAC5B,GAAG,OAAO;IAiCX;;;;;;;OAOG;IACH,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,MAAM,GAAG,SAAS;IAUvD,+CAA+C;IAC/C,sBAAsB,CACpB,YAAY,EAAE,MAAM,GAAG,IAAI,EAC3B,SAAS,EAAE,MAAM,GAChB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAS1B,6CAA6C;IAC7C,iBAAiB,CACf,OAAO,EAAE,4BAA4B,EACrC,SAAS,EAAE,MAAM,GAChB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAmB1B;;;;;;;OAOG;IACH,WAAW,CAAC,MAAM,EAAE,oBAAoB,GAAG,IAAI,GAAG,SAAS,GAAG,IAAI;IASlE;;;;OAIG;IACH,sBAAsB,CACpB,MAAM,EAAE,oBAAoB,GAAG,IAAI,GAAG,SAAS,GAC9C,qBAAqB;IAaxB,6EAA6E;IAC7E,iBAAiB,CACf,MAAM,EAAE,oBAAoB,GAAG,IAAI,GAAG,SAAS,GAC9C,qBAAqB;IAuDxB,KAAK,IAAI,IAAI;IAWb;;;;;;OAMG;IACH,oBAAoB,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO;CA2B7D"}
@@ -0,0 +1,348 @@
1
+ const PROVISIONING_STATUSES = [
2
+ "dispatch_queued",
3
+ "dispatch_accepted",
4
+ "dispatch_failed",
5
+ ];
6
+ /**
7
+ * Coerce a wire status into the closed union.
8
+ *
9
+ * An unrecognised value becomes `dispatch_queued`, the one state that promises
10
+ * nothing: it means "still working", so a client waits for `bot-ready` rather
11
+ * than acting. Mapping to `dispatch_accepted` would imply an actor exists, and
12
+ * to `dispatch_failed` would hide a member that is actually coming up.
13
+ */
14
+ function toProvisioningStatus(value) {
15
+ return typeof value === "string" && PROVISIONING_STATUSES.includes(value)
16
+ ? value
17
+ : "dispatch_queued";
18
+ }
19
+ /**
20
+ * Convert a wire entry into an instance, or null when it cannot be attributed.
21
+ *
22
+ * A member with no `membership_id` is unaddressable: it cannot be targeted,
23
+ * removed, or matched to a participant. The contract's rule throughout is to
24
+ * fail closed rather than guess, so it is dropped instead of being given a
25
+ * synthesised id that would later collide.
26
+ */
27
+ function toInstance(raw) {
28
+ if (!raw?.membership_id)
29
+ return null;
30
+ return {
31
+ membershipId: raw.membership_id,
32
+ characterId: raw.character_id ?? "",
33
+ sessionId: raw.session_id ?? null,
34
+ characterSessionId: raw.character_session_id ?? null,
35
+ // Identity is derivable and always `character:{membership_id}`. Falling
36
+ // back to it is safe; falling back to a positional name or a LiveKit SID
37
+ // would not be, so those are never used.
38
+ participantIdentity: raw.participant_identity || `character:${raw.membership_id}`,
39
+ isInitial: raw.is_initial === true,
40
+ provisioningStatus: toProvisioningStatus(raw.provisioning_status),
41
+ displayName: raw.display_name ?? null,
42
+ description: raw.description ?? null,
43
+ failureCode: raw.failure_code ?? null,
44
+ isReady: false,
45
+ };
46
+ }
47
+ export class CharacterRoster {
48
+ constructor() {
49
+ this._characters = [];
50
+ this._activeMembershipId = null;
51
+ this._roomSessionId = null;
52
+ this._roomName = null;
53
+ this._requestTraceId = null;
54
+ this._routeEpoch = 0;
55
+ this._rosterEpoch = 0;
56
+ this._partialDispatch = false;
57
+ }
58
+ /** Character instances in roster order. Empty in single-character mode. */
59
+ get characters() {
60
+ return this._characters;
61
+ }
62
+ /** Membership currently receiving user turns, or null. */
63
+ get activeMembershipId() {
64
+ return this._activeMembershipId;
65
+ }
66
+ get roomSessionId() {
67
+ return this._roomSessionId;
68
+ }
69
+ get routeEpoch() {
70
+ return this._routeEpoch;
71
+ }
72
+ get rosterEpoch() {
73
+ return this._rosterEpoch;
74
+ }
75
+ /** True once a multi-character connect response has been applied. */
76
+ get isMultiCharacter() {
77
+ return this._characters.length > 0;
78
+ }
79
+ /** The server-owned snapshot, or null for a single-character session. */
80
+ get roomSession() {
81
+ if (!this._roomSessionId)
82
+ return null;
83
+ return {
84
+ roomSessionId: this._roomSessionId,
85
+ roomName: this._roomName,
86
+ requestTraceId: this._requestTraceId,
87
+ activeMembershipId: this._activeMembershipId,
88
+ routeEpoch: this._routeEpoch,
89
+ rosterEpoch: this._rosterEpoch,
90
+ partialDispatch: this._partialDispatch,
91
+ characters: this._characters,
92
+ };
93
+ }
94
+ find(membershipId) {
95
+ return this._characters.find((c) => c.membershipId === membershipId);
96
+ }
97
+ /** Members that have sent their own `bot-ready`. */
98
+ get readyCharacters() {
99
+ return this._characters.filter((c) => c.isReady);
100
+ }
101
+ /**
102
+ * Mark a member ready from its own `bot-ready`, filling in any detail the
103
+ * connect response did not yet have.
104
+ *
105
+ * Returns whether anything changed, so the caller only pushes state when it
106
+ * did. A single-character `bot-ready` also carries these fields, so nothing
107
+ * is tracked until a roster is actually seeded.
108
+ */
109
+ applyBotReady(about) {
110
+ if (!about || !this.isMultiCharacter)
111
+ return false;
112
+ const incoming = toInstance(about);
113
+ if (!incoming)
114
+ return false;
115
+ const index = this._characters.findIndex((c) => c.membershipId === incoming.membershipId);
116
+ if (index === -1) {
117
+ // A member added at runtime announces itself this way.
118
+ this._characters = [...this._characters, { ...incoming, isReady: true }];
119
+ return true;
120
+ }
121
+ const existing = this._characters[index];
122
+ const merged = {
123
+ ...existing,
124
+ characterSessionId: incoming.characterSessionId ?? existing.characterSessionId,
125
+ sessionId: incoming.sessionId ?? existing.sessionId,
126
+ displayName: incoming.displayName ?? existing.displayName,
127
+ participantIdentity: incoming.participantIdentity || existing.participantIdentity,
128
+ isReady: true,
129
+ };
130
+ if (existing.isReady &&
131
+ merged.characterSessionId === existing.characterSessionId &&
132
+ merged.sessionId === existing.sessionId &&
133
+ merged.participantIdentity === existing.participantIdentity) {
134
+ return false;
135
+ }
136
+ this._characters = [
137
+ ...this._characters.slice(0, index),
138
+ merged,
139
+ ...this._characters.slice(index + 1),
140
+ ];
141
+ return true;
142
+ }
143
+ /**
144
+ * Apply a `character-status` transition. Readiness recorded here is
145
+ * bookkeeping for replacement selection; `bot-ready` remains what gates
146
+ * interaction.
147
+ */
148
+ applyCharacterStatus(status) {
149
+ const index = this._characters.findIndex((c) => c.membershipId === status.membershipId);
150
+ if (index === -1)
151
+ return false;
152
+ const existing = this._characters[index];
153
+ const next = {
154
+ ...existing,
155
+ ...(status.status === "failed"
156
+ ? {
157
+ provisioningStatus: "dispatch_failed",
158
+ failureCode: status.failureCode ?? existing.failureCode,
159
+ isReady: false,
160
+ }
161
+ : {}),
162
+ };
163
+ if (next.provisioningStatus === existing.provisioningStatus &&
164
+ next.failureCode === existing.failureCode &&
165
+ next.isReady === existing.isReady) {
166
+ return false;
167
+ }
168
+ this._characters = [
169
+ ...this._characters.slice(0, index),
170
+ next,
171
+ ...this._characters.slice(index + 1),
172
+ ];
173
+ if (status.status === "failed")
174
+ this._partialDispatch = true;
175
+ return true;
176
+ }
177
+ /**
178
+ * Pick a member to take over routing when the active one is removed.
179
+ *
180
+ * The runtime refuses a replacement that has not sent `bot-ready`
181
+ * (`replacement_target_unavailable`), so ready survivors come first. Falling
182
+ * back to any survivor at least produces a nameable rejection rather than
183
+ * `replacement_target_required`.
184
+ */
185
+ pickReplacement(removing) {
186
+ const removed = new Set(removing);
187
+ const survivors = this._characters.filter((c) => !removed.has(c.membershipId));
188
+ return (survivors.find((c) => c.isReady)?.membershipId ?? survivors[0]?.membershipId);
189
+ }
190
+ /** Payload that switches the active member. */
191
+ buildInteractionTarget(membershipId, commandId) {
192
+ return {
193
+ room_session_id: this._roomSessionId,
194
+ target_membership_id: membershipId,
195
+ expected_route_epoch: this._routeEpoch,
196
+ command_id: commandId,
197
+ };
198
+ }
199
+ /** Payload that adds and removes members. */
200
+ buildRosterUpdate(options, commandId) {
201
+ const add = (options.add ?? []).map((spec) => ({
202
+ character_id: spec.characterId.trim(),
203
+ ...(spec.characterSessionId
204
+ ? { character_session_id: spec.characterSessionId }
205
+ : {}),
206
+ }));
207
+ return {
208
+ room_session_id: this._roomSessionId,
209
+ expected_roster_epoch: this._rosterEpoch,
210
+ command_id: commandId,
211
+ ...(add.length > 0 && { add }),
212
+ ...(options.remove?.length && { remove_membership_ids: options.remove }),
213
+ ...(options.replacementTarget && {
214
+ replacement_target_membership_id: options.replacementTarget,
215
+ }),
216
+ };
217
+ }
218
+ /**
219
+ * Adopt the server's authoritative epochs and active member from any
220
+ * response, errors included.
221
+ *
222
+ * A `stale_route_epoch` rejection carries the real epoch, and adopting it is
223
+ * precisely what makes the single retry meaningful rather than a repeat of
224
+ * the same losing bet.
225
+ */
226
+ adoptEpochs(extras) {
227
+ if (!extras)
228
+ return;
229
+ if (typeof extras.route_epoch === "number")
230
+ this._routeEpoch = extras.route_epoch;
231
+ if (typeof extras.roster_epoch === "number")
232
+ this._rosterEpoch = extras.roster_epoch;
233
+ if (extras.active_membership_id !== undefined) {
234
+ this._activeMembershipId = extras.active_membership_id ?? null;
235
+ }
236
+ }
237
+ /**
238
+ * Apply a successful `interaction-target` ack. Returns the change even when
239
+ * the target did not move, so a caller awaiting the promise always learns the
240
+ * authoritative state.
241
+ */
242
+ applyInteractionTarget(extras) {
243
+ const previous = extras?.previous_membership_id !== undefined
244
+ ? extras.previous_membership_id
245
+ : this._activeMembershipId;
246
+ this.adoptEpochs(extras);
247
+ return {
248
+ previous: previous ?? null,
249
+ active: this._activeMembershipId,
250
+ routeEpoch: this._routeEpoch,
251
+ };
252
+ }
253
+ /** Apply a successful `character-roster-update` ack, mutating the roster. */
254
+ applyRosterUpdate(extras) {
255
+ const added = (Array.isArray(extras?.added) ? extras.added : [])
256
+ .map((c) => toInstance(c))
257
+ .filter((c) => c !== null);
258
+ const removedIds = new Set(Array.isArray(extras?.removed_membership_ids)
259
+ ? extras.removed_membership_ids
260
+ : []);
261
+ // Prefer the roster's own record of a removed member: the ack echoes only
262
+ // its id, and the caller wants the instance it had been rendering.
263
+ const removed = [...removedIds].map((id) => this.find(id) ?? {
264
+ membershipId: id,
265
+ characterId: "",
266
+ sessionId: null,
267
+ characterSessionId: null,
268
+ participantIdentity: `character:${id}`,
269
+ isInitial: false,
270
+ provisioningStatus: "dispatch_failed",
271
+ displayName: null,
272
+ description: null,
273
+ failureCode: null,
274
+ isReady: false,
275
+ });
276
+ const addedIds = new Set(added.map((c) => c.membershipId));
277
+ this._characters = [
278
+ ...this._characters.filter((c) => !removedIds.has(c.membershipId) && !addedIds.has(c.membershipId)),
279
+ ...added,
280
+ ];
281
+ this.adoptEpochs(extras);
282
+ // A removal can retire the member that was active; the ack names the
283
+ // survivor, and adoptEpochs has already taken it.
284
+ if (this._activeMembershipId &&
285
+ !this.find(this._activeMembershipId)) {
286
+ this._activeMembershipId = this._characters[0]?.membershipId ?? null;
287
+ }
288
+ return {
289
+ added,
290
+ removed,
291
+ characters: this._characters,
292
+ rosterEpoch: this._rosterEpoch,
293
+ routeEpoch: this._routeEpoch,
294
+ activeMembershipId: this._activeMembershipId,
295
+ };
296
+ }
297
+ reset() {
298
+ this._characters = [];
299
+ this._activeMembershipId = null;
300
+ this._roomSessionId = null;
301
+ this._roomName = null;
302
+ this._requestTraceId = null;
303
+ this._routeEpoch = 0;
304
+ this._rosterEpoch = 0;
305
+ this._partialDispatch = false;
306
+ }
307
+ /**
308
+ * Seed the roster from a `/connect` response, for either a create or a join.
309
+ *
310
+ * Returns false for a response with no `characters` array — that is an
311
+ * ordinary single-character session, and the roster stays empty so
312
+ * `isMultiCharacter` keeps distinguishing the two.
313
+ */
314
+ applyConnectResponse(data) {
315
+ const raw = data?.characters;
316
+ if (!Array.isArray(raw) || raw.length === 0)
317
+ return false;
318
+ const characters = raw
319
+ .map((c) => toInstance(c))
320
+ .filter((c) => c !== null);
321
+ if (characters.length === 0)
322
+ return false;
323
+ this._characters = characters;
324
+ this._roomSessionId = asString(data.room_session_id);
325
+ this._roomName = asString(data.room_name);
326
+ this._requestTraceId = asString(data.request_trace_id);
327
+ // The server names the active member. Falling back to the initial entry,
328
+ // then to position zero, matches the rule that `characters[0]` is initial.
329
+ this._activeMembershipId =
330
+ asString(data.active_membership_id) ??
331
+ characters.find((c) => c.isInitial)?.membershipId ??
332
+ characters[0]?.membershipId ??
333
+ null;
334
+ this._routeEpoch = asNumber(data.route_epoch) ?? 0;
335
+ this._rosterEpoch = asNumber(data.roster_epoch) ?? 0;
336
+ this._partialDispatch =
337
+ data.partial_dispatch === true ||
338
+ characters.some((c) => c.provisioningStatus === "dispatch_failed");
339
+ return true;
340
+ }
341
+ }
342
+ function asString(value) {
343
+ return typeof value === "string" && value ? value : null;
344
+ }
345
+ function asNumber(value) {
346
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
347
+ }
348
+ //# sourceMappingURL=CharacterRoster.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"CharacterRoster.js","sourceRoot":"","sources":["../../src/core/CharacterRoster.ts"],"names":[],"mappings":"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","sourcesContent":["import type {\n ActiveCharacterChange,\n CharacterProvisioningStatus,\n CharacterRosterChange,\n ConvaiCharacterInstance,\n ConvaiCharacterSpec,\n ConvaiRoomSession,\n ServerResponseExtras,\n UpdateCharacterRosterOptions,\n} from \"./types\";\n\n/**\n * Roster state for a multi-character room: which character instances exist,\n * which one receives user turns, and the epochs that fence concurrent changes.\n *\n * Pure state — it owns no transport. `ConvaiClient` feeds it from the connect\n * response, from per-instance readiness events, and from server acks, and\n * publishes whatever commands it builds.\n *\n * Slice A populates the read half below. Roster mutation (`interaction-target`,\n * `character-roster-update`) grows this same class rather than a second home\n * for the same state.\n */\n\n/** A roster entry as it appears on the wire, in any of the places it appears. */\ninterface RawCharacter {\n membership_id?: string;\n character_id?: string;\n session_id?: string | null;\n character_session_id?: string | null;\n participant_identity?: string;\n is_initial?: boolean;\n provisioning_status?: string;\n failure_code?: string | null;\n display_name?: string | null;\n description?: string | null;\n}\n\nconst PROVISIONING_STATUSES: readonly string[] = [\n \"dispatch_queued\",\n \"dispatch_accepted\",\n \"dispatch_failed\",\n];\n\n/**\n * Coerce a wire status into the closed union.\n *\n * An unrecognised value becomes `dispatch_queued`, the one state that promises\n * nothing: it means \"still working\", so a client waits for `bot-ready` rather\n * than acting. Mapping to `dispatch_accepted` would imply an actor exists, and\n * to `dispatch_failed` would hide a member that is actually coming up.\n */\nfunction toProvisioningStatus(value: unknown): CharacterProvisioningStatus {\n return typeof value === \"string\" && PROVISIONING_STATUSES.includes(value)\n ? (value as CharacterProvisioningStatus)\n : \"dispatch_queued\";\n}\n\n/**\n * Convert a wire entry into an instance, or null when it cannot be attributed.\n *\n * A member with no `membership_id` is unaddressable: it cannot be targeted,\n * removed, or matched to a participant. The contract's rule throughout is to\n * fail closed rather than guess, so it is dropped instead of being given a\n * synthesised id that would later collide.\n */\nfunction toInstance(raw: RawCharacter | undefined): ConvaiCharacterInstance | null {\n if (!raw?.membership_id) return null;\n return {\n membershipId: raw.membership_id,\n characterId: raw.character_id ?? \"\",\n sessionId: raw.session_id ?? null,\n characterSessionId: raw.character_session_id ?? null,\n // Identity is derivable and always `character:{membership_id}`. Falling\n // back to it is safe; falling back to a positional name or a LiveKit SID\n // would not be, so those are never used.\n participantIdentity:\n raw.participant_identity || `character:${raw.membership_id}`,\n isInitial: raw.is_initial === true,\n provisioningStatus: toProvisioningStatus(raw.provisioning_status),\n displayName: raw.display_name ?? null,\n description: raw.description ?? null,\n failureCode: raw.failure_code ?? null,\n isReady: false,\n };\n}\n\nexport class CharacterRoster {\n private _characters: ConvaiCharacterInstance[] = [];\n private _activeMembershipId: string | null = null;\n private _roomSessionId: string | null = null;\n private _roomName: string | null = null;\n private _requestTraceId: string | null = null;\n private _routeEpoch = 0;\n private _rosterEpoch = 0;\n private _partialDispatch = false;\n\n /** Character instances in roster order. Empty in single-character mode. */\n get characters(): ConvaiCharacterInstance[] {\n return this._characters;\n }\n\n /** Membership currently receiving user turns, or null. */\n get activeMembershipId(): string | null {\n return this._activeMembershipId;\n }\n\n get roomSessionId(): string | null {\n return this._roomSessionId;\n }\n\n get routeEpoch(): number {\n return this._routeEpoch;\n }\n\n get rosterEpoch(): number {\n return this._rosterEpoch;\n }\n\n /** True once a multi-character connect response has been applied. */\n get isMultiCharacter(): boolean {\n return this._characters.length > 0;\n }\n\n /** The server-owned snapshot, or null for a single-character session. */\n get roomSession(): ConvaiRoomSession | null {\n if (!this._roomSessionId) return null;\n return {\n roomSessionId: this._roomSessionId,\n roomName: this._roomName,\n requestTraceId: this._requestTraceId,\n activeMembershipId: this._activeMembershipId,\n routeEpoch: this._routeEpoch,\n rosterEpoch: this._rosterEpoch,\n partialDispatch: this._partialDispatch,\n characters: this._characters,\n };\n }\n\n find(membershipId: string): ConvaiCharacterInstance | undefined {\n return this._characters.find((c) => c.membershipId === membershipId);\n }\n\n /** Members that have sent their own `bot-ready`. */\n get readyCharacters(): ConvaiCharacterInstance[] {\n return this._characters.filter((c) => c.isReady);\n }\n\n /**\n * Mark a member ready from its own `bot-ready`, filling in any detail the\n * connect response did not yet have.\n *\n * Returns whether anything changed, so the caller only pushes state when it\n * did. A single-character `bot-ready` also carries these fields, so nothing\n * is tracked until a roster is actually seeded.\n */\n applyBotReady(about: Record<string, unknown> | undefined): boolean {\n if (!about || !this.isMultiCharacter) return false;\n const incoming = toInstance(about as RawCharacter);\n if (!incoming) return false;\n\n const index = this._characters.findIndex(\n (c) => c.membershipId === incoming.membershipId,\n );\n if (index === -1) {\n // A member added at runtime announces itself this way.\n this._characters = [...this._characters, { ...incoming, isReady: true }];\n return true;\n }\n\n const existing = this._characters[index]!;\n const merged: ConvaiCharacterInstance = {\n ...existing,\n characterSessionId:\n incoming.characterSessionId ?? existing.characterSessionId,\n sessionId: incoming.sessionId ?? existing.sessionId,\n displayName: incoming.displayName ?? existing.displayName,\n participantIdentity:\n incoming.participantIdentity || existing.participantIdentity,\n isReady: true,\n };\n if (\n existing.isReady &&\n merged.characterSessionId === existing.characterSessionId &&\n merged.sessionId === existing.sessionId &&\n merged.participantIdentity === existing.participantIdentity\n ) {\n return false;\n }\n this._characters = [\n ...this._characters.slice(0, index),\n merged,\n ...this._characters.slice(index + 1),\n ];\n return true;\n }\n\n /**\n * Apply a `character-status` transition. Readiness recorded here is\n * bookkeeping for replacement selection; `bot-ready` remains what gates\n * interaction.\n */\n applyCharacterStatus(status: {\n membershipId: string;\n status: \"starting\" | \"ready\" | \"failed\";\n failureCode: string | null;\n }): boolean {\n const index = this._characters.findIndex(\n (c) => c.membershipId === status.membershipId,\n );\n if (index === -1) return false;\n const existing = this._characters[index]!;\n\n const next: ConvaiCharacterInstance = {\n ...existing,\n ...(status.status === \"failed\"\n ? {\n provisioningStatus: \"dispatch_failed\" as const,\n failureCode: status.failureCode ?? existing.failureCode,\n isReady: false,\n }\n : {}),\n };\n if (\n next.provisioningStatus === existing.provisioningStatus &&\n next.failureCode === existing.failureCode &&\n next.isReady === existing.isReady\n ) {\n return false;\n }\n this._characters = [\n ...this._characters.slice(0, index),\n next,\n ...this._characters.slice(index + 1),\n ];\n if (status.status === \"failed\") this._partialDispatch = true;\n return true;\n }\n\n /**\n * Pick a member to take over routing when the active one is removed.\n *\n * The runtime refuses a replacement that has not sent `bot-ready`\n * (`replacement_target_unavailable`), so ready survivors come first. Falling\n * back to any survivor at least produces a nameable rejection rather than\n * `replacement_target_required`.\n */\n pickReplacement(removing: string[]): string | undefined {\n const removed = new Set(removing);\n const survivors = this._characters.filter(\n (c) => !removed.has(c.membershipId),\n );\n return (\n survivors.find((c) => c.isReady)?.membershipId ?? survivors[0]?.membershipId\n );\n }\n\n /** Payload that switches the active member. */\n buildInteractionTarget(\n membershipId: string | null,\n commandId: string,\n ): Record<string, unknown> {\n return {\n room_session_id: this._roomSessionId,\n target_membership_id: membershipId,\n expected_route_epoch: this._routeEpoch,\n command_id: commandId,\n };\n }\n\n /** Payload that adds and removes members. */\n buildRosterUpdate(\n options: UpdateCharacterRosterOptions,\n commandId: string,\n ): Record<string, unknown> {\n const add = (options.add ?? []).map((spec: ConvaiCharacterSpec) => ({\n character_id: spec.characterId.trim(),\n ...(spec.characterSessionId\n ? { character_session_id: spec.characterSessionId }\n : {}),\n }));\n return {\n room_session_id: this._roomSessionId,\n expected_roster_epoch: this._rosterEpoch,\n command_id: commandId,\n ...(add.length > 0 && { add }),\n ...(options.remove?.length && { remove_membership_ids: options.remove }),\n ...(options.replacementTarget && {\n replacement_target_membership_id: options.replacementTarget,\n }),\n };\n }\n\n /**\n * Adopt the server's authoritative epochs and active member from any\n * response, errors included.\n *\n * A `stale_route_epoch` rejection carries the real epoch, and adopting it is\n * precisely what makes the single retry meaningful rather than a repeat of\n * the same losing bet.\n */\n adoptEpochs(extras: ServerResponseExtras | null | undefined): void {\n if (!extras) return;\n if (typeof extras.route_epoch === \"number\") this._routeEpoch = extras.route_epoch;\n if (typeof extras.roster_epoch === \"number\") this._rosterEpoch = extras.roster_epoch;\n if (extras.active_membership_id !== undefined) {\n this._activeMembershipId = (extras.active_membership_id as string) ?? null;\n }\n }\n\n /**\n * Apply a successful `interaction-target` ack. Returns the change even when\n * the target did not move, so a caller awaiting the promise always learns the\n * authoritative state.\n */\n applyInteractionTarget(\n extras: ServerResponseExtras | null | undefined,\n ): ActiveCharacterChange {\n const previous =\n extras?.previous_membership_id !== undefined\n ? (extras.previous_membership_id as string | null)\n : this._activeMembershipId;\n this.adoptEpochs(extras);\n return {\n previous: previous ?? null,\n active: this._activeMembershipId,\n routeEpoch: this._routeEpoch,\n };\n }\n\n /** Apply a successful `character-roster-update` ack, mutating the roster. */\n applyRosterUpdate(\n extras: ServerResponseExtras | null | undefined,\n ): CharacterRosterChange {\n const added = (Array.isArray(extras?.added) ? extras!.added : [])\n .map((c) => toInstance(c as RawCharacter))\n .filter((c): c is ConvaiCharacterInstance => c !== null);\n\n const removedIds = new Set<string>(\n Array.isArray(extras?.removed_membership_ids)\n ? (extras!.removed_membership_ids as string[])\n : [],\n );\n // Prefer the roster's own record of a removed member: the ack echoes only\n // its id, and the caller wants the instance it had been rendering.\n const removed = [...removedIds].map(\n (id) =>\n this.find(id) ?? {\n membershipId: id,\n characterId: \"\",\n sessionId: null,\n characterSessionId: null,\n participantIdentity: `character:${id}`,\n isInitial: false,\n provisioningStatus: \"dispatch_failed\" as const,\n displayName: null,\n description: null,\n failureCode: null,\n isReady: false,\n },\n );\n\n const addedIds = new Set(added.map((c) => c.membershipId));\n this._characters = [\n ...this._characters.filter(\n (c) => !removedIds.has(c.membershipId) && !addedIds.has(c.membershipId),\n ),\n ...added,\n ];\n this.adoptEpochs(extras);\n // A removal can retire the member that was active; the ack names the\n // survivor, and adoptEpochs has already taken it.\n if (\n this._activeMembershipId &&\n !this.find(this._activeMembershipId)\n ) {\n this._activeMembershipId = this._characters[0]?.membershipId ?? null;\n }\n return {\n added,\n removed,\n characters: this._characters,\n rosterEpoch: this._rosterEpoch,\n routeEpoch: this._routeEpoch,\n activeMembershipId: this._activeMembershipId,\n };\n }\n\n reset(): void {\n this._characters = [];\n this._activeMembershipId = null;\n this._roomSessionId = null;\n this._roomName = null;\n this._requestTraceId = null;\n this._routeEpoch = 0;\n this._rosterEpoch = 0;\n this._partialDispatch = false;\n }\n\n /**\n * Seed the roster from a `/connect` response, for either a create or a join.\n *\n * Returns false for a response with no `characters` array — that is an\n * ordinary single-character session, and the roster stays empty so\n * `isMultiCharacter` keeps distinguishing the two.\n */\n applyConnectResponse(data: Record<string, unknown>): boolean {\n const raw = data?.characters;\n if (!Array.isArray(raw) || raw.length === 0) return false;\n\n const characters = raw\n .map((c) => toInstance(c as RawCharacter))\n .filter((c): c is ConvaiCharacterInstance => c !== null);\n if (characters.length === 0) return false;\n\n this._characters = characters;\n this._roomSessionId = asString(data.room_session_id);\n this._roomName = asString(data.room_name);\n this._requestTraceId = asString(data.request_trace_id);\n // The server names the active member. Falling back to the initial entry,\n // then to position zero, matches the rule that `characters[0]` is initial.\n this._activeMembershipId =\n asString(data.active_membership_id) ??\n characters.find((c) => c.isInitial)?.membershipId ??\n characters[0]?.membershipId ??\n null;\n this._routeEpoch = asNumber(data.route_epoch) ?? 0;\n this._rosterEpoch = asNumber(data.roster_epoch) ?? 0;\n this._partialDispatch =\n data.partial_dispatch === true ||\n characters.some((c) => c.provisioningStatus === \"dispatch_failed\");\n return true;\n }\n}\n\nfunction asString(value: unknown): string | null {\n return typeof value === \"string\" && value ? value : null;\n}\n\nfunction asNumber(value: unknown): number | null {\n return typeof value === \"number\" && Number.isFinite(value) ? value : null;\n}\n"]}